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.
This commit is contained in:
2026-07-07 02:54:36 +03:00
parent b3a249e450
commit a93e9807f0
5 changed files with 259 additions and 1 deletions
@@ -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<bool>,
result: Option<Result<(), String>>,
result_at: Option<std::time::Instant>,
}
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<u32> {
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<Line> = Vec::new();
for r in 0..rows {
let mut spans: Vec<Span> = 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);
}
}
}
@@ -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<Vec<crate::open_files::OpenFile>>,
pub open_files_for_pid: Option<u32>,
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,
@@ -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);
@@ -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::<libc::cpu_set_t>(), &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())
}
}
@@ -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)