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