aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/er_xdg_pipe_menu_renderer.erl58
-rw-r--r--test/er_xdg_pipe_menu_renderer_tests.erl96
2 files changed, 154 insertions, 0 deletions
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 `<item>' 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([
+ <<"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n">>,
+ <<"<openbox_pipe_menu>\n">>,
+ [render_item(E) || E <- Sorted],
+ <<"</openbox_pipe_menu>\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}) ->
+ [
+ <<" <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]
+ ).
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(
+ <<"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
+ "<openbox_pipe_menu>\n"
+ "</openbox_pipe_menu>\n">>,
+ er_xdg_pipe_menu_renderer:render([])
+ ).
+
+single_entry_with_icon_test() ->
+ Rendered = er_xdg_pipe_menu_renderer:render([entry(<<"Firefox">>, <<"firefox">>, <<"firefox">>)]),
+ ?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"
+ "</openbox_pipe_menu>\n">>,
+ Rendered
+ ).
+
+entry_without_icon_omits_icon_attr_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"
+ "</openbox_pipe_menu>\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 & <Bar> \"Baz\"">>, <<"foo --title=\"<x>&y\"">>, <<"icon\"&<>">>),
+ Rendered = er_xdg_pipe_menu_renderer:render([Entry]),
+ ?assertEqual(
+ <<"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
+ "<openbox_pipe_menu>\n"
+ " <item label=\"Foo &amp; &lt;Bar&gt; &quot;Baz&quot;\" icon=\"icon&quot;&amp;&lt;&gt;\">\n"
+ " <action name=\"Execute\">\n"
+ " <command>foo --title=\"&lt;x&gt;&amp;y\"</command>\n"
+ " </action>\n"
+ " </item>\n"
+ "</openbox_pipe_menu>\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)).