diff --git a/.gitignore b/.gitignore index d553018c..e206d001 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ .vscode/ _build/ -make_doc.bat -_tabulous_history.txt +_docs_temp/ # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/Makefile b/Makefile index e63e79d0..9dfda83a 100644 --- a/Makefile +++ b/Makefile @@ -10,3 +10,9 @@ release: images: python ./image/generate_figs.py + +images-rst: + python ./rst/fig/generate_figs.py + +watch-rst: + watchfiles "sphinx-build -b html ./rst ./_docs_temp" rst diff --git a/image/generate_figs.py b/image/generate_figs.py index 7c3453a8..3bfdd9d5 100644 --- a/image/generate_figs.py +++ b/image/generate_figs.py @@ -1,5 +1,3 @@ -from typing import Callable -from functools import wraps from pathlib import Path import numpy as np from magicgui import magicgui @@ -7,27 +5,12 @@ from tabulous import TableViewer, commands as cmds -_ALL_FUNCTIONS = [] -_DIR_PATH = Path(__file__).parent +from tabulous_doc import FunctionRegistry +REG = FunctionRegistry(Path(__file__).parent) -def register(f: Callable[[], TableViewer]): - @wraps(f) - def wrapped(): - viewer = f() - viewer.save_screenshot(_DIR_PATH / f"{f.__name__}.png") - viewer.close() - _ALL_FUNCTIONS.append(wrapped) - return wrapped - - -def main(): - for f in _ALL_FUNCTIONS: - f() - - -@register +@REG.register def viewer_example(): viewer = TableViewer() sheet = viewer.open_sample("iris", type="spreadsheet") @@ -40,7 +23,7 @@ def viewer_example(): return viewer -@register +@REG.register def filter_example(): viewer = TableViewer() rng = np.random.default_rng(1234) @@ -52,7 +35,7 @@ def filter_example(): return viewer -@register +@REG.register def sort_example(): viewer = TableViewer() rng = np.random.default_rng(1234) @@ -67,21 +50,14 @@ def sort_example(): return viewer -@register +@REG.register def colormap_example(): viewer = TableViewer() sheet = viewer.open_sample("iris", type="spreadsheet") - sheet.background_colormap( - "species", + sheet["species"].background_color.set( {"setosa": "lightblue", "versicolor": "orange", "virginica": "violet"}, ) - - @sheet.foreground_colormap("petal_width") - def _cmap(v): - v = float(v) - r = (v - 0.1) / 2.4 * 255 - b = (1 - (v - 0.1) / 2.4) * 255 - return [r, 255, b, 255] + sheet["petal_width"].text_color.set(interp_from=["red", "blue"]) viewer.resize(100, 100) sheet.move_iloc(53, 4) @@ -89,7 +65,7 @@ def _cmap(v): return viewer -@register +@REG.register def eval_example(): viewer = TableViewer() sheet = viewer.add_spreadsheet(np.arange(100)) @@ -103,7 +79,7 @@ def eval_example(): return viewer -@register +@REG.register def command_palette_example(): viewer = TableViewer() viewer.add_spreadsheet() @@ -112,7 +88,7 @@ def command_palette_example(): return viewer -@register +@REG.register def custom_widget_example(): viewer = TableViewer() sheet = viewer.add_spreadsheet() @@ -140,4 +116,4 @@ def read_csv(save_path: Path): if __name__ == "__main__": - main() + REG.run_all() diff --git a/rst/_static/font.css b/rst/_static/font.css new file mode 100644 index 00000000..0ce4fd50 --- /dev/null +++ b/rst/_static/font.css @@ -0,0 +1,54 @@ +.gray {color: gray;} + +.silver {color: silver;} + +.white {color: white;} + +.red {color: red;} + +.magenta {color: magenta;} + +.pink {color: pink;} + +.orange {color: orange;} + +.yellow {color: yellow;} + +.lime {color: lime;} + +.green {color: green;} + +.teal {color: teal;} + +.cyan {color: cyan;} + +.aqua {color: aqua;} + +.blue {color: blue;} + +.navy {color: navy;} + +.purple {color: purple;} + +.kbd { + background-color: #f1f1f1; + border: 1px solid #ccc; + border-radius: 3px; + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.2), 0 0 0 2px #fff inset; + color: #444; + display: inline-block; + font-size: 0.85em; + font-weight: 700; + line-height: 1; + padding: 0.2em 0.4em; + white-space: nowrap; +} + +.menu { + background-color: #f1f1f1; + border: 1px solid #ccc; + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.2), 0 0 0 1px #fff inset; + font-size: 0.85em; + padding: 0.2em 0.4em; + white-space: nowrap; +} diff --git a/rst/apidoc/tabulous.widgets.rst b/rst/apidoc/tabulous.widgets.rst index 336fbb90..6b1336ec 100644 --- a/rst/apidoc/tabulous.widgets.rst +++ b/rst/apidoc/tabulous.widgets.rst @@ -1,17 +1,6 @@ tabulous.widgets package ======================== -Submodules ----------- - -tabulous.widgets.filtering module ---------------------------------- - -.. automodule:: tabulous.widgets.filtering - :members: - :undoc-members: - :show-inheritance: - Module contents --------------- diff --git a/rst/conf.py b/rst/conf.py index ae58afac..7b508933 100644 --- a/rst/conf.py +++ b/rst/conf.py @@ -54,6 +54,7 @@ # html_theme = 'sphinx_rtd_theme' html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] +html_css_files = ["font.css"] # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, diff --git a/rst/fig/cell_labels.png b/rst/fig/cell_labels.png new file mode 100644 index 00000000..1e9dbbfb Binary files /dev/null and b/rst/fig/cell_labels.png differ diff --git a/rst/fig/colormap.png b/rst/fig/colormap.png index 462aa424..19cf1698 100644 Binary files a/rst/fig/colormap.png and b/rst/fig/colormap.png differ diff --git a/rst/fig/colormap_interpolate.png b/rst/fig/colormap_interpolate.png new file mode 100644 index 00000000..9715031c Binary files /dev/null and b/rst/fig/colormap_interpolate.png differ diff --git a/rst/fig/column_filter.png b/rst/fig/column_filter.png new file mode 100644 index 00000000..a50f65b5 Binary files /dev/null and b/rst/fig/column_filter.png differ diff --git a/rst/fig/command_palette.png b/rst/fig/command_palette.png new file mode 100644 index 00000000..c1291ae7 Binary files /dev/null and b/rst/fig/command_palette.png differ diff --git a/rst/fig/dock_with_table_data_annotation.png b/rst/fig/dock_with_table_data_annotation.png new file mode 100644 index 00000000..3ce29dfc Binary files /dev/null and b/rst/fig/dock_with_table_data_annotation.png differ diff --git a/rst/fig/edit_cell.png b/rst/fig/edit_cell.png new file mode 100644 index 00000000..073b504e Binary files /dev/null and b/rst/fig/edit_cell.png differ diff --git a/rst/fig/filter.gif b/rst/fig/filter.gif deleted file mode 100644 index 434681f6..00000000 Binary files a/rst/fig/filter.gif and /dev/null differ diff --git a/rst/fig/formatter.png b/rst/fig/formatter.png new file mode 100644 index 00000000..0dc29f16 Binary files /dev/null and b/rst/fig/formatter.png differ diff --git a/rst/fig/generage_figures.py b/rst/fig/generage_figures.py deleted file mode 100644 index a507a5d7..00000000 --- a/rst/fig/generage_figures.py +++ /dev/null @@ -1,43 +0,0 @@ -import sys -import numpy as np -import tabulous as tbl - -def colormap(): - viewer = tbl.TableViewer() - table = viewer.open_sample("iris") - lmin, lmax = table.data["sepal_length"].min(), table.data["sepal_length"].max() - lrange = lmax - lmin - @table.foreground_colormap("sepal_length") - def _(x: float): - red = np.array([255, 0, 0, 255], dtype=np.uint8) - blue = np.array([0, 0, 255, 255], dtype=np.uint8) - return (x - lmin) / lrange * blue + (lmax - x) / lrange * red - - @table.background_colormap("sepal_width") - def _(x: float): - return "green" if x < 3.2 else "violet" - viewer.show() - -def iris(): - viewer = tbl.TableViewer() - viewer.open_sample("iris") - viewer.show() - -def table(): - df = {"label": ["A", "B", "C"], - "value": [1.2, 2.4, 3.6], - "valid": [True, False, True]} - viewer = tbl.TableViewer() - viewer.add_table(df, editable=True) - viewer.show() - -if __name__ == "__main__": - arg = sys.argv[1] - if arg == "colormap": - colormap() - elif arg == "iris": - iris() - elif arg == "table": - table() - else: - raise ValueError(f"Unknown argument: {arg}") diff --git a/rst/fig/generate_figs.py b/rst/fig/generate_figs.py new file mode 100644 index 00000000..99e60b6c --- /dev/null +++ b/rst/fig/generate_figs.py @@ -0,0 +1,163 @@ +from pathlib import Path +import numpy as np + +from tabulous import TableViewer, commands as cmds +from tabulous_doc import FunctionRegistry + +REG = FunctionRegistry(Path(__file__).parent) + +@REG.register +def table(): + viewer = TableViewer() + table = viewer.add_table({"A": [1, 2, 3], "B": [4, 5, 6]}, name="table name") + viewer.native.setMinimumSize(1, 1) + viewer.resize(120, 180) + return table + +@REG.register +def spreadsheet(): + viewer = TableViewer() + sheet = viewer.add_spreadsheet([["2", "t"], ["3", "u"]]) + viewer.native.setMinimumSize(1, 1) + viewer.resize(120, 180) + return sheet + +@REG.register +def edit_cell(): + viewer = TableViewer() + sheet = viewer.add_spreadsheet({"A": [1, 2, 3, 4], "B": ["a", "b", "c", "d"]}) + viewer.resize(100, 100) + sheet.move_iloc(2, 2) + cmds.selection.edit_current(viewer) + return viewer + +@REG.register +def colormap(): + viewer = TableViewer() + table = viewer.open_sample("iris") + + # set a continuous colormap to the "sepal_length" column + lmin = table.data["sepal_length"].min() + lmax = table.data["sepal_length"].max() + lrange = lmax - lmin + + @table.text_color.set("sepal_length") + def _(x: float): + red = np.array([255, 0, 0, 255], dtype=np.uint8) + blue = np.array([0, 0, 255, 255], dtype=np.uint8) + return (x - lmin) / lrange * blue + (lmax - x) / lrange * red + + # set a discrete colormap to the "sepal_width" column + @table.background_color.set("sepal_width") + def _(x: float): + return "green" if x < 3.2 else "violet" + + viewer.resize(100, 100) + table.move_iloc(57, 2) + return viewer + +@REG.register +def colormap_interpolate(): + viewer = TableViewer() + table = viewer.add_table({"value": [-3, -2, -1, 0, 1, 2, 3]}) + table.text_color.set("value", interp_from=["blue", "gray", "red"]) + viewer.resize(100, 100) + return viewer + +@REG.register +def formatter(): + viewer = TableViewer() + table = viewer.add_table({"length": [2.1, 3.2, 1.8, 6.3]}) + + @table.formatter.set("length") + def _(x: float): + return f"{x:.2f} cm" + viewer.resize(100, 100) + return viewer + +@REG.register +def validator(): + viewer = TableViewer() + table = viewer.add_table( + {"sample": [1, 2, 3], "volume": [0., 0., 0.]}, + editable=True, + ) + + @table.validator.set("volume") + def _(x: float): + if x < 0: + raise ValueError("Volume must be positive.") + + viewer.resize(100, 100) + table.move_iloc(1, 1) + cmds.selection.edit_current(viewer) + table.native._qtable_view._create_eval_editor("-1") + return viewer + +@REG.register +def cell_labels(): + viewer = TableViewer() + sheet = viewer.add_spreadsheet(np.arange(4)) + sheet.cell[1, 1] = "&=np.mean(df.iloc[:, 0])" + sheet.cell.label[1, 1] = "mean: " + viewer.native.setMinimumSize(1, 1) + viewer.resize(120, 180) + return sheet + +@REG.register +def tile_tables(): + viewer = TableViewer() + sheet0 = viewer.add_spreadsheet(name="A") + sheet0.cell[0:5, 0:5] = "A" + sheet1 = viewer.add_spreadsheet(name="B") + sheet1.cell[0:5, 0:5] = "B" + viewer.tables.tile([0, 1]) + viewer.resize(100, 100) + return viewer + +@REG.register +def dock_with_table_data_annotation(): + from tabulous.types import TableData + from magicgui import magicgui + + viewer = TableViewer() + rng = np.random.default_rng(0) + viewer.add_table( + {"value_0": rng.normal(size=20), "value_1": rng.random(20)} + ) + @magicgui + def f(table: TableData, mean: bool, std: bool, max: bool, min: bool) -> TableData: + funcs = [] + for checked, f in [(mean, np.mean), (std, np.std), (max, np.max), (min, np.min)]: + if checked: + funcs.append(f) + return table.apply(funcs) + + viewer.add_dock_widget(f) + viewer.resize(120, 180) + return viewer + +@REG.register +def column_filter(): + viewer = TableViewer() + rng = np.random.default_rng(0) + sheet = viewer.add_spreadsheet( + {"A": rng.integers(0, 10, size=20), + "B": rng.random(20).round(2)} + ) + viewer.resize(120, 180) + sheet.proxy.filter("A > 5") + return viewer + +@REG.register +def command_palette(): + viewer = TableViewer() + viewer.add_spreadsheet() + viewer.resize(100, 100) + cmds.window.show_command_palette(viewer) + cmdp = viewer.native._command_palette.get_widget(viewer.native) + cmdp._line.setText("cop") + return viewer + +if __name__ == "__main__": + REG.run_all() diff --git a/rst/fig/spreadsheet.png b/rst/fig/spreadsheet.png new file mode 100644 index 00000000..6b3681fb Binary files /dev/null and b/rst/fig/spreadsheet.png differ diff --git a/rst/fig/table.png b/rst/fig/table.png new file mode 100644 index 00000000..23a5307d Binary files /dev/null and b/rst/fig/table.png differ diff --git a/rst/fig/table_interface_0.gif b/rst/fig/table_interface_0.gif deleted file mode 100644 index 209ea723..00000000 Binary files a/rst/fig/table_interface_0.gif and /dev/null differ diff --git a/rst/fig/table_interface_1.gif b/rst/fig/table_interface_1.gif deleted file mode 100644 index 08931818..00000000 Binary files a/rst/fig/table_interface_1.gif and /dev/null differ diff --git a/rst/fig/tile_tables.png b/rst/fig/tile_tables.png new file mode 100644 index 00000000..790967e6 Binary files /dev/null and b/rst/fig/tile_tables.png differ diff --git a/rst/fig/validator.png b/rst/fig/validator.png new file mode 100644 index 00000000..99365ebf Binary files /dev/null and b/rst/fig/validator.png differ diff --git a/rst/font.rst b/rst/font.rst new file mode 100644 index 00000000..ae07863d --- /dev/null +++ b/rst/font.rst @@ -0,0 +1,17 @@ +.. role:: black +.. role:: gray +.. role:: white +.. role:: red +.. role:: magenta +.. role:: pink +.. role:: orange +.. role:: yellow +.. role:: lime +.. role:: green +.. role:: teal +.. role:: cyan +.. role:: aqua +.. role:: blue +.. role:: purple +.. role:: kbd +.. role:: menu diff --git a/rst/index.rst b/rst/index.rst index 571391ee..10d0e438 100644 --- a/rst/index.rst +++ b/rst/index.rst @@ -20,21 +20,21 @@ Tables ------ .. toctree:: - :maxdepth: 2 + :maxdepth: 1 ./main/selections ./main/sort_filter ./main/columnwise_settings - ./main/table_advanced - + ./main/table_fields + ./main/table_view_mode Cooperate with Other Widgets ---------------------------- .. toctree:: - :maxdepth: 2 + :maxdepth: 1 - ./main/dock_widget + ./main/integrate_custom_widgets ./main/non_mainwindow diff --git a/rst/main/columnwise_settings.rst b/rst/main/columnwise_settings.rst index b75e0eea..93dd818d 100644 --- a/rst/main/columnwise_settings.rst +++ b/rst/main/columnwise_settings.rst @@ -2,6 +2,8 @@ Column-wise Settings ==================== +.. include:: ../font.rst + Tables are composed of several columns with different data types. There are some settings that can be applied to each column individually, for better visualizing the data and safely editing the data. @@ -10,23 +12,32 @@ editing the data. :local: :depth: 2 +Each column-specific setting is stored as a ``dict`` like field. + Colormap ======== +A "colormap" is a function that maps a value to a color. All the colormaps are stored in +:attr:`text_color` / :attr:`background_color` fields. + Use Colormap Functions ---------------------- -The foreground color (text color) and the background color can be set for each column. -You have to provide a colormap (function that maps values to colors) to do this. A colormap -must return a RGBA array (0-255) or a standard color name. +The text color and the background color can be set for each column. +You can provide a custom colormap function to do this by :meth:`set` method of fields. +A colormap function must return a RGBA array (0-255) or a standard color name. .. code-block:: python viewer = TableViewer() - viewer.open_sample("iris") + table = viewer.open_sample("iris") + + lmin = table.data["sepal_length"].min() + lmax = table.data["sepal_length"].max() + lrange = lmax - lmin # set a continuous colormap to the "sepal_length" column - @table.foreground_colormap("sepal_length") + @table.text_color.set("sepal_length") def _(x: float): red = np.array([255, 0, 0, 255], dtype=np.uint8) blue = np.array([0, 0, 255, 255], dtype=np.uint8) @@ -35,12 +46,20 @@ must return a RGBA array (0-255) or a standard color name. .. code-block:: python # set a discrete colormap to the "sepal_width" column - @table.background_colormap("sepal_width") + @table.background_color.set("sepal_width") def _(x: float): return "green" if x < 3.2 else "violet" .. image:: ../fig/colormap.png +Since the fields are ``dict`` like, you can refer to existing colormaps or set new ones. + +.. code-block:: python + + print(table.text_color["sepal_length"]) # get the colormap function + table.text_color["sepal_length"] = new_cmap # set a new colormap + del table.text_color["sepal_length"] # reset the existing colormap + Use Dictionaries ---------------- @@ -53,13 +72,100 @@ For categorical data, you can also use dictionaries to set the colors. "versicolor": "green", "virginica": "blue", } - table.foreground_colormap("species", cmap) # set cmap + # set discrete colormap + table.text_color.set("species", cmap) + # or like this + table.text_color["species"] = cmap + +Use :mod:`matplotlib` Colormaps +------------------------------- + +The colormap names defined in :mod:`matplotlib` are available. Limits of the contrast +will be defined by the mininum/maximum values of the column. + +.. code-block:: python + + table.text_color["sepal_length"] = "inferno" + +.. note:: + + Since colormaps are defined continuously, data type of the column must be numbers, + datetime or timedelta. + +Interpolate Colors to Define Colormaps +-------------------------------------- + +In many cases, you'll want to define your own colormap by supplying colors that +represent the minimum/maximum values, or several colors with their corresponding +values. + +The ``interp_from`` argument is useful for this purpose. A linearly segmented +colormap will be defined . + +.. code-block:: python + + viewer = TableViewer() + table = viewer.add_table({"value": [-3, -2, -1, 0, 1, 2, 3]}) + + # use value -> color mapping + table.text_color.set("value", interp_from={-3: "blue", 0: "gray", 3: "red"}) + + # or a list of (value, color) + table.text_color.set("value", interp_from=[(-3, "blue"), (0, "gray"), (3, "red")]) + +.. image:: ../fig/colormap_interpolate.png + +.. note:: + + You can just pass a list of colors to define a equally divided colormap. + + .. code-block:: python + + table.text_color.set("value", interp_from=["blue", "gray", "red"]) + + The simplest argument will be two colors, which represent minimum/maximum. + + .. code-block:: python + + table.text_color.set("value", interp_from=["blue", "red"]) Set Colormaps in GUI -------------------- Some basic colormaps are available in the right-click context menu of the columns, -such as ``Color > Set foreground colormap``. +such as :menu:`Color > Set background colormap`. + +Update Colormaps +---------------- + +There are several ways to update the existing colormap. + +1. Update opacity + + :meth:`set_opacity` method takes a value between 0 and 1 to changes the alpha + channel of the output color. + + .. code-block:: python + + table.text_color.set_opacity("A", 0.5) + +2. Invert colormap + + :meth:`invert` method invert the returned RGB value of the colormap. + + .. code-block:: python + + table.text_color.invert("A") + +3. Adjust brightness of colormap + + :meth:`adjust_brightness` takes a value between -1 and 1 to change the brightness + of the current colormap. + + .. code-block:: python + + table.text_color.adjust_brightness("A", 0.5) # brighter + table.text_color.adjust_brightness("A", -0.5) # darker Validator ========= @@ -71,18 +177,24 @@ Set validator Functions ----------------------- A validator function doesn't care about the returned value. It should raise an exception -if the input value is invalid. +if the input value is invalid. In following example, negative value is interpreted as +invalid and the editor becomes red. .. code-block:: python viewer = TableViewer() - viewer.add_table({"sample": [1, 2, 3], "volume": [0., 0., 0.]}, editable=True) + table = viewer.add_table( + {"sample": [1, 2, 3], "volume": [0., 0., 0.]}, + editable=True, + ) - @table.validator("volume") + @table.validator.set("volume") def _(x: float): if x < 0: raise ValueError("Volume must be positive.") +.. image:: ../fig/validator.png + .. note:: A :class:`Table` object converts the input value to the data type of the column. @@ -108,18 +220,23 @@ Set formatter function ---------------------- As usual in this chapter, you can use functions that convert a value into a string -as formatter function. The formatted strings are not necessary to satisfy the -column specific validation including data type conversion. +as formatter function. + +- The formatted strings do not affect the real data. +- The formatted strings are not necessary to satisfy the column specific validation + including data type conversion. .. code-block:: python viewer = TableViewer() - table = viewer.open_sample("iris") + table = viewer.add_table({"length": [2.1, 3.2, 1.8, 6.3]}) - @table.formatter("sepal_length") + @table.formatter.set("length") def _(x: float): return f"{x:.2f} cm" +.. image:: ../fig/formatter.png + Set formatter string -------------------- @@ -127,7 +244,10 @@ Instead of passing a function, you can also use a ready-to-be-formatted strings. .. code-block:: python - table.formatter("sepal_length", "{:.2f} cm") + table.formatter.set("sepal_length", "{:.2f} cm") + + # or use __setitem__ + table.formatter["sepal_length"] = "{:.2f} cm" Example above is identical to passing ``"{:.2f} cm".format``. @@ -135,7 +255,7 @@ Set Formatter in GUI -------------------- Some basic formatters are available in the right-click context menu of the columns, -such as ``Formatter > Set text formatter``. You'll see a preview of the column in +such as :menu:`Formatter > Set text formatter`. You'll see a preview of the column in the dialog. Typing Spreadsheet @@ -146,8 +266,8 @@ because a spreadsheet is a string-based table data in general. This characterist raises a problem of data type. This drawback is especially important when you want to use data types such as ``datetime64`` or ``category``. -To solve this problem, ``SpreadSheet`` implements a typing system on each column. -You can tag any data types supported by ``pandas`` to each column, and optionally +To solve this problem, :class:`SpreadSheet` implements a typing system on each column. +You can tag any data types supported by :class:`pandas` to each column, and optionally set validator functions appropriate for the data types. .. code-block:: python @@ -163,8 +283,8 @@ set validator functions appropriate for the data types. sheet.dtypes.update(int="int64", label="category") # set dtypes and default validators - sheet.dtypes.set_dtype("int", "int64") - sheet.dtypes.set_dtype("label", "category") + sheet.dtypes.set("int", "int64") + sheet.dtypes.set("label", "category") .. code-block:: python @@ -177,4 +297,35 @@ set validator functions appropriate for the data types. dtype: object You can also set dtypes from GUI. Right-click the column header and select -``Column dtype``. +:menu:`Column dtype`. + +Use Syntax of Table Subset +========================== + +A :class:`pd.Series`-like table subset, :class:`TableSeries` can be obtained by +indexing a table. + +.. code-block:: python + + sheet = viewer.open_sample("iris") + sheet["sepal_length"] + +.. code-block:: + + of SpreadSheet<'sheet'>> + +:class:`TableSeries` also as fields such as :attr:`text_color` and :attr:`formatter` +and they can be considered as partial table fields. + +.. code-block:: python + + print(sheet["sepal_length"].formatter) # get formatter function + sheet["sepal_length"].formatter = f"{:2f} cm" # set formatter + del sheet["sepal_length"].formatter # reset formatter + +Colormap can also be updated this way. + +.. code-block:: python + + sheet["sepal_length"].background_color.set(interp_from=["violet", "teal"]) + sheet["sepal_length"].background_color.set_opacity(0.5) diff --git a/rst/main/dock_widget.rst b/rst/main/dock_widget.rst deleted file mode 100644 index e260dee8..00000000 --- a/rst/main/dock_widget.rst +++ /dev/null @@ -1,61 +0,0 @@ -================== -Custom Dock widget -================== - -.. contents:: Contents - :local: - :depth: 2 - -Add Dock Widget -=============== - -.. code-block:: python - - from qtpy.QtWidgets import QWidget - - class MyWidget(QWidget): - def __init__(self, parent=None): - super().__init__(parent) - self.setObjectName("MyWidget") - - widget = MyWidget() - viewer.add_dock_widget(widget) - - -Use Magicgui Widget -=================== - -Basics ------- - -.. code-block:: python - - from magicgui import magicgui - - @magicgui - def f(tip: str): - viewer.status = tip - - viewer.add_dock_widget(f) - -Tabulous Types --------------- - -.. note:: - - In ``napari``, you can use such as ``ImageData`` as an alias for ``np.ndarray`` type, - while inform ``@magicgui`` that the data you want is the array stored in an ``Image`` - layer, or returned array should be added as a new ``Image`` layer. ``tabulous`` uses - the same strategy to recover a ``pd.DataFrame`` from the table list or send a new one - to the viewer. - - -.. code-block:: python - - from tabulous.types import TableData - - @magicgui - def f(table: TableData) -> TableData: - return table.apply([np.mean, np.std]) - - viewer.add_dock_widget(f) diff --git a/rst/main/integrate_custom_widgets.rst b/rst/main/integrate_custom_widgets.rst new file mode 100644 index 00000000..a65de49e --- /dev/null +++ b/rst/main/integrate_custom_widgets.rst @@ -0,0 +1,134 @@ +======================== +Integrate Custom Widgets +======================== + +.. contents:: Contents + :local: + :depth: 1 + +.. include:: ../font.rst + +There are several places to integrate your custom widgets to :mod:`tabulous` viewer. + +Dock Widget Area +================ + +Dock widget areas are located outside the central table stack area. Widgets docked in +this area are always visible in the same place no matter which table is activated. + +Add Qt Widgets +-------------- + +.. code-block:: python + + from qtpy.QtWidgets import QWidget + + class MyWidget(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("MyWidget") + + widget = MyWidget() + viewer.add_dock_widget(widget) + + +Use Magicgui Widget +------------------- + +Basic usage +^^^^^^^^^^^ + +.. code-block:: python + + from magicgui import magicgui + + @magicgui + def f(tip: str): + viewer.status = tip + + viewer.add_dock_widget(f) + +:mod:`tabulous` type annotations +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. note:: + + In :mod:`napari``, you can use such as :mod:`ImageData` as an alias for :class:`np.ndarray` type, + while inform ``@magicgui`` that the data you want is the array stored in an :class:`Image` + layer, or returned array should be added as a new :class:`Image` layer. :mod:`tabulous` uses + the same strategy to recover a :class:`pd.DataFrame` from the table list or send a new one + to the viewer. + +:class:`TableData` type is an alias of :class:`pd.DataFrame`. Arguments annotated by this +type will be interpreted as a combobox of table data by :mod:`magicgui`. + +.. code-block:: python + + from tabulous.types import TableData + + @magicgui + def f(table: TableData, mean: bool, std: bool, max: bool, min: bool) -> TableData: + funcs = [] + for checked, f in [(mean, np.mean), (std, np.std), (max, np.max), (min, np.min)]: + if checked: + funcs.append(f) + return table.apply(funcs) + + viewer.add_dock_widget(f) + +.. image:: ../fig/dock_with_table_data_annotation.png + +Table Side Area +=============== + +Every table has a side area that can be used to add table-specific widgets or show +table-specific information. + +Custom Qt widgets or :mod:`magicgui` widgets can be added to the side area using +:meth:`add_side_widget` method. + +.. code-block:: python + + table = viewer.tables[0] + table.add_side_widget(widget) + # if you want to give a name to the widget + table.add_side_widget(widget, name="widget name") + + # example + from magicgui import magicgui + + @magicgui + def func(): + print(table.data.mean()) + + table.add_side_widget(func) + +Built-in Widgets +---------------- + +There are built-in widgets that uses the table side area by default. + +1. Undo stack widget + + Undo/redo is implemented for each table. You can see the registered operations in a list + view in the side area. You can open it by pressing :kbd:`Ctrl+H`. + +2. Plot canvas + + Interactive :mod:`matplotlib` canvas is available in the "Plot" tool or the :attr:`plt` + field of table widgets. + +Table Overlay Widget +==================== + +Instead of the side area, you can also add widgets as an overlay over the table. An +overlay widget is similar to the overlay charts in Excel. + +.. code-block:: python + + table = viewer.tables[0] + table.add_overlay_widget(widget) + # if you want to give a label to the widget + table.add_overlay_widget(widget, label="my widget") + # you can give the top-left coordinate of the widget + table.add_overlay_widget(widget, topleft=(5, 5)) diff --git a/rst/main/non_mainwindow.rst b/rst/main/non_mainwindow.rst index 62029f1a..59066234 100644 --- a/rst/main/non_mainwindow.rst +++ b/rst/main/non_mainwindow.rst @@ -2,7 +2,7 @@ Use Non-main Window Table Viewers ================================= -Aiming at better extensibility, ``tabulous`` is designed to allow many different types of +Aiming at better extensibility, :mod:`tabulous` is designed to allow many different types of integration to external packages. .. contents:: Contents @@ -12,8 +12,8 @@ integration to external packages. Use TableViewer in Your Qt Widget ================================= -If you plan to use a table viewer as a child of another ``QWidget``, you can use a non-main -window version of it. The ``native`` property returns the Qt backend widget. +If you plan to use a table viewer as a child of another :class:`QWidget`, you can use a non-main +window version of it. The :attr:`native` property returns the Qt backend widget. .. code-block:: python @@ -28,23 +28,24 @@ window version of it. The ``native`` property returns the Qt backend widget. .. note:: - A benefit of using ``tabulous`` is that a table widget usually takes too much space but this - problem can be solve by popup view of tables in ``tabulous``. See :doc:`table_advanced` for + A benefit of using :mod:`tabulous` is that a table widget usually takes too much space but this + problem can be solve by popup view of tables in :mod:`tabulous`. See :doc:`table_view_mode` for more detail. .. note:: To avoid conflicting with the main widget, the non-main-window version of table viewer has - some restriction. For instance, embedded console does not open with shortcut ``Ctrl+Shift+C`` + some restriction. For instance, embedded console does not open with shortcut :kbd:`Ctrl+Shift+C` so you have to programmatically open it by ``viewer.console.visible = True``. Use TableViewer with magicgui ============================= -If you want to use a `magicgui `_ version of it, you can -use ``MagicTableViewer``. ``MagicTableViewer`` is a subclass of ``TableViewerWidget`` and -``magicgui.widgets.Widget`` so it is compatible with all the ``magicgui`` functionalities. +If you want to use a `magicgui `_ version of it, +you can use :class:`MagicTableViewer`. :class:`MagicTableViewer`` is a subclass of +:class:`TableViewerWidget` and :class:`magicgui.widgets.Widget` so it is compatible with +all the :mod:`magicgui` functionalities. In following simple example you can load a table data from a file. @@ -63,7 +64,7 @@ In following simple example you can load a table data from a file. container.show() -``MagicTableViewer`` can also easily be used with `magic-class `_. +:class:`MagicTableViewer` can also easily be used with `magic-class `_. Following example does similar thing as the one above. .. code-block:: python diff --git a/rst/main/quickstart.rst b/rst/main/quickstart.rst index 2ad2ecd2..f09d3889 100644 --- a/rst/main/quickstart.rst +++ b/rst/main/quickstart.rst @@ -6,10 +6,12 @@ Quickstart :local: :depth: 1 +.. include:: ../font.rst + Open A Table Viewer =================== -The main window of ``tabulous`` is a ``TableViewer`` instance. +The main window of :mod:`tabulous` is a :class:`TableViewer` instance. .. code-block:: python @@ -29,7 +31,7 @@ You can also read table data from files to create a viewer. # Read a Excel file and add all the sheets to the viewer. viewer = tbl.read_excel("path/to/data.xlsx") -If virtual environment (such as ``conda``) is used, you can use ``tabulous`` command to launch +If virtual environment (such as ``conda``) is used, you can use :mod:`tabulous` command to launch a viewer. .. code-block:: bash @@ -41,7 +43,7 @@ a viewer. Open an Interpreter =================== -``tabulous`` viewer has an embedded Python interpreter console. It is not visible by default +:mod:`tabulous` viewer has an embedded Python interpreter console. It is not visible by default but you can show it in several ways. .. |toggle_console| image:: ../../tabulous/_qt/_icons/toggle_console.svg @@ -49,24 +51,22 @@ but you can show it in several ways. 1. Set :attr:`visible` property of :attr:`console` interface to ``True``: ``>>> viewer.conosole.visible = True`` -2. Activate keyboard shortcut ``Ctrl+Shift+C``. -3. Click the |toggle_console| tool button in the "Analysis" tab of the toolbar. -4. Click the tool button of (3.) using key combo ``Alt, A, 4`` +2. Activate keyboard shortcut :kbd:`Ctrl+Shift+C`. +3. Click the |toggle_console| tool button in the the toolbar. In ``tabulous`` viewer there are additional keybindings. -- ``Ctrl + Shift + ↑``: Set console floating. -- ``Ctrl + Shift + ↓``: Dock console. -- ``Ctrl + I``: Insert a data reference object (See :doc:`/main/user_interface`). +- :kbd:`Ctrl+Shift+↑`: Set console floating. +- :kbd:`Ctrl+Shift+↓`: Dock console. -Handle Tables -============= +Use Tables +========== -Basically, table data is handled based on ``pandas``. +In :mod:`tabulous`, table data is handled based on :mod:`pandas`. A :class:`TableViewer` instance has several methods that add :class:`DataFrame` to the viewer. -1. :meth:`add_table` ... add a table data as a ``Table`` object. -2. :meth:`add_spreadsheet` ... add a table data as a ``SpreadSheet`` object. +1. :meth:`add_table` ... add a table data as a :class:`Table` object. +2. :meth:`add_spreadsheet` ... add a table data as a :class:`SpreadSheet` object. Table ----- @@ -80,7 +80,7 @@ A :class:`Table` is the most simple interface with :class:`DataFrame`. rejected. A :class:`DataFrame` (or other objects that can be converted into a :class:`DataFrame`) can be added to -the viewer using ``add_table`` method. +the viewer using :meth:`add_table` method. .. code-block:: python @@ -95,6 +95,8 @@ the viewer using ``add_table`` method. Table<'table name'> +.. image:: ../fig/table.png + .. note:: The newly added table is stored in :attr:`tables` property of the viewer in a :class:`list` like @@ -120,16 +122,16 @@ You have to pass ``editable=True`` or set the :attr:`editable` property to make # or set the property table.editable = True -Table data is available in ``data`` property. You can also update the table data by directly -setting the ``data`` property. +Table data is available in :attr:`data` property. You can also update the table data by directly +setting the :attr:`data` property. .. code-block:: python df = table.data # get the table data as a DataFrame table.data = df2 # set a new table data -The selected range of data is available in ``selections`` property. You can also -programmatically set table selections via ``selections`` property. Since table selections are +The selected range of data is available in :attr:`selections` property. You can also +programmatically set table selections via :attr:`selections` property. Since table selections are multi-selection, this property takes a ``list`` of slicable objects. .. code-block:: python @@ -146,22 +148,16 @@ See :doc:`selections` for more details. SpreadSheet ----------- -A ``SpreadSheet`` behaves more like Excel or Google Spreadsheet. +A :class:`SpreadSheet` behaves more like Excel or Google Spreadsheet. - It stores a copy of an input :class:`DataFrame` as "string" types. - It is editable by default and the input value will not be checked. - Shape of table is unlimited (as far as it is not too large). -- The data type is inferred by ``pd.read_csv`` when it is obtained by ``data`` property. +- The data type is inferred by :meth:`pd.read_csv` when it is obtained by :attr:`data` property. For instance, if you manually edited the cells -+---+---+---+ -| | A | B | -+---+---+---+ -| 0 | 2 | t | -+---+---+---+ -| 1 | 3 | u | -+---+---+---+ +.. image:: ../fig/spreadsheet.png then you'll get following :class:`DataFrame`. @@ -175,7 +171,9 @@ then you'll get following :class:`DataFrame`. A int64 B object -A spreadsheet can be added to the viewer by ``add_spreadsheet`` method. +Rows and columns can be inserted or removed in the right-click contextmenu. + +A spreadsheet can be added to the viewer by :meth:`add_spreadsheet` method. .. code-block:: python @@ -190,7 +188,7 @@ A spreadsheet can be added to the viewer by ``add_spreadsheet`` method. SpreadSheet<'sheet'> -Since a ``SpreadSheet`` is easily editable, it is reasonable to add an empty spreadsheet to +Since a :class:`SpreadSheet` is easily editable, it is reasonable to add an empty spreadsheet to the viewer. .. code-block:: python @@ -200,12 +198,13 @@ the viewer. For more details ... -------------------- -See :doc:`/main/table_advanced`. +- :doc:`/main/table_fields` +- :doc:`/main/table_view_mode` Table List ========== -All the table data is available in ``tables`` property. It is a ``list`` like +All the table data is available in :attr:`tables` property. It is a ``list`` like object with some extended methods. .. code-block:: python @@ -217,16 +216,16 @@ object with some extended methods. viewer.tables.move(0, 2) # move the 0-th table to the 2-th position You can also get currently acitive (visible) table or its index with -``viewer.current_table`` or ``viewer.current_index``. +:attr:`viewer.current_table` or :attr:`viewer.current_index`. Key combo ========= -``tabulous`` supports many keyboard shortcuts including key combo. +:mod:`tabulous` supports many keyboard shortcuts including key combo. All the global key map is listed in a widget that will be shown when you press -``Ctrl+K, Shift+?`` key combo. +:kbd:`Ctrl+K ⇒ Shift+?` key combo. :attr:`keymap` is the key map registry object of table viewers. You can use :meth:`bind_key` to register custom key combo. @@ -253,5 +252,7 @@ Command palette .. versionadded:: 0.4.0 -``Ctrl+Shift+P`` or ``F1`` opens a command palette widget. You can search for a variety of +:kbd:`Ctrl+Shift+P` or :kbd:`F1` opens a command palette widget. You can search for a variety of registered commands. + +.. image:: ../fig/command_palette.png diff --git a/rst/main/register_action.rst b/rst/main/register_action.rst index 307e1de2..98553ead 100644 --- a/rst/main/register_action.rst +++ b/rst/main/register_action.rst @@ -13,15 +13,15 @@ to the context menu. Suppose you have a viewer and a table: you can register functions using following methods. -- ``viewer.tables.register_action`` ... register action to the tab bar. -- ``table.index.register_action`` ... register action to the vertical header. -- ``table.columns.register_action`` ... register action to the horizontal header. -- ``table.cells.register_action`` ... register action to the table cells. +- :meth:`viewer.tables.register_action` ... register action to the tab bar. +- :meth:`table.index.register_action` ... register action to the vertical header. +- :meth:`table.columns.register_action` ... register action to the horizontal header. +- :meth:`table.cells.register_action` ... register action to the table cells. Register actions to the tab bar =============================== -Action for ``viewer.tables.register_action`` must have signature ``(index: int)``. +Action for :meth:`viewer.tables.register_action` must have signature ``(index: int)``. ``index`` is the index of the right-clicked tab. .. code-block:: python @@ -42,7 +42,7 @@ If you want to register it at a submenu, use ``">"`` as the separator. Register actions to the headers =============================== -Other ``register_action`` method works similarly. In the case of headers, +Other :meth:`register_action` method works similarly. In the case of headers, the signature for the action is also ``(index: int)``. Here, ``index`` is the index of the right-clicked position (ready for :meth:`iloc`). @@ -59,7 +59,7 @@ the index of the right-clicked position (ready for :meth:`iloc`). Register actions to the cells ============================= -The ``register_action`` method for cells also work in a similar way, but has +The :meth:`register_action` method for cells also work in a similar way, but has signature ``(index: tuple[int, int])`` unlike others. Here, ``index`` is a tuple of row index and column index (ready for :meth:`iloc`). diff --git a/rst/main/selections.rst b/rst/main/selections.rst index 9d68cb54..56acae7f 100644 --- a/rst/main/selections.rst +++ b/rst/main/selections.rst @@ -81,12 +81,13 @@ or directly set a list to :attr:`selections`. table.selections = [(slice(0, 10, None), slice(0, 1, None))] -Catch Change in Table Selections -================================ +Catch Changes in Table Selections +================================= You can bind a callback function that will get called on every selection change event. -The :attr:`events` attribute is a :class:`SignalGroup` object of `psygnal `_. -``table.events.selections.connect`` registers a callback function to table events. +The :attr:`events` attribute is a :class:`SignalGroup` object of +`psygnal `_. +:meth:`table.events.selections.connect` registers a callback function to table events. .. code-block:: python diff --git a/rst/main/sort_filter.rst b/rst/main/sort_filter.rst index 71c3d35e..be881ceb 100644 --- a/rst/main/sort_filter.rst +++ b/rst/main/sort_filter.rst @@ -10,9 +10,14 @@ new indices. :local: :depth: 1 +.. include:: ../font.rst + Filtering ========= +.. |filter| image:: ../../tabulous/_qt/_icons/filter.svg + :width: 20em + Use filter functions -------------------- @@ -35,27 +40,32 @@ This example is essentially equivalent to slicing a :class:`DataFrame` by ``df[d If the table is -+---+---+-------+ -| | A | label | -+---+---+-------+ -| 0 | 2 | A | -+---+---+-------+ -| 1 | 3 | B | -+---+---+-------+ -| 2 | 6 | B | -+---+---+-------+ -| 3 | 4 | A | -+---+---+-------+ +==== === ======= + .. A label +==== === ======= + 0 2 A + 1 3 B + 2 6 B + 3 4 A +==== === ======= then it looks like following after applying the filter. -+---+---+-------+ -| | A | label | -+---+---+-------+ -| 0 | 2 | A | -+---+---+-------+ -| 3 | 4 | A | -+---+---+-------+ +==== === ======= + .. A label +==== === ======= + 0 2 A + 3 4 A +==== === ======= + +Simple filtering in GUI +----------------------- + +In right-click contextmenu, you can select :menu:`Filter` to filter the table by the column. +Filter buttons |filter| will be anchored at the corner of the column section during +the table being filtered. You can update or remove the filter by clicking the button. + +.. image:: ../fig/column_filter.png Use query-style expression -------------------------- @@ -69,19 +79,17 @@ Instead of a function, you can also set a query-style expression as a filter. See `the API reference of pandas.eval `_ for details of the syntax. -Filter in GUI -------------- - -.. |filter| image:: ../../tabulous/_qt/_icons/filter.svg - :width: 20em +If the expression can be interpreted as a simple column-wise filter, |filter| button will +similarly be added. -You can open a overlay dialog to filter the table data from the |filter| button in the toolbar, -push key combo starting with ``Alt``, or right click on the selected column(s). +Query-style filtering in GUI +---------------------------- -The line edit for filter expression supports auto-completion (Tab) and history browsing -(↑, ↓). +You can also open a overlay dialog to filter the table data from the |filter| button in +the toolbar. -.. image:: ../fig/filter.gif +The line edit for filter expression supports auto-completion (:kbd:`Tab`) and history browsing +(:kbd:`↑`, :kbd:`↓`). Sorting ======= @@ -106,31 +114,25 @@ should map a :class:`DataFrame` to a 1-D interger array, just like :meth:`argsor If the table is -+---+---+----+ -| | x | y | -+---+---+----+ -| 0 | 2 | a0 | -+---+---+----+ -| 1 | 3 | a1 | -+---+---+----+ -| 2 | 1 | a2 | -+---+---+----+ -| 3 | 0 | a3 | -+---+---+----+ +=== === === + .. x y +=== === === + 0 2 a0 + 1 3 a1 + 2 1 a2 + 3 0 a3 +=== === === then it looks like following after sorting. -+---+---+----+ -| | x | y | -+---+---+----+ -| 3 | 0 | a3 | -+---+---+----+ -| 2 | 1 | a2 | -+---+---+----+ -| 0 | 2 | a0 | -+---+---+----+ -| 1 | 3 | a1 | -+---+---+----+ +=== === === + .. x y +=== === === + 3 0 a3 + 2 1 a2 + 0 2 a0 + 1 3 a1 +=== === === Sorting function doesn't always have to be surjective, i.e. it can return only a subset of the source indices. @@ -165,5 +167,73 @@ Sort in GUI .. |sort| image:: ../../tabulous/_qt/_icons/sort_table.svg :width: 20em -You can sort selected column(s) by clicking |sort| button in the toolbar, -push key combo starting with ``Alt``, or right click on the selected column(s). +You can sort selected column(s) by clicking |sort| button in the toolbar. + +Edit Cells during Proxy +======================= + +You can edit cells while the table is sorted/filtered. + +Suppose you have a table like below + +=== ===== + A B +=== ===== + 0 row-0 + 1 row-1 + 2 row-2 + 3 row-3 + 4 row-4 + 5 row-5 + 6 row-6 + 7 row-7 + 8 row-8 + 9 row-9 +=== ===== + +and applied a filter by + +.. code-block:: python + + viewer.current_table.proxy.filter("A % 2 == 1") + + +=== ===== + A B +=== ===== + 1 row-1 + 3 row-3 + 5 row-5 + 7 row-7 + 9 row-9 +=== ===== + +Here, you can edit, or paste data directly (The edited cells are highlighted in red). + +=== ========== + A B +=== ========== + 1 row-1 + 3 :red:`X-3` + 5 :red:`X-5` + 7 :red:`X-7` + 9 row-9 +=== ========== + +After removing filter (:meth:`viewer.current_table.proxy.reset`), you'll see the cells +are properly edited. + +=== ========== + A B +=== ========== + 0 row-0 + 1 row-1 + 2 row-2 + 3 :red:`X-3` + 4 row-4 + 5 :red:`X-5` + 6 row-6 + 7 :red:`X-7` + 8 row-8 + 9 row-9 +=== ========== diff --git a/rst/main/table_advanced.rst b/rst/main/table_advanced.rst deleted file mode 100644 index 59d7b426..00000000 --- a/rst/main/table_advanced.rst +++ /dev/null @@ -1,179 +0,0 @@ -=================== -Working with Tables -=================== - -.. contents:: Contents - :local: - :depth: 2 - -Use View Modes -============== - -Dual View ---------- - -In dual view mode, table is split into two part and each part can be scrolled -and zoomed independently. This mode is useful to inspect large data. - -Dual view is enabled by setting ``table.view_mode = "horizontal"`` for horizontal -view, and ``table.view_mode = "vertical"`` for vertical one. - -.. code-block:: python - - table = viewer.add_table(data) - table.view_mode = "horizontal" - -To reset dual view, set the property to ``"normal"``. - -.. code-block:: python - - table.view_mode = "normal" - -Dual view can also be turned on by key combo ``Ctrl+K, H`` (horizontal) or -``Ctrl+K, V`` (vertical). Reset it by key combo ``Ctrl+K, N``. - -Popup View ----------- - -In popup view mode, a large popup window appears and the table data is shown -inside it. This mode is useful when you want to focus on seeing or editing one -table, or the table viewer widget is docked in other widget so it is very small. - -Popup view is enabled by setting ``table.view_mode = "popup"`` and can be reset -similar to dual view by ``table.view_mode = "normal"`` - -.. code-block:: python - - table = viewer.add_table(data) - table.view_mode = "popup" - -Dual view can also be turned on by key combo ``Ctrl+K, P``. - -Tile View ---------- - -Tile view is a mode that shows different tables in a same window, while the -structure of table list and tabs are not affected. - -How tiling works -^^^^^^^^^^^^^^^^ - -For instance, if you tiled tables "A" and "B", they will appear in the same -window, but tabs named "A" and "B" still exist in the tab bar. ``viewer.tables[i]`` -also returns the same table as before. When tab "A" or "B" is clicked, the tiled -table with "A" and "B" is shown as ``A|B``. - -You can tile the current table and the table next to it by shortcut ``Ctrl+K, ^``. -You can also programmatically tile tables by calling ``viewer.tables.tile([0, 1, 2])``. - -Untiling -^^^^^^^^ - -Untiling is also well-defined operation. Let's say tabs "A", "B" and "C" is tiled so -these tabs show tiled view ``A|B|C``. If you untiled "B", "A" and "C" are re-tiled -while "B" returns the original state. Therefore, tabs "A" and "C" shows ``A|C`` and -tab "B" shows ``B``. - -You can untile the current table by shortcut ``Ctrl+K, \``. -You can also programmatically untile tables by calling ``viewer.tables.untile([0, 1, 2])``. - - -Side Area -========= - -Every table has a side area that can be used to add table-specific widgets or show -table-specific information. - -Custom widgets --------------- - -Custom Qt widgets or ``magicgui`` widgets can be added to the side area using -:meth:`add_side_widget` method. - -.. code-block:: python - - table = viewer.tables[0] - table.add_side_widget(widget) - # if you want to give a name to the widget - table.add_side_widget(widget, name="widget name") - -Examples -^^^^^^^^ - -.. code-block:: python - - from magicgui import magicgui - - @magicgui - def func(): - print(table.data.mean()) - - table.add_side_widget(func) - -Plot Canvas ------------ - -Since plotting is a common use case for table data analysis, plot canvases are implemented -by default. The basic plot functions are available in :attr:`plt` attribute with the -similar API as ``matplotlib.pyplot`` module. - -.. code-block:: python - - table = viewer.tables[0] - table.plt.plot(x, y) - table.plt.hist(x) - table.plt.scatter(x, y) - -You can also update plot canvas from the "Plot" tab of the toolbar. - -Undo Stack ----------- - -Undo/redo is implemented for each table. You can see the registered operations in a list -view in the side area. You can open it by pressing ``Ctrl+H``. - - -Overlay Widget -============== - -Instead of the side area, you can also add widgets as an overlay over the table. An -overlay widget is similar to the overlay charts in Excel. - -.. code-block:: python - - table = viewer.tables[0] - table.add_overlay_widget(widget) - # if you want to give a label to the widget - table.add_overlay_widget(widget, label="my widget") - # you can give the top-left coordinate of the widget - table.add_overlay_widget(widget, topleft=(5, 5)) - -Update Cell Values -================== - -To set new table data, :meth:`loc` and :meth:`iloc` is not safe. - -.. code-block:: python - - table.data.iloc[1, 2] = -1 # set new data - -This is not equivalent to editing cells directly for several reasons. - -- ``Table`` data will be updated in this way but ``SpreadSheet`` will not since the returned - data is a copy. -- :attr:`loc` and :attr:`iloc` does not check data type. -- Table will not be updated immediately. - -Better way is to use :attr:`cell` attribute. - -.. code-block:: python - - table.cell[1, 2] = -1 - table.cell[1, 0:5] = [1, 2, 3, 4, 5] - -The same applies to headers. Use :attr:`index` and :attr:`column` attributes. - -.. code-block:: python - - table.index[1] = "index_name" - table.columns[2] = "column_name" diff --git a/rst/main/table_fields.rst b/rst/main/table_fields.rst new file mode 100644 index 00000000..3c7b229e --- /dev/null +++ b/rst/main/table_fields.rst @@ -0,0 +1,232 @@ +========================== +Field Attributes of Tables +========================== + +.. contents:: Contents + :local: + :depth: 1 + +.. include:: ../font.rst + +Table operations are very complicated. Providing all the programmatic operations +to interact with table state and data as table methods is confusing. Thus, in +:mod:`tabulous`, these operations are well organized with fields and sub-fields +(For instance, all the methods related to table cells are all in :attr:`cell` +field). + +Followings are all the fields that are available in table widgets. + +``cell`` field +-------------- + +The :attr:`cell` field provides several methods to get access to table cells. + +.. code-block:: python + + table.cell[1, 2] = -1 + table.cell[1, 0:5] = [1, 2, 3, 4, 5] + +:attr:`cell` supports custom contextmenu registration. See :doc:`register_action` +for more detail. + +.. note:: + + To set new table data, :attr:`loc` and :attr:`iloc` is not safe. + + .. code-block:: python + + table.data.iloc[1, 2] = -1 # set new data + + This is not equivalent to editing cells directly for several reasons. + + - :class:`Table` data will be updated in this way but :class:`SpreadSheet` will not since + the returned data is a copy. + - :attr:`loc` and :attr:`iloc` does not check data type. + - Table will not be updated immediately. + +The :attr:`cell` field has several sub-fields. + +``cell.ref`` +^^^^^^^^^^^^ + +All the in-cell functions with cell references are accessible via :attr:`ref` sub-field. + +.. code-block:: python + + table = viewer.add_spreadsheet(np.arange(10)) + table.cell[0, 1] = "&=np.mean(df.iloc[:, 0])" + print(table.cell.ref[0, 1]) # get the slot function at (0, 1) + print(table.cell.ref[1, 1]) # KeyError + +``cell.label`` +^^^^^^^^^^^^^^ + +Cell labels can be edited programmatically using this sub-field. + +.. code-block:: python + + print(table.cell.label[0, 1]) + table.cell.label[0, 1] = "mean:" + +``cell.text`` +^^^^^^^^^^^^^ + +Displayed (formatted) text in cells can be obtained using this sub-field. + +.. code-block:: python + + print(table.cell.text[0, 1]) + +``cell.text_color`` +^^^^^^^^^^^^^^^^^^^ + +Displayed text color (8-bit RGBA) in cells can be obtained using this sub-field. + +.. code-block:: python + + print(table.cell.text_color[0, 1]) + +``cell.background_color`` +^^^^^^^^^^^^^^^^^^^^^^^^^ + +Displayed background color (8-bit RGBA) in cells can be obtained using this sub-field. + +.. code-block:: python + + print(table.cell.text_color[0, 1]) + +``plt`` field +------------- + +Since plotting is a common use case for table data analysis, plot canvases are implemented +by default. The basic plot functions are available in :attr:`plt` field with the +similar API as :mod:`matplotlib.pyplot` module. + +.. code-block:: python + + table = viewer.tables[0] + table.plt.plot(x, y) + table.plt.hist(x) + table.plt.scatter(x, y) + +.. note:: + + You can also update plot canvas from the "Plot" tab of the toolbar. + + +``index`` / ``columns`` field +----------------------------- + +:attr:`index` and :attr:`column` behaves very similar to :attr:`index` and :attr:`column` +of :class:`pandas.DataFrame`. + +.. code-block:: python + + # get header data + print(table.index[1]) + print(table.columns[2]) + + # get index of header name + table.index.get_loc("index_name") + table.columns.get_loc("column_name") + + # update header data + table.index[1] = "index_name" + table.columns[2] = "column_name" + +:attr:`index` and `columns` support custom contextmenu registration. See +:doc:`register_action` for more detail. + +``proxy`` field +--------------- + +Proxy includes sorting and filtering, that is, deciding which rows to be shown and +which not to be. + +.. code-block:: python + + table.proxy.filter("label == 'A'") # filter by 'label' column + table.proxy.sort("value") # sort by 'value' column + table.reset() # reset proxy + +See :doc:`sort_filter` for more details. + +``text_color`` / ``background_color`` field +------------------------------------------- + +:attr:`text_color` and :attr:`background_color` stores all the column-specific colormaps. + +.. code-block:: python + + table.text_color["A"] # get the colormap of column "A" + table.text_color["A"] = cmap # set a colormap to column "A" + + # set a colormap to column "A" with a decorator + @table.text_color.set("A") + def cmap(x): + ... + + del table.text_color["A"] # reset the colormap defined for column "A" + +See :doc:`columnwise_settings` for more details. + +``formatter`` / ``validator`` field +----------------------------------- + +:attr:`formatter` and :attr:`validator` stores all the column-specific text formatter +and data validator functions. + +.. code-block:: python + + table.formatter["A"] # get the formatter of column "A" + table.formatter["A"] = "{:2f} cm" # set a formatter of column "A" + + # set a formatter of column "A" with a decorator + @table.formatter.set("A") + def fmt(x): + return f"{:2f} cm" + + del table.formatter["A"] # reset the formatter of column "A" + +See :doc:`columnwise_settings` for more details. + +``dtypes`` field +---------------- + +:attr:`dtypes` is a :class:`SpreadSheet`-specific field. Since a spreadsheet has to +determine the data type of each column, you may occasionally want to tell which +data type it should be. This is especially important when a column should be +interpreted as ``category`` or ``datetime``. + +:attr:`dtypes` is a ``dict``-like object that maps column names to data types. + +.. code-block:: python + + table = viewer.add_spreadsheet({"A": ["X", "X", "Y"], "B": [1, 2, 3]}) + table.dtypes["A"] = "category" + table.dtypes["B"] = "float" + table.data + +.. code-block:: + + A B + 0 X 1.0 + 1 X 2.0 + 2 Y 3.0 + +.. code-block:: python + + table.dtypes + +.. code-block:: + + ColumnDtypeInterface( + 'A': category, + 'B': float64 + ) + +Simply delete items if you want to reset the dtype setting. + +.. code-block:: python + + del table.dtypes["A"] diff --git a/rst/main/table_view_mode.rst b/rst/main/table_view_mode.rst new file mode 100644 index 00000000..ba9faa17 --- /dev/null +++ b/rst/main/table_view_mode.rst @@ -0,0 +1,86 @@ +============================== +View Tables in Different Modes +============================== + +.. contents:: Contents + :local: + :depth: 1 + +.. include:: ../font.rst + +To efficiently inspect table data, it is very useful to change table view modes. + +Dual View +--------- + +In dual view mode, table is split into two part and each part can be scrolled +and zoomed independently. This mode is useful to inspect large data. + +Dual view is enabled by setting ``table.view_mode = "horizontal"`` for horizontal +view, and ``table.view_mode = "vertical"`` for vertical one. + +.. code-block:: python + + table = viewer.add_table(data) + table.view_mode = "horizontal" + +To reset dual view, set the property to ``"normal"``. + +.. code-block:: python + + table.view_mode = "normal" + +Dual view can also be turned on by key combo :kbd:`Ctrl+K ⇒ H` (horizontal) or +:kbd:`Ctrl+K ⇒ V` (vertical). Reset it by key combo :kbd:`Ctrl+K ⇒ N`. + +Popup View +---------- + +In popup view mode, a large popup window appears and the table data is shown +inside it. This mode is useful when you want to focus on seeing or editing one +table, or the table viewer widget is docked in other widget so it is very small. + +Popup view is enabled by setting ``table.view_mode = "popup"`` and can be reset +similar to dual view by ``table.view_mode = "normal"`` + +.. code-block:: python + + table = viewer.add_table(data) + table.view_mode = "popup" + +Dual view can also be turned on by key combo :kbd:`Ctrl+K ⇒ P`. + +Tile View +--------- + +Tile view is a mode that shows different tables in a same window, while the +structure of table list and tabs are not affected. + +How tiling works +^^^^^^^^^^^^^^^^ + +For instance, if you tiled tables "A" and "B", they will appear in the same +window, but tabs named "A" and "B" still exist in the tab bar. ``viewer.tables[i]`` +also returns the same table as before. When tab "A" or "B" is clicked, the tiled +table with "A" and "B" is shown as ``A|B``. + +You can tile the current table and the table next to it by shortcut :kbd:`Ctrl+K ⇒ ^`. +You can also programmatically tile tables by calling :meth:`viewer.tables.tile`. + +.. code-block:: python + + viewer.tables.tile([0, 1]) # tile the 0th and 1st tables + viewer.tables.tile([0, 1, 3]) # tile tables at indices [0, 1, 3] + +.. image:: ../fig/tile_tables.png + +Untiling +^^^^^^^^ + +Untiling is also well-defined operation. Let's say tabs "A", "B" and "C" is tiled so +these tabs show tiled view ``A|B|C``. If you untiled "B", "A" and "C" are re-tiled +while "B" returns the original state. Therefore, tabs "A" and "C" shows ``A|C`` and +tab "B" shows ``B``. + +You can untile the current table by shortcut :kbd:`Ctrl+K ⇒ \\`. +You can also programmatically untile tables by calling ``viewer.tables.untile([0, 1, 2])``. diff --git a/rst/main/user_interface.rst b/rst/main/user_interface.rst index 4dd5780d..e19d49b5 100644 --- a/rst/main/user_interface.rst +++ b/rst/main/user_interface.rst @@ -6,32 +6,54 @@ User Interface :local: :depth: 2 +.. include:: ../font.rst + Tables ====== Move around the table --------------------- -Arrow keys ``→``, ``←``, ``↑``, ``↓`` with ``Ctrl`` (or ``⌘`` in Mac), ``Shift`` modifier -work as you expects in most of table data editors. - -Additionally, ``Ctrl`` + ``mouse wheel`` zooms in/out the table. ``Ctrl`` + ``Alt`` + arrow -keys scrolls the table to the desired direction. +Arrow keys (:kbd:`→` :kbd:`←` :kbd:`↑` :kbd:`↓`) with :kbd:`Ctrl` (or :kbd:`⌘` in Mac), +:kbd:`Shift` modifier work as you expects in most of table data editors. -.. image:: ../fig/table_interface_0.gif +Additionally, :kbd:`Ctrl` + ``mouse wheel`` zooms in/out the table. Arrow keys (:kbd:`→` +:kbd:`←` :kbd:`↑` :kbd:`↓`) with :kbd:`Ctrl` + :kbd:`Alt` scrolls the table to the desired +direction. Edit cells and headers ---------------------- -If a table is editable, you can edit the values of cells and headers. Double-clicking, ``F2`` +If a table is editable, you can edit the values of cells and headers. Double-clicking, :kbd:`F2` or typing any characters will create an editor for the value. -.. image:: ../fig/table_interface_1.gif +.. image:: ../fig/edit_cell.png During editing, the text will always be validated. Invalid text will be shown in red. For the table cells, you can set any validation rules (see :doc:`/main/columnwise_settings`). For the table headers, duplicated names are not allowed and considered to be invalid. +Add cell labels +--------------- + +People using spreadsheets usually want to name some of the cells. For instance, when you +calculated the mean of a column, you want to name the cell as "mean". Usually, it is done +by editing one of the adjacent cells. + +=== ==== + A B +=== ==== + 1 mean + 2 2.5 + 3 + 4 +=== ==== + +In :mod:`tabulous`, however, you can directly name the cell using cell label. You can edit +cell labels by :kbd:`F3` key. + +.. image:: ../fig/cell_labels.png + Excel-style data evaluation --------------------------- @@ -60,67 +82,57 @@ Scalar value If the evaluation result is a scalar value, -+---+------+--------------------------+ -| | col-0| col-1| -+---+------+--------------------------+ -| 0 | 10 | =np.sum(df['col-0'][0:3])| -+---+------+--------------------------+ -| 1 | 20 | | -+---+------+--------------------------+ -| 2 | 30 | | -+---+------+--------------------------+ +==== ======= ========================= + .. col-0 col-1 +==== ======= ========================= + 0 10 :red:`=np.sum(df['col-0'][0:3])` + 1 20 + 2 30 +==== ======= ========================= it will simply update the current cell. -+---+------+------+ -| | col-0| col-1| -+---+------+------+ -| 0 | 10 | 60 | -+---+------+------+ -| 1 | 20 | | -+---+------+------+ -| 2 | 30 | | -+---+------+------+ +==== ======= ===== + .. col-0 col-1 +==== ======= ===== + 0 10 60 + 1 20 + 2 30 +==== ======= ===== Column vector ^^^^^^^^^^^^^ If the evaluation result is an array such as ``pd.Series``, -+---+------+-----------------------------+ -| | col-0| col-1| -+---+------+-----------------------------+ -| 0 | 10 | =np.cumsum(df['col-0'][0:3])| -+---+------+-----------------------------+ -| 1 | 20 | | -+---+------+-----------------------------+ -| 2 | 30 | | -+---+------+-----------------------------+ +==== ======= ============================ + .. col-0 col-1 +==== ======= ============================ + 0 10 :red:`=np.cumsum(df['col-0'][0:3])` + 1 20 + 2 30 +==== ======= ============================ it will update the relevant cells. -+---+------+------+ -| | col-0| col-1| -+---+------+------+ -| 0 | 10 | 10 | -+---+------+------+ -| 1 | 20 | 30 | -+---+------+------+ -| 2 | 30 | 60 | -+---+------+------+ +==== ======= ===== + .. col-0 col-1 +==== ======= ===== + 0 10 10 + 1 20 30 + 2 30 60 +==== ======= ===== You don't have to edit the top cell. As long as the editing cell will be one of the destinations, result will be the same. -+---+------+-----------------------------+ -| | col-0| col-1| -+---+------+-----------------------------+ -| 0 | 10 | | -+---+------+-----------------------------+ -| 1 | 20 | =np.cumsum(df['col-0'][0:3])| -+---+------+-----------------------------+ -| 2 | 30 | | -+---+------+-----------------------------+ +==== ======= ============================ + .. col-0 col-1 +==== ======= ============================ + 0 10 + 1 20 :red:`=np.cumsum(df['col-0'][0:3])` + 2 30 +==== ======= ============================ Row vector @@ -128,56 +140,47 @@ Row vector An row will be updated if the result should be interpreted as a row vector. -+---+------+----------------------------------------+ -| | col-0| col-1 | -+---+------+----------------------------------------+ -| 0 | 10 | 20 | -+---+------+----------------------------------------+ -| 1 | 20 | 40 | -+---+------+----------------------------------------+ -| 2 | 30 | 60 | -+---+------+----------------------------------------+ -| 3 | | =np.mean(df.loc[0:3, 'col-0':'col-1']) | -+---+------+----------------------------------------+ +==== ======= ====================================== + .. col-0 col-1 +==== ======= ====================================== + 0 10 20 + 1 20 40 + 2 30 60 + 3 :red:`=np.mean(df.loc[0:3, 'col-0':'col-1'])` +==== ======= ====================================== will return ``pd.Series([20, 40])``, which will update the table to -+---+------+------+ -| | col-0| col-1| -+---+------+------+ -| 0 | 10 | 20 | -+---+------+------+ -| 1 | 20 | 40 | -+---+------+------+ -| 2 | 30 | 60 | -+---+------+------+ -| 3 | 20 | 40 | -+---+------+------+ +==== ======= ===== + .. col-0 col-1 +==== ======= ===== + 0 10 20 + 1 20 40 + 2 30 60 + 3 20 40 +==== ======= ===== + Evaluate with references ^^^^^^^^^^^^^^^^^^^^^^^^ To use cell references like Excel, use "&=" instead of "=". -+---+------+----------------------------+ -| | col-0| col-1| -+---+------+----------------------------+ -| 0 | 10 | &=np.mean(df['col-0'][0:3])| -+---+------+----------------------------+ -| 1 | 20 | | -+---+------+----------------------------+ -| 2 | 30 | | -+---+------+----------------------------+ - -+---+------+------+ -| | col-0| col-1| -+---+------+------+ -| 0 | 10 | 20 | -+---+------+------+ -| 1 | 20 | | -+---+------+------+ -| 2 | 30 | | -+---+------+------+ +==== ======= ========================= + .. col-0 col-1 +==== ======= ========================= + 0 10 :red:`&=np.mean(df['col-0'][0:3])` + 1 20 + 2 30 +==== ======= ========================= + +==== ======= ===== + .. col-0 col-1 +==== ======= ===== + 0 10 20 + 1 20 + 2 30 +==== ======= ===== When one of the cell is edited, the value of the destination will also be updated. For instance, editing 10 → 40 will cause the value of ``(0, "col-1")`` to be updated to 30. @@ -229,13 +232,6 @@ want to add more variables or functions, there are two ways to do it. You can't use none of ``np``, ``pd`` or ``df`` as a variable name. -Send the values to the console ------------------------------- - -``Ctrl + I`` in the console will insert a data reference object ``viewer.data[...]`` at the -cursor position. The data reference object is updated in real-time when the table selection is -changed. This is the fastest way to obtain the values in the table. - Toolbar ======= @@ -243,9 +239,10 @@ Toolbar contains many functions that help you with analyzing the table data. .. note:: - You can "click" any buttons in the toolbar using the keyboard; push ``Alt`` (or ``⌥`` - in Mac) to change focus to the toolbar, and follow the tooltip labels to find the - appropriate key combo to get to the button you want (similar to Microsoft Office). + You can "click" any buttons in the toolbar using the keyboard; push :kbd:`Alt` + (or :kbd:`⌥` in Mac) to change focus to the toolbar, and follow the tooltip + labels to find the appropriate key combo to get to the button you want + (similar to Microsoft Office). Home menu --------- diff --git a/tabulous/__init__.py b/tabulous/__init__.py index 428c4d1b..46f92216 100644 --- a/tabulous/__init__.py +++ b/tabulous/__init__.py @@ -1,4 +1,4 @@ -__version__ = "0.4.0rc0" +__version__ = "0.4.0" from tabulous.widgets import TableViewer, TableViewerWidget from tabulous.core import ( diff --git a/tabulous/_qt/_console.py b/tabulous/_qt/_console.py index 72a5c724..b26e5f85 100644 --- a/tabulous/_qt/_console.py +++ b/tabulous/_qt/_console.py @@ -162,7 +162,7 @@ def insertText(self, text: str) -> None: def setTempText(self, text: str | None = None) -> None: if text is None: - text = f"viewer.data.loc[...]" + text = "viewer.data.loc[...]" cursor = self._control.textCursor() cursor.removeSelectedText() pos = cursor.position() @@ -190,7 +190,8 @@ def execute( self.codeExecuted.emit(source) return None - # NOTE: qtconsole overwrites "parent" method so we have to use another method to manage parent. + # NOTE: qtconsole overwrites "parent" method so we have to use another method to + # manage parent. def dockParent(self) -> QtDockWidget: """Return the dock widget parent.""" if self._dock_parent is None: diff --git a/tabulous/_qt/_mainwindow/_command_palette.py b/tabulous/_qt/_mainwindow/_command_palette.py index d5ef8f2f..16fbd4c7 100644 --- a/tabulous/_qt/_mainwindow/_command_palette.py +++ b/tabulous/_qt/_mainwindow/_command_palette.py @@ -37,6 +37,7 @@ def load_all_commands(): view_group = palette.add_group("View") plot_group = palette.add_group("Plot") selection_group = palette.add_group("Selection") + column_group = palette.add_group("Column") _groups = { "window": window_group, @@ -47,6 +48,7 @@ def load_all_commands(): "view": view_group, "plot": plot_group, "selection": selection_group, + "column": column_group, } kb = get_config().keybindings.copy() diff --git a/tabulous/_qt/_table/_base/_table_base.py b/tabulous/_qt/_table/_base/_table_base.py index dacbccbe..ff9dba5e 100644 --- a/tabulous/_qt/_table/_base/_table_base.py +++ b/tabulous/_qt/_table/_base/_table_base.py @@ -827,6 +827,22 @@ def parentViewer(self) -> _QtMainWidgetBase: """Return the parent table viewer.""" return self._qtable_view.parentViewer() + def screenshot(self): + """Create an array of pixel data of the current view.""" + import qtpy + + img = self.grab().toImage() + bits = img.constBits() + h, w, c = img.height(), img.width(), 4 + if qtpy.API_NAME.startswith("PySide"): + arr = np.array(bits).reshape(h, w, c) + else: + bits.setsize(h * w * c) + arr: np.ndarray = np.frombuffer(bits, np.uint8) + arr = arr.reshape(h, w, c) + + return arr[:, :, [2, 1, 0, 3]] + def _switch_head_and_index(self, axis: int = 1) -> None: """Switch the first row/column data and the index object.""" self.setProxy(None) # reset filter to avoid unexpected errors @@ -839,14 +855,14 @@ def _switch_head_and_index(self, axis: int = 1) -> None: df_new = df.reset_index() if was_range: _index = as_constructor(df.columns)(df.columns.size) - df_new.set_axis(_index, axis=1, inplace=True) + df_new = df_new.set_axis(_index, axis=1) elif axis == 1: was_range = is_ranged(df.index) if is_ranged(df.columns): # df[0] to column top_row = df.iloc[0, :].astype(str) df_new = df.iloc[1:, :] - df_new.set_axis(top_row, axis=1, inplace=True) + df_new = df_new.set_axis(top_row, axis=1) else: # column to df[0] columns = range(len(df.columns)) head = pd.DataFrame( @@ -856,10 +872,10 @@ def _switch_head_and_index(self, axis: int = 1) -> None: ], columns=columns, ) - df.set_axis(columns, axis=1, inplace=True) + df = df.set_axis(columns, axis=1) df_new = pd.concat([head, df], axis=0) if was_range: - df_new.set_axis(pd.RangeIndex(len(df_new)), axis=0, inplace=True) + df_new = df_new.set_axis(pd.RangeIndex(len(df_new)), axis=0) else: raise ValueError("axis must be 0 or 1.") return self.setDataFrame(df_new) diff --git a/tabulous/color.py b/tabulous/color.py index 959adb39..0897626f 100644 --- a/tabulous/color.py +++ b/tabulous/color.py @@ -13,7 +13,7 @@ class ColorTuple(NamedTuple): r: int g: int b: int - a: int + a: int = 255 @property def opacity(self) -> float: @@ -74,6 +74,10 @@ def from_hsva(cls, *hsva) -> ColorTuple: *[int(round(c * 255)) for c in colorsys.hsv_to_rgb(*hsv_float)], alpha ) + def equals(self, other): + other = normalize_color(other) + return self == other + def normalize_color(color: ColorType) -> ColorTuple: """Normalize a color-like object to a ColorTuple.""" diff --git a/tabulous/commands/__init__.py b/tabulous/commands/__init__.py index 4eef4cd2..ab45229c 100644 --- a/tabulous/commands/__init__.py +++ b/tabulous/commands/__init__.py @@ -2,7 +2,7 @@ from typing import Any, Callable, Iterator, TYPE_CHECKING -from . import file, plot, selection, tab, table, view, analysis, window +from . import file, plot, column, selection, tab, table, view, analysis, window from types import FunctionType, ModuleType from qt_command_palette import get_palette @@ -13,6 +13,7 @@ _SUB_MODULES: list[ModuleType] = [ file, plot, + column, selection, tab, table, diff --git a/tabulous/commands/_dialogs.py b/tabulous/commands/_dialogs.py index 8708c200..9c2ef338 100644 --- a/tabulous/commands/_dialogs.py +++ b/tabulous/commands/_dialogs.py @@ -360,6 +360,11 @@ def choose_multiple(choices: List): return choices +@dialog_factory +def get_float(x: float): + return x + + @dialog_factory def spinbox(min: str = "0", max: str = "1000", step: str = "") -> dict: min = int(min) diff --git a/tabulous/commands/column.py b/tabulous/commands/column.py new file mode 100644 index 00000000..ba653e00 --- /dev/null +++ b/tabulous/commands/column.py @@ -0,0 +1,122 @@ +from __future__ import annotations +from typing import TYPE_CHECKING +from . import _utils, _dialogs + +if TYPE_CHECKING: + from tabulous.widgets import TableViewerBase + +_OPACITY_CONFIG = { + "min": 0, + "max": 1, + "step": 0.01, + "value": 0.8, + "label": "opacity", + "widget_type": "FloatSlider", +} + +_BRIGHTNESS_CONFIG = { + "min": -1, + "max": 1, + "step": 0.01, + "value": 0.0, + "label": "opacity", + "widget_type": "FloatSlider", +} + + +def set_text_colormap(viewer: TableViewerBase) -> None: + """Set text colormap to a column""" + 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_text_colormap(viewer: TableViewerBase) -> None: + """Reset text colormap""" + table, column_name = _utils.get_table_and_column_name(viewer) + del table.text_color[column_name] + + +def set_text_colormap_opacity(viewer: TableViewerBase) -> None: + """Set opacity to the text colormap""" + table, column_name = _utils.get_table_and_column_name(viewer) + if val := _dialogs.get_float(x=_OPACITY_CONFIG, parent=viewer.native): + table.text_color.set_opacity(column_name, val) + + +def invert_text_colormap(viewer: TableViewerBase) -> None: + """Invert text colormap""" + table, column_name = _utils.get_table_and_column_name(viewer) + table.text_color.invert(column_name) + + +def adjust_brightness_text_colormap(viewer: TableViewerBase) -> None: + """Adjust brightness of the text colormap""" + table, column_name = _utils.get_table_and_column_name(viewer) + if val := _dialogs.get_float(x=_BRIGHTNESS_CONFIG, parent=viewer.native): + table.text_color.adjust_brightness(column_name, val) + + +def set_background_colormap(viewer: TableViewerBase) -> None: + """Set background colormap to a column""" + 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""" + table, column_name = _utils.get_table_and_column_name(viewer) + del table.background_color[column_name] + + +def set_background_colormap_opacity(viewer: TableViewerBase) -> None: + """Set opacity to the background colormap""" + table, column_name = _utils.get_table_and_column_name(viewer) + if val := _dialogs.get_float(x=_OPACITY_CONFIG, parent=viewer.native): + table.background_color.set_opacity(column_name, val) + + +def invert_background_colormap(viewer: TableViewerBase) -> None: + """Invert background colormap""" + table, column_name = _utils.get_table_and_column_name(viewer) + table.background_color.invert(column_name) + + +def adjust_brightness_background_colormap(viewer: TableViewerBase) -> None: + """Adjust brightness of the background colormap""" + table, column_name = _utils.get_table_and_column_name(viewer) + if val := _dialogs.get_float(x=_BRIGHTNESS_CONFIG, parent=viewer.native): + table.background_color.adjust_brightness(column_name, val) + + +def set_text_formatter(viewer: TableViewerBase) -> None: + """Set text formatter""" + from tabulous._text_formatter import exec_formatter_dialog + + table, column_name = _utils.get_table_and_column_name(viewer) + + 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""" + table, column_name = _utils.get_table_and_column_name(viewer) + del table.formatter[column_name] diff --git a/tabulous/commands/selection.py b/tabulous/commands/selection.py index 00280f77..28d5d46f 100644 --- a/tabulous/commands/selection.py +++ b/tabulous/commands/selection.py @@ -38,12 +38,12 @@ def copy_as_markdown(viewer: TableViewerBase): def copy_as_rst_simple(viewer: TableViewerBase): - """Copy as rst simple table""" + """Copy as reStructuredText (rst) simple table""" _utils.get_table(viewer)._qwidget._copy_as_formated("rst") def copy_as_rst_grid(viewer: TableViewerBase): - """Copy as rst grid table""" + """Copy as reStructuredText (rst) grid table""" _utils.get_table(viewer)._qwidget._copy_as_formated("grid") @@ -161,7 +161,7 @@ def paste_data_from_markdown(viewer: TableViewerBase): def paste_data_from_rst(viewer: TableViewerBase): - """Paste from reStructuredText table""" + """Paste from reStructuredText (rst) table""" import numpy as np import pandas as pd @@ -329,64 +329,6 @@ def remove_selected_columns(viewer: TableViewerBase): raise ValueError("No selection under cursor.") -def set_foreground_colormap(viewer: TableViewerBase) -> None: - """Set foreground colormap to a column""" - 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""" - 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 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""" - 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 - - table, column_name = _utils.get_table_and_column_name(viewer) - - 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""" - table, column_name = _utils.get_table_and_column_name(viewer) - del table.formatter[column_name] - - def write_data_signal_in_console(viewer: TableViewerBase): """Write data signal connection to console""" from qtpy import QtCore diff --git a/tabulous/widgets/_component/__init__.py b/tabulous/widgets/_component/__init__.py index f6101675..35938ec4 100644 --- a/tabulous/widgets/_component/__init__.py +++ b/tabulous/widgets/_component/__init__.py @@ -11,22 +11,27 @@ ColumnDtypeInterface, ) from ._proxy import ProxyInterface +from ._table_subset import TableSeries, TableSubset, TableLocIndexer, TableILocIndexer __all__ = [ - Component, - TableComponent, - ViewerComponent, - VerticalHeaderInterface, - HorizontalHeaderInterface, - SelectionRanges, - HighlightRanges, - SelectedData, - CellInterface, - PlotInterface, - TextColormapInterface, - BackgroundColormapInterface, - TextFormatterInterface, - ValidatorInterface, - ColumnDtypeInterface, - ProxyInterface, + "Component", + "TableComponent", + "ViewerComponent", + "VerticalHeaderInterface", + "HorizontalHeaderInterface", + "SelectionRanges", + "HighlightRanges", + "SelectedData", + "CellInterface", + "PlotInterface", + "TextColormapInterface", + "BackgroundColormapInterface", + "TextFormatterInterface", + "ValidatorInterface", + "ColumnDtypeInterface", + "ProxyInterface", + "TableSeries", + "TableSubset", + "TableLocIndexer", + "TableILocIndexer", ] diff --git a/tabulous/widgets/_component/_base.py b/tabulous/widgets/_component/_base.py index 4cded723..48acaa99 100644 --- a/tabulous/widgets/_component/_base.py +++ b/tabulous/widgets/_component/_base.py @@ -47,7 +47,7 @@ def __get__(self, obj: Literal[None], owner=None) -> Self[_NoRef]: ... @overload - def __get__(self, obj: T, owner=None) -> Self[T]: + def __get__(self, obj: Any, owner=None) -> Self[T]: ... def __get__(self, obj, owner=None): @@ -60,7 +60,7 @@ def __get__(self, obj, owner=None): out = self._instances[_id] = self.__class__(obj) return out - def __set__(self, obj: T, value: Any) -> None: + def __set__(self, obj: Any, value: Any) -> None: if obj is None: raise AttributeError("Cannot set attribute.") _id = id(obj) diff --git a/tabulous/widgets/_component/_cell.py b/tabulous/widgets/_component/_cell.py index da14e133..bbb15e56 100644 --- a/tabulous/widgets/_component/_cell.py +++ b/tabulous/widgets/_component/_cell.py @@ -19,6 +19,7 @@ from tabulous.exceptions import TableImmutableError from tabulous.types import EvalInfo +from tabulous.color import ColorTuple from tabulous._psygnal import InCellRangedSlot from ._base import TableComponent @@ -82,26 +83,26 @@ def __repr__(self) -> str: class CellBackgroundColorInterface(_Sequence2D): - def __getitem__(self, key: tuple[int, int]) -> tuple[int, int, int, int] | None: + def __getitem__(self, key: tuple[int, int]) -> ColorTuple | 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 ColorTuple(*qcolor.getRgb()) return None class CellForegroundColorInterface(_Sequence2D): - def __getitem__(self, key: tuple[int, int]) -> tuple[int, int, int, int] | None: + def __getitem__(self, key: tuple[int, int]) -> ColorTuple | 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 ColorTuple(*qcolor.getRgb()) return None diff --git a/tabulous/widgets/_component/_column_setting.py b/tabulous/widgets/_component/_column_setting.py index 4395aef4..05683de9 100644 --- a/tabulous/widgets/_component/_column_setting.py +++ b/tabulous/widgets/_component/_column_setting.py @@ -29,7 +29,7 @@ _DtypeLike = Union[ExtensionDtype, np.dtype] - from typing_extensions import TypeGuard + from typing_extensions import TypeGuard, Self import pandas as pd _Formatter = Union[Callable[[Any], str], str, None] @@ -81,12 +81,19 @@ def __iter__(self) -> Iterator[str]: return iter(self._get_dict()) def set(self, column_name: str, func: _F = _Void): + """Set function to given column name.""" + def _wrapper(f: _F) -> _F: self._set_value(column_name, f) return f return _wrapper(func) if func is not _Void else _wrapper + def reset(self, column_name) -> Self: + """Reset function for the given column name.""" + self._set_value(column_name, None) + return self + def __call__(self, *args, **kwargs): # backwards compatibility return self.set(*args, **kwargs) diff --git a/tabulous/widgets/_component/_header.py b/tabulous/widgets/_component/_header.py index feef6337..12ec8cb5 100644 --- a/tabulous/widgets/_component/_header.py +++ b/tabulous/widgets/_component/_header.py @@ -12,7 +12,7 @@ import numpy as np from tabulous.exceptions import TableImmutableError -from ._base import TableComponent +from ._base import Component, TableComponent if TYPE_CHECKING: import pandas as pd @@ -21,6 +21,38 @@ _F = TypeVar("_F", bound=Callable) +class HeaderSectionSpan(Component["_HeaderInterface"]): + def __getitem__(self, index: int) -> int: + header = self.parent._get_header() + return header.sectionSize(index) + + def __setitem__( + self, + index: int | slice | list[int], + span: int | Sequence[int], + ) -> None: + header = self.parent._get_header() + if isinstance(index, (slice, list)): + if isinstance(index, slice): + index = list(range(index.indices(header.count()))) + if isinstance(span, Sequence): + # set span for each section + if len(span) != len(index): + raise ValueError("Size mismatch between destination and spans.") + [header.resizeSection(idx, sp) for idx, sp in zip(index, span)] + else: + [header.resizeSection(idx, span) for idx in index] + else: + header.resizeSection(index, span) + + def resize_to_content(self): + from qtpy.QtWidgets import QHeaderView + + header = self.parent._get_header() + header.resizeSections(QHeaderView.ResizeMode.ResizeToContents) + return None + + class _HeaderInterface(TableComponent): """ Interface to the table {index/columns} header. @@ -69,7 +101,14 @@ def _get_header(self) -> QDataFrameHeaderView: def __repr__(self) -> str: return f"<{type(self).__name__}({self._get_axis()!r}) of {self.parent!r}>" - def __getitem__(self, key: int | slice): + # fmt: off + @overload + def __getitem__(self, key: int) -> str: ... + @overload + def __getitem__(self, key: slice | list[int] | np.ndarray) -> str: ... + # fmt: on + + def __getitem__(self, key): return self._get_axis()[key] def __setitem__(self, key: int | slice, value: Any): @@ -150,6 +189,11 @@ def selected(self) -> list[slice]: ] return out + @property + def span(self) -> HeaderSectionSpan: + """Sub-field to interact with section spans.""" + return HeaderSectionSpan(self) + class VerticalHeaderInterface(_HeaderInterface): __doc__ = _HeaderInterface.__doc__.replace("{index/columns}", "index") diff --git a/tabulous/widgets/_component/_table_subset.py b/tabulous/widgets/_component/_table_subset.py new file mode 100644 index 00000000..5d4bb48c --- /dev/null +++ b/tabulous/widgets/_component/_table_subset.py @@ -0,0 +1,310 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Sequence, TypeVar, Union, overload, Callable + +import numpy as np + +from tabulous.types import ColorMapping +from ._base import TableComponent +from ._column_setting import _Void + +if TYPE_CHECKING: + from numpy.typing import NDArray + import pandas as pd + from tabulous.widgets import TableBase + from ._column_setting import ( + _Interpolatable, + _ColormapInterface, + _DictPropertyInterface, + ) + + _RowLocIdx = Union[int, str, slice, list[str], list[int]] + _ColumnLocIdx = Union[str, slice, list[str]] + _ILocIdx = Union[slice, list[int], NDArray[np.integer]] + + +class TableLocIndexer(TableComponent): + """ + loc indexer for Table widget. + + >>> sheet = viewer.add_spreadsheet(np.zeros((10, 5))) + >>> sheet.loc[:, "B":"C"].data + """ + + # fmt: off + @overload + def __getitem__(self, key: _RowLocIdx | tuple[_RowLocIdx, _ColumnLocIdx]) -> TableSubset: ... # noqa: E501 + @overload + def __getitem__(self, key: tuple[_RowLocIdx, str]) -> TableSeries: ... + # fmt: on + + def __getitem__(self, key): + table = self.parent + if not isinstance(key, tuple): + ckey = list(self.parent.columns) + rkey = key + else: + rkey, ckey = key + + if isinstance(rkey, (int, str)): + idx = table.index.get_loc(rkey) + rsl = slice(idx, idx + 1) + elif isinstance(rkey, slice): + if rkey.start is None: + start = None + else: + start = table.index.get_loc(rkey.start) + if rkey.stop is None: + stop = None + else: + stop = table.index.get_loc(rkey.stop) + 1 + rsl = slice(start, stop) + elif isinstance(rkey, Sequence): + rsl = [table.index.get_loc(lbl) for lbl in rkey] + else: + raise TypeError(f"Cannot loc-slice by {type(ckey)}") + + if isinstance(ckey, str): + if ckey not in table.columns: + raise KeyError(ckey) + return TableSeries(table, rsl, ckey) + elif isinstance(ckey, slice): + if ckey.start is None: + start = None + else: + start = table.columns.get_loc(ckey.start) + if ckey.stop is None: + stop = None + else: + stop = table.columns.get_loc(ckey.stop) + 1 + csl = slice(start, stop) + columns = table.columns[csl] + return TableSubset(table, rsl, columns) + elif isinstance(ckey, Sequence): + for ck in ckey: + if ck not in table.columns: + raise KeyError(ckey) + return TableSubset(table, rsl, ckey) + else: + raise TypeError(f"Cannot loc-slice by {type(ckey)}") + + +class TableILocIndexer(TableComponent): + """ + iloc indexer for Table widget. + + >>> sheet = viewer.add_spreadsheet(np.zeros((10, 5))) + >>> sheet.iloc[:, 2:5].data + """ + + # fmt: off + @overload + def __getitem__(self, key: _ILocIdx | tuple[_ILocIdx, _ILocIdx]) -> TableSubset: ... + @overload + def __getitem__(self, key: tuple[_ILocIdx, int]) -> TableSeries: ... + # fmt: on + + def __getitem__(self, key): + table = self.parent + if not isinstance(key, tuple): + ckey = slice(None) + rkey = key + else: + rkey, ckey = key + + if isinstance(ckey, int): + return TableSeries(table, rkey, table.columns[ckey]) + elif isinstance(ckey, slice): + columns = table.columns[ckey] + return TableSubset(table, rkey, columns) + elif isinstance(ckey, Sequence): + return TableSubset(table, rkey, list(table.columns[ckey])) + else: + raise TypeError(f"Cannot iloc-slice by {type(ckey)}") + + +class TableSubset(TableComponent): + def __init__( + self, parent: TableBase, row_slice: slice | list[int], columns: list[str] + ): + super().__init__(parent) + self._row_slice = row_slice + self._columns = columns + + @property + def data(self) -> pd.DataFrame: + table = self.parent + return table.native._get_sub_frame(self._columns).iloc[self._row_slice] + + +class TableSeries(TableComponent): + def __init__(self, parent: TableBase, row_slice: slice | list[int], column: str): + super().__init__(parent) + self._row_slice = row_slice + self._column = column + + def __repr__(self) -> str: + return f" of {self.parent!r}>" + + @property + def data(self) -> pd.Series: + table = self.parent + return table.native._get_sub_frame(self._column).iloc[self._row_slice] + + @property + def text_color(self): + """Get the text colormap of the column.""" + self._assert_row_not_sliced() + return PartialTextColormapInterface(self.parent, self._column) + + @text_color.setter + def text_color(self, val) -> None: + """Set the text colormap of the column.""" + self._assert_row_not_sliced() + self.parent.text_color[self._column] = val + return None + + @text_color.deleter + def text_color(self) -> None: + """Delete the text colormap of the column.""" + self._assert_row_not_sliced() + del self.parent.text_color[self._column] + return None + + @property + def background_color(self): + """Get the background colormap of the column.""" + self._assert_row_not_sliced() + return PartialBackgroundColormapInterface(self.parent, self._column) + + @background_color.setter + def background_color(self, val): + """Set the background colormap of the column.""" + self._assert_row_not_sliced() + self.parent.background_color[self._column] = val + return None + + @background_color.deleter + def background_color(self): + """Delete the background colormap of the column.""" + self._assert_row_not_sliced() + del self.parent.background_color[self._column] + return None + + @property + def formatter(self): + self._assert_row_not_sliced() + return PartialTextFormatterInterface(self.parent, self._column) + + @formatter.setter + def formatter(self, val): + self._assert_row_not_sliced() + self.parent.formatter[self._column] = val + return None + + @formatter.deleter + def formatter(self): + self._assert_row_not_sliced() + del self.parent.formatter[self._column] + return None + + @property + def validator(self): + self._assert_row_not_sliced() + return PartialValidatorInterface(self.parent, self._column) + + @validator.setter + def validator(self, val): + self._assert_row_not_sliced() + self.parent.validator[self._column] = val + return None + + @validator.deleter + def validator(self): + self._assert_row_not_sliced() + del self.parent.validator[self._column] + return None + + def _assert_row_not_sliced(self): + if self._row_slice == slice(None): + return + raise ValueError(f"{self!r} is sliced in row axis.") + + +_F = TypeVar("_F", bound=Callable) + + +class _PartialInterface(TableComponent): + def _get_field(self) -> _DictPropertyInterface: + raise NotImplementedError() + + def __init__(self, parent: TableBase, column: str): + super().__init__(parent) + self._column = column + + def __repr__(self) -> str: + pclsname = type(self._get_field()).__name__ + item = self.item() + if item is None: + return f"{pclsname}<{self._column!r}>(Undefined)" + else: + return f"{pclsname}<{self._column!r}>({item})" + + def set(self, func: _F = _Void): + return self._get_field().set(self._column, func) + + def reset(self): + return self._get_field().reset(self._column) + + def item(self): + return self._get_field().get(self._column, None) + + +class _PartialColormapInterface(_PartialInterface): + def _get_field(self) -> _ColormapInterface: + raise NotImplementedError() + + def invert(self): + return self._get_field().invert(self._column) + + def set_opacity(self, opacity: float): + return self._get_field().set_opacity(self._column, opacity) + + def adjust_brightness(self, factor: float): + return self._get_field().adjust_brightness(self._column, factor) + + def set( + self, + colormap: ColorMapping = _Void, + *, + interp_from: _Interpolatable | None = None, + infer_parser: bool = True, + opacity: float | None = None, + ): + return self._get_field().set( + self._column, + colormap, + interp_from=interp_from, + infer_parser=infer_parser, + opacity=opacity, + ) + + +class PartialTextColormapInterface(_PartialColormapInterface): + def _get_field(self): + return self.parent.text_color + + +class PartialBackgroundColormapInterface(_PartialColormapInterface): + def _get_field(self): + return self.parent.background_color + + +class PartialTextFormatterInterface(_PartialInterface): + def _get_field(self): + return self.parent.formatter + + +class PartialValidatorInterface(_PartialInterface): + def _get_field(self): + return self.parent.validator diff --git a/tabulous/widgets/_mainwindow.py b/tabulous/widgets/_mainwindow.py index 2c8a11e4..ba585f33 100644 --- a/tabulous/widgets/_mainwindow.py +++ b/tabulous/widgets/_mainwindow.py @@ -494,6 +494,10 @@ def close(self): """Close the viewer.""" return self._qwidget.close() + def resize(self, width: int, height: int): + """Resize the table viewer.""" + return self._qwidget.resize(width, height) + def _link_events(self): _tablist = self._tablist _qtablist = self._qwidget._tablestack @@ -638,6 +642,7 @@ def add_dock_widget( ) dock.setSourceObject(widget) self._dock_widgets[name] = dock + dock.show() return dock def remove_dock_widget(self, name_or_widget: str | Widget | QWidget): @@ -664,10 +669,6 @@ def reset_choices(self, *_): if hasattr(widget, "reset_choices"): widget.reset_choices() - def resize(self, width: int, height: int): - """Resize the table viewer.""" - return self._qwidget.resize(width, height) - class DummyViewer(_AbstractViewer): """ diff --git a/tabulous/widgets/_table.py b/tabulous/widgets/_table.py index 5f14f020..5e628bd4 100644 --- a/tabulous/widgets/_table.py +++ b/tabulous/widgets/_table.py @@ -4,7 +4,7 @@ from abc import ABC, abstractmethod from enum import Enum from pathlib import Path -from typing import Any, Callable, Hashable, TYPE_CHECKING +from typing import Any, Callable, Hashable, TYPE_CHECKING, overload import warnings from psygnal import SignalGroup, Signal @@ -14,6 +14,7 @@ if TYPE_CHECKING: from typing_extensions import Self, Literal + import numpy as np import pandas as pd from collections_undo import UndoManager from qtpy import QtWidgets as QtW @@ -66,6 +67,7 @@ class TableBase(ABC): """The base class for a table layer.""" _Default_Name = "None" + cell = _comp.CellInterface() index = _comp.VerticalHeaderInterface() columns = _comp.HorizontalHeaderInterface() @@ -77,6 +79,8 @@ class TableBase(ABC): validator = _comp.ValidatorInterface() selections = _comp.SelectionRanges() highlights = _comp.HighlightRanges() + loc = _comp.TableLocIndexer() + iloc = _comp.TableILocIndexer() def __init__( self, @@ -119,6 +123,20 @@ def __init__( def __repr__(self) -> str: return f"{self.__class__.__name__}<{self.name!r}>" + # fmt: off + @overload + def __getitem__(self, key: str) -> _comp.TableSeries: ... + @overload + def __getitem__(self, key: list[str]) -> _comp.TableSubset: ... + # fmt: on + + def __getitem__(self, key): + if isinstance(key, str): + return _comp.TableSeries(self, slice(None), key) + elif isinstance(key, list): + return _comp.TableSubset(self, slice(None), key) + raise TypeError(f"Invalid key type: {type(key)}") + @property def cellref(self): warnings.warn( @@ -452,6 +470,18 @@ def save(self, path: str | Path) -> None: save_file(path, self.data) return None + def screenshot(self) -> np.ndarray: + """Get screenshot of the widget.""" + return self._qwidget.screenshot() + + def save_screenshot(self, path: str): + """Save screenshot of the widget.""" + from PIL import Image + + arr = self.screenshot() + Image.fromarray(arr).save(path) + return None + def _emit_selections(self): with self.selections.blocked(): # Block selection to avoid recursive update. @@ -525,12 +555,12 @@ def _install_actions(self): # fmt: off _hheader_register = self.columns.register_action - _hheader_register("Color > Set foreground colormap")(_wrap(cmds.selection.set_foreground_colormap)) # noqa: E501 - _hheader_register("Color > Reset foreground colormap")(_wrap(cmds.selection.reset_foreground_colormap)) # noqa: E501 - _hheader_register("Color > Set background colormap")(_wrap(cmds.selection.set_background_colormap)) # noqa: E501 - _hheader_register("Color > Reset background colormap")(_wrap(cmds.selection.reset_background_colormap)) # noqa: E501 - _hheader_register("Formatter > Set text formatter")(_wrap(cmds.selection.set_text_formatter)) # noqa: E501 - _hheader_register("Formatter > Reset text formatter")(_wrap(cmds.selection.reset_text_formatter)) # noqa: E501 + _hheader_register("Color > Set text colormap")(_wrap(cmds.column.set_text_colormap)) # noqa: E501 + _hheader_register("Color > Reset text colormap")(_wrap(cmds.column.reset_text_colormap)) # noqa: E501 + _hheader_register("Color > Set background colormap")(_wrap(cmds.column.set_background_colormap)) # noqa: E501 + _hheader_register("Color > Reset background colormap")(_wrap(cmds.column.reset_background_colormap)) # noqa: E501 + _hheader_register("Formatter > Set text formatter")(_wrap(cmds.column.set_text_formatter)) # noqa: E501 + _hheader_register("Formatter > Reset text formatter")(_wrap(cmds.column.reset_text_formatter)) # noqa: E501 self._qwidget._qtable_view.horizontalHeader().addSeparator() _hheader_register("Sort")(_wrap(cmds.selection.sort_by_columns)) _hheader_register("Filter")(_wrap(cmds.selection.filter_by_columns)) @@ -629,7 +659,7 @@ class Table(_DataFrameTableLayer): _Default_Name = "table" _qwidget: QTableLayer - native: Table + native: QTableLayer def _create_backend(self, data: pd.DataFrame) -> QTableLayer: from tabulous._qt import QTableLayer diff --git a/tabulous_doc.py b/tabulous_doc.py new file mode 100644 index 00000000..4cab63c7 --- /dev/null +++ b/tabulous_doc.py @@ -0,0 +1,28 @@ +from __future__ import annotations +from pathlib import Path +from typing import Callable, Union + +from tabulous import TableViewer +from tabulous.widgets import TableBase + +_Registerable = Callable[[], Union[TableBase, TableViewer]] + + +class FunctionRegistry: + def __init__(self, root: str | Path): + self._root = Path(root) + self._all_functions: list[_Registerable] = [] + + def register(self, f: _Registerable): + def wrapped(): + if out := f(): + out.save_screenshot(self._root / f"{f.__name__}.png") + if isinstance(out, TableViewer): + out.close() + + self._all_functions.append(wrapped) + return wrapped + + def run_all(self): + for f in self._all_functions: + f() diff --git a/tests/_utils.py b/tests/_utils.py index f69791ac..707e8418 100644 --- a/tests/_utils.py +++ b/tests/_utils.py @@ -9,10 +9,6 @@ def get_tabwidget_tab_name(viewer: TableViewer, i: int) -> str: qtablist: QTabbedTableStack = viewer._qwidget._tablestack return qtablist.tabText(i) -def get_cell_value(table: QBaseTable, row, col) -> str: - index = table.model().index(row, col) - return table.model().data(index) - def edit_cell(table: QBaseTable, row, col, value): table.model().dataEdited.emit(row, col, value) diff --git a/tests/test_spreadsheet.py b/tests/test_spreadsheet.py index 7919c36a..639a7297 100644 --- a/tests/test_spreadsheet.py +++ b/tests/test_spreadsheet.py @@ -112,7 +112,7 @@ def test_column_dtype(dtype: str): def test_column_dtype_validation(): viewer = TableViewer(show=False) sheet = viewer.add_spreadsheet({"a": [1, 2, 3]}) - sheet.dtypes.set_dtype("a", "int") + sheet.dtypes.set("a", "int") sheet.cell[0, 0] = 1 with pytest.raises(ValueError): sheet.cell[0, 0] = "a" diff --git a/tests/test_table.py b/tests/test_table.py index e59598ac..66a3457a 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -7,7 +7,7 @@ from qtpy import QtWidgets as QtW import pytest -from ._utils import get_cell_value, edit_cell, selection_equal +from ._utils import edit_cell, selection_equal df0 = pd.DataFrame({"a": [10, 20, 30], "b": [1.0, 1.1, 1.2]}) df1 = pd.DataFrame({"label": ["one", "two", "one"], "value": [1.0, 1.1, 1.2]}) @@ -21,7 +21,7 @@ def test_display(df: pd.DataFrame): assert table.data.columns is df.columns assert table.data.index is df.index assert table.table_shape == df.shape - assert get_cell_value(table._qwidget, 0, 0) == str(df.iloc[0, 0]) + assert table.cell.text[0, 0] == str(df.iloc[0, 0]) @pytest.mark.parametrize("df", [df0, df1]) def test_editing_data(df: pd.DataFrame): diff --git a/tests/test_table_subset.py b/tests/test_table_subset.py new file mode 100644 index 00000000..266d6187 --- /dev/null +++ b/tests/test_table_subset.py @@ -0,0 +1,99 @@ +from tabulous.widgets import Table +import numpy as np +import pandas as pd +import pytest +from pandas.testing import assert_frame_equal, assert_series_equal + +def assert_obj_equal(a, b): + if isinstance(a, pd.DataFrame): + assert_frame_equal(a, b) + else: + assert_series_equal(a, b) + +DATA = pd.DataFrame(np.arange(50).reshape(10, 5), columns=list("ABCDE")) + +@pytest.mark.parametrize( + "sl", + [ + slice(None), + slice(1, 4), + (slice(None), slice(None)), + (slice(None), "A"), + (slice(None), slice("A", "C")), + (slice(None), slice("B", "B")), + (slice(None), slice("C", None)), + (slice(None), slice(None, "B")), + (slice(None), ["B", "D"]), + ([1, 3, 6], "B"), + ] +) +def test_loc_data_equal(sl): + table = Table(DATA) + assert_obj_equal(table.loc[sl].data, table.data.loc[sl]) + +@pytest.mark.parametrize( + "sl", + [ + slice(None), + slice(1, 4), + (slice(None), slice(None)), + (slice(None), 0), + (slice(None), slice(0, 3)), + (slice(None), slice(1, 2)), + (slice(None), slice(3, None)), + (slice(None), slice(None, 3)), + (slice(None), [1, 3]), + ([1, 3, 6], 1), + ] +) +def test_iloc_data_equal(sl): + table = Table(DATA) + assert_obj_equal(table.iloc[sl].data, table.data.iloc[sl]) + +def test_partial_text_color(): + table = Table(DATA) + + assert table["B"].text_color.item() is None + table["B"].text_color.set(interp_from=["red", "blue"]) + assert table["B"].text_color.item() is not None + assert table["B"].text_color.item() is table.text_color["B"] + assert table.cell.text_color[0, 1].equals("red") + + table["B"].text_color.reset() + assert table["B"].text_color.item() is None + +def test_partial_background_color(): + table = Table(DATA) + + assert table["B"].background_color.item() is None + table["B"].background_color.set(interp_from=["red", "blue"]) + assert table["B"].background_color.item() is not None + assert table["B"].background_color.item() is table.background_color["B"] + assert table.cell.background_color[0, 1].equals("red") + + table["B"].background_color.reset() + assert table["B"].background_color.item() is None + +def test_partial_formatter(): + table = Table(DATA) + + assert table["B"].formatter.item() is None + table["B"].formatter.set(lambda x: "test") + assert table["B"].formatter.item() is not None + assert table.cell.text[0, 1] == "test" + + table["B"].formatter.reset() + assert table["B"].formatter.item() is None + +def test_partial_validator(): + table = Table(DATA, editable=True) + + def _raise(x): + raise ValueError + + table["B"].validator.set(_raise) + with pytest.raises(ValueError): + table.cell[0, 1] = "6" + + table["B"].validator.reset() + table.cell[0, 1] = "6" diff --git a/tests/test_text_formatter.py b/tests/test_text_formatter.py index 8e2d4b61..97d02b81 100644 --- a/tests/test_text_formatter.py +++ b/tests/test_text_formatter.py @@ -1,28 +1,27 @@ from tabulous import TableViewer -from . import _utils def test_text_formatter(): viewer = TableViewer(show=False) table = viewer.add_table({"number": [1, 2, 3], "char": ["a", "b", "c"]}) - assert _utils.get_cell_value(table.native, 0, 0) == "1" + assert table.cell.text[0, 0] == "1" # set formatter table.text_formatter("number", lambda x: str(x) + "!") - assert _utils.get_cell_value(table.native, 0, 0) == "1!" - assert _utils.get_cell_value(table.native, 0, 1) == "a" + assert table.cell.text[0, 0] == "1!" + assert table.cell.text[0, 1] == "a" # reset formatter table.text_formatter("number", None) - assert _utils.get_cell_value(table.native, 0, 0) == "1" + assert table.cell.text[0, 0] == "1" def test_spreadsheet_default_formatter(): viewer = TableViewer(show=False) sheet = viewer.add_spreadsheet({"number": ["1.2", "1.23456789"]}) - assert _utils.get_cell_value(sheet.native, 0, 0) == "1.2" - assert _utils.get_cell_value(sheet.native, 1, 0) == "1.23456789" - sheet.dtypes.set_dtype("number", "float", formatting=False) - assert _utils.get_cell_value(sheet.native, 0, 0) == "1.2" - assert _utils.get_cell_value(sheet.native, 1, 0) == "1.23456789" - sheet.dtypes.set_dtype("number", "float", formatting=True) - assert _utils.get_cell_value(sheet.native, 0, 0) == "1.2000" - assert _utils.get_cell_value(sheet.native, 1, 0) == "1.2346" + assert sheet.cell.text[0, 0] == "1.2" + assert sheet.cell.text[1, 0] == "1.23456789" + sheet.dtypes.set("number", "float", formatting=False) + assert sheet.cell.text[0, 0] == "1.2" + assert sheet.cell.text[1, 0] == "1.23456789" + sheet.dtypes.set("number", "float", formatting=True) + assert sheet.cell.text[0, 0] == "1.2000" + assert sheet.cell.text[1, 0] == "1.2346"