Make the new machine form build every machine it offers

6a69b24 · Gabriel Morell · 2026-09-16 23:01

29 files +805 -933
Message
{commit_body(@commit)}

Files changed

modified lib/lambent_ex/machine/library.ex
+21 −1
@@ -1,4 +1,24 @@
1 1 defmodule LambentEx.Machines.Library do
2 + @doc "One catalog entry by class and name, however they arrive."
3 + def entry(cls, name) do
4 + with {:ok, cls} <- key(cls),
5 + {:ok, name} <- key(name) do
6 + machines() |> Keyword.get(cls, %{}) |> Map.get(name)
7 + else
8 + _otherwise -> nil
9 + end
10 + end
11 +
12 + defp key(value) when is_atom(value), do: {:ok, value}
13 +
14 + defp key(value) when is_binary(value) do
15 + {:ok, String.to_existing_atom(value)}
16 + rescue
17 + ArgumentError -> :error
18 + end
19 +
20 + defp key(_value), do: :error
21 +
2 22 def machines do
3 23 [
4 24 solid: %{
@@ -133,7 +153,7 @@ defmodule LambentEx.Machines.Library do
133 153 desc: "star-of-life",
134 154 machine: LambentEx.Machine.Steps.Sparkle,
135 155 args: %{hues: [13, 187, 85]}
136 − },
156 + }
137 157 },
138 158 twinkle: %{
139 159 green: %{
added lib/lambent_ex/machine/preview.ex
+52 −0
@@ -0,0 +1,52 @@
1 +defmodule LambentEx.Machines.Preview do
2 + @moduledoc """
3 + What a machine would look like, without starting one.
4 +
5 + A step is a plain GenServer that answers `:read` with a pixel, so running one
6 + off to the side and stepping it is enough to show a configuration before it
7 + is committed to — the same single pixel over time the catalog draws.
8 + """
9 +
10 + @count 64
11 +
12 + @doc """
13 + `count` pixels of `step` configured with `args`, oldest first, as `[r, g, b]`.
14 +
15 + The process is started unlinked and unregistered: a configuration that a step
16 + cannot start with is a thing the form should say, not a thing that should
17 + take the page down with it.
18 + """
19 + def strip(step, args, count \\ @count)
20 +
21 + def strip(nil, _args, _count), do: []
22 +
23 + def strip(step, args, count) do
24 + case GenServer.start(step, args |> Map.put(:id, 0)) do
25 + {:ok, pid} -> read(pid, count)
26 + _otherwise -> []
27 + end
28 + end
29 +
30 + defp read(pid, count) do
31 + pixels =
32 + Enum.map(1..count, fn _tick ->
33 + GenServer.cast(pid, :step)
34 + GenServer.call(pid, :read)
35 + end)
36 +
37 + GenServer.stop(pid)
38 + Enum.filter(pixels, &match?([_r, _g, _b], &1))
39 + catch
40 + # a half configured step can fail mid-run, and a partial strip is still
41 + # worth showing
42 + _kind, _reason ->
43 + Process.exit(pid, :kill)
44 + []
45 + end
46 +
47 + @doc "The strip as css colours, for a template to lay out."
48 + def swatches(step, args, count \\ @count) do
49 + strip(step, args, count)
50 + |> Enum.map(fn [r, g, b] -> "rgb(#{round(r)}, #{round(g)}, #{round(b)})" end)
51 + end
52 +end
modified lib/lambent_ex/machine/steps/scapes.ex
+1 −1
@@ -30,7 +30,7 @@ defmodule LambentEx.Machine.Steps.Scape do
30 30 cfg
31 31 |> Map.put(:hues, hues)
32 32 |> Map.put(:target, length(hues) - 1)
33 − |> Map.put(:status, Integer.mod(opts.id, length(hues) - 1))}
33 + |> Map.put(:status, Integer.mod(opts.id, max(length(hues) - 1, 1)))}
34 34 end
35 35
36 36 defp via_tuple(name) do
modified lib/lambent_ex/schema/machines.ex
+285 −55
@@ -3,7 +3,6 @@ defmodule LambentEx.Schema.Machines do
3 3
4 4 use Ecto.Schema
5 5 import Ecto.Changeset
6 − import PolymorphicEmbed, only: [cast_polymorphic_embed: 3]
7 6
8 7 alias LambentEx.Schema.Machines
9 8
@@ -37,20 +36,108 @@ defmodule LambentEx.Schema.Machines do
37 36 rcv_hyp: "Hyperion Receiver"
38 37 }
39 38
40 − @steps %{
41 − # gen_solid: "Solid",
42 − gen_solid_h: LambentEx.Machine.Steps.Solid,
43 − # gen_chaser: "Chaser",
44 − gen_chaser_h: LambentEx.Machine.Steps.Chase,
45 − gen_rainbow: LambentEx.Machine.Steps.Rainbow,
46 − gen_rainbow_s: LambentEx.Machine.Steps.Rainbow,
47 − gen_rocker: LambentEx.Machine.Steps.Rocker,
48 − gen_rocker_s: LambentEx.Machine.Steps.Rocker,
49 − gen_scape: LambentEx.Machine.Steps.Scape,
50 − gen_scape_s: LambentEx.Machine.Steps.Scape,
51 − gen_firefly: LambentEx.Machine.Steps.Firefly,
52 − gen_growth: LambentEx.Machine.Steps.Growth,
53 − gen_twinkler: LambentEx.Machine.Steps.Twinkle,
39 + @doc """
40 + The machine types the form can actually build: what holds their settings,
41 + what runs them, and whether the machine drives one pixel that is mirrored
42 + across the strand or a pixel per position.
43 +
44 + Anything in `@values` but not here is a type nothing implements yet, and the
45 + form does not offer it.
46 + """
47 + @forms [
48 + gen_solid_h: %{
49 + schema: LambentEx.Schema.Steps.Solid,
50 + step: LambentEx.Machine.Steps.Solid,
51 + single: true
52 + },
53 + gen_chaser_h: %{
54 + schema: LambentEx.Schema.Steps.Chaser,
55 + step: LambentEx.Machine.Steps.Chase,
56 + single: false
57 + },
58 + gen_rainbow: %{
59 + schema: LambentEx.Schema.Steps.Rainbow,
60 + step: LambentEx.Machine.Steps.Rainbow,
61 + single: false
62 + },
63 + gen_rainbow_s: %{
64 + schema: LambentEx.Schema.Steps.Rainbow,
65 + step: LambentEx.Machine.Steps.Rainbow,
66 + single: true
67 + },
68 + gen_rocker: %{
69 + schema: LambentEx.Schema.Steps.Rocker,
70 + step: LambentEx.Machine.Steps.Rocker,
71 + single: false
72 + },
73 + gen_rocker_s: %{
74 + schema: LambentEx.Schema.Steps.Rocker,
75 + step: LambentEx.Machine.Steps.Rocker,
76 + single: true
77 + },
78 + gen_scape: %{
79 + schema: LambentEx.Schema.Steps.Scape,
80 + step: LambentEx.Machine.Steps.Scape,
81 + single: false
82 + },
83 + gen_scape_s: %{
84 + schema: LambentEx.Schema.Steps.Scape,
85 + step: LambentEx.Machine.Steps.Scape,
86 + single: true
87 + },
88 + gen_firefly: %{
89 + schema: LambentEx.Schema.Steps.Firefly,
90 + step: LambentEx.Machine.Steps.Firefly,
91 + single: false
92 + },
93 + gen_growth: %{
94 + schema: LambentEx.Schema.Steps.Growth,
95 + step: LambentEx.Machine.Steps.Growth,
96 + single: false
97 + },
98 + gen_twinkler: %{
99 + schema: LambentEx.Schema.Steps.Twinkle,
100 + step: LambentEx.Machine.Steps.Twinkle,
101 + single: false
102 + }
103 + ]
104 +
105 + # what a field is for, so the form knows what to draw. anything not named
106 + # here is a plain number
107 + @hues [:h, :h_sel, :h_l, :h_h]
108 + @levels [:s, :v]
109 +
110 + @labels %{
111 + h: "Hue",
112 + h_sel: "Hue",
113 + h_l: "Hue (low)",
114 + h_h: "Hue (high)",
115 + h_g: "Hues (growing)",
116 + h_d: "Hues (dying)",
117 + s: "Saturation",
118 + v: "Value",
119 + modulo: "Modulo",
120 + spacing: "Spacing",
121 + fadeby: "Fade by",
122 + linger: "Linger",
123 + dark: "Rest dark between hues",
124 + mult: "Multiplier",
125 + min_g: "Growth (min)",
126 + max_g: "Growth (max)",
127 + min_d: "Decay (min)",
128 + max_d: "Decay (max)",
129 + lb_min: "Lower bound (min)",
130 + lb_max: "Lower bound (max)",
131 + min_t: "Twinkles (min)",
132 + max_t: "Twinkles (max)",
133 + min_w: "Wait (min)",
134 + max_w: "Wait (max)",
135 + surprise_chance: "Surprise chance"
136 + }
137 +
138 + # the schemas name a couple of fields more legibly than the running steps do
139 + @renames %{
140 + LambentEx.Schema.Steps.Rocker => %{linger: :l, dark: :d}
54 141 }
55 142
56 143 embedded_schema do
@@ -58,36 +145,182 @@ defmodule LambentEx.Schema.Machines do
58 145
59 146 field(:type, Ecto.Enum, values: @values |> Map.keys())
60 147
61 − field(:class, PolymorphicEmbed,
62 − types: [
63 − # gen_solid: LambentEx.Schema.Steps.Solid,
64 − gen_solid_h: LambentEx.Schema.Steps.Solid,
65 − gen_chaser: LambentEx.Schema.Steps.Chaser,
66 − gen_chaser_h: LambentEx.Schema.Steps.Chaser,
67 − gen_rocker: LambentEx.Schema.Steps.Rocker,
68 − gen_rocker_s: LambentEx.Schema.Steps.Rocker,
69 − gen_scape: LambentEx.Schema.Steps.Scape,
70 − gen_scape_s: LambentEx.Schema.Steps.Scape,
71 − gen_rainbow: LambentEx.Schema.Steps.Rainbow,
72 − gen_rainbow_s: LambentEx.Schema.Steps.Rainbow,
73 − gen_firefly: LambentEx.Schema.Steps.Firefly,
74 − gen_growth: LambentEx.Schema.Steps.Growth,
75 − gen_twinkler: LambentEx.Machine.Steps.Twinkle,
76 − ],
77 − on_type_not_found: :raise,
78 − on_replace: :update
79 − )
148 + # whichever settings schema the type calls for, cast by hand below
149 + field(:class, :any, virtual: true)
80 150 end
81 151
82 152 def values do
83 153 @values
84 154 end
85 155
156 + def forms, do: @forms
157 +
158 + def spec(type) when is_atom(type), do: Keyword.get(@forms, type)
159 + def spec(type) when is_binary(type), do: spec(existing_atom(type))
160 + def spec(_type), do: nil
161 +
162 + @doc "What the type select offers, label first, in the order @values lists."
163 + def options do
164 + @values
165 + |> Enum.filter(fn {type, _label} -> Keyword.has_key?(@forms, type) end)
166 + |> Enum.map(fn {type, label} -> {label, type} end)
167 + end
168 +
169 + @doc """
170 + The settings a type carries, in the order its schema declares them.
171 +
172 + A field with no default is one the machine cannot run without, so the form
173 + puts those up front and folds the rest away. The kind is what to draw: a hue
174 + wheel position, a 0..255 level, a list of hues built by picking them one at
175 + a time, a flag, or a plain number.
176 + """
177 + def fields(type) do
178 + case spec(type) do
179 + nil ->
180 + []
181 +
182 + %{schema: schema} ->
183 + defaults = struct(schema)
184 +
185 + schema.__schema__(:fields)
186 + |> Enum.map(fn name ->
187 + %{
188 + name: name,
189 + kind: kind(name, schema.__schema__(:type, name)),
190 + required: Map.get(defaults, name) == nil,
191 + label: Map.get(@labels, name, name |> to_string() |> String.capitalize())
192 + }
193 + end)
194 + end
195 + end
196 +
197 + defp kind(_name, {:array, :integer}), do: :hues
198 + defp kind(_name, :boolean), do: :flag
199 + defp kind(name, :integer) when name in @hues, do: :hue
200 + defp kind(name, :integer) when name in @levels, do: :level
201 + defp kind(_name, _type), do: :number
202 +
203 + @doc "Whether this type builds its hue list by picking hues one at a time."
204 + def picks_hues?(type) do
205 + fields(type) |> Enum.any?(fn f -> f.kind == :hues end)
206 + end
207 +
208 + @doc """
209 + The settings as the running step wants them. Mostly the schema fields
210 + verbatim, minus the form's own selector, and with the couple of keys the
211 + steps spell differently.
212 + """
213 + def args(nil), do: %{}
214 +
215 + def args(%schema{} = class) do
216 + renames = Map.get(@renames, schema, %{})
217 +
218 + class
219 + |> Map.from_struct()
220 + |> Map.drop([:h_sel])
221 + |> Enum.map(fn {k, v} -> {Map.get(renames, k, k), v} end)
222 + |> Map.new()
223 + end
224 +
225 + @doc """
226 + A library entry as the form would have built it, named for editing rather
227 + than for replacing what the catalog already offers.
228 + """
229 + def from_library(nil, _entry), do: nil
230 + def from_library(_cls, nil), do: nil
231 +
232 + def from_library(cls, entry) do
233 + case LambentEx.Machines.Library.entry(cls, entry) do
234 + nil ->
235 + nil
236 +
237 + config ->
238 + case type_for(config) do
239 + nil -> nil
240 + type -> %Machines{name: "#{entry}-edited", type: type, class: class_for(type, config)}
241 + end
242 + end
243 + end
244 +
245 + @doc "Whether the catalog can hand this entry to the form at all."
246 + def editable?(config), do: type_for(config) != nil
247 +
248 + # several types share a step and differ only in whether they drive the whole
249 + # strand from one pixel, which is what the library records as mach_opts
250 + defp type_for(%{machine: step} = config) do
251 + single = config |> Map.get(:mach_opts, []) |> Keyword.get(:single, false) == true
252 +
253 + Enum.find_value(@forms, fn {type, spec} ->
254 + if spec.step == step and spec.single == single, do: type
255 + end)
256 + end
257 +
258 + defp type_for(_config), do: nil
259 +
260 + defp class_for(type, config) do
261 + %{schema: schema} = spec(type)
262 + renames = Map.get(@renames, schema, %{}) |> Map.new(fn {k, v} -> {v, k} end)
263 +
264 + args =
265 + config
266 + |> Map.get(:args, %{})
267 + |> Enum.map(fn {k, v} -> {Map.get(renames, k, k), v} end)
268 + |> Map.new()
269 + |> Map.take(schema.__schema__(:fields))
270 +
271 + struct(schema, args)
272 + end
273 +
274 + defp existing_atom(value) do
275 + String.to_existing_atom(value)
276 + rescue
277 + ArgumentError -> nil
278 + end
279 +
86 280 def changeset(source, params) do
87 281 source
88 282 |> cast(params, ~w(name type)a)
89 − |> cast_polymorphic_embed(:class, required: true)
90 283 |> validate_required([:name, :type])
284 + |> cast_class(source, params)
285 + end
286 +
287 + # the settings are a different schema per type, so they are cast against
288 + # whichever one the type names and kept on the changeset as that struct
289 + defp cast_class(changeset, source, params) do
290 + case changeset |> get_field(:type) |> spec() do
291 + nil ->
292 + changeset
293 +
294 + %{schema: schema} ->
295 + base =
296 + case source.class do
297 + %{__struct__: ^schema} = existing -> existing
298 + _otherwise -> struct(schema)
299 + end
300 +
301 + class = schema.changeset(base, class_params(params))
302 + changeset = put_change(changeset, :class, apply_changes(class))
303 +
304 + case class.valid? do
305 + true -> changeset
306 + false -> add_error(changeset, :class, class_error(class))
307 + end
308 + end
309 + end
310 +
311 + defp class_params(params) do
312 + case params do
313 + %{"class" => %{} = class} -> class
314 + %{class: %{} = class} -> class
315 + _otherwise -> %{}
316 + end
317 + end
318 +
319 + defp class_error(changeset) do
320 + changeset
321 + |> traverse_errors(fn {msg, _opts} -> msg end)
322 + |> Enum.map(fn {field, msgs} -> "#{field} #{Enum.join(msgs, ", ")}" end)
323 + |> Enum.join("; ")
91 324 end
92 325
93 326 def change_machine(%Machines{} = machine, attrs \\ %{}) do
@@ -96,30 +329,27 @@ defmodule LambentEx.Schema.Machines do
96 329
97 330 def update_machines(%Machines{} = machines, attrs) do
98 331 res = machines |> Machines.changeset(attrs)
99 − IO.inspect(res)
100 −
101 − opts =
102 − case res.changes[:type] do
103 − :gen_solid -> [single: true]
104 − :gen_solid_h -> [single: true]
105 − :gen_rainbow_s -> [single: true]
106 − :gen_rocker_s -> [single: true]
107 − :gen_scape_s -> [single: true]
108 − _otherwise -> []
109 − end
110 − |> IO.inspect()
111 −
112 − case res.valid? do
113 − true ->
332 +
333 + # a form opened on an existing configuration may have nothing in changes,
334 + # so what gets started is the whole field, not just what was touched
335 + type = Ecto.Changeset.get_field(res, :type)
336 + class = Ecto.Changeset.get_field(res, :class)
337 + name = Ecto.Changeset.get_field(res, :name)
338 +
339 + case {res.valid?, spec(type)} do
340 + {true, %{step: step, single: single}} ->
114 341 {:ok,
115 342 LambentEx.MachineSupervisor.start_child(
116 − Map.get(@steps, res.changes[:type]),
117 − res.changes[:class] |> Map.from_struct(),
118 − res.changes[:name],
119 − opts
343 + step,
344 + args(class),
345 + name,
346 + if(single, do: [single: true], else: [])
120 347 )}
121 348
122 − false ->
349 + {true, nil} ->
350 + {:error, Ecto.Changeset.add_error(res, :type, "has nothing to run it")}
351 +
352 + {false, _spec} ->
123 353 {:error, res}
124 354 end
125 355 end
modified lib/lambent_ex/schema/steps/firefly.ex
+1 −1
@@ -15,6 +15,6 @@ defmodule LambentEx.Schema.Steps.Firefly do
15 15 def changeset(source, params) do
16 16 source
17 17 |> cast(params, ~w(h h_sel mult s v)a)
18 − |> validate_required([:h, :h_sel, :mult, :s, :v])
18 + |> validate_required([:h, :mult, :s, :v])
19 19 end
20 20 end
modified lib/lambent_ex/schema/steps/growth.ex
+2 −2

Click to load diff…

modified lib/lambent_ex/schema/steps/rocker.ex
+1 −1

Click to load diff…

added lib/lambent_ex/schema/steps/twinkle.ex
+26 −0

Click to load diff…

modified lib/lambent_ex_web.ex
+0 −1

Click to load diff…

modified lib/lambent_ex_web/live/machines/form_component.ex
+222 −221

Click to load diff…

modified lib/lambent_ex_web/live/machines/form_component.html.heex
+41 −630

Click to load diff…

modified lib/lambent_ex_web/live/machines/index.ex
+17 −3

Click to load diff…

modified lib/lambent_ex_web/live/machines/library_form_component.ex
+3 −0

Click to load diff…

modified lib/lambent_ex_web/live/machines/library_form_component.html.heex
+14 −7

Click to load diff…

modified mix.exs
+15 −9

Click to load diff…

modified mix.lock
+0 −1

Click to load diff…

added test/lambent_ex_web/live/machine_form_test.exs
+104 −0

Click to load diff…

Parents: 54988b8