From e06f2e73fbea70352aef2d123f4ee7422d11c367 Mon Sep 17 00:00:00 2001 From: dragon Date: Wed, 14 Oct 2026 14:51:14 +0800 Subject: [PATCH] Tap a task card to open that card's agent The daemon gives each item in `tasks` an `id`: eight hex digits of FNV-1a over the activity's key, the same for as long as the activity lasts. On a box with a touchscreen, a tap on a card sends {"version":1,"event":"focus","task":""}, and the daemon opens that activity's source the way K2 opens its choice. An activity that has ended or has no source opens nothing. Cards without an id, from an older daemon, send nothing (ADR-0017, #148). Two cards with the same title that swap places now redraw the box, since their ids moved; the CI test that saw no event there expects it. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- README.zh-CN.md | 2 +- daemon/src/activity.rs | 39 +++++++++++++++++++++++ daemon/src/ci.rs | 4 ++- daemon/src/main.rs | 28 ++++++++++++++++ docs/device-menu.md | 3 +- docs/protocol.md | 10 +++++- firmware-rs/core/examples/look_preview.rs | 4 +-- firmware-rs/core/examples/preview.rs | 9 +++--- firmware-rs/core/src/display.rs | 9 ++++++ firmware-rs/core/src/firmware.rs | 10 +++++- firmware-rs/core/tests/firmware.rs | 29 +++++++++++++++++ 12 files changed, 137 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 781f7295..ea6b4e1d 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ It has three modes: - **Pomodoro**: a focus timer, 25 minutes of focus and 5 of break. K0 starts, pauses and resumes, and a long press gives up; K1 switches between On duty and Pomodoro, and a long press opens the box's menu (volume, mute, status); K2 still takes you to the agent's window, and a long press mutes. Each phase ends with a chime and a spoken line, and the next one waits for you to press K0. Today's finished sessions and focus time are kept on the box, reset daily, and survive a restart. - **Leisure**: after On duty has been idle long enough, the buddy goes off to play. After five minutes it starts little skits (patrolling, kicking a ball, reading, counting stars, hide-and-seek, startling itself awake, talking in its sleep); after half an hour it gets sleepy and dims; at night, after 90 minutes of sleep, it turns the backlight off. Any agent activity or button press brings it straight back on duty. -Some boxes have a touchscreen. On one, tap a row of the box's menu to pick it, tap a key hint at the bottom of the menu to press that key or outside the menu to close it, and tap the buddy to pet it. Everything else stays on the keys, which work the same on every box. +Some boxes have a touchscreen. On one, tap a task card to go to that agent's window (K2 goes to the most pressing one), tap a row of the box's menu to pick it, tap a key hint at the bottom of the menu to press that key or outside the menu to close it, and tap the buddy to pet it. Everything else stays on the keys, which work the same on every box. Pomodoro runs entirely on the box: its timer and today's tally keep going without the Mac. Leisure's state also lives in the firmware, but it needs a live link to the daemon: if the box hears nothing from the Mac for 15 seconds it counts the link as lost, and Leisure returns to On duty until the link is back. Design notes: [`docs/pomodoro.md`](docs/pomodoro.md) and [`docs/leisure.md`](docs/leisure.md). diff --git a/README.zh-CN.md b/README.zh-CN.md index 2522993e..c76b946b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -38,7 +38,7 @@ VibeBuddy 是给 AI 编程 Agent 配的桌面宠物。它住在键盘旁一个 - **番茄钟**:给你计时,专注 25 分钟、休息 5 分钟。K0 开始、暂停、继续,长按放弃;K1 在值班与番茄钟之间切换,长按打开盒子上的菜单(音量、静音、状态);K2 照旧带你回到 Agent 所在的窗口,长按静音。阶段结束响铃并播报,下一阶段等你按 K0 再开始。今天完成了几次、专注了多久记在盒子上,按日清零,重启不丢。 - **休闲**:值班空闲够久之后它自己去玩。五分钟后开始演小剧目(巡逻、踢球、看书、数星星、躲猫猫、被自己吓醒、梦话),半小时后困了转暗睡觉,夜里睡够 90 分钟关背光。Agent 一有动静或按任何键,立刻回来值班。 -部分盒子带触摸屏。在这样的盒子上,点盒子菜单里的一行就选中它,点菜单底部的按键提示就等于按那个键,点菜单外面就关掉菜单,点小助手就是摸摸它。其余操作仍然用按键,每台盒子都一样。 +部分盒子带触摸屏。在这样的盒子上,点一张任务卡就回到那个 Agent 的窗口(K2 去的是最要紧的那个),点盒子菜单里的一行就选中它,点菜单底部的按键提示就等于按那个键,点菜单外面就关掉菜单,点小助手就是摸摸它。其余操作仍然用按键,每台盒子都一样。 番茄钟完全在盒子上运行,计时和当日次数不依赖 Mac 端。休闲的状态同样在固件里,但它需要与 daemon 的链路在线:盒子 15 秒收不到 Mac 的消息就判定链路断开,休闲随即回到值班,链路恢复前不会再进入。设计见 [`docs/pomodoro.md`](docs/pomodoro.md) 与 [`docs/leisure.md`](docs/leisure.md)。 diff --git a/daemon/src/activity.rs b/daemon/src/activity.rs index 35233971..bdc61070 100644 --- a/daemon/src/activity.rs +++ b/daemon/src/activity.rs @@ -707,6 +707,7 @@ impl ActivityTracker { .take(MAX_VISIBLE_TASKS) .map(|(key, activity)| { let mut task = json!({ + "id": card_id(key), "title": activity.title, "status": match activity.status { ActivityStatus::Working => "working", @@ -776,6 +777,12 @@ impl ActivityTracker { }) } + /// Where a tapped card leads (ADR-0019): the source of the activity whose `card_id` the box named. None when + /// that activity has ended since, or has no source to open. + pub fn card_source(&self, card: &str) -> Option { + self.activities.iter().find(|(key, _)| card_id(key) == card).and_then(|(_, activity)| activity.source.clone()) + } + /// Stamp the fields that only mean something at send time: card timers and today's stats. /// /// They can't go into `activity_snapshot`, because that snapshot takes part in dedup. Both fields @@ -1006,6 +1013,13 @@ fn format_duration(seconds: u64) -> String { } } +/// A task card's id: short, so the box can send it back on a tap, and the same for an activity as long as it lasts. +/// FNV-1a of the activity's key; a clash between the few cards on screen is unlikely, and would only open the other one. +fn card_id(key: &str) -> String { + let hash = key.bytes().fold(0x811c_9dc5_u32, |hash, byte| (hash ^ u32::from(byte)).wrapping_mul(0x0100_0193)); + format!("{hash:08x}") +} + fn event(name: &str, session_id: &str, title: &str) -> Event { Event { version: VERSION, @@ -1251,6 +1265,31 @@ mod tests { assert_eq!(ended[RECENTLY_ENDED - 1].title, "TASK 5"); } + #[test] + fn a_tapped_card_leads_to_its_own_activity() { + let mut tracker = ActivityTracker::default(); + let older = id("older", "older:1"); + tracker.observe(&older, "CC:OLDER", ActivityStatus::Working); + let codex = ActivitySource::Codex { + thread_id: "older".to_owned(), + surface: Surface::default(), + }; + tracker.associate_source(&older, codex.clone()); + let newer = id("newer", "newer:1"); + tracker.require_input(&newer, "CX:NEWER"); + + let snapshot = tracker.activity_snapshot().expect("there should be a visible activity"); + let tasks = snapshot.extra["tasks"].as_array().expect("cards"); + let older_card = tasks[1]["id"].as_str().expect("every card has an id"); + assert_eq!(tasks[1]["title"], "CC:OLDER"); + assert_eq!(older_card, card_id("older:1")); + assert_eq!(older_card.len(), 8); + // Not K2's choice, which would be the one waiting for input: the card that was tapped. + assert_eq!(tracker.card_source(older_card), Some(codex)); + assert_eq!(tracker.card_source(tasks[0]["id"].as_str().unwrap()), None, "no source to open"); + assert_eq!(tracker.card_source("00000000"), None, "gone, or never there"); + } + #[test] fn source_survives_activity_refreshes() { let mut tracker = ActivityTracker::default(); diff --git a/daemon/src/ci.rs b/daemon/src/ci.rs index cfe45f73..45d2492f 100644 --- a/daemon/src/ci.rs +++ b/daemon/src/ci.rs @@ -403,7 +403,9 @@ mod tests { &mut tracker, vec![run(11, "in_progress", ""), run(10, "completed", "failure")], ); - assert_eq!(names(&events), ["task.error"]); + // Run 11 moves to the top first. Both cards read CI:VIBE-BUDDY, but their ids swap places, and a box + // with a touchscreen needs them in their new order (ADR-0019). + assert_eq!(names(&events), ["task.start", "task.error"]); let events = poll( &mut watcher, diff --git a/daemon/src/main.rs b/daemon/src/main.rs index 43977d27..dd1ffd06 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -412,6 +412,8 @@ async fn publish_device_message(state: &AppState, message: DeviceMessage) { }; if is_k2_press(&event) { open_k2_source(state).await; + } else if let Some(card) = tapped_card(&event) { + open_card_source(state, card).await; } else if !event.event.starts_with("voice.") && event.event != "echo" { info!(event = %event.event, "ignoring unbound device event"); } @@ -515,6 +517,24 @@ async fn open_k2_source(state: &AppState) { } } +/// A tap on a task card on a box with a touchscreen (ADR-0019): the card's id. +fn tapped_card(event: &Event) -> Option<&str> { + (event.event == "focus").then(|| event.extra.get("task").and_then(|value| value.as_str())).flatten() +} + +/// Opens the agent behind a tapped card. If it has ended since, or has nothing to open, nothing happens: opening +/// something other than what was tapped would be more confusing than nothing. +async fn open_card_source(state: &AppState, card: &str) { + let Some(source) = state.activities.lock().await.card_source(card) else { + info!(%card, "a tapped card's activity is gone or has no source"); + return; + }; + match source_opener::open(source).await { + Ok(link) => info!(%link, "opened a tapped card's source"), + Err(error) => warn!(%error, "failed to open a tapped card's source"), + } +} + fn is_k2_press(event: &Event) -> bool { event.event == "button" && event.extra.get("button").and_then(|value| value.as_str()) == Some("K2") @@ -2220,6 +2240,14 @@ mod tests { assert_eq!(local_day(&now), 20260915); } + #[test] + fn a_focus_event_names_the_tapped_card() { + let event: Event = serde_json::from_str(r#"{"version":1,"event":"focus","task":"0d4e77c3"}"#).expect("focus event should parse"); + assert_eq!(tapped_card(&event), Some("0d4e77c3")); + let button: Event = serde_json::from_str(r#"{"version":1,"event":"button","button":"K2","action":"press"}"#).expect("button event should parse"); + assert_eq!(tapped_card(&button), None); + } + #[test] fn only_a_k2_press_requests_source_opening() { let event: Event = serde_json::from_str( diff --git a/docs/device-menu.md b/docs/device-menu.md index 17569f8a..c358998a 100644 --- a/docs/device-menu.md +++ b/docs/device-menu.md @@ -93,9 +93,10 @@ Some boxes have a touchscreen (ADR-0019). The firmware looks for one at boot and - **A row** is selected and acted on at once, as K1 to the row and then K0 would. VOLUME steps, MUTE toggles, COMPUTERS opens; FORGET ALL and NEW ID still ask first. - **A key hint** along the bottom is that key: tap CLOSE to close, BACK to go back. - **The dimmed screen around the panel** closes the menu, as K2 does. +- **A task card** on duty takes you to that card's agent: the box sends the card's id in a `focus` event and the daemon opens it, the way K2 opens the most pressing one ([`protocol.md`](protocol.md)). - **The buddy** on duty, outside the menu, is petted: it hops with a heart for a moment, and nothing else changes. -The tapped row, hint or buddy is outlined for a moment, so a tap that landed is seen to. A tap in Leisure only calls the buddy back, like any key, and wakes a screen dimmed for the night. The menu still opens on K1 held, and the Pomodoro screen has nothing to tap: a box on a desk gets brushed, and the timer stays on K0. Every tap prints `TAP ` as a diagnostic line. +The tapped row, hint, card or buddy is outlined for a moment, so a tap that landed is seen to. A tap in Leisure only calls the buddy back, like any key, and wakes a screen dimmed for the night. The menu still opens on K1 held, and the Pomodoro screen has nothing to tap: a box on a desk gets brushed, and the timer stays on K0. Every tap prints `TAP ` as a diagnostic line. ![A tapped row](images/touch-row.png) ![The buddy petted](images/touch-pet.png) diff --git a/docs/protocol.md b/docs/protocol.md index 337c726c..74dbdbfd 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -48,7 +48,7 @@ What the current firmware recognizes: {"version":1,"event":"task.start","title":"GAMMA","tasks":[{"title":"GAMMA","status":"working","elapsed_s":75},{"title":"BETA","status":"input_required","elapsed_s":900}],"stats":["7 DONE","4 ASKS","1H23 BUSY"]} ``` -Each item contains `title`, `status` and `elapsed_s`, with optional `project`; the current status values are `working`, `input_required`, `done` and `failed`. `title` is the card's first line: the name the agent gave the session itself (the Claude app's session title, Codex's thread name) or the branch name, falling back to the project name only when neither exists. `project` is the project name, which the device draws on the second line unless it duplicates the title. Older firmware ignores `tasks` and `project` under the v1 rules. Future events may include `task.progress`, `task.cancelled`, `agent.waiting`, `message`, `system` and `device.status`. +Each item contains `id`, `title`, `status` and `elapsed_s`, with optional `project`; `id` is eight hex digits that stay the same for an activity as long as it lasts, which a box with a touchscreen sends back when its card is tapped (see `focus` below); the current status values are `working`, `input_required`, `done` and `failed`. `title` is the card's first line: the name the agent gave the session itself (the Claude app's session title, Codex's thread name) or the branch name, falling back to the project name only when neither exists. `project` is the project name, which the device draws on the second line unless it duplicates the title. Older firmware ignores `tasks`, `project` and `id` under the v1 rules. Future events may include `task.progress`, `task.cancelled`, `agent.waiting`, `message`, `system` and `device.status`. `elapsed_s` is the number of seconds since the activity entered its **current state**, not since the previous event: a working card answers "how long has this turn been running", and a card waiting for input answers "how long has it been waiting". The device keeps counting on its own after receiving it, because `vibebuddyd` deduplicates and sends nothing while the visible state is unchanged, yet the number on screen has to keep ticking. And precisely because of deduplication, `elapsed_s` and `stats` are stamped onto the event only after deduplication; putting them in the snapshot would turn every tool event into a redraw, constantly knocking the working animation back to its first frame. @@ -88,6 +88,14 @@ Device-to-Mac events use NDJSON as well, for example: Currently only a K2 short press is reported, in every mode. K0 (Pomodoro), K1 (switch mode; long-press for the device menu, see [`device-menu.md`](device-menu.md)), a K2 long press (mute) and every key while the menu is open are consumed by the firmware itself and not reported. A short press counts only on release, because only on release do you know it wasn't the start of a long press, so reporting is deferred from press to release. See [`pomodoro.md`](pomodoro.md) for modes and Pomodoro, and [`leisure.md`](leisure.md) for Leisure. On receiving it, `vibebuddyd` picks a destination in this order: first, a task waiting for someone to answer (it's what the screen's main state shows, and it's blocking a person); next, the task most recently announced as finished (it has left the card stack and can no longer be seen on screen, whereas a task still running stays on screen and never needs K2 to find it); only then the newest work item. The destination is replaced by the next announcement, with no time window: the whole point of this device is that you're away from the computer, and expiring the destination on a wall clock would assume the user is always sitting nearby. Once an activity is chosen, where it runs decides what happens: only if it runs in the agent's own desktop app is a deeplink used (a top-level Codex activity opens its own thread, a sub-agent activity opens the parent thread that owns it, and Claude Code uses the desktop session id reported by the hook via `claude://code/continue?session=`); if it runs in another app (a terminal, an editor's integrated terminal), that app is brought to the front, using a bundle id reported by the hook, so the daemon doesn't need to know the app; a session with no host (SSH, a background process) is skipped outright and the next candidate is tried. GitHub Actions opens the corresponding run. When there's no current activity, it falls back to the most recent locatable source; that destination is written to a local state file, so it survives a daemon restart. Candidates are tried one by one in this order: a Codex thread is first checked against the local thread table, and skipped if it isn't there, so a blank session doesn't get opened. K0, K1 and long-press/release are not yet bound. +On a box with a touchscreen (ADR-0019), a tap on a task card names that card's activity instead of leaving the choice to the daemon: + +```json +{"version":1,"event":"focus","task":"0d4e77c3"} +``` + +`task` is the `id` the card came with. `vibebuddyd` opens that activity's source the same way K2 opens its choice, on macOS and Linux alike. If the activity has ended since, or has no source to open, nothing happens and the daemon logs it: opening something other than what was tapped would be more confusing than nothing. Cards from an older daemon have no `id`, and a tap on one sends nothing. + The `READY`, `EVENT`, `TITLE` and `ERROR` lines the firmware prints are diagnostic text for verifying the on-device link, not formal device-to-Mac JSON events. In the same category are `MODE DUTY` / `POMODORO` / `LEISURE` (mode switches); `POMODORO FOCUS START` / `FOCUS END` / `BREAK START` / `BREAK END` / `PAUSED` / `RESUMED` / `STOPPED` / `BREAK SKIPPED` (Pomodoro transitions); `LEISURE ALERT` / `BORED` / `SLEEPY`, `LEISURE SKIT `, `LEISURE LIGHTS OUT` / `ON` (Leisure); and `CLOCK HOUR ` (the received hour changed), `TALLY LOADED S DAY ` (the daily record restored at boot), `MUTE ON` / `OFF` (K2 long press, or the menu), `MENU OPEN` / `MENU CLOSED` (the device menu), `TOUCH CHSC5432` / `TOUCH NONE` (whether the box has a touchscreen, at boot and in answer to `device.hello`; ADR-0019), `TAP ` (a tap on the touchscreen, in screen pixels) and `AUDIO MUTED ` (a voice line swallowed while muted). Pomodoro and Leisure state lives only in the firmware; the Mac just logs it. ## Device maintenance: blink to identify and voice pack writes diff --git a/firmware-rs/core/examples/look_preview.rs b/firmware-rs/core/examples/look_preview.rs index f849f3a7..7f8939f6 100644 --- a/firmware-rs/core/examples/look_preview.rs +++ b/firmware-rs/core/examples/look_preview.rs @@ -63,8 +63,8 @@ fn main() { display.set_look(&mut screen, &scene!(), Some(look)); let cards = [ - TaskInput { title: Some(b"CC:CHARACTERS"), state: State::Working, elapsed_s: 75, project: Some(b"VIBE-BUDDY") }, - TaskInput { title: Some(b"CX:EROS-TRAINING"), state: State::Working, elapsed_s: 900, project: None }, + TaskInput { title: Some(b"CC:CHARACTERS"), state: State::Working, elapsed_s: 75, project: Some(b"VIBE-BUDDY"), id: None }, + TaskInput { title: Some(b"CX:EROS-TRAINING"), state: State::Working, elapsed_s: 900, project: None, id: None }, ]; let states: [(&str, State, &[TaskInput], u32); 6] = [ ("idle", State::Idle, &[], 120), diff --git a/firmware-rs/core/examples/preview.rs b/firmware-rs/core/examples/preview.rs index 7e3a9efd..756d6508 100644 --- a/firmware-rs/core/examples/preview.rs +++ b/firmware-rs/core/examples/preview.rs @@ -124,12 +124,13 @@ fn main() { preview.snapshot("pomodoro_idle"); let tasks = [ - TaskInput { title: Some(b"CC:POMODORO TIMER FEATURE"), state: State::Working, elapsed_s: 75, project: Some(b"AGENT-BEACON") }, + TaskInput { title: Some(b"CC:POMODORO TIMER FEATURE"), state: State::Working, elapsed_s: 75, project: Some(b"AGENT-BEACON"), id: None }, TaskInput { title: Some(b"CX:EROS-TRAINING-INFRA"), state: State::InputRequired, elapsed_s: 900, project: Some(b"EROS-TRAINING-INFRA"), + id: None, }, ]; preview.show(State::InputRequired, Some(b"CC:AGENT-BEACON"), &tasks); @@ -187,9 +188,9 @@ fn main() { // Three cards: the state label keeps clear of the last one. preview.display.set_muted(&mut preview.screen, &scene!(preview), false); let three = [ - TaskInput { title: Some(b"CC:UPDATE README"), state: State::Working, elapsed_s: 20, project: Some(b"VIBEBUDDY") }, - TaskInput { title: Some(b"CX:BUILD FIRMWARE"), state: State::Working, elapsed_s: 60, project: Some(b"VIBEBUDDY") }, - TaskInput { title: Some(b"CI:APP TESTS"), state: State::Working, elapsed_s: 120, project: Some(b"VIBEBUDDY") }, + TaskInput { title: Some(b"CC:UPDATE README"), state: State::Working, elapsed_s: 20, project: Some(b"VIBEBUDDY"), id: None }, + TaskInput { title: Some(b"CX:BUILD FIRMWARE"), state: State::Working, elapsed_s: 60, project: Some(b"VIBEBUDDY"), id: None }, + TaskInput { title: Some(b"CI:APP TESTS"), state: State::Working, elapsed_s: 120, project: Some(b"VIBEBUDDY"), id: None }, ]; preview.show(State::Working, None, &three); preview.snapshot("pet_three_tasks"); diff --git a/firmware-rs/core/src/display.rs b/firmware-rs/core/src/display.rs index 0dccb90f..07230216 100644 --- a/firmware-rs/core/src/display.rs +++ b/firmware-rs/core/src/display.rs @@ -100,10 +100,13 @@ pub struct TaskInput<'a> { /// Owning project; drawn on the second line when the title is a session name, skipped when empty or /// the same as the title. pub project: Option<&'a [u8]>, + /// The daemon's id for the card's activity, which a tap on the card names (ADR-0019); None from an older daemon. + pub id: Option<&'a [u8]>, } struct Task { title: Vec, + id: Vec, project: Vec, state: State, elapsed_base: i32, @@ -1312,6 +1315,7 @@ impl Display { .map(|task| Task { title: truncated(task.title.unwrap_or(b"CODEX"), TITLE_BYTES), project: truncated(task.project.unwrap_or(b""), TITLE_BYTES), + id: task.id.unwrap_or_default().to_vec(), state: task.state, elapsed_base: task.elapsed_s, received_ms: scene.now_ms, @@ -1539,6 +1543,11 @@ impl Display { self.petted_until.is_some_and(|until| (now_ms.wrapping_sub(until) as i32) < 0) } + /// The id of the activity behind the card at `index`; None when the daemon sent none. + pub fn card_id(&self, index: usize) -> Option<&[u8]> { + self.tasks.get(index).map(|task| task.id.as_slice()).filter(|id| !id.is_empty()) + } + /// Where a target is drawn now, if it's on the screen at all. fn target_rect(&self, target: Target) -> Option { match (target, &self.panel) { diff --git a/firmware-rs/core/src/firmware.rs b/firmware-rs/core/src/firmware.rs index f4da3ae2..77adda19 100644 --- a/firmware-rs/core/src/firmware.rs +++ b/firmware-rs/core/src/firmware.rs @@ -956,7 +956,13 @@ impl Firmware { // Tapping away from the menu closes it, as K2 does. Target::OutsidePanel => self.on_button(board, ButtonEvent::K2Short), Target::Buddy => self.display.pet(board, &scene!(self, now)), - Target::Card(_) => {} + // The daemon opens that card's agent, as K2 opens the most pressing one. + Target::Card(index) => { + if let Some(id) = self.display.card_id(index) { + let line = text!(80, "{{\"version\":1,\"event\":\"focus\",\"task\":\"{}\"}}\n", core::str::from_utf8(id).unwrap_or_default()); + board.write(line.as_bytes()); + } + } } } @@ -1579,6 +1585,8 @@ impl Firmware { state: task_state(status), elapsed_s: number(item, "elapsed_s").map(|value| value as i32).unwrap_or(0), project: string(item, "project").map(str::as_bytes), + // Only a short id of letters and digits goes back out in a JSON line as is. + id: string(item, "id").filter(|id| id.len() <= 16 && id.bytes().all(|byte| byte.is_ascii_alphanumeric())).map(str::as_bytes), }); } diff --git a/firmware-rs/core/tests/firmware.rs b/firmware-rs/core/tests/firmware.rs index f7ce9a3b..6b18dcec 100644 --- a/firmware-rs/core/tests/firmware.rs +++ b/firmware-rs/core/tests/firmware.rs @@ -1892,6 +1892,35 @@ mod touchscreen { assert_ne!(pixel(&board, 202, 34), 0xF800); } + #[test] + fn a_tapped_card_asks_for_its_agent() { + let (mut firmware, mut board, _) = touch_box(); + send( + &mut firmware, + &mut board, + r#"{"version":1,"event":"task.start","title":"B","tasks":[{"title":"B","status":"working","elapsed_s":5,"id":"9f2c01ab"},{"title":"A","status":"input_required","elapsed_s":60,"id":"0d4e77c3"}]}"#, + ); + // The second card sits 38 px under the first. + assert_eq!(tap(&mut firmware, &mut board, 100, 104), ["TAP 100 104", r#"{"version":1,"event":"focus","task":"0d4e77c3"}"#]); + assert_eq!(tap(&mut firmware, &mut board, 100, 66), ["TAP 100 66", r#"{"version":1,"event":"focus","task":"9f2c01ab"}"#]); + // Under the cards there's nothing; in Pomodoro there are no cards. + assert_eq!(tap(&mut firmware, &mut board, 100, 130), ["TAP 100 130"]); + press(&mut firmware, &mut board, Key::K1, 50); + assert_eq!(tap(&mut firmware, &mut board, 100, 66), ["TAP 100 66"]); + } + + #[test] + fn a_card_from_an_older_daemon_or_with_an_odd_id_names_nothing() { + let (mut firmware, mut board, _) = touch_box(); + send( + &mut firmware, + &mut board, + r#"{"version":1,"event":"task.start","title":"B","tasks":[{"title":"B","status":"working","elapsed_s":5},{"title":"A","status":"working","elapsed_s":6,"id":"a\"b"}]}"#, + ); + assert_eq!(tap(&mut firmware, &mut board, 100, 66), ["TAP 100 66"]); + assert_eq!(tap(&mut firmware, &mut board, 100, 104), ["TAP 100 104"]); + } + #[test] fn a_slide_is_not_a_tap() { let (mut firmware, mut board, _) = touch_box();