aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorChristopher R. Nelson <christopher.nelson@languidnights.com>2026-07-21 10:54:00 -0400
committerChristopher R. Nelson <christopher.nelson@languidnights.com>2026-07-21 10:54:00 -0400
commit46b72a375101b54819f8750750017bd71e7c3798 (patch)
treee8d72906b59252bd01939a5806235ccc75934f56 /src
parent6bbc784b4cf0717ddc7f5c3ad32c1d5e2d486877 (diff)
Group menu items into per-category submenus
Parse the Categories key from .desktop files and have the renderer emit one <menu> submenu per category (via xmerl), with items sorted within each; entries in multiple categories appear in each submenu, and uncategorized entries fall into a catch-all "Other" submenu. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Diffstat (limited to 'src')
-rw-r--r--src/er_xdg_pipe_menu.app.src3
-rw-r--r--src/er_xdg_pipe_menu_desktop_entry.erl16
-rw-r--r--src/er_xdg_pipe_menu_renderer.erl94
3 files changed, 72 insertions, 41 deletions
diff --git a/src/er_xdg_pipe_menu.app.src b/src/er_xdg_pipe_menu.app.src
index 7013bc7..1649ec3 100644
--- a/src/er_xdg_pipe_menu.app.src
+++ b/src/er_xdg_pipe_menu.app.src
@@ -5,7 +5,8 @@
{mod, {er_xdg_pipe_menu_app, []}},
{applications, [
kernel,
- stdlib
+ stdlib,
+ xmerl
]},
{env, []},
{modules, []},
diff --git a/src/er_xdg_pipe_menu_desktop_entry.erl b/src/er_xdg_pipe_menu_desktop_entry.erl
index bbd2594..9a71013 100644
--- a/src/er_xdg_pipe_menu_desktop_entry.erl
+++ b/src/er_xdg_pipe_menu_desktop_entry.erl
@@ -3,8 +3,9 @@
%%
%% Reads the `[Desktop Entry]' group of a desktop entry file and
%% extracts the fields needed to build a menu item: `Name', `Exec'
-%% and (optionally) `Icon'. Other groups (e.g. `[Desktop Action ...]')
-%% and localized keys (e.g. `Name[fr]') are ignored.
+%% and (optionally) `Icon' and `Categories'. Other groups (e.g.
+%% `[Desktop Action ...]') and localized keys (e.g. `Name[fr]') are
+%% ignored.
%% @end
%%%-------------------------------------------------------------------
@@ -16,7 +17,7 @@
-define(GROUP, <<"Desktop Entry">>).
-define(WANTED_KEYS, [
- <<"Name">>, <<"Exec">>, <<"Icon">>, <<"NoDisplay">>, <<"Hidden">>, <<"Type">>
+ <<"Name">>, <<"Exec">>, <<"Icon">>, <<"Categories">>, <<"NoDisplay">>, <<"Hidden">>, <<"Type">>
]).
-define(FIELD_CODES, "fFuUdDnNickvm").
@@ -99,11 +100,20 @@ to_entry(Fields) ->
name = Name,
exec = strip_field_codes(Exec),
icon = maps:get(<<"Icon">>, Fields, undefined),
+ categories = parse_categories(maps:get(<<"Categories">>, Fields, undefined)),
hidden = is_hidden(Fields)
}}
end
end.
+%% `Categories' is a `;'-separated list, conventionally with a
+%% trailing separator (e.g. `Utility;Development;'); empty segments
+%% from that trailing separator (or repeated ones) are dropped.
+parse_categories(undefined) ->
+ [];
+parse_categories(Bin) ->
+ [C || C <- binary:split(Bin, <<";">>, [global]), C =/= <<>>].
+
%% Niche variations (e.g. a Type-less override dropped into
%% ~/.local/share/applications) are left to be special-cased there
%% rather than accounted for here.
diff --git a/src/er_xdg_pipe_menu_renderer.erl b/src/er_xdg_pipe_menu_renderer.erl
index fafed45..f89d868 100644
--- a/src/er_xdg_pipe_menu_renderer.erl
+++ b/src/er_xdg_pipe_menu_renderer.erl
@@ -2,9 +2,13 @@
%% @doc Renders parsed desktop entries as an Openbox pipe menu.
%%
%% Given a list of `#desktop_entry{}' records, produces the XML that
-%% Openbox expects on stdout from a pipe menu: one `<item>' per
-%% visible entry (entries with `hidden = true' are dropped), sorted
-%% case-insensitively by name, each wrapping an `Execute' action.
+%% Openbox expects on stdout from a pipe menu: one `<menu>' submenu
+%% per category, each containing an `<item>' per visible entry in
+%% that category (entries with `hidden = true' are dropped). An entry
+%% belonging to several categories appears in each of their submenus;
+%% an entry with no categories is placed in a catch-all `Other'
+%% submenu. Categories are sorted case-insensitively, and entries
+%% within a submenu are sorted case-insensitively by name.
%% @end
%%%-------------------------------------------------------------------
@@ -14,45 +18,61 @@
-export([render/1]).
+-define(OTHER_CATEGORY, <<"Other">>).
+
-spec render([#desktop_entry{}]) -> binary().
render(Entries) ->
Visible = [E || E <- Entries, not E#desktop_entry.hidden],
- Sorted = lists:sort(fun by_name/2, Visible),
- iolist_to_binary([
- <<"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n">>,
- <<"<openbox_pipe_menu>\n">>,
- [render_item(E) || E <- Sorted],
- <<"</openbox_pipe_menu>\n">>
- ]).
+ Grouped = group_by_category(Visible),
+ Categories = lists:sort(fun category_lt/2, maps:keys(Grouped)),
+ Menu = {openbox_pipe_menu, [], submenus_content(Categories, Grouped)},
+ Xml = xmerl:export_simple(
+ [Menu],
+ xmerl_xml,
+ [{prolog, ["<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"]}]
+ ),
+ iolist_to_binary([Xml, "\n"]).
+
+group_by_category(Entries) ->
+ lists:foldl(fun add_entry_to_categories/2, #{}, Entries).
+
+add_entry_to_categories(#desktop_entry{categories = []} = E, Acc) ->
+ add_entry_to_category(?OTHER_CATEGORY, E, Acc);
+add_entry_to_categories(#desktop_entry{categories = Cats} = E, Acc) ->
+ lists:foldl(fun(Cat, Acc0) -> add_entry_to_category(Cat, E, Acc0) end, Acc, Cats).
+
+add_entry_to_category(Cat, E, Acc) ->
+ maps:update_with(Cat, fun(Es) -> [E | Es] end, [E], Acc).
+
+category_lt(A, B) -> string:lowercase(A) =< string:lowercase(B).
by_name(#desktop_entry{name = A}, #desktop_entry{name = B}) ->
string:lowercase(A) =< string:lowercase(B).
-render_item(#desktop_entry{name = Name, exec = Exec, icon = Icon}) ->
- [
- <<" <item label=\"">>,
- escape_attr(Name),
- <<"\"">>,
- icon_attr(Icon),
- <<">\n">>,
- <<" <action name=\"Execute\">\n">>,
- <<" <command>">>,
- escape_text(Exec),
- <<"</command>\n">>,
- <<" </action>\n">>,
- <<" </item>\n">>
- ].
-
-icon_attr(undefined) -> <<>>;
-icon_attr(Icon) -> [<<" icon=\"">>, escape_attr(Icon), <<"\"">>].
-
-%% Attribute values additionally need '"' escaped; text content doesn't.
-escape_attr(Bin) -> escape(Bin, [{<<"\"">>, <<"&quot;">>}]).
-escape_text(Bin) -> escape(Bin, []).
-
-escape(Bin, Extra) ->
- lists:foldl(
- fun({Pat, Repl}, Acc) -> binary:replace(Acc, Pat, Repl, [global]) end,
- Bin,
- [{<<"&">>, <<"&amp;">>}, {<<"<">>, <<"&lt;">>}, {<<">">>, <<"&gt;">>} | Extra]
+submenus_content(Categories, Grouped) ->
+ lists:foldr(
+ fun(Cat, Acc) -> ["\n ", submenu_element(Cat, Grouped) | Acc] end, ["\n"], Categories
).
+
+submenu_element(Cat, Grouped) ->
+ Entries = lists:sort(fun by_name/2, maps:get(Cat, Grouped)),
+ {menu, [{id, Cat}, {label, Cat}], items_content(Entries)}.
+
+items_content(Entries) ->
+ lists:foldr(fun(E, Acc) -> ["\n ", item_element(E) | Acc] end, ["\n "], Entries).
+
+item_element(#desktop_entry{name = Name, exec = Exec, icon = Icon}) ->
+ {item, item_attrs(Name, Icon), [
+ "\n ",
+ {action, [{name, <<"Execute">>}], [
+ "\n ",
+ {command, [], [text(Exec)]},
+ "\n "
+ ]},
+ "\n "
+ ]}.
+
+item_attrs(Name, undefined) -> [{label, Name}];
+item_attrs(Name, Icon) -> [{label, Name}, {icon, Icon}].
+
+text(Bin) -> unicode:characters_to_list(Bin).