diff --git a/local/recipes/tui/tlc/source/src/app.rs b/local/recipes/tui/tlc/source/src/app.rs index ba194dc20d..b2fe908759 100644 --- a/local/recipes/tui/tlc/source/src/app.rs +++ b/local/recipes/tui/tlc/source/src/app.rs @@ -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(()) } diff --git a/local/recipes/tui/tlc/source/src/filemanager/mod.rs b/local/recipes/tui/tlc/source/src/filemanager/mod.rs index 52575568a6..90793bb156 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/mod.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/mod.rs @@ -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); diff --git a/local/recipes/tui/tlc/source/src/terminal/popup.rs b/local/recipes/tui/tlc/source/src/terminal/popup.rs index 16b52bed7d..80364fd77a 100644 --- a/local/recipes/tui/tlc/source/src/terminal/popup.rs +++ b/local/recipes/tui/tlc/source/src/terminal/popup.rs @@ -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, 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, 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, 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, + 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>, label: &str,