From 6bbc784b4cf0717ddc7f5c3ad32c1d5e2d486877 Mon Sep 17 00:00:00 2001 From: "Christopher R. Nelson" Date: Mon, 20 Jul 2026 23:03:25 -0400 Subject: Add Openbox pipe menu renderer render/1 turns a list of #desktop_entry{} into the XML Openbox expects on stdout from a pipe menu: hidden entries dropped, remaining ones sorted case-insensitively by name, each as an wrapping an Execute action with its command and (if present) icon attribute. XML-escapes label/command/icon values. Icon values are passed through as-is (theme icon names, not resolved paths) -- icon theme resolution is left for later, separate from rendering. Service wiring (scan -> parse -> render, with caching and invalidation) is also left for later. Co-Authored-By: Claude Sonnet 5 --- src/er_xdg_pipe_menu_renderer.erl | 58 +++++++++++++++++++ test/er_xdg_pipe_menu_renderer_tests.erl | 96 ++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 src/er_xdg_pipe_menu_renderer.erl create mode 100644 test/er_xdg_pipe_menu_renderer_tests.erl diff --git a/src/er_xdg_pipe_menu_renderer.erl b/src/er_xdg_pipe_menu_renderer.erl new file mode 100644 index 0000000..fafed45 --- /dev/null +++ b/src/er_xdg_pipe_menu_renderer.erl @@ -0,0 +1,58 @@ +%%%------------------------------------------------------------------- +%% @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. +%% @end +%%%------------------------------------------------------------------- + +-module(er_xdg_pipe_menu_renderer). + +-include("er_xdg_pipe_menu.hrl"). + +-export([render/1]). + +-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">> + ]). + +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] + ). diff --git a/test/er_xdg_pipe_menu_renderer_tests.erl b/test/er_xdg_pipe_menu_renderer_tests.erl new file mode 100644 index 0000000..1fc0192 --- /dev/null +++ b/test/er_xdg_pipe_menu_renderer_tests.erl @@ -0,0 +1,96 @@ +-module(er_xdg_pipe_menu_renderer_tests). + +-include_lib("eunit/include/eunit.hrl"). +-include_lib("xmerl/include/xmerl.hrl"). +-include("er_xdg_pipe_menu.hrl"). + +entry(Name, Exec, Icon) -> + entry(Name, Exec, Icon, false). + +entry(Name, Exec, Icon, Hidden) -> + #desktop_entry{name = Name, exec = Exec, icon = Icon, hidden = Hidden}. + +empty_list_renders_empty_menu_test() -> + ?assertEqual( + <<"\n" + "\n" + "\n">>, + er_xdg_pipe_menu_renderer:render([]) + ). + +single_entry_with_icon_test() -> + Rendered = er_xdg_pipe_menu_renderer:render([entry(<<"Firefox">>, <<"firefox">>, <<"firefox">>)]), + ?assertEqual( + <<"\n" + "\n" + " \n" + " \n" + " firefox\n" + " \n" + " \n" + "\n">>, + Rendered + ). + +entry_without_icon_omits_icon_attr_test() -> + Rendered = er_xdg_pipe_menu_renderer:render([entry(<<"Foo">>, <<"foo">>, undefined)]), + ?assertEqual( + <<"\n" + "\n" + " \n" + " \n" + " foo\n" + " \n" + " \n" + "\n">>, + Rendered + ). + +hidden_entries_are_dropped_test() -> + Entries = [ + entry(<<"Visible">>, <<"visible">>, undefined), + entry(<<"Invisible">>, <<"invisible">>, undefined, 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() -> + Entries = [ + entry(<<"zeta">>, <<"zeta">>, undefined), + entry(<<"Alpha">>, <<"alpha">>, undefined), + entry(<<"beta">>, <<"beta">>, undefined) + ], + 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)). + +escapes_special_characters_test() -> + Entry = entry(<<"Foo & \"Baz\"">>, <<"foo --title=\"&y\"">>, <<"icon\"&<>">>), + Rendered = er_xdg_pipe_menu_renderer:render([Entry]), + ?assertEqual( + <<"\n" + "\n" + " \n" + " \n" + " foo --title=\"<x>&y\"\n" + " \n" + " \n" + "\n">>, + Rendered + ). + +output_is_well_formed_xml_test() -> + Entries = [ + entry(<<"Firefox">>, <<"firefox">>, <<"firefox">>), + entry(<<"Foo & Bar">>, <<"foo">>, undefined) + ], + 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], + ?assertEqual(2, length(Items)). -- cgit v1.2.3