diff --git a/local/recipes/tui/tlc/source/src/app.rs b/local/recipes/tui/tlc/source/src/app.rs index 22fad59383..313104fe07 100644 --- a/local/recipes/tui/tlc/source/src/app.rs +++ b/local/recipes/tui/tlc/source/src/app.rs @@ -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(()) } diff --git a/local/recipes/tui/tlc/source/src/editor/mod.rs b/local/recipes/tui/tlc/source/src/editor/mod.rs index cb5f19223b..a7408b6d95 100644 --- a/local/recipes/tui/tlc/source/src/editor/mod.rs +++ b/local/recipes/tui/tlc/source/src/editor/mod.rs @@ -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"); diff --git a/local/recipes/tui/tlc/source/src/editor/render.rs b/local/recipes/tui/tlc/source/src/editor/render.rs index e90481ba55..fe4aafff30 100644 --- a/local/recipes/tui/tlc/source/src/editor/render.rs +++ b/local/recipes/tui/tlc/source/src/editor/render.rs @@ -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 = 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, diff --git a/local/recipes/tui/tlc/source/src/filemanager/copy_dialog.rs b/local/recipes/tui/tlc/source/src/filemanager/copy_dialog.rs index 51bc4d655d..97a6b1d8ae 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/copy_dialog.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/copy_dialog.rs @@ -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")]); diff --git a/local/recipes/tui/tlc/source/src/filemanager/dialog_ops.rs b/local/recipes/tui/tlc/source/src/filemanager/dialog_ops.rs index ee7f278d1e..db969f7680 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/dialog_ops.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/dialog_ops.rs @@ -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}")); + } } } } diff --git a/local/recipes/tui/tlc/source/src/filemanager/dispatch.rs b/local/recipes/tui/tlc/source/src/filemanager/dispatch.rs index 30d9d817e2..16ac1ad064 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/dispatch.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/dispatch.rs @@ -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) diff --git a/local/recipes/tui/tlc/source/src/filemanager/move_dialog.rs b/local/recipes/tui/tlc/source/src/filemanager/move_dialog.rs index 896f706882..25ded70909 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/move_dialog.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/move_dialog.rs @@ -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")]); diff --git a/local/recipes/tui/tlc/source/src/filemanager/permission.rs b/local/recipes/tui/tlc/source/src/filemanager/permission.rs index 116cb0113c..d55941c81a 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/permission.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/permission.rs @@ -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 { 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); + } } diff --git a/local/recipes/tui/tlc/source/src/keymap/mod.rs b/local/recipes/tui/tlc/source/src/keymap/mod.rs index 81e16a6636..e2fbb13cdb 100644 --- a/local/recipes/tui/tlc/source/src/keymap/mod.rs +++ b/local/recipes/tui/tlc/source/src/keymap/mod.rs @@ -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] diff --git a/local/recipes/tui/tlc/source/src/viewer/text.rs b/local/recipes/tui/tlc/source/src/viewer/text.rs index 90f5941046..49895f5308 100644 --- a/local/recipes/tui/tlc/source/src/viewer/text.rs +++ b/local/recipes/tui/tlc/source/src/viewer/text.rs @@ -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> { @@ -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();