Files
morns-keyboard-basic/srcs/juloo.keyboard2/LightsPage.java
T
Nyra 2ab0cd16d8 Lights page: three Home Assistant lights, lit/unlit glyph, state colours
Opened from the free fourth corner slot on the Alt key (switch_lights), closed
by the full-width Keyboard key. One key per light, registry-driven labels, so
the page renders whatever the lights-proxy reports rather than knowing any
entity ids. ON is blue, OFF is pink, anything unreachable is stale grey, and
the glyph flips between a lit lamp (0xE025) and an unlit twin (0xE026) so an
unreachable light can never look lit. Toggles are sent once and never retried.
2026-09-28 03:18:50 +00:00

215 lines
9.3 KiB
Java

package juloo.keyboard2;
import java.util.ArrayList;
import java.util.List;
/** The lights page (res/xml/lights.xml): one key per light, plus the way back.
*
* The page is opened by the switch_lights key (the lamp glyph at 0xE025, in the free bottom-right
* corner of the Alt key) and left by lights_close, which fires the same Event.SWITCH_TEXT as
* roku_close. Like the Roku page it is loaded as a plain layout (Keyboard2.loadLightsLayout), NOT
* through LayoutModifier.modify_layout, so the forced config key and the user's extra keys never
* land on it: this page is its own keys and nothing else.
*
* The skeleton - a row of three slots and the full-width 'Keyboard' key - lives in the XML. This
* class turns that skeleton into the live page: one key per light the proxy reported, three to a
* row, with a short last row stretched to the full width. Each key carries two values:
*
* key0 the light's name, exactly as the registry gave it (shortened only when it would run
* over the key), coloured by the light's state;
* key7 the state glyph, in the special font: the lit lamp (0xE025) for a light that is on, the
* unlit one (0xE026) for everything else. The glyph is chosen when the key is *drawn*, not
* when the page is built, because a press and a poll change the state under the key
* without the page being rebuilt.
*
* Before the proxy has answered, the template's three slots stand with a dash and the unlit lamp in
* the stale look: nobody has said which lights exist, and an unlit lamp never claims otherwise.
*
* [build], [displayName], [glyphFor] and [colorFor] are pure, so the unit tests drive the real page
* logic.
*/
public final class LightsPage
{
/** The most lights the page can show: the page's events are LIGHT_1..LIGHT_12. A whitelist
longer than this is reported on the last key rather than silently dropped. */
public static final int MAX_LIGHTS = 12;
/** The template's slot row holds three keys; the page repeats it. */
public static final int SLOTS_PER_ROW = 3;
/** The template's corner slot for the state glyph. */
public static final int GLYPH_SUB_INDEX = 7;
/** A key is a third of the keyboard wide; a longer name would run over its neighbours. The name
itself is whatever the registry says, never a mapping kept here. */
public static final int MAX_NAME_CHARS = 16;
/** Drawn on a slot before the proxy has named a light for it. */
public static final String PLACEHOLDER = "—";
private static final String ELLIPSIS = "…";
/** The two glyphs, as the Strings the draw path hands to [android.graphics.Canvas]: kept here so
drawing a key allocates nothing. */
private static final String LIT_GLYPH = String.valueOf((char)0xE025);
private static final String UNLIT_GLYPH = String.valueOf((char)0xE026);
private LightsPage() {}
// ------------------------------------------------------------------ the page
/** The live page: the template's row of slots repeated once per row of lights, then the
template's 'Keyboard' row, untouched. [lights] may be null or empty, in which case the slots
stand as placeholders. Never mutates the template (KeyboardData.load caches it). */
public static KeyboardData build(KeyboardData template, LightsProxy.Light[] lights)
{
if (template == null || template.rows.size() < 2)
return template;
LightsProxy.Light[] list = lights == null ? new LightsProxy.Light[0] : lights;
int shown = Math.min(list.length, MAX_LIGHTS);
List<KeyboardData.Row> rows = new ArrayList<KeyboardData.Row>();
if (shown == 0)
rows.add(slotRow(template, list, 0, 0));
else
for (int first = 0; first < shown; first += SLOTS_PER_ROW)
rows.add(slotRow(template, list, first, shown));
rows.add(template.rows.get(template.rows.size() - 1)); // the 'Keyboard' key
return template.with_rows(rows);
}
/** One row of up to three light keys, built from the template's slot keys so the geometry lives
in the XML. A short row is stretched to the template row's width. */
private static KeyboardData.Row slotRow(KeyboardData template,
LightsProxy.Light[] lights, int first, int shown)
{
KeyboardData.Row tpl = template.rows.get(0);
ArrayList<KeyboardData.Key> keys = new ArrayList<KeyboardData.Key>();
for (int i = 0; i < tpl.keys.size(); i++)
{
int slot = first + i + 1; // 1-based: LIGHT_<slot>
if (shown != 0 && slot > shown)
break;
String label = PLACEHOLDER;
if (shown != 0)
{
LightsProxy.Light light = lights[slot - 1];
label = displayName(light.name);
if (slot == shown && lights.length > MAX_LIGHTS)
{
// Say what did not fit rather than dropping it silently, and make room for it: the count
// is the last thing on the key and must never be the part that gets cut.
String more = " +" + (lights.length - MAX_LIGHTS);
label = ellipsize(label, MAX_NAME_CHARS - more.length()) + more;
}
}
KeyValue.Event ev = eventFor(slot);
keys.add(tpl.keys.get(i)
.withKeyValue(0, KeyValue.lightKey(label, ev))
.withKeyValue(GLYPH_SUB_INDEX, KeyValue.lightGlyphKey(ev)));
}
KeyboardData.Row row = new KeyboardData.Row(keys, tpl.height, tpl.shift);
return keys.size() == tpl.keys.size() ? row : row.updateWidth(tpl.keysWidth);
}
/** The name as it fits on a key. The label is the registry's own name - there is deliberately no
mapping from a light to a shorter label anywhere in the keyboard - cut with a visible ellipsis
only when it would run over the neighbouring keys. Pure. */
public static String displayName(String name)
{
return ellipsize(name, MAX_NAME_CHARS);
}
/** Cut a line to [max] characters, marking the cut. Pure. */
public static String ellipsize(String text, int max)
{
if (text == null)
return "";
String s = text.trim();
if (s.length() <= max)
return s;
return s.substring(0, max - 1).trim() + ELLIPSIS;
}
// ------------------------------------------------------------------ the keys
/** True for a key of the lights page. [KeyValue.getEvent()] is only valid for its own kind, so
the kind is checked first - the same guard RokuRemote.isLit uses. */
public static boolean isLightKey(KeyValue k)
{
return k != null && k.getKind() == KeyValue.Kind.Event && slotOf(k.getEvent()) > 0;
}
/** True for the state-glyph value of a lights key. It is the value drawn in the special font
(FLAG_KEY_FONT), which is exactly what tells it apart from the light's name. */
public static boolean isStateGlyph(KeyValue k)
{
return isLightKey(k) && k.hasFlagsAny(KeyValue.FLAG_KEY_FONT);
}
/** The glyph for a state: the lamp with its beams for a light that is on, the unlit lamp for
everything else - 'off', 'unavailable', 'unknown' and no data at all. Only ON is ever drawn
lit, so an unreachable light cannot look switched on. Pure, and the returned Strings are
shared, so the draw path allocates nothing. */
public static String glyphFor(LightsProxy.State state)
{
return state == LightsProxy.State.ON ? LIT_GLYPH : UNLIT_GLYPH;
}
/** 1..MAX_LIGHTS for a lights key's event, 0 for anything else. */
public static int slotOf(KeyValue.Event ev)
{
if (ev == null)
return 0;
switch (ev)
{
case LIGHT_1: return 1;
case LIGHT_2: return 2;
case LIGHT_3: return 3;
case LIGHT_4: return 4;
case LIGHT_5: return 5;
case LIGHT_6: return 6;
case LIGHT_7: return 7;
case LIGHT_8: return 8;
case LIGHT_9: return 9;
case LIGHT_10: return 10;
case LIGHT_11: return 11;
case LIGHT_12: return 12;
default: return 0;
}
}
/** The event of slot [slot] (1-based), or null when the page has no such slot. */
public static KeyValue.Event eventFor(int slot)
{
switch (slot)
{
case 1: return KeyValue.Event.LIGHT_1;
case 2: return KeyValue.Event.LIGHT_2;
case 3: return KeyValue.Event.LIGHT_3;
case 4: return KeyValue.Event.LIGHT_4;
case 5: return KeyValue.Event.LIGHT_5;
case 6: return KeyValue.Event.LIGHT_6;
case 7: return KeyValue.Event.LIGHT_7;
case 8: return KeyValue.Event.LIGHT_8;
case 9: return KeyValue.Event.LIGHT_9;
case 10: return KeyValue.Event.LIGHT_10;
case 11: return KeyValue.Event.LIGHT_11;
case 12: return KeyValue.Event.LIGHT_12;
default: return null;
}
}
/** The colour a lights key's label and glyph are drawn in. The rule itself is in one place
([LightsProxy.colorFor]); all this does is fetch the three theme colours and the state. */
public static int colorFor(KeyValue k, Theme theme)
{
return LightsProxy.colorFor(LightsProxy.stateAt(slotOf(k.getEvent())), theme.lightOnColor,
theme.lightOffColor, theme.lightStaleColor);
}
/** What a lights key says: the answer to the last press on this light while that answer is still
fresh (a refusal, or that the state could not be read back), and null otherwise - in which
case the key shows the light's name in its state colour. */
public static String noteFor(KeyValue k, long now)
{
if (!isLightKey(k))
return null;
LightsProxy.Light light = LightsProxy.lightAt(slotOf(k.getEvent()));
return light == null ? null : light.noteOrNull(now);
}
}