返回 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::{RailPanel, WorkRowId};
10
11 pub(crate) fn select_dock_panel(app: &mut App, panel: RailPanel) {
12 app.work_surface.panel = panel;
13 app.work_surface.explicit_view = true;
14 app.work_surface.dismissed = false;
15 app.work_surface.focused = true;
16 app.work_surface.scroll_offset = 0;
17 app.work_surface.selected = None;
18 app.needs_redraw = true;
19 }
20
21 /// Esc / the `×` tab: close the view. An explicitly opened view goes back to
22 /// the auto rule; the dock stays down until new work arrives.
23 pub(crate) fn dismiss_dock(app: &mut App) {
24 app.work_surface.explicit_view = false;
25 app.work_surface.dismissed = true;
26 // Measure the view the auto rule will show next, not the one just
27 // closed: the dock re-opens when *that* view grows.
28 super::model::resolve_view(app);
29 app.work_surface.dismissed_view = app.work_surface.panel;
30 app.work_surface.dismissed_at_rows = super::model::auto_work_rows(app);
31 app.work_surface.focused = false;
32 app.work_surface.selected = None;
33 app.needs_redraw = true;
34 }
35
36 /// Claim work-surface focus and clear competing selection owners.
37 pub fn claim_focus(app: &mut App) {
38 let was_focused = app.work_surface.focused;
39 app.work_surface.focused = true;
40 crate::tui::mouse_ui::clear_transcript_selection(app);
41 if !was_focused {
42 app.needs_redraw = true;
43 }
44 }
45
46 /// Release work-surface focus without clearing the remembered selection.
47 pub fn release_focus(app: &mut App) {
48 if !app.work_surface.focused && app.work_surface.hovered.is_none() {
49 return;
50 }
51 app.work_surface.focused = false;
52 app.work_surface.hovered = None;
53 app.needs_redraw = true;
54 }
55
56 /// Open or toggle-close the primary detail for a row.
57 ///
58 /// Enter/click on an already-opened selected row closes it. Opening a different
59 /// row updates the inspector owner.
60 pub fn activate_primary(
61 app: &mut App,
62 row_id: &WorkRowId,
63 primary: Option<SidebarRowAction>,
64 ) -> Option<SidebarRowAction> {
65 if app.work_surface.opened.as_ref() == Some(row_id) {
66 // Toggle-close only while the detail is actually on screen. When the
67 // pager closed itself (q/Esc inside it), `opened` is a stale owner —
68 // swallowing the click here would make the row look dead, so fall
69 // through and reopen instead.
70 let detail_on_screen = app.view_stack.top_kind() == Some(ModalKind::Pager);
71 close_opened(app);
72 if detail_on_screen {
73 return None;
74 }
75 }
76 app.work_surface.selected = Some(row_id.clone());
77 let action = primary?;
78 // A group heading changes the visible panel but does not open a modal.
79 // Keep `opened` reserved for a real detail owner, otherwise a later
80 // activation treats the still-visible heading as a stale pager toggle.
81 if !matches!(action, SidebarRowAction::ShowSubagentsPanel) {
82 app.work_surface.opened = Some(row_id.clone());
83 }
84 Some(action)
85 }
86
87 /// Whether the work-surface-owned detail is actually on screen. A row action
88 /// that runs a command without pushing a view (the Context panel's `/compact`
89 /// row) still records `opened`; with nothing stacked above the dock that owner
90 /// is stale, and Esc belongs to the dock again (#6502).
91 pub(crate) fn opened_detail_on_screen(app: &App) -> bool {
92 app.work_surface.opened.is_some() && !app.view_stack.is_empty()
93 }
94
95 /// Close the work-surface-owned detail (pager when we opened it).
96 pub fn close_opened(app: &mut App) {
97 if app.work_surface.opened.take().is_none() {
98 return;
99 }
100 if app.view_stack.top_kind() == Some(ModalKind::Pager) {
101 app.view_stack.pop();
102 }
103 app.needs_redraw = true;
104 }
105
106 /// Release a closed Agent Details owner without disturbing Work selection.
107 /// The modal has already popped itself before this event is handled.
108 pub(crate) fn agent_details_closed(app: &mut App, agent_id: &str) {
109 let owner = WorkRowId(format!("worker:{agent_id}"));
110 if app.work_surface.opened.as_ref() == Some(&owner) {
111 app.work_surface.opened = None;
112 app.needs_redraw = true;
113 }
114 }
115
116 #[cfg(test)]
117 mod tests {
118 use super::*;
119 use crate::config::Config;
120 use crate::tui::app::TuiOptions;
121 use std::path::PathBuf;
122
123 fn app() -> App {
124 let options = TuiOptions {
125 use_mouse_capture: true,
126 max_subagents: 4,
127 ..crate::test_support::test_tui_options(PathBuf::from("."))
128 };
129 App::new(options, &Config::default())
130 }
131
132 #[test]
133 fn primary_toggles_opened_closed() {
134 let mut app = app();
135 let row = WorkRowId("worker:a1".into());
136 let open = SidebarRowAction::OpenAgentDetail {
137 agent_id: "a1".into(),
138 };
139 assert!(activate_primary(&mut app, &row, Some(open.clone())).is_some());
140 assert_eq!(app.work_surface.opened.as_ref(), Some(&row));
141 // With the detail pager on screen, the second activation toggles it
142 // closed; with no pager on screen (it closed itself), the activation
143 // reopens instead of going dead.
144 app.view_stack.push(crate::tui::pager::PagerView::from_text(
145 "Agent".to_string(),
146 "body",
147 40,
148 ));
149 assert!(activate_primary(&mut app, &row, Some(open.clone())).is_none());
150 assert!(app.work_surface.opened.is_none());
151 assert!(activate_primary(&mut app, &row, Some(open.clone())).is_some());
152 assert_eq!(app.work_surface.opened.as_ref(), Some(&row));
153 // Pager already gone (closed from inside): reopen, don't swallow.
154 assert!(activate_primary(&mut app, &row, Some(open)).is_some());
155 assert_eq!(app.work_surface.opened.as_ref(), Some(&row));
156 }
157
158 #[test]
159 fn claim_focus_clears_transcript_selection() {
160 use crate::tui::selection::TranscriptSelectionPoint;
161 let mut app = app();
162 app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {
163 line_index: 0,
164 column: 0,
165 });
166 app.viewport.transcript_selection.head = app.viewport.transcript_selection.anchor;
167 claim_focus(&mut app);
168 assert!(app.work_surface.focused);
169 assert!(!app.viewport.transcript_selection.is_active());
170 assert!(app.needs_redraw);
171 }
172 }
173
173 lines RUST