Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Animations refactor #54

Merged
merged 8 commits into from
Jul 11, 2020
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions .formatter.exs
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
# Used by "mix format"
[
inputs: [
"{mix,.formatter}.exs",
"{config,lib,test}/**/*.{ex,exs}",
"dialyzer.ignore.exs"
inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"],
locals_without_parens: [
field: 3
]
]
45 changes: 45 additions & 0 deletions lib/layout.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
defmodule Layout do
@moduledoc """
Describes a keyboard layout.
"""
doughsay marked this conversation as resolved.
Show resolved Hide resolved

alias __MODULE__.{Key, LED}

@type t :: %__MODULE__{
keys: [Key.t()],
leds: [LED.t()],
leds_by_keys: %{atom => LED.t()},
keys_by_leds: %{atom => Key.t()}
doughsay marked this conversation as resolved.
Show resolved Hide resolved
}
doughsay marked this conversation as resolved.
Show resolved Hide resolved
defstruct [:keys, :leds, :leds_by_keys, :keys_by_leds]

def new(keys, leds \\ []) do
doughsay marked this conversation as resolved.
Show resolved Hide resolved
leds_map = Map.new(leds, &{&1.id, &1})

leds_by_keys =
keys
|> Enum.filter(& &1.led)
|> Map.new(&{&1.id, Map.fetch!(leds_map, &1.led)})

keys_by_leds =
keys
|> Enum.filter(& &1.led)
|> Map.new(&{&1.led, &1})

struct!(__MODULE__,
keys: keys,
leds: leds,
leds_by_keys: leds_by_keys,
keys_by_leds: keys_by_leds
)
end

def keys(layout), do: layout.keys
def leds(layout), do: layout.leds

def led_for_key(%__MODULE__{} = layout, key_id) when is_atom(key_id),
do: Map.get(layout.leds_by_keys, key_id)

def key_for_led(%__MODULE__{} = layout, led_id) when is_atom(led_id),
do: Map.get(layout.keys_by_leds, led_id)
end
28 changes: 28 additions & 0 deletions lib/layout/key.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
defmodule Layout.Key do
@moduledoc """
Describes a physical key and its location.
"""
doughsay marked this conversation as resolved.
Show resolved Hide resolved

@type id :: atom

@type t :: %__MODULE__{
id: id,
x: float,
y: float,
width: float,
height: float,
doughsay marked this conversation as resolved.
Show resolved Hide resolved
led: atom
doughsay marked this conversation as resolved.
Show resolved Hide resolved
}
defstruct [:id, :x, :y, :width, :height, :led]

def new(id, x, y, opts \\ []) do
struct!(__MODULE__,
doughsay marked this conversation as resolved.
Show resolved Hide resolved
id: id,
x: x,
y: y,
width: Keyword.get(opts, :width, 1),
height: Keyword.get(opts, :height, 1),
led: Keyword.get(opts, :led)
)
end
doughsay marked this conversation as resolved.
Show resolved Hide resolved
end
22 changes: 22 additions & 0 deletions lib/layout/led.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
defmodule Layout.LED do
@moduledoc """
Describes a physical LED location.
"""
doughsay marked this conversation as resolved.
Show resolved Hide resolved

@type id :: atom

@type t :: %__MODULE__{
id: id,
doughsay marked this conversation as resolved.
Show resolved Hide resolved
x: float,
y: float
}
defstruct [:id, :x, :y]

def new(id, x, y) do
struct!(__MODULE__,
id: id,
x: x,
y: y
)
end
end
10 changes: 10 additions & 0 deletions lib/rgb_matrix.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
defmodule RGBMatrix do
@type any_color_model ::
Chameleon.Color.RGB.t()
| Chameleon.Color.CMYK.t()
| Chameleon.Color.Hex.t()
| Chameleon.Color.HSL.t()
| Chameleon.Color.HSV.t()
| Chameleon.Color.Keyword.t()
| Chameleon.Color.Pantone.t()
Comment on lines +2 to +9
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ (optional)

I wonder if the color model is actually a property of LED. 🤔 Not sure yet.

end
127 changes: 53 additions & 74 deletions lib/rgb_matrix/animation.ex
Original file line number Diff line number Diff line change
@@ -1,109 +1,88 @@
defmodule RGBMatrix.Animation do
@moduledoc """
Provides a data structure and functions to define an RGBMatrix animation.
alias Layout.LED
doughsay marked this conversation as resolved.
Show resolved Hide resolved

There are currently two distinct ways to define an animation.
@callback new(leds :: list(LED.t()), config :: any) :: {render_in, any}
doughsay marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ (optional)

What if this interface were the following?

@callback new(leds :: Layout.t, opts :: [keyword]) :: t

It accepts a layout of LEDs, and opts. Let it crash if the layout items can't be rendered to (i.e. aren't LEDs). The opts can be used to set/override/lockout the initial config values or whatever else the animation developer wants to do with them; the animation's %Config{} being created by the animation itself.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted the %Config{} structs to be serializable as "pre-sets" somewhere and be able to be fed in to the animation initialization. It felt nicer to be able to snapshot the actual config struct somewhere, and not a keyword list of options...

As for the layout change, that's a bigger discussion that we can have after this PR.

@callback render(state :: any, config :: any) ::
{list(RGBMatrix.any_color_model()), render_in, any}
doughsay marked this conversation as resolved.
Show resolved Hide resolved
doughsay marked this conversation as resolved.
Show resolved Hide resolved
@callback interact(state :: any, config :: any, led :: LED.t()) :: {render_in, any}
doughsay marked this conversation as resolved.
Show resolved Hide resolved

You may define an animation with a predefined `:frames` field. Each frame will advance every `:delay_ms` milliseconds.
These animations should use the `RGBMatrix.Animation.Static` `:type`. See the moduledocs of that module for
examples.

Alternatively, you may have a more dynamic animation which generates frames based on the current `:tick` of the
animation. See `RGBMatrix.Animation.{CycleAll, CycleLeftToRight, Pinwheel}` for examples.
"""

alias __MODULE__
alias RGBMatrix.Frame
@type t :: %__MODULE__{
type: type,
config: any,
doughsay marked this conversation as resolved.
Show resolved Hide resolved
state: any
}
defstruct [:type, :config, :state]
doughsay marked this conversation as resolved.
Show resolved Hide resolved

defmacro __using__(_) do
quote do
alias RGBMatrix.Animation

@behaviour Animation
@behaviour RGBMatrix.Animation
end
end

@callback next_frame(animation :: Animation.t()) :: Frame.t()
@type render_in :: non_neg_integer() | :never | :ignore

@type t :: %__MODULE__{
type: animation_type,
tick: non_neg_integer,
speed: non_neg_integer,
loop: non_neg_integer | :infinite,
delay_ms: non_neg_integer,
frames: list(Frame.t()),
next_frame: Frame.t() | nil
}
defstruct [:type, :tick, :speed, :delay_ms, :loop, :next_frame, :frames]

@type animation_type ::
@type type ::
__MODULE__.CycleAll
| __MODULE__.CycleLeftToRight
| __MODULE__.HueWave
| __MODULE__.Pinwheel
| __MODULE__.Static
| __MODULE__.RandomSolid
| __MODULE__.RandomKeypresses
| __MODULE__.SolidColor
| __MODULE__.Breathing
| __MODULE__.SolidReactive

@doc """
Returns a list of the available types of animations.
"""
@spec types :: list(animation_type)
@spec types :: list(type)
def types do
[
__MODULE__.CycleAll,
__MODULE__.CycleLeftToRight,
__MODULE__.Pinwheel
__MODULE__.HueWave,
__MODULE__.Pinwheel,
__MODULE__.RandomSolid,
__MODULE__.RandomKeypresses,
__MODULE__.SolidColor,
__MODULE__.Breathing,
__MODULE__.SolidReactive
]
end
doughsay marked this conversation as resolved.
Show resolved Hide resolved

@type animation_opt ::
{:type, animation_type}
| {:frames, list}
| {:tick, non_neg_integer}
| {:speed, non_neg_integer}
| {:delay_ms, non_neg_integer}
| {:loop, non_neg_integer | :infinite}

@spec new(opts :: list(animation_opt)) :: Animation.t()
def new(opts) do
animation_type = Keyword.fetch!(opts, :type)
frames = Keyword.get(opts, :frames, [])
@doc """
Returns an animation's initial state.
"""
@spec new(animation_type :: type, leds :: list(LED.t())) :: {render_in, t}
def new(animation_type, leds) do
doughsay marked this conversation as resolved.
Show resolved Hide resolved
config_module = Module.concat([animation_type, Config])
animation_config = config_module.new()
{render_in, animation_state} = animation_type.new(leds, animation_config)

%Animation{
animation = %__MODULE__{
type: animation_type,
tick: opts[:tick] || 0,
speed: opts[:speed] || 100,
delay_ms: opts[:delay_ms] || 17,
loop: opts[:loop] || :infinite,
frames: frames,
next_frame: List.first(frames)
config: animation_config,
state: animation_state
}
end

@doc """
Updates the state of an animation with the next tick of animation.
"""
@spec next_frame(animation :: Animation.t()) :: Animation.t()
def next_frame(animation) do
next_frame = animation.type.next_frame(animation)
%Animation{animation | next_frame: next_frame, tick: animation.tick + 1}
{render_in, animation}
end

@doc """
Returns the frame count of a given animation,

Note: this function returns :infinite for dynamic animations.
Returns the next state of an animation based on its current state.
"""
@spec frame_count(animation :: Animation.t()) :: non_neg_integer | :infinite
def frame_count(%{loop: :infinite}), do: :infinite
@spec render(animation :: t) :: {list(RGBMatrix.any_color_model()), render_in, t}
def render(animation) do
{colors, render_in, animation_state} =
doughsay marked this conversation as resolved.
Show resolved Hide resolved
animation.type.render(animation.state, animation.config)

def frame_count(animation), do: length(animation.frames) * animation.loop
{colors, render_in, %{animation | state: animation_state}}
doughsay marked this conversation as resolved.
Show resolved Hide resolved
end

@doc """
Returns the expected duration of a given animation.

Note: this function returns :infinite for dynamic animations.
Sends an interaction event to an animation.
"""
@spec duration(animation :: Animation.t()) :: non_neg_integer | :infinite
def duration(%{loop: :infinite}), do: :infinite

def duration(animation), do: frame_count(animation) * animation.delay_ms
@spec interact(animation :: t, led :: LED.t()) :: {render_in, t}
def interact(animation, led) do
{render_in, animation_state} = animation.type.interact(animation.state, animation.config, led)
{render_in, %{animation | state: animation_state}}
end
end
45 changes: 45 additions & 0 deletions lib/rgb_matrix/animation/breathing.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
defmodule RGBMatrix.Animation.Breathing do
@moduledoc """
Single hue brightness cycling.
"""

alias Chameleon.HSV
alias RGBMatrix.Animation

use Animation

defmodule Config do
use RGBMatrix.Animation.Config
end
doughsay marked this conversation as resolved.
Show resolved Hide resolved

defmodule State do
defstruct [:color, :tick, :speed, :led_ids]
end

@delay_ms 17

@impl true
def new(leds, _config) do
# TODO: configurable base color
color = HSV.new(40, 100, 100)
led_ids = Enum.map(leds, & &1.id)
{0, %State{color: color, tick: 0, speed: 100, led_ids: led_ids}}
end

@impl true
def render(state, _config) do
%{color: base_color, tick: tick, speed: speed, led_ids: led_ids} = state

value = trunc(abs(:math.sin(tick * speed / 5_000)) * base_color.v)
color = HSV.new(base_color.h, base_color.s, value)

colors = Enum.map(led_ids, fn id -> {id, color} end)

{colors, @delay_ms, %{state | tick: tick + 1}}
end

@impl true
def interact(state, _config, _led) do
{:ignore, state}
end
doughsay marked this conversation as resolved.
Show resolved Hide resolved
end
Loading