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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user