Skip to main content

presentar_terminal/widgets/
process_table.rs

1//! `ProcessTable` widget for system process monitoring.
2//!
3//! Displays running processes with CPU/Memory usage in a ttop/btop style.
4//! Reference: ttop/btop process displays.
5
6use crate::theme::Gradient;
7use presentar_core::{
8    Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Event, Key,
9    LayoutResult, Point, Rect, Size, TextStyle, TypeId, Widget,
10};
11use std::any::Any;
12use std::cmp::Ordering;
13use std::fmt::Write as _;
14use std::time::Duration;
15
16/// Process state (from /proc/[pid]/stat)
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub enum ProcessState {
19    /// Running (R)
20    Running,
21    /// Sleeping (S)
22    #[default]
23    Sleeping,
24    /// Disk sleep/waiting (D)
25    DiskWait,
26    /// Zombie (Z)
27    Zombie,
28    /// Stopped (T)
29    Stopped,
30    /// Idle (I)
31    Idle,
32}
33
34impl ProcessState {
35    /// Get the single-character representation
36    #[must_use]
37    pub fn char(&self) -> char {
38        match self {
39            Self::Running => 'R',
40            Self::Sleeping => 'S',
41            Self::DiskWait => 'D',
42            Self::Zombie => 'Z',
43            Self::Stopped => 'T',
44            Self::Idle => 'I',
45        }
46    }
47
48    /// Get the color for this state
49    #[must_use]
50    pub fn color(&self) -> Color {
51        match self {
52            Self::Running => Color::new(0.3, 0.9, 0.3, 1.0), // Green
53            Self::Sleeping => Color::new(0.5, 0.5, 0.5, 1.0), // Gray
54            Self::DiskWait => Color::new(1.0, 0.7, 0.2, 1.0), // Orange
55            Self::Zombie => Color::new(1.0, 0.3, 0.3, 1.0),  // Red
56            Self::Stopped => Color::new(0.9, 0.9, 0.3, 1.0), // Yellow
57            Self::Idle => Color::new(0.4, 0.4, 0.4, 1.0),    // Dark gray
58        }
59    }
60}
61
62/// A running process entry.
63#[derive(Debug, Clone)]
64pub struct ProcessEntry {
65    /// Process ID.
66    pub pid: u32,
67    /// User running the process.
68    pub user: String,
69    /// CPU usage percentage (0-100).
70    pub cpu_percent: f32,
71    /// Memory usage percentage (0-100).
72    pub mem_percent: f32,
73    /// Command name.
74    pub command: String,
75    /// Full command line (optional).
76    pub cmdline: Option<String>,
77    /// Process state.
78    pub state: ProcessState,
79    /// OOM score (0-1000, higher = more likely to be killed).
80    pub oom_score: Option<i32>,
81    /// cgroup path (short form).
82    pub cgroup: Option<String>,
83    /// Nice value (-20 to +19).
84    pub nice: Option<i32>,
85    /// Thread count (CB-PROC-006).
86    pub threads: Option<u32>,
87    /// Parent process ID (CB-PROC-001 tree view).
88    pub parent_pid: Option<u32>,
89    /// Tree depth level for indentation (CB-PROC-001).
90    pub tree_depth: usize,
91    /// Whether this is the last child at its level (CB-PROC-001).
92    pub is_last_child: bool,
93    /// Tree prefix string (e.g., "│ └─") for display (CB-PROC-001).
94    pub tree_prefix: String,
95}
96
97impl ProcessEntry {
98    /// Create a new process entry.
99    #[must_use]
100    pub fn new(
101        pid: u32,
102        user: impl Into<String>,
103        cpu: f32,
104        mem: f32,
105        command: impl Into<String>,
106    ) -> Self {
107        Self {
108            pid,
109            user: user.into(),
110            cpu_percent: cpu,
111            mem_percent: mem,
112            command: command.into(),
113            cmdline: None,
114            state: ProcessState::default(),
115            oom_score: None,
116            cgroup: None,
117            nice: None,
118            threads: None,
119            parent_pid: None,
120            tree_depth: 0,
121            is_last_child: false,
122            tree_prefix: String::new(),
123        }
124    }
125
126    /// Set full command line.
127    #[must_use]
128    pub fn with_cmdline(mut self, cmdline: impl Into<String>) -> Self {
129        self.cmdline = Some(cmdline.into());
130        self
131    }
132
133    /// Set process state.
134    #[must_use]
135    pub fn with_state(mut self, state: ProcessState) -> Self {
136        self.state = state;
137        self
138    }
139
140    /// Set OOM score (0-1000).
141    #[must_use]
142    pub fn with_oom_score(mut self, score: i32) -> Self {
143        self.oom_score = Some(score);
144        self
145    }
146
147    /// Set cgroup path.
148    #[must_use]
149    pub fn with_cgroup(mut self, cgroup: impl Into<String>) -> Self {
150        self.cgroup = Some(cgroup.into());
151        self
152    }
153
154    /// Set nice value.
155    #[must_use]
156    pub fn with_nice(mut self, nice: i32) -> Self {
157        self.nice = Some(nice);
158        self
159    }
160
161    /// Set thread count (CB-PROC-006).
162    #[must_use]
163    pub fn with_threads(mut self, threads: u32) -> Self {
164        self.threads = Some(threads);
165        self
166    }
167
168    /// Set parent PID (CB-PROC-001 tree view).
169    #[must_use]
170    pub fn with_parent_pid(mut self, ppid: u32) -> Self {
171        self.parent_pid = Some(ppid);
172        self
173    }
174
175    /// Set tree display info (CB-PROC-001 tree view).
176    pub fn set_tree_info(&mut self, depth: usize, is_last: bool, prefix: String) {
177        self.tree_depth = depth;
178        self.is_last_child = is_last;
179        self.tree_prefix = prefix;
180    }
181}
182
183/// Sort column for process table.
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185pub enum ProcessSort {
186    Pid,
187    User,
188    Cpu,
189    Memory,
190    Command,
191    Oom,
192}
193
194/// Process table widget with color-coded CPU/Memory bars.
195#[derive(Debug, Clone)]
196#[allow(clippy::struct_excessive_bools)]
197pub struct ProcessTable {
198    /// Process entries.
199    processes: Vec<ProcessEntry>,
200    /// Selected row index.
201    selected: usize,
202    /// Scroll offset.
203    scroll_offset: usize,
204    /// Current sort column.
205    sort_by: ProcessSort,
206    /// Sort ascending.
207    sort_ascending: bool,
208    /// CPU gradient (low → high).
209    cpu_gradient: Gradient,
210    /// Memory gradient (low → high).
211    mem_gradient: Gradient,
212    /// Show command line instead of command name.
213    show_cmdline: bool,
214    /// Compact mode (fewer columns).
215    compact: bool,
216    /// Show OOM score column.
217    show_oom: bool,
218    /// Show nice value column.
219    show_nice: bool,
220    /// Show thread count column (CB-PROC-006).
221    show_threads: bool,
222    /// Tree view mode (CB-PROC-001).
223    tree_view: bool,
224    /// Cached bounds.
225    bounds: Rect,
226}
227
228impl Default for ProcessTable {
229    fn default() -> Self {
230        Self::new()
231    }
232}
233
234impl ProcessTable {
235    /// Create a new process table.
236    #[must_use]
237    pub fn new() -> Self {
238        Self {
239            processes: Vec::new(),
240            selected: 0,
241            scroll_offset: 0,
242            sort_by: ProcessSort::Cpu,
243            sort_ascending: false, // Default: highest CPU first
244            cpu_gradient: Gradient::from_hex(&["#7aa2f7", "#e0af68", "#f7768e"]),
245            mem_gradient: Gradient::from_hex(&["#9ece6a", "#e0af68", "#f7768e"]),
246            show_cmdline: false,
247            compact: false,
248            show_oom: false,
249            show_nice: false,
250            show_threads: false,
251            tree_view: false,
252            bounds: Rect::default(),
253        }
254    }
255
256    /// Set processes.
257    pub fn set_processes(&mut self, processes: Vec<ProcessEntry>) {
258        self.processes = processes;
259        // Tree view (CB-PROC-001) takes precedence over sorting
260        if self.tree_view {
261            self.build_tree();
262        } else {
263            self.sort_processes();
264        }
265        // Clamp selection
266        if !self.processes.is_empty() && self.selected >= self.processes.len() {
267            self.selected = self.processes.len() - 1;
268        }
269    }
270
271    /// Add a process.
272    pub fn add_process(&mut self, process: ProcessEntry) {
273        self.processes.push(process);
274    }
275
276    /// Clear all processes.
277    pub fn clear(&mut self) {
278        self.processes.clear();
279        self.selected = 0;
280        self.scroll_offset = 0;
281    }
282
283    /// Set CPU gradient.
284    #[must_use]
285    pub fn with_cpu_gradient(mut self, gradient: Gradient) -> Self {
286        self.cpu_gradient = gradient;
287        self
288    }
289
290    /// Set memory gradient.
291    #[must_use]
292    pub fn with_mem_gradient(mut self, gradient: Gradient) -> Self {
293        self.mem_gradient = gradient;
294        self
295    }
296
297    /// Enable compact mode.
298    #[must_use]
299    pub fn compact(mut self) -> Self {
300        self.compact = true;
301        self
302    }
303
304    /// Show full command line.
305    #[must_use]
306    pub fn with_cmdline(mut self) -> Self {
307        self.show_cmdline = true;
308        self
309    }
310
311    /// Show OOM score column.
312    #[must_use]
313    pub fn with_oom(mut self) -> Self {
314        self.show_oom = true;
315        self
316    }
317
318    /// Show nice value column.
319    #[must_use]
320    pub fn with_nice_column(mut self) -> Self {
321        self.show_nice = true;
322        self
323    }
324
325    /// Show thread count column (CB-PROC-006).
326    #[must_use]
327    pub fn with_threads_column(mut self) -> Self {
328        self.show_threads = true;
329        self
330    }
331
332    /// Enable tree view mode (CB-PROC-001).
333    #[must_use]
334    pub fn with_tree_view(mut self) -> Self {
335        self.tree_view = true;
336        self
337    }
338
339    /// Toggle tree view mode (CB-PROC-001).
340    pub fn toggle_tree_view(&mut self) {
341        self.tree_view = !self.tree_view;
342        if self.tree_view {
343            self.build_tree();
344        }
345    }
346
347    /// Check if tree view is enabled (CB-PROC-001).
348    #[must_use]
349    pub fn is_tree_view(&self) -> bool {
350        self.tree_view
351    }
352
353    /// Set sort column.
354    pub fn sort_by(&mut self, column: ProcessSort) {
355        if self.sort_by == column {
356            self.sort_ascending = !self.sort_ascending;
357        } else {
358            self.sort_by = column;
359            // Default directions (CPU/Memory/OOM default to descending)
360            self.sort_ascending = !matches!(
361                column,
362                ProcessSort::Cpu | ProcessSort::Memory | ProcessSort::Oom
363            );
364        }
365        self.sort_processes();
366    }
367
368    /// Get current sort column.
369    #[must_use]
370    pub fn current_sort(&self) -> ProcessSort {
371        self.sort_by
372    }
373
374    /// Get selected index.
375    #[must_use]
376    pub fn selected(&self) -> usize {
377        self.selected
378    }
379
380    /// Get selected process.
381    #[must_use]
382    pub fn selected_process(&self) -> Option<&ProcessEntry> {
383        self.processes.get(self.selected)
384    }
385
386    /// Select a row.
387    pub fn select(&mut self, row: usize) {
388        if !self.processes.is_empty() {
389            self.selected = row.min(self.processes.len() - 1);
390            self.ensure_visible();
391        }
392    }
393
394    /// Move selection up.
395    pub fn select_prev(&mut self) {
396        if self.selected > 0 {
397            self.selected -= 1;
398            self.ensure_visible();
399        }
400    }
401
402    /// Move selection down.
403    pub fn select_next(&mut self) {
404        if !self.processes.is_empty() && self.selected < self.processes.len() - 1 {
405            self.selected += 1;
406            self.ensure_visible();
407        }
408    }
409
410    /// Get process count.
411    #[must_use]
412    pub fn len(&self) -> usize {
413        self.processes.len()
414    }
415
416    /// Check if empty.
417    #[must_use]
418    pub fn is_empty(&self) -> bool {
419        self.processes.is_empty()
420    }
421
422    /// Build process tree structure (CB-PROC-001).
423    ///
424    /// Reorganizes processes into a tree by parent-child relationships.
425    /// Uses ASCII art prefixes: └─ (last child), ├─ (middle child), │ (continuation).
426    fn build_tree(&mut self) {
427        use std::collections::HashMap;
428
429        if self.processes.is_empty() {
430            return;
431        }
432
433        // Build PID -> index map
434        let pid_to_idx: HashMap<u32, usize> = self
435            .processes
436            .iter()
437            .enumerate()
438            .map(|(i, p)| (p.pid, i))
439            .collect();
440
441        // Build PPID -> children map
442        let mut children: HashMap<u32, Vec<usize>> = HashMap::new();
443        let mut roots: Vec<usize> = Vec::new();
444
445        for (idx, proc) in self.processes.iter().enumerate() {
446            if let Some(ppid) = proc.parent_pid {
447                if pid_to_idx.contains_key(&ppid) {
448                    children.entry(ppid).or_default().push(idx);
449                } else {
450                    // Parent not in list, treat as root
451                    roots.push(idx);
452                }
453            } else {
454                roots.push(idx);
455            }
456        }
457
458        // Sort children by CPU descending at each level
459        for children_list in children.values_mut() {
460            children_list.sort_by(|&a, &b| {
461                self.processes[b]
462                    .cpu_percent
463                    .partial_cmp(&self.processes[a].cpu_percent)
464                    .unwrap_or(std::cmp::Ordering::Equal)
465            });
466        }
467
468        // Sort roots by CPU descending
469        roots.sort_by(|&a, &b| {
470            self.processes[b]
471                .cpu_percent
472                .partial_cmp(&self.processes[a].cpu_percent)
473                .unwrap_or(std::cmp::Ordering::Equal)
474        });
475
476        // DFS walk to build tree order
477        let mut tree_order: Vec<(usize, usize, bool, String)> = Vec::new();
478
479        let roots_len = roots.len();
480        for (i, &root_idx) in roots.iter().enumerate() {
481            let is_last = i == roots_len - 1;
482            Self::build_tree_dfs(
483                root_idx,
484                0,
485                "",
486                is_last,
487                &self.processes,
488                &children,
489                &mut tree_order,
490            );
491        }
492
493        // Reorder processes and apply tree info
494        let old_processes = std::mem::take(&mut self.processes);
495        self.processes.reserve(tree_order.len());
496
497        for (idx, depth, is_last, prefix) in tree_order {
498            let mut proc = old_processes[idx].clone();
499            proc.set_tree_info(depth, is_last, prefix);
500            self.processes.push(proc);
501        }
502    }
503
504    fn build_tree_dfs(
505        idx: usize,
506        depth: usize,
507        prefix: &str,
508        is_last: bool,
509        processes: &[ProcessEntry],
510        children: &std::collections::HashMap<u32, Vec<usize>>,
511        tree_order: &mut Vec<(usize, usize, bool, String)>,
512    ) {
513        let proc = &processes[idx];
514        let current_prefix = if depth == 0 {
515            String::new()
516        } else if is_last {
517            format!("{prefix}└─")
518        } else {
519            format!("{prefix}├─")
520        };
521
522        tree_order.push((idx, depth, is_last, current_prefix));
523
524        // Calculate next prefix for children
525        let next_prefix = if depth == 0 {
526            String::new()
527        } else if is_last {
528            format!("{prefix}  ")
529        } else {
530            format!("{prefix}│ ")
531        };
532
533        if let Some(child_indices) = children.get(&proc.pid) {
534            let len = child_indices.len();
535            for (i, &child_idx) in child_indices.iter().enumerate() {
536                let child_is_last = i == len - 1;
537                Self::build_tree_dfs(
538                    child_idx,
539                    depth + 1,
540                    &next_prefix,
541                    child_is_last,
542                    processes,
543                    children,
544                    tree_order,
545                );
546            }
547        }
548    }
549
550    fn sort_processes(&mut self) {
551        let ascending = self.sort_ascending;
552        match self.sort_by {
553            ProcessSort::Pid => {
554                self.processes.sort_by(|a, b| {
555                    if ascending {
556                        a.pid.cmp(&b.pid)
557                    } else {
558                        b.pid.cmp(&a.pid)
559                    }
560                });
561            }
562            ProcessSort::User => {
563                self.processes.sort_by(|a, b| {
564                    if ascending {
565                        a.user.cmp(&b.user)
566                    } else {
567                        b.user.cmp(&a.user)
568                    }
569                });
570            }
571            ProcessSort::Cpu => {
572                self.processes.sort_by(|a, b| {
573                    let cmp = a
574                        .cpu_percent
575                        .partial_cmp(&b.cpu_percent)
576                        .unwrap_or(std::cmp::Ordering::Equal);
577                    if ascending {
578                        cmp
579                    } else {
580                        cmp.reverse()
581                    }
582                });
583            }
584            ProcessSort::Memory => {
585                self.processes.sort_by(|a, b| {
586                    let cmp = a
587                        .mem_percent
588                        .partial_cmp(&b.mem_percent)
589                        .unwrap_or(std::cmp::Ordering::Equal);
590                    if ascending {
591                        cmp
592                    } else {
593                        cmp.reverse()
594                    }
595                });
596            }
597            ProcessSort::Command => {
598                self.processes.sort_by(|a, b| {
599                    if ascending {
600                        a.command.cmp(&b.command)
601                    } else {
602                        b.command.cmp(&a.command)
603                    }
604                });
605            }
606            ProcessSort::Oom => {
607                self.processes.sort_by(|a, b| {
608                    let a_oom = a.oom_score.unwrap_or(0);
609                    let b_oom = b.oom_score.unwrap_or(0);
610                    if ascending {
611                        a_oom.cmp(&b_oom)
612                    } else {
613                        b_oom.cmp(&a_oom)
614                    }
615                });
616            }
617        }
618    }
619
620    fn ensure_visible(&mut self) {
621        let visible_rows = (self.bounds.height as usize).saturating_sub(2);
622        if visible_rows == 0 {
623            return;
624        }
625
626        if self.selected < self.scroll_offset {
627            self.scroll_offset = self.selected;
628        } else if self.selected >= self.scroll_offset + visible_rows {
629            self.scroll_offset = self.selected - visible_rows + 1;
630        }
631    }
632
633    fn truncate(s: &str, width: usize) -> String {
634        if s.chars().count() <= width {
635            format!("{s:width$}")
636        } else if width > 1 {
637            // Use proper ellipsis character "…" instead of "..."
638            let chars: String = s.chars().take(width - 1).collect();
639            format!("{chars}…")
640        } else {
641            s.chars().take(width).collect()
642        }
643    }
644
645    /// Get OOM score color based on risk level.
646    fn oom_color(oom: i32) -> Color {
647        if oom > 500 {
648            Color::new(1.0, 0.3, 0.3, 1.0) // Red - high risk
649        } else if oom > 200 {
650            Color::new(1.0, 0.8, 0.2, 1.0) // Yellow - medium risk
651        } else {
652            Color::new(0.5, 0.8, 0.5, 1.0) // Green - low risk
653        }
654    }
655
656    /// Get nice value color based on priority.
657    fn nice_color(ni: i32) -> Color {
658        match ni.cmp(&0) {
659            Ordering::Less => Color::new(0.3, 0.9, 0.9, 1.0), // Cyan - high priority
660            Ordering::Greater => Color::new(0.6, 0.6, 0.6, 1.0), // Gray - low priority
661            Ordering::Equal => Color::new(0.8, 0.8, 0.8, 1.0), // White - normal
662        }
663    }
664
665    /// Get thread count color based on count.
666    fn threads_color(th: u32) -> Color {
667        if th > 50 {
668            Color::new(0.3, 0.9, 0.9, 1.0) // Cyan - many threads
669        } else if th > 10 {
670            Color::new(1.0, 0.8, 0.2, 1.0) // Yellow - moderate
671        } else {
672            Color::new(0.8, 0.8, 0.8, 1.0) // White - normal
673        }
674    }
675
676    /// Build header string for the table.
677    fn build_header(&self, cols: &ColumnWidths) -> String {
678        let sep = if self.compact { " " } else { " │ " };
679        let mut header = String::new();
680        let _ = write!(header, "{:>w$}", "PID", w = cols.pid);
681        if self.compact {
682            header.push(' ');
683            let _ = write!(header, "{:>1}", "S");
684        } else {
685            header.push_str(sep);
686            let _ = write!(header, "{:w$}", "USER", w = cols.user);
687        }
688        if self.show_oom {
689            header.push_str(sep);
690            let _ = write!(header, "{:>3}", "OOM");
691        }
692        if self.show_nice {
693            header.push_str(sep);
694            let _ = write!(header, "{:>3}", "NI");
695        }
696        if self.show_threads {
697            header.push_str(sep);
698            let _ = write!(header, "{:>3}", "TH");
699        }
700        header.push_str(sep);
701        let _ = write!(
702            header,
703            "{:>w$}",
704            if self.compact { "C%" } else { "CPU%" },
705            w = cols.cpu
706        );
707        header.push_str(sep);
708        let _ = write!(
709            header,
710            "{:>w$}",
711            if self.compact { "M%" } else { "MEM%" },
712            w = cols.mem
713        );
714        header.push_str(sep);
715        let _ = write!(header, "{:w$}", "COMMAND", w = cols.cmd);
716        header
717    }
718
719    /// Draw a single process row.
720    #[allow(clippy::too_many_arguments)]
721    fn draw_row(
722        &self,
723        canvas: &mut dyn Canvas,
724        proc: &ProcessEntry,
725        y: f32,
726        is_selected: bool,
727        cols: &ColumnWidths,
728        default_style: &TextStyle,
729    ) {
730        let sep = if self.compact { 1.0 } else { 3.0 };
731        let mut x = self.bounds.x;
732        // PID
733        canvas.draw_text(
734            &format!("{:>w$}", proc.pid, w = cols.pid),
735            Point::new(x, y),
736            default_style,
737        );
738        x += cols.pid as f32;
739        // State or User
740        if self.compact {
741            x += 1.0;
742            canvas.draw_text(
743                &proc.state.char().to_string(),
744                Point::new(x, y),
745                &TextStyle {
746                    color: proc.state.color(),
747                    ..Default::default()
748                },
749            );
750            x += 1.0;
751        } else {
752            x += sep;
753            canvas.draw_text(
754                &Self::truncate(&proc.user, cols.user),
755                Point::new(x, y),
756                default_style,
757            );
758            x += cols.user as f32;
759        }
760        // OOM
761        if self.show_oom {
762            x += sep;
763            let oom = proc.oom_score.unwrap_or(0);
764            canvas.draw_text(
765                &format!("{oom:>3}"),
766                Point::new(x, y),
767                &TextStyle {
768                    color: Self::oom_color(oom),
769                    ..Default::default()
770                },
771            );
772            x += 3.0;
773        }
774        // Nice
775        if self.show_nice {
776            x += sep;
777            let ni = proc.nice.unwrap_or(0);
778            canvas.draw_text(
779                &format!("{ni:>3}"),
780                Point::new(x, y),
781                &TextStyle {
782                    color: Self::nice_color(ni),
783                    ..Default::default()
784                },
785            );
786            x += 3.0;
787        }
788        // Threads
789        if self.show_threads {
790            x += sep;
791            let th = proc.threads.unwrap_or(1);
792            canvas.draw_text(
793                &format!("{th:>3}"),
794                Point::new(x, y),
795                &TextStyle {
796                    color: Self::threads_color(th),
797                    ..Default::default()
798                },
799            );
800            x += 3.0;
801        }
802        // CPU
803        x += sep;
804        canvas.draw_text(
805            &format!("{:>5.1}%", proc.cpu_percent),
806            Point::new(x, y),
807            &TextStyle {
808                color: self.cpu_gradient.for_percent(proc.cpu_percent as f64),
809                ..Default::default()
810            },
811        );
812        x += cols.cpu as f32;
813        // Mem
814        x += sep;
815        canvas.draw_text(
816            &format!("{:>5.1}%", proc.mem_percent),
817            Point::new(x, y),
818            &TextStyle {
819                color: self.mem_gradient.for_percent(proc.mem_percent as f64),
820                ..Default::default()
821            },
822        );
823        x += cols.mem as f32;
824        // Command
825        x += sep;
826        self.draw_command(canvas, proc, x, y, is_selected, cols.cmd, default_style);
827    }
828
829    /// Draw command column with optional tree prefix.
830    #[allow(clippy::too_many_arguments)]
831    fn draw_command(
832        &self,
833        canvas: &mut dyn Canvas,
834        proc: &ProcessEntry,
835        x: f32,
836        y: f32,
837        is_selected: bool,
838        cmd_w: usize,
839        default_style: &TextStyle,
840    ) {
841        let cmd = if self.show_cmdline {
842            proc.cmdline.as_deref().unwrap_or(&proc.command)
843        } else {
844            &proc.command
845        };
846        let cmd_style = if is_selected {
847            TextStyle {
848                color: Color::new(1.0, 1.0, 1.0, 1.0),
849                ..Default::default()
850            }
851        } else {
852            default_style.clone()
853        };
854        if self.tree_view && !proc.tree_prefix.is_empty() {
855            let prefix_len = proc.tree_prefix.chars().count();
856            canvas.draw_text(
857                &proc.tree_prefix,
858                Point::new(x, y),
859                &TextStyle {
860                    color: Color::new(0.4, 0.5, 0.6, 1.0),
861                    ..Default::default()
862                },
863            );
864            canvas.draw_text(
865                &Self::truncate(cmd, cmd_w.saturating_sub(prefix_len)),
866                Point::new(x + prefix_len as f32, y),
867                &cmd_style,
868            );
869        } else {
870            canvas.draw_text(&Self::truncate(cmd, cmd_w), Point::new(x, y), &cmd_style);
871        }
872    }
873}
874
875/// Column widths for process table layout.
876#[allow(dead_code)]
877struct ColumnWidths {
878    pid: usize,
879    state: usize,
880    oom: usize,
881    nice: usize,
882    threads: usize,
883    user: usize,
884    cpu: usize,
885    mem: usize,
886    sep: usize,
887    cmd: usize,
888    num_seps: usize,
889}
890
891impl ColumnWidths {
892    fn new(table: &ProcessTable, width: usize) -> Self {
893        let pid = 7;
894        let state = if table.compact { 2 } else { 0 };
895        let oom = if table.show_oom { 4 } else { 0 };
896        let nice = if table.show_nice { 4 } else { 0 };
897        let threads = if table.show_threads { 4 } else { 0 };
898        let user = if table.compact { 0 } else { 8 };
899        let cpu = 6;
900        let mem = 6;
901        let sep = if table.compact { 1 } else { 3 };
902        let extra_cols = usize::from(table.show_oom)
903            + usize::from(table.show_nice)
904            + usize::from(table.show_threads);
905        let num_seps = if table.compact { 3 } else { 4 } + extra_cols;
906        let fixed = pid + state + oom + nice + threads + user + cpu + mem + sep * num_seps;
907        let cmd = width.saturating_sub(fixed);
908        Self {
909            pid,
910            state,
911            oom,
912            nice,
913            threads,
914            user,
915            cpu,
916            mem,
917            sep,
918            cmd,
919            num_seps,
920        }
921    }
922}
923
924impl Brick for ProcessTable {
925    fn brick_name(&self) -> &'static str {
926        "process_table"
927    }
928
929    fn assertions(&self) -> &[BrickAssertion] {
930        static ASSERTIONS: &[BrickAssertion] = &[BrickAssertion::max_latency_ms(16)];
931        ASSERTIONS
932    }
933
934    fn budget(&self) -> BrickBudget {
935        BrickBudget::uniform(16)
936    }
937
938    fn verify(&self) -> BrickVerification {
939        let passed = if self.processes.is_empty() || self.selected < self.processes.len() {
940            vec![BrickAssertion::max_latency_ms(16)]
941        } else {
942            vec![]
943        };
944        let failed = if !self.processes.is_empty() && self.selected >= self.processes.len() {
945            vec![(
946                BrickAssertion::max_latency_ms(16),
947                format!(
948                    "Selected {} >= process count {}",
949                    self.selected,
950                    self.processes.len()
951                ),
952            )]
953        } else {
954            vec![]
955        };
956
957        BrickVerification {
958            passed,
959            failed,
960            verification_time: Duration::from_micros(10),
961        }
962    }
963
964    fn to_html(&self) -> String {
965        String::new()
966    }
967
968    fn to_css(&self) -> String {
969        String::new()
970    }
971}
972
973impl Widget for ProcessTable {
974    fn type_id(&self) -> TypeId {
975        TypeId::of::<Self>()
976    }
977
978    fn measure(&self, constraints: Constraints) -> Size {
979        let min_width = if self.compact { 40.0 } else { 60.0 };
980        let width = constraints.max_width.max(min_width);
981        let height = (self.processes.len() + 2).min(30) as f32;
982        constraints.constrain(Size::new(width, height.max(3.0)))
983    }
984
985    fn layout(&mut self, bounds: Rect) -> LayoutResult {
986        self.bounds = bounds;
987        self.ensure_visible();
988        LayoutResult {
989            size: Size::new(bounds.width, bounds.height),
990        }
991    }
992
993    fn paint(&self, canvas: &mut dyn Canvas) {
994        let width = self.bounds.width as usize;
995        let height = self.bounds.height as usize;
996        if width == 0 || height == 0 {
997            return;
998        }
999
1000        let cols = ColumnWidths::new(self, width);
1001
1002        // Draw header
1003        let header_style = TextStyle {
1004            color: Color::new(0.0, 1.0, 1.0, 1.0),
1005            weight: presentar_core::FontWeight::Bold,
1006            ..Default::default()
1007        };
1008        canvas.draw_text(
1009            &self.build_header(&cols),
1010            Point::new(self.bounds.x, self.bounds.y),
1011            &header_style,
1012        );
1013
1014        // Draw separator
1015        if height > 1 {
1016            canvas.draw_text(
1017                &"─".repeat(width),
1018                Point::new(self.bounds.x, self.bounds.y + 1.0),
1019                &TextStyle {
1020                    color: Color::new(0.3, 0.3, 0.4, 1.0),
1021                    ..Default::default()
1022                },
1023            );
1024        }
1025
1026        // Draw rows
1027        let default_style = TextStyle {
1028            color: Color::new(0.8, 0.8, 0.8, 1.0),
1029            ..Default::default()
1030        };
1031        let visible_rows = height.saturating_sub(2);
1032        for (i, proc_idx) in (self.scroll_offset..self.processes.len())
1033            .take(visible_rows)
1034            .enumerate()
1035        {
1036            let proc = &self.processes[proc_idx];
1037            let y = self.bounds.y + 2.0 + i as f32;
1038            let is_selected = proc_idx == self.selected;
1039            if is_selected {
1040                canvas.fill_rect(
1041                    Rect::new(self.bounds.x, y, self.bounds.width, 1.0),
1042                    Color::new(0.2, 0.2, 0.4, 0.5),
1043                );
1044            }
1045            self.draw_row(canvas, proc, y, is_selected, &cols, &default_style);
1046        }
1047
1048        // Empty state
1049        if self.processes.is_empty() && height > 2 {
1050            canvas.draw_text(
1051                "No processes",
1052                Point::new(self.bounds.x + 1.0, self.bounds.y + 2.0),
1053                &TextStyle {
1054                    color: Color::new(0.5, 0.5, 0.5, 1.0),
1055                    ..Default::default()
1056                },
1057            );
1058        }
1059    }
1060
1061    fn event(&mut self, event: &Event) -> Option<Box<dyn Any + Send>> {
1062        match event {
1063            Event::KeyDown { key, .. } => {
1064                match key {
1065                    Key::Up | Key::K => self.select_prev(),
1066                    Key::Down | Key::J => self.select_next(),
1067                    Key::C => self.sort_by(ProcessSort::Cpu),
1068                    Key::M => self.sort_by(ProcessSort::Memory),
1069                    Key::P => self.sort_by(ProcessSort::Pid),
1070                    Key::N => self.sort_by(ProcessSort::Command),
1071                    Key::O => self.sort_by(ProcessSort::Oom),
1072                    Key::T => self.toggle_tree_view(), // CB-PROC-001
1073                    _ => {}
1074                }
1075                None
1076            }
1077            _ => None,
1078        }
1079    }
1080
1081    fn children(&self) -> &[Box<dyn Widget>] {
1082        &[]
1083    }
1084
1085    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
1086        &mut []
1087    }
1088}
1089
1090#[cfg(test)]
1091#[allow(clippy::unwrap_used, clippy::disallowed_methods)]
1092#[path = "process_table_tests.rs"]
1093mod tests;