diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 38abaf9..9f5e386 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -113,6 +113,7 @@ jobs:
- keyboard+hyprland
- label
- launcher
+ - menu
- music+all
- music+mpris
- music+mpd
diff --git a/Cargo.toml b/Cargo.toml
index f94d8c3..bdb119d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -23,6 +23,7 @@ default = [
"keyboard+all",
"launcher",
"label",
+ "menu",
"music+all",
"network_manager",
"notifications",
@@ -76,6 +77,8 @@ label = []
launcher = []
+menu = []
+
music = ["dep:regex"]
"music+all" = ["music", "music+mpris", "music+mpd"]
"music+mpris" = ["music", "mpris"]
diff --git a/docs/modules/Menu.md b/docs/modules/Menu.md
new file mode 100644
index 0000000..793f4d0
--- /dev/null
+++ b/docs/modules/Menu.md
@@ -0,0 +1,157 @@
+Application menu that shows installed programs and optionally custom entries.
+This works by reading all `.desktop` files on the system.
+
+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.
+
+It is also possible to add custom categories and actions into the menu.
+
+
+
+## Configuration
+
+| | Type | Default | Description |
+|-----------------------|------------------------------------------------------|------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `start` | `MenuEntry[]` | `[]` | Items to add to the start of the main menu. |
+| `center` | `MenuEntry[]` | Default XDG menu | Items to add to the centre of the main menu. By default this shows a number of XDG entries that should cover all common applications. |
+| `end` | `MenuEntry[]` | `[]` | Items to add to the end of the main menu. |
+| `height` | `integer` | `null` | Height of the menu. Leave null to resize dynamically. |
+| `width` | `integer` | `null` | Width of the menu. Leave null to resize dynamically. |
+| `label` | `string` | `≡` | Label to show on the menu button on the bar. |
+| `label_icon` | `string` | `null` | Icon to show on the menu button on the bar. |
+| `label_icon_size` | `integer` | `16` | Size of the label_icon image. |
+| `truncate` | `'start'` or `'middle'` or `'end'` or `off` or `Map` | `off` | Applies to popup. The location of the ellipses and where to truncate text from. Leave null to avoid truncating. Use the long-hand `Map` version if specifying a length. |
+| `truncate.mode` | `'start'` or `'middle'` or `'end'` or `off` | `off` | Applies to popup. The location of the ellipses and where to truncate text from. Leave null to avoid truncating. |
+| `truncate.length` | `integer` | `null` | Applies to popup. The fixed width (in chars) of the widget. Leave blank to let GTK automatically handle. |
+| `truncate.max_length` | `integer` | `null` | Applies to popup. The maximum number of characters before truncating. Leave blank to let GTK automatically handle. |
+
+
+### `MenuEntry`
+
+Each entry can be one of three types:
+
+- `xdg_entry` - Contains all applications matching the configured `categories`.
+- `xdg_other` - Contains all applications not covered by `xdg_entry` categories.
+- `custom` - Individual shell command entry.
+
+| | Type | Default | Description |
+|--------------|----------------------------------------|---------|----------------------------------------------------------------------------------------|
+| `type` | `xdg_entry` or `xdg_other` or `custom` | | Type of the entry. |
+| `label` | `string` | `''` | Label of the entry's button. |
+| `icon` | `string` | `null` | Icon for the entry's button. |
+| `categories` | `string[]` | `[]` | [`xfg_entry`] List of freedesktop.org categories to include in this entry's sub menu . |
+| `on_click` | `string` | `''` | [`custom`] Shell command to execute when the entry's button is clicked |
+
+### Default XDG Menu
+
+Setting the `center` menu entries will override the default menu.
+
+The default menu can be found in the `default` example files [here](https://github.com/jakestanger/ironbar/blob/examples/menu/).
+
+
+
+JSON
+
+```json
+{
+ "start": [
+ {
+ "type": "menu",
+ "start": [
+ {
+ "type": "custom",
+ "label": "Terminal",
+ "on_click": "xterm"
+ }
+ ],
+ "height": 440,
+ "width": 200,
+ "icon": "archlinux",
+ "label": null
+ }
+ ]
+}
+
+
+```
+
+
+
+
+TOML
+
+```toml
+[[start]]
+type = "memu"
+height = 400
+width = 200
+icon = "archlinux"
+
+[[start.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 |
+| `.popup-menu` | Main container of the popup |
+| `.popup-menu .main` | Main menu of the menu |
+| `.popup-menu .main .category` | Category button |
+| `.popup-menu .main .category.open` | Open category button |
+| `.popup-menu .main .main-start` | Container for `start` entries |
+| `.popup-menu .main .main-center` | Container for `center` entries |
+| `.popup-menu .main .main-end` | Container for `end` entries |
+| `.popup-menu .sub-menu` | All sub-menus |
+| `.popup-menu .sub-menu .application` | Application button within submenu |
+
+For more information on styling, please see the [styling guide](styling-guide).
\ No newline at end of file
diff --git a/examples/menu/default.corn b/examples/menu/default.corn
new file mode 100644
index 0000000..fc0a20c
--- /dev/null
+++ b/examples/menu/default.corn
@@ -0,0 +1,102 @@
+let {
+ $menu = [
+ {
+ type = "xdg_entry"
+ label = "Accessories"
+ icon = "accessories"
+ categories = [
+ "Accessibility"
+ "Core"
+ "Legacy"
+ "Utility"
+ ]
+ }
+ {
+ type = "xdg_entry"
+ label = "Development"
+ icon = "applications-development"
+ categories = [
+ "Development"
+ ]
+ }
+ {
+ type = "xdg_entry"
+ label = "Education"
+ icon = "applications-education"
+ categories = [
+ "Education"
+ ]
+ }
+ {
+ type = "xdg_entry"
+ label = "Games"
+ icon = "applications-games"
+ categories = [
+ "Games"
+ ]
+ }
+ {
+ type = "xdg_entry"
+ label = "Graphics"
+ icon = "applications-graphics"
+ categories = [
+ "Graphics"
+ ]
+ }
+ {
+ type = "xdg_entry"
+ label = "Multimedia"
+ icon = "applications-multimedia"
+ categories = [
+ "Audio"
+ "Video"
+ "AudioVideo"
+ ]
+ }
+ {
+ type = "xdg_entry"
+ label = "Network"
+ icon = "applications-internet"
+ categories = [
+ "Network"
+ ]
+ }
+ {
+ type = "xdg_entry"
+ label = "Office"
+ icon = "applications-office"
+ categories = [
+ "Office"
+ ]
+ }
+ {
+ type = "xdg_entry"
+ label = "Science"
+ icon = "applications-science"
+ categories = [
+ "Science"
+ ]
+ }
+ {
+ type = "xdg_entry"
+ label = "System"
+ icon = "applications-system"
+ categories = [
+ "Emulator"
+ "System"
+ ]
+ }
+ { type = "xdg_other" }
+ {
+ type = "xdg_entry"
+ label = "Settings"
+ icon = "preferences-system"
+ categories = [
+ "Settings"
+ "Screensaver"
+ ]
+ }
+ ]
+} in {
+ start = [ { type = "menu" center = $menu } ]
+}
\ No newline at end of file
diff --git a/examples/menu/default.json b/examples/menu/default.json
new file mode 100644
index 0000000..92396fc
--- /dev/null
+++ b/examples/menu/default.json
@@ -0,0 +1,107 @@
+{
+ "start": [
+ {
+ "type": "menu",
+ "center": [
+ {
+ "type": "xdg_entry",
+ "label": "Accessories",
+ "icon": "accessories",
+ "categories": [
+ "Accessibility",
+ "Core",
+ "Legacy",
+ "Utility"
+ ]
+ },
+ {
+ "type": "xdg_entry",
+ "label": "Development",
+ "icon": "applications-development",
+ "categories": [
+ "Development"
+ ]
+ },
+ {
+ "type": "xdg_entry",
+ "label": "Education",
+ "icon": "applications-education",
+ "categories": [
+ "Education"
+ ]
+ },
+ {
+ "type": "xdg_entry",
+ "label": "Games",
+ "icon": "applications-games",
+ "categories": [
+ "Games"
+ ]
+ },
+ {
+ "type": "xdg_entry",
+ "label": "Graphics",
+ "icon": "applications-graphics",
+ "categories": [
+ "Graphics"
+ ]
+ },
+ {
+ "type": "xdg_entry",
+ "label": "Multimedia",
+ "icon": "applications-multimedia",
+ "categories": [
+ "Audio",
+ "Video",
+ "AudioVideo"
+ ]
+ },
+ {
+ "type": "xdg_entry",
+ "label": "Network",
+ "icon": "applications-internet",
+ "categories": [
+ "Network"
+ ]
+ },
+ {
+ "type": "xdg_entry",
+ "label": "Office",
+ "icon": "applications-office",
+ "categories": [
+ "Office"
+ ]
+ },
+ {
+ "type": "xdg_entry",
+ "label": "Science",
+ "icon": "applications-science",
+ "categories": [
+ "Science"
+ ]
+ },
+ {
+ "type": "xdg_entry",
+ "label": "System",
+ "icon": "applications-system",
+ "categories": [
+ "Emulator",
+ "System"
+ ]
+ },
+ {
+ "type": "xdg_other"
+ },
+ {
+ "type": "xdg_entry",
+ "label": "Settings",
+ "icon": "preferences-system",
+ "categories": [
+ "Settings",
+ "Screensaver"
+ ]
+ }
+ ]
+ }
+ ]
+}
diff --git a/examples/menu/default.toml b/examples/menu/default.toml
new file mode 100644
index 0000000..48e62d0
--- /dev/null
+++ b/examples/menu/default.toml
@@ -0,0 +1,87 @@
+[[start]]
+type = "menu"
+
+[[start.center]]
+type = "xdg_entry"
+label = "Accessories"
+icon = "accessories"
+categories = [
+ "Accessibility",
+ "Core",
+ "Legacy",
+ "Utility",
+]
+
+[[start.center]]
+type = "xdg_entry"
+label = "Development"
+icon = "applications-development"
+categories = ["Development"]
+
+[[start.center]]
+type = "xdg_entry"
+label = "Education"
+icon = "applications-education"
+categories = ["Education"]
+
+[[start.center]]
+type = "xdg_entry"
+label = "Games"
+icon = "applications-games"
+categories = ["Games"]
+
+[[start.center]]
+type = "xdg_entry"
+label = "Graphics"
+icon = "applications-graphics"
+categories = ["Graphics"]
+
+[[start.center]]
+type = "xdg_entry"
+label = "Multimedia"
+icon = "applications-multimedia"
+categories = [
+ "Audio",
+ "Video",
+ "AudioVideo",
+]
+
+[[start.center]]
+type = "xdg_entry"
+label = "Network"
+icon = "applications-internet"
+categories = ["Network"]
+
+[[start.center]]
+type = "xdg_entry"
+label = "Office"
+icon = "applications-office"
+categories = ["Office"]
+
+[[start.center]]
+type = "xdg_entry"
+label = "Science"
+icon = "applications-science"
+categories = ["Science"]
+
+[[start.center]]
+type = "xdg_entry"
+label = "System"
+icon = "applications-system"
+categories = [
+ "Emulator",
+ "System",
+]
+
+[[start.center]]
+type = "xdg_other"
+
+[[start.center]]
+type = "xdg_entry"
+label = "Settings"
+icon = "preferences-system"
+categories = [
+ "Settings",
+ "Screensaver",
+]
+
diff --git a/examples/menu/default.yml b/examples/menu/default.yml
new file mode 100644
index 0000000..c593d04
--- /dev/null
+++ b/examples/menu/default.yml
@@ -0,0 +1,68 @@
+start:
+ - type: menu
+ center:
+ - type: xdg_entry
+ label: Accessories
+ icon: accessories
+ categories:
+ - Accessibility
+ - Core
+ - Legacy
+ - Utility
+ - type: xdg_entry
+ label: Development
+ icon: applications-development
+ categories:
+ - Development
+ - type: xdg_entry
+ label: Education
+ icon: applications-education
+ categories:
+ - Education
+ - type: xdg_entry
+ label: Games
+ icon: applications-games
+ categories:
+ - Games
+ - type: xdg_entry
+ label: Graphics
+ icon: applications-graphics
+ categories:
+ - Graphics
+ - type: xdg_entry
+ label: Multimedia
+ icon: applications-multimedia
+ categories:
+ - Audio
+ - Video
+ - AudioVideo
+ - type: xdg_entry
+ label: Network
+ icon: applications-internet
+ categories:
+ - Network
+ - type: xdg_entry
+ label: Office
+ icon: applications-office
+ categories:
+ - Office
+ - type: xdg_entry
+ label: Science
+ icon: applications-science
+ categories:
+ - Science
+ - type: xdg_entry
+ label: System
+ icon: applications-system
+ categories:
+ - Emulator
+ - System
+ - type: xdg_other
+ - type: xdg_entry
+ label: Settings
+ icon: preferences-system
+ categories:
+ - Settings
+ - Screensaver
+
+
diff --git a/src/config/mod.rs b/src/config/mod.rs
index 088fdb3..0fb7875 100644
--- a/src/config/mod.rs
+++ b/src/config/mod.rs
@@ -21,6 +21,8 @@ use crate::modules::keyboard::KeyboardModule;
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 = "network_manager")]
@@ -75,6 +77,8 @@ pub enum ModuleConfig {
Label(Box),
#[cfg(feature = "launcher")]
Launcher(Box),
+ #[cfg(feature = "menu")]
+ Menu(Box),
#[cfg(feature = "music")]
Music(Box),
#[cfg(feature = "network_manager")]
@@ -127,6 +131,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 = "network_manager")]
diff --git a/src/desktop_file.rs b/src/desktop_file.rs
index 2d788df..3fdf346 100644
--- a/src/desktop_file.rs
+++ b/src/desktop_file.rs
@@ -53,6 +53,8 @@ impl DesktopFileRef {
let mut has_wm_class = false;
let mut has_exec = false;
let mut has_icon = false;
+ let mut has_categories = false;
+ let mut has_no_display = false;
while let Ok(Some(line)) = lines.next_line().await {
let Some((key, value)) = line.split_once('=') else {
@@ -60,31 +62,46 @@ impl DesktopFileRef {
};
match key {
- "Name" => {
+ "Name" if !has_name => {
desktop_file.name = Some(value.to_string());
has_name = true;
}
- "Type" => {
+ "Type" if !has_type => {
desktop_file.app_type = Some(value.to_string());
has_type = true;
}
- "StartupWMClass" => {
+ "StartupWMClass" if !has_wm_class => {
desktop_file.startup_wm_class = Some(value.to_string());
has_wm_class = true;
}
- "Exec" => {
+ "Exec" if !has_exec => {
desktop_file.exec = Some(value.to_string());
has_exec = true;
}
- "Icon" => {
+ "Icon" if !has_icon => {
desktop_file.icon = Some(value.to_string());
has_icon = true;
}
+ "Categories" if !has_categories => {
+ desktop_file.categories = value.split(';').map(|s| s.to_string()).collect();
+ has_categories = true;
+ }
+ "NoDisplay" if !has_no_display => {
+ desktop_file.no_display = Some(value.parse()?);
+ has_no_display = true;
+ }
_ => {}
}
// parsing complete - don't bother with the rest of the lines
- if has_name && has_type && has_wm_class && has_exec && has_icon {
+ if has_name
+ && has_type
+ && has_wm_class
+ && has_exec
+ && has_icon
+ && has_categories
+ && has_no_display
+ {
break;
}
}
@@ -101,6 +118,8 @@ pub struct DesktopFile {
pub startup_wm_class: Option,
pub exec: Option,
pub icon: Option,
+ pub categories: Vec,
+ pub no_display: Option,
}
impl DesktopFile {
@@ -112,6 +131,8 @@ impl DesktopFile {
startup_wm_class: None,
exec: None,
icon: None,
+ categories: vec![],
+ no_display: None,
}
}
}
@@ -159,6 +180,18 @@ impl DesktopFiles {
}
}
+ pub async fn get_all(&self) -> Result> {
+ let mut files = self.files.lock().await;
+
+ let mut res = Vec::with_capacity(files.len());
+ for file in files.values_mut() {
+ let file = file.get().await?;
+ res.push(file);
+ }
+
+ Ok(res)
+ }
+
/// Attempts to locate a applications file by file name or contents.
///
/// Input should typically be the app id, app name or icon.
diff --git a/src/modules/menu/config.rs b/src/modules/menu/config.rs
new file mode 100644
index 0000000..4575880
--- /dev/null
+++ b/src/modules/menu/config.rs
@@ -0,0 +1,271 @@
+use crate::config::{CommonConfig, TruncateMode};
+use crate::modules::menu::{MenuEntry, XdgSection};
+use indexmap::IndexMap;
+use serde::Deserialize;
+
+/// An individual entry in the main menu section.
+#[derive(Debug, Deserialize, Clone)]
+#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum MenuConfig {
+ /// Contains all applications matching the configured `categories`.
+ XdgEntry(XdgEntry),
+ /// Contains all applications not covered by `xdg_entry` categories.
+ XdgOther,
+ /// Individual shell command entry.
+ Custom(CustomEntry),
+}
+
+#[derive(Debug, Deserialize, Clone)]
+#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
+pub struct XdgEntry {
+ /// Text to display on the button.
+ #[serde(default)]
+ pub label: String,
+
+ /// Name of the image icon to show next to the label.
+ #[serde(default)]
+ pub icon: Option,
+
+ /// XDG categories the associated submenu should contain.
+ #[serde(default)]
+ pub categories: Vec,
+}
+
+/// Individual shell command entry.
+#[derive(Debug, Deserialize, Clone)]
+#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
+pub struct CustomEntry {
+ /// Text to display on the button.
+ #[serde(default)]
+ pub label: String,
+
+ /// Name of the image icon to show next to the label.
+ ///
+ /// **Default**: `null`
+ pub icon: Option,
+
+ /// Shell command to execute when the button is clicked.
+ /// This is run using `sh -c`.
+ #[serde(default)]
+ pub on_click: String,
+}
+
+#[derive(Debug, Deserialize, Clone)]
+#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
+pub struct MenuModule {
+ /// Items to add to the start of the main menu.
+ ///
+ /// **Default**: `[]`
+ #[serde(default)]
+ pub(super) start: Vec,
+
+ /// Items to add to the start of the main menu.
+ ///
+ /// By default, this shows a number of XDG entries
+ /// that should cover all common applications.
+ ///
+ /// **Default**: See `examples/menu/default`
+ #[serde(default = "default_menu")]
+ pub(super) center: Vec,
+
+ /// Items to add to the end of the main menu.
+ ///
+ /// **Default**: `[]`
+ #[serde(default)]
+ pub(super) end: Vec,
+
+ /// Fixed height of the menu.
+ ///
+ /// When set, if the number of (sub)menu entries exceeds this value,
+ /// a scrollbar will be shown.
+ ///
+ /// Leave null to resize dynamically.
+ ///
+ /// **Default**: `null`
+ #[serde(default)]
+ pub(super) height: Option,
+
+ /// Fixed width of the menu.
+ ///
+ /// Can be used with `truncate` options
+ /// to customise how item labels are truncated.
+ ///
+ /// **Default**: `null`
+ #[serde(default)]
+ pub(super) width: Option,
+
+ /// Label to show on the menu button on the bar.
+ ///
+ /// **Default**: `≡`
+ #[serde(default = "default_menu_popup_label")]
+ pub(super) label: Option,
+
+ /// Icon to show on the menu button on the bar.
+ ///
+ /// **Default**: `null`
+ #[serde(default)]
+ pub(super) label_icon: Option,
+
+ /// Size of the `label_icon` image.
+ #[serde(default = "default_menu_popup_icon_size")]
+ pub(super) label_icon_size: i32,
+
+ // -- common --
+ /// Truncate options to apply to (sub)menu item labels.
+ ///
+ /// See [truncate options](module-level-options#truncate-mode).
+ ///
+ /// **Default**: `Auto (end)`
+ #[serde(default)]
+ pub(super) truncate: TruncateMode,
+
+ /// See [common options](module-level-options#common-options).
+ #[serde(flatten)]
+ pub common: Option,
+}
+
+impl Default for MenuModule {
+ fn default() -> Self {
+ MenuModule {
+ start: vec![],
+ center: default_menu(),
+ end: vec![],
+ height: None,
+ width: None,
+ truncate: TruncateMode::default(),
+ // 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: "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,
+ MenuConfig::XdgEntry(XdgEntry {
+ label: "Settings".to_string(),
+ icon: Some("preferences-system".to_string()),
+ categories: vec!["Settings".to_string(), "Screensaver".to_string()],
+ }),
+ ]
+}
+
+fn default_menu_popup_label() -> Option {
+ Some("≡".to_string())
+}
+
+const fn default_menu_popup_icon_size() -> i32 {
+ 16
+}
+
+pub const OTHER_LABEL: &str = "Other";
+
+pub fn parse_config(
+ section_config: Vec,
+ sections_by_cat: &mut IndexMap>,
+) -> IndexMap {
+ section_config
+ .into_iter()
+ .map(|entry_config| match entry_config {
+ MenuConfig::XdgEntry(entry) => {
+ entry.categories.into_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, vec![entry.label.clone()]);
+ }
+ });
+
+ (
+ entry.label.clone(),
+ MenuEntry::Xdg(XdgSection {
+ label: entry.label,
+ icon: entry.icon,
+ applications: IndexMap::new(),
+ }),
+ )
+ }
+ MenuConfig::XdgOther => (
+ OTHER_LABEL.to_string(),
+ MenuEntry::Xdg(XdgSection {
+ label: OTHER_LABEL.to_string(),
+ icon: Some("applications-other".to_string()),
+ applications: IndexMap::new(),
+ }),
+ ),
+ MenuConfig::Custom(entry) => (
+ entry.label.clone(),
+ MenuEntry::Custom(CustomEntry {
+ icon: entry.icon,
+ label: entry.label,
+ on_click: entry.on_click,
+ }),
+ ),
+ })
+ .collect()
+}
diff --git a/src/modules/menu/mod.rs b/src/modules/menu/mod.rs
new file mode 100644
index 0000000..e9f70ef
--- /dev/null
+++ b/src/modules/menu/mod.rs
@@ -0,0 +1,321 @@
+mod config;
+mod ui;
+
+use color_eyre::Result;
+use color_eyre::eyre::Report;
+use config::*;
+use gtk::prelude::*;
+use gtk::{Align, Button, Orientation};
+use indexmap::IndexMap;
+use serde::Deserialize;
+use tokio::sync::mpsc;
+
+use super::{ModuleLocation, PopupButton};
+use crate::channels::{AsyncSenderExt, BroadcastReceiverExt};
+use crate::config::BarPosition;
+use crate::gtk_helpers::IronbarGtkExt;
+use crate::modules::{
+ Module, ModuleInfo, ModuleParts, ModulePopup, ModuleUpdateEvent, WidgetContext,
+};
+use crate::{module_impl, spawn};
+
+pub use config::MenuModule;
+
+/// XDG button and menu from parsed config.
+#[derive(Debug, Clone)]
+pub struct XdgSection {
+ pub label: String,
+ pub icon: Option,
+ pub applications: IndexMap,
+}
+
+#[derive(Debug, Deserialize, Clone)]
+pub struct MenuApplication {
+ pub label: String,
+ pub file_name: String,
+ pub categories: Vec,
+}
+
+#[derive(Debug)]
+pub enum MenuEntry {
+ Xdg(XdgSection),
+ Custom(CustomEntry),
+}
+
+impl MenuEntry {
+ pub fn label(&self) -> String {
+ match self {
+ Self::Xdg(entry) => entry.label.clone(),
+ Self::Custom(entry) => entry.label.clone(),
+ }
+ }
+
+ pub fn icon(&self) -> Option {
+ match self {
+ Self::Xdg(entry) => entry.icon.clone(),
+ Self::Custom(entry) => entry.icon.clone(),
+ }
+ }
+}
+
+impl Module