aboutsummaryrefslogtreecommitdiff
path: root/src/er_xdg_pipe_menu_renderer.erl
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/er_xdg_pipe_menu_renderer.erl
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/er_xdg_pipe_menu_renderer.erl')
-rw-r--r--src/er_xdg_pipe_menu_renderer.erl94
1 files changed, 57 insertions, 37 deletions
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).