Commit eb314226 authored by Craig Everett's avatar Craig Everett

Implement JSON->ETerms conversion

parent 3ddd85a9
.eunit
deps
tester
*.o
*.beam
*.plt
*.swp
erl_crash.dump
ebin/*.beam
rel/example_project
.concrete/DEV_MODE
.rebar
No preview for this file type
No preview for this file type
......@@ -10,6 +10,7 @@
-license("MIT").
-behavior(gen_server).
-export([read_file/1, save_file/2]).
-export([start_link/0, stop/0]).
-export([init/1, terminate/2, code_change/3,
handle_call/3, handle_cast/2, handle_info/2]).
......@@ -28,6 +29,15 @@
%% Interface
  • Note for new Erlangers:

    Here we need to implement interface functions to expose externally, which will be called in the context of whatever processes want to talk to tg_con, that will create and send messages of a known form for us behind a functional interface. The alternative is to send "naked messages" with the ! or send/2, which while syntactically correct, gets very confusing to understand if not abstracted behind an interface function call.

Please register or sign in to reply
read_file(Path) ->
gen_server:call(?MODULE, {read_file, Path}).
save_file(Path, Data) ->
gen_server:call(?MODULE, {save_file, Path, Data}).
-spec stop() -> ok.
stop() ->
......@@ -55,11 +65,9 @@ start_link() ->
init(none) ->
ok = log(info, "Starting"),
Window = tg_gui:start_link("Hello, WX!"),
Window = tg_gui:start_link("Termifier GUI"),
ok = log(info, "Window: ~p", [Window]),
State = #s{window = Window},
ArgV = zx_daemon:argv(),
ok = tg_gui:show(ArgV),
{ok, State}.
......@@ -80,6 +88,12 @@ init(none) ->
%% The gen_server:handle_call/3 callback.
%% See: http://erlang.org/doc/man/gen_server.html#Module:handle_call-3
handle_call({read_file, Path}, _, State) ->
Result = do_read_file(Path),
{reply, Result, State};
handle_call({save_file, Path, Data}, _, State) ->
Result = do_save_file(Path, Data),
{reply, Result, State};
  • Note for new Erlangers:

    Above we have the handle_call/3 clauses that match on the message forms sent by our interface functions above and are the tg_con process' way of receiving messages because it is a gen_server type process.

    The typical structure is to have an interface function that is named something "verby", like read_file/1 above. That is changed to a message of the form {read_file, Path}, and that causes the called process to execute do_read_file/N, and then the return value Result is sent back to the caller as a reply message.

Please register or sign in to reply
handle_call(Unexpected, From, State) ->
ok = log(warning, "Unexpected call from ~tp: ~tp~n", [From, Unexpected]),
{noreply, State}.
......@@ -96,7 +110,8 @@ handle_call(Unexpected, From, State) ->
handle_cast(stop, State) ->
ok = log(info, "Received a 'stop' message."),
% {noreply, State};
{stop, normal, State};
ok = zx:silent_stop(),
{noreply, State};
  • ZX will launch the application as a permanent application, meaning that if it retires the runtime should be shut down. So instead of using gen_server's {stop, Reason, State} result value to terminate, we are going to call ZX's own zx:silent_stop/0 function to retire the entire runtime gracefully, shutting down all applications in a smooth way without emitting any noisy output to stdout, stderror or logs.

    An alternative is to call init:stop/0,1 yourself -- ZX's version just has a convenient wrapper around the possible return values init:stop/0,1 might hand you other than ok.

Please register or sign in to reply
handle_cast(Unexpected, State) ->
ok = log(warning, "Unexpected cast: ~tp~n", [Unexpected]),
{noreply, State}.
......@@ -127,3 +142,11 @@ code_change(_, State, _) ->
terminate(Reason, State) ->
ok = log(info, "Reason: ~tp, State: ~tp", [Reason, State]),
zx:stop().
do_read_file(Path) ->
file:read_file(Path).
do_save_file(Path, Data) ->
file:write_file(Path, [Data, "\n"]).
  • Remember, the return value of the do_* functions is going to be the Result value in the calling handle_* functions, and that will be sent as a reply value back to the original calling process (which blocks while waiting for the result).

    This simple example is slightly overkill, of course -- we could have done all this directly in the GUI code itself -- but that style snowballs into a "ball of mud" with shocking speed, so it is much better to just follow proper structure and form from the outset since we're writing an OTP-style app here.

    It is also useful to separate the underlying procedure code and processes from the interface code and processes, because it is very easy to add different interfaces to this later if we have a clean separation of concerns. We could add, for example, a web interface, a CLI interface, and even alternative GUI interfaces (using a 3D widget set in OpenGL, for example) to this program very simply without needing to rewrite tg_con or even touch tg_gui.

Please register or sign in to reply
......@@ -13,7 +13,7 @@
-behavior(wx_object).
-include_lib("wx/include/wx.hrl").
-export([show/1]).
-export([json/1]).
-export([start_link/1]).
-export([init/1, terminate/2, code_change/3,
handle_call/3, handle_cast/2, handle_info/2, handle_event/2]).
......@@ -21,18 +21,27 @@
-record(s,
{frame = none :: none | wx:wx_object(),
text = none :: none | wx:wx_object()}).
{frame = none :: none | wx:wx_object(),
json = none :: none | wx:wx_object(),
eterms = none :: none | wx:wx_object()}).
  • The GUI process will need to keep track of the WX object references for its most important components: the main frame (window), the JSON text control and the Erlang terms text control. A place for each is added to the state record here so we can reference them more easily throughout the rest of the program.

    It is common to abbreviate the state record simply to #s{} for brevity, but in some code you will see #state{} written out in full.

Please register or sign in to reply
-type state() :: term().
%%% Labels
-define(sysREAD, 10).
-define(sysCONV, 11).
-define(sysSAVE, 12).
  • We have three control buttons in the program: "Read", "Convert" and "Save". If we alias their IDs with human-readable labels as macros then it is easy to reference them later when this GUI process starts receiving click events from the different buttons later in the code.

    This is reminiscent of C and C++ style code, of course, but that is a big part of why WX is "hard" for Erlangers early on -- WxErlang is a direct wrapper around the underlying WX C++ library, so a many of the idioms we will see throughout the following code (to include the somewhat jarring introduction of camelCaseNames) are a mix of both Erlang style and Wx's C++ style.

    This mishmash of styles is just one more good reason to separate the GUI code entirely from the core program logic.

    Edited by Craig Everett
Please register or sign in to reply
%%% Interface functions
show(Terms) ->
wx_object:cast(?MODULE, {show, Terms}).
json(String) ->
wx_object:cast(?MODULE, {json, String}).
  • This is the only external function in the program, and actually it is not used, only an available hook that has been left in! If this function is called with some string data the JSON text control will be updated to show the new string data. Really simple, but in this case not actually used (yet).

    It would be simple to modify this program to optionally work more like the CLI version of this program and accept an optional start argument to read a JSON file in at startup time. In that case the control part of the program would read the file in during init/1, spawn this tg_gui process, then pass it the file contents by calling tg_gui:json(String).

Please register or sign in to reply
......@@ -46,17 +55,44 @@ init(Title) ->
ok = log(info, "GUI starting..."),
Wx = wx:new(),
Frame = wxFrame:new(Wx, ?wxID_ANY, Title),
MainSz = wxBoxSizer:new(?wxVERTICAL),
TextC = wxTextCtrl:new(Frame, ?wxID_ANY, [{style, ?wxDEFAULT bor ?wxTE_MULTILINE}]),
wxSizer:add(MainSz, TextC, [{flag, ?wxEXPAND}, {proportion, 1}]),
wxFrame:setSizer(Frame, MainSz),
wxSizer:layout(MainSz),
State = build_interface(Frame),
{Frame, State}.
  • Init functions should stay as small as possible, but GUI code is often enormously verbose. For that reason we separate out the interface building code from the necessary Wx initialization steps.

Please register or sign in to reply
build_interface(Frame) ->
MainSz = wxBoxSizer:new(?wxVERTICAL),
MenuSz = wxBoxSizer:new(?wxHORIZONTAL),
TextSz = wxBoxSizer:new(?wxHORIZONTAL),
  • We establish 3 sizers at the outset: the "main" sizer that will fill the displayed window completely and will flow down the screen, then we will put two sizers inside that, one for buttons (the "menu") and one for the text fields. Since we want the buttons and text fields to be arranged side-by-side we make their sizers flow horizontally. The order in which we add objects to the sizers will determine their position in the lineup.

Please register or sign in to reply
ReadBn = wxButton:new(Frame, ?sysREAD, [{label, "Read File"}]),
ConvBn = wxButton:new(Frame, ?sysCONV, [{label, "Convert"}]),
SaveBn = wxButton:new(Frame, ?sysSAVE, [{label, "Save File"}]),
JSON = wxStyledTextCtrl:new(Frame),
ETerms = wxStyledTextCtrl:new(Frame),
Mono = wxFont:new(10, ?wxMODERN, ?wxNORMAL, ?wxNORMAL,
[{encoding, ?wxFONTENCODING_UTF8}, {face, "Monospace"}]),
ok = wxStyledTextCtrl:styleSetFont(JSON, ?wxSTC_STYLE_DEFAULT, Mono),
ok = wxStyledTextCtrl:styleSetFont(ETerms, ?wxSTC_STYLE_DEFAULT, Mono),
  • Creating buttons above we see our macro labels used to provide IDs to the different buttons. We'll see those labels one more time in the handle_event/2 functions below.

    We also see some wxFont stuff happening here. The wxStyledTextCtrl works differently than the more simple wxTextCtrl, especially with respect to "styles" and fonts (that's the whole point!). Because fonts are not a simple idea in wxStyledTextCtrl we have to go through a special procedure to set the default wxStyle to a specific font to make sure that the user sees monospace text instead of some potentially random system default.

Please register or sign in to reply
_ = wxBoxSizer:add(MenuSz, ReadBn, [{flag, ?wxEXPAND}, {proportion, 1}]),
_ = wxBoxSizer:add(MenuSz, ConvBn, [{flag, ?wxEXPAND}, {proportion, 1}]),
_ = wxBoxSizer:add(MenuSz, SaveBn, [{flag, ?wxEXPAND}, {proportion, 1}]),
_ = wxBoxSizer:add(TextSz, JSON, [{flag, ?wxEXPAND}, {proportion, 1}]),
_ = wxBoxSizer:add(TextSz, ETerms, [{flag, ?wxEXPAND}, {proportion, 1}]),
_ = wxBoxSizer:add(MainSz, MenuSz, [{flag, ?wxEXPAND}, {proportion, 0}]),
_ = wxBoxSizer:add(MainSz, TextSz, [{flag, ?wxEXPAND}, {proportion, 1}]),
ok = wxFrame:setSizer(Frame, MainSz),
ok = wxBoxSizer:layout(MainSz),
  • The direction in which a sizer is configured to arrange things and the order in which elements are added to the sizer (including other sizers) dictates how elements will be arranged on the page. Here we see the internal parts of the MenuSz put in place, then the internal parts of TextSz put in place, then both of those placed into the MainSz, which results in the menu appearing above the text fields.

    After these are arranged we have to tell the wxFrame (the main window) which sizer it should be taking its graphical paint cues from, and then we need to tell the top-level sizer to lay out everything that has been placed in it.

Please register or sign in to reply
Display = wxDisplay:new(),
{X, Y, W, H} = wxDisplay:getClientArea(Display),
ok = wxDisplay:destroy(Display),
ok = wxFrame:connect(Frame, close_window),
ok = wxFrame:connect(Frame, command_button_clicked),
ok = wxFrame:setSize(Frame, {X, Y, (W div 4) * 3, (H div 4) * 3}),
ok = wxFrame:center(Frame),
true = wxFrame:show(Frame),
State = #s{frame = Frame, text = TextC},
{Frame, State}.
#s{frame = Frame, json = JSON, eterms = ETerms}.
  • If we don't tell the window how large it should be at the beginning of the program we don't really know what will happen -- probably not anything crazy, but probably not anything particularly useful.

    To determine what size we should start things at we need to know where the top-left corner of the frame should be as well as how large the user display area is so we can gracefully calculate a size about 3/4 of the screen and tell the frame to place itself there. Notice that we didn't actually change the X,Y coordinates of the upper left corner (we could have, but I'm lazy) and instead called wxFrame:center/1 to calculate it automatically before showing the frame to the user.

    Note also the calls to wxFrame:connect/2. There are a gajillionjillion GUI events flitting around all the time and trying to handle all of them would be a nightmare. The default, therefore, is to actually ignore them all by default and then only register to receive the ones you want using connect/2. Note that you will not see the connect/2 function listed in the docs for wxFrame, and that is because wxFrame inherits from wxEvtHandler because Wx is written in an OOPsy style -- so pay attention to the inheritance notes in the docs!

    Finally, of course, we must populate a state record to return to the calling function.

    Edited by Craig Everett
Please register or sign in to reply
-spec handle_call(Message, From, State) -> Result
......@@ -82,8 +118,8 @@ handle_call(Unexpected, From, State) ->
%% The gen_server:handle_cast/2 callback.
%% See: http://erlang.org/doc/man/gen_server.html#Module:handle_cast-2
handle_cast({show, Terms}, State) ->
ok = do_show(Terms, State),
handle_cast({json, String}, State) ->
ok = do_json(String, State),
{noreply, State};
handle_cast(Unexpected, State) ->
ok = log(warning, "Unexpected cast: ~tp~n", [Unexpected]),
......@@ -111,6 +147,18 @@ handle_info(Unexpected, State) ->
%% The wx_object:handle_event/2 callback.
%% See: http://erlang.org/doc/man/gen_server.html#Module:handle_info-2
handle_event(#wx{id = ID, event = #wxCommand{type = command_button_clicked}}, State) ->
case ID of
?sysREAD ->
NewState = do_read(State),
{noreply, NewState};
?sysCONV ->
NewState = do_conv(State),
{noreply, NewState};
?sysSAVE ->
NewState = do_save(State),
{noreply, NewState}
end;
  • Here is where we receive those command_button_clicked events that we connect/2ed to in the build_interface/1 function above.

    I usually try to visually group code that dispatches (picks an execution path) in a way that collects the decisions in as small an area as possible. In the function head we only match on command_button_clicked and assign the ID a label, then use a case statement to pick what to do based on which button was clicked -- and I do not do any processing in the body of handle_* (becomes nightmarishly ugly!). I find this less noisy than matching everything in the function heads, but that is merely a style choice, of course. When you have a lot of buttons and a lot of events this organization makes the (huge) handle_event/2 section much easier to read!

Please register or sign in to reply
handle_event(#wx{event = #wxClose{}}, State = #s{frame = Frame}) ->
ok = tg_con:stop(),
ok = wxWindow:destroy(Frame),
......@@ -131,6 +179,55 @@ terminate(Reason, State) ->
do_show(Terms, #s{text = TextC}) ->
String = io_lib:format("Received args: ~tp", [Terms]),
wxTextCtrl:changeValue(TextC, String).
do_json(String, #s{json = JSON}) ->
wxStyledTextCtrl:setText(JSON, String).
do_read(State = #s{frame = Frame, json = JSON}) ->
Dialog = wxFileDialog:new(Frame, [{style, ?wxFD_OPEN}]),
_ = wxFileDialog:showModal(Dialog),
Path = wxFileDialog:getPath(Dialog),
ok = wxFileDialog:destroy(Dialog),
ok =
case tg_con:read_file(Path) of
{ok, Bin} ->
Text = unicode:characters_to_list(Bin),
wxStyledTextCtrl:setText(JSON, Text);
{error, eisdir} ->
ok;
{error, enoent} ->
ok
end,
State.
  • Since this code is purely side-effecty we don't actually need to update the state, just perform some interface action if the file read operation was successful. Note that we are matching explicitly on two cases that are expected to be possible: the case where a directory was chosen and the case where the user may have cancelled the selection action (so the file path chosen would be empty: "") and the program tries to read a non-existant file. The reason we match on the successful case and the two expected failure cases explicitly is so that we can crash in the case something wild happens -- because we definitely want to see what was going on there.

    An alternative that does not involve crashing is to match a final clause on just Error -> and log whatever that was.

Please register or sign in to reply
do_conv(State = #s{json = JSON, eterms = ETerms}) ->
InText = wxStyledTextCtrl:getText(JSON),
OutText =
case zj:decode(InText) of
{ok, Terms} -> format(Terms);
Error -> io_lib:format("ERROR: ~tp", [Error])
end,
ok = wxStyledTextCtrl:setText(ETerms, OutText),
State.
  • The moment of moral dilemma: where to execute the core function here zj:decode/1. Calling this function is the whole purpose of the program, of course, and it is therefore proper to actually have tg_con make this call! It is also true, however, that this is interface code, and layout is a part of interface. The underlying data we are dealing with is the same, whether it is serialized as JSON or as Erlang terms... so one could argue that conversion from one to the other is a layout issue.

    I actually lean more toward having an exported tg_con:decode function that abstracts this and causes the control process to do the conversion and just return the result.

    In this suuuuper simple case, though, I thought it simpler to have the conversion occur here so that new Erlangers don't get confused by too many interface functions presented up front.

Please register or sign in to reply
format(Terms) when is_list(Terms) ->
Format = fun(Term) -> io_lib:format("~tp.~n", [Term]) end,
lists:map(Format, Terms);
format(Term) ->
format([Term]).
  • This is the formatting procedure adapted from the write_terms/2 function that was written for the CLI version of this tutorial previously.

Please register or sign in to reply
do_save(State = #s{frame = Frame, eterms = ETerms}) ->
Dialog = wxFileDialog:new(Frame, [{style, ?wxFD_SAVE}]),
_ = wxFileDialog:showModal(Dialog),
ok =
case wxFileDialog:getPath(Dialog) of
[] ->
ok;
Path ->
Data = wxStyledTextCtrl:getText(ETerms),
tg_con:save_file(Path, Data)
end,
ok = wxFileDialog:destroy(Dialog),
State.
  • Once again we have a bit of "just in case" handling code. If the user cancels the file save dialog the return value will be the empty string "" (which is the same as an empty list, and I just matched on [] here out of habit, though matching on "" would actually be more semantically correct).

    After using the dialog we need to destroy it (remember, the underlying library is C++), and just return the State.

Please register or sign in to reply
......@@ -2,8 +2,9 @@
{author,"Craig Everett"}.
{c_email,"zxq9@zxq9.com"}.
{copyright,"Craig Everett"}.
{deps,[]}.
{deps,[{"otpr","zj",{1,0,5}}]}.
{desc,"Create, edit and convert JSON to Erlang terms."}.
{file_exts,[]}.
{key_name,none}.
{license,"MIT"}.
{modules,[]}.
......@@ -13,4 +14,4 @@
{repo_url,[]}.
{tags,[]}.
{type,gui}.
{ws_url,[]}.
{ws_url,"https://gitlab.com/zxq9/termifierg"}.
  • I called zx set dep otpr-zj-1.0.5 to make the ZJ JSON library available to the program, and then zx update meta to give myself a change to add the URL to this repo, which actually I see here that I did backwards (I set the website URL to the repo URL -- whoops!).

    If I was going to publish this via Zomp I would update it again before submitting the package, setting the blog post as the website URL and this repo's URL in the correct spot. I'd probably also add a few tags so that this program would be searchable within ZX.

Please register or sign in to reply
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment