use std::collections::HashSet;
use std::path::{Path, PathBuf};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget};
use gravityfile_ops::{Conflict, ConflictKind, OperationProgress, OperationType};
use crate::app::input::InputState;
use crate::app::state::{BookmarkListState, Bookmarks, DeletionProgress, SettingsState};
use crate::theme::Theme;
use crate::ui::format_size;
pub struct DeleteConfirmModal<'a> {
theme: &'a Theme,
marked_paths: &'a HashSet<PathBuf>,
#[allow(clippy::type_complexity)]
get_size: Box<dyn Fn(&Path) -> Option<u64> + 'a>,
}
impl<'a> DeleteConfirmModal<'a> {
pub fn new<F>(theme: &'a Theme, marked_paths: &'a HashSet<PathBuf>, get_size: F) -> Self
where
F: Fn(&Path) -> Option<u64> + 'a,
{
Self {
theme,
marked_paths,
get_size: Box::new(get_size),
}
}
}
impl Widget for DeleteConfirmModal<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let popup_width = 60.min(area.width.saturating_sub(4));
let popup_height = (self.marked_paths.len() as u16 + 8).min(area.height.saturating_sub(4));
let popup_x = (area.width.saturating_sub(popup_width)) / 2 + area.x;
let popup_y = (area.height.saturating_sub(popup_height)) / 2 + area.y;
let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
Clear.render(popup_area, buf);
let block = Block::default()
.title(" Confirm Deletion ")
.title_style(
Style::default()
.fg(self.theme.error)
.add_modifier(Modifier::BOLD),
)
.borders(Borders::ALL)
.border_style(Style::default().fg(self.theme.error));
let inner = block.inner(popup_area);
block.render(popup_area, buf);
let total_size: u64 = self
.marked_paths
.iter()
.filter_map(|p| (self.get_size)(p))
.sum();
let mut lines = vec![
Line::styled(
format!(
"Move {} items ({}) to trash?",
self.marked_paths.len(),
format_size(total_size)
),
Style::default()
.fg(self.theme.warning)
.add_modifier(Modifier::BOLD),
),
Line::raw(""),
];
let max_items = (inner.height as usize).saturating_sub(5);
let max_path_len = (inner.width as usize).saturating_sub(6);
let mut sorted_paths: Vec<&PathBuf> = self.marked_paths.iter().collect();
sorted_paths.sort();
for path in sorted_paths.iter().take(max_items) {
let full_path = path.display().to_string();
let display_path = if full_path.len() > max_path_len {
format!(
"...{}",
&full_path[full_path.len().saturating_sub(max_path_len - 3)..]
)
} else {
full_path
};
let (icon, style) = if path.is_symlink() {
("🔗", self.theme.symlink)
} else if path.is_dir() {
("📁", self.theme.directory)
} else {
(" ", Style::default())
};
lines.push(Line::from(vec![
Span::raw(" "),
Span::raw(icon),
Span::styled(format!(" {}", display_path), style),
]));
}
if self.marked_paths.len() > max_items {
lines.push(Line::styled(
format!(" ... and {} more", self.marked_paths.len() - max_items),
Style::default().fg(self.theme.muted),
));
}
lines.push(Line::raw(""));
lines.push(Line::from(vec![
Span::styled(" y/Enter ", self.theme.help_key),
Span::raw("Confirm "),
Span::styled(" n/Esc ", self.theme.help_key),
Span::raw("Cancel"),
]));
Paragraph::new(lines).render(inner, buf);
}
}
pub struct DeletionProgressModal<'a> {
theme: &'a Theme,
progress: Option<&'a DeletionProgress>,
}
impl<'a> DeletionProgressModal<'a> {
pub fn new(theme: &'a Theme, progress: Option<&'a DeletionProgress>) -> Self {
Self { theme, progress }
}
}
impl Widget for DeletionProgressModal<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let popup_width = 50.min(area.width.saturating_sub(4));
let popup_height = 10.min(area.height.saturating_sub(4));
let popup_x = (area.width.saturating_sub(popup_width)) / 2 + area.x;
let popup_y = (area.height.saturating_sub(popup_height)) / 2 + area.y;
let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
Clear.render(popup_area, buf);
let block = Block::default()
.title(" Deleting... ")
.title_style(
Style::default()
.fg(self.theme.warning)
.add_modifier(Modifier::BOLD),
)
.borders(Borders::ALL)
.border_style(Style::default().fg(self.theme.warning));
let inner = block.inner(popup_area);
block.render(popup_area, buf);
let mut lines = vec![];
if let Some(progress) = self.progress {
let pct = progress.percentage();
let bar_width = (inner.width as usize).saturating_sub(10);
let filled = (pct as usize * bar_width) / 100;
let empty = bar_width.saturating_sub(filled);
lines.push(Line::raw(""));
lines.push(Line::from(vec![
Span::raw(" ["),
Span::styled(
"\u{2588}".repeat(filled),
Style::default().fg(self.theme.info),
),
Span::styled(
"\u{2591}".repeat(empty),
Style::default().fg(self.theme.muted),
),
Span::raw(format!("] {}%", pct)),
]));
lines.push(Line::raw(""));
lines.push(Line::from(vec![
Span::styled(" Progress: ", self.theme.help_desc),
Span::raw(format!(
"{}/{} items",
progress.deleted + progress.failed,
progress.total
)),
]));
lines.push(Line::from(vec![
Span::styled(" Freed: ", self.theme.help_desc),
Span::raw(format_size(progress.bytes_freed)),
]));
if progress.failed > 0 {
lines.push(Line::from(vec![
Span::styled(" Failed: ", Style::default().fg(self.theme.error)),
Span::styled(
progress.failed.to_string(),
Style::default().fg(self.theme.error),
),
]));
}
if let Some(current) = &progress.current {
let name = current
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| current.display().to_string());
let max_len = (inner.width as usize).saturating_sub(4);
let display_name = if name.len() > max_len {
format!("...{}", &name[name.len().saturating_sub(max_len - 3)..])
} else {
name
};
lines.push(Line::raw(""));
lines.push(Line::styled(
format!(" {}", display_name),
Style::default().fg(self.theme.muted),
));
}
} else {
lines.push(Line::raw(" Preparing deletion..."));
}
Paragraph::new(lines).render(inner, buf);
}
}
pub struct CommandPalette<'a> {
theme: &'a Theme,
input: &'a str,
cursor: usize,
}
impl<'a> CommandPalette<'a> {
pub fn new(theme: &'a Theme, input: &'a str, cursor: usize) -> Self {
Self {
theme,
input,
cursor,
}
}
}
impl Widget for CommandPalette<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
Clear.render(area, buf);
let prompt = ":";
let mut spans = vec![Span::styled(
prompt,
Style::default()
.fg(self.theme.info)
.add_modifier(Modifier::BOLD),
)];
if self.input.is_empty() {
spans.push(Span::styled(
"type command (q, cd, help...)",
Style::default().fg(self.theme.muted),
));
} else {
let cursor_pos = self.cursor.min(self.input.len());
let (before, after) = self.input.split_at(cursor_pos);
spans.push(Span::raw(before));
if !after.is_empty() {
let (cursor_char, rest) = after.split_at(1);
spans.push(Span::styled(
cursor_char,
Style::default().add_modifier(Modifier::REVERSED),
));
spans.push(Span::raw(rest));
} else {
spans.push(Span::styled(
" ",
Style::default().add_modifier(Modifier::REVERSED),
));
}
}
let line = Line::from(spans);
Paragraph::new(line)
.style(self.theme.footer)
.render(area, buf);
}
}
pub struct OperationProgressModal<'a> {
theme: &'a Theme,
progress: &'a OperationProgress,
}
impl<'a> OperationProgressModal<'a> {
pub fn new(theme: &'a Theme, progress: &'a OperationProgress) -> Self {
Self { theme, progress }
}
fn operation_title(&self) -> &'static str {
match self.progress.operation_type {
OperationType::Copy => " Copying... ",
OperationType::Move => " Moving... ",
OperationType::Delete => " Deleting... ",
OperationType::Rename => " Renaming... ",
OperationType::CreateFile => " Creating File... ",
OperationType::CreateDirectory => " Creating Directory... ",
}
}
fn operation_verb(&self) -> &'static str {
match self.progress.operation_type {
OperationType::Copy => "Copied",
OperationType::Move => "Moved",
OperationType::Delete => "Deleted",
OperationType::Rename => "Renamed",
OperationType::CreateFile => "Created",
OperationType::CreateDirectory => "Created",
}
}
}
impl Widget for OperationProgressModal<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let popup_width = 55.min(area.width.saturating_sub(4));
let popup_height = 12.min(area.height.saturating_sub(4));
let popup_x = (area.width.saturating_sub(popup_width)) / 2 + area.x;
let popup_y = (area.height.saturating_sub(popup_height)) / 2 + area.y;
let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
Clear.render(popup_area, buf);
let block = Block::default()
.title(self.operation_title())
.title_style(
Style::default()
.fg(self.theme.info)
.add_modifier(Modifier::BOLD),
)
.borders(Borders::ALL)
.border_style(Style::default().fg(self.theme.info));
let inner = block.inner(popup_area);
block.render(popup_area, buf);
let mut lines = vec![];
let pct = if self.progress.bytes_total > 0 {
((self.progress.bytes_processed as f64 / self.progress.bytes_total as f64) * 100.0)
as u8
} else if self.progress.files_total > 0 {
((self.progress.files_completed as f64 / self.progress.files_total as f64) * 100.0)
as u8
} else {
0
};
let bar_width = (inner.width as usize).saturating_sub(10);
let filled = (pct as usize * bar_width) / 100;
let empty = bar_width.saturating_sub(filled);
lines.push(Line::raw(""));
lines.push(Line::from(vec![
Span::raw(" ["),
Span::styled(
"\u{2588}".repeat(filled),
Style::default().fg(self.theme.info),
),
Span::styled(
"\u{2591}".repeat(empty),
Style::default().fg(self.theme.muted),
),
Span::raw(format!("] {}%", pct)),
]));
lines.push(Line::raw(""));
lines.push(Line::from(vec![
Span::styled(" Files: ", self.theme.help_desc),
Span::raw(format!(
"{}/{} {}",
self.progress.files_completed,
self.progress.files_total,
self.operation_verb().to_lowercase()
)),
]));
lines.push(Line::from(vec![
Span::styled(" Size: ", self.theme.help_desc),
Span::raw(format!(
"{} / {}",
format_size(self.progress.bytes_processed),
format_size(self.progress.bytes_total)
)),
]));
if !self.progress.errors.is_empty() {
lines.push(Line::from(vec![
Span::styled(" Errors: ", Style::default().fg(self.theme.error)),
Span::styled(
self.progress.errors.len().to_string(),
Style::default().fg(self.theme.error),
),
]));
}
if let Some(current) = &self.progress.current_file {
let name = current
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| current.display().to_string());
let max_len = (inner.width as usize).saturating_sub(4);
let display_name = if name.len() > max_len {
format!("...{}", &name[name.len().saturating_sub(max_len - 3)..])
} else {
name
};
lines.push(Line::raw(""));
lines.push(Line::styled(
format!(" {}", display_name),
Style::default().fg(self.theme.muted),
));
}
Paragraph::new(lines).render(inner, buf);
}
}
pub struct InputModal<'a> {
theme: &'a Theme,
input: &'a InputState,
title: &'a str,
prompt: &'a str,
}
impl<'a> InputModal<'a> {
pub fn new(theme: &'a Theme, input: &'a InputState, title: &'a str, prompt: &'a str) -> Self {
Self {
theme,
input,
title,
prompt,
}
}
}
impl Widget for InputModal<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let popup_width = 50.min(area.width.saturating_sub(4));
let popup_height = if self.input.error().is_some() { 8 } else { 7 };
let popup_height = popup_height.min(area.height.saturating_sub(4));
let popup_x = (area.width.saturating_sub(popup_width)) / 2 + area.x;
let popup_y = (area.height.saturating_sub(popup_height)) / 2 + area.y;
let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
Clear.render(popup_area, buf);
let block = Block::default()
.title(format!(" {} ", self.title))
.title_style(
Style::default()
.fg(self.theme.info)
.add_modifier(Modifier::BOLD),
)
.borders(Borders::ALL)
.border_style(self.theme.border);
let inner = block.inner(popup_area);
block.render(popup_area, buf);
let mut lines = vec![];
lines.push(Line::styled(self.prompt, self.theme.help_desc));
lines.push(Line::raw(""));
let buffer = self.input.buffer();
let cursor = self.input.cursor();
let max_visible = (inner.width as usize).saturating_sub(4);
let (visible_start, cursor_in_view) = if cursor > max_visible - 1 {
(cursor - (max_visible - 1), max_visible - 1)
} else {
(0, cursor)
};
let visible_end = (visible_start + max_visible).min(buffer.len());
let visible_text = if buffer.is_empty() {
String::new()
} else {
buffer
.chars()
.skip(visible_start)
.take(visible_end - visible_start)
.collect()
};
let mut input_spans = vec![Span::raw(" ")];
if visible_text.is_empty() {
input_spans.push(Span::styled(
" ",
Style::default().add_modifier(Modifier::REVERSED),
));
} else {
let before: String = visible_text.chars().take(cursor_in_view).collect();
if !before.is_empty() {
input_spans.push(Span::raw(before));
}
let cursor_char: String = visible_text.chars().skip(cursor_in_view).take(1).collect();
if cursor_char.is_empty() {
input_spans.push(Span::styled(
" ",
Style::default().add_modifier(Modifier::REVERSED),
));
} else {
input_spans.push(Span::styled(
cursor_char,
Style::default().add_modifier(Modifier::REVERSED),
));
}
let after: String = visible_text.chars().skip(cursor_in_view + 1).collect();
if !after.is_empty() {
input_spans.push(Span::raw(after));
}
}
lines.push(Line::from(input_spans));
if let Some(error) = self.input.error() {
lines.push(Line::raw(""));
lines.push(Line::styled(
format!(" {}", error),
Style::default().fg(self.theme.error),
));
}
lines.push(Line::raw(""));
lines.push(Line::from(vec![
Span::styled(" Enter ", self.theme.help_key),
Span::raw("Confirm "),
Span::styled(" Esc ", self.theme.help_key),
Span::raw("Cancel"),
]));
Paragraph::new(lines).render(inner, buf);
}
}
pub struct ConflictModal<'a> {
theme: &'a Theme,
conflict: &'a Conflict,
}
impl<'a> ConflictModal<'a> {
pub fn new(theme: &'a Theme, conflict: &'a Conflict) -> Self {
Self { theme, conflict }
}
}
impl Widget for ConflictModal<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let popup_width = 60.min(area.width.saturating_sub(4));
let popup_height = 12.min(area.height.saturating_sub(4));
let popup_x = (area.width.saturating_sub(popup_width)) / 2 + area.x;
let popup_y = (area.height.saturating_sub(popup_height)) / 2 + area.y;
let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
Clear.render(popup_area, buf);
let (title, title_color) = match self.conflict.kind {
ConflictKind::FileExists => (" File Exists ", self.theme.warning),
ConflictKind::DirectoryExists => (" Directory Exists ", self.theme.warning),
ConflictKind::SourceIsAncestor => (" Invalid Operation ", self.theme.error),
ConflictKind::PermissionDenied => (" Permission Denied ", self.theme.error),
ConflictKind::SameFile => (" Same File ", self.theme.info),
};
let block = Block::default()
.title(title)
.title_style(
Style::default()
.fg(title_color)
.add_modifier(Modifier::BOLD),
)
.borders(Borders::ALL)
.border_style(Style::default().fg(title_color));
let inner = block.inner(popup_area);
block.render(popup_area, buf);
let mut lines = vec![];
let description = match self.conflict.kind {
ConflictKind::FileExists => "A file with this name already exists:",
ConflictKind::DirectoryExists => "A directory with this name already exists:",
ConflictKind::SourceIsAncestor => "Cannot copy/move a directory into itself:",
ConflictKind::PermissionDenied => "Permission denied for:",
ConflictKind::SameFile => "Source and destination are the same file:",
};
lines.push(Line::styled(description, self.theme.help_desc));
lines.push(Line::raw(""));
let source_name = self
.conflict
.source
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| self.conflict.source.display().to_string());
let max_len = (inner.width as usize).saturating_sub(4);
let display_name = if source_name.len() > max_len {
format!(
"...{}",
&source_name[source_name.len().saturating_sub(max_len - 3)..]
)
} else {
source_name
};
lines.push(Line::styled(
format!(" {}", display_name),
Style::default().fg(self.theme.info),
));
let dest = &self.conflict.destination;
let dest_name = dest
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| dest.display().to_string());
let dest_display = if dest_name.len() > max_len {
format!(
"...{}",
&dest_name[dest_name.len().saturating_sub(max_len - 3)..]
)
} else {
dest_name
};
lines.push(Line::styled(
format!(" → {}", dest_display),
Style::default().fg(self.theme.muted),
));
lines.push(Line::raw(""));
lines.push(Line::raw(""));
match self.conflict.kind {
ConflictKind::FileExists | ConflictKind::DirectoryExists => {
lines.push(Line::from(vec![
Span::styled(" s ", self.theme.help_key),
Span::raw("Skip "),
Span::styled(" o ", self.theme.help_key),
Span::raw("Overwrite "),
Span::styled(" r ", self.theme.help_key),
Span::raw("Rename"),
]));
lines.push(Line::from(vec![
Span::styled(" S ", self.theme.help_key),
Span::raw("Skip All "),
Span::styled(" O ", self.theme.help_key),
Span::raw("Overwrite All "),
Span::styled(" Esc ", self.theme.help_key),
Span::raw("Abort"),
]));
}
ConflictKind::PermissionDenied => {
lines.push(Line::from(vec![
Span::styled(" s ", self.theme.help_key),
Span::raw("Skip "),
Span::styled(" S ", self.theme.help_key),
Span::raw("Skip All "),
Span::styled(" Esc ", self.theme.help_key),
Span::raw("Abort"),
]));
}
ConflictKind::SourceIsAncestor => {
lines.push(Line::from(vec![
Span::styled(" Enter/Esc ", self.theme.help_key),
Span::raw("Acknowledge"),
]));
}
ConflictKind::SameFile => {
lines.push(Line::styled(
"Cannot copy a file onto itself.",
Style::default().fg(self.theme.muted),
));
lines.push(Line::raw(""));
lines.push(Line::from(vec![
Span::styled(" Enter/s ", self.theme.help_key),
Span::raw("Skip "),
Span::styled(" r ", self.theme.help_key),
Span::raw("Create Renamed Copy "),
Span::styled(" Esc ", self.theme.help_key),
Span::raw("Cancel"),
]));
}
}
Paragraph::new(lines).render(inner, buf);
}
}
pub struct SettingsModal<'a> {
theme: &'a Theme,
state: &'a SettingsState,
}
impl<'a> SettingsModal<'a> {
pub fn new(theme: &'a Theme, state: &'a SettingsState) -> Self {
Self { theme, state }
}
fn render_toggle(&self, label: &str, value: bool, selected: bool) -> Line<'static> {
let checkbox = if value { "[x]" } else { "[ ]" };
let style = if selected {
Style::default()
.fg(self.theme.info)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
Line::from(vec![
Span::styled(if selected { " > " } else { " " }, style),
Span::styled(checkbox.to_string(), style),
Span::styled(format!(" {}", label), style),
])
}
fn render_choice(&self, label: &str, value: &str, selected: bool) -> Line<'static> {
let style = if selected {
Style::default()
.fg(self.theme.info)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
Line::from(vec![
Span::styled(if selected { " > " } else { " " }, style),
Span::styled(format!("{}: ", label), style),
Span::styled(
value.to_string(),
Style::default()
.fg(self.theme.info)
.add_modifier(if selected {
Modifier::BOLD
} else {
Modifier::empty()
}),
),
])
}
}
impl Widget for SettingsModal<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let popup_width = 50.min(area.width.saturating_sub(4));
let popup_height = 14.min(area.height.saturating_sub(4));
let popup_x = (area.width.saturating_sub(popup_width)) / 2 + area.x;
let popup_y = (area.height.saturating_sub(popup_height)) / 2 + area.y;
let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
Clear.render(popup_area, buf);
let title = if self.state.dirty {
" Settings * "
} else {
" Settings "
};
let block = Block::default()
.title(title)
.title_style(
Style::default()
.fg(self.theme.info)
.add_modifier(Modifier::BOLD),
)
.borders(Borders::ALL)
.border_style(self.theme.border);
let inner = block.inner(popup_area);
block.render(popup_area, buf);
let mut lines = vec![Line::styled(
" Startup",
Style::default().add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
)];
lines.push(Line::raw(""));
lines.push(self.render_toggle(
"Scan on startup",
self.state.settings.scan_on_startup,
self.state.selected == 0,
));
lines.push(Line::raw(""));
lines.push(Line::styled(
" Display",
Style::default().add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
));
lines.push(Line::raw(""));
lines.push(self.render_toggle(
"Show hidden files",
self.state.settings.show_hidden,
self.state.selected == 1,
));
lines.push(self.render_choice(
"Default layout",
&self.state.settings.default_layout,
self.state.selected == 2,
));
lines.push(Line::raw(""));
lines.push(Line::raw(""));
lines.push(Line::from(vec![
Span::styled(" j/k ", self.theme.help_key_style()),
Span::styled("Nav ", self.theme.help_desc_style()),
Span::styled(" Space/Enter ", self.theme.help_key_style()),
Span::styled("Toggle ", self.theme.help_desc_style()),
Span::styled(" s ", self.theme.help_key_style()),
Span::styled("Save ", self.theme.help_desc_style()),
Span::styled(" Esc ", self.theme.help_key_style()),
Span::styled("Close", self.theme.help_desc_style()),
]));
Paragraph::new(lines).render(inner, buf);
}
}
pub struct BookmarkListModal<'a> {
theme: &'a Theme,
bookmarks: &'a Bookmarks,
state: &'a BookmarkListState,
}
impl<'a> BookmarkListModal<'a> {
pub fn new(theme: &'a Theme, bookmarks: &'a Bookmarks, state: &'a BookmarkListState) -> Self {
Self {
theme,
bookmarks,
state,
}
}
}
impl Widget for BookmarkListModal<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let entries = self.bookmarks.sorted_entries();
let popup_width = 60.min(area.width.saturating_sub(4));
let popup_height = ((entries.len() + 6) as u16)
.min(area.height.saturating_sub(4))
.max(8);
let popup_x = (area.width.saturating_sub(popup_width)) / 2 + area.x;
let popup_y = (area.height.saturating_sub(popup_height)) / 2 + area.y;
let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
Clear.render(popup_area, buf);
let block = Block::default()
.title(" Bookmarks ")
.title_style(
Style::default()
.fg(self.theme.info)
.add_modifier(Modifier::BOLD),
)
.borders(Borders::ALL)
.border_style(self.theme.border);
let inner = block.inner(popup_area);
block.render(popup_area, buf);
let mut lines = vec![];
if entries.is_empty() {
lines.push(Line::styled(
" No bookmarks set",
Style::default().fg(self.theme.muted),
));
lines.push(Line::raw(""));
lines.push(Line::styled(
" Press m<key> to set a bookmark",
Style::default().fg(self.theme.muted),
));
} else {
let max_visible = (inner.height as usize).saturating_sub(3);
let max_path_len = (inner.width as usize).saturating_sub(8);
for (i, (key, path)) in entries.iter().enumerate().take(max_visible) {
let is_selected = i == self.state.selected;
let path_str = path.display().to_string();
let display_path = if path_str.len() > max_path_len {
format!(
"...{}",
&path_str[path_str.len().saturating_sub(max_path_len - 3)..]
)
} else {
path_str
};
let style = if is_selected {
Style::default()
.fg(self.theme.info)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let prefix = if is_selected { " > " } else { " " };
lines.push(Line::from(vec![
Span::styled(prefix, style),
Span::styled(
format!("[{}]", key),
Style::default()
.fg(self.theme.info)
.add_modifier(if is_selected {
Modifier::BOLD
} else {
Modifier::empty()
}),
),
Span::styled(format!(" {}", display_path), style),
]));
}
if entries.len() > max_visible {
lines.push(Line::styled(
format!(" ... and {} more", entries.len() - max_visible),
Style::default().fg(self.theme.muted),
));
}
}
lines.push(Line::raw(""));
lines.push(Line::from(vec![
Span::styled(" j/k ", self.theme.help_key_style()),
Span::styled("Nav ", self.theme.help_desc_style()),
Span::styled(" Enter ", self.theme.help_key_style()),
Span::styled("Jump ", self.theme.help_desc_style()),
Span::styled(" d ", self.theme.help_key_style()),
Span::styled("Delete ", self.theme.help_desc_style()),
Span::styled(" Esc ", self.theme.help_key_style()),
Span::styled("Close", self.theme.help_desc_style()),
]));
Paragraph::new(lines).render(inner, buf);
}
}
pub struct BookmarkPrompt<'a> {
theme: &'a Theme,
prompt: &'a str,
}
impl<'a> BookmarkPrompt<'a> {
pub fn new(theme: &'a Theme, prompt: &'a str) -> Self {
Self { theme, prompt }
}
}
impl Widget for BookmarkPrompt<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
Clear.render(area, buf);
let line = Line::from(vec![
Span::styled(
self.prompt,
Style::default()
.fg(self.theme.info)
.add_modifier(Modifier::BOLD),
),
Span::styled(" ", Style::default().add_modifier(Modifier::REVERSED)),
]);
Paragraph::new(line)
.style(self.theme.footer)
.render(area, buf);
}
}
pub struct BulkRenameConfirmModal<'a> {
theme: &'a Theme,
state: &'a crate::app::state::BulkRenameState,
}
impl<'a> BulkRenameConfirmModal<'a> {
pub fn new(theme: &'a Theme, state: &'a crate::app::state::BulkRenameState) -> Self {
Self { theme, state }
}
}
impl Widget for BulkRenameConfirmModal<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let popup_width = 70.min(area.width.saturating_sub(4));
let popup_height = ((self.state.entries.len() + 8) as u16)
.min(area.height.saturating_sub(4))
.max(10);
let popup_x = (area.width.saturating_sub(popup_width)) / 2 + area.x;
let popup_y = (area.height.saturating_sub(popup_height)) / 2 + area.y;
let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
Clear.render(popup_area, buf);
let (title, title_color) = if self.state.error.is_some() {
(" Bulk Rename - Error ", self.theme.error)
} else {
(" Confirm Bulk Rename ", self.theme.info)
};
let block = Block::default()
.title(title)
.title_style(
Style::default()
.fg(title_color)
.add_modifier(Modifier::BOLD),
)
.borders(Borders::ALL)
.border_style(Style::default().fg(title_color));
let inner = block.inner(popup_area);
block.render(popup_area, buf);
let mut lines = vec![];
if let Some(error) = &self.state.error {
lines.push(Line::styled(
format!("Error: {}", error),
Style::default()
.fg(self.theme.error)
.add_modifier(Modifier::BOLD),
));
lines.push(Line::raw(""));
}
lines.push(Line::styled(
format!(
"{} rename{} to perform:",
self.state.entries.len(),
if self.state.entries.len() == 1 {
""
} else {
"s"
}
),
Style::default().fg(self.theme.info),
));
lines.push(Line::raw(""));
let max_visible = (inner.height as usize).saturating_sub(6);
let max_name_len = ((inner.width as usize).saturating_sub(8)) / 2;
let arrow = " -> ";
for (i, entry) in self.state.entries.iter().enumerate().take(max_visible) {
let is_selected = i == self.state.selected;
let old_name = entry
.original
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "?".to_string());
let old_display = if old_name.len() > max_name_len {
format!("{}...", &old_name[..max_name_len.saturating_sub(3)])
} else {
old_name
};
let new_display = if entry.new_name.len() > max_name_len {
format!("{}...", &entry.new_name[..max_name_len.saturating_sub(3)])
} else {
entry.new_name.clone()
};
let prefix = if is_selected { " > " } else { " " };
let style = if is_selected {
Style::default()
.fg(self.theme.info)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
lines.push(Line::from(vec![
Span::styled(prefix, style),
Span::styled(old_display, Style::default().fg(self.theme.muted)),
Span::raw(arrow),
Span::styled(new_display, Style::default().fg(self.theme.success)),
]));
}
if self.state.entries.len() > max_visible {
lines.push(Line::styled(
format!(" ... and {} more", self.state.entries.len() - max_visible),
Style::default().fg(self.theme.muted),
));
}
lines.push(Line::raw(""));
if self.state.error.is_some() {
lines.push(Line::from(vec![
Span::styled(" e ", self.theme.help_key_style()),
Span::styled("Edit ", self.theme.help_desc_style()),
Span::styled(" Esc ", self.theme.help_key_style()),
Span::styled("Cancel", self.theme.help_desc_style()),
]));
} else {
lines.push(Line::from(vec![
Span::styled(" y/Enter ", self.theme.help_key_style()),
Span::styled("Confirm ", self.theme.help_desc_style()),
Span::styled(" e ", self.theme.help_key_style()),
Span::styled("Edit ", self.theme.help_desc_style()),
Span::styled(" n/Esc ", self.theme.help_key_style()),
Span::styled("Cancel", self.theme.help_desc_style()),
]));
}
Paragraph::new(lines).render(inner, buf);
}
}