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
This commit is contained in:
2026-07-07 01:00:13 +03:00
parent f7f2890f4e
commit d5c8498ac3
5 changed files with 380 additions and 1 deletions
@@ -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<Vec<crate::open_files::OpenFile>>,
pub open_files_for_pid: Option<u32>,
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,
};
@@ -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<ratatui::text::Line> =
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();
@@ -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<Result<(), String>>,
pub result_timer: Option<std::time::Instant>,
}
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);
}
}
@@ -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<OpenFile> {
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<String> {
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()
}
@@ -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