From 46b72a375101b54819f8750750017bd71e7c3798 Mon Sep 17 00:00:00 2001 From: "Christopher R. Nelson" Date: Tue, 21 Jul 2026 10:54:00 -0400 Subject: Group menu items into per-category submenus Parse the Categories key from .desktop files and have the renderer emit one 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 --- src/er_xdg_pipe_menu_renderer.erl | 94 ++++++++++++++++++++++++--------------- 1 file changed, 57 insertions(+), 37 deletions(-) (limited to 'src/er_xdg_pipe_menu_renderer.erl') 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 `' 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 `' submenu +%% per category, each containing an `' 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([ - <<"\n">>, - <<"\n">>, - [render_item(E) || E <- Sorted], - <<"\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, ["\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}) -> - [ - <<" >, - escape_attr(Name), - <<"\"">>, - icon_attr(Icon), - <<">\n">>, - <<" \n">>, - <<" ">>, - escape_text(Exec), - <<"\n">>, - <<" \n">>, - <<" \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, [{<<"\"">>, <<""">>}]). -escape_text(Bin) -> escape(Bin, []). - -escape(Bin, Extra) -> - lists:foldl( - fun({Pat, Repl}, Acc) -> binary:replace(Acc, Pat, Repl, [global]) end, - Bin, - [{<<"&">>, <<"&">>}, {<<"<">>, <<"<">>}, {<<">">>, <<">">>} | 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). -- cgit v1.2.3