use std::collections::BTreeSet;
use std::io;
use std::time::Duration;
use anyhow::Context;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style, Stylize};
use ratatui::text::Line;
use ratatui::widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table, TableState, Widget};
use ratatui::{DefaultTerminal, Frame};
use sysinfo::Pid;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use crate::error::AppResult;
use crate::process::{ProcessCatalog, ProcessRecord};
const TABLE_COLUMN_SPACING: u16 = 2;
const SEL_WIDTH: u16 = 5;
const PID_WIDTH: u16 = 7;
const CPU_WIDTH: u16 = 8;
const MEMORY_WIDTH: u16 = 10;
const APP_MIN_WIDTH: u16 = 12;
const APP_MAX_WIDTH: u16 = 22;
const PROCESS_MIN_WIDTH: u16 = 20;
const PROCESS_MAX_WIDTH: u16 = 42;
const PORTS_MIN_WIDTH: u16 = 8;
const PORTS_MAX_WIDTH: u16 = 18;
pub fn pick_interactive(catalog: &ProcessCatalog, verbose: bool) -> AppResult<BTreeSet<Pid>> {
let records = catalog.process_records();
if records.is_empty() {
return Ok(BTreeSet::new());
}
let mut terminal = init_terminal().context("interactive mode failed")?;
let result = run_app(&mut terminal, records, verbose);
restore_terminal(terminal).context("failed to restore terminal")?;
result.map(|state| state.selected_pids().into_iter().collect::<BTreeSet<_>>())
}
fn run_app(
terminal: &mut DefaultTerminal,
records: Vec<ProcessRecord>,
verbose: bool,
) -> AppResult<AppState> {
let mut state = AppState::new(records, verbose);
drain_pending_events()?;
loop {
terminal
.draw(|frame| draw(frame, &mut state))
.context("failed to draw interactive UI")?;
if event::poll(Duration::from_millis(200)).context("failed to read terminal events")?
&& let Event::Key(key) = event::read().context("failed to read key event")?
&& handle_key_event(&mut state, key)
{
return Ok(state);
}
}
}
fn init_terminal() -> io::Result<DefaultTerminal> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
ratatui::Terminal::new(ratatui::backend::CrosstermBackend::new(stdout))
}
fn restore_terminal(mut terminal: DefaultTerminal) -> io::Result<()> {
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
Ok(())
}
fn drain_pending_events() -> AppResult<()> {
while event::poll(Duration::from_millis(0)).context("failed to drain terminal events")? {
let _ = event::read().context("failed to read terminal event")?;
}
Ok(())
}
fn handle_key_event(state: &mut AppState, key: KeyEvent) -> bool {
if key.kind != KeyEventKind::Press {
return false;
}
match key.code {
KeyCode::Esc => {
state.cancelled = true;
true
}
KeyCode::Enter => !state.selected.is_empty(),
KeyCode::F(1) => {
state.set_sort_mode(SortMode::Cpu);
false
}
KeyCode::F(2) => {
state.set_sort_mode(SortMode::Memory);
false
}
KeyCode::F(3) => {
state.set_sort_mode(SortMode::Name);
false
}
KeyCode::Tab => {
state.toggle_search_mode();
false
}
KeyCode::Up => {
state.move_up();
false
}
KeyCode::Down => {
state.move_down();
false
}
KeyCode::PageUp => {
state.page_up();
false
}
KeyCode::PageDown => {
state.page_down();
false
}
KeyCode::Home => {
state.jump_to_start();
false
}
KeyCode::End => {
state.jump_to_end();
false
}
KeyCode::Char(' ') => {
state.toggle_current();
false
}
KeyCode::Backspace => {
state.pop_query();
false
}
KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
state.select_all_filtered();
false
}
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
state.cancelled = true;
true
}
KeyCode::Char(ch)
if !key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::ALT) =>
{
state.push_query(ch);
false
}
_ => false,
}
}
fn draw(frame: &mut Frame<'_>, state: &mut AppState) {
let vertical = Layout::vertical([
Constraint::Length(3),
Constraint::Min(8),
Constraint::Length(3),
Constraint::Length(2),
]);
let [search_area, table_area, detail_area, help_area] = vertical.areas(frame.area());
render_search(frame, search_area, state);
render_table(frame, table_area, state);
render_detail(frame, detail_area, state);
render_help(frame, help_area, state);
}
fn render_search(frame: &mut Frame<'_>, area: Rect, state: &AppState) {
let title = format!(
" Search [{}] {} ",
state.search_mode.label(),
if state.query.is_empty() {
"(Tab to switch)"
} else {
""
}
);
let input = Paragraph::new(state.query.clone()).block(
Block::bordered()
.title(title)
.border_style(Style::default().fg(Color::Cyan)),
);
frame.render_widget(input, area);
}
fn render_table(frame: &mut Frame<'_>, area: Rect, state: &mut AppState) {
state.sync_table_state();
let header = Row::new([
Cell::from("Sel"),
Cell::from("PID"),
Cell::from("App"),
Cell::from("Process"),
Cell::from(format!("{:>6}", "CPU")),
Cell::from("Memory"),
Cell::from("Ports"),
])
.style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
.height(1);
let filtered_records = state.filtered_records();
let widths = compute_table_widths(&filtered_records, state.verbose, area.width);
let app_width = constraint_length(widths[2]);
let process_width = constraint_length(widths[3]);
let rows = filtered_records.into_iter().map(|record| {
let selected = if state.is_selected(record.pid) {
"[x]"
} else {
"[ ]"
};
let process_name = if state.verbose && !record.cmd.is_empty() {
format!("{} | {}", record.name, truncate(&record.cmd, 48))
} else {
record.name.clone()
};
let cpu_style = cpu_style(record.cpu_usage);
let mem_style = memory_style(record.memory_bytes);
let wrapped_app_name = wrap_cell_text(&record.app_name, app_width);
let wrapped_process_name = wrap_cell_text(&process_name, process_width);
let wrapped_ports = format_ports_wrapped(&record.ports);
let row_lines = wrapped_app_name
.lines()
.count()
.max(wrapped_process_name.lines().count())
.max(wrapped_ports.lines().count())
.max(1) as u16;
Row::new([
Cell::from(selected),
Cell::from(record.pid.as_u32().to_string()),
Cell::from(wrapped_app_name),
Cell::from(wrapped_process_name),
Cell::from(format!("{:>5.1}%", record.cpu_usage)).style(cpu_style),
Cell::from(format_memory(record.memory_bytes)).style(mem_style),
Cell::from(wrapped_ports),
])
.height(row_lines)
});
let table = Table::new(rows, widths)
.column_spacing(TABLE_COLUMN_SPACING)
.header(header)
.row_highlight_style(
Style::default()
.bg(Color::Rgb(24, 34, 54))
.add_modifier(Modifier::BOLD),
)
.block(
Block::default()
.title(" Processes ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Blue)),
);
frame.render_stateful_widget(table, area, &mut state.table_state);
if state.filtered_indexes.is_empty() {
frame.render_widget(Clear, centered_rect(50, 10, area));
frame.render_widget(EmptyState, centered_rect(50, 10, area));
}
}
fn render_help(frame: &mut Frame<'_>, area: Rect, state: &AppState) {
let total = state.filtered_records().len();
let selected = state.selected.len();
let sort = state.sort_mode.label();
let search = state.search_mode.label();
let line = Line::from(vec![
"F1".cyan().bold(),
" CPU ".dark_gray(),
"F2".cyan().bold(),
" Memory ".dark_gray(),
"F3".cyan().bold(),
" Name ".dark_gray(),
"Space".cyan().bold(),
" toggle ".dark_gray(),
"Ctrl+A".cyan().bold(),
" all ".dark_gray(),
"Tab".cyan().bold(),
" search ".dark_gray(),
"Enter".green().bold(),
" confirm ".dark_gray(),
"Esc".yellow().bold(),
" cancel ".dark_gray(),
format!("Sort {sort} Search {search} Showing {total} Selected {selected}").white(),
]);
frame.render_widget(Paragraph::new(line), area);
}
fn render_detail(frame: &mut Frame<'_>, area: Rect, state: &AppState) {
let block = Block::default()
.title(" Details ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Blue));
let content = match state.current_record() {
Some(record) => {
let ports = format_ports_inline(&record.ports);
let line1 = Line::from(vec![
"App ".dark_gray(),
record.app_name.as_str().white().bold(),
" Process ".dark_gray(),
record.name.as_str().white(),
" PID ".dark_gray(),
record.pid.as_u32().to_string().cyan(),
" CPU ".dark_gray(),
format!("{:.1}%", record.cpu_usage).fg(cpu_color(record.cpu_usage)),
" Memory ".dark_gray(),
format_memory(record.memory_bytes).fg(memory_color(record.memory_bytes)),
" Ports ".dark_gray(),
ports.white(),
]);
let cmd = if record.cmd.is_empty() {
"Command: -".to_string()
} else {
format!("Command: {}", truncate(&record.cmd, 160))
};
vec![line1, Line::from(cmd.dark_gray())]
}
None => vec![Line::from("No process selected".dark_gray())],
};
frame.render_widget(Paragraph::new(content).block(block), area);
}
fn centered_rect(horizontal: u16, vertical: u16, area: Rect) -> Rect {
let vertical_layout = Layout::vertical([
Constraint::Percentage((100 - vertical) / 2),
Constraint::Percentage(vertical),
Constraint::Percentage((100 - vertical) / 2),
]);
let [_, middle, _] = vertical_layout.areas(area);
let horizontal_layout = Layout::horizontal([
Constraint::Percentage((100 - horizontal) / 2),
Constraint::Percentage(horizontal),
Constraint::Percentage((100 - horizontal) / 2),
]);
let [_, center, _] = horizontal_layout.areas(middle);
center
}
fn cpu_style(cpu: f32) -> Style {
Style::default().fg(cpu_color(cpu))
}
fn cpu_color(cpu: f32) -> Color {
if cpu >= 60.0 {
Color::Red
} else if cpu >= 25.0 {
Color::Yellow
} else {
Color::Green
}
}
fn memory_style(memory: u64) -> Style {
Style::default().fg(memory_color(memory))
}
fn memory_color(memory: u64) -> Color {
if memory >= 1_500_000_000 {
Color::Red
} else if memory >= 512_000_000 {
Color::Yellow
} else {
Color::Green
}
}
fn format_ports_inline(ports: &BTreeSet<u16>) -> String {
if ports.is_empty() {
return "-".to_string();
}
ports
.iter()
.take(4)
.map(|port| format!(":{port}"))
.collect::<Vec<_>>()
.join(",")
}
fn format_ports_wrapped(ports: &BTreeSet<u16>) -> String {
if ports.is_empty() {
return "-".to_string();
}
ports
.iter()
.take(4)
.map(|port| format!(":{port}"))
.collect::<Vec<_>>()
.chunks(2)
.map(|chunk| chunk.join(","))
.collect::<Vec<_>>()
.join("\n")
}
fn compute_table_widths(
records: &[&ProcessRecord],
verbose: bool,
total_width: u16,
) -> [Constraint; 7] {
let reserved = SEL_WIDTH + PID_WIDTH + CPU_WIDTH + MEMORY_WIDTH + 2;
let spacing = TABLE_COLUMN_SPACING * 6;
let flexible = total_width.saturating_sub(reserved + spacing);
let app_target = content_width(
records
.iter()
.map(|record| UnicodeWidthStr::width(record.app_name.as_str())),
APP_MIN_WIDTH,
APP_MAX_WIDTH,
);
let process_target = content_width(
records.iter().map(|record| {
if verbose && !record.cmd.is_empty() {
UnicodeWidthStr::width(
format!("{} | {}", record.name, truncate(&record.cmd, 48)).as_str(),
)
} else {
UnicodeWidthStr::width(record.name.as_str())
}
}),
PROCESS_MIN_WIDTH,
PROCESS_MAX_WIDTH,
);
let ports_target = content_width(
records
.iter()
.map(|record| longest_line_width(&format_ports_wrapped(&record.ports))),
PORTS_MIN_WIDTH,
PORTS_MAX_WIDTH,
);
let (app_width, process_width, ports_width) = allocate_flexible_widths(
flexible,
[
(APP_MIN_WIDTH, app_target),
(PROCESS_MIN_WIDTH, process_target),
(PORTS_MIN_WIDTH, ports_target),
],
);
[
Constraint::Length(SEL_WIDTH),
Constraint::Length(PID_WIDTH),
Constraint::Length(app_width),
Constraint::Length(process_width),
Constraint::Length(CPU_WIDTH),
Constraint::Length(MEMORY_WIDTH),
Constraint::Length(ports_width),
]
}
fn content_width<I>(lengths: I, min_width: u16, max_width: u16) -> u16
where
I: Iterator<Item = usize>,
{
let width = lengths.max().unwrap_or(min_width as usize) as u16;
width.clamp(min_width, max_width)
}
fn longest_line_width(value: &str) -> usize {
value.lines().map(UnicodeWidthStr::width).max().unwrap_or(0)
}
fn allocate_flexible_widths(total: u16, specs: [(u16, u16); 3]) -> (u16, u16, u16) {
let min_total = specs.iter().map(|(min, _)| *min).sum::<u16>();
if total <= min_total {
return (specs[0].0, specs[1].0, specs[2].0);
}
let mut widths = [specs[0].0, specs[1].0, specs[2].0];
let targets = [specs[0].1, specs[1].1, specs[2].1];
let mut remaining = total - min_total;
loop {
let mut changed = false;
for index in 0..widths.len() {
if remaining == 0 {
break;
}
if widths[index] < targets[index] {
widths[index] += 1;
remaining -= 1;
changed = true;
}
}
if !changed {
break;
}
}
while remaining > 0 {
widths[1] += 1;
remaining -= 1;
}
(widths[0], widths[1], widths[2])
}
fn constraint_length(constraint: Constraint) -> usize {
match constraint {
Constraint::Length(length) => length as usize,
_ => 1,
}
}
fn wrap_cell_text(value: &str, width: usize) -> String {
if width == 0 {
return String::new();
}
let mut lines = Vec::new();
for raw_line in value.lines() {
lines.extend(wrap_line(raw_line, width));
}
if lines.is_empty() {
String::new()
} else {
lines.join("\n")
}
}
fn wrap_line(value: &str, width: usize) -> Vec<String> {
if value.is_empty() {
return vec![String::new()];
}
let mut lines = Vec::new();
let mut current = String::new();
for word in value.split_whitespace() {
let word_len = UnicodeWidthStr::width(word);
if current.is_empty() {
if word_len <= width {
current.push_str(word);
continue;
}
lines.extend(split_long_word(word, width));
continue;
}
let candidate_len = UnicodeWidthStr::width(current.as_str()) + 1 + word_len;
if candidate_len <= width {
current.push(' ');
current.push_str(word);
} else {
lines.push(current);
current = String::new();
if word_len <= width {
current.push_str(word);
} else {
lines.extend(split_long_word(word, width));
}
}
}
if !current.is_empty() {
lines.push(current);
}
lines
}
fn split_long_word(value: &str, width: usize) -> Vec<String> {
let mut chunks = Vec::new();
let mut current = String::new();
let mut current_width = 0;
for ch in value.chars() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if current_width > 0 && current_width + ch_width > width {
chunks.push(std::mem::take(&mut current));
current_width = 0;
}
current.push(ch);
current_width += ch_width;
if current_width >= width {
chunks.push(std::mem::take(&mut current));
current_width = 0;
}
}
if !current.is_empty() {
chunks.push(current);
}
chunks
}
fn format_memory(bytes: u64) -> String {
const KB: f64 = 1024.0;
const MB: f64 = KB * 1024.0;
const GB: f64 = MB * 1024.0;
let bytes = bytes as f64;
if bytes >= GB {
format!("{:.1}G", bytes / GB)
} else if bytes >= MB {
format!("{:.1}M", bytes / MB)
} else if bytes >= KB {
format!("{:.1}K", bytes / KB)
} else {
format!("{:.0}B", bytes)
}
}
fn truncate(value: &str, width: usize) -> String {
let mut result = value.chars().take(width).collect::<String>();
if value.chars().count() > width && width > 1 {
result.pop();
result.push('~');
}
result
}
#[derive(Default)]
struct EmptyState;
impl Widget for EmptyState {
fn render(self, area: Rect, buf: &mut Buffer) {
Paragraph::new("No matching processes")
.block(Block::bordered().title(" Filter "))
.render(area, buf);
}
}
struct AppState {
records: Vec<ProcessRecord>,
filtered_indexes: Vec<usize>,
selected: BTreeSet<Pid>,
cursor: usize,
query: String,
sort_mode: SortMode,
search_mode: SearchMode,
verbose: bool,
cancelled: bool,
table_state: TableState,
}
impl AppState {
fn new(records: Vec<ProcessRecord>, verbose: bool) -> Self {
let filtered_indexes = (0..records.len()).collect::<Vec<_>>();
let mut table_state = TableState::default();
table_state.select(Some(0));
Self {
records,
filtered_indexes,
selected: BTreeSet::new(),
cursor: 0,
query: String::new(),
sort_mode: SortMode::Cpu,
search_mode: SearchMode::Contains,
verbose,
cancelled: false,
table_state,
}
}
fn filtered_records(&self) -> Vec<&ProcessRecord> {
self.filtered_indexes
.iter()
.map(|index| &self.records[*index])
.collect()
}
fn selected_pids(&self) -> Vec<Pid> {
if self.cancelled {
Vec::new()
} else {
self.selected.iter().copied().collect()
}
}
fn is_selected(&self, pid: Pid) -> bool {
self.selected.contains(&pid)
}
fn current_record(&self) -> Option<&ProcessRecord> {
self.filtered_indexes
.get(self.cursor)
.map(|index| &self.records[*index])
}
fn sync_table_state(&mut self) {
if self.filtered_indexes.is_empty() {
self.cursor = 0;
self.table_state.select(None);
} else {
self.cursor = self.cursor.min(self.filtered_indexes.len() - 1);
self.table_state.select(Some(self.cursor));
}
}
fn move_up(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
}
}
fn move_down(&mut self) {
if !self.filtered_indexes.is_empty() && self.cursor + 1 < self.filtered_indexes.len() {
self.cursor += 1;
}
}
fn page_up(&mut self) {
self.cursor = self.cursor.saturating_sub(10);
}
fn page_down(&mut self) {
if !self.filtered_indexes.is_empty() {
self.cursor = (self.cursor + 10).min(self.filtered_indexes.len() - 1);
}
}
fn jump_to_start(&mut self) {
self.cursor = 0;
}
fn jump_to_end(&mut self) {
if !self.filtered_indexes.is_empty() {
self.cursor = self.filtered_indexes.len() - 1;
}
}
fn toggle_current(&mut self) {
if let Some(pid) = self.current_pid() {
if !self.selected.insert(pid) {
self.selected.remove(&pid);
}
}
}
fn select_all_filtered(&mut self) {
let filtered = self
.filtered_indexes
.iter()
.map(|index| self.records[*index].pid)
.collect::<Vec<_>>();
if filtered.iter().all(|pid| self.selected.contains(pid)) {
for pid in filtered {
self.selected.remove(&pid);
}
} else {
self.selected.extend(filtered);
}
}
fn current_pid(&self) -> Option<Pid> {
self.filtered_indexes
.get(self.cursor)
.map(|index| self.records[*index].pid)
}
fn push_query(&mut self, ch: char) {
self.query.push(ch);
self.refresh_filter();
}
fn pop_query(&mut self) {
self.query.pop();
self.refresh_filter();
}
fn set_sort_mode(&mut self, sort_mode: SortMode) {
self.sort_mode = sort_mode;
self.refresh_filter();
}
fn toggle_search_mode(&mut self) {
self.search_mode = self.search_mode.toggle();
self.refresh_filter();
}
fn refresh_filter(&mut self) {
let mut matches = self
.records
.iter()
.enumerate()
.filter_map(|(index, record)| {
self.search_mode
.match_score(record, &self.query)
.map(|score| (index, score))
})
.collect::<Vec<_>>();
matches.sort_by(|(left_index, left_score), (right_index, right_score)| {
let left = &self.records[*left_index];
let right = &self.records[*right_index];
let fuzzy_cmp = right_score.cmp(left_score);
let sort_cmp = self.sort_mode.compare(left, right);
if self.query.trim().is_empty() {
sort_cmp.then(left.pid.as_u32().cmp(&right.pid.as_u32()))
} else {
fuzzy_cmp
.then(sort_cmp)
.then(left.pid.as_u32().cmp(&right.pid.as_u32()))
}
});
self.filtered_indexes = matches.into_iter().map(|(index, _)| index).collect();
self.cursor = 0;
self.sync_table_state();
}
}
#[derive(Clone, Copy)]
enum SortMode {
Cpu,
Memory,
Name,
}
impl SortMode {
fn label(self) -> &'static str {
match self {
SortMode::Cpu => "CPU",
SortMode::Memory => "Memory",
SortMode::Name => "Name",
}
}
fn compare(self, left: &ProcessRecord, right: &ProcessRecord) -> std::cmp::Ordering {
match self {
SortMode::Cpu => right
.cpu_usage
.partial_cmp(&left.cpu_usage)
.unwrap_or(std::cmp::Ordering::Equal)
.then(right.memory_bytes.cmp(&left.memory_bytes))
.then(left.app_name.cmp(&right.app_name))
.then(left.name.cmp(&right.name)),
SortMode::Memory => right
.memory_bytes
.cmp(&left.memory_bytes)
.then(
right
.cpu_usage
.partial_cmp(&left.cpu_usage)
.unwrap_or(std::cmp::Ordering::Equal),
)
.then(left.app_name.cmp(&right.app_name))
.then(left.name.cmp(&right.name)),
SortMode::Name => left
.app_name
.cmp(&right.app_name)
.then(left.name.cmp(&right.name))
.then(
right
.cpu_usage
.partial_cmp(&left.cpu_usage)
.unwrap_or(std::cmp::Ordering::Equal),
),
}
}
}
#[derive(Clone, Copy)]
enum SearchMode {
Contains,
Fuzzy,
}
impl SearchMode {
fn label(self) -> &'static str {
match self {
SearchMode::Contains => "Contains",
SearchMode::Fuzzy => "Fuzzy",
}
}
fn toggle(self) -> Self {
match self {
SearchMode::Contains => SearchMode::Fuzzy,
SearchMode::Fuzzy => SearchMode::Contains,
}
}
fn match_score(self, record: &ProcessRecord, query: &str) -> Option<i64> {
match self {
SearchMode::Contains => contains_match_score(record, query),
SearchMode::Fuzzy => fuzzy_match_score(record, query),
}
}
}
fn contains_match_score(record: &ProcessRecord, query: &str) -> Option<i64> {
if query.trim().is_empty() {
return Some(0);
}
let ports = format_ports_inline(&record.ports);
let pid = record.pid.as_u32().to_string();
let fields = [
record.app_name.as_str(),
record.name.as_str(),
record.cmd.as_str(),
ports.as_str(),
pid.as_str(),
];
let query = query.to_lowercase();
fields
.into_iter()
.filter_map(|field| contains_score(field, &query))
.max()
}
fn contains_score(field: &str, query: &str) -> Option<i64> {
let haystack = field.to_lowercase();
haystack.find(query).map(|index| {
let len_bonus = (query.chars().count() as i64) * 8;
let start_bonus = 100_i64.saturating_sub(index as i64);
len_bonus + start_bonus
})
}
fn fuzzy_match_score(record: &ProcessRecord, query: &str) -> Option<i64> {
if query.trim().is_empty() {
return Some(0);
}
let ports = format_ports_inline(&record.ports);
let fields = [
record.app_name.as_str(),
record.name.as_str(),
record.cmd.as_str(),
ports.as_str(),
];
let mut best = None;
for field in fields {
if let Some(score) = fuzzy_score(field, query) {
best = Some(best.map_or(score, |current: i64| current.max(score)));
}
}
if let Some(score) = fuzzy_score(&record.pid.as_u32().to_string(), query) {
best = Some(best.map_or(score, |current: i64| current.max(score)));
}
best
}
fn fuzzy_score(haystack: &str, needle: &str) -> Option<i64> {
let haystack = haystack.to_lowercase();
let needle = needle.to_lowercase();
let needle_chars = needle.chars().collect::<Vec<_>>();
if needle_chars.is_empty() {
return Some(0);
}
let haystack_chars = haystack.chars().collect::<Vec<_>>();
let mut score = 0_i64;
let mut needle_index = 0_usize;
let mut consecutive = 0_i64;
let mut last_match = None;
for (index, ch) in haystack_chars.iter().enumerate() {
if needle_index >= needle_chars.len() {
break;
}
if *ch == needle_chars[needle_index] {
score += 10;
if index == 0
|| matches!(
haystack_chars.get(index.saturating_sub(1)),
Some(' ' | '-' | '_' | '/' | '\\' | '.')
)
{
score += 15;
}
if let Some(previous) = last_match {
if index == previous + 1 {
consecutive += 1;
score += 20 + consecutive * 5;
} else {
consecutive = 0;
score -= (index - previous - 1) as i64;
}
} else {
score += 25_i64.saturating_sub(index as i64);
}
last_match = Some(index);
needle_index += 1;
}
}
if needle_index == needle_chars.len() {
score += (needle_chars.len() as i64) * 8;
Some(score)
} else {
None
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use sysinfo::Pid;
use super::{
AppState, SearchMode, SortMode, allocate_flexible_widths, contains_match_score,
format_memory, format_ports_wrapped, fuzzy_match_score, wrap_cell_text,
};
use crate::process::ProcessRecord;
fn sample_record() -> ProcessRecord {
ProcessRecord {
pid: Pid::from_u32(42),
app_name: "chrome".to_string(),
name: "chrome.exe".to_string(),
cmd: "chrome.exe --profile".to_string(),
cpu_usage: 12.5,
memory_bytes: 512 * 1024 * 1024,
ports: BTreeSet::from([9222]),
}
}
#[test]
fn fuzzy_filter_matches_app_name_and_ports() {
let record = sample_record();
assert!(fuzzy_match_score(&record, "chrm").is_some());
assert!(fuzzy_match_score(&record, "922").is_some());
assert!(fuzzy_match_score(&record, "cexp").is_some());
assert!(fuzzy_match_score(&record, "firefox").is_none());
}
#[test]
fn contains_filter_prefers_literal_substrings() {
let record = sample_record();
assert!(contains_match_score(&record, "chrome").is_some());
assert!(contains_match_score(&record, "922").is_some());
assert!(contains_match_score(&record, "chrm").is_none());
}
#[test]
fn state_resets_cursor_on_filter_change() {
let records = vec![sample_record()];
let mut state = AppState::new(records, false);
state.push_query('c');
assert_eq!(state.cursor, 0);
}
#[test]
fn memory_format_uses_human_units() {
assert_eq!(format_memory(1024), "1.0K");
assert_eq!(format_memory(1024 * 1024), "1.0M");
}
#[test]
fn wrapped_ports_break_after_every_two_entries() {
let ports = BTreeSet::from([3000, 3001, 3002]);
assert_eq!(format_ports_wrapped(&ports), ":3000,:3001\n:3002");
}
#[test]
fn flexible_widths_expand_toward_targets() {
let widths = allocate_flexible_widths(45, [(12, 18), (20, 30), (8, 14)]);
assert_eq!(widths, (14, 22, 9));
}
#[test]
fn search_mode_toggles_between_contains_and_fuzzy() {
assert!(matches!(SearchMode::Contains.toggle(), SearchMode::Fuzzy));
assert!(matches!(SearchMode::Fuzzy.toggle(), SearchMode::Contains));
}
#[test]
fn sort_mode_switches() {
let mut second = sample_record();
second.pid = Pid::from_u32(99);
second.memory_bytes = 2048;
second.cpu_usage = 99.0;
second.app_name = "aaa".to_string();
let records = vec![sample_record(), second];
let mut state = AppState::new(records, false);
state.set_sort_mode(SortMode::Name);
assert_eq!(
state
.current_record()
.map(|record| record.app_name.as_str()),
Some("aaa")
);
}
#[test]
fn ctrl_a_selects_all_filtered_records() {
let mut second = sample_record();
second.pid = Pid::from_u32(99);
second.app_name = "firefox".to_string();
second.name = "firefox.exe".to_string();
second.cmd = "firefox.exe".to_string();
let records = vec![sample_record(), second];
let mut state = AppState::new(records, false);
state.push_query('c');
state.select_all_filtered();
assert_eq!(state.selected.len(), 1);
assert!(state.selected.contains(&Pid::from_u32(42)));
}
#[test]
fn ctrl_a_toggles_off_when_all_filtered_records_are_selected() {
let records = vec![sample_record()];
let mut state = AppState::new(records, false);
state.select_all_filtered();
state.select_all_filtered();
assert!(state.selected.is_empty());
}
#[test]
fn cell_text_wraps_across_multiple_lines() {
assert_eq!(
wrap_cell_text("Microsoft Edge WebView", 10),
"Microsoft\nEdge\nWebView"
);
assert_eq!(wrap_cell_text("verylongtoken", 4), "very\nlong\ntoke\nn");
}
}