Skip to main content

teksilo_widgets/table_view/
filter.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Per-column filter popover UI.
5//!
6//! `HeaderCell` renders a small filter glyph (the unconditional
7//! `FilterIndicator` paint widget) after the sort indicator when the
8//! column is `filterable`. Tapping the glyph opens a [`Popover`]
9//! anchored to it whose content is a `FilterPopoverContent` widget — a
10//! [`TextInput`] with a trailing [`IconButton::clear`] bound to the
11//! table's `filters_signal[col_id]` slot. Callers can also drive
12//! `filters_signal` programmatically.
13//!
14//! [`Popover`]: crate::popover_widget::PopoverWidget
15//! [`TextInput`]: crate::text_input::TextInput
16//! [`IconButton::clear`]: crate::icon_button::IconButton::clear
17
18use teksilo_canvas::{Rect, Size, SizeProposal};
19use teksilo_core::accessibility::AccessNodeBuilder;
20use teksilo_core::widget::{LayoutContext, PaintContext, Widget};
21use teksilo_tokens::TextRole;
22
23/// Tiny header-cell affordance — a stylized funnel glyph that opens
24/// the filter popover when tapped. Tints accent when the column has
25/// an active filter, secondary otherwise.
26pub(crate) struct FilterIndicator {
27    size: f32,
28    active: bool,
29}
30
31impl FilterIndicator {
32    pub(crate) fn new(size: f32, active: bool) -> Self {
33        Self { size, active }
34    }
35}
36
37impl std::fmt::Debug for FilterIndicator {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        f.debug_struct("FilterIndicator")
40            .field("size", &self.size)
41            .field("active", &self.active)
42            .finish()
43    }
44}
45
46impl Widget for FilterIndicator {
47    fn layout_response(
48        &self,
49        _proposal: SizeProposal,
50        _ctx: &LayoutContext,
51    ) -> teksilo_core::widget::LayoutResponse {
52        Size::new(self.size, self.size).into()
53    }
54
55    fn paint(&self, bounds: Rect, canvas: &mut teksilo_canvas::Canvas, ctx: &PaintContext) {
56        let color = if self.active {
57            TextRole::Accent.resolve(&ctx.theme.colors)
58        } else {
59            TextRole::Secondary.resolve(&ctx.theme.colors)
60        };
61        // Funnel: top horizontal bar; two converging diagonals; small
62        // stem at the bottom. Drawn as filled rectangles so it works
63        // without invoking the path pipeline for such a tiny glyph.
64        let cx = bounds.x + bounds.width * 0.5;
65        let cy = bounds.y + bounds.height * 0.5;
66        let r = bounds.width.min(bounds.height) * 0.42;
67        // Top bar
68        canvas.fill_rect(
69            Rect::new(cx - r, cy - r, r * 2.0, (r * 0.30).max(1.0)),
70            color,
71        );
72        // Diagonals approximated with two thin trapezoidal bars.
73        let stem_h = (r * 0.45).max(1.0);
74        let stem_w = (r * 0.30).max(1.0);
75        canvas.fill_rect(
76            Rect::new(cx - stem_w * 0.5, cy - r * 0.4, stem_w, r * 1.05),
77            color,
78        );
79        // Stem
80        canvas.fill_rect(
81            Rect::new(
82                cx - (r * 0.14).max(0.5),
83                cy + r * 0.4,
84                (r * 0.28).max(1.0),
85                stem_h,
86            ),
87            color,
88        );
89    }
90
91    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
92        builder.set_hidden();
93    }
94}
95
96pub(crate) use rich::FilterPopoverContent;
97
98mod rich {
99    use std::cell::Cell;
100    use std::rc::Rc;
101    use teksilo_i18n::lit;
102
103    use teksilo_canvas::{Rect, SizeProposal};
104    use teksilo_core::build_context::BuildContext;
105    use teksilo_core::signal::Signal;
106    use teksilo_core::widget::{LayoutContext, Widget, WidgetPlacement};
107    use teksilo_core::widget_id::WidgetId;
108
109    use crate::icon_button::IconButton;
110    use crate::text_input::TextInput;
111
112    /// Content widget for the per-column filter popover. A
113    /// [`TextInput`] bound to a `Signal<String>` with a trailing
114    /// [`IconButton::clear`] that empties the field — and via the
115    /// `on_change` bridge, the upstream `filters_signal[col_id]` slot.
116    pub(crate) struct FilterPopoverContent {
117        text: Signal<String>,
118        placeholder: String,
119        #[allow(clippy::type_complexity)]
120        on_change: Option<Rc<dyn Fn(&str)>>,
121        /// Slot written to by `build()` with the inner `TextInput`'s
122        /// WidgetId so the Popover's open handler can request focus on
123        /// it immediately.
124        focus_slot: Option<Rc<Cell<Option<WidgetId>>>>,
125        root_child_id: Option<WidgetId>,
126    }
127
128    impl FilterPopoverContent {
129        pub(crate) fn new(initial: impl Into<String>) -> Self {
130            Self {
131                text: Signal::new(initial.into()),
132                placeholder: String::from("Filter…"),
133                on_change: None,
134                focus_slot: None,
135                root_child_id: None,
136            }
137        }
138
139        #[allow(dead_code)]
140        pub(crate) fn placeholder(mut self, text: impl Into<String>) -> Self {
141            self.placeholder = text.into();
142            self
143        }
144
145        pub(crate) fn on_change(mut self, f: impl Fn(&str) + 'static) -> Self {
146            self.on_change = Some(Rc::new(f));
147            self
148        }
149
150        pub(crate) fn focus_slot(mut self, slot: Rc<Cell<Option<WidgetId>>>) -> Self {
151            self.focus_slot = Some(slot);
152            self
153        }
154    }
155
156    impl std::fmt::Debug for FilterPopoverContent {
157        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158            f.debug_struct("FilterPopoverContent")
159                .field("text_len", &self.text.get().len())
160                .field("placeholder", &self.placeholder)
161                .finish()
162        }
163    }
164
165    impl Widget for FilterPopoverContent {
166        fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
167            let text = self.text.clone();
168            let commit = self.on_change.clone();
169
170            // The clear affordance empties the field *and* applies the
171            // cleared filter immediately (commit "" removes the column's
172            // entry from `filters_signal`).
173            let clear = IconButton::clear().embedded().on_activate_fn({
174                let text = text.clone();
175                let commit = commit.clone();
176                move |_| {
177                    text.set(String::new());
178                    if let Some(cb) = commit.as_ref() {
179                        cb("");
180                    }
181                }
182            });
183
184            let mut input = TextInput::new(text.clone())
185                .placeholder(lit!(self.placeholder.clone()))
186                .trailing_slot(clear);
187
188            // Apply the filter on Enter — NOT on every keystroke. Pushing
189            // each character into `filters_signal` re-queries the source
190            // model, which resets the data and rebuilds the owning
191            // TableView; that rebuild tears down this very popover after a
192            // single character. Committing on Enter keeps the popover alive
193            // while the user types the whole term.
194            if let Some(cb) = commit {
195                let text = text.clone();
196                input = input.on_submit_fn(move |_ctx| {
197                    cb(&text.get());
198                });
199            }
200
201            let input_id = ctx.add(input);
202            if let Some(slot) = &self.focus_slot {
203                slot.set(Some(input_id));
204            }
205
206            self.root_child_id = Some(input_id);
207            vec![input_id]
208        }
209
210        fn layout_response(
211            &self,
212            proposal: SizeProposal,
213            ctx: &LayoutContext,
214        ) -> teksilo_core::widget::LayoutResponse {
215            self.root_child_id
216                .and_then(|id| ctx.child_size(id, proposal))
217                .unwrap_or_else(|| proposal.resolve(280.0, 32.0))
218                .into()
219        }
220
221        fn place_children(
222            &self,
223            bounds: Rect,
224            _proposal: SizeProposal,
225            children: &mut [WidgetPlacement],
226            _ctx: &LayoutContext,
227        ) {
228            for child in children.iter_mut() {
229                child.origin = bounds.origin();
230                child.size = bounds.size();
231            }
232        }
233
234        fn children(&self) -> Vec<WidgetId> {
235            self.root_child_id.into_iter().collect()
236        }
237    }
238}