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:
2026-06-18 17:19:02 +03:00
parent 4b988041b5
commit 8657c6d45e
27 changed files with 1093 additions and 312 deletions
-2
View File
@@ -1,3 +1 @@
PODMAN_BUILD?=0
REDBEAR_RELEASE?=0.1.0
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}"
+23 -10
View File
@@ -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);
+103 -25
View File
@@ -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);
}
}
+20 -11
View File
@@ -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);
}
+17 -6
View File
@@ -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]);
}
}