One of the more interesting topics for me in college was logic circuit simulators. You start off with on/off switches and lightbulbs, then introduce gates such as OR, AND, NOT, XOR, etc. From those simple building blocks you can simulate more complex things such as memory by looping an output of two NOR gates, or number displays with a complex mesh of AND/ORs feeding each of the seven segments of the 8
.
Shortly after Phoenix LiveView went public, I saw an advertisement for a mechanical logic circuit simulator, and it got me thinking how a logic circuit simulator would work with Elixir GenServers representing each node, message passing for the outputs/inputs, and a Phoenix LiveView frontend.
In this article, I will review the logic simulatior (logic_sim
) and the Phoenix LiveView frontend (logic_sim_liveview
) that resulted from that idea. If you want to jump in and play around with LogicSim first, check out the hosted version here.
GenServers
First, lets explore what it means to be a Node
in LogicSim. Not to be confused with the Elixir Node
module, a LogicSim Node
represents one simple circuit in LogicSim, such as a switch; lightbulb; or a gate, like and, not, or. A Node
will have zero or more inputs, zero or more outputs, and the ability to calculate its outputs from its inputs. Some nodes may need to track some sort of inherently internal state (such as an on/off switch), but we should be careful not to store too much state in a Node
or we risk ruining the fundamental concept that circuits are always built from a small handful of basic building blocks.
A Node
also needs to track the output nodes it is connected to on each of its outputs, and be able to tell a remote node when its output has changed. On the flip side, a Node
also needs to listen for incoming messages about any of its inputs, and when it receives a change, recalculate outputs, as well as notify its output nodes.
This concept of tracking state, updating state, and message passing is perfect for GenServers. We are going to put all the common code for a Node
into the Node
module, and pull it into individual circuits via use LogicSim.Node
. The common code accounts for the majority of the code required, so our actual circuits have very little code. For example, here is the complete Or
circuit:
defmodule LogicSim.Node.Or do
use LogicSim.Node, inputs: [:a, :b], outputs: [:a]
def calculate_outputs(_state, %{a: a, b: b} = _input_values) do
%{a: a or b}
end
end
We pass the list of two inputs and one output into the use LogicSim.Node
call, and simply implement the callback for calculate_outputs/2
, which gives us the current state and input values map, and takes back the new output values map.
In the code for OnOffSwitch
we see the use of additional internal state, initialized during the use LogicSim.Node
, and then destructured from state and used to calculate the output value:
defmodule LogicSim.Node.OnOffSwitch do
use LogicSim.Node, outputs: [:a], additional_state: %{on: false}
def toggle(server) do
GenServer.call(server, :toggle)
end
def handle_call(:toggle, _from, %{on: on} = state) do
on = !on
state = %{state | on: on}
state = set_output_value(:a, on, state)
{:reply, :ok, state}
end
def calculate_outputs(%{on: on} = _state, _input_values) do
%{a: on}
end
end
We also see that the OnOffSwitch
has the ability to “toggle” its internal state via a GenServer
call, which hooks back into the Node
function set_output_value
that handles updating the state and notifying any nodes connected to outputs. Finally, OnOffSwitch
shows us inputs/outputs are optional.
Macros
The __using__
macro in LogicSim.Node
is what allows us the simplicity of replacing all the boilerplate common code with a call to use LogicSim.Node
. By injecting the outputs, inputs, and additional state into the start_link
call with unquote
, the outside caller to start_link
only needs to specify the variable opts (e.g. :listeners
) when starting a node. The :listeners
list specifies the pids of processes that want to be notified any time the state of the node changes. We will hook into this later when we put a LiveView front end on LogicSim.
defmacro __using__(opts) do
inputs = Keyword.get(opts, :inputs, [])
outputs = Keyword.get(opts, :outputs, [])
additional_state = Keyword.get(opts, :additional_state, Macro.escape(%{}))
quote do
use GenServer
require Logger
@behaviour LogicSim.Node
def start_link(opts \\ []) do
output_nodes = Enum.reduce(unquote(outputs), %{}, &Map.put(&2, &1, %{}))
output_values = Enum.reduce(unquote(outputs), %{}, &Map.put(&2, &1, false))
input_values = Enum.reduce(unquote(inputs), %{}, &Map.put(&2, &1, false))
listeners = Keyword.get(opts, :listeners, [])
state =
unquote(additional_state)
|> Map.put(:inputs, unquote(inputs))
|> Map.put(:outputs, unquote(outputs))
|> Map.put(:output_nodes, output_nodes)
|> Map.put(:output_values, output_values)
|> Map.put(:input_values, input_values)
|> Map.put(:listeners, listeners)
GenServer.start_link(__MODULE__, state)
end
Note that we also tag @behaviour LogicSim.node
inside the macro. This ensures any module that uses LogicSim.Node
is warned if they don’t implement the required calculate_outputs
function.
Linking and Message Passing
The rest of the code in LogicView.Node.__using__
revolves around linking and message passing. Each node is responsible for tracking a list of output nodes for each one of its outputs. It does this by associating a map of input pids/inputs for each output. For example, the :output_nodes
for a node that is connected from its :a
output to node 0.671.0
’s input :a
and node 0.675.0
’s input :b
would look like:
%{a: %{#PID<0.671.0> => :a, #PID<0.675.0> => :b}}
Maybe you see in this data model the issue that I just saw as I was writing this article: An output can be connected to two inputs on different nodes, but can one output of a node be connected to multiple inputs on a single node? The answer right now is no, the first connection will get overwritten by the second, so I’ll throw that on the todo list to fix. (Side note: if you ever want to find the issues in your code, write a blog post explaining your code.)
To link two nodes, we call the client function link_output_to_node
, which calls the nodes GenServer with a :link_output_to_node
message:
def handle_call(
{:link_output_to_node, output, node, input},
_from,
%{output_nodes: output_nodes, output_values: output_values} = state
) do
output_nodes = put_in(output_nodes, [output, node], input)
set_node_input(node, input, Map.fetch!(output_values, output))
state = %{state | output_nodes: output_nodes}
send_state_to_listeners(state)
{:reply, :ok, state}
end
Here we add the input node to the list of outputs, noting which input on the input node it is connected to. We then send the current state of this nodes output to the other node via set_node_input
.
When a node needs to tell a linked node that its input should be updated, it calls the client function set_node_input
, which casts a :set_node_input
message to the GenServer. Side note: this was originally a call
instead of a cast
, but the first time I created a circuit that looped back on itself, the nodes deadlocked.
def set_node_input(node, input, input_value) do
GenServer.cast(node, {:set_node_input, input, input_value})
end
def handle_cast(
{:set_node_input, input, input_value},
%{
input_values: input_values,
output_values: old_output_values
} = state
) do
if Map.get(input_values, input) != input_value do
input_values = Map.put(input_values, input, input_value)
output_values = calculate_outputs(state, input_values)
state = %{state | input_values: input_values}
state =
output_values
|> Map.keys()
|> Enum.filter(fn key -> old_output_values[key] != output_values[key] end)
|> Enum.reduce(state, fn output, state_acc ->
set_output_value(output, output_values[output], state_acc, false)
end)
send_state_to_listeners(state)
{:noreply, state}
else
{:noreply, state}
end
end
We first check to see if the new input value is actually different from the current one. If it isn’t, then there is no need to do anything as our outputs won’t have changed. If it has changed, we calculate the new output values, and then if that results in any changed outputs, we apply those changes (including recursive calls to set_node_input
for any connected nodes). Finally we send our new state to any external listeners.
Since the intention of using LogicSim is to display the circuits in some way, we need a way to let external systems know when the state of a node changes. We saw in use LogicSim.Node
that we can specify a list of external pids via the :listeners
opt. send_state_to_listeners
, which we saw used above, simply loops through the list and calls send
with the current state:
defp send_state_to_listeners(%{listeners: listeners} = state) do
listeners
|> Enum.map(&send(&1, {:logic_sim_node_state, self(), state}))
end
This allows us to hook into LogicSim with LiveView.
LiveView
I could probably write a full blog post just on the LiveView implementation of LogicSim, but for now I am going to concentrate on the touch points between LogicSim and the LiveView interface.
The design of LogicSim.Node
makes instantiating a node as simple as calling start_link!
on the node type you want. Since we want the LiveView process to be notified of any state changes, we pass self()
in the :listeners
opt:
def create_node(type, x, y, uuid \\ nil) do
node_process = type.start_link!(listeners: [self()])
node_state = Node.get_state(node_process)
%{
uuid: uuid || UUID.uuid4(),
type: type,
node_process: node_process,
node_state: node_state,
top: y,
left: x
}
end
Our LiveView implementation tracks its own information alongside the reference to the node process, including top/left coordinates and the current state of the node. This way LiveView can render the state of the node without having to ask it on every render.
Connecting Nodes
Connecting nodes in LiveView hooks into the above mentioned link_output_to_node
function:
defp connect_nodes(%{nodes: nodes} = socket, output_uuid, output_output, input_uuid, input_input) do
%{node_process: input_node_process} = get_node_by_uuid(nodes, input_uuid)
%{node_process: output_node_process} = get_node_by_uuid(nodes, output_uuid)
Node.link_output_to_node(
output_node_process,
String.to_existing_atom(output_output),
input_node_process,
String.to_existing_atom(input_input)
)
{:noreply, assign(socket, select_output_for_input: nil, select_input_for_output: nil)}
end
Other than connecting nodes, the only other input into the system is currently by clicking on an OnOffSwitch
, which simply calls the toggle
function you saw above:
def handle_node_click(%{type: OnOffSwitch, node_process: node_process}, socket) do
OnOffSwitch.toggle(node_process)
{:noreply, socket}
end
The LiveView assigns (and therefore the UI) are updated via the :logic_sim_node_state
message that we saw above. Since each LiveView process is a GenServer, the message comes in as a handle_info
:
def handle_info({:logic_sim_node_state, from, node_state}, %{assigns: %{nodes: nodes}} = socket) do
nodes
|> Enum.split_with(fn %{node_process: node_process} -> node_process == from end)
|> case do
{[], _nodes} ->
Logger.warn("Received :logic_sim_node_state for untracked node: #{inspect(from)}")
{:noreply, socket}
{[node], nodes} ->
Logger.debug("Received :logic_sim_node_state with node_state: #{inspect(node_state)}")
node = %{node | node_state: node_state}
nodes = [node | nodes]
{:noreply, assign(socket, nodes: nodes)}
end
end
We simply look up the node in the LiveView state and update our copy of its state in our assigns. This triggers LiveView to re-render the view.
Click Location
Most of the click interactions in LogicSim LiveView simply use phx-click="node_click_<%= uuid %>"
on the div to trigger a server side event, with a corresponding def handle_event("node_click_" <> uuid, _, socket) do
to parse the uuid and handle the click. It gets a bit tricky when you want to figure out where on an element the user clicked. This is necessary to know where to add a new node, or where to move it to. LogicSim LiveView uses the following JavaScript to capture the click event and then set the phx-value
of the element clicked to the x,y coordinates of the click.
window.addEventListener("click", e => {
if (e.target.getAttribute("phx-click") && e.target.getAttribute("phx-send-click-coords")) {
let x = Math.floor(e.clientX - e.target.getClientRects()[0].x);
let y = Math.floor(e.clientY - e.target.getClientRects()[0].y);
let val = `${x},${y}`;
e.target.setAttribute("phx-value", val)
}
}, true)
As you can see, it only activates if phx-send-click-coords="true"
is set on the element. Whenever we want to track where a user is about to click, we put a selection div covering the whole screen, which has phx-click="selection_mode_div_clicked" phx-send-click-coords="true"
.
We handle that event on the server here:
def handle_event("selection_mode_div_clicked", params, %{assigns: %{selection_mode: selection_mode}} = socket) do
[x, y] = String.split(params, ",")
{x, _} = Integer.parse(x)
{y, _} = Integer.parse(y)
handle_selection_mode_div_clicked(selection_mode, x, y, socket)
end
The Result
That’s about it for this blog post. You can find the full code for LogicSim here, the full code for the LiveView server here, and a deployment of the LiveView server here.
DockYard is a digital product agency offering exceptional strategy, design, full stack engineering, web app development, custom software, Ember, Elixir, and Phoenix services, consulting, and training. With a nationwide staff, we’ve got consultants in key markets across the United States, including San Francisco, San Diego, Phoenix, Dallas, Detroit, Pittsburgh, Baltimore, and New York.