@@ -296,6 +296,7 @@ pub struct FilterStrip {
296296 state : Rc < RefCell < FilterSet > > ,
297297 columns : Rc < RefCell < Vec < ColumnInfo > > > ,
298298 rebuild : RebuilderSlot ,
299+ raw_entry : gtk:: Entry ,
299300}
300301
301302impl FilterStrip {
@@ -309,32 +310,13 @@ impl FilterStrip {
309310
310311 pub fn toggle ( & self ) {
311312 let opening = !self . is_revealed ( ) ;
312- if opening {
313- // Re-seed an empty editor on every open so the user faces
314- // a ready row instead of a blank list. Idempotent: if any
315- // rule or raw SQL already exists, leave the state alone.
316- self . seed_if_empty ( ) ;
317- }
318313 self . set_revealed ( opening) ;
319- }
320-
321- fn seed_if_empty ( & self ) {
322- let columns = self . columns . borrow ( ) ;
323- let mut state = self . state . borrow_mut ( ) ;
324- if state. rules . is_empty ( )
325- && extra_is_blank ( state. extra_sql . as_deref ( ) )
326- && let Some ( first) = columns. first ( )
327- {
328- state. rules . push ( FilterRule {
329- column : first. name . clone ( ) ,
330- op : FilterOp :: Eq ,
331- value : Some ( FilterValue :: Single ( String :: new ( ) ) ) ,
332- } ) ;
333- drop ( state) ;
334- drop ( columns) ;
335- if let Some ( f) = self . rebuild . borrow ( ) . as_ref ( ) {
336- f ( ) ;
337- }
314+ if opening {
315+ // Drop the cursor in the raw SQL field so the user can
316+ // start typing immediately. Raw is the primary path; the
317+ // rule editor is one expander click away for the click-
318+ // driven case.
319+ self . raw_entry . grab_focus ( ) ;
338320 }
339321 }
340322
@@ -353,7 +335,12 @@ impl FilterStrip {
353335 /// strip (e.g. saved-filter restore on tab open) so the editor
354336 /// reflects what's actually in effect.
355337 pub fn update_filter ( & self , set : FilterSet ) {
338+ let extra = set. extra_sql . clone ( ) . unwrap_or_default ( ) ;
356339 * self . state . borrow_mut ( ) = set;
340+ // Raw entry mirrors state too — without this the entry's
341+ // text still shows the previous raw fragment after a
342+ // FilterApplied that cleared it.
343+ self . raw_entry . set_text ( & extra) ;
357344 if let Some ( f) = self . rebuild . borrow ( ) . as_ref ( ) {
358345 f ( ) ;
359346 }
@@ -455,34 +442,81 @@ pub fn build(columns: Vec<ColumnInfo>, initial: FilterSet, on_apply: Rc<dyn Fn(F
455442 } ;
456443 } ) ;
457444
458- // Rule list — boxed-list of rule rows, no section header.
459- // Implicit title (the strip itself reads as "Filter") + the
460- // header's Match dropdown is enough context. An empty list
461- // gets one auto-seeded rule below so the user always faces a
462- // ready-to-fill row instead of a blank Add Rule banner.
445+ // Rebuild closure — captured by every input-changed callback.
446+ // Drains the rules list, walks `state.rules`, builds a row per
447+ // rule. Re-entrancy guard: a CHANGED signal fired while we're
448+ // rebuilding (programmatic set_text on an EntryRow) would
449+ // re-enter and double-update state. The suppress flag
450+ // short-circuits during rebuild.
451+ let suppress: Rc < std:: cell:: Cell < bool > > = Rc :: new ( std:: cell:: Cell :: new ( false ) ) ;
452+ let rebuild: RebuilderSlot = Rc :: new ( RefCell :: new ( None ) ) ;
453+
454+ // Raw SQL input — primary, always-visible. The strip is aimed
455+ // at developers who already think in WHERE clauses; making them
456+ // expand a section to type SQL would be backwards. Structured
457+ // rules become the secondary affordance below.
458+ let raw_row = gtk:: Box :: builder ( )
459+ . orientation ( gtk:: Orientation :: Horizontal )
460+ . spacing ( 8 )
461+ . build ( ) ;
462+ let where_label = gtk:: Label :: builder ( ) . label ( "WHERE" ) . build ( ) ;
463+ where_label. add_css_class ( "monospace" ) ;
464+ where_label. add_css_class ( "dim-label" ) ;
465+ let raw_entry = gtk:: Entry :: builder ( )
466+ . placeholder_text ( crate :: tr!( "e.g. created_at > now() - interval '1 day'" ) )
467+ . hexpand ( true )
468+ . build ( ) ;
469+ raw_entry. add_css_class ( "monospace" ) ;
470+ raw_entry. set_text ( state. borrow ( ) . extra_sql . as_deref ( ) . unwrap_or ( "" ) ) ;
471+ let state_for_raw = state. clone ( ) ;
472+ let rebuild_for_raw = rebuild. clone ( ) ;
473+ raw_entry. connect_changed ( move |e| {
474+ let text = e. text ( ) . to_string ( ) ;
475+ let trimmed = text. trim ( ) ;
476+ state_for_raw. borrow_mut ( ) . extra_sql = if trimmed. is_empty ( ) { None } else { Some ( text) } ;
477+ // Re-evaluate the Match revealer — combinator visibility
478+ // depends on whether raw + ≥1 rule are both present.
479+ if let Some ( f) = rebuild_for_raw. borrow ( ) . as_ref ( ) {
480+ f ( ) ;
481+ }
482+ } ) ;
483+ let apply_btn_for_enter = apply_btn. clone ( ) ;
484+ raw_entry. connect_activate ( move |_| {
485+ apply_btn_for_enter. activate ( ) ;
486+ } ) ;
487+ raw_row. append ( & where_label) ;
488+ raw_row. append ( & raw_entry) ;
489+ content. append ( & raw_row) ;
490+
491+ // Structured rules — secondary, collapsed by default. Power
492+ // users who think in raw SQL never need to expand this; users
493+ // building filters by clicking get a dropdown-driven editor
494+ // when they reach for it. Pre-expanded only when the saved
495+ // FilterSet already has rules from a previous session.
496+ let rules_expander = gtk:: Expander :: builder ( )
497+ . label ( crate :: tr!( "Or use the rule editor" ) )
498+ . expanded ( !state. borrow ( ) . rules . is_empty ( ) )
499+ . build ( ) ;
500+ let rules_body = gtk:: Box :: builder ( )
501+ . orientation ( gtk:: Orientation :: Vertical )
502+ . spacing ( 8 )
503+ . margin_top ( 8 )
504+ . build ( ) ;
505+ rules_expander. set_child ( Some ( & rules_body) ) ;
506+ content. append ( & rules_expander) ;
507+
463508 let rules_list = gtk:: ListBox :: builder ( ) . selection_mode ( gtk:: SelectionMode :: None ) . build ( ) ;
464509 rules_list. add_css_class ( "boxed-list" ) ;
465- content . append ( & rules_list) ;
510+ rules_body . append ( & rules_list) ;
466511
467- // Inline "Add rule" — small, left-aligned, flat. Replaces the
468- // earlier full-width AdwButtonRow banner that read like a
469- // signpost rather than an action.
512+ // Inline "Add rule" button — small, left-aligned, flat.
470513 let add_rule_btn = gtk:: Button :: builder ( )
471514 . icon_name ( "list-add-symbolic" )
472515 . label ( crate :: tr!( "Add rule" ) )
473516 . halign ( gtk:: Align :: Start )
474517 . build ( ) ;
475518 add_rule_btn. add_css_class ( "flat" ) ;
476- content. append ( & add_rule_btn) ;
477-
478- // Rebuild closure — captured by every input-changed callback.
479- // Drains the list, walks `state.rules`, builds a row per rule,
480- // appends "Add rule" at the end. Re-entrancy guard: a CHANGED
481- // signal fired while we're rebuilding (programmatic set_text on
482- // an EntryRow) would re-enter and double-update state. The
483- // suppress flag short-circuits during rebuild.
484- let suppress: Rc < std:: cell:: Cell < bool > > = Rc :: new ( std:: cell:: Cell :: new ( false ) ) ;
485- let rebuild: RebuilderSlot = Rc :: new ( RefCell :: new ( None ) ) ;
519+ rules_body. append ( & add_rule_btn) ;
486520
487521 {
488522 let rules_list = rules_list. clone ( ) ;
@@ -508,9 +542,14 @@ pub fn build(columns: Vec<ColumnInfo>, initial: FilterSet, on_apply: Rc<dyn Fn(F
508542 ) ;
509543 rules_list. append ( & row) ;
510544 }
511- // Match dropdown is meaningless until there are at least
512- // two rules to combine; reveal it then, hide it otherwise.
513- match_revealer. set_reveal_child ( rules_snapshot. len ( ) >= 2 ) ;
545+ // Match dropdown is meaningful when at least two clauses
546+ // need a combinator — that's ≥2 structured rules, or 1
547+ // structured rule combined with raw SQL. Hide it
548+ // otherwise so the user doesn't see a control that has
549+ // no effect on the resulting WHERE.
550+ let raw_present = !extra_is_blank ( state. borrow ( ) . extra_sql . as_deref ( ) ) ;
551+ let needs_combinator = rules_snapshot. len ( ) >= 2 || ( !rules_snapshot. is_empty ( ) && raw_present) ;
552+ match_revealer. set_reveal_child ( needs_combinator) ;
514553 // Visually mute the entire rules list when empty so the
515554 // strip reads as ready-for-input rather than already-
516555 // populated.
@@ -519,27 +558,6 @@ pub fn build(columns: Vec<ColumnInfo>, initial: FilterSet, on_apply: Rc<dyn Fn(F
519558 } ) ;
520559 * rebuild. borrow_mut ( ) = Some ( closure) ;
521560 }
522-
523- // Auto-seed one rule when the user opens the strip on a new
524- // empty filter — saves a click, makes the strip self-explanatory
525- // (column ▾ op ▾ value entry visible from the moment it slides
526- // in). Skipped when columns aren't loaded yet (the rule would
527- // have nothing to bind to) or when the user already has rules
528- // saved from a previous session.
529- {
530- let columns_borrow = columns. borrow ( ) ;
531- let mut state_mut = state. borrow_mut ( ) ;
532- if state_mut. rules . is_empty ( )
533- && extra_is_blank ( state_mut. extra_sql . as_deref ( ) )
534- && let Some ( first) = columns_borrow. first ( )
535- {
536- state_mut. rules . push ( FilterRule {
537- column : first. name . clone ( ) ,
538- op : FilterOp :: Eq ,
539- value : Some ( FilterValue :: Single ( String :: new ( ) ) ) ,
540- } ) ;
541- }
542- }
543561 if let Some ( f) = rebuild. borrow ( ) . as_ref ( ) {
544562 f ( ) ;
545563 }
@@ -563,43 +581,6 @@ pub fn build(columns: Vec<ColumnInfo>, initial: FilterSet, on_apply: Rc<dyn Fn(F
563581 }
564582 } ) ;
565583
566- // Advanced (raw SQL) — collapsible at the bottom of the strip.
567- // The user types arbitrary SQL appended to the structured rules
568- // with the chosen combinator. No quoting or parameterisation;
569- // identical security model to the SQL editor (the user already
570- // owns the connection — a raw filter is a power feature, not an
571- // injection vector). AdwExpanderRow keeps it out of the way for
572- // the common case where structured rules are enough.
573- // Advanced expander — collapsed by default unless the user has
574- // a saved raw fragment. Dropping the long subtitle in favour of
575- // a tooltip — the title alone is enough hint for the few users
576- // who reach for raw SQL.
577- let advanced_group = adw:: PreferencesGroup :: builder ( ) . build ( ) ;
578- let advanced_row = adw:: ExpanderRow :: builder ( )
579- . title ( crate :: tr!( "Advanced (raw SQL)" ) )
580- . expanded (
581- state
582- . borrow ( )
583- . extra_sql
584- . as_deref ( )
585- . is_some_and ( |s| !s. trim ( ) . is_empty ( ) ) ,
586- )
587- . build ( ) ;
588- advanced_row. set_tooltip_text ( Some ( & crate :: tr!(
589- "Raw SQL fragment appended to the rules with the combinator above."
590- ) ) ) ;
591- let extra_entry = adw:: EntryRow :: builder ( ) . title ( crate :: tr!( "Raw SQL" ) ) . build ( ) ;
592- extra_entry. set_text ( state. borrow ( ) . extra_sql . as_deref ( ) . unwrap_or ( "" ) ) ;
593- let state_for_extra = state. clone ( ) ;
594- extra_entry. connect_changed ( move |e| {
595- let text = e. text ( ) . to_string ( ) ;
596- let trimmed = text. trim ( ) ;
597- state_for_extra. borrow_mut ( ) . extra_sql = if trimmed. is_empty ( ) { None } else { Some ( text) } ;
598- } ) ;
599- advanced_row. add_row ( & extra_entry) ;
600- advanced_group. add ( & advanced_row) ;
601- content. append ( & advanced_group) ;
602-
603584 let scroller = gtk:: ScrolledWindow :: builder ( )
604585 . child ( & content)
605586 . hscrollbar_policy ( gtk:: PolicyType :: Never )
@@ -651,6 +632,7 @@ pub fn build(columns: Vec<ColumnInfo>, initial: FilterSet, on_apply: Rc<dyn Fn(F
651632 state,
652633 columns,
653634 rebuild,
635+ raw_entry,
654636 }
655637}
656638
0 commit comments