From a459938700f17e1092f3f8feeee020a335418b02 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:30:27 -0700 Subject: [PATCH 01/13] mobile: the shell gesture contract every phone surface consumes Types only: ShellGesture, GestureKind, ExclusionZones and GestureMetrics. The recognizer and the surfaces that read it land in their own modules. Co-Authored-By: Claude Fable 5.1 --- src/main.rs | 1 + src/mobile_gestures.rs | 85 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 src/mobile_gestures.rs diff --git a/src/main.rs b/src/main.rs index 70c75520..e15b7d4b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,6 +23,7 @@ mod desktop_app; mod snap; mod mobile; mod mobile_surface; +mod mobile_gestures; mod mobile_app; mod mobile_tiles; mod scene; diff --git a/src/mobile_gestures.rs b/src/mobile_gestures.rs new file mode 100644 index 00000000..78572603 --- /dev/null +++ b/src/mobile_gestures.rs @@ -0,0 +1,85 @@ +//! The phone shell's gesture contract. Every mobile surface (shade, pages, +//! island, tile groups, switcher) is reached through one of these gestures, +//! so the recognizer is the single owner of edge and bottom touches and every +//! surface consumes the same enum. Exclusion zones let an app own an edge +//! (a map's pan, a slider's track) so shell gestures never fight it. +//! +//! Contract for the feature modules (`mobile_shade`, `mobile_pages`, +//! `mobile_island`, `mobile_groups`): read `ShellGesture` values from +//! `PhoneState::gesture_out`, never touch raw fingers. +use makepad_widgets::*; + +/// Which screen edge a gesture started from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Edge { Top, Bottom, Left, Right } + +/// Which half of the top edge the shade was pulled from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ShadeSide { Notifications, Controls } + +/// Horizontal direction, in screen space. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Dir { Left, Right } + +/// A shell gesture, delivered every frame while it is in progress and once +/// more as `Commit` or `Cancel`. `progress` is 0..1 of the distance that +/// commits the gesture; surfaces animate from it directly. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ShellGesture { + /// Bottom edge, upward: go home. Held still near the end: the switcher. + HomeUp { progress: f64, held: bool }, + /// A flick along the bottom edge: the previous or next app. + QuickSwitch { dir: Dir, progress: f64 }, + /// Inward from a side edge: back, with a predictive preview. + Back { edge: Edge, progress: f64 }, + /// Downward from the top edge: the shade, notifications or controls. + ShadePull { side: ShadeSide, progress: f64 }, + /// Horizontal on the home page: previous or next page (the glance page + /// is the page left of the first). + PageSwipe { dir: Dir, progress: f64 }, + /// Downward in the middle of the home page: search. + HomeSearch { progress: f64 }, + /// The gesture reached its commit distance and the finger lifted. + Commit(GestureKind), + /// The finger lifted short of the commit distance, or another finger + /// took over: surfaces animate back. + Cancel(GestureKind), +} + +/// The gesture family, for `Commit` and `Cancel`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GestureKind { HomeUp, Switcher, QuickSwitch(Dir), Back, Shade(ShadeSide), Page(Dir), HomeSearch } + +/// A rect an app owns: shell gestures that start inside it are not +/// recognised, so a map can pan from the edge and a slider can be dragged. +#[derive(Clone, Debug, PartialEq)] +pub struct ExclusionZone { pub rect: Rect, pub edges: [bool; 4] } + +/// Exclusion zones per client, in screen space, refreshed each frame by the +/// surface that draws the client. +#[derive(Clone, Debug, Default)] +pub struct ExclusionZones { pub zones: Vec } + +impl ExclusionZones { + pub fn clear(&mut self) { self.zones.clear(); } + pub fn add(&mut self, rect: Rect, edges: [bool; 4]) { self.zones.push(ExclusionZone { rect, edges }); } + /// True when a gesture from `edge` starting at `p` must be left to the app. + pub fn excludes(&self, p: Vec2d, edge: Edge) -> bool { + let i = match edge { Edge::Top => 0, Edge::Bottom => 1, Edge::Left => 2, Edge::Right => 3 }; + self.zones.iter().any(|z| z.edges[i] && z.rect.contains(p)) + } +} + +/// Edge band widths and commit distances, in points; one place to tune. +#[derive(Clone, Copy, Debug)] +pub struct GestureMetrics { + pub edge_band: f64, + pub bottom_band: f64, + pub top_band: f64, + pub commit_distance: f64, + pub hold_time: f64, + pub flick_velocity: f64, +} +impl Default for GestureMetrics { + fn default() -> Self { Self { edge_band: 24.0, bottom_band: 28.0, top_band: 32.0, commit_distance: 120.0, hold_time: 0.28, flick_velocity: 900.0 } } +} From 4802c119b911aabdbbb730401db35a3c39f38141 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:30:48 -0700 Subject: [PATCH 02/13] mobile: PhoneState carries the recognised gesture and the exclusion zones Co-Authored-By: Claude Fable 5.1 --- src/mobile.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/mobile.rs b/src/mobile.rs index 88c1585a..bba09f53 100644 --- a/src/mobile.rs +++ b/src/mobile.rs @@ -56,6 +56,12 @@ pub struct PhoneState { /// The home page's live app tiles (mobile_tiles.rs): which client shows /// which tile and in which face. pub tiles: HomeTiles, + /// The shell gesture recognised this frame, for every mobile surface to + /// read (mobile_gestures.rs owns it; surfaces never touch raw fingers). + pub gesture_out: Option, + /// Rects apps own on screen; shell gestures starting inside them are not + /// recognised. + pub exclusions: crate::mobile_gestures::ExclusionZones, } impl Default for PhoneState { fn default() -> Self { @@ -65,7 +71,9 @@ impl Default for PhoneState { search_query: String::new(), search_focused: false, search_scroll: 0.0, ime: HashMap::new(), shift: false, symbols: false, desktop_size: None, desktop_clients: Vec::new(), desktop_style: DesktopStyle::Omarchy, viewport: Rect::default(), - tiles: HomeTiles::default() } + tiles: HomeTiles::default(), + gesture_out: None, + exclusions: Default::default() } } } impl PhoneState { From 9ac82b0a6cbfb59a8a292073b372e49a748de420 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:54:28 -0700 Subject: [PATCH 03/13] mobile: the live island, three activities, AppCard turns as producers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A black pill under the status bar shows up to three ongoing activities (mobile_island.rs): an elapsed timer ticking mm:ss, a segmented progress with a tiny dot ring in the pill, a countdown. Two or three show as the primary plus dots, HyperOS-style; a tap expands the pill into a card with a row per activity (title, detail, segmented bar, time, action buttons), a tap outside collapses it. Width and height tween with the frame time. Model: `LiveActivity { id, source, label, title, kind: Elapsed | Progress { done, total, segments } | Countdown, detail, actions, started, updated, finished }`, `IslandState { activities, expanded, focus, .. }` with push/update/finish. Cap three: a fourth arrival drops the oldest finished (else lowest-priority) one into the shade through `dock(DockedNote { id, app, title, body, time })` — `take_docked` until the shade connects it. Finished activities linger four seconds with a checkmark. The island reads ShadePull / Commit(Shade) / Cancel(Shade) from `gesture_out` and docks while the shade is pulled or open (`shade_open`, cleared by the shade with `set_shade_open(false)`). Producers: the AppCard wrapper's `ask` tool reports `begin` through a small `ActivityReporter` trait (apps/appcard); the turn's end, and turns typed into the card's own composer, come from polling the wrapper's `turn_in_flight` (octos-app's chat state). Script data fetches have no host-visible hook and are not shown. `--test-action island:demo` pushes three demo activities (`island:expand` opens the card); on a phone, three quick taps on the status-bar clock do the same. Hooks in shared files: `mod mobile_island`, the `island` field on PhoneState and its step, one draw call in draw_overlay, one `PhoneHit::Island` arm, the test-action branch, `install_producers` at startup and `wake_island` on the clock tick. Co-Authored-By: Claude Fable 5.1 --- apps/appcard/src/lib.rs | 49 +++ src/main.rs | 4 + src/mobile.rs | 7 +- src/mobile_app.rs | 1 + src/mobile_island.rs | 821 ++++++++++++++++++++++++++++++++++++++++ src/mobile_surface.rs | 1 + 6 files changed, 882 insertions(+), 1 deletion(-) create mode 100644 src/mobile_island.rs diff --git a/apps/appcard/src/lib.rs b/apps/appcard/src/lib.rs index dd478e89..5801cadc 100644 --- a/apps/appcard/src/lib.rs +++ b/apps/appcard/src/lib.rs @@ -43,6 +43,50 @@ use octos_app::AppShell; pub struct AppCardModule; pub static APPCARD_MODULE: AppCardModule = AppCardModule; +/// A host's live-activity sink (OctoSense's island): told about the kernel +/// turns this module submits. `begin` fires from the `ask` tool with the +/// call id as the activity id; `progress` and `finish` are here for the +/// day octos-app exposes its turn lifecycle to a host — today a turn's end +/// is read back through [`turn_in_flight`], and turns typed into the card's +/// own composer never pass through this crate at all (TODO: an +/// `AppShell` turn callback in octos-app would cover both). +pub trait ActivityReporter: Send + Sync { + fn begin(&self, id: &str, title: &str); + fn progress(&self, id: &str, done: usize, total: usize, detail: &str); + fn finish(&self, id: &str); +} + +static ACTIVITY_REPORTER: std::sync::OnceLock> = std::sync::OnceLock::new(); + +/// Install the host's reporter, once. False when one is already there. +pub fn set_activity_reporter(reporter: Box) -> bool { + ACTIVITY_REPORTER.set(reporter).is_ok() +} + +/// The reporter the host installed, if any. +pub fn activity_reporter() -> Option<&'static dyn ActivityReporter> { + ACTIVITY_REPORTER.get().map(|r| r.as_ref()) +} + +/// The kernel turn in flight, as the app's chat state knows it: the +/// prompt text that started it (empty when the app has no record of it), +/// `None` when no turn is running. Set at submit — the `ask` tool and the +/// composer alike — and cleared when the turn completes or fails. +pub fn turn_in_flight() -> Option { + let data = octos_app::CHAT_DATA.read().ok()?; + if !data.is_streaming { + return None; + } + Some( + data.messages + .iter() + .rev() + .find(|m| matches!(m.role, octos_app::ChatRole::User)) + .map(|m| m.text.clone()) + .unwrap_or_default(), + ) +} + impl AppModule for AppCardModule { fn id(&self) -> &'static str { "appcard" } fn label(&self) -> &'static str { "AppCard" } @@ -125,6 +169,11 @@ impl ServiceExecutor for AppCardExecutor { .map(|mut shell| shell.ask(cx, &text)) .unwrap_or(false); if submitted { + // The host's island shows the turn from here; its + // end is read back through `turn_in_flight`. + if let Some(reporter) = activity_reporter() { + reporter.begin(&call.call_id, &text); + } ToolResult::ok(&call.call_id, format!("submitted: {text}"), "") } else { ToolResult::unavailable(&call.call_id, "AppCard is not running") diff --git a/src/main.rs b/src/main.rs index e15b7d4b..40cd66d8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,6 +26,7 @@ mod mobile_surface; mod mobile_gestures; mod mobile_app; mod mobile_tiles; +mod mobile_island; mod scene; mod dock_warp; mod host; @@ -2766,6 +2767,7 @@ impl App { if let Some(clock)=self.bar_sample.clock.split_whitespace().find(|s|s.contains(':')).map(str::to_string) { self.state_mut().phone.clock=clock; } + self.wake_island(cx); let mut shown: Vec = Vec::new(); let workspaces = { let state = self.state_mut(); @@ -3640,6 +3642,7 @@ impl App { i += 2; continue; } + if self.island_test_action(cx, name) { i += 2; continue; } match test_action(name) { Some(action) => { log!("wm: --test-action {} -> {:?}", name, action); @@ -3954,6 +3957,7 @@ impl MatchEvent for App { if startup_style != self.state_mut().style.target { self.set_desktop_style(cx, startup_style); } + mobile_island::install_producers(); if cfg!(any(target_os = "ios", target_os = "android")) { self.update_bar_chrome(cx, &WindowGeom { safe_area_insets: cx.display_context.safe_area_insets, diff --git a/src/mobile.rs b/src/mobile.rs index bba09f53..4ab0f254 100644 --- a/src/mobile.rs +++ b/src/mobile.rs @@ -12,6 +12,7 @@ pub enum PhoneHit { App(String), Card(ClientId), Home, Recents, Drawer, Back, Rotate, Style, Appearance, Desktop, Key(String), Shift, Symbols, HideKeyboard, ClearSearch, CancelSearch, + Island(crate::mobile_island::IslandHit), } #[derive(Clone)] @@ -62,6 +63,8 @@ pub struct PhoneState { /// Rects apps own on screen; shell gestures starting inside them are not /// recognised. pub exclusions: crate::mobile_gestures::ExclusionZones, + /// The live island's activities and state (mobile_island.rs). + pub island: crate::mobile_island::IslandState, } impl Default for PhoneState { fn default() -> Self { @@ -73,7 +76,8 @@ impl Default for PhoneState { desktop_size: None, desktop_clients: Vec::new(), desktop_style: DesktopStyle::Omarchy, viewport: Rect::default(), tiles: HomeTiles::default(), gesture_out: None, - exclusions: Default::default() } + exclusions: Default::default(), + island: Default::default() } } } impl PhoneState { @@ -128,6 +132,7 @@ impl PhoneState { self.keyboard += (self.keyboard_target - self.keyboard) * t; if (self.keyboard_target - self.keyboard).abs() < 0.25 { self.keyboard = self.keyboard_target; } active |= self.keyboard != self.keyboard_target; + active |= self.island.step(dt, crate::host::now(), self.gesture_out); if self.gesture.is_none() { let target = self.page.round().clamp(0.0, self.order.len().saturating_sub(1) as f64); self.page += (target - self.page) * t; diff --git a/src/mobile_app.rs b/src/mobile_app.rs index 8715ff96..cbf4dfca 100644 --- a/src/mobile_app.rs +++ b/src/mobile_app.rs @@ -382,6 +382,7 @@ impl App { if self.state_mut().phone.keyboard_target>0.0 {self.dismiss_phone_keyboard(cx);} else {self.phone_back(cx);} } + PhoneHit::Island(hit)=>{if let Some(app)=self.island_hit(hit) {self.phone_action(cx,PhoneHit::App(app));}} } self.sync_phone_keyboard(cx); self.sync_home_tiles(cx); diff --git a/src/mobile_island.rs b/src/mobile_island.rs new file mode 100644 index 00000000..77823687 --- /dev/null +++ b/src/mobile_island.rs @@ -0,0 +1,821 @@ +//! The live island (灵动岛): a black pill at the top of the phone, under +//! the status bar, that shows up to THREE ongoing activities — an elapsed +//! timer ticking mm:ss, a segmented progress, a countdown — and expands on +//! tap into a card with each activity's detail and action buttons. +//! +//! Contract with the shell: +//! - The model is `PhoneState::island` (`IslandState`). Producers call +//! `push` / `update` / `finish` on it, or post a `Report` through the +//! module's inbox from anywhere (`report`); the inbox is drained on the +//! phone's animation step (`PhoneState::step` → `IslandState::step`). +//! - Drawing: `draw`, called once from `PhoneSurface::draw_overlay` — under +//! the shade, over the apps. Taps arrive as `PhoneHit::Island`; the +//! island needs no gesture beyond tap. +//! - The shade: the island reads `ShellGesture::ShadePull`, `Commit(Shade)` +//! and `Cancel(Shade)` from `gesture_out` and docks (slides up under the +//! status bar) while the shade is pulled or open; `shade_open` is the flag +//! it keeps from those, and the shade module clears it when it closes +//! (`set_shade_open(false)`). +//! - Cap: `CAP` activities. When a fourth arrives, the oldest finished one +//! (else the lowest-priority one) is dropped into the shade's +//! notifications through `dock(DockedNote)` — the note's shape (id, app +//! label, title, body, time) is `DockedNote`; the shade drains +//! `take_docked()` until integration wires `dock` to its own push. +//! - Finished activities linger `LINGER` seconds with a checkmark, then +//! leave. +//! +//! Producers wired here: the hosted AppCard's kernel turns (the wrapper's +//! `ask` tool reports `begin`; the turn's end — and turns typed into the +//! card's own composer — come from polling the wrapper's `turn_in_flight`), +//! and a demo (`--test-action island:demo`, or three quick taps on the +//! status-bar clock on a phone). +use crate::{ + desktop::{DesktopStyle, DrawDesktopChrome}, + mobile::PhoneHit, + mobile_gestures::{GestureKind, ShellGesture}, + octosense::style::AppIconDraw, + shell::{alpha, rgb, ui::{rect, HAlign, Ico, ShellDraw}}, + App, +}; +use makepad_widgets::*; +use std::cell::Cell; +use std::sync::Mutex; + +/// How many activities the island shows at once. +pub const CAP: usize = 3; +/// How long a finished activity stays, with its checkmark, in seconds. +pub const LINGER: f64 = 4.0; +/// Three taps on the clock within this window push the demo (a phone has +/// no `--test-action`). +const CLOCK_TAP_WINDOW: f64 = 1.6; + +/// What an activity measures. +#[derive(Clone, Debug, PartialEq)] +pub enum ActivityKind { + /// Ticking since `started` (mm:ss). + Elapsed { started: f64 }, + /// `done` of `total` steps; `segments` are each step's own fill 0..1 + /// (empty: a step is full when it is done). + Progress { done: usize, total: usize, segments: Vec }, + /// Ticking down to `until`; finishes itself when it gets there. + Countdown { until: f64 }, +} + +/// What an activity's button does. +#[derive(Clone, Debug, PartialEq)] +pub enum ActivityAction { + /// Bring the source app to the front. + Open(String), + /// Stop the activity (it finishes now). + Cancel, + /// Drop it from the island without finishing it. + Dismiss, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct LiveActivity { + pub id: String, + /// The producing app's id (`appcard`), for its glyph. + pub source: String, + /// The producing app's label (`AppCard`), for the docked note. + pub label: String, + pub title: String, + pub kind: ActivityKind, + pub detail: String, + pub actions: Vec<(String, ActivityAction)>, + pub started: f64, + pub updated: f64, + /// When `finish` was called: the activity lingers `LINGER` seconds. + pub finished: Option, +} + +impl LiveActivity { + pub fn new(id: &str, source: &str, label: &str, title: &str, kind: ActivityKind, now: f64) -> Self { + Self { + id: id.into(), source: source.into(), label: label.into(), title: title.into(), kind, + detail: String::new(), actions: Vec::new(), started: now, updated: now, finished: None, + } + } + pub fn with_detail(mut self, detail: &str) -> Self { self.detail = detail.into(); self } + pub fn with_action(mut self, label: &str, action: ActivityAction) -> Self { + self.actions.push((label.into(), action)); + self + } + pub fn done(&self) -> bool { self.finished.is_some() } + /// What survives when the island is full: finished ones go first, then + /// timers, then countdowns; a progress in flight stays. + pub fn priority(&self) -> u8 { + if self.done() { return 0; } + match self.kind { + ActivityKind::Elapsed { .. } => 1, + ActivityKind::Countdown { .. } => 2, + ActivityKind::Progress { .. } => 3, + } + } + /// The fraction complete of a `Progress` (the mean of its segments). + pub fn fraction(&self) -> Option { + match &self.kind { + ActivityKind::Progress { done, total, segments } => { + if *total == 0 { return Some(1.0); } + if segments.is_empty() { return Some((*done as f32 / *total as f32).clamp(0.0, 1.0)); } + let n = (*total).max(segments.len()) as f32; + Some((segments.iter().map(|s| s.clamp(0.0, 1.0)).sum::() / n).clamp(0.0, 1.0)) + } + _ => None, + } + } + /// The fill of each segment of a `Progress` bar, `total` entries. + pub fn segment_fills(&self) -> Vec { + match &self.kind { + ActivityKind::Progress { done, total, segments } => (0..*total) + .map(|i| segments.get(i).copied().unwrap_or(if i < *done { 1.0 } else { 0.0 }).clamp(0.0, 1.0)) + .collect(), + _ => Vec::new(), + } + } + /// The right-hand text at `now`: the elapsed or remaining time, the + /// step count, or "Done". + pub fn status(&self, now: f64) -> String { + if self.done() { return "Done".into(); } + match &self.kind { + ActivityKind::Elapsed { started } => format_clock(now - started), + ActivityKind::Countdown { until } => format_clock(until - now), + ActivityKind::Progress { done, total, .. } => format!("{done}/{total}"), + } + } + /// The body of the note this activity leaves in the shade. + fn note_body(&self, now: f64) -> String { + if !self.detail.is_empty() { return self.detail.clone(); } + match &self.kind { + ActivityKind::Elapsed { .. } if self.done() => format!("Finished after {}", format_clock(self.finished.unwrap_or(now) - self.started)), + ActivityKind::Elapsed { .. } => format!("Running for {}", self.status(now)), + ActivityKind::Countdown { .. } if self.done() => "Time is up".into(), + ActivityKind::Countdown { .. } => format!("{} left", self.status(now)), + ActivityKind::Progress { .. } if self.done() => "Complete".into(), + ActivityKind::Progress { done, total, .. } => format!("{done} of {total} done"), + } + } +} + +/// `mm:ss`, `h:mm:ss` past an hour; never negative. +pub fn format_clock(secs: f64) -> String { + let s = secs.max(0.0).floor() as u64; + if s >= 3600 { format!("{}:{:02}:{:02}", s / 3600, (s / 60) % 60, s % 60) } else { format!("{:02}:{:02}", s / 60, s % 60) } +} + +// ------------------------------------------------------------------ shade + +/// A note the island hands to the shade's notifications: the shape the +/// shade keeps (`mobile_shade`): id, app label, title, body, time. +#[derive(Clone, Debug, PartialEq)] +pub struct DockedNote { + pub id: String, + pub app: String, + pub title: String, + pub body: String, + pub time: f64, +} + +static DOCKED: Mutex> = Mutex::new(Vec::new()); + +/// Drop a note into the shade. Until the shade's push is connected here, +/// the notes wait in a queue the shade drains with `take_docked`. +pub fn dock(note: DockedNote) { + if let Ok(mut q) = DOCKED.lock() { q.push(note); } +} +/// The notes docked since the last call (the shade's side of `dock`). +pub fn take_docked() -> Vec { + DOCKED.lock().map(|mut q| std::mem::take(&mut *q)).unwrap_or_default() +} + +// -------------------------------------------------------------- producers + +/// What a producer posts from anywhere (a module wrapper, a closure with +/// no shell access); applied on the next island step. +#[derive(Clone, Debug, PartialEq)] +pub enum Report { + Begin { id: String, source: String, label: String, title: String }, + Progress { id: String, done: usize, total: usize, segments: Vec, detail: String }, + Finish { id: String }, +} + +static INBOX: Mutex> = Mutex::new(Vec::new()); + +/// Post a report; the island applies it on its next step. +pub fn report(r: Report) { + if let Ok(mut q) = INBOX.lock() { q.push(r); } +} +fn take_reports() -> Vec { + INBOX.lock().map(|mut q| std::mem::take(&mut *q)).unwrap_or_default() +} +fn inbox_waiting() -> bool { + INBOX.lock().map(|q| !q.is_empty()).unwrap_or(false) +} + +/// Install the producers this build links. Called once at startup. +pub fn install_producers() { + #[cfg(feature = "app-appcard")] + { + // The AppCard wrapper's `ask` tool reports the turn it submits. + octosense_appcard::set_activity_reporter(Box::new(IslandReporter)); + } +} + +/// The island as the AppCard wrapper's `ActivityReporter`: reports go +/// through the inbox, so the wrapper needs no shell access. +#[cfg(feature = "app-appcard")] +struct IslandReporter; +#[cfg(feature = "app-appcard")] +impl octosense_appcard::ActivityReporter for IslandReporter { + fn begin(&self, id: &str, title: &str) { + report(Report::Begin { id: id.into(), source: "appcard".into(), label: "AppCard".into(), title: title.into() }); + } + fn progress(&self, id: &str, done: usize, total: usize, detail: &str) { + report(Report::Progress { id: id.into(), done, total, segments: Vec::new(), detail: detail.into() }); + } + fn finish(&self, id: &str) { + report(Report::Finish { id: id.into() }); + } +} + +// ------------------------------------------------------------------ model + +/// A tap on the island. +#[derive(Clone, Debug, PartialEq)] +pub enum IslandHit { + /// The pill: expand or collapse. + Toggle, + /// Outside the expanded card: collapse. + Collapse, + /// An activity's button: the activity id and the action index. + Action(String, usize), + /// The status-bar clock: three quick taps push the demo. + Clock, +} + +/// The draw-side tween of the pill's geometry, kept in a `Cell` so the +/// surface (which draws from `&WmState`) can animate width and height +/// changes with the frame time. +#[derive(Clone, Copy, Debug)] +struct Anim { w: f64, h: f64, t: f64, settled: bool } +impl Default for Anim { + /// Never drawn yet: nothing to settle. + fn default() -> Self { Self { w: 0.0, h: 0.0, t: 0.0, settled: true } } +} + +#[derive(Clone, Debug, Default)] +pub struct IslandState { + pub activities: Vec, + pub expanded: bool, + /// The activity the pill shows first; else the most important one. + pub focus: Option, + /// The shade is open: the island stays hidden until it closes. + pub shade_open: bool, + /// A shade pull in progress, 0..1: the island docks with it. + pub shade_pull: f64, + /// Animated expansion, 0 (pill) .. 1 (card). + pub open: f64, + /// Animated visibility, 0 (docked away) .. 1 (shown). + pub presence: f64, + /// Activities the demo finishes at a given time. + auto_finish: Vec<(String, f64)>, + clock_taps: Vec, + anim: Cell, +} + +impl IslandState { + /// Add an activity (replacing one with the same id, keeping its start). + /// Past the cap, the oldest finished — else the lowest-priority — one + /// is docked into the shade. + pub fn push(&mut self, mut activity: LiveActivity) { + if let Some(existing) = self.activities.iter_mut().find(|a| a.id == activity.id) { + activity.started = existing.started; + *existing = activity; + return; + } + let now = activity.updated; + while self.activities.len() >= CAP { + let victim = self.activities.iter().enumerate() + .min_by(|(_, a), (_, b)| { + (a.priority(), a.finished.unwrap_or(a.started)).partial_cmp(&(b.priority(), b.finished.unwrap_or(b.started))).unwrap() + }) + .map(|(i, _)| i) + .unwrap(); + let dropped = self.activities.remove(victim); + if self.focus.as_deref() == Some(dropped.id.as_str()) { self.focus = None; } + dock(DockedNote { id: dropped.id.clone(), app: dropped.label.clone(), title: dropped.title.clone(), body: dropped.note_body(now), time: now }); + } + self.activities.push(activity); + } + pub fn get(&self, id: &str) -> Option<&LiveActivity> { self.activities.iter().find(|a| a.id == id) } + /// Change an activity in place; it counts as updated at `now`. + pub fn update(&mut self, id: &str, now: f64, f: impl FnOnce(&mut LiveActivity)) -> bool { + match self.activities.iter_mut().find(|a| a.id == id) { + Some(a) => { f(a); a.updated = now; true } + None => false, + } + } + /// Mark an activity finished: it shows a checkmark for `LINGER` + /// seconds, then leaves. + pub fn finish(&mut self, id: &str, now: f64) -> bool { + self.update(id, now, |a| { + if a.finished.is_none() { a.finished = Some(now); } + if let ActivityKind::Progress { done, total, segments } = &mut a.kind { + *done = *total; + for s in segments.iter_mut() { *s = 1.0; } + } + }) + } + /// Remove an activity outright (no linger, no note). + pub fn remove(&mut self, id: &str) { + self.activities.retain(|a| a.id != id); + if self.focus.as_deref() == Some(id) { self.focus = None; } + } + pub fn set_shade_open(&mut self, open: bool) { self.shade_open = open; if open { self.expanded = false; } } + /// Nothing to show: no activity, or the shade owns the top edge. + pub fn hidden(&self) -> bool { self.activities.is_empty() || self.shade_open || self.shade_pull > 0.001 } + /// The activity the pill shows: the focused one, else the unfinished + /// one of highest priority, most recently updated. + pub fn primary(&self) -> Option<&LiveActivity> { + if let Some(a) = self.focus.as_ref().and_then(|id| self.get(id)) { return Some(a); } + self.activities.iter().max_by(|a, b| { + (!a.done(), a.priority(), a.updated).partial_cmp(&(!b.done(), b.priority(), b.updated)).unwrap() + }) + } + /// A tap. `Open` comes back for the shell to bring the app forward. + pub fn on_hit(&mut self, hit: IslandHit, now: f64) -> Option { + match hit { + IslandHit::Toggle => { if !self.hidden() { self.expanded = !self.expanded; } } + IslandHit::Collapse => self.expanded = false, + IslandHit::Action(id, index) => { + let action = self.get(&id).and_then(|a| a.actions.get(index)).map(|(_, a)| a.clone()); + match action { + Some(ActivityAction::Cancel) => { self.finish(&id, now); } + Some(ActivityAction::Dismiss) => self.remove(&id), + Some(ActivityAction::Open(app)) => return Some(ActivityAction::Open(app)), + None => {} + } + } + IslandHit::Clock => { + self.clock_taps.retain(|t| now - t < CLOCK_TAP_WINDOW); + self.clock_taps.push(now); + if self.clock_taps.len() >= 3 { self.clock_taps.clear(); self.demo(now); } + } + } + None + } + /// Three demo activities: a ticking timer that finishes after 6 s (the + /// linger), a three-segment progress, and a countdown with a button. + pub fn demo(&mut self, now: f64) { + self.push(LiveActivity::new("demo:writing", "appcard", "AppCard", "Writing weather card", ActivityKind::Elapsed { started: now }, now) + .with_detail("Kernel turn: weather, Tokyo")); + self.push(LiveActivity::new("demo:forecast", "appcard", "AppCard", "Fetching forecast", ActivityKind::Progress { done: 1, total: 3, segments: vec![1.0, 0.45, 0.0] }, now) + .with_detail("Open-Meteo: current, hourly, daily") + .with_action("Open", ActivityAction::Open("appcard".into()))); + self.push(LiveActivity::new("demo:pomodoro", "clock", "Clock", "Pomodoro", ActivityKind::Countdown { until: now + 25.0 * 60.0 }, now) + .with_detail("Focus, then a 5 minute break") + .with_action("Stop", ActivityAction::Cancel)); + self.auto_finish.retain(|(id, _)| id != "demo:writing"); + self.auto_finish.push(("demo:writing".into(), now + 6.0)); + } + /// Apply a producer's report. + pub fn apply(&mut self, r: Report, now: f64) { + match r { + Report::Begin { id, source, label, title } => { + self.push(LiveActivity::new(&id, &source, &label, &title, ActivityKind::Elapsed { started: now }, now)); + } + Report::Progress { id, done, total, segments, detail } => { + if self.get(&id).is_none() { + self.push(LiveActivity::new(&id, "", "", &id, ActivityKind::Progress { done, total, segments: segments.clone() }, now)); + } + self.update(&id, now, |a| { + a.kind = ActivityKind::Progress { done, total, segments }; + if !detail.is_empty() { a.detail = detail; } + }); + } + Report::Finish { id } => { self.finish(&id, now); } + } + } + /// The hosted AppCard's turns, from the wrapper: a turn in flight with + /// no activity of its own (typed into the card's composer) begins one; + /// no turn in flight finishes the ones there are. + #[cfg(feature = "app-appcard")] + fn poll_appcard(&mut self, now: f64) { + let running: Vec = self.activities.iter().filter(|a| a.source == "appcard" && !a.done()).map(|a| a.id.clone()).collect(); + match octosense_appcard::turn_in_flight() { + Some(text) => { + if running.is_empty() { + let title = if text.trim().is_empty() { "AppCard is thinking".to_string() } else { text.trim().to_string() }; + self.push(LiveActivity::new("appcard:turn", "appcard", "AppCard", &title, ActivityKind::Elapsed { started: now }, now) + .with_detail("Kernel turn") + .with_action("Open", ActivityAction::Open("appcard".into()))); + } + } + None => { + for id in running { + // A submit races the first poll by a frame: give it half a second. + if self.get(&id).is_some_and(|a| now - a.started > 0.5) { self.finish(&id, now); } + } + } + } + } + /// Whether the shell should run a frame for the island now (it is + /// called once a second from the clock tick while nothing animates). + pub fn needs_step(&self) -> bool { + if !self.activities.is_empty() || inbox_waiting() { return true; } + #[cfg(feature = "app-appcard")] + if octosense_appcard::turn_in_flight().is_some() { return true; } + false + } + /// One animation step. Drains the producers, times out finished + /// activities and countdowns, follows the shade, tweens the pill. + /// True while something still moves. + pub fn step(&mut self, dt: f64, now: f64, gesture: Option) -> bool { + for r in take_reports() { self.apply(r, now); } + #[cfg(feature = "app-appcard")] + self.poll_appcard(now); + let due: Vec = self.auto_finish.iter().filter(|(_, at)| now >= *at).map(|(id, _)| id.clone()).collect(); + self.auto_finish.retain(|(_, at)| now < *at); + for id in due { self.finish(&id, now); } + let ended: Vec = self.activities.iter() + .filter(|a| !a.done() && matches!(a.kind, ActivityKind::Countdown { until } if now >= until)) + .map(|a| a.id.clone()).collect(); + for id in ended { self.finish(&id, now); } + let gone: Vec = self.activities.iter().filter(|a| a.finished.is_some_and(|t| now - t >= LINGER)).map(|a| a.id.clone()).collect(); + for id in gone { self.remove(&id); } + match gesture { + Some(ShellGesture::ShadePull { progress, .. }) => { self.shade_pull = progress.clamp(0.0, 1.0); self.expanded = false; } + Some(ShellGesture::Commit(GestureKind::Shade(_))) => { self.shade_pull = 0.0; self.set_shade_open(true); } + Some(ShellGesture::Cancel(GestureKind::Shade(_))) => self.shade_pull = 0.0, + _ => {} + } + if self.activities.is_empty() { self.expanded = false; } + let t = 1.0 - (-dt * 16.0).exp(); + let presence = if self.activities.is_empty() || self.shade_open { 0.0 } else { 1.0 - self.shade_pull }; + let open = if self.expanded && presence > 0.5 { 1.0 } else { 0.0 }; + let mut moving = false; + for (value, target) in [(&mut self.presence, presence), (&mut self.open, open)] { + *value += (target - *value) * t; + if (*value - target).abs() < 0.002 { *value = target; } + moving |= *value != target; + } + moving + || !self.auto_finish.is_empty() + || self.activities.iter().any(|a| a.done() || matches!(a.kind, ActivityKind::Countdown { until } if until - now < 1.5)) + || !self.anim.get().settled + } +} + +// --------------------------------------------------------------- the shell + +impl App { + /// `--test-action island:demo` pushes the three demo activities (on a + /// desktop run the shell switches to the Android phone first); + /// `island:expand` opens the card; `island:phone` only switches, for a + /// run that watches a real producer. True when `name` was one of ours. + pub(crate) fn island_test_action(&mut self, cx: &mut Cx, name: &str) -> bool { + let Some(what) = name.strip_prefix("island:") else { return false }; + log!("wm: --test-action island:{}", what); + if !self.state_mut().style.target.mobile() { self.set_desktop_style(cx, DesktopStyle::Android); } + let now = crate::host::now(); + match what { + "demo" => self.state_mut().phone.island.demo(now), + "expand" => self.state_mut().phone.island.expanded = true, + "phone" => {} + other => log!("wm: unknown --test-action island:{}", other), + } + self.animate_phone(cx); + true + } + /// A tap on the island (`PhoneHit::Island`). The app an `Open` button + /// names comes back for the phone to bring forward. + pub(crate) fn island_hit(&mut self, hit: IslandHit) -> Option { + match self.state_mut().phone.island.on_hit(hit, crate::host::now()) { + Some(ActivityAction::Open(app)) => Some(app), + _ => None, + } + } + /// From the clock tick: while the island has something to time, run a + /// frame so its step sees the second pass (the phone's animation loop + /// stops on its own once nothing moves). + pub(crate) fn wake_island(&mut self, cx: &mut Cx) { + if self.state.as_ref().is_some_and(|s| s.style.target.mobile() && s.phone.island.needs_step()) { self.animate_phone(cx); } + } +} + +// ---------------------------------------------------------------- drawing + +fn rounded(cx: &mut Cx2d, chrome: &mut DrawDesktopChrome, r: Rect, radius: f64, color: Vec4f) { + if r.size.x <= 0.0 || r.size.y <= 0.0 || color.w <= 0.0 { return; } + chrome.radius = (radius * 2.0) as f32; + chrome.bevel = 0.0; + chrome.color = color; + chrome.draw_abs(cx, r); +} +fn mix(a: f64, b: f64, t: f64) -> f64 { a + (b - a) * t } + +/// The card's height for these activities. +fn card_height(activities: &[LiveActivity]) -> f64 { + 14.0 + activities.iter().map(|a| 56.0 + if a.fraction().is_some() { 18.0 } else { 0.0 } + if a.actions.is_empty() { 0.0 } else { 40.0 }).sum::() +} + +/// Draw the island for this frame and register its taps. One call from +/// `PhoneSurface::draw_overlay`, after the status bar. +pub fn draw(cx: &mut Cx2d, chrome: &mut DrawDesktopChrome, d: &mut ShellDraw, icons: &mut AppIconDraw, hits: &mut Vec<(Rect, PhoneHit)>, state: &crate::desk::WmState, screen: Rect) { + let island = &state.phone.island; + let style = state.style.target; + let ios = style == DesktopStyle::Ios; + let now = crate::host::now(); + let landscape = screen.size.x > screen.size.y; + let status_h = if landscape { 24.0 } else { 42.0 }; + let pill_h = if landscape { 22.0 } else { 34.0 }; + // The clock: three quick taps push the demo on a phone. + hits.push((rect(screen.pos.x, screen.pos.y, 90.0, status_h), PhoneHit::Island(IslandHit::Clock))); + let Some(primary) = island.primary() else { + island.anim.set(Anim::default()); + return; + }; + let presence = island.presence; + let open = island.open; + let white = rgb(255, 255, 255); + let accent = if ios { rgb(10, 132, 255) } else { rgb(172, 200, 255) }; + let green = rgb(52, 199, 89); + // ---- geometry: the compact pill is sized by its content, the card by the screen. + let px = if landscape { 11.0 } else { 13.0 }; + let glyph = if landscape { 16.0 } else { 20.0 }; + let title_w = d.measure(cx, true, px, &primary.title).min(if landscape { 120.0 } else { 168.0 }); + let status = primary.status(now); + let status_w = if primary.done() { 16.0 } else if primary.fraction().is_some() { 18.0 } else { d.measure(cx, true, px, &status) }; + let dots = island.activities.len().saturating_sub(1) as f64; + let compact_w = 12.0 + glyph + 8.0 + title_w + 10.0 + status_w + dots * 10.0 + 12.0; + let card_w = (screen.size.x - 24.0).min(400.0); + let card_h = card_height(&island.activities); + let target_w = mix(compact_w, card_w, open); + let target_h = mix(pill_h, card_h, open); + let mut a = island.anim.get(); + if a.settled && a.w == 0.0 { a.w = target_w; a.h = target_h; } + let dt = if a.t == 0.0 { 1.0 / 60.0 } else { (now - a.t).clamp(0.0, 0.05) }; + let k = 1.0 - (-dt * 18.0).exp(); + a.w += (target_w - a.w) * k; + a.h += (target_h - a.h) * k; + a.settled = (a.w - target_w).abs() < 0.5 && (a.h - target_h).abs() < 0.5; + if a.settled { a.w = target_w; a.h = target_h; } + a.t = now; + island.anim.set(a); + if presence < 0.01 { return; } + let alpha_f = presence as f32; + let top = screen.pos.y + (status_h - pill_h) * 0.5 - (1.0 - presence) * (pill_h + 10.0); + let r = rect(screen.pos.x + (screen.size.x - a.w) * 0.5, top, a.w, a.h); + let radius = mix(pill_h * 0.5, 24.0, open); + // ---- the surface: a soft shadow, the black pill, a hairline of light. + rounded(cx, chrome, rect(r.pos.x - 1.0, r.pos.y + 2.0, r.size.x + 2.0, r.size.y + 2.0), radius + 1.0, alpha(rgb(0, 0, 0), 0.28 * alpha_f)); + rounded(cx, chrome, rect(r.pos.x - 0.5, r.pos.y - 0.5, r.size.x + 1.0, r.size.y + 1.0), radius + 0.5, alpha(white, (0.06 + 0.08 * open as f32) * alpha_f)); + rounded(cx, chrome, r, radius, alpha(rgb(6, 6, 9), (0.97 + 0.025 * open as f32) * alpha_f)); + // ---- taps. + if open < 0.5 { + hits.push((rect(r.pos.x - 8.0, r.pos.y - 4.0, r.size.x + 16.0, r.size.y + 8.0), PhoneHit::Island(IslandHit::Toggle))); + } else { + hits.push((screen, PhoneHit::Island(IslandHit::Collapse))); + } + // ---- compact: glyph, title, the time or the ring, the other activities' dots. + let compact = ((1.0 - open * 2.5).clamp(0.0, 1.0) as f32) * alpha_f; + if compact > 0.01 { + let ink = alpha(white, compact); + let dim = alpha(white, 0.45 * compact); + let mut x = r.pos.x + 12.0; + let mid = r.pos.y + pill_h * 0.5; + icons.draw(cx, &primary.source, style, rect(x, mid - glyph * 0.5, glyph, glyph), compact, ink); + x += glyph + 8.0; + d.label_elided(cx, rect(x, r.pos.y, title_w, pill_h), true, px, ink, HAlign::Left, &primary.title); + x += title_w + 10.0; + if primary.done() { + d.icon_centered(cx, Ico::Check, rect(x, r.pos.y, 16.0, pill_h), 14.0, alpha(green, compact)); + } else if let Some(fraction) = primary.fraction() { + ring(cx, chrome, dvec2(x + 9.0, mid), 7.0, fraction, alpha(accent, compact), dim); + } else { + d.label(cx, rect(x, r.pos.y, status_w, pill_h), true, px, ink, HAlign::Left, &status); + } + x += status_w + 6.0; + for other in island.activities.iter().filter(|o| o.id != primary.id) { + let color = if other.done() { alpha(green, compact) } else if other.fraction().is_some() { alpha(accent, compact) } else { dim }; + rounded(cx, chrome, rect(x + 2.0, mid - 3.0, 6.0, 6.0), 3.0, color); + x += 10.0; + } + } + // ---- expanded: a row per activity, its bar and its buttons. + let card = ((open - 0.4) / 0.6).clamp(0.0, 1.0) as f32 * alpha_f; + if card > 0.01 { + let ink = alpha(white, card); + let soft = alpha(white, 0.62 * card); + let mut y = r.pos.y + 14.0; + let left = r.pos.x + 18.0; + let inner_w = r.size.x - 36.0; + for (index, activity) in island.activities.iter().enumerate() { + let glyph = 30.0; + icons.draw(cx, &activity.source, style, rect(left, y + 4.0, glyph, glyph), card, ink); + let text_x = left + glyph + 12.0; + let status = activity.status(now); + let status_w = if activity.done() { 22.0 } else { d.measure(cx, true, 14.0, &status) + 4.0 }; + let text_w = inner_w - glyph - 12.0 - status_w - 8.0; + d.label_elided(cx, rect(text_x, y, text_w, 22.0), true, 14.0, ink, HAlign::Left, &activity.title); + let detail = if activity.detail.is_empty() { activity.label.clone() } else { activity.detail.clone() }; + d.label_elided(cx, rect(text_x, y + 22.0, text_w, 20.0), false, 12.0, soft, HAlign::Left, &detail); + if activity.done() { + d.icon_centered(cx, Ico::Check, rect(left + inner_w - 22.0, y, 22.0, 22.0), 18.0, alpha(green, card)); + } else { + d.label(cx, rect(left + inner_w - status_w, y, status_w, 22.0), true, 14.0, ink, HAlign::Right, &status); + } + y += 50.0; + let fills = activity.segment_fills(); + if !fills.is_empty() { + let gap = 4.0; + let seg_w = (inner_w - gap * (fills.len() as f64 - 1.0)) / fills.len() as f64; + for (i, fill) in fills.iter().enumerate() { + let sx = left + i as f64 * (seg_w + gap); + rounded(cx, chrome, rect(sx, y, seg_w, 6.0), 3.0, alpha(white, 0.16 * card)); + let done = if activity.done() { green } else { accent }; + rounded(cx, chrome, rect(sx, y, seg_w * *fill as f64, 6.0), 3.0, alpha(done, card)); + } + y += 18.0; + } + if !activity.actions.is_empty() { + let mut bx = text_x; + for (n, (label, _)) in activity.actions.iter().enumerate() { + let w = d.measure(cx, true, 12.0, label) + 28.0; + let b = rect(bx, y + 4.0, w, 30.0); + rounded(cx, chrome, b, 15.0, alpha(white, 0.14 * card)); + d.label(cx, b, true, 12.0, ink, HAlign::Center, label); + if open > 0.9 { hits.push((rect(b.pos.x - 4.0, b.pos.y - 4.0, b.size.x + 8.0, b.size.y + 8.0), PhoneHit::Island(IslandHit::Action(activity.id.clone(), n)))); } + bx += w + 10.0; + } + y += 40.0; + } + y += 6.0; + if index + 1 < island.activities.len() { + rounded(cx, chrome, rect(left, y - 3.0, inner_w, 1.0), 0.5, alpha(white, 0.10 * card)); + } + } + } +} + +/// A tiny progress ring: eight dots around `center`, `fraction` of them +/// lit — no arc shader needed. +fn ring(cx: &mut Cx2d, chrome: &mut DrawDesktopChrome, center: Vec2d, radius: f64, fraction: f32, lit: Vec4f, dim: Vec4f) { + let n = 8; + let count = (fraction.clamp(0.0, 1.0) * n as f32).round() as usize; + for i in 0..n { + let angle = -std::f64::consts::FRAC_PI_2 + i as f64 * std::f64::consts::TAU / n as f64; + let p = center + dvec2(angle.cos(), angle.sin()) * radius; + rounded(cx, chrome, rect(p.x - 1.5, p.y - 1.5, 3.0, 3.0), 1.5, if i < count { lit } else { dim }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mobile_gestures::ShadeSide; + + fn elapsed(id: &str, now: f64) -> LiveActivity { + LiveActivity::new(id, "appcard", "AppCard", id, ActivityKind::Elapsed { started: now }, now) + } + fn settle(island: &mut IslandState, now: f64) { + for _ in 0..120 { island.step(1.0 / 60.0, now, None); } + } + + #[test] + fn a_fourth_activity_docks_the_oldest_finished_else_the_least_important() { + let mut island = IslandState::default(); + island.push(elapsed("cap:a", 1.0)); + island.push(LiveActivity::new("cap:b", "appcard", "AppCard", "Fetching", ActivityKind::Progress { done: 0, total: 2, segments: vec![] }, 2.0).with_detail("two calls")); + island.push(elapsed("cap:c", 3.0)); + assert_eq!(island.activities.len(), 3); + // Nothing finished: the oldest timer goes, the progress in flight stays. + island.push(elapsed("cap:d", 4.0)); + let ids: Vec<&str> = island.activities.iter().map(|a| a.id.as_str()).collect(); + assert_eq!(ids, ["cap:b", "cap:c", "cap:d"]); + let docked = take_docked(); + let note = docked.iter().find(|n| n.id == "cap:a").expect("the dropped timer became a note"); + assert_eq!((note.app.as_str(), note.title.as_str(), note.time), ("AppCard", "cap:a", 4.0)); + assert_eq!(note.body, "Running for 00:03"); + // A finished one goes before any live one, however important. + island.finish("cap:b", 5.0); + island.push(elapsed("cap:e", 6.0)); + let ids: Vec<&str> = island.activities.iter().map(|a| a.id.as_str()).collect(); + assert_eq!(ids, ["cap:c", "cap:d", "cap:e"]); + let docked = take_docked(); + let note = docked.iter().find(|n| n.id == "cap:b").expect("the finished progress became a note"); + assert_eq!(note.body, "two calls"); + assert_eq!(island.activities.len(), CAP); + } + + #[test] + fn finishing_lingers_with_a_checkmark_then_leaves() { + let mut island = IslandState::default(); + island.push(LiveActivity::new("lin:p", "appcard", "AppCard", "Fetching", ActivityKind::Progress { done: 1, total: 3, segments: vec![1.0, 0.5, 0.0] }, 0.0)); + assert!(island.finish("lin:p", 10.0)); + let a = island.get("lin:p").unwrap(); + assert!(a.done()); + assert_eq!(a.status(11.0), "Done"); + assert_eq!(a.segment_fills(), vec![1.0, 1.0, 1.0], "a finished progress is full"); + assert!(island.step(1.0 / 60.0, 10.0 + LINGER - 0.1, None), "still lingering: keeps animating"); + assert!(island.get("lin:p").is_some()); + island.step(1.0 / 60.0, 10.0 + LINGER, None); + assert!(island.get("lin:p").is_none(), "gone after the linger"); + settle(&mut island, 20.0); + assert!(!island.step(1.0 / 60.0, 20.0, None), "an empty island settles"); + assert!(!island.finish("lin:p", 21.0)); + } + + #[test] + fn elapsed_and_countdown_format_as_clocks() { + assert_eq!(format_clock(0.0), "00:00"); + assert_eq!(format_clock(65.9), "01:05"); + assert_eq!(format_clock(-3.0), "00:00"); + assert_eq!(format_clock(3600.0 + 61.0), "1:01:01"); + let timer = elapsed("fmt:t", 100.0); + assert_eq!(timer.status(100.0 + 754.0), "12:34"); + let count = LiveActivity::new("fmt:c", "clock", "Clock", "Pomodoro", ActivityKind::Countdown { until: 100.0 + 25.0 * 60.0 }, 100.0); + assert_eq!(count.status(100.0), "25:00"); + assert_eq!(count.status(100.0 + 24.0 * 60.0 + 59.0), "00:01"); + assert_eq!(count.status(100.0 + 26.0 * 60.0), "00:00"); + let progress = LiveActivity::new("fmt:p", "appcard", "AppCard", "Fetching", ActivityKind::Progress { done: 2, total: 3, segments: vec![1.0, 1.0, 0.5] }, 0.0); + assert_eq!(progress.status(0.0), "2/3"); + assert!((progress.fraction().unwrap() - 2.5 / 3.0).abs() < 1e-6); + // A countdown finishes itself when it reaches zero. + let mut island = IslandState::default(); + island.push(count.clone()); + island.step(1.0 / 60.0, 100.0 + 25.0 * 60.0, None); + assert!(island.get("fmt:c").unwrap().done()); + } + + #[test] + fn taps_expand_collapse_and_run_actions() { + let mut island = IslandState::default(); + assert_eq!(island.on_hit(IslandHit::Toggle, 0.0), None); + assert!(!island.expanded, "nothing to expand on an empty island"); + island.push(elapsed("tap:a", 0.0).with_action("Open", ActivityAction::Open("appcard".into())).with_action("Stop", ActivityAction::Cancel)); + island.on_hit(IslandHit::Toggle, 1.0); + assert!(island.expanded); + settle(&mut island, 1.0); + assert!((island.open - 1.0).abs() < 0.01 && (island.presence - 1.0).abs() < 0.01); + island.on_hit(IslandHit::Collapse, 2.0); + assert!(!island.expanded); + settle(&mut island, 2.0); + assert!(island.open.abs() < 0.01); + assert_eq!(island.on_hit(IslandHit::Action("tap:a".into(), 0), 3.0), Some(ActivityAction::Open("appcard".into()))); + assert_eq!(island.on_hit(IslandHit::Action("tap:a".into(), 1), 4.0), None); + assert!(island.get("tap:a").unwrap().done(), "Stop finishes the activity"); + assert_eq!(island.on_hit(IslandHit::Action("tap:nope".into(), 0), 5.0), None); + // Three quick taps on the clock push the demo; slow ones do not. + for t in [10.0, 12.0, 14.0] { island.on_hit(IslandHit::Clock, t); } + assert!(island.get("demo:writing").is_none()); + for t in [20.0, 20.3, 20.6] { island.on_hit(IslandHit::Clock, t); } + assert!(island.get("demo:writing").is_some() && island.get("demo:pomodoro").is_some()); + assert_eq!(island.activities.len(), CAP, "the demo's three fill the island"); + island.step(1.0 / 60.0, 27.0, None); + assert!(island.get("demo:writing").unwrap().done(), "the demo's timer finishes after six seconds"); + } + + #[test] + fn hidden_while_the_shade_is_pulled_or_open() { + let mut island = IslandState::default(); + island.push(elapsed("shade:a", 0.0)); + island.expanded = true; + settle(&mut island, 0.0); + assert!(!island.hidden() && island.presence > 0.99); + island.step(1.0 / 60.0, 1.0, Some(ShellGesture::ShadePull { side: ShadeSide::Notifications, progress: 0.5 })); + assert!(island.hidden() && !island.expanded, "a pull docks the island and closes the card"); + settle(&mut island, 1.0); + assert!((island.presence - 0.5).abs() < 0.01, "docked with the pull"); + island.step(1.0 / 60.0, 2.0, Some(ShellGesture::Cancel(GestureKind::Shade(ShadeSide::Notifications)))); + settle(&mut island, 2.0); + assert!(!island.hidden() && island.presence > 0.99, "a cancelled pull brings it back"); + island.step(1.0 / 60.0, 3.0, Some(ShellGesture::ShadePull { side: ShadeSide::Controls, progress: 1.0 })); + island.step(1.0 / 60.0, 3.0, Some(ShellGesture::Commit(GestureKind::Shade(ShadeSide::Controls)))); + assert!(island.shade_open && island.hidden()); + settle(&mut island, 3.0); + assert!(island.presence < 0.01, "gone while the shade is open"); + island.on_hit(IslandHit::Toggle, 4.0); + assert!(!island.expanded, "no card under the shade"); + island.set_shade_open(false); + settle(&mut island, 5.0); + assert!(!island.hidden() && island.presence > 0.99); + } + + #[test] + fn reports_begin_update_and_finish_activities() { + let mut island = IslandState::default(); + island.apply(Report::Begin { id: "rep:1".into(), source: "appcard".into(), label: "AppCard".into(), title: "weather tokyo".into() }, 1.0); + assert_eq!(island.primary().map(|a| a.title.as_str()), Some("weather tokyo")); + island.apply(Report::Progress { id: "rep:1".into(), done: 1, total: 2, segments: vec![], detail: "routing".into() }, 2.0); + let a = island.get("rep:1").unwrap(); + assert_eq!((a.status(2.0).as_str(), a.detail.as_str(), a.started), ("1/2", "routing", 1.0)); + island.apply(Report::Finish { id: "rep:1".into() }, 3.0); + assert!(island.get("rep:1").unwrap().done()); + // The primary prefers live work over finished work, and the focus over both. + island.push(elapsed("rep:2", 4.0)); + assert_eq!(island.primary().map(|a| a.id.as_str()), Some("rep:2")); + island.focus = Some("rep:1".into()); + assert_eq!(island.primary().map(|a| a.id.as_str()), Some("rep:1")); + } +} diff --git a/src/mobile_surface.rs b/src/mobile_surface.rs index 6875e920..81ac3e7a 100644 --- a/src/mobile_surface.rs +++ b/src/mobile_surface.rs @@ -377,6 +377,7 @@ impl PhoneSurface { self.d.icon_centered(cx,Ico::Wifi,rect(screen.pos.x+screen.size.x-69.0,screen.pos.y,22.0,status_h),14.0,ink); self.rounded(cx,rect(screen.pos.x+screen.size.x-40.0,screen.pos.y+(status_h-11.0)*0.5,23.0,11.0),3.0,alpha(ink,0.45)); self.rounded(cx,rect(screen.pos.x+screen.size.x-38.0,screen.pos.y+(status_h-7.0)*0.5,16.0,7.0),1.5,ink); + crate::mobile_island::draw(cx,&mut self.chrome,&mut self.d,&mut self.icons,&mut self.hits,state,screen); if phone.overview>0.01 { for (index,client) in phone.order.iter().enumerate() { if let Some(slot)=state.clients.get(client) { From 7296b431e92731ea5b2ef90ed1f917c84e5b8134 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:55:48 -0700 Subject: [PATCH 04/13] mobile: tile groups as sub-windows, app pairs, split screen from Recents A group tile on the home page (mobile_groups.rs) opens as a frosted sub-window grown out of its tile, holding its members as tiles with their live captures; a group of exactly two is an app pair whose "Open both" runs a split screen. Split{left,right,ratio} gives two clients each a pane of the app area (top/bottom in portrait, side by side in landscape): the desk records each pane's capture at its own rect so both apps relayout live, the divider drags the ratio and is an exclusion band, and Recents enters the same split from two cards. Leaving the app screen (HomeUp) leaves the split. Hooks in shared files are one-liners: the PhoneState field, the PhoneHit variants, a TileKind::Group placed by home_layout_for_apps, the group face in draw_home, the window in the desk scene, the pane and the foreground rule in the client loop, the divider drag, and the phone:, group: and split: test actions. Co-Authored-By: Claude Fable 5.1 --- src/desk.rs | 4 +- src/desk/phone.rs | 24 +- src/main.rs | 2 + src/mobile.rs | 10 +- src/mobile_app.rs | 21 +- src/mobile_groups.rs | 754 ++++++++++++++++++++++++++++++++++++++++++ src/mobile_surface.rs | 29 +- src/mobile_tiles.rs | 39 ++- 8 files changed, 860 insertions(+), 23 deletions(-) create mode 100644 src/mobile_groups.rs diff --git a/src/desk.rs b/src/desk.rs index 9a6776b9..85a8154d 100644 --- a/src/desk.rs +++ b/src/desk.rs @@ -804,12 +804,12 @@ struct TabHit { #[derive(Script, WidgetRef, WidgetSet, WidgetRegister)] pub struct WmDesk { - #[live] phone_ui: PhoneSurface, + #[live] pub phone_ui: PhoneSurface, #[live] draw_phone: DrawPhoneApp, #[rust] phone_frames: HashMap, #[rust] desktop_frames: HashMap, #[rust] pub wallpaper: WidgetRef, - #[rust] compositor: Option, + #[rust] pub compositor: Option, #[rust] composing: bool, #[rust] terminal_clients: HashSet, #[rust] blur_counts: (usize, usize), diff --git a/src/desk/phone.rs b/src/desk/phone.rs index 92e36f84..ff31fcda 100644 --- a/src/desk/phone.rs +++ b/src/desk/phone.rs @@ -116,6 +116,22 @@ impl WmDesk { capture.frame.end(cx); self.compositor.as_mut().unwrap().content_pass(capture.frame.pass_id()); } + /// A group member's live look (mobile_groups.rs): its compact capture, + /// else its full one, fitted into `cell` by aspect. False without one. + pub(crate) fn present_member_capture(&mut self,cx:&mut Cx2d,client:ClientId,cell:Rect,opacity:f32,radius:f32)->bool { + let Some(stored)=self.phone_frames.remove(&client) else {return false}; + let shown=stored.tile.as_ref().or(stored.full.as_ref()).filter(|c|c.size.x>=1.0).map(|c|(c.size,c.frame.texture().clone())); + if let Some((size,texture))=&shown { + let rect=crate::mobile_groups::fit(cell,*size); + self.draw_phone.draw_vars.set_texture(0,texture); + self.draw_phone.opacity=opacity; + self.draw_phone.radius=radius*0.5; + self.draw_phone.y_flip=0.0; + self.draw_phone.draw_abs(cx,rect); + } + self.phone_frames.insert(client,stored); + shown.is_some() + } fn present_capture(&mut self,cx:&mut Cx2d,capture:&Capture,rect:Rect,opacity:f32,radius:f32) { self.draw_phone.draw_vars.set_texture(0,capture.frame.texture()); self.draw_phone.opacity=opacity; @@ -142,6 +158,7 @@ impl WmDesk { (*slot,client,status,connected) }).collect(); for (slot,client,status,connected) in slots { + if matches!(slot.kind,crate::mobile_tiles::TileKind::Group(_)) {continue;} let gave_up=phone.tiles.gave_up(slot.app); let entry=client.and_then(|c|phone.tiles.get(c)); let mut shown=false; @@ -214,6 +231,7 @@ impl WmDesk { self.phone_ui.draw_home(cx,state,screen,home_backdrop); self.compositor.as_mut().unwrap().content(screen); if phone.home_visible() {self.draw_home_tiles(cx,scope,screen);} + if phone.groups.window_visible() {let state=scope.data.get_mut::().unwrap();self.draw_group_window(cx,state,screen);} if phone.overview>0.001 { let blur = (phone.overview.clamp(0.0, 1.0) * 3.0) as f32; self.phone_ui.overview_glass.set_blurriness(cx, blur); @@ -228,7 +246,9 @@ impl WmDesk { if let Some(c)=phone.client {order.retain(|i|*i!=c);order.push(c);} } for client in order { - let foreground=phone.client==Some(client); + let foreground=phone.client==Some(client) || (phone.screen==PhoneScreen::App && phone.groups.in_split(client)); + // In a split each client gets its pane, so it lays out for it. + let app=phone.groups.pane(client,app); if !foreground && phone.overview<0.001 {continue;} if foreground && phone.openness<0.001 {continue;} let index=phone.order.iter().position(|c|*c==client).unwrap_or(0); @@ -286,7 +306,7 @@ impl WmDesk { let input=matches!(event,Event::TouchUpdate(_)|Event::MouseDown(_)|Event::MouseUp(_)|Event::MouseMove(_)|Event::Scroll(_)|Event::KeyDown(_)|Event::KeyUp(_)|Event::TextInput(_)); let client=state.phone.client; if input && !state.phone.accepts_app_input() {return;} - let items:Vec<_>=self.items.iter().filter(|(c,_)|!input || Some(**c)==client).map(|(_,w)|w.clone()).collect(); + let items:Vec<_>=self.items.iter().filter(|(c,_)|!input || Some(**c)==client || state.phone.groups.in_split(**c)).map(|(_,w)|w.clone()).collect(); for item in items {item.handle_event(cx,event,scope);} } } diff --git a/src/main.rs b/src/main.rs index e15b7d4b..4cdbffdf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,6 +26,7 @@ mod mobile_surface; mod mobile_gestures; mod mobile_app; mod mobile_tiles; +mod mobile_groups; mod scene; mod dock_warp; mod host; @@ -3608,6 +3609,7 @@ impl App { if let Some(name) = args.get(i + 1) { // launch-: spawn a registered app directly — the // deterministic way to put one app on the desk in a test. + if self.groups_test_action(cx, name) { i += 2; continue; } if let Some(app) = name.strip_prefix("launch-") { let app = app.to_string(); log!("wm: --test-action launch {}", app); diff --git a/src/mobile.rs b/src/mobile.rs index bba09f53..8e89fe29 100644 --- a/src/mobile.rs +++ b/src/mobile.rs @@ -12,6 +12,10 @@ pub enum PhoneHit { App(String), Card(ClientId), Home, Recents, Drawer, Back, Rotate, Style, Appearance, Desktop, Key(String), Shift, Symbols, HideKeyboard, ClearSearch, CancelSearch, + /// Tile groups (mobile_groups.rs): the tile, a member in its window, + /// the window's scrim, a pair's "Open both", a Recents card's split + /// button and the split divider. + Group(String), GroupApp(String, String), GroupClose, OpenBoth(String), Split(ClientId), Divider, } #[derive(Clone)] @@ -62,6 +66,8 @@ pub struct PhoneState { /// Rects apps own on screen; shell gestures starting inside them are not /// recognised. pub exclusions: crate::mobile_gestures::ExclusionZones, + /// Tile groups, the open group window and the split screen (mobile_groups.rs). + pub groups: crate::mobile_groups::GroupsState, } impl Default for PhoneState { fn default() -> Self { @@ -73,7 +79,8 @@ impl Default for PhoneState { desktop_size: None, desktop_clients: Vec::new(), desktop_style: DesktopStyle::Omarchy, viewport: Rect::default(), tiles: HomeTiles::default(), gesture_out: None, - exclusions: Default::default() } + exclusions: Default::default(), + groups: Default::default() } } } impl PhoneState { @@ -128,6 +135,7 @@ impl PhoneState { self.keyboard += (self.keyboard_target - self.keyboard) * t; if (self.keyboard_target - self.keyboard).abs() < 0.25 { self.keyboard = self.keyboard_target; } active |= self.keyboard != self.keyboard_target; + active |= self.groups.step(dt); if self.gesture.is_none() { let target = self.page.round().clamp(0.0, self.order.len().saturating_sub(1) as f64); self.page += (target - self.page) * t; diff --git a/src/mobile_app.rs b/src/mobile_app.rs index 8715ff96..2f4e68fd 100644 --- a/src/mobile_app.rs +++ b/src/mobile_app.rs @@ -67,8 +67,10 @@ impl App { .collect::>() .into_iter() .filter_map(|(client, app, background)| { + // A split member is in front too, at its pane's size. + let foreground = if self.state_mut().phone.groups.in_split(client) { Some(client) } else { foreground }; let face = mobile_tiles::wanted_face(client, foreground, settled, background)?; - let viewport = match face { Face::Full => full, Face::Tile => self.tile_viewport(&app)? }; + let viewport = match face { Face::Full => self.state_mut().phone.groups.pane_size(client, full), Face::Tile => self.tile_viewport(&app)? }; if viewport.x < 1.0 || viewport.y < 1.0 { return None; } self.state_mut().phone.tiles.pending(client, face, viewport).then_some((client, face, viewport)) }) @@ -280,6 +282,7 @@ impl App { let phone = &mut self.state_mut().phone; phone.wallpaper_time = frame.time; let moving = phone.step(dt); + crate::mobile_groups::follow(phone); let wallpaper_visible = phone.screen != PhoneScreen::App || phone.openness < 0.999 || phone.overview > 0.001; if moving || wallpaper_visible {self.phone_frame=cx.new_next_frame();} // The tiles follow the phone state every frame: a window @@ -349,7 +352,20 @@ impl App { if let Some(client)=existing {self.activate_client(cx,client);} else {self.launch_app(cx,&app);} }, - PhoneHit::Card(client)=>self.activate_client(cx,client), + PhoneHit::Card(client)=>{ + match self.state_mut().phone.groups.pick.filter(|p|*p!=client) { + Some(first)=>{self.state_mut().phone.groups.pick=None;self.enter_split(cx,first,client);} + None=>self.activate_client(cx,client), + } + } + PhoneHit::Group(name)=>self.open_group(cx,&name), + PhoneHit::GroupApp(_,app)=>{self.state_mut().phone.groups.close();self.phone_action(cx,PhoneHit::App(app));return;} + PhoneHit::GroupClose=>self.state_mut().phone.groups.close(), + PhoneHit::OpenBoth(name)=>{self.open_pair(cx,&name);} + PhoneHit::Split(client)=>{ + if let Some((first,second))=self.state_mut().phone.groups.pick_card(client) {self.enter_split(cx,first,second);} + } + PhoneHit::Divider=>{} PhoneHit::Home=>self.state_mut().phone.navigate(PhoneScreen::Home), PhoneHit::Recents=>self.state_mut().phone.navigate(PhoneScreen::Recents), PhoneHit::Drawer=>self.state_mut().phone.navigate(PhoneScreen::Drawer), @@ -523,6 +539,7 @@ impl App { if delta.y.abs()>delta.x.abs()*1.2 {phone.dismiss_y=delta.y.min(0.0);} else {let width=card_rect(screen,0.0,0.0).size.x+22.0;phone.page=(phone.page-last.x/width).clamp(-0.25,phone.order.len().saturating_sub(1)as f64+0.25);} }else if g.edge {phone.openness=(1.0-delta.x.max(0.0)/screen.size.x*0.6).clamp(0.4,1.0);} + else if g.hit==Some(PhoneHit::Divider) {phone.groups.drag_divider(p,app_rect(screen));} self.animate_phone(cx);true } PhonePointerPhase::Up=>{ diff --git a/src/mobile_groups.rs b/src/mobile_groups.rs new file mode 100644 index 00000000..48b52fdd --- /dev/null +++ b/src/mobile_groups.rs @@ -0,0 +1,754 @@ +//! Tile groups on the phone home page: a group tile opens as a sub-window +//! holding its members' tiles (live captures where a member runs); a group +//! of exactly two doubles as an app pair, which "Open both" runs as a split +//! screen (two live clients, top/bottom in portrait, side by side in +//! landscape). Recents enters the same split from two cards. The model, +//! the animation state and the geometry live here, pure and tested; the +//! drawing and the App-side transitions sit in `impl` blocks below so the +//! shared files only carry one-line hooks. +//! +//! Gesture contract (mobile_gestures.rs): a committed `HomeUp` leaves the +//! split like leaving an app; the divider's band is an exclusion zone, so +//! no shell gesture starts on it. +use crate::{hub::ClientId, mobile::{self, PhoneHit, PhoneScreen, PhoneState}, mobile_gestures::{GestureKind, ShellGesture}, mobile_tiles::{TileKind, TileSlot, TILE_RADIUS}}; +use makepad_widgets::*; + +/// The groups every phone starts with, from the linked apps. A member that +/// is not in this build's catalog is simply absent from the face; a group +/// with fewer than two members present is not placed at all (one app is +/// just that app's icon). +/// TODO: read groups from the `.octosense` settings next to the catalog +/// `apps.rs` loads, so people can make their own. +pub const SEED_GROUPS: [(&str, &[&str]); 2] = [ + ("Work", &["sheets", "reference"]), + ("Media", &["photos", "appcard"]), +]; + +/// Height of a group tile in portrait: a chip under the app tiles that +/// still leaves the favorites their rows. +pub const GROUP_TILE_HEIGHT: f64 = 76.0; +/// The divider's ratio is kept away from the edges so neither app is +/// squeezed to nothing. +pub const SPLIT_MIN_RATIO: f64 = 0.25; +pub const SPLIT_MAX_RATIO: f64 = 0.75; +/// The gap between the two panes, and the band around it that drags the +/// divider (and excludes shell gestures). +pub const SPLIT_GAP: f64 = 8.0; +pub const DIVIDER_BAND: f64 = 32.0; + +/// A named set of apps. `pair` is derived: exactly two members. +#[derive(Clone, Debug, PartialEq)] +pub struct TileGroup { + pub name: String, + pub apps: Vec, + pub pair: bool, +} + +impl TileGroup { + pub fn new(name: &str, apps: &[&str]) -> Self { + let mut group = Self { name: name.to_string(), apps: apps.iter().map(|a| a.to_string()).collect(), pair: false }; + group.refresh(); + group + } + fn refresh(&mut self) { + self.apps.dedup(); + self.pair = self.apps.len() == 2; + } + pub fn add(&mut self, app: &str) { + if !self.apps.iter().any(|a| a == app) { self.apps.push(app.to_string()); } + self.refresh(); + } + pub fn remove(&mut self, app: &str) { + self.apps.retain(|a| a != app); + self.refresh(); + } + /// The members this catalog can launch, in group order. + pub fn present<'a>(&'a self, available: &[&str]) -> Vec<&'a str> { + self.apps.iter().map(String::as_str).filter(|a| available.contains(a)).collect() + } +} + +/// Two clients sharing the app area. `left` is the first pane (top in +/// portrait); `ratio` is its share of the area along the split axis. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Split { + pub left: ClientId, + pub right: ClientId, + pub ratio: f64, +} + +impl Split { + pub fn new(left: ClientId, right: ClientId) -> Self { Self { left, right, ratio: 0.5 } } + pub fn has(&self, client: ClientId) -> bool { self.left == client || self.right == client } + pub fn set_ratio(&mut self, ratio: f64) { self.ratio = ratio.clamp(SPLIT_MIN_RATIO, SPLIT_MAX_RATIO); } + pub fn swap(&mut self) { + std::mem::swap(&mut self.left, &mut self.right); + self.ratio = 1.0 - self.ratio; + } + /// Portrait splits top/bottom, landscape left/right. + pub fn vertical(app: Rect) -> bool { app.size.x <= app.size.y } + /// The pane `client` gets of `app`, or None when it is not in the split. + pub fn pane(&self, client: ClientId, app: Rect) -> Option { + let first = if client == self.left { true } else if client == self.right { false } else { return None }; + let half = SPLIT_GAP * 0.5; + Some(if Self::vertical(app) { + let cut = app.size.y * self.ratio; + if first { Rect { pos: app.pos, size: dvec2(app.size.x, (cut - half).max(1.0)) } } + else { Rect { pos: app.pos + dvec2(0.0, cut + half), size: dvec2(app.size.x, (app.size.y - cut - half).max(1.0)) } } + } else { + let cut = app.size.x * self.ratio; + if first { Rect { pos: app.pos, size: dvec2((cut - half).max(1.0), app.size.y) } } + else { Rect { pos: app.pos + dvec2(cut + half, 0.0), size: dvec2((app.size.x - cut - half).max(1.0), app.size.y) } } + }) + } + /// The band across the divider that drags it. + pub fn divider(&self, app: Rect) -> Rect { + if Self::vertical(app) { + Rect { pos: dvec2(app.pos.x, app.pos.y + app.size.y * self.ratio - DIVIDER_BAND * 0.5), size: dvec2(app.size.x, DIVIDER_BAND) } + } else { + Rect { pos: dvec2(app.pos.x + app.size.x * self.ratio - DIVIDER_BAND * 0.5, app.pos.y), size: dvec2(DIVIDER_BAND, app.size.y) } + } + } + /// The finger is at `p`: the divider follows it. Dragging past the + /// far side of the other pane swaps the two. + pub fn drag_to(&mut self, p: Vec2d, app: Rect) { + let raw = if Self::vertical(app) { (p.y - app.pos.y) / app.size.y.max(1.0) } else { (p.x - app.pos.x) / app.size.x.max(1.0) }; + self.set_ratio(raw); + } +} + +/// The groups on this phone and everything animated around them. +#[derive(Clone, Debug)] +pub struct GroupsState { + pub groups: Vec, + /// The group whose sub-window is open (or closing while `openness` falls). + pub open: Option, + /// 0 = tile, 1 = the sub-window fully grown. + pub openness: f64, + pub split: Option, + /// Recents: the first card picked for a split, waiting for a second. + pub pick: Option, + /// The tile the open sub-window grew out of, remembered while it closes + /// (the home layout may not be at hand then). + pub origin: Option, + /// The divider band handed to the exclusion zones last frame, so it + /// is replaced rather than piled up. + excluded: Option, + /// The last group name that was closing, so `openness` keeps a target. + closing: Option, +} + +impl Default for GroupsState { + fn default() -> Self { + Self { + groups: SEED_GROUPS.iter().map(|(name, apps)| TileGroup::new(name, apps)).collect(), + open: None, openness: 0.0, split: None, pick: None, origin: None, excluded: None, closing: None, + } + } +} + +impl GroupsState { + pub fn get(&self, name: &str) -> Option<&TileGroup> { self.groups.iter().find(|g| g.name == name) } + pub fn get_mut(&mut self, name: &str) -> Option<&mut TileGroup> { self.groups.iter_mut().find(|g| g.name == name) } + /// The group whose window is on screen: the open one, or the one still + /// shrinking back into its tile. + pub fn shown(&self) -> Option<&TileGroup> { + self.open.as_deref().or(self.closing.as_deref()).and_then(|n| self.get(n)) + } + pub fn is_open(&self, name: &str) -> bool { self.open.as_deref() == Some(name) } + /// Names of the seeded groups this catalog can show (two or more + /// members present), in seed order. + pub fn placed(available: &[&str]) -> Vec<&'static str> { + SEED_GROUPS.iter().filter(|(_, apps)| apps.iter().filter(|a| available.contains(a)).count() >= 2).map(|(name, _)| *name).collect() + } + pub fn open_group(&mut self, name: &str, tile: Rect) { + if self.get(name).is_none() { return; } + self.open = Some(name.to_string()); + self.closing = None; + self.origin = Some(tile); + } + pub fn close(&mut self) { + if let Some(name) = self.open.take() { self.closing = Some(name); } + } + pub fn in_split(&self, client: ClientId) -> bool { self.split.is_some_and(|s| s.has(client)) } + /// The rect `client` draws in: its pane of `app` inside a split, the + /// whole of `app` otherwise. + pub fn pane(&self, client: ClientId, app: Rect) -> Rect { + self.split.and_then(|s| s.pane(client, app)).unwrap_or(app) + } + pub fn pane_size(&self, client: ClientId, full: Vec2d) -> Vec2d { + self.pane(client, Rect { pos: dvec2(0.0, 0.0), size: full }).size + } + pub fn enter_split(&mut self, left: ClientId, right: ClientId) -> bool { + if left == right { return false; } + self.split = Some(Split::new(left, right)); + self.pick = None; + self.close(); + true + } + pub fn leave_split(&mut self) { self.split = None; self.pick = None; } + /// Recents: a card's split button was tapped. The first tap remembers + /// the card; the second (on another card) makes the split. Returns the + /// pair to enter, if this tap completed one. + pub fn pick_card(&mut self, client: ClientId) -> Option<(ClientId, ClientId)> { + match self.pick { + Some(first) if first != client => { self.pick = None; Some((first, client)) } + Some(_) => { self.pick = None; None } + None => { self.pick = Some(client); None } + } + } + /// The divider's drag: the finger is at `p` over the app area `app`. + pub fn drag_divider(&mut self, p: Vec2d, app: Rect) { + if let Some(split) = self.split.as_mut() { split.drag_to(p, app); } + } + /// A shell gesture was recognised: a committed HomeUp leaves the split. + pub fn on_gesture(&mut self, gesture: &ShellGesture) { + if matches!(gesture, ShellGesture::Commit(GestureKind::HomeUp)) { self.leave_split(); } + } + /// Keep the split tied to the foreground: leaving the app screen, or + /// bringing a third client in front, ends it (the panes go back to + /// full size on their next frame). + pub fn follow(&mut self, screen: PhoneScreen, client: Option) { + if let Some(split) = self.split { + let stays = screen == PhoneScreen::App && client.is_some_and(|c| split.has(c)); + if !stays { self.leave_split(); } + } + if screen != PhoneScreen::Recents { self.pick = None; } + if screen != PhoneScreen::Home && self.open.is_some() { self.close(); } + } + /// The divider band as an exclusion zone, replacing last frame's. + pub fn sync_exclusions(&mut self, screen: PhoneScreen, app: Rect, zones: &mut crate::mobile_gestures::ExclusionZones) { + if let Some(old) = self.excluded.take() { zones.zones.retain(|z| z.rect != old); } + if screen != PhoneScreen::App { return; } + if let Some(split) = self.split { + let band = split.divider(app); + zones.add(band, [true; 4]); + self.excluded = Some(band); + } + } + /// Animate the sub-window. True while it is still moving. + pub fn step(&mut self, dt: f64) -> bool { + let target = if self.open.is_some() { 1.0 } else { 0.0 }; + let t = 1.0 - (-dt * 16.0).exp(); + self.openness += (target - self.openness) * t; + if (self.openness - target).abs() < 0.002 { self.openness = target; } + if self.openness == 0.0 { self.closing = None; self.origin = None; } + self.openness != target + } + pub fn window_visible(&self) -> bool { self.openness > 0.001 } +} + +/// Everything the animation frame needs from the phone state, in one call. +pub fn follow(phone: &mut PhoneState) { + if let Some(gesture) = phone.gesture_out.as_ref() { phone.groups.on_gesture(gesture); } + phone.groups.follow(phone.screen, phone.client); + let app = mobile::app_rect(phone.viewport); + phone.groups.sync_exclusions(phone.screen, app, &mut phone.exclusions); +} + +// -------------------------------------------------------------------- +// Geometry of the sub-window +// -------------------------------------------------------------------- + +/// One member's cell in the open group window. +#[derive(Clone, Debug, PartialEq)] +pub struct MemberCell { pub app: String, pub rect: Rect } + +/// The group window at one moment of its animation. +#[derive(Clone, Debug, PartialEq)] +pub struct GroupWindow { + pub panel: Rect, + pub title: Rect, + pub cells: Vec, + /// The pair's "Open both" button, once it is a pair with both present. + pub open_both: Option, + pub t: f64, +} + +fn ease(t: f64) -> f64 { let t = t.clamp(0.0, 1.0); 1.0 - (1.0 - t) * (1.0 - t) } + +/// The window `group` grows into on `screen`, at animation `t` (0 = the +/// tile it came from, 1 = fully open). Members sit two across. +pub fn group_window(screen: Rect, tile: Rect, members: &[&str], pair: bool, t: f64) -> GroupWindow { + let landscape = screen.size.x > screen.size.y; + let margin = if landscape { 60.0 } else { 22.0 }; + let width = (screen.size.x - margin * 2.0).min(if landscape { 560.0 } else { 420.0 }); + let columns = if landscape { members.len().clamp(1, 3) } else { members.len().clamp(1, 2) }; + let gap = 12.0; + let pad = 16.0; + let cell_w = (width - pad * 2.0 - gap * (columns as f64 - 1.0)) / columns as f64; + let cell_h = (cell_w * if landscape { 0.62 } else { 0.78 }).round(); + let rows = (members.len().max(1) + columns - 1) / columns; + let title_h = 46.0; + let button_h = if pair && members.len() == 2 { 52.0 } else { 0.0 }; + let height = title_h + pad + rows as f64 * (cell_h + gap) - gap + button_h + pad; + let height = height.min(screen.size.y - 100.0).max(120.0); + let full = Rect { pos: dvec2(screen.pos.x + (screen.size.x - width) * 0.5, screen.pos.y + (screen.size.y - height) * 0.5 - 20.0), size: dvec2(width, height) }; + let panel = mobile::mix_rect(tile, full, ease(t)); + let scale = (panel.size.x / full.size.x).max(0.01); + let map = |r: Rect| Rect { pos: panel.pos + (r.pos - full.pos) * scale, size: r.size * scale }; + let title = map(Rect { pos: full.pos + dvec2(pad, 0.0), size: dvec2(width - pad * 2.0, title_h) }); + let cells = members.iter().enumerate().map(|(i, app)| { + let col = (i % columns) as f64; + let row = (i / columns) as f64; + let r = Rect { pos: full.pos + dvec2(pad + col * (cell_w + gap), title_h + pad * 0.5 + row * (cell_h + gap)), size: dvec2(cell_w, cell_h) }; + MemberCell { app: app.to_string(), rect: map(r) } + }).collect(); + let open_both = (button_h > 0.0).then(|| map(Rect { pos: full.pos + dvec2(pad, height - pad - button_h + 6.0), size: dvec2(width - pad * 2.0, button_h - 6.0) })); + GroupWindow { panel, title, cells, open_both, t } +} + +/// Fit `size`'s aspect inside `cell`, centred. +pub fn fit(cell: Rect, size: Vec2d) -> Rect { + if size.x < 1.0 || size.y < 1.0 { return cell; } + let scale = (cell.size.x / size.x).min(cell.size.y / size.y); + let fitted = size * scale; + Rect { pos: cell.pos + (cell.size - fitted) * 0.5, size: fitted } +} + +/// The group tile in `tiles`, if it is placed. +pub fn tile_of<'a>(tiles: &'a [TileSlot], name: &str) -> Option<&'a TileSlot> { + tiles.iter().find(|s| matches!(s.kind, TileKind::Group(n) if n == name)) +} + +// -------------------------------------------------------------------- +// Drawing: the group face on the home page and the overlay chrome +// -------------------------------------------------------------------- + +use crate::{desk::WmDesk, desktop::DesktopStyle, mobile_surface::PhoneSurface, shell::{alpha, rgb, ui::{rect, HAlign}}}; + +impl PhoneSurface { + /// The group's face on the home page: a 2x2 mosaic of its members' + /// icons beside its name. Tapping it opens the sub-window. + pub(crate) fn draw_group_tile(&mut self, cx: &mut Cx2d, groups: &GroupsState, slot: TileSlot, available: &[&str], style: DesktopStyle, dark: bool, opacity: f32) { + let TileKind::Group(name) = slot.kind else { return }; + let Some(group) = groups.get(name) else { return }; + let members = group.present(available); + let r = slot.rect; + let ios = style == DesktopStyle::Ios; + let face = if dark { rgb(30, 32, 46) } else if ios { rgb(246, 247, 252) } else { rgb(255, 251, 255) }; + let ink = if dark { rgb(240, 240, 248) } else { rgb(28, 27, 36) }; + let pressed = self.pressed_hit() == Some(&PhoneHit::Group(name.to_string())); + self.rounded(cx, r, TILE_RADIUS as f32, alpha(face, (if pressed { 0.95 } else { 0.82 }) * opacity)); + let mosaic = (r.size.y - 20.0).clamp(24.0, 64.0); + let mx = r.pos.x + 14.0; + let my = r.pos.y + (r.size.y - mosaic) * 0.5; + self.rounded(cx, rect(mx - 4.0, my - 4.0, mosaic + 8.0, mosaic + 8.0), 12.0, alpha(ink, 0.08 * opacity)); + let icon = (mosaic - 4.0) * 0.5; + for (i, app) in members.iter().take(4).enumerate() { + let ir = rect(mx + (i % 2) as f64 * (icon + 4.0), my + (i / 2) as f64 * (icon + 4.0), icon, icon); + self.icons.draw(cx, app, style, ir, opacity, alpha(ink, opacity)); + } + let text_x = mx + mosaic + 18.0; + let text_w = (r.pos.x + r.size.x - text_x - 10.0).max(10.0); + let mid = r.pos.y + r.size.y * 0.5; + self.d.label_elided(cx, rect(text_x, mid - 22.0, text_w, 24.0), true, 15.0, alpha(ink, opacity), HAlign::Left, name); + let sub = if group.pair && members.len() == 2 { "App pair".to_string() } else { format!("{} app{}", members.len(), if members.len() == 1 { "" } else { "s" }) }; + self.d.label_elided(cx, rect(text_x, mid + 2.0, text_w, 20.0), false, 12.0, alpha(ink, 0.6 * opacity), HAlign::Left, &sub); + self.hits.push((r, PhoneHit::Group(name.to_string()))); + } + + /// The overlay's part: the split divider (draggable, an exclusion + /// band) and the split buttons on the Recents cards. + pub(crate) fn draw_groups_overlay(&mut self, cx: &mut Cx2d, state: &crate::desk::WmState, screen: Rect) { + let phone = &state.phone; + let ink = if state.style.dark { rgb(238, 238, 242) } else { rgb(30, 30, 34) }; + if let Some(split) = phone.groups.split.filter(|_| phone.screen == PhoneScreen::App && phone.openness > 0.5 && phone.overview < 0.01) { + let app = mobile::app_rect(screen); + let band = split.divider(app); + let vertical = Split::vertical(app); + let pill = if vertical { rect(band.pos.x + band.size.x * 0.5 - 28.0, band.pos.y + band.size.y * 0.5 - 2.5, 56.0, 5.0) } + else { rect(band.pos.x + band.size.x * 0.5 - 2.5, band.pos.y + band.size.y * 0.5 - 28.0, 5.0, 56.0) }; + let pressed = self.pressed_hit() == Some(&PhoneHit::Divider); + self.rounded(cx, pill, 2.5, alpha(ink, if pressed { 0.9 } else { 0.55 })); + self.hits.push((band, PhoneHit::Divider)); + } + if phone.screen == PhoneScreen::Recents && phone.overview > 0.5 { + let accent = if state.style.target == DesktopStyle::Ios { rgb(0, 122, 255) } else { rgb(103, 80, 164) }; + for (index, client) in phone.order.iter().enumerate() { + let card = mobile::card_rect(screen, index as f64, phone.page); + if card.pos.x + card.size.x < screen.pos.x || card.pos.x > screen.pos.x + screen.size.x { continue; } + let picked = phone.groups.pick == Some(*client); + let r = rect(card.pos.x + card.size.x - 84.0, card.pos.y - 38.0, 84.0, 30.0); + self.rounded(cx, r, 15.0, if picked { alpha(accent, 0.95) } else { alpha(rgb(255, 255, 255), 0.22) }); + let label = if picked { "Picked" } else if phone.groups.pick.is_some() { "Pair with" } else { "Split" }; + self.label(cx, r, label, 12.0, true, rgb(255, 255, 255)); + self.hits.push((r, PhoneHit::Split(*client))); + } + if phone.groups.pick.is_some() { + let hint = rect(screen.pos.x, screen.pos.y + screen.size.y - 110.0, screen.size.x, 24.0); + self.label(cx, hint, "Tap another app to open both", 13.0, false, alpha(rgb(255, 255, 255), 0.85)); + } + } + } +} + +impl WmDesk { + /// The open group's sub-window: a frosted panel grown out of its tile, + /// its members as tiles (their live captures where they run), and for + /// a pair the "Open both" button. Drawn inside the compositor so the + /// panel can blur what is under it; tapping outside closes it. + pub(crate) fn draw_group_window(&mut self, cx: &mut Cx2d, state: &crate::desk::WmState, screen: Rect) { + let phone = &state.phone; + let groups = &phone.groups; + if !groups.window_visible() { return; } + let Some(group) = groups.shown() else { return }; + let style = state.style.target; + let dark = state.style.dark; + let ios = style == DesktopStyle::Ios; + let catalog = crate::shell::launcher::apps(); + let available: Vec<&str> = catalog.iter().map(|a| a.id.trim_start_matches("apps.")).collect(); + let members = group.present(&available); + let layout = PhoneSurface::home_layout(style, screen); + let tile = tile_of(&layout.tiles, &group.name).map(|s| s.rect).or(groups.origin) + .unwrap_or(Rect { pos: screen.pos + screen.size * 0.5, size: dvec2(1.0, 1.0) }); + let window = group_window(screen, tile, &members, group.pair, groups.openness); + let t = window.t as f32; + let face = if dark { rgb(30, 32, 46) } else if ios { rgb(246, 247, 252) } else { rgb(255, 251, 255) }; + let ink = if dark { rgb(240, 240, 248) } else { rgb(28, 27, 36) }; + // The scrim: dims the page and catches the tap that closes. + self.phone_ui.rounded(cx, screen, 0.0, alpha(rgb(0, 0, 0), 0.32 * t)); + if groups.open.is_some() { self.phone_ui.hits.push((screen, PhoneHit::GroupClose)); } + let backdrop = self.compositor.as_mut().unwrap().backdrop(cx, window.panel, 4.0); + self.phone_ui.group_glass.draw_surface_with_backdrop(cx, window.panel, Some(backdrop), t); + self.phone_ui.rounded(cx, window.panel, 28.0, alpha(face, 0.55 * t)); + self.compositor.as_mut().unwrap().content(window.panel); + let label_a = (t * t).max(0.0); + self.phone_ui.d.label_elided(cx, window.title, true, 17.0, alpha(ink, label_a), HAlign::Left, &group.name); + let client_of = |app: &str| state.clients.iter() + .filter(|(_, s)| s.app == app && !s.warm && !s.pane && !s.is_preview && s.closing.is_none()) + .map(|(c, _)| *c).min(); + for cell in &window.cells { + let r = cell.rect; + self.phone_ui.rounded(cx, r, 18.0, alpha(ink, 0.07 * t)); + let client = client_of(&cell.app); + let mut live = false; + if let Some(client) = client { + live = self.present_member_capture(cx, client, r, t, 18.0); + } + let size = (r.size.y.min(r.size.x) * 0.36).clamp(20.0, 56.0); + if !live { + self.phone_ui.icons.draw(cx, &cell.app, style, rect(r.pos.x + (r.size.x - size) * 0.5, r.pos.y + (r.size.y - size) * 0.5 - 10.0, size, size), t, alpha(ink, t)); + } else { + // A live member keeps its badge in the corner so the name stays readable. + let badge = (size * 0.5).max(16.0); + self.phone_ui.rounded(cx, rect(r.pos.x + 6.0, r.pos.y + r.size.y - badge - 8.0, badge + 4.0, badge + 4.0), 7.0, alpha(face, 0.85 * t)); + self.phone_ui.icons.draw(cx, &cell.app, style, rect(r.pos.x + 8.0, r.pos.y + r.size.y - badge - 6.0, badge, badge), t, alpha(ink, t)); + } + let name = crate::clients::find_app(&cell.app).map(|a| a.label).unwrap_or_else(|| cell.app.clone()); + let name_r = if live { rect(r.pos.x + size * 0.5 + 16.0, r.pos.y + r.size.y - 26.0, r.size.x - size * 0.5 - 24.0, 20.0) } else { rect(r.pos.x + 6.0, r.pos.y + r.size.y * 0.5 + size * 0.5 - 2.0, r.size.x - 12.0, 22.0) }; + if live { self.phone_ui.rounded(cx, rect(name_r.pos.x - 6.0, name_r.pos.y - 2.0, name_r.size.x + 12.0, name_r.size.y + 4.0), 8.0, alpha(face, 0.85 * t)); } + self.phone_ui.d.label_elided(cx, name_r, true, 13.0, alpha(ink, label_a), if live { HAlign::Left } else { HAlign::Center }, &name); + self.compositor.as_mut().unwrap().content(r); + if groups.open.is_some() { self.phone_ui.hits.push((r, PhoneHit::GroupApp(group.name.clone(), cell.app.clone()))); } + } + if let Some(button) = window.open_both { + let accent = if ios { rgb(0, 122, 255) } else { rgb(103, 80, 164) }; + let pressed = self.phone_ui.pressed_hit() == Some(&PhoneHit::OpenBoth(group.name.clone())); + self.phone_ui.rounded(cx, button, (button.size.y * 0.5) as f32, alpha(accent, (if pressed { 0.75 } else { 1.0 }) * t)); + self.phone_ui.d.label_elided(cx, button, true, 15.0, alpha(rgb(255, 255, 255), label_a), HAlign::Center, "Open both"); + self.compositor.as_mut().unwrap().content(button); + if groups.open.is_some() { self.phone_ui.hits.push((button, PhoneHit::OpenBoth(group.name.clone()))); } + } + } +} + +// -------------------------------------------------------------------- +// The App side: what a tap does +// -------------------------------------------------------------------- + +impl crate::App { + /// A tap on the group tile: the sub-window grows out of it. + pub(super) fn open_group(&mut self, cx: &mut Cx, name: &str) { + let state = self.state_mut(); + let screen = state.phone.viewport; + let tile = tile_of(&PhoneSurface::home_layout(state.style.target, screen).tiles, name).map(|s| s.rect) + .unwrap_or(Rect { pos: screen.pos + screen.size * 0.5, size: dvec2(1.0, 1.0) }); + state.phone.groups.open_group(name, tile); + self.animate_phone(cx); + } + /// Two clients side by side: both become windows the person opened, + /// `left` in front, and the split tells each its pane before the + /// zoom-in, so neither draws a full-screen frame into a half. + pub(super) fn enter_split(&mut self, cx: &mut Cx, left: ClientId, right: ClientId) { + if !self.state_mut().phone.groups.enter_split(left, right) { return; } + self.activate_client(cx, right); + self.activate_client(cx, left); + log!("wm: split screen {} | {}", left, right); + } + /// The pair's "Open both": every member that runs already joins as it + /// is; a missing one is launched first. Returns false when the pair is + /// not ready (the second tap, once both run, enters the split). + pub(super) fn open_pair(&mut self, cx: &mut Cx, name: &str) -> bool { + let Some(apps) = self.state_mut().phone.groups.get(name).filter(|g| g.pair).map(|g| g.apps.clone()) else { return false }; + let mut clients = Vec::new(); + for app in &apps { + match self.client_of_app(app) { + Some(client) => clients.push(client), + None => { self.launch_app(cx, app); if let Some(client) = self.client_of_app(app) { clients.push(client); } } + } + } + if let [left, right] = clients[..] { + self.enter_split(cx, left, right); + true + } else { + log!("wm: pair {} waits for its apps to start ({} of 2 running)", name, clients.len()); + self.state_mut().phone.groups.close(); + false + } + } + /// The running window of `app`, the way the home page finds it. + pub(super) fn client_of_app(&mut self, app: &str) -> Option { + self.state_mut().clients.iter() + .filter(|(_, s)| s.app == app && !s.warm && !s.pane && !s.is_preview && s.closing.is_none()) + .map(|(c, _)| *c).min() + } + /// `--test-action phone:` puts the desktop into that phone + /// shell; `group:` opens a group's window; `split:,` + /// launches both apps and enters a split. + pub(super) fn groups_test_action(&mut self, cx: &mut Cx, name: &str) -> bool { + if let Some(style) = name.strip_prefix("phone:") { + let style = if style.eq_ignore_ascii_case("ios") { DesktopStyle::Ios } else { DesktopStyle::Android }; + log!("wm: --test-action phone {:?}", style); + self.set_desktop_style(cx, style); + return true; + } + if let Some(group) = name.strip_prefix("group:") { + log!("wm: --test-action group {}", group); + self.open_group(cx, group); + return true; + } + if let Some(pair) = name.strip_prefix("split:") { + let apps: Vec = pair.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(); + let mut clients = Vec::new(); + for app in &apps { + if self.client_of_app(app).is_none() { self.launch_app(cx, app); } + if let Some(client) = self.client_of_app(app) { clients.push(client); } + } + log!("wm: --test-action split {:?} -> clients {:?}", apps, clients); + if let [left, right] = clients[..] { self.enter_split(cx, left, right); } + return true; + } + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mobile_gestures::{Dir, ExclusionZones}; + + fn app() -> Rect { Rect { pos: dvec2(0.0, 42.0), size: dvec2(412.0, 826.0) } } + + #[test] + fn a_group_of_exactly_two_is_a_pair() { + let mut g = TileGroup::new("Work", &["sheets", "reference"]); + assert!(g.pair); + g.add("photos"); + assert!(!g.pair); + g.remove("photos"); + assert!(g.pair); + g.add("sheets"); + assert_eq!(g.apps.len(), 2, "no duplicate members"); + g.remove("sheets"); + assert!(!g.pair && g.apps == ["reference"]); + assert_eq!(g.present(&["reference", "clock"]), ["reference"]); + let seeded = GroupsState::default(); + assert!(seeded.groups.iter().all(|g| g.pair), "the seed groups are pairs"); + assert_eq!(GroupsState::placed(&["reference", "sheets", "photos", "appcard"]), ["Work", "Media"]); + assert_eq!(GroupsState::placed(&["clock", "photos", "appcard"]), ["Media"]); + assert!(GroupsState::placed(&["clock", "photos"]).is_empty(), "one member present is just that app's icon"); + } + + #[test] + fn the_sub_window_grows_out_of_the_tile_and_back() { + let mut groups = GroupsState::default(); + let tile = Rect { pos: dvec2(16.0, 300.0), size: dvec2(183.0, 76.0) }; + assert!(!groups.window_visible()); + groups.open_group("Nope", tile); + assert!(groups.open.is_none(), "an unknown group does not open"); + groups.open_group("Work", tile); + assert!(groups.is_open("Work") && groups.origin == Some(tile)); + assert!(groups.step(1.0 / 60.0) && groups.window_visible()); + for _ in 0..90 { groups.step(1.0 / 60.0); } + assert_eq!(groups.openness, 1.0); + assert!(!groups.step(1.0 / 60.0), "settled"); + groups.close(); + assert!(groups.open.is_none() && groups.shown().is_some_and(|g| g.name == "Work"), "still drawn while it shrinks"); + assert!(groups.step(1.0 / 60.0) && groups.openness < 1.0); + for _ in 0..120 { groups.step(1.0 / 60.0); } + assert_eq!(groups.openness, 0.0); + assert!(groups.shown().is_none() && groups.origin.is_none()); + // The window's geometry follows the animation from the tile. + let screen = Rect { pos: dvec2(0.0, 0.0), size: dvec2(412.0, 892.0) }; + let start = group_window(screen, tile, &["sheets", "reference"], true, 0.0); + let end = group_window(screen, tile, &["sheets", "reference"], true, 1.0); + assert_eq!(start.panel, tile); + assert!(end.panel.size.x > 300.0 && end.panel.size.y > 200.0); + assert!(end.panel.pos.x >= screen.pos.x && end.panel.pos.x + end.panel.size.x <= screen.size.x); + assert_eq!(end.cells.len(), 2); + assert!(end.open_both.is_some(), "a pair offers Open both"); + assert!(group_window(screen, tile, &["sheets"], false, 1.0).open_both.is_none()); + let [a, b] = [end.cells[0].rect, end.cells[1].rect]; + assert!(a.pos.x + a.size.x <= b.pos.x, "two across"); + for cell in &end.cells { + assert!(cell.rect.pos.x >= end.panel.pos.x && cell.rect.pos.x + cell.rect.size.x <= end.panel.pos.x + end.panel.size.x + 0.01); + } + let button = end.open_both.unwrap(); + assert!(button.pos.y >= a.pos.y + a.size.y && button.pos.y + button.size.y <= end.panel.pos.y + end.panel.size.y + 0.01); + let mid = group_window(screen, tile, &["sheets", "reference"], true, 0.5); + assert!(mid.panel.size.x > tile.size.x && mid.panel.size.x < end.panel.size.x); + // Landscape stays inside a wide screen too. + let wide = Rect { pos: dvec2(0.0, 0.0), size: dvec2(892.0, 412.0) }; + let w = group_window(wide, tile, &["a", "b", "c"], false, 1.0); + assert!(w.panel.pos.y + w.panel.size.y <= wide.size.y && w.cells.len() == 3); + } + + #[test] + fn split_panes_share_the_app_area_and_the_ratio_is_clamped() { + let mut split = Split::new(3, 4); + let app = app(); + let top = split.pane(3, app).unwrap(); + let bottom = split.pane(4, app).unwrap(); + assert!(split.pane(9, app).is_none()); + assert_eq!(top.pos, app.pos); + assert!((top.size.y + bottom.size.y + SPLIT_GAP - app.size.y).abs() < 0.01, "the two halves and the gap fill the area"); + assert!(top.pos.y + top.size.y <= bottom.pos.y, "portrait: top over bottom"); + assert_eq!(top.size.x, app.size.x); + split.set_ratio(0.05); + assert_eq!(split.ratio, SPLIT_MIN_RATIO); + split.set_ratio(0.99); + assert_eq!(split.ratio, SPLIT_MAX_RATIO); + // The divider follows the finger, in app-area coordinates. + split.drag_to(dvec2(200.0, app.pos.y + app.size.y * 0.4), app); + assert!((split.ratio - 0.4).abs() < 0.001); + split.drag_to(dvec2(200.0, app.pos.y + app.size.y * 0.95), app); + assert_eq!(split.ratio, SPLIT_MAX_RATIO); + let band = split.divider(app); + assert!(band.contains(dvec2(100.0, app.pos.y + app.size.y * SPLIT_MAX_RATIO))); + assert_eq!(band.size, dvec2(app.size.x, DIVIDER_BAND)); + split.swap(); + assert_eq!((split.left, split.right), (4, 3)); + assert!((split.ratio - (1.0 - SPLIT_MAX_RATIO)).abs() < 1e-9); + // Landscape: side by side. + let wide = Rect { pos: dvec2(0.0, 24.0), size: dvec2(892.0, 364.0) }; + let split = Split::new(1, 2); + let l = split.pane(1, wide).unwrap(); + let r = split.pane(2, wide).unwrap(); + assert!(l.pos.x + l.size.x <= r.pos.x && l.size.y == wide.size.y); + assert_eq!(split.divider(wide).size, dvec2(DIVIDER_BAND, wide.size.y)); + let mut groups = GroupsState::default(); + groups.split = Some(Split::new(1, 2)); + assert_eq!(groups.pane_size(1, dvec2(412.0, 826.0)).y, 826.0 * 0.5 - SPLIT_GAP * 0.5); + assert_eq!(groups.pane_size(7, dvec2(412.0, 826.0)), dvec2(412.0, 826.0), "outside the split: the whole area"); + } + + #[test] + fn recents_picks_two_cards_into_a_split_and_home_leaves_it() { + let mut groups = GroupsState::default(); + assert_eq!(groups.pick_card(10), None); + assert_eq!(groups.pick, Some(10)); + assert_eq!(groups.pick_card(10), None, "the same card again unpicks"); + assert_eq!(groups.pick, None); + groups.pick_card(10); + assert_eq!(groups.pick_card(11), Some((10, 11))); + assert!(groups.enter_split(10, 11)); + assert!(!groups.enter_split(5, 5), "a client cannot split with itself"); + assert!(groups.in_split(10) && groups.in_split(11) && !groups.in_split(12)); + assert_eq!(groups.pick, None); + // The split survives the app screen with either member in front... + groups.follow(PhoneScreen::App, Some(11)); + assert!(groups.split.is_some()); + // ...ends when a third client comes in front, or the app screen is left. + groups.follow(PhoneScreen::App, Some(12)); + assert!(groups.split.is_none()); + groups.enter_split(10, 11); + groups.follow(PhoneScreen::Home, Some(10)); + assert!(groups.split.is_none()); + groups.enter_split(10, 11); + groups.follow(PhoneScreen::Recents, Some(10)); + assert!(groups.split.is_none(), "Recents is for choosing what comes next"); + // A committed HomeUp gesture leaves the split; a progress frame does not. + groups.enter_split(10, 11); + groups.on_gesture(&ShellGesture::HomeUp { progress: 0.5, held: false }); + assert!(groups.split.is_some()); + groups.on_gesture(&ShellGesture::Commit(GestureKind::QuickSwitch(Dir::Left))); + assert!(groups.split.is_some()); + groups.on_gesture(&ShellGesture::Commit(GestureKind::HomeUp)); + assert!(groups.split.is_none()); + // Entering a split from an open pair window closes the window. + groups.open_group("Work", Rect::default()); + groups.enter_split(10, 11); + assert!(groups.open.is_none()); + } + + #[test] + fn the_divider_band_is_an_exclusion_zone_only_while_split() { + let mut groups = GroupsState::default(); + let mut zones = ExclusionZones::default(); + let app = app(); + groups.sync_exclusions(PhoneScreen::App, app, &mut zones); + assert!(zones.zones.is_empty()); + groups.enter_split(1, 2); + groups.sync_exclusions(PhoneScreen::App, app, &mut zones); + assert_eq!(zones.zones.len(), 1); + let mid = dvec2(200.0, app.pos.y + app.size.y * 0.5); + assert!(zones.excludes(mid, crate::mobile_gestures::Edge::Bottom)); + groups.drag_divider(dvec2(200.0, app.pos.y + app.size.y * 0.3), app); + groups.sync_exclusions(PhoneScreen::App, app, &mut zones); + assert_eq!(zones.zones.len(), 1, "replaced, not piled up"); + assert!(!zones.excludes(mid, crate::mobile_gestures::Edge::Bottom)); + assert!(zones.excludes(dvec2(200.0, app.pos.y + app.size.y * 0.3), crate::mobile_gestures::Edge::Left)); + groups.leave_split(); + groups.sync_exclusions(PhoneScreen::App, app, &mut zones); + assert!(zones.zones.is_empty()); + // The whole phone state in one call: leaving for home clears it too. + let mut phone = PhoneState::default(); + phone.viewport = Rect { pos: dvec2(0.0, 0.0), size: dvec2(412.0, 892.0) }; + phone.activate(1); phone.activate(2); + phone.groups.enter_split(2, 1); + follow(&mut phone); + assert!(phone.groups.split.is_some() && phone.exclusions.zones.len() == 1); + phone.navigate(PhoneScreen::Home); + follow(&mut phone); + assert!(phone.groups.split.is_none() && phone.exclusions.zones.is_empty()); + } + + #[test] + fn group_tiles_sit_in_the_home_grid_and_leave_the_favorites_a_row() { + use crate::mobile_tiles::{home_layout_for_apps, TileKind}; + let screen = Rect { pos: dvec2(0.0, 78.0), size: dvec2(412.0, 814.0) }; + let top = screen.pos.y + 156.0; + let dock = PhoneSurface::home_dock(screen); + let layout = home_layout_for_apps(screen, top, dock, &["reference", "sheets", "photos", "appcard"]); + let groups: Vec<_> = layout.tiles.iter().filter(|t| matches!(t.kind, TileKind::Group(_))).collect(); + assert_eq!(groups.iter().map(|t| t.app).collect::>(), ["Work", "Media"]); + assert!(tile_of(&layout.tiles, "Work").is_some() && tile_of(&layout.tiles, "Nope").is_none()); + let apps: Vec<_> = layout.tiles.iter().filter(|t| !matches!(t.kind, TileKind::Group(_))).collect(); + assert_eq!(apps.len(), 2, "the app tiles are untouched"); + for g in &groups { + assert_eq!(g.rect.size.y, GROUP_TILE_HEIGHT); + assert!(apps.iter().all(|a| a.rect.pos.y + a.rect.size.y <= g.rect.pos.y), "groups sit under the app tiles"); + assert!(g.rect.pos.y + g.rect.size.y <= layout.favorites.pos.y); + } + assert!(groups[0].rect.pos.x + groups[0].rect.size.x <= groups[1].rect.pos.x, "two groups share a row"); + assert!(layout.capacity >= 4, "a row of favorites still fits: {}", layout.capacity); + let none = home_layout_for_apps(screen, top, dock, &["clock"]); + assert!(none.tiles.iter().all(|t| !matches!(t.kind, TileKind::Group(_)))); + let wide = Rect { pos: dvec2(0.0, 26.0), size: dvec2(892.0, 386.0) }; + let landscape = home_layout_for_apps(wide, wide.pos.y + 44.0, PhoneSurface::home_dock(wide), &["reference", "sheets", "photos", "appcard"]); + let y = landscape.tiles[0].rect.pos.y; + assert!(landscape.tiles.iter().all(|t| t.rect.pos.y == y), "landscape: one row, groups included"); + assert_eq!(landscape.tiles.len(), 4); + } + + #[test] + fn captures_fit_their_cells_by_aspect() { + let cell = Rect { pos: dvec2(10.0, 10.0), size: dvec2(160.0, 120.0) }; + let wide = fit(cell, dvec2(370.0, 177.0)); + assert!((wide.size.x - 160.0).abs() < 0.01 && wide.size.y < 120.0 && wide.pos.y > cell.pos.y); + let tall = fit(cell, dvec2(402.0, 782.0)); + assert!((tall.size.y - 120.0).abs() < 0.01 && tall.size.x < 160.0 && tall.pos.x > cell.pos.x); + assert_eq!(fit(cell, dvec2(0.0, 0.0)), cell); + } +} diff --git a/src/mobile_surface.rs b/src/mobile_surface.rs index 6875e920..76368bec 100644 --- a/src/mobile_surface.rs +++ b/src/mobile_surface.rs @@ -41,6 +41,14 @@ script_mod! { border_alpha: 0.18 border_width: 0.7 } } + group_glass: GlassPanel { + draw_bg +: { + blur_level: 4.0 corner_radius: 28.0 + tint_color: #eeeeff tint_alpha: 0.22 surface_alpha: 0.90 + lensing_strength: 0.3 specular_strength: 0.08 + border_alpha: 0.18 border_width: 0.7 + } + } overview_glass: GlassPanel { draw_bg +: { blur_level: 3.0 corner_radius: 0.0 @@ -99,7 +107,7 @@ pub struct PhoneSurface { #[walk] walk: Walk, #[layout] layout: Layout, #[visible] #[live(true)] visible: bool, - #[live] d: ShellDraw, + #[live] pub d: ShellDraw, #[live] ios_font: TextStyle, #[live] ios_bold: TextStyle, #[live] android_font: TextStyle, @@ -110,10 +118,11 @@ pub struct PhoneSurface { #[live] glass: GaussRoundedView, #[live] keyboard_glass: GaussRoundedView, #[live] pub overview_glass: GaussRoundedView, + #[live] pub group_glass: GaussRoundedView, #[rust] pressed: Option, #[live] wallpaper: DrawQuad, - #[rust] icons: AppIconDraw, - #[rust] hits: Vec<(Rect, PhoneHit)>, + #[rust] pub icons: AppIconDraw, + #[rust] pub hits: Vec<(Rect, PhoneHit)>, #[find] #[live] search: WidgetRef, #[rust] search_style: Option<(bool, bool)>, #[rust] search_rect: Rect, @@ -130,12 +139,13 @@ impl PhoneSurface { self.hits.iter().find(|(_, h)| h == hit).map(|(r, _)| *r) } pub fn begin(&mut self) { self.hits.clear(); } - fn rounded(&mut self, cx: &mut Cx2d, r: Rect, radius: f32, color: Vec4f) { + pub(crate) fn pressed_hit(&self) -> Option<&PhoneHit> { self.pressed.as_ref() } + pub(crate) fn rounded(&mut self, cx: &mut Cx2d, r: Rect, radius: f32, color: Vec4f) { self.chrome.radius = radius*2.0; self.chrome.bevel = 0.0; self.chrome.color = color; self.chrome.draw_abs(cx,r); } - fn label(&mut self, cx: &mut Cx2d, r: Rect, label: &str, size: f64, bold: bool, color: Vec4f) { + pub(crate) fn label(&mut self, cx: &mut Cx2d, r: Rect, label: &str, size: f64, bold: bool, color: Vec4f) { self.d.label_elided(cx,r,bold,size,color,HAlign::Center,label); } fn use_fonts(&mut self, ios: bool) { @@ -245,7 +255,11 @@ impl PhoneSurface { // The tiles themselves are composited by the desk (their captures // or placeholders); the page owns their hit regions. if home { - for slot in &layout.tiles {self.hits.push((slot.rect,PhoneHit::App(slot.app.into())));} + let available: Vec<&str>=ids.iter().map(|(id,_)|id.as_str()).collect(); + for slot in &layout.tiles { + if matches!(slot.kind,mobile_tiles::TileKind::Group(_)) {self.draw_group_tile(cx,&phone.groups,*slot,&available,style,state.style.dark,opacity);} + else {self.hits.push((slot.rect,PhoneHit::App(slot.app.into())));} + } } // Favorites: the launcher's order minus the dock, as many as fit // above it. The rest live in the App Library / the drawer. @@ -389,6 +403,7 @@ impl PhoneSurface { } if phone.order.is_empty() {self.label(cx,screen,"No recent apps",20.0,false,ink);} } + self.draw_groups_overlay(cx,state,screen); if phone.keyboard>0.5 {self.draw_keyboard(cx,state,screen,backdrop);} let bottom=rect(screen.pos.x,screen.pos.y+screen.size.y-24.0,screen.size.x,24.0); if phone.screen==PhoneScreen::App || phone.keyboard>0.5 { @@ -505,7 +520,7 @@ mod tests { let available = crate::shell::launcher::apps(); for style in [DesktopStyle::Ios, DesktopStyle::Android] { let layout = PhoneSurface::home_layout(style, rect(0.0, 0.0, 430.0, 900.0)); - for slot in layout.tiles { + for slot in layout.tiles.into_iter().filter(|s| !matches!(s.kind, mobile_tiles::TileKind::Group(_))) { assert!(available.iter().any(|app| app.id == format!("apps.{}", slot.app)), "unavailable tile: {}", slot.app); } } diff --git a/src/mobile_tiles.rs b/src/mobile_tiles.rs index eca1495c..b9ed661b 100644 --- a/src/mobile_tiles.rs +++ b/src/mobile_tiles.rs @@ -14,6 +14,9 @@ pub enum TileKind { Small, /// The full-width tile under them (portrait) or the third column (landscape). Wide, + /// A tile group's face (mobile_groups.rs), named; a chip row under the + /// app tiles in portrait, one more column in landscape. + Group(&'static str), } /// The apps that own a home tile, in tile order. Every id is a launcher @@ -69,9 +72,10 @@ pub fn home_layout_for_apps(screen: Rect, top: f64, dock: Rect, apps: &[&str]) - let left = screen.pos.x + m; let width = (screen.size.x - m * 2.0).max(1.0); let tile_apps: Vec<_> = TILE_APPS.iter().filter(|(id, _)| apps.contains(id)).collect(); + let groups = crate::mobile_groups::GroupsState::placed(apps); let mut tiles = Vec::new(); if landscape { - let count = tile_apps.len().max(1) as f64; + let count = (tile_apps.len() + groups.len()).max(1) as f64; let w = ((width - TILE_GAP * (count - 1.0)) / count).max(1.0); // Short enough that a row of favorites still fits above the dock. let h = (w * 0.56).min((dock.pos.y - top - 126.0).max(60.0)).max(1.0); @@ -79,6 +83,10 @@ pub fn home_layout_for_apps(screen: Rect, top: f64, dock: Rect, apps: &[&str]) - let x = left + index as f64 * (w + TILE_GAP); tiles.push(TileSlot { app, kind: *kind, rect: Rect { pos: dvec2(x, top), size: dvec2(w, h) } }); } + for (index, name) in groups.iter().enumerate() { + let x = left + (tile_apps.len() + index) as f64 * (w + TILE_GAP); + tiles.push(TileSlot { app: name, kind: TileKind::Group(name), rect: Rect { pos: dvec2(x, top), size: dvec2(w, h) } }); + } } else { let s = ((width - TILE_GAP) / 2.0).max(1.0); // One wide tile is as tall as a small one; two or more stack as @@ -99,8 +107,17 @@ pub fn home_layout_for_apps(screen: Rect, top: f64, dock: Rect, apps: &[&str]) - tiles.push(TileSlot { app, kind: *kind, rect: Rect { pos: dvec2(left, y), size: dvec2(width, wide_h) } }); wide += 1; } + TileKind::Group(_) => {} } } + // Group chips under the app tiles, two to a row. + let bottom = tiles.iter().map(|s| s.rect.pos.y + s.rect.size.y + TILE_GAP).fold(top, f64::max); + let gh = crate::mobile_groups::GROUP_TILE_HEIGHT; + for (index, name) in groups.iter().enumerate() { + let (col, row) = ((index % 2) as f64, (index / 2) as f64); + let w = if groups.len() == 1 { width } else { s }; + tiles.push(TileSlot { app: name, kind: TileKind::Group(name), rect: Rect { pos: dvec2(left + col * (s + TILE_GAP), bottom + row * (gh + TILE_GAP)), size: dvec2(w, gh) } }); + } } let columns = if landscape { 7 } else { 4 }; let fav_top = tiles.iter().map(|slot| slot.rect.pos.y + slot.rect.size.y + TILE_GAP + 6.0) @@ -401,19 +418,23 @@ mod tests { let screen = Rect { pos: dvec2(0.0, 78.0), size: dvec2(412.0, 768.0) }; let top = screen.pos.y + 156.0; let dock = PhoneSurface::home_dock(screen); + // Group chips (mobile_groups.rs) share the grid; this test is about the app tiles. + let app_tiles = |l: &HomeLayout| l.tiles.iter().filter(|t| !matches!(t.kind, TileKind::Group(_))).copied().collect::>(); let layout = home_layout_for_apps(screen, top, dock, &["reference", "sheets", "photos"]); - assert_eq!(layout.tiles.len(), 1); - assert_eq!(layout.tiles[0].app, "photos"); - assert_eq!(layout.tiles[0].rect.pos.y, top, "no gap for absent Clock and Weather"); + let tiles = app_tiles(&layout); + assert_eq!(tiles.len(), 1); + assert_eq!(tiles[0].app, "photos"); + assert_eq!(tiles[0].rect.pos.y, top, "no gap for absent Clock and Weather"); assert!(layout.capacity >= 2, "Reference and Sheets must be visible"); - assert!(!overlaps(layout.tiles[0].rect, layout.favorites)); + assert!(layout.tiles.iter().all(|t| !overlaps(t.rect, layout.favorites))); assert!(layout.favorites.pos.y + layout.favorites.size.y <= dock.pos.y - 36.0); // The phone catalog: Photos and the AppCard are both wide tiles. let two_wide = home_layout_for_apps(screen, top, dock, &["reference", "sheets", "photos", "appcard"]); - assert_eq!(two_wide.tiles.iter().map(|t| t.app).collect::>(), ["photos", "appcard"]); - assert!(two_wide.tiles[1].rect.pos.y >= two_wide.tiles[0].rect.pos.y + two_wide.tiles[0].rect.size.y + TILE_GAP - 0.01, "stacked, not overlapping"); - assert!(two_wide.tiles.iter().all(|t| (t.rect.size.x - (screen.size.x - HOME_MARGIN * 2.0)).abs() < 0.01)); - assert!(two_wide.capacity >= 8, "two rows of favorites beside two wide tiles: {}", two_wide.capacity); + let tiles = app_tiles(&two_wide); + assert_eq!(tiles.iter().map(|t| t.app).collect::>(), ["photos", "appcard"]); + assert!(tiles[1].rect.pos.y >= tiles[0].rect.pos.y + tiles[0].rect.size.y + TILE_GAP - 0.01, "stacked, not overlapping"); + assert!(tiles.iter().all(|t| (t.rect.size.x - (screen.size.x - HOME_MARGIN * 2.0)).abs() < 0.01)); + assert!(two_wide.capacity >= 4, "a row of favorites (the catalog has three) beside two wide tiles and the group chips: {}", two_wide.capacity); assert!(two_wide.tiles.iter().all(|t| !overlaps(t.rect, two_wide.favorites) && !overlaps(t.rect, dock))); let empty = home_layout_for_apps(screen, top, dock, &[]); assert!(empty.tiles.is_empty()); From 6349fe21d2af6a7e295fd2c7b2c03c7d6bac7818 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:57:51 -0700 Subject: [PATCH 05/13] mobile: swipeable home pages with the glance page on the left The phone home is a pager now (src/mobile_pages.rs): the glance page at position -1, one or more pages of app icons, and the App Library at the right end. Page 0 keeps the live tiles and the favorites that fit beside them; favorites past that capacity spill to page 1 (and on), each spill page laid out by `home_layout_for_apps` with no tiles. The last position opens the existing Drawer / App Library screen and the pager rests on the last apps page underneath it. `PagesState` (on `PhoneState::pages`) holds the ordered pages, the animated `index`, the in-progress `drag`, the glance feed and the glance scroll. `PhoneState::step` drives it from the gesture contract: `PageSwipe{dir, progress}` drags, `Commit(Page(dir))` animates one page over (clamped: glance on the left, library on the right), `Cancel` springs back. Until the recognizer lands, the indicator dots jump (`PhoneHit::Page(n)`: a glance glyph, a dot per apps page, the library glyph) and `--test-action page:` jumps from a script, switching the desktop to the iOS shell first. The glance page is a scrollable column of frosted cards on a dimmed wallpaper, in the App Library's style: `GlanceFeed` with typed items (Weather, Event, Fetch, Note), seeded from what the shell knows (the date, the linked apps, the live tiles) and `push`ed by apps later (a TODO marks where the ai_bus / ServiceExecutor delivers AppCard's cards; nothing is wired yet). Page 0 carries a one-line "at a glance" strip (date, then weather and next event when the feed has them) that the glance page expands; on Android's portrait home it sits under the big clock. Hooks in the shared files are one line each: the `pages` field, the `Page` hit and two lines in `step` (mobile.rs), the hit arm and a wheel-scroll arm for the glance column (mobile_app.rs), `mod` and the `page:` action (main.rs), the per-frame `sync` and the tiles riding page 0's offset (desk/phone.rs). `draw_home` draws every visible page at its pager offset; `home_top`, `PINNED` and the surface's drawing helpers are opened to the pages module. A layout change (rotation, the transient desktop-size first frame after a style switch) never opens the library by itself: sync clamps the pager onto an apps page, and only a commit or a tap reaches the library. Tests: page assignment (overflow spills, empty catalog), commit/cancel arithmetic with clamping at both ends, deferred and clamped jumps, the feed's order and the strip text; 221 default-member tests pass. Verified on the desktop (`--test-action page:-1|0|1` with `capture:`) and on the phone (bf0a4730): the glance page, page 0 with the strip, page 1 with the spill favorites (desktop, 20-app catalog), the library from the last dot. Co-Authored-By: Claude Fable 5.1 --- src/desk/phone.rs | 13 +- src/main.rs | 27 ++ src/mobile.rs | 9 +- src/mobile_app.rs | 4 + src/mobile_pages.rs | 603 ++++++++++++++++++++++++++++++++++++++++++ src/mobile_surface.rs | 95 ++++--- 6 files changed, 704 insertions(+), 47 deletions(-) create mode 100644 src/mobile_pages.rs diff --git a/src/desk/phone.rs b/src/desk/phone.rs index 92e36f84..d6ef304f 100644 --- a/src/desk/phone.rs +++ b/src/desk/phone.rs @@ -134,6 +134,9 @@ impl WmDesk { let dark=state.style.dark; let opacity=(1.0-phone.openness*0.85) as f32; if opacity<0.01 || phone.screen==PhoneScreen::Drawer {return;} + // The tiles ride page 0 of the home pager (mobile_pages.rs). + let dx=phone.pages.page_offset(0,screen.size.x); + if !phone.pages.page_visible(0,screen.size.x) {return;} let layout=PhoneSurface::home_layout(style,screen); // Everything the placeholders need, read before any tile draws. let slots:Vec<(crate::mobile_tiles::TileSlot,Option,String,bool)>=layout.tiles.iter().map(|slot| { @@ -142,6 +145,7 @@ impl WmDesk { (*slot,client,status,connected) }).collect(); for (slot,client,status,connected) in slots { + let shown_rect=Rect{pos:slot.rect.pos+dvec2(dx,0.0),size:slot.rect.size}; let gave_up=phone.tiles.gave_up(slot.app); let entry=client.and_then(|c|phone.tiles.get(c)); let mut shown=false; @@ -159,7 +163,7 @@ impl WmDesk { capture.frame.freeze(cx); } if ready { - self.present_capture(cx,&capture,slot.rect,opacity,TILE_RADIUS as f32); + self.present_capture(cx,&capture,shown_rect,opacity,TILE_RADIUS as f32); shown=true; } stored.tile=Some(capture); @@ -168,7 +172,7 @@ impl WmDesk { // stands in until the client is back in it. capture.frame.freeze(cx); if capture.size==slot.rect.size { - self.present_capture(cx,capture,slot.rect,opacity,TILE_RADIUS as f32); + self.present_capture(cx,capture,shown_rect,opacity,TILE_RADIUS as f32); shown=true; } } @@ -178,14 +182,15 @@ impl WmDesk { let (headline,detail)=if client.is_none() && !gave_up { ("Tap to open", String::new()) } else { crate::mobile_tiles::placeholder_text(&status,connected,gave_up) }; - self.phone_ui.draw_tile_placeholder(cx,slot,style,dark,opacity,headline,&detail); - self.compositor.as_mut().unwrap().content(slot.rect); + self.phone_ui.draw_tile_placeholder(cx,crate::mobile_tiles::TileSlot{rect:shown_rect,..slot},style,dark,opacity,headline,&detail); + self.compositor.as_mut().unwrap().content(shown_rect); } } } pub(super) fn draw_phone_scene(&mut self,cx:&mut Cx2d,scope:&mut Scope,screen:Rect) { let state=scope.data.get_mut::().unwrap(); state.phone.viewport=screen; + crate::mobile_pages::sync(&mut state.phone,state.style.target,screen); state.phone.order.retain(|c|state.clients.contains_key(c)); if state.phone.client.is_some_and(|c|!state.clients.contains_key(&c)) { state.phone.client=state.phone.order.first().copied(); diff --git a/src/main.rs b/src/main.rs index e15b7d4b..ace2c81e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,6 +26,7 @@ mod mobile_surface; mod mobile_gestures; mod mobile_app; mod mobile_tiles; +mod mobile_pages; mod scene; mod dock_warp; mod host; @@ -366,6 +367,10 @@ pub struct App { /// at without a screen (the GPU readback does not need one). #[rust] test_capture: Option<(Timer, std::path::PathBuf)>, + /// `--test-action page:`: the home page to jump to, once the phone + /// home has laid its pages out at the phone's size (mobile_pages.rs). + #[rust] + test_page: Option<(Timer, i64)>, /// When each warm client was last ticked. Kept apart from `WarmFrame` /// because the FIRST ticks are what make a frame possible at all — see /// `pump_warm`. @@ -3579,6 +3584,14 @@ impl App { let _ = std::fs::rename(path.with_extension("part.png"), path); } } + if let Some((timer, n)) = &self.test_page { + if timer.is_timer(te).is_some() { + let n = *n; + self.test_page = None; + self.state_mut().phone.pages.jump(n); + self.animate_phone(cx); + } + } let Some(pos) = self.test_asks.iter().position(|(t, _)| t.is_timer(te).is_some()) else { return; }; @@ -3625,6 +3638,20 @@ impl App { i += 2; continue; } + // page:: jump the phone home to page (-1 is the + // glance page, the last position the App Library), on + // the iOS shell when the desktop is not a phone yet. + if let Some(n) = name.strip_prefix("page:") { + let n = n.trim().parse::().unwrap_or(0); + log!("wm: --test-action page {}", n); + if !self.state_mut().style.target.mobile() { self.set_desktop_style(cx, desktop::DesktopStyle::Ios); } + // After the first frames: the home's pages are laid + // out at the phone's size by then. + let timer = cx.start_timeout(1.0); + self.test_page = Some((timer, n)); + i += 2; + continue; + } // ask-appcard:: submit to the hosted AppCard's // composer (its `ask` tool over the bus) after a delay — // `OCTOSENSE_TEST_ASK_DELAY` seconds, default 25 — so the diff --git a/src/mobile.rs b/src/mobile.rs index bba09f53..06ba173a 100644 --- a/src/mobile.rs +++ b/src/mobile.rs @@ -12,6 +12,8 @@ pub enum PhoneHit { App(String), Card(ClientId), Home, Recents, Drawer, Back, Rotate, Style, Appearance, Desktop, Key(String), Shift, Symbols, HideKeyboard, ClearSearch, CancelSearch, + /// A page indicator dot: jump the home pager there (mobile_pages.rs). + Page(i64), } #[derive(Clone)] @@ -62,6 +64,8 @@ pub struct PhoneState { /// Rects apps own on screen; shell gestures starting inside them are not /// recognised. pub exclusions: crate::mobile_gestures::ExclusionZones, + /// The home pager: glance page, apps pages, library (mobile_pages.rs). + pub pages: crate::mobile_pages::PagesState, } impl Default for PhoneState { fn default() -> Self { @@ -73,7 +77,8 @@ impl Default for PhoneState { desktop_size: None, desktop_clients: Vec::new(), desktop_style: DesktopStyle::Omarchy, viewport: Rect::default(), tiles: HomeTiles::default(), gesture_out: None, - exclusions: Default::default() } + exclusions: Default::default(), + pages: Default::default() } } } impl PhoneState { @@ -134,6 +139,8 @@ impl PhoneState { if (target - self.page).abs() < 0.001 { self.page = target; } active |= self.page != target; } + active |= self.pages.step(dt, if self.screen == PhoneScreen::Home { self.gesture_out } else { None }); + if self.pages.take_library_request() { self.navigate(PhoneScreen::Drawer); } active } pub fn accepts_app_input(&self) -> bool { diff --git a/src/mobile_app.rs b/src/mobile_app.rs index 8715ff96..19b02442 100644 --- a/src/mobile_app.rs +++ b/src/mobile_app.rs @@ -353,6 +353,7 @@ impl App { PhoneHit::Home=>self.state_mut().phone.navigate(PhoneScreen::Home), PhoneHit::Recents=>self.state_mut().phone.navigate(PhoneScreen::Recents), PhoneHit::Drawer=>self.state_mut().phone.navigate(PhoneScreen::Drawer), + PhoneHit::Page(n)=>self.state_mut().phone.pages.jump(n), PhoneHit::Rotate=>{ let window=self.ui.window(cx,ids!(main_window));let size=window.get_inner_size(cx); self.state_mut().phone.gesture=None; @@ -551,6 +552,9 @@ impl App { let p=&mut self.state_mut().phone;p.page=(p.page+scroll.signum()).clamp(0.0,p.order.len().saturating_sub(1)as f64); self.animate_phone(cx);true } + PhonePointerPhase::Scroll if self.state_mut().phone.screen==PhoneScreen::Home && self.state_mut().phone.pages.on_glance()=>{ + let p=&mut self.state_mut().phone;p.pages.scroll_glance(scroll,p.viewport.size.y);self.animate_phone(cx);true + } PhonePointerPhase::Scroll if self.state_mut().phone.searching()=>{ let phone=&mut self.state_mut().phone; phone.search_scroll=(phone.search_scroll.min(search_scroll_max)+scroll).clamp(0.0,search_scroll_max); diff --git a/src/mobile_pages.rs b/src/mobile_pages.rs new file mode 100644 index 00000000..54305ed8 --- /dev/null +++ b/src/mobile_pages.rs @@ -0,0 +1,603 @@ +//! Swipeable home pages: the glance page pinned left of the first apps page, +//! one or more pages of app icons, and the App Library at the right end. +//! +//! The page model and the pager animation are plain state (`PagesState`) +//! that `PhoneState::step` drives from the shell gesture contract +//! (mobile_gestures.rs): `PageSwipe` moves `drag`, `Commit(Page)` animates +//! `index` one page over, `Cancel` lets `drag` spring back. Until the +//! recognizer lands, pages also change by tapping the indicator dots +//! (`PhoneHit::Page`) and by `--test-action page:`. +//! +//! Positions: apps page `k` is at `k`, the glance page at -1, the library at +//! `apps_count`. Reaching the library position opens the existing Drawer / +//! App Library screen; the pager then rests on the last apps page again so +//! coming home lands where the person left. +//! +//! Page assignment: page 0 keeps the tiles and the favorites that fit beside +//! them; favorites beyond that capacity spill to page 1 (and 2, ...), each +//! spill page laid out by `home_layout_for_apps` with no tiles. +//! +//! Drawing lives here too (`impl PhoneSurface`): the glance cards in the +//! frosted style of the App Library, the spill pages, the indicator row. +use crate::{ + desktop::DesktopStyle, + mobile::{PhoneHit, PhoneState}, + mobile_gestures::{Dir, GestureKind, ShellGesture}, + mobile_surface::{PhoneSurface, PINNED}, + mobile_tiles, + shell::{alpha, rgb, ui::{rect, HAlign, Ico}}, +}; +use makepad_widgets::*; + +/// One page of the pager, in pager order (glance, apps..., library). +#[derive(Clone, Debug, PartialEq)] +pub enum HomePage { + /// The feed of cards left of the first page (position -1). + Glance, + /// A page of app icons; page 0 also carries the live tiles. + Apps { ids: Vec }, + /// The App Library / drawer, the right end of the pager. + Library, +} + +/// One card on the glance page. +#[derive(Clone, Debug, PartialEq)] +pub enum GlanceItem { + Weather { place: String, temp: String, hi: String, lo: String, cond: String }, + Event { title: String, when: String }, + Fetch { title: String, progress: f64 }, + Note { title: String, body: String }, +} + +impl GlanceItem { + /// The card's height on the glance page, fixed per kind so the column's + /// extent (and the scroll range) is known without drawing. + pub fn height(&self) -> f64 { + match self { + GlanceItem::Weather { .. } => 128.0, + GlanceItem::Event { .. } => 78.0, + GlanceItem::Fetch { .. } => 88.0, + GlanceItem::Note { .. } => 104.0, + } + } + pub fn title(&self) -> &str { + match self { + GlanceItem::Weather { place, .. } => place, + GlanceItem::Event { title, .. } | GlanceItem::Fetch { title, .. } | GlanceItem::Note { title, .. } => title, + } + } +} + +/// The glance page's data: what the shell itself knows (refreshed by +/// `sync`) under whatever apps posted (newest first). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct GlanceFeed { + posted: Vec, + shell: Vec, +} + +impl GlanceFeed { + /// An app posted a card: it goes on top, above everything older. + /// TODO(ai_bus): the AppCard module's ServiceExecutor delivers its cards + /// here (`glance.push` on the os service); nothing is wired yet. + pub fn push(&mut self, item: GlanceItem) { + self.posted.insert(0, item); + } + /// Replace the shell's own cards, leaving posted ones alone. + pub fn seed(&mut self, items: Vec) { + self.shell = items; + } + pub fn items(&self) -> impl Iterator { + self.posted.iter().chain(self.shell.iter()) + } + pub fn len(&self) -> usize { + self.posted.len() + self.shell.len() + } + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + pub fn weather(&self) -> Option<&GlanceItem> { + self.items().find(|i| matches!(i, GlanceItem::Weather { .. })) + } + pub fn next_event(&self) -> Option<&GlanceItem> { + self.items().find(|i| matches!(i, GlanceItem::Event { .. })) + } + /// The whole column's height at `gap` between cards. + pub fn column_height(&self, gap: f64) -> f64 { + let n = self.len(); + self.items().map(GlanceItem::height).sum::() + gap * n.saturating_sub(1) as f64 + } +} + +/// How much of a page one full-progress swipe drags before it commits: the +/// recognizer's commit distance is a fraction of the screen, so the page +/// follows the finger at about that rate rather than flying across. +pub const SWIPE_FRACTION: f64 = 0.35; + +/// The pager: which pages exist, where the pager is, and the glance feed. +#[derive(Clone, Debug)] +pub struct PagesState { + /// Glance, then every apps page, then the library. + pub pages: Vec, + /// Animated position: 0 is the first apps page, -1 the glance page. + pub index: f64, + /// Extra offset while a swipe is in progress (springs back on cancel). + pub drag: f64, + /// Where `index` is heading (a page position). + target: i64, + /// A commit or a jump reached the library: the shell opens it once. + open_library: bool, + /// A jump asked for before the pages were known (`--test-action page:` + /// at startup): applied by the first `sync`. + pending: Option, + pub feed: GlanceFeed, + /// The glance column's scroll offset, in points. + pub glance_scroll: f64, + /// Today's date as the glance header shows it. + pub date: String, +} + +impl Default for PagesState { + fn default() -> Self { + Self { pages: Vec::new(), index: 0.0, drag: 0.0, target: 0, open_library: false, pending: None, feed: GlanceFeed::default(), glance_scroll: 0.0, date: String::new() } + } +} + +/// Cut the favorites into pages: the first `capacity0` on page 0 (beside the +/// tiles), the rest in `capacity_n`-sized spill pages. Always at least one +/// apps page, so the pager has a home even with nothing installed. +pub fn assign_pages(favorites: &[String], capacity0: usize, capacity_n: usize) -> Vec { + let mut pages = vec![HomePage::Glance]; + let (first, rest) = favorites.split_at(capacity0.min(favorites.len())); + pages.push(HomePage::Apps { ids: first.to_vec() }); + for chunk in rest.chunks(capacity_n.max(1)) { + pages.push(HomePage::Apps { ids: chunk.to_vec() }); + } + pages.push(HomePage::Library); + pages +} + +impl PagesState { + /// How many apps pages there are (the library's position). + pub fn apps_count(&self) -> usize { + self.pages.iter().filter(|p| matches!(p, HomePage::Apps { .. })).count() + } + pub fn library_index(&self) -> i64 { + self.apps_count() as i64 + } + fn known(&self) -> bool { + !self.pages.is_empty() + } + /// The ids on apps page `k`, empty for any other position. + pub fn page_ids(&self, k: i64) -> &[String] { + if k < 0 { return &[]; } + match self.pages.iter().filter(|p| matches!(p, HomePage::Apps { .. })).nth(k as usize) { + Some(HomePage::Apps { ids }) => ids, + _ => &[], + } + } + /// Where the pager is right now, drag included. + pub fn position(&self) -> f64 { + self.index + self.drag + } + /// The page the person is looking at (nearest to the position). + pub fn current(&self) -> i64 { + self.position().round() as i64 + } + pub fn on_glance(&self) -> bool { + self.current() < 0 + } + /// The horizontal offset of page `k` for a screen `width` wide. + pub fn page_offset(&self, k: i64, width: f64) -> f64 { + (k as f64 - self.position()) * width + } + /// A page is at least partly on screen. + pub fn page_visible(&self, k: i64, width: f64) -> bool { + self.page_offset(k, width).abs() < width - 0.5 + } + /// Every page position, glance to library. + pub fn positions(&self) -> std::ops::RangeInclusive { + -1..=self.library_index() + } + + /// The pages changed (apps installed, screen rotated): reassign, and + /// keep the pager on an apps page inside the new range. Only a commit + /// or a tap opens the library, never a layout change (the first frame + /// after a style switch can still be the old window size). + pub fn sync(&mut self, favorites: &[String], capacity0: usize, capacity_n: usize) { + let pages = assign_pages(favorites, capacity0, capacity_n); + if pages != self.pages { self.pages = pages; } + let last = (self.library_index() - 1).max(0); + if let Some(n) = self.pending.take() { + self.target = n.clamp(-1, last); + self.index = self.target as f64; + } + self.target = self.target.clamp(-1, last); + self.index = self.index.clamp(-1.0, last as f64); + } + + /// Go to page `n` (-1 is the glance page; the library's position and + /// anything past it open the library). Before the pages are known the + /// jump waits for `sync`, and lands on an apps page. + pub fn jump(&mut self, n: i64) { + self.drag = 0.0; + if !self.known() { self.pending = Some(n); return; } + let lib = self.library_index(); + self.target = n.clamp(-1, lib); + if self.target == lib { self.open_library = true; } + } + + /// Drive the pager one frame from the shell gesture (None when there is + /// none, or the home page is not the screen). True while animating. + pub fn step(&mut self, dt: f64, gesture: Option) -> bool { + let lib = self.library_index() as f64; + let mut dragging = false; + match gesture { + Some(ShellGesture::PageSwipe { dir, progress }) => { + // A finger moving left reveals the page on the right. + let sign = match dir { Dir::Left => 1.0, Dir::Right => -1.0 }; + let raw = sign * progress.max(0.0) * SWIPE_FRACTION; + let (lo, hi) = ((-1.0 - self.index).min(0.0), (lib - self.index).max(0.0)); + self.drag = raw.clamp(lo, hi); + dragging = true; + } + Some(ShellGesture::Commit(GestureKind::Page(dir))) => { + let step = match dir { Dir::Left => 1, Dir::Right => -1 }; + // Carry on from where the finger left the page. + self.index += self.drag; + self.drag = 0.0; + self.target = (self.index.round() as i64 + step).clamp(-1, lib as i64); + if self.known() && self.target == lib as i64 { self.open_library = true; } + } + Some(ShellGesture::Cancel(GestureKind::Page(_))) => { + self.target = self.index.round() as i64; + } + _ => {} + } + if dragging { return true; } + let t = 1.0 - (-dt * 16.0).exp(); + self.drag += (0.0 - self.drag) * t; + if self.drag.abs() < 0.001 { self.drag = 0.0; } + let target = self.target as f64; + self.index += (target - self.index) * t; + if (target - self.index).abs() < 0.001 { self.index = target; } + self.drag != 0.0 || self.index != target + } + + /// The pager reached the library: true once, and the pager rests on the + /// last apps page so the home behind the library is the one left. + pub fn take_library_request(&mut self) -> bool { + if !self.open_library { return false; } + self.open_library = false; + let last = (self.library_index() - 1).max(0); + self.target = last; + self.index = last as f64; + self.drag = 0.0; + true + } + + /// Scroll the glance column by `dy` points on a screen `height` tall. + pub fn scroll_glance(&mut self, dy: f64, height: f64) { + let max = (self.feed.column_height(GLANCE_GAP) + GLANCE_HEADER - (height - GLANCE_BOTTOM)).max(0.0); + self.glance_scroll = (self.glance_scroll + dy).clamp(0.0, max); + } + + /// The one-line "at a glance" strip on page 0: the date, then the + /// weather and the next event when the feed has them. + pub fn strip_text(&self) -> String { + let mut parts: Vec = Vec::new(); + if !self.date.is_empty() { parts.push(self.date.clone()); } + if let Some(GlanceItem::Weather { temp, cond, .. }) = self.feed.weather() { + parts.push(format!("{temp} {cond}")); + } + if let Some(GlanceItem::Event { title, when, .. }) = self.feed.next_event() { + parts.push(format!("{title} {when}")); + } + parts.join(" · ") + } +} + +/// Points above the glance column (its header) and below it (the dock). +const GLANCE_HEADER: f64 = 96.0; +const GLANCE_BOTTOM: f64 = 124.0; +const GLANCE_GAP: f64 = 12.0; + +/// Refresh the page model from the shell each frame before the home draws: +/// the favorites (the launcher's apps minus the dock), the capacities of +/// page 0 and of a spill page on this screen, and the glance feed's shell +/// cards. Cheap when nothing changed. +pub fn sync(phone: &mut PhoneState, style: DesktopStyle, screen: Rect) { + if screen.size.x < 1.0 || screen.size.y < 1.0 { return; } + let apps = crate::shell::launcher::apps(); + let ids: Vec<(String, String)> = apps.iter().map(|a| (a.id.trim_start_matches("apps.").to_string(), a.label.clone())).collect(); + let favorites: Vec = ids.iter().filter(|(id, _)| !PINNED.contains(&id.as_str())).map(|(id, _)| id.clone()).collect(); + let capacity0 = PhoneSurface::home_layout(style, screen).capacity; + let spill = mobile_tiles::home_layout_for_apps(screen, PhoneSurface::home_top(style, screen), PhoneSurface::home_dock(screen), &[]); + phone.pages.sync(&favorites, capacity0, spill.capacity.max(1)); + // The date changes once a day; the string compare is the cheap check. + let date = crate::host::fallback_clock(true); + let weekday = crate::host::fallback_clock(false); + let weekday = weekday.split_whitespace().next().unwrap_or(""); + let date = if weekday.is_empty() { date } else { format!("{weekday}, {date}") }; + if phone.pages.date != date { phone.pages.date = date; } + phone.pages.feed.seed(shell_cards(&ids, &phone.tiles)); +} + +/// The cards the shell can fill in by itself. The weather tile's data lives +/// in the Weather app's own process and is not reachable from here; a +/// Weather card arrives through `GlanceFeed::push` once an app posts one. +fn shell_cards(ids: &[(String, String)], tiles: &mobile_tiles::HomeTiles) -> Vec { + let mut cards = Vec::new(); + let live: Vec<&str> = mobile_tiles::TILE_APPS.iter().map(|(id, _)| *id) + .filter(|id| tiles.client_of(id).is_some_and(|c| tiles.get(c).is_some_and(|t| t.tile_ready()))).collect(); + if !live.is_empty() { + let names: Vec = live.iter().map(|id| label_of(ids, id)).collect(); + cards.push(GlanceItem::Note { title: "Live tiles".into(), body: format!("{} on the home page: {}", live.len(), names.join(", ")) }); + } + let names: Vec = ids.iter().map(|(_, label)| label.clone()).collect(); + let body = if names.is_empty() { "No apps are linked into this build.".to_string() } else { names.join(", ") }; + let title = if ids.len() == 1 { "1 app".to_string() } else { format!("{} apps", ids.len()) }; + cards.push(GlanceItem::Note { title, body }); + cards.push(GlanceItem::Note { title: "At a glance".into(), body: "Weather, events and downloads land here when an app posts them.".into() }); + cards +} + +fn label_of(ids: &[(String, String)], id: &str) -> String { + ids.iter().find(|(i, _)| i == id).map(|(_, l)| l.clone()).unwrap_or_else(|| id.to_string()) +} + +// ---------------------------------------------------------------- drawing + +impl PhoneSurface { + /// The glance page at horizontal offset `dx`: a dimmed wallpaper and a + /// scrollable column of frosted cards under an "At a glance" header. + pub fn draw_glance(&mut self, cx: &mut Cx2d, phone: &PhoneState, screen: Rect, style: DesktopStyle, dark: bool, opacity: f32, dx: f64) { + self.use_fonts(style == DesktopStyle::Ios); + let page = rect(screen.pos.x + dx, screen.pos.y, screen.size.x, screen.size.y); + self.rounded(cx, page, 0.0, alpha(if dark { rgb(8, 9, 16) } else { rgb(228, 231, 242) }, 0.86 * opacity)); + let ink = alpha(if dark { rgb(255, 255, 255) } else { rgb(26, 26, 32) }, opacity); + let landscape = screen.size.x > screen.size.y; + let top = page.pos.y + if landscape { 30.0 } else { 52.0 }; + let left = page.pos.x + 20.0; + let width = page.size.x - 40.0; + self.d.label_elided(cx, rect(left, top, width, 30.0), true, 24.0, ink, HAlign::Left, "At a glance"); + self.d.label_elided(cx, rect(left, top + 32.0, width, 20.0), false, 13.0, alpha(ink, 0.7 * opacity), HAlign::Left, &phone.pages.date); + let bottom = screen.pos.y + screen.size.y - GLANCE_BOTTOM; + let mut y = top + 64.0 - phone.pages.glance_scroll; + let items: Vec = phone.pages.feed.items().cloned().collect(); + for item in &items { + let h = item.height(); + if y + h > top + 56.0 && y < bottom { + self.draw_glance_card(cx, rect(left, y, width, h), item, style, dark, ink, opacity); + } + y += h + GLANCE_GAP; + } + } + + fn draw_glance_card(&mut self, cx: &mut Cx2d, r: Rect, item: &GlanceItem, style: DesktopStyle, dark: bool, ink: Vec4f, opacity: f32) { + self.rounded(cx, r, 18.0, alpha(rgb(255, 255, 255), if dark { 0.10 } else { 0.55 } * opacity)); + let pad = 16.0; + let inner = rect(r.pos.x + pad, r.pos.y + pad, r.size.x - pad * 2.0, r.size.y - pad * 2.0); + let dim = alpha(ink, 0.65 * opacity); + let accent = if style == DesktopStyle::Ios { rgb(0, 122, 255) } else { rgb(103, 80, 164) }; + match item { + GlanceItem::Weather { place, temp, hi, lo, cond } => { + self.d.label_elided(cx, rect(inner.pos.x, inner.pos.y, inner.size.x * 0.6, 20.0), true, 15.0, ink, HAlign::Left, place); + self.d.label_elided(cx, rect(inner.pos.x + inner.size.x * 0.5, inner.pos.y, inner.size.x * 0.5, 20.0), false, 13.0, dim, HAlign::Right, cond); + self.d.label_elided(cx, rect(inner.pos.x, inner.pos.y + 30.0, inner.size.x * 0.6, 50.0), false, 40.0, ink, HAlign::Left, temp); + self.d.label_elided(cx, rect(inner.pos.x + inner.size.x * 0.5, inner.pos.y + 44.0, inner.size.x * 0.5, 20.0), false, 13.0, dim, HAlign::Right, &format!("H:{hi} L:{lo}")); + } + GlanceItem::Event { title, when } => { + self.d.icon_centered(cx, Ico::Calendar, rect(inner.pos.x, inner.pos.y, 28.0, inner.size.y), 20.0, alpha(accent, opacity)); + self.d.label_elided(cx, rect(inner.pos.x + 40.0, inner.pos.y, inner.size.x - 40.0, 22.0), true, 15.0, ink, HAlign::Left, title); + self.d.label_elided(cx, rect(inner.pos.x + 40.0, inner.pos.y + 24.0, inner.size.x - 40.0, 20.0), false, 13.0, dim, HAlign::Left, when); + } + GlanceItem::Fetch { title, progress } => { + let progress = progress.clamp(0.0, 1.0); + self.d.label_elided(cx, rect(inner.pos.x, inner.pos.y, inner.size.x - 60.0, 22.0), true, 15.0, ink, HAlign::Left, title); + self.d.label_elided(cx, rect(inner.pos.x + inner.size.x - 60.0, inner.pos.y, 60.0, 22.0), false, 13.0, dim, HAlign::Right, &format!("{}%", (progress * 100.0).round())); + let track = rect(inner.pos.x, inner.pos.y + 36.0, inner.size.x, 8.0); + self.rounded(cx, track, 4.0, alpha(ink, 0.15 * opacity)); + if progress > 0.0 { + self.rounded(cx, rect(track.pos.x, track.pos.y, (track.size.x * progress).max(8.0), 8.0), 4.0, alpha(accent, opacity)); + } + } + GlanceItem::Note { title, body } => { + self.d.label_elided(cx, rect(inner.pos.x, inner.pos.y, inner.size.x, 22.0), true, 15.0, ink, HAlign::Left, title); + let lines = self.d.wrap(cx, false, 13.0, body, inner.size.x, 3); + for (n, line) in lines.iter().enumerate() { + self.d.label(cx, rect(inner.pos.x, inner.pos.y + 26.0 + n as f64 * 18.0, inner.size.x, 18.0), false, 13.0, dim, HAlign::Left, line); + } + } + } + } + + /// The library's stand-in while it slides in from the right: the pager's + /// last position opens the real App Library / drawer screen. + pub fn draw_library_preview(&mut self, cx: &mut Cx2d, screen: Rect, dark: bool, ink: Vec4f, opacity: f32, dx: f64) { + let page = rect(screen.pos.x + dx, screen.pos.y, screen.size.x, screen.size.y); + self.rounded(cx, page, 0.0, alpha(if dark { rgb(8, 9, 16) } else { rgb(228, 231, 242) }, 0.86 * opacity)); + let glyph = rect(page.pos.x + (page.size.x - 48.0) * 0.5, page.pos.y + page.size.y * 0.42, 48.0, 48.0); + self.rounded(cx, glyph, 12.0, alpha(ink, 0.35 * opacity)); + for (gx, gy) in [(10.0, 10.0), (26.0, 10.0), (10.0, 26.0), (26.0, 26.0)] { + self.rounded(cx, rect(glyph.pos.x + gx, glyph.pos.y + gy, 12.0, 12.0), 3.0, alpha(ink, 0.9 * opacity)); + } + self.label(cx, rect(page.pos.x, glyph.pos.y + 60.0, page.size.x, 24.0), "App Library", 15.0, true, alpha(ink, opacity)); + } + + /// The indicator row above the dock: the glance glyph, a dot per apps + /// page, the library glyph. Tapping any of them jumps there. + pub fn draw_page_indicator(&mut self, cx: &mut Cx2d, phone: &PhoneState, dock: Rect, screen: Rect, ink: Vec4f, opacity: f32, hits: bool) { + let pages = &phone.pages; + let current = pages.current(); + let count = pages.apps_count(); + let cell = 22.0; + let total = (count + 2) as f64 * cell; + let left = screen.pos.x + (screen.size.x - total) * 0.5; + let y = dock.pos.y - 30.0; + for (n, k) in pages.positions().enumerate() { + let slot = rect(left + n as f64 * cell, y, cell, 24.0); + let active = k == current; + let a = if active { 1.0 } else { 0.45 } * opacity; + let c = dvec2(slot.pos.x + cell * 0.5, slot.pos.y + 12.0); + if k < 0 { + // The glance page: a small card with two lines of text. + let g = rect(c.x - 6.0, c.y - 6.0, 12.0, 12.0); + self.rounded(cx, g, 3.0, alpha(ink, a)); + let bar = if dark_on(ink) { alpha(rgb(255, 255, 255), a) } else { alpha(rgb(20, 20, 30), 0.9 * a) }; + self.rounded(cx, rect(g.pos.x + 2.5, g.pos.y + 3.0, 7.0, 2.0), 1.0, bar); + self.rounded(cx, rect(g.pos.x + 2.5, g.pos.y + 7.0, 5.0, 2.0), 1.0, bar); + } else if k == pages.library_index() { + let lib = rect(c.x - 6.0, c.y - 6.0, 12.0, 12.0); + self.rounded(cx, lib, 3.0, alpha(ink, a)); + let cell_ink = if dark_on(ink) { alpha(rgb(255, 255, 255), a) } else { alpha(rgb(20, 20, 30), 0.9 * a) }; + for (gx, gy) in [(2.5, 2.5), (6.5, 2.5), (2.5, 6.5), (6.5, 6.5)] { + self.rounded(cx, rect(lib.pos.x + gx, lib.pos.y + gy, 3.0, 3.0), 1.0, cell_ink); + } + } else { + let d = if active { 8.0 } else { 6.0 }; + self.rounded(cx, rect(c.x - d * 0.5, c.y - d * 0.5, d, d), d as f32 * 0.5, alpha(ink, a)); + } + if hits { self.hits.push((rect(slot.pos.x, slot.pos.y - 8.0, cell, 40.0), PhoneHit::Page(k))); } + } + } +} + +/// The indicator ink is light on a dark ground: the glyph insides go dark. +fn dark_on(ink: Vec4f) -> bool { + ink.x + ink.y + ink.z < 1.5 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ids(n: usize) -> Vec { + (0..n).map(|i| format!("app{i}")).collect() + } + fn settle(pages: &mut PagesState) { + for _ in 0..120 { pages.step(1.0 / 60.0, None); } + } + + #[test] + fn overflow_favorites_spill_to_page_1_and_the_library_stays_last() { + let pages = assign_pages(&ids(12), 8, 12); + assert_eq!(pages.len(), 4, "glance, page 0, page 1, library"); + assert_eq!(pages[0], HomePage::Glance); + assert_eq!(pages[1], HomePage::Apps { ids: ids(8) }); + assert_eq!(pages[2], HomePage::Apps { ids: ids(12)[8..].to_vec() }); + assert_eq!(pages[3], HomePage::Library); + // Everything fits beside the tiles: one apps page, no spill. + let pages = assign_pages(&ids(5), 8, 12); + assert_eq!(pages, vec![HomePage::Glance, HomePage::Apps { ids: ids(5) }, HomePage::Library]); + // Two spill pages when the spill page is small; nothing installed + // still leaves one (empty) apps page to come home to. + assert_eq!(assign_pages(&ids(20), 8, 6).iter().filter(|p| matches!(p, HomePage::Apps { .. })).count(), 3); + assert_eq!(assign_pages(&[], 8, 12), vec![HomePage::Glance, HomePage::Apps { ids: vec![] }, HomePage::Library]); + let mut state = PagesState::default(); + state.sync(&ids(12), 8, 12); + assert_eq!(state.apps_count(), 2); + assert_eq!(state.library_index(), 2); + assert_eq!(state.page_ids(1), &ids(12)[8..]); + assert!(state.page_ids(-1).is_empty() && state.page_ids(2).is_empty()); + } + + #[test] + fn swipes_commit_cancel_and_clamp_at_both_ends() { + let mut p = PagesState::default(); + p.sync(&ids(12), 8, 12); + // A swipe left drags the page after it in; committing lands on it. + assert!(p.step(1.0 / 60.0, Some(ShellGesture::PageSwipe { dir: Dir::Left, progress: 0.5 }))); + assert!((p.drag - 0.5 * SWIPE_FRACTION).abs() < 1e-9); + assert!(p.page_offset(1, 400.0) < 400.0 && p.page_offset(0, 400.0) < 0.0); + p.step(1.0 / 60.0, Some(ShellGesture::Commit(GestureKind::Page(Dir::Left)))); + assert_eq!(p.drag, 0.0, "the drag folds into the index on commit"); + settle(&mut p); + assert_eq!(p.index, 1.0); + assert_eq!(p.current(), 1); + assert!(!p.step(1.0 / 60.0, None), "settled"); + // A cancelled swipe springs back to the same page. + p.step(1.0 / 60.0, Some(ShellGesture::PageSwipe { dir: Dir::Right, progress: 0.3 })); + assert!(p.drag < 0.0); + p.step(1.0 / 60.0, Some(ShellGesture::Cancel(GestureKind::Page(Dir::Right)))); + settle(&mut p); + assert_eq!((p.index, p.drag), (1.0, 0.0)); + // Right past page 0 is the glance page, and nothing left of it. + for _ in 0..4 { p.step(1.0 / 60.0, Some(ShellGesture::Commit(GestureKind::Page(Dir::Right)))); settle(&mut p); } + assert_eq!(p.index, -1.0); + assert!(p.on_glance()); + p.step(1.0 / 60.0, Some(ShellGesture::PageSwipe { dir: Dir::Right, progress: 1.0 })); + assert_eq!(p.drag, 0.0, "no dragging past the glance page"); + assert!(!p.take_library_request()); + // Left past the last apps page reaches the library exactly once, + // and the pager rests on the last apps page underneath it. + for _ in 0..3 { p.step(1.0 / 60.0, Some(ShellGesture::Commit(GestureKind::Page(Dir::Left)))); settle(&mut p); } + assert!(p.take_library_request()); + assert!(!p.take_library_request()); + assert_eq!((p.index, p.current()), (1.0, 1)); + // Other gestures are not the pager's business. + p.step(1.0 / 60.0, Some(ShellGesture::Commit(GestureKind::HomeUp))); + p.step(1.0 / 60.0, Some(ShellGesture::HomeUp { progress: 0.5, held: false })); + assert_eq!((p.index, p.drag), (1.0, 0.0)); + } + + #[test] + fn jumps_clamp_and_wait_for_the_pages_when_they_come_first() { + let mut p = PagesState::default(); + // `--test-action page:1` fires before the first frame lays pages out. + p.jump(1); + assert!(!p.take_library_request(), "nothing known yet: not the library"); + p.sync(&ids(12), 8, 12); + settle(&mut p); + assert_eq!(p.index, 1.0); + p.jump(-5); + settle(&mut p); + assert_eq!(p.index, -1.0, "clamped to the glance page"); + p.jump(9); + assert!(p.take_library_request(), "past the end is the library"); + assert_eq!(p.index, 1.0); + // Fewer apps: the pager is pulled back onto the last apps page, and + // a layout change never opens the library by itself. + p.jump(1); + settle(&mut p); + p.sync(&ids(3), 8, 12); + assert_eq!(p.apps_count(), 1); + assert!(!p.take_library_request()); + assert_eq!((p.index, p.current()), (0.0, 0)); + // A deferred jump past the end lands on the last apps page too: the + // first layout may be transient, so only a tap opens the library. + let mut p = PagesState::default(); + p.jump(5); + p.sync(&ids(12), 8, 12); + assert!(!p.take_library_request()); + assert_eq!(p.index, 1.0); + } + + #[test] + fn the_glance_feed_keeps_posted_cards_newest_first_above_the_shells() { + let mut feed = GlanceFeed::default(); + feed.seed(vec![GlanceItem::Note { title: "Apps".into(), body: "a, b".into() }]); + feed.push(GlanceItem::Event { title: "Standup".into(), when: "10:00".into() }); + feed.push(GlanceItem::Weather { place: "Tokyo".into(), temp: "24°".into(), hi: "27°".into(), lo: "19°".into(), cond: "Clear".into() }); + let titles: Vec<&str> = feed.items().map(GlanceItem::title).collect(); + assert_eq!(titles, ["Tokyo", "Standup", "Apps"]); + // Reseeding the shell's cards leaves the posted ones alone. + feed.seed(vec![GlanceItem::Note { title: "Apps".into(), body: "a, b, c".into() }, GlanceItem::Fetch { title: "Model".into(), progress: 0.4 }]); + let titles: Vec<&str> = feed.items().map(GlanceItem::title).collect(); + assert_eq!(titles, ["Tokyo", "Standup", "Apps", "Model"]); + assert!(matches!(feed.weather(), Some(GlanceItem::Weather { place, .. }) if place == "Tokyo")); + assert!(matches!(feed.next_event(), Some(GlanceItem::Event { title, .. }) if title == "Standup")); + assert_eq!(feed.column_height(12.0), 128.0 + 78.0 + 104.0 + 88.0 + 36.0); + let mut p = PagesState { feed, date: "Monday, 14 September 2026".into(), ..Default::default() }; + assert_eq!(p.strip_text(), "Monday, 14 September 2026 · 24° Clear · Standup 10:00"); + // The column scrolls only as far as it overflows the screen. + // 434 of cards under a 96 header on a 500 screen with 124 kept for + // the dock: 154 points hidden. + p.scroll_glance(1000.0, 500.0); + assert_eq!(p.glance_scroll, 434.0 + 96.0 - (500.0 - 124.0)); + p.scroll_glance(-1000.0, 500.0); + assert_eq!(p.glance_scroll, 0.0); + p.scroll_glance(50.0, 5000.0); + assert_eq!(p.glance_scroll, 0.0, "a tall screen shows everything"); + } +} diff --git a/src/mobile_surface.rs b/src/mobile_surface.rs index 6875e920..a1ed345f 100644 --- a/src/mobile_surface.rs +++ b/src/mobile_surface.rs @@ -90,7 +90,7 @@ script_mod! { } /// The dock's four apps, left to right. -const PINNED: [&str; 4] = ["browser", "files", "photos", "terminal"]; +pub const PINNED: [&str; 4] = ["browser", "files", "photos", "terminal"]; #[derive(Script, ScriptHook, Widget)] pub struct PhoneSurface { @@ -99,7 +99,7 @@ pub struct PhoneSurface { #[walk] walk: Walk, #[layout] layout: Layout, #[visible] #[live(true)] visible: bool, - #[live] d: ShellDraw, + #[live] pub d: ShellDraw, #[live] ios_font: TextStyle, #[live] ios_bold: TextStyle, #[live] android_font: TextStyle, @@ -113,7 +113,7 @@ pub struct PhoneSurface { #[rust] pressed: Option, #[live] wallpaper: DrawQuad, #[rust] icons: AppIconDraw, - #[rust] hits: Vec<(Rect, PhoneHit)>, + #[rust] pub hits: Vec<(Rect, PhoneHit)>, #[find] #[live] search: WidgetRef, #[rust] search_style: Option<(bool, bool)>, #[rust] search_rect: Rect, @@ -130,15 +130,15 @@ impl PhoneSurface { self.hits.iter().find(|(_, h)| h == hit).map(|(r, _)| *r) } pub fn begin(&mut self) { self.hits.clear(); } - fn rounded(&mut self, cx: &mut Cx2d, r: Rect, radius: f32, color: Vec4f) { + pub(crate) fn rounded(&mut self, cx: &mut Cx2d, r: Rect, radius: f32, color: Vec4f) { self.chrome.radius = radius*2.0; self.chrome.bevel = 0.0; self.chrome.color = color; self.chrome.draw_abs(cx,r); } - fn label(&mut self, cx: &mut Cx2d, r: Rect, label: &str, size: f64, bold: bool, color: Vec4f) { + pub(crate) fn label(&mut self, cx: &mut Cx2d, r: Rect, label: &str, size: f64, bold: bool, color: Vec4f) { self.d.label_elided(cx,r,bold,size,color,HAlign::Center,label); } - fn use_fonts(&mut self, ios: bool) { + pub(crate) fn use_fonts(&mut self, ios: bool) { self.d.text.text_style=if ios {self.ios_font.clone()}else{self.android_font.clone()}; self.d.text_bold.text_style=if ios {self.ios_bold.clone()}else{self.android_bold.clone()}; } @@ -155,7 +155,7 @@ impl PhoneSurface { } /// Where the home page's content starts: under the status bar, and on /// Android's portrait home under the big clock. - fn home_top(style: DesktopStyle, screen: Rect) -> f64 { + pub fn home_top(style: DesktopStyle, screen: Rect) -> f64 { let landscape=screen.size.x>screen.size.y; screen.pos.y + if landscape {44.0} else if style==DesktopStyle::Ios {70.0} else {156.0} } @@ -235,28 +235,51 @@ impl PhoneSurface { if ios {self.draw_app_library(cx,state,screen,&ids);} else {self.draw_android_drawer(cx,state,screen,&ids);} return; } - let ink=if !ios && !state.style.dark {rgb(31,27,38)}else{rgb(255,255,255)}; - if !ios && !landscape { - self.label(cx,rect(screen.pos.x+24.0,screen.pos.y+48.0,screen.size.x-48.0,58.0),&phone.clock,48.0,false,alpha(ink,opacity)); - self.label(cx,rect(screen.pos.x+24.0,screen.pos.y+110.0,screen.size.x-48.0,26.0),"OctoSense",15.0,false,alpha(ink,opacity*0.8)); - } + let dark=state.style.dark; + let ink=if !ios && !dark {rgb(31,27,38)}else{rgb(255,255,255)}; let layout=Self::home_layout(style,screen); let home=phone.screen==PhoneScreen::Home; - // The tiles themselves are composited by the desk (their captures - // or placeholders); the page owns their hit regions. - if home { - for slot in &layout.tiles {self.hits.push((slot.rect,PhoneHit::App(slot.app.into())));} - } - // Favorites: the launcher's order minus the dock, as many as fit - // above it. The rest live in the App Library / the drawer. - let favorites: Vec<&(String,String)>=ids.iter().filter(|(id,_)|!PINNED.contains(&id.as_str())).take(layout.capacity).collect(); - let cell=layout.favorites.size.x/layout.columns as f64; - let size=if landscape {44.0}else{60.0}; - for (index,(id,label)) in favorites.iter().enumerate() { - let r=rect(layout.favorites.pos.x+(index%layout.columns)as f64*cell,layout.favorites.pos.y+(index/layout.columns)as f64*layout.row_height,cell,layout.row_height); - self.icons.draw(cx,id,style,rect(r.pos.x+(cell-size)*0.5,r.pos.y,size,size),opacity,ink); - self.label(cx,rect(r.pos.x,r.pos.y+size+4.0,cell,20.0),label,11.0,false,alpha(ink,opacity)); - if home {self.hits.push((r,PhoneHit::App(id.clone())));} + let width=screen.size.x; + // The pager (mobile_pages.rs): every page drawn at its offset from + // the current position — the glance page left of page 0, the + // favorites that overflow page 0 on the spill pages, the library's + // stand-in at the right end. The dock and the indicator stay put. + for k in phone.pages.positions() { + if !phone.pages.page_visible(k,width) {continue;} + let dx=phone.pages.page_offset(k,width); + if k<0 {self.draw_glance(cx,phone,screen,style,dark,opacity,dx);continue;} + if k==phone.pages.library_index() {self.draw_library_preview(cx,screen,dark,ink,opacity,dx);continue;} + let first=k==0; + if first { + // The "at a glance" strip: the date, weather and next event + // in one line, which the glance page expands. On Android's + // portrait home it sits under the big clock. + let strip=phone.pages.strip_text(); + if !ios && !landscape { + self.label(cx,rect(screen.pos.x+24.0+dx,screen.pos.y+48.0,screen.size.x-48.0,58.0),&phone.clock,48.0,false,alpha(ink,opacity)); + self.label(cx,rect(screen.pos.x+24.0+dx,screen.pos.y+110.0,screen.size.x-48.0,26.0),&strip,13.0,false,alpha(ink,opacity*0.8)); + } else { + let y=screen.pos.y+if landscape {23.0} else {44.0}; + self.label(cx,rect(screen.pos.x+24.0+dx,y,screen.size.x-48.0,22.0),&strip,12.0,false,alpha(ink,opacity*0.85)); + } + // The tiles themselves are composited by the desk (their + // captures or placeholders); the page owns their hit regions. + if home { + for slot in &layout.tiles {self.hits.push((rect(slot.rect.pos.x+dx,slot.rect.pos.y,slot.rect.size.x,slot.rect.size.y),PhoneHit::App(slot.app.into())));} + } + } + // Favorites: page 0 takes as many as fit beside the tiles, the + // spill pages lay the rest out on a tile-free grid. + let page=if first {layout.clone()} else {mobile_tiles::home_layout_for_apps(screen,Self::home_top(style,screen),Self::home_dock(screen),&[])}; + let cell=page.favorites.size.x/page.columns as f64; + let size=if landscape {44.0}else{60.0}; + for (index,id) in phone.pages.page_ids(k).iter().enumerate() { + let label=ids.iter().find(|(i,_)|i==id).map(|(_,l)|l.as_str()).unwrap_or(id.as_str()); + let r=rect(page.favorites.pos.x+dx+(index%page.columns)as f64*cell,page.favorites.pos.y+(index/page.columns)as f64*page.row_height,cell,page.row_height); + self.icons.draw(cx,id,style,rect(r.pos.x+(cell-size)*0.5,r.pos.y,size,size),opacity,ink); + self.label(cx,rect(r.pos.x,r.pos.y+size+4.0,cell,20.0),label,11.0,false,alpha(ink,opacity)); + if home {self.hits.push((r,PhoneHit::App(id.clone())));} + } } let dock=Self::home_dock(screen); if ios {self.glass.draw_surface_with_backdrop(cx,dock,backdrop,opacity);} @@ -266,21 +289,9 @@ impl PhoneSurface { self.icons.draw(cx,id,style,rect(r.pos.x+(cell-58.0)*0.5,r.pos.y+12.0,58.0,58.0),opacity,ink); if home {self.hits.push((r,PhoneHit::App((*id).into())));} } - if ios { - // The page indicator: this page, then the App Library's dot. - // Tapping it (or swiping left) opens the library. - let r=rect(screen.pos.x+(screen.size.x-60.0)*0.5,dock.pos.y-30.0,60.0,24.0); - self.rounded(cx,rect(r.pos.x+15.0,r.pos.y+8.0,8.0,8.0),4.0,alpha(ink,opacity)); - let lib=rect(r.pos.x+34.0,r.pos.y+6.0,12.0,12.0); - self.rounded(cx,lib,3.0,alpha(ink,0.45*opacity)); - for (dx,dy) in [(2.5,2.5),(6.5,2.5),(2.5,6.5),(6.5,6.5)] { - self.rounded(cx,rect(lib.pos.x+dx,lib.pos.y+dy,3.0,3.0),1.0,alpha(ink,0.9*opacity)); - } - if home {self.hits.push((rect(r.pos.x-20.0,r.pos.y-6.0,100.0,36.0),PhoneHit::Drawer));} - } else if home { - let r=rect(screen.pos.x+(screen.size.x-100.0)*0.5,dock.pos.y-32.0,100.0,28.0); - self.label(cx,r,"All apps ↑",12.0,false,ink);self.hits.push((r,PhoneHit::Drawer)); - } + // The page indicator: the glance glyph, a dot per apps page, the + // library glyph; tapping one jumps there (the library dot opens it). + self.draw_page_indicator(cx,phone,dock,screen,ink,opacity,home); } /// Android's app drawer: a sheet with every launchable app on one grid. fn draw_android_drawer(&mut self, cx: &mut Cx2d, state: &WmState, screen: Rect, ids: &[(String,String)]) { From 11c3d473f21ce84ff10922c9b8daba9d34b1af38 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:47:23 -0700 Subject: [PATCH 06/13] =?UTF-8?q?mobile:=20the=20shade=20=E2=80=94=20notif?= =?UTF-8?q?ications=20left,=20controls=20right?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new `mobile_shade` module owns the phone's pull-down sheet: pulled from the top-left it is the notification list, from the top-right the quick controls, and once open the two halves are one sideways-swipeable sheet over a blurred backdrop. Notifications are HyperOS-style rounded cards (app, title, body, relative age); a right swipe dismisses, a left swipe reveals the actions, "Clear all" empties the list. Controls: brightness and volume sliders, a three-column grid of toggles that change from a pill to a rounded rectangle when on (Android 16 style), and the clock and battery line the status bar already knows. Swipe up on the sheet or tap the dimmed backdrop to close; while open the sheet's rect is an exclusion zone for every edge so shell gestures do not fire beneath it. The state machine reads `PhoneState::gesture_out` every frame (ShadePull drives `open`, Commit/Cancel animate it, PageSwipe switches halves) and is unit-tested. Until the recognizer lands the shade opens from a tap on the status bar's left or right half and from `--test-action shade:`. The shell's notification stack and the WM protocol's Notify mirror into the shade; the sample cards appear only when it opens onto an empty list. Hooks in shared files are one line each: the module, the `shade` field and its per-frame feed on PhoneState, `PhoneHit::Shade`, the tap arm and drag/release lines in the phone pointer path, the topmost draw call, and the sheet's backdrop snapshot. Co-Authored-By: Claude Fable 5.1 --- src/desk/phone.rs | 5 +- src/main.rs | 18 ++ src/mobile.rs | 7 +- src/mobile_app.rs | 3 + src/mobile_shade.rs | 644 ++++++++++++++++++++++++++++++++++++++++++ src/mobile_surface.rs | 3 +- 6 files changed, 677 insertions(+), 3 deletions(-) create mode 100644 src/mobile_shade.rs diff --git a/src/desk/phone.rs b/src/desk/phone.rs index 92e36f84..b3b678ee 100644 --- a/src/desk/phone.rs +++ b/src/desk/phone.rs @@ -274,12 +274,15 @@ impl WmDesk { self.phone_frames.insert(client,stored); if foreground {self.zorder.push(client);} } + // The shade's frosted sheet samples the finished scene here (the + // final-glass snapshot is upside down on GL). + let shade_backdrop=if phone.shade.open>0.001 {Some(self.compositor.as_mut().unwrap().backdrop(cx,screen,3.0))}else{None}; let glass=if phone.keyboard>0.5 { Some((Rect {pos:screen.pos+dvec2(0.0,screen.size.y-phone.keyboard-24.0),size:dvec2(screen.size.x,phone.keyboard)},4.0)) }else{None}; let (backdrop,_,_)=self.compositor.as_mut().unwrap().finish(cx,screen,glass); let state=scope.data.get_mut::().unwrap(); - self.phone_ui.draw_overlay(cx,state,screen,backdrop); + self.phone_ui.draw_overlay(cx,state,screen,shade_backdrop.or(backdrop)); } pub(super) fn handle_phone_event(&mut self,cx:&mut Cx,event:&Event,scope:&mut Scope) { let state=scope.data.get_mut::().unwrap(); diff --git a/src/main.rs b/src/main.rs index e15b7d4b..579055b4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,6 +26,7 @@ mod mobile_surface; mod mobile_gestures; mod mobile_app; mod mobile_tiles; +mod mobile_shade; mod scene; mod dock_warp; mod host; @@ -1088,6 +1089,8 @@ impl App { // The notifications surface is the shell-UI lane's; until // it lands the notification is at least not lost. log!("wm: notify from client {}: {} — {}", client, title, body); + let (app, now) = (self.state_mut().clients.get(&client).map(|s| s.app.clone()).unwrap_or_default(), cx.seconds_since_app_start()); + self.state_mut().phone.shade.post(&app, title, body, now, vec!["Open".into()]); } WmRequest::Close => self.request_close(cx, client), WmRequest::SetFloating { floating } => { @@ -2729,6 +2732,8 @@ impl App { n.notify(cx, title, body); } } + let now = cx.seconds_since_app_start(); + if let Some(state) = self.state.as_mut() { state.phone.shade.post("wm", title, body, now, Vec::new()); } self.redraw_all(cx); } @@ -2766,6 +2771,7 @@ impl App { if let Some(clock)=self.bar_sample.clock.split_whitespace().find(|s|s.contains(':')).map(str::to_string) { self.state_mut().phone.clock=clock; } + self.state_mut().phone.shade.battery = self.bar_sample.battery.map(|b| (b.percent, b.charging)); let mut shown: Vec = Vec::new(); let workspaces = { let state = self.state_mut(); @@ -3640,6 +3646,18 @@ impl App { i += 2; continue; } + // shade:: open the shade on that + // side (switching to the Android phone shell first when + // the desk is up), for scripted screenshots. + if let Some(side) = name.strip_prefix("shade:") { + let side = if side.trim() == "controls" { mobile_gestures::ShadeSide::Controls } else { mobile_gestures::ShadeSide::Notifications }; + log!("wm: --test-action shade -> {:?}", side); + if !self.state_mut().style.target.mobile() { self.set_desktop_style(cx, desktop::DesktopStyle::Android); } + self.state_mut().phone.shade.open_on(side); + self.animate_phone(cx); + i += 2; + continue; + } match test_action(name) { Some(action) => { log!("wm: --test-action {} -> {:?}", name, action); diff --git a/src/mobile.rs b/src/mobile.rs index bba09f53..f578ed0e 100644 --- a/src/mobile.rs +++ b/src/mobile.rs @@ -12,6 +12,7 @@ pub enum PhoneHit { App(String), Card(ClientId), Home, Recents, Drawer, Back, Rotate, Style, Appearance, Desktop, Key(String), Shift, Symbols, HideKeyboard, ClearSearch, CancelSearch, + Shade(crate::mobile_shade::ShadeHit), } #[derive(Clone)] @@ -62,6 +63,8 @@ pub struct PhoneState { /// Rects apps own on screen; shell gestures starting inside them are not /// recognised. pub exclusions: crate::mobile_gestures::ExclusionZones, + /// The notification/controls shade (mobile_shade.rs). + pub shade: crate::mobile_shade::ShadeState, } impl Default for PhoneState { fn default() -> Self { @@ -73,7 +76,8 @@ impl Default for PhoneState { desktop_size: None, desktop_clients: Vec::new(), desktop_style: DesktopStyle::Omarchy, viewport: Rect::default(), tiles: HomeTiles::default(), gesture_out: None, - exclusions: Default::default() } + exclusions: Default::default(), + shade: Default::default() } } } impl PhoneState { @@ -134,6 +138,7 @@ impl PhoneState { if (target - self.page).abs() < 0.001 { self.page = target; } active |= self.page != target; } + active |= self.shade.step(dt, self.gesture_out, self.viewport, self.wallpaper_time, &mut self.exclusions); active } pub fn accepts_app_input(&self) -> bool { diff --git a/src/mobile_app.rs b/src/mobile_app.rs index 8715ff96..1b300732 100644 --- a/src/mobile_app.rs +++ b/src/mobile_app.rs @@ -382,6 +382,7 @@ impl App { if self.state_mut().phone.keyboard_target>0.0 {self.dismiss_phone_keyboard(cx);} else {self.phone_back(cx);} } + PhoneHit::Shade(hit)=>self.state_mut().phone.shade.tap(hit), } self.sync_phone_keyboard(cx); self.sync_home_tiles(cx); @@ -514,6 +515,7 @@ impl App { let phone=&mut self.state_mut().phone; let Some(g)=phone.gesture.as_mut() else{return phone.screen!=PhoneScreen::App;}; let delta=p-g.start;let last=p-g.last;g.last=p; + if let Some(PhoneHit::Shade(h))=g.hit.clone() {phone.shade.drag(&h,p,delta,screen);self.animate_phone(cx);return true;} if g.bottom && delta.y < -8.0 { phone.overview=(-delta.y/(screen.size.y*0.42)).clamp(0.0,1.0); phone.openness=1.0; @@ -528,6 +530,7 @@ impl App { PhonePointerPhase::Up=>{ let Some(g)=self.state_mut().phone.gesture.take() else{return self.state_mut().phone.screen!=PhoneScreen::App;}; let delta=p-g.start; + if let (Some(PhoneHit::Shade(h)),true)=(&g.hit,delta.length()>=12.0) {self.state_mut().phone.shade.release(h,delta,time-g.time);self.animate_phone(cx);return true;} if g.bottom { if delta.x.abs()>70.0 && delta.x.abs()>delta.y.abs()*1.5 { let phone=&self.state_mut().phone; diff --git a/src/mobile_shade.rs b/src/mobile_shade.rs new file mode 100644 index 00000000..9d39aedb --- /dev/null +++ b/src/mobile_shade.rs @@ -0,0 +1,644 @@ +//! The shade: pulled down from the top-left it is the notification list, +//! from the top-right the quick controls; once open the two halves are one +//! sideways-swipeable sheet. All of its state, layout and drawing live +//! here; the phone shell only keeps a `ShadeState` on `PhoneState`, feeds +//! it the recognised gesture each frame (`ShadeState::step`), routes taps +//! and drags through `PhoneHit::Shade` and calls `draw` last so the sheet +//! is topmost. +//! +//! Until the gesture recognizer lands, the shade opens programmatically: +//! tapping the status bar's left or right half, or `--test-action +//! shade:`. +use crate::mobile_gestures::{Dir, ExclusionZones, GestureKind, ShadeSide, ShellGesture}; +use crate::{desk::WmState, desktop::{DesktopStyle, DrawDesktopChrome}, mobile::PhoneHit, octosense::style::AppIconDraw, shell::{alpha, rgb, ui::{rect, HAlign, Ico, ShellDraw}}}; +use makepad_widgets::{gauss_view::{GaussBlurSnapshot, GaussRoundedView}, *}; + +/// One notification card. `time` is seconds since app start when it was +/// posted, so the card shows a relative age. +#[derive(Clone, Debug, PartialEq)] +pub struct ShadeNote { + pub id: u64, + /// The posting app's id (`wm` for the shell's own). + pub app: String, + pub title: String, + pub body: String, + pub time: f64, + /// Action buttons a left swipe reveals, by label. + pub actions: Vec, + /// The card's horizontal swipe offset in points (right: dismissing, + /// left: revealing the actions), animated back or away on release. + pub offset: f64, + /// The actions are showing (the card rests at `-actions_width`). + pub revealed: bool, +} + +/// The quick-settings toggles, in grid order. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Toggle { Wifi, Bluetooth, Torch, RotationLock, DoNotDisturb } +impl Toggle { + pub const ALL: [Toggle; 5] = [Toggle::Wifi, Toggle::Bluetooth, Toggle::Torch, Toggle::RotationLock, Toggle::DoNotDisturb]; + fn label(self) -> &'static str { + match self { Toggle::Wifi => "Wi-Fi", Toggle::Bluetooth => "Bluetooth", Toggle::Torch => "Torch", Toggle::RotationLock => "Rotation", Toggle::DoNotDisturb => "DND" } + } + fn index(self) -> usize { Toggle::ALL.iter().position(|t| *t == self).unwrap_or(0) } +} + +/// Everything on the shade a finger can land on; `PhoneHit::Shade` carries +/// one. Taps go through `ShadeState::tap`, drags through `drag`/`release`. +#[derive(Clone, Debug, PartialEq)] +pub enum ShadeHit { + /// The status bar's left or right half while the shade is closed. + Open(ShadeSide), + /// The sheet itself: sideways switches the page, upward closes. + Sheet, + /// The dimmed rest of the screen: a tap closes. + Backdrop, + /// A notification card: right dismisses, left reveals its actions. + Note(u64), + /// A revealed action button (`index == actions.len()` is "Clear"). + Action(u64, usize), + ClearAll, + Brightness, + Volume, + Toggle(Toggle), +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum DragAxis { Undecided, Horizontal, Vertical } + +/// The shade's whole state; animated by `step` every phone frame. +#[derive(Clone, Debug)] +pub struct ShadeState { + /// 0 closed .. 1 open; follows the pull directly, animates otherwise. + pub open: f64, + open_target: f64, + pub side: ShadeSide, + /// 0 = notifications, 1 = controls, animated for the sideways swipe. + pub page: f64, + page_target: f64, + pub notifications: Vec, + next_id: u64, + pub brightness: f64, + pub volume: f64, + pub wifi: bool, + pub bluetooth: bool, + pub torch: bool, + pub rotation_lock: bool, + pub do_not_disturb: bool, + /// Per-toggle 0..1 shape animation (pill → rounded rectangle). + toggle_anim: [f64; 5], + /// Battery percent and charging, from the status sampler the bar uses. + pub battery: Option<(u32, bool)>, + /// Seconds since app start at the last frame, for the cards' ages. + pub now: f64, + /// A `ShadePull` drove `open` this frame: hold the animation. + pulling: bool, + /// A finger on the sheet: the axis it settled on, and page/open at the + /// drag's start so the offsets are absolute. + drag: Option<(DragAxis, f64, f64)>, + /// A finger on a card: it holds its offset until release. + dragging_note: Option, + /// The exclusion zone added last frame, removed before the next. + last_zone: Option, + seeded: bool, +} +impl Default for ShadeState { + fn default() -> Self { + Self { + open: 0.0, open_target: 0.0, side: ShadeSide::Notifications, page: 0.0, page_target: 0.0, + notifications: Vec::new(), next_id: 0, + brightness: 0.62, volume: 0.45, wifi: true, bluetooth: false, torch: false, rotation_lock: false, do_not_disturb: false, + toggle_anim: [1.0, 0.0, 0.0, 0.0, 0.0], + battery: None, now: 0.0, pulling: false, drag: None, dragging_note: None, last_zone: None, seeded: false, + } + } +} + +/// Width of the revealed action strip behind a card. +const ACTION_W: f64 = 84.0; +/// Bottom band the sheet leaves to the navigation pill. +const NAV_H: f64 = 24.0; +const CARD_RADIUS: f32 = 22.0; + +impl ShadeState { + pub fn is_open(&self) -> bool { self.open > 0.001 || self.open_target > 0.5 } + pub fn toggled(&self, t: Toggle) -> bool { + match t { Toggle::Wifi => self.wifi, Toggle::Bluetooth => self.bluetooth, Toggle::Torch => self.torch, Toggle::RotationLock => self.rotation_lock, Toggle::DoNotDisturb => self.do_not_disturb } + } + fn flip(&mut self, t: Toggle) { + match t { Toggle::Wifi => self.wifi = !self.wifi, Toggle::Bluetooth => self.bluetooth = !self.bluetooth, Toggle::Torch => self.torch = !self.torch, Toggle::RotationLock => self.rotation_lock = !self.rotation_lock, Toggle::DoNotDisturb => self.do_not_disturb = !self.do_not_disturb } + } + + /// Open on `side`, animated; the page follows the side. + pub fn open_on(&mut self, side: ShadeSide) { + self.side = side; + self.page_target = if side == ShadeSide::Controls { 1.0 } else { 0.0 }; + if self.open < 0.001 { self.page = self.page_target; } + self.open_target = 1.0; + self.pulling = false; + if self.notifications.is_empty() && !self.seeded { self.seed_fixtures(); } + } + pub fn close(&mut self) { self.open_target = 0.0; self.pulling = false; self.drag = None; } + + /// Post a notification; the shell's stack and the WM protocol both + /// mirror into here. Returns the card id. + pub fn post(&mut self, app: &str, title: &str, body: &str, now: f64, actions: Vec) -> u64 { + self.next_id += 1; + self.notifications.insert(0, ShadeNote { id: self.next_id, app: app.into(), title: title.into(), body: body.into(), time: now, actions, offset: 0.0, revealed: false }); + self.next_id + } + /// Demo content until Android's real notifications are wired: shown + /// the first time the shade opens onto an empty list. + pub fn seed_fixtures(&mut self) { + self.seeded = true; + let now = self.now; + self.post("photos", "Memories", "A new memory from this day last year is ready to watch.", now - 240.0, vec!["Watch".into()]); + self.post("terminal", "Build finished", "octosense — release build completed in 4m 12s", now - 900.0, vec!["Open".into(), "Rerun".into()]); + self.post("wm", "Welcome to OctoSense", "Pull down from the top-left for notifications and from the top-right for controls.", now - 3600.0, vec![]); + } + pub fn dismiss(&mut self, id: u64) { self.notifications.retain(|n| n.id != id); } + pub fn clear_all(&mut self) { self.notifications.clear(); } + + /// The sheet's rect for `screen` at the current openness: it grows from + /// the top edge and leaves the navigation band. + pub fn sheet_rect(&self, screen: Rect) -> Rect { + let h = (screen.size.y - NAV_H).max(1.0); + rect(screen.pos.x, screen.pos.y, screen.size.x, h * self.open.clamp(0.0, 1.0)) + } + /// Where the sheet's content sits: attached to the sheet's bottom edge, + /// so it slides down with the pull. + fn content_rect(&self, screen: Rect) -> Rect { + let h = (screen.size.y - NAV_H).max(1.0); + rect(screen.pos.x, screen.pos.y - (1.0 - self.open.clamp(0.0, 1.0)) * h, screen.size.x, h) + } + fn slider_track(content: Rect, which: ShadeHit) -> Rect { + let y = content.pos.y + if which == ShadeHit::Brightness { 128.0 } else { 186.0 }; + rect(content.pos.x + 20.0, y, content.size.x - 40.0, 46.0) + } + fn toggle_cell(content: Rect, index: usize) -> Rect { + let gap = 10.0; + let w = (content.size.x - 40.0 - gap * 2.0) / 3.0; + rect(content.pos.x + 20.0 + (index % 3) as f64 * (w + gap), content.pos.y + 262.0 + (index / 3) as f64 * 74.0, w, 64.0) + } + + /// A tap on a shade hit (the shell's tap detection already happened). + pub fn tap(&mut self, hit: ShadeHit) { + match hit { + ShadeHit::Open(side) => self.open_on(side), + ShadeHit::Backdrop => self.close(), + ShadeHit::Sheet => {} + ShadeHit::Note(id) => { + if let Some(n) = self.notifications.iter_mut().find(|n| n.id == id) { n.revealed = false; } + } + ShadeHit::Action(id, _) => self.dismiss(id), + ShadeHit::ClearAll => self.clear_all(), + ShadeHit::Brightness | ShadeHit::Volume => {} + ShadeHit::Toggle(t) => self.flip(t), + } + } + + /// A finger moved with `hit` under its start: `p` is where it is now, + /// `delta` its travel from the start, `screen` the phone's viewport. + pub fn drag(&mut self, hit: &ShadeHit, p: Vec2d, delta: Vec2d, screen: Rect) { + match hit { + ShadeHit::Sheet | ShadeHit::Backdrop | ShadeHit::ClearAll => { + let (axis, page0, open0) = self.drag.unwrap_or((DragAxis::Undecided, self.page, self.open)); + let axis = match axis { + DragAxis::Undecided if delta.length() < 8.0 => DragAxis::Undecided, + DragAxis::Undecided => if delta.x.abs() > delta.y.abs() { DragAxis::Horizontal } else { DragAxis::Vertical }, + a => a, + }; + self.drag = Some((axis, page0, open0)); + match axis { + DragAxis::Horizontal => self.page = (page0 - delta.x / screen.size.x.max(1.0)).clamp(-0.2, 1.2), + DragAxis::Vertical => { + let h = (screen.size.y - NAV_H).max(1.0); + self.open = (open0 + delta.y / h).clamp(0.0, 1.0); + self.pulling = true; + } + DragAxis::Undecided => {} + } + } + ShadeHit::Note(id) => { + self.dragging_note = Some(*id); + if let Some(n) = self.notifications.iter_mut().find(|n| n.id == *id) { + let base = if n.revealed { -(n.actions.len() as f64 + 1.0) * ACTION_W } else { 0.0 }; + let min = -(n.actions.len() as f64 + 1.0) * ACTION_W - 20.0; + n.offset = (base + delta.x).clamp(min, screen.size.x); + } + } + ShadeHit::Brightness | ShadeHit::Volume => { + let track = Self::slider_track(self.content_rect(screen), hit.clone()); + let v = ((p.x - track.pos.x) / track.size.x.max(1.0)).clamp(0.0, 1.0); + if *hit == ShadeHit::Brightness { self.brightness = v; } else { self.volume = v; } + } + _ => {} + } + } + + /// The finger lifted after a drag (not a tap) on `hit`; `delta` is the + /// travel and `dt` the seconds it took, for flicks. + pub fn release(&mut self, hit: &ShadeHit, delta: Vec2d, dt: f64) { + let fast = dt < 0.3; + match hit { + ShadeHit::Sheet | ShadeHit::Backdrop | ShadeHit::ClearAll => { + let (axis, page0, _) = self.drag.take().unwrap_or((DragAxis::Undecided, self.page, self.open)); + self.pulling = false; + match axis { + DragAxis::Horizontal => { + let flick = if fast && delta.x < -40.0 { 1.0 } else if fast && delta.x > 40.0 { -1.0 } else { 0.0 }; + self.page_target = if flick != 0.0 { (page0 + flick).clamp(0.0, 1.0) } else { self.page.round().clamp(0.0, 1.0) }; + self.side = if self.page_target > 0.5 { ShadeSide::Controls } else { ShadeSide::Notifications }; + } + DragAxis::Vertical => { + if delta.y < -60.0 || (fast && delta.y < -25.0) || self.open < 0.5 { self.open_target = 0.0; } else { self.open_target = 1.0; } + } + DragAxis::Undecided => {} + } + } + ShadeHit::Note(id) => { + self.dragging_note = None; + let Some(n) = self.notifications.iter_mut().find(|n| n.id == *id) else { return }; + let reveal_w = (n.actions.len() as f64 + 1.0) * ACTION_W; + if n.offset > 96.0 || (fast && delta.x > 40.0) { + let id = *id; + self.dismiss(id); + } else if n.offset < -reveal_w * 0.4 { + n.revealed = true; + } else { + n.revealed = false; + } + } + _ => {} + } + } + + /// Per frame: apply this frame's shell gesture, animate, refresh the + /// exclusion zone. Returns true while anything is still moving. + pub fn step(&mut self, dt: f64, gesture: Option, screen: Rect, now: f64, exclusions: &mut ExclusionZones) -> bool { + if now > 0.0 { self.now = now; } + self.apply_gesture(gesture); + let t = 1.0 - (-dt * 16.0).exp(); + let mut active = false; + if !self.pulling { + self.open += (self.open_target - self.open) * t; + if (self.open - self.open_target).abs() < 0.002 { self.open = self.open_target; } + active |= self.open != self.open_target; + } + if self.drag.map_or(true, |(a, _, _)| a != DragAxis::Horizontal) { + self.page += (self.page_target - self.page) * t; + if (self.page - self.page_target).abs() < 0.002 { self.page = self.page_target; } + active |= self.page != self.page_target; + } + for n in &mut self.notifications { + if self.dragging_note == Some(n.id) { continue; } + let target = if n.revealed { -(n.actions.len() as f64 + 1.0) * ACTION_W } else { 0.0 }; + n.offset += (target - n.offset) * t; + if (n.offset - target).abs() < 0.3 { n.offset = target; } + active |= n.offset != target; + } + for (i, tg) in Toggle::ALL.iter().enumerate() { + let target = if self.toggled(*tg) { 1.0 } else { 0.0 }; + let v = &mut self.toggle_anim[i]; + *v += (target - *v) * t; + if (*v - target).abs() < 0.005 { *v = target; } + active |= *v != target; + } + // The sheet owns every edge under it while it shows. + if let Some(z) = self.last_zone.take() { exclusions.zones.retain(|e| !(e.rect == z && e.edges == [true; 4])); } + if self.open > 0.01 { + let z = if self.open > 0.5 { screen } else { self.sheet_rect(screen) }; + exclusions.add(z, [true; 4]); + self.last_zone = Some(z); + } + self.pulling = false; + active + } + + fn apply_gesture(&mut self, gesture: Option) { + match gesture { + Some(ShellGesture::ShadePull { side, progress }) => { + if self.open_target < 0.5 || !self.is_open() { + self.side = side; + self.page_target = if side == ShadeSide::Controls { 1.0 } else { 0.0 }; + self.page = self.page_target; + if self.notifications.is_empty() && !self.seeded { self.seed_fixtures(); } + } + self.open = progress.clamp(0.0, 1.0); + self.pulling = true; + } + Some(ShellGesture::Commit(GestureKind::Shade(side))) => self.open_on(side), + Some(ShellGesture::Cancel(GestureKind::Shade(_))) => self.close(), + Some(ShellGesture::PageSwipe { dir, progress }) if self.is_open() => { + let from = self.page_target; + let to = match dir { Dir::Left => (from + 1.0).min(1.0), Dir::Right => (from - 1.0).max(0.0) }; + self.page = from + (to - from) * progress.clamp(0.0, 1.0); + self.drag = Some((DragAxis::Horizontal, from, self.open)); + } + Some(ShellGesture::Commit(GestureKind::Page(dir))) if self.is_open() => { + self.page_target = match dir { Dir::Left => (self.page_target + 1.0).min(1.0), Dir::Right => (self.page_target - 1.0).max(0.0) }; + self.side = if self.page_target > 0.5 { ShadeSide::Controls } else { ShadeSide::Notifications }; + self.drag = None; + } + Some(ShellGesture::Cancel(GestureKind::Page(_))) if self.is_open() => { self.drag = None; } + _ => {} + } + } +} + +/// "now", "4m", "2h", "1d". +fn age(now: f64, then: f64) -> String { + let s = (now - then).max(0.0); + if s < 60.0 { "now".into() } else if s < 3600.0 { format!("{}m", (s / 60.0) as u64) } else if s < 86400.0 { format!("{}h", (s / 3600.0) as u64) } else { format!("{}d", (s / 86400.0) as u64) } +} + +fn app_label(id: &str) -> String { + if id == "wm" { return "OctoSense".into(); } + crate::clients::find_app(id).map(|a| a.label).unwrap_or_else(|| { + let mut c = id.chars(); + match c.next() { Some(f) => f.to_uppercase().collect::() + c.as_str(), None => String::new() } + }) +} + +fn rounded(chrome: &mut DrawDesktopChrome, cx: &mut Cx2d, r: Rect, radius: f32, color: Vec4f) { + chrome.radius = radius; + chrome.bevel = 0.0; + chrome.color = color; + chrome.draw_abs(cx, r); +} + +/// Draw the shade over everything else. `hits` receives the tappable +/// regions; while closed only the status bar's two halves are registered. +#[allow(clippy::too_many_arguments)] +pub fn draw(cx: &mut Cx2d, d: &mut ShellDraw, chrome: &mut DrawDesktopChrome, icons: &mut AppIconDraw, glass: &mut GaussRoundedView, hits: &mut Vec<(Rect, PhoneHit)>, state: &WmState, screen: Rect, backdrop: Option) { + let shade = &state.phone.shade; + let style = state.style.target; + let dark = state.style.dark; + let ios = style == DesktopStyle::Ios; + let landscape = screen.size.x > screen.size.y; + if shade.open < 0.001 { + let status_h = if landscape { 24.0 } else { 42.0 }; + let half = screen.size.x * 0.5; + hits.push((rect(screen.pos.x, screen.pos.y, half, status_h), PhoneHit::Shade(ShadeHit::Open(ShadeSide::Notifications)))); + hits.push((rect(screen.pos.x + half, screen.pos.y, half, status_h), PhoneHit::Shade(ShadeHit::Open(ShadeSide::Controls)))); + return; + } + let open = shade.open.clamp(0.0, 1.0) as f32; + let sheet = shade.sheet_rect(screen); + let content = shade.content_rect(screen); + // The dimmed, blurred backdrop, then the sheet's own frosted surface. + rounded(chrome, cx, screen, 0.0, alpha(rgb(0, 0, 0), 0.28 * open)); + hits.push((screen, PhoneHit::Shade(ShadeHit::Backdrop))); + glass.set_blurriness(cx, 3.0); + glass.draw_surface_with_backdrop(cx, sheet, backdrop, 1.0); + rounded(chrome, cx, sheet, 0.0, if dark { alpha(rgb(12, 12, 20), 0.55) } else { alpha(rgb(236, 238, 246), 0.62) }); + hits.push((sheet, PhoneHit::Shade(ShadeHit::Sheet))); + let ink = if dark { rgb(245, 245, 250) } else { rgb(26, 26, 34) }; + let accent = if ios { rgb(0, 122, 255) } else { rgb(103, 80, 164) }; + let card = if dark { alpha(rgb(44, 46, 62), 0.86) } else { alpha(rgb(255, 255, 255), 0.88) }; + let w = content.size.x; + let pages = [content.pos.x - shade.page * w, content.pos.x + (1.0 - shade.page) * w]; + // The status line on both pages: clock left, battery right. + for (page, x) in pages.iter().enumerate() { + if *x + w < screen.pos.x || *x > screen.pos.x + screen.size.x { continue; } + let top = rect(*x + 24.0, content.pos.y + 14.0, w - 48.0, 30.0); + d.label(cx, top, true, 22.0, ink, HAlign::Left, &state.phone.clock); + let (percent, charging) = shade.battery.unwrap_or((100, false)); + let text = if charging { format!("{percent}% charging") } else { format!("{percent}%") }; + let tw = d.measure(cx, false, 13.0, &text); + d.label(cx, rect(top.pos.x + top.size.x - tw, top.pos.y, tw, 30.0), false, 13.0, alpha(ink, 0.85), HAlign::Right, &text); + let bx = top.pos.x + top.size.x - tw - 32.0; + let by = top.pos.y + 9.0; + rounded(chrome, cx, rect(bx, by, 24.0, 12.0), 3.0, alpha(ink, 0.45)); + rounded(chrome, cx, rect(bx + 24.5, by + 3.5, 2.0, 5.0), 1.0, alpha(ink, 0.45)); + rounded(chrome, cx, rect(bx + 2.0, by + 2.0, 20.0 * (percent.min(100) as f64 / 100.0).max(0.05), 8.0), 1.5, if percent <= 20 && !charging { rgb(228, 66, 52) } else { ink }); + if page == 0 { draw_notifications(cx, d, chrome, icons, hits, shade, style, dark, ink, accent, card, rect(*x, content.pos.y, w, content.size.y)); } + else { draw_controls(cx, d, chrome, hits, shade, dark, ink, accent, card, rect(*x, content.pos.y, w, content.size.y)); } + } + // Page dots and the drag handle along the sheet's bottom edge. + let by = sheet.pos.y + sheet.size.y; + for i in 0..2 { + let on = 1.0 - (shade.page - i as f64).abs().clamp(0.0, 1.0); + rounded(chrome, cx, rect(screen.pos.x + screen.size.x * 0.5 - 11.0 + i as f64 * 14.0, by - 36.0, 8.0, 8.0), 4.0, alpha(ink, 0.3 + 0.6 * on as f32)); + } + rounded(chrome, cx, rect(screen.pos.x + screen.size.x * 0.5 - 24.0, by - 16.0, 48.0, 5.0), 2.5, alpha(ink, 0.35)); +} + +#[allow(clippy::too_many_arguments)] +fn draw_notifications(cx: &mut Cx2d, d: &mut ShellDraw, chrome: &mut DrawDesktopChrome, icons: &mut AppIconDraw, hits: &mut Vec<(Rect, PhoneHit)>, shade: &ShadeState, style: DesktopStyle, dark: bool, ink: Vec4f, accent: Vec4f, card: Vec4f, page: Rect) { + let x = page.pos.x; + let w = page.size.x; + let head = rect(x + 24.0, page.pos.y + 58.0, w - 48.0, 30.0); + d.label(cx, head, true, 17.0, ink, HAlign::Left, "Notifications"); + if !shade.notifications.is_empty() { + let pill = rect(head.pos.x + head.size.x - 84.0, head.pos.y, 84.0, 30.0); + rounded(chrome, cx, pill, 15.0, alpha(ink, 0.10)); + d.label(cx, pill, false, 12.5, ink, HAlign::Center, "Clear all"); + hits.push((pill, PhoneHit::Shade(ShadeHit::ClearAll))); + } + let mut y = head.pos.y + 44.0; + let bottom = page.pos.y + page.size.y - 48.0; + if shade.notifications.is_empty() { + d.label(cx, rect(x, page.pos.y + page.size.y * 0.4, w, 30.0), false, 15.0, alpha(ink, 0.5), HAlign::Center, "No notifications"); + } + for n in &shade.notifications { + if y > bottom { break; } + let slot = rect(x + 16.0, y, w - 32.0, 0.0); + let text_w = slot.size.x - 78.0; + let lines = d.wrap(cx, false, 13.0, &n.body, text_w, 2); + let h = 62.0 + lines.len() as f64 * 18.0 + if lines.is_empty() { 0.0 } else { 4.0 }; + let slot = rect(slot.pos.x, y, slot.size.x, h); + // The action strip behind the card, revealed by a left swipe. + if n.offset < -8.0 { + let mut labels: Vec<&str> = n.actions.iter().map(|s| s.as_str()).collect(); + labels.push("Clear"); + let strip_w = (labels.len() as f64) * ACTION_W; + let visible = (-n.offset).min(strip_w); + for (i, label) in labels.iter().enumerate() { + let bx = slot.pos.x + slot.size.x - strip_w + i as f64 * ACTION_W; + let r = rect(bx + 6.0, slot.pos.y + 8.0, ACTION_W - 12.0, h - 16.0); + if bx + ACTION_W <= slot.pos.x + slot.size.x - visible + 1.0 { continue; } + let last = i + 1 == labels.len(); + rounded(chrome, cx, r, 16.0, if last { alpha(rgb(228, 66, 52), 0.9) } else { accent }); + d.label_elided(cx, r, true, 12.5, rgb(255, 255, 255), HAlign::Center, label); + hits.push((r, PhoneHit::Shade(ShadeHit::Action(n.id, i)))); + } + } + let fade = (1.0 - (n.offset.max(0.0) / (slot.size.x * 0.8))).clamp(0.0, 1.0) as f32; + let r = rect(slot.pos.x + n.offset, slot.pos.y, slot.size.x, h); + rounded(chrome, cx, r, CARD_RADIUS, alpha(card, fade)); + let ink_f = alpha(ink, fade); + let icon = rect(r.pos.x + 16.0, r.pos.y + 16.0, 40.0, 40.0); + if n.app == "wm" || crate::clients::find_app(&n.app).is_none() { + rounded(chrome, cx, icon, 12.0, alpha(accent, 0.9 * fade)); + d.icon_centered(cx, Ico::Bell, icon, 20.0, alpha(rgb(255, 255, 255), fade)); + } else { + icons.draw(cx, &n.app, style, icon, fade, ink_f); + } + let tx = r.pos.x + 68.0; + d.label_elided(cx, rect(tx, r.pos.y + 14.0, text_w - 40.0, 16.0), false, 11.0, alpha(ink, 0.6 * fade), HAlign::Left, &app_label(&n.app)); + let when = age(shade.now, n.time); + d.label(cx, rect(tx + text_w - 40.0, r.pos.y + 14.0, 40.0, 16.0), false, 11.0, alpha(ink, 0.5 * fade), HAlign::Right, &when); + d.label_elided(cx, rect(tx, r.pos.y + 32.0, text_w, 22.0), true, 14.5, ink_f, HAlign::Left, &n.title); + for (i, line) in lines.iter().enumerate() { + d.label(cx, rect(tx, r.pos.y + 56.0 + i as f64 * 18.0, text_w, 18.0), false, 13.0, alpha(ink, 0.8 * fade), HAlign::Left, line); + } + let _ = dark; + // The card's own hit sits over the strip only where the card is. + hits.push((r, PhoneHit::Shade(ShadeHit::Note(n.id)))); + y += h + 10.0; + } +} + +#[allow(clippy::too_many_arguments)] +fn draw_controls(cx: &mut Cx2d, d: &mut ShellDraw, chrome: &mut DrawDesktopChrome, hits: &mut Vec<(Rect, PhoneHit)>, shade: &ShadeState, dark: bool, ink: Vec4f, accent: Vec4f, card: Vec4f, page: Rect) { + let x = page.pos.x; + let w = page.size.x; + d.label(cx, rect(x + 24.0, page.pos.y + 58.0, w - 48.0, 30.0), true, 17.0, ink, HAlign::Left, "Controls"); + for (hit, value, ico) in [(ShadeHit::Brightness, shade.brightness, Ico::Brightness), (ShadeHit::Volume, shade.volume, if shade.volume < 0.01 { Ico::Volume0 } else if shade.volume < 0.5 { Ico::Volume1 } else { Ico::Volume3 })] { + let track = ShadeState::slider_track(page, hit.clone()); + rounded(chrome, cx, track, 23.0, card); + let fill = rect(track.pos.x, track.pos.y, (track.size.x * value).max(46.0), track.size.y); + rounded(chrome, cx, fill, 23.0, alpha(accent, 0.92)); + d.icon_centered(cx, ico, rect(track.pos.x + 4.0, track.pos.y, 40.0, track.size.y), 20.0, rgb(255, 255, 255)); + d.label(cx, rect(track.pos.x, track.pos.y, track.size.x - 18.0, track.size.y), false, 12.5, alpha(ink, 0.7), HAlign::Right, &format!("{}%", (value * 100.0).round() as u32)); + hits.push((rect(track.pos.x - 8.0, track.pos.y - 6.0, track.size.x + 16.0, track.size.y + 12.0), PhoneHit::Shade(hit))); + } + // Android 16 style toggles: a pill at rest, a rounded rectangle on. + for (i, t) in Toggle::ALL.iter().enumerate() { + let cell = ShadeState::toggle_cell(page, i); + let on = shade.toggle_anim[t.index()] as f32; + let radius = (cell.size.y * 0.5) as f32 * (1.0 - on) + 16.0 * on; + let face = if on > 0.5 { alpha(accent, 0.5 + on * 0.5) } else if dark { alpha(rgb(255, 255, 255), 0.10 + on * 0.5) } else { alpha(card, 1.0) }; + rounded(chrome, cx, cell, radius, face); + let fg = if on > 0.5 { rgb(255, 255, 255) } else { ink }; + let ico = match t { + Toggle::Wifi => if shade.wifi { Ico::Wifi } else { Ico::WifiOff }, + Toggle::Bluetooth => if shade.bluetooth { Ico::Bluetooth } else { Ico::BluetoothOff }, + Toggle::Torch => Ico::Power, + Toggle::RotationLock => Ico::Lock, + Toggle::DoNotDisturb => Ico::Moon, + }; + d.icon_centered(cx, ico, rect(cell.pos.x + 10.0, cell.pos.y, 30.0, cell.size.y), 18.0, fg); + let text = rect(cell.pos.x + 42.0, cell.pos.y, cell.size.x - 50.0, cell.size.y); + let (tw, size) = if cell.size.x < 110.0 { (text.size.x, 11.0) } else { (text.size.x, 12.5) }; + d.label_elided(cx, rect(text.pos.x, text.pos.y, tw, text.size.y), false, size, fg, HAlign::Left, t.label()); + hits.push((cell, PhoneHit::Shade(ShadeHit::Toggle(*t)))); + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn screen() -> Rect { rect(0.0, 0.0, 412.0, 892.0) } + fn settle(s: &mut ShadeState, ex: &mut ExclusionZones) { for _ in 0..120 { s.step(1.0 / 60.0, None, screen(), 100.0, ex); } } + + #[test] + fn pull_progress_drives_open_and_commit_finishes_it() { + let mut s = ShadeState::default(); + let mut ex = ExclusionZones::default(); + s.step(1.0 / 60.0, Some(ShellGesture::ShadePull { side: ShadeSide::Controls, progress: 0.4 }), screen(), 1.0, &mut ex); + assert!((s.open - 0.4).abs() < 1e-9 && s.side == ShadeSide::Controls); + assert_eq!(s.page, 1.0, "the controls half is the page the pull opens onto"); + assert!(!ex.zones.is_empty(), "a showing sheet owns the edges under it"); + s.step(1.0 / 60.0, Some(ShellGesture::Commit(GestureKind::Shade(ShadeSide::Controls))), screen(), 1.0, &mut ex); + settle(&mut s, &mut ex); + assert_eq!(s.open, 1.0); + assert_eq!(ex.zones.len(), 1, "one zone, refreshed not accumulated"); + assert_eq!(ex.zones[0].rect, screen()); + } + + #[test] + fn cancel_animates_back_closed_and_drops_the_exclusion() { + let mut s = ShadeState::default(); + let mut ex = ExclusionZones::default(); + s.step(1.0 / 60.0, Some(ShellGesture::ShadePull { side: ShadeSide::Notifications, progress: 0.3 }), screen(), 1.0, &mut ex); + s.step(1.0 / 60.0, Some(ShellGesture::Cancel(GestureKind::Shade(ShadeSide::Notifications))), screen(), 1.0, &mut ex); + settle(&mut s, &mut ex); + assert_eq!(s.open, 0.0); + assert!(ex.zones.is_empty()); + assert!(!s.step(1.0 / 60.0, None, screen(), 1.0, &mut ex), "settled: nothing left to animate"); + } + + #[test] + fn sideways_drag_and_page_gesture_switch_halves() { + let mut s = ShadeState::default(); + let mut ex = ExclusionZones::default(); + s.open_on(ShadeSide::Notifications); + settle(&mut s, &mut ex); + assert_eq!(s.page, 0.0); + s.drag(&ShadeHit::Sheet, dvec2(100.0, 300.0), dvec2(-260.0, 4.0), screen()); + assert!(s.page > 0.5 && s.page < 1.0); + s.release(&ShadeHit::Sheet, dvec2(-260.0, 4.0), 0.6); + settle(&mut s, &mut ex); + assert_eq!((s.page, s.side), (1.0, ShadeSide::Controls)); + // A short fast flick right goes back even before halfway. + s.drag(&ShadeHit::Sheet, dvec2(200.0, 300.0), dvec2(60.0, 0.0), screen()); + s.release(&ShadeHit::Sheet, dvec2(60.0, 0.0), 0.1); + settle(&mut s, &mut ex); + assert_eq!((s.page, s.side), (0.0, ShadeSide::Notifications)); + // The recognizer's page swipe does the same once it exists. + s.step(1.0 / 60.0, Some(ShellGesture::PageSwipe { dir: Dir::Left, progress: 0.5 }), screen(), 1.0, &mut ex); + assert!((s.page - 0.5).abs() < 1e-9); + s.step(1.0 / 60.0, Some(ShellGesture::Commit(GestureKind::Page(Dir::Left))), screen(), 1.0, &mut ex); + settle(&mut s, &mut ex); + assert_eq!(s.page, 1.0); + } + + #[test] + fn swipe_up_on_the_sheet_and_backdrop_tap_close() { + let mut s = ShadeState::default(); + let mut ex = ExclusionZones::default(); + s.open_on(ShadeSide::Controls); + settle(&mut s, &mut ex); + s.drag(&ShadeHit::Sheet, dvec2(200.0, 400.0), dvec2(2.0, -120.0), screen()); + assert!(s.open < 1.0, "the sheet follows the finger up"); + s.release(&ShadeHit::Sheet, dvec2(2.0, -120.0), 0.5); + settle(&mut s, &mut ex); + assert_eq!(s.open, 0.0); + s.open_on(ShadeSide::Controls); + settle(&mut s, &mut ex); + s.tap(ShadeHit::Backdrop); + settle(&mut s, &mut ex); + assert_eq!(s.open, 0.0); + } + + #[test] + fn dismiss_reveal_actions_and_clear_all() { + let mut s = ShadeState::default(); + let mut ex = ExclusionZones::default(); + let a = s.post("wm", "A", "first", 1.0, vec![]); + let b = s.post("messages", "B", "second", 2.0, vec!["Reply".into()]); + assert_eq!(s.notifications.len(), 2); + s.drag(&ShadeHit::Note(a), dvec2(0.0, 0.0), dvec2(150.0, 0.0), screen()); + s.release(&ShadeHit::Note(a), dvec2(150.0, 0.0), 0.5); + assert!(s.notifications.iter().all(|n| n.id != a), "a right swipe dismisses"); + s.drag(&ShadeHit::Note(b), dvec2(0.0, 0.0), dvec2(-120.0, 0.0), screen()); + s.release(&ShadeHit::Note(b), dvec2(-120.0, 0.0), 0.5); + settle(&mut s, &mut ex); + let note = s.notifications.iter().find(|n| n.id == b).unwrap(); + assert!(note.revealed && (note.offset + 2.0 * ACTION_W).abs() < 1e-6, "a left swipe rests on the action strip"); + s.tap(ShadeHit::Action(b, 0)); + assert!(s.notifications.is_empty()); + s.post("wm", "C", "", 3.0, vec![]); + s.post("wm", "D", "", 3.0, vec![]); + s.tap(ShadeHit::ClearAll); + assert!(s.notifications.is_empty()); + } + + #[test] + fn sliders_follow_the_finger_and_toggles_flip() { + let mut s = ShadeState::default(); + s.open_on(ShadeSide::Controls); + s.open = 1.0; + let track = ShadeState::slider_track(s.content_rect(screen()), ShadeHit::Brightness); + s.drag(&ShadeHit::Brightness, dvec2(track.pos.x + track.size.x * 0.25, track.pos.y), dvec2(0.0, 0.0), screen()); + assert!((s.brightness - 0.25).abs() < 1e-9); + assert!(!s.torch); + s.tap(ShadeHit::Toggle(Toggle::Torch)); + assert!(s.torch); + assert_eq!(age(100.0, 100.0), "now"); + assert_eq!(age(400.0, 100.0), "5m"); + assert_eq!(age(8000.0, 100.0), "2h"); + } +} diff --git a/src/mobile_surface.rs b/src/mobile_surface.rs index 6875e920..75a0d6e5 100644 --- a/src/mobile_surface.rs +++ b/src/mobile_surface.rs @@ -389,7 +389,7 @@ impl PhoneSurface { } if phone.order.is_empty() {self.label(cx,screen,"No recent apps",20.0,false,ink);} } - if phone.keyboard>0.5 {self.draw_keyboard(cx,state,screen,backdrop);} + if phone.keyboard>0.5 {self.draw_keyboard(cx,state,screen,backdrop.clone());} let bottom=rect(screen.pos.x,screen.pos.y+screen.size.y-24.0,screen.size.x,24.0); if phone.screen==PhoneScreen::App || phone.keyboard>0.5 { self.rounded(cx,bottom,0.0,if state.style.dark {rgb(28,28,31)}else{rgb(244,244,248)}); @@ -403,6 +403,7 @@ impl PhoneSurface { let back=rect(bottom.pos.x+12.0,bottom.pos.y-10.0,40.0,34.0); self.d.icon_centered(cx,Ico::ChevronLeft,back,16.0,nav_ink);self.hits.push((back,PhoneHit::Back)); } + crate::mobile_shade::draw(cx,&mut self.d,&mut self.chrome,&mut self.icons,&mut self.overview_glass,&mut self.hits,state,screen,backdrop); } fn draw_keyboard(&mut self, cx: &mut Cx2d, state: &WmState, screen: Rect, backdrop: Option) { let phone=&state.phone; From 52140fbb331808cf5d4f66d93457d9a58116ab9e Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:06:10 -0700 Subject: [PATCH 07/13] mobile: the shell gesture recognizer with exclusion zones GestureRecognizer (mobile_gestures.rs) turns the one finger the shell owns into ShellGesture values: bottom band up = HomeUp (a pause near commit while still down = held, the switcher), a fast flick along the bottom = QuickSwitch, side bands = Back, top band = ShadePull (left half notifications, right half controls), and on the home page a horizontal drag = PageSwipe, a downward drag = HomeSearch. Velocity over the last samples decides flicks and whether a lift commits; the hold is measured on the frame clock because Android stamps touches with the event uptime. Edge gestures check ExclusionZones first and leave excluded touches to the app. The phone touch path (mobile_app.rs) feeds it, publishes the result in PhoneState::gesture_out every frame (Commit/Cancel stay for one drawn frame, None when idle) and maps commits to the navigation that exists: HomeUp -> Home (the drawer from the Android home page), held -> Recents, QuickSwitch -> the previous/next client in `order`, Back -> PhoneHit::Back so the app sees the press first. Shade, page and search commits only reach gesture_out for their own surfaces. Exclusion zones are rebuilt every frame by the desk: a client with the new ClientSlot::owns_edges flag excludes the left/right edges of its viewport while it is the open app, and the shell keyboard (plus the navigation bar under it) excludes the bottom band. Co-Authored-By: Claude Fable 5.1 --- src/clients.rs | 6 + src/desk/phone.rs | 19 ++- src/main.rs | 7 + src/mobile.rs | 10 +- src/mobile_app.rs | 156 ++++++++++++----- src/mobile_gestures.rs | 378 +++++++++++++++++++++++++++++++++++++++++ src/mobile_surface.rs | 8 +- 7 files changed, 540 insertions(+), 44 deletions(-) diff --git a/src/clients.rs b/src/clients.rs index 7e04b1e0..2bf977ca 100644 --- a/src/clients.rs +++ b/src/clients.rs @@ -505,6 +505,10 @@ pub struct ClientSlot { pub closing: Option, /// The aichat child seated in the AI pane: not in the layout, no tile. pub pane: bool, + /// On the phone, this client owns the left and right edges of its + /// viewport (a map that pans from the edge): the shell's back gesture + /// is not recognised over it. Off by default; an app opts in. + pub owns_edges: bool, } impl ClientSlot { @@ -528,6 +532,7 @@ impl ClientSlot { open_at: Some(host::now()), opened_warm: false, takes_focus: true, + owns_edges: false, via_cargo: false, status: String::new(), diagnostic: String::new(), @@ -968,6 +973,7 @@ pub fn spawn_client( // A warm instance is not a window yet: nothing may focus it until // adoption hands it a tile. takes_focus: !warm, + owns_edges: false, via_cargo, status: String::new(), diagnostic: String::new(), diff --git a/src/desk/phone.rs b/src/desk/phone.rs index 92e36f84..7dff0c23 100644 --- a/src/desk/phone.rs +++ b/src/desk/phone.rs @@ -186,6 +186,9 @@ impl WmDesk { pub(super) fn draw_phone_scene(&mut self,cx:&mut Cx2d,scope:&mut Scope,screen:Rect) { let state=scope.data.get_mut::().unwrap(); state.phone.viewport=screen; + // The frame's exclusion zones are rebuilt below from what is drawn. + state.phone.exclusions.clear(); + let owns_edges:Vec=state.clients.iter().filter(|(_,s)|s.owns_edges).map(|(c,_)|*c).collect(); state.phone.order.retain(|c|state.clients.contains_key(c)); if state.phone.client.is_some_and(|c|!state.clients.contains_key(&c)) { state.phone.client=state.phone.order.first().copied(); @@ -221,6 +224,7 @@ impl WmDesk { self.phone_ui.overview_glass.draw_surface_with_backdrop(cx,screen,Some(backdrop),phone.overview as f32); self.compositor.as_mut().unwrap().content(screen); } + let mut excluded:Vec=Vec::new(); let mut order=phone.order.clone(); order.reverse(); // Foreground paints last during launch/return transitions. @@ -272,13 +276,26 @@ impl WmDesk { } } self.phone_frames.insert(client,stored); - if foreground {self.zorder.push(client);} + if foreground { + self.zorder.push(client); + // An app that owns its edges keeps them while it is the one + // full-screen window the finger can reach. + if phone.screen==PhoneScreen::App && owns_edges.contains(&client) {excluded.push(display);} + } } let glass=if phone.keyboard>0.5 { Some((Rect {pos:screen.pos+dvec2(0.0,screen.size.y-phone.keyboard-24.0),size:dvec2(screen.size.x,phone.keyboard)},4.0)) }else{None}; let (backdrop,_,_)=self.compositor.as_mut().unwrap().finish(cx,screen,glass); let state=scope.data.get_mut::().unwrap(); + for r in excluded {state.phone.exclusions.add(r,[false,false,true,true]);} + if phone.keyboard>0.5 { + // The keyboard and the navigation bar under it: a key at the + // bottom row is a key, never the start of a home swipe. + let mut kb=PhoneSurface::keyboard_rect(&phone,screen); + kb.size.y=screen.pos.y+screen.size.y-kb.pos.y; + state.phone.exclusions.add(kb,[false,true,false,false]); + } self.phone_ui.draw_overlay(cx,state,screen,backdrop); } pub(super) fn handle_phone_event(&mut self,cx:&mut Cx,event:&Event,scope:&mut Scope) { diff --git a/src/main.rs b/src/main.rs index e15b7d4b..e078627e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -54,6 +54,7 @@ use layout::{Axis, Dir, DividerHit, FullscreenMode, LRect}; use makepad_studio_protocol::{AppToStudio, StudioToApp}; use makepad_wm_api::{WmEvent, WmRequest}; use preview::PreviewCache; +use mobile_gestures::GestureRecognizer; use run_view::{MpRunView, MpRunViewAction}; use makepad_widgets::makepad_micro_serde::*; use shell::bar::{BarData, BarModule, SampledStatus, ShellBarAction}; @@ -401,6 +402,12 @@ pub struct App { #[rust] stylesheet: Option, #[rust] phone_frame: NextFrame, #[rust] phone_time: f64, + /// The shell gesture recognizer (mobile_gestures.rs): the one owner of + /// the finger the phone shell claims. + #[rust] phone_gestures: GestureRecognizer, + /// Frames a Commit/Cancel has been visible in `phone.gesture_out`: the + /// surfaces get one drawn frame to see it before it clears. + #[rust] gesture_out_age: u32, } /// A warm instance's own swapchain: the host end of the frames a DORMANT diff --git a/src/mobile.rs b/src/mobile.rs index bba09f53..83999928 100644 --- a/src/mobile.rs +++ b/src/mobile.rs @@ -20,8 +20,9 @@ pub struct PhoneGesture { pub last: Vec2d, pub time: f64, pub hit: Option, - pub bottom: bool, - pub edge: bool, + /// The gesture recognizer (mobile_gestures.rs) claimed this finger: it + /// started in a shell band, or in the home page body. + pub shell: bool, pub screen: PhoneScreen, } @@ -117,7 +118,10 @@ impl PhoneState { let open = if matches!(self.screen, PhoneScreen::App | PhoneScreen::Recents) && self.client.is_some() { 1.0 } else { 0.0 }; let overview = if self.screen == PhoneScreen::Recents { 1.0 } else { 0.0 }; let mut active = false; - let dragging = self.gesture.as_ref().is_some_and(|g| g.bottom); + // A finger driving the home swipe or the back preview holds the + // window where it is; a lifted finger lets it settle. + let dragging = self.gesture.is_some() + && matches!(self.gesture_out, Some(crate::mobile_gestures::ShellGesture::HomeUp { .. } | crate::mobile_gestures::ShellGesture::Back { .. })); for (value, target) in [(&mut self.openness, open), (&mut self.overview, overview)] { if !dragging { *value += (target - *value) * t; diff --git a/src/mobile_app.rs b/src/mobile_app.rs index 8715ff96..a734fdac 100644 --- a/src/mobile_app.rs +++ b/src/mobile_app.rs @@ -1,5 +1,6 @@ //! Phone navigation/input, sharing the WM's real clients and launch paths. use crate::{mobile::*, mobile_surface::PhoneSurface, mobile_tiles::{self, Face, TILE_APPS}, *}; +use crate::mobile_gestures::{Dir, FingerPhase, GestureContext, GestureKind, SafeInsets, ShellGesture}; use makepad_widgets::makepad_platform::ime::{HostedKeyboard, InputMode}; use makepad_widgets::widget_async::{enter_isolate, leave_isolate}; @@ -277,11 +278,28 @@ impl App { if self.state.as_ref().is_some_and(|s|s.style.target.mobile()) { let dt=if self.phone_time==0.0 {1.0/60.0}else{(frame.time-self.phone_time).clamp(0.001,0.05)}; self.phone_time=frame.time; - let phone = &mut self.state_mut().phone; + let Some(state) = self.state.as_mut() else { return }; + let phone = &mut state.phone; phone.wallpaper_time = frame.time; let moving = phone.step(dt); + // A finger resting near the top of a home swipe becomes the + // switcher without moving; the frame keeps running while + // the recognizer owns a finger so the hold can land. + let mut tracking = false; + if self.phone_gestures.active() { + tracking = true; + if let Some(out) = self.phone_gestures.tick(frame.time) { + log!("wm: gesture {:?}", out); + let from = phone.gesture.as_ref().map(|g| g.screen).unwrap_or(phone.screen); + phone.gesture_out = Some(out); + Self::drive_gesture(phone, out, from); + } + } else if matches!(phone.gesture_out, Some(ShellGesture::Commit(_) | ShellGesture::Cancel(_))) { + // Commit/Cancel stay for exactly one drawn frame. + if self.gesture_out_age >= 1 { phone.gesture_out = None; } else { self.gesture_out_age += 1; tracking = true; } + } let wallpaper_visible = phone.screen != PhoneScreen::App || phone.openness < 0.999 || phone.overview > 0.001; - if moving || wallpaper_visible {self.phone_frame=cx.new_next_frame();} + if moving || wallpaper_visible || tracking {self.phone_frame=cx.new_next_frame();} // The tiles follow the phone state every frame: a window // takes its compact face only once its dismissal settled. self.sync_home_tiles(cx); @@ -357,6 +375,8 @@ impl App { let window=self.ui.window(cx,ids!(main_window));let size=window.get_inner_size(cx); self.state_mut().phone.gesture=None; self.state_mut().phone.touch=None; + self.phone_gestures.cancel(); + self.state_mut().phone.gesture_out=None; window.resize(cx,dvec2(size.y,size.x)); } PhoneHit::Style=>self.open_style_menu(cx), @@ -486,6 +506,58 @@ impl App { }; self.phone_pointer_at(cx, phase, p, time, primary, scroll) } + /// What the recognizer needs to know about the screen right now. + fn gesture_context(&mut self, cx: &Cx) -> GestureContext { + let phone = &self.state_mut().phone; + let i = cx.display_context.safe_area_insets; + GestureContext { + screen: phone.viewport, + insets: SafeInsets { top: i.top, right: i.right, bottom: i.bottom, left: i.left }, + phone: phone.screen, + } + } + /// The recognizer's in-progress gesture moves what the shell draws + /// itself: the window pulling back on a home swipe, the predictive + /// back preview. Every other surface reads `gesture_out` on its own. + fn drive_gesture(phone: &mut PhoneState, out: ShellGesture, from: PhoneScreen) { + match out { + ShellGesture::HomeUp { progress, held } if from != PhoneScreen::Home => { + phone.openness = 1.0; + phone.overview = if held { 1.0 } else { progress * 0.6 }; + } + ShellGesture::Back { progress, .. } if from == PhoneScreen::App => { + phone.openness = (1.0 - progress * 0.18).clamp(0.4, 1.0); + } + _ => {} + } + } + /// A committed gesture becomes the navigation the shell already has. + /// ShadePull, PageSwipe and HomeSearch commits only reach `gesture_out`: + /// the shade, the pages and search are their own surfaces' work. + fn commit_gesture(&mut self, cx: &mut Cx, kind: GestureKind, from: PhoneScreen) { + match kind { + GestureKind::HomeUp => { + let android = self.state_mut().style.target == desktop::DesktopStyle::Android; + let target = if from == PhoneScreen::Home && android { PhoneHit::Drawer } else { PhoneHit::Home }; + self.phone_action(cx, target); + } + GestureKind::Switcher => self.phone_action(cx, PhoneHit::Recents), + GestureKind::QuickSwitch(dir) => { + // `order` is most-recent first: right brings back the app + // used before this one, left cycles the other way round. + let order = &self.state_mut().phone.order; + let next = match dir { + Dir::Right => order.get(1).copied(), + Dir::Left => order.last().copied().filter(|_| order.len() > 1), + }; + if let Some(client) = next { self.phone_action(cx, PhoneHit::Card(client)); } + } + // The app sees the back press first (BackPressed / HostedBack) + // and the shell goes home only when it declines. + GestureKind::Back => self.phone_action(cx, PhoneHit::Back), + GestureKind::Shade(_) | GestureKind::Page(_) | GestureKind::HomeSearch => {} + } + } fn phone_pointer_at(&mut self, cx: &mut Cx, phase: PhonePointerPhase, p: Vec2d, time: f64, primary: bool, scroll: f64) -> bool { if self.state_mut().phone.gesture.is_none() { if let Some(hit) = self.phone_toolbar_hit(cx, p) { @@ -494,69 +566,77 @@ impl App { } } let (hit,search_scroll_max)=self.desk(cx).borrow::().map(|d|(d.phone_hit(p),d.phone_search_scroll_max())).unwrap_or_default(); - let phone=&self.state_mut().phone; + let ctx=self.gesture_context(cx); + let Some(state)=self.state.as_mut() else {return false}; + let phone=&mut state.phone; let screen=phone.viewport; - let bottom=p.y>screen.pos.y+screen.size.y-28.0; - let edge=p.x{ if !primary {return phone.screen!=PhoneScreen::App;} - if !screen.contains(p) {return false;} - if bottom || edge || hit.is_some() || phone.screen!=PhoneScreen::App { - let old=phone.screen; - self.state_mut().phone.gesture=Some(PhoneGesture{start:p,last:p,time,hit,bottom,edge,screen:old}); + let old=phone.screen; + // The recognizer claims a finger in a band (or on the home + // page body); an excluded edge is left to the app. + self.phone_gestures.feed(FingerPhase::Down,p,time,&ctx,&phone.exclusions); + let shell=self.phone_gestures.active(); + if !shell && !screen.contains(p) {return false;} + if shell || hit.is_some() || old!=PhoneScreen::App { + phone.gesture=Some(PhoneGesture{start:p,last:p,time,hit,shell,screen:old}); + phone.gesture_out=None; self.redraw_all(cx); return true; } false } PhonePointerPhase::Move=>{ - let phone=&mut self.state_mut().phone; let Some(g)=phone.gesture.as_mut() else{return phone.screen!=PhoneScreen::App;}; let delta=p-g.start;let last=p-g.last;g.last=p; - if g.bottom && delta.y < -8.0 { - phone.overview=(-delta.y/(screen.size.y*0.42)).clamp(0.0,1.0); - phone.openness=1.0; - }else if g.screen==PhoneScreen::Drawer && (phone.search_focused || !phone.search_query.is_empty()) { + let (shell,from)=(g.shell,g.screen); + let out=if shell {self.phone_gestures.feed(FingerPhase::Move,p,time,&ctx,&phone.exclusions)} else {None}; + phone.gesture_out=out; + if let Some(out)=out { + Self::drive_gesture(phone,out,from); + }else if from==PhoneScreen::Drawer && (phone.search_focused || !phone.search_query.is_empty()) { phone.search_scroll=(phone.search_scroll.min(search_scroll_max)-last.y).clamp(0.0,search_scroll_max); - }else if g.screen==PhoneScreen::Recents { + }else if from==PhoneScreen::Recents && !shell { if delta.y.abs()>delta.x.abs()*1.2 {phone.dismiss_y=delta.y.min(0.0);} else {let width=card_rect(screen,0.0,0.0).size.x+22.0;phone.page=(phone.page-last.x/width).clamp(-0.25,phone.order.len().saturating_sub(1)as f64+0.25);} - }else if g.edge {phone.openness=(1.0-delta.x.max(0.0)/screen.size.x*0.6).clamp(0.4,1.0);} + } self.animate_phone(cx);true } PhonePointerPhase::Up=>{ - let Some(g)=self.state_mut().phone.gesture.take() else{return self.state_mut().phone.screen!=PhoneScreen::App;}; + let Some(g)=phone.gesture.take() else{return phone.screen!=PhoneScreen::App;}; let delta=p-g.start; - if g.bottom { - if delta.x.abs()>70.0 && delta.x.abs()>delta.y.abs()*1.5 { - let phone=&self.state_mut().phone; - if let Some(client)=phone.order.get(1).copied() {self.phone_action(cx,PhoneHit::Card(client));} - }else if delta.y < -45.0 { - let fast=time-g.time<0.30 && delta.y < -100.0; - let target=if g.screen==PhoneScreen::Home && self.state_mut().style.target==desktop::DesktopStyle::Android {PhoneHit::Drawer} - else if fast {PhoneHit::Home}else{PhoneHit::Recents}; - self.phone_action(cx,target); - }else{self.phone_action(cx,PhoneHit::Home);} - }else if g.edge && delta.x>70.0 {self.phone_action(cx,PhoneHit::Back);} - else if g.screen==PhoneScreen::Recents && delta.y < -90.0 && delta.y.abs()>delta.x.abs()*1.2 { - if let Some(PhoneHit::Card(client))=g.hit {self.request_close(cx,client);self.state_mut().phone.navigate(PhoneScreen::Recents);} - }else if delta.length()<12.0 { - if let Some(hit)=g.hit.filter(|h|Some(h)==hit.as_ref()) {self.phone_action(cx,hit);} - }else if g.screen==PhoneScreen::Home && (delta.y < -55.0 || delta.x < -70.0) {self.phone_action(cx,PhoneHit::Drawer);} + let out=if g.shell {self.phone_gestures.feed(FingerPhase::Up,p,time,&ctx,&phone.exclusions)} else {None}; + phone.gesture_out=out; + self.gesture_out_age=0; + match out { + Some(ShellGesture::Commit(kind))=>{ + log!("wm: gesture commit {:?} from {:?}",kind,g.screen); + self.commit_gesture(cx,kind,g.screen); + } + Some(ShellGesture::Cancel(kind))=>{ + log!("wm: gesture cancel {:?}",kind); + } + _=>{ + if g.screen==PhoneScreen::Recents && !g.shell && delta.y < -90.0 && delta.y.abs()>delta.x.abs()*1.2 { + if let Some(PhoneHit::Card(client))=g.hit {self.request_close(cx,client);self.state_mut().phone.navigate(PhoneScreen::Recents);} + }else if delta.length()<12.0 { + if let Some(hit)=g.hit.filter(|h|Some(h)==hit.as_ref()) {self.phone_action(cx,hit);} + }else if g.screen==PhoneScreen::Home && delta.y < -55.0 && delta.y.abs()>delta.x.abs() {self.phone_action(cx,PhoneHit::Drawer);} + } + } self.state_mut().phone.dismiss_y=0.0; self.animate_phone(cx);true } - PhonePointerPhase::Scroll if self.state_mut().phone.screen==PhoneScreen::Recents=>{ - let p=&mut self.state_mut().phone;p.page=(p.page+scroll.signum()).clamp(0.0,p.order.len().saturating_sub(1)as f64); + PhonePointerPhase::Scroll if phone.screen==PhoneScreen::Recents=>{ + phone.page=(phone.page+scroll.signum()).clamp(0.0,phone.order.len().saturating_sub(1)as f64); self.animate_phone(cx);true } - PhonePointerPhase::Scroll if self.state_mut().phone.searching()=>{ - let phone=&mut self.state_mut().phone; + PhonePointerPhase::Scroll if phone.searching()=>{ phone.search_scroll=(phone.search_scroll.min(search_scroll_max)+scroll).clamp(0.0,search_scroll_max); self.animate_phone(cx);true } - _=>self.state_mut().phone.screen!=PhoneScreen::App, + _=>phone.screen!=PhoneScreen::App, } } } diff --git a/src/mobile_gestures.rs b/src/mobile_gestures.rs index 78572603..79826cdf 100644 --- a/src/mobile_gestures.rs +++ b/src/mobile_gestures.rs @@ -83,3 +83,381 @@ pub struct GestureMetrics { impl Default for GestureMetrics { fn default() -> Self { Self { edge_band: 24.0, bottom_band: 28.0, top_band: 32.0, commit_distance: 120.0, hold_time: 0.28, flick_velocity: 900.0 } } } + +// --------------------------------------------------------------------------- +// The recognizer +// --------------------------------------------------------------------------- + +use crate::mobile::PhoneScreen; +use std::collections::VecDeque; + +/// One finger event in screen points. The shell owns at most one finger at +/// a time (`PhoneState::touch`), so the recognizer tracks a single finger. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FingerPhase { Down, Move, Up } + +/// The platform's safe-area insets around the phone viewport (the Android +/// status bar and navigation bar). A finger inside one of the bars still +/// counts as the band next to it, so a swipe up from the very bottom of +/// the glass goes home like the system's own gesture would. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct SafeInsets { pub top: f64, pub right: f64, pub bottom: f64, pub left: f64 } + +/// What the recognizer knows about the screen when a finger arrives: the +/// phone viewport, the insets around it and which shell screen is showing +/// (the home page recognises page swipes and search; the others do not). +#[derive(Clone, Copy, Debug)] +pub struct GestureContext { pub screen: Rect, pub insets: SafeInsets, pub phone: PhoneScreen } + +/// Where the finger touched down: the band decides the family of gesture +/// it can become. `Body` is only ever an origin on the home page. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Origin { Bottom, Top(ShadeSide), Side(Edge), Body } + +#[derive(Clone, Debug)] +struct Track { + origin: Origin, + start: Vec2d, + last: Vec2d, + /// The newest positions with their times, for the lift velocity. + samples: VecDeque<(Vec2d, f64)>, + /// Locked once the finger has moved past the slop in a direction the + /// origin allows; never reclassified while the finger is down. + kind: Option, + held: bool, + /// The finger has not strayed more than `STILL` from here since + /// `still_since`: the pause that turns a home swipe into the switcher. + still_anchor: Vec2d, + still_since: f64, + /// The first `tick` after the finger last moved, on the frame clock. + /// Finger and frame clocks need not agree (Android stamps touches + /// with the event uptime), so the pause is measured on the clock that + /// reports it. + still_tick: Option, + live: Option, +} + +/// Movement before a touch is a drag, not a tap. +const SLOP: f64 = 10.0; +/// Jitter tolerated while "holding still". +const STILL: f64 = 4.0; +/// How far back the lift velocity looks. +const VELOCITY_WINDOW: f64 = 0.12; +const SAMPLES: usize = 8; +/// A home swipe this far along counts as "near commit" for the hold. +const HOLD_NEAR: f64 = 0.6; + +/// Turns the shell's finger into `ShellGesture`s. Feed it every Down/Move/Up +/// of the finger the shell owns; it answers with the gesture in progress +/// (each Move), then `Commit` or `Cancel` (the Up). `tick` lets a finger +/// that stopped moving become the switcher without a new event. +#[derive(Clone, Debug, Default)] +pub struct GestureRecognizer { + pub metrics: GestureMetrics, + track: Option, +} + +impl GestureRecognizer { + pub fn new(metrics: GestureMetrics) -> Self { Self { metrics, track: None } } + /// The recognizer claimed the finger that is down. + pub fn active(&self) -> bool { self.track.is_some() } + /// The gesture in progress, as last emitted. + pub fn current(&self) -> Option { self.track.as_ref().and_then(|t| t.live) } + /// The finger that is down started in a shell band (bottom, top or a + /// side) rather than in the home page body. + pub fn from_band(&self) -> bool { self.track.as_ref().is_some_and(|t| t.origin != Origin::Body) } + + /// Feed one finger event. `Down` decides whether the shell claims the + /// finger (`active()` afterwards); an excluded edge, a body touch off + /// the home page or a touch outside the screen are left to the app. + pub fn feed(&mut self, phase: FingerPhase, p: Vec2d, time: f64, ctx: &GestureContext, exclusions: &ExclusionZones) -> Option { + match phase { + FingerPhase::Down => { + self.track = None; + let origin = self.origin_at(p, ctx, exclusions)?; + let mut samples = VecDeque::with_capacity(SAMPLES); + samples.push_back((p, time)); + self.track = Some(Track { origin, start: p, last: p, samples, kind: None, held: false, still_anchor: p, still_since: time, still_tick: None, live: None }); + None + } + FingerPhase::Move => { + let m = self.metrics; + let t = self.track.as_mut()?; + Self::sample(t, p, time); + let delta = p - t.start; + if t.kind.is_none() && delta.length() > SLOP { t.kind = Self::classify(t.origin, delta); } + let kind = t.kind?; + let progress = Self::progress(kind, t.origin, delta, m.commit_distance); + Self::check_hold(t, kind, progress, time, m.hold_time); + let live = Self::live(kind, t.origin, progress, t.held); + t.live = Some(live); + Some(live) + } + FingerPhase::Up => { + let m = self.metrics; + let mut t = self.track.take()?; + Self::sample(&mut t, p, time); + let kind = t.kind?; + let delta = p - t.start; + let progress = Self::progress(kind, t.origin, delta, m.commit_distance); + let along = Self::along(kind, t.origin, Self::velocity(&t)); + if kind == GestureKind::HomeUp && t.held { return Some(ShellGesture::Commit(GestureKind::Switcher)); } + if progress >= 1.0 || along >= m.flick_velocity { Some(ShellGesture::Commit(kind)) } else { Some(ShellGesture::Cancel(kind)) } + } + } + } + + /// Time passed without a finger event: a home swipe that is resting + /// near or past its commit distance becomes the switcher intent. + pub fn tick(&mut self, time: f64) -> Option { + let m = self.metrics; + let t = self.track.as_mut()?; + let kind = t.kind?; + if t.held || kind != GestureKind::HomeUp { return None; } + let since = *t.still_tick.get_or_insert(time); + let delta = t.last - t.start; + let progress = Self::progress(kind, t.origin, delta, m.commit_distance); + if progress < HOLD_NEAR || time - since < m.hold_time { return None; } + t.held = true; + let live = Self::live(kind, t.origin, progress, t.held); + t.live = Some(live); + Some(live) + } + + /// Another finger took over, the screen rotated, or the shell changed + /// under the finger: drop the track, telling the surfaces to animate back. + pub fn cancel(&mut self) -> Option { + let t = self.track.take()?; + t.kind.map(ShellGesture::Cancel) + } + + fn origin_at(&self, p: Vec2d, ctx: &GestureContext, exclusions: &ExclusionZones) -> Option { + let m = self.metrics; + let s = ctx.screen; + let (left, top) = (s.pos.x, s.pos.y); + let (right, bottom) = (s.pos.x + s.size.x, s.pos.y + s.size.y); + let i = ctx.insets; + if p.x < left - i.left || p.x > right + i.right || p.y < top - i.top || p.y > bottom + i.bottom { return None; } + let clear = |edge: Edge| !exclusions.excludes(p, edge); + if p.y >= bottom - m.bottom_band { return clear(Edge::Bottom).then_some(Origin::Bottom); } + if p.y <= top + m.top_band { + let side = if p.x < left + s.size.x * 0.5 { ShadeSide::Notifications } else { ShadeSide::Controls }; + return clear(Edge::Top).then_some(Origin::Top(side)); + } + if p.x <= left + m.edge_band { return clear(Edge::Left).then_some(Origin::Side(Edge::Left)); } + if p.x >= right - m.edge_band { return clear(Edge::Right).then_some(Origin::Side(Edge::Right)); } + (ctx.phone == PhoneScreen::Home).then_some(Origin::Body) + } + + fn classify(origin: Origin, d: Vec2d) -> Option { + let (ax, ay) = (d.x.abs(), d.y.abs()); + let dir = if d.x < 0.0 { Dir::Left } else { Dir::Right }; + match origin { + Origin::Bottom => { + if ax > ay * 1.5 { Some(GestureKind::QuickSwitch(dir)) } + else if d.y < 0.0 && ay >= ax { Some(GestureKind::HomeUp) } + else { None } + } + Origin::Top(side) => (d.y > 0.0 && ay >= ax).then_some(GestureKind::Shade(side)), + Origin::Side(edge) => { + let inward = if edge == Edge::Left { d.x } else { -d.x }; + (inward > 0.0 && inward >= ay * 0.8).then_some(GestureKind::Back) + } + Origin::Body => { + if ax > ay * 1.2 { Some(GestureKind::Page(dir)) } + else if d.y > 0.0 && ay > ax * 1.2 { Some(GestureKind::HomeSearch) } + else { None } + } + } + } + + /// The component of `v` (a displacement or a velocity) that advances + /// the gesture, in points. + fn along(kind: GestureKind, origin: Origin, v: Vec2d) -> f64 { + match kind { + GestureKind::HomeUp | GestureKind::Switcher => -v.y, + GestureKind::QuickSwitch(dir) | GestureKind::Page(dir) => if dir == Dir::Left { -v.x } else { v.x }, + GestureKind::Back => if origin == Origin::Side(Edge::Right) { -v.x } else { v.x }, + GestureKind::Shade(_) | GestureKind::HomeSearch => v.y, + } + } + fn progress(kind: GestureKind, origin: Origin, delta: Vec2d, commit: f64) -> f64 { + (Self::along(kind, origin, delta) / commit).clamp(0.0, 1.0) + } + fn live(kind: GestureKind, origin: Origin, progress: f64, held: bool) -> ShellGesture { + match kind { + GestureKind::HomeUp | GestureKind::Switcher => ShellGesture::HomeUp { progress, held }, + GestureKind::QuickSwitch(dir) => ShellGesture::QuickSwitch { dir, progress }, + GestureKind::Back => ShellGesture::Back { edge: if let Origin::Side(e) = origin { e } else { Edge::Left }, progress }, + GestureKind::Shade(side) => ShellGesture::ShadePull { side, progress }, + GestureKind::Page(dir) => ShellGesture::PageSwipe { dir, progress }, + GestureKind::HomeSearch => ShellGesture::HomeSearch { progress }, + } + } + fn sample(t: &mut Track, p: Vec2d, time: f64) { + t.last = p; + if (p - t.still_anchor).length() > STILL { t.still_anchor = p; t.still_since = time; t.still_tick = None; } + if t.samples.len() == SAMPLES { t.samples.pop_front(); } + t.samples.push_back((p, time)); + } + /// Points per second over the newest `VELOCITY_WINDOW` of samples. + fn velocity(t: &Track) -> Vec2d { + let Some(&(newest, now)) = t.samples.back() else { return dvec2(0.0, 0.0) }; + let Some(&(oldest, then)) = t.samples.iter().find(|(_, time)| now - *time <= VELOCITY_WINDOW) else { return dvec2(0.0, 0.0) }; + let dt = now - then; + if dt < 1e-4 { return dvec2(0.0, 0.0); } + (newest - oldest) / dt + } + /// True when this call turned the home swipe into the held switcher. + fn check_hold(t: &mut Track, kind: GestureKind, progress: f64, time: f64, hold_time: f64) -> bool { + if kind != GestureKind::HomeUp || t.held || progress < HOLD_NEAR || time - t.still_since < hold_time { return false; } + t.held = true; + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + use FingerPhase::*; + + fn screen() -> Rect { Rect { pos: dvec2(0.0, 0.0), size: dvec2(412.0, 892.0) } } + fn ctx(phone: PhoneScreen) -> GestureContext { GestureContext { screen: screen(), insets: SafeInsets::default(), phone } } + /// Feed a finger path; each step is (phase, x, y, time). + fn drive(rec: &mut GestureRecognizer, ctx: &GestureContext, ex: &ExclusionZones, steps: &[(FingerPhase, f64, f64, f64)]) -> Vec> { + steps.iter().map(|&(phase, x, y, t)| rec.feed(phase, dvec2(x, y), t, ctx, ex)).collect() + } + /// A straight drag from `a` to `b` over `secs`, in `n` moves, lifting at the end. + fn swipe(a: (f64, f64), b: (f64, f64), secs: f64, n: usize) -> Vec<(FingerPhase, f64, f64, f64)> { + let mut steps = vec![(Down, a.0, a.1, 1.0)]; + for i in 1..=n { + let k = i as f64 / n as f64; + steps.push((Move, a.0 + (b.0 - a.0) * k, a.1 + (b.1 - a.1) * k, 1.0 + secs * k)); + } + steps.push((Up, b.0, b.1, 1.0 + secs)); + steps + } + fn last(out: &[Option]) -> ShellGesture { out.last().copied().flatten().expect("a terminal gesture") } + + #[test] + fn home_swipe_commits() { + let mut rec = GestureRecognizer::default(); + let out = drive(&mut rec, &ctx(PhoneScreen::App), &ExclusionZones::default(), &swipe((200.0, 880.0), (200.0, 700.0), 0.2, 6)); + assert!(matches!(out[3], Some(ShellGesture::HomeUp { progress, held: false }) if progress > 0.5 && progress < 1.0), "{:?}", out[3]); + assert_eq!(last(&out), ShellGesture::Commit(GestureKind::HomeUp)); + assert!(!rec.active()); + } + #[test] + fn short_swipe_cancels() { + let mut rec = GestureRecognizer::default(); + let out = drive(&mut rec, &ctx(PhoneScreen::App), &ExclusionZones::default(), &swipe((200.0, 880.0), (200.0, 840.0), 0.6, 6)); + assert_eq!(last(&out), ShellGesture::Cancel(GestureKind::HomeUp)); + } + #[test] + fn hold_produces_held_and_commits_the_switcher() { + let mut rec = GestureRecognizer::default(); + let ctx = ctx(PhoneScreen::App); + let ex = ExclusionZones::default(); + drive(&mut rec, &ctx, &ex, &[(Down, 200.0, 880.0, 1.0), (Move, 200.0, 840.0, 1.05), (Move, 200.0, 780.0, 1.1)]); + assert!(matches!(rec.current(), Some(ShellGesture::HomeUp { held: false, .. }))); + // The frame clock is another epoch than the finger's (Android's + // touches carry the event uptime): the pause counts from the first + // tick after the last move. + assert_eq!(rec.tick(900.0), None, "not held yet"); + assert_eq!(rec.tick(900.2), None, "not held yet"); + let held = rec.tick(900.3).expect("the pause turns the swipe into the switcher"); + assert!(matches!(held, ShellGesture::HomeUp { held: true, .. }), "{held:?}"); + assert_eq!(rec.tick(900.4), None, "reported once"); + let up = rec.feed(Up, dvec2(200.0, 780.0), 1.7, &ctx, &ex); + assert_eq!(up, Some(ShellGesture::Commit(GestureKind::Switcher))); + } + #[test] + fn a_fast_flick_along_the_bottom_is_quick_switch() { + let mut rec = GestureRecognizer::default(); + let out = drive(&mut rec, &ctx(PhoneScreen::App), &ExclusionZones::default(), &swipe((100.0, 882.0), (200.0, 884.0), 0.06, 4)); + assert!(matches!(out[2], Some(ShellGesture::QuickSwitch { dir: Dir::Right, .. })), "{:?}", out[2]); + assert_eq!(last(&out), ShellGesture::Commit(GestureKind::QuickSwitch(Dir::Right)), "100pt in 60ms is a flick even short of the commit distance"); + let mut rec = GestureRecognizer::default(); + let out = drive(&mut rec, &ctx(PhoneScreen::App), &ExclusionZones::default(), &swipe((300.0, 882.0), (240.0, 884.0), 1.0, 4)); + assert_eq!(last(&out), ShellGesture::Cancel(GestureKind::QuickSwitch(Dir::Left)), "a slow short slide cancels"); + } + #[test] + fn a_left_edge_drag_is_back() { + let mut rec = GestureRecognizer::default(); + let out = drive(&mut rec, &ctx(PhoneScreen::App), &ExclusionZones::default(), &swipe((6.0, 400.0), (160.0, 404.0), 0.3, 5)); + assert!(matches!(out[2], Some(ShellGesture::Back { edge: Edge::Left, progress }) if progress > 0.0), "{:?}", out[2]); + assert_eq!(last(&out), ShellGesture::Commit(GestureKind::Back)); + let mut rec = GestureRecognizer::default(); + let out = drive(&mut rec, &ctx(PhoneScreen::App), &ExclusionZones::default(), &swipe((408.0, 400.0), (300.0, 404.0), 0.3, 5)); + assert!(matches!(out[2], Some(ShellGesture::Back { edge: Edge::Right, .. })), "{:?}", out[2]); + } + #[test] + fn top_left_pulls_notifications_and_top_right_controls() { + for (x, side) in [(100.0, ShadeSide::Notifications), (350.0, ShadeSide::Controls)] { + let mut rec = GestureRecognizer::default(); + let out = drive(&mut rec, &ctx(PhoneScreen::Home), &ExclusionZones::default(), &swipe((x, 8.0), (x, 300.0), 0.3, 5)); + assert!(matches!(out[2], Some(ShellGesture::ShadePull { side: s, .. }) if s == side), "{:?}", out[2]); + assert_eq!(last(&out), ShellGesture::Commit(GestureKind::Shade(side))); + } + } + #[test] + fn an_excluded_rect_suppresses_back() { + let mut ex = ExclusionZones::default(); + ex.add(Rect { pos: dvec2(0.0, 42.0), size: dvec2(412.0, 826.0) }, [false, false, true, true]); + let mut rec = GestureRecognizer::default(); + let out = drive(&mut rec, &ctx(PhoneScreen::App), &ex, &swipe((6.0, 400.0), (160.0, 404.0), 0.3, 5)); + assert!(out.iter().all(|g| g.is_none()), "the map keeps its edge: {out:?}"); + assert!(!rec.active()); + // The bottom band is not part of that zone: home still works. + let out = drive(&mut rec, &ctx(PhoneScreen::App), &ex, &swipe((200.0, 880.0), (200.0, 700.0), 0.2, 6)); + assert_eq!(last(&out), ShellGesture::Commit(GestureKind::HomeUp)); + } + #[test] + fn the_keyboard_owns_the_bottom_band() { + let mut ex = ExclusionZones::default(); + // The keyboard plus the navigation bar under it, as the shell registers it. + ex.add(Rect { pos: dvec2(0.0, 576.0), size: dvec2(412.0, 316.0) }, [false, true, false, false]); + let mut rec = GestureRecognizer::default(); + rec.feed(Down, dvec2(200.0, 880.0), 1.0, &ctx(PhoneScreen::App), &ex); + assert!(!rec.active(), "a key at the bottom of the keyboard is a key, not home"); + } + #[test] + fn a_home_horizontal_drag_is_a_page_swipe() { + let mut rec = GestureRecognizer::default(); + let out = drive(&mut rec, &ctx(PhoneScreen::Home), &ExclusionZones::default(), &swipe((300.0, 400.0), (120.0, 410.0), 0.3, 5)); + assert!(matches!(out[2], Some(ShellGesture::PageSwipe { dir: Dir::Left, .. })), "{:?}", out[2]); + assert_eq!(last(&out), ShellGesture::Commit(GestureKind::Page(Dir::Left))); + // The same drag over an open app belongs to the app. + let mut rec = GestureRecognizer::default(); + let out = drive(&mut rec, &ctx(PhoneScreen::App), &ExclusionZones::default(), &swipe((300.0, 400.0), (120.0, 410.0), 0.3, 5)); + assert!(out.iter().all(|g| g.is_none()) && !rec.active()); + } + #[test] + fn a_home_downward_drag_is_search_and_upward_is_nothing() { + let mut rec = GestureRecognizer::default(); + let out = drive(&mut rec, &ctx(PhoneScreen::Home), &ExclusionZones::default(), &swipe((200.0, 300.0), (204.0, 500.0), 0.3, 5)); + assert!(matches!(out[2], Some(ShellGesture::HomeSearch { .. })), "{:?}", out[2]); + assert_eq!(last(&out), ShellGesture::Commit(GestureKind::HomeSearch)); + let mut rec = GestureRecognizer::default(); + let out = drive(&mut rec, &ctx(PhoneScreen::Home), &ExclusionZones::default(), &swipe((200.0, 500.0), (204.0, 300.0), 0.3, 5)); + assert!(out.iter().all(|g| g.is_none()), "an upward drag on the home page is the drawer's, not a shell gesture: {out:?}"); + } + #[test] + fn a_tap_in_a_band_is_not_a_gesture_and_a_finger_in_the_nav_bar_is_the_bottom_band() { + let mut rec = GestureRecognizer::default(); + let out = drive(&mut rec, &ctx(PhoneScreen::App), &ExclusionZones::default(), &[(Down, 200.0, 880.0, 1.0), (Move, 202.0, 878.0, 1.05), (Up, 202.0, 878.0, 1.1)]); + assert!(out.iter().all(|g| g.is_none()), "{out:?}"); + let ctx = GestureContext { insets: SafeInsets { bottom: 20.0, ..Default::default() }, ..ctx(PhoneScreen::App) }; + let out = drive(&mut rec, &ctx, &ExclusionZones::default(), &swipe((200.0, 905.0), (200.0, 700.0), 0.2, 6)); + assert_eq!(last(&out), ShellGesture::Commit(GestureKind::HomeUp)); + assert_eq!(rec.feed(Down, dvec2(200.0, 930.0), 3.0, &ctx, &ExclusionZones::default()), None); + assert!(!rec.active(), "below the navigation bar is off the glass"); + } + #[test] + fn cancel_reports_the_gesture_in_progress() { + let mut rec = GestureRecognizer::default(); + drive(&mut rec, &ctx(PhoneScreen::App), &ExclusionZones::default(), &[(Down, 6.0, 400.0, 1.0), (Move, 80.0, 402.0, 1.1)]); + assert_eq!(rec.cancel(), Some(ShellGesture::Cancel(GestureKind::Back))); + assert_eq!(rec.cancel(), None); + } +} diff --git a/src/mobile_surface.rs b/src/mobile_surface.rs index 6875e920..ef68d3da 100644 --- a/src/mobile_surface.rs +++ b/src/mobile_surface.rs @@ -404,12 +404,16 @@ impl PhoneSurface { self.d.icon_centered(cx,Ico::ChevronLeft,back,16.0,nav_ink);self.hits.push((back,PhoneHit::Back)); } } + /// Where the shell keyboard sits while it is up (or sliding up). + pub fn keyboard_rect(phone: &PhoneState, screen: Rect) -> Rect { + rect(screen.pos.x,screen.pos.y+screen.size.y-phone.keyboard-24.0,screen.size.x,phone.keyboard_height()) + } fn draw_keyboard(&mut self, cx: &mut Cx2d, state: &WmState, screen: Rect, backdrop: Option) { let phone=&state.phone; let ios=state.style.target==DesktopStyle::Ios; let dark=state.style.dark; - let height=phone.keyboard_height(); - let r=rect(screen.pos.x,screen.pos.y+screen.size.y-phone.keyboard-24.0,screen.size.x,height); + let r=Self::keyboard_rect(phone,screen); + let height=r.size.y; if ios && !dark {self.keyboard_glass.draw_surface_with_backdrop(cx,r,backdrop,1.0);} else {self.rounded(cx,r,0.0,if dark {rgb(34,32,40)}else{rgb(232,225,242)});} let ink=if dark {rgb(250,248,255)}else{rgb(30,28,36)}; From 40297e72ef8a3405f3d651141e9434e2f926b881 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:15:11 -0700 Subject: [PATCH 08/13] mobile: exclusion zones are cleared once per frame and every surface adds The shade and the split divider used to remove last frame's zone and add this frame's inside their step, but the desk clears the zones at draw time, after the step, so both were wiped before any finger arrived. Now draw_phone_scene clears once and adds the shade's sheet, the divider band, the edge-owning apps and the keyboard; the shade and the groups only report their rect. Co-Authored-By: Claude Fable 5.1 --- src/desk/phone.rs | 6 +++- src/mobile.rs | 2 +- src/mobile_groups.rs | 38 +++++++++++----------- src/mobile_shade.rs | 76 +++++++++++++++++++++----------------------- 4 files changed, 60 insertions(+), 62 deletions(-) diff --git a/src/desk/phone.rs b/src/desk/phone.rs index 1a33a2f7..6e522e8a 100644 --- a/src/desk/phone.rs +++ b/src/desk/phone.rs @@ -207,8 +207,12 @@ impl WmDesk { pub(super) fn draw_phone_scene(&mut self,cx:&mut Cx2d,scope:&mut Scope,screen:Rect) { let state=scope.data.get_mut::().unwrap(); state.phone.viewport=screen; - // The frame's exclusion zones are rebuilt below from what is drawn. + // The frame's exclusion zones are rebuilt from what is drawn: cleared + // once here, then every surface adds its own (the shade's sheet, the + // split divider, the apps that own their edges, the keyboard). state.phone.exclusions.clear(); + if let Some(z)=state.phone.shade.exclusion(screen) {state.phone.exclusions.add(z,[true;4]);} + state.phone.groups.add_exclusions(state.phone.screen,crate::mobile::app_rect(screen),&mut state.phone.exclusions); let owns_edges:Vec=state.clients.iter().filter(|(_,s)|s.owns_edges).map(|(c,_)|*c).collect(); crate::mobile_pages::sync(&mut state.phone,state.style.target,screen); state.phone.order.retain(|c|state.clients.contains_key(c)); diff --git a/src/mobile.rs b/src/mobile.rs index 58d3f45c..bafc57d3 100644 --- a/src/mobile.rs +++ b/src/mobile.rs @@ -160,7 +160,7 @@ impl PhoneState { if (target - self.page).abs() < 0.001 { self.page = target; } active |= self.page != target; } - active |= self.shade.step(dt, self.gesture_out, self.viewport, self.wallpaper_time, &mut self.exclusions); + active |= self.shade.step(dt, self.gesture_out, self.wallpaper_time); active |= self.pages.step(dt, if self.screen == PhoneScreen::Home { self.gesture_out } else { None }); if self.pages.take_library_request() { self.navigate(PhoneScreen::Drawer); } active diff --git a/src/mobile_groups.rs b/src/mobile_groups.rs index 48b52fdd..83d292dc 100644 --- a/src/mobile_groups.rs +++ b/src/mobile_groups.rs @@ -131,9 +131,6 @@ pub struct GroupsState { /// The tile the open sub-window grew out of, remembered while it closes /// (the home layout may not be at hand then). pub origin: Option, - /// The divider band handed to the exclusion zones last frame, so it - /// is replaced rather than piled up. - excluded: Option, /// The last group name that was closing, so `openness` keeps a target. closing: Option, } @@ -142,7 +139,7 @@ impl Default for GroupsState { fn default() -> Self { Self { groups: SEED_GROUPS.iter().map(|(name, apps)| TileGroup::new(name, apps)).collect(), - open: None, openness: 0.0, split: None, pick: None, origin: None, excluded: None, closing: None, + open: None, openness: 0.0, split: None, pick: None, origin: None, closing: None, } } } @@ -216,15 +213,11 @@ impl GroupsState { if screen != PhoneScreen::Recents { self.pick = None; } if screen != PhoneScreen::Home && self.open.is_some() { self.close(); } } - /// The divider band as an exclusion zone, replacing last frame's. - pub fn sync_exclusions(&mut self, screen: PhoneScreen, app: Rect, zones: &mut crate::mobile_gestures::ExclusionZones) { - if let Some(old) = self.excluded.take() { zones.zones.retain(|z| z.rect != old); } + /// The divider band as an exclusion zone. The desk clears the zones + /// once per frame and every surface adds its own, so this only adds. + pub fn add_exclusions(&self, screen: PhoneScreen, app: Rect, zones: &mut crate::mobile_gestures::ExclusionZones) { if screen != PhoneScreen::App { return; } - if let Some(split) = self.split { - let band = split.divider(app); - zones.add(band, [true; 4]); - self.excluded = Some(band); - } + if let Some(split) = self.split { zones.add(split.divider(app), [true; 4]); } } /// Animate the sub-window. True while it is still moving. pub fn step(&mut self, dt: f64) -> bool { @@ -239,11 +232,10 @@ impl GroupsState { } /// Everything the animation frame needs from the phone state, in one call. +/// (The divider's exclusion zone is added at draw time, by the desk.) pub fn follow(phone: &mut PhoneState) { if let Some(gesture) = phone.gesture_out.as_ref() { phone.groups.on_gesture(gesture); } phone.groups.follow(phone.screen, phone.client); - let app = mobile::app_rect(phone.viewport); - phone.groups.sync_exclusions(phone.screen, app, &mut phone.exclusions); } // -------------------------------------------------------------------- @@ -687,30 +679,36 @@ mod tests { let mut groups = GroupsState::default(); let mut zones = ExclusionZones::default(); let app = app(); - groups.sync_exclusions(PhoneScreen::App, app, &mut zones); + groups.add_exclusions(PhoneScreen::App, app, &mut zones); assert!(zones.zones.is_empty()); groups.enter_split(1, 2); - groups.sync_exclusions(PhoneScreen::App, app, &mut zones); + groups.add_exclusions(PhoneScreen::App, app, &mut zones); assert_eq!(zones.zones.len(), 1); let mid = dvec2(200.0, app.pos.y + app.size.y * 0.5); assert!(zones.excludes(mid, crate::mobile_gestures::Edge::Bottom)); + // The desk clears the frame's zones before every surface adds again. groups.drag_divider(dvec2(200.0, app.pos.y + app.size.y * 0.3), app); - groups.sync_exclusions(PhoneScreen::App, app, &mut zones); - assert_eq!(zones.zones.len(), 1, "replaced, not piled up"); + zones.clear(); + groups.add_exclusions(PhoneScreen::App, app, &mut zones); + assert_eq!(zones.zones.len(), 1); assert!(!zones.excludes(mid, crate::mobile_gestures::Edge::Bottom)); assert!(zones.excludes(dvec2(200.0, app.pos.y + app.size.y * 0.3), crate::mobile_gestures::Edge::Left)); groups.leave_split(); - groups.sync_exclusions(PhoneScreen::App, app, &mut zones); + zones.clear(); + groups.add_exclusions(PhoneScreen::App, app, &mut zones); assert!(zones.zones.is_empty()); - // The whole phone state in one call: leaving for home clears it too. + // Off the app screen the band is nobody's, split or not. let mut phone = PhoneState::default(); phone.viewport = Rect { pos: dvec2(0.0, 0.0), size: dvec2(412.0, 892.0) }; phone.activate(1); phone.activate(2); phone.groups.enter_split(2, 1); follow(&mut phone); + phone.groups.add_exclusions(phone.screen, mobile::app_rect(phone.viewport), &mut phone.exclusions); assert!(phone.groups.split.is_some() && phone.exclusions.zones.len() == 1); phone.navigate(PhoneScreen::Home); follow(&mut phone); + phone.exclusions.clear(); + phone.groups.add_exclusions(phone.screen, mobile::app_rect(phone.viewport), &mut phone.exclusions); assert!(phone.groups.split.is_none() && phone.exclusions.zones.is_empty()); } diff --git a/src/mobile_shade.rs b/src/mobile_shade.rs index 9d39aedb..1e9efdf8 100644 --- a/src/mobile_shade.rs +++ b/src/mobile_shade.rs @@ -98,8 +98,6 @@ pub struct ShadeState { drag: Option<(DragAxis, f64, f64)>, /// A finger on a card: it holds its offset until release. dragging_note: Option, - /// The exclusion zone added last frame, removed before the next. - last_zone: Option, seeded: bool, } impl Default for ShadeState { @@ -109,7 +107,7 @@ impl Default for ShadeState { notifications: Vec::new(), next_id: 0, brightness: 0.62, volume: 0.45, wifi: true, bluetooth: false, torch: false, rotation_lock: false, do_not_disturb: false, toggle_anim: [1.0, 0.0, 0.0, 0.0, 0.0], - battery: None, now: 0.0, pulling: false, drag: None, dragging_note: None, last_zone: None, seeded: false, + battery: None, now: 0.0, pulling: false, drag: None, dragging_note: None, seeded: false, } } } @@ -273,9 +271,16 @@ impl ShadeState { } } - /// Per frame: apply this frame's shell gesture, animate, refresh the - /// exclusion zone. Returns true while anything is still moving. - pub fn step(&mut self, dt: f64, gesture: Option, screen: Rect, now: f64, exclusions: &mut ExclusionZones) -> bool { + /// The rect the sheet owns while it shows: every shell edge under it is + /// the shade's. The desk adds it to the frame's exclusion zones (which + /// it clears once per frame), so the shade never removes anything. + pub fn exclusion(&self, screen: Rect) -> Option { + if self.open <= 0.01 { return None; } + Some(if self.open > 0.5 { screen } else { self.sheet_rect(screen) }) + } + /// Per frame: apply this frame's shell gesture and animate. Returns + /// true while anything is still moving. + pub fn step(&mut self, dt: f64, gesture: Option, now: f64) -> bool { if now > 0.0 { self.now = now; } self.apply_gesture(gesture); let t = 1.0 - (-dt * 16.0).exp(); @@ -304,13 +309,6 @@ impl ShadeState { if (*v - target).abs() < 0.005 { *v = target; } active |= *v != target; } - // The sheet owns every edge under it while it shows. - if let Some(z) = self.last_zone.take() { exclusions.zones.retain(|e| !(e.rect == z && e.edges == [true; 4])); } - if self.open > 0.01 { - let z = if self.open > 0.5 { screen } else { self.sheet_rect(screen) }; - exclusions.add(z, [true; 4]); - self.last_zone = Some(z); - } self.pulling = false; active } @@ -531,82 +529,80 @@ fn draw_controls(cx: &mut Cx2d, d: &mut ShellDraw, chrome: &mut DrawDesktopChrom mod tests { use super::*; fn screen() -> Rect { rect(0.0, 0.0, 412.0, 892.0) } - fn settle(s: &mut ShadeState, ex: &mut ExclusionZones) { for _ in 0..120 { s.step(1.0 / 60.0, None, screen(), 100.0, ex); } } + fn settle(s: &mut ShadeState) { for _ in 0..120 { s.step(1.0 / 60.0, None, 100.0); } } + /// The frame's exclusion zones as the desk rebuilds them: cleared, then the shade's. + fn zones(s: &ShadeState) -> ExclusionZones { let mut ex = ExclusionZones::default(); if let Some(z) = s.exclusion(screen()) { ex.add(z, [true; 4]); } ex } #[test] fn pull_progress_drives_open_and_commit_finishes_it() { let mut s = ShadeState::default(); - let mut ex = ExclusionZones::default(); - s.step(1.0 / 60.0, Some(ShellGesture::ShadePull { side: ShadeSide::Controls, progress: 0.4 }), screen(), 1.0, &mut ex); + s.step(1.0 / 60.0, Some(ShellGesture::ShadePull { side: ShadeSide::Controls, progress: 0.4 }), 1.0); assert!((s.open - 0.4).abs() < 1e-9 && s.side == ShadeSide::Controls); assert_eq!(s.page, 1.0, "the controls half is the page the pull opens onto"); - assert!(!ex.zones.is_empty(), "a showing sheet owns the edges under it"); - s.step(1.0 / 60.0, Some(ShellGesture::Commit(GestureKind::Shade(ShadeSide::Controls))), screen(), 1.0, &mut ex); - settle(&mut s, &mut ex); + assert!(!zones(&s).zones.is_empty(), "a showing sheet owns the edges under it"); + s.step(1.0 / 60.0, Some(ShellGesture::Commit(GestureKind::Shade(ShadeSide::Controls))), 1.0); + settle(&mut s); assert_eq!(s.open, 1.0); - assert_eq!(ex.zones.len(), 1, "one zone, refreshed not accumulated"); + let ex = zones(&s); + assert_eq!(ex.zones.len(), 1, "one zone per frame: the desk clears, the shade adds"); assert_eq!(ex.zones[0].rect, screen()); } #[test] fn cancel_animates_back_closed_and_drops_the_exclusion() { let mut s = ShadeState::default(); - let mut ex = ExclusionZones::default(); - s.step(1.0 / 60.0, Some(ShellGesture::ShadePull { side: ShadeSide::Notifications, progress: 0.3 }), screen(), 1.0, &mut ex); - s.step(1.0 / 60.0, Some(ShellGesture::Cancel(GestureKind::Shade(ShadeSide::Notifications))), screen(), 1.0, &mut ex); - settle(&mut s, &mut ex); + s.step(1.0 / 60.0, Some(ShellGesture::ShadePull { side: ShadeSide::Notifications, progress: 0.3 }), 1.0); + s.step(1.0 / 60.0, Some(ShellGesture::Cancel(GestureKind::Shade(ShadeSide::Notifications))), 1.0); + settle(&mut s); assert_eq!(s.open, 0.0); - assert!(ex.zones.is_empty()); - assert!(!s.step(1.0 / 60.0, None, screen(), 1.0, &mut ex), "settled: nothing left to animate"); + assert!(zones(&s).zones.is_empty()); + assert!(!s.step(1.0 / 60.0, None, 1.0), "settled: nothing left to animate"); } #[test] fn sideways_drag_and_page_gesture_switch_halves() { let mut s = ShadeState::default(); - let mut ex = ExclusionZones::default(); s.open_on(ShadeSide::Notifications); - settle(&mut s, &mut ex); + settle(&mut s); assert_eq!(s.page, 0.0); s.drag(&ShadeHit::Sheet, dvec2(100.0, 300.0), dvec2(-260.0, 4.0), screen()); assert!(s.page > 0.5 && s.page < 1.0); s.release(&ShadeHit::Sheet, dvec2(-260.0, 4.0), 0.6); - settle(&mut s, &mut ex); + settle(&mut s); assert_eq!((s.page, s.side), (1.0, ShadeSide::Controls)); // A short fast flick right goes back even before halfway. s.drag(&ShadeHit::Sheet, dvec2(200.0, 300.0), dvec2(60.0, 0.0), screen()); s.release(&ShadeHit::Sheet, dvec2(60.0, 0.0), 0.1); - settle(&mut s, &mut ex); + settle(&mut s); assert_eq!((s.page, s.side), (0.0, ShadeSide::Notifications)); // The recognizer's page swipe does the same once it exists. - s.step(1.0 / 60.0, Some(ShellGesture::PageSwipe { dir: Dir::Left, progress: 0.5 }), screen(), 1.0, &mut ex); + s.step(1.0 / 60.0, Some(ShellGesture::PageSwipe { dir: Dir::Left, progress: 0.5 }), 1.0); assert!((s.page - 0.5).abs() < 1e-9); - s.step(1.0 / 60.0, Some(ShellGesture::Commit(GestureKind::Page(Dir::Left))), screen(), 1.0, &mut ex); - settle(&mut s, &mut ex); + s.step(1.0 / 60.0, Some(ShellGesture::Commit(GestureKind::Page(Dir::Left))), 1.0); + settle(&mut s); assert_eq!(s.page, 1.0); } #[test] fn swipe_up_on_the_sheet_and_backdrop_tap_close() { let mut s = ShadeState::default(); - let mut ex = ExclusionZones::default(); s.open_on(ShadeSide::Controls); - settle(&mut s, &mut ex); + settle(&mut s); s.drag(&ShadeHit::Sheet, dvec2(200.0, 400.0), dvec2(2.0, -120.0), screen()); assert!(s.open < 1.0, "the sheet follows the finger up"); s.release(&ShadeHit::Sheet, dvec2(2.0, -120.0), 0.5); - settle(&mut s, &mut ex); + settle(&mut s); assert_eq!(s.open, 0.0); s.open_on(ShadeSide::Controls); - settle(&mut s, &mut ex); + settle(&mut s); s.tap(ShadeHit::Backdrop); - settle(&mut s, &mut ex); + settle(&mut s); assert_eq!(s.open, 0.0); } #[test] fn dismiss_reveal_actions_and_clear_all() { let mut s = ShadeState::default(); - let mut ex = ExclusionZones::default(); let a = s.post("wm", "A", "first", 1.0, vec![]); let b = s.post("messages", "B", "second", 2.0, vec!["Reply".into()]); assert_eq!(s.notifications.len(), 2); @@ -615,7 +611,7 @@ mod tests { assert!(s.notifications.iter().all(|n| n.id != a), "a right swipe dismisses"); s.drag(&ShadeHit::Note(b), dvec2(0.0, 0.0), dvec2(-120.0, 0.0), screen()); s.release(&ShadeHit::Note(b), dvec2(-120.0, 0.0), 0.5); - settle(&mut s, &mut ex); + settle(&mut s); let note = s.notifications.iter().find(|n| n.id == b).unwrap(); assert!(note.revealed && (note.offset + 2.0 * ACTION_W).abs() < 1e-6, "a left swipe rests on the action strip"); s.tap(ShadeHit::Action(b, 0)); From 2b365cfe497fab2ea95d99542fcc142ef74ab1dd Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:15:13 -0700 Subject: [PATCH 09/13] mobile: the island's docked activities land in the shade, and the island follows the shade PhoneState::step drains mobile_island::take_docked() into ShadeState::post every frame (stamped on the shade's clock), and tells the island whether the shade is open (open_target, so a pull in progress still docks the pill instead of cutting it) so the pill hides under the sheet and returns when it closes. Co-Authored-By: Claude Fable 5.1 --- src/mobile.rs | 7 +++++++ src/mobile_shade.rs | 3 +++ 2 files changed, 10 insertions(+) diff --git a/src/mobile.rs b/src/mobile.rs index bafc57d3..4af8c7ac 100644 --- a/src/mobile.rs +++ b/src/mobile.rs @@ -161,6 +161,13 @@ impl PhoneState { active |= self.page != target; } active |= self.shade.step(dt, self.gesture_out, self.wallpaper_time); + // An activity the island dropped (or finished) becomes a card in the + // shade, stamped on the shade's clock; the island stays hidden while + // the sheet is (or is about to be) open and comes back as it closes. + for note in crate::mobile_island::take_docked() { + self.shade.post(¬e.app, ¬e.title, ¬e.body, self.wallpaper_time, Vec::new()); + } + self.island.set_shade_open(self.shade.wants_open()); active |= self.pages.step(dt, if self.screen == PhoneScreen::Home { self.gesture_out } else { None }); if self.pages.take_library_request() { self.navigate(PhoneScreen::Drawer); } active diff --git a/src/mobile_shade.rs b/src/mobile_shade.rs index 1e9efdf8..c89a6428 100644 --- a/src/mobile_shade.rs +++ b/src/mobile_shade.rs @@ -120,6 +120,9 @@ const CARD_RADIUS: f32 = 22.0; impl ShadeState { pub fn is_open(&self) -> bool { self.open > 0.001 || self.open_target > 0.5 } + /// The sheet is open or opening (not merely being pulled): what the + /// island reads to hide, so it still docks with a pull in progress. + pub fn wants_open(&self) -> bool { self.open_target > 0.5 } pub fn toggled(&self, t: Toggle) -> bool { match t { Toggle::Wifi => self.wifi, Toggle::Bluetooth => self.bluetooth, Toggle::Torch => self.torch, Toggle::RotationLock => self.rotation_lock, Toggle::DoNotDisturb => self.do_not_disturb } } From 1cee2978abad8458705f8ffa592b6df125d8b598 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:16:08 -0700 Subject: [PATCH 10/13] mobile: the closed shade's tap targets sit under the island, the pull stays the recognizer's Hits are last-wins and the shade drew last, so its two status-bar halves covered the island's clock and pill; they are now registered right after the status bar, before the island draws. A drag that starts on those halves is the recognizer's ShadePull (the shade's own drag handles only the open sheet), and a finger on the open sheet releases the recognizer at Down so no track is left running. Co-Authored-By: Claude Fable 5.1 --- src/mobile_app.rs | 7 +++++-- src/mobile_shade.rs | 24 ++++++++++++++++++------ src/mobile_surface.rs | 1 + 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/mobile_app.rs b/src/mobile_app.rs index dfd7c139..1a429667 100644 --- a/src/mobile_app.rs +++ b/src/mobile_app.rs @@ -1,5 +1,6 @@ //! Phone navigation/input, sharing the WM's real clients and launch paths. use crate::{mobile::*, mobile_surface::PhoneSurface, mobile_tiles::{self, Face, TILE_APPS}, *}; +use crate::mobile_shade::ShadeState; use crate::mobile_gestures::{Dir, FingerPhase, GestureContext, GestureKind, SafeInsets, ShellGesture}; use makepad_widgets::makepad_platform::ime::{HostedKeyboard, InputMode}; use makepad_widgets::widget_async::{enter_isolate, leave_isolate}; @@ -596,6 +597,8 @@ impl App { // The recognizer claims a finger in a band (or on the home // page body); an excluded edge is left to the app. self.phone_gestures.feed(FingerPhase::Down,p,time,&ctx,&phone.exclusions); + // A finger on the open shade's sheet is the shade's own drag. + if matches!(&hit,Some(PhoneHit::Shade(h)) if ShadeState::drags(h)) {self.phone_gestures.cancel();} let shell=self.phone_gestures.active(); if !shell && !screen.contains(p) {return false;} if shell || hit.is_some() || old!=PhoneScreen::App { @@ -611,7 +614,7 @@ impl App { let delta=p-g.start;let last=p-g.last;g.last=p; let (shell,from)=(g.shell,g.screen); let divider=g.hit==Some(PhoneHit::Divider); - let shade_hit=if let Some(PhoneHit::Shade(h))=&g.hit {Some(h.clone())} else {None}; + let shade_hit=match &g.hit {Some(PhoneHit::Shade(h)) if ShadeState::drags(h)=>Some(h.clone()),_=>None}; if let Some(h)=shade_hit {phone.shade.drag(&h,p,delta,screen);self.animate_phone(cx);return true;} let out=if shell {self.phone_gestures.feed(FingerPhase::Move,p,time,&ctx,&phone.exclusions)} else {None}; phone.gesture_out=out; @@ -628,7 +631,7 @@ impl App { PhonePointerPhase::Up=>{ let Some(g)=phone.gesture.take() else{return phone.screen!=PhoneScreen::App;}; let delta=p-g.start; - if let (Some(PhoneHit::Shade(h)),true)=(&g.hit,delta.length()>=12.0) {phone.shade.release(h,delta,time-g.time);self.animate_phone(cx);return true;} + if let (Some(PhoneHit::Shade(h)),true)=(&g.hit,delta.length()>=12.0 && !g.shell) {phone.shade.release(h,delta,time-g.time);self.animate_phone(cx);return true;} let out=if g.shell {self.phone_gestures.feed(FingerPhase::Up,p,time,&ctx,&phone.exclusions)} else {None}; phone.gesture_out=out; self.gesture_out_age=0; diff --git a/src/mobile_shade.rs b/src/mobile_shade.rs index c89a6428..8c594ef8 100644 --- a/src/mobile_shade.rs +++ b/src/mobile_shade.rs @@ -183,6 +183,10 @@ impl ShadeState { } /// A tap on a shade hit (the shell's tap detection already happened). + /// A hit the sheet drags itself (the recognizer lets the finger go): + /// everything but the closed status bar, whose pull is the + /// recognizer's `ShadePull`. + pub fn drags(hit: &ShadeHit) -> bool { !matches!(hit, ShadeHit::Open(_)) } pub fn tap(&mut self, hit: ShadeHit) { match hit { ShadeHit::Open(side) => self.open_on(side), @@ -368,8 +372,20 @@ fn rounded(chrome: &mut DrawDesktopChrome, cx: &mut Cx2d, r: Rect, radius: f32, chrome.draw_abs(cx, r); } -/// Draw the shade over everything else. `hits` receives the tappable -/// regions; while closed only the status bar's two halves are registered. +/// The closed shade's tap targets: the status bar's two halves open the +/// side under the finger. Registered right after the status bar is drawn, +/// under the island's own targets (hits are last-wins), so the pill and +/// the clock keep their taps. +pub fn status_bar_hits(hits: &mut Vec<(Rect, PhoneHit)>, state: &WmState, screen: Rect) { + if state.phone.shade.open >= 0.001 { return; } + let status_h = if screen.size.x > screen.size.y { 24.0 } else { 42.0 }; + let half = screen.size.x * 0.5; + hits.push((rect(screen.pos.x, screen.pos.y, half, status_h), PhoneHit::Shade(ShadeHit::Open(ShadeSide::Notifications)))); + hits.push((rect(screen.pos.x + half, screen.pos.y, half, status_h), PhoneHit::Shade(ShadeHit::Open(ShadeSide::Controls)))); +} + +/// Draw the shade over everything else. `hits` receives the sheet's +/// tappable regions (the closed shade's are `status_bar_hits`). #[allow(clippy::too_many_arguments)] pub fn draw(cx: &mut Cx2d, d: &mut ShellDraw, chrome: &mut DrawDesktopChrome, icons: &mut AppIconDraw, glass: &mut GaussRoundedView, hits: &mut Vec<(Rect, PhoneHit)>, state: &WmState, screen: Rect, backdrop: Option) { let shade = &state.phone.shade; @@ -378,10 +394,6 @@ pub fn draw(cx: &mut Cx2d, d: &mut ShellDraw, chrome: &mut DrawDesktopChrome, ic let ios = style == DesktopStyle::Ios; let landscape = screen.size.x > screen.size.y; if shade.open < 0.001 { - let status_h = if landscape { 24.0 } else { 42.0 }; - let half = screen.size.x * 0.5; - hits.push((rect(screen.pos.x, screen.pos.y, half, status_h), PhoneHit::Shade(ShadeHit::Open(ShadeSide::Notifications)))); - hits.push((rect(screen.pos.x + half, screen.pos.y, half, status_h), PhoneHit::Shade(ShadeHit::Open(ShadeSide::Controls)))); return; } let open = shade.open.clamp(0.0, 1.0) as f32; diff --git a/src/mobile_surface.rs b/src/mobile_surface.rs index ea54d2b3..18fb1e38 100644 --- a/src/mobile_surface.rs +++ b/src/mobile_surface.rs @@ -406,6 +406,7 @@ impl PhoneSurface { self.d.icon_centered(cx,Ico::Wifi,rect(screen.pos.x+screen.size.x-69.0,screen.pos.y,22.0,status_h),14.0,ink); self.rounded(cx,rect(screen.pos.x+screen.size.x-40.0,screen.pos.y+(status_h-11.0)*0.5,23.0,11.0),3.0,alpha(ink,0.45)); self.rounded(cx,rect(screen.pos.x+screen.size.x-38.0,screen.pos.y+(status_h-7.0)*0.5,16.0,7.0),1.5,ink); + crate::mobile_shade::status_bar_hits(&mut self.hits,state,screen); crate::mobile_island::draw(cx,&mut self.chrome,&mut self.d,&mut self.icons,&mut self.hits,state,screen); if phone.overview>0.01 { for (index,client) in phone.order.iter().enumerate() { From 10d40f8275ff0a9e1482617eb5fc309f59cd38f4 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:20:47 -0700 Subject: [PATCH 11/13] mobile: the shade no longer imports what only its tests use Co-Authored-By: Claude Fable 5.1 --- src/mobile_shade.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mobile_shade.rs b/src/mobile_shade.rs index 8c594ef8..5f1a0b2e 100644 --- a/src/mobile_shade.rs +++ b/src/mobile_shade.rs @@ -9,7 +9,7 @@ //! Until the gesture recognizer lands, the shade opens programmatically: //! tapping the status bar's left or right half, or `--test-action //! shade:`. -use crate::mobile_gestures::{Dir, ExclusionZones, GestureKind, ShadeSide, ShellGesture}; +use crate::mobile_gestures::{Dir, GestureKind, ShadeSide, ShellGesture}; use crate::{desk::WmState, desktop::{DesktopStyle, DrawDesktopChrome}, mobile::PhoneHit, octosense::style::AppIconDraw, shell::{alpha, rgb, ui::{rect, HAlign, Ico, ShellDraw}}}; use makepad_widgets::{gauss_view::{GaussBlurSnapshot, GaussRoundedView}, *}; @@ -392,7 +392,6 @@ pub fn draw(cx: &mut Cx2d, d: &mut ShellDraw, chrome: &mut DrawDesktopChrome, ic let style = state.style.target; let dark = state.style.dark; let ios = style == DesktopStyle::Ios; - let landscape = screen.size.x > screen.size.y; if shade.open < 0.001 { return; } @@ -543,6 +542,7 @@ fn draw_controls(cx: &mut Cx2d, d: &mut ShellDraw, chrome: &mut DrawDesktopChrom #[cfg(test)] mod tests { use super::*; + use crate::mobile_gestures::ExclusionZones; fn screen() -> Rect { rect(0.0, 0.0, 412.0, 892.0) } fn settle(s: &mut ShadeState) { for _ in 0..120 { s.step(1.0 / 60.0, None, 100.0); } } /// The frame's exclusion zones as the desk rebuilds them: cleared, then the shade's. From 7119a83d4805136cf1f8b80c6641e17ccd4b0d23 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:24:46 -0700 Subject: [PATCH 12/13] mobile: a gesture commit reaches the surfaces once, and a swipe up over a card shuts the shade The animation frame stepped the phone before ageing gesture_out, so a Commit/Cancel was fed to PhoneState::step on two consecutive frames: the pager jumped two positions per swipe (glance straight into the drawer on the OnePlus). The ageing now runs before the step. A vertical drag that starts on a notification card was the card's alone and did nothing; it now pulls the sheet like a drag on the sheet body, while sideways drags still slide the card. Co-Authored-By: Claude Fable 5.1 --- src/mobile_app.rs | 6 ++++-- src/mobile_shade.rs | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/mobile_app.rs b/src/mobile_app.rs index 1a429667..545ec283 100644 --- a/src/mobile_app.rs +++ b/src/mobile_app.rs @@ -284,7 +284,6 @@ impl App { let Some(state) = self.state.as_mut() else { return }; let phone = &mut state.phone; phone.wallpaper_time = frame.time; - let moving = phone.step(dt); // A finger resting near the top of a home swipe becomes the // switcher without moving; the frame keeps running while // the recognizer owns a finger so the hold can land. @@ -298,9 +297,12 @@ impl App { Self::drive_gesture(phone, out, from); } } else if matches!(phone.gesture_out, Some(ShellGesture::Commit(_) | ShellGesture::Cancel(_))) { - // Commit/Cancel stay for exactly one drawn frame. + // Commit/Cancel stay for exactly one stepped frame: aged + // before `step` below, so the pager (and every other + // surface that acts on a commit) sees it once. if self.gesture_out_age >= 1 { phone.gesture_out = None; } else { self.gesture_out_age += 1; tracking = true; } } + let moving = phone.step(dt); crate::mobile_groups::follow(phone); let wallpaper_visible = phone.screen != PhoneScreen::App || phone.openness < 0.999 || phone.overview > 0.001; if moving || wallpaper_visible || tracking {self.phone_frame=cx.new_next_frame();} diff --git a/src/mobile_shade.rs b/src/mobile_shade.rs index 5f1a0b2e..0f71041a 100644 --- a/src/mobile_shade.rs +++ b/src/mobile_shade.rs @@ -225,6 +225,21 @@ impl ShadeState { } } ShadeHit::Note(id) => { + // Sideways moves the card; a clearly vertical drag over a + // card is the sheet's, as on a phone: it pulls the shade shut. + let (axis, page0, open0) = self.drag.unwrap_or((DragAxis::Undecided, self.page, self.open)); + let axis = match axis { + DragAxis::Undecided if delta.length() < 8.0 => DragAxis::Undecided, + DragAxis::Undecided => if delta.y.abs() > delta.x.abs() * 1.5 { DragAxis::Vertical } else { DragAxis::Horizontal }, + a => a, + }; + self.drag = Some((axis, page0, open0)); + if axis == DragAxis::Vertical { + let h = (screen.size.y - NAV_H).max(1.0); + self.open = (open0 + delta.y / h).clamp(0.0, 1.0); + self.pulling = true; + return; + } self.dragging_note = Some(*id); if let Some(n) = self.notifications.iter_mut().find(|n| n.id == *id) { let base = if n.revealed { -(n.actions.len() as f64 + 1.0) * ACTION_W } else { 0.0 }; @@ -263,6 +278,11 @@ impl ShadeState { } ShadeHit::Note(id) => { self.dragging_note = None; + if let Some((DragAxis::Vertical, _, _)) = self.drag.take() { + self.pulling = false; + if delta.y < -60.0 || (fast && delta.y < -25.0) || self.open < 0.5 { self.open_target = 0.0; } else { self.open_target = 1.0; } + return; + } let Some(n) = self.notifications.iter_mut().find(|n| n.id == *id) else { return }; let reveal_w = (n.actions.len() as f64 + 1.0) * ACTION_W; if n.offset > 96.0 || (fast && delta.x > 40.0) { @@ -563,6 +583,28 @@ mod tests { assert_eq!(ex.zones[0].rect, screen()); } + #[test] + fn an_upward_drag_over_a_card_pulls_the_sheet_shut() { + let mut s = ShadeState::default(); + s.open_on(ShadeSide::Notifications); + settle(&mut s); + let id = s.post("Photos", "Memories", "ready", 1.0, Vec::new()); + let hit = ShadeHit::Note(id); + for k in 1..=6 { s.drag(&hit, dvec2(200.0, 600.0 - 40.0 * k as f64), dvec2(2.0, -40.0 * k as f64), screen()); } + assert!(s.open < 1.0, "the sheet followed the finger up: {}", s.open); + assert_eq!(s.notifications[0].offset, 0.0, "the card did not slide"); + s.release(&hit, dvec2(2.0, -240.0), 0.2); + settle(&mut s); + assert_eq!(s.open, 0.0); + // A sideways drag on the card is still the card's. + s.open_on(ShadeSide::Notifications); + settle(&mut s); + s.drag(&hit, dvec2(260.0, 400.0), dvec2(60.0, 3.0), screen()); + assert!(s.notifications[0].offset > 0.0 && s.open == 1.0); + s.release(&hit, dvec2(60.0, 3.0), 0.5); + assert!(s.drag.is_none()); + } + #[test] fn cancel_animates_back_closed_and_drops_the_exclusion() { let mut s = ShadeState::default(); From de33b65314583b3acecb99d4e9b66abf60f233a7 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:29:10 -0700 Subject: [PATCH 13/13] mobile: the island-to-shade hand-off is one pure step, with a test PhoneState::absorb_docked takes the docked notes the frame drained, so the seam is testable without the island's process-wide queue. Co-Authored-By: Claude Fable 5.1 --- src/mobile.rs | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/src/mobile.rs b/src/mobile.rs index 4af8c7ac..b088de09 100644 --- a/src/mobile.rs +++ b/src/mobile.rs @@ -161,17 +161,21 @@ impl PhoneState { active |= self.page != target; } active |= self.shade.step(dt, self.gesture_out, self.wallpaper_time); - // An activity the island dropped (or finished) becomes a card in the - // shade, stamped on the shade's clock; the island stays hidden while - // the sheet is (or is about to be) open and comes back as it closes. - for note in crate::mobile_island::take_docked() { - self.shade.post(¬e.app, ¬e.title, ¬e.body, self.wallpaper_time, Vec::new()); - } + self.absorb_docked(crate::mobile_island::take_docked()); + // The island stays hidden while the sheet is (or is about to be) + // open and comes back as it closes. self.island.set_shade_open(self.shade.wants_open()); active |= self.pages.step(dt, if self.screen == PhoneScreen::Home { self.gesture_out } else { None }); if self.pages.take_library_request() { self.navigate(PhoneScreen::Drawer); } active } + /// An activity the island dropped becomes a card in the shade, stamped + /// on the shade's clock (the frame time, not the island's). + pub fn absorb_docked(&mut self, notes: Vec) { + for note in notes { + self.shade.post(¬e.app, ¬e.title, ¬e.body, self.wallpaper_time, Vec::new()); + } + } pub fn accepts_app_input(&self) -> bool { self.screen == PhoneScreen::App && self.gesture.is_none() && self.overview < 0.01 && self.openness > 0.99 @@ -204,6 +208,25 @@ pub fn mix_rect(a: Rect, b: Rect, t: f64) -> Rect { #[cfg(test)] mod tests { use super::*; + + #[test] + fn a_docked_activity_becomes_a_shade_card_and_the_island_hides_under_the_open_shade() { + use crate::mobile_gestures::{GestureKind, ShadeSide, ShellGesture}; + let mut phone = PhoneState::default(); + phone.viewport = Rect { pos: dvec2(0.0, 0.0), size: dvec2(412.0, 892.0) }; + phone.wallpaper_time = 42.0; + phone.absorb_docked(vec![crate::mobile_island::DockedNote { id: "x".into(), app: "AppCard".into(), title: "Fetching forecast".into(), body: "done".into(), time: 7.0 }]); + let note = &phone.shade.notifications[0]; + assert_eq!((note.app.as_str(), note.title.as_str(), note.time), ("AppCard", "Fetching forecast", 42.0), "stamped on the shade's clock"); + // The shade commits open: the island learns it on the same step. + phone.gesture_out = Some(ShellGesture::Commit(GestureKind::Shade(ShadeSide::Notifications))); + phone.step(1.0 / 60.0); + assert!(phone.shade.wants_open() && phone.island.shade_open); + phone.gesture_out = None; + phone.shade.close(); + phone.step(1.0 / 60.0); + assert!(!phone.island.shade_open, "the island returns as the sheet closes"); + } #[test] fn both_orientations_reserve_system_bars_and_keep_selected_card_inside() { for size in [phone_size(DesktopStyle::Ios), phone_size(DesktopStyle::Android)] {