Initial commit: kmod-tui v1 — kernel module inspector TUI

Features:
- Browse all loaded kernel modules from /proc/modules
- Two-pane layout: scrollable list + detail pane
- Full-screen detail view with modinfo, parameters, holders
- Vim-style navigation (j/k, Enter, /, s, r, ?, q)
- Edge-scrolling (cursor moves freely, viewport follows)
- Filter by module name AND dependents (used_by)
- Sort by name, size, refcnt, or dependents count
- Lazy modinfo fetching (shells out only on selection)
- 13 unit tests (parser, filter, scroll logic)

Tech: Rust + ratatui + crossterm
This commit is contained in:
Hermes Agent
2026-09-07 17:35:00 +00:00
commit 00ba8781f2
12 changed files with 2209 additions and 0 deletions
+305
View File
@@ -0,0 +1,305 @@
//! App state: the in-memory model the UI renders from.
use crate::model::{self, ModInfo, Module};
use std::sync::mpsc;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortMode {
Name,
Size,
Refcnt,
Dependents,
}
impl SortMode {
pub fn next(self) -> Self {
match self {
SortMode::Name => SortMode::Size,
SortMode::Size => SortMode::Refcnt,
SortMode::Refcnt => SortMode::Dependents,
SortMode::Dependents => SortMode::Name,
}
}
pub fn label(self) -> &'static str {
match self {
SortMode::Name => "name",
SortMode::Size => "size",
SortMode::Refcnt => "refcnt",
SortMode::Dependents => "dependents",
}
}
}
/// Which screen is active.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum View {
List,
FullDetail,
}
pub struct App {
/// All loaded modules (unfiltered, unsorted master copy).
pub modules: Vec<Module>,
/// Indices into `modules` after filtering + sorting.
pub view: Vec<usize>,
pub cursor: usize,
pub filter: String,
pub filter_active: bool,
pub sort: SortMode,
pub view_mode: View,
pub kernel: String,
/// Pending lazy modinfo fetches.
pub pending: Vec<mpsc::Receiver<(String, Option<ModInfo>)>>,
/// Whether the help overlay is shown.
pub help: bool,
pub should_quit: bool,
/// Vertical scroll offset (index of the first visible row in the list).
pub scroll: usize,
/// Number of data rows visible in the list pane (set by the UI each draw).
pub viewport_rows: usize,
}
impl App {
pub fn new() -> Self {
let modules = model::collect_modules();
let kernel = model::kernel_release();
let mut app = App {
modules,
view: Vec::new(),
cursor: 0,
filter: String::new(),
filter_active: false,
sort: SortMode::Name,
view_mode: View::List,
kernel,
pending: Vec::new(),
help: false,
should_quit: false,
scroll: 0,
viewport_rows: 0,
};
app.rebuild_view();
app
}
/// Rebuild the filtered + sorted view from the master module list.
pub fn rebuild_view(&mut self) {
let f = self.filter.to_lowercase();
let mut idxs: Vec<usize> = self
.modules
.iter()
.enumerate()
.filter(|(_, m)| m.matches_filter(&f))
.map(|(i, _)| i)
.collect();
idxs.sort_by(|&a, &b| {
let ma = &self.modules[a];
let mb = &self.modules[b];
match self.sort {
SortMode::Name => ma.name.cmp(&mb.name),
SortMode::Size => mb.size.cmp(&ma.size),
SortMode::Refcnt => mb.refcnt.cmp(&ma.refcnt),
SortMode::Dependents => mb.used_by.len().cmp(&ma.used_by.len()),
}
});
self.view = idxs;
if self.cursor >= self.view.len() {
self.cursor = self.cursor.saturating_sub(1);
}
}
/// Index into `modules` of the module under the cursor, if any.
pub fn current_module(&self) -> Option<&Module> {
self.view
.get(self.cursor)
.and_then(|&i| self.modules.get(i))
}
/// Reserved for v1.1: mutating a selected module in place (e.g. on refresh).
#[allow(dead_code)]
pub fn current_module_mut(&mut self) -> Option<&mut Module> {
self.view
.get(self.cursor)
.and_then(|&i| self.modules.get_mut(i))
}
pub fn move_cursor(&mut self, delta: i32) {
if self.view.is_empty() {
return;
}
let n = self.view.len() as i32;
let mut c = self.cursor as i32 + delta;
if c < 0 {
c = 0;
}
if c >= n {
c = n - 1;
}
self.cursor = c as usize;
}
pub fn toggle_sort(&mut self) {
self.sort = self.sort.next();
self.rebuild_view();
self.cursor = 0;
self.scroll = 0;
}
/// Reserved for v1.1: set an explicit sort mode by key.
#[allow(dead_code)]
pub fn set_sort(&mut self, s: SortMode) {
self.sort = s;
self.rebuild_view();
self.cursor = 0;
self.scroll = 0;
}
pub fn enter_filter(&mut self) {
self.filter_active = true;
}
pub fn push_filter_char(&mut self, c: char) {
self.filter.push(c);
self.rebuild_view();
self.cursor = 0;
self.scroll = 0;
}
pub fn pop_filter_char(&mut self) {
self.filter.pop();
self.rebuild_view();
self.cursor = 0;
self.scroll = 0;
}
pub fn clear_filter(&mut self) {
self.filter.clear();
self.filter_active = false;
self.rebuild_view();
self.cursor = 0;
self.scroll = 0;
}
pub fn refresh(&mut self) {
self.modules = model::collect_modules();
self.rebuild_view();
}
/// Request modinfo for the module under the cursor (if not already loaded).
pub fn ensure_modinfo(&mut self) {
if let Some(m) = self.current_module() {
if m.modinfo.is_none() {
let name = m.name.clone();
let rx = model::spawn_modinfo_fetch(name);
self.pending.push(rx);
}
}
}
/// Drain any completed lazy modinfo fetches and merge them into the
/// matching module. Returns true if anything changed (needs redraw).
pub fn poll_pending(&mut self) -> bool {
let mut changed = false;
let mut i = 0;
while i < self.pending.len() {
match self.pending[i].try_recv() {
Ok((name, mi)) => {
if let Some(m) = self.modules.iter_mut().find(|m| m.name == name) {
m.modinfo = mi;
changed = true;
}
self.pending.remove(i);
}
Err(mpsc::TryRecvError::Empty) => {
i += 1;
}
Err(mpsc::TryRecvError::Disconnected) => {
self.pending.remove(i);
}
}
}
changed
}
/// Adjust the scroll offset so the cursor stays within the viewport.
/// Edge-scrolling: only scrolls when the cursor would leave the visible
/// area. Called by the UI after layout is known (viewport_rows is set).
pub fn adjust_scroll(&mut self) {
self.scroll = compute_scroll(self.cursor, self.scroll, self.viewport_rows, self.view.len());
}
}
/// Pure scroll-offset computation. Edge-scrolling: the cursor moves freely
/// within the viewport; the viewport only scrolls when the cursor would
/// leave it.
///
/// - `cursor` — index of the selected row
/// - `current_scroll`— the current scroll offset (top visible row index)
/// - `rows_visible` — how many data rows fit in the list pane
/// - `total` — total number of rows in the view
pub fn compute_scroll(cursor: usize, current_scroll: usize, rows_visible: usize, total: usize) -> usize {
if rows_visible == 0 || total <= rows_visible {
return 0;
}
let max_scroll = total - rows_visible;
let mut scroll = current_scroll;
if cursor < scroll {
// Cursor is above the viewport — scroll up to reveal it.
scroll = cursor;
} else if cursor >= scroll + rows_visible {
// Cursor is below the viewport — scroll down so it's the last row.
scroll = cursor - rows_visible + 1;
}
// cursor is within [scroll, scroll+rows_visible) — no change needed.
scroll.min(max_scroll)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scroll_no_scroll_when_all_fit() {
// total (8) < rows_visible (10) — everything fits, no scrolling.
assert_eq!(compute_scroll(5, 0, 10, 8), 0);
}
#[test]
fn scroll_stays_at_top_when_cursor_in_viewport() {
// cursor 3 in [0,10) — no scroll change.
assert_eq!(compute_scroll(3, 0, 10, 100), 0);
}
#[test]
fn scroll_down_when_cursor_below_viewport() {
// cursor 10, viewport [0,10) — scroll to 1, cursor becomes last row.
assert_eq!(compute_scroll(10, 0, 10, 100), 1);
// cursor 15 — scroll to 6.
assert_eq!(compute_scroll(15, 0, 10, 100), 6);
}
#[test]
fn scroll_up_when_cursor_above_viewport() {
// cursor 5 < scroll 20 — scroll up to 5.
assert_eq!(compute_scroll(5, 20, 10, 100), 5);
}
#[test]
fn scroll_no_change_when_cursor_in_middle_of_viewport() {
// cursor 25 in [20,30) — no scroll change.
assert_eq!(compute_scroll(25, 20, 10, 100), 20);
}
#[test]
fn scroll_clamps_at_bottom() {
// Can't scroll past total - rows_visible = 90.
assert_eq!(compute_scroll(99, 90, 10, 100), 90);
// Even if cursor is at the very last row.
assert_eq!(compute_scroll(99, 0, 10, 100), 90);
}
#[test]
fn scroll_handles_zero_rows() {
assert_eq!(compute_scroll(5, 0, 0, 100), 0);
}
}
+16
View File
@@ -0,0 +1,16 @@
//! Terminal input events via crossterm.
use crossterm::event::{self, Event, KeyEvent};
use std::time::Duration;
/// Poll for a terminal event with a small timeout. Returns None if no event
/// arrived within the poll window (lets the app stay responsive and do
/// background work like receiving lazy modinfo fetches).
pub fn poll(timeout_ms: u64) -> Option<KeyEvent> {
if event::poll(Duration::from_millis(timeout_ms)).unwrap_or(false) {
if let Ok(Event::Key(key)) = event::read() {
return Some(key);
}
}
None
}
+169
View File
@@ -0,0 +1,169 @@
//! kmod-tui — a TUI for browsing and inspecting loaded Linux kernel modules.
//!
//! Read-only. Data from /proc/modules, /sys/module/<name>/, and `modinfo`.
mod app;
mod event;
mod model;
mod ui;
use std::io::{self, stdout};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use crate::app::View;
fn main() -> io::Result<()> {
// Setup terminal.
enable_raw_mode()?;
let mut stdout = stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
// Run the app.
let result = run(&mut terminal);
// Restore terminal regardless of outcome.
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
result
}
fn run(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<()> {
let mut app = app::App::new();
// Eagerly fetch modinfo for the first module so the detail pane isn't empty.
app.ensure_modinfo();
loop {
// Draw current state.
terminal.draw(|f| ui::draw(f, &mut app))?;
// Poll for input (short timeout so we can drain background fetches).
if let Some(key) = event::poll(100) {
handle_key(&mut app, key);
}
// Drain any completed modinfo fetches; redraw if something arrived.
if app.poll_pending() {
terminal.draw(|f| ui::draw(f, &mut app))?;
}
if app.should_quit {
break;
}
}
Ok(())
}
fn handle_key(app: &mut app::App, key: KeyEvent) {
// Global: quit works anywhere (unless typing in filter).
if !app.filter_active && matches!(key.code, KeyCode::Char('q')) {
app.should_quit = true;
return;
}
if !app.filter_active && matches!(key.code, KeyCode::Char('?')) {
app.help = !app.help;
return;
}
if app.help {
// Any key (except ?) closes help.
if matches!(key.code, KeyCode::Char('?')) {
app.help = false;
} else {
app.help = false;
}
return;
}
if app.filter_active {
handle_filter_key(app, key);
return;
}
match app.view_mode {
View::List => handle_list_key(app, key),
View::FullDetail => handle_detail_key(app, key),
}
}
fn handle_filter_key(app: &mut app::App, key: KeyEvent) {
match key.code {
KeyCode::Esc => app.clear_filter(),
KeyCode::Enter => app.filter_active = false,
KeyCode::Backspace => {
app.pop_filter_char();
}
KeyCode::Char(c) => {
if key.modifiers.contains(KeyModifiers::CONTROL) {
if c == 'c' {
app.clear_filter();
}
return;
}
app.push_filter_char(c);
}
_ => {}
}
}
fn handle_list_key(app: &mut app::App, key: KeyEvent) {
match key.code {
KeyCode::Down | KeyCode::Char('j') => app.move_cursor(1),
KeyCode::Up | KeyCode::Char('k') => app.move_cursor(-1),
KeyCode::Char('G') => {
if !app.view.is_empty() {
app.cursor = app.view.len() - 1;
}
}
KeyCode::Char('g') => {
if key.modifiers.contains(KeyModifiers::CONTROL) || key.modifiers.is_empty() {
app.cursor = 0;
}
}
KeyCode::PageDown => {
let step = 10;
app.move_cursor(step);
}
KeyCode::PageUp => app.move_cursor(-10),
KeyCode::Home => app.cursor = 0,
KeyCode::End => {
if !app.view.is_empty() {
app.cursor = app.view.len() - 1;
}
}
KeyCode::Enter => {
app.view_mode = View::FullDetail;
app.ensure_modinfo();
}
KeyCode::Char('/') => app.enter_filter(),
KeyCode::Char('s') => app.toggle_sort(),
KeyCode::Char('r') => app.refresh(),
_ => {}
}
}
fn handle_detail_key(app: &mut app::App, key: KeyEvent) {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => app.view_mode = View::List,
KeyCode::Up | KeyCode::Char('k') => {
app.view_mode = View::List;
app.move_cursor(-1);
app.ensure_modinfo();
app.view_mode = View::FullDetail;
}
KeyCode::Down | KeyCode::Char('j') => {
app.view_mode = View::List;
app.move_cursor(1);
app.ensure_modinfo();
app.view_mode = View::FullDetail;
}
_ => {}
}
}
+407
View File
@@ -0,0 +1,407 @@
//! Data model: collect and represent loaded kernel modules.
//!
//! Data sources (read-only):
//! - /proc/modules : one line per loaded module
//! - /sys/module/<name>/ : refcnt, coresize, initsize, initstate, srcversion,
//! taint, version, parameters/, holders/
//! - `modinfo <name>` : filename, license, description, author, depends,
//! vermagic, retpoline, intree, signature info
//! (shelled out lazily, only when detail is requested,
//! to keep the list view fast.)
use std::fs;
use std::path::Path;
use std::process::Command;
use std::sync::mpsc;
use std::thread;
/// One loaded kernel module.
#[derive(Debug, Clone)]
pub struct Module {
pub name: String,
/// Size in bytes from /proc/modules (this is the core size for most modules).
pub size: usize,
pub refcnt: i64,
/// Modules that depend on this one (parsed from the "used-by" column).
pub used_by: Vec<String>,
pub state: String,
/// Base address (hex string as in /proc/modules).
pub addr: String,
// From /sys/module/<name>/
pub coresize: Option<usize>,
pub initsize: Option<usize>,
pub initstate: Option<String>,
pub srcversion: Option<String>,
pub taint: Option<String>,
pub version: Option<String>,
/// Parameters and their current values (read from /sys/module/<name>/parameters).
pub parameters: Vec<(String, String)>,
/// Modules holding a reference (from /sys/module/<name>/holders).
pub holders: Vec<String>,
// From `modinfo` (lazy; None until explicitly fetched).
pub modinfo: Option<ModInfo>,
}
impl Module {
/// True if the module matches a substring filter: against its own name OR
/// any of the modules that depend on it (`used_by`). Case-insensitive.
pub fn matches_filter(&self, needle: &str) -> bool {
if needle.is_empty() {
return true;
}
let needle = needle.to_lowercase();
if self.name.to_lowercase().contains(&needle) {
return true;
}
self.used_by
.iter()
.any(|dep| dep.to_lowercase().contains(&needle))
}
}
/// The richest metadata, obtained from `modinfo`.
#[derive(Debug, Clone, Default)]
pub struct ModInfo {
pub filename: String,
pub license: String,
pub description: String,
pub author: String,
pub depends: Vec<String>,
pub vermagic: String,
pub retpoline: String,
pub intree: String,
pub signer: String,
pub sig_hashalgo: String,
/// Other fields we didn't classify, kept as (key, value) pairs.
pub other: Vec<(String, String)>,
}
/// Read a sysfs file to a trimmed String. Returns None on any error.
fn read_sysfs(path: &Path) -> Option<String> {
fs::read_to_string(path).ok().map(|s| s.trim().to_string())
}
/// Parse the "used-by" column of /proc/modules: tokens separated by commas,
/// or "-" if nothing uses this module.
fn parse_used_by(s: &str) -> Vec<String> {
if s == "-" || s.is_empty() {
Vec::new()
} else {
s.split_terminator(',')
.map(|x| x.trim().trim_end_matches('[').trim_end_matches(']').to_string())
.filter(|x| !x.is_empty())
.collect()
}
}
/// Read parameters from /sys/module/<name>/parameters.
fn read_parameters(name: &str) -> Vec<(String, String)> {
let dir = Path::new("/sys/module").join(name).join("parameters");
let mut out = Vec::new();
if let Ok(entries) = fs::read_dir(&dir) {
for ent in entries.flatten() {
let pname = ent.file_name().to_string_lossy().to_string();
let val = fs::read_to_string(ent.path())
.unwrap_or_default()
.trim()
.to_string();
out.push((pname, val));
}
}
// Deterministic ordering.
out.sort_by(|a, b| a.0.cmp(&b.0));
out
}
/// Read holders from /sys/module/<name>/holders.
fn read_holders(name: &str) -> Vec<String> {
let dir = Path::new("/sys/module").join(name).join("holders");
let mut out = Vec::new();
if let Ok(entries) = fs::read_dir(&dir) {
for ent in entries.flatten() {
out.push(ent.file_name().to_string_lossy().to_string());
}
}
out.sort();
out
}
/// Collect all currently-loaded modules from /proc/modules and /sys/module.
pub fn collect_modules() -> Vec<Module> {
let proc = fs::read_to_string("/proc/modules").unwrap_or_default();
let mut modules: Vec<Module> = Vec::new();
for line in proc.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 6 {
continue;
}
// parts: name size refcnt used_by state addr [extra...]
let name = parts[0].to_string();
let size: usize = parts[1].parse().unwrap_or(0);
let refcnt: i64 = parts[2].parse().unwrap_or(0);
let used_by = parse_used_by(parts[3]);
let state = parts.get(4).map(|s| s.to_string()).unwrap_or_default();
let addr = parts.get(5).map(|s| s.to_string()).unwrap_or_default();
// Enrich from /sys/module/<name>.
let sys = Path::new("/sys/module").join(&name);
let coresize = read_sysfs(&sys.join("coresize")).and_then(|s| s.parse().ok());
let initsize = read_sysfs(&sys.join("initsize")).and_then(|s| s.parse().ok());
let initstate = read_sysfs(&sys.join("initstate"));
let srcversion = read_sysfs(&sys.join("srcversion"));
let taint = read_sysfs(&sys.join("taint"));
let version = read_sysfs(&sys.join("version"));
let parameters = read_parameters(&name);
let holders = read_holders(&name);
modules.push(Module {
name,
size,
refcnt,
used_by,
state,
addr,
coresize,
initsize,
initstate,
srcversion,
taint,
version,
parameters,
holders,
modinfo: None,
});
}
modules.sort_by(|a, b| a.name.cmp(&b.name));
modules
}
/// Fetch rich metadata via `modinfo <name>` and parse it into a ModInfo struct.
pub fn fetch_modinfo(name: &str) -> Option<ModInfo> {
let output = Command::new("modinfo").arg(name).output().ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
Some(parse_modinfo(&stdout))
}
/// Parse `modinfo` output. Each line: `key: value`. Multi-line values (like
/// `signature`) are folded by detecting a leading tab/whitespace continuation.
fn parse_modinfo(text: &str) -> ModInfo {
let mut mi = ModInfo::default();
let known: &[&str] = &[
"filename",
"license",
"description",
"author",
"depends",
"vermagic",
"retpoline",
"intree",
"signer",
"sig_hashalgo",
"sig_id",
"sig_key",
"signature",
"srcversion",
"name",
"version",
];
let mut last_key = String::new();
for raw in text.lines() {
// Continuation line (starts with whitespace) appends to last value.
if raw.starts_with('\t') || (raw.starts_with(' ') && !raw.trim().is_empty()) {
if !last_key.is_empty() {
let cont = raw.trim();
// Append to whichever field last_key refers to.
append_to_field(&mut mi, &last_key, cont);
}
continue;
}
if let Some((k, v)) = raw.split_once(':') {
let key = k.trim().to_string();
let val = v.trim().to_string();
last_key = key.clone();
set_field(&mut mi, &key, &val, known);
}
}
mi
}
fn set_field(mi: &mut ModInfo, key: &str, val: &str, known: &[&str]) {
match key {
"filename" => mi.filename = val.to_string(),
"license" => mi.license = val.to_string(),
"description" => mi.description = val.to_string(),
"author" => mi.author = val.to_string(),
"depends" => {
mi.depends = if val == "-" || val.is_empty() {
Vec::new()
} else {
val.split(',').map(|s| s.trim().to_string()).collect()
};
}
"vermagic" => mi.vermagic = val.to_string(),
"retpoline" => mi.retpoline = val.to_string(),
"intree" => mi.intree = val.to_string(),
"signer" => mi.signer = val.to_string(),
"sig_hashalgo" => mi.sig_hashalgo = val.to_string(),
_ => {
let _ = known;
mi.other.push((key.to_string(), val.to_string()));
}
}
}
fn append_to_field(mi: &mut ModInfo, key: &str, val: &str) {
match key {
"description" => {
if !mi.description.is_empty() {
mi.description.push(' ');
}
mi.description.push_str(val);
}
"signature" | "sig_key" => {
// Append to the most recent 'other' entry if it matches.
if let Some(last) = mi.other.last_mut() {
if last.0 == key {
last.1.push_str(val);
}
}
}
_ => {}
}
}
/// Spawn a thread to fetch modinfo for a module, returning a channel that
/// yields (name, Option<ModInfo>) when ready. Lets the UI stay responsive.
pub fn spawn_modinfo_fetch(name: String) -> mpsc::Receiver<(String, Option<ModInfo>)> {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let mi = fetch_modinfo(&name);
let _ = tx.send((name, mi));
});
rx
}
/// Human-readable byte size, /proc/modules style: 12288 -> "12K", 114688 -> "112K".
pub fn human_size(bytes: usize) -> String {
let k = 1024usize;
let m = k * 1024;
if bytes >= m {
format!("{:.1}M", bytes as f64 / m as f64)
} else if bytes >= k {
format!("{}K", bytes / k)
} else {
format!("{}B", bytes)
}
}
/// Kernel release string, e.g. "6.8.0-137-generic".
pub fn kernel_release() -> String {
fs::read_to_string("/proc/sys/kernel/osrelease")
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| "unknown".to_string())
}
/// Count of modules currently loaded (cheap: line count of /proc/modules).
/// Reserved for a future status-bar "loaded vs filtered" split.
#[allow(dead_code)]
pub fn loaded_count() -> usize {
fs::read_to_string("/proc/modules")
.map(|s| s.lines().filter(|l| !l.is_empty()).count())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_used_by_dash() {
assert!(parse_used_by("-").is_empty());
}
#[test]
fn parse_used_by_list() {
let v = parse_used_by("wireguard,curve25519_x86_64");
assert_eq!(v, vec!["wireguard", "curve25519_x86_64"]);
}
#[test]
fn filter_matches_name() {
let m = Module {
name: "wireguard".into(),
size: 0,
refcnt: 0,
used_by: vec![],
state: "Live".into(),
addr: String::new(),
coresize: None,
initsize: None,
initstate: None,
srcversion: None,
taint: None,
version: None,
parameters: vec![],
holders: vec![],
modinfo: None,
};
assert!(m.matches_filter("wire"));
assert!(m.matches_filter("GUARD")); // case-insensitive
assert!(!m.matches_filter("ipsec"));
assert!(m.matches_filter("")); // empty matches all
}
#[test]
fn filter_matches_dependents() {
let m = Module {
name: "curve25519_x86_64".into(),
size: 0,
refcnt: 1,
used_by: vec!["wireguard".into()],
state: "Live".into(),
addr: String::new(),
coresize: None,
initsize: None,
initstate: None,
srcversion: None,
taint: None,
version: None,
parameters: vec![],
holders: vec![],
modinfo: None,
};
// Name doesn't contain "wireguard" but a dependent does.
assert!(m.matches_filter("wireguard"));
assert!(m.matches_filter("curve")); // also still matches own name
assert!(!m.matches_filter("chacha"));
}
#[test]
fn parse_modinfo_basic() {
let s = "filename: /lib/modules/6.8.0-137-generic/kernel/wireguard/wireguard.ko.zst\n\
license: GPL\n\
description: WireGuard secure tunnel\n\
author: Jason A. Donenfeld <Jason@zx2c4.com>\n\
depends: \n\
retpoline: Y\n\
intree: Y\n\
name: wireguard\n\
vermagic: 6.8.0-137-generic SMP preempt mod_unload modversions \n";
let mi = parse_modinfo(s);
assert_eq!(mi.license, "GPL");
assert_eq!(mi.description, "WireGuard secure tunnel");
assert_eq!(mi.author, "Jason A. Donenfeld <Jason@zx2c4.com>");
assert!(mi.depends.is_empty());
assert_eq!(mi.retpoline, "Y");
}
#[test]
fn human_size_units() {
assert_eq!(human_size(12288), "12K");
assert_eq!(human_size(114688), "112K");
assert_eq!(human_size(1048576), "1.0M");
assert_eq!(human_size(512), "512B");
}
}
+207
View File
@@ -0,0 +1,207 @@
//! The detail pane (bottom of list view) and the full-screen detail view.
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap};
use ratatui::Frame;
use crate::app::App;
use crate::model::{human_size, Module};
/// Bottom detail pane: a compact two-column summary of the selected module.
pub fn draw_pane(f: &mut Frame, area: Rect, app: &App) {
let title = match app.current_module() {
Some(m) => format!(" Detail: {} ", m.name),
None => " Detail ".to_string(),
};
let block = Block::default().borders(Borders::ALL).title(title);
f.render_widget(block, area);
let inner = area.inner(ratatui::layout::Margin {
horizontal: 1,
vertical: 1,
});
if inner.height < 1 {
return;
}
let Some(m) = app.current_module() else {
let p = Paragraph::new("No module selected.")
.style(Style::default().fg(Color::DarkGray));
f.render_widget(p, inner);
return;
};
let cols = Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]).split(inner);
let left = summary_lines(m);
let right = meta_lines(m);
f.render_widget(Paragraph::new(left).wrap(Wrap { trim: true }), cols[0]);
f.render_widget(Paragraph::new(right).wrap(Wrap { trim: true }), cols[1]);
}
fn kv<'a>(k: &str, v: &str) -> Line<'a> {
Line::from(vec![
Span::styled(
format!("{:<11}", k),
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
),
Span::raw(v.to_string()),
])
}
fn summary_lines<'a>(m: &Module) -> Vec<Line<'a>> {
let mut lines = vec![
kv("Name", &m.name),
kv("Size", &human_size(m.size)),
kv("Refcnt", &m.refcnt.to_string()),
kv("State", &m.state),
kv("Address", &m.addr),
];
if let Some(cs) = m.coresize {
lines.push(kv("Coresize", &human_size(cs)));
}
if let Some(is) = m.initsize {
lines.push(kv("Initsize", &human_size(is)));
}
if let Some(v) = &m.initstate {
lines.push(kv("Initstate", v));
}
if let Some(v) = &m.taint {
if !v.is_empty() {
lines.push(kv("Taint", v));
}
}
if let Some(v) = &m.version {
lines.push(kv("Version", v));
}
if let Some(v) = &m.srcversion {
lines.push(kv("Srcversion", v));
}
lines
}
fn meta_lines<'a>(m: &Module) -> Vec<Line<'a>> {
let mut lines: Vec<Line> = Vec::new();
match &m.modinfo {
Some(mi) => {
lines.push(kv("Filename", &mi.filename));
lines.push(kv("License", &mi.license));
lines.push(kv("Author", &mi.author));
lines.push(kv("Vermagic", &mi.vermagic));
lines.push(kv("Retpoline", &mi.retpoline));
lines.push(kv("Intree", &mi.intree));
lines.push(kv("Signer", &mi.signer));
}
None => {
lines.push(Line::from(Span::styled(
"Loading modinfo…",
Style::default().fg(Color::DarkGray),
)));
}
}
let dep = if m.used_by.is_empty() {
"(none)".to_string()
} else {
m.used_by.join(", ")
};
lines.push(kv("Used by", &dep));
let holders = if m.holders.is_empty() {
"(none)".to_string()
} else {
m.holders.join(", ")
};
lines.push(kv("Holders", &holders));
let params = if m.parameters.is_empty() {
"(none)".to_string()
} else {
m.parameters
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join(", ")
};
lines.push(kv("Params", &params));
lines
}
/// Full-screen detail view for the selected module.
pub fn draw_full(f: &mut Frame, area: Rect, app: &App) {
let title = match app.current_module() {
Some(m) => format!(" {} — full detail (Esc to return) ", m.name),
None => " Full detail ".to_string(),
};
let block = Block::default().borders(Borders::ALL).title(title);
f.render_widget(block, area);
let inner = area.inner(ratatui::layout::Margin {
horizontal: 1,
vertical: 1,
});
let Some(m) = app.current_module() else {
return;
};
// Three sections: summary, modinfo/depends, parameters+holders.
let chunks = Layout::vertical([
Constraint::Length(9),
Constraint::Length(8),
Constraint::Min(5),
])
.split(inner);
f.render_widget(Paragraph::new(summary_lines(m)), chunks[0]);
let mut meta = meta_lines_full(m);
f.render_widget(Paragraph::new(meta.drain(..).collect::<Vec<_>>()), chunks[1]);
// Parameters and holders as lists.
let sub = Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]).split(chunks[2]);
let pitems: Vec<ListItem> = if m.parameters.is_empty() {
vec![ListItem::new("(none)")]
} else {
m.parameters
.iter()
.map(|(k, v)| ListItem::new(format!("{} = {}", k, v)))
.collect()
};
f.render_widget(
List::new(pitems).block(Block::default().borders(Borders::ALL).title("Parameters")),
sub[0],
);
let hitems: Vec<ListItem> = if m.holders.is_empty() {
vec![ListItem::new("(none)")]
} else {
m.holders.iter().map(|h| ListItem::new(h.clone())).collect()
};
f.render_widget(
List::new(hitems).block(Block::default().borders(Borders::ALL).title("Holders")),
sub[1],
);
}
fn meta_lines_full<'a>(m: &Module) -> Vec<Line<'a>> {
let mut lines: Vec<Line> = Vec::new();
if let Some(mi) = &m.modinfo {
lines.push(kv("Filename", &mi.filename));
lines.push(kv("License", &mi.license));
lines.push(kv("Description", &mi.description));
lines.push(kv("Author", &mi.author));
lines.push(kv("Vermagic", &mi.vermagic));
let dep = if mi.depends.is_empty() {
"(none)".to_string()
} else {
mi.depends.join(", ")
};
lines.push(kv("Depends", &dep));
lines.push(kv("Retpoline", &mi.retpoline));
} else {
lines.push(Line::from(Span::styled(
"Loading modinfo… (press Enter again if it stalls)",
Style::default().fg(Color::DarkGray),
)));
}
lines
}
#[allow(dead_code)]
fn _alignment_unused() -> Alignment {
Alignment::Left
}
+91
View File
@@ -0,0 +1,91 @@
//! The module list pane (top of the list view).
//!
//! Scrolling is edge-based: the cursor moves freely within the viewport and
//! the list only scrolls when the cursor would leave the visible area. The
//! scroll offset lives in `App` (set by `ui::mod` via `adjust_scroll`) so it's
//! stateful and stable across draws.
use ratatui::layout::{Constraint, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{Block, Borders, Row, Table};
use ratatui::Frame;
use crate::app::App;
use crate::model::human_size;
/// Column widths.
const WIDTHS: [Constraint; 6] = [
Constraint::Length(4), // #
Constraint::Min(22), // name
Constraint::Length(8), // size
Constraint::Length(5), // refcnt
Constraint::Min(18), // used by
Constraint::Length(7), // state
];
/// How many data rows fit in the list pane for the given area (excludes the
/// header row and the border). Used by `ui::mod` to size the viewport before
/// the list is drawn.
pub fn visible_rows(area: Rect) -> usize {
let inner = area.inner(ratatui::layout::Margin {
horizontal: 1,
vertical: 1,
});
inner.height.saturating_sub(1) as usize // account for the header row
}
pub fn draw(f: &mut Frame, area: Rect, app: &App) {
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" Loaded modules: {} ", app.view.len()));
f.render_widget(block, area);
let inner = area.inner(ratatui::layout::Margin {
horizontal: 1,
vertical: 1,
});
if inner.height < 1 {
return;
}
let header = Row::new(vec!["#", "Module", "Size", "Refc", "Used by", "State"])
.style(Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan));
let rows_visible = visible_rows(area);
let start = app.scroll;
let rows: Vec<Row> = app
.view
.iter()
.enumerate()
.skip(start)
.take(rows_visible)
.map(|(vn, &idx)| {
let m = &app.modules[idx];
let used = if m.used_by.is_empty() {
"-".to_string()
} else {
m.used_by.join(",")
};
let selected = vn == app.cursor;
let row_style = if selected {
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
Row::new(vec![
format!("{}", vn + 1),
m.name.clone(),
human_size(m.size),
format!("{}", m.refcnt),
used,
m.state.clone(),
])
.style(row_style)
})
.collect();
let table = Table::new(rows, WIDTHS).header(header);
f.render_widget(table, inner);
}
+171
View File
@@ -0,0 +1,171 @@
pub mod list;
pub mod detail;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use ratatui::Frame;
use crate::app::{App, View};
/// Compose the whole frame from the app state.
pub fn draw(f: &mut Frame, app: &mut App) {
let area = f.area();
let chunks = Layout::vertical([
Constraint::Length(3), // status/filter bar
Constraint::Min(5), // main area
Constraint::Length(2), // footer
])
.split(area);
draw_status(f, chunks[0], app);
match app.view_mode {
View::List => {
let main = Layout::vertical([Constraint::Fill(2), Constraint::Length(10)])
.split(chunks[1]);
// Set how many rows the list pane can show, then edge-scroll so
// the cursor stays visible without centering it.
app.viewport_rows = list::visible_rows(main[0]);
app.adjust_scroll();
list::draw(f, main[0], app);
detail::draw_pane(f, main[1], app);
}
View::FullDetail => {
detail::draw_full(f, chunks[1], app);
}
}
draw_footer(f, chunks[2], app);
if app.help {
draw_help(f, area);
}
}
fn draw_status(f: &mut Frame, area: Rect, app: &App) {
let title = format!(
" kmod-tui — {} modules loaded (kernel {}) ",
app.view.len(),
app.kernel
);
let block = Block::default().borders(Borders::ALL).title(title);
f.render_widget(block, area);
let inner = area.inner(ratatui::layout::Margin {
horizontal: 1,
vertical: 1,
});
let line = if app.filter_active {
Line::from(vec![
Span::styled(
"Filter: ",
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
),
Span::raw(app.filter.clone()),
Span::styled("_", Style::default().fg(Color::Gray)),
Span::raw(" "),
Span::raw(format!("Sort: {}", app.sort.label())),
])
} else {
Line::from(vec![
Span::styled(
"Sort: ",
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
),
Span::raw(app.sort.label()),
Span::raw(" "),
Span::styled(
format!("Filter: {}", if app.filter.is_empty() { "(none)" } else { &app.filter }),
Style::default().fg(Color::Gray),
),
])
};
f.render_widget(Paragraph::new(line), inner);
}
fn draw_footer(f: &mut Frame, area: Rect, app: &App) {
let block = Block::default().borders(Borders::TOP);
f.render_widget(block, area);
let inner = area.inner(ratatui::layout::Margin {
horizontal: 1,
vertical: 0,
});
let keys = match app.view_mode {
View::List => {
if app.filter_active {
"Esc clear Backspace del type to filter Enter accept q quit"
} else {
"↑/j down ↓/k up Enter detail / filter s sort r refresh ? help q quit"
}
}
View::FullDetail => "Esc back ? help q quit",
};
let line = Line::from(Span::styled(
keys,
Style::default().fg(Color::DarkGray),
));
f.render_widget(Paragraph::new(line), inner);
}
fn draw_help(f: &mut Frame, area: Rect) {
let help_text = vec![
Line::from(Span::styled(
"kmod-tui — help",
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(vec![
Span::styled(" ↑/j ", Style::default().fg(Color::Yellow)),
Span::raw("move selection down"),
]),
Line::from(vec![
Span::styled(" ↓/k ", Style::default().fg(Color::Yellow)),
Span::raw("move selection up"),
]),
Line::from(vec![
Span::styled(" Enter ", Style::default().fg(Color::Yellow)),
Span::raw("open full detail for selected module"),
]),
Line::from(vec![
Span::styled(" / ", Style::default().fg(Color::Yellow)),
Span::raw("start filtering by module name"),
]),
Line::from(vec![
Span::styled(" Esc ", Style::default().fg(Color::Yellow)),
Span::raw("clear filter / close full detail"),
]),
Line::from(vec![
Span::styled(" s ", Style::default().fg(Color::Yellow)),
Span::raw("cycle sort: name → size → refcnt → dependents"),
]),
Line::from(vec![
Span::styled(" r ", Style::default().fg(Color::Yellow)),
Span::raw("refresh module list from /proc/modules"),
]),
Line::from(vec![
Span::styled(" ? ", Style::default().fg(Color::Yellow)),
Span::raw("toggle this help overlay"),
]),
Line::from(vec![
Span::styled(" q ", Style::default().fg(Color::Yellow)),
Span::raw("quit"),
]),
];
let width = 52;
let height = help_text.len() as u16 + 4;
let x = area.width.saturating_sub(width) / 2;
let y = area.height.saturating_sub(height) / 2;
let popup = Rect::new(x, y, width, height);
f.render_widget(Clear, popup);
let block = Block::default()
.borders(Borders::ALL)
.title(" Help (press ? to close) ");
f.render_widget(block, popup);
let inner = popup.inner(ratatui::layout::Margin {
horizontal: 1,
vertical: 1,
});
f.render_widget(Paragraph::new(help_text), inner);
}