boxmux_lib/draw_loop.rs
1use crate::draw_utils::{draw_app, draw_muxbox};
2use crate::model::app::{save_muxbox_bounds_to_yaml, save_complete_state_to_yaml, save_active_layout_to_yaml, save_muxbox_content_to_yaml, save_muxbox_scroll_to_yaml};
3use crate::model::common::InputBounds;
4use crate::model::muxbox::Choice;
5use crate::thread_manager::Runnable;
6use crate::utils::{run_script_with_pty_and_redirect, should_use_pty_for_choice};
7use crate::{
8 apply_buffer, apply_buffer_if_changed, handle_keypress, run_script, AppContext, MuxBox,
9 ScreenBuffer,
10};
11use crate::{thread_manager::*, FieldUpdate};
12// use crossbeam_channel::Sender; // T311: Removed with ChoiceThreadManager
13use crossterm::{
14 terminal::{enable_raw_mode, EnterAlternateScreen},
15 ExecutableCommand,
16};
17use std::io::stdout;
18use std::io::Stdout;
19use std::sync::{mpsc, Mutex};
20
21use uuid::Uuid;
22
23// F0188: Drag state tracking for draggable scroll knobs
24#[derive(Debug, Clone)]
25struct DragState {
26 muxbox_id: String,
27 is_vertical: bool, // true for vertical scrollbar, false for horizontal
28 start_x: u16,
29 start_y: u16,
30 start_scroll_percentage: f64,
31}
32
33// F0189: MuxBox resize state tracking for draggable muxbox borders
34#[derive(Debug, Clone)]
35struct MuxBoxResizeState {
36 muxbox_id: String,
37 resize_edge: ResizeEdge,
38 start_x: u16,
39 start_y: u16,
40 original_bounds: InputBounds,
41}
42
43// F0191: MuxBox move state tracking for draggable muxbox titles/top borders
44#[derive(Debug, Clone)]
45struct MuxBoxMoveState {
46 muxbox_id: String,
47 start_x: u16,
48 start_y: u16,
49 original_bounds: InputBounds,
50}
51
52#[derive(Debug, Clone, PartialEq)]
53pub enum ResizeEdge {
54 BottomRight, // Only corner resize allowed
55}
56
57static DRAG_STATE: Mutex<Option<DragState>> = Mutex::new(None);
58static MUXBOX_RESIZE_STATE: Mutex<Option<MuxBoxResizeState>> = Mutex::new(None);
59static MUXBOX_MOVE_STATE: Mutex<Option<MuxBoxMoveState>> = Mutex::new(None);
60
61// F0189: Helper functions to detect muxbox border resize areas (corner-only)
62pub fn detect_resize_edge(muxbox: &MuxBox, click_x: u16, click_y: u16) -> Option<ResizeEdge> {
63 let bounds = muxbox.bounds();
64 let x = click_x as usize;
65 let y = click_y as usize;
66
67 // Check for corner resize (bottom-right only) with tolerance for easier clicking
68 // Allow clicking within 1 pixel of the exact corner to make it easier to grab
69 let corner_tolerance = 1;
70
71 // Standard detection zone - same for all panels including 100% width
72 if (x >= bounds.x2.saturating_sub(corner_tolerance) && x <= bounds.x2)
73 && (y >= bounds.y2.saturating_sub(corner_tolerance) && y <= bounds.y2)
74 {
75 return Some(ResizeEdge::BottomRight);
76 }
77
78 None
79}
80
81// F0191: Helper function to detect muxbox title/top border for movement
82pub fn detect_move_area(muxbox: &MuxBox, click_x: u16, click_y: u16) -> bool {
83 let bounds = muxbox.bounds();
84 let x = click_x as usize;
85 let y = click_y as usize;
86
87 // Check for title area or top border (y1 coordinate across muxbox width)
88 y == bounds.y1 && x >= bounds.x1 && x <= bounds.x2
89}
90
91pub fn calculate_new_bounds(
92 original_bounds: &InputBounds,
93 resize_edge: &ResizeEdge,
94 start_x: u16,
95 start_y: u16,
96 current_x: u16,
97 current_y: u16,
98 terminal_width: usize,
99 terminal_height: usize,
100) -> InputBounds {
101 let delta_x = (current_x as i32) - (start_x as i32);
102 let delta_y = (current_y as i32) - (start_y as i32);
103
104 let mut new_bounds = original_bounds.clone();
105
106 // F0197: Minimum resize constraints - prevent boxes smaller than 2x2 characters
107 let min_width_percent = (2.0 / terminal_width as f32) * 100.0;
108 let min_height_percent = (2.0 / terminal_height as f32) * 100.0;
109
110 match resize_edge {
111 ResizeEdge::BottomRight => {
112 // Update both x2 and y2 coordinates for corner resize
113 if let Ok(current_x2_percent) = new_bounds.x2.replace('%', "").parse::<f32>() {
114 if let Ok(current_x1_percent) = new_bounds.x1.replace('%', "").parse::<f32>() {
115 let pixel_delta_x = delta_x as f32;
116 let percent_delta_x = (pixel_delta_x / terminal_width as f32) * 100.0;
117 let new_x2_percent =
118 (current_x2_percent + percent_delta_x).max(10.0).min(100.0);
119
120 // Enforce minimum width constraint
121 let min_x2_for_width = current_x1_percent + min_width_percent;
122 let constrained_x2 = new_x2_percent.max(min_x2_for_width);
123
124 new_bounds.x2 = format!("{}%", constrained_x2.round() as i32);
125 }
126 }
127
128 // Also update y2 coordinate for corner resize
129 if let Ok(current_y2_percent) = new_bounds.y2.replace('%', "").parse::<f32>() {
130 if let Ok(current_y1_percent) = new_bounds.y1.replace('%', "").parse::<f32>() {
131 let pixel_delta_y = delta_y as f32;
132 let percent_delta_y = (pixel_delta_y / terminal_height as f32) * 100.0;
133 let new_y2_percent =
134 (current_y2_percent + percent_delta_y).max(10.0).min(100.0);
135
136 // Enforce minimum height constraint
137 let min_y2_for_height = current_y1_percent + min_height_percent;
138 let constrained_y2 = new_y2_percent.max(min_y2_for_height);
139
140 new_bounds.y2 = format!("{}%", constrained_y2.round() as i32);
141 }
142 }
143 }
144 }
145
146 new_bounds
147}
148
149// F0191: Calculate new muxbox position during drag move
150pub fn calculate_new_position(
151 original_bounds: &InputBounds,
152 start_x: u16,
153 start_y: u16,
154 current_x: u16,
155 current_y: u16,
156 terminal_width: usize,
157 terminal_height: usize,
158) -> InputBounds {
159 let delta_x = (current_x as i32) - (start_x as i32);
160 let delta_y = (current_y as i32) - (start_y as i32);
161
162 let mut new_bounds = original_bounds.clone();
163
164 // Convert pixel deltas to percentage deltas and update position
165 let pixel_delta_x = delta_x as f32;
166 let percent_delta_x = (pixel_delta_x / terminal_width as f32) * 100.0;
167
168 let pixel_delta_y = delta_y as f32;
169 let percent_delta_y = (pixel_delta_y / terminal_height as f32) * 100.0;
170
171 // Update x1 and x2 (maintain width)
172 if let (Ok(current_x1), Ok(current_x2)) = (
173 new_bounds.x1.replace('%', "").parse::<f32>(),
174 new_bounds.x2.replace('%', "").parse::<f32>(),
175 ) {
176 let new_x1 = (current_x1 + percent_delta_x).max(0.0).min(90.0);
177 let new_x2 = (current_x2 + percent_delta_x).max(10.0).min(100.0);
178
179 // Ensure we don't go beyond boundaries while maintaining muxbox width
180 if new_x2 <= 100.0 && new_x1 >= 0.0 {
181 new_bounds.x1 = format!("{}%", new_x1.round() as i32);
182 new_bounds.x2 = format!("{}%", new_x2.round() as i32);
183 }
184 }
185
186 // Update y1 and y2 (maintain height)
187 if let (Ok(current_y1), Ok(current_y2)) = (
188 new_bounds.y1.replace('%', "").parse::<f32>(),
189 new_bounds.y2.replace('%', "").parse::<f32>(),
190 ) {
191 let new_y1 = (current_y1 + percent_delta_y).max(0.0).min(90.0);
192 let new_y2 = (current_y2 + percent_delta_y).max(10.0).min(100.0);
193
194 // Ensure we don't go beyond boundaries while maintaining muxbox height
195 if new_y2 <= 100.0 && new_y1 >= 0.0 {
196 new_bounds.y1 = format!("{}%", new_y1.round() as i32);
197 new_bounds.y2 = format!("{}%", new_y2.round() as i32);
198 }
199 }
200
201 new_bounds
202}
203
204// F0188: Helper functions to determine if click is on scroll knob (not just track)
205fn is_on_vertical_knob(muxbox: &MuxBox, click_y: usize) -> bool {
206 let muxbox_bounds = muxbox.bounds();
207 let viewable_height = muxbox_bounds.height().saturating_sub(4);
208
209 // Get content dimensions to calculate knob position and size
210 let max_content_height = if let Some(content) = &muxbox.content {
211 let lines: Vec<&str> = content.split('\n').collect();
212 let mut total_height = lines.len();
213
214 // Add choices height if present
215 if let Some(choices) = &muxbox.choices {
216 total_height += choices.len();
217 }
218 total_height
219 } else if let Some(choices) = &muxbox.choices {
220 choices.len()
221 } else {
222 viewable_height // No scrolling needed
223 };
224
225 if max_content_height <= viewable_height {
226 return false; // No scrollbar needed
227 }
228
229 let track_height = viewable_height.saturating_sub(2);
230 if track_height == 0 {
231 return false;
232 }
233
234 // Calculate knob position and size (matching draw_utils.rs logic)
235 let content_ratio = viewable_height as f64 / max_content_height as f64;
236 let knob_size = std::cmp::max(1, (track_height as f64 * content_ratio).round() as usize);
237 let available_track = track_height.saturating_sub(knob_size);
238
239 let vertical_scroll = muxbox.vertical_scroll.unwrap_or(0.0);
240 let knob_position = if available_track > 0 {
241 ((vertical_scroll / 100.0) * available_track as f64).round() as usize
242 } else {
243 0
244 };
245
246 // Check if click is within knob bounds
247 let knob_start_y = muxbox_bounds.top() + 1 + knob_position;
248 let knob_end_y = knob_start_y + knob_size;
249
250 click_y >= knob_start_y && click_y < knob_end_y
251}
252
253fn is_on_horizontal_knob(muxbox: &MuxBox, click_x: usize) -> bool {
254 let muxbox_bounds = muxbox.bounds();
255 let viewable_width = muxbox_bounds.width().saturating_sub(4);
256
257 // Get content width to calculate knob position and size
258 let max_content_width = if let Some(content) = &muxbox.content {
259 let lines: Vec<&str> = content.split('\n').collect();
260 lines.iter().map(|line| line.len()).max().unwrap_or(0)
261 } else if let Some(choices) = &muxbox.choices {
262 choices
263 .iter()
264 .map(|choice| choice.content.as_ref().map(|c| c.len()).unwrap_or(0))
265 .max()
266 .unwrap_or(0)
267 } else {
268 viewable_width // No scrolling needed
269 };
270
271 if max_content_width <= viewable_width {
272 return false; // No scrollbar needed
273 }
274
275 let track_width = viewable_width.saturating_sub(2);
276 if track_width == 0 {
277 return false;
278 }
279
280 // Calculate knob position and size (matching draw_utils.rs logic)
281 let content_ratio = viewable_width as f64 / max_content_width as f64;
282 let knob_size = std::cmp::max(1, (track_width as f64 * content_ratio).round() as usize);
283 let available_track = track_width.saturating_sub(knob_size);
284
285 let horizontal_scroll = muxbox.horizontal_scroll.unwrap_or(0.0);
286 let knob_position = if available_track > 0 {
287 ((horizontal_scroll / 100.0) * available_track as f64).round() as usize
288 } else {
289 0
290 };
291
292 // Check if click is within knob bounds
293 let knob_start_x = muxbox_bounds.left() + 1 + knob_position;
294 let knob_end_x = knob_start_x + knob_size;
295
296 click_x >= knob_start_x && click_x < knob_end_x
297}
298
299lazy_static! {
300 static ref GLOBAL_SCREEN: Mutex<Option<Stdout>> = Mutex::new(None);
301 static ref GLOBAL_BUFFER: Mutex<Option<ScreenBuffer>> = Mutex::new(None);
302}
303
304create_runnable!(
305 DrawLoop,
306 |_inner: &mut RunnableImpl, app_context: AppContext, _messages: Vec<Message>| -> bool {
307 let mut global_screen = GLOBAL_SCREEN.lock().unwrap();
308 let mut global_buffer = GLOBAL_BUFFER.lock().unwrap();
309 let mut app_context_unwrapped = app_context.clone();
310 let (adjusted_bounds, app_graph) = app_context_unwrapped
311 .app
312 .get_adjusted_bounds_and_app_graph(Some(true));
313
314 let is_first_render = global_screen.is_none();
315 if is_first_render {
316 let mut stdout = stdout();
317 enable_raw_mode().unwrap();
318 stdout.execute(EnterAlternateScreen).unwrap();
319 *global_screen = Some(stdout);
320 *global_buffer = Some(ScreenBuffer::new());
321 }
322
323 if let (Some(ref mut screen), Some(ref mut buffer)) =
324 (&mut *global_screen, &mut *global_buffer)
325 {
326 let mut new_buffer = ScreenBuffer::new();
327 draw_app(
328 &app_context_unwrapped,
329 &app_graph,
330 &adjusted_bounds,
331 &mut new_buffer,
332 );
333 if is_first_render {
334 // Force full render on first run to ensure everything is drawn
335 apply_buffer(&mut new_buffer, screen);
336 } else {
337 apply_buffer_if_changed(buffer, &new_buffer, screen);
338 }
339 *buffer = new_buffer;
340 }
341
342 true
343 },
344 |inner: &mut RunnableImpl,
345 app_context: AppContext,
346 messages: Vec<Message>|
347 -> (bool, AppContext) {
348 let mut global_screen = GLOBAL_SCREEN.lock().unwrap();
349 let mut global_buffer = GLOBAL_BUFFER.lock().unwrap();
350 let mut should_continue = true;
351
352 if let (Some(ref mut screen), Some(ref mut buffer)) =
353 (&mut *global_screen, &mut *global_buffer)
354 {
355 let mut new_buffer;
356 let mut app_context_unwrapped = app_context.clone();
357 let (adjusted_bounds, app_graph) = app_context_unwrapped
358 .app
359 .get_adjusted_bounds_and_app_graph(Some(true));
360 // T311: choice_ids_now_waiting removed - no longer needed with unified threading
361
362 if !messages.is_empty() {
363 log::info!("DrawLoop processing {} messages", messages.len());
364 for msg in &messages {
365 match msg {
366 Message::ChoiceExecutionComplete(choice_id, muxbox_id, _) => {
367 log::info!(
368 "About to process ChoiceExecutionComplete: {} -> {}",
369 choice_id,
370 muxbox_id
371 );
372 }
373 _ => {}
374 }
375 }
376 }
377
378 for message in &messages {
379 match message {
380 Message::MuxBoxEventRefresh(_) => {
381 log::trace!("MuxBoxEventRefresh");
382 }
383 Message::Exit => should_continue = false,
384 Message::Terminate => should_continue = false,
385 Message::NextMuxBox() => {
386 let active_layout = app_context_unwrapped
387 .app
388 .get_active_layout_mut()
389 .expect("No active layout found!");
390
391 // First, collect the IDs of currently selected muxboxes before changing the selection.
392 let unselected_muxbox_ids: Vec<String> = active_layout
393 .get_selected_muxboxes()
394 .iter()
395 .map(|muxbox| muxbox.id.clone())
396 .collect();
397
398 // Now perform the mutation that changes the muxbox selection.
399 active_layout.select_next_muxbox();
400
401 // After mutation, get the newly selected muxboxes' IDs.
402 let selected_muxbox_ids: Vec<String> = active_layout
403 .get_selected_muxboxes()
404 .iter()
405 .map(|muxbox| muxbox.id.clone())
406 .collect();
407
408 // Update the application context and issue redraw commands based on the collected IDs.
409 inner.update_app_context(app_context_unwrapped.clone());
410 for muxbox_id in unselected_muxbox_ids {
411 inner.send_message(Message::RedrawMuxBox(muxbox_id));
412 }
413 for muxbox_id in selected_muxbox_ids {
414 inner.send_message(Message::RedrawMuxBox(muxbox_id));
415 }
416 }
417 Message::PreviousMuxBox() => {
418 let active_layout = app_context_unwrapped
419 .app
420 .get_active_layout_mut()
421 .expect("No active layout found!");
422
423 // First, collect the IDs of currently selected muxboxes before changing the selection.
424 let unselected_muxbox_ids: Vec<String> = active_layout
425 .get_selected_muxboxes()
426 .iter()
427 .map(|muxbox| muxbox.id.clone())
428 .collect();
429
430 // Now perform the mutation that changes the muxbox selection.
431 active_layout.select_previous_muxbox();
432
433 // After mutation, get the newly selected muxboxes' IDs.
434 let selected_muxbox_ids: Vec<String> = active_layout
435 .get_selected_muxboxes()
436 .iter()
437 .map(|muxbox| muxbox.id.clone())
438 .collect();
439
440 // Update the application context and issue redraw commands based on the collected IDs.
441 inner.update_app_context(app_context_unwrapped.clone());
442 for muxbox_id in unselected_muxbox_ids {
443 inner.send_message(Message::RedrawMuxBox(muxbox_id));
444 }
445 for muxbox_id in selected_muxbox_ids {
446 inner.send_message(Message::RedrawMuxBox(muxbox_id));
447 }
448 }
449 Message::ScrollMuxBoxDown() => {
450 let selected_muxboxes = app_context_unwrapped
451 .app
452 .get_active_layout()
453 .unwrap()
454 .get_selected_muxboxes();
455 if !selected_muxboxes.is_empty() {
456 let selected_id = selected_muxboxes.first().unwrap().id.clone();
457 let muxbox =
458 app_context_unwrapped.app.get_muxbox_by_id_mut(&selected_id);
459 if let Some(found_muxbox) = muxbox {
460 if found_muxbox.choices.is_some() {
461 //select first or next choice
462 let choices = found_muxbox.choices.as_mut().unwrap();
463 let selected_choice = choices.iter().position(|c| c.selected);
464 let selected_choice_unwrapped =
465 selected_choice.unwrap_or_default();
466 let new_selected_choice =
467 if selected_choice_unwrapped + 1 < choices.len() {
468 selected_choice_unwrapped + 1
469 } else {
470 0
471 };
472 for (i, choice) in choices.iter_mut().enumerate() {
473 choice.selected = i == new_selected_choice;
474 }
475
476 // Auto-scroll to keep selected choice visible
477 auto_scroll_to_selected_choice(found_muxbox, new_selected_choice);
478 } else {
479 found_muxbox.scroll_down(Some(1.0));
480 }
481
482 inner.update_app_context(app_context_unwrapped.clone());
483 inner.send_message(Message::RedrawMuxBox(selected_id));
484 }
485 }
486 }
487 Message::ScrollMuxBoxUp() => {
488 let selected_muxboxes = app_context_unwrapped
489 .app
490 .get_active_layout()
491 .unwrap()
492 .get_selected_muxboxes();
493 if !selected_muxboxes.is_empty() {
494 let selected_id = selected_muxboxes.first().unwrap().id.clone();
495 let muxbox =
496 app_context_unwrapped.app.get_muxbox_by_id_mut(&selected_id);
497 if let Some(found_muxbox) = muxbox {
498 if found_muxbox.choices.is_some() {
499 //select first or next choice
500 let choices = found_muxbox.choices.as_mut().unwrap();
501 let selected_choice = choices.iter().position(|c| c.selected);
502 let selected_choice_unwrapped =
503 selected_choice.unwrap_or_default();
504 let new_selected_choice = if selected_choice_unwrapped > 0 {
505 selected_choice_unwrapped - 1
506 } else {
507 choices.len() - 1
508 };
509 for (i, choice) in choices.iter_mut().enumerate() {
510 choice.selected = i == new_selected_choice;
511 }
512
513 // Auto-scroll to keep selected choice visible
514 auto_scroll_to_selected_choice(found_muxbox, new_selected_choice);
515 } else {
516 found_muxbox.scroll_up(Some(1.0));
517 }
518 inner.update_app_context(app_context_unwrapped.clone());
519 inner.send_message(Message::RedrawMuxBox(selected_id));
520 }
521 }
522 }
523 Message::ScrollMuxBoxLeft() => {
524 let selected_muxboxes = app_context_unwrapped
525 .app
526 .get_active_layout()
527 .unwrap()
528 .get_selected_muxboxes();
529 if !selected_muxboxes.is_empty() {
530 let selected_id = selected_muxboxes.first().unwrap().id.clone();
531 let muxbox =
532 app_context_unwrapped.app.get_muxbox_by_id_mut(&selected_id);
533 if let Some(found_muxbox) = muxbox {
534 found_muxbox.scroll_left(Some(1.0));
535 inner.update_app_context(app_context_unwrapped.clone());
536 inner.send_message(Message::RedrawMuxBox(selected_id));
537 }
538 }
539 }
540 Message::ScrollMuxBoxRight() => {
541 let selected_muxboxes = app_context_unwrapped
542 .app
543 .get_active_layout()
544 .unwrap()
545 .get_selected_muxboxes();
546 if !selected_muxboxes.is_empty() {
547 let selected_id = selected_muxboxes.first().unwrap().id.clone();
548 let muxbox =
549 app_context_unwrapped.app.get_muxbox_by_id_mut(&selected_id);
550 if let Some(found_muxbox) = muxbox {
551 found_muxbox.scroll_right(Some(1.0));
552 inner.update_app_context(app_context_unwrapped.clone());
553 inner.send_message(Message::RedrawMuxBox(selected_id));
554 }
555 }
556 }
557 Message::ScrollMuxBoxPageUp() => {
558 let selected_muxboxes = app_context_unwrapped
559 .app
560 .get_active_layout()
561 .unwrap()
562 .get_selected_muxboxes();
563 if !selected_muxboxes.is_empty() {
564 let selected_id = selected_muxboxes.first().unwrap().id.clone();
565 let muxbox =
566 app_context_unwrapped.app.get_muxbox_by_id_mut(&selected_id);
567 if let Some(found_muxbox) = muxbox {
568 // Page up scrolls by larger amount (10 units for page-based scrolling)
569 found_muxbox.scroll_up(Some(10.0));
570 inner.update_app_context(app_context_unwrapped.clone());
571 inner.send_message(Message::RedrawMuxBox(selected_id));
572 }
573 }
574 }
575 Message::ScrollMuxBoxPageDown() => {
576 let selected_muxboxes = app_context_unwrapped
577 .app
578 .get_active_layout()
579 .unwrap()
580 .get_selected_muxboxes();
581 if !selected_muxboxes.is_empty() {
582 let selected_id = selected_muxboxes.first().unwrap().id.clone();
583 let muxbox =
584 app_context_unwrapped.app.get_muxbox_by_id_mut(&selected_id);
585 if let Some(found_muxbox) = muxbox {
586 // Page down scrolls by larger amount (10 units for page-based scrolling)
587 found_muxbox.scroll_down(Some(10.0));
588 inner.update_app_context(app_context_unwrapped.clone());
589 inner.send_message(Message::RedrawMuxBox(selected_id));
590 }
591 }
592 }
593 Message::ScrollMuxBoxPageLeft() => {
594 let selected_muxboxes = app_context_unwrapped
595 .app
596 .get_active_layout()
597 .unwrap()
598 .get_selected_muxboxes();
599 if !selected_muxboxes.is_empty() {
600 let selected_id = selected_muxboxes.first().unwrap().id.clone();
601 let muxbox =
602 app_context_unwrapped.app.get_muxbox_by_id_mut(&selected_id);
603 if let Some(found_muxbox) = muxbox {
604 // Page left scrolls by larger amount (10 units for page-based scrolling)
605 found_muxbox.scroll_left(Some(10.0));
606 inner.update_app_context(app_context_unwrapped.clone());
607 inner.send_message(Message::RedrawMuxBox(selected_id));
608 }
609 }
610 }
611 Message::ScrollMuxBoxPageRight() => {
612 let selected_muxboxes = app_context_unwrapped
613 .app
614 .get_active_layout()
615 .unwrap()
616 .get_selected_muxboxes();
617 if !selected_muxboxes.is_empty() {
618 let selected_id = selected_muxboxes.first().unwrap().id.clone();
619 let muxbox =
620 app_context_unwrapped.app.get_muxbox_by_id_mut(&selected_id);
621 if let Some(found_muxbox) = muxbox {
622 // Page right scrolls by larger amount (10 units for page-based scrolling)
623 found_muxbox.scroll_right(Some(10.0));
624 inner.update_app_context(app_context_unwrapped.clone());
625 inner.send_message(Message::RedrawMuxBox(selected_id));
626 }
627 }
628 }
629 Message::ScrollMuxBoxToBeginning() => {
630 // Home key: scroll to beginning horizontally (horizontal_scroll = 0)
631 let selected_muxboxes = app_context_unwrapped
632 .app
633 .get_active_layout()
634 .unwrap()
635 .get_selected_muxboxes();
636 if !selected_muxboxes.is_empty() {
637 let selected_id = selected_muxboxes.first().unwrap().id.clone();
638 let muxbox =
639 app_context_unwrapped.app.get_muxbox_by_id_mut(&selected_id);
640 if let Some(found_muxbox) = muxbox {
641 found_muxbox.horizontal_scroll = Some(0.0);
642
643 // F0200: Save scroll position to YAML
644 inner.send_message(Message::SaveMuxBoxScroll(
645 found_muxbox.id.clone(),
646 0,
647 (found_muxbox.vertical_scroll.unwrap_or(0.0) * 100.0) as usize,
648 ));
649 inner.update_app_context(app_context_unwrapped.clone());
650 inner.send_message(Message::RedrawMuxBox(selected_id));
651 }
652 }
653 }
654 Message::ScrollMuxBoxToEnd() => {
655 // End key: scroll to end horizontally (horizontal_scroll = 100)
656 let selected_muxboxes = app_context_unwrapped
657 .app
658 .get_active_layout()
659 .unwrap()
660 .get_selected_muxboxes();
661 if !selected_muxboxes.is_empty() {
662 let selected_id = selected_muxboxes.first().unwrap().id.clone();
663 let muxbox =
664 app_context_unwrapped.app.get_muxbox_by_id_mut(&selected_id);
665 if let Some(found_muxbox) = muxbox {
666 found_muxbox.horizontal_scroll = Some(100.0);
667
668 // F0200: Save scroll position to YAML
669 inner.send_message(Message::SaveMuxBoxScroll(
670 found_muxbox.id.clone(),
671 100,
672 (found_muxbox.vertical_scroll.unwrap_or(0.0) * 100.0) as usize,
673 ));
674 inner.update_app_context(app_context_unwrapped.clone());
675 inner.send_message(Message::RedrawMuxBox(selected_id));
676 }
677 }
678 }
679 Message::ScrollMuxBoxToTop() => {
680 // Ctrl+Home: scroll to top vertically (vertical_scroll = 0)
681 let selected_muxboxes = app_context_unwrapped
682 .app
683 .get_active_layout()
684 .unwrap()
685 .get_selected_muxboxes();
686 if !selected_muxboxes.is_empty() {
687 let selected_id = selected_muxboxes.first().unwrap().id.clone();
688 let muxbox =
689 app_context_unwrapped.app.get_muxbox_by_id_mut(&selected_id);
690 if let Some(found_muxbox) = muxbox {
691 found_muxbox.vertical_scroll = Some(0.0);
692
693 // F0200: Save scroll position to YAML
694 inner.send_message(Message::SaveMuxBoxScroll(
695 found_muxbox.id.clone(),
696 (found_muxbox.horizontal_scroll.unwrap_or(0.0) * 100.0) as usize,
697 0,
698 ));
699 inner.update_app_context(app_context_unwrapped.clone());
700 inner.send_message(Message::RedrawMuxBox(selected_id));
701 }
702 }
703 }
704 Message::ScrollMuxBoxToBottom() => {
705 // Ctrl+End: scroll to bottom vertically (vertical_scroll = 100)
706 let selected_muxboxes = app_context_unwrapped
707 .app
708 .get_active_layout()
709 .unwrap()
710 .get_selected_muxboxes();
711 if !selected_muxboxes.is_empty() {
712 let selected_id = selected_muxboxes.first().unwrap().id.clone();
713 let muxbox =
714 app_context_unwrapped.app.get_muxbox_by_id_mut(&selected_id);
715 if let Some(found_muxbox) = muxbox {
716 found_muxbox.vertical_scroll = Some(100.0);
717
718 // F0200: Save scroll position to YAML
719 inner.send_message(Message::SaveMuxBoxScroll(
720 found_muxbox.id.clone(),
721 (found_muxbox.horizontal_scroll.unwrap_or(0.0) * 100.0) as usize,
722 100,
723 ));
724 inner.update_app_context(app_context_unwrapped.clone());
725 inner.send_message(Message::RedrawMuxBox(selected_id));
726 }
727 }
728 }
729 Message::CopyFocusedMuxBoxContent() => {
730 let selected_muxboxes = app_context_unwrapped
731 .app
732 .get_active_layout()
733 .unwrap()
734 .get_selected_muxboxes();
735 if !selected_muxboxes.is_empty() {
736 let selected_id = selected_muxboxes.first().unwrap().id.clone();
737 let muxbox = app_context_unwrapped.app.get_muxbox_by_id(&selected_id);
738 if let Some(found_muxbox) = muxbox {
739 // Get muxbox content to copy
740 let content_to_copy =
741 get_muxbox_content_for_clipboard(found_muxbox);
742
743 // Copy to clipboard
744 if copy_to_clipboard(&content_to_copy).is_ok() {
745 // Trigger visual flash for the muxbox
746 trigger_muxbox_flash(&selected_id);
747 inner.send_message(Message::RedrawMuxBox(selected_id));
748 }
749 }
750 }
751 }
752 Message::RedrawMuxBox(muxbox_id) => {
753 if let Some(mut found_muxbox) = app_context_unwrapped
754 .app
755 .get_muxbox_by_id_mut(muxbox_id)
756 .cloned()
757 {
758 new_buffer = buffer.clone();
759
760 // Clone the parent layout to avoid mutable borrow conflicts
761 if let Some(parent_layout) =
762 found_muxbox.get_parent_layout_clone(&mut app_context_unwrapped)
763 {
764 draw_muxbox(
765 &app_context_unwrapped,
766 &app_graph,
767 &adjusted_bounds,
768 &parent_layout,
769 &mut found_muxbox,
770 &mut new_buffer,
771 );
772 apply_buffer_if_changed(buffer, &new_buffer, screen);
773 *buffer = new_buffer;
774 }
775 }
776 }
777 Message::RedrawApp | Message::Resize => {
778 screen
779 .execute(crossterm::terminal::Clear(
780 crossterm::terminal::ClearType::All,
781 ))
782 .unwrap();
783 let mut new_buffer = ScreenBuffer::new();
784 draw_app(
785 &app_context_unwrapped,
786 &app_graph,
787 &adjusted_bounds,
788 &mut new_buffer,
789 );
790 apply_buffer(&mut new_buffer, screen);
791 *buffer = new_buffer;
792 }
793 Message::RedrawAppDiff => {
794 // Redraw entire app with diff-based rendering (no screen clear)
795 let mut new_buffer = ScreenBuffer::new();
796 draw_app(
797 &app_context_unwrapped,
798 &app_graph,
799 &adjusted_bounds,
800 &mut new_buffer,
801 );
802 apply_buffer_if_changed(buffer, &new_buffer, screen);
803 *buffer = new_buffer;
804 }
805 Message::MuxBoxOutputUpdate(muxbox_id, success, output) => {
806 log::info!("RECEIVED MuxBoxOutputUpdate for muxbox: {}, success: {}, output_len: {}, preview: {}",
807 muxbox_id, success, output.len(), output.chars().take(50).collect::<String>());
808 let mut app_context_unwrapped_cloned = app_context_unwrapped.clone();
809 // For PTY streaming output, we need to use a special update method
810 // that doesn't add timestamp formatting. The presence of a newline
811 // at the end of the output indicates it's PTY streaming data.
812 let is_pty_streaming = output.ends_with('\n');
813
814 if is_pty_streaming {
815 // Use streaming update for PTY output
816 let content_for_save = {
817 let target_muxbox = app_context_unwrapped_cloned
818 .app
819 .get_muxbox_by_id_mut(muxbox_id)
820 .unwrap();
821 target_muxbox.update_streaming_content(output, *success);
822 target_muxbox.content.clone()
823 };
824 inner.update_app_context(app_context_unwrapped_cloned.clone());
825 inner.send_message(Message::RedrawMuxBox(muxbox_id.to_string()));
826
827 // F0200: Save PTY streaming content to YAML
828 if let Some(updated_content) = content_for_save {
829 inner.send_message(Message::SaveMuxBoxContent(
830 muxbox_id.clone(),
831 updated_content,
832 ));
833 }
834 } else {
835 // Use regular update for non-PTY output
836 let muxbox = app_context_unwrapped
837 .app
838 .get_muxbox_by_id(muxbox_id)
839 .unwrap();
840
841 // Store content before update for YAML persistence
842 let old_content = muxbox.content.clone();
843
844 update_muxbox_content(
845 inner,
846 &mut app_context_unwrapped_cloned,
847 muxbox_id,
848 *success,
849 muxbox.append_output.unwrap_or(false),
850 output,
851 );
852
853 // F0200: Save updated content to YAML if it changed
854 if let Some(updated_muxbox) = app_context_unwrapped_cloned.app.get_muxbox_by_id(muxbox_id) {
855 if updated_muxbox.content != old_content {
856 if let Some(new_content) = &updated_muxbox.content {
857 inner.send_message(Message::SaveMuxBoxContent(
858 muxbox_id.clone(),
859 new_content.clone(),
860 ));
861 }
862 }
863 }
864 }
865 }
866 // ExternalMessage handling is now done by RSJanusComms library
867 // Messages are converted to appropriate internal messages by the socket handler
868 Message::ExternalMessage(_) => {
869 // This should no longer be used - socket handler converts messages directly
870 log::warn!("Received deprecated ExternalMessage - should be converted by socket handler");
871 }
872 Message::ExecuteHotKeyChoice(choice_id) => {
873 log::info!("=== EXECUTING HOT KEY CHOICE: {} ===", choice_id);
874
875 let active_layout = app_context_unwrapped.app.get_active_layout().unwrap();
876
877 // Find the choice by ID in any muxbox
878 log::info!("Searching for choice {} in active layout", choice_id);
879 if let Some(choice_muxbox) =
880 active_layout.find_muxbox_with_choice(&choice_id)
881 {
882 log::info!("Found choice in muxbox: {}", choice_muxbox.id);
883
884 if let Some(choices) = &choice_muxbox.choices {
885 if let Some(choice) = choices.iter().find(|c| c.id == *choice_id) {
886 // T315: Unified choice execution - thread field no longer affects execution path
887 log::info!("Executing choice config - pty: {}, redirect: {:?}, script_lines: {}",
888 choice.pty.unwrap_or(false),
889 choice.redirect_output,
890 choice.script.as_ref().map(|s| s.len()).unwrap_or(0)
891 );
892
893 if let Some(script) = &choice.script {
894 let libs = app_context_unwrapped.app.libs.clone();
895 let use_pty = should_use_pty_for_choice(choice);
896 let pty_manager =
897 app_context_unwrapped.pty_manager.as_ref();
898 let message_sender = Some((
899 inner.get_message_sender().as_ref().unwrap().clone(),
900 inner.get_uuid(),
901 ));
902
903 log::info!("Unified execution - use_pty: {}, has_manager: {}, redirect: {:?}",
904 use_pty, pty_manager.is_some(), choice.redirect_output);
905
906 let result = run_script_with_pty_and_redirect(
907 libs,
908 script,
909 use_pty,
910 pty_manager.map(|arc| arc.as_ref()),
911 Some(choice.id.clone()),
912 message_sender,
913 choice.redirect_output.clone(),
914 );
915
916 // Send completion message via unified system
917 inner.send_message(Message::ChoiceExecutionComplete(
918 choice_id.clone(),
919 choice_muxbox.id.clone(),
920 result.map_err(|e| e.to_string()),
921 ));
922 }
923 } else {
924 log::warn!("Choice {} found in muxbox {} but no matching choice in choices list", choice_id, choice_muxbox.id);
925 }
926 } else {
927 log::warn!("MuxBox {} has no choices list", choice_muxbox.id);
928 }
929 } else {
930 log::error!(
931 "Choice {} not found in any muxbox of active layout",
932 choice_id
933 );
934 }
935 }
936 Message::KeyPress(pressed_key) => {
937 let mut app_context_for_keypress = app_context_unwrapped.clone();
938 let active_layout = app_context_unwrapped.app.get_active_layout().unwrap();
939
940 let selected_muxboxes: Vec<&MuxBox> = active_layout.get_selected_muxboxes();
941
942 let selected_muxboxes_with_keypress_events: Vec<&MuxBox> =
943 selected_muxboxes
944 .clone()
945 .into_iter()
946 .filter(|p| p.on_keypress.is_some())
947 .filter(|p| p.choices.is_none())
948 .collect();
949
950 let libs = app_context_unwrapped.app.libs.clone();
951
952 if pressed_key == "Enter" {
953 let selected_muxboxes_with_choices: Vec<&MuxBox> = selected_muxboxes
954 .into_iter()
955 .filter(|p| p.choices.is_some())
956 .collect();
957 for muxbox in selected_muxboxes_with_choices {
958 // First, extract choice information before any mutable operations
959 let (selected_choice_data, choice_needs_execution) = {
960 let muxbox_ref = app_context_for_keypress
961 .app
962 .get_muxbox_by_id(&muxbox.id)
963 .unwrap();
964 if let Some(ref choices) = muxbox_ref.choices {
965 if let Some(selected_choice) =
966 choices.iter().find(|c| c.selected)
967 {
968 let choice_data = (
969 selected_choice.id.clone(),
970 selected_choice.script.clone(),
971 selected_choice.pty.unwrap_or(false),
972 selected_choice.thread.unwrap_or(false),
973 selected_choice.redirect_output.clone(),
974 selected_choice.append_output.unwrap_or(false),
975 muxbox.id.clone(),
976 );
977 (Some(choice_data), selected_choice.script.is_some())
978 } else {
979 (None, false)
980 }
981 } else {
982 (None, false)
983 }
984 };
985
986 if let Some((
987 choice_id,
988 script_opt,
989 use_pty,
990 use_thread,
991 redirect_output,
992 append_output,
993 muxbox_id,
994 )) = selected_choice_data
995 {
996 if choice_needs_execution {
997 log::info!(
998 "=== ENTER KEY CHOICE EXECUTION: {} (muxbox: {}) ===",
999 choice_id,
1000 muxbox_id
1001 );
1002 log::info!("Enter choice config - pty: {}, thread: {}, redirect: {:?}",
1003 use_pty, use_thread, redirect_output
1004 );
1005
1006 if let Some(script) = script_opt {
1007 let libs_clone = libs.clone();
1008
1009 // T312: Execute choice using unified threading system - proper architecture
1010 log::info!("Enter key requesting ThreadManager to execute choice {} (pty: {})", choice_id, use_pty);
1011
1012 // Set choice to waiting state before execution
1013 if let Some(muxbox_mut) = app_context_for_keypress
1014 .app
1015 .get_muxbox_by_id_mut(&muxbox_id)
1016 {
1017 if let Some(ref mut choices) = muxbox_mut.choices {
1018 if let Some(choice) = choices
1019 .iter_mut()
1020 .find(|c| c.id == choice_id)
1021 {
1022 choice.waiting = true;
1023 }
1024 }
1025 }
1026
1027 // Create the choice object for execution
1028 let choice_for_execution = Choice {
1029 id: choice_id.clone(),
1030 content: Some("".to_string()), // Not needed for execution
1031 selected: false, // Not needed for execution
1032 script: Some(script.clone()),
1033 pty: Some(use_pty),
1034 thread: Some(use_thread),
1035 redirect_output: redirect_output.clone(),
1036 append_output: Some(append_output),
1037 waiting: true,
1038 };
1039
1040 // Send ExecuteChoice message to ThreadManager (proper architecture)
1041 log::info!("Sending ExecuteChoice message for choice {} (pty: {}, thread: {})",
1042 choice_id, use_pty, use_thread);
1043 inner.send_message(Message::ExecuteChoice(
1044 choice_for_execution,
1045 muxbox_id.clone(),
1046 libs_clone,
1047 ));
1048
1049 // Update the app context to persist the waiting state change
1050 inner.update_app_context(
1051 app_context_for_keypress.clone(),
1052 );
1053
1054 log::trace!(
1055 "ExecuteChoice message sent for choice {}",
1056 choice_id
1057 );
1058 }
1059 }
1060 }
1061 }
1062 }
1063
1064 for muxbox in selected_muxboxes_with_keypress_events {
1065 let actions =
1066 handle_keypress(pressed_key, &muxbox.on_keypress.clone().unwrap());
1067 if actions.is_none() {
1068 if let Some(actions_unwrapped) = actions {
1069 let libs = app_context_unwrapped.app.libs.clone();
1070
1071 match run_script(libs, &actions_unwrapped) {
1072 Ok(output) => {
1073 if muxbox.redirect_output.is_some() {
1074 update_muxbox_content(
1075 inner,
1076 &mut app_context_for_keypress,
1077 muxbox.redirect_output.as_ref().unwrap(),
1078 true,
1079 muxbox.append_output.unwrap_or(false),
1080 &output,
1081 )
1082 } else {
1083 update_muxbox_content(
1084 inner,
1085 &mut app_context_for_keypress,
1086 &muxbox.id,
1087 true,
1088 muxbox.append_output.unwrap_or(false),
1089 &output,
1090 )
1091 }
1092 }
1093 Err(e) => {
1094 if muxbox.redirect_output.is_some() {
1095 update_muxbox_content(
1096 inner,
1097 &mut app_context_for_keypress,
1098 muxbox.redirect_output.as_ref().unwrap(),
1099 false,
1100 muxbox.append_output.unwrap_or(false),
1101 e.to_string().as_str(),
1102 )
1103 } else {
1104 update_muxbox_content(
1105 inner,
1106 &mut app_context_for_keypress,
1107 &muxbox.id,
1108 false,
1109 muxbox.append_output.unwrap_or(false),
1110 e.to_string().as_str(),
1111 )
1112 }
1113 }
1114 }
1115 }
1116 }
1117 }
1118 }
1119 Message::PTYInput(muxbox_id, input) => {
1120 log::trace!("PTY input for muxbox {}: {}", muxbox_id, input);
1121
1122 // Find the target muxbox to verify it exists and has PTY enabled
1123 if let Some(muxbox) = app_context_unwrapped.app.get_muxbox_by_id(muxbox_id)
1124 {
1125 if muxbox.pty.unwrap_or(false) {
1126 log::debug!(
1127 "Routing input to PTY muxbox {}: {:?}",
1128 muxbox_id,
1129 input.chars().collect::<Vec<_>>()
1130 );
1131
1132 // TODO: Write input to PTY process when PTY manager is thread-safe
1133 // For now, log the successful routing detection
1134 log::info!(
1135 "PTY input ready for routing to muxbox {}: {} chars",
1136 muxbox_id,
1137 input.len()
1138 );
1139 } else {
1140 log::warn!(
1141 "MuxBox {} received PTY input but pty field is false",
1142 muxbox_id
1143 );
1144 }
1145 } else {
1146 log::error!(
1147 "PTY input received for non-existent muxbox: {}",
1148 muxbox_id
1149 );
1150 }
1151 }
1152 Message::MouseClick(x, y) => {
1153 log::trace!("Mouse click at ({}, {})", x, y);
1154 let mut app_context_for_click = app_context_unwrapped.clone();
1155 let active_layout = app_context_unwrapped.app.get_active_layout().unwrap();
1156
1157 // F0187: Check for scrollbar clicks first
1158 let mut handled_scrollbar_click = false;
1159 for muxbox in active_layout.get_all_muxboxes() {
1160 if muxbox.has_scrollable_content() {
1161 let muxbox_bounds = muxbox.bounds();
1162
1163 // Check for vertical scrollbar click (right border)
1164 if *x as usize == muxbox_bounds.right()
1165 && *y as usize > muxbox_bounds.top()
1166 && (*y as usize) < muxbox_bounds.bottom()
1167 {
1168 let track_height =
1169 (muxbox_bounds.height() as isize - 2).max(1) as usize;
1170 let click_position = ((*y as usize) - muxbox_bounds.top() - 1)
1171 as f64
1172 / track_height as f64;
1173 let scroll_percentage =
1174 (click_position * 100.0).min(100.0).max(0.0);
1175
1176 log::trace!(
1177 "Vertical scrollbar click on muxbox {} at {}%",
1178 muxbox.id,
1179 scroll_percentage
1180 );
1181
1182 // Update muxbox vertical scroll
1183 let (muxbox_id, horizontal_scroll) = {
1184 let muxbox_to_update = app_context_for_click
1185 .app
1186 .get_muxbox_by_id_mut(&muxbox.id)
1187 .unwrap();
1188 muxbox_to_update.vertical_scroll = Some(scroll_percentage);
1189 (muxbox_to_update.id.clone(), muxbox_to_update.horizontal_scroll.unwrap_or(0.0))
1190 };
1191
1192 inner.update_app_context(app_context_for_click.clone());
1193 inner.send_message(Message::RedrawAppDiff);
1194 handled_scrollbar_click = true;
1195
1196 // F0200: Save scroll position to YAML
1197 inner.send_message(Message::SaveMuxBoxScroll(
1198 muxbox_id,
1199 (horizontal_scroll * 100.0) as usize,
1200 (scroll_percentage * 100.0) as usize,
1201 ));
1202 break;
1203 }
1204
1205 // Check for horizontal scrollbar click (bottom border)
1206 if *y as usize == muxbox_bounds.bottom()
1207 && *x as usize > muxbox_bounds.left()
1208 && (*x as usize) < muxbox_bounds.right()
1209 {
1210 let track_width =
1211 (muxbox_bounds.width() as isize - 2).max(1) as usize;
1212 let click_position = ((*x as usize) - muxbox_bounds.left() - 1)
1213 as f64
1214 / track_width as f64;
1215 let scroll_percentage =
1216 (click_position * 100.0).min(100.0).max(0.0);
1217
1218 log::trace!(
1219 "Horizontal scrollbar click on muxbox {} at {}%",
1220 muxbox.id,
1221 scroll_percentage
1222 );
1223
1224 // Update muxbox horizontal scroll
1225 let (muxbox_id, vertical_scroll) = {
1226 let muxbox_to_update = app_context_for_click
1227 .app
1228 .get_muxbox_by_id_mut(&muxbox.id)
1229 .unwrap();
1230 muxbox_to_update.horizontal_scroll = Some(scroll_percentage);
1231 (muxbox_to_update.id.clone(), muxbox_to_update.vertical_scroll.unwrap_or(0.0))
1232 };
1233
1234 inner.update_app_context(app_context_for_click.clone());
1235 inner.send_message(Message::RedrawAppDiff);
1236 handled_scrollbar_click = true;
1237
1238 // F0200: Save scroll position to YAML
1239 inner.send_message(Message::SaveMuxBoxScroll(
1240 muxbox_id,
1241 (scroll_percentage * 100.0) as usize,
1242 (vertical_scroll * 100.0) as usize,
1243 ));
1244 break;
1245 }
1246 }
1247 }
1248
1249 // If scrollbar click was handled, skip muxbox selection
1250 if handled_scrollbar_click {
1251 // Continue to next message
1252 } else {
1253 // F0091: Find which muxbox was clicked based on coordinates
1254 if let Some(clicked_muxbox) =
1255 active_layout.find_muxbox_at_coordinates(*x, *y)
1256 {
1257 log::trace!("Clicked on muxbox: {}", clicked_muxbox.id);
1258
1259 // Check if muxbox has choices (menu items)
1260 if let Some(choices) = &clicked_muxbox.choices {
1261 // Calculate which choice was clicked based on y and x offset within muxbox
1262 if let Some(clicked_choice_idx) = calculate_clicked_choice_index(
1263 clicked_muxbox,
1264 *x,
1265 *y,
1266 choices,
1267 ) {
1268 if let Some(clicked_choice) =
1269 choices.get(clicked_choice_idx)
1270 {
1271 log::trace!("Clicked on choice: {}", clicked_choice.id);
1272
1273 // First, select the parent muxbox if not already selected
1274 let layout = app_context_for_click
1275 .app
1276 .get_active_layout_mut()
1277 .unwrap();
1278 layout.deselect_all_muxboxes();
1279 layout.select_only_muxbox(&clicked_muxbox.id);
1280
1281 // Then select the clicked choice visually
1282 let muxbox_to_update = app_context_for_click
1283 .app
1284 .get_muxbox_by_id_mut(&clicked_muxbox.id)
1285 .unwrap();
1286 if let Some(ref mut muxbox_choices) =
1287 muxbox_to_update.choices
1288 {
1289 // Deselect all choices first
1290 for choice in muxbox_choices.iter_mut() {
1291 choice.selected = false;
1292 }
1293 // Select only the clicked choice
1294 if let Some(selected_choice) =
1295 muxbox_choices.get_mut(clicked_choice_idx)
1296 {
1297 selected_choice.selected = true;
1298 }
1299 }
1300
1301 // Update the app context and immediately trigger redraw for responsiveness
1302 inner.update_app_context(app_context_for_click.clone());
1303 inner.send_message(Message::RedrawAppDiff);
1304
1305 // Then activate the clicked choice (same as pressing Enter)
1306 // Force threaded execution for clicked choices to maintain UI responsiveness
1307 if let Some(script) = &clicked_choice.script {
1308 let libs = app_context_unwrapped.app.libs.clone();
1309
1310 // Always use threaded execution for mouse clicks to keep UI responsive
1311 let _script_clone = script.clone();
1312 let _choice_id_clone = clicked_choice.id.clone();
1313 let muxbox_id_clone = clicked_muxbox.id.clone();
1314 let libs_clone = libs.clone();
1315
1316 // T312: Use unified ExecuteChoice message system
1317 inner.send_message(Message::ExecuteChoice(
1318 clicked_choice.clone(),
1319 muxbox_id_clone,
1320 libs_clone,
1321 ));
1322
1323 // Spawn the choice execution in ThreadManager
1324 // TODO: Get ThreadManager reference to spawn the runnable
1325 log::trace!("Mouse click choice {} ready for ThreadManager execution", clicked_choice.id);
1326 }
1327 }
1328 } else {
1329 // Click was on muxbox with choices but not on any specific choice
1330 // Only select the muxbox, don't activate any choice
1331 if clicked_muxbox.tab_order.is_some()
1332 || clicked_muxbox.has_scrollable_content()
1333 {
1334 log::trace!(
1335 "Selecting muxbox (clicked on empty area): {}",
1336 clicked_muxbox.id
1337 );
1338
1339 // Deselect all muxboxes in the layout first
1340 let layout = app_context_for_click
1341 .app
1342 .get_active_layout_mut()
1343 .unwrap();
1344 layout.deselect_all_muxboxes();
1345 layout.select_only_muxbox(&clicked_muxbox.id);
1346
1347 inner.update_app_context(app_context_for_click);
1348 inner.send_message(Message::RedrawAppDiff);
1349 }
1350 }
1351 } else {
1352 // MuxBox has no choices - just select it if it's selectable
1353 if clicked_muxbox.tab_order.is_some()
1354 || clicked_muxbox.has_scrollable_content()
1355 {
1356 log::trace!(
1357 "Selecting muxbox (no choices): {}",
1358 clicked_muxbox.id
1359 );
1360
1361 // Deselect all muxboxes in the layout first
1362 let layout = app_context_for_click
1363 .app
1364 .get_active_layout_mut()
1365 .unwrap();
1366 layout.deselect_all_muxboxes();
1367 layout.select_only_muxbox(&clicked_muxbox.id);
1368
1369 inner.update_app_context(app_context_for_click);
1370 inner.send_message(Message::RedrawAppDiff);
1371 }
1372 }
1373 }
1374 }
1375 }
1376 Message::MouseDragStart(x, y) => {
1377 // Check if muxboxes are locked before allowing resize/move
1378 if app_context_unwrapped.config.locked {
1379 // Skip all resize/move operations when locked
1380 log::trace!("MuxBox resize/move blocked: muxboxes are locked");
1381 } else {
1382 // F0189: Check if drag started on a muxbox border first
1383 let active_layout =
1384 app_context_unwrapped.app.get_active_layout().unwrap();
1385 let mut resize_state = MUXBOX_RESIZE_STATE.lock().unwrap();
1386 *resize_state = None; // Clear any previous resize state
1387
1388 // Check for muxbox border resize first
1389 let mut handled_resize = false;
1390 for muxbox in active_layout.get_all_muxboxes() {
1391 if let Some(resize_edge) = detect_resize_edge(muxbox, *x, *y) {
1392 *resize_state = Some(MuxBoxResizeState {
1393 muxbox_id: muxbox.id.clone(),
1394 resize_edge,
1395 start_x: *x,
1396 start_y: *y,
1397 original_bounds: muxbox.position.clone(),
1398 });
1399 log::trace!(
1400 "Started resizing muxbox {} via {:?} edge",
1401 muxbox.id,
1402 resize_state.as_ref().unwrap().resize_edge
1403 );
1404 handled_resize = true;
1405 break;
1406 }
1407 }
1408
1409 // F0191: If not a resize, check if drag started on muxbox title/top border for movement
1410 let mut handled_move = false;
1411 if !handled_resize {
1412 let mut move_state = MUXBOX_MOVE_STATE.lock().unwrap();
1413 *move_state = None; // Clear any previous move state
1414
1415 for muxbox in active_layout.get_all_muxboxes() {
1416 if detect_move_area(muxbox, *x, *y) {
1417 *move_state = Some(MuxBoxMoveState {
1418 muxbox_id: muxbox.id.clone(),
1419 start_x: *x,
1420 start_y: *y,
1421 original_bounds: muxbox.position.clone(),
1422 });
1423 log::trace!(
1424 "Started moving muxbox {} via title/top border",
1425 muxbox.id
1426 );
1427 handled_move = true;
1428 break;
1429 }
1430 }
1431 }
1432 }
1433
1434 // F0188: Check for scroll knob drag (allowed even when locked)
1435 let active_layout = app_context_unwrapped.app.get_active_layout().unwrap();
1436
1437 // Check if any resize/move states are active (only possible when unlocked)
1438 let has_active_resize = if !app_context_unwrapped.config.locked {
1439 let resize_state_guard = MUXBOX_RESIZE_STATE.lock().unwrap();
1440 resize_state_guard.is_some()
1441 } else {
1442 false
1443 };
1444
1445 let has_active_move = if !app_context_unwrapped.config.locked {
1446 let move_state_guard = MUXBOX_MOVE_STATE.lock().unwrap();
1447 move_state_guard.is_some()
1448 } else {
1449 false
1450 };
1451
1452 // F0188: If no resize or move is active, check if drag started on a scroll knob
1453 if !has_active_resize && !has_active_move {
1454 let mut drag_state = DRAG_STATE.lock().unwrap();
1455 *drag_state = None; // Clear any previous drag state
1456
1457 for muxbox in active_layout.get_all_muxboxes() {
1458 if muxbox.has_scrollable_content() {
1459 let muxbox_bounds = muxbox.bounds();
1460
1461 // Check if drag started on vertical scroll knob
1462 if *x as usize == muxbox_bounds.right()
1463 && *y as usize > muxbox_bounds.top()
1464 && (*y as usize) < muxbox_bounds.bottom()
1465 {
1466 // Check if we clicked on the actual knob, not just the track
1467 if is_on_vertical_knob(muxbox, *y as usize) {
1468 let current_scroll =
1469 muxbox.vertical_scroll.unwrap_or(0.0);
1470 *drag_state = Some(DragState {
1471 muxbox_id: muxbox.id.clone(),
1472 is_vertical: true,
1473 start_x: *x,
1474 start_y: *y,
1475 start_scroll_percentage: current_scroll,
1476 });
1477 log::trace!("Started dragging vertical scroll knob on muxbox {}", muxbox.id);
1478 break;
1479 }
1480 }
1481
1482 // Check if drag started on horizontal scroll knob
1483 if *y as usize == muxbox_bounds.bottom()
1484 && *x as usize > muxbox_bounds.left()
1485 && (*x as usize) < muxbox_bounds.right()
1486 {
1487 // Check if we clicked on the actual knob, not just the track
1488 if is_on_horizontal_knob(muxbox, *x as usize) {
1489 let current_scroll =
1490 muxbox.horizontal_scroll.unwrap_or(0.0);
1491 *drag_state = Some(DragState {
1492 muxbox_id: muxbox.id.clone(),
1493 is_vertical: false,
1494 start_x: *x,
1495 start_y: *y,
1496 start_scroll_percentage: current_scroll,
1497 });
1498 log::trace!("Started dragging horizontal scroll knob on muxbox {}", muxbox.id);
1499 break;
1500 }
1501 }
1502 }
1503 }
1504 }
1505 }
1506 Message::MouseDrag(x, y) => {
1507 // Skip resize/move operations when muxboxes are locked
1508 if !app_context_unwrapped.config.locked {
1509 // F0189: Handle muxbox border resize during drag
1510 let resize_state_guard = MUXBOX_RESIZE_STATE.lock().unwrap();
1511 if let Some(ref resize_state) = *resize_state_guard {
1512 let terminal_width = crate::screen_width();
1513 let terminal_height = crate::screen_height();
1514
1515 // FIXED: Handle 100% width panels where horizontal drag events may not work
1516 let (effective_x, effective_y) =
1517 if resize_state.original_bounds.x2 == "100%" {
1518 // For 100% width panels at rightmost edge, if no horizontal movement is detected,
1519 // use the vertical movement as a proxy for horizontal movement to enable resizing
1520 let horizontal_delta =
1521 (*x as i32) - (resize_state.start_x as i32);
1522 let vertical_delta =
1523 (*y as i32) - (resize_state.start_y as i32);
1524
1525 if horizontal_delta == 0 && vertical_delta != 0 {
1526 // No horizontal movement detected but vertical movement exists
1527 // Use diagonal movement: apply vertical delta to horizontal as well
1528 let adjusted_x =
1529 resize_state.start_x as i32 + vertical_delta;
1530 (adjusted_x.max(0) as u16, *y)
1531 } else {
1532 (*x, *y)
1533 }
1534 } else {
1535 (*x, *y)
1536 };
1537
1538 let new_bounds = calculate_new_bounds(
1539 &resize_state.original_bounds,
1540 &resize_state.resize_edge,
1541 resize_state.start_x,
1542 resize_state.start_y,
1543 effective_x,
1544 effective_y,
1545 terminal_width,
1546 terminal_height,
1547 );
1548
1549 // Update the muxbox bounds in real-time
1550 if let Some(muxbox) = app_context_unwrapped
1551 .app
1552 .get_muxbox_by_id_mut(&resize_state.muxbox_id)
1553 {
1554 muxbox.position = new_bounds;
1555 inner.update_app_context(app_context_unwrapped.clone());
1556 inner.send_message(Message::RedrawAppDiff);
1557 }
1558 }
1559
1560 // F0191: Handle muxbox movement during drag
1561 let move_state_guard = MUXBOX_MOVE_STATE.lock().unwrap();
1562 if let Some(ref move_state) = *move_state_guard {
1563 let terminal_width = crate::screen_width();
1564 let terminal_height = crate::screen_height();
1565
1566 let new_position = calculate_new_position(
1567 &move_state.original_bounds,
1568 move_state.start_x,
1569 move_state.start_y,
1570 *x,
1571 *y,
1572 terminal_width,
1573 terminal_height,
1574 );
1575
1576 // Update the muxbox position in real-time
1577 if let Some(muxbox) = app_context_unwrapped
1578 .app
1579 .get_muxbox_by_id_mut(&move_state.muxbox_id)
1580 {
1581 muxbox.position = new_position;
1582 inner.update_app_context(app_context_unwrapped.clone());
1583 inner.send_message(Message::RedrawAppDiff);
1584 }
1585 }
1586 }
1587
1588 // F0188: Handle scroll knob drag (always allowed, even when locked)
1589 let drag_state_guard = DRAG_STATE.lock().unwrap();
1590 if let Some(ref drag_state) = *drag_state_guard {
1591 let muxbox_to_update = app_context_unwrapped
1592 .app
1593 .get_muxbox_by_id_mut(&drag_state.muxbox_id);
1594
1595 if let Some(muxbox) = muxbox_to_update {
1596 let muxbox_bounds = muxbox.bounds();
1597
1598 if drag_state.is_vertical {
1599 // Calculate new vertical scroll percentage based on drag distance
1600 let track_height =
1601 (muxbox_bounds.height() as isize - 2).max(1) as usize;
1602 let drag_delta = (*y as isize) - (drag_state.start_y as isize);
1603 let percentage_delta =
1604 (drag_delta as f64 / track_height as f64) * 100.0;
1605 let new_percentage = (drag_state.start_scroll_percentage
1606 + percentage_delta)
1607 .min(100.0)
1608 .max(0.0);
1609
1610 muxbox.vertical_scroll = Some(new_percentage);
1611 } else {
1612 // Calculate new horizontal scroll percentage based on drag distance
1613 let track_width =
1614 (muxbox_bounds.width() as isize - 2).max(1) as usize;
1615 let drag_delta = (*x as isize) - (drag_state.start_x as isize);
1616 let percentage_delta =
1617 (drag_delta as f64 / track_width as f64) * 100.0;
1618 let new_percentage = (drag_state.start_scroll_percentage
1619 + percentage_delta)
1620 .min(100.0)
1621 .max(0.0);
1622
1623 muxbox.horizontal_scroll = Some(new_percentage);
1624 }
1625
1626 inner.update_app_context(app_context_unwrapped.clone());
1627 inner.send_message(Message::RedrawAppDiff);
1628 }
1629 }
1630 }
1631 Message::MouseDragEnd(x, y) => {
1632 // Only handle resize/move end when muxboxes are unlocked
1633 if !app_context_unwrapped.config.locked {
1634 // F0189: End muxbox resize operation
1635 let mut resize_state = MUXBOX_RESIZE_STATE.lock().unwrap();
1636 if let Some(ref resize_state_data) = *resize_state {
1637 log::trace!(
1638 "Ended muxbox resize at ({}, {}) for muxbox {}",
1639 x,
1640 y,
1641 resize_state_data.muxbox_id
1642 );
1643
1644 // Trigger YAML persistence
1645 inner.send_message(Message::MuxBoxResizeComplete(
1646 resize_state_data.muxbox_id.clone(),
1647 ));
1648 *resize_state = None; // Clear resize state
1649 } else {
1650 // F0191: End muxbox move operation
1651 let mut move_state = MUXBOX_MOVE_STATE.lock().unwrap();
1652 if let Some(ref move_state_data) = *move_state {
1653 log::trace!(
1654 "Ended muxbox move at ({}, {}) for muxbox {}",
1655 x,
1656 y,
1657 move_state_data.muxbox_id
1658 );
1659
1660 // Trigger YAML persistence for new position
1661 inner.send_message(Message::MuxBoxMoveComplete(
1662 move_state_data.muxbox_id.clone(),
1663 ));
1664 *move_state = None; // Clear move state
1665 }
1666 }
1667 }
1668
1669 // F0188: End scroll knob drag operation (always allowed, even when locked)
1670 let mut drag_state = DRAG_STATE.lock().unwrap();
1671 if drag_state.is_some() {
1672 log::trace!("Ended scroll knob drag at ({}, {})", x, y);
1673 *drag_state = None; // Clear drag state
1674 }
1675 }
1676 Message::ChoiceExecutionComplete(choice_id, muxbox_id, result) => {
1677 log::info!(
1678 "=== DRAWLOOP RECEIVED CHOICE EXECUTION COMPLETE: {} on muxbox {} ===",
1679 choice_id,
1680 muxbox_id
1681 );
1682 match result {
1683 Ok(ref output) => log::info!(
1684 "DrawLoop processing choice success: {} chars of output",
1685 output.len()
1686 ),
1687 Err(ref error) => {
1688 log::error!("DrawLoop processing choice error: {}", error)
1689 }
1690 }
1691
1692 // First update the choice waiting state
1693 if let Some(muxbox) =
1694 app_context_unwrapped.app.get_muxbox_by_id_mut(muxbox_id)
1695 {
1696 if let Some(ref mut choices) = muxbox.choices {
1697 if let Some(choice) =
1698 choices.iter_mut().find(|c| c.id == *choice_id)
1699 {
1700 choice.waiting = false;
1701 }
1702 }
1703 }
1704
1705 // Then handle the output in a separate scope to avoid borrow conflicts
1706 let target_muxbox_id = {
1707 if let Some(muxbox) =
1708 app_context_unwrapped.app.get_muxbox_by_id(muxbox_id)
1709 {
1710 if let Some(ref choices) = muxbox.choices {
1711 if let Some(choice) =
1712 choices.iter().find(|c| c.id == *choice_id)
1713 {
1714 let redirect_target = choice
1715 .redirect_output
1716 .as_ref()
1717 .unwrap_or(muxbox_id)
1718 .clone();
1719 log::info!(
1720 "Choice {} redirect_output: {:?} -> target muxbox: {}",
1721 choice_id,
1722 choice.redirect_output,
1723 redirect_target
1724 );
1725 redirect_target
1726 } else {
1727 log::warn!(
1728 "Choice {} not found in muxbox {}",
1729 choice_id,
1730 muxbox_id
1731 );
1732 muxbox_id.clone()
1733 }
1734 } else {
1735 log::warn!("MuxBox {} has no choices", muxbox_id);
1736 muxbox_id.clone()
1737 }
1738 } else {
1739 log::error!("MuxBox {} not found", muxbox_id);
1740 muxbox_id.clone()
1741 }
1742 };
1743
1744 let append = {
1745 if let Some(muxbox) =
1746 app_context_unwrapped.app.get_muxbox_by_id(muxbox_id)
1747 {
1748 if let Some(ref choices) = muxbox.choices {
1749 if let Some(choice) =
1750 choices.iter().find(|c| c.id == *choice_id)
1751 {
1752 choice.append_output.unwrap_or(false)
1753 } else {
1754 false
1755 }
1756 } else {
1757 false
1758 }
1759 } else {
1760 false
1761 }
1762 };
1763
1764 match result {
1765 Ok(output) => {
1766 log::info!(
1767 "Choice {} output length: {} chars, redirecting to muxbox: {}",
1768 choice_id,
1769 output.len(),
1770 target_muxbox_id
1771 );
1772 update_muxbox_content(
1773 inner,
1774 &mut app_context_unwrapped,
1775 &target_muxbox_id,
1776 true,
1777 append,
1778 output,
1779 );
1780 }
1781 Err(error) => {
1782 log::error!("Error running choice script: {}", error);
1783 update_muxbox_content(
1784 inner,
1785 &mut app_context_unwrapped,
1786 &target_muxbox_id,
1787 false,
1788 append,
1789 error,
1790 );
1791 }
1792 }
1793 }
1794 Message::MuxBoxResizeComplete(muxbox_id) => {
1795 // F0190: Save muxbox bounds changes to YAML file
1796 log::info!(
1797 "Saving muxbox resize changes to YAML for muxbox: {}",
1798 muxbox_id
1799 );
1800
1801 // Get the updated muxbox bounds
1802 if let Some(muxbox) = app_context_unwrapped.app.get_muxbox_by_id(muxbox_id)
1803 {
1804 let new_bounds = &muxbox.position;
1805 log::debug!(
1806 "New bounds for muxbox {}: x1={}, y1={}, x2={}, y2={}",
1807 muxbox_id,
1808 new_bounds.x1,
1809 new_bounds.y1,
1810 new_bounds.x2,
1811 new_bounds.y2
1812 );
1813
1814 // Find the original YAML file path
1815 if let Some(yaml_path) = &app_context_unwrapped.yaml_file_path {
1816 match save_muxbox_bounds_to_yaml(yaml_path, muxbox_id, new_bounds) {
1817 Ok(()) => {
1818 log::info!(
1819 "Successfully saved muxbox {} bounds to YAML file",
1820 muxbox_id
1821 );
1822 }
1823 Err(e) => {
1824 log::error!(
1825 "Failed to save muxbox {} bounds to YAML: {}",
1826 muxbox_id,
1827 e
1828 );
1829 }
1830 }
1831 } else {
1832 log::error!("CRITICAL: No YAML file path available for saving muxbox bounds - resize changes will not persist!");
1833 }
1834 } else {
1835 log::error!("MuxBox {} not found for saving bounds", muxbox_id);
1836 }
1837 }
1838 Message::MuxBoxMoveComplete(muxbox_id) => {
1839 // F0191: Save muxbox position changes to YAML file
1840 log::info!(
1841 "Saving muxbox move changes to YAML for muxbox: {}",
1842 muxbox_id
1843 );
1844
1845 // Get the updated muxbox position
1846 if let Some(muxbox) = app_context_unwrapped.app.get_muxbox_by_id(muxbox_id)
1847 {
1848 let new_position = &muxbox.position;
1849 log::debug!(
1850 "New position for muxbox {}: x1={}, y1={}, x2={}, y2={}",
1851 muxbox_id,
1852 new_position.x1,
1853 new_position.y1,
1854 new_position.x2,
1855 new_position.y2
1856 );
1857
1858 // Find the original YAML file path
1859 if let Some(yaml_path) = &app_context_unwrapped.yaml_file_path {
1860 match save_muxbox_bounds_to_yaml(yaml_path, muxbox_id, new_position)
1861 {
1862 Ok(()) => {
1863 log::info!(
1864 "Successfully saved muxbox {} position to YAML file",
1865 muxbox_id
1866 );
1867 }
1868 Err(e) => {
1869 log::error!(
1870 "Failed to save muxbox {} position to YAML: {}",
1871 muxbox_id,
1872 e
1873 );
1874 }
1875 }
1876 } else {
1877 log::error!("CRITICAL: No YAML file path available for saving muxbox position - move changes will not persist!");
1878 }
1879 } else {
1880 log::error!("MuxBox {} not found for saving position", muxbox_id);
1881 }
1882 }
1883 Message::SaveYamlState => {
1884 // F0200: Save complete application state to YAML
1885 log::info!("Saving complete application state to YAML");
1886
1887 if let Some(yaml_path) = &app_context_unwrapped.yaml_file_path {
1888 match save_complete_state_to_yaml(yaml_path, &app_context_unwrapped) {
1889 Ok(()) => {
1890 log::info!("Successfully saved complete state to YAML file");
1891 }
1892 Err(e) => {
1893 log::error!("Failed to save complete state to YAML: {}", e);
1894 }
1895 }
1896 } else {
1897 log::error!("CRITICAL: No YAML file path available for saving complete state!");
1898 }
1899 }
1900 Message::SaveActiveLayout(layout_id) => {
1901 // F0200: Save active layout to YAML
1902 log::info!("Saving active layout '{}' to YAML", layout_id);
1903
1904 if let Some(yaml_path) = &app_context_unwrapped.yaml_file_path {
1905 match save_active_layout_to_yaml(yaml_path, layout_id) {
1906 Ok(()) => {
1907 log::info!("Successfully saved active layout to YAML file");
1908 }
1909 Err(e) => {
1910 log::error!("Failed to save active layout to YAML: {}", e);
1911 }
1912 }
1913 } else {
1914 log::error!("CRITICAL: No YAML file path available for saving active layout!");
1915 }
1916 }
1917 Message::SaveMuxBoxContent(muxbox_id, content) => {
1918 // F0200: Save muxbox content changes to YAML
1919 log::debug!("Saving content changes to YAML for muxbox: {}", muxbox_id);
1920
1921 if let Some(yaml_path) = &app_context_unwrapped.yaml_file_path {
1922 match save_muxbox_content_to_yaml(yaml_path, muxbox_id, content) {
1923 Ok(()) => {
1924 log::debug!("Successfully saved muxbox {} content to YAML", muxbox_id);
1925 }
1926 Err(e) => {
1927 log::error!("Failed to save muxbox {} content to YAML: {}", muxbox_id, e);
1928 }
1929 }
1930 } else {
1931 log::warn!("No YAML file path available for saving muxbox content");
1932 }
1933 }
1934 Message::SaveMuxBoxScroll(muxbox_id, scroll_x, scroll_y) => {
1935 // F0200: Save muxbox scroll position to YAML
1936 log::debug!("Saving scroll position to YAML for muxbox: {} ({}, {})", muxbox_id, scroll_x, scroll_y);
1937
1938 if let Some(yaml_path) = &app_context_unwrapped.yaml_file_path {
1939 match save_muxbox_scroll_to_yaml(yaml_path, muxbox_id, *scroll_x, *scroll_y) {
1940 Ok(()) => {
1941 log::debug!("Successfully saved muxbox {} scroll position to YAML", muxbox_id);
1942 }
1943 Err(e) => {
1944 log::error!("Failed to save muxbox {} scroll position to YAML: {}", muxbox_id, e);
1945 }
1946 }
1947 } else {
1948 log::warn!("No YAML file path available for saving muxbox scroll position");
1949 }
1950 }
1951 Message::SwitchActiveLayout(layout_id) => {
1952 // F0200: Switch active layout with YAML persistence
1953 log::info!("Switching to active layout: {}", layout_id);
1954
1955 // Update the active layout in app context
1956 let mut app_context_cloned = app_context_unwrapped.clone();
1957 match app_context_cloned.app.set_active_layout_with_yaml_save(
1958 layout_id,
1959 app_context_cloned.yaml_file_path.as_deref()
1960 ) {
1961 Ok(()) => {
1962 inner.update_app_context(app_context_cloned);
1963 inner.send_message(Message::RedrawApp);
1964 log::info!("Successfully switched to layout '{}' with YAML persistence", layout_id);
1965 }
1966 Err(e) => {
1967 log::error!("Failed to switch layout with YAML persistence: {}", e);
1968 // Still update app context without YAML persistence
1969 app_context_cloned.app.set_active_layout(layout_id);
1970 inner.update_app_context(app_context_cloned);
1971 inner.send_message(Message::RedrawApp);
1972 }
1973 }
1974 }
1975 _ => {}
1976 }
1977 }
1978
1979 // T311: Choice execution now handled via ChoiceExecutionComplete messages
1980 // Old POOL-based choice results processing removed
1981
1982 // Ensure the loop continues by sleeping briefly
1983 std::thread::sleep(std::time::Duration::from_millis(
1984 app_context.config.frame_delay,
1985 ));
1986 return (should_continue, app_context_unwrapped);
1987 }
1988
1989 (should_continue, app_context)
1990 }
1991);
1992
1993pub fn update_muxbox_content(
1994 inner: &mut RunnableImpl,
1995 app_context_unwrapped: &mut AppContext,
1996 muxbox_id: &str,
1997 success: bool,
1998 append_output: bool,
1999 output: &str,
2000) {
2001 log::info!(
2002 "=== UPDATE MUXBOX CONTENT: {} (success: {}, append: {}, output_len: {}) ===",
2003 muxbox_id,
2004 success,
2005 append_output,
2006 output.len()
2007 );
2008
2009 let mut app_context_unwrapped_cloned = app_context_unwrapped.clone();
2010 let muxbox = app_context_unwrapped.app.get_muxbox_by_id_mut(muxbox_id);
2011
2012 if let Some(found_muxbox) = muxbox {
2013 log::info!(
2014 "Found target muxbox: {} (redirect_output: {:?})",
2015 muxbox_id,
2016 found_muxbox.redirect_output
2017 );
2018
2019 if found_muxbox.redirect_output.is_some()
2020 && found_muxbox.redirect_output.as_ref().unwrap() != muxbox_id
2021 {
2022 log::info!(
2023 "MuxBox {} has its own redirect to: {}, following redirect chain",
2024 muxbox_id,
2025 found_muxbox.redirect_output.as_ref().unwrap()
2026 );
2027 update_muxbox_content(
2028 inner,
2029 &mut app_context_unwrapped_cloned,
2030 found_muxbox.redirect_output.as_ref().unwrap(),
2031 success,
2032 append_output,
2033 output,
2034 );
2035 } else {
2036 log::info!(
2037 "Updating muxbox {} content directly (no redirection)",
2038 muxbox_id
2039 );
2040 log::info!(
2041 "MuxBox {} current content length: {} chars",
2042 muxbox_id,
2043 found_muxbox.content.as_ref().map_or(0, |c| c.len())
2044 );
2045
2046 // Check if this is PTY streaming output by the newline indicator
2047 let is_pty_streaming = output.ends_with('\n');
2048
2049 if is_pty_streaming {
2050 // Use streaming update for PTY output (no timestamp formatting)
2051 log::info!("Using streaming update for muxbox {}", muxbox_id);
2052 found_muxbox.update_streaming_content(output, success);
2053 } else {
2054 // Use regular update for non-PTY output
2055 log::info!(
2056 "Using regular update for muxbox {} (append: {})",
2057 muxbox_id,
2058 append_output
2059 );
2060 found_muxbox.update_content(output, append_output, success);
2061 }
2062
2063 log::info!(
2064 "MuxBox {} updated content length: {} chars",
2065 muxbox_id,
2066 found_muxbox.content.as_ref().map_or(0, |c| c.len())
2067 );
2068 inner.update_app_context(app_context_unwrapped.clone());
2069 inner.send_message(Message::RedrawMuxBox(muxbox_id.to_string()));
2070 log::info!("Sent RedrawMuxBox message for muxbox: {}", muxbox_id);
2071 }
2072 } else {
2073 log::error!("Could not find muxbox {} for content update.", muxbox_id);
2074 // List available muxboxes for debugging
2075 let available_muxboxes: Vec<String> = app_context_unwrapped
2076 .app
2077 .get_active_layout()
2078 .unwrap()
2079 .get_all_muxboxes()
2080 .iter()
2081 .map(|p| p.id.clone())
2082 .collect();
2083 log::error!("Available muxboxes: {:?}", available_muxboxes);
2084 }
2085}
2086
2087/// Extract muxbox content for clipboard copy
2088pub fn get_muxbox_content_for_clipboard(muxbox: &MuxBox) -> String {
2089 // Priority order: output > content > default message
2090 if !muxbox.output.is_empty() {
2091 muxbox.output.clone()
2092 } else if let Some(content) = &muxbox.content {
2093 content.clone()
2094 } else {
2095 format!("MuxBox '{}': No content", muxbox.id)
2096 }
2097}
2098
2099/// Copy text to system clipboard
2100pub fn copy_to_clipboard(content: &str) -> Result<(), Box<dyn std::error::Error>> {
2101 use std::process::Command;
2102
2103 // Platform-specific clipboard commands
2104 #[cfg(target_os = "macos")]
2105 {
2106 let mut child = Command::new("pbcopy")
2107 .stdin(std::process::Stdio::piped())
2108 .spawn()?;
2109
2110 if let Some(stdin) = child.stdin.take() {
2111 use std::io::Write;
2112 let mut stdin = stdin;
2113 stdin.write_all(content.as_bytes())?;
2114 }
2115
2116 child.wait()?;
2117 }
2118
2119 #[cfg(target_os = "linux")]
2120 {
2121 // Try xclip first, then xsel as fallback
2122 let result = Command::new("xclip")
2123 .arg("-selection")
2124 .arg("clipboard")
2125 .stdin(std::process::Stdio::piped())
2126 .spawn();
2127
2128 match result {
2129 Ok(mut child) => {
2130 if let Some(stdin) = child.stdin.take() {
2131 use std::io::Write;
2132 let mut stdin = stdin;
2133 stdin.write_all(content.as_bytes())?;
2134 }
2135 child.wait()?;
2136 }
2137 Err(_) => {
2138 // Fallback to xsel
2139 let mut child = Command::new("xsel")
2140 .arg("--clipboard")
2141 .arg("--input")
2142 .stdin(std::process::Stdio::piped())
2143 .spawn()?;
2144
2145 if let Some(stdin) = child.stdin.take() {
2146 use std::io::Write;
2147 let mut stdin = stdin;
2148 stdin.write_all(content.as_bytes())?;
2149 }
2150 child.wait()?;
2151 }
2152 }
2153 }
2154
2155 #[cfg(target_os = "windows")]
2156 {
2157 let mut child = Command::new("clip")
2158 .stdin(std::process::Stdio::piped())
2159 .spawn()?;
2160
2161 if let Some(stdin) = child.stdin.take() {
2162 use std::io::Write;
2163 let mut stdin = stdin;
2164 stdin.write_all(content.as_bytes())?;
2165 }
2166
2167 child.wait()?;
2168 }
2169
2170 Ok(())
2171}
2172
2173/// Calculate which choice was clicked based on muxbox bounds and click coordinates
2174/// T0258: Enhanced to check both X and Y coordinates against actual choice text bounds
2175/// Only clicks on actual choice text (not empty space after text) trigger choice activation
2176#[cfg(test)]
2177pub fn calculate_clicked_choice_index(
2178 muxbox: &MuxBox,
2179 click_x: u16,
2180 click_y: u16,
2181 choices: &[crate::model::muxbox::Choice],
2182) -> Option<usize> {
2183 calculate_clicked_choice_index_impl(muxbox, click_x, click_y, choices)
2184}
2185
2186#[cfg(not(test))]
2187fn calculate_clicked_choice_index(
2188 muxbox: &MuxBox,
2189 click_x: u16,
2190 click_y: u16,
2191 choices: &[crate::model::muxbox::Choice],
2192) -> Option<usize> {
2193 calculate_clicked_choice_index_impl(muxbox, click_x, click_y, choices)
2194}
2195
2196fn calculate_clicked_choice_index_impl(
2197 muxbox: &MuxBox,
2198 click_x: u16,
2199 click_y: u16,
2200 choices: &[crate::model::muxbox::Choice],
2201) -> Option<usize> {
2202 let bounds = muxbox.bounds();
2203 let muxbox_top = bounds.y1 as u16;
2204
2205 if click_y < muxbox_top || choices.is_empty() {
2206 return None;
2207 }
2208
2209 // Choices start at bounds.top() + 1 (one line below border) as per draw_utils.rs:610
2210 let choices_start_y = muxbox_top + 1;
2211
2212 if click_y < choices_start_y {
2213 return None; // Click was on border or title area
2214 }
2215
2216 // Check if this muxbox uses text wrapping by checking overflow_behavior directly
2217 // Note: We assume "wrap" behavior since this function will be called from contexts
2218 // where the overflow behavior is already determined to be "wrap"
2219 if let Some(overflow_behavior) = &muxbox.overflow_behavior {
2220 if overflow_behavior == "wrap" {
2221 return calculate_wrapped_choice_click(muxbox, click_x, click_y, choices);
2222 }
2223 }
2224
2225 // Original logic for non-wrapped choices
2226 let choice_index = (click_y - choices_start_y) as usize;
2227
2228 // Ensure click is within choice bounds (don't exceed available choices or muxbox height)
2229 let muxbox_bottom = bounds.y2 as u16;
2230 if choice_index >= choices.len() || click_y >= muxbox_bottom {
2231 return None;
2232 }
2233
2234 // T0258: Check if click is within the actual text bounds of the choice
2235 if let Some(choice) = choices.get(choice_index) {
2236 if let Some(content) = &choice.content {
2237 // Choices are rendered at bounds.left() + 2 (per draw_utils.rs:636)
2238 let choice_text_start_x = bounds.left() + 2;
2239
2240 // Format the content as it appears (including "..." for waiting choices)
2241 let formatted_content = if choice.waiting {
2242 format!("{}...", content)
2243 } else {
2244 content.clone()
2245 };
2246
2247 let choice_text_end_x = choice_text_start_x + formatted_content.len();
2248
2249 // Check if click X is within the actual text bounds
2250 if (click_x as usize) >= choice_text_start_x && (click_x as usize) < choice_text_end_x {
2251 Some(choice_index)
2252 } else {
2253 None // Click was after the text on the same line - should only select muxbox
2254 }
2255 } else {
2256 None // Choice has no content to click on
2257 }
2258 } else {
2259 None
2260 }
2261}
2262
2263/// Handle click detection for wrapped choices
2264fn calculate_wrapped_choice_click(
2265 muxbox: &MuxBox,
2266 click_x: u16,
2267 click_y: u16,
2268 choices: &[crate::model::muxbox::Choice],
2269) -> Option<usize> {
2270 let bounds = muxbox.bounds();
2271 let viewable_width = bounds.width().saturating_sub(4);
2272 let choices_start_y = bounds.y1 as u16 + 1;
2273 let choice_text_start_x = bounds.left() + 2;
2274
2275 // Create wrapped choice lines (same logic as in draw_utils.rs)
2276 let mut wrapped_choices = Vec::new();
2277
2278 for (choice_idx, choice) in choices.iter().enumerate() {
2279 if let Some(content) = &choice.content {
2280 let formatted_content = if choice.waiting {
2281 format!("{}...", content)
2282 } else {
2283 content.clone()
2284 };
2285
2286 let wrapped_lines = wrap_text_to_width_simple(&formatted_content, viewable_width);
2287
2288 for wrapped_line in wrapped_lines {
2289 wrapped_choices.push((choice_idx, wrapped_line));
2290 }
2291 }
2292 }
2293
2294 if wrapped_choices.is_empty() {
2295 return None;
2296 }
2297
2298 // Calculate which wrapped line was clicked
2299 let clicked_line_index = (click_y - choices_start_y) as usize;
2300
2301 if clicked_line_index >= wrapped_choices.len() {
2302 return None;
2303 }
2304
2305 let (original_choice_index, line_content) = &wrapped_choices[clicked_line_index];
2306 let choice_text_end_x = choice_text_start_x + line_content.len();
2307
2308 // Check if click X is within the actual text bounds of this wrapped line
2309 if (click_x as usize) >= choice_text_start_x && (click_x as usize) < choice_text_end_x {
2310 Some(*original_choice_index)
2311 } else {
2312 None // Click was after the text on the same line - should only select muxbox
2313 }
2314}
2315
2316/// Simple text wrapping for click detection (matches draw_utils.rs logic)
2317fn wrap_text_to_width_simple(text: &str, width: usize) -> Vec<String> {
2318 if width == 0 {
2319 return vec![text.to_string()];
2320 }
2321
2322 let mut wrapped_lines = Vec::new();
2323
2324 for line in text.lines() {
2325 if line.len() <= width {
2326 wrapped_lines.push(line.to_string());
2327 continue;
2328 }
2329
2330 // Split long line into multiple wrapped lines
2331 let mut current_line = String::new();
2332 let mut current_width = 0;
2333
2334 for word in line.split_whitespace() {
2335 let word_len = word.len();
2336
2337 // If word itself is longer than width, break it
2338 if word_len > width {
2339 // Finish current line if it has content
2340 if !current_line.is_empty() {
2341 wrapped_lines.push(current_line.clone());
2342 current_line.clear();
2343 current_width = 0;
2344 }
2345
2346 // Break the long word across multiple lines
2347 let mut remaining_word = word;
2348 while remaining_word.len() > width {
2349 let (chunk, rest) = remaining_word.split_at(width);
2350 wrapped_lines.push(chunk.to_string());
2351 remaining_word = rest;
2352 }
2353
2354 if !remaining_word.is_empty() {
2355 current_line = remaining_word.to_string();
2356 current_width = remaining_word.len();
2357 }
2358 continue;
2359 }
2360
2361 // Check if adding this word would exceed width
2362 let space_needed = if current_line.is_empty() { 0 } else { 1 }; // Space before word
2363 if current_width + space_needed + word_len > width {
2364 // Start new line with this word
2365 if !current_line.is_empty() {
2366 wrapped_lines.push(current_line.clone());
2367 }
2368 current_line = word.to_string();
2369 current_width = word_len;
2370 } else {
2371 // Add word to current line
2372 if !current_line.is_empty() {
2373 current_line.push(' ');
2374 current_width += 1;
2375 }
2376 current_line.push_str(word);
2377 current_width += word_len;
2378 }
2379 }
2380
2381 // Add final line if it has content
2382 if !current_line.is_empty() {
2383 wrapped_lines.push(current_line);
2384 }
2385 }
2386
2387 if wrapped_lines.is_empty() {
2388 wrapped_lines.push(String::new());
2389 }
2390
2391 wrapped_lines
2392}
2393
2394/// Auto-scroll to ensure selected choice is visible
2395fn auto_scroll_to_selected_choice(muxbox: &mut crate::model::muxbox::MuxBox, selected_choice_index: usize) {
2396 use crate::draw_utils::wrap_text_to_width;
2397
2398 let bounds = muxbox.bounds();
2399 let viewable_height = bounds.height().saturating_sub(2); // Account for borders
2400
2401 // Handle different overflow behaviors
2402 if let Some(overflow_behavior) = &muxbox.overflow_behavior {
2403 match overflow_behavior.as_str() {
2404 "wrap" => {
2405 // Calculate wrapped lines for auto-scroll in wrapped choice mode
2406 if let Some(choices) = &muxbox.choices {
2407 let viewable_width = bounds.width().saturating_sub(4);
2408 let mut total_lines = 0;
2409 let mut selected_line_start = 0;
2410 let mut selected_line_end = 0;
2411
2412 for (i, choice) in choices.iter().enumerate() {
2413 if let Some(content) = &choice.content {
2414 let formatted_content = if choice.waiting {
2415 format!("{}...", content)
2416 } else {
2417 content.clone()
2418 };
2419
2420 let wrapped_lines = wrap_text_to_width(&formatted_content, viewable_width);
2421 let line_count = wrapped_lines.len();
2422
2423 if i == selected_choice_index {
2424 selected_line_start = total_lines;
2425 selected_line_end = total_lines + line_count - 1;
2426 }
2427
2428 total_lines += line_count;
2429 }
2430 }
2431
2432 // Adjust scroll to keep selected wrapped lines visible
2433 if total_lines > viewable_height {
2434 let current_scroll_percent = muxbox.vertical_scroll.unwrap_or(0.0);
2435 let current_scroll_offset = ((current_scroll_percent / 100.0) * (total_lines - viewable_height) as f64).floor() as usize;
2436 let visible_start = current_scroll_offset;
2437 let visible_end = visible_start + viewable_height - 1;
2438
2439 let mut new_scroll_percent = current_scroll_percent;
2440
2441 // Scroll down if selected choice is below visible area
2442 if selected_line_end > visible_end {
2443 let new_offset = selected_line_end.saturating_sub(viewable_height - 1);
2444 new_scroll_percent = (new_offset as f64 / (total_lines - viewable_height) as f64) * 100.0;
2445 }
2446 // Scroll up if selected choice is above visible area
2447 else if selected_line_start < visible_start {
2448 let new_offset = selected_line_start;
2449 new_scroll_percent = (new_offset as f64 / (total_lines - viewable_height) as f64) * 100.0;
2450 }
2451
2452 muxbox.vertical_scroll = Some(new_scroll_percent.clamp(0.0, 100.0));
2453 }
2454 }
2455 },
2456 "scroll" => {
2457 // For scroll mode, use choice index directly
2458 if let Some(choices) = &muxbox.choices {
2459 let total_choices = choices.len();
2460 if total_choices > viewable_height {
2461 let current_scroll_percent = muxbox.vertical_scroll.unwrap_or(0.0);
2462 let current_scroll_offset = ((current_scroll_percent / 100.0) * (total_choices - viewable_height) as f64).floor() as usize;
2463 let visible_start = current_scroll_offset;
2464 let visible_end = visible_start + viewable_height - 1;
2465
2466 let mut new_scroll_percent = current_scroll_percent;
2467
2468 // Scroll down if selected choice is below visible area
2469 if selected_choice_index > visible_end {
2470 let new_offset = selected_choice_index.saturating_sub(viewable_height - 1);
2471 new_scroll_percent = (new_offset as f64 / (total_choices - viewable_height) as f64) * 100.0;
2472 }
2473 // Scroll up if selected choice is above visible area
2474 else if selected_choice_index < visible_start {
2475 let new_offset = selected_choice_index;
2476 new_scroll_percent = (new_offset as f64 / (total_choices - viewable_height) as f64) * 100.0;
2477 }
2478
2479 muxbox.vertical_scroll = Some(new_scroll_percent.clamp(0.0, 100.0));
2480 }
2481 }
2482 },
2483 _ => {
2484 // For other overflow behaviors (fill, cross_out, etc.), use simple choice index
2485 if let Some(choices) = &muxbox.choices {
2486 let total_choices = choices.len();
2487 if total_choices > viewable_height {
2488 let current_scroll_percent = muxbox.vertical_scroll.unwrap_or(0.0);
2489 let current_scroll_offset = ((current_scroll_percent / 100.0) * (total_choices - viewable_height) as f64).floor() as usize;
2490 let visible_start = current_scroll_offset;
2491 let visible_end = visible_start + viewable_height - 1;
2492
2493 let mut new_scroll_percent = current_scroll_percent;
2494
2495 if selected_choice_index > visible_end {
2496 let new_offset = selected_choice_index.saturating_sub(viewable_height - 1);
2497 new_scroll_percent = (new_offset as f64 / (total_choices - viewable_height) as f64) * 100.0;
2498 } else if selected_choice_index < visible_start {
2499 let new_offset = selected_choice_index;
2500 new_scroll_percent = (new_offset as f64 / (total_choices - viewable_height) as f64) * 100.0;
2501 }
2502
2503 muxbox.vertical_scroll = Some(new_scroll_percent.clamp(0.0, 100.0));
2504 }
2505 }
2506 }
2507 }
2508 }
2509}
2510
2511/// Trigger visual flash for muxbox (stub implementation)
2512fn trigger_muxbox_flash(_muxbox_id: &str) {
2513 // TODO: Implement visual flash with color inversion
2514 // This would require storing flash state and modifying muxbox rendering
2515 // For now, the redraw provides visual feedback
2516}