Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
a459938
mobile: the shell gesture contract every phone surface consumes
ymote Sep 14, 2026
4802c11
mobile: PhoneState carries the recognised gesture and the exclusion z…
ymote Sep 14, 2026
9ac82b0
mobile: the live island, three activities, AppCard turns as producers
ymote Sep 14, 2026
7296b43
mobile: tile groups as sub-windows, app pairs, split screen from Recents
ymote Sep 14, 2026
6349fe2
mobile: swipeable home pages with the glance page on the left
ymote Sep 14, 2026
11c3d47
mobile: the shade — notifications left, controls right
ymote Sep 14, 2026
52140fb
mobile: the shell gesture recognizer with exclusion zones
ymote Sep 14, 2026
a1e99bc
Merge branch 'feat/mobile-shell-gestures' into feat/mobile-shell
ymote Sep 14, 2026
ab8e22a
Merge branch 'feat/mobile-shell-shade' into feat/mobile-shell
ymote Sep 14, 2026
f902bad
Merge branch 'feat/mobile-shell-pages' into feat/mobile-shell
ymote Sep 14, 2026
f0cfd99
Merge branch 'feat/mobile-shell-island' into feat/mobile-shell
ymote Sep 14, 2026
8e63b68
Merge branch 'feat/mobile-shell-groups' into feat/mobile-shell
ymote Sep 14, 2026
40297e7
mobile: exclusion zones are cleared once per frame and every surface …
ymote Sep 14, 2026
2b365cf
mobile: the island's docked activities land in the shade, and the isl…
ymote Sep 14, 2026
1cee297
mobile: the closed shade's tap targets sit under the island, the pull…
ymote Sep 14, 2026
10d40f8
mobile: the shade no longer imports what only its tests use
ymote Sep 14, 2026
7119a83
mobile: a gesture commit reaches the surfaces once, and a swipe up ov…
ymote Sep 14, 2026
de33b65
mobile: the island-to-shade hand-off is one pure step, with a test
ymote Sep 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions apps/appcard/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Box<dyn ActivityReporter>> = std::sync::OnceLock::new();

/// Install the host's reporter, once. False when one is already there.
pub fn set_activity_reporter(reporter: Box<dyn ActivityReporter>) -> 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<String> {
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" }
Expand Down Expand Up @@ -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")
Expand Down
6 changes: 6 additions & 0 deletions src/clients.rs
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,10 @@ pub struct ClientSlot {
pub closing: Option<f64>,
/// 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 {
Expand All @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down
4 changes: 2 additions & 2 deletions src/desk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ClientId, PhoneFrame>,
#[rust] desktop_frames: HashMap<ClientId, WindowFrame>,
#[rust] pub wallpaper: WidgetRef,
#[rust] compositor: Option<BackdropCompositor>,
#[rust] pub compositor: Option<BackdropCompositor>,
#[rust] composing: bool,
#[rust] terminal_clients: HashSet<ClientId>,
#[rust] blur_counts: (usize, usize),
Expand Down
65 changes: 57 additions & 8 deletions src/desk/phone.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<ClientId>,String,bool)>=layout.tiles.iter().map(|slot| {
Expand All @@ -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;
Expand All @@ -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);
Expand All @@ -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;
}
}
Expand All @@ -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::<WmState>().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<ClientId>=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();
Expand Down Expand Up @@ -214,21 +243,25 @@ 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::<WmState>().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);
let backdrop=self.compositor.as_mut().unwrap().backdrop(cx,screen,blur as f64);
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<Rect>=Vec::new();
let mut order=phone.order.clone();
order.reverse();
// Foreground paints last during launch/return transitions.
if phone.overview<0.001 {
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);
Expand Down Expand Up @@ -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::<WmState>().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::<WmState>().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);}
}
}
Loading