Packages

Native desktop GUIs from Elixir, powered by iced

Retired package: Renamed - Package has been renamed to plushie: hex.pm/packages/plushie

Current section

Files

Jump to
toddy examples color_picker.ex
Raw

examples/color_picker.ex

defmodule ColorPicker do
@moduledoc """
HSV color picker using the canvas widget as a custom interactive control.
A hue ring surrounds a saturation/value square. Drag the ring to select
a hue; drag the square to adjust saturation and value. The selected color
is displayed as a swatch and hex string below the canvas.
Demonstrates:
- Canvas with multiple layers and per-layer invalidation
- Path commands (ring segments) and linear gradients with alpha
- Interactive mouse events (press/move/release) for drag tracking
- Coordinate math for hit testing (ring vs. square regions)
- HSV-to-hex color conversion
"""
use Toddy.App
import Toddy.UI
alias Toddy.Canvas.Shape
alias Toddy.Event.Canvas
# -- Geometry constants ------------------------------------------------------
@canvas_size 400
@cx div(@canvas_size, 2)
@cy div(@canvas_size, 2)
@outer_r 190
@inner_r 150
@mid_r div(@inner_r + @outer_r, 2)
@sq_origin 100
@sq_size 200
@segments 72
@cursor_r 7
# -- App callbacks -----------------------------------------------------------
def init(_opts) do
%{hue: 0.0, saturation: 1.0, value: 1.0, drag: :none}
end
def update(model, %Canvas{type: :press, id: "picker", x: x, y: y, button: "left"}) do
dx = x - @cx
dy = y - @cy
dist = :math.sqrt(dx * dx + dy * dy)
cond do
dist >= @inner_r and dist <= @outer_r ->
%{model | drag: :ring, hue: hue_from_point(dx, dy)}
in_square?(x, y) ->
apply_sv(%{model | drag: :square}, x, y)
true ->
model
end
end
def update(model, %Canvas{type: :move, id: "picker", x: x, y: y}) do
case model.drag do
:ring -> %{model | hue: hue_from_point(x - @cx, y - @cy)}
:square -> apply_sv(model, x, y)
:none -> model
end
end
def update(model, %Canvas{type: :release, id: "picker"}) do
%{model | drag: :none}
end
def update(model, _event), do: model
def view(model) do
hex = hsv_to_hex(model.hue, model.saturation, model.value)
h_int = round(model.hue)
s_pct = round(model.saturation * 100)
v_pct = round(model.value * 100)
window "color_picker", title: "Color Picker" do
column padding: 20, spacing: 16, align_x: :center do
canvas("picker",
width: @canvas_size,
height: @canvas_size,
on_press: true,
on_release: true,
on_move: true,
layers: build_layers(model)
)
row spacing: 16, align_y: :center do
container "swatch", width: 48, height: 48, background: hex do
end
column spacing: 4 do
text("hex_display", hex, size: 18)
text("hsv_display", "H: #{h_int} S: #{s_pct}% V: #{v_pct}%")
end
end
end
end
end
# -- Hit testing -------------------------------------------------------------
defp in_square?(x, y) do
x >= @sq_origin and x <= @sq_origin + @sq_size and
y >= @sq_origin and y <= @sq_origin + @sq_size
end
# -- Coordinate math ---------------------------------------------------------
defp hue_from_point(dx, dy) do
angle = :math.atan2(dy, dx)
# Shift so hue 0 (red) is at 12 o'clock (top, -pi/2 in screen coords)
hue = angle + :math.pi() / 2
hue = if hue < 0, do: hue + 2 * :math.pi(), else: hue
hue * 180.0 / :math.pi()
end
defp apply_sv(model, x, y) do
s = clamp((x - @sq_origin) / @sq_size, 0.0, 1.0)
v = clamp(1.0 - (y - @sq_origin) / @sq_size, 0.0, 1.0)
%{model | saturation: s, value: v}
end
defp clamp(val, lo, hi), do: max(lo, min(hi, val))
# -- Layer builders ----------------------------------------------------------
defp build_layers(model) do
%{
"a_ring" => ring_layer(),
"b_sv_hue" => sv_hue_layer(model.hue),
"c_sv_dark" => sv_dark_layer(),
"d_cursors" => cursors_layer(model)
}
end
defp ring_layer do
deg_per_segment = 360 / @segments
for i <- 0..(@segments - 1) do
hue_deg = i * deg_per_segment
a1 = (hue_deg - 90) * :math.pi() / 180
a2 = (hue_deg + deg_per_segment - 90) * :math.pi() / 180
Shape.path(
[
Shape.move_to(@cx + @inner_r * :math.cos(a1), @cy + @inner_r * :math.sin(a1)),
Shape.line_to(@cx + @outer_r * :math.cos(a1), @cy + @outer_r * :math.sin(a1)),
Shape.line_to(@cx + @outer_r * :math.cos(a2), @cy + @outer_r * :math.sin(a2)),
Shape.line_to(@cx + @inner_r * :math.cos(a2), @cy + @inner_r * :math.sin(a2)),
Shape.close()
],
fill: hsv_to_hex(hue_deg, 1.0, 1.0)
)
end
end
defp sv_hue_layer(hue) do
hue_color = hsv_to_hex(hue, 1.0, 1.0)
[
Shape.rect(@sq_origin, @sq_origin, @sq_size, @sq_size,
fill:
Shape.linear_gradient(
{@sq_origin, @sq_origin},
{@sq_origin + @sq_size, @sq_origin},
[{0.0, "#ffffff"}, {1.0, hue_color}]
)
)
]
end
defp sv_dark_layer do
[
Shape.rect(@sq_origin, @sq_origin, @sq_size, @sq_size,
fill:
Shape.linear_gradient(
{@sq_origin, @sq_origin},
{@sq_origin, @sq_origin + @sq_size},
[{0.0, "#00000000"}, {1.0, "#000000ff"}]
)
)
]
end
defp cursors_layer(model) do
angle = (model.hue - 90) * :math.pi() / 180
ring_x = @cx + @mid_r * :math.cos(angle)
ring_y = @cy + @mid_r * :math.sin(angle)
sv_x = @sq_origin + model.saturation * @sq_size
sv_y = @sq_origin + (1.0 - model.value) * @sq_size
cursor_stroke = Shape.stroke("#333333", 2)
[
Shape.circle(ring_x, ring_y, @cursor_r, fill: "#ffffff", stroke: cursor_stroke),
Shape.circle(sv_x, sv_y, @cursor_r, fill: "#ffffff", stroke: cursor_stroke)
]
end
# -- Color conversion --------------------------------------------------------
defp hsv_to_hex(h, s, v) do
h = fmod(h, 360.0)
h = if h < 0, do: h + 360.0, else: h
c = v * s
h_sector = h / 60.0
x = c * (1.0 - abs(fmod(h_sector, 2.0) - 1.0))
m = v - c
{r1, g1, b1} =
cond do
h_sector < 1 -> {c, x, 0.0}
h_sector < 2 -> {x, c, 0.0}
h_sector < 3 -> {0.0, c, x}
h_sector < 4 -> {0.0, x, c}
h_sector < 5 -> {x, 0.0, c}
true -> {c, 0.0, x}
end
r = round((r1 + m) * 255)
g = round((g1 + m) * 255)
b = round((b1 + m) * 255)
"##{hex_byte(r)}#{hex_byte(g)}#{hex_byte(b)}"
end
defp hex_byte(n) do
n
|> max(0)
|> min(255)
|> Integer.to_string(16)
|> String.pad_leading(2, "0")
|> String.downcase()
end
defp fmod(a, b), do: a - b * Float.floor(a / b)
end