diff --git a/Cargo.lock b/Cargo.lock index a5519168..b06c0a74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6852,7 +6852,7 @@ dependencies = [ [[package]] name = "octos-agent" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "async-trait", "base64 0.22.1", @@ -6982,7 +6982,7 @@ dependencies = [ [[package]] name = "octos-bus" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "aes 0.8.4", "async-trait", @@ -7011,7 +7011,7 @@ dependencies = [ [[package]] name = "octos-cli" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "agent-client-protocol", "argon2", @@ -7092,7 +7092,7 @@ dependencies = [ [[package]] name = "octos-core" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "chrono", "eyre", @@ -7106,7 +7106,7 @@ dependencies = [ [[package]] name = "octos-diagnostics" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "eyre", "octos-core", @@ -7118,7 +7118,7 @@ dependencies = [ [[package]] name = "octos-fleet" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "eyre", "octos-core", @@ -7134,7 +7134,7 @@ dependencies = [ [[package]] name = "octos-fleet-worker" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "async-trait", "eyre", @@ -7151,7 +7151,7 @@ dependencies = [ [[package]] name = "octos-llm" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "async-trait", "base64 0.22.1", @@ -7175,7 +7175,7 @@ dependencies = [ [[package]] name = "octos-memory" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "bincode", "chrono", @@ -7194,7 +7194,7 @@ dependencies = [ [[package]] name = "octos-pipeline" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "async-trait", "chrono", @@ -7216,7 +7216,7 @@ dependencies = [ [[package]] name = "octos-plugin" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "eyre", "metrics", @@ -7230,7 +7230,7 @@ dependencies = [ [[package]] name = "octos-research" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "base64 0.22.1", "chromiumoxide", @@ -7254,7 +7254,7 @@ dependencies = [ [[package]] name = "octos-sandbox" version = "0.1.0" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "clap", "eyre", @@ -7269,7 +7269,7 @@ dependencies = [ [[package]] name = "octos-services" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "chrono", "dirs", @@ -7291,7 +7291,7 @@ dependencies = [ [[package]] name = "octos-store" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "base64 0.22.1", "chrono", @@ -7311,7 +7311,7 @@ dependencies = [ [[package]] name = "octos-swarm" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "async-trait", "chrono", @@ -7330,7 +7330,7 @@ dependencies = [ [[package]] name = "octos-workflows" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "chrono", "eyre", diff --git a/Cargo.toml b/Cargo.toml index 8653183d..5108d045 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -110,7 +110,12 @@ octosense-app-hub = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub" # and App Hub and Rinx no longer need the same App Hub commit. octosense-app-contract = "1" # --- octos: ONE source for every octos crate (octos-org/octos main at the -# merge of octos#2656, low fixes for host-owned app peers; after #2655, the +# merge of octos#2675, hydrate rows that name a tool row's call and tool; +# after #2672, the accepted client_commands echoed on session/open, #2671 +# and #2669, docs, #2665, undeclared secrets kept from strict skills, #2664, +# octos's own dev profile, and #2261, a credential store that keeps +# credentials on a failed save; before that #2656, low fixes for host-owned +# app peers; #2655, the # unknown-outcome interlock kept across a kernel restart, #2653, a bound # workspace swapped for a symlink no longer re-roots the session, #2648, a # host session's exact tool list; #2649, @@ -120,12 +125,12 @@ octosense-app-contract = "1" # keeps its peer/input, and #2645, Bing News's Chinese results). # Move octos-core, octos-cli and octos-research together; the Android kernel # artifact (liboctos.so) is built from this same revision. -octos-core = { git = "https://github.com/octos-org/octos.git", rev = "ae230ce04d57f3c29cf6c2518e5956a86c07d788" } -octos-cli = { git = "https://github.com/octos-org/octos.git", rev = "ae230ce04d57f3c29cf6c2518e5956a86c07d788", default-features = false } +octos-core = { git = "https://github.com/octos-org/octos.git", rev = "056173e85b150e387805fc307fe231064ac1ed35" } +octos-cli = { git = "https://github.com/octos-org/octos.git", rev = "056173e85b150e387805fc307fe231064ac1ed35", default-features = false } # The research engine (the metasearch, the SSRF-safe polite reader and the # toolbox scope) for the toolbox's `octos-engine` feature. Same source and # revision as above. -octos-research = { git = "https://github.com/octos-org/octos.git", rev = "ae230ce04d57f3c29cf6c2518e5956a86c07d788", default-features = false } +octos-research = { git = "https://github.com/octos-org/octos.git", rev = "056173e85b150e387805fc307fe231064ac1ed35", default-features = false } # --- AppCard's inline renderers. streaming-markdown-kit = { git = "https://github.com/Project-Robius-China/streaming-markdown-kit.git", branch = "main" } makepad-diagram-kit = { git = "https://github.com/Project-Robius-China/makepad-diagram-kit.git", branch = "main" } diff --git a/README.md b/README.md index 5858307f..b614364d 100644 --- a/README.md +++ b/README.md @@ -261,7 +261,7 @@ The limits are 6 publishes per minute and 4 cards per app. The shell keeps 32 ca | Surface | What happens | | --- | --- | -| Desktop panel | A new card opens the glance panel unless a card window is already open. The bar’s bell or F9 also opens the panel. A press on a card outside its own controls opens it in the card window. The hovered card shows its open and dismiss actions, and a card that just came wears an accent mark for a few seconds. A card that does not fit whole peeks in at the end of the list. Opened with F9 (or a press in it), the panel has the keyboard: the arrows move a focus ring from card to card, Return opens the card, Delete dismisses it and Esc closes the panel. | +| Desktop panel | A new card opens the glance panel unless a card window is already open. The bar’s bell or F9 also opens the panel. A press on a card outside its own controls opens it in the card window. The hovered card shows its open and dismiss actions, and a card that just came wears an accent mark for a few seconds. A card that does not fit whole peeks in at the end of the list. A card taller than its tile scrolls inside it with the wheel; a card with an in-card chat stays at its newest rows, so its field and the latest exchange stay in view. Opened with F9 (or a press in it), the panel has the keyboard: the arrows move a focus ring from card to card, Return opens the card, Delete dismisses it and Esc closes the panel. | | Desktop notification | A card published with `notify` also posts a toast with the app’s icon and name, the card’s title and its `summary` (else the card’s own). Selecting the toast opens the card in its own window. At most three toasts show at once; a “+N more” chip under them shows the rest. While the panel is open, toasts stack to its left. | | Desktop dismissal | The hovered card shows a dismiss button: `glance::dismiss` removes the card as if the app had withdrawn it. Clear all dismisses every card. A dismissal can be undone from the toast that reports it, or with ⌘Z while the panel has the keyboard. The panel has a separate close button. | | Phone | `notify` posts a shade notification. Selecting it opens the glance page. | diff --git a/README.zh-CN.md b/README.zh-CN.md index 1c743b65..ad7c8646 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -261,7 +261,7 @@ sequenceDiagram | 界面 | 行为 | | --- | --- | -| 桌面面板 | 新卡片会打开 glance 面板,除非已有卡片窗口打开。顶栏铃铛或 F9 也可以打开面板。点击卡片上自身控件以外的地方,会在卡片窗口中打开它。鼠标悬停的卡片会显示打开和移除操作,刚到的卡片旁会有几秒钟的强调色标记。放不下的那张卡片会在列表末尾露出一部分。用 F9(或在面板里点击)打开时,面板接管键盘:方向键在卡片之间移动焦点环,Return 打开卡片,Delete 移除卡片,Esc 关闭面板。 | +| 桌面面板 | 新卡片会打开 glance 面板,除非已有卡片窗口打开。顶栏铃铛或 F9 也可以打开面板。点击卡片上自身控件以外的地方,会在卡片窗口中打开它。鼠标悬停的卡片会显示打开和移除操作,刚到的卡片旁会有几秒钟的强调色标记。放不下的那张卡片会在列表末尾露出一部分。卡片在面板里有高度上限,更高的卡片可以用滚轮在原处滚动;带卡内对话的卡片停在最新的消息处,输入框和最近一轮对话始终可见。用 F9(或在面板里点击)打开时,面板接管键盘:方向键在卡片之间移动焦点环,Return 打开卡片,Delete 移除卡片,Esc 关闭面板。 | | 桌面通知 | 以 `notify` 发布的卡片还会弹出 toast,显示应用的图标和名称、卡片标题及其 `summary`(没有时用卡片自带的摘要)。点击 toast 会在独立窗口中打开卡片。同时最多显示三条 toast,其余的由下方的“+N more”标签展开。面板打开时,toast 叠放在面板左侧。 | | 桌面关闭操作 | 鼠标悬停的卡片会显示移除按钮:`glance::dismiss` 会移除卡片,效果如同应用撤回了它。“Clear all”会移除所有卡片。移除的卡片可以在报告这次移除的 toast 上撤销(Undo),面板接管键盘时也可以按 ⌘Z 撤销。面板本身另有关闭按钮。 | | 手机 | `notify` 在通知栏发出通知,点击后打开 glance 页面。 | diff --git a/crates/kernel/src/kernel.rs b/crates/kernel/src/kernel.rs index 360847b4..78482254 100644 --- a/crates/kernel/src/kernel.rs +++ b/crates/kernel/src/kernel.rs @@ -60,6 +60,9 @@ struct Io { /// host-managed server stops when it reaches EOF, which also happens /// when this process dies, so a crashed shell leaves no kernel behind. lifeline: Option, + /// The task copying the child's stderr into the [`Tail`]; it ends at + /// the end of the pipe, after the kernel's last line. + stderr: Option>, } /// Keeps the last lines the kernel wrote to stderr, to say why it exited. @@ -116,7 +119,7 @@ fn start(launch: &Launch, network: &Network, log: &LogSink, tail: &Tail) -> Resu return Err("the kernel's stdin/stdout are not piped".into()); }; // The kernel logs to stderr; it never carries protocol frames. - if let Some(stderr) = child.stderr.take() { + let stderr = child.stderr.take().map(|stderr| { let log = log.clone(); let tail = tail.clone(); tokio::spawn(async move { @@ -126,14 +129,14 @@ fn start(launch: &Launch, network: &Network, log: &LogSink, tail: &Tail) -> Resu (log)(&format!("octos: {line}")); tail.push(line); } - }); - } + }) + }); let lines = BufReader::new(Box::pin(stdout) as Pin>).lines(); Ok(if shared { // Frames go over the WebSocket once it is up (`prepare_network`). - Io { writer: Box::pin(tokio::io::sink()), lines, running: Running::Child(child), lifeline: Some(stdin) } + Io { writer: Box::pin(tokio::io::sink()), lines, running: Running::Child(child), lifeline: Some(stdin), stderr } } else { - Io { writer: Box::pin(stdin), lines, running: Running::Child(child), lifeline: None } + Io { writer: Box::pin(stdin), lines, running: Running::Child(child), lifeline: None, stderr } }) } #[cfg(target_env = "ohos")] @@ -153,6 +156,7 @@ fn start(launch: &Launch, network: &Network, log: &LogSink, tail: &Tail) -> Resu lines: BufReader::new(Box::pin(reader) as Pin>).lines(), running: Running::Embedded(task), lifeline: None, + stderr: None, }) } #[cfg(not(target_env = "ohos"))] @@ -266,7 +270,7 @@ async fn exited(running: &mut Running) -> String { async fn stop(io: Io) { // Closing stdin stops either kind of child: the stdio pipe drains owned // turns on EOF; a host-managed server drains its connections and exits. - let Io { writer, lines, mut running, lifeline } = io; + let Io { writer, lines, mut running, lifeline, .. } = io; let grace = if lifeline.is_some() { SHARED_GRACE } else { STDIO_GRACE }; drop(writer); drop(lines); @@ -401,16 +405,18 @@ pub(crate) async fn supervise( } } } - Ok(None) => break CloseReason::Exited(with_tail("the kernel closed its output", &tail)), + Ok(None) => break CloseReason::Exited("the kernel closed its output".into()), Err(e) => break CloseReason::Exited(format!("reading from the kernel failed: {e}")), }, - why = exited(&mut io.running) => break CloseReason::Exited(with_tail(&why, &tail)), + why = exited(&mut io.running) => break CloseReason::Exited(why), } }}; (log)(&format!("octos-core: stopping kernel {generation}: {reason}")); + let stderr = io.stderr.take(); stop(io).await; match reason { - CloseReason::Failed(why) => CloseReason::Failed(with_tail(&why, &tail)), + CloseReason::Failed(why) => CloseReason::Failed(last_words(&why, stderr, &tail).await), + CloseReason::Exited(why) => CloseReason::Exited(last_words(&why, stderr, &tail).await), other => other, } } @@ -464,6 +470,24 @@ fn tool_list_reply(text: &str) -> Option { }) } +/// How long a stopped kernel's stderr may take to reach its end. The pipe +/// closes with the kernel; only a process it started that inherited the +/// pipe can hold it open longer. +const LAST_WORDS_GRACE: Duration = Duration::from_secs(2); + +/// `why` with the kernel's last words (the [`Tail`]), once `stderr`, the +/// task reading them, has reached the end of the pipe. That task runs +/// beside the supervisor, so lines the kernel wrote just before its stdout +/// closed or it exited can still be on their way to the tail: read at once, +/// the reason said "the kernel closed its output: fake kernel up" without +/// the "asked to exit" the kernel wrote last. +async fn last_words(why: &str, stderr: Option>, tail: &Tail) -> String { + if let Some(reader) = stderr { + let _ = tokio::time::timeout(LAST_WORDS_GRACE, reader).await; + } + with_tail(why, tail) +} + fn with_tail(why: &str, tail: &Tail) -> String { let tail = tail.text(); if tail.is_empty() { diff --git a/crates/kernel/tests/core.rs b/crates/kernel/tests/core.rs index 2a74ba25..43d9d545 100644 --- a/crates/kernel/tests/core.rs +++ b/crates/kernel/tests/core.rs @@ -238,7 +238,9 @@ async fn a_kernel_that_dies_closes_its_connections_with_its_last_words() { call(&mut a, "1", "session/list", json!({})).await; a.send(json!({"jsonrpc": "2.0", "method": "test/exit", "params": {}}).to_string()).unwrap(); match next(&mut a).await { - Err(CloseReason::Exited(why)) => assert!(why.contains("asked to exit") || why.contains("exit"), "{why}"), + // The line it wrote to stderr last, whichever end the core saw + // first: its output closing or its exit status. + Err(CloseReason::Exited(why)) => assert!(why.contains("asked to exit"), "{why}"), other => panic!("expected Exited, got {other:?}"), } for _ in 0..100 { diff --git a/crates/shell/src/approvals/mod.rs b/crates/shell/src/approvals/mod.rs index 33168e40..0d1a0fd2 100644 --- a/crates/shell/src/approvals/mod.rs +++ b/crates/shell/src/approvals/mod.rs @@ -130,6 +130,31 @@ impl Approvals { pub fn consent_granted(&self, app: &str) -> bool { self.consent.granted(app, self.router.hooks().grants_all(app)) } + /// The relay's entry point (octos#2567) on these approvals: a call + /// needs an approval. + pub fn approval_requested(&mut self, app: &str, tool: ToolSpec, args: serde_json::Value, caller: Caller, context: RequestContext) -> Route { + self.router.approval_requested(app, tool, args, caller, context) + } + /// The relay installs itself; decisions made before are handed over + /// first. + pub fn set_relay(&mut self, mut relay: Box) { + for (id, decision, reason) in self.queue.take() { + relay.approval_decided(&id, decision, &reason); + } + *self.external.lock().unwrap_or_else(|e| e.into_inner()) = Some(relay); + } + /// The AI bus's held `confirm: host` call as a request like any other. + pub fn bus_requested(&mut self, held: &crate::ai_bus::HeldCall) -> Route { + let mut tool = ToolSpec::host(&held.tool); + tool.auto_approvable = held.auto_approvable; + if held.command { + tool = tool.command(); + } + let args = serde_json::from_str(&held.args).unwrap_or_else(|_| serde_json::Value::String(held.args.clone())); + // The pane is the person's own conversation with the system agent. + let context = RequestContext { call_id: held.key.clone(), trigger: Trigger::Person, ..RequestContext::default() }; + self.approval_requested(&held.app, tool, args, Caller::SystemAgent, context) + } } static STATE: Mutex> = Mutex::new(None); @@ -160,34 +185,23 @@ pub fn now() -> u64 { // ------------------------------------------------------------ the relay -/// The relay's entry point (octos#2567): a call needs an approval. +/// The relay's entry point (octos#2567): a call needs an approval +/// ([`Approvals::approval_requested`] on the shell's approvals). pub fn approval_requested(app: &str, tool: ToolSpec, args: serde_json::Value, caller: Caller, context: RequestContext) -> Route { - with(|a| a.router.approval_requested(app, tool, args, caller, context)).unwrap_or_else(|| Route::Refused("approvals are not set up".into())) + with(|a| a.approval_requested(app, tool, args, caller, context)).unwrap_or_else(|| Route::Refused("approvals are not set up".into())) } -/// The relay installs itself; decisions made before are handed over first. -pub fn set_relay(mut relay: Box) { - with(|a| { - for (id, decision, reason) in a.queue.take() { - relay.approval_decided(&id, decision, &reason); - } - *a.external.lock().unwrap_or_else(|e| e.into_inner()) = Some(relay); - }); +/// The relay installs itself; decisions made before are handed over first +/// ([`Approvals::set_relay`] on the shell's approvals). +pub fn set_relay(relay: Box) { + with(|a| a.set_relay(relay)); } /// The AI bus's `confirm: host` calls (`ai_bus::Route::Approval`): each is /// a request like any other; the shell drains the answers here and /// releases the held call (`AiBus::release`). pub fn bus_requested(held: &crate::ai_bus::HeldCall) -> Route { - let mut tool = ToolSpec::host(&held.tool); - tool.auto_approvable = held.auto_approvable; - if held.command { - tool = tool.command(); - } - let args = serde_json::from_str(&held.args).unwrap_or_else(|_| serde_json::Value::String(held.args.clone())); - // The pane is the person's own conversation with the system agent. - let context = RequestContext { call_id: held.key.clone(), trigger: Trigger::Person, ..RequestContext::default() }; - approval_requested(&held.app, tool, args, Caller::SystemAgent, context) + with(|a| a.bus_requested(held)).unwrap_or_else(|| Route::Refused("approvals are not set up".into())) } pub fn take_bus_decisions() -> Vec<(RequestId, Decision, String)> { diff --git a/crates/shell/src/approvals/tests.rs b/crates/shell/src/approvals/tests.rs index 6b41ace1..329ae884 100644 --- a/crates/shell/src/approvals/tests.rs +++ b/crates/shell/src/approvals/tests.rs @@ -993,32 +993,31 @@ fn only_settings_and_the_sheet_make_a_person_gesture() { // ---------------------------------------------------------------- the relay seam +/// On approvals of its own, never the shell's (`super::with`): those are +/// the process's, and the tests that run app agents keep their consent +/// and relay there while this runs. #[test] fn the_relay_gets_decisions_made_before_it_was_installed() { - super::init_memory(); - let route = super::approval_requested("os.mail", ToolSpec::host("mail.archive").not_auto_approvable(), json!({"id": 1}), Caller::SystemAgent, ctx("g1", Trigger::Person)); - assert!(matches!(route, Route::Sheet(_))); - let (sheet, id) = super::with(|a| { + let mut a = super::Approvals::memory(); + let front = |a: &super::Approvals| { let s = a.router.front_sheet().unwrap(); (s.id, s.lines[0].request.clone()) - }) - .unwrap(); - super::with(|a| a.router.answer(sheet, &id, Answer::Deny, &ApprovalGesture::sheet_tap(), T0)).unwrap().unwrap(); + }; + let route = a.approval_requested("os.mail", ToolSpec::host("mail.archive").not_auto_approvable(), json!({"id": 1}), Caller::SystemAgent, ctx("g1", Trigger::Person)); + assert!(matches!(route, Route::Sheet(_))); + let (sheet, id) = front(&a); + a.router.answer(sheet, &id, Answer::Deny, &ApprovalGesture::sheet_tap(), T0).unwrap(); let relay = RecordingRelay::default(); - super::set_relay(Box::new(relay.clone())); + a.set_relay(Box::new(relay.clone())); assert_eq!(relay.take(), vec![(RequestId("g1".into()), Decision::Deny, "denied on the sheet".into())]); // From now on, straight to it. - super::approval_requested("os.mail", ToolSpec::host("mail.archive").not_auto_approvable(), json!({"id": 2}), Caller::SystemAgent, ctx("g2", Trigger::Person)); - let (sheet, id) = super::with(|a| { - let s = a.router.front_sheet().unwrap(); - (s.id, s.lines[0].request.clone()) - }) - .unwrap(); - super::with(|a| a.router.answer(sheet, &id, Answer::Once, &ApprovalGesture::sheet_tap(), T0)).unwrap().unwrap(); + a.approval_requested("os.mail", ToolSpec::host("mail.archive").not_auto_approvable(), json!({"id": 2}), Caller::SystemAgent, ctx("g2", Trigger::Person)); + let (sheet, id) = front(&a); + a.router.answer(sheet, &id, Answer::Once, &ApprovalGesture::sheet_tap(), T0).unwrap(); assert_eq!(relay.take().len(), 1); // The AI bus's held `confirm: host` calls come back to the shell, not // to the relay: the Terminal's `run` asks the person, never a rule. - super::with(|a| a.router.create_rule(&ApprovalGesture::settings_tap(), RuleDraft::everything("terminal", 30), T0)).unwrap().unwrap(); + a.router.create_rule(&ApprovalGesture::settings_tap(), RuleDraft::everything("terminal", 30), T0).unwrap(); let held = crate::ai_bus::HeldCall { key: "bus:w4:c1".into(), app: "terminal".into(), @@ -1027,19 +1026,18 @@ fn the_relay_gets_decisions_made_before_it_was_installed() { auto_approvable: false, command: true, }; - assert!(matches!(super::bus_requested(&held), Route::Sheet(_))); - let (sheet, id, caller, args) = super::with(|a| { + assert!(matches!(a.bus_requested(&held), Route::Sheet(_))); + let (sheet, id, caller, args) = { let s = a.router.front_sheet().unwrap(); (s.id, s.lines[0].request.clone(), s.lines[0].caller.clone(), s.lines[0].args.join(" ")) - }) - .unwrap(); + }; assert_eq!(id, RequestId("bus:w4:c1".into())); assert_eq!(caller, "The system agent"); assert_eq!(args, "command (1 line): 1 \u{2502} ls", "a command, one numbered row per line"); - super::with(|a| a.router.answer(sheet, &id, Answer::Once, &ApprovalGesture::sheet_tap(), T0)).unwrap().unwrap(); + a.router.answer(sheet, &id, Answer::Once, &ApprovalGesture::sheet_tap(), T0).unwrap(); assert!(relay.take().is_empty()); - assert_eq!(super::take_bus_decisions(), vec![(id, Decision::ApproveOnce, "approved on the sheet".into())]); - assert!(!super::consent_granted("os.news"), "developer mode is off in tests: nothing is granted"); + assert_eq!(a.bus.take(), vec![(id, Decision::ApproveOnce, "approved on the sheet".into())]); + assert!(!a.consent_granted("os.news"), "developer mode is off in tests: nothing is granted"); } /// The module host's gate: the first ask shows the first-use sheet and diff --git a/crates/shell/src/apps.rs b/crates/shell/src/apps.rs index 5cba914d..64b30f32 100644 --- a/crates/shell/src/apps.rs +++ b/crates/shell/src/apps.rs @@ -141,6 +141,32 @@ pub fn system_card_apps() -> Vec { Vec::new() } +/// System apps a test registers for itself (the glance tile tests' +/// probes). They join App Hub's registry, the one [`system_card_apps`] +/// lists, which is process-wide and never forgets an app, so a test of the +/// build's own catalog saw them or not depending on which tests had run +/// before it in the same process. A test registers its app here, and the +/// catalog tests leave these out. +#[cfg(test)] +pub(crate) mod test_system_apps { + use std::sync::Mutex; + + static IDS: Mutex> = Mutex::new(Vec::new()); + + /// Register `app` for a test. It is noted first, so a catalog that + /// lists it was read after it was noted. + #[cfg(feature = "app-hub")] + pub(crate) fn register(app: octosense_appstore::system::SystemApp) { + IDS.lock().unwrap_or_else(|e| e.into_inner()).push(app.id); + octosense_appstore::system::register_system_app(app); + } + + /// Whether a test registered the system app `id` (an `os.` id). + pub(crate) fn is_test_app(id: &str) -> bool { + IDS.lock().unwrap_or_else(|e| e.into_inner()).contains(&id) + } +} + /// The services contained apps call through `host.request` (ADR 0004) /// that are not the assistant's, registered once, before the first system /// app can open: `mail` keeps accounts and passwords for the Mail app; @@ -658,12 +684,19 @@ mod tests { .collect() } + /// The build's own rows among `rows`: without the system apps other + /// tests registered for themselves ([`super::test_system_apps`]). + /// `rows` must be read before this looks, as an argument is. + fn build_rows(rows: Vec) -> Vec { + rows.into_iter().filter(|row| !card_manifest_id(row).is_some_and(super::test_system_apps::is_test_app)).collect() + } + #[cfg(feature = "mobile-apps")] #[test] fn bundled_apps_open_without_catalog_files_or_child_processes() { use makepad_widgets::*; let _one_rinx = crate::module_host::RINX_INSTANCE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - let catalog = bundled_catalog(); + let catalog = build_rows(bundled_catalog()); // The linked modules in link order (AppCard is opt-in, `app-appcard`, // not part of `mobile-apps`; `settings` is the phone product's), then // the system apps no native module of the same id replaces. @@ -718,7 +751,7 @@ mod tests { let mut cx = Cx::new(Box::new(|_, _| {})); cx.with_vm(makepad_widgets::script_mod); let mut host = crate::module_host::ModuleHost::default(); - for (index, app) in bundled_catalog().iter().enumerate() { + for (index, app) in build_rows(bundled_catalog()).iter().enumerate() { let module = registry.module(&app.id).unwrap(); let client = index as u64 + 1; host.create(&mut cx, client, module, module_open(module, app).unwrap(), dvec2(400.0, 700.0)).unwrap(); @@ -768,7 +801,7 @@ mod tests { fn the_system_apps_ship_as_card_apps() { let registry = AppRegistry::default(); let native = registry.linked_ids(); - let ids: Vec = system_card_apps().into_iter().map(|app| app.id).collect(); + let ids: Vec = build_rows(system_card_apps()).into_iter().map(|app| app.id).collect(); let expected: Vec<&str> = system_app_ids().iter().copied().filter(|id| !native.contains(id)).collect(); assert_eq!(ids, expected); for id in &ids { diff --git a/crates/shell/src/glance_card.rs b/crates/shell/src/glance_card.rs index 7716a342..82947f20 100644 --- a/crates/shell/src/glance_card.rs +++ b/crates/shell/src/glance_card.rs @@ -16,8 +16,10 @@ //! the desktop panel's 328 pt). Height: the card's own measured height, //! clamped to [`TILE_MIN_HEIGHT`]..=[`TILE_MAX_HEIGHT`] (room for a whole //! action card, its buttons included); until the first draw measures it, -//! [`TILE_DEFAULT_HEIGHT`]. A taller card is clipped at the -//! cap; the app is one tap away. A script card should size its root `Fit`. +//! [`TILE_DEFAULT_HEIGHT`]. A taller card is clipped at the cap ([`overflow`] +//! says by how much), or scrolled inside its tile where the surface offers +//! that ([`GlanceTiles::draw_scrolled`]: the glance panel); the app is one +//! tap away. A script card should size its root `Fit`. //! //! **Policy.** A tile's isolate runs under the publishing app's resolved //! policy, applied exactly as the Card runner applies it @@ -41,13 +43,15 @@ //! "l0:{e,k,v}", v?)` (Octoscript-Makepad's general translation). Every //! isolate gets a host `NAV` ([`install_nav`]) that queues the call with the //! isolate's heap key; nothing else reads it. The card window -//! (glance_sheet.rs) and the desktop's glance panel (glance_panel.rs) keep -//! their L0 cards in a [`LiveCards`], one path for both: a tile's own -//! isolate's calls ([`take_taps`], never another tile's) run through its -//! card's [`L0Session`], made for the app that published the card: the -//! declared transition (`octoscript_ui_l0` dispatch), the §5.12 writes the -//! host performs (a `sys.chat` append, glance_chat.rs), and a re-lowering. -//! The phone's glance page does not dispatch yet: its L0 taps stay inert. +//! (glance_sheet.rs), the desktop's glance panel (glance_panel.rs) and the +//! phone's glance page (mobile_pages.rs `GlanceCards`) keep their L0 cards +//! in a [`LiveCards`], one path for all three: a tile's own isolate's calls +//! ([`take_taps`], never another tile's) run through its card's +//! [`L0Session`], made for the app that published the card: the declared +//! transition (`octoscript_ui_l0` dispatch), the §5.12 writes the host +//! performs (a `sys.chat` append, glance_chat.rs), and a re-lowering. On the +//! phone the shell first drops a card's clicks ([`drop_clicks`]) when the +//! finger was no plain tap on that card: a page swipe, a pull, a long press. use makepad_widgets::*; use std::cell::RefCell; use std::collections::HashMap; @@ -292,6 +296,12 @@ fn queue_tap(tap: Tap) { } } +/// [`queue_tap`], for another module's tests: a card's `NAV` call. +#[cfg(all(test, feature = "app-hub"))] +pub(crate) fn queue_tap_for_test(tap: Tap) { + queue_tap(tap) +} + /// Register [`install_nav`] for every isolate made from now on, once, with /// the hit target a lowered card's taps are drawn as (`OctoscriptTap`, which /// the Card runner's own isolate mods do not include). @@ -323,6 +333,13 @@ pub fn take_taps(heap: usize) -> Vec { mine } +/// Whether the isolate `heap` has a click waiting: a call with no text (a +/// tap target's; a field's carries its text). A tap target claims no press, +/// so this is how a surface learns that a press was on one (glance_panel.rs). +pub fn has_clicks(heap: usize) -> bool { + TAPS.lock().is_ok_and(|taps| taps.iter().any(|t| t.heap == heap && t.typed.is_none())) +} + /// Forget the queued calls of the isolate `heap`, whose tile went away: a /// heap key is unique only while its isolate lives, so a later isolate may /// get the same one, and must not run the calls the old one queued. @@ -332,6 +349,17 @@ pub fn drop_taps(heap: usize) { } } +/// Forget the isolate `heap`'s queued clicks, its calls with no text (a tap +/// target's), and keep its field edits (a field's call carries the text the +/// person typed): for a surface whose finger was no tap on that tile +/// (mobile_pages.rs `GlanceCards`). How many went. +pub fn drop_clicks(heap: usize) -> usize { + let Ok(mut taps) = TAPS.lock() else { return 0 }; + let before = taps.len(); + taps.retain(|t| t.heap != heap || t.typed.is_some()); + before - taps.len() +} + /// `l0:{"e":event,"k":instance key,"v":value}` → `(key, event, value)`. pub fn parse_tap(target: &str) -> Option<(String, String, String)> { let json: serde_json::Value = serde_json::from_str(target.strip_prefix("l0:")?).ok()?; @@ -496,9 +524,10 @@ impl L0Session { /// The L0 cards one surface keeps live, by tile key: each card's /// [`L0Session`] and the body its tile draws now. The card window keeps one -/// for its card and the glance panel one for its tiles, so a tap runs the -/// same way on both (module docs, "L0 taps"). A script card is not kept -/// here: it runs as published and keeps its own state. +/// for its card, and the glance panel and the phone's glance page one for +/// their tiles, so a tap runs the same way on each (module docs, "L0 +/// taps"). A script card is not kept here: it runs as published and keeps +/// its own state. #[derive(Default)] pub struct LiveCards { cards: HashMap, @@ -579,6 +608,12 @@ pub fn clamp_height(measured: f64) -> f64 { measured.clamp(TILE_MIN_HEIGHT, TILE_MAX_HEIGHT) } +/// How much taller than its tile a card is, as last measured: what of it the +/// tile cannot show at once (0 when it fits). +pub fn overflow(key: &str) -> f64 { + measured_height(key).map_or(0.0, |m| (m - tile_height(key)).max(0.0)) +} + thread_local! { /// Measured tile heights by card key (`app/card_id`), shared by every /// surface that draws the card (phone glance page, desktop panel). @@ -644,12 +679,26 @@ impl GlanceTiles { /// when it changed. The first draw seats the isolate under `app`'s /// policy (module docs). pub fn draw(&mut self, cx: &mut Cx2d, key: &str, app: &str, contained: bool, body: &std::sync::Arc, rect: Rect) { + self.draw_scrolled(cx, key, app, contained, body, rect, 0.0); + } + + /// [`Self::draw`], the card scrolled `scroll` points up inside its tile, + /// so a card taller than its tile shows its part from there down (the + /// glance panel's, glance_panel.rs `TileScroll`). The frame's own layout + /// scrolls, not a scroll view: no scroll bar claims a press, so a press + /// a card's controls do not claim stays unclaimed, and the card's hits + /// stay where it is drawn. Tiles that scroll by themselves (the card + /// window's) ignore it. + pub fn draw_scrolled(&mut self, cx: &mut Cx2d, key: &str, app: &str, contained: bool, body: &std::sync::Arc, rect: Rect, scroll: f64) { if !CAN_RENDER { return; } ensure_vocabulary(cx); let splash = self.open(cx, key, app, contained, body); let Some(tile) = self.tiles.get_mut(key) else { return }; + if !self.scroll { + tile.frame.as_view().set_scroll_pos(cx, dvec2(0.0, scroll.max(0.0))); + } let walk = Walk { abs_pos: Some(rect.pos), width: Size::Fixed(rect.size.x), height: Size::Fixed(rect.size.y), ..Walk::default() }; let mut scope = Scope::empty(); // Inside the card's isolate, as the Card runner draws its card. @@ -839,7 +888,8 @@ mod tests { } /// A system app, registered from a pack made on the fly, whose manifest - /// grants `glance` and `storage` and nothing else. + /// grants `glance` and `storage` and nothing else. It is registered as + /// a test's, so the catalog tests running beside these leave it out. #[cfg(feature = "app-hub")] fn register_test_app(id: &'static str) { let dir = std::env::temp_dir().join(format!("glance-tile-app-{id}-{}", std::process::id())); @@ -852,7 +902,7 @@ mod tests { ); std::fs::write(dir.join("manifest.json"), manifest).unwrap(); let pack = serde_json::to_string(&octosense_app_hub::pack::pack_dir(&dir).unwrap()).unwrap(); - octosense_appstore::system::register_system_app(octosense_appstore::system::SystemApp { id, name: "Glance test", pack: Box::leak(pack.into_boxed_str()), assets: &[] }); + crate::apps::test_system_apps::register(octosense_appstore::system::SystemApp { id, name: "Glance test", pack: Box::leak(pack.into_boxed_str()), assets: &[] }); } /// A Cx with the widgets and the tile frame registered, for driving @@ -1058,6 +1108,23 @@ mod tests { assert!(mark > body.find("Q?").unwrap() && mark < body.find("A.").unwrap(), "the mark sits on the agent's entry: {body}"); } + /// A click waiting for an isolate is seen without taking it; a field's + /// edit (it carries text) is no click, and another isolate's is not its. + #[test] + fn a_waiting_click_is_seen_without_taking_it() { + const A: usize = 0x6a11_0011; + const B: usize = 0x6a11_0012; + queue_tap(Tap { heap: A, target: "a".into(), typed: Some("typed".into()) }); + assert!(!has_clicks(A), "a field's edit is not a click"); + queue_tap(Tap { heap: B, target: "b".into(), typed: None }); + assert!(!has_clicks(A), "another isolate's click"); + queue_tap(Tap { heap: A, target: "a".into(), typed: None }); + assert!(has_clicks(A)); + assert_eq!(take_taps(A).len(), 2, "seen, not taken"); + assert!(!has_clicks(A)); + drop_taps(B); + } + /// Taps are taken per isolate: a surface takes its own tile's, in /// order, and leaves every other tile's for that tile's surface; a tile /// that goes away takes its queued taps with it. @@ -1237,6 +1304,17 @@ mod tests { assert!(take_replies_for(&[heap]).is_empty(), "nothing is left queued for the tile that went away"); } + /// A card taller than its tile overflows by the difference, as last + /// measured; one that fits, or that never drew, by nothing. + #[test] + fn a_card_taller_than_its_tile_overflows_by_the_difference() { + record_height("os.overflow/tall", TILE_MAX_HEIGHT + 80.0); + assert_eq!(overflow("os.overflow/tall"), 80.0); + record_height("os.overflow/short", 200.0); + assert_eq!(overflow("os.overflow/short"), 0.0); + assert_eq!(overflow("os.overflow/never"), 0.0); + } + #[test] fn heights_clamp_to_the_tile_range() { assert_eq!(clamp_height(10.0), TILE_MIN_HEIGHT); diff --git a/crates/shell/src/glance_panel.rs b/crates/shell/src/glance_panel.rs index b2103758..ead1b9d7 100644 --- a/crates/shell/src/glance_panel.rs +++ b/crates/shell/src/glance_panel.rs @@ -10,7 +10,11 @@ //! //! - **A card's own controls** (its chips, buttons and fields) work in place. //! A press anywhere else on it opens the card in the card window -//! ([`ShellGlancePanelAction::OpenCard`]), as its notification does. +//! ([`ShellGlancePanelAction::OpenCard`]), as its notification does. A +//! field claims its press; a chip (a tap target, `OctoscriptTap`) claims +//! none and calls its card once the release's handlers have run, so a +//! release is held for a frame: a click from that card means its control +//! had it. //! - **The hovered card** shows its actions over its top-right corner, on a //! backdrop: open the app that published it, and dismiss the card. They //! are not drawn over every card's content all the time. @@ -22,6 +26,14 @@ //! ([`first_to_reveal`]). "More above" heads the list and "more below" //! ends it; the card that does not fit whole peeks in above that line, //! cut at the list's end. +//! - **A card taller than its tile** (the cap, glance_card.rs) scrolls +//! inside it ([`TileScroll`]), with a thumb at its edge. A card with an +//! in-card chat (`sys.chat`) sits at its newest rows and follows them as +//! the conversation grows, so its field and the latest exchange stay in +//! view; any other card starts at its top. The wheel over such a card +//! scrolls it first and moves the list only at its ends. The tile's own +//! layout scrolls, so a press the card's controls do not claim still +//! opens the card window. //! - **A dismissal** (a card's close, Delete, Clear all) can be undone: the //! shell's toast offers Undo ([`ShellGlancePanelAction::Dismissed`]), and //! ⌘Z does it while the panel holds the keyboard. @@ -42,6 +54,7 @@ use crate::glance_card::{GlanceTiles, LiveCards}; use crate::shell::ui::{contains, rect, slide, DrawShellFill, HAlign, Ico, ShellDraw}; use crate::shell::{alpha, MaterialTokens, ShellTokens}; use makepad_widgets::*; +use std::collections::HashMap; pub const PANEL_WIDTH: f64 = 380.0; const PAD: f64 = 16.0; @@ -98,6 +111,60 @@ pub fn focus_after_dismiss(keys: &[String], i: usize) -> Option { keys.get(i + 1).or(i.checked_sub(1).and_then(|j| keys.get(j))).cloned() } +/// Where a card taller than its tile sits in it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum TileScroll { + /// At its newest rows, its bottom, followed as the card grows: where a + /// card with an in-card chat starts, so its field and the latest + /// exchange stay in view. + Newest, + /// This far down from its top: where any other card starts (at 0), and + /// where the wheel leaves a chat scrolled back from its newest rows. + Top(f64), +} + +impl TileScroll { + /// Where a card starts: one with a chat at its newest rows, any other at + /// its top. + pub fn start(chat: bool) -> Self { + if chat { TileScroll::Newest } else { TileScroll::Top(0.0) } + } + + /// How far up the card is scrolled in a tile it overflows by `overflow`. + pub fn offset(self, overflow: f64) -> f64 { + let overflow = overflow.max(0.0); + match self { + TileScroll::Newest => overflow, + TileScroll::Top(down) => down.clamp(0.0, overflow), + } + } + + /// The wheel's `dy` (positive: further down the card) over a card that + /// overflows its tile by `overflow`: it scrolls as far as it goes, and a + /// chat back at its bottom follows its newest rows again. False when it + /// was at that end already: the wheel is the list's. + pub fn wheel(&mut self, dy: f64, overflow: f64, chat: bool) -> bool { + let overflow = overflow.max(0.0); + let at = self.offset(overflow); + let to = (at + dy).clamp(0.0, overflow); + if (to - at).abs() < 0.5 { + return false; + } + *self = if chat && to >= overflow - 0.5 { TileScroll::Newest } else { TileScroll::Top(to) }; + true + } +} + +/// A tall card's place in its tile, as the panel keeps it while the card is +/// drawn tall: the publish it is for (a newer one starts over), whether the +/// card has a chat, and where it sits. +#[derive(Clone, Copy, Debug)] +struct TallCard { + published_ms: u64, + chat: bool, + at: TileScroll, +} + script_mod! { use mod.prelude.widgets_internal.* use mod.widgets.* @@ -165,6 +232,12 @@ pub struct ShellGlancePanel { hover: Option, #[rust] card_press: Option, + /// That press released on its card: it opens the card window on the next + /// frame unless the card clicked by then (one of its chips had it). + #[rust] + card_release: Option, + #[rust] + release_frame: NextFrame, /// A new card came: the next draw scrolls as little as shows it. #[rust] reveal_newest: bool, @@ -196,6 +269,15 @@ pub struct ShellGlancePanel { /// What the last layout log said, so it is logged once per change. #[rust] logged: String, + /// The cards taller than their tiles: where each sits (by key), the ones + /// this frame drew with how far each overflows, and what the last log of + /// their places said. + #[rust] + tall: HashMap, + #[rust] + tall_drawn: Vec<(String, f64)>, + #[rust] + tall_logged: String, #[rust] tiles: GlanceTiles, /// The L0 cards' live state, by card key, as the card window keeps its @@ -316,6 +398,32 @@ impl ShellGlancePanel { fn card_at(&self, p: DVec2) -> Option { self.card_rects.iter().find(|(r, ..)| contains(*r, p)).map(|(.., key)| key.clone()) } + /// How far up the tall card `card` is scrolled in its tile, which it + /// overflows by `overflow`: where it sits, kept from frame to frame for + /// its publish (first seen: a card with a chat at its newest rows, any + /// other at its top), and noted as drawn for the wheel. + fn tall_scroll(&mut self, key: &str, card: &crate::glance::GlanceCard, overflow: f64) -> f64 { + let fresh = || { + let chat = card.l0.as_ref().is_some_and(|l0| crate::glance_chat::reads_chat(&l0.source)); + TallCard { published_ms: card.published_ms, chat, at: TileScroll::start(chat) } + }; + let tall = self.tall.entry(key.to_string()).or_insert_with(fresh); + if tall.published_ms != card.published_ms { + *tall = fresh(); + } + let at = tall.at.offset(overflow); + self.tall_drawn.push((key.to_string(), overflow)); + at + } + /// The wheel's `dy` at `p`: a tall card there scrolls first. False when + /// none is there, or it is at its end that way: the wheel is the list's. + fn scroll_tall(&mut self, p: DVec2, dy: f64) -> bool { + let Some(key) = self.card_at(p) else { return false }; + let Some(overflow) = self.tall_drawn.iter().find(|(k, _)| *k == key).map(|(_, o)| *o) else { return false }; + let Some(tall) = self.tall.get_mut(&key) else { return false }; + let chat = tall.chat; + tall.at.wheel(dy, overflow, chat) + } pub fn toggle(&mut self, cx: &mut Cx) { self.set_open(!self.open); self.redraw(cx); @@ -335,6 +443,7 @@ impl Widget for ShellGlancePanel { cx.begin_turtle(walk, self.layout); let screen = cx.turtle().rect(); self.card_rects.clear(); + self.tall_drawn.clear(); self.close = Rect::default(); // Every frame, open or closed: the kit keeps its overlay in tree order. self.d.begin_surface(cx); @@ -431,7 +540,17 @@ impl Widget for ShellGlancePanel { } let r = rect(x + PAD, y, PANEL_WIDTH - PAD * 2.0, h); let body = self.live.body(&key, card, "glance panel"); - self.tiles.draw(cx, &key, &card.app, card.contained, &body, r); + // A card taller than its tile scrolls inside it (one that + // peeks in shows its top), with a thumb at its edge, under + // the card's actions. + let overflow = if cut { 0.0 } else { crate::glance_card::overflow(&key) }; + let scroll = if overflow > 0.0 { self.tall_scroll(&key, card, overflow) } else { 0.0 }; + self.tiles.draw_scrolled(cx, &key, &card.app, card.contained, &body, r, scroll); + if overflow > 0.0 { + let track = rect(r.pos.x + r.size.x - 6.0, r.pos.y + 40.0, 3.0, (r.size.y - 50.0).max(12.0)); + let thumb = (track.size.y * r.size.y / (r.size.y + overflow)).clamp(12.0, track.size.y); + self.d.solid(cx, rect(track.pos.x, track.pos.y + (track.size.y - thumb) * scroll / overflow, track.size.x, thumb), alpha(ink, 0.35)); + } // A card that just came: an accent mark in the gutter // beside it for a few seconds, the one its toast is about. let age = now.saturating_sub(card.published_ms); @@ -486,9 +605,30 @@ impl Widget for ShellGlancePanel { log!("glance panel: {} card(s) {}", self.card_rects.len(), layout); self.logged = layout; } + // Where the tall cards sit, once per change: evidence for a remote run. + let places: Vec = self + .tall_drawn + .iter() + .filter_map(|(key, overflow)| { + let tall = self.tall.get(key)?; + let newest = if tall.at == TileScroll::Newest { " newest" } else { "" }; + Some(format!("{key} {:.0}/{:.0}{newest}", tall.at.offset(*overflow), overflow)) + }) + .collect(); + let places = places.join(", "); + if self.open && !self.sliding && places != self.tall_logged { + if !places.is_empty() { + log!("glance panel: scrolled {places}"); + } + self.tall_logged = places; + } let live: Vec = if self.open { crate::glance::listed().iter().map(|c| c.key()).collect() } else { Vec::new() }; self.tiles.sweep(cx, &live); self.live.retain(&live); + // A card that is not tall now (it fits, peeks in or is scrolled out + // of the list) starts over when it is again. + let tall_drawn = &self.tall_drawn; + self.tall.retain(|key, _| tall_drawn.iter().any(|(k, _)| k == key)); cx.end_turtle_with_area(&mut self.area); DrawStep::done() } @@ -506,9 +646,15 @@ impl Widget for ShellGlancePanel { self.new_timer = Timer::empty(); self.redraw(cx); } - // The wheel over the column: a card at a time. + // The wheel over the column: a card at a time, after a tall card + // under it has scrolled to its end that way. if let Event::Scroll(e) = event { if self.open && contains(self.column, e.abs) { + if self.scroll_tall(e.abs, e.scroll.y) { + self.wheel = 0.0; + self.redraw(cx); + return; + } self.wheel += e.scroll.y; let mut moved = false; while self.wheel >= WHEEL_STEP { @@ -605,13 +751,29 @@ impl Widget for ShellGlancePanel { Event::MouseUp(e) => { if let Some(key) = self.card_press.take() { if self.card_at(e.abs).as_deref() == Some(key.as_str()) { - log!("wm: glance card {key} opens in the card window"); - cx.widget_action(self.uid, ShellGlancePanelAction::OpenCard { key }); + // A chip claims no press and clicks once this + // release's handlers have run: wait a frame. + self.card_release = Some(key); + self.release_frame = cx.new_next_frame(); } } } _ => {} } + // The released card clicked: the press was one of its chips', + // not the card's. Seen before its taps run, on any event after + // the release's. + if let Some(key) = self.card_release.as_deref() { + if self.tiles.heap_key(cx, key).is_some_and(crate::glance_card::has_clicks) { + self.card_release = None; + } + } + if self.release_frame.is_event(event).is_some() { + if let Some(key) = self.card_release.take() { + log!("wm: glance card {key} opens in the card window"); + cx.widget_action(self.uid, ShellGlancePanelAction::OpenCard { key }); + } + } // Then the L0 taps those tiles queued, as the card window runs // its card's, and the cards whose chat moved (a reply came). if self.live.dispatch(cx, &self.tiles, "glance panel") { @@ -651,6 +813,41 @@ mod tests { assert_eq!(step_focus(3, None, 7, true), 2, "a stale first is clamped"); } + /// A chat card past its tile's cap (after the first exchange) sits at + /// its newest rows, so its field and the latest exchange show, and stays + /// there as the conversation grows; any other card shows its top. + #[test] + fn a_chat_card_taller_than_its_tile_shows_its_newest_rows() { + let chat = TileScroll::start(true); + assert_eq!(chat.offset(80.0), 80.0, "scrolled up by all it overflows: its bottom shows"); + assert_eq!(chat.offset(140.0), 140.0, "a reply came: still at the newest rows"); + assert_eq!(chat.offset(0.0), 0.0, "it fits: nothing to scroll"); + let other = TileScroll::start(false); + assert_eq!((other.offset(80.0), other.offset(140.0)), (0.0, 0.0), "its top, as before"); + } + + /// The wheel over a tall card scrolls it as far as it goes and is the + /// list's at the card's ends; a chat scrolled back to its bottom follows + /// its newest rows again, and one scrolled up stays where it was read. + #[test] + fn the_wheel_scrolls_a_tall_card_and_leaves_the_list_its_ends() { + let mut at = TileScroll::start(true); + assert!(!at.wheel(40.0, 80.0, true), "at the newest rows already: the list's"); + assert!(at.wheel(-30.0, 80.0, true)); + assert_eq!(at, TileScroll::Top(50.0)); + assert_eq!(at.offset(120.0), 50.0, "a reply while it was scrolled back: the rows read stay put"); + assert!(at.wheel(-100.0, 80.0, true)); + assert_eq!(at, TileScroll::Top(0.0)); + assert!(!at.wheel(-10.0, 80.0, true), "at its top: the list's"); + assert!(at.wheel(500.0, 80.0, true)); + assert_eq!(at, TileScroll::Newest, "back at the bottom: it follows the newest rows"); + let mut other = TileScroll::start(false); + assert!(!other.wheel(-10.0, 80.0, false)); + assert!(other.wheel(30.0, 80.0, false)); + assert!(other.wheel(500.0, 80.0, false)); + assert_eq!(other, TileScroll::Top(80.0), "a card with no chat does not follow its bottom"); + } + /// A dismissed card's focus passes to the card under it, or above it /// when it was the last, or to none when it was the only one. #[test] diff --git a/crates/shell/src/host_tools/scenario_tests.rs b/crates/shell/src/host_tools/scenario_tests.rs index 9e4e1125..083d6216 100644 --- a/crates/shell/src/host_tools/scenario_tests.rs +++ b/crates/shell/src/host_tools/scenario_tests.rs @@ -688,6 +688,23 @@ fn the_system_agent_and_the_person_work_with_one_app_agent_at_once() { assert_eq!((rows[audience]["lane"].clone(), rows[publish]["lane"].clone()), (json!("person"), json!("system_agent"))); assert!(task < ask && ask < audience && audience < publish, "merged by time: task {task}, ask {ask}, audience {audience}, publish {publish}"); assert!(rows.iter().all(|m| !m["content"].as_str().unwrap_or("").contains("() { - desk.phone_ui.glance_tiles.handle_event(cx, event); + let changed = self.desk(cx).borrow_mut::().is_some_and(|mut desk| desk.phone_ui.glance_cards.handle_event(cx, event, finger)); + if changed { + self.redraw_all(cx); } } } diff --git a/crates/shell/src/mobile_pages.rs b/crates/shell/src/mobile_pages.rs index ce15b409..4dac83ec 100644 --- a/crates/shell/src/mobile_pages.rs +++ b/crates/shell/src/mobile_pages.rs @@ -19,9 +19,19 @@ //! //! Drawing lives here too (`impl PhoneSurface`): the glance cards in the //! frosted style of the App Library, the spill pages, the indicator row. +//! +//! The published cards on the glance page are live, as in the desktop's +//! glance panel and card window ([`GlanceCards`], glance_card.rs +//! `LiveCards`): their chips, buttons, fields and in-card chat work here. +//! The shell, not a card, decides what a tap is ([`GlanceFinger`]): a page +//! swipe, a pull, a long press or a press on the shell's own controls clicks +//! no card, and a plain tap clicks only the card under it. What the person +//! types in a card's field is theirs, whatever the finger did. use crate::{ desktop::DesktopStyle, - mobile::{PhoneHit, PhoneState}, + glance::GlanceCard, + glance_card::{GlanceTiles, LiveCards}, + mobile::{PhoneGesture, PhoneHit, PhoneScreen, PhoneState}, mobile_gestures::{Dir, GestureKind, ShellGesture}, mobile_surface::{dock_ids, PhoneSurface}, mobile_tiles, @@ -49,39 +59,11 @@ pub enum GlanceItem { Event { title: String, when: String }, Fetch { title: String, progress: f64 }, Note { title: String, body: String }, - /// A card an app published through `glance.publish` (glance.rs), drawn - /// by the Card runner's pipeline at the tile size (glance_card.rs). - Card(GlanceCardItem), -} - -/// A published card as the feed holds it. -#[derive(Clone, Debug, PartialEq)] -pub struct GlanceCardItem { - /// The publishing app (the caller's id) and its card id: the dedupe key. - pub app: String, - pub card_id: String, - pub title: String, - pub priority: i64, - pub published_ms: u64, - /// The launcher id a tap opens, and the route inside it. - pub open_app: String, - pub route: Option, - /// The Splash body the tile runs. - pub body: std::sync::Arc, - /// Published by a contained app: the tile runs under its policy. - pub contained: bool, -} - -impl GlanceCardItem { - pub fn key(&self) -> String { - format!("{}/{}", self.app, self.card_id) - } -} - -impl From for GlanceCardItem { - fn from(c: crate::glance::GlanceCard) -> Self { - GlanceCardItem { app: c.app, card_id: c.card_id, title: c.title, priority: c.priority, published_ms: c.published_ms, open_app: c.open_app, route: c.route, body: c.body, contained: c.contained } - } + /// A card an app published through `glance.publish`, as the glance + /// service holds it (glance.rs): its publisher (the caller's id, with + /// its card id the dedupe key), the launcher id its open button opens, + /// and the L0 card its tile keeps live ([`GlanceCards`]). + Card(GlanceCard), } impl GlanceItem { @@ -145,10 +127,10 @@ impl GlanceFeed { return; } self.generation = generation; - self.replace_cards(crate::glance::shown().into_iter().map(GlanceCardItem::from).collect()); + self.replace_cards(crate::glance::shown()); } /// Replace every published card. - pub fn replace_cards(&mut self, cards: Vec) { + pub fn replace_cards(&mut self, cards: Vec) { self.cards = cards.into_iter().map(GlanceItem::Card).collect(); self.order_cards(); } @@ -161,7 +143,7 @@ impl GlanceFeed { self.cards.truncate(crate::glance::SHOWN_CARDS); } /// The published cards shown, in glance order. - pub fn cards(&self) -> impl Iterator { + pub fn cards(&self) -> impl Iterator { self.cards.iter().filter_map(|i| match i { GlanceItem::Card(c) => Some(c), _ => None, @@ -476,6 +458,165 @@ fn label_of(ids: &[(String, String)], id: &str) -> String { ids.iter().find(|(i, _)| i == id).map(|(_, l)| l.clone()).unwrap_or_else(|| id.to_string()) } +// ------------------------------------------------------- the live cards + +/// How far a finger may travel and still tap: the shell's own rule for its +/// hits (mobile_app.rs), and about a lowered card's tap targets' (Octoscript- +/// Makepad's `OctoscriptTap`, 12 points). +pub const TAP_SLOP: f64 = 12.0; + +/// A pointer event as the shell's finger makes it, for the glance page's +/// cards: the shell, not a card, decides what a tap is. A card's tap target +/// fires on a release near its press, but on the glance page the same finger +/// may be a page swipe, a pull, a long press, or a press on the shell's own +/// controls (a card's open button, the page indicator, the dock). +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum GlanceFinger { + /// Not the pointer: typing in a card's field, an answer, a timer. The + /// cards' taps run. + NotPointer, + /// The lift of a finger that stayed a plain tap, down at this point: + /// the card under it may act on it, and no other. + Tap(Vec2d), + /// Any other pointer event: no card acts on it. + NotATap, +} + +impl GlanceFinger { + /// What `event` is, before the shell handles it: `gesture` is the finger + /// the shell holds (`PhoneState::gesture`), `claimed` whether the + /// recognizer took it for a swipe or a pull (mobile_gestures.rs, its + /// gesture in progress), `touch` the touch the shell follows. + pub fn of(event: &Event, gesture: Option<&PhoneGesture>, claimed: bool, touch: Option) -> Self { + use makepad_platform::event::TouchState; + let lift = match event { + Event::MouseUp(e) if e.button.contains(MouseButton::PRIMARY) => e.abs, + Event::TouchUpdate(update) => match update.touches.iter().find(|t| t.state == TouchState::Stop && Some(t.uid) == touch) { + Some(t) => t.abs, + None => return Self::NotATap, + }, + Event::MouseDown(_) | Event::MouseMove(_) | Event::MouseUp(_) | Event::Scroll(_) | Event::LongPress(_) => return Self::NotATap, + _ => return Self::NotPointer, + }; + match gesture { + // A press on the page itself, not on one of the shell's controls, + // that no gesture took and that stayed put. + Some(g) if g.screen == PhoneScreen::Home && g.hit.is_none() && !claimed && (lift - g.start).length() < TAP_SLOP => Self::Tap(g.start), + _ => Self::NotATap, + } + } +} + +/// The part of a tile a finger can tap: the tile (a card taller than the +/// tile's cap is clipped there, its taps too) as far as `column` shows it, +/// the page above the page indicator and the dock, which are the shell's. +pub fn tappable(tile: Rect, column: Rect) -> Rect { + tile.clip((column.pos, column.pos + column.size)) +} + +const WHO: &str = "glance page"; + +/// The glance page's published cards, live as in the desktop's glance panel +/// and card window (glance_card.rs): each card's tile (a Splash under the +/// publishing app's policy) and its L0 session ([`LiveCards`]), so a card's +/// chips, buttons, field edits and in-card chat (`sys.chat`, with the +/// publishing app's own agent) run here through the same path, as that app. +/// What differs is the finger: a card's clicks (its tap targets' calls) run +/// only for a plain tap on that card ([`GlanceFinger`]); those of any other +/// finger are dropped. Its field edits (what the person typed) always run. +/// A card's state lasts while it is published, scrolled or paged out of view +/// included; a newer publish starts it over. +#[derive(Default)] +pub struct GlanceCards { + pub tiles: GlanceTiles, + live: LiveCards, + /// The published cards' keys, as of the last draw. + keys: Vec, + /// The part of each tile a finger can tap, as drawn this frame. + drawn: Vec<(String, Rect)>, + /// The card whose clicks count: the one the last pointer event was a + /// plain tap on, if it was one. + clicks: Option, + /// What the last layout log said, so it is logged once per change. + logged: String, +} + +impl GlanceCards { + /// A new frame: no tile is drawn yet. + pub fn begin(&mut self) { + self.drawn.clear(); + } + + /// Draw `card` at `tile` as its session has it now (made on first use + /// for the app that published it); `column` is where the page lets a + /// finger tap ([`tappable`]). + pub fn draw(&mut self, cx: &mut Cx2d, card: &GlanceCard, tile: Rect, column: Rect) { + let key = card.key(); + let body = self.live.body(&key, card, WHO); + self.tiles.draw(cx, &key, &card.app, card.contained, &body, tile); + self.drawn.push((key, tappable(tile, column))); + } + + /// The end of the page's draw: `live` are the published cards' keys. + /// The others' tiles stop and their sessions go. + pub fn sweep(&mut self, cx: &mut Cx, live: Vec) { + self.tiles.sweep(cx, &live); + self.live.retain(&live); + self.keys = live; + } + + /// Where the tiles landed (their tappable parts), logged once per + /// change: evidence for a remote run. The page calls it at rest only, + /// not on every frame of a swipe. + pub fn log_layout(&mut self) { + let layout: Vec = self.drawn.iter().map(|(key, r)| format!("{key}@{},{},{},{}", r.pos.x as i32, r.pos.y as i32, r.size.x as i32, r.size.y as i32)).collect(); + let layout = layout.join(" "); + if layout != self.logged { + log!("{WHO}: {} card(s) {layout}", self.drawn.len()); + self.logged = layout; + } + } + + /// The card a finger at `p` is on: the tile drawn there, as much of it + /// as the page shows. + fn under(&self, p: Vec2d) -> Option<&str> { + self.drawn.iter().rev().find(|(_, r)| r.size.x > 0.0 && r.size.y > 0.0 && r.contains(p)).map(|(key, _)| key.as_str()) + } + + /// One event for the cards: the tiles get it (glance_card.rs + /// `GlanceTiles::handle_event`), then the cards' queued taps run through + /// their sessions (`LiveCards::dispatch`), as do the cards whose chat + /// moved (a reply came); true when a card changed, and the page redraws. + /// + /// A tap target calls `NAV` once the event's handlers have run (a + /// widget's script call runs at the end of the event's cycle), so a click + /// is queued after the pointer event it came from, and judged on a later + /// event by that pointer event: `finger`, what the shell says it is, is + /// kept until the next pointer event. Clicks of every card but the one a + /// plain tap was on are dropped before the taps run. + pub fn handle_event(&mut self, cx: &mut Cx, event: &Event, finger: GlanceFinger) -> bool { + for key in &self.keys { + if self.clicks.as_deref() == Some(key.as_str()) { + continue; + } + let Some(heap) = self.tiles.heap_key(cx, key) else { continue }; + let dropped = crate::glance_card::drop_clicks(heap); + if dropped > 0 { + let why = if self.clicks.is_some() { "the tap was on another card" } else { "not a tap" }; + log!("{WHO}: {key}: {dropped} tap(s) dropped ({why})"); + } + } + let changed = self.live.dispatch(cx, &self.tiles, WHO); + self.tiles.handle_event(cx, event); + match finger { + GlanceFinger::NotPointer => {} + GlanceFinger::Tap(at) => self.clicks = self.under(at).map(str::to_string), + GlanceFinger::NotATap => self.clicks = None, + } + changed + } +} + // ---------------------------------------------------------------- drawing impl PhoneSurface { @@ -497,30 +638,36 @@ impl PhoneSurface { self.d.label_elided(cx, rect(left, top, width, 30.0), true, 24.0, ink, HAlign::Left, "At a glance"); self.d.label_elided(cx, rect(left, top + 32.0, width, 20.0), false, 13.0, alpha(ink, 0.7 * opacity), HAlign::Left, &phone.pages.date); let bottom = screen.pos.y + screen.size.y - GLANCE_BOTTOM; + // Where a finger can tap a card: the page on screen, above the page + // indicator and the dock (a tall card reaches under them). + let column = page.clip((screen.pos, dvec2(screen.pos.x + screen.size.x, bottom))); let mut y = top + 64.0 - phone.pages.glance_scroll; let items: Vec = phone.pages.feed.items().cloned().collect(); for item in &items { let h = item.height(); if y + h > top + 56.0 && y < bottom { - self.draw_glance_card(cx, rect(left, y, width, h), item, style, dark, ink, opacity); + self.draw_glance_card(cx, rect(left, y, width, h), column, item, style, dark, ink, opacity); } y += h + GLANCE_GAP; } - let live: Vec = phone.pages.feed.cards().map(GlanceCardItem::key).collect(); - self.glance_tiles.sweep(cx, &live); + let live: Vec = phone.pages.feed.cards().map(GlanceCard::key).collect(); + self.glance_cards.sweep(cx, live); + if dx == 0.0 { + self.glance_cards.log_layout(); + } } - fn draw_glance_card(&mut self, cx: &mut Cx2d, r: Rect, item: &GlanceItem, style: DesktopStyle, dark: bool, ink: Vec4f, opacity: f32) { + fn draw_glance_card(&mut self, cx: &mut Cx2d, r: Rect, column: Rect, item: &GlanceItem, style: DesktopStyle, dark: bool, ink: Vec4f, opacity: f32) { if let GlanceItem::Card(card) = item { // A published card draws itself (its own surface) at the tile - // rect and takes its own input (glance_card.rs); the open button - // at its corner opens its app. Without App Hub's vocabulary a - // frosted title stands in. + // rect, takes its own input and runs its taps ([`GlanceCards`]); + // the open button at its corner opens its app. Without App Hub's + // vocabulary a frosted title stands in. if !crate::glance_card::CAN_RENDER { self.rounded(cx, r, 18.0, alpha(self.theme_face(rgb(255, 255, 255)), if dark { 0.10 } else { 0.55 } * opacity)); self.d.label_elided(cx, rect(r.pos.x + 16.0, r.pos.y + 16.0, r.size.x - 32.0, 22.0), true, 15.0, ink, HAlign::Left, &card.title); } - self.glance_tiles.draw(cx, &card.key(), &card.app, card.contained, &card.body, r); + self.glance_cards.draw(cx, card, r, column); let open = crate::glance_card::open_button(r); self.rounded(cx, open, 14.0, alpha(self.theme_face(rgb(255, 255, 255)), if dark { 0.22 } else { 0.8 } * opacity)); self.d.icon_centered(cx, Ico::ChevronRight, open, 14.0, ink); @@ -829,9 +976,9 @@ mod tests { } fn card(app: &str, id: &str, priority: i64, published_ms: u64) -> GlanceItem { - GlanceItem::Card(GlanceCardItem { - app: app.into(), card_id: id.into(), title: format!("{app}/{id}"), priority, published_ms, - open_app: app.trim_start_matches("os.").into(), route: None, body: "".into(), contained: true, + GlanceItem::Card(GlanceCard { + app: app.into(), card_id: id.into(), title: format!("{app}/{id}"), priority, published_ms, expires_ms: 0, + open_app: app.trim_start_matches("os.").into(), route: None, body: "".into(), contained: true, l0: None, }) } @@ -865,4 +1012,164 @@ mod tests { // A card's height is its tile's (glance_card.rs), before it has drawn. assert_eq!(card("os.y", "new", 1, 1).height(), crate::glance_card::TILE_DEFAULT_HEIGHT); } + + /// The feed keeps a published card as the glance service gives it, its + /// L0 source included: that is what a tile keeps live. + #[test] + fn the_feed_keeps_a_cards_l0_source_for_its_tile() { + let (_, _, source, data) = crate::glance::demo_mail().into_iter().find(|c| c.0 == "ups-lamp").unwrap(); + let GlanceItem::Card(mut published) = card("os.mail", "ups-lamp", 50, 1) else { unreachable!() }; + published.l0 = Some(std::sync::Arc::new(crate::glance::L0Source { source, data })); + let mut feed = GlanceFeed::default(); + feed.replace_cards(vec![published.clone()]); + assert_eq!(feed.cards().next(), Some(&published)); + assert!(feed.cards().next().unwrap().l0.is_some()); + } + + fn gesture(start: Vec2d, hit: Option) -> PhoneGesture { + PhoneGesture { start, last: start, time: 0.0, hit, shell: true, screen: PhoneScreen::Home } + } + fn mouse_up(abs: Vec2d) -> Event { + Event::MouseUp(MouseUpEvent { abs, button: MouseButton::PRIMARY, window_id: CxWindowPool::id_zero(), modifiers: Default::default(), time: 0.0 }) + } + fn touch(state: makepad_platform::event::TouchState, abs: Vec2d, uid: u64) -> Event { + use makepad_platform::event::{TouchPoint, TouchUpdateEvent}; + Event::TouchUpdate(TouchUpdateEvent { + time: 0.0, window_id: CxWindowPool::id_zero(), modifiers: Default::default(), + touches: vec![TouchPoint { state, abs, time: 0.0, uid, rotation_angle: 0.0, force: 0.0, radius: dvec2(1.0, 1.0), handled: Default::default(), sweep_lock: Default::default() }], + }) + } + + /// Only the lift of a plain tap on the page itself is a tap for a card: + /// not a swipe or a pull the recognizer took, not a finger that + /// travelled, not a press on the shell's own controls, not a finger a + /// long press took (the shell let go of it), not another touch, and no + /// other pointer event. Typing and answers are not the pointer at all. + #[test] + fn only_a_plain_taps_lift_is_a_tap_for_a_card() { + use makepad_platform::event::TouchState; + let at = dvec2(120.0, 300.0); + let g = gesture(at, None); + assert_eq!(GlanceFinger::of(&mouse_up(at + dvec2(3.0, 2.0)), Some(&g), false, None), GlanceFinger::Tap(at)); + assert_eq!(GlanceFinger::of(&mouse_up(at + dvec2(11.0, 0.0)), Some(&g), true, None), GlanceFinger::NotATap, "a page swipe the recognizer took, short as it was"); + assert_eq!(GlanceFinger::of(&mouse_up(at + dvec2(0.0, -40.0)), Some(&g), false, None), GlanceFinger::NotATap, "a drag no gesture took"); + let open = gesture(at, Some(PhoneHit::Glance("mail".into()))); + assert_eq!(GlanceFinger::of(&mouse_up(at), Some(&open), false, None), GlanceFinger::NotATap, "the card's open button is the shell's"); + assert_eq!(GlanceFinger::of(&mouse_up(at), None, false, None), GlanceFinger::NotATap, "a long press took the finger"); + let elsewhere = PhoneGesture { screen: PhoneScreen::Drawer, ..g.clone() }; + assert_eq!(GlanceFinger::of(&mouse_up(at), Some(&elsewhere), false, None), GlanceFinger::NotATap); + // The touch the shell follows; another finger's lift, a press and a + // move are no tap. + assert_eq!(GlanceFinger::of(&touch(TouchState::Stop, at, 7), Some(&g), false, Some(7)), GlanceFinger::Tap(at)); + assert_eq!(GlanceFinger::of(&touch(TouchState::Stop, at, 8), Some(&g), false, Some(7)), GlanceFinger::NotATap); + assert_eq!(GlanceFinger::of(&touch(TouchState::Start, at, 7), Some(&g), false, Some(7)), GlanceFinger::NotATap); + assert_eq!(GlanceFinger::of(&touch(TouchState::Move, at, 7), Some(&g), false, Some(7)), GlanceFinger::NotATap); + let down = Event::MouseDown(MouseDownEvent { abs: at, button: MouseButton::PRIMARY, window_id: CxWindowPool::id_zero(), modifiers: Default::default(), handled: Default::default(), time: 0.0 }); + assert_eq!(GlanceFinger::of(&down, Some(&g), false, None), GlanceFinger::NotATap); + let typed = Event::TextInput(TextInputEvent { input: "hi".into(), ..Default::default() }); + assert_eq!(GlanceFinger::of(&typed, Some(&g), true, None), GlanceFinger::NotPointer); + assert_eq!(GlanceFinger::of(&Event::Signal, None, false, None), GlanceFinger::NotPointer); + } + + /// A card is tappable only where the column shows it: a tall card's part + /// under the page indicator and the dock is theirs. + #[test] + fn a_tile_is_tappable_only_where_the_column_shows_it() { + let column = rect(0.0, 0.0, 400.0, 776.0); + assert_eq!(tappable(rect(20.0, 160.0, 360.0, 300.0), column), rect(20.0, 160.0, 360.0, 300.0)); + assert_eq!(tappable(rect(20.0, 600.0, 360.0, 440.0), column), rect(20.0, 600.0, 360.0, 176.0)); + let cards = GlanceCards { drawn: vec![("os.a/x".into(), tappable(rect(20.0, 600.0, 360.0, 440.0), column))], ..Default::default() }; + assert_eq!(cards.under(dvec2(100.0, 700.0)), Some("os.a/x")); + assert_eq!(cards.under(dvec2(100.0, 800.0)), None, "under the dock"); + let gone = GlanceCards { drawn: vec![("os.a/x".into(), tappable(rect(20.0, 800.0, 360.0, 100.0), column))], ..Default::default() }; + assert_eq!(gone.under(dvec2(100.0, 776.0)), None, "nothing of it shows"); + } + + /// The tap target for `event` a lowered card offers (its `NAV` call). + #[cfg(feature = "app-hub")] + fn target(body: &str, event: &str) -> String { + body.split("NAV(t: ") + .skip(1) + .filter_map(|rest| serde_json::from_str::(&rest[..rest.find("\"}\"").map(|i| i + 3).unwrap_or(0)]).ok()) + .find(|t| crate::glance_card::parse_tap(t).is_some_and(|(_, e, _)| e == event)) + .unwrap_or_else(|| panic!("no {event} in {body}")) + } + + /// The glance page's cards run their L0 taps as the desktop's panel + /// does (glance_card.rs `LiveCards`), as the app that published them, + /// and a card's clicks only for a plain tap on that card. A tap target + /// calls `NAV` at the end of the event's cycle, so each click here is + /// queued after the pointer event it came from and judged on the next + /// event by that pointer event: a swipe's lift drops it, as does a tap + /// on another card or under the column; a field's edit (it carries what + /// the person typed) runs whatever the finger did. Before, the page drew + /// the chips and ran none of them. + #[cfg(feature = "app-hub")] + #[test] + fn the_glance_pages_cards_run_their_taps_for_a_plain_tap_only() { + use crate::glance::{Caller, GlanceStore}; + use crate::glance_card::{queue_tap_for_test as queue, take_taps, Tap}; + let (_, title, source, data) = crate::glance::demo_mail().into_iter().find(|c| c.0 == "ups-lamp").unwrap(); + let mut store = GlanceStore::default(); + store.publish(&Caller::granted("com.example.shop"), &serde_json::json!({"card_id": "parcel", "title": title, "source": source, "data": data}), 0).unwrap(); + store.publish(&Caller::granted("com.example.other"), &serde_json::json!({"card_id": "c", "title": "Other", "script": "View{}"}), 0).unwrap(); + let shop = store.card("com.example.shop/parcel", 0).unwrap(); + let other = store.card("com.example.other/c", 0).unwrap(); + let mut cx = Cx::new(Box::new(|_, _| {})); + cx.with_vm(|vm| { + makepad_widgets::script_mod(vm); + crate::glance_card::script_mod(vm); + }); + let mut cards = GlanceCards::default(); + let body = cards.live.body(&shop.key(), &shop, WHO); + cards.tiles.open(&mut cx, &shop.key(), &shop.app, true, &"View{}".into()); + cards.tiles.open(&mut cx, &other.key(), &other.app, true, &"View{}".into()); + let heap = cards.tiles.heap_key(&mut cx, &shop.key()).unwrap(); + let other_heap = cards.tiles.heap_key(&mut cx, &other.key()).unwrap(); + cards.keys = vec![shop.key(), other.key()]; + cards.drawn = vec![(shop.key(), rect(20.0, 160.0, 360.0, 200.0)), (other.key(), rect(20.0, 372.0, 360.0, 148.0))]; + let track = target(&body, "track"); + let click = |target: &str| Tap { heap, target: target.into(), typed: None }; + let tracking = |cards: &mut GlanceCards| cards.live.body(&shop.key(), &shop, WHO).contains("Opening the carrier's tracking page"); + let on_shop = dvec2(100.0, 200.0); + let up = mouse_up(on_shop); + let next = Event::Signal; + + // A page swipe that began on Track: its lift is no tap, and the + // click Track queued after it is dropped on the next event. + assert!(!cards.handle_event(&mut cx, &up, GlanceFinger::NotATap)); + queue(click(&track)); + assert!(!cards.handle_event(&mut cx, &next, GlanceFinger::NotPointer)); + assert!(!tracking(&mut cards) && take_taps(heap).is_empty(), "dropped, not left queued"); + // A plain tap on the other card: this card's click is not its; that + // card's own stays for it. + cards.handle_event(&mut cx, &up, GlanceFinger::Tap(dvec2(100.0, 400.0))); + queue(click(&track)); + queue(Tap { heap: other_heap, target: "l0:{}".into(), typed: None }); + assert!(!cards.handle_event(&mut cx, &next, GlanceFinger::NotPointer)); + assert!(!tracking(&mut cards) && take_taps(heap).is_empty()); + assert_eq!(take_taps(other_heap).len(), 1); + // Below the column (the indicator's and the dock's): no card's. + cards.handle_event(&mut cx, &up, GlanceFinger::Tap(dvec2(100.0, 900.0))); + queue(click(&track)); + cards.handle_event(&mut cx, &next, GlanceFinger::NotPointer); + assert!(!tracking(&mut cards) && take_taps(heap).is_empty()); + // A plain tap on this card runs it, as the app that published it, + // also when other events (not the pointer) come between. + cards.handle_event(&mut cx, &up, GlanceFinger::Tap(on_shop)); + cards.handle_event(&mut cx, &next, GlanceFinger::NotPointer); + queue(click(&track)); + assert!(cards.handle_event(&mut cx, &next, GlanceFinger::NotPointer), "the card changed"); + assert!(tracking(&mut cards)); + // A field's edit carries the person's text: it runs after a finger + // that was no tap too. + cards.handle_event(&mut cx, &up, GlanceFinger::NotATap); + let back = target(&cards.live.body(&shop.key(), &shop, WHO), "back"); + queue(Tap { heap, target: back, typed: Some(String::new()) }); + assert!(cards.handle_event(&mut cx, &next, GlanceFinger::NotPointer)); + assert!(!tracking(&mut cards), "back to Track"); + // A card no longer published takes its tile and session with it. + cards.sweep(&mut cx, vec![other.key()]); + assert!(cards.tiles.heap_key(&mut cx, &shop.key()).is_none()); + } } diff --git a/crates/shell/src/mobile_surface.rs b/crates/shell/src/mobile_surface.rs index 51c7a1c2..7c1b3c51 100644 --- a/crates/shell/src/mobile_surface.rs +++ b/crates/shell/src/mobile_surface.rs @@ -2,7 +2,7 @@ use crate::{desktop::DesktopStyle, desk::WmState, mobile::*, mobile_tiles::{self, HomeLayout, TileSlot, TILE_RADIUS}, shell::{alpha, rgb, ui::{rect, HAlign, Ico, ShellDraw}}}; use makepad_widgets::{gauss_view::{GaussRoundedView, GaussBlurSnapshot}, *}; use crate::mobile_shade::ShadeContentCache; -use crate::glance_card::GlanceTiles; +use crate::mobile_pages::GlanceCards; use crate::octosense::style::AppIconDraw; mod search; use search::SearchResults; @@ -398,8 +398,9 @@ pub struct PhoneSurface { #[live] android_icon: DrawImage, #[rust] pub icons: AppIconDraw, #[rust] pub hits: Vec<(Rect, PhoneHit)>, - /// The glance page's published cards, each a contained Splash tile (glance_card.rs). - #[rust] pub glance_tiles: GlanceTiles, + /// The glance page's published cards, live: each a Splash tile under its + /// app's policy with its L0 session (mobile_pages.rs, glance_card.rs). + #[rust] pub glance_cards: GlanceCards, /// The hits published as accessibility nodes this frame, in node order /// (an activation from the platform names a node by its index). #[rust] pub a11y_hits: Vec, @@ -590,7 +591,7 @@ impl PhoneSurface { pub fn hit_rect(&self, hit: &PhoneHit) -> Option { self.hits.iter().find(|(_, h)| h == hit).map(|(r, _)| *r) } - pub fn begin(&mut self) { self.hits.clear();self.home_icon_bounds.clear(); } + pub fn begin(&mut self) { self.hits.clear();self.home_icon_bounds.clear();self.glance_cards.begin(); } pub fn pressed_hit(&self) -> Option<&PhoneHit> { self.pressed.as_ref() } pub fn rounded(&mut self, cx: &mut Cx2d, r: Rect, radius: f32, color: Vec4f) { self.round.radius = radius*2.0; diff --git a/crates/shell/src/module_input_tests.rs b/crates/shell/src/module_input_tests.rs index f7b5b752..023feda6 100644 --- a/crates/shell/src/module_input_tests.rs +++ b/crates/shell/src/module_input_tests.rs @@ -6,6 +6,12 @@ //! which a GestureView ignores. News's story rows are GestureViews, so a //! click on Mail's inbox footer, over News's third story, opened that story //! (the instrument's B3). +//! +//! A hover goes the same way. The window in front takes a hover inside it +//! even where none of its widgets does, and a tile behind lets a hover a +//! window in front took by. Before, no tile claimed a move, so a hover over +//! Mail's empty part reached News's widgets unclaimed: they lit up and set +//! their cursors under Mail. use crate::module_host::ModuleHost; use crate::module_view::MpModuleView; @@ -27,12 +33,13 @@ script_mod! { } } -/// An app's root that counts the presses reaching it. A tappable one takes -/// them as makepad's GestureView does: a button press through +/// An app's root that counts the presses and hovers reaching it. A tappable +/// one takes them as makepad's GestureView does: a button press through /// `hits_with_capture_overload`, which co-captures a press another area /// already claimed, and a touch straight from the raw stream. A claim alone -/// never keeps a press from it. An inert one takes nothing, like the empty -/// part of a window (Mail's inbox footer). +/// never keeps a press from it. It takes a hover through the same hit test, +/// as a Button or a View does, which claims the move. An inert one takes +/// nothing, like the empty part of a window (Mail's inbox footer). #[derive(Script, ScriptHook, Widget)] pub struct TapProbe { #[uid] @@ -54,6 +61,12 @@ pub struct TapProbe { /// Presses it took and saw released over it. #[rust] taps: usize, + /// Hovers (mouse moves with no button held) that reached the app. + #[rust] + moves: usize, + /// Hovers it took. + #[rust] + hovers: usize, /// The touch it follows. #[rust] touch: Option, @@ -63,6 +76,7 @@ impl Widget for TapProbe { fn handle_event(&mut self, cx: &mut Cx, event: &Event, _scope: &mut Scope) { match event { Event::MouseDown(_) => self.seen += 1, + Event::MouseMove(_) => self.moves += 1, Event::TouchUpdate(update) => { self.seen += update.touches.iter().filter(|t| t.state == TouchState::Start).count(); } @@ -88,10 +102,10 @@ impl Widget for TapProbe { } return; } - if let Hit::FingerUp(e) = event.hits_with_capture_overload(cx, area, true) { - if e.is_over { - self.taps += 1; - } + match event.hits_with_capture_overload(cx, area, true) { + Hit::FingerUp(e) if e.is_over => self.taps += 1, + Hit::FingerHoverIn(_) | Hit::FingerHoverOver(_) => self.hovers += 1, + _ => {} } } @@ -112,6 +126,8 @@ enum Root { Inert, /// Makepad's own GestureView. Gesture, + /// Makepad's own Button. + Button, } struct InputProbe { @@ -142,6 +158,10 @@ impl AppModule for InputProbe { let value = script_eval!(vm, { use mod.widgets.* GestureView {} }); WidgetRef::script_from_value(vm, value) } + Root::Button => { + let value = script_eval!(vm, { use mod.widgets.* Button { text: "Read" } }); + WidgetRef::script_from_value(vm, value) + } Root::Tappable | Root::Inert => { let value = script_eval!(vm, { use mod.widgets.* TapProbe {} }); let root = WidgetRef::script_from_value(vm, value); @@ -169,6 +189,8 @@ static NEWS: InputProbe = InputProbe { id: "news-probe", root: Root::Tappable }; static MAIL: InputProbe = InputProbe { id: "mail-probe", root: Root::Inert }; /// News with makepad's GestureView as its row, as its bundle has it. static GESTURE_NEWS: InputProbe = InputProbe { id: "gesture-probe", root: Root::Gesture }; +/// News with makepad's Button under the pointer, as its tab bar has them. +static BUTTON_NEWS: InputProbe = InputProbe { id: "button-probe", root: Root::Button }; fn setup() -> (Cx, ModuleHost) { let mut cx = Cx::new(Box::new(|_, _| {})); @@ -242,6 +264,18 @@ fn mouse_up(abs: Vec2d) -> Event { Event::MouseUp(MouseUpEvent { abs, button: MouseButton::PRIMARY, window_id: WindowId(0, 0), modifiers: Default::default(), time: 1.1 }) } +/// A mouse move with no button held. +fn hover(abs: Vec2d) -> Event { + Event::MouseMove(MouseMoveEvent { + abs, + lock_delta: Vec2d::default(), + window_id: WindowId(0, 0), + modifiers: Default::default(), + time: 1.2, + handled: Cell::new(Area::Empty), + }) +} + fn touch(abs: Vec2d, state: TouchState) -> Event { Event::TouchUpdate(TouchUpdateEvent { time: 1.0, @@ -296,6 +330,15 @@ fn counts(cx: &mut Cx, host: &mut ModuleHost, client: u64) -> (usize, usize) { .unwrap() } +/// (hovers seen, hovers taken) of a probe app. +fn hover_counts(cx: &mut Cx, host: &mut ModuleHost, client: u64) -> (usize, usize) { + host.dispatch(cx, client, "a test read", |_, root| { + let probe = root.borrow::().unwrap(); + (probe.moves, probe.hovers) + }) + .unwrap() +} + /// News is opened, then Mail, which cascades over it. A press inside both /// lands on Mail where Mail has nothing to take it, over a News row. const NEWS_AT: (f64, f64) = (20.0, 40.0); @@ -368,6 +411,70 @@ fn a_gesture_view_behind_the_window_in_front_never_captures_its_press() { close(cx, host, vec![news, mail], frame, &[1, 2]); } +/// A hover on the front window where it takes none: Mail's tile takes it, +/// and News's app behind never sees it. +#[test] +fn a_hover_on_the_window_in_front_never_reaches_the_app_behind() { + let (mut cx, mut host, news, mail, frame) = news_and_mail(&NEWS); + let over_both = dvec2(OVER_BOTH.0, OVER_BOTH.1); + let moved = [hover(over_both)]; + assert_eq!(offer(&mut cx, &[&mail, &news], &moved), [], "a hover raises nothing"); + assert_eq!(hover_counts(&mut cx, &mut host, 2), (1, 0), "Mail's app saw it; nothing there took it"); + assert_eq!(hover_counts(&mut cx, &mut host, 1), (0, 0), "News's app never saw it, so its row did not light up"); + // So Mail's tile took it: any window further back sees it claimed. + let Event::MouseMove(e) = &moved[0] else { unreachable!() }; + assert!(!e.handled.get().is_empty(), "the hover left Mail unclaimed"); + // Where News is in front of nothing, its row takes the hover... + offer(&mut cx, &[&mail, &news], &[hover(dvec2(NEWS_ONLY.0, NEWS_ONLY.1))]); + assert_eq!(hover_counts(&mut cx, &mut host, 1), (1, 1)); + // ...and the move that takes the pointer on over Mail still reaches News, + // so the row sees its hover end. Mail took that move, so nothing lights + // up, and no move after it reaches News. + offer(&mut cx, &[&mail, &news], &[hover(over_both), hover(over_both + dvec2(10.0, 0.0))]); + assert_eq!(hover_counts(&mut cx, &mut host, 1), (2, 1), "one move to end the hover, none after it"); + // Mail's app sees every move: those over it, and the one outside it. + assert_eq!(hover_counts(&mut cx, &mut host, 2), (4, 0)); + close(cx, host, vec![news, mail], frame, &[1, 2]); +} + +/// With makepad's own Button as the app behind: a hover it takes lights it +/// up (`hover.on`) and asks for the hand cursor. A hover on Mail where Mail +/// takes none does neither. +#[test] +fn a_button_behind_the_window_in_front_never_lights_up_under_it() { + let (mut cx, mut host, news, mail, frame) = news_and_mail(&BUTTON_NEWS); + let lit = |cx: &mut Cx, host: &mut ModuleHost| { + host.dispatch(cx, 1, "a test read", |cx, root| root.borrow::