mirror of
https://github.com/imgurbot12/rmenu.git
synced 2025-01-27 05:18:33 +01:00
feat: implement custom right-click context menu
This commit is contained in:
parent
d59003a8b0
commit
e19a791023
7 changed files with 234 additions and 85 deletions
|
@ -138,6 +138,9 @@ struct OptionArgs {
|
||||||
/// Override Single-Click Activation Option
|
/// Override Single-Click Activation Option
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub single_click: Option<bool>,
|
pub single_click: Option<bool>,
|
||||||
|
/// Override Right-Click Context-Menu Activation Option
|
||||||
|
#[arg(long)]
|
||||||
|
pub context_menu: Option<bool>,
|
||||||
// search settings
|
// search settings
|
||||||
/// Override Default Placeholder
|
/// Override Default Placeholder
|
||||||
#[arg(short = 'P', long)]
|
#[arg(short = 'P', long)]
|
||||||
|
@ -204,6 +207,7 @@ impl Into<Options> for OptionArgs {
|
||||||
placeholder: self.placeholder,
|
placeholder: self.placeholder,
|
||||||
hover_select: self.hover_select,
|
hover_select: self.hover_select,
|
||||||
single_click: self.single_click,
|
single_click: self.single_click,
|
||||||
|
context_menu: self.context_menu,
|
||||||
search_restrict: self.search_restrict,
|
search_restrict: self.search_restrict,
|
||||||
search_max_length: self.search_max_length,
|
search_max_length: self.search_max_length,
|
||||||
key_exec: self.key_exec,
|
key_exec: self.key_exec,
|
||||||
|
|
|
@ -98,6 +98,8 @@ pub struct Options {
|
||||||
pub hover_select: Option<bool>,
|
pub hover_select: Option<bool>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub single_click: Option<bool>,
|
pub single_click: Option<bool>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub context_menu: Option<bool>,
|
||||||
// search settings
|
// search settings
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub placeholder: Option<String>,
|
pub placeholder: Option<String>,
|
||||||
|
|
|
@ -104,3 +104,32 @@ img {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Context Menu */
|
||||||
|
|
||||||
|
.context-menu {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
.menu {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 0;
|
||||||
|
list-style-type: none;
|
||||||
|
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 10px 20px rgb(64 64 64 / 5%);
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
.menu > li > a {
|
||||||
|
font: inherit;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
width: -webkit-fill-available;
|
||||||
|
padding: 5px 30px 5px 15px;
|
||||||
|
|
||||||
|
color: #000;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: unset;
|
||||||
|
}
|
||||||
|
.menu > li > a:hover {
|
||||||
|
background-color: lightblue;
|
||||||
|
}
|
||||||
|
|
|
@ -85,6 +85,9 @@ pub struct Args {
|
||||||
/// Activate Menu Result with Single Click
|
/// Activate Menu Result with Single Click
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
single_click: Option<bool>,
|
single_click: Option<bool>,
|
||||||
|
/// Allow Right Click Context Menu
|
||||||
|
#[arg(long)]
|
||||||
|
context_menu: Option<bool>,
|
||||||
|
|
||||||
// search settings
|
// search settings
|
||||||
/// Enforce Regex Pattern on Search
|
/// Enforce Regex Pattern on Search
|
||||||
|
@ -226,6 +229,7 @@ impl Args {
|
||||||
config.use_comments = self.use_icons.unwrap_or(config.use_comments);
|
config.use_comments = self.use_icons.unwrap_or(config.use_comments);
|
||||||
config.hover_select = self.hover_select.unwrap_or(config.hover_select);
|
config.hover_select = self.hover_select.unwrap_or(config.hover_select);
|
||||||
config.single_click = self.single_click.unwrap_or(config.single_click);
|
config.single_click = self.single_click.unwrap_or(config.single_click);
|
||||||
|
config.context_menu = self.context_menu.unwrap_or(config.context_menu);
|
||||||
// override search settings
|
// override search settings
|
||||||
cfg_replace!(config.search.restrict, self.search_restrict);
|
cfg_replace!(config.search.restrict, self.search_restrict);
|
||||||
cfg_replace!(config.search.max_length, self.search_max_length, true);
|
cfg_replace!(config.search.max_length, self.search_max_length, true);
|
||||||
|
|
|
@ -28,6 +28,7 @@ pub struct Config {
|
||||||
pub use_comments: bool,
|
pub use_comments: bool,
|
||||||
pub hover_select: bool,
|
pub hover_select: bool,
|
||||||
pub single_click: bool,
|
pub single_click: bool,
|
||||||
|
pub context_menu: bool,
|
||||||
pub search: SearchConfig,
|
pub search: SearchConfig,
|
||||||
pub window: WindowConfig,
|
pub window: WindowConfig,
|
||||||
pub keybinds: KeyConfig,
|
pub keybinds: KeyConfig,
|
||||||
|
@ -46,6 +47,7 @@ impl Default for Config {
|
||||||
use_comments: true,
|
use_comments: true,
|
||||||
hover_select: false,
|
hover_select: false,
|
||||||
single_click: false,
|
single_click: false,
|
||||||
|
context_menu: false,
|
||||||
search: Default::default(),
|
search: Default::default(),
|
||||||
window: Default::default(),
|
window: Default::default(),
|
||||||
keybinds: Default::default(),
|
keybinds: Default::default(),
|
||||||
|
@ -63,6 +65,7 @@ impl Config {
|
||||||
cfg_replace!(self.jump_dist, options.jump_dist, true);
|
cfg_replace!(self.jump_dist, options.jump_dist, true);
|
||||||
cfg_replace!(self.hover_select, options.hover_select, true);
|
cfg_replace!(self.hover_select, options.hover_select, true);
|
||||||
cfg_replace!(self.single_click, options.single_click, true);
|
cfg_replace!(self.single_click, options.single_click, true);
|
||||||
|
cfg_replace!(self.context_menu, options.context_menu, true);
|
||||||
// search settings
|
// search settings
|
||||||
cfg_replace!(self.search.placeholder, options.placeholder);
|
cfg_replace!(self.search.placeholder, options.placeholder);
|
||||||
cfg_replace!(self.search.restrict, options.search_restrict);
|
cfg_replace!(self.search.restrict, options.search_restrict);
|
||||||
|
|
|
@ -7,7 +7,7 @@ mod image;
|
||||||
mod state;
|
mod state;
|
||||||
|
|
||||||
pub use state::ContextBuilder;
|
pub use state::ContextBuilder;
|
||||||
use state::{Context, Position};
|
use state::{Context, ContextMenu, Position};
|
||||||
|
|
||||||
const DEFAULT_CSS_CONTENT: &'static str = include_str!("../../public/default.css");
|
const DEFAULT_CSS_CONTENT: &'static str = include_str!("../../public/default.css");
|
||||||
|
|
||||||
|
@ -25,15 +25,125 @@ pub fn run(ctx: Context) {
|
||||||
.with_theme(ctx.config.window.get_theme());
|
.with_theme(ctx.config.window.get_theme());
|
||||||
let config = dioxus_desktop::Config::default()
|
let config = dioxus_desktop::Config::default()
|
||||||
.with_window(window)
|
.with_window(window)
|
||||||
.with_menu(None);
|
.with_menu(None)
|
||||||
|
.with_disable_context_menu(true);
|
||||||
LaunchBuilder::desktop()
|
LaunchBuilder::desktop()
|
||||||
.with_cfg(config)
|
.with_cfg(config)
|
||||||
.with_context(Rc::new(RefCell::new(ctx)))
|
.with_context(Rc::new(RefCell::new(ctx)))
|
||||||
.launch(gui_main);
|
.launch(gui_main);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn gui_main() -> Element {
|
||||||
|
// build context and signals for state
|
||||||
|
let ctx = use_context::<Ctx>();
|
||||||
|
let window = dioxus_desktop::use_window();
|
||||||
|
let mut search = use_signal(String::new);
|
||||||
|
let mut position = use_signal(Position::default);
|
||||||
|
let mut results = use_signal(|| ctx.borrow().all_results());
|
||||||
|
let mut ctx_menu = use_signal(ContextMenu::default);
|
||||||
|
|
||||||
|
// refocus on input
|
||||||
|
let js = format!("setTimeout(() => {{ document.getElementById('search').focus() }}, 100)");
|
||||||
|
eval(&js);
|
||||||
|
|
||||||
|
// configure exit cleanup function
|
||||||
|
use_drop(move || {
|
||||||
|
let ctx = consume_context::<Ctx>();
|
||||||
|
ctx.borrow_mut().cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
// update search results on search
|
||||||
|
let effect_ctx = use_context::<Ctx>();
|
||||||
|
use_effect(move || {
|
||||||
|
let search = search();
|
||||||
|
results.set(effect_ctx.borrow_mut().set_search(&search, &mut position));
|
||||||
|
});
|
||||||
|
|
||||||
|
// declare keyboard handler
|
||||||
|
let key_ctx = use_context::<Ctx>();
|
||||||
|
let keydown = move |e: KeyboardEvent| {
|
||||||
|
let context = key_ctx.borrow();
|
||||||
|
// suport console key
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
if e.code() == Code::Backquote {
|
||||||
|
window.devtool();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// calculate current entry index
|
||||||
|
let pos = position.with(|p| p.pos);
|
||||||
|
let index = results.with(|r| r.get(pos).cloned().unwrap_or(0));
|
||||||
|
// handle events
|
||||||
|
let quit = context.handle_keybinds(e, index, &mut position);
|
||||||
|
// if e.code() == Code::
|
||||||
|
// handle quit event
|
||||||
|
if quit {
|
||||||
|
window.set_visible(false);
|
||||||
|
spawn(async move {
|
||||||
|
// wait for window to vanish
|
||||||
|
let time = std::time::Duration::from_millis(50);
|
||||||
|
let window = dioxus_desktop::use_window();
|
||||||
|
while window.is_visible() {
|
||||||
|
tokio::time::sleep(time).await;
|
||||||
|
}
|
||||||
|
// actually close app after it becomes invisible
|
||||||
|
window.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let context = ctx.borrow();
|
||||||
|
let pattern = context.config.search.restrict.clone();
|
||||||
|
let maxlength = context.config.search.max_length as i64;
|
||||||
|
let max_result = context.calc_limit(&position);
|
||||||
|
rsx! {
|
||||||
|
style { "{DEFAULT_CSS_CONTENT}" }
|
||||||
|
style { "{context.theme}" }
|
||||||
|
style { "{context.css}" }
|
||||||
|
// menu content
|
||||||
|
div {
|
||||||
|
id: "content",
|
||||||
|
class: "content",
|
||||||
|
onclick: move |_| {
|
||||||
|
ctx_menu.with_mut(|m| m.reset());
|
||||||
|
},
|
||||||
|
onkeydown: keydown,
|
||||||
|
prevent_default: "contextmenu",
|
||||||
|
div {
|
||||||
|
id: "navbar",
|
||||||
|
class: "navbar",
|
||||||
|
input {
|
||||||
|
id: "search",
|
||||||
|
value: "{search}",
|
||||||
|
pattern: pattern,
|
||||||
|
maxlength: maxlength,
|
||||||
|
oninput: move |e| search.set(e.value()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
id: "results",
|
||||||
|
class: "results",
|
||||||
|
for (pos, index) in results().iter().take(max_result).enumerate() {
|
||||||
|
gui_entry {
|
||||||
|
key: "{pos}-{index}",
|
||||||
|
ctx_menu,
|
||||||
|
position,
|
||||||
|
search_index: pos,
|
||||||
|
entry_index: *index,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// custom context menu
|
||||||
|
context_menu {
|
||||||
|
ctx_menu,
|
||||||
|
position,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Props)]
|
#[derive(Clone, Props)]
|
||||||
struct Row {
|
struct Row {
|
||||||
|
ctx_menu: Signal<ContextMenu>,
|
||||||
position: Signal<Position>,
|
position: Signal<Position>,
|
||||||
search_index: usize,
|
search_index: usize,
|
||||||
entry_index: usize,
|
entry_index: usize,
|
||||||
|
@ -70,6 +180,7 @@ fn gui_entry(mut row: Row) -> Element {
|
||||||
let (pos, subpos) = row.position.with(|p| (p.pos, p.subpos));
|
let (pos, subpos) = row.position.with(|p| (p.pos, p.subpos));
|
||||||
// build element from entry
|
// build element from entry
|
||||||
let single_click = context.config.single_click;
|
let single_click = context.config.single_click;
|
||||||
|
let context_menu = context.config.context_menu;
|
||||||
let action_select = pos == row.search_index && subpos > 0;
|
let action_select = pos == row.search_index && subpos > 0;
|
||||||
let aclass = action_select.then_some("active").unwrap_or_default();
|
let aclass = action_select.then_some("active").unwrap_or_default();
|
||||||
let rclass = (pos == row.search_index && subpos == 0)
|
let rclass = (pos == row.search_index && subpos == 0)
|
||||||
|
@ -77,6 +188,7 @@ fn gui_entry(mut row: Row) -> Element {
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let result_ctx1 = use_context::<Ctx>();
|
let result_ctx1 = use_context::<Ctx>();
|
||||||
let result_ctx2 = use_context::<Ctx>();
|
let result_ctx2 = use_context::<Ctx>();
|
||||||
|
let menu_active = row.ctx_menu.with(|m| m.is_active());
|
||||||
rsx! {
|
rsx! {
|
||||||
div {
|
div {
|
||||||
class: "result-entry",
|
class: "result-entry",
|
||||||
|
@ -85,21 +197,34 @@ fn gui_entry(mut row: Row) -> Element {
|
||||||
id: "result-{row.search_index}",
|
id: "result-{row.search_index}",
|
||||||
class: "result {rclass}",
|
class: "result {rclass}",
|
||||||
// actions
|
// actions
|
||||||
|
oncontextmenu: move |e| {
|
||||||
|
if context_menu {
|
||||||
|
let mouse: MouseData = e
|
||||||
|
.downcast::<SerializedMouseData>()
|
||||||
|
.cloned()
|
||||||
|
.unwrap()
|
||||||
|
.into();
|
||||||
|
let coords = mouse.page_coordinates();
|
||||||
|
row.ctx_menu.with_mut(|c| c.set(row.entry_index, coords));
|
||||||
|
}
|
||||||
|
},
|
||||||
onmouseenter: move |_| {
|
onmouseenter: move |_| {
|
||||||
if hover_select {
|
if hover_select && !menu_active {
|
||||||
row.position.with_mut(|p| p.set(row.search_index, 0));
|
row.position.with_mut(|p| p.set(row.search_index, 0));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onclick: move |_| {
|
onclick: move |_| {
|
||||||
row.position.with_mut(|p| p.set(row.search_index, 0));
|
row.position.with_mut(|p| p.set(row.search_index, 0));
|
||||||
if single_click {
|
if single_click && !menu_active {
|
||||||
let pos = row.position.clone();
|
let pos = row.position.clone();
|
||||||
result_ctx1.borrow().execute(row.entry_index, &pos);
|
result_ctx1.borrow().execute(row.entry_index, &pos);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
ondoubleclick: move |_| {
|
ondoubleclick: move |_| {
|
||||||
|
if !menu_active {
|
||||||
let pos = row.position.clone();
|
let pos = row.position.clone();
|
||||||
result_ctx2.borrow().execute(row.entry_index, &pos);
|
result_ctx2.borrow().execute(row.entry_index, &pos);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
// content
|
// content
|
||||||
if context.config.use_icons {
|
if context.config.use_icons {
|
||||||
|
@ -180,88 +305,40 @@ fn gui_entry(mut row: Row) -> Element {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn gui_main() -> Element {
|
#[component]
|
||||||
// build context and signals for state
|
fn context_menu(ctx_menu: Signal<ContextMenu>, position: Signal<Position>) -> Element {
|
||||||
let ctx = use_context::<Ctx>();
|
let ctx = use_context::<Ctx>();
|
||||||
let window = dioxus_desktop::use_window();
|
|
||||||
let mut search = use_signal(String::new);
|
|
||||||
let mut position = use_signal(Position::default);
|
|
||||||
let mut results = use_signal(|| ctx.borrow().all_results());
|
|
||||||
|
|
||||||
// refocus on input
|
|
||||||
let js = format!("setTimeout(() => {{ document.getElementById('search').focus() }}, 100)");
|
|
||||||
eval(&js);
|
|
||||||
|
|
||||||
// configure exit cleanup function
|
|
||||||
use_drop(move || {
|
|
||||||
let ctx = consume_context::<Ctx>();
|
|
||||||
ctx.borrow_mut().cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
// update search results on search
|
|
||||||
let effect_ctx = use_context::<Ctx>();
|
|
||||||
use_effect(move || {
|
|
||||||
let search = search();
|
|
||||||
results.set(effect_ctx.borrow_mut().set_search(&search, &mut position));
|
|
||||||
});
|
|
||||||
|
|
||||||
// declare keyboard handler
|
|
||||||
let key_ctx = use_context::<Ctx>();
|
|
||||||
let keydown = move |e: KeyboardEvent| {
|
|
||||||
let context = key_ctx.borrow();
|
|
||||||
// calculate current entry index
|
|
||||||
let pos = position.with(|p| p.pos);
|
|
||||||
let index = results.with(|r| r.get(pos).cloned().unwrap_or(0));
|
|
||||||
// handle events
|
|
||||||
let quit = context.handle_keybinds(e, index, &mut position);
|
|
||||||
// handle quit event
|
|
||||||
if quit {
|
|
||||||
window.set_visible(false);
|
|
||||||
spawn(async move {
|
|
||||||
// wait for window to vanish
|
|
||||||
let time = std::time::Duration::from_millis(50);
|
|
||||||
let window = dioxus_desktop::use_window();
|
|
||||||
while window.is_visible() {
|
|
||||||
tokio::time::sleep(time).await;
|
|
||||||
}
|
|
||||||
// actually close app after it becomes invisible
|
|
||||||
window.close();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let context = ctx.borrow();
|
let context = ctx.borrow();
|
||||||
let pattern = context.config.search.restrict.clone();
|
let index = ctx_menu.with(|c| c.entry.unwrap_or(0));
|
||||||
let maxlength = context.config.search.max_length as i64;
|
let entry = context.get_entry(index);
|
||||||
let max_result = context.calc_limit(&position);
|
|
||||||
rsx! {
|
rsx! {
|
||||||
style { "{DEFAULT_CSS_CONTENT}" }
|
|
||||||
style { "{context.theme}" }
|
|
||||||
style { "{context.css}" }
|
|
||||||
div {
|
div {
|
||||||
id: "content",
|
id: "context-menu",
|
||||||
class: "content",
|
class: "context-menu",
|
||||||
onkeydown: keydown,
|
style: ctx_menu.with(|m| m.style()),
|
||||||
div {
|
ul {
|
||||||
id: "navbar",
|
class: "menu",
|
||||||
class: "navbar",
|
for (idx, name, ctx) in entry.actions
|
||||||
input {
|
.iter()
|
||||||
id: "search",
|
.enumerate()
|
||||||
value: "{search}",
|
.map(|(idx, action)| {
|
||||||
pattern: pattern,
|
let name = match idx == 0 {
|
||||||
maxlength: maxlength,
|
true => format!("Launch {:?}", entry.name),
|
||||||
oninput: move |e| search.set(e.value()),
|
false => action.name.to_owned(),
|
||||||
}
|
};
|
||||||
}
|
(idx, name, use_context::<Ctx>())
|
||||||
div {
|
}) {
|
||||||
id: "results",
|
li {
|
||||||
class: "results",
|
class: "menu-action",
|
||||||
for (pos, index) in results().iter().take(max_result).enumerate() {
|
a {
|
||||||
gui_entry {
|
href: "#",
|
||||||
key: "{pos}-{index}",
|
onclick: move |_| {
|
||||||
position,
|
position.with_mut(|p| p.subpos = idx);
|
||||||
search_index: pos,
|
let pos = position.clone();
|
||||||
entry_index: *index,
|
ctx.borrow().execute(index, &pos);
|
||||||
|
},
|
||||||
|
"{name}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
use dioxus::html::geometry::euclid::Point2D;
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
use rmenu_plugin::Entry;
|
use rmenu_plugin::Entry;
|
||||||
|
|
||||||
|
@ -49,8 +50,37 @@ impl ContextBuilder {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Custom ContextMenu Tracker
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct ContextMenu {
|
||||||
|
pub entry: Option<usize>,
|
||||||
|
pub x: f64,
|
||||||
|
pub y: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ContextMenu {
|
||||||
|
#[inline]
|
||||||
|
pub fn is_active(&self) -> bool {
|
||||||
|
self.entry.is_some()
|
||||||
|
}
|
||||||
|
pub fn style(&self) -> String {
|
||||||
|
if self.entry.is_none() {
|
||||||
|
return "display: hidden".to_owned();
|
||||||
|
}
|
||||||
|
return format!("display: block; left: {}px; top: {}px", self.x, self.y);
|
||||||
|
}
|
||||||
|
pub fn set<T>(&mut self, index: usize, coords: Point2D<f64, T>) {
|
||||||
|
self.entry = Some(index);
|
||||||
|
self.x = coords.x;
|
||||||
|
self.y = coords.y;
|
||||||
|
}
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.entry = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Global Position Tracker
|
/// Global Position Tracker
|
||||||
#[derive(Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct Position {
|
pub struct Position {
|
||||||
pub pos: usize,
|
pub pos: usize,
|
||||||
pub subpos: usize,
|
pub subpos: usize,
|
||||||
|
|
Loading…
Reference in a new issue