diff --git a/Cargo.lock b/Cargo.lock index 85e3adae..8f51c306 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -886,6 +886,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "freedesktop_entry_parser" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db9c27b72f19a99a895f8ca89e2d26e4ef31013376e56fdafef697627306c3e4" +dependencies = [ + "nom", + "thiserror", +] + [[package]] name = "from_variants" version = "1.0.2" @@ -1584,6 +1594,7 @@ dependencies = [ "color-eyre", "ctrlc", "dirs", + "freedesktop_entry_parser", "futures-lite 2.3.0", "futures-util", "glib", @@ -1612,6 +1623,7 @@ dependencies = [ "tracing-appender", "tracing-error", "tracing-subscriber", + "unicode-segmentation", "universal-config", "upower_dbus", "walkdir", @@ -3359,9 +3371,9 @@ dependencies = [ [[package]] name = "unicode-segmentation" -version = "1.10.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" +checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" [[package]] name = "unicode-xid" diff --git a/Cargo.toml b/Cargo.toml index 274acead..388d5506 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ default = [ "http", "ipc", "launcher", + "menu", "music+all", "notifications", "sys_info", @@ -56,6 +57,8 @@ focused = [] launcher = [] +menu = ["dep:freedesktop_entry_parser", "dep:unicode-segmentation"] + music = ["regex"] "music+all" = ["music", "music+mpris", "music+mpd"] "music+mpris" = ["music", "mpris"] @@ -130,6 +133,10 @@ nix = { version = "0.27.1", optional = true, features = ["event"] } # clock chrono = { version = "0.4.38", optional = true, default_features = false, features = ["clock", "unstable-locales"] } +# menu +freedesktop_entry_parser = { version = "1.3.0", optional = true } +unicode-segmentation = { version = "1.11.0", optional = true } + # music mpd-utils = { version = "0.2.1", optional = true } mpris = { version = "2.0.1", optional = true } diff --git a/docs/modules/Menu.md b/docs/modules/Menu.md new file mode 100644 index 00000000..90a745ae --- /dev/null +++ b/docs/modules/Menu.md @@ -0,0 +1,133 @@ +Application menu that shows installed programs and optionally custom entries. Clicking the menu button will open the main menu, clicking on any application category will open a sub-menu with any installed applications that match. + +## Configuration + +> Type: `menu` + +| | Type | Default | Description | +|--------------|------------|---------|-----------------------------------------------------------------------------------------------------| +| `start` | `MenuEntry[]` | `[]` | List of menu entries | +| `center` | `MenuEntry[]` | default XDG menu | List of menu entries. By default this shows a number of XDG entries that should cover all common applications | +| `end` | `MenuEntry[]` | `[]` | List of menu entries | +| `height` | `integer | null` | `null` | The height of the menu, leave null for it to resize dynamically | +| `width` | `integer | null` | `null` | The width of the menu, leave null for it to resize dynamically | +| `max_label_length` | `integer` | `25` | Maximum length for the label of an XDG entry | +| `label` | `string | null` | `≡` | The label of the button that opens the menu | +| `label_icon` | `string | null` | `null` | An icon (from icon theme) to display on the button which opens the application menu | +| `label_icon_size` | `integer` | `16` | Size of the label_icon if one is supplied | + + +> Type: `MenuEntry` + +| | Type | Default | Description | +|--------------|------------|---------|-----------------------------------------------------------------------------------------------------| +| `type` | `xdg_entry | xdg_other | custom` | | Type of the entry | +| `label` | `string` | | Label of the entry's button | +| `icon` | `string | null` | `null` | Icon for the entry's button | +| `categories` | `string[]` | | If `xdg_entry` this is is the list of freedesktop.org categories to include in this entry's sub menu | +| `on_click` | `string` | | If `custom` this is a shell command to execute when the entry's button is clicked | + +
+ +JSON + +```json +{ + "start": [ + { + "type": "menu", + "start": [ + { + "type": "custom", + "label": "Terminal", + "on_click": "xterm", + } + ], + "height": 440, + "width": 200, + "icon": "archlinux", + "label": null + } + ] +} + + +``` + +
+ +
+TOML + +```toml +[[start.menu]] +height = 400 +width = 200 +icon = "archlinux" +label = null + +[[start.menu.start]] +type = "custom" +label = "Terminal" +on_click = "xterm" +``` + +
+ +
+YAML + +```yaml +start: + - type: "menu" + start: + - type: custom + label: Terminal + on_click: xterm + height: 440 + width: 200 + icon: archlinux + label: null +``` + +
+ +
+Corn + +```corn +{ + start = [ + { + type = "menu" + start = [ + { + type = "custom" + label = "Terminal" + on_click = "xterm" + } + ] + height = 440 + width = 200 + icon = "archlinux" + label = null + } + ] +} +``` + +
+ +## Styling + +| Selector | Description | +|-------------------------------|--------------------------------| +| `.menu` | Menu button | +| `.menu-popup` | Main container of the popup | +| `.menu-popup_main` | Main menu of the menu | +| `.menu-popup_main_start` | Container for `start` entries | +| `.menu-popup_main_center` | Container for `center` entries | +| `.menu-popup_main_end` | Container for `end` entries | +| `.menu-popup_sub-menu` | All sub-menues | + +For more information on styling, please see the [styling guide](styling-guide). \ No newline at end of file diff --git a/src/config/mod.rs b/src/config/mod.rs index 44ee8db8..a8d18074 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -14,6 +14,8 @@ use crate::modules::focused::FocusedModule; use crate::modules::label::LabelModule; #[cfg(feature = "launcher")] use crate::modules::launcher::LauncherModule; +#[cfg(feature = "menu")] +use crate::modules::menu::MenuModule; #[cfg(feature = "music")] use crate::modules::music::MusicModule; #[cfg(feature = "notifications")] @@ -54,6 +56,8 @@ pub enum ModuleConfig { Label(Box), #[cfg(feature = "launcher")] Launcher(Box), + #[cfg(feature = "menu")] + Menu(Box), #[cfg(feature = "music")] Music(Box), #[cfg(feature = "notifications")] @@ -97,6 +101,8 @@ impl ModuleConfig { Self::Label(module) => create!(module), #[cfg(feature = "launcher")] Self::Launcher(module) => create!(module), + #[cfg(feature = "menu")] + Self::Menu(module) => create!(module), #[cfg(feature = "music")] Self::Music(module) => create!(module), #[cfg(feature = "notifications")] diff --git a/src/desktop_file.rs b/src/desktop_file.rs index 375b198b..757768fb 100644 --- a/src/desktop_file.rs +++ b/src/desktop_file.rs @@ -47,7 +47,7 @@ fn find_application_dirs() -> Vec { } /// Finds all the desktop files -fn find_desktop_files() -> Vec { +pub fn find_desktop_files() -> Vec { let dirs = find_application_dirs(); dirs.into_iter() .flat_map(|dir| { diff --git a/src/modules/menu.rs b/src/modules/menu.rs new file mode 100644 index 00000000..c26c9527 --- /dev/null +++ b/src/modules/menu.rs @@ -0,0 +1,722 @@ +use color_eyre::eyre::Report; +use color_eyre::Result; +use freedesktop_entry_parser::Entry; +use glib::Propagation; +use gtk::{prelude::*, IconTheme}; +use gtk::{Align, Button, Label, Orientation}; +use indexmap::IndexMap; +use serde::Deserialize; +use std::process::{Command, Stdio}; +use tokio::sync::{broadcast, mpsc}; +use unicode_segmentation::UnicodeSegmentation; + +use crate::config::{BarPosition, CommonConfig}; +use crate::desktop_file::find_desktop_files; +use crate::image::ImageProvider; +use crate::modules::{ + Module, ModuleInfo, ModuleParts, ModulePopup, ModuleUpdateEvent, PopupButton, WidgetContext, +}; +use crate::script::Script; +use crate::{glib_recv, module_impl, spawn, try_send}; +use tracing::{debug, error}; + +use super::ModuleLocation; + +const fn default_length() -> usize { + 25 +} + +fn default_menu_popup_label() -> Option { + Some("≡".to_string()) +} + +const fn default_menu_popup_icon_size() -> i32 { + 16 +} + +const OTHER_LABEL: &str = "Other"; + +#[derive(Debug, Deserialize, Clone)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum MenuConfig { + XdgEntry(XdgEntry), + XdgOther, + Custom(CustomEntry), +} + +#[derive(Debug, Deserialize, Clone)] +pub struct XdgEntry { + label: String, + + #[serde(default)] + icon: Option, + + categories: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct CustomEntry { + icon: Option, + label: String, + on_click: String, +} + +#[derive(Debug, Clone)] +pub struct XdgSection { + label: String, + icon: Option, + applications: IndexMap, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct MenuApplication { + label: String, + file_name: String, + categories: Vec, +} +enum MenuEntry { + Xdg(XdgSection), + Custom(CustomEntry), +} + +impl MenuEntry { + fn label(&self) -> String { + match self { + Self::Xdg(entry) => entry.label.clone(), + Self::Custom(entry) => entry.label.clone(), + } + } + fn icon(&self) -> Option { + match self { + Self::Xdg(entry) => entry.icon.clone(), + Self::Custom(entry) => entry.icon.clone(), + } + } +} + +#[derive(Debug, Deserialize, Clone)] +pub struct MenuModule { + #[serde(default)] + start: Vec, + + #[serde(default = "default_menu")] + center: Vec, + + #[serde(default)] + end: Vec, + + #[serde(default)] + height: Option, + + #[serde(default)] + width: Option, + + #[serde(default = "default_length")] + max_label_length: usize, + + #[serde(default = "default_menu_popup_label")] + label: Option, + + #[serde(default)] + label_icon: Option, + + #[serde(default = "default_menu_popup_icon_size")] + label_icon_size: i32, + + #[serde(flatten)] + pub common: Option, +} + +impl Default for MenuModule { + fn default() -> Self { + MenuModule { + start: vec![], + center: default_menu(), + end: vec![], + height: None, + width: None, + max_label_length: default_length(), + label: default_menu_popup_label(), + label_icon: None, + label_icon_size: default_menu_popup_icon_size(), + common: Some(CommonConfig::default()), + } + } +} + +fn default_menu() -> Vec { + vec![ + MenuConfig::XdgEntry(XdgEntry { + label: "Settings".to_string(), + icon: Some("preferences-system".to_string()), + categories: vec!["Settings".to_string(), "Screensaver".to_string()], + }), + MenuConfig::XdgEntry(XdgEntry { + label: "Accessories".to_string(), + icon: Some("accessories".to_string()), + categories: vec![ + "Accessibility".to_string(), + "Core".to_string(), + "Legacy".to_string(), + "Utility".to_string(), + ], + }), + MenuConfig::XdgEntry(XdgEntry { + label: "Development".to_string(), + icon: Some("applications-development".to_string()), + categories: vec!["Development".to_string()], + }), + MenuConfig::XdgEntry(XdgEntry { + label: "Education".to_string(), + icon: Some("applications-education".to_string()), + categories: vec!["Education".to_string()], + }), + MenuConfig::XdgEntry(XdgEntry { + label: "Games".to_string(), + icon: Some("applications-games".to_string()), + categories: vec!["Game".to_string()], + }), + MenuConfig::XdgEntry(XdgEntry { + label: "Graphics".to_string(), + icon: Some("applications-graphics".to_string()), + categories: vec!["Graphics".to_string()], + }), + MenuConfig::XdgEntry(XdgEntry { + label: "Multimedia".to_string(), + icon: Some("applications-multimedia".to_string()), + categories: vec![ + "Audio".to_string(), + "Video".to_string(), + "AudioVideo".to_string(), + ], + }), + MenuConfig::XdgEntry(XdgEntry { + label: "Network".to_string(), + icon: Some("applications-internet".to_string()), + categories: vec!["Network".to_string()], + }), + MenuConfig::XdgEntry(XdgEntry { + label: "Office".to_string(), + icon: Some("applications-office".to_string()), + categories: vec!["Office".to_string()], + }), + MenuConfig::XdgEntry(XdgEntry { + label: "Science".to_string(), + icon: Some("applications-science".to_string()), + categories: vec!["Science".to_string()], + }), + MenuConfig::XdgEntry(XdgEntry { + label: "System".to_string(), + icon: Some("applications-system".to_string()), + categories: vec!["Emulator".to_string(), "System".to_string()], + }), + MenuConfig::XdgOther, + ] +} + +/* +type:xdg +categories: [Foo, Bar] + */ + +fn parse_config( + section_config: Vec, + mut sections_by_cat: IndexMap>, +) -> (IndexMap, IndexMap>) { + let mut entries = IndexMap::::new(); + section_config + .iter() + .for_each(|entry_config| match entry_config { + MenuConfig::XdgEntry(entry) => { + entry.categories.iter().for_each(|cat| { + let existing = sections_by_cat.get_mut(cat); + if let Some(existing) = existing { + existing.push(entry.label.clone()); + } else { + sections_by_cat.insert(cat.clone(), vec![entry.label.clone()]); + } + }); + let _ = entries.insert_sorted( + entry.label.clone(), + MenuEntry::Xdg(XdgSection { + label: entry.label.clone(), + icon: entry.icon.clone(), + applications: IndexMap::new(), + }), + ); + } + MenuConfig::XdgOther => { + let _ = entries.insert_sorted( + OTHER_LABEL.to_string(), + MenuEntry::Xdg(XdgSection { + label: OTHER_LABEL.to_string(), + icon: Some("applications-other".to_string()), + applications: IndexMap::new(), + }), + ); + } + MenuConfig::Custom(entry) => { + let _ = entries.insert_sorted( + entry.label.clone(), + MenuEntry::Custom(CustomEntry { + icon: entry.icon.clone(), + label: entry.label.clone(), + on_click: entry.on_click.clone(), + }), + ); + } + }); + (entries, sections_by_cat) +} + +fn make_entry( + entry: &MenuEntry, + tx: mpsc::Sender>, + icon_theme: IconTheme, +) -> (Button, Option) { + let button = Button::new(); + let button_container = gtk::Box::new(Orientation::Horizontal, 4); + let label = Label::builder().label(entry.label()).build(); + label.set_halign(Align::Start); + button.add(&button_container); + + if let Some(icon_name) = entry.icon() { + let gtk_image = gtk::Image::new(); + gtk_image.set_halign(Align::Start); + let image = ImageProvider::parse(&icon_name, &icon_theme, true, 16); + if let Some(image) = image { + button_container.add(>k_image); + + if let Err(err) = image.load_into_image(gtk_image) { + error!("{err:?}"); + } + }; + } + button_container.add(&label); + button_container.foreach(|child| { + child.set_halign(Align::Start); + }); + if let MenuEntry::Xdg(_) = entry { + let right_arrow = Label::builder().label("🢒").build(); + right_arrow.set_halign(Align::End); + button_container.pack_end(&right_arrow, false, false, 0); + } + + button.show_all(); + + let sub_menu = match entry { + MenuEntry::Xdg(entry) => { + let sub_menu = gtk::Box::new(Orientation::Vertical, 0); + entry.applications.values().for_each(|sub_entry| { + let mut button = Button::builder(); + button = button.label(sub_entry.label.clone()); + let button = button.build(); + + let icon_name = sub_entry.file_name.trim_end_matches(".desktop"); + let gtk_image = gtk::Image::new(); + let image = ImageProvider::parse(icon_name, &icon_theme, true, 16); + if let Some(image) = image { + button.set_image(Some(>k_image)); + button.set_always_show_image(true); + + if let Err(err) = image.load_into_image(gtk_image) { + error!("{err:?}"); + } + }; + button.foreach(|child| { + child.set_halign(Align::Start); + }); + sub_menu.add(&button); + + { + let sub_menu = sub_menu.clone(); + let file_name = sub_entry.file_name.clone(); + let tx = tx.clone(); + button.connect_clicked(move |_button| { + let _ = Command::new("gtk-launch") + .arg(file_name.clone()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); + sub_menu.hide(); + try_send!(tx, ModuleUpdateEvent::ClosePopup); + }); + } + + button.show_all(); + }); + Some(sub_menu) + } + MenuEntry::Custom(_) => None, + }; + + (button, sub_menu) +} + +fn add_entries( + entry: &MenuEntry, + button: Button, + sub_menu: Option, + main_menu: gtk::Box, + container: gtk::Box, + height: Option, +) { + let container1 = container.clone(); + main_menu.add(&button); + + if let Some(sub_menu) = sub_menu { + if let Some(height) = height { + container.set_height_request(height); + let scrolled = gtk::ScrolledWindow::builder() + .max_content_height(height) + .hscrollbar_policy(gtk::PolicyType::Never) + .build(); + sub_menu.show(); + scrolled.add(&sub_menu); + container.add(&scrolled); + + let sub_menu1 = scrolled.clone(); + let sub_menu_popup_container = sub_menu.clone(); + button.connect_clicked(move |_button| { + container1.children().iter().skip(1).for_each(|sub_menu| { + if sub_menu.get_visible() { + sub_menu.hide(); + } + }); + sub_menu1.show(); + // Reset scroll to top. + if let Some(w) = sub_menu_popup_container.children().first() { + w.set_has_focus(true) + } + }); + } else { + container.add(&sub_menu); + let sub_menu1 = sub_menu.clone(); + button.connect_clicked(move |_button| { + container1.children().iter().skip(1).for_each(|sub_menu| { + if sub_menu.get_visible() { + sub_menu.hide(); + } + }); + sub_menu1.show(); + }); + } + } + if let MenuEntry::Custom(entry) = entry { + let label = entry.on_click.clone(); + let container = container.clone(); + button.connect_clicked(move |_button| { + container.children().iter().skip(1).for_each(|sub_menu| { + sub_menu.hide(); + }); + let script = Script::from(label.as_str()); + debug!("executing command: '{}'", script.cmd); + + let args = Vec::new(); + + spawn(async move { + if let Err(err) = script.get_output(Some(&args)).await { + error!("{err:?}"); + } + }); + }); + } + main_menu.show_all(); +} + +impl Module