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:
kellito
2026-07-05 14:42:17 +03:00
parent 146986b955
commit a2df7a06cf
10 changed files with 556 additions and 121 deletions
+13
View File
@@ -51,6 +51,9 @@ impl Application {
let mut shell_manager = ShellManager::new();
let keymap = default_keymap();
// MC parity: cursor hidden in panel mode; render() manages visibility.
let _ = tui.terminal_mut().hide_cursor();
// Initial paint.
render(&mut tui, &mut fm)?;
@@ -324,6 +327,16 @@ fn render(tui: &mut Tui, fm: &mut FileManager) -> Result<()> {
fm.render(frame, area);
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(())
}
@@ -2354,11 +2354,11 @@ mod tests {
})
.unwrap();
let buffer = terminal.backend().buffer();
assert_eq!(buffer.cell((1, 1)).expect("a cell").symbol(), "a");
assert_eq!(buffer.cell((2, 1)).expect("space cell").symbol(), "·");
assert_eq!(buffer.cell((3, 1)).expect("tab cell").symbol(), "");
assert_eq!(buffer.cell((4, 1)).expect("b cell").symbol(), "b");
assert_eq!(buffer.cell((5, 1)).expect("caret cell").symbol(), "^");
assert_eq!(buffer.cell((4, 1)).expect("a cell").symbol(), "a");
assert_eq!(buffer.cell((5, 1)).expect("space cell").symbol(), "·");
assert_eq!(buffer.cell((6, 1)).expect("tab cell").symbol(), "");
assert_eq!(buffer.cell((7, 1)).expect("b cell").symbol(), "b");
assert_eq!(buffer.cell((8, 1)).expect("caret cell").symbol(), "^");
}
#[test]
@@ -2387,7 +2387,7 @@ mod tests {
})
})
.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.fg, bookmarkfound.fg);
assert_eq!(cell.bg, bookmarkfound.bg);
@@ -2673,8 +2673,8 @@ mod tests {
fg: theme.marked_fg,
bg: theme.marked_bg,
});
let f_cell = buffer.cell((1, 1)).expect("'f' cell");
let n_cell = buffer.cell((2, 1)).expect("'n' cell");
let f_cell = buffer.cell((4, 1)).expect("'f' cell");
let n_cell = buffer.cell((5, 1)).expect("'n' cell");
assert_eq!(f_cell.symbol(), "f");
assert_eq!(n_cell.symbol(), "n");
assert_eq!(f_cell.bg, marked_pair.bg, "selected 'f' cell bg");
@@ -118,7 +118,24 @@ impl Editor {
let inner = block.inner(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.buffer,
@@ -130,7 +147,6 @@ impl Editor {
let height = body_area.height as usize;
let cursor_line = self.buffer_line_of(self.cursor.position());
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 {
build_wrap_map(&self.buffer, top, height, body_width)
} else {
@@ -285,6 +301,38 @@ impl Editor {
}
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 {
let margin_area = Rect::new(
body_area.x + body_area.width - 1,
@@ -53,6 +53,10 @@ pub struct CopyDialog {
pub width_pct: f32,
/// Height as a fraction of the parent area.
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 {
@@ -93,6 +97,36 @@ impl CopyDialog {
cancelled: false,
width_pct: 0.6,
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() {
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.
@@ -365,6 +409,36 @@ mod tests {
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]
fn enter_with_empty_does_not_confirm() {
let mut d = CopyDialog::new(vec![std::path::PathBuf::from("/tmp/a")]);
@@ -196,6 +196,34 @@ impl FileManager {
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
/// all marked files.
pub fn open_delete_dialog(&mut self) -> Result<()> {
@@ -838,46 +866,88 @@ impl FileManager {
Some(DialogState::Copy(d)) => {
if let Some(dst) = d.result() {
let sources = d.src.clone();
let preserve_attributes = d.preserve_attributes;
let follow_links = d.follow_links;
let handle = self.ops_manager.begin(
crate::ops::OpKind::Copy,
sources.clone(),
Some(dst.clone()),
);
match crate::ops::copy::copy_many(
&sources,
&dst,
&handle,
false,
preserve_attributes,
follow_links,
) {
Ok(()) => {
self.status.set_message(format!(
"Copied {} item(s) to {}",
sources.len(),
dst.display()
));
self.ops_manager.finish();
let _ = self.active_panel_mut().refresh();
if d.same_dir {
if let Some(src) = sources.first().cloned() {
if src == dst {
self.status
.set_message("copy: source and destination are the same");
} else if dst.exists() {
self.pending_op = Some(PendingFileOp {
is_move: false,
sources,
dst: dst.clone(),
preserve_attributes: d.preserve_attributes,
follow_links: d.follow_links,
});
self.dialog = Some(DialogState::Overwrite(Box::new(
overwrite_dialog::OverwriteDialog::new_copy(
dst.display().to_string(),
),
)));
} else {
let handle = self.ops_manager.begin(
crate::ops::OpKind::Copy,
sources.clone(),
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)) => {
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}"));
} else {
let preserve_attributes = d.preserve_attributes;
let follow_links = d.follow_links;
let handle = self.ops_manager.begin(
crate::ops::OpKind::Copy,
sources.clone(),
Some(dst.clone()),
);
match crate::ops::copy::copy_many(
&sources,
&dst,
&handle,
false,
preserve_attributes,
follow_links,
) {
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)) => {
if let Some(dst) = d.result() {
let sources = d.src.clone();
let preserve_attributes = d.preserve_attributes;
let follow_links = d.follow_links;
let handle = self.ops_manager.begin(
crate::ops::OpKind::Move,
sources.clone(),
Some(dst.clone()),
);
match crate::ops::move_op::move_many(
&sources,
&dst,
&handle,
false,
preserve_attributes,
follow_links,
) {
Ok(()) => {
self.status.set_message(format!(
"Moved {} item(s) to {}",
sources.len(),
dst.display()
));
self.ops_manager.finish();
let _ = self.active_panel_mut().refresh();
if d.same_dir {
if let Some(src) = sources.first().cloned() {
if src == dst {
self.status
.set_message("move: source and destination are the same");
} else {
let handle = self.ops_manager.begin(
crate::ops::OpKind::Move,
sources.clone(),
Some(dst.clone()),
);
match crate::ops::move_op::move_one(
&src,
&dst,
&handle,
false,
d.preserve_attributes,
d.follow_links,
) {
Ok(()) => {
self.status.set_message(format!(
"Renamed {} -> {}",
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)) => {
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}"));
} else {
let preserve_attributes = d.preserve_attributes;
let follow_links = d.follow_links;
let handle = self.ops_manager.begin(
crate::ops::OpKind::Move,
sources.clone(),
Some(dst.clone()),
);
match crate::ops::move_op::move_many(
&sources,
&dst,
&handle,
false,
preserve_attributes,
follow_links,
) {
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()?;
Ok(true)
}
Cmd::CopySameDir => {
self.open_copy_same_dir_dialog()?;
Ok(true)
}
Cmd::Move => {
self.open_move_dialog()?;
Ok(true)
}
Cmd::MoveSameDir => {
self.open_move_same_dir_dialog()?;
Ok(true)
}
Cmd::Delete => {
self.open_delete_dialog()?;
Ok(true)
@@ -53,6 +53,10 @@ pub struct MoveDialog {
pub width_pct: f32,
/// Height as a fraction of the parent area.
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 {
@@ -93,6 +97,36 @@ impl MoveDialog {
cancelled: false,
width_pct: 0.6,
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() {
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.
@@ -355,6 +399,25 @@ mod tests {
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]
fn enter_with_valid_dst_confirms() {
let mut d = MoveDialog::new(vec![std::path::PathBuf::from("/tmp/a")]);
@@ -81,6 +81,39 @@ pub const PERM_CELLS: [PermCell; 9] = [
}, // 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.
#[derive(Debug, Clone)]
pub struct PermissionDialog {
@@ -111,7 +144,7 @@ impl PermissionDialog {
Self {
path,
current_mode,
mode: current_mode & 0o777,
mode: current_mode & 0o7777,
focused: 0,
confirmed: false,
cancelled: false,
@@ -130,7 +163,7 @@ impl PermissionDialog {
/// Toggle the cell under the cursor.
pub fn toggle_focused(&mut self) {
let cell = PERM_CELLS[self.focused];
let cell = cell_at(self.focused);
self.mode ^= cell.mask();
}
@@ -139,7 +172,7 @@ impl PermissionDialog {
pub fn move_focus(&mut self, row_delta: i32, col_delta: i32) {
let row = (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);
self.focused = (r * 3 + c) as usize;
}
@@ -149,7 +182,7 @@ impl PermissionDialog {
#[must_use]
pub fn result(&self) -> Option<u32> {
if self.confirmed {
Some(self.mode & 0o777)
Some(self.mode & 0o7777)
} else {
None
}
@@ -179,8 +212,7 @@ impl PermissionDialog {
true
}
Key { code: 0x09, .. } => {
// Tab — cycle focus forward across the 9 cells.
self.focused = (self.focused + 1) % 9;
self.focused = (self.focused + 1) % 12;
true
}
Key { code: 0x2190, .. } => {
@@ -225,7 +257,7 @@ impl PermissionDialog {
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // header
Constraint::Min(3), // grid
Constraint::Min(4), // grid
Constraint::Length(2), // hint + button
])
.split(inner);
@@ -238,7 +270,7 @@ impl PermissionDialog {
),
Span::raw(" "),
Span::styled(
format!("Mode: {:03o}", self.mode & 0o777),
format!("Mode: {:04o}", self.mode & 0o7777),
Style::default()
.fg(theme.warning)
.add_modifier(Modifier::BOLD),
@@ -250,18 +282,18 @@ impl PermissionDialog {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 4),
Constraint::Ratio(1, 4),
Constraint::Ratio(1, 4),
Constraint::Ratio(1, 4),
])
.split(chunks[1]);
let row_labels = ["user", "group", "other"];
let col_labels = ["r", "w", "x"];
let row_labels = ["user", "group", "other", "special"];
for (row_idx, row_area) in rows.iter().enumerate() {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(8), // class label
Constraint::Length(8),
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
@@ -278,11 +310,12 @@ impl PermissionDialog {
);
for col_idx in 0..3 {
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 focused = self.focused == cell_idx;
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 {
Style::default()
.fg(theme.cursor_fg)
@@ -323,9 +356,9 @@ impl PermissionDialog {
]);
let ok = Line::from(Span::styled(
format!(
" [ OK ] (current {:03o} -> new {:03o}) ",
self.current_mode & 0o777,
self.mode & 0o777
" [ OK ] (current {:04o} -> new {:04o}) ",
self.current_mode & 0o7777,
self.mode & 0o7777
),
Style::default()
.fg(theme.cursor_fg)
@@ -413,7 +446,7 @@ mod tests {
d.move_focus(-1, 0);
assert_eq!(d.focused, 0);
d.move_focus(10, 10);
assert_eq!(d.focused, 8);
assert_eq!(d.focused, 11);
}
#[test]
@@ -428,4 +461,59 @@ mod tests {
assert_eq!(PERM_CELLS[7].mask(), 0o002); // other-w
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,
/// F5 — open the copy dialog.
Copy,
/// Shift-F5 — copy the cursor file under a new name in the same directory.
CopySameDir,
/// F6 — open the move/rename dialog.
Move,
/// Shift-F6 — move/rename the cursor file under a new name in the same directory.
MoveSameDir,
/// F8 — open the delete dialog.
Delete,
/// F7 — open the make-directory dialog.
@@ -200,7 +204,9 @@ impl Cmd {
Cmd::View => "View",
Cmd::EnterDir => "Enter",
Cmd::Copy => "Copy",
Cmd::CopySameDir => "Copy (rename)",
Cmd::Move => "Move",
Cmd::MoveSameDir => "Move (rename)",
Cmd::Delete => "Delete",
Cmd::MkDir => "Make directory",
Cmd::SwapPanels => "Swap panels",
@@ -323,6 +329,13 @@ const F8: Key = Key::f(8);
const F9: Key = Key::f(9);
const F10: Key = Key::f(10);
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 {
code: 0x09,
mods: Modifiers::empty(),
@@ -390,6 +403,8 @@ pub fn default_keymap() -> Keymap {
km.bind(F4, Cmd::Edit);
km.bind(F5, Cmd::Copy);
km.bind(F6, Cmd::Move);
km.bind(shift(F5), Cmd::CopySameDir);
km.bind(shift(F6), Cmd::MoveSameDir);
km.bind(F7, Cmd::MkDir);
km.bind(F8, Cmd::Delete);
km.bind(F9, Cmd::MenuBar);
@@ -500,6 +515,9 @@ mod tests {
assert_eq!(km.lookup(F1), Some(Cmd::Help));
assert_eq!(km.lookup(F3), Some(Cmd::View));
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]
+14 -11
View File
@@ -4,7 +4,7 @@
//! line-wrap and search highlight — no line-number gutter (MC parity).
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Wrap};
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 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 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);
#[cfg(feature = "syntect")]
{
@@ -333,6 +337,7 @@ pub fn render(v: &mut Viewer, frame: &mut Frame, area: Rect, theme: &Theme) {
usable_w,
v.wrap,
theme,
cursor_line_bg,
&nroff_ranges,
#[cfg(feature = "syntect")]
v.highlighter.as_mut(),
@@ -363,6 +368,7 @@ pub fn render(v: &mut Viewer, frame: &mut Frame, area: Rect, theme: &Theme) {
usable_w,
v.wrap,
theme,
cursor_line_bg,
&nroff_ranges,
#[cfg(feature = "syntect")]
v.highlighter.as_mut(),
@@ -424,6 +430,7 @@ fn render_line_with_highlight(
usable_w: usize,
_wrap: bool,
theme: &Theme,
cursor_line_bg: Color,
nroff_ranges: &[NroffRange],
#[cfg(feature = "syntect")] highlighter: Option<&mut crate::editor::syntax::Highlighter>,
) -> Vec<Span<'static>> {
@@ -482,6 +489,11 @@ fn render_line_with_highlight(
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.
if !matches_in_line.is_empty() {
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);
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;
spans
}
@@ -800,6 +802,7 @@ mod tests {
},
);
v.text_view.search("bar");
v.cursor = 12;
let backend = TestBackend::new(80, 10);
let mut terminal = Terminal::new(backend).unwrap();