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/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.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..6e522e8a 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; @@ -134,6 +150,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 +161,8 @@ 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 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 +180,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 +189,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 +199,22 @@ 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; + // 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)); if state.phone.client.is_some_and(|c|!state.clients.contains_key(&c)) { state.phone.client=state.phone.order.first().copied(); @@ -214,6 +243,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); @@ -221,6 +251,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. @@ -228,7 +259,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); @@ -272,21 +305,37 @@ 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);} + } } + // 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); + 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,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(); 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 70c75520..1bde3aff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,8 +23,13 @@ mod desktop_app; mod snap; mod mobile; mod mobile_surface; +mod mobile_gestures; mod mobile_app; mod mobile_tiles; +mod mobile_shade; +mod mobile_pages; +mod mobile_island; +mod mobile_groups; mod scene; mod dock_warp; mod host; @@ -53,6 +58,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}; @@ -365,6 +371,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`. @@ -400,6 +410,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 @@ -1087,6 +1103,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 } => { @@ -2728,6 +2746,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); } @@ -2765,6 +2785,8 @@ 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)); + self.wake_island(cx); let mut shown: Vec = Vec::new(); let workspaces = { let state = self.state_mut(); @@ -3578,6 +3600,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; }; @@ -3607,6 +3637,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); @@ -3624,6 +3655,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 @@ -3639,6 +3684,19 @@ 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; + } + if self.island_test_action(cx, name) { i += 2; continue; } match test_action(name) { Some(action) => { log!("wm: --test-action {} -> {:?}", name, action); @@ -3953,6 +4011,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 88c1585a..b088de09 100644 --- a/src/mobile.rs +++ b/src/mobile.rs @@ -12,6 +12,14 @@ 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), + /// A page indicator dot: jump the home pager there (mobile_pages.rs). + Page(i64), + Island(crate::mobile_island::IslandHit), + /// 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)] @@ -20,8 +28,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, } @@ -56,6 +65,20 @@ 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, + /// The notification/controls shade (mobile_shade.rs). + pub shade: crate::mobile_shade::ShadeState, + /// The home pager: glance page, apps pages, library (mobile_pages.rs). + pub pages: crate::mobile_pages::PagesState, + /// The live island's activities and state (mobile_island.rs). + pub island: crate::mobile_island::IslandState, + /// 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 { @@ -65,7 +88,13 @@ 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(), + shade: Default::default(), + pages: Default::default(), + island: Default::default(), + groups: Default::default() } } } impl PhoneState { @@ -109,7 +138,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; @@ -120,14 +152,30 @@ 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); + 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; if (target - self.page).abs() < 0.001 { self.page = target; } active |= self.page != target; } + active |= self.shade.step(dt, self.gesture_out, self.wallpaper_time); + 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 @@ -160,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)] { diff --git a/src/mobile_app.rs b/src/mobile_app.rs index 8715ff96..545ec283 100644 --- a/src/mobile_app.rs +++ b/src/mobile_app.rs @@ -1,5 +1,7 @@ //! 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}; @@ -67,8 +69,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)) }) @@ -277,11 +281,31 @@ 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; + // 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 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 {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); @@ -349,14 +373,30 @@ 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), + 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; 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), @@ -382,6 +422,8 @@ 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), + 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); @@ -486,6 +528,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 +588,86 @@ 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); + // 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 { + 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 divider=g.hit==Some(PhoneHit::Divider); + 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; + 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);} + }else if divider {phone.groups.drag_divider(p,app_rect(screen));} 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);} + 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; + 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.screen==PhoneScreen::Home && phone.pages.on_glance()=>{ + phone.pages.scroll_glance(scroll,phone.viewport.size.y);self.animate_phone(cx);true + } + 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 new file mode 100644 index 00000000..79826cdf --- /dev/null +++ b/src/mobile_gestures.rs @@ -0,0 +1,463 @@ +//! 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 } } +} + +// --------------------------------------------------------------------------- +// 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_groups.rs b/src/mobile_groups.rs new file mode 100644 index 00000000..83d292dc --- /dev/null +++ b/src/mobile_groups.rs @@ -0,0 +1,752 @@ +//! 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 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, 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. 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 { 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 { + 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. +/// (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); +} + +// -------------------------------------------------------------------- +// 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.add_exclusions(PhoneScreen::App, app, &mut zones); + assert!(zones.zones.is_empty()); + groups.enter_split(1, 2); + 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); + 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(); + zones.clear(); + groups.add_exclusions(PhoneScreen::App, app, &mut zones); + assert!(zones.zones.is_empty()); + // 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()); + } + + #[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_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_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_shade.rs b/src/mobile_shade.rs new file mode 100644 index 00000000..0f71041a --- /dev/null +++ b/src/mobile_shade.rs @@ -0,0 +1,697 @@ +//! 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, 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, + 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, 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 } + /// 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 } + } + 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). + /// 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), + 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) => { + // 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 }; + 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; + 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) { + let id = *id; + self.dismiss(id); + } else if n.offset < -reveal_w * 0.4 { + n.revealed = true; + } else { + n.revealed = false; + } + } + _ => {} + } + } + + /// 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(); + 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; + } + 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); +} + +/// 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; + let style = state.style.target; + let dark = state.style.dark; + let ios = style == DesktopStyle::Ios; + if shade.open < 0.001 { + 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::*; + 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. + 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(); + 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!(!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); + 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 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(); + 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!(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(); + s.open_on(ShadeSide::Notifications); + 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); + 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); + 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 }), 1.0); + assert!((s.page - 0.5).abs() < 1e-9); + 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(); + s.open_on(ShadeSide::Controls); + 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); + assert_eq!(s.open, 0.0); + s.open_on(ShadeSide::Controls); + settle(&mut s); + s.tap(ShadeHit::Backdrop); + settle(&mut s); + assert_eq!(s.open, 0.0); + } + + #[test] + fn dismiss_reveal_actions_and_clear_all() { + let mut s = ShadeState::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); + 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..18fb1e38 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 @@ -90,7 +98,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 +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,15 +139,16 @@ 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) { + 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 +165,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 +245,59 @@ 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 { + let available: Vec<&str>=ids.iter().map(|(id,_)|id.as_str()).collect(); + for slot in &layout.tiles { + let shifted=rect(slot.rect.pos.x+dx,slot.rect.pos.y,slot.rect.size.x,slot.rect.size.y); + if matches!(slot.kind,mobile_tiles::TileKind::Group(_)) { + // A group chip is drawn by the page (mobile_groups.rs), so it rides the pager like the tiles. + let chip=mobile_tiles::TileSlot {rect:shifted,..*slot}; + self.draw_group_tile(cx,&phone.groups,chip,&available,style,dark,opacity); + } else {self.hits.push((shifted,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 +307,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)]) { @@ -377,6 +406,8 @@ 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() { if let Some(slot)=state.clients.get(client) { @@ -389,7 +420,8 @@ 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);} + self.draw_groups_overlay(cx,state,screen); + 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,13 +435,18 @@ 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); + } + /// 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)}; @@ -505,7 +542,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());