Skip to content

Commit 617de75

Browse files
committed
feat(desktop): the bot's cursor on the owner's screen
The owner asked to see the bot's cursor on their own screen, not only in the panel. The outline's window now draws it over the window in use: an arrow in the bot's color with its name in a pill beside it, gliding to the point of each action, a growing ring where it clicks and dots while it types. The owner's mouse never moves for it. Reading leaves it where it was; another window starts without one until the bot acts there.
1 parent 7b9c801 commit 617de75

8 files changed

Lines changed: 409 additions & 58 deletions

File tree

‎crates/botloftd/src/desktop/outline.rs‎

Lines changed: 109 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,19 @@
22
//! 24.9): it shows when a bot reads or acts in a window and follows the
33
//! window while the bot's turn goes on, until the bot leaves the desktop
44
//! alone for a minute, its turn ends or the owner stops it. One window at
5-
//! a time: the one used last.
5+
//! a time: the one used last. The bot's cursor goes with it, at the point
6+
//! of its last action in that window.
67
78
use std::collections::HashMap;
89
use std::sync::Arc;
910
use std::time::{Duration, Instant};
1011

1112
use botloft_core::ids::BotId;
12-
use botloft_core::protocol::{BotState, DesktopState};
13+
use botloft_core::protocol::{BotState, DesktopActionKind, DesktopState};
1314
use tokio::sync::broadcast::error::RecvError;
1415

1516
use super::notice::color;
16-
use crate::platform::desktop;
17+
use crate::platform::desktop::{self, ScreenCursor};
1718
use crate::state::{Daemon, Event};
1819

1920
/// How often the outline catches up with its window.
@@ -29,11 +30,17 @@ struct Outlined {
2930
bot: BotId,
3031
window: u64,
3132
color: (u8, u8, u8),
33+
name: String,
34+
/// Where the bot acted last in the window.
35+
cursor: Option<ScreenCursor>,
3236
used: Instant,
3337
/// When the bot's turn ended, if it did.
3438
turn_over: Option<Instant>,
3539
}
3640

41+
/// What the outline shows now: the window, the bot's color and cursor.
42+
type Shown = (u64, (u8, u8, u8), Option<ScreenCursor>);
43+
3744
/// What the outline does, apart from the screen.
3845
#[derive(Debug, Default)]
3946
struct Follow {
@@ -44,11 +51,12 @@ struct Follow {
4451
}
4552

4653
impl Follow {
47-
/// A bot read or acted in a window, or the owner stopped it.
54+
/// A bot read or acted in a window, or the owner stopped it; `who`
55+
/// gives the bot's color and name.
4856
fn changed(
4957
&mut self,
5058
state: &DesktopState,
51-
color: impl FnOnce() -> (u8, u8, u8),
59+
who: impl FnOnce() -> ((u8, u8, u8), String),
5260
now: Instant,
5361
) {
5462
let mine = self
@@ -68,14 +76,34 @@ impl Follow {
6876
if self.last.insert(state.bot_id.clone(), at) == Some(at) {
6977
return;
7078
}
71-
let color = match &self.outlined {
72-
Some(outlined) if mine => outlined.color,
73-
_ => color(),
79+
let (color, name, before) = match self.outlined.take() {
80+
Some(outlined) if mine => {
81+
let before = outlined.cursor.filter(|_| outlined.window == window.id);
82+
(outlined.color, outlined.name, before)
83+
}
84+
_ => {
85+
let (color, name) = who();
86+
(color, name, None)
87+
}
88+
};
89+
// Only reading leaves the cursor where it was.
90+
let cursor = match &state.action {
91+
Some(action) => action.x.zip(action.y).map(|(x, y)| ScreenCursor {
92+
x,
93+
y,
94+
click: action.kind == DesktopActionKind::Click,
95+
typing: action.kind == DesktopActionKind::Type,
96+
name: name.clone(),
97+
at,
98+
}),
99+
None => before,
74100
};
75101
self.outlined = Some(Outlined {
76102
bot: state.bot_id.clone(),
77103
window: window.id,
78104
color,
105+
name,
106+
cursor,
79107
used: now,
80108
turn_over: None,
81109
});
@@ -93,7 +121,7 @@ impl Follow {
93121
}
94122

95123
/// The window to outline now, if any.
96-
fn now(&mut self, now: Instant) -> Option<(u64, (u8, u8, u8))> {
124+
fn now(&mut self, now: Instant) -> Option<Shown> {
97125
let outlined = self.outlined.as_ref()?;
98126
let over = outlined
99127
.turn_over
@@ -103,7 +131,7 @@ impl Follow {
103131
self.outlined = None;
104132
return None;
105133
}
106-
Some((outlined.window, outlined.color))
134+
Some((outlined.window, outlined.color, outlined.cursor.clone()))
107135
}
108136
}
109137

@@ -121,8 +149,8 @@ pub async fn run(daemon: Arc<Daemon>) {
121149
loop {
122150
tokio::select! {
123151
_ = tick.tick() => match follow.now(Instant::now()) {
124-
Some((window, color)) => {
125-
desktop::outline_show(window, color);
152+
Some((window, color, cursor)) => {
153+
desktop::outline_show(window, color, cursor);
126154
shown = true;
127155
}
128156
None if shown => {
@@ -133,11 +161,17 @@ pub async fn run(daemon: Arc<Daemon>) {
133161
},
134162
event = events.recv() => match event {
135163
Ok(Event::DesktopChanged(state)) => {
136-
let hex = || {
164+
let who = || {
137165
let bot = daemon.store().bot(&state.bot_id).ok().flatten();
138-
color(&bot.map(|bot| bot.color).unwrap_or_default())
166+
let (hex, name) = bot.map(|bot| (bot.color, bot.name)).unwrap_or_default();
167+
(color(&hex), name)
139168
};
140-
follow.changed(&state, hex, Instant::now());
169+
follow.changed(&state, who, Instant::now());
170+
// The cursor sets off now, not at the next tick.
171+
if let Some((window, color, cursor)) = follow.now(Instant::now()) {
172+
desktop::outline_show(window, color, cursor);
173+
shown = true;
174+
}
141175
}
142176
Ok(Event::BotState(change)) => {
143177
follow.turn(&change.bot_id, working(change.state), Instant::now());
@@ -151,7 +185,7 @@ pub async fn run(daemon: Arc<Daemon>) {
151185

152186
#[cfg(test)]
153187
mod tests {
154-
use botloft_core::protocol::DesktopWindow;
188+
use botloft_core::protocol::{DesktopAction, DesktopWindow};
155189

156190
use super::*;
157191

@@ -171,47 +205,90 @@ mod tests {
171205

172206
const BLUE: (u8, u8, u8) = (0x5E, 0xC8, 0xFF);
173207

208+
/// The window and color outlined at `now`, without the cursor.
209+
fn spot(follow: &mut Follow, now: Instant) -> Option<(u64, (u8, u8, u8))> {
210+
follow.now(now).map(|(window, color, _)| (window, color))
211+
}
212+
213+
#[test]
214+
fn the_cursor_is_at_the_last_action_and_stays_while_the_bot_reads() {
215+
let mut follow = Follow::default();
216+
let (scout, start) = (BotId::generate(), Instant::now());
217+
let mut clicked = used(&scout, 7, 1);
218+
clicked.action = Some(DesktopAction {
219+
kind: DesktopActionKind::Click,
220+
target: "Save".to_owned(),
221+
option: None,
222+
x: Some(0.25),
223+
y: Some(0.5),
224+
});
225+
follow.changed(&clicked, || (BLUE, "Scout".to_owned()), start);
226+
let cursor = follow
227+
.now(start)
228+
.and_then(|(_, _, cursor)| cursor)
229+
.expect("cursor");
230+
assert_eq!(
231+
(cursor.x, cursor.y, cursor.click, cursor.typing),
232+
(0.25, 0.5, true, false)
233+
);
234+
assert_eq!((cursor.name.as_str(), cursor.at), ("Scout", 1));
235+
236+
follow.changed(&used(&scout, 7, 2), || (BLUE, "Scout".to_owned()), start);
237+
let kept = follow.now(start).and_then(|(_, _, cursor)| cursor);
238+
assert_eq!(kept.map(|cursor| cursor.at), Some(1), "reading leaves it");
239+
// Another window starts without one.
240+
follow.changed(&used(&scout, 8, 3), || (BLUE, "Scout".to_owned()), start);
241+
assert_eq!(follow.now(start).and_then(|(_, _, cursor)| cursor), None);
242+
}
243+
174244
#[test]
175245
fn the_outline_follows_the_window_in_use_until_the_turn_ends() {
176246
let mut follow = Follow::default();
177247
let (scout, start) = (BotId::generate(), Instant::now());
178-
assert_eq!(follow.now(start), None);
179-
follow.changed(&used(&scout, 7, 1), || BLUE, start);
180-
assert_eq!(follow.now(start), Some((7, BLUE)));
248+
assert_eq!(spot(&mut follow, start), None);
249+
follow.changed(&used(&scout, 7, 1), || (BLUE, "Scout".to_owned()), start);
250+
assert_eq!(spot(&mut follow, start), Some((7, BLUE)));
181251

182252
// Its turn ends: a moment more, then gone.
183253
follow.turn(&scout, false, start);
184-
assert_eq!(follow.now(start + Duration::from_secs(1)), Some((7, BLUE)));
185-
assert_eq!(follow.now(start + AFTER_TURN), None);
254+
assert_eq!(
255+
spot(&mut follow, start + Duration::from_secs(1)),
256+
Some((7, BLUE))
257+
);
258+
assert_eq!(spot(&mut follow, start + AFTER_TURN), None);
186259
}
187260

188261
#[test]
189262
fn a_minute_away_from_the_desktop_or_a_stop_takes_it_away() {
190263
let mut follow = Follow::default();
191264
let (scout, start) = (BotId::generate(), Instant::now());
192-
follow.changed(&used(&scout, 7, 1), || BLUE, start);
193-
assert_eq!(follow.now(start + IDLE_FOR), None);
265+
follow.changed(&used(&scout, 7, 1), || (BLUE, "Scout".to_owned()), start);
266+
assert_eq!(spot(&mut follow, start + IDLE_FOR), None);
194267

195-
follow.changed(&used(&scout, 7, 2), || BLUE, start);
268+
follow.changed(&used(&scout, 7, 2), || (BLUE, "Scout".to_owned()), start);
196269
let mut stopped = used(&scout, 7, 2);
197270
stopped.stopped = true;
198-
follow.changed(&stopped, || BLUE, start);
199-
assert_eq!(follow.now(start), None);
271+
follow.changed(&stopped, || (BLUE, "Scout".to_owned()), start);
272+
assert_eq!(spot(&mut follow, start), None);
200273
// Letting it go on is not a use.
201274
stopped.stopped = false;
202-
follow.changed(&stopped, || BLUE, start);
203-
assert_eq!(follow.now(start), None);
275+
follow.changed(&stopped, || (BLUE, "Scout".to_owned()), start);
276+
assert_eq!(spot(&mut follow, start), None);
204277
}
205278

206279
#[test]
207280
fn the_window_used_last_is_the_one_outlined() {
208281
let mut follow = Follow::default();
209282
let (scout, writer, start) = (BotId::generate(), BotId::generate(), Instant::now());
210-
follow.changed(&used(&scout, 7, 1), || BLUE, start);
211-
follow.changed(&used(&writer, 9, 2), || (1, 2, 3), start);
212-
assert_eq!(follow.now(start), Some((9, (1, 2, 3))));
283+
follow.changed(&used(&scout, 7, 1), || (BLUE, "Scout".to_owned()), start);
284+
follow.changed(
285+
&used(&writer, 9, 2),
286+
|| ((1, 2, 3), "Writer".to_owned()),
287+
start,
288+
);
289+
assert_eq!(spot(&mut follow, start), Some((9, (1, 2, 3))));
213290
// Another bot's turn ending leaves it.
214291
follow.turn(&scout, false, start);
215-
assert_eq!(follow.now(start + AFTER_TURN), Some((9, (1, 2, 3))));
292+
assert_eq!(spot(&mut follow, start + AFTER_TURN), Some((9, (1, 2, 3))));
216293
}
217294
}

‎crates/botloftd/src/platform/desktop/mod.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ mod windows;
1616
use std::path::PathBuf;
1717

1818
pub use blocked::{Never, never};
19-
pub use overlay::{notice_hide, notice_show, outline_hide, outline_show};
19+
pub use overlay::{ScreenCursor, notice_hide, notice_show, outline_hide, outline_show};
2020
pub use read::{Control, LINES_MAX, by_reference, reference, render};
2121

2222
/// The largest picture of a window the bot gets, in logical pixels (24.4).

‎crates/botloftd/src/platform/desktop/overlay.rs‎

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,22 @@
55
#[cfg(windows)]
66
use super::windows;
77

8+
/// The bot's cursor on the owner's screen, over the window it uses.
9+
#[derive(Debug, Clone, PartialEq)]
10+
pub struct ScreenCursor {
11+
/// Where, as fractions of the window's frame from its top left corner.
12+
pub x: f64,
13+
pub y: f64,
14+
/// A ring shows where it clicked.
15+
pub click: bool,
16+
/// Dots show while it types.
17+
pub typing: bool,
18+
/// The bot's name, beside the arrow.
19+
pub name: String,
20+
/// When the bot acted: a new one glides there.
21+
pub at: i64,
22+
}
23+
824
/// Shows the notice that a bot uses the real mouse and keyboard: `text`
925
/// in a pill, a border in `color` around the monitor of window `over`
1026
/// (spec 24.7).
@@ -21,13 +37,14 @@ pub fn notice_hide() {
2137
windows::notice_hide();
2238
}
2339

24-
/// Outlines window `over` in `color`, or moves the outline to where the
25-
/// window is now; hidden while the window is minimized or gone.
26-
pub fn outline_show(over: u64, color: (u8, u8, u8)) {
40+
/// Outlines window `over` in `color`, with the bot's `cursor` in it, or
41+
/// moves the outline to where the window is now; hidden while the window
42+
/// is minimized or gone.
43+
pub fn outline_show(over: u64, color: (u8, u8, u8), cursor: Option<ScreenCursor>) {
2744
#[cfg(windows)]
28-
windows::outline_show(over, color);
45+
windows::outline_show(over, color, cursor);
2946
#[cfg(not(windows))]
30-
let _ = (over, color);
47+
let _ = (over, color, cursor);
3148
}
3249

3350
/// Takes the outline away.

‎crates/botloftd/src/platform/desktop/windows/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ mod capture;
99
mod input;
1010
mod notice;
1111
mod outline;
12+
mod outline_cursor;
1213
#[cfg(test)]
1314
mod outline_tests;
1415
mod owner;

0 commit comments

Comments
 (0)