diff options
| author | Christopher R. Nelson <christopher.nelson@languidnights.com> | 2026-07-21 10:54:00 -0400 |
|---|---|---|
| committer | Christopher R. Nelson <christopher.nelson@languidnights.com> | 2026-07-21 10:54:00 -0400 |
| commit | 46b72a375101b54819f8750750017bd71e7c3798 (patch) | |
| tree | e8d72906b59252bd01939a5806235ccc75934f56 | |
| parent | 6bbc784b4cf0717ddc7f5c3ad32c1d5e2d486877 (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>
| -rw-r--r-- | include/er_xdg_pipe_menu.hrl | 1 | ||||
| -rw-r--r-- | src/er_xdg_pipe_menu.app.src | 3 | ||||
| -rw-r--r-- | src/er_xdg_pipe_menu_desktop_entry.erl | 16 | ||||
| -rw-r--r-- | src/er_xdg_pipe_menu_renderer.erl | 94 | ||||
| -rw-r--r-- | test/er_xdg_pipe_menu_desktop_entry_tests.erl | 41 | ||||
| -rw-r--r-- | test/er_xdg_pipe_menu_renderer_tests.erl | 129 |
6 files changed, 206 insertions, 78 deletions
diff --git a/include/er_xdg_pipe_menu.hrl b/include/er_xdg_pipe_menu.hrl index 2cee607..6d50b8d 100644 --- a/include/er_xdg_pipe_menu.hrl +++ b/include/er_xdg_pipe_menu.hrl @@ -2,5 +2,6 @@ name :: binary(), exec :: binary(), icon :: binary() | undefined, + categories :: [binary()], hidden :: boolean() }). 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, [{<<"\"">>, <<""">>}]). -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). diff --git a/test/er_xdg_pipe_menu_desktop_entry_tests.erl b/test/er_xdg_pipe_menu_desktop_entry_tests.erl index 399e3a5..bcea976 100644 --- a/test/er_xdg_pipe_menu_desktop_entry_tests.erl +++ b/test/er_xdg_pipe_menu_desktop_entry_tests.erl @@ -13,7 +13,7 @@ basic_entry_test() -> >>, ?assertEqual( {ok, #desktop_entry{ - name = <<"Firefox">>, exec = <<"firefox">>, icon = <<"firefox">>, hidden = false + name = <<"Firefox">>, exec = <<"firefox">>, icon = <<"firefox">>, categories = [], hidden = false }}, er_xdg_pipe_menu_desktop_entry:parse(Bin) ). @@ -25,10 +25,35 @@ icon_is_optional_test() -> "Exec=foo\n" >>, ?assertEqual( - {ok, #desktop_entry{name = <<"Foo">>, exec = <<"foo">>, icon = undefined, hidden = false}}, + {ok, #desktop_entry{name = <<"Foo">>, exec = <<"foo">>, icon = undefined, categories = [], hidden = false}}, er_xdg_pipe_menu_desktop_entry:parse(Bin) ). +categories_are_split_on_semicolon_test() -> + Bin = << + "[Desktop Entry]\n" + "Name=Foo\n" + "Exec=foo\n" + "Categories=Utility;Development;\n" + >>, + {ok, Entry} = er_xdg_pipe_menu_desktop_entry:parse(Bin), + ?assertEqual([<<"Utility">>, <<"Development">>], Entry#desktop_entry.categories). + +categories_is_empty_list_when_absent_test() -> + Bin = <<"[Desktop Entry]\nName=Foo\nExec=foo\n">>, + {ok, Entry} = er_xdg_pipe_menu_desktop_entry:parse(Bin), + ?assertEqual([], Entry#desktop_entry.categories). + +repeated_category_separators_are_ignored_test() -> + Bin = << + "[Desktop Entry]\n" + "Name=Foo\n" + "Exec=foo\n" + "Categories=Utility;;Development\n" + >>, + {ok, Entry} = er_xdg_pipe_menu_desktop_entry:parse(Bin), + ?assertEqual([<<"Utility">>, <<"Development">>], Entry#desktop_entry.categories). + missing_name_test() -> Bin = <<"[Desktop Entry]\nExec=foo\n">>, ?assertEqual({error, missing_name}, er_xdg_pipe_menu_desktop_entry:parse(Bin)). @@ -48,7 +73,7 @@ ignores_other_groups_test() -> "Exec=foo --new-window\n" >>, ?assertEqual( - {ok, #desktop_entry{name = <<"Foo">>, exec = <<"foo">>, icon = undefined, hidden = false}}, + {ok, #desktop_entry{name = <<"Foo">>, exec = <<"foo">>, icon = undefined, categories = [], hidden = false}}, er_xdg_pipe_menu_desktop_entry:parse(Bin) ). @@ -63,7 +88,7 @@ ignores_comments_and_blank_lines_test() -> "Exec=foo\n" >>, ?assertEqual( - {ok, #desktop_entry{name = <<"Foo">>, exec = <<"foo">>, icon = undefined, hidden = false}}, + {ok, #desktop_entry{name = <<"Foo">>, exec = <<"foo">>, icon = undefined, categories = [], hidden = false}}, er_xdg_pipe_menu_desktop_entry:parse(Bin) ). @@ -74,7 +99,7 @@ strips_multiple_field_codes_test() -> "Exec=foo %f --flag %i %c\n" >>, ?assertEqual( - {ok, #desktop_entry{name = <<"Foo">>, exec = <<"foo --flag">>, icon = undefined, hidden = false}}, + {ok, #desktop_entry{name = <<"Foo">>, exec = <<"foo --flag">>, icon = undefined, categories = [], hidden = false}}, er_xdg_pipe_menu_desktop_entry:parse(Bin) ). @@ -86,7 +111,7 @@ literal_percent_is_unescaped_test() -> >>, ?assertEqual( {ok, #desktop_entry{ - name = <<"Foo">>, exec = <<"foo --progress=%">>, icon = undefined, hidden = false + name = <<"Foo">>, exec = <<"foo --progress=%">>, icon = undefined, categories = [], hidden = false }}, er_xdg_pipe_menu_desktop_entry:parse(Bin) ). @@ -94,7 +119,7 @@ literal_percent_is_unescaped_test() -> unescapes_value_test() -> Bin = <<"[Desktop Entry]\nName=Foo\\sBar\nExec=foo\n">>, ?assertEqual( - {ok, #desktop_entry{name = <<"Foo Bar">>, exec = <<"foo">>, icon = undefined, hidden = false}}, + {ok, #desktop_entry{name = <<"Foo Bar">>, exec = <<"foo">>, icon = undefined, categories = [], hidden = false}}, er_xdg_pipe_menu_desktop_entry:parse(Bin) ). @@ -141,7 +166,7 @@ parse_file_test() -> Result = er_xdg_pipe_menu_desktop_entry:parse_file(Path), ok = file:delete(Path), ?assertEqual( - {ok, #desktop_entry{name = <<"Foo">>, exec = <<"foo">>, icon = undefined, hidden = false}}, + {ok, #desktop_entry{name = <<"Foo">>, exec = <<"foo">>, icon = undefined, categories = [], hidden = false}}, Result ). diff --git a/test/er_xdg_pipe_menu_renderer_tests.erl b/test/er_xdg_pipe_menu_renderer_tests.erl index 1fc0192..7273625 100644 --- a/test/er_xdg_pipe_menu_renderer_tests.erl +++ b/test/er_xdg_pipe_menu_renderer_tests.erl @@ -5,10 +5,15 @@ -include("er_xdg_pipe_menu.hrl"). entry(Name, Exec, Icon) -> - entry(Name, Exec, Icon, false). + entry(Name, Exec, Icon, []). -entry(Name, Exec, Icon, Hidden) -> - #desktop_entry{name = Name, exec = Exec, icon = Icon, hidden = Hidden}. +entry(Name, Exec, Icon, Categories) -> + entry(Name, Exec, Icon, Categories, false). + +entry(Name, Exec, Icon, Categories, Hidden) -> + #desktop_entry{ + name = Name, exec = Exec, icon = Icon, categories = Categories, hidden = Hidden + }. empty_list_renders_empty_menu_test() -> ?assertEqual( @@ -19,47 +24,76 @@ empty_list_renders_empty_menu_test() -> ). single_entry_with_icon_test() -> - Rendered = er_xdg_pipe_menu_renderer:render([entry(<<"Firefox">>, <<"firefox">>, <<"firefox">>)]), + Rendered = er_xdg_pipe_menu_renderer:render([ + entry(<<"Firefox">>, <<"firefox">>, <<"firefox">>, [<<"Network">>]) + ]), ?assertEqual( <<"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" "<openbox_pipe_menu>\n" - " <item label=\"Firefox\" icon=\"firefox\">\n" - " <action name=\"Execute\">\n" - " <command>firefox</command>\n" - " </action>\n" - " </item>\n" + " <menu id=\"Network\" label=\"Network\">\n" + " <item label=\"Firefox\" icon=\"firefox\">\n" + " <action name=\"Execute\">\n" + " <command>firefox</command>\n" + " </action>\n" + " </item>\n" + " </menu>\n" "</openbox_pipe_menu>\n">>, Rendered ). entry_without_icon_omits_icon_attr_test() -> + Rendered = er_xdg_pipe_menu_renderer:render([ + entry(<<"Foo">>, <<"foo">>, undefined, [<<"Utility">>]) + ]), + ?assertEqual( + <<"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" + "<openbox_pipe_menu>\n" + " <menu id=\"Utility\" label=\"Utility\">\n" + " <item label=\"Foo\">\n" + " <action name=\"Execute\">\n" + " <command>foo</command>\n" + " </action>\n" + " </item>\n" + " </menu>\n" + "</openbox_pipe_menu>\n">>, + Rendered + ). + +entries_without_categories_go_in_other_submenu_test() -> Rendered = er_xdg_pipe_menu_renderer:render([entry(<<"Foo">>, <<"foo">>, undefined)]), ?assertEqual( <<"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" "<openbox_pipe_menu>\n" - " <item label=\"Foo\">\n" - " <action name=\"Execute\">\n" - " <command>foo</command>\n" - " </action>\n" - " </item>\n" + " <menu id=\"Other\" label=\"Other\">\n" + " <item label=\"Foo\">\n" + " <action name=\"Execute\">\n" + " <command>foo</command>\n" + " </action>\n" + " </item>\n" + " </menu>\n" "</openbox_pipe_menu>\n">>, Rendered ). hidden_entries_are_dropped_test() -> Entries = [ - entry(<<"Visible">>, <<"visible">>, undefined), - entry(<<"Invisible">>, <<"invisible">>, undefined, true) + entry(<<"Visible">>, <<"visible">>, undefined, [<<"Utility">>]), + entry(<<"Invisible">>, <<"invisible">>, undefined, [<<"Utility">>], true) ], Rendered = er_xdg_pipe_menu_renderer:render(Entries), ?assertNot(binary:match(Rendered, <<"Invisible">>) =/= nomatch), ?assert(binary:match(Rendered, <<"Visible">>) =/= nomatch). -entries_are_sorted_case_insensitively_test() -> +hidden_entry_leaves_no_empty_submenu_test() -> + Entries = [entry(<<"Invisible">>, <<"invisible">>, undefined, [<<"Utility">>], true)], + Rendered = er_xdg_pipe_menu_renderer:render(Entries), + ?assertNot(binary:match(Rendered, <<"Utility">>) =/= nomatch). + +entries_are_sorted_case_insensitively_within_a_submenu_test() -> Entries = [ - entry(<<"zeta">>, <<"zeta">>, undefined), - entry(<<"Alpha">>, <<"alpha">>, undefined), - entry(<<"beta">>, <<"beta">>, undefined) + entry(<<"zeta">>, <<"zeta">>, undefined, [<<"Utility">>]), + entry(<<"Alpha">>, <<"alpha">>, undefined, [<<"Utility">>]), + entry(<<"beta">>, <<"beta">>, undefined, [<<"Utility">>]) ], Rendered = er_xdg_pipe_menu_renderer:render(Entries), Positions = [ @@ -69,28 +103,65 @@ entries_are_sorted_case_insensitively_test() -> ], ?assertEqual(Positions, lists:sort(Positions)). +submenus_are_sorted_case_insensitively_test() -> + Entries = [ + entry(<<"Zed App">>, <<"zed">>, undefined, [<<"zeta">>]), + entry(<<"Alpha App">>, <<"alpha">>, undefined, [<<"Alpha">>]), + entry(<<"Beta App">>, <<"beta">>, undefined, [<<"beta">>]) + ], + Rendered = er_xdg_pipe_menu_renderer:render(Entries), + Positions = [ + element(1, binary:match(Rendered, <<"label=\"Alpha\"">>)), + element(1, binary:match(Rendered, <<"label=\"beta\"">>)), + element(1, binary:match(Rendered, <<"label=\"zeta\"">>)) + ], + ?assertEqual(Positions, lists:sort(Positions)). + +entry_with_multiple_categories_appears_in_each_submenu_test() -> + Entries = [entry(<<"Foo">>, <<"foo">>, undefined, [<<"Utility">>, <<"Development">>])], + Rendered = er_xdg_pipe_menu_renderer:render(Entries), + {Doc, _Rest} = xmerl_scan:string(binary_to_list(Rendered)), + Menus = [E || E = #xmlElement{name = menu} <- Doc#xmlElement.content], + ?assertEqual([<<"Development">>, <<"Utility">>], lists:sort([menu_id(M) || M <- Menus])), + Items = [I || M <- Menus, I = #xmlElement{name = item} <- M#xmlElement.content], + ?assertEqual(2, length(Items)). + escapes_special_characters_test() -> - Entry = entry(<<"Foo & <Bar> \"Baz\"">>, <<"foo --title=\"<x>&y\"">>, <<"icon\"&<>">>), + Entry = entry( + <<"Foo & <Bar> \"Baz\"">>, + <<"foo --title=\"<x>&y\"">>, + <<"icon\"&<>">>, + [<<"Foo & <Bar>">>] + ), Rendered = er_xdg_pipe_menu_renderer:render([Entry]), ?assertEqual( <<"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" "<openbox_pipe_menu>\n" - " <item label=\"Foo & <Bar> "Baz"\" icon=\"icon"&<>\">\n" - " <action name=\"Execute\">\n" - " <command>foo --title=\"<x>&y\"</command>\n" - " </action>\n" - " </item>\n" + " <menu id=\"Foo & <Bar>\" label=\"Foo & <Bar>\">\n" + " <item label=\"Foo & <Bar> "Baz"\" icon=\"icon"&<>\">\n" + " <action name=\"Execute\">\n" + " <command>foo --title=\"<x>&y\"</command>\n" + " </action>\n" + " </item>\n" + " </menu>\n" "</openbox_pipe_menu>\n">>, Rendered ). output_is_well_formed_xml_test() -> Entries = [ - entry(<<"Firefox">>, <<"firefox">>, <<"firefox">>), - entry(<<"Foo & Bar">>, <<"foo">>, undefined) + entry(<<"Firefox">>, <<"firefox">>, <<"firefox">>, [<<"Network">>]), + entry(<<"Foo & Bar">>, <<"foo">>, undefined, [<<"Network">>]) ], Rendered = er_xdg_pipe_menu_renderer:render(Entries), {Doc, _Rest} = xmerl_scan:string(binary_to_list(Rendered)), ?assertEqual(openbox_pipe_menu, Doc#xmlElement.name), - Items = [E || E = #xmlElement{name = item} <- Doc#xmlElement.content], + Menus = [E || E = #xmlElement{name = menu} <- Doc#xmlElement.content], + ?assertEqual(1, length(Menus)), + [Menu] = Menus, + Items = [E || E = #xmlElement{name = item} <- Menu#xmlElement.content], ?assertEqual(2, length(Items)). + +menu_id(#xmlElement{attributes = Attrs}) -> + [Value] = [V || #xmlAttribute{name = id, value = V} <- Attrs], + list_to_binary(Value). |
