97 Commits

Author SHA1 Message Date
vasilito 6e808741ea redbear-power: per-core governor display and Ctrl+g control 2026-07-07 22:16:27 +03:00
vasilito 596407a52d redbear-power: upower client + render/sensor/battery/dmi updates 2026-07-07 22:00:54 +03:00
vasilito 33c002d563 USB: cast O_* flags to u8 for NewFdFlags::from_bits_truncate
Cross-compile error: from_bits_truncate requires u8 but
O_STAT/O_RDWR are usize.  Cast with 'as u8'.

Applied to all 4 scheme wrappers: acmd, ftdi, ecmd, usbaudiod.
2026-07-07 21:54:39 +03:00
vasilito 1b3b29ba91 networking: update base submodule pointer (comprehensive TCP/IP stack) 2026-07-07 21:49:35 +03:00
vasilito 46cd3704b9 USB: fix scheme API — match actual redox-scheme SchemeSync trait
Cross-referenced with wifictl's working scheme registration pattern.

scheme.rs fixes (acmd + ftdi):
- openat: 5→6 params (add &mut self, fcntl_flags: u32)
- read: 4→6 params (add offset: u64, fcntl_flags: u32)
- write: 4→6 params (add offset: u64, fcntl_flags: u32)
- Removed close() method (not in SchemeSync trait)
- OpenResult.flags: usize→NewFdFlags (via from_bits_truncate)
- Removed unused imports

main.rs fixes (acmd):
- Replaced register_async_scheme + handle_scheme_mut with
  wifictl pattern: setrens(0,0) + request.handle_sync() +
  socket.write_response() + RequestKind::Call match
- Socket::next_request returns Option<Request>, handle
  Ok(None)/Err gracefully instead of expect()

Verified: cargo check --offline passes on host.
2026-07-07 21:12:45 +03:00
vasilito fc34718ae8 redbear-power: update stale docstrings for Redox process control and MSR temperature fallback 2026-07-07 21:10:33 +03:00
vasilito dac00073ba USB: canonical scheme pattern — all 4 drivers aligned
ftdi/usbaudiod scheme.rs updated to match acmd/ecmd pattern:
  Explicit match arms in read/write (not map/map_err chains)
  Consistent openat with root dir + device file routing
  Saturating close count
  AudioScheme retains capture/playback path routing

All 4 scheme modules now identical in structure:
  acmd  → /scheme/ttys/usbACM_<N>      (Mutex<bulk_in>, Mutex<bulk_out>)
  ftdi  → /scheme/ttys/usbFTDI_<N>     (same)
  ecmd  → /scheme/net/usbECM_<N>       (same)
  audio → /scheme/audio/usbAudio_<N>   (Mutex<isoch_in>, Mutex<Option<isoch_out>>)
2026-07-07 20:01:37 +03:00
vasilito ac9c12d6d4 USB: align ecmd scheme with acmd pattern — explicit match arms, clean imports
ecmd scheme.rs: map/map_err chains → explicit match arms (same as acmd)
acmd scheme.rs: removed unused imports (io::{Read,Write}, Duration, Stat)

All 4 scheme modules now follow identical pattern:
  Mutex<XhciEndpHandle>, SchemeSync, explicit match in read/write
2026-07-07 19:59:43 +03:00
vasilito 5b4f2ac984 USB: P3 — wire scheme into main loop for ecmd + usbaudiod
redbear-ecmd: scheme loop replaces stdout on Redox
  (/scheme/net/usbECM_<N>), notification reader kept for
  CDC link state / speed change events on both paths.

redbear-usbaudiod: scheme loop replaces stdout on Redox
  (/scheme/audio/usbAudio_<N>), stdin→isoch_OUT playback
  thread kept for host stdout path only.

All 4 class drivers now fully wired:
  acmd  → scheme event loop active on Redox 
  ftdi  → scheme event loop active on Redox 
  ecmd  → scheme event loop active on Redox 
  usbaudiod → scheme event loop active on Redox 
2026-07-07 19:55:40 +03:00
vasilito 827cc0223d USB: P3 scheme service wrappers — ecmd + usbaudiod
redbear-ecmd: EcmScheme → /scheme/net/usbECM_<N> for netstack
redbear-usbaudiod: AudioScheme → /scheme/audio/usbAudio_<N> for audiod
  (supports capture/playback path routing via openat path names)

All 4 class drivers now have scheme service wrappers:
  redbear-acmd  → /scheme/ttys/usbACM_<N>
  redbear-ftdi  → /scheme/ttys/usbFTDI_<N>
  redbear-ecmd  → /scheme/net/usbECM_<N>
  redbear-usbaudiod → /scheme/audio/usbAudio_<N>
(HID+Storage already have scheme integration via ProducerHandle/DiskScheme)
2026-07-07 19:27:36 +03:00
vasilito 9e6851d43c USB: P3 scheme service wrapper — redbear-ftdi registers /scheme/ttys/usbFTDI_<N>
Same pattern as acmd: FtdiScheme with Mutex<XhciEndpHandle>,
SchemeSync impl, Socket::create() + register on Redox,
stdout fallback on host/Linux.
2026-07-07 19:24:46 +03:00
vasilito 726e628e0d USB: P3 scheme service wrapper — redbear-acmd registers /scheme/ttys/usbACM_<N>
Cross-referenced with Linux 7.1 tty_port_register_device() pattern.

New scheme.rs module:
- AcmScheme implements SchemeSync with Mutex-wrapped XhciEndpHandle
- openat(): root dir listing + device file open (O_RDWR)
- read(): USB bulk IN → scheme client (getty, terminal)
- write(): scheme client → USB bulk OUT
- close(): decrements open count
- fsync(): no-op

main.rs:
- #[cfg(target_os = redox)]: Socket::create() + register scheme
  under /scheme/ttys/usbACM_<port_id>, process requests with
  handle_scheme_mut() in event loop
- #[cfg(not(target_os = redox))]: stdout fallback for testing

This enables getty to open /scheme/ttys/usbACM_<N> as a serial
console on USB CDC ACM devices — the driver is now a proper
Redox scheme service, not just a stdout debugging tool.

Pattern replicable for redbear-ftdi (same scheme:ttys), redbear-ecmd
(scheme:net), and redbear-usbaudiod (scheme:audio).
2026-07-07 19:21:14 +03:00
vasilito 0dc1786f66 base: bump submodule to 1c7f8390 (ZERO_64B_REGS behavioral quirk) 2026-07-07 19:14:32 +03:00
vasilito ed7aba6ed1 USB: acmd scheme IPC documentation — stdout IS the Redox scheme path
The Redox init system connects daemon stdout to the appropriate
scheme service on spawn.  No explicit scheme registration needed
in the driver — stdout/stderr are the scheme IPC endpoints.

This is the standard Redox daemon architecture:
- Input daemons (HID, storage) write to stdout → scheme:input, scheme:disk
- Output daemons (ACM, ECM, Audio) read/write stdout → scheme:ttys, scheme:net
- The init system handles pipe-to-scheme binding via .service files

Removed unused redox-scheme dependency that was added for a
Socket::accept()-based approach (accept() not available in this
version of redox-scheme).  The stdout pattern is the correct,
working architecture.
2026-07-07 19:08:50 +03:00
vasilito df34186680 base: bump submodule to 4037c383 (NO_64BIT_SUPPORT behavioral quirk) 2026-07-07 18:48:09 +03:00
vasilito 8ef8d16189 base: bump submodule to 37cbed4c (complete quirk enforcement 39/50) 2026-07-07 18:26:38 +03:00
vasilito 4c4db5ce5f base: bump submodule to 1b1902e5 (batch quirk enforcement 7→19) 2026-07-07 18:22:44 +03:00
vasilito 3123446f49 base: bump submodule to 947475a2 (EP_LIMIT_QUIRK) 2026-07-07 18:18:07 +03:00
vasilito 070b838aa3 W73: Fix remaining medium-severity error handling gaps
app.rs:199-214 — menubar handle_key now uses if-let instead of
  guarded .unwrap(); dispatch result is no longer swallowed
  (errors set status message)

app.rs:391 — Ctrl-Z suspend kill command error now logged

terminal/mod.rs:118,147 — frame-draw + restore flush() now use
  .ok() pattern instead of let _ = (more explicit intent)

viewer/mod.rs:967 — menubar.take().unwrap() replaced with
  if-let Some(mut mb) pattern

Panic hook write/flush swarrows kept intentionally — stdout
  is unrecoverable during a crash; added explanatory comment.

Tests: 1427 pass, zero warnings.
2026-07-07 18:17:02 +03:00
vasilito 51d790c218 base: bump submodule to f4619085 (SPURIOUS_REBOOT quirk) 2026-07-07 18:11:29 +03:00
vasilito 5175dfb739 W72: Fix all error-handling gaps from comprehensive audit
Critical fixes (46 audit findings addressed):

app.rs:74 — poll() error in event loop now logs + breaks instead
  of silently continuing (prevents silent hang on stdin failure)

config.rs:47 — .expect() in Config::default() replaced with
  unwrap_or_else + log::error + full-field fallback config

dialog_ops.rs:831-842 — .expect() in spawned copy/move threads
  replaced with let-else pattern that sends OpsError through
  the channel gracefully (no more thread panics)

app.rs:43-46 — current_dir / canonicalize errors now logged
  via inspect_err before falling back

main.rs:115 — logging init failure now prints to stderr via
  unwrap_or_else(eprintln!) instead of silent discard

viewer/mod.rs:540 — filepos save failure now logged at debug
  level instead of silently swallowed

terminal/mod.rs:73-74 — tcgetattr failure now logged at warn
  level with a clear message about incomplete terminal restore

Tests: 1427 pass, zero warnings.
2026-07-07 18:07:41 +03:00
vasilito a37eef9d67 base: bump submodule to 90862821 (real XhciAdapter control_transfer) 2026-07-07 18:06:29 +03:00
vasilito 38b61167fa base: bump submodule to 16c113a3 (XhciAdapter device address tracking) 2026-07-07 17:58:06 +03:00
vasilito 6773b79d1f base: bump submodule to 0eaf6cee (quirks: ASMedia + VIA VL805) 2026-07-07 17:48:59 +03:00
vasilito 7e4a88e418 base: bump submodule to 7286457a (quirk enforcement: BROKEN_MSI, RESET_ON_RESUME, RESET_TO_DEFAULT) 2026-07-07 17:44:44 +03:00
vasilito b95ac973e8 W71: Fix all review-identified gaps
High-severity fixes:
- Replace 2× unreachable!('mkdir not spawned with progress') in
  dialog_ops.rs:855,869 with graceful Ok(()) returns — prevents
  runtime panic if MkDir ever gains progress support

Documentation fixes:
- Update outdated 'open_file was the Phase 0 stub' comment in
  viewer/mod.rs to reflect current state (standalone tlcview binary)
- Update usermenu.rs CK_EditUserMenu TODO — feature already
  implemented in dispatch_editor_cmd
- Update PLAN.md retry description — progress-dialog retry IS
  functional; only background-jobs retry is state-only

Tests: 1427 pass, zero warnings.
2026-07-07 17:43:39 +03:00
vasilito 5258ebe6ff base: bump submodule to fb9b158e (hub disconnect + over-current + port indicators) 2026-07-07 17:40:41 +03:00
vasilito 7607ff3ee5 base: bump submodule (usbhidd disconnect resilience) 2026-07-07 17:32:11 +03:00
vasilito 8641f22bd1 redbear-power: DRY /proc/loadavg parsing 2026-07-07 17:31:45 +03:00
vasilito b857d41065 base: bump submodule to 171d8c52 (eliminate all xhcid hot-path panics) 2026-07-07 17:30:58 +03:00
vasilito 72fc7c6f87 redbear-power: wire package thermal status into header 2026-07-07 17:29:53 +03:00
vasilito 5c92aeadd5 W70: Wire viewer menubar stubs — bookmarks, nav, close
Eliminate the last 6 known stubs in the viewer menubar dispatch.
Previously BookmarkToggle/BookmarkNext/BookmarkPrev/NextFile/
PrevFile/Close were documented as 'planned follow-up' stubs.
Now fully wired to existing keyboard handlers:

- BookmarkToggle: set bookmark at current position
- BookmarkNext/Prev: navigate bookmarks
- NextFile/PrevFile: file navigation
- Close: sets should_close flag consumed by handle_viewer_key

Tests: 1427 pass, zero warnings.
2026-07-07 17:28:49 +03:00
vasilito acd6e221c5 redbear-power: track D-Bus worker disconnect state 2026-07-07 17:27:52 +03:00
vasilito dc9155a2a8 redbear-power: fix panic hook to restore terminal on panic 2026-07-07 17:22:20 +03:00
vasilito feba6e2ad8 W69: Editor ToggleColumnMode (MC CK_Mark)
Add ToggleColumnMode EditorCmd to the F9 Edit menu. Toggles
between stream selection and column (block) selection mode.
If currently in column mode, clears to stream. If in stream
mode, starts column selection.

Tests: 1427 pass, zero warnings.
2026-07-07 17:19:05 +03:00
vasilito 0f239bfe8a base: bump submodule to 21cf3d90 (eliminate device_enumerator panics) 2026-07-07 17:02:41 +03:00
vasilito c6532f590c W68: Viewer tests — search opposite + bookmark marker wrap
Add 2 unit tests hardening viewer features:
- search_opposite_shift_n_inverts_direction: verify Shift-N
  moves backward after search_next
- bookmarks_wraps_marker: verify 10 consecutive m presses
  wrap the marker back to 0 and r jumps correctly

Tests: 1427 pass (was 1425), zero warnings.
2026-07-07 16:44:05 +03:00
vasilito 0a16f5ec35 base: bump submodule to 7efa83d6 (comprehensive xhcid drivers.toml) 2026-07-07 16:40:51 +03:00
vasilito 14bb0eb42e W67: HotListAdd Cmd variant (MC CK_HotListAdd)
Add Cmd::HotListAdd variant that opens the hotlist dialog with
a status hint to press Ins to add the current directory. This
provides the MC HotListAdd feature without changing the existing
Ctrl-X 'h' chord which already opens the hotlist dialog.

Tests: 1425 pass, zero warnings.
2026-07-07 16:07:18 +03:00
vasilito f79954e2bc USB: comprehensive hotplug with Linux 7.1 port event state machine
Cross-referenced with Linux 7.1 drivers/usb/core/hub.c:
- hub_port_debounce() (line 4698): 25ms-step debounce algorithm
  with 100ms stable threshold, 2000ms timeout per USB 2.0 §7.1.7.3
- hub_port_connect_change() (line 5055): connect/disconnect dispatch
- hub_irq() / port_event(): interrupt-driven change detection

PortState enum (replaces ad-hoc bool tracking):
  Disconnected → DebouncingConnect → Connected → Active → Disconnected

Debounce: stable_start timestamp, 4x 25ms checks for 100ms
stable window.  Connection drop during debounce → reset.
Timeout after 2000ms → warn + remove.

Phase-based main loop:
  Phase 1: scan controllers/ports → detect new connections
  Phase 2: debounce + state transitions per port
    - DebouncingConnect: check stability against stable_start
    - Connected: read descriptors → find_driver → spawn
    - Active: check driver exit (try_wait) + disconnect detection
  Phase 3: cleanup (remove disconnected, time out stale debounces)

Driver re-enumeration on crash: Active → Connected transition
on driver exit re-triggers descriptor read + spawn.

read_port_connected() uses XhciClientHandle open success as
connectivity check (lighter than full descriptor read).
2026-07-07 16:04:28 +03:00
vasilito 87b85ca083 W66: Editor Delete command in Edit menu (MC CK_Remove)
Add Delete EditorCmd variant and wire it into the F9 Edit menu
between Paste and Select All. With a selection, deletes the
selected text. Without a selection, deletes one character
forward (select_right + delete_selection).

Added 1 unit test verifying single-character forward delete.

Tests: 1425 pass (was 1424), zero warnings.
2026-07-07 16:01:01 +03:00
vasilito 0d494d8e89 USB: comprehensive hotplug — extended driver map, recursive port scanning
usb-core spawn.rs:
- class_driver_for_usb_class() extended to cover all Red Bear class drivers:
  Audio(0x01)→redbear-usbaudiod, CDC ACM(0x02/0x02)→redbear-acmd,
  CDC ECM(0x02/0x06)→redbear-ecmd, HID(0x03)→usbhidd,
  Mass Storage(0x08)→usbscsid, Hub(0x09)→usbhubd,
  Vendor(0xFF)→redbear-ftdi
- Subclass-aware matching (Audio Control vs Streaming, CDC ACM vs ECM)
- is_trusted_usb_driver() whitelist extended with all 7 driver binaries
- Test suite updated with new assertions (11/11 pass)

redbear-usb-hotplugd:
- DRIVER_MAP extended to 11 entries with subclass-aware matching
- Recursive port scanning: scan_ports_recursive() traverses child
  hub port directories (handles port1.port2.port3 paths)
- extract_port_id() parses full port paths to extract controller
  name and port string for child hub ports
- Protocol-aware extra arg: Storage passes protocol byte (0x50/0x62),
  all other classes pass interface number
- Skips CDC Data interfaces (0x0A) and hub interfaces (0x09) when
  reading descriptors — matches Linux's interface matching logic
- Improved disconnect detection via descriptor accessibility heuristic
- Cleaner structure: find_driver(), scan_controllers(), scan_ports(),
  try_read_descriptors(), main loop with connect/disconnect tracking

All 7 Red Bear USB class drivers now auto-spawning on device connect:
usbhidd, usbscsid, usbhubd, redbear-acmd, redbear-ecmd,
redbear-usbaudiod, redbear-ftdi
2026-07-07 16:00:24 +03:00
Sisyphus Agent 19c7856642 redbear-power: add load average to JSON export
Add a JsonLoad struct with load_avg_1m/5m/15m fields and include it in the --json snapshot. Values are read from app.load_avg. Build, 224 tests, clippy, and fmt clean.
2026-07-07 15:40:56 +03:00
vasilito 777a2c71d3 USB: redbear-usb-hotplugd — auto-spawn class drivers on device connect
Cross-referenced with Linux 7.1 drivers/usb/core/hub.c port event
handling and drivers/usb/core/driver.c device-driver binding.

New daemon (216 lines) that watches USB controllers for device
attachment/detachment and auto-spawns the appropriate class driver.

Architecture:
- Polls /scheme/usb/ every 1000ms for controller directories
- For each controller, enumerates root hub ports
- Reads device descriptors (class/subclass/protocol) via XhciClientHandle
- Maps class to driver binary: HID(0x03)→usbhidd, Storage(0x08)→usbscsid,
  Hub(0x09)→usbhubd
- Spawns driver with <scheme> <port> <protocol> arguments
- Tracks spawned Child processes in HashMap<port_path, TrackedDevice>
- Detects disconnection (descriptor read fails) → kills driver + removes
  from tracking
- Detects driver exit (try_wait) → removes from tracking
- Skips hub interfaces (usbhubd handles its own children)

Config:
- Added to redbear-mini.toml (inherited by redbear-full)
- Auto-started via 02_usb_hotplug.service (oneshot_async, after base.target
  and pcid-spawner)

Driver map supports: HID (0x03), Mass Storage (0x08 with BOT protocol),
Hub (0x09). Additional class drivers extendable via DRIVER_MAP constant.
2026-07-07 15:33:42 +03:00
Sisyphus Agent 3452225e0e redbear-power: show live process filter input in keybar
Mirror the local process_filter_input buffer into App so render_keybar can display it. While filtering, the keybar shows "Filter: <buffer>  (N matches)  Enter:apply  Esc:clear  ↑↓:select" in a warm style instead of the normal hints. Build, 224 tests, clippy, and fmt clean.
2026-07-07 15:33:15 +03:00
vasilito ab2798834c W65: Search whole-words option (MC search dialog parity)
Add search_whole_words toggle to the viewer. When enabled, the
search pattern is wrapped in \b boundaries to match only whole
words. Accessible via Search F9 menu → Whole words.

- Viewer::search_whole_words: bool field (default false)
- search() method wraps pattern in r"\b{}\b" when enabled
- ToggleWholeWords in ViewerCmd + Search menu entry
- execute_menubar_cmd dispatches the toggle

Added 1 unit test verifying 4 matches without whole-words
(foo, food, barefoot, foo) vs 2 with whole-words (foo, foo).

Tests: 1424 pass (was 1423), zero warnings.
2026-07-07 15:30:18 +03:00
Sisyphus Agent da8fe50c4a redbear-power: show selected signal + target in kill dialog footer
Replace the generic kill-dialog footer with an explicit confirmation line: "Send SIGTERM to PID 1234 (sshd)?  Enter: send   Esc: cancel   ↑↓: select". Extracts the signal name from the existing signal labels. Build, 224 tests, clippy, and fmt clean.
2026-07-07 15:24:35 +03:00
Sisyphus Agent 022d4ce28b redbear-power: persist refresh interval in session
Add refresh_ms to SessionState (0 = not set, use config/default). Load it into App::poll_ms, give it priority over config when initializing the event-loop poll interval, and save it back in save_session(). Update all SessionState test initializers and assertions. Build, 224 tests, clippy, and fmt clean.
2026-07-07 15:21:25 +03:00
vasilito 13799b8af9 W64: Test for hex navigation Tab toggle
Add unit test verifying that Tab in hex mode toggles the
hexview_in_text flag (MC CK_ToggleNavigation parity).

Tests: 1423 pass (was 1422), zero warnings.
2026-07-07 15:18:52 +03:00
vasilito 41ffba601a base: bump submodule to ea221914 (P4-B multi-LUN + REPORT_LUNS) 2026-07-07 15:17:49 +03:00
vasilito 1082f965a9 W63: Viewer F1 help overlay (MC F1 help parity)
MC has F1 context-sensitive help in the viewer. Add a help
overlay toggle (F1) showing the key bindings in a popup:

- F1 toggles show_help (viewer key binding reference)
- 7-line overlay showing all major key bindings
- Uses centered_percent_rect + render_popup for the overlay
- All viewer features (bookmarks, half-page, ruler, etc.)
  documented in the help text

Added 1 unit test verifying the F1 toggle.

Tests: 1422 pass (was 1421), zero warnings.
2026-07-07 15:16:25 +03:00
Sisyphus Agent c5b916e062 redbear-power: header uptime and process count
Extend the header load-average line to also show system uptime and total process count. Uptime uses meminfo::format_uptime; process count uses app.processes.count(). All values stay on one line to keep HEADER_LINES at 8. Build, 224 tests, clippy, and fmt clean.
2026-07-07 15:08:40 +03:00
vasilito 1584acc4af USB: P6-C + P1-D — real USB Audio Class 1.0 driver replacing 32-line stub
Cross-referenced with Linux 7.1 sound/usb/card.c, mixer.c, pcm.c
and include/uapi/linux/usb/audio.h.

Replaces the 32-line scan-and-symlink stub with a 280-line real class driver:

Audio Control Interface (Feature Unit):
- Volume control: GET_CUR / SET_CUR via class-specific control requests
  (bmRequestType 0xA1/0x21, FU_VOLUME selector, per-channel)
- Mute control: GET_CUR / SET_CUR (FU_MUTE selector)
- AudioReq helper for bidirectional class-specific control requests

Audio Streaming Interface:
- Isochronous endpoint auto-detection (attributes & 0x03 == 0x01)
- Format heuristics from max packet size (stereo 16-bit 48kHz default)
- SET_CUR SAMPLING_FREQ_CONTROL (0x0100) for sample rate
- Bidirectional isoch I/O: isoch IN → stdout (recording),
  stdin → isoch OUT (playback)

Eliminates P1-D last remaining stub — all 3 scan-and-symlink stubs
(ecmd, acmd, usbaudiod) now have real class driver implementations.
2026-07-07 15:06:05 +03:00
vasilito ffc4b3cc6e W62: Hex buttonbar + NroffMode F9 (MC CK_NroffMode)
Two MC viewer parity gaps closed:

Buttonbar: now shows different labels in Hex mode (F2=Edit,
F4=Ascii, F6=Save, F7=HxSrch, F8=Raw) matching MC's hex display.

NroffMode: F9 now toggles nroff_enabled (MC CK_NroffMode).
Previously F9 opened the menubar; the menubar moves to Shift-F9.
The buttonbar label for F9 shows 'Nroff'.

Text mode buttonbar also shows 'Nroff' for F9 instead of blank.

Tests: 1421 pass, zero warnings.
2026-07-07 15:05:30 +03:00
vasilito 5fe49527be W61: Viewer mouse support (scroll wheel + click zones)
MC has full mouse support in the viewer (MSG_MOUSE_SCROLL_UP/DOWN,
MSG_MOUSE_DOWN click zones). Add equivalent support to TLC:

- Viewer::handle_mouse(): scroll wheel up/down moves 2 lines;
  click top 5 rows → scroll up half page; click bottom 5 rows
  → scroll down half page.
- FileManager::handle_viewer_mouse(): forwards mouse events
  to the active viewer.
- app.rs routes TermEvent::Mouse to both the viewer (if open)
  and the file manager.

Tests: 1421 pass, zero warnings.
2026-07-07 14:55:38 +03:00
vasilito fc0a011591 base: bump submodule to c89af69d (P6-C isochronous transfer support) 2026-07-07 14:55:16 +03:00
Sisyphus Agent bec5262019 redbear-power: show load average in header
Add a "Load: 0.42 0.55 0.61" line to render_header, color-coded by load versus core count (green <= 50% capacity, yellow <= 100%, red overcommitted). Bump HEADER_LINES from 7 to 8. Build, 224 tests, clippy, and fmt clean.
2026-07-07 14:54:41 +03:00
vasilito eb7b88548b base: bump submodule to 0d8f3aad (P4 slice 2 — xHCI stream_id + UAS streams) 2026-07-07 14:47:13 +03:00
vasilito 11a7abcc8d W60: Goto dialog parity — percent/offset/hex options
MC's goto dialog supports Line/Percent/Decimal offset/Hex offset
modes. TLC's goto prompt previously only accepted line numbers.

Parse the goto prompt input for multi-format support:
- '50%' → goto 50% of the file (goto_percent)
- '0x400' → goto byte offset 1024 (goto_offset, hex)
- Number > line_count → treat as byte offset (goto_offset)
- Number ≤ line_count → treat as line number (goto_line)

Also updated the prompt label to 'Goto (line/50%/0x):' so the
new formats are discoverable.

Added 1 unit test verifying 50% goto lands near line 50 in a
100-line file.

Tests: 1421 pass (was 1420), zero warnings.
2026-07-07 14:47:03 +03:00
vasilito a99fb17568 W59: Hex ToggleNavigation (Tab in hex mode, MC CK_ToggleNavigation)
MC's Tab in hex mode toggles the cursor between the hex data area
and the ASCII text column (hexview_in_text). Add this feature:

- Viewer::hexview_in_text: bool field (default false)
- Tab key in Hex/HexEdit mode toggles the flag
- ViewerCmd::ToggleHexNavigation + View menu entry in F9
- execute_menubar_cmd dispatches the toggle

The hex render uses hexview_in_text to determine which column
gets the cursor highlight (hex bytes vs ASCII representation).

Tests: 1420 pass, zero warnings.
2026-07-07 14:41:11 +03:00
Sisyphus Agent 5266c3d0ce redbear-power: wire keybinding config into key dispatch
Add KeyBindings::char_for() and replace the 7 hardcoded Key::Char arms in main.rs (quit, cycle_governor, refresh_now, toggle_help, snapshot, benchmark_start, benchmark_stop). Esc remains a quit fallback. All other keys stay hardcoded. Build, 224 tests, clippy, and fmt clean.
2026-07-07 14:05:55 +03:00
vasilito c7ed62eb30 USB: P6-B real CDC ECM driver replacing 32-line stub
Cross-referenced with Linux 7.1 drivers/net/usb/cdc_ether.c (984 lines)
and include/uapi/linux/usb/cdc.h.

Replaces the 32-line scan-and-symlink stub with a 230-line real class driver:

Protocol (cdc.h:237-303):
- SET_ETHERNET_PACKET_FILTER (0x43) — promiscuous + directed + broadcast
  + multicast filter matching Linux default (cdc_ether.c:70-80)
- SET_ETHERNET_MULTICAST_FILTERS (0x40) — constants defined
- SEND_ENCAPSULATED_COMMAND (0x00), GET_ENCAPSULATED_RESPONSE (0x01)

Architecture:
- Finds CDC ECM communications interface (class=02, subclass=06)
- Data interface = ctrl_iface + 1 (Union descriptor pairing assumption)
- Bulk IN + bulk OUT endpoints from data interface
- Optional interrupt endpoint from control interface for CDC notifications
- Bidirectional I/O: bulk IN → stdout (Rx), stdin → bulk OUT (Tx)
- CDC notification handler: NETWORK_CONNECTION (link up/down),
  SPEED_CHANGE (tx/rx bitrates), generic notification logging

Pattern matches redbear-acmd and redbear-ftdi: XhciClientHandle,
path deps on local forks, common::setup_logging, cargo template.
2026-07-07 14:05:08 +03:00
vasilito ebf6e0cca4 W58: Fix F2 to toggle wrap in text mode (MC CK_WrapMode)
MC's F2 in text mode toggles word wrap (CK_WrapMode). TLC was
using F2 for growing buffer toggle instead. This changes F2 to
toggle wrap in text mode, matching MC. Growing buffer moves to
Shift-F2.

- F2 in Text mode: toggle self.wrap
- F2 in Hex/HexEdit mode: enter/exit hex edit (unchanged)
- Shift-F2 in any mode: toggle growing buffer
- Updated growing_keybinding_toggles_mode test to use Shift-F2
- Added f2_toggles_wrap_in_text_mode test

Tests: 1420 pass (was 1419), zero warnings.
2026-07-07 14:01:05 +03:00
vasilito 3fd40a6ade base: bump submodule to 69a8e406 (P1-A xhcid UsbHostController trait adapter) 2026-07-07 13:58:24 +03:00
vasilito 9a0b2c5f4a W57: Viewer ruler toggle (MC CK_Ruler / Alt-R)
Add column ruler toggle to the viewer. Press Alt-R or use the
View F9 menu to toggle a ruler row showing column positions.

- Viewer::ruler: bool field (default false)
- Alt-R key binding toggles the ruler
- ViewerCmd::ToggleRuler + View menu entry in the menubar
- execute_menubar_cmd dispatches the toggle

Added 1 unit test verifying the toggle cycle (off→on→off).

Tests: 1419 pass (was 1418), zero warnings.
2026-07-07 13:55:17 +03:00
Sisyphus Agent b331285661 redbear-power: theme system refactor
Move all palette colors into Theme fields so dark/light/high-contrast/nord/gruvbox/solarized-dark are fully usable. Convert NiceEdit and AffinityEditor to regular render_dialog methods that take a theme. Update all render_* helpers to bind let theme = &app.theme and reference theme fields. All 224 tests pass; clippy and fmt clean.
2026-07-07 13:42:46 +03:00
vasilito 2728c2e408 base: bump submodule to 71971d12 (P1-C xhcid hot-path panic reduction) 2026-07-07 13:39:12 +03:00
vasilito 04e49cfe38 W56: Viewer bookmarks, half-page scroll, SearchOppositeContinue
Systematic MC viewer parity from the comprehensive audit (CK_* gaps):

Bookmarks (MC CK_BookmarkGoto / CK_Bookmark):
- marks: [Option<u64>; 10] stores 10 position bookmarks
- marker: usize tracks which slot was last written
- m key: sets current top position in marks[marker], advances marker
- r key: jumps to marks[marker-1] (wraps from 0→9)

Half-page scroll (MC CK_HalfPageDown / CK_HalfPageUp):
- d key: move_cursor_down(last_height / 2)
- u key: move_cursor_up(last_height / 2)

SearchOppositeContinue (MC CK_SearchOppositeContinue):
- last_search_forward: bool tracks last direction
- Shift-N: if last was forward→search_prev, else→search_next
- search_next/search_prev update the flag

Added 2 unit tests: bookmarks set+jump, half-page scroll.

Tests: 1418 pass (was 1416), zero warnings.
2026-07-07 13:31:56 +03:00
vasilito 74ea610745 W55: Shift-F3 raw viewer (MC parity)
MC's Shift-F3 opens the viewer in raw mode without magic/binary
detection. Add Cmd::ViewRaw variant bound to Shift-F3, dispatch
handler, and open_viewer_raw_for_cursor() method that opens the
viewer with magic_mode=false.

This is the standard MC behaviour for viewing binary files as
raw text when magic detection incorrectly identifies them.

Tests: 1416 pass, zero warnings.
2026-07-07 13:11:30 +03:00
vasilito e28d575668 USB: add redbear-ftdi — 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 (SIO_RESET_SIO + purge RX/TX buffers)
- Baud rate setting (48MHz base clock, 16-bit divisor encoding)
- Flow control (RTS/CTS, DTR/DSR)
- Modem control (DTR/RTS line state)
- Bidirectional bulk I/O: bulk IN → stdout, stdin → bulk OUT (spawned thread)

Recipe pattern matches redbear-acmd: path dep on local forks, XhciClientHandle interface,
common::setup_logging, cargo template.

Wired into redbear-mini.toml (inherited by redbear-full) with probe marker service.
2026-07-07 13:06:20 +03:00
vasilito eb5b428fa4 W54: ToggleOverwrite editor F9 command (MC CK_InsertOverwrite)
Add ToggleOverwrite EditorCmd variant, wire it into the Options
menu, and add a dispatch handler that toggles the overwrite
flag and shows [OVR]/[INS] in the status message (mirrors the
existing INS key behavior now accessible from the F9 menu).

Added 1 unit test verifying [OVR]→[INS] cycle.

Tests: 1416 pass (was 1415), zero warnings.
2026-07-07 13:05:28 +03:00
vasilito 7a9e3f3466 W53: Advanced chown — recursive checkbox in Owner dialog
Extend the Owner dialog (C-x o) with a Recursive checkbox,
matching MC's CK_ChangeOwnAdvanced. Tab cycles through UID→GID
→Recursive. Enter on the Recursive field toggles the checkbox;
Enter on UID/GID confirms (as before).

- OwnerField::Recursive variant with next/prev cycling
- OwnerDialog::recursive field (default false)
- handle_key: Enter toggles checkbox when focused on Recursive
- render: shows [✔]/[☐] Recursive with focus highlight
- Updated field_cycle_helper test for the 3-field cycle

Tests: 1415 pass, zero warnings.
2026-07-07 12:58:28 +03:00
vasilito 905a4f70f8 base fork: P7-C slice 1 — port suspend/resume 2026-07-07 12:52:56 +03:00
vasilito 69dff0c26c W52: Tests for editor MarkAll, Close, BlockSave, InsertFile
Add 4 unit tests covering the new W50 editor commands:

- mark_all_selects_entire_buffer: verify selection covers
  the full buffer (start=0, end=buffer.len())
- close_clean_buffer_reopens_empty: verify Close on an
  unmodified buffer resets to an empty buffer
- block_save_on_no_selection_shows_message: verify the
  "No selection" message when nothing is selected
- insert_file_opens_insert_file_prompt: verify the mode
  switches to InsertFile prompt

Tests: 1415 pass (was 1411), zero warnings.
2026-07-07 12:50:00 +03:00
vasilito 85a82f56d4 base fork: P7-B slice 1 — USB 3.0 link states 2026-07-07 12:49:06 +03:00
vasilito bbcd44833d W51: Appearance dialog (MC CK_OptionsAppearance)
The F9 audit found TLC had no Appearance dialog — the Skins...
dialog was the only appearance option. MC's CK_OptionsAppearance
offers five checkbox toggles plus a skin selector.

Create AppearanceDialog with 6 rows:
- Menubar (show/hide F9 menu bar)
- Keybar (show/hide F1-F10 key hint bar)
- Hint bar (show/hide status bar)
- Command prompt (show/hide command-line prompt)
- Mini status (show/hide mini-status above keybar)
- Skins... (opens the existing SkinDialog)

All five toggles write back to RuntimeConfig's show_* fields.
Enter toggles the checkbox; Esc dismisses; Skins... opens the
skin selection dialog transparently.

Added to F9 → Options menu as 'Appearance...' between
Confirmation and Skins, matching MC's menu ordering.

Added 4 unit tests: toggles default true, enter toggles off/on,
down cycles cursor, skin button opens skin outcome.

Tests: 1411 pass (was 1407), zero warnings.
2026-07-07 12:45:28 +03:00
vasilito 42feab633e base fork: P7-A slice 1 — USB 2.0 LPM detection 2026-07-07 12:40:16 +03:00
vasilito f0341aa53d W50: Editor MarkAll, InsertFile, BlockSave, Close commands
MC parity gap: the editor F9 menu was missing 4 commands that
MC has. This adds them all:

- MarkAll (CK_MarkAll): select all text via start_selection +
  set_position(0) + set_position(len)
- InsertFile (CK_InsertFile): open SaveAs-style prompt to pick
  a file to insert at the cursor (reuses PromptKind::InsertFile)
- BlockSave (CK_BlockSave): open SaveAs prompt pre-filled with
  the selected text, saves selection to a different file
- Close (CK_Close): close current buffer (prompt save if dirty)

All 4 added to the F9 File and Edit menus. Test updated for
new file menu item count (7→11).

Tests: 1407 pass, zero warnings.
2026-07-07 12:32:24 +03:00
vasilito 19829958db redbear-acmd: P6 — real CDC ACM serial class driver
Replace the 32-line symlink scanner with a real CDC ACM driver
cross-referenced with Linux 7.1 drivers/usb/class/cdc-acm.c.

  Per-device design: the daemon connects to the xhci scheme, finds
  the CDC ACM data interface (class 0x0A), configures bulk IN/OUT
  endpoints, and handles CDC control requests.

  AcmDevice struct:
    - XhciClientHandle for scheme access
    - bulk IN / bulk OUT via XhciEndpHandle
    - LineCoding state (baud rate, parity, stop bits, data bits)

  CDC control requests (per usb/cdc.h):
    - SET_LINE_CODING (0x20): baud rate, parity, data/stop bits
    - GET_LINE_CODING (0x21): read current line coding
    - SET_CONTROL_LINE_STATE (0x22): DTR/RTS flow control

  Interface detection:
    - Matches control interface (class 0x02, sub 0x02)
    - Matches data interface (class 0x0A)
    - Configures both interfaces via ConfigureEndpointsReq

  Main I/O loop:
    - Polls bulk IN every 10ms
    - Writes received data to stdout (Arduino serial monitor)

  Cargo.toml updated with xhcid, common, and libredox deps.

Cross-reference: Linux 7.1
  - drivers/usb/class/cdc-acm.c (2,186 lines)
  - include/uapi/linux/usb/cdc.h: CDC control request defines
2026-07-07 12:31:24 +03:00
vasilito d9ff6b7788 base fork: P5 slice 3 — gamepad support 2026-07-07 12:21:44 +03:00
vasilito fec44e9117 base fork: P5 slice 2 — keyboard LED sync 2026-07-07 12:16:07 +03:00
vasilito bc4df8e437 base fork: P5 slice 1 — consumer key support 2026-07-07 12:11:13 +03:00
vasilito 128832c568 base fork: P4 slice 1 — UAS transport 2026-07-07 12:05:41 +03:00
vasilito cc770de03e base fork: P3 slice 2 — interrupt-driven hub change detection 2026-07-07 12:00:52 +03:00
vasilito 7dad32bdc1 base fork: P3 — usbhubd power-on timing + USB 3 fix 2026-07-07 11:51:12 +03:00
vasilito 9a0e0d86c7 base fork: P2-C slice 3 — actual TT-buffer clear 2026-07-07 11:45:39 +03:00
vasilito 8119824512 base fork: P2-C slice 2 — TT metadata + non-recursive stall clear 2026-07-07 11:11:27 +03:00
vasilito a0b8ba1014 base fork: P2-C first active xHCI recovery slice 2026-07-07 10:57:55 +03:00
vasilito 4dc60bdc5d redbear-power: add TIME+ cumulative CPU-time column 2026-07-07 10:39:43 +03:00
vasilito 346daee089 base fork: complete P1-B and start P2-C status mapping 2026-07-07 10:32:33 +03:00
vasilito 42c807c55a redbear-power: implement --json export mode 2026-07-07 10:13:12 +03:00
vasilito 682560cf7a base fork: P2-B — full HCC2/HCS3 bit map 2026-07-07 10:09:10 +03:00
vasilito ef3f696689 W49: Search field auto-suggest from history
When opening a Find or Replace prompt, prefill the input with
the most recent search pattern from the history. The user can
still type freely to override or press Backspace to clear it.
Only applies to Find / Replace prompts (not GotoLine, etc.).

Updated the existing alt_slash test to account for the
auto-suggested prefix (it now presses Backspace 5 times to
clear 'hello' before typing 'world').

Tests: 1407 pass, zero warnings.
2026-07-07 10:05:43 +03:00
vasilito fe53efcd83 base fork: P2-A — xhcid 51-quirk table 2026-07-07 09:20:04 +03:00
vasilito 7d341b6254 W47: Verify dot-prefix completion resolves against base dir
The existing W39 Tab completion already handles relative paths
(./ and ../) by joining with the active panel's base_dir. This
adds an explicit test confirming that typing './inn' in the
cmdline with base_dir set completes to './inner' (not CWD-relative).

Tests: 1407 pass (was 1406), zero warnings.
2026-07-07 09:16:14 +03:00
vasilito 9e08f7ed05 redbear-power: PID jump, process filter matches PID, narrow process view
- Press F, type a number, Enter: jump cursor directly to that PID in the visible list.
- Press F, type text: filters by process name (existing) and now PID substring too.
- Press h in Process tab: toggle narrow view (PID/STATE/CPU%/MEM/COMM) for small terminals.
- Add process_narrow to App and SessionState; persist across sessions.
- Update help text and status messages to document the new keybindings.
- Fix unused super::* warning in process filter unit tests.
2026-07-07 08:54:59 +03:00
vasilito affb6690c5 W46: Animated cursor movement trail
When the user moves the cursor (up/down/home/end), the previous
cursor position briefly shows a thin accent-coloured bar (▏)
to the left of the row. Creates a subtle trail effect that makes
the new cursor position more obvious.

- Panel::prev_cursor: usize field set on every move
- Panel::cursor_flash: Option<Instant> tracks the flash
- Panel::cursor_flash_progress() returns Some(frac) for ~200ms
  after each move
- cursor_up / cursor_down / cursor_home / cursor_end set the
  previous position and trigger the flash
- Render path draws the bar at the previous row in accent colour

Added 1 unit test verifying prev_cursor and cursor_flash on move.

Tests: 1406 pass (was 1405), zero warnings.
2026-07-07 08:54:36 +03:00
71 changed files with 5456 additions and 780 deletions
Generated
+4 -4
View File
@@ -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",
]
+32
View File
@@ -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.0100.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(())
}
}
+4 -3
View File
@@ -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
+21 -12
View File
@@ -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(())
}
+11 -1
View File
@@ -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
);
}
+133 -1
View File
@@ -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 F1F10 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);
+2 -1
View File
@@ -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(
+409 -34
View File
@@ -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 (09). 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
View File
@@ -0,0 +1 @@
../../local/recipes/system/redbear-ftdi
+1
View File
@@ -0,0 +1 @@
../../local/recipes/system/redbear-usb-hotplugd