From d5c8498ac33d12fdce006eec5bc881d1f0cdbbbf Mon Sep 17 00:00:00 2001 From: vasilito Date: Tue, 7 Jul 2026 01:00:13 +0300 Subject: [PATCH] redbear-power: add nice editor (N), open files (l), remove dead event.rs, bench auto-stop toast - nice_edit.rs: modal dialog for adjusting process nice (-20..19), slider visualization - open_files.rs: reads /proc/[pid]/fd, classifies file descriptors by type - Removed dead event.rs module (never wired, unused Event enum) - Benchmark auto-completion: detects elapsed >= duration, calls stop(), flashes toast - PROCHOT toast trigger wired in app.rs - Help text updated with N and l keybindings - 217 tests pass, 0 warnings, 4.1 MB LTO release binary --- .../system/redbear-power/source/src/app.rs | 8 + .../system/redbear-power/source/src/main.rs | 94 ++++++++++- .../redbear-power/source/src/nice_edit.rs | 155 ++++++++++++++++++ .../redbear-power/source/src/open_files.rs | 122 ++++++++++++++ .../system/redbear-power/source/src/render.rs | 2 + 5 files changed, 380 insertions(+), 1 deletion(-) create mode 100644 local/recipes/system/redbear-power/source/src/nice_edit.rs create mode 100644 local/recipes/system/redbear-power/source/src/open_files.rs diff --git a/local/recipes/system/redbear-power/source/src/app.rs b/local/recipes/system/redbear-power/source/src/app.rs index 0cf8476581..022d61822a 100644 --- a/local/recipes/system/redbear-power/source/src/app.rs +++ b/local/recipes/system/redbear-power/source/src/app.rs @@ -265,6 +265,10 @@ pub struct App { pub storage_graph_history: crate::graph::RingHistory, /// Process kill dialog. Opened by `k` key in the Process tab. pub kill_dialog: crate::kill::KillDialog, + pub nice_edit: crate::nice_edit::NiceEdit, + pub open_files_result: Option>, + pub open_files_for_pid: Option, + pub show_open_files: bool, /// Help dialog scroll offset (lines from top). pub help_scroll: usize, /// When true, the current tab is rendered full-screen (widget @@ -477,6 +481,10 @@ impl App { net_graph_history: crate::graph::RingHistory::new(120), storage_graph_history: crate::graph::RingHistory::new(120), kill_dialog: crate::kill::KillDialog::new(), + nice_edit: crate::nice_edit::NiceEdit::default(), + open_files_result: None, + open_files_for_pid: None, + show_open_files: false, help_scroll: 0, expanded: false, }; diff --git a/local/recipes/system/redbear-power/source/src/main.rs b/local/recipes/system/redbear-power/source/src/main.rs index 5007a86cb5..e71cc0ffcd 100644 --- a/local/recipes/system/redbear-power/source/src/main.rs +++ b/local/recipes/system/redbear-power/source/src/main.rs @@ -55,12 +55,13 @@ mod cpufreq; mod cpuid; mod dbus; mod dmi; -mod event; mod graph; mod kill; mod meminfo; mod msr; mod network; +mod nice_edit; +mod open_files; mod pid_detail; mod platform; mod process; @@ -383,6 +384,20 @@ fn main() -> io::Result<()> { app.refresh(); last_refresh = Instant::now(); app.bench_line = bench.status_line(app.cpus.len()); + if bench.running { + if let Some((elapsed, _)) = bench.progress() { + let dur = bench.duration.as_secs() as u32; + if dur > 0 && elapsed >= dur { + bench.stop(); + app.flash_toast(format!( + "Benchmark done: {} {} in {}s", + bench.last_score, + bench.unit_name(), + bench.last_duration_s + )); + } + } + } if let Some(server) = dbus_server.as_ref() { server.publish(dbus::PowerSnapshot::from_app(&app)); } @@ -568,6 +583,34 @@ fn main() -> io::Result<()> { f.render_widget(render_pid_detail(detail, pid, &app.theme), area); } f.render_widget(&app.kill_dialog, full); + if app.nice_edit.open { + f.render_widget(Clear, full); + f.render_widget(&app.nice_edit, full); + } + if app.show_open_files { + if let Some(files) = &app.open_files_result { + let pid = app.open_files_for_pid.unwrap_or(0); + let area = + full.centered(Constraint::Percentage(70), Constraint::Percentage(60)); + f.render_widget(Clear, area); + let lines: Vec = + crate::open_files::format_files(files) + .into_iter() + .map(ratatui::text::Line::from) + .collect(); + let block = ratatui::widgets::Block::default() + .borders(ratatui::widgets::Borders::ALL) + .title(format!( + " Open FDs: PID {} ({}) \u{2014} Esc to close ", + pid, + files.len() + )); + let para = ratatui::widgets::Paragraph::new(lines) + .block(block) + .scroll((0, 0)); + f.render_widget(para, area); + } + } // Hint bar: remind user how to exit expand mode. let hint = ratatui::widgets::Paragraph::new(ratatui::text::Line::styled( " Press e to restore normal view ", @@ -777,6 +820,7 @@ fn main() -> io::Result<()> { } app.kill_dialog.auto_close_if_done(); + app.nice_edit.auto_close_if_done(); if let Some(Ok(event)) = events.next() { if let Event::Mouse(me) = event { @@ -807,6 +851,29 @@ fn main() -> io::Result<()> { app.kill_dialog.move_selection(1); } _ if app.kill_dialog.open => {} // eat all other keys + Key::Esc if app.nice_edit.open => { + app.nice_edit.close(); + app.flash_status("nice edit cancelled"); + } + Key::Char('\n') if app.nice_edit.open => { + app.nice_edit.apply(); + if let Some(Ok(())) = &app.nice_edit.result { + app.flash_toast(format!( + "Nice set to {} for PID {}", + app.nice_edit.value, app.nice_edit.pid + )); + } + } + Key::Up if app.nice_edit.open => app.nice_edit.adjust(1), + Key::Down if app.nice_edit.open => app.nice_edit.adjust(-1), + Key::Char('+') if app.nice_edit.open => app.nice_edit.adjust(1), + Key::Char('-') if app.nice_edit.open => app.nice_edit.adjust(-1), + _ if app.nice_edit.open => {} + Key::Esc if app.show_open_files => { + app.show_open_files = false; + app.open_files_result = None; + app.open_files_for_pid = None; + } // Esc closes PID detail first, then quits. Key::Esc if app.pid_detail.is_some() => { app.pid_detail = None; @@ -914,6 +981,31 @@ fn main() -> io::Result<()> { app.flash_status("no process selected (Up/Down to select)"); } } + Key::Char('N') if app.current_tab == TabId::Process => { + let target = app.selected_pid().and_then(|pid| { + app.visible_processes() + .iter() + .find(|p| p.pid == pid) + .map(|p| (p.pid, p.comm.clone(), p.nice as i32)) + }); + if let Some((pid, comm, nice)) = target { + app.nice_edit.open_for(pid, &comm, nice); + } else { + app.flash_status("no process selected"); + } + } + Key::Char('l') if app.current_tab == TabId::Process => { + if let Some(pid) = app.selected_pid() { + let files = crate::open_files::read(pid); + let count = files.len(); + app.open_files_result = Some(files); + app.open_files_for_pid = Some(pid); + app.show_open_files = true; + app.flash_toast(format!("PID {} has {} open fd", pid, count)); + } else { + app.flash_status("no process selected"); + } + } Key::Char('r') => { app.refresh(); last_refresh = Instant::now(); diff --git a/local/recipes/system/redbear-power/source/src/nice_edit.rs b/local/recipes/system/redbear-power/source/src/nice_edit.rs new file mode 100644 index 0000000000..43104d2525 --- /dev/null +++ b/local/recipes/system/redbear-power/source/src/nice_edit.rs @@ -0,0 +1,155 @@ +use ratatui::{ + layout::{Alignment, Constraint, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph, Widget}, +}; + +use crate::theme; + +const MIN_NICE: i32 = -20; +const MAX_NICE: i32 = 19; + +pub struct NiceEdit { + pub open: bool, + pub pid: u32, + pub comm: String, + pub value: i32, + pub result: Option>, + pub result_timer: Option, +} + +impl Default for NiceEdit { + fn default() -> Self { + Self { + open: false, + pid: 0, + comm: String::new(), + value: 0, + result: None, + result_timer: None, + } + } +} + +impl NiceEdit { + pub fn open_for(&mut self, pid: u32, comm: &str, current_nice: i32) { + self.pid = pid; + self.comm = comm.to_string(); + self.value = current_nice.clamp(MIN_NICE, MAX_NICE) as i32; + self.open = true; + self.result = None; + self.result_timer = None; + } + + pub fn close(&mut self) { + self.open = false; + } + + pub fn adjust(&mut self, delta: i32) { + self.value = (self.value + delta).clamp(MIN_NICE, MAX_NICE); + } + + pub fn apply(&mut self) { + let pid = self.pid; + let nice = self.value; + let result = crate::process::set_nice(pid, nice); + self.result = Some(result); + self.result_timer = Some(std::time::Instant::now()); + } + + pub fn auto_close_if_done(&mut self) { + if let Some(t) = self.result_timer { + if t.elapsed().as_secs() >= 2 { + self.close(); + } + } + } +} + +impl Widget for &NiceEdit { + fn render(self, area: Rect, buf: &mut ratatui::buffer::Buffer) { + let width = 48u16.min(area.width); + let height = 9u16.min(area.height); + let x = area.x + (area.width - width) / 2; + let y = area.y + (area.height - height) / 2; + let dialog = Rect::new(x, y, width, height); + + Clear.render(dialog, buf); + + let block_style = Style::default().add_modifier(Modifier::BOLD); + let block = Block::default() + .borders(Borders::ALL) + .title(Span::styled( + " Adjust Nice ", + Style::default().add_modifier(Modifier::BOLD), + )) + .style(block_style); + let inner = block.inner(dialog); + block.render(dialog, buf); + + let [info_area, slider_area, msg_area] = Layout::vertical([ + Constraint::Length(2), + Constraint::Length(3), + Constraint::Min(1), + ]) + .spacing(0) + .areas(inner); + + let info = Paragraph::new(vec![ + Line::from(Span::styled( + format!(" PID {} {}", self.pid, self.comm), + theme::VALUE, + )), + Line::from(""), + ]); + Widget::render(&info, info_area, buf); + + let bar_width = slider_area.width.saturating_sub(2) as usize; + let range = (MAX_NICE - MIN_NICE) as usize; + let pos = ((self.value - MIN_NICE) as usize).min(range); + let filled = if bar_width > 0 { + pos * bar_width / range.max(1) + } else { + 0 + }; + + let bar: String = + "\u{2588}".repeat(filled) + &"\u{2591}".repeat(bar_width.saturating_sub(filled)); + let slider_line = Line::from(vec![ + Span::raw(format!("{:+4} [", self.value)), + Span::styled( + bar, + if self.value < 0 { + Style::default().fg(theme::STATUS_OK.fg.unwrap_or_default()) + } else if self.value > 0 { + Style::default().fg(theme::STATUS_WARN.fg.unwrap_or_default()) + } else { + theme::VALUE + }, + ), + Span::raw("]"), + ]); + Widget::render( + &Paragraph::new(slider_line).alignment(Alignment::Center), + slider_area, + buf, + ); + + let msg = if let Some(res) = &self.result { + match res { + Ok(()) => Line::from(Span::styled( + " Applied \u{2014} press Esc to close", + theme::STATUS_OK, + )), + Err(e) => Line::from(Span::styled(format!(" Error: {}", e), theme::STATUS_ERR)), + } + } else { + Line::from(Span::styled( + " \u{2191}\u{2193}/+- adjust, Enter=apply, Esc=close", + theme::VALUE_OFF, + )) + }; + Widget::render(&Paragraph::new(msg), msg_area, buf); + } +} diff --git a/local/recipes/system/redbear-power/source/src/open_files.rs b/local/recipes/system/redbear-power/source/src/open_files.rs new file mode 100644 index 0000000000..7204130081 --- /dev/null +++ b/local/recipes/system/redbear-power/source/src/open_files.rs @@ -0,0 +1,122 @@ +use std::fs; +use std::os::unix::fs::FileTypeExt; +use std::os::unix::fs::MetadataExt; + +#[derive(Clone, Debug)] +pub enum FileKind { + Regular, + Directory, + Socket, + Pipe, + Character, + Block, + Symlink, + AnonInode, + Other, +} + +impl FileKind { + fn icon(&self) -> &str { + match self { + Self::Regular => "\u{1F4C4}", + Self::Directory => "\u{1F4C1}", + Self::Socket => "\u{1F310}", + Self::Pipe => "\u{1F6BF}", + Self::Character => "\u{1F5A5}", + Self::Block => "\u{1F4BE}", + Self::Symlink => "\u{1F517}", + Self::AnonInode => "\u{2699}", + Self::Other => "\u{2753}", + } + } +} + +#[derive(Clone, Debug)] +pub struct OpenFile { + pub fd: u32, + pub target: String, + pub kind: FileKind, + pub size: u64, + pub inode: u64, +} + +pub fn read(pid: u32) -> Vec { + let fd_dir = format!("/proc/{}/fd", pid); + let entries = match fs::read_dir(&fd_dir) { + Ok(e) => e, + Err(_) => return Vec::new(), + }; + + let mut files = Vec::new(); + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = match name.to_str() { + Some(s) => s, + None => continue, + }; + let fd: u32 = match name_str.parse() { + Ok(n) => n, + Err(_) => continue, + }; + + let link_path = entry.path(); + let target = fs::read_link(&link_path) + .ok() + .and_then(|p| p.to_str().map(|s| s.to_string())) + .unwrap_or_else(|| "?".to_string()); + + let (kind, size, inode) = if target.starts_with("socket:[") { + (FileKind::Socket, 0, 0) + } else if target.starts_with("pipe:[") { + (FileKind::Pipe, 0, 0) + } else if target.starts_with("anon_inode:") { + (FileKind::AnonInode, 0, 0) + } else if !target.starts_with('/') { + (FileKind::Other, 0, 0) + } else { + match fs::metadata(&target) { + Ok(meta) => { + let kind = meta.file_type(); + let k = if kind.is_dir() { + FileKind::Directory + } else if kind.is_file() { + FileKind::Regular + } else if kind.is_symlink() { + FileKind::Symlink + } else if kind.is_fifo() { + FileKind::Pipe + } else { + FileKind::Other + }; + (k, meta.len(), meta.ino()) + } + Err(_) => (FileKind::Other, 0, 0), + } + }; + + files.push(OpenFile { + fd, + target, + kind, + size, + inode, + }); + } + + files.sort_by_key(|f| f.fd); + files +} + +pub fn format_files(files: &[OpenFile]) -> Vec { + files + .iter() + .map(|f| { + let size_str = if f.size > 0 { + format!(" ({} bytes)", f.size) + } else { + String::new() + }; + format!("{} {:>3} {}{}", f.kind.icon(), f.fd, f.target, size_str) + }) + .collect() +} diff --git a/local/recipes/system/redbear-power/source/src/render.rs b/local/recipes/system/redbear-power/source/src/render.rs index 0a8d5b9f47..8481f6698a 100644 --- a/local/recipes/system/redbear-power/source/src/render.rs +++ b/local/recipes/system/redbear-power/source/src/render.rs @@ -2020,6 +2020,8 @@ TABS: [e] expand current tab to full-screen (toggle) [f] freeze / unfreeze data updates [k] kill selected process (Process tab only) + [N] edit nice value of selected process (Process tab) + [l] list open file descriptors of selected process (Process tab) [y] toggle process tree view (Process tab) [F] filter processes by name (Process tab) [o] cycle process sort mode