Skip to main content

tix/app/
views.rs

1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4impl App {
5    /// The project ids a view's project scope resolves against.
6    ///
7    /// The TUI has no first-class project registry in this build — the same
8    /// reason `commands::views::project_directory` returns nothing — so a
9    /// project-scoped view is refused by the registry's own diagnostic rather
10    /// than silently accepted. When the project registry lands, only this
11    /// function changes.
12    pub(in crate::app) fn view_projects() -> BTreeSet<String> {
13        BTreeSet::new()
14    }
15
16    /// The repository-owned `tix.toml` a view mutation commits to.
17    pub(in crate::app) fn view_config_path(&self) -> PathBuf {
18        self.store
19            .config_path()
20            .unwrap_or_else(|_| PathBuf::from(".").join("tix.toml"))
21    }
22
23    /// The board configuration the **live** board is built from: the active
24    /// board's, with the applied view's visibility snapshot overlaid while that
25    /// overlay is on (T025).
26    ///
27    /// Overlaying rather than writing is what keeps "apply a view" read-only
28    /// (C-005) while still honouring a plan's `hidden_columns`/`show_mismatch`.
29    pub(in crate::app) fn board_config(&self) -> BoardConfiguration {
30        match &self.view_session {
31            Some(session) if session.visibility => session
32                .marker
33                .applied_plan()
34                .effective_board(self.active_board_config()),
35            _ => self.active_board_config().clone(),
36        }
37    }
38
39    /// The stable id of the active saved view (WP05, T024), or `None` when no
40    /// view is associated with the live board.
41    ///
42    /// Public because the **identity** is what a surface or a test asserts
43    /// (C-002): the name is a label and renaming a view must not change what is
44    /// active.
45    pub fn active_view_id(&self) -> Option<&ViewId> {
46        self.view_session
47            .as_ref()
48            .map(|session| session.marker.view_id())
49    }
50
51    /// The **live** normalized plan (C-006): the criteria in force, the text
52    /// query (which carries the sort/group control tokens), and the visibility
53    /// actually applied to the board.
54    ///
55    /// Visibility is set only when a view's snapshot is overlaid, which mirrors
56    /// what the plan of a view that leaves visibility alone *means* ("inherit the
57    /// live board") — so applying such a view reports `in sync` rather than a
58    /// difference that is not one.
59    pub fn live_plan(&self) -> ViewPlan {
60        let mut plan = ViewPlan::from_parts(
61            self.active_structured_filter().clone(),
62            self.filter.as_deref().unwrap_or_default(),
63        );
64        if let Some(session) = &self.view_session
65            && session.visibility
66            && let Some(visibility) = session.marker.applied_plan().visibility()
67        {
68            plan = plan.with_visibility(visibility.clone());
69        }
70        plan
71    }
72
73    /// The plan a **create/update from live** saves: [`Self::live_plan`] plus the
74    /// visibility the user is actually looking at.
75    ///
76    /// Unlike divergence (which asks "does the plan still describe the board?"),
77    /// saving asks "what is on screen?", so an absent snapshot is captured as a
78    /// real one: `hidden_columns` and `show_mismatch` as configured right now.
79    pub fn capture_plan(&self) -> ViewPlan {
80        let board = self.board_config();
81        self.live_plan().with_visibility(ViewVisibility::new(
82            board.hidden_columns.clone(),
83            Some(board.show_mismatch),
84        ))
85    }
86
87    /// The divergence of the live board from the **saved definition** of the
88    /// active view, or `None` when no view is marked (T026).
89    ///
90    /// The three cases are the ones `ViewService::divergence` decides — a deleted
91    /// view and an uninterpretable plan are explicit diagnostics, never a silent
92    /// `in_sync` — derived here from the registry because rendering borrows
93    /// `&App`. `tests/tui_state.rs` pins this report against the service's own
94    /// for the same inputs, so the two cannot drift.
95    pub fn view_divergence(&self) -> Option<ViewDivergence> {
96        let session = self.view_session.as_ref()?;
97        let live = self.live_plan();
98        Some(
99            match self.store.config.views.get(session.marker.view_id()) {
100                None => ViewDivergence::deleted(session.marker.view_id(), &live),
101                Some(view) => match ViewPlan::from_toml(&view.plan) {
102                    Ok(saved) => session.marker.divergence(&saved, &live),
103                    Err(refusal) => ViewDivergence::unreadable(
104                        session.marker.view_id(),
105                        &live,
106                        refusal.diagnostics().to_vec(),
107                    ),
108                },
109            },
110        )
111    }
112
113    /// The board's active-view indicator row (T024), or `None` when no view is
114    /// marked. The same string the filter modal and the views modal show, so the
115    /// three surfaces cannot describe one state three ways.
116    pub fn view_indicator(&self) -> Option<String> {
117        let session = self.view_session.as_ref()?;
118        Some(match self.view_divergence() {
119            Some(divergence) => crate::ui::views::indicator_line(&session.name, &divergence),
120            None => format!("view {} · (no comparison)", session.name),
121        })
122    }
123
124    /// The rendered feedback for the **active** view (T026): its scope, its plan,
125    /// the live-vs-saved divergence, and every diagnostic the registry reports for
126    /// it.
127    pub fn active_view_feedback(&self) -> Option<ViewFeedback> {
128        let id = self.view_session.as_ref()?.marker.view_id().clone();
129        Some(self.view_feedback_for(&id, true))
130    }
131
132    /// The rendered feedback for one stored view, by stable id (T026).
133    ///
134    /// Non-mutating by construction: every input is read from the registry or the
135    /// live state, and the plan is only *parsed* — a malformed one is reported as
136    /// its refusal rather than repaired.
137    pub(in crate::app) fn view_feedback_for(&self, id: &ViewId, is_active: bool) -> ViewFeedback {
138        let Some(view) = self.store.config.views.get(id) else {
139            let diagnostic = ViewDiagnostic::new(
140                crate::views::registry::ViewDiagnosticKind::UnknownView,
141                format!("no saved view with id `{id}`"),
142            );
143            return ViewFeedback::new(
144                format!("view {id} · unknown"),
145                vec![crate::ui::views::diagnostic_line(&diagnostic)],
146            );
147        };
148        let projects = Self::view_projects();
149        let mut lines = vec![format!(
150            "scope: {}",
151            scope_summary(&view.scope, view.scope.resolution(&projects))
152        )];
153        match ViewPlan::from_toml(&view.plan) {
154            Ok(plan) => {
155                lines.push(crate::ui::views::plan_summary(&plan));
156                // Unknown metadata at any plan level, then every field this
157                // board/category vocabulary rejects — reported, never repaired
158                // (NFR-002), and never silently applied (a refusal happens on
159                // apply, where a menu must not explode).
160                lines.extend(
161                    plan.diagnostics()
162                        .iter()
163                        .map(crate::ui::views::diagnostic_line),
164                );
165                if let Err(refusal) = plan.validate(self.active_board_config(), &self.store.config)
166                {
167                    lines.extend(
168                        refusal
169                            .diagnostics()
170                            .iter()
171                            .map(crate::ui::views::diagnostic_line),
172                    );
173                }
174            }
175            Err(refusal) => lines.extend(
176                refusal
177                    .diagnostics()
178                    .iter()
179                    .map(crate::ui::views::diagnostic_line),
180            ),
181        }
182
183        let divergence = if is_active {
184            self.view_divergence()
185        } else {
186            None
187        };
188        let indicator = match &divergence {
189            Some(divergence) => crate::ui::views::indicator_line(&view.name, divergence),
190            None => format!("view {} · id {id}", view.name),
191        };
192        if let Some(divergence) = &divergence {
193            if divergence.in_sync() {
194                lines.push("live: in sync with the saved plan".to_string());
195            } else {
196                lines.push("live: diverged from the saved plan".to_string());
197                lines.extend(crate::ui::views::difference_lines(divergence));
198                lines.extend(
199                    divergence
200                        .diagnostics()
201                        .iter()
202                        .map(crate::ui::views::diagnostic_line),
203                );
204            }
205        }
206        // Scope conflicts, missing projects, and preserved unknown metadata are
207        // reported for a record the registry already holds — reported, never
208        // silently rewritten (NFR-002/C-003).
209        lines.extend(
210            self.store
211                .config
212                .views
213                .diagnostics(&projects)
214                .into_iter()
215                .filter(|diagnostic| diagnostic.view_id() == Some(id))
216                .map(|diagnostic| crate::ui::views::diagnostic_line(&diagnostic)),
217        );
218
219        ViewFeedback::new(indicator, lines)
220    }
221
222    /// Open the saved-views modal (`v`).
223    pub(in crate::app) fn open_views(&mut self) {
224        self.views = Some(ViewsModal::new(self.view_rows()));
225        self.refresh_views_feedback();
226        self.mode = Mode::Views;
227    }
228
229    /// One row per stored view, in registry order, with the active view marked.
230    pub(crate) fn view_rows(&self) -> Vec<ViewRow> {
231        let projects = Self::view_projects();
232        let active = self
233            .view_session
234            .as_ref()
235            .map(|session| session.marker.view_id().clone());
236        self.store
237            .config
238            .views
239            .iter()
240            .map(|view| ViewRow {
241                active: active.as_ref() == Some(&view.id),
242                id: view.id.clone(),
243                name: view.name.clone(),
244                scope: scope_summary(&view.scope, view.scope.resolution(&projects)),
245            })
246            .collect()
247    }
248
249    /// Re-read the registry into the modal, keeping the cursor on the same
250    /// **stable id** where it still exists — so a rename or a duplicate leaves the
251    /// cursor where the user put it.
252    pub(in crate::app) fn refresh_views_rows(&mut self) {
253        let rows = self.view_rows();
254        let Some(modal) = self.views.as_mut() else {
255            return;
256        };
257        let previous = modal.selected_row().map(|row| row.id.clone());
258        modal.rows = rows;
259        if let Some(id) = previous
260            && let Some(index) = modal.rows.iter().position(|row| row.id == id)
261        {
262            modal.selected = index;
263        }
264        modal.move_selected(0);
265    }
266
267    /// Recompute the feedback panel for the row under the cursor (T026).
268    pub(in crate::app) fn refresh_views_feedback(&mut self) {
269        let selected = self
270            .views
271            .as_ref()
272            .and_then(|modal| modal.selected_row())
273            .map(|row| (row.id.clone(), row.active));
274        let feedback = selected.map(|(id, active)| self.view_feedback_for(&id, active));
275        if let Some(modal) = self.views.as_mut() {
276            modal.feedback = feedback;
277        }
278    }
279
280    /// Record the outcome of one view operation on the modal's note line and the
281    /// status bar, then re-read the registry.
282    ///
283    /// A **refusal** is a decision, not a crash: it is rendered through the
284    /// registry's own refusal text and nothing is written (C-008). The note is
285    /// shown on the modal rather than only in the status bar, because the status
286    /// bar is one truncated row and a reason is worth reading.
287    pub(in crate::app) fn note_view_outcome(&mut self, note: String) {
288        if let Some(modal) = self.views.as_mut() {
289            modal.note = Some(note.clone());
290        }
291        self.status_msg = Some(note);
292    }
293
294    /// The note for a refused or failed view write.
295    pub(in crate::app) fn view_write_note(error: &ViewWriteError) -> String {
296        match error {
297            ViewWriteError::Refused(refusal) => refusal.render(),
298            ViewWriteError::Failed(error) => error.to_string(),
299        }
300    }
301
302    /// Apply the view under the cursor, or the one named by `id` (T025).
303    pub(crate) fn apply_view(&mut self, reference: &str) -> Result<Action> {
304        let path = self.view_config_path();
305        let projects = Self::view_projects();
306        let board_config = self.board_config();
307        let resolution = self.workflow_resolution();
308        // The service borrows `store.config` mutably (an accepted *mutation* is a
309        // config change); `apply` only reads, and borrows the corpus and the
310        // board. Disjoint fields, one scope.
311        let outcome = {
312            let service = ViewService::new(&mut self.store.config, &projects, &path);
313            service.apply(reference, &board_config, &self.corpus, &resolution)
314        };
315        match outcome {
316            Ok(result) => {
317                let plan = result.plan().clone();
318                let id = result.view_id().cloned();
319                let name = id
320                    .as_ref()
321                    .and_then(|id| self.store.config.views.get(id))
322                    .map_or_else(|| reference.to_string(), |view| view.name.clone());
323                // The marker goes on **before** the rebuild: the live board must be
324                // built with the applied plan's visibility overlay, and the
325                // indicator/divergence the rebuild refreshes describe the state the
326                // apply just installed.
327                if let Some(id) = id {
328                    self.view_session = Some(ViewSession {
329                        visibility: plan.visibility().is_some(),
330                        marker: ActiveViewMarker::new(id, plan.clone(), Some(ViewSurface::Tui)),
331                        name: name.clone(),
332                    });
333                }
334                self.install_live_plan(&plan);
335                self.refresh_views_rows();
336                self.refresh_views_feedback();
337                self.sync_filter_modal_feedback();
338                self.note_view_outcome(format!("Applied view '{name}'"));
339            }
340            Err(refusal) => {
341                // Refused: nothing applied, no issue touched, the live filter
342                // state left exactly as it was (C-005/C-008).
343                self.sync_filter_modal_feedback();
344                self.note_view_outcome(refusal.render());
345            }
346        }
347        Ok(Action::None)
348    }
349
350    /// Install an applied plan into the **live** filter state through the
351    /// existing query path (T025).
352    ///
353    /// The typed criteria become the committed structured filter, and the plan's
354    /// query — free text plus its `sort:`/`group:` control tokens, re-spelled by
355    /// the plan itself rather than here — is what `apply_filter` replays. So the
356    /// board after an apply is the board the existing path builds, which is the
357    /// same path (and the same result) the service used.
358    pub(in crate::app) fn install_live_plan(&mut self, plan: &ViewPlan) {
359        let selected = self
360            .board
361            .get_selected_card()
362            .map(|card| card.filename.clone());
363        self.structured = plan.filter().clone();
364        let query = plan.effective_query();
365        self.apply_filter(&query);
366        // Keep the cursor on the card the user was standing on when that card
367        // survives the new plan, exactly as a board switch does: an apply that
368        // silently jumped the cursor to the first column would be a navigation
369        // change the user did not ask for (T025).
370        if let Some(filename) = selected {
371            self.select_card_by_filename(&filename);
372        }
373    }
374
375    /// Keep the filter modal's active-view indicator in step with live state
376    /// (T026).
377    ///
378    /// Called from the single funnel every live change passes through
379    /// ([`Self::rebuild_board`]), so the indicator inside the modal cannot
380    /// describe a board the user has already changed.
381    pub(in crate::app) fn sync_filter_modal_feedback(&mut self) {
382        if self.filter_modal.is_none() {
383            return;
384        }
385        let active = self
386            .view_session
387            .as_ref()
388            .map(|session| session.marker.clone());
389        let feedback = if active.is_some() {
390            self.active_view_feedback()
391        } else {
392            None
393        };
394        if let Some(modal) = self.filter_modal.as_mut() {
395            modal.set_active(active, feedback);
396        }
397    }
398
399    /// Create a view from the live plan (T027).
400    ///
401    /// The scope is **explicit**: the TUI creates repository-scoped views. It has
402    /// no project registry to scope against and no host identity to share with,
403    /// and inventing either is an escalation trigger (C-003/C-009); project and
404    /// user/share scopes are managed by `tix view share`.
405    pub(in crate::app) fn create_view_from_live(&mut self, name: String) {
406        let definition = ViewDefinition::new(name, ViewScope::repository(), self.capture_plan());
407        let path = self.view_config_path();
408        let projects = Self::view_projects();
409        let outcome = {
410            let mut service = ViewService::new(&mut self.store.config, &projects, &path);
411            service.create(definition)
412        };
413        match outcome {
414            Ok(outcome) => {
415                let audit = outcome.audit().map(|event| event.view_name().to_string());
416                let id = outcome.value().clone();
417                self.refresh_views_rows();
418                // Select the row just created, so the next action applies to what
419                // the user just made rather than to whatever was under the cursor —
420                // and close the prompt only now, so a refusal left the typed name
421                // in place to be corrected.
422                if let Some(modal) = self.views.as_mut() {
423                    modal.clear_prompt();
424                    if let Some(index) = modal.rows.iter().position(|row| row.id == id) {
425                        modal.selected = index;
426                    }
427                }
428                self.refresh_views_feedback();
429                self.note_view_outcome(format!(
430                    "Created {id}{}",
431                    audit.map_or_else(String::new, |name| format!(" '{name}'"))
432                ));
433            }
434            Err(error) => {
435                let note = Self::view_write_note(&error);
436                self.note_view_outcome(note);
437            }
438        }
439    }
440
441    /// Rename the view under the cursor (T027).
442    ///
443    /// `ViewService::update` replaces the whole definition, so the stored scope
444    /// and plan are carried over from `show` — a rename can never drop either, and
445    /// the stable id is untouched (C-002).
446    pub(in crate::app) fn rename_view(&mut self, id: &ViewId, name: String) {
447        let path = self.view_config_path();
448        let projects = Self::view_projects();
449        let outcome = {
450            let mut service = ViewService::new(&mut self.store.config, &projects, &path);
451            match service.show(id.as_str()) {
452                Ok(detail) => {
453                    let definition = ViewDefinition::new(
454                        name,
455                        detail.view().scope.clone(),
456                        detail.plan().clone(),
457                    );
458                    service.update(id.as_str(), definition).map(|outcome| {
459                        (
460                            outcome.is_noop(),
461                            outcome.value().clone(),
462                            outcome.audit().map(|event| event.view_name().to_string()),
463                        )
464                    })
465                }
466                Err(refusal) => Err(ViewWriteError::Refused(refusal)),
467            }
468        };
469        match outcome {
470            Ok((noop, id, name)) => {
471                if let Some(modal) = self.views.as_mut() {
472                    modal.clear_prompt();
473                }
474                self.refresh_views_rows();
475                self.refresh_views_feedback();
476                let word = if noop { "Unchanged" } else { "Renamed" };
477                self.note_view_outcome(format!(
478                    "{word} {id}{}",
479                    name.map_or_else(String::new, |name| format!(" '{name}'"))
480                ));
481            }
482            Err(error) => {
483                let note = Self::view_write_note(&error);
484                self.note_view_outcome(note);
485            }
486        }
487    }
488
489    /// Replace the view under the cursor's plan with the live plan (T027) — the
490    /// "save what I am looking at into this view" action, the counterpart of
491    /// create.
492    pub(in crate::app) fn update_view_from_live(&mut self, id: &ViewId) {
493        let plan = self.capture_plan();
494        let path = self.view_config_path();
495        let projects = Self::view_projects();
496        let outcome = {
497            let mut service = ViewService::new(&mut self.store.config, &projects, &path);
498            match service.show(id.as_str()) {
499                Ok(detail) => {
500                    let definition = ViewDefinition::new(
501                        detail.view().name.clone(),
502                        detail.view().scope.clone(),
503                        plan,
504                    );
505                    service.update(id.as_str(), definition).map(|outcome| {
506                        (
507                            outcome.is_noop(),
508                            outcome.value().clone(),
509                            outcome.audit().map(|event| event.view_name().to_string()),
510                        )
511                    })
512                }
513                Err(refusal) => Err(ViewWriteError::Refused(refusal)),
514            }
515        };
516        match outcome {
517            Ok((noop, id, name)) => {
518                self.refresh_views_rows();
519                self.refresh_views_feedback();
520                let word = if noop { "Unchanged" } else { "Updated" };
521                self.note_view_outcome(format!(
522                    "{word} {id}{}",
523                    name.map_or_else(String::new, |name| format!(" '{name}'"))
524                ));
525            }
526            Err(error) => {
527                let note = Self::view_write_note(&error);
528                self.note_view_outcome(note);
529            }
530        }
531    }
532
533    /// Duplicate the view under the cursor under a fresh stable id (T027).
534    pub(in crate::app) fn duplicate_view(&mut self, id: &ViewId) {
535        let path = self.view_config_path();
536        let projects = Self::view_projects();
537        let outcome = {
538            let mut service = ViewService::new(&mut self.store.config, &projects, &path);
539            service.duplicate(id.as_str(), None)
540        };
541        match outcome {
542            Ok(outcome) => {
543                let copy = outcome.value().clone();
544                self.refresh_views_rows();
545                if let Some(modal) = self.views.as_mut()
546                    && let Some(index) = modal.rows.iter().position(|row| row.id == copy)
547                {
548                    modal.selected = index;
549                }
550                self.refresh_views_feedback();
551                self.note_view_outcome(format!("Duplicated {id} -> {copy}"));
552            }
553            Err(error) => {
554                let note = Self::view_write_note(&error);
555                self.note_view_outcome(note);
556            }
557        }
558    }
559
560    /// Delete the view under the cursor's **definition**, after confirmation
561    /// (T027/C-005).
562    ///
563    /// Only the definition goes: issues, comments, relationships, and the live
564    /// filter state are all untouched. If the deleted view was the active one the
565    /// marker is cleared — the association is gone with the definition — and the
566    /// board says so, rather than silently keeping a marker that names nothing.
567    pub(in crate::app) fn delete_view(&mut self, id: &ViewId) {
568        let path = self.view_config_path();
569        let projects = Self::view_projects();
570        let outcome = {
571            let mut service = ViewService::new(&mut self.store.config, &projects, &path);
572            service.delete(id.as_str(), DeleteConfirmed::confirmed())
573        };
574        match outcome {
575            Ok(outcome) => {
576                let removed = outcome.value();
577                let name = removed.view().name.clone();
578                // The service built above is ephemeral (`active: None`), so its
579                // `marker_cleared()` is always false; ask the App's own marker
580                // instead, or a deleted active view leaves a dangling id.
581                let cleared = self.active_view_id() == Some(id);
582                if cleared {
583                    self.view_session = None;
584                }
585                self.refresh_views_rows();
586                self.refresh_views_feedback();
587                self.sync_filter_modal_feedback();
588                self.note_view_outcome(if cleared {
589                    format!("Deleted '{name}' (definition only; active marker cleared)")
590                } else {
591                    format!("Deleted '{name}' (definition only)")
592                });
593            }
594            Err(error) => {
595                let note = Self::view_write_note(&error);
596                self.note_view_outcome(note);
597            }
598        }
599    }
600
601    /// The saved-views modal's keys (WP05, T024–T027).
602    ///
603    /// Two layers, exactly as the settings modal has: an open prompt takes the key
604    /// first, and the list has it otherwise. Every branch either moves a cursor
605    /// that belongs to *this* modal, calls the shared service, or applies through
606    /// [`Self::apply_view`]; nothing here writes an issue.
607    #[allow(clippy::too_many_lines)]
608    pub(in crate::app) fn handle_views_key(&mut self, key: KeyEvent) -> Result<Action> {
609        if self.views.is_none() {
610            self.mode = Mode::Board;
611            return Ok(Action::None);
612        }
613        let prompt = self.views.as_ref().and_then(|modal| modal.prompt.clone());
614
615        match prompt {
616            Some(ViewsPrompt::Create(buffer)) => match key.code {
617                KeyCode::Esc => {
618                    if let Some(modal) = self.views.as_mut() {
619                        modal.clear_prompt();
620                    }
621                }
622                KeyCode::Enter => self.create_view_from_live(buffer),
623                KeyCode::Backspace => {
624                    if let Some(modal) = self.views.as_mut()
625                        && let Some(ViewsPrompt::Create(buffer)) = modal.prompt.as_mut()
626                    {
627                        buffer.pop();
628                    }
629                }
630                KeyCode::Char(c) => {
631                    if let Some(modal) = self.views.as_mut()
632                        && let Some(ViewsPrompt::Create(buffer)) = modal.prompt.as_mut()
633                    {
634                        buffer.push(c);
635                    }
636                }
637                _ => {}
638            },
639            Some(ViewsPrompt::Rename { id, buffer }) => match key.code {
640                KeyCode::Esc => {
641                    if let Some(modal) = self.views.as_mut() {
642                        modal.clear_prompt();
643                    }
644                }
645                KeyCode::Enter => self.rename_view(&id, buffer),
646                KeyCode::Backspace => {
647                    if let Some(modal) = self.views.as_mut()
648                        && let Some(ViewsPrompt::Rename { buffer, .. }) = modal.prompt.as_mut()
649                    {
650                        buffer.pop();
651                    }
652                }
653                KeyCode::Char(c) => {
654                    if let Some(modal) = self.views.as_mut()
655                        && let Some(ViewsPrompt::Rename { buffer, .. }) = modal.prompt.as_mut()
656                    {
657                        buffer.push(c);
658                    }
659                }
660                _ => {}
661            },
662            // The confirmation borrows the shared `ConfirmDelete` context, whose
663            // canonical confirm key is `y` and whose cancel is `esc`.
664            Some(ViewsPrompt::ConfirmDelete { id, .. }) => {
665                if let Some(modal) = self.views.as_mut() {
666                    modal.clear_prompt();
667                }
668                match key.code {
669                    KeyCode::Char('y') => self.delete_view(&id),
670                    KeyCode::Esc => self.refresh_views_feedback(),
671                    _ => {}
672                }
673            }
674            None => match key.code {
675                KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
676                    self.views = None;
677                    self.mode = Mode::Board;
678                }
679                KeyCode::Up | KeyCode::Char('k') => {
680                    if let Some(modal) = self.views.as_mut() {
681                        modal.move_selected(-1);
682                        // The note describes the last operation, not the row under
683                        // the cursor: moving on retires it rather than letting it
684                        // read as a fact about the newly selected view.
685                        modal.note = None;
686                    }
687                    self.refresh_views_feedback();
688                }
689                KeyCode::Down | KeyCode::Char('j') => {
690                    if let Some(modal) = self.views.as_mut() {
691                        modal.move_selected(1);
692                        modal.note = None;
693                    }
694                    self.refresh_views_feedback();
695                }
696                KeyCode::Enter => {
697                    if let Some(id) = self
698                        .views
699                        .as_ref()
700                        .and_then(|modal| modal.selected_row())
701                        .map(|row| row.id.clone())
702                    {
703                        self.apply_view(id.as_str())?;
704                    }
705                }
706                KeyCode::Char('n') => {
707                    if let Some(modal) = self.views.as_mut() {
708                        modal.note = None;
709                        modal.prompt = Some(ViewsPrompt::Create(String::new()));
710                    }
711                }
712                KeyCode::Char('e') => {
713                    let selected = self
714                        .views
715                        .as_ref()
716                        .and_then(|modal| modal.selected_row())
717                        .map(|row| (row.id.clone(), row.name.clone()));
718                    if let Some((id, name)) = selected
719                        && let Some(modal) = self.views.as_mut()
720                    {
721                        modal.note = None;
722                        modal.prompt = Some(ViewsPrompt::Rename { id, buffer: name });
723                    }
724                }
725                KeyCode::Char('u') => {
726                    let selected = self
727                        .views
728                        .as_ref()
729                        .and_then(|modal| modal.selected_row())
730                        .map(|row| row.id.clone());
731                    if let Some(id) = selected {
732                        self.update_view_from_live(&id);
733                    }
734                }
735                KeyCode::Char('d') => {
736                    let selected = self
737                        .views
738                        .as_ref()
739                        .and_then(|modal| modal.selected_row())
740                        .map(|row| row.id.clone());
741                    if let Some(id) = selected {
742                        self.duplicate_view(&id);
743                    }
744                }
745                KeyCode::Char('x') | KeyCode::Char('X') => {
746                    let selected = self
747                        .views
748                        .as_ref()
749                        .and_then(|modal| modal.selected_row())
750                        .map(|row| (row.id.clone(), row.name.clone()));
751                    if let Some((id, name)) = selected
752                        && let Some(modal) = self.views.as_mut()
753                    {
754                        modal.note = None;
755                        modal.prompt = Some(ViewsPrompt::ConfirmDelete { id, name });
756                    }
757                }
758                _ => {}
759            },
760        }
761        Ok(Action::None)
762    }
763
764    /// The saved-views modal's pointer handling (WP05, T025).
765    ///
766    /// A left click on a row selects that row and runs the same Enter action the
767    /// keyboard would — the D6 rule the settings modal's rows follow. The row
768    /// geometry comes from the widget's own [`ViewsModal::row_hit_regions`], so the
769    /// click cannot drift from what was drawn. Clicks land here rather than
770    /// through a recorded region because the effect (an apply) belongs to this
771    /// handler, exactly as card selection belongs to `handle_board_mouse`.
772    pub(in crate::app) fn handle_views_mouse(&mut self, mouse: MouseEvent) -> Result<Action> {
773        if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
774            return Ok(Action::None);
775        }
776        if self.views.as_ref().is_some_and(ViewsModal::is_prompting) {
777            return Ok(Action::None);
778        }
779        let area = self.frame_size.get();
780        let hit = self.views.as_ref().and_then(|modal| {
781            modal
782                .row_hit_regions(area)
783                .into_iter()
784                .find(|(rect, _)| rect.contains(Position::new(mouse.column, mouse.row)))
785                .map(|(_, index)| index)
786        });
787        let Some(index) = hit else {
788            return Ok(Action::None);
789        };
790        let selected = {
791            let Some(modal) = self.views.as_mut() else {
792                return Ok(Action::None);
793            };
794            modal.move_selected_to(index);
795            modal.selected_row().map(|row| row.id.clone())
796        };
797        if let Some(id) = selected {
798            self.apply_view(id.as_str())?;
799        }
800        Ok(Action::None)
801    }
802}