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/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/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/app_chat/mod.rs b/crates/shell/src/app_chat/mod.rs index eab20482..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) } @@ -547,6 +571,26 @@ pub fn key(e: &KeyEvent) -> bool { p.ui_generation += 1; } }), + Key::NewLine => with(|p| { + 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/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 2c439802..66161ede 100644 --- a/crates/shell/src/apps.rs +++ b/crates/shell/src/apps.rs @@ -169,6 +169,17 @@ fn register_host_services() { } else { octosense_mail_service::register() } + // `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 dbc310af..9611270e 100644 --- a/crates/shell/src/desktop_app.rs +++ b/crates/shell/src/desktop_app.rs @@ -63,6 +63,20 @@ 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); + } + } + // 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 33a0d339..86fa07f6 100644 --- a/crates/shell/src/glance.rs +++ b/crates/shell/src/glance.rs @@ -650,6 +650,38 @@ 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. + /// 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() { + 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/glance_card.rs b/crates/shell/src/glance_card.rs index 0667a5ba..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) } @@ -125,9 +127,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 +645,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/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/host_tools/script_apps.rs b/crates/shell/src/host_tools/script_apps.rs index e5ffbc1e..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 @@ -132,6 +134,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 +150,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 @@ -186,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; } @@ -282,6 +302,41 @@ 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); + } + + /// 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' @@ -435,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/crates/shell/src/lib.rs b/crates/shell/src/lib.rs index b135bbad..03f73b97 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,9 +3553,15 @@ 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(); + self.focus_system_chat(cx); self.system_chat_changed(cx); return; } @@ -3643,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 { @@ -3863,6 +3884,64 @@ 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). + 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. + 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() { @@ -3955,10 +4034,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(), @@ -3968,7 +4047,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); } @@ -4055,6 +4136,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)); @@ -5776,6 +5865,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 @@ -6131,8 +6224,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)) @@ -6273,6 +6381,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; } @@ -6356,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); @@ -6363,11 +6477,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(); + // 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() { diff --git a/crates/shell/src/shell/bar.rs b/crates/shell/src/shell/bar.rs index 84a43932..79348d2b 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)); } @@ -631,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(); @@ -857,7 +870,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 = news; + } + } self.d.icon_centered(cx, *ico, cell, canvas, color); + // The glance button's count, at the bell's corner: a tag in the + // 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; + 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, 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); + } + } self.hits.push((*module, cell)); } @@ -896,6 +930,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()); } 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/shell/ui.rs b/crates/shell/src/shell/ui.rs index 0e3680ff..03beb8e6 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() { @@ -1000,41 +996,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 +1010,18 @@ 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. + 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, @@ -1617,6 +1596,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,20 +1642,86 @@ 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, + ); + } + } + + /// 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. + /// 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() { + // 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 * 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); + } + at } /// `Ui/PanelSectionHeader.qml`: bold caption in `darker(fg, 1.4)`. @@ -1851,3 +1914,248 @@ 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 +} + +/// 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::*; + + /// 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", "发送"]); + } + + /// 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"]); + 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..4304f619 100644 --- a/crates/shell/src/system_chat/composer.rs +++ b/crates/shell/src/system_chat/composer.rs @@ -15,21 +15,87 @@ //! 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`]). -//! -//! The caret is always at the end: the pane has no cursor of its own. +//! - **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 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. 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, +} + +/// 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 { @@ -41,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) } @@ -51,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() } @@ -65,14 +146,249 @@ impl Composer { CharOffset(chars).to_byte_index(&self.text) } + 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.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'; + // 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'); + 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 && !event.was_paste { + while typed > 0 { + 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 } @@ -82,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)) } } } @@ -175,7 +494,15 @@ 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, + /// 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+. @@ -192,7 +519,21 @@ 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::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, @@ -305,6 +646,121 @@ 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()); + } + + 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/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..9ac7314e 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; @@ -356,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) } @@ -469,6 +495,26 @@ pub fn key(e: &KeyEvent) -> bool { c.ui_generation += 1; } }), + composer::Key::NewLine => with(|c| { + 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 235f10d1..79708669 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 @@ -12,6 +13,24 @@ //! ([`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 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. +//! - **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. @@ -28,15 +47,67 @@ //! 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::*; +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, 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::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.* @@ -57,6 +128,143 @@ 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; +/// 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; +/// 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. +#[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))) +} + +/// 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 { + 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 +278,8 @@ enum Hit { OpenProviders, Field, Pane, + /// The desktop pane's frame ([`Grip`]). + Grip(Grip), } /// What a pointer event did. @@ -116,7 +326,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 @@ -235,12 +445,150 @@ 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: 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). +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(); + // 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 +/// 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.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; + } + 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)] @@ -263,6 +611,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] @@ -297,6 +648,44 @@ pub struct ShellSystemChat { /// hidden-window runs' logs). #[rust] pub shown: Vec, + /// 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). + #[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 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, 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. @@ -325,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(), @@ -411,12 +818,65 @@ 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) { + 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 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 { + 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 +885,56 @@ 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 where it opens. + 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; + } + // 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), Event::TouchUpdate(e) => { use makepad_widgets::makepad_platform::event::TouchState; @@ -556,6 +1058,64 @@ 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 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); + } + } + + /// 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 } @@ -608,7 +1168,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; } } @@ -621,12 +1181,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 { @@ -674,13 +1239,17 @@ 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. - 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); @@ -721,22 +1290,78 @@ 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); + 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 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 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; + } + 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, 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); + let field = rect(x, field_y, field_w, field_h); + 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}")); @@ -785,9 +1410,25 @@ 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 { + 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(), + _ => { + 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); @@ -799,9 +1440,47 @@ 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() { + 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. + let ts = self.d.text_scale(); + let mut rx = lx; + for (text, style) in &line.runs { + let bold = line.bold || style.bold; + let r = rect(rx, line_top, (x + cw - rx).max(0.0) + 2.0, lh); + let run_color = if style.link { accent } else { color }; + 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; + } } if model.items.is_empty() && usable { let hint = source.hint(); @@ -814,6 +1493,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 +1510,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,15 +1522,21 @@ 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 } 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 = cx.turtle().rect(); + 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); @@ -873,6 +1562,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) { @@ -924,6 +1625,58 @@ 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"); + } + + /// 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] + 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(); 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",