Skip to content

Commit 86ef715

Browse files
committed
New f key filters CTable rows in the data panel
Filters use the same string expressions as CTable.where() (dotted nested names, and/or) and page through the matching view; escape or an empty expression restores the full table, and each node remembers its filter for the session.
1 parent d64d858 commit 86ef715

4 files changed

Lines changed: 198 additions & 7 deletions

File tree

doc/getting_started/b2view.rst

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,22 @@ select the active dimension, ``up`` / ``down`` change its fixed index (or
7676
scroll the viewport), ``enter`` toggles a dimension between fixed and
7777
navigable, and ``escape`` leaves dim mode.
7878

79+
Step 4 — Filter CTable rows
80+
---------------------------
81+
82+
On a CTable node, press ``f`` and type a filter expression to page through
83+
only the matching rows — the same expressions ``CTable.where()`` accepts,
84+
including dotted nested column names and ``and`` / ``or``:
85+
86+
.. code-block:: text
87+
88+
payment.tips > 100 and trip.km > 0 and trip.sec > 0
89+
90+
The data header shows the active filter and the matching row count; all
91+
navigation (paging, ``g``, ``t`` / ``b``) then operates on the filtered
92+
rows. Press ``escape`` (or submit an empty expression) to go back to the
93+
unfiltered table; each node remembers its filter for the session.
94+
7995
CLI options
8096
-----------
8197

src/blosc2/b2view/app.py

Lines changed: 93 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from typing import TYPE_CHECKING, Any, ClassVar
66

7+
from rich.markup import escape as markup_escape
78
from textual.app import App, ComposeResult
89
from textual.binding import Binding
910
from textual.containers import Horizontal, Vertical, VerticalScroll
@@ -200,6 +201,8 @@ class HelpScreen(ModalScreen[None]):
200201
("pageup / pagedown", "previous / next page"),
201202
("t / b", "first / last row"),
202203
("g", "go to row..."),
204+
("f", "filter rows (CTable)"),
205+
("escape", "clear the active filter"),
203206
],
204207
),
205208
(
@@ -370,6 +373,49 @@ def action_cancel(self) -> None:
370373
self.dismiss(None)
371374

372375

376+
class FilterScreen(ModalScreen[str | None]):
377+
"""Small modal asking for a CTable row filter expression."""
378+
379+
CSS = """
380+
FilterScreen {
381+
align: center middle;
382+
}
383+
#filter-dialog {
384+
width: 70;
385+
height: auto;
386+
border: thick $accent;
387+
background: $surface;
388+
padding: 1 2;
389+
}
390+
#filter-title {
391+
text-style: bold;
392+
margin-bottom: 1;
393+
}
394+
"""
395+
396+
BINDINGS: ClassVar = [("escape", "cancel", "Cancel")]
397+
398+
def __init__(self, *, current: str | None = None):
399+
super().__init__()
400+
self.current = current or ""
401+
402+
def compose(self) -> ComposeResult:
403+
with Vertical(id="filter-dialog"):
404+
yield Static("Filter rows (empty clears)", id="filter-title")
405+
yield Input(placeholder="e.g. payment.tips > 100 and trip.km > 0", id="filter-input")
406+
407+
def on_mount(self) -> None:
408+
input_widget = self.query_one("#filter-input", Input)
409+
input_widget.value = self.current
410+
input_widget.focus()
411+
412+
def on_input_submitted(self, event: Input.Submitted) -> None:
413+
self.dismiss(event.value.strip())
414+
415+
def action_cancel(self) -> None:
416+
self.dismiss(None)
417+
418+
373419
class B2ViewApp(App):
374420
"""Browse TreeStore hierarchy and preview objects."""
375421

@@ -407,6 +453,7 @@ class B2ViewApp(App):
407453
Binding("s", "grid_col_start", "Row start", show=False),
408454
Binding("e", "grid_col_end", "Row end", show=False),
409455
Binding("c", "go_to_column", "Go to column", show=False),
456+
Binding("f", "filter_rows", "Filter rows", show=False),
410457
Binding("d", "dim_cycle", "Dim mode", show=False),
411458
Binding("enter", "dim_toggle_nav", "Toggle nav", show=False),
412459
Binding("escape", "dim_exit", "Exit dim mode", show=False),
@@ -456,7 +503,9 @@ def compose(self) -> ComposeResult:
456503
yield Static("", id="vlmetadata")
457504
with B2ViewPanel(id="data-pane") as data_pane:
458505
data_pane.border_title = "data"
459-
data_pane.border_subtitle = "?(help) | d(im mode) | rows: t/b/g(oto) | cols: s/e/c(goto)"
506+
data_pane.border_subtitle = (
507+
"?(help) | d(im mode) | f(ilter) | rows: t/b/g(oto) | cols: s/e/c(goto)"
508+
)
460509
yield Static("", id="data-header")
461510
with Horizontal(id="data-table-row"):
462511
yield BufferedDataTable(id="data-table", show_row_labels=True, zebra_stripes=True)
@@ -1041,6 +1090,12 @@ def _update_data_header(self, data: dict) -> None:
10411090
header_parts.append(f"rows {data['start']}:{data['stop']} of {data['nrows']}")
10421091
if "col_start" in data:
10431092
header_parts.append(f"cols {data['col_start']}:{data['col_stop']} of {data['ncols']}")
1093+
if data.get("source_kind") == "ctable" and self.browser is not None:
1094+
flt = self.browser.get_filter(self.selected_path)
1095+
if flt:
1096+
total = self.browser.base_nrows(self.selected_path)
1097+
header_parts.append(f"filter: [bold]{markup_escape(flt)}[/bold] ({total} total)")
1098+
header_parts.append("<f>edit <Esc>clear")
10441099

10451100
line = ", ".join(header_parts)
10461101
if self._dim_mode and layout is not None:
@@ -1161,6 +1216,31 @@ def action_go_to_column(self) -> None:
11611216
screen = GoToColumnScreen(ncols=page["ncols"], current=current, names=names)
11621217
self.push_screen(screen, self._go_to_column)
11631218

1219+
def action_filter_rows(self) -> None:
1220+
if not self._in_data_grid():
1221+
return
1222+
if self.table_page.get("source_kind") != "ctable":
1223+
self.notify("Filtering is only supported for CTable nodes", severity="warning")
1224+
return
1225+
screen = FilterScreen(current=self.browser.get_filter(self.selected_path))
1226+
self.push_screen(screen, self._apply_filter)
1227+
1228+
def _apply_filter(self, expr: str | None) -> None:
1229+
if expr is None or self.browser is None or self.table_page is None:
1230+
return
1231+
if expr == (self.browser.get_filter(self.selected_path) or ""):
1232+
return
1233+
try:
1234+
self.browser.set_filter(self.selected_path, expr)
1235+
except Exception as exc:
1236+
self.notify(f"Invalid filter: {exc}", severity="error")
1237+
return
1238+
self.table_buffer = None
1239+
data = self._load_table_page(self.selected_path, 0)
1240+
self._update_data_table(data, cursor_row=0, cursor_col=0)
1241+
self._update_data_header(data)
1242+
self.query_one("#data-table", DataTable).focus()
1243+
11641244
def _go_to_column(self, col: int | None) -> None:
11651245
if col is None or self.table_page is None:
11661246
return
@@ -1401,12 +1481,19 @@ def action_dim_toggle_nav(self) -> None:
14011481
self._dim_toggle()
14021482

14031483
def action_dim_exit(self) -> None:
1404-
"""Escape: exit dim mode."""
1405-
if not self._dim_mode:
1484+
"""Escape: exit dim mode, or clear an active CTable filter."""
1485+
if self._dim_mode:
1486+
self._dim_mode = False
1487+
if self.table_page is not None:
1488+
self._update_data_header(self.table_page)
14061489
return
1407-
self._dim_mode = False
1408-
if self.table_page is not None:
1409-
self._update_data_header(self.table_page)
1490+
if (
1491+
self._in_data_grid()
1492+
and self.table_page.get("source_kind") == "ctable"
1493+
and self.browser is not None
1494+
and self.browser.get_filter(self.selected_path)
1495+
):
1496+
self._apply_filter("")
14101497

14111498
def action_grid_row_top(self) -> None:
14121499
"""Jump to the first row of the table."""

src/blosc2/b2view/model.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,9 @@ def __init__(self, urlpath: str):
136136
self.urlpath = urlpath
137137
self.store = blosc2.open(urlpath, mode="r")
138138
self.is_tree = isinstance(self.store, blosc2.TreeStore)
139+
# Per-path row filters for CTable nodes (path -> expr / where() view)
140+
self._filters: dict[str, str] = {}
141+
self._filter_views: dict[str, Any] = {}
139142

140143
def close(self) -> None:
141144
close = getattr(self.store, "close", None)
@@ -260,6 +263,7 @@ def preview(
260263
return preview_array_1d(obj, start=start, stop=stop)
261264
return preview_array(obj, slices=slices, max_rows=max_rows, max_cols=max_cols)
262265
if kind == "ctable":
266+
obj = self._filter_views.get(path, obj)
263267
stop = min(start + max_rows, len(obj)) if stop is None else stop
264268
return preview_ctable(
265269
obj, start=start, stop=stop, columns=columns, max_cols=max_cols, col_start=col_start
@@ -273,6 +277,31 @@ def column_names(self, path: str) -> list[str] | None:
273277
names = list(getattr(self._get_object(path), "col_names", []) or [])
274278
return names or None
275279

280+
def set_filter(self, path: str, expr: str | None) -> int:
281+
"""Set or clear the row filter of a CTable path; return its row count.
282+
283+
An empty (or None) *expr* clears the filter. Errors from ``where()``
284+
propagate to the caller and leave any previous filter untouched.
285+
"""
286+
path = self.normalize_path(path)
287+
expr = (expr or "").strip()
288+
if not expr:
289+
self._filters.pop(path, None)
290+
self._filter_views.pop(path, None)
291+
return len(self._get_object(path))
292+
view = self._get_object(path).where(expr)
293+
self._filters[path] = expr
294+
self._filter_views[path] = view
295+
return len(view)
296+
297+
def get_filter(self, path: str) -> str | None:
298+
"""Return the active filter expression for *path*, if any."""
299+
return self._filters.get(self.normalize_path(path))
300+
301+
def base_nrows(self, path: str) -> int:
302+
"""Return the unfiltered row count of the CTable at *path*."""
303+
return len(self._get_object(path))
304+
276305
def _get_object(self, path: str) -> Any:
277306
"""Return the object represented by *path*."""
278307
path = self.normalize_path(path)

tests/b2view/test_basics.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
import tree_store_gen as gen
4040
from textual.widgets import DataTable, Input, Tree
4141

42-
from blosc2.b2view.app import B2ViewApp, GoToColumnScreen, GoToRowScreen, HelpScreen
42+
from blosc2.b2view.app import B2ViewApp, FilterScreen, GoToColumnScreen, GoToRowScreen, HelpScreen
4343

4444
pytestmark = [pytest.mark.asyncio, pytest.mark.tui]
4545

@@ -487,3 +487,62 @@ async def test_ctable_column_paging(store_path):
487487
assert page["viewport_width"] == table.size.width
488488
assert len(page["columns"]) < len(wide_columns)
489489
assert table.virtual_size.width <= table.size.width
490+
491+
492+
# ── CTable filtering ─────────────────────────────────────────────────────
493+
494+
495+
async def test_ctable_filtering(store_path):
496+
"""The 'f' modal filters CTable rows; errors and clearing keep state sane."""
497+
app = B2ViewApp(store_path, start_path="/level0/ctable", start_panel="data")
498+
async with app.run_test(size=TERM_SIZE) as pilot:
499+
await wait_for_table(pilot)
500+
await focus_data_table(pilot)
501+
expected = gen.ctable_values(NROWS)
502+
503+
async def submit_filter(expr: str) -> None:
504+
await pilot.press("f")
505+
await pilot.pause()
506+
assert isinstance(app.screen, FilterScreen)
507+
app.screen.query_one("#filter-input", Input).value = expr
508+
await pilot.press("enter")
509+
await wait_for_table(pilot)
510+
511+
# Apply a filter: rows with b in [100, 110) (column b holds 0..NROWS-1)
512+
await submit_filter("b >= 100 and b < 110")
513+
page = app.table_page
514+
assert page["nrows"] == 10
515+
np.testing.assert_array_equal(page["data"]["b"], expected["b"][100:110])
516+
np.testing.assert_allclose(page["data"]["c"], expected["c"][100:110])
517+
518+
# An invalid expression notifies and keeps the previous filter
519+
await submit_filter("nosuchcol > 1")
520+
assert app.browser.get_filter("/level0/ctable") == "b >= 100 and b < 110"
521+
assert app.table_page["nrows"] == 10
522+
523+
# Re-opening the modal prefills the active filter; escape cancels
524+
await pilot.press("f")
525+
await pilot.pause()
526+
assert app.screen.query_one("#filter-input", Input).value == "b >= 100 and b < 110"
527+
await pilot.press("escape")
528+
await wait_for_table(pilot)
529+
assert app.table_page["nrows"] == 10
530+
531+
# A filter matching nothing yields an empty (but live) grid
532+
await submit_filter("b < 0")
533+
assert app.table_page["nrows"] == 0
534+
535+
# An empty input clears the filter and restores the full table
536+
await submit_filter("")
537+
page = app.table_page
538+
assert app.browser.get_filter("/level0/ctable") is None
539+
assert page["nrows"] == NROWS
540+
np.testing.assert_array_equal(page["data"]["b"], expected["b"][: page["stop"]])
541+
542+
# Escape on the data grid also clears an active filter
543+
await submit_filter("b >= 100 and b < 110")
544+
assert app.table_page["nrows"] == 10
545+
await pilot.press("escape")
546+
await wait_for_table(pilot)
547+
assert app.browser.get_filter("/level0/ctable") is None
548+
assert app.table_page["nrows"] == NROWS

0 commit comments

Comments
 (0)