tlc: split filemanager/mod.rs into submodules + comment out mc in configs

- Split filemanager/mod.rs into dispatch.rs, render.rs, dialog_ops.rs,
  format_utils.rs (3567→~1200 lines in mod.rs)
- Comment out mc in redbear-mini.toml and redbear-full.toml per user request
- Enable tlc in redbear-full.toml (was temporarily disabled)
- 1022 tests pass, zero behavior changes
This commit is contained in:
2026-06-19 17:30:28 +03:00
parent 035304f15b
commit 940e5f55c5
7 changed files with 2506 additions and 2300 deletions
+1 -1
View File
@@ -160,7 +160,7 @@ cosmic-icons = "ignore"
cosmic-term = "ignore"
curl = "ignore"
git = "ignore"
mc = {}
#mc = {}
#curl = "ignore" # suppressed: cascade rebuild
#git = "ignore" # suppressed: cascade rebuild
#konsole = {} # WIP: recipe exists, not yet built — blocked by libiconv fetch
+1 -1
View File
@@ -46,7 +46,7 @@ redbear-wifictl = {}
# Diagnostics and shell-side utilities.
tlc = {}
mc = {}
#mc = {}
redbear-info = {}
# Keep package builder utility in live environment.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,692 @@
//! Key dispatch and event handling for the file manager.
//!
//! The dispatcher receives a [`Cmd`] (a parsed keymap action) and
//! routes it to the matching [`FileManager`] method. When a modal
//! dialog is open, the [`handle_dialog_key`] fallback forwards the
//! raw key into the dialog state machine and applies any returned
//! outcome. The Ctrl-X chord entry point, [`dispatch_ctrl_x_followup`],
//! resolves the second key and re-dispatches the chosen [`Cmd`].
use anyhow::Result;
use crate::filemanager::{
compare, config_dialog, edit_history, exec, external_panelize, filter_dialog, filtered_view,
find, format_utils, help, hotlist, jobs, layout_dialog, link, menubar, move_dialog,
overwrite_dialog, panel_options, percent, quickcd_dialog, render, screen_list, skin_dialog,
tree, usermenu, vfs_list, Cmd, DialogState, FileManager, PendingFileOp,
};
use crate::filemanager::link::LinkKind;
use crate::key::Key;
use super::copy_dialog::CopyDialog;
use super::delete_dialog::DeleteDialog;
use super::info::InfoDialog;
use super::layout_dialog::LayoutDialog;
use super::panel::ListingMode;
use super::panel_options::PanelOptionsDialog;
use super::permission::PermissionDialog;
use super::config_dialog::ConfigDialog;
impl FileManager {
/// Apply a [`Cmd`] to the active panel. Returns `Ok(true)` if the
/// command was handled, `Ok(false)` if the application should
/// process the key itself (e.g. quit).
pub fn dispatch(&mut self, cmd: Cmd) -> Result<bool> {
// If a dialog is open, only Esc and Quit close it; everything
// else is forwarded to the dialog via `handle_dialog_key`.
if self.dialog.is_some() && !matches!(cmd, Cmd::Quit) {
return Ok(true);
}
match cmd {
Cmd::Tab => {
self.switch_focus();
Ok(true)
}
Cmd::SwapPanels => {
self.swap_panels();
Ok(true)
}
Cmd::ToggleLayout => {
self.toggle_layout();
Ok(true)
}
Cmd::Reload => {
self.active_panel_mut().refresh()?;
self.status.set_message("Refreshed");
Ok(true)
}
Cmd::ToggleHidden => {
self.active_panel_mut().toggle_hidden()?;
self.status
.set_message(if self.active_panel().show_hidden() {
"Hidden: shown"
} else {
"Hidden: hidden"
});
Ok(true)
}
Cmd::EnterDir => {
// ENTER on the cursor: descend into directory, or open
// a file in the editor. This preserves MC's historical
// "press Enter to navigate into" behavior.
let p = self.active_panel().cursor_path();
if p.is_dir() {
self.active_panel_mut().enter()?;
} else {
self.open_editor_for_cursor()?;
}
Ok(true)
}
Cmd::MkDir => {
self.open_mkdir_dialog()?;
Ok(true)
}
Cmd::Copy => {
self.open_copy_dialog()?;
Ok(true)
}
Cmd::Move => {
self.open_move_dialog()?;
Ok(true)
}
Cmd::Delete => {
self.open_delete_dialog()?;
Ok(true)
}
Cmd::Edit => {
self.open_editor_for_cursor()?;
Ok(true)
}
Cmd::View => {
self.open_viewer_for_cursor()?;
Ok(true)
}
Cmd::UserMenu => {
self.open_user_menu_dialog()?;
Ok(true)
}
Cmd::HotList => {
self.open_hotlist_dialog()?;
Ok(true)
}
Cmd::Tree => {
self.open_tree_dialog()?;
Ok(true)
}
Cmd::Find => {
self.open_find_dialog()?;
Ok(true)
}
Cmd::Cmdline => {
self.cmdline.activate();
self.status.set_message("Command:");
Ok(true)
}
Cmd::Help => {
self.open_help_dialog()?;
Ok(true)
}
Cmd::Info => {
self.open_info_dialog()?;
Ok(true)
}
Cmd::Permission => {
self.open_permission_dialog()?;
Ok(true)
}
Cmd::Owner => {
self.open_owner_dialog()?;
Ok(true)
}
Cmd::Link => {
self.open_link_dialog(LinkKind::Hard)?;
Ok(true)
}
Cmd::Symlink => {
self.open_link_dialog(LinkKind::Sym)?;
Ok(true)
}
Cmd::Rmdir => {
self.run_rmdir_on_cursor()?;
Ok(true)
}
Cmd::SkinSelect => {
self.open_skin_dialog();
Ok(true)
}
Cmd::Search => {
self.search = Some(String::new());
self.status.set_message("Search:");
Ok(true)
}
Cmd::Quit => {
if self.should_quit {
Ok(false)
} else {
self.dialog = Some(DialogState::Quit(Box::default()));
Ok(true)
}
}
Cmd::MenuBar => {
if self.menubar.is_some() {
self.menubar = None;
} else {
self.menubar = Some(menubar::MenuBar::new());
}
Ok(true)
}
Cmd::SelectGroup => {
self.dialog = Some(DialogState::SelectGroup(Box::new(
super::pattern_dialog::PatternDialog::new_select(),
)));
Ok(true)
}
Cmd::UnselectGroup => {
self.dialog = Some(DialogState::UnselectGroup(Box::new(
super::pattern_dialog::PatternDialog::new_unselect(),
)));
Ok(true)
}
Cmd::QuickCd => {
let history: Vec<String> = self
.active_panel()
.history_paths()
.iter()
.map(|p| p.display().to_string())
.collect();
self.dialog = Some(DialogState::QuickCd(Box::new(
quickcd_dialog::QuickCdDialog::new(&history),
)));
Ok(true)
}
Cmd::TogglePanels => {
self.panels_visible = !self.panels_visible;
if !self.panels_visible {
self.status.set_message("Panels hidden.");
} else {
self.status.set_message("Panels visible.");
}
Ok(true)
}
Cmd::SubShell => {
self.want_subshell = true;
Ok(true)
}
Cmd::Mark => {
self.active_panel_mut().toggle_mark();
self.active_panel_mut().cursor_down();
Ok(true)
}
Cmd::MarkDown => {
self.active_panel_mut().toggle_mark();
self.active_panel_mut().cursor_down();
Ok(true)
}
Cmd::MarkUp => {
self.active_panel_mut().toggle_mark();
self.active_panel_mut().cursor_up();
Ok(true)
}
Cmd::InvertMarks => {
self.active_panel_mut().reverse_marks();
let n = self.active_panel().marked_count();
self.status.set_message(format!("Inverted marks ({n} marked)"));
Ok(true)
}
Cmd::SortNext => {
self.active_panel_mut().cycle_sort();
let sf = self.active_panel().sort_field_name();
self.status.set_message(format!("Sort: {sf}"));
Ok(true)
}
Cmd::SortReverse => {
self.active_panel_mut().toggle_sort_reverse();
let r = self.active_panel().sort_reverse();
self.status.set_message(format!("Sort reverse: {r}"));
Ok(true)
}
Cmd::History => {
let dirs: Vec<String> = self
.active_panel()
.history_paths()
.iter()
.rev()
.map(|p| p.display().to_string())
.collect();
self.status.set_message(format!(
"History: {} dirs (Alt-Y/Alt-U to navigate)",
dirs.len()
));
Ok(true)
}
Cmd::SaveSetup => {
match self.save_config() {
Ok(()) => {
self.status.set_message("Configuration saved.");
}
Err(e) => {
self.status.set_message(format!("Save failed: {e}"));
}
}
Ok(true)
}
Cmd::ListingCycle => {
self.active_panel_mut().cycle_listing_mode();
let mode = self.active_panel().listing_mode();
self.status.set_message(format!("Listing: {}", mode.label()));
Ok(true)
}
Cmd::HistoryBack => {
match self.active_panel_mut().history_back() {
Ok(()) => {
self.status
.set_message("Back".to_string());
}
Err(e) => {
self.status.set_message(format!("{e}"));
}
}
Ok(true)
}
Cmd::HistoryForward => {
match self.active_panel_mut().history_forward() {
Ok(()) => {
self.status
.set_message("Forward".to_string());
}
Err(e) => {
self.status.set_message(format!("{e}"));
}
}
Ok(true)
}
Cmd::LayoutDialog => {
self.dialog = Some(DialogState::Layout(Box::new(
LayoutDialog::from_runtime_config(&self.runtime),
)));
Ok(true)
}
Cmd::PanelOptionsDialog => {
self.dialog = Some(DialogState::PanelOptions(Box::new(
PanelOptionsDialog::from_runtime_config(&self.runtime),
)));
Ok(true)
}
Cmd::ConfigDialog => {
self.dialog = Some(DialogState::Config(Box::new(
ConfigDialog::from_runtime_config(&self.runtime),
)));
Ok(true)
}
Cmd::CompareDirs => {
self.compare_dirs();
Ok(true)
}
Cmd::ViewerNextFile | Cmd::ViewerPrevFile => Ok(true),
Cmd::QuitQuiet => {
self.should_quit = true;
Ok(false)
}
Cmd::EqualSplit => {
self.runtime.equal_split = Some(true);
self.split_ratio = 50;
Ok(true)
}
Cmd::DirSize => {
let p = self.active_panel().cursor_path();
if p.is_dir() {
let bytes = crate::ops::count_bytes(std::slice::from_ref(&p));
self.status
.set_message(format!("{}: {}", p.display(), format_utils::format_size(bytes)));
} else {
self.status.set_message("Not a directory".to_string());
}
Ok(true)
}
Cmd::InsertCurPath => {
let p = self.active_panel().path().to_string_lossy().to_string();
self.cmdline.insert_text(&p);
Ok(true)
}
Cmd::InsertOtherPath => {
let p = self.other_panel().path().to_string_lossy().to_string();
self.cmdline.insert_text(&p);
Ok(true)
}
Cmd::InsertCurFile => {
let name = self
.active_panel()
.cursor_path()
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
self.cmdline.insert_text(&name);
Ok(true)
}
Cmd::SplitLess => {
self.runtime.equal_split = Some(false);
self.split_ratio = self.split_ratio.saturating_sub(5).max(10);
Ok(true)
}
Cmd::SplitMore => {
self.runtime.equal_split = Some(false);
self.split_ratio = self.split_ratio.saturating_add(5).min(90);
Ok(true)
}
Cmd::Jobs => {
let dlg = jobs::JobsDialog::new(Arc::clone(&self.jobs));
self.dialog = Some(DialogState::Jobs(Box::new(dlg)));
Ok(true)
}
Cmd::Panelize => {
self.dialog = Some(DialogState::ExternalPanelize(Box::default()));
Ok(true)
}
Cmd::VfsList => {
self.dialog = Some(DialogState::VfsList(Box::default()));
Ok(true)
}
Cmd::SymlinkRelative => {
let cursor = self.active_panel().cursor_path();
if let Some(dlg) = link::LinkDialog::for_relative(cursor) {
self.dialog = Some(DialogState::Link(Box::new(dlg)));
}
Ok(true)
}
Cmd::SymlinkEdit => {
self.edit_symlink_target();
Ok(true)
}
Cmd::ScreenList => {
let dlg = screen_list::ScreenListDialog::with_screens(self.collect_active_screens());
self.dialog = Some(DialogState::ScreenList(Box::new(dlg)));
Ok(true)
}
Cmd::EditHistory => {
let dlg = edit_history::EditHistoryDialog::from_history(
self.active_panel().history_paths().to_vec(),
);
self.dialog = Some(DialogState::EditHistory(Box::new(dlg)));
Ok(true)
}
Cmd::FilteredView => {
let cursor = self.active_panel().cursor_path();
self.dialog = Some(DialogState::FilteredView(Box::new(
filtered_view::FilteredViewDialog::new(cursor),
)));
Ok(true)
}
Cmd::CompareFiles => {
let left = self.active_panel().cursor_path();
let right = self.other_panel().cursor_path();
if left.is_file() && right.is_file() {
self.dialog = Some(DialogState::Compare(Box::new(
compare::CompareDialog::new(&left, &right),
)));
Ok(true)
} else {
self.status.set_message(
"Compare: both panels must have a regular file under cursor".to_string(),
);
Ok(true)
}
}
Cmd::Suspend => {
self.status
.set_message("Suspend: use Ctrl-O to drop to a shell".to_string());
Ok(true)
}
Cmd::PanelInfo => {
let was_info = matches!(
self.other_panel().listing_mode(),
ListingMode::Info
);
self.other_panel_mut().toggle_info_mode();
self.status
.set_message(if was_info { "Info off" } else { "Info on" });
Ok(true)
}
Cmd::PanelQuickView => {
let was_qv = matches!(
self.other_panel().listing_mode(),
ListingMode::QuickView
);
self.other_panel_mut().toggle_quickview_mode();
self.status
.set_message(if was_qv { "Quick view off" } else { "Quick view on" });
Ok(true)
}
}
}
/// Dispatch a follow-up key after the user pressed Ctrl-X.
///
/// The Ctrl-X prefix activates a two-key chord for commands that
/// don't have a single-key binding. When `pending_ctrl_x` is true,
/// the next key is routed here instead of the normal keymap.
pub fn dispatch_ctrl_x_followup(&mut self, key: crate::key::Key) -> Result<bool, String> {
self.pending_ctrl_x = false;
// Translate the key into a (char) so we can match on the
// follow-up letter. We ignore the modifier bits for the
// chord's second key — MC's chord semantics are letter-only.
let ch = char::from_u32(key.code);
let cmd = match ch {
Some('d') => Some(Cmd::CompareDirs),
Some('j') => Some(Cmd::Jobs),
Some('c') => Some(Cmd::Permission),
Some('o') => Some(Cmd::Owner),
Some('l') => Some(Cmd::Symlink),
Some('s') => Some(Cmd::SymlinkRelative),
Some('v') => Some(Cmd::SymlinkEdit),
Some('a') => Some(Cmd::VfsList),
Some('!') => Some(Cmd::Panelize),
Some('i') => Some(Cmd::PanelInfo),
Some('q') => Some(Cmd::PanelQuickView),
_ => None,
};
match cmd {
Some(c) => self.dispatch(c).map_err(|e| e.to_string()),
None => {
self.status.set_message("Ctrl-X: unknown follow-up key".to_string());
Ok(true)
}
}
}
/// Forward a key to the active modal dialog (if any) and apply any
/// returned outcome. Returns `true` if the active dialog consumed
/// the key, `false` if there was no dialog to handle it.
pub fn handle_dialog_key(&mut self, key: Key) -> bool {
// The 4 new dialogs (find/hotlist/tree/usermenu) return
// an `Outcome` enum from `handle_key`; we capture and
// apply the result here. The 4 ops dialogs (MkDir/Copy/
// Move/Delete) and the 4 metadata dialogs (Info/Permission/
// Owner/Link) have a self-contained `confirmed` flag on
// the dialog itself, so they go through the default
// `handle_key` -> bool path.
let mut consumed = false;
let mut tree_outcome: Option<tree::TreeOutcome> = None;
let mut find_outcome: Option<find::FindOutcome> = None;
let mut hot_outcome: Option<hotlist::HotlistOutcome> = None;
let mut menu_outcome: Option<usermenu::UserMenuOutcome> = None;
let mut skin_outcome: Option<skin_dialog::SkinDialogOutcome> = None;
let mut layout_outcome: Option<layout_dialog::LayoutResult> = None;
let mut panel_outcome: Option<panel_options::PanelOptionsResult> = None;
let mut config_outcome: Option<config_dialog::ConfigResult> = None;
let mut jobs_should_close = false;
let mut panelize_outcome: Option<external_panelize::ExternalPanelizeOutcome> = None;
let mut vfs_outcome: Option<vfs_list::VfsListOutcome> = None;
let mut screen_list_outcome: Option<screen_list::ScreenListOutcome> = None;
let mut edit_history_outcome: Option<edit_history::EditHistoryOutcome> = None;
let mut filtered_view_outcome: Option<filtered_view::FilteredViewOutcome> = None;
match &mut self.dialog {
Some(DialogState::Info(_d)) => {
// Info dialog consumes Enter and Esc (close).
if matches!(key, Key::ENTER | Key::ESCAPE) {
consumed = true;
}
}
Some(DialogState::Permission(d)) => {
consumed = d.handle_key(key);
}
Some(DialogState::Owner(d)) => {
consumed = d.handle_key(key);
}
Some(DialogState::Link(d)) => {
consumed = d.handle_key(key);
}
Some(DialogState::MkDir(d)) => {
consumed = d.handle_key(key);
}
Some(DialogState::Copy(d)) => {
consumed = d.handle_key(key);
}
Some(DialogState::Move(d)) => {
consumed = d.handle_key(key);
}
Some(DialogState::Delete(d)) => {
consumed = d.handle_key(key);
}
Some(DialogState::Find(d)) => {
find_outcome = Some(d.handle_key(key));
consumed = true;
}
Some(DialogState::Hotlist(d)) => {
hot_outcome = Some(d.handle_key(key));
consumed = true;
}
Some(DialogState::Tree(d)) => {
tree_outcome = Some(d.handle_key(key));
consumed = true;
}
Some(DialogState::UserMenu(d)) => {
menu_outcome = Some(d.handle_key(key));
consumed = true;
}
Some(DialogState::Help(d)) => {
if d.handle_key(key) {
self.dialog = None;
}
consumed = true;
}
Some(DialogState::Skin(d)) => {
skin_outcome = d.handle_key(key);
consumed = true;
}
Some(DialogState::Quit(d)) => {
consumed = d.handle_key(key);
}
Some(DialogState::SelectGroup(d)) => {
d.handle_key(key);
consumed = true;
}
Some(DialogState::UnselectGroup(d)) => {
d.handle_key(key);
consumed = true;
}
Some(DialogState::QuickCd(d)) => {
d.handle_key(key);
consumed = true;
}
Some(DialogState::Overwrite(d)) => {
d.handle_key(key);
consumed = true;
}
Some(DialogState::Layout(d)) => {
layout_outcome = Some(d.handle_key(key));
consumed = true;
}
Some(DialogState::PanelOptions(d)) => {
panel_outcome = Some(d.handle_key(key));
consumed = true;
}
Some(DialogState::Config(d)) => {
config_outcome = Some(d.handle_key(key));
consumed = true;
}
Some(DialogState::Jobs(d)) => {
jobs_should_close = d.handle_key(key);
consumed = true;
}
Some(DialogState::ExternalPanelize(d)) => {
panelize_outcome = Some(d.handle_key(key));
consumed = true;
}
Some(DialogState::VfsList(d)) => {
vfs_outcome = Some(d.handle_key(key));
consumed = true;
}
Some(DialogState::ScreenList(d)) => {
screen_list_outcome = Some(d.handle_key(key));
consumed = true;
}
Some(DialogState::EditHistory(d)) => {
edit_history_outcome = Some(d.handle_key(key));
consumed = true;
}
Some(DialogState::FilteredView(d)) => {
filtered_view_outcome = Some(d.handle_key(key));
consumed = true;
}
Some(DialogState::Compare(d)) => {
let o = d.handle_key(key);
if matches!(o, compare::CompareOutcome::Close) {
self.dialog = None;
}
consumed = true;
}
None => return false,
}
// Apply captured outcomes.
if let Some(o) = tree_outcome {
self.apply_tree_outcome(o);
}
if let Some(o) = find_outcome {
self.apply_find_outcome(o);
}
if let Some(o) = hot_outcome {
self.apply_hotlist_outcome(o);
}
if let Some(o) = menu_outcome {
self.apply_user_menu_outcome(o);
}
if let Some(o) = skin_outcome {
self.apply_skin_outcome_or_close(o);
}
if let Some(o) = layout_outcome {
self.apply_layout_outcome(o);
}
if let Some(o) = panel_outcome {
self.apply_panel_options_outcome(o);
}
if let Some(o) = config_outcome {
self.apply_config_outcome(o);
}
if jobs_should_close {
self.dialog = None;
}
if let Some(o) = panelize_outcome {
self.apply_external_panelize_outcome(o);
}
if let Some(o) = vfs_outcome {
self.apply_vfs_list_outcome(o);
}
if let Some(o) = screen_list_outcome {
self.apply_screen_list_outcome(o);
}
if let Some(o) = edit_history_outcome {
self.apply_edit_history_outcome(o);
}
if let Some(o) = filtered_view_outcome {
self.apply_filtered_view_outcome(o);
}
if let Some(d) = &self.dialog {
if d.is_finished() {
self.apply_finished_dialog();
}
}
consumed
}
}
use std::sync::Arc;
@@ -0,0 +1,101 @@
//! Formatting helpers shared across the file manager.
//!
//! These free functions format common primitives (sizes, timestamps,
//! permission triples) and produce the human-readable labels the file
//! manager uses in panel rows, dialog titles, and overlay screens.
use std::path::Path;
use crate::filemanager::panel::Panel;
/// Format a file size in bytes using B/K/M/G suffixes.
pub fn format_size(bytes: u64) -> String {
if bytes < 1024 {
format!("{bytes}B")
} else if bytes < 1024 * 1024 {
format!("{:.1}K", bytes as f64 / 1024.0)
} else if bytes < 1024 * 1024 * 1024 {
format!("{:.1}M", bytes as f64 / (1024.0 * 1024.0))
} else {
format!("{:.1}G", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
}
}
/// Format a unix timestamp (seconds since epoch) as a human-readable
/// string. Returns `"—"` for non-positive values.
pub fn info_format_time(secs: i64) -> String {
if secs <= 0 {
return "".to_string();
}
match chrono::DateTime::from_timestamp(secs, 0) {
Some(dt) => dt.format("%Y-%m-%d %H:%M:%S").to_string(),
None => format!("@{secs}"),
}
}
/// Short, ellipsised path used in panel titles.
///
/// Paths longer than 50 chars collapse to `".../<last 47 chars>"`.
pub fn path_short(path: &Path) -> String {
let s = path.display().to_string();
if s.len() > 50 {
// Show ".../<last 47 chars>".
let tail: String = s
.chars()
.rev()
.take(47)
.collect::<String>()
.chars()
.rev()
.collect();
format!(".../{tail}")
} else {
s
}
}
/// Render the panel meta line (mode, sort, item count, hidden/filter).
pub fn panel_meta_text(panel: &Panel) -> String {
let filter = panel
.filter()
.map(|f| format!(" Filter:{f}"))
.unwrap_or_default();
let hidden = if panel.show_hidden() { " Hidden" } else { "" };
let reverse = if panel.sort_reverse() { " desc" } else { "" };
format!(
" {} Sort:{}{} {} items{}{} ",
panel.listing_mode().label(),
panel.sort_field_name(),
reverse,
panel.entry_count(),
hidden,
filter
)
}
/// Render the panel footer line (file/dir counts, marks, current entry).
pub fn panel_footer_text(panel: &Panel) -> String {
let total = panel.entry_count();
let dirs = panel.dir_count();
let files = total.saturating_sub(dirs);
let marked = panel.marked_count();
let stats = if marked > 0 {
format!("{files}f {dirs}d {marked} marked")
} else {
format!("{files}f {dirs}d")
};
let current = panel
.entries()
.get(panel.cursor())
.map(|e| {
if e.name == ".." {
"../".to_string()
} else if e.is_dir() {
format!("{}/", e.name)
} else {
format!("{} {}", e.name, format_size(e.stat.size))
}
})
.unwrap_or_default();
format!(" {stats} {current} ")
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,669 @@
//! Rendering helpers for the file manager.
//!
//! Houses the [`FileManager::render`] entry point together with the
//! panel/body drawing routines, the listing-mode panel renderers
//! (Info / QuickView) and the small helpers (snapshot comparison,
//! symlink-relative paths, panel row formatting) they use.
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
use ratatui::Frame;
use crate::filemanager::panel::Panel;
use crate::filemanager::{
format_utils, Active, DialogState, FileManager, LayoutMode,
};
use crate::terminal::color::Theme;
use crate::terminal::mc_skin;
use crate::vfs::local::Entry;
use super::filehighlight;
use super::panel;
/// Render the file manager to a ratatui frame.
pub fn render(fm: &mut FileManager, frame: &mut Frame, area: Rect) {
fm.render(frame, area);
}
impl FileManager {
/// Render the file manager to a ratatui frame.
pub fn render(&mut self, frame: &mut Frame, area: Rect) {
if let Some(ed) = &mut self.editor {
ed.render(frame, area, &self.theme);
return;
}
if let Some(v) = &mut self.viewer {
v.render(frame, area, &self.theme);
return;
}
if let Some(x) = &mut self.exec {
x.render(frame, area, &self.theme);
return;
}
if !self.panels_visible {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(1),
Constraint::Length(1),
Constraint::Length(1),
])
.split(area);
let blank = Paragraph::new("").style(
Style::default()
.fg(self.theme.foreground)
.bg(self.theme.background),
);
frame.render_widget(blank, chunks[0]);
if self.cmdline.is_active() {
self.cmdline.render_inline(frame, chunks[1], &self.theme);
} else if self.status.has_message() {
self.status.render(frame, chunks[1], &self.theme);
} else {
self.cmdline.render_inline(frame, chunks[1], &self.theme);
}
self.render_buttonbar(frame, chunks[2]);
return;
}
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(3), // panels
Constraint::Length(1), // command/status line
Constraint::Length(1), // function-key bar
])
.split(area);
self.render_panels(frame, chunks[0]);
let (left_panel_area, right_panel_area) = self.panel_areas(chunks[0]);
if self.cmdline.is_active() {
self.cmdline.render_inline(frame, chunks[1], &self.theme);
} else if self.status.has_message() {
self.status.render(frame, chunks[1], &self.theme);
} else {
self.cmdline.render_inline(frame, chunks[1], &self.theme);
}
self.render_buttonbar(frame, chunks[2]);
if let Some(ref mb) = self.menubar {
mb.render(frame, area, &self.theme);
}
if let Some(d) = &mut self.dialog {
match d {
DialogState::Info(d) => d.render(frame, area, &self.theme),
DialogState::Permission(d) => d.render(frame, area, &self.theme),
DialogState::Owner(d) => d.render(frame, area, &self.theme),
DialogState::Link(d) => d.render(frame, area, &self.theme),
DialogState::MkDir(d) => d.render(frame, area, &self.theme),
DialogState::Copy(d) => d.render(frame, area, &self.theme),
DialogState::Move(d) => d.render(frame, area, &self.theme),
DialogState::Delete(d) => d.render(frame, area, &self.theme),
DialogState::Find(d) => d.render(frame, area, &self.theme),
DialogState::Hotlist(d) => d.render(frame, area, &self.theme),
DialogState::Tree(d) => d.render(frame, area, &self.theme),
DialogState::UserMenu(d) => d.render(frame, area, &self.theme),
DialogState::Help(d) => d.render(frame, area),
DialogState::Skin(d) => d.render(frame, area, &self.theme),
DialogState::Quit(d) => d.render(frame, area, &self.theme),
DialogState::SelectGroup(d) => d.render(frame, area, &self.theme),
DialogState::UnselectGroup(d) => d.render(frame, area, &self.theme),
DialogState::QuickCd(d) => d.render(
frame,
match self.active {
Active::Left => left_panel_area,
Active::Right => right_panel_area,
},
&self.theme,
),
DialogState::Overwrite(d) => d.render(frame, area, &self.theme),
DialogState::Layout(d) => d.render(frame, area, &self.theme),
DialogState::PanelOptions(d) => d.render(frame, area, &self.theme),
DialogState::Config(d) => d.render(frame, area, &self.theme),
DialogState::Jobs(d) => d.render(frame, area, &self.theme),
DialogState::ExternalPanelize(d) => d.render(frame, area, &self.theme),
DialogState::VfsList(d) => d.render(frame, area, &self.theme),
DialogState::ScreenList(d) => d.render(frame, area, &self.theme),
DialogState::EditHistory(d) => d.render(frame, area, &self.theme),
DialogState::FilteredView(d) => d.render(frame, area, &self.theme),
DialogState::Compare(d) => d.render(frame, area, &self.theme),
}
}
}
/// Compute the two panel rectangles based on the active layout
/// mode and the configured split ratio.
pub fn panel_areas(&self, area: Rect) -> (Rect, Rect) {
let left_pct = if self.runtime.equal_split.unwrap_or(true) {
50
} else {
self.split_ratio.clamp(1, 99)
};
let right_pct = 100u16.saturating_sub(left_pct);
match self.layout {
LayoutMode::Horizontal => {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(left_pct),
Constraint::Percentage(right_pct),
])
.split(area);
(chunks[0], chunks[1])
}
LayoutMode::Vertical => {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(left_pct),
Constraint::Percentage(right_pct),
])
.split(area);
(chunks[0], chunks[1])
}
}
}
/// Render both panels into the given area.
pub fn render_panels(&mut self, frame: &mut Frame, area: Rect) {
let (left_a, right_a) = self.panel_areas(area);
// Adjust each panel's scroll so the cursor is visible.
let left_rows = panel_body_rows(left_a);
let right_rows = panel_body_rows(right_a);
self.left.ensure_cursor_visible(left_rows);
self.right.ensure_cursor_visible(right_rows);
self.render_panel(frame, left_a, &self.left, self.active == Active::Left);
self.render_panel(frame, right_a, &self.right, self.active == Active::Right);
}
/// Render the function-key bar (bottom row): 1 Help 2 Menu 3 View ...
pub fn render_buttonbar(&self, frame: &mut Frame, area: Rect) {
let hotkey_pair = mc_skin::color_pair(self.theme.name, "buttonbar", "hotkey");
let button_pair = mc_skin::color_pair(self.theme.name, "buttonbar", "button");
let bar_bg = button_pair.map(|p| p.bg).unwrap_or(self.theme.buttonbar_bg);
let bar_fg = button_pair.map(|p| p.fg).unwrap_or(self.theme.buttonbar_fg);
let hot_fg = hotkey_pair.map(|p| p.fg).unwrap_or(self.theme.buttonbar_bg);
let hot_bg = hotkey_pair.map(|p| p.bg).unwrap_or(self.theme.buttonbar_fg);
let labels = [
("1", "Help"),
("2", "Menu"),
("3", "View"),
("4", "Edit"),
("5", "Copy"),
("6", "Move"),
("7", "MkDir"),
("8", "Del"),
("9", "Menu"),
("10", "Quit"),
];
frame.render_widget(
Paragraph::new("")
.style(Style::default().bg(bar_bg).fg(bar_fg)),
area,
);
let mut spans: Vec<Span> = Vec::new();
for (num, label) in &labels {
spans.push(Span::styled(
*num,
Style::default()
.fg(hot_fg)
.bg(hot_bg)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(
format!(" {} ", label),
Style::default()
.fg(bar_fg)
.bg(bar_bg),
));
}
frame.render_widget(
Paragraph::new(Line::from(spans)),
area,
);
}
/// Render a single panel into `area`. `active` controls the
/// border highlight and the cursor marker.
pub fn render_panel(&self, frame: &mut Frame, area: Rect, panel: &Panel, active: bool) {
let core_default = mc_skin::color_pair(self.theme.name, "core", "_default_");
let core_header = mc_skin::color_pair(self.theme.name, "core", "header");
let core_disabled = mc_skin::color_pair(self.theme.name, "core", "disabled");
let panel_bg = core_default.map(|p| p.bg).unwrap_or(self.theme.background);
let panel_fg = core_default.map(|p| p.fg).unwrap_or(self.theme.foreground);
let header_fg = core_header.map(|p| p.fg).unwrap_or(self.theme.title_fg);
let header_bg = core_header
.and_then(|p| if p.bg == ratatui::style::Color::Reset { None } else { Some(p.bg) })
.unwrap_or(self.theme.title_bg);
let disabled_fg = core_disabled.map(|p| p.fg).unwrap_or(self.theme.hidden);
let title = format!(" {} ", format_utils::path_short(panel.path()));
let block = Block::default()
.borders(Borders::ALL)
.border_style(if active {
Style::default().fg(header_fg).bg(panel_bg)
} else {
Style::default().fg(self.theme.border).bg(panel_bg)
})
.style(Style::default().bg(panel_bg).fg(panel_fg))
.title(Span::styled(
title,
Style::default()
.fg(header_fg)
.bg(header_bg)
.add_modifier(Modifier::BOLD),
));
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.width == 0 || inner.height == 0 {
return;
}
let show_meta = inner.height >= 3;
let show_footer = inner.height >= 2;
let body_y = inner.y + u16::from(show_meta);
let footer_h = u16::from(show_footer);
let body_height = inner
.height
.saturating_sub(u16::from(show_meta))
.saturating_sub(footer_h)
.max(1);
let height = body_height as usize;
let top = panel.top();
let cursor = panel.cursor();
let mode = panel.listing_mode();
let line_width = inner.width.saturating_sub(1) as usize;
if show_meta {
frame.render_widget(
Paragraph::new(Span::styled(
format_utils::panel_meta_text(panel),
Style::default().fg(disabled_fg).bg(panel_bg),
)),
Rect::new(inner.x, inner.y, inner.width, 1),
);
}
let list_area = Rect::new(inner.x, body_y, inner.width, body_height);
match mode {
panel::ListingMode::Info => {
render_info_body(frame, list_area, panel, panel_fg, panel_bg);
}
panel::ListingMode::QuickView => {
render_quickview_body(frame, list_area, panel, panel_fg, panel_bg);
}
_ => {
let items: Vec<ListItem> = (0..height)
.map(|row| {
let idx = top + row;
if let Some(entry) = panel.entries().get(idx) {
let is_marked = panel
.marked_names()
.iter()
.any(|n| n == &entry.name);
let style =
entry_style(entry, active, idx == cursor, is_marked, &self.theme);
let prefix = if is_marked { "*" } else { " " };
let display =
format!("{prefix}{}", format_line_mode(entry, mode, line_width));
ListItem::new(Span::styled(display, style))
} else {
ListItem::new(Span::raw(""))
}
})
.collect();
let list = List::new(items)
.style(Style::default().fg(panel_fg).bg(panel_bg));
frame.render_widget(list, list_area);
}
}
if show_footer {
let footer_y = inner.y + inner.height.saturating_sub(1);
frame.render_widget(
Paragraph::new(Span::styled(
format_utils::panel_footer_text(panel),
Style::default().fg(disabled_fg).bg(panel_bg),
)),
Rect::new(inner.x, footer_y, inner.width, 1),
);
}
// Cursor marker: a `>` on the leftmost column for the active panel.
if active {
let cursor_y = body_y + (cursor.saturating_sub(top)) as u16;
if cursor_y < body_y + body_height {
let marker = Paragraph::new(Span::styled(
">",
Style::default()
.fg(self.theme.cursor_fg)
.bg(self.theme.cursor_bg)
.add_modifier(Modifier::BOLD),
));
frame.render_widget(marker, Rect::new(inner.x, cursor_y, 1, 1));
}
}
}
}
/// Compute the [`Style`] for a panel entry given selection state.
pub fn entry_style(
e: &Entry,
active: bool,
cursor: bool,
marked: bool,
theme: &Theme,
) -> Style {
let core_default = mc_skin::color_pair(theme.name, "core", "_default_");
let core_selected = mc_skin::color_pair(theme.name, "core", "selected");
let core_reverse = mc_skin::color_pair(theme.name, "core", "reverse");
let core_marked = mc_skin::color_pair(theme.name, "core", "marked");
let core_markselect = mc_skin::color_pair(theme.name, "core", "markselect");
let base = if e.is_dir() {
Style::default().fg(theme.directory)
} else if e.is_symlink() {
Style::default().fg(theme.symlink)
} else {
let is_executable = e.stat.permissions.owner_exec
|| e.stat.permissions.group_exec
|| e.stat.permissions.other_exec;
let ft = filehighlight::categorize(&e.name, is_executable);
let color = filehighlight::file_type_color(ft, theme)
.unwrap_or_else(|| core_default.map(|p| p.fg).unwrap_or(theme.foreground));
Style::default().fg(color)
};
if cursor && marked {
let pair = core_markselect.unwrap_or_else(|| {
if active {
core_selected.unwrap_or(mc_skin::ColorPair { fg: theme.cursor_fg, bg: theme.cursor_bg })
} else {
core_marked.unwrap_or(mc_skin::ColorPair { fg: theme.marked_fg, bg: theme.marked_bg })
}
});
base.bg(pair.bg).fg(pair.fg).add_modifier(Modifier::BOLD)
} else if cursor && active {
let pair = core_selected.unwrap_or(mc_skin::ColorPair { fg: theme.cursor_fg, bg: theme.cursor_bg });
base.bg(pair.bg).fg(pair.fg).add_modifier(Modifier::BOLD)
} else if cursor {
let pair = core_reverse.unwrap_or(mc_skin::ColorPair { fg: theme.cursor_fg, bg: theme.cursor_bg });
base.bg(pair.bg).fg(pair.fg)
} else if marked {
let pair = core_marked.unwrap_or(mc_skin::ColorPair { fg: theme.marked_fg, bg: theme.marked_bg });
base.bg(pair.bg).fg(pair.fg)
} else {
base
}
}
/// Format a single panel row according to the active listing mode.
pub fn format_line_mode(e: &Entry, mode: panel::ListingMode, width: usize) -> String {
if e.name == ".." {
return "../".to_string();
}
match mode {
panel::ListingMode::Brief => {
let suffix = if e.is_dir() { "/" } else { "" };
format!("{}{}", e.name, suffix)
}
panel::ListingMode::Full
| panel::ListingMode::Long
| panel::ListingMode::Info
| panel::ListingMode::QuickView => {
// Info and QuickView are handled by dedicated renderers
// (render_info_panel / render_quickview_panel) and never
// reach this function. Fall back to Full formatting if a
// caller ever does pass them through.
let effective_mode = if mode == panel::ListingMode::Long {
panel::ListingMode::Long
} else {
panel::ListingMode::Full
};
let suffix = if e.is_dir() { "/" } else { "" };
let size_str = if e.is_dir() {
"<DIR>".to_string()
} else {
format_utils::format_size(e.stat.size)
};
let name_part = format!("{}{}", e.name, suffix);
let room = width.max(size_str.len() + 2);
let pad = room.saturating_sub(name_part.chars().count() + size_str.len() + 3);
if effective_mode == panel::ListingMode::Long {
let perm = format!("{:03o}", e.stat.permissions.to_mode());
let mut out = format!("{name_part} {size_str:>7} {perm}");
if out.chars().count() > width {
out = out.chars().take(width).collect();
}
out
} else {
let mut out = format!("{name_part}{}{size_str:>7}", " ".repeat(pad + 1));
if out.chars().count() > width {
out = out.chars().take(width).collect();
}
out
}
}
}
}
/// Build a name→size index from panel snapshots.
pub fn build_size_index_from_snap(
snaps: &[(String, u64, bool)],
) -> std::collections::HashMap<String, u64> {
snaps.iter().map(|(n, s, _)| (n.clone(), *s)).collect()
}
/// Mark entries whose size differs from the index. Returns count marked.
pub fn mark_differing_from_snap(
panel: &mut Panel,
index: &std::collections::HashMap<String, u64>,
) -> usize {
panel.unmark_all();
let mut count = 0;
let entries: Vec<(String, u64)> = panel
.file_snapshots()
.into_iter()
.map(|(n, s, _)| (n, s))
.collect();
for (i, (name, size)) in entries.iter().enumerate() {
if let Some(&other_size) = index.get(name) {
if other_size != *size {
panel.mark_at(i);
count += 1;
}
}
}
count
}
/// Compute a relative path from `link_dir` to `target`.
pub fn relpath_from(target: &std::path::Path, link_dir: &std::path::Path) -> std::path::PathBuf {
let target_components: Vec<_> = target.components().collect();
let link_components: Vec<_> = link_dir.components().collect();
let common = target_components
.iter()
.zip(link_components.iter())
.take_while(|(a, b)| a == b)
.count();
let up = link_components.len().saturating_sub(common);
let mut result = std::path::PathBuf::new();
for _ in 0..up {
result.push("..");
}
for comp in &target_components[common..] {
result.push(comp.as_os_str());
}
if result.as_os_str().is_empty() {
result.push(".");
}
result
}
/// Visible row count of a panel's body area (excluding border / meta / footer).
pub fn panel_body_rows(area: Rect) -> usize {
let inner_height = area.height.saturating_sub(2);
let meta = usize::from(inner_height >= 3);
let footer = usize::from(inner_height >= 2);
inner_height.saturating_sub((meta + footer) as u16).max(1) as usize
}
/// Render the Info-mode body of the panel.
pub fn render_info_body(
frame: &mut Frame,
area: Rect,
panel: &Panel,
fg: ratatui::style::Color,
bg: ratatui::style::Color,
) {
let lines = info_mode_lines(panel);
let width = area.width as usize;
let height = area.height as usize;
let max_rows = height.max(1);
let para_lines: Vec<Line> = lines
.into_iter()
.take(max_rows)
.map(|s| {
let truncated: String = if s.chars().count() > width {
s.chars().take(width).collect()
} else {
s
};
Line::from(Span::styled(truncated, Style::default().fg(fg).bg(bg)))
})
.collect();
let p = Paragraph::new(para_lines).style(Style::default().fg(fg).bg(bg));
frame.render_widget(p, area);
}
/// Render the QuickView-mode body of the panel.
pub fn render_quickview_body(
frame: &mut Frame,
area: Rect,
panel: &Panel,
fg: ratatui::style::Color,
bg: ratatui::style::Color,
) {
let lines = quickview_lines(panel);
let width = area.width as usize;
let height = area.height as usize;
let max_rows = height.max(1);
let para_lines: Vec<Line> = lines
.into_iter()
.take(max_rows)
.map(|s| {
let truncated: String = if s.chars().count() > width {
s.chars().take(width).collect()
} else {
s
};
Line::from(Span::styled(truncated, Style::default().fg(fg).bg(bg)))
})
.collect();
let p = Paragraph::new(para_lines).style(Style::default().fg(fg).bg(bg));
frame.render_widget(p, area);
}
/// Build the lines of text displayed by the Info listing mode.
pub fn info_mode_lines(panel: &Panel) -> Vec<String> {
let mut lines: Vec<String> = Vec::new();
let dir_path = panel.path();
let fs_line = match std::fs::metadata(dir_path) {
Ok(_) => match dir_path.to_str() {
Some(s) => format!("Filesystem: {s}"),
None => format!("Filesystem: {}", dir_path.display()),
},
Err(e) => format!("Filesystem: <stat error: {e}>"),
};
lines.push(fs_line);
let entries = panel.entry_count();
let dirs = panel.dir_count();
let files = entries.saturating_sub(dirs);
lines.push(format!("Items: {entries} ({files} files, {dirs} dirs)"));
if let Some(entry) = panel.entries().get(panel.cursor()) {
if entry.name != ".." {
lines.push(String::new());
lines.push(format!("File: {}", entry.name));
let size_str = if entry.is_dir() {
"<DIR>".to_string()
} else {
format_utils::format_size(entry.stat.size)
};
lines.push(format!("Size: {size_str}"));
lines.push(format!("Mode: {:o}", entry.stat.permissions.to_mode() & 0o7777));
lines.push(format!(
"Owner: {} Group: {}",
entry.stat.uid, entry.stat.gid
));
lines.push(format!("Links: {}", entry.stat.nlinks));
lines.push(format!(
"Modified: {}",
format_utils::info_format_time(entry.stat.mtime)
));
lines.push(format!(
"Accessed: {}",
format_utils::info_format_time(entry.stat.atime)
));
lines.push(format!(
"Changed: {}",
format_utils::info_format_time(entry.stat.ctime)
));
}
}
lines
}
/// Read up to 30 lines from the cursor file for the QuickView mode.
pub fn quickview_lines(panel: &Panel) -> Vec<String> {
const MAX_LINES: usize = 30;
let Some(entry) = panel.entries().get(panel.cursor()) else {
return vec!["(no entry)".to_string()];
};
if entry.name == ".." {
return vec!["(parent directory)".to_string()];
}
if entry.is_dir() {
return vec!["Directory, use Enter to browse".to_string()];
}
let target = panel.path().join(&entry.name);
let meta = match std::fs::metadata(&target) {
Ok(m) => m,
Err(e) => return vec![format!("(cannot stat: {e})")],
};
if !meta.is_file() {
return vec!["(not a regular file)".to_string()];
}
let file = match std::fs::File::open(&target) {
Ok(f) => f,
Err(e) => return vec![format!("(cannot open: {e})")],
};
let reader = std::io::BufReader::new(file);
use std::io::BufRead;
let mut lines: Vec<String> = Vec::new();
let mut saw_binary = false;
for line_res in reader.lines().take(MAX_LINES) {
match line_res {
Ok(l) => {
if l.bytes().any(|b| b == 0) {
saw_binary = true;
break;
}
lines.push(l);
}
Err(_) => {
saw_binary = true;
break;
}
}
}
if saw_binary {
return vec!["Binary file, cannot display".to_string()];
}
if lines.is_empty() {
return vec!["(empty file)".to_string()];
}
lines
}