Skip to content

Colour control for lights: hue/saturation as a real colour surface #89

Description

@schizza

What problem does this solve?

#87 gave lights a brightness slider and a white colour-temperature slider. A colour light still cannot be given a colour from Snapdash.

This adds a hue/saturation surface to the expanded widget: a 2D field that sets both components in one light.turn_on call, alongside the sliders that are already there.


Corrections to the original framing

The first version of this issue argued from three claims about Home Assistant. Two of them are wrong, and the design follows from the corrected versions, so they are recorded here rather than quietly dropped. All of this was checked against homeassistant/components/light/__init__.py and homeassistant/util/color.py on dev.

"A light sitting in color_temp mode reports no hs_color." False. _light_internal_convert_color derives hs_color, rgb_color and xy_color from the kelvin value whenever the active mode is color_temp. The attribute is present, just computed. It therefore cannot be used to discriminate the mode.

The real asymmetry runs the other way, and it is worse. state_attributes sets color_temp_kelvin to None whenever the active mode is not color_temp:

if color_temp_supported(supported_color_modes):
    if color_mode == ColorMode.COLOR_TEMP:
        data[ATTR_COLOR_TEMP_KELVIN] = self.color_temp_kelvin
    else:
        data[ATTR_COLOR_TEMP_KELVIN] = None

brightness is None on the same terms, and color_mode itself is None whenever the light is off, which nulls every colour attribute at once. Today's value resolution is pending.or(control.current).unwrap_or(control.min) (src/ui/entity_window.rs:147), so a null axis renders as its minimum. Setting a colour would make the White slider jump to 2000 K and claim it. That is a regression this issue would introduce, and it is handled below.

hs_color is a universal input and a universal output. process_turn_on_params converts an inbound hs_color into whatever space the light actually supports, including RGB, RGBW, RGBWW, XY and, as a last resort, back to color_temp_kelvin. Snapdash can always send hue/saturation and always read it back, whatever the bulb is.

The capability predicate is COLOR_MODES_COLOR, which is {hs, rgb, rgbw, rgbww, xy} (light/const.py), not hs alone.


Design

1. Vocabulary: a Control may drive more than one Axis

hs_color is not one axis carrying two numbers. Hue is one numeric dimension and saturation is another, exactly as CONTEXT.md defines an Axis. What is new is a control that sets two axes with one gesture and one service call.

Today the two concepts are fused, because until now they were 1:1. This issue splits them:

pub struct Axis {                      // was ContinuousControl
    pub kind: AxisKind,                // was ContinuousKind
    pub min: f32,
    pub max: f32,
    pub step: f32,
    pub current: Option<f32>,
}

pub enum Control {
    Value(Axis),
    Color { hue: Axis, saturation: Axis },
}

pub struct Capabilities {
    pub primary: Option<ActionKind>,
    pub controls: Vec<Control>,        // was continuous: Vec<ContinuousControl>
}

An Axis owns its range, its Home Assistant value and one scalar pending value. A Control owns three things: how it draws, what it sends, and how it recognises its own echo.

AxisKind::{Hue, Saturation} exist only inside Control::Color, which the enum makes unrepresentable otherwise. That is the reason for the enum over a flat Vec with a group marker: a marker permits hue without saturation, or an orphan hue in a slider of its own, and the type system would not catch either.

The rename touches 61 sites across five files (src/ha/actions.rs, src/ha/mod.rs, src/app/pending.rs, src/app/snapdash.rs, src/ui/entity_window.rs). The word "axis" already appears 85 times in the code, in comments, in WidgetView.axes and in Message::ControlValueChanged { axis, .. }, so the code already calls these things axes in prose. This aligns the types with CONTEXT.md for the first time.

2. Discovery and mutual exclusivity

The colour surface is offered when supported_color_modes intersects {hs, rgb, rgbw, rgbww, xy}, and not otherwise.

Colour temperature and colour are both shown at once, not behind a mode switch. They are genuinely both settable, and which one the device is currently honouring is communicated by rule 3 rather than by hiding a control.

Discovery stays keyed on supported_color_modes, not the active color_mode, because color_mode is None whenever the light is off and the controls must not appear and disappear as the light is switched.

3. An axis Home Assistant reports as null renders without a knob

null from Home Assistant is information, not missing data: it means the device is not currently driving that axis. It is rendered as such.

The whole controls block drops to ~40% opacity and the slider knob and colour marker are not drawn at all. A missing knob says "this axis has no value right now" in a way that cannot be confused with "the value is at the minimum", which is exactly the confusion the current unwrap_or(control.min) fallback creates.

Deliberately not chosen: remembering the last non-null value. That shows 4000 K on a light currently glowing blue, with no signal that it is history.

This also fixes existing behaviour: brightness on a light that is off currently renders as 0 rather than as absent. iced::widget::slider can hide its knob through slider::Style.handle.color, so no custom slider is needed.

4. Shape and geometry

A 2D field, the width of a slider track, aspect ratio 2:1.

metric::PAD is 14, so the track width is 132 / 172 / 212 at Small / Normal / Large, which makes the field 132x66, 172x86 and 212x106.

Worst case for a colour light with brightness, colour temperature and colour, the expanded window becomes 292 / 353 / 418 px tall. The 2:1 ratio was picked because at Small it costs 292 px against 294 px for two stacked gradient strips, so the 2D field and its single gesture come for free relative to the cheapest alternative. A 1:1 square would cost 358 / 439 / 524 px, which stops being a widget.

controls_height stops being a flat multiplication and sums per-control heights.

5. Interaction

Absolute positioning: the marker follows the pointer, and the drag keeps tracking when the pointer leaves the field.

  • Shift locks the axis that has moved less since the press.
  • Saturation magnets to exactly 0 and 100 within roughly 3 px of the top and bottom edges. Pure function of position, no state.
  • Alt scales pointer movement to 1/4 relative to the press point, for fine adjustment. The marker detaches from the pointer while held, as it does in every tool that has this.
  • Scroll wheel over the field nudges hue by one step; Shift and wheel nudges saturation.

No automatic directional lock. An auto-lock that guesses wrong presents as "the control stopped responding", which is indistinguishable from a defect, and it is the hidden in-gesture classifier that docs/adr/0001 exists to keep out. Shift and Alt are pure functions of the current frame and cannot get stuck.

Resolution: the field is 132 logical points wide at Small, so 2.72° of hue per point. iced_winit converts the cursor with position.to_logical::<f64>(scale_factor) before casting to f32, so a 2x display already halves that for free. Alt takes it to 0.68° per point at 1x.

6. Rendering: a pre-rendered texture, not gradients

iced's gradients cannot draw this correctly. Both wgpu/src/shader/quad/gradient.wgsl and triangle/gradient.wgsl interpolate with smoothstep, not linearly:

let factor = smoothstep(curr_offset, next_offset, coord_offset);
color = interpolate_color(from_, to_, factor);

smoothstep(t) = 3t² - 2t³ departs from t by up to 9.6% of the segment length, at t ≈ 0.211. A two-stop white-to-hue saturation gradient would show 11.5% where the marker claims 21%. A seven-stop rainbow (the maximum, stops is [Option<ColorStop>; 8]) would be about 5.8° out, with wide plateaus of pure red and green and compressed transitions. The marker would sit on a different colour from the one it names, which disqualifies gradients for a colour picker rather than merely degrading them.

Not a wgpu shader widget either. iced::widget::shader is available today with no Cargo.toml change, but it is a wgpu-only path and iced's default features include tiny-skia. On a machine that falls back to software rendering, remote desktop or a VM, the colour field would render as nothing. For an application that is entirely made of these widgets that is not an acceptable hole.

So: a pre-rendered RGBA texture. The field is computed exactly in Rust once, at a fixed 256x128, and drawn scaled through advanced::image::Renderer::draw_image. One texture for the whole application. Correct to the pixel on every backend.

This costs one iced feature: image-without-codecs = ["iced_widget/image", "dep:image"], where image is default-features = false, so no decoders are pulled in.

This reasoning must be recorded in two places, docs/adr/0005 and a doc comment on the texture generator itself, because "surely that is just two gradient quads" is exactly what somebody will propose as a simplification, and the answer needs to be reachable from the code.

7. Echo reconciliation: compare in RGB, and tolerance belongs to the Control

Home Assistant's color_hs_to_RGB goes through color_hsv_to_RGB(h, s, 100), which returns integer RGB, and color_RGB_to_hs reads those integers back. A light working in RGB or XY therefore returns different hue/saturation from what was sent:

saturation max hue error max saturation error
1 19.9° 0.18
5 4.52° 0.10
20 1.18° 0.00
60 0.39° 0.00
100 0.24° 0.39

The hue error is roughly 20 / saturation degrees, because at low saturation hue is barely determined in 8-bit RGB at all. The current EPSILON = 0.01 would never match, at any saturation.

The colour Control recognises its echo by comparing in 8-bit RGB: reconcile when hs_to_rgb(sent) and hs_to_rgb(echoed) differ by at most 1 in each channel. This compares what the user actually sees rather than the coordinates the colour happens to be written in, and it dissolves the low-saturation hue ambiguity for free, since a 20° difference at saturation 1 is literally the same colour. It also removes any need for wraparound handling: hue 0 and hue 360 produce identical RGB.

The general rule, which is what makes this not a special case: the tolerance is a property of the Control, alongside sending and drawing. #92 is the minimal per-axis version of the same fix for colour temperature and lands first; this issue generalises it.

8. PendingValues takes a batch, because one gesture now moves two axes

PendingValues::set (src/app/pending.rs:128-150) fuses recording shown with consuming the throttle window. Calling it once per component would be a live bug:

self.pending.set(&id, Hue, h, now);         // due -> records, sets last_sent, stamps last_send_at
self.pending.set(&id, Saturation, s, now);  // not due -> records shown, returns None

The second call never sets last_sent, and Pending::reconciles is last_sent.is_some_and(..), so saturation could never reconcile. Every colour drag would run to the settle timeout, and because reconcile only returns true when no axis of the entity is still pending, it would hold brightness hostage too.

The batch becomes the only path, so the scalar and batch cases cannot drift apart:

pub fn set(&mut self, entity_id: &str, updates: &[(AxisKind, f32)], now: Instant) -> bool;
pub fn release(&mut self, entity_id: &str, axes: &[AxisKind], now: Instant)
    -> Option<Vec<(AxisKind, f32)>>;

set returns whether a send is due; the caller already holds the values it passed, so nothing is allocated. release must return values because shown is not the caller's to know. The 17 existing tests in pending.rs are rewritten onto slices.

The throttle stays keyed per entity and unchanged at 200 ms, which is the invariant docs/adr/0003 depends on. A colour drag peaks at the same five calls per second as a brightness drag.

New messages ColorChanged { entity_id, hue, saturation } and ColorReleased { entity_id }, rather than bending ControlValueChanged, which would have to carry an Option second component for every scalar control that never has one.

9. On the wire

  • Hue is [0, 359], step 1. Saturation is [0, 100], step 1. Integer degrees are finer than the pixel grid even at Large, where the field is 212 points wide at 1.7° per point. Capping hue at 359 removes the 0/360 ambiguity entirely rather than introducing wraparound state to resolve it; red appears at both ends of the field, as it does in every hue strip.
  • ActionKind::SetHs { hue: u16, saturation: u8 }, mapping to light.turn_on with the single parameter hs_color: [h, s]. ActionKind stays Copy. The comment on src/ha/actions.rs:65 claiming every payload is a scalar stops being true and needs rewriting.
  • Brightness is not sent with a colour. light.turn_on without brightness preserves it. One control sets one thing.
  • Dragging the colour field on a light that is off turns it on, because light.turn_on with hs_color is a turn-on. This matches what brightness already does today (src/ha/actions.rs:111), so it is not a new exception. Note the corollary that already exists: dragging brightness to 0 turns the light off, because Home Assistant treats brightness: 0 as such.

10. Discoverability

Shift, Alt and the wheel are invisible unless they are advertised.

A help icon sits in the colour control's own label row, right-aligned: Colour ...... 212°, 85% (?). Hover shows a tooltip with the three shortcuts. No click target, no new window.

It lives there rather than in the widget header because the header is already carrying the title, the action icon, the chevron and the update alert, which leaves roughly 76 px for the title at Small; one more icon would cut that to ~56 px and make the title visibly shorten on expand. The label row costs the header nothing, sits next to the thing it explains, and exists only when a colour surface does. The shortcuts apply only to the colour field in this iteration, since the stock slider does not implement them, so an icon on a brightness-only widget would promise help it could not give.

docs/adr/0001 forbids hover chrome pinned to the card's edges while expanded, because those edges become sliders. The label row is part of the control's own layout, not an overlay on a track, so this does not conflict. If it proves visually noisy in practice, the fallback is the bottom-right corner of the card, which the same ADR leaves free while the widget is expanded.

The readout carries 212°, 85% because saturation cannot be judged by eye mid-drag. At Small the row is 132 px, the Colour label takes about 30 and the icon about 14, leaving ~88 px for a readout that needs ~45 at detail_font 10.


Implementation plan

Six PRs, all against dev (see docs/agents/branching.md). The first three change no observable behaviour and should be reviewable by reading.

  1. refactor - rename ContinuousControl to Axis, ContinuousKind to AxisKind, Capabilities.continuous to controls. Mechanical, 61 sites, no behaviour change.
  2. refactor - PendingValues takes a batch. 17 tests rewritten onto slices. No behaviour change.
  3. fix - per-axis echo tolerance. This is Colour temperature never reconciles: one global echo tolerance is too tight for an axis that round-trips through mireds #92 and can land independently of the rest.
  4. feat - Control grouping, AxisKind::{Hue, Saturation}, SetHs on the wire, tolerance moved from Axis to Control. Headless, fully unit-testable, no UI.
  5. fix - an axis with no value renders without a knob. Touches brightness and colour temperature as they stand today.
  6. feat - the colour surface: texture, custom widget, modifiers, wheel, help icon, and the documentation below.

Tests

The gap that matters: a template light backed by input_number helpers does not reproduce the round-trip loss at all, because it stores hue and saturation verbatim. Testing only against one would let a wrong tolerance pass.

  • A golden table of (sent, echoed) pairs harvested from homeassistant.util.color, for example (59, 1) -> (40.0, 1.176) and (0, 80) -> (0.0, 80.392), asserted to reconcile. Plus a second table of genuinely different colours asserted not to. Deliberately not a port of Home Assistant's conversion maths into the test, which would only test the port.
  • A second fake light in the Home Assistant test instance reporting supported_color_modes: [rgb], so end-to-end exercises the same path a real bulb does.
  • src/ha/actions.rs: extend the golden wire_mapping_is_stable with SetHs; discovery tests that the colour control appears for each of {hs, rgb, rgbw, rgbww, xy} and for none of {onoff, brightness, color_temp, white}.
  • src/app/pending.rs: a test that a batch stamps last_sent on both axes, which is the bug in section 8.
  • src/widget_size/tests.rs: the height of a Control::Color block across presets.
  • New pure function, extracted out of Widget::update so that it is testable at all: (bounds, point, modifiers, press origin) -> (hue, saturation), covering the Shift lock, the Alt fine drag and the saturation magnets.
  • New: texture generation, asserted at specific pixels. Top-left white, the saturation-100 row on the pure hues, the hue-0 column red.

Definition of done

  • docs/adr/0004-controls-and-axes.md - a Control may drive several Axes and owns drawing, sending and echo recognition; an Axis owns range, value and one scalar pending value. Extends 0002.
  • docs/adr/0005-the-colour-field-is-a-texture.md - why not gradients, why not a shader, why a texture.
  • The same reasoning, abridged, as a doc comment on the texture generator.
  • CONTEXT.md - a Control entry, and Axis extended to say that an axis need not have a control of its own.
  • README - the Shift / Alt / wheel shortcuts.
  • Release notes - the same.

Testing seams

Development is test-first. Tickets are written red-first and name the seam each test lands on. These are the agreed seams; no test is written anywhere else.

S1 - Home Assistant attributes in, capabilities out. Capabilities::from_state. Fixtures are real attribute blobs of the shape Home Assistant actually broadcasts, not invented ones.

S2 - the application driven by messages, against a fake Home Assistant over real HTTP. Snapdash::update with a wiremock server standing in for the REST endpoint. A test expands a widget, drags a control, and asserts on the actual JSON body posted, then feeds the echo back and asserts the widget returns to Home Assistant truth. This is the end-to-end seam and covers discovery, batching, the throttle, the send and reconciliation in one path. Snapdash::new() takes no arguments and its fields are public, and call_service builds its URL from the connection config, so it can be pointed at 127.0.0.1. Costs one dev-dependency, wiremock.

S3 - PendingValues through its public API. Time is a parameter on every method, so no clock is mocked.

S4 - the pointer-to-value mapping, as a free function of bounds, cursor, modifiers and press origin. Extracted out of the widget's event handler in ticket 05, because inside it, it cannot be tested at all.

S5 - the texture generator, sampled at known points.

Deliberately not under test: iced's layout, actual pixels on screen, the window manager, and the WebSocket reconnect path. S4 and S5 exist precisely because S2 cannot reach rendering, and saying so is better than pretending otherwise.

One trap worth naming: the tolerance tests use a golden table of pairs harvested from homeassistant.util.color. Porting Home Assistant's conversion maths into the test instead would be tautological - the assertion would recompute the expected value the way the code does and could never disagree with it.

Tickets

This issue is delivered as vertical slices, each demoable on its own. See the sub-issues.

Which area does this affect?

UI / widgets

Additional context

Depends on #87, merged in #91. Blocked by nothing; #92 should land first but only shares a seam, not a blocker.

Planned in a grilling session; every measurement quoted above was taken rather than estimated.

Metadata

Metadata

Assignees

No one assigned

    Labels

    featNew feature or enhancementpriority: lowNice-to-have / laterreadyReady for review / mergeuiUI / widgets / theme / layout

    Projects

    Status
    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions