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.
This commit is contained in:
Submodule local/recipes/dev/ninja-build/source updated: 79feac0f3e...26f6155f0f
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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} に移動しました"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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<Span> = 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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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<Input>,
|
||||
/// 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<PathBuf> {
|
||||
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);
|
||||
|
||||
@@ -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::ColorPair> {
|
||||
mc_skin::color_pair(theme.name, "menu", "menusel")
|
||||
}
|
||||
|
||||
impl Default for MenuBar {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
|
||||
@@ -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<PathBuf>,
|
||||
/// 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<String> = self
|
||||
.active_panel()
|
||||
.history_paths()
|
||||
.iter()
|
||||
.map(|p| p.display().to_string())
|
||||
.collect();
|
||||
self.dialog = Some(DialogState::QuickCd(Box::new(
|
||||
quickcd_dialog::QuickCdDialog::new(&[]),
|
||||
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<Span> = 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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/<name>.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<SkinEntry> {
|
||||
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> {
|
||||
},
|
||||
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<SkinEntry> {
|
||||
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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<String>, 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<Span<'static>>,
|
||||
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<Span<'static>> = 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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Span> =
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user