diff --git a/rmenu-plugin/src/bin/main.rs b/rmenu-plugin/src/bin/main.rs index b3aca11..fc59cfe 100644 --- a/rmenu-plugin/src/bin/main.rs +++ b/rmenu-plugin/src/bin/main.rs @@ -138,6 +138,9 @@ struct OptionArgs { /// Override Single-Click Activation Option #[arg(long)] pub single_click: Option, + /// Override Right-Click Context-Menu Activation Option + #[arg(long)] + pub context_menu: Option, // search settings /// Override Default Placeholder #[arg(short = 'P', long)] @@ -204,6 +207,7 @@ impl Into for OptionArgs { placeholder: self.placeholder, hover_select: self.hover_select, single_click: self.single_click, + context_menu: self.context_menu, search_restrict: self.search_restrict, search_max_length: self.search_max_length, key_exec: self.key_exec, diff --git a/rmenu-plugin/src/lib.rs b/rmenu-plugin/src/lib.rs index dacb39a..256d08b 100644 --- a/rmenu-plugin/src/lib.rs +++ b/rmenu-plugin/src/lib.rs @@ -98,6 +98,8 @@ pub struct Options { pub hover_select: Option, #[serde(skip_serializing_if = "Option::is_none")] pub single_click: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub context_menu: Option, // search settings #[serde(skip_serializing_if = "Option::is_none")] pub placeholder: Option, diff --git a/rmenu/public/default.css b/rmenu/public/default.css index f9ac49a..6aa8951 100644 --- a/rmenu/public/default.css +++ b/rmenu/public/default.css @@ -104,3 +104,32 @@ img { flex-direction: column; 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; +} diff --git a/rmenu/src/cli.rs b/rmenu/src/cli.rs index d999b25..d98f904 100644 --- a/rmenu/src/cli.rs +++ b/rmenu/src/cli.rs @@ -85,6 +85,9 @@ pub struct Args { /// Activate Menu Result with Single Click #[arg(long)] single_click: Option, + /// Allow Right Click Context Menu + #[arg(long)] + context_menu: Option, // search settings /// Enforce Regex Pattern on Search @@ -226,6 +229,7 @@ impl Args { config.use_comments = self.use_icons.unwrap_or(config.use_comments); config.hover_select = self.hover_select.unwrap_or(config.hover_select); 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 cfg_replace!(config.search.restrict, self.search_restrict); cfg_replace!(config.search.max_length, self.search_max_length, true); diff --git a/rmenu/src/config.rs b/rmenu/src/config.rs index d59d19d..fefa06f 100644 --- a/rmenu/src/config.rs +++ b/rmenu/src/config.rs @@ -28,6 +28,7 @@ pub struct Config { pub use_comments: bool, pub hover_select: bool, pub single_click: bool, + pub context_menu: bool, pub search: SearchConfig, pub window: WindowConfig, pub keybinds: KeyConfig, @@ -46,6 +47,7 @@ impl Default for Config { use_comments: true, hover_select: false, single_click: false, + context_menu: false, search: Default::default(), window: Default::default(), keybinds: Default::default(), @@ -63,6 +65,7 @@ impl Config { cfg_replace!(self.jump_dist, options.jump_dist, true); cfg_replace!(self.hover_select, options.hover_select, true); cfg_replace!(self.single_click, options.single_click, true); + cfg_replace!(self.context_menu, options.context_menu, true); // search settings cfg_replace!(self.search.placeholder, options.placeholder); cfg_replace!(self.search.restrict, options.search_restrict); diff --git a/rmenu/src/gui/mod.rs b/rmenu/src/gui/mod.rs index d1b231b..850bdf4 100644 --- a/rmenu/src/gui/mod.rs +++ b/rmenu/src/gui/mod.rs @@ -7,7 +7,7 @@ mod image; mod state; pub use state::ContextBuilder; -use state::{Context, Position}; +use state::{Context, ContextMenu, Position}; 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()); let config = dioxus_desktop::Config::default() .with_window(window) - .with_menu(None); + .with_menu(None) + .with_disable_context_menu(true); LaunchBuilder::desktop() .with_cfg(config) .with_context(Rc::new(RefCell::new(ctx))) .launch(gui_main); } +fn gui_main() -> Element { + // build context and signals for state + let ctx = use_context::(); + 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.borrow_mut().cleanup(); + }); + + // update search results on search + let effect_ctx = use_context::(); + 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::(); + 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)] struct Row { + ctx_menu: Signal, position: Signal, search_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)); // build element from entry let single_click = context.config.single_click; + let context_menu = context.config.context_menu; let action_select = pos == row.search_index && subpos > 0; let aclass = action_select.then_some("active").unwrap_or_default(); let rclass = (pos == row.search_index && subpos == 0) @@ -77,6 +188,7 @@ fn gui_entry(mut row: Row) -> Element { .unwrap_or_default(); let result_ctx1 = use_context::(); let result_ctx2 = use_context::(); + let menu_active = row.ctx_menu.with(|m| m.is_active()); rsx! { div { class: "result-entry", @@ -85,21 +197,34 @@ fn gui_entry(mut row: Row) -> Element { id: "result-{row.search_index}", class: "result {rclass}", // actions + oncontextmenu: move |e| { + if context_menu { + let mouse: MouseData = e + .downcast::() + .cloned() + .unwrap() + .into(); + let coords = mouse.page_coordinates(); + row.ctx_menu.with_mut(|c| c.set(row.entry_index, coords)); + } + }, onmouseenter: move |_| { - if hover_select { + if hover_select && !menu_active { row.position.with_mut(|p| p.set(row.search_index, 0)); } }, onclick: move |_| { row.position.with_mut(|p| p.set(row.search_index, 0)); - if single_click { + if single_click && !menu_active { let pos = row.position.clone(); result_ctx1.borrow().execute(row.entry_index, &pos); } }, ondoubleclick: move |_| { - let pos = row.position.clone(); - result_ctx2.borrow().execute(row.entry_index, &pos); + if !menu_active { + let pos = row.position.clone(); + result_ctx2.borrow().execute(row.entry_index, &pos); + } }, // content if context.config.use_icons { @@ -180,88 +305,40 @@ fn gui_entry(mut row: Row) -> Element { } } -fn gui_main() -> Element { - // build context and signals for state +#[component] +fn context_menu(ctx_menu: Signal, position: Signal) -> Element { let ctx = use_context::(); - 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.borrow_mut().cleanup(); - }); - - // update search results on search - let effect_ctx = use_context::(); - 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::(); - 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 pattern = context.config.search.restrict.clone(); - let maxlength = context.config.search.max_length as i64; - let max_result = context.calc_limit(&position); + let index = ctx_menu.with(|c| c.entry.unwrap_or(0)); + let entry = context.get_entry(index); rsx! { - style { "{DEFAULT_CSS_CONTENT}" } - style { "{context.theme}" } - style { "{context.css}" } div { - id: "content", - class: "content", - onkeydown: keydown, - 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}", - position, - search_index: pos, - entry_index: *index, + id: "context-menu", + class: "context-menu", + style: ctx_menu.with(|m| m.style()), + ul { + class: "menu", + for (idx, name, ctx) in entry.actions + .iter() + .enumerate() + .map(|(idx, action)| { + let name = match idx == 0 { + true => format!("Launch {:?}", entry.name), + false => action.name.to_owned(), + }; + (idx, name, use_context::()) + }) { + li { + class: "menu-action", + a { + href: "#", + onclick: move |_| { + position.with_mut(|p| p.subpos = idx); + let pos = position.clone(); + ctx.borrow().execute(index, &pos); + }, + "{name}" + } } } } diff --git a/rmenu/src/gui/state.rs b/rmenu/src/gui/state.rs index fecafc6..88e465f 100644 --- a/rmenu/src/gui/state.rs +++ b/rmenu/src/gui/state.rs @@ -1,3 +1,4 @@ +use dioxus::html::geometry::euclid::Point2D; use dioxus::prelude::*; use rmenu_plugin::Entry; @@ -49,8 +50,37 @@ impl ContextBuilder { } } +/// Custom ContextMenu Tracker +#[derive(Debug, Default)] +pub struct ContextMenu { + pub entry: Option, + 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(&mut self, index: usize, coords: Point2D) { + self.entry = Some(index); + self.x = coords.x; + self.y = coords.y; + } + pub fn reset(&mut self) { + self.entry = None; + } +} + /// Global Position Tracker -#[derive(Default)] +#[derive(Debug, Default)] pub struct Position { pub pos: usize, pub subpos: usize,