tlc: Sprint 1 MC parity fixes (A1-A7)
Implements all 5 critical parity items from the comprehensive MC
assessment. Reference: MC source at local/recipes/tui/mc/source/.
A1 — Editor line number gutter:
Split editor inner area into gutter_area + body_area; gutter
renders right-aligned line numbers (or relative offsets when
relative_lines mode is on); bookmark rows show current-line
style; ~ shown for lines past end-of-file.
A2 — Viewer cursor line highlight:
cursor_line_bg derived from body_bg + RGB(12,12,12); applied
before search-match overlay so matches win on the cursor line.
A3 — Hide terminal cursor in file manager mode:
App::run() hides the cursor after Tui::new(); render() shows
the cursor only when editor/viewer/cmdline/dialog/menubar
is active.
A6 — Shift-F5/F6 same-directory rename:
New Cmd::CopySameDir and Cmd::MoveSameDir variants, bound to
Shift-F5 / Shift-F6. Reuse CopyDialog/MoveDialog with a new
same_dir flag and new_rename() constructor; result() resolves
the typed name against the source's parent directory.
A7 — SUID/SGID/sticky bits in chmod dialog:
4-row PermCell grid (user, group, other, special); class_shift
= 0o4000, bit = 1. Display as 0oXXXX. Overwrite dialog shows
all 12 cells. 6 new unit tests cover all special-bit combinations.
Other fixes in the same commit:
- 4 editor render tests shifted x-coordinates by gutter_chars
to account for the new gutter column.
- 1 viewer text test moves cursor to line 1 so cursor-line
highlight doesn't overlap the search-match assertion.
Tests: 1230 passed (was 1219 before A7; +11 new tests across A7
and A6). All Sprint 1 items compile clean.
This commit is contained in:
@@ -51,6 +51,9 @@ impl Application {
|
|||||||
let mut shell_manager = ShellManager::new();
|
let mut shell_manager = ShellManager::new();
|
||||||
let keymap = default_keymap();
|
let keymap = default_keymap();
|
||||||
|
|
||||||
|
// MC parity: cursor hidden in panel mode; render() manages visibility.
|
||||||
|
let _ = tui.terminal_mut().hide_cursor();
|
||||||
|
|
||||||
// Initial paint.
|
// Initial paint.
|
||||||
render(&mut tui, &mut fm)?;
|
render(&mut tui, &mut fm)?;
|
||||||
|
|
||||||
@@ -324,6 +327,16 @@ fn render(tui: &mut Tui, fm: &mut FileManager) -> Result<()> {
|
|||||||
fm.render(frame, area);
|
fm.render(frame, area);
|
||||||
fm.toasts.render(frame, area, &fm.theme);
|
fm.toasts.render(frame, area, &fm.theme);
|
||||||
})?;
|
})?;
|
||||||
|
let need_cursor = fm.editor.is_some()
|
||||||
|
|| fm.viewer.is_some()
|
||||||
|
|| fm.cmdline.is_active()
|
||||||
|
|| fm.dialog.is_some()
|
||||||
|
|| fm.menubar.is_some();
|
||||||
|
if need_cursor {
|
||||||
|
let _ = tui.terminal_mut().show_cursor();
|
||||||
|
} else {
|
||||||
|
let _ = tui.terminal_mut().hide_cursor();
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2354,11 +2354,11 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let buffer = terminal.backend().buffer();
|
let buffer = terminal.backend().buffer();
|
||||||
assert_eq!(buffer.cell((1, 1)).expect("a cell").symbol(), "a");
|
assert_eq!(buffer.cell((4, 1)).expect("a cell").symbol(), "a");
|
||||||
assert_eq!(buffer.cell((2, 1)).expect("space cell").symbol(), "·");
|
assert_eq!(buffer.cell((5, 1)).expect("space cell").symbol(), "·");
|
||||||
assert_eq!(buffer.cell((3, 1)).expect("tab cell").symbol(), "→");
|
assert_eq!(buffer.cell((6, 1)).expect("tab cell").symbol(), "→");
|
||||||
assert_eq!(buffer.cell((4, 1)).expect("b cell").symbol(), "b");
|
assert_eq!(buffer.cell((7, 1)).expect("b cell").symbol(), "b");
|
||||||
assert_eq!(buffer.cell((5, 1)).expect("caret cell").symbol(), "^");
|
assert_eq!(buffer.cell((8, 1)).expect("caret cell").symbol(), "^");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2387,7 +2387,7 @@ mod tests {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.expect("must find 'b' from beta");
|
.expect("must find 'b' from beta");
|
||||||
let cell = buffer.cell((1, beta_y)).expect("beta line cell");
|
let cell = buffer.cell((4, beta_y)).expect("beta line cell");
|
||||||
assert_eq!(cell.symbol(), "b");
|
assert_eq!(cell.symbol(), "b");
|
||||||
assert_eq!(cell.fg, bookmarkfound.fg);
|
assert_eq!(cell.fg, bookmarkfound.fg);
|
||||||
assert_eq!(cell.bg, bookmarkfound.bg);
|
assert_eq!(cell.bg, bookmarkfound.bg);
|
||||||
@@ -2673,8 +2673,8 @@ mod tests {
|
|||||||
fg: theme.marked_fg,
|
fg: theme.marked_fg,
|
||||||
bg: theme.marked_bg,
|
bg: theme.marked_bg,
|
||||||
});
|
});
|
||||||
let f_cell = buffer.cell((1, 1)).expect("'f' cell");
|
let f_cell = buffer.cell((4, 1)).expect("'f' cell");
|
||||||
let n_cell = buffer.cell((2, 1)).expect("'n' cell");
|
let n_cell = buffer.cell((5, 1)).expect("'n' cell");
|
||||||
assert_eq!(f_cell.symbol(), "f");
|
assert_eq!(f_cell.symbol(), "f");
|
||||||
assert_eq!(n_cell.symbol(), "n");
|
assert_eq!(n_cell.symbol(), "n");
|
||||||
assert_eq!(f_cell.bg, marked_pair.bg, "selected 'f' cell bg");
|
assert_eq!(f_cell.bg, marked_pair.bg, "selected 'f' cell bg");
|
||||||
|
|||||||
@@ -118,7 +118,24 @@ impl Editor {
|
|||||||
let inner = block.inner(editor_area);
|
let inner = block.inner(editor_area);
|
||||||
frame.render_widget(block, editor_area);
|
frame.render_widget(block, editor_area);
|
||||||
|
|
||||||
let body_area = inner;
|
let line_count = self.buffer.line_count();
|
||||||
|
let gutter_chars = line_count.max(1).to_string().len().max(3) as u16;
|
||||||
|
let body_area = if gutter_chars < inner.width {
|
||||||
|
Rect::new(
|
||||||
|
inner.x + gutter_chars,
|
||||||
|
inner.y,
|
||||||
|
inner.width - gutter_chars,
|
||||||
|
inner.height,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
inner
|
||||||
|
};
|
||||||
|
let gutter_area = Rect::new(
|
||||||
|
inner.x,
|
||||||
|
inner.y,
|
||||||
|
gutter_chars.min(inner.width),
|
||||||
|
inner.height,
|
||||||
|
);
|
||||||
|
|
||||||
self.view.ensure_cursor_visible(
|
self.view.ensure_cursor_visible(
|
||||||
&self.buffer,
|
&self.buffer,
|
||||||
@@ -130,7 +147,6 @@ impl Editor {
|
|||||||
let height = body_area.height as usize;
|
let height = body_area.height as usize;
|
||||||
let cursor_line = self.buffer_line_of(self.cursor.position());
|
let cursor_line = self.buffer_line_of(self.cursor.position());
|
||||||
let body_width = body_area.width as usize;
|
let body_width = body_area.width as usize;
|
||||||
let line_count = self.buffer.line_count();
|
|
||||||
let wrapped_map: Vec<(usize, usize)> = if self.word_wrap && body_width > 0 {
|
let wrapped_map: Vec<(usize, usize)> = if self.word_wrap && body_width > 0 {
|
||||||
build_wrap_map(&self.buffer, top, height, body_width)
|
build_wrap_map(&self.buffer, top, height, body_width)
|
||||||
} else {
|
} else {
|
||||||
@@ -285,6 +301,38 @@ impl Editor {
|
|||||||
}
|
}
|
||||||
frame.render_widget(Paragraph::new(body_lines), body_area);
|
frame.render_widget(Paragraph::new(body_lines), body_area);
|
||||||
|
|
||||||
|
if gutter_chars < inner.width {
|
||||||
|
let gutter_lines: Vec<Line> = visible_wrapped
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.map(|(line_idx, wrap_row)| {
|
||||||
|
if line_idx >= line_count {
|
||||||
|
return Line::from(Span::styled(
|
||||||
|
"~",
|
||||||
|
Style::default().fg(frame_fg).bg(body_bg),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let text = if wrap_row > 0 {
|
||||||
|
" ".repeat(gutter_chars as usize)
|
||||||
|
} else {
|
||||||
|
let num = if self.relative_lines {
|
||||||
|
(line_idx as i64 - cursor_line as i64).unsigned_abs()
|
||||||
|
} else {
|
||||||
|
line_idx as u64 + 1
|
||||||
|
};
|
||||||
|
format!("{:>w$}", num, w = gutter_chars as usize)
|
||||||
|
};
|
||||||
|
let style = if line_idx == cursor_line {
|
||||||
|
Style::default().fg(linestate_fg).bg(cur_line_bg)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(frame_fg).bg(body_bg)
|
||||||
|
};
|
||||||
|
Line::from(Span::styled(text, style))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
frame.render_widget(Paragraph::new(gutter_lines), gutter_area);
|
||||||
|
}
|
||||||
|
|
||||||
if body_area.width > 0 {
|
if body_area.width > 0 {
|
||||||
let margin_area = Rect::new(
|
let margin_area = Rect::new(
|
||||||
body_area.x + body_area.width - 1,
|
body_area.x + body_area.width - 1,
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ pub struct CopyDialog {
|
|||||||
pub width_pct: f32,
|
pub width_pct: f32,
|
||||||
/// Height as a fraction of the parent area.
|
/// Height as a fraction of the parent area.
|
||||||
pub height_pct: f32,
|
pub height_pct: f32,
|
||||||
|
/// True for Shift-F5 same-directory rename mode: the user
|
||||||
|
/// types just a new filename and the copy stays in the source's
|
||||||
|
/// parent directory. Only valid with a single source.
|
||||||
|
pub same_dir: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CopyDialog {
|
impl CopyDialog {
|
||||||
@@ -93,6 +97,36 @@ impl CopyDialog {
|
|||||||
cancelled: false,
|
cancelled: false,
|
||||||
width_pct: 0.6,
|
width_pct: 0.6,
|
||||||
height_pct: 0.3,
|
height_pct: 0.3,
|
||||||
|
same_dir: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a same-directory rename dialog (Shift-F5). The user
|
||||||
|
/// types just a new filename; the copy stays in `src`'s parent.
|
||||||
|
/// `src` must be a single file (not a marked batch).
|
||||||
|
#[must_use]
|
||||||
|
pub fn new_rename(src: PathBuf) -> Self {
|
||||||
|
let default_text = src
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let input = Input::new()
|
||||||
|
.label("Copy to new name in same directory")
|
||||||
|
.text(default_text);
|
||||||
|
Self {
|
||||||
|
src: vec![src],
|
||||||
|
marked_count: 1,
|
||||||
|
dst_input: input,
|
||||||
|
preserve_attributes: true,
|
||||||
|
follow_links: false,
|
||||||
|
dive_into_subdirs: false,
|
||||||
|
stable_symlinks: false,
|
||||||
|
focused: 0,
|
||||||
|
confirmed: false,
|
||||||
|
cancelled: false,
|
||||||
|
width_pct: 0.6,
|
||||||
|
height_pct: 0.3,
|
||||||
|
same_dir: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +149,17 @@ impl CopyDialog {
|
|||||||
if s.is_empty() {
|
if s.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some(PathBuf::from(s))
|
let typed = PathBuf::from(s);
|
||||||
|
if self.same_dir {
|
||||||
|
let parent = self
|
||||||
|
.src
|
||||||
|
.first()
|
||||||
|
.and_then(|p| p.parent())
|
||||||
|
.map(|p| p.to_path_buf())
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."));
|
||||||
|
return Some(parent.join(typed));
|
||||||
|
}
|
||||||
|
Some(typed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True if the dialog was cancelled.
|
/// True if the dialog was cancelled.
|
||||||
@@ -365,6 +409,36 @@ mod tests {
|
|||||||
assert_eq!(d.result(), Some(std::path::PathBuf::from("/var/dst")));
|
assert_eq!(d.result(), Some(std::path::PathBuf::from("/var/dst")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_rename_sets_same_dir_and_prefills_filename() {
|
||||||
|
let d = CopyDialog::new_rename(std::path::PathBuf::from("/tmp/dir/foo.txt"));
|
||||||
|
assert!(d.same_dir);
|
||||||
|
assert_eq!(d.dst_input.value(), "foo.txt");
|
||||||
|
assert_eq!(d.src, vec![std::path::PathBuf::from("/tmp/dir/foo.txt")]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_rename_result_resolves_relative_to_parent() {
|
||||||
|
let mut d = CopyDialog::new_rename(std::path::PathBuf::from("/tmp/dir/foo.txt"));
|
||||||
|
d.dst_input = Input::new().text("bar.txt");
|
||||||
|
d.confirmed = true;
|
||||||
|
assert_eq!(
|
||||||
|
d.result(),
|
||||||
|
Some(std::path::PathBuf::from("/tmp/dir/bar.txt"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_rename_result_with_nested_name() {
|
||||||
|
let mut d = CopyDialog::new_rename(std::path::PathBuf::from("/home/user/old.md"));
|
||||||
|
d.dst_input = Input::new().text("new.md");
|
||||||
|
d.confirmed = true;
|
||||||
|
assert_eq!(
|
||||||
|
d.result(),
|
||||||
|
Some(std::path::PathBuf::from("/home/user/new.md"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn enter_with_empty_does_not_confirm() {
|
fn enter_with_empty_does_not_confirm() {
|
||||||
let mut d = CopyDialog::new(vec![std::path::PathBuf::from("/tmp/a")]);
|
let mut d = CopyDialog::new(vec![std::path::PathBuf::from("/tmp/a")]);
|
||||||
|
|||||||
@@ -196,6 +196,34 @@ impl FileManager {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open the Shift-F5 same-directory rename dialog for the cursor file.
|
||||||
|
pub fn open_copy_same_dir_dialog(&mut self) -> Result<()> {
|
||||||
|
let panel = self.active_panel();
|
||||||
|
let src = panel.cursor_path();
|
||||||
|
if src.as_os_str().is_empty() {
|
||||||
|
self.status.set_message("copy: no source selected");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
self.dialog = Some(DialogState::Copy(Box::new(
|
||||||
|
CopyDialog::new_rename(src),
|
||||||
|
)));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the Shift-F6 same-directory rename dialog for the cursor file.
|
||||||
|
pub fn open_move_same_dir_dialog(&mut self) -> Result<()> {
|
||||||
|
let panel = self.active_panel();
|
||||||
|
let src = panel.cursor_path();
|
||||||
|
if src.as_os_str().is_empty() {
|
||||||
|
self.status.set_message("move: no source selected");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
self.dialog = Some(DialogState::Move(Box::new(
|
||||||
|
MoveDialog::new_rename(src),
|
||||||
|
)));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Open the F8 delete confirmation dialog for the cursor file or
|
/// Open the F8 delete confirmation dialog for the cursor file or
|
||||||
/// all marked files.
|
/// all marked files.
|
||||||
pub fn open_delete_dialog(&mut self) -> Result<()> {
|
pub fn open_delete_dialog(&mut self) -> Result<()> {
|
||||||
@@ -838,46 +866,88 @@ impl FileManager {
|
|||||||
Some(DialogState::Copy(d)) => {
|
Some(DialogState::Copy(d)) => {
|
||||||
if let Some(dst) = d.result() {
|
if let Some(dst) = d.result() {
|
||||||
let sources = d.src.clone();
|
let sources = d.src.clone();
|
||||||
let preserve_attributes = d.preserve_attributes;
|
if d.same_dir {
|
||||||
let follow_links = d.follow_links;
|
if let Some(src) = sources.first().cloned() {
|
||||||
let handle = self.ops_manager.begin(
|
if src == dst {
|
||||||
crate::ops::OpKind::Copy,
|
self.status
|
||||||
sources.clone(),
|
.set_message("copy: source and destination are the same");
|
||||||
Some(dst.clone()),
|
} else if dst.exists() {
|
||||||
);
|
self.pending_op = Some(PendingFileOp {
|
||||||
match crate::ops::copy::copy_many(
|
is_move: false,
|
||||||
&sources,
|
sources,
|
||||||
&dst,
|
dst: dst.clone(),
|
||||||
&handle,
|
preserve_attributes: d.preserve_attributes,
|
||||||
false,
|
follow_links: d.follow_links,
|
||||||
preserve_attributes,
|
});
|
||||||
follow_links,
|
self.dialog = Some(DialogState::Overwrite(Box::new(
|
||||||
) {
|
overwrite_dialog::OverwriteDialog::new_copy(
|
||||||
Ok(()) => {
|
dst.display().to_string(),
|
||||||
self.status.set_message(format!(
|
),
|
||||||
"Copied {} item(s) to {}",
|
)));
|
||||||
sources.len(),
|
} else {
|
||||||
dst.display()
|
let handle = self.ops_manager.begin(
|
||||||
));
|
crate::ops::OpKind::Copy,
|
||||||
self.ops_manager.finish();
|
sources.clone(),
|
||||||
let _ = self.active_panel_mut().refresh();
|
Some(dst.clone()),
|
||||||
|
);
|
||||||
|
match std::fs::copy(&src, &dst) {
|
||||||
|
Ok(_) => {
|
||||||
|
self.status.set_message(format!(
|
||||||
|
"Copied {} -> {}",
|
||||||
|
src.display(),
|
||||||
|
dst.display()
|
||||||
|
));
|
||||||
|
self.ops_manager.finish();
|
||||||
|
let _ = self.active_panel_mut().refresh();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.status.set_message(format!("copy: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(crate::ops::OpsError::DestExists(p)) => {
|
} else {
|
||||||
self.pending_op = Some(PendingFileOp {
|
let preserve_attributes = d.preserve_attributes;
|
||||||
is_move: false,
|
let follow_links = d.follow_links;
|
||||||
sources,
|
let handle = self.ops_manager.begin(
|
||||||
dst,
|
crate::ops::OpKind::Copy,
|
||||||
preserve_attributes,
|
sources.clone(),
|
||||||
follow_links,
|
Some(dst.clone()),
|
||||||
});
|
);
|
||||||
self.dialog = Some(DialogState::Overwrite(Box::new(
|
match crate::ops::copy::copy_many(
|
||||||
overwrite_dialog::OverwriteDialog::new_copy(
|
&sources,
|
||||||
p.display().to_string(),
|
&dst,
|
||||||
),
|
&handle,
|
||||||
)));
|
false,
|
||||||
}
|
preserve_attributes,
|
||||||
Err(e) => {
|
follow_links,
|
||||||
self.status.set_message(format!("copy: {e}"));
|
) {
|
||||||
|
Ok(()) => {
|
||||||
|
self.status.set_message(format!(
|
||||||
|
"Copied {} item(s) to {}",
|
||||||
|
sources.len(),
|
||||||
|
dst.display()
|
||||||
|
));
|
||||||
|
self.ops_manager.finish();
|
||||||
|
let _ = self.active_panel_mut().refresh();
|
||||||
|
}
|
||||||
|
Err(crate::ops::OpsError::DestExists(p)) => {
|
||||||
|
self.pending_op = Some(PendingFileOp {
|
||||||
|
is_move: false,
|
||||||
|
sources,
|
||||||
|
dst,
|
||||||
|
preserve_attributes,
|
||||||
|
follow_links,
|
||||||
|
});
|
||||||
|
self.dialog = Some(DialogState::Overwrite(Box::new(
|
||||||
|
overwrite_dialog::OverwriteDialog::new_copy(
|
||||||
|
p.display().to_string(),
|
||||||
|
),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.status.set_message(format!("copy: {e}"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -885,46 +955,96 @@ impl FileManager {
|
|||||||
Some(DialogState::Move(d)) => {
|
Some(DialogState::Move(d)) => {
|
||||||
if let Some(dst) = d.result() {
|
if let Some(dst) = d.result() {
|
||||||
let sources = d.src.clone();
|
let sources = d.src.clone();
|
||||||
let preserve_attributes = d.preserve_attributes;
|
if d.same_dir {
|
||||||
let follow_links = d.follow_links;
|
if let Some(src) = sources.first().cloned() {
|
||||||
let handle = self.ops_manager.begin(
|
if src == dst {
|
||||||
crate::ops::OpKind::Move,
|
self.status
|
||||||
sources.clone(),
|
.set_message("move: source and destination are the same");
|
||||||
Some(dst.clone()),
|
} else {
|
||||||
);
|
let handle = self.ops_manager.begin(
|
||||||
match crate::ops::move_op::move_many(
|
crate::ops::OpKind::Move,
|
||||||
&sources,
|
sources.clone(),
|
||||||
&dst,
|
Some(dst.clone()),
|
||||||
&handle,
|
);
|
||||||
false,
|
match crate::ops::move_op::move_one(
|
||||||
preserve_attributes,
|
&src,
|
||||||
follow_links,
|
&dst,
|
||||||
) {
|
&handle,
|
||||||
Ok(()) => {
|
false,
|
||||||
self.status.set_message(format!(
|
d.preserve_attributes,
|
||||||
"Moved {} item(s) to {}",
|
d.follow_links,
|
||||||
sources.len(),
|
) {
|
||||||
dst.display()
|
Ok(()) => {
|
||||||
));
|
self.status.set_message(format!(
|
||||||
self.ops_manager.finish();
|
"Renamed {} -> {}",
|
||||||
let _ = self.active_panel_mut().refresh();
|
src.display(),
|
||||||
|
dst.display()
|
||||||
|
));
|
||||||
|
self.ops_manager.finish();
|
||||||
|
let _ = self.active_panel_mut().refresh();
|
||||||
|
}
|
||||||
|
Err(crate::ops::OpsError::DestExists(p)) => {
|
||||||
|
self.pending_op = Some(PendingFileOp {
|
||||||
|
is_move: true,
|
||||||
|
sources,
|
||||||
|
dst,
|
||||||
|
preserve_attributes: d.preserve_attributes,
|
||||||
|
follow_links: d.follow_links,
|
||||||
|
});
|
||||||
|
self.dialog = Some(DialogState::Overwrite(Box::new(
|
||||||
|
overwrite_dialog::OverwriteDialog::new_move(
|
||||||
|
p.display().to_string(),
|
||||||
|
),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.status.set_message(format!("move: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(crate::ops::OpsError::DestExists(p)) => {
|
} else {
|
||||||
self.pending_op = Some(PendingFileOp {
|
let preserve_attributes = d.preserve_attributes;
|
||||||
is_move: true,
|
let follow_links = d.follow_links;
|
||||||
sources,
|
let handle = self.ops_manager.begin(
|
||||||
dst,
|
crate::ops::OpKind::Move,
|
||||||
preserve_attributes,
|
sources.clone(),
|
||||||
follow_links,
|
Some(dst.clone()),
|
||||||
});
|
);
|
||||||
self.dialog = Some(DialogState::Overwrite(Box::new(
|
match crate::ops::move_op::move_many(
|
||||||
overwrite_dialog::OverwriteDialog::new_move(
|
&sources,
|
||||||
p.display().to_string(),
|
&dst,
|
||||||
),
|
&handle,
|
||||||
)));
|
false,
|
||||||
}
|
preserve_attributes,
|
||||||
Err(e) => {
|
follow_links,
|
||||||
self.status.set_message(format!("move: {e}"));
|
) {
|
||||||
|
Ok(()) => {
|
||||||
|
self.status.set_message(format!(
|
||||||
|
"Moved {} item(s) to {}",
|
||||||
|
sources.len(),
|
||||||
|
dst.display()
|
||||||
|
));
|
||||||
|
self.ops_manager.finish();
|
||||||
|
let _ = self.active_panel_mut().refresh();
|
||||||
|
}
|
||||||
|
Err(crate::ops::OpsError::DestExists(p)) => {
|
||||||
|
self.pending_op = Some(PendingFileOp {
|
||||||
|
is_move: true,
|
||||||
|
sources,
|
||||||
|
dst,
|
||||||
|
preserve_attributes,
|
||||||
|
follow_links,
|
||||||
|
});
|
||||||
|
self.dialog = Some(DialogState::Overwrite(Box::new(
|
||||||
|
overwrite_dialog::OverwriteDialog::new_move(
|
||||||
|
p.display().to_string(),
|
||||||
|
),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.status.set_message(format!("move: {e}"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,10 +84,18 @@ impl FileManager {
|
|||||||
self.open_copy_dialog()?;
|
self.open_copy_dialog()?;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
Cmd::CopySameDir => {
|
||||||
|
self.open_copy_same_dir_dialog()?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
Cmd::Move => {
|
Cmd::Move => {
|
||||||
self.open_move_dialog()?;
|
self.open_move_dialog()?;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
Cmd::MoveSameDir => {
|
||||||
|
self.open_move_same_dir_dialog()?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
Cmd::Delete => {
|
Cmd::Delete => {
|
||||||
self.open_delete_dialog()?;
|
self.open_delete_dialog()?;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ pub struct MoveDialog {
|
|||||||
pub width_pct: f32,
|
pub width_pct: f32,
|
||||||
/// Height as a fraction of the parent area.
|
/// Height as a fraction of the parent area.
|
||||||
pub height_pct: f32,
|
pub height_pct: f32,
|
||||||
|
/// True for Shift-F6 same-directory rename mode: the user
|
||||||
|
/// types just a new filename and the move stays in the source's
|
||||||
|
/// parent directory. Only valid with a single source.
|
||||||
|
pub same_dir: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MoveDialog {
|
impl MoveDialog {
|
||||||
@@ -93,6 +97,36 @@ impl MoveDialog {
|
|||||||
cancelled: false,
|
cancelled: false,
|
||||||
width_pct: 0.6,
|
width_pct: 0.6,
|
||||||
height_pct: 0.3,
|
height_pct: 0.3,
|
||||||
|
same_dir: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a same-directory rename dialog (Shift-F6). The user
|
||||||
|
/// types just a new filename; the move stays in `src`'s parent.
|
||||||
|
/// `src` must be a single file (not a marked batch).
|
||||||
|
#[must_use]
|
||||||
|
pub fn new_rename(src: PathBuf) -> Self {
|
||||||
|
let default_text = src
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let input = Input::new()
|
||||||
|
.label("Move to new name in same directory")
|
||||||
|
.text(default_text);
|
||||||
|
Self {
|
||||||
|
src: vec![src],
|
||||||
|
marked_count: 1,
|
||||||
|
dst_input: input,
|
||||||
|
preserve_attributes: true,
|
||||||
|
follow_links: false,
|
||||||
|
dive_into_subdirs: false,
|
||||||
|
stable_symlinks: false,
|
||||||
|
focused: 0,
|
||||||
|
confirmed: false,
|
||||||
|
cancelled: false,
|
||||||
|
width_pct: 0.6,
|
||||||
|
height_pct: 0.3,
|
||||||
|
same_dir: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +149,17 @@ impl MoveDialog {
|
|||||||
if s.is_empty() {
|
if s.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some(PathBuf::from(s))
|
let typed = PathBuf::from(s);
|
||||||
|
if self.same_dir {
|
||||||
|
let parent = self
|
||||||
|
.src
|
||||||
|
.first()
|
||||||
|
.and_then(|p| p.parent())
|
||||||
|
.map(|p| p.to_path_buf())
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."));
|
||||||
|
return Some(parent.join(typed));
|
||||||
|
}
|
||||||
|
Some(typed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True if the dialog was cancelled.
|
/// True if the dialog was cancelled.
|
||||||
@@ -355,6 +399,25 @@ mod tests {
|
|||||||
assert_eq!(d.src.len(), 2);
|
assert_eq!(d.src.len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_rename_sets_same_dir_and_prefills_filename() {
|
||||||
|
let d = MoveDialog::new_rename(std::path::PathBuf::from("/tmp/dir/foo.txt"));
|
||||||
|
assert!(d.same_dir);
|
||||||
|
assert_eq!(d.dst_input.value(), "foo.txt");
|
||||||
|
assert_eq!(d.src, vec![std::path::PathBuf::from("/tmp/dir/foo.txt")]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_rename_result_resolves_relative_to_parent() {
|
||||||
|
let mut d = MoveDialog::new_rename(std::path::PathBuf::from("/tmp/dir/foo.txt"));
|
||||||
|
d.dst_input = Input::new().text("bar.txt");
|
||||||
|
d.confirmed = true;
|
||||||
|
assert_eq!(
|
||||||
|
d.result(),
|
||||||
|
Some(std::path::PathBuf::from("/tmp/dir/bar.txt"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn enter_with_valid_dst_confirms() {
|
fn enter_with_valid_dst_confirms() {
|
||||||
let mut d = MoveDialog::new(vec![std::path::PathBuf::from("/tmp/a")]);
|
let mut d = MoveDialog::new(vec![std::path::PathBuf::from("/tmp/a")]);
|
||||||
|
|||||||
@@ -81,6 +81,39 @@ pub const PERM_CELLS: [PermCell; 9] = [
|
|||||||
}, // other x
|
}, // other x
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const SPECIAL_LABELS: [&str; 3] = ["setuid", "setgid", "sticky"];
|
||||||
|
|
||||||
|
const SPECIAL_CELLS: [PermCell; 3] = [
|
||||||
|
PermCell {
|
||||||
|
class_shift: 0o4000,
|
||||||
|
bit: 1,
|
||||||
|
}, // setuid
|
||||||
|
PermCell {
|
||||||
|
class_shift: 0o2000,
|
||||||
|
bit: 1,
|
||||||
|
}, // setgid
|
||||||
|
PermCell {
|
||||||
|
class_shift: 0o1000,
|
||||||
|
bit: 1,
|
||||||
|
}, // sticky
|
||||||
|
];
|
||||||
|
|
||||||
|
fn cell_at(idx: usize) -> PermCell {
|
||||||
|
if idx < 9 {
|
||||||
|
PERM_CELLS[idx]
|
||||||
|
} else {
|
||||||
|
SPECIAL_CELLS[idx - 9]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cell_label(idx: usize) -> &'static str {
|
||||||
|
if idx < 9 {
|
||||||
|
["r", "w", "x"][idx % 3]
|
||||||
|
} else {
|
||||||
|
["u", "g", "t"][idx - 9]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// C-x c chmod dialog.
|
/// C-x c chmod dialog.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct PermissionDialog {
|
pub struct PermissionDialog {
|
||||||
@@ -111,7 +144,7 @@ impl PermissionDialog {
|
|||||||
Self {
|
Self {
|
||||||
path,
|
path,
|
||||||
current_mode,
|
current_mode,
|
||||||
mode: current_mode & 0o777,
|
mode: current_mode & 0o7777,
|
||||||
focused: 0,
|
focused: 0,
|
||||||
confirmed: false,
|
confirmed: false,
|
||||||
cancelled: false,
|
cancelled: false,
|
||||||
@@ -130,7 +163,7 @@ impl PermissionDialog {
|
|||||||
|
|
||||||
/// Toggle the cell under the cursor.
|
/// Toggle the cell under the cursor.
|
||||||
pub fn toggle_focused(&mut self) {
|
pub fn toggle_focused(&mut self) {
|
||||||
let cell = PERM_CELLS[self.focused];
|
let cell = cell_at(self.focused);
|
||||||
self.mode ^= cell.mask();
|
self.mode ^= cell.mask();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +172,7 @@ impl PermissionDialog {
|
|||||||
pub fn move_focus(&mut self, row_delta: i32, col_delta: i32) {
|
pub fn move_focus(&mut self, row_delta: i32, col_delta: i32) {
|
||||||
let row = (self.focused / 3) as i32;
|
let row = (self.focused / 3) as i32;
|
||||||
let col = (self.focused % 3) as i32;
|
let col = (self.focused % 3) as i32;
|
||||||
let r = (row + row_delta).clamp(0, 2);
|
let r = (row + row_delta).clamp(0, 3);
|
||||||
let c = (col + col_delta).clamp(0, 2);
|
let c = (col + col_delta).clamp(0, 2);
|
||||||
self.focused = (r * 3 + c) as usize;
|
self.focused = (r * 3 + c) as usize;
|
||||||
}
|
}
|
||||||
@@ -149,7 +182,7 @@ impl PermissionDialog {
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn result(&self) -> Option<u32> {
|
pub fn result(&self) -> Option<u32> {
|
||||||
if self.confirmed {
|
if self.confirmed {
|
||||||
Some(self.mode & 0o777)
|
Some(self.mode & 0o7777)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -179,8 +212,7 @@ impl PermissionDialog {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
Key { code: 0x09, .. } => {
|
Key { code: 0x09, .. } => {
|
||||||
// Tab — cycle focus forward across the 9 cells.
|
self.focused = (self.focused + 1) % 12;
|
||||||
self.focused = (self.focused + 1) % 9;
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
Key { code: 0x2190, .. } => {
|
Key { code: 0x2190, .. } => {
|
||||||
@@ -225,7 +257,7 @@ impl PermissionDialog {
|
|||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([
|
.constraints([
|
||||||
Constraint::Length(1), // header
|
Constraint::Length(1), // header
|
||||||
Constraint::Min(3), // grid
|
Constraint::Min(4), // grid
|
||||||
Constraint::Length(2), // hint + button
|
Constraint::Length(2), // hint + button
|
||||||
])
|
])
|
||||||
.split(inner);
|
.split(inner);
|
||||||
@@ -238,7 +270,7 @@ impl PermissionDialog {
|
|||||||
),
|
),
|
||||||
Span::raw(" "),
|
Span::raw(" "),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
format!("Mode: {:03o}", self.mode & 0o777),
|
format!("Mode: {:04o}", self.mode & 0o7777),
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(theme.warning)
|
.fg(theme.warning)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
@@ -250,18 +282,18 @@ impl PermissionDialog {
|
|||||||
let rows = Layout::default()
|
let rows = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([
|
.constraints([
|
||||||
Constraint::Ratio(1, 3),
|
Constraint::Ratio(1, 4),
|
||||||
Constraint::Ratio(1, 3),
|
Constraint::Ratio(1, 4),
|
||||||
Constraint::Ratio(1, 3),
|
Constraint::Ratio(1, 4),
|
||||||
|
Constraint::Ratio(1, 4),
|
||||||
])
|
])
|
||||||
.split(chunks[1]);
|
.split(chunks[1]);
|
||||||
let row_labels = ["user", "group", "other"];
|
let row_labels = ["user", "group", "other", "special"];
|
||||||
let col_labels = ["r", "w", "x"];
|
|
||||||
for (row_idx, row_area) in rows.iter().enumerate() {
|
for (row_idx, row_area) in rows.iter().enumerate() {
|
||||||
let cols = Layout::default()
|
let cols = Layout::default()
|
||||||
.direction(Direction::Horizontal)
|
.direction(Direction::Horizontal)
|
||||||
.constraints([
|
.constraints([
|
||||||
Constraint::Length(8), // class label
|
Constraint::Length(8),
|
||||||
Constraint::Ratio(1, 3),
|
Constraint::Ratio(1, 3),
|
||||||
Constraint::Ratio(1, 3),
|
Constraint::Ratio(1, 3),
|
||||||
Constraint::Ratio(1, 3),
|
Constraint::Ratio(1, 3),
|
||||||
@@ -278,11 +310,12 @@ impl PermissionDialog {
|
|||||||
);
|
);
|
||||||
for col_idx in 0..3 {
|
for col_idx in 0..3 {
|
||||||
let cell_idx = row_idx * 3 + col_idx;
|
let cell_idx = row_idx * 3 + col_idx;
|
||||||
let cell = PERM_CELLS[cell_idx];
|
let cell = cell_at(cell_idx);
|
||||||
let on = (self.mode & cell.mask()) != 0;
|
let on = (self.mode & cell.mask()) != 0;
|
||||||
let focused = self.focused == cell_idx;
|
let focused = self.focused == cell_idx;
|
||||||
let mark = if on { 'x' } else { ' ' };
|
let mark = if on { 'x' } else { ' ' };
|
||||||
let line = format!("[{}] {}", mark, col_labels[col_idx]);
|
let label = cell_label(cell_idx);
|
||||||
|
let line = format!("[{}] {}", mark, label);
|
||||||
let style = if focused {
|
let style = if focused {
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(theme.cursor_fg)
|
.fg(theme.cursor_fg)
|
||||||
@@ -323,9 +356,9 @@ impl PermissionDialog {
|
|||||||
]);
|
]);
|
||||||
let ok = Line::from(Span::styled(
|
let ok = Line::from(Span::styled(
|
||||||
format!(
|
format!(
|
||||||
" [ OK ] (current {:03o} -> new {:03o}) ",
|
" [ OK ] (current {:04o} -> new {:04o}) ",
|
||||||
self.current_mode & 0o777,
|
self.current_mode & 0o7777,
|
||||||
self.mode & 0o777
|
self.mode & 0o7777
|
||||||
),
|
),
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(theme.cursor_fg)
|
.fg(theme.cursor_fg)
|
||||||
@@ -413,7 +446,7 @@ mod tests {
|
|||||||
d.move_focus(-1, 0);
|
d.move_focus(-1, 0);
|
||||||
assert_eq!(d.focused, 0);
|
assert_eq!(d.focused, 0);
|
||||||
d.move_focus(10, 10);
|
d.move_focus(10, 10);
|
||||||
assert_eq!(d.focused, 8);
|
assert_eq!(d.focused, 11);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -428,4 +461,59 @@ mod tests {
|
|||||||
assert_eq!(PERM_CELLS[7].mask(), 0o002); // other-w
|
assert_eq!(PERM_CELLS[7].mask(), 0o002); // other-w
|
||||||
assert_eq!(PERM_CELLS[8].mask(), 0o001); // other-x
|
assert_eq!(PERM_CELLS[8].mask(), 0o001); // other-x
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn special_cell_mask_values() {
|
||||||
|
assert_eq!(SPECIAL_CELLS[0].mask(), 0o4000); // setuid
|
||||||
|
assert_eq!(SPECIAL_CELLS[1].mask(), 0o2000); // setgid
|
||||||
|
assert_eq!(SPECIAL_CELLS[2].mask(), 0o1000); // sticky
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toggle_setuid_bit() {
|
||||||
|
let mut d = PermissionDialog::new("/x".into(), 0o755);
|
||||||
|
d.focused = 9;
|
||||||
|
d.toggle_focused();
|
||||||
|
assert_eq!(d.mode, 0o4755);
|
||||||
|
d.toggle_focused();
|
||||||
|
assert_eq!(d.mode, 0o0755);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toggle_setgid_bit() {
|
||||||
|
let mut d = PermissionDialog::new("/x".into(), 0o755);
|
||||||
|
d.focused = 10;
|
||||||
|
d.toggle_focused();
|
||||||
|
assert_eq!(d.mode, 0o2755);
|
||||||
|
d.toggle_focused();
|
||||||
|
assert_eq!(d.mode, 0o0755);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toggle_sticky_bit() {
|
||||||
|
let mut d = PermissionDialog::new("/x".into(), 0o755);
|
||||||
|
d.focused = 11;
|
||||||
|
d.toggle_focused();
|
||||||
|
assert_eq!(d.mode, 0o1755);
|
||||||
|
d.toggle_focused();
|
||||||
|
assert_eq!(d.mode, 0o0755);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tab_cycles_through_12_cells() {
|
||||||
|
let mut d = PermissionDialog::new("/x".into(), 0o000);
|
||||||
|
for _ in 0..11 {
|
||||||
|
d.handle_key(Key { code: 0x09, mods: crate::key::Modifiers::empty() });
|
||||||
|
}
|
||||||
|
assert_eq!(d.focused, 11);
|
||||||
|
d.handle_key(Key { code: 0x09, mods: crate::key::Modifiers::empty() });
|
||||||
|
assert_eq!(d.focused, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preserves_special_bits_from_current_mode() {
|
||||||
|
let d = PermissionDialog::new("/x".into(), 0o6755);
|
||||||
|
assert_eq!(d.mode, 0o6755);
|
||||||
|
assert_eq!(d.result(), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,12 @@ pub enum Cmd {
|
|||||||
EnterDir,
|
EnterDir,
|
||||||
/// F5 — open the copy dialog.
|
/// F5 — open the copy dialog.
|
||||||
Copy,
|
Copy,
|
||||||
|
/// Shift-F5 — copy the cursor file under a new name in the same directory.
|
||||||
|
CopySameDir,
|
||||||
/// F6 — open the move/rename dialog.
|
/// F6 — open the move/rename dialog.
|
||||||
Move,
|
Move,
|
||||||
|
/// Shift-F6 — move/rename the cursor file under a new name in the same directory.
|
||||||
|
MoveSameDir,
|
||||||
/// F8 — open the delete dialog.
|
/// F8 — open the delete dialog.
|
||||||
Delete,
|
Delete,
|
||||||
/// F7 — open the make-directory dialog.
|
/// F7 — open the make-directory dialog.
|
||||||
@@ -200,7 +204,9 @@ impl Cmd {
|
|||||||
Cmd::View => "View",
|
Cmd::View => "View",
|
||||||
Cmd::EnterDir => "Enter",
|
Cmd::EnterDir => "Enter",
|
||||||
Cmd::Copy => "Copy",
|
Cmd::Copy => "Copy",
|
||||||
|
Cmd::CopySameDir => "Copy (rename)",
|
||||||
Cmd::Move => "Move",
|
Cmd::Move => "Move",
|
||||||
|
Cmd::MoveSameDir => "Move (rename)",
|
||||||
Cmd::Delete => "Delete",
|
Cmd::Delete => "Delete",
|
||||||
Cmd::MkDir => "Make directory",
|
Cmd::MkDir => "Make directory",
|
||||||
Cmd::SwapPanels => "Swap panels",
|
Cmd::SwapPanels => "Swap panels",
|
||||||
@@ -323,6 +329,13 @@ const F8: Key = Key::f(8);
|
|||||||
const F9: Key = Key::f(9);
|
const F9: Key = Key::f(9);
|
||||||
const F10: Key = Key::f(10);
|
const F10: Key = Key::f(10);
|
||||||
const F11: Key = Key::f(11);
|
const F11: Key = Key::f(11);
|
||||||
|
|
||||||
|
fn shift(k: Key) -> Key {
|
||||||
|
Key {
|
||||||
|
code: k.code,
|
||||||
|
mods: k.mods | crate::key::Modifiers::SHIFT,
|
||||||
|
}
|
||||||
|
}
|
||||||
const TAB: Key = Key {
|
const TAB: Key = Key {
|
||||||
code: 0x09,
|
code: 0x09,
|
||||||
mods: Modifiers::empty(),
|
mods: Modifiers::empty(),
|
||||||
@@ -390,6 +403,8 @@ pub fn default_keymap() -> Keymap {
|
|||||||
km.bind(F4, Cmd::Edit);
|
km.bind(F4, Cmd::Edit);
|
||||||
km.bind(F5, Cmd::Copy);
|
km.bind(F5, Cmd::Copy);
|
||||||
km.bind(F6, Cmd::Move);
|
km.bind(F6, Cmd::Move);
|
||||||
|
km.bind(shift(F5), Cmd::CopySameDir);
|
||||||
|
km.bind(shift(F6), Cmd::MoveSameDir);
|
||||||
km.bind(F7, Cmd::MkDir);
|
km.bind(F7, Cmd::MkDir);
|
||||||
km.bind(F8, Cmd::Delete);
|
km.bind(F8, Cmd::Delete);
|
||||||
km.bind(F9, Cmd::MenuBar);
|
km.bind(F9, Cmd::MenuBar);
|
||||||
@@ -500,6 +515,9 @@ mod tests {
|
|||||||
assert_eq!(km.lookup(F1), Some(Cmd::Help));
|
assert_eq!(km.lookup(F1), Some(Cmd::Help));
|
||||||
assert_eq!(km.lookup(F3), Some(Cmd::View));
|
assert_eq!(km.lookup(F3), Some(Cmd::View));
|
||||||
assert_eq!(km.lookup(F5), Some(Cmd::Copy));
|
assert_eq!(km.lookup(F5), Some(Cmd::Copy));
|
||||||
|
assert_eq!(km.lookup(F6), Some(Cmd::Move));
|
||||||
|
assert_eq!(km.lookup(shift(F5)), Some(Cmd::CopySameDir));
|
||||||
|
assert_eq!(km.lookup(shift(F6)), Some(Cmd::MoveSameDir));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
//! line-wrap and search highlight — no line-number gutter (MC parity).
|
//! line-wrap and search highlight — no line-number gutter (MC parity).
|
||||||
|
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
use ratatui::style::{Modifier, Style};
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Paragraph, Wrap};
|
use ratatui::widgets::{Paragraph, Wrap};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
@@ -208,6 +208,10 @@ pub fn render(v: &mut Viewer, frame: &mut Frame, area: Rect, theme: &Theme) {
|
|||||||
let viewer_bold = mc_skin::color_pair(theme.name, "viewer", "viewbold");
|
let viewer_bold = mc_skin::color_pair(theme.name, "viewer", "viewbold");
|
||||||
let body_fg = viewer_default.map(|p| p.fg).unwrap_or(theme.foreground);
|
let body_fg = viewer_default.map(|p| p.fg).unwrap_or(theme.foreground);
|
||||||
let body_bg = viewer_default.map(|p| p.bg).unwrap_or(theme.background);
|
let body_bg = viewer_default.map(|p| p.bg).unwrap_or(theme.background);
|
||||||
|
let cursor_line_bg = match body_bg {
|
||||||
|
Color::Rgb(r, g, b) => Color::Rgb(r.saturating_add(12), g.saturating_add(12), b.saturating_add(12)),
|
||||||
|
_ => theme.current_line_bg(),
|
||||||
|
};
|
||||||
let bold_fg = viewer_bold.map(|p| p.fg).unwrap_or(theme.warning);
|
let bold_fg = viewer_bold.map(|p| p.fg).unwrap_or(theme.warning);
|
||||||
#[cfg(feature = "syntect")]
|
#[cfg(feature = "syntect")]
|
||||||
{
|
{
|
||||||
@@ -333,6 +337,7 @@ pub fn render(v: &mut Viewer, frame: &mut Frame, area: Rect, theme: &Theme) {
|
|||||||
usable_w,
|
usable_w,
|
||||||
v.wrap,
|
v.wrap,
|
||||||
theme,
|
theme,
|
||||||
|
cursor_line_bg,
|
||||||
&nroff_ranges,
|
&nroff_ranges,
|
||||||
#[cfg(feature = "syntect")]
|
#[cfg(feature = "syntect")]
|
||||||
v.highlighter.as_mut(),
|
v.highlighter.as_mut(),
|
||||||
@@ -363,6 +368,7 @@ pub fn render(v: &mut Viewer, frame: &mut Frame, area: Rect, theme: &Theme) {
|
|||||||
usable_w,
|
usable_w,
|
||||||
v.wrap,
|
v.wrap,
|
||||||
theme,
|
theme,
|
||||||
|
cursor_line_bg,
|
||||||
&nroff_ranges,
|
&nroff_ranges,
|
||||||
#[cfg(feature = "syntect")]
|
#[cfg(feature = "syntect")]
|
||||||
v.highlighter.as_mut(),
|
v.highlighter.as_mut(),
|
||||||
@@ -424,6 +430,7 @@ fn render_line_with_highlight(
|
|||||||
usable_w: usize,
|
usable_w: usize,
|
||||||
_wrap: bool,
|
_wrap: bool,
|
||||||
theme: &Theme,
|
theme: &Theme,
|
||||||
|
cursor_line_bg: Color,
|
||||||
nroff_ranges: &[NroffRange],
|
nroff_ranges: &[NroffRange],
|
||||||
#[cfg(feature = "syntect")] highlighter: Option<&mut crate::editor::syntax::Highlighter>,
|
#[cfg(feature = "syntect")] highlighter: Option<&mut crate::editor::syntax::Highlighter>,
|
||||||
) -> Vec<Span<'static>> {
|
) -> Vec<Span<'static>> {
|
||||||
@@ -482,6 +489,11 @@ fn render_line_with_highlight(
|
|||||||
Style::default().fg(theme.foreground),
|
Style::default().fg(theme.foreground),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if cursor_col.is_some() {
|
||||||
|
for span in &mut spans {
|
||||||
|
span.style = span.style.bg(cursor_line_bg);
|
||||||
|
}
|
||||||
|
}
|
||||||
// Apply search match highlight ON TOP of syntax colors.
|
// Apply search match highlight ON TOP of syntax colors.
|
||||||
if !matches_in_line.is_empty() {
|
if !matches_in_line.is_empty() {
|
||||||
matches_in_line.sort_by_key(|m| m.0);
|
matches_in_line.sort_by_key(|m| m.0);
|
||||||
@@ -492,16 +504,6 @@ fn render_line_with_highlight(
|
|||||||
nroff_in_line.sort_by_key(|r| r.0);
|
nroff_in_line.sort_by_key(|r| r.0);
|
||||||
spans = overlay_nroff_styles(spans, &nroff_in_line);
|
spans = overlay_nroff_styles(spans, &nroff_in_line);
|
||||||
}
|
}
|
||||||
// Cursor marker.
|
|
||||||
if let Some(col) = cursor_col {
|
|
||||||
if col < spans.len() {
|
|
||||||
// We don't try to insert the marker inside a Span — instead
|
|
||||||
// we just change the bg of the matching Span if possible.
|
|
||||||
// For a true cursor, the Viewer's main draw loop in mod.rs
|
|
||||||
// handles cursor overlay; here we just ensure the spans are
|
|
||||||
// intact.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let _ = cursor_col;
|
let _ = cursor_col;
|
||||||
spans
|
spans
|
||||||
}
|
}
|
||||||
@@ -800,6 +802,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
v.text_view.search("bar");
|
v.text_view.search("bar");
|
||||||
|
v.cursor = 12;
|
||||||
|
|
||||||
let backend = TestBackend::new(80, 10);
|
let backend = TestBackend::new(80, 10);
|
||||||
let mut terminal = Terminal::new(backend).unwrap();
|
let mut terminal = Terminal::new(backend).unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user