From a93e9807f0f425ca17c5600dc4af0a16d5e2648e Mon Sep 17 00:00:00 2001 From: vasilito Date: Tue, 7 Jul 2026 02:54:36 +0300 Subject: [PATCH] redbear-power: CPU affinity editor (a key, Process tab) New affinity.rs module renders a toggleable CPU grid dialog: - Space toggles selected CPU on/off - Arrow keys navigate the grid - Enter applies via sched_setaffinity(2) - Auto-closes 2s after successful apply - Shows +N for enabled, -N for disabled CPUs - Grid adapts to CPU count (4/8/16 columns) Added set_affinity() to process.rs using libc::sched_setaffinity. Added 'a' key to keybar and help text. --- .../redbear-power/source/src/affinity.rs | 195 ++++++++++++++++++ .../system/redbear-power/source/src/app.rs | 2 + .../system/redbear-power/source/src/main.rs | 33 +++ .../redbear-power/source/src/process.rs | 27 +++ .../system/redbear-power/source/src/render.rs | 3 +- 5 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 local/recipes/system/redbear-power/source/src/affinity.rs diff --git a/local/recipes/system/redbear-power/source/src/affinity.rs b/local/recipes/system/redbear-power/source/src/affinity.rs new file mode 100644 index 0000000000..5e3386094e --- /dev/null +++ b/local/recipes/system/redbear-power/source/src/affinity.rs @@ -0,0 +1,195 @@ +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::Style, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Widget}, +}; + +use crate::theme; + +pub struct AffinityEditor { + pub open: bool, + pid: u32, + comm: String, + num_cpus: usize, + selected: usize, + mask: Vec, + result: Option>, + result_at: Option, +} + +impl Default for AffinityEditor { + fn default() -> Self { + Self { + open: false, + pid: 0, + comm: String::new(), + num_cpus: 1, + selected: 0, + mask: vec![true], + result: None, + result_at: None, + } + } +} + +impl AffinityEditor { + pub fn open_for(&mut self, pid: u32, comm: &str, current: &[u32], num_cpus: usize) { + self.open = true; + self.pid = pid; + self.comm = comm.to_string(); + self.num_cpus = num_cpus.max(1); + self.selected = 0; + self.mask = vec![false; self.num_cpus]; + for &cpu in current { + if (cpu as usize) < self.mask.len() { + self.mask[cpu as usize] = true; + } + } + if !self.mask.iter().any(|&v| v) { + self.mask.iter_mut().for_each(|v| *v = true); + } + self.result = None; + self.result_at = None; + } + + pub fn close(&mut self) { + self.open = false; + } + + pub fn move_cursor(&mut self, dx: i32, dy: i32) { + let cols = self.grid_cols(); + let row = (self.selected / cols) as i32 + dy; + let col = (self.selected % cols) as i32 + dx; + if row < 0 || col < 0 { + return; + } + let idx = row as usize * cols + col as usize; + if idx < self.num_cpus { + self.selected = idx; + } + } + + pub fn toggle(&mut self) { + if self.selected < self.mask.len() { + self.mask[self.selected] = !self.mask[self.selected]; + } + } + + fn grid_cols(&self) -> usize { + match self.num_cpus { + 0..=8 => 4, + 9..=32 => 8, + 33..=64 => 8, + _ => 16, + } + } + + fn selected_cpus(&self) -> Vec { + self.mask + .iter() + .enumerate() + .filter(|&(_, &on)| on) + .map(|(i, _)| i as u32) + .collect() + } + + pub fn apply(&mut self) { + let cpus = self.selected_cpus(); + if cpus.is_empty() { + self.result = Some(Err("cannot set empty affinity".into())); + self.result_at = Some(std::time::Instant::now()); + return; + } + let r = crate::process::set_affinity(self.pid, &cpus); + match &r { + Ok(()) => { + self.result = Some(Ok(())); + } + Err(e) => { + self.result = Some(Err(e.clone())); + } + } + self.result_at = Some(std::time::Instant::now()); + } + + pub fn should_auto_close(&self) -> bool { + if let Some(at) = self.result_at { + if self.result.as_ref().map_or(false, |r| r.is_ok()) { + return at.elapsed().as_secs() >= 2; + } + } + false + } +} + +impl Widget for &mut AffinityEditor { + fn render(self, area: Rect, buf: &mut ratatui::buffer::Buffer) { + Clear.render(area, buf); + let cols = self.grid_cols(); + let rows = (self.num_cpus + cols - 1) / cols; + let dialog_h = (rows + 7).min(24) as u16; + let dialog_w = ((cols * 6) + 4).min(80) as u16; + let dx = area.x + (area.width.saturating_sub(dialog_w)) / 2; + let dy = area.y + (area.height.saturating_sub(dialog_h)) / 2; + let darea = Rect::new(dx, dy, dialog_w, dialog_h); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(theme::BORDER_FOCUSED) + .title(format!(" Affinity: {} (PID {}) ", self.comm, self.pid)); + let inner = block.inner(darea); + block.render(darea, buf); + + let chunks = Layout::vertical([Constraint::Min(3), Constraint::Length(2)]).split(inner); + let grid_area = chunks[0]; + let footer_area = chunks[1]; + + let mut lines: Vec = Vec::new(); + for r in 0..rows { + let mut spans: Vec = Vec::new(); + for c in 0..cols { + let idx = r * cols + c; + if idx >= self.num_cpus { + break; + } + let is_on = self.mask[idx]; + let is_sel = idx == self.selected; + let label = format!("{:>3}", idx); + let style = if is_sel { + Style::default().add_modifier( + ratatui::style::Modifier::BOLD | ratatui::style::Modifier::REVERSED, + ) + } else if is_on { + theme::VALUE + } else { + theme::VALUE_OFF + }; + let marker = if is_on { "+" } else { "-" }; + spans.push(Span::styled(format!("{marker}{label} "), style)); + } + lines.push(Line::from(spans)); + } + let para = ratatui::widgets::Paragraph::new(lines); + para.render(grid_area, buf); + + if let Some(result) = &self.result { + let msg = match result { + Ok(()) => format!("OK — affinity set to {} CPU(s)", self.selected_cpus().len()), + Err(e) => format!("Error: {e}"), + }; + let style = if result.is_ok() { + theme::STATUS_OK + } else { + theme::STATUS_ERR + }; + let para = ratatui::widgets::Paragraph::new(Line::from(Span::styled(msg, style))); + para.render(footer_area, buf); + } else { + let hint = "Space:toggle Enter:apply Esc:cancel Arrows:move"; + let para = + ratatui::widgets::Paragraph::new(Line::from(Span::styled(hint, theme::VALUE_OFF))); + para.render(footer_area, buf); + } + } +} diff --git a/local/recipes/system/redbear-power/source/src/app.rs b/local/recipes/system/redbear-power/source/src/app.rs index 79a9b18b09..98b3b7fe30 100644 --- a/local/recipes/system/redbear-power/source/src/app.rs +++ b/local/recipes/system/redbear-power/source/src/app.rs @@ -270,6 +270,7 @@ pub struct App { /// 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 affinity_editor: crate::affinity::AffinityEditor, pub open_files_result: Option>, pub open_files_for_pid: Option, pub show_open_files: bool, @@ -501,6 +502,7 @@ impl App { storage_graph_history: crate::graph::RingHistory::new(120), kill_dialog: crate::kill::KillDialog::new(), nice_edit: crate::nice_edit::NiceEdit::default(), + affinity_editor: crate::affinity::AffinityEditor::default(), open_files_result: None, open_files_for_pid: None, show_open_files: false, diff --git a/local/recipes/system/redbear-power/source/src/main.rs b/local/recipes/system/redbear-power/source/src/main.rs index b071176c01..d17348b62a 100644 --- a/local/recipes/system/redbear-power/source/src/main.rs +++ b/local/recipes/system/redbear-power/source/src/main.rs @@ -46,6 +46,7 @@ use termion::raw::IntoRawMode; use termion::screen::IntoAlternateScreen; mod acpi; +mod affinity; mod app; mod battery; mod bench; @@ -601,6 +602,9 @@ fn main() -> io::Result<()> { if app.nice_edit.open { f.render_widget(&app.nice_edit, full); } + if app.affinity_editor.open { + f.render_widget(&mut app.affinity_editor, full); + } if app.show_open_files { if let Some(files) = &app.open_files_result { let pid = app.open_files_for_pid.unwrap_or(0); @@ -838,6 +842,9 @@ fn main() -> io::Result<()> { app.kill_dialog.auto_close_if_done(); app.nice_edit.auto_close_if_done(); + if app.affinity_editor.should_auto_close() { + app.affinity_editor.close(); + } if let Some(Ok(event)) = events.next() { if let Event::Mouse(me) = event { @@ -886,6 +893,14 @@ fn main() -> io::Result<()> { 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.affinity_editor.open => app.affinity_editor.close(), + Key::Char('\n') if app.affinity_editor.open => app.affinity_editor.apply(), + Key::Char(' ') if app.affinity_editor.open => app.affinity_editor.toggle(), + Key::Up if app.affinity_editor.open => app.affinity_editor.move_cursor(0, -1), + Key::Down if app.affinity_editor.open => app.affinity_editor.move_cursor(0, 1), + Key::Left if app.affinity_editor.open => app.affinity_editor.move_cursor(-1, 0), + Key::Right if app.affinity_editor.open => app.affinity_editor.move_cursor(1, 0), + _ if app.affinity_editor.open => {} Key::Esc if app.show_open_files => { app.show_open_files = false; app.open_files_result = None; @@ -1025,6 +1040,24 @@ fn main() -> io::Result<()> { app.flash_status("no process selected"); } } + Key::Char('a') + if app.current_tab == TabId::Process && !app.affinity_editor.open => + { + 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.cpu_affinity.clone())) + }); + if let Some((pid, comm, affinity)) = target { + let cur = + affinity.unwrap_or_else(|| (0..app.cpus.len() as u32).collect()); + app.affinity_editor + .open_for(pid, &comm, &cur, app.cpus.len()); + } 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); diff --git a/local/recipes/system/redbear-power/source/src/process.rs b/local/recipes/system/redbear-power/source/src/process.rs index a79a4c5d7b..63689081a8 100644 --- a/local/recipes/system/redbear-power/source/src/process.rs +++ b/local/recipes/system/redbear-power/source/src/process.rs @@ -2017,3 +2017,30 @@ pub fn set_nice(pid: u32, nice: i32) -> Result<(), String> { Err("setpriority not supported on this platform".into()) } } + +pub fn set_affinity(pid: u32, cpus: &[u32]) -> Result<(), String> { + #[cfg(target_os = "linux")] + { + let mut set: libc::cpu_set_t = unsafe { std::mem::zeroed() }; + for &cpu in cpus { + if (cpu as usize) < libc::CPU_SETSIZE as usize { + unsafe { + libc::CPU_SET(cpu as usize, &mut set); + } + } + } + let r = unsafe { + libc::sched_setaffinity(pid as i32, std::mem::size_of::(), &set) + }; + if r == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error().to_string()) + } + } + #[cfg(not(target_os = "linux"))] + { + let _ = (pid, cpus); + Err("sched_setaffinity not supported on this platform".into()) + } +} diff --git a/local/recipes/system/redbear-power/source/src/render.rs b/local/recipes/system/redbear-power/source/src/render.rs index fdc8651d37..699c929c6c 100644 --- a/local/recipes/system/redbear-power/source/src/render.rs +++ b/local/recipes/system/redbear-power/source/src/render.rs @@ -2057,7 +2057,7 @@ pub fn render_keybar<'a>(app: &'a App) -> Paragraph<'a> { let gov = format!(" g:{}", app.cpufreq.active.as_str()); let interval = format!(" [/]:{}ms", app.poll_ms); let tab_hints: &str = if app.current_tab == crate::app::TabId::Process { - " ↑↓:sel k:kill N:nice l:files C:cmd o:sort i:invert F:filter y:tree Enter:detail" + " ↑↓:sel k:kill N:nice a:affinity l:files C:cmd o:sort i:invert F:filter y:tree Enter:detail" } else { " ↑↓:cpu p/P:±pstate m/M:min/max t:throttle r:refresh" }; @@ -2174,6 +2174,7 @@ INTERACTIVE CONTROLS: [k] kill selected process (Process tab only) [N] edit nice value of selected process (Process tab) + [a] edit CPU affinity of selected process (Process tab) [l] list open file descriptors of selected process (Process tab) [C] toggle full command line vs comm name (Process tab) [y] toggle process tree view (Process tab)