Skip to main content

tix/app/
filter.rs

1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4/// An open value picker inside the filter modal: the reused [`DropdownState`] and
5/// the criterion row it is choosing for. `Some` exactly while a list overlays the
6/// modal; at most one exists at a time.
7///
8/// App-owned rather than a field on [`crate::ui::filter_modal::FilterModal`] for the
9/// same reason [`FormPicker`] is: the widget stays a pure view+model, and the
10/// transient list, its key routing and its option sourcing live where the store and
11/// config are reachable.
12#[derive(Debug, Clone)]
13pub(in crate::app) struct FilterPicker {
14    pub(in crate::app) field: FilterField,
15    pub(in crate::app) state: DropdownState,
16}
17
18impl App {
19    pub(in crate::app) fn handle_filter_key(&mut self, key: KeyEvent) -> Result<Action> {
20        match key.code {
21            KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
22                self.filter = None;
23                self.board = BoardState::build_with(
24                    &self.store,
25                    self.active_board_config(),
26                    false,
27                    self.show_children,
28                );
29                self.status_msg = None;
30                self.mode = Mode::Board;
31            }
32            // Commit the search and, when it narrowed the board to a single
33            // displayed issue, open that issue's action menu immediately
34            // (tix #1744) — the same `Enter` the board itself binds. A search
35            // with zero or several results just returns to the board, where the
36            // cursor decides as before.
37            KeyCode::Enter => {
38                self.mode = Mode::Board;
39                if self.board.sole_visible_card().is_some() {
40                    return self.open_card_action_menu();
41                }
42            }
43            KeyCode::Char(c) => {
44                let mut next = match &self.mode {
45                    Mode::Filter { buffer } => buffer.clone(),
46                    _ => String::new(),
47                };
48                next.push(c);
49                self.mode = Mode::Filter {
50                    buffer: next.clone(),
51                };
52                self.apply_filter(&next);
53            }
54            KeyCode::Backspace => {
55                let mut next = match &self.mode {
56                    Mode::Filter { buffer } => buffer.clone(),
57                    _ => String::new(),
58                };
59                next.pop();
60                self.mode = Mode::Filter {
61                    buffer: next.clone(),
62                };
63                self.apply_filter(&next);
64            }
65            _ => {}
66        }
67        Ok(Action::None)
68    }
69
70    /// The structured filter to apply right now: the live in-progress criteria
71    /// while the modal is open, otherwise the committed one.
72    pub(in crate::app) fn active_structured_filter(&self) -> &search::StructuredFilter {
73        self.filter_modal
74            .as_ref()
75            .map(|m| m.active_filter())
76            .unwrap_or(&self.structured)
77    }
78
79    /// Open the structured filter modal seeded from the committed filter.
80    pub(in crate::app) fn open_filter_modal(&mut self) {
81        let active = self
82            .view_session
83            .as_ref()
84            .map(|session| session.marker.clone());
85        let feedback = if active.is_some() {
86            self.active_view_feedback()
87        } else {
88            None
89        };
90        let mut modal = crate::ui::filter_modal::FilterModal::with_filter(self.structured.clone());
91        modal.set_active(active, feedback);
92        self.filter_modal = Some(modal);
93        self.filter_picker = None;
94        self.mode = Mode::FilterModal;
95    }
96
97    /// Open the focused criterion's value picker: a single-select list of the
98    /// values that criterion can hold, or the calendar on a date row.
99    ///
100    /// Criterion rows are picker-only, so this is the one way a value is entered.
101    /// The list starts on the criterion's current value when it has one.
102    pub(in crate::app) fn open_filter_picker(&mut self) {
103        let Some(modal) = self.filter_modal.as_ref() else {
104            return;
105        };
106        let field = modal.focus();
107        if field.is_date() {
108            self.open_date_picker(Mode::FilterModal);
109            return;
110        }
111        let current = modal.focused_value();
112        let mut state = DropdownState::single(self.filter_picker_options(field));
113        state.seed_single(&current);
114        self.filter_picker = Some(FilterPicker { field, state });
115    }
116
117    /// The known values for one criterion, most-trusted source first, each once.
118    ///
119    /// Every list ends with the criterion's current value (see
120    /// [`push_current_option`]), which is what makes removing free text safe: a value
121    /// a user could previously type and have match is still reachable.
122    #[allow(clippy::too_many_lines)]
123    pub(in crate::app) fn filter_picker_options(&self, field: FilterField) -> Vec<PickerItem> {
124        let current = self
125            .filter_modal
126            .as_ref()
127            .map(|modal| modal.focused_value())
128            .unwrap_or_default();
129        match field {
130            // Storage folders are the ground truth for who has issues; the shared
131            // triage helper adds configured team members and the current value.
132            FilterField::Assignee => {
133                crate::ui::triage::assignee_options(&self.store, Some(&current))
134            }
135            // Configured team members, then every reviewer an issue already carries.
136            FilterField::Reviewer => {
137                let mut out = Vec::new();
138                let mut seen = std::collections::BTreeSet::new();
139                let mut push = |value: &str, out: &mut Vec<PickerItem>| {
140                    let value = value.trim();
141                    if value.is_empty() || !seen.insert(value.to_string()) {
142                        return;
143                    }
144                    out.push(PickerItem {
145                        id: value.to_string(),
146                        label: value.to_string(),
147                    });
148                };
149                for team in self.store.config.teams.values() {
150                    for member in &team.members {
151                        push(member, &mut out);
152                    }
153                }
154                for issue in self.store.list().unwrap_or_default() {
155                    if let Some(reviewer) = issue.reviewer.as_deref() {
156                        push(reviewer, &mut out);
157                    }
158                }
159                push_current_option(&mut out, &current);
160                out
161            }
162            // Exactly the active board's columns, in board order.
163            FilterField::Status => {
164                let columns = self.active_board_config().columns.clone();
165                let mut out = crate::ui::triage::status_options(&columns);
166                push_current_option(&mut out, &current);
167                out
168            }
169            // The built-ins first, then every priority the store already uses — the
170            // same "defaults plus what the project actually uses" rule the form's
171            // priority picker follows.
172            FilterField::Priority => {
173                const BUILT_INS: [&str; 3] = ["low", "medium", "high"];
174                let mut out: Vec<PickerItem> = BUILT_INS
175                    .iter()
176                    .map(|priority| PickerItem {
177                        id: (*priority).to_string(),
178                        label: (*priority).to_string(),
179                    })
180                    .collect();
181                for issue in self.store.list().unwrap_or_default() {
182                    let priority = issue.priority.to_string();
183                    if !priority.trim().is_empty() && !out.iter().any(|o| o.id == priority) {
184                        out.push(PickerItem {
185                            id: priority.clone(),
186                            label: priority,
187                        });
188                    }
189                }
190                push_current_option(&mut out, &current);
191                out
192            }
193            // Every label in use, de-duplicated.
194            FilterField::Label => {
195                let mut labels = std::collections::BTreeSet::new();
196                for issue in self.store.list().unwrap_or_default() {
197                    for label in issue.labels {
198                        if !label.trim().is_empty() {
199                            labels.insert(label);
200                        }
201                    }
202                }
203                let mut out: Vec<PickerItem> = labels
204                    .into_iter()
205                    .map(|label| PickerItem {
206                        id: label.clone(),
207                        label,
208                    })
209                    .collect();
210                push_current_option(&mut out, &current);
211                out
212            }
213            FilterField::Cycle => {
214                let root = self.project_root();
215                let planner = Planner::from_config(self.store.config.clone(), root);
216                let mut out: Vec<PickerItem> = planner
217                    .cycle_names()
218                    .into_iter()
219                    .map(|name| PickerItem {
220                        id: name.clone(),
221                        label: name,
222                    })
223                    .collect();
224                push_current_option(&mut out, &current);
225                out
226            }
227            FilterField::Milestone => {
228                let root = self.project_root();
229                let planner = Planner::from_config(self.store.config.clone(), root);
230                let mut out: Vec<PickerItem> = planner
231                    .milestone_names()
232                    .unwrap_or_default()
233                    .into_iter()
234                    .map(|name| PickerItem {
235                        id: name.clone(),
236                        label: name,
237                    })
238                    .collect();
239                push_current_option(&mut out, &current);
240                out
241            }
242            // Every issue is a candidate parent; the criterion stores the canonical
243            // identity and the label carries the human number and title.
244            FilterField::Parent => {
245                let mut out: Vec<PickerItem> = self
246                    .store
247                    .list()
248                    .unwrap_or_default()
249                    .into_iter()
250                    .map(|issue| PickerItem {
251                        id: crate::id::canonical_ref(&issue).to_string(),
252                        label: format!("#{} {}", issue.number, issue.title),
253                    })
254                    .collect();
255                push_current_option(&mut out, &current);
256                out
257            }
258            // The project's own closed vocabulary, in its declared order.
259            FilterField::Category => {
260                let mut out: Vec<PickerItem> = self
261                    .store
262                    .config
263                    .categories
264                    .order
265                    .iter()
266                    .map(|key| PickerItem {
267                        id: key.clone(),
268                        label: key.clone(),
269                    })
270                    .collect();
271                push_current_option(&mut out, &current);
272                out
273            }
274            // Date rows open the calendar, never a list; unreachable in practice
275            // because `open_filter_picker` routes them to the date picker first.
276            FilterField::StartDate | FilterField::DueDate => Vec::new(),
277            // WP06 T026: the service's project list, exactly as the form's picker
278            // lists it — id for the criterion's value, the shared `name (id)` form
279            // for the label — so `project:` in the modal and `project:` in a query
280            // mean the same set.
281            FilterField::Project => {
282                let mut options: Vec<PickerItem> = self
283                    .live_projects()
284                    .into_iter()
285                    .map(|project| PickerItem {
286                        id: project.id.to_string(),
287                        label: crate::projects::project_display(&project.id, &project.name),
288                    })
289                    .collect();
290                push_current_option(&mut options, &current);
291                options
292            }
293            // WP06 T028/T029: the service's epic list, exactly as the form's picker
294            // lists it — id for the criterion's value, the shared `name (id)` form
295            // for the label — so `epic:` in the modal and `epic:` in a query mean the
296            // same set.
297            FilterField::Epic => {
298                let mut options: Vec<PickerItem> = self
299                    .live_epics()
300                    .into_iter()
301                    .map(|epic| PickerItem {
302                        id: epic.id.to_string(),
303                        label: crate::epics::epic_display(&epic.id, &epic.name),
304                    })
305                    .collect();
306                push_current_option(&mut options, &current);
307                options
308            }
309        }
310    }
311
312    /// Write one criterion's value into the open modal's in-progress filter and
313    /// re-render the board live — the preview the spec requires before commit. An
314    /// empty `value` clears the criterion.
315    pub(in crate::app) fn set_filter_criterion(&mut self, field: FilterField, value: &str) {
316        let Some(modal) = self.filter_modal.as_mut() else {
317            return;
318        };
319        let filter = modal.active_filter_mut();
320        if value.is_empty() {
321            field.unset(filter);
322        } else {
323            *field.value_mut(filter) = value.to_string();
324        }
325        self.rebuild_board();
326    }
327
328    /// Keys for the open value picker. Called **before** the binding layer
329    /// canonicalizes (see [`App::handle_key`]), so the list sees the user's real key:
330    /// `l`, `enter` and `F2` would otherwise rewrite to the modal's open-picker
331    /// action and the list could neither be filtered nor chosen.
332    pub(in crate::app) fn handle_filter_picker_key(&mut self, key: KeyEvent) -> Result<Action> {
333        // A filter lists values that exist; `+`/Insert would offer to invent one,
334        // which is not what a filter is for. Swallow it before `DropdownState` turns
335        // it into an inline-create prompt.
336        if matches!(key.code, KeyCode::Char('+') | KeyCode::Insert)
337            && self
338                .filter_picker
339                .as_ref()
340                .is_some_and(|picker| picker.state.mode != PickerMode::InlineCreate)
341        {
342            return Ok(Action::None);
343        }
344        let event = self
345            .filter_picker
346            .as_mut()
347            .map(|picker| (picker.field, picker.state.handle_key(key.code)));
348        let Some((field, event)) = event else {
349            return Ok(Action::None);
350        };
351        match event {
352            PickerEvent::None => {}
353            PickerEvent::Cancelled => self.filter_picker = None,
354            PickerEvent::Selected(ids) => {
355                self.filter_picker = None;
356                let value = ids.first().map(String::as_str).unwrap_or("");
357                self.set_filter_criterion(field, value);
358            }
359            // Inline create is refused above, so a `Created` event cannot arise.
360            PickerEvent::Created(_) => {}
361        }
362        Ok(Action::None)
363    }
364
365    /// Run a filter-modal pointer action recorded during the draw. Public so the
366    /// hit-region tests drive the same path the run loop does.
367    pub fn apply_filter_modal_action(&mut self, action: Action) -> Result<Action> {
368        match action {
369            // A row click focuses it and opens what the key would: the calendar on a
370            // date row, the value list otherwise.
371            Action::FilterModalFocus(field) => {
372                if let Some(modal) = self.filter_modal.as_mut() {
373                    modal.focus_field(field);
374                }
375                self.open_filter_picker();
376            }
377            Action::FilterPickerClose => self.filter_picker = None,
378            Action::FilterPickerOption(row) => {
379                let chosen = self.filter_picker.as_ref().and_then(|picker| {
380                    let index = *picker.state.visible_indices().get(row)?;
381                    let value = picker.state.options.get(index)?.id.clone();
382                    Some((picker.field, value))
383                });
384                self.filter_picker = None;
385                if let Some((field, value)) = chosen {
386                    self.set_filter_criterion(field, &value);
387                }
388            }
389            _ => {}
390        }
391        Ok(Action::None)
392    }
393
394    /// Apply the modal's in-progress criteria to the board and close the modal.
395    ///
396    /// The modal is **ad-hoc**: a selection takes effect on the board the moment it
397    /// is made, and closing **keeps** it. Both exits land here — `esc` and the
398    /// explicit apply key — because there is no discard path left to give `esc` a
399    /// different meaning. `r` clears every criterion and the cleared set is what the
400    /// next close persists.
401    pub(in crate::app) fn close_filter_modal_applying(&mut self) {
402        let mut filter = self
403            .filter_modal
404            .as_ref()
405            .map(|modal| modal.active_filter().clone())
406            .unwrap_or_default();
407        filter.normalize();
408        self.structured = filter;
409        self.filter_modal = None;
410        self.filter_picker = None;
411        self.mode = Mode::Board;
412        self.rebuild_board();
413    }
414
415    /// FilterModal keys, as the binding layer canonicalizes them: `Tab`/`Down` and
416    /// `BackTab`/`Up` move between criterion rows; `r` resets every criterion
417    /// (FR-014); `F(2)` — what `enter`, `l`, `right` and `F2` all rewrite to — opens
418    /// the focused row's value list or the calendar; `Esc` and the apply key (`a`,
419    /// what `Enter` here rewrites to) both close the modal with the current criteria
420    /// applied. Rows take no typed text: the open picker's handler owns typing. All
421    /// criteria run over the corpus in one pass as the query does (NFR-001) via
422    /// [`BoardState::build_filtered`].
423    pub(in crate::app) fn handle_filter_modal_key(&mut self, key: KeyEvent) -> Result<Action> {
424        if self.filter_modal.is_none() {
425            self.mode = Mode::Board;
426            return Ok(Action::None);
427        }
428        match key.code {
429            KeyCode::Esc | KeyCode::Enter => self.close_filter_modal_applying(),
430            KeyCode::Tab | KeyCode::Down => {
431                if let Some(modal) = self.filter_modal.as_mut() {
432                    modal.focus_next();
433                }
434            }
435            KeyCode::BackTab | KeyCode::Up => {
436                if let Some(modal) = self.filter_modal.as_mut() {
437                    modal.focus_prev();
438                }
439            }
440            // The binding layer rewrites `r` and `R` to this one event.
441            KeyCode::Char('r') => {
442                if let Some(modal) = self.filter_modal.as_mut() {
443                    modal.clear();
444                }
445                self.rebuild_board();
446            }
447            KeyCode::F(2) => self.open_filter_picker(),
448            _ => {}
449        }
450        Ok(Action::None)
451    }
452}