返回 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 /// Close the work-surface-owned detail (pager when we opened it).
88 pub fn close_opened(app: &mut App) {
89 if app.work_surface.opened.take().is_none() {
90 return;
91 }
92 if app.view_stack.top_kind() == Some(ModalKind::Pager) {
93 app.view_stack.pop();
94 }
95 app.needs_redraw = true;
96 }
97
98 /// Release a closed Agent Details owner without disturbing Work selection.
99 /// The modal has already popped itself before this event is handled.
100 pub(crate) fn agent_details_closed(app: &mut App, agent_id: &str) {
101 let owner = WorkRowId(format!("worker:{agent_id}"));
102 if app.work_surface.opened.as_ref() == Some(&owner) {
103 app.work_surface.opened = None;
104 app.needs_redraw = true;
105 }
106 }
107
108 #[cfg(test)]
109 mod tests {
110 use super::*;
111 use crate::config::Config;
112 use crate::tui::app::TuiOptions;
113 use std::path::PathBuf;
114
115 fn app() -> App {
116 let options = TuiOptions {
117 use_mouse_capture: true,
118 max_subagents: 4,
119 ..crate::test_support::test_tui_options(PathBuf::from("."))
120 };
121 App::new(options, &Config::default())
122 }
123
124 #[test]
125 fn primary_toggles_opened_closed() {
126 let mut app = app();
127 let row = WorkRowId("worker:a1".into());
128 let open = SidebarRowAction::OpenAgentDetail {
129 agent_id: "a1".into(),
130 };
131 assert!(activate_primary(&mut app, &row, Some(open.clone())).is_some());
132 assert_eq!(app.work_surface.opened.as_ref(), Some(&row));
133 // With the detail pager on screen, the second activation toggles it
134 // closed; with no pager on screen (it closed itself), the activation
135 // reopens instead of going dead.
136 app.view_stack.push(crate::tui::pager::PagerView::from_text(
137 "Agent".to_string(),
138 "body",
139 40,
140 ));
141 assert!(activate_primary(&mut app, &row, Some(open.clone())).is_none());
142 assert!(app.work_surface.opened.is_none());
143 assert!(activate_primary(&mut app, &row, Some(open.clone())).is_some());
144 assert_eq!(app.work_surface.opened.as_ref(), Some(&row));
145 // Pager already gone (closed from inside): reopen, don't swallow.
146 assert!(activate_primary(&mut app, &row, Some(open)).is_some());
147 assert_eq!(app.work_surface.opened.as_ref(), Some(&row));
148 }
149
150 #[test]
151 fn claim_focus_clears_transcript_selection() {
152 use crate::tui::selection::TranscriptSelectionPoint;
153 let mut app = app();
154 app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {
155 line_index: 0,
156 column: 0,
157 });
158 app.viewport.transcript_selection.head = app.viewport.transcript_selection.anchor;
159 claim_focus(&mut app);
160 assert!(app.work_surface.focused);
161 assert!(!app.viewport.transcript_selection.is_active());
162 assert!(app.needs_redraw);
163 }
164 }
165
165 lines RUST