feat: introduce confirm popup

Confirm popup that takes a '\n' separated string message renders
a popup requesting user for confirmation

The message is displayed as a Paragraph with vertical scrollbar (if
needed)

  - Lua binding: ya.confrm(title, message, position)
  - Position::try_from now parses "h" value (default 3)
This commit is contained in:
Chris Schade 2024-06-06 07:55:31 +02:00 committed by sxyazi
parent 573ca5287f
commit e9c9daf33d
No known key found for this signature in database
27 changed files with 417 additions and 46 deletions

View file

@ -262,6 +262,27 @@ keymap = [
{ on = "<F1>", run = "help", desc = "Open help" },
]
[confirm]
keymap = [
{ on = [ "<Esc>" ], run = "close", desc = "Cancel the confirm" },
{ on = [ "n" ], run = "close", desc = "Cancel the confirm" },
{ on = [ "y" ], run = "close --submit", desc = "Submit the confirm" },
{ on = [ "k" ], run = "arrow -1", desc = "Move cursor up" },
{ on = [ "j" ], run = "arrow 1", desc = "Move cursor down" },
{ on = [ "K" ], run = "arrow -5", desc = "Move cursor up 5 lines" },
{ on = [ "J" ], run = "arrow 5", desc = "Move cursor down 5 lines" },
{ on = [ "<Up>" ], run = "arrow -1", desc = "Move cursor up" },
{ on = [ "<Down>" ], run = "arrow 1", desc = "Move cursor down" },
{ on = [ "<S-Up>" ], run = "arrow -5", desc = "Move cursor up 5 lines" },
{ on = [ "<S-Down>" ], run = "arrow 5", desc = "Move cursor down 5 lines" },
{ on = [ "~" ], run = "help", desc = "Open help" }
]
[completion]
keymap = [

View file

@ -147,16 +147,6 @@ rename_title = "Rename:"
rename_origin = "hovered"
rename_offset = [ 0, 1, 50, 3 ]
# trash
trash_title = "Move {n} selected file{s} to trash? (y/N)"
trash_origin = "top-center"
trash_offset = [ 0, 2, 50, 3 ]
# delete
delete_title = "Delete {n} selected file{s} permanently? (y/N)"
delete_origin = "top-center"
delete_offset = [ 0, 2, 50, 3 ]
# filter
filter_title = "Filter:"
filter_origin = "top-center"
@ -187,6 +177,17 @@ quit_title = "{n} task{s} running, sure to quit? (y/N)"
quit_origin = "top-center"
quit_offset = [ 0, 2, 50, 3 ]
[confirm]
# trash
trash_title = "Move {n} selected file{s} to trash? (y/N)"
trash_origin = "top-center"
trash_offset = [ 0, 5, 70, 20 ]
# delete
delete_title = "Delete {n} selected file{s} permanently? (y/N)"
delete_origin = "top-center"
delete_offset = [ 0, 5, 70, 20 ]
[select]
open_title = "Open with:"
open_origin = "hovered"

View file

@ -12,6 +12,7 @@ pub struct Keymap {
pub tasks: Vec<Control>,
pub select: Vec<Control>,
pub input: Vec<Control>,
pub confirm: Vec<Control>,
pub help: Vec<Control>,
pub completion: Vec<Control>,
}
@ -25,6 +26,7 @@ impl Keymap {
Layer::Tasks => &self.tasks,
Layer::Select => &self.select,
Layer::Input => &self.input,
Layer::Confirm => &self.confirm,
Layer::Help => &self.help,
Layer::Completion => &self.completion,
Layer::Which => unreachable!(),
@ -49,6 +51,7 @@ impl<'de> Deserialize<'de> for Keymap {
tasks: Inner,
select: Inner,
input: Inner,
confirm: Inner,
help: Inner,
completion: Inner,
}
@ -72,6 +75,8 @@ impl<'de> Deserialize<'de> for Keymap {
#[rustfmt::skip]
Preset::mix(&mut shadow.input.keymap, shadow.input.prepend_keymap, shadow.input.append_keymap);
#[rustfmt::skip]
Preset::mix(&mut shadow.confirm.keymap, shadow.confirm.prepend_keymap, shadow.confirm.append_keymap);
#[rustfmt::skip]
Preset::mix(&mut shadow.help.keymap, shadow.help.prepend_keymap, shadow.help.append_keymap);
#[rustfmt::skip]
Preset::mix(&mut shadow.completion.keymap, shadow.completion.prepend_keymap, shadow.completion.append_keymap);
@ -81,6 +86,7 @@ impl<'de> Deserialize<'de> for Keymap {
tasks: shadow.tasks.keymap,
select: shadow.select.keymap,
input: shadow.input.keymap,
confirm: shadow.confirm.keymap,
help: shadow.help.keymap,
completion: shadow.completion.keymap,
})

View file

@ -35,6 +35,7 @@ pub static PREVIEW: RoCell<preview::Preview> = RoCell::new();
pub static TASKS: RoCell<tasks::Tasks> = RoCell::new();
pub static THEME: RoCell<theme::Theme> = RoCell::new();
pub static INPUT: RoCell<popup::Input> = RoCell::new();
pub static CONFIRM: RoCell<popup::Confirm> = RoCell::new();
pub static SELECT: RoCell<popup::Select> = RoCell::new();
pub static WHICH: RoCell<which::Which> = RoCell::new();
@ -55,6 +56,7 @@ pub fn init() -> anyhow::Result<()> {
TASKS.init(<_>::from_str(yazi_toml)?);
THEME.init(<_>::from_str(theme_toml)?);
INPUT.init(<_>::from_str(yazi_toml)?);
CONFIRM.init(<_>::from_str(yazi_toml)?);
SELECT.init(<_>::from_str(yazi_toml)?);
WHICH.init(<_>::from_str(yazi_toml)?);

View file

@ -0,0 +1,36 @@
use std::str::FromStr;
use serde::Deserialize;
use super::{Offset, Origin};
#[derive(Deserialize)]
pub struct Confirm {
// trash
pub trash_title: String,
pub trash_origin: Origin,
pub trash_offset: Offset,
// delete
pub delete_title: String,
pub delete_origin: Origin,
pub delete_offset: Offset,
}
impl FromStr for Confirm {
type Err = toml::de::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
#[derive(Deserialize)]
struct Outer {
confirm: Confirm,
}
Ok(toml::from_str::<Outer>(s)?.confirm)
}
}
impl Confirm {
#[inline]
pub const fn border(&self) -> u16 { 2 }
}

View file

@ -23,16 +23,6 @@ pub struct Input {
pub rename_origin: Origin,
pub rename_offset: Offset,
// trash
pub trash_title: String,
pub trash_origin: Origin,
pub trash_offset: Offset,
// delete
pub delete_title: String,
pub delete_origin: Origin,
pub delete_offset: Offset,
// filter
pub filter_title: String,
pub filter_origin: Origin,

View file

@ -1,3 +1,4 @@
mod confirm;
mod input;
mod offset;
mod options;
@ -5,6 +6,7 @@ mod origin;
mod position;
mod select;
pub use confirm::*;
pub use input::*;
pub use offset::*;
pub use options::*;

View file

@ -1,5 +1,5 @@
use super::{Offset, Position};
use crate::{INPUT, SELECT};
use crate::{CONFIRM, INPUT, SELECT};
#[derive(Default)]
pub struct InputCfg {
@ -19,6 +19,13 @@ pub struct SelectCfg {
pub position: Position,
}
#[derive(Default)]
pub struct ConfirmCfg {
pub title: String,
pub message: String,
pub position: Position,
}
impl InputCfg {
#[inline]
pub fn cd() -> Self {
@ -48,26 +55,6 @@ impl InputCfg {
}
}
#[inline]
pub fn trash(n: usize) -> Self {
let title = INPUT.trash_title.replace("{n}", &n.to_string());
Self {
title: title.replace("{s}", if n > 1 { "s" } else { "" }),
position: Position::new(INPUT.trash_origin, INPUT.trash_offset),
..Default::default()
}
}
#[inline]
pub fn delete(n: usize) -> Self {
let title = INPUT.delete_title.replace("{n}", &n.to_string());
Self {
title: title.replace("{s}", if n > 1 { "s" } else { "" }),
position: Position::new(INPUT.delete_origin, INPUT.delete_offset),
..Default::default()
}
}
#[inline]
pub fn filter() -> Self {
Self {
@ -139,6 +126,26 @@ impl InputCfg {
}
}
impl ConfirmCfg {
#[inline]
pub fn delete(targets: &[yazi_shared::fs::Url]) -> Self {
Self {
title: CONFIRM.delete_title.replace("{n}", &targets.len().to_string()),
position: Position::new(CONFIRM.delete_origin, CONFIRM.delete_offset),
message: targets.iter().map(|t| t.to_string()).collect::<Vec<_>>().join("\n"),
}
}
#[inline]
pub fn trash(targets: &[yazi_shared::fs::Url]) -> Self {
Self {
title: CONFIRM.trash_title.replace("{n}", &targets.len().to_string()),
position: Position::new(CONFIRM.trash_origin, CONFIRM.trash_offset),
message: targets.iter().map(|t| t.to_string()).collect::<Vec<_>>().join("\n"),
}
}
}
impl SelectCfg {
#[inline]
fn max_height(len: usize) -> u16 {

View file

@ -0,0 +1,38 @@
use yazi_shared::{event::{Cmd, Data}, render};
use crate::confirm::Confirm;
pub struct Opt {
step: isize,
}
impl From<Cmd> for Opt {
fn from(c: Cmd) -> Self { Self { step: c.first().and_then(Data::as_isize).unwrap_or(0) } }
}
impl Confirm {
fn next(&mut self, step: usize) {
let len = self.message_num_lines();
if len == 0 {
return;
}
let old = self.vertical_scroll;
self.vertical_scroll = (self.vertical_scroll + step).min(len - 1);
render!(old != self.vertical_scroll);
}
fn prev(&mut self, step: usize) {
let old = self.vertical_scroll;
self.vertical_scroll -= step.min(self.vertical_scroll);
render!(old != self.vertical_scroll);
}
pub fn arrow(&mut self, opt: impl Into<Opt>) {
let opt = opt.into() as Opt;
if opt.step > 0 { self.next(opt.step as usize) } else { self.prev(opt.step.unsigned_abs()) }
}
}

View file

@ -0,0 +1,27 @@
use anyhow::anyhow;
use yazi_shared::{event::Cmd, render};
use crate::confirm::Confirm;
pub struct Opt {
submit: bool,
}
impl From<Cmd> for Opt {
fn from(c: Cmd) -> Self { Self { submit: c.bool("submit") } }
}
impl From<bool> for Opt {
fn from(submit: bool) -> Self { Self { submit } }
}
impl Confirm {
pub fn close(&mut self, opt: impl Into<Opt>) {
let opt = opt.into() as Opt;
if let Some(cb) = self.callback.take() {
_ = cb.send(if opt.submit { Ok(true) } else { Err(anyhow!("canceled")) });
}
self.visible = false;
render!();
}
}

View file

@ -0,0 +1,3 @@
mod arrow;
mod close;
mod show;

View file

@ -0,0 +1,36 @@
use tokio::sync::oneshot;
use yazi_config::popup::ConfirmCfg;
use yazi_shared::{event::Cmd, render};
use crate::confirm::Confirm;
pub struct Opt {
cfg: ConfirmCfg,
tx: oneshot::Sender<anyhow::Result<bool>>,
}
impl TryFrom<Cmd> for Opt {
type Error = ();
fn try_from(mut c: Cmd) -> Result<Self, Self::Error> {
Ok(Self { cfg: c.take_any("cfg").ok_or(())?, tx: c.take_any("tx").ok_or(())? })
}
}
impl Confirm {
pub fn show(&mut self, opt: impl TryInto<Opt>) {
let Ok(opt) = opt.try_into() else {
return;
};
self.close(false);
self.title = opt.cfg.title;
self.set_message(&opt.cfg.message);
self.vertical_scroll = 0;
self.position = opt.cfg.position;
self.callback = Some(opt.tx);
self.visible = true;
render!();
}
}

View file

@ -0,0 +1,34 @@
use anyhow::Result;
use tokio::sync::oneshot::Sender;
use yazi_config::popup::Position;
#[derive(Default)]
pub struct Confirm {
pub(super) title: String,
message: String,
message_num_lines: usize,
pub position: Position,
pub vertical_scroll: usize,
pub(super) callback: Option<Sender<Result<bool>>>,
pub visible: bool,
}
impl Confirm {
#[inline]
pub fn set_message(&mut self, message: &str) {
self.message = message.to_string();
self.message_num_lines = self.message.split('\n').count();
}
#[inline]
pub fn message(&self) -> String { self.message.clone() }
#[inline]
pub fn message_num_lines(&self) -> usize { self.message_num_lines }
#[inline]
pub fn title(&self) -> String { self.title.clone() }
}

View file

@ -0,0 +1,4 @@
mod commands;
mod confirm;
pub use confirm::*;

View file

@ -0,0 +1,64 @@
use ratatui::{buffer::Buffer, layout::{Constraint, Layout, Margin, Rect}, text::Line, widgets::{Block, BorderType, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget, Widget, Wrap}};
use yazi_config::THEME;
use crate::Ctx;
pub(crate) struct Confirm<'a> {
cx: &'a Ctx,
}
impl<'a> Confirm<'a> {
pub(crate) fn new(cx: &'a Ctx) -> Self { Self { cx } }
}
impl<'a> Widget for Confirm<'a> {
fn render(self, _win: Rect, buf: &mut Buffer) {
let confirm = &self.cx.confirm;
let area = self.cx.area(&confirm.position);
yazi_plugin::elements::Clear::default().render(area, buf);
Block::bordered()
.border_type(BorderType::Rounded)
.border_style(THEME.input.border)
.title(Line::styled(&confirm.title(), THEME.input.title))
.render(area, buf);
let popup_layout =
Layout::vertical(vec![Constraint::Percentage(70), Constraint::Percentage(30)])
.vertical_margin(1)
.horizontal_margin(2)
.split(area);
let button_layout = Layout::horizontal(vec![
Constraint::Percentage(10),
Constraint::Percentage(30),
Constraint::Percentage(20),
Constraint::Percentage(30),
Constraint::Percentage(10),
])
.vertical_margin(1)
.split(popup_layout[1]);
Paragraph::new(confirm.message().split('\n').map(Line::from).collect::<Vec<Line>>())
.block(Block::bordered().border_type(BorderType::Rounded).border_style(THEME.input.border))
.scroll((confirm.vertical_scroll as u16, 0))
.wrap(Wrap { trim: false })
.render(popup_layout[0], buf);
const BORDER_SIZE: usize = 2;
if confirm.message_num_lines() > popup_layout[0].as_size().height as usize - BORDER_SIZE {
let mut scrollbar_state =
ScrollbarState::new(confirm.message().split('\n').collect::<Vec<&str>>().len())
.position(confirm.vertical_scroll);
Scrollbar::new(ScrollbarOrientation::VerticalRight).render(
popup_layout[0].inner(&Margin { vertical: 1, horizontal: 0 }),
buf,
&mut scrollbar_state,
);
}
Paragraph::new("[Y]es").block(Block::bordered()).centered().render(button_layout[1], buf);
Paragraph::new("(N)o").block(Block::bordered()).centered().render(button_layout[3], buf);
}
}

View file

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

View file

@ -1,13 +1,14 @@
use ratatui::layout::Rect;
use yazi_adapter::Dimension;
use yazi_config::popup::{Origin, Position};
use yazi_core::{completion::Completion, help::Help, input::Input, manager::Manager, notify::Notify, select::Select, tasks::Tasks, which::Which};
use yazi_core::{completion::Completion, confirm::Confirm, help::Help, input::Input, manager::Manager, notify::Notify, select::Select, tasks::Tasks, which::Which};
pub struct Ctx {
pub manager: Manager,
pub tasks: Tasks,
pub select: Select,
pub input: Input,
pub confirm: Confirm,
pub help: Help,
pub completion: Completion,
pub which: Which,
@ -21,6 +22,7 @@ impl Ctx {
tasks: Tasks::serve(),
select: Default::default(),
input: Default::default(),
confirm: Default::default(),
help: Default::default(),
completion: Default::default(),
which: Default::default(),

View file

@ -19,6 +19,7 @@ impl<'a> Executor<'a> {
Layer::Tasks => self.tasks(cmd),
Layer::Select => self.select(cmd),
Layer::Input => self.input(cmd),
Layer::Confirm => self.confirm(cmd),
Layer::Help => self.help(cmd),
Layer::Completion => self.completion(cmd),
Layer::Which => self.which(cmd),
@ -249,6 +250,20 @@ impl<'a> Executor<'a> {
}
}
fn confirm(&mut self, cmd: Cmd) {
macro_rules! on {
($name:ident) => {
if cmd.name == stringify!($name) {
return self.app.cx.confirm.$name(cmd);
}
};
}
on!(arrow);
on!(show);
on!(close);
}
fn help(&mut self, cmd: Cmd) {
macro_rules! on {
($name:ident) => {

View file

@ -8,6 +8,7 @@ static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
mod app;
mod completion;
mod components;
mod confirm;
mod context;
mod executor;
mod help;

View file

@ -3,7 +3,7 @@ use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget};
use tracing::error;
use yazi_plugin::{bindings::Cast, elements::render_widgets, LUA};
use super::{completion, input, select, tasks, which};
use super::{completion, confirm, input, select, tasks, which};
use crate::{components, help, Ctx};
pub(super) struct Root<'a> {
@ -41,6 +41,10 @@ impl<'a> Widget for Root<'a> {
input::Input::new(self.cx).render(area, buf);
}
if self.cx.confirm.visible {
confirm::Confirm::new(self.cx).render(area, buf);
}
if self.cx.help.visible {
help::Layout::new(self.cx).render(area, buf);
}

View file

@ -31,6 +31,8 @@ impl<'a> Router<'a> {
self.matches(Layer::Help, key)
} else if cx.input.visible {
self.matches(Layer::Input, key)
} else if cx.confirm.visible {
self.matches(Layer::Confirm, key)
} else if cx.select.visible {
self.matches(Layer::Select, key)
} else if cx.tasks.visible {

View file

@ -0,0 +1,37 @@
use std::pin::Pin;
use mlua::{prelude::LuaUserDataMethods, UserData};
use tokio::pin;
use tokio_stream::StreamExt;
use yazi_shared::InputError;
pub struct ConfirmRx<T: StreamExt<Item = Result<String, InputError>>> {
inner: T,
}
impl<T: StreamExt<Item = Result<String, InputError>>> InputRx<T> {
pub fn new(inner: T) -> Self { Self { inner } }
pub async fn consume(inner: T) -> (Option<String>, u8) {
pin!(inner);
inner.next().await.map(Self::parse).unwrap_or((None, 0))
}
fn parse(res: Result<String, InputError>) -> (Option<String>, u8) {
match res {
Ok(s) => (Some(s), 1),
Err(InputError::Canceled(s)) => (Some(s), 2),
Err(InputError::Typed(s)) => (Some(s), 3),
_ => (None, 0),
}
}
}
impl<T: StreamExt<Item = Result<String, InputError>> + 'static> UserData for InputRx<T> {
fn add_methods<'lua, M: LuaUserDataMethods<'lua, Self>>(methods: &mut M) {
methods.add_async_method_mut("recv", |_, me, ()| async move {
let mut inner = unsafe { Pin::new_unchecked(&mut me.inner) };
Ok(inner.next().await.map(Self::parse).unwrap_or((None, 0)))
});
}
}

View file

@ -26,7 +26,7 @@ impl<'a> TryFrom<mlua::Table<'a>> for Position {
x: t.raw_get("x").unwrap_or_default(),
y: t.raw_get("y").unwrap_or_default(),
width: t.raw_get("w")?,
height: 3,
height: t.raw_get("h").unwrap_or(3),
},
}))
}

View file

@ -3,8 +3,8 @@ use std::{str::FromStr, time::Duration};
use mlua::{ExternalError, ExternalResult, IntoLuaMulti, Lua, Table, Value};
use tokio::sync::mpsc;
use tokio_stream::wrappers::UnboundedReceiverStream;
use yazi_config::{keymap::{Control, Key}, popup::InputCfg};
use yazi_proxy::{AppProxy, InputProxy};
use yazi_config::{keymap::{Control, Key}, popup::{ConfirmCfg, InputCfg}};
use yazi_proxy::{AppProxy, ConfirmProxy, InputProxy};
use yazi_shared::{emit, event::Cmd, Debounce, Layer};
use super::Utils;
@ -86,6 +86,23 @@ impl Utils {
})?,
)?;
ya.raw_set(
"confirm",
lua.create_async_function(|lua, t: Table| async move {
let result = ConfirmProxy::show(ConfirmCfg {
title: t.raw_get("title")?,
message: t.raw_get("message")?,
position: Position::try_from(t.raw_get::<_, Table>("position")?)?.into(),
});
if let Ok(_answer) = result.await {
true.into_lua_multi(lua)
} else {
false.into_lua_multi(lua)
}
})?,
)?;
ya.raw_set(
"notify",
lua.create_function(|_, t: Table| {

14
yazi-proxy/src/confirm.rs Normal file
View file

@ -0,0 +1,14 @@
use tokio::sync::oneshot;
use yazi_config::popup::ConfirmCfg;
use yazi_shared::{emit, event::Cmd, Layer};
pub struct ConfirmProxy;
impl ConfirmProxy {
#[inline]
pub async fn show(cfg: ConfirmCfg) -> anyhow::Result<bool> {
let (tx, rx) = oneshot::channel();
emit!(Call(Cmd::new("show").with_any("tx", tx).with_any("cfg", cfg), Layer::Confirm));
rx.await?
}
}

View file

@ -1,5 +1,6 @@
mod app;
mod completion;
mod confirm;
mod input;
mod manager;
pub mod options;
@ -10,6 +11,7 @@ mod tasks;
pub use app::*;
pub use completion::*;
pub use confirm::*;
pub use input::*;
pub use manager::*;
pub use select::*;

View file

@ -10,6 +10,7 @@ pub enum Layer {
Tasks,
Select,
Input,
Confirm,
Help,
Completion,
Which,
@ -23,6 +24,7 @@ impl Display for Layer {
Self::Tasks => "tasks",
Self::Select => "select",
Self::Input => "input",
Self::Confirm => "confirm",
Self::Help => "help",
Self::Completion => "completion",
Self::Which => "which",
@ -40,6 +42,7 @@ impl FromStr for Layer {
"tasks" => Self::Tasks,
"select" => Self::Select,
"input" => Self::Input,
"confirm" => Self::Confirm,
"help" => Self::Help,
"completion" => Self::Completion,
"which" => Self::Which,