|
4 | 4 |
|
5 | 5 | from typing import TYPE_CHECKING, Any, ClassVar |
6 | 6 |
|
| 7 | +from rich.markup import escape as markup_escape |
7 | 8 | from textual.app import App, ComposeResult |
8 | 9 | from textual.binding import Binding |
9 | 10 | from textual.containers import Horizontal, Vertical, VerticalScroll |
@@ -200,6 +201,8 @@ class HelpScreen(ModalScreen[None]): |
200 | 201 | ("pageup / pagedown", "previous / next page"), |
201 | 202 | ("t / b", "first / last row"), |
202 | 203 | ("g", "go to row..."), |
| 204 | + ("f", "filter rows (CTable)"), |
| 205 | + ("escape", "clear the active filter"), |
203 | 206 | ], |
204 | 207 | ), |
205 | 208 | ( |
@@ -370,6 +373,49 @@ def action_cancel(self) -> None: |
370 | 373 | self.dismiss(None) |
371 | 374 |
|
372 | 375 |
|
| 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 | + |
373 | 419 | class B2ViewApp(App): |
374 | 420 | """Browse TreeStore hierarchy and preview objects.""" |
375 | 421 |
|
@@ -407,6 +453,7 @@ class B2ViewApp(App): |
407 | 453 | Binding("s", "grid_col_start", "Row start", show=False), |
408 | 454 | Binding("e", "grid_col_end", "Row end", show=False), |
409 | 455 | Binding("c", "go_to_column", "Go to column", show=False), |
| 456 | + Binding("f", "filter_rows", "Filter rows", show=False), |
410 | 457 | Binding("d", "dim_cycle", "Dim mode", show=False), |
411 | 458 | Binding("enter", "dim_toggle_nav", "Toggle nav", show=False), |
412 | 459 | Binding("escape", "dim_exit", "Exit dim mode", show=False), |
@@ -456,7 +503,9 @@ def compose(self) -> ComposeResult: |
456 | 503 | yield Static("", id="vlmetadata") |
457 | 504 | with B2ViewPanel(id="data-pane") as data_pane: |
458 | 505 | 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 | + ) |
460 | 509 | yield Static("", id="data-header") |
461 | 510 | with Horizontal(id="data-table-row"): |
462 | 511 | yield BufferedDataTable(id="data-table", show_row_labels=True, zebra_stripes=True) |
@@ -1041,6 +1090,12 @@ def _update_data_header(self, data: dict) -> None: |
1041 | 1090 | header_parts.append(f"rows {data['start']}:{data['stop']} of {data['nrows']}") |
1042 | 1091 | if "col_start" in data: |
1043 | 1092 | 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") |
1044 | 1099 |
|
1045 | 1100 | line = ", ".join(header_parts) |
1046 | 1101 | if self._dim_mode and layout is not None: |
@@ -1161,6 +1216,31 @@ def action_go_to_column(self) -> None: |
1161 | 1216 | screen = GoToColumnScreen(ncols=page["ncols"], current=current, names=names) |
1162 | 1217 | self.push_screen(screen, self._go_to_column) |
1163 | 1218 |
|
| 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 | + |
1164 | 1244 | def _go_to_column(self, col: int | None) -> None: |
1165 | 1245 | if col is None or self.table_page is None: |
1166 | 1246 | return |
@@ -1401,12 +1481,19 @@ def action_dim_toggle_nav(self) -> None: |
1401 | 1481 | self._dim_toggle() |
1402 | 1482 |
|
1403 | 1483 | 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) |
1406 | 1489 | 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("") |
1410 | 1497 |
|
1411 | 1498 | def action_grid_row_top(self) -> None: |
1412 | 1499 | """Jump to the first row of the table.""" |
|
0 commit comments