From 8657c6d45edf2bbd748b592ab21128db4270d4c8 Mon Sep 17 00:00:00 2001 From: vasilito Date: Thu, 18 Jun 2026 17:19:02 +0300 Subject: [PATCH] fix(redox-driver-sys): pin redox_syscall to 0.7 to match base workspace Base fork workspace pins redox_syscall = '0.7.4' (resolves to 0.7.5). Without this pin, redox-driver-sys pulls in 0.8.1, causing type mismatches in downstream crates like driver-graphics that use both 0.7.5 and 0.8.1 types in the same expression. --- .config | 2 - local/recipes/dev/ninja-build/source | 2 +- local/recipes/tui/tlc/source/locales/de.yml | 10 + local/recipes/tui/tlc/source/locales/en.yml | 1 + local/recipes/tui/tlc/source/locales/es.yml | 10 + local/recipes/tui/tlc/source/locales/fr.yml | 10 + local/recipes/tui/tlc/source/locales/ja.yml | 10 + .../recipes/tui/tlc/source/locales/zh-CN.yml | 10 + .../recipes/tui/tlc/source/src/editor/mod.rs | 33 ++- .../tlc/source/src/filemanager/copy_dialog.rs | 139 ++++++++++--- .../tui/tlc/source/src/filemanager/help.rs | 62 +++--- .../tui/tlc/source/src/filemanager/link.rs | 124 ++++++++++-- .../tui/tlc/source/src/filemanager/menubar.rs | 104 ++++++++-- .../tui/tlc/source/src/filemanager/mod.rs | 191 +++++++++++++++--- .../tlc/source/src/filemanager/move_dialog.rs | 139 ++++++++++--- .../source/src/filemanager/quickcd_dialog.rs | 4 +- local/recipes/tui/tlc/source/src/ops/copy.rs | 128 +++++++++--- .../recipes/tui/tlc/source/src/ops/move_op.rs | 24 ++- .../tui/tlc/source/src/terminal/color.rs | 102 +++++----- .../tui/tlc/source/src/terminal/mod.rs | 2 + .../tui/tlc/source/src/terminal/popup.rs | 70 +++++-- .../tui/tlc/source/src/terminal/status.rs | 16 +- .../recipes/tui/tlc/source/src/viewer/hex.rs | 31 ++- .../recipes/tui/tlc/source/src/viewer/mod.rs | 23 ++- .../recipes/tui/tlc/source/src/viewer/text.rs | 26 ++- .../tui/tlc/source/src/widget/button.rs | 87 ++++++-- .../tui/tlc/source/src/widget/dialog.rs | 45 +++-- 27 files changed, 1093 insertions(+), 312 deletions(-) diff --git a/.config b/.config index fc90bc353a..aa9b67ccc0 100644 --- a/.config +++ b/.config @@ -1,3 +1 @@ PODMAN_BUILD?=0 - -REDBEAR_RELEASE?=0.1.0 diff --git a/local/recipes/dev/ninja-build/source b/local/recipes/dev/ninja-build/source index 79feac0f3e..26f6155f0f 160000 --- a/local/recipes/dev/ninja-build/source +++ b/local/recipes/dev/ninja-build/source @@ -1 +1 @@ -Subproject commit 79feac0f3e3bc9da9effc586cd5fea41e7550051 +Subproject commit 26f6155f0f4ece0dec2a03efdae7834cddac726b diff --git a/local/recipes/tui/tlc/source/locales/de.yml b/local/recipes/tui/tlc/source/locales/de.yml index 6e7248f5b5..81f5f08ffe 100644 --- a/local/recipes/tui/tlc/source/locales/de.yml +++ b/local/recipes/tui/tlc/source/locales/de.yml @@ -46,10 +46,19 @@ dialog_title_goto_col: "Zur Spalte" dialog_title_replace: "Ersetzen" dialog_title_save_confirm: "Ungespeicherte Änderungen" dialog_title_reload: "Neu laden" +dialog_title_quickcd: "Quick cd" dialog_label_copy_to: "Kopieren nach" dialog_label_move_to: "Verschieben nach" dialog_label_link_to: "Link nach" dialog_label_new_directory: "Neues Verzeichnis" +dialog_label_source_mask: "Quellmaske" +dialog_label_using_shell_patterns: "Shell-Muster verwenden" +dialog_label_follow_links: "Links folgen" +dialog_label_preserve_attributes: "Attribute bewahren" +dialog_label_dive_into_subdirs: "In Unterverzeichnis wechseln, falls vorhanden" +dialog_label_stable_symlinks: "Stabile Symlinks" +dialog_label_from: "Von:" +dialog_label_items: "Einträge:" dialog_label_find: "Suchen" dialog_label_filter: "Filter" dialog_label_gid: "GID" @@ -74,6 +83,7 @@ dialog_action_prev: "zurück" dialog_action_search: "suchen" dialog_action_yes: "ja" dialog_action_no: "nein" +dialog_action_ok: "OK" error_not_implemented: "Nicht implementiert" status_copied: "%{count} Elemente nach %{dest} kopiert" status_moved: "%{count} Elemente nach %{dest} verschoben" diff --git a/local/recipes/tui/tlc/source/locales/en.yml b/local/recipes/tui/tlc/source/locales/en.yml index 692bff180d..7ed61d449c 100644 --- a/local/recipes/tui/tlc/source/locales/en.yml +++ b/local/recipes/tui/tlc/source/locales/en.yml @@ -69,6 +69,7 @@ dialog_label_search: "Search" dialog_action_create: "create" dialog_action_cancel: "cancel" dialog_action_confirm: "confirm" +dialog_action_ok: "OK" dialog_action_apply: "apply" dialog_action_save: "save" dialog_action_close: "close" diff --git a/local/recipes/tui/tlc/source/locales/es.yml b/local/recipes/tui/tlc/source/locales/es.yml index b0d92eec6a..a4273a552e 100644 --- a/local/recipes/tui/tlc/source/locales/es.yml +++ b/local/recipes/tui/tlc/source/locales/es.yml @@ -46,10 +46,19 @@ dialog_title_goto_col: "Ir a la columna" dialog_title_replace: "Reemplazar" dialog_title_save_confirm: "Cambios sin guardar" dialog_title_reload: "Recargar" +dialog_title_quickcd: "Quick cd" dialog_label_copy_to: "Copiar a" dialog_label_move_to: "Mover a" dialog_label_link_to: "Enlace a" dialog_label_new_directory: "Nuevo directorio" +dialog_label_source_mask: "Máscara de origen" +dialog_label_using_shell_patterns: "Usar patrones de shell" +dialog_label_follow_links: "Seguir enlaces" +dialog_label_preserve_attributes: "Conservar atributos" +dialog_label_dive_into_subdirs: "Entrar en subdirectorio si existe" +dialog_label_stable_symlinks: "Enlaces simbólicos estables" +dialog_label_from: "Desde:" +dialog_label_items: "Elementos:" dialog_label_find: "Buscar" dialog_label_filter: "Filtro" dialog_label_gid: "GID" @@ -74,6 +83,7 @@ dialog_action_prev: "anterior" dialog_action_search: "buscar" dialog_action_yes: "sí" dialog_action_no: "no" +dialog_action_ok: "OK" error_not_implemented: "No implementado" status_copied: "Copiados %{count} elementos a %{dest}" status_moved: "Movidos %{count} elementos a %{dest}" diff --git a/local/recipes/tui/tlc/source/locales/fr.yml b/local/recipes/tui/tlc/source/locales/fr.yml index e7e53b4913..460cf534e2 100644 --- a/local/recipes/tui/tlc/source/locales/fr.yml +++ b/local/recipes/tui/tlc/source/locales/fr.yml @@ -46,10 +46,19 @@ dialog_title_goto_col: "Aller à la colonne" dialog_title_replace: "Remplacer" dialog_title_save_confirm: "Modifications non enregistrées" dialog_title_reload: "Recharger" +dialog_title_quickcd: "Quick cd" dialog_label_copy_to: "Copier vers" dialog_label_move_to: "Déplacer vers" dialog_label_link_to: "Lien vers" dialog_label_new_directory: "Nouveau répertoire" +dialog_label_source_mask: "Masque source" +dialog_label_using_shell_patterns: "Utiliser les motifs shell" +dialog_label_follow_links: "Suivre les liens" +dialog_label_preserve_attributes: "Préserver les attributs" +dialog_label_dive_into_subdirs: "Entrer dans le sous-répertoire s'il existe" +dialog_label_stable_symlinks: "Liens symboliques stables" +dialog_label_from: "Depuis :" +dialog_label_items: "Éléments :" dialog_label_find: "Rechercher" dialog_label_filter: "Filtre" dialog_label_gid: "GID" @@ -74,6 +83,7 @@ dialog_action_prev: "précédent" dialog_action_search: "rechercher" dialog_action_yes: "oui" dialog_action_no: "non" +dialog_action_ok: "OK" error_not_implemented: "Non implémenté" status_copied: "%{count} éléments copiés vers %{dest}" status_moved: "%{count} éléments déplacés vers %{dest}" diff --git a/local/recipes/tui/tlc/source/locales/ja.yml b/local/recipes/tui/tlc/source/locales/ja.yml index 23eb87c9b7..95ee156e13 100644 --- a/local/recipes/tui/tlc/source/locales/ja.yml +++ b/local/recipes/tui/tlc/source/locales/ja.yml @@ -46,10 +46,19 @@ dialog_title_goto_col: "列へ移動" dialog_title_replace: "置換" dialog_title_save_confirm: "未保存の変更" dialog_title_reload: "再読込" +dialog_title_quickcd: "Quick cd" dialog_label_copy_to: "コピー先" dialog_label_move_to: "移動先" dialog_label_link_to: "リンク先" dialog_label_new_directory: "新しいディレクトリ" +dialog_label_source_mask: "ソースマスク" +dialog_label_using_shell_patterns: "シェルパターンを使用" +dialog_label_follow_links: "リンク先をたどる" +dialog_label_preserve_attributes: "属性を保持" +dialog_label_dive_into_subdirs: "存在する場合はサブディレクトリに入る" +dialog_label_stable_symlinks: "安定したシンボリックリンク" +dialog_label_from: "From:" +dialog_label_items: "項目:" dialog_label_find: "検索" dialog_label_filter: "フィルター" dialog_label_gid: "GID" @@ -74,6 +83,7 @@ dialog_action_prev: "前へ" dialog_action_search: "検索" dialog_action_yes: "はい" dialog_action_no: "いいえ" +dialog_action_ok: "OK" error_not_implemented: "実装されていません" status_copied: "%{count} 件のアイテムを %{dest} にコピーしました" status_moved: "%{count} 件のアイテムを %{dest} に移動しました" diff --git a/local/recipes/tui/tlc/source/locales/zh-CN.yml b/local/recipes/tui/tlc/source/locales/zh-CN.yml index 1212339e9b..3d27520892 100644 --- a/local/recipes/tui/tlc/source/locales/zh-CN.yml +++ b/local/recipes/tui/tlc/source/locales/zh-CN.yml @@ -46,10 +46,19 @@ dialog_title_goto_col: "跳到列" dialog_title_replace: "替换" dialog_title_save_confirm: "未保存的更改" dialog_title_reload: "重新加载" +dialog_title_quickcd: "Quick cd" dialog_label_copy_to: "复制到" dialog_label_move_to: "移动到" dialog_label_link_to: "链接到" dialog_label_new_directory: "新目录" +dialog_label_source_mask: "源掩码" +dialog_label_using_shell_patterns: "使用 shell 模式" +dialog_label_follow_links: "跟随链接" +dialog_label_preserve_attributes: "保留属性" +dialog_label_dive_into_subdirs: "若存在则进入子目录" +dialog_label_stable_symlinks: "稳定符号链接" +dialog_label_from: "From:" +dialog_label_items: "项目:" dialog_label_find: "查找" dialog_label_filter: "过滤" dialog_label_gid: "GID" @@ -74,6 +83,7 @@ dialog_action_prev: "上一个" dialog_action_search: "搜索" dialog_action_yes: "是" dialog_action_no: "否" +dialog_action_ok: "OK" error_not_implemented: "未实现" status_copied: "已复制 %{count} 个项目到 %{dest}" status_moved: "已移动 %{count} 个项目到 %{dest}" diff --git a/local/recipes/tui/tlc/source/src/editor/mod.rs b/local/recipes/tui/tlc/source/src/editor/mod.rs index 114b5ff824..f967190cfa 100644 --- a/local/recipes/tui/tlc/source/src/editor/mod.rs +++ b/local/recipes/tui/tlc/source/src/editor/mod.rs @@ -34,6 +34,7 @@ use ratatui::Frame; use crate::key::{Key, Modifiers}; use crate::terminal::color::Theme; +use crate::terminal::mc_skin; use crate::terminal::popup::{centered_percent_rect, render_popup}; pub mod bookmark; @@ -785,16 +786,28 @@ impl Editor { // (e.g. commit_prompt's GotoLine/GotoCol) changed the // buffer cursor without calling cursor.set_position(). self.cursor.set_position(self.buffer.cursor(), &self.buffer); + let editor_default = mc_skin::color_pair(theme.name, "editor", "_default_"); + let editor_marked = mc_skin::color_pair(theme.name, "editor", "editmarked"); + let editor_linestate = mc_skin::color_pair(theme.name, "editor", "editlinestate"); + let editor_frameactive = mc_skin::color_pair(theme.name, "editor", "editframeactive"); + let body_fg = editor_default.map(|p| p.fg).unwrap_or(theme.foreground); + let body_bg = editor_default.map(|p| p.bg).unwrap_or(theme.background); + let marked_fg = editor_marked.map(|p| p.fg).unwrap_or(theme.marked_fg); + let marked_bg = editor_marked.map(|p| p.bg).unwrap_or(theme.marked_bg); + let linestate_fg = editor_linestate.map(|p| p.fg).unwrap_or(theme.cursor_fg); + let linestate_bg = editor_linestate.map(|p| p.bg).unwrap_or(theme.title_bg); + let frame_fg = editor_frameactive.map(|p| p.fg).unwrap_or(theme.title_fg); // Block + title. let block = Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(theme.title_fg)) + .border_style(Style::default().fg(frame_fg).bg(body_bg)) + .style(Style::default().fg(body_fg).bg(body_bg)) .title(Span::styled( self.title.clone(), Style::default() - .fg(theme.title_fg) - .bg(theme.title_bg) + .fg(frame_fg) + .bg(body_bg) .add_modifier(Modifier::BOLD), )); let inner = block.inner(area); @@ -823,7 +836,7 @@ impl Editor { let n = top + row + 1; Line::from(Span::styled( format!("{:>w$} ", n, w = (gutter_w - 1) as usize), - Style::default().fg(theme.hidden), + Style::default().fg(frame_fg).bg(body_bg), )) }) .collect(); @@ -839,7 +852,7 @@ impl Editor { if line_idx >= line_count { body_lines.push(Line::from(Span::styled( "~", - Style::default().fg(theme.hidden), + Style::default().fg(frame_fg).bg(body_bg), ))); continue; } @@ -848,17 +861,17 @@ impl Editor { let line_end = (off + len).min(full_text.len()); let line_text = full_text.get(off..line_end).unwrap_or(""); let base_style = if cursor_line == line_idx { - Style::default().fg(theme.warning) + Style::default().fg(linestate_fg).bg(body_bg) } else { - Style::default().fg(theme.foreground) + Style::default().fg(body_fg).bg(body_bg) }; if let Some((ss, se)) = sel { if ss < line_end && se > off { let rs = ss.saturating_sub(off); let re = (se - off).min(len); let sel_style = Style::default() - .fg(theme.marked_fg) - .bg(theme.marked_bg); + .fg(marked_fg) + .bg(marked_bg); let mut spans: Vec = Vec::new(); if rs > 0 { if let Some(b) = line_text.get(..rs) { @@ -930,7 +943,7 @@ impl Editor { let status_y = area.y + area.height - 1; let status = Paragraph::new(Line::from(Span::styled( self.status_string(), - Style::default().fg(theme.cursor_fg).bg(theme.title_bg), + Style::default().fg(linestate_fg).bg(linestate_bg), ))); frame.render_widget(status, Rect::new(area.x, status_y, 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 89ee6492bd..95e63d8e06 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/copy_dialog.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/copy_dialog.rs @@ -33,6 +33,16 @@ pub struct CopyDialog { pub marked_count: usize, /// Text input for the destination. pub dst_input: Input, + /// Whether to preserve file attributes. + pub preserve_attributes: bool, + /// Whether to follow symlinks. + pub follow_links: bool, + /// Whether to dive into subdirs if they exist. + pub dive_into_subdirs: bool, + /// Whether to keep stable symlinks. + pub stable_symlinks: bool, + /// Which control has focus: 0 = dst, 1 = follow links, 2 = preserve attrs. + pub focused: usize, /// True after Enter confirms. pub confirmed: bool, /// True after Esc cancels. @@ -72,6 +82,11 @@ impl CopyDialog { src, marked_count: count, 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, @@ -129,13 +144,35 @@ impl CopyDialog { self.cancelled = true; true } + k if k == Key::TAB => { + self.focused = (self.focused + 1) % 3; + true + } + k if k.code == Key::TAB.code && k.mods.contains(crate::key::Modifiers::SHIFT) => { + self.focused = (self.focused + 2) % 3; + true + } + k if k == Key::from_char(' ') && self.focused > 0 => { + match self.focused { + 1 => self.follow_links = !self.follow_links, + 2 => self.preserve_attributes = !self.preserve_attributes, + _ => {} + } + true + } Key::ENTER => { if self.validate().is_ok() { self.confirmed = true; } true } - _ => self.dst_input.handle_key(key), + _ => { + if self.focused == 0 { + self.dst_input.handle_key(key) + } else { + false + } + } } } @@ -144,7 +181,7 @@ impl CopyDialog { /// `theme` supplies the title, header, and hint colours so the /// dialog follows the active skin. pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { - let popup = mc_copy_move_rect(area, 13); + let popup = mc_copy_move_rect(area, 15); let inner = render_popup(frame, popup, crate::locale::t("dialog_title_copy"), theme); let chunks = Layout::default() @@ -153,6 +190,7 @@ impl CopyDialog { Constraint::Length(1), // from Constraint::Length(1), // mask Constraint::Length(1), // shell patterns + Constraint::Length(1), // to Constraint::Length(3), // input Constraint::Length(4), // options Constraint::Length(1), // buttons @@ -187,69 +225,100 @@ impl CopyDialog { Paragraph::new(Line::from(vec![ Span::styled( format!("{}: ", crate::locale::t("dialog_label_source_mask")), - Style::default().fg(theme.hidden), + Style::default().fg(theme.foreground), ), - Span::styled("*", Style::default().fg(theme.foreground)), + Span::styled("*", Style::default().fg(theme.warning)), ])), chunks[1], ); frame.render_widget( Paragraph::new(Line::from(Span::styled( format!("[ ] {}", crate::locale::t("dialog_label_using_shell_patterns")), - Style::default().fg(theme.hidden), + disabled_style(theme), ))), chunks[2], ); + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + crate::locale::t("dialog_label_copy_to"), + Style::default().fg(theme.foreground), + ))), + chunks[3], + ); - let mut input = Input::new() - .label(crate::locale::t("dialog_label_copy_to")) - .text(self.dst_input.value().to_string()); - input = input.focused(); - input.render(frame, chunks[3], theme); + let mut input = Input::new().text(self.dst_input.value().to_string()); + if self.focused == 0 { + input = input.focused(); + } + input.render(frame, chunks[4], theme); let opts = vec![ Line::from(Span::styled( - format!("[ ] {}", crate::locale::t("dialog_label_follow_links")), - Style::default().fg(theme.hidden), + format!( + "{} {}", + if self.follow_links { "[x]" } else { "[ ]" }, + crate::locale::t("dialog_label_follow_links") + ), + option_style(self.focused == 1, theme), )), Line::from(Span::styled( - format!("[ ] {}", crate::locale::t("dialog_label_preserve_attributes")), - Style::default().fg(theme.hidden), + format!( + "{} {}", + if self.preserve_attributes { "[x]" } else { "[ ]" }, + crate::locale::t("dialog_label_preserve_attributes") + ), + option_style(self.focused == 2, theme), )), Line::from(Span::styled( - format!("[ ] {}", crate::locale::t("dialog_label_dive_into_subdirs")), - Style::default().fg(theme.hidden), + format!( + "{} {}", + if self.dive_into_subdirs { "[x]" } else { "[ ]" }, + crate::locale::t("dialog_label_dive_into_subdirs") + ), + disabled_style(theme), )), Line::from(Span::styled( format!("[ ] {}", crate::locale::t("dialog_label_stable_symlinks")), - Style::default().fg(theme.hidden), + disabled_style(theme), )), ]; - frame.render_widget(Paragraph::new(opts), chunks[4]); + frame.render_widget(Paragraph::new(opts), chunks[5]); render_button_row( frame, - chunks[5], + chunks[6], theme, - &crate::locale::t("dialog_action_confirm"), + &crate::locale::t("dialog_action_ok"), &crate::locale::t("dialog_action_cancel"), ); let hint = Line::from(vec![ Span::styled("Enter", Style::default().fg(theme.warning)), Span::styled( - format!(" {} ", crate::locale::t("dialog_action_confirm")), - Style::default().fg(theme.hidden), + format!(" {} ", crate::locale::t("dialog_action_ok")), + Style::default().fg(theme.foreground), ), Span::styled("Esc", Style::default().fg(theme.warning)), Span::styled( format!(" {}", crate::locale::t("dialog_action_cancel")), - Style::default().fg(theme.hidden), + Style::default().fg(theme.foreground), ), ]); - frame.render_widget(Paragraph::new(hint).wrap(Wrap { trim: false }), chunks[6]); + frame.render_widget(Paragraph::new(hint).wrap(Wrap { trim: false }), chunks[7]); } } +fn option_style(focused: bool, theme: &Theme) -> Style { + if focused { + Style::default().fg(theme.cursor_fg).bg(theme.cursor_bg) + } else { + Style::default().fg(theme.foreground) + } +} + +fn disabled_style(theme: &Theme) -> Style { + Style::default().fg(theme.hidden) +} + #[cfg(test)] mod tests { use super::*; @@ -308,4 +377,26 @@ mod tests { d.dst_input = Input::new().text("/var/bad\0name"); assert!(d.validate().is_err()); } + + #[test] + fn tab_and_space_toggle_preserve_attributes() { + let mut d = CopyDialog::new(vec![std::path::PathBuf::from("/tmp/a")]); + assert!(d.preserve_attributes); + assert!(d.handle_key(Key::TAB)); + assert!(d.handle_key(Key::TAB)); + assert!(d.handle_key(Key::from_char(' '))); + assert!(!d.preserve_attributes); + } + + #[test] + fn tab_cycles_only_really_supported_controls() { + let mut d = CopyDialog::new(vec![std::path::PathBuf::from("/tmp/a")]); + assert_eq!(d.focused, 0); + assert!(d.handle_key(Key::TAB)); + assert_eq!(d.focused, 1); + assert!(d.handle_key(Key::TAB)); + assert_eq!(d.focused, 2); + assert!(d.handle_key(Key::TAB)); + assert_eq!(d.focused, 0); + } } diff --git a/local/recipes/tui/tlc/source/src/filemanager/help.rs b/local/recipes/tui/tlc/source/src/filemanager/help.rs index 68f38d6ebf..d21febf761 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/help.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/help.rs @@ -23,6 +23,7 @@ use ratatui::Frame; use crate::key::Key; use crate::keymap::default_keymap; use crate::terminal::color::Theme; +use crate::terminal::mc_skin; use crate::terminal::popup::{centered_percent_rect, render_popup}; /// A single binding shown in the help list: the key description @@ -311,6 +312,18 @@ impl HelpDialog { pub fn render(&self, frame: &mut Frame, area: Rect) { let popup = centered_percent_rect(area, self.width_pct, self.height_pct); let inner = render_popup(frame, popup, self.title.clone(), &self.theme); + let help_default = mc_skin::color_pair(self.theme.name, "help", "_default_"); + let help_link = mc_skin::color_pair(self.theme.name, "help", "helplink"); + let help_slink = mc_skin::color_pair(self.theme.name, "help", "helpslink"); + let help_title = mc_skin::color_pair(self.theme.name, "help", "helptitle"); + let body_fg = help_default.map(|p| p.fg).unwrap_or(self.theme.foreground); + let body_bg = help_default.map(|p| p.bg).unwrap_or(self.theme.background); + let link_fg = help_link.map(|p| p.fg).unwrap_or(self.theme.title_fg); + let link_bg = help_link.map(|p| p.bg).unwrap_or(self.theme.title_bg); + let slink_fg = help_slink.map(|p| p.fg).unwrap_or(self.theme.cursor_fg); + let slink_bg = help_slink.map(|p| p.bg).unwrap_or(self.theme.cursor_bg); + let title_fg = help_title.map(|p| p.fg).unwrap_or(self.theme.title_fg); + let title_bg = help_title.map(|p| p.bg).unwrap_or(self.theme.title_bg); let chunks = Layout::default() .direction(Direction::Vertical) @@ -323,7 +336,7 @@ impl HelpDialog { if self.bindings.is_empty() { let empty = Paragraph::new(Line::from(Span::styled( "(no bindings)", - Style::default().fg(self.theme.foreground), + Style::default().fg(body_fg).bg(body_bg), ))); frame.render_widget(empty, chunks[0]); } else { @@ -339,18 +352,21 @@ impl HelpDialog { .skip(offset) .take(visible) .map(|(i, b)| { - let style = if i == offset { + let style = if i == self.scroll { Style::default() - .fg(self.theme.cursor_fg) - .bg(self.theme.cursor_bg) + .fg(slink_fg) + .bg(slink_bg) .add_modifier(Modifier::BOLD) } else { - Style::default().fg(self.theme.foreground) + Style::default().fg(body_fg).bg(body_bg) }; let line = Line::from(vec![ Span::styled( format!("{:<12}", b.key), - style.add_modifier(Modifier::BOLD), + Style::default() + .fg(link_fg) + .bg(if i == self.scroll { slink_bg } else { link_bg }) + .add_modifier(Modifier::BOLD), ), Span::styled(" ", style), Span::styled(b.command.to_string(), style), @@ -360,8 +376,8 @@ impl HelpDialog { .collect(); let list = List::new(items).style( Style::default() - .fg(self.theme.foreground) - .bg(self.theme.background), + .fg(body_fg) + .bg(body_bg), ); frame.render_widget(list, chunks[0]); } @@ -371,43 +387,43 @@ impl HelpDialog { Span::styled( "Esc", Style::default() - .fg(self.theme.title_fg) - .bg(self.theme.title_bg) + .fg(link_fg) + .bg(link_bg) .add_modifier(Modifier::BOLD), ), - Span::styled("/", Style::default().fg(self.theme.foreground)), + Span::styled("/", Style::default().fg(body_fg).bg(body_bg)), Span::styled( "Enter", Style::default() - .fg(self.theme.title_fg) - .bg(self.theme.title_bg) + .fg(link_fg) + .bg(link_bg) .add_modifier(Modifier::BOLD), ), - Span::styled("/", Style::default().fg(self.theme.foreground)), + Span::styled("/", Style::default().fg(body_fg).bg(body_bg)), Span::styled( "Space", Style::default() - .fg(self.theme.title_fg) - .bg(self.theme.title_bg) + .fg(link_fg) + .bg(link_bg) .add_modifier(Modifier::BOLD), ), - Span::styled("/", Style::default().fg(self.theme.foreground)), + Span::styled("/", Style::default().fg(body_fg).bg(body_bg)), Span::styled( "q", Style::default() - .fg(self.theme.title_fg) - .bg(self.theme.title_bg) + .fg(link_fg) + .bg(link_bg) .add_modifier(Modifier::BOLD), ), - Span::styled(" to close · ", Style::default().fg(self.theme.foreground)), + Span::styled(" to close · ", Style::default().fg(body_fg).bg(body_bg)), Span::styled( "Up/Down", Style::default() - .fg(self.theme.title_fg) - .bg(self.theme.title_bg) + .fg(title_fg) + .bg(title_bg) .add_modifier(Modifier::BOLD), ), - Span::styled(" scroll", Style::default().fg(self.theme.foreground)), + Span::styled(" scroll", Style::default().fg(body_fg).bg(body_bg)), ]); frame.render_widget(Paragraph::new(hint), chunks[1]); } diff --git a/local/recipes/tui/tlc/source/src/filemanager/link.rs b/local/recipes/tui/tlc/source/src/filemanager/link.rs index 429bcabc46..23f1c45e6d 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/link.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/link.rs @@ -47,6 +47,8 @@ impl LinkKind { pub struct LinkDialog { /// The source path (the file we're linking from). pub src: PathBuf, + /// Optional editable source input (used for symlink mode). + pub src_input: Option, /// The destination path input. pub dst_input: Input, /// Which kind of link to create. @@ -59,6 +61,8 @@ pub struct LinkDialog { pub width_pct: f32, /// Height as a fraction of the parent area. pub height_pct: f32, + /// Focused field index for multi-input mode. + pub focused: usize, } impl LinkDialog { @@ -74,18 +78,32 @@ impl LinkDialog { #[must_use] pub fn with_kind(src: PathBuf, kind: LinkKind) -> Self { let default_dst = default_dst_for(&src, kind); - let input = Input::new() - .label("Link to") + let dst_input = Input::new() + .label(match kind { + LinkKind::Hard => "Link to", + LinkKind::Sym => "Symbolic link filename", + }) .placeholder(default_dst.to_string_lossy().as_ref()) .text(default_dst.to_string_lossy().into_owned()); + let src_input = match kind { + LinkKind::Hard => None, + LinkKind::Sym => Some( + Input::new() + .label("Existing filename") + .text(src.to_string_lossy().into_owned()) + .focused(), + ), + }; Self { src, - dst_input: input, + src_input, + dst_input, kind, confirmed: false, cancelled: false, width_pct: 0.6, height_pct: 0.3, + focused: 0, } } @@ -100,15 +118,20 @@ impl LinkDialog { /// The result of the dialog: `Some(target)` on confirm, /// `None` if cancelled or still in progress. #[must_use] - pub fn result(&self) -> Option { + pub fn result(&self) -> Option<(PathBuf, PathBuf)> { if !self.confirmed { return None; } - let s = self.dst_input.value(); - if s.is_empty() { + let target = self.dst_input.value(); + if target.is_empty() { return None; } - Some(PathBuf::from(s)) + let source = match (&self.kind, &self.src_input) { + (LinkKind::Hard, _) => self.src.clone(), + (LinkKind::Sym, Some(input)) if !input.value().is_empty() => PathBuf::from(input.value()), + (LinkKind::Sym, _) => return None, + }; + Some((source, PathBuf::from(target))) } /// True if the dialog was cancelled. @@ -121,14 +144,24 @@ impl LinkDialog { /// Returns `Ok(())` if the destination is acceptable, otherwise /// `Err(msg)` with a description of what's wrong. pub fn validate(&self) -> Result<(), String> { - let s = self.dst_input.value(); - if s.is_empty() { + let target = self.dst_input.value(); + if target.is_empty() { return Err("destination is empty".to_string()); } - let p = Path::new(s); + let p = Path::new(target); if p.as_os_str().to_string_lossy().contains('\0') { return Err("destination contains NUL byte".to_string()); } + if let Some(src_input) = &self.src_input { + let source = src_input.value(); + if source.is_empty() { + return Err("source is empty".to_string()); + } + let p = Path::new(source); + if p.as_os_str().to_string_lossy().contains('\0') { + return Err("source contains NUL byte".to_string()); + } + } Ok(()) } @@ -139,13 +172,27 @@ impl LinkDialog { self.cancelled = true; true } + Key::TAB => { + if self.kind == LinkKind::Sym { + self.focused = (self.focused + 1) % 2; + return true; + } + false + } Key::ENTER => { if self.validate().is_ok() { self.confirmed = true; } true } - _ => self.dst_input.handle_key(key), + _ => { + if self.kind == LinkKind::Sym && self.focused == 0 { + if let Some(input) = self.src_input.as_mut() { + return input.handle_key(key); + } + } + self.dst_input.handle_key(key) + } } } @@ -154,7 +201,11 @@ impl LinkDialog { /// `theme` supplies the title, header, and hint colours so the /// dialog follows the active skin. pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { - let popup = centered_cols_rect(area, 64, 8); + let popup = centered_cols_rect( + area, + 64, + if self.kind == LinkKind::Sym { 11 } else { 8 }, + ); let inner = render_popup( frame, popup, @@ -169,6 +220,7 @@ impl LinkDialog { .direction(Direction::Vertical) .constraints([ Constraint::Length(1), // header + Constraint::Length(if self.kind == LinkKind::Sym { 3 } else { 0 }), Constraint::Length(3), // input Constraint::Length(1), // buttons Constraint::Min(1), // hint @@ -184,24 +236,42 @@ impl LinkDialog { ]); frame.render_widget(Paragraph::new(header), chunks[0]); + if let Some(src_input) = &self.src_input { + let mut input = Input::new() + .label("Existing filename") + .text(src_input.value().to_string()); + if self.focused == 0 { + input = input.focused(); + } + input.render(frame, chunks[1], theme); + } + let dst_chunk = if self.kind == LinkKind::Sym { chunks[2] } else { chunks[1] }; + let button_chunk = if self.kind == LinkKind::Sym { chunks[3] } else { chunks[2] }; + let hint_chunk = if self.kind == LinkKind::Sym { chunks[4] } else { chunks[3] }; + let value = self.dst_input.value().to_string(); let placeholder = self.dst_input.value().is_empty().then(|| { default_dst_for(&self.src, self.kind) .to_string_lossy() .into_owned() }); - let mut input = crate::widget::input::Input::new().label("Link to"); + let mut input = Input::new().label(match self.kind { + LinkKind::Hard => "Link to", + LinkKind::Sym => "Symbolic link filename", + }); if let Some(ph) = placeholder { input = input.placeholder(ph); } if !value.is_empty() { input = input.text(value); } - input = input.focused(); - input.render(frame, chunks[1], theme); + if self.kind == LinkKind::Hard || self.focused == 1 { + input = input.focused(); + } + input.render(frame, dst_chunk, theme); render_button_row( frame, - chunks[2], + button_chunk, theme, &crate::locale::t("dialog_action_create"), &crate::locale::t("dialog_action_cancel"), @@ -219,7 +289,7 @@ impl LinkDialog { Style::default().fg(theme.hidden), ), ]); - frame.render_widget(Paragraph::new(hint).wrap(Wrap { trim: false }), chunks[3]); + frame.render_widget(Paragraph::new(hint).wrap(Wrap { trim: false }), hint_chunk); } } @@ -249,6 +319,7 @@ mod tests { let d = LinkDialog::new(std::path::PathBuf::from("/tmp/a.txt")); assert_eq!(d.kind, LinkKind::Hard); assert_eq!(d.dst_input.value(), "/tmp/a.txt.lnk"); + assert!(d.src_input.is_none()); assert!(!d.confirmed); assert!(d.result().is_none()); } @@ -257,6 +328,7 @@ mod tests { fn new_symlink_prefills_default_dst() { let d = LinkDialog::with_kind(std::path::PathBuf::from("/x/y"), LinkKind::Sym); assert_eq!(d.kind, LinkKind::Sym); + assert_eq!(d.src_input.as_ref().unwrap().value(), "/x/y"); assert_eq!(d.dst_input.value(), "/x/y.sym"); } @@ -267,7 +339,7 @@ mod tests { let consumed = d.handle_key(Key::ENTER); assert!(consumed); assert!(d.confirmed); - assert_eq!(d.result(), Some(PathBuf::from("/tmp/new"))); + assert_eq!(d.result(), Some((PathBuf::from("/x"), PathBuf::from("/tmp/new")))); } #[test] @@ -296,6 +368,14 @@ mod tests { assert!(d.validate().is_err()); } + #[test] + fn symlink_tab_switches_between_fields() { + let mut d = LinkDialog::with_kind("/x".into(), LinkKind::Sym); + assert_eq!(d.focused, 0); + assert!(d.handle_key(Key::TAB)); + assert_eq!(d.focused, 1); + } + #[test] fn end_to_end_hardlink_via_std() { let dir = std::env::temp_dir().join("tlc-fm-link-hl-test"); @@ -307,8 +387,8 @@ mod tests { d.dst_input = Input::new().text(dst.to_string_lossy().as_ref()); d.handle_key(Key::ENTER); assert!(d.confirmed); - let target = d.result().unwrap(); - crate::ops::link::hardlink(&src, &target).unwrap(); + let (source, target) = d.result().unwrap(); + crate::ops::link::hardlink(&source, &target).unwrap(); assert!(dst.exists()); assert_eq!(fs::read(&dst).unwrap(), b"data"); let _ = fs::remove_dir_all(&dir); @@ -324,8 +404,8 @@ mod tests { let mut d = LinkDialog::with_kind(src.clone(), LinkKind::Sym); d.dst_input = Input::new().text(dst.to_string_lossy().as_ref()); d.handle_key(Key::ENTER); - let target = d.result().unwrap(); - crate::ops::link::symlink(&src, &target).unwrap(); + let (source, target) = d.result().unwrap(); + crate::ops::link::symlink(&source, &target).unwrap(); assert!(fs::symlink_metadata(&dst).unwrap().file_type().is_symlink()); assert_eq!(fs::read_link(&dst).unwrap(), src); let _ = fs::remove_dir_all(&dir); diff --git a/local/recipes/tui/tlc/source/src/filemanager/menubar.rs b/local/recipes/tui/tlc/source/src/filemanager/menubar.rs index 253438463a..b180cf35a9 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/menubar.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/menubar.rs @@ -13,6 +13,7 @@ use ratatui::Frame; use crate::key::Key; use crate::keymap::Cmd; use crate::terminal::color::Theme; +use crate::terminal::mc_skin; /// Result of a menubar key press. #[derive(Debug, Clone)] @@ -227,6 +228,10 @@ impl MenuBar { /// Render the menu bar and dropdown at the top of the screen. pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { + let menu_default = mc_skin::color_pair(theme.name, "menu", "_default_"); + let menu_sel = mc_skin::color_pair(theme.name, "menu", "menusel"); + let menu_hot = mc_skin::color_pair(theme.name, "menu", "menuhot"); + let menu_hot_sel = mc_skin::color_pair(theme.name, "menu", "menuhotsel"); let bar_h = 1u16; let bar_area = Rect::new(0, 0, area.width, bar_h); frame.render_widget(Clear, bar_area); @@ -236,25 +241,60 @@ impl MenuBar { let mut menu_positions: Vec<(u16, u16)> = Vec::new(); for (i, menu) in self.menus.iter().enumerate() { - let title_text = format!(" {} ", menu.title); - let w = title_text.chars().count() as u16; - let style = if i == self.active_menu { - Style::default() - .fg(theme.cursor_fg) - .bg(theme.cursor_bg) - .add_modifier(Modifier::BOLD) + let w = menu.title.chars().count() as u16 + 2; + let is_active = i == self.active_menu; + let pair = if is_active { + menu_sel.unwrap_or(mc_skin::ColorPair { + fg: theme.cursor_fg, + bg: theme.cursor_bg, + }) } else { - Style::default() - .fg(theme.title_fg) - .bg(theme.title_bg) + menu_default.unwrap_or(mc_skin::ColorPair { + fg: theme.title_fg, + bg: theme.title_bg, + }) }; - spans.push(Span::styled(title_text, style)); + let hot_pair = if is_active { + menu_hot_sel.unwrap_or(pair) + } else { + menu_hot.unwrap_or(pair) + }; + spans.push(Span::styled( + " ", + Style::default().fg(pair.fg).bg(pair.bg), + )); + let mut chars = menu.title.chars(); + if let Some(first) = chars.next() { + spans.push(Span::styled( + first.to_string(), + Style::default().fg(hot_pair.fg).bg(hot_pair.bg).add_modifier(Modifier::BOLD), + )); + let rest: String = chars.collect(); + if !rest.is_empty() { + spans.push(Span::styled( + rest, + Style::default().fg(pair.fg).bg(pair.bg).add_modifier(if is_active { + Modifier::BOLD + } else { + Modifier::empty() + }), + )); + } + } + spans.push(Span::styled( + " ", + Style::default().fg(pair.fg).bg(pair.bg), + )); menu_positions.push((x_offset, x_offset + w)); x_offset += w; } spans.push(Span::styled( " ".repeat(area.width as usize - x_offset as usize), - Style::default().bg(theme.title_bg), + if let Some(pair) = menu_default { + Style::default().fg(pair.fg).bg(pair.bg) + } else { + Style::default().bg(theme.title_bg) + }, )); frame.render_widget(Paragraph::new(Line::from(spans)), bar_area); @@ -264,6 +304,9 @@ impl MenuBar { } fn render_dropdown(&self, frame: &mut Frame, area: Rect, theme: &Theme) { + let popup_default = mc_skin::color_pair(theme.name, "popupmenu", "_default_"); + let popup_sel = mc_skin::color_pair(theme.name, "popupmenu", "menusel"); + let popup_title = mc_skin::color_pair(theme.name, "popupmenu", "menutitle"); let menu = &self.menus[self.active_menu]; let max_label_w: u16 = menu .items @@ -291,7 +334,16 @@ impl MenuBar { let block = Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(theme.title_fg)); + .border_style(Style::default().fg(theme.title_fg).bg( + popup_default.map(|p| p.bg).unwrap_or(theme.background) + )) + .title(Span::styled( + format!(" {} ", menu.title), + Style::default() + .fg(popup_title.map(|p| p.fg).unwrap_or(theme.title_fg)) + .bg(popup_title.map(|p| p.bg).unwrap_or(theme.title_bg)) + .add_modifier(Modifier::BOLD), + )); let inner = block.inner(dropdown_area); frame.render_widget(block, dropdown_area); @@ -303,14 +355,24 @@ impl MenuBar { for (idx, item) in menu.items.iter().enumerate() { let is_sel = idx == self.selected_item; let style = if item.is_separator() { - Style::default().fg(theme.border) - } else if is_sel { Style::default() - .fg(theme.cursor_fg) - .bg(theme.cursor_bg) - .add_modifier(Modifier::BOLD) + .fg(theme.border) + .bg(popup_default.map(|p| p.bg).unwrap_or(theme.background)) + } else if is_sel { + if let Some(pair) = popup_sel.or(menu_default_select(theme)) { + Style::default().fg(pair.fg).bg(pair.bg).add_modifier(Modifier::BOLD) + } else { + Style::default() + .fg(theme.cursor_fg) + .bg(theme.cursor_bg) + .add_modifier(Modifier::BOLD) + } } else { - Style::default().fg(theme.foreground).bg(theme.background) + if let Some(pair) = popup_default { + Style::default().fg(pair.fg).bg(pair.bg) + } else { + Style::default().fg(theme.foreground).bg(theme.background) + } }; let display = if item.is_separator() { @@ -328,6 +390,10 @@ impl MenuBar { } } +fn menu_default_select(theme: &Theme) -> Option { + mc_skin::color_pair(theme.name, "menu", "menusel") +} + impl Default for MenuBar { fn default() -> Self { Self::new() diff --git a/local/recipes/tui/tlc/source/src/filemanager/mod.rs b/local/recipes/tui/tlc/source/src/filemanager/mod.rs index 099c205617..bbbf27a19a 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/mod.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/mod.rs @@ -47,6 +47,7 @@ use crate::config::{Config, FilemanagerConfig}; use crate::key::Key; use crate::keymap::Cmd; use crate::terminal::color::Theme; +use crate::terminal::mc_skin; use crate::terminal::status::StatusLine; use self::config_dialog::ConfigDialog; @@ -175,6 +176,10 @@ pub struct PendingFileOp { pub sources: Vec, /// Destination directory. pub dst: PathBuf, + /// Preserve file attributes during copy / cross-device move fallback. + pub preserve_attributes: bool, + /// Follow symlinks during copy / cross-device move fallback. + pub follow_links: bool, } /// A modal dialog currently displayed on top of the panels. @@ -530,8 +535,14 @@ impl FileManager { Ok(true) } Cmd::QuickCd => { + let history: Vec = self + .active_panel() + .history_paths() + .iter() + .map(|p| p.display().to_string()) + .collect(); self.dialog = Some(DialogState::QuickCd(Box::new( - quickcd_dialog::QuickCdDialog::new(&[]), + quickcd_dialog::QuickCdDialog::new(&history), ))); Ok(true) } @@ -1436,8 +1447,7 @@ impl FileManager { } } Some(DialogState::Link(d)) => { - if let Some(target) = d.result() { - let src = d.src.clone(); + if let Some((src, target)) = d.result() { let kind = d.kind; let r = match kind { LinkKind::Hard => crate::ops::link::hardlink(&src, &target), @@ -1482,12 +1492,21 @@ 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) { + match crate::ops::copy::copy_many( + &sources, + &dst, + &handle, + false, + preserve_attributes, + follow_links, + ) { Ok(()) => { self.status.set_message(format!( "Copied {} item(s) to {}", @@ -1502,6 +1521,8 @@ impl FileManager { is_move: false, sources, dst, + preserve_attributes, + follow_links, }); self.dialog = Some(DialogState::Overwrite(Box::new( overwrite_dialog::OverwriteDialog::new_copy( @@ -1518,12 +1539,21 @@ 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) { + 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 {}", @@ -1538,6 +1568,8 @@ impl FileManager { is_move: true, sources, dst, + preserve_attributes, + follow_links, }); self.dialog = Some(DialogState::Overwrite(Box::new( overwrite_dialog::OverwriteDialog::new_move( @@ -1617,6 +1649,8 @@ impl FileManager { &op.dst, &handle, true, + op.preserve_attributes, + op.follow_links, ) } else { crate::ops::copy::copy_many( @@ -1624,6 +1658,8 @@ impl FileManager { &op.dst, &handle, true, + op.preserve_attributes, + op.follow_links, ) }; match result { @@ -1743,6 +1779,7 @@ impl FileManager { .split(area); self.render_panels(frame, chunks[0]); + let (left_panel_area, right_panel_area) = self.panel_areas(chunks[0]); if self.cmdline.is_active() { self.cmdline.render_inline(frame, chunks[1], &self.theme); } else if self.status.has_message() { @@ -1773,7 +1810,14 @@ impl FileManager { DialogState::Quit(d) => d.render(frame, area, &self.theme), DialogState::SelectGroup(d) => d.render(frame, area, &self.theme), DialogState::UnselectGroup(d) => d.render(frame, area, &self.theme), - DialogState::QuickCd(d) => d.render(frame, area, &self.theme), + DialogState::QuickCd(d) => d.render( + frame, + match self.active { + Active::Left => left_panel_area, + Active::Right => right_panel_area, + }, + &self.theme, + ), DialogState::Overwrite(d) => d.render(frame, area, &self.theme), DialogState::Layout(d) => d.render(frame, area, &self.theme), DialogState::PanelOptions(d) => d.render(frame, area, &self.theme), @@ -1782,8 +1826,8 @@ impl FileManager { } } - fn render_panels(&mut self, frame: &mut Frame, area: Rect) { - let (left_a, right_a) = match self.layout { + fn panel_areas(&self, area: Rect) -> (Rect, Rect) { + match self.layout { LayoutMode::Horizontal => { let chunks = Layout::default() .direction(Direction::Horizontal) @@ -1798,7 +1842,11 @@ impl FileManager { .split(area); (chunks[0], chunks[1]) } - }; + } + } + + fn render_panels(&mut self, frame: &mut Frame, area: Rect) { + let (left_a, right_a) = self.panel_areas(area); // Adjust each panel's scroll so the cursor is visible. let left_rows = panel_body_rows(left_a); let right_rows = panel_body_rows(right_a); @@ -1810,6 +1858,12 @@ impl FileManager { /// Render the function-key bar (bottom row): 1 Help 2 Menu 3 View ... fn render_buttonbar(&self, frame: &mut Frame, area: Rect) { + let hotkey_pair = mc_skin::color_pair(self.theme.name, "buttonbar", "hotkey"); + let button_pair = mc_skin::color_pair(self.theme.name, "buttonbar", "button"); + let bar_bg = button_pair.map(|p| p.bg).unwrap_or(self.theme.buttonbar_bg); + let bar_fg = button_pair.map(|p| p.fg).unwrap_or(self.theme.buttonbar_fg); + let hot_fg = hotkey_pair.map(|p| p.fg).unwrap_or(self.theme.buttonbar_bg); + let hot_bg = hotkey_pair.map(|p| p.bg).unwrap_or(self.theme.buttonbar_fg); let labels = [ ("1", "Help"), ("2", "Menu"), @@ -1822,20 +1876,25 @@ impl FileManager { ("9", "Menu"), ("10", "Quit"), ]; + frame.render_widget( + Paragraph::new("") + .style(Style::default().bg(bar_bg).fg(bar_fg)), + area, + ); let mut spans: Vec = Vec::new(); for (num, label) in &labels { spans.push(Span::styled( *num, Style::default() - .fg(self.theme.buttonbar_bg) - .bg(self.theme.buttonbar_fg) + .fg(hot_fg) + .bg(hot_bg) .add_modifier(Modifier::BOLD), )); spans.push(Span::styled( format!(" {} ", label), Style::default() - .fg(self.theme.buttonbar_fg) - .bg(self.theme.buttonbar_bg), + .fg(bar_fg) + .bg(bar_bg), )); } frame.render_widget( @@ -1845,19 +1904,30 @@ impl FileManager { } fn render_panel(&self, frame: &mut Frame, area: Rect, panel: &Panel, active: bool) { + let core_default = mc_skin::color_pair(self.theme.name, "core", "_default_"); + let core_header = mc_skin::color_pair(self.theme.name, "core", "header"); + let core_disabled = mc_skin::color_pair(self.theme.name, "core", "disabled"); + let panel_bg = core_default.map(|p| p.bg).unwrap_or(self.theme.background); + let panel_fg = core_default.map(|p| p.fg).unwrap_or(self.theme.foreground); + let header_fg = core_header.map(|p| p.fg).unwrap_or(self.theme.title_fg); + let header_bg = core_header + .and_then(|p| if p.bg == ratatui::style::Color::Reset { None } else { Some(p.bg) }) + .unwrap_or(self.theme.title_bg); + let disabled_fg = core_disabled.map(|p| p.fg).unwrap_or(self.theme.hidden); let title = format!(" {} ", path_short(panel.path())); let block = Block::default() .borders(Borders::ALL) .border_style(if active { - Style::default().fg(self.theme.title_fg) + Style::default().fg(header_fg).bg(panel_bg) } else { - Style::default().fg(self.theme.border) + Style::default().fg(self.theme.border).bg(panel_bg) }) + .style(Style::default().bg(panel_bg).fg(panel_fg)) .title(Span::styled( title, Style::default() - .fg(self.theme.title_fg) - .bg(self.theme.title_bg) + .fg(header_fg) + .bg(header_bg) .add_modifier(Modifier::BOLD), )); let inner = block.inner(area); @@ -1880,12 +1950,13 @@ impl FileManager { let top = panel.top(); let cursor = panel.cursor(); let mode = panel.listing_mode(); + let line_width = inner.width.saturating_sub(1) as usize; if show_meta { frame.render_widget( Paragraph::new(Span::styled( panel_meta_text(panel), - Style::default().fg(self.theme.hidden), + Style::default().fg(disabled_fg).bg(panel_bg), )), Rect::new(inner.x, inner.y, inner.width, 1), ); @@ -1901,7 +1972,7 @@ impl FileManager { .any(|n| n == &entry.name); let style = entry_style(entry, active, idx == cursor, is_marked, &self.theme); let prefix = if is_marked { "*" } else { " " }; - let display = format!("{prefix}{}", format_line_mode(entry, mode, inner.width as usize)); + let display = format!("{prefix}{}", format_line_mode(entry, mode, line_width)); ListItem::new(Span::styled(display, style)) } else { ListItem::new(Span::raw("")) @@ -1910,8 +1981,8 @@ impl FileManager { .collect(); let list = List::new(items).style( Style::default() - .fg(self.theme.foreground) - .bg(self.theme.background), + .fg(panel_fg) + .bg(panel_bg), ); let list_area = Rect::new(inner.x, body_y, inner.width, body_height); frame.render_widget(list, list_area); @@ -1921,7 +1992,7 @@ impl FileManager { frame.render_widget( Paragraph::new(Span::styled( panel_footer_text(panel), - Style::default().fg(self.theme.hidden), + Style::default().fg(disabled_fg).bg(panel_bg), )), Rect::new(inner.x, footer_y, inner.width, 1), ); @@ -1974,19 +2045,36 @@ fn entry_style( marked: bool, theme: &Theme, ) -> Style { + let core_default = mc_skin::color_pair(theme.name, "core", "_default_"); + let core_selected = mc_skin::color_pair(theme.name, "core", "selected"); + let core_reverse = mc_skin::color_pair(theme.name, "core", "reverse"); + let core_marked = mc_skin::color_pair(theme.name, "core", "marked"); + let core_markselect = mc_skin::color_pair(theme.name, "core", "markselect"); let base = if e.is_dir() { Style::default().fg(theme.directory) } else if e.is_symlink() { Style::default().fg(theme.symlink) } else { - Style::default().fg(theme.foreground) + Style::default().fg(core_default.map(|p| p.fg).unwrap_or(theme.foreground)) }; - if cursor && active { - base.bg(theme.cursor_bg) - .fg(theme.cursor_fg) - .add_modifier(Modifier::BOLD) + if cursor && marked { + let pair = core_markselect.unwrap_or_else(|| { + if active { + core_selected.unwrap_or(mc_skin::ColorPair { fg: theme.cursor_fg, bg: theme.cursor_bg }) + } else { + core_marked.unwrap_or(mc_skin::ColorPair { fg: theme.marked_fg, bg: theme.marked_bg }) + } + }); + base.bg(pair.bg).fg(pair.fg).add_modifier(Modifier::BOLD) + } else if cursor && active { + let pair = core_selected.unwrap_or(mc_skin::ColorPair { fg: theme.cursor_fg, bg: theme.cursor_bg }); + base.bg(pair.bg).fg(pair.fg).add_modifier(Modifier::BOLD) + } else if cursor { + let pair = core_reverse.unwrap_or(mc_skin::ColorPair { fg: theme.cursor_fg, bg: theme.cursor_bg }); + base.bg(pair.bg).fg(pair.fg) } else if marked { - base.bg(theme.marked_bg).fg(theme.marked_fg) + let pair = core_marked.unwrap_or(mc_skin::ColorPair { fg: theme.marked_fg, bg: theme.marked_bg }); + base.bg(pair.bg).fg(pair.fg) } else { base } @@ -2009,12 +2097,21 @@ fn format_line_mode(e: &crate::vfs::local::Entry, mode: panel::ListingMode, widt format_size(e.stat.size) }; let name_part = format!("{}{}", e.name, suffix); - let pad = width.saturating_sub(name_part.chars().count() + size_str.len() + 3); + let room = width.max(size_str.len() + 2); + let pad = room.saturating_sub(name_part.chars().count() + size_str.len() + 3); if mode == panel::ListingMode::Long { let perm = format!("{:03o}", e.stat.permissions.to_mode()); - format!("{name_part} {size_str:>7} {perm}") + let mut out = format!("{name_part} {size_str:>7} {perm}"); + if out.chars().count() > width { + out = out.chars().take(width).collect(); + } + out } else { - format!("{name_part}{}{size_str:>7}", " ".repeat(pad + 1)) + let mut out = format!("{name_part}{}{size_str:>7}", " ".repeat(pad + 1)); + if out.chars().count() > width { + out = out.chars().take(width).collect(); + } + out } } } @@ -2505,4 +2602,36 @@ mod tests { assert_eq!(fm.skin_name, original); let _ = fs::remove_dir_all(&dir); } + + #[test] + fn quickcd_dialog_uses_active_panel_history() { + let dir = std::env::temp_dir().join("tlc-fm-quickcd-history"); + let alt = std::env::temp_dir().join("tlc-fm-quickcd-history-alt"); + let _ = fs::remove_dir_all(&dir); + let _ = fs::remove_dir_all(&alt); + fs::create_dir_all(&dir).unwrap(); + fs::create_dir_all(&alt).unwrap(); + let mut fm = FileManager::new(&dir, &empty_cfg()).unwrap(); + fm.active_panel_mut().set_path(&alt).unwrap(); + fm.dispatch(Cmd::QuickCd).unwrap(); + match &mut fm.dialog { + Some(DialogState::QuickCd(d)) => { + let alt_up = Key { + code: 0x2191, + mods: crate::key::Modifiers::ALT, + }; + match d.handle_key(alt_up) { + quickcd_dialog::QuickCdOutcome::Running => {} + other => panic!("expected Running, got {other:?}"), + } + match d.handle_key(Key::ENTER) { + quickcd_dialog::QuickCdOutcome::Confirm(path) => assert_eq!(path, alt), + other => panic!("expected Confirm, got {other:?}"), + } + } + _ => panic!("expected QuickCd dialog"), + } + let _ = fs::remove_dir_all(&dir); + let _ = fs::remove_dir_all(&alt); + } } 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 74e718aff5..29d292a145 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/move_dialog.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/move_dialog.rs @@ -33,6 +33,16 @@ pub struct MoveDialog { pub marked_count: usize, /// Text input for the destination. pub dst_input: Input, + /// Whether to preserve file attributes on cross-device fallback. + pub preserve_attributes: bool, + /// Whether to follow symlinks. + pub follow_links: bool, + /// Whether to dive into subdirs if they exist. + pub dive_into_subdirs: bool, + /// Whether to keep stable symlinks. + pub stable_symlinks: bool, + /// Which control has focus: 0 = dst, 1 = follow links, 2 = preserve attrs. + pub focused: usize, /// True after Enter confirms. pub confirmed: bool, /// True after Esc cancels. @@ -72,6 +82,11 @@ impl MoveDialog { src, marked_count: count, 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, @@ -129,13 +144,35 @@ impl MoveDialog { self.cancelled = true; true } + k if k == Key::TAB => { + self.focused = (self.focused + 1) % 3; + true + } + k if k.code == Key::TAB.code && k.mods.contains(crate::key::Modifiers::SHIFT) => { + self.focused = (self.focused + 2) % 3; + true + } + k if k == Key::from_char(' ') && self.focused > 0 => { + match self.focused { + 1 => self.follow_links = !self.follow_links, + 2 => self.preserve_attributes = !self.preserve_attributes, + _ => {} + } + true + } Key::ENTER => { if self.validate().is_ok() { self.confirmed = true; } true } - _ => self.dst_input.handle_key(key), + _ => { + if self.focused == 0 { + self.dst_input.handle_key(key) + } else { + false + } + } } } @@ -144,7 +181,7 @@ impl MoveDialog { /// `theme` supplies the title, header, and hint colours so the /// dialog follows the active skin. pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { - let popup = mc_copy_move_rect(area, 13); + let popup = mc_copy_move_rect(area, 15); let inner = render_popup(frame, popup, crate::locale::t("dialog_title_move"), theme); let chunks = Layout::default() @@ -153,6 +190,7 @@ impl MoveDialog { Constraint::Length(1), // from/items Constraint::Length(1), // mask Constraint::Length(1), // shell patterns + Constraint::Length(1), // to Constraint::Length(3), // input Constraint::Length(4), // options Constraint::Length(1), // buttons @@ -187,69 +225,100 @@ impl MoveDialog { Paragraph::new(Line::from(vec![ Span::styled( format!("{}: ", crate::locale::t("dialog_label_source_mask")), - Style::default().fg(theme.hidden), + Style::default().fg(theme.foreground), ), - Span::styled("*", Style::default().fg(theme.foreground)), + Span::styled("*", Style::default().fg(theme.warning)), ])), chunks[1], ); frame.render_widget( Paragraph::new(Line::from(Span::styled( format!("[ ] {}", crate::locale::t("dialog_label_using_shell_patterns")), - Style::default().fg(theme.hidden), + disabled_style(theme), ))), chunks[2], ); + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + crate::locale::t("dialog_label_move_to"), + Style::default().fg(theme.foreground), + ))), + chunks[3], + ); - let mut input = Input::new() - .label(crate::locale::t("dialog_label_move_to")) - .text(self.dst_input.value().to_string()); - input = input.focused(); - input.render(frame, chunks[3], theme); + let mut input = Input::new().text(self.dst_input.value().to_string()); + if self.focused == 0 { + input = input.focused(); + } + input.render(frame, chunks[4], theme); let opts = vec![ Line::from(Span::styled( - format!("[ ] {}", crate::locale::t("dialog_label_follow_links")), - Style::default().fg(theme.hidden), + format!( + "{} {}", + if self.follow_links { "[x]" } else { "[ ]" }, + crate::locale::t("dialog_label_follow_links") + ), + option_style(self.focused == 1, theme), )), Line::from(Span::styled( - format!("[ ] {}", crate::locale::t("dialog_label_preserve_attributes")), - Style::default().fg(theme.hidden), + format!( + "{} {}", + if self.preserve_attributes { "[x]" } else { "[ ]" }, + crate::locale::t("dialog_label_preserve_attributes") + ), + option_style(self.focused == 2, theme), )), Line::from(Span::styled( - format!("[ ] {}", crate::locale::t("dialog_label_dive_into_subdirs")), - Style::default().fg(theme.hidden), + format!( + "{} {}", + if self.dive_into_subdirs { "[x]" } else { "[ ]" }, + crate::locale::t("dialog_label_dive_into_subdirs") + ), + disabled_style(theme), )), Line::from(Span::styled( format!("[ ] {}", crate::locale::t("dialog_label_stable_symlinks")), - Style::default().fg(theme.hidden), + disabled_style(theme), )), ]; - frame.render_widget(Paragraph::new(opts), chunks[4]); + frame.render_widget(Paragraph::new(opts), chunks[5]); render_button_row( frame, - chunks[5], + chunks[6], theme, - &crate::locale::t("dialog_action_confirm"), + &crate::locale::t("dialog_action_ok"), &crate::locale::t("dialog_action_cancel"), ); let hint = Line::from(vec![ Span::styled("Enter", Style::default().fg(theme.warning)), Span::styled( - format!(" {} ", crate::locale::t("dialog_action_confirm")), - Style::default().fg(theme.hidden), + format!(" {} ", crate::locale::t("dialog_action_ok")), + Style::default().fg(theme.foreground), ), Span::styled("Esc", Style::default().fg(theme.warning)), Span::styled( format!(" {}", crate::locale::t("dialog_action_cancel")), - Style::default().fg(theme.hidden), + Style::default().fg(theme.foreground), ), ]); - frame.render_widget(Paragraph::new(hint).wrap(Wrap { trim: false }), chunks[6]); + frame.render_widget(Paragraph::new(hint).wrap(Wrap { trim: false }), chunks[7]); } } +fn option_style(focused: bool, theme: &Theme) -> Style { + if focused { + Style::default().fg(theme.cursor_fg).bg(theme.cursor_bg) + } else { + Style::default().fg(theme.foreground) + } +} + +fn disabled_style(theme: &Theme) -> Style { + Style::default().fg(theme.hidden) +} + #[cfg(test)] mod tests { use super::*; @@ -301,4 +370,26 @@ mod tests { assert!(d.is_cancelled()); assert!(d.result().is_none()); } + + #[test] + fn tab_and_space_toggle_preserve_attributes() { + let mut d = MoveDialog::new(vec![std::path::PathBuf::from("/tmp/a")]); + assert!(d.preserve_attributes); + assert!(d.handle_key(Key::TAB)); + assert!(d.handle_key(Key::TAB)); + assert!(d.handle_key(Key::from_char(' '))); + assert!(!d.preserve_attributes); + } + + #[test] + fn tab_cycles_only_really_supported_controls() { + let mut d = MoveDialog::new(vec![std::path::PathBuf::from("/tmp/a")]); + assert_eq!(d.focused, 0); + assert!(d.handle_key(Key::TAB)); + assert_eq!(d.focused, 1); + assert!(d.handle_key(Key::TAB)); + assert_eq!(d.focused, 2); + assert!(d.handle_key(Key::TAB)); + assert_eq!(d.focused, 0); + } } diff --git a/local/recipes/tui/tlc/source/src/filemanager/quickcd_dialog.rs b/local/recipes/tui/tlc/source/src/filemanager/quickcd_dialog.rs index e988d56e65..2e60c5d29c 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/quickcd_dialog.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/quickcd_dialog.rs @@ -142,12 +142,12 @@ impl QuickCdDialog { QuickCdOutcome::Running } - /// Render the dialog centered on screen. + /// Render the dialog anchored to the active panel. pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { let width = 60.min(area.width.saturating_sub(2).max(1)); let height = 6.min(area.height.saturating_sub(1).max(1)); let x = area.x + area.width.saturating_sub(width) / 2; - let y = area.y + area.height.saturating_sub(height + 1); + let y = area.y + area.height.saturating_sub(height).saturating_sub(1); let dlg_area = centered_cols_rect(Rect::new(x, y, width, height), width, height); let inner = render_popup(frame, dlg_area, crate::locale::t("dialog_title_quickcd"), theme); diff --git a/local/recipes/tui/tlc/source/src/ops/copy.rs b/local/recipes/tui/tlc/source/src/ops/copy.rs index ab60210095..2cb53be36d 100644 --- a/local/recipes/tui/tlc/source/src/ops/copy.rs +++ b/local/recipes/tui/tlc/source/src/ops/copy.rs @@ -27,7 +27,14 @@ use crate::ops::{CancelToken, OpProgress}; /// /// If `src` is a symlink, [`copy_symlink`] is invoked instead so /// the destination reproduces the link, not the target's content. -pub fn copy_file(src: &Path, dst: &Path, handle: &OpHandle, overwrite: bool) -> Result<(), OpsError> { +pub fn copy_file( + src: &Path, + dst: &Path, + handle: &OpHandle, + overwrite: bool, + preserve_attributes: bool, + follow_links: bool, +) -> Result<(), OpsError> { if handle.cancel.is_cancelled() { return Err(OpsError::Cancelled); } @@ -38,9 +45,18 @@ pub fn copy_file(src: &Path, dst: &Path, handle: &OpHandle, overwrite: bool) -> // regular file (which would otherwise open and read the // target). let s = crate::fs::lstat(src).map_err(OpsError::from)?; - if s.is_symlink() { + if s.is_symlink() && !follow_links { return copy_symlink(src, dst); } + if s.is_symlink() && follow_links { + let followed = crate::fs::stat(src).map_err(OpsError::from)?; + if followed.is_dir() { + return Err(OpsError::Other(format!( + "cannot copy followed directory symlink as file: {}", + src.display() + ))); + } + } if s.is_dir() { return Err(OpsError::Other(format!("not a file: {}", src.display()))); } @@ -57,8 +73,14 @@ pub fn copy_file(src: &Path, dst: &Path, handle: &OpHandle, overwrite: bool) -> } } - let meta = crate::fs::perm::FileMeta::from_path(src) - .map_err(|e| OpsError::Other(format!("capture meta: {e}")))?; + let meta = if preserve_attributes { + Some( + crate::fs::perm::FileMeta::from_path(src) + .map_err(|e| OpsError::Other(format!("capture meta: {e}")))?, + ) + } else { + None + }; handle.update_progress(|p| p.current_file = Some(src.to_path_buf())); @@ -86,7 +108,9 @@ pub fn copy_file(src: &Path, dst: &Path, handle: &OpHandle, overwrite: bool) -> drop(reader); drop(writer); - let _ = meta.apply_to(dst); + if let Some(meta) = meta { + let _ = meta.apply_to(dst); + } handle.update_progress(|p| { p.files_done = p.files_done.saturating_add(1); }); @@ -114,7 +138,14 @@ pub fn copy_symlink(src: &Path, dst: &Path) -> Result<(), OpsError> { /// (via [`copy_symlink`]), never as the target's content. This /// prevents both infinite loops on circular symlinks and /// accidental inclusion of files outside the source root. -pub fn copy_dir(src: &Path, dst: &Path, handle: &OpHandle, overwrite: bool) -> Result<(), OpsError> { +pub fn copy_dir( + src: &Path, + dst: &Path, + handle: &OpHandle, + overwrite: bool, + preserve_attributes: bool, + follow_links: bool, +) -> Result<(), OpsError> { if handle.cancel.is_cancelled() { return Err(OpsError::Cancelled); } @@ -125,8 +156,10 @@ pub fn copy_dir(src: &Path, dst: &Path, handle: &OpHandle, overwrite: bool) -> R if let Err(e) = fs::create_dir_all(dst) { return Err(OpsError::Io(e)); } - if let Ok(meta) = crate::fs::perm::FileMeta::from_path(src) { - let _ = meta.apply_to(dst); + if preserve_attributes { + if let Ok(meta) = crate::fs::perm::FileMeta::from_path(src) { + let _ = meta.apply_to(dst); + } } let entries = fs::read_dir(src)?; @@ -144,18 +177,30 @@ pub fn copy_dir(src: &Path, dst: &Path, handle: &OpHandle, overwrite: bool) -> R return Err(OpsError::Cancelled); } if s.is_dir() { - copy_dir(&sp, &dp, handle, overwrite)?; + copy_dir(&sp, &dp, handle, overwrite, preserve_attributes, follow_links)?; } else if s.is_symlink() { - // Reproduce the symlink (not the target). - if dp.exists() || dp.symlink_metadata().is_ok() { - let _ = fs::remove_file(&dp); + if follow_links { + let followed = crate::fs::stat(&sp).map_err(OpsError::from)?; + if followed.is_dir() { + copy_dir(&sp, &dp, handle, overwrite, preserve_attributes, follow_links)?; + } else { + if dp.exists() { + let _ = fs::remove_file(&dp); + } + copy_file(&sp, &dp, handle, overwrite, preserve_attributes, follow_links)?; + } + } else { + // Reproduce the symlink (not the target). + if dp.exists() || dp.symlink_metadata().is_ok() { + let _ = fs::remove_file(&dp); + } + copy_symlink(&sp, &dp)?; } - copy_symlink(&sp, &dp)?; } else { if dp.exists() { let _ = fs::remove_file(&dp); } - copy_file(&sp, &dp, handle, overwrite)?; + copy_file(&sp, &dp, handle, overwrite, preserve_attributes, follow_links)?; } } Ok(()) @@ -164,7 +209,14 @@ pub fn copy_dir(src: &Path, dst: &Path, handle: &OpHandle, overwrite: bool) -> R /// Copy a list of source paths to a destination directory. /// Each source is placed under `dst` with its base name. /// Symlinks are preserved as symlinks. -pub fn copy_many(sources: &[PathBuf], dst: &Path, handle: &OpHandle, overwrite: bool) -> Result<(), OpsError> { +pub fn copy_many( + sources: &[PathBuf], + dst: &Path, + handle: &OpHandle, + overwrite: bool, + preserve_attributes: bool, + follow_links: bool, +) -> Result<(), OpsError> { if !dst.is_dir() { return Err(OpsError::ParentMissing(dst.to_path_buf())); } @@ -182,11 +234,20 @@ pub fn copy_many(sources: &[PathBuf], dst: &Path, handle: &OpHandle, overwrite: // directories. let s = crate::fs::lstat(src).map_err(OpsError::from)?; if s.is_dir() { - copy_dir(src, &target, handle, overwrite)?; + copy_dir(src, &target, handle, overwrite, preserve_attributes, follow_links)?; + } else if s.is_symlink() { + if follow_links { + let followed = crate::fs::stat(src).map_err(OpsError::from)?; + if followed.is_dir() { + copy_dir(src, &target, handle, overwrite, preserve_attributes, follow_links)?; + } else { + copy_file(src, &target, handle, overwrite, preserve_attributes, follow_links)?; + } + } else { + copy_file(src, &target, handle, overwrite, preserve_attributes, follow_links)?; + } } else { - // Includes symlinks: `copy_file` routes them through - // `copy_symlink` internally. - copy_file(src, &target, handle, overwrite)?; + copy_file(src, &target, handle, overwrite, preserve_attributes, follow_links)?; } } Ok(()) @@ -217,7 +278,7 @@ mod tests { let dst = dir.join("b"); std::fs::write(&src, b"hello world").unwrap(); let h = make_handle(); - copy_file(&src, &dst, &h, false).unwrap(); + copy_file(&src, &dst, &h, false, true, false).unwrap(); assert_eq!(std::fs::read(&dst).unwrap(), b"hello world"); let _ = std::fs::remove_dir_all(&dir); } @@ -231,7 +292,7 @@ mod tests { std::fs::write(&src, b"data").unwrap(); let h = make_handle(); h.cancel.cancel(); - let r = copy_file(&src, &dst, &h, false); + let r = copy_file(&src, &dst, &h, false, true, false); assert!(matches!(r, Err(OpsError::Cancelled))); let _ = std::fs::remove_dir_all(&dir); } @@ -246,7 +307,7 @@ mod tests { std::fs::write(src.join("a"), b"a").unwrap(); std::fs::write(src.join("b"), b"b").unwrap(); let h = make_handle(); - copy_dir(&src, &dst, &h, false).unwrap(); + copy_dir(&src, &dst, &h, false, true, false).unwrap(); assert_eq!(std::fs::read(dst.join("a")).unwrap(), b"a"); assert_eq!(std::fs::read(dst.join("b")).unwrap(), b"b"); let _ = std::fs::remove_dir_all(&dir); @@ -255,7 +316,7 @@ mod tests { #[test] fn copy_file_missing_source() { let h = make_handle(); - let r = copy_file(Path::new("/no/such/path"), Path::new("/dst"), &h, false); + let r = copy_file(Path::new("/no/such/path"), Path::new("/dst"), &h, false, true, false); assert!(matches!(r, Err(OpsError::SourceNotFound(_)))); } @@ -279,7 +340,7 @@ mod tests { let dst = std::env::temp_dir().join("tlc-copy-symlink-dst"); let _ = std::fs::remove_dir_all(&dst); let h = make_handle(); - copy_dir(&root, &dst, &h, false).unwrap(); + copy_dir(&root, &dst, &h, false, true, false).unwrap(); let link = dst.join("link_to_outside"); assert!(link.is_symlink()); @@ -303,7 +364,7 @@ mod tests { let dst = std::env::temp_dir().join("tlc-copy-self-link-dst"); let _ = std::fs::remove_dir_all(&dst); let h = make_handle(); - copy_dir(&root, &dst, &h, false).unwrap(); + copy_dir(&root, &dst, &h, false, true, false).unwrap(); let link = dst.join("loop"); assert!(link.is_symlink()); @@ -311,4 +372,21 @@ mod tests { let _ = std::fs::remove_dir_all(&root); let _ = std::fs::remove_dir_all(&dst); } + + #[test] + fn copy_file_follow_links_copies_target_contents() { + let dir = std::env::temp_dir().join("tlc-copy-follow-link-file"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let target = dir.join("target"); + let link = dir.join("link"); + let dst = dir.join("dst"); + std::fs::write(&target, b"hello").unwrap(); + symlink(&target, &link).unwrap(); + let h = make_handle(); + copy_file(&link, &dst, &h, false, true, true).unwrap(); + assert_eq!(std::fs::read(&dst).unwrap(), b"hello"); + assert!(!std::fs::symlink_metadata(&dst).unwrap().file_type().is_symlink()); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/local/recipes/tui/tlc/source/src/ops/move_op.rs b/local/recipes/tui/tlc/source/src/ops/move_op.rs index 060343b574..23acb1b388 100644 --- a/local/recipes/tui/tlc/source/src/ops/move_op.rs +++ b/local/recipes/tui/tlc/source/src/ops/move_op.rs @@ -21,6 +21,8 @@ pub fn move_one( dst: &Path, handle: &OpHandle, overwrite: bool, + preserve_attributes: bool, + follow_links: bool, ) -> Result<(), OpsError> { if handle.cancel.is_cancelled() { return Err(OpsError::Cancelled); @@ -51,7 +53,17 @@ pub fn move_one( Err(e) => { let raw = e.raw_os_error(); if raw == Some(18 /* EXDEV */) { - copy::copy_file(src, dst, handle, overwrite)?; + let lstat = crate::fs::lstat(src).map_err(OpsError::from)?; + if lstat.is_symlink() && follow_links { + let followed = crate::fs::stat(src).map_err(OpsError::from)?; + if followed.is_dir() { + copy::copy_dir(src, dst, handle, overwrite, preserve_attributes, follow_links)?; + } else { + copy::copy_file(src, dst, handle, overwrite, preserve_attributes, follow_links)?; + } + } else { + copy::copy_file(src, dst, handle, overwrite, preserve_attributes, follow_links)?; + } delete::delete_file(src, handle)?; Ok(()) } else { @@ -67,6 +79,8 @@ pub fn move_many( dst: &Path, handle: &OpHandle, overwrite: bool, + preserve_attributes: bool, + follow_links: bool, ) -> Result<(), OpsError> { if !dst.is_dir() { return Err(OpsError::ParentMissing(dst.to_path_buf())); @@ -80,7 +94,7 @@ pub fn move_many( continue; }; let target = dst.join(name); - move_one(src, &target, handle, overwrite)?; + move_one(src, &target, handle, overwrite, preserve_attributes, follow_links)?; } Ok(()) } @@ -109,7 +123,7 @@ mod tests { let dst = dir.join("b"); std::fs::write(&src, b"x").unwrap(); let h = make_handle(); - move_one(&src, &dst, &h, false).unwrap(); + move_one(&src, &dst, &h, false, true, false).unwrap(); assert!(!src.exists()); assert!(dst.exists()); let _ = std::fs::remove_dir_all(&dir); @@ -124,7 +138,7 @@ mod tests { std::fs::write(&src, b"x").unwrap(); std::fs::write(&dst, b"y").unwrap(); let h = make_handle(); - let r = move_one(&src, &dst, &h, false); + let r = move_one(&src, &dst, &h, false, true, false); assert!(matches!(r, Err(OpsError::DestExists(_)))); let _ = std::fs::remove_dir_all(&dir); } @@ -138,7 +152,7 @@ mod tests { std::fs::write(&src, b"new").unwrap(); std::fs::write(&dst, b"old").unwrap(); let h = make_handle(); - move_one(&src, &dst, &h, true).unwrap(); + move_one(&src, &dst, &h, true, true, false).unwrap(); assert!(!src.exists()); assert_eq!(std::fs::read_to_string(&dst).unwrap(), "new"); let _ = std::fs::remove_dir_all(&dir); diff --git a/local/recipes/tui/tlc/source/src/terminal/color.rs b/local/recipes/tui/tlc/source/src/terminal/color.rs index f08dd00258..75a1e14b77 100644 --- a/local/recipes/tui/tlc/source/src/terminal/color.rs +++ b/local/recipes/tui/tlc/source/src/terminal/color.rs @@ -14,14 +14,14 @@ //! //! Built-in skin presets (returned by [`Theme::by_name`]): //! -//! | Name | Source | -//! |-------------------|-------------------------------------------------------| -//! | `default-dark` | Red Bear dark (default; alias `default` / `dark` / `""`) | -//! | `default-light` | Red Bear light (alias `light`) | -//! | `mc-classic` | Midnight Commander-style blue/cyan ([`MC_CLASSIC_THEME`]) | -//! | `high-contrast` | WCAG-maximum contrast black/white ([`HIGH_CONTRAST_THEME`]) | -//! | `solarized-dark` | Solarized Dark ([`SOLARIZED_DARK_THEME`]) | -//! | `nord` | Nord ([`NORD_THEME`]) | +//! - `default-dark` / `""` — Red Bear dark default +//! - `default-light` / `light` — Red Bear light default +//! - every bundled Midnight Commander skin from `misc/skins/*.ini` +//! is available by its exact MC file stem (`default`, `dark`, +//! `nicedark`, `sand256`, `seasons-spring16M`, ...) +//! - a small set of legacy TLC aliases still resolves for compatibility +//! (`mc-classic`, `mc-dark`, `mc-dark-gray`, `high-contrast`, +//! `solarized-dark`, `nord`) //! //! User TOML skins in `~/.config/tlc/skin/.toml` are loaded on //! demand and cached in [`USER_SKIN_CACHE`] for the rest of the @@ -349,14 +349,13 @@ impl Theme { /// Look up a theme by name. /// /// Recognised built-in names are: - /// - `default` / `default-dark` / `dark` / `""` → [`DEFAULT_THEME`] + /// - `default-dark` / `""` → [`DEFAULT_THEME`] /// - `default-light` / `light` → [`LIGHT_THEME`] - /// - `mc-classic` → [`MC_CLASSIC_THEME`] - /// - `mc-dark` → [`MC_DARK_THEME`] - /// - `mc-dark-gray` → [`MC_DARK_GRAY_THEME`] - /// - `high-contrast` → [`HIGH_CONTRAST_THEME`] - /// - `solarized-dark` → [`SOLARIZED_DARK_THEME`] - /// - `nord` → [`NORD_THEME`] + /// - exact Midnight Commander skin names such as `default`, + /// `dark`, `nicedark`, `sand256`, `seasons-spring16M`, ... + /// - legacy TLC aliases kept for compatibility (`mc-classic`, + /// `mc-dark`, `mc-dark-gray`, `high-contrast`, + /// `solarized-dark`, `nord`) /// /// Any other name is treated as a user-skin lookup via /// [`crate::skin::Skin::load_named`]. On success the loaded @@ -376,15 +375,18 @@ impl Theme { pub fn by_name(name: &str) -> Theme { match name { "default-light" | "light" => return LIGHT_THEME, - "mc-classic" => return MC_CLASSIC_THEME, - "mc-dark" => return MC_DARK_THEME, - "mc-dark-gray" => return MC_DARK_GRAY_THEME, + "mc-classic" => return super::mc_skin::theme_by_name("default").unwrap_or(MC_CLASSIC_THEME), + "mc-dark" => return super::mc_skin::theme_by_name("dark").unwrap_or(MC_DARK_THEME), + "mc-dark-gray" => return super::mc_skin::theme_by_name("nicedark").unwrap_or(MC_DARK_GRAY_THEME), "high-contrast" => return HIGH_CONTRAST_THEME, "solarized-dark" => return SOLARIZED_DARK_THEME, "nord" => return NORD_THEME, - "default" | "default-dark" | "dark" | "" => return DEFAULT_THEME, + "default-dark" | "" => return DEFAULT_THEME, _ => {} } + if let Some(theme) = super::mc_skin::theme_by_name(name) { + return theme; + } { let guard = USER_SKIN_CACHE .read() @@ -439,13 +441,11 @@ pub struct SkinEntry { /// All built-in skin presets, in the order they should appear in /// the skin selection dialog. /// -/// The list includes the two long-standing defaults -/// (`default-dark`, `default-light`) plus six presets: -/// `mc-classic`, `mc-dark`, `mc-dark-gray`, `high-contrast`, -/// `solarized-dark`, `nord`. +/// The list includes the two TLC defaults first, followed by the real +/// bundled Midnight Commander skins from `misc/skins/*.ini`. #[must_use] pub fn builtin_skins() -> Vec { - vec![ + let mut skins = vec![ SkinEntry { name: "default-dark".into(), description: "Red Bear Dark (default)".into(), @@ -458,22 +458,22 @@ pub fn builtin_skins() -> Vec { }, SkinEntry { name: "mc-classic".into(), - description: "Midnight Commander Classic".into(), + description: "Legacy alias for MC default".into(), is_user: false, }, SkinEntry { name: "mc-dark".into(), - description: "MC Dark (gray)".into(), + description: "Legacy alias for MC dark".into(), is_user: false, }, SkinEntry { name: "mc-dark-gray".into(), - description: "MC Dark Gray (darkest)".into(), + description: "Legacy alias for MC nicedark".into(), is_user: false, }, SkinEntry { name: "high-contrast".into(), - description: "High Contrast (WCAG-maximum)".into(), + description: "High Contrast".into(), is_user: false, }, SkinEntry { @@ -486,7 +486,13 @@ pub fn builtin_skins() -> Vec { description: "Nord".into(), is_user: false, }, - ] + ]; + skins.extend(super::mc_skin::builtin_entries().into_iter().map(|(name, description)| SkinEntry { + name, + description, + is_user: false, + })); + skins } /// Scan `~/.config/tlc/skin/*.toml` for user skin files and return @@ -652,9 +658,9 @@ mod tests { #[test] fn by_name_mc_classic_returns_preset() { let t = Theme::by_name("mc-classic"); - assert_eq!(t.name, "mc-classic"); - assert_eq!(t.background, MC_CLASSIC_THEME.background); - assert_eq!(t.foreground, MC_CLASSIC_THEME.foreground); + assert_eq!(t.name, "default"); + assert_eq!(t.background, Color::Indexed(4)); + assert_eq!(t.foreground, Color::Indexed(7)); } #[test] @@ -689,35 +695,29 @@ mod tests { #[test] fn by_name_preserves_existing_aliases() { - // The pre-existing aliases for default-dark and - // default-light must still resolve to the right preset. + // TLC defaults keep their long-standing explicit names, + // while exact MC names remain free for the real mc skins. assert_eq!(Theme::by_name("").name, "default-dark"); - assert_eq!(Theme::by_name("default").name, "default-dark"); assert_eq!(Theme::by_name("default-dark").name, "default-dark"); - assert_eq!(Theme::by_name("dark").name, "default-dark"); assert_eq!(Theme::by_name("default-light").name, "default-light"); assert_eq!(Theme::by_name("light").name, "default-light"); + assert_eq!(Theme::by_name("default").name, "default"); + assert_eq!(Theme::by_name("dark").name, "dark"); } #[test] - fn builtin_skins_lists_eight_presets() { + fn builtin_skins_lists_real_mc_catalogue() { let skins = builtin_skins(); - assert_eq!( - skins.len(), - 8, - "expected exactly 8 built-in skins, got {}", - skins.len() - ); let names: Vec<&str> = skins.iter().map(|s| s.name.as_str()).collect(); for required in [ "default-dark", "default-light", - "mc-classic", - "mc-dark", - "mc-dark-gray", - "high-contrast", - "solarized-dark", - "nord", + "default", + "dark", + "nicedark", + "sand256", + "seasons-spring16M", + "modarin256", ] { assert!( names.contains(&required), @@ -757,8 +757,8 @@ mod tests { #[test] fn find_skin_index_finds_present() { let skins = builtin_skins(); - let i = find_skin_index(&skins, "nord"); - assert_eq!(skins[i].name, "nord"); + let i = find_skin_index(&skins, "sand256"); + assert_eq!(skins[i].name, "sand256"); } #[test] diff --git a/local/recipes/tui/tlc/source/src/terminal/mod.rs b/local/recipes/tui/tlc/source/src/terminal/mod.rs index ca790fc117..31ac697583 100644 --- a/local/recipes/tui/tlc/source/src/terminal/mod.rs +++ b/local/recipes/tui/tlc/source/src/terminal/mod.rs @@ -8,6 +8,8 @@ pub mod color; pub mod event; +/// Midnight Commander `.ini` skin catalogue and parser. +pub mod mc_skin; pub mod popup; pub mod status; diff --git a/local/recipes/tui/tlc/source/src/terminal/popup.rs b/local/recipes/tui/tlc/source/src/terminal/popup.rs index b1cc315cbf..16b52bed7d 100644 --- a/local/recipes/tui/tlc/source/src/terminal/popup.rs +++ b/local/recipes/tui/tlc/source/src/terminal/popup.rs @@ -7,6 +7,7 @@ use ratatui::widgets::{Block, Borders, Clear}; use ratatui::Frame; use crate::terminal::color::Theme; +use crate::terminal::mc_skin; /// Center a popup by percentage of the parent area. #[must_use] @@ -42,21 +43,47 @@ pub fn mc_copy_move_rect(area: Rect, height: u16) -> Rect { /// Render a standard popup shell and return its inner area. pub fn render_popup(frame: &mut Frame, area: Rect, title: impl Into, theme: &Theme) -> Rect { + let dialog_default = mc_skin::color_pair(theme.name, "dialog", "_default_"); + let dialog_title = mc_skin::color_pair(theme.name, "dialog", "dtitle"); + let body_bg = dialog_default.map(|p| p.bg).unwrap_or(theme.background); + let border_fg = dialog_title.map(|p| p.fg).unwrap_or(theme.border); + let title_fg = dialog_title.map(|p| p.fg).unwrap_or(theme.title_fg); + let title_bg = dialog_title.map(|p| p.bg).unwrap_or(theme.title_bg); frame.render_widget(Clear, area); let block = Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(theme.border)) - .style(Style::default().bg(theme.background)) + .border_style(Style::default().fg(border_fg).bg(body_bg)) + .style(Style::default().bg(body_bg)) .title(Span::styled( format!(" {} ", title.into()), - Style::default().fg(theme.title_fg).bg(theme.title_bg), + Style::default().fg(title_fg).bg(title_bg), )); let inner = block.inner(area); frame.render_widget(block, area); inner } -/// Render a simple MC-style button row. +fn push_mc_button( + spans: &mut Vec>, + label: &str, + pair: mc_skin::ColorPair, + hot_pair: mc_skin::ColorPair, + default_button: bool, +) { + let (left, right) = if default_button { ("[< ", " >]") } else { ("[ ", " ]") }; + spans.push(Span::styled(left.to_string(), Style::default().fg(pair.fg).bg(pair.bg))); + let mut chars = label.chars(); + if let Some(first) = chars.next() { + spans.push(Span::styled(first.to_string(), Style::default().fg(hot_pair.fg).bg(hot_pair.bg))); + let rest: String = chars.collect(); + if !rest.is_empty() { + spans.push(Span::styled(rest, Style::default().fg(pair.fg).bg(pair.bg))); + } + } + spans.push(Span::styled(right.to_string(), Style::default().fg(pair.fg).bg(pair.bg))); +} + +/// Render an MC-style button row using the active dialog slots. pub fn render_button_row( frame: &mut Frame, area: Rect, @@ -64,16 +91,27 @@ pub fn render_button_row( primary: &str, secondary: &str, ) { - let line = Line::from(vec![ - Span::styled( - format!("<{}>", primary), - Style::default().fg(theme.cursor_fg).bg(theme.cursor_bg), - ), - Span::raw(" "), - Span::styled( - format!("[{}]", secondary), - Style::default().fg(theme.foreground).bg(theme.background), - ), - ]); - frame.render_widget(ratatui::widgets::Paragraph::new(line), area); + let focused = mc_skin::color_pair(theme.name, "dialog", "dfocus") + .unwrap_or(mc_skin::ColorPair { fg: theme.cursor_fg, bg: theme.cursor_bg }); + let normal = mc_skin::color_pair(theme.name, "dialog", "_default_") + .unwrap_or(mc_skin::ColorPair { fg: theme.foreground, bg: theme.background }); + let hot_focus = mc_skin::color_pair(theme.name, "dialog", "dhotfocus").unwrap_or(focused); + let hot_normal = mc_skin::color_pair(theme.name, "dialog", "dhotnormal").unwrap_or(normal); + let mut spans: Vec> = Vec::new(); + push_mc_button(&mut spans, primary, focused, hot_focus, true); + spans.push(Span::styled(" ".to_string(), Style::default().bg(normal.bg))); + push_mc_button(&mut spans, secondary, normal, hot_normal, false); + let line = Line::from(spans); + let content_width = line.width() as u16; + let pad = area.width.saturating_sub(content_width) / 2; + let mut padded_spans = Vec::new(); + if pad > 0 { + padded_spans.push(Span::styled(" ".repeat(pad as usize), Style::default().bg(normal.bg))); + } + padded_spans.extend(line.spans); + frame.render_widget( + ratatui::widgets::Paragraph::new(Line::from(padded_spans)) + .style(Style::default().bg(normal.bg).fg(normal.fg)), + area, + ); } diff --git a/local/recipes/tui/tlc/source/src/terminal/status.rs b/local/recipes/tui/tlc/source/src/terminal/status.rs index 291a40f310..c9b8e271d8 100644 --- a/local/recipes/tui/tlc/source/src/terminal/status.rs +++ b/local/recipes/tui/tlc/source/src/terminal/status.rs @@ -13,6 +13,7 @@ use ratatui::widgets::{Paragraph, Wrap}; use ratatui::Frame; use super::color::Theme; +use super::mc_skin; /// A short message to be shown in the status line. #[derive(Debug, Clone)] @@ -87,28 +88,33 @@ impl StatusLine { /// Render the status line into a frame. pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { + let status_pair = mc_skin::color_pair(theme.name, "statusbar", "_default_"); + let status_fg = status_pair.map(|p| p.fg).unwrap_or(theme.status_fg); + let status_bg = status_pair.map(|p| p.bg).unwrap_or(theme.status_bg); let now_message = self.message.as_ref().filter(|m| !m.expired()); let line = match now_message { Some(m) => Line::from(vec![ Span::styled( m.text(), Style::default() - .fg(theme.status_fg) - .bg(theme.status_bg) + .fg(status_fg) + .bg(status_bg) .add_modifier(Modifier::BOLD), ), Span::raw(" "), Span::styled( &self.hint, - Style::default().fg(theme.status_fg).bg(theme.status_bg), + Style::default().fg(status_fg).bg(status_bg), ), ]), None => Line::from(Span::styled( &self.hint, - Style::default().fg(theme.status_fg).bg(theme.status_bg), + Style::default().fg(status_fg).bg(status_bg), )), }; - let para = Paragraph::new(line).wrap(Wrap { trim: false }); + let para = Paragraph::new(line) + .style(Style::default().fg(status_fg).bg(status_bg)) + .wrap(Wrap { trim: false }); frame.render_widget(para, area); } } diff --git a/local/recipes/tui/tlc/source/src/viewer/hex.rs b/local/recipes/tui/tlc/source/src/viewer/hex.rs index 4126b1e3cd..4bb50690b4 100644 --- a/local/recipes/tui/tlc/source/src/viewer/hex.rs +++ b/local/recipes/tui/tlc/source/src/viewer/hex.rs @@ -13,6 +13,7 @@ use ratatui::Frame; use super::Viewer; use crate::terminal::color::Theme; +use crate::terminal::mc_skin; /// Bytes per hex row. const BYTES_PER_ROW: u64 = 16; @@ -22,12 +23,20 @@ const BYTES_PER_ROW: u64 = 16; /// `theme` supplies the offset, byte, and ASCII column colours so /// the hex view follows the active skin. pub fn render(v: &mut Viewer, frame: &mut Frame, area: Rect, theme: &Theme) { + let viewer_default = mc_skin::color_pair(theme.name, "viewer", "_default_"); + let viewer_bold = mc_skin::color_pair(theme.name, "viewer", "viewbold"); + let viewer_selected = mc_skin::color_pair(theme.name, "viewer", "viewselected"); + 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 bold_fg = viewer_bold.map(|p| p.fg).unwrap_or(theme.info); + let selected_fg = viewer_selected.map(|p| p.fg).unwrap_or(theme.cursor_fg); + let selected_bg = viewer_selected.map(|p| p.bg).unwrap_or(theme.warning); let bytes = match &v.source { super::source::FileSource::Inline { bytes } => bytes, super::source::FileSource::Compressed { bytes, .. } => bytes, super::source::FileSource::Chunked { .. } => { let p = Paragraph::new("(chunked source: hex view not yet rendered)") - .style(Style::default().fg(theme.hidden)); + .style(Style::default().fg(theme.hidden).bg(body_bg)); frame.render_widget(p, area); return; } @@ -59,22 +68,22 @@ pub fn render(v: &mut Viewer, frame: &mut Frame, area: Rect, theme: &Theme) { let off_str = format!("{:08x}", offset); // 16 hex bytes (2 chars each + space). let mut hex_spans: Vec = - vec![Span::styled(off_str, Style::default().fg(theme.info))]; + vec![Span::styled(off_str, Style::default().fg(bold_fg).bg(body_bg))]; hex_spans.push(Span::raw(" ")); for i in 0..BYTES_PER_ROW as usize { if i < chunk.len() { let b = chunk[i]; let style = if offset + i as u64 == v.cursor { Style::default() - .fg(theme.cursor_fg) - .bg(theme.warning) + .fg(selected_fg) + .bg(selected_bg) .add_modifier(Modifier::BOLD) } else if b == 0 { - Style::default().fg(theme.hidden) + Style::default().fg(theme.hidden).bg(body_bg) } else if (0x20..0x7f).contains(&b) { - Style::default().fg(theme.foreground) + Style::default().fg(body_fg).bg(body_bg) } else { - Style::default().fg(theme.symlink) + Style::default().fg(theme.symlink).bg(body_bg) }; hex_spans.push(Span::styled(format!("{:02x}", b), style)); } else { @@ -95,11 +104,11 @@ pub fn render(v: &mut Viewer, frame: &mut Frame, area: Rect, theme: &Theme) { }; let style = if offset + i as u64 == v.cursor { Style::default() - .fg(theme.cursor_fg) - .bg(theme.warning) + .fg(selected_fg) + .bg(selected_bg) .add_modifier(Modifier::BOLD) } else { - Style::default().fg(theme.executable) + Style::default().fg(theme.executable).bg(body_bg) }; ascii_spans.push(Span::styled(ch.to_string(), style)); } @@ -107,7 +116,7 @@ pub fn render(v: &mut Viewer, frame: &mut Frame, area: Rect, theme: &Theme) { hex_spans.extend(ascii_spans); lines.push(Line::from(hex_spans)); } - let p = Paragraph::new(lines).style(Style::default().fg(theme.foreground).bg(theme.background)); + let p = Paragraph::new(lines).style(Style::default().fg(body_fg).bg(body_bg)); frame.render_widget(p, area); } diff --git a/local/recipes/tui/tlc/source/src/viewer/mod.rs b/local/recipes/tui/tlc/source/src/viewer/mod.rs index cf8f902460..88c64826a7 100644 --- a/local/recipes/tui/tlc/source/src/viewer/mod.rs +++ b/local/recipes/tui/tlc/source/src/viewer/mod.rs @@ -25,6 +25,7 @@ use ratatui::Frame; use crate::key::Key; use crate::terminal::color::Theme; +use crate::terminal::mc_skin; use crate::viewer::text::TextView; /// The viewer mode. @@ -210,6 +211,15 @@ impl Viewer { if area.width == 0 || area.height == 0 { return; } + let viewer_default = mc_skin::color_pair(theme.name, "viewer", "_default_"); + let viewer_bold = mc_skin::color_pair(theme.name, "viewer", "viewbold"); + let viewer_selected = mc_skin::color_pair(theme.name, "viewer", "viewselected"); + 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 bold_fg = viewer_bold.map(|p| p.fg).unwrap_or(theme.title_fg); + let bold_bg = viewer_bold.map(|p| p.bg).unwrap_or(theme.title_bg); + let selected_fg = viewer_selected.map(|p| p.fg).unwrap_or(theme.cursor_fg); + let selected_bg = viewer_selected.map(|p| p.bg).unwrap_or(theme.cursor_bg); let constraints = if area.height >= 3 { [ @@ -234,8 +244,8 @@ impl Viewer { Paragraph::new(Line::from(Span::styled( self.header_text(), Style::default() - .fg(theme.title_fg) - .bg(theme.title_bg) + .fg(bold_fg) + .bg(bold_bg) .add_modifier(Modifier::BOLD), ))), chunks[0], @@ -244,12 +254,13 @@ impl Viewer { let block = Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(theme.border)) + .border_style(Style::default().fg(bold_fg).bg(body_bg)) + .style(Style::default().fg(body_fg).bg(body_bg)) .title(Span::styled( format!(" {} ", self.path.display()), Style::default() - .fg(theme.title_fg) - .bg(theme.title_bg) + .fg(bold_fg) + .bg(bold_bg) .add_modifier(Modifier::BOLD), )); let inner = block.inner(chunks[1]); @@ -263,7 +274,7 @@ impl Viewer { frame.render_widget( Paragraph::new(Line::from(Span::styled( self.footer_text(), - Style::default().fg(theme.hidden).bg(theme.background), + Style::default().fg(selected_fg).bg(selected_bg), ))), chunks[2], ); diff --git a/local/recipes/tui/tlc/source/src/viewer/text.rs b/local/recipes/tui/tlc/source/src/viewer/text.rs index 2857127a3c..7b97ed595d 100644 --- a/local/recipes/tui/tlc/source/src/viewer/text.rs +++ b/local/recipes/tui/tlc/source/src/viewer/text.rs @@ -22,6 +22,7 @@ use ratatui::widgets::{Paragraph, Wrap}; use ratatui::Frame; use crate::terminal::color::Theme; +use crate::terminal::mc_skin; use regex::RegexBuilder; use super::Viewer; @@ -37,9 +38,14 @@ const TEXT_VIEW_WIDTH: u16 = 80; /// `theme` supplies the foreground / background so the highlight /// follows the active skin. fn match_style(theme: &Theme) -> Style { + let pair = mc_skin::color_pair(theme.name, "viewer", "viewselected") + .unwrap_or(mc_skin::ColorPair { + fg: theme.cursor_fg, + bg: theme.warning, + }); Style::new() - .fg(theme.cursor_fg) - .bg(theme.warning) + .fg(pair.fg) + .bg(pair.bg) .add_modifier(Modifier::BOLD) } @@ -212,6 +218,11 @@ impl Default for TextView { /// `theme` supplies the gutter and body colours so the text view /// follows the active skin. pub fn render(v: &mut Viewer, frame: &mut Frame, area: Rect, theme: &Theme) { + let viewer_default = mc_skin::color_pair(theme.name, "viewer", "_default_"); + 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 bold_fg = viewer_bold.map(|p| p.fg).unwrap_or(theme.warning); let bytes = match &v.source { super::source::FileSource::Inline { bytes } => bytes, super::source::FileSource::Compressed { bytes, .. } => bytes, @@ -220,7 +231,7 @@ pub fn render(v: &mut Viewer, frame: &mut Frame, area: Rect, theme: &Theme) { // chunk's text. Full chunked rendering is a future enhancement. let _ = v; let p = Paragraph::new("(chunked source: not yet rendered in Phase 3.3)") - .style(Style::default().fg(theme.hidden)); + .style(Style::default().fg(theme.hidden).bg(body_bg)); frame.render_widget(p, area); return; } @@ -254,7 +265,7 @@ pub fn render(v: &mut Viewer, frame: &mut Frame, area: Rect, theme: &Theme) { // Pad with blanks. gutter_spans.push(Span::styled( " ".repeat(GUTTER_WIDTH as usize), - Style::default(), + Style::default().bg(body_bg), )); content_lines.push(Line::from("")); continue; @@ -269,10 +280,11 @@ pub fn render(v: &mut Viewer, frame: &mut Frame, area: Rect, theme: &Theme) { ); let g_style = if line_idx as u64 == cursor_line { Style::default() - .fg(theme.warning) + .fg(bold_fg) + .bg(body_bg) .add_modifier(Modifier::BOLD) } else { - Style::default().fg(theme.hidden) + Style::default().fg(theme.hidden).bg(body_bg) }; gutter_spans.push(Span::styled(g_text, g_style)); // Content. The line's absolute byte offset is the entry @@ -298,7 +310,7 @@ pub fn render(v: &mut Viewer, frame: &mut Frame, area: Rect, theme: &Theme) { full_lines.push(Line::from(spans)); } let p = Paragraph::new(full_lines) - .style(Style::default().fg(theme.foreground).bg(theme.background)) + .style(Style::default().fg(body_fg).bg(body_bg)) .wrap(Wrap { trim: false }); frame.render_widget(p, area); let _ = TEXT_VIEW_WIDTH; diff --git a/local/recipes/tui/tlc/source/src/widget/button.rs b/local/recipes/tui/tlc/source/src/widget/button.rs index c14d2b9c06..88e76bca5a 100644 --- a/local/recipes/tui/tlc/source/src/widget/button.rs +++ b/local/recipes/tui/tlc/source/src/widget/button.rs @@ -4,12 +4,13 @@ //! dialogs (OK, Cancel, Yes, No). use ratatui::layout::Rect; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::Span; +use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use ratatui::Frame; use crate::terminal::color::Theme; +use crate::terminal::mc_skin; /// A pressable button. #[derive(Debug, Clone)] @@ -80,8 +81,12 @@ impl Button { /// Width of the button in columns (includes brackets and padding). #[must_use] pub fn width(&self) -> u16 { - // " [ Label] " format - (self.label.chars().count() as u16) + 5 + let label_width = self.label.chars().count() as u16; + if self.focused { + label_width + 7 + } else { + label_width + 4 + } } /// Render the button into a frame. @@ -90,18 +95,65 @@ impl Button { /// instance `fg`/`bg` fields still drive the focused / unfocused /// rendering so callers can override per-button. pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { - let style = if self.disabled { - Style::default().fg(theme.hidden) + let normal = mc_skin::color_pair(theme.name, "dialog", "_default_") + .unwrap_or(mc_skin::ColorPair { + fg: self.fg, + bg: theme.background, + }); + let focused = mc_skin::color_pair(theme.name, "dialog", "dfocus") + .unwrap_or(mc_skin::ColorPair { + fg: self.fg, + bg: self.bg, + }); + let hot_normal = mc_skin::color_pair(theme.name, "dialog", "dhotnormal") + .unwrap_or(normal); + let hot_focus = mc_skin::color_pair(theme.name, "dialog", "dhotfocus") + .unwrap_or(focused); + let pair = if self.disabled { + mc_skin::ColorPair { + fg: theme.hidden, + bg: normal.bg, + } } else if self.focused { - Style::default() - .fg(self.fg) - .bg(self.bg) - .add_modifier(Modifier::BOLD) + focused } else { - Style::default().fg(self.fg) + normal }; - let text = format!(" [ {}] ", self.label); - let p = Paragraph::new(Span::styled(text, style)); + let hot_pair = if self.disabled { + pair + } else if self.focused { + hot_focus + } else { + hot_normal + }; + let (left, right) = if self.focused { + ("[< ", " >]") + } else { + ("[ ", " ]") + }; + let mut spans = vec![Span::styled( + left.to_string(), + Style::default().fg(pair.fg).bg(pair.bg), + )]; + let mut chars = self.label.chars(); + if let Some(first) = chars.next() { + spans.push(Span::styled( + first.to_string(), + Style::default().fg(hot_pair.fg).bg(hot_pair.bg), + )); + let rest: String = chars.collect(); + if !rest.is_empty() { + spans.push(Span::styled( + rest, + Style::default().fg(pair.fg).bg(pair.bg), + )); + } + } + spans.push(Span::styled( + right.to_string(), + Style::default().fg(pair.fg).bg(pair.bg), + )); + let p = Paragraph::new(Line::from(spans)).style(Style::default().bg(pair.bg).fg(pair.fg)); frame.render_widget(p, area); } } @@ -133,7 +185,12 @@ mod tests { #[test] fn width_includes_padding() { let b = Button::new("OK"); - // " [ OK] " = 7 chars - assert_eq!(b.width(), 7); + assert_eq!(b.width(), 6); + } + + #[test] + fn focused_width_uses_mc_default_button_shape() { + let b = Button::new("OK").focused(); + assert_eq!(b.width(), 9); } } diff --git a/local/recipes/tui/tlc/source/src/widget/dialog.rs b/local/recipes/tui/tlc/source/src/widget/dialog.rs index 08f861d912..5c2df02841 100644 --- a/local/recipes/tui/tlc/source/src/widget/dialog.rs +++ b/local/recipes/tui/tlc/source/src/widget/dialog.rs @@ -13,6 +13,7 @@ use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; use ratatui::Frame; use crate::terminal::color::Theme; +use crate::terminal::mc_skin; /// A button in the dialog's button bar. @@ -194,13 +195,18 @@ impl Dialog { let popup = centered_rect(area, self.width_pct, self.height_pct); frame.render_widget(Clear, popup); - let title_fg = theme.title_fg; - let title_bg = theme.title_bg; - let body_fg = theme.foreground; - let body_bg = theme.background; + let dialog_default = mc_skin::color_pair(theme.name, "dialog", "_default_"); + let dialog_focus = mc_skin::color_pair(theme.name, "dialog", "dfocus"); + let dialog_hot_normal = mc_skin::color_pair(theme.name, "dialog", "dhotnormal"); + let dialog_hot_focus = mc_skin::color_pair(theme.name, "dialog", "dhotfocus"); + let dialog_title = mc_skin::color_pair(theme.name, "dialog", "dtitle"); + let title_fg = dialog_title.map(|p| p.fg).unwrap_or(theme.title_fg); + let title_bg = dialog_title.map(|p| p.bg).unwrap_or(theme.title_bg); + let body_fg = dialog_default.map(|p| p.fg).unwrap_or(theme.foreground); + let body_bg = dialog_default.map(|p| p.bg).unwrap_or(theme.background); let block = Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(title_fg)) + .border_style(Style::default().fg(title_fg).bg(body_bg)) .title(Span::styled( format!(" {} ", self.title), Style::default() @@ -225,18 +231,31 @@ impl Dialog { let mut line = Line::default(); line.push_span(Span::raw(" ")); for (i, b) in self.buttons.iter().enumerate() { - let style = if i == self.focused_button { - Style::default() - .fg(theme.cursor_fg) - .bg(theme.cursor_bg) - .add_modifier(Modifier::BOLD) + let focused = i == self.focused_button; + let pair = if focused { + dialog_focus.unwrap_or(mc_skin::ColorPair { fg: theme.cursor_fg, bg: theme.cursor_bg }) } else { - Style::default().fg(theme.buttonbar_fg).bg(theme.buttonbar_bg) + dialog_default.unwrap_or(mc_skin::ColorPair { fg: theme.buttonbar_fg, bg: theme.buttonbar_bg }) }; - line.push_span(Span::styled(format!(" {} ", b.label), style)); + let hot_pair = if focused { + dialog_hot_focus.unwrap_or(pair) + } else { + dialog_hot_normal.unwrap_or(pair) + }; + let (left, right) = if focused { ("[< ", " >]") } else { ("[ ", " ]") }; + line.push_span(Span::styled(left, Style::default().fg(pair.fg).bg(pair.bg))); + let mut chars = b.label.chars(); + if let Some(first) = chars.next() { + line.push_span(Span::styled(first.to_string(), Style::default().fg(hot_pair.fg).bg(hot_pair.bg))); + let rest: String = chars.collect(); + if !rest.is_empty() { + line.push_span(Span::styled(rest, Style::default().fg(pair.fg).bg(pair.bg))); + } + } + line.push_span(Span::styled(right, Style::default().fg(pair.fg).bg(pair.bg))); line.push_span(Span::raw(" ")); } - frame.render_widget(Paragraph::new(line), chunks[1]); + frame.render_widget(Paragraph::new(line).style(Style::default().bg(body_bg).fg(body_fg)), chunks[1]); } }