@@ -60,6 +60,8 @@ pub struct App { |
| 60 | 60 | jump_time: Option<Instant>, |
| 61 | 61 | /// Kill confirmation: (pid, signal, process_name) |
| 62 | 62 | kill_confirm: Option<(i32, i32, String)>, |
| 63 | + /// Process detail view: PID of process being viewed |
| 64 | + detail_pid: Option<i32>, |
| 63 | 65 | status: Option<StatusInfo>, |
| 64 | 66 | cpu_stats: Option<CpuStats>, |
| 65 | 67 | memory_stats: Option<MemoryStats>, |
@@ -153,6 +155,7 @@ impl App { |
| 153 | 155 | jump_pattern: String::new(), |
| 154 | 156 | jump_time: None, |
| 155 | 157 | kill_confirm: None, |
| 158 | + detail_pid: None, |
| 156 | 159 | status: None, |
| 157 | 160 | cpu_stats: None, |
| 158 | 161 | memory_stats: None, |
@@ -371,6 +374,13 @@ impl App { |
| 371 | 374 | self.render_tab_content(content_y, content_height)?; |
| 372 | 375 | } |
| 373 | 376 | |
| 377 | + // Process detail view (rendered on top of content) |
| 378 | + if let Some(pid) = self.detail_pid { |
| 379 | + if let Some(process) = self.processes.iter().find(|p| p.pid == pid) { |
| 380 | + self.render_detail_view(process)?; |
| 381 | + } |
| 382 | + } |
| 383 | + |
| 374 | 384 | // Help overlay (rendered on top) |
| 375 | 385 | if self.show_help { |
| 376 | 386 | self.render_help_overlay()?; |
@@ -462,11 +472,11 @@ impl App { |
| 462 | 472 | ("\u{2191} / \u{2193}", "Navigate list"), |
| 463 | 473 | ("Home / End", "First / last"), |
| 464 | 474 | ("PgUp/PgDn", "Jump 10 rows"), |
| 475 | + ("Enter", "Process detail"), |
| 465 | 476 | ("", ""), |
| 466 | 477 | ("Alt+f", "Freeze list"), |
| 467 | 478 | ("/", "Search filter"), |
| 468 | 479 | ("a-z", "Fuzzy jump"), |
| 469 | | - ("Backspace", "Delete jump char"), |
| 470 | 480 | ("", ""), |
| 471 | 481 | ("K", "Kill (SIGTERM)"), |
| 472 | 482 | ("X", "Kill (SIGKILL)"), |
@@ -545,6 +555,187 @@ impl App { |
| 545 | 555 | Ok(()) |
| 546 | 556 | } |
| 547 | 557 | |
| 558 | + /// Render process detail view overlay. |
| 559 | + fn render_detail_view(&self, process: &ProcessInfo) -> Result<()> { |
| 560 | + // Full-screen backdrop |
| 561 | + let backdrop = Rect::new(0, 0, self.width, self.height); |
| 562 | + self.renderer.fill_rect(backdrop, gartk_core::Color::new(0.0, 0.0, 0.0, 0.85))?; |
| 563 | + |
| 564 | + // Detail panel (slightly smaller than full screen) |
| 565 | + let margin = 24u32; |
| 566 | + let panel_rect = Rect::new( |
| 567 | + margin as i32, |
| 568 | + margin as i32, |
| 569 | + self.width - margin * 2, |
| 570 | + self.height - margin * 2, |
| 571 | + ); |
| 572 | + self.renderer.fill_rounded_rect(panel_rect, 8.0, self.theme.panel_bg)?; |
| 573 | + |
| 574 | + let x = margin as f64 + 20.0; |
| 575 | + let right_col = (self.width / 2) as f64 + 20.0; |
| 576 | + let mut y = margin as f64 + 24.0; |
| 577 | + |
| 578 | + // Title |
| 579 | + let title_style = TextStyle { |
| 580 | + font_family: "monospace".to_string(), |
| 581 | + font_size: 14.0, |
| 582 | + color: self.theme.text, |
| 583 | + ..Default::default() |
| 584 | + }; |
| 585 | + let title = format!("Process: {} (PID {})", process.name, process.pid); |
| 586 | + self.renderer.text(&title, x, y, &title_style)?; |
| 587 | + |
| 588 | + // Back hint |
| 589 | + let hint_style = TextStyle { |
| 590 | + font_family: "monospace".to_string(), |
| 591 | + font_size: 10.0, |
| 592 | + color: self.theme.text_secondary, |
| 593 | + ..Default::default() |
| 594 | + }; |
| 595 | + self.renderer.text("[Esc] Back", (self.width - margin - 80) as f64, y, &hint_style)?; |
| 596 | + |
| 597 | + // Separator |
| 598 | + y += 20.0; |
| 599 | + self.renderer.line( |
| 600 | + x, y, |
| 601 | + (self.width - margin * 2) as f64 + x - 20.0, y, |
| 602 | + self.theme.border, 1.0 |
| 603 | + )?; |
| 604 | + y += 16.0; |
| 605 | + |
| 606 | + // Info labels and values |
| 607 | + let label_style = TextStyle { |
| 608 | + font_family: "monospace".to_string(), |
| 609 | + font_size: 11.0, |
| 610 | + color: self.theme.text_secondary, |
| 611 | + ..Default::default() |
| 612 | + }; |
| 613 | + let value_style = TextStyle { |
| 614 | + font_family: "monospace".to_string(), |
| 615 | + font_size: 11.0, |
| 616 | + color: self.theme.text, |
| 617 | + ..Default::default() |
| 618 | + }; |
| 619 | + |
| 620 | + // Left column |
| 621 | + let row_height = 20.0; |
| 622 | + |
| 623 | + // State |
| 624 | + self.renderer.text("State:", x, y, &label_style)?; |
| 625 | + let state_color = match process.state.as_str() { |
| 626 | + "R" => self.theme.cpu_color, // Running |
| 627 | + "S" => self.theme.memory_color, // Sleeping |
| 628 | + "Z" => self.theme.swap_color, // Zombie |
| 629 | + "T" => self.theme.disk_color, // Stopped |
| 630 | + _ => self.theme.text, |
| 631 | + }; |
| 632 | + let state_style = TextStyle { color: state_color, ..value_style.clone() }; |
| 633 | + let state_name = match process.state.as_str() { |
| 634 | + "R" => "Running", |
| 635 | + "S" => "Sleeping", |
| 636 | + "D" => "Disk Sleep", |
| 637 | + "Z" => "Zombie", |
| 638 | + "T" => "Stopped", |
| 639 | + "t" => "Tracing", |
| 640 | + "X" => "Dead", |
| 641 | + _ => &process.state, |
| 642 | + }; |
| 643 | + self.renderer.text(state_name, x + 80.0, y, &state_style)?; |
| 644 | + y += row_height; |
| 645 | + |
| 646 | + // User |
| 647 | + self.renderer.text("User:", x, y, &label_style)?; |
| 648 | + self.renderer.text(&process.user, x + 80.0, y, &value_style)?; |
| 649 | + y += row_height; |
| 650 | + |
| 651 | + // CPU |
| 652 | + self.renderer.text("CPU:", x, y, &label_style)?; |
| 653 | + let cpu_style = TextStyle { color: self.theme.cpu_color, ..value_style.clone() }; |
| 654 | + self.renderer.text(&format!("{:.1}%", process.cpu_percent), x + 80.0, y, &cpu_style)?; |
| 655 | + y += row_height; |
| 656 | + |
| 657 | + // Memory |
| 658 | + self.renderer.text("Memory:", x, y, &label_style)?; |
| 659 | + let mem_style = TextStyle { color: self.theme.memory_color, ..value_style.clone() }; |
| 660 | + self.renderer.text( |
| 661 | + &format!("{} ({:.1}%)", format_bytes(process.rss), process.memory_percent), |
| 662 | + x + 80.0, y, &mem_style |
| 663 | + )?; |
| 664 | + y += row_height; |
| 665 | + |
| 666 | + // Virtual |
| 667 | + self.renderer.text("Virtual:", x, y, &label_style)?; |
| 668 | + self.renderer.text(&format_bytes(process.vsize), x + 80.0, y, &value_style)?; |
| 669 | + |
| 670 | + // Right column - reset y |
| 671 | + y = margin as f64 + 24.0 + 20.0 + 16.0; |
| 672 | + |
| 673 | + // Disk I/O |
| 674 | + self.renderer.text("Disk Read:", right_col, y, &label_style)?; |
| 675 | + let disk_style = TextStyle { color: self.theme.disk_color, ..value_style.clone() }; |
| 676 | + self.renderer.text(&format_rate(process.io_read_rate), right_col + 90.0, y, &disk_style)?; |
| 677 | + y += row_height; |
| 678 | + |
| 679 | + self.renderer.text("Disk Write:", right_col, y, &label_style)?; |
| 680 | + self.renderer.text(&format_rate(process.io_write_rate), right_col + 90.0, y, &disk_style)?; |
| 681 | + y += row_height; |
| 682 | + |
| 683 | + // Network |
| 684 | + self.renderer.text("Net Conn:", right_col, y, &label_style)?; |
| 685 | + let net_style = TextStyle { color: self.theme.network_color, ..value_style.clone() }; |
| 686 | + self.renderer.text( |
| 687 | + &format!("{} ({} TCP, {} UDP)", process.net_connections, process.net_tcp, process.net_udp), |
| 688 | + right_col + 90.0, y, &net_style |
| 689 | + )?; |
| 690 | + y += row_height; |
| 691 | + |
| 692 | + self.renderer.text("Net Rate:", right_col, y, &label_style)?; |
| 693 | + self.renderer.text( |
| 694 | + &format!("↓{} ↑{}", format_rate(process.net_rx_rate), format_rate(process.net_tx_rate)), |
| 695 | + right_col + 90.0, y, &net_style |
| 696 | + )?; |
| 697 | + |
| 698 | + // Command line section |
| 699 | + y = margin as f64 + 24.0 + 20.0 + 16.0 + row_height * 6.0; |
| 700 | + self.renderer.text("Command:", x, y, &label_style)?; |
| 701 | + y += row_height; |
| 702 | + |
| 703 | + // Command line (truncate if too long) |
| 704 | + let cmd_style = TextStyle { |
| 705 | + font_family: "monospace".to_string(), |
| 706 | + font_size: 10.0, |
| 707 | + color: self.theme.text, |
| 708 | + ..Default::default() |
| 709 | + }; |
| 710 | + let max_cmd_len = ((self.width - margin * 2 - 40) / 6) as usize; // Approximate char width |
| 711 | + let cmdline = if process.cmdline.len() > max_cmd_len { |
| 712 | + format!("{}...", &process.cmdline[..max_cmd_len - 3]) |
| 713 | + } else if process.cmdline.is_empty() { |
| 714 | + format!("[{}]", process.name) |
| 715 | + } else { |
| 716 | + process.cmdline.clone() |
| 717 | + }; |
| 718 | + self.renderer.text(&cmdline, x, y, &cmd_style)?; |
| 719 | + |
| 720 | + // Actions footer |
| 721 | + let footer_y = (self.height - margin - 40) as f64; |
| 722 | + self.renderer.line( |
| 723 | + x, footer_y - 8.0, |
| 724 | + (self.width - margin * 2) as f64 + x - 20.0, footer_y - 8.0, |
| 725 | + self.theme.border, 1.0 |
| 726 | + )?; |
| 727 | + |
| 728 | + let action_style = TextStyle { |
| 729 | + font_family: "monospace".to_string(), |
| 730 | + font_size: 10.0, |
| 731 | + color: self.theme.cpu_color, |
| 732 | + ..Default::default() |
| 733 | + }; |
| 734 | + self.renderer.text("[K] Kill (SIGTERM) [X] Kill (SIGKILL)", x, footer_y, &action_style)?; |
| 735 | + |
| 736 | + Ok(()) |
| 737 | + } |
| 738 | + |
| 548 | 739 | /// Render the content for the active tab. |
| 549 | 740 | fn render_tab_content(&self, content_y: i32, _content_height: u32) -> Result<()> { |
| 550 | 741 | let graph_width = self.width - (CONTENT_PADDING * 2); |
@@ -1166,6 +1357,36 @@ impl App { |
| 1166 | 1357 | } |
| 1167 | 1358 | _ => {} |
| 1168 | 1359 | } |
| 1360 | + } |
| 1361 | + // Detail view mode |
| 1362 | + else if self.detail_pid.is_some() { |
| 1363 | + match key_event.key { |
| 1364 | + Key::Escape => { |
| 1365 | + self.detail_pid = None; |
| 1366 | + ev_loop.request_redraw(); |
| 1367 | + } |
| 1368 | + Key::Char('K') => { |
| 1369 | + if let Some(pid) = self.detail_pid { |
| 1370 | + let name = self.processes.iter() |
| 1371 | + .find(|p| p.pid == pid) |
| 1372 | + .map(|p| p.name.clone()) |
| 1373 | + .unwrap_or_else(|| format!("PID {}", pid)); |
| 1374 | + self.kill_confirm = Some((pid, 15, name)); |
| 1375 | + ev_loop.request_redraw(); |
| 1376 | + } |
| 1377 | + } |
| 1378 | + Key::Char('X') => { |
| 1379 | + if let Some(pid) = self.detail_pid { |
| 1380 | + let name = self.processes.iter() |
| 1381 | + .find(|p| p.pid == pid) |
| 1382 | + .map(|p| p.name.clone()) |
| 1383 | + .unwrap_or_else(|| format!("PID {}", pid)); |
| 1384 | + self.kill_confirm = Some((pid, 9, name)); |
| 1385 | + ev_loop.request_redraw(); |
| 1386 | + } |
| 1387 | + } |
| 1388 | + _ => {} |
| 1389 | + } |
| 1169 | 1390 | } else { |
| 1170 | 1391 | // Normal mode key handling |
| 1171 | 1392 | match key_event.key { |
@@ -1285,6 +1506,13 @@ impl App { |
| 1285 | 1506 | } |
| 1286 | 1507 | ev_loop.request_redraw(); |
| 1287 | 1508 | } |
| 1509 | + // Enter - open process detail view |
| 1510 | + Key::Return => { |
| 1511 | + if let Some(pid) = self.process_list.selected_pid() { |
| 1512 | + self.detail_pid = Some(pid); |
| 1513 | + ev_loop.request_redraw(); |
| 1514 | + } |
| 1515 | + } |
| 1288 | 1516 | // Kill selected process (K = SIGTERM, X = SIGKILL) - with confirmation |
| 1289 | 1517 | Key::Char('K') => { |
| 1290 | 1518 | if let Some(pid) = self.process_list.selected_pid() { |