返回 CodeWhale
interaction.rs
根目录 / crates / tui / src / tui / work_surface / interaction.rs
1 //! Typed work-surface interaction ownership (TUI-DOG-004 / 005 / 006).
2 //!
3 //! Selection, focus, and detail-open are distinct axes. Destructive lifecycle
4 //! actions live inside the inspector pager, not in compact rows.
5
6 use crate::tui::app::{App, SidebarRowAction};
7 use crate::tui::views::ModalKind;
8
9 use super::model::WorkRowId;
10
11 /// Claim work-surface focus and clear competing selection owners.
12 pub fn claim_focus(app: &mut App) {
13 let was_focused = app.work_surface.focused;
14 app.work_surface.focused = true;
15 if app.viewport.transcript_selection.is_active() {
16 app.viewport.transcript_selection.clear();
17 }
18 if !was_focused {
19 app.needs_redraw = true;
20 }
21 }
22
23 /// Release work-surface focus without clearing the remembered selection.
24 pub fn release_focus(app: &mut App) {
25 if !app.work_surface.focused && app.work_surface.hovered.is_none() {
26 return;
27 }
28 app.work_surface.focused = false;
29 app.work_surface.hovered = None;
30 app.needs_redraw = true;
31 }
32
33 /// Open or toggle-close the primary detail for a row.
34 ///
35 /// Enter/click on an already-opened selected row closes it. Opening a different
36 /// row updates the inspector owner.
37 pub fn activate_primary(
38 app: &mut App,
39 row_id: &WorkRowId,
40 primary: Option<SidebarRowAction>,
41 ) -> Option<SidebarRowAction> {
42 if app.work_surface.opened.as_ref() == Some(row_id) {
43 // Toggle-close only while the detail is actually on screen. When the
44 // pager closed itself (q/Esc inside it), `opened` is a stale owner —
45 // swallowing the click here would make the row look dead, so fall
46 // through and reopen instead.
47 let detail_on_screen = app.view_stack.top_kind() == Some(ModalKind::Pager);
48 close_opened(app);
49 if detail_on_screen {
50 return None;
51 }
52 }
53 app.work_surface.selected = Some(row_id.clone());
54 let action = primary?;
55 // A group heading changes the visible panel but does not open a modal.
56 // Keep `opened` reserved for a real detail owner, otherwise a later
57 // activation treats the still-visible heading as a stale pager toggle.
58 if !matches!(action, SidebarRowAction::ShowSubagentsPanel) {
59 app.work_surface.opened = Some(row_id.clone());
60 }
61 Some(action)
62 }
63
64 /// Close the work-surface-owned detail (pager when we opened it).
65 pub fn close_opened(app: &mut App) {
66 if app.work_surface.opened.take().is_none() {
67 return;
68 }
69 if app.view_stack.top_kind() == Some(ModalKind::Pager) {
70 app.view_stack.pop();
71 }
72 app.needs_redraw = true;
73 }
74
75 /// Release a closed Agent Details owner without disturbing Work selection.
76 /// The modal has already popped itself before this event is handled.
77 pub(crate) fn agent_details_closed(app: &mut App, agent_id: &str) {
78 let owner = WorkRowId(format!("worker:{agent_id}"));
79 if app.work_surface.opened.as_ref() == Some(&owner) {
80 app.work_surface.opened = None;
81 app.needs_redraw = true;
82 }
83 }
84
85 #[cfg(test)]
86 mod tests {
87 use super::*;
88 use crate::config::Config;
89 use crate::tui::app::TuiOptions;
90 use std::path::PathBuf;
91
92 fn app() -> App {
93 let options = TuiOptions {
94 use_mouse_capture: true,
95 max_subagents: 4,
96 ..crate::test_support::test_tui_options(PathBuf::from("."))
97 };
98 App::new(options, &Config::default())
99 }
100
101 #[test]
102 fn primary_toggles_opened_closed() {
103 let mut app = app();
104 let row = WorkRowId("worker:a1".into());
105 let open = SidebarRowAction::OpenAgentDetail {
106 agent_id: "a1".into(),
107 };
108 assert!(activate_primary(&mut app, &row, Some(open.clone())).is_some());
109 assert_eq!(app.work_surface.opened.as_ref(), Some(&row));
110 // With the detail pager on screen, the second activation toggles it
111 // closed; with no pager on screen (it closed itself), the activation
112 // reopens instead of going dead.
113 app.view_stack.push(crate::tui::pager::PagerView::from_text(
114 "Agent".to_string(),
115 "body",
116 40,
117 ));
118 assert!(activate_primary(&mut app, &row, Some(open.clone())).is_none());
119 assert!(app.work_surface.opened.is_none());
120 assert!(activate_primary(&mut app, &row, Some(open.clone())).is_some());
121 assert_eq!(app.work_surface.opened.as_ref(), Some(&row));
122 // Pager already gone (closed from inside): reopen, don't swallow.
123 assert!(activate_primary(&mut app, &row, Some(open)).is_some());
124 assert_eq!(app.work_surface.opened.as_ref(), Some(&row));
125 }
126
127 #[test]
128 fn claim_focus_clears_transcript_selection() {
129 use crate::tui::selection::TranscriptSelectionPoint;
130 let mut app = app();
131 app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {
132 line_index: 0,
133 column: 0,
134 });
135 app.viewport.transcript_selection.head = app.viewport.transcript_selection.anchor;
136 claim_focus(&mut app);
137 assert!(app.work_surface.focused);
138 assert!(!app.viewport.transcript_selection.is_active());
139 }
140 }
141
141 lines RUST