feat: Vim-like notification (#659)

This commit is contained in:
三咲雅 · Misaki Masa 2024-02-13 03:26:17 +08:00 committed by GitHub
parent a764e42098
commit 22ecad47ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 387 additions and 31 deletions

View file

@ -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" },

View file

@ -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;

View file

@ -0,0 +1,2 @@
mod push;
mod tick;

View file

@ -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<Message>) {
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));
}
}

View file

@ -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<Cmd> for Opt {
type Error = ();
fn try_from(mut c: Cmd) -> Result<Self, Self::Error> {
let interval = c.take_first().and_then(|s| s.parse::<f64>().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<Opt>) {
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));
}));
}
}

View file

@ -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<Self, Self::Err> {
Ok(match s {
"info" => Self::Info,
"warn" => Self::Warn,
"error" => Self::Error,
_ => return Err(()),
})
}
}

View file

@ -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<Cmd> for Message {
type Error = ();
fn try_from(mut c: Cmd) -> Result<Self, Self::Error> {
let timeout = c.take_name("timeout").and_then(|s| s.parse::<f64>().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 }
}

View file

@ -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;

View file

@ -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<JoinHandle<()>>,
pub messages: Vec<Message>,
}
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,
})
}
}

View file

@ -1,7 +1,9 @@
mod notify;
mod plugin;
mod quit;
mod render;
mod resize;
mod resume;
mod stop;
mod update_notify;
mod update_progress;

View file

@ -0,0 +1,7 @@
use yazi_core::notify::Message;
use crate::app::App;
impl App {
pub(crate) fn notify(&mut self, msg: impl TryInto<Message>) { self.cx.notify.push(msg); }
}

View file

@ -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();
}
}

View file

@ -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();
}
}
}

View file

@ -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))));
}
}

View file

@ -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(),
}
}

View file

@ -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);

View file

@ -14,6 +14,7 @@ mod help;
mod input;
mod lives;
mod logs;
mod notify;
mod panic;
mod root;
mod router;

View file

@ -0,0 +1,3 @@
mod notify;
pub(super) use notify::*;

View file

@ -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, &notify.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);
}
}
}

View file

@ -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);
}
}

View file

@ -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<Path>]) -> PathBuf {

View file

@ -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<CrosstermBackend<Stdout>>);
pub struct Term {
inner: Terminal<CrosstermBackend<Stdout>>,
last_area: Rect,
last_buffer: Buffer,
}
impl Term {
pub fn start() -> Result<Self> {
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<CompletedFrame> {
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<CompletedFrame> {
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<CrosstermBackend<Stdout>>;
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 }
}