From e34d8fe30e7f1efc4e7b961ba25df42ee9960674 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:29:56 -0700 Subject: [PATCH 01/11] feat(mail): Mail's agent can notify the person with a glance card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mail declares an agent (read-only profile, ask_user_question) and the `glance` capability, and ships `tools.json` with one tool, `mail.notify` {title, body, card_id?, priority?}. It runs on the mail host service, which fills a fixed L0 notice card (resources/notice.card) from the call and publishes it through the shell's glance service as the calling app, with a notification; the model never writes card code. The shell installs the publisher at startup and passes the app's `glance` grant from its admitted manifest (script_apps::grants), since a tool call runs outside the app's isolate. Verified on a scratch home (deepseek-v4-flash, demo mailbox, developer mode, OCTOSENSE_CONTAINED_APPS=1): the system chat's peer_send_input started os.mail's turn, Mail's agent called mail.notify, and the card and toast "Mail · Hello" appeared. Mail's read tools (accounts, list, message) are left out: octos takes a tool only with an object output schema, and mail.accounts answers an array. Co-Authored-By: Claude Opus 5.5 --- apps/mail/bundle/manifest.json | 9 ++- apps/mail/bundle/tools.json | 60 ++++++++++++++++ apps/mail/host-service/resources/notice.card | 23 ++++++ apps/mail/host-service/src/lib.rs | 74 ++++++++++++++++++++ crates/shell/src/apps.rs | 6 ++ crates/shell/src/glance.rs | 15 ++++ crates/shell/src/host_tools/script_apps.rs | 37 +++++++++- 7 files changed, 219 insertions(+), 5 deletions(-) create mode 100644 apps/mail/bundle/tools.json create mode 100644 apps/mail/host-service/resources/notice.card diff --git a/apps/mail/bundle/manifest.json b/apps/mail/bundle/manifest.json index 92ba3075..2730688c 100644 --- a/apps/mail/bundle/manifest.json +++ b/apps/mail/bundle/manifest.json @@ -4,6 +4,11 @@ "version": "1.0.0", "name": "Mail", "integrity": { "bundle_blake3": "" }, - "capabilities": ["storage", "mail"], - "storage": { "accounts": true } + "capabilities": ["storage", "mail", "glance"], + "storage": { "accounts": true }, + "agent": { + "profile": "read-only", + "tools": ["ask_user_question"], + "model": { "needs": ["tool_calling"] } + } } diff --git a/apps/mail/bundle/tools.json b/apps/mail/bundle/tools.json new file mode 100644 index 00000000..e1f4905c --- /dev/null +++ b/apps/mail/bundle/tools.json @@ -0,0 +1,60 @@ +{ + "schema": 1, + "tools": [ + { + "name": "mail.notify", + "description": "Tell the person something now: put a notice card from Mail on the glance screen and show a notification. Give a short title (at most 80 characters) and the text (at most 600 characters); Mail draws the card. Use it when the person or the system agent asks Mail to notify them, or when a message needs the person's attention. The same card_id replaces an earlier notice.", + "input_schema": { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "body": { + "type": "string", + "minLength": 1, + "maxLength": 600 + }, + "card_id": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[A-Za-z0-9._-]+$" + }, + "priority": { + "type": "integer", + "minimum": 0, + "maximum": 100 + } + }, + "required": [ + "title", + "body" + ], + "additionalProperties": false + }, + "output_schema": { + "type": "object", + "properties": { + "card_id": { + "type": "string" + }, + "replaced": { + "type": "boolean" + }, + "expires_at": { + "type": "integer" + } + }, + "required": [ + "card_id" + ] + }, + "risk": "act", + "background": true, + "implemented_by": "host-service" + } + ] +} diff --git a/apps/mail/host-service/resources/notice.card b/apps/mail/host-service/resources/notice.card new file mode 100644 index 00000000..a2fa4026 --- /dev/null +++ b/apps/mail/host-service/resources/notice.card @@ -0,0 +1,23 @@ +# ledger mail.notice@0.1.0 +# level: L0 +# profile: ui/l0 +# +# Mail's notice card (`mail.notify`): a title and a short text Mail's agent +# wrote, as `os.mail`. `note` is a `sys.dataset` record the service fills +# from the call's arguments; the card states nothing else. + +source note sys.dataset(fields: [title, summary, as_of]) + +copy app { class: vocabulary, en: "Mail" } + +view root Surface(pad: .tight) { + Col(gap: 8) { + Row(gap: 8, align: .center) { + Icon(name: .mail, size: .row) + TextCaption(text: copy.app, width: .fill) + TextCaption(value: note.as_of) + } + TextTitle(text: note.title, width: .fill) + TextBody(text: note.summary, width: .fill) + } +} diff --git a/apps/mail/host-service/src/lib.rs b/apps/mail/host-service/src/lib.rs index 20d60795..caac5ecd 100644 --- a/apps/mail/host-service/src/lib.rs +++ b/apps/mail/host-service/src/lib.rs @@ -14,6 +14,7 @@ //! | `mail.message` | `{account, folder?, message}` | `{id, sender, address, subject, body, html, attachments, date, time}` | //! | `mail.mark_read` | `{account, folder?, message}` | `{}` | //! | `mail.send` | `{account, to, subject, body}` | `{accepted}` | +//! | `mail.notify` | `{title, body, card_id?, priority?}` | `{card_id, replaced, expires_at}` once a notice card is on the glance screen, with a notification ([`on_publish_card`]) | //! //! The app never sees a password or a socket. `mail.add_account` raises the //! host's sign-in sheet, a separate isolate over the app; only calls from @@ -84,6 +85,51 @@ fn account_event(event: AccountEvent) { } } +/// Publishes one glance card as the calling app: `(app id, glance.publish +/// arguments)`. The shell installs it once at startup; it decides whether +/// the app may publish (the `glance` grant). +pub type CardPublisher = Arc Result + Send + Sync>; + +fn card_publisher() -> &'static Mutex> { + static PUBLISHER: std::sync::OnceLock>> = std::sync::OnceLock::new(); + PUBLISHER.get_or_init(Default::default) +} + +/// Install (or with `None` remove) what `mail.notify` publishes through. +pub fn on_publish_card(publisher: Option) { + *card_publisher().lock().unwrap_or_else(|e| e.into_inner()) = publisher; +} + +/// Mail's notice card (L0): a title and a short text, nothing else. +pub const NOTICE_CARD: &str = include_str!("../resources/notice.card"); +const NOTICE_TITLE_MAX: usize = 80; +const NOTICE_BODY_MAX: usize = 600; + +/// `mail.notify`'s `glance.publish` arguments: the notice card filled from +/// the call, opening Mail, with a notification. The card's text is the +/// caller's; the card itself is fixed, so a model never writes card code. +pub fn notice_publish_args(args: &Value, now_ms: u128) -> Result { + let title = text(args, "title").trim(); + let body = text(args, "body").trim(); + if title.is_empty() || title.chars().count() > NOTICE_TITLE_MAX { + return Err(format!("Provide a title of 1 to {NOTICE_TITLE_MAX} characters.")); + } + if body.is_empty() || body.chars().count() > NOTICE_BODY_MAX { + return Err(format!("Provide a body of 1 to {NOTICE_BODY_MAX} characters.")); + } + let card_id = match text(args, "card_id") { + "" => format!("notice-{now_ms:x}"), + id => id.to_string(), + }; + let priority = args["priority"].as_i64().unwrap_or(60).clamp(0, 100); + let as_of = chrono::Local::now().format("%H:%M").to_string(); + Ok(json!({ + "card_id": card_id, "title": format!("Mail · {title}"), "source": NOTICE_CARD, + "data": {"note": {"title": title, "summary": body, "as_of": as_of}}, + "priority": priority, "open": {"app": "mail"}, "notify": true, + })) +} + /// How mail moves: IMAP or POP3 and SMTP in the shell, or a fake in tests. pub trait Transport: Send + Sync { /// Sign in and out again: the account works. @@ -691,6 +737,14 @@ impl HostService for MailService { reply.send(sent) }); } + "notify" => { + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0); + let publisher = card_publisher().lock().unwrap_or_else(|e| e.into_inner()).clone(); + let Some(publisher) = publisher else { + return reply.send(Err("This device shows no glance cards.".into())); + }; + reply.send(notice_publish_args(&call.args, now).and_then(|args| publisher(&call.app_id, args))); + } other => reply.send(Err(format!("mail has no method {other:?}"))), } } @@ -895,6 +949,26 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// `mail.notify` fills the fixed notice card from the call: the text is + /// the caller's, the card opens Mail and notifies, and a blank or long + /// title or body is refused before anything is published. + #[test] + fn a_notice_is_the_fixed_card_filled_with_the_callers_text() { + let args = notice_publish_args(&json!({"title": "Hello", "body": "From the system agent", "card_id": "hello"}), 1).unwrap(); + assert_eq!(args["card_id"], "hello"); + assert_eq!(args["title"], "Mail · Hello"); + assert_eq!(args["source"], NOTICE_CARD); + assert_eq!(args["data"]["note"]["title"], "Hello"); + assert_eq!(args["data"]["note"]["summary"], "From the system agent"); + assert_eq!((args["open"]["app"].as_str(), args["notify"].as_bool()), (Some("mail"), Some(true))); + assert_eq!(args["priority"], 60); + let generated = notice_publish_args(&json!({"title": "Hi", "body": "x", "priority": 500}), 0xabc).unwrap(); + assert_eq!((generated["card_id"].as_str(), generated["priority"].as_i64()), (Some("notice-abc"), Some(100))); + assert!(notice_publish_args(&json!({"title": " ", "body": "x"}), 0).is_err()); + assert!(notice_publish_args(&json!({"title": "x".repeat(81), "body": "x"}), 0).is_err()); + assert!(notice_publish_args(&json!({"title": "x", "body": "y".repeat(601)}), 0).is_err()); + } + #[test] fn an_app_signs_in_on_the_hosts_sheet_and_reads_and_sends_without_the_password() { let dir = std::env::temp_dir().join(format!("mail-service-{}", std::process::id())); diff --git a/crates/shell/src/apps.rs b/crates/shell/src/apps.rs index 2c439802..6d87215d 100644 --- a/crates/shell/src/apps.rs +++ b/crates/shell/src/apps.rs @@ -169,6 +169,12 @@ fn register_host_services() { } else { octosense_mail_service::register() } + // `mail.notify` (Mail's agent's tool): a notice card published as + // the calling app, only when its manifest was granted `glance`. + octosense_mail_service::on_publish_card(Some(std::sync::Arc::new(|app: &str, args| { + let caller = crate::glance::Caller::Contained { app: app.to_string(), granted: crate::host_tools::script_apps::grants(app, "glance") }; + crate::glance::request(&caller, "glance.publish", &args) + }))); register_news(); }); } diff --git a/crates/shell/src/glance.rs b/crates/shell/src/glance.rs index 33a0d339..f7c0508f 100644 --- a/crates/shell/src/glance.rs +++ b/crates/shell/src/glance.rs @@ -650,6 +650,21 @@ mod tests { assert_eq!(store.len(), 1); } + /// Mail's `mail.notify` card is a valid L0 card the store admits as + /// Mail's, and a Mail without the `glance` grant cannot publish it. + #[cfg(any(feature = "app-hub", native_mobile))] + #[test] + fn mails_notice_card_is_admitted_as_mails_own() { + let mut store = GlanceStore::default(); + let args = octosense_mail_service::notice_publish_args(&json!({"title": "Hello", "body": "From the system agent", "card_id": "hello"}), 1).unwrap(); + let ok = store.publish(&Caller::granted("os.mail"), &args, 1_000).unwrap(); + assert_eq!(ok["card_id"], "hello"); + let shown = store.shown(1_000, SHOWN_CARDS); + assert_eq!((shown[0].app.as_str(), shown[0].open_app.as_str()), ("os.mail", "mail")); + let ungranted = Caller::Contained { app: "os.mail".into(), granted: false }; + assert!(store.publish(&ungranted, &args, 1_000).is_err()); + } + #[test] fn a_contained_app_needs_the_glance_capability_whoever_it_is() { let mut store = GlanceStore::default(); diff --git a/crates/shell/src/host_tools/script_apps.rs b/crates/shell/src/host_tools/script_apps.rs index e5ffbc1e..af06f71d 100644 --- a/crates/shell/src/host_tools/script_apps.rs +++ b/crates/shell/src/host_tools/script_apps.rs @@ -132,6 +132,15 @@ pub fn install(app: &str, loaded: Loaded, host_dir: PathBuf) { /// Load `app`'s agent block from App Hub: a system app's packed bundle, or /// an installed one. pub fn load(app: &str) -> Result<(), String> { + let (root, bundle) = admitted_bundle(app)?; + let loaded = from_bundle(&bundle)?; + install(app, loaded, root.join(".host")); + Ok(()) +} + +/// `app`'s admitted bundle: a system app's packed bundle, or an installed +/// one, with App Hub's apps root. +fn admitted_bundle(app: &str) -> Result<(PathBuf, PathBuf), String> { // Never a native app's tools, executor or grants (ADR 0004 §3, §7). crate::apps::check_script_app_id(app)?; let root = octosense_appstore::data_root_if_set().ok_or("App Hub has no apps root yet")?; @@ -139,9 +148,14 @@ pub fn load(app: &str) -> Result<(), String> { Some(system) => octosense_appstore::system::prepare(&root, &system)?.0, None => root.join(app).join("bundle"), }; - let loaded = from_bundle(&bundle)?; - install(app, loaded, root.join(".host")); - Ok(()) + Ok((root, bundle)) +} + +/// Whether `app`'s admitted manifest was granted the capability `family`: +/// for a host service acting for the app outside its isolate (a tool call), +/// where the Card runner's gate does not run. +pub fn grants(app: &str, family: &str) -> bool { + admitted_bundle(app).and_then(|(_, bundle)| from_bundle(&bundle)).is_ok_and(|loaded| loaded.families.contains(family)) } // ------------------------------------------------------------ the executor @@ -282,6 +296,23 @@ pub(crate) mod tests { let _ = std::fs::remove_dir_all(dir); } + /// Mail's bundle gives its agent `mail.notify` on the `mail` host + /// service, and Mail is granted `glance` (what `mail.notify` publishes + /// under). octos takes a tool only with object schemas. + #[test] + fn mail_offers_its_tools_and_notify_from_its_bundle() { + let dir = stamped_bundle("mail", "tools", |_, _| {}); + let loaded = from_bundle(&dir).unwrap(); + let names: Vec<&str> = loaded.tools.iter().filter_map(|t| t["name"].as_str()).collect(); + assert_eq!(names, ["mail.notify"]); + assert_eq!(loaded.host_service_tools.len(), 1); + assert!(loaded.tools.iter().all(|t| t["input_schema"]["type"] == "object" && t["output_schema"]["type"] == "object")); + assert!(loaded.tools.iter().all(|t| t["shareable"] == false), "Mail's tools are its own agent's"); + assert!(["mail", "glance"].iter().all(|f| loaded.families.contains(*f))); + assert_eq!(loaded.generic, ["ask_user_question"]); + let _ = std::fs::remove_dir_all(dir); + } + /// The manifest's `agent.tools`: a plain name is a kernel tool, and App /// Hub admits only `ask_user_question` (any other, `web_search` or /// octos's shell, refuses the bundle); dotted ones are other apps' From 79cc10f93ad32127a66518a624bb9168e0499762 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:46:02 -0700 Subject: [PATCH 02/11] fix(shell): a toast takes its click over the panes; the assistant pane moves and sizes A toast is drawn over the card window, the glance panel, the chat panes and the windows, but the panes took the pointer first: a click on a toast over the system chat pressed the pane's button beneath it (New conversation) and never opened the card. A press on a toast now goes to the toast alone, after the modal sheets and menus only. On a desktop the system chat and "Ask " panes can be placed: the header moves the pane, an edge or a corner sizes it (with resize cursors), never below 320 x 240 or off screen; a double-click on the header puts it back in its column. The pane also keeps to the window: its surface starts under the bar yet is the window's height, so its bottom edge and padding hung past the window. Verified in a hidden --remote run with the fake Mail toasts: a click on the UPS toast where it covers New conversation opened the UPS card; the header, right and bottom edges moved and sized the pane; a double-click reset it. Co-Authored-By: Claude Opus 5.5 --- crates/shell/src/lib.rs | 29 ++- crates/shell/src/shell/notifications.rs | 6 + crates/shell/src/system_chat/view.rs | 250 +++++++++++++++++++++++- 3 files changed, 277 insertions(+), 8 deletions(-) diff --git a/crates/shell/src/lib.rs b/crates/shell/src/lib.rs index b135bbad..caa3179a 100644 --- a/crates/shell/src/lib.rs +++ b/crates/shell/src/lib.rs @@ -3863,6 +3863,16 @@ impl App { true } + /// A press on a toast (shell/notifications.rs `hit`). Only the press: a + /// drag of a pane's frame keeps its moves and its release wherever they + /// go. + fn press_on_toast(&mut self, cx: &mut Cx, event: &Event) -> bool { + let Event::MouseDown(e) = event else { return false }; + let notes = self.ui.widget(cx, ids!(shell_notes)); + let on = notes.borrow::().is_some_and(|n| n.hit(e.abs)); + on + } + /// The system chat's pane owns the pointer inside its rect while open. fn system_chat_pointer(&mut self, cx: &mut Cx, event: &Event) -> bool { if !system_chat::is_open() { @@ -6131,8 +6141,23 @@ impl App { ) && (self.dev_banner_pointer(cx, event) || self.shell_menu_pointer(cx, event) - || self.shell_panel_pointer(cx, event) - || self.glance_sheet_pointer(cx, event) + || self.shell_panel_pointer(cx, event)) + { + return; + } + // A toast is drawn over the card window, the glance panel, the chat + // panes and the windows: a press on it is the toast's alone (it + // opens its card), whatever lies under it. + if self.state.is_some() && self.press_on_toast(cx, event) { + self.ui.widget(cx, ids!(shell_notes)).handle_event(cx, event, &mut Scope::empty()); + return; + } + if self.state.is_some() + && matches!( + event, + Event::TouchUpdate(_) | Event::MouseMove(_) | Event::MouseDown(_) | Event::MouseUp(_) | Event::Scroll(_) + ) + && (self.glance_sheet_pointer(cx, event) || self.shell_glance_pointer(cx, event) || self.app_chat_pointer(cx, event) || self.system_chat_pointer(cx, event)) diff --git a/crates/shell/src/shell/notifications.rs b/crates/shell/src/shell/notifications.rs index d9383379..81efc552 100644 --- a/crates/shell/src/shell/notifications.rs +++ b/crates/shell/src/shell/notifications.rs @@ -212,6 +212,12 @@ impl ShellNotifications { self.live.is_empty() } + /// Whether `p` is on a toast, its card or its close button: a press + /// there is the toast's, whatever surface lies under it. + pub fn hit(&self, p: Vec2d) -> bool { + !self.inert && self.card_rects.iter().any(|(_, card, close)| contains(*card, p) || contains(*close, p)) + } + /// The height a card needs for its text. fn card_height(&mut self, cx: &mut Cx2d, note: &Notification) -> f64 { let tok = self.d.tokens(self.tokens); diff --git a/crates/shell/src/system_chat/view.rs b/crates/shell/src/system_chat/view.rs index 235f10d1..2af06d39 100644 --- a/crates/shell/src/system_chat/view.rs +++ b/crates/shell/src/system_chat/view.rs @@ -12,6 +12,11 @@ //! ([`ShellSystemChat::pointer`]), the keyboard (`super::key`) and text //! input (`super::text_input`, [`ShellSystemChat::ime`]). //! +//! - **Placing it** (a desktop, not the full-screen surface): the header +//! (title and status) moves the pane, an edge or a corner sizes it, each +//! with its cursor; a double-click on the header puts it back in its +//! column. The placement holds while the shell runs and stays on screen +//! when the window shrinks ([`placed_pane`], [`dragged`]). //! - **Scrolling**: the wheel, and a touch drag with its fling //! ([`TouchScroll`]): a phone sends no scroll events for a drag. A drag //! that starts on a button scrolls and presses nothing. @@ -57,6 +62,121 @@ pub const PANE_W: f64 = 440.0; pub const FULL_SCREEN_BELOW: f64 = 720.0; const PAD: f64 = 16.0; const FIELD_H: f64 = 36.0; +/// The smallest the person may size the desktop pane. +pub const MIN_W: f64 = 320.0; +pub const MIN_H: f64 = 240.0; +/// The resize band along the pane's edges, and its corners' reach. +const EDGE: f64 = 6.0; +const CORNER: f64 = 14.0; +/// The header the pane is moved by: the padding, the title and the status. +const HEADER_H: f64 = PAD + 30.0 + 22.0; +/// Two presses on the header this close (seconds) put the pane back. +const DOUBLE_PRESS_S: f64 = 0.4; + +/// What a press on the desktop pane's frame drags: the header moves it; an +/// edge or a corner (by compass point) sizes it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Grip { + Move, + N, + S, + E, + W, + NE, + NW, + SE, + SW, +} + +impl Grip { + fn cursor(self) -> MouseCursor { + match self { + Grip::Move => MouseCursor::Move, + Grip::N | Grip::S => MouseCursor::NsResize, + Grip::E | Grip::W => MouseCursor::EwResize, + Grip::NE | Grip::SW => MouseCursor::NeswResize, + Grip::NW | Grip::SE => MouseCursor::NwseResize, + } + } +} + +/// `v` within `lo..=hi`, `lo` winning when the range is empty (a screen +/// smaller than the pane's minimum). +fn between(v: f64, lo: f64, hi: f64) -> f64 { + v.min(hi).max(lo) +} + +/// The part of the surface `r` inside the window `pass` (its size; zero +/// when unknown): the surface starts under the bar yet is the window's +/// height, so its bottom would hang past the window, and with it the +/// pane's bottom edge and padding. +pub fn visible(r: Rect, pass: Vec2d) -> Rect { + if pass.x <= 0.0 || pass.y <= 0.0 { + return r; + } + rect(r.pos.x, r.pos.y, r.size.x.min((pass.x - r.pos.x).max(0.0)), r.size.y.min((pass.y - r.pos.y).max(0.0))) +} + +/// A placement kept on `screen`: no smaller than the minimum (unless the +/// screen is), no larger than the screen, wholly on it. +pub fn placed_pane(r: Rect, screen: Rect) -> Rect { + let w = between(r.size.x, MIN_W.min(screen.size.x), screen.size.x); + let h = between(r.size.y, MIN_H.min(screen.size.y), screen.size.y); + let x = between(r.pos.x, screen.pos.x, screen.pos.x + screen.size.x - w); + let y = between(r.pos.y, screen.pos.y, screen.pos.y + screen.size.y - h); + rect(x, y, w, h) +} + +/// The pane `from`, dragged by `grip` through `d`: moved, or one or two of +/// its edges moved, never past the minimum size or off `screen`. +pub fn dragged(grip: Grip, from: Rect, d: Vec2d, screen: Rect) -> Rect { + if grip == Grip::Move { + return placed_pane(rect(from.pos.x + d.x, from.pos.y + d.y, from.size.x, from.size.y), screen); + } + let (mut l, mut t) = (from.pos.x, from.pos.y); + let (mut r, mut b) = (l + from.size.x, t + from.size.y); + let (sr, sb) = (screen.pos.x + screen.size.x, screen.pos.y + screen.size.y); + if matches!(grip, Grip::W | Grip::NW | Grip::SW) { + l = between(l + d.x, screen.pos.x, r - MIN_W); + } + if matches!(grip, Grip::E | Grip::NE | Grip::SE) { + r = between(r + d.x, l + MIN_W, sr); + } + if matches!(grip, Grip::N | Grip::NW | Grip::NE) { + t = between(t + d.y, screen.pos.y, b - MIN_H); + } + if matches!(grip, Grip::S | Grip::SW | Grip::SE) { + b = between(b + d.y, t + MIN_H, sb); + } + placed_pane(rect(l, t, r - l, b - t), screen) +} + +/// The desktop pane's frame, as hits: corners, then edges, then the header, +/// so an edge wins over the header it crosses. Pushed after the controls, +/// which win over all of them. +fn grips(pane: Rect) -> Vec<(Rect, Hit)> { + let (x, y, w, h) = (pane.pos.x, pane.pos.y, pane.size.x, pane.size.y); + vec![ + (rect(x, y, CORNER, CORNER), Hit::Grip(Grip::NW)), + (rect(x + w - CORNER, y, CORNER, CORNER), Hit::Grip(Grip::NE)), + (rect(x, y + h - CORNER, CORNER, CORNER), Hit::Grip(Grip::SW)), + (rect(x + w - CORNER, y + h - CORNER, CORNER, CORNER), Hit::Grip(Grip::SE)), + (rect(x, y, w, EDGE), Hit::Grip(Grip::N)), + (rect(x, y + h - EDGE, w, EDGE), Hit::Grip(Grip::S)), + (rect(x, y, EDGE, h), Hit::Grip(Grip::W)), + (rect(x + w - EDGE, y, EDGE, h), Hit::Grip(Grip::E)), + (rect(x, y, w, HEADER_H.min(h)), Hit::Grip(Grip::Move)), + ] +} + +/// A drag of the pane's frame under way: by what, from where, and the pane +/// it started from. +#[derive(Clone, Copy, Debug)] +struct PaneDrag { + grip: Grip, + start: Vec2d, + from: Rect, +} #[derive(Clone, Debug, PartialEq)] enum Hit { @@ -70,6 +190,8 @@ enum Hit { OpenProviders, Field, Pane, + /// The desktop pane's frame ([`Grip`]). + Grip(Grip), } /// What a pointer event did. @@ -116,7 +238,7 @@ fn grown(r: Rect) -> Rect { /// Whether a target gets a finger's width of slop: buttons do; the pane, /// the prompt and an answer option (an answer must be meant) do not. fn has_slop(hit: &Hit) -> bool { - !matches!(hit, Hit::Pane | Hit::Field | Hit::Option { .. }) + !matches!(hit, Hit::Pane | Hit::Field | Hit::Option { .. } | Hit::Grip(_)) } /// What a press at `p` hits: a control first (a button's finger's width @@ -297,6 +419,22 @@ pub struct ShellSystemChat { /// hidden-window runs' logs). #[rust] pub shown: Vec, + /// Where the person put the desktop pane (None: its column). + #[rust] + placed: Option, + /// The surface the pane was last drawn on (a drag stays on it). + #[rust] + screen: Rect, + /// The pane's frame being dragged. + #[rust] + drag: Option, + /// The grip under the pointer (its cursor is worn). + #[rust] + grip_hover: Option, + /// When the header was last pressed (a second press soon after puts + /// the pane back). + #[rust] + header_pressed: f64, } /// The conversation a pane shows: the system chat's or an app's. @@ -411,12 +549,39 @@ impl ShellSystemChat { hit_in(&self.hits, p) } + /// The frame's grip at `p`, if a control is not there first. + fn grip_at(&self, p: Vec2d) -> Option { + match self.hit_at(p) { + Some(Hit::Grip(g)) => Some(g), + _ => None, + } + } + /// The shell's pointer hook: the pane's own rect is its own while open. pub fn pointer(&mut self, cx: &mut Cx, event: &Event) -> Outcome { let source = self.source(); if !source.is_open() || self.pane.size.x <= 0.0 { + self.drag = None; return Outcome::Ignored; } + // A drag of the frame has the pointer until the button comes up, + // wherever it goes. + if let Some(drag) = self.drag { + match event { + Event::MouseMove(e) => { + self.placed = Some(dragged(drag.grip, drag.from, e.abs - drag.start, self.screen)); + cx.set_cursor(drag.grip.cursor()); + self.redraw(cx); + return Outcome::Taken; + } + Event::MouseUp(_) => { + self.drag = None; + return Outcome::Taken; + } + Event::MouseDown(_) => return Outcome::Taken, + _ => {} + } + } match event { Event::Scroll(e) if contains(self.pane, e.abs) => { self.touch.cancel_fling(); @@ -425,14 +590,39 @@ impl ShellSystemChat { return Outcome::Taken; } Event::MouseMove(e) => { - let hover = self.hits.iter().find(|(r, h)| *h != Hit::Pane && contains(*r, e.abs)).map(|(r, _)| *r); + let hover = self.hits.iter().find(|(r, h)| *h != Hit::Pane && !matches!(h, Hit::Grip(_)) && contains(*r, e.abs)).map(|(r, _)| *r); if hover != self.hover { self.hover = hover; self.redraw(cx); } + let grip = self.grip_at(e.abs); + if let Some(g) = grip { + cx.set_cursor(g.cursor()); + } else if self.grip_hover.is_some() { + cx.set_cursor(MouseCursor::Default); + } + self.grip_hover = grip; return if contains(self.pane, e.abs) { Outcome::Taken } else { Outcome::Ignored }; } - Event::MouseDown(e) => return self.press(cx, source, e.abs), + Event::MouseDown(e) => { + if let Some(grip) = self.grip_at(e.abs) { + if grip == Grip::Move && e.time - self.header_pressed < DOUBLE_PRESS_S { + // A double-click on the header: back in its column. + self.placed = None; + self.header_pressed = 0.0; + self.redraw(cx); + return Outcome::Taken; + } + if grip == Grip::Move { + self.header_pressed = e.time; + } + self.down = None; + self.drag = Some(PaneDrag { grip, start: e.abs, from: self.pane }); + cx.set_cursor(grip.cursor()); + return Outcome::Taken; + } + return self.press(cx, source, e.abs); + } Event::MouseUp(e) => return self.release(cx, source, e.abs), Event::TouchUpdate(e) => { use makepad_widgets::makepad_platform::event::TouchState; @@ -674,8 +864,14 @@ impl ShellSystemChat { } let tok = self.d.tokens(self.tokens); let full = screen.size.x < FULL_SCREEN_BELOW; + self.screen = screen; let pane = if full { screen + } else if let Some(placed) = self.placed { + // Where the person put it, kept on screen. + let kept = placed_pane(placed, screen); + self.placed = Some(kept); + kept } else { let gap = tok.spacing.gaps_out; // "Ask " stands left of the system chat when both are open. @@ -814,6 +1010,10 @@ impl ShellSystemChat { } shown.extend(lines.iter().map(|l| l.text.clone())); self.hits.extend(hits); + // The frame last: the controls win over it. + if !full { + self.hits.extend(grips(pane)); + } self.shown = shown; } } @@ -827,7 +1027,7 @@ fn act(source: Source, hit: Hit) -> Outcome { Hit::Stop => crate::app_chat::stop(), Hit::StopSystemAgent => crate::app_chat::stop_system_agent(), Hit::Option { question, count, label } => crate::app_chat::answer_option(&question, count, &label), - Hit::New | Hit::OpenProviders | Hit::Field | Hit::Pane => {} + Hit::New | Hit::OpenProviders | Hit::Field | Hit::Pane | Hit::Grip(_) => {} } return Outcome::Taken; } @@ -839,7 +1039,7 @@ fn act(source: Source, hit: Hit) -> Outcome { Hit::StopSystemAgent => {} Hit::Option { question, count, label } => super::answer_option(&question, count, &label), Hit::OpenProviders => return Outcome::OpenProviders, - Hit::Field | Hit::Pane => {} + Hit::Field | Hit::Pane | Hit::Grip(_) => {} } Outcome::Taken } @@ -847,7 +1047,7 @@ fn act(source: Source, hit: Hit) -> Outcome { impl Widget for ShellSystemChat { fn draw_walk(&mut self, cx: &mut Cx2d, _scope: &mut Scope, walk: Walk) -> DrawStep { cx.begin_turtle(walk, self.layout); - let screen = cx.turtle().rect(); + let screen = visible(cx.turtle().rect(), cx.current_pass_size()); self.d.begin_surface(cx); self.draw_pane(cx, screen); self.d.end_surface(cx); @@ -924,6 +1124,44 @@ mod tests { assert!(!lifted_on(&option, yes, dvec2(40.0, 682.0)), "an answer is exact"); } + /// The desktop pane's frame: the header's buttons win over the header + /// they sit in, an edge wins over the header it crosses, and the inside + /// is still the pane's. + #[test] + fn the_frame_moves_and_sizes_the_pane_but_never_takes_a_button() { + let pane = rect(100.0, 50.0, 440.0, 700.0); + let close = rect(470.0, 66.0, 54.0, 28.0); + let mut hits = vec![(pane, Hit::Pane), (close, Hit::Close)]; + hits.extend(grips(pane)); + assert_eq!(hit_in(&hits, dvec2(490.0, 80.0)), Some(Hit::Close), "a button in the header is a button"); + assert_eq!(hit_in(&hits, dvec2(250.0, 80.0)), Some(Hit::Grip(Grip::Move)), "the header moves it"); + assert_eq!(hit_in(&hits, dvec2(250.0, 52.0)), Some(Hit::Grip(Grip::N)), "its top edge sizes it"); + assert_eq!(hit_in(&hits, dvec2(102.0, 400.0)), Some(Hit::Grip(Grip::W))); + assert_eq!(hit_in(&hits, dvec2(537.0, 747.0)), Some(Hit::Grip(Grip::SE))); + assert_eq!(hit_in(&hits, dvec2(300.0, 400.0)), Some(Hit::Pane), "the transcript stays the pane's"); + } + + /// Moving keeps the pane on screen; sizing moves only the dragged edges, + /// never below the minimum; a placement shrinks with the window. + #[test] + fn a_dragged_pane_stays_on_screen_and_above_its_minimum() { + let screen = rect(0.0, 30.0, 1400.0, 860.0); + let pane = rect(944.0, 40.0, 440.0, 840.0); + assert_eq!(dragged(Grip::Move, pane, dvec2(-300.0, 0.0), screen), rect(644.0, 40.0, 440.0, 840.0)); + assert_eq!(dragged(Grip::Move, pane, dvec2(500.0, -100.0), screen), rect(960.0, 30.0, 440.0, 840.0), "never off screen"); + assert_eq!(dragged(Grip::W, pane, dvec2(-200.0, 0.0), screen), rect(744.0, 40.0, 640.0, 840.0), "the left edge only"); + assert_eq!(dragged(Grip::W, pane, dvec2(400.0, 0.0), screen), rect(1064.0, 40.0, MIN_W, 840.0), "no narrower than the minimum"); + assert_eq!(dragged(Grip::SE, pane, dvec2(-100.0, -500.0), screen), rect(944.0, 40.0, 340.0, 340.0)); + assert_eq!(dragged(Grip::N, pane, dvec2(0.0, 700.0), screen), rect(944.0, 640.0, 440.0, MIN_H)); + let small = rect(0.0, 0.0, 800.0, 500.0); + assert_eq!(placed_pane(rect(600.0, 300.0, 440.0, 840.0), small), rect(360.0, 0.0, 440.0, 500.0), "a window that shrank keeps it whole"); + let tiny = rect(0.0, 0.0, 200.0, 100.0); + assert_eq!(placed_pane(pane, tiny), tiny, "a screen smaller than the minimum: the whole screen"); + // A surface under a 33 px bar, as tall as the window: cut to it. + assert_eq!(visible(rect(0.0, 33.0, 1400.0, 894.0), dvec2(1400.0, 894.0)), rect(0.0, 33.0, 1400.0, 861.0)); + assert_eq!(visible(rect(0.0, 33.0, 1400.0, 894.0), dvec2(0.0, 0.0)), rect(0.0, 33.0, 1400.0, 894.0), "no window size known"); + } + #[test] fn a_touch_drag_scrolls_and_presses_nothing() { let mut t = TouchScroll::default(); From be5962d749e9305a9cbb985b2e75626c4763f806 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:55:10 -0700 Subject: [PATCH 03/11] fix(shell): the assistant pane repaints as it is dragged; its caret blinks The pane's `#[redraw]` field was `draw_bg`, which it never draws, so every repaint it asked for (a drag, a scroll, a hover) repainted nothing: the pane moved only when something else redrew the window. Its own area is now the redraw target. A 30-step header drag on a visible window: about 2 ms from each move to its frame. The prompt's caret blinks (half a second, as makepad's TextInput) while typed text lands in it: its prompt holds the key focus, or nothing does and the shell hands typed text to it (F8). Typing keeps it on; an empty prompt shows it before the placeholder. `ShellDraw::text_field_caret` draws the field with the caret on request; `text_field` is unchanged. The pane also stays above the developer-mode banner, which covered the bottom of the prompt. Co-Authored-By: Claude Opus 5.5 --- crates/shell/src/shell/ui.rs | 44 +++++++++++----- crates/shell/src/system_chat/view.rs | 77 +++++++++++++++++++++++++++- 2 files changed, 106 insertions(+), 15 deletions(-) diff --git a/crates/shell/src/shell/ui.rs b/crates/shell/src/shell/ui.rs index 0e3680ff..1d465259 100644 --- a/crates/shell/src/shell/ui.rs +++ b/crates/shell/src/shell/ui.rs @@ -1617,6 +1617,24 @@ impl ShellDraw { focused: bool, hot: bool, foreground: Vec4f, + ) { + self.text_field_caret(cx, r, tok, text, placeholder, focused, hot, foreground, focused && !text.is_empty()); + } + + /// [`Self::text_field`] with the caret (at the end of the text, at the + /// start of an empty field) drawn only when `caret` says: a blinking + /// caret is drawn every other half second. + pub fn text_field_caret( + &mut self, + cx: &mut Cx2d, + r: Rect, + tok: &ShellTokens, + text: &str, + placeholder: &str, + focused: bool, + hot: bool, + foreground: Vec4f, + caret: bool, ) { let state = if focused { CtrlState::Focus @@ -1645,19 +1663,19 @@ impl ShellDraw { ); } else { self.label_elided(cx, inner, false, px, foreground, HAlign::Left, text); - if focused { - let w = self.measure(cx, false, px, text).min(inner.size.x); - self.solid( - cx, - rect( - inner.pos.x + w + 1.0, - inner.pos.y + (inner.size.y - px * 1.1) * 0.5, - 1.0, - px * 1.1, - ), - foreground, - ); - } + } + if caret { + let w = if text.is_empty() { 0.0 } else { self.measure(cx, false, px, text).min(inner.size.x) }; + self.solid( + cx, + rect( + inner.pos.x + w + 1.0, + inner.pos.y + (inner.size.y - px * 1.1) * 0.5, + 1.0, + px * 1.1, + ), + foreground, + ); } } diff --git a/crates/shell/src/system_chat/view.rs b/crates/shell/src/system_chat/view.rs index 2af06d39..6e438e32 100644 --- a/crates/shell/src/system_chat/view.rs +++ b/crates/shell/src/system_chat/view.rs @@ -17,6 +17,8 @@ //! with its cursor; a double-click on the header puts it back in its //! column. The placement holds while the shell runs and stays on screen //! when the window shrinks ([`placed_pane`], [`dragged`]). +//! - **The caret** blinks at the end of the prompt while the pane holds +//! the keyboard, and stays on while the person types. //! - **Scrolling**: the wheel, and a touch drag with its fling //! ([`TouchScroll`]): a phone sends no scroll events for a drag. A drag //! that starts on a button scrolls and presses nothing. @@ -72,6 +74,8 @@ const CORNER: f64 = 14.0; const HEADER_H: f64 = PAD + 30.0 + 22.0; /// Two presses on the header this close (seconds) put the pane back. const DOUBLE_PRESS_S: f64 = 0.4; +/// The caret's half period (seconds), as makepad's `TextInput` blinks. +const BLINK_S: f64 = 0.5; /// What a press on the desktop pane's frame drags: the header moves it; an /// edge or a corner (by compass point) sizes it. @@ -385,6 +389,9 @@ pub struct ShellSystemChat { /// The "Ask " panel ([`crate::app_chat`]) instead of the system chat. #[live] app_panel: bool, + /// The pane's own area: what `redraw` repaints (`draw_bg` draws + /// nothing, so its area alone would repaint nothing). + #[redraw] #[rust] area: Area, #[rust] @@ -435,6 +442,14 @@ pub struct ShellSystemChat { /// the pane back). #[rust] header_pressed: f64, + /// The caret's blink: its timer, whether it is in its off half, and + /// the prompt it last saw (typing keeps it on). + #[rust] + blink_timer: Timer, + #[rust] + blink_off: bool, + #[rust] + blink_draft: String, } /// The conversation a pane shows: the system chat's or an app's. @@ -746,6 +761,45 @@ impl ShellSystemChat { } /// This is the "Ask " panel. + /// Whether what the person types lands in this prompt now: the pane is + /// open and takes text, and its prompt holds the key focus, or nothing + /// does and the shell hands typed text to this pane (F8 opened it + /// without a press; lib.rs `chat_text_input`). + fn typing(&self, cx: &Cx) -> bool { + if !self.usable || !self.source().is_open() { + return false; + } + if self.has_keyboard(cx) { + return true; + } + let pane = match self.source() { + Source::System => super::composer::Pane::System, + Source::App => super::composer::Pane::App, + }; + cx.key_focus().is_empty() && super::composer::text_target(true, crate::app_chat::is_focused(), super::is_open()) == Some(pane) + } + + /// The caret's blink: on again (and its half period restarted) when the + /// prompt changed, so it never hides while the person types; started + /// when the prompt takes the keyboard; dropped when it lets go. + fn keep_blinking(&mut self, cx: &mut Cx) { + if !self.typing(cx) { + self.blink_off = false; + if !self.blink_timer.is_empty() { + cx.stop_timer(self.blink_timer); + self.blink_timer = Timer::empty(); + } + return; + } + let draft = self.source().draft(); + if draft != self.blink_draft || self.blink_timer.is_empty() { + self.blink_draft = draft; + self.blink_off = false; + cx.stop_timer(self.blink_timer); + self.blink_timer = cx.start_timeout(BLINK_S); + } + } + pub fn is_app_panel(&self) -> bool { self.app_panel } @@ -923,6 +977,7 @@ impl ShellSystemChat { let usable = source.usable(&model); self.usable = usable; let (label, hit, enabled) = composer_button(source.person_running(&model), usable, &draft); + let caret = self.typing(cx) && !self.blink_off; { let mut b = Buttons { d: &mut self.d, tok, hover }; let bw = b.width(cx, label); @@ -932,7 +987,7 @@ impl ShellSystemChat { let field = rect(x, field_y, cw - bw - 8.0, FIELD_H); self.field = field; let placeholder = source.placeholder(model.open_question().is_some()); - b.d.text_field(cx, field, &tok, &draft, &placeholder, true, hover == Some(field), ink); + b.d.text_field_caret(cx, field, &tok, &draft, &placeholder, true, hover == Some(field), ink, caret); hits.push((field, Hit::Field)); } shown.push(format!("prompt: {draft}")); @@ -1046,8 +1101,14 @@ fn act(source: Source, hit: Hit) -> Outcome { impl Widget for ShellSystemChat { fn draw_walk(&mut self, cx: &mut Cx2d, _scope: &mut Scope, walk: Walk) -> DrawStep { + self.keep_blinking(cx); cx.begin_turtle(walk, self.layout); - let screen = visible(cx.turtle().rect(), cx.current_pass_size()); + let mut screen = visible(cx.turtle().rect(), cx.current_pass_size()); + // The developer-mode banner holds the bottom strip while it shows: + // the pane (and its prompt) stays above it. + if crate::dev_mode::status().is_some() { + screen.size.y = (screen.size.y - crate::shell::dev_banner::HEIGHT).max(0.0); + } self.d.begin_surface(cx); self.draw_pane(cx, screen); self.d.end_surface(cx); @@ -1073,6 +1134,18 @@ impl Widget for ShellSystemChat { } fn handle_event(&mut self, cx: &mut Cx, event: &Event, _scope: &mut Scope) { + // The caret's blink, while the prompt holds the keyboard. + if self.blink_timer.is_event(event).is_some() { + self.blink_timer = Timer::empty(); + if self.typing(cx) { + self.blink_off = !self.blink_off; + self.blink_timer = cx.start_timeout(BLINK_S); + } else { + // The keyboard went elsewhere: the caret goes too. + self.blink_off = false; + } + self.redraw(cx); + } // A fling goes on frame by frame. if let Some(ne) = self.fling_frame.is_event(event) { if let Some(dy) = self.touch.fling_step(ne.time) { From 8936c77ec7425c723ad97194d1c11789c50e1b56 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:13:21 -0700 Subject: [PATCH 04/11] feat(shell): the assistant pane renders Markdown, wraps Chinese, takes several lines and follows the theme Replies are Markdown (system_chat/markdown.rs): headings, bold, inline code on a tint, links in the accent, bulleted and numbered lists with hanging indents, quotes with a bar, rules, fenced code and table rows on a tint with their spacing kept. What the person typed is shown as typed. Wrapping (shell/ui.rs `wrap_styled`, used by `ShellDraw::wrap` for every surface) breaks between CJK characters, never before closing punctuation, and inside a word longer than a line (a URL); before, a Chinese sentence was one word, put on one line and cut with an ellipsis. Styled runs wrap together. The prompt takes up to eight lines and then shows its last ones (the caret's); Shift+Return breaks a line, a paste keeps its breaks, Return and an input method's Enter still send. On a desktop the prompt takes the keyboard when the pane opens (F8, the menu), so the macOS input method composes Chinese in it at once with its candidates at the caret: makepad hands keys to the input method only while a text field holds the focus. The chat panes now take the shell's material and palette like its other surfaces, so they are light in the light theme and dark in the dark one. The laid-out transcript is cached until the conversation, the width or the font changes. Checked in a hidden --remote run with deepseek-v4-flash: a two-line Chinese and English prompt wrapped in the prompt; the reply's heading, bold, code, nested list, numbered list, quote, rule, code block and table drew in the light theme. Chinese marked bold draws at the regular weight (the fallback CJK font has no bold face). Co-Authored-By: Claude Opus 5.5 --- crates/shell/src/app_chat/mod.rs | 4 + crates/shell/src/desktop_app.rs | 6 + crates/shell/src/lib.rs | 14 ++ crates/shell/src/shell/ui.rs | 266 +++++++++++++++++--- crates/shell/src/system_chat/composer.rs | 75 +++++- crates/shell/src/system_chat/markdown.rs | 294 +++++++++++++++++++++++ crates/shell/src/system_chat/mod.rs | 6 + crates/shell/src/system_chat/view.rs | 257 ++++++++++++++++++-- 8 files changed, 866 insertions(+), 56 deletions(-) create mode 100644 crates/shell/src/system_chat/markdown.rs diff --git a/crates/shell/src/app_chat/mod.rs b/crates/shell/src/app_chat/mod.rs index eab20482..24c63dc5 100644 --- a/crates/shell/src/app_chat/mod.rs +++ b/crates/shell/src/app_chat/mod.rs @@ -547,6 +547,10 @@ pub fn key(e: &KeyEvent) -> bool { p.ui_generation += 1; } }), + Key::NewLine => with(|p| { + p.draft.newline(); + p.ui_generation += 1; + }), Key::Stop => stop(), Key::Swallow => {} // Function keys (F8: the system chat) go on to the shell. diff --git a/crates/shell/src/desktop_app.rs b/crates/shell/src/desktop_app.rs index dbc310af..1bed0228 100644 --- a/crates/shell/src/desktop_app.rs +++ b/crates/shell/src/desktop_app.rs @@ -63,6 +63,12 @@ impl App { if let Some(mut w) = self.ui.widget(cx, ids!(shell_glance_sheet)).borrow_mut::() { w.set_material(material, palette); } + // The chat panes: the system chat and "Ask ". + for pane in [ids!(shell_system_chat), ids!(shell_app_chat)] { + if let Some(mut w) = self.ui.widget(cx, pane).borrow_mut::() { + w.set_material(material, palette); + } + } if let Some(mut w) = self.ui.widget(cx, ids!(shell_ai_pane)).borrow_mut::() { w.set_material(material); } diff --git a/crates/shell/src/lib.rs b/crates/shell/src/lib.rs index caa3179a..bf348428 100644 --- a/crates/shell/src/lib.rs +++ b/crates/shell/src/lib.rs @@ -3552,6 +3552,7 @@ impl App { if target == shell::menu::SYSTEM_CHAT_ROW { self.close_shell_menu(cx); system_chat::open(); + self.focus_system_chat(cx); self.system_chat_changed(cx); return; } @@ -3863,6 +3864,18 @@ impl App { true } + /// The system chat just opened on a desktop: its prompt takes the + /// keyboard, so an input method composes in it at once (a phone opens + /// it with a tap, which gives the keyboard itself). + fn focus_system_chat(&mut self, cx: &mut Cx) { + if !system_chat::is_open() || self.state.is_none() || self.state_mut().style.target.mobile() { + return; + } + if let Some(mut pane) = self.ui.widget(cx, ids!(shell_system_chat)).borrow_mut::() { + pane.focus_prompt(cx); + } + } + /// A press on a toast (shell/notifications.rs `hit`). Only the press: a /// drag of a pane's frame keeps its moves and its release wherever they /// go. @@ -6298,6 +6311,7 @@ impl App { system_chat::toggle(); // The system chat takes the keyboard when it opens. app_chat::focus(!system_chat::is_open()); + self.focus_system_chat(cx); self.system_chat_changed(cx); return; } diff --git a/crates/shell/src/shell/ui.rs b/crates/shell/src/shell/ui.rs index 1d465259..51dea31d 100644 --- a/crates/shell/src/shell/ui.rs +++ b/crates/shell/src/shell/ui.rs @@ -1000,41 +1000,12 @@ impl ShellDraw { max_w: f64, max_lines: usize, ) -> Vec { - let mut lines: Vec = Vec::new(); - for para in text.split('\n') { - let mut line = String::new(); - for word in para.split_whitespace() { - let candidate = if line.is_empty() { - word.to_string() - } else { - format!("{} {}", line, word) - }; - if self.measure(cx, bold, px, &candidate) <= max_w || line.is_empty() { - line = candidate; - } else { - lines.push(std::mem::take(&mut line)); - line = word.to_string(); - if lines.len() == max_lines { - break; - } - } - } - if !line.is_empty() && lines.len() < max_lines { - lines.push(line); - } - if lines.len() >= max_lines { - break; - } - } + let mut lines = self.wrap_lines(cx, bold, px, text, max_w, false); // Anything that did not fit is elided onto the last line. - let overflowed = { - let joined = lines.join(" "); - joined.split_whitespace().count() < text.split_whitespace().count() - }; - if overflowed { + if lines.len() > max_lines { + lines.truncate(max_lines); if let Some(last) = lines.last_mut() { - let s = format!("{}\u{2026}", last); - *last = s; + last.push('\u{2026}'); } } if lines.is_empty() && !text.is_empty() { @@ -1043,6 +1014,12 @@ impl ShellDraw { lines } + /// Every line of `text` wrapped to `max_w` ([`wrap_with`]), blank lines + /// kept when `keep_blank` says (a prompt being typed shows them). + pub fn wrap_lines(&mut self, cx: &mut Cx2d, bold: bool, px: f64, text: &str, max_w: f64, keep_blank: bool) -> Vec { + wrap_with(text, max_w, keep_blank, |s| self.measure(cx, bold, px, s)) + } + /// One line, placed at an absolute top-left. pub fn text_at( &mut self, @@ -1679,6 +1656,56 @@ impl ShellDraw { } } + /// A text field of several lines: `lines` (wrapped already) from the + /// top, `line_h` apart and centred as a block; the placeholder while + /// there is no text; the caret after the last line when `caret` says. + /// Gives back the caret's rect (the input method's anchor). + pub fn text_area( + &mut self, + cx: &mut Cx2d, + r: Rect, + tok: &ShellTokens, + lines: &[String], + line_h: f64, + placeholder: &str, + focused: bool, + hot: bool, + foreground: Vec4f, + caret: bool, + ) -> Rect { + let state = if focused { + CtrlState::Focus + } else if hot { + CtrlState::Hover + } else { + CtrlState::Normal + }; + self.control(cx, r, &tok.controls, state); + let inner = rect( + r.pos.x + tok.spacing.control_padding_x, + r.pos.y, + (r.size.x - tok.spacing.control_padding_x * 2.0).max(0.0), + r.size.y, + ); + let px = tok.font.body; + let n = lines.len().max(1) as f64; + let top = r.pos.y + (r.size.y - n * line_h) * 0.5; + if lines.iter().all(|l| l.is_empty()) && lines.len() <= 1 { + self.label_elided(cx, rect(inner.pos.x, top, inner.size.x, line_h), false, px, super::darker(foreground, 1.6), HAlign::Left, placeholder); + } else { + for (i, line) in lines.iter().enumerate() { + self.label_elided(cx, rect(inner.pos.x, top + i as f64 * line_h, inner.size.x, line_h), false, px, foreground, HAlign::Left, line); + } + } + let last = lines.last().map(String::as_str).unwrap_or(""); + let w = if last.is_empty() { 0.0 } else { self.measure(cx, false, px, last).min(inner.size.x) }; + let at = rect(inner.pos.x + w + 1.0, top + (n - 1.0) * line_h + (line_h - px * 1.1) * 0.5, 1.0, px * 1.1); + if caret { + self.solid(cx, at, foreground); + } + at + } + /// `Ui/PanelSectionHeader.qml`: bold caption in `darker(fg, 1.4)`. pub fn section_header(&mut self, cx: &mut Cx2d, r: Rect, tok: &ShellTokens, fg: Vec4f, s: &str) { self.label( @@ -1869,3 +1896,176 @@ mod tests { assert!(d.material().is_glass()); } } + +/// Whether a line may break before and after `c` with no space between: the +/// CJK scripts (ideographs, kana, hangul) and their punctuation and +/// full-width forms. +pub fn breaks_anywhere(c: char) -> bool { + matches!(c as u32, + 0x1100..=0x11FF | 0x2E80..=0x2FDF | 0x2FF0..=0x30FF | 0x3100..=0x33FF + | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xA000..=0xA4CF | 0xAC00..=0xD7AF + | 0xF900..=0xFAFF | 0xFE30..=0xFE4F | 0xFF00..=0xFFEF | 0x20000..=0x3FFFF) +} + +/// Punctuation a line must not start with: it stays at the end of the line +/// before, a little past the edge (CJK line breaking's "kinsoku"). +fn closes(c: char) -> bool { + matches!(c, ',' | '。' | '、' | ';' | ':' | '!' | '?' | ')' | '」' | '』' | '】' | '》' | '〉' | '〕' | '”' | '’' | '…' | '.' | '・' | 'ー' | '~' | ',' | '.' | ';' | ':' | '!' | '?' | ')' | ']' | '}') +} + +/// A paragraph's pieces for wrapping: a run of spaces, or a word. A word +/// is a run of other characters, across style runs ("**bold**text" is one +/// word, two segments), or one CJK character, which a line may break +/// around. +#[derive(Debug, PartialEq)] +enum Piece { + Space, + Word(Vec<(String, S)>), +} + +fn pieces(runs: &[(&str, S)]) -> Vec> { + let mut out: Vec> = Vec::new(); + // Whether the last piece is a word that takes more characters. + let mut open = false; + for &(text, style) in runs { + for c in text.chars() { + if c.is_whitespace() { + open = false; + if !matches!(out.last(), Some(Piece::Space)) { + out.push(Piece::Space); + } + } else if breaks_anywhere(c) { + open = false; + out.push(Piece::Word(vec![(c.to_string(), style)])); + } else if let (true, Some(Piece::Word(segments))) = (open, out.last_mut()) { + match segments.last_mut() { + Some((last, s)) if *s == style => last.push(c), + _ => segments.push((c.to_string(), style)), + } + } else { + out.push(Piece::Word(vec![(c.to_string(), style)])); + open = true; + } + } + } + out +} + +/// One paragraph of styled runs wrapped to `max_w` by `measure` (a +/// string's width in a style): lines break at spaces (a run of them is +/// one), between CJK characters, and inside a word longer than a line; +/// never before closing punctuation. Each line is its runs, adjacent ones +/// of one style merged. No lines for an empty paragraph. +pub fn wrap_styled(runs: &[(&str, S)], max_w: f64, mut measure: impl FnMut(&str, S) -> f64) -> Vec> { + fn push(line: &mut Vec<(String, S)>, text: &str, style: S) { + match line.last_mut() { + Some((last, s)) if *s == style => last.push_str(text), + _ => line.push((text.to_string(), style)), + } + } + let mut lines = Vec::new(); + let mut line: Vec<(String, S)> = Vec::new(); + let mut w = 0.0; + // A space is owed before the next word on this line, in this style. + let mut space: Option = None; + let mut last_style: Option = None; + for piece in pieces(runs) { + let segments = match piece { + Piece::Space => { + space = if line.is_empty() { None } else { last_style }; + continue; + } + Piece::Word(segments) => segments, + }; + let ww: f64 = segments.iter().map(|(t, s)| measure(t, *s)).sum(); + let gap = space.map_or(0.0, |s| measure(" ", s)); + let lone_closer = matches!(&segments[..], [(t, _)] if { let mut cs = t.chars(); matches!((cs.next(), cs.next()), (Some(c), None) if closes(c)) }); + if !line.is_empty() && w + gap + ww > max_w && !lone_closer { + lines.push(std::mem::take(&mut line)); + w = 0.0; + } else if let (Some(s), false) = (space, line.is_empty()) { + push(&mut line, " ", s); + w += gap; + } + space = None; + if line.is_empty() && ww > max_w { + // Longer than a line: broken between its characters. + for (text, style) in &segments { + for c in text.chars() { + let mut buf = [0u8; 4]; + let c_str = c.encode_utf8(&mut buf); + let cw = measure(c_str, *style); + if !line.is_empty() && w + cw > max_w && !closes(c) { + lines.push(std::mem::take(&mut line)); + w = 0.0; + } + push(&mut line, c_str, *style); + w += cw; + } + } + } else { + for (text, style) in &segments { + push(&mut line, text, *style); + } + w += ww; + } + last_style = segments.last().map(|(_, s)| *s); + } + if !line.is_empty() { + lines.push(line); + } + lines +} + +/// `text` wrapped to `max_w` by `measure` ([`wrap_styled`], one style): a +/// paragraph per line break; blank paragraphs are lines only when +/// `keep_blank`. +pub fn wrap_with(text: &str, max_w: f64, keep_blank: bool, mut measure: impl FnMut(&str) -> f64) -> Vec { + let mut lines = Vec::new(); + for para in text.split('\n') { + let para = para.trim_end_matches('\r'); + let wrapped = wrap_styled(&[(para, ())], max_w, |s, _| measure(s)); + if wrapped.is_empty() { + if keep_blank { + lines.push(String::new()); + } + continue; + } + lines.extend(wrapped.into_iter().map(|runs| runs.into_iter().map(|(t, _)| t).collect::())); + } + lines +} + +#[cfg(test)] +mod wrap_tests { + use super::*; + + /// A width per character: 1 for Latin, 2 for CJK (as full-width text). + fn width(s: &str) -> f64 { + s.chars().map(|c| if breaks_anywhere(c) { 2.0 } else { 1.0 }).sum() + } + + #[test] + fn latin_text_breaks_at_spaces_and_a_long_word_between_its_letters() { + assert_eq!(wrap_with("the quick brown fox", 10.0, false, width), ["the quick", "brown fox"]); + assert_eq!(wrap_with("a b", 10.0, false, width), ["a b"], "a run of spaces is one"); + assert_eq!(wrap_with("https://example.com/x", 8.0, false, width), ["https://", "example.", "com/x"]); + } + + #[test] + fn chinese_breaks_between_characters_but_never_before_a_closing_mark() { + assert_eq!(wrap_with("你好世界再见", 6.0, false, width), ["你好世", "界再见"]); + // "," would start the second line: it stays on the first. + assert_eq!(wrap_with("你好世,再见", 6.0, false, width), ["你好世,", "再见"]); + // Latin words and CJK mix with no space between them. + assert_eq!(wrap_with("用OctoSense发送", 12.0, false, width), ["用OctoSense", "发送"]); + } + + #[test] + fn paragraphs_are_lines_and_blank_ones_only_when_asked() { + assert_eq!(wrap_with("one\n\ntwo", 10.0, false, width), ["one", "two"]); + assert_eq!(wrap_with("one\n\ntwo", 10.0, true, width), ["one", "", "two"]); + assert_eq!(wrap_with("one\r\ntwo\n", 10.0, true, width), ["one", "two", ""], "a trailing break is a new, empty line"); + } +} + diff --git a/crates/shell/src/system_chat/composer.rs b/crates/shell/src/system_chat/composer.rs index 398f4055..af01c65f 100644 --- a/crates/shell/src/system_chat/composer.rs +++ b/crates/shell/src/system_chat/composer.rs @@ -15,9 +15,11 @@ //! replaces the text. Without the answer the platform drops the edit. //! - **Composition** (a word being composed, `replace_last`) replaces the //! previous preview until it is committed, as the widget does. -//! - **A line break sends**: the prompt is one line, so a newline an input -//! method types (its Enter key) is taken out and asks to send -//! ([`Composer::take_submit`]). +//! - **Lines**: Return sends; Shift+Return breaks the line +//! ([`Composer::newline`]); a paste keeps its line breaks. A line break an +//! input method types (a phone keyboard's Enter) still sends: it is taken +//! out and asks to send ([`Composer::take_submit`]), and the breaks the +//! prompt already held stay. //! //! The caret is always at the end: the pane has no cursor of its own. @@ -30,6 +32,9 @@ pub struct Composer { composition: Option>, /// A line break was typed: send. submit: bool, + /// Shift+Return just broke the line: a platform that also types that + /// break as text input has it dropped once. + key_break: bool, } impl Composer { @@ -65,14 +70,45 @@ impl Composer { CharOffset(chars).to_byte_index(&self.text) } + /// Shift+Return: a line break at the end (the caret's place). + pub fn newline(&mut self) { + self.composition = None; + self.text.push('\n'); + self.key_break = true; + } + /// One text-input event. True when the text changed. pub fn text_input(&mut self, event: &TextInputEvent) -> bool { + let is_break = |c: char| c == '\n' || c == '\r'; + // The break Shift+Return already made, typed again as text. + if std::mem::take(&mut self.key_break) && !event.was_paste && event.full_state_sync.is_none() && !event.input.is_empty() && event.input.chars().all(is_break) { + return false; + } let before = self.text.clone(); self.apply(event); - if self.text.contains(['\n', '\r']) { - self.text.retain(|c| c != '\n' && c != '\r'); - self.composition = None; - self.submit = true; + if event.was_paste { + // A paste keeps its lines, as plain line breaks. + if self.text.contains('\r') { + self.text = self.text.replace("\r\n", "\n").replace('\r', "\n"); + } + } else { + // A typed line break (an input method's Enter) sends: the new + // breaks go, the prompt's own stay. + let breaks = |s: &str| s.chars().filter(|c| is_break(*c)).count(); + let mut typed = breaks(&self.text).saturating_sub(breaks(&before)); + if typed > 0 { + while typed > 0 { + match self.text.rfind(is_break) { + Some(i) => { + self.text.remove(i); + } + None => break, + } + typed -= 1; + } + self.composition = None; + self.submit = true; + } } self.text != before } @@ -175,6 +211,8 @@ pub fn text_target(nothing_focused: bool, app_focused: bool, system_open: bool) pub enum Key { Close, Send, + /// Shift+Return: a line break in the prompt. + NewLine, Backspace, /// Command+N / Control+N. New, @@ -192,6 +230,7 @@ pub fn key(e: &makepad_widgets::KeyEvent) -> Key { match e.key_code { KeyCode::Escape => Key::Close, KeyCode::ReturnKey if !e.modifiers.shift => Key::Send, + KeyCode::ReturnKey if !command_key => Key::NewLine, KeyCode::Backspace => Key::Backspace, KeyCode::KeyN if command_key => Key::New, KeyCode::Period if command_key => Key::Stop, @@ -305,6 +344,28 @@ mod tests { assert!(c.take_submit()); } + /// Shift+Return breaks the line and a paste keeps its breaks; an input + /// method's Enter still sends, and the prompt's own breaks stay. + #[test] + fn shift_return_and_a_paste_keep_their_line_breaks() { + let mut c = Composer::default(); + c.text_input(&typed("first")); + let shifted = KeyEvent { key_code: KeyCode::ReturnKey, modifiers: KeyModifiers { shift: true, ..Default::default() }, ..Default::default() }; + assert_eq!(key(&shifted), Key::NewLine); + c.newline(); + // A platform that types that break as text too: dropped once. + assert!(!c.text_input(&typed("\r"))); + c.text_input(&typed("second")); + assert_eq!(c.text(), "first\nsecond"); + assert!(!c.take_submit()); + c.text_input(&TextInputEvent { input: "\r\nthird\r\nfourth".into(), was_paste: true, ..Default::default() }); + assert_eq!(c.text(), "first\nsecond\nthird\nfourth"); + assert!(!c.take_submit(), "a paste never sends"); + c.text_input(&typed("\n")); + assert_eq!(c.text(), "first\nsecond\nthird\nfourth"); + assert!(c.take_submit()); + } + /// The soft keyboard's Enter (its Send action) sends, in either pane, /// even when no pane holds the key focus at that moment. #[test] diff --git a/crates/shell/src/system_chat/markdown.rs b/crates/shell/src/system_chat/markdown.rs new file mode 100644 index 00000000..829ed006 --- /dev/null +++ b/crates/shell/src/system_chat/markdown.rs @@ -0,0 +1,294 @@ +//! Markdown in an agent's replies, as the chat panes draw it ([`super::view`]): +//! the blocks and inline styles a chat answer uses. Anything else is shown +//! as written. +//! +//! | Block | Drawn as | +//! | --- | --- | +//! | `# Heading` to `######` | bold; larger for the first two levels | +//! | `- item`, `* item`, `+ item`, `1. item` | a bullet or the number, with a hanging indent, nested by indentation | +//! | `> quote` | dimmed, with a bar | +//! | fenced code (```` ``` ```` or `~~~`) | each line on a tint, spaces kept, wrapped between characters | +//! | `---`, `***`, `___` | a rule | +//! | `\| a \| b \|` (a table row) | as a code line, so the columns stay aligned; the `\|---\|` row is dropped | +//! +//! Inline: `**bold**` and `__bold__`; `*italic*` and `_italic_` (the markers +//! dropped: there is no italic face); `` `code` `` (on a tint); +//! `[text](url)` (the text, in the accent colour); `~~struck~~` (the +//! markers dropped). A marker with no closing twin later on the line is +//! text, and a backslash keeps the next marker as text. +//! +//! Each source line is a line: a chat answer's single line breaks are +//! meant, so they are kept rather than joined into one paragraph. + +/// How an inline run is drawn. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Style { + pub bold: bool, + pub code: bool, + pub link: bool, +} + +/// Styled text: runs, adjacent ones of one style merged. +pub type Runs = Vec<(String, Style)>; + +/// One source line of an answer. +#[derive(Clone, Debug, PartialEq)] +pub enum Block { + Para(Runs), + Heading(u8, Runs), + /// A list item: its nesting depth, its marker (a bullet or `3.`). + Item { depth: usize, marker: String, runs: Runs }, + Quote(Runs), + /// A line of a fenced block, or a table row: shown as written. + Code(String), + Rule, + Blank, +} + +/// The blocks of `text`, one per source line (a fence line makes none). +pub fn blocks(text: &str) -> Vec { + let mut out = Vec::new(); + // The fence that opened the code block we are in. + let mut fence: Option<&'static str> = None; + for raw in text.split('\n') { + let line = raw.trim_end_matches('\r'); + let trimmed = line.trim_start(); + if let Some(f) = fence { + if trimmed.starts_with(f) { + fence = None; + } else { + out.push(Block::Code(line.to_string())); + } + continue; + } + if let Some(f) = ["```", "~~~"].into_iter().find(|f| trimmed.starts_with(*f)) { + fence = Some(f); + continue; + } + if trimmed.is_empty() { + out.push(Block::Blank); + } else if is_rule(trimmed) { + out.push(Block::Rule); + } else if let Some((level, rest)) = heading(trimmed) { + out.push(Block::Heading(level, inline(rest))); + } else if trimmed.starts_with('|') && trimmed.trim_end().len() > 1 && trimmed.trim_end().ends_with('|') { + if !is_table_rule(trimmed) { + out.push(Block::Code(trimmed.trim_end().to_string())); + } + } else if let Some(rest) = trimmed.strip_prefix('>') { + out.push(Block::Quote(inline(rest.strip_prefix(' ').unwrap_or(rest)))); + } else if let Some((marker, rest)) = list_item(trimmed) { + let indent: usize = line.chars().take_while(|c| c.is_whitespace()).map(|c| if c == '\t' { 4 } else { 1 }).sum(); + out.push(Block::Item { depth: (indent / 2).min(4), marker, runs: inline(rest) }); + } else { + out.push(Block::Para(inline(trimmed))); + } + } + out +} + +fn is_rule(line: &str) -> bool { + let compact: String = line.chars().filter(|c| !c.is_whitespace()).collect(); + compact.len() >= 3 && ['-', '*', '_'].into_iter().any(|m| compact.chars().all(|c| c == m)) +} + +fn is_table_rule(line: &str) -> bool { + line.chars().all(|c| matches!(c, '|' | '-' | ':' | ' ' | '\t')) +} + +fn heading(line: &str) -> Option<(u8, &str)> { + let hashes = line.chars().take_while(|c| *c == '#').count(); + if !(1..=6).contains(&hashes) { + return None; + } + let rest = &line[hashes..]; + let text = rest.strip_prefix(' ').or_else(|| rest.is_empty().then_some(""))?; + Some((hashes as u8, text.trim_end().trim_end_matches('#').trim_end())) +} + +fn list_item(line: &str) -> Option<(String, &str)> { + for bullet in ["- ", "* ", "+ "] { + if let Some(rest) = line.strip_prefix(bullet) { + return Some(("\u{2022}".to_string(), rest)); + } + } + let digits = line.chars().take_while(|c| c.is_ascii_digit()).count(); + // Up to three digits: a line that starts with a year is a sentence. + if (1..=3).contains(&digits) { + let rest = &line[digits..]; + for close in [". ", ") "] { + if let Some(after) = rest.strip_prefix(close) { + return Some((format!("{}.", &line[..digits]), after)); + } + } + } + None +} + +/// The inline runs of one line. +pub fn inline(text: &str) -> Runs { + let chars: Vec = text.chars().collect(); + let mut out: Runs = Vec::new(); + let mut style = Style::default(); + // The marker that opened bold (`*` or `_`), italic, and whether a strike is open. + let mut bold: Option = None; + let mut italic: Option = None; + let mut struck = false; + let mut i = 0; + let push = |out: &mut Runs, text: &str, style: Style| { + if text.is_empty() { + return; + } + match out.last_mut() { + Some((last, s)) if *s == style => last.push_str(text), + _ => out.push((text.to_string(), style)), + } + }; + let alnum = |c: Option<&char>| c.is_some_and(|c| c.is_alphanumeric()); + let space = |c: Option<&char>| c.map_or(true, |c| c.is_whitespace()); + while i < chars.len() { + let c = chars[i]; + let next = chars.get(i + 1); + let prev = if i > 0 { chars.get(i - 1) } else { None }; + // A backslash keeps the next marker as text. + if c == '\\' && next.is_some_and(|n| "\\`*_[]()~#>|-+.!".contains(*n)) { + push(&mut out, &next.unwrap().to_string(), style); + i += 2; + continue; + } + // `code`: nothing inside is a marker. + if c == '`' { + if let Some(end) = find(&chars, i + 1, &['`']) { + let code: String = chars[i + 1..end].iter().collect(); + push(&mut out, &code, Style { code: true, ..style }); + i = end + 1; + continue; + } + } + // [text](url): the text, as a link. + if c == '[' { + if let Some(close) = find(&chars, i + 1, &[']']) { + if chars.get(close + 1) == Some(&'(') { + if let Some(end) = find(&chars, close + 2, &[')']) { + let label: String = chars[i + 1..close].iter().collect(); + push(&mut out, &label, Style { link: true, ..style }); + i = end + 1; + continue; + } + } + } + } + // **bold** and __bold__. + if (c == '*' || c == '_') && next == Some(&c) { + if bold == Some(c) && !space(prev) { + bold = None; + style.bold = false; + i += 2; + continue; + } + if bold.is_none() && !space(chars.get(i + 2)) && (c == '*' || !alnum(prev)) && find(&chars, i + 2, &[c, c]).is_some() { + bold = Some(c); + style.bold = true; + i += 2; + continue; + } + } + // *italic* and _italic_: the markers go. + if c == '*' || c == '_' { + if italic == Some(c) && !space(prev) && (c == '*' || !alnum(next)) { + italic = None; + i += 1; + continue; + } + if italic.is_none() && !space(next) && (c == '*' || !alnum(prev)) && find_single(&chars, i + 1, c).is_some() { + italic = Some(c); + i += 1; + continue; + } + } + // ~~struck~~: the markers go. + if c == '~' && next == Some(&'~') && (struck || find(&chars, i + 2, &['~', '~']).is_some()) { + struck = !struck; + i += 2; + continue; + } + push(&mut out, &c.to_string(), style); + i += 1; + } + out +} + +/// Where `pattern` next starts at or after `from`. +fn find(chars: &[char], from: usize, pattern: &[char]) -> Option { + (from..chars.len()).find(|&i| chars[i..].starts_with(pattern)) +} + +/// Where a single `marker` (not doubled) next closes an italic run after +/// `from`: preceded by a non-space. +fn find_single(chars: &[char], from: usize, marker: char) -> Option { + (from..chars.len()).find(|&i| { + chars[i] == marker + && chars.get(i + 1) != Some(&marker) + && (i == 0 || chars[i - 1] != marker) + && i > from + && !chars[i - 1].is_whitespace() + && (marker == '*' || !chars.get(i + 1).is_some_and(|c| c.is_alphanumeric())) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn plain(runs: &Runs) -> String { + runs.iter().map(|(t, _)| t.as_str()).collect() + } + + const B: Style = Style { bold: true, code: false, link: false }; + const C: Style = Style { bold: false, code: true, link: false }; + const L: Style = Style { bold: false, code: false, link: true }; + const N: Style = Style { bold: false, code: false, link: false }; + + #[test] + fn inline_markers_style_their_text_and_go() { + assert_eq!(inline("a **bold** b"), vec![("a ".into(), N), ("bold".into(), B), (" b".into(), N)]); + assert_eq!(inline("use `mail.notify` now"), vec![("use ".into(), N), ("mail.notify".into(), C), (" now".into(), N)]); + assert_eq!(inline("see [the docs](https://x.example)"), vec![("see ".into(), N), ("the docs".into(), L)]); + assert_eq!(plain(&inline("an *italic* and _this_ and ~~gone~~")), "an italic and this and gone"); + assert_eq!(inline("**粗体**文字"), vec![("粗体".into(), B), ("文字".into(), N)]); + } + + #[test] + fn markers_without_their_twin_and_inside_words_are_text() { + assert_eq!(plain(&inline("2 * 3 = 6")), "2 * 3 = 6"); + assert_eq!(plain(&inline("a **dangling marker")), "a **dangling marker"); + assert_eq!(plain(&inline("file_name_here.rs")), "file_name_here.rs"); + assert_eq!(plain(&inline(r"a \*literal\* star")), "a *literal* star"); + assert_eq!(plain(&inline("`unclosed code")), "`unclosed code"); + } + + #[test] + fn a_reply_becomes_its_blocks() { + let reply = "# Title\nSome **text**.\n\n- one\n - nested\n2. two\n> quoted\n---\n```rust\nlet x = 1;\n```\n| a | b |\n|---|---|\n| 1 | 2 |"; + let b = blocks(reply); + assert_eq!(b[0], Block::Heading(1, vec![("Title".into(), N)])); + assert_eq!(plain(match &b[1] { Block::Para(r) => r, _ => panic!("{:?}", b[1]) }), "Some text."); + assert_eq!(b[2], Block::Blank); + assert!(matches!(&b[3], Block::Item { depth: 0, marker, .. } if marker == "\u{2022}")); + assert!(matches!(&b[4], Block::Item { depth: 1, .. })); + assert!(matches!(&b[5], Block::Item { marker, .. } if marker == "2.")); + assert_eq!(b[6], Block::Quote(vec![("quoted".into(), N)])); + assert_eq!(b[7], Block::Rule); + assert_eq!(b[8], Block::Code("let x = 1;".into()), "a code line keeps its spaces"); + assert_eq!(b[9], Block::Code("| a | b |".into())); + assert_eq!(b[10], Block::Code("| 1 | 2 |".into()), "the table's rule row is dropped"); + assert_eq!(b.len(), 11); + } + + #[test] + fn hashes_and_bullets_need_their_space() { + assert_eq!(blocks("#hashtag"), vec![Block::Para(vec![("#hashtag".into(), N)])]); + assert_eq!(blocks("*emphasis*"), vec![Block::Para(vec![("emphasis".into(), N)])]); + assert_eq!(blocks("2024. was a year"), vec![Block::Para(vec![("2024. was a year".into(), N)])], "a year is not a list"); + } +} diff --git a/crates/shell/src/system_chat/mod.rs b/crates/shell/src/system_chat/mod.rs index b4329cde..88d0732b 100644 --- a/crates/shell/src/system_chat/mod.rs +++ b/crates/shell/src/system_chat/mod.rs @@ -10,6 +10,7 @@ //! | Setup → Assistant → Command execution: the grant, the person's gesture, restart to apply | [`grants`] | //! | the pane (desktop side panel, phone full screen) | [`view`] | //! | its prompt: text input, the input method, the pane's keys | [`composer`] | +//! | Markdown in the replies, as the pane draws it | [`markdown`] | //! //! **Where it runs.** A thread owns the [`session::Driver`] and its link; //! the UI thread sends it [`session::Command`]s and draws a snapshot of the @@ -38,6 +39,7 @@ pub mod composer; pub mod grants; +pub mod markdown; pub mod model; pub mod session; pub mod view; @@ -469,6 +471,10 @@ pub fn key(e: &KeyEvent) -> bool { c.ui_generation += 1; } }), + composer::Key::NewLine => with(|c| { + c.draft.newline(); + c.ui_generation += 1; + }), composer::Key::New => new_conversation(), composer::Key::Stop => interrupt(), composer::Key::Swallow => {} diff --git a/crates/shell/src/system_chat/view.rs b/crates/shell/src/system_chat/view.rs index 6e438e32..7c0280ce 100644 --- a/crates/shell/src/system_chat/view.rs +++ b/crates/shell/src/system_chat/view.rs @@ -19,6 +19,17 @@ //! when the window shrinks ([`placed_pane`], [`dragged`]). //! - **The caret** blinks at the end of the prompt while the pane holds //! the keyboard, and stays on while the person types. +//! - **The prompt** takes several lines: it grows with what is typed up to +//! [`PROMPT_LINES`], then shows its last lines; Shift+Return breaks a +//! line ([`super::composer`]). On a desktop it takes the keyboard when the +//! pane opens, so an input method (Chinese, Japanese) composes in it at +//! once, its candidates at the caret. +//! - **Replies are Markdown** ([`super::markdown`]): styled runs, lists, +//! quotes, code and rules. Text wraps between CJK characters as well as +//! at spaces. The laid-out transcript is kept until the conversation, +//! the width or the font changes. +//! - **Colours** follow the shell's theme, light or dark, like its other +//! surfaces ([`ShellSystemChat::set_material`]). //! - **Scrolling**: the wheel, and a touch drag with its fling //! ([`TouchScroll`]): a phone sends no scroll events for a drag. A drag //! that starts on a button scrolls and presses nothing. @@ -40,10 +51,12 @@ use makepad_widgets::*; +use super::markdown as md; use super::model::{ApprovalState, ChatModel, Item, Phase, Role, ToolStatus}; use crate::approvals::view::Buttons; -use crate::shell::ui::{contains, rect, DrawShellFill, HAlign, ShellDraw}; -use crate::shell::{alpha, ShellTokens}; +use crate::shell::ui::{contains, rect, wrap_styled, DrawShellFill, HAlign, ShellDraw}; +use crate::shell::{alpha, MaterialTokens, ShellPalette, ShellTokens}; +use std::rc::Rc; script_mod! { use mod.prelude.widgets_internal.* @@ -76,6 +89,12 @@ const HEADER_H: f64 = PAD + 30.0 + 22.0; const DOUBLE_PRESS_S: f64 = 0.4; /// The caret's half period (seconds), as makepad's `TextInput` blinks. const BLINK_S: f64 = 0.5; +/// The most lines the prompt grows to; past them its last lines show. +pub const PROMPT_LINES: usize = 8; +/// A list's nesting step, a quote's and a code line's inset. +const LIST_INDENT: f64 = 16.0; +const QUOTE_INDENT: f64 = 12.0; +const CODE_PAD: f64 = 8.0; /// What a press on the desktop pane's frame drags: the header moves it; an /// edge or a corner (by compass point) sizes it. @@ -361,12 +380,140 @@ impl TouchScroll { /// One drawn line of the transcript. #[derive(Clone, Debug, PartialEq)] struct Line { + /// The line as plain text (what a hidden-window run logs). text: String, + /// Its styled runs (a reply's Markdown), drawn instead of `text`. + runs: md::Runs, bold: bool, small: bool, dim: bool, accent: bool, gap_before: f64, + /// How far right of the transcript's edge it starts (lists, quotes). + indent: f64, + /// A heading's size over the body's; 1 for any other line. + scale: f64, + kind: LineKind, +} + +/// What a transcript line is drawn as, besides its text. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum LineKind { + #[default] + Text, + /// A line of a code block or a table, on a tint. + Code, + /// A quoted line, with a bar to its left. + Quote, + Rule, +} + +impl Line { + fn plain(text: String, bold: bool, small: bool, dim: bool, accent: bool, gap_before: f64) -> Line { + Line { text, runs: Vec::new(), bold, small, dim, accent, gap_before, indent: 0.0, scale: 1.0, kind: LineKind::Text } + } + + fn height(&self, body: f64, small: f64) -> f64 { + let px = if self.small { small } else { body } * self.scale; + self.gap_before + if self.kind == LineKind::Rule { 12.0 } else { px * 1.45 } + } +} + +/// What a laid-out transcript depends on: the conversation's generation, +/// the width, the two font sizes and the text scale. +type TranscriptKey = (u64, f64, f64, f64, f64); + +/// Styled runs wrapped to `width`, each run measured in its weight at `px` +/// (the drawn size). +fn wrap_runs(d: &mut ShellDraw, cx: &mut Cx2d, runs: &md::Runs, width: f64, px: f64, bold: bool) -> Vec { + let runs: Vec<(&str, md::Style)> = runs.iter().map(|(t, s)| (t.as_str(), *s)).collect(); + wrap_styled(&runs, width, |t, s| d.measure(cx, bold || s.bold, px, t)) +} + +/// A code line wrapped to `width` between characters, its spaces kept (a +/// tab as four). +fn wrap_code(d: &mut ShellDraw, cx: &mut Cx2d, text: &str, width: f64, px: f64) -> Vec { + let mut lines = Vec::new(); + let mut line = String::new(); + let mut w = 0.0; + for c in text.chars() { + let piece = if c == '\t' { " ".to_string() } else { c.to_string() }; + let cw = d.measure(cx, false, px, &piece); + if !line.is_empty() && w + cw > width { + lines.push(std::mem::take(&mut line)); + w = 0.0; + } + line.push_str(&piece); + w += cw; + } + if !line.is_empty() { + lines.push(line); + } + lines +} + +/// An agent's reply as transcript lines ([`md::blocks`]): each block +/// wrapped to `width`, the first line `gap` below the speaker. +fn markdown_lines(d: &mut ShellDraw, cx: &mut Cx2d, lines: &mut Vec, text: &str, width: f64, body: f64, gap: f64) { + let ts = d.text_scale(); + let first = lines.len(); + let mut gap_next = gap; + for block in md::blocks(text) { + let base = Line::plain(String::new(), false, false, false, false, 0.0); + let mut out: Vec = Vec::new(); + match block { + md::Block::Blank => { + gap_next = gap_next.max(6.0); + continue; + } + md::Block::Rule => out.push(Line { kind: LineKind::Rule, dim: true, ..base }), + md::Block::Code(code) => { + let wrapped = wrap_code(d, cx, &code, (width - CODE_PAD * 2.0).max(1.0), body * ts); + let wrapped = if wrapped.is_empty() { vec![String::new()] } else { wrapped }; + out.extend(wrapped.into_iter().map(|l| Line { text: l, kind: LineKind::Code, indent: CODE_PAD, ..base.clone() })); + } + md::Block::Heading(level, runs) => { + let scale = match level { + 1 => 1.3, + 2 => 1.15, + _ => 1.0, + }; + if lines.len() > first { + gap_next = gap_next.max(8.0); + } + out.extend(wrap_runs(d, cx, &runs, width, body * scale * ts, true).into_iter().map(|r| Line { runs: r, bold: true, scale, ..base.clone() })); + } + md::Block::Item { depth, marker, runs } => { + let indent = depth as f64 * LIST_INDENT; + let marker = format!("{marker} "); + let mw = d.measure(cx, false, body * ts, &marker); + let wrapped = wrap_runs(d, cx, &runs, (width - indent - mw).max(1.0), body * ts, false); + let wrapped = if wrapped.is_empty() { vec![Vec::new()] } else { wrapped }; + for (i, mut r) in wrapped.into_iter().enumerate() { + let at = if i == 0 { + r.insert(0, (marker.clone(), md::Style::default())); + indent + } else { + indent + mw + }; + out.push(Line { runs: r, indent: at, ..base.clone() }); + } + } + md::Block::Quote(runs) => { + out.extend(wrap_runs(d, cx, &runs, (width - QUOTE_INDENT).max(1.0), body * ts, false).into_iter().map(|r| Line { runs: r, dim: true, indent: QUOTE_INDENT, kind: LineKind::Quote, ..base.clone() })); + } + md::Block::Para(runs) => { + out.extend(wrap_runs(d, cx, &runs, width, body * ts, false).into_iter().map(|r| Line { runs: r, ..base.clone() })); + } + } + for mut line in out { + if !line.runs.is_empty() { + line.text = line.runs.iter().map(|(t, _)| t.as_str()).collect(); + } + line.gap_before = std::mem::take(&mut gap_next); + lines.push(line); + } + } } #[derive(Script, ScriptHook, Widget)] @@ -450,6 +597,9 @@ pub struct ShellSystemChat { blink_off: bool, #[rust] blink_draft: String, + /// The transcript last laid out, and what it was laid out for. + #[rust] + laid_out: Option<(TranscriptKey, Rc>)>, } /// The conversation a pane shows: the system chat's or an app's. @@ -503,6 +653,12 @@ impl Source { Source::App => crate::app_chat::scroll(), } } + fn generation(self) -> u64 { + match self { + Source::System => super::generation(), + Source::App => crate::app_chat::generation(), + } + } fn scroll_by(self, dy: f64, max: f64) { match self { Source::System => super::scroll_by(dy, max), @@ -800,6 +956,23 @@ impl ShellSystemChat { } } + /// The shell's theme (light or dark) and material, as its other + /// surfaces take them (desktop_app.rs `apply_material_to_chrome`). + pub fn set_material(&mut self, m: MaterialTokens, palette: Option) { + self.d.set_material(m); + self.d.set_palette(palette); + self.laid_out = None; + } + + /// The pane just opened on a desktop: its prompt takes the keyboard + /// unless a field already holds it, so typing (and an input method's + /// composing) lands in it at once. + pub fn focus_prompt(&mut self, cx: &mut Cx) { + if self.source().is_open() && cx.key_focus().is_empty() { + self.take_keyboard(cx); + } + } + pub fn is_app_panel(&self) -> bool { self.app_panel } @@ -852,7 +1025,7 @@ impl ShellSystemChat { for para in text.split('\n') { let wrapped = if para.trim().is_empty() { vec![String::new()] } else { d.wrap(cx, false, px, para, width, 400) }; for l in wrapped { - lines.push(Line { text: l, bold: false, small: small_text, dim, accent, gap_before: if first { gap } else { 0.0 } }); + lines.push(Line::plain(l, false, small_text, dim, accent, if first { gap } else { 0.0 })); first = false; } } @@ -865,12 +1038,17 @@ impl ShellSystemChat { (None, Role::User) => "You", (None, Role::Assistant) => self.source().assistant_label(), }; - lines.push(Line { text: who.into(), bold: true, small: true, dim: *role == Role::User, accent: false, gap_before: 12.0 }); + lines.push(Line::plain(who.into(), true, true, *role == Role::User, false, 12.0)); let mut text = text.clone(); if running && i == last && *role == Role::Assistant { text.push('\u{258d}'); } - push_wrapped(&mut self.d, cx, &mut lines, &text, false, false, false, 2.0); + // A reply is Markdown; what the person typed is shown as typed. + if *role == Role::Assistant { + markdown_lines(&mut self.d, cx, &mut lines, &text, width, body, 2.0); + } else { + push_wrapped(&mut self.d, cx, &mut lines, &text, false, false, false, 2.0); + } } Item::Tool { name, status, detail, .. } => { let state = match status { @@ -971,9 +1149,9 @@ impl ShellSystemChat { self.d.separator(cx, rect(x, y, cw, 1.0), ink, 0.12); let top = y + 6.0; - // Composer at the bottom. + // Composer at the bottom: as tall as its lines. let bottom = pane.pos.y + pane.size.y - PAD; - let field_y = bottom - FIELD_H; + let field_y; let usable = source.usable(&model); self.usable = usable; let (label, hit, enabled) = composer_button(source.person_running(&model), usable, &draft); @@ -981,13 +1159,24 @@ impl ShellSystemChat { { let mut b = Buttons { d: &mut self.d, tok, hover }; let bw = b.width(cx, label); - let button = b.draw(cx, x + cw - bw, field_y + (FIELD_H - 28.0) * 0.5, bw, label, enabled); + let field_w = cw - bw - 8.0; + let inner_w = (field_w - tok.spacing.control_padding_x * 2.0).max(1.0); + let ts = b.d.text_scale(); + let mut lines = b.d.wrap_lines(cx, false, tok.font.body * ts, &draft, inner_w, true); + if lines.len() > PROMPT_LINES { + // Past its height the prompt shows its last lines: the caret's. + lines.drain(..lines.len() - PROMPT_LINES); + } + let line_h = (tok.font.body * ts * 1.45).ceil(); + let field_h = FIELD_H.max(lines.len() as f64 * line_h + (FIELD_H - line_h)); + field_y = bottom - field_h; + let button = b.draw(cx, x + cw - bw, bottom - FIELD_H + (FIELD_H - 28.0) * 0.5, bw, label, enabled); hits.push((button, hit)); shown.push(format!("button: {label}")); - let field = rect(x, field_y, cw - bw - 8.0, FIELD_H); - self.field = field; + let field = rect(x, field_y, field_w, field_h); let placeholder = source.placeholder(model.open_question().is_some()); - b.d.text_field_caret(cx, field, &tok, &draft, &placeholder, true, hover == Some(field), ink, caret); + // The input method's candidates go by the caret. + self.field = b.d.text_area(cx, field, &tok, &lines, line_h, &placeholder, true, hover == Some(field), ink, caret); hits.push((field, Hit::Field)); } shown.push(format!("prompt: {draft}")); @@ -1036,9 +1225,18 @@ impl ShellSystemChat { shown.push("Open AI providers".into()); } - // The transcript, newest at the bottom, scrolled back by `scroll`. - let lines = self.transcript(cx, &model, cw, &tok); - let heights: Vec = lines.iter().map(|l| l.gap_before + if l.small { tok.font.body_small * 1.45 } else { tok.font.body * 1.45 }).collect(); + // The transcript, newest at the bottom, scrolled back by `scroll`; + // laid out again only when it or its measure changed. + let key: TranscriptKey = (source.generation(), cw, tok.font.body, tok.font.body_small, self.d.text_scale()); + let lines = match &self.laid_out { + Some((k, lines)) if *k == key => lines.clone(), + _ => { + let lines = Rc::new(self.transcript(cx, &model, cw, &tok)); + self.laid_out = Some((key, lines.clone())); + lines + } + }; + let heights: Vec = lines.iter().map(|l| l.height(tok.font.body, tok.font.body_small)).collect(); let total: f64 = heights.iter().sum(); let room = (list_bottom - top).max(0.0); self.max_scroll = (total - room).max(0.0); @@ -1050,9 +1248,36 @@ impl ShellSystemChat { if line_top < top || ly > list_bottom + 0.5 { continue; } - let px = if line.small { tok.font.body_small } else { tok.font.body }; + let lh = h - line.gap_before; + let px = if line.small { tok.font.body_small } else { tok.font.body } * line.scale; let color = if line.accent { accent } else if line.dim { dim } else { ink }; - self.d.label_elided(cx, rect(x, line_top, cw, h - line.gap_before), line.bold, px, color, HAlign::Left, &line.text); + match line.kind { + LineKind::Rule => { + self.d.separator(cx, rect(x, line_top + lh * 0.5, cw, 1.0), ink, 0.18); + continue; + } + LineKind::Code => self.d.solid(cx, rect(x, line_top, cw, lh), alpha(ink, 0.07)), + LineKind::Quote => self.d.solid(cx, rect(x + 3.0, line_top, 2.0, lh), alpha(ink, 0.35)), + LineKind::Text => {} + } + let lx = x + line.indent; + if line.runs.is_empty() { + self.d.label_elided(cx, rect(lx, line_top, (cw - line.indent).max(0.0), lh), line.bold, px, color, HAlign::Left, &line.text); + continue; + } + // Styled runs side by side, measured as they are drawn. + let ts = self.d.text_scale(); + let mut rx = lx; + for (text, style) in &line.runs { + let bold = line.bold || style.bold; + let w = self.d.measure(cx, bold, px * ts, text); + if style.code { + self.d.solid(cx, rect(rx - 1.0, line_top + lh * 0.1, w + 2.0, lh * 0.8), alpha(ink, 0.1)); + } + let run_color = if style.link { accent } else { color }; + self.d.label_elided(cx, rect(rx, line_top, (x + cw - rx).max(0.0) + 2.0, lh), bold, px, run_color, HAlign::Left, text); + rx += w; + } } if model.items.is_empty() && usable { let hint = source.hint(); From 2b428f04d798ec0a14524f1e2fd42e1f6618a02d Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:19:25 -0700 Subject: [PATCH 05/11] fix(shell): the prompt shows what is typed at once; typing no longer lays out the transcript The prompt wrapped the draft like a reply: spaces were only break points, so a typed space showed (and moved the caret) only with the next character, and a run of spaces showed as one. It now wraps with `wrap_exact`, which keeps every character: a space at a line's end hangs past it, blank lines stay, and the lines put back together are the text. The caret is measured at the drawn size. The transcript's cache was keyed on the chat's generation, which typing bumps, so every keystroke laid the whole transcript out again; it is now keyed on the items, the running turn, the width and the font. Co-Authored-By: Claude Opus 5.5 --- crates/shell/src/shell/ui.rs | 87 +++++++++++++++++++++++++++- crates/shell/src/system_chat/view.rs | 32 ++++++---- 2 files changed, 105 insertions(+), 14 deletions(-) diff --git a/crates/shell/src/shell/ui.rs b/crates/shell/src/shell/ui.rs index 51dea31d..17924592 100644 --- a/crates/shell/src/shell/ui.rs +++ b/crates/shell/src/shell/ui.rs @@ -1014,8 +1014,14 @@ impl ShellDraw { lines } + /// Text being typed, wrapped to `max_w` with every character kept + /// ([`wrap_exact`]). + pub fn wrap_input(&mut self, cx: &mut Cx2d, px: f64, text: &str, max_w: f64) -> Vec { + wrap_exact(text, max_w, |s| self.measure(cx, false, px, s)) + } + /// Every line of `text` wrapped to `max_w` ([`wrap_with`]), blank lines - /// kept when `keep_blank` says (a prompt being typed shows them). + /// kept when `keep_blank` says. pub fn wrap_lines(&mut self, cx: &mut Cx2d, bold: bool, px: f64, text: &str, max_w: f64, keep_blank: bool) -> Vec { wrap_with(text, max_w, keep_blank, |s| self.measure(cx, bold, px, s)) } @@ -1694,11 +1700,14 @@ impl ShellDraw { self.label_elided(cx, rect(inner.pos.x, top, inner.size.x, line_h), false, px, super::darker(foreground, 1.6), HAlign::Left, placeholder); } else { for (i, line) in lines.iter().enumerate() { - self.label_elided(cx, rect(inner.pos.x, top + i as f64 * line_h, inner.size.x, line_h), false, px, foreground, HAlign::Left, line); + // A line's trailing spaces hang past its end: not drawn, so + // they never elide the line. + self.label_elided(cx, rect(inner.pos.x, top + i as f64 * line_h, inner.size.x, line_h), false, px, foreground, HAlign::Left, line.trim_end()); } } + // The caret goes after everything typed, spaces too. let last = lines.last().map(String::as_str).unwrap_or(""); - let w = if last.is_empty() { 0.0 } else { self.measure(cx, false, px, last).min(inner.size.x) }; + let w = if last.is_empty() { 0.0 } else { self.measure(cx, false, px * self.text_scale(), last).min(inner.size.x) }; let at = rect(inner.pos.x + w + 1.0, top + (n - 1.0) * line_h + (line_h - px * 1.1) * 0.5, 1.0, px * 1.1); if caret { self.solid(cx, at, foreground); @@ -2036,6 +2045,64 @@ pub fn wrap_with(text: &str, max_w: f64, keep_blank: bool, mut measure: impl FnM lines } +/// Text being typed, wrapped to `max_w` by `measure` with every character +/// kept: each space stays (a space at a line's end hangs past it, so the +/// caret goes after it at once), a line break makes a line even when blank, +/// and the lines put back together are the text. Lines break before a word +/// that does not fit, between CJK characters, and inside a word longer +/// than a line; never before closing punctuation. +pub fn wrap_exact(text: &str, max_w: f64, mut measure: impl FnMut(&str) -> f64) -> Vec { + let mut lines = Vec::new(); + for para in text.split('\n') { + let mut line = String::new(); + let mut w = 0.0; + let mut word: Option = None; + let mut tokens: Vec<&str> = Vec::new(); + for (i, c) in para.char_indices() { + if c.is_whitespace() || breaks_anywhere(c) { + if let Some(start) = word.take() { + tokens.push(¶[start..i]); + } + tokens.push(¶[i..i + c.len_utf8()]); + } else if word.is_none() { + word = Some(i); + } + } + if let Some(start) = word { + tokens.push(¶[start..]); + } + for token in tokens { + let tw = measure(token); + let mut chars = token.chars(); + let first = chars.next().unwrap_or(' '); + let single = chars.next().is_none(); + let space = first.is_whitespace(); + if !space && !line.is_empty() && w + tw > max_w && !(single && closes(first)) { + lines.push(std::mem::take(&mut line)); + w = 0.0; + } + if !space && line.is_empty() && tw > max_w { + for c in token.chars() { + let mut buf = [0u8; 4]; + let c_str = c.encode_utf8(&mut buf); + let cw = measure(c_str); + if !line.is_empty() && w + cw > max_w && !closes(c) { + lines.push(std::mem::take(&mut line)); + w = 0.0; + } + line.push(c); + w += cw; + } + } else { + line.push_str(token); + w += tw; + } + } + lines.push(line); + } + lines +} + #[cfg(test)] mod wrap_tests { use super::*; @@ -2061,6 +2128,20 @@ mod wrap_tests { assert_eq!(wrap_with("用OctoSense发送", 12.0, false, width), ["用OctoSense", "发送"]); } + /// What is typed is shown as typed: a trailing space at once, a run of + /// spaces whole, blank lines, and the lines put back are the text. + #[test] + fn typed_text_keeps_every_space_and_line() { + assert_eq!(wrap_exact("hello ", 20.0, width), ["hello "]); + assert_eq!(wrap_exact("a b", 20.0, width), ["a b"]); + assert_eq!(wrap_exact("aaa bbb", 5.0, width), ["aaa ", "bbb"], "the space hangs at the line's end"); + assert_eq!(wrap_exact("你好世界", 4.0, width), ["你好", "世界"]); + assert_eq!(wrap_exact("abc\n", 20.0, width), ["abc", ""], "the caret's empty line after a break"); + assert_eq!(wrap_exact("", 20.0, width), [""]); + let typed = "one two 三四,五 https://example.com/very/long\n\nend "; + assert_eq!(wrap_exact(typed, 9.0, width).concat(), typed.replace('\n', ""), "nothing typed is lost"); + } + #[test] fn paragraphs_are_lines_and_blank_ones_only_when_asked() { assert_eq!(wrap_with("one\n\ntwo", 10.0, false, width), ["one", "two"]); diff --git a/crates/shell/src/system_chat/view.rs b/crates/shell/src/system_chat/view.rs index 7c0280ce..67a82bb6 100644 --- a/crates/shell/src/system_chat/view.rs +++ b/crates/shell/src/system_chat/view.rs @@ -419,9 +419,18 @@ impl Line { } } -/// What a laid-out transcript depends on: the conversation's generation, -/// the width, the two font sizes and the text scale. -type TranscriptKey = (u64, f64, f64, f64, f64); +/// What a laid-out transcript depends on: its items, whether a turn runs +/// (the streaming caret), the width, the two font sizes and the text +/// scale. Never the prompt: typing does not lay the transcript out again. +#[derive(Clone, Debug, PartialEq)] +struct TranscriptKey { + items: Vec, + running: bool, + width: f64, + body: f64, + small: f64, + scale: f64, +} /// Styled runs wrapped to `width`, each run measured in its weight at `px` /// (the drawn size). @@ -653,12 +662,6 @@ impl Source { Source::App => crate::app_chat::scroll(), } } - fn generation(self) -> u64 { - match self { - Source::System => super::generation(), - Source::App => crate::app_chat::generation(), - } - } fn scroll_by(self, dy: f64, max: f64) { match self { Source::System => super::scroll_by(dy, max), @@ -1162,7 +1165,7 @@ impl ShellSystemChat { let field_w = cw - bw - 8.0; let inner_w = (field_w - tok.spacing.control_padding_x * 2.0).max(1.0); let ts = b.d.text_scale(); - let mut lines = b.d.wrap_lines(cx, false, tok.font.body * ts, &draft, inner_w, true); + let mut lines = b.d.wrap_input(cx, tok.font.body * ts, &draft, inner_w); if lines.len() > PROMPT_LINES { // Past its height the prompt shows its last lines: the caret's. lines.drain(..lines.len() - PROMPT_LINES); @@ -1227,7 +1230,14 @@ impl ShellSystemChat { // The transcript, newest at the bottom, scrolled back by `scroll`; // laid out again only when it or its measure changed. - let key: TranscriptKey = (source.generation(), cw, tok.font.body, tok.font.body_small, self.d.text_scale()); + let key = TranscriptKey { + items: model.items.clone(), + running: model.phase().running_turn().is_some(), + width: cw, + body: tok.font.body, + small: tok.font.body_small, + scale: self.d.text_scale(), + }; let lines = match &self.laid_out { Some((k, lines)) if *k == key => lines.clone(), _ => { From c357cff9f593f6f21a14570a25f0f5bcd0f28d60 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:27:13 -0700 Subject: [PATCH 06/11] feat(shell): the assistant opens as a medium pane at the left On a desktop the system chat opens at the left, the column's width and 60% of the screen's height (at most 640), centred top to bottom, instead of a full-height column at the right; "Ask " stands right of it when both are open. A double-click on the header puts the pane back there. Co-Authored-By: Claude Opus 5.5 --- crates/shell/src/system_chat/view.rs | 51 +++++++++++++++++++++------- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/crates/shell/src/system_chat/view.rs b/crates/shell/src/system_chat/view.rs index 67a82bb6..6e431618 100644 --- a/crates/shell/src/system_chat/view.rs +++ b/crates/shell/src/system_chat/view.rs @@ -1,6 +1,7 @@ //! The system chat pane, drawn by the shell like its other surfaces -//! (approvals/view.rs, glance_panel.rs): a column at the right of the -//! desktop, the whole screen on a phone (or any narrow window). +//! (approvals/view.rs, glance_panel.rs): a medium pane at the left of the +//! desktop ([`default_pane`]), which the person can move and size; the +//! whole screen on a phone (or any narrow window). //! //! It shows the conversation ([`super::model`]): the person's messages, //! the assistant's streamed text, each tool call with its status, the @@ -14,8 +15,8 @@ //! //! - **Placing it** (a desktop, not the full-screen surface): the header //! (title and status) moves the pane, an edge or a corner sizes it, each -//! with its cursor; a double-click on the header puts it back in its -//! column. The placement holds while the shell runs and stays on screen +//! with its cursor; a double-click on the header puts it back where it +//! opens. The placement holds while the shell runs and stays on screen //! when the window shrinks ([`placed_pane`], [`dragged`]). //! - **The caret** blinks at the end of the prompt while the pane holds //! the keyboard, and stays on while the person types. @@ -46,8 +47,8 @@ //! whenever the PERSON's lane is idle, even while the system agent's turn //! runs, and Stop stops the person's own turn. The system agent's running //! turn has its own row with "Stop the system agent's task". On a desktop -//! the panel stands left of the system chat when both are open, so the two -//! lanes show side by side; on a phone it is a full-screen sheet. +//! the panel stands right of the system chat when both are open, so the +//! two lanes show side by side; on a phone it is a full-screen sheet. use makepad_widgets::*; @@ -77,6 +78,10 @@ pub const PANE_W: f64 = 440.0; pub const FULL_SCREEN_BELOW: f64 = 720.0; const PAD: f64 = 16.0; const FIELD_H: f64 = 36.0; +/// The desktop pane's default height, as a share of the screen's, and the +/// most it takes by default. +const DEFAULT_HEIGHT: f64 = 0.6; +const DEFAULT_MAX_H: f64 = 640.0; /// The smallest the person may size the desktop pane. pub const MIN_W: f64 = 320.0; pub const MIN_H: f64 = 240.0; @@ -140,6 +145,16 @@ pub fn visible(r: Rect, pass: Vec2d) -> Rect { rect(r.pos.x, r.pos.y, r.size.x.min((pass.x - r.pos.x).max(0.0)), r.size.y.min((pass.y - r.pos.y).max(0.0))) } +/// Where the desktop pane opens: a medium pane at the left (the column's +/// width, [`DEFAULT_HEIGHT`] of the screen up to [`DEFAULT_MAX_H`]), +/// centred top to bottom; `beside` (the "Ask " panel while the system +/// chat is open) right of the system chat's. +pub fn default_pane(screen: Rect, gap: f64, beside: bool) -> Rect { + let h = between(screen.size.y * DEFAULT_HEIGHT, MIN_H, DEFAULT_MAX_H).min((screen.size.y - gap * 2.0).max(0.0)); + let x = screen.pos.x + gap + if beside { PANE_W + gap } else { 0.0 }; + placed_pane(rect(x, screen.pos.y + (screen.size.y - h) * 0.5, PANE_W, h), screen) +} + /// A placement kept on `screen`: no smaller than the minimum (unless the /// screen is), no larger than the screen, wholly on it. pub fn placed_pane(r: Rect, screen: Rect) -> Rect { @@ -582,7 +597,7 @@ pub struct ShellSystemChat { /// hidden-window runs' logs). #[rust] pub shown: Vec, - /// Where the person put the desktop pane (None: its column). + /// Where the person put the desktop pane (None: where it opens). #[rust] placed: Option, /// The surface the pane was last drawn on (a drag stays on it). @@ -781,7 +796,7 @@ impl ShellSystemChat { Event::MouseDown(e) => { if let Some(grip) = self.grip_at(e.abs) { if grip == Grip::Move && e.time - self.header_pressed < DOUBLE_PRESS_S { - // A double-click on the header: back in its column. + // A double-click on the header: back where it opens. self.placed = None; self.header_pressed = 0.0; self.redraw(cx); @@ -1108,10 +1123,8 @@ impl ShellSystemChat { self.placed = Some(kept); kept } else { - let gap = tok.spacing.gaps_out; - // "Ask " stands left of the system chat when both are open. - let beside = if source == Source::App && super::is_open() { PANE_W + gap } else { 0.0 }; - rect(screen.pos.x + screen.size.x - gap - PANE_W - beside, screen.pos.y + gap, PANE_W, (screen.size.y - gap * 2.0).max(240.0)) + // "Ask " stands right of the system chat when both are open. + default_pane(screen, tok.spacing.gaps_out, source == Source::App && super::is_open()) }; self.pane = pane; self.d.card(cx, pane, &tok.popups); @@ -1449,6 +1462,20 @@ mod tests { assert_eq!(hit_in(&hits, dvec2(300.0, 400.0)), Some(Hit::Pane), "the transcript stays the pane's"); } + /// The pane opens at the left, medium and centred; "Ask " beside + /// it; a short screen gives it its minimum height. + #[test] + fn the_pane_opens_medium_at_the_left() { + let screen = rect(0.0, 31.0, 1400.0, 832.0); + let pane = default_pane(screen, 5.0, false); + assert_eq!((pane.pos.x, pane.size.x), (5.0, PANE_W)); + assert!((pane.size.y - 832.0 * DEFAULT_HEIGHT).abs() < 1e-9); + assert!((pane.pos.y + pane.size.y * 0.5 - (31.0 + 416.0)).abs() < 1e-9, "centred top to bottom"); + assert_eq!(default_pane(screen, 5.0, true).pos.x, 5.0 + PANE_W + 5.0, "Ask right of it"); + assert_eq!(default_pane(rect(0.0, 0.0, 1400.0, 2000.0), 5.0, false).size.y, DEFAULT_MAX_H); + assert_eq!(default_pane(rect(0.0, 0.0, 800.0, 300.0), 5.0, false).size.y, MIN_H); + } + /// Moving keeps the pane on screen; sizing moves only the dragged edges, /// never below the minimum; a placement shrinks with the window. #[test] From efaf4bcff860387459f2cec13e8483aaf1ef6605 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:42:28 -0700 Subject: [PATCH 07/11] feat(shell): a bar button and a menu row open the glance panel; new cards light it The glance panel had only F9. The bar now carries a bell among its status icons: the cards on the glance screen counted on a badge, lit in the accent while cards have arrived since the panel was last open (cards published after `glance_seen_ms`); clicking it toggles the panel, which then wears the open-panel pill. The menu lists "At a glance" right after Apps. A published, replaced or withdrawn card refreshes the bar. Co-Authored-By: Claude Opus 5.5 --- crates/shell/src/lib.rs | 34 ++++++++++++++++++++++++++++++++ crates/shell/src/shell/bar.rs | 36 ++++++++++++++++++++++++++++++++++ crates/shell/src/shell/menu.rs | 24 +++++++++++++++++------ 3 files changed, 88 insertions(+), 6 deletions(-) diff --git a/crates/shell/src/lib.rs b/crates/shell/src/lib.rs index bf348428..3c0c8f86 100644 --- a/crates/shell/src/lib.rs +++ b/crates/shell/src/lib.rs @@ -405,6 +405,10 @@ pub struct App { /// The glance cards' generation the surfaces last drew (glance.rs). #[rust] pub glance_generation: u64, + /// When the glance panel was last open: cards published after it are + /// new (the bar's glance button is lit). + #[rust] + pub glance_seen_ms: u64, /// The notifications cards asked for (`glance.publish` with `notify`): /// the desktop toasts' ids and the card each opens, and the phone /// shade's ids, which open the glance page. @@ -3549,6 +3553,11 @@ impl App { self.ask_focused_app(cx); return; } + if target == shell::menu::GLANCE_ROW { + self.close_shell_menu(cx); + self.set_glance_open(cx, true); + return; + } if target == shell::menu::SYSTEM_CHAT_ROW { self.close_shell_menu(cx); system_chat::open(); @@ -3644,10 +3653,21 @@ impl App { if let Some(mut panel) = self.ui.widget(cx, ids!(shell_glance)).borrow_mut::() { panel.open = open; } + // Seen: the bar's glance button goes back from lit. + if open { + self.glance_seen_ms = glance::now_ms(); + } log!("wm: glance panel {}", if open { "open" } else { "closed" }); + if self.state.is_some() { + self.update_bar(cx); + } self.redraw_all(cx); } + fn glance_open(&mut self, cx: &mut Cx) -> bool { + self.ui.widget(cx, ids!(shell_glance)).borrow::().is_some_and(|p| p.open) + } + /// The card window (glance_sheet.rs) is modal: while a card is open /// every pointer event is its own (a press outside the card closes it). fn glance_sheet_pointer(&mut self, cx: &mut Cx, event: &Event) -> bool { @@ -4078,6 +4098,14 @@ impl App { data.active_window = (!title.is_empty()).then_some(title); data.ask_agent = self.focused_agent_app().map(|a| a.name); data.open_panel = self.shell_panel_open; + // The glance button: the cards shown now, and how many arrived since + // the panel was last open; it wears the pill while the panel is up. + let cards = glance::shown(); + let new = cards.iter().filter(|c| c.published_ms > self.glance_seen_ms).count(); + data.glance = Some((cards.len(), new)); + if self.glance_open(cx) { + data.open_panel = Some(BarModule::Glance); + } // The middle window control reads "restore" while maximized. data.maximized = self.ui.window(cx, ids!(main_window)).is_fullscreen(cx); let bar = self.ui.widget(cx, ids!(shell_bar)); @@ -5799,6 +5827,10 @@ impl MatchEvent for App { } } BarModule::AskAgent => self.ask_focused_app(cx), + BarModule::Glance => { + let open = self.glance_open(cx); + self.set_glance_open(cx, !open); + } control @ (BarModule::WindowMin | BarModule::WindowMax | BarModule::WindowClose) => { // The gallery's bar is a picture of one: its // controls log (shell/gallery.rs), never close or @@ -6402,6 +6434,8 @@ impl App { // A card was published, replaced or withdrawn (glance.rs). if glance::generation() != self.glance_generation && self.state.is_some() { self.glance_generation = glance::generation(); + // The bar's glance button counts the cards. + self.update_bar(cx); self.redraw_all(cx); } if self.state.is_some() { diff --git a/crates/shell/src/shell/bar.rs b/crates/shell/src/shell/bar.rs index 84a43932..3c1eb047 100644 --- a/crates/shell/src/shell/bar.rs +++ b/crates/shell/src/shell/bar.rs @@ -41,6 +41,9 @@ pub enum BarModule { /// has an agent (ADR 0004 §4): opens the shell's panel for it. AskAgent, Indicator(usize), + /// The glance panel (F9): the cards apps published, their count on a + /// badge, lit while some are new since the panel was last open. + Glance, Clock, KeyboardLayout, Weather, @@ -111,6 +114,9 @@ pub struct BarData { pub open_panel: Option, /// The window is maximized: the middle control shows "restore". pub maximized: bool, + /// The glance screen's cards and how many arrived since the panel was + /// last open (`None`: no glance button). + pub glance: Option<(usize, usize)>, } #[derive(Clone, Copy, Debug, Default)] @@ -171,6 +177,7 @@ impl BarData { }), open_panel: None, maximized: false, + glance: None, } } } @@ -569,6 +576,9 @@ impl ShellBar { /// icon each one shows right now. fn right_modules(&self) -> Vec<(BarModule, Ico, bool)> { let mut v: Vec<(BarModule, Ico, bool)> = Vec::new(); + if self.data.glance.is_some() { + v.push((BarModule::Glance, Ico::Bell, true)); + } for (i, ico) in self.data.tray.iter().enumerate() { v.push((BarModule::Tray(i), *ico, true)); } @@ -857,7 +867,28 @@ impl ShellBar { if *module == BarModule::Audio && self.data.muted { color = fade(fg, 0.45); } + let glance = if *module == BarModule::Glance { self.data.glance } else { None }; + if let Some((_, new)) = glance { + if new > 0 { + color = accent; + } + } self.d.icon_centered(cx, *ico, cell, canvas, color); + // The glance button's count, at the bell's corner: a tag in the + // accent while some cards are new. + if let Some((count, new)) = glance.filter(|(count, _)| *count > 0) { + let text = if count > 9 { "9+".to_string() } else { count.to_string() }; + let px = tok.font.caption * 0.8; + let h = (px * 1.25).round(); + let w = (self.d.measure(cx, true, px, &text) + 4.0).max(h); + let badge = rect((cell.pos.x + cell.size.x * 0.5 + canvas * 0.15).round(), (cell.pos.y + cell.size.y * 0.5 - canvas * 0.6).round(), w, h); + if new > 0 { + self.d.solid(cx, badge, accent); + self.d.label(cx, badge, true, px, tok.bar.background, super::ui::HAlign::Center, &text); + } else { + self.d.label(cx, badge, true, px, fade(fg, 0.7), super::ui::HAlign::Center, &text); + } + } self.hits.push((*module, cell)); } @@ -896,6 +927,11 @@ impl ShellBar { .clone() .unwrap_or_default(), BarModule::AskAgent => format!("Talk to {}'s agent (Shift+F8)", self.data.ask_agent.clone().unwrap_or_default()), + BarModule::Glance => match self.data.glance { + Some((count, new)) if new > 0 => format!("At a glance (F9): {count} card(s), {new} new"), + Some((count, _)) => format!("At a glance (F9): {count} card(s)"), + None => "At a glance (F9)".into(), + }, BarModule::Clock => "Calendar".into(), BarModule::KeyboardLayout => "Keyboard layout".into(), BarModule::Weather => "Weather".into(), diff --git a/crates/shell/src/shell/menu.rs b/crates/shell/src/shell/menu.rs index 8e029dd9..91088a29 100644 --- a/crates/shell/src/shell/menu.rs +++ b/crates/shell/src/shell/menu.rs @@ -526,6 +526,15 @@ impl MenuModel { let at = items.iter().position(|item| item.id == "style").map_or(items.len(), |i| i + 1); items.splice(at..at, developer); } + // The glance panel, right after Apps: the cards apps published. + let at = items.iter().position(|item| item.id == "apps").map_or(0, |i| i + 1); + items.insert( + at, + MenuItem::new(GLANCE_ROW, "At a glance", MenuKind::Action) + .icon(Ico::Bell) + .aliases(&["glance", "cards", "notifications"]) + .describe("The cards your apps published (F9)"), + ); // Settings → Assistant → Approvals (approvals/settings_page.rs), in // every build: rules, app agents' consent, recent auto-approvals. let assistant = assistant_items(&items); @@ -828,6 +837,8 @@ pub const COMMANDS_ALLOW_ROW: &str = "setup.assistant.commands.allow"; pub const COMMANDS_OFF_ROW: &str = "setup.assistant.commands.off"; /// Restart the assistant so a changed grant applies. pub const ASSISTANT_RESTART_ROW: &str = "setup.assistant.restart"; +/// The glance panel (lib.rs opens it), at the menu's top level. +pub const GLANCE_ROW: &str = "glance"; /// Settings (Setup) → Assistant: the system chat, Approvals, and Command /// execution. @@ -2022,13 +2033,14 @@ mod tests { let styles = if crate::MOBILE_ONLY { 0 } else { 1 }; // A development build lists Setup (Developer options) after Style. let setup = usize::from(crate::dev_mode::settings_available()); - assert_eq!(m.rows.len(), 4 + setup + styles); + assert_eq!(m.rows.len(), 5 + setup + styles); assert_eq!(m.rows.iter().any(|r| r.target == "desktop"), styles == 1); assert_eq!(m.rows[0].label, "Apps"); + assert_eq!(m.rows[1].label, "At a glance", "the glance panel, right after Apps"); if setup == 1 { - assert_eq!(m.rows[3].label, "Setup"); + assert_eq!(m.rows[4].label, "Setup"); } - assert_eq!(m.rows[3 + setup].label, "System"); + assert_eq!(m.rows[4 + setup].label, "System"); assert!(m.rows.iter().all(|r| !r.disabled)); // Submenu rows carry the chevron. assert!(m.rows[0].has_children); @@ -2073,14 +2085,14 @@ mod tests { fn back_walks_the_visited_stack_then_the_parents() { let mut m = MenuModel::default(); m.open_at("", MenuSkin::Menu); - m.sel = 2; // Style - assert_eq!(m.rows[2].label, "Style"); + let style = m.rows.iter().position(|r| r.label == "Style").expect("Style"); + m.sel = style; m.activate(); assert_eq!(m.path, "style"); assert!(m.rows.iter().any(|r| r.label == "Theme")); assert!(m.back()); assert_eq!(m.path, ""); - assert_eq!(m.sel, 2); + assert_eq!(m.sel, style); // At the root there is nowhere left to go. assert!(!m.back()); } From 5fd53869d381252a5756f8aecca7cff475f7939a Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:54:05 -0700 Subject: [PATCH 08/11] feat(shell): the desktop's text follows the theme's fonts, as the apps' does The shell's drawing kit named JetBrains Mono for every label (the bar, menus, toasts, panels, the card window's title, the chat panes), while apps take the theme's font roles (makepad's font policy: IBM Plex Sans, LXGW WenKai for CJK, Noto Color Emoji, unless a style names its own) and glance cards the backend's Roboto. The kit now draws with the theme's regular and bold roles, and its code role for code (`with_code`: code blocks and inline code in the chat panes); Chinese gets the theme's CJK faces, bold included. A glance card's text follows the theme too: the backend names Roboto (its reference renderer's face) for text in no font of its own, and the shell rewrites those styles to the theme's bold role at weight 500 and up and its regular one below (`glance_card::theme_fonts`); a mood with its own font (atro's Montserrat) keeps it. New glance cards light the bar's bell in the palette's accent, not the bar's alert colour. Checked in hidden --remote runs with the demo Mail cards: bar, toasts, card window, glance panel and chat pane in the theme's fonts, Chinese in LXGW WenKai. Found, not changed here: in the glance panel (not the card window) the shipping card's tiles draw light with white text and the request card's chips lose their labels, with or without this change. Co-Authored-By: Claude Opus 5.5 --- crates/shell/src/glance_card.rs | 49 +++++++++++++++++++++++ crates/shell/src/shell/bar.rs | 9 +++-- crates/shell/src/shell/ui.rs | 60 +++++++++++++--------------- crates/shell/src/system_chat/view.rs | 28 +++++++++---- 4 files changed, 103 insertions(+), 43 deletions(-) diff --git a/crates/shell/src/glance_card.rs b/crates/shell/src/glance_card.rs index 0667a5ba..9a8bc092 100644 --- a/crates/shell/src/glance_card.rs +++ b/crates/shell/src/glance_card.rs @@ -125,9 +125,41 @@ fn lower_report(source: &str, report: octoscript_ui_l0::RealizeReport, l0_ui: bo // own properties are the only lines at this indentation. let ui = ui.replacen("\n height: Fill\n", "\n height: Fit\n", 1); let ui = multiline_fields(&ui); + let ui = theme_fonts(&ui); Ok(format!("width:Fill height:Fit flow:Overlay {ui}")) } +/// The card's text in the theme's fonts, as every app's: the backend names +/// Roboto (its reference renderer's face) for text in no font of its own, +/// so a glance card would not match the shell and the apps around it. Such +/// a line takes the theme's bold role at weight 500 and up, its regular one +/// below, at the same size and spacing. A mood with a font of its own +/// (atro's Montserrat) keeps it. +fn theme_fonts(ui: &str) -> String { + const BACKEND_FACE: &str = "crate_resource(\"makepad_widgets:resources/Roboto-Regular.ttf\")"; + const STYLE: &str = "draw_text.text_style: TextStyle{"; + let number = |line: &str, key: &str| { + let rest = line.split(key).nth(1)?; + let n: String = rest.chars().take_while(|c| c.is_ascii_digit() || *c == '.').collect(); + (!n.is_empty()).then_some(n) + }; + let mut out: Vec = Vec::new(); + for line in ui.lines() { + let (Some(at), true) = (line.find(STYLE), line.contains(BACKEND_FACE)) else { + out.push(line.to_string()); + continue; + }; + let (Some(weight), Some(size)) = (number(line, "weight: "), number(line, "font_size: ")) else { + out.push(line.to_string()); + continue; + }; + let role = if weight.parse::().unwrap_or(400.0) >= 500.0 { "font_bold" } else { "font_regular" }; + let spacing = number(line, "line_spacing: ").unwrap_or_else(|| "1.45".into()); + out.push(format!("{}draw_text.text_style: mod.theme.{role}{{ line_spacing: {spacing} font_size: {size} }}", &line[..at])); + } + out.join("\n") +} + /// The host's additions to the L0 kit, after the kit so they win. /// /// A multi-line field. L0's `Field` has no argument for it (that needs @@ -611,6 +643,23 @@ mod tests { assert!(body.contains("3 stories since this morning") && body.contains("Makepad adds contained script isolates"), "{body}"); } + /// A card's text follows the theme like the apps': none of the backend's + /// Roboto is left, the title takes the bold role and the rest regular. + #[test] + fn a_cards_text_is_in_the_themes_fonts() { + for (_, _, source, data) in crate::glance::demo_mail() { + let body = lower(&source, &data).expect("lowers"); + assert!(!body.contains("Roboto-Regular"), "{body}"); + assert!(body.contains("mod.theme.font_bold{") && body.contains("mod.theme.font_regular{"), "{body}"); + } + assert_eq!( + theme_fonts(" draw_text.text_style: TextStyle{ font_family: FontFamily{ latin := FontMember{res: crate_resource(\"makepad_widgets:resources/Roboto-Regular.ttf\") asc: -0.1 desc: 0.0 weight: 600} } line_spacing: 1.45 font_size: 13.5 }"), + " draw_text.text_style: mod.theme.font_bold{ line_spacing: 1.45 font_size: 13.5 }" + ); + let own = " draw_text.text_style: TextStyle{ font_family: FontFamily{ latin := FontMember{res: crate_resource(\"self:resources/atro/Montserrat-Medium.ttf\") asc: -0.1 desc: 0.0 weight: 500} } line_spacing: 1.45 font_size: 12 }"; + assert_eq!(theme_fonts(own), own, "a mood's own font stays"); + } + /// A system app, registered from a pack made on the fly, whose manifest /// grants `glance` and `storage` and nothing else. #[cfg(feature = "app-hub")] diff --git a/crates/shell/src/shell/bar.rs b/crates/shell/src/shell/bar.rs index 3c1eb047..79348d2b 100644 --- a/crates/shell/src/shell/bar.rs +++ b/crates/shell/src/shell/bar.rs @@ -641,6 +641,9 @@ impl ShellBar { let tok = self.d.tokens(self.tokens); let fg = tok.bar.text; let accent = tok.bar.active; + // New glance cards wear the palette's accent (the toasts' colour), + // not the bar's alert one. + let news = tok.notifications.countdown; let slot = self.icon_slot(); let canvas = tok.bar.icon_canvas; self.hits.clear(); @@ -870,12 +873,12 @@ impl ShellBar { let glance = if *module == BarModule::Glance { self.data.glance } else { None }; if let Some((_, new)) = glance { if new > 0 { - color = accent; + color = news; } } self.d.icon_centered(cx, *ico, cell, canvas, color); // The glance button's count, at the bell's corner: a tag in the - // accent while some cards are new. + // palette's accent while some cards are new. if let Some((count, new)) = glance.filter(|(count, _)| *count > 0) { let text = if count > 9 { "9+".to_string() } else { count.to_string() }; let px = tok.font.caption * 0.8; @@ -883,7 +886,7 @@ impl ShellBar { let w = (self.d.measure(cx, true, px, &text) + 4.0).max(h); let badge = rect((cell.pos.x + cell.size.x * 0.5 + canvas * 0.15).round(), (cell.pos.y + cell.size.y * 0.5 - canvas * 0.6).round(), w, h); if new > 0 { - self.d.solid(cx, badge, accent); + self.d.solid(cx, badge, news); self.d.label(cx, badge, true, px, tok.bar.background, super::ui::HAlign::Center, &text); } else { self.d.label(cx, badge, true, px, fade(fg, 0.7), super::ui::HAlign::Center, &text); diff --git a/crates/shell/src/shell/ui.rs b/crates/shell/src/shell/ui.rs index 17924592..a6bc8507 100644 --- a/crates/shell/src/shell/ui.rs +++ b/crates/shell/src/shell/ui.rs @@ -499,46 +499,25 @@ script_mod! { } // ------------------------------------------------------------------ - // The kit. `Style.font.family` is "monospace", which on an omarchy box - // is JetBrains Mono — the variable cut is the one makepad ships, so - // `bold` is the same face at weight 700 (never a different family). + // The kit. Its text follows the theme, as every app's does: the theme's + // regular and bold roles (makepad's font policy: IBM Plex Sans, LXGW + // WenKai for CJK, Noto Color Emoji, unless a style names its own), and + // its code role for code (`ShellDraw::with_code`). // ------------------------------------------------------------------ set_type_default() do #(ShellDraw::script_component(vm)) { fill +: {} chrome +: {} glass +: {} text +: { - text_style: TextStyle{ - font_family: FontFamily{ - latin := FontMember{ - res: crate_resource("makepad_widgets:resources/jetbrains_mono_variable.ttf") - asc: 0.0 desc: 0.0 weight: 400.0 - } - emoji := FontMember{ - res: crate_resource("makepad_widgets:resources/NotoColorEmoji.ttf") - asc: 0.0 desc: 0.0 - } - } - font_size: 9.0 - line_spacing: 1.2 - } + text_style: theme.font_regular{font_size: 9.0 line_spacing: 1.2} color: #ffffff } text_bold +: { - text_style: TextStyle{ - font_family: FontFamily{ - latin := FontMember{ - res: crate_resource("makepad_widgets:resources/jetbrains_mono_variable.ttf") - asc: 0.0 desc: 0.0 weight: 700.0 - } - emoji := FontMember{ - res: crate_resource("makepad_widgets:resources/NotoColorEmoji.ttf") - asc: 0.0 desc: 0.0 - } - } - font_size: 9.0 - line_spacing: 1.2 - } + text_style: theme.font_bold{font_size: 9.0 line_spacing: 1.2} + color: #ffffff + } + text_code +: { + text_style: theme.font_code{font_size: 9.0 line_spacing: 1.2} color: #ffffff } icons +: { @@ -863,6 +842,11 @@ pub struct ShellDraw { pub text: DrawText, #[live] pub text_bold: DrawText, + /// The theme's code face ([`Self::with_code`]). + #[live] + pub text_code: DrawText, + #[rust] + code_face: bool, #[live] pub icons: ShellIcons, /// The material every surface drawn through this kit paints with — @@ -938,13 +922,25 @@ impl ShellDraw { // ------------------------------------------------------------- text fn face(&mut self, bold: bool) -> &mut DrawText { - if bold { + if self.code_face { + &mut self.text_code + } else if bold { &mut self.text_bold } else { &mut self.text } } + /// Measure and draw in the theme's code face while `f` runs (code + /// blocks, inline code); every other label uses the theme's regular and + /// bold faces. + pub fn with_code(&mut self, f: impl FnOnce(&mut Self) -> R) -> R { + let was = std::mem::replace(&mut self.code_face, true); + let out = f(self); + self.code_face = was; + out + } + /// Width of one line at a px size — QML's `Text.implicitWidth`. pub fn measure(&mut self, cx: &mut Cx2d, bold: bool, px: f64, s: &str) -> f64 { if s.is_empty() { diff --git a/crates/shell/src/system_chat/view.rs b/crates/shell/src/system_chat/view.rs index 6e431618..3bacef02 100644 --- a/crates/shell/src/system_chat/view.rs +++ b/crates/shell/src/system_chat/view.rs @@ -451,7 +451,8 @@ struct TranscriptKey { /// (the drawn size). fn wrap_runs(d: &mut ShellDraw, cx: &mut Cx2d, runs: &md::Runs, width: f64, px: f64, bold: bool) -> Vec { let runs: Vec<(&str, md::Style)> = runs.iter().map(|(t, s)| (t.as_str(), *s)).collect(); - wrap_styled(&runs, width, |t, s| d.measure(cx, bold || s.bold, px, t)) + // Code runs in the theme's code face, the rest in its regular or bold. + wrap_styled(&runs, width, |t, s| if s.code { d.with_code(|d| d.measure(cx, false, px, t)) } else { d.measure(cx, bold || s.bold, px, t) }) } /// A code line wrapped to `width` between characters, its spaces kept (a @@ -462,7 +463,7 @@ fn wrap_code(d: &mut ShellDraw, cx: &mut Cx2d, text: &str, width: f64, px: f64) let mut w = 0.0; for c in text.chars() { let piece = if c == '\t' { " ".to_string() } else { c.to_string() }; - let cw = d.measure(cx, false, px, &piece); + let cw = d.with_code(|d| d.measure(cx, false, px, &piece)); if !line.is_empty() && w + cw > width { lines.push(std::mem::take(&mut line)); w = 0.0; @@ -1285,7 +1286,12 @@ impl ShellSystemChat { } let lx = x + line.indent; if line.runs.is_empty() { - self.d.label_elided(cx, rect(lx, line_top, (cw - line.indent).max(0.0), lh), line.bold, px, color, HAlign::Left, &line.text); + let r = rect(lx, line_top, (cw - line.indent).max(0.0), lh); + if line.kind == LineKind::Code { + self.d.with_code(|d| d.label_elided(cx, r, false, px, color, HAlign::Left, &line.text)); + } else { + self.d.label_elided(cx, r, line.bold, px, color, HAlign::Left, &line.text); + } continue; } // Styled runs side by side, measured as they are drawn. @@ -1293,12 +1299,18 @@ impl ShellSystemChat { let mut rx = lx; for (text, style) in &line.runs { let bold = line.bold || style.bold; - let w = self.d.measure(cx, bold, px * ts, text); - if style.code { - self.d.solid(cx, rect(rx - 1.0, line_top + lh * 0.1, w + 2.0, lh * 0.8), alpha(ink, 0.1)); - } + let r = rect(rx, line_top, (x + cw - rx).max(0.0) + 2.0, lh); let run_color = if style.link { accent } else { color }; - self.d.label_elided(cx, rect(rx, line_top, (x + cw - rx).max(0.0) + 2.0, lh), bold, px, run_color, HAlign::Left, text); + let w = if style.code { + let w = self.d.with_code(|d| d.measure(cx, false, px * ts, text)); + self.d.solid(cx, rect(rx - 1.0, line_top + lh * 0.1, w + 2.0, lh * 0.8), alpha(ink, 0.1)); + self.d.with_code(|d| d.label_elided(cx, r, false, px, run_color, HAlign::Left, text)); + w + } else { + let w = self.d.measure(cx, bold, px * ts, text); + self.d.label_elided(cx, r, bold, px, run_color, HAlign::Left, text); + w + }; rx += w; } } From 3b6f4c2204cfefc25066409f97551e4fce1e9a25 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:31:50 -0700 Subject: [PATCH 09/11] fix(shell): a new card opens the glance panel, which draws cards whole and scrolls A card an agent published reached only a toast: the glance panel stayed closed, and when open it drew cards through the general translation, which re-lowers chips the Material way and drops their labels, and drew the shipping card's tiles light under white text. Now: - a new card opens the glance panel on a desktop (not over an open card window), showing the newest card first; a card the open panel shows posts no toast over it; - `glance_card::lower` uses Octoscript-Makepad's L0 translation, as the card window does: tiles in the kit's colours, chips with their labels; - a tile may be 440 pt tall (was 260), room for a whole action card with its buttons; - the wheel over the panel moves a card at a time, with "N more above / below" at its foot; - the panel's `#[redraw]` field was `draw_bg`, which it never draws; its area is the redraw target now; a press outside it that closes it is logged. Checked in a hidden --remote run with the demo Mail cards: the panel opened by itself on the UPS card (tiles, Track, Mark done) with "1 more above", and the wheel brought the request card (Reply, Mark done, Ask). Co-Authored-By: Claude Opus 5.5 --- crates/shell/src/glance_card.rs | 20 ++++----- crates/shell/src/glance_panel.rs | 69 ++++++++++++++++++++++++++++++-- crates/shell/src/glance_sheet.rs | 6 +-- crates/shell/src/lib.rs | 24 ++++++++--- 4 files changed, 99 insertions(+), 20 deletions(-) diff --git a/crates/shell/src/glance_card.rs b/crates/shell/src/glance_card.rs index 9a8bc092..95e7f956 100644 --- a/crates/shell/src/glance_card.rs +++ b/crates/shell/src/glance_card.rs @@ -14,8 +14,9 @@ //! //! **Tile size.** Width: the glance column (the phone's screen minus 40 pt, //! the desktop panel's 328 pt). Height: the card's own measured height, -//! clamped to [`TILE_MIN_HEIGHT`]..=[`TILE_MAX_HEIGHT`]; until the first -//! draw measures it, [`TILE_DEFAULT_HEIGHT`]. A taller card is clipped at the +//! 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`. //! //! **Policy.** A tile's isolate runs under the publishing app's resolved @@ -49,7 +50,7 @@ use std::cell::RefCell; use std::collections::HashMap; pub const TILE_MIN_HEIGHT: f64 = 72.0; -pub const TILE_MAX_HEIGHT: f64 = 260.0; +pub const TILE_MAX_HEIGHT: f64 = 440.0; pub const TILE_DEFAULT_HEIGHT: f64 = 148.0; /// Script instructions a tile with no grants (a native module's) may run /// over its life; an app's tile has its policy's budget. @@ -89,16 +90,17 @@ const MOODS: &[(&str, &str)] = &[ /// Lower an admitted L0 card with its data to the Splash body a tile draws: /// realize (the no-facts rule: every value from `data`), assemble with the -/// kit, evaluate the checked design VM, translate to Makepad UI. +/// kit, evaluate the checked design VM, translate to Makepad UI through +/// Octoscript-Makepad's L0 translation, as the card window does: it keeps +/// the kit's resolved sizes, its tiles' colours and its chips' labels (the +/// general translation re-lowered chips the Material way and dropped their +/// text, and drew the shipping card's tiles light under white text). pub fn lower(source: &str, data: &serde_json::Value) -> Result { - lower_report(source, octoscript_ui_l0::realize(source, data, Default::default()), false) + lower_report(source, octoscript_ui_l0::realize(source, data, Default::default()), true) } /// [`lower`], realized against a card's local state (its `InstanceStore`), -/// for the card window: through Octoscript-Makepad's L0 translation, which -/// keeps the kit's resolved sizes and its chips' labels (the general -/// translation [`lower`] uses re-lowers chips the Material way and drops -/// their text). +/// for the card window. pub fn lower_with_state(source: &str, data: &serde_json::Value, store: &octoscript_ui_l0::InstanceStore) -> Result { lower_report(source, octoscript_ui_l0::realize_with_state(source, data, store, Default::default()), true) } diff --git a/crates/shell/src/glance_panel.rs b/crates/shell/src/glance_panel.rs index 55a13c88..2d9ec984 100644 --- a/crates/shell/src/glance_panel.rs +++ b/crates/shell/src/glance_panel.rs @@ -72,10 +72,25 @@ pub struct ShellGlancePanel { logged: String, #[rust] tiles: GlanceTiles, + /// The panel's own area: what `redraw` repaints (`draw_bg` draws + /// nothing). + #[redraw] #[rust] area: Area, + /// The first card shown (the wheel moves it a card at a time), the + /// wheel's travel not yet turned into a step, and how many cards did + /// not fit below the last one drawn. + #[rust] + first: usize, + #[rust] + wheel: f64, + #[rust] + below: usize, } +/// How far the wheel travels for one card. +const WHEEL_STEP: f64 = 60.0; + impl ShellGlancePanel { /// Whether a pointer event at `p` is the panel's: anywhere in the open /// column (its cards' or its own), or a press anywhere (outside the @@ -83,6 +98,12 @@ impl ShellGlancePanel { pub fn owns_pointer(&self, p: DVec2, press: bool) -> bool { self.open && (press || contains(self.column, p)) } + /// Show the newest card first (a new card opened the panel). + pub fn show_newest(&mut self) { + let cards = crate::glance::shown(); + self.first = cards.iter().enumerate().max_by_key(|(_, c)| c.published_ms).map_or(0, |(i, _)| i); + self.wheel = 0.0; + } pub fn toggle(&mut self, cx: &mut Cx) { self.open = !self.open; self.redraw(cx); @@ -114,16 +135,20 @@ impl Widget for ShellGlancePanel { let ink = tok.notifications.surface.text; self.d.label_elided(cx, rect(x + PAD, top + 14.0, PANEL_WIDTH - PAD * 2.0, 24.0), true, 18.0, ink, HAlign::Left, "At a glance"); let cards = crate::glance::shown(); + self.first = self.first.min(cards.len().saturating_sub(1)); let status = if cards.is_empty() { "Nothing published yet".to_string() } else { format!("{} from your apps", cards.len()) }; self.d.label_elided(cx, rect(x + PAD, top + 38.0, PANEL_WIDTH - PAD * 2.0, 18.0), false, 12.0, alpha(ink, 0.65), HAlign::Left, &status); let mut y = top + HEADER; - let bottom = column.pos.y + column.size.y - PAD; - for card in &cards { + let bottom = column.pos.y + column.size.y - PAD - 20.0; + let mut drawn = 0; + for card in cards.iter().skip(self.first) { let key = card.key(); let h = crate::glance_card::tile_height(&key); - if y + h > bottom { + // The first card shown always draws (clipped by its cap). + if drawn > 0 && y + h > bottom { break; } + drawn += 1; let r = rect(x + PAD, y, PANEL_WIDTH - PAD * 2.0, h); self.tiles.draw(cx, &key, &card.app, card.contained, &card.body, r); let open = crate::glance_card::open_button(r); @@ -132,6 +157,17 @@ impl Widget for ShellGlancePanel { self.card_rects.push((r, card.open_app.clone(), card.route.clone())); y += h + GAP; } + // The cards the column does not show: the wheel brings them. + self.below = cards.len().saturating_sub(self.first + drawn); + let more = match (self.first, self.below) { + (0, 0) => String::new(), + (0, n) => format!("{n} more below \u{2193}"), + (n, 0) => format!("\u{2191} {n} more above"), + (a, b) => format!("\u{2191} {a} above \u{00b7} {b} below \u{2193}"), + }; + if !more.is_empty() { + self.d.label_elided(cx, rect(x + PAD, column.pos.y + column.size.y - PAD - 16.0, PANEL_WIDTH - PAD * 2.0, 16.0), false, 12.0, alpha(ink, 0.65), HAlign::Center, &more); + } } self.d.end_surface(cx); // Where the cards landed, once per change: evidence for a remote run. @@ -148,6 +184,32 @@ impl Widget for ShellGlancePanel { } fn handle_event(&mut self, cx: &mut Cx, event: &Event, _scope: &mut Scope) { + // The wheel over the column: a card at a time. + if let Event::Scroll(e) = event { + if self.open && contains(self.column, e.abs) { + self.wheel += e.scroll.y; + let mut moved = false; + while self.wheel >= WHEEL_STEP { + self.wheel -= WHEEL_STEP; + if self.below > 0 { + self.first += 1; + self.below -= 1; + moved = true; + } + } + while self.wheel <= -WHEEL_STEP { + self.wheel += WHEEL_STEP; + if self.first > 0 { + self.first -= 1; + moved = true; + } + } + if moved { + self.redraw(cx); + } + return; + } + } if let Event::MouseDown(e) = event { if !self.open { return; @@ -157,6 +219,7 @@ impl Widget for ShellGlancePanel { return; } else if !contains(self.column, e.abs) { self.open = false; + log!("wm: glance panel closed (a press outside it)"); self.redraw(cx); return; } diff --git a/crates/shell/src/glance_sheet.rs b/crates/shell/src/glance_sheet.rs index 87f39be6..8881c958 100644 --- a/crates/shell/src/glance_sheet.rs +++ b/crates/shell/src/glance_sheet.rs @@ -5,7 +5,7 @@ //! //! The window is [`SHEET_WIDTH`] wide and as tall as its card (measured each //! frame, so it follows the card from state to state), not clipped to the -//! glance tile's 260 pt: at most the screen less a margin, and a taller card +//! glance tile's cap: at most the screen less a margin, and a taller card //! scrolls inside. The card runs in its own isolate, under the publishing //! app's policy, as a glance tile does (glance_card.rs). //! @@ -249,8 +249,8 @@ mod tests { assert_eq!(card.size.y, 300.0, "the card fills the window: no empty area below it"); assert!(contains(sheet, close_rect(sheet).pos)); // Taller than the tile cap, it is not clipped there. - assert_eq!(card_rect(sheet_rect(screen, 400.0)).size.y, 400.0); - assert!(400.0 > crate::glance_card::TILE_MAX_HEIGHT); + let tall = crate::glance_card::TILE_MAX_HEIGHT + 100.0; + assert_eq!(card_rect(sheet_rect(screen, tall)).size.y, tall); // Taller than the screen, the window stops at the margin (the card // scrolls inside); a tiny card keeps the least height. assert_eq!(sheet_rect(screen, 5000.0).size.y, 800.0 - MARGIN * 2.0); diff --git a/crates/shell/src/lib.rs b/crates/shell/src/lib.rs index 3c0c8f86..cb7a096a 100644 --- a/crates/shell/src/lib.rs +++ b/crates/shell/src/lib.rs @@ -3998,10 +3998,10 @@ impl App { /// a toast on a desktop, which opens that card in the card window /// (glance_sheet.rs); a shade notification on the phone, which opens the /// glance page. - fn glance_notify(&mut self, cx: &mut Cx, note: &glance::GlanceNote) { + fn glance_notify(&mut self, cx: &mut Cx, note: &glance::GlanceNote, toast: bool) { let body = "Open the card at a glance"; let notes = self.ui.widget(cx, ids!(shell_notes)); - let toast = shell::notifications::Notification { + let card_toast = shell::notifications::Notification { id: 0, app: note.app.clone(), summary: note.title.clone(), @@ -4011,7 +4011,9 @@ impl App { // A card's toast stays its longest, so it can still be opened. requested: 30.0, }; - if let Some(id) = notes.borrow_mut::().map(|mut n| n.post(cx, toast)) { + if !toast { + log!("glance: {} shown in the open glance panel, no toast", note.key); + } else if let Some(id) = notes.borrow_mut::().map(|mut n| n.post(cx, card_toast)) { self.glance_toasts.record(id, ¬e.key); log!("glance: toast {id} opens {}", note.key); } @@ -6434,13 +6436,25 @@ impl App { // A card was published, replaced or withdrawn (glance.rs). if glance::generation() != self.glance_generation && self.state.is_some() { self.glance_generation = glance::generation(); - // The bar's glance button counts the cards. + // A new card opens the glance panel on a desktop, unless a + // card window is up; the bar's glance button counts the cards. + let fresh = glance::shown().iter().any(|c| c.published_ms > self.glance_seen_ms); + let sheet_open = self.ui.widget(cx, ids!(shell_glance_sheet)).borrow::().is_some_and(|s| s.is_open()); + if fresh && !sheet_open && !self.state_mut().style.target.mobile() && !self.glance_open(cx) { + log!("wm: a new glance card opens the glance panel"); + if let Some(mut panel) = self.ui.widget(cx, ids!(shell_glance)).borrow_mut::() { + panel.show_newest(); + } + self.set_glance_open(cx, true); + } self.update_bar(cx); self.redraw_all(cx); } if self.state.is_some() { + // A card the open panel shows needs no toast over it. + let toast = !self.glance_open(cx); for note in glance::take_notifications() { - self.glance_notify(cx, ¬e); + self.glance_notify(cx, ¬e, toast); } } if SignalToUI::check_and_clear_ui_signal() && self.state.is_some() { From aa22898449fefdc8d9920605ce3575c92d378d3a Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:37:11 -0700 Subject: [PATCH 10/11] feat(shell): the assistant's prompt edits like a text field: caret, selection, copy and paste The prompt's caret was pinned to the end. The composer now keeps a caret and a selection: arrows move it (Option by words, Command to the line's and, with Up/Down, the text's ends), Home and End, Shift selects, Command+A selects all; Up and Down go by the drawn lines, keeping the caret's x. A click places the caret, Shift+click or a drag selects, a double-click takes a word. Typing, a paste and an input method's composition go in at the caret over the selection; Backspace and Delete take the selection or the character beside the caret; Command+C and Command+X copy and cut the selection (the shell answers TextCopy and TextCut for the pane typing goes to). The selection is drawn behind the text, the caret where it is, and the lines shown follow the caret. Checked in a hidden --remote run: Option+arrows and Shift selected "hello card", a double-click took "Mail" and Backspace removed it, a drag selected across words. Copy and cut go through the platform's menu keys, which a remote run cannot send: covered by the composer's tests. Co-Authored-By: Claude Opus 5.5 --- crates/shell/src/app_chat/mod.rs | 40 ++ crates/shell/src/lib.rs | 41 ++ crates/shell/src/shell/ui.rs | 13 + crates/shell/src/system_chat/composer.rs | 473 +++++++++++++++++++++-- crates/shell/src/system_chat/mod.rs | 40 ++ crates/shell/src/system_chat/view.rs | 196 +++++++++- 6 files changed, 750 insertions(+), 53 deletions(-) diff --git a/crates/shell/src/app_chat/mod.rs b/crates/shell/src/app_chat/mod.rs index 24c63dc5..0c047a19 100644 --- a/crates/shell/src/app_chat/mod.rs +++ b/crates/shell/src/app_chat/mod.rs @@ -513,6 +513,30 @@ pub fn draft_state() -> makepad_widgets::makepad_platform::event::FullTextState with(|p| p.draft.state()) } +/// The prompt as it stands (its text, caret and selection), for drawing. +pub fn composer() -> crate::system_chat::composer::Composer { + with(|p| p.draft.clone()) +} + +/// Edit the prompt (a click placing the caret, a drag selecting). +pub fn edit_draft(f: impl FnOnce(&mut crate::system_chat::composer::Composer)) { + with(|p| { + f(&mut p.draft); + p.ui_generation += 1; + }); +} + +/// Copy or cut the prompt's selection (`None` without one). +pub fn copy_draft(cut: bool) -> Option { + with(|p| { + let text = p.draft.copy(cut); + if cut && text.is_some() { + p.ui_generation += 1; + } + text + }) +} + pub fn scroll() -> f64 { with(|p| p.scroll) } @@ -551,6 +575,22 @@ pub fn key(e: &KeyEvent) -> bool { p.draft.newline(); p.ui_generation += 1; }), + Key::Delete => with(|p| { + if p.draft.delete_forward() { + p.ui_generation += 1; + } + }), + Key::Move(m) => { + let layout = crate::system_chat::view::prompt_layout(crate::system_chat::composer::Pane::App); + with(|p| { + p.draft.motion(m, &layout); + p.ui_generation += 1; + }); + } + Key::SelectAll => with(|p| { + p.draft.select_all(); + p.ui_generation += 1; + }), Key::Stop => stop(), Key::Swallow => {} // Function keys (F8: the system chat) go on to the shell. diff --git a/crates/shell/src/lib.rs b/crates/shell/src/lib.rs index cb7a096a..03f73b97 100644 --- a/crates/shell/src/lib.rs +++ b/crates/shell/src/lib.rs @@ -3884,6 +3884,42 @@ impl App { true } + /// Copy or cut (Command+C, Command+X) in the chat pane typing goes to: + /// its prompt's selection, cut taking it out. Without a selection the + /// event is left to whoever else answers it. + fn chat_clipboard(&mut self, cx: &mut Cx, event: &Event) -> bool { + let (response, cut) = match event { + Event::TextCopy(e) => (e.response.clone(), false), + Event::TextCut(e) => (e.response.clone(), true), + _ => return false, + }; + let mut focused = [false; 2]; + for (i, pane) in [ids!(shell_app_chat), ids!(shell_system_chat)].into_iter().enumerate() { + let pane = self.ui.widget(cx, pane); + focused[i] = pane.borrow::().is_some_and(|p| p.has_keyboard(cx)); + } + use system_chat::composer::{text_target, Pane}; + let target = if focused[0] { + Some(Pane::App) + } else if focused[1] { + Some(Pane::System) + } else { + text_target(cx.key_focus().is_empty(), app_chat::is_focused(), system_chat::is_open()) + }; + let text = match target { + Some(Pane::App) => app_chat::copy_draft(cut), + Some(Pane::System) => system_chat::copy_draft(cut), + None => None, + }; + match text { + Some(text) => { + *response.borrow_mut() = Some(text); + true + } + None => false, + } + } + /// The system chat just opened on a desktop: its prompt takes the /// keyboard, so an input method composes in it at once (a phone opens /// it with a tap, which gives the keyboard itself). @@ -6429,6 +6465,11 @@ impl App { self.system_chat_changed(cx); return; } + // Copy and cut in a chat pane's prompt: its selection. + if self.state.is_some() && matches!(event, Event::TextCopy(_) | Event::TextCut(_)) && self.chat_clipboard(cx, event) { + self.system_chat_changed(cx); + return; + } if let Event::Signal = event { if self.state.is_some() { self.system_chat_changed(cx); diff --git a/crates/shell/src/shell/ui.rs b/crates/shell/src/shell/ui.rs index a6bc8507..03beb8e6 100644 --- a/crates/shell/src/shell/ui.rs +++ b/crates/shell/src/shell/ui.rs @@ -1658,6 +1658,19 @@ impl ShellDraw { } } + /// A text field's frame alone (its owner draws the text, the caret and + /// the selection): focused, hovered or at rest. + pub fn field_frame(&mut self, cx: &mut Cx2d, r: Rect, tok: &ShellTokens, focused: bool, hot: bool) { + let state = if focused { + CtrlState::Focus + } else if hot { + CtrlState::Hover + } else { + CtrlState::Normal + }; + self.control(cx, r, &tok.controls, state); + } + /// A text field of several lines: `lines` (wrapped already) from the /// top, `line_h` apart and centred as a block; the placeholder while /// there is no text; the caret after the last line when `caret` says. diff --git a/crates/shell/src/system_chat/composer.rs b/crates/shell/src/system_chat/composer.rs index af01c65f..4304f619 100644 --- a/crates/shell/src/system_chat/composer.rs +++ b/crates/shell/src/system_chat/composer.rs @@ -20,14 +20,24 @@ //! input method types (a phone keyboard's Enter) still sends: it is taken //! out and asks to send ([`Composer::take_submit`]), and the breaks the //! prompt already held stay. -//! -//! The caret is always at the end: the pane has no cursor of its own. +//! - **The caret and the selection**: arrows, Option for words, Command +//! for the line's (and Up/Down the text's) ends, Home and End, Shift to +//! select, Command+A; a click places the caret, a drag or Shift+click +//! selects, a double-click takes a word ([`Composer::motion`], +//! [`Composer::word_at`]). Typing, a paste and a composition go in at the +//! caret over the selection; Backspace and Delete take the selection or +//! the character beside the caret; copy and cut take the selection +//! ([`Composer::copy`]). use makepad_widgets::makepad_platform::event::{CharOffset, FullTextState, TextInputEvent}; #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Composer { text: String, + /// The caret, in characters. + cursor: usize, + /// The selection's other end, in characters, while one is made. + anchor: Option, /// The characters (not bytes) being composed, if a word is. composition: Option>, /// A line break was typed: send. @@ -37,6 +47,57 @@ pub struct Composer { key_break: bool, } +/// Where a caret moves ([`Composer::motion`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Dir { + Left, + Right, + Up, + Down, + Home, + End, +} + +/// A caret move from the keyboard: its direction, whether it selects +/// (Shift), goes by word (Option/Alt) or to the line's or the text's end +/// (Command). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Motion { + pub dir: Dir, + pub select: bool, + pub word: bool, + pub line: bool, +} + +/// One drawn line of the prompt: where it starts in the text (characters) +/// and the x of each of its character boundaries (one more than its +/// characters), for Up and Down and for a click ([`Composer::index_at`]). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct LineLayout { + pub start: usize, + pub xs: Vec, +} + +impl LineLayout { + fn len(&self) -> usize { + self.xs.len().saturating_sub(1) + } + /// The boundary nearest `x` on this line. + fn nearest(&self, x: f64) -> usize { + let mut best = 0; + for (i, bx) in self.xs.iter().enumerate() { + if (bx - x).abs() < (self.xs[best] - x).abs() { + best = i; + } + } + self.start + best + } +} + +fn is_word(c: char) -> bool { + c.is_alphanumeric() || c == '_' +} + impl Composer { pub fn text(&self) -> &str { &self.text @@ -46,9 +107,30 @@ impl Composer { self.text.is_empty() } + /// The caret, in characters. + pub fn cursor(&self) -> usize { + self.cursor + } + + /// The selection, in characters (empty when there is none). + pub fn selection(&self) -> std::ops::Range { + match self.anchor { + Some(a) if a != self.cursor => a.min(self.cursor)..a.max(self.cursor), + _ => self.cursor..self.cursor, + } + } + + /// The selected text ("" without a selection). + pub fn selected_text(&self) -> String { + let r = self.selection(); + self.text.chars().skip(r.start).take(r.len()).collect() + } + /// Empty the prompt (after Send) and give back what it held. pub fn take(&mut self) -> String { self.composition = None; + self.cursor = 0; + self.anchor = None; std::mem::take(&mut self.text) } @@ -56,12 +138,6 @@ impl Composer { self.take(); } - /// Backspace: the last character (a composition ends with it). - pub fn backspace(&mut self) -> bool { - self.composition = None; - self.text.pop().is_some() - } - fn chars(&self) -> usize { self.text.chars().count() } @@ -70,13 +146,206 @@ impl Composer { CharOffset(chars).to_byte_index(&self.text) } - /// Shift+Return: a line break at the end (the caret's place). + fn char_at(&self, i: usize) -> Option { + self.text.chars().nth(i) + } + + /// Put `with` in place of the characters `range`; the caret after it. + fn replace_chars(&mut self, range: std::ops::Range, with: &str) { + let (a, b) = (self.byte(range.start), self.byte(range.end)); + self.text.replace_range(a..b, with); + self.cursor = range.start + with.chars().count(); + self.anchor = None; + } + + /// Type `with` at the caret, over the selection. + fn insert(&mut self, with: &str) { + let at = self.selection(); + self.replace_chars(at, with); + } + + /// Backspace: the selection, else the character before the caret. + pub fn backspace(&mut self) -> bool { + self.composition = None; + let r = self.selection(); + if !r.is_empty() { + self.replace_chars(r, ""); + return true; + } + if self.cursor == 0 { + return false; + } + self.replace_chars(self.cursor - 1..self.cursor, ""); + true + } + + /// Delete: the selection, else the character after the caret. + pub fn delete_forward(&mut self) -> bool { + self.composition = None; + let r = self.selection(); + if !r.is_empty() { + self.replace_chars(r, ""); + return true; + } + if self.cursor >= self.chars() { + return false; + } + self.replace_chars(self.cursor..self.cursor + 1, ""); + true + } + + /// Shift+Return: a line break at the caret. pub fn newline(&mut self) { self.composition = None; - self.text.push('\n'); + self.insert("\n"); self.key_break = true; } + /// Move the caret to `index`, selecting from where it was when `select`. + pub fn move_to(&mut self, index: usize, select: bool) { + let index = index.min(self.chars()); + if select { + self.anchor.get_or_insert(self.cursor); + } else { + self.anchor = None; + } + self.cursor = index; + self.composition = None; + } + + /// Select from `from` to `to` (the caret at `to`). + pub fn select(&mut self, from: usize, to: usize) { + let len = self.chars(); + self.anchor = Some(from.min(len)); + self.cursor = to.min(len); + self.composition = None; + } + + pub fn select_all(&mut self) { + let len = self.chars(); + self.select(0, len); + } + + /// The word (or the run of spaces, or the one other character) at + /// `index`: what a double-click selects. + pub fn word_at(&self, index: usize) -> std::ops::Range { + let chars: Vec = self.text.chars().collect(); + let i = index.min(chars.len()); + let at = chars.get(i).or_else(|| i.checked_sub(1).and_then(|j| chars.get(j))); + let Some(&c) = at else { return i..i }; + let i = if chars.get(i).is_some() { i } else { i - 1 }; + let same = |d: char| if is_word(c) { is_word(d) } else if c.is_whitespace() { d.is_whitespace() && d != '\n' } else { false }; + if !is_word(c) && !c.is_whitespace() { + return i..i + 1; + } + let mut a = i; + while a > 0 && same(chars[a - 1]) { + a -= 1; + } + let mut b = i + 1; + while b < chars.len() && same(chars[b]) { + b += 1; + } + a..b + } + + /// The start and end of the line (between line breaks) the caret is on. + fn line_bounds(&self, index: usize) -> (usize, usize) { + let chars: Vec = self.text.chars().collect(); + let mut a = index.min(chars.len()); + while a > 0 && chars[a - 1] != '\n' { + a -= 1; + } + let mut b = index.min(chars.len()); + while b < chars.len() && chars[b] != '\n' { + b += 1; + } + (a, b) + } + + /// A caret move from the keyboard. Up and Down go by the drawn lines + /// (`layout`, keeping the caret's x); without it, to the text's ends. + pub fn motion(&mut self, m: Motion, layout: &[LineLayout]) { + let len = self.chars(); + let sel = self.selection(); + // Left or Right with a selection and no Shift: to its edge. + if !m.select && !sel.is_empty() && !m.word && !m.line && matches!(m.dir, Dir::Left | Dir::Right) { + self.move_to(if m.dir == Dir::Left { sel.start } else { sel.end }, false); + return; + } + let chars: Vec = self.text.chars().collect(); + let c = self.cursor; + let target = match m.dir { + Dir::Left if m.line => self.line_bounds(c).0, + Dir::Right if m.line => self.line_bounds(c).1, + Dir::Left if m.word => { + let mut i = c; + while i > 0 && !is_word(chars[i - 1]) { + i -= 1; + } + while i > 0 && is_word(chars[i - 1]) { + i -= 1; + } + i + } + Dir::Right if m.word => { + let mut i = c; + while i < len && !is_word(chars[i]) { + i += 1; + } + while i < len && is_word(chars[i]) { + i += 1; + } + i + } + Dir::Left => c.saturating_sub(1), + Dir::Right => (c + 1).min(len), + Dir::Home => if m.line { 0 } else { self.line_bounds(c).0 }, + Dir::End => if m.line { len } else { self.line_bounds(c).1 }, + Dir::Up | Dir::Down if m.line => if m.dir == Dir::Up { 0 } else { len }, + Dir::Up | Dir::Down => { + let here = layout.iter().rposition(|l| l.start <= c); + match here { + Some(i) => { + let line = &layout[i]; + let x = line.xs.get(c - line.start).copied().unwrap_or(0.0); + let next = if m.dir == Dir::Up { i.checked_sub(1) } else { Some(i + 1).filter(|j| *j < layout.len()) }; + match next { + Some(j) => layout[j].nearest(x).min(layout[j].start + layout[j].len()), + None => if m.dir == Dir::Up { 0 } else { len }, + } + } + None => if m.dir == Dir::Up { 0 } else { len }, + } + } + }; + self.move_to(target, m.select); + } + + /// The character boundary under `(line, x)` of a drawn layout: where a + /// click puts the caret. + pub fn index_at(layout: &[LineLayout], line: usize, x: f64) -> usize { + match layout.get(line).or_else(|| layout.last()) { + Some(l) => l.nearest(x), + None => 0, + } + } + + /// Copy (or cut): the selected text; cut takes it out. `None` without + /// a selection. + pub fn copy(&mut self, cut: bool) -> Option { + let r = self.selection(); + if r.is_empty() { + return None; + } + let text = self.selected_text(); + if cut { + self.composition = None; + self.replace_chars(r, ""); + } + Some(text) + } + /// One text-input event. True when the text changed. pub fn text_input(&mut self, event: &TextInputEvent) -> bool { let is_break = |c: char| c == '\n' || c == '\r'; @@ -85,30 +354,41 @@ impl Composer { return false; } let before = self.text.clone(); - self.apply(event); - if event.was_paste { - // A paste keeps its lines, as plain line breaks. - if self.text.contains('\r') { - self.text = self.text.replace("\r\n", "\n").replace('\r', "\n"); - } - } else { - // A typed line break (an input method's Enter) sends: the new - // breaks go, the prompt's own stay. + if event.full_state_sync.is_some() { + self.apply(event, &event.input); + // An input method's Enter typed into its editor state sends: + // the new breaks (by the caret) go, the prompt's own stay. let breaks = |s: &str| s.chars().filter(|c| is_break(*c)).count(); let mut typed = breaks(&self.text).saturating_sub(breaks(&before)); - if typed > 0 { + if typed > 0 && !event.was_paste { while typed > 0 { - match self.text.rfind(is_break) { - Some(i) => { - self.text.remove(i); - } - None => break, + let near = self.cursor.checked_sub(1).filter(|i| self.char_at(*i).is_some_and(is_break)); + let at = near.or_else(|| self.text.chars().collect::>().iter().rposition(|c| is_break(*c))); + let Some(i) = at else { break }; + let byte = self.byte(i); + self.text.remove(byte); + if self.cursor > i { + self.cursor -= 1; } typed -= 1; } + self.anchor = None; self.composition = None; self.submit = true; } + } else if event.was_paste { + // A paste keeps its lines, as plain line breaks. + let input = event.input.replace("\r\n", "\n").replace('\r', "\n"); + self.apply(event, &input); + } else if event.input.contains(is_break) { + // A typed line break (an input method's Enter) sends; the rest + // of what came with it is typed. + let input: String = event.input.chars().filter(|c| !is_break(*c)).collect(); + self.apply(event, &input); + self.composition = None; + self.submit = true; + } else { + self.apply(event, &event.input); } self.text != before } @@ -118,46 +398,49 @@ impl Composer { std::mem::take(&mut self.submit) } - fn apply(&mut self, event: &TextInputEvent) { + fn apply(&mut self, event: &TextInputEvent, input: &str) { if let Some(state) = &event.full_state_sync { // The input method's whole editor state. self.text = state.text.clone(); let end = self.chars(); + let (a, b) = (state.selection.start.0.min(end), state.selection.end.0.min(end)); + self.cursor = b; + self.anchor = (a != b).then_some(a); self.composition = state.composition.as_ref().map(|c| c.start.0.min(end)..c.end.0.min(end)).filter(|c| c.start < c.end); return; } if let Some((start, end)) = event.replace_range { // A replacement of a range (iOS autocorrect, a paste over it). let (a, b) = (start.0.min(end.0), start.0.max(end.0)); - let (a, b) = (self.byte(a), self.byte(b)); - self.text.replace_range(a..b, &event.input); + let len = self.chars(); + self.replace_chars(a.min(len)..b.min(len), input); self.composition = None; return; } - let input = event.input.as_str(); + let len = input.chars().count(); match self.composition.clone() { // A composition preview replaces the one before it; a commit // replaces it for good. Some(c) => { - let (a, b) = (self.byte(c.start), self.byte(c.end)); - self.text.replace_range(a..b, input); - let len = input.chars().count(); + self.replace_chars(c.clone(), input); self.composition = (event.replace_last && len > 0).then(|| c.start..c.start + len); } None => { - let start = self.chars(); - self.text.push_str(input); - let len = input.chars().count(); + let start = self.selection().start; + self.insert(input); self.composition = (event.replace_last && len > 0).then(|| start..start + len); } } } - /// The editor state the input method asks for: the text, the caret at - /// its end, and the composition. + /// The editor state the input method asks for: the text, the selection + /// (the caret when there is none), and the composition. pub fn state(&self) -> FullTextState { - let end = CharOffset(self.chars()); - FullTextState { text: self.text.clone(), selection: end..end, composition: self.composition.clone().map(|c| CharOffset(c.start)..CharOffset(c.end)) } + let r = match self.anchor { + Some(a) if a != self.cursor => CharOffset(a)..CharOffset(self.cursor), + _ => CharOffset(self.cursor)..CharOffset(self.cursor), + }; + FullTextState { text: self.text.clone(), selection: r, composition: self.composition.clone().map(|c| CharOffset(c.start)..CharOffset(c.end)) } } } @@ -214,6 +497,12 @@ pub enum Key { /// Shift+Return: a line break in the prompt. NewLine, Backspace, + /// Forward Delete. + Delete, + /// The caret moves (arrows, Home, End), selecting with Shift. + Move(Motion), + /// Command+A / Control+A. + SelectAll, /// Command+N / Control+N. New, /// Command+. / Control+. @@ -232,6 +521,19 @@ pub fn key(e: &makepad_widgets::KeyEvent) -> Key { KeyCode::ReturnKey if !e.modifiers.shift => Key::Send, KeyCode::ReturnKey if !command_key => Key::NewLine, KeyCode::Backspace => Key::Backspace, + KeyCode::Delete => Key::Delete, + KeyCode::KeyA if command_key => Key::SelectAll, + KeyCode::ArrowLeft | KeyCode::ArrowRight | KeyCode::ArrowUp | KeyCode::ArrowDown | KeyCode::Home | KeyCode::End => { + let dir = match e.key_code { + KeyCode::ArrowLeft => Dir::Left, + KeyCode::ArrowRight => Dir::Right, + KeyCode::ArrowUp => Dir::Up, + KeyCode::ArrowDown => Dir::Down, + KeyCode::Home => Dir::Home, + _ => Dir::End, + }; + Key::Move(Motion { dir, select: e.modifiers.shift, word: e.modifiers.alt, line: e.modifiers.logo }) + } KeyCode::KeyN if command_key => Key::New, KeyCode::Period if command_key => Key::Stop, other if !command_key && other.to_char(e.modifiers.shift).is_some() => Key::Swallow, @@ -366,6 +668,99 @@ mod tests { assert!(c.take_submit()); } + fn moved(dir: Dir, select: bool, word: bool, line: bool) -> Motion { + Motion { dir, select, word, line } + } + + /// The caret moves and typing goes in at it, over a selection. + #[test] + fn typing_goes_in_at_the_caret_over_the_selection() { + let mut c = Composer::default(); + c.text_input(&typed("hello world")); + c.motion(moved(Dir::Left, false, true, false), &[]); + assert_eq!(c.cursor(), 6, "Option+Left: the word's start"); + c.text_input(&typed("big ")); + assert_eq!(c.text(), "hello big world"); + // Shift+Option+Right selects "world"; typing replaces it. + c.motion(moved(Dir::Right, true, true, false), &[]); + assert_eq!(c.selected_text(), "world"); + c.text_input(&typed("there")); + assert_eq!(c.text(), "hello big there"); + // Left without Shift collapses a selection to its start. + c.select(6, 9); + c.motion(moved(Dir::Left, false, false, false), &[]); + assert_eq!((c.cursor(), c.selection().is_empty()), (6, true)); + // Backspace and Delete take the selection, else one character. + c.select(0, 6); + assert!(c.backspace()); + assert_eq!(c.text(), "big there"); + c.move_to(0, false); + assert!(c.delete_forward()); + assert_eq!(c.text(), "ig there"); + assert!(!c.backspace(), "nothing before the caret"); + } + + #[test] + fn home_end_and_command_go_to_the_lines_and_the_texts_ends() { + let mut c = Composer::default(); + c.text_input(&typed("one")); + c.newline(); + c.text_input(&typed("two")); + c.motion(moved(Dir::Home, false, false, false), &[]); + assert_eq!(c.cursor(), 4, "Home: this line's start"); + c.motion(moved(Dir::Right, false, false, true), &[]); + assert_eq!(c.cursor(), 7, "Command+Right: this line's end"); + c.motion(moved(Dir::Up, true, false, true), &[]); + assert_eq!(c.selection(), 0..7, "Shift+Command+Up selects to the text's start"); + c.select_all(); + assert_eq!(c.selected_text(), "one\ntwo"); + } + + /// Up and Down go by the drawn lines, keeping the caret's x. + #[test] + fn up_and_down_follow_the_drawn_lines() { + let mut c = Composer::default(); + c.text_input(&typed("abcd efgh")); + // Drawn as "abcd " and "efgh", 10 px a character. + let xs = |n: usize| (0..=n).map(|i| i as f64 * 10.0).collect::>(); + let layout = vec![LineLayout { start: 0, xs: xs(5) }, LineLayout { start: 5, xs: xs(4) }]; + c.move_to(7, false); + c.motion(moved(Dir::Up, false, false, false), &layout); + assert_eq!(c.cursor(), 2, "the same x on the line above"); + c.motion(moved(Dir::Down, true, false, false), &layout); + assert_eq!((c.cursor(), c.selection()), (7, 2..7)); + c.motion(moved(Dir::Down, false, false, false), &layout); + assert_eq!(c.cursor(), 9, "below the last line: the text's end"); + assert_eq!(Composer::index_at(&layout, 1, 23.0), 7, "a click at x 23 on the second line"); + } + + #[test] + fn a_double_click_takes_a_word_and_copy_and_cut_take_the_selection() { + let mut c = Composer::default(); + c.text_input(&typed("send mail.notify now")); + assert_eq!(c.word_at(6), 5..9, "a word stops at punctuation"); + assert_eq!(c.word_at(9), 9..10, "punctuation alone"); + assert_eq!(c.copy(false), None, "nothing selected"); + c.select(5, 16); + assert_eq!(c.copy(false).as_deref(), Some("mail.notify")); + assert_eq!(c.text(), "send mail.notify now"); + assert_eq!(c.copy(true).as_deref(), Some("mail.notify")); + assert_eq!((c.text(), c.cursor()), ("send now", 5)); + } + + /// An input method composes at the caret, mid-text too. + #[test] + fn a_composition_goes_in_at_the_caret() { + let mut c = Composer::default(); + c.text_input(&typed("ab")); + c.move_to(1, false); + c.text_input(&composing("ni")); + assert_eq!(c.text(), "anib"); + c.text_input(&typed("你")); + assert_eq!((c.text(), c.cursor()), ("a你b", 2)); + assert_eq!(c.state().selection, CharOffset(2)..CharOffset(2)); + } + /// The soft keyboard's Enter (its Send action) sends, in either pane, /// even when no pane holds the key focus at that moment. #[test] diff --git a/crates/shell/src/system_chat/mod.rs b/crates/shell/src/system_chat/mod.rs index 88d0732b..9ac7314e 100644 --- a/crates/shell/src/system_chat/mod.rs +++ b/crates/shell/src/system_chat/mod.rs @@ -358,6 +358,30 @@ pub fn draft_state() -> makepad_widgets::makepad_platform::event::FullTextState with(|c| c.draft.state()) } +/// The prompt as it stands (its text, caret and selection), for drawing. +pub fn composer() -> composer::Composer { + with(|c| c.draft.clone()) +} + +/// Edit the prompt (a click placing the caret, a drag selecting). +pub fn edit_draft(f: impl FnOnce(&mut composer::Composer)) { + with(|c| { + f(&mut c.draft); + c.ui_generation += 1; + }); +} + +/// Copy or cut the prompt's selection (`None` without one). +pub fn copy_draft(cut: bool) -> Option { + with(|c| { + let text = c.draft.copy(cut); + if cut && text.is_some() { + c.ui_generation += 1; + } + text + }) +} + pub fn scroll() -> f64 { with(|c| c.scroll) } @@ -475,6 +499,22 @@ pub fn key(e: &KeyEvent) -> bool { c.draft.newline(); c.ui_generation += 1; }), + composer::Key::Delete => with(|c| { + if c.draft.delete_forward() { + c.ui_generation += 1; + } + }), + composer::Key::Move(m) => { + let layout = view::prompt_layout(composer::Pane::System); + with(|c| { + c.draft.motion(m, &layout); + c.ui_generation += 1; + }); + } + composer::Key::SelectAll => with(|c| { + c.draft.select_all(); + c.ui_generation += 1; + }), composer::Key::New => new_conversation(), composer::Key::Stop => interrupt(), composer::Key::Swallow => {} diff --git a/crates/shell/src/system_chat/view.rs b/crates/shell/src/system_chat/view.rs index 3bacef02..79708669 100644 --- a/crates/shell/src/system_chat/view.rs +++ b/crates/shell/src/system_chat/view.rs @@ -52,13 +52,63 @@ use makepad_widgets::*; +use super::composer::{Composer, LineLayout, Pane}; use super::markdown as md; use super::model::{ApprovalState, ChatModel, Item, Phase, Role, ToolStatus}; use crate::approvals::view::Buttons; use crate::shell::ui::{contains, rect, wrap_styled, DrawShellFill, HAlign, ShellDraw}; use crate::shell::{alpha, MaterialTokens, ShellPalette, ShellTokens}; +use std::cell::RefCell; use std::rc::Rc; +thread_local! { + /// Each pane's prompt as last drawn (its lines, their characters' x): + /// Up and Down from the keyboard move by them ([`prompt_layout`]). + static PROMPT_LAYOUT: RefCell<[Vec; 2]> = RefCell::new([Vec::new(), Vec::new()]); +} + +/// A pane's prompt lines as last drawn. +pub fn prompt_layout(pane: Pane) -> Vec { + PROMPT_LAYOUT.with(|l| l.borrow()[pane as usize].clone()) +} + +/// Where the prompt's lines were drawn, for a click and a drag: the first +/// line shown, its top, the line height, the text's left edge and every +/// line's layout. +#[derive(Clone, Debug, Default)] +struct PromptGeom { + first: usize, + top: f64, + line_h: f64, + inner_x: f64, + layout: Vec, +} + +/// Each drawn prompt line's start in the text and its character +/// boundaries' x, measured at `px` (the drawn size). +fn layout_prompt(d: &mut ShellDraw, cx: &mut Cx2d, text: &str, lines: &[String], px: f64) -> Vec { + let chars: Vec = text.chars().collect(); + let mut at = 0; + let mut out = Vec::with_capacity(lines.len()); + for line in lines { + let mut xs = Vec::with_capacity(line.chars().count() + 1); + xs.push(0.0); + let mut w = 0.0; + for c in line.chars() { + let mut buf = [0u8; 4]; + w += d.measure(cx, false, px, c.encode_utf8(&mut buf)); + xs.push(w); + } + let n = xs.len() - 1; + out.push(LineLayout { start: at, xs }); + at += n; + if chars.get(at) == Some(&'\n') { + at += 1; + } + } + out +} + script_mod! { use mod.prelude.widgets_internal.* use mod.widgets.* @@ -621,10 +671,21 @@ pub struct ShellSystemChat { #[rust] blink_off: bool, #[rust] - blink_draft: String, + blink_draft: (String, usize, usize, usize), /// The transcript last laid out, and what it was laid out for. #[rust] laid_out: Option<(TranscriptKey, Rc>)>, + /// The prompt as last drawn, the first of its lines shown, a drag in it + /// selecting, and the last press in it (a second soon after on the + /// same place takes the word). + #[rust] + prompt: PromptGeom, + #[rust] + prompt_first: usize, + #[rust] + field_drag: bool, + #[rust] + field_press: (f64, usize), } /// The conversation a pane shows: the system chat's or an app's. @@ -653,6 +714,24 @@ impl Source { Source::App => crate::app_chat::draft(), } } + fn composer(self) -> Composer { + match self { + Source::System => super::composer(), + Source::App => crate::app_chat::composer(), + } + } + fn edit(self, f: impl FnOnce(&mut Composer)) { + match self { + Source::System => super::edit_draft(f), + Source::App => crate::app_chat::edit_draft(f), + } + } + fn pane(self) -> Pane { + match self { + Source::System => Pane::System, + Source::App => Pane::App, + } + } fn draft_state(self) -> makepad_widgets::makepad_platform::event::FullTextState { match self { Source::System => super::draft_state(), @@ -739,6 +818,18 @@ impl ShellSystemChat { hit_in(&self.hits, p) } + /// The prompt's character boundary under `p`: where a click puts the + /// caret. + fn prompt_index(&self, p: Vec2d) -> usize { + let g = &self.prompt; + if g.layout.is_empty() || g.line_h <= 0.0 { + return 0; + } + let row = ((p.y - g.top) / g.line_h).floor().max(0.0) as usize; + let line = (g.first + row).min(g.layout.len() - 1); + Composer::index_at(&g.layout, line, p.x - g.inner_x) + } + /// The frame's grip at `p`, if a control is not there first. fn grip_at(&self, p: Vec2d) -> Option { match self.hit_at(p) { @@ -754,6 +845,20 @@ impl ShellSystemChat { self.drag = None; return Outcome::Ignored; } + // A drag in the prompt selects, wherever it goes, until the button + // comes up. + if self.field_drag { + match event { + Event::MouseMove(e) => { + let i = self.prompt_index(e.abs); + source.edit(|c| c.move_to(i, true)); + self.redraw(cx); + return Outcome::Taken; + } + Event::MouseUp(_) => self.field_drag = false, + _ => {} + } + } // A drag of the frame has the pointer until the button comes up, // wherever it goes. if let Some(drag) = self.drag { @@ -811,6 +916,23 @@ impl ShellSystemChat { cx.set_cursor(grip.cursor()); return Outcome::Taken; } + // In the prompt: the caret goes where pressed (Shift: the + // selection grows to there), a drag selects, a second press + // on the same place takes the word. + if self.hit_at(e.abs) == Some(Hit::Field) { + let i = self.prompt_index(e.abs); + let double = e.time - self.field_press.0 < DOUBLE_PRESS_S && self.field_press.1 == i; + self.field_press = (e.time, i); + if double { + let word = source.composer().word_at(i); + source.edit(|c| c.select(word.start, word.end)); + } else { + let extend = e.modifiers.shift; + source.edit(|c| c.move_to(i, extend)); + self.field_drag = true; + } + self.redraw(cx); + } return self.press(cx, source, e.abs); } Event::MouseUp(e) => return self.release(cx, source, e.abs), @@ -966,9 +1088,11 @@ impl ShellSystemChat { } return; } - let draft = self.source().draft(); - if draft != self.blink_draft || self.blink_timer.is_empty() { - self.blink_draft = draft; + let c = self.source().composer(); + let sel = c.selection(); + let now = (c.text().to_string(), c.cursor(), sel.start, sel.end); + if now != self.blink_draft || self.blink_timer.is_empty() { + self.blink_draft = now; self.blink_off = false; cx.stop_timer(self.blink_timer); self.blink_timer = cx.start_timeout(BLINK_S); @@ -1172,28 +1296,72 @@ impl ShellSystemChat { let usable = source.usable(&model); self.usable = usable; let (label, hit, enabled) = composer_button(source.person_running(&model), usable, &draft); - let caret = self.typing(cx) && !self.blink_off; + let caret_on = self.typing(cx) && !self.blink_off; + let composer = source.composer(); { let mut b = Buttons { d: &mut self.d, tok, hover }; let bw = b.width(cx, label); let field_w = cw - bw - 8.0; let inner_w = (field_w - tok.spacing.control_padding_x * 2.0).max(1.0); let ts = b.d.text_scale(); - let mut lines = b.d.wrap_input(cx, tok.font.body * ts, &draft, inner_w); - if lines.len() > PROMPT_LINES { - // Past its height the prompt shows its last lines: the caret's. - lines.drain(..lines.len() - PROMPT_LINES); + let px = tok.font.body * ts; + let lines = b.d.wrap_input(cx, px, &draft, inner_w); + let layout = layout_prompt(b.d, cx, &draft, &lines, px); + // The lines shown: up to PROMPT_LINES, the caret's always among them. + let caret_line = layout.iter().rposition(|l| l.start <= composer.cursor()).unwrap_or(0); + let n = lines.len().clamp(1, PROMPT_LINES); + let mut first = self.prompt_first.min(lines.len().saturating_sub(n)); + if caret_line < first { + first = caret_line; + } else if caret_line >= first + n { + first = caret_line + 1 - n; } - let line_h = (tok.font.body * ts * 1.45).ceil(); - let field_h = FIELD_H.max(lines.len() as f64 * line_h + (FIELD_H - line_h)); + self.prompt_first = first; + let line_h = (px * 1.45).ceil(); + let field_h = FIELD_H.max(n as f64 * line_h + (FIELD_H - line_h)); field_y = bottom - field_h; let button = b.draw(cx, x + cw - bw, bottom - FIELD_H + (FIELD_H - 28.0) * 0.5, bw, label, enabled); hits.push((button, hit)); shown.push(format!("button: {label}")); let field = rect(x, field_y, field_w, field_h); - let placeholder = source.placeholder(model.open_question().is_some()); - // The input method's candidates go by the caret. - self.field = b.d.text_area(cx, field, &tok, &lines, line_h, &placeholder, true, hover == Some(field), ink, caret); + b.d.field_frame(cx, field, &tok, true, hover == Some(field)); + let inner_x = x + tok.spacing.control_padding_x; + let top = field_y + (field_h - n as f64 * line_h) * 0.5; + // The selection behind the text; a line break in it shows as a + // sliver past its line's end. + let sel = composer.selection(); + if !sel.is_empty() { + for (i, l) in layout.iter().enumerate().skip(first).take(n) { + let len = l.xs.len() - 1; + let (a, z) = (sel.start.max(l.start), sel.end.min(l.start + len)); + let past = sel.end > l.start + len && sel.start <= l.start + len && i + 1 < layout.len(); + if a < z || past { + let x1 = l.xs[a.saturating_sub(l.start).min(len)]; + let x2 = if z > a { l.xs[(z - l.start).min(len)] } else { x1 } + if past { 5.0 } else { 0.0 }; + let y = top + (i - first) as f64 * line_h; + b.d.solid(cx, rect(inner_x + x1, y, (x2 - x1).max(1.0), line_h), alpha(accent, 0.3)); + } + } + } + if draft.is_empty() { + let placeholder = source.placeholder(model.open_question().is_some()); + b.d.label_elided(cx, rect(inner_x, top, inner_w, line_h), false, tok.font.body, crate::shell::darker(ink, 1.6), HAlign::Left, &placeholder); + } else { + for (i, line) in lines.iter().enumerate().skip(first).take(n) { + // A line's trailing spaces hang past its end: not drawn. + b.d.label_elided(cx, rect(inner_x, top + (i - first) as f64 * line_h, inner_w, line_h), false, tok.font.body, ink, HAlign::Left, line.trim_end()); + } + } + // The caret where it is (none over a selection); the input + // method's candidates go by it. + let caret_x = layout.get(caret_line).map_or(0.0, |l| l.xs[(composer.cursor() - l.start).min(l.xs.len() - 1)]).min(inner_w); + let caret = rect(inner_x + caret_x + 1.0, top + (caret_line.saturating_sub(first)) as f64 * line_h + (line_h - px * 1.1) * 0.5, 1.0, px * 1.1); + if caret_on && sel.is_empty() { + b.d.solid(cx, caret, ink); + } + self.field = caret; + PROMPT_LAYOUT.with(|l| l.borrow_mut()[source.pane() as usize] = layout.clone()); + self.prompt = PromptGeom { first, top, line_h, inner_x, layout }; hits.push((field, Hit::Field)); } shown.push(format!("prompt: {draft}")); From 0e0393f07c9bf8404497d9bca361564133df9508 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:57:59 -0700 Subject: [PATCH 11/11] feat(calendar): a Calendar system app whose agent keeps events and puts event and agenda cards on the glance screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Calendar (`os.calendar`, desktop) joins Mail as a system app with an agent: its manifest declares the agent (read-only profile, ask_user_question) and `glance`, and `tools.json` gives it `calendar.events`, `calendar.add_event`, `calendar.remove_event` (destructive: the person approves), `calendar.notify` (an event card) and `calendar.agenda` (the next three events). Its peer has octos's per-app memory like Mail's. The tools run on a new host service, `octosense-calendar-service` (apps/calendar/host-service): events in the host's own directory (`/calendar/events.json`), local times, and fixed L0 cards (`event.card`: day and time tiles, place, notes; `agenda.card`) filled from the events and published through the glance service as Calendar, so the model never writes card code. Only Calendar may call it. App Hub's closed capability list names no `calendar`, so the shell's executor now runs a system app's own namespace on its own service (`os.calendar` → `calendar`) without a grant; any other family still needs one. Calendar's window says how to reach the agent: listing the events there waits for App Hub's `calendar` capability. It takes the `calendar` id, so Makepad's example Calendar leaves the catalog, as its Mail and Photos did. The approval sheets and the Approvals page now take the shell's theme and material too: lifted with the chat panes, they stay over them (the first-use sheet had drawn under the Assistant pane). Checked in a hidden --remote run with deepseek-v4-flash: the system agent asked for Calendar's agent, the first-use sheet showed over the pane, Allow, then Calendar's agent added "Dentist" (2026-10-02 15:00-16:00, Main St) and published its event card, which opened the glance panel. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 11 + Cargo.toml | 2 + apps/calendar/bundle/main.splash | 15 + apps/calendar/bundle/manifest.json | 13 + apps/calendar/bundle/tools.json | 228 ++++++++++ apps/calendar/host-service/Cargo.toml | 16 + .../host-service/resources/agenda.card | 36 ++ .../host-service/resources/event.card | 31 ++ apps/calendar/host-service/src/lib.rs | 416 ++++++++++++++++++ crates/shell/Cargo.toml | 4 +- crates/shell/src/approvals/settings_page.rs | 8 + crates/shell/src/approvals/view.rs | 8 + crates/shell/src/apps.rs | 13 +- crates/shell/src/desktop_app.rs | 8 + crates/shell/src/glance.rs | 17 + crates/shell/src/host_tools/script_apps.rs | 52 ++- desktop/README.md | 6 +- desktop/README.zh-CN.md | 6 +- desktop/config/apps.json | 8 - desktop/config/apps.overlay.json | 5 +- desktop/system-apps.json | 2 +- native-apps.json | 2 +- 22 files changed, 882 insertions(+), 25 deletions(-) create mode 100644 apps/calendar/bundle/main.splash create mode 100644 apps/calendar/bundle/manifest.json create mode 100644 apps/calendar/bundle/tools.json create mode 100644 apps/calendar/host-service/Cargo.toml create mode 100644 apps/calendar/host-service/resources/agenda.card create mode 100644 apps/calendar/host-service/resources/event.card create mode 100644 apps/calendar/host-service/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 1628cfea..d496f1ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7540,6 +7540,16 @@ dependencies = [ "serde_json", ] +[[package]] +name = "octosense-calendar-service" +version = "0.1.0" +dependencies = [ + "chrono", + "octosense-appstore", + "serde", + "serde_json", +] + [[package]] name = "octosense-home" version = "0.1.0" @@ -7697,6 +7707,7 @@ dependencies = [ "octosense-app-policy", "octosense-appcard", "octosense-appstore", + "octosense-calendar-service", "octosense-l0-chat", "octosense-mail-service", "octosense-news-service", diff --git a/Cargo.toml b/Cargo.toml index 77647257..78c83828 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ members = [ "apps/reference", "apps/news/host-service", "apps/mail/host-service", + "apps/calendar/host-service", "apps/ai-providers/config", "apps/ai-providers/host-service", "apps/appcard/module", @@ -142,6 +143,7 @@ octosense-l0-chat = { path = "crates/l0-chat" } octosense-llm-config = { path = "apps/ai-providers/config" } octosense-llm-service = { path = "apps/ai-providers/host-service" } octosense-mail-service = { path = "apps/mail/host-service" } +octosense-calendar-service = { path = "apps/calendar/host-service" } octosense-news-service = { path = "apps/news/host-service" } octos-app = { path = "apps/appcard/app/app", default-features = false } octos-app-store = { path = "apps/appcard/app/crates/octos-app-store" } diff --git a/apps/calendar/bundle/main.splash b/apps/calendar/bundle/main.splash new file mode 100644 index 00000000..ae30261d --- /dev/null +++ b/apps/calendar/bundle/main.splash @@ -0,0 +1,15 @@ +// Calendar: its assistant keeps the events (the `calendar` host service) and +// puts event and agenda cards on the glance screen. This window says how to +// reach it; listing the events here waits for App Hub's `calendar` capability. +let ink = #x1c1c1e +let secondary = #x8e8e93 + +SolidView{width: Fill height: Fill flow: Overlay draw_bg.color: #xffffff + View{width: Fill height: Fill flow: Down align: Align{x: 0.5 y: 0.5} spacing: 12 padding: 32 + Label{text: "Calendar" draw_text.color: ink draw_text.text_style: theme.font_bold{font_size: 32}} + Label{width: Fill align: Align{x: 0.5} text: "Calendar's assistant keeps your events. Ask it to add one, to show an event on your glance screen, or to put your agenda there." + draw_text.color: secondary draw_text.text_style.font_size: 13} + Label{width: Fill align: Align{x: 0.5} text: "Ask Calendar from the bar while this window is in front, or ask the assistant (F8) to ask Calendar." + draw_text.color: secondary draw_text.text_style.font_size: 12} + } +} diff --git a/apps/calendar/bundle/manifest.json b/apps/calendar/bundle/manifest.json new file mode 100644 index 00000000..7911f97d --- /dev/null +++ b/apps/calendar/bundle/manifest.json @@ -0,0 +1,13 @@ +{ + "schema": 1, + "id": "os.calendar", + "version": "1.0.0", + "name": "Calendar", + "integrity": { "bundle_blake3": "" }, + "capabilities": ["storage", "glance"], + "agent": { + "profile": "read-only", + "tools": ["ask_user_question"], + "model": { "needs": ["tool_calling"] } + } +} diff --git a/apps/calendar/bundle/tools.json b/apps/calendar/bundle/tools.json new file mode 100644 index 00000000..521d9f69 --- /dev/null +++ b/apps/calendar/bundle/tools.json @@ -0,0 +1,228 @@ +{ + "schema": 1, + "tools": [ + { + "name": "calendar.events", + "description": "List the person's calendar events, soonest first: id, title, start, end, location and notes. Times are local, like 2026-10-02T15:00. Filter with from and to (a time or a date); limit caps the list.", + "input_schema": { + "type": "object", + "properties": { + "from": { + "type": "string", + "maxLength": 32 + }, + "to": { + "type": "string", + "maxLength": 32 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 200 + } + }, + "additionalProperties": false + }, + "output_schema": { + "type": "object", + "properties": { + "events": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "required": [ + "events" + ] + }, + "risk": "read", + "background": true, + "private_data": true, + "implemented_by": "host-service" + }, + { + "name": "calendar.add_event", + "description": "Add an event to the person's calendar. start (and end) are local times like 2026-10-02T15:00, or a date like 2026-10-02 for the whole day. Answers the new event's id.", + "input_schema": { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "start": { + "type": "string", + "minLength": 1, + "maxLength": 32 + }, + "end": { + "type": "string", + "maxLength": 32 + }, + "location": { + "type": "string", + "maxLength": 120 + }, + "notes": { + "type": "string", + "maxLength": 600 + } + }, + "required": [ + "title", + "start" + ], + "additionalProperties": false + }, + "output_schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "start": { + "type": "string" + } + }, + "required": [ + "id" + ] + }, + "risk": "act", + "background": true, + "implemented_by": "host-service" + }, + { + "name": "calendar.remove_event", + "description": "Remove an event from the person's calendar by its id (from calendar.events). It is gone for good, so the person approves it.", + "input_schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + }, + "required": [ + "id" + ], + "additionalProperties": false + }, + "output_schema": { + "type": "object", + "properties": { + "removed": { + "type": "boolean" + } + }, + "required": [ + "removed" + ] + }, + "risk": "destructive", + "confirm": "host", + "background": true, + "implemented_by": "host-service" + }, + { + "name": "calendar.notify", + "description": "Show an event on the person's glance screen as an event card, with a notification: name one of the calendar's events by its id (event), or give a title and when (a local time like 2026-10-02T15:00, or words), with a location and notes if any. Calendar draws the card. The same card_id replaces an earlier card.", + "input_schema": { + "type": "object", + "properties": { + "event": { + "type": "string", + "maxLength": 64 + }, + "title": { + "type": "string", + "maxLength": 80 + }, + "when": { + "type": "string", + "maxLength": 80 + }, + "location": { + "type": "string", + "maxLength": 120 + }, + "notes": { + "type": "string", + "maxLength": 600 + }, + "card_id": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[A-Za-z0-9._-]+$" + }, + "priority": { + "type": "integer", + "minimum": 0, + "maximum": 100 + } + }, + "additionalProperties": false + }, + "output_schema": { + "type": "object", + "properties": { + "card_id": { + "type": "string" + }, + "replaced": { + "type": "boolean" + }, + "expires_at": { + "type": "integer" + } + }, + "required": [ + "card_id" + ] + }, + "risk": "act", + "background": true, + "implemented_by": "host-service" + }, + { + "name": "calendar.agenda", + "description": "Put the person's agenda on the glance screen: a card with the next three events within days (7 by default), with a notification. It replaces the previous agenda card.", + "input_schema": { + "type": "object", + "properties": { + "days": { + "type": "integer", + "minimum": 1, + "maximum": 60 + } + }, + "additionalProperties": false + }, + "output_schema": { + "type": "object", + "properties": { + "card_id": { + "type": "string" + }, + "replaced": { + "type": "boolean" + }, + "expires_at": { + "type": "integer" + } + }, + "required": [ + "card_id" + ] + }, + "risk": "act", + "background": true, + "implemented_by": "host-service" + } + ] +} diff --git a/apps/calendar/host-service/Cargo.toml b/apps/calendar/host-service/Cargo.toml new file mode 100644 index 00000000..6a22ebd0 --- /dev/null +++ b/apps/calendar/host-service/Cargo.toml @@ -0,0 +1,16 @@ +# octosense-calendar-service — the `calendar` host service: Calendar's events, +# kept by the host, and the event and agenda cards its agent puts on the +# glance screen. +[package] +name = "octosense-calendar-service" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +publish = false + +[dependencies] +# The same App Hub rev as Mail and News: one appstore, one service registry. +octosense-appstore = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +chrono = { workspace = true } diff --git a/apps/calendar/host-service/resources/agenda.card b/apps/calendar/host-service/resources/agenda.card new file mode 100644 index 00000000..5f2ec76f --- /dev/null +++ b/apps/calendar/host-service/resources/agenda.card @@ -0,0 +1,36 @@ +# ledger calendar.agenda@0.1.0 +# level: L0 +# profile: ui/l0 +# +# Calendar's agenda card (`calendar.agenda`): the next three events, as +# `os.calendar`. `day` is a `sys.dataset` record the service fills from the +# events (an event's title, and its time and place); the card states nothing +# else. + +source day sys.dataset(fields: [title, as_of, pick1_title, pick1_body, pick2_title, pick2_body, pick3_title, pick3_body, summary]) + +copy app { class: vocabulary, en: "Calendar" } + +view root Surface(pad: .tight) { + Col(gap: 8) { + Row(gap: 8, align: .center) { + Icon(name: .calendar, size: .row) + TextCaption(text: copy.app, width: .fill) + TextCaption(value: day.as_of) + } + TextTitle(text: day.title, width: .fill) + Row(gap: 8) { + TextRow(text: day.pick1_title, width: .fill) + TextCaption(text: day.pick1_body) + } + Row(gap: 8) { + TextRow(text: day.pick2_title, width: .fill) + TextCaption(text: day.pick2_body) + } + Row(gap: 8) { + TextRow(text: day.pick3_title, width: .fill) + TextCaption(text: day.pick3_body) + } + TextCaption(value: day.summary) + } +} diff --git a/apps/calendar/host-service/resources/event.card b/apps/calendar/host-service/resources/event.card new file mode 100644 index 00000000..abd44f72 --- /dev/null +++ b/apps/calendar/host-service/resources/event.card @@ -0,0 +1,31 @@ +# ledger calendar.event@0.2.0 +# level: L0 +# profile: ui/l0 +# +# Calendar's event card (`calendar.notify`): one event, as `os.calendar`. +# `ev` is a `sys.dataset` record the service fills from the event (its day +# and time on two tiles, its place, its notes); the card states nothing else. + +source ev sys.dataset(fields: [title, as_of, metric1_label, metric1_value, metric2_label, metric2_value, subtitle, summary]) + +copy app { class: vocabulary, en: "Calendar" } + +view root Surface(pad: .tight) { + Col(gap: 10) { + Row(gap: 8, align: .center) { + Icon(name: .calendar, size: .row) + TextCaption(text: copy.app, width: .fill) + TextCaption(value: ev.as_of) + } + TextTitle(text: ev.title, width: .fill) + Row(gap: 8) { + Tile(label: ev.metric1_label, value: ev.metric1_value) + Tile(label: ev.metric2_label, value: ev.metric2_value) + } + Row(gap: 8, align: .center) { + Icon(name: .location, size: .row) + TextRow(text: ev.subtitle, width: .fill) + } + TextBody(text: ev.summary, width: .fill) + } +} diff --git a/apps/calendar/host-service/src/lib.rs b/apps/calendar/host-service/src/lib.rs new file mode 100644 index 00000000..1927a217 --- /dev/null +++ b/apps/calendar/host-service/src/lib.rs @@ -0,0 +1,416 @@ +//! The `calendar` host service: Calendar's events, kept by the host, and +//! the cards Calendar's agent puts on the glance screen. +//! +//! | method | args | answer | +//! |---|---|---| +//! | `calendar.events` | `{from?, to?, limit?}` | `{events: [{id, title, start, end, location, notes}]}`, soonest first | +//! | `calendar.add_event` | `{title, start, end?, location?, notes?}` | `{id, start}` | +//! | `calendar.remove_event` | `{id}` | `{removed}` | +//! | `calendar.notify` | `{event}` or `{title, when, location?, notes?}`, and `{card_id?, priority?}` | `{card_id, replaced, expires_at}` once an event card is on the glance screen, with a notification | +//! | `calendar.agenda` | `{days?}` | the same, for an agenda card: the next three events within `days` (7) | +//! +//! Times are local: `2026-10-02T15:00`, `2026-10-02 15:00`, RFC 3339 (taken +//! to local time) or a date alone (the day, all day). Events live in the +//! host's own directory (`/calendar/events.json`), outside every +//! app's jail. +//! +//! **Only Calendar.** Calendar (`os.calendar`) is the one caller: App Hub's +//! capability list names no `calendar`, so no other app can be granted it, +//! and the shell runs Calendar's agent's `calendar.*` tools here as the +//! system app's own service (`host_tools::script_apps`). +//! +//! **Cards.** Each card is a fixed L0 card (`resources/event.card`, +//! `resources/agenda.card`) filled from the events, published through the +//! shell's glance service as the calling app ([`on_publish_card`]): the +//! model only names the event, it never writes card code. + +use chrono::{Duration, Local, NaiveDate, NaiveDateTime}; +use octosense_appstore::services::{HostService, Replier, ServiceCall, ServiceHost}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::path::Path; +use std::sync::{Arc, Mutex}; + +/// The one app this service answers. +pub const APP: &str = "os.calendar"; + +/// Calendar's event card (L0): one event's title, time, place and notes. +pub const EVENT_CARD: &str = include_str!("../resources/event.card"); +/// Calendar's agenda card (L0): the next three events. +pub const AGENDA_CARD: &str = include_str!("../resources/agenda.card"); + +const TITLE_MAX: usize = 80; +const PLACE_MAX: usize = 120; +const NOTES_MAX: usize = 600; +/// Events kept at most (the oldest past ones go first). +const EVENTS_MAX: usize = 500; + +/// Publishes one glance card as the calling app: `(app id, glance.publish +/// arguments)`. The shell installs it once at startup; it decides whether +/// the app may publish (the `glance` grant). +pub type CardPublisher = Arc Result + Send + Sync>; + +fn card_publisher() -> &'static Mutex> { + static PUBLISHER: std::sync::OnceLock>> = std::sync::OnceLock::new(); + PUBLISHER.get_or_init(Default::default) +} + +/// Install (or with `None` remove) what the cards are published through. +pub fn on_publish_card(publisher: Option) { + *card_publisher().lock().unwrap_or_else(|e| e.into_inner()) = publisher; +} + +/// One event. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Event { + pub id: String, + pub title: String, + /// Local, `YYYY-MM-DDTHH:MM` (sorts as it reads). + pub start: String, + #[serde(default)] + pub end: Option, + #[serde(default)] + pub location: String, + #[serde(default)] + pub notes: String, +} + +fn text<'a>(v: &'a Value, key: &str) -> &'a str { + v.get(key).and_then(Value::as_str).unwrap_or("").trim() +} + +/// A local time from what a person or a model writes. +pub fn parse_time(s: &str) -> Result { + let s = s.trim(); + for format in ["%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"] { + if let Ok(t) = NaiveDateTime::parse_from_str(s, format) { + return Ok(t); + } + } + if let Ok(t) = chrono::DateTime::parse_from_rfc3339(s) { + return Ok(t.with_timezone(&Local).naive_local()); + } + if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") { + return Ok(d.and_hms_opt(0, 0, 0).expect("midnight")); + } + Err(format!("{s:?} is not a time: write 2026-10-02T15:00 or 2026-10-02")) +} + +fn stamp(t: NaiveDateTime) -> String { + t.format("%Y-%m-%dT%H:%M").to_string() +} + +/// How an event's time reads on a card: `Fri 2 Oct · 15:00–16:00`, a day +/// alone for an event at midnight with no end. +pub fn when_text(start: &str, end: Option<&str>) -> String { + let Ok(s) = parse_time(start) else { return start.to_string() }; + let day = s.format("%a %-d %b").to_string(); + let all_day = s.format("%H:%M").to_string() == "00:00" && end.is_none(); + if all_day { + return day; + } + match end.and_then(|e| parse_time(e).ok()) { + Some(e) if e.date() == s.date() => format!("{day} \u{00b7} {}\u{2013}{}", s.format("%H:%M"), e.format("%H:%M")), + Some(e) => format!("{day} \u{00b7} {} \u{2013} {}", s.format("%H:%M"), e.format("%a %-d %b %H:%M")), + None => format!("{day} \u{00b7} {}", s.format("%H:%M")), + } +} + +/// An event's day and time for the card's two tiles: `Fri 2 Oct` and +/// `15:00–16:00` (`All day` for a day alone). +pub fn day_and_time(start: &str, end: Option<&str>) -> (String, String) { + let Ok(s) = parse_time(start) else { return (start.to_string(), String::new()) }; + let day = s.format("%a %-d %b").to_string(); + if s.format("%H:%M").to_string() == "00:00" && end.is_none() { + return (day, "All day".into()); + } + let time = match end.and_then(|e| parse_time(e).ok()) { + Some(e) if e.date() == s.date() => format!("{}\u{2013}{}", s.format("%H:%M"), e.format("%H:%M")), + Some(e) => format!("{} \u{2013} {}", s.format("%H:%M"), e.format("%-d %b %H:%M")), + None => s.format("%H:%M").to_string(), + }; + (day, time) +} + +fn store_path(host_dir: &Path) -> std::path::PathBuf { + host_dir.join("calendar").join("events.json") +} + +/// The events kept under `host_dir`, soonest first. +pub fn load(host_dir: &Path) -> Vec { + let mut events: Vec = std::fs::read(store_path(host_dir)) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + .unwrap_or_default(); + events.sort_by(|a, b| a.start.cmp(&b.start).then(a.id.cmp(&b.id))); + events +} + +fn save(host_dir: &Path, events: &[Event]) -> Result<(), String> { + let path = store_path(host_dir); + let dir = path.parent().expect("a parent"); + std::fs::create_dir_all(dir).map_err(|e| format!("Calendar cannot keep events: {e}"))?; + let tmp = dir.join(format!(".events-{}.json", std::process::id())); + std::fs::write(&tmp, serde_json::to_vec_pretty(events).expect("events serialize")).map_err(|e| format!("Calendar cannot keep events: {e}"))?; + std::fs::rename(&tmp, &path).map_err(|e| format!("Calendar cannot keep events: {e}")) +} + +fn bounded(s: &str, max: usize, what: &str) -> Result { + if s.chars().count() > max { + return Err(format!("{what} takes at most {max} characters.")); + } + Ok(s.to_string()) +} + +/// `calendar.notify`'s `glance.publish` arguments: the event card filled +/// from an event (its day and time, place and notes), opening Calendar, +/// with a notification. +pub fn event_card_args(title: &str, day: &str, time: &str, location: &str, notes: &str, card_id: &str, priority: i64) -> Value { + json!({ + "card_id": card_id, "title": format!("Calendar \u{00b7} {title}"), "source": EVENT_CARD, + "data": {"ev": { + "title": title, "as_of": "Event", + "metric1_label": "Day", "metric1_value": day, + "metric2_label": "Time", "metric2_value": if time.is_empty() { "\u{2014}" } else { time }, + "subtitle": if location.is_empty() { "No place given" } else { location }, + "summary": notes, + }}, + "priority": priority.clamp(0, 100), "open": {"app": "calendar"}, "notify": true, + }) +} + +/// `calendar.agenda`'s `glance.publish` arguments: the next three of +/// `events` (soonest first) on the agenda card. +pub fn agenda_card_args(events: &[Event], days: i64, now: NaiveDateTime) -> Value { + let mut day = json!({"title": "", "as_of": now.format("%a %-d %b").to_string(), "summary": ""}); + for i in 1..=3 { + let (title, body) = match events.get(i - 1) { + Some(e) => { + let when = when_text(&e.start, e.end.as_deref()); + let body = if e.location.is_empty() { when } else { format!("{when} \u{00b7} {}", e.location) }; + (e.title.clone(), body) + } + None => (String::new(), String::new()), + }; + day[format!("pick{i}_title")] = json!(title); + day[format!("pick{i}_body")] = json!(body); + } + day["title"] = json!(match events.len() { + 0 => format!("Nothing in the next {days} days"), + 1 => "Your next event".to_string(), + n => format!("Your next {} events", n.min(3)), + }); + if events.len() > 3 { + day["summary"] = json!(format!("and {} more in the next {days} days", events.len() - 3)); + } + json!({ + "card_id": "agenda", "title": "Calendar \u{00b7} Agenda", "source": AGENDA_CARD, + "data": {"day": day}, "priority": 60, "open": {"app": "calendar"}, "notify": true, + }) +} + +/// One call, answered: the events under `host_dir`, cards through +/// `publish`. `now` is local time. +pub fn handle(app: &str, method: &str, args: &Value, host_dir: &Path, now: NaiveDateTime, publish: Option<&CardPublisher>) -> Result { + if app != APP { + return Err("calendar is Calendar's own service".into()); + } + let publish_card = |card: Value| match publish { + Some(p) => p(app, card), + None => Err("This device shows no glance cards.".into()), + }; + match method { + "events" => { + let from = match text(args, "from") { + "" => None, + s => Some(stamp(parse_time(s)?)), + }; + let to = match text(args, "to") { + "" => None, + s => Some(stamp(parse_time(s)?)), + }; + let limit = args["limit"].as_u64().unwrap_or(50).clamp(1, 200) as usize; + let events: Vec = load(host_dir) + .into_iter() + .filter(|e| from.as_ref().map_or(true, |f| e.end.as_ref().unwrap_or(&e.start) >= f)) + .filter(|e| to.as_ref().map_or(true, |t| &e.start <= t)) + .take(limit) + .collect(); + Ok(json!({"events": events})) + } + "add_event" => { + let title = bounded(text(args, "title"), TITLE_MAX, "A title")?; + if title.is_empty() { + return Err("Give the event a title.".into()); + } + let start = stamp(parse_time(text(args, "start"))?); + let end = match text(args, "end") { + "" => None, + s => { + let end = stamp(parse_time(s)?); + if end < start { + return Err("The event ends before it starts.".into()); + } + Some(end) + } + }; + let event = Event { + id: format!("ev-{}", now.and_utc().timestamp_millis()), + title, + start: start.clone(), + end, + location: bounded(text(args, "location"), PLACE_MAX, "A place")?, + notes: bounded(text(args, "notes"), NOTES_MAX, "Notes")?, + }; + let mut events = load(host_dir); + let id = if events.iter().any(|e| e.id == event.id) { format!("{}-{}", event.id, events.len()) } else { event.id.clone() }; + events.push(Event { id: id.clone(), ..event }); + // Kept bounded: the oldest past events go first. + while events.len() > EVENTS_MAX { + events.remove(0); + } + save(host_dir, &events)?; + Ok(json!({"id": id, "start": start})) + } + "remove_event" => { + let id = text(args, "id"); + let mut events = load(host_dir); + let before = events.len(); + events.retain(|e| e.id != id); + let removed = events.len() != before; + if removed { + save(host_dir, &events)?; + } + Ok(json!({"removed": removed})) + } + "notify" => { + let priority = args["priority"].as_i64().unwrap_or(70); + let (title, (day, time), location, notes, card_id) = match text(args, "event") { + "" => { + let title = bounded(text(args, "title"), TITLE_MAX, "A title")?; + let when = bounded(text(args, "when"), TITLE_MAX, "When")?; + if title.is_empty() || when.is_empty() { + return Err("Name an event (`event`), or give a title and when.".into()); + } + // A time the service reads goes on the tiles; words as written. + let day_time = if parse_time(&when).is_ok() { day_and_time(&when, None) } else { (when, String::new()) }; + let id = format!("event-{}", now.and_utc().timestamp_millis()); + (title, day_time, bounded(text(args, "location"), PLACE_MAX, "A place")?, bounded(text(args, "notes"), NOTES_MAX, "Notes")?, id) + } + id => { + let event = load(host_dir).into_iter().find(|e| e.id == id).ok_or_else(|| format!("There is no event {id:?}."))?; + let day_time = day_and_time(&event.start, event.end.as_deref()); + (event.title, day_time, event.location, event.notes, event.id) + } + }; + let card_id = match text(args, "card_id") { + "" => card_id, + own => own.to_string(), + }; + publish_card(event_card_args(&title, &day, &time, &location, ¬es, &card_id, priority)) + } + "agenda" => { + let days = args["days"].as_i64().unwrap_or(7).clamp(1, 60); + let from = stamp(now); + let to = stamp(now + Duration::days(days)); + let events: Vec = load(host_dir).into_iter().filter(|e| e.end.as_ref().unwrap_or(&e.start) >= &from && e.start <= to).collect(); + publish_card(agenda_card_args(&events, days, now)) + } + other => Err(format!("calendar has no method {other:?}")), + } +} + +/// The service the Card runner (and the agent's tools) call. +pub struct CalendarService; + +impl HostService for CalendarService { + fn family(&self) -> &'static str { + "calendar" + } + + fn call(&mut self, call: ServiceCall, reply: Replier, _host: &mut dyn ServiceHost) { + let publisher = card_publisher().lock().unwrap_or_else(|e| e.into_inner()).clone(); + let now = Local::now().naive_local(); + let answer = handle(&call.app_id, call.method(), &call.args, &call.host_dir, now, publisher.as_ref()); + reply.send(answer); + } +} + +/// Offer the service (the shell, once at startup). +pub fn register() { + octosense_appstore::services::register_host_service(Box::new(CalendarService)); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dir(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("calendar-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + d + } + + fn at(s: &str) -> NaiveDateTime { + parse_time(s).unwrap() + } + + #[test] + fn times_read_as_people_and_models_write_them() { + assert_eq!(stamp(at("2026-10-02T15:00")), "2026-10-02T15:00"); + assert_eq!(stamp(at("2026-10-02 15:00")), "2026-10-02T15:00"); + assert_eq!(stamp(at("2026-10-02")), "2026-10-02T00:00"); + assert!(parse_time("tomorrow").is_err()); + assert_eq!(when_text("2026-10-02T15:00", Some("2026-10-02T16:30")), "Fri 2 Oct \u{00b7} 15:00\u{2013}16:30"); + assert_eq!(when_text("2026-10-02", None), "Fri 2 Oct", "a day alone"); + assert_eq!(day_and_time("2026-10-02T15:00", Some("2026-10-02T16:30")), ("Fri 2 Oct".to_string(), "15:00\u{2013}16:30".to_string())); + assert_eq!(day_and_time("2026-10-02", None).1, "All day"); + } + + #[test] + fn events_are_kept_listed_soonest_first_and_removed() { + let d = dir("events"); + let now = at("2026-10-01T09:00"); + let add = |title: &str, start: &str| handle(APP, "add_event", &json!({"title": title, "start": start, "location": "Room 4"}), &d, now, None).unwrap(); + let late = add("Review", "2026-10-03T10:00"); + add("Standup", "2026-10-02T09:30"); + let listed = handle(APP, "events", &json!({}), &d, now, None).unwrap(); + let titles: Vec<&str> = listed["events"].as_array().unwrap().iter().map(|e| e["title"].as_str().unwrap()).collect(); + assert_eq!(titles, ["Standup", "Review"]); + let only = handle(APP, "events", &json!({"from": "2026-10-03"}), &d, now, None).unwrap(); + assert_eq!(only["events"].as_array().unwrap().len(), 1); + assert_eq!(handle(APP, "remove_event", &json!({"id": late["id"]}), &d, now, None).unwrap()["removed"], true); + assert_eq!(load(&d).len(), 1); + assert!(handle(APP, "add_event", &json!({"title": "", "start": "2026-10-02"}), &d, now, None).is_err()); + assert!(handle(APP, "add_event", &json!({"title": "x", "start": "2026-10-02T10:00", "end": "2026-10-02T09:00"}), &d, now, None).is_err()); + assert!(handle("os.mail", "events", &json!({}), &d, now, None).is_err(), "Calendar's own service"); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn cards_are_the_fixed_cards_filled_from_the_events() { + let d = dir("cards"); + let now = at("2026-10-01T09:00"); + let seen: Arc>> = Arc::default(); + let log = seen.clone(); + let publisher: CardPublisher = Arc::new(move |app, args| { + log.lock().unwrap().push((app.to_string(), args.clone())); + Ok(json!({"card_id": args["card_id"], "replaced": false})) + }); + let added = handle(APP, "add_event", &json!({"title": "Dentist", "start": "2026-10-02T15:00", "end": "2026-10-02T16:00", "location": "Main St"}), &d, now, None).unwrap(); + handle(APP, "notify", &json!({"event": added["id"]}), &d, now, Some(&publisher)).unwrap(); + handle(APP, "notify", &json!({"title": "Lunch", "when": "2026-10-02T12:30"}), &d, now, Some(&publisher)).unwrap(); + handle(APP, "agenda", &json!({}), &d, now, Some(&publisher)).unwrap(); + let seen = seen.lock().unwrap(); + assert_eq!(seen.len(), 3); + assert!(seen.iter().all(|(app, args)| app == APP && args["notify"] == true && args["open"]["app"] == "calendar")); + let ev = &seen[0].1["data"]["ev"]; + assert_eq!((ev["title"].as_str(), ev["metric1_value"].as_str(), ev["metric2_value"].as_str(), ev["subtitle"].as_str()), (Some("Dentist"), Some("Fri 2 Oct"), Some("15:00\u{2013}16:00"), Some("Main St"))); + assert_eq!(seen[0].1["source"], EVENT_CARD); + assert_eq!((seen[1].1["data"]["ev"]["metric2_value"].as_str(), seen[1].1["data"]["ev"]["subtitle"].as_str()), (Some("12:30"), Some("No place given"))); + let day = &seen[2].1["data"]["day"]; + assert_eq!((day["title"].as_str(), day["pick1_title"].as_str(), day["pick2_title"].as_str()), (Some("Your next event"), Some("Dentist"), Some(""))); + assert!(handle(APP, "notify", &json!({}), &d, now, Some(&publisher)).is_err(), "names no event"); + let _ = std::fs::remove_dir_all(&d); + } +} diff --git a/crates/shell/Cargo.toml b/crates/shell/Cargo.toml index 1df3e370..54a2292f 100644 --- a/crates/shell/Cargo.toml +++ b/crates/shell/Cargo.toml @@ -50,6 +50,7 @@ octoscript-ui-l0 = { workspace = true } octosense-l0-chat = { workspace = true } octosense-ai-host = { workspace = true } octosense-mail-service = { workspace = true, optional = true } +octosense-calendar-service = { workspace = true, optional = true } octosense-news-service = { workspace = true, optional = true } # App Hub's host-service registry: the `glance` service registers into it. octosense-appstore = { workspace = true, optional = true } @@ -93,6 +94,7 @@ octosense-app-hub = { workspace = true } octosense-app-contract = { workspace = true } octosense-mail-service = { workspace = true } octosense-news-service = { workspace = true } +octosense-calendar-service = { workspace = true } octosense-ai-host = { workspace = true, features = ["llm"] } [features] @@ -113,7 +115,7 @@ app-reference = ["dep:octosense-reference"] app-sheets = ["dep:makepad-sheets"] app-terminal = ["dep:makepad-terminal"] app-appcard = ["dep:octosense-appcard", "octos-core"] -app-hub = ["dep:octosense-app-hub-app", "dep:octosense-appstore", "dep:octosense-app-policy", "dep:octosense-app-hub", "dep:octosense-app-contract", "dep:octosense-mail-service", "dep:octosense-news-service", "octosense-ai-host/llm"] +app-hub = ["dep:octosense-app-hub-app", "dep:octosense-appstore", "dep:octosense-app-policy", "dep:octosense-app-hub", "dep:octosense-app-contract", "dep:octosense-mail-service", "dep:octosense-news-service", "dep:octosense-calendar-service", "octosense-ai-host/llm"] # END native-apps: features # The aichat module, seated in the pane in-process. app-aichat = ["dep:makepad-aichat"] diff --git a/crates/shell/src/approvals/settings_page.rs b/crates/shell/src/approvals/settings_page.rs index c03b9322..6ae0b226 100644 --- a/crates/shell/src/approvals/settings_page.rs +++ b/crates/shell/src/approvals/settings_page.rs @@ -173,6 +173,14 @@ pub struct ShellApprovalsSettings { } impl ShellApprovalsSettings { + /// The shell's theme (light or dark) and material, as its other + /// surfaces take them (desktop_app.rs `apply_material_to_chrome`): a + /// sheet is lifted with them, so it stays over the chat panes. + pub fn set_material(&mut self, m: crate::shell::MaterialTokens, palette: Option) { + self.d.set_material(m); + self.d.set_palette(palette); + } + fn hit_at(&self, p: Vec2d) -> Option { self.hits.iter().find(|(r, h)| *h != Hit::Page && contains(*r, p)).or_else(|| self.hits.iter().find(|(r, _)| contains(*r, p))).map(|(_, h)| h.clone()) } diff --git a/crates/shell/src/approvals/view.rs b/crates/shell/src/approvals/view.rs index 5e97101c..96543ea0 100644 --- a/crates/shell/src/approvals/view.rs +++ b/crates/shell/src/approvals/view.rs @@ -209,6 +209,14 @@ pub struct ShellApprovals { } impl ShellApprovals { + /// The shell's theme (light or dark) and material, as its other + /// surfaces take them (desktop_app.rs `apply_material_to_chrome`): a + /// sheet is lifted with them, so it stays over the chat panes. + pub fn set_material(&mut self, m: crate::shell::MaterialTokens, palette: Option) { + self.d.set_material(m); + self.d.set_palette(palette); + } + fn hit_at(&self, p: Vec2d) -> Option { // Buttons before the card they sit on. self.hits.iter().rev().find(|(r, h)| *h != Hit::Card && contains(*r, p)).or_else(|| self.hits.iter().find(|(r, _)| contains(*r, p))).map(|(_, h)| h.clone()) diff --git a/crates/shell/src/apps.rs b/crates/shell/src/apps.rs index 6d87215d..66161ede 100644 --- a/crates/shell/src/apps.rs +++ b/crates/shell/src/apps.rs @@ -169,12 +169,17 @@ fn register_host_services() { } else { octosense_mail_service::register() } - // `mail.notify` (Mail's agent's tool): a notice card published as - // the calling app, only when its manifest was granted `glance`. - octosense_mail_service::on_publish_card(Some(std::sync::Arc::new(|app: &str, args| { + // `mail.notify` (Mail's agent's tool) and Calendar's cards: a card + // published as the calling app, only when its manifest was granted + // `glance`. + let publish = |app: &str, args: serde_json::Value| { let caller = crate::glance::Caller::Contained { app: app.to_string(), granted: crate::host_tools::script_apps::grants(app, "glance") }; crate::glance::request(&caller, "glance.publish", &args) - }))); + }; + octosense_mail_service::on_publish_card(Some(std::sync::Arc::new(publish))); + // Calendar's events and cards (its agent's `calendar.*` tools). + octosense_calendar_service::register(); + octosense_calendar_service::on_publish_card(Some(std::sync::Arc::new(publish))); register_news(); }); } diff --git a/crates/shell/src/desktop_app.rs b/crates/shell/src/desktop_app.rs index 1bed0228..9611270e 100644 --- a/crates/shell/src/desktop_app.rs +++ b/crates/shell/src/desktop_app.rs @@ -69,6 +69,14 @@ impl App { w.set_material(material, palette); } } + // The approval sheets and the Approvals page, after the chat panes + // in the tree: lifted with the same material, they stay over them. + if let Some(mut w) = self.ui.widget(cx, ids!(shell_approvals)).borrow_mut::() { + w.set_material(material, palette); + } + if let Some(mut w) = self.ui.widget(cx, ids!(shell_approvals_settings)).borrow_mut::() { + w.set_material(material, palette); + } if let Some(mut w) = self.ui.widget(cx, ids!(shell_ai_pane)).borrow_mut::() { w.set_material(material); } diff --git a/crates/shell/src/glance.rs b/crates/shell/src/glance.rs index f7c0508f..86fa07f6 100644 --- a/crates/shell/src/glance.rs +++ b/crates/shell/src/glance.rs @@ -652,6 +652,23 @@ mod tests { /// Mail's `mail.notify` card is a valid L0 card the store admits as /// Mail's, and a Mail without the `glance` grant cannot publish it. + /// Calendar's event and agenda cards are valid L0 cards the store + /// admits as Calendar's. + #[cfg(any(feature = "app-hub", native_mobile))] + #[test] + fn calendars_cards_are_admitted_as_its_own() { + use octosense_calendar_service as cal; + let mut store = GlanceStore::default(); + let event = cal::event_card_args("Dentist", "Fri 2 Oct", "15:00\u{2013}16:00", "Main St", "Bring the form", "ev-1", 70); + assert!(store.publish(&Caller::granted("os.calendar"), &event, 1_000).is_ok()); + let now = cal::parse_time("2026-10-01T09:00").unwrap(); + let events = vec![cal::Event { id: "a".into(), title: "Standup".into(), start: "2026-10-02T09:30".into(), end: None, location: String::new(), notes: String::new() }]; + let agenda = cal::agenda_card_args(&events, 7, now); + assert!(store.publish(&Caller::granted("os.calendar"), &agenda, 1_000).is_ok()); + let shown = store.shown(1_000, SHOWN_CARDS); + assert!(shown.iter().all(|c| c.app == "os.calendar" && c.open_app == "calendar")); + } + #[cfg(any(feature = "app-hub", native_mobile))] #[test] fn mails_notice_card_is_admitted_as_mails_own() { diff --git a/crates/shell/src/host_tools/script_apps.rs b/crates/shell/src/host_tools/script_apps.rs index af06f71d..4ebedb0c 100644 --- a/crates/shell/src/host_tools/script_apps.rs +++ b/crates/shell/src/host_tools/script_apps.rs @@ -18,7 +18,9 @@ //! is `implemented_by: "host-service"` runs on the host service of its //! namespace (`news.list` → the `news` service), exactly as the app's own //! `host.request("news.list", …)` would: with the app's identity, never -//! from a sheet, and only when the app's manifest was granted that family. +//! from a sheet, and only when the app's manifest was granted that family, +//! or the family is a system app's own namespace (`os.calendar` and its +//! `calendar` service, which ship with the shell). //! A tool the app's own script implements (`implemented_by: "app"`) needs //! the app open, and is refused visibly until the Card runner can take it. //! Answers arrive on App Hub's reply queue; [`poll`] (from @@ -200,7 +202,11 @@ impl ToolExecutor for HostServiceExecutor { return; } let family = call.name.split('.').next().unwrap_or(""); - if !self.families.contains(family) { + // A system app's own namespace is its own host service: both ship + // with the shell (Calendar's `calendar`, which App Hub's closed + // capability list does not name). Any other family needs the grant. + let own = self.app.strip_prefix(octosense_appstore::system::SYSTEM_ID_PREFIX) == Some(family); + if !self.families.contains(family) && !own { reply.finish(ToolOutcome::error("not_granted", format!("{} was not granted the {family} service", self.app))); return; } @@ -313,6 +319,24 @@ pub(crate) mod tests { let _ = std::fs::remove_dir_all(dir); } + /// Calendar's bundle gives its agent its five tools, all on its own + /// `calendar` host service; removing an event is destructive (the + /// person approves it); Calendar is granted `glance`. + #[test] + fn calendar_offers_its_tools_from_its_bundle() { + let dir = stamped_bundle("calendar", "tools", |_, _| {}); + let loaded = from_bundle(&dir).unwrap(); + let names: Vec<&str> = loaded.tools.iter().filter_map(|t| t["name"].as_str()).collect(); + assert_eq!(names, ["calendar.events", "calendar.add_event", "calendar.remove_event", "calendar.notify", "calendar.agenda"]); + assert_eq!(loaded.host_service_tools.len(), 5); + assert!(loaded.tools.iter().all(|t| t["input_schema"]["type"] == "object" && t["output_schema"]["type"] == "object")); + let remove = loaded.tools.iter().find(|t| t["name"] == "calendar.remove_event").unwrap(); + assert_eq!(remove["risk"], "destructive"); + assert!(loaded.families.contains("glance") && !loaded.families.contains("calendar"), "no `calendar` capability exists to grant"); + assert_eq!(loaded.generic, ["ask_user_question"]); + let _ = std::fs::remove_dir_all(dir); + } + /// The manifest's `agent.tools`: a plain name is a kernel tool, and App /// Hub admits only `ask_user_question` (any other, `web_search` or /// octos's shell, refuses the bundle); dotted ones are other apps' @@ -466,4 +490,28 @@ pub(crate) mod tests { exec.execute(call("g3probe.in_script"), r); assert_eq!(sent.lock().unwrap()[0]["error"]["kind"], "app_tool_unavailable"); } + + /// A system app's own namespace is its own service, granted or not + /// (Calendar's `calendar`); a store app's needs the grant like any + /// other family. + #[test] + fn a_system_apps_own_namespace_needs_no_grant() { + octosense_appstore::services::register_host_service(Box::new(Probe)); + let run = |app: &str| { + let exec = HostServiceExecutor { app: app.into(), tools: ["g3probe.echo".to_string()].into_iter().collect(), families: Default::default(), host_dir: std::env::temp_dir() }; + let (r, sent) = reply(); + exec.execute(call("g3probe.echo"), r); + for _ in 0..50 { + poll(); + if !sent.lock().unwrap().is_empty() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + let got = sent.lock().unwrap().clone(); + got + }; + assert_eq!(run("os.g3probe")[0]["ok"], true, "its own service"); + assert_eq!(run("com.example.g3probe")[0]["error"]["kind"], "not_granted", "a store app is granted nothing by its id"); + } } diff --git a/desktop/README.md b/desktop/README.md index ebb49d89..a9a79019 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -13,7 +13,7 @@ The desktop shell of [OctoSense](https://github.com/OctoSense-org), the agent sh | Where | Role for the desktop | | --- | --- | | [`../phone/`](../phone/README.md) | Home, the phone shell. Same app model, same runtime, same system apps. | -| [`../apps/`](../apps/README.md) | News, Photos, Maps, Mail, AI providers and YouTube bundles (Camera is phone-only), the Mail and `llm` host services, the AppCard assistant (`octos-app`, opt-in, not shipped by default), and Reference. | +| [`../apps/`](../apps/README.md) | News, Photos, Maps, Mail, Calendar, AI providers and YouTube bundles (Camera is phone-only), the Mail, Calendar and `llm` host services, the AppCard assistant (`octos-app`, opt-in, not shipped by default), and Reference. | | [`../crates/`](../crates/) | The shell itself (`crates/shell`, package `octosense-shell`, which this package wraps), its AI services (`crates/ai-host`), the octos kernel service (`crates/kernel`, package `octosense-kernel`) and the app-agent broker (`crates/app-peers`). | | [OctoSense-App-Hub](https://github.com/OctoSense-org/OctoSense-App-Hub) | The signed catalog, the store and the Card runner. Linked as the Git crate `octosense-app-hub-app`. | | [OctoScript-App-Design-Flow](https://github.com/OctoSense-org/OctoScript-App-Design-Flow) | Where apps are designed, built and published to the App Hub. | @@ -125,12 +125,12 @@ The launcher lists four kinds of app together: | Kind | Comes from | Runs as | Launcher id | | --- | --- | --- | --- | -| **System apps**: News, Photos, Maps, Mail, AI providers, YouTube (Camera ships on the phone only) | `../apps//bundle`, selected by `system-apps.json`, packed into the build | Contained Splash programs in App Hub's Card runner, each in its own isolate under the capabilities its manifest asks for | `` (manifest id `os.`) | +| **System apps**: News, Photos, Maps, Mail, Calendar (desktop only), AI providers, YouTube (Camera ships on the phone only) | `../apps//bundle`, selected by `system-apps.json`, packed into the build | Contained Splash programs in App Hub's Card runner, each in its own isolate under the capabilities its manifest asks for | `` (manifest id `os.`) | | **Store apps** | The signed App Hub catalog, installed from the store (`apphub`) | The same Card runner. Every open is checked against the catalog; an update closes old instances. | `hub:` | | **Native modules** | Rust crates linked into this binary | In-process `AppModule`s. Trusted code only: App Hub, AppCard, Rinx, Reference and the `app-*` features. | module id | | **Developer programs** | `config/apps.json` | Separate processes in tiles, over Makepad's `--stdin-loop` hosting protocol, built on first launch | catalog `id` | -Precedence: a linked native module beats a system app of the same id, and a system app beats a catalog row of the same id. That is why Makepad's example Mail and Photos are dropped from the shipped catalogs (`drop` in `config/apps.overlay.json`). +Precedence: a linked native module beats a system app of the same id, and a system app beats a catalog row of the same id. That is why Makepad's example Mail, Photos and Calendar are dropped from the shipped catalogs (`drop` in `config/apps.overlay.json`). ### Containment and permissions diff --git a/desktop/README.zh-CN.md b/desktop/README.zh-CN.md index 9dc8bd22..34bf5635 100644 --- a/desktop/README.zh-CN.md +++ b/desktop/README.zh-CN.md @@ -13,7 +13,7 @@ | 位置 | 对桌面端的作用 | | --- | --- | | [`../phone/`](../phone/README.zh-CN.md) | Home,手机 Shell。相同的应用模型、相同的运行时、相同的系统应用。 | -| [`../apps/`](../apps/README.zh-CN.md) | 新闻、相册、地图、相机、邮件、AI 提供商和 YouTube 的应用包,邮件与 `llm` 宿主服务,AppCard 助手(`octos-app`,需显式启用,默认不随产品发布),以及 Reference。 | +| [`../apps/`](../apps/README.zh-CN.md) | 新闻、相册、地图、相机、邮件、日历、AI 提供商和 YouTube 的应用包,邮件、日历与 `llm` 宿主服务,AppCard 助手(`octos-app`,需显式启用,默认不随产品发布),以及 Reference。 | | [`../crates/`](../crates/) | Shell 本身(`crates/shell`,包名 `octosense-shell`,本包包装它)、它的 AI 服务(`crates/ai-host`)、octos 内核服务(`crates/kernel`,包名 `octosense-kernel`)和应用与 Agent 之间的代理(`crates/app-peers`)。 | | [OctoSense-App-Hub](https://github.com/OctoSense-org/OctoSense-App-Hub) | 签名目录、商店和 Card 运行器。以 Git crate `octosense-app-hub-app` 链接。 | | [OctoScript-App-Design-Flow](https://github.com/OctoSense-org/OctoScript-App-Design-Flow) | 设计、构建应用并发布到 App Hub 的地方。 | @@ -125,12 +125,12 @@ launcher 把四类应用列在一起: | 类别 | 来源 | 运行方式 | Launcher id | | --- | --- | --- | --- | -| **系统应用**:新闻、相册、地图、相机、邮件、AI 提供商、YouTube | `../apps//bundle`,由 `system-apps.json` 选择,打包进构建 | App Hub 的 Card 运行器中隔离运行的 Splash 程序,每个应用一个 isolate,只拥有其清单申请的能力 | ``(清单 id `os.`) | +| **系统应用**:新闻、相册、地图、相机、邮件、日历(仅桌面)、AI 提供商、YouTube | `../apps//bundle`,由 `system-apps.json` 选择,打包进构建 | App Hub 的 Card 运行器中隔离运行的 Splash 程序,每个应用一个 isolate,只拥有其清单申请的能力 | ``(清单 id `os.`) | | **商店应用** | 签名的 App Hub 目录,从商店(`apphub`)安装 | 同一个 Card 运行器。每次打开都会对照目录检查;更新会关闭旧实例。 | `hub:` | | **原生模块** | 链接进本二进制的 Rust crate | 进程内的 `AppModule`。只允许受信任的代码:App Hub、AppCard、Rinx、Reference 以及各 `app-*` feature。 | 模块 id | | **开发者程序** | `config/apps.json` | tile 中的独立进程,通过 Makepad 的 `--stdin-loop` 托管协议运行,首次启动时构建 | 目录 `id` | -优先级:已链接的原生模块优先于同 id 的系统应用,系统应用优先于同 id 的目录条目。因此 Makepad 示例中的 Mail 和 Photos 已从随附目录中移除(`config/apps.overlay.json` 中的 `drop`)。 +优先级:已链接的原生模块优先于同 id 的系统应用,系统应用优先于同 id 的目录条目。因此 Makepad 示例中的 Mail、Photos 和 Calendar 已从随附目录中移除(`config/apps.overlay.json` 中的 `drop`)。 ### 隔离与权限 diff --git a/desktop/config/apps.json b/desktop/config/apps.json index 57c57bd1..ce0dad68 100644 --- a/desktop/config/apps.json +++ b/desktop/config/apps.json @@ -90,14 +90,6 @@ "bin": "notes", "policy": "focus" }, - { - "id": "calendar", - "label": "Calendar", - "source": "makepad", - "package": "makepad-calendar", - "bin": "calendar", - "policy": "focus" - }, { "id": "reminders", "label": "Reminders", diff --git a/desktop/config/apps.overlay.json b/desktop/config/apps.overlay.json index d9e1cec8..5bce41fb 100644 --- a/desktop/config/apps.overlay.json +++ b/desktop/config/apps.overlay.json @@ -20,11 +20,12 @@ "bin": "route" } }, - "_drop": "mail and photos are OctoSense system apps (OctoSense-System-Apps, run by App Hub's Card runner) and take these ids; Makepad's example programs of the same name are not shipped.", + "_drop": "mail, photos and calendar are OctoSense system apps (run by App Hub's Card runner) and take these ids; Makepad's example programs of the same name are not shipped.", "drop": [ "scope", "mail", - "photos" + "photos", + "calendar" ], "append": [ {"id": "image", "label": "Image Viewer", "source": "makepad", "package": "makepad-image", "bin": "image", "policy": "new"}, diff --git a/desktop/system-apps.json b/desktop/system-apps.json index 9463b915..200f9ec8 100644 --- a/desktop/system-apps.json +++ b/desktop/system-apps.json @@ -1,6 +1,6 @@ { "schema": 1, "source": "../apps", - "apps": ["news", "photos", "maps", "mail", "ai-providers", "youtube"], + "apps": ["news", "photos", "maps", "mail", "calendar", "ai-providers", "youtube"], "assets": {} } diff --git a/native-apps.json b/native-apps.json index 67ddfe12..1041d592 100644 --- a/native-apps.json +++ b/native-apps.json @@ -104,7 +104,7 @@ "bin": null, "default_features": true, "crate_features": [], - "implies": ["dep:octosense-appstore", "dep:octosense-app-policy", "dep:octosense-app-hub", "dep:octosense-app-contract", "dep:octosense-mail-service", "dep:octosense-news-service", "octosense-ai-host/llm"], + "implies": ["dep:octosense-appstore", "dep:octosense-app-policy", "dep:octosense-app-hub", "dep:octosense-app-contract", "dep:octosense-mail-service", "dep:octosense-news-service", "dep:octosense-calendar-service", "octosense-ai-host/llm"], "hosting": { "macos": "module", "windows": "module", "linux": "module", "android": "module", "ios": "module", "ohos": "module" }, "shells": { "desktop": "default", "phone": "default" }, "native_mobile": "always",