Tables¶
These views show records in a table: a table that the user edits, and a large table that reads its rows from a source.
A table of records¶
A TableView shows text that the app formats itself. A RecordTableView shows attrs
records, with a column per field, such as the records that the fetch of a
SQLite model returns:
from composeui.core.view import Column, RecordTableView, T, child
runs: RecordTableView[Run] = child(
RecordTableView[Run],
columns=[Column("id", T("Run")), Column("radius", T("Radius"), editable=True)],
)
Assign
recordsto update the table (table.records = model.runs.above(0.1)).Read
selected_record, orselected_records.Connect to
row_activatedto open a row: a double click, or Enter, emits the row’s index inrows. A double click on a cell that edits, or on a button or a link, doesn’t. On Textual and the web, Enter also selects the row first.The table parses an edited cell into the type of its field, through the
cattrsconverter of the views.record_editedemits the old and the new record.When a cell doesn’t parse, the table refuses the edit and says why in the status bar.
The study saves and loads the records by their type. So build the table as
RecordTableView[Run], or subclass it.
Sorting and filtering¶
Mark a column that the user sorts and filters:
Column("radius", T("Radius"), sortable=True, filterable=True). On a TableView,
sortable_columns and filterable_columns list these columns.
A click on a sortable header sorts by its column: ascending, then descending, then back in the app’s order.
While a column is filterable, a bar above the table has:
a search over the text of the filterable columns, once the user stops typing for
SEARCH_DELAY_MS(300 ms), or presses Enter;“+ Filter”, which adds a filter on a column. The filter shows as a chip. A click on the chip opens it for editing, and so does Enter, since the focus lands on the chip.
A filter accepts these values:
In a text column, ignoring case:
the text: the cells that contain it;
=kitchen: the cells equal to it;!=kitchen: the cells not equal to it;!kit: the cells that do not contain it;kit*: the cells that start with it;*en: the cells that end with it;with the
.*toggle of the editor on, a regular expression, for exampleRoom [1-3]. The chip reads “Room matches Room [1-3]”. The app sets this filter asas_pattern("Room [1-3]"), between slashes.
In a number or a date column:
>20,>=20,<20,<=20,!=20, a range18..24(both ends included), or a bare value, which means=.In a boolean column:
trueorfalse.In any column:
=alone, for the empty cells.
A RecordTableView sorts by the values of the fields, so numbers and dates sort as numbers
and dates. A TableView sorts its text.
The app sets the order and the filters too: sort_column, sort_descending, search,
filters, or sort_by(column), as a click does. The study saves them.
shown_rows lists the rows shown, in order, as indexes into rows. selected_rows,
selection_changed, and the edited row always use indexes into rows, whatever the
order. When a search or a filter hides a selected row, the table deselects it.
Widgets in cells¶
A column can show a widget in each of its cells, such as a check box to keep a variable or
a combobox to pick its role. Set the column’s widget to a CellWidget:
from composeui.core.view import CellWidget, Column, RecordTableView, T, child
variables: RecordTableView[Variable] = child(
RecordTableView[Variable],
columns=[
Column("is_kept", T("Keep"), editable=True, widget=CellWidget.check_box(check_all=True)),
Column("name", T("Variable")),
Column("role", T("Role"), editable=True, widget=CellWidget.combobox()),
Column("distribution", T("Distribution"), widget=CellWidget.button()),
Column("doc", T("Help"), widget=CellWidget.link()),
Column("data", T("Data"), editable=True, widget=CellWidget.file_path({"CSV": (".csv",)})),
],
)
On a TableView, cell_widgets maps each column’s index to its CellWidget.
CellWidget.check_box()shows a check box. The box is checked when the cell’s text is “true”, ignoring case. A change sets the text to “True” or “False”.CellWidget.combobox(options)shows the cell’s text in a combobox ofoptions. Without options, on an enum field of aRecordTableView, it offers the enum’s members.CellWidget.button(text)shows the cell’s text, then a button (”…” by default). A text such asT("Retry")follows the app’s language.CellWidget.link()shows the cell’s text as a link.CellWidget.file_path(extensions)shows a file’s path, then a “…” button that picks a file, as a file path input does. The dialog offers the file types ofextensions, such as{"CSV": (".csv",)}; the web refuses a file of another type. Itstext, “Choose a file” by default, names the button for a tooltip and a screen reader. Withserver_roots, such asCellWidget.file_path(extensions, server_roots=(studies,)), the web lists the server’s files in those folders, as a field with server roots does, instead of uploading a copy.CellWidget.folder_path()shows a folder’s path, then a “…” button that picks a folder, as a folder path input does.CellWidget.save_path(extensions)shows a path to save a file to, then a “…” button that picks it, as a save path input does. The file may not exist yet. The first ofextensionsis added to a name without one of them. Withconfirms_overwrite(on by default), the user agrees to replace a file that exists before it is picked. Picking a path writes nothing: the app writes the file.CellWidget.path_by(kind_column, editors)picks the path widget of each row by the row’s kind (see Paths by the row’s kind).
The three path widgets take server_roots and base_folder, as the path inputs do (see
Relative paths). With base_folder, a relative path in the
cell names a path in that folder, and a path picked in it shows relative to it.
A check box, a combobox, or a path changes its cell only when the column is editable.
Otherwise it only shows the cell, and the “…” button picks nothing. The change is an edit, as
when the user types in a cell: cell_edited emits it, and on a RecordTableView,
record_edited too. The user also types in a path cell, but not in the other cells with a
widget. On the web, a path typed outside a column’s server_roots is refused, .. and
symbolic links followed: the cell keeps its text, and the status bar says why. file_texts holds the texts of the list
of the server’s files and of that message, by what they’re for.
A click on a button or a link emits cell_clicked with the row and the column. The row
is an index into rows, whatever the order.
With check_all=True, the column’s header starts with a check box. The box is checked when
all the shown rows are, and partly checked when only some are. A click checks every shown
row, or unchecks them all when all are checked. Each changed cell is an edit. The app does
the same with check_all(column, is_checked=True). column_checked(column) tells whether
the shown rows are all checked (True), none (False), or some (None).
The study does not save cell_widgets: they are the app’s setup.
pipechart.py shows or hides each curve with a check box,
and picks how to draw it with a combobox. taskview.py runs
a task again from its Retry button, and opens the error of a failed task from a link.
What each backend shows:
Qt draws a checkable cell, a combobox frame, a push button, or underlined link text. A click or F2 on an editable combobox cell opens a
QComboBox. Space toggles the current check box, or clicks the current button or link. A path’s “…” button, or Space on its cell, opens aQFileDialog: a file, a folder, or a save dialog. It opens on the cell’s path, else in itsbase_folder, else in the first of itsserver_roots.Textual shows the cell as text: a box (☑ or ☐), the text and ▾, the text and the button’s text in brackets, or underlined text. A click toggles a box, and so does Space on the cursor’s row. A click on an editable combobox cell opens a list of its options. A click on a button or a link cell clicks it. A path shows its text and “[…]”. A click on an editable path cell opens the picker of a file, a folder, or a file to save, on the cell’s path, else in its
base_folder, else in the first of itsserver_roots. F2’s editor types the path. A check-all header shows its box (▣ when partly checked), and a click on the header toggles it instead of sorting.The web shows a check box, a
<select>, a button, or a link-like button. A click on a widget does not select its row. A file path’s “…” button uploads the file that the user picks in the browser. The session keeps a copy of the file, and the cell shows the copy’s path. The session removes a copy when no cell of the table holds its path any more, at the table’s next upload, and all the copies when it ends. Withserver_roots, the button opens the list of the server’s files instead, and a file picked there puts its path on the server in the cell. The browser cannot give the page a folder’s path, or a path to save to. Withoutserver_roots, a folder or a save path cell has no button: the user types the path. With them, the button lists the server’s folders, as the folder and save path inputs do.
Paths by the row’s kind¶
A column can hold paths of several kinds, such as a study’s files: some are files to read,
some folders, and some files to write. CellWidget.path_by gives the column a path widget
per kind. The row’s kind is the text of another of its cells, such as a combobox’s:
from pathlib import Path
from composeui.core.view import CellWidget, MainView, TableView, child, define_view
STUDY = Path.home() / "studies" / "beam"
KIND, PATH = 1, 2
@define_view
class StudyFilesMainView(MainView):
files: TableView = child(
TableView,
headers=["Name", "Kind", "Path"],
rows=[["Mesh", "Input file", "inputs/mesh.med"], ["Results", "Output folder", "results"]],
editable_columns=[KIND, PATH],
cell_widgets={
KIND: CellWidget.combobox(["Input file", "Output file", "Output folder"]),
PATH: CellWidget.path_by(
KIND,
{
"Input file": CellWidget.file_path({"MED": (".med",)}),
"Output file": CellWidget.save_path({"Text": (".txt",)}),
"Output folder": CellWidget.folder_path(),
},
base_folder=STUDY,
),
},
)
The row’s kind picks the widget. The “Input file” row’s “…” button picks a file, and the “Output folder” row’s a folder. When the user changes the kind, the path cell’s widget follows: the app does nothing. The path’s text stays as it is.
A kind without a widget shows a plain cell, which the user types in when the column is editable.
base_folderis that of the kinds’ widgets that have none of their own.cell_widget(row, column)gets a cell’s widget, the kind’s forpath_by, for example to read its path:table.cell_widget(row, PATH).path(text)gives the path thattextnames frombase_folder. It isNonefor a cell without a widget.
studyinputs.py lists a study’s files this way.
How cells and headers look¶
A cell can have a background color, such as red for NaN or orange for a warning. It can be
disabled, show a mark, or span the rows under it. Set cell_styles, a CellStyle per cell
by its index in rows and its column:
from composeui.core.view import CellStyle, HeaderGroup, NodeMark, T
table.headers = ["Group", "Point", "Standard space", "Physical space"]
table.header_groups = [HeaderGroup(T("Coordinates"), column=2, column_count=2)]
table.rows = [["A", "P1", "0.5", "12.0"], ["", "P2", "nan", "nan"]]
table.cell_styles = {
(0, 0): CellStyle(row_span=2),
(1, 2): CellStyle(background="#dc2626"),
(1, 3): CellStyle(background="#fde68a", mark=NodeMark.WARNING),
(1, 1): CellStyle(is_enabled=False),
}
backgroundis a color as#rrggbb, the same in every theme. The text on it is black or white, whichever reads better (text_color).is_enabled,Trueby default, as for a view.Falsegrays the cell. The user never edits it, even in an editable column, and its widget is disabled.check_allleaves it as it is, andcolumn_checkedignores it.markshows aNodeMarkbefore the text: done, warning, or error.done_text,warning_text, anderror_textname it, as a tooltip (Qt, the web) and for screen readers (the web).row_spanmakes the cell cover the cells under it in its column, for example a group over its rows. The span holds only while the table shows those rows right under the cell, in their order. A sort or a filter that moves or hides them shortens it.
Assign cell_styles together with the rows that they describe. The styles go by index, so
new rows take the styles of the rows that were at their indexes.
On a RecordTableView, column_index(field) gives the index of the column that shows a
field, for example table.column_index("deviation").
header_groups puts a header over several columns, on a row above their headers. A
HeaderGroup has its text, its first column, and its number of columns. A column is in one
group at most.
row_headers puts a header before each row, by its index in rows. Use it for a small
key/value table of results:
table.headers = [T("Value")]
table.row_headers = [T("Number of calls"), T("Time (s)")]
table.rows = [["2500"], ["3.2"]]
The study does not save cell_styles, header_groups, or row_headers: the app sets them.
What each backend shows:
Qt colors the cell, grays a disabled cell, and draws the mark as an icon. A cell spans its rows (
QTableView.setSpan). The header is twice as tall with groups. A click on a group sorts nothing. The rows’ headers show in the vertical header.Textual colors the cell’s text, dims a disabled cell, and shows the mark as a glyph (✓, !, ✗). A terminal has no cell across rows: the covered cells are empty. With groups, the header has two lines, each group’s text over its first column. The rows’ headers are the
DataTable’s row labels.The web colors the cell, grays a disabled cell, and shows the mark as an icon named for screen readers. A cell spans its rows (
rowspan), and a group its columns (colspan). Each row starts with its header (<th scope="row">).
The Measurements tab of pipechart.py uses each of these
looks.
A large table¶
A SourceTableView shows the rows of a source, such as a million results of a study. It
never reads all the rows. It reads only the rows that it shows, a window at a time. A
TableView keeps every row as text, which suits a small table that the user edits.
from composeui.core.view import ListSource, SourceTableView, T, child
readings: SourceTableView = child(
SourceTableView,
column_formats={"temperature": ".2f"},
filterable_columns=["sensor", "temperature"],
)
# once the app has the records
main_view.readings.source = ListSource(
Reading, {"sensor": T("Sensor"), "temperature": T("Temperature")}, records
)
The source is a TableSource. Three sources come with ComposeUI:
A
ListSourcekeepsattrsrecords in memory, for rows that the app has already.A
SqliteSourcereads the rows from SQLite (see Rows in SQLite).A
DataFrameSourcereads the rows of a pandas, Polars, or PyArrow frame (see Rows in a dataframe).
A source of your own gives:
Member |
What it gives |
|---|---|
|
The columns of the rows |
|
The number of rows |
|
The ids of the rows shown, in order |
|
The values of some rows |
|
A signal that the rows changed |
When the app sets a new source, the view drops the sort and the filters on the columns that the new source doesn’t have.
Sorting and filtering a large table¶
These settings of the view name the columns of the source:
sort_columnandsort_descendingsort the rows.searchfinds a text in the text columns.filtershold the filters, written as the user types them (see Sorting and filtering).
A click on a column’s header sorts by it: ascending, descending, then in the source’s
order. The app does the same with sort_by(column).
Each new setting asks the source for the order once. The view keeps only the ids of the
rows shown. Integer ids go in an array, at 8 bytes each. ordered emits once the rows
have their new order. For example, its callback shows row_count of total. total is
the number of the source’s rows, before the search and the filters.
A source of 100,000 rows or more (SOURCE_TABLE_JOB_ROWS) orders its rows in a
job on Qt and Textual, so that the window stays
responsive:
While the job runs, the table shows the previous order, and
is_orderingis true.A setting changed while the job runs orders the rows once more, when the job is over.
When the source changed, the job also counts its rows.
The web orders the rows in the thread of the request. The page never freezes.
filterable_columns names the columns that the user filters. The table then shows the
filter bar of Sorting and filtering above its rows. The bar has a
search, “+ Filter”, the filters as chips, and the count of the rows shown
(“1,234 of 1,000,000”). Without filterable_columns, only the app sets the search and the
filters.
Cells, selection, copy, and export¶
The cells show the values in the format that
column_formatsgives, such as".2f"or".0%". A cell ofNoneis empty. An enum shows its text.The selection is a list of ids:
selected_ids. A new order keeps the selected rows that it still shows. A click on a row selects it. WithSelectionMode.MULTIPLE, the click adds the row, or removes it if it is selected.selection_changedemits the ids. A double click, or Enter, emits the id of the row withrow_activated.Ctrl+C copies the selected rows, in their order, as tab-separated text that a spreadsheet takes. Each row is a line, with its cells as shown, and without the headers (
selection_text). The view reads the rows from the source a window at a time.Export CSV writes the rows shown, in their order, to a CSV file in UTF-8 (
export_csv). The file has the headers, then each value in full, not in itscolumn_formatsformat. In an app, a job writes the file, a window of rows at a time, and the status bar says when the file is done. A new order during the export doesn’t change the file.csv_chunksgives the same text in pieces.export_filenameis the name offered for the file (table.csv).
The study saves the settings and selected_ids. It never saves the rows or the source:
the app gives the view its source again, for example from its command line or its model.
What each backend shows:
Qt shows a
QTableView. Its model asks the view only for the rows that Qt draws.Each row starts with its position, and the header shows Qt’s sort indicator.
A selected row has the highlight colors. Shift+click selects the rows from the row clicked last. Space selects the current row, and Enter activates it.
“Export CSV…”, above the rows and in their context menu, asks where to save the file.
While a job orders the rows, the cursor over them is busy.
Textual draws the headers on the first line, then only the lines on screen.
A cursor marks a row. The arrow keys, Page Up, Page Down, Home, and End move it.
Space selects the row of the cursor, and Shift+Space or Shift+click a range. Enter or a double click activates the row.
The header of the sorted column ends with ▲ or ▼.
The key
easks where to export the rows as CSV.While a job orders the rows, Textual’s loading indicator covers them.
The web scrolls the rows in a frame, and draws only the rows near the view.
Once the user stops scrolling, the page asks for the rows shown. Past 400,000 rows, the scroll bar keeps the height of 400,000 rows, and its position maps to a row by its ratio.
Each header is a button, with
aria-sort. A sort scrolls back to the top.A click or Enter on a row selects it (
aria-selected, oraria-currentfor one row at most). A double click or Enter then activates it. The web selects no range.The “Export CSV” link above the frame downloads the rows. The server streams them, a window at a time.
largetable.py shows a million rows this way.
Rows in SQLite¶
A SqliteSource gives a large table the rows of a query on a SqliteModel. SQLite sorts
and filters the rows. The table reads only the ids of the rows shown, and the rows that it
draws. So the rows never all come to Python.
A CSVSqliteTable gives its own source:
model.readings.source() takes the columns and their types from the row class. For
another table, or a query of your own, give the query and its columns:
from composeui.core.sqlitemodel import SqliteSource
from composeui.core.view import SourceColumn, T
main_view.readings.source = SqliteSource(
model.readings, # a SqliteTable: its connection, and its changed signal
"SELECT * FROM reading",
id_column="id",
columns=[
SourceColumn("sensor", T("Sensor")),
SourceColumn("temperature", T("Temperature"), float),
],
)
columnslists the columns of the query that the table shows, with their types. Adatetime.dateordatetime.datetimecolumn needs nocattrshook of its own. The columns sort and filter as the columns of aListSourcedo. Only these columns sort and filter, because SQLite takes no column name as a parameter. The values of the filters are parameters. Without a sort, the rows come in the order of their ids.The
changedsignal of the table reads the rows again, for example after a job wrote rows.Index the columns that sort. With no search and no filter, SQLite reads the rows in the order of the index. This takes about 0.4 seconds for a million rows, where a sort takes about a second. A search or a filter sorts only the rows that pass it.
The source reads on connections of its own (
SqliteModel.reading). The order and the cells never wait for each other, or for a write, such as a job that writes rows. They read the committed rows. Once a write commits, thechangedsignal of the table reads the rows again.The search and the filters ignore the case of a text, as for a
ListSource, with one difference:SQLite compares a text typed in ASCII only (
COLLATE NOCASE), so an index serves it. It doesn’t find a cell whose letters fold into the text: “strasse” doesn’t find “Straße”.For other text, SQLite calls Python on each row, as for a
/regex/filter: “École” finds “ÉCOLE”.
A sort ignores the case of ASCII letters only.
largetable.py writes its readings into SQLite in a job,
then shows them this way.
Rows in a dataframe¶
A DataFrameSource gives a large table the rows of a dataframe. The frame’s library sorts
and filters the rows with its own expressions, never row by row in Python. It needs the
dataframe extra, which installs narwhals.
Narwhals reads the frames of pandas, Polars, PyArrow, and other libraries through one API.
import polars as pl
from composeui.core.dataframesource import DataFrameSource
frame = pl.read_csv("readings.csv")
main_view.readings.source = DataFrameSource(
frame, {"sensor": "Sensor", "temperature": "Temperature"}
)
The headers select, order, and label the columns. Without them, the table shows every column under its name. The types of the columns come from the frame’s schema: a number, a date, or a boolean sorts and filters as one, any other column as text.
A row’s id is its position in the frame, or the value of its
id_columnwhen the source has one. A lazy frame, such as a PolarsLazyFrame, needs anid_column: its rows have no position of their own. The source then reads only the columns it needs.Assign a new
frameto change the rows. The source emitschanged, and the table reads the rows again. Assign it on the main thread, for example from a job’s result.The cells are Python values. A missing value of pandas (
NaN,NaT, orNA) isNone, and a numpy value is the Python value it holds.The search and the filters ignore the case of a text, as for a
ListSource, with two differences:They compare the text in lower case, which isn’t Python’s
casefold: “strasse” doesn’t find “Straße”.A
/regex/filter is in the syntax of the frame’s library: Rust’sregexfor Polars, Python’srefor pandas.
The same module reads a chart’s series (series_of) and a map’s
points (points_of) from a frame’s columns.
uberpickupsframe.py shows a million pickups of a
Polars frame this way.