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:
+305
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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", ¶ms));
|
||||
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
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user