diff --git a/Cargo.lock b/Cargo.lock index ff37d2ed..b6631ecb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7037,7 +7037,7 @@ dependencies = [ [[package]] name = "octos-agent" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=39e22d457c47df57d7c7c9fa64539979c9da93fd#39e22d457c47df57d7c7c9fa64539979c9da93fd" +source = "git+https://github.com/octos-org/octos.git?rev=b0759a57719fd35b3a2da1c5d969bc67538ed516#b0759a57719fd35b3a2da1c5d969bc67538ed516" dependencies = [ "async-trait", "base64 0.22.1", @@ -7167,7 +7167,7 @@ dependencies = [ [[package]] name = "octos-bus" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=39e22d457c47df57d7c7c9fa64539979c9da93fd#39e22d457c47df57d7c7c9fa64539979c9da93fd" +source = "git+https://github.com/octos-org/octos.git?rev=b0759a57719fd35b3a2da1c5d969bc67538ed516#b0759a57719fd35b3a2da1c5d969bc67538ed516" dependencies = [ "aes 0.8.4", "async-trait", @@ -7196,7 +7196,7 @@ dependencies = [ [[package]] name = "octos-cli" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=39e22d457c47df57d7c7c9fa64539979c9da93fd#39e22d457c47df57d7c7c9fa64539979c9da93fd" +source = "git+https://github.com/octos-org/octos.git?rev=b0759a57719fd35b3a2da1c5d969bc67538ed516#b0759a57719fd35b3a2da1c5d969bc67538ed516" dependencies = [ "agent-client-protocol", "argon2", @@ -7277,7 +7277,7 @@ dependencies = [ [[package]] name = "octos-core" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=39e22d457c47df57d7c7c9fa64539979c9da93fd#39e22d457c47df57d7c7c9fa64539979c9da93fd" +source = "git+https://github.com/octos-org/octos.git?rev=b0759a57719fd35b3a2da1c5d969bc67538ed516#b0759a57719fd35b3a2da1c5d969bc67538ed516" dependencies = [ "chrono", "eyre", @@ -7291,7 +7291,7 @@ dependencies = [ [[package]] name = "octos-diagnostics" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=39e22d457c47df57d7c7c9fa64539979c9da93fd#39e22d457c47df57d7c7c9fa64539979c9da93fd" +source = "git+https://github.com/octos-org/octos.git?rev=b0759a57719fd35b3a2da1c5d969bc67538ed516#b0759a57719fd35b3a2da1c5d969bc67538ed516" dependencies = [ "eyre", "octos-core", @@ -7303,7 +7303,7 @@ dependencies = [ [[package]] name = "octos-fleet" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=39e22d457c47df57d7c7c9fa64539979c9da93fd#39e22d457c47df57d7c7c9fa64539979c9da93fd" +source = "git+https://github.com/octos-org/octos.git?rev=b0759a57719fd35b3a2da1c5d969bc67538ed516#b0759a57719fd35b3a2da1c5d969bc67538ed516" dependencies = [ "eyre", "octos-core", @@ -7319,7 +7319,7 @@ dependencies = [ [[package]] name = "octos-fleet-worker" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=39e22d457c47df57d7c7c9fa64539979c9da93fd#39e22d457c47df57d7c7c9fa64539979c9da93fd" +source = "git+https://github.com/octos-org/octos.git?rev=b0759a57719fd35b3a2da1c5d969bc67538ed516#b0759a57719fd35b3a2da1c5d969bc67538ed516" dependencies = [ "async-trait", "eyre", @@ -7336,7 +7336,7 @@ dependencies = [ [[package]] name = "octos-llm" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=39e22d457c47df57d7c7c9fa64539979c9da93fd#39e22d457c47df57d7c7c9fa64539979c9da93fd" +source = "git+https://github.com/octos-org/octos.git?rev=b0759a57719fd35b3a2da1c5d969bc67538ed516#b0759a57719fd35b3a2da1c5d969bc67538ed516" dependencies = [ "async-trait", "base64 0.22.1", @@ -7360,7 +7360,7 @@ dependencies = [ [[package]] name = "octos-memory" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=39e22d457c47df57d7c7c9fa64539979c9da93fd#39e22d457c47df57d7c7c9fa64539979c9da93fd" +source = "git+https://github.com/octos-org/octos.git?rev=b0759a57719fd35b3a2da1c5d969bc67538ed516#b0759a57719fd35b3a2da1c5d969bc67538ed516" dependencies = [ "bincode", "chrono", @@ -7379,7 +7379,7 @@ dependencies = [ [[package]] name = "octos-pipeline" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=39e22d457c47df57d7c7c9fa64539979c9da93fd#39e22d457c47df57d7c7c9fa64539979c9da93fd" +source = "git+https://github.com/octos-org/octos.git?rev=b0759a57719fd35b3a2da1c5d969bc67538ed516#b0759a57719fd35b3a2da1c5d969bc67538ed516" dependencies = [ "async-trait", "chrono", @@ -7401,7 +7401,7 @@ dependencies = [ [[package]] name = "octos-plugin" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=39e22d457c47df57d7c7c9fa64539979c9da93fd#39e22d457c47df57d7c7c9fa64539979c9da93fd" +source = "git+https://github.com/octos-org/octos.git?rev=b0759a57719fd35b3a2da1c5d969bc67538ed516#b0759a57719fd35b3a2da1c5d969bc67538ed516" dependencies = [ "eyre", "metrics", @@ -7415,7 +7415,7 @@ dependencies = [ [[package]] name = "octos-research" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=39e22d457c47df57d7c7c9fa64539979c9da93fd#39e22d457c47df57d7c7c9fa64539979c9da93fd" +source = "git+https://github.com/octos-org/octos.git?rev=b0759a57719fd35b3a2da1c5d969bc67538ed516#b0759a57719fd35b3a2da1c5d969bc67538ed516" dependencies = [ "base64 0.22.1", "chromiumoxide", @@ -7439,7 +7439,7 @@ dependencies = [ [[package]] name = "octos-sandbox" version = "0.1.0" -source = "git+https://github.com/octos-org/octos.git?rev=39e22d457c47df57d7c7c9fa64539979c9da93fd#39e22d457c47df57d7c7c9fa64539979c9da93fd" +source = "git+https://github.com/octos-org/octos.git?rev=b0759a57719fd35b3a2da1c5d969bc67538ed516#b0759a57719fd35b3a2da1c5d969bc67538ed516" dependencies = [ "clap", "eyre", @@ -7454,7 +7454,7 @@ dependencies = [ [[package]] name = "octos-services" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=39e22d457c47df57d7c7c9fa64539979c9da93fd#39e22d457c47df57d7c7c9fa64539979c9da93fd" +source = "git+https://github.com/octos-org/octos.git?rev=b0759a57719fd35b3a2da1c5d969bc67538ed516#b0759a57719fd35b3a2da1c5d969bc67538ed516" dependencies = [ "chrono", "dirs", @@ -7476,7 +7476,7 @@ dependencies = [ [[package]] name = "octos-store" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=39e22d457c47df57d7c7c9fa64539979c9da93fd#39e22d457c47df57d7c7c9fa64539979c9da93fd" +source = "git+https://github.com/octos-org/octos.git?rev=b0759a57719fd35b3a2da1c5d969bc67538ed516#b0759a57719fd35b3a2da1c5d969bc67538ed516" dependencies = [ "base64 0.22.1", "chrono", @@ -7496,7 +7496,7 @@ dependencies = [ [[package]] name = "octos-swarm" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=39e22d457c47df57d7c7c9fa64539979c9da93fd#39e22d457c47df57d7c7c9fa64539979c9da93fd" +source = "git+https://github.com/octos-org/octos.git?rev=b0759a57719fd35b3a2da1c5d969bc67538ed516#b0759a57719fd35b3a2da1c5d969bc67538ed516" dependencies = [ "async-trait", "chrono", @@ -7515,7 +7515,7 @@ dependencies = [ [[package]] name = "octos-workflows" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=39e22d457c47df57d7c7c9fa64539979c9da93fd#39e22d457c47df57d7c7c9fa64539979c9da93fd" +source = "git+https://github.com/octos-org/octos.git?rev=b0759a57719fd35b3a2da1c5d969bc67538ed516#b0759a57719fd35b3a2da1c5d969bc67538ed516" dependencies = [ "chrono", "eyre", @@ -7648,7 +7648,7 @@ dependencies = [ [[package]] name = "octosense-app-hub" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=f762267864fc3d68631b46924899cff324f7d157#f762267864fc3d68631b46924899cff324f7d157" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=d2ca3a30ce06b0b1390cff305520962731baa1f8#d2ca3a30ce06b0b1390cff305520962731baa1f8" dependencies = [ "base64 0.22.1", "blake3", @@ -7667,7 +7667,7 @@ dependencies = [ [[package]] name = "octosense-app-hub-app" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=f762267864fc3d68631b46924899cff324f7d157#f762267864fc3d68631b46924899cff324f7d157" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=d2ca3a30ce06b0b1390cff305520962731baa1f8#d2ca3a30ce06b0b1390cff305520962731baa1f8" dependencies = [ "makepad-app-module", "makepad-widgets", @@ -7695,7 +7695,7 @@ dependencies = [ [[package]] name = "octosense-app-policy" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=f762267864fc3d68631b46924899cff324f7d157#f762267864fc3d68631b46924899cff324f7d157" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=d2ca3a30ce06b0b1390cff305520962731baa1f8#d2ca3a30ce06b0b1390cff305520962731baa1f8" dependencies = [ "makepad-widgets", "octosense-app-contract", @@ -7717,7 +7717,7 @@ dependencies = [ [[package]] name = "octosense-appstore" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=f762267864fc3d68631b46924899cff324f7d157#f762267864fc3d68631b46924899cff324f7d157" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=d2ca3a30ce06b0b1390cff305520962731baa1f8#d2ca3a30ce06b0b1390cff305520962731baa1f8" dependencies = [ "makepad-app-module", "makepad-widgets", diff --git a/Cargo.toml b/Cargo.toml index be99af31..77c8a1b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,7 +71,7 @@ octosense-reference = { path = "apps/reference" } makepad-sheets = { git = "https://github.com/OctoSense-org/makepad.git", rev = "68d1f4ecc111daa90c50530e77df3245f05fc2cf", default-features = false } makepad-terminal = { git = "https://github.com/OctoSense-org/makepad.git", rev = "68d1f4ecc111daa90c50530e77df3245f05fc2cf" } octosense-appcard = { path = "apps/appcard/module" } -octosense-app-hub-app = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "f762267864fc3d68631b46924899cff324f7d157" } +octosense-app-hub-app = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "d2ca3a30ce06b0b1390cff305520962731baa1f8" } makepad-calculator = { git = "https://github.com/OctoSense-org/makepad.git", rev = "68d1f4ecc111daa90c50530e77df3245f05fc2cf", default-features = false } makepad-clock = { git = "https://github.com/OctoSense-org/makepad.git", rev = "68d1f4ecc111daa90c50530e77df3245f05fc2cf", default-features = false } makepad-notes = { git = "https://github.com/OctoSense-org/makepad.git", rev = "68d1f4ecc111daa90c50530e77df3245f05fc2cf", default-features = false } @@ -106,19 +106,22 @@ octoscript-makepad = { git = "https://github.com/OctoSense-org/Octoscript-Makepa octoscript-node = { git = "https://github.com/OctoSense-org/Octoscript-Makepad.git", rev = "aa80f72c509c767faf04822b939d44b2f34fbc81" } # --- App Hub's host service registry the Mail and llm services register # into; the same revision as App Hub's app crate (a native app, above). -octosense-appstore = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "f762267864fc3d68631b46924899cff324f7d157" } +octosense-appstore = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "d2ca3a30ce06b0b1390cff305520962731baa1f8" } # The app policy (resolution, isolate settings, the Splash adapter) and the # Hub client the Card runner resolves an app's policy with; the shell's # glance tiles run cards under it (crates/shell/src/glance_card.rs). -octosense-app-policy = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "f762267864fc3d68631b46924899cff324f7d157" } -octosense-app-hub = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "f762267864fc3d68631b46924899cff324f7d157" } +octosense-app-policy = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "d2ca3a30ce06b0b1390cff305520962731baa1f8" } +octosense-app-hub = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "d2ca3a30ce06b0b1390cff305520962731baa1f8" } # The app contract (ADR 0005), from crates.io by version: App Hub, the shell # and Rinx all depend on `octosense-app-contract = "1"`, so Cargo links ONE # 1.x for the whole build (Cargo.lock pins the exact version OctoSense ships) # and App Hub and Rinx no longer need the same App Hub commit. octosense-app-contract = "1" -# --- octos: ONE source for every octos crate (octos-org/octos main at -# 39e22d45, after #2709 and #2704, a deleted sub-account's or profile's +# --- octos: ONE source for every octos crate (reviewed octos#2715 at +# b0759a57: bounded ledger session-directory names, serialized peer purge +# against session writes, and provider transport diagnostics that omit +# credentials, request bodies and URL queries. +# Based on 39e22d45, after #2709 and #2704, a deleted sub-account's or profile's # keychain items released; #2697 and #2695, peer/purge residue kept on its # tombstone; #2692, a stdio turn's terminal after its queued envelopes; # #2677, option-like revisions refused in workspace history show/diff; @@ -140,12 +143,12 @@ octosense-app-contract = "1" # keeps its peer/input, and #2645, Bing News's Chinese results). # Move octos-core, octos-cli and octos-research together; the Android kernel # artifact (liboctos.so) is built from this same revision. -octos-core = { git = "https://github.com/octos-org/octos.git", rev = "39e22d457c47df57d7c7c9fa64539979c9da93fd" } -octos-cli = { git = "https://github.com/octos-org/octos.git", rev = "39e22d457c47df57d7c7c9fa64539979c9da93fd", default-features = false } +octos-core = { git = "https://github.com/octos-org/octos.git", rev = "b0759a57719fd35b3a2da1c5d969bc67538ed516" } +octos-cli = { git = "https://github.com/octos-org/octos.git", rev = "b0759a57719fd35b3a2da1c5d969bc67538ed516", default-features = false } # The research engine (the metasearch, the SSRF-safe polite reader and the # toolbox scope) for the toolbox's `octos-engine` feature. Same source and # revision as above. -octos-research = { git = "https://github.com/octos-org/octos.git", rev = "39e22d457c47df57d7c7c9fa64539979c9da93fd", default-features = false } +octos-research = { git = "https://github.com/octos-org/octos.git", rev = "b0759a57719fd35b3a2da1c5d969bc67538ed516", default-features = false } # --- AppCard's inline renderers. streaming-markdown-kit = { git = "https://github.com/Project-Robius-China/streaming-markdown-kit.git", branch = "main" } makepad-diagram-kit = { git = "https://github.com/Project-Robius-China/makepad-diagram-kit.git", branch = "main" } diff --git a/apps/ai-providers/host-service/src/sheets.rs b/apps/ai-providers/host-service/src/sheets.rs index e0d6e55d..2a6205b0 100644 --- a/apps/ai-providers/host-service/src/sheets.rs +++ b/apps/ai-providers/host-service/src/sheets.rs @@ -26,7 +26,7 @@ let Field = TextInput{width: Fill height: 40 border_color: #x00000000 border_color_hover: #x00000000 border_color_focus: #x007aff border_color_empty: #x00000000 border_radius: 10.0} draw_text +: {color: #x1c1c1e color_hover: #x1c1c1e color_focus: #x1c1c1e color_empty: #x8e8e93 color_empty_hover: #x8e8e93} } -let Caption = Label{text: "" draw_text.color: #x8e8e93 draw_text.text_style.font_size: 11} +let Caption = Label{width: Fill height: Fit flow: Right{wrap: true} text: "" draw_text.color: #x8e8e93 draw_text.text_style.font_size: 11} let Note = Label{width: Fill text: "" draw_text.color: #x3a3a3c draw_text.text_style.font_size: 12} let Choice = ButtonFlat{height: 32 width: Fill draw_bg +: {border_radius: 8.0 color: #xf2f2f7 color_focus: #xf2f2f7 color_hover: #xe5e5ea color_down: #xd1d1d6 border_size: 0.0} diff --git a/apps/calendar/bundle/main.splash b/apps/calendar/bundle/main.splash index 20bbe861..d2e1701d 100644 --- a/apps/calendar/bundle/main.splash +++ b/apps/calendar/bundle/main.splash @@ -266,7 +266,7 @@ SolidView{width: Fill height: Fill flow: Overlay align: Align{x: 0.5} draw_bg.co Primary{text: "Edit" on_click: || edit_existing()} } ScrollYView{width: Fill height: Fill flow: Down padding: 20 spacing: 18 - event_title := Label{width: Fill text: "" draw_text.color: ink draw_text.text_style: theme.font_bold{font_size: 28}} + event_title := UiTitle{text: ""} SolidView{width: Fill height: Fit flow: Down padding: 18 spacing: 8 draw_bg.color: ui_field Caption{text: "WHEN"} event_when := Label{width: Fill text: "" draw_text.color: accent draw_text.text_style: theme.font_bold{font_size: 21}} diff --git a/apps/camera/bundle/main.splash b/apps/camera/bundle/main.splash index da889182..ef017b8c 100644 --- a/apps/camera/bundle/main.splash +++ b/apps/camera/bundle/main.splash @@ -70,6 +70,9 @@ fn start_recording(){ } fn stop_recording(){ ui.cam.record_stop() + settle_recording() +} +fn settle_recording(){ recording = false if rec_timer != nil { stop_timer(rec_timer); rec_timer = nil } ui.rec_box.set_visible(false) @@ -105,7 +108,17 @@ fn open_viewer(){ ui.viewer.set_visible(true) } fn close_viewer(){ ui.viewer.set_visible(false) } -fn failed(){ ui.toast.set_text(ui.cam.error()) } +fn failed(){ + // Recording starts asynchronously. A failed native request clears the + // camera's recording state; stop its UI timer without issuing another + // recording request. An unrelated error must not stop a healthy recording. + if recording && !ui.cam.is_recording() { settle_recording() } + ui.toast.set_text(ui.cam.error()) ui.retry_camera.set_visible(true) +} +fn retry_camera(){ + ui.toast.set_text("") ui.retry_camera.set_visible(false) + ui.cam.start() +} fn boot(){ load_shots() show_thumb() @@ -140,6 +153,7 @@ SolidView{width: Fill height: Fill flow: Overlay draw_bg.color: #000000 } } toast := Label{width: Fill align: Align{x: 0.5} padding: 8 text: "" draw_text.color: #xffd60a draw_text.text_style.font_size: 14} + retry_camera := Chip{visible: false text: "Retry camera" on_click: || retry_camera()} View{width: Fill height: Fill} zooms := View{width: Fill height: Fit flow: Right spacing: 8 align: Align{x: 0.5} padding: Inset{bottom: 12} on_render: || { for z in [1 2 5] { diff --git a/apps/maps/bundle/main.splash b/apps/maps/bundle/main.splash index 25474a08..f12e0ed7 100644 --- a/apps/maps/bundle/main.splash +++ b/apps/maps/bundle/main.splash @@ -71,6 +71,7 @@ fn fix_origin(){ if origin.picked { return } if sys.gps("ok") >= 1 { origin = {lat: sys.gps("lat") lon: sys.gps("lon") name: "Your location" picked: false} + location_note("") } } fn load_recents(){ @@ -114,6 +115,7 @@ fn pick(p){ } if finding == "origin" { origin = {lat: p.lat lon: p.lon name: p.name picked: true} + location_note("") finding = "" show("route") return @@ -123,8 +125,17 @@ fn pick(p){ remember(p) show("place") } +fn location_note(message){ + ui.location_status.set_text(message) + ui.location_status.set_visible(message != "") +} fn use_my_location(){ - origin = {lat: origin.lat lon: origin.lon name: "Your location" picked: false} + let request = sys.request_location() + // Keep the previous, honestly named origin until a real fix arrives. + origin = {lat: origin.lat lon: origin.lon name: origin.name picked: false} + if request < 0 { location_note("This app does not have location access. Choose an origin instead.") } + else if request == 0 { location_note("Location requests are unavailable here. Choose an origin instead.") } + else { location_note("Waiting for location. If access is denied, enable it in Android settings or choose an origin.") } fix_origin() finding = "" show("route") @@ -340,6 +351,7 @@ View{width: Fill height: Fill flow: Overlay show_bg: true draw_bg.color: #xe8eae rfrom := Label{width: Fill text: "" max_lines: 1 draw_text.color: ink draw_text.text_style.font_size: 14} Link{text: "Change" on_click: || find_for("origin")} } + location_status := UiCaption{visible: false text: ""} stop_rows := View{width: Fill height: Fit flow: Down on_render: || { for i st in stops { View{width: Fill height: Fit flow: Right align: Align{y: 0.5} diff --git a/apps/youtube/bundle/main.splash b/apps/youtube/bundle/main.splash index 98fdd417..45feeef1 100644 --- a/apps/youtube/bundle/main.splash +++ b/apps/youtube/bundle/main.splash @@ -106,6 +106,7 @@ fn search_for(text){ let t = ("" + text).trim() if t == "" { return } q = t + ui.search.set_text(q) loaded_q = "" failed = false hits = [] @@ -182,6 +183,7 @@ fn clear_history(){ fn boot(){ load_state() + ui.search.set_text(q) ui.tabs.render() sync_status() ui.list.render() @@ -229,10 +231,7 @@ SolidView{width: Fill height: Fill flow: Overlay align: Align{x: 0.5} show_bg: t View{width: Fill height: Fit padding: Inset{left: 12 right: 12 bottom: 8} search := UiField{width: Fill height: 48 empty_text: "Search YouTube" on_return: |text| search_for(text) - draw_bg +: {color: #x222222 color_hover: #x222222 color_focus: #x222222 color_empty: #x222222 - border_color: ui_field border_color_hover: ui_field border_color_focus: #x3ea6ff border_color_empty: ui_field - border_radius: 20.0} - draw_text +: {color: ink color_hover: ink color_focus: ink color_empty: secondary color_empty_hover: secondary} + draw_bg.border_radius: 20.0 } } tabs := ScrollXView{width: Fill height: 60 flow: Right spacing: 8 padding: Inset{left: 12 right: 12 top: 2 bottom: 10} diff --git a/crates/ai-host/src/tests.rs b/crates/ai-host/src/tests.rs index 22ed30f6..5d2ff855 100644 --- a/crates/ai-host/src/tests.rs +++ b/crates/ai-host/src/tests.rs @@ -182,3 +182,43 @@ fn start_is_once_and_starts_no_kernel() { shutdown(); assert!(!kernel_running()); } + +/// A host caption must have a width bound: Fit-width text cannot wrap even +/// when the Label prototype's native flow permits wrapping. +#[cfg(feature = "llm")] +#[test] +fn provider_import_caption_wraps_inside_a_phone_sheet() { + use makepad_widgets::makepad_draw::cx_draw::CxDraw; + let sheet = octosense_llm_service::sheets::import(false, true, false); + let caption = sheet.split_once("let Caption = ").unwrap().1.split_once('\n').unwrap().0; + let text = "Choose image reads the code from a screenshot or photo."; + let mut cx = Cx::new(Box::new(|_, _| {})); + let root = cx.with_vm(|vm| { + makepad_widgets::script_mod(vm); + desktop_style::install(vm, desktop_style::StyleSheet::load(desktop_style::DesktopStyle::Android)); + vm.with_reload(makepad_widgets::script_mod); + vm.bx.captured_errors = Some(Vec::new()); + let value = vm.eval(ScriptMod {code:format!("use mod.prelude.widgets.*\nlet Caption = {caption}\nView{{width: Fill height: Fit padding: 16 flow: Down caption := Caption{{text: \"{text}\"}}}}"),..Default::default()}); + let root = WidgetRef::script_from_value(vm,value); + assert!(vm.take_errors().is_empty()); + root + }); + let pass = DrawPass::new(&mut cx); let mut list = DrawList2d::new(&mut cx); + for width in [280.,340.,430.] { + let size = dvec2(width,180.); pass.set_size(&mut cx,size); + let event = DrawEvent::default(); + { + let mut draw = CxDraw::new(&mut cx,&event); let mut draw = Cx2d::new(&mut draw); + draw.begin_pass(&pass,Some(1.)); list.begin_always(&mut draw); draw.begin_root_turtle(size,Layout::default()); + root.draw_walk_all(&mut draw,&mut Scope::empty(),Walk::fixed(size.x,size.y)); + draw.end_turtle(); list.end(&mut draw); draw.end_pass(&pass); + } + let caption = root.label(&cx,ids!(caption)); + assert_eq!(caption.text(),text); + let area = caption.area().rect(&cx); + let text_rect = caption.borrow().unwrap().text_layout_rect; + assert!(area.size.x <= width-32.+0.1 && area.pos.x>=16.); + assert!(text_rect.size.x <= area.size.x+0.5,"whole instruction fits at {width}: {text_rect:?}/{area:?}"); + if width==280. { assert!(area.size.y>20.,"narrow caption takes multiple lines"); } + } +} diff --git a/crates/shell/src/approvals/view.rs b/crates/shell/src/approvals/view.rs index 0be4188a..7aa40730 100644 --- a/crates/shell/src/approvals/view.rs +++ b/crates/shell/src/approvals/view.rs @@ -61,6 +61,101 @@ const ARG_LINE_H: f64 = 17.0; /// Rows the argument area shows at once; more scroll. pub const MAX_ARG_ROWS: usize = 12; const INDICATOR_H: f64 = 30.0; +const CONSENT_INTRO: &str = "The first time an app asks for its agent, you decide. You can change it in Settings."; + +/// First-use disclosures scroll independently of the fixed decision row. +/// The drag is owned from its start in the body until release, so lifting +/// a scrolling finger over Allow cannot answer the consent prompt. +#[derive(Default)] +struct ConsentScroll { + app: String, + area: Option, + offset: f64, + max: f64, + touch: Option<(u64, Vec2d)>, +} + +impl ConsentScroll { + fn layout(&mut self, app: &str, area: Rect, content_height: f64) { + if self.app != app { + *self = Self { app: app.to_string(), ..Self::default() }; + } + self.area = Some(area); + self.max = (content_height - area.size.y).max(0.0); + self.offset = self.offset.clamp(0.0, self.max); + } + + fn scroll(&mut self, delta: f64) { + self.offset = (self.offset + delta).clamp(0.0, self.max); + } + + fn touch(&mut self, point: &makepad_platform::event::TouchPoint) -> bool { + use makepad_platform::event::TouchState; + match point.state { + TouchState::Start if self.touch.is_none() && self.area.is_some_and(|r| contains(r, point.abs)) => { + self.touch = Some((point.uid, point.abs)); + true + } + TouchState::Move | TouchState::Stop if self.touch.is_some_and(|(uid, _)| uid == point.uid) => { + let (_, previous) = self.touch.unwrap(); + self.scroll(previous.y - point.abs.y); + self.touch = if point.state == TouchState::Stop { None } else { Some((point.uid, point.abs)) }; + true + } + _ => false, + } + } +} + +/// The natural height grows with wrapped text; overflow stays above the +/// decision row even on a short phone viewport. Leave a scrollbar gutter. +fn consent_rects(screen: Rect, content_height: f64) -> (Rect, Rect, Rect) { + let card = ShellApprovals::card_rect(screen, PAD * 2.0 + content_height + 12.0 + BUTTON_H); + let footer = rect(card.pos.x + PAD, card.pos.y + card.size.y - PAD - BUTTON_H, card.size.x - PAD * 2.0, BUTTON_H); + let body = rect(footer.pos.x, card.pos.y + PAD, footer.size.x - 10.0, (footer.pos.y - 12.0 - card.pos.y - PAD).max(0.0)); + (card, body, footer) +} + +struct ConsentRow { + text: String, + bold: bool, + px: f64, + opacity: f32, + line_height: f64, + height: f64, +} + +fn consent_rows( + summary: &AgentSummary, + font: crate::shell::FontTokens, + scale: f64, + width: f64, + mut wrap: impl FnMut(bool, f64, &str, f64) -> Vec, +) -> Vec { + let mut rows = Vec::new(); + let mut block = |text: &str, bold: bool, px: f64, opacity: f32, gap: f64| { + let line_height = (px * 1.4).max(20.0) * scale; + for text in wrap(bold, px * scale, text, width) { + rows.push(ConsentRow { text, bold, px, opacity, line_height, height: line_height }); + } + if let Some(last) = rows.last_mut() { + last.height += gap; + } + }; + block(&format!("Let {}'s agent start?", summary.name), true, font.heading, 1.0, 4.0); + block(CONSENT_INTRO, false, font.body_small, 0.85, 12.0); + block("It may read", true, font.body, 1.0, 0.0); + for read in &summary.reads { + block(&format!("\u{2022} {read}"), false, font.body, 0.85, 0.0); + } + block("It may use", true, font.body, 1.0, 0.0); + for usage in &summary.uses { + block(&format!("\u{2022} {usage}"), false, font.body, 0.85, 0.0); + } + block("Where the model runs", true, font.body, 1.0, 0.0); + block(&summary.model, false, font.body, 0.85, 0.0); + rows +} /// What a press lands on. #[derive(Clone, Debug, PartialEq)] @@ -220,6 +315,8 @@ pub struct ShellApprovals { /// Each open line's wrapped rows, and the width they were wrapped to. #[rust] wrapped: HashMap)>, + #[rust] + consent_scroll: ConsentScroll, } impl ShellApprovals { @@ -244,7 +341,23 @@ impl ShellApprovals { /// The shell's pointer hook (`approvals::pointer`). True when taken. pub fn pointer(&mut self, cx: &mut Cx, event: &Event) -> bool { + if let Event::TouchUpdate(update) = event { + let mut consumed = false; + for point in &update.touches { + consumed |= self.consent_scroll.touch(point); + } + if consumed { + self.down = None; + self.redraw(cx); + return true; + } + } if let Event::Scroll(e) = event { + if self.consent_scroll.area.is_some_and(|r| contains(r, e.abs)) { + self.consent_scroll.scroll(e.scroll.y); + self.redraw(cx); + return true; + } let over = self.arg_boxes.iter().find(|(r, ..)| contains(*r, e.abs)).map(|(_, id, ..)| id.clone()); if let Some(id) = over { let rows = (e.scroll.y / ARG_LINE_H).round() as isize; @@ -298,8 +411,12 @@ impl ShellApprovals { fn draw_all(&mut self, cx: &mut Cx2d, screen: Rect) { self.hits.clear(); self.shown.clear(); + self.arg_boxes.clear(); self.modal = false; let f = frame(); + if f.consent.is_none() { + self.consent_scroll = ConsentScroll::default(); + } let tok = self.d.tokens(self.tokens); self.draw_indicator(cx, screen, &f, tok); self.draw_expired(cx, screen, &f, tok); @@ -586,45 +703,48 @@ impl ShellApprovals { fn draw_consent(&mut self, cx: &mut Cx2d, screen: Rect, s: &AgentSummary, tok: ShellTokens) { self.scrim(cx, screen); - let rows = 2 + s.reads.len() + 1 + s.uses.len() + 1 + 1; - let card = Self::card_rect(screen, PAD * 2.0 + 26.0 + 20.0 + 12.0 + rows as f64 * 20.0 + 12.0 + BUTTON_H); + let width = consent_rects(screen, 0.0).1.size.x; + let scale = self.d.text_scale(); + let rows = consent_rows(s, tok.font, scale, width, |bold, px, text, width| { + self.d.wrap_lines(cx, bold, px, text, width, true) + }); + let content_height = rows.iter().map(|r| r.height).sum::(); + let (card, body, footer) = consent_rects(screen, content_height); + self.consent_scroll.layout(&s.app, body, content_height); self.d.card(cx, card, &tok.popups); self.hits.push((card, Hit::Card)); let ink = tok.popups.text; - let dim = alpha(ink, 0.65); - let x = card.pos.x + PAD; - let w = card.size.x - PAD * 2.0; - let mut y = card.pos.y + PAD; - let title = format!("Let {}'s agent start?", s.name); - self.d.label_elided(cx, rect(x, y, w, 24.0), true, tok.font.heading, ink, HAlign::Left, &title); - y += 26.0; - self.d.label_elided(cx, rect(x, y, w, 18.0), false, tok.font.body_small, dim, HAlign::Left, "The first time an app asks for its agent, you decide. You can change it in Settings."); - y += 20.0 + 12.0; - let row = |d: &mut ShellDraw, cx: &mut Cx2d, y: &mut f64, bold: bool, text: &str| { - d.label_elided(cx, rect(x, *y, w, 18.0), bold, tok.font.body, if bold { ink } else { alpha(ink, 0.85) }, HAlign::Left, text); - *y += 20.0; - }; - row(&mut self.d, cx, &mut y, true, "It may read"); - for r in &s.reads { - row(&mut self.d, cx, &mut y, false, &format!("\u{2022} {r}")); + let mut y = body.pos.y - self.consent_scroll.offset; + cx.push_clip_rect(body); + for row in rows { + if y + row.height > body.pos.y && y < body.pos.y + body.size.y { + // Measured at the same scaled font size as label(): no + // elision, including long provider/account descriptions. + self.d.label(cx, rect(body.pos.x, y, body.size.x, row.line_height), row.bold, row.px, alpha(ink, row.opacity), HAlign::Left, &row.text); + } + y += row.height; } - row(&mut self.d, cx, &mut y, true, "It may use"); - for u in &s.uses { - row(&mut self.d, cx, &mut y, false, &format!("\u{2022} {u}")); + cx.pop_clip_rect(); + if self.consent_scroll.max > 0.0 { + let track = rect(body.pos.x + body.size.x + 5.0, body.pos.y, 3.0, body.size.y); + let thumb_h = (body.size.y * body.size.y / content_height).max(20.0).min(body.size.y); + let thumb_y = body.pos.y + (body.size.y - thumb_h) * self.consent_scroll.offset / self.consent_scroll.max; + self.d.solid(cx, track, alpha(ink, 0.10)); + self.d.solid(cx, rect(track.pos.x, thumb_y, track.size.x, thumb_h), alpha(ink, 0.40)); } - row(&mut self.d, cx, &mut y, true, "Where the model runs"); - row(&mut self.d, cx, &mut y, false, &s.model); - y += 12.0; let mut buttons = Buttons { d: &mut self.d, tok, hover: self.hover }; - let allow = buttons.draw(cx, x, y, w, "Allow", true); - let deny = buttons.draw(cx, x + allow.size.x + 8.0, y, w, "Don't allow", false); + let allow = buttons.draw(cx, footer.pos.x, footer.pos.y, (footer.size.x - 8.0) * 0.5, "Allow", true); + let deny_x = allow.pos.x + allow.size.x + 8.0; + let deny = buttons.draw(cx, deny_x, footer.pos.y, footer.pos.x + footer.size.x - deny_x, "Don't allow", false); self.hits.push((allow, Hit::Consent { app: s.app.clone(), allow: true })); self.hits.push((deny, Hit::Consent { app: s.app.clone(), allow: false })); - self.shown.push(title); + self.shown.push(format!("Let {}'s agent start?", s.name)); + self.shown.push(CONSENT_INTRO.to_string()); self.shown.extend(s.reads.iter().cloned()); self.shown.extend(s.uses.iter().cloned()); self.shown.push(s.model.clone()); } + } /// How many of a line's `total` rows the person has seen. @@ -637,6 +757,69 @@ fn stop_label(app: &str) -> String { format!("Stop {}'s agent", app_label(app)) } +#[cfg(test)] +mod consent_layout_tests { + use super::*; + use makepad_platform::event::{TouchPoint, TouchState}; + + fn point(uid: u64, state: TouchState, abs: Vec2d) -> TouchPoint { + TouchPoint { uid, state, abs, time: 0.0, rotation_angle: 0.0, force: 0.0, radius: dvec2(1.0, 1.0), handled: Default::default(), sweep_lock: Default::default() } + } + + #[test] + fn consent_wraps_complete_disclosures_and_keeps_phone_decisions_below_content() { + let summary = AgentSummary { + app: "test.consent".into(), name: "A long connected application name".into(), + reads: vec!["Account-specific documents and saved drafts for this account only".into()], + uses: vec!["Tools from another application, subject to its grant and approval policy".into()], + model: "The provider configured by the person in AI providers; private tool results may be sent to that provider".into(), + }; + for (width, height, scale) in [(320.0, 480.0, 1.0), (360.0, 640.0, 1.25), (640.0, 320.0, 1.25), (1000.0, 1000.0, 1.0)] { + let screen = rect(0.0, 0.0, width, height); + let body_width = consent_rects(screen, 0.0).1.size.x; + let rows = consent_rows(&summary, crate::shell::FontTokens::default(), scale, body_width, |_, px, text, width| { + crate::shell::ui::wrap_with(text, width, true, |s| s.chars().count() as f64 * px * 0.6) + }); + let text = rows.iter().map(|r| r.text.as_str()).collect::>().join(" "); + let normalized = |s: &str| s.split_whitespace().collect::>().join(" "); + for disclosure in [CONSENT_INTRO, &summary.reads[0], &summary.uses[0], &summary.model] { + assert!(normalized(&text).contains(&normalized(disclosure)), "a disclosure was elided: {disclosure}"); + } + let total: f64 = rows.iter().map(|r| r.height).sum(); + let (card, body, footer) = consent_rects(screen, total); + assert!(body.size.y > 0.0); + assert!(body.pos.y + body.size.y + 12.0 <= footer.pos.y + 0.01); + assert!(footer.pos.y + footer.size.y <= card.pos.y + card.size.y - PAD + 0.01); + assert!(card.pos.y >= screen.pos.y && card.pos.y + card.size.y <= height); + let mut scroll = ConsentScroll::default(); + scroll.layout(&summary.app, body, total); + scroll.scroll(total * 2.0); + assert!((scroll.offset - (total - body.size.y).max(0.0)).abs() < 0.01); + assert!(total - scroll.offset <= body.size.y + 0.01, "last disclosure must be reachable"); + if height == 1000.0 { assert_eq!(scroll.max, 0.0, "fitting content grows the card without scrolling"); } + if height == 320.0 { assert!(scroll.max > 0.0, "short phone viewport must scroll"); } + } + } + + #[test] + fn consent_scroll_owns_release_over_decisions_and_resets_for_another_app() { + let (_, body, footer) = consent_rects(rect(0.0, 0.0, 320.0, 480.0), 1200.0); + let mut scroll = ConsentScroll::default(); + scroll.layout("one", body, 1200.0); + let at = body.pos + dvec2(12.0, 100.0); + assert!(scroll.touch(&point(7, TouchState::Start, at))); + assert!(!scroll.touch(&point(8, TouchState::Move, at - dvec2(0.0, 60.0)))); + assert!(scroll.touch(&point(7, TouchState::Move, at - dvec2(0.0, 60.0)))); + assert_eq!(scroll.offset, 60.0); + assert!(scroll.touch(&point(7, TouchState::Stop, footer.pos + dvec2(10.0, 10.0))), "a scrolling release must not fall through to Allow"); + assert!(scroll.touch.is_none()); + scroll.scroll(500.0); + scroll.layout("two", body, 1200.0); + assert_eq!(scroll.offset, 0.0); + assert!(!scroll.touch(&point(9, TouchState::Start, footer.pos + dvec2(10.0, 10.0))), "ordinary decision taps keep the existing consent path"); + } +} + /// A press on one of the surface's buttons, exactly as /// [`ShellApprovals::pointer`] hands it over: the two-lane scenario tests /// (`host_tools/scenario_tests.rs`) press the drawn sheet's and question diff --git a/crates/shell/src/card_chat.rs b/crates/shell/src/card_chat.rs index 87431d41..809d096c 100644 --- a/crates/shell/src/card_chat.rs +++ b/crates/shell/src/card_chat.rs @@ -55,7 +55,9 @@ script_mod! { draw_bg.color: theme.color_bg_container draw_bg.border_radius: 18.0 input := TextInputFlat { - width: Fill height: 62 margin: 0 padding: 8 + // Size the viewport from the selected font's real line layout, + // while longer drafts retain their full scrollable content. + width: Fill height: Fit min_height: 62 max_height: 122 margin: 0 padding: 8 is_multiline: true empty_text: "Message the app…" draw_bg +: {pixel: fn() {return vec4(0.0)}} @@ -91,15 +93,31 @@ fn message_rows(snapshot: &serde_json::Value) -> Vec { rows } -fn composer_height(text: &str, width: f64) -> f64 { - // Include soft wraps as well as explicit newlines. Height is bounded; - // TextInput keeps its complete layout/selection and scrolls longer drafts. - let line_width = (width - 16.0).max(120.0); - let lines: f64 = text.split('\n').map(|line| { - let pixels: f64 = line.chars().map(|c| if c.is_ascii() {8.0} else {16.0}).sum(); - (pixels / line_width).ceil().max(1.0) - }).sum(); - 20.0 * lines.clamp(2.0, 5.0) + 22.0 +/// One presentation-only patch, shared by application and contrast checks. +fn input_style_patch(vm: &mut ScriptVm) -> ScriptValue { + script_eval!(vm, { + use mod.prelude.widgets.* + let styled = TextInputFlat { + draw_text +: {text_style: theme.font_regular{font_size: 15.0 line_spacing: 1.25} + color: theme.color_text color_hover: theme.color_text color_focus: theme.color_text + color_empty: theme.color_text_placeholder color_empty_hover: theme.color_text_placeholder color_empty_focus: theme.color_text_placeholder} + draw_cursor.color: theme.color_focus + draw_selection +: { + color: theme.color_bg_highlight_inline color_hover: theme.color_bg_highlight_inline + color_focus: theme.color_bg_highlight_inline color_down: theme.color_bg_highlight_inline + color_2: vec4(-1.0) color_2_hover: vec4(-1.0) color_2_focus: vec4(-1.0) color_2_down: vec4(-1.0) + } + } + {draw_text: styled.draw_text, draw_cursor: styled.draw_cursor, draw_selection: styled.draw_selection} + }) +} + +struct NativeStyle { + regular: TextStyle, + bold: TextStyle, + text: Vec4f, + placeholder: Vec4f, + face: Vec4f, } #[derive(Script, Widget)] @@ -111,29 +129,90 @@ pub struct CardChat { #[rust] answering: bool, #[rust] available: bool, #[rust] ink: Option, + #[rust] style_revision: u64, + #[rust] native_style: Option, #[rust] reply_requested: bool, #[rust] publisher: String, } impl ScriptHook for CardChat { fn on_after_apply(&mut self, _vm: &mut ScriptVm, apply: &Apply, _scope: &mut Scope, _value: ScriptValue) { - if !apply.is_eval() { self.ink = None; } + if !apply.is_eval() { self.ink = None; self.style_revision = 0; } } } impl CardChat { pub fn set_ink(&mut self, cx: &mut Cx, ink: Vec4f) { + self.apply_selected_style(cx); + let ink = self.native_style.as_ref().map_or(ink, |style| style.text); if self.ink == Some(ink) { return; } self.ink = Some(ink); - let face = if ink.x + ink.y + ink.z < 1.5 { crate::shell::rgb(240, 243, 248) } else { crate::shell::rgb(35, 39, 48) }; + let face = self.native_style.as_ref().map_or_else(|| { + if ink.x + ink.y + ink.z < 1.5 { crate::shell::rgb(240, 243, 248) } else { crate::shell::rgb(35, 39, 48) } + }, |style| style.face); let mut form = self.view.widget(cx, ids!(form)); script_apply_eval!(cx, form, {draw_bg +: {color: #(face)}}); let mut input = self.view.widget(cx, ids!(input)); - let hint = crate::shell::alpha(ink, 0.55); - script_apply_eval!(cx, input, {draw_text +: {color: #(ink) color_hover: #(ink) color_focus: #(ink) color_empty: #(hint) color_empty_hover: #(hint) color_empty_focus: #(hint)}}); - if let Some(mut label) = self.view.label(cx, ids!(status)).borrow_mut() { label.draw_text.color = crate::shell::alpha(ink, 0.7); } + let hint = self.native_style.as_ref().map_or_else(|| crate::shell::alpha(ink, 0.55), |style| style.placeholder); + if self.native_style.is_none() { + script_apply_eval!(cx, input, {draw_text +: {color: #(ink) color_hover: #(ink) color_focus: #(ink) color_empty: #(hint) color_empty_hover: #(hint) color_empty_focus: #(hint)}}); + } + if let Some(mut label) = self.view.label(cx, ids!(status)).borrow_mut() { label.draw_text.color = hint; } + self.view.redraw(cx); + } + fn apply_selected_style(&mut self, cx: &mut Cx) { + let (revision, _) = crate::glance_style::current(cx); + if revision == self.style_revision { return; } + let input = self.view.widget(cx, ids!(input)); + let send = self.view.widget(cx, ids!(send)); + let enable = self.view.widget(cx, ids!(enable)); + let reply = self.view.widget(cx, ids!(reply)); + self.native_style = crate::glance_style::with_native_theme(cx, |vm| { + // Eval applies presentation fields only. It never rebinds the + // widget source or reapplies draft text, focus, rows or handlers. + let mut input = input; + let patch = input_style_patch(vm); + input.script_apply(vm, &Apply::Eval, &mut Scope::empty(), patch); + for (mut button, size) in [(send, 20.0), (enable, 14.0)] { + let patch = script_eval!(vm, { + use mod.prelude.widgets.* + let styled = ButtonFlat { + draw_text +: {text_style: theme.font_bold{font_size: #(size)} + color: theme.color_text_on_accent color_hover: theme.color_text_on_accent color_down: theme.color_text_on_accent} + draw_bg +: {color: theme.color_focus color_hover: theme.color_focus color_down: theme.color_focus} + } + {draw_text: styled.draw_text, draw_bg: styled.draw_bg} + }); + button.script_apply(vm, &Apply::Eval, &mut Scope::empty(), patch); + } + // The navigation action remains secondary in the selected style. + let mut reply = reply; + let patch = script_eval!(vm, { + use mod.prelude.widgets.* + let styled = ButtonFlat {draw_text.text_style: theme.font_regular{font_size: 14.0}} + {draw_text: styled.draw_text, draw_bg: styled.draw_bg} + }); + reply.script_apply(vm, &Apply::Eval, &mut Scope::empty(), patch); + let theme = vm.module(id!(theme)); + let regular = vm.bx.heap.value(theme, id!(font_regular).into(), NoTrap); + let regular = TextStyle::script_from_value(vm, regular); + let bold = vm.bx.heap.value(theme, id!(font_bold).into(), NoTrap); + let bold = TextStyle::script_from_value(vm, bold); + let mut color = |key: LiveId| { + let value = vm.bx.heap.value(theme, key.into(), NoTrap); + Vec4f::script_from_value(vm, value) + }; + NativeStyle { regular, bold, text: color(id!(color_text)), + placeholder: color(id!(color_text_placeholder)), face: color(id!(color_bg_container)) } + }); + if let (Some(style), Some(mut label)) = (self.native_style.as_ref(), self.view.label(cx, ids!(status)).borrow_mut()) { + label.draw_text.text_style.font_family = style.regular.font_family.clone(); + } + self.style_revision = revision; + self.ink = None; self.view.redraw(cx); } + pub fn has_unsent_input(&self, cx: &Cx) -> bool { !self.view.text_input(cx, ids!(input)).text().trim().is_empty() } pub fn take_reply_request(&mut self) -> bool { std::mem::take(&mut self.reply_requested) } /// Rebind transcript/status while retaining the person's unsubmitted text. @@ -198,7 +277,6 @@ impl CardChat { match result { Ok(()) => { self.view.text_input(cx, ids!(input)).set_text(cx, ""); - self.view.text_input(cx, ids!(input)).set_height(cx, Size::Fixed(62.0)); self.view.portal_list(cx, ids!(transcript)).scroll_to_end(cx); self.generation = None; } @@ -220,9 +298,8 @@ impl Widget for CardChat { } if self.view.button(cx, ids!(reply)).clicked(&actions) { self.reply_requested = true; } let input = self.view.text_input(cx, ids!(input)); - if let Some(text) = input.changed(&actions) { + if input.changed(&actions).is_some() { // Local editing only. No host storage, model calls, or L0 parsing. - input.set_height(cx, Size::Fixed(composer_height(&text, input.area().rect(cx).size.x))); self.update_send(cx); } let clicked = self.view.button(cx, ids!(send)).clicked(&actions); @@ -243,6 +320,14 @@ impl Widget for CardChat { item.label(cx, ids!(speaker)).set_text(cx, &row.speaker); item.label(cx, ids!(speaker)).set_visible(cx, !row.speaker.is_empty()); item.label(cx, ids!(body)).set_text(cx, &row.text); + if let Some(style) = &self.native_style { + if let Some(mut label) = item.label(cx, ids!(body)).borrow_mut() { + if label.draw_text.text_style.font_family != style.regular.font_family { label.draw_text.text_style.font_family = style.regular.font_family.clone(); } + } + if let Some(mut label) = item.label(cx, ids!(speaker)).borrow_mut() { + if label.draw_text.text_style.font_family != style.bold.font_family { label.draw_text.text_style.font_family = style.bold.font_family.clone(); } + } + } if let Some(ink) = self.ink { if let Some(mut label) = item.label(cx, ids!(body)).borrow_mut() { label.draw_text.color = ink; } if let Some(mut label) = item.label(cx, ids!(speaker)).borrow_mut() { label.draw_text.color = crate::shell::alpha(ink, 0.65); } @@ -260,11 +345,170 @@ mod tests { use super::*; #[test] - fn composer_grows_for_wrapped_text_and_stops_before_covering_history() { - assert_eq!(composer_height("short", 280.0), 62.0); - assert!(composer_height(&"words ".repeat(18), 250.0) > 62.0); - assert!(composer_height(&"更改时间".repeat(12), 250.0) > 62.0); - assert_eq!(composer_height(&"long ".repeat(1000), 250.0), 122.0); + fn selected_glance_style_keeps_native_chat_and_draft_with_readable_selection() { + use makepad_widgets::makepad_draw::cx_draw::CxDraw; + fn contrast(a: Vec4f, b: Vec4f) -> f64 { + let luminance = |v: Vec4f| { + let component = |n: f32| { let n = n as f64; if n <= 0.04045 {n / 12.92} else {((n + 0.055) / 1.055).powf(2.4)} }; + 0.2126 * component(v.x) + 0.7152 * component(v.y) + 0.0722 * component(v.z) + }; + let (a,b) = (luminance(a), luminance(b)); (a.max(b)+0.05)/(a.min(b)+0.05) + } + let mut cx = Cx::new(Box::new(|_, _| {})); + let widget = cx.with_vm(|vm| { + makepad_widgets::script_mod(vm); + desktop_style::install(vm, desktop_style::StyleSheet::load(desktop_style::DesktopStyle::Omarchy)); + vm.with_reload(makepad_widgets::script_mod); + super::script_mod(vm); + let value = script_eval!(vm, {use mod.widgets.* CardChat{}}); + WidgetRef::script_from_value(vm,value) + }); + let input = widget.text_input(&cx, ids!(input)); + let identity = input.widget_uid(); + let source = input.script_source(); + input.set_text(&mut cx, "Same unsent question — 保留"); + input.set_selection(&mut cx, makepad_draw::text::selection::Selection { + anchor: makepad_draw::text::selection::Cursor {index:5, prefer_next_row:false}, + cursor: makepad_draw::text::selection::Cursor {index:11, prefer_next_row:false}, + }); + let mut focused = false; + widget.borrow_mut::().unwrap().rows = vec![MessageRow {speaker:"Assistant".into(),text:"Retained answer".into()}]; + let pass = DrawPass::new(&mut cx); let mut list = DrawList2d::new(&mut cx); + for dark in [false,true,false] { + let sheet = desktop_style::StyleSheet::load_with_appearance(desktop_style::DesktopStyle::Android,dark); + crate::glance_style::select(&mut cx,&sheet); + widget.borrow_mut::().unwrap().set_ink(&mut cx,crate::shell::rgb(255,255,255)); + let (revision,_) = crate::glance_style::current(&mut cx); + crate::glance_style::select(&mut cx,&sheet); + assert_eq!(crate::glance_style::current(&mut cx).0,revision,"no repeated restyle per frame"); + let size = dvec2(380.,330.); pass.set_size(&mut cx,size); + for _ in 0..2 { + let event = DrawEvent::default(); let mut draw = CxDraw::new(&mut cx,&event); let mut draw = Cx2d::new(&mut draw); + draw.begin_pass(&pass,Some(1.)); list.begin_always(&mut draw); draw.begin_root_turtle(size,Layout::default()); + widget.draw_walk_all(&mut draw,&mut Scope::empty(),Walk::fixed(size.x,size.y)); + draw.end_turtle(); list.end(&mut draw); draw.end_pass(&pass); + } + assert_eq!(widget.text_input(&cx,ids!(input)).widget_uid(),identity); + assert_eq!(input.script_source(),source,"presentation patch must not retarget native event ownership"); + assert_eq!(input.text(),"Same unsent question — 保留"); + assert_eq!((input.selection().anchor.index,input.selection().cursor.index),(5,11)); + if focused { assert!(cx.has_key_focus(input.area()), "theme switching retains the active editor"); } + else { + cx.set_key_focus(input.area()); + // The platform commits pending focus after event dispatch. + cx.send_trigger(input.area(), Trigger{id:id!(glance_style_focus),from:Area::Empty}); + cx.handle_triggers(); + assert!(cx.has_key_focus(input.area())); + focused = true; + } + let chat = widget.borrow::().unwrap(); + assert_eq!(chat.rows[0].text,"Retained answer"); + let style = chat.native_style.as_ref().unwrap(); + assert!(contrast(style.text,style.face)>=4.5,"typed text against native field"); + assert!(contrast(style.placeholder,style.face)>=3.0,"placeholder remains distinguishable"); + assert_eq!(widget.label(&cx,ids!(status)).borrow().unwrap().draw_text.text_style.font_family_id(),style.regular.font_family_id()); + crate::glance_style::with_native_theme(&mut cx,|vm| { + vm.bx.captured_errors = Some(Vec::new()); + let patch = input_style_patch(vm).as_object().unwrap(); + let selection = vm.bx.heap.value(patch,id!(draw_selection).into(),NoTrap).as_object().unwrap(); + for key in [id!(color),id!(color_hover),id!(color_focus),id!(color_down)] { + let value = vm.bx.heap.value(selection,key.into(),NoTrap); + let color = Vec4f::script_from_value(vm,value); + assert!(contrast(style.text,color)>=4.5,"selected text at {key:?}"); + } + assert!(vm.take_errors().is_empty(),"native presentation patch evaluates cleanly"); + }).unwrap(); + let area = input.area().rect(&cx); + assert!(area.pos.y>=0. && area.pos.y+area.size.y<=size.y && area.size.y>=40.); + assert_eq!(cx.with_vm(|vm|desktop_style::current(vm).unwrap().name),"omarchy"); + } + } + + #[test] + fn styled_composer_keeps_both_short_rows_visible_and_long_drafts_scrollable() { + use makepad_widgets::makepad_draw::cx_draw::CxDraw; + use makepad_draw::text::selection::Cursor; + let mut cx = Cx::new(Box::new(|_, _| {})); + let widget = cx.with_vm(|vm| { + makepad_widgets::script_mod(vm); + desktop_style::install(vm, desktop_style::StyleSheet::load(desktop_style::DesktopStyle::Android)); + vm.with_reload(makepad_widgets::script_mod); + super::script_mod(vm); + let value = script_eval!(vm, {use mod.widgets.* CardChat{}}); + WidgetRef::script_from_value(vm, value) + }); + let input = widget.text_input(&cx, ids!(input)); + let identity = input.widget_uid(); + let pass = DrawPass::new(&mut cx); + let mut list = DrawList2d::new(&mut cx); + let mut draw = |cx: &mut Cx, height| { + let size = dvec2(360.0, height); + pass.set_size(cx, size); + for _ in 0..2 { + let event = DrawEvent::default(); + let mut draw = CxDraw::new(cx, &event); + let mut draw = Cx2d::new(&mut draw); + draw.begin_pass(&pass, Some(1.0)); + list.begin_always(&mut draw); + draw.begin_root_turtle(size, Layout::default()); + widget.draw_walk_all(&mut draw, &mut Scope::empty(), Walk::fixed(size.x, size.y)); + draw.end_turtle(); list.end(&mut draw); draw.end_pass(&pass); + } + }; + for dark in [false, true, false] { + let sheet = desktop_style::StyleSheet::load_with_appearance(desktop_style::DesktopStyle::Android, dark); + crate::glance_style::select(&mut cx, &sheet); + widget.borrow_mut::().unwrap().set_ink(&mut cx, crate::shell::rgb(0, 0, 0)); + // Use the exact presentation object applied to the native editor, + // so the measurement shares its selected font and line spacing. + let measured = crate::glance_style::with_native_theme(&mut cx, |vm| { + let patch = input_style_patch(vm).as_object().unwrap(); + let text = vm.bx.heap.value(patch, id!(draw_text).into(), NoTrap); + DrawText::script_from_value(vm, text) + }).unwrap(); + for height in [740.0, 330.0, 740.0] { + input.set_text(&mut cx, ""); + draw(&mut cx, height); + input.take_key_focus(&mut cx); + cx.send_trigger(input.area(), Trigger {id:id!(composer_test_focus), from:Area::Empty}); + cx.handle_triggers(); + let short = "Keep this calendar question\nunsent."; + widget.handle_event(&mut cx, &Event::TextInput(TextInputEvent {input:short.into(), ..Default::default()}), &mut Scope::empty()); + draw(&mut cx, height); + assert_eq!(input.text(), short, "native input must reach the actual composer"); + let area = input.area().rect(&cx); + let layout = measured.layout(&mut cx, 0.0, 0.0, Some((area.size.x - 16.0) as f32), true, Align::default(), short); + assert_eq!(layout.rows.len(), 2); + let caret = input.cursor_rect_in_absolute(&cx).unwrap(); + let first = &layout.rows[0]; + let last = layout.rows.last().unwrap(); + let row_delta = ((last.origin_in_lpxs.y - last.ascender_in_lpxs) - (first.origin_in_lpxs.y - first.ascender_in_lpxs)) as f64; + let first_top = caret.pos.y - row_delta; + eprintln!("composer dark={dark} viewport={height} field={} content={} first_top={} clip_top={} last_bottom={} clip_bottom={}", area.size.y, layout.size_in_lpxs.height, first_top, area.pos.y+8.0, caret.pos.y+caret.size.y, area.pos.y+area.size.y-8.0); + assert!((caret.size.y - (last.ascender_in_lpxs - last.descender_in_lpxs) as f64).abs() < 0.1, "measurement and actual editor line metrics agree"); + assert!(first_top >= area.pos.y + 8.0 - 0.1, "first line is clipped after typing at its end: {area:?}, {caret:?}"); + assert!(caret.pos.y + caret.size.y <= area.pos.y + area.size.y - 8.0 + 0.1); + assert!(area.size.y <= 122.0 && area.pos.y + area.size.y <= height); + assert_eq!(input.widget_uid(), identity); + assert!(cx.has_key_focus(input.area())); + + let long = "A complete long draft must stay editable.\n".repeat(24); + input.set_text(&mut cx, ""); + widget.handle_event(&mut cx, &Event::TextInput(TextInputEvent {input:long.clone(), ..Default::default()}), &mut Scope::empty()); + draw(&mut cx, height); + assert_eq!(input.text(), long); + let area = input.area().rect(&cx); + assert!((area.size.y - 122.0).abs() < 0.1, "long editor stays bounded"); + let caret = input.cursor_rect_in_absolute(&cx).unwrap(); + assert!(caret.pos.y >= area.pos.y + 8.0 - 0.1 && caret.pos.y + caret.size.y <= area.pos.y + area.size.y - 8.0 + 0.1, "last row remains reachable"); + input.set_cursor(&mut cx, Cursor {index:0, prefer_next_row:false}, false); + draw(&mut cx, height); + let caret = input.cursor_rect_in_absolute(&cx).unwrap(); + assert!(caret.pos.y >= area.pos.y + 8.0 - 0.1 && caret.pos.y + caret.size.y <= area.pos.y + area.size.y - 8.0 + 0.1, "first row remains reachable"); + assert_eq!(input.text(), long); + assert!(widget.button(&cx, ids!(send)).area().rect(&cx).pos.y + 44.0 <= height); + } + } } #[test] diff --git a/crates/shell/src/desk/phone.rs b/crates/shell/src/desk/phone.rs index 0d5ba3f9..3c9efe4c 100644 --- a/crates/shell/src/desk/phone.rs +++ b/crates/shell/src/desk/phone.rs @@ -295,7 +295,8 @@ impl WmDesk { // 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 app_content = state.phone.app_content_rect(screen); + state.phone.groups.add_exclusions(state.phone.screen,app_content,&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); self.phone_ui.sync_native_widgets(cx,state,full,screen); @@ -322,7 +323,7 @@ impl WmDesk { state.phone.draw_active = false; let style=state.style.target; let dark=state.style.dark; - let app=mobile::app_rect(screen); + let app=phone.app_content_rect(screen); if self.phone_ui.set_theme(phone.theme.map(|choice| choice.palette(dark))) { // Recolor existing app/tile instances, including captures otherwise // keyed only by size and light/dark. Keep their navigation state. @@ -523,7 +524,7 @@ impl WmDesk { 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); - let card=mobile::card_rect(screen,index as f64,phone.page); + let card=phone.card_rect(screen,index as f64,phone.page); let mut display=if foreground { let icon=PhoneSurface::launch_origin(style,screen,phone.tiles.get(client).map(|t|t.app.as_str())); mobile::mix_rect(mobile::mix_rect(icon,app,phone.openness),card,phone.overview) diff --git a/crates/shell/src/desktop_app.rs b/crates/shell/src/desktop_app.rs index 5a1ce24e..d3b0e066 100644 --- a/crates/shell/src/desktop_app.rs +++ b/crates/shell/src/desktop_app.rs @@ -153,6 +153,7 @@ impl App { .or_else(|| (style == DesktopStyle::OctoSense).then(|| theme::scan_shell_palette(&sheet.theme))); self.apply_material_to_chrome(cx, material, palette); self.module_host.apply_style(cx, &sheet); + crate::glance_style::select(cx, &sheet); self.stylesheet = Some(sheet); let omarchy_source = if style == DesktopStyle::Omarchy { theme::load_theme_source(&self.state_mut().theme_name) diff --git a/crates/shell/src/glance_card.rs b/crates/shell/src/glance_card.rs index db99d6dd..d295b900 100644 --- a/crates/shell/src/glance_card.rs +++ b/crates/shell/src/glance_card.rs @@ -24,6 +24,12 @@ //! `Fit`; explicit viewport workspaces use a bounded `Fill` root and own their //! scroll regions. Script feed summaries display metadata without running UI. //! +//! **Presentation.** `glance_style` receives the same host-selected stylesheet +//! as normal app modules. A tile applies it once per revision to its resident +//! Splash and host sheet; source, item, heap, local state and grants are retained. +//! The shell's ambient chrome theme is not the app's theme. Explicit colors in +//! published source remain app-owned; native fallback chat reads selected roles. +//! //! **Policy.** A tile's isolate runs under the publishing app's resolved //! policy, applied exactly as the Card runner applies it //! (`octosense_app_policy::splash_adapter::apply` with the app's @@ -950,6 +956,7 @@ struct Tile { contained: bool, workspace: bool, admitted_prompts: bool, + style_revision: u64, viewport: Option, focused: Option, } @@ -1147,7 +1154,7 @@ impl GlanceTiles { /// The tile for `key`, made and seated on first use, running `body`. pub(crate) fn open(&mut self, cx: &mut Cx, key: &str, app: &str, contained: bool, body: &std::sync::Arc) -> SplashRef { - let tile = self.tiles.entry(key.to_string()).or_insert_with(|| Tile { frame: WidgetRef::empty(), card: SplashRef::default(), sheet: SplashRef::default(), body: "".into(), app: app.to_string(), contained, workspace: self.viewport_layout, admitted_prompts: false, viewport: None, focused: None }); + let tile = self.tiles.entry(key.to_string()).or_insert_with(|| Tile { frame: WidgetRef::empty(), card: SplashRef::default(), sheet: SplashRef::default(), body: "".into(), app: app.to_string(), contained, workspace: self.viewport_layout, admitted_prompts: false, style_revision: 0, viewport: None, focused: None }); if tile.frame.is_empty() { let scroll = self.scroll; let viewport = self.viewport_layout; @@ -1165,6 +1172,14 @@ impl GlanceTiles { tile.admitted_prompts = seat(cx, &splash, app, contained); if tile.workspace { splash.set_host_prompts(cx, self.foreground && tile.admitted_prompts); } } + let (revision, sheet) = crate::glance_style::current(cx); + if tile.style_revision != revision { + if let Some(sheet) = sheet { + if let Some(mut card) = tile.card.borrow_mut() { card.set_stylesheet(cx, (*sheet).clone()); } + if let Some(mut host_sheet) = tile.sheet.borrow_mut() { host_sheet.set_stylesheet(cx, (*sheet).clone()); } + } + tile.style_revision = revision; + } let splash = tile.card.clone(); if tile.body.as_ref() != body.as_ref() { splash.set_text(cx, body); @@ -1445,6 +1460,71 @@ mod tests { splash.borrow_mut().unwrap().isolate_heap_key(cx).expect("the card runs in its own isolate") } + #[test] + fn selected_glance_style_reaches_existing_cards_without_replacing_state() { + use makepad_widgets::makepad_draw::cx_draw::CxDraw; + let mut cx = tile_cx(); + cx.with_vm(|vm| { + desktop_style::install(vm, desktop_style::StyleSheet::load(desktop_style::DesktopStyle::Omarchy)); + }); + let mut tiles = GlanceTiles::scrolling(); + tiles.viewport_layout = true; + let body: std::sync::Arc = r#" + mod.state = {item: "retained-calendar-event", edits: 0} + height: Fill flow: Down + title := Label {width: Fill height: Fit text: "A long event title that stays wrapped and readable in the selected phone style"} + owned := Label {text: "Explicit app color" draw_text.color: #ce2756} + draft := TextInput {width: Fill height: 80 text: "original"} + "#.into(); + let pass = DrawPass::new(&mut cx); + let mut list = DrawList2d::new(&mut cx); + let mut draw = |cx: &mut Cx, tiles: &mut GlanceTiles| { + let size = dvec2(350.0, 500.0); + pass.set_size(cx, size); + let event = DrawEvent::default(); + let mut draw = CxDraw::new(cx, &event); let mut draw = Cx2d::new(&mut draw); + draw.begin_pass(&pass, Some(1.0)); list.begin_always(&mut draw); + draw.begin_root_turtle(size, Layout::default()); + tiles.draw_workspace(&mut draw, "style-event", "test.style", false, &body, Rect{pos:dvec2(0.,0.),size}); + draw.end_turtle(); list.end(&mut draw); draw.end_pass(&pass); + }; + let mut identity = None; + for dark in [false, true, false] { + let sheet = desktop_style::StyleSheet::load_with_appearance(desktop_style::DesktopStyle::Android, dark); + crate::glance_style::select(&mut cx, &sheet); + let card = tiles.open(&mut cx, "style-event", "test.style", false, &body); + draw(&mut cx, &mut tiles); draw(&mut cx, &mut tiles); + let vm_id = isolate_of(&mut cx, &card).unwrap(); + let draft = card.text_input(&cx, ids!(draft)); + let title = card.label(&cx, ids!(title)); + let expected = if dark {0xe6e0e9ff} else {0x1d1b20ff}; + assert_eq!(title.borrow().unwrap().draw_text.color.to_u32(), expected); + assert_eq!(card.label(&cx, ids!(owned)).borrow().unwrap().draw_text.color.to_u32(), 0xce2756ff, + "the host never overwrites app-owned colors"); + let font = title.borrow().unwrap().draw_text.text_style.font_family.clone(); + assert!(font.member_ids().any(|id| !id.to_lowercase().contains("mono")), "phone sans family"); + if let Some((old_vm, old_widget)) = identity { + assert_eq!(vm_id, old_vm); assert_eq!(draft.widget_uid(), old_widget); + assert_eq!(draft.text(), "Edited time 09:30 — 保留"); + } else { + identity = Some((vm_id, draft.widget_uid())); + draft.set_text(&mut cx, "Edited time 09:30 — 保留"); + cx.with_script_vm_id(vm_id, |vm| {script_eval!(vm, {mod.state.edits = 7});}); + } + cx.with_script_vm_id(vm_id, |vm| { + assert_eq!(desktop_style::current(vm).unwrap().name, sheet.name); + let value = script_eval!(vm, {mod.state.edits}); + assert_eq!(value.as_number(), Some(7.0)); + let value = script_eval!(vm, {mod.state.item == "retained-calendar-event"}); + assert_eq!(value.as_bool(), Some(true)); + }); + assert!(title.area().rect(&cx).size.y > 25.0, "title stays wrapped after restyle"); + assert_eq!(cx.with_vm(|vm| desktop_style::current(vm).unwrap().name), "omarchy", + "shell chrome does not inherit an app's stylesheet"); + } + tiles.sweep(&mut cx, &[]); + } + /// A contained app's tile runs under that app's resolved policy, the one /// its Card runner applies: its grants pass the isolate's gate, and /// nothing it was not granted does. A native module's tile gets none. diff --git a/crates/shell/src/glance_style.rs b/crates/shell/src/glance_style.rs new file mode 100644 index 00000000..3233beda --- /dev/null +++ b/crates/shell/src/glance_style.rs @@ -0,0 +1,50 @@ +//! Host-selected presentation for resident Glance surfaces. The shell's own +//! chrome keeps its ambient theme; app source is never rewritten for styling. +use makepad_widgets::*; +use std::rc::Rc; + +#[derive(Default)] +struct Selected { + sheet: Option>, + revision: u64, + native_vm: Option, +} + +pub(crate) fn select(cx: &mut Cx, sheet: &desktop_style::StyleSheet) { + let selected = cx.global::(); + if selected.sheet.as_deref() == Some(sheet) { return; } + selected.sheet = Some(Rc::new(sheet.clone())); + selected.revision += 1; + let old = selected.native_vm.take(); + if let Some(old) = old { cx.free_splash_vm(old); } + cx.redraw_all(); +} + +pub(crate) fn current(cx: &mut Cx) -> (u64, Option>) { + let selected = cx.global::(); + (selected.revision, selected.sheet.clone()) +} + +/// Native fallback chat reads bundled font/color roles in a trusted theme-only +/// isolate, cached once per selected revision. No app source, grants, network, +/// host services or timers run here, and the ambient shell VM stays unchanged. +pub(crate) fn with_native_theme(cx: &mut Cx, f: impl FnOnce(&mut ScriptVm) -> R) -> Option { + let sheet = cx.global::().sheet.clone()?; + let existing = cx.global::().native_vm; + let vm_id = if let Some(vm_id) = existing { vm_id } else { + let vm_id = cx.alloc_splash_vm_with_network(false); + cx.with_script_vm_id_trusted(vm_id, |vm| { + desktop_style::install(vm, (*sheet).clone()); + vm.with_reload(|vm| { + // Only the framework's packaged-font resolver is needed. + script_eval!(vm, {mod.res = {crate_resource: mod.prelude.widgets.crate_resource}}); + makepad_widgets::widgets_mod(vm); + desktop_style::apply_widgets(vm); + script_eval!(vm, {mod.res = nil}); + }); + }); + cx.global::().native_vm = Some(vm_id); + vm_id + }; + Some(cx.with_script_vm_id_trusted(vm_id, f)) +} diff --git a/crates/shell/src/lib.rs b/crates/shell/src/lib.rs index 32c9c1fc..b8c9d2a7 100644 --- a/crates/shell/src/lib.rs +++ b/crates/shell/src/lib.rs @@ -82,6 +82,7 @@ pub mod wm_reply; pub mod ext; pub mod glance; pub mod glance_card; +mod glance_style; pub mod glance_chat; pub mod mail_card; #[cfg(any(feature = "app-hub", native_mobile))] @@ -5926,6 +5927,7 @@ impl MatchEvent for App { let sheet = octosense::style::load_sheet(desktop::DesktopStyle::Omarchy, false); host::set_child_env("MAKEPAD_WIDGET_STYLE", std::ffi::OsStr::new(&sheet.name)); self.module_host.apply_style(cx, &sheet); + glance_style::select(cx, &sheet); let (material, roles) = Self::chrome_from_sheet(&sheet); self.stylesheet = Some(sheet); let source = theme::load_theme_source(&theme_name); diff --git a/crates/shell/src/mobile.rs b/crates/shell/src/mobile.rs index ad61b1b5..b02e219b 100644 --- a/crates/shell/src/mobile.rs +++ b/crates/shell/src/mobile.rs @@ -11,6 +11,8 @@ pub enum PhoneScreen { #[default] Home, App, Recents, Drawer } pub enum PhoneHit { App(String), TileApp(String), Card(ClientId), Home, Recents, Drawer, Search, Back, Floating(crate::mobile_navigation::NavigationHit), + /// Android app navigation lives outside the app viewport. + AppNavigation(crate::mobile_navigation::NavigationHit), /// The desk bar's phone strip (universal builds only): rotate the /// window, the style menu, Light/Dark, back to the desktop. #[cfg(not(mobile_only))] Rotate, @@ -218,6 +220,34 @@ impl PhoneState { pub fn cancel_navigation_input(&mut self) { self.touch = None; self.navigation.cancel(); + if self.gesture.as_ref().is_some_and(|g| matches!(g.hit, Some(PhoneHit::AppNavigation(_)))) { + self.gesture = None; + } + } + pub fn app_dock_for_platform(&self, screen: Rect, android: bool) -> Option { + (android && self.screen == PhoneScreen::App && self.navigation.visible()) + .then(|| crate::mobile_navigation::app_dock(screen)) + } + pub fn app_dock(&self, screen: Rect) -> Option { + self.app_dock_for_platform(screen, cfg!(target_os = "android")) + } + /// Shared by capture sizing, presentation and split/input geometry. The + /// backing app keeps this size in Home/Recents too, so navigation animates + /// its retained texture rather than resizing it as the dock disappears. + pub fn app_content_rect_for_platform(&self, screen: Rect, android: bool) -> Rect { + if android { + if self.navigation.visible() { crate::mobile_navigation::app_dock(screen).content } + else { screen } + } else { app_rect(screen) } + } + pub fn app_content_rect(&self, screen: Rect) -> Rect { + self.app_content_rect_for_platform(screen, cfg!(target_os = "android")) + } + pub fn card_rect(&self, screen: Rect, index: f64, page: f64) -> Rect { + card_rect_for_app(screen, self.app_content_rect(screen), index, page) + } + pub fn floating_navigation_visible(&self) -> bool { + self.navigation.visible() && !(cfg!(target_os = "android") && self.screen == PhoneScreen::App) } pub fn navigation_rect(&self) -> Rect { // The native KeyboardView already resizes this viewport above the @@ -235,7 +265,15 @@ impl PhoneState { } pub fn native_keyboard_event(&mut self, event: &VirtualKeyboardEvent) { match event { - VirtualKeyboardEvent::WillShow { .. } | VirtualKeyboardEvent::DidShow { .. } => self.navigation.set_ime_visible(true), + VirtualKeyboardEvent::WillShow { .. } | VirtualKeyboardEvent::DidShow { .. } => { + self.navigation.set_ime_visible(true); + // Consume the old owned finger through release, but never let + // its now-hidden navigation target fire after editing starts. + if let Some(g) = self.gesture.as_mut().filter(|g| matches!(g.hit, Some(PhoneHit::AppNavigation(_)))) { + g.hit = Some(PhoneHit::AppNavigation(crate::mobile_navigation::NavigationHit::Dismiss)); + g.shell = false; + } + }, VirtualKeyboardEvent::DidHide { .. } => self.navigation.set_ime_visible(false), // Keep the editing area clear throughout the hide animation. VirtualKeyboardEvent::WillHide { .. } => {} @@ -485,7 +523,9 @@ pub fn app_rect(screen: Rect) -> Rect { Rect { pos: screen.pos + dvec2(0.0, top), size: dvec2(screen.size.x, (screen.size.y - top - 24.0).max(1.0)) } } pub fn card_rect(screen: Rect, index: f64, page: f64) -> Rect { - let app = app_rect(screen); + card_rect_for_app(screen, app_rect(screen), index, page) +} +pub fn card_rect_for_app(screen: Rect, app: Rect, index: f64, page: f64) -> Rect { let scale = if screen.size.x > screen.size.y { 0.74 } else { 0.76 }; let size = app.size * scale; Rect { pos: app.pos + (app.size - size) * 0.5 + dvec2((index-page)*(size.x+22.0), -4.0), size } diff --git a/crates/shell/src/mobile_app.rs b/crates/shell/src/mobile_app.rs index 704fbc20..1723c568 100644 --- a/crates/shell/src/mobile_app.rs +++ b/crates/shell/src/mobile_app.rs @@ -83,7 +83,7 @@ impl App { fn full_viewport(&mut self) -> Vec2d { let screen = self.state_mut().phone.viewport; if screen.size.x < 1.0 { return dvec2(0.0, 0.0); } - app_rect(screen).size + self.state_mut().phone.app_content_rect(screen).size } /// Bind or launch a client per tile app, then send every tile client /// the face it should be showing. Cheap when nothing changed; called @@ -491,11 +491,22 @@ impl App { } pub fn phone_action(&mut self,cx:&mut Cx,hit:PhoneHit) { match hit { + PhoneHit::AppNavigation(hit)=>{ + use crate::mobile_navigation::NavigationHit; + let phone = &self.state_mut().phone; + if phone.app_dock(phone.viewport).is_none() { return; } + match hit { + NavigationHit::Home=>self.phone_action(cx,PhoneHit::Home), + NavigationHit::Recents=>self.phone_action(cx,PhoneHit::Recents), + _=>{}, + } + return; + } PhoneHit::Floating(hit)=>{ use crate::mobile_navigation::NavigationHit; // A cached accessibility or pointer target must not reopen // controls after the keyboard has taken the editing area. - if !self.state_mut().phone.navigation.visible() { return; } + if !self.state_mut().phone.floating_navigation_visible() { return; } match hit { NavigationHit::Bubble=>{let nav=&mut self.state_mut().phone.navigation;nav.open=!nav.open;} NavigationHit::Dismiss=>self.state_mut().phone.navigation.cancel(), @@ -693,7 +704,7 @@ impl App { } pub(super) fn phone_search_event(&mut self,cx:&mut Cx,event:&Event)->bool { let Some(state)=self.state.as_ref() else{return false}; - if crate::mobile_navigation::ENABLED { + if crate::mobile_navigation::ENABLED && state.phone.floating_navigation_visible() { let nav=&state.phone.navigation; let screen=state.phone.navigation_rect(); let over=match event { @@ -1040,7 +1051,7 @@ impl App { }; crate::mobile_perf::trace_phone_input(name, p); } - if crate::mobile_navigation::ENABLED && primary { + if crate::mobile_navigation::ENABLED && primary && self.state_mut().phone.floating_navigation_visible() { use crate::mobile_navigation::{Phase,NavigationHit}; let phase=match phase { PhonePointerPhase::Down=>Phase::Down,PhonePointerPhase::Move=>Phase::Move, @@ -1072,7 +1083,11 @@ impl App { let ctx=self.gesture_context(cx); let Some(state)=self.state.as_mut() else {return false}; let phone=&mut state.phone; - let hit=hit.filter(|hit| !matches!(hit,PhoneHit::Floating(_)) || phone.navigation.visible()); + let hit=hit.filter(|hit| match hit { + PhoneHit::Floating(_)=>phone.floating_navigation_visible(), + PhoneHit::AppNavigation(_)=>phone.app_dock(phone.viewport).is_some(), + _=>true, + }); let screen=phone.viewport; if phone.drag.is_some() { match phase { @@ -1100,7 +1115,7 @@ impl App { // page body); an excluded edge is left to the app. self.phone_gestures.feed(FingerPhase::Down,p,time,&ctx,&phone.exclusions); // The letter index and shade own their complete drag streams. - if hit==Some(PhoneHit::Scrub) || matches!(&hit,Some(PhoneHit::Shade(h)) if ShadeState::drags(h)) {self.phone_gestures.cancel();} + if hit==Some(PhoneHit::Scrub) || matches!(&hit,Some(PhoneHit::AppNavigation(_))) || 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) && hit.is_none() {return false;} phone.search_touch(p.y,time); @@ -1149,8 +1164,8 @@ impl App { phone.search_drag(last.y,p.y,time,search_scroll_max); }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 divider {phone.groups.drag_divider(p,app_rect(screen));} + else {let width=phone.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 divider {let app = phone.app_content_rect(screen);phone.groups.drag_divider(p,app);} self.animate_phone(cx);true } PhonePointerPhase::Up=>{ diff --git a/crates/shell/src/mobile_groups.rs b/crates/shell/src/mobile_groups.rs index fc013d54..d17e74f6 100644 --- a/crates/shell/src/mobile_groups.rs +++ b/crates/shell/src/mobile_groups.rs @@ -388,7 +388,7 @@ impl PhoneSurface { let phone = &state.phone; let ink = self.theme_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 app = phone.app_content_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) } @@ -400,7 +400,7 @@ impl PhoneSurface { if phone.screen == PhoneScreen::Recents && phone.overview > 0.5 { let accent = self.theme_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); + let card = phone.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); diff --git a/crates/shell/src/mobile_navigation.rs b/crates/shell/src/mobile_navigation.rs index 3e6c3b92..a1596d8b 100644 --- a/crates/shell/src/mobile_navigation.rs +++ b/crates/shell/src/mobile_navigation.rs @@ -8,6 +8,26 @@ const EDGE: f64 = 28.0; const VERTICAL: f64 = 40.0; const DRAG_SLOP: f64 = 10.0; +/// Android hosted apps reserve this row outside their content. System Back +/// remains Android-owned; these controls navigate inside OctoSense. +pub const APP_DOCK_HEIGHT: f64 = 48.0; +#[derive(Clone, Copy, Debug)] +pub struct AppDock { + pub bar: Rect, + pub content: Rect, + pub home: Rect, + pub recents: Rect, +} +pub fn app_dock(screen: Rect) -> AppDock { + let height = APP_DOCK_HEIGHT.min(screen.size.y.max(0.0)); + let bar = Rect { pos: screen.pos + dvec2(0.0, screen.size.y - height), size: dvec2(screen.size.x, height) }; + let content = Rect { pos: screen.pos, size: dvec2(screen.size.x, screen.size.y - height) }; + let width = 72.0_f64.min(screen.size.x * 0.5); + let home = Rect { pos: bar.pos + dvec2(screen.size.x * 0.5 - width, 0.0), size: dvec2(width, height) }; + let recents = Rect { pos: bar.pos + dvec2(screen.size.x * 0.5, 0.0), size: dvec2(width, height) }; + AppDock { bar, content, home, recents } +} + #[derive(Clone, Copy, Debug, PartialEq)] pub enum NavigationHit { Bubble, Home, Recents, Dismiss } @@ -168,6 +188,77 @@ mod tests { nav.pointer(Phase::Up, point, screen()) } + #[test] + fn android_app_dock_partitions_the_native_viewport_without_covering_content() { + for screen in [screen(), Rect { pos: dvec2(12.0, 28.0), size: dvec2(780.0, 360.0) }] { + let dock = app_dock(screen); + assert_eq!(dock.content.pos, screen.pos); + assert_eq!(dock.content.size.x, screen.size.x); + assert_eq!(dock.content.pos.y + dock.content.size.y, dock.bar.pos.y); + assert_eq!(dock.bar.pos.y + dock.bar.size.y, screen.pos.y + screen.size.y); + assert_eq!(dock.bar.size.y, 48.0); + let recent = crate::mobile::card_rect_for_app(screen, dock.content, 0.0, 0.0); + assert!((recent.size.x / recent.size.y - dock.content.size.x / dock.content.size.y).abs() < 0.0001, "Recents must preserve the captured app's aspect ratio"); + for control in [dock.home, dock.recents] { + assert!(control.size.x >= 44.0 && control.size.y >= 44.0); + assert!(dock.bar.contains(center(control))); + assert!(!dock.content.contains(center(control))); + } + assert!(dock.home.pos.x + dock.home.size.x <= dock.recents.pos.x); + } + } + + #[test] + fn app_dock_is_android_foreground_only_and_returns_space_to_the_ime() { + use crate::mobile::{PhoneScreen, PhoneState}; + let mut phone = PhoneState::default(); + phone.viewport = screen(); + assert!(phone.app_dock_for_platform(screen(), true).is_none()); + phone.screen = PhoneScreen::App; + let before = phone.app_dock_for_platform(screen(), true).unwrap(); + for page in [PhoneScreen::Home, PhoneScreen::Recents, PhoneScreen::App] { + phone.screen = page; + assert_eq!(phone.app_content_rect_for_platform(screen(), true), before.content, "app capture geometry stays stable across navigation"); + } + assert!(phone.app_dock_for_platform(screen(), false).is_none(), "OpenHarmony keeps its existing floating navigation"); + phone.native_keyboard_event(&VirtualKeyboardEvent::WillShow { + time: 0.0, height: 330.0, duration: 0.2, ease: Ease::OutCubic, + }); + phone.viewport.size.y -= 330.0; + assert!(phone.app_dock_for_platform(phone.viewport, true).is_none(), "KeyboardView's remaining viewport must have no extra reserved row"); + assert_eq!(phone.app_content_rect_for_platform(phone.viewport, true), phone.viewport); + phone.native_keyboard_event(&VirtualKeyboardEvent::WillHide { + time: 0.5, height: 0.0, duration: 0.2, ease: Ease::OutCubic, + }); + assert!(phone.app_dock_for_platform(phone.viewport, true).is_none()); + phone.native_keyboard_event(&VirtualKeyboardEvent::DidHide { time: 1.0 }); + phone.viewport = screen(); + assert_eq!(phone.app_dock_for_platform(screen(), true).unwrap().content, before.content); + phone.screen = PhoneScreen::Recents; + assert!(phone.app_dock_for_platform(screen(), true).is_none()); + } + + #[test] + fn hidden_app_dock_cancels_the_held_action_but_consumes_its_release() { + use crate::mobile::{PhoneGesture, PhoneHit, PhoneScreen, PhoneState}; + let mut phone = PhoneState::default(); + phone.screen = PhoneScreen::App; + phone.touch = Some(42); + phone.gesture = Some(PhoneGesture { + start: center(app_dock(screen()).home), last: center(app_dock(screen()).home), + time: 0.0, hit: Some(PhoneHit::AppNavigation(NavigationHit::Home)), + shell: false, glance_scroll: false, screen: PhoneScreen::App, + }); + phone.native_keyboard_event(&VirtualKeyboardEvent::DidShow { time: 1.0, height: 330.0 }); + assert_eq!(phone.touch, Some(42)); + assert_eq!(phone.gesture.as_ref().unwrap().hit, Some(PhoneHit::AppNavigation(NavigationHit::Dismiss))); + assert!(!phone.gesture.as_ref().unwrap().shell); + phone.native_keyboard_event(&VirtualKeyboardEvent::DidHide { time: 2.0 }); + assert_eq!(phone.gesture.as_ref().unwrap().hit, Some(PhoneHit::AppNavigation(NavigationHit::Dismiss)), "the old Home action must not return with the controls"); + phone.cancel_navigation_input(); + assert!(phone.gesture.is_none() && phone.touch.is_none(), "focus loss may never deliver the old release"); + } + #[test] fn mobile_floating_tap_opens_and_dispatches_home_then_collapses() { let mut nav = FloatingNavigation::default(); diff --git a/crates/shell/src/mobile_surface.rs b/crates/shell/src/mobile_surface.rs index 8f843474..74596b48 100644 --- a/crates/shell/src/mobile_surface.rs +++ b/crates/shell/src/mobile_surface.rs @@ -452,7 +452,7 @@ impl PhoneSurface { PhoneHit::Card(client)=>format!("{}, recent app",state.clients.get(client).map(|c|c.display_title().to_string()).unwrap_or_default()), PhoneHit::Home=>"Home".into(), PhoneHit::Recents=>"Recents".into(), - PhoneHit::Floating(hit)=>match hit { + PhoneHit::Floating(hit)|PhoneHit::AppNavigation(hit)=>match hit { crate::mobile_navigation::NavigationHit::Bubble=>if phone.navigation.open {"Close quick actions"}else{"Floating button: tap for quick actions, drag to move"}.into(), crate::mobile_navigation::NavigationHit::Home=>"Home".into(), crate::mobile_navigation::NavigationHit::Recents=>"Recents".into(), @@ -1072,11 +1072,28 @@ impl PhoneSurface { self.navigation_surface.draw_abs(cx,rect(r.pos.x-12.0,r.pos.y-12.0,r.size.x+24.0,r.size.y+24.0)); } - /// A small app-local control, drawn over content without resizing it. + /// Android apps use reserved navigation; Home and other platforms retain + /// their movable app-local control. fn draw_app_navigation(&mut self, cx: &mut Cx2d, state: &WmState, screen: Rect) { use crate::mobile_navigation::NavigationHit; let nav=&state.phone.navigation; - if !nav.visible() { return; } + if let Some(dock) = state.phone.app_dock(screen) { + let face = self.theme_face(if state.style.dark {rgb(28,28,31)} else {rgb(248,248,252)}); + let ink = self.theme_ink(if state.style.dark {rgb(238,238,242)} else {rgb(30,30,34)}); + self.rounded(cx, dock.bar, 0.0, face); + self.hits.push((dock.bar, PhoneHit::AppNavigation(NavigationHit::Dismiss))); + for (button, hit) in [(dock.home, PhoneHit::AppNavigation(NavigationHit::Home)), (dock.recents, PhoneHit::AppNavigation(NavigationHit::Recents))] { + if self.pressed_hit() == Some(&hit) { self.rounded(cx, button, 8.0, alpha(ink, 0.12)); } + let icon = rect(button.pos.x + (button.size.x - 24.0) * 0.5, button.pos.y + (button.size.y - 24.0) * 0.5, 24.0, 24.0); + if hit == PhoneHit::AppNavigation(NavigationHit::Home) { + self.navigation_home.color = ink; + self.navigation_home.draw_walk(cx, Walk::abs_rect(icon)); + } else { self.d.icon_centered(cx, Ico::WindowRestore, icon, 23.0, ink); } + self.hits.push((button, hit)); + } + return; + } + if !state.phone.floating_navigation_visible() { return; } let layout=nav.layout(state.phone.navigation_rect()); let dark=state.style.dark; let face=self.theme_face(if dark {rgb(39,37,47)} else {rgb(252,251,255)}); @@ -1159,7 +1176,7 @@ impl PhoneSurface { if phone.overview>0.01 { for (index,client) in phone.order.iter().enumerate() { if let Some(slot)=state.clients.get(client) { - let card=card_rect(screen,index as f64,phone.page); + let card=phone.card_rect(screen,index as f64,phone.page); if card.pos.x+card.size.xscreen.pos.x+screen.size.x {continue;} self.icons.draw(cx,&slot.app,state.style.target,rect(card.pos.x+2.0,card.pos.y-36.0,26.0,26.0),phone.overview as f32,ink); self.d.label_elided(cx,rect(card.pos.x+36.0,card.pos.y-36.0,card.size.x-36.0,26.0),true,13.0,alpha(rgb(255,255,255),phone.overview as f32),HAlign::Left,slot.display_title()); @@ -1271,7 +1288,7 @@ impl PhoneSurface { return; } if phone.android.recent_apps.is_empty() {return;} - let card=card_rect(screen,0.0,phone.page); + let card=phone.card_rect(screen,0.0,phone.page); if phone.groups.pick.is_none() && (phone.order.is_empty() || card.pos.y+card.size.y<=y-24.0) { self.d.label(cx,rect(x0,y-24.0,width,20.0),false,12.0,alpha(white,0.75*a),HAlign::Left,"Recent Android apps"); } diff --git a/crates/shell/src/module_resize_tests.rs b/crates/shell/src/module_resize_tests.rs index 53fe3242..2b60a067 100644 --- a/crates/shell/src/module_resize_tests.rs +++ b/crates/shell/src/module_resize_tests.rs @@ -177,6 +177,111 @@ fn set_card_body(cx: &mut Cx, root: &WidgetRef, outer: SplashVmId, body: &str) { }); } +#[test] +fn calendar_saved_event_title_wraps_above_its_details_on_a_narrow_phone() { + let source = include_str!("../../../apps/calendar/bundle/main.splash"); + let title_type = source.lines().find(|line| line.starts_with("let UiTitle = ")).unwrap(); + let title_instance = source.lines().find(|line| line.trim_start().starts_with("event_title := ")).unwrap(); + let (mut cx, tile, root, outer) = hosted_card(); + set_card_body(&mut cx, &root, outer, &format!( + "let ui_ink = theme.color_text\n{title_type}\nView{{width: Fill height: Fill flow: Down padding: 20 spacing: 18\n{title_instance}\nwhen := Label{{text: \"WHEN\"}}\n}}" + )); + widget_tree::set_ui_root(&mut cx, &root); + let title = root.label(&cx, ids!(event_title)); + title.set_text(&mut cx, "Short"); + draw_card(&mut cx, &tile, 360.0, 600.0); + let one_line = title.area().rect(&cx); + title.set_text(&mut cx, "OnePlus acceptance event with a longer appointment title"); + draw_card(&mut cx, &tile, 360.0, 600.0); + let wrapped = title.area().rect(&cx); + let details = root.label(&cx, ids!(when)).area().rect(&cx); + assert!(wrapped.size.y > one_line.size.y, "the saved title must grow instead of clipping: {wrapped:?}"); + assert!(wrapped.pos.x >= 20.0 && wrapped.pos.x + wrapped.size.x <= 340.0); + assert!(details.pos.y >= wrapped.pos.y + wrapped.size.y, "event details must follow all title lines"); + assert_eq!(title.text(), "OnePlus acceptance event with a longer appointment title"); +} + +#[test] +fn maps_location_request_checks_the_contained_app_capability() { + let (mut cx, _tile, root, outer) = hosted_card(); + set_card_body(&mut cx, &root, outer, "View{}"); + let source = root.splash(&cx, ids!(card)).borrow().unwrap().view.source.clone(); + let owner = cx.script_ref_vm_id(&source).unwrap(); + let heap = cx.with_script_vm_id_trusted(owner, |vm| vm.bx.heap.heap_key()); + let request = |cx: &mut Cx| cx.with_script_vm_id_trusted(owner, |vm| { + splash::register_agent_module(vm); + vm.bx.captured_errors = Some(Vec::new()); + let result = script_eval!(vm, {sys.request_location()}); + let errors = vm.take_errors(); + assert!(errors.is_empty(), "{errors:?}"); + result.as_number().unwrap() + }); + for capability in ["net", "location.get", ""] { + splash_policy::set_policy_for_heap(heap, vec![capability.into()], vec![], None); + assert_eq!(request(&mut cx), -1.0, "{capability} cannot start permission consent"); + } + splash_policy::set_policy_for_heap(heap, vec!["location".into()], vec![], None); + assert_eq!(request(&mut cx), if cfg!(target_os = "android") {1.0} else {0.0}, "unsupported platforms must not claim a request started"); + splash_policy::set_policy_for_heap(heap, vec![], vec![], None); + assert_eq!(request(&mut cx), -1.0, "revoking the grant takes effect on the next request"); +} + +#[test] +fn maps_reads_are_passive_and_only_the_location_action_requests_permission() { + let source = include_str!("../../../apps/maps/bundle/main.splash"); + let function = |name: &str| { + let start = source.find(&format!("fn {name}(" )).unwrap(); + source[start..].split_once("\nfn ").unwrap().0 + }; + let functions = ["fix_origin", "location_note", "use_my_location"].map(function).join("\n"); + let code = format!(r#" +use mod.std.assert +mod.requests = 0 +mod.result = 1 +mod.fixed = false +mod.note = "" +mod.note_visible = false +mod.screen = "search" +let origin = {{lat: 37.3350 lon: -121.8850 name: "San Jose (downtown)" picked: false}} +let finding = "origin" +let sys = {{ + request_location: fn() {{mod.requests = mod.requests + 1; return mod.result}} + gps: fn(field) {{if !mod.fixed {{return 0}}; if field == "lat" {{return 37.7}}; if field == "lon" {{return -122.4}}; return 1}} +}} +let ui = {{location_status: {{ + set_text: fn(text) {{mod.note = text}} + set_visible: fn(value) {{mod.note_visible = value}} +}}}} +fn show(name) {{mod.screen = name}} +{functions} +fix_origin() +fix_origin() +assert(mod.requests == 0) +use_my_location() +assert(mod.requests == 1) +assert(origin.name == "San Jose (downtown)") +assert(mod.note_visible && mod.note != "") +assert(mod.screen == "route") +mod.fixed = true +fix_origin() +assert(mod.requests == 1) +assert(origin.name == "Your location" && origin.lat == 37.7 && origin.lon == -122.4) +assert(mod.note == "" && !mod.note_visible) +mod.fixed = false +mod.result = -1 +use_my_location() +assert(mod.note == "This app does not have location access. Choose an origin instead.") +1 +"#); + let mut host = ScriptVmHost::new((), ()); + let mut vm = ScriptVm { host: &mut host, bx: Box::new(ScriptVmBase::new()) }; + vm.bx.captured_errors = Some(Vec::new()); + let result = vm.eval(ScriptMod { file: "maps_location_test.splash".into(), code, ..Default::default() }); + let errors = vm.take_errors(); + assert!(errors.is_empty(), "{errors:?}"); + assert_eq!(result.as_number(), Some(1.0)); +} + #[test] fn permission_dialog_pause_and_resume_leave_a_new_video_ready_for_its_first_source() { let mut cx = Cx::new(Box::new(|_, _| {})); @@ -382,18 +487,114 @@ fn card_runner_restyles_nested_script_without_replacing_draft() { WidgetRef::script_from_value(vm, value) }); tile.borrow_mut::().unwrap().set_root(&mut cx, 1, owner, root.clone()); - let probe = format!("{}\n{}", include_str!("../../../apps/interface.splash"), - r#"let ink = ui_ink - SolidView{draw_bg.color: ui_page label := UiTitle{text: "Theme" draw_text.color: ink} input := UiField{text: "Draft"}}"#); - set_card_body(&mut cx, &root, owner, &probe); + // Exercise the Card runner's nested-isolate restyle with only the two + // widgets whose identity/state matter here. Loading the complete shared + // interface made this boundary test spend its 64 ms startup budget on + // unrelated widget prototypes under concurrent CI load. Full interface + // colors are covered by system_app_theme_tests; keep the runtime budget. + let probe = r#"let theme = mod.theme + let ink = theme.color_text + SolidView{draw_bg.color: theme.color_bg_app + label := Label{text: "Theme" draw_text.color: ink} + input := TextInput{text: "Draft"}} + "#; + set_card_body(&mut cx, &root, owner, probe); draw_card(&mut cx, &tile, 400.0, 700.0); let label = root.label(&mut cx, ids!(label)); - let light = label.borrow().unwrap().draw_text.color; - root.text_input(&mut cx, ids!(input)).set_text(&mut cx, "Unsaved draft"); + let light = label.borrow().expect("the contained theme probe initialized").draw_text.color; + let input = root.text_input(&mut cx, ids!(input)); + let input_id = input.widget_uid(); + input.set_text(&mut cx, "Unsaved draft"); host.apply_style(&mut cx, &sheet(true)); draw_card(&mut cx, &tile, 400.0, 700.0); let dark = label.borrow().unwrap().draw_text.color; assert_ne!(light, dark, "the Card runner must restyle its hosted app, not only its outer view"); + assert_eq!(root.text_input(&mut cx, ids!(input)).widget_uid(), input_id, "restyle must retain the live editor"); assert_eq!(root.text_input(&mut cx, ids!(input)).text(), "Unsaved draft"); host.teardown(&mut cx, 1); } + +#[test] +fn card_runner_restyle_keeps_dynamic_labels_wrapped_and_explicit_no_wrap() { + use makepad_app_module::AppModule; + let mut cx = Cx::new(Box::new(|_, _| {})); + cx.with_vm(makepad_widgets::script_mod); + let mut host = crate::module_host::ModuleHost::default(); + let sheet = |dark| desktop_style::StyleSheet::load_with_appearance(desktop_style::DesktopStyle::Android, dark); + host.apply_style(&mut cx, &sheet(false)); + let module = &octosense_appstore::cardapp::CARD_MODULE; + let open = module.open_schema().validate("{\"app\":\"os.calendar\"}", &[]).unwrap(); + host.create(&mut cx, 1, module, open, dvec2(390.0, 700.0)).unwrap(); + let root = host.get(1).unwrap().root.clone(); + let owner = host.get(1).unwrap().vm_id; + let tile = cx.with_vm(|vm| { + script_eval!(vm, {mod.wm_theme = {background: #ffffff}}); + crate::module_view::script_mod(vm); + let value = script_eval!(vm, {use mod.widgets.* MpModuleView{}}); + WidgetRef::script_from_value(vm, value) + }); + tile.borrow_mut::().unwrap().set_root(&mut cx, 1, owner, root.clone()); + set_card_body(&mut cx, &root, owner, r#" +fn populate(subject, message){ + ui.subject.set_text(subject) + ui.message_body.set_text(message) + ui.workspace.set_visible(true) +} +SolidView{width:Fill height:Fill flow:Down padding:16 spacing:10 +workspace := View{width:Fill height:Fill flow:Down spacing:10 visible:false +subject := Label{width:Fill text:"" draw_text.text_style:theme.font_bold{font_size:18}} +message_view := View{width:Fill height:Fill flow:Down spacing:10 +ScrollYView{width:Fill height:Fill flow:Down +message_body := Label{width:Fill text:"" draw_text.text_style.font_size:16} +}} +Button{text:"Compose reply"} +explicit_line := Label{width:Fill flow:Flow.Right{wrap:false} text:"Explicitly single line content stays single line across every retained restyle"} +input := TextInput{width:Fill text:"Draft"} +} +} +} +"#); + widget_tree::set_ui_root(&mut cx, &root); + draw_card(&mut cx, &tile, 390.0, 700.0); + let subject=root.label(&cx,ids!(subject)); + let message=root.label(&cx,ids!(message_body)); + let subject_text = "A longer appointment title requiring more than one line on a narrow phone display"; + let message_text = "Hello,\n\nYour appointment is Tuesday, October 6 at 9:00 AM Pacific. Please confirm this time or suggest another appointment.\n\nCedar Clinic"; + // Match an async app callback: initially blank labels live in a hidden + // workspace, then the app's own script populates and shows them. + assert!(root.splash(&cx, ids!(card)).call_script_fn_with_strings( + &mut cx, live_id!(populate), &[subject_text, message_text] + )); + // ui.* setters suspend script execution until the widget task pump runs, + // as they do after an actual host callback. + makepad_widgets::makepad_platform::makepad_script_std::handle_script_tasks(&mut cx); + let subject_uid = subject.widget_uid(); + let message_uid = message.widget_uid(); + let input = root.text_input(&cx, ids!(input)); + let input_uid = input.widget_uid(); + input.set_text(&mut cx, "Unsaved draft"); + for restyle in [None,Some(true),Some(false)] { + if let Some(dark)=restyle {host.apply_style(&mut cx,&sheet(dark));} + for (width,height) in [(390.0,700.0),(390.0,320.0)] { + draw_card(&mut cx,&tile,width,height); + for (name,label) in [("subject",&subject),("message_body",&message)] { + let area=label.area().rect(&cx); + let text=label.borrow().unwrap().text_layout_rect; + assert!(text.size.x <= area.size.x, "{restyle:?} {name}: text overflows {text:?}, frame {area:?}"); + assert!(area.size.x<=width-32.0 && area.size.x>0.0); + assert!(area.size.y>40.0,"expected wrapped text after blank-set_text/show/restyle"); + } + let nowrap = root.label(&cx, ids!(explicit_line)); + let nowrap = nowrap.borrow().unwrap(); + assert!(nowrap.text_layout_rect.size.x > nowrap.area().rect(&cx).size.x, "explicit no-wrap must be respected"); + assert!(nowrap.text_layout_rect.size.y < 40.0); + assert_eq!(subject.widget_uid(), subject_uid); + assert_eq!(message.widget_uid(), message_uid); + assert_eq!(subject.text(), subject_text); + assert_eq!(message.text(), message_text); + assert_eq!(root.text_input(&cx, ids!(input)).widget_uid(), input_uid); + assert_eq!(input.text(), "Unsaved draft"); + } + } + host.teardown(&mut cx,1); +} diff --git a/crates/shell/src/system_app_theme_tests.rs b/crates/shell/src/system_app_theme_tests.rs index 6e505585..5150086c 100644 --- a/crates/shell/src/system_app_theme_tests.rs +++ b/crates/shell/src/system_app_theme_tests.rs @@ -7,6 +7,87 @@ use makepad_widgets::{desktop_style, *}; const NEWS: &str = include_str!("../../../apps/news/bundle/main.splash"); +#[test] +fn camera_recording_failure_settles_controls_without_stopping_a_healthy_recording() { + let source = include_str!("../../../apps/camera/bundle/main.splash"); + let logic = source.split_once("\nstart_timeout(").expect("Camera boot boundary").0; + let mut cx = Cx::new(Box::new(|_, _| {})); + cx.with_vm(|vm| { + makepad_widgets::script_mod(vm); + vm.bx.captured_errors = Some(Vec::new()); + // Execute the shipped app's callbacks in the native VM. Camera, + // timers and rendering endpoints are test doubles: this verifies app + // state and endpoint calls, not pixels or successful hardware capture. + let value = vm.eval(ScriptMod { + file: "camera_recording_failure_test.splash".into(), + code: format!(r#" +use mod.std.assert +let fs = {{}} +let native_recording = false +let native_error = "" +let timer_visible = false +let retry_visible = false +let toast = "" +let timers = [] +let stopped = [] +let stop_requests = 0 +fn time_now() {{ 123 }} +fn start_interval(seconds, callback) {{ timers.push(callback); timers.len() }} +fn stop_timer(timer) {{ stopped.push(timer) }} +let widget = {{render: fn(){{}} set_text: fn(text){{}}}} +let ui = {{ + cam: {{ + record_start: fn(){{ native_recording = true; true }} + record_stop: fn(){{ stop_requests += 1; native_recording = false }} + is_recording: fn(){{ native_recording }} + error: fn(){{ native_error }} + set_aspect: fn(aspect){{}} + }} + rec: widget + rec_box: {{set_visible: fn(value){{ timer_visible = value }}}} + shutter_face: widget modes: widget + toast: {{set_text: fn(value){{ toast = value }}}} + retry_camera: {{set_visible: fn(value){{ retry_visible = value }}}} +}} +{logic} +set_mode("video") +start_recording() +assert(recording && timer_visible && rec_timer == 1) +// Android queues a request, then reports that recording is unsupported. +native_recording = false +native_error = "video failed: recording is not implemented on Android yet" +failed() +assert(!recording && !timer_visible && rec_timer == nil) +assert(stopped.len() == 1 && stopped[0] == 1) +assert(stop_requests == 0 && retry_visible) +assert(toast == native_error) +set_mode("photo") +assert(mode == "photo") +failed() +assert(stopped.len() == 1) +// An unrelated error while the native recorder remains active must not +// discard the UI state or permit a mode change during that recording. +set_mode("video") +start_recording() +native_error = "photo failed: synthetic still failure" +failed() +assert(recording && timer_visible && rec_timer == 2) +assert(stopped.len() == 1 && stop_requests == 0) +set_mode("photo") +assert(mode == "video") +stop_recording() +assert(!recording && !timer_visible && rec_timer == nil) +assert(stopped.len() == 2 && stopped[1] == 2 && stop_requests == 1) +true +;"#), + ..Default::default() + }); + let errors = vm.take_errors(); + assert!(errors.is_empty(), "Camera app errors: {errors:?}"); + assert!(!value.is_err(), "Camera callbacks returned {value:?}"); + }); +} + /// News's styles: its `let`s, without the boot before them or the view after. fn news_styles() -> String { let after_boot = NEWS.split_once("\nlet ink = ").unwrap().1; @@ -16,12 +97,15 @@ fn news_styles() -> String { /// Each colour in `expressions`, evaluated after News's styles in a VM /// themed as a hosted app is in `style` and appearance. fn news_colors(style: DesktopStyle, dark: bool, expressions: &[&str]) -> Vec { + script_colors(style, dark, &news_styles(), expressions) +} + +fn script_colors(style: DesktopStyle, dark: bool, styles: &str, expressions: &[&str]) -> Vec { let mut cx = Cx::new(Box::new(|_, _| {})); cx.with_vm(|vm| { makepad_widgets::script_mod(vm); desktop_style::install(vm, load_sheet(style, dark)); vm.with_reload(makepad_widgets::script_mod); - let styles = news_styles(); expressions .iter() .map(|expression| { @@ -39,6 +123,110 @@ fn news_colors(style: DesktopStyle, dark: bool, expressions: &[&str]) -> Vec= 4.5, "{} dark={dark}, state={state}: {:08x} on {:08x}", style.id(), pair[1], pair[0]); + } + } + } +} + #[test] fn system_app_interface_keeps_text_and_controls_readable_in_every_appearance() { for style in DesktopStyle::ALL { diff --git a/native-apps.json b/native-apps.json index 1fa2ff9f..86c01534 100644 --- a/native-apps.json +++ b/native-apps.json @@ -100,7 +100,7 @@ "id": "apphub", "feature": "app-hub", "crate": "octosense-app-hub-app", - "source": { "git": "https://github.com/OctoSense-org/OctoSense-App-Hub", "rev": "f762267864fc3d68631b46924899cff324f7d157", "local": null }, + "source": { "git": "https://github.com/OctoSense-org/OctoSense-App-Hub", "rev": "d2ca3a30ce06b0b1390cff305520962731baa1f8", "local": null }, "module": ["octosense_app_hub_app::APP_HUB_MODULE", "octosense_app_hub_app::CARD_MODULE"], "bin": null, "default_features": true, diff --git a/phone/README.md b/phone/README.md index 7415fb8d..bf7f195b 100644 --- a/phone/README.md +++ b/phone/README.md @@ -215,6 +215,8 @@ does not establish that integration. | App tile | long press | Remove the tile (the home menu's "Show hidden tiles" brings them back) | | Empty home | long press | Widgets, Light/Dark appearance, Grid: 4 or 5 columns, Pull-downs (launcher shade or system-wide panel), System setup, Show hidden tiles | +Android hosted apps reserve a 48-point bottom row for Home and Recents, outside the app's drawing and input viewport. The row disappears while the keyboard is shown, returning that space to the editor; Android's system Back remains available. Home and OpenHarmony retain their existing floating controls. Startup and resume no longer request location permission for the legacy GPS cache: it updates only when permission was already granted. Maps requests location only after **Your location** is tapped. That action requires the app's location grant and opens Android's normal permission flow; cached GPS reads and startup never request it. Until a real fix arrives, Maps keeps the previous named origin and explains how to choose another. Retained theme changes also keep existing labels wrapped, including message bodies filled after the view opens; an app’s explicit single-line flow remains respected. These follow-up changes require renewed device acceptance. + On Android the top edge is the system's: it opens the system-wide OctoSense panel when that is on (the OctoSense ROM; Home's side pulls then open search), and Android's own panel otherwise. OpenHarmony has no shade. A pull commits from 40 % of the way (≈135 px on a 1080-wide phone); navigation swipes need the full distance or a flick. While a pull is in flight the page dims and a search field rises from the bottom with the finger; a committed gesture gives a short haptic tick. Until each hidden gesture has been used once, the home page shows a one-line hint for it (`crates/shell/src/mobile_hints.rs`; Android remembers what was seen). A second Home press on a settled home page returns to the primary page. diff --git a/phone/README.zh-CN.md b/phone/README.zh-CN.md index 4b776891..07fee22a 100644 --- a/phone/README.zh-CN.md +++ b/phone/README.zh-CN.md @@ -153,6 +153,8 @@ Home 贡献卡片。启动器验收必须从 Android 实际指定的 Home 开始 | 应用磁贴 | 长按 | 移除该磁贴(桌面菜单中的“显示隐藏的磁贴”可将其恢复) | | 桌面空白处 | 长按 | 小组件、浅色/深色外观、网格:4 列或 5 列、下拉方式(启动器通知面板或系统级面板)、系统设置、显示隐藏的磁贴 | +Android 托管应用在底部预留 48 个逻辑像素的导航栏,提供桌面和最近任务图标;应用的绘制与输入区域不包含这条栏。键盘显示时导航栏隐藏,把空间归还编辑器;Android 系统返回键仍可使用。桌面和 OpenHarmony 保留现有悬浮控制。启动与恢复时,旧 GPS 缓存不再主动申请定位权限,只在已经授权时更新;Maps 仅在点按 **Your location(你的位置)** 后申请定位。该操作要求应用具备定位授权,并使用 Android 标准权限流程;读取 GPS 缓存和启动均不触发申请。真实定位到达前,Maps 保留原来有名称的起点,并提示可以另选起点。原位切换主题时,已有标签(包括打开界面后才填入的邮件正文)仍保持换行,应用明确指定的单行布局不受影响。这些后续改动仍需重新进行设备验收。 + 在 Android 上,顶部边缘归系统所有:开启系统级 OctoSense 面板时(OctoSense ROM),它打开该面板,此时在桌面两侧下拉会打开搜索;否则打开 Android 自己的面板。OpenHarmony 上没有通知面板。 下拉手势在拉到 40 % 时即生效(在 1080 像素宽的手机上约为 135 px);导航类滑动则需要滑完整段距离或快速轻扫。下拉过程中页面会变暗,搜索框随手指从底部升起;手势生效时会有一次短促的触感反馈。每个隐藏手势在首次使用之前,桌面页面都会显示一行对应提示(`crates/shell/src/mobile_hints.rs`;Android 会记住哪些提示已经看过)。在已停稳的桌面页面上再次按下 Home,会回到主页面。 diff --git a/runtime-patches.lock.json b/runtime-patches.lock.json index 7ca508f5..273a1e3e 100644 --- a/runtime-patches.lock.json +++ b/runtime-patches.lock.json @@ -4,7 +4,7 @@ "base_revision": "68d1f4ecc111daa90c50530e77df3245f05fc2cf", "patch": "tools/runtime-patches/makepad-settings.patch", "sha256": "b17352ebf59e50ab3db6ff1cb825c4e006bcdff47d83522e372b8fe585e57570", - "tree": "566ae606dee20138d2e3173b48ec75c608a26919", + "tree": "c8cf9450e3b00bde30fdf5f3d9b9a8351b803bc3", "product_fixes": [ "Settings integration: Android IME synchronization, accessibility and reduced motion, GLES program-cache validation, obscured-gesture cancellation and scroll-position observation.", "Contained-app isolation and secret-input restrictions come from the pinned upstream runtime; the Settings overlay preserves those gates.", @@ -19,7 +19,11 @@ "Host-owned backend authentication reuses native WebViews with isolated sessions, exact callback interception, same-origin navigation and no page tool bridge; Android uses an ephemeral private process and profile.", "Android Camera waits for one permission answer and refreshes an empty camera list after grant. Video pause/resume preserves an unprepared player so first-use authorization cannot prevent its initial source assignment.", "Video uses its resolved drawable size for aspect-preserving fill, including Fill/Fit and resized layouts, so rotated Android camera previews are cropped instead of stretched.", - "Android QR scanning selects a supported common preview/YUV buffer size before opening Camera2, fits the preview without stretching, and ignores callbacks from a previous scanner session." + "Android QR scanning selects a supported common preview/YUV buffer size before opening Camera2, fits the preview without stretching, and ignores callbacks from a previous scanner session.", + "Android startup and resume never request location for the legacy GPS cache; it uses existing grants only. Explicit location actions retain the normal permission flow.", + "Android Maps requests its first location grant only from Your location through the app location capability and the existing foreground GPS listener/permission callback; GPS reads stay passive.", + "Label keeps its native wrapping default across retained theme reapplication; named labels populated after mounting stay wrapped, while explicit app no-wrap flows remain unchanged.", + "Android native Camera checks each startup stage, cleans partially initialized resources, reports missing first frames after a bounded wait, and retries only after an explicit user action; unrelated video events cannot change its status." ], "stacked": [ { @@ -69,6 +73,22 @@ { "patch": "tools/runtime-patches/makepad-qr-preview-size.patch", "sha256": "8866e751196dc1b33d8a4cc5de1872cd62999f4d565b1bc903327c3dd8dfa676" + }, + { + "patch": "tools/runtime-patches/makepad-location-explicit-consent.patch", + "sha256": "e4490b7a6e717b2737431c99bd9486ea14c272a45effdd3ddab2d679f4fa5c63" + }, + { + "patch": "tools/runtime-patches/makepad-location-request.patch", + "sha256": "9cd0980e8d07a24bf0a650e5e4f8bddc736cfa4bf143ced72d772d5f3f6f1e06" + }, + { + "patch": "tools/runtime-patches/makepad-label-restyle-wrap.patch", + "sha256": "caffe63992d32aa5295af426903d2e6ef028cdb5d831336fa43ea79710d34305" + }, + { + "patch": "tools/runtime-patches/makepad-camera-checked-start.patch", + "sha256": "f28349631a2f7b851c0d5289ebebfa387a020170a69793f65c4f2f985504ffc0" } ] } diff --git a/tools/runtime-patches/makepad-camera-checked-start.patch b/tools/runtime-patches/makepad-camera-checked-start.patch new file mode 100644 index 00000000..c50f841f --- /dev/null +++ b/tools/runtime-patches/makepad-camera-checked-start.patch @@ -0,0 +1,803 @@ +--- a/platform/src/os/linux/android/android.rs ++++ b/platform/src/os/linux/android/android.rs +@@ -1975,6 +1975,7 @@ + None + }; + let mut events = Vec::new(); ++ let mut failed = Vec::new(); + + for (_video_id, player) in players.iter_mut() { + match player.check_prepared() { +@@ -1997,10 +1998,12 @@ + })); + } + Some(Err(err)) => { ++ failed.push(player.video_id); + events.push(Event::VideoDecodingError(VideoDecodingErrorEvent { + video_id: player.video_id, + error: err, + })); ++ continue; + } + None => {} + } +@@ -2084,6 +2087,12 @@ + } + } + ++ for id in &failed { ++ if let Some(mut player) = players.remove(id) { player.cleanup(); } ++ } ++ if !failed.is_empty() && players.is_empty() { ++ self.os.media.android_camera().lock().unwrap().use_video_input(&[]); ++ } + self.os.camera_players = players; + for event in events { + self.call_event_handler(&event); +--- a/platform/src/os/linux/android/android_camera.rs ++++ b/platform/src/os/linux/android/android_camera.rs +@@ -95,10 +95,12 @@ + controls: CameraControlState, + dispatch: Arc>, + session: Option, ++ start_error: Option, + preview_window: *mut ANativeWindow, + needs_image_reader: bool, + } + ++#[derive(Default)] + pub struct AndroidCaptureSession { + capture_session: *mut ACameraCaptureSession, + output_container: *mut ACaptureSessionOutputContainer, +@@ -562,7 +564,16 @@ + preview_window: Option<*mut ANativeWindow>, + needs_image_reader: bool, + still: Option<(VideoInputId, i32, i32)>, +- ) -> Option { ++ ) -> Result { ++ fn check(stage: &str, status: i32) -> Result<(), String> { ++ crate::log!("camera startup stage={stage} status={status}"); ++ if status == 0 { Ok(()) } ++ else { Err(format!("camera startup {stage} failed (status {status})")) } ++ } ++ fn pointer(stage: &str, value: *mut T) -> Result<(), String> { ++ if value.is_null() { Err(format!("camera startup {stage} returned no resource")) } ++ else { Ok(()) } ++ } + let reader_mode = { + let guard = dispatch.lock().unwrap(); + if !guard.preview_hardware_buffer_input_cbs.is_empty() +@@ -570,252 +581,131 @@ + && guard.frame_input_cbs.is_empty() + && guard.preview_frame_input_cbs.is_empty() + && guard.encoders.is_empty() +- { +- AndroidImageReaderMode::HardwareBufferYuv +- } else { +- AndroidImageReaderMode::CpuReadable +- } ++ { AndroidImageReaderMode::HardwareBufferYuv } ++ else { AndroidImageReaderMode::CpuReadable } + }; +- let alive = Arc::new(AtomicBool::new(true)); +- let capture_context = Box::into_raw(Box::new(AndroidCaptureContext { +- format, +- dispatch, +- alive, +- reader_mode, ++ crate::log!("camera startup begin; preview={}x{} still={:?}", format.width, format.height, ++ still.map(|(_, width, height)| (width, height))); ++ let mut session = Self::default(); ++ session.capture_context = Box::into_raw(Box::new(AndroidCaptureContext { ++ format, dispatch, alive: Arc::new(AtomicBool::new(true)), reader_mode, + logged_first_hardware_buffer_frame: AtomicBool::new(false), + })); +- +- let mut device_callbacks = ACameraDevice_StateCallbacks { +- onError: Some(Self::device_on_error), +- onDisconnected: Some(Self::device_on_disconnected), +- context: capture_context as *mut _, +- }; +- let mut camera_device = std::ptr::null_mut(); +- +- if ACameraManager_openCamera( +- manager, +- camera_id.as_ptr(), +- &mut device_callbacks, +- &mut camera_device, +- ) != 0 +- { +- crate::log!("Error opening android camera"); +- let _ = Box::from_raw(capture_context); +- return None; +- }; +- +- let mut capture_request = std::ptr::null_mut(); +- ACameraDevice_createCaptureRequest(camera_device, TEMPLATE_PREVIEW, &mut capture_request); +- +- let mut image_reader = std::ptr::null_mut(); +- let mut image_window = std::ptr::null_mut(); +- let mut image_target = std::ptr::null_mut(); +- let mut image_output = std::ptr::null_mut(); +- +- if needs_image_reader { +- let image_reader_result = match reader_mode { +- AndroidImageReaderMode::HardwareBufferYuv => AImageReader_newWithUsage( +- format.width as _, +- format.height as _, +- AIMAGE_FORMAT_YUV_420_888, +- ndk_sys::AHARDWAREBUFFER_USAGE_GPU_SAMPLED_IMAGE, +- 3, +- &mut image_reader, +- ), +- AndroidImageReaderMode::CpuReadable => { +- let aimage_format = match format.pixel_format { +- VideoPixelFormat::YUV420 => AIMAGE_FORMAT_YUV_420_888, +- VideoPixelFormat::MJPEG => AIMAGE_FORMAT_JPEG, +- _ => { +- crate::log!( +- "Android camera pixelformat not possible, should not happen" +- ); +- ACameraDevice_close(camera_device); +- let _ = Box::from_raw(capture_context); +- return None; +- } +- }; +- AImageReader_new( +- format.width as _, +- format.height as _, +- aimage_format, +- 2, +- &mut image_reader, +- ) ++ // Every acquired resource belongs to this partial session. A failed ++ // stage closes it; no null/incomplete session is reported as running. ++ let result = (|| -> Result<(), String> { ++ let mut callbacks = ACameraDevice_StateCallbacks { ++ onError: Some(Self::device_on_error), ++ onDisconnected: Some(Self::device_on_disconnected), ++ context: session.capture_context as *mut _, ++ }; ++ check("open-device", ACameraManager_openCamera(manager, camera_id.as_ptr(), ++ &mut callbacks, &mut session.camera_device))?; ++ pointer("open-device", session.camera_device)?; ++ check("preview-request", ACameraDevice_createCaptureRequest(session.camera_device, ++ TEMPLATE_PREVIEW, &mut session.capture_request))?; ++ pointer("preview-request", session.capture_request)?; ++ ++ if needs_image_reader { ++ let status = match reader_mode { ++ AndroidImageReaderMode::HardwareBufferYuv => AImageReader_newWithUsage( ++ format.width as _, format.height as _, AIMAGE_FORMAT_YUV_420_888, ++ ndk_sys::AHARDWAREBUFFER_USAGE_GPU_SAMPLED_IMAGE, 3, &mut session.image_reader), ++ AndroidImageReaderMode::CpuReadable => { ++ let image_format = match format.pixel_format { ++ VideoPixelFormat::YUV420 => AIMAGE_FORMAT_YUV_420_888, ++ VideoPixelFormat::MJPEG => AIMAGE_FORMAT_JPEG, ++ _ => return Err("camera startup has an unsupported image format".into()), ++ }; ++ AImageReader_new(format.width as _, format.height as _, image_format, ++ 2, &mut session.image_reader) ++ } ++ }; ++ check("preview-reader", status)?; ++ pointer("preview-reader", session.image_reader)?; ++ let mut listener = AImageReader_ImageListener { ++ context: session.capture_context as *mut _, ++ onImageAvailable: Some(Self::image_on_image_available), ++ }; ++ check("preview-listener", AImageReader_setImageListener(session.image_reader, &mut listener))?; ++ let mut window = std::ptr::null_mut(); ++ check("preview-window", AImageReader_getWindow(session.image_reader, &mut window))?; ++ pointer("preview-window", window)?; ++ ANativeWindow_acquire(window); ++ session.image_window = window; ++ check("preview-target", ACameraOutputTarget_create(window, &mut session.image_target))?; ++ pointer("preview-target", session.image_target)?; ++ check("preview-request-target", ACaptureRequest_addTarget(session.capture_request, session.image_target))?; ++ check("preview-output", ACaptureSessionOutput_create(window, &mut session.image_output))?; ++ pointer("preview-output", session.image_output)?; ++ if reader_mode == AndroidImageReaderMode::CpuReadable && format.pixel_format == VideoPixelFormat::MJPEG { ++ let quality = 60u8; ++ check("preview-jpeg-quality", ACaptureRequest_setEntry_u8(session.capture_request, ++ ACAMERA_JPEG_QUALITY, 1, &quality))?; + } ++ } ++ ++ if let Some((input_id, width, height)) = still { ++ check("still-reader", AImageReader_new(width, height, AIMAGE_FORMAT_JPEG, ++ 2, &mut session.still_reader))?; ++ pointer("still-reader", session.still_reader)?; ++ session.still_context = Box::into_raw(Box::new(AndroidStillContext { ++ input_id, pending: Mutex::new(None), alive: Arc::new(AtomicBool::new(true)), ++ })); ++ let mut listener = AImageReader_ImageListener { ++ context: session.still_context as *mut _, onImageAvailable: Some(Self::still_on_image_available), ++ }; ++ check("still-listener", AImageReader_setImageListener(session.still_reader, &mut listener))?; ++ let mut window = std::ptr::null_mut(); ++ check("still-window", AImageReader_getWindow(session.still_reader, &mut window))?; ++ pointer("still-window", window)?; ++ ANativeWindow_acquire(window); ++ session.still_window = window; ++ check("still-target", ACameraOutputTarget_create(window, &mut session.still_target))?; ++ pointer("still-target", session.still_target)?; ++ check("still-output", ACaptureSessionOutput_create(window, &mut session.still_output))?; ++ pointer("still-output", session.still_output)?; ++ } ++ check("output-container", ACaptureSessionOutputContainer_create(&mut session.output_container))?; ++ pointer("output-container", session.output_container)?; ++ for (stage, output) in [("still-output-add", session.still_output), ("preview-output-add", session.image_output)] { ++ if !output.is_null() { check(stage, ACaptureSessionOutputContainer_add(session.output_container, output))?; } ++ } ++ if let Some(window) = preview_window.filter(|w| !w.is_null()) { ++ ANativeWindow_acquire(window); ++ session.preview_window = window; ++ check("native-preview-target", ACameraOutputTarget_create(window, &mut session.preview_target))?; ++ pointer("native-preview-target", session.preview_target)?; ++ check("native-preview-request-target", ACaptureRequest_addTarget(session.capture_request, session.preview_target))?; ++ check("native-preview-output", ACaptureSessionOutput_create(window, &mut session.preview_output))?; ++ pointer("native-preview-output", session.preview_output)?; ++ check("native-preview-output-add", ACaptureSessionOutputContainer_add(session.output_container, session.preview_output))?; ++ } ++ if session.image_output.is_null() && session.preview_output.is_null() { ++ return Err("camera startup has no preview output".into()); ++ } ++ let callbacks = ACameraCaptureSession_stateCallbacks { ++ context: session.capture_context as *mut _, onClosed: Some(Self::session_on_closed), ++ onReady: Some(Self::session_on_ready), onActive: Some(Self::session_on_active), + }; +- if image_reader_result != 0 || image_reader.is_null() { +- crate::warning!( +- "Android camera: failed to create image reader for mode {:?}", +- reader_mode as u32 +- ); +- ACameraDevice_close(camera_device); +- let _ = Box::from_raw(capture_context); +- return None; +- } +- +- let mut image_listener = AImageReader_ImageListener { +- context: capture_context as *mut _, +- onImageAvailable: Some(Self::image_on_image_available), +- }; +- +- AImageReader_setImageListener(image_reader, &mut image_listener); +- +- AImageReader_getWindow(image_reader, &mut image_window); +- ANativeWindow_acquire(image_window); +- +- let image_target_result = ACameraOutputTarget_create(image_window, &mut image_target); +- if !image_target.is_null() { +- let _ = ACaptureRequest_addTarget(capture_request, image_target); +- } else { +- crate::warning!( +- "Android camera: image target creation failed result={} mode={:?} size={}x{}", +- image_target_result, +- reader_mode as u32, +- format.width, +- format.height, +- ); +- } +- +- if reader_mode == AndroidImageReaderMode::CpuReadable +- && format.pixel_format == VideoPixelFormat::MJPEG +- { +- let jpeg_quality = 60u8; +- ACaptureRequest_setEntry_u8( +- capture_request, +- ACAMERA_JPEG_QUALITY, +- 1, +- &jpeg_quality, +- ); +- } +- +- let _ = ACaptureSessionOutput_create(image_window, &mut image_output); +- } +- +- // A still needs its own JPEG stream, declared before the session is +- // configured: Android fixes the output set at that point. +- let mut still_reader = std::ptr::null_mut(); +- let mut still_window = std::ptr::null_mut(); +- let mut still_output = std::ptr::null_mut(); +- let mut still_target = std::ptr::null_mut(); +- let mut still_context = std::ptr::null_mut(); +- if let Some((input_id, still_width, still_height)) = still { +- if AImageReader_new( +- still_width, +- still_height, +- AIMAGE_FORMAT_JPEG, +- 2, +- &mut still_reader, +- ) == 0 +- && !still_reader.is_null() +- { +- still_context = Box::into_raw(Box::new(AndroidStillContext { +- input_id, +- pending: Mutex::new(None), +- alive: Arc::new(AtomicBool::new(true)), +- })); +- let mut still_listener = AImageReader_ImageListener { +- context: still_context as *mut _, +- onImageAvailable: Some(Self::still_on_image_available), +- }; +- AImageReader_setImageListener(still_reader, &mut still_listener); +- AImageReader_getWindow(still_reader, &mut still_window); +- ANativeWindow_acquire(still_window); +- let _ = ACameraOutputTarget_create(still_window, &mut still_target); +- let _ = ACaptureSessionOutput_create(still_window, &mut still_output); +- } else { +- crate::warning!("Android camera: no still stream at {still_width}x{still_height}"); +- } +- } +- +- let mut output_container = std::ptr::null_mut(); +- ACaptureSessionOutputContainer_create(&mut output_container); +- if !still_output.is_null() { +- ACaptureSessionOutputContainer_add(output_container, still_output); +- } +- +- if !image_output.is_null() { +- ACaptureSessionOutputContainer_add(output_container, image_output); +- } +- +- let mut preview_target = std::ptr::null_mut(); +- let mut preview_output = std::ptr::null_mut(); +- let mut preview_window_ptr = std::ptr::null_mut(); +- if let Some(preview_window) = preview_window { +- if !preview_window.is_null() { +- preview_window_ptr = preview_window; +- ANativeWindow_acquire(preview_window_ptr); +- let _ = ACameraOutputTarget_create(preview_window_ptr, &mut preview_target); +- if !preview_target.is_null() { +- let _ = ACaptureRequest_addTarget(capture_request, preview_target); +- } +- let _ = ACaptureSessionOutput_create(preview_window_ptr, &mut preview_output); +- if !preview_output.is_null() { +- ACaptureSessionOutputContainer_add(output_container, preview_output); +- } +- } +- } +- +- if image_output.is_null() && preview_output.is_null() { +- ACaptureSessionOutputContainer_free(output_container); +- ACaptureRequest_free(capture_request); +- ACameraDevice_close(camera_device); +- let _ = Box::from_raw(capture_context); +- return None; +- } +- +- let session_callbacks = ACameraCaptureSession_stateCallbacks { +- context: capture_context as *mut _, +- onClosed: Some(Self::session_on_closed), +- onReady: Some(Self::session_on_ready), +- onActive: Some(Self::session_on_active), +- }; +- +- let mut capture_session = std::ptr::null_mut(); +- +- let _ = ACameraDevice_createCaptureSession( +- camera_device, +- output_container, +- &session_callbacks, +- &mut capture_session, +- ); +- let mut capture_callbacks = ACameraCaptureSession_captureCallbacks { +- context: capture_context as *mut _, +- onCaptureStarted: Some(Self::capture_on_started), +- onCaptureProgressed: Some(Self::capture_on_progressed), +- onCaptureCompleted: Some(Self::capture_on_completed), +- onCaptureFailed: Some(Self::capture_on_failed), +- onCaptureSequenceCompleted: Some(Self::capture_on_sequence_completed), +- onCaptureSequenceAborted: Some(Self::capture_on_sequence_aborted), +- onCaptureBufferLost: Some(Self::capture_on_buffer_lost), +- }; +- +- let _ = ACameraCaptureSession_setRepeatingRequest( +- capture_session, +- &mut capture_callbacks, +- 1, +- &mut capture_request, +- std::ptr::null_mut(), +- ); +- Some(Self { +- image_reader, +- image_window, +- image_target, +- image_output, +- preview_output, +- preview_target, +- preview_window: preview_window_ptr, +- capture_request, +- capture_session, +- output_container, +- camera_device, +- capture_context, +- still_reader, +- still_window, +- still_output, +- still_target, +- still_context, +- }) ++ check("configure-session", ACameraDevice_createCaptureSession(session.camera_device, ++ session.output_container, &callbacks, &mut session.capture_session))?; ++ pointer("configure-session", session.capture_session)?; ++ let mut callbacks = Self::capture_callbacks(session.capture_context); ++ check("repeating-request", ACameraCaptureSession_setRepeatingRequest(session.capture_session, ++ &mut callbacks, 1, &mut session.capture_request, std::ptr::null_mut()))?; ++ Ok(()) ++ })(); ++ if let Err(error) = result { ++ // Diagnostic contains fixed stage names, status and dimensions only. ++ crate::warning!("{}; preview={}x{} still={:?}", error, format.width, format.height, ++ still.map(|(_, width, height)| (width, height))); ++ session.stop(); ++ return Err(error); ++ } ++ crate::log!("camera startup configured; preview={}x{} still={:?}", format.width, format.height, ++ still.map(|(_, width, height)| (width, height))); ++ Ok(session) + } + + /// Apply the person's controls to the repeating request. `rotation` is the +@@ -979,68 +869,41 @@ + } + + unsafe fn stop(self) { +- (*self.capture_context) +- .alive +- .store(false, Ordering::Relaxed); +- if !self.still_context.is_null() { +- (*self.still_context).alive.store(false, Ordering::Relaxed); +- } +- if !self.still_reader.is_null() { +- let mut none = AImageReader_ImageListener { context: std::ptr::null_mut(), onImageAvailable: None }; +- AImageReader_setImageListener(self.still_reader, &mut none); +- AImageReader_delete(self.still_reader); +- } +- if !self.still_target.is_null() { +- ACameraOutputTarget_free(self.still_target); +- } +- if !self.still_output.is_null() { +- ACaptureSessionOutput_free(self.still_output); +- } +- if !self.still_window.is_null() { +- ANativeWindow_release(self.still_window); +- } +- if !self.still_context.is_null() { +- let _ = Box::from_raw(self.still_context); +- } +- +- if !self.image_reader.is_null() { +- let mut image_listener = AImageReader_ImageListener { +- context: std::ptr::null_mut(), +- onImageAvailable: None, +- }; +- AImageReader_setImageListener(self.image_reader, &mut image_listener); +- } +- +- ACameraCaptureSession_stopRepeating(self.capture_session); +- ACameraCaptureSession_close(self.capture_session); +- ACaptureSessionOutputContainer_free(self.output_container); +- if !self.image_output.is_null() { +- ACaptureSessionOutput_free(self.image_output); +- } +- if !self.preview_output.is_null() { +- ACaptureSessionOutput_free(self.preview_output); +- } +- if !self.image_target.is_null() { +- ACaptureRequest_removeTarget(self.capture_request, self.image_target); +- ACameraOutputTarget_free(self.image_target); +- } +- if !self.preview_target.is_null() { +- ACaptureRequest_removeTarget(self.capture_request, self.preview_target); +- ACameraOutputTarget_free(self.preview_target); +- } +- ACaptureRequest_free(self.capture_request); +- if !self.image_window.is_null() { +- ANativeWindow_release(self.image_window); +- } +- if !self.preview_window.is_null() { +- ANativeWindow_release(self.preview_window); +- } +- if !self.image_reader.is_null() { +- AImageReader_delete(self.image_reader); +- } +- ACameraDevice_close(self.camera_device); +- let _ = Box::from_raw(self.capture_context); +- } ++ if !self.capture_context.is_null() { (*self.capture_context).alive.store(false, Ordering::Relaxed); } ++ if !self.still_context.is_null() { (*self.still_context).alive.store(false, Ordering::Relaxed); } ++ // Stop producers before deleting their windows/readers and callback ++ // contexts. This also handles a startup failure at any earlier stage. ++ if !self.capture_session.is_null() { ++ ACameraCaptureSession_stopRepeating(self.capture_session); ++ ACameraCaptureSession_close(self.capture_session); ++ } ++ if !self.camera_device.is_null() { ACameraDevice_close(self.camera_device); } ++ for reader in [self.image_reader, self.still_reader] { ++ if !reader.is_null() { ++ let mut none = AImageReader_ImageListener { context: std::ptr::null_mut(), onImageAvailable: None }; ++ AImageReader_setImageListener(reader, &mut none); ++ AImageReader_delete(reader); ++ } ++ } ++ if !self.output_container.is_null() { ACaptureSessionOutputContainer_free(self.output_container); } ++ for output in [self.image_output, self.preview_output, self.still_output] { ++ if !output.is_null() { ACaptureSessionOutput_free(output); } ++ } ++ for target in [self.image_target, self.preview_target] { ++ if !target.is_null() { ++ if !self.capture_request.is_null() { ACaptureRequest_removeTarget(self.capture_request, target); } ++ ACameraOutputTarget_free(target); ++ } ++ } ++ if !self.still_target.is_null() { ACameraOutputTarget_free(self.still_target); } ++ if !self.capture_request.is_null() { ACaptureRequest_free(self.capture_request); } ++ for window in [self.image_window, self.preview_window, self.still_window] { ++ if !window.is_null() { ANativeWindow_release(window); } ++ } ++ if !self.still_context.is_null() { let _ = Box::from_raw(self.still_context); } ++ if !self.capture_context.is_null() { let _ = Box::from_raw(self.capture_context); } ++ } ++ + } + + pub struct AndroidCameraAccess { +@@ -1225,7 +1088,7 @@ + + *node.dispatch.lock().unwrap() = dispatch; + +- let restart_needed = node.session.is_none() ++ let restart_needed = (node.session.is_none() && node.start_error.is_none()) + || node.preview_window != desired_preview_window + || node.needs_image_reader != needs_image_reader; + +@@ -1249,7 +1112,8 @@ + .iter() + .find(|device| device.desc.input_id == key.input_id) + .and_then(|device| device.still_size.map(|(w, h)| (key.input_id, w, h))); +- node.session = unsafe { ++ node.start_error = None; ++ let started = unsafe { + AndroidCaptureSession::start( + node.dispatch.clone(), + self.manager, +@@ -1264,6 +1128,10 @@ + still, + ) + }; ++ match started { ++ Ok(session) => node.session = Some(session), ++ Err(error) => node.start_error = Some(error), ++ } + if let Some(session) = node.session.as_ref() { + let (active_array, rotation) = self + .devices +@@ -1386,6 +1254,7 @@ + controls: CameraControlState::default(), + dispatch: Arc::new(Mutex::new(StreamDispatch::default())), + session: None, ++ start_error: None, + preview_window: std::ptr::null_mut(), + needs_image_reader: false, + }, +@@ -1432,6 +1301,7 @@ + crate::log!("camera: no stream for this input and format; the preview stays empty"); + return; + }; ++ if let Some(node) = self.streams.get_mut(&stream) { node.start_error = None; } + if let Some(old) = self.preview_subscriptions.remove(&video_id) { + if !old.preview_window.is_null() { + unsafe { ANativeWindow_release(old.preview_window) }; +@@ -1462,6 +1332,7 @@ + return; + }; + ++ if let Some(node) = self.streams.get_mut(&stream) { node.start_error = None; } + if let Some(old) = self.preview_subscriptions.remove(&video_id) { + if !old.preview_window.is_null() { + unsafe { ANativeWindow_release(old.preview_window) }; +@@ -1478,6 +1349,11 @@ + }, + ); + self.reconcile_streams(); ++ } ++ ++ pub fn preview_start_error(&self, video_id: LiveId) -> Option { ++ let sub = self.preview_subscriptions.get(&video_id)?; ++ self.streams.get(&sub.stream)?.start_error.clone() + } + + pub fn update_preview_window( +--- a/platform/src/os/linux/android/android_camera_player.rs ++++ b/platform/src/os/linux/android/android_camera_player.rs +@@ -237,63 +237,42 @@ + return None; + } + +- if self.native_preview && self.prepared { ++ if let Some(error) = self.camera_access.as_ref().and_then(|cam| { ++ cam.lock().unwrap().preview_start_error(self.video_id) ++ }) { + self.prepare_notified = true; +- return Some(Ok(PlaybackPrepared::new( +- self.width, +- self.height, +- 0, +- false, +- vec!["camera".to_string()], +- vec![], +- ))); +- } +- +- if let Some(latest) = self.hardware_buffer_frame.as_ref() { +- let guard = latest.lock().unwrap(); +- let Some(frame) = guard.as_ref() else { +- if !self.warned_waiting_for_first_frame +- && self.created_at.elapsed().as_secs_f32() >= 2.0 +- { +- self.warned_waiting_for_first_frame = true; +- } +- return None; +- }; +- self.width = frame.width; +- self.height = frame.height; ++ return Some(Err(error)); ++ } ++ // Readiness wins over the deadline: the UI can be suspended after the ++ // producer queues its first frame. A late poll must not reject it. ++ let ready_size = if self.native_preview && self.prepared { ++ Some((self.width, self.height)) ++ } else if let Some(latest) = self.hardware_buffer_frame.as_ref() { ++ latest.lock().unwrap().as_ref().map(|frame| (frame.width, frame.height)) ++ } else { ++ self.i420_frames.as_mut().and_then(|frames| { ++ if !frames.prime_pending_from_latest() { return None; } ++ frames.pending_frame().map(|frame| (frame.width as u32, frame.height as u32)) ++ }) ++ }; ++ if let Some((width, height)) = ready_size { ++ self.width = width; ++ self.height = height; + self.prepared = true; + self.prepare_notified = true; + return Some(Ok(PlaybackPrepared::new( +- self.width, +- self.height, +- 0, +- false, +- vec!["camera".to_string()], +- vec![], ++ self.width, self.height, 0, false, ++ vec!["camera".to_string()], vec![], + ))); + } +- +- let frames = self.i420_frames.as_mut()?; +- if !frames.prime_pending_from_latest() { +- return None; +- } +- +- let (width, height) = { +- let frame = frames.pending_frame()?; +- (frame.width as u32, frame.height as u32) +- }; +- self.width = width; +- self.height = height; +- self.prepared = true; +- self.prepare_notified = true; +- Some(Ok(PlaybackPrepared::new( +- self.width, +- self.height, +- 0, +- false, +- vec!["camera".to_string()], +- vec![], +- ))) ++ if !self.native_preview && self.created_at.elapsed().as_secs_f32() >= 10.0 { ++ self.prepare_notified = true; ++ return Some(Err("camera startup first-frame timed out after 10 seconds".into())); ++ } ++ if !self.warned_waiting_for_first_frame && self.created_at.elapsed().as_secs_f32() >= 2.0 { ++ self.warned_waiting_for_first_frame = true; ++ } ++ None + } + + pub fn take_hardware_buffer_frame(&mut self) -> Option { +--- a/widgets/src/camera_preview.rs ++++ b/widgets/src/camera_preview.rs +@@ -229,6 +229,10 @@ + return false; + } + self.wanted = true; ++ self.last_error.clear(); ++ if self.permission.is_some_and(|p| p != PermissionStatus::Granted) && !self.permission_pending { ++ self.permission = None; ++ } + if self.permission.is_none() && !self.permission_pending { + self.permission_pending = true; + self.asked_at = cx.seconds_since_app_start(); +@@ -574,19 +578,20 @@ + .collect(); + self.drive(cx); + } +- Event::VideoPlaybackPrepared(_) => { ++ Event::VideoPlaybackPrepared(e) if self.video(cx).playback_id() == Some(e.video_id) => { + if self.state == State::Starting { + self.state = State::Running; + } + } +- Event::VideoPlaybackResourcesReleased(_) => { ++ Event::VideoPlaybackResourcesReleased(e) if self.video(cx).playback_id() == Some(e.video_id) => { + if self.state == State::Stopping { + self.state = State::Idle; + } + } +- Event::VideoDecodingError(e) => { ++ Event::VideoDecodingError(e) if self.video(cx).playback_id() == Some(e.video_id) => { + if matches!(self.state, State::Starting | State::Running) { + self.state = State::Idle; ++ self.wanted = false; + let why = format!("the camera stopped: {}", e.error); + self.fail(cx, why); + } +@@ -616,6 +621,43 @@ + + fn format(width: usize, height: usize, pixel_format: VideoPixelFormat, id: u64) -> VideoFormat { + VideoFormat { format_id: VideoFormatId(LiveId(id)), width, height, frame_rate: Some(30.0), pixel_format } ++ } ++ ++ #[test] ++ fn failed_start_stops_retries_until_explicit_start_and_ignores_other_players() { ++ let mut cx = Cx::new(Box::new(|_, _| {})); ++ let widget = cx.with_vm(|vm| { ++ crate::script_mod(vm); ++ let value = crate::script_eval!(vm, {use mod.widgets.* CameraPreview{}}); ++ WidgetRef::script_from_value(vm, value) ++ }); ++ let mut preview = widget.borrow_mut::().unwrap(); ++ let heap = preview.heap_key(); ++ crate::splash_policy::set_policy_for_heap(heap, vec!["camera".into()], vec![], None); ++ let video_id = preview.video(&cx).playback_id().unwrap(); ++ preview.state = State::Starting; ++ preview.wanted = true; ++ preview.permission = Some(PermissionStatus::Granted); ++ let event = |video_id| Event::VideoDecodingError(crate::makepad_platform::event::VideoDecodingErrorEvent { ++ video_id, error: "camera startup configure-session failed (status -10001)".into(), ++ }); ++ preview.handle_event(&mut cx, &event(LiveId::unique()), &mut Scope::empty()); ++ assert_eq!(preview.state, State::Starting); ++ assert!(preview.wanted); ++ preview.handle_event(&mut cx, &event(video_id), &mut Scope::empty()); ++ assert_eq!(preview.state, State::Idle); ++ assert!(!preview.wanted, "a watchdog must not reopen a failed camera automatically"); ++ assert!(preview.last_error.contains("configure-session")); ++ preview.cameras = vec![CameraChoice { input_id: VideoInputId(LiveId(1)), front: false, ++ formats: vec![format(640, 480, VideoPixelFormat::YUV420, 1)] }]; ++ preview.drive(&mut cx); ++ assert_eq!(preview.state, State::Idle); ++ assert!(preview.start(&mut cx), "retry is an explicit action under the existing camera grant"); ++ assert_eq!(preview.state, State::Starting); ++ assert!(preview.last_error.is_empty()); ++ assert!(!preview.permission_pending, "an already granted camera does not prompt again"); ++ preview.stop(&mut cx); ++ crate::splash_policy::gc_policies(&[heap]); + } + + #[test] +--- a/widgets/src/video.rs ++++ b/widgets/src/video.rs +@@ -622,6 +622,11 @@ + } + + impl VideoRef { ++ /// Native lifecycle events belong only to this playback instance. ++ pub(crate) fn playback_id(&self) -> Option { ++ self.borrow().map(|inner| inner.id) ++ } ++ + /// Prepares the video for playback. Does not start playback or update the video texture. + /// + /// Once playback is prepared, [`begin_playback`] can be called to start the actual playback. diff --git a/tools/runtime-patches/makepad-label-restyle-wrap.patch b/tools/runtime-patches/makepad-label-restyle-wrap.patch new file mode 100644 index 00000000..90c254c5 --- /dev/null +++ b/tools/runtime-patches/makepad-label-restyle-wrap.patch @@ -0,0 +1,12 @@ +--- a/widgets/src/label.rs ++++ b/widgets/src/label.rs +@@ -11,6 +11,9 @@ + mod.widgets.Label = set_type_default() do mod.widgets.LabelBase{ + width: Fit + height: Fit ++ // Match the native construction default during a retained restyle. ++ // An omitted enum field otherwise reloads Flow's unwrapped default. ++ flow: Flow.Right{wrap: true} + padding: theme.mspace_1 + + draw_text +: { diff --git a/tools/runtime-patches/makepad-location-explicit-consent.patch b/tools/runtime-patches/makepad-location-explicit-consent.patch new file mode 100644 index 00000000..b3134410 --- /dev/null +++ b/tools/runtime-patches/makepad-location-explicit-consent.patch @@ -0,0 +1,47 @@ +--- a/tools/cargo_makepad/src/android/java/dev/makepad/android/MakepadActivity.java ++++ b/tools/cargo_makepad/src/android/java/dev/makepad/android/MakepadActivity.java +@@ -1366,19 +1366,12 @@ + private long mQrLastFrameMs = 0; + private static final int QR_CAMERA_PERM_REQ = 0x51A2; + +- // GPS location (AppCard, automatic) — a LocationListener feeds each fix to Rust via ++ // Legacy GPS cache (AppCard, existing grants only) — a LocationListener feeds each fix to Rust via + // MakepadNative.onLocation; the Splash sys.gps(...) helper reads it. + private LocationManager mGpsLocationManager; + private LocationListener mGpsLocationListener; + private static final int LOCATION_PERM_REQ = 0x10CA; +- // Guard so the location runtime permission is requested AT MOST ONCE per +- // process. startGpsLocationUpdates() runs on every onResume(); without this, +- // a user who has DENIED location makes every resume call requestPermissions() +- // again, and each call bounces activity focus (pause→resume→focus-change). +- // That storm pins the window's HWUI layer redrawing continuously (~30fps on +- // a static screen) — the composer/whole-screen flicker. Requesting once, then +- // leaving the user's decision alone, lets the window go idle. +- private boolean mLocationPermissionRequested = false; ++ + + static { + System.loadLibrary("makepad"); +@@ -2183,18 +2176,10 @@ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + && checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED + && checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) { +- // Request only once per process. Re-requesting on every onResume() +- // when the user has declined creates a pause/resume/focus storm that +- // pins the window redrawing (the flicker). If granted later, +- // onRequestPermissionsResult re-invokes this and the checks above pass. +- if (!mLocationPermissionRequested) { +- mLocationPermissionRequested = true; +- requestPermissions(new String[]{ +- Manifest.permission.ACCESS_FINE_LOCATION, +- Manifest.permission.ACCESS_COARSE_LOCATION +- }, LOCATION_PERM_REQ); +- } +- return; // re-invoked from onRequestPermissionsResult once granted ++ // Startup/resume is not a request to share location. The explicit ++ // Cx::start_location_updates flow owns permission consent; this ++ // legacy cached GPS feed may only use an existing grant. ++ return; + } + if (mGpsLocationManager == null) { + mGpsLocationManager = (LocationManager) getSystemService(Context.LOCATION_SERVICE); diff --git a/tools/runtime-patches/makepad-location-request.patch b/tools/runtime-patches/makepad-location-request.patch new file mode 100644 index 00000000..c7426642 --- /dev/null +++ b/tools/runtime-patches/makepad-location-request.patch @@ -0,0 +1,148 @@ +--- a/widgets/src/splash.rs ++++ b/widgets/src/splash.rs +@@ -3754,6 +3754,27 @@ + }, + ); + ++ // Explicit Android request using the existing foreground GPS cache. ++ // Reads stay passive. Return requested (1), unsupported (0), or missing ++ // app capability (-1), never a promise of permission or a position fix. ++ vm.add_method( ++ sys, ++ id_lut!(request_location), ++ script_args_def!(), ++ |vm, _args| { ++ if crate::splash_policy::service_allowed(vm.bx.heap.heap_key(), "location.request").is_err() { ++ return ScriptValue::from_f64(-1.0); ++ } ++ #[cfg(target_os = "android")] ++ { ++ vm.host.cx_mut().request_gps_location(); ++ return ScriptValue::from_f64(1.0); ++ } ++ #[cfg(not(target_os = "android"))] ++ ScriptValue::from_f64(0.0) ++ }, ++ ); ++ + // sys.gps("lat"|"lon"|"acc"|"ok") -> the device's last-known GPS fix, read + // SYNCHRONOUSLY from the platform global (NO network fetch — so, unlike + // sys.search/navroute, it must NOT gate the card via body_binds_live_data). +--- a/platform/src/cx_api.rs ++++ b/platform/src/cx_api.rs +@@ -457,6 +457,8 @@ + request_id: i32, + }, + ++ #[cfg(target_os = "android")] ++ RequestGpsLocation, + StartLocationUpdates, + StopLocationUpdates, + +@@ -674,6 +676,8 @@ + + Self::CheckPermission { .. } => write!(f, "CheckPermission"), + Self::RequestPermission { .. } => write!(f, "RequestPermission"), ++ #[cfg(target_os = "android")] ++ Self::RequestGpsLocation => write!(f, "RequestGpsLocation"), + Self::StartLocationUpdates => write!(f, "StartLocationUpdates"), + Self::StopLocationUpdates => write!(f, "StopLocationUpdates"), + +@@ -1849,6 +1853,13 @@ + self.permissions_request_id + } + ++ /// Explicit request for Android's foreground cached GPS feed. The native ++ /// permission dialog starts only from this operation, not on resume. ++ #[cfg(target_os = "android")] ++ pub fn request_gps_location(&mut self) { ++ self.platform_ops.push_back(CxOsOp::RequestGpsLocation); ++ } ++ + /// Start streaming position fixes from the platform location service + /// (CoreLocation on macOS/iOS, LocationManager on Android, + /// `navigator.geolocation` on web). Fixes arrive as +--- a/platform/src/os/linux/android/android.rs ++++ b/platform/src/os/linux/android/android.rs +@@ -3020,6 +3020,9 @@ + } => { + self.handle_permission_request(permission, request_id); + } ++ CxOsOp::RequestGpsLocation => unsafe { ++ android_jni::to_java_request_gps_location(); ++ }, + CxOsOp::StartLocationUpdates => { + self.os.location_updates_wanted = true; + match self +--- a/platform/src/os/linux/android/android_jni.rs ++++ b/platform/src/os/linux/android/android_jni.rs +@@ -2654,6 +2654,11 @@ + array + } + ++pub unsafe fn to_java_request_gps_location() { ++ let env = attach_jni_env(); ++ ndk_utils::call_void_method!(env, get_activity(), "requestGpsLocation", "()V"); ++} ++ + pub unsafe fn to_java_start_location_updates(min_interval_ms: i64, min_distance_m: f32) { + let env = attach_jni_env(); + ndk_utils::call_void_method!( +--- a/tools/cargo_makepad/src/android/java/dev/makepad/android/MakepadActivity.java ++++ b/tools/cargo_makepad/src/android/java/dev/makepad/android/MakepadActivity.java +@@ -1371,6 +1371,7 @@ + private LocationManager mGpsLocationManager; + private LocationListener mGpsLocationListener; + private static final int LOCATION_PERM_REQ = 0x10CA; ++ private boolean mGpsPermissionPending = false; + + + static { +@@ -2021,6 +2022,7 @@ + + // Location permission granted → start GPS updates. + if (requestId == LOCATION_PERM_REQ) { ++ mGpsPermissionPending = false; + for (int i = 0; i < permissions.length; i++) { + if (grantResults[i] == PackageManager.PERMISSION_GRANTED) { + startGpsLocationUpdates(); +@@ -2172,12 +2174,37 @@ + // octos has no simulated location, so this feeds the DEVICE's real fix to the + // nav card (via sys.gps). Started in onResume once the runtime permission is + // granted, stopped in onPause so GPS isn't polled while backgrounded. ++ // Explicit app action only. Reuse the foreground listener and its ++ // existing permission-result/onPause lifecycle, not a second stream. ++ public void requestGpsLocation() { ++ runOnUiThread(() -> { ++ if (isFinishing() || isDestroyed() || !hasWindowFocus()) return; ++ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ++ && checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED ++ && checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) { ++ if (mGpsPermissionPending) return; ++ mGpsPermissionPending = true; ++ try { ++ requestPermissions(new String[]{ ++ Manifest.permission.ACCESS_FINE_LOCATION, ++ Manifest.permission.ACCESS_COARSE_LOCATION ++ }, LOCATION_PERM_REQ); ++ } catch (RuntimeException unavailable) { ++ mGpsPermissionPending = false; ++ Log.w("Makepad", "Location permission request could not be opened"); ++ } ++ return; ++ } ++ startGpsLocationUpdates(); ++ }); ++ } ++ + private void startGpsLocationUpdates() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + && checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED + && checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) { +- // Startup/resume is not a request to share location. The explicit +- // Cx::start_location_updates flow owns permission consent; this ++ // Startup/resume is not a request to share location. Explicit ++ // location actions own permission consent; this + // legacy cached GPS feed may only use an existing grant. + return; + }