Port "Mein Zimmer" - the Home Assistant room panel
The second of the web front-end's three pages. Lights get a toggle, five brightness steps and six colours; shutters get four presets, up/stop/down and a window that reflects the position Home Assistant reports. Its own violet hue rather than the player's teal, so the two pages read as siblings rather than one bleeding into the other - the same reason room.css has its own tokens. The backend keeps Home Assistant's URL and token and relays the calls, so this client learns only which entities exist, exactly as the browser does. A 404 from GET /api/ha means Home Assistant is not configured, which is a normal answer: the tab simply does not appear. Polled, not subscribed, and only while the page is on screen. HA's own auth-and- subscribe websocket is more machinery than a room panel needs, and a lamp's state is not worth a request every 2.5 seconds when nobody is looking at it - this device has a music player to stay out of the way of. A tap patches the entity locally and records when; a poll that was already in flight when the patch landed is dropped for that entity rather than putting the lamp back to "off" until the next one catches up. That reconciliation is the one non-obvious thing in useHomeAssistant.ts and it is carried over for the same reason. Nothing on a shutter card animates locally. A real cover reports its own movement and position, and a client-side guess at where it will end up is precisely what would fight with that - the design mockup animated it because it had no backend to ask. Two additions beyond a straight port, both because a kiosk has no pointer: CTRL+TAB cycles the pages. The web's tab rail is pointer-only, which sits badly with a front-end whose README claims every screen is reachable from the keyboard. Plain TAB was already the group cycle, and CTRL+TAB is the idiom for the outer one everywhere else. --page opens directly on a page, which a kiosk may well want and which is the only way in with neither keyboard nor pointer. Verified against the real Home Assistant: the shutter reported open and the lamp off, and both drew that way. Nothing was switched - those entities are hardware. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,8 +19,39 @@ pub const SEEK_STEP: f64 = 15.0;
|
||||
/// How far Ctrl+d/Ctrl+u jump - vim's half-page scroll, applied to rows of cards.
|
||||
pub const PAGE_ROWS: i32 = 3;
|
||||
|
||||
/// Which top-level page is showing. Orthogonal to everything below it, so switching
|
||||
/// pages and coming back leaves the music side exactly as it was.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Page {
|
||||
Music,
|
||||
Room,
|
||||
Typing,
|
||||
}
|
||||
|
||||
impl Page {
|
||||
pub fn icon(self) -> &'static str {
|
||||
match self {
|
||||
// A plain Unicode symbol rather than the emoji: that glyph carries its own
|
||||
// muted colour on some platforms and ignores `color`, which makes it
|
||||
// illegible on the rail's own background.
|
||||
Self::Music => "♪",
|
||||
Self::Room => "💡",
|
||||
Self::Typing => "⌨️",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Music => "Musik",
|
||||
Self::Room => "Mein Zimmer",
|
||||
Self::Typing => "Tippen",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UiState {
|
||||
pub page: Page,
|
||||
pub search: String,
|
||||
pub tracks_mode: bool,
|
||||
/// `None` is the bare root screen (three shelves); otherwise which one is open.
|
||||
@@ -42,6 +73,7 @@ pub struct UiState {
|
||||
impl Default for UiState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
page: Page::Music,
|
||||
search: String::new(),
|
||||
tracks_mode: false,
|
||||
group: None,
|
||||
@@ -89,7 +121,8 @@ impl UiState {
|
||||
|
||||
/// Whether anything is left to back out of - what the corner button's presence is.
|
||||
pub fn can_go_back(&self) -> bool {
|
||||
self.play_view
|
||||
self.page != Page::Music
|
||||
|| self.play_view
|
||||
|| self.open_album.is_some()
|
||||
|| !self.search.is_empty()
|
||||
|| self.tracks_mode
|
||||
@@ -270,6 +303,20 @@ pub fn handle_key(
|
||||
return vec![Action::Toggle];
|
||||
}
|
||||
|
||||
// Ctrl+Tab cycles the pages. This is an addition to the web front-end, where the
|
||||
// tab rail is reachable only with a pointer - which sits badly with a device whose
|
||||
// stated design is that every screen can be reached from the keyboard. Plain Tab
|
||||
// is already taken by the group cycle, and Ctrl+Tab is the idiom for the outer
|
||||
// one everywhere else.
|
||||
if ctrl && key == "tab" {
|
||||
state.page = match state.page {
|
||||
Page::Music => Page::Room,
|
||||
Page::Room => Page::Typing,
|
||||
Page::Typing => Page::Music,
|
||||
};
|
||||
return vec![];
|
||||
}
|
||||
|
||||
// Ctrl+hjkl moves the highlight in whichever list is on screen, the same job the
|
||||
// arrows do but without leaving the home row. Ctrl+d/u are vim's half-page jump.
|
||||
if ctrl {
|
||||
@@ -389,8 +436,12 @@ pub fn handle_key(
|
||||
vec![]
|
||||
}
|
||||
"escape" => {
|
||||
// Peel one layer at a time rather than dumping you back at the top.
|
||||
if state.open_album.is_some() {
|
||||
// Peel one layer at a time rather than dumping you back at the top. The
|
||||
// page is the outermost layer: ESC on the room or typing page comes back
|
||||
// to the player, and only then starts unwinding the browse hierarchy.
|
||||
if state.page != Page::Music {
|
||||
state.page = Page::Music;
|
||||
} else if state.open_album.is_some() {
|
||||
state.open_album = None;
|
||||
} else if state.play_view {
|
||||
state.play_view = false;
|
||||
@@ -621,6 +672,37 @@ mod tests {
|
||||
assert!(!state.can_go_back());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_tab_cycles_the_pages_and_plain_tab_still_cycles_groups() {
|
||||
let mut state = UiState::default();
|
||||
for expected in [Page::Room, Page::Typing, Page::Music] {
|
||||
handle_key("tab", true, false, &mut state, &empty(), &[0, 0, 0], 0);
|
||||
assert_eq!(state.page, expected);
|
||||
}
|
||||
// And the outer cycle has not eaten the inner one.
|
||||
press("tab", &mut state);
|
||||
assert_eq!(state.group, Some(Group::Music));
|
||||
assert_eq!(state.page, Page::Music);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_comes_back_from_another_page_before_it_touches_the_browse_state() {
|
||||
let mut state = UiState {
|
||||
page: Page::Room,
|
||||
group: Some(Group::Music),
|
||||
..Default::default()
|
||||
};
|
||||
press("escape", &mut state);
|
||||
assert_eq!(state.page, Page::Music);
|
||||
assert_eq!(
|
||||
state.group,
|
||||
Some(Group::Music),
|
||||
"the player was left as it was"
|
||||
);
|
||||
press("escape", &mut state);
|
||||
assert_eq!(state.group, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_cycles_the_three_groups_and_wraps() {
|
||||
let mut state = UiState::default();
|
||||
|
||||
Reference in New Issue
Block a user