diff --git a/CONTEXT.md b/CONTEXT.md index 91ee98a..56c2c43 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -39,10 +39,22 @@ A toggle, a scene activation or a script run. _Avoid_: tap action, default action **Axis**: -One numeric dimension of an entity that is set rather than toggled, such as brightness, white colour temperature, thermostat setpoint or cover position. +One numeric dimension of an entity that is set rather than toggled, such as brightness, white colour temperature, hue, thermostat setpoint or cover position. An entity may expose several at once, each with its own range reported by Home Assistant. +An axis need not have a control of its own: hue and saturation are two axes driven by a single colour surface. _Avoid_: slider, analog, channel +**Absent axis**: +An axis Home Assistant is currently reporting as `null`, meaning the device is not driving that dimension at all. +Absent is a reading of the present, not a gap in the record, and it is rendered as such: dimmed, with no knob and no readout. +It is never rendered as the axis minimum, for reasons recorded in `docs/adr/0006-a-null-axis-renders-as-absent.md`. +_Avoid_: missing, unknown, zero, unset + +**Control**: +What the user grabs in an expanded widget, and the owner of three things: how it draws, what it sends, and how it recognises its own echo. +A control may drive more than one axis, which is what separates it from an axis and is recorded in `docs/adr/0004-controls-and-axes.md`. +_Avoid_: slider, input, field + **Armed**: A widget with confirmation enabled that has taken its first tap and is awaiting a second. Being armed has a bounded life: it ends on confirmation, on cancellation, on a timeout, or when the pointer leaves the widget. diff --git a/Cargo.lock b/Cargo.lock index be85aed..4d35a73 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -142,6 +142,16 @@ dependencies = [ "libloading", ] +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -456,6 +466,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -985,6 +1001,24 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + [[package]] name = "der" version = "0.7.10" @@ -1833,6 +1867,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hybrid-array" version = "0.4.11" @@ -1856,6 +1896,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -1937,6 +1978,7 @@ dependencies = [ "iced_runtime", "iced_widget", "iced_winit", + "image", "thiserror 2.0.18", ] @@ -1992,6 +2034,8 @@ dependencies = [ "half", "iced_core", "iced_futures", + "image", + "kamadak-exif", "log", "raw-window-handle", "rustc-hash 2.1.2", @@ -2215,6 +2259,18 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -2385,6 +2441,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kamadak-exif" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1130d80c7374efad55a117d715a3af9368f0fa7a2c54573afc15a188cd984837" +dependencies = [ + "mutate_once", +] + [[package]] name = "keyring" version = "3.6.3" @@ -2627,6 +2692,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "mundy" version = "0.2.2" @@ -2652,6 +2727,12 @@ dependencies = [ "zbus 5.14.0", ] +[[package]] +name = "mutate_once" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" + [[package]] name = "naga" version = "27.0.3" @@ -2831,6 +2912,16 @@ dependencies = [ "libm", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "num_enum" version = "0.7.6" @@ -3615,6 +3706,12 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + [[package]] name = "quick-xml" version = "0.38.4" @@ -4601,6 +4698,7 @@ dependencies = [ "tracing-appender", "tracing-subscriber", "url", + "wiremock", "zip 8.6.0", ] @@ -6472,6 +6570,29 @@ dependencies = [ "memchr", ] +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index a7aea6b..8c88618 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,11 +34,22 @@ diagnostics = [] anyhow = "1.0.102" auto-launch = "0.6.0" chrono = { version = "0.4.44", default-features = false, features = ["clock"] } +# `image-without-codecs` rather than `image`: we only ever hand iced a +# buffer of RGBA bytes we computed ourselves (see `ui::colour_texture`), +# so none of the `image` crate's format decoders are wanted. The plain +# `image` feature turns them all on and drags PNG, JPEG, GIF and the +# rest into the binary for nothing. +# `advanced` for `iced::advanced::Widget`: the colour surface is a +# widget of our own, because a slider cannot express two axes and a +# `mouse_area` cannot say *where* inside itself it was pressed. See +# `ui::colour_field`. iced = { version = "0.14.0", features = [ "wgpu", "tokio", "markdown", "sysinfo", + "image-without-codecs", + "advanced", ] } iced_winit = { version = "0.14.0", features = ["sysinfo"] } directories = "6.0.0" @@ -97,6 +108,7 @@ keyring = { version = "3.6.3", features = [ [dev-dependencies] tempfile = "3.27.0" +wiremock = "0.6.5" [patch.crates-io] iced = { git = "https://github.com/schizza/iced.git", tag = "snapdash-v0.14.0-p10" } diff --git a/README.md b/README.md index 44cdf7a..9ab2046 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,7 @@ If the token is compromised: delete it in HA, generate a new one, paste it into | --- | --- | | `sensor.*`, `binary_sensor.*` | Read-only — value, unit and last-changed detail | | `switch.*` | Read + tap to **toggle** | -| `light.*` | Read + tap to **toggle** (on/off only — no brightness or color yet) | +| `light.*` | Read + tap to **toggle**, expand to set brightness, white temperature and colour | | `input_boolean.*` | Read + tap to **toggle** | | `scene.*` | Tap to **activate** the scene | | `script.*` | Tap to **run** the script | @@ -201,6 +201,7 @@ Widgets are frameless — the controls appear on hover: | Control | Position | Does | | --- | --- | --- | | **Action button** | top-right | Triggers the entity's action (actionable entities only, while connected) | +| **Chevron** | top-right | Expands the widget to reveal its continuous controls (entities that have any, while connected) | | **Update icon** | top-right | Shown when a new Snapdash release is available — opens the release notes | | **Sliders** | right edge | Opens this widget's own settings dialog | | **Priority dots** | bottom-left | Quick Low / Normal / High switch | @@ -208,6 +209,35 @@ Widgets are frameless — the controls appear on hover: Dragging anywhere on the card moves the widget; the position is persisted. +### Adjusting a value + +Tap the chevron and the card grows downwards to reveal a control for everything the entity has to set: a slider for a light's brightness, another for its white temperature, and a colour field. +Tap it again to put them away. +The controls belong to the widget rather than to a window of their own, so they cannot drift away from the value they set, and each one calls Home Assistant as you drag. + +The colour field is two-dimensional - hue runs left to right, saturation top to bottom - and one drag sets both at once, as a single `hs_color`. +That is a lot of colour in a small space: at the Small preset the field is 132 points wide and covers all 359 degrees of hue, so one point of movement is worth nearly three degrees. +Three shortcuts answer that, and none of them is a mode you can get stuck in. + +| Hold | Does | +| --- | --- | +| **Shift** | Holds whichever axis has moved less since you pressed, so you can sweep the hue without disturbing the saturation | +| **Alt** (Option on macOS) | Keeps a quarter of the movement, measured from where you pressed, so the fine adjustment carries on from where the coarse one had got to | +| **Wheel** | Nudges the hue one step at a time, and the saturation with Shift held | + +Letting go of the key is the whole of undoing it. +Nothing is remembered between one frame and the next, so a modifier cannot get stuck and a released one stops applying immediately. + +The two ends of the saturation axis pull the last few points onto exactly 0% and exactly 100%, so white and full colour are not one-pixel targets. +Hue has no such magnet, because 0 and 359 are the same red and there is nothing at either end worth snapping to. + +The `?` beside the colour readout names the three shortcuts on hover. +It appears there and nowhere else: the sliders are ordinary sliders, and Shift, Alt and the wheel do nothing on them. + +While the controls are up, the hover chrome is gone - not dimmed, absent. +Every pixel the card grew by is a control, and a priority dot sitting across a slider is one you cannot drag. +Collapse the widget to get the chrome back. + ## Settings The Settings window is split into pages: diff --git a/docs/adr/0004-controls-and-axes.md b/docs/adr/0004-controls-and-axes.md new file mode 100644 index 0000000..8bdfd46 --- /dev/null +++ b/docs/adr/0004-controls-and-axes.md @@ -0,0 +1,69 @@ +# A Control may drive several Axes, and owns drawing, sending and echo recognition + +An **Axis** is one numeric dimension of an entity: it owns a range, the value Home Assistant reports for it, and one scalar pending value. +A **Control** is the thing the user grabs. +It owns three things: how it draws, what it puts on the wire, and how it recognises its own echo. + +A Control may drive more than one Axis. +An Axis need not have a Control of its own. + +## Why they were one thing, and why they stop being one + +Until the colour surface arrived, every control drove exactly one axis. +Brightness, white colour temperature, a thermostat setpoint and a cover position are all a label, a range and a slider, so a single type carrying all of it was not wrong, it was just not yet distinguishable from the alternative. + +A colour surface is one control that sets hue and saturation with one gesture and one `light.turn_on` call. +Hue is a numeric dimension and saturation is another, exactly as `CONTEXT.md` defines an Axis, so the answer is not "an axis that carries two numbers". +The answer is that the control and the axis were never the same concept, and the code had simply never had to tell them apart. + +## Why an enum rather than a grouping marker + +`Control` is an enum whose variants name their shape, rather than a flat list of axes with a marker saying which of them belong together. + +A marker permits a hue with no saturation beside it, or an orphan hue rendered in a slider of its own, and nothing in the type system would catch either. +`AxisKind::Hue` and `AxisKind::Saturation` exist only inside `Control::Color`, which makes the invalid arrangements unrepresentable rather than merely undocumented. + +## Why the Control owns echo recognition + +`0002-pending-values-and-settle-window.md` records that echoes are matched by value, because `state_changed` carries no correlation id. +How much slack that comparison needs is not a property of the machinery doing the comparing. +It is a property of what the value passes through on the way back. + +Colour temperature round-trips through mireds and comes back up to 19 K away from what was sent. +A colour round-trips through 8-bit RGB and comes back up to 19.9 degrees of hue away at low saturation, because at saturation 1 hue is barely determined in 8 bits at all. +Brightness and cover position are stored in the units we send and come back untouched. + +So the colour control does not compare hue and saturation numerically at all. +It converts both the sent and the echoed colour to 8-bit RGB and compares those, which is comparing what the user actually sees rather than the coordinates the colour happens to be written in. +That dissolves the low-saturation ambiguity for free, and it removes any need for hue wraparound handling, since hue 0 and hue 360 produce identical RGB. + +The general rule is what matters here rather than the particular numbers: the tolerance belongs to the Control, alongside sending and drawing. + +## What comparing in RGB does not fix + +A light whose native mode is `xy`, which includes Philips Hue bulbs, does not lose colour to quantisation. +It loses it to a gamut conversion, and that is a different kind of loss. +Measured over the whole wheel at full saturation, roughly a third of it comes back more than half a degree out, worst case 2.7 degrees at hue 242. +At full saturation 2.7 degrees of hue is about eleven levels of an 8-bit channel, so an RGB comparison tight enough to reject a colour the user did not pick will not confirm it either. + +Those lights therefore reconcile on the settle window rather than on the echo, which is the path `0002-pending-values-and-settle-window.md` describes as the failure path. +That is a known and accepted limitation rather than an oversight: the alternative is a tolerance wide enough to confirm a visibly different colour, which is worse, because it would leave the widget claiming a colour the house is not showing. +Closing it properly means comparing against what the device reports it is actually doing rather than against what it was told to do, and that is a larger change than a tolerance. + +## Consequences + +The expanded widget renders by iterating controls, and its height is the sum of per-control heights rather than one row height multiplied by a count. +Controls are not all the same height: a colour surface is a field, not a slider. + +`PendingValues` takes a batch of axes per gesture rather than one axis per call. +A gesture that moves two axes must consume the entity's throttle window once, not twice; the second call would otherwise find the window spent, record what is displayed, and silently never record what was sent, leaving that axis unable to ever recognise its echo. +That is recorded in the module documentation of `src/app/pending.rs`, and the batch is the only way in so that a scalar path and a batch path cannot drift apart. + +The throttle stays keyed per entity at 200 ms. +Adding axes must not move the peak send rate, which is the invariant the arithmetic in `0003-service-calls-stay-on-rest.md` rests on. + +Asking a Control to recognise its echo means asking about three states per axis and not two. +A per-entity throttle plus more than one Control per entity makes "held by the user with nothing yet on the wire" reachable: grabbing a second control inside the window the first one spent records what is shown and nothing sent. +That axis has no last-sent value, exactly like an axis nobody is touching, and the two need opposite verdicts. +An untouched axis is vacuously confirmed, because there is nothing for the echo to disagree with; a held one must never be, because releasing it would drop a gesture in progress and the value the user finally chose would never be sent at all. +So `Control::reconciles` is asked for `Outstanding`, whose three variants make the distinction one the type carries rather than one every caller has to remember. diff --git a/docs/adr/0005-the-colour-field-is-a-texture.md b/docs/adr/0005-the-colour-field-is-a-texture.md new file mode 100644 index 0000000..8a353b5 --- /dev/null +++ b/docs/adr/0005-the-colour-field-is-a-texture.md @@ -0,0 +1,69 @@ +# The colour field is a texture, not a gradient + +The colour field a user drags a marker across is painted from an RGBA buffer computed in Rust, once per process, at a fixed 256x128. +It is drawn scaled to whatever the widget's size preset gives it. +It is not two crossed gradients, and it is not a `wgpu` shader. + +The obvious implementation is the gradient one, so this records why it was rejected before anyone reaches for it again. + +## Why not a gradient + +iced's gradients interpolate between stops with `smoothstep`, not linearly, in both the quad and the triangle shader. +`smoothstep(t) = 3t^2 - 2t^3` departs from `t` by up to 9.6% of the segment it spans, at t = 0.211 and its mirror at t = 0.789. + +On the saturation axis that is a ten-point error. +At t = 0.211 the shader emits 0.115, so the field shows 11.5% saturation exactly where the marker claims 21%. + +On the hue axis it is worse in character if not in magnitude. +The natural stops for a hue ramp are the six vertices of the colour wheel plus the end of the range, which is seven stops and six segments of about 60 degrees, and 9.6% of 60 degrees is about 5.8 degrees. +The error is not spread evenly either: it lands as wide plateaus of pure red, yellow and green sitting on the stops, with the transitions between them compressed. +Subdividing is not a way out, because `iced::gradient::Linear` holds at most eight stops in total. + +A brightness slider whose fill is a few percent off is merely ugly. +A colour picker whose marker sits on a different colour from the one it names is wrong, because naming the colour under the marker is the whole job of the widget. +That is the difference between a gradient degrading this control and a gradient disqualifying itself from it. + +We did consider approximating our way out by inverting `smoothstep` at each stop so the pre-distorted offsets come back straight. +It amounts to fighting the shader with stop placement, it still has only eight stops to work with, and it leaves the field's accuracy resting on a shader detail we do not control and iced does not promise. + +## Why not a wgpu shader + +A custom shader primitive would be exact, and it would be exact only on `wgpu`. + +iced's default features include the `tiny-skia` software renderer, and that is what runs when there is no usable adapter: a remote desktop session, a virtual machine, an old GPU, a driver that fails to initialise. +On those machines a shader primitive draws nothing at all. +Snapdash is made entirely of these widgets, so "the colour picker is invisible on some machines" is not a hole we can leave open in exchange for avoiding 128 KiB of pixels. + +## What the texture is + +Hue runs across, from 0 at the left edge to 359 at the right. +It stops one degree short of 360 rather than wrapping, because 360 is the same red the left edge already shows and the axis is easier to reason about as a plain range than as a circle. + +Saturation runs down, from 0 at the top row to 100 at the bottom, so the top edge is white and the bottom edge is the pure hues. + +Value is pinned to 100 across the whole field. +Brightness is a separate axis with its own control, and a `light.turn_on` that omits `brightness` leaves the lamp where the user put it. +A field that faded to black down one edge would be advertising an axis it does not set. + +256x128 is 128 KiB of RGBA, computed once and shared by every widget and every size preset through one image handle. +The renderer keys its upload on that handle, so pinning twenty lights costs one texture, not twenty. + +## Consequences + +The crate gains one iced feature, `image-without-codecs`, which enables the image widget path and pulls in the `image` crate with `default-features = false`. +No format decoders are compiled in, because nothing is ever decoded: we hand iced bytes we computed ourselves. +If anyone later needs to display an actual PNG, that is the moment to weigh the decoders, not now. + +The field is quantised to 256 hues and 128 saturations rather than being continuous. +The residue is bounded and small: half a column is 0.70 degrees of hue, which inside a 60-degree sector is worth about three of 255 levels. +That is two orders of magnitude better than the gradient it replaces, and it is a number that can be improved by making the texture bigger if it ever matters. + +The marker's position and the texture's geometry are now two statements of the same mapping, and they have to agree. +Anything that changes the axes has to change both, which is why the extents live as named constants in `ui::colour_texture` rather than as literals at the drawing site. + +Scaling is the renderer's, so the field is resampled from 256x128 to whatever the widget is. +In practice that is always downwards - the widest preset is 212 points across - and the two shapes are both 2:1, so it is a uniform scale rather than a stretch. +That is fine for a colour field, where neighbouring pixels are near-identical by construction, and it is the reason the texture can be small enough to keep. + +The 2:1 is therefore a shared invariant rather than a coincidence, held in `ui::colour_texture`'s extents at one end and `WidgetSize::colour_field_size` at the other, and asserted across the presets in `widget_size`'s tests. +Breaking it would not fail to draw; it would quietly stop putting the colour the marker names underneath the marker. diff --git a/docs/adr/0006-a-null-axis-renders-as-absent.md b/docs/adr/0006-a-null-axis-renders-as-absent.md new file mode 100644 index 0000000..fdde628 --- /dev/null +++ b/docs/adr/0006-a-null-axis-renders-as-absent.md @@ -0,0 +1,35 @@ +# An axis Home Assistant reports as null renders as absent, not as minimum + +Home Assistant nulls an axis the device is not currently driving. +A light that is off nulls `brightness`, `color_temp_kelvin` and every colour attribute at once; a light in some other colour mode nulls `color_temp_kelvin` on its own. + +When that happens the control renders as *absent*: the block drops to around 40% opacity, the readout shows no number, and the slider knob is not drawn at all. +The control stays fully operable, and touching it is how the axis gets a value again. + +## Why not fall back to the axis minimum + +That is what shipped first, and it is a lie with no tell. +A knob parked hard left over a "0%" readout is the same picture a light genuinely dimmed to zero produces, so the user has nothing to tell "off" from "on but dark" with. +"There is no brightness" and "the brightness is zero" are different claims, and only one of them is true. + +## Why not remember the last non-null value + +It reintroduces the same problem one step removed. +Showing 4000 K on a light currently glowing blue is a statement about the past presented as a statement about the present, with nothing in the widget marking it as history. +A dashboard's whole job is to be the state of the house right now. + +## Why a missing knob rather than a differently-styled one + +The knob is the mark that asserts a position on the rail. +Any knob that is drawn is somewhere, and wherever it is drawn is a value the user will read off it. +Removing it is the only rendering that makes no claim at all. + +`iced::widget::slider` can hide its knob by giving the handle a transparent background, so the slider keeps its geometry and its hit area, and only the mark goes away. +No custom widget is needed. + +## Consequences + +Value resolution answers `Option` rather than a number, and every control surface has to decide what absent looks like for it. +A colour surface inherits the same rule: its marker is not drawn either. + +The pending value still wins while the user drives the axis, so an axis with no Home Assistant value shows what the user is setting the moment they grab it, and goes back to absent if the send never lands. diff --git a/docs/agents/branching.md b/docs/agents/branching.md new file mode 100644 index 0000000..c8a4d0d --- /dev/null +++ b/docs/agents/branching.md @@ -0,0 +1,30 @@ +# Branching + +Everything reaches `main` through `dev`. +`main` is the release branch and only ever moves when a release is cut. + +## The rule + +- Branch from `dev`, never from `main`. +- Every PR targets `dev`, whatever it carries: feature, fix, refactor, chore, docs, dependency bump. +- `main` receives exactly one kind of PR, the release PR that promotes `dev` once the work accumulated there has been tested and is stable. + +So a change lands twice: once into `dev` when it is written, and again into `main` when it ships. +That second hop is what a version number means in this repo. + +## Consequences worth knowing + +`main` lags `dev`, sometimes by several features. +Reading `main` to find out what the code does now is wrong; read `dev`. + +A branch cut from `main` will be missing whatever is sitting in `dev`, and rebasing it onto `dev` afterwards is a merge conflict you did not need to have. +Check `git log --oneline main..dev` before branching if you are unsure how far apart they are. + +Local `origin/*` refs go stale if `git fetch` is failing. +`git fetch` runs over SSH and will refuse when the key is locked, while the `gh` CLI keeps working over its token, so the tracker can look current while the branches do not. +`git ls-remote https://github.com/schizza/snapdash.git refs/heads/dev refs/heads/main` answers the question without needing the key. + +## Where the base branch is set + +`gh pr create --base dev`. +GitHub's repository default branch is `main`, so the base is not inferred correctly and has to be passed. diff --git a/src/app/pending.rs b/src/app/pending.rs index 34b149a..d8e6576 100644 --- a/src/app/pending.rs +++ b/src/app/pending.rs @@ -1,5 +1,5 @@ //! Pending values: the local override that wins over Home Assistant -//! truth while the user is driving a continuous control. +//! truth while the user is driving a control. //! //! Sends are throttled during a drag, so HA keeps broadcasting //! `state_changed` with values that lag the user's finger. Binding a @@ -16,6 +16,18 @@ //! authoritative forever and the widget would quietly lie about the //! state of the house. //! +//! How an echo is recognised is not this module's business at all. A +//! value that passes through a unit conversion on the way back does not +//! return the number we sent, and how much it loses is a fact about that +//! conversion, not about floats - and for a colour it is not even a +//! per-axis fact, because confirming a colour means comparing two +//! colours rather than two pairs of coordinates. So an echo arrives here +//! as the [`Control`]s Home Assistant just reported, and each control is +//! asked whether it recognises its own ([`Control::reconciles`]). A +//! control that says yes releases every axis it drives; one that says no +//! releases none of them, because half a confirmed colour is not a +//! thing. Recorded in `docs/adr/0004-controls-and-axes.md`. +//! //! State is kept **per axis**, so brightness and colour temperature //! reconcile independently and neither overwrites the other. Throttling //! is kept **per entity**, so a light with two sliders still peaks at @@ -23,12 +35,21 @@ //! the arithmetic in `docs/adr/0003-service-calls-stay-on-rest.md` valid //! as axes are added. //! +//! Because of that split, the unit both [`PendingValues::set`] and +//! [`PendingValues::release`] take is the **gesture**: a batch of axes, +//! never a single one. One gesture spends one throttle window, so the +//! window has to be consulted once for everything it moved. A per-axis +//! entry point would have let a caller spend it on the first axis and +//! silently record nothing sent for the rest, which is a bug the type +//! system could not catch and only shows up as an axis that never +//! reconciles. There is one way in so the two cannot drift apart. +//! //! Recorded in `docs/adr/0002-pending-values-and-settle-window.md`. use std::collections::HashMap; use std::time::{Duration, Instant}; -use crate::ha::ContinuousKind; +use crate::ha::{AxisKind, Control, Outstanding}; /// Minimum gap between service calls for one entity while it is being /// driven. Caps the send rate at ~5/sec, which is what keeps REST viable @@ -39,11 +60,6 @@ pub const SEND_INTERVAL: Duration = Duration::from_millis(200); /// matching echo before HA truth is allowed to win again. pub const SETTLE_TIMEOUT: Duration = Duration::from_secs(2); -/// Echoes are compared to the last sent value with a tolerance, because -/// a setpoint round-trips through HA as a float and may come back with -/// a different representation than we sent. -const EPSILON: f32 = 0.01; - /// One axis's in-flight interaction. #[derive(Debug, Clone)] struct Pending { @@ -73,13 +89,6 @@ impl Pending { self.settle_deadline = None; } - /// `true` when this echo is the one we were waiting for, meaning the - /// axis can go back to being driven by HA. - fn reconciles(&self, echoed: f32) -> bool { - self.last_sent - .is_some_and(|sent| (sent - echoed).abs() <= EPSILON) - } - /// `true` once the settle window has run out. Never true while the /// user still holds the control. fn expired(&self, now: Instant) -> bool { @@ -87,7 +96,7 @@ impl Pending { } } -type AxisKey = (String, ContinuousKind); +type AxisKey = (String, AxisKind); /// All in-flight interactions. #[derive(Debug, Default)] @@ -102,7 +111,7 @@ pub struct PendingValues { impl PendingValues { /// The locally-held value for one axis, if it currently has one. /// Callers render this in preference to the HA state. - pub fn shown(&self, entity_id: &str, kind: ContinuousKind) -> Option { + pub fn shown(&self, entity_id: &str, kind: AxisKind) -> Option { self.axes .get(&(entity_id.to_owned(), kind)) .map(|pending| pending.shown) @@ -120,65 +129,126 @@ impl PendingValues { } } - /// Record a new value for one axis. Returns `Some(value)` when a send - /// is due now, `None` when the entity's throttle window swallows it. + /// Record the new values one gesture produced, and say whether they + /// are due to go out now. `false` means the entity's throttle window + /// swallowed this batch. /// - /// A swallowed value is not lost: it stays in `shown`, and - /// [`Self::release`] always flushes the final one. - pub fn set( - &mut self, - entity_id: &str, - kind: ContinuousKind, - value: f32, - now: Instant, - ) -> Option { + /// Nothing is returned but the verdict, because the caller is holding + /// the values it just passed in. + /// + /// A swallowed batch is not lost: every value stays in `shown`, and + /// [`Self::release`] flushes the final ones. + pub fn set(&mut self, entity_id: &str, updates: &[(AxisKind, f32)], now: Instant) -> bool { + if updates.is_empty() { + return false; + } + + // Asked once, for the batch. The throttle window is spent by the + // gesture and not by the axis, so asking per axis would let the + // first one spend it and leave every other one recording nothing + // but `shown`. Those axes would have no `last_sent` to recognise + // their echo against, would run to the settle timeout on every + // interaction, and - since an entity only goes back to being + // HA-driven when none of its axes are pending - would hold their + // siblings there too. let due = self.send_due(entity_id, now); - let pending = self - .axes - .entry((entity_id.to_owned(), kind)) - .or_insert_with(|| Pending::new(value)); - pending.update(value); + for (kind, value) in updates { + let pending = self + .axes + .entry((entity_id.to_owned(), *kind)) + .or_insert_with(|| Pending::new(*value)); + pending.update(*value); - if !due { - return None; + if due { + pending.last_sent = Some(*value); + } } - pending.last_sent = Some(value); - self.last_send_at.insert(entity_id.to_owned(), now); - Some(value) + if due { + self.last_send_at.insert(entity_id.to_owned(), now); + } + + due } - /// The user released the control. Always returns the final value to - /// send, so an interaction never ends on a throttled-away - /// intermediate, and starts the settle window. - pub fn release(&mut self, entity_id: &str, kind: ContinuousKind, now: Instant) -> Option { - let pending = self.axes.get_mut(&(entity_id.to_owned(), kind))?; - let value = pending.shown; + /// The user let go of the control. Always returns the final value of + /// every axis it was driving, so an interaction never ends on a + /// throttled-away intermediate, and starts the settle window on each. + /// + /// The values have to come back, unlike in [`Self::set`]: the last + /// one the caller saw may well have been swallowed by the throttle, + /// so what the control is showing is not the caller's to know. + /// + /// `None` when this entity has nothing pending on any of these axes, + /// which is the case for a release that follows a reconciled echo. + pub fn release( + &mut self, + entity_id: &str, + axes: &[AxisKind], + now: Instant, + ) -> Option> { + let mut flushed = Vec::with_capacity(axes.len()); - pending.last_sent = Some(value); - pending.settle_deadline = Some(now + SETTLE_TIMEOUT); - self.last_send_at.insert(entity_id.to_owned(), now); + for kind in axes { + let Some(pending) = self.axes.get_mut(&(entity_id.to_owned(), *kind)) else { + continue; + }; + let value = pending.shown; + + pending.last_sent = Some(value); + pending.settle_deadline = Some(now + SETTLE_TIMEOUT); + flushed.push((*kind, value)); + } + + if flushed.is_empty() { + return None; + } - Some(value) + self.last_send_at.insert(entity_id.to_owned(), now); + Some(flushed) } - /// Feed in the values an entity's `state_changed` carries, one per - /// axis it reports. + /// Feed in an entity's `state_changed`, as the controls it reports. + /// + /// The echo arrives as controls rather than as loose axis/value pairs + /// because recognising an echo is the control's own job, and for a + /// colour it is a judgement over both of its axes at once. Each + /// control carries the echoed value of every axis it drives, in + /// [`Axis::current`], so it has everything it needs to answer. + /// + /// A control that recognises its echo releases every axis it drives; + /// one that does not releases none of them. /// /// Returns `true` when the entity is HA-driven again, meaning no axis /// of it is still waiting. `false` means the caller must keep /// rendering the pending values and ignore this echo, because - /// applying it would drag a slider back under the user's finger. - pub fn reconcile(&mut self, entity_id: &str, echoed: &[(ContinuousKind, Option)]) -> bool { - for (kind, value) in echoed { - let key = (entity_id.to_owned(), *kind); - let Some(pending) = self.axes.get(&key) else { - continue; - }; - - if value.is_some_and(|value| pending.reconciles(value)) { - self.axes.remove(&key); + /// applying it would drag a control back under the user's finger. + /// + /// [`Axis::current`]: crate::ha::Axis::current + pub fn reconcile(&mut self, entity_id: &str, echoed: &[Control]) -> bool { + for control in echoed { + let axes = &self.axes; + let confirmed = control.reconciles(|kind| { + // Three states, not two. An axis whose sends the entity's + // throttle has so far swallowed is holding a gesture in + // progress with nothing on the wire, and answering `None` + // for it - as for an axis with no interaction at all - + // would have this echo vacuously confirm it and release + // the value out from under the user's finger. + match axes.get(&(entity_id.to_owned(), kind)) { + None => Outstanding::Nothing, + Some(pending) => match pending.last_sent { + None => Outstanding::Unsent, + Some(sent) => Outstanding::Sent(sent), + }, + } + }); + + if confirmed { + for axis in control.axes() { + self.axes.remove(&(entity_id.to_owned(), axis.kind)); + } } } @@ -238,17 +308,47 @@ impl PendingValues { mod tests { use super::*; - const BRIGHTNESS: ContinuousKind = ContinuousKind::Brightness; - const TEMP: ContinuousKind = ContinuousKind::ColorTemp; + use crate::ha::Axis; + + const BRIGHTNESS: AxisKind = AxisKind::Brightness; + const TEMP: AxisKind = AxisKind::ColorTemp; + const HUE: AxisKind = AxisKind::Hue; + const SATURATION: AxisKind = AxisKind::Saturation; fn t0() -> Instant { Instant::now() } + /// An axis carrying an echoed value. The range is filler: recognising + /// an echo consults the kind and the value and nothing else. + fn axis(kind: AxisKind, echoed: Option) -> Axis { + Axis { + kind, + min: 0.0, + max: 255.0, + step: 1.0, + current: echoed, + } + } + + /// One scalar control as Home Assistant has just reported it. + fn echo(kind: AxisKind, value: Option) -> Control { + Control::Value(axis(kind, value)) + } + + /// A colour surface as Home Assistant has just reported it. + fn colour_echo(hue: Option, saturation: Option) -> Control { + Control::Color { + hue: axis(HUE, hue), + saturation: axis(SATURATION, saturation), + } + } + #[test] fn first_value_sends_immediately() { let mut p = PendingValues::default(); - assert_eq!(p.set("light.a", BRIGHTNESS, 100.0, t0()), Some(100.0)); + assert!(p.set("light.a", &[(BRIGHTNESS, 100.0)], t0())); + assert_eq!(p.shown("light.a", BRIGHTNESS), Some(100.0)); } #[test] @@ -256,34 +356,24 @@ mod tests { let now = t0(); let mut p = PendingValues::default(); - assert_eq!(p.set("light.a", BRIGHTNESS, 100.0, now), Some(100.0)); + assert!(p.set("light.a", &[(BRIGHTNESS, 100.0)], now)); // Still inside SEND_INTERVAL, so no wire traffic... - assert_eq!( - p.set( - "light.a", - BRIGHTNESS, - 120.0, - now + Duration::from_millis(50) - ), - None - ); - assert_eq!( - p.set( - "light.a", - BRIGHTNESS, - 140.0, - now + Duration::from_millis(100) - ), - None - ); + assert!(!p.set( + "light.a", + &[(BRIGHTNESS, 120.0)], + now + Duration::from_millis(50) + )); + assert!(!p.set( + "light.a", + &[(BRIGHTNESS, 140.0)], + now + Duration::from_millis(100) + )); // ...but the value is not lost, the control still shows it. assert_eq!(p.shown("light.a", BRIGHTNESS), Some(140.0)); // Past the window, the next move goes out. - assert_eq!( - p.set("light.a", BRIGHTNESS, 160.0, now + SEND_INTERVAL), - Some(160.0) - ); + assert!(p.set("light.a", &[(BRIGHTNESS, 160.0)], now + SEND_INTERVAL)); + assert_eq!(p.shown("light.a", BRIGHTNESS), Some(160.0)); } /// The throttle is per entity, not per axis. Two sliders on one light @@ -294,42 +384,140 @@ mod tests { let now = t0(); let mut p = PendingValues::default(); - assert_eq!(p.set("light.a", BRIGHTNESS, 100.0, now), Some(100.0)); + assert!(p.set("light.a", &[(BRIGHTNESS, 100.0)], now)); // A different axis of the same light, immediately after. - assert_eq!( - p.set("light.a", TEMP, 3000.0, now + Duration::from_millis(10)), - None, + assert!( + !p.set( + "light.a", + &[(TEMP, 3000.0)], + now + Duration::from_millis(10) + ), "shares the entity's window" ); - // A different entity is unaffected. - assert_eq!( - p.set("light.b", BRIGHTNESS, 50.0, now + Duration::from_millis(10)), - Some(50.0) + // And so does every axis added since: the window belongs to the + // light, so a bulb with a colour control does not send faster + // than one without. + assert!( + !p.set("light.a", &[(HUE, 200.0)], now + Duration::from_millis(20)), + "colour shares the same window as brightness" ); + // A different entity is unaffected. + assert!(p.set( + "light.b", + &[(BRIGHTNESS, 50.0)], + now + Duration::from_millis(10) + )); } + /// The bug this batch API exists to make unrepresentable. One gesture + /// spends one throttle window, so every axis it moved has to come out + /// of it able to recognise its own echo. Set once per axis instead and + /// the second axis records only what it shows: it never reconciles, + /// runs to the settle timeout every time, and holds the whole entity + /// - the first axis included - there with it. #[test] - fn release_always_flushes_the_final_value() { + fn a_batch_that_goes_out_records_the_sent_value_on_every_axis() { let now = t0(); let mut p = PendingValues::default(); - p.set("light.a", BRIGHTNESS, 100.0, now); - // Throttled away. - assert_eq!( - p.set( + assert!(p.set("light.a", &[(BRIGHTNESS, 100.0), (TEMP, 3000.0)], now)); + + assert!( + p.reconcile( "light.a", - BRIGHTNESS, - 250.0, - now + Duration::from_millis(10) + &[echo(BRIGHTNESS, Some(100.0)), echo(TEMP, Some(3000.0))] ), - None + "both axes must recognise the echo of what the batch sent" + ); + assert!(p.is_empty()); + } + + /// A batch the window swallows is still what the controls show, all + /// of it: the axes of one gesture cannot be allowed to disagree about + /// where the user's finger is. + #[test] + fn a_throttled_batch_still_records_every_axis_as_shown() { + let now = t0(); + let mut p = PendingValues::default(); + + assert!(p.set("light.a", &[(BRIGHTNESS, 100.0), (TEMP, 3000.0)], now)); + assert!(!p.set( + "light.a", + &[(BRIGHTNESS, 120.0), (TEMP, 4000.0)], + now + Duration::from_millis(50) + )); + + assert_eq!(p.shown("light.a", BRIGHTNESS), Some(120.0)); + assert_eq!(p.shown("light.a", TEMP), Some(4000.0)); + } + + /// The window is consumed by the gesture, not by the axis. A batch of + /// two must leave the entity exactly as throttled as a batch of one, + /// or the peak call rate would grow with every axis a control gains + /// and ADR-0003's arithmetic would stop holding. + #[test] + fn a_batch_consults_the_throttle_once_however_many_axes_it_moves() { + let now = t0(); + let mut p = PendingValues::default(); + + assert!(p.set("light.a", &[(BRIGHTNESS, 100.0), (TEMP, 3000.0)], now)); + + assert!( + !p.send_due("light.a", now + SEND_INTERVAL - Duration::from_millis(1)), + "one batch, one window" ); + assert!(p.send_due("light.a", now + SEND_INTERVAL)); + } + + #[test] + fn release_always_flushes_the_final_value() { + let now = t0(); + let mut p = PendingValues::default(); + + p.set("light.a", &[(BRIGHTNESS, 100.0)], now); + // Throttled away. + assert!(!p.set( + "light.a", + &[(BRIGHTNESS, 250.0)], + now + Duration::from_millis(10) + )); // Release must still put 250 on the wire, or the light ends up // sitting at a value the user scrubbed past. assert_eq!( - p.release("light.a", BRIGHTNESS, now + Duration::from_millis(20)), - Some(250.0) + p.release("light.a", &[BRIGHTNESS], now + Duration::from_millis(20)), + Some(vec![(BRIGHTNESS, 250.0)]) + ); + } + + /// Release answers for the whole gesture, in the order it was asked, + /// so the caller can put every axis of it on the wire. + #[test] + fn release_flushes_every_axis_of_the_gesture() { + let now = t0(); + let mut p = PendingValues::default(); + + p.set("light.a", &[(BRIGHTNESS, 100.0), (TEMP, 3000.0)], now); + + assert_eq!( + p.release("light.a", &[BRIGHTNESS, TEMP], now), + Some(vec![(BRIGHTNESS, 100.0), (TEMP, 3000.0)]) + ); + } + + /// An axis with nothing pending has nothing to flush, and must not + /// invent a value for one that has. + #[test] + fn release_skips_axes_that_are_not_pending() { + let now = t0(); + let mut p = PendingValues::default(); + + p.set("light.a", &[(BRIGHTNESS, 100.0)], now); + + assert_eq!( + p.release("light.a", &[BRIGHTNESS, TEMP], now), + Some(vec![(BRIGHTNESS, 100.0)]) ); + assert_eq!(p.release("light.a", &[TEMP], now), None); } #[test] @@ -337,15 +525,15 @@ mod tests { let now = t0(); let mut p = PendingValues::default(); - p.set("light.a", BRIGHTNESS, 100.0, now); + p.set("light.a", &[(BRIGHTNESS, 100.0)], now); assert!( - !p.reconcile("light.a", &[(BRIGHTNESS, Some(40.0))]), + !p.reconcile("light.a", &[echo(BRIGHTNESS, Some(40.0))]), "stale echo ignored" ); assert_eq!(p.shown("light.a", BRIGHTNESS), Some(100.0)); assert!( - p.reconcile("light.a", &[(BRIGHTNESS, Some(100.0))]), + p.reconcile("light.a", &[echo(BRIGHTNESS, Some(100.0))]), "matching echo resolves" ); assert_eq!(p.shown("light.a", BRIGHTNESS), None); @@ -359,19 +547,19 @@ mod tests { let now = t0(); let mut p = PendingValues::default(); - p.set("light.a", BRIGHTNESS, 100.0, now); - p.set("light.a", TEMP, 3000.0, now + SEND_INTERVAL); + p.set("light.a", &[(BRIGHTNESS, 100.0)], now); + p.set("light.a", &[(TEMP, 3000.0)], now + SEND_INTERVAL); let resolved = p.reconcile( "light.a", - &[(BRIGHTNESS, Some(100.0)), (TEMP, Some(2500.0))], + &[echo(BRIGHTNESS, Some(100.0)), echo(TEMP, Some(2500.0))], ); assert!(!resolved, "colour temperature has not come back yet"); assert_eq!(p.shown("light.a", BRIGHTNESS), None, "brightness resolved"); assert_eq!(p.shown("light.a", TEMP), Some(3000.0), "still held"); - assert!(p.reconcile("light.a", &[(TEMP, Some(3000.0))])); + assert!(p.reconcile("light.a", &[echo(TEMP, Some(3000.0))])); assert!(p.is_empty()); } @@ -385,27 +573,24 @@ mod tests { let now = t0(); let mut p = PendingValues::default(); - assert_eq!(p.set("light.a", BRIGHTNESS, 100.0, now), Some(100.0)); + assert!(p.set("light.a", &[(BRIGHTNESS, 100.0)], now)); // HA confirms almost immediately, so the axis is no longer waiting. - assert!(p.reconcile("light.a", &[(BRIGHTNESS, Some(100.0))])); + assert!(p.reconcile("light.a", &[echo(BRIGHTNESS, Some(100.0))])); assert!(p.is_empty()); // The user has not let go, and the window has not elapsed. - assert_eq!( - p.set( + assert!( + !p.set( "light.a", - BRIGHTNESS, - 120.0, + &[(BRIGHTNESS, 120.0)], now + Duration::from_millis(30) ), - None, "the echo must not have reopened the window" ); - assert_eq!( - p.set("light.a", BRIGHTNESS, 140.0, now + SEND_INTERVAL), - Some(140.0), + assert!( + p.set("light.a", &[(BRIGHTNESS, 140.0)], now + SEND_INTERVAL), "and it still opens on time" ); } @@ -418,12 +603,12 @@ mod tests { let now = t0(); let mut p = PendingValues::default(); - p.set("light.a", BRIGHTNESS, 100.0, now); - p.release("light.a", BRIGHTNESS, now); + p.set("light.a", &[(BRIGHTNESS, 100.0)], now); + p.release("light.a", &[BRIGHTNESS], now); assert_eq!(p.expire(now + SETTLE_TIMEOUT), vec!["light.a".to_owned()]); assert!(p.send_due("light.a", now + SETTLE_TIMEOUT)); - p.set("light.b", BRIGHTNESS, 50.0, now); + p.set("light.b", &[(BRIGHTNESS, 50.0)], now); p.clear("light.b"); assert!(p.send_due("light.b", now)); } @@ -433,15 +618,165 @@ mod tests { let now = t0(); let mut p = PendingValues::default(); - p.set("climate.a", ContinuousKind::Temperature, 21.5, now); - assert!(p.reconcile("climate.a", &[(ContinuousKind::Temperature, Some(21.502))])); + p.set("climate.a", &[(AxisKind::Temperature, 21.5)], now); + assert!(p.reconcile("climate.a", &[echo(AxisKind::Temperature, Some(21.502))])); + } + + /// A light that stores mireds internally round-trips kelvin through + /// `round(1_000_000 / kelvin)` and back, so the echo is never the + /// number we sent. 6350 K comes back as 6369 K, the worst case over + /// the 50 K grid Snapdash sends. Against the old global 0.01 this + /// could not match at all, and the axis ran to the settle timeout on + /// every single interaction. + #[test] + fn colour_temperature_reconciles_across_the_mired_round_trip() { + let now = t0(); + let mut p = PendingValues::default(); + + p.set("light.a", &[(TEMP, 6350.0)], now); + assert!(p.reconcile("light.a", &[echo(TEMP, Some(6369.0))])); + } + + /// The slack must stay under the 50 K step, or a stop could be + /// confirmed by its neighbour and the slider would silently accept + /// a value the user did not ask for. + #[test] + fn colour_temperature_does_not_reconcile_against_a_neighbouring_stop() { + let now = t0(); + let mut p = PendingValues::default(); + + p.set("light.a", &[(TEMP, 3000.0)], now); + assert!(!p.reconcile("light.a", &[echo(TEMP, Some(3050.0))])); + assert_eq!(p.shown("light.a", TEMP), Some(3000.0)); + } + + /// A colour is confirmed or held as one thing. The control says yes + /// and both of its axes go; it says no and both of them stay, because + /// a widget showing Home Assistant's hue over the user's saturation + /// would be showing a colour that exists nowhere. + /// + /// Hue 132 at full saturation is stored as `rgb(0, 255, 50)` by a + /// light of the older Home Assistant vintage and read back as + /// 131.765, which the control recognises because it compares the + /// bytes and not the degrees. What those bytes are, and why, is + /// [`Control::reconciles`]'s business and is tested there. + #[test] + fn a_colour_releases_both_of_its_axes_or_neither() { + let now = t0(); + let mut p = PendingValues::default(); + + assert!(p.set("light.a", &[(HUE, 132.0), (SATURATION, 100.0)], now)); + assert!(p.reconcile("light.a", &[colour_echo(Some(131.765), Some(100.0))])); + assert!(p.is_empty(), "both axes went together"); + + // Past the throttle window, so this one really goes out and both + // axes have something outstanding to be disagreed with. + assert!(p.set( + "light.a", + &[(HUE, 132.0), (SATURATION, 100.0)], + now + SEND_INTERVAL + )); + assert!(!p.reconcile("light.a", &[colour_echo(Some(200.0), Some(100.0))])); + assert_eq!(p.shown("light.a", HUE), Some(132.0)); + assert_eq!( + p.shown("light.a", SATURATION), + Some(100.0), + "and neither did" + ); + } + + /// A colour and a slider on the same light are still separate + /// interactions: one control confirming must not release the other's + /// axes, and must not hand the whole entity back while it waits. + #[test] + fn a_colour_confirming_leaves_a_slider_of_the_same_light_alone() { + let now = t0(); + let mut p = PendingValues::default(); + + p.set( + "light.a", + &[(BRIGHTNESS, 100.0), (HUE, 132.0), (SATURATION, 100.0)], + now, + ); + + let resolved = p.reconcile( + "light.a", + &[ + echo(BRIGHTNESS, Some(40.0)), + colour_echo(Some(131.765), Some(100.0)), + ], + ); + + assert!(!resolved, "brightness has not come back yet"); + assert_eq!(p.shown("light.a", HUE), None, "the colour resolved"); + assert_eq!(p.shown("light.a", SATURATION), None); + assert_eq!(p.shown("light.a", BRIGHTNESS), Some(100.0), "still held"); + } + + /// A gesture that has not reached the wire yet is still a gesture. + /// + /// Grabbing a second control inside the entity's throttle window + /// creates axes with nothing sent, and the sibling's echo says + /// nothing about them. Reading that silence as confirmation would + /// release the axes mid-drag: the surface would jump back to Home + /// Assistant's colour under the user's finger, and the release would + /// then find nothing pending and never send what they chose. + #[test] + fn a_throttled_gesture_is_not_confirmed_by_a_siblings_echo() { + let now = t0(); + let mut p = PendingValues::default(); + + // Brightness goes out at once and spends the entity's window. + assert!(p.set("light.a", &[(BRIGHTNESS, 100.0)], now)); + + // Inside that window the user grabs the colour surface, so hue + // and saturation are held with nothing yet on the wire. + assert!(!p.set( + "light.a", + &[(HUE, 132.0), (SATURATION, 100.0)], + now + Duration::from_millis(50) + )); + + // Brightness's echo arrives. It says nothing about the colour. + p.reconcile( + "light.a", + &[ + echo(BRIGHTNESS, Some(100.0)), + colour_echo(Some(10.0), Some(20.0)), + ], + ); + + assert_eq!( + p.shown("light.a", HUE), + Some(132.0), + "the colour is still under the finger" + ); + assert_eq!(p.shown("light.a", SATURATION), Some(100.0)); + + let flushed = p.release( + "light.a", + &[HUE, SATURATION], + now + Duration::from_millis(300), + ); + assert_eq!(flushed, Some(vec![(HUE, 132.0), (SATURATION, 100.0)])); + } + + /// The slack is colour temperature's, not everybody's. Brightness + /// makes the round trip untouched, so 100 is not 110. + #[test] + fn brightness_keeps_the_tight_tolerance() { + let now = t0(); + let mut p = PendingValues::default(); + + p.set("light.a", &[(BRIGHTNESS, 100.0)], now); + assert!(!p.reconcile("light.a", &[echo(BRIGHTNESS, Some(110.0))])); } #[test] fn entities_without_a_pending_value_are_always_ha_driven() { let mut p = PendingValues::default(); assert!(p.reconcile("sensor.temp", &[])); - assert!(p.reconcile("sensor.temp", &[(BRIGHTNESS, Some(12.0))])); + assert!(p.reconcile("sensor.temp", &[echo(BRIGHTNESS, Some(12.0))])); } #[test] @@ -449,10 +784,10 @@ mod tests { let now = t0(); let mut p = PendingValues::default(); - p.set("light.a", BRIGHTNESS, 100.0, now); + p.set("light.a", &[(BRIGHTNESS, 100.0)], now); // A state_changed with no readable value for the axis must not be // mistaken for confirmation. - assert!(!p.reconcile("light.a", &[(BRIGHTNESS, None)])); + assert!(!p.reconcile("light.a", &[echo(BRIGHTNESS, None)])); assert_eq!(p.shown("light.a", BRIGHTNESS), Some(100.0)); } @@ -461,7 +796,7 @@ mod tests { let now = t0(); let mut p = PendingValues::default(); - p.set("light.a", BRIGHTNESS, 100.0, now); + p.set("light.a", &[(BRIGHTNESS, 100.0)], now); // Long past the settle timeout, but the user has not let go. assert!(p.expire(now + SETTLE_TIMEOUT * 10).is_empty()); assert_eq!(p.shown("light.a", BRIGHTNESS), Some(100.0)); @@ -475,11 +810,11 @@ mod tests { let now = t0(); let mut p = PendingValues::default(); - p.set("light.a", BRIGHTNESS, 100.0, now); - p.release("light.a", BRIGHTNESS, now); + p.set("light.a", &[(BRIGHTNESS, 100.0)], now); + p.release("light.a", &[BRIGHTNESS], now); // Second entity grabbed later, so its window has not elapsed. - p.set("light.b", BRIGHTNESS, 50.0, now + SETTLE_TIMEOUT); - p.release("light.b", BRIGHTNESS, now + SETTLE_TIMEOUT); + p.set("light.b", &[(BRIGHTNESS, 50.0)], now + SETTLE_TIMEOUT); + p.release("light.b", &[BRIGHTNESS], now + SETTLE_TIMEOUT); let retired = p.expire(now + SETTLE_TIMEOUT); assert_eq!(retired, vec!["light.a".to_owned()]); @@ -500,10 +835,10 @@ mod tests { let now = t0(); let mut p = PendingValues::default(); - p.set("light.a", BRIGHTNESS, 100.0, now); - p.release("light.a", BRIGHTNESS, now); - p.set("light.a", TEMP, 3000.0, now); - p.release("light.a", TEMP, now); + p.set("light.a", &[(BRIGHTNESS, 100.0)], now); + p.release("light.a", &[BRIGHTNESS], now); + p.set("light.a", &[(TEMP, 3000.0)], now); + p.release("light.a", &[TEMP], now); assert_eq!(p.expire(now + SETTLE_TIMEOUT), vec!["light.a".to_owned()]); assert!(p.is_empty()); @@ -514,8 +849,8 @@ mod tests { let now = t0(); let mut p = PendingValues::default(); - p.set("light.a", BRIGHTNESS, 100.0, now); - p.release("light.a", BRIGHTNESS, now); + p.set("light.a", &[(BRIGHTNESS, 100.0)], now); + p.release("light.a", &[BRIGHTNESS], now); assert!( p.expire(now + SETTLE_TIMEOUT - Duration::from_millis(1)) @@ -537,10 +872,10 @@ mod tests { let now = t0(); let mut p = PendingValues::default(); - p.set("light.a", BRIGHTNESS, 100.0, now); - p.release("light.a", BRIGHTNESS, now); + p.set("light.a", &[(BRIGHTNESS, 100.0)], now); + p.release("light.a", &[BRIGHTNESS], now); // User grabs the slider again before the window elapses. - p.set("light.a", BRIGHTNESS, 200.0, now + SEND_INTERVAL); + p.set("light.a", &[(BRIGHTNESS, 200.0)], now + SEND_INTERVAL); // The old deadline must not still be running. assert!(p.expire(now + SETTLE_TIMEOUT).is_empty()); @@ -552,9 +887,9 @@ mod tests { let now = t0(); let mut p = PendingValues::default(); - p.set("light.a", BRIGHTNESS, 100.0, now); - p.set("light.a", TEMP, 3000.0, now); - p.set("light.b", BRIGHTNESS, 50.0, now); + p.set("light.a", &[(BRIGHTNESS, 100.0)], now); + p.set("light.a", &[(TEMP, 3000.0)], now); + p.set("light.b", &[(BRIGHTNESS, 50.0)], now); p.clear("light.a"); diff --git a/src/app/snapdash.rs b/src/app/snapdash.rs index 161eb94..d5ce2b8 100644 --- a/src/app/snapdash.rs +++ b/src/app/snapdash.rs @@ -153,7 +153,7 @@ pub enum Message { /// only if the entity's throttle window has elapsed. ControlValueChanged { entity_id: String, - axis: ha::ContinuousKind, + axis: ha::AxisKind, value: f32, }, /// Slider released. Always flushes the final value so an interaction @@ -161,7 +161,23 @@ pub enum Message { /// settle window. ControlReleased { entity_id: String, - axis: ha::ContinuousKind, + axis: ha::AxisKind, + }, + /// The colour surface was dragged to a new point (#97). Its own + /// message rather than a second component bolted onto + /// [`Self::ControlValueChanged`]: an optional saturation would then + /// ride along with every brightness and every setpoint, none of which + /// can ever have one, and every handler would have to say so. + ColorChanged { + entity_id: String, + hue: f32, + saturation: f32, + }, + /// The colour surface was let go. It names no axis because a colour + /// is not dragged one axis at a time: both are flushed, and both go + /// out in the single `hs_color` they share. + ColorReleased { + entity_id: String, }, /// Ticks only while a pending value is outstanding, to retire the /// ones whose settle window ran out without a matching echo. @@ -405,18 +421,6 @@ impl Snapdash { } } - /// The value this echo carries for each axis the entity exposes, so - /// each can be compared against what we last sent for it. An axis HA - /// is not currently reporting comes back as `None`, which never - /// counts as confirmation. - fn echoed_values(state: &EntityState) -> Vec<(ha::ContinuousKind, Option)> { - ha::Capabilities::from_state(state) - .continuous - .into_iter() - .map(|control| (control.kind, control.current)) - .collect() - } - fn apply_entity_state(&mut self, new_state: EntityState) { let entity_id = new_state.entity_id.clone(); @@ -427,9 +431,15 @@ impl Snapdash { // updated, only the *display* is held back, so the moment the // pending value resolves or expires there is real state to fall // back to. See `docs/adr/0002-pending-values-and-settle-window.md`. - let ha_driven = self - .pending - .reconcile(&entity_id, &Self::echoed_values(&new_state)); + // + // The echo goes in as the controls it built, not as loose values: + // each control carries what Home Assistant just reported for + // every axis it drives, which is exactly what it needs to + // recognise its own echo (`docs/adr/0004-controls-and-axes.md`). + let ha_driven = self.pending.reconcile( + &entity_id, + &ha::Capabilities::from_state(&new_state).controls, + ); let (pulse, should_refresh_settings) = match self.ha.entities.get(&entity_id) { None => (true, true), @@ -503,34 +513,72 @@ impl Snapdash { } } - /// Every axis an entity currently exposes, in display order. - fn controls_for(&self, entity_id: &str) -> Vec { + /// Every control an entity currently offers, in display order. + fn controls_for(&self, entity_id: &str) -> Vec { self.ha .entities .get(entity_id) .map(ha::Capabilities::from_state) - .map(|caps| caps.continuous) + .map(|caps| caps.controls) .unwrap_or_default() } - /// Turn a raw slider value into the right service call for that axis, - /// then dispatch it. - fn send_continuous( + /// Every control an entity offers, in display order, each carrying + /// the locally-held value of the axes it drives. + /// + /// This is what the expanded card is built from, and it is the only + /// place that answers "what does this widget offer for this axis + /// right now?". Kept on `Snapdash` rather than in the view so the + /// answer can be asserted on without rendering anything. + pub fn control_views(&self, entity_id: &str) -> Vec { + self.controls_for(entity_id) + .into_iter() + .map(|control| crate::ui::entity_window::ControlView { + pending: control + .axes() + .map(|axis| self.pending.shown(entity_id, axis.kind)) + .collect(), + control, + }) + .collect() + } + + /// Put the values one gesture produced on the wire, as the service + /// calls the controls owning those axes build from them. + /// + /// Written over controls rather than over axes because the call is + /// the control's to build (`docs/adr/0004-controls-and-axes.md`). A + /// colour is one `light.turn_on` carrying both of its axes, and + /// dispatching per axis would make it two calls and therefore two + /// colours, the first of them one the user never pointed at. + /// + /// A control none of whose axes the gesture named stays silent, which + /// is what keeps a brightness drag on a colour bulb from also + /// restating the colour. + fn send_gesture( &self, entity_id: &str, - axis: ha::ContinuousKind, - value: f32, + values: &[(ha::AxisKind, f32)], connection: HaConnectionConfig, ) -> Task { - let Some(control) = self + let actions: Vec = self .controls_for(entity_id) - .into_iter() - .find(|c| c.kind == axis) - else { - return Task::none(); - }; + .iter() + .filter_map(|control| { + control.action(|kind| { + values + .iter() + .find(|(moved, _)| *moved == kind) + .map(|(_, value)| *value) + }) + }) + .collect(); - self.dispatch_action(entity_id.to_owned(), control.action(value), connection) + Task::batch( + actions.into_iter().map(|action| { + self.dispatch_action(entity_id.to_owned(), action, connection.clone()) + }), + ) } /// Put an expanded widget back at its preset size and, when it had @@ -1674,14 +1722,14 @@ impl Snapdash { return Task::none(); }; // Nothing to reveal means nothing to grow into. - let axes = self.controls_for(&entity_id).len(); - if axes == 0 { + let controls = self.controls_for(&entity_id); + if controls.is_empty() { return Task::none(); } let size = self.config.widget_settings.widget_size; let base = size.window_size(); - let grown_by = size.controls_height(axes); + let grown_by = size.controls_height(&controls); // Without a reported position there is no way to tell // whether the widget is near an edge, so it grows @@ -1726,17 +1774,17 @@ impl Snapdash { return Task::none(); }; - // `set` returns None when the entity's throttle window - // swallows this move. The value is not lost: it stays in - // `shown`, and `ControlReleased` flushes the final one. - let Some(value) = - self.pending - .set(&entity_id, axis, value, std::time::Instant::now()) - else { + // `set` says no when the entity's throttle window swallows + // this move. The value is not lost: it stays in `shown`, + // and `ControlReleased` flushes the final one. + if !self + .pending + .set(&entity_id, &[(axis, value)], std::time::Instant::now()) + { return Task::none(); - }; + } - self.send_continuous(&entity_id, axis, value, connection) + self.send_gesture(&entity_id, &[(axis, value)], connection) } Message::ControlReleased { entity_id, axis } => { @@ -1746,9 +1794,9 @@ impl Snapdash { // got one can never expire: the card would keep showing a // number the house never confirmed, with nothing left to // correct it. Only the service call needs a live socket. - let Some(value) = self - .pending - .release(&entity_id, axis, std::time::Instant::now()) + let Some(flushed) = + self.pending + .release(&entity_id, &[axis], std::time::Instant::now()) else { return Task::none(); }; @@ -1757,7 +1805,64 @@ impl Snapdash { return Task::none(); }; - self.send_continuous(&entity_id, axis, value, connection) + // Written over whatever the release hands back rather than + // over the axis this message names, so that a control + // which grows a second axis cannot quietly leave that axis + // unsent. + self.send_gesture(&entity_id, &flushed, connection) + } + + Message::ColorChanged { + entity_id, + hue, + saturation, + } => { + let Some(connection) = self.live_connection() else { + self.set_status( + format!("Cannot adjust {entity_id}: not connected to Home Assistant"), + LogType::Warn, + ); + return Task::none(); + }; + + // One batch, so one throttle window for the whole gesture + // and a `last_sent` recorded on both axes when it goes + // out. Two `set` calls would spend the window on the hue + // and leave the saturation unable to ever recognise its + // echo (#96). + let moved = [ + (ha::AxisKind::Hue, hue), + (ha::AxisKind::Saturation, saturation), + ]; + + if !self + .pending + .set(&entity_id, &moved, std::time::Instant::now()) + { + return Task::none(); + } + + self.send_gesture(&entity_id, &moved, connection) + } + + Message::ColorReleased { entity_id } => { + let Some(flushed) = self.pending.release( + &entity_id, + &[ha::AxisKind::Hue, ha::AxisKind::Saturation], + std::time::Instant::now(), + ) else { + return Task::none(); + }; + + let Some(connection) = self.live_connection() else { + return Task::none(); + }; + + // Both axes come back from one release and go out as one + // `SetHs`, not as two calls: `send_gesture` asks the + // control what to send, and a colour surface answers with + // a single `hs_color`. + self.send_gesture(&entity_id, &flushed, connection) } Message::PendingTick(now) => { diff --git a/src/ha/actions.rs b/src/ha/actions.rs index b18e622..1a3a003 100644 --- a/src/ha/actions.rs +++ b/src/ha/actions.rs @@ -61,8 +61,9 @@ fn http() -> &'static reqwest::Client { /// A concrete Home Assistant service call. /// /// Phase 1 variants are zero-argument: HA decides the resulting state. -/// Phase 2 variants carry the value being set. The enum stays `Copy` -/// because every payload is a scalar. +/// Phase 2 variants carry the value being set. The enum stays `Copy`: +/// a payload is a scalar, or - where the service takes a colour - a +/// fixed-size group of them, and nothing here owns an allocation. #[derive(Debug, Clone, Copy, PartialEq)] pub enum ActionKind { /// `switch.toggle` - HA flips it based on current state. @@ -79,6 +80,19 @@ pub enum ActionKind { SetBrightness(u8), /// `light.turn_on` with `color_temp_kelvin`. SetColorTemp(u32), + /// `light.turn_on` with `hs_color`. + /// + /// Hue and saturation travel together because the service parameter + /// is one two-element array: there is no way to name one of them on + /// the wire without also naming the other. + /// + /// `hs_color` is a universal input rather than a mode-specific one. + /// Home Assistant's `process_turn_on_params` converts it into + /// whatever space the light actually supports - RGB, RGBW, RGBWW, XY, + /// or as a last resort back to a colour temperature - so Snapdash + /// sends hue and saturation whatever the bulb is, and never has to + /// branch on the device's native colour mode. + SetHs { hue: u16, saturation: u8 }, /// `climate.set_temperature` with `temperature`. SetTemperature(f32), /// `cover.set_cover_position` with `position` (0-100). @@ -114,6 +128,14 @@ impl ActionKind { "turn_on", vec![("color_temp_kelvin", Value::from(v))], ), + Self::SetHs { hue, saturation } => ( + "light", + "turn_on", + vec![( + "hs_color", + Value::Array(vec![Value::from(hue), Value::from(saturation)]), + )], + ), Self::SetTemperature(v) => ( "climate", "set_temperature", @@ -136,9 +158,8 @@ impl ActionKind { /// The zero-argument action a widget fires from its header icon, or /// `None` for entities that only display. /// - /// Derivable from the entity id alone, unlike [`ContinuousControl`], - /// because every primary action is a plain domain-level toggle or - /// trigger. + /// Derivable from the entity id alone, unlike an [`Axis`], because + /// every primary action is a plain domain-level toggle or trigger. pub fn primary_for_entity(entity_id: &str) -> Option { match domain(entity_id) { "switch" => Some(Self::ToggleSwitch), @@ -151,17 +172,59 @@ impl ActionKind { } } -/// Which dimension a [`ContinuousControl`] adjusts. Determines the -/// action built on release and how the value is presented. +/// Which dimension an [`Axis`] adjusts. Determines the action built +/// on release and how the value is presented. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum ContinuousKind { +pub enum AxisKind { Brightness, /// White colour temperature of a light, in kelvin. ColorTemp, + /// Position on the colour wheel, in degrees. + Hue, + /// How much colour, as a percentage, with 0 being white. + Saturation, Temperature, Position, } +impl AxisKind { + /// How far an echo may sit from the value we sent and still count as + /// confirmation of it, for the axes that are confirmed on their own. + /// + /// Not one constant, because the round trip through Home Assistant + /// is lossy by different amounts per axis. Brightness, position and + /// a setpoint are stored in the units we send, so anything past + /// float representation noise is a genuinely different value. + /// + /// Colour temperature is not: most integrations store mireds and + /// round-trip kelvin through `round(1_000_000 / kelvin)` and back, + /// which does not return what we sent. Over the 50 K grid Snapdash + /// actually sends, the worst case is 19 K (6350 K comes back as + /// 6369 K). 25 K clears that comfortably while staying at half the + /// step, so it can never confirm a neighbouring stop. + /// + /// Hue and saturation have no answer here, and cannot have one. How + /// far a colour may have drifted is not a question either axis can + /// be asked alone: a light storing 8-bit RGB returns a hue roughly + /// `20 / saturation` degrees from the one it was given, which is + /// 0.24 degrees at full saturation and 19.9 at saturation 1, where + /// three bytes barely determine a hue at all. No fixed number on the + /// hue axis absorbs that without also confirming colours the user + /// did not pick. They are confirmed jointly instead, by + /// [`Control::Color`], and in RGB - see + /// `docs/adr/0004-controls-and-axes.md`. + fn echo_tolerance(self) -> Option { + match self { + // A setpoint round-trips through HA as a float and may come + // back with a different representation than we sent, which + // is all this needs to absorb. + Self::Brightness | Self::Temperature | Self::Position => Some(0.01), + Self::ColorTemp => Some(25.0), + Self::Hue | Self::Saturation => None, + } + } +} + /// A numeric dimension of an entity that is *set* rather than toggled, /// together with the range needed to render a control for it. /// @@ -169,8 +232,8 @@ pub enum ContinuousKind { /// because HA reports per-device limits (a thermostat's `min_temp` and /// `max_temp` vary by device and by unit system). #[derive(Debug, Clone, PartialEq)] -pub struct ContinuousControl { - pub kind: ContinuousKind, +pub struct Axis { + pub kind: AxisKind, pub min: f32, pub max: f32, pub step: f32, @@ -178,15 +241,275 @@ pub struct ContinuousControl { pub current: Option, } -impl ContinuousControl { - /// Build the action that sets this dimension to `value`. - pub fn action(&self, value: f32) -> ActionKind { - let clamped = value.clamp(self.min, self.max); - match self.kind { - ContinuousKind::Brightness => ActionKind::SetBrightness(clamped as u8), - ContinuousKind::ColorTemp => ActionKind::SetColorTemp(clamped as u32), - ContinuousKind::Temperature => ActionKind::SetTemperature(clamped), - ContinuousKind::Position => ActionKind::SetPosition(clamped as u8), +impl Axis { + /// `value` brought inside the range Home Assistant reported for this + /// dimension. + fn clamped(&self, value: f32) -> f32 { + value.clamp(self.min, self.max) + } +} + +/// What one axis has outstanding at the moment an echo arrives. +/// +/// The distinction that matters is between the first two: an axis nobody +/// is touching and an axis being dragged whose sends the throttle has so +/// far swallowed both have no value on the wire, and both would answer +/// `None` to "what was last sent?". They need opposite verdicts. The +/// first is vacuously confirmed, because there is nothing for the echo to +/// disagree with. The second must never be, because releasing it would +/// throw away a gesture the user is still making, and the value they +/// finally chose would never be sent at all. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Outstanding { + /// No interaction. Nothing to confirm and nothing to release. + Nothing, + /// Held by the user, but every send so far fell inside the entity's + /// throttle window, so there is nothing for this echo to be an echo + /// of. Resolved by [`Control::action`] going out on the next window, + /// or by the flush on release. + Unsent, + /// This value is on the wire, awaiting confirmation. + Sent(f32), +} + +/// One thing the user grabs in an expanded widget. +/// +/// An [`Axis`] is a numeric dimension of the entity; a `Control` is the +/// affordance that drives one or more of them, and it owns three things: +/// how it draws, what it puts on the wire ([`Control::action`]), and how +/// it recognises its own echo ([`Control::reconciles`]). +/// +/// They were 1:1 until the colour surface arrived, which is why they were +/// the same type, but they are not the same concept: a colour surface is +/// one control setting two axes with one gesture and one service call. +/// +/// The enum exists so that grouping is a fact of the type rather than a +/// convention. A flat list with a "these belong together" marker would +/// permit a hue with no saturation beside it, and nothing would catch +/// it. Recorded in `docs/adr/0004-controls-and-axes.md`. +#[derive(Debug, Clone, PartialEq)] +pub enum Control { + /// A single axis, dragged on a slider of its own. + Value(Axis), + /// Hue and saturation, dragged as one point on a two-dimensional + /// field. + Color { hue: Axis, saturation: Axis }, +} + +/// How far two colours may differ, per eight-bit channel, and still +/// count as the same colour. +/// +/// One level, which is the least a comparison can allow and still be a +/// tolerance at all. It has to be at least that, because Home Assistant +/// changed `color_hsv_to_RGB` from truncating to rounding between the +/// 2024.7 and 2025.1 releases: the two differ by exactly one level, and +/// Snapdash cannot know which of them the house is running. One level is +/// also enough. Two whole degrees of hue are four levels apart at full +/// saturation and two at half, so no neighbouring colour the user could +/// have meant to pick instead can slip through - and below about +/// saturation 20 neighbouring degrees are the *same* three bytes, which +/// is the point: they are the same colour. +const ECHO_CHANNEL_TOLERANCE: u8 = 1; + +/// A colour as the three eight-bit channels a light actually stores. +type Rgb8 = [u8; 3]; + +/// Hue and saturation as the eight-bit colour Home Assistant makes of +/// them. +/// +/// A transcription of `color_hs_to_RGB` from `homeassistant/util/color.py`, +/// which is `color_hsv_to_RGB(hue, saturation, 100)`, which is +/// `colorsys.hsv_to_rgb(hue / 360, saturation / 100, 1)` scaled to eight +/// bits. It is deliberately Home Assistant's arithmetic and not a tidier +/// equivalent, because the whole purpose is to land on the same three +/// bytes the bulb was given. +/// +/// In `f64` rather than `f32` for the same reason: Python computes this +/// in doubles, and a component that lands within float noise of a +/// half-level would otherwise round the other way and shift a channel. +/// +/// Note that this is *not* [`crate::ui::colour_texture`]'s pixel +/// function, even though both turn an HSV triple into bytes. That one +/// paints what the user sees and rounds so the field does not darken +/// towards its saturated edge; this one has to agree with a third party +/// to the level. Making them one function would mean one of the two jobs +/// silently getting the other's answer. +fn hs_to_rgb8(hue: f32, saturation: f32) -> Rgb8 { + let hue = f64::from(hue) / 360.0; + let saturation = f64::from(saturation) / 100.0; + + // `colorsys.hsv_to_rgb` takes this branch verbatim, and it matters + // for more than speed: with S = 0 the sector arithmetic below is + // undefined-ish at the seam, and white is white at every hue. + let (red, green, blue) = if saturation == 0.0 { + (1.0, 1.0, 1.0) + } else { + let sector = hue * 6.0; + // Python's `int()`, which truncates towards zero rather than + // rounding. `rem_euclid` then folds hue 360 back onto the red + // the wheel starts at, exactly as `i % 6` does there. + let index = sector as i64; + let offset = sector - index as f64; + + // The value axis is pinned to 1, so `v` drops out of all three + // of Python's `p`, `q` and `t`. + let p = 1.0 - saturation; + let q = 1.0 - saturation * offset; + let t = 1.0 - saturation * (1.0 - offset); + + match index.rem_euclid(6) { + 0 => (1.0, t, p), + 1 => (q, 1.0, p), + 2 => (p, 1.0, t), + 3 => (p, q, 1.0), + 4 => (t, p, 1.0), + _ => (1.0, p, q), + } + }; + + [level(red), level(green), level(blue)] +} + +/// A 0..=1 colour component as the eight-bit level Python's `round` +/// makes of it. +/// +/// Ties go to even, which is what `round` does in Python 3 and what +/// `f64::round` does not: `round(178.5)` is 178 there and 179 here. +/// Half a level is not visible, but agreeing to the level is the entire +/// job of this conversion, so it uses the same rule. +fn level(component: f64) -> u8 { + (component * 255.0).round_ties_even() as u8 +} + +fn same_colour(sent: Rgb8, echoed: Rgb8) -> bool { + sent.iter() + .zip(echoed) + .all(|(sent, echoed)| sent.abs_diff(echoed) <= ECHO_CHANNEL_TOLERANCE) +} + +impl Control { + /// Every axis this control drives, in the order it presents them. + pub fn axes(&self) -> impl Iterator { + let (first, second) = match self { + Self::Value(axis) => (axis, None), + Self::Color { hue, saturation } => (hue, Some(saturation)), + }; + + std::iter::once(first).chain(second) + } + + /// The one service call that sets this control to the values a + /// gesture produced, or `None` when the gesture did not name + /// everything the call needs. + /// + /// One call per control and never one per axis. `hs_color` is a + /// single two-element parameter, so there is no way to name hue on + /// the wire without also naming saturation, and a colour sent as two + /// calls would be two colours - the first of them one the user never + /// pointed at. + /// + /// `value` answers the value of an axis this gesture moved, or `None` + /// for one it did not, which is how a brightness drag on a colour + /// bulb leaves the colour surface silent. + pub fn action(&self, value: impl Fn(AxisKind) -> Option) -> Option { + match self { + Self::Value(axis) => { + let clamped = axis.clamped(value(axis.kind)?); + + Some(match axis.kind { + AxisKind::Brightness => ActionKind::SetBrightness(clamped as u8), + AxisKind::ColorTemp => ActionKind::SetColorTemp(clamped as u32), + AxisKind::Temperature => ActionKind::SetTemperature(clamped), + AxisKind::Position => ActionKind::SetPosition(clamped as u8), + // Unreachable by construction: discovery only ever + // puts these two inside `Control::Color`, which is + // what makes an orphan hue unrepresentable. Silence + // rather than a guess, because the guess would be a + // saturation the user never chose. + AxisKind::Hue | AxisKind::Saturation => return None, + }) + } + + Self::Color { hue, saturation } => Some(ActionKind::SetHs { + hue: hue.clamped(value(AxisKind::Hue)?) as u16, + saturation: saturation.clamped(value(AxisKind::Saturation)?) as u8, + }), + } + } + + /// Whether this echo confirms what was put on the wire. + /// + /// `self` is the control as Home Assistant has just reported it, so + /// each [`Axis::current`] is the echoed value. `outstanding` answers + /// what an axis has in flight, which is three states and not two: + /// see [`Outstanding`] for why an axis with nothing on the wire is + /// not the same as an axis nobody is touching. + /// + /// The judgement is the control's whole gesture at once and never one + /// axis at a time, which is what a colour needs: half a confirmed + /// colour is not a thing, and the caller releases every axis this + /// control drives or none of them. + /// + /// A control with nothing outstanding is vacuously confirmed - there + /// is nothing for the echo to disagree with, and nothing to release. + pub fn reconciles(&self, outstanding: impl Fn(AxisKind) -> Outstanding) -> bool { + match self { + Self::Value(axis) => { + let sent = match outstanding(axis.kind) { + Outstanding::Nothing => return true, + Outstanding::Unsent => return false, + Outstanding::Sent(sent) => sent, + }; + + // An echo that carries no value for the axis is not + // confirmation of one, and an axis with no tolerance is + // not confirmable alone at all. + match (axis.current, axis.kind.echo_tolerance()) { + (Some(echoed), Some(tolerance)) => (sent - echoed).abs() <= tolerance, + _ => false, + } + } + + Self::Color { hue, saturation } => { + let (hue_out, saturation_out) = ( + outstanding(AxisKind::Hue), + outstanding(AxisKind::Saturation), + ); + + if hue_out == Outstanding::Nothing && saturation_out == Outstanding::Nothing { + return true; + } + + // Half a colour is not a thing in either direction. One + // component outstanding without the other cannot be + // judged, because eight-bit RGB needs both; an axis whose + // sends the throttle swallowed has nothing to be judged + // against; and an echo carrying no colour at all - the + // light went off, or back into a white mode - confirms + // nothing. + let (Outstanding::Sent(hue_sent), Outstanding::Sent(saturation_sent)) = + (hue_out, saturation_out) + else { + return false; + }; + let (Some(hue_echoed), Some(saturation_echoed)) = (hue.current, saturation.current) + else { + return false; + }; + + // Compared as colours rather than as the coordinates they + // happen to be written in. A light working in RGB or XY + // returns a hue and a saturation that are not the ones it + // was given, by an amount that grows without bound as + // saturation falls, but the three bytes it is showing are + // the three bytes it was told to show. Comparing those + // dissolves the low-saturation ambiguity, and takes hue + // wraparound with it: hue 0 and hue 360 are one colour. + same_colour( + hs_to_rgb8(hue_sent, saturation_sent), + hs_to_rgb8(hue_echoed, saturation_echoed), + ) + } } } } @@ -196,27 +519,32 @@ impl ContinuousControl { pub struct Capabilities { /// Fired by the widget's header icon. pub primary: Option, - /// Every axis the entity exposes, adjusted from the expanded widget. - /// Empty for an entity with nothing to set. - pub continuous: Vec, + /// Every control the entity offers, in the order the expanded widget + /// shows them. Empty for an entity with nothing to set. + pub controls: Vec, } impl Capabilities { pub fn from_state(state: &EntityState) -> Self { Self { primary: ActionKind::primary_for_entity(&state.entity_id), - continuous: continuous_controls(state), + controls: controls(state), } } + /// Every axis the entity exposes, across all of its controls. + pub fn axes(&self) -> impl Iterator { + self.controls.iter().flat_map(Control::axes) + } + /// The axis of a given kind, when the entity has one. - pub fn axis(&self, kind: ContinuousKind) -> Option<&ContinuousControl> { - self.continuous.iter().find(|c| c.kind == kind) + pub fn axis(&self, kind: AxisKind) -> Option<&Axis> { + self.axes().find(|a| a.kind == kind) } /// `true` when the entity can only be displayed, never acted on. pub fn is_display_only(&self) -> bool { - self.primary.is_none() && self.continuous.is_empty() + self.primary.is_none() && self.controls.is_empty() } } @@ -228,6 +556,25 @@ fn attr_f32(state: &EntityState, key: &str) -> Option { } } +/// One element of the `hs_color` Home Assistant is currently reporting: +/// index 0 is the hue in degrees, index 1 the saturation in percent. +/// +/// Its own reader because `hs_color` is the one attribute Snapdash takes +/// a value from that is not a scalar, and [`attr_f32`] would answer +/// `None` for it. +/// +/// The attribute says what colour the light is showing and nothing about +/// which mode it is in: Home Assistant *derives* an `hs_color` from the +/// kelvin value whenever the active mode is `color_temp`, so it is +/// present for a bulb sitting in plain white. Discovery is keyed on +/// `supported_color_modes` for that reason, never on this. +fn attr_hs(state: &EntityState, index: usize) -> Option { + match state.attributes.get("hs_color")? { + Value::Array(hs) => hs.get(index)?.as_f64().map(|value| value as f32), + _ => None, + } +} + fn supported_features(state: &EntityState) -> u64 { state .attributes @@ -236,19 +583,46 @@ fn supported_features(state: &EntityState) -> u64 { .unwrap_or(0) } +/// The colour modes a light advertises as supported. +/// +/// Every light capability Snapdash discovers is read from this one list, +/// and from the *supported* list rather than the active `color_mode`. +/// `color_mode` is `None` whenever the light is off, so keying on it +/// would make controls appear and disappear as the light is switched. +fn color_modes(state: &EntityState) -> impl Iterator { + match state.attributes.get("supported_color_modes") { + Some(Value::Array(modes)) => modes.as_slice(), + _ => &[], + } + .iter() + .filter_map(Value::as_str) +} + +/// The colour modes that carry a full colour, `COLOR_MODES_COLOR` in +/// Home Assistant's `light/const.py`. +/// +/// A light in any one of these can be given a hue. `color_temp` and +/// `white` are deliberately absent: they are modes for producing white +/// light and a hue means nothing in them. +const COLOR_MODES_COLOR: &[&str] = &["hs", "rgb", "rgbw", "rgbww", "xy"]; + /// A light is dimmable when it advertises any color mode other than /// plain on/off. `onoff` and `unknown` are the two modes that carry no /// brightness channel; everything else (`brightness`, `color_temp`, /// `hs`, `xy`, `rgb`, `rgbw`, `rgbww`, `white`) does. fn light_is_dimmable(state: &EntityState) -> bool { - let Some(Value::Array(modes)) = state.attributes.get("supported_color_modes") else { - return false; - }; + color_modes(state).any(|m| !matches!(m, "onoff" | "unknown")) +} - modes - .iter() - .filter_map(Value::as_str) - .any(|m| !matches!(m, "onoff" | "unknown")) +/// A light can be given a colour when it advertises any of the colour +/// modes, not `hs` alone. +/// +/// The distinction matters because `hs_color` is a universal input: +/// Home Assistant converts it into whichever space the device natively +/// speaks. An `rgb`-only bulb takes hue and saturation perfectly well +/// and would be left with no colour control by a narrower predicate. +fn light_has_colour(state: &EntityState) -> bool { + color_modes(state).any(|m| COLOR_MODES_COLOR.contains(&m)) } /// A light supports white colour temperature when it advertises the @@ -256,46 +630,40 @@ fn light_is_dimmable(state: &EntityState) -> bool { /// /// `color_temp_kelvin` is `null` whenever the light is currently in some /// other mode, so the axis is offered on the strength of what the device -/// *supports*, and the slider simply starts at its minimum until the -/// light is put into that mode. +/// *supports* rather than of what it happens to be doing. Until the +/// light is put into that mode the axis has no value, and the control +/// renders as absent (`docs/adr/0006-a-null-axis-renders-as-absent.md`). fn light_has_color_temp(state: &EntityState) -> bool { - let Some(Value::Array(modes)) = state.attributes.get("supported_color_modes") else { - return false; - }; - - modes - .iter() - .filter_map(Value::as_str) - .any(|m| m == "color_temp") + color_modes(state).any(|m| m == "color_temp") } -/// Every axis an entity exposes, in the order they should be shown. +/// Every control an entity offers, in the order they should be shown. /// /// A light can carry more than one, which is why this returns a list. /// Each axis reconciles independently, but they share one send throttle /// per entity, so the peak call rate does not grow with the axis count /// (`docs/adr/0003-service-calls-stay-on-rest.md`). -fn continuous_controls(state: &EntityState) -> Vec { +fn controls(state: &EntityState) -> Vec { let mut controls = Vec::new(); match domain(&state.entity_id) { "light" => { if light_is_dimmable(state) { - controls.push(ContinuousControl { - kind: ContinuousKind::Brightness, + controls.push(Control::Value(Axis { + kind: AxisKind::Brightness, min: 0.0, max: 255.0, step: 1.0, current: attr_f32(state, "brightness"), - }); + })); } if light_has_color_temp(state) { // Ranges are per-device. The fallbacks are HA's own // defaults for a light that reports the mode but not its // limits, which some integrations do. - controls.push(ContinuousControl { - kind: ContinuousKind::ColorTemp, + controls.push(Control::Value(Axis { + kind: AxisKind::ColorTemp, min: attr_f32(state, "min_color_temp_kelvin").unwrap_or(2000.0), max: attr_f32(state, "max_color_temp_kelvin").unwrap_or(6535.0), // Kelvin spans thousands, so a 1 K step would be a @@ -303,6 +671,33 @@ fn continuous_controls(state: &EntityState) -> Vec { // difference between neighbours. step: 50.0, current: attr_f32(state, "color_temp_kelvin"), + })); + } + + if light_has_colour(state) { + controls.push(Control::Color { + hue: Axis { + kind: AxisKind::Hue, + min: 0.0, + // Capped at 359 rather than 360. The two ends of + // the wheel are the same red, so a 360th stop + // would be a second name for the value at 0 and + // every piece of state that compares hues would + // have to know it. Red still appears at both ends + // of the field, as it does in every colour picker. + max: 359.0, + step: 1.0, + current: attr_hs(state, 0), + }, + saturation: Axis { + kind: AxisKind::Saturation, + min: 0.0, + // The range `hs_color` itself uses, so nothing is + // rescaled on the way out or on the way back. + max: 100.0, + step: 1.0, + current: attr_hs(state, 1), + }, }); } } @@ -311,25 +706,25 @@ fn continuous_controls(state: &EntityState) -> Vec { // entity has no single setpoint to drag (it may be a range-only // thermostat, which is not covered). "climate" if supported_features(state) & 1 != 0 => { - controls.push(ContinuousControl { - kind: ContinuousKind::Temperature, + controls.push(Control::Value(Axis { + kind: AxisKind::Temperature, min: attr_f32(state, "min_temp").unwrap_or(7.0), max: attr_f32(state, "max_temp").unwrap_or(35.0), step: attr_f32(state, "target_temp_step").unwrap_or(0.5), current: attr_f32(state, "temperature"), - }); + })); } // CoverEntityFeature.SET_POSITION == 4. Open/close-only covers // report 1|2 but not 4 and get no slider. "cover" if supported_features(state) & 4 != 0 => { - controls.push(ContinuousControl { - kind: ContinuousKind::Position, + controls.push(Control::Value(Axis { + kind: AxisKind::Position, min: 0.0, max: 100.0, step: 1.0, current: attr_f32(state, "current_position"), - }); + })); } _ => {} @@ -466,7 +861,7 @@ mod tests { assert!(is_widget_candidate("scene.evening")); assert!(is_widget_candidate("script.morning")); assert!(is_widget_candidate("input_boolean.guest_mode")); - // Continuous-control domains (#87). Candidates regardless of + // Domains that carry a control (#87). Candidates regardless of // whether the individual device supports a setpoint or position - // their state is worth displaying either way. assert!(is_widget_candidate("climate.thermostat")); @@ -521,6 +916,17 @@ mod tests { call(ActionKind::SetColorTemp(3000)), ("light", "turn_on", vec![("color_temp_kelvin", json!(3000))]) ); + // One parameter, carrying both numbers. Brightness is deliberately + // not sent with it: `light.turn_on` without `brightness` leaves + // the light's brightness where it was, so one control sets one + // thing and the colour cannot dim the lamp behind the user's back. + assert_eq!( + call(ActionKind::SetHs { + hue: 200, + saturation: 100 + }), + ("light", "turn_on", vec![("hs_color", json!([200, 100]))]) + ); } #[test] @@ -533,12 +939,12 @@ mod tests { assert_eq!(caps.primary, Some(ActionKind::ToggleLight)); let c = caps - .axis(ContinuousKind::Brightness) + .axis(AxisKind::Brightness) .expect("dimmable light has a brightness axis"); assert_eq!((c.min, c.max), (0.0, 255.0)); assert_eq!(c.current, Some(128.0)); // Brightness alone: this bulb advertises no colour temperature. - assert_eq!(caps.continuous.len(), 1); + assert_eq!(caps.controls.len(), 1); } /// A light can expose more than one axis, which is the whole reason @@ -557,12 +963,15 @@ mod tests { ); let caps = Capabilities::from_state(&s); - assert_eq!(caps.continuous.len(), 2); + assert_eq!(caps.controls.len(), 2); // Brightness first: it is the axis people reach for. - assert_eq!(caps.continuous[0].kind, ContinuousKind::Brightness); + assert_eq!( + caps.axes().map(|a| a.kind).collect::>(), + vec![AxisKind::Brightness, AxisKind::ColorTemp] + ); let temp = caps - .axis(ContinuousKind::ColorTemp) + .axis(AxisKind::ColorTemp) .expect("color_temp mode means a white axis"); assert_eq!((temp.min, temp.max), (2202.0, 6535.0)); assert_eq!(temp.current, Some(3000.0)); @@ -570,7 +979,8 @@ mod tests { /// `color_temp_kelvin` is null whenever the light sits in another /// colour mode. The axis is still offered, because the device - /// supports it, and the slider simply starts at its minimum. + /// supports it, and it carries no value until the light is in that + /// mode. #[test] fn colour_temperature_survives_the_light_being_in_another_mode() { let s = state( @@ -582,28 +992,163 @@ mod tests { }), ); let temp = Capabilities::from_state(&s) - .axis(ContinuousKind::ColorTemp) + .axis(AxisKind::ColorTemp) .cloned() .expect("still supported, just not active"); assert_eq!(temp.current, None); } + /// The predicate is `COLOR_MODES_COLOR` and not `hs` alone. Hue and + /// saturation are a universal input that Home Assistant converts into + /// whatever the device natively speaks, so every one of these bulbs + /// takes a colour, and an `hs`-only check would leave the most common + /// of them - a plain RGB strip - with no colour control at all. + #[test] + fn every_colour_mode_offers_a_colour_surface() { + for mode in ["hs", "rgb", "rgbw", "rgbww", "xy"] { + let s = state( + "light.strip", + json!({ "supported_color_modes": [mode], "hs_color": [120.0, 90.0] }), + ); + let caps = Capabilities::from_state(&s); + + let hue = caps + .axis(AxisKind::Hue) + .unwrap_or_else(|| panic!("{mode} is a colour mode")); + assert_eq!(hue.current, Some(120.0), "{mode}"); + assert_eq!( + caps.axis(AxisKind::Saturation).and_then(|a| a.current), + Some(90.0), + "{mode}" + ); + } + } + + /// The negatives, and the reason the check cannot simply be "not + /// on/off": `brightness`, `color_temp` and `white` are all modes a + /// dimmable light reports, and none of them can show a colour. + #[test] + fn a_light_with_no_colour_mode_offers_no_colour_surface() { + for mode in ["onoff", "brightness", "color_temp", "white"] { + let s = state( + "light.lamp", + json!({ + "supported_color_modes": [mode], + // Given on purpose to every one of these, because + // Home Assistant really does report an `hs_color` + // for a light in `color_temp` mode: the attribute's + // presence is never evidence of a colour capability, + // and discovery must not be tempted to read it. + "hs_color": [28.391, 65.659] + }), + ); + + let caps = Capabilities::from_state(&s); + + assert!( + !caps + .controls + .iter() + .any(|control| matches!(control, Control::Color { .. })), + "{mode} is not a colour mode" + ); + assert!(caps.axis(AxisKind::Hue).is_none(), "{mode}"); + assert!(caps.axis(AxisKind::Saturation).is_none(), "{mode}"); + } + } + + /// The wheel is `[0, 359]` in whole degrees. Capping at 359 rather + /// than 360 keeps one value per colour: the two ends are the same + /// red, and a 360th stop would be a second name for 0 that every + /// comparison of hues would then have to know about. + #[test] + fn hue_spans_the_wheel_capped_at_359() { + let s = state("light.strip", json!({ "supported_color_modes": ["rgb"] })); + let caps = Capabilities::from_state(&s); + let hue = caps.axis(AxisKind::Hue).expect("an rgb light has a hue"); + + assert_eq!((hue.min, hue.max, hue.step), (0.0, 359.0, 1.0)); + // No `hs_color` at all, which is what an off light reports. The + // axis is offered because the device supports colour, and carries + // no value until it is showing one (#94). + assert_eq!(hue.current, None); + } + + /// Home Assistant reports the colour as one two-element array, so + /// both axes of the surface are read out of the same attribute. + #[test] + fn the_colour_surface_reads_both_elements_of_hs_color() { + let s = state( + "light.strip", + json!({ "supported_color_modes": ["rgb"], "hs_color": [199.765, 62.5] }), + ); + let caps = Capabilities::from_state(&s); + + assert_eq!(caps.axis(AxisKind::Hue).unwrap().current, Some(199.765)); + assert_eq!(caps.axis(AxisKind::Saturation).unwrap().current, Some(62.5)); + } + + /// Saturation is a full axis of its own, and one the user drives, so + /// it carries the range `hs_color` is stated in rather than a + /// rescaling of it. #[test] - fn onoff_only_light_has_no_continuous_axes() { + fn saturation_spans_zero_to_a_hundred_in_whole_steps() { + let s = state("light.strip", json!({ "supported_color_modes": ["rgb"] })); + let caps = Capabilities::from_state(&s); + let saturation = caps + .axis(AxisKind::Saturation) + .expect("an rgb light has a saturation"); + + assert_eq!( + (saturation.min, saturation.max, saturation.step), + (0.0, 100.0, 1.0) + ); + // No `hs_color` at all, which is what an off light reports. + assert_eq!(saturation.current, None); + } + + /// The two axes are one control, not two beside each other. Nothing + /// downstream has to be told they belong together, and nothing can + /// render the hue on a slider of its own. + #[test] + fn hue_and_saturation_arrive_as_one_control() { + let s = state( + "light.strip", + json!({ "supported_color_modes": ["rgb"], "hs_color": [199.765, 62.5] }), + ); + let caps = Capabilities::from_state(&s); + let colour = caps + .controls + .iter() + .find(|control| matches!(control, Control::Color { .. })) + .expect("an rgb light has a colour surface"); + + // Brightness beside it, and nothing else: two controls, three + // axes. + assert_eq!(caps.controls.len(), 2); + assert_eq!( + colour.axes().map(|axis| axis.kind).collect::>(), + vec![AxisKind::Hue, AxisKind::Saturation], + "hue across, saturation down, in the order the field shows them" + ); + } + + #[test] + fn onoff_only_light_has_no_axes() { let s = state("light.porch", json!({ "supported_color_modes": ["onoff"] })); let caps = Capabilities::from_state(&s); // Still tappable, just not adjustable. This is exactly the case // an entity-id-only check gets wrong. assert_eq!(caps.primary, Some(ActionKind::ToggleLight)); - assert!(caps.continuous.is_empty()); + assert!(caps.controls.is_empty()); } #[test] - fn light_without_color_modes_has_no_continuous_axes() { + fn light_without_color_modes_has_no_axes() { let s = state("light.mystery", json!({})); - assert!(Capabilities::from_state(&s).continuous.is_empty()); + assert!(Capabilities::from_state(&s).controls.is_empty()); } #[test] @@ -620,7 +1165,7 @@ mod tests { ); let caps = Capabilities::from_state(&s); let c = caps - .axis(ContinuousKind::Temperature) + .axis(AxisKind::Temperature) .expect("target temperature supported"); assert_eq!((c.min, c.max, c.step), (10.0, 28.0, 0.5)); @@ -630,18 +1175,14 @@ mod tests { #[test] fn climate_without_target_temperature_feature_has_no_control() { let s = state("climate.hall", json!({ "supported_features": 0 })); - assert!(Capabilities::from_state(&s).continuous.is_empty()); + assert!(Capabilities::from_state(&s).controls.is_empty()); } #[test] fn cover_needs_set_position_feature() { // OPEN|CLOSE|STOP but no SET_POSITION (4). let positionless = state("cover.garage", json!({ "supported_features": 11 })); - assert!( - Capabilities::from_state(&positionless) - .continuous - .is_empty() - ); + assert!(Capabilities::from_state(&positionless).controls.is_empty()); let positionable = state( "cover.blinds", @@ -649,7 +1190,7 @@ mod tests { ); let caps = Capabilities::from_state(&positionable); let c = caps - .axis(ContinuousKind::Position) + .axis(AxisKind::Position) .expect("SET_POSITION means a position axis"); assert_eq!(c.current, Some(40.0)); } @@ -663,18 +1204,297 @@ mod tests { assert!(Capabilities::from_state(&s).is_display_only()); } - #[test] - fn continuous_control_clamps_before_building_an_action() { - let c = ContinuousControl { - kind: ContinuousKind::Brightness, + /// Every value in this module's tests reaches an action through the + /// same door a gesture does: a lookup by axis. + /// The values a gesture moved, for [`Control::action`]. + fn at(values: &[(AxisKind, f32)]) -> impl Fn(AxisKind) -> Option + use<'_> { + move |kind| { + values + .iter() + .find(|(k, _)| *k == kind) + .map(|(_, value)| *value) + } + } + + /// The axes named here are on the wire; every other one has no + /// interaction at all. [`Outstanding::Unsent`] is the third state and + /// gets its own helper, because it is the one nothing else covers. + fn sent(values: &[(AxisKind, f32)]) -> impl Fn(AxisKind) -> Outstanding + use<'_> { + move |kind| { + values + .iter() + .find(|(k, _)| *k == kind) + .map_or(Outstanding::Nothing, |(_, value)| Outstanding::Sent(*value)) + } + } + + /// The axes named here are held with nothing yet on the wire. + fn unsent(kinds: &[AxisKind]) -> impl Fn(AxisKind) -> Outstanding + use<'_> { + move |kind| { + if kinds.contains(&kind) { + Outstanding::Unsent + } else { + Outstanding::Nothing + } + } + } + + fn brightness() -> Control { + Control::Value(Axis { + kind: AxisKind::Brightness, min: 0.0, max: 255.0, step: 1.0, current: None, - }; + }) + } + + /// A colour that went out and the colour that came back, each as + /// `(hue, saturation)`. + type RoundTrip = ((f32, f32), (f32, f32)); + + /// A colour surface built by hand, with whatever the echo reports. + fn colour(current: Option<(f32, f32)>) -> Control { + Control::Color { + hue: Axis { + kind: AxisKind::Hue, + min: 0.0, + max: 359.0, + step: 1.0, + current: current.map(|(hue, _)| hue), + }, + saturation: Axis { + kind: AxisKind::Saturation, + min: 0.0, + max: 100.0, + step: 1.0, + current: current.map(|(_, saturation)| saturation), + }, + } + } + + #[test] + fn a_control_clamps_before_building_an_action() { + let control = brightness(); + + assert_eq!( + control.action(at(&[(AxisKind::Brightness, 300.0)])), + Some(ActionKind::SetBrightness(255)) + ); + assert_eq!( + control.action(at(&[(AxisKind::Brightness, -20.0)])), + Some(ActionKind::SetBrightness(0)) + ); + assert_eq!( + control.action(at(&[(AxisKind::Brightness, 128.0)])), + Some(ActionKind::SetBrightness(128)) + ); + } + + /// A gesture that did not touch this control has nothing for it to + /// send. That is what keeps a brightness drag on a colour bulb from + /// also restating the colour. + #[test] + fn a_control_the_gesture_did_not_move_sends_nothing() { + assert_eq!(brightness().action(at(&[(AxisKind::Hue, 200.0)])), None); + assert_eq!( + colour(None).action(at(&[(AxisKind::Brightness, 128.0)])), + None + ); + } + + /// Both components in one call, and the clamp is what keeps the + /// wheel single-valued: a hue past the top lands on 359 rather than + /// wrapping to 0, so nothing downstream has to reason about which + /// end of the field a value came from. + #[test] + fn a_colour_surface_sends_both_components_and_never_leaves_the_wheel() { + let field = colour(None); + let hs = |hue: u16, saturation: u8| Some(ActionKind::SetHs { hue, saturation }); + + assert_eq!( + field.action(at(&[(AxisKind::Hue, 212.0), (AxisKind::Saturation, 85.0)])), + hs(212, 85) + ); + assert_eq!( + field.action(at(&[(AxisKind::Hue, 400.0), (AxisKind::Saturation, 140.0)])), + hs(359, 100) + ); + assert_eq!( + field.action(at(&[(AxisKind::Hue, -1.0), (AxisKind::Saturation, -1.0)])), + hs(0, 0) + ); + } + + /// Half a colour cannot go on the wire, because `hs_color` is one + /// parameter carrying two numbers: there is no way to name the hue + /// without also naming a saturation, and inventing one would send a + /// colour the user did not pick. + #[test] + fn a_colour_surface_sends_nothing_for_half_a_gesture() { + let field = colour(Some((40.0, 100.0))); + + assert_eq!(field.action(at(&[(AxisKind::Hue, 212.0)])), None); + assert_eq!(field.action(at(&[(AxisKind::Saturation, 85.0)])), None); + } + + /// Every `(sent, echoed)` pair below was produced by running Home + /// Assistant's own `homeassistant.util.color`, not by any arithmetic + /// of Snapdash's: + /// + /// ```text + /// rgb = color_hs_to_RGB(hue, saturation) + /// echoed = color_RGB_to_hs(*rgb) + /// ``` + /// + /// That matters more than it looks. An expected value recomputed the + /// way the code computes it can never disagree with the code, so a + /// table generated by this module's `hs_to_rgb8` would assert + /// nothing at all. + /// + /// Both eras are here because both are running in people's houses. + /// Home Assistant changed `color_hsv_to_RGB` from truncating to + /// rounding between 2025.1 and the 2024.7 before it, which moves + /// whole rows: hue 59 at saturation 1 comes back as 60 from one and + /// as 40 from the other. Every row of both must confirm. + const GOLDEN_ECHOES: &[RoundTrip] = &[ + // Home Assistant 2025.1 and later, which rounds. + ((0.0, 100.0), (0.0, 100.0)), // rgb(255, 0, 0) + ((59.0, 1.0), (60.0, 1.176)), // rgb(255, 255, 252) + ((0.0, 80.0), (0.0, 80.0)), // rgb(255, 51, 51) + ((132.0, 100.0), (132.0, 100.0)), // rgb(0, 255, 51) + ((200.0, 100.0), (200.0, 100.0)), // rgb(0, 170, 255) + ((212.0, 85.0), (212.074, 85.098)), // rgb(38, 139, 255) + ((240.0, 3.0), (240.0, 3.137)), // rgb(247, 247, 255) + ((300.0, 50.0), (300.0, 49.804)), // rgb(255, 128, 255) + ((359.0, 100.0), (359.059, 100.0)), // rgb(255, 0, 4) + ((359.0, 1.0), (0.0, 1.176)), // rgb(255, 252, 252) + ((45.0, 2.0), (48.0, 1.961)), // rgb(255, 254, 250) + ((180.0, 7.0), (180.0, 7.059)), // rgb(237, 255, 255) + ((96.0, 33.0), (95.714, 32.941)), // rgb(205, 255, 171) + // Home Assistant 2024.7 and earlier, which truncates. + ((59.0, 1.0), (40.0, 1.176)), // rgb(255, 254, 252) + ((0.0, 80.0), (0.0, 80.392)), // rgb(255, 50, 50) + ((132.0, 100.0), (131.765, 100.0)), // rgb(0, 255, 50) + ((200.0, 100.0), (200.235, 100.0)), // rgb(0, 169, 255) + ((300.0, 50.0), (300.0, 50.196)), // rgb(255, 127, 255) + ((45.0, 2.0), (40.0, 2.353)), // rgb(255, 253, 249) + ((96.0, 33.0), (96.0, 33.333)), // rgb(204, 255, 170) + ]; + + /// The tolerance, stated as the round trips it has to survive. + /// + /// Read the low-saturation rows first: hue 59 at saturation 1 comes + /// back as 40 or as 60 depending on the release, nineteen degrees + /// out and past any tolerance a hue axis could carry without also + /// confirming a colour the user did not pick. As three bytes they + /// are the same colour, which is the whole argument for comparing + /// there. + #[test] + fn an_echo_from_an_rgb_light_confirms_at_any_saturation() { + for &((hue_sent, saturation_sent), (hue_echoed, saturation_echoed)) in GOLDEN_ECHOES { + let control = colour(Some((hue_echoed, saturation_echoed))); + + assert!( + control.reconciles(sent(&[ + (AxisKind::Hue, hue_sent), + (AxisKind::Saturation, saturation_sent), + ])), + "({hue_sent}, {saturation_sent}) came back as \ + ({hue_echoed}, {saturation_echoed}) and must confirm" + ); + } + } + + /// And no wider than that. Each pair here is two colours a person + /// would call different, so the light answering with the second is + /// the light not having done what it was told - the case the settle + /// window exists for, and one the widget must not paper over by + /// declaring itself confirmed. + /// + /// The eight-bit distances are the reason each one is here: ten + /// degrees of hue at full saturation is 42 levels, one degree is 4, + /// one point of saturation at full hue is 3, and three points of + /// saturation near white is 7. + #[test] + fn a_genuinely_different_colour_does_not_confirm() { + const DIFFERENT: &[RoundTrip] = &[ + ((200.0, 100.0), (210.0, 100.0)), + ((120.0, 100.0), (121.0, 100.0)), + ((200.0, 100.0), (200.0, 99.0)), + ((0.0, 50.0), (180.0, 50.0)), + ((0.0, 100.0), (359.0, 100.0)), + ((59.0, 1.0), (59.0, 4.0)), + ((200.0, 100.0), (200.0, 90.0)), + ]; + + for &((hue_sent, saturation_sent), (hue_echoed, saturation_echoed)) in DIFFERENT { + let control = colour(Some((hue_echoed, saturation_echoed))); + + assert!( + !control.reconciles(sent(&[ + (AxisKind::Hue, hue_sent), + (AxisKind::Saturation, saturation_sent), + ])), + "({hue_sent}, {saturation_sent}) must not be confirmed by \ + ({hue_echoed}, {saturation_echoed})" + ); + } + } + + /// A light that went off, or back into a white mode, reports no + /// colour at all. That is not confirmation of the one it was given, + /// and treating it as one would hand the surface back to Home + /// Assistant while the house is showing something else entirely. + #[test] + fn an_echo_carrying_no_colour_confirms_nothing() { + assert!(!colour(None).reconciles(sent(&[ + (AxisKind::Hue, 212.0), + (AxisKind::Saturation, 85.0), + ]))); + } + + /// Nothing outstanding, nothing to confirm. The caller uses this to + /// decide what to release, so a control with no pending axis has to + /// answer yes and release nothing rather than answer no and hold an + /// entity that is not waiting on anything. + #[test] + fn a_control_with_nothing_in_flight_is_vacuously_confirmed() { + assert!(colour(Some((40.0, 100.0))).reconciles(sent(&[]))); + assert!(brightness().reconciles(sent(&[]))); + } + + /// Half a colour cannot be judged either. Eight-bit RGB needs both + /// components, so a control holding one of them and not the other + /// keeps waiting rather than guessing. + #[test] + fn half_a_colour_in_flight_is_not_confirmed() { + let control = colour(Some((212.074, 85.098))); + + assert!(!control.reconciles(sent(&[(AxisKind::Hue, 212.0)]))); + assert!(!control.reconciles(sent(&[(AxisKind::Saturation, 85.0)]))); + } + + /// And "held with nothing on the wire" is the opposite of "not held", + /// however alike the two look from here. An echo cannot confirm a + /// value that was never sent, whatever it happens to carry, so a + /// control in that state holds - which is what keeps a gesture the + /// entity's throttle has so far swallowed from being released by + /// some other control's echo. + #[test] + fn an_axis_held_with_nothing_on_the_wire_is_not_confirmed() { + assert!(!brightness().reconciles(unsent(&[AxisKind::Brightness]))); + + let control = colour(Some((212.074, 85.098))); + assert!(!control.reconciles(unsent(&[AxisKind::Hue, AxisKind::Saturation]))); - assert_eq!(c.action(300.0), ActionKind::SetBrightness(255)); - assert_eq!(c.action(-20.0), ActionKind::SetBrightness(0)); - assert_eq!(c.action(128.0), ActionKind::SetBrightness(128)); + // Including when the echo carries exactly the colour the user is + // pointing at: it is the echo of somebody else's call, and the + // next window is what will put this one on the wire. + assert!(!control.reconciles(|kind| match kind { + AxisKind::Hue => Outstanding::Unsent, + AxisKind::Saturation => Outstanding::Sent(85.0), + _ => Outstanding::Nothing, + })); } } diff --git a/src/ha/mod.rs b/src/ha/mod.rs index 57c0c89..717b258 100644 --- a/src/ha/mod.rs +++ b/src/ha/mod.rs @@ -9,7 +9,7 @@ pub mod ws; use std::collections::HashMap; -pub use actions::{ActionKind, Capabilities, ContinuousControl, ContinuousKind}; +pub use actions::{ActionKind, Axis, AxisKind, Capabilities, Control, Outstanding}; pub use types::{EntityState, HaConnectionConfig, HaEvent}; /// Runtime state for the HA integration. Owned by `Snapdash` as a single diff --git a/src/ui/chrome.rs b/src/ui/chrome.rs index 426bf12..442bb8c 100644 --- a/src/ui/chrome.rs +++ b/src/ui/chrome.rs @@ -26,19 +26,7 @@ pub fn window_content<'a>( settings: app.config.widget_settings, priority: app.config.priority(entity_id), title: app.display_name(entity_id), - axes: app - .ha - .entities - .get(entity_id) - .map(crate::ha::Capabilities::from_state) - .map(|caps| caps.continuous) - .unwrap_or_default() - .into_iter() - .map(|control| { - let pending = app.pending.shown(entity_id, control.kind); - (control, pending) - }) - .collect(), + controls: app.control_views(entity_id), }) } WindowKind::ReleaseNotes => crate::ui::release_notes::view(app, id), diff --git a/src/ui/colour_field.rs b/src/ui/colour_field.rs new file mode 100644 index 0000000..30a1cb3 --- /dev/null +++ b/src/ui/colour_field.rs @@ -0,0 +1,723 @@ +//! The colour surface: one drag sets hue and saturation together. +//! +//! ## Why a widget of its own +//! +//! `iced::widget::slider` is one-dimensional by construction - its +//! value is a scalar and its event handler reads only the horizontal +//! offset of the cursor - so two of them side by side would be two +//! gestures and two service calls for one colour. And `mouse_area` +//! reports that something was pressed but not *where*: its callbacks +//! carry no cursor position at all, let alone one relative to the +//! widget's bounds, which is the only thing a colour field needs to +//! know. +//! +//! ## What lives here and what does not +//! +//! The mapping from a pointer to a colour is [`colour_at`], a free +//! function of geometry that the event handler calls. Inside the handler +//! it could not be tested at all: reaching it means an event loop, a +//! window and a renderer, and the places it is most likely to be wrong - +//! the corners of the field and the exact extents of the two axes - are +//! the cheapest possible assertions once it is out. +//! +//! The pixels are `ui::colour_texture`'s, and the only thing this file +//! knows about them is that they are laid out the way [`colour_at`] maps +//! a pointer: hue across, saturation down, both inclusive of their ends. +//! The two are one statement of the same mapping made twice, so they +//! share the extents rather than each spelling them out +//! (`docs/adr/0005-the-colour-field-is-a-texture.md`). +//! +//! ## Precision +//! +//! At Small the field is 132 points across 359 degrees, which is 2.72 +//! degrees under every point of movement. Four things answer that, and +//! all four are functions of the current frame rather than modes: +//! [`SATURATION_MAGNET`] pulls the last few points at each end of the +//! saturation axis onto exactly 0 and exactly 100, Shift holds whichever +//! axis has moved less since the press, Alt keeps [`FINE_SCALE`] of the +//! movement, and the wheel nudges a step at a time. +//! +//! What is deliberately absent is an automatic directional lock. One +//! that guesses wrong presents as the control having stopped responding, +//! which is indistinguishable from a defect, and escaping it means +//! releasing and pressing again with nothing anywhere to say so. It is +//! exactly the hidden in-gesture classifier +//! `docs/adr/0001-widget-interaction-model.md` exists to keep out. A held +//! key cannot get stuck and cannot surprise, because letting go of it is +//! the whole of undoing it. + +use iced::advanced::image; +use iced::advanced::layout::{self, Layout}; +use iced::advanced::renderer; +use iced::advanced::widget::{self, Widget, tree}; +use iced::keyboard::{self, Modifiers}; +use iced::{Color, Element, Length, Point, Radians, Rectangle, Size, border, mouse, touch}; + +use crate::ui::colour_texture::{self, MAX_HUE, MAX_SATURATION}; + +/// The precision shortcuts, in the words the field is advertised by. +/// +/// Shift, Alt and the wheel are invisible unless something says they +/// exist, so `ui::entity_window` hangs this off a help icon in the +/// colour control's own label row (#100). It lives here, beside +/// [`constrained`] and [`nudged`], because it is a statement about what +/// they do: a wording that drifts from them is a lie the user is told at +/// the exact moment they went looking for the truth. +/// +/// Four short lines rather than four sentences. The tooltip is bounded +/// by the widget's own window, which is 160 points wide at Small and +/// leaves about 126 for text, and every line here fits inside that at +/// the size `components::tooltip_message` draws them. The precise +/// statements - *which* axis Shift holds, how much finer Alt is - are +/// the README's job, because a tooltip is read in the second before a +/// gesture and not studied. +/// +/// The saturation magnets are deliberately absent. They need no holding +/// down and no discovering: they happen on their own, at the two ends of +/// an axis the user was already dragging towards. +pub const SHORTCUTS: &str = "Shift holds an axis\n\ + Alt drags finer\n\ + Wheel nudges hue\n\ + With Shift, saturation"; + +/// The colour under a pointer, given where the field is and where the +/// gesture started. +/// +/// Hue runs left to right from 0 to [`MAX_HUE`] and saturation top to +/// bottom from 0 to [`MAX_SATURATION`], which is the arrangement +/// `ui::colour_texture` computes its pixels in: white along the top +/// edge, fully saturated along the bottom, red at both ends because the +/// two ends of a wheel are the same colour. +/// +/// Both come back on the whole-number steps the axes carry, so what the +/// readout says, what the pending value holds and what goes on the wire +/// are one number rather than three roundings of one. +/// +/// A cursor outside `bounds` clamps rather than being rejected, and that +/// is the whole of "the drag keeps tracking when the pointer leaves the +/// field": there is no outside to handle, only an edge to sit on. +/// +/// `modifiers` and `pressed_at` are read by [`constrained`], which is +/// where Shift and Alt live. Both are arguments rather than remembered +/// state, and that is the whole of "no state persists across a gesture": +/// every frame is answered from the modifiers of that frame, so a +/// modifier released mid-drag stops applying on the next pointer move +/// with nothing to reset and nothing that can get stuck. +pub fn colour_at( + bounds: Rectangle, + cursor: Point, + modifiers: Modifiers, + pressed_at: Point, +) -> (f32, f32) { + let point = constrained(cursor, modifiers, pressed_at); + + let across = fraction(point.x - bounds.x, bounds.width); + let down = magnetised(point.y - bounds.y, bounds.height); + + ((across * MAX_HUE).round(), (down * MAX_SATURATION).round()) +} + +/// The point the held modifiers say the gesture is really at. +/// +/// Both modifiers are answered by moving the point rather than by +/// branching on the colour it produces, and that is what makes them +/// compose: Alt shortens the displacement from the press, Shift throws +/// one component of that displacement away, and holding both does both +/// because the two edits are edits of the same thing. A third branch for +/// "Shift and Alt" would be a third behaviour to get wrong. +fn constrained(cursor: Point, modifiers: Modifiers, pressed_at: Point) -> Point { + let moved = cursor - pressed_at; + + // Alt keeps a quarter of the movement since the press rather than a + // quarter of the position, so the fine gesture carries on from where + // the coarse one had got to instead of teleporting to a quarter of + // the way across the field the moment the key goes down. + let mut point = pressed_at + moved * if modifiers.alt() { FINE_SCALE } else { 1.0 }; + + if modifiers.shift() { + // "Moved less" is measured in points of pointer movement, not in + // degrees and percent. The two are different questions here, + // because the field is twice as wide as it is tall while hue + // spans 359 against saturation's 100: a point of sideways + // movement is worth 2.1 degrees and a point of downward movement + // 1.2 percent, so measuring in units would call almost every + // diagonal drag a hue drag and lock saturation nearly always. + // Degrees and percent are not commensurable anyway - there is no + // honest sense in which 20 degrees is more or less than 12 + // percent - whereas the hand really did move some number of + // points one way and some number the other. The lock is a + // reading of the gesture, so it is measured in what the gesture + // is made of. + // + // A tie locks saturation, so that the one drag the lock is + // mostly wanted for - holding a saturation while sweeping the + // hue - is the one a dead heat resolves towards. + if moved.x.abs() >= moved.y.abs() { + point.y = pressed_at.y; + } else { + point.x = pressed_at.x; + } + } + + point +} + +/// The colour `steps` of the wheel move `colour` to: hue, or saturation +/// while Shift is held. +/// +/// Up the wheel is up the axis on both, rather than up the wheel being +/// up the *field*. The wheel nudges a number the readout is showing, not +/// the marker, and "scroll up for more" is what every other control that +/// answers a wheel does. Reading it spatially would make Shift and the +/// wheel upwards desaturate, which is backwards for a control whose +/// whole readout is "hue degrees, saturation percent". +/// +/// The result is on whole steps whichever axis moved, because the two +/// leave together as one `hs_color` and neither the readout nor the +/// service call has anywhere to put a fraction. +fn nudged(colour: (f32, f32), steps: f32, modifiers: Modifiers) -> (f32, f32) { + let (hue, saturation) = (colour.0.round(), colour.1.round()); + + if modifiers.shift() { + (hue, (saturation + steps).clamp(0.0, MAX_SATURATION)) + } else { + ((hue + steps).clamp(0.0, MAX_HUE), saturation) + } +} + +/// How many wheel detents a scroll event is worth, which may well be a +/// fraction of one. +/// +/// The vertical component when there is one and the horizontal one +/// otherwise, because the field has exactly one meaning for a scroll and +/// the modifier rather than the direction of the swipe chooses the axis +/// it lands on. Falling back on the horizontal component is not +/// tidiness: macOS moves a shift-held wheel onto it, so on that platform +/// it carries the entire "Shift and wheel nudges saturation" gesture. +/// `iced::widget::scrollable` compensates for the same thing by swapping +/// the two, which is the same fact stated for a widget that has two +/// directions to tell apart. +/// +/// Preferring the vertical component rather than adding the two is what +/// keeps the sideways wobble of a two-finger swipe from cancelling the +/// swipe out. +fn notches(delta: mouse::ScrollDelta) -> f32 { + let (x, y, per_notch) = match delta { + mouse::ScrollDelta::Lines { x, y } => (x, y, 1.0), + mouse::ScrollDelta::Pixels { x, y } => (x, y, PIXELS_PER_NOTCH), + }; + + (if y == 0.0 { x } else { y }) / per_notch +} + +/// Pixels of a precise scroll that make one detent. +/// +/// A trackpad has no detents to report, so it reports the distance the +/// fingers travelled and leaves the quantising to whoever wants it. Sixty +/// is what iced's own scrollable calls a line, so one step of the colour +/// field costs the same swipe as one line of scrolling anything else - +/// the hand already knows how far that is. +/// +/// Erring coarse is deliberate. A trackpad emits scroll events at the +/// refresh rate, and every step this widget hands out is a `light.turn_on` +/// that the throttle in `app::pending` does not cover, because a nudge +/// is released as soon as it is made. A generous notch is what keeps a +/// flick over the field from being a hundred service calls. +const PIXELS_PER_NOTCH: f32 = 60.0; + +/// Where a colour sits in the field, which is [`colour_at`] read +/// backwards and how the marker finds its place. +fn position_of(bounds: Rectangle, hue: f32, saturation: f32) -> Point { + Point::new( + bounds.x + fraction(hue, MAX_HUE) * bounds.width, + bounds.y + fraction(saturation, MAX_SATURATION) * bounds.height, + ) +} + +/// How far down the saturation axis an offset sits, as 0..=1, with the +/// two ends of the axis pulling the last [`SATURATION_MAGNET`] points +/// onto themselves. +/// +/// Only saturation has magnets. Its ends are two colours a user asks for +/// by name - white and fully saturated - and both are one pixel wide +/// without help. Hue's ends are not: 0 and 359 are the same red, so +/// there is nothing at either end worth snapping to, and a magnet there +/// would only make the reds harder to tell apart. +fn magnetised(offset: f32, extent: f32) -> f32 { + // Asked before either edge, because a field with no extent has no + // two edges to be near: without this the bottom test would answer 1 + // for any offset at all and a degenerate layout would report full + // saturation. + if extent <= 0.0 { + return 0.0; + } + + if offset <= SATURATION_MAGNET { + return 0.0; + } + + if offset >= extent - SATURATION_MAGNET { + return 1.0; + } + + fraction(offset, extent) +} + +/// How far along an extent an offset sits, as 0..=1. +/// +/// A zero extent answers 0 rather than dividing by it. That is a +/// degenerate layout rather than a real one, and the alternative is a +/// NaN travelling all the way to a service call. +fn fraction(offset: f32, extent: f32) -> f32 { + if extent <= 0.0 { + return 0.0; + } + + (offset / extent).clamp(0.0, 1.0) +} + +/// How much of the pointer's movement Alt keeps. +/// +/// A quarter, which is what the field needs to be precise enough at +/// Small: 132 points across 359 degrees is 2.72 degrees per point, and a +/// quarter of that is 0.68 - finer than the eye can tell two hues apart, +/// so the axis stops being the limit. A HiDPI display has already halved +/// the coarse figure for free, because the cursor arrives in logical +/// coordinates before anything is cast down to a step. +const FINE_SCALE: f32 = 0.25; + +/// How near an end of the saturation axis a pointer has to be for the +/// axis to snap onto it, in logical points of the field. +/// +/// Points and not a fraction of the height, deliberately. The field is +/// 66 points tall at Small and 106 at Large, so a fractional magnet +/// would be a full half again as deep on the large one - the same +/// gesture would ask for a different steadiness at each preset, for no +/// reason the hand can account for. Points make the magnet the same +/// physical size everywhere, which is where the steadiness actually +/// lives. +/// +/// Three points is about the slop in a deliberate mouse movement, and it +/// is cheap: at Small it is 4.5 percent, so the axis gives up the four +/// steps inside each end and keeps the other ninety-two. Alt does not +/// win those eight back, because it scales the movement and not the +/// magnet - the wheel does, a step at a time, which is what the wheel is +/// for. +const SATURATION_MAGNET: f32 = 3.0; + +/// Radius of the ring drawn where the colour sits. +const MARKER_RADIUS: f32 = 6.0; + +/// Thickness of the ring's bright inner stroke. +const MARKER_STROKE: f32 = 2.0; + +/// Corner radius of the field itself, following the rounded language of +/// the card it sits in without competing with it. +const FIELD_RADIUS: f32 = 8.0; + +/// How a colour field is painted. +/// +/// Passed in rather than read from a palette here so the widget stays a +/// widget: it knows about pointers and pixels and nothing about themes. +#[derive(Debug, Clone, Copy)] +pub struct Style { + /// What sits under the spectrum. + /// + /// The texture is opaque, so on a settled frame this shows only + /// through the rounded corners. It matters on the first frame a + /// field is drawn: a renderer is entitled to upload an image handle + /// in the background and draw nothing until it is resident, and a + /// hole the shape of the field flashing into the card is worse than + /// a plate of the card's own colour doing so. + pub fill: Color, + /// The ring drawn at the current colour. + pub marker: Color, + /// A darker ring just outside the bright one, so the marker stays + /// visible over a pale part of the field. + pub marker_shadow: Color, + /// How much of the field's presence to keep, as 0..=1. + /// + /// Below 1 the spectrum recedes towards the card behind it, which is + /// how an absent colour reads: the same statement a slider makes by + /// fading its rail, and made here by fading the surface, because the + /// surface is this control's rail (#94). + /// + /// It is the caller's number rather than something inferred from + /// `colour` being `None`, so that the field, the label above it and + /// every slider in the same card dim by one shared constant instead + /// of by two mechanisms that can drift apart. + pub opacity: f32, +} + +/// A two-dimensional colour surface: hue across, saturation down. +pub struct ColorField<'a, Message> { + /// The colour to mark, or `None` when Home Assistant is reporting + /// none - the light is off, or sitting in a white mode. The field is + /// still drawn and still operable; only the mark that would claim a + /// colour goes away, exactly as a null axis renders without a knob + /// (`docs/adr/0006-a-null-axis-renders-as-absent.md`). + colour: Option<(f32, f32)>, + height: f32, + style: Style, + on_change: Box Message + 'a>, + on_release: Message, +} + +/// What the widget has to remember between events. +/// +/// `pressed_at` is both the "is a drag in progress" flag and the origin +/// #07 constrains against, which is why it is a point and not a bool. +/// +/// `modifiers` is here because `Widget::update` is not given them: they +/// arrive as their own keyboard event, and a widget that wants to know +/// whether shift is down while the mouse moves has to have been +/// listening. +/// +/// What is deliberately *not* here is any record of what a modifier did +/// last frame - no locked axis, no origin the lock was taken against +/// beyond the press itself. [`colour_at`] is handed the modifiers of the +/// frame it is answering, so a lock cannot outlive the key that asked +/// for it, and there is no state to be found in the wrong position by a +/// user who released Shift while the pointer was still moving. +/// +/// `scrolled` is the one exception and is not a decision: it is the +/// remainder of a detent that a precise scrolling device has not yet +/// finished paying for. +#[derive(Debug, Default)] +struct State { + pressed_at: Option, + modifiers: Modifiers, + scrolled: f32, +} + +/// A colour field bound to `colour`, `height` tall and as wide as it is +/// given. +/// +/// The width is filled rather than fixed so the field is exactly as wide +/// as the slider tracks above it, whatever the card's padding is; +/// `WidgetSize::colour_field_size` states the pair the window grows by. +pub fn colour_field<'a, Message>( + colour: Option<(f32, f32)>, + height: f32, + style: Style, + on_change: impl Fn(f32, f32) -> Message + 'a, + on_release: Message, +) -> ColorField<'a, Message> { + ColorField { + colour, + height, + style, + on_change: Box::new(on_change), + on_release, + } +} + +/// The renderer a colour field needs: one that can draw a raster image, +/// which is a strictly stronger requirement than the plain +/// `renderer::Renderer` the marker and the fill would have been happy +/// with. Every backend iced ships satisfies it once the crate asks for +/// the image feature, `tiny-skia` included, which is the whole reason +/// the field is a texture and not a shader. +impl Widget for ColorField<'_, Message> +where + Message: Clone, + Renderer: image::Renderer, +{ + fn size(&self) -> Size { + Size { + width: Length::Fill, + height: Length::Fixed(self.height), + } + } + + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + + fn state(&self) -> tree::State { + tree::State::new(State::default()) + } + + fn layout( + &mut self, + _tree: &mut widget::Tree, + _renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + layout::atomic(limits, Length::Fill, Length::Fixed(self.height)) + } + + fn update( + &mut self, + tree: &mut widget::Tree, + event: &iced::Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + _renderer: &Renderer, + _clipboard: &mut dyn iced::advanced::Clipboard, + shell: &mut iced::advanced::Shell<'_, Message>, + _viewport: &Rectangle, + ) { + let state = tree.state.downcast_mut::(); + let bounds = layout.bounds(); + + // Publishing only on a change keeps a stationary pointer from + // restating the same colour every frame. The widget's own idea of + // the colour moves with it, because the application's answer + // comes back a frame later and would otherwise re-arm every one + // of those messages. + // + // The verdict comes back for the wheel's sake: a nudge that ran + // into the end of an axis has nothing to release. + let mut change = |colour: &mut Option<(f32, f32)>, next: (f32, f32)| { + if *colour == Some(next) { + return false; + } + + *colour = Some(next); + shell.publish((self.on_change)(next.0, next.1)); + true + }; + + // A touch is read through `cursor` rather than through the + // position the touch event carries, which looks like it would + // leave a touchscreen with no mouse unable to press the field at + // all. It does not: iced's winit shell sets its cursor position + // from `WindowEvent::Touch` as well as from `CursorMoved`, so on + // a finger event the cursor *is* the finger, and it is already + // in logical coordinates while `touch::Event` is not. + // + // What this does give up is multi-touch. The cursor is wherever + // the most recent finger went, so a second finger on the field + // moves the same gesture rather than starting its own. Snapdash + // has one colour surface open at a time and one marker to put + // somewhere, so there is nothing a second finger could mean. + match event { + iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) + | iced::Event::Touch(touch::Event::FingerPressed { .. }) => { + if let Some(position) = cursor.position_over(bounds) { + state.pressed_at = Some(position); + change( + &mut self.colour, + colour_at(bounds, position, state.modifiers, position), + ); + + // Captured so the press does not also reach the card + // behind, which would start dragging the window + // (`docs/adr/0001-widget-interaction-model.md`). + shell.capture_event(); + } + } + + iced::Event::Mouse(mouse::Event::CursorMoved { .. }) + | iced::Event::Touch(touch::Event::FingerMoved { .. }) => { + // `land()` because a pointer that has left the widget's + // layer still belongs to this drag: the gesture is owned + // by the press, not by what happens to be under the + // cursor now. + if let (Some(pressed_at), Some(position)) = + (state.pressed_at, cursor.land().position()) + { + change( + &mut self.colour, + colour_at(bounds, position, state.modifiers, pressed_at), + ); + shell.capture_event(); + } + } + + iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) + | iced::Event::Touch(touch::Event::FingerLifted { .. }) + | iced::Event::Touch(touch::Event::FingerLost { .. }) => { + if state.pressed_at.take().is_some() { + shell.publish(self.on_release.clone()); + } + } + + iced::Event::Mouse(mouse::Event::WheelScrolled { delta }) => { + if !cursor.is_over(bounds) { + return; + } + + // A detent at a time, with the remainder kept: a precise + // device reports the distance the fingers travelled + // rather than detents, and emits at the refresh rate, so + // spending every event as a step would make one flick + // over the field a hundred service calls. + state.scrolled += notches(*delta); + let steps = state.scrolled.trunc(); + state.scrolled -= steps; + + // A field with no colour has nothing to nudge. That is + // not the same as being inert: a press names a value + // outright and is how an absent axis is given one + // (`docs/adr/0006-a-null-axis-renders-as-absent.md`), + // whereas a wheel can only offer a number to add to one + // that is not there. + // + // The release goes out with the change because a nudge is + // a whole interaction and not the middle of one. + // `app::pending` throttles what a gesture puts on the + // wire and flushes it on release, so a change with no + // release would leave the last nudge to be swallowed by + // the throttle and the pending value with no settle + // deadline - a widget showing a colour the house never + // received, with nothing left to correct it. + if let Some(colour) = self.colour + && steps != 0.0 + && change(&mut self.colour, nudged(colour, steps, state.modifiers)) + { + shell.publish(self.on_release.clone()); + } + + // Captured whether or not this event turned into a step: + // the field owns the wheel over itself, and a scroll it + // has only half consumed still must not also reach + // whatever is behind it. + shell.capture_event(); + } + + iced::Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => { + state.modifiers = *modifiers; + } + + _ => {} + } + } + + fn mouse_interaction( + &self, + tree: &widget::Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + _viewport: &Rectangle, + _renderer: &Renderer, + ) -> mouse::Interaction { + let state = tree.state.downcast_ref::(); + + if state.pressed_at.is_some() || cursor.is_over(layout.bounds()) { + mouse::Interaction::Crosshair + } else { + mouse::Interaction::None + } + } + + fn draw( + &self, + _tree: &widget::Tree, + renderer: &mut Renderer, + _theme: &Theme, + _style: &renderer::Style, + layout: Layout<'_>, + _cursor: mouse::Cursor, + _viewport: &Rectangle, + ) { + let bounds = layout.bounds(); + + renderer.fill_quad( + renderer::Quad { + bounds, + border: border::rounded(FIELD_RADIUS), + ..renderer::Quad::default() + }, + self.style.fill, + ); + + // The spectrum, drawn into exactly the bounds the marker is + // placed in. `WidgetSize::colour_field_size` is a slider track + // wide and half as tall at every preset, and the texture is + // computed at 2:1, so this is a uniform scale down in both axes + // and a column of the texture stays a column on screen. + // + // `clip_bounds` is the same rectangle, because the border radius + // is applied to the clip rather than to the image: the corners + // are rounded by clipping the surface, not by rounding it. + // + // Only `wgpu` honours that radius. `iced_tiny_skia` carries the + // field through its layer and then never reads it, clipping the + // raster to a plain rectangle, so on the software fallback the + // spectrum has square corners sitting on a rounded plate. It is + // set anyway, because it is right where it is read and costs + // nothing where it is not, and the divergence is four corners of + // a cosmetic radius rather than anything the widget claims about + // the light. Opacity, which is the part that carries meaning, is + // honoured by both. + // + // Not snapped to the pixel grid. Snapping would move the surface + // by up to half a physical pixel relative to the marker, whose + // position comes from these unsnapped bounds, and the marker + // agreeing with the colour under it is the entire job here. + // Nothing is lost by leaving it off, because a smooth gradient + // has no hard edge for the pixel grid to shimmer against. + renderer.draw_image( + image::Image { + handle: colour_texture::handle(), + filter_method: image::FilterMethod::Linear, + rotation: Radians(0.0), + border_radius: FIELD_RADIUS.into(), + opacity: self.style.opacity, + snap: false, + }, + bounds, + bounds, + ); + + let Some((hue, saturation)) = self.colour else { + return; + }; + + let centre = position_of(bounds, hue, saturation); + + // Clipped to the field rather than inset into it, so the ring is + // centred on the colour it names right up to the edges. Inset, a + // marker at saturation 0 would sit a whole radius below the top + // row and name a colour the field is not showing there - and it + // would draw over the label above, which is outside these bounds. + renderer.with_layer(bounds, |renderer| { + renderer.fill_quad( + renderer::Quad { + bounds: square_at(centre, MARKER_RADIUS + 1.0), + border: border::rounded(MARKER_RADIUS + 1.0) + .color(self.style.marker_shadow) + .width(1.0), + ..renderer::Quad::default() + }, + Color::TRANSPARENT, + ); + + renderer.fill_quad( + renderer::Quad { + bounds: square_at(centre, MARKER_RADIUS), + border: border::rounded(MARKER_RADIUS) + .color(self.style.marker) + .width(MARKER_STROKE), + ..renderer::Quad::default() + }, + Color::TRANSPARENT, + ); + }); + } +} + +/// The box a circle of `radius` centred on `centre` occupies. +fn square_at(centre: Point, radius: f32) -> Rectangle { + Rectangle::new( + Point::new(centre.x - radius, centre.y - radius), + Size::new(radius * 2.0, radius * 2.0), + ) +} + +impl<'a, Message, Theme, Renderer> From> + for Element<'a, Message, Theme, Renderer> +where + Message: Clone + 'a, + Theme: 'a, + Renderer: image::Renderer + 'a, +{ + fn from(field: ColorField<'a, Message>) -> Self { + Self::new(field) + } +} + +#[cfg(test)] +mod tests; diff --git a/src/ui/colour_field/tests.rs b/src/ui/colour_field/tests.rs new file mode 100644 index 0000000..79e424c --- /dev/null +++ b/src/ui/colour_field/tests.rs @@ -0,0 +1,608 @@ +//! Seam S4: the pointer-to-colour mapping, on its own. +//! +//! Every expectation is stated from the field's geometry - hue across +//! from 0 to 359, saturation down from 0 to 100 - and never by running +//! the mapping's own arithmetic against itself. +//! +//! One test at the bottom is not S4 at all: it drives `Widget::update` +//! to say that a scroll event reaches the widget. That belongs at S2 by +//! rights, but S2 drives `Snapdash::update` with messages and a widget +//! event has no message to arrive as - the whole question is whether the +//! widget turns one into the other. So it is asked here, of the widget, +//! with a bare tree and no window. + +use super::*; + +/// A Normal-preset field, placed away from the origin so a mapping that +/// forgot to subtract the field's own position cannot pass. +fn field() -> Rectangle { + Rectangle::new(Point::new(24.0, 90.0), Size::new(172.0, 86.0)) +} + +/// The four corners and the centre, which is where a flipped axis, a +/// swapped pair or an off-by-one in the extents shows up. +/// +/// The centre is 180 and not 179.5 because the wheel stops one degree +/// short of the full turn - 359 has no exact midpoint in whole degrees, +/// and the axis carries whole degrees. +#[test] +fn the_corners_and_the_centre_name_the_colours_the_field_shows_there() { + let bounds = field(); + let at = |x: f32, y: f32| { + colour_at( + bounds, + Point::new(x, y), + Modifiers::default(), + Point::ORIGIN, + ) + }; + + assert_eq!(at(bounds.x, bounds.y), (0.0, 0.0), "top left is white"); + assert_eq!( + at(bounds.x + bounds.width, bounds.y), + (MAX_HUE, 0.0), + "top right is the far end of the wheel, still white" + ); + assert_eq!( + at(bounds.x, bounds.y + bounds.height), + (0.0, MAX_SATURATION), + "bottom left is pure red" + ); + assert_eq!( + at(bounds.x + bounds.width, bounds.y + bounds.height), + (MAX_HUE, MAX_SATURATION), + "bottom right is the far end, fully saturated" + ); + assert_eq!( + at( + bounds.x + bounds.width / 2.0, + bounds.y + bounds.height / 2.0 + ), + (180.0, 50.0), + "the centre of the field" + ); +} + +/// The whole of "the drag keeps tracking when the pointer leaves the +/// field". A cursor past an edge is not ignored and does not wrap; it +/// names the colour on the edge it went past, so a fast drag out of the +/// widget leaves the light on the last colour the user actually pointed +/// at rather than wherever the gesture happened to be abandoned. +#[test] +fn a_cursor_outside_the_field_clamps_to_its_edge() { + let bounds = field(); + let at = |x: f32, y: f32| { + colour_at( + bounds, + Point::new(x, y), + Modifiers::default(), + Point::ORIGIN, + ) + }; + + assert_eq!(at(bounds.x - 400.0, bounds.y - 400.0), (0.0, 0.0)); + assert_eq!( + at( + bounds.x + bounds.width + 400.0, + bounds.y + bounds.height + 400.0 + ), + (MAX_HUE, MAX_SATURATION) + ); + // One axis out and the other in: the axis still inside must not be + // dragged to an edge with it. + assert_eq!( + at(bounds.x - 400.0, bounds.y + bounds.height / 2.0), + (0.0, 50.0) + ); +} + +/// The axes carry whole steps, so the mapping hands back whole steps. +/// Anything else would leave the readout rounding one way, the pending +/// value holding another and the service call truncating to a third. +#[test] +fn every_point_in_the_field_names_a_whole_step() { + let bounds = field(); + + for x in 0..=bounds.width as u32 { + for y in 0..=bounds.height as u32 { + let (hue, saturation) = colour_at( + bounds, + Point::new(bounds.x + x as f32, bounds.y + y as f32), + Modifiers::default(), + Point::ORIGIN, + ); + + assert_eq!(hue, hue.trunc(), "hue at ({x}, {y})"); + assert_eq!(saturation, saturation.trunc(), "saturation at ({x}, {y})"); + assert!((0.0..=MAX_HUE).contains(&hue), "hue at ({x}, {y})"); + assert!( + (0.0..=MAX_SATURATION).contains(&saturation), + "saturation at ({x}, {y})" + ); + } + } +} + +/// A layout that gave the field no room must not produce a NaN, because +/// a NaN here travels all the way to a service call. +#[test] +fn a_field_with_no_extent_names_a_colour_anyway() { + let bounds = Rectangle::new(Point::ORIGIN, Size::new(0.0, 0.0)); + + assert_eq!( + colour_at( + bounds, + Point::new(10.0, 10.0), + Modifiers::default(), + Point::ORIGIN + ), + (0.0, 0.0) + ); +} + +/// Shift locks the axis that has moved less since the press, and the +/// locked axis holds the value the press gave it rather than freezing +/// wherever the pointer happened to cross. +/// +/// Both directions are checked from one press, because a lock that only +/// ever holds saturation would pass a test that only ever drags sideways +/// - and sideways is the drag this feature is mostly wanted for. +#[test] +fn shift_locks_the_axis_that_has_moved_less_since_the_press() { + let bounds = field(); + // A quarter across and halfway down: hue 90, saturation 50. + let pressed_at = Point::new(bounds.x + 43.0, bounds.y + 43.0); + + // Mostly sideways, to halfway across and 60% down. + let sideways = Point::new(bounds.x + 86.0, bounds.y + 51.6); + + assert_eq!( + colour_at(bounds, sideways, Modifiers::default(), pressed_at), + (180.0, 60.0), + "unlocked, the pointer names both axes" + ); + assert_eq!( + colour_at(bounds, sideways, Modifiers::SHIFT, pressed_at), + (180.0, 50.0), + "saturation moved less, so it stays where the press put it" + ); + + // Mostly downwards, to a quarter across plus a little and 75% down. + let downwards = Point::new(bounds.x + 47.3, bounds.y + 64.5); + + assert_eq!( + colour_at(bounds, downwards, Modifiers::default(), pressed_at), + (99.0, 75.0), + "unlocked, the pointer names both axes" + ); + assert_eq!( + colour_at(bounds, downwards, Modifiers::SHIFT, pressed_at), + (90.0, 75.0), + "hue moved less, so it stays where the press put it" + ); +} + +/// Saturation reaches exactly 0 and exactly 100 without the pointer +/// having to reach the very edge, so "keep this fully saturated while I +/// change the hue" does not need a steady hand. +/// +/// The magnet is stated in points of the field rather than as a fraction +/// of it, so the assertions here are in points too: two points inside +/// the edge snaps, and a little further in does not - the axis still has +/// to be able to say 5 and 95. +#[test] +fn saturation_reaches_exactly_zero_and_one_hundred_near_the_edges() { + let bounds = field(); + let at = |y: f32| { + colour_at( + bounds, + Point::new(bounds.x + 86.0, y), + Modifiers::default(), + Point::ORIGIN, + ) + }; + + assert_eq!( + at(bounds.y + 2.0), + (180.0, 0.0), + "two points below the top edge is white, and the hue is untouched" + ); + assert_eq!( + at(bounds.y + bounds.height - 2.0), + (180.0, 100.0), + "two points above the bottom edge is fully saturated" + ); + + // Past the magnet the axis is itself again. Without this the magnet + // would be indistinguishable from an axis that simply cannot express + // the saturations near its ends. + assert_eq!( + at(bounds.y + 4.3), + (180.0, 5.0), + "just past the magnet, saturation is read off the field again" + ); + assert_eq!(at(bounds.y + bounds.height - 4.3), (180.0, 95.0)); +} + +/// Alt keeps a quarter of the movement *since the press*, not a quarter +/// of the position in the field. A quarter of the position would jump +/// the colour to somewhere near the left edge the moment the key went +/// down, which is the opposite of a fine adjustment. +/// +/// The second half of this is the marker detaching from the pointer, +/// which is what every tool with a fine-drag modifier does and what +/// makes the modifier legible: the ring stops following the hand, so the +/// hand can see it is being listened to differently. +#[test] +fn alt_scales_movement_to_a_quarter_of_it_relative_to_the_press() { + let bounds = field(); + // A quarter across and halfway down: hue 90, saturation 50. + let pressed_at = Point::new(bounds.x + 43.0, bounds.y + 43.0); + // Half the field to the right and 40% of it down. + let cursor = Point::new(bounds.x + 129.0, bounds.y + 77.4); + + assert_eq!( + colour_at(bounds, cursor, Modifiers::default(), pressed_at), + (269.0, 90.0), + "unscaled, the colour is read straight off the pointer" + ); + assert_eq!( + colour_at(bounds, cursor, Modifiers::ALT, pressed_at), + (135.0, 60.0), + "a quarter of the way from the press towards the pointer" + ); + + // The marker is drawn from the colour, so a colour that is no longer + // the pointer's puts the marker somewhere the pointer is not. Stated + // against the geometry: an eighth of the field to the right of the + // press and a tenth of it down. + let marker = position_of(bounds, 135.0, 60.0); + + assert!( + (marker.x - (pressed_at.x + 21.5)).abs() <= bounds.width / MAX_HUE, + "{marker:?}" + ); + assert!( + (marker.y - (pressed_at.y + 8.6)).abs() <= bounds.height / MAX_SATURATION, + "{marker:?}" + ); +} + +/// The wheel nudges hue a step at a time, and Shift moves it onto +/// saturation. It is the only way to reach a colour the field cannot +/// resolve under a pointer at all - the eight saturations the magnets +/// give up, and any single degree of hue at the Small preset. +#[test] +fn the_wheel_nudges_hue_by_a_step_and_shift_and_the_wheel_nudges_saturation() { + assert_eq!( + nudged((100.0, 50.0), 1.0, Modifiers::default()), + (101.0, 50.0), + "up the wheel is up the axis" + ); + assert_eq!( + nudged((100.0, 50.0), -1.0, Modifiers::default()), + (99.0, 50.0) + ); + assert_eq!( + nudged((100.0, 50.0), 1.0, Modifiers::SHIFT), + (100.0, 51.0), + "shift moves the same nudge onto the other axis" + ); + assert_eq!(nudged((100.0, 50.0), -1.0, Modifiers::SHIFT), (100.0, 49.0)); + + // The ends hold rather than wrapping. Hue is an angle, so wrapping + // is arithmetically defensible and would still be wrong here: the + // axis stops one degree short of the full turn precisely so that it + // stays a plain range, and a wheel that jumped from the far red back + // to the near one would be the only place in the widget where it + // did not. + assert_eq!( + nudged((MAX_HUE, MAX_SATURATION), 1.0, Modifiers::default()), + (MAX_HUE, MAX_SATURATION) + ); + assert_eq!(nudged((0.0, 0.0), -1.0, Modifiers::SHIFT), (0.0, 0.0)); + + // Home Assistant's own reading is not on a step - a light in + // `color_temp` mode reports the colour its white corresponds to, to + // three decimal places - and a nudge has to land on one, or the + // readout, the pending value and the service call would go back to + // being three roundings of one number. Both axes round, because they + // travel as one `hs_color` pair however few of them moved. + assert_eq!( + nudged((28.391, 65.659), 1.0, Modifiers::default()), + (29.0, 66.0) + ); +} + +/// A wheel and a trackpad describe the same gesture in different units, +/// and the field has to answer both in steps. +#[test] +fn a_wheel_s_lines_and_a_trackpad_s_pixels_both_come_out_in_notches() { + let lines = |x, y| notches(mouse::ScrollDelta::Lines { x, y }); + let pixels = |x, y| notches(mouse::ScrollDelta::Pixels { x, y }); + + assert_eq!(lines(0.0, 1.0), 1.0, "one detent of a mouse wheel"); + assert_eq!(lines(0.0, -2.0), -2.0); + + assert_eq!( + pixels(0.0, PIXELS_PER_NOTCH), + 1.0, + "a line's worth of a precise device is a detent" + ); + assert!( + pixels(0.0, 4.0) > 0.0 && pixels(0.0, 4.0) < 1.0, + "a few pixels is a fraction of one, for the caller to accumulate" + ); + + // macOS puts a shift-held wheel on the horizontal axis, so the + // horizontal component is not noise to be discarded - on that + // platform it is the whole of the shift-and-wheel gesture. + assert_eq!(lines(1.0, 0.0), 1.0, "a wheel macOS has turned sideways"); + // And when there is a vertical component it wins, so the sideways + // wobble of a two-finger swipe cannot cancel the swipe out. + assert_eq!(lines(-1.0, 2.0), 2.0); +} + +/// The modifiers compose: Shift and Alt together lock and refine at +/// once, and the composed answer is neither of the two taken alone. +/// +/// This is the assertion that says the two are edits of the same point +/// rather than two behaviours with a third one wired up between them. +/// The same press and the same cursor as the two tests above, so all +/// four answers can be read against each other. +#[test] +fn shift_and_alt_together_lock_and_refine_at_once() { + let bounds = field(); + let pressed_at = Point::new(bounds.x + 43.0, bounds.y + 43.0); + let cursor = Point::new(bounds.x + 129.0, bounds.y + 77.4); + + // Sideways by half the field against downwards by 40% of it, so + // saturation is the axis that moved less and the lock holds it at + // the press. Alt takes the hue a quarter of the way instead of all + // of it. + assert_eq!( + colour_at( + bounds, + cursor, + Modifiers::SHIFT | Modifiers::ALT, + pressed_at + ), + (135.0, 50.0) + ); + + // Neither modifier alone, and not the plain answer either. + assert_eq!( + colour_at(bounds, cursor, Modifiers::SHIFT, pressed_at), + (269.0, 50.0) + ); + assert_eq!( + colour_at(bounds, cursor, Modifiers::ALT, pressed_at), + (135.0, 60.0) + ); + assert_eq!( + colour_at(bounds, cursor, Modifiers::default(), pressed_at), + (269.0, 90.0) + ); +} + +/// Letting a modifier go part way through a drag restores plain +/// behaviour on the very next pointer move, with nothing to unwind. +/// +/// The point of the test is the *order*: the same cursor is asked for +/// three times with the modifiers changing underneath it, and the third +/// answer is the first one. That is what says the mapping keeps no +/// record of having been constrained - the failure it guards against is +/// a lock that latches, which reads to a user as the control having +/// stopped responding with no way to tell why, and which is the hidden +/// in-gesture classifier `docs/adr/0001-widget-interaction-model.md` +/// exists to keep out. +#[test] +fn releasing_a_modifier_mid_drag_restores_plain_behaviour_at_once() { + let bounds = field(); + let pressed_at = Point::new(bounds.x + 43.0, bounds.y + 43.0); + let cursor = Point::new(bounds.x + 129.0, bounds.y + 77.4); + + let plain = colour_at(bounds, cursor, Modifiers::default(), pressed_at); + + assert_eq!(plain, (269.0, 90.0), "where the pointer actually is"); + + for held in [ + Modifiers::SHIFT, + Modifiers::ALT, + Modifiers::SHIFT | Modifiers::ALT, + ] { + assert_ne!( + colour_at(bounds, cursor, held, pressed_at), + plain, + "{held:?} did nothing, so letting it go proves nothing" + ); + assert_eq!( + colour_at(bounds, cursor, Modifiers::default(), pressed_at), + plain, + "the frame after {held:?} was let go" + ); + } +} + +/// The marker has to land back where the pointer was, or it would drift +/// away from the colour it names as the user drags. +#[test] +fn the_marker_sits_where_the_pointer_was() { + let bounds = field(); + + for (x, y) in [ + (0.0, 0.0), + (bounds.width, 0.0), + (0.0, bounds.height), + (bounds.width, bounds.height), + (bounds.width / 2.0, bounds.height / 2.0), + ] { + let pointer = Point::new(bounds.x + x, bounds.y + y); + let (hue, saturation) = colour_at(bounds, pointer, Modifiers::default(), Point::ORIGIN); + let marker = position_of(bounds, hue, saturation); + + // Within half a step of a pixel: the colour is quantised to whole + // degrees and whole percent, and the marker sits on the quantised + // colour rather than between two of them. + assert!( + (marker.x - pointer.x).abs() <= bounds.width / MAX_HUE, + "x: {marker:?} against {pointer:?}" + ); + assert!( + (marker.y - pointer.y).abs() <= bounds.height / MAX_SATURATION, + "y: {marker:?} against {pointer:?}" + ); + } +} + +/// What one wheel event made the widget say. +#[derive(Debug, Clone, PartialEq)] +enum Said { + Colour(f32, f32), + Released, +} + +/// Deliver `events` to a colour field showing `colour`, and collect what +/// it published. +/// +/// A widget can be driven without a window: the tree is two public +/// fields the widget itself fills in, the layout is one node placed +/// where `field()` says, and `()` is a renderer and `clipboard::Null` a +/// clipboard. That is the whole harness, which is why this one question +/// is worth asking here rather than being left to a human's hands. +/// +/// `()` is a renderer only under `debug_assertions`, which is where +/// iced's null implementation of the trait lives, so this compiles under +/// `cargo test` and not under `cargo test --release`. CI runs the +/// former. +fn published(colour: Option<(f32, f32)>, events: &[iced::Event], cursor: Point) -> Vec { + let bounds = field(); + let mut widget = colour_field(colour, bounds.height, style(), Said::Colour, Said::Released); + + let mut tree = widget::Tree { + tag: <_ as Widget>::tag(&widget), + state: <_ as Widget>::state(&widget), + children: Vec::new(), + }; + let node = layout::Node::new(bounds.size()).move_to(bounds.position()); + let mut messages = Vec::new(); + + for event in events { + let mut shell = iced::advanced::Shell::new(&mut messages); + + <_ as Widget>::update( + &mut widget, + &mut tree, + event, + Layout::new(&node), + mouse::Cursor::Available(cursor), + &(), + &mut iced::advanced::clipboard::Null, + &mut shell, + &bounds, + ); + } + + messages +} + +/// Any style at all: nothing under test here draws. +fn style() -> Style { + Style { + fill: Color::BLACK, + marker: Color::WHITE, + marker_shadow: Color::BLACK, + opacity: 1.0, + } +} + +/// The one thing about the wheel the pure functions cannot answer: that +/// the event reaches the widget, over the field and not elsewhere, and +/// comes back out as a change the application can act on. +/// +/// The release is published with it because a nudge is a whole +/// interaction rather than the middle of one. `app::pending` throttles +/// what a gesture puts on the wire and flushes it on release; a change +/// with no release would leave the last nudge to be swallowed by the +/// throttle and the pending value with no settle deadline, which is a +/// widget showing a colour the house never received and nothing left to +/// correct it. +#[test] +fn a_wheel_over_the_field_reaches_the_control() { + let bounds = field(); + let over = Point::new(bounds.x + 86.0, bounds.y + 43.0); + let wheel = |x, y| { + iced::Event::Mouse(mouse::Event::WheelScrolled { + delta: mouse::ScrollDelta::Lines { x, y }, + }) + }; + + assert_eq!( + published(Some((100.0, 50.0)), &[wheel(0.0, 1.0)], over), + vec![Said::Colour(101.0, 50.0), Said::Released], + ); + + // Off the field the wheel belongs to whatever is behind it. A colour + // surface that answered a scroll anywhere in the window would change + // the light while the user was scrolling something else. + assert_eq!( + published( + Some((100.0, 50.0)), + &[wheel(0.0, 1.0)], + Point::new(bounds.x - 10.0, bounds.y - 10.0) + ), + vec![], + ); + + // Shift reaches it too, which is the only place the widget's own + // record of the modifiers is exercised. + assert_eq!( + published( + Some((100.0, 50.0)), + &[ + iced::Event::Keyboard(keyboard::Event::ModifiersChanged(Modifiers::SHIFT)), + wheel(0.0, 1.0) + ], + over + ), + vec![Said::Colour(100.0, 51.0), Said::Released], + ); + + // A field with no colour has nothing to nudge. Pressing is how an + // absent axis is given a value, because a press names one outright; + // a wheel can only offer a number to add to one that is not there. + assert_eq!(published(None, &[wheel(0.0, 1.0)], over), vec![]); +} + +/// A precise device reports the distance the fingers moved, not +/// detents, so its events accumulate until they are worth a step +/// instead of each being one. +/// +/// Without this a trackpad would run away: it emits at the refresh rate, +/// so a flick over the field would be a hundred steps and a hundred +/// service calls. +#[test] +fn a_trackpad_s_pixels_accumulate_into_steps() { + let bounds = field(); + let over = Point::new(bounds.x + 86.0, bounds.y + 43.0); + let creep = iced::Event::Mouse(mouse::Event::WheelScrolled { + delta: mouse::ScrollDelta::Pixels { + x: 0.0, + y: PIXELS_PER_NOTCH / 4.0, + }, + }); + + assert_eq!( + published(Some((100.0, 50.0)), std::slice::from_ref(&creep), over), + vec![], + "a quarter of a notch is not a step yet" + ); + + let four = [creep.clone(), creep.clone(), creep.clone(), creep]; + + assert_eq!( + published(Some((100.0, 50.0)), &four, over), + vec![Said::Colour(101.0, 50.0), Said::Released], + "four quarters are one step, and one step only" + ); +} diff --git a/src/ui/colour_texture.rs b/src/ui/colour_texture.rs new file mode 100644 index 0000000..c9d57af --- /dev/null +++ b/src/ui/colour_texture.rs @@ -0,0 +1,160 @@ +//! The colour field's texture: hue across, saturation down, computed +//! once as RGBA bytes and drawn scaled. +//! +//! ## Why a texture and not two gradient quads +//! +//! Because iced's gradients cannot draw this correctly, and the reason +//! is arithmetic rather than taste. +//! +//! Both the quad and the triangle gradient shaders interpolate between +//! stops with `smoothstep` rather than linearly. `smoothstep(t) = +//! 3t^2 - 2t^3` departs from `t` by up to 9.6% of the segment it spans. +//! On the saturation axis that is a ten-point error: at t = 0.211 the +//! shader emits 0.115, so the field would show 11.5% saturation exactly +//! where the marker claims 21%. +//! +//! On the hue axis the natural stops are the six vertices of the colour +//! wheel plus the end of the range, which is seven stops and six +//! segments of about 60 degrees, so the same 9.6% is about 5.8 degrees. +//! It is not spread evenly either: it lands as wide plateaus of pure +//! red, yellow and green sitting on the stops, with the transitions +//! between them compressed. Subdividing is not a way out, because +//! `iced::gradient::Linear` holds at most eight stops in total. +//! +//! A slider whose colours are a few percent off is merely ugly. A +//! colour picker whose marker sits on a different colour from the one +//! it names is wrong, because naming the colour under the marker is the +//! entire job of the widget. That is what disqualifies gradients here +//! rather than just degrading them. +//! +//! ## Why not a wgpu shader +//! +//! It would be exact, and it would be wgpu-only. iced's default +//! features include the `tiny-skia` software renderer, which is what +//! runs on a machine with no usable adapter: a remote desktop session, +//! a VM, an old GPU. A custom shader primitive draws nothing at all +//! there. For an application that is made entirely of these widgets, a +//! hole shaped like "the colour picker is invisible on some machines" +//! is not a hole we can leave. +//! +//! So the field is computed exactly in Rust, once, and drawn scaled. +//! Every backend gets the same pixels. +//! +//! Recorded in `docs/adr/0005-the-colour-field-is-a-texture.md`. + +use std::sync::OnceLock; + +use iced::widget::image::Handle; + +/// Texture width, and therefore the number of distinct hues the field +/// can show. +/// +/// 256 columns over 359 degrees puts each column 1.4 degrees from its +/// neighbour, so the worst a marker can be wrong about its own hue is +/// 0.7 degrees. That is roughly three of 255 levels on the ramping +/// channel, against the 5.8 degrees a gradient would cost. +pub const WIDTH: u32 = 256; + +/// Texture height, and therefore the number of distinct saturations. +/// +/// Half the width, matching the shape of the field on screen: it is +/// drawn about twice as wide as it is tall, so steps of equal size in +/// both directions want half as many rows as columns. 128 rows over 100 +/// percent still lands every saturation the marker can name within 0.4 +/// of a percentage point of a row. +pub const HEIGHT: u32 = 128; + +/// The hue of the rightmost column, in degrees. +/// +/// 359 and not 360, deliberately. Hue is an angle, so 360 is the same +/// red the leftmost column already shows; capping one degree short +/// keeps every column a distinct colour and keeps the axis a plain +/// range rather than a wrapping one. +pub const MAX_HUE: f32 = 359.0; + +/// The saturation of the bottom row, in percent, matching the range +/// Home Assistant's `hs_color` attribute uses. +pub const MAX_SATURATION: f32 = 100.0; + +/// The colour field, as an image handle every caller can draw. +pub fn handle() -> Handle { + static TEXTURE: OnceLock = OnceLock::new(); + + TEXTURE + .get_or_init(|| Handle::from_rgba(WIDTH, HEIGHT, render())) + .clone() +} + +/// The colour field as RGBA bytes, row-major from the top left. +/// +/// Both axes are inclusive of their ends, so the division is by one +/// less than the extent: column 0 is hue 0 and column 255 is +/// [`MAX_HUE`], row 0 is saturation 0 and row 127 is +/// [`MAX_SATURATION`]. Getting that off by one would put the pure hues +/// one column short of the right edge and leave the field unable to +/// name a fully saturated colour at all. +fn render() -> Vec { + let mut pixels = Vec::with_capacity((WIDTH * HEIGHT * 4) as usize); + + for row in 0..HEIGHT { + let saturation = row as f32 / (HEIGHT - 1) as f32; + + for column in 0..WIDTH { + let hue = column as f32 * MAX_HUE / (WIDTH - 1) as f32; + + pixels.extend_from_slice(&pixel(hue, saturation)); + } + } + + pixels +} + +/// One pixel of the field, from HSV with the value axis pinned to 1. +/// +/// Value is pinned because this field sets hue and saturation only. +/// Brightness is its own axis with its own control, and a +/// `light.turn_on` that omits `brightness` leaves the lamp's brightness +/// where the user put it. A field that dimmed towards black down one +/// edge would be showing an axis it does not set. +/// +/// `saturation` is the 0..=1 fraction rather than the 0..=100 percent, +/// because that is the form the HSV arithmetic wants. +fn pixel(hue: f32, saturation: f32) -> [u8; 4] { + let sector = hue / 60.0; + + // The standard formulation: chroma is the full swing between the + // brightest and darkest channel, the ramping channel is chroma + // scaled by how far into the sector the hue sits, and `floor` lifts + // the whole triple so the darkest channel lands on V - C. + let chroma = saturation; + let ramp = chroma * (1.0 - (sector % 2.0 - 1.0).abs()); + let floor = 1.0 - chroma; + + let (red, green, blue) = match sector as u32 { + 0 => (chroma, ramp, 0.0), + 1 => (ramp, chroma, 0.0), + 2 => (0.0, chroma, ramp), + 3 => (0.0, ramp, chroma), + 4 => (ramp, 0.0, chroma), + _ => (chroma, 0.0, ramp), + }; + + [ + level(red + floor), + level(green + floor), + level(blue + floor), + u8::MAX, + ] +} + +/// A 0..=1 colour component as an eight-bit level. +/// +/// Rounded rather than truncated: truncation biases every channel +/// downwards by up to a level, which over a field this size shows up as +/// a visible darkening towards the saturated edge. +fn level(component: f32) -> u8 { + (component * 255.0).round() as u8 +} + +#[cfg(test)] +mod tests; diff --git a/src/ui/colour_texture/tests.rs b/src/ui/colour_texture/tests.rs new file mode 100644 index 0000000..631e5ae --- /dev/null +++ b/src/ui/colour_texture/tests.rs @@ -0,0 +1,148 @@ +//! Every expectation here is stated from the definition of HSV, by +//! hand, and never by re-running the generator's own arithmetic. A test +//! that computes the answer the way the code computes it can only ever +//! agree with the code, including when the code is wrong. + +use super::*; + +/// The rendered field, addressable by column and row. +struct Field(Vec); + +impl Field { + fn render() -> Self { + Self(super::render()) + } + + fn at(&self, x: u32, y: u32) -> [u8; 4] { + let offset = ((y * WIDTH + x) * 4) as usize; + self.0[offset..offset + 4] + .try_into() + .expect("four bytes per pixel") + } +} + +const WHITE: [u8; 4] = [255, 255, 255, 255]; +const OPAQUE: u8 = 255; + +#[test] +fn the_buffer_is_one_opaque_rgba_pixel_per_cell() { + let field = Field::render(); + + assert_eq!(field.0.len(), (WIDTH * HEIGHT * 4) as usize); + assert!( + field.0.chunks_exact(4).all(|pixel| pixel[3] == OPAQUE), + "the field is a solid surface, so no pixel may be see-through" + ); +} + +/// Saturation 0 is white at every hue, whatever the hue is: with S = 0 +/// the HSV chroma is 0, so R = G = B = V, and V is pinned to 1 across +/// the whole field. +#[test] +fn the_top_row_is_pure_white() { + let field = Field::render(); + + for x in 0..WIDTH { + assert_eq!(field.at(x, 0), WHITE, "column {x} of the top row"); + } +} + +/// At hue 0 the HSV definition reduces to R = V and G = B = V(1 - S). +/// With V = 1 that is a straight wash of red into white, and the column +/// runs from saturation 0 at the top to saturation 100 at the bottom. +#[test] +fn the_hue_zero_column_runs_white_to_red() { + let field = Field::render(); + + assert_eq!(field.at(0, 0), WHITE); + assert_eq!(field.at(0, HEIGHT - 1), [255, 0, 0, OPAQUE]); + + for y in 0..HEIGHT { + let saturation = y as f32 / (HEIGHT - 1) as f32; + let washed = (255.0 * (1.0 - saturation)).round() as u8; + + assert_eq!( + field.at(0, y), + [255, washed, washed, OPAQUE], + "row {y} of the hue-zero column" + ); + } +} + +/// The bottom row is saturation 100, where HSV puts the six vertices of +/// the colour wheel at hues 0, 60, 120, 180, 240 and 300. +/// +/// The columns are `round(hue * 255 / 359)`, and they do not land on +/// the vertices exactly, because 359 degrees do not divide evenly into +/// 255 steps. The nearest column is off by at most half a step, which +/// is 359 / 255 / 2 = 0.704 degrees. Inside a 60-degree sector the +/// ramping component covers the full 0..255 range, so 0.704 degrees is +/// worth at most 0.704 / 60 * 255 = 2.99 levels. Hence three. +/// +/// That residue is the honest cost of a 256-wide texture, and it is two +/// orders of magnitude smaller than the 5.8-degree error a seven-stop +/// smoothstep gradient would introduce at the same points. +#[test] +fn the_bottom_row_reaches_the_six_pure_hue_vertices() { + const TOLERANCE: i32 = 3; + + let field = Field::render(); + let bottom = HEIGHT - 1; + + let vertices: [(u32, [u8; 3], &str); 6] = [ + (0, [255, 0, 0], "red at hue 0"), + (43, [255, 255, 0], "yellow at hue 60"), + (85, [0, 255, 0], "green at hue 120"), + (128, [0, 255, 255], "cyan at hue 180"), + (170, [0, 0, 255], "blue at hue 240"), + (213, [255, 0, 255], "magenta at hue 300"), + ]; + + for (x, expected, name) in vertices { + let actual = field.at(x, bottom); + + assert_eq!(actual[3], OPAQUE, "{name}"); + + for channel in 0..3 { + let drift = i32::from(actual[channel]) - i32::from(expected[channel]); + + assert!( + drift.abs() <= TOLERANCE, + "{name}: channel {channel} is {} but should be within {TOLERANCE} of {}", + actual[channel], + expected[channel] + ); + } + } +} + +/// Two points worked out longhand from HSV, one in each half of the +/// saturation axis and in different sectors of the wheel, so that a +/// generator that got a sector boundary or an axis direction wrong +/// cannot slip past the white row and the pure-hue row. +#[test] +fn interior_pixels_match_the_hsv_definition() { + let field = Field::render(); + + // Column 32, row 112. H = 32 * 359 / 255 = 45.051, S = 112 / 127 = + // 0.88189, V = 1. H is in the first sector, so C = SV = 0.88189, + // X = C(1 - |H/60 mod 2 - 1|) = 0.88189 * 0.75085 = 0.66217, and + // m = V - C = 0.11811. (R, G, B) = (C, X, 0) + m = (1, 0.78028, + // 0.11811), which is (255, 199, 30) at eight bits. + assert_eq!(field.at(32, 112), [255, 199, 30, OPAQUE]); + + // Column 192, row 16. H = 192 * 359 / 255 = 270.306, S = 16 / 127 = + // 0.12598, V = 1. H is in the fifth sector, so C = 0.12598, + // X = C(1 - |H/60 mod 2 - 1|) = 0.12598 * 0.50510 = 0.06363, and + // m = 0.87402. (R, G, B) = (X, 0, C) + m = (0.93765, 0.87402, 1), + // which is (239, 223, 255) at eight bits. + assert_eq!(field.at(192, 16), [239, 223, 255, OPAQUE]); +} + +/// The whole point of computing the field once: every widget and every +/// size preset draws the same handle, so the renderer uploads one +/// texture and scales it, rather than one per widget. +#[test] +fn one_texture_serves_every_caller() { + assert_eq!(handle().id(), handle().id()); +} diff --git a/src/ui/entity_window.rs b/src/ui/entity_window.rs index 8f4596c..56883be 100644 --- a/src/ui/entity_window.rs +++ b/src/ui/entity_window.rs @@ -3,8 +3,9 @@ use iced::{Alignment, Element, Length}; use super::components; use crate::app::{EntityWindowState, Message}; -use crate::ha::{ActionKind, ContinuousControl, ContinuousKind}; +use crate::ha::{ActionKind, Axis, AxisKind, Control}; use crate::theme::{Palette, metric}; +use crate::ui::colour_field; use crate::ui::components::IconVisual; use crate::ui::format::format_entity_value; use crate::ui::icon::Icon; @@ -100,17 +101,24 @@ fn confirm_prompt<'a>(entity_id: &str, font: f32, gap: f32, p: Palette) -> Eleme /// those units, so it reads as a percentage. Position is already a /// percentage. A setpoint keeps its own scale, and its step decides /// whether a decimal is worth showing. -fn readout(control: &ContinuousControl, value: f32) -> String { +fn readout(control: &Axis, value: f32) -> String { match control.kind { - ContinuousKind::Brightness => { + AxisKind::Brightness => { let pct = (value / control.max * 100.0).round(); format!("{pct:.0}%") } // Kelvin is the unit users actually see on a bulb's box, so it // is shown as-is rather than rescaled to a percentage. - ContinuousKind::ColorTemp => format!("{:.0}K", value.round()), - ContinuousKind::Position => format!("{:.0}%", value.round()), - ContinuousKind::Temperature => { + AxisKind::ColorTemp => format!("{:.0}K", value.round()), + // Degrees around the wheel and percent of colour, which are the + // units these values are in. Neither is read by a block of its + // own: the colour surface states both at once, in + // [`colour_block`], because a colour is one value with two + // components rather than two values shown together. + AxisKind::Hue => format!("{:.0}°", value.round()), + AxisKind::Saturation => format!("{:.0}%", value.round()), + AxisKind::Position => format!("{:.0}%", value.round()), + AxisKind::Temperature => { if control.step < 1.0 { format!("{value:.1}°") } else { @@ -120,49 +128,129 @@ fn readout(control: &ContinuousControl, value: f32) -> String { } } -fn axis_label(kind: ContinuousKind) -> &'static str { +fn axis_label(kind: AxisKind) -> &'static str { match kind { - ContinuousKind::Brightness => "Brightness", - ContinuousKind::ColorTemp => "White", - ContinuousKind::Temperature => "Target", - ContinuousKind::Position => "Position", + AxisKind::Brightness => "Brightness", + AxisKind::ColorTemp => "White", + // Neither of these heads a block of its own: they are the two + // axes of the colour surface, which is labelled once, as + // "Colour". The names are here because an axis is entitled to + // one, not because anything currently draws them. + AxisKind::Hue => "Hue", + AxisKind::Saturation => "Saturation", + AxisKind::Temperature => "Target", + AxisKind::Position => "Position", + } +} + +/// One control inside an expanded widget. +/// +/// The dispatch that turns a [`Control`] into its own layout. Each +/// variant draws itself; this is the only place that decides which. +fn control_block<'a>( + entity_id: &str, + view: &ControlView, + size: WidgetSize, + p: Palette, +) -> Element<'a, Message> { + let font = size.detail_font(); + let help = shortcut_hint(&view.control); + + match &view.control { + Control::Value(axis) => control_row(entity_id, axis, view.value(0), font, help, p), + Control::Color { .. } => { + colour_block(entity_id, view.value(0), view.value(1), help, size, p) + } + } +} + +/// What a control's precision shortcuts are, or `None` for a control +/// that has none. +/// +/// The one place that decides where the help affordance appears, rather +/// than each block deciding for itself (#100). Only the colour surface +/// implements the shortcuts: `iced::widget::slider` reads the pointer's +/// horizontal offset and nothing else, so the same icon over a +/// brightness rail would promise help that pressing Shift cannot give. +/// +/// Answering with the words instead of a `bool` is what keeps the two +/// facts together. A control that grows shortcuts of its own says so +/// here and describes them in its own module, and the label row picks +/// them up without being touched. +pub fn shortcut_hint(control: &Control) -> Option<&'static str> { + match control { + Control::Value(_) => None, + Control::Color { .. } => Some(colour_field::SHORTCUTS), + } +} + +/// How much of its normal presence a control keeps while nothing is +/// driving the axis it belongs to. +/// +/// Faint enough to read as "not in play" without a second look, and +/// solid enough that the label stays legible: the control is still fully +/// operable, and grabbing it is precisely how the axis gets a value. +const ABSENT_OPACITY: f32 = 0.4; + +/// The same colour, scaled towards transparent. +/// +/// Scaling the existing alpha rather than replacing it keeps a palette's +/// own translucency intact, so a theme that already ships a soft rail +/// does not come back opaque. +fn fade(color: iced::Color, opacity: f32) -> iced::Color { + iced::Color { + a: color.a * opacity, + ..color + } +} + +fn fade_background(background: iced::Background, opacity: f32) -> iced::Background { + match background { + iced::Background::Color(color) => fade(color, opacity).into(), + other => other, } } /// One axis inside an expanded widget: a label with its readout, and the /// slider beneath. /// -/// The slider renders the *pending* value whenever the user is driving -/// it, falling back to what HA reported once the interaction reconciles. -/// Falling back to `min` keeps the slider in range for an entity that -/// reports no value at all, such as an unavailable light with no -/// brightness. See `crate::app::pending`. +/// `value` is what [`ControlView::value`] resolved for this axis, and +/// `None` there means Home Assistant is reporting the axis as null. The +/// row then renders as *absent* rather than as sitting at its minimum: +/// the whole block dims and the knob is not drawn at all. A rail with no +/// knob says "this axis has no value right now" in a way that cannot be +/// misread as "the value is at the minimum", which is exactly what a +/// knob parked hard left over a "0%" readout does say (#94). +/// +/// Nothing about the row's *behaviour* changes. Grabbing it sends the +/// same service call, and for a light that is off `light.turn_on` is +/// also what turns the light on. fn control_row<'a>( entity_id: &str, - control: &ContinuousControl, - pending: Option, + control: &Axis, + value: Option, font: f32, + help: Option<&'static str>, p: Palette, ) -> Element<'a, Message> { - let value = pending - .or(control.current) - .unwrap_or(control.min) - .clamp(control.min, control.max); - - let label = text(axis_label(control.kind)) - .size(font) - .style(move |_: &iced::Theme| iced::widget::text::Style { - color: Some(p.text_dim), - }); - - let value_text = text(readout(control, value)) - .size(font) - .style(move |_: &iced::Theme| iced::widget::text::Style { - color: Some(p.text_secondary), - }); + let absent = value.is_none(); + let opacity = if absent { ABSENT_OPACITY } else { 1.0 }; + + // The slider still needs a position in range to lay itself out. It + // is the minimum, but with the knob hidden nothing renders there. + let position = value.unwrap_or(control.min); + + let header = control_header( + axis_label(control.kind), + value.map(|value| readout(control, value)), + font, + opacity, + help, + p, + ); let axis = control.kind; - let bar = iced::widget::slider(control.min..=control.max, value, { + let bar = iced::widget::slider(control.min..=control.max, position, { let entity_id = entity_id.to_owned(); move |value: f32| Message::ControlValueChanged { entity_id: entity_id.clone(), @@ -174,11 +262,180 @@ fn control_row<'a>( .on_release(Message::ControlReleased { entity_id: entity_id.to_owned(), axis, + }) + .style(move |theme: &iced::Theme, status| { + let mut style = iced::widget::slider::default(theme, status); + + if absent { + style.rail.backgrounds = ( + fade_background(style.rail.backgrounds.0, ABSENT_OPACITY), + fade_background(style.rail.backgrounds.1, ABSENT_OPACITY), + ); + // A transparent handle is how the knob is removed: the + // slider keeps its geometry and its hit area, so the row + // stays draggable, and only the mark that would claim a + // value goes away. + style.handle.background = iced::Color::TRANSPARENT.into(); + style.handle.border_color = iced::Color::TRANSPARENT; + } + + style }); + column![header, bar].spacing(4).width(Length::Fill).into() +} + +/// The label-and-readout line every control is headed by. +/// +/// Shared so a colour surface and a slider line their labels up, and so +/// "the value, or a dash when there is none" is stated once. The dash is +/// what an absent axis reads as: a readout is a statement about the +/// device, and an axis the device is not driving has nothing to state. +/// +/// `help` is the control's precision shortcuts, from [`shortcut_hint`], +/// and where there are any the line ends with an icon that names them on +/// hover (#100). +fn control_header<'a>( + label: &'static str, + value: Option, + font: f32, + opacity: f32, + help: Option<&'static str>, + p: Palette, +) -> Element<'a, Message> { + let label_color = fade(p.text_dim, opacity); + let value_color = fade(p.text_secondary, opacity); + + let label = text(label) + .size(font) + .style(move |_: &iced::Theme| iced::widget::text::Style { + color: Some(label_color), + }); + + let value = text(value.unwrap_or_else(|| "-".to_owned())) + .size(font) + .style(move |_: &iced::Theme| iced::widget::text::Style { + color: Some(value_color), + }); + + let mut line = row![label, space().width(Length::Fill), value].align_y(Alignment::Center); + + if let Some(hint) = help { + line = line + .push(space().width(HELP_GAP)) + .push(help_icon(hint, font, opacity, p)); + } + + line.into() +} + +/// How far the help icon stands off the readout. +/// +/// Enough that "85%" and the circle do not read as one glyph, and no +/// more: at Small the line is 132 points wide and the label, the widest +/// readout and the icon already claim about 100 of them. +const HELP_GAP: f32 = 4.0; + +/// The affordance that says the precision shortcuts exist. +/// +/// Hover and nothing else. It fires no message, so a press over it falls +/// through to the card underneath and still drags the widget, which is +/// what keeps it from becoming a hole in the drag surface +/// (`docs/adr/0001-widget-interaction-model.md`). +/// +/// That ADR also keeps hover chrome off an expanded card, because every +/// pixel the card grew by is a control and an overlay pinned to its +/// edges swallows the press underneath. This is not that: it sits inside +/// the control's own layout rather than over a track, it takes its space +/// from the line it is part of rather than from anything draggable, and +/// the panel it opens is only up while the pointer is on the icon - so +/// it is never between the user and a gesture. +/// +/// Drawn at the label's own size. The glyph fills its em box where the +/// text only fills its cap height, so at equal nominal sizes the icon +/// already reads a little larger than the words beside it, and asking +/// for more would make the help louder than the readout it follows. +fn help_icon<'a>(hint: &'static str, font: f32, opacity: f32, p: Palette) -> Element<'a, Message> { + iced::widget::tooltip( + Icon::Help + .text(p) + .size(font) + .color(fade(p.text_dim, opacity)), + components::tooltip_message(hint, crate::ui::theme::MessageType::Info, p), + iced::widget::tooltip::Position::Bottom, + ) + .into() +} + +/// The colour surface inside an expanded widget: a label with its +/// readout, and the two-dimensional field beneath (#97). +/// +/// The readout names both axes, in the units they are in - degrees +/// around the wheel and percent of colour - because a colour is one +/// value with two components rather than two values shown together. +/// +/// `hue` and `saturation` are what [`ControlView::value`] resolved, and +/// `None` in either means Home Assistant is reporting no colour: the +/// light is off, or sitting in a white mode. The block then renders as +/// *absent* - dimmed, with no marker at all - for the reason a slider +/// then renders without a knob. A marker parked in the top-left corner +/// over a "0°, 0%" readout would be claiming the bulb is showing white, +/// which is a claim about the house and a false one (#94). +/// +/// Nothing about its behaviour changes. Dragging it sends the same +/// service call, and for a light that is off `light.turn_on` carrying +/// `hs_color` is also what turns the light on. +fn colour_block<'a>( + entity_id: &str, + hue: Option, + saturation: Option, + help: Option<&'static str>, + size: WidgetSize, + p: Palette, +) -> Element<'a, Message> { + let colour = hue.zip(saturation); + let opacity = if colour.is_none() { + ABSENT_OPACITY + } else { + 1.0 + }; + + let readout = colour.map(|(hue, saturation)| format!("{hue:.0}°, {saturation:.0}%")); + + let field = colour_field::colour_field( + colour, + size.colour_field_size().height, + colour_field::Style { + // The plate the spectrum is painted onto, which shows only + // through the rounded corners and on the frame before the + // texture is resident. It fades with the field so an absent + // colour recedes into the card rather than onto a plate. + fill: fade(p.card_2, opacity), + marker: fade(iced::Color::WHITE, opacity), + marker_shadow: fade(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.5), opacity), + // The one number a slider's rail is faded by, applied to the + // surface that stands where a slider's rail would. Colours + // are faded by scaling their alpha and a texture cannot be, + // so it travels as its own field and the widget hands it to + // the renderer. + opacity, + }, + { + let entity_id = entity_id.to_owned(); + move |hue: f32, saturation: f32| Message::ColorChanged { + entity_id: entity_id.clone(), + hue, + saturation, + } + }, + Message::ColorReleased { + entity_id: entity_id.to_owned(), + }, + ); + column![ - row![label, space().width(Length::Fill), value_text].align_y(Alignment::Center), - bar, + control_header("Colour", readout, size.detail_font(), opacity, help, p), + field, ] .spacing(4) .width(Length::Fill) @@ -216,11 +473,47 @@ pub struct WidgetView<'a> { pub settings: crate::config::WidgetSettings, pub priority: Priority, pub title: String, - /// Every axis this entity exposes, each paired with the locally-held - /// value if the user is currently driving it. A pending value wins - /// over whatever HA last reported. A non-empty list is what earns - /// the widget its expand chevron. - pub axes: Vec<(ContinuousControl, Option)>, + /// Every control this entity offers, in display order. A non-empty + /// list is what earns the widget its expand chevron. + pub controls: Vec, +} + +/// One control together with the locally-held value of each axis it +/// drives, in the same order as [`Control::axes`]. +/// +/// A pending value wins over whatever HA last reported, for as long as +/// the user is driving that axis (`crate::app::pending`). +pub struct ControlView { + pub control: Control, + pub pending: Vec>, +} + +impl ControlView { + /// The pending value of the `n`th axis this control drives. + fn pending(&self, index: usize) -> Option { + self.pending.get(index).copied().flatten() + } + + /// The value the `n`th axis renders at, or `None` when nothing is + /// currently driving it. + /// + /// A pending value wins while the user is driving that axis, and + /// Home Assistant takes over again once the interaction reconciles. + /// + /// `None` is a reading rather than a gap in the record. Home + /// Assistant nulls an axis the device is not currently driving: + /// every colour attribute of a light that is off, and + /// `color_temp_kelvin` on its own whenever the light is in some + /// other colour mode. Answering the axis minimum instead would turn + /// "there is no brightness" into "the brightness is zero", which is + /// a claim about the bulb, and a false one (#94). + pub fn value(&self, index: usize) -> Option { + let axis = self.control.axes().nth(index)?; + + self.pending(index) + .or(axis.current) + .map(|value| value.clamp(axis.min, axis.max)) + } } pub fn view(ctx: WidgetView<'_>) -> Element<'_, Message> { @@ -232,7 +525,7 @@ pub fn view(ctx: WidgetView<'_>) -> Element<'_, Message> { settings: widget_settings, priority, title, - axes, + controls, } = ctx; let (_friendly, main_opt, detail) = format_main_value(state); @@ -284,6 +577,7 @@ pub fn view(ctx: WidgetView<'_>) -> Element<'_, Message> { // back here and there is no header affordance for them. ActionKind::SetBrightness(_) | ActionKind::SetColorTemp(_) + | ActionKind::SetHs { .. } | ActionKind::SetTemperature(_) | ActionKind::SetPosition(_) => return None, }; @@ -318,7 +612,7 @@ pub fn view(ctx: WidgetView<'_>) -> Element<'_, Message> { // does something, and the chevron the signal that it has a value // worth adjusting. Gated on `connected` for the same reason the // action is, a control that cannot reach HA would swallow drags. - if !axes.is_empty() && connected { + if !controls.is_empty() && connected { let (icon, tooltip) = if state.is_expanded() { (Icon::ChevronUp, "Hide controls") } else { @@ -445,14 +739,13 @@ pub fn view(ctx: WidgetView<'_>) -> Element<'_, Message> { // window of their own, so they cannot drift away from the value // they belong to (#87). if state.is_expanded() { - for (control, pending) in &axes { + for view in &controls { inner_column = inner_column .push(space().height(widget_settings.widget_size.value_detail_gap())); - inner_column = inner_column.push(control_row( + inner_column = inner_column.push(control_block( &state.entity_id, - control, - *pending, - widget_settings.widget_size.detail_font(), + view, + widget_settings.widget_size, p, )); } diff --git a/src/ui/icon.rs b/src/ui/icon.rs index 5c11feb..251e136 100644 --- a/src/ui/icon.rs +++ b/src/ui/icon.rs @@ -35,6 +35,15 @@ pub enum Icon { ChevronDown, /// Collapses an expanded widget back to its size preset. ChevronUp, + /// A question mark in a circle, sitting beside a control that + /// answers modifier keys and the wheel (#100). + /// + /// Lucide's `circle-help` rather than `info`, because the question + /// it answers is "what else can I do here?" rather than anything + /// about the state of the house, and rather than [`Icon::Unknown`], + /// which is `badge-help` and already means "nothing is known about + /// this" wherever it appears. + Help, } impl Icon { @@ -57,6 +66,7 @@ impl Icon { Self::Play => '\u{e13c}', // play Self::ChevronDown => '\u{e06d}', // chevron-down Self::ChevronUp => '\u{e070}', // chevron-up + Self::Help => '\u{e082}', // circle-help } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 7e021d6..c7a11c8 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,4 +1,6 @@ pub mod chrome; +pub mod colour_field; +pub mod colour_texture; pub mod components; pub mod entity_window; pub mod format; diff --git a/src/widget_size.rs b/src/widget_size.rs index 3812205..69ffbd0 100644 --- a/src/widget_size.rs +++ b/src/widget_size.rs @@ -4,7 +4,17 @@ use serde::{Deserialize, Serialize}; +use crate::ha::Control; use crate::helpers; +use crate::theme::metric; + +/// The height of the rail a scalar control puts under its label. +/// +/// `iced::widget::Slider::DEFAULT_HEIGHT`, which these rows do not +/// override. Named here because the colour surface's height is stated +/// relative to a slider row's: both are headed by the same +/// label-and-readout line, and only what sits beneath it differs. +const SLIDER_HEIGHT: f32 = 16.0; #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct Adaptive { @@ -147,7 +157,7 @@ impl WidgetSize { } } - /// Height one continuous control adds to an expanded widget (#87): + /// Height a single-axis control adds to an expanded widget (#87): /// a label with its readout, and the slider under it. pub fn control_row_height(self) -> f32 { match self { @@ -157,16 +167,53 @@ impl WidgetSize { } } - /// Total height the controls area adds for `axes` controls, or `0.0` - /// when the entity has none to show. + /// The colour surface's size (#97): the width of a slider track, and + /// half as tall. /// - /// Every row is preceded by its own separating gap, which is how - /// `entity_window` builds them: an entity with two axes gets two + /// The track is the card's inner width, which is the window less the + /// card's padding at both edges - 132, 172 and 212 across the three + /// presets. Half as tall is the shape `ui::colour_texture` computes + /// the field at, so a degree of hue and a point of saturation are + /// about the same distance under the finger, and so that ticket 06 + /// can draw the texture into these bounds without stretching it. + pub fn colour_field_size(self) -> iced::Size { + let width = self.window_size().width - 2.0 * metric::PAD; + + iced::Size::new(width, width / 2.0) + } + + /// Height one control adds, its own separating gap included. + /// + /// Every control is preceded by its own gap, which is how + /// `entity_window` builds them: an entity with two controls gets two /// gaps, not one. Counting a single gap for the whole block left the - /// window short by one gap per extra axis, which the last row paid - /// for out of its own slack. - pub fn controls_height(self, axes: usize) -> f32 { - (self.value_detail_gap() + self.control_row_height()) * axes as f32 + /// window short by one gap per extra control, which the last row + /// paid for out of its own slack. + pub fn control_height(self, control: &Control) -> f32 { + let body = match control { + Control::Value(_) => self.control_row_height(), + // The same label-and-readout line a slider row is headed by, + // with the field standing where the rail would. Taking the + // rail out rather than adding to the whole row is what keeps + // a colour block and a slider block lining their labels up. + Control::Color { .. } => { + self.control_row_height() - SLIDER_HEIGHT + self.colour_field_size().height + } + }; + + self.value_detail_gap() + body + } + + /// Total height the controls area adds, or `0.0` when the entity has + /// none to show. + /// + /// A sum rather than a multiplication, because controls are not all + /// the same height: a colour surface is a field, not a slider. + pub fn controls_height(self, controls: &[Control]) -> f32 { + controls + .iter() + .map(|control| self.control_height(control)) + .sum() } } diff --git a/src/widget_size/tests.rs b/src/widget_size/tests.rs index 44859bf..00e5b57 100644 --- a/src/widget_size/tests.rs +++ b/src/widget_size/tests.rs @@ -1,30 +1,135 @@ +use crate::ha::{Axis, AxisKind, Control}; use crate::helpers::humanize_magnitude; +use crate::ui::colour_texture; use crate::widget_size::WidgetSize; -/// An entity with no axis has no controls to reveal, so there is nothing +fn axis(kind: AxisKind, max: f32) -> Axis { + Axis { + kind, + min: 0.0, + max, + step: 1.0, + current: None, + } +} + +fn slider() -> Control { + Control::Value(axis(AxisKind::Brightness, 255.0)) +} + +fn colour() -> Control { + Control::Color { + hue: axis(AxisKind::Hue, 359.0), + saturation: axis(AxisKind::Saturation, 100.0), + } +} + +/// The three the design calls for, stated as literals rather than +/// recomputed from the presets: they are the numbers the field was drawn +/// to, and deriving them here would make this test agree with whatever +/// the arithmetic happens to say. +#[test] +fn the_colour_field_is_a_slider_track_wide_and_half_as_tall() { + assert_eq!( + WidgetSize::Small.colour_field_size(), + iced::Size::new(132.0, 66.0) + ); + assert_eq!( + WidgetSize::Normal.colour_field_size(), + iced::Size::new(172.0, 86.0) + ); + assert_eq!( + WidgetSize::Large.colour_field_size(), + iced::Size::new(212.0, 106.0) + ); +} + +/// The field is painted by scaling one fixed texture into these bounds, +/// so the two shapes have to be the same shape. They are stated in two +/// files that know nothing of each other - the presets here, the pixel +/// extents in `ui::colour_texture` - and nothing else would notice them +/// disagreeing: a stretched texture still draws, it just stops putting +/// the colour it names under the marker, which is the one thing a colour +/// picker is for. +#[test] +fn the_texture_is_the_shape_of_the_field_at_every_preset() { + let texture = colour_texture::WIDTH as f32 / colour_texture::HEIGHT as f32; + + for &size in WidgetSize::ALL { + let field = size.colour_field_size(); + + assert_eq!(field.width / field.height, texture, "{size}"); + } +} + +/// A colour surface is a field, not a slider, so the window grows by +/// more for one than for the other. That is the whole reason the +/// controls area is a sum over per-control heights rather than a row +/// height times a count. +#[test] +fn a_colour_surface_costs_more_height_than_a_slider() { + for &size in WidgetSize::ALL { + let field = size.colour_field_size().height; + + assert!( + size.control_height(&colour()) > size.control_height(&slider()), + "{size}" + ); + // The difference is the field standing where the rail would, and + // nothing else: the label line and the separating gap are shared. + assert_eq!( + size.control_height(&colour()) - size.control_height(&slider()), + field - 16.0, + "{size}" + ); + } +} + +/// The mixture a colour bulb actually offers: a brightness slider, a +/// white slider and the colour surface, each with its own gap. +#[test] +fn a_colour_bulbs_controls_sum_their_own_heights() { + for &size in WidgetSize::ALL { + assert_eq!( + size.controls_height(&[slider(), slider(), colour()]), + size.control_height(&slider()) * 2.0 + size.control_height(&colour()), + "{size}" + ); + } +} + +/// An entity with no control has nothing to reveal, so there is nothing /// to grow into and the widget stays at its preset. #[test] -fn an_entity_with_no_axes_grows_by_nothing() { +fn an_entity_with_no_controls_grows_by_nothing() { for &size in WidgetSize::ALL { - assert_eq!(size.controls_height(0), 0.0, "{size}"); + assert_eq!(size.controls_height(&[]), 0.0, "{size}"); } } -/// `entity_window` puts a separating gap in front of every control row, -/// so the height each axis costs is the gap plus the row. A widget that +/// `entity_window` puts a separating gap in front of every control, +/// so the height each one costs is the gap plus its body. A widget that /// counted one gap for the whole block came up short as soon as it had a -/// second axis, and the last slider paid for it. +/// second control, and the last slider paid for it. #[test] -fn every_axis_costs_its_own_gap() { +fn every_control_costs_its_own_gap() { for &size in WidgetSize::ALL { - let one = size.controls_height(1); + let one = size.controls_height(&[slider()]); assert_eq!( one, size.value_detail_gap() + size.control_row_height(), "{size}" ); - assert_eq!(size.controls_height(2), one * 2.0, "{size}"); - assert_eq!(size.controls_height(3), one * 3.0, "{size}"); + assert_eq!( + size.controls_height(&[slider(), slider()]), + one * 2.0, + "{size}" + ); + assert_eq!( + size.controls_height(&[slider(), slider(), slider()]), + one * 3.0, + "{size}" + ); } } diff --git a/tests/harness/mod.rs b/tests/harness/mod.rs new file mode 100644 index 0000000..42db137 --- /dev/null +++ b/tests/harness/mod.rs @@ -0,0 +1,226 @@ +//! Seam S2: the application driven by messages, against a fake Home +//! Assistant speaking real HTTP. +//! +//! The unit seams below this one can say what a `Capabilities` holds or +//! what a `PendingValues` returns, but they cannot say what the user +//! ends up looking at or what Snapdash ends up putting on the wire. This +//! one can: it drives [`Snapdash::update`] with the same messages the +//! runtime delivers, and points the connection at a `wiremock` server so +//! a service call is a real POST whose body can be read back. +//! +//! Two things make that possible without launching a window system. +//! `Snapdash::new` takes no arguments and its fields are public, so the +//! connection can be pointed anywhere. And `call_service` builds its URL +//! from that connection rather than from configuration, so `127.0.0.1` +//! is as valid a Home Assistant as any. +//! +//! Attribute blobs are copied from what a real Home Assistant sends +//! rather than reduced to the keys a given test happens to read. The +//! whole class of defect this seam exists to catch is Snapdash +//! misreading something Home Assistant actually says, and a blob trimmed +//! to what the code already handles cannot catch that. + +use serde_json::{Value, json}; +use snapdash::app::{Message, Snapdash}; +use snapdash::ha::{EntityState, HaConnectionConfig, HaEvent}; +use wiremock::matchers::{method, path_regex}; +use wiremock::{Mock, MockServer, Request, ResponseTemplate}; + +use iced_winit::futures::futures::StreamExt; +use iced_winit::runtime::{Action, task}; + +/// Home Assistant's `state_changed` payload for one entity, built from a +/// real attribute blob. +pub fn entity_state(entity_id: &str, state: &str, attributes: Value) -> EntityState { + EntityState { + entity_id: entity_id.to_owned(), + state: state.to_owned(), + attributes: serde_json::from_value(attributes).expect("attributes are a JSON object"), + last_changed: None, + last_updated: None, + } +} + +/// A `Snapdash` whose Home Assistant is a local HTTP server. +pub struct Harness { + pub app: Snapdash, + server: MockServer, +} + +impl Harness { + /// Boot the application connected to a fake Home Assistant that + /// accepts every service call. + /// + /// Home Assistant answers `POST /api/services/{domain}/{service}` + /// with the list of states the call changed. Snapdash ignores the + /// body and waits for the `state_changed` broadcast instead, so an + /// empty list is a faithful enough reply. + pub async fn new() -> Self { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path_regex(r"^/api/services/[^/]+/[^/]+$")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([]))) + .mount(&server) + .await; + + let mut app = Snapdash::new(); + app.ha.connection = Some(HaConnectionConfig { + url: server.uri(), + token: "test-token".to_owned(), + }); + app.ha.connected = true; + + Self { app, server } + } + + /// Deliver one message and run whatever it hands back to the + /// runtime, returning the messages that came out. + /// + /// Running the task is the point: a `Task::perform` is an inert + /// stream until something polls it, so without this the HTTP request + /// inside a service call would never leave. The messages it produces + /// are returned rather than fed back in, so a test stays in charge + /// of how far a single interaction is allowed to cascade. + pub async fn send(&mut self, message: Message) -> Vec { + let Some(stream) = task::into_stream(self.app.update(message)) else { + return Vec::new(); + }; + + stream + .filter_map(|action| async move { + match action { + Action::Output(message) => Some(message), + _ => None, + } + }) + .collect() + .await + } + + /// Feed the `state_changed` Home Assistant broadcasts for an entity, + /// exactly as the WebSocket feed does. + pub async fn state_changed(&mut self, entity_id: &str, state: &str, attributes: Value) { + self.send(Message::HaEvent(HaEvent::StateChanged { + new_state: entity_state(entity_id, state, attributes), + })) + .await; + } + + /// Every service call posted so far, as `(path, body)`. + pub async fn service_calls(&self) -> Vec<(String, Value)> { + self.server + .received_requests() + .await + .unwrap_or_default() + .iter() + .map(|request: &Request| { + ( + request.url.path().to_owned(), + serde_json::from_slice(&request.body).expect("a JSON body"), + ) + }) + .collect() + } +} + +/// What Home Assistant reports for a colour-temperature bulb that is +/// **off**. +/// +/// Every axis is `null`: no brightness, no colour temperature, no colour +/// mode. The static capability attributes stay, which is why the widget +/// still knows the bulb is dimmable while it is off. +pub fn light_off_attributes() -> Value { + json!({ + "min_color_temp_kelvin": 2202, + "max_color_temp_kelvin": 6535, + "min_mireds": 153, + "max_mireds": 454, + "effect_list": ["None", "candle"], + "supported_color_modes": ["color_temp", "hs"], + "color_mode": null, + "brightness": null, + "color_temp_kelvin": null, + "color_temp": null, + "hs_color": null, + "rgb_color": null, + "xy_color": null, + "effect": null, + "friendly_name": "Living room", + "supported_features": 44 + }) +} + +/// What Home Assistant reports for a bulb whose native colour mode is +/// **rgb**, on and showing a colour. +/// +/// The two arguments have to be given together because they are the same +/// fact stated twice. `rgb_color` is what the light actually stores, and +/// Home Assistant *derives* `hs_color` and `xy_color` from it on every +/// state build. A hue that goes out as an integer therefore comes back +/// through 8-bit RGB, and not as the number Snapdash sent - which is the +/// whole reason this seam needs an rgb light and not only the `hs` one +/// above. A template light backed by `input_number` helpers stores hue +/// verbatim and would confirm any tolerance at all. +/// +/// Note the absence of the `color_temp` and `mireds` keys: Home +/// Assistant only reports those for a light that advertises the +/// `color_temp` mode, and this one does not. +pub fn rgb_light_attributes(rgb_color: [u8; 3], hs_color: [f64; 2], xy_color: [f64; 2]) -> Value { + json!({ + "supported_color_modes": ["rgb"], + "color_mode": "rgb", + "brightness": 199, + "hs_color": hs_color, + "rgb_color": rgb_color, + "xy_color": xy_color, + "friendly_name": "Desk lamp", + "supported_features": 0 + }) +} + +/// What Home Assistant reports for a **dimmable white** light, on. +/// +/// The one colour mode is `brightness`, which is how a light says it can +/// be dimmed and nothing else. Note what that removes: no `hs_color`, +/// `rgb_color` or `xy_color`, because Home Assistant only reports those +/// for a light advertising a colour mode, and none of the mireds or +/// kelvin keys, because it only reports those for one advertising +/// `color_temp`. A blob that kept them at null would be describing a +/// bulb that does not exist, and the absence is the whole point of this +/// one: it is the entity that must *not* be offered a colour surface, or +/// the shortcuts that go with it. +/// +/// `supported_features` is 32 - transition, and no effects - which is +/// why there is no `effect_list` either. +pub fn dimmable_light_attributes() -> Value { + json!({ + "supported_color_modes": ["brightness"], + "color_mode": "brightness", + "brightness": 128, + "friendly_name": "Hallway", + "supported_features": 32 + }) +} + +/// The same bulb **on**, sitting in `color_temp` mode. +pub fn light_on_attributes() -> Value { + json!({ + "min_color_temp_kelvin": 2202, + "max_color_temp_kelvin": 6535, + "min_mireds": 153, + "max_mireds": 454, + "effect_list": ["None", "candle"], + "supported_color_modes": ["color_temp", "hs"], + "color_mode": "color_temp", + "brightness": 172, + "color_temp_kelvin": 2703, + "color_temp": 370, + "hs_color": [28.391, 65.659], + "rgb_color": [255, 167, 87], + "xy_color": [0.524, 0.387], + "effect": null, + "friendly_name": "Living room", + "supported_features": 44 + }) +} diff --git a/tests/widget_controls.rs b/tests/widget_controls.rs new file mode 100644 index 0000000..289a330 --- /dev/null +++ b/tests/widget_controls.rs @@ -0,0 +1,507 @@ +//! The expanded widget's controls, driven end to end (seam S2). +//! +//! See `tests/harness/mod.rs` for what this seam is and why it exists. + +mod harness; + +use serde_json::json; +use snapdash::app::Message; +use snapdash::ha::AxisKind; +use snapdash::ui::entity_window::shortcut_hint; + +use harness::{ + Harness, dimmable_light_attributes, light_off_attributes, light_on_attributes, + rgb_light_attributes, +}; + +const ENTITY: &str = "light.living_room"; +/// A second light, whose only colour mode is `rgb`. Colour reaches this +/// one through an 8-bit conversion in both directions, which is the +/// round trip the hue tolerance has to survive. +const RGB_ENTITY: &str = "light.desk_lamp"; +/// A third light that can only be dimmed, and so has no colour surface +/// and none of the gestures that go with one. +const DIMMABLE_ENTITY: &str = "light.hallway"; + +/// What the control driving `kind` currently shows, found by axis rather +/// than by position so a test never encodes the order the entity happens +/// to offer its controls in. +fn axis_value(harness: &Harness, entity_id: &str, kind: AxisKind) -> Option { + harness + .app + .control_views(entity_id) + .iter() + .find_map(|view| { + let index = view.control.axes().position(|axis| axis.kind == kind)?; + view.value(index) + }) +} + +/// Home Assistant nulls every colour attribute of a light that is off. +/// Reading that as zero is a different claim from reading it as absent, +/// and only one of them is true: the bulb is not sitting at 0% +/// brightness, it has no brightness at all right now. +#[tokio::test] +async fn an_axis_home_assistant_reports_as_null_offers_no_value() { + let mut harness = Harness::new().await; + harness + .state_changed(ENTITY, "off", light_off_attributes()) + .await; + + let controls = harness.app.control_views(ENTITY); + + // The bulb is still dimmable and still supports both colour temperature + // and colour - those are static capabilities - so every control is + // offered. + assert_eq!(controls.len(), 3, "an off light still offers its controls"); + assert_eq!(controls[0].value(0), None, "brightness"); + assert_eq!(controls[1].value(0), None, "colour temperature"); + // Both axes of the colour surface, which is what stops the marker + // being drawn at all. + assert_eq!(controls[2].value(0), None, "hue"); + assert_eq!(controls[2].value(1), None, "saturation"); +} + +/// The absent value is a reading of the present, not a gap in the +/// record. The moment Home Assistant reports a number again, that is +/// what the control shows. +#[tokio::test] +async fn the_value_comes_back_once_home_assistant_reports_one_again() { + let mut harness = Harness::new().await; + harness + .state_changed(ENTITY, "off", light_off_attributes()) + .await; + harness + .state_changed(ENTITY, "on", light_on_attributes()) + .await; + + let controls = harness.app.control_views(ENTITY); + + assert_eq!(controls[0].value(0), Some(172.0), "brightness"); + assert_eq!(controls[1].value(0), Some(2703.0), "colour temperature"); + // Home Assistant derives `hs_color` from the kelvin value while the + // light sits in `color_temp` mode, so the colour surface has a + // reading even though nobody has set a colour: it is the colour the + // white the bulb is showing corresponds to. + assert_eq!(controls[2].value(0), Some(28.391), "hue"); + assert_eq!(controls[2].value(1), Some(65.659), "saturation"); +} + +/// Rendering an axis as absent must not make it inert. Grabbing a +/// control with no value is how the user gives it one, and for a light +/// that is off the `light.turn_on` it produces is also what turns the +/// light on - there is no separate "on first, then set" step to get +/// wrong. +#[tokio::test] +async fn dragging_an_axis_with_no_value_still_sets_it_and_turns_the_light_on() { + let mut harness = Harness::new().await; + harness + .state_changed(ENTITY, "off", light_off_attributes()) + .await; + assert_eq!( + harness.app.control_views(ENTITY)[0].value(0), + None, + "the axis under test has no value to start from" + ); + + harness + .send(Message::ControlValueChanged { + entity_id: ENTITY.to_owned(), + axis: AxisKind::Brightness, + value: 128.0, + }) + .await; + harness + .send(Message::ControlReleased { + entity_id: ENTITY.to_owned(), + axis: AxisKind::Brightness, + }) + .await; + + let calls = harness.service_calls().await; + + assert!(!calls.is_empty(), "a valueless axis is still operable"); + for (path, body) in &calls { + assert_eq!(path, "/api/services/light/turn_on"); + assert_eq!(body, &json!({ "entity_id": ENTITY, "brightness": 128 })); + } +} + +/// The whole path, stated the way a user would: expand a colour light, +/// drag across the colour field from one point to another, and the bulb +/// takes the colour under the finger. +/// +/// One `light.turn_on` per position and never one per axis. Hue and +/// saturation reach Home Assistant as the two elements of a single +/// `hs_color`, so a gesture that sent them separately would be sending +/// two colours, the first of them one the user never pointed at. +/// +/// Every body is asserted whole rather than for the presence of +/// `hs_color`, because "and no other parameter" is the substance of it. +/// A `light.turn_on` carrying brightness alongside the colour would set +/// both, and a colour surface that quietly also sets brightness is one +/// that fights the brightness slider above it. Leaving the key out is +/// what preserves it: `light.turn_on` without `brightness` keeps +/// whatever the light already had. +#[tokio::test] +async fn dragging_across_the_colour_field_posts_hs_color_and_nothing_else() { + let mut harness = Harness::new().await; + harness + .state_changed( + RGB_ENTITY, + "on", + rgb_light_attributes([255, 170, 0], [40.0, 100.0], [0.555, 0.422]), + ) + .await; + + harness + .send(Message::ColorChanged { + entity_id: RGB_ENTITY.to_owned(), + hue: 100.0, + saturation: 50.0, + }) + .await; + harness + .send(Message::ColorChanged { + entity_id: RGB_ENTITY.to_owned(), + hue: 212.0, + saturation: 85.0, + }) + .await; + harness + .send(Message::ColorReleased { + entity_id: RGB_ENTITY.to_owned(), + }) + .await; + + let calls = harness.service_calls().await; + + assert!(!calls.is_empty(), "the drag reached Home Assistant"); + for (path, body) in &calls { + assert_eq!(path, "/api/services/light/turn_on"); + assert_eq!( + body.as_object().map(serde_json::Map::len), + Some(2), + "the entity and its colour, and nothing else: {body}" + ); + } + + assert_eq!( + calls.first().map(|(_, body)| body), + Some(&json!({ "entity_id": RGB_ENTITY, "hs_color": [100, 50] })), + "where the drag started" + ); + assert_eq!( + calls.last().map(|(_, body)| body), + Some(&json!({ "entity_id": RGB_ENTITY, "hs_color": [212, 85] })), + "where it ended, flushed by the release however the throttle fell" + ); +} + +/// The other half of the round trip: Home Assistant answers, and the +/// control goes back to showing the house rather than the finger. +/// +/// The echo is not the pair Snapdash sent. This light stores 8-bit RGB, +/// so `(212, 85)` is written as `rgb(38, 139, 255)` and read back as +/// `(212.074, 85.098)` - and that is what the control must end up +/// showing, because it is what the bulb is actually doing. +/// +/// Nothing here advances a clock. The reconciliation happens on the +/// strength of the echo alone, which is the difference between a control +/// that hands back in one round trip and one that sits on a local value +/// for the two seconds of the settle window on every single drag. +#[tokio::test] +async fn the_echo_from_an_rgb_light_hands_the_colour_straight_back() { + let mut harness = Harness::new().await; + harness + .state_changed( + RGB_ENTITY, + "on", + rgb_light_attributes([255, 170, 0], [40.0, 100.0], [0.555, 0.422]), + ) + .await; + + harness + .send(Message::ColorChanged { + entity_id: RGB_ENTITY.to_owned(), + hue: 212.0, + saturation: 85.0, + }) + .await; + harness + .send(Message::ColorReleased { + entity_id: RGB_ENTITY.to_owned(), + }) + .await; + assert_eq!( + axis_value(&harness, RGB_ENTITY, AxisKind::Hue), + Some(212.0), + "held locally until the echo arrives" + ); + + harness + .state_changed( + RGB_ENTITY, + "on", + rgb_light_attributes([38, 139, 255], [212.074, 85.098], [0.149, 0.156]), + ) + .await; + + assert!( + harness.app.pending.is_empty(), + "one round trip, no settle timeout" + ); + assert_eq!( + axis_value(&harness, RGB_ENTITY, AxisKind::Hue), + Some(212.074), + "Home Assistant's number, not the one we sent" + ); + assert_eq!( + axis_value(&harness, RGB_ENTITY, AxisKind::Saturation), + Some(85.098) + ); +} + +/// The case a numeric hue tolerance cannot survive, and the reason the +/// comparison moved into the control and into RGB. +/// +/// At saturation 1 an eight-bit colour barely determines a hue at all: +/// `(212, 1)` is stored as `rgb(252, 254, 255)` and read straight back +/// as hue **200**, twelve degrees from what was sent. As a colour those +/// two are the same three bytes, so comparing colours confirms it in one +/// round trip where comparing degrees could only ever time out. +#[tokio::test] +async fn an_echo_at_the_lowest_saturation_still_confirms() { + let mut harness = Harness::new().await; + harness + .state_changed( + RGB_ENTITY, + "on", + rgb_light_attributes([255, 170, 0], [40.0, 100.0], [0.555, 0.422]), + ) + .await; + + harness + .send(Message::ColorChanged { + entity_id: RGB_ENTITY.to_owned(), + hue: 212.0, + saturation: 1.0, + }) + .await; + harness + .send(Message::ColorReleased { + entity_id: RGB_ENTITY.to_owned(), + }) + .await; + + harness + .state_changed( + RGB_ENTITY, + "on", + rgb_light_attributes([252, 254, 255], [200.0, 1.176], [0.32, 0.328]), + ) + .await; + + assert!( + harness.app.pending.is_empty(), + "twelve degrees out and still the colour that was sent" + ); + assert_eq!( + axis_value(&harness, RGB_ENTITY, AxisKind::Hue), + Some(200.0), + "Home Assistant's number, not the one we sent" + ); +} + +/// A colour the user did not pick must not end the interaction. Here +/// the bulb answers with a colour ten degrees away at full saturation, +/// which is four eight-bit levels of blue - far outside anything +/// quantisation can account for. +#[tokio::test] +async fn a_genuinely_different_colour_does_not_confirm() { + let mut harness = Harness::new().await; + harness + .state_changed( + RGB_ENTITY, + "on", + rgb_light_attributes([255, 170, 0], [40.0, 100.0], [0.555, 0.422]), + ) + .await; + + harness + .send(Message::ColorChanged { + entity_id: RGB_ENTITY.to_owned(), + hue: 200.0, + saturation: 100.0, + }) + .await; + harness + .send(Message::ColorReleased { + entity_id: RGB_ENTITY.to_owned(), + }) + .await; + + harness + .state_changed( + RGB_ENTITY, + "on", + rgb_light_attributes([0, 128, 255], [210.0, 100.0], [0.156, 0.163]), + ) + .await; + + assert!( + !harness.app.pending.is_empty(), + "the house is showing a colour nobody asked for" + ); + assert_eq!( + axis_value(&harness, RGB_ENTITY, AxisKind::Hue), + Some(200.0), + "still the user's colour, until the settle window says otherwise" + ); +} + +/// A colour light that is off reports `hs_color` as null, so its colour +/// surface renders as absent (#94) - and is still the way to give the +/// light a colour. `light.turn_on` carrying `hs_color` is also what +/// turns the light on, so there is no "on first, then colour" step to +/// get wrong. +#[tokio::test] +async fn dragging_the_colour_field_on_a_light_that_is_off_turns_it_on() { + let mut harness = Harness::new().await; + harness + .state_changed(ENTITY, "off", light_off_attributes()) + .await; + assert_eq!( + axis_value(&harness, ENTITY, AxisKind::Hue), + None, + "an off light has no colour to show" + ); + + harness + .send(Message::ColorChanged { + entity_id: ENTITY.to_owned(), + hue: 275.0, + saturation: 60.0, + }) + .await; + harness + .send(Message::ColorReleased { + entity_id: ENTITY.to_owned(), + }) + .await; + + let calls = harness.service_calls().await; + + assert!(!calls.is_empty(), "an absent axis is still operable"); + for (path, body) in &calls { + assert_eq!(path, "/api/services/light/turn_on"); + assert_eq!(body, &json!({ "entity_id": ENTITY, "hs_color": [275, 60] })); + } +} + +/// A bulb with no colour mode has no colour surface to offer, whatever +/// else it can do. Discovery keys on `supported_color_modes`, so a +/// dimmable white light gets a brightness slider and nothing else. +#[tokio::test] +async fn a_light_with_no_colour_mode_offers_no_colour_surface() { + let mut harness = Harness::new().await; + harness + .state_changed(DIMMABLE_ENTITY, "on", dimmable_light_attributes()) + .await; + + let controls = harness.app.control_views(DIMMABLE_ENTITY); + + assert_eq!(controls.len(), 1, "brightness alone"); + assert_eq!( + axis_value(&harness, DIMMABLE_ENTITY, AxisKind::Hue), + None, + "no hue axis exists to have a value" + ); + assert_eq!( + axis_value(&harness, DIMMABLE_ENTITY, AxisKind::Saturation), + None + ); +} + +/// Shift, Alt and the wheel are invisible unless something says they +/// exist, which is what the help affordance in the colour control's +/// label row is for (#100). +/// +/// It is asked for here rather than looked at, because a rendered +/// `Element` cannot be read back: [`shortcut_hint`] is the one place +/// deciding which controls get one, and the widget builds its label row +/// from that answer. +/// +/// The negative half is the half with teeth. A brightness slider is +/// `iced::widget::slider`, which reads the pointer's horizontal offset +/// and nothing else, so an icon there would promise help that holding +/// Shift cannot give. +#[tokio::test] +async fn only_a_colour_control_offers_the_shortcut_help() { + let mut harness = Harness::new().await; + harness + .state_changed(ENTITY, "on", light_on_attributes()) + .await; + harness + .state_changed(DIMMABLE_ENTITY, "on", dimmable_light_attributes()) + .await; + + let colour_bulb = harness.app.control_views(ENTITY); + let helped: Vec<_> = colour_bulb + .iter() + .filter(|view| shortcut_hint(&view.control).is_some()) + .collect(); + + assert_eq!( + helped.len(), + 1, + "one help affordance on a bulb offering brightness, white and colour" + ); + assert!( + helped[0] + .control + .axes() + .any(|axis| axis.kind == AxisKind::Hue), + "and it is the colour surface that carries it" + ); + + let hint = shortcut_hint(&helped[0].control).expect("the colour control is the helped one"); + for shortcut in ["Shift", "Alt", "Wheel"] { + assert!( + hint.contains(shortcut), + "the hint names {shortcut}, and says: {hint}" + ); + } + + let dimmable = harness.app.control_views(DIMMABLE_ENTITY); + + assert_eq!(dimmable.len(), 1, "brightness alone"); + assert!( + dimmable + .iter() + .all(|view| shortcut_hint(&view.control).is_none()), + "a light with no colour surface is promised no shortcuts" + ); +} + +/// While the user drives it, the control shows the local value even +/// though Home Assistant is still reporting null for that axis: the +/// echo lags the finger, and an axis being dragged is by definition +/// being driven. +#[tokio::test] +async fn a_dragged_axis_shows_the_value_the_user_is_setting() { + let mut harness = Harness::new().await; + harness + .state_changed(ENTITY, "off", light_off_attributes()) + .await; + + harness + .send(Message::ControlValueChanged { + entity_id: ENTITY.to_owned(), + axis: AxisKind::Brightness, + value: 200.0, + }) + .await; + + assert_eq!(harness.app.control_views(ENTITY)[0].value(0), Some(200.0)); +}