@@ -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
376377class 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."""
0 commit comments