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(¤t);
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(¤t))
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, ¤t);
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, ¤t);
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, ¤t);
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, ¤t);
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, ¤t);
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, ¤t);
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, ¤t);
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, ¤t);
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, ¤t);
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, ¤t);
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}