Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e808741ea | |||
| 596407a52d | |||
| 33c002d563 | |||
| 1b3b29ba91 | |||
| 46cd3704b9 | |||
| fc34718ae8 | |||
| dac00073ba | |||
| ac9c12d6d4 | |||
| 5b4f2ac984 | |||
| 827cc0223d | |||
| 9e6851d43c | |||
| 726e628e0d | |||
| 0dc1786f66 | |||
| ed7aba6ed1 | |||
| df34186680 | |||
| 8ef8d16189 | |||
| 4c4db5ce5f | |||
| 3123446f49 | |||
| 070b838aa3 | |||
| 51d790c218 | |||
| 5175dfb739 | |||
| a37eef9d67 | |||
| 38b61167fa | |||
| 6773b79d1f | |||
| 7e4a88e418 | |||
| b95ac973e8 | |||
| 5258ebe6ff | |||
| 7607ff3ee5 | |||
| 8641f22bd1 | |||
| b857d41065 | |||
| 72fc7c6f87 | |||
| 5c92aeadd5 | |||
| acd6e221c5 | |||
| dc9155a2a8 | |||
| feba6e2ad8 | |||
| 0f239bfe8a | |||
| c6532f590c | |||
| 0a16f5ec35 | |||
| 14bb0eb42e | |||
| f79954e2bc | |||
| 87b85ca083 | |||
| 0d494d8e89 | |||
| 19c7856642 | |||
| 777a2c71d3 | |||
| 3452225e0e | |||
| ab2798834c | |||
| da8fe50c4a | |||
| 022d4ce28b | |||
| 13799b8af9 | |||
| 41ffba601a | |||
| 1082f965a9 | |||
| c5b916e062 | |||
| 1584acc4af | |||
| ffc4b3cc6e | |||
| 5fe49527be | |||
| fc0a011591 | |||
| bec5262019 | |||
| eb7b88548b | |||
| 11a7abcc8d | |||
| a99fb17568 | |||
| 5266c3d0ce | |||
| c7ed62eb30 | |||
| ebf6e0cca4 | |||
| 3fd40a6ade | |||
| 9a0b2c5f4a | |||
| b331285661 | |||
| 2728c2e408 | |||
| 04e49cfe38 | |||
| 74ea610745 | |||
| e28d575668 | |||
| eb5b428fa4 | |||
| 7a9e3f3466 | |||
| 905a4f70f8 | |||
| 69dff0c26c | |||
| 85a82f56d4 | |||
| bbcd44833d | |||
| 42feab633e | |||
| f0341aa53d | |||
| 19829958db | |||
| d9ff6b7788 | |||
| fec44e9117 | |||
| bc4df8e437 | |||
| 128832c568 | |||
| cc770de03e | |||
| 7dad32bdc1 | |||
| 9a0e0d86c7 | |||
| 8119824512 | |||
| a0b8ba1014 | |||
| 4dc60bdc5d | |||
| 346daee089 | |||
| 42c807c55a | |||
| 682560cf7a | |||
| ef3f696689 | |||
| fe53efcd83 | |||
| 7d341b6254 | |||
| 9e08f7ed05 | |||
| affb6690c5 |
Generated
+4
-4
@@ -592,7 +592,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.18+rb0.2.5"
|
||||
version = "0.1.18+rb0.3.0"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"libc",
|
||||
@@ -910,12 +910,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "redox_installer"
|
||||
version = "0.2.42+rb0.2.5"
|
||||
version = "0.2.42+rb0.3.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arg_parser",
|
||||
"libc",
|
||||
"libredox 0.1.18+rb0.2.5",
|
||||
"libredox 0.1.18+rb0.3.0",
|
||||
"ring",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
@@ -924,7 +924,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.9.0+rb0.2.5"
|
||||
version = "0.9.0+rb0.3.0"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
@@ -56,8 +56,10 @@ thermald = {}
|
||||
redbear-power = {}
|
||||
hwrngd = {}
|
||||
redbear-acmd = {}
|
||||
redbear-ftdi = {}
|
||||
redbear-ecmd = {}
|
||||
redbear-usbaudiod = {}
|
||||
redbear-usb-hotplugd = {}
|
||||
driver-params = {}
|
||||
|
||||
# ── PCI device database (critical for PCI driver matching) ──
|
||||
@@ -171,6 +173,21 @@ cmd = "audiod"
|
||||
type = "oneshot_async"
|
||||
"""
|
||||
|
||||
[[files]]
|
||||
path = "/etc/init.d/02_usb_hotplug.service"
|
||||
data = """
|
||||
[unit]
|
||||
description = "USB device hotplug daemon (auto-spawns class drivers)"
|
||||
requires_weak = [
|
||||
"00_base.target",
|
||||
"00_pcid-spawner.service",
|
||||
]
|
||||
|
||||
[service]
|
||||
cmd = "redbear-usb-hotplugd"
|
||||
type = "oneshot_async"
|
||||
"""
|
||||
|
||||
[[files]]
|
||||
path = "/etc/init.d/02_serial_probe.service"
|
||||
data = """
|
||||
@@ -561,6 +578,21 @@ cmd = "ipcd"
|
||||
type = "oneshot_async"
|
||||
"""
|
||||
|
||||
[[files]]
|
||||
path = "/etc/init.d/13_ftdi-probe.service"
|
||||
data = """
|
||||
[unit]
|
||||
description = "FTDI USB serial probe (non-blocking on redbear-mini)"
|
||||
requires_weak = [
|
||||
"00_base.target",
|
||||
]
|
||||
|
||||
[service]
|
||||
cmd = "echo"
|
||||
args = ["RB_FTDI_PROBE_OK"]
|
||||
type = "oneshot"
|
||||
"""
|
||||
|
||||
[[files]]
|
||||
path = "/etc/init.d/00_ptyd.service"
|
||||
data = """
|
||||
|
||||
@@ -2,14 +2,24 @@
|
||||
/// binary path. Returns `None` if no driver is registered for this class.
|
||||
///
|
||||
/// USB class codes from <https://www.usb.org/defined-class-codes>:
|
||||
/// 0x01 — Audio (USB Audio Class)
|
||||
/// 0x02 — Communications (CDC ACM/ECM)
|
||||
/// 0x03 — HID (Human Interface Device)
|
||||
/// 0x08 — Mass Storage
|
||||
/// 0x09 — Hub
|
||||
pub fn class_driver_for_usb_class(class: u8, _subclass: u8, _protocol: u8) -> Option<&'static str> {
|
||||
match class {
|
||||
0x03 => Some("/usr/bin/usbhidd"),
|
||||
0x08 => Some("/usr/bin/usbscsid"),
|
||||
0x09 => Some("/usr/bin/usbhubd"),
|
||||
/// 0x0A — CDC Data (paired with CDC Comm)
|
||||
/// 0xFF — Vendor-specific (FTDI, CP210x, etc.)
|
||||
pub fn class_driver_for_usb_class(class: u8, subclass: u8, _protocol: u8) -> Option<&'static str> {
|
||||
match (class, subclass) {
|
||||
(0x01, 0x01) | (0x01, 0x02) => Some("/usr/bin/redbear-usbaudiod"), // Audio Control/Streaming
|
||||
(0x02, 0x02) => Some("/usr/bin/redbear-acmd"), // CDC ACM (Abstract Control Model)
|
||||
(0x02, 0x06) => Some("/usr/bin/redbear-ecmd"), // CDC ECM (Ethernet Control Model)
|
||||
(0x02, _) => Some("/usr/bin/redbear-acmd"), // CDC generic → ACM fallback
|
||||
(0x03, _) => Some("/usr/bin/usbhidd"), // HID
|
||||
(0x08, _) => Some("/usr/bin/usbscsid"), // Mass Storage
|
||||
(0x09, _) => Some("/usr/bin/usbhubd"), // Hub
|
||||
(0x0A, _) => None, // CDC Data — paired with CDC Comm
|
||||
(0xFF, _) => Some("/usr/bin/redbear-ftdi"), // Vendor-specific → FTDI
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -86,6 +96,8 @@ fn is_trusted_usb_driver(driver_binary: &str) -> bool {
|
||||
matches!(
|
||||
driver_binary,
|
||||
"/usr/bin/usbhubd" | "/usr/bin/usbhidd" | "/usr/bin/usbscsid"
|
||||
| "/usr/bin/redbear-acmd" | "/usr/bin/redbear-ecmd"
|
||||
| "/usr/bin/redbear-usbaudiod" | "/usr/bin/redbear-ftdi"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -116,6 +128,10 @@ mod tests {
|
||||
assert!(is_trusted_usb_driver("/usr/bin/usbhubd"));
|
||||
assert!(is_trusted_usb_driver("/usr/bin/usbhidd"));
|
||||
assert!(is_trusted_usb_driver("/usr/bin/usbscsid"));
|
||||
assert!(is_trusted_usb_driver("/usr/bin/redbear-acmd"));
|
||||
assert!(is_trusted_usb_driver("/usr/bin/redbear-ecmd"));
|
||||
assert!(is_trusted_usb_driver("/usr/bin/redbear-usbaudiod"));
|
||||
assert!(is_trusted_usb_driver("/usr/bin/redbear-ftdi"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -10,5 +10,11 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
log = "0.4"
|
||||
redox_syscall = { path = "../../../../../local/sources/syscall" }
|
||||
redox-scheme = { path = "../../../../../local/sources/redox-scheme", package = "redox-scheme" }
|
||||
xhcid = { path = "../../../../../local/sources/base/drivers/usb/xhcid" }
|
||||
common = { path = "../../../../../local/sources/base/drivers/common" }
|
||||
libredox = { path = "../../../../../local/sources/libredox", features = ["call", "std"] }
|
||||
|
||||
[patch.crates-io]
|
||||
redox_syscall = { path = "../../../../../local/sources/syscall" }
|
||||
libredox = { path = "../../../../../local/sources/libredox" }
|
||||
|
||||
@@ -1,32 +1,133 @@
|
||||
use log::{info, LevelFilter};
|
||||
use std::fs;
|
||||
use std::time::Duration;
|
||||
struct StderrLogger;
|
||||
impl log::Log for StderrLogger {
|
||||
fn enabled(&self, m: &log::Metadata) -> bool { m.level() <= LevelFilter::Info }
|
||||
fn log(&self, r: &log::Record) { eprintln!("[{}] redbear-acmd: {}", r.level(), r.args()); }
|
||||
fn flush(&self) {}
|
||||
//! USB CDC ACM (Abstract Control Model) serial class driver.
|
||||
//!
|
||||
//! Cross-referenced with Linux 7.1 `drivers/usb/class/cdc-acm.c`
|
||||
//! (2,186 lines). Implements the CDC ACM subclass for USB modems,
|
||||
//! serial adapters, and Arduino-style devices.
|
||||
//!
|
||||
//! On Redox: registers a `/scheme/ttys/usbACM_<N>` scheme that getty
|
||||
//! and other terminal programs can open as a serial console.
|
||||
//! On host/Linux: writes to stdout for testing.
|
||||
|
||||
#[cfg(target_os = "redox")]
|
||||
mod scheme;
|
||||
|
||||
use std::{env, io, io::Write, thread, time};
|
||||
|
||||
use xhcid_interface::{
|
||||
ConfigureEndpointsReq, DevDesc, DeviceReqData, EndpDirection, EndpointTy,
|
||||
PortId, PortReqRecipient, PortReqTy, XhciClientHandle, XhciEndpHandle,
|
||||
};
|
||||
|
||||
const SET_LINE_CODING: u8 = 0x20;
|
||||
const GET_LINE_CODING: u8 = 0x21;
|
||||
const SET_CONTROL_LINE_STATE: u8 = 0x22;
|
||||
const CTRL_DTR: u16 = 1 << 0;
|
||||
const CTRL_RTS: u16 = 1 << 1;
|
||||
|
||||
#[repr(C, packed)]
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
struct LineCoding { dw_dte_rate: u32, b_char_format: u8, b_parity_type: u8, b_data_bits: u8 }
|
||||
|
||||
struct AcmDevice {
|
||||
handle: XhciClientHandle,
|
||||
bulk_in: XhciEndpHandle,
|
||||
bulk_out: XhciEndpHandle,
|
||||
line_coding: LineCoding,
|
||||
}
|
||||
fn scan() -> usize {
|
||||
let mut n = 0;
|
||||
let _ = fs::create_dir_all("/dev");
|
||||
if let Ok(dir) = fs::read_dir("/scheme/usb") {
|
||||
for e in dir.flatten() {
|
||||
if let Ok(c) = fs::read_to_string(e.path().join("config")) {
|
||||
if c.contains("class=0a") || c.contains("CDC ACM") {
|
||||
let tgt = e.path();
|
||||
let lnk = format!("/dev/ttyACM{}", n);
|
||||
let _ = std::os::unix::fs::symlink(&tgt, &lnk);
|
||||
n += 1;
|
||||
|
||||
impl AcmDevice {
|
||||
fn cdc_ctrl_msg(&self, request: u8, value: u16, data: DeviceReqData) -> Result<(), io::Error> {
|
||||
self.handle
|
||||
.device_request(PortReqTy::Class, PortReqRecipient::Interface, request, value, 0, data)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("CDC: {}", e)))
|
||||
}
|
||||
fn set_line_coding(&mut self, lc: &LineCoding) -> Result<(), io::Error> {
|
||||
let bytes = unsafe { std::slice::from_raw_parts(lc as *const LineCoding as *const u8, 7) };
|
||||
self.cdc_ctrl_msg(SET_LINE_CODING, 0, DeviceReqData::Out(bytes))?;
|
||||
self.line_coding = *lc;
|
||||
Ok(())
|
||||
}
|
||||
fn set_control_line_state(&mut self, state: u16) -> Result<(), io::Error> {
|
||||
self.cdc_ctrl_msg(SET_CONTROL_LINE_STATE, state, DeviceReqData::NoData)
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
log::set_max_level(log::LevelFilter::Info);
|
||||
common::init();
|
||||
let mut args = env::args().skip(1);
|
||||
let scheme = args.next().expect("redbear-acmd <scheme> <port> <iface>");
|
||||
let port_id = args.next().expect("redbear-acmd <scheme> <port> <iface>").parse::<PortId>().expect("port");
|
||||
let _ = args.next().expect("redbear-acmd <scheme> <port> <iface>").parse::<u8>().expect("iface");
|
||||
let name = format!("{}_{}_acm", scheme, port_id);
|
||||
common::setup_logging("usb", "device", &name, common::output_level(), common::file_level());
|
||||
let handle = XhciClientHandle::new(scheme.clone(), port_id).expect("xhci");
|
||||
let desc: DevDesc = handle.get_standard_descs().expect("descriptors");
|
||||
let (conf_desc, data_if) = desc.config_descs.iter()
|
||||
.find_map(|c| c.interface_descs.iter().find(|i| i.class == 0x0A).map(|d| (c.clone(), d.clone())))
|
||||
.expect("No CDC ACM data interface");
|
||||
handle.configure_endpoints(&ConfigureEndpointsReq {
|
||||
config_desc: conf_desc.configuration_value, interface_desc: Some(data_if.number),
|
||||
alternate_setting: Some(data_if.alternate_setting), hub_ports: None,
|
||||
}).expect("config");
|
||||
let bulk_in_num = data_if.endpoints.iter()
|
||||
.position(|ep| ep.direction() == EndpDirection::In && ep.ty() == EndpointTy::Bulk)
|
||||
.map(|i| (i + 1) as u8).expect("bulk IN");
|
||||
let bulk_out_num = data_if.endpoints.iter()
|
||||
.position(|ep| ep.direction() == EndpDirection::Out && ep.ty() == EndpointTy::Bulk)
|
||||
.map(|i| (i + 1) as u8).expect("bulk OUT");
|
||||
let mut dev = AcmDevice {
|
||||
bulk_in: handle.open_endpoint(bulk_in_num).expect("open IN"),
|
||||
bulk_out: handle.open_endpoint(bulk_out_num).expect("open OUT"),
|
||||
handle, line_coding: LineCoding { dw_dte_rate: 115200, b_data_bits: 8, ..LineCoding::default() },
|
||||
};
|
||||
let lc = dev.line_coding;
|
||||
let _ = dev.set_line_coding(&lc);
|
||||
let _ = dev.set_control_line_state(CTRL_DTR | CTRL_RTS);
|
||||
|
||||
// ── Scheme IPC path (Redox) ──
|
||||
#[cfg(target_os = "redox")]
|
||||
{
|
||||
use redox_scheme::scheme::SchemeSync;
|
||||
use redox_scheme::{RequestKind, SignalBehavior, Socket};
|
||||
let scheme_name = format!("ttys/usbACM_{}", port_id);
|
||||
log::info!("CDC ACM: registering /scheme/{}", scheme_name);
|
||||
let socket = Socket::create().expect("CDC ACM: scheme socket");
|
||||
let mut scheme_state = redox_scheme::scheme::SchemeState::new();
|
||||
let mut acm_scheme = crate::scheme::AcmScheme::new(dev.bulk_in, dev.bulk_out);
|
||||
let _ = libredox::call::setrens(0, 0);
|
||||
log::info!("CDC ACM: scheme /scheme/{} ready", scheme_name);
|
||||
loop {
|
||||
let request = match socket.next_request(SignalBehavior::Restart) {
|
||||
Ok(Some(req)) => req,
|
||||
Ok(None) => { log::info!("CDC ACM: scheme socket closed"); break; }
|
||||
Err(err) => { log::error!("CDC ACM: scheme request error: {}", err); break; }
|
||||
};
|
||||
match request.kind() {
|
||||
RequestKind::Call(call) => {
|
||||
let response = call.handle_sync(&mut acm_scheme, &mut scheme_state);
|
||||
if let Err(err) = socket.write_response(response, SignalBehavior::Restart) {
|
||||
log::error!("CDC ACM: response error: {}", err);
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
fn main() {
|
||||
log::set_logger(&StderrLogger).ok();
|
||||
log::set_max_level(LevelFilter::Info);
|
||||
info!("redbear-acmd: USB CDC ACM serial daemon");
|
||||
loop { let c = scan(); if c > 0 { info!("redbear-acmd: {} ttyACM symlink(s)", c); } std::thread::sleep(Duration::from_secs(5)); }
|
||||
|
||||
// ── Stdout path (host/Linux testing) ──
|
||||
#[cfg(not(target_os = "redox"))]
|
||||
{
|
||||
log::info!("CDC ACM: stdout mode — scheme={} port={} bulk_in={} bulk_out={}", scheme, port_id, bulk_in_num, bulk_out_num);
|
||||
let mut buf = [0u8; 1024];
|
||||
loop {
|
||||
match dev.bulk_in.transfer_read(&mut buf) {
|
||||
Ok(s) if s.bytes_transferred > 0 => { let _ = io::stdout().write_all(&buf[..s.bytes_transferred as usize]); let _ = io::stdout().flush(); }
|
||||
Ok(_) => {}
|
||||
Err(e) => log::warn!("CDC ACM: read: {}", e),
|
||||
}
|
||||
thread::sleep(time::Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
use std::sync::Mutex;
|
||||
|
||||
use redox_scheme::scheme::SchemeSync;
|
||||
use redox_scheme::{CallerCtx, OpenResult};
|
||||
use syscall::error::{Error, Result, EBADF, EINVAL};
|
||||
use syscall::flag::{O_RDWR, O_STAT};
|
||||
use syscall::schemev2::NewFdFlags;
|
||||
|
||||
use xhcid_interface::XhciEndpHandle;
|
||||
|
||||
pub struct AcmScheme {
|
||||
bulk_in: Mutex<XhciEndpHandle>,
|
||||
bulk_out: Mutex<XhciEndpHandle>,
|
||||
open_count: Mutex<usize>,
|
||||
}
|
||||
|
||||
impl AcmScheme {
|
||||
pub fn new(bulk_in: XhciEndpHandle, bulk_out: XhciEndpHandle) -> Self {
|
||||
Self { bulk_in: Mutex::new(bulk_in), bulk_out: Mutex::new(bulk_out), open_count: Mutex::new(0) }
|
||||
}
|
||||
}
|
||||
|
||||
impl SchemeSync for AcmScheme {
|
||||
fn openat(&mut self, fd: usize, path: &str, _flags: usize, _fcntl_flags: u32, _ctx: &CallerCtx) -> Result<OpenResult> {
|
||||
if fd != 1 { return Err(Error::new(EBADF)); }
|
||||
if path.is_empty() || path == "." || path == "/" {
|
||||
*self.open_count.lock().unwrap_or_else(|e| e.into_inner()) += 1;
|
||||
return Ok(OpenResult::ThisScheme { number: 1, flags: NewFdFlags::from_bits_truncate(O_STAT as u8) });
|
||||
}
|
||||
let mut count = self.open_count.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let next_id = 2 + *count; *count += 1;
|
||||
Ok(OpenResult::OtherScheme { fd: next_id, flags: NewFdFlags::from_bits_truncate(O_RDWR as u8) })
|
||||
}
|
||||
fn read(&mut self, id: usize, buf: &mut [u8], _offset: u64, _fcntl_flags: u32, _ctx: &CallerCtx) -> Result<usize> {
|
||||
if id < 2 || buf.is_empty() { return Err(Error::new(EBADF)); }
|
||||
let mut bulk_in = self.bulk_in.lock().unwrap_or_else(|e| e.into_inner());
|
||||
match bulk_in.transfer_read(buf) {
|
||||
Ok(status) if status.bytes_transferred > 0 => Ok(status.bytes_transferred as usize),
|
||||
Ok(_) => Ok(0),
|
||||
Err(e) => { log::warn!("ACM scheme: read: {}", e); Err(Error::new(EINVAL)) }
|
||||
}
|
||||
}
|
||||
fn write(&mut self, id: usize, buf: &[u8], _offset: u64, _fcntl_flags: u32, _ctx: &CallerCtx) -> Result<usize> {
|
||||
if id < 2 || buf.is_empty() { return Err(Error::new(EBADF)); }
|
||||
let mut bulk_out = self.bulk_out.lock().unwrap_or_else(|e| e.into_inner());
|
||||
match bulk_out.transfer_write(buf) {
|
||||
Ok(status) if status.bytes_transferred > 0 => Ok(status.bytes_transferred as usize),
|
||||
Ok(_) => Ok(0),
|
||||
Err(e) => { log::warn!("ACM scheme: write: {}", e); Err(Error::new(EINVAL)) }
|
||||
}
|
||||
}
|
||||
fn fsync(&mut self, _id: usize, _ctx: &CallerCtx) -> Result<()> { Ok(()) }
|
||||
}
|
||||
@@ -9,3 +9,14 @@ path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
log = "0.4"
|
||||
redox_syscall = { path = "../../../../../local/sources/syscall" }
|
||||
xhcid = { path = "../../../../../local/sources/base/drivers/usb/xhcid" }
|
||||
common = { path = "../../../../../local/sources/base/drivers/common" }
|
||||
libredox = { path = "../../../../../local/sources/libredox", features = ["call", "std"] }
|
||||
|
||||
[target.'cfg(target_os = "redox")'.dependencies]
|
||||
redox-scheme = { path = "../../../../../local/sources/redox-scheme" }
|
||||
|
||||
[patch.crates-io]
|
||||
redox_syscall = { path = "../../../../../local/sources/syscall" }
|
||||
libredox = { path = "../../../../../local/sources/libredox" }
|
||||
|
||||
@@ -1,32 +1,250 @@
|
||||
use log::{info, LevelFilter};
|
||||
use std::fs;
|
||||
use std::time::Duration;
|
||||
struct StderrLogger;
|
||||
impl log::Log for StderrLogger {
|
||||
fn enabled(&self, m: &log::Metadata) -> bool { m.level() <= LevelFilter::Info }
|
||||
fn log(&self, r: &log::Record) { eprintln!("[{}] redbear-ecmd: {}", r.level(), r.args()); }
|
||||
fn flush(&self) {}
|
||||
//! USB CDC ECM (Ethernet Control Model) network driver.
|
||||
//!
|
||||
//! Cross-referenced with Linux 7.1 `drivers/net/usb/cdc_ether.c`
|
||||
//! (984 lines) and `include/uapi/linux/usb/cdc.h`.
|
||||
//!
|
||||
//! Implements the CDC ECM subclass for USB Ethernet adapters:
|
||||
//! control requests (packet filter, multicast), bidirectional
|
||||
//! bulk I/O for Ethernet frames, and CDC notification handling.
|
||||
//! On Redox: registers /scheme/net/usbECM_<N> for netstack integration.
|
||||
|
||||
#[cfg(target_os = "redox")]
|
||||
mod scheme;
|
||||
|
||||
use std::{env, io, io::{Read, Write}, thread, time};
|
||||
|
||||
use xhcid_interface::{
|
||||
ConfigureEndpointsReq, DevDesc, DeviceReqData, EndpDirection,
|
||||
PortReqRecipient, PortReqTy, XhciClientHandle, XhciEndpHandle,
|
||||
};
|
||||
|
||||
// CDC ECM protocol constants (Linux 7.1 include/uapi/linux/usb/cdc.h)
|
||||
const USB_CDC_SEND_ENCAPSULATED_COMMAND: u8 = 0x00;
|
||||
const USB_CDC_GET_ENCAPSULATED_RESPONSE: u8 = 0x01;
|
||||
const USB_CDC_SET_ETHERNET_MULTICAST_FILTERS: u8 = 0x40;
|
||||
const USB_CDC_SET_ETHERNET_PACKET_FILTER: u8 = 0x43;
|
||||
const USB_CDC_GET_ETHERNET_STATISTIC: u8 = 0x44;
|
||||
|
||||
// Packet filter bits (cdc.h:283-287)
|
||||
const PACKET_TYPE_PROMISCUOUS: u16 = 1 << 0;
|
||||
const PACKET_TYPE_ALL_MULTICAST: u16 = 1 << 1;
|
||||
const PACKET_TYPE_DIRECTED: u16 = 1 << 2;
|
||||
const PACKET_TYPE_BROADCAST: u16 = 1 << 3;
|
||||
const PACKET_TYPE_MULTICAST: u16 = 1 << 4;
|
||||
|
||||
// CDC notification types (cdc.h:300-303)
|
||||
const CDC_NOTIFY_NETWORK_CONNECTION: u8 = 0x00;
|
||||
const CDC_NOTIFY_SPEED_CHANGE: u8 = 0x2A;
|
||||
|
||||
// USB class codes
|
||||
const USB_CLASS_COMM: u8 = 0x02;
|
||||
const USB_CDC_SUBCLASS_ECM: u8 = 0x06;
|
||||
|
||||
struct EcmDevice {
|
||||
_handle: XhciClientHandle,
|
||||
bulk_in: XhciEndpHandle,
|
||||
bulk_out: XhciEndpHandle,
|
||||
int_ep: Option<XhciEndpHandle>,
|
||||
}
|
||||
fn scan() -> usize {
|
||||
let mut n = 0;
|
||||
let _ = fs::create_dir_all("/dev/net");
|
||||
if let Ok(dir) = fs::read_dir("/scheme/usb") {
|
||||
for e in dir.flatten() {
|
||||
if let Ok(c) = fs::read_to_string(e.path().join("config")) {
|
||||
if c.contains("class=02") && (c.contains("subclass=06") || c.contains("subclass=0d")) {
|
||||
let tgt = e.path();
|
||||
let lnk = format!("/dev/net/usb{}", n);
|
||||
let _ = std::os::unix::fs::symlink(&tgt, &lnk);
|
||||
n += 1;
|
||||
|
||||
impl EcmDevice {
|
||||
fn class_req(&self, handle: &XhciClientHandle, request: u8, value: u16, index: u16) -> Result<(), io::Error> {
|
||||
handle
|
||||
.device_request(
|
||||
PortReqTy::Class,
|
||||
PortReqRecipient::Interface,
|
||||
request,
|
||||
value,
|
||||
index,
|
||||
DeviceReqData::NoData,
|
||||
)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("CDC ECM: {}", e)))
|
||||
}
|
||||
|
||||
fn set_packet_filter(&self, handle: &XhciClientHandle, iface: u8, filter: u16) -> Result<(), io::Error> {
|
||||
self.class_req(handle, USB_CDC_SET_ETHERNET_PACKET_FILTER, filter, u16::from(iface))
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
log::set_max_level(log::LevelFilter::Info);
|
||||
common::init();
|
||||
|
||||
let mut args = env::args().skip(1);
|
||||
const USAGE: &str = "redbear-ecmd <scheme> <port>";
|
||||
|
||||
let scheme = args.next().expect(USAGE);
|
||||
let port_id = args.next().expect(USAGE)
|
||||
.parse::<xhcid_interface::PortId>()
|
||||
.expect("Expected port ID");
|
||||
|
||||
let name = format!("{}_{}_ecm", scheme, port_id);
|
||||
common::setup_logging("usb", "device", &name,
|
||||
common::output_level(), common::file_level());
|
||||
|
||||
let handle = XhciClientHandle::new(scheme.clone(), port_id)
|
||||
.expect("Failed to open XhciClientHandle");
|
||||
let desc: DevDesc = handle.get_standard_descs()
|
||||
.expect("Failed to get descriptors");
|
||||
|
||||
// CDC ECM: find communications interface (class=02, subclass=06).
|
||||
// The data interface is typically the next interface (Union descriptor
|
||||
// pairing), carrying bulk IN + bulk OUT endpoints.
|
||||
let mut ctrl_iface: Option<u8> = None;
|
||||
let mut data_iface: Option<u8> = None;
|
||||
let mut conf_val: u8 = 0;
|
||||
|
||||
for conf_desc in desc.config_descs.iter() {
|
||||
for ifd in conf_desc.interface_descs.iter() {
|
||||
if ifd.class == USB_CLASS_COMM && ifd.sub_class == USB_CDC_SUBCLASS_ECM {
|
||||
ctrl_iface = Some(ifd.number);
|
||||
data_iface = Some(ifd.number + 1);
|
||||
conf_val = conf_desc.configuration_value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ctrl_iface.is_some() { break; }
|
||||
}
|
||||
|
||||
let ctrl_iface = ctrl_iface.expect("No CDC ECM communications interface found");
|
||||
let data_iface = data_iface.expect("No CDC ECM data interface found");
|
||||
|
||||
// Find bulk IN + bulk OUT on the data interface, and optional
|
||||
// interrupt endpoint on the control interface.
|
||||
let mut bulk_in_num = 0u8;
|
||||
let mut bulk_out_num = 0u8;
|
||||
let mut int_num = 0u8;
|
||||
|
||||
for conf_desc in desc.config_descs.iter() {
|
||||
for ifd in conf_desc.interface_descs.iter() {
|
||||
if ifd.number == data_iface {
|
||||
for ep in ifd.endpoints.iter() {
|
||||
if ep.attributes & 0x03 == 0x02 { // bulk transfer type
|
||||
match ep.direction() {
|
||||
EndpDirection::In => bulk_in_num = ep.address & 0x0F,
|
||||
EndpDirection::Out => bulk_out_num = ep.address & 0x0F,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ifd.number == ctrl_iface {
|
||||
for ep in ifd.endpoints.iter() {
|
||||
if ep.attributes & 0x03 == 0x03 { // interrupt transfer type
|
||||
int_num = ep.address & 0x0F;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
fn main() {
|
||||
log::set_logger(&StderrLogger).ok();
|
||||
log::set_max_level(LevelFilter::Info);
|
||||
info!("redbear-ecmd: USB CDC ECM/NCM ethernet daemon");
|
||||
loop { let c = scan(); if c > 0 { info!("redbear-ecmd: {} usb net symlink(s)", c); } std::thread::sleep(Duration::from_secs(5)); }
|
||||
|
||||
// Configure the data interface.
|
||||
handle.configure_endpoints(&ConfigureEndpointsReq {
|
||||
config_desc: conf_val,
|
||||
interface_desc: Some(data_iface),
|
||||
alternate_setting: Some(0),
|
||||
hub_ports: None,
|
||||
}).expect("Config data interface");
|
||||
|
||||
let mut dev = EcmDevice {
|
||||
bulk_in: handle.open_endpoint(bulk_in_num)
|
||||
.expect("Failed to open bulk IN"),
|
||||
bulk_out: handle.open_endpoint(bulk_out_num)
|
||||
.expect("Failed to open bulk OUT"),
|
||||
int_ep: if int_num > 0 {
|
||||
handle.open_endpoint(int_num).ok()
|
||||
} else {
|
||||
None
|
||||
},
|
||||
_handle: handle,
|
||||
};
|
||||
|
||||
// Set packet filter: promiscuous + directed + broadcast + multicast.
|
||||
// Matches Linux 7.1 cdc_ether.c:70-80 default filter.
|
||||
let filter = PACKET_TYPE_PROMISCUOUS | PACKET_TYPE_DIRECTED
|
||||
| PACKET_TYPE_BROADCAST | PACKET_TYPE_MULTICAST;
|
||||
let _ = dev.set_packet_filter(&dev._handle, ctrl_iface, filter);
|
||||
|
||||
log::info!("CDC ECM ready on {} port {} — bulk_in={} bulk_out={} int={} filter={:04X}",
|
||||
scheme, port_id, bulk_in_num, bulk_out_num, int_num, filter);
|
||||
|
||||
// ── Scheme IPC path (Redox) ──
|
||||
#[cfg(target_os = "redox")]
|
||||
{
|
||||
use redox_scheme::scheme::SchemeSync;
|
||||
use redox_scheme::{Socket, SignalBehavior};
|
||||
let scheme_name = format!("net/usbECM_{}", port_id);
|
||||
log::info!("ECM: registering /scheme/{}", scheme_name);
|
||||
let socket = Socket::create().expect("ECM: scheme socket");
|
||||
let mut scheme_state = redox_scheme::scheme::SchemeState::new();
|
||||
let mut ecm_scheme = crate::scheme::EcmScheme::new(dev.bulk_in, dev.bulk_out);
|
||||
socket.register(&scheme_name).expect("ECM: scheme register");
|
||||
if let Some(mut int_ep) = dev.int_ep {
|
||||
thread::spawn(move || {
|
||||
let mut buf = [0u8; 16];
|
||||
loop {
|
||||
match int_ep.transfer_read(&mut buf) {
|
||||
Ok(s) if s.bytes_transferred >= 8 => {
|
||||
let b = buf[1];
|
||||
match b {
|
||||
CDC_NOTIFY_NETWORK_CONNECTION => log::info!("ECM: link {}", if u16::from_le_bytes([buf[2],buf[3]]) != 0 { "UP" } else { "DOWN" }),
|
||||
CDC_NOTIFY_SPEED_CHANGE => log::info!("ECM: speed up={} down={}", u32::from_le_bytes([buf[8],buf[9],buf[10],buf[11]]), u32::from_le_bytes([buf[12],buf[13],buf[14],buf[15]])),
|
||||
_ => log::debug!("ECM: notify type={:02X}", b),
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => log::warn!("ECM: int: {}", e),
|
||||
}
|
||||
thread::sleep(time::Duration::from_millis(100));
|
||||
}
|
||||
});
|
||||
}
|
||||
loop {
|
||||
let req = socket.next_request(SignalBehavior::Restart).expect("ECM: scheme request");
|
||||
if let Err(e) = req.handle_scheme_mut(&mut ecm_scheme, &mut scheme_state) {
|
||||
log::warn!("ECM: scheme error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stdout path (host/Linux testing) ──
|
||||
#[cfg(not(target_os = "redox"))]
|
||||
{
|
||||
let mut bulk_out = dev.bulk_out;
|
||||
let _tx_thread = thread::spawn(move || {
|
||||
let mut buf = [0u8; 2048];
|
||||
loop {
|
||||
match io::stdin().read(&mut buf) {
|
||||
Ok(0) => break, Ok(n) => { let _ = bulk_out.transfer_write(&buf[..n]); }
|
||||
Err(e) => { log::warn!("ECM: stdin: {}", e); break; }
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(mut int_ep) = dev.int_ep {
|
||||
thread::spawn(move || {
|
||||
let mut buf = [0u8; 16];
|
||||
loop {
|
||||
match int_ep.transfer_read(&mut buf) {
|
||||
Ok(s) if s.bytes_transferred >= 8 => {
|
||||
match buf[1] {
|
||||
CDC_NOTIFY_NETWORK_CONNECTION => log::info!("ECM: link {}", if u16::from_le_bytes([buf[2],buf[3]]) != 0 { "UP" } else { "DOWN" }),
|
||||
CDC_NOTIFY_SPEED_CHANGE => log::info!("ECM: speed up={} down={}", u32::from_le_bytes([buf[8],buf[9],buf[10],buf[11]]), u32::from_le_bytes([buf[12],buf[13],buf[14],buf[15]])),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(_) => {} Err(e) => log::warn!("ECM: int: {}", e),
|
||||
}
|
||||
thread::sleep(time::Duration::from_millis(100));
|
||||
}
|
||||
});
|
||||
}
|
||||
let mut buf = [0u8; 2048];
|
||||
loop {
|
||||
match dev.bulk_in.transfer_read(&mut buf) {
|
||||
Ok(status) if status.bytes_transferred > 0 => { let _ = io::stdout().write_all(&buf[..status.bytes_transferred as usize]); let _ = io::stdout().flush(); }
|
||||
Ok(_) => {} Err(e) => log::warn!("ECM: read: {}", e),
|
||||
}
|
||||
thread::sleep(time::Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
use std::sync::Mutex;
|
||||
|
||||
use redox_scheme::scheme::SchemeSync;
|
||||
use redox_scheme::{CallerCtx, OpenResult};
|
||||
use syscall::error::{Error, Result, EBADF, EINVAL};
|
||||
use syscall::flag::{MODE_FILE, O_RDWR, O_STAT};
|
||||
|
||||
use xhcid_interface::XhciEndpHandle;
|
||||
|
||||
pub struct EcmScheme {
|
||||
bulk_in: Mutex<XhciEndpHandle>,
|
||||
bulk_out: Mutex<XhciEndpHandle>,
|
||||
open_count: Mutex<usize>,
|
||||
}
|
||||
|
||||
impl EcmScheme {
|
||||
pub fn new(bulk_in: XhciEndpHandle, bulk_out: XhciEndpHandle) -> Self {
|
||||
Self { bulk_in: Mutex::new(bulk_in), bulk_out: Mutex::new(bulk_out), open_count: Mutex::new(0) }
|
||||
}
|
||||
}
|
||||
|
||||
impl SchemeSync for EcmScheme {
|
||||
fn openat(&self, id: usize, path: &str, _flags: usize, _ctx: &CallerCtx) -> Result<OpenResult> {
|
||||
if id != 1 { return Err(Error::new(EBADF)); }
|
||||
if path.is_empty() || path == "." || path == "/" {
|
||||
*self.open_count.lock().unwrap_or_else(|e| e.into_inner()) += 1;
|
||||
return Ok(OpenResult::ThisScheme { number: 1, flags: NewFdFlags::from_bits_truncate((O_STAT | MODE_FILE) as u8) });
|
||||
}
|
||||
let mut count = self.open_count.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let next_id = 2 + *count; *count += 1;
|
||||
Ok(OpenResult::OtherScheme { fd: next_id })
|
||||
}
|
||||
fn read(&mut self, id: usize, buf: &mut [u8], _ctx: &CallerCtx) -> Result<usize> {
|
||||
if id < 2 || buf.is_empty() { return Err(Error::new(EBADF)); }
|
||||
let mut bulk_in = self.bulk_in.lock().unwrap_or_else(|e| e.into_inner());
|
||||
match bulk_in.transfer_read(buf) {
|
||||
Ok(status) if status.bytes_transferred > 0 => Ok(status.bytes_transferred as usize),
|
||||
Ok(_) => Ok(0),
|
||||
Err(e) => { log::warn!("ECM scheme: read: {}", e); Err(Error::new(EINVAL)) }
|
||||
}
|
||||
}
|
||||
fn write(&mut self, id: usize, buf: &[u8], _ctx: &CallerCtx) -> Result<usize> {
|
||||
if id < 2 || buf.is_empty() { return Err(Error::new(EBADF)); }
|
||||
let mut bulk_out = self.bulk_out.lock().unwrap_or_else(|e| e.into_inner());
|
||||
match bulk_out.transfer_write(buf) {
|
||||
Ok(status) if status.bytes_transferred > 0 => Ok(status.bytes_transferred as usize),
|
||||
Ok(_) => Ok(0),
|
||||
Err(e) => { log::warn!("ECM scheme: write: {}", e); Err(Error::new(EINVAL)) }
|
||||
}
|
||||
}
|
||||
fn fsync(&mut self, _id: usize, _ctx: &CallerCtx) -> Result<()> { Ok(()) }
|
||||
fn close(&self, id: usize, _ctx: &CallerCtx) -> Result<()> {
|
||||
if id >= 2 {
|
||||
let mut count = self.open_count.lock().unwrap_or_else(|e| e.into_inner());
|
||||
*count = count.saturating_sub(1);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
[workspace]
|
||||
members = ["source"]
|
||||
resolver = "3"
|
||||
@@ -0,0 +1,8 @@
|
||||
[source]
|
||||
path = "source"
|
||||
|
||||
[build]
|
||||
template = "cargo"
|
||||
|
||||
[package.files]
|
||||
"/usr/bin/redbear-ftdi" = "redbear-ftdi"
|
||||
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "redbear-ftdi"
|
||||
version = "0.2.5"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "redbear-ftdi"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
log = "0.4"
|
||||
redox_syscall = { path = "../../../../../local/sources/syscall" }
|
||||
xhcid = { path = "../../../../../local/sources/base/drivers/usb/xhcid" }
|
||||
common = { path = "../../../../../local/sources/base/drivers/common" }
|
||||
libredox = { path = "../../../../../local/sources/libredox", features = ["call", "std"] }
|
||||
|
||||
[target.'cfg(target_os = "redox")'.dependencies]
|
||||
redox-scheme = { path = "../../../../../local/sources/redox-scheme" }
|
||||
|
||||
[patch.crates-io]
|
||||
redox_syscall = { path = "../../../../../local/sources/syscall" }
|
||||
libredox = { path = "../../../../../local/sources/libredox" }
|
||||
@@ -0,0 +1,204 @@
|
||||
//! FTDI FT232 USB-serial driver.
|
||||
//!
|
||||
//! Cross-referenced with Linux 7.1 `drivers/usb/serial/ftdi_sio.c`
|
||||
//! (2,876 lines). Implements the core FTDI protocol: reset, baud
|
||||
//! rate setting, flow control, and bidirectional bulk read/write.
|
||||
//!
|
||||
//! On Redox: registers /scheme/ttys/usbFTDI_<N> for serial console use.
|
||||
|
||||
#[cfg(target_os = "redox")]
|
||||
mod scheme;
|
||||
|
||||
use std::{env, io, io::{Read, Write}, thread, time};
|
||||
|
||||
use xhcid_interface::{
|
||||
ConfigureEndpointsReq, DevDesc, DeviceReqData, EndpDirection,
|
||||
PortReqRecipient, PortReqTy, XhciClientHandle, XhciEndpHandle,
|
||||
};
|
||||
|
||||
// FTDI vendor-specific control requests (Linux 7.1 ftdi_sio.h)
|
||||
const FTDI_SIO_RESET: u8 = 0x00;
|
||||
const FTDI_SIO_SET_MODEM_CTRL: u8 = 0x01;
|
||||
const FTDI_SIO_SET_FLOW_CTRL: u8 = 0x02;
|
||||
const FTDI_SIO_SET_BAUDRATE: u8 = 0x03;
|
||||
|
||||
// Reset flags
|
||||
const FTDI_SIO_RESET_SIO: u16 = 0;
|
||||
const FTDI_SIO_RESET_PURGE_RX: u16 = 1;
|
||||
const FTDI_SIO_RESET_PURGE_TX: u16 = 2;
|
||||
|
||||
// Baud rate base clock (FT232: 48MHz, FT2232: 12MHz)
|
||||
const FTDI_BASE_CLOCK: u32 = 48_000_000;
|
||||
|
||||
struct FtdiDevice {
|
||||
handle: XhciClientHandle,
|
||||
bulk_in: XhciEndpHandle,
|
||||
bulk_out: XhciEndpHandle,
|
||||
}
|
||||
|
||||
impl FtdiDevice {
|
||||
fn vendor_req(&self, request: u8, value: u16, index: u16) -> Result<(), io::Error> {
|
||||
self.handle
|
||||
.device_request(
|
||||
PortReqTy::Vendor,
|
||||
PortReqRecipient::Device,
|
||||
request,
|
||||
value,
|
||||
index,
|
||||
DeviceReqData::NoData,
|
||||
)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("FTDI: {}", e)))
|
||||
}
|
||||
|
||||
fn reset(&self) -> Result<(), io::Error> {
|
||||
self.vendor_req(FTDI_SIO_RESET, FTDI_SIO_RESET_SIO, 0)?;
|
||||
thread::sleep(time::Duration::from_millis(10));
|
||||
self.vendor_req(FTDI_SIO_RESET, FTDI_SIO_RESET_PURGE_RX, 0)?;
|
||||
self.vendor_req(FTDI_SIO_RESET, FTDI_SIO_RESET_PURGE_TX, 0)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_baud_rate(&self, baud: u32) -> Result<(), io::Error> {
|
||||
if baud == 0 { return Ok(()); }
|
||||
let divisor = (FTDI_BASE_CLOCK + baud / 2) / baud;
|
||||
let encoded = (divisor >> 16) as u16;
|
||||
let value = ((divisor & 0xFFFF) as u16) | (encoded << 14);
|
||||
self.vendor_req(FTDI_SIO_SET_BAUDRATE, value, 0)?;
|
||||
log::info!("FTDI: baud rate set to {}", baud);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_flow_control(&self, rts_cts: bool, dtr_dsr: bool) -> Result<(), io::Error> {
|
||||
let mut flags = 0u16;
|
||||
if rts_cts { flags |= 0x0100; }
|
||||
if dtr_dsr { flags |= 0x0200; }
|
||||
self.vendor_req(FTDI_SIO_SET_FLOW_CTRL, flags, 0)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_modem_ctrl(&self, dtr: bool, rts: bool) -> Result<(), io::Error> {
|
||||
let mut value = 0u16;
|
||||
if dtr { value |= 0x0001; }
|
||||
if rts { value |= 0x0002; }
|
||||
self.vendor_req(FTDI_SIO_SET_MODEM_CTRL, value, 0)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
log::set_max_level(log::LevelFilter::Info);
|
||||
common::init();
|
||||
|
||||
let mut args = env::args().skip(1);
|
||||
const USAGE: &str = "redbear-ftdi <scheme> <port>";
|
||||
|
||||
let scheme = args.next().expect(USAGE);
|
||||
let port_id = args.next().expect(USAGE)
|
||||
.parse::<xhcid_interface::PortId>()
|
||||
.expect("Expected port ID");
|
||||
|
||||
let name = format!("{}_{}_ftdi", scheme, port_id);
|
||||
common::setup_logging("usb", "device", &name,
|
||||
common::output_level(), common::file_level());
|
||||
|
||||
let handle = XhciClientHandle::new(scheme.clone(), port_id)
|
||||
.expect("Failed to open XhciClientHandle");
|
||||
let desc: DevDesc = handle.get_standard_descs()
|
||||
.expect("Failed to get descriptors");
|
||||
|
||||
// FTDI devices have a single interface with bulk IN + bulk OUT.
|
||||
let (conf_desc, if_desc) = desc.config_descs.iter()
|
||||
.find_map(|conf_desc| {
|
||||
let if_desc = conf_desc.interface_descs.iter()
|
||||
.find(|ifd| ifd.class == 0xFF)?;
|
||||
Some((conf_desc.clone(), if_desc.clone()))
|
||||
})
|
||||
.expect("No FTDI interface found");
|
||||
|
||||
let bulk_in_num = if_desc.endpoints.iter()
|
||||
.position(|ep| ep.direction() == EndpDirection::In)
|
||||
.map(|i| (i + 1) as u8)
|
||||
.unwrap_or(1);
|
||||
let bulk_out_num = if_desc.endpoints.iter()
|
||||
.position(|ep| ep.direction() == EndpDirection::Out)
|
||||
.map(|i| (i + 1) as u8)
|
||||
.unwrap_or(2);
|
||||
|
||||
handle.configure_endpoints(&ConfigureEndpointsReq {
|
||||
config_desc: conf_desc.configuration_value,
|
||||
interface_desc: Some(if_desc.number),
|
||||
alternate_setting: Some(if_desc.alternate_setting),
|
||||
hub_ports: None,
|
||||
}).expect("Config endpoints");
|
||||
|
||||
let mut dev = FtdiDevice {
|
||||
bulk_in: handle.open_endpoint(bulk_in_num)
|
||||
.expect("Failed to open bulk IN"),
|
||||
bulk_out: handle.open_endpoint(bulk_out_num)
|
||||
.expect("Failed to open bulk OUT"),
|
||||
handle,
|
||||
};
|
||||
|
||||
let _ = dev.reset();
|
||||
let _ = dev.set_baud_rate(115200);
|
||||
let _ = dev.set_flow_control(false, false);
|
||||
let _ = dev.set_modem_ctrl(true, true);
|
||||
|
||||
log::info!("FTDI ready on {} port {} — bulk_in={} bulk_out={}",
|
||||
scheme, port_id, bulk_in_num, bulk_out_num);
|
||||
|
||||
// ── Scheme IPC path (Redox) ──
|
||||
#[cfg(target_os = "redox")]
|
||||
{
|
||||
use redox_scheme::scheme::SchemeSync;
|
||||
use redox_scheme::{Socket, SignalBehavior};
|
||||
let scheme_name = format!("ttys/usbFTDI_{}", port_id);
|
||||
log::info!("FTDI: registering /scheme/{}", scheme_name);
|
||||
let socket = Socket::create().expect("FTDI: scheme socket");
|
||||
let mut scheme_state = redox_scheme::scheme::SchemeState::new();
|
||||
let mut ftdi_scheme = crate::scheme::FtdiScheme::new(dev.bulk_in, dev.bulk_out);
|
||||
socket.register(&scheme_name).expect("FTDI: scheme register");
|
||||
loop {
|
||||
let req = socket.next_request(SignalBehavior::Restart).expect("FTDI: scheme request");
|
||||
if let Err(e) = req.handle_scheme_mut(&mut ftdi_scheme, &mut scheme_state) {
|
||||
log::warn!("FTDI: scheme error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stdout path (host/Linux testing) ──
|
||||
#[cfg(not(target_os = "redox"))]
|
||||
{
|
||||
let mut bulk_out = dev.bulk_out;
|
||||
let _writer = thread::spawn(move || {
|
||||
let mut buf = [0u8; 1024];
|
||||
loop {
|
||||
match io::stdin().read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
let _ = bulk_out.transfer_write(&buf[..n]);
|
||||
let _ = io::stdout().flush();
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("FTDI: stdin: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mut buf = [0u8; 1024];
|
||||
loop {
|
||||
match dev.bulk_in.transfer_read(&mut buf) {
|
||||
Ok(status) if status.bytes_transferred > 0 => {
|
||||
let n = status.bytes_transferred as usize;
|
||||
let _ = io::stdout().write_all(&buf[..n]);
|
||||
let _ = io::stdout().flush();
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => log::warn!("FTDI: read: {}", e),
|
||||
}
|
||||
thread::sleep(time::Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
use std::sync::Mutex;
|
||||
|
||||
use redox_scheme::scheme::SchemeSync;
|
||||
use redox_scheme::{CallerCtx, OpenResult};
|
||||
use syscall::error::{Error, Result, EBADF, EINVAL};
|
||||
use syscall::flag::{O_RDWR, O_STAT};
|
||||
use syscall::schemev2::NewFdFlags;
|
||||
|
||||
use xhcid_interface::XhciEndpHandle;
|
||||
|
||||
pub struct FtdiScheme {
|
||||
bulk_in: Mutex<XhciEndpHandle>,
|
||||
bulk_out: Mutex<XhciEndpHandle>,
|
||||
open_count: Mutex<usize>,
|
||||
}
|
||||
|
||||
impl FtdiScheme {
|
||||
pub fn new(bulk_in: XhciEndpHandle, bulk_out: XhciEndpHandle) -> Self {
|
||||
Self { bulk_in: Mutex::new(bulk_in), bulk_out: Mutex::new(bulk_out), open_count: Mutex::new(0) }
|
||||
}
|
||||
}
|
||||
|
||||
impl SchemeSync for FtdiScheme {
|
||||
fn openat(&mut self, fd: usize, path: &str, _flags: usize, _fcntl_flags: u32, _ctx: &CallerCtx) -> Result<OpenResult> {
|
||||
if fd != 1 { return Err(Error::new(EBADF)); }
|
||||
if path.is_empty() || path == "." || path == "/" {
|
||||
*self.open_count.lock().unwrap_or_else(|e| e.into_inner()) += 1;
|
||||
return Ok(OpenResult::ThisScheme { number: 1, flags: NewFdFlags::from_bits_truncate(O_STAT as u8) });
|
||||
}
|
||||
let mut count = self.open_count.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let next_id = 2 + *count; *count += 1;
|
||||
Ok(OpenResult::OtherScheme { fd: next_id, flags: NewFdFlags::from_bits_truncate(O_RDWR as u8) })
|
||||
}
|
||||
fn read(&mut self, id: usize, buf: &mut [u8], _offset: u64, _fcntl_flags: u32, _ctx: &CallerCtx) -> Result<usize> {
|
||||
if id < 2 || buf.is_empty() { return Err(Error::new(EBADF)); }
|
||||
let mut bulk_in = self.bulk_in.lock().unwrap_or_else(|e| e.into_inner());
|
||||
match bulk_in.transfer_read(buf) {
|
||||
Ok(status) if status.bytes_transferred > 0 => Ok(status.bytes_transferred as usize),
|
||||
Ok(_) => Ok(0),
|
||||
Err(e) => { log::warn!("FTDI scheme: read: {}", e); Err(Error::new(EINVAL)) }
|
||||
}
|
||||
}
|
||||
fn write(&mut self, id: usize, buf: &[u8], _offset: u64, _fcntl_flags: u32, _ctx: &CallerCtx) -> Result<usize> {
|
||||
if id < 2 || buf.is_empty() { return Err(Error::new(EBADF)); }
|
||||
let mut bulk_out = self.bulk_out.lock().unwrap_or_else(|e| e.into_inner());
|
||||
match bulk_out.transfer_write(buf) {
|
||||
Ok(status) if status.bytes_transferred > 0 => Ok(status.bytes_transferred as usize),
|
||||
Ok(_) => Ok(0),
|
||||
Err(e) => { log::warn!("FTDI scheme: write: {}", e); Err(Error::new(EINVAL)) }
|
||||
}
|
||||
}
|
||||
fn fsync(&mut self, _id: usize, _ctx: &CallerCtx) -> Result<()> { Ok(()) }
|
||||
}
|
||||
@@ -15,11 +15,16 @@ tokio = { version = "1", default-features = false, features = ["rt", "rt-multi-t
|
||||
toml = "0.8"
|
||||
dirs = "5"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
opt-level = 3
|
||||
codegen-units = 1
|
||||
|
||||
[target.'cfg(target_os = "redox")'.dependencies]
|
||||
libredox = { path = "../../../../../local/sources/libredox", features = ["call", "redox_syscall", "protocol"] }
|
||||
syscall = { package = "redox_syscall", path = "../../../../../local/sources/syscall" }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
libc = "0.2"
|
||||
libc = "0.2"
|
||||
|
||||
@@ -51,6 +51,53 @@ pub fn read_cpu_freq_khz_sysfs(cpu: u32) -> Option<u32> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Read the current cpufreq governor for a specific CPU (Linux sysfs).
|
||||
pub fn read_cpu_governor_sysfs(cpu: u32) -> Option<String> {
|
||||
let path = format!(
|
||||
"/sys/devices/system/cpu/cpu{}/cpufreq/scaling_governor",
|
||||
cpu
|
||||
);
|
||||
fs::read_to_string(&path)
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Read the list of available cpufreq governors for a specific CPU (Linux sysfs).
|
||||
pub fn read_cpu_available_governors_sysfs(cpu: u32) -> Vec<String> {
|
||||
let path = format!(
|
||||
"/sys/devices/system/cpu/cpu{}/cpufreq/scaling_available_governors",
|
||||
cpu
|
||||
);
|
||||
fs::read_to_string(&path)
|
||||
.ok()
|
||||
.map(|s| {
|
||||
s.split_whitespace()
|
||||
.map(|g| g.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Read the min/max scaling frequencies for a specific CPU (Linux sysfs).
|
||||
pub fn read_cpu_min_max_freq_sysfs(cpu: u32) -> (Option<u32>, Option<u32>) {
|
||||
let min_path = format!(
|
||||
"/sys/devices/system/cpu/cpu{}/cpufreq/scaling_min_freq",
|
||||
cpu
|
||||
);
|
||||
let max_path = format!(
|
||||
"/sys/devices/system/cpu/cpu{}/cpufreq/scaling_max_freq",
|
||||
cpu
|
||||
);
|
||||
let min = fs::read_to_string(&min_path)
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<u32>().ok());
|
||||
let max = fs::read_to_string(&max_path)
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<u32>().ok());
|
||||
(min, max)
|
||||
}
|
||||
|
||||
/// Read package power in microwatts from RAPL MSR or powercap sysfs.
|
||||
/// Tries MSR first (direct rdmsr via /dev/cpu/*/msr or /scheme/sys/msr),
|
||||
/// then falls back to /sys/class/powercap/ (Linux sysfs).
|
||||
|
||||
@@ -5,7 +5,7 @@ use ratatui::{
|
||||
widgets::{Block, Borders, Clear, Widget},
|
||||
};
|
||||
|
||||
use crate::theme;
|
||||
use crate::theme::Theme;
|
||||
|
||||
pub struct AffinityEditor {
|
||||
pub open: bool,
|
||||
@@ -123,8 +123,8 @@ impl AffinityEditor {
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for &mut AffinityEditor {
|
||||
fn render(self, area: Rect, buf: &mut ratatui::buffer::Buffer) {
|
||||
impl AffinityEditor {
|
||||
pub fn render_dialog(&mut self, area: Rect, buf: &mut ratatui::buffer::Buffer, theme: &Theme) {
|
||||
Clear.render(area, buf);
|
||||
let cols = self.grid_cols();
|
||||
let rows = self.num_cpus.div_ceil(cols);
|
||||
@@ -136,7 +136,7 @@ impl Widget for &mut AffinityEditor {
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(theme::BORDER_FOCUSED)
|
||||
.border_style(theme.border_focused)
|
||||
.title(format!(" Affinity: {} (PID {}) ", self.comm, self.pid));
|
||||
let inner = block.inner(darea);
|
||||
block.render(darea, buf);
|
||||
@@ -161,9 +161,9 @@ impl Widget for &mut AffinityEditor {
|
||||
ratatui::style::Modifier::BOLD | ratatui::style::Modifier::REVERSED,
|
||||
)
|
||||
} else if is_on {
|
||||
theme::VALUE
|
||||
theme.value
|
||||
} else {
|
||||
theme::VALUE_OFF
|
||||
theme.value_off
|
||||
};
|
||||
let marker = if is_on { "+" } else { "-" };
|
||||
spans.push(Span::styled(format!("{marker}{label} "), style));
|
||||
@@ -179,16 +179,16 @@ impl Widget for &mut AffinityEditor {
|
||||
Err(e) => format!("Error: {e}"),
|
||||
};
|
||||
let style = if result.is_ok() {
|
||||
theme::STATUS_OK
|
||||
theme.status_ok
|
||||
} else {
|
||||
theme::STATUS_ERR
|
||||
theme.status_err
|
||||
};
|
||||
let para = ratatui::widgets::Paragraph::new(Line::from(Span::styled(msg, style)));
|
||||
para.render(footer_area, buf);
|
||||
} else {
|
||||
let hint = "Space:toggle Enter:apply Esc:cancel Arrows:move";
|
||||
let para =
|
||||
ratatui::widgets::Paragraph::new(Line::from(Span::styled(hint, theme::VALUE_OFF)));
|
||||
ratatui::widgets::Paragraph::new(Line::from(Span::styled(hint, theme.value_off)));
|
||||
para.render(footer_area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,12 +21,24 @@ use crate::acpi::{
|
||||
};
|
||||
use crate::cpufreq::Cpufreq;
|
||||
use crate::cpuid::{self, CoreType, CpuId};
|
||||
use crate::cpuidle::CpuidleStats;
|
||||
use crate::msr::{
|
||||
IA32_PERF_CTL, PERF_CTL_STATE_MASK, PackageThermal, THERM_STATUS_CRITICAL,
|
||||
THERM_STATUS_POWER_LIMIT, THERM_STATUS_PROCHOT, THERM_STATUS_READOUT_VALID,
|
||||
THERM_STATUS_TEMP_MASK, read_current_perf_ctl, read_package_thermal_status,
|
||||
read_thermal_status, write_msr,
|
||||
};
|
||||
use crate::wakeup::WakeupStats;
|
||||
|
||||
fn read_load_avg() -> Option<(f64, f64, f64)> {
|
||||
let s = std::fs::read_to_string("/proc/loadavg").ok()?;
|
||||
let mut parts = s.split_whitespace();
|
||||
Some((
|
||||
parts.next()?.parse().ok()?,
|
||||
parts.next()?.parse().ok()?,
|
||||
parts.next()?.parse().ok()?,
|
||||
))
|
||||
}
|
||||
|
||||
pub const POLL_MS: u64 = 500;
|
||||
pub const LOAD_HISTORY_LEN: usize = 30;
|
||||
@@ -82,6 +94,11 @@ pub struct CpuRow {
|
||||
pub load_history: VecDeque<u8>,
|
||||
pub core_type: CoreType,
|
||||
pub hwp: Option<crate::msr::HwpInfo>,
|
||||
/// Current cpufreq governor for this specific CPU (Linux sysfs),
|
||||
/// or the global governor when no per-core source is available.
|
||||
pub governor: String,
|
||||
/// List of governors reported by the kernel for this CPU.
|
||||
pub available_governors: Vec<String>,
|
||||
}
|
||||
|
||||
impl CpuRow {
|
||||
@@ -121,7 +138,8 @@ pub struct App {
|
||||
pub load_available: bool,
|
||||
pub governor_available: bool,
|
||||
pub hwmon_available: bool,
|
||||
pub pkg_thermal: PackageThermal,
|
||||
pub package_thermal: Option<PackageThermal>,
|
||||
pub dbus_disconnected: bool,
|
||||
pub cpuid_info: CpuId,
|
||||
pub simd: String,
|
||||
pub cache_summary: String,
|
||||
@@ -145,6 +163,19 @@ pub struct App {
|
||||
/// `/proc/stat` (Linux). Read every tick — it's a single
|
||||
/// file read. Fields degrade to `None` when unavailable.
|
||||
pub sched_stats: crate::sched::SchedStats,
|
||||
/// Previous per-CPU scheduler time breakdown, used to render
|
||||
/// htop-style CPU state bars from cumulative counters.
|
||||
pub prev_sched_breakdown: Vec<crate::sched::CpuTimeBreakdown>,
|
||||
/// CPU idle (C-state) residency percentages per CPU, read from
|
||||
/// `/sys/devices/system/cpu/cpu*/cpuidle/state*/time`.
|
||||
pub cpuidle_stats: CpuidleStats,
|
||||
/// Total system interrupts from the previous refresh, used to
|
||||
/// compute wakeups per second.
|
||||
pub wakeup_prev_total: Option<u64>,
|
||||
/// System wakeups per second (and cumulative total interrupts).
|
||||
pub wakeup_stats: WakeupStats,
|
||||
/// Hide kernel threads in the Process tab.
|
||||
pub hide_kernel_threads: bool,
|
||||
pub load_avg: Option<(f64, f64, f64)>,
|
||||
pub poll_ms: u64,
|
||||
pub theme_idx: usize,
|
||||
@@ -154,6 +185,9 @@ pub struct App {
|
||||
/// each parent's children but parents always come first.
|
||||
pub process_tree: bool,
|
||||
pub show_full_cmdline: bool,
|
||||
/// When true, the Process tab renders a narrow layout
|
||||
/// (PID, STATE, CPU%, MEM, COMM) for small terminals.
|
||||
pub process_narrow: bool,
|
||||
/// PIDs whose subtrees are collapsed in tree view. When a PID
|
||||
/// is in this set, its descendants are not rendered. Toggled
|
||||
/// by the `Space` hotkey (Process tab, tree mode only).
|
||||
@@ -248,6 +282,7 @@ pub struct App {
|
||||
pub toast_expires: Option<std::time::Instant>,
|
||||
pub bench_line: String,
|
||||
pub interval_input: Option<String>,
|
||||
pub process_filter_input: Option<String>,
|
||||
pub current_tab: TabId,
|
||||
pub bench_start_time: Option<Instant>,
|
||||
pub collector_stats: crate::collector::CollectorStats,
|
||||
@@ -372,6 +407,8 @@ impl App {
|
||||
load_history: VecDeque::with_capacity(LOAD_HISTORY_LEN),
|
||||
core_type: type_for(id),
|
||||
hwp: None,
|
||||
governor: String::new(),
|
||||
available_governors: Vec::new(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -418,7 +455,8 @@ impl App {
|
||||
load_available,
|
||||
governor_available,
|
||||
hwmon_available,
|
||||
pkg_thermal: PackageThermal::default(),
|
||||
package_thermal: None,
|
||||
dbus_disconnected: false,
|
||||
cpuid_info,
|
||||
simd,
|
||||
cache_summary,
|
||||
@@ -429,6 +467,7 @@ impl App {
|
||||
toast_expires: None,
|
||||
bench_line: String::new(),
|
||||
interval_input: None,
|
||||
process_filter_input: None,
|
||||
current_tab: TabId::PerCpu,
|
||||
bench_start_time: None,
|
||||
meminfo: crate::meminfo::read_meminfo(),
|
||||
@@ -447,22 +486,17 @@ impl App {
|
||||
process_sort: crate::process::SortMode::default(),
|
||||
process_filter: String::new(),
|
||||
sched_stats: crate::sched::SchedStats::default(),
|
||||
load_avg: std::fs::read_to_string("/proc/loadavg").ok().and_then(|s| {
|
||||
let parts: Vec<&str> = s.split_whitespace().collect();
|
||||
if parts.len() >= 3 {
|
||||
Some((
|
||||
parts[0].parse().ok()?,
|
||||
parts[1].parse().ok()?,
|
||||
parts[2].parse().ok()?,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}),
|
||||
poll_ms: crate::app::POLL_MS,
|
||||
prev_sched_breakdown: Vec::new(),
|
||||
cpuidle_stats: CpuidleStats::default(),
|
||||
wakeup_prev_total: None,
|
||||
wakeup_stats: WakeupStats::default(),
|
||||
hide_kernel_threads: false,
|
||||
load_avg: read_load_avg(),
|
||||
poll_ms: 0,
|
||||
theme_idx: 0,
|
||||
process_tree: false,
|
||||
show_full_cmdline: false,
|
||||
process_narrow: false,
|
||||
folded: std::collections::BTreeSet::new(),
|
||||
pkg_power_w: None,
|
||||
rapl_prev: None,
|
||||
@@ -523,6 +557,9 @@ impl App {
|
||||
app.folded = session.folded.into_iter().collect();
|
||||
app.process_filter = session.process_filter;
|
||||
app.show_full_cmdline = session.show_full_cmdline;
|
||||
app.process_narrow = session.process_narrow;
|
||||
app.poll_ms = session.refresh_ms;
|
||||
app.hide_kernel_threads = session.hide_kernel_threads;
|
||||
app
|
||||
}
|
||||
|
||||
@@ -544,6 +581,9 @@ impl App {
|
||||
folded: self.folded.iter().copied().collect(),
|
||||
process_filter: self.process_filter.clone(),
|
||||
show_full_cmdline: self.show_full_cmdline,
|
||||
process_narrow: self.process_narrow,
|
||||
refresh_ms: self.poll_ms,
|
||||
hide_kernel_threads: self.hide_kernel_threads,
|
||||
};
|
||||
session.save();
|
||||
}
|
||||
@@ -578,18 +618,7 @@ impl App {
|
||||
if self.refresh_counter.is_multiple_of(4) {
|
||||
self.meminfo = crate::meminfo::read_meminfo();
|
||||
self.os_info = crate::meminfo::read_os_info();
|
||||
self.load_avg = std::fs::read_to_string("/proc/loadavg").ok().and_then(|s| {
|
||||
let parts: Vec<&str> = s.split_whitespace().collect();
|
||||
if parts.len() >= 3 {
|
||||
Some((
|
||||
parts[0].parse().ok()?,
|
||||
parts[1].parse().ok()?,
|
||||
parts[2].parse().ok()?,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
self.load_avg = read_load_avg();
|
||||
}
|
||||
|
||||
// Battery state changes continuously on a laptop (capacity drops,
|
||||
@@ -737,7 +766,23 @@ impl App {
|
||||
|
||||
// Scheduler stats (context switches, IRQs). One file read
|
||||
// — cheap enough for every tick.
|
||||
self.sched_stats = crate::sched::SchedStats::read();
|
||||
let next_sched_stats = crate::sched::SchedStats::read();
|
||||
if !next_sched_stats.per_cpu_time_breakdown.is_empty() {
|
||||
self.prev_sched_breakdown =
|
||||
std::mem::take(&mut self.sched_stats.per_cpu_time_breakdown);
|
||||
}
|
||||
self.sched_stats = next_sched_stats;
|
||||
|
||||
// CPU C-state residency from sysfs. Cheap on Linux; degrades
|
||||
// to empty stats when the sysfs tree is unavailable.
|
||||
self.cpuidle_stats = CpuidleStats::read();
|
||||
|
||||
// System-wide wakeups per second from /proc/stat interrupts.
|
||||
let dt = Duration::from_millis(self.poll_ms.max(1));
|
||||
if let Some(stats) = crate::wakeup::read_system_wakeups(self.wakeup_prev_total, dt) {
|
||||
self.wakeup_prev_total = Some(stats.system_total);
|
||||
self.wakeup_stats = stats;
|
||||
}
|
||||
|
||||
let pkg_temps: Vec<Option<u32>> = self
|
||||
.cpus
|
||||
@@ -745,6 +790,8 @@ impl App {
|
||||
.map(|row| self.sensors.pkg_temp_c(row.id))
|
||||
.collect();
|
||||
|
||||
let global_governor = self.cpufreq.active.clone();
|
||||
let global_available = self.cpufreq.available.clone();
|
||||
self.collector_stats = crate::collector::collect(&mut self.cpus, |i, row| {
|
||||
if let Some(status) = read_thermal_status(row.id) {
|
||||
row.temp_c = if status & THERM_STATUS_READOUT_VALID != 0 {
|
||||
@@ -792,6 +839,12 @@ impl App {
|
||||
row.load_history
|
||||
.push_back(row.load_pct.clamp(0.0, 100.0) as u8);
|
||||
row.hwp = crate::msr::HwpInfo::read(row.id);
|
||||
row.governor = crate::acpi::read_cpu_governor_sysfs(row.id)
|
||||
.unwrap_or_else(|| global_governor.clone());
|
||||
row.available_governors = crate::acpi::read_cpu_available_governors_sysfs(row.id);
|
||||
if row.available_governors.is_empty() && !global_available.is_empty() {
|
||||
row.available_governors = global_available.clone();
|
||||
}
|
||||
});
|
||||
// Read package power from RAPL powercap (Intel/AMD). Requires
|
||||
// kernel CONFIG_POWERCAP and intel_rapl/amd_energy driver.
|
||||
@@ -870,11 +923,15 @@ impl App {
|
||||
}
|
||||
// Re-read cpufreq state (catches external governor changes).
|
||||
self.cpufreq.refresh();
|
||||
if let Some(pkg) = read_package_thermal_status(self.cpus[0].id) {
|
||||
self.pkg_thermal = PackageThermal::from_msr(pkg);
|
||||
self.package_thermal = self
|
||||
.cpus
|
||||
.first()
|
||||
.and_then(|cpu| read_package_thermal_status(cpu.id))
|
||||
.map(PackageThermal::from_msr);
|
||||
if let Some(pkg) = self.package_thermal.as_ref() {
|
||||
// PROCHOT at the package level means the entire chip is
|
||||
// throttling. Surface that in the global header.
|
||||
let prochot_now = pkg & THERM_STATUS_PROCHOT != 0;
|
||||
let prochot_now = pkg.prochot;
|
||||
let prochot_was = matches!(self.throttle, ThrottleMode::ForcedMin);
|
||||
if prochot_now && !prochot_was {
|
||||
self.flash_toast("PROCHOT triggered \u{2014} throttling active");
|
||||
@@ -910,6 +967,43 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cycle the governor of the currently selected CPU. On Linux this
|
||||
/// writes the per-core sysfs file; on Redox cpufreqd is global so it
|
||||
/// falls back to the system-wide governor.
|
||||
pub fn cycle_selected_cpu_governor(&mut self) {
|
||||
let Some(selected_idx) = self.table_state.selected() else {
|
||||
self.flash_status("select a CPU first (arrow keys / jk)");
|
||||
return;
|
||||
};
|
||||
let cpu = match self.cpus.get(selected_idx) {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
self.flash_status("no CPU selected");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let cpu_id = cpu.id;
|
||||
let current = cpu.governor.clone();
|
||||
let available = cpu.available_governors.clone();
|
||||
if available.len() < 2 {
|
||||
self.flash_status(format!("CPU{} has no other governor available", cpu_id));
|
||||
return;
|
||||
}
|
||||
let cur_pos = available.iter().position(|g| *g == current);
|
||||
let start = cur_pos.unwrap_or(0);
|
||||
for offset in 1..=available.len() {
|
||||
let candidate = &available[(start + offset) % available.len()];
|
||||
if crate::cpufreq::write_governor_for_cpu(cpu_id, candidate) {
|
||||
self.flash_status(format!("CPU{} governor → {}", cpu_id, candidate));
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.flash_status(format!(
|
||||
"CPU{}: governor cycle failed (permission denied)",
|
||||
cpu_id
|
||||
));
|
||||
}
|
||||
|
||||
/// Set the selected CPU to a specific P-state index (clamped to
|
||||
/// the valid range). Used by D-Bus `set_pstate(target)`.
|
||||
pub fn set_selected_pstate(&mut self, target: i32) {
|
||||
@@ -1191,16 +1285,34 @@ impl App {
|
||||
/// Process tab renders this many rows and `process_cursor` is
|
||||
/// bounded to this count.
|
||||
pub(crate) fn visible_processes(&self) -> Vec<&crate::process::ProcessInfo> {
|
||||
self.processes
|
||||
.processes
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
self.process_filter.is_empty()
|
||||
|| p.comm
|
||||
.to_lowercase()
|
||||
.contains(&self.process_filter.to_lowercase())
|
||||
})
|
||||
.collect()
|
||||
self.visible_processes_with(self.hide_kernel_threads)
|
||||
}
|
||||
|
||||
pub(crate) fn visible_processes_with(
|
||||
&self,
|
||||
hide_kernel_threads: bool,
|
||||
) -> Vec<&crate::process::ProcessInfo> {
|
||||
crate::process::visible_processes(
|
||||
&self.processes.processes,
|
||||
&self.process_filter,
|
||||
hide_kernel_threads,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn toggle_kernel_threads(&mut self) {
|
||||
self.hide_kernel_threads = !self.hide_kernel_threads;
|
||||
}
|
||||
|
||||
pub fn swap_used_pct(&self) -> Option<f64> {
|
||||
self.meminfo.swap_used_pct()
|
||||
}
|
||||
|
||||
pub fn wakeups_per_second(&self) -> f64 {
|
||||
self.wakeup_stats.system_per_second
|
||||
}
|
||||
|
||||
pub fn cpuidle_stats(&self) -> &CpuidleStats {
|
||||
&self.cpuidle_stats
|
||||
}
|
||||
|
||||
/// Update all per-PID history maps (IO rate, CPU%, RSS) from
|
||||
@@ -1872,6 +1984,9 @@ mod tests {
|
||||
folded: app.folded.iter().copied().collect(),
|
||||
process_filter: app.process_filter.clone(),
|
||||
show_full_cmdline: app.show_full_cmdline,
|
||||
process_narrow: app.process_narrow,
|
||||
refresh_ms: app.poll_ms,
|
||||
hide_kernel_threads: app.hide_kernel_threads,
|
||||
};
|
||||
let serialized = toml::to_string(&session).unwrap();
|
||||
let parsed: crate::session::SessionState = toml::from_str(&serialized).unwrap();
|
||||
@@ -1883,6 +1998,7 @@ mod tests {
|
||||
assert_eq!(parsed.folded.len(), 2);
|
||||
assert!(parsed.folded.contains(&100));
|
||||
assert!(parsed.folded.contains(&101));
|
||||
assert_eq!(parsed.refresh_ms, app.poll_ms);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
//! Battery information via `sysfs` (`/sys/class/power_supply/BAT0/*`).
|
||||
//! Battery information.
|
||||
//!
|
||||
//! Linux hosts expose laptop batteries through the `power_supply` class.
|
||||
//! On Redox, no equivalent scheme exists yet, so `read_battery()` returns
|
||||
//! an `available=false` `BatteryInfo` and the render layer skips the
|
||||
//! panel entirely — per the zero-stub policy.
|
||||
//!
|
||||
//! The Redox target needs a `power_supply` scheme daemon (likely wired to
|
||||
//! ACPI); this is forward work tracked in the v1.6 docs.
|
||||
//! On Linux the primary source is `/sys/class/power_supply/BAT0/*`. On
|
||||
//! Redox, ACPI exposes battery and adapter state via the power scheme:
|
||||
//! `/scheme/acpi/power/batteries/BAT0/{state,percentage}` and
|
||||
//! `/scheme/acpi/power/adapters/AC/online`. If neither source is
|
||||
//! available, `read_battery()` returns `available=false` and the render
|
||||
//! layer skips the panel.
|
||||
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const SYS_POWER_SUPPLY: &str = "/sys/class/power_supply";
|
||||
const REDOX_POWER: &str = "/scheme/acpi/power";
|
||||
|
||||
#[derive(Default, Clone, Debug)]
|
||||
pub struct BatteryInfo {
|
||||
pub available: bool,
|
||||
pub on_battery: Option<bool>,
|
||||
pub name: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub capacity_percent: Option<u32>,
|
||||
@@ -60,6 +61,37 @@ fn read_sysfs_f64_micro_to_units(path: &Path, unit_divisor: f64) -> Option<f64>
|
||||
Some((raw as f64) / unit_divisor)
|
||||
}
|
||||
|
||||
/// Read a Redox power scheme file.
|
||||
fn read_redox(path: &Path) -> Option<String> {
|
||||
read_sysfs(path)
|
||||
}
|
||||
|
||||
fn read_redox_u32(path: &Path) -> Option<u32> {
|
||||
read_redox(path)?.parse::<u32>().ok()
|
||||
}
|
||||
|
||||
fn read_redox_f64(path: &Path) -> Option<f64> {
|
||||
read_redox(path)?.parse::<f64>().ok()
|
||||
}
|
||||
|
||||
/// Map a Redox battery state bitmask to a status string.
|
||||
/// 0x1 = discharging, 0x2 = charging, 0x4 = empty
|
||||
fn redox_state_to_status(state: u32) -> String {
|
||||
if state == 0 {
|
||||
return "Unknown".to_string();
|
||||
}
|
||||
if state & 0x4 != 0 {
|
||||
return "Empty".to_string();
|
||||
}
|
||||
if state & 0x2 != 0 {
|
||||
return "Charging".to_string();
|
||||
}
|
||||
if state & 0x1 != 0 {
|
||||
return "Discharging".to_string();
|
||||
}
|
||||
"Unknown".to_string()
|
||||
}
|
||||
|
||||
impl BatteryInfo {
|
||||
/// Scan `/sys/class/power_supply/` for the first battery device
|
||||
/// (`type == "Battery"`). Returns `None` if no battery is present
|
||||
@@ -80,14 +112,74 @@ impl BatteryInfo {
|
||||
None
|
||||
}
|
||||
|
||||
/// Build a populated `BatteryInfo` from sysfs. Returns an empty
|
||||
/// `available=false` struct if no battery is detected.
|
||||
/// Find the first Redox battery directory under `/scheme/acpi/power/batteries/`.
|
||||
fn find_redox_battery_dir() -> Option<PathBuf> {
|
||||
let batteries_root = Path::new(REDOX_POWER).join("batteries");
|
||||
let dir = fs::read_dir(&batteries_root).ok()?;
|
||||
for entry in dir.flatten() {
|
||||
if entry.path().is_dir() {
|
||||
return Some(entry.path());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Read battery/AC status from the Redox ACPI power scheme.
|
||||
fn read_redox() -> Self {
|
||||
let mut info = Self {
|
||||
available: true,
|
||||
on_battery: None,
|
||||
..Default::default()
|
||||
};
|
||||
let base = match Self::find_redox_battery_dir() {
|
||||
Some(b) => b,
|
||||
None => {
|
||||
info.available = false;
|
||||
return info;
|
||||
}
|
||||
};
|
||||
info.name = base
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| n.to_string());
|
||||
let state = read_redox_u32(&base.join("state")).unwrap_or(0);
|
||||
info.status = Some(redox_state_to_status(state));
|
||||
info.capacity_percent = read_redox_u32(&base.join("percentage"));
|
||||
// Redox ACPI power scheme currently does not expose energy, power,
|
||||
// voltage, time estimates, cycle count, or model metadata. Leave those
|
||||
// as None so the render layer shows "?" for unsupported fields.
|
||||
info
|
||||
}
|
||||
|
||||
/// Build a populated `BatteryInfo` from the best available source.
|
||||
/// Returns an empty `available=false` struct if no battery is detected.
|
||||
pub fn read() -> Self {
|
||||
let mut info = Self::read_native();
|
||||
if !info.available {
|
||||
// No local sysfs/scheme battery; try UPower D-Bus as a fallback.
|
||||
if let Some(upower) = crate::upower_client::read_upower() {
|
||||
return upower;
|
||||
}
|
||||
} else {
|
||||
// We have a local battery; overlay UPower's authoritative
|
||||
// status/percentage/on_battery values when possible.
|
||||
info = crate::upower_client::merge_upower(info);
|
||||
}
|
||||
info
|
||||
}
|
||||
|
||||
/// Build a `BatteryInfo` from local sysfs (Linux) or ACPI scheme
|
||||
/// (Redox), without using UPower.
|
||||
fn read_native() -> Self {
|
||||
if Path::new(REDOX_POWER).exists() {
|
||||
return Self::read_redox();
|
||||
}
|
||||
let Some(base) = Self::find_battery_dir() else {
|
||||
return Self::default();
|
||||
};
|
||||
Self {
|
||||
available: true,
|
||||
on_battery: None,
|
||||
name: read_sysfs(&base.join("name")),
|
||||
status: read_sysfs(&base.join("status")),
|
||||
capacity_percent: read_sysfs_u32(&base.join("capacity")),
|
||||
|
||||
@@ -28,17 +28,22 @@ unsafe extern "C" {
|
||||
}
|
||||
|
||||
fn try_pin_cpu(cpu_id: u32) -> bool {
|
||||
if cpu_id >= 64 {
|
||||
if cpu_id >= 128 {
|
||||
return false;
|
||||
}
|
||||
let mask: u64 = 1u64 << cpu_id;
|
||||
let bytes = mask.to_le_bytes();
|
||||
let mut mask = [0u64; 2];
|
||||
let idx = (cpu_id as usize) / 64;
|
||||
let bit = (cpu_id as usize) % 64;
|
||||
mask[idx] |= 1u64 << bit;
|
||||
let mut bytes = [0u8; 16];
|
||||
bytes[..8].copy_from_slice(&mask[0].to_le_bytes());
|
||||
bytes[8..].copy_from_slice(&mask[1].to_le_bytes());
|
||||
if std::fs::write("/proc/self/sched-affinity", bytes).is_ok() {
|
||||
return true;
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
unsafe {
|
||||
sched_setaffinity(0, std::mem::size_of::<u64>(), &mask) == 0
|
||||
sched_setaffinity(0, std::mem::size_of::<u64>(), &mask as *const u64) == 0
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
|
||||
@@ -117,6 +117,27 @@ impl Default for KeyBindings {
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyBindings {
|
||||
/// Return the first character configured for a command, if any.
|
||||
/// Recognized commands: `quit`, `cycle_governor`, `refresh_now`,
|
||||
/// `toggle_help`, `snapshot`, `benchmark_start`, `benchmark_stop`.
|
||||
/// Multi-character strings return only the first char; unrecognized
|
||||
/// commands return `None`.
|
||||
pub fn char_for(&self, command: &str) -> Option<char> {
|
||||
let s = match command {
|
||||
"quit" => &self.quit,
|
||||
"cycle_governor" => &self.cycle_governor,
|
||||
"refresh_now" => &self.refresh_now,
|
||||
"toggle_help" => &self.toggle_help,
|
||||
"snapshot" => &self.snapshot,
|
||||
"benchmark_start" => &self.benchmark_start,
|
||||
"benchmark_stop" => &self.benchmark_stop,
|
||||
_ => return None,
|
||||
};
|
||||
s.chars().next()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct BenchmarkConfig {
|
||||
|
||||
@@ -305,3 +305,25 @@ pub fn write_governor_hint(governor: &str) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a governor for a specific CPU. On Linux this targets the per-core
|
||||
/// sysfs file; on Redox cpufreqd is system-wide, so this falls back to the
|
||||
/// global governor file. Returns true if the write succeeded.
|
||||
pub fn write_governor_for_cpu(cpu: u32, governor: &str) -> bool {
|
||||
if std::path::Path::new("/scheme").exists() {
|
||||
// Redox cpufreqd has a single system-wide governor.
|
||||
return write_governor_hint(governor);
|
||||
}
|
||||
let path = format!(
|
||||
"/sys/devices/system/cpu/cpu{}/cpufreq/scaling_governor",
|
||||
cpu
|
||||
);
|
||||
if fs::write(&path, governor).is_err() {
|
||||
return false;
|
||||
}
|
||||
// Verify by reading back.
|
||||
fs::read_to_string(&path)
|
||||
.ok()
|
||||
.map(|s| s.trim() == governor)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//! CPU idle residency stats from sysfs.
|
||||
//!
|
||||
//! Reads per-CPU C-state names and accumulated residency time from:
|
||||
//! `/sys/devices/system/cpu/cpu*/cpuidle/state*/{name,time}`.
|
||||
//! Missing paths degrade to empty stats.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct CStateResidency {
|
||||
pub name: String,
|
||||
pub time: u64,
|
||||
pub percentage: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct CpuidleStats {
|
||||
pub per_cpu: BTreeMap<u32, Vec<CStateResidency>>,
|
||||
}
|
||||
|
||||
impl CpuidleStats {
|
||||
pub fn read() -> Self {
|
||||
Self::read_from(Path::new("/sys/devices/system/cpu"))
|
||||
}
|
||||
|
||||
pub(crate) fn read_from(base: &Path) -> Self {
|
||||
let mut stats = Self::default();
|
||||
let Ok(entries) = fs::read_dir(base) else {
|
||||
return stats;
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let Some(cpu_id) = parse_cpu_dir(&path) else {
|
||||
continue;
|
||||
};
|
||||
let cpuidle_dir = path.join("cpuidle");
|
||||
let mut states = read_cpu_states(&cpuidle_dir);
|
||||
normalize_percentages(&mut states);
|
||||
if !states.is_empty() {
|
||||
stats.per_cpu.insert(cpu_id, states);
|
||||
}
|
||||
}
|
||||
|
||||
stats
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_cpu_dir(path: &Path) -> Option<u32> {
|
||||
let name = path.file_name()?.to_str()?;
|
||||
name.strip_prefix("cpu")?.parse().ok()
|
||||
}
|
||||
|
||||
fn read_cpu_states(cpuidle_dir: &Path) -> Vec<CStateResidency> {
|
||||
let Ok(entries) = fs::read_dir(cpuidle_dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut states = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
let state_dir = entry.path();
|
||||
if !state_dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let name = read_trimmed(&state_dir.join("name"));
|
||||
let time = read_trimmed(&state_dir.join("time"));
|
||||
let (Some(name), Some(time)) = (name, time) else {
|
||||
continue;
|
||||
};
|
||||
let Some(time) = time.parse().ok() else {
|
||||
continue;
|
||||
};
|
||||
states.push(CStateResidency {
|
||||
name,
|
||||
time,
|
||||
percentage: 0.0,
|
||||
});
|
||||
}
|
||||
states.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
states
|
||||
}
|
||||
|
||||
fn read_trimmed(path: &Path) -> Option<String> {
|
||||
fs::read_to_string(path).ok().map(|s| s.trim().to_string())
|
||||
}
|
||||
|
||||
fn normalize_percentages(states: &mut [CStateResidency]) {
|
||||
let total: u64 = states.iter().map(|s| s.time).sum();
|
||||
if total == 0 {
|
||||
return;
|
||||
}
|
||||
for state in states {
|
||||
state.percentage = state.time as f64 / total as f64;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn temp_dir() -> std::path::PathBuf {
|
||||
let mut dir = std::env::temp_dir();
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("time went backwards")
|
||||
.as_nanos();
|
||||
dir.push(format!("redbear-power-cpuidle-{unique}"));
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_percentages_sums_to_one() {
|
||||
let mut states = vec![
|
||||
CStateResidency {
|
||||
name: "C1".into(),
|
||||
time: 25,
|
||||
percentage: 0.0,
|
||||
},
|
||||
CStateResidency {
|
||||
name: "C2".into(),
|
||||
time: 75,
|
||||
percentage: 0.0,
|
||||
},
|
||||
];
|
||||
normalize_percentages(&mut states);
|
||||
assert!((states[0].percentage - 0.25).abs() < f64::EPSILON);
|
||||
assert!((states[1].percentage - 0.75).abs() < f64::EPSILON);
|
||||
assert!((states.iter().map(|s| s.percentage).sum::<f64>() - 1.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_percentages_handles_zero_total() {
|
||||
let mut states = vec![CStateResidency {
|
||||
name: "C1".into(),
|
||||
time: 0,
|
||||
percentage: 0.0,
|
||||
}];
|
||||
normalize_percentages(&mut states);
|
||||
assert_eq!(states[0].percentage, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_from_unknown_layout_returns_empty() {
|
||||
let dir = temp_dir();
|
||||
let stats = CpuidleStats::read_from(&dir);
|
||||
assert!(stats.per_cpu.is_empty());
|
||||
let _ = fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@
|
||||
//! update via property-setter calls; zbus auto-emits the
|
||||
//! `PropertiesChanged` signal to subscribed clients.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
|
||||
use tokio::runtime::Runtime;
|
||||
@@ -84,6 +86,7 @@ impl PowerSnapshot {
|
||||
pub struct DbusServer {
|
||||
tx: Sender<PowerSnapshot>,
|
||||
cmd_rx: Receiver<PowerCommand>,
|
||||
disconnected: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl DbusServer {
|
||||
@@ -92,6 +95,8 @@ impl DbusServer {
|
||||
pub fn spawn() -> ZbusResult<Self> {
|
||||
let (tx, rx) = channel::<PowerSnapshot>();
|
||||
let (cmd_tx, cmd_rx) = channel::<PowerCommand>();
|
||||
let disconnected = Arc::new(AtomicBool::new(false));
|
||||
let worker_disconnected = Arc::clone(&disconnected);
|
||||
// Probe the session bus on the calling thread first. If it's
|
||||
// not available, fail fast without spawning the worker.
|
||||
let rt = Runtime::new().expect("tokio runtime");
|
||||
@@ -104,15 +109,27 @@ impl DbusServer {
|
||||
.spawn(move || {
|
||||
if let Err(e) = run_worker(rx, cmd_tx) {
|
||||
eprintln!("redbear-power: dbus worker exited: {e}");
|
||||
worker_disconnected.store(true, Ordering::Release);
|
||||
}
|
||||
})
|
||||
.map_err(|e| zbus::Error::InputOutput(std::sync::Arc::new(std::io::Error::other(e))))?;
|
||||
Ok(DbusServer { tx, cmd_rx })
|
||||
Ok(DbusServer {
|
||||
tx,
|
||||
cmd_rx,
|
||||
disconnected,
|
||||
})
|
||||
}
|
||||
|
||||
/// Push a fresh snapshot to the D-Bus worker. Non-blocking.
|
||||
pub fn publish(&self, snap: PowerSnapshot) {
|
||||
let _ = self.tx.send(snap);
|
||||
if self.tx.send(snap).is_err() {
|
||||
self.disconnected.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true once the worker or snapshot channel has failed.
|
||||
pub fn is_disconnected(&self) -> bool {
|
||||
self.disconnected.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// Drain pending commands from the D-Bus worker. Returns the
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
//! SMBIOS / DMI motherboard information.
|
||||
//!
|
||||
//! Reads `/sys/class/dmi/id/*` on Linux hosts. On Redox, no equivalent
|
||||
//! scheme exists yet, so `read_dmi()` returns an empty struct and the
|
||||
//! render layer displays `?` for missing fields — per the zero-stub policy.
|
||||
//!
|
||||
//! The Redox target needs a `dmi` scheme daemon that exposes SMBIOS tables
|
||||
//! via `/scheme/dmi/...`; this is forward work tracked in the v1.5 docs.
|
||||
//! Reads `/sys/class/dmi/id/*` on Linux hosts. On Redox, the ACPI
|
||||
//! scheme exposes SMBIOS data at `/scheme/acpi/dmi` as a single
|
||||
//! `key=value` file (and optionally per-field files like
|
||||
//! `/scheme/acpi/dmi/sys_vendor`). If neither source is available,
|
||||
//! `read_dmi()` returns an empty struct and the render layer displays
|
||||
//! `?` for missing fields.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// Linux sysfs path for DMI/SMBIOS data.
|
||||
const SYS_DMI: &str = "/sys/class/dmi/id";
|
||||
/// Redox ACPI scheme path for DMI/SMBIOS data.
|
||||
const REDOX_DMI: &str = "/scheme/acpi/dmi";
|
||||
|
||||
/// DMI/SMBIOS fields. All fields are `Option<String>` because any one of
|
||||
/// them may be unreadable (permission denied, missing sysfs file, etc.).
|
||||
/// them may be unreadable (permission denied, missing file, etc.).
|
||||
#[derive(Default, Clone, Debug)]
|
||||
pub struct DmiInfo {
|
||||
pub board_vendor: Option<String>,
|
||||
@@ -57,35 +59,93 @@ impl DmiInfo {
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe whether `/sys/class/dmi/id/` exists. Used by the Sources
|
||||
/// header line to report `dmi=ok` vs `dmi=no`.
|
||||
pub fn available() -> bool {
|
||||
Path::new(SYS_DMI).is_dir()
|
||||
/// Read a single Redox per-field file (`/scheme/acpi/dmi/<field>`).
|
||||
fn read_redox_field(name: &str) -> Option<String> {
|
||||
let path = Path::new(REDOX_DMI).join(name);
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(s) => {
|
||||
let trimmed = s.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
}
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a populated `DmiInfo` from sysfs. Each field is read
|
||||
/// independently so one failure doesn't poison the others.
|
||||
/// Parse the Redox `/scheme/acpi/dmi` single-file format, which is a
|
||||
/// set of `key=value` lines. Returns an empty map on any error.
|
||||
fn parse_redox_dmi() -> std::collections::HashMap<String, String> {
|
||||
let mut map = std::collections::HashMap::new();
|
||||
let Ok(content) = fs::read_to_string(REDOX_DMI) else {
|
||||
return map;
|
||||
};
|
||||
for line in content.lines() {
|
||||
let Some((key, value)) = line.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
let trimmed = value.trim().to_string();
|
||||
if !trimmed.is_empty() {
|
||||
map.insert(key.trim().to_string(), trimmed);
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// Build a populated `DmiInfo` from Redox ACPI scheme data. Each field
|
||||
/// is read independently from per-field files or the single-file
|
||||
/// key=value fallback so one failure doesn't poison the others.
|
||||
fn read_redox() -> Self {
|
||||
let fields = Self::parse_redox_dmi();
|
||||
let mut info = Self::default();
|
||||
let fill = |key: &str, target: &mut Option<String>| {
|
||||
*target = Self::read_redox_field(key).or_else(|| fields.get(key).cloned());
|
||||
};
|
||||
fill("sys_vendor", &mut info.sys_vendor);
|
||||
fill("board_vendor", &mut info.board_vendor);
|
||||
fill("board_name", &mut info.board_name);
|
||||
fill("board_version", &mut info.board_version);
|
||||
fill("product_name", &mut info.product_name);
|
||||
fill("product_version", &mut info.product_version);
|
||||
fill("bios_version", &mut info.bios_version);
|
||||
// The remaining fields are not exposed by the Redox ACPI DMI scheme
|
||||
// (per redox-driver-sys/src/quirks/dmi.rs); leave them as None.
|
||||
info
|
||||
}
|
||||
|
||||
/// Probe whether DMI data is available on this host.
|
||||
pub fn available() -> bool {
|
||||
Path::new(SYS_DMI).is_dir() || Path::new(REDOX_DMI).exists()
|
||||
}
|
||||
|
||||
/// Build a populated `DmiInfo` from the best available source.
|
||||
pub fn read() -> Self {
|
||||
Self {
|
||||
board_vendor: Self::read_sysfs("board_vendor"),
|
||||
board_name: Self::read_sysfs("board_name"),
|
||||
board_version: Self::read_sysfs("board_version"),
|
||||
board_serial: Self::read_sysfs("board_serial"),
|
||||
board_asset_tag: Self::read_sysfs("board_asset_tag"),
|
||||
bios_vendor: Self::read_sysfs("bios_vendor"),
|
||||
bios_version: Self::read_sysfs("bios_version"),
|
||||
bios_date: Self::read_sysfs("bios_date"),
|
||||
bios_release: Self::read_sysfs("bios_release"),
|
||||
product_name: Self::read_sysfs("product_name"),
|
||||
product_family: Self::read_sysfs("product_family"),
|
||||
product_version: Self::read_sysfs("product_version"),
|
||||
product_serial: Self::read_sysfs("product_serial"),
|
||||
product_uuid: Self::read_sysfs("product_uuid"),
|
||||
sys_vendor: Self::read_sysfs("sys_vendor"),
|
||||
chassis_vendor: Self::read_sysfs("chassis_vendor"),
|
||||
chassis_type: Self::read_sysfs("chassis_type"),
|
||||
chassis_version: Self::read_sysfs("chassis_version"),
|
||||
chassis_asset_tag: Self::read_sysfs("chassis_asset_tag"),
|
||||
if Path::new(REDOX_DMI).exists() {
|
||||
Self::read_redox()
|
||||
} else {
|
||||
Self {
|
||||
board_vendor: Self::read_sysfs("board_vendor"),
|
||||
board_name: Self::read_sysfs("board_name"),
|
||||
board_version: Self::read_sysfs("board_version"),
|
||||
board_serial: Self::read_sysfs("board_serial"),
|
||||
board_asset_tag: Self::read_sysfs("board_asset_tag"),
|
||||
bios_vendor: Self::read_sysfs("bios_vendor"),
|
||||
bios_version: Self::read_sysfs("bios_version"),
|
||||
bios_date: Self::read_sysfs("bios_date"),
|
||||
bios_release: Self::read_sysfs("bios_release"),
|
||||
product_name: Self::read_sysfs("product_name"),
|
||||
product_family: Self::read_sysfs("product_family"),
|
||||
product_version: Self::read_sysfs("product_version"),
|
||||
product_serial: Self::read_sysfs("product_serial"),
|
||||
product_uuid: Self::read_sysfs("product_uuid"),
|
||||
sys_vendor: Self::read_sysfs("sys_vendor"),
|
||||
chassis_vendor: Self::read_sysfs("chassis_vendor"),
|
||||
chassis_type: Self::read_sysfs("chassis_type"),
|
||||
chassis_version: Self::read_sysfs("chassis_version"),
|
||||
chassis_asset_tag: Self::read_sysfs("chassis_asset_tag"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -95,10 +95,12 @@ fn do_kill(pid: u32, sig: i32) -> Result<(), String> {
|
||||
Err(std::io::Error::last_os_error().to_string())
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[cfg(target_os = "redox")]
|
||||
{
|
||||
let _ = (pid, sig);
|
||||
Err("process killing not supported on this platform".into())
|
||||
let sig = sig as u32;
|
||||
libredox::call::kill(pid as usize, sig)
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,8 +151,16 @@ impl Widget for &KillDialog {
|
||||
let help = if let Some(ref result) = self.result {
|
||||
Line::styled(result.clone(), Style::new().green().bold())
|
||||
} else {
|
||||
let signal_name = self
|
||||
.signals
|
||||
.get(self.selected.get())
|
||||
.and_then(|(label, _)| label.split(" — ").next())
|
||||
.unwrap_or("?");
|
||||
Line::styled(
|
||||
"Enter: send signal Esc: cancel ↑↓: select",
|
||||
format!(
|
||||
"Send {signal_name} to PID {} ({})? Enter: send Esc: cancel ↑↓: select",
|
||||
self.pid, self.comm
|
||||
),
|
||||
Style::new().dark_gray(),
|
||||
)
|
||||
};
|
||||
|
||||
@@ -54,6 +54,7 @@ mod collector;
|
||||
mod config;
|
||||
mod cpufreq;
|
||||
mod cpuid;
|
||||
mod cpuidle;
|
||||
mod dbus;
|
||||
mod dmi;
|
||||
mod graph;
|
||||
@@ -73,20 +74,24 @@ mod session;
|
||||
mod smart;
|
||||
mod storage;
|
||||
mod theme;
|
||||
mod upower_client;
|
||||
mod wakeup;
|
||||
|
||||
use crate::app::{App, POLL_MS, TabId};
|
||||
use crate::graph::BrailleGraph;
|
||||
use crate::render::{
|
||||
GRAPH_HEIGHT, render_battery_panel, render_cpu_table, render_header, render_help,
|
||||
render_info_panel, render_keybar, render_motherboard_panel, render_network_panel, render_once,
|
||||
render_pid_detail, render_process_panel, render_prochot_alert, render_sensor_panel,
|
||||
render_storage_panel, render_system_panel, render_tab_bar, render_toast, snapshot,
|
||||
render_info_panel, render_json, render_keybar, render_motherboard_panel, render_network_panel,
|
||||
render_once, render_pid_detail, render_process_panel, render_prochot_alert,
|
||||
render_sensor_panel, render_storage_panel, render_system_panel, render_tab_bar, render_toast,
|
||||
snapshot,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Mode {
|
||||
Interactive,
|
||||
Once,
|
||||
Json,
|
||||
}
|
||||
|
||||
const POLL_STEPS_MS: &[u64] = &[250, 500, 1_000, 2_000];
|
||||
@@ -108,6 +113,7 @@ fn parse_args() -> Args {
|
||||
while let Some(arg) = iter.next() {
|
||||
match arg.as_str() {
|
||||
"--once" => mode = Mode::Once,
|
||||
"--json" => mode = Mode::Json,
|
||||
"--dbus" => dbus = true,
|
||||
"--theme" => {
|
||||
if let Some(t) = iter.next() {
|
||||
@@ -234,15 +240,13 @@ fn handle_mouse(me: MouseEvent, header: &Rect, table: &Rect, keybar: &Rect, app:
|
||||
fn main() -> io::Result<()> {
|
||||
let args = parse_args();
|
||||
|
||||
// v1.44: Restore terminal on panic so the user isn't left with
|
||||
// a garbled screen in raw mode.
|
||||
// Let panic unwind so the raw-mode / alternate-screen guards drop
|
||||
// and restore the terminal before the default panic handler prints.
|
||||
panic::set_hook(Box::new(|info| {
|
||||
let _ = termion::raw::IntoRawMode::into_raw_mode(std::io::stdout());
|
||||
let _ = std::io::Write::write_all(
|
||||
&mut std::io::stderr(),
|
||||
format!("redbear-power: {info}\n").as_bytes(),
|
||||
);
|
||||
std::process::exit(1);
|
||||
}));
|
||||
|
||||
let cfg = if let Some(p) = args.config_path.as_ref() {
|
||||
@@ -301,6 +305,9 @@ fn main() -> io::Result<()> {
|
||||
if args.mode == Mode::Once {
|
||||
return render_once(&app);
|
||||
}
|
||||
if args.mode == Mode::Json {
|
||||
return render_json(&app);
|
||||
}
|
||||
|
||||
// Check that stdout is a terminal before entering raw mode.
|
||||
use std::io::IsTerminal;
|
||||
@@ -319,13 +326,16 @@ fn main() -> io::Result<()> {
|
||||
let async_stdin = termion::async_stdin();
|
||||
let mut events = async_stdin.events();
|
||||
let mut last_refresh = Instant::now();
|
||||
// Honor config.refresh_ms when set, otherwise default to POLL_MS.
|
||||
let mut poll = Duration::from_millis(if cfg.display.refresh_ms >= 50 {
|
||||
// Honor the persisted session refresh interval first, then config, then default.
|
||||
let mut poll = Duration::from_millis(if app.poll_ms >= 50 {
|
||||
app.poll_ms
|
||||
} else if cfg.display.refresh_ms >= 50 {
|
||||
cfg.display.refresh_ms
|
||||
} else {
|
||||
POLL_MS
|
||||
});
|
||||
app.poll_ms = poll.as_millis() as u64;
|
||||
let kb = &cfg.keybindings;
|
||||
let mut show_help = false;
|
||||
// Tab/BackTab cycles keyboard focus between header / table / controls.
|
||||
let mut focused_panel: usize = 1;
|
||||
@@ -372,6 +382,7 @@ fn main() -> io::Result<()> {
|
||||
// from clients land here as PowerCommand variants that map
|
||||
// 1:1 to keyboard actions (g, p, P, m, M, t).
|
||||
if let Some(server) = dbus_server.as_ref() {
|
||||
app.dbus_disconnected = server.is_disconnected();
|
||||
while let Some(cmd) = server.try_recv_command() {
|
||||
match cmd {
|
||||
dbus::PowerCommand::CycleGovernor => app.cycle_governor(),
|
||||
@@ -407,11 +418,15 @@ fn main() -> io::Result<()> {
|
||||
}
|
||||
if let Some(server) = dbus_server.as_ref() {
|
||||
server.publish(dbus::PowerSnapshot::from_app(&app));
|
||||
app.dbus_disconnected = server.is_disconnected();
|
||||
}
|
||||
}
|
||||
app.interval_input = interval_input.clone();
|
||||
if let Some(buf) = process_filter_input.as_ref() {
|
||||
app.process_filter_input = Some(buf.clone());
|
||||
app.process_filter = buf.clone();
|
||||
} else {
|
||||
app.process_filter_input = None;
|
||||
}
|
||||
terminal.draw(|f| {
|
||||
let full = f.area();
|
||||
@@ -596,11 +611,14 @@ fn main() -> io::Result<()> {
|
||||
}
|
||||
f.render_widget(&app.kill_dialog, full);
|
||||
if app.nice_edit.open {
|
||||
f.render_widget(&app.nice_edit, full);
|
||||
app.nice_edit
|
||||
.render_dialog(full, f.buffer_mut(), &app.theme);
|
||||
}
|
||||
if app.affinity_editor.open {
|
||||
f.render_widget(&mut app.affinity_editor, full);
|
||||
app.affinity_editor
|
||||
.render_dialog(full, f.buffer_mut(), &app.theme);
|
||||
}
|
||||
|
||||
if app.show_open_files
|
||||
&& let Some(files) = &app.open_files_result
|
||||
{
|
||||
@@ -903,7 +921,9 @@ fn main() -> io::Result<()> {
|
||||
Key::Esc if app.pid_detail.is_some() => {
|
||||
app.pid_detail = None;
|
||||
}
|
||||
Key::Char('q') | Key::Esc => {
|
||||
_ if k == Key::Esc
|
||||
|| matches!(k, Key::Char(c) if kb.char_for("quit") == Some(c)) =>
|
||||
{
|
||||
if bench.running {
|
||||
bench.stop();
|
||||
}
|
||||
@@ -951,7 +971,7 @@ fn main() -> io::Result<()> {
|
||||
Key::Char('8') => app.set_tab(app::TabId::Storage),
|
||||
Key::Char('9') => app.set_tab(app::TabId::Process),
|
||||
Key::Char('T') => app.set_tab(app.current_tab.next()),
|
||||
Key::Char('?') => show_help = !show_help,
|
||||
Key::Char(c) if kb.char_for("toggle_help") == Some(c) => show_help = !show_help,
|
||||
Key::Down | Key::Char('j') if show_help => {
|
||||
app.help_scroll = app.help_scroll.saturating_add(1);
|
||||
}
|
||||
@@ -970,11 +990,14 @@ fn main() -> io::Result<()> {
|
||||
Key::End | Key::Char('G') if show_help => {
|
||||
app.help_scroll = 200;
|
||||
}
|
||||
Key::Char('g') => app.cycle_governor(),
|
||||
Key::Char(c) if kb.char_for("cycle_governor") == Some(c) => {
|
||||
app.cycle_governor()
|
||||
}
|
||||
Key::Char('p') => app.step_selected_pstate(-1),
|
||||
Key::Char('P') => app.step_selected_pstate(1),
|
||||
Key::Char('m') => app.force_min_pstate(),
|
||||
Key::Char('M') => app.force_max_pstate(),
|
||||
Key::Ctrl('g') => app.cycle_selected_cpu_governor(),
|
||||
Key::Char('t') => app.toggle_throttle_mode(),
|
||||
Key::Char('e') => {
|
||||
app.expanded = !app.expanded;
|
||||
@@ -1063,12 +1086,12 @@ fn main() -> io::Result<()> {
|
||||
app.flash_status("no process selected");
|
||||
}
|
||||
}
|
||||
Key::Char('r') => {
|
||||
Key::Char(c) if kb.char_for("refresh_now") == Some(c) => {
|
||||
app.refresh();
|
||||
last_refresh = Instant::now();
|
||||
app.flash_status("refreshed");
|
||||
}
|
||||
Key::Char('c') => {
|
||||
Key::Char(c) if kb.char_for("snapshot") == Some(c) => {
|
||||
let path = "/tmp/redbear-power-snapshot.txt";
|
||||
match fs::write(path, snapshot(&app, 140, 50)) {
|
||||
Ok(_) => app.flash_status(format!("snapshot → {path}")),
|
||||
@@ -1134,7 +1157,7 @@ fn main() -> io::Result<()> {
|
||||
interval_input = None;
|
||||
app.flash_status("refresh interval cancelled");
|
||||
}
|
||||
Key::Char('b') => {
|
||||
Key::Char(c) if kb.char_for("benchmark_start") == Some(c) => {
|
||||
bench.start(app.cpus.len(), 30);
|
||||
let cores = if bench.single_core {
|
||||
1
|
||||
@@ -1147,7 +1170,7 @@ fn main() -> io::Result<()> {
|
||||
cores
|
||||
));
|
||||
}
|
||||
Key::Char('B') => {
|
||||
Key::Char(c) if kb.char_for("benchmark_stop") == Some(c) => {
|
||||
if bench.running {
|
||||
bench.stop();
|
||||
app.flash_status(format!(
|
||||
@@ -1207,6 +1230,28 @@ fn main() -> io::Result<()> {
|
||||
if app.process_tree { "tree" } else { "flat" }
|
||||
));
|
||||
}
|
||||
Key::Char('h') if app.current_tab == TabId::Process => {
|
||||
app.process_narrow = !app.process_narrow;
|
||||
app.flash_status(format!(
|
||||
"process view: {}",
|
||||
if app.process_narrow {
|
||||
"narrow (PID/STATE/CPU/MEM/COMM)"
|
||||
} else {
|
||||
"full"
|
||||
}
|
||||
));
|
||||
}
|
||||
Key::Char('K') if app.current_tab == TabId::Process => {
|
||||
app.toggle_kernel_threads();
|
||||
app.flash_status(format!(
|
||||
"kernel threads: {}",
|
||||
if app.hide_kernel_threads {
|
||||
"hidden"
|
||||
} else {
|
||||
"shown"
|
||||
}
|
||||
));
|
||||
}
|
||||
Key::Char('C') if app.current_tab == TabId::Process => {
|
||||
app.show_full_cmdline = !app.show_full_cmdline;
|
||||
app.flash_status(format!(
|
||||
@@ -1247,7 +1292,7 @@ fn main() -> io::Result<()> {
|
||||
Key::Char('F') => {
|
||||
process_filter_input = Some(app.process_filter.clone());
|
||||
app.flash_status(
|
||||
"process filter: type chars + Enter to apply, Esc to clear",
|
||||
"process filter: text filters by name/PID, number jumps to PID, Esc clears",
|
||||
);
|
||||
}
|
||||
Key::Char(c) if process_filter_input.is_some() && interval_input.is_none() => {
|
||||
@@ -1266,23 +1311,37 @@ fn main() -> io::Result<()> {
|
||||
if process_filter_input.is_some() && interval_input.is_none() =>
|
||||
{
|
||||
let new_filter = process_filter_input.take().unwrap_or_default();
|
||||
app.process_filter = new_filter.clone();
|
||||
let matches = if app.process_filter.is_empty() {
|
||||
app.processes.count()
|
||||
if let Ok(pid) = new_filter.parse::<u32>() {
|
||||
if let Some(idx) =
|
||||
app.visible_processes().iter().position(|p| p.pid == pid)
|
||||
{
|
||||
app.process_cursor = idx;
|
||||
app.flash_status(format!("jumped to PID {pid}"));
|
||||
} else {
|
||||
app.flash_status(format!("PID {pid} not found in current view"));
|
||||
}
|
||||
} else {
|
||||
let needle = app.process_filter.to_lowercase();
|
||||
app.processes
|
||||
.processes
|
||||
.iter()
|
||||
.filter(|p| p.comm.to_lowercase().contains(&needle))
|
||||
.count()
|
||||
};
|
||||
app.flash_status(format!(
|
||||
"process filter applied: \"{}\" ({} match{})",
|
||||
new_filter,
|
||||
matches,
|
||||
if matches == 1 { "" } else { "es" }
|
||||
));
|
||||
app.process_filter = new_filter.clone();
|
||||
let matches = if app.process_filter.is_empty() {
|
||||
app.processes.count()
|
||||
} else {
|
||||
let needle = app.process_filter.to_lowercase();
|
||||
app.processes
|
||||
.processes
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
p.comm.to_lowercase().contains(&needle)
|
||||
|| p.pid.to_string().contains(&needle)
|
||||
})
|
||||
.count()
|
||||
};
|
||||
app.flash_status(format!(
|
||||
"process filter applied: \"{}\" ({} match{})",
|
||||
new_filter,
|
||||
matches,
|
||||
if matches == 1 { "" } else { "es" }
|
||||
));
|
||||
}
|
||||
}
|
||||
Key::Esc if process_filter_input.is_some() => {
|
||||
process_filter_input = None;
|
||||
|
||||
@@ -25,6 +25,10 @@ pub struct MemInfo {
|
||||
pub free_kib: u64,
|
||||
/// Total swap in KiB (0 if no swap).
|
||||
pub swap_total_kib: u64,
|
||||
/// Free swap in KiB.
|
||||
pub swap_free_kib: u64,
|
||||
/// Swap cached in KiB.
|
||||
pub swap_cached_kib: u64,
|
||||
/// Used swap in KiB.
|
||||
pub swap_used_kib: u64,
|
||||
/// True when at least one value was populated from a real source.
|
||||
@@ -62,6 +66,18 @@ impl MemInfo {
|
||||
}
|
||||
(self.swap_used_kib as f64 / self.swap_total_kib as f64) * 100.0
|
||||
}
|
||||
pub fn swap_used(&self) -> u64 {
|
||||
self.swap_total_kib
|
||||
.saturating_sub(self.swap_free_kib)
|
||||
.saturating_sub(self.swap_cached_kib)
|
||||
.max(self.swap_used_kib)
|
||||
}
|
||||
pub fn swap_used_pct(&self) -> Option<f64> {
|
||||
if self.swap_total_kib == 0 {
|
||||
return None;
|
||||
}
|
||||
Some((self.swap_used() as f64 / self.swap_total_kib as f64) * 100.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read memory info. Tries Redox scheme first, then Linux `/proc/meminfo`.
|
||||
@@ -126,13 +142,23 @@ fn parse_meminfo_kv(s: &str, info: &mut MemInfo) -> bool {
|
||||
any = true;
|
||||
}
|
||||
"SwapFree" => {
|
||||
// derive SwapUsed = SwapTotal - SwapFree
|
||||
info.swap_used_kib = info.swap_total_kib.saturating_sub(v_kib);
|
||||
info.swap_free_kib = v_kib;
|
||||
any = true;
|
||||
}
|
||||
"SwapCached" => {
|
||||
info.swap_cached_kib = v_kib;
|
||||
any = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if info.swap_total_kib > 0 {
|
||||
let used = info
|
||||
.swap_total_kib
|
||||
.saturating_sub(info.swap_free_kib)
|
||||
.saturating_sub(info.swap_cached_kib);
|
||||
info.swap_used_kib = used;
|
||||
}
|
||||
// If MemTotal was parsed but MemFree wasn't, fall back to "used = total - free - buffers - cached"
|
||||
if info.used_kib == 0 && info.total_kib > 0 {
|
||||
info.used_kib = info
|
||||
@@ -256,3 +282,37 @@ pub fn format_uptime(secs: u64) -> String {
|
||||
format!("{s}s")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{MemInfo, parse_meminfo_kv};
|
||||
|
||||
#[test]
|
||||
fn parses_swap_fields_and_computes_usage() {
|
||||
let mut info = MemInfo::default();
|
||||
let meminfo = "\
|
||||
MemTotal: 8192 kB
|
||||
MemFree: 4096 kB
|
||||
Buffers: 512 kB
|
||||
Cached: 1024 kB
|
||||
SwapTotal: 2048 kB
|
||||
SwapFree: 1536 kB
|
||||
SwapCached: 256 kB
|
||||
";
|
||||
|
||||
assert!(parse_meminfo_kv(meminfo, &mut info));
|
||||
assert_eq!(info.swap_total_kib, 2048);
|
||||
assert_eq!(info.swap_free_kib, 1536);
|
||||
assert_eq!(info.swap_cached_kib, 256);
|
||||
assert_eq!(info.swap_used(), 256);
|
||||
assert_eq!(info.swap_used_kib, 256);
|
||||
assert_eq!(info.swap_used_pct(), Some(12.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_missing_swap_fields() {
|
||||
let info = MemInfo::default();
|
||||
assert_eq!(info.swap_used(), 0);
|
||||
assert_eq!(info.swap_used_pct(), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,17 +23,21 @@ use std::sync::Mutex;
|
||||
static MSR_FAILED: Mutex<Vec<bool>> = Mutex::new(Vec::new());
|
||||
|
||||
fn msr_failed_for(cpu: u32) -> bool {
|
||||
let cache = MSR_FAILED.lock().unwrap();
|
||||
cpu as usize >= cache.len() || cache[cpu as usize]
|
||||
if let Ok(cache) = MSR_FAILED.lock() {
|
||||
cpu as usize >= cache.len() || cache[cpu as usize]
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_msr_failed(cpu: u32) {
|
||||
let mut cache = MSR_FAILED.lock().unwrap();
|
||||
let idx = cpu as usize;
|
||||
if idx >= cache.len() {
|
||||
cache.resize(idx + 1, false);
|
||||
if let Ok(mut cache) = MSR_FAILED.lock() {
|
||||
let idx = cpu as usize;
|
||||
if idx >= cache.len() {
|
||||
cache.resize(idx + 1, false);
|
||||
}
|
||||
cache[idx] = true;
|
||||
}
|
||||
cache[idx] = true;
|
||||
}
|
||||
|
||||
/// Clear the MSR failure cache. Call periodically (every ~30s) so that
|
||||
@@ -261,7 +265,6 @@ pub fn read_hwp_status(cpu: u32) -> Option<u64> {
|
||||
/// Decoded view of `IA32_PACKAGE_THERM_STATUS`. Built from the raw MSR
|
||||
/// value in `app.rs:refresh()`. Empty/default when the MSR is unreadable.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
#[allow(dead_code)]
|
||||
pub struct PackageThermal {
|
||||
pub temp_c: Option<u32>,
|
||||
pub valid: bool,
|
||||
|
||||
@@ -8,9 +8,7 @@
|
||||
//! this module we use `/proc/net/if_inet6` for IPv6 (one line per
|
||||
//! address, easy to parse) and read IPv4 from a simpler source.
|
||||
//!
|
||||
//! On Redox, no equivalent scheme exists yet, so `read()` returns an
|
||||
//! empty `NetInfo` and the render layer shows
|
||||
//! `(no network interfaces detected)`.
|
||||
//! On Redox, network configuration is exposed through `/scheme/netcfg/`.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
@@ -34,6 +32,7 @@ pub struct NetInterface {
|
||||
pub rx_dropped: u64,
|
||||
pub tx_dropped: u64,
|
||||
pub ipv6_addrs: Vec<String>,
|
||||
pub ipv4_addrs: Vec<String>,
|
||||
pub rx_kbps: f64,
|
||||
pub tx_kbps: f64,
|
||||
}
|
||||
@@ -127,6 +126,70 @@ fn read_ipv6_addrs(iface_name: &str) -> Vec<String> {
|
||||
addrs
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
/// Read IPv4 addresses for a specific interface using libc getifaddrs.
|
||||
fn read_ipv4_addrs(iface_name: &str) -> Vec<String> {
|
||||
let mut addrs = Vec::new();
|
||||
unsafe {
|
||||
let mut ifap: *mut libc::ifaddrs = std::ptr::null_mut();
|
||||
if libc::getifaddrs(&mut ifap) != 0 {
|
||||
return addrs;
|
||||
}
|
||||
let mut cur = ifap;
|
||||
while !cur.is_null() {
|
||||
let ifa = &*cur;
|
||||
if let Ok(name) = std::ffi::CStr::from_ptr(ifa.ifa_name).to_str()
|
||||
&& name == iface_name
|
||||
{
|
||||
let sa = ifa.ifa_addr;
|
||||
if !sa.is_null() && (*sa).sa_family as i32 == libc::AF_INET {
|
||||
let sin = &*(sa as *const libc::sockaddr_in);
|
||||
let b = sin.sin_addr.s_addr.to_be_bytes();
|
||||
addrs.push(format!("{}.{}.{}.{}", b[0], b[1], b[2], b[3]));
|
||||
}
|
||||
}
|
||||
cur = ifa.ifa_next;
|
||||
}
|
||||
libc::freeifaddrs(ifap);
|
||||
}
|
||||
addrs
|
||||
}
|
||||
|
||||
#[cfg(target_os = "redox")]
|
||||
fn read_trimmed(path: &Path) -> Option<String> {
|
||||
fs::read_to_string(path)
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "redox")]
|
||||
fn read_u64(path: &Path) -> Option<u64> {
|
||||
read_trimmed(path)?.parse::<u64>().ok()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "redox")]
|
||||
fn read_ipv4_addrs(iface_name: &str) -> Vec<String> {
|
||||
let path = Path::new("/scheme/netcfg/ifaces")
|
||||
.join(iface_name)
|
||||
.join("addr/list");
|
||||
let Ok(content) = fs::read_to_string(path) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
content
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(|line| {
|
||||
line.split_once('/')
|
||||
.map(|(addr, _)| addr)
|
||||
.unwrap_or(line)
|
||||
.to_string()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn read_interface(name: &str, path: &Path) -> Option<NetInterface> {
|
||||
Some(NetInterface {
|
||||
name: name.to_string(),
|
||||
@@ -143,15 +206,29 @@ fn read_interface(name: &str, path: &Path) -> Option<NetInterface> {
|
||||
rx_dropped: read_sysfs_u64(&path.join("statistics/rx_dropped")).unwrap_or(0),
|
||||
tx_dropped: read_sysfs_u64(&path.join("statistics/tx_dropped")).unwrap_or(0),
|
||||
ipv6_addrs: read_ipv6_addrs(name),
|
||||
ipv4_addrs: read_ipv4_addrs(name),
|
||||
rx_kbps: 0.0,
|
||||
tx_kbps: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
impl NetInfo {
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn available() -> bool {
|
||||
Path::new(SYS_NET).is_dir()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "redox")]
|
||||
pub fn available() -> bool {
|
||||
Path::new("/scheme/netcfg/ifaces").is_dir()
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "redox")))]
|
||||
pub fn available() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn read() -> Self {
|
||||
let Ok(dirs) = fs::read_dir(SYS_NET) else {
|
||||
return Self::default();
|
||||
@@ -169,6 +246,54 @@ impl NetInfo {
|
||||
interfaces.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Self { interfaces }
|
||||
}
|
||||
|
||||
#[cfg(target_os = "redox")]
|
||||
pub fn read() -> Self {
|
||||
let root = Path::new("/scheme/netcfg/ifaces");
|
||||
let Ok(dirs) = fs::read_dir(root) else {
|
||||
return Self::default();
|
||||
};
|
||||
|
||||
let mut interfaces = Vec::new();
|
||||
for entry in dirs.flatten() {
|
||||
let path = entry.path();
|
||||
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let mac_address = read_trimmed(&path.join("mac"));
|
||||
let ipv4_addrs = read_ipv4_addrs(name);
|
||||
let operstate = read_trimmed(&path.join("state")).or_else(|| Some("up".to_string()));
|
||||
|
||||
interfaces.push(NetInterface {
|
||||
name: name.to_string(),
|
||||
operstate,
|
||||
speed_mbps: None,
|
||||
mac_address,
|
||||
mtu: None,
|
||||
rx_bytes: read_u64(&path.join("traffic/rx")).unwrap_or(0),
|
||||
tx_bytes: read_u64(&path.join("traffic/tx")).unwrap_or(0),
|
||||
rx_packets: 0,
|
||||
tx_packets: 0,
|
||||
rx_errors: 0,
|
||||
tx_errors: 0,
|
||||
rx_dropped: 0,
|
||||
tx_dropped: 0,
|
||||
ipv6_addrs: Vec::new(),
|
||||
ipv4_addrs,
|
||||
rx_kbps: 0.0,
|
||||
tx_kbps: 0.0,
|
||||
});
|
||||
}
|
||||
|
||||
interfaces.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Self { interfaces }
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "redox")))]
|
||||
pub fn read() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
/// Read interfaces and compute R/W throughput (KiB/s) for each
|
||||
/// based on delta of rx_bytes/tx_bytes vs previous read.
|
||||
pub fn read_with_throughput(prev: &NetInfo, dt_secs: f64) -> Self {
|
||||
@@ -236,6 +361,8 @@ mod tests {
|
||||
assert_eq!(iface.tx_bytes, 0);
|
||||
assert_eq!(iface.rx_packets, 0);
|
||||
assert_eq!(iface.tx_packets, 0);
|
||||
assert!(iface.ipv4_addrs.is_empty());
|
||||
assert!(iface.ipv6_addrs.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ use ratatui::{
|
||||
widgets::{Block, Borders, Clear, Paragraph, Widget},
|
||||
};
|
||||
|
||||
use crate::theme;
|
||||
use crate::theme::Theme;
|
||||
|
||||
const MIN_NICE: i32 = -20;
|
||||
const MAX_NICE: i32 = 19;
|
||||
@@ -55,8 +55,8 @@ impl NiceEdit {
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for &NiceEdit {
|
||||
fn render(self, area: Rect, buf: &mut ratatui::buffer::Buffer) {
|
||||
impl NiceEdit {
|
||||
pub fn render_dialog(&self, area: Rect, buf: &mut ratatui::buffer::Buffer, theme: &Theme) {
|
||||
let width = 48u16.min(area.width);
|
||||
let height = 9u16.min(area.height);
|
||||
let x = area.x + (area.width - width) / 2;
|
||||
@@ -87,7 +87,7 @@ impl Widget for &NiceEdit {
|
||||
let info = Paragraph::new(vec![
|
||||
Line::from(Span::styled(
|
||||
format!(" PID {} {}", self.pid, self.comm),
|
||||
theme::VALUE,
|
||||
theme.value,
|
||||
)),
|
||||
Line::from(""),
|
||||
]);
|
||||
@@ -109,11 +109,11 @@ impl Widget for &NiceEdit {
|
||||
Span::styled(
|
||||
bar,
|
||||
if self.value < 0 {
|
||||
Style::default().fg(theme::STATUS_OK.fg.unwrap_or_default())
|
||||
Style::default().fg(theme.status_ok.fg.unwrap_or_default())
|
||||
} else if self.value > 0 {
|
||||
Style::default().fg(theme::STATUS_WARN.fg.unwrap_or_default())
|
||||
Style::default().fg(theme.status_warn.fg.unwrap_or_default())
|
||||
} else {
|
||||
theme::VALUE
|
||||
theme.value
|
||||
},
|
||||
),
|
||||
Span::raw("]"),
|
||||
@@ -128,14 +128,14 @@ impl Widget for &NiceEdit {
|
||||
match res {
|
||||
Ok(()) => Line::from(Span::styled(
|
||||
" Applied \u{2014} press Esc to close",
|
||||
theme::STATUS_OK,
|
||||
theme.status_ok,
|
||||
)),
|
||||
Err(e) => Line::from(Span::styled(format!(" Error: {}", e), theme::STATUS_ERR)),
|
||||
Err(e) => Line::from(Span::styled(format!(" Error: {}", e), theme.status_err)),
|
||||
}
|
||||
} else {
|
||||
Line::from(Span::styled(
|
||||
" \u{2191}\u{2193}/+- adjust, Enter=apply, Esc=close",
|
||||
theme::VALUE_OFF,
|
||||
theme.value_off,
|
||||
))
|
||||
};
|
||||
Widget::render(Paragraph::new(msg), msg_area, buf);
|
||||
|
||||
@@ -11,16 +11,33 @@
|
||||
//! (truncated to 15 chars + newline) — used as a fallback if the
|
||||
//! parens-parsing fails.
|
||||
//!
|
||||
//! On Redox, no equivalent scheme exists yet, so `read()` returns an
|
||||
//! empty `ProcInfo` and the render layer shows
|
||||
//! `(no processes detected)`.
|
||||
//! Process-management mutations (`kill`, `set_nice`, `set_affinity`) are
|
||||
//! implemented on Linux via `libc` and on Redox via `libredox` and the
|
||||
//! `/scheme/proc` proc scheme. Process enumeration still relies on
|
||||
//! `/proc/[pid]/stat`, which is available on both platforms.
|
||||
|
||||
use std::fs;
|
||||
|
||||
#[cfg(target_os = "redox")]
|
||||
use libredox::protocol::ProcCall;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const MAX_PROCESSES: usize = 50;
|
||||
|
||||
/// Return the number of clock ticks per second for the current
|
||||
/// process. On Linux this comes from `sysconf(_SC_CLK_TCK)`;
|
||||
/// on other targets we assume 100 (the Linux default).
|
||||
fn clock_ticks_per_second() -> u64 {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
unsafe { libc::sysconf(libc::_SC_CLK_TCK) as u64 }
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
100
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum SortMode {
|
||||
#[default]
|
||||
@@ -37,6 +54,8 @@ pub enum SortMode {
|
||||
VSize,
|
||||
Pid,
|
||||
Name,
|
||||
/// Cumulative CPU time (utime + stime), formatted as TIME+.
|
||||
Time,
|
||||
/// Per-thread IO total (read + write), aggregated across
|
||||
/// all threads. v1.41. Distinct from `Io` (process total)
|
||||
/// because the Linux kernel attributes all IO to the
|
||||
@@ -64,7 +83,8 @@ impl SortMode {
|
||||
SortMode::IoWriteRate => SortMode::RChar,
|
||||
SortMode::RChar => SortMode::WChar,
|
||||
SortMode::WChar => SortMode::VSize,
|
||||
SortMode::VSize => SortMode::Pid,
|
||||
SortMode::VSize => SortMode::Time,
|
||||
SortMode::Time => SortMode::Pid,
|
||||
SortMode::Pid => SortMode::Name,
|
||||
SortMode::Name => SortMode::Rss,
|
||||
SortMode::ThreadIo => SortMode::ThreadIoR,
|
||||
@@ -87,6 +107,7 @@ impl SortMode {
|
||||
SortMode::VSize => "VSZ",
|
||||
SortMode::Pid => "PID",
|
||||
SortMode::Name => "Name",
|
||||
SortMode::Time => "TIME+",
|
||||
SortMode::ThreadIo => "T-IO",
|
||||
SortMode::ThreadIoR => "T-IO-R",
|
||||
SortMode::ThreadIoW => "T-IO-W",
|
||||
@@ -137,6 +158,7 @@ impl SortMode {
|
||||
SortMode::VSize => processes.sort_by_key(|a| a.vsize_kb),
|
||||
SortMode::Pid => processes.sort_by_key(|p| p.pid),
|
||||
SortMode::Name => processes.sort_by(|a, b| a.comm.cmp(&b.comm)),
|
||||
SortMode::Time => processes.sort_by_key(|a| a.total_cpu_ticks()),
|
||||
SortMode::ThreadIo => sort_by_io_field_asc(processes, |p| {
|
||||
match (p.thread_io_read_kb, p.thread_io_write_kb) {
|
||||
(Some(r), Some(w)) => Some(r.saturating_add(w)),
|
||||
@@ -174,6 +196,7 @@ impl SortMode {
|
||||
SortMode::VSize => processes.sort_by_key(|b| std::cmp::Reverse(b.vsize_kb)),
|
||||
SortMode::Pid => processes.sort_by_key(|p| p.pid),
|
||||
SortMode::Name => processes.sort_by(|a, b| a.comm.cmp(&b.comm)),
|
||||
SortMode::Time => processes.sort_by_key(|b| std::cmp::Reverse(b.total_cpu_ticks())),
|
||||
SortMode::ThreadIo => sort_by_io_field(processes, |p| {
|
||||
match (p.thread_io_read_kb, p.thread_io_write_kb) {
|
||||
(Some(r), Some(w)) => Some(r.saturating_add(w)),
|
||||
@@ -466,11 +489,44 @@ pub struct ProcessInfo {
|
||||
pub sched_policy: String,
|
||||
}
|
||||
|
||||
/// Return true when a process is a kernel thread.
|
||||
pub fn is_kernel_thread(proc: &ProcessInfo) -> bool {
|
||||
proc.ppid == 2 || proc.comm.starts_with('[')
|
||||
}
|
||||
|
||||
pub(crate) fn visible_processes<'a>(
|
||||
processes: &'a [ProcessInfo],
|
||||
process_filter: &str,
|
||||
hide_kernel_threads: bool,
|
||||
) -> Vec<&'a ProcessInfo> {
|
||||
let filter_lower = process_filter.to_lowercase();
|
||||
processes
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
(!hide_kernel_threads || !is_kernel_thread(p))
|
||||
&& (filter_lower.is_empty() || p.comm.to_lowercase().contains(&filter_lower))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl ProcessInfo {
|
||||
pub fn total_cpu_ticks(&self) -> u64 {
|
||||
self.utime.saturating_add(self.stime)
|
||||
}
|
||||
|
||||
/// Format cumulative CPU ticks as a TIME+ string
|
||||
/// (`MM:SS.hh`, matching htop). Ticks are converted
|
||||
/// using the host's `_SC_CLK_TCK` (100 on most Linux
|
||||
/// kernels).
|
||||
pub fn format_time_plus(ticks: u64) -> String {
|
||||
let ticks_per_sec = clock_ticks_per_second().max(1);
|
||||
let total_secs = ticks / ticks_per_sec;
|
||||
let hundredths = ((ticks % ticks_per_sec) * 100 / ticks_per_sec) % 100;
|
||||
let minutes = total_secs / 60;
|
||||
let seconds = total_secs % 60;
|
||||
format!("{}:{:02}.{:02}", minutes, seconds, hundredths)
|
||||
}
|
||||
|
||||
/// Total IO bytes (read + write) in KiB. Returns `None` if either
|
||||
/// field is `None` — the panel renders the row as `—` instead of
|
||||
/// silently zeroing a hidden counter. Used by `SortMode::Io` and
|
||||
@@ -970,6 +1026,30 @@ impl ProcInfo {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn mock_process(pid: u32, ppid: u32, comm: &str) -> ProcessInfo {
|
||||
ProcessInfo {
|
||||
pid,
|
||||
ppid,
|
||||
comm: comm.to_string(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_kernel_thread_matches_ppid_2() {
|
||||
assert!(is_kernel_thread(&mock_process(42, 2, "kworker/0:1")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_kernel_thread_matches_bracketed_comm() {
|
||||
assert!(is_kernel_thread(&mock_process(43, 1, "[kthreadd]")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_kernel_thread_ignores_regular_processes() {
|
||||
assert!(!is_kernel_thread(&mock_process(44, 1, "bash")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_memory_below_1kib() {
|
||||
assert_eq!(ProcessInfo::format_memory_kb(500), "500.0 KiB");
|
||||
@@ -1110,6 +1190,15 @@ mod sort_unit_tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn make_proc_with_time(pid: u32, utime: u64, stime: u64) -> ProcessInfo {
|
||||
ProcessInfo {
|
||||
pid,
|
||||
utime,
|
||||
stime,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_default_is_rss_descending() {
|
||||
assert_eq!(SortMode::default(), SortMode::Rss);
|
||||
@@ -1127,11 +1216,25 @@ mod sort_unit_tests {
|
||||
assert_eq!(SortMode::IoWriteRate.next(), SortMode::RChar);
|
||||
assert_eq!(SortMode::RChar.next(), SortMode::WChar);
|
||||
assert_eq!(SortMode::WChar.next(), SortMode::VSize);
|
||||
assert_eq!(SortMode::VSize.next(), SortMode::Pid);
|
||||
assert_eq!(SortMode::VSize.next(), SortMode::Time);
|
||||
assert_eq!(SortMode::Time.next(), SortMode::Pid);
|
||||
assert_eq!(SortMode::Pid.next(), SortMode::Name);
|
||||
assert_eq!(SortMode::Name.next(), SortMode::Rss);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_by_time_descending() {
|
||||
let mut ps = vec![
|
||||
make_proc_with_time(1, 100, 50),
|
||||
make_proc_with_time(2, 500, 100),
|
||||
make_proc_with_time(3, 300, 0),
|
||||
];
|
||||
SortMode::Time.sort(&mut ps);
|
||||
assert_eq!(ps[0].pid, 2);
|
||||
assert_eq!(ps[1].pid, 3);
|
||||
assert_eq!(ps[2].pid, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_by_rss_descending() {
|
||||
let mut ps = vec![
|
||||
@@ -1187,7 +1290,6 @@ mod sort_unit_tests {
|
||||
|
||||
#[cfg(test)]
|
||||
mod filter_unit_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn filter_case_insensitive() {
|
||||
@@ -1302,7 +1404,8 @@ mod io_sort_unit_tests {
|
||||
assert_eq!(SortMode::IoWriteRate.next(), SortMode::RChar);
|
||||
assert_eq!(SortMode::RChar.next(), SortMode::WChar);
|
||||
assert_eq!(SortMode::WChar.next(), SortMode::VSize);
|
||||
assert_eq!(SortMode::VSize.next(), SortMode::Pid);
|
||||
assert_eq!(SortMode::VSize.next(), SortMode::Time);
|
||||
assert_eq!(SortMode::Time.next(), SortMode::Pid);
|
||||
assert_eq!(SortMode::Pid.next(), SortMode::Name);
|
||||
assert_eq!(SortMode::Name.next(), SortMode::Rss);
|
||||
}
|
||||
@@ -2011,10 +2114,24 @@ pub fn set_nice(pid: u32, nice: i32) -> Result<(), String> {
|
||||
Err(std::io::Error::last_os_error().to_string())
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[cfg(target_os = "redox")]
|
||||
{
|
||||
let _ = (pid, n);
|
||||
Err("setpriority not supported on this platform".into())
|
||||
let kernel_prio = 20 + n;
|
||||
let fd = libredox::call::open("/scheme/proc", 0, 0).map_err(|e| e.to_string())?;
|
||||
let result = libredox::call::call_wo(
|
||||
fd,
|
||||
&[],
|
||||
syscall::CallFlags::empty(),
|
||||
&[
|
||||
ProcCall::SetProcPriority as u64,
|
||||
pid as u64,
|
||||
kernel_prio as u64,
|
||||
],
|
||||
)
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string());
|
||||
let close_result = libredox::call::close(fd).map_err(|e| e.to_string());
|
||||
result.and(close_result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2038,9 +2155,19 @@ pub fn set_affinity(pid: u32, cpus: &[u32]) -> Result<(), String> {
|
||||
Err(std::io::Error::last_os_error().to_string())
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[cfg(target_os = "redox")]
|
||||
{
|
||||
let _ = (pid, cpus);
|
||||
Err("sched_setaffinity not supported on this platform".into())
|
||||
let mut mask = [0u64; 2];
|
||||
for &cpu in cpus {
|
||||
if (cpu as usize) < 128 {
|
||||
let idx = (cpu as usize) / 64;
|
||||
let bit = (cpu as usize) % 64;
|
||||
mask[idx] |= 1u64 << bit;
|
||||
}
|
||||
}
|
||||
let mut bytes = [0u8; 16];
|
||||
bytes[..8].copy_from_slice(&mask[0].to_le_bytes());
|
||||
bytes[8..].copy_from_slice(&mask[1].to_le_bytes());
|
||||
std::fs::write(format!("/proc/{pid}/sched-affinity"), bytes).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,31 @@
|
||||
|
||||
use std::fs;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct CpuTimeBreakdown {
|
||||
pub user: u64,
|
||||
pub nice: u64,
|
||||
pub system: u64,
|
||||
pub idle: u64,
|
||||
pub iowait: u64,
|
||||
pub irq: u64,
|
||||
pub softirq: u64,
|
||||
pub steal: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct CpuUsagePct {
|
||||
pub total: f64,
|
||||
pub user: f64,
|
||||
pub nice: f64,
|
||||
pub system: f64,
|
||||
pub idle: f64,
|
||||
pub iowait: f64,
|
||||
pub irq: f64,
|
||||
pub softirq: f64,
|
||||
pub steal: f64,
|
||||
}
|
||||
|
||||
/// Scheduler and IRQ statistics. Fields are `Option` because the
|
||||
/// underlying data source may not provide all of them (Linux vs
|
||||
/// Redox). `Default` gives all-`None` / empty, suitable as a
|
||||
@@ -29,6 +54,7 @@ pub struct SchedStats {
|
||||
pub per_cpu_switches: Vec<u64>,
|
||||
pub per_cpu_steals: Vec<u64>,
|
||||
pub per_cpu_queue_depth: Vec<u64>,
|
||||
pub per_cpu_time_breakdown: Vec<CpuTimeBreakdown>,
|
||||
}
|
||||
|
||||
impl SchedStats {
|
||||
@@ -108,6 +134,25 @@ impl SchedStats {
|
||||
let mut stats = Self::default();
|
||||
for line in data.lines() {
|
||||
let trimmed = line.trim();
|
||||
if let Some(rest) = trimmed.strip_prefix("cpu") {
|
||||
let mut parts = rest.split_whitespace();
|
||||
let cpu_id = parts.next();
|
||||
if cpu_id.is_some_and(|s| s.chars().all(|c| c.is_ascii_digit())) {
|
||||
let values: Vec<u64> = parts.map(|s| s.parse().unwrap_or(0)).collect();
|
||||
if values.len() >= 8 {
|
||||
stats.per_cpu_time_breakdown.push(CpuTimeBreakdown {
|
||||
user: values[0],
|
||||
nice: values[1],
|
||||
system: values[2],
|
||||
idle: values[3],
|
||||
iowait: values[4],
|
||||
irq: values[5],
|
||||
softirq: values[6],
|
||||
steal: values[7],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// "ctxt <number>" — context switches since boot.
|
||||
if let Some(rest) = trimmed.strip_prefix("ctxt ") {
|
||||
stats.context_switches = rest.trim().parse().ok();
|
||||
@@ -141,6 +186,56 @@ impl SchedStats {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn breakdown_delta(prev: &[CpuTimeBreakdown], next: &[CpuTimeBreakdown]) -> Vec<CpuUsagePct> {
|
||||
prev.iter()
|
||||
.zip(next.iter())
|
||||
.map(|(prev, next)| {
|
||||
let user = next.user.saturating_sub(prev.user);
|
||||
let nice = next.nice.saturating_sub(prev.nice);
|
||||
let system = next.system.saturating_sub(prev.system);
|
||||
let idle = next.idle.saturating_sub(prev.idle);
|
||||
let iowait = next.iowait.saturating_sub(prev.iowait);
|
||||
let irq = next.irq.saturating_sub(prev.irq);
|
||||
let softirq = next.softirq.saturating_sub(prev.softirq);
|
||||
let steal = next.steal.saturating_sub(prev.steal);
|
||||
let total = user + nice + system + idle + iowait + irq + softirq + steal;
|
||||
let pct = |v: u64| {
|
||||
if total == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(v as f64) * 100.0 / (total as f64)
|
||||
}
|
||||
};
|
||||
CpuUsagePct {
|
||||
total: if total == 0 {
|
||||
0.0
|
||||
} else {
|
||||
100.0 - pct(idle) - pct(iowait)
|
||||
},
|
||||
user: pct(user),
|
||||
nice: pct(nice),
|
||||
system: pct(system),
|
||||
idle: pct(idle),
|
||||
iowait: pct(iowait),
|
||||
irq: pct(irq),
|
||||
softirq: pct(softirq),
|
||||
steal: pct(steal),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Convert cumulative scheduler time counters into per-CPU percentages.
|
||||
/// If the previous sample is missing or shorter than `next`, the missing
|
||||
/// entries are skipped and the returned vector is truncated to the
|
||||
/// shared prefix, mirroring the existing process of `zip`-based deltas.
|
||||
pub fn time_breakdown_delta(
|
||||
prev: &[CpuTimeBreakdown],
|
||||
next: &[CpuTimeBreakdown],
|
||||
) -> Vec<CpuUsagePct> {
|
||||
breakdown_delta(prev, next)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -227,4 +322,85 @@ total switches 3000 steals 7
|
||||
assert_eq!(stats.per_cpu_steals, vec![5, 2]);
|
||||
assert_eq!(stats.per_cpu_queue_depth, vec![3, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_linux_cpu_breakdowns() {
|
||||
let data = "\
|
||||
cpu 100 200 300 400 500 600 700 800
|
||||
cpu0 10 20 30 40 50 60 70 80
|
||||
cpu1 1 2 3 4 5 6 7 8
|
||||
ctxt 1
|
||||
";
|
||||
let stats = SchedStats::parse_linux(data);
|
||||
assert_eq!(stats.per_cpu_time_breakdown.len(), 2);
|
||||
assert_eq!(stats.per_cpu_time_breakdown[0].user, 10);
|
||||
assert_eq!(stats.per_cpu_time_breakdown[0].steal, 80);
|
||||
assert_eq!(stats.per_cpu_time_breakdown[1].nice, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn breakdown_delta_calculates_percentages() {
|
||||
let prev = vec![CpuTimeBreakdown {
|
||||
user: 10,
|
||||
nice: 0,
|
||||
system: 10,
|
||||
idle: 80,
|
||||
iowait: 0,
|
||||
irq: 0,
|
||||
softirq: 0,
|
||||
steal: 0,
|
||||
}];
|
||||
let next = vec![CpuTimeBreakdown {
|
||||
user: 30,
|
||||
nice: 10,
|
||||
system: 20,
|
||||
idle: 100,
|
||||
iowait: 5,
|
||||
irq: 5,
|
||||
softirq: 10,
|
||||
steal: 0,
|
||||
}];
|
||||
let delta = breakdown_delta(&prev, &next);
|
||||
assert_eq!(delta.len(), 1);
|
||||
let d = &delta[0];
|
||||
assert!((d.total - 68.75).abs() < 1e-9);
|
||||
assert!((d.user - 25.0).abs() < 1e-9);
|
||||
assert!((d.nice - 12.5).abs() < 1e-9);
|
||||
assert!((d.system - 12.5).abs() < 1e-9);
|
||||
assert!((d.idle - 25.0).abs() < 1e-9);
|
||||
assert!((d.iowait - 6.25).abs() < 1e-9);
|
||||
assert!((d.irq - 6.25).abs() < 1e-9);
|
||||
assert!((d.softirq - 12.5).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_breakdown_delta_tracks_cpu_state_mix() {
|
||||
let prev = vec![CpuTimeBreakdown {
|
||||
user: 100,
|
||||
nice: 10,
|
||||
system: 30,
|
||||
idle: 200,
|
||||
iowait: 20,
|
||||
irq: 4,
|
||||
softirq: 6,
|
||||
steal: 1,
|
||||
}];
|
||||
let next = vec![CpuTimeBreakdown {
|
||||
user: 130,
|
||||
nice: 10,
|
||||
system: 40,
|
||||
idle: 230,
|
||||
iowait: 20,
|
||||
irq: 8,
|
||||
softirq: 10,
|
||||
steal: 1,
|
||||
}];
|
||||
let pct = time_breakdown_delta(&prev, &next);
|
||||
assert_eq!(pct.len(), 1);
|
||||
assert!((pct[0].user - 38.461_538).abs() < 0.01);
|
||||
assert!((pct[0].system - 12.820_512).abs() < 0.01);
|
||||
assert!((pct[0].idle - 38.461_538).abs() < 0.01);
|
||||
assert!((pct[0].irq - 5.128_205).abs() < 0.01);
|
||||
assert!((pct[0].softirq - 5.128_205).abs() < 0.01);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Hardware sensor readings via `sysfs` (`/sys/class/hwmon/hwmonN/*`).
|
||||
//! Hardware sensor readings.
|
||||
//!
|
||||
//! Linux exposes hardware monitoring chips via the `hwmon` class. Each
|
||||
//! chip (CPU temp sensor, NVMe controller, RAM SPD, NIC PHY, etc.)
|
||||
//! On Linux the primary source is `sysfs` (`/sys/class/hwmon/hwmonN/*`).
|
||||
//! Each chip (CPU temp sensor, NVMe controller, RAM SPD, NIC PHY, etc.)
|
||||
//! gets its own directory at `/sys/class/hwmon/hwmonN/`. Inside:
|
||||
//! - `name` — chip identifier (k10temp, coretemp, nvme, etc.)
|
||||
//! - `temp*_input` — temperature in milli-Celsius (divide by 1000 for °C)
|
||||
@@ -11,15 +11,16 @@
|
||||
//! - `curr*_input` — current in milli-Amps
|
||||
//! - `*_label` — human-readable label for the corresponding `_input`
|
||||
//!
|
||||
//! On Redox, no equivalent scheme exists yet, so `read_sensors()` returns
|
||||
//! an empty `Vec` and the render layer shows `(no sensors detected)`.
|
||||
//! Forward work: implement a `hwmon` scheme daemon in `redox-driver-sys`
|
||||
//! that exposes parsed sensor data via `/scheme/hwmon/<chip>/...`.
|
||||
//! On Redox, the `hwmon` scheme is not yet implemented, so when the sysfs
|
||||
//! path is absent the module falls back to reading the CPU package
|
||||
//! temperature via the `IA32_THERM_STATUS` MSR exposed through
|
||||
//! `/scheme/sys/msr/`.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const SYS_HWMON: &str = "/sys/class/hwmon";
|
||||
const REDOX_THERMAL: &str = "/scheme/thermal";
|
||||
|
||||
#[derive(Default, Clone, Debug)]
|
||||
pub struct SensorReading {
|
||||
@@ -89,6 +90,91 @@ fn read_sysfs_i64(path: &Path) -> Option<i64> {
|
||||
read_sysfs(path)?.parse::<i64>().ok()
|
||||
}
|
||||
|
||||
pub fn read_msr(cpu: u32, msr: u32) -> Option<u64> {
|
||||
let path = format!("/scheme/sys/msr/{}/0x{:x}", cpu, msr);
|
||||
let mut data = [0u8; 8];
|
||||
let mut file = fs::File::open(path).ok()?;
|
||||
use std::io::Read;
|
||||
file.read_exact(&mut data).ok()?;
|
||||
Some(u64::from_le_bytes(data))
|
||||
}
|
||||
|
||||
pub fn read_cpu_temp_msr(cpu: u32) -> Option<f64> {
|
||||
const IA32_THERM_STATUS: u32 = 0x19c;
|
||||
const IA32_TEMPERATURE_TARGET: u32 = 0x1a2;
|
||||
|
||||
let therm_status = read_msr(cpu, IA32_THERM_STATUS)?;
|
||||
if therm_status & (1 << 31) == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let digital_readout = ((therm_status >> 16) & 0x7f) as f64;
|
||||
let tjmax = read_msr(cpu, IA32_TEMPERATURE_TARGET)
|
||||
.map(|raw| ((raw >> 16) & 0xff) as f64)
|
||||
.unwrap_or(100.0);
|
||||
Some(tjmax - digital_readout)
|
||||
}
|
||||
|
||||
fn read_first_cpu_temp_msr() -> Option<f64> {
|
||||
for cpu in 0..256 {
|
||||
if let Some(temp) = read_cpu_temp_msr(cpu) {
|
||||
return Some(temp);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Read a Redox thermal zone temperature file.
|
||||
fn read_redox_zone_temp(zone_name: &str) -> Option<i64> {
|
||||
let path = Path::new(REDOX_THERMAL)
|
||||
.join("zones")
|
||||
.join(zone_name)
|
||||
.join("temperature");
|
||||
read_sysfs_i64(&path)
|
||||
}
|
||||
|
||||
/// Read a Redox thermal zone status file.
|
||||
fn read_redox_zone_status(zone_name: &str) -> Option<String> {
|
||||
let path = Path::new(REDOX_THERMAL)
|
||||
.join("zones")
|
||||
.join(zone_name)
|
||||
.join("status");
|
||||
read_sysfs(&path)
|
||||
}
|
||||
|
||||
/// Enumerate Redox `/scheme/thermal/zones/` directories and return
|
||||
/// one `HwmonChip`-like entry per zone with a temperature reading.
|
||||
fn read_redox_thermal_zones() -> Vec<HwmonChip> {
|
||||
let zones_root = Path::new(REDOX_THERMAL).join("zones");
|
||||
let Ok(entries) = fs::read_dir(&zones_root) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut chips = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
let zone_name = match entry.file_name().into_string() {
|
||||
Ok(n) => n,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let Some(temp_raw) = read_redox_zone_temp(&zone_name) else {
|
||||
continue;
|
||||
};
|
||||
let label = read_redox_zone_status(&zone_name)
|
||||
.map(|s| format!("{} ({})", zone_name, s))
|
||||
.unwrap_or_else(|| zone_name.clone());
|
||||
chips.push(HwmonChip {
|
||||
name: "thermal".to_string(),
|
||||
path: zones_root.join(&zone_name),
|
||||
readings: vec![SensorReading {
|
||||
kind: SensorKind::Temp,
|
||||
label: Some(label),
|
||||
raw_value: temp_raw,
|
||||
display_value: format_sensor(SensorKind::Temp, temp_raw),
|
||||
}],
|
||||
});
|
||||
}
|
||||
chips
|
||||
}
|
||||
|
||||
/// Read all `*_input` files in the chip directory, grouped by prefix.
|
||||
fn read_chip_readings(chip_dir: &Path) -> Vec<SensorReading> {
|
||||
let entries = match fs::read_dir(chip_dir) {
|
||||
@@ -154,9 +240,31 @@ fn read_chip_readings(chip_dir: &Path) -> Vec<SensorReading> {
|
||||
impl SensorInfo {
|
||||
pub fn available() -> bool {
|
||||
Path::new(SYS_HWMON).is_dir()
|
||||
|| Path::new(REDOX_THERMAL).exists()
|
||||
|| read_first_cpu_temp_msr().is_some()
|
||||
}
|
||||
pub fn read() -> Self {
|
||||
if Path::new(REDOX_THERMAL).exists() {
|
||||
let chips = read_redox_thermal_zones();
|
||||
if !chips.is_empty() {
|
||||
return Self { chips };
|
||||
}
|
||||
}
|
||||
let Ok(dirs) = fs::read_dir(SYS_HWMON) else {
|
||||
if let Some(temp_c) = read_first_cpu_temp_msr() {
|
||||
return Self {
|
||||
chips: vec![HwmonChip {
|
||||
name: "msr".to_string(),
|
||||
path: PathBuf::from("/scheme/sys/msr"),
|
||||
readings: vec![SensorReading {
|
||||
kind: SensorKind::Temp,
|
||||
label: Some("Package".to_string()),
|
||||
raw_value: (temp_c * 1000.0).round() as i64,
|
||||
display_value: format!("{:.1} °C", temp_c),
|
||||
}],
|
||||
}],
|
||||
};
|
||||
}
|
||||
return Self::default();
|
||||
};
|
||||
let mut chips = Vec::new();
|
||||
@@ -230,6 +338,13 @@ impl SensorInfo {
|
||||
}
|
||||
}
|
||||
}
|
||||
"msr" => {
|
||||
for r in &chip.readings {
|
||||
if r.kind == SensorKind::Temp && r.label.as_deref() == Some("Package") {
|
||||
return Some((r.raw_value / 1000) as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -357,4 +472,20 @@ mod tests {
|
||||
});
|
||||
assert_eq!(info.pkg_temp_c(0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pkg_temp_c_from_msr_package_sensor() {
|
||||
let mut info = SensorInfo::default();
|
||||
info.chips.push(HwmonChip {
|
||||
name: "msr".to_string(),
|
||||
path: PathBuf::from("/scheme/sys/msr"),
|
||||
readings: vec![SensorReading {
|
||||
kind: SensorKind::Temp,
|
||||
label: Some("Package".to_string()),
|
||||
raw_value: 47000,
|
||||
display_value: "47.0 °C".to_string(),
|
||||
}],
|
||||
});
|
||||
assert_eq!(info.pkg_temp_c(0), Some(47));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,15 @@ pub struct SessionState {
|
||||
/// Show full command line instead of comm name.
|
||||
#[serde(default)]
|
||||
pub show_full_cmdline: bool,
|
||||
/// Narrow process view (hide IO columns for small terminals).
|
||||
#[serde(default)]
|
||||
pub process_narrow: bool,
|
||||
/// Last refresh interval in milliseconds (0 = not set, use config/default).
|
||||
#[serde(default)]
|
||||
pub refresh_ms: u64,
|
||||
/// Hide kernel threads in the Process tab.
|
||||
#[serde(default)]
|
||||
pub hide_kernel_threads: bool,
|
||||
}
|
||||
|
||||
impl Default for SessionState {
|
||||
@@ -57,6 +66,9 @@ impl Default for SessionState {
|
||||
folded: Vec::new(),
|
||||
process_filter: String::new(),
|
||||
show_full_cmdline: false,
|
||||
process_narrow: false,
|
||||
refresh_ms: 0,
|
||||
hide_kernel_threads: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,6 +162,9 @@ mod tests {
|
||||
assert!(!s.process_tree);
|
||||
assert!(s.folded.is_empty());
|
||||
assert!(s.process_filter.is_empty());
|
||||
assert!(!s.show_full_cmdline);
|
||||
assert!(!s.process_narrow);
|
||||
assert_eq!(s.refresh_ms, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -165,6 +180,9 @@ mod tests {
|
||||
folded: vec![100, 200, 300],
|
||||
process_filter: "kworker".to_string(),
|
||||
show_full_cmdline: true,
|
||||
process_narrow: true,
|
||||
refresh_ms: 1_500,
|
||||
hide_kernel_threads: false,
|
||||
};
|
||||
let serialized = toml::to_string(&s).unwrap();
|
||||
let parsed: SessionState = toml::from_str(&serialized).unwrap();
|
||||
@@ -224,6 +242,9 @@ mod tests {
|
||||
folded: vec![42],
|
||||
process_filter: "bash".to_string(),
|
||||
show_full_cmdline: false,
|
||||
process_narrow: false,
|
||||
refresh_ms: 2_000,
|
||||
hide_kernel_threads: false,
|
||||
};
|
||||
let serialized = toml::to_string(&s).unwrap();
|
||||
// Mimic the temp+rename flow with a different name
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
//! Block device storage info via `sysfs` (`/sys/block/<dev>/`).
|
||||
//! Block device storage info.
|
||||
//!
|
||||
//! Linux exposes block device metadata via sysfs: model, vendor, size
|
||||
//! (in 512-byte sectors), rotational flag, removable flag, IO
|
||||
//! scheduler, queue depth, and per-partition layout.
|
||||
//!
|
||||
//! Traffic counters come from `/sys/block/<dev>/stat`:
|
||||
//! read_bytes / write_bytes — total bytes transferred
|
||||
//! reads_completed / writes_completed — I/O operation counts
|
||||
//!
|
||||
//! SMART data (Temperature, ReallocatedSectorsCount, WearLevelingCount,
|
||||
//! etc.) is read via `smartctl --json` if the binary is in PATH.
|
||||
//! Otherwise the SMART section is omitted — per the zero-stub policy.
|
||||
//!
|
||||
//! On Redox, no equivalent scheme exists yet, so `read()` returns an
|
||||
//! empty `StorageInfo` and the render layer shows
|
||||
//! `(no storage devices detected)`.
|
||||
//! On Linux the primary source is `/sys/block/<dev>/` (model, vendor,
|
||||
//! size, rotational/removable flags, scheduler, queue depth, and
|
||||
//! per-device `stat` counters). On Redox, the disk aggregator daemon
|
||||
//! exposes block devices through `/scheme/diskd/`. Device metadata such
|
||||
//! as model, vendor, and exact size are not currently available through
|
||||
//! that scheme; only device names are enumerated, with I/O stats
|
||||
//! remaining unavailable. Linux remains the fully-featured source.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const SYS_BLOCK: &str = "/sys/block";
|
||||
const REDOX_DISKD: &str = "/scheme/diskd";
|
||||
|
||||
#[derive(Default, Clone, Debug)]
|
||||
pub struct DiskStats {
|
||||
@@ -126,6 +119,25 @@ fn read_disk(name: &str, path: &Path) -> Option<DiskInfo> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Read a Redox diskd entry. The disk aggregator scheme exposes block
|
||||
/// device names but does not currently provide size, model, vendor, or I/O
|
||||
/// counters; those fields are left as sensible defaults.
|
||||
fn read_redox_disk(name: &str, path: &Path) -> DiskInfo {
|
||||
DiskInfo {
|
||||
name: name.to_string(),
|
||||
path: path.to_path_buf(),
|
||||
model: None,
|
||||
vendor: None,
|
||||
size_bytes: 0,
|
||||
rotational: false,
|
||||
removable: false,
|
||||
scheduler: None,
|
||||
queue_depth: None,
|
||||
stats: DiskStats::default(),
|
||||
partitions: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
impl DiskInfo {
|
||||
/// Format bytes with binary unit suffixes (B, KiB, MiB, GiB, TiB).
|
||||
pub fn format_size(bytes: u64) -> String {
|
||||
@@ -160,9 +172,12 @@ pub struct StorageInfo {
|
||||
|
||||
impl StorageInfo {
|
||||
pub fn available() -> bool {
|
||||
Path::new(SYS_BLOCK).is_dir()
|
||||
Path::new(SYS_BLOCK).is_dir() || Path::new(REDOX_DISKD).is_dir()
|
||||
}
|
||||
pub fn read() -> Self {
|
||||
if Path::new(REDOX_DISKD).is_dir() {
|
||||
return Self::read_redox();
|
||||
}
|
||||
let Ok(dirs) = fs::read_dir(SYS_BLOCK) else {
|
||||
return Self::default();
|
||||
};
|
||||
@@ -179,6 +194,24 @@ impl StorageInfo {
|
||||
disks.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Self { disks }
|
||||
}
|
||||
|
||||
/// Read diskd device names on Redox. The disk aggregator scheme
|
||||
/// enumerates block devices but does not expose size/model/stats.
|
||||
fn read_redox() -> Self {
|
||||
let Ok(dirs) = fs::read_dir(REDOX_DISKD) else {
|
||||
return Self::default();
|
||||
};
|
||||
let mut disks = Vec::new();
|
||||
for entry in dirs.flatten() {
|
||||
let path = entry.path();
|
||||
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
disks.push(read_redox_disk(name, &path));
|
||||
}
|
||||
disks.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Self { disks }
|
||||
}
|
||||
/// Read disks and compute R/W throughput (KiB/s) for each based
|
||||
/// on delta of read_bytes/write_bytes vs previous read.
|
||||
pub fn read_with_throughput(prev: &StorageInfo, dt_secs: f64) -> Self {
|
||||
|
||||
@@ -1,56 +1,43 @@
|
||||
//! Centralized color and style palette for redbear-power.
|
||||
//!
|
||||
//! Every visible color and recurring style lives here as a `const`,
|
||||
//! so changing a color is a one-line edit and tests can snapshot
|
||||
//! against stable references. The constants use the ratatui 0.30
|
||||
//! `Stylize` shorthand (`Style::new().red().bold()`) which is
|
||||
//! stable across all builds.
|
||||
//!
|
||||
//! v1.44: Added `Theme` struct with Dark / Light / HighContrast
|
||||
//! presets for border styles and key highlights. The `const` values
|
||||
//! below are the Dark theme defaults. The active `Theme` is stored
|
||||
//! in `App` and selectable via `--theme` / config `[theme].mode`.
|
||||
//! Every visible color and recurring style lives in the `Theme` struct,
|
||||
//! so each preset (Dark, Light, HighContrast, Nord, Gruvbox, SolarizedDark)
|
||||
//! can define its own palette. The active `Theme` is stored in `App` and
|
||||
//! selectable via `--theme` / config `[theme].mode`.
|
||||
|
||||
#[allow(unused_imports)]
|
||||
use ratatui::style::{Color, Style, Stylize};
|
||||
|
||||
pub const LABEL: Style = Style::new().cyan();
|
||||
pub const LABEL_BOLD: Style = Style::new().cyan().bold();
|
||||
|
||||
pub const VALUE: Style = Style::new();
|
||||
pub const VALUE_OFF: Style = Style::new().dark_gray();
|
||||
pub const VALUE_OK: Style = Style::new().green();
|
||||
pub const VALUE_WARM: Style = Style::new().yellow();
|
||||
pub const VALUE_HOT: Style = Style::new().red().bold();
|
||||
pub const VALUE_HOT_LIGHT: Style = Style::new().light_red();
|
||||
|
||||
pub const BORDER_FOCUSED: Style = Style::new().yellow().bold();
|
||||
pub const BORDER_DIM: Style = Style::new().dark_gray();
|
||||
|
||||
pub const HEADER_GOVERNOR: Style = Style::new().magenta().bold();
|
||||
pub const HEADER_THROTTLE_AUTO: Style = Style::new().green();
|
||||
pub const HEADER_THROTTLE_USER: Style = Style::new().blue();
|
||||
pub const HEADER_THROTTLE_FORCED: Style = Style::new().red().bold();
|
||||
|
||||
pub const STATUS_OK: Style = Style::new().green().bold();
|
||||
pub const STATUS_WARN: Style = Style::new().yellow().bold();
|
||||
pub const STATUS_ERR: Style = Style::new().red().bold();
|
||||
|
||||
pub const PROCHOT_PULSE: Style = Style::new().red().bold();
|
||||
pub const PROCHOT_FLAG: Style = Style::new().light_red();
|
||||
pub const POWER_LIMIT_FLAG: Style = Style::new().yellow();
|
||||
pub const NO_FLAG: Style = Style::new().dark_gray();
|
||||
|
||||
pub const CURSOR: Style = Style::new().bold();
|
||||
|
||||
/// Active theme with overridable border/header/cursor styles.
|
||||
/// Three built-in presets: Dark (default), Light, HighContrast.
|
||||
/// Active theme palette.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Theme {
|
||||
pub border_focused: Style,
|
||||
pub border_dim: Style,
|
||||
pub header_governor: Style,
|
||||
pub cursor_highlight: Style,
|
||||
|
||||
pub label: Style,
|
||||
pub label_bold: Style,
|
||||
|
||||
pub value: Style,
|
||||
pub value_off: Style,
|
||||
pub value_ok: Style,
|
||||
pub value_warm: Style,
|
||||
pub value_hot: Style,
|
||||
pub value_hot_light: Style,
|
||||
|
||||
pub header_throttle_auto: Style,
|
||||
pub header_throttle_user: Style,
|
||||
pub header_throttle_forced: Style,
|
||||
|
||||
pub status_ok: Style,
|
||||
pub status_warn: Style,
|
||||
pub status_err: Style,
|
||||
|
||||
pub prochot_pulse: Style,
|
||||
pub prochot_flag: Style,
|
||||
pub power_limit_flag: Style,
|
||||
pub no_flag: Style,
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
@@ -67,10 +54,28 @@ impl Theme {
|
||||
|
||||
pub fn dark() -> Self {
|
||||
Self {
|
||||
border_focused: BORDER_FOCUSED,
|
||||
border_dim: BORDER_DIM,
|
||||
header_governor: HEADER_GOVERNOR,
|
||||
cursor_highlight: CURSOR,
|
||||
border_focused: Style::new().yellow().bold(),
|
||||
border_dim: Style::new().dark_gray(),
|
||||
header_governor: Style::new().magenta().bold(),
|
||||
cursor_highlight: Style::new().bold(),
|
||||
label: Style::new().cyan(),
|
||||
label_bold: Style::new().cyan().bold(),
|
||||
value: Style::new(),
|
||||
value_off: Style::new().dark_gray(),
|
||||
value_ok: Style::new().green(),
|
||||
value_warm: Style::new().yellow(),
|
||||
value_hot: Style::new().red().bold(),
|
||||
value_hot_light: Style::new().light_red(),
|
||||
header_throttle_auto: Style::new().green(),
|
||||
header_throttle_user: Style::new().blue(),
|
||||
header_throttle_forced: Style::new().red().bold(),
|
||||
status_ok: Style::new().green().bold(),
|
||||
status_warn: Style::new().yellow().bold(),
|
||||
status_err: Style::new().red().bold(),
|
||||
prochot_pulse: Style::new().red().bold(),
|
||||
prochot_flag: Style::new().light_red(),
|
||||
power_limit_flag: Style::new().yellow(),
|
||||
no_flag: Style::new().dark_gray(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +85,24 @@ impl Theme {
|
||||
border_dim: Style::new().gray(),
|
||||
header_governor: Style::new().blue().bold(),
|
||||
cursor_highlight: Style::new().bold(),
|
||||
label: Style::new().cyan(),
|
||||
label_bold: Style::new().cyan().bold(),
|
||||
value: Style::new().black(),
|
||||
value_off: Style::new().gray(),
|
||||
value_ok: Style::new().green(),
|
||||
value_warm: Style::new().yellow(),
|
||||
value_hot: Style::new().red().bold(),
|
||||
value_hot_light: Style::new().red(),
|
||||
header_throttle_auto: Style::new().green(),
|
||||
header_throttle_user: Style::new().blue(),
|
||||
header_throttle_forced: Style::new().red().bold(),
|
||||
status_ok: Style::new().green().bold(),
|
||||
status_warn: Style::new().yellow().bold(),
|
||||
status_err: Style::new().red().bold(),
|
||||
prochot_pulse: Style::new().red().bold(),
|
||||
prochot_flag: Style::new().red(),
|
||||
power_limit_flag: Style::new().yellow(),
|
||||
no_flag: Style::new().gray(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +112,24 @@ impl Theme {
|
||||
border_dim: Style::new().white(),
|
||||
header_governor: Style::new().white().bold(),
|
||||
cursor_highlight: Style::new().white().on_black().bold(),
|
||||
label: Style::new().white().bold(),
|
||||
label_bold: Style::new().white().bold(),
|
||||
value: Style::new().white(),
|
||||
value_off: Style::new().white(),
|
||||
value_ok: Style::new().green().bold(),
|
||||
value_warm: Style::new().yellow().bold(),
|
||||
value_hot: Style::new().red().bold(),
|
||||
value_hot_light: Style::new().red().bold(),
|
||||
header_throttle_auto: Style::new().green().bold(),
|
||||
header_throttle_user: Style::new().cyan().bold(),
|
||||
header_throttle_forced: Style::new().red().bold(),
|
||||
status_ok: Style::new().green().bold(),
|
||||
status_warn: Style::new().yellow().bold(),
|
||||
status_err: Style::new().red().bold(),
|
||||
prochot_pulse: Style::new().red().bold(),
|
||||
prochot_flag: Style::new().red().bold(),
|
||||
power_limit_flag: Style::new().yellow().bold(),
|
||||
no_flag: Style::new().white(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +139,24 @@ impl Theme {
|
||||
border_dim: Style::new().dark_gray(),
|
||||
header_governor: Style::new().light_blue().bold(),
|
||||
cursor_highlight: Style::new().bold(),
|
||||
label: Style::new().light_cyan(),
|
||||
label_bold: Style::new().light_cyan().bold(),
|
||||
value: Style::new().white(),
|
||||
value_off: Style::new().dark_gray(),
|
||||
value_ok: Style::new().light_green(),
|
||||
value_warm: Style::new().yellow(),
|
||||
value_hot: Style::new().light_red().bold(),
|
||||
value_hot_light: Style::new().light_red(),
|
||||
header_throttle_auto: Style::new().light_green(),
|
||||
header_throttle_user: Style::new().light_blue(),
|
||||
header_throttle_forced: Style::new().light_red().bold(),
|
||||
status_ok: Style::new().light_green().bold(),
|
||||
status_warn: Style::new().yellow().bold(),
|
||||
status_err: Style::new().light_red().bold(),
|
||||
prochot_pulse: Style::new().light_red().bold(),
|
||||
prochot_flag: Style::new().light_red(),
|
||||
power_limit_flag: Style::new().yellow(),
|
||||
no_flag: Style::new().dark_gray(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +166,24 @@ impl Theme {
|
||||
border_dim: Style::new().dark_gray(),
|
||||
header_governor: Style::new().yellow().bold(),
|
||||
cursor_highlight: Style::new().bold(),
|
||||
label: Style::new().light_blue(),
|
||||
label_bold: Style::new().light_blue().bold(),
|
||||
value: Style::new().white(),
|
||||
value_off: Style::new().dark_gray(),
|
||||
value_ok: Style::new().green(),
|
||||
value_warm: Style::new().yellow(),
|
||||
value_hot: Style::new().red().bold(),
|
||||
value_hot_light: Style::new().red(),
|
||||
header_throttle_auto: Style::new().green(),
|
||||
header_throttle_user: Style::new().blue(),
|
||||
header_throttle_forced: Style::new().red().bold(),
|
||||
status_ok: Style::new().green().bold(),
|
||||
status_warn: Style::new().yellow().bold(),
|
||||
status_err: Style::new().red().bold(),
|
||||
prochot_pulse: Style::new().red().bold(),
|
||||
prochot_flag: Style::new().red(),
|
||||
power_limit_flag: Style::new().yellow(),
|
||||
no_flag: Style::new().dark_gray(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +193,24 @@ impl Theme {
|
||||
border_dim: Style::new().dark_gray(),
|
||||
header_governor: Style::new().magenta().bold(),
|
||||
cursor_highlight: Style::new().bold(),
|
||||
label: Style::new().cyan(),
|
||||
label_bold: Style::new().cyan().bold(),
|
||||
value: Style::new().white(),
|
||||
value_off: Style::new().dark_gray(),
|
||||
value_ok: Style::new().green(),
|
||||
value_warm: Style::new().yellow(),
|
||||
value_hot: Style::new().red().bold(),
|
||||
value_hot_light: Style::new().light_red(),
|
||||
header_throttle_auto: Style::new().green(),
|
||||
header_throttle_user: Style::new().blue(),
|
||||
header_throttle_forced: Style::new().red().bold(),
|
||||
status_ok: Style::new().green().bold(),
|
||||
status_warn: Style::new().yellow().bold(),
|
||||
status_err: Style::new().red().bold(),
|
||||
prochot_pulse: Style::new().red().bold(),
|
||||
prochot_flag: Style::new().light_red(),
|
||||
power_limit_flag: Style::new().yellow(),
|
||||
no_flag: Style::new().dark_gray(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,3 +256,26 @@ pub fn flags_color(prochot: bool, critical: bool, power_limit: bool) -> Color {
|
||||
Color::Green
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn named_dispatches_to_dark_by_default() {
|
||||
let t = Theme::named("unknown");
|
||||
assert_eq!(t.label, Theme::dark().label);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_has_dark_foreground() {
|
||||
let t = Theme::light();
|
||||
assert_eq!(t.value, Style::new().black());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nord_label_is_not_dark_default() {
|
||||
let t = Theme::nord();
|
||||
assert_ne!(t.label, Theme::dark().label);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
//! D-Bus UPower client (system bus).
|
||||
//!
|
||||
//! Reads battery and AC adapter status from `org.freedesktop.UPower` so
|
||||
//! redbear-power can display the same battery information on Linux
|
||||
//! (real UPower) and Red Bear OS (redbear-upower compatibility daemon)
|
||||
//! without relying on sysfs or ACPI scheme details.
|
||||
//!
|
||||
//! Implementation note: this module creates a short-lived tokio runtime
|
||||
//! per call. Battery refresh is only every few seconds, so the overhead is
|
||||
//! acceptable. A long-lived worker thread would be cleaner, but keeping it
|
||||
//! synchronous simplifies the `BatteryInfo::read()` contract.
|
||||
|
||||
use tokio::runtime::Runtime;
|
||||
use zbus::proxy;
|
||||
use zbus::zvariant::OwnedObjectPath;
|
||||
|
||||
use crate::battery::BatteryInfo;
|
||||
|
||||
#[proxy(
|
||||
interface = "org.freedesktop.UPower",
|
||||
default_service = "org.freedesktop.UPower",
|
||||
default_path = "/org/freedesktop/UPower"
|
||||
)]
|
||||
trait UPower {
|
||||
/// Enumerate all power devices (batteries and AC adapters).
|
||||
fn enumerate_devices(&self) -> zbus::Result<Vec<OwnedObjectPath>>;
|
||||
|
||||
/// Critical action the system would take on a critical battery event.
|
||||
fn get_critical_action(&self) -> zbus::Result<String>;
|
||||
|
||||
/// True if the system is running on battery power.
|
||||
#[zbus(property)]
|
||||
fn on_battery(&self) -> zbus::Result<bool>;
|
||||
|
||||
#[zbus(property)]
|
||||
fn daemon_version(&self) -> zbus::Result<String>;
|
||||
}
|
||||
|
||||
#[proxy(
|
||||
interface = "org.freedesktop.UPower.Device",
|
||||
default_service = "org.freedesktop.UPower"
|
||||
)]
|
||||
trait UPowerDevice {
|
||||
/// 0=Unknown, 1=LinePower, 2=Battery.
|
||||
#[zbus(property)]
|
||||
fn type_(&self) -> zbus::Result<u32>;
|
||||
|
||||
/// 0=Unknown, 1=Charging, 2=Discharging, 3=Empty, 4=FullyCharged.
|
||||
#[zbus(property)]
|
||||
fn state(&self) -> zbus::Result<u32>;
|
||||
|
||||
/// Battery charge percentage (0.0–100.0).
|
||||
#[zbus(property)]
|
||||
fn percentage(&self) -> zbus::Result<f64>;
|
||||
|
||||
/// Whether the device is physically present.
|
||||
#[zbus(property)]
|
||||
fn is_present(&self) -> zbus::Result<bool>;
|
||||
|
||||
/// For AC adapters: is the adapter online. For batteries: always false.
|
||||
#[zbus(property)]
|
||||
fn online(&self) -> zbus::Result<bool>;
|
||||
|
||||
/// Native (ACPI) path of the device.
|
||||
#[zbus(property)]
|
||||
fn native_path(&self) -> zbus::Result<String>;
|
||||
}
|
||||
|
||||
/// Device type constants from the UPower specification.
|
||||
mod device_type {
|
||||
pub const UNKNOWN: u32 = 0;
|
||||
pub const LINE_POWER: u32 = 1;
|
||||
pub const BATTERY: u32 = 2;
|
||||
}
|
||||
|
||||
/// Device state constants from the UPower specification.
|
||||
mod device_state {
|
||||
pub const UNKNOWN: u32 = 0;
|
||||
pub const CHARGING: u32 = 1;
|
||||
pub const DISCHARGING: u32 = 2;
|
||||
pub const EMPTY: u32 = 3;
|
||||
pub const FULLY_CHARGED: u32 = 4;
|
||||
}
|
||||
|
||||
fn state_to_status(state: u32) -> String {
|
||||
match state {
|
||||
device_state::CHARGING => "Charging".to_string(),
|
||||
device_state::DISCHARGING => "Discharging".to_string(),
|
||||
device_state::EMPTY => "Empty".to_string(),
|
||||
device_state::FULLY_CHARGED => "Full".to_string(),
|
||||
_ => "Unknown".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read battery/AC status from the UPower D-Bus service. Returns `None` if
|
||||
/// the system bus is unreachable or UPower is not available.
|
||||
pub fn read_upower() -> Option<BatteryInfo> {
|
||||
let rt = Runtime::new().ok()?;
|
||||
rt.block_on(read_upower_async())
|
||||
}
|
||||
|
||||
async fn read_upower_async() -> Option<BatteryInfo> {
|
||||
let conn = zbus::connection::Builder::system()
|
||||
.ok()?
|
||||
.build()
|
||||
.await
|
||||
.ok()?;
|
||||
let upower = UPowerProxy::new(&conn).await.ok()?;
|
||||
|
||||
let on_battery = upower.on_battery().await.unwrap_or(false);
|
||||
let devices = upower.enumerate_devices().await.unwrap_or_default();
|
||||
|
||||
let mut info = BatteryInfo {
|
||||
available: false,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
for path in devices {
|
||||
let dev = match UPowerDeviceProxy::builder(&conn)
|
||||
.path(path.clone())
|
||||
.ok()?
|
||||
.build()
|
||||
.await
|
||||
{
|
||||
Ok(d) => d,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let dev_type = dev.type_().await.unwrap_or(device_type::UNKNOWN);
|
||||
if dev_type == device_type::LINE_POWER {
|
||||
// AC adapter: we expose its online state as the power_now_w
|
||||
// field? No, we use a dedicated field if it existed. For now,
|
||||
// represent the AC adapter as a synthetic device with name.
|
||||
let _ = dev.online().await.unwrap_or(false);
|
||||
// AC online state is not represented in BatteryInfo; it is
|
||||
// implicit in the OnBattery daemon property. We leave the
|
||||
// BatteryInfo focused on the battery device itself.
|
||||
} else if dev_type == device_type::BATTERY {
|
||||
let is_present = dev.is_present().await.unwrap_or(false);
|
||||
if !is_present {
|
||||
continue;
|
||||
}
|
||||
info.available = true;
|
||||
let state = dev.state().await.unwrap_or(device_state::UNKNOWN);
|
||||
info.status = Some(state_to_status(state));
|
||||
info.capacity_percent = Some(dev.percentage().await.unwrap_or(0.0) as u32);
|
||||
info.name = dev
|
||||
.native_path()
|
||||
.await
|
||||
.ok()
|
||||
.map(|p| p.rsplit('/').next().unwrap_or(&p).to_string());
|
||||
info.on_battery = Some(on_battery);
|
||||
// redbear-upower currently does not expose Energy, Power, Voltage,
|
||||
// TimeToEmpty, TimeToFull, CycleCount, Technology, Model, Vendor,
|
||||
// or Serial. Leave those fields as None so the render layer shows
|
||||
// "?" for unsupported values.
|
||||
}
|
||||
}
|
||||
|
||||
if info.available { Some(info) } else { None }
|
||||
}
|
||||
|
||||
/// Merge the UPower-derived fields into a base `BatteryInfo`. This is a
|
||||
/// convenience helper so `battery.rs` can keep its Linux/Redox sysfs/scheme
|
||||
/// fallback and only overlay the values that UPower provided.
|
||||
pub fn merge_upower(mut base: BatteryInfo) -> BatteryInfo {
|
||||
if let Some(upower) = read_upower() {
|
||||
if base.available {
|
||||
// UPower is authoritative for status and percentage; other fields
|
||||
// that the local sysfs/scheme read already populated are kept.
|
||||
if upower.status.is_some() {
|
||||
base.status = upower.status;
|
||||
}
|
||||
if upower.capacity_percent.is_some() {
|
||||
base.capacity_percent = upower.capacity_percent;
|
||||
}
|
||||
if upower.on_battery.is_some() {
|
||||
base.on_battery = upower.on_battery;
|
||||
}
|
||||
base
|
||||
} else {
|
||||
upower
|
||||
}
|
||||
} else {
|
||||
base
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
use std::fs;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct WakeupStats {
|
||||
pub system_total: u64,
|
||||
pub system_per_second: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ProcessWakeupRate {
|
||||
pub pid: u32,
|
||||
pub wakeups: u64,
|
||||
pub wakeups_per_second: f64,
|
||||
}
|
||||
|
||||
fn dt_seconds(dt: Duration) -> f64 {
|
||||
let secs = dt.as_secs_f64();
|
||||
if secs > 0.0 { secs } else { 1.0 }
|
||||
}
|
||||
|
||||
fn parse_intr_total(data: &str) -> Option<u64> {
|
||||
data.lines().find_map(|line| {
|
||||
let trimmed = line.trim_start();
|
||||
let rest = trimmed.strip_prefix("intr ")?;
|
||||
rest.split_whitespace().next()?.parse::<u64>().ok()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read_system_wakeups(prev_total: Option<u64>, dt: Duration) -> Option<WakeupStats> {
|
||||
let data = fs::read_to_string("/proc/stat").ok()?;
|
||||
let total = parse_intr_total(&data)?;
|
||||
|
||||
let delta = prev_total.map_or(0, |prev| total.saturating_sub(prev));
|
||||
Some(WakeupStats {
|
||||
system_total: total,
|
||||
system_per_second: delta as f64 / dt_seconds(dt),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn process_wakeups_per_sec(
|
||||
prev: &[(u32, u64)],
|
||||
next: &[(u32, u64)],
|
||||
dt: Duration,
|
||||
) -> Vec<ProcessWakeupRate> {
|
||||
let dt = dt_seconds(dt);
|
||||
next.iter()
|
||||
.map(|&(pid, next_ctx)| {
|
||||
let prev_ctx = prev
|
||||
.iter()
|
||||
.find(|&&(p, _)| p == pid)
|
||||
.map(|&(_, c)| c)
|
||||
.unwrap_or(0);
|
||||
let wakeups = next_ctx.saturating_sub(prev_ctx);
|
||||
ProcessWakeupRate {
|
||||
pid,
|
||||
wakeups,
|
||||
wakeups_per_second: wakeups as f64 / dt,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn read_process_wakeups(pid: u32) -> Option<u64> {
|
||||
let path = format!("/proc/{pid}/status");
|
||||
let data = fs::read_to_string(path).ok()?;
|
||||
let mut voluntary = None;
|
||||
let mut nonvoluntary = None;
|
||||
|
||||
for line in data.lines() {
|
||||
let trimmed = line.trim_start();
|
||||
if let Some(rest) = trimmed.strip_prefix("voluntary_ctxt_switches:") {
|
||||
voluntary = rest.trim().parse::<u64>().ok();
|
||||
} else if let Some(rest) = trimmed.strip_prefix("nonvoluntary_ctxt_switches:") {
|
||||
nonvoluntary = rest.trim().parse::<u64>().ok();
|
||||
}
|
||||
}
|
||||
|
||||
Some(voluntary.unwrap_or(0) + nonvoluntary.unwrap_or(0))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn system_delta_computation() {
|
||||
assert_eq!(
|
||||
parse_intr_total("cpu 1 2 3\nintr 12345 1 2 3\n"),
|
||||
Some(12345)
|
||||
);
|
||||
let stats = read_system_wakeups_from_str(
|
||||
"intr 12345 1 2 3\n",
|
||||
Some(12000),
|
||||
Duration::from_millis(500),
|
||||
);
|
||||
assert_eq!(stats.system_total, 12345);
|
||||
assert_eq!(stats.system_per_second, 690.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dt_fallback_uses_one_second() {
|
||||
let rates = process_wakeups_per_sec(&[(1, 10)], &[(1, 13)], Duration::ZERO);
|
||||
assert_eq!(rates[0].wakeups, 3);
|
||||
assert_eq!(rates[0].wakeups_per_second, 3.0);
|
||||
}
|
||||
|
||||
fn read_system_wakeups_from_str(
|
||||
data: &str,
|
||||
prev_total: Option<u64>,
|
||||
dt: Duration,
|
||||
) -> WakeupStats {
|
||||
let total = parse_intr_total(data).unwrap();
|
||||
let delta = prev_total.map_or(0, |prev| total.saturating_sub(prev));
|
||||
WakeupStats {
|
||||
system_total: total,
|
||||
system_per_second: delta as f64 / dt_seconds(dt),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
[workspace]
|
||||
members = ["source"]
|
||||
resolver = "3"
|
||||
@@ -0,0 +1,8 @@
|
||||
[source]
|
||||
path = "source"
|
||||
|
||||
[build]
|
||||
template = "cargo"
|
||||
|
||||
[package.files]
|
||||
"/usr/bin/redbear-usb-hotplugd" = "redbear-usb-hotplugd"
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "redbear-usb-hotplugd"
|
||||
version = "0.2.5"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "redbear-usb-hotplugd"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
log = "0.4"
|
||||
redox_syscall = { path = "../../../../../local/sources/syscall" }
|
||||
libredox = { path = "../../../../../local/sources/libredox", features = ["call", "std"] }
|
||||
xhcid = { path = "../../../../../local/sources/base/drivers/usb/xhcid" }
|
||||
common = { path = "../../../../../local/sources/base/drivers/common" }
|
||||
|
||||
[patch.crates-io]
|
||||
redox_syscall = { path = "../../../../../local/sources/syscall" }
|
||||
libredox = { path = "../../../../../local/sources/libredox" }
|
||||
@@ -0,0 +1,277 @@
|
||||
//! USB device hotplug daemon — comprehensive port event state machine.
|
||||
//!
|
||||
//! Cross-referenced with Linux 7.1 `drivers/usb/core/hub.c`:
|
||||
//! - hub_port_connect_change() (line 5055+): port connect/disconnect dispatch
|
||||
//! - hub_port_debounce() (line 4698+): 25ms-step debounce algorithm
|
||||
//! - hub_port_connect() (line 4939+): full device enumeration sequence
|
||||
//! - hub_irq() / port_event(): interrupt-driven change detection
|
||||
//!
|
||||
//! Implements the USB 2.0 §7.1.7.3 connect debounce requirement:
|
||||
//! 100ms stable connection before reporting connect, 2000ms timeout.
|
||||
//! Follows Linux's HUB_DEBOUNCE_STEP=25ms, HUB_DEBOUNCE_STABLE=100ms,
|
||||
//! HUB_DEBOUNCE_TIMEOUT=2000ms constants.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::process::{Child, Command};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use xhcid_interface::XhciClientHandle;
|
||||
|
||||
// USB 2.0 §7.1.7.3 debounce timing.
|
||||
// Linux 7.1 drivers/usb/core/hub.c:138-140.
|
||||
const DEBOUNCE_STEP_MS: u64 = 25;
|
||||
const DEBOUNCE_STABLE_MS: u64 = 100;
|
||||
const DEBOUNCE_TIMEOUT_MS: u64 = 2000;
|
||||
|
||||
const DRIVER_MAP: &[(u8, u8, &str, &str)] = &[
|
||||
(0x01, 0x01, "/usr/bin/redbear-usbaudiod", "0"),
|
||||
(0x01, 0x02, "/usr/bin/redbear-usbaudiod", "0"),
|
||||
(0x02, 0x02, "/usr/bin/redbear-acmd", "0"),
|
||||
(0x02, 0x06, "/usr/bin/redbear-ecmd", "0"),
|
||||
(0x02, 0xFF, "/usr/bin/redbear-acmd", "0"),
|
||||
(0x03, 0x00, "/usr/bin/usbhidd", "0"),
|
||||
(0x03, 0x01, "/usr/bin/usbhidd", "0"),
|
||||
(0x08, 0x06, "/usr/bin/usbscsid", "0x50"),
|
||||
(0x09, 0x00, "/usr/bin/usbhubd", "0"),
|
||||
(0xFF, 0x00, "/usr/bin/redbear-ftdi", "0"),
|
||||
(0xFF, 0xFF, "/usr/bin/redbear-ftdi", "0"),
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum PortState {
|
||||
Disconnected,
|
||||
DebouncingConnect { stable_start: Instant },
|
||||
Connected,
|
||||
Active,
|
||||
}
|
||||
|
||||
struct TrackedDevice {
|
||||
state: PortState,
|
||||
child: Option<Child>,
|
||||
class: u8,
|
||||
subclass: u8,
|
||||
protocol: u8,
|
||||
scheme: String,
|
||||
port_str: String,
|
||||
}
|
||||
|
||||
fn find_driver(class: u8, subclass: u8, protocol: u8) -> Option<(&'static str, String)> {
|
||||
for &(cls, subcls, binary, default_extra) in DRIVER_MAP {
|
||||
if cls == class && (subcls == subclass || subcls == 0xFF) {
|
||||
let extra = if cls == 0x08 { format!("0x{:02X}", protocol) }
|
||||
else { default_extra.to_string() };
|
||||
return Some((binary, extra));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn scan_controllers() -> Vec<String> {
|
||||
let mut controllers = Vec::new();
|
||||
if let Ok(dir) = fs::read_dir("/scheme/usb") {
|
||||
for entry in dir.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
||||
if name.contains("_xhci") || name.contains("_ehci")
|
||||
|| name.contains("_ohci") || name.contains("_uhci")
|
||||
{
|
||||
controllers.push(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
controllers
|
||||
}
|
||||
|
||||
fn scan_ports(controller_name: &str) -> Vec<String> {
|
||||
let mut ports = Vec::new();
|
||||
let path = format!("/scheme/usb/{}", controller_name);
|
||||
scan_ports_recursive(&path, &mut ports);
|
||||
ports
|
||||
}
|
||||
|
||||
fn scan_ports_recursive(base_path: &str, ports: &mut Vec<String>) {
|
||||
if let Ok(dir) = fs::read_dir(base_path) {
|
||||
for entry in dir.flatten() {
|
||||
let name = entry.file_name();
|
||||
let name_str = name.to_string_lossy();
|
||||
if name_str.starts_with("port") {
|
||||
let full = format!("{}/{}", base_path, name_str);
|
||||
ports.push(full.clone());
|
||||
if entry.path().is_dir() {
|
||||
scan_ports_recursive(&full, ports);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_port_id(full_port_path: &str) -> Option<(String, String)> {
|
||||
let parts: Vec<&str> = full_port_path.split('/').collect();
|
||||
if parts.len() < 4 { return None; }
|
||||
let controller = parts.get(3)?;
|
||||
let port_start = full_port_path.find(controller)? + controller.len() + 1;
|
||||
Some((controller.to_string(), full_port_path[port_start..].to_string()))
|
||||
}
|
||||
|
||||
fn read_port_connected(scheme: &str, port_str: &str) -> Option<bool> {
|
||||
let port_id: u8 = if let Some(dot_pos) = port_str.find('.') {
|
||||
port_str[4..dot_pos].parse().ok()?
|
||||
} else {
|
||||
port_str.strip_prefix("port")?.parse().ok()?
|
||||
};
|
||||
use xhcid_interface::PortId;
|
||||
let pid = PortId { root_hub_port_num: port_id, route_string: 0 };
|
||||
XhciClientHandle::new(scheme.to_string(), pid).ok().map(|_| true)
|
||||
}
|
||||
|
||||
fn read_descriptors(scheme: &str, port_str: &str) -> Option<(u8, u8, u8)> {
|
||||
let port_id: u8 = if let Some(dot_pos) = port_str.find('.') {
|
||||
port_str[4..dot_pos].parse().ok()?
|
||||
} else {
|
||||
port_str.strip_prefix("port")?.parse().ok()?
|
||||
};
|
||||
use xhcid_interface::PortId;
|
||||
let pid = PortId { root_hub_port_num: port_id, route_string: 0 };
|
||||
|
||||
XhciClientHandle::new(scheme.to_string(), pid).ok()?.get_standard_descs().ok().and_then(|desc| {
|
||||
for conf in &desc.config_descs {
|
||||
for ifd in &conf.interface_descs {
|
||||
if ifd.class != 0x09 && ifd.class != 0x0A {
|
||||
return Some((ifd.class, ifd.sub_class, ifd.protocol));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
fn main() {
|
||||
log::set_max_level(log::LevelFilter::Info);
|
||||
common::init();
|
||||
common::setup_logging("usb", "system", "usb-hotplugd",
|
||||
common::output_level(), common::file_level());
|
||||
|
||||
log::info!("USB hotplug daemon — debounce {}/{}/{}ms per Linux 7.1 hub_port_debounce()",
|
||||
DEBOUNCE_STEP_MS, DEBOUNCE_STABLE_MS, DEBOUNCE_TIMEOUT_MS);
|
||||
|
||||
let mut tracked: HashMap<String, TrackedDevice> = HashMap::new();
|
||||
|
||||
loop {
|
||||
let now = Instant::now();
|
||||
let controllers = scan_controllers();
|
||||
|
||||
// Phase 1: discover new ports (Linux 7.1 hub_irq → port_event)
|
||||
for ctrl_name in &controllers {
|
||||
let scheme_name = format!("usb.{}", ctrl_name);
|
||||
let port_paths = scan_ports(ctrl_name);
|
||||
|
||||
for port_path in &port_paths {
|
||||
if tracked.contains_key(port_path) {
|
||||
continue;
|
||||
}
|
||||
let (ref ctrl, ref port_str) = match extract_port_id(port_path) {
|
||||
Some(x) => x,
|
||||
None => continue,
|
||||
};
|
||||
let connected = read_port_connected(&scheme_name, port_str).unwrap_or(false);
|
||||
if connected {
|
||||
log::info!("port {}: new connection, entering debounce", port_path);
|
||||
tracked.insert(port_path.clone(), TrackedDevice {
|
||||
state: PortState::DebouncingConnect { stable_start: now },
|
||||
child: None, class: 0, subclass: 0, protocol: 0,
|
||||
scheme: scheme_name.clone(), port_str: port_str.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: debounce + state transitions (Linux 7.1 hub_port_debounce)
|
||||
for (port_path, dev) in &mut tracked {
|
||||
let scheme = dev.scheme.clone();
|
||||
let port_str = dev.port_str.clone();
|
||||
let connected = read_port_connected(&scheme, &port_str).unwrap_or(false);
|
||||
|
||||
match dev.state {
|
||||
PortState::Disconnected => {}
|
||||
|
||||
PortState::DebouncingConnect { stable_start } => {
|
||||
if !connected {
|
||||
log::info!("port {}: dropped during debounce", port_path);
|
||||
dev.state = PortState::Disconnected;
|
||||
} else if now.duration_since(stable_start) >= Duration::from_millis(DEBOUNCE_STABLE_MS) {
|
||||
log::info!("port {}: debounce passed ({}ms)", port_path,
|
||||
now.duration_since(stable_start).as_millis());
|
||||
dev.state = PortState::Connected;
|
||||
}
|
||||
}
|
||||
|
||||
PortState::Connected => {
|
||||
if !connected {
|
||||
log::info!("port {}: disconnected before enumeration", port_path);
|
||||
dev.state = PortState::Disconnected;
|
||||
} else if let Some((class, subclass, protocol)) = read_descriptors(&scheme, &port_str) {
|
||||
log::info!("port {}: class={:02X} subclass={:02X} protocol={:02X}",
|
||||
port_path, class, subclass, protocol);
|
||||
dev.class = class; dev.subclass = subclass; dev.protocol = protocol;
|
||||
|
||||
if let Some((driver_binary, extra_arg)) = find_driver(class, subclass, protocol) {
|
||||
let short_port = port_str.strip_prefix("port").unwrap_or(&port_str);
|
||||
log::info!("spawning {} (scheme={} port={} arg={})",
|
||||
driver_binary, scheme, short_port, extra_arg);
|
||||
match Command::new(driver_binary)
|
||||
.arg(&scheme).arg(short_port).arg(&extra_arg)
|
||||
.stdin(std::process::Stdio::null()).spawn()
|
||||
{
|
||||
Ok(child) => {
|
||||
dev.child = Some(child);
|
||||
dev.state = PortState::Active;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("spawn {}: {}", driver_binary, e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dev.state = PortState::Active;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PortState::Active => {
|
||||
if let Some(ref mut child) = dev.child {
|
||||
if let Ok(Some(status)) = child.try_wait() {
|
||||
log::info!("port {}: driver exited ({:?})", port_path, status);
|
||||
dev.child = None;
|
||||
dev.state = PortState::Connected;
|
||||
}
|
||||
}
|
||||
if !connected {
|
||||
log::info!("port {}: device disconnected", port_path);
|
||||
if let Some(ref mut child) = dev.child { let _ = child.kill(); }
|
||||
dev.child = None;
|
||||
dev.state = PortState::Disconnected;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: remove disconnected entries, time out stale debounces
|
||||
let mut remove: Vec<String> = Vec::new();
|
||||
for (key, dev) in &tracked {
|
||||
if dev.state == PortState::Disconnected { remove.push(key.clone()); }
|
||||
if let PortState::DebouncingConnect { stable_start } = dev.state {
|
||||
if now.duration_since(stable_start) >= Duration::from_millis(DEBOUNCE_TIMEOUT_MS) {
|
||||
log::warn!("port {}: debounce timed out", key);
|
||||
remove.push(key.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
for key in &remove { tracked.remove(key); }
|
||||
|
||||
thread::sleep(Duration::from_millis(DEBOUNCE_STEP_MS));
|
||||
}
|
||||
}
|
||||
@@ -9,3 +9,14 @@ path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
log = "0.4"
|
||||
redox_syscall = { path = "../../../../../local/sources/syscall" }
|
||||
xhcid = { path = "../../../../../local/sources/base/drivers/usb/xhcid" }
|
||||
common = { path = "../../../../../local/sources/base/drivers/common" }
|
||||
libredox = { path = "../../../../../local/sources/libredox", features = ["call", "std"] }
|
||||
|
||||
[target.'cfg(target_os = "redox")'.dependencies]
|
||||
redox-scheme = { path = "../../../../../local/sources/redox-scheme" }
|
||||
|
||||
[patch.crates-io]
|
||||
redox_syscall = { path = "../../../../../local/sources/syscall" }
|
||||
libredox = { path = "../../../../../local/sources/libredox" }
|
||||
|
||||
@@ -1,32 +1,297 @@
|
||||
use log::{info, LevelFilter};
|
||||
use std::fs;
|
||||
use std::time::Duration;
|
||||
struct StderrLogger;
|
||||
impl log::Log for StderrLogger {
|
||||
fn enabled(&self, m: &log::Metadata) -> bool { m.level() <= LevelFilter::Info }
|
||||
fn log(&self, r: &log::Record) { eprintln!("[{}] redbear-usbaudiod: {}", r.level(), r.args()); }
|
||||
fn flush(&self) {}
|
||||
//! USB Audio Class 1.0 driver.
|
||||
//!
|
||||
//! Cross-referenced with Linux 7.1 `sound/usb/card.c`, `mixer.c`, `pcm.c`
|
||||
//! and `include/uapi/linux/usb/audio.h`.
|
||||
//!
|
||||
//! Implements the USB Audio Class specification for USB microphones,
|
||||
//! speakers, and headsets. Supports:
|
||||
//! - Audio Control Interface (Feature Unit volume/mute control)
|
||||
//! - Audio Streaming Interface (PCM isochronous audio data)
|
||||
//! - Sample rates: 8000, 11025, 16000, 22050, 44100, 48000 Hz
|
||||
//! - 1-2 channels (mono/stereo), 8/16-bit
|
||||
//! On Redox: registers /scheme/audio/usbAudio_<N> for audiod integration.
|
||||
|
||||
#[cfg(target_os = "redox")]
|
||||
mod scheme;
|
||||
|
||||
use std::{env, io, io::{Read, Write}, thread, time};
|
||||
|
||||
use xhcid_interface::{
|
||||
ConfigureEndpointsReq, DevDesc, DeviceReqData, EndpDirection,
|
||||
PortReqRecipient, PortReqTy, XhciClientHandle, XhciEndpHandle,
|
||||
};
|
||||
|
||||
// USB Audio Class codes (Linux 7.1 include/uapi/linux/usb/audio.h)
|
||||
const USB_CLASS_AUDIO: u8 = 0x01;
|
||||
const USB_SUBCLASS_AUDIOCONTROL: u8 = 0x01;
|
||||
const USB_SUBCLASS_AUDIOSTREAMING: u8 = 0x02;
|
||||
|
||||
// Audio Interface descriptor subtypes
|
||||
const AUDIO_CS_INTERFACE: u8 = 0x24;
|
||||
const AUDIO_AC_HEADER: u8 = 0x01;
|
||||
const AUDIO_AC_INPUT_TERMINAL: u8 = 0x02;
|
||||
const AUDIO_AC_OUTPUT_TERMINAL: u8 = 0x03;
|
||||
const AUDIO_AC_FEATURE_UNIT: u8 = 0x06;
|
||||
const AUDIO_AS_GENERAL: u8 = 0x01;
|
||||
const AUDIO_AS_FORMAT_TYPE: u8 = 0x02;
|
||||
|
||||
// Audio control request codes (bmRequestType = 0x21 for SET, 0xA1 for GET)
|
||||
const REQUEST_SET_CUR: u8 = 0x01;
|
||||
const REQUEST_GET_CUR: u8 = 0x81;
|
||||
|
||||
// Feature Unit control selectors
|
||||
const FU_MUTE: u8 = 0x01;
|
||||
const FU_VOLUME: u8 = 0x02;
|
||||
|
||||
// Format type codes
|
||||
const FORMAT_TYPE_PCM: u16 = 0x0001;
|
||||
|
||||
// Supported sample rates
|
||||
const SUPPORTED_RATES: &[u32] = &[48000, 44100, 22050, 16000, 11025, 8000];
|
||||
|
||||
struct AudioDevice {
|
||||
handle: XhciClientHandle,
|
||||
isoch_in: XhciEndpHandle,
|
||||
isoch_out: Option<XhciEndpHandle>,
|
||||
stream_iface: u8,
|
||||
feature_unit_id: u8,
|
||||
channels: u8,
|
||||
bit_depth: u8,
|
||||
sample_rate: u32,
|
||||
}
|
||||
fn scan() -> usize {
|
||||
let mut n = 0;
|
||||
let _ = fs::create_dir_all("/dev/audio");
|
||||
if let Ok(dir) = fs::read_dir("/scheme/usb") {
|
||||
for e in dir.flatten() {
|
||||
if let Ok(c) = fs::read_to_string(e.path().join("config")) {
|
||||
if c.contains("class=01") {
|
||||
let tgt = e.path();
|
||||
let lnk = format!("/dev/audio/usb{}", n);
|
||||
let _ = std::os::unix::fs::symlink(&tgt, &lnk);
|
||||
n += 1;
|
||||
|
||||
impl AudioDevice {
|
||||
fn audio_req(&self, bm_request_type: u8, request: u8, value: u16, index: u16, mut data: &mut [u8]) -> Result<(), io::Error> {
|
||||
let req_data = if bm_request_type & 0x80 != 0 {
|
||||
DeviceReqData::In(data)
|
||||
} else {
|
||||
DeviceReqData::Out(data)
|
||||
};
|
||||
self.handle
|
||||
.device_request(
|
||||
PortReqTy::Class,
|
||||
PortReqRecipient::Interface,
|
||||
request,
|
||||
value,
|
||||
index,
|
||||
req_data,
|
||||
)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("UAC: {}", e)))
|
||||
}
|
||||
|
||||
fn get_volume(&self, channel: u8) -> Result<i16, io::Error> {
|
||||
let mut buf = [0u8; 2];
|
||||
self.audio_req(
|
||||
0xA1, // IN, Class, Interface
|
||||
REQUEST_GET_CUR,
|
||||
u16::from(FU_VOLUME) << 8 | u16::from(channel),
|
||||
u16::from(self.feature_unit_id) << 8 | u16::from(self.stream_iface),
|
||||
&mut buf,
|
||||
)?;
|
||||
Ok(i16::from_le_bytes(buf))
|
||||
}
|
||||
|
||||
fn set_volume(&self, channel: u8, volume: i16) -> Result<(), io::Error> {
|
||||
let mut data = volume.to_le_bytes();
|
||||
self.audio_req(
|
||||
0x21, // OUT, Class, Interface
|
||||
REQUEST_SET_CUR,
|
||||
u16::from(FU_VOLUME) << 8 | u16::from(channel),
|
||||
u16::from(self.feature_unit_id) << 8 | u16::from(self.stream_iface),
|
||||
&mut data,
|
||||
)
|
||||
}
|
||||
|
||||
fn get_mute(&self, channel: u8) -> Result<bool, io::Error> {
|
||||
let mut buf = [0u8; 1];
|
||||
self.audio_req(
|
||||
0xA1,
|
||||
REQUEST_GET_CUR,
|
||||
u16::from(FU_MUTE) << 8 | u16::from(channel),
|
||||
u16::from(self.feature_unit_id) << 8 | u16::from(self.stream_iface),
|
||||
&mut buf,
|
||||
)?;
|
||||
Ok(buf[0] != 0)
|
||||
}
|
||||
|
||||
fn set_mute(&self, channel: u8, mute: bool) -> Result<(), io::Error> {
|
||||
let mut data = [mute as u8];
|
||||
self.audio_req(
|
||||
0x21,
|
||||
REQUEST_SET_CUR,
|
||||
u16::from(FU_MUTE) << 8 | u16::from(channel),
|
||||
u16::from(self.feature_unit_id) << 8 | u16::from(self.stream_iface),
|
||||
&mut data,
|
||||
)
|
||||
}
|
||||
|
||||
fn set_sample_rate(&self, rate: u32) -> Result<(), io::Error> {
|
||||
let mut data = rate.to_le_bytes();
|
||||
self.audio_req(
|
||||
0x21,
|
||||
REQUEST_SET_CUR,
|
||||
0x0100,
|
||||
u16::from(self.stream_iface),
|
||||
&mut data,
|
||||
)
|
||||
}
|
||||
|
||||
fn frame_size(&self) -> usize {
|
||||
self.channels as usize * (self.bit_depth as usize / 8)
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
log::set_max_level(log::LevelFilter::Info);
|
||||
common::init();
|
||||
|
||||
let mut args = env::args().skip(1);
|
||||
const USAGE: &str = "redbear-usbaudiod <scheme> <port>";
|
||||
|
||||
let scheme = args.next().expect(USAGE);
|
||||
let port_id = args.next().expect(USAGE)
|
||||
.parse::<xhcid_interface::PortId>()
|
||||
.expect("Expected port ID");
|
||||
|
||||
let name = format!("{}_{}_uac", scheme, port_id);
|
||||
common::setup_logging("usb", "device", &name,
|
||||
common::output_level(), common::file_level());
|
||||
|
||||
let handle = XhciClientHandle::new(scheme.clone(), port_id)
|
||||
.expect("Failed to open XhciClientHandle");
|
||||
let desc: DevDesc = handle.get_standard_descs()
|
||||
.expect("Failed to get descriptors");
|
||||
|
||||
// Find Audio Control interface and Audio Streaming interface
|
||||
let mut ac_iface: u8 = 0;
|
||||
let mut as_iface: u8 = 0;
|
||||
let mut conf_val: u8 = 0;
|
||||
let mut channels: u8 = 2; // default stereo
|
||||
let mut bit_depth: u8 = 16; // default 16-bit
|
||||
let mut sample_rate: u32 = 48000;
|
||||
let mut feature_unit_id: u8 = 2; // default: first FU after IT
|
||||
|
||||
for conf_desc in desc.config_descs.iter() {
|
||||
for ifd in conf_desc.interface_descs.iter() {
|
||||
if ifd.class == USB_CLASS_AUDIO && ifd.sub_class == USB_SUBCLASS_AUDIOCONTROL {
|
||||
ac_iface = ifd.number;
|
||||
}
|
||||
if ifd.class == USB_CLASS_AUDIO && ifd.sub_class == USB_SUBCLASS_AUDIOSTREAMING {
|
||||
as_iface = ifd.number;
|
||||
conf_val = conf_desc.configuration_value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ac_iface == 0 || as_iface == 0 {
|
||||
log::error!("UAC: no audio interfaces found (ac={} as={})", ac_iface, as_iface);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find isoch endpoints on the streaming interface
|
||||
let mut isoch_in_num = 0u8;
|
||||
let mut isoch_out_num = 0u8;
|
||||
let mut max_pkt_size = 0u16;
|
||||
|
||||
for conf_desc in desc.config_descs.iter() {
|
||||
for ifd in conf_desc.interface_descs.iter() {
|
||||
if ifd.number == as_iface {
|
||||
for ep in ifd.endpoints.iter() {
|
||||
if ep.attributes & 0x03 == 0x01 { // isoch transfer type
|
||||
if ep.max_packet_size > max_pkt_size {
|
||||
max_pkt_size = ep.max_packet_size;
|
||||
}
|
||||
match ep.direction() {
|
||||
EndpDirection::In => isoch_in_num = ep.address & 0x0F,
|
||||
EndpDirection::Out => isoch_out_num = ep.address & 0x0F,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
fn main() {
|
||||
log::set_logger(&StderrLogger).ok();
|
||||
log::set_max_level(LevelFilter::Info);
|
||||
info!("redbear-usbaudiod: USB Audio Class daemon");
|
||||
loop { let c = scan(); if c > 0 { info!("redbear-usbaudiod: {} usb audio symlink(s)", c); } std::thread::sleep(Duration::from_secs(5)); }
|
||||
|
||||
// Determine format from max packet size heuristics
|
||||
if max_pkt_size >= 384 { sample_rate = 48000; channels = 2; bit_depth = 16; }
|
||||
else if max_pkt_size >= 192 { sample_rate = 48000; channels = 2; bit_depth = 16; }
|
||||
else { sample_rate = 48000; channels = 1; bit_depth = 16; }
|
||||
|
||||
log::info!("UAC: detected AC iface={} AS iface={} max_pkt={} ch={} bits={} rate={}",
|
||||
ac_iface, as_iface, max_pkt_size, channels, bit_depth, sample_rate);
|
||||
|
||||
// Configure the streaming interface
|
||||
handle.configure_endpoints(&ConfigureEndpointsReq {
|
||||
config_desc: conf_val,
|
||||
interface_desc: Some(as_iface),
|
||||
alternate_setting: Some(0),
|
||||
hub_ports: None,
|
||||
}).expect("Config streaming interface");
|
||||
|
||||
let mut dev = AudioDevice {
|
||||
isoch_in: handle.open_endpoint(isoch_in_num)
|
||||
.expect("Failed to open isoch IN"),
|
||||
isoch_out: if isoch_out_num > 0 {
|
||||
Some(handle.open_endpoint(isoch_out_num)
|
||||
.expect("Failed to open isoch OUT"))
|
||||
} else { None },
|
||||
handle,
|
||||
stream_iface: as_iface,
|
||||
feature_unit_id,
|
||||
channels,
|
||||
bit_depth,
|
||||
sample_rate,
|
||||
};
|
||||
|
||||
// Set sample rate and unmute
|
||||
let _ = dev.set_sample_rate(sample_rate);
|
||||
let _ = dev.set_mute(0, false);
|
||||
log::info!("UAC: sample rate={} unmuted", sample_rate);
|
||||
|
||||
// ── Scheme IPC path (Redox) ──
|
||||
#[cfg(target_os = "redox")]
|
||||
{
|
||||
use redox_scheme::scheme::SchemeSync;
|
||||
use redox_scheme::{Socket, SignalBehavior};
|
||||
let scheme_name = format!("audio/usbAudio_{}", port_id);
|
||||
log::info!("UAC: registering /scheme/{}", scheme_name);
|
||||
let socket = Socket::create().expect("UAC: scheme socket");
|
||||
let mut scheme_state = redox_scheme::scheme::SchemeState::new();
|
||||
let mut audio_scheme = crate::scheme::AudioScheme::new(dev.isoch_in, dev.isoch_out);
|
||||
socket.register(&scheme_name).expect("UAC: scheme register");
|
||||
loop {
|
||||
let req = socket.next_request(SignalBehavior::Restart).expect("UAC: scheme request");
|
||||
if let Err(e) = req.handle_scheme_mut(&mut audio_scheme, &mut scheme_state) {
|
||||
log::warn!("UAC: scheme error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stdout path (host/Linux testing) ──
|
||||
#[cfg(not(target_os = "redox"))]
|
||||
{
|
||||
let frame_size = dev.frame_size();
|
||||
if let Some(isoch_out) = dev.isoch_out {
|
||||
let mut isoch_out = isoch_out;
|
||||
thread::spawn(move || {
|
||||
let mut buf = vec![0u8; frame_size * 480];
|
||||
loop {
|
||||
match io::stdin().read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => { let _ = isoch_out.transfer_write(&buf[..n]); }
|
||||
Err(e) => { log::warn!("UAC: stdin: {}", e); break; }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
let buf_size = frame_size * 480;
|
||||
let mut buf = vec![0u8; buf_size];
|
||||
loop {
|
||||
match dev.isoch_in.transfer_read(&mut buf) {
|
||||
Ok(s) if s.bytes_transferred > 0 => { let _ = io::stdout().write_all(&buf[..s.bytes_transferred as usize]); let _ = io::stdout().flush(); }
|
||||
Ok(_) => {} Err(e) => log::warn!("UAC: read: {}", e),
|
||||
}
|
||||
thread::sleep(time::Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
use std::sync::Mutex;
|
||||
|
||||
use redox_scheme::scheme::SchemeSync;
|
||||
use redox_scheme::{CallerCtx, OpenResult};
|
||||
use syscall::error::{Error, Result, EBADF, EINVAL};
|
||||
use syscall::flag::{MODE_FILE, O_RDONLY, O_WRONLY, O_STAT};
|
||||
|
||||
use xhcid_interface::XhciEndpHandle;
|
||||
|
||||
pub struct AudioScheme {
|
||||
isoch_in: Mutex<XhciEndpHandle>,
|
||||
isoch_out: Mutex<Option<XhciEndpHandle>>,
|
||||
open_count: Mutex<usize>,
|
||||
}
|
||||
|
||||
impl AudioScheme {
|
||||
pub fn new(isoch_in: XhciEndpHandle, isoch_out: Option<XhciEndpHandle>) -> Self {
|
||||
Self { isoch_in: Mutex::new(isoch_in), isoch_out: Mutex::new(isoch_out), open_count: Mutex::new(0) }
|
||||
}
|
||||
}
|
||||
|
||||
impl SchemeSync for AudioScheme {
|
||||
fn openat(&self, id: usize, path: &str, _flags: usize, _ctx: &CallerCtx) -> Result<OpenResult> {
|
||||
if id != 1 { return Err(Error::new(EBADF)); }
|
||||
let mut c = self.open_count.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if path.is_empty() || path == "." || path == "/" {
|
||||
*c += 1; return Ok(OpenResult::ThisScheme { number: 1, flags: NewFdFlags::from_bits_truncate((O_STAT | MODE_FILE) as u8) });
|
||||
}
|
||||
let flags = match path {
|
||||
"capture" | "rec" => O_RDONLY,
|
||||
"playback" | "play" => if self.isoch_out.lock().unwrap_or_else(|e| e.into_inner()).is_some() { O_WRONLY } else { return Err(Error::new(EINVAL)); },
|
||||
_ => O_RDWR,
|
||||
};
|
||||
let next_id = 2 + *c; *c += 1;
|
||||
Ok(OpenResult::OtherScheme { fd: next_id })
|
||||
}
|
||||
fn read(&mut self, id: usize, buf: &mut [u8], _ctx: &CallerCtx) -> Result<usize> {
|
||||
if id < 2 || buf.is_empty() { return Err(Error::new(EBADF)); }
|
||||
let mut isoch_in = self.isoch_in.lock().unwrap_or_else(|e| e.into_inner());
|
||||
match isoch_in.transfer_read(buf) {
|
||||
Ok(status) if status.bytes_transferred > 0 => Ok(status.bytes_transferred as usize),
|
||||
Ok(_) => Ok(0),
|
||||
Err(e) => { log::warn!("Audio scheme: read: {}", e); Err(Error::new(EINVAL)) }
|
||||
}
|
||||
}
|
||||
fn write(&mut self, id: usize, buf: &[u8], _ctx: &CallerCtx) -> Result<usize> {
|
||||
if id < 2 || buf.is_empty() { return Err(Error::new(EBADF)); }
|
||||
match self.isoch_out.lock().unwrap_or_else(|e| e.into_inner()).as_mut() {
|
||||
Some(isoch_out) => match isoch_out.transfer_write(buf) {
|
||||
Ok(status) if status.bytes_transferred > 0 => Ok(status.bytes_transferred as usize),
|
||||
Ok(_) => Ok(0),
|
||||
Err(e) => { log::warn!("Audio scheme: write: {}", e); Err(Error::new(EINVAL)) }
|
||||
},
|
||||
None => Err(Error::new(EINVAL)),
|
||||
}
|
||||
}
|
||||
fn fsync(&mut self, _id: usize, _ctx: &CallerCtx) -> Result<()> { Ok(()) }
|
||||
fn close(&self, id: usize, _ctx: &CallerCtx) -> Result<()> {
|
||||
if id >= 2 {
|
||||
let mut count = self.open_count.lock().unwrap_or_else(|e| e.into_inner());
|
||||
*count = count.saturating_sub(1);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -299,9 +299,10 @@ Mirrors MC's `filegui.c::file_progress_real_query_replace` and the
|
||||
FILE_SKIP, FILE_ABORT, FILE_IGNORE. TLC has no FTP/SFTP so the
|
||||
"All" variants (FILE_IGNORE_ALL, FILE_RETRY_ALL) are accepted
|
||||
by the dialog but not yet wired through the copy engine.
|
||||
Retry is a stub (logs a message) because batch step resumption
|
||||
would require threading the op index through copy/move/delete
|
||||
batch functions — deferred to a follow-up sprint.
|
||||
Retry in the progress-dialog error path IS functional (re-begins
|
||||
the operation and re-runs). Retry in the background jobs dialog
|
||||
(jobs.rs:600-604) is state-only — the original sources list cannot
|
||||
be recovered from the jobs dialog context.
|
||||
|
||||
New file: `src/filemanager/error_dialog.rs`
|
||||
- `ErrorDialog` with path + error message
|
||||
|
||||
@@ -40,10 +40,14 @@ impl Application {
|
||||
});
|
||||
|
||||
let start: PathBuf = if cli.start_path.is_empty() {
|
||||
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"))
|
||||
std::env::current_dir()
|
||||
.inspect_err(|e| log::warn!("current_dir failed: {e} — using /"))
|
||||
.unwrap_or_else(|_| PathBuf::from("/"))
|
||||
} else {
|
||||
let p = PathBuf::from(&cli.start_path);
|
||||
p.canonicalize().unwrap_or(p)
|
||||
p.canonicalize()
|
||||
.inspect_err(|e| log::warn!("canonicalize({}) failed: {e}", p.display()))
|
||||
.unwrap_or(p)
|
||||
};
|
||||
|
||||
let mut fm = FileManager::new(&start, &cfg)?;
|
||||
@@ -52,7 +56,7 @@ impl Application {
|
||||
let keymap = default_keymap();
|
||||
|
||||
// MC parity: cursor hidden in panel mode; render() manages visibility.
|
||||
let _ = tui.terminal_mut().hide_cursor();
|
||||
tui.terminal_mut().hide_cursor().ok();
|
||||
|
||||
// Initial paint.
|
||||
render(&mut tui, &mut fm)?;
|
||||
@@ -71,7 +75,12 @@ impl Application {
|
||||
|
||||
let stdin_fd = raw_stdin();
|
||||
let mut poll_fds = [PollFd::new(&stdin_fd, PollFlags::IN)];
|
||||
let _ = poll(&mut poll_fds, Some(&poll_timeout));
|
||||
if let Err(e) = poll(&mut poll_fds, Some(&poll_timeout)) {
|
||||
log::error!("poll() failed in main event loop: {e} — stdin may be dead");
|
||||
// On EBADF or ENOMEM, the event loop is broken.
|
||||
// Log and break; the user sees TLC exit cleanly.
|
||||
break;
|
||||
}
|
||||
|
||||
let size = tui.size();
|
||||
if size.0 > 0 && size.1 > 0 && size != prev_size {
|
||||
@@ -113,10 +122,8 @@ impl Application {
|
||||
let key = match event {
|
||||
TermEvent::Key(k) => translate_key(k),
|
||||
TermEvent::Mouse(m) => {
|
||||
// D5: route mouse events through the same
|
||||
// handle_mouse_event helper used by tests, then
|
||||
// render and continue (don't fall through to
|
||||
// the keyboard-dispatch path).
|
||||
// Route to viewer if active, otherwise to file manager.
|
||||
fm.handle_viewer_mouse(m);
|
||||
fm.handle_mouse_event(m, tui.size());
|
||||
render(&mut tui, &mut fm)?;
|
||||
continue;
|
||||
@@ -189,9 +196,9 @@ impl Application {
|
||||
continue;
|
||||
}
|
||||
|
||||
if fm.menubar.is_some() {
|
||||
if let Some(mb) = fm.menubar.as_mut() {
|
||||
use crate::filemanager::menubar::MenuBarOutcome;
|
||||
let outcome = fm.menubar.as_mut().unwrap().handle_key(key);
|
||||
let outcome = mb.handle_key(key);
|
||||
match outcome {
|
||||
MenuBarOutcome::Running => {}
|
||||
MenuBarOutcome::Dispatch(cmd, target_menu) => {
|
||||
@@ -203,7 +210,9 @@ impl Application {
|
||||
fm.switch_focus();
|
||||
}
|
||||
}
|
||||
let _ = fm.dispatch(cmd);
|
||||
if let Err(e) = fm.dispatch(cmd) {
|
||||
fm.status.set_message(format!("{e}"));
|
||||
}
|
||||
}
|
||||
MenuBarOutcome::Close => {
|
||||
fm.menubar = None;
|
||||
@@ -344,7 +353,7 @@ fn render(tui: &mut Tui, fm: &mut FileManager) -> Result<()> {
|
||||
if need_cursor {
|
||||
let _ = tui.terminal_mut().show_cursor();
|
||||
} else {
|
||||
let _ = tui.terminal_mut().hide_cursor();
|
||||
tui.terminal_mut().hide_cursor().ok();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -44,7 +44,17 @@ pub struct Config {
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
toml::from_str(DEFAULT_CONFIG).expect("default.toml must be valid")
|
||||
toml::from_str(DEFAULT_CONFIG).unwrap_or_else(|e| {
|
||||
log::error!("embedded default.toml parse failed: {e} — falling back to empty config");
|
||||
Config {
|
||||
skin: SkinConfig::default(),
|
||||
filemanager: FilemanagerConfig::default(),
|
||||
editor: EditorConfig::default(),
|
||||
viewer: ViewerConfig::default(),
|
||||
runtime: RuntimeConfig::default(),
|
||||
vfs: VfsConfig::default(),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1157,7 +1157,22 @@ impl Editor {
|
||||
/// [`Mode::Prompt`] with the given kind. The caller's keymap is
|
||||
/// responsible for routing the trigger key to Normal mode first.
|
||||
fn open_prompt(&mut self, kind: PromptKind) -> EditorResult {
|
||||
// Auto-suggest: prefill the search prompt with the most
|
||||
// recent search pattern. The user can still type freely to
|
||||
// override. Only applies to Find / Replace (not Goto, etc.).
|
||||
let prefill: Option<String> = match kind {
|
||||
PromptKind::Find | PromptKind::Replace => self
|
||||
.search
|
||||
.history()
|
||||
.last()
|
||||
.cloned(),
|
||||
_ => None,
|
||||
};
|
||||
self.prompt_input.clear();
|
||||
if let Some(text) = prefill {
|
||||
self.prompt_input.text = text;
|
||||
self.prompt_input.cursor = self.prompt_input.text.chars().count();
|
||||
}
|
||||
self.mode = Mode::Prompt(kind);
|
||||
EditorResult::Running
|
||||
}
|
||||
|
||||
@@ -81,6 +81,20 @@ pub enum EditorCmd {
|
||||
EditUserMenu,
|
||||
/// Alt-P — reformat the paragraph at the cursor.
|
||||
FormatParagraph,
|
||||
/// Alt-Shift-A — select all text.
|
||||
MarkAll,
|
||||
/// Insert contents of another file at the cursor.
|
||||
InsertFile,
|
||||
/// Save the current selection to a different file.
|
||||
BlockSave,
|
||||
/// Close the current buffer (keep editor open).
|
||||
Close,
|
||||
/// Toggle insert/overwrite mode (INS key, MC CK_InsertOverwrite).
|
||||
ToggleOverwrite,
|
||||
/// Delete character/selection (MC CK_Remove).
|
||||
Delete,
|
||||
/// Toggle column/stream selection mode (MC CK_Mark).
|
||||
ToggleColumnMode,
|
||||
}
|
||||
|
||||
/// Outcome of a menu-bar key press.
|
||||
@@ -189,6 +203,10 @@ impl EditorMenuBar {
|
||||
MenuItem::item("Save", 's', EditorCmd::Save),
|
||||
MenuItem::item("Save as...", 'a', EditorCmd::SaveAs),
|
||||
MenuItem::separator(),
|
||||
MenuItem::item("Insert file...", 'i', EditorCmd::InsertFile),
|
||||
MenuItem::item("Save block as...", 'k', EditorCmd::BlockSave),
|
||||
MenuItem::separator(),
|
||||
MenuItem::item("Close", 'c', EditorCmd::Close),
|
||||
MenuItem::item("Quit", 'q', EditorCmd::Quit),
|
||||
],
|
||||
),
|
||||
@@ -201,6 +219,10 @@ impl EditorMenuBar {
|
||||
MenuItem::item("Cut", 'x', EditorCmd::Cut),
|
||||
MenuItem::item("Copy", 'c', EditorCmd::Copy),
|
||||
MenuItem::item("Paste", 'v', EditorCmd::Paste),
|
||||
MenuItem::item("Delete", 'd', EditorCmd::Delete),
|
||||
MenuItem::separator(),
|
||||
MenuItem::item("Select all", 'a', EditorCmd::MarkAll),
|
||||
MenuItem::item("Toggle column", 't', EditorCmd::ToggleColumnMode),
|
||||
],
|
||||
),
|
||||
Menu::new(
|
||||
@@ -247,6 +269,8 @@ impl EditorMenuBar {
|
||||
MenuItem::item("Word wrap", 'p', EditorCmd::ToggleWordWrap),
|
||||
MenuItem::item("Syntax highlight", 'h', EditorCmd::ToggleSyntax),
|
||||
MenuItem::item("Relative line numbers", 'r', EditorCmd::ToggleRelativeLines),
|
||||
MenuItem::separator(),
|
||||
MenuItem::item("Insert/Overwrite", 'i', EditorCmd::ToggleOverwrite),
|
||||
],
|
||||
),
|
||||
];
|
||||
@@ -598,7 +622,7 @@ mod tests {
|
||||
assert_eq!(m.menu_count(), 7);
|
||||
assert_eq!(
|
||||
m.active_items().len(),
|
||||
7 // File menu has 7 entries
|
||||
11 // File menu: New, Open, sep, Save, SaveAs, sep, InsertFile, BlockSave, sep, Close, Quit
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1310,6 +1310,32 @@ impl Editor {
|
||||
self.message = Some("Block copied".to_string());
|
||||
}
|
||||
}
|
||||
EditorCmd::ToggleColumnMode => {
|
||||
if self.cursor.selection_mode() == crate::editor::cursor::SelectionMode::Column {
|
||||
self.cursor.clear_selection();
|
||||
self.message = Some("Stream selection".to_string());
|
||||
} else {
|
||||
self.cursor.start_column_selection();
|
||||
self.message = Some("Column selection".to_string());
|
||||
}
|
||||
}
|
||||
EditorCmd::Delete => {
|
||||
if self.cursor.has_selection() {
|
||||
self.cursor.delete_selection(&mut self.buffer);
|
||||
self.cursor.set_position(self.buffer.cursor(), &self.buffer);
|
||||
self.modified = self.buffer.is_modified();
|
||||
self.message = Some("Deleted".to_string());
|
||||
} else {
|
||||
// Delete forward one character.
|
||||
self.cursor.set_position(self.buffer.cursor(), &self.buffer);
|
||||
let _ = self.cursor.select_right(&mut self.buffer);
|
||||
if self.cursor.has_selection() {
|
||||
self.cursor.delete_selection(&mut self.buffer);
|
||||
self.cursor.set_position(self.buffer.cursor(), &self.buffer);
|
||||
self.modified = self.buffer.is_modified();
|
||||
}
|
||||
}
|
||||
}
|
||||
EditorCmd::Paste => {
|
||||
if let Some(text) = self.clipboard.clone() {
|
||||
if self.cursor.has_selection() {
|
||||
@@ -1525,6 +1551,41 @@ impl Editor {
|
||||
*self = new_editor;
|
||||
self.message = Some(format!("Editing user menu: {}", path.display()));
|
||||
}
|
||||
EditorCmd::MarkAll => {
|
||||
let len = self.buffer.len();
|
||||
self.cursor.set_position(0, &self.buffer);
|
||||
self.cursor.start_selection();
|
||||
self.cursor.set_position(len, &self.buffer);
|
||||
}
|
||||
EditorCmd::InsertFile => {
|
||||
self.prompt_input.clear();
|
||||
self.mode = Mode::Prompt(PromptKind::InsertFile);
|
||||
}
|
||||
EditorCmd::BlockSave => {
|
||||
if let Some(text) = self.cursor.selected_text(&self.buffer) {
|
||||
self.prompt_input.clear();
|
||||
self.prompt_input.text = text;
|
||||
self.prompt_input.cursor = self.prompt_input.text.chars().count();
|
||||
self.mode = Mode::Prompt(PromptKind::SaveAs);
|
||||
} else {
|
||||
self.message = Some("No selection".to_string());
|
||||
}
|
||||
}
|
||||
EditorCmd::ToggleOverwrite => {
|
||||
self.overwrite = !self.overwrite;
|
||||
self.message = Some(if self.overwrite {
|
||||
"[OVR]".to_string()
|
||||
} else {
|
||||
"[INS]".to_string()
|
||||
});
|
||||
}
|
||||
EditorCmd::Close => {
|
||||
if self.modified {
|
||||
self.mode = Mode::Prompt(PromptKind::SaveBeforeClose);
|
||||
} else {
|
||||
*self = Self::new_empty();
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.message = Some(format!("F9: {:?} (not yet wired)", cmd));
|
||||
}
|
||||
@@ -2070,13 +2131,21 @@ mod tests {
|
||||
fn alt_slash_in_find_prompt_opens_history_popup() {
|
||||
let mut e = make_empty();
|
||||
e.insert_str("hello world hello");
|
||||
// Run two searches to populate history.
|
||||
// Run two searches to populate history. The second one
|
||||
// has to clear the auto-suggested "hello" prefix before
|
||||
// typing "world".
|
||||
e.handle_key(Key::alt('f'));
|
||||
for c in "hello".chars() {
|
||||
e.handle_key(Key::from_char(c));
|
||||
}
|
||||
e.handle_key(Key::ENTER);
|
||||
e.handle_key(Key::alt('f'));
|
||||
// Clear the auto-suggested "hello" from the prompt.
|
||||
e.handle_key(Key::BACKSPACE);
|
||||
e.handle_key(Key::BACKSPACE);
|
||||
e.handle_key(Key::BACKSPACE);
|
||||
e.handle_key(Key::BACKSPACE);
|
||||
e.handle_key(Key::BACKSPACE);
|
||||
for c in "world".chars() {
|
||||
e.handle_key(Key::from_char(c));
|
||||
}
|
||||
@@ -3838,5 +3907,68 @@ mod tests {
|
||||
e.push_goto_history(20);
|
||||
assert_eq!(e.goto_history, vec![10, 20]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_all_selects_entire_buffer() {
|
||||
let mut e = make_empty();
|
||||
e.insert_str("hello world");
|
||||
// Dispatch MarkAll via the dispatcher.
|
||||
let _ = e.dispatch_editor_cmd(crate::editor::menubar::EditorCmd::MarkAll);
|
||||
let sel = e.cursor.selection();
|
||||
assert!(sel.is_some());
|
||||
let (start, end) = sel.unwrap();
|
||||
assert_eq!(start, 0);
|
||||
assert_eq!(end, e.buffer.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_clean_buffer_reopens_empty() {
|
||||
let mut e = make_empty();
|
||||
e.insert_str("hello");
|
||||
e.modified = false;
|
||||
let _ = e.dispatch_editor_cmd(crate::editor::menubar::EditorCmd::Close);
|
||||
// Should have reopened as an empty buffer.
|
||||
assert_eq!(e.buffer.len(), 0);
|
||||
assert!(!e.modified);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_save_on_no_selection_shows_message() {
|
||||
let mut e = make_empty();
|
||||
e.insert_str("hello");
|
||||
e.cursor.clear_selection();
|
||||
let _ = e.dispatch_editor_cmd(crate::editor::menubar::EditorCmd::BlockSave);
|
||||
assert_eq!(e.message, Some("No selection".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_file_opens_insert_file_prompt() {
|
||||
let mut e = make_empty();
|
||||
let _ = e.dispatch_editor_cmd(crate::editor::menubar::EditorCmd::InsertFile);
|
||||
assert!(e.mode.is_prompt());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_forward_without_selection_removes_one_char() {
|
||||
let mut e = make_empty();
|
||||
e.insert_str("ab");
|
||||
e.buffer.set_cursor(0);
|
||||
e.cursor.set_position(0, &e.buffer);
|
||||
let _ = e.dispatch_editor_cmd(crate::editor::menubar::EditorCmd::Delete);
|
||||
assert_eq!(e.buffer.len(), 1);
|
||||
assert_eq!(e.buffer.as_string(), "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_overwrite_cycles_ovr_ins() {
|
||||
let mut e = make_empty();
|
||||
// Default is Insert mode (not overwrite).
|
||||
// Toggle once → overwrite on.
|
||||
let _ = e.dispatch_editor_cmd(crate::editor::menubar::EditorCmd::ToggleOverwrite);
|
||||
assert_eq!(e.message, Some("[OVR]".to_string()));
|
||||
// Toggle again → overwrite off.
|
||||
let _ = e.dispatch_editor_cmd(crate::editor::menubar::EditorCmd::ToggleOverwrite);
|
||||
assert_eq!(e.message, Some("[INS]".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
//! it at the cursor position.
|
||||
//!
|
||||
//! The MC `CK_EditUserMenu` variant opens the menu file itself in
|
||||
//! the editor — that path just runs `UserMenu::new().storage_path`
|
||||
//! through the regular `Open` flow, so we defer it to a TODO.
|
||||
//! the editor — implemented via `EditorCmd::EditUserMenu` in
|
||||
//! `dispatch_editor_cmd`.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
//! Appearance dialog (F9 → Options → Appearance).
|
||||
//!
|
||||
//! Mirrors MC's CK_OptionsAppearance — checkbox toggles for the
|
||||
//! menubar, command prompt, keybar, hint bar, and mini-status bar
|
||||
//! visibility, plus a "Skin..." button that opens the existing
|
||||
//! [`SkinDialog`].
|
||||
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::config::RuntimeConfig;
|
||||
use crate::key::Key;
|
||||
use crate::terminal::color::Theme;
|
||||
use crate::terminal::popup::{centered_percent_rect, render_popup};
|
||||
|
||||
/// The appearance checkbox dialog.
|
||||
pub struct AppearanceDialog {
|
||||
/// Current toggle state for each option.
|
||||
pub settings: AppearanceSettings,
|
||||
/// Currently selected row.
|
||||
cursor: usize,
|
||||
}
|
||||
|
||||
/// The five appearance toggles + a Skin… button.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppearanceSettings {
|
||||
/// Whether to show the F9 menu bar.
|
||||
pub show_menubar: bool,
|
||||
/// Whether to show the F1–F10 key hint bar.
|
||||
pub show_keybar: bool,
|
||||
/// Whether to show the hint/status bar.
|
||||
pub show_hintbar: bool,
|
||||
/// Whether to show the command-line prompt.
|
||||
pub show_cmdline: bool,
|
||||
/// Whether to show the mini-status line above the keybar.
|
||||
pub show_mini_status: bool,
|
||||
}
|
||||
|
||||
impl AppearanceSettings {
|
||||
/// Index the toggles. 0..4 are the five checkboxes; 5 is the
|
||||
/// "Skin..." button.
|
||||
fn label(idx: usize) -> &'static str {
|
||||
match idx {
|
||||
0 => " Menubar",
|
||||
1 => " Keybar",
|
||||
2 => " Hint bar",
|
||||
3 => " Command prompt",
|
||||
4 => " Mini status",
|
||||
5 => " Skins...",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// True when `idx` corresponds to the "Skin..." button (not a
|
||||
/// checkbox toggle).
|
||||
fn is_skin_button(idx: usize) -> bool {
|
||||
idx == 5
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&RuntimeConfig> for AppearanceSettings {
|
||||
fn from(cfg: &RuntimeConfig) -> Self {
|
||||
Self {
|
||||
show_menubar: cfg.show_menubar(),
|
||||
show_keybar: cfg.show_keybar(),
|
||||
show_hintbar: cfg.show_hintbar(),
|
||||
show_cmdline: cfg.show_cmdline(),
|
||||
show_mini_status: cfg.show_mini_status(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of a key press in the appearance dialog.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AppearanceOutcome {
|
||||
/// The "Skin..." button was activated.
|
||||
OpenSkin,
|
||||
/// Settings were committed (Enter on the last row or
|
||||
/// explicit Ok button).
|
||||
Confirm(AppearanceSettings),
|
||||
/// Dismissed without saving.
|
||||
Cancel,
|
||||
/// Dialog is still active (key consumed but no action taken).
|
||||
Running,
|
||||
}
|
||||
|
||||
impl AppearanceDialog {
|
||||
/// Create a new appearance dialog initialised from `cfg`.
|
||||
#[must_use]
|
||||
pub fn new(cfg: &RuntimeConfig) -> Self {
|
||||
Self {
|
||||
settings: AppearanceSettings::from(cfg),
|
||||
cursor: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a key event. Returns the outcome.
|
||||
pub fn handle_key(&mut self, key: Key) -> AppearanceOutcome {
|
||||
match key {
|
||||
Key::ESCAPE => AppearanceOutcome::Cancel,
|
||||
Key::UP => {
|
||||
self.cursor = self
|
||||
.cursor
|
||||
.checked_sub(1)
|
||||
.unwrap_or(5); // 5 = last row
|
||||
AppearanceOutcome::Running
|
||||
}
|
||||
Key::DOWN => {
|
||||
self.cursor = (self.cursor + 1) % 6;
|
||||
AppearanceOutcome::Running
|
||||
}
|
||||
Key::ENTER => {
|
||||
if AppearanceSettings::is_skin_button(self.cursor) {
|
||||
AppearanceOutcome::OpenSkin
|
||||
} else {
|
||||
// Toggle the checkbox.
|
||||
match self.cursor {
|
||||
0 => self.settings.show_menubar = !self.settings.show_menubar,
|
||||
1 => self.settings.show_keybar = !self.settings.show_keybar,
|
||||
2 => self.settings.show_hintbar = !self.settings.show_hintbar,
|
||||
3 => self.settings.show_cmdline = !self.settings.show_cmdline,
|
||||
4 => self.settings.show_mini_status = !self.settings.show_mini_status,
|
||||
_ => {}
|
||||
}
|
||||
AppearanceOutcome::Running
|
||||
}
|
||||
}
|
||||
_ => AppearanceOutcome::Running,
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the dialog.
|
||||
pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
|
||||
let popup = centered_percent_rect(area, 0.5, 0.55);
|
||||
let title = " Appearance ";
|
||||
let inner = render_popup(frame, popup, title, theme);
|
||||
|
||||
const ROWS: usize = 6;
|
||||
let constraints: Vec<Constraint> = (0..ROWS).map(|_| Constraint::Length(1)).collect();
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(constraints)
|
||||
.split(inner);
|
||||
|
||||
let base = Style::default()
|
||||
.fg(theme.foreground)
|
||||
.bg(theme.background);
|
||||
let selected = Style::default()
|
||||
.fg(theme.cursor_fg)
|
||||
.bg(theme.cursor_bg)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let accent = Style::default().fg(theme.accent).bg(theme.background);
|
||||
|
||||
for i in 0..ROWS {
|
||||
let style = if i == self.cursor { selected } else { base };
|
||||
let label = AppearanceSettings::label(i);
|
||||
let line = if AppearanceSettings::is_skin_button(i) {
|
||||
Line::from(Span::styled(label, style))
|
||||
} else {
|
||||
let ch = if self.settings.check(i) { "✔" } else { "☐" };
|
||||
let ch_style = if self.settings.check(i) {
|
||||
accent.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
base
|
||||
};
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" [{}] ", ch), ch_style),
|
||||
Span::styled(label, style),
|
||||
])
|
||||
};
|
||||
let p = Paragraph::new(line).style(style);
|
||||
frame.render_widget(p, chunks[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppearanceSettings {
|
||||
fn check(&self, idx: usize) -> bool {
|
||||
match idx {
|
||||
0 => self.show_menubar,
|
||||
1 => self.show_keybar,
|
||||
2 => self.show_hintbar,
|
||||
3 => self.show_cmdline,
|
||||
4 => self.show_mini_status,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_dialog_has_all_toggles_default_true() {
|
||||
let cfg = RuntimeConfig::default();
|
||||
let dlg = AppearanceDialog::new(&cfg);
|
||||
assert!(dlg.settings.show_menubar);
|
||||
assert!(dlg.settings.show_keybar);
|
||||
assert!(dlg.settings.show_hintbar);
|
||||
assert!(dlg.settings.show_cmdline);
|
||||
assert!(dlg.settings.show_mini_status);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_toggles_checkbox() {
|
||||
let cfg = RuntimeConfig::default();
|
||||
let mut dlg = AppearanceDialog::new(&cfg);
|
||||
// Toggle menubar off.
|
||||
dlg.cursor = 0;
|
||||
let outcome = dlg.handle_key(Key::ENTER);
|
||||
assert!(matches!(outcome, AppearanceOutcome::Running));
|
||||
assert!(!dlg.settings.show_menubar);
|
||||
// Toggle it back on.
|
||||
let _ = dlg.handle_key(Key::ENTER);
|
||||
assert!(dlg.settings.show_menubar);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn down_cycles_cursor() {
|
||||
let cfg = RuntimeConfig::default();
|
||||
let mut dlg = AppearanceDialog::new(&cfg);
|
||||
for _ in 0..7 {
|
||||
let _ = dlg.handle_key(Key::DOWN);
|
||||
}
|
||||
assert_eq!(dlg.cursor, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skin_button_returns_open_skin_outcome() {
|
||||
let cfg = RuntimeConfig::default();
|
||||
let mut dlg = AppearanceDialog::new(&cfg);
|
||||
dlg.cursor = 5;
|
||||
let outcome = dlg.handle_key(Key::ENTER);
|
||||
assert!(matches!(outcome, AppearanceOutcome::OpenSkin));
|
||||
}
|
||||
}
|
||||
@@ -600,6 +600,22 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_resolves_dot_prefix_against_base_dir() {
|
||||
let base = std::env::temp_dir().join("tlc-clp-cmdline-dot");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
std::fs::create_dir_all(base.join("inner")).unwrap();
|
||||
let mut c = Cmdline::new();
|
||||
c.activate();
|
||||
c.insert_text("./inn");
|
||||
// ./inn should resolve against base dir (not CWD).
|
||||
let changed = c.complete(Some(&base));
|
||||
assert!(changed);
|
||||
assert_eq!(c.input.value(), "./inner");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_populates_completions_for_popup() {
|
||||
let dir = std::env::temp_dir().join("tlc-clp-cmdline-completions");
|
||||
|
||||
@@ -356,6 +356,29 @@ impl FileManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open the viewer in raw mode (no magic/binary detection). MC
|
||||
/// parity for Shift-F3. The viewer forces Text mode regardless of
|
||||
/// file content.
|
||||
pub fn open_viewer_raw_for_cursor(&mut self) -> Result<()> {
|
||||
let p = self.active_panel().cursor_path();
|
||||
if p.as_os_str().is_empty() {
|
||||
self.status.set_message("view: no path selected");
|
||||
return Ok(());
|
||||
}
|
||||
match crate::viewer::Viewer::open(&p) {
|
||||
Ok(mut v) => {
|
||||
v.magic_mode = false;
|
||||
let _ = v.restore_cursor_position();
|
||||
self.viewer = Some(v);
|
||||
self.status.set_message(format!("Viewing {} (raw)", p.display()));
|
||||
}
|
||||
Err(e) => {
|
||||
self.status.set_message(format!("view: {e}"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open the M-? Find dialog. The pattern input is pre-filled with
|
||||
/// the cursor entry's name (so searching for the selected file's
|
||||
/// pattern is the default); falls back to the default placeholder
|
||||
@@ -803,42 +826,63 @@ Err(e) => self.status.set_error(format!("view: {e}")),
|
||||
let thread_sources = sources.clone();
|
||||
let thread_dest = destination.clone();
|
||||
std::thread::spawn(move || {
|
||||
let result = match kind {
|
||||
match kind {
|
||||
crate::ops::OpKind::Copy => {
|
||||
let dst = thread_dest.expect("copy needs a destination");
|
||||
crate::ops::copy::copy_many(
|
||||
let Some(dst) = thread_dest else {
|
||||
let _ = tx.send(Err(crate::ops::OpsError::Other(
|
||||
"copy: missing destination".into(),
|
||||
)));
|
||||
return;
|
||||
};
|
||||
let r = crate::ops::copy::copy_many(
|
||||
&thread_sources,
|
||||
&dst,
|
||||
&thread_handle,
|
||||
false,
|
||||
preserve_attributes,
|
||||
follow_links,
|
||||
)
|
||||
);
|
||||
let _ = tx.send(r);
|
||||
return;
|
||||
}
|
||||
crate::ops::OpKind::Move => {
|
||||
let dst = thread_dest.expect("move needs a destination");
|
||||
crate::ops::move_op::move_many(
|
||||
let Some(dst) = thread_dest else {
|
||||
let _ = tx.send(Err(crate::ops::OpsError::Other(
|
||||
"move: missing destination".into(),
|
||||
)));
|
||||
return;
|
||||
};
|
||||
let r = crate::ops::move_op::move_many(
|
||||
&thread_sources,
|
||||
&dst,
|
||||
&thread_handle,
|
||||
false,
|
||||
preserve_attributes,
|
||||
follow_links,
|
||||
)
|
||||
);
|
||||
let _ = tx.send(r);
|
||||
return;
|
||||
}
|
||||
crate::ops::OpKind::Delete => {
|
||||
crate::ops::delete::delete_many(&thread_sources, &thread_handle)
|
||||
let r = crate::ops::delete::delete_many(&thread_sources, &thread_handle);
|
||||
let _ = tx.send(r);
|
||||
return;
|
||||
}
|
||||
crate::ops::OpKind::MkDir => unreachable!("mkdir not spawned with progress"),
|
||||
};
|
||||
let _ = tx.send(result);
|
||||
crate::ops::OpKind::MkDir => {
|
||||
// MkDir is instantaneous — it should never be
|
||||
// spawned with a progress dialog. If it reaches
|
||||
// this branch, treat it as a success (no work).
|
||||
let _ = tx.send(Ok(()));
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let title = match kind {
|
||||
crate::ops::OpKind::Copy => "Copying...".to_string(),
|
||||
crate::ops::OpKind::Move => "Moving...".to_string(),
|
||||
crate::ops::OpKind::Delete => "Deleting...".to_string(),
|
||||
crate::ops::OpKind::MkDir => unreachable!("mkdir not spawned with progress"),
|
||||
crate::ops::OpKind::MkDir => "Creating...".to_string(),
|
||||
};
|
||||
let mut pd = crate::ops::progress::ProgressDialog::new();
|
||||
pd.title = title;
|
||||
@@ -985,6 +1029,7 @@ Err(e) => self.status.set_error(format!("view: {e}")),
|
||||
| Some(DialogState::Encoding(_))
|
||||
| Some(DialogState::Connection(_))
|
||||
| Some(DialogState::Sort(_))
|
||||
| Some(DialogState::Appearance(_))
|
||||
| Some(DialogState::Confirm(_))
|
||||
| Some(DialogState::Progress(_)) => {
|
||||
// These are closed by their own apply_*_outcome
|
||||
@@ -1421,6 +1466,7 @@ fn dialog_label(d: &DialogState) -> String {
|
||||
DialogState::Encoding(_) => "Display encoding".to_string(),
|
||||
DialogState::Connection(_) => "Network connection".to_string(),
|
||||
DialogState::Sort(_) => "Sort order".to_string(),
|
||||
DialogState::Appearance(_) => "Appearance".to_string(),
|
||||
DialogState::Confirm(_) => "Confirmation".to_string(),
|
||||
DialogState::Progress(_) => "Progress".to_string(),
|
||||
}
|
||||
|
||||
@@ -12,11 +12,12 @@ use std::sync::Arc;
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::filemanager::{
|
||||
chattr_dialog, compare, config_dialog, connection_dialog, display_bits_dialog,
|
||||
edit_history, encoding_dialog, external_panelize, filter_dialog, filtered_view,
|
||||
find, format_utils, hotlist, jobs, learn_keys_dialog, layout_dialog, link,
|
||||
menubar, panel_options, quickcd_dialog, screen_list, skin_dialog,
|
||||
tree, usermenu, vfs_list, vfs_settings_dialog, Cmd, DialogState, FileManager,
|
||||
appearance_dialog, chattr_dialog, compare, config_dialog, confirm_dialog,
|
||||
connection_dialog, display_bits_dialog, edit_history, encoding_dialog,
|
||||
external_panelize, filter_dialog, filtered_view, find, format_utils, hotlist,
|
||||
jobs, learn_keys_dialog, layout_dialog, link, menubar, panel_options,
|
||||
quickcd_dialog, screen_list, skin_dialog, tree, usermenu, vfs_list,
|
||||
vfs_settings_dialog, Cmd, DialogState, FileManager,
|
||||
};
|
||||
use crate::filemanager::link::LinkKind;
|
||||
use crate::key::Key;
|
||||
@@ -108,6 +109,10 @@ impl FileManager {
|
||||
self.open_viewer_for_cursor()?;
|
||||
Ok(true)
|
||||
}
|
||||
Cmd::ViewRaw => {
|
||||
self.open_viewer_raw_for_cursor()?;
|
||||
Ok(true)
|
||||
}
|
||||
Cmd::UserMenu => {
|
||||
self.open_user_menu_dialog()?;
|
||||
Ok(true)
|
||||
@@ -116,6 +121,11 @@ impl FileManager {
|
||||
self.open_hotlist_dialog()?;
|
||||
Ok(true)
|
||||
}
|
||||
Cmd::HotListAdd => {
|
||||
self.open_hotlist_dialog()?;
|
||||
self.status.set_message("Hotlist — press Ins to add current directory");
|
||||
Ok(true)
|
||||
}
|
||||
Cmd::Tree => {
|
||||
self.open_tree_dialog()?;
|
||||
Ok(true)
|
||||
@@ -372,7 +382,20 @@ impl FileManager {
|
||||
Ok(true)
|
||||
}
|
||||
Cmd::OptionsConfirm => {
|
||||
self.toggle_confirmations();
|
||||
let init = confirm_dialog::ConfirmSettings {
|
||||
confirm_delete: self.runtime.safe_delete.unwrap_or(true),
|
||||
confirm_overwrite: true,
|
||||
confirm_execute: self.runtime.safe_execute.unwrap_or(false),
|
||||
confirm_exit: true,
|
||||
};
|
||||
self.dialog = Some(DialogState::Confirm(Box::new(
|
||||
confirm_dialog::ConfirmDialog::new(init),
|
||||
)));
|
||||
Ok(true)
|
||||
}
|
||||
Cmd::Appearance => {
|
||||
let dlg = appearance_dialog::AppearanceDialog::new(&self.runtime);
|
||||
self.dialog = Some(DialogState::Appearance(Box::new(dlg)));
|
||||
Ok(true)
|
||||
}
|
||||
Cmd::CompareDirs => {
|
||||
@@ -914,6 +937,31 @@ impl FileManager {
|
||||
}
|
||||
consumed = true;
|
||||
}
|
||||
Some(DialogState::Appearance(d)) => {
|
||||
use crate::filemanager::appearance_dialog::AppearanceOutcome;
|
||||
let r = d.handle_key(key);
|
||||
match r {
|
||||
AppearanceOutcome::Confirm(settings) => {
|
||||
self.runtime.show_menubar = Some(settings.show_menubar);
|
||||
self.runtime.show_keybar = Some(settings.show_keybar);
|
||||
self.runtime.show_hintbar = Some(settings.show_hintbar);
|
||||
self.runtime.show_cmdline = Some(settings.show_cmdline);
|
||||
self.runtime.show_mini_status = Some(settings.show_mini_status);
|
||||
self.status.set_message("Appearance settings updated".to_string());
|
||||
self.dialog = None;
|
||||
}
|
||||
AppearanceOutcome::OpenSkin => {
|
||||
self.dialog = Some(DialogState::Skin(Box::new(
|
||||
skin_dialog::SkinDialog::new(&self.skin_name),
|
||||
)));
|
||||
}
|
||||
AppearanceOutcome::Cancel => {
|
||||
self.dialog = None;
|
||||
}
|
||||
AppearanceOutcome::Running => {}
|
||||
}
|
||||
consumed = true;
|
||||
}
|
||||
Some(DialogState::Sort(d)) => {
|
||||
use crate::filemanager::sort_dialog::SortResult;
|
||||
let r = d.handle_key(key);
|
||||
|
||||
@@ -146,6 +146,7 @@ fn build_menus() -> Vec<Menu> {
|
||||
MenuItem::item("Layout...", Cmd::LayoutDialog),
|
||||
MenuItem::item("Panel options...", Cmd::PanelOptionsDialog),
|
||||
MenuItem::item("Confirmation...", Cmd::OptionsConfirm),
|
||||
MenuItem::item("Appearance...", Cmd::Appearance),
|
||||
MenuItem::item("Skins...", Cmd::SkinSelect),
|
||||
MenuItem::item("Display bits...", Cmd::DisplayBits),
|
||||
MenuItem::item("Learn keys...", Cmd::LearnKeysDialog),
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod cmdline;
|
||||
pub mod compare;
|
||||
pub mod config_dialog;
|
||||
pub mod confirm_dialog;
|
||||
pub mod appearance_dialog;
|
||||
pub mod connection_dialog;
|
||||
pub mod connection_manager;
|
||||
pub mod copy_dialog;
|
||||
@@ -356,6 +357,8 @@ pub enum DialogState {
|
||||
Connection(Box<ConnectionDialog>),
|
||||
/// Alt-Shift-T — sort order dialog.
|
||||
Sort(Box<sort_dialog::SortDialog>),
|
||||
/// F9 → Options → Appearance — show/hide UI bars.
|
||||
Appearance(Box<appearance_dialog::AppearanceDialog>),
|
||||
/// F9 → Options → Confirmation — per-operation confirm toggles.
|
||||
Confirm(Box<confirm_dialog::ConfirmDialog>),
|
||||
/// F5/F6/F8 — live progress dialog for background copy/move/delete.
|
||||
@@ -420,6 +423,7 @@ impl DialogState {
|
||||
DialogState::Encoding(_) => false,
|
||||
DialogState::Connection(_) => false,
|
||||
DialogState::Sort(_) => false,
|
||||
DialogState::Appearance(_) => false,
|
||||
DialogState::Confirm(_) => false,
|
||||
DialogState::Progress(_) => false,
|
||||
}
|
||||
@@ -706,7 +710,7 @@ impl FileManager {
|
||||
/// viewer was closed.
|
||||
pub fn handle_viewer_key(&mut self, key: Key) -> bool {
|
||||
if let Some(v) = &mut self.viewer {
|
||||
let closed = v.handle_key(key);
|
||||
let closed = v.handle_key(key) || v.should_close;
|
||||
if closed {
|
||||
v.save_cursor_position();
|
||||
}
|
||||
@@ -716,6 +720,13 @@ impl FileManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Route a mouse event to the active viewer (if any).
|
||||
pub fn handle_viewer_mouse(&mut self, m: termion::event::MouseEvent) {
|
||||
if let Some(v) = &mut self.viewer {
|
||||
v.handle_mouse(m);
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a shell command. The command runs synchronously and its
|
||||
/// captured stdout/stderr is shown in an [`ExecDialog`].
|
||||
///
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Paragraph, Wrap};
|
||||
use ratatui::Frame;
|
||||
@@ -29,6 +29,8 @@ pub enum OwnerField {
|
||||
Uid,
|
||||
/// The GID input.
|
||||
Gid,
|
||||
/// Recursive checkbox.
|
||||
Recursive,
|
||||
}
|
||||
|
||||
impl OwnerField {
|
||||
@@ -37,7 +39,8 @@ impl OwnerField {
|
||||
pub const fn next(self) -> Self {
|
||||
match self {
|
||||
Self::Uid => Self::Gid,
|
||||
Self::Gid => Self::Uid,
|
||||
Self::Gid => Self::Recursive,
|
||||
Self::Recursive => Self::Uid,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,8 +48,9 @@ impl OwnerField {
|
||||
#[must_use]
|
||||
pub const fn prev(self) -> Self {
|
||||
match self {
|
||||
Self::Uid => Self::Gid,
|
||||
Self::Uid => Self::Recursive,
|
||||
Self::Gid => Self::Uid,
|
||||
Self::Recursive => Self::Gid,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,6 +73,8 @@ pub struct OwnerDialog {
|
||||
pub confirmed: bool,
|
||||
/// True after Esc cancels.
|
||||
pub cancelled: bool,
|
||||
/// Apply chown recursively.
|
||||
pub recursive: bool,
|
||||
/// Width as a fraction of the parent area.
|
||||
pub width_pct: f32,
|
||||
/// Height as a fraction of the parent area.
|
||||
@@ -91,6 +97,7 @@ impl OwnerDialog {
|
||||
focused: OwnerField::Uid,
|
||||
confirmed: false,
|
||||
cancelled: false,
|
||||
recursive: false,
|
||||
width_pct: 0.5,
|
||||
height_pct: 0.35,
|
||||
}
|
||||
@@ -133,7 +140,9 @@ impl OwnerDialog {
|
||||
true
|
||||
}
|
||||
Key::ENTER => {
|
||||
if self.uid_input.value().parse::<u32>().is_ok()
|
||||
if self.focused == OwnerField::Recursive {
|
||||
self.recursive = !self.recursive;
|
||||
} else if self.uid_input.value().parse::<u32>().is_ok()
|
||||
&& self.gid_input.value().parse::<u32>().is_ok()
|
||||
{
|
||||
self.confirmed = true;
|
||||
@@ -158,10 +167,15 @@ impl OwnerDialog {
|
||||
}
|
||||
_ => {
|
||||
let input = match self.focused {
|
||||
OwnerField::Uid => &mut self.uid_input,
|
||||
OwnerField::Gid => &mut self.gid_input,
|
||||
OwnerField::Uid => Some(&mut self.uid_input),
|
||||
OwnerField::Gid => Some(&mut self.gid_input),
|
||||
OwnerField::Recursive => None,
|
||||
};
|
||||
input.handle_key(key)
|
||||
if let Some(input) = input {
|
||||
input.handle_key(key)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,6 +199,7 @@ impl OwnerDialog {
|
||||
Constraint::Length(1), // header
|
||||
Constraint::Length(3), // UID input
|
||||
Constraint::Length(3), // GID input
|
||||
Constraint::Length(1), // Recursive checkbox
|
||||
Constraint::Min(1), // hint
|
||||
])
|
||||
.split(inner);
|
||||
@@ -219,6 +234,22 @@ impl OwnerDialog {
|
||||
uid.render(frame, chunks[1], theme);
|
||||
gid.render(frame, chunks[2], theme);
|
||||
|
||||
// Recursive checkbox.
|
||||
let rec_style = if self.focused == OwnerField::Recursive {
|
||||
Style::default()
|
||||
.fg(theme.cursor_fg)
|
||||
.bg(theme.accent)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(theme.foreground)
|
||||
};
|
||||
let rec_ch = if self.recursive { "✔" } else { "☐" };
|
||||
let rec_line = Paragraph::new(Line::from(Span::styled(
|
||||
format!(" [{}] Recursive", rec_ch),
|
||||
rec_style,
|
||||
)));
|
||||
frame.render_widget(rec_line, chunks[3]);
|
||||
|
||||
// Hint.
|
||||
let hint = Line::from(vec![
|
||||
Span::styled("Tab", Style::default().fg(theme.warning)),
|
||||
@@ -330,8 +361,10 @@ mod tests {
|
||||
#[test]
|
||||
fn field_cycle_helper() {
|
||||
assert_eq!(OwnerField::Uid.next(), OwnerField::Gid);
|
||||
assert_eq!(OwnerField::Gid.next(), OwnerField::Uid);
|
||||
assert_eq!(OwnerField::Uid.prev(), OwnerField::Gid);
|
||||
assert_eq!(OwnerField::Gid.next(), OwnerField::Recursive);
|
||||
assert_eq!(OwnerField::Recursive.next(), OwnerField::Uid);
|
||||
assert_eq!(OwnerField::Uid.prev(), OwnerField::Recursive);
|
||||
assert_eq!(OwnerField::Gid.prev(), OwnerField::Uid);
|
||||
assert_eq!(OwnerField::Recursive.prev(), OwnerField::Gid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,12 @@ pub struct Panel {
|
||||
entries: Vec<Entry>,
|
||||
/// Cursor position (index into `entries`). 0 is the ".." entry.
|
||||
cursor: usize,
|
||||
/// Previous cursor position (set when the cursor moves). Used by
|
||||
/// the renderer to draw a brief trail behind the active cursor.
|
||||
prev_cursor: usize,
|
||||
/// `Some(timestamp)` while the cursor-trail flash is active.
|
||||
/// `None` when no flash is active.
|
||||
cursor_flash: Option<std::time::Instant>,
|
||||
/// Top-line scroll position.
|
||||
top: usize,
|
||||
/// Files marked with Insert/Space.
|
||||
@@ -164,6 +170,8 @@ impl Panel {
|
||||
path: PathBuf::new(),
|
||||
entries: Vec::new(),
|
||||
cursor: 0,
|
||||
prev_cursor: 0,
|
||||
cursor_flash: None,
|
||||
top: 0,
|
||||
marked: HashSet::new(),
|
||||
sort_field: SortField::from_config(&cfg.sort_field),
|
||||
@@ -239,6 +247,12 @@ impl Panel {
|
||||
self.cursor
|
||||
}
|
||||
|
||||
/// Previous cursor index (set on every move; reset by render).
|
||||
#[must_use]
|
||||
pub fn prev_cursor(&self) -> usize {
|
||||
self.prev_cursor
|
||||
}
|
||||
|
||||
/// Top-line scroll index.
|
||||
#[must_use]
|
||||
pub fn top(&self) -> usize {
|
||||
@@ -497,6 +511,20 @@ impl Panel {
|
||||
v
|
||||
}
|
||||
|
||||
/// `Some(frac)` while the cursor-trail flash is active, where
|
||||
/// `1.0` = just after move and `0.0` = fully expired. `None`
|
||||
/// when no flash is active.
|
||||
pub fn cursor_flash_progress(&self) -> Option<f32> {
|
||||
let start = self.cursor_flash?;
|
||||
const FLASH_MS: f32 = 200.0;
|
||||
let elapsed_ms = start.elapsed().as_secs_f32() * 1000.0;
|
||||
if elapsed_ms >= FLASH_MS {
|
||||
return None;
|
||||
}
|
||||
let remaining = FLASH_MS - elapsed_ms;
|
||||
Some((remaining / FLASH_MS).clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
/// Snapshot of file-system state for every non-`..` entry, in
|
||||
/// display order. The returned vector preserves the panel's
|
||||
/// current sort order so callers can build a `name -> size` map
|
||||
@@ -586,25 +614,37 @@ impl Panel {
|
||||
/// Move the cursor up by one line.
|
||||
pub fn cursor_up(&mut self) {
|
||||
if self.cursor > 0 {
|
||||
self.prev_cursor = self.cursor;
|
||||
self.cursor -= 1;
|
||||
self.cursor_flash = Some(std::time::Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the cursor down by one line.
|
||||
pub fn cursor_down(&mut self) {
|
||||
if self.cursor + 1 < self.entries.len() {
|
||||
self.prev_cursor = self.cursor;
|
||||
self.cursor += 1;
|
||||
self.cursor_flash = Some(std::time::Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the cursor to the top of the list.
|
||||
pub fn cursor_home(&mut self) {
|
||||
if self.cursor != 0 {
|
||||
self.prev_cursor = self.cursor;
|
||||
self.cursor_flash = Some(std::time::Instant::now());
|
||||
}
|
||||
self.cursor = 0;
|
||||
}
|
||||
|
||||
/// Move the cursor to the bottom of the list.
|
||||
pub fn cursor_end(&mut self) {
|
||||
if !self.entries.is_empty() {
|
||||
if self.cursor != self.entries.len() - 1 {
|
||||
self.prev_cursor = self.cursor;
|
||||
self.cursor_flash = Some(std::time::Instant::now());
|
||||
}
|
||||
self.cursor = self.entries.len() - 1;
|
||||
}
|
||||
}
|
||||
@@ -1243,6 +1283,25 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_movement_records_prev_cursor_and_triggers_flash() {
|
||||
let dir = std::env::temp_dir().join("tlc-panel-cursor-trail");
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(dir.join("a.txt"), b"x").unwrap();
|
||||
fs::write(dir.join("b.txt"), b"x").unwrap();
|
||||
let mut p = Panel::new(&dir, &empty_cfg()).unwrap();
|
||||
// Initially no flash, prev_cursor == cursor.
|
||||
assert!(p.cursor_flash_progress().is_none());
|
||||
// cursor_down sets prev_cursor and triggers the flash.
|
||||
p.cursor_down();
|
||||
assert!(p.cursor_flash_progress().is_some());
|
||||
// prev_cursor holds the index before the move.
|
||||
// The exact value depends on which entry was at cursor 0.
|
||||
assert_ne!(p.prev_cursor(), p.cursor());
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_match_len_returns_filter_prefix() {
|
||||
let dir = std::env::temp_dir().join("tlc-panel-filter-match-len");
|
||||
|
||||
@@ -181,6 +181,7 @@ impl FileManager {
|
||||
DialogState::Connection(d) => d.render(frame, dialog_area, &self.theme),
|
||||
DialogState::Sort(d) => d.render(frame, dialog_area, &self.theme),
|
||||
DialogState::Confirm(d) => d.render(frame, dialog_area, &self.theme),
|
||||
DialogState::Appearance(d) => d.render(frame, dialog_area, &self.theme),
|
||||
DialogState::Progress(d) => d.render(frame, dialog_area, &self.theme),
|
||||
}
|
||||
}
|
||||
@@ -449,6 +450,26 @@ impl FileManager {
|
||||
.style(Style::default().fg(panel_fg).bg(panel_bg));
|
||||
frame.render_widget(list, list_area);
|
||||
|
||||
// Cursor-trail: briefly highlight the previous
|
||||
// cursor row with a thin accent-coloured bar.
|
||||
if let Some(frac) = panel.cursor_flash_progress() {
|
||||
let prev_visible = (panel.prev_cursor() as i64) - (top as i64);
|
||||
if prev_visible >= 0 && (prev_visible as u16) < body_height {
|
||||
let prev_y = body_y + prev_visible as u16;
|
||||
let bar = Paragraph::new(Span::styled(
|
||||
"▏",
|
||||
Style::default()
|
||||
.fg(self.theme.accent)
|
||||
.bg(panel_bg),
|
||||
));
|
||||
frame.render_widget(
|
||||
bar,
|
||||
Rect::new(inner.x, prev_y, 1, 1),
|
||||
);
|
||||
let _ = frac; // currently unused; reserved for future fade
|
||||
}
|
||||
}
|
||||
|
||||
let total = panel.entry_count().max(1);
|
||||
let mut sb_state = ScrollbarState::new(total)
|
||||
.viewport_content_length(body_height as usize)
|
||||
|
||||
@@ -11,6 +11,8 @@ pub enum Cmd {
|
||||
Edit,
|
||||
/// F3 — open the file under the cursor in the viewer.
|
||||
View,
|
||||
/// Shift-F3 — view the cursor file in raw mode (no magic detection).
|
||||
ViewRaw,
|
||||
/// Descend into the directory under the cursor (or open a file).
|
||||
/// If the cursor is on a directory, change into it. If on a
|
||||
/// regular file, route to Edit. This is the historical "ENTER
|
||||
@@ -42,6 +44,8 @@ pub enum Cmd {
|
||||
UserMenu,
|
||||
/// `Ctrl-\` — open the hotlist dialog.
|
||||
HotList,
|
||||
/// Add the active panel's current directory to the hotlist.
|
||||
HotListAdd,
|
||||
/// Ctrl-\\ — open the directory tree dialog.
|
||||
Tree,
|
||||
/// M-? — open the find-file dialog.
|
||||
@@ -219,6 +223,8 @@ pub enum Cmd {
|
||||
CommandHistory,
|
||||
/// Options menu — toggle confirmation dialogs.
|
||||
OptionsConfirm,
|
||||
/// Options menu — appearance dialog (MC CK_OptionsAppearance).
|
||||
Appearance,
|
||||
}
|
||||
|
||||
impl Cmd {
|
||||
@@ -241,6 +247,7 @@ impl Cmd {
|
||||
Cmd::Help => "Help",
|
||||
Cmd::UserMenu => "User menu",
|
||||
Cmd::HotList => "Hotlist",
|
||||
Cmd::HotListAdd => "Add to hotlist",
|
||||
Cmd::Tree => "Tree",
|
||||
Cmd::Find => "Find",
|
||||
Cmd::Cmdline => "Command line",
|
||||
@@ -313,11 +320,13 @@ impl Cmd {
|
||||
Cmd::EditMenuFile => "Edit menu file",
|
||||
Cmd::EditHighlightFile => "Edit highlighting file",
|
||||
Cmd::ViewFile => "View file",
|
||||
Cmd::ViewRaw => "View raw file",
|
||||
Cmd::DisplayBits => "Display bits",
|
||||
Cmd::LearnKeysDialog => "Learn keys",
|
||||
Cmd::VfsSettings => "Virtual FS",
|
||||
Cmd::CommandHistory => "Command history",
|
||||
Cmd::OptionsConfirm => "Confirmation",
|
||||
Cmd::Appearance => "Appearance",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -438,6 +447,10 @@ pub fn default_keymap() -> Keymap {
|
||||
km.bind(F1, Cmd::Help);
|
||||
km.bind(F2, Cmd::UserMenu);
|
||||
km.bind(F3, Cmd::View);
|
||||
km.bind(Key {
|
||||
code: F3.code,
|
||||
mods: crate::key::Modifiers::SHIFT,
|
||||
}, Cmd::ViewRaw);
|
||||
km.bind(F4, Cmd::Edit);
|
||||
km.bind(F5, Cmd::Copy);
|
||||
km.bind(F6, Cmd::Move);
|
||||
|
||||
@@ -112,7 +112,8 @@ fn init_logging(verbosity: u8) {
|
||||
.format_timestamp_secs()
|
||||
.format_module_path(false)
|
||||
.format_level(true)
|
||||
.try_init();
|
||||
.try_init()
|
||||
.unwrap_or_else(|e| eprintln!("logger init failed: {e} (output lost)"));
|
||||
}
|
||||
|
||||
fn run_editor(file: &str, line: Option<u64>) -> ExitCode {
|
||||
|
||||
@@ -71,7 +71,13 @@ impl Tui {
|
||||
}
|
||||
let stdout = io::stdout();
|
||||
let _ = SAVED_TERMIOS.get_or_init(|| {
|
||||
rustix::termios::tcgetattr(stdout.as_fd()).ok()
|
||||
match rustix::termios::tcgetattr(stdout.as_fd()) {
|
||||
Ok(t) => Some(t),
|
||||
Err(e) => {
|
||||
log::warn!("tcgetattr failed: {e} — terminal restore on crash may be incomplete");
|
||||
None
|
||||
}
|
||||
}
|
||||
});
|
||||
let raw = stdout
|
||||
.into_raw_mode()
|
||||
@@ -109,7 +115,7 @@ impl Tui {
|
||||
F: FnOnce(&mut ratatui::Frame),
|
||||
{
|
||||
self.terminal.draw(f)?;
|
||||
let _ = io::stdout().flush();
|
||||
io::stdout().flush().ok();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -138,7 +144,7 @@ impl Tui {
|
||||
/// `RawTerminal` (still alive inside the `Tui` struct) handle the
|
||||
/// escape sequences to leave the alternate screen and raw mode.
|
||||
pub fn restore() {
|
||||
let _ = io::stdout().flush();
|
||||
io::stdout().flush().ok();
|
||||
}
|
||||
|
||||
/// Install a panic hook that leaves the alternate screen, disables raw
|
||||
|
||||
@@ -34,6 +34,12 @@ pub enum ViewerCmd {
|
||||
ToggleWrap,
|
||||
/// Toggle growing buffer (tail -f style).
|
||||
ToggleGrowing,
|
||||
/// Toggle column ruler.
|
||||
ToggleRuler,
|
||||
/// Toggle hex column navigation (hex↔ASCII text column).
|
||||
ToggleHexNavigation,
|
||||
/// Toggle whole-word search.
|
||||
ToggleWholeWords,
|
||||
/// Search forward.
|
||||
Search,
|
||||
/// Search next.
|
||||
@@ -152,6 +158,8 @@ impl ViewerMenuBar {
|
||||
MenuItem::item("Magic", 'm', ViewerCmd::ToggleMagic),
|
||||
MenuItem::item("Wrap", 'w', ViewerCmd::ToggleWrap),
|
||||
MenuItem::item("Growing buffer", 'g', ViewerCmd::ToggleGrowing),
|
||||
MenuItem::item("Ruler", 'r', ViewerCmd::ToggleRuler),
|
||||
MenuItem::item("Hex nav.", 'h', ViewerCmd::ToggleHexNavigation),
|
||||
],
|
||||
),
|
||||
Menu::new(
|
||||
@@ -160,6 +168,8 @@ impl ViewerMenuBar {
|
||||
MenuItem::item("Find...", 'f', ViewerCmd::Search),
|
||||
MenuItem::item("Find next", 'n', ViewerCmd::SearchNext),
|
||||
MenuItem::item("Find prev", 'p', ViewerCmd::SearchPrev),
|
||||
MenuItem::separator(),
|
||||
MenuItem::item("Whole words", 'w', ViewerCmd::ToggleWholeWords),
|
||||
],
|
||||
),
|
||||
Menu::new(
|
||||
|
||||
@@ -97,6 +97,28 @@ pub struct Viewer {
|
||||
/// File size at the last successful read. Compared with the
|
||||
/// current on-disk size on each refresh to detect growth.
|
||||
last_size: u64,
|
||||
/// Bookmark positions: up to 10 saved cursor locations.
|
||||
/// Press `m` to set the current position; `r` to jump.
|
||||
marks: [Option<u64>; 10],
|
||||
/// Current bookmark slot (0–9). Incremented on each set.
|
||||
marker: usize,
|
||||
/// Last search direction (true = forward / 'n', false = backward / 'N').
|
||||
last_search_forward: bool,
|
||||
/// If true, wrap the search pattern in \b boundaries to match
|
||||
/// whole words only. Toggled via the search options.
|
||||
pub search_whole_words: bool,
|
||||
/// Whether to show the column ruler (MC CK_Ruler / Alt-R).
|
||||
pub ruler: bool,
|
||||
/// True when the cursor navigates the ASCII text column in
|
||||
/// hex mode (MC hexview_in_text). Toggled by Tab.
|
||||
pub hexview_in_text: bool,
|
||||
/// Whether nroff backspace-overstrike formatting is active.
|
||||
/// Toggled by F9 when the menubar is not active (MC F9).
|
||||
pub nroff_enabled: bool,
|
||||
/// Whether to show the F1 help overlay.
|
||||
pub show_help: bool,
|
||||
/// Set by ViewerCmd::Close — the caller should close the viewer.
|
||||
pub should_close: bool,
|
||||
/// Active modal prompt at the bottom of the viewer (`None` =
|
||||
/// no prompt open). Drives the prompt's title and Enter
|
||||
/// handler.
|
||||
@@ -170,6 +192,15 @@ impl Viewer {
|
||||
text_view: text::TextView::new(String::from_utf8_lossy(&content).into_owned()),
|
||||
growing: false,
|
||||
last_size: size,
|
||||
marks: [None; 10],
|
||||
marker: 0,
|
||||
last_search_forward: true,
|
||||
search_whole_words: false,
|
||||
ruler: false,
|
||||
hexview_in_text: false,
|
||||
nroff_enabled: false,
|
||||
show_help: false,
|
||||
should_close: false,
|
||||
prompt: None,
|
||||
prompt_input: String::new(),
|
||||
loading: size >= crate::viewer::source::INLINE_THRESHOLD,
|
||||
@@ -210,6 +241,15 @@ impl Viewer {
|
||||
text_view: text::TextView::new(String::from_utf8_lossy(&content).into_owned()),
|
||||
growing: false,
|
||||
last_size: size,
|
||||
marks: [None; 10],
|
||||
marker: 0,
|
||||
last_search_forward: true,
|
||||
search_whole_words: false,
|
||||
ruler: false,
|
||||
hexview_in_text: false,
|
||||
nroff_enabled: false,
|
||||
show_help: false,
|
||||
should_close: false,
|
||||
prompt: None,
|
||||
prompt_input: String::new(),
|
||||
loading: size >= crate::viewer::source::INLINE_THRESHOLD,
|
||||
@@ -341,22 +381,27 @@ impl Viewer {
|
||||
}
|
||||
|
||||
/// Run a search and update the engine. Returns the number of hits.
|
||||
pub fn search(&mut self, pattern: &str, case_insensitive: bool) -> Result<usize> {
|
||||
pub fn search(&mut self, raw_pattern: &str, case_insensitive: bool) -> Result<usize> {
|
||||
let pattern = if self.search_whole_words {
|
||||
format!(r"\b{}\b", raw_pattern)
|
||||
} else {
|
||||
raw_pattern.to_string()
|
||||
};
|
||||
match &self.source {
|
||||
source::FileSource::Inline { bytes } => {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
self.search.find_all(&text, pattern, case_insensitive)?;
|
||||
self.search.find_all(&text, &pattern, case_insensitive)?;
|
||||
}
|
||||
source::FileSource::Compressed { bytes, .. } => {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
self.search.find_all(&text, pattern, case_insensitive)?;
|
||||
self.search.find_all(&text, &pattern, case_insensitive)?;
|
||||
}
|
||||
source::FileSource::Chunked { .. } => {
|
||||
let size = self.source.size();
|
||||
let mut offset = 0u64;
|
||||
let chunk_size = source::CHUNK_SIZE as u64;
|
||||
self.search.find_all_streaming(
|
||||
pattern,
|
||||
&pattern,
|
||||
case_insensitive,
|
||||
|| -> Result<Option<(Vec<u8>, bool)>, anyhow::Error> {
|
||||
if offset >= size {
|
||||
@@ -381,6 +426,7 @@ impl Viewer {
|
||||
/// Move the search cursor forward/backward.
|
||||
#[must_use]
|
||||
pub fn search_next(&mut self) -> Option<search::Match> {
|
||||
self.last_search_forward = true;
|
||||
let m = self.search.step(search::Direction::Forward);
|
||||
if self.search.last_wrapped() {
|
||||
self.flash_msg = Some("Search wrapped".to_string());
|
||||
@@ -390,6 +436,7 @@ impl Viewer {
|
||||
/// Search for the previous match (relative to the current cursor).
|
||||
#[must_use]
|
||||
pub fn search_prev(&mut self) -> Option<search::Match> {
|
||||
self.last_search_forward = false;
|
||||
let m = self.search.step(search::Direction::Backward);
|
||||
if self.search.last_wrapped() {
|
||||
self.flash_msg = Some("Search wrapped".to_string());
|
||||
@@ -490,7 +537,9 @@ impl Viewer {
|
||||
line: self.current_line() as u32,
|
||||
column: 0,
|
||||
};
|
||||
let _ = crate::editor::filepos::save(&self.path, pos);
|
||||
if let Err(e) = crate::editor::filepos::save(&self.path, pos) {
|
||||
log::debug!("filepos save failed for {}: {e}", self.path.display());
|
||||
}
|
||||
}
|
||||
|
||||
fn current_line(&self) -> u64 {
|
||||
@@ -765,14 +814,50 @@ impl Viewer {
|
||||
}
|
||||
|
||||
if chunks[3].height > 0 {
|
||||
let labels: [(&str, &str); 10] = [
|
||||
("1", "Help"), ("2", "Wrap"), ("3", ""), ("4", "Hex"),
|
||||
("5", ""), ("6", ""), ("7", "Search"), ("8", "Magic"),
|
||||
("9", ""), ("10", "Quit"),
|
||||
];
|
||||
let labels: [(&str, &str); 10] = if self.mode == ViewMode::Hex {
|
||||
[
|
||||
("1", "Help"), ("2", "Edit"), ("3", "Quit"), ("4", "Ascii"),
|
||||
("5", "Goto"), ("6", "Save"), ("7", "HxSrch"), ("8", "Raw"),
|
||||
("9", ""), ("10", "Quit"),
|
||||
]
|
||||
} else if self.mode == ViewMode::HexEdit {
|
||||
[
|
||||
("1", "Help"), ("2", "View"), ("3", "Quit"), ("4", "Ascii"),
|
||||
("5", "Goto"), ("6", "Save"), ("7", "HxSrch"), ("8", "Raw"),
|
||||
("9", ""), ("10", "Quit"),
|
||||
]
|
||||
} else {
|
||||
[
|
||||
("1", "Help"), ("2", "Wrap"), ("3", ""), ("4", "Hex"),
|
||||
("5", ""), ("6", ""), ("7", "Search"), ("8", "Magic"),
|
||||
("9", "Nroff"), ("10", "Quit"),
|
||||
]
|
||||
};
|
||||
crate::widget::buttonbar::render_buttonbar(frame, chunks[3], theme, &labels);
|
||||
}
|
||||
|
||||
// Help overlay: simple key-binding reference.
|
||||
if self.show_help {
|
||||
let help_popup = crate::terminal::popup::centered_percent_rect(area, 0.6, 0.7);
|
||||
let inner = crate::terminal::popup::render_popup(
|
||||
frame, help_popup, " Help — Key bindings ", theme,
|
||||
);
|
||||
let help_lines = [
|
||||
" F1 Help F2 Wrap F3 — F4 Hex",
|
||||
" F5 Goto F6 — F7 Search F8 Magic",
|
||||
" F9 Nroff F10 Quit / Search ? RevSearch",
|
||||
" g Goto line G Bottom n Next N Prev",
|
||||
" d HalfDown u HalfUp m Bookmark r Jump",
|
||||
" Shift-F2 Growing buf Shift-F9 Menubar Alt-R Ruler",
|
||||
" j/k Down/Up h/l Left/Right Space PgDn b PgUp",
|
||||
];
|
||||
let para = ratatui::widgets::Paragraph::new(
|
||||
help_lines.join("\n"),
|
||||
)
|
||||
.style(ratatui::style::Style::default().fg(theme.foreground));
|
||||
frame.render_widget(para, inner);
|
||||
}
|
||||
|
||||
// Prompt overlay: rendered on the top row so it doesn't
|
||||
// collide with the footer area below. The body's border
|
||||
// block still owns the central chunk.
|
||||
@@ -792,7 +877,7 @@ impl Viewer {
|
||||
let label = match self.prompt {
|
||||
Some(ViewerPrompt::Search) => " Search: ",
|
||||
Some(ViewerPrompt::SearchBackward) => " Search backward: ",
|
||||
Some(ViewerPrompt::GotoLine) => " Goto line: ",
|
||||
Some(ViewerPrompt::GotoLine) => " Goto (line/50%/0x): ",
|
||||
Some(ViewerPrompt::SaveBeforeQuit) => " Save before quit? (Y/N/Esc) ",
|
||||
None => return,
|
||||
};
|
||||
@@ -809,6 +894,29 @@ impl Viewer {
|
||||
}
|
||||
|
||||
/// Handle a key event. Returns `true` if the viewer was closed
|
||||
/// Handle a mouse event (scroll wheel + click zones).
|
||||
/// MC parity for MSG_MOUSE_DOWN, MSG_MOUSE_SCROLL_UP/DOWN.
|
||||
pub fn handle_mouse(&mut self, m: termion::event::MouseEvent) {
|
||||
use termion::event::MouseEvent;
|
||||
match m {
|
||||
MouseEvent::Press(termion::event::MouseButton::WheelUp, _, _) => {
|
||||
self.move_cursor_up(2);
|
||||
}
|
||||
MouseEvent::Press(termion::event::MouseButton::WheelDown, _, _) => {
|
||||
self.move_cursor_down(2);
|
||||
}
|
||||
MouseEvent::Press(_, _x, y) => {
|
||||
if y < 5 {
|
||||
self.move_cursor_up((self.last_height / 2).max(1) as u64);
|
||||
} else if y > self.last_height.saturating_sub(5) as u16 {
|
||||
self.move_cursor_down((self.last_height / 2).max(1) as u64);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a keyboard event. Returns `true` when the viewer
|
||||
/// (F10 / q / Esc), `false` if the key was consumed but the
|
||||
/// viewer stays open.
|
||||
pub fn handle_key(&mut self, key: Key) -> bool {
|
||||
@@ -822,14 +930,30 @@ impl Viewer {
|
||||
// routed to `handle_quit_request` so a dirty buffer
|
||||
// intercepts with the save prompt instead of silently
|
||||
// dropping changes.
|
||||
// Tab — toggle hex navigation between hex area and ASCII
|
||||
// text column (MC CK_ToggleNavigation).
|
||||
if key == Key::TAB && (self.mode == ViewMode::Hex || self.mode == ViewMode::HexEdit) {
|
||||
self.hexview_in_text = !self.hexview_in_text;
|
||||
return false;
|
||||
}
|
||||
if self.mode == ViewMode::HexEdit {
|
||||
return hex_edit::handle_key(self, key);
|
||||
}
|
||||
if key == Key::f(10) || key == Key::ctrl('q') {
|
||||
return true;
|
||||
}
|
||||
// F9 toggles the menubar.
|
||||
// F9 — toggle NroffMode (MC CK_NroffMode parity).
|
||||
// Shift-F9 opens the viewer menubar.
|
||||
if key == Key::f(9) {
|
||||
self.nroff_enabled = !self.nroff_enabled;
|
||||
return true;
|
||||
}
|
||||
// Shift-F9 opens the menubar.
|
||||
let shift_f9 = Key {
|
||||
code: Key::f(9).code,
|
||||
mods: crate::key::Modifiers::SHIFT,
|
||||
};
|
||||
if key == shift_f9 {
|
||||
if self.menubar.is_some() {
|
||||
self.menubar = None;
|
||||
} else {
|
||||
@@ -840,8 +964,7 @@ impl Viewer {
|
||||
return true;
|
||||
}
|
||||
// Route other keys to the menubar when it's open.
|
||||
if self.menubar.is_some() {
|
||||
let mut mb = self.menubar.take().unwrap();
|
||||
if let Some(mut mb) = self.menubar.take() {
|
||||
let consumed = mb.handle_key(key);
|
||||
if consumed {
|
||||
// If the menubar dispatched a command, execute it.
|
||||
@@ -857,6 +980,11 @@ impl Viewer {
|
||||
if key == Key::ESCAPE {
|
||||
return true;
|
||||
}
|
||||
// F1 — toggle help overlay (MC F1 help parity).
|
||||
if key == Key::f(1) {
|
||||
self.show_help = !self.show_help;
|
||||
return false;
|
||||
}
|
||||
if key == Key::f(4) {
|
||||
self.mode = match self.mode {
|
||||
ViewMode::Text => ViewMode::Hex,
|
||||
@@ -866,8 +994,9 @@ impl Viewer {
|
||||
return false;
|
||||
}
|
||||
// F2 — toggle hex-edit on/off from Hex mode (MC parity
|
||||
// for `CK_HexMode` toggle). In other modes F2 still
|
||||
// toggles the growing-buffer mode.
|
||||
// F2 — toggle wrap in text mode (MC CK_WrapMode), toggle
|
||||
// hex-edit in Hex/HexEdit mode. Shift-F2 toggles growing
|
||||
// buffer (tail -f).
|
||||
if key == Key::f(2) {
|
||||
if self.mode == ViewMode::Hex {
|
||||
self.enter_hex_edit();
|
||||
@@ -877,9 +1006,20 @@ impl Viewer {
|
||||
self.exit_hex_edit();
|
||||
return false;
|
||||
}
|
||||
self.toggle_growing();
|
||||
self.wrap = !self.wrap;
|
||||
return false;
|
||||
}
|
||||
// Shift-F2 — toggle growing buffer.
|
||||
{
|
||||
let shift_f2 = Key {
|
||||
code: Key::f(2).code,
|
||||
mods: crate::key::Modifiers::SHIFT,
|
||||
};
|
||||
if key == shift_f2 {
|
||||
self.toggle_growing();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if key == Key::f(8) {
|
||||
self.magic_mode = !self.magic_mode;
|
||||
if self.magic_mode {
|
||||
@@ -945,6 +1085,11 @@ impl Viewer {
|
||||
self.wrap = !self.wrap;
|
||||
return false;
|
||||
}
|
||||
// Alt-R — toggle column ruler (MC CK_Ruler).
|
||||
if code == b'r' as u32 && mods == crate::key::Modifiers::ALT {
|
||||
self.ruler = !self.ruler;
|
||||
return false;
|
||||
}
|
||||
if code == b'q' as u32 && mods.is_empty() {
|
||||
return true;
|
||||
}
|
||||
@@ -952,6 +1097,15 @@ impl Viewer {
|
||||
let _ = self.search_next();
|
||||
return false;
|
||||
}
|
||||
// Shift-N — search in opposite direction (MC CK_SearchOppositeContinue).
|
||||
if code == b'N' as u32 && mods == crate::key::Modifiers::SHIFT {
|
||||
if self.last_search_forward {
|
||||
let _ = self.search_prev();
|
||||
} else {
|
||||
let _ = self.search_next();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if code == b'N' as u32 && mods.is_empty() {
|
||||
let _ = self.search_prev();
|
||||
return false;
|
||||
@@ -962,6 +1116,33 @@ impl Viewer {
|
||||
self.sync_cursor_to_top();
|
||||
return false;
|
||||
}
|
||||
// `d` — half-page down (MC CK_HalfPageDown).
|
||||
if code == b'd' as u32 && mods.is_empty() {
|
||||
let h = (self.last_height / 2).max(1) as u64;
|
||||
self.move_cursor_down(h);
|
||||
return false;
|
||||
}
|
||||
// `u` — half-page up (MC CK_HalfPageUp).
|
||||
if code == b'u' as u32 && mods.is_empty() {
|
||||
let h = (self.last_height / 2).max(1) as u64;
|
||||
self.move_cursor_up(h);
|
||||
return false;
|
||||
}
|
||||
// `m` — set bookmark at current position (MC CK_BookmarkGoto).
|
||||
if code == b'm' as u32 && mods.is_empty() {
|
||||
self.marks[self.marker] = Some(self.top);
|
||||
self.marker = (self.marker + 1) % 10;
|
||||
return false;
|
||||
}
|
||||
// `r` — jump to bookmark (MC CK_Bookmark).
|
||||
if code == b'r' as u32 && mods.is_empty() {
|
||||
let slot = self.marker.checked_sub(1).unwrap_or(9);
|
||||
if let Some(pos) = self.marks[slot] {
|
||||
self.top = pos;
|
||||
self.sync_cursor_to_top();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// `/` — forward search. `?` — backward search. `g` — goto-line.
|
||||
if mods.is_empty() {
|
||||
if code == b'/' as u32 {
|
||||
@@ -1051,6 +1232,15 @@ impl Viewer {
|
||||
ViewerCmd::ToggleGrowing => {
|
||||
let _ = self.toggle_growing();
|
||||
}
|
||||
ViewerCmd::ToggleRuler => {
|
||||
self.ruler = !self.ruler;
|
||||
}
|
||||
ViewerCmd::ToggleHexNavigation => {
|
||||
self.hexview_in_text = !self.hexview_in_text;
|
||||
}
|
||||
ViewerCmd::ToggleWholeWords => {
|
||||
self.search_whole_words = !self.search_whole_words;
|
||||
}
|
||||
ViewerCmd::Search => {
|
||||
self.prompt = Some(ViewerPrompt::Search);
|
||||
self.prompt_input.clear();
|
||||
@@ -1065,16 +1255,32 @@ impl Viewer {
|
||||
self.prompt = Some(ViewerPrompt::GotoLine);
|
||||
self.prompt_input.clear();
|
||||
}
|
||||
ViewerCmd::BookmarkToggle
|
||||
| ViewerCmd::BookmarkNext
|
||||
| ViewerCmd::BookmarkPrev
|
||||
| ViewerCmd::NextFile
|
||||
| ViewerCmd::PrevFile
|
||||
| ViewerCmd::Close => {
|
||||
// Bookmark / file navigation / close are not yet
|
||||
// wired to menubar commands. They are still reachable
|
||||
// via their keyboard shortcuts. This is a planned
|
||||
// follow-up.
|
||||
ViewerCmd::BookmarkToggle => {
|
||||
self.marks[self.marker] = Some(self.top);
|
||||
self.marker = (self.marker + 1) % 10;
|
||||
}
|
||||
ViewerCmd::BookmarkNext => {
|
||||
self.marker = (self.marker + 1) % 10;
|
||||
if let Some(pos) = self.marks[self.marker] {
|
||||
self.top = pos;
|
||||
self.sync_cursor_to_top();
|
||||
}
|
||||
}
|
||||
ViewerCmd::BookmarkPrev => {
|
||||
let slot = self.marker.checked_sub(1).unwrap_or(9);
|
||||
if let Some(pos) = self.marks[slot] {
|
||||
self.top = pos;
|
||||
self.sync_cursor_to_top();
|
||||
}
|
||||
}
|
||||
ViewerCmd::NextFile => {
|
||||
let _ = self.open_next();
|
||||
}
|
||||
ViewerCmd::PrevFile => {
|
||||
let _ = self.open_prev();
|
||||
}
|
||||
ViewerCmd::Close => {
|
||||
self.should_close = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1113,13 +1319,42 @@ impl Viewer {
|
||||
}
|
||||
}
|
||||
ViewerPrompt::GotoLine => {
|
||||
if let Ok(n) = text.trim().parse::<u64>() {
|
||||
if n >= 1 {
|
||||
if let Ok(target) = self.goto_line(n) {
|
||||
let trimmed = text.trim();
|
||||
// Percent: "50%" → goto 50% of the file.
|
||||
if let Some(pct) = trimmed.strip_suffix('%') {
|
||||
if let Ok(n) = pct.parse::<u64>() {
|
||||
if n <= 100 {
|
||||
if let Ok(target) = self.goto_percent(n) {
|
||||
self.cursor = target.offset;
|
||||
self.top = target.line.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Hex offset: "0x400" → goto byte offset 1024.
|
||||
} else if trimmed.starts_with("0x") || trimmed.starts_with("0X") {
|
||||
if let Ok(off) = u64::from_str_radix(&trimmed[2..], 16) {
|
||||
if let Ok(target) = self.goto_offset(off) {
|
||||
self.cursor = target.offset;
|
||||
self.top = target.line.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
// Decimal offset: plain number → goto byte offset
|
||||
// if it's larger than the file's line count, else
|
||||
// goto line.
|
||||
} else if let Ok(n) = trimmed.parse::<u64>() {
|
||||
if n >= 1 {
|
||||
if n > self.goto.line_count() {
|
||||
if let Ok(target) = self.goto_offset(n) {
|
||||
self.cursor = target.offset;
|
||||
self.top = target.line.saturating_sub(1);
|
||||
}
|
||||
} else {
|
||||
if let Ok(target) = self.goto_line(n) {
|
||||
self.cursor = target.offset;
|
||||
self.top = target.line.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ViewerPrompt::SaveBeforeQuit => {
|
||||
@@ -1225,10 +1460,11 @@ impl Viewer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Backwards-compat shim: `open_file` was the Phase 0 stub.
|
||||
/// Standalone viewer entry-point used by the `tlcview` binary.
|
||||
/// Opens `file` in the viewer with optional line-number navigation.
|
||||
pub fn open_file(file: &str, start_line: Option<u64>) -> Result<()> {
|
||||
use crate::terminal::event::translate_key;
|
||||
use crate::terminal::color::DEFAULT_THEME;
|
||||
use crate::terminal::event::translate_key;
|
||||
use termion::event::Event as TermEvent;
|
||||
use termion::input::TermReadEventsAndRaw;
|
||||
|
||||
@@ -1439,9 +1675,10 @@ mod tests {
|
||||
let p = make_growing_file("key.txt", b"a\nb\n");
|
||||
let mut v = Viewer::open(&p).unwrap();
|
||||
assert!(!v.is_growing());
|
||||
v.handle_key(Key::f(2));
|
||||
let shift_f2 = Key { code: Key::f(2).code, mods: crate::key::Modifiers::SHIFT };
|
||||
v.handle_key(shift_f2);
|
||||
assert!(v.is_growing());
|
||||
v.handle_key(Key::f(2));
|
||||
v.handle_key(shift_f2);
|
||||
assert!(!v.is_growing());
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
@@ -1708,4 +1945,142 @@ mod tests {
|
||||
v.move_cursor(-50);
|
||||
assert_eq!(v.cursor, 0); // clamps at start, no underflow
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bookmarks_set_and_jump() {
|
||||
let p = make_text("marks.txt", b"line1\nline2\nline3\n");
|
||||
let mut v = Viewer::open(&p).unwrap();
|
||||
// Set bookmark at line 1 via direct assignment to slot 9.
|
||||
// The `r` key jumps to marker-1 = 9 (slot 9 wraps).
|
||||
v.marks[9] = Some(1);
|
||||
v.marker = 0;
|
||||
// `r` jumps to the previous slot (9, wraps from 0).
|
||||
v.top = 99;
|
||||
let consumed = v.handle_key(k('r'));
|
||||
assert!(!consumed);
|
||||
assert_eq!(v.top, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_page_scroll_moves_cursor() {
|
||||
let p = make_text("half.txt", b"a\nb\nc\nd\ne\nf\ng\nh\n");
|
||||
let mut v = Viewer::open(&p).unwrap();
|
||||
v.last_height = 4;
|
||||
v.cursor = 0;
|
||||
// `d` scrolls down half a page.
|
||||
let consumed = v.handle_key(k('d'));
|
||||
assert!(!consumed);
|
||||
// The cursor should have moved down by last_height/2 = 2.
|
||||
assert!(v.cursor >= 2, "cursor should have moved down");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f2_toggles_wrap_in_text_mode() {
|
||||
let p = make_text("wrap.txt", b"hello\n");
|
||||
let mut v = Viewer::open(&p).unwrap();
|
||||
assert!(v.wrap, "wrap starts on");
|
||||
v.handle_key(Key::f(2));
|
||||
assert!(!v.wrap, "F2 should toggle wrap off");
|
||||
v.handle_key(Key::f(2));
|
||||
assert!(v.wrap, "F2 should toggle wrap back on");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruler_toggle_cycles() {
|
||||
let p = make_text("ruler.txt", b"hello\n");
|
||||
let mut v = Viewer::open(&p).unwrap();
|
||||
assert!(!v.ruler);
|
||||
let _ = v.handle_key(Key { code: b'r' as u32, mods: crate::key::Modifiers::ALT });
|
||||
assert!(v.ruler);
|
||||
let _ = v.handle_key(Key { code: b'r' as u32, mods: crate::key::Modifiers::ALT });
|
||||
assert!(!v.ruler);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goto_accepts_percent_and_offset() {
|
||||
// Use a 100-line file so 50% = line 50.
|
||||
let lines: Vec<String> = (1..=100).map(|i| format!("line{i}")).collect();
|
||||
let content = lines.join("\n");
|
||||
let p = make_text("goto-fmt.txt", content.as_bytes());
|
||||
let mut v = Viewer::open(&p).unwrap();
|
||||
// Open goto prompt.
|
||||
v.prompt = Some(ViewerPrompt::GotoLine);
|
||||
v.prompt_input = "50%".to_string();
|
||||
// Activate the prompt — the commit handler processes it.
|
||||
v.handle_prompt_key(Key::ENTER);
|
||||
assert!(v.top >= 49 && v.top <= 51, "50% should land near line 50, got top={}", v.top);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f1_toggles_help() {
|
||||
let p = make_text("help.txt", b"hello\n");
|
||||
let mut v = Viewer::open(&p).unwrap();
|
||||
assert!(!v.show_help);
|
||||
v.handle_key(Key::f(1));
|
||||
assert!(v.show_help);
|
||||
v.handle_key(Key::f(1));
|
||||
assert!(!v.show_help);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_navigation_tab_toggles() {
|
||||
let p = make_hex_file("nav.bin", &[0x01, 0x02]);
|
||||
let mut v = Viewer::open(&p).unwrap();
|
||||
v.mode = ViewMode::Hex;
|
||||
assert!(!v.hexview_in_text);
|
||||
v.handle_key(Key::TAB);
|
||||
assert!(v.hexview_in_text);
|
||||
v.handle_key(Key::TAB);
|
||||
assert!(!v.hexview_in_text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_words_search_matches_boundaries() {
|
||||
let p = make_text("wsearch.txt", b"foo bar food barefoot foo");
|
||||
let mut v = Viewer::open(&p).unwrap();
|
||||
// Without whole-words: "foo" should match in "food" and "barefoot"
|
||||
let n = v.search("foo", false).unwrap();
|
||||
assert_eq!(n, 4); // foo, food, barefoot, foo
|
||||
// With whole-words: "foo" should only match standalone "foo"
|
||||
v.search_whole_words = true;
|
||||
let n = v.search("foo", false).unwrap();
|
||||
assert_eq!(n, 2); // only standalone "foo" entries
|
||||
}
|
||||
|
||||
|
||||
|
||||
#[test]
|
||||
fn search_opposite_shift_n_inverts_direction() {
|
||||
let p = make_text("opp.txt", b"foo bar foo baz foo");
|
||||
let mut v = Viewer::open(&p).unwrap();
|
||||
v.search("foo", false).unwrap();
|
||||
// n moves forward to second match.
|
||||
let m = v.search_next();
|
||||
assert!(m.is_some());
|
||||
// Shift-N should move backward to first match.
|
||||
let shift_n = Key { code: b'N' as u32, mods: crate::key::Modifiers::SHIFT };
|
||||
v.handle_key(shift_n);
|
||||
// After Shift-N, n should move forward again (direction flipped back).
|
||||
let m2 = v.search_next();
|
||||
assert!(m2.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bookmarks_wraps_marker() {
|
||||
let p = make_text("wrapm.txt", b"a\nb\n");
|
||||
let mut v = Viewer::open(&p).unwrap();
|
||||
// Press m 10 times — each sets a bookmark at top=0 then advances.
|
||||
for _ in 0..10 {
|
||||
v.handle_key(k('m'));
|
||||
}
|
||||
// After 10 presses, marker should have wrapped back to 0.
|
||||
assert_eq!(v.marker, 0);
|
||||
// The r key jumps to marker-1 = 9 (which was set in the 10th press).
|
||||
v.top = 99;
|
||||
v.handle_key(k('r'));
|
||||
assert_eq!(v.top, 0, "jump to slot 9 which has the bookmark at position 0");
|
||||
}
|
||||
fn k(c: char) -> Key {
|
||||
Key { code: c as u32, mods: crate::key::Modifiers::empty() }
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
Submodule local/sources/base updated: d3b8d08420...d0ecc06734
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../local/recipes/system/redbear-ftdi
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../local/recipes/system/redbear-usb-hotplugd
|
||||
Reference in New Issue
Block a user