tlc: add animation tick loop, popup shadows, and FileManager animation state
100ms poll timeout with frame counter driving spinner/toast ticks. Popup rendering gains drop shadows (▓), backdrop dim (Modifier::DIM), and rounded borders. FileManager struct gets spinner, toasts, frame_count, panel_switch_anim fields plus sync_animations() method.
This commit is contained in:
@@ -56,18 +56,11 @@ impl Application {
|
||||
|
||||
let poll_timeout = Timespec {
|
||||
tv_sec: 0,
|
||||
tv_nsec: 200_000_000,
|
||||
tv_nsec: 100_000_000,
|
||||
};
|
||||
let mut prev_size = tui.size();
|
||||
|
||||
// Main event loop — poll stdin with 200 ms timeout so the
|
||||
// terminal size can be checked between key presses. When the
|
||||
// user resizes the window without typing, poll times out, we
|
||||
// detect the new size, and trigger a redraw.
|
||||
loop {
|
||||
// Handle pending external execution (subshell or command line)
|
||||
// before reading the next key. This runs every iteration
|
||||
// regardless of which code path set the flag.
|
||||
if let Some(action) = take_external_action(&mut fm) {
|
||||
tui = run_external(tui, &mut shell_manager, action)?;
|
||||
render(&mut tui, &mut fm)?;
|
||||
@@ -84,6 +77,13 @@ impl Application {
|
||||
}
|
||||
|
||||
if !poll_fds[0].revents().contains(PollFlags::IN) {
|
||||
fm.frame_count = fm.frame_count.wrapping_add(1);
|
||||
fm.sync_animations();
|
||||
fm.spinner.tick();
|
||||
let toast_active = fm.toasts.tick();
|
||||
if fm.spinner.is_active() || toast_active || fm.panel_switch_anim < 100 {
|
||||
render(&mut tui, &mut fm)?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -306,6 +306,7 @@ fn render(tui: &mut Tui, fm: &mut FileManager) -> Result<()> {
|
||||
let area = ratatui::layout::Rect::new(0, 0, w, h);
|
||||
tui.terminal_mut().draw(|frame| {
|
||||
fm.render(frame, area);
|
||||
fm.toasts.render(frame, area, &fm.theme);
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -194,6 +194,14 @@ pub struct FileManager {
|
||||
/// config.toml` and is updated whenever the user confirms one of
|
||||
/// the three options dialogs.
|
||||
pub runtime: crate::config::RuntimeConfig,
|
||||
/// Spinner for background-job / loading indicators.
|
||||
pub spinner: crate::widget::Spinner,
|
||||
/// Toast notification manager (slide-in completion messages).
|
||||
pub toasts: crate::widget::ToastManager,
|
||||
/// Global animation frame counter (incremented every tick).
|
||||
pub frame_count: u64,
|
||||
/// Panel-switch animation percent (0 = just switched, 100 = settled).
|
||||
pub panel_switch_anim: u8,
|
||||
}
|
||||
|
||||
/// A copy or move operation that hit a destination-exists conflict
|
||||
@@ -382,6 +390,10 @@ impl FileManager {
|
||||
pending_op: None,
|
||||
pending_ctrl_x: false,
|
||||
runtime: crate::config::RuntimeConfig::default(),
|
||||
spinner: crate::widget::Spinner::new(),
|
||||
toasts: crate::widget::ToastManager::new(),
|
||||
frame_count: 0,
|
||||
panel_switch_anim: 100,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -420,9 +432,29 @@ impl FileManager {
|
||||
/// Switch focus to the other panel.
|
||||
pub fn switch_focus(&mut self) {
|
||||
self.active = self.active.other();
|
||||
self.panel_switch_anim = 0;
|
||||
self.status.set_message("Switched panel");
|
||||
}
|
||||
|
||||
/// Sync spinner and toast state with background jobs.
|
||||
/// Starts the spinner when any job is Running; stops it when all
|
||||
/// are done. Pushes a toast for each newly-completed job.
|
||||
pub fn sync_animations(&mut self) {
|
||||
if self.panel_switch_anim < 100 {
|
||||
self.panel_switch_anim = self.panel_switch_anim.saturating_add(25);
|
||||
}
|
||||
let snapshots = {
|
||||
let reg = self.jobs.lock().unwrap_or_else(|e| e.into_inner());
|
||||
reg.list()
|
||||
};
|
||||
let any_running = snapshots.iter().any(|s| matches!(s.state, jobs::JobState::Running));
|
||||
if any_running {
|
||||
self.spinner.start();
|
||||
} else {
|
||||
self.spinner.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// Swap the two panels (left ↔ right).
|
||||
pub fn swap_panels(&mut self) {
|
||||
std::mem::swap(&mut self.left, &mut self.right);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! Shared popup helpers for centered modal surfaces.
|
||||
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Clear};
|
||||
use ratatui::widgets::{Block, BorderType, Borders, Clear};
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::terminal::color::Theme;
|
||||
@@ -41,7 +42,49 @@ pub fn mc_copy_move_rect(area: Rect, height: u16) -> Rect {
|
||||
centered_cols_rect(area, ((area.width * 2) / 3).max(68), height)
|
||||
}
|
||||
|
||||
/// Render a drop shadow one cell below-right of `area`.
|
||||
///
|
||||
/// Fills the offset rectangle with the dark shade character (`▓`)
|
||||
/// so the popup appears to "float" above the underlying surface.
|
||||
/// Must be called **before** [`render_popup`] so the shadow is
|
||||
/// painted underneath the `Clear` widget.
|
||||
fn render_drop_shadow(buf: &mut Buffer, area: Rect, shadow_color: ratatui::style::Color) {
|
||||
let style = Style::default().fg(shadow_color);
|
||||
for y in (area.y + 1)..area.bottom() {
|
||||
if let Some(cell) = buf.cell_mut((area.right(), y)) {
|
||||
cell.set_char('▓').set_style(style);
|
||||
}
|
||||
}
|
||||
for x in area.x..=area.right() {
|
||||
if let Some(cell) = buf.cell_mut((x, area.bottom())) {
|
||||
cell.set_char('▓').set_style(style);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply `Modifier::DIM` to every cell in `full_area` that is **outside**
|
||||
/// `popup_area`. Creates a focus-scrim effect around the modal.
|
||||
fn render_backdrop_dim(buf: &mut Buffer, full_area: Rect, popup_area: Rect) {
|
||||
for y in full_area.top()..full_area.bottom() {
|
||||
for x in full_area.left()..full_area.right() {
|
||||
if x >= popup_area.left()
|
||||
&& x < popup_area.right()
|
||||
&& y >= popup_area.top()
|
||||
&& y < popup_area.bottom()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(cell) = buf.cell_mut((x, y)) {
|
||||
let existing = cell.style();
|
||||
cell.set_style(existing.add_modifier(Modifier::DIM));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a standard popup shell and return its inner area.
|
||||
///
|
||||
/// Uses rounded borders and a drop shadow for a premium look.
|
||||
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");
|
||||
@@ -49,9 +92,12 @@ pub fn render_popup(frame: &mut Frame, area: Rect, title: impl Into<String>, the
|
||||
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);
|
||||
|
||||
render_drop_shadow(frame.buffer_mut(), area, theme.shadow());
|
||||
frame.render_widget(Clear, area);
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(border_fg).bg(body_bg))
|
||||
.style(Style::default().bg(body_bg))
|
||||
.title(Span::styled(
|
||||
@@ -63,6 +109,20 @@ pub fn render_popup(frame: &mut Frame, area: Rect, title: impl Into<String>, the
|
||||
inner
|
||||
}
|
||||
|
||||
/// Like [`render_popup`] but also dims the surrounding content for a
|
||||
/// stronger focus effect.
|
||||
pub fn render_popup_focused(
|
||||
frame: &mut Frame,
|
||||
full_area: Rect,
|
||||
area: Rect,
|
||||
title: impl Into<String>,
|
||||
theme: &Theme,
|
||||
) -> Rect {
|
||||
let inner = render_popup(frame, area, title, theme);
|
||||
render_backdrop_dim(frame.buffer_mut(), full_area, area);
|
||||
inner
|
||||
}
|
||||
|
||||
fn push_mc_button(
|
||||
spans: &mut Vec<Span<'static>>,
|
||||
label: &str,
|
||||
|
||||
Reference in New Issue
Block a user