Skip to content

Commit 204dd6a

Browse files
authored
Merge pull request #93 from httpsphl/feat/cursor-before-click
feat(browser): the owner's cursor gets there before the click
2 parents b828f55 + 513890b commit 204dd6a

8 files changed

Lines changed: 141 additions & 31 deletions

File tree

‎app/src/motion.css‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@
214214
border: 3px solid;
215215
border-radius: 9999px;
216216
pointer-events: none;
217-
animation: ripple 700ms var(--ease-out) 420ms both;
217+
animation: ripple 700ms var(--ease-out) 520ms both;
218218
}
219219

220220
.bot-typing {

‎crates/botloftd/src/browser/mod.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ pub use self::call::Call;
3939
pub use self::hands::{Asking, Hands, InputError, OWNER_WAIT, TakeError};
4040
pub use self::keys::{Key, find as find_key, names as key_names};
4141
pub use self::launch::find as find_program;
42-
pub use self::page::{Done, Scroll};
42+
pub use self::page::{Aim, Done, Scroll};
4343
pub use self::read::{READ_MAX, Reading};
4444
pub use self::session::Session;
4545
pub use self::sweep::{Want, run};

‎crates/botloftd/src/browser/page.rs‎

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
//! What a bot does in its active tab (spec 21.4): open, read, click, type,
22
//! choose, press, scroll, go back and look. Each action waits for the page
3-
//! to settle (spec 21.3).
3+
//! to settle (spec 21.3). Acting on an element takes two steps: `aim` finds
4+
//! it and scrolls it into view, so the owner's cursor can get there first
5+
//! (spec 21.7), and then the action itself.
46
57
use serde_json::{Value, json};
68

@@ -21,6 +23,14 @@ pub struct Done {
2123
pub chosen: Option<String>,
2224
}
2325

26+
/// An element found and in view, before anything is done to it.
27+
#[derive(Debug, Clone)]
28+
pub struct Aim {
29+
reference: String,
30+
point: Value,
31+
pub done: Done,
32+
}
33+
2434
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2535
pub enum Scroll {
2636
Down,
@@ -52,7 +62,7 @@ impl Session {
5262
}
5363

5464
/// Scrolls the element into view and finds where to click it.
55-
async fn locate(&self, reference: &str) -> Result<(Value, Done), BrowserError> {
65+
pub async fn aim(&self, reference: &str) -> Result<Aim, BrowserError> {
5666
let point = self.reader(&format!("point({})", json!(reference))).await?;
5767
if point["missing"] == true {
5868
return Err(BrowserError::Stale(reference.to_owned()));
@@ -73,7 +83,24 @@ impl Session {
7383
cover: point["cover"].as_str().map(str::to_owned),
7484
chosen: None,
7585
};
76-
Ok((point, done))
86+
Ok(Aim {
87+
reference: reference.to_owned(),
88+
point,
89+
done,
90+
})
91+
}
92+
93+
/// `aim` for a text field: anything else fails before the cursor goes
94+
/// there.
95+
pub async fn aim_field(&self, reference: &str) -> Result<Aim, BrowserError> {
96+
let aim = self.aim(reference).await?;
97+
if aim.point["text"] != true {
98+
let role = aim.point["role"].as_str().unwrap_or("element");
99+
return Err(BrowserError::Page(format!(
100+
"{reference} is a {role}, not a text field; browser_type only types into text fields"
101+
)));
102+
}
103+
Ok(aim)
77104
}
78105

79106
async fn mouse_click(&self, (x, y): (f64, f64)) -> Result<(), BrowserError> {
@@ -87,27 +114,23 @@ impl Session {
87114
Ok(())
88115
}
89116

90-
pub async fn click(&self, reference: &str) -> Result<Done, BrowserError> {
91-
let (_, done) = self.locate(reference).await?;
117+
pub async fn click(&self, Aim { done, .. }: Aim) -> Result<Done, BrowserError> {
92118
let marks = self.marks();
93119
self.mouse_click(done.point.unwrap_or_default()).await?;
94120
self.settle(marks).await;
95121
Ok(done)
96122
}
97123

124+
/// Types into a field from `aim_field`.
98125
pub async fn type_text(
99126
&self,
100-
reference: &str,
127+
aim: Aim,
101128
text: &str,
102129
submit: bool,
103130
) -> Result<Done, BrowserError> {
104-
let (point, done) = self.locate(reference).await?;
105-
if point["text"] != true {
106-
let role = point["role"].as_str().unwrap_or("element");
107-
return Err(BrowserError::Page(format!(
108-
"{reference} is a {role}, not a text field; browser_type only types into text fields"
109-
)));
110-
}
131+
let Aim {
132+
reference, done, ..
133+
} = aim;
111134
let marks = self.marks();
112135
self.mouse_click(done.point.unwrap_or_default()).await?;
113136
let ready = self
@@ -135,8 +158,12 @@ impl Session {
135158
Ok(done)
136159
}
137160

138-
pub async fn choose(&self, reference: &str, option: &str) -> Result<Done, BrowserError> {
139-
let (_, mut done) = self.locate(reference).await?;
161+
pub async fn choose(&self, aim: Aim, option: &str) -> Result<Done, BrowserError> {
162+
let Aim {
163+
reference,
164+
mut done,
165+
..
166+
} = aim;
140167
let marks = self.marks();
141168
let result = self
142169
.reader(&format!("select({}, {})", json!(reference), json!(option)))
@@ -237,7 +264,7 @@ impl Session {
237264
/// Scrolls until the element is in the middle of the screen.
238265
pub async fn scroll_to(&self, reference: &str) -> Result<Done, BrowserError> {
239266
let marks = self.marks();
240-
let (_, done) = self.locate(reference).await?;
267+
let Aim { done, .. } = self.aim(reference).await?;
241268
self.settle(marks).await;
242269
Ok(done)
243270
}

‎crates/botloftd/src/browser/watch.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ impl Browsers {
3434
}
3535
}
3636

37+
/// Someone has the bot's browser open in the app.
38+
pub fn watched(&self, bot: &BotId) -> bool {
39+
lock(&self.slots)
40+
.get(bot)
41+
.is_some_and(|slot| slot.watchers > 0)
42+
}
43+
3744
/// The room a watching app has for the page, `width` by `height`: the
3845
/// page takes its shape, now or when the browser opens (spec 21.3).
3946
pub fn resize(&self, bot: &BotId, width: u32, height: u32) {

‎crates/botloftd/src/tools/browser.rs‎

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use tracing::debug;
1212

1313
use super::browser_args::Tool;
1414
use super::browser_help::ask_owner;
15-
use super::browser_reply::{answer, describe, report, unavailable};
15+
use super::browser_reply::{answer, describe, point, report, unavailable};
1616
use super::browser_sites::{allowed, page_allowed};
1717
use super::calls::explain;
1818
use crate::browser::{Done, OWNER_WAIT, Scroll, sites};
@@ -168,19 +168,38 @@ async fn run(
168168
}
169169
return Ok(Reply::Picture(data, line));
170170
}
171-
Tool::Click { reference } => (BrowserActionKind::Click, session.click(&reference).await),
171+
// The cursor goes first; these report before they act.
172+
Tool::Click { reference } => {
173+
let kind = BrowserActionKind::Click;
174+
let aim = session
175+
.aim(&reference)
176+
.await
177+
.map_err(|err| describe(&err))?;
178+
point(daemon, id, kind, &aim).await;
179+
(kind, session.click(aim).await)
180+
}
172181
Tool::Type {
173182
reference,
174183
text,
175184
submit,
176-
} => (
177-
BrowserActionKind::Type,
178-
session.type_text(&reference, &text, submit).await,
179-
),
180-
Tool::Select { reference, option } => (
181-
BrowserActionKind::Select,
182-
session.choose(&reference, &option).await,
183-
),
185+
} => {
186+
let kind = BrowserActionKind::Type;
187+
let aim = session
188+
.aim_field(&reference)
189+
.await
190+
.map_err(|err| describe(&err))?;
191+
point(daemon, id, kind, &aim).await;
192+
(kind, session.type_text(aim, &text, submit).await)
193+
}
194+
Tool::Select { reference, option } => {
195+
let kind = BrowserActionKind::Select;
196+
let aim = session
197+
.aim(&reference)
198+
.await
199+
.map_err(|err| describe(&err))?;
200+
point(daemon, id, kind, &aim).await;
201+
(kind, session.choose(aim, &option).await)
202+
}
184203
Tool::Press { key } => {
185204
let label = Some(key.key.trim().to_owned()).filter(|key| !key.is_empty());
186205
let result = session.press(key).await.map(|()| Done {
@@ -206,7 +225,13 @@ async fn run(
206225
Tool::Open { .. } | Tool::Close | Tool::AskOwner { .. } => unreachable!("handled above"),
207226
};
208227
let done = done.map_err(|err| describe(&err))?;
209-
report(daemon, id, kind, &done);
228+
let pointed = matches!(
229+
kind,
230+
BrowserActionKind::Click | BrowserActionKind::Type | BrowserActionKind::Select
231+
);
232+
if !pointed {
233+
report(daemon, id, kind, &done);
234+
}
210235
let mut notes = Vec::new();
211236
if let Some(cover) = &done.cover {
212237
notes.push(format!(

‎crates/botloftd/src/tools/browser_reply.rs‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22
//! the action for the owner's cursor, and errors in words the bot can act
33
//! on.
44
5+
use std::time::Duration;
6+
57
use botloft_core::ids::BotId;
68
use botloft_core::protocol::{BrowserAction, BrowserActionKind};
79

810
use super::browser::{Bot, Reply};
911
use super::browser_sites::outside_file;
10-
use crate::browser::{BrowserError, Done, Session, sites};
12+
use crate::browser::{Aim, BrowserError, Done, Session, sites};
1113
use crate::state::Daemon;
1214

1315
/// The page as the bot reads it, with what happened on the way.
@@ -54,6 +56,20 @@ pub(super) async fn answer(
5456
Ok(Reply::Text(out))
5557
}
5658

59+
/// How long the owner's cursor takes to glide to a point (`.bot-cursor` in
60+
/// the app's `motion.css`, 520 ms), with a little to spare.
61+
const GLIDE: Duration = Duration::from_millis(560);
62+
63+
/// Shows the owner where the bot is about to act and, while someone
64+
/// watches, waits for the cursor to get there, so the page changes after
65+
/// the cursor arrives and not before (spec 21.7).
66+
pub(super) async fn point(daemon: &Daemon, bot: &BotId, kind: BrowserActionKind, aim: &Aim) {
67+
report(daemon, bot, kind, &aim.done);
68+
if daemon.browsers.watched(bot) {
69+
tokio::time::sleep(GLIDE).await;
70+
}
71+
}
72+
5773
pub(super) fn report(daemon: &Daemon, bot: &BotId, kind: BrowserActionKind, done: &Done) {
5874
daemon.browsers.action(BrowserAction {
5975
bot_id: bot.clone(),

‎crates/botloftd/tests/browser.rs‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
55
mod common;
66

7+
use std::time::{Duration, Instant};
8+
79
use common::browsing::{answer_site, call, ref_of, setup};
810
use serde_json::{Value, json};
911

@@ -197,3 +199,36 @@ async fn new_tabs_and_dialogs_reach_the_bot_and_the_owner_watches() {
197199
.await
198200
.expect("unwatch");
199201
}
202+
203+
#[tokio::test(flavor = "multi_thread")]
204+
async fn the_owner_sees_the_cursor_get_there_before_the_click() {
205+
let Some(mut b) = setup().await else { return };
206+
b.app
207+
.call("browser.watch", json!({ "botId": b.bot["id"] }))
208+
.await
209+
.expect("watch");
210+
let opening = call(
211+
&b.mcp,
212+
"browser_open",
213+
json!({ "url": format!("{}/links", b.site) }),
214+
);
215+
answer_site(&mut b.app, true, None).await;
216+
let page = opening.await.expect("task").expect("page");
217+
218+
let warn = ref_of(&page, "button \"Warn me\"");
219+
let clicking = call(&b.mcp, "browser_click", json!({ "ref": warn }));
220+
let action = loop {
221+
let action = b.app.notification("browser.action").await;
222+
if action["kind"] == "click" {
223+
break action;
224+
}
225+
};
226+
let pointed = Instant::now();
227+
assert_eq!(action["label"], "Warn me");
228+
assert!(action["x"].as_f64().expect("x") > 0.0);
229+
// The click waits for the cursor's glide in the panel.
230+
assert!(!clicking.is_finished());
231+
let warned = clicking.await.expect("task").expect("clicked");
232+
assert!(warned.contains("\"Hi there\""), "{warned}");
233+
assert!(pointed.elapsed() >= Duration::from_millis(500));
234+
}

‎docs/spec.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1264,7 +1264,7 @@ Esqueceu a senha? [e5 link "Recuperar acesso"]
12641264

12651265
- O que muda o navegador responde assim que o pedido é aceito; o resultado chega em `browser.changed` e nos quadros.
12661266
- `BrowserState`: `botId`, `status` (`closed`, `starting`, `open`, `failed`), `url`, `title` e `loading` (da aba ativa), `tabs` (as abas abertas, na ordem em que abriram: `BrowserTab {id, title, url, active}`, com `id` opaco), `error` (por que não abriu, em `failed`), `control` (`bot` ou `owner`, 21.10), `resting` (o navegador descansa, 21.2), `ask` (o que o bot pediu ao dono com `browser_ask_owner`, enquanto o pedido está aberto) e `updatedAt`.
1267-
- Notificações para todos: `browser.changed` (`BrowserState`) e `browser.action {botId, kind, x, y, label, at}`, com `kind` `open`, `click`, `type`, `select`, `press`, `scroll` ou `back`; `x` e `y` em pixels da página (o tamanho que vem em `browser.frame`) quando a ação tem um ponto; `label` é o nome do elemento, a tecla ou o site, nunca o texto digitado.
1267+
- Notificações para todos: `browser.changed` (`BrowserState`) e `browser.action {botId, kind, x, y, label, at}`, com `kind` `open`, `click`, `type`, `select`, `press`, `scroll` ou `back`; `x` e `y` em pixels da página (o tamanho que vem em `browser.frame`) quando a ação tem um ponto; `label` é o nome do elemento, a tecla ou o site, nunca o texto digitado. Clicar, digitar e escolher avisam antes de agir: o daemon acha o elemento e o traz para a tela, manda `browser.action` com o ponto e, se alguém assiste, espera o cursor deslizar até lá no painel (560 ms, o tempo da animação mais uma folga) antes do clique de verdade; sem ninguém assistindo, não espera. As outras ações avisam depois de feitas. Assim a página muda depois que o cursor chega, e não antes.
12681268
- Só para a conexão que assiste: `browser.frame {botId, data, width, height}`, um JPEG em base64 e o tamanho da página que ele mostra. Cada conexão assiste um bot por vez; `browser.watch` de outro troca, e fechar a conexão para.
12691269
- Os quadros vêm do `Page.startScreencast` (JPEG, qualidade 60, do tamanho da página, 21.3), que roda só enquanto alguém assiste, e só quando a tela muda. O daemon confirma cada quadro no máximo ~15 vezes por segundo. Um app lento não acumula quadros: a conexão manda sempre o mais novo quando consegue, e quadros não contam no limite de 1024 notificações (11.1).
12701270

0 commit comments

Comments
 (0)