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:
2026-06-19 23:58:50 +03:00
parent 3164d82227
commit d79dc12af3
3 changed files with 103 additions and 10 deletions
+9 -8
View File
@@ -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,