Skip to content

Commit 6779b7b

Browse files
committed
New f// keys filter CTable rows and columns in the data panel
`f` takes the same string expressions as CTable.where() (dotted nested names, and/or) and pages through the matching view; `/` narrows the visible columns by case-insensitive substring, and column paging plus the `c` goto-column modal operate on that subset. Both filters combine freely, are remembered per node for the session, and are shown in the data header along with the unfiltered totals; escape clears one layer per press (dim mode, then rows, then columns).
1 parent 86ef715 commit 6779b7b

5 files changed

Lines changed: 183 additions & 18 deletions

File tree

doc/getting_started/b2view.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,12 @@ navigation (paging, ``g``, ``t`` / ``b``) then operates on the filtered
9292
rows. Press ``escape`` (or submit an empty expression) to go back to the
9393
unfiltered table; each node remembers its filter for the session.
9494

95+
Columns can be filtered too: press ``/`` and type a case-insensitive
96+
substring (e.g. ``payment``) to show only the matching columns — column
97+
paging and the ``c`` goto-column modal then operate on that subset. Row
98+
and column filters combine freely; ``escape`` clears them one layer at a
99+
time (row filter first, then columns).
100+
95101
CLI options
96102
-----------
97103

src/blosc2/b2view/app.py

Lines changed: 64 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ class HelpScreen(ModalScreen[None]):
211211
("left / right", "move cursor; pages at the edges"),
212212
("s / e (home / end)", "first / last column window"),
213213
("c", "go to column index or name..."),
214+
("/", "filter visible columns by substring (CTable)"),
214215
],
215216
),
216217
(
@@ -374,7 +375,7 @@ def action_cancel(self) -> None:
374375

375376

376377
class FilterScreen(ModalScreen[str | None]):
377-
"""Small modal asking for a CTable row filter expression."""
378+
"""Small modal asking for a CTable filter (row expression or column pattern)."""
378379

379380
CSS = """
380381
FilterScreen {
@@ -395,14 +396,22 @@ class FilterScreen(ModalScreen[str | None]):
395396

396397
BINDINGS: ClassVar = [("escape", "cancel", "Cancel")]
397398

398-
def __init__(self, *, current: str | None = None):
399+
def __init__(
400+
self,
401+
*,
402+
current: str | None = None,
403+
title: str = "Filter rows (empty clears)",
404+
placeholder: str = "e.g. payment.tips > 100 and trip.km > 0",
405+
):
399406
super().__init__()
400407
self.current = current or ""
408+
self.title_text = title
409+
self.placeholder = placeholder
401410

402411
def compose(self) -> ComposeResult:
403412
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")
413+
yield Static(self.title_text, id="filter-title")
414+
yield Input(placeholder=self.placeholder, id="filter-input")
406415

407416
def on_mount(self) -> None:
408417
input_widget = self.query_one("#filter-input", Input)
@@ -454,6 +463,7 @@ class B2ViewApp(App):
454463
Binding("e", "grid_col_end", "Row end", show=False),
455464
Binding("c", "go_to_column", "Go to column", show=False),
456465
Binding("f", "filter_rows", "Filter rows", show=False),
466+
Binding("slash", "filter_columns", "Filter columns", show=False),
457467
Binding("d", "dim_cycle", "Dim mode", show=False),
458468
Binding("enter", "dim_toggle_nav", "Toggle nav", show=False),
459469
Binding("escape", "dim_exit", "Exit dim mode", show=False),
@@ -503,9 +513,7 @@ def compose(self) -> ComposeResult:
503513
yield Static("", id="vlmetadata")
504514
with B2ViewPanel(id="data-pane") as data_pane:
505515
data_pane.border_title = "data"
506-
data_pane.border_subtitle = (
507-
"?(help) | d(im mode) | f(ilter) | rows: t/b/g(oto) | cols: s/e/c(goto)"
508-
)
516+
data_pane.border_subtitle = "?(help) | d(im mode) | filter: f(rows) /(cols) | rows: t/b/g(oto) | cols: s/e/c(goto)"
509517
yield Static("", id="data-header")
510518
with Horizontal(id="data-table-row"):
511519
yield BufferedDataTable(id="data-table", show_row_labels=True, zebra_stripes=True)
@@ -1092,10 +1100,15 @@ def _update_data_header(self, data: dict) -> None:
10921100
header_parts.append(f"cols {data['col_start']}:{data['col_stop']} of {data['ncols']}")
10931101
if data.get("source_kind") == "ctable" and self.browser is not None:
10941102
flt = self.browser.get_filter(self.selected_path)
1103+
col_flt = self.browser.get_column_filter(self.selected_path)
10951104
if flt:
10961105
total = self.browser.base_nrows(self.selected_path)
10971106
header_parts.append(f"filter: [bold]{markup_escape(flt)}[/bold] ({total} total)")
1098-
header_parts.append("<f>edit <Esc>clear")
1107+
if col_flt:
1108+
total_cols = self.browser.base_ncols(self.selected_path)
1109+
header_parts.append(f"cols: [bold]{markup_escape(col_flt)}[/bold] ({total_cols} total)")
1110+
if flt or col_flt:
1111+
header_parts.append("<Esc>clear")
10991112

11001113
line = ", ".join(header_parts)
11011114
if self._dim_mode and layout is not None:
@@ -1241,6 +1254,37 @@ def _apply_filter(self, expr: str | None) -> None:
12411254
self._update_data_header(data)
12421255
self.query_one("#data-table", DataTable).focus()
12431256

1257+
def action_filter_columns(self) -> None:
1258+
if not self._in_data_grid():
1259+
return
1260+
if self.table_page.get("source_kind") != "ctable":
1261+
self.notify("Column filtering is only supported for CTable nodes", severity="warning")
1262+
return
1263+
screen = FilterScreen(
1264+
current=self.browser.get_column_filter(self.selected_path),
1265+
title="Filter columns by substring (empty clears)",
1266+
placeholder="e.g. payment",
1267+
)
1268+
self.push_screen(screen, self._apply_column_filter)
1269+
1270+
def _apply_column_filter(self, pattern: str | None) -> None:
1271+
if pattern is None or self.browser is None or self.table_page is None:
1272+
return
1273+
if pattern == (self.browser.get_column_filter(self.selected_path) or ""):
1274+
return
1275+
try:
1276+
self.browser.set_column_filter(self.selected_path, pattern)
1277+
except Exception as exc:
1278+
self.notify(f"Invalid column filter: {exc}", severity="error")
1279+
return
1280+
self.grid_col_start = 0
1281+
self.table_buffer = None
1282+
data = self._load_table_page(self.selected_path, self.table_page["start"])
1283+
cursor_row = self.query_one("#data-table", DataTable).cursor_row
1284+
self._update_data_table(data, cursor_row=cursor_row, cursor_col=0)
1285+
self._update_data_header(data)
1286+
self.query_one("#data-table", DataTable).focus()
1287+
12441288
def _go_to_column(self, col: int | None) -> None:
12451289
if col is None or self.table_page is None:
12461290
return
@@ -1481,19 +1525,26 @@ def action_dim_toggle_nav(self) -> None:
14811525
self._dim_toggle()
14821526

14831527
def action_dim_exit(self) -> None:
1484-
"""Escape: exit dim mode, or clear an active CTable filter."""
1528+
"""Escape: exit dim mode, or clear an active CTable filter.
1529+
1530+
One layer per press: dim mode, then the row filter, then the
1531+
column filter.
1532+
"""
14851533
if self._dim_mode:
14861534
self._dim_mode = False
14871535
if self.table_page is not None:
14881536
self._update_data_header(self.table_page)
14891537
return
14901538
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)
1539+
not self._in_data_grid()
1540+
or self.table_page.get("source_kind") != "ctable"
1541+
or self.browser is None
14951542
):
1543+
return
1544+
if self.browser.get_filter(self.selected_path):
14961545
self._apply_filter("")
1546+
elif self.browser.get_column_filter(self.selected_path):
1547+
self._apply_column_filter("")
14971548

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

src/blosc2/b2view/model.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,9 @@ def __init__(self, urlpath: str):
139139
# Per-path row filters for CTable nodes (path -> expr / where() view)
140140
self._filters: dict[str, str] = {}
141141
self._filter_views: dict[str, Any] = {}
142+
# Per-path column filters (path -> substring pattern / matched names)
143+
self._column_filters: dict[str, str] = {}
144+
self._column_selections: dict[str, list[str]] = {}
142145

143146
def close(self) -> None:
144147
close = getattr(self.store, "close", None)
@@ -264,6 +267,8 @@ def preview(
264267
return preview_array(obj, slices=slices, max_rows=max_rows, max_cols=max_cols)
265268
if kind == "ctable":
266269
obj = self._filter_views.get(path, obj)
270+
if columns is None:
271+
columns = self._column_selections.get(path)
267272
stop = min(start + max_rows, len(obj)) if stop is None else stop
268273
return preview_ctable(
269274
obj, start=start, stop=stop, columns=columns, max_cols=max_cols, col_start=col_start
@@ -273,7 +278,15 @@ def preview(
273278
return {"message": f"Preview is not supported for {kind!r} objects."}
274279

275280
def column_names(self, path: str) -> list[str] | None:
276-
"""Return the column names for a CTable path, or None for other kinds."""
281+
"""Return the column names for a CTable path, or None for other kinds.
282+
283+
When a column filter is active, only the matching names are returned
284+
(navigation operates on the filtered universe).
285+
"""
286+
path = self.normalize_path(path)
287+
selection = self._column_selections.get(path)
288+
if selection is not None:
289+
return list(selection)
277290
names = list(getattr(self._get_object(path), "col_names", []) or [])
278291
return names or None
279292

@@ -302,6 +315,37 @@ def base_nrows(self, path: str) -> int:
302315
"""Return the unfiltered row count of the CTable at *path*."""
303316
return len(self._get_object(path))
304317

318+
def set_column_filter(self, path: str, pattern: str | None) -> int:
319+
"""Set or clear the column filter of a CTable path; return the match count.
320+
321+
Columns are matched by case-insensitive substring, keeping the table
322+
order. An empty (or None) *pattern* clears the filter. A pattern
323+
matching no column raises ValueError and leaves any previous filter
324+
untouched.
325+
"""
326+
path = self.normalize_path(path)
327+
pattern = (pattern or "").strip()
328+
all_names = list(getattr(self._get_object(path), "col_names", []) or [])
329+
if not pattern:
330+
self._column_filters.pop(path, None)
331+
self._column_selections.pop(path, None)
332+
return len(all_names)
333+
needle = pattern.lower()
334+
selection = [name for name in all_names if needle in name.lower()]
335+
if not selection:
336+
raise ValueError(f"no column matches {pattern!r}")
337+
self._column_filters[path] = pattern
338+
self._column_selections[path] = selection
339+
return len(selection)
340+
341+
def get_column_filter(self, path: str) -> str | None:
342+
"""Return the active column filter pattern for *path*, if any."""
343+
return self._column_filters.get(self.normalize_path(path))
344+
345+
def base_ncols(self, path: str) -> int:
346+
"""Return the unfiltered column count of the CTable at *path*."""
347+
return len(list(getattr(self._get_object(path), "col_names", []) or []))
348+
305349
def _get_object(self, path: str) -> Any:
306350
"""Return the object represented by *path*."""
307351
path = self.normalize_path(path)

tests/b2view/test_basics.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,3 +546,53 @@ async def submit_filter(expr: str) -> None:
546546
await wait_for_table(pilot)
547547
assert app.browser.get_filter("/level0/ctable") is None
548548
assert app.table_page["nrows"] == NROWS
549+
550+
# ── Column filtering ('/' modal) ─────────────────────────────────
551+
552+
async def submit_column_filter(pattern: str) -> None:
553+
await pilot.press("slash")
554+
await pilot.pause()
555+
assert isinstance(app.screen, FilterScreen)
556+
app.screen.query_one("#filter-input", Input).value = pattern
557+
await pilot.press("enter")
558+
await wait_for_table(pilot)
559+
560+
# 'v1' matches v10..v19; paging universe shrinks to those 10 columns
561+
await submit_column_filter("v1")
562+
page = app.table_page
563+
assert page["ncols"] == 10
564+
assert page["columns"][0] == "v10"
565+
assert all(name.startswith("v1") for name in page["columns"])
566+
567+
# The goto-column modal resolves names within the filtered set
568+
await pilot.press("c")
569+
await pilot.pause()
570+
app.screen.query_one("#gotocol-input", Input).value = "v15"
571+
await pilot.press("enter")
572+
await wait_for_table(pilot)
573+
assert app.table_page["columns"][0] == "v15"
574+
575+
# Row and column filters combine (back at the first column window)
576+
await pilot.press("s")
577+
await wait_for_table(pilot)
578+
await submit_filter("b >= 100 and b < 110")
579+
page = app.table_page
580+
assert page["nrows"] == 10
581+
assert page["ncols"] == 10
582+
np.testing.assert_array_equal(page["data"]["v10"], expected["v10"][100:110])
583+
584+
# A pattern matching nothing notifies and keeps the selection
585+
await submit_column_filter("nosuchcol")
586+
assert app.browser.get_column_filter("/level0/ctable") == "v1"
587+
assert app.table_page["ncols"] == 10
588+
589+
# Escape clears one layer at a time: row filter first, then columns
590+
await pilot.press("escape")
591+
await wait_for_table(pilot)
592+
assert app.browser.get_filter("/level0/ctable") is None
593+
assert app.table_page["nrows"] == NROWS
594+
assert app.table_page["ncols"] == 10
595+
await pilot.press("escape")
596+
await wait_for_table(pilot)
597+
assert app.browser.get_column_filter("/level0/ctable") is None
598+
assert app.table_page["ncols"] == len(expected)

todo/b2view.md

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,6 @@ Tests live in `tests/b2view/` (marker `tui`); see the note at the top of
1111

1212
### Navigation
1313

14-
- [ ] Column-name search/filter for wide CTables (e.g. `/` to filter the
15-
visible columns by substring). Note: the `c` goto-column modal already
16-
resolves unique name prefixes; this item is about *filtering* the
17-
visible set, not jumping.
1814
- [ ] Row paging can lose page alignment after dim-mode single-row scrolls
1915
(`_scroll_navigable_viewport` shifts by 1); consider re-aligning on the
2016
next page up/down, as column paging does now.
@@ -73,3 +69,21 @@ Tests live in `tests/b2view/` (marker `tui`); see the note at the top of
7369
caught that the resize handler lived on the App, which never receives
7470
Resize events; moved to BufferedDataTable.on_resize, so the windows now
7571
re-fit on terminal resize and panel maximize/restore for real.
72+
- 2026-06-12: The terminal owns the mouse by default, so native text
73+
selection/copy works; `--mouse` lets b2view capture it instead
74+
(click-to-focus, wheel scrolls the data grid by half a page, paging at
75+
the edges via the same path as the arrow keys).
76+
- 2026-06-12: Dim-mode index/viewport movements clamp at the boundaries
77+
instead of wrapping (left/right dimension *selection* still cycles);
78+
navigable viewports clamp to the last full page / whole-column window.
79+
- 2026-06-12: CTable row filtering — `f` opens a modal that takes the same
80+
string expressions as `CTable.where()` (dotted nested names, and/or) and
81+
pages through the matching view; escape or an empty expression clears,
82+
filters are remembered per node (`StoreBrowser.set_filter`), and the data
83+
header shows the active filter plus the unfiltered total.
84+
- 2026-06-12: CTable column filtering — `/` filters the visible columns by
85+
case-insensitive substring (`StoreBrowser.set_column_filter`); column
86+
paging, the two-pass width fit and the `c` goto-column modal all operate
87+
on the filtered subset (`preview_ctable` already took a `columns`
88+
universe). Combines freely with the row filter; escape clears one layer
89+
per press (dim mode, then rows, then columns).

0 commit comments

Comments
 (0)