diff --git a/yazi-config/preset/yazi.toml b/yazi-config/preset/yazi.toml index 82c4cdde..cd7d6787 100644 --- a/yazi-config/preset/yazi.toml +++ b/yazi-config/preset/yazi.toml @@ -25,8 +25,8 @@ ueberzug_offset = [ 0, 0, 0, 0 ] [opener] edit = [ - { exec = '$EDITOR "$@"', block = true, for = "unix" }, - { exec = 'code "%*"', orphan = true, for = "windows" }, + { exec = '[ -n "$EDITOR" ] && $EDITOR "$@"', desc = "$EDITOR", block = true, for = "unix" }, + { exec = 'code "%*"', orphan = true, for = "windows" }, ] open = [ { exec = 'xdg-open "$@"', desc = "Open", for = "linux" }, diff --git a/yazi-core/src/lib.rs b/yazi-core/src/lib.rs index c2cefc44..7a096e5f 100644 --- a/yazi-core/src/lib.rs +++ b/yazi-core/src/lib.rs @@ -12,6 +12,7 @@ pub mod folder; pub mod help; pub mod input; pub mod manager; +pub mod notify; pub mod select; mod step; pub mod tab; diff --git a/yazi-core/src/notify/commands/mod.rs b/yazi-core/src/notify/commands/mod.rs new file mode 100644 index 00000000..23231d13 --- /dev/null +++ b/yazi-core/src/notify/commands/mod.rs @@ -0,0 +1,2 @@ +mod push; +mod tick; diff --git a/yazi-core/src/notify/commands/push.rs b/yazi-core/src/notify/commands/push.rs new file mode 100644 index 00000000..f3f2572b --- /dev/null +++ b/yazi-core/src/notify/commands/push.rs @@ -0,0 +1,19 @@ +use std::time::Instant; + +use yazi_shared::{emit, event::Cmd, Layer}; + +use crate::notify::{Message, Notify}; + +impl Notify { + pub fn push(&mut self, msg: impl TryInto) { + let Ok(mut msg) = msg.try_into() else { + return; + }; + + let instant = Instant::now(); + msg.timeout += instant - self.messages.first().map_or(instant, |m| m.instant); + self.messages.push(msg); + + emit!(Call(Cmd::args("update_notify", vec![0.to_string()]), Layer::App)); + } +} diff --git a/yazi-core/src/notify/commands/tick.rs b/yazi-core/src/notify/commands/tick.rs new file mode 100644 index 00000000..7b78d814 --- /dev/null +++ b/yazi-core/src/notify/commands/tick.rs @@ -0,0 +1,67 @@ +use std::time::Duration; + +use yazi_shared::{emit, event::Cmd, Layer}; + +use crate::notify::Notify; + +pub struct Opt { + interval: Duration, +} + +impl TryFrom for Opt { + type Error = (); + + fn try_from(mut c: Cmd) -> Result { + let interval = c.take_first().and_then(|s| s.parse::().ok()).ok_or(())?; + if interval < 0.0 { + return Err(()); + } + + Ok(Self { interval: Duration::from_secs_f64(interval) }) + } +} + +impl Notify { + pub fn tick(&mut self, opt: impl TryInto) { + self.tick_handle.take().map(|h| h.abort()); + let Ok(opt) = opt.try_into() else { + return; + }; + + let limit = self.limit(); + if limit == 0 { + return; + } + + for m in &mut self.messages[..limit] { + if m.timeout.is_zero() { + m.percent = m.percent.saturating_sub(20); + } else if m.percent < 100 { + m.percent = m.percent.saturating_add(20); + } else { + m.timeout = m.timeout.saturating_sub(opt.interval); + } + } + + self.messages.retain(|m| m.percent > 0 || !m.timeout.is_zero()); + let limit = self.limit(); + let timeouts: Vec<_> = self.messages[..limit] + .iter() + .filter(|&m| m.percent == 100 && !m.timeout.is_zero()) + .map(|m| m.timeout) + .collect(); + + let interval = if timeouts.len() != limit { + Duration::from_millis(50) + } else if let Some(min) = timeouts.iter().min() { + *min + } else { + return; + }; + + self.tick_handle = Some(tokio::spawn(async move { + tokio::time::sleep(interval).await; + emit!(Call(Cmd::args("update_notify", vec![interval.as_secs_f64().to_string()]), Layer::App)); + })); + } +} diff --git a/yazi-core/src/notify/level.rs b/yazi-core/src/notify/level.rs new file mode 100644 index 00000000..f1cfa946 --- /dev/null +++ b/yazi-core/src/notify/level.rs @@ -0,0 +1,20 @@ +use std::str::FromStr; + +pub enum Level { + Info, + Warn, + Error, +} + +impl FromStr for Level { + type Err = (); + + fn from_str(s: &str) -> Result { + Ok(match s { + "info" => Self::Info, + "warn" => Self::Warn, + "error" => Self::Error, + _ => return Err(()), + }) + } +} diff --git a/yazi-core/src/notify/message.rs b/yazi-core/src/notify/message.rs new file mode 100644 index 00000000..13bb39d2 --- /dev/null +++ b/yazi-core/src/notify/message.rs @@ -0,0 +1,47 @@ +use std::time::{Duration, Instant}; + +use yazi_shared::event::Cmd; + +use super::{Level, NOTIFY_BORDER}; + +pub struct Message { + pub title: String, + pub content: String, + pub level: Level, + + pub instant: Instant, + pub timeout: Duration, + + pub lines: usize, + pub percent: u8, +} + +impl TryFrom for Message { + type Error = (); + + fn try_from(mut c: Cmd) -> Result { + let timeout = c.take_name("timeout").and_then(|s| s.parse::().ok()).ok_or(())?; + if timeout < 0.0 { + return Err(()); + } + + let content = c.take_name("content").ok_or(())?; + let lines = content.lines().count(); + Ok(Self { + title: c.take_name("title").ok_or(())?, + content, + level: c.take_name("level").ok_or(())?.parse()?, + + instant: Instant::now(), + timeout: Duration::from_secs_f64(timeout), + + lines, + percent: 0, + }) + } +} + +impl Message { + #[inline] + pub fn height(&self) -> usize { self.lines + NOTIFY_BORDER as usize } +} diff --git a/yazi-core/src/notify/mod.rs b/yazi-core/src/notify/mod.rs new file mode 100644 index 00000000..f54c7704 --- /dev/null +++ b/yazi-core/src/notify/mod.rs @@ -0,0 +1,11 @@ +mod commands; +mod level; +mod message; +mod notify; + +pub use level::*; +pub use message::*; +pub use notify::*; + +pub const NOTIFY_BORDER: u16 = 2; +pub const NOTIFY_SPACING: u16 = 1; diff --git a/yazi-core/src/notify/notify.rs b/yazi-core/src/notify/notify.rs new file mode 100644 index 00000000..a0adea4c --- /dev/null +++ b/yazi-core/src/notify/notify.rs @@ -0,0 +1,36 @@ +use std::ops::ControlFlow; + +use tokio::task::JoinHandle; +use yazi_shared::term::Term; + +use super::{Message, NOTIFY_SPACING}; + +#[derive(Default)] +pub struct Notify { + pub(super) tick_handle: Option>, + pub messages: Vec, +} + +impl Notify { + pub fn limit(&self) -> usize { + if self.messages.is_empty() { + return 0; + } + + let mut height = Term::size().height as usize; + let flow = (0..self.messages.len().min(3)).try_fold(0, |acc, i| { + match height.checked_sub(self.messages[i].height() + NOTIFY_SPACING as usize) { + Some(h) => { + height = h; + ControlFlow::Continue(acc + 1) + } + None => ControlFlow::Break(acc), + } + }); + + 1.max(match flow { + ControlFlow::Continue(i) => i, + ControlFlow::Break(i) => i, + }) + } +} diff --git a/yazi-fm/src/app/commands/mod.rs b/yazi-fm/src/app/commands/mod.rs index d40453a6..e96c024d 100644 --- a/yazi-fm/src/app/commands/mod.rs +++ b/yazi-fm/src/app/commands/mod.rs @@ -1,7 +1,9 @@ +mod notify; mod plugin; mod quit; mod render; mod resize; mod resume; mod stop; +mod update_notify; mod update_progress; diff --git a/yazi-fm/src/app/commands/notify.rs b/yazi-fm/src/app/commands/notify.rs new file mode 100644 index 00000000..2222fb76 --- /dev/null +++ b/yazi-fm/src/app/commands/notify.rs @@ -0,0 +1,7 @@ +use yazi_core::notify::Message; + +use crate::app::App; + +impl App { + pub(crate) fn notify(&mut self, msg: impl TryInto) { self.cx.notify.push(msg); } +} diff --git a/yazi-fm/src/app/commands/render.rs b/yazi-fm/src/app/commands/render.rs index baf6ba38..c77e1242 100644 --- a/yazi-fm/src/app/commands/render.rs +++ b/yazi-fm/src/app/commands/render.rs @@ -1,8 +1,8 @@ use std::sync::atomic::Ordering; -use ratatui::backend::Backend; +use ratatui::{backend::{Backend, CrosstermBackend}, CompletedFrame}; -use crate::{app::App, lives::Lives, root::{Root, COLLISION}}; +use crate::{app::App, lives::Lives, notify::Notify, root::{Root, COLLISION}}; impl App { pub(crate) fn render(&mut self) { @@ -14,35 +14,68 @@ impl App { let frame = term .draw(|f| { _ = Lives::scope(&self.cx, |_| Ok(f.render_widget(Root::new(&self.cx), f.size()))); + if let Some((x, y)) = self.cx.cursor() { f.set_cursor(x, y); } }) .unwrap(); - if !COLLISION.load(Ordering::Relaxed) { - if collision { - // Reload preview if collision is resolved - self.cx.manager.peek(true); - } - return; + if COLLISION.load(Ordering::Relaxed) { + Self::patch(frame, self.cx.cursor()); + } + if !self.cx.notify.messages.is_empty() { + self.render_notify(); } - let mut patch = vec![]; - for x in frame.area.left()..frame.area.right() { - for y in frame.area.top()..frame.area.bottom() { + // Reload preview if collision is resolved + if collision && !COLLISION.load(Ordering::Relaxed) { + self.cx.manager.peek(true); + } + } + + pub(crate) fn render_notify(&mut self) { + let Some(term) = &mut self.term else { + return; + }; + + if !term.can_partial() { + return self.render(); + } + + let frame = term + .draw_partial(|f| { + f.render_widget(Notify::new(&self.cx), f.size()); + + if let Some((x, y)) = self.cx.cursor() { + f.set_cursor(x, y); + } + }) + .unwrap(); + + if COLLISION.load(Ordering::Relaxed) { + Self::patch(frame, self.cx.cursor()); + } + } + + #[inline] + fn patch(frame: CompletedFrame, cursor: Option<(u16, u16)>) { + let mut patches = vec![]; + for y in frame.area.top()..frame.area.bottom() { + for x in frame.area.left()..frame.area.right() { let cell = frame.buffer.get(x, y); if cell.skip { - patch.push((x, y, cell.clone())); + patches.push((x, y, cell)); } } } - term.backend_mut().draw(patch.iter().map(|(x, y, cell)| (*x, *y, cell))).ok(); - if let Some((x, y)) = self.cx.cursor() { - term.show_cursor().ok(); - term.set_cursor(x, y).ok(); + let mut backend = CrosstermBackend::new(std::io::stdout().lock()); + backend.draw(patches.into_iter()).ok(); + if let Some((x, y)) = cursor { + backend.show_cursor().ok(); + backend.set_cursor(x, y).ok(); } - term.backend_mut().flush().ok(); + backend.flush().ok(); } } diff --git a/yazi-fm/src/app/commands/update_notify.rs b/yazi-fm/src/app/commands/update_notify.rs new file mode 100644 index 00000000..a9bb76a6 --- /dev/null +++ b/yazi-fm/src/app/commands/update_notify.rs @@ -0,0 +1,15 @@ +use yazi_shared::event::Cmd; + +use crate::app::App; + +impl App { + pub(crate) fn update_notify(&mut self, cmd: Cmd) { + self.cx.notify.tick(cmd); + + if self.cx.notify.messages.is_empty() { + self.render(); + } else { + self.render_notify(); + } + } +} diff --git a/yazi-fm/src/components/progress.rs b/yazi-fm/src/components/progress.rs index 87dafe77..b234d329 100644 --- a/yazi-fm/src/components/progress.rs +++ b/yazi-fm/src/components/progress.rs @@ -22,8 +22,8 @@ impl Progress { w.render(buf); let mut patch = Vec::with_capacity(area.width as usize * area.height as usize); - for x in area.left()..area.right() { - for y in area.top()..area.bottom() { + for y in area.top()..area.bottom() { + for x in area.left()..area.right() { patch.push((x, y, mem::take(buf.get_mut(x, y)))); } } diff --git a/yazi-fm/src/context.rs b/yazi-fm/src/context.rs index d4c360ed..30e6c882 100644 --- a/yazi-fm/src/context.rs +++ b/yazi-fm/src/context.rs @@ -1,6 +1,6 @@ use ratatui::layout::Rect; use yazi_config::popup::{Origin, Position}; -use yazi_core::{completion::Completion, help::Help, input::Input, manager::Manager, select::Select, tasks::Tasks, which::Which}; +use yazi_core::{completion::Completion, help::Help, input::Input, manager::Manager, notify::Notify, select::Select, tasks::Tasks, which::Which}; pub struct Ctx { pub manager: Manager, @@ -10,6 +10,7 @@ pub struct Ctx { pub help: Help, pub completion: Completion, pub which: Which, + pub notify: Notify, } impl Ctx { @@ -22,6 +23,7 @@ impl Ctx { help: Default::default(), completion: Default::default(), which: Default::default(), + notify: Default::default(), } } diff --git a/yazi-fm/src/executor.rs b/yazi-fm/src/executor.rs index 7577edf9..1fb13d07 100644 --- a/yazi-fm/src/executor.rs +++ b/yazi-fm/src/executor.rs @@ -34,8 +34,10 @@ impl<'a> Executor<'a> { }; } + on!(notify); on!(plugin); on!(plugin_do); + on!(update_notify); on!(update_progress); on!(resize); on!(stop); diff --git a/yazi-fm/src/main.rs b/yazi-fm/src/main.rs index f1c0086c..e025eb8a 100644 --- a/yazi-fm/src/main.rs +++ b/yazi-fm/src/main.rs @@ -14,6 +14,7 @@ mod help; mod input; mod lives; mod logs; +mod notify; mod panic; mod root; mod router; diff --git a/yazi-fm/src/notify/mod.rs b/yazi-fm/src/notify/mod.rs new file mode 100644 index 00000000..2e5703d1 --- /dev/null +++ b/yazi-fm/src/notify/mod.rs @@ -0,0 +1,3 @@ +mod notify; + +pub(super) use notify::*; diff --git a/yazi-fm/src/notify/notify.rs b/yazi-fm/src/notify/notify.rs new file mode 100644 index 00000000..3a097f49 --- /dev/null +++ b/yazi-fm/src/notify/notify.rs @@ -0,0 +1,54 @@ +use std::rc::Rc; + +use ratatui::{buffer::Buffer, layout::{Constraint, Layout, Offset, Rect}, style::{Style, Stylize}, widgets::{Block, BorderType, Paragraph, Widget}}; +use yazi_core::notify::{Level, Message}; + +use crate::{widgets::Clear, Ctx}; + +pub(crate) struct Notify<'a> { + cx: &'a Ctx, +} + +impl<'a> Notify<'a> { + pub(crate) fn new(cx: &'a Ctx) -> Self { Self { cx } } + + fn chunks(area: Rect, messages: &[Message]) -> Rc<[Rect]> { + let chunks = Layout::horizontal([Constraint::Percentage(100), Constraint::Min(40)]).split(area); + + Layout::vertical(messages.iter().map(|m| Constraint::Length(m.height() as u16))) + .spacing(1) + .split(chunks[1]) + } +} + +impl<'a> Widget for Notify<'a> { + fn render(self, area: Rect, buf: &mut Buffer) { + let notify = &self.cx.notify; + + let limit = notify.limit(); + let chunks = Self::chunks(area, ¬ify.messages[..limit]); + + for (i, m) in notify.messages.iter().enumerate().take(limit) { + let (icon, style) = match m.level { + Level::Info => ("", Style::default().green()), + Level::Warn => ("", Style::default().yellow()), + Level::Error => ("", Style::default().red()), + }; + + let mut rect = chunks[i] + .offset(Offset { x: (100 - m.percent) as i32 * chunks[i].width as i32 / 100, y: 0 }); + rect.width = area.width.saturating_sub(rect.x); + + Clear.render(rect, buf); + Paragraph::new(m.content.as_str()) + .block( + Block::bordered() + .border_type(BorderType::Rounded) + .title(format!("{} {}", icon, m.title)) + .title_style(style) + .border_style(style), + ) + .render(rect, buf); + } + } +} diff --git a/yazi-fm/src/widgets/clear.rs b/yazi-fm/src/widgets/clear.rs index bf6532ee..553d55f1 100644 --- a/yazi-fm/src/widgets/clear.rs +++ b/yazi-fm/src/widgets/clear.rs @@ -34,8 +34,8 @@ impl Widget for Clear { ADAPTOR.image_erase(r).ok(); COLLISION.store(true, Ordering::Relaxed); - for x in area.left()..area.right() { - for y in area.top()..area.bottom() { + for y in area.top()..area.bottom() { + for x in area.left()..area.right() { buf.get_mut(x, y).set_skip(true); } } diff --git a/yazi-shared/src/fs/fns.rs b/yazi-shared/src/fs/fns.rs index 0adfcf9b..94cb6e3d 100644 --- a/yazi-shared/src/fs/fns.rs +++ b/yazi-shared/src/fs/fns.rs @@ -139,7 +139,7 @@ pub fn permissions(m: libc::mode_t) -> String { s } -// Find the max common root of a list of files +// Find the max common root in a list of files // e.g. /a/b/c, /a/b/d -> /a/b // /aa/bb/cc, /aa/dd/ee -> /aa pub fn max_common_root(files: &[impl AsRef]) -> PathBuf { diff --git a/yazi-shared/src/term/term.rs b/yazi-shared/src/term/term.rs index 67ae7315..ea0bf0d9 100644 --- a/yazi-shared/src/term/term.rs +++ b/yazi-shared/src/term/term.rs @@ -1,16 +1,24 @@ -use std::{io::{stdout, Stdout, Write}, mem, ops::{Deref, DerefMut}, sync::atomic::{AtomicBool, Ordering}}; +use std::{io::{self, stdout, Stdout, Write}, mem, ops::{Deref, DerefMut}, sync::atomic::{AtomicBool, Ordering}}; use anyhow::Result; use crossterm::{event::{DisableBracketedPaste, DisableFocusChange, EnableBracketedPaste, EnableFocusChange, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags}, execute, queue, terminal::{disable_raw_mode, enable_raw_mode, supports_keyboard_enhancement, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, WindowSize}}; -use ratatui::{backend::CrosstermBackend, Terminal}; +use ratatui::{backend::CrosstermBackend, buffer::Buffer, layout::Rect, CompletedFrame, Frame, Terminal}; static CSI_U: AtomicBool = AtomicBool::new(false); -pub struct Term(Terminal>); +pub struct Term { + inner: Terminal>, + last_area: Rect, + last_buffer: Buffer, +} impl Term { pub fn start() -> Result { - let mut term = Self(Terminal::new(CrosstermBackend::new(stdout()))?); + let mut term = Self { + inner: Terminal::new(CrosstermBackend::new(stdout()))?, + last_area: Default::default(), + last_buffer: Default::default(), + }; enable_raw_mode()?; queue!(stdout(), EnterAlternateScreen, EnableBracketedPaste, EnableFocusChange)?; @@ -69,6 +77,32 @@ impl Term { std::process::exit(f() as i32); } + pub fn draw(&mut self, f: impl FnOnce(&mut Frame)) -> io::Result { + let last = self.inner.draw(f)?; + + self.last_area = last.area; + self.last_buffer = last.buffer.clone(); + Ok(last) + } + + pub fn draw_partial(&mut self, f: impl FnOnce(&mut Frame)) -> io::Result { + self.inner.draw(|frame| { + let buffer = frame.buffer_mut(); + for y in self.last_area.top()..self.last_area.bottom() { + for x in self.last_area.left()..self.last_area.right() { + let mut cell = self.last_buffer.get(x, y).clone(); + cell.skip = false; + *buffer.get_mut(x, y) = cell; + } + } + + f(frame); + }) + } + + #[inline] + pub fn can_partial(&mut self) -> bool { self.last_area == self.inner.get_frame().size() } + pub fn size() -> WindowSize { let mut size = WindowSize { rows: 0, columns: 0, width: 0, height: 0 }; if let Ok(s) = crossterm::terminal::window_size() { @@ -112,9 +146,9 @@ impl Drop for Term { impl Deref for Term { type Target = Terminal>; - fn deref(&self) -> &Self::Target { &self.0 } + fn deref(&self) -> &Self::Target { &self.inner } } impl DerefMut for Term { - fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 } + fn deref_mut(&mut self) -> &mut Self::Target { &mut self.inner } }