From 88657800cd1e890390cd908c88bb9d9b0a14bcf9 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:14:11 -0700 Subject: [PATCH 1/3] Unify app interfaces and fix Android editor and camera startup --- .github/workflows/apps.yml | 4 + Cargo.lock | 8 +- Cargo.toml | 8 +- README.md | 4 + README.zh-CN.md | 4 + apps/README.md | 26 +++ apps/README.zh-CN.md | 20 ++ apps/ai-providers/bundle/main.splash | 179 +++++++++++------- apps/calendar/bundle/main.splash | 123 +++++++----- apps/camera/bundle/main.splash | 25 ++- apps/interface.splash | 35 ++++ apps/mail/bundle/main.splash | 101 ++++++---- apps/maps/bundle/main.splash | 96 ++++++---- apps/news/bundle/main.splash | 119 ++++++++---- apps/news/host-service/tests/script.rs | 42 +++- apps/photos/bundle/main.splash | 140 +++++++++----- apps/youtube/bundle/main.splash | 81 +++++--- crates/shell/src/card_chat.rs | 16 +- crates/shell/src/glance_sheet.rs | 10 +- crates/shell/src/module_resize_tests.rs | 155 ++++++++++++++- crates/shell/src/module_view.rs | 20 ++ crates/shell/src/system_app_theme_tests.rs | 23 ++- native-apps.json | 2 +- runtime-patches.lock.json | 9 +- tools/app-ux/README.md | 100 ++++++++++ tools/app-ux/README.zh-CN.md | 88 +++++++++ tools/app-ux/acceptance-rubric.json | 75 ++++++++ tools/app-ux/benchmark.py | 52 +++++ tools/app-ux/calendar_journey.py | 132 +++++++++++++ tools/app-ux/capture.py | 126 ++++++++++++ tools/app-ux/glance_journey.py | 68 +++++++ tools/app-ux/glance_swipe_journey.py | 69 +++++++ tools/app-ux/hub_journey.py | 61 ++++++ tools/app-ux/mail_journey.py | 65 +++++++ tools/app-ux/news_journey.py | 57 ++++++ tools/app-ux/providers_journey.py | 32 ++++ tools/app-ux/retention_journey.py | 59 ++++++ tools/app-ux/theme_journey.py | 47 +++++ .../makepad-camera-permission-wait.patch | 65 +++++++ tools/sync-app-interface.py | 31 +++ 40 files changed, 2035 insertions(+), 342 deletions(-) create mode 100644 apps/interface.splash create mode 100644 tools/app-ux/README.md create mode 100644 tools/app-ux/README.zh-CN.md create mode 100644 tools/app-ux/acceptance-rubric.json create mode 100644 tools/app-ux/benchmark.py create mode 100644 tools/app-ux/calendar_journey.py create mode 100644 tools/app-ux/capture.py create mode 100644 tools/app-ux/glance_journey.py create mode 100644 tools/app-ux/glance_swipe_journey.py create mode 100644 tools/app-ux/hub_journey.py create mode 100644 tools/app-ux/mail_journey.py create mode 100644 tools/app-ux/news_journey.py create mode 100644 tools/app-ux/providers_journey.py create mode 100644 tools/app-ux/retention_journey.py create mode 100644 tools/app-ux/theme_journey.py create mode 100644 tools/runtime-patches/makepad-camera-permission-wait.patch create mode 100644 tools/sync-app-interface.py diff --git a/.github/workflows/apps.yml b/.github/workflows/apps.yml index 067a1c73..f9f34f7a 100644 --- a/.github/workflows/apps.yml +++ b/.github/workflows/apps.yml @@ -15,6 +15,7 @@ on: - 'native-runtime.lock.json' - 'runtime-patches.lock.json' - 'tools/setup.py' + - 'tools/sync-app-interface.py' - '.github/workflows/apps.yml' pull_request: paths: @@ -26,6 +27,7 @@ on: - 'native-runtime.lock.json' - 'runtime-patches.lock.json' - 'tools/setup.py' + - 'tools/sync-app-interface.py' - '.github/workflows/apps.yml' workflow_dispatch: @@ -61,6 +63,8 @@ jobs: # though most protocol crates do not build Makepad. The OAuth host # adapter also checks App Hub's Makepad-linked service integration. - run: python3 tools/setup.py + - name: Shared system app interface + run: python3 tools/sync-app-interface.py --check - uses: Swatinem/rust-cache@v2 with: shared-key: services diff --git a/Cargo.lock b/Cargo.lock index 66e4c23c..ff37d2ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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=a52d928b0c14d2822c09f6ccd18f44bf7b885221#a52d928b0c14d2822c09f6ccd18f44bf7b885221" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=f762267864fc3d68631b46924899cff324f7d157#f762267864fc3d68631b46924899cff324f7d157" 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=a52d928b0c14d2822c09f6ccd18f44bf7b885221#a52d928b0c14d2822c09f6ccd18f44bf7b885221" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=f762267864fc3d68631b46924899cff324f7d157#f762267864fc3d68631b46924899cff324f7d157" 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=a52d928b0c14d2822c09f6ccd18f44bf7b885221#a52d928b0c14d2822c09f6ccd18f44bf7b885221" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=f762267864fc3d68631b46924899cff324f7d157#f762267864fc3d68631b46924899cff324f7d157" 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=a52d928b0c14d2822c09f6ccd18f44bf7b885221#a52d928b0c14d2822c09f6ccd18f44bf7b885221" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=f762267864fc3d68631b46924899cff324f7d157#f762267864fc3d68631b46924899cff324f7d157" dependencies = [ "makepad-app-module", "makepad-widgets", diff --git a/Cargo.toml b/Cargo.toml index 733f0c98..be99af31 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 = "a52d928b0c14d2822c09f6ccd18f44bf7b885221" } +octosense-app-hub-app = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "f762267864fc3d68631b46924899cff324f7d157" } 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,12 +106,12 @@ 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 = "a52d928b0c14d2822c09f6ccd18f44bf7b885221" } +octosense-appstore = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "f762267864fc3d68631b46924899cff324f7d157" } # 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 = "a52d928b0c14d2822c09f6ccd18f44bf7b885221" } -octosense-app-hub = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "a52d928b0c14d2822c09f6ccd18f44bf7b885221" } +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" } # 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) diff --git a/README.md b/README.md index c7e9b520..d86edb1b 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@ OctoSense is an agent shell that runs on top of an ordinary operating system. On This repository holds the shell, its services, the system apps, and the three products built from them. Most native apps come from other repositories: the OctoSense fork of Makepad, App Hub and Rinx ([What it depends on](#what-it-depends-on)). +The script apps share [responsive, theme-aware interface styles](apps/README.md#shared-appearance) +across desktop and phone. [Local native UX checks](tools/app-ux/README.md) cover +saved state and actual interaction; phone-size previews remain separate from device acceptance. + | Product | What it is | Where | | --- | --- | --- | | **OctoSense desktop** | The shell as one Makepad window on macOS (Windows and Linux untested) | [`desktop/`](desktop/README.md) | diff --git a/README.zh-CN.md b/README.zh-CN.md index 9e685310..afbba2f2 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -8,6 +8,10 @@ OctoSense 是运行在普通操作系统之上的 Agent Shell。从屏幕上看 本仓库存放 Shell、Shell 服务、系统应用,以及由它们构建的三个产品。大多数原生应用来自其他仓库:OctoSense 的 Makepad fork、App Hub 和 Rinx(见[依赖](#依赖))。 +脚本应用在桌面和手机端共用[自适应主题与布局](apps/README.zh-CN.md#共用外观)。 +[本机原生 UX 检查](tools/app-ux/README.zh-CN.md) 验证真实交互和保存状态; +手机尺寸预览与真机验收分别记录。 + | 产品 | 是什么 | 位置 | | --- | --- | --- | | **OctoSense 桌面端** | 在 macOS 上作为一个 Makepad 窗口运行的 Shell(Windows 和 Linux 未经测试) | [`desktop/`](desktop/README.zh-CN.md) | diff --git a/apps/README.md b/apps/README.md index 687bb0e7..4d37519f 100644 --- a/apps/README.md +++ b/apps/README.md @@ -65,6 +65,32 @@ OctoScript-App-Design-Flow: ## The apps +### Shared appearance + +News, Photos, Mail, Calendar, Maps, AI providers and YouTube share the interface +in [`interface.splash`](interface.splash): theme-aware page/card/field surfaces, +readable secondary text, 44-point actions and 48-point inputs. Desktop content +has a maximum width; narrow windows keep the same actions. Camera and media +viewers retain a dark viewing surface. App Hub uses the same spacing and action +hierarchy in its native module. Expanded Glance cards and in-card chat use the +shared shell on both desktop and phone. + +When a keyboard shrinks a focused hosted app, the shell reveals the active +editor through the app’s own scrolling container and keeps fixed actions in +place. Maps switches its tiles with the host appearance. Camera controls use +a dark scrim to remain readable over a bright preview; Android waits for the +first-use permission answer before preparing that preview. A permission dialog +pauses only a playing video; it cannot turn an unprepared camera into a fake +playing state when the app resumes. + +After editing the interface, run `python3 tools/sync-app-interface.py` from the +repository root. It embeds the exact prelude in each contained bundle; +`python3 tools/sync-app-interface.py --check` detects drift in CI. +The prelude resolves the current host theme on restyle, without replacing +unsaved input. [Local native UX checks](../tools/app-ux/README.md) exercise +editing, persistence, appearance switching, browsing and search in isolated +profiles. Phone-sized desktop captures are not physical-device verification. + | App | Id | What it does | Capabilities (manifest) | Network hosts (manifest) | Host services | | --- | --- | --- | --- | --- | --- | | [News](news/bundle) | `os.news` | Hacker News, TechMeme and Google News feeds in tabs (Today, HN, TechMeme, Google, Saved), with a reader for stories | `storage`, `net`, `images`, `web`, `news`, `glance` | `hn.algolia.com`, `www.techmeme.com`, `news.google.com`, `api.gdeltproject.org`, `feeds.bbci.co.uk`, `feeds.npr.org`, `www.theguardian.com`, `feeds.arstechnica.com` | [`news`](news/host-service) | diff --git a/apps/README.zh-CN.md b/apps/README.zh-CN.md index 8b96a736..a23edb6a 100644 --- a/apps/README.zh-CN.md +++ b/apps/README.zh-CN.md @@ -57,6 +57,26 @@ OctoScript-App-Design-Flow 的 `AGENTS.md`,再读 `docs/QUICKSTART.md`), ## 应用一览 +### 共用外观 + +新闻、相册、邮件、日历、地图、AI providers 和 YouTube 共用 +[`interface.splash`](interface.splash):页面、卡片和输入框跟随主题, +辅助文字保持可读,操作按钮高 44 点,输入框高 48 点。桌面内容限制最大宽度, +窄窗口保留相同操作。相机和媒体查看器保留深色观看背景。应用大厅的原生模块 +采用相同的间距和操作层级。展开的 Glance 卡片及卡片内聊天在桌面和手机端 +使用同一套 Shell。 + +键盘缩小当前应用的可视区域时,Shell 通过应用自己的滚动区域显示当前输入框, +固定操作栏保留原位。地图图块随宿主切换明暗外观。相机按钮使用深色底板, +在明亮取景画面上仍可阅读;Android 等待首次权限弹窗的结果后才准备预览。 +权限弹窗只能暂停正在播放的视频,不能将尚未初始化的相机在返回后误标为正在播放。 + +修改后在仓库根目录运行 `python3 tools/sync-app-interface.py`,将共用样式 +原样嵌入各个独立应用包;CI 通过 `python3 tools/sync-app-interface.py --check` +检查是否同步。样式重载时读取当前宿主主题,同时保留未保存的输入。 +[本机原生 UX 检查](../tools/app-ux/README.zh-CN.md) 在独立配置中验证编辑、 +持久化、主题切换、浏览和搜索。手机尺寸的桌面截图不代表真机验证。 + | 应用 | Id | 功能 | 权限(manifest) | 网络主机(manifest) | 宿主服务 | | --- | --- | --- | --- | --- | --- | | [News](news/bundle) | `os.news` | Hacker News、TechMeme 和 Google News 的订阅源,分标签页(Today、HN、TechMeme、Google、Saved),带文章阅读器 | `storage`、`net`、`images`、`web`、`news`、`glance` | `hn.algolia.com`、`www.techmeme.com`、`news.google.com`、`api.gdeltproject.org`、`feeds.bbci.co.uk`、`feeds.npr.org`、`www.theguardian.com`、`feeds.arstechnica.com` | [`news`](news/host-service) | diff --git a/apps/ai-providers/bundle/main.splash b/apps/ai-providers/bundle/main.splash index b1536e10..52f87c06 100644 --- a/apps/ai-providers/bundle/main.splash +++ b/apps/ai-providers/bundle/main.splash @@ -1,3 +1,40 @@ +// BEGIN shared app interface +// Shared system-app interface. Run tools/sync-app-interface.py after editing. +// Bundles carry this prelude so they work in the contained standalone host too. +// Resolve the current theme after a host restyle, not a retained prelude alias. +let theme = mod.theme +let ui_ink = theme.color_text +let ui_page = theme.color_bg_app +let ui_light = dot(ui_ink, #x36b61200) < 0.5 +let ui_surface = if ui_light { #xffffff } else { mix(ui_page, ui_ink, 0.06) } +let ui_field = mix(ui_page, ui_ink, 0.075) +let ui_hover = mix(ui_page, ui_ink, 0.12) +let ui_border = mix(ui_page, ui_ink, 0.22) +let ui_muted = mix(ui_ink, ui_page, 0.24) +let ui_link = mix(ui_ink, #x356bce, 0.25) +let ui_primary = #x285cd6 +let ui_success = mix(ui_ink, #x28a55b, 0.20) +let ui_danger = mix(ui_ink, #xe13b53, 0.20) +let UiButton = ButtonFlat{height: 44 margin: 0 padding: Inset{left: 14 right: 14} + draw_bg +: {border_radius: 12.0 border_size: 0.0 color: #x00000000 color_hover: ui_hover color_down: ui_field color_focus: ui_hover} + draw_text +: {color: ui_link color_hover: ui_link color_down: ui_link color_focus: ui_link text_style: theme.font_bold{font_size: 14}} +} +let UiPrimary = UiButton{ + draw_bg +: {color: ui_primary color_hover: #x3468df color_down: #x204cb8 color_focus: #x204cb8} + draw_text +: {color: #xffffff color_hover: #xffffff color_down: #xffffff color_focus: #xffffff} +} +let UiPill = UiButton{draw_bg +: {color: ui_surface border_radius: 22.0} draw_text +: {color: ui_ink color_hover: ui_ink color_down: ui_ink color_focus: ui_ink}} +let UiField = TextInput{width: Fill height: 48 margin: 0 padding: Inset{left: 14 right: 14 top: 12 bottom: 12} + draw_bg +: {color: ui_field color_hover: ui_field color_focus: ui_field color_empty: ui_field + border_color: ui_border border_color_hover: ui_link border_color_focus: ui_link border_color_empty: ui_border border_radius: 12.0} + draw_text +: {color: ui_ink color_hover: ui_ink color_focus: ui_ink color_empty: ui_muted color_empty_hover: ui_muted color_empty_focus: ui_muted text_style: theme.font_regular{font_size: 15}} + draw_selection.color: #x557dcc55 +} +let UiCard = RoundedView{width: Fill height: Fit flow: Down spacing: 12 padding: 20 + show_bg: true draw_bg +: {color: ui_surface border_radius: 16.0}} +let UiCaption = Label{padding: 0 flow: Flow.Right{wrap: true} width: Fill draw_text +: {color: ui_muted text_style: theme.font_regular{font_size: 13 line_spacing: 1.4}}} +let UiTitle = Label{padding: 0 flow: Flow.Right{wrap: true} width: Fill draw_text +: {color: ui_ink text_style: theme.font_bold{font_size: 28 line_spacing: 1.2}}} +// END shared app interface // AI providers: the models the assistant answers with, primary first, then // its fallbacks. Every change goes through the `llm` host service; keys, PINs // and QR codes are typed and shown only on OctoSense's own sheets, never here. @@ -34,6 +71,10 @@ fn load(){ let i = 0 for p in list { p.index = i; i = i + 1 } providers = list + ui.add.set_visible(list.len() > 0) + ui.talk.set_visible(list.len() > 0) + ui.saved_heading.set_visible(list.len() > 0) + ui.export_qr.set_visible(list.len() > 0) scanner = r.data.scanner store = r.data.store ui.scan.set_visible(scanner) @@ -170,71 +211,63 @@ fn key_text(p){ start_timeout(0.05, || load()) -let ink = #x1c1c1e -let secondary = #x6e6e73 -let faint = #x8e8e93 -let accent = #x007aff -let danger = #xff3b30 -let page = #xf2f2f7 -let Act = ButtonFlat{height: 30 padding: Inset{left: 10 right: 10} - draw_bg +: {border_radius: 8.0 color: #xf2f2f7 color_focus: #xf2f2f7 color_hover: #xe5e5ea color_down: #xd1d1d6 border_size: 0.0} - draw_text +: {color: accent color_focus: accent color_hover: accent color_down: accent text_style +: {font_size: 13}} -} +let ink = ui_ink +let secondary = ui_muted +let faint = ui_muted +let accent = ui_link +let danger = ui_danger +let page = ui_page +let Act = UiPill{} let ActDanger = Act{draw_text +: {color: danger color_focus: danger color_hover: danger color_down: danger}} -let TestAct = Act{draw_bg +: {color: #xe3edff color_focus: #xe3edff color_hover: #xd6e5ff color_down: #xc7dbff} draw_text +: {text_style: theme.font_bold{font_size: 13}}} -let Mark = Label{text: "" draw_text.text_style: theme.font_icons{font_size: 11}} -let Big = ButtonFlat{width: Fill height: 44 - draw_bg +: {border_radius: 12.0 color: #x007aff color_focus: #x007aff color_hover: #x0a84ff color_down: #x0062cc border_size: 0.0} - draw_text +: {color: #xffffff color_focus: #xffffff color_hover: #xffffff color_down: #xffffff text_style: theme.font_bold{font_size: 15}} -} -let Quiet = ButtonFlat{width: Fill height: 40 - draw_bg +: {border_radius: 10.0 color: #xffffff color_focus: #xffffff color_hover: #xf7f7fa color_down: #xe5e5ea border_size: 0.0} - draw_text +: {color: accent color_focus: accent color_hover: accent color_down: accent text_style +: {font_size: 14}} -} -let Field = TextInput{width: Fill height: 38 - draw_bg +: {color: #xffffff color_hover: #xffffff color_focus: #xffffff color_empty: #xffffff - border_color: #x00000000 border_color_hover: #x00000000 border_color_focus: #x007aff border_color_empty: #x00000000 border_radius: 10.0} - draw_text +: {color: ink color_hover: ink color_focus: ink color_empty: faint color_empty_hover: faint} -} -let Section = Label{text: "" draw_text.color: secondary draw_text.text_style: theme.font_bold{font_size: 11}} -let Card = RoundedView{width: Fill height: Fit flow: Down spacing: 8 padding: Inset{left: 12 right: 12 top: 12 bottom: 12} - show_bg: true draw_bg.color: #xffffff draw_bg.border_radius: 14.0} -let Badge = RoundedView{width: Fit height: Fit padding: Inset{left: 8 right: 8 top: 3 bottom: 3} show_bg: true draw_bg.color: #xe8f0fe draw_bg.border_radius: 7.0} -let BadgeGrey = Badge{draw_bg.color: #xf2f2f7} -let BadgeText = Label{text: "" draw_text.color: accent draw_text.text_style: theme.font_bold{font_size: 11}} -let BadgeGreyText = Label{text: "" draw_text.color: secondary draw_text.text_style: theme.font_bold{font_size: 11}} -let KeyOk = Label{text: "" draw_text.color: #x248a3d draw_text.text_style.font_size: 12} -let KeyBad = Label{text: "" draw_text.color: danger draw_text.text_style.font_size: 12} -let KeyNone = Label{text: "" draw_text.color: faint draw_text.text_style.font_size: 12} +let TestAct = Act{draw_bg +: {color: ui_field color_focus: ui_field color_hover: ui_field color_down: ui_field} draw_text +: {text_style: theme.font_bold{font_size: 13}}} +let Mark = Label{text: "" draw_text.text_style: theme.font_icons{font_size: 13}} +let Big = UiPrimary{width: Fill} +let Quiet = UiButton{width: Fill} +let Field = UiField{} +let Section = Label{padding: 0 text: "" draw_text.color: secondary draw_text.text_style: theme.font_bold{font_size: 13}} +let Card = RoundedView{width: Fill height: Fit flow: Down spacing: 14 padding: 20 + show_bg: true draw_bg.color: ui_surface draw_bg.border_radius: 14.0} +let Badge = RoundedView{width: Fit height: Fit padding: Inset{left: 8 right: 8 top: 3 bottom: 3} show_bg: true draw_bg.color: ui_field draw_bg.border_radius: 7.0} +let BadgeGrey = Badge{draw_bg.color: ui_field} +let BadgeText = Label{text: "" draw_text.color: accent draw_text.text_style: theme.font_bold{font_size: 13}} +let BadgeGreyText = Label{text: "" draw_text.color: secondary draw_text.text_style: theme.font_bold{font_size: 13}} +let KeyOk = Label{text: "" draw_text.color: ui_success draw_text.text_style.font_size: 13} +let KeyBad = Label{text: "" draw_text.color: danger draw_text.text_style.font_size: 13} +let KeyNone = Label{text: "" draw_text.color: faint draw_text.text_style.font_size: 13} // The pull-down: the same look as the host's add sheet (and the colours the // end-to-end test finds it by). let Drop = RoundedView{width: Fill height: Fit flow: Right align: Align{y: 0.5} spacing: 10 - padding: Inset{left: 12 right: 14 top: 8 bottom: 8} show_bg: true draw_bg.color: #xedf2fc draw_bg.border_radius: 10.0} + padding: Inset{left: 12 right: 14 top: 8 bottom: 8} show_bg: true draw_bg.color: ui_field draw_bg.border_radius: 10.0} let Opt = RoundedView{width: Fill height: Fit flow: Right align: Align{y: 0.5} spacing: 10 - padding: Inset{left: 12 right: 14 top: 6 bottom: 6} show_bg: true draw_bg.color: #xf6f8fc draw_bg.border_radius: 8.0} -let Glyph = Label{text: "" draw_text.color: faint draw_text.text_style: theme.font_icons{font_size: 10}} -let Tick = Label{text: "" draw_text.color: accent draw_text.text_style: theme.font_icons{font_size: 11}} -let Tag = RoundedView{width: Fit height: Fit padding: Inset{left: 6 right: 6 top: 2 bottom: 2} show_bg: true draw_bg.color: #xe2f4e6 draw_bg.border_radius: 6.0} -let TagText = Label{padding: 0 text: "" draw_text.color: #x1f7a3a draw_text.text_style: theme.font_bold{font_size: 10}} + padding: Inset{left: 12 right: 14 top: 6 bottom: 6} show_bg: true draw_bg.color: ui_field draw_bg.border_radius: 8.0} +let Glyph = Label{text: "" draw_text.color: faint draw_text.text_style: theme.font_icons{font_size: 13}} +let Tick = Label{text: "" draw_text.color: accent draw_text.text_style: theme.font_icons{font_size: 13}} +let Tag = RoundedView{width: Fit height: Fit padding: Inset{left: 6 right: 6 top: 2 bottom: 2} show_bg: true draw_bg.color: ui_field draw_bg.border_radius: 6.0} +let TagText = Label{padding: 0 text: "" draw_text.color: ui_success draw_text.text_style: theme.font_bold{font_size: 13}} let OptTitle = Label{padding: 0 width: Fit text: "" draw_text.color: ink draw_text.text_style.font_size: 14} let OptTitleOn = Label{padding: 0 width: Fit text: "" draw_text.color: ink draw_text.text_style: theme.font_bold{font_size: 14}} -let OptNote = Label{padding: 0 width: Fill text: "" draw_text.color: faint draw_text.text_style.font_size: 11} +let OptNote = Label{padding: 0 width: Fill text: "" draw_text.color: faint draw_text.text_style.font_size: 13} -SolidView{width: Fill height: Fill flow: Down draw_bg.color: page - ScrollYView{width: Fill height: Fill flow: Down spacing: 10 padding: Inset{left: 16 right: 16 top: 16 bottom: 20} - View{width: Fill height: Fit flow: Down spacing: 4 - Label{text: "AI providers" draw_text.color: ink draw_text.text_style: theme.font_bold{font_size: 26}} - Label{width: Fill text: "The assistant answers with the primary model, and tries the fallbacks in order when it fails." - draw_text.color: secondary draw_text.text_style.font_size: 13} +SolidView{width: Fill height: Fill flow: Down align: Align{x: 0.5} draw_bg.color: page + ScrollYView{width: Fill max_width: 840 height: Fill flow: Down spacing: 16 padding: Inset{left: 20 right: 20 top: 24 bottom: 24} + View{width: Fill height: Fit flow: Right spacing: 12 align: Align{y: 0.5} + RoundedView{width: 48 height: 48 align: Align{x: 0.5 y: 0.5} + show_bg: true draw_bg +: {color: ui_primary border_radius: 14.0} + Label{padding: 0 text: "AI" draw_text +: {color: #xffffff text_style: theme.font_bold{font_size: 18}}} + } + View{width: Fill height: Fit flow: Down spacing: 4 + UiTitle{text: "AI providers" draw_text.text_style.font_size: 24} + UiCaption{text: "Models for your assistant"} + } } - Big{text: "Add model" on_click: || add_model()} - Quiet{text: "Talk to Octos" on_click: || { + add := Big{visible: false text: "Add model" on_click: || add_model()} + talk := Quiet{visible: false text: "Talk to Octos" on_click: || { host.request("llm.connect_client", {}, fn(r){ if !r.is_ok { status(r.error) } }) }} - View{width: Fill height: Fit flow: Right align: Align{y: 1.0} padding: Inset{top: 4} + saved_heading := View{visible: false width: Fill height: Fit flow: Right align: Align{y: 1.0} padding: Inset{top: 4} Section{text: "SAVED MODELS"} View{width: Fill height: 1} - count := Label{text: "" draw_text.color: faint draw_text.text_style.font_size: 12} + count := Label{text: "" draw_text.color: faint draw_text.text_style.font_size: 13} } filter_box := View{visible: false width: Fill height: Fit Field{empty_text: "Filter saved models" on_change: |text| set_query(text)} @@ -243,10 +276,12 @@ SolidView{width: Fill height: Fill flow: Down draw_bg.color: page // Always one child: a render that yields none keeps the last rows. View{width: Fill height: 1} if providers.len() == 0 { - Card{align: Align{x: 0.5} padding: Inset{left: 20 right: 20 top: 28 bottom: 28} spacing: 6 - Label{width: Fill align: Align{x: 0.5} text: "No models yet" draw_text.color: ink draw_text.text_style: theme.font_bold{font_size: 16}} - Label{width: Fill align: Align{x: 0.5} text: "Add a model so the assistant can answer, or import the ones saved on another device." - draw_text.color: secondary draw_text.text_style.font_size: 13} + Card{spacing: 16 + View{width: Fill height: Fit flow: Down spacing: 8 + Label{padding: 0 width: Fill text: "Connect your first model" draw_text +: {color: ink text_style: theme.font_bold{font_size: 20}}} + UiCaption{text: "Choose a provider and model to start using your assistant."} + } + Big{text: "Add model" on_click: || add_model()} } } if providers.len() > 0 && shown.len() == 0 { @@ -268,12 +303,12 @@ SolidView{width: Fill height: Fill flow: Down draw_bg.color: page if menu_for == p.id { View{width: Fill height: Fit flow: Down spacing: 2 Label{width: Fill text: "Choose a model" draw_text.color: ink draw_text.text_style: theme.font_bold{font_size: 16}} - Label{width: Fill text: p.label + "'s catalog · the route and key stay" draw_text.color: faint draw_text.text_style.font_size: 12} + Label{width: Fill text: p.label + "'s catalog · the route and key stay" draw_text.color: faint draw_text.text_style.font_size: 13} } } else { View{width: Fill height: Fit flow: Down spacing: 2 Label{width: Fill text: p.model_label draw_text.color: ink draw_text.text_style: theme.font_bold{font_size: 16}} - Label{width: Fill text: meta(p) draw_text.color: faint draw_text.text_style.font_size: 12} + Label{width: Fill text: meta(p) draw_text.color: faint draw_text.text_style.font_size: 13} } } if menu_for == p.id { Glyph{text: ""} } else { Glyph{text: ""} } @@ -281,7 +316,7 @@ SolidView{width: Fill height: Fill flow: Down draw_bg.color: page } if menu_for == p.id { View{width: Fill height: Fit flow: Down spacing: 4 - if menu_models.len() == 0 { Label{text: "Loading models…" draw_text.color: faint draw_text.text_style.font_size: 12} } + if menu_models.len() == 0 { Label{text: "Loading models…" draw_text.color: faint draw_text.text_style.font_size: 13} } for m in menu_models { GestureView{width: Fill height: Fit on_tap: |x, y| pick_model(p, m) Opt{ @@ -296,23 +331,23 @@ SolidView{width: Fill height: Fill flow: Down draw_bg.color: page } } } - Label{width: Fill text: "Another route, a custom model or a new key: Edit." draw_text.color: faint draw_text.text_style.font_size: 11} + Label{width: Fill text: "Another route, a custom model or a new key: Edit." draw_text.color: faint draw_text.text_style.font_size: 13} } } - if state_of(p.id) == "testing" { Label{width: Fill text: result_of(p.id) draw_text.color: faint draw_text.text_style.font_size: 12} } + if state_of(p.id) == "testing" { Label{width: Fill text: result_of(p.id) draw_text.color: faint draw_text.text_style.font_size: 13} } if state_of(p.id) == "ok" { View{width: Fill height: Fit flow: Right spacing: 6 align: Align{y: 0.5} - Mark{text: "" draw_text.color: #x248a3d} - Label{width: Fill text: result_of(p.id) draw_text.color: #x248a3d draw_text.text_style: theme.font_bold{font_size: 12}} + Mark{text: "" draw_text.color: ui_success} + Label{width: Fill text: result_of(p.id) draw_text.color: ui_success draw_text.text_style: theme.font_bold{font_size: 13}} } } if state_of(p.id) == "fail" { View{width: Fill height: Fit flow: Down spacing: 2 View{width: Fill height: Fit flow: Right spacing: 6 align: Align{y: 0.5} Mark{text: "" draw_text.color: danger} - Label{width: Fill text: result_of(p.id) draw_text.color: #xc4281c draw_text.text_style: theme.font_bold{font_size: 12}} + Label{width: Fill text: result_of(p.id) draw_text.color: ui_danger draw_text.text_style: theme.font_bold{font_size: 13}} } - Label{width: Fill text: detail_of(p.id) draw_text.color: faint draw_text.text_style.font_size: 11} + Label{width: Fill text: detail_of(p.id) draw_text.color: faint draw_text.text_style.font_size: 13} } } View{width: Fill height: Fit flow: Flow.Right{wrap: true} spacing: 6 padding: Inset{top: 2} @@ -327,19 +362,21 @@ SolidView{width: Fill height: Fill flow: Down draw_bg.color: page } } }} - View{width: Fill height: Fit flow: Down spacing: 8 padding: Inset{top: 8} - Section{text: "ANOTHER DEVICE"} - // Stacked: side by side, a phone's width clips both labels. - Quiet{text: "Show QR for phone" on_click: || show_qr()} + Card{spacing: 12 + View{width: Fill height: Fit flow: Down spacing: 6 + Label{padding: 0 width: Fill text: "Already set up elsewhere?" draw_text +: {color: ink text_style: theme.font_bold{font_size: 16}}} + UiCaption{text: "Bring your saved models from another device."} + } scan := Quiet{visible: false text: "Scan QR from desktop" on_click: || import_code()} paste := Quiet{text: "Paste code" on_click: || import_code()} - where := Label{width: Fill text: "" draw_text.color: faint draw_text.text_style.font_size: 12} + export_qr := Quiet{visible: false text: "Show QR for phone" on_click: || show_qr()} } + where := UiCaption{text: ""} } banner := View{visible: false width: Fill height: Fit padding: Inset{left: 12 right: 12 top: 6 bottom: 10} RoundedView{width: Fill height: Fit flow: Right align: Align{y: 0.5} padding: Inset{left: 14 right: 6 top: 4 bottom: 4} show_bg: true draw_bg.color: #x1c1c1e draw_bg.border_radius: 12.0 status := Label{width: Fill text: "" draw_text.color: #xffffff draw_text.text_style.font_size: 13} - ButtonFlat{height: 34 padding: Inset{left: 10 right: 10} text: "OK" on_click: || status("") + ButtonFlat{height: 44 padding: Inset{left: 10 right: 10} text: "OK" on_click: || status("") draw_bg +: {color: #x00000000 color_focus: #x00000000 color_hover: #xffffff22 color_down: #xffffff33 border_size: 0.0} draw_text +: {color: #x64d2ff color_focus: #x64d2ff color_hover: #x64d2ff color_down: #x64d2ff text_style: theme.font_bold{font_size: 13}}} } diff --git a/apps/calendar/bundle/main.splash b/apps/calendar/bundle/main.splash index af666cee..20bbe861 100644 --- a/apps/calendar/bundle/main.splash +++ b/apps/calendar/bundle/main.splash @@ -1,3 +1,40 @@ +// BEGIN shared app interface +// Shared system-app interface. Run tools/sync-app-interface.py after editing. +// Bundles carry this prelude so they work in the contained standalone host too. +// Resolve the current theme after a host restyle, not a retained prelude alias. +let theme = mod.theme +let ui_ink = theme.color_text +let ui_page = theme.color_bg_app +let ui_light = dot(ui_ink, #x36b61200) < 0.5 +let ui_surface = if ui_light { #xffffff } else { mix(ui_page, ui_ink, 0.06) } +let ui_field = mix(ui_page, ui_ink, 0.075) +let ui_hover = mix(ui_page, ui_ink, 0.12) +let ui_border = mix(ui_page, ui_ink, 0.22) +let ui_muted = mix(ui_ink, ui_page, 0.24) +let ui_link = mix(ui_ink, #x356bce, 0.25) +let ui_primary = #x285cd6 +let ui_success = mix(ui_ink, #x28a55b, 0.20) +let ui_danger = mix(ui_ink, #xe13b53, 0.20) +let UiButton = ButtonFlat{height: 44 margin: 0 padding: Inset{left: 14 right: 14} + draw_bg +: {border_radius: 12.0 border_size: 0.0 color: #x00000000 color_hover: ui_hover color_down: ui_field color_focus: ui_hover} + draw_text +: {color: ui_link color_hover: ui_link color_down: ui_link color_focus: ui_link text_style: theme.font_bold{font_size: 14}} +} +let UiPrimary = UiButton{ + draw_bg +: {color: ui_primary color_hover: #x3468df color_down: #x204cb8 color_focus: #x204cb8} + draw_text +: {color: #xffffff color_hover: #xffffff color_down: #xffffff color_focus: #xffffff} +} +let UiPill = UiButton{draw_bg +: {color: ui_surface border_radius: 22.0} draw_text +: {color: ui_ink color_hover: ui_ink color_down: ui_ink color_focus: ui_ink}} +let UiField = TextInput{width: Fill height: 48 margin: 0 padding: Inset{left: 14 right: 14 top: 12 bottom: 12} + draw_bg +: {color: ui_field color_hover: ui_field color_focus: ui_field color_empty: ui_field + border_color: ui_border border_color_hover: ui_link border_color_focus: ui_link border_color_empty: ui_border border_radius: 12.0} + draw_text +: {color: ui_ink color_hover: ui_ink color_focus: ui_ink color_empty: ui_muted color_empty_hover: ui_muted color_empty_focus: ui_muted text_style: theme.font_regular{font_size: 15}} + draw_selection.color: #x557dcc55 +} +let UiCard = RoundedView{width: Fill height: Fit flow: Down spacing: 12 padding: 20 + show_bg: true draw_bg +: {color: ui_surface border_radius: 16.0}} +let UiCaption = Label{padding: 0 flow: Flow.Right{wrap: true} width: Fill draw_text +: {color: ui_muted text_style: theme.font_regular{font_size: 13 line_spacing: 1.4}}} +let UiTitle = Label{padding: 0 flow: Flow.Right{wrap: true} width: Fill draw_text +: {color: ui_ink text_style: theme.font_bold{font_size: 28 line_spacing: 1.2}}} +// END shared app interface // Calendar owns one store. Both this UI and agents use calendar.*; the // Glance event card is a projection of the same saved event, never a copy. let month = "" @@ -63,7 +100,7 @@ fn refresh(direction){ ui.day_title.set_text(r.data.day_label) ui.grid.render() ui.events.render() - status("Local calendar · Times include each event's zone") + status("Your schedule, all in one place") if r.data.focused != nil && screen != "editor" { open_event(r.data.focused) } if screen == "detail" && selected != nil { for event in events { if event.id == selected.id { selected = event; render_detail() } } @@ -84,7 +121,7 @@ fn edit_new(){ ui.e_zone.set_text(zone) ui.e_place.set_text("") ui.e_notes.set_text("") - ui.edit_status.set_text("An empty end leaves the duration unspecified.") + ui.edit_status.set_text("Title and date are required. End time is optional.") pane("editor") } fn edit_existing(){ @@ -151,33 +188,24 @@ fn remove(){ if r.is_ok { back() } else { ui.detail_status.set_text(r.error) } }) } -fn poll(){ - start_timeout(1.0, || { - if !busy && screen != "editor" { refresh(0) } - poll() - }) -} -start_timeout(0.05, || { refresh(0); poll() }) +start_timeout(0.05, || refresh(0)) +// A top-level interval is replaced by the host on a theme reload. A recursive +// timeout chain survives that reload and starts an extra poller every time. +start_interval(1.0, || { if !busy && screen != "editor" { refresh(0) } }) -let ink = #x172033 -let muted = #x53627a -let accent = #x285cd6 -let Bar = ButtonFlat{height: 44 padding: Inset{left: 12 right: 12} - draw_bg +: {color: #x00000000 color_hover: #xeaf0ff color_down: #xdbe6ff border_size: 0.0 border_radius: 10.0} - draw_text +: {color: accent color_hover: accent color_down: accent text_style +: {font_size: 14}} -} -let Primary = Bar{draw_bg +: {color: accent color_hover: #x396de6 color_down: #x204bb5} - draw_text +: {color: #xffffff color_hover: #xffffff color_down: #xffffff}} -let Field = TextInput{width: Fill height: 46 - draw_bg +: {color: #xf1f4fa color_hover: #xeaf0fa color_focus: #xeaf0ff color_empty: #xf1f4fa border_color: #xd9e0ec border_color_hover: #xb9cbea border_color_focus: accent border_color_empty: #xd9e0ec border_radius: 10.0} - draw_text +: {color: ink color_hover: ink color_focus: ink color_empty: muted color_empty_hover: muted}} -let Caption = Label{width: Fill draw_text.color: muted draw_text.text_style.font_size: 12} -let Day = Bar{width: Fill height: 43 padding: 0 draw_text +: {color: ink color_hover: ink color_down: ink text_style +: {font_size: 14}}} -let Marked = Day{draw_bg +: {color: #xeaf0ff color_hover: #xdbe6ff color_down: #xcddcff}} -let Chosen = Day{draw_bg +: {color: accent color_hover: accent color_down: accent} draw_text +: {color: #xffffff color_hover: #xffffff color_down: #xffffff}} +let ink = ui_ink +let muted = ui_muted +let accent = ui_link +let Bar = UiButton{} +let Primary = UiPrimary{} +let Field = UiField{} +let Caption = UiCaption{} +let Day = Bar{draw_text.text_style: theme.font_regular{font_size: 14} width: Fill height: 44 padding: 0 draw_text +: {color: ink color_hover: ink color_down: ink text_style +: {font_size: 14}}} +let Marked = Day{draw_bg +: {color: ui_field color_hover: ui_hover color_down: ui_hover}} +let Chosen = Day{draw_bg +: {color: ui_primary color_hover: ui_primary color_down: ui_primary} draw_text +: {color: #xffffff color_hover: #xffffff color_down: #xffffff}} -SolidView{width: Fill height: Fill flow: Overlay draw_bg.color: #xffffff - calendar := View{width: Fill height: Fill flow: Down +SolidView{width: Fill height: Fill flow: Overlay align: Align{x: 0.5} draw_bg.color: ui_page + calendar := View{width: Fill max_width: 840 height: Fill flow: Down View{width: Fill height: 62 flow: Right align: Align{y: 0.5} padding: Inset{left: 18 right: 14} spacing: 6 Label{width: Fill text: "Calendar" draw_text.color: ink draw_text.text_style: theme.font_bold{font_size: 26}} Bar{text: "Today" on_click: || go_today()} @@ -185,47 +213,53 @@ SolidView{width: Fill height: Fill flow: Overlay draw_bg.color: #xffffff } status := Caption{padding: Inset{left: 18 right: 18 bottom: 8} text: "Loading calendar…"} ScrollYView{width: Fill height: Fill flow: Down padding: Inset{left: 14 right: 14 bottom: 28} spacing: 12 + UiCard{padding: 8 spacing: 8 View{width: Fill height: 46 flow: Right align: Align{y: 0.5} Bar{width: 44 text: "‹" on_click: || refresh(-1)} month_title := Label{width: Fill align: Align{x: 0.5} text: "" draw_text.color: ink draw_text.text_style: theme.font_bold{font_size: 20}} Bar{width: 44 text: "›" on_click: || refresh(1)} } View{width: Fill height: 22 flow: Right - for name in weekdays { Label{width: Fill align: Align{x: 0.5} text: name draw_text.color: muted draw_text.text_style.font_size: 12} } + for name in weekdays { Label{width: Fill align: Align{x: 0.5} text: name draw_text.color: muted draw_text.text_style.font_size: 13} } } grid := View{width: Fill height: Fit flow: Down spacing: 3 on_render: || { for week in weeks { - View{width: Fill height: 43 flow: Right spacing: 3 + View{width: Fill height: 44 flow: Right spacing: 3 for date in week.days { let label = date.label if date.marked { label = label + " •" } if date.selected { Chosen{text: label on_click: || pick(date.date)} } else if date.marked { Marked{text: label on_click: || pick(date.date)} } - else if !date.in_month { Day{text: label draw_text.color: #x929db0 on_click: || pick(date.date)} } + else if !date.in_month { Day{text: label draw_text.color: ui_muted on_click: || pick(date.date)} } else { Day{text: label on_click: || pick(date.date)} } } } } }} + } day_title := Label{width: Fill margin: Inset{top: 10} text: "" draw_text.color: ink draw_text.text_style: theme.font_bold{font_size: 18}} events := View{width: Fill height: Fit flow: Down spacing: 8 on_render: || { if events.len() == 0 { - View{width: Fill height: Fit padding: 16 flow: Down spacing: 8 + UiCard{padding: 20 spacing: 8 + Label{width: Fill text: "A little room in your day" draw_text.color: ink draw_text.text_style: theme.font_bold{font_size: 17}} Label{width: Fill text: "No events on this day" draw_text.color: muted draw_text.text_style.font_size: 15} - Bar{text: "Add an event" on_click: || edit_new()} + Bar{text: "+ Add an event" on_click: || edit_new()} } } for event in events { - GestureView{width: Fill height: Fit flow: Down padding: 16 spacing: 6 show_bg: true draw_bg.color: #xf1f5fc on_tap: |x, y| open_event(event) - Label{width: Fill text: event.time draw_text.color: accent draw_text.text_style: theme.font_bold{font_size: 15}} - Label{width: Fill text: event.title draw_text.color: ink draw_text.text_style: theme.font_bold{font_size: 18}} - if event.location != "" { Caption{text: event.location} } + GestureView{width: Fill height: Fit on_tap: |x, y| open_event(event) + UiCard{padding: 16 spacing: 6 + Label{width: Fill text: event.time draw_text.color: accent draw_text.text_style: theme.font_bold{font_size: 15}} + Label{width: Fill text: event.title draw_text.color: ink draw_text.text_style: theme.font_bold{font_size: 18}} + if event.location != "" { Caption{text: event.location} } + Bar{text: "View event ›" on_click: || open_event(event)} + } } } }} } } - detail := SolidView{visible: false width: Fill height: Fill flow: Down draw_bg.color: #xffffff + detail := SolidView{visible: false width: Fill max_width: 840 height: Fill flow: Down draw_bg.color: ui_surface View{width: Fill height: 56 flow: Right padding: Inset{left: 8 right: 12} align: Align{y: 0.5} Bar{text: "‹ Calendar" on_click: || back()} View{width: Fill height: 1} @@ -233,7 +267,7 @@ SolidView{width: Fill height: Fill flow: Overlay draw_bg.color: #xffffff } 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}} - SolidView{width: Fill height: Fit flow: Down padding: 18 spacing: 8 draw_bg.color: #xeaf0ff + 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}} event_zone := Caption{text: ""} @@ -244,17 +278,20 @@ SolidView{width: Fill height: Fill flow: Overlay draw_bg.color: #xffffff event_notes := Label{width: Fill text: "" draw_text.color: ink draw_text.text_style.font_size: 16} Primary{text: "Show in Glance" on_click: || show_glance()} detail_status := Caption{text: ""} - delete_button := Bar{text: "Delete event" draw_text.color: #xb52c38 on_click: || remove()} + delete_button := Bar{text: "Delete event" draw_text +: {color: ui_danger color_hover: ui_danger color_down: ui_danger color_focus: ui_danger} on_click: || remove()} } } - editor := SolidView{visible: false width: Fill height: Fill flow: Down draw_bg.color: #xffffff + editor := SolidView{visible: false width: Fill max_width: 840 height: Fill flow: Down draw_bg.color: ui_surface View{width: Fill height: 58 flow: Right padding: Inset{left: 8 right: 12} align: Align{y: 0.5} spacing: 8 Bar{text: "Cancel" on_click: || cancel_edit()} edit_heading := Label{width: Fill text: "New event" align: Align{x: 0.5} draw_text.color: ink draw_text.text_style: theme.font_bold{font_size: 17}} Primary{text: "Save" on_click: || save()} } - edit_status := Caption{padding: Inset{left: 18 right: 18 bottom: 10} text: ""} - ScrollYView{width: Fill height: Fill flow: Down padding: 18 spacing: 10 + View{width: Fill height: Fit flow: Down new_batch: true padding: Inset{left: 18 right: 18 bottom: 10} + edit_status := Caption{text: ""} + } + ScrollYView{width: Fill height: Fill flow: Down padding: 18 spacing: 10 new_batch: true + UiTitle{text: "Event details"} Caption{text: "Title"} e_title := Field{empty_text: "Event title"} View{width: Fill height: Fit flow: Right spacing: 12 @@ -282,7 +319,7 @@ SolidView{width: Fill height: Fill flow: Overlay draw_bg.color: #xffffff Caption{text: "Location"} e_place := Field{empty_text: "Add a location"} Caption{text: "Notes"} - e_notes := Field{height: 150 empty_text: "Add notes"} + e_notes := Field{is_multiline: true height: 150 empty_text: "Add notes"} } } } diff --git a/apps/camera/bundle/main.splash b/apps/camera/bundle/main.splash index d084fc7d..da889182 100644 --- a/apps/camera/bundle/main.splash +++ b/apps/camera/bundle/main.splash @@ -116,16 +116,13 @@ fn boot(){ } start_timeout(0.05, || boot()) -let Chip = ButtonFlat{height: 34 padding: Inset{left: 12 right: 12} - draw_bg +: {border_radius: 17.0 color: #x00000066 color_hover: #x00000088 color_down: #x000000aa border_size: 0.0} - draw_text +: {color: #xffffff color_hover: #xffffff color_down: #xffffff text_style +: {font_size: 13}} +let Chip = ButtonFlat{height: 44 padding: Inset{left: 12 right: 12} + draw_bg +: {border_radius: 17.0 color: #x000000b3 color_hover: #x000000cc color_down: #x000000dd color_focus: #x000000cc border_size: 0.0} + draw_text +: {color: #xffffff color_hover: #xffffff color_down: #xffffff color_focus: #xffffff text_style +: {font_size: 13}} } -let ChipOn = Chip{draw_text +: {color: #xffd60a color_hover: #xffd60a color_down: #xffd60a}} -let ModeOff = ButtonFlat{height: 30 padding: Inset{left: 10 right: 10} - draw_bg +: {color: #x00000000 color_hover: #x00000000 color_down: #x00000000 border_size: 0.0} - draw_text +: {color: #xffffffaa color_hover: #xffffff color_down: #xffffff text_style +: {font_size: 14}} -} -let ModeOn = ModeOff{draw_text +: {color: #xffd60a color_hover: #xffd60a color_down: #xffd60a}} +let ChipOn = Chip{draw_text +: {color: #xffd60a color_hover: #xffd60a color_down: #xffd60a color_focus: #xffd60a}} +let ModeOff = Chip{draw_text.text_style.font_size: 14} +let ModeOn = ModeOff{draw_text +: {color: #xffd60a color_hover: #xffd60a color_down: #xffd60a color_focus: #xffd60a}} SolidView{width: Fill height: Fill flow: Overlay draw_bg.color: #000000 cam := CameraPreview{width: Fill height: Fill facing: "back" @@ -142,7 +139,7 @@ SolidView{width: Fill height: Fill flow: Overlay draw_bg.color: #000000 rec := Label{text: "00:00" draw_text.color: #xffffff draw_text.text_style: theme.font_bold{font_size: 13}} } } - toast := Label{width: Fill align: Align{x: 0.5} padding: 8 text: "" draw_text.color: #xffd60a draw_text.text_style.font_size: 12} + toast := Label{width: Fill align: Align{x: 0.5} padding: 8 text: "" draw_text.color: #xffd60a draw_text.text_style.font_size: 14} 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] { @@ -175,14 +172,14 @@ SolidView{width: Fill height: Fill flow: Overlay draw_bg.color: #000000 } }} View{width: Fill height: 1} - ButtonFlat{width: 56 height: 56 text: "Flip" on_click: || ui.cam.switch() - draw_bg +: {border_radius: 28.0 color: #x00000066 color_hover: #x00000088 color_down: #x000000aa border_size: 0.0} - draw_text +: {color: #xffffff color_hover: #xffffff color_down: #xffffff text_style +: {font_size: 14}}} + Chip{width: 56 height: 56 text: "Flip" on_click: || ui.cam.switch() + draw_bg.border_radius: 28.0 + draw_text.text_style.font_size: 14} } } viewer := SolidView{visible: false width: Fill height: Fill flow: Down new_batch: true draw_bg.color: #000000 View{width: Fill height: Fit padding: Inset{left: 8 top: 8} - ButtonFlat{text: "‹ Camera" on_click: || close_viewer() + ButtonFlat{height: 44 text: "‹ Camera" on_click: || close_viewer() draw_bg +: {color: #x00000000 color_hover: #x00000000 color_down: #x00000000 border_size: 0.0} draw_text +: {color: #xffd60a color_hover: #xffd60a color_down: #xffd60a text_style +: {font_size: 15}}} } diff --git a/apps/interface.splash b/apps/interface.splash new file mode 100644 index 00000000..0ed2ba47 --- /dev/null +++ b/apps/interface.splash @@ -0,0 +1,35 @@ +// Shared system-app interface. Run tools/sync-app-interface.py after editing. +// Bundles carry this prelude so they work in the contained standalone host too. +// Resolve the current theme after a host restyle, not a retained prelude alias. +let theme = mod.theme +let ui_ink = theme.color_text +let ui_page = theme.color_bg_app +let ui_light = dot(ui_ink, #x36b61200) < 0.5 +let ui_surface = if ui_light { #xffffff } else { mix(ui_page, ui_ink, 0.06) } +let ui_field = mix(ui_page, ui_ink, 0.075) +let ui_hover = mix(ui_page, ui_ink, 0.12) +let ui_border = mix(ui_page, ui_ink, 0.22) +let ui_muted = mix(ui_ink, ui_page, 0.24) +let ui_link = mix(ui_ink, #x356bce, 0.25) +let ui_primary = #x285cd6 +let ui_success = mix(ui_ink, #x28a55b, 0.20) +let ui_danger = mix(ui_ink, #xe13b53, 0.20) +let UiButton = ButtonFlat{height: 44 margin: 0 padding: Inset{left: 14 right: 14} + draw_bg +: {border_radius: 12.0 border_size: 0.0 color: #x00000000 color_hover: ui_hover color_down: ui_field color_focus: ui_hover} + draw_text +: {color: ui_link color_hover: ui_link color_down: ui_link color_focus: ui_link text_style: theme.font_bold{font_size: 14}} +} +let UiPrimary = UiButton{ + draw_bg +: {color: ui_primary color_hover: #x3468df color_down: #x204cb8 color_focus: #x204cb8} + draw_text +: {color: #xffffff color_hover: #xffffff color_down: #xffffff color_focus: #xffffff} +} +let UiPill = UiButton{draw_bg +: {color: ui_surface border_radius: 22.0} draw_text +: {color: ui_ink color_hover: ui_ink color_down: ui_ink color_focus: ui_ink}} +let UiField = TextInput{width: Fill height: 48 margin: 0 padding: Inset{left: 14 right: 14 top: 12 bottom: 12} + draw_bg +: {color: ui_field color_hover: ui_field color_focus: ui_field color_empty: ui_field + border_color: ui_border border_color_hover: ui_link border_color_focus: ui_link border_color_empty: ui_border border_radius: 12.0} + draw_text +: {color: ui_ink color_hover: ui_ink color_focus: ui_ink color_empty: ui_muted color_empty_hover: ui_muted color_empty_focus: ui_muted text_style: theme.font_regular{font_size: 15}} + draw_selection.color: #x557dcc55 +} +let UiCard = RoundedView{width: Fill height: Fit flow: Down spacing: 12 padding: 20 + show_bg: true draw_bg +: {color: ui_surface border_radius: 16.0}} +let UiCaption = Label{padding: 0 flow: Flow.Right{wrap: true} width: Fill draw_text +: {color: ui_muted text_style: theme.font_regular{font_size: 13 line_spacing: 1.4}}} +let UiTitle = Label{padding: 0 flow: Flow.Right{wrap: true} width: Fill draw_text +: {color: ui_ink text_style: theme.font_bold{font_size: 28 line_spacing: 1.2}}} diff --git a/apps/mail/bundle/main.splash b/apps/mail/bundle/main.splash index 1ba1aff0..68850627 100644 --- a/apps/mail/bundle/main.splash +++ b/apps/mail/bundle/main.splash @@ -1,3 +1,40 @@ +// BEGIN shared app interface +// Shared system-app interface. Run tools/sync-app-interface.py after editing. +// Bundles carry this prelude so they work in the contained standalone host too. +// Resolve the current theme after a host restyle, not a retained prelude alias. +let theme = mod.theme +let ui_ink = theme.color_text +let ui_page = theme.color_bg_app +let ui_light = dot(ui_ink, #x36b61200) < 0.5 +let ui_surface = if ui_light { #xffffff } else { mix(ui_page, ui_ink, 0.06) } +let ui_field = mix(ui_page, ui_ink, 0.075) +let ui_hover = mix(ui_page, ui_ink, 0.12) +let ui_border = mix(ui_page, ui_ink, 0.22) +let ui_muted = mix(ui_ink, ui_page, 0.24) +let ui_link = mix(ui_ink, #x356bce, 0.25) +let ui_primary = #x285cd6 +let ui_success = mix(ui_ink, #x28a55b, 0.20) +let ui_danger = mix(ui_ink, #xe13b53, 0.20) +let UiButton = ButtonFlat{height: 44 margin: 0 padding: Inset{left: 14 right: 14} + draw_bg +: {border_radius: 12.0 border_size: 0.0 color: #x00000000 color_hover: ui_hover color_down: ui_field color_focus: ui_hover} + draw_text +: {color: ui_link color_hover: ui_link color_down: ui_link color_focus: ui_link text_style: theme.font_bold{font_size: 14}} +} +let UiPrimary = UiButton{ + draw_bg +: {color: ui_primary color_hover: #x3468df color_down: #x204cb8 color_focus: #x204cb8} + draw_text +: {color: #xffffff color_hover: #xffffff color_down: #xffffff color_focus: #xffffff} +} +let UiPill = UiButton{draw_bg +: {color: ui_surface border_radius: 22.0} draw_text +: {color: ui_ink color_hover: ui_ink color_down: ui_ink color_focus: ui_ink}} +let UiField = TextInput{width: Fill height: 48 margin: 0 padding: Inset{left: 14 right: 14 top: 12 bottom: 12} + draw_bg +: {color: ui_field color_hover: ui_field color_focus: ui_field color_empty: ui_field + border_color: ui_border border_color_hover: ui_link border_color_focus: ui_link border_color_empty: ui_border border_radius: 12.0} + draw_text +: {color: ui_ink color_hover: ui_ink color_focus: ui_ink color_empty: ui_muted color_empty_hover: ui_muted color_empty_focus: ui_muted text_style: theme.font_regular{font_size: 15}} + draw_selection.color: #x557dcc55 +} +let UiCard = RoundedView{width: Fill height: Fit flow: Down spacing: 12 padding: 20 + show_bg: true draw_bg +: {color: ui_surface border_radius: 16.0}} +let UiCaption = Label{padding: 0 flow: Flow.Right{wrap: true} width: Fill draw_text +: {color: ui_muted text_style: theme.font_regular{font_size: 13 line_spacing: 1.4}}} +let UiTitle = Label{padding: 0 flow: Flow.Right{wrap: true} width: Fill draw_text +: {color: ui_ink text_style: theme.font_bold{font_size: 28 line_spacing: 1.2}}} +// END shared app interface let account = nil // {id, address} let messages = [] let total = 0 @@ -157,39 +194,31 @@ fn open_folder(f){ start_timeout(0.05, || load_accounts()) -let ink = #x1c1c1e -let secondary = #x8e8e93 -let accent = #x007aff -let Bar = ButtonFlat{height: 36 padding: Inset{left: 10 right: 10} - draw_bg +: {color: #x00000000 color_hover: #x0000000a color_down: #x00000014 border_size: 0.0} - draw_text +: {color: accent color_hover: accent color_down: accent text_style +: {font_size: 15}} -} -let Field = TextInput{width: Fill height: 40 - draw_bg +: {color: #xf2f2f7 color_hover: #xf2f2f7 color_focus: #xf2f2f7 color_empty: #xf2f2f7 - border_color: #x00000000 border_color_hover: #x00000000 border_color_focus: #x00000000 border_color_empty: #x00000000 border_radius: 10.0} - draw_text +: {color: ink color_hover: ink color_focus: ink color_empty: secondary color_empty_hover: secondary} -} -let Row = GestureView{width: Fill height: Fit flow: Down padding: Inset{left: 16 right: 16 top: 10 bottom: 10} spacing: 2 +let ink = ui_ink +let secondary = ui_muted +let accent = ui_link +let Bar = UiButton{} +let Field = UiField{} +let Row = GestureView{width: Fill height: Fit flow: Down padding: Inset{left: 16 right: 16 top: 18 bottom: 18} spacing: 6 top := View{width: Fill height: Fit flow: Right sender := Label{width: Fill text: "" max_lines: 1 draw_text.color: ink draw_text.text_style: theme.font_bold{font_size: 15}} - time := Label{text: "" draw_text.color: secondary draw_text.text_style.font_size: 12} + time := Label{text: "" draw_text.color: secondary draw_text.text_style.font_size: 13} } - subject := Label{width: Fill text: "" max_lines: 1 draw_text.color: ink draw_text.text_style.font_size: 14} + subject := Label{width: Fill text: "" max_lines: 1 draw_text.color: ink draw_text.text_style.font_size: 16} preview := Label{width: Fill text: "" max_lines: 2 draw_text.color: secondary draw_text.text_style.font_size: 13} } let RowRead = Row{top +: {sender +: {draw_text.text_style: theme.font_regular{font_size: 15}}}} -SolidView{width: Fill height: Fill flow: Overlay draw_bg.color: #xffffff - welcome := View{width: Fill height: Fill flow: Down align: Align{x: 0.5 y: 0.5} spacing: 12 padding: 32 +SolidView{width: Fill height: Fill flow: Overlay align: Align{x: 0.5} draw_bg.color: ui_page + welcome := View{width: Fill max_width: 840 height: Fill flow: Down align: Align{x: 0.5 y: 0.5} spacing: 12 padding: 32 + AppIcon{width: 72 height: 72 name: "mail"} Label{text: "Mail" draw_text.color: ink draw_text.text_style: theme.font_bold{font_size: 32}} - Label{width: Fill align: Align{x: 0.5} text: "Add an account to read and send mail. OctoSense keeps your password; Mail never sees it." + Label{width: Fill align: Align{x: 0.5} text: "Your inbox, with room to focus. Connect an account to start reading and writing." draw_text.color: secondary draw_text.text_style.font_size: 13} - ButtonFlat{text: "Add account" height: 44 padding: Inset{left: 22 right: 22} on_click: || add_account() - draw_bg +: {border_radius: 22.0 color: #x007aff color_hover: #x0a84ff color_down: #x0062cc border_size: 0.0} - draw_text +: {color: #xffffff color_hover: #xffffff color_down: #xffffff text_style +: {font_size: 15}}} - welcome_note := Label{width: Fill align: Align{x: 0.5} text: "" draw_text.color: secondary draw_text.text_style.font_size: 12} + UiPrimary{text: "Add account" padding: Inset{left: 24 right: 24} on_click: || add_account()} + welcome_note := Label{width: Fill align: Align{x: 0.5} text: "" draw_text.color: secondary draw_text.text_style.font_size: 13} } - inbox := View{visible: false width: Fill height: Fill flow: Down + inbox := View{visible: false width: Fill max_width: 840 height: Fill flow: Down View{width: Fill height: Fit flow: Right align: Align{y: 0.5} padding: Inset{left: 8 right: 8 top: 8} Bar{text: "Folders" on_click: || show_folders()} Bar{text: "Refresh" on_click: || sync()} @@ -198,10 +227,10 @@ SolidView{width: Fill height: Fill flow: Overlay draw_bg.color: #xffffff } View{width: Fill height: Fit flow: Down padding: Inset{left: 16 right: 16 bottom: 6} spacing: 2 title := Label{text: "Inbox" draw_text.color: ink draw_text.text_style: theme.font_bold{font_size: 30}} - address := Label{text: "" draw_text.color: secondary draw_text.text_style.font_size: 12} - status := Label{text: "" draw_text.color: secondary draw_text.text_style.font_size: 12} + address := Label{text: "" draw_text.color: secondary draw_text.text_style.font_size: 13} + status := Label{text: "" draw_text.color: secondary draw_text.text_style.font_size: 13} } - rows := ScrollYView{width: Fill height: Fill flow: Down on_render: || { + rows := ScrollYView{width: Fill height: Fill flow: Down padding: Inset{left: 8 right: 8} on_render: || { if messages.len() == 0 { View{width: Fill height: 160 align: Align{x: 0.5 y: 0.5} Label{text: "No mail yet." draw_text.color: secondary draw_text.text_style.font_size: 13}} @@ -218,12 +247,12 @@ SolidView{width: Fill height: Fill flow: Overlay draw_bg.color: #xffffff View{width: Fill height: Fit flow: Right spacing: 12 padding: Inset{left: 8 right: 8 bottom: 8} Bar{height: 44 text: "Reconnect account" on_click: || add_account()} View{width: Fill height: 1} - ButtonFlat{text: "Remove account" on_click: || remove_account() + UiButton{text: "Remove account" on_click: || remove_account() draw_bg +: {color: #x00000000 color_hover: #x00000000 color_down: #x00000000 border_size: 0.0} - draw_text +: {color: #xff3b30 color_hover: #xff3b30 color_down: #xff3b30 text_style +: {font_size: 12}}} + draw_text +: {color: ui_danger color_hover: ui_danger color_down: ui_danger text_style +: {font_size: 13}}} } } - reader := SolidView{visible: false width: Fill height: Fill flow: Down new_batch: true draw_bg.color: #xffffff + reader := SolidView{visible: false width: Fill max_width: 840 height: Fill flow: Down new_batch: true draw_bg.color: ui_surface View{width: Fill height: Fit flow: Right align: Align{y: 0.5} padding: Inset{left: 8 right: 8 top: 8} Bar{text: "‹ Back" on_click: || back_to_inbox()} View{width: Fill height: 1} @@ -232,9 +261,9 @@ SolidView{width: Fill height: Fill flow: Overlay draw_bg.color: #xffffff ScrollYView{width: Fill height: Fill flow: Down padding: 16 spacing: 6 r_subject := Label{width: Fill text: "" draw_text.color: ink draw_text.text_style: theme.font_bold{font_size: 20}} r_sender := Label{width: Fill text: "" draw_text.color: secondary draw_text.text_style.font_size: 13} - r_time := Label{width: Fill text: "" draw_text.color: secondary draw_text.text_style.font_size: 12} + r_time := Label{width: Fill text: "" draw_text.color: secondary draw_text.text_style.font_size: 13} Hr{} - r_attachments := Label{visible: false width: Fill text: "" draw_text.color: secondary draw_text.text_style.font_size: 12} + r_attachments := Label{visible: false width: Fill text: "" draw_text.color: secondary draw_text.text_style.font_size: 13} r_body := Label{width: Fill text: "" draw_text.color: ink draw_text.text_style.font_size: 15} r_html := Html{visible: false width: Fill height: Fit padding: 0 font_color: ink font_size: 15 draw_text +: {color: ink} @@ -242,14 +271,14 @@ SolidView{width: Fill height: Fill flow: Overlay draw_bg.color: #xffffff text_style_italic +: {font_size: 15} text_style_bold_italic +: {font_size: 15} text_style_fixed +: {font_size: 14}} } } - folders := SolidView{visible: false width: Fill height: Fill flow: Down new_batch: true draw_bg.color: #xffffff + folders := SolidView{visible: false width: Fill max_width: 840 height: Fill flow: Down new_batch: true draw_bg.color: ui_surface View{width: Fill height: Fit flow: Right align: Align{y: 0.5} padding: Inset{left: 8 right: 8 top: 8} Bar{text: "‹ Back" on_click: || back_to_inbox()} } View{width: Fill height: Fit padding: Inset{left: 16 right: 16 bottom: 6} Label{text: "Folders" draw_text.color: ink draw_text.text_style: theme.font_bold{font_size: 30}} } - folder_rows := ScrollYView{width: Fill height: Fill flow: Down on_render: || { + folder_rows := ScrollYView{width: Fill height: Fill flow: Down padding: Inset{left: 8 right: 8} on_render: || { if folders.len() == 0 { View{width: Fill height: 120 align: Align{x: 0.5 y: 0.5} Label{text: "Looking for folders…" draw_text.color: secondary draw_text.text_style.font_size: 13}} @@ -262,7 +291,7 @@ SolidView{width: Fill height: Fill flow: Overlay draw_bg.color: #xffffff } }} } - composer := SolidView{visible: false width: Fill height: Fill flow: Down new_batch: true draw_bg.color: #xffffff padding: Inset{left: 12 right: 12 top: 8} spacing: 8 + composer := SolidView{visible: false width: Fill max_width: 840 height: Fill flow: Down new_batch: true draw_bg.color: ui_surface padding: Inset{left: 12 right: 12 top: 8} spacing: 8 View{width: Fill height: Fit flow: Right align: Align{y: 0.5} Bar{text: "Cancel" on_click: || back_to_inbox()} View{width: Fill height: 1} @@ -270,7 +299,7 @@ SolidView{width: Fill height: Fill flow: Overlay draw_bg.color: #xffffff } c_to := Field{empty_text: "To"} c_subject := Field{empty_text: "Subject"} - c_body := Field{height: 260 empty_text: "Message"} - c_note := Label{width: Fill text: "" draw_text.color: #xff3b30 draw_text.text_style.font_size: 12} + c_body := Field{is_multiline: true height: Fill empty_text: "Message"} + c_note := Label{width: Fill text: "" draw_text.color: ui_danger draw_text.text_style.font_size: 13} } } diff --git a/apps/maps/bundle/main.splash b/apps/maps/bundle/main.splash index f62aa294..25474a08 100644 --- a/apps/maps/bundle/main.splash +++ b/apps/maps/bundle/main.splash @@ -1,3 +1,40 @@ +// BEGIN shared app interface +// Shared system-app interface. Run tools/sync-app-interface.py after editing. +// Bundles carry this prelude so they work in the contained standalone host too. +// Resolve the current theme after a host restyle, not a retained prelude alias. +let theme = mod.theme +let ui_ink = theme.color_text +let ui_page = theme.color_bg_app +let ui_light = dot(ui_ink, #x36b61200) < 0.5 +let ui_surface = if ui_light { #xffffff } else { mix(ui_page, ui_ink, 0.06) } +let ui_field = mix(ui_page, ui_ink, 0.075) +let ui_hover = mix(ui_page, ui_ink, 0.12) +let ui_border = mix(ui_page, ui_ink, 0.22) +let ui_muted = mix(ui_ink, ui_page, 0.24) +let ui_link = mix(ui_ink, #x356bce, 0.25) +let ui_primary = #x285cd6 +let ui_success = mix(ui_ink, #x28a55b, 0.20) +let ui_danger = mix(ui_ink, #xe13b53, 0.20) +let UiButton = ButtonFlat{height: 44 margin: 0 padding: Inset{left: 14 right: 14} + draw_bg +: {border_radius: 12.0 border_size: 0.0 color: #x00000000 color_hover: ui_hover color_down: ui_field color_focus: ui_hover} + draw_text +: {color: ui_link color_hover: ui_link color_down: ui_link color_focus: ui_link text_style: theme.font_bold{font_size: 14}} +} +let UiPrimary = UiButton{ + draw_bg +: {color: ui_primary color_hover: #x3468df color_down: #x204cb8 color_focus: #x204cb8} + draw_text +: {color: #xffffff color_hover: #xffffff color_down: #xffffff color_focus: #xffffff} +} +let UiPill = UiButton{draw_bg +: {color: ui_surface border_radius: 22.0} draw_text +: {color: ui_ink color_hover: ui_ink color_down: ui_ink color_focus: ui_ink}} +let UiField = TextInput{width: Fill height: 48 margin: 0 padding: Inset{left: 14 right: 14 top: 12 bottom: 12} + draw_bg +: {color: ui_field color_hover: ui_field color_focus: ui_field color_empty: ui_field + border_color: ui_border border_color_hover: ui_link border_color_focus: ui_link border_color_empty: ui_border border_radius: 12.0} + draw_text +: {color: ui_ink color_hover: ui_ink color_focus: ui_ink color_empty: ui_muted color_empty_hover: ui_muted color_empty_focus: ui_muted text_style: theme.font_regular{font_size: 15}} + draw_selection.color: #x557dcc55 +} +let UiCard = RoundedView{width: Fill height: Fit flow: Down spacing: 12 padding: 20 + show_bg: true draw_bg +: {color: ui_surface border_radius: 16.0}} +let UiCaption = Label{padding: 0 flow: Flow.Right{wrap: true} width: Fill draw_text +: {color: ui_muted text_style: theme.font_regular{font_size: 13 line_spacing: 1.4}}} +let UiTitle = Label{padding: 0 flow: Flow.Right{wrap: true} width: Fill draw_text +: {color: ui_ink text_style: theme.font_bold{font_size: 28 line_spacing: 1.2}}} +// END shared app interface let origin = {lat: 37.3350 lon: -121.8850 name: "San Jose (downtown)" picked: false} let q = "" let loaded_q = "" @@ -221,55 +258,46 @@ fn boot(){ start_timeout(0.05, || boot()) start_interval(0.5, || tick()) -let ink = #x1c1c1e -let secondary = #x8e8e93 -let accent = #x1a73e8 +let ink = ui_ink +let secondary = ui_muted +let accent = ui_link -let Pill = ButtonFlat{height: 34 padding: Inset{left: 14 right: 14} - draw_bg +: {border_radius: 17.0 color: #xf1f3f4 color_hover: #xe8eaed color_down: #xdadce0 border_size: 0.0} - draw_text +: {color: ink color_hover: ink color_down: ink text_style +: {font_size: 13}} -} +let Pill = UiPill{} let PillOn = Pill{ - draw_bg +: {color: accent color_hover: accent color_down: #x1557b0} + draw_bg +: {color: ui_primary color_hover: ui_primary color_down: #x204cb8} draw_text +: {color: #xffffff color_hover: #xffffff color_down: #xffffff} } -let Primary = ButtonFlat{height: 44 width: Fill align: Align{x: 0.5 y: 0.5} - draw_bg +: {border_radius: 22.0 color: accent color_hover: #x1765cc color_down: #x1557b0 border_size: 0.0} - draw_text +: {color: #xffffff color_hover: #xffffff color_down: #xffffff text_style: theme.font_bold{font_size: 15}} -} -let Link = ButtonFlat{height: 32 padding: Inset{left: 8 right: 8} - draw_bg +: {color: #x00000000 color_hover: #x0000000a color_down: #x00000014 border_size: 0.0} - draw_text +: {color: accent color_hover: accent color_down: accent text_style +: {font_size: 14}} -} -let Panel = RoundedView{width: Fill height: Fit flow: Down padding: Inset{left: 16 right: 16 top: 14 bottom: 18} spacing: 8 - new_batch: true show_bg: true draw_bg.color: #xffffff draw_bg.border_radius: 18.0 +let Primary = UiPrimary{width: Fill} +let Link = UiButton{} +let Panel = RoundedView{width: Fill max_width: 480 height: Fit flow: Down padding: Inset{left: 16 right: 16 top: 14 bottom: 18} spacing: 8 + new_batch: true show_bg: true draw_bg.color: ui_surface draw_bg.border_radius: 18.0 } -let HitRow = GestureView{width: Fill height: Fit flow: Down padding: Inset{top: 8 bottom: 8} spacing: 2 +let HitRow = GestureView{width: Fill height: Fit flow: Down padding: Inset{top: 12 bottom: 12} spacing: 6 name := Label{width: Fill text: "" max_lines: 1 draw_text.color: ink draw_text.text_style: theme.font_bold{font_size: 14}} - label := Label{width: Fill text: "" max_lines: 1 draw_text.color: secondary draw_text.text_style.font_size: 12} + label := Label{width: Fill text: "" max_lines: 1 draw_text.color: secondary draw_text.text_style.font_size: 13} } View{width: Fill height: Fill flow: Overlay show_bg: true draw_bg.color: #xe8eaed plan_box := View{width: Fill height: Fill - plan_map := MapView{width: Fill height: Fill nav_mode: "plan" nav_route_width: 36.0 zoom: 13.0 min_zoom: 3.0 max_zoom: 18.0 + plan_map := MapView{width: Fill height: Fill dark_theme: !ui_light nav_mode: "plan" nav_route_width: 36.0 zoom: 13.0 min_zoom: 3.0 max_zoom: 18.0 center_lat: 37.3350 center_lon: -121.8850 archive_url: "https://makepad.nl/maps/world-20260926.mkmap" debug_cam: false} } drive_box := View{visible: false width: Fill height: Fill flow: Overlay drive_3d := View{width: Fill height: Fill - drive_map := MapView{width: Fill height: Fill nav_mode: "3d" nav_period: 100 nav_route_width: 14.0 zoom: 15.0 min_zoom: 3.0 max_zoom: 19.0 + drive_map := MapView{width: Fill height: Fill dark_theme: !ui_light nav_mode: "3d" nav_period: 100 nav_route_width: 14.0 zoom: 15.0 min_zoom: 3.0 max_zoom: 19.0 center_lat: 37.3350 center_lon: -121.8850 use_network: true use_local_mbtiles: false debug_cam: false} } drive_2d := View{visible: false width: Fill height: Fill - drive_map_2d := MapView{width: Fill height: Fill nav_mode: "2d" nav_period: 100 nav_route_width: 11.0 zoom: 16.0 min_zoom: 3.0 max_zoom: 19.0 + drive_map_2d := MapView{width: Fill height: Fill dark_theme: !ui_light nav_mode: "2d" nav_period: 100 nav_route_width: 11.0 zoom: 16.0 min_zoom: 3.0 max_zoom: 19.0 center_lat: 37.3350 center_lon: -121.8850 use_network: true use_local_mbtiles: false debug_cam: false} } } View{width: Fill height: Fill flow: Down padding: Inset{left: 10 right: 10 top: 10 bottom: 16} search_panel := Panel{ - search_hint := Label{text: "Where to?" draw_text.color: secondary draw_text.text_style.font_size: 12} - search := TextInput{width: Fill height: 40 empty_text: "Search Maps" + search_hint := Label{text: "Where to?" draw_text.color: secondary draw_text.text_style.font_size: 13} + search := UiField{width: Fill height: 48 empty_text: "Search Maps" on_return: |text| search_for(text) - draw_bg +: {color: #xf1f3f4 color_hover: #xf1f3f4 color_focus: #xf1f3f4 color_empty: #xf1f3f4 + draw_bg +: {color: ui_field color_hover: ui_field color_focus: ui_field color_empty: ui_field border_color: #x00000000 border_color_hover: #x00000000 border_color_focus: #x00000000 border_color_empty: #x00000000 border_radius: 20.0} draw_text +: {color: ink color_hover: ink color_focus: ink color_empty: secondary color_empty_hover: secondary} @@ -277,11 +305,11 @@ View{width: Fill height: Fill flow: Overlay show_bg: true draw_bg.color: #xe8eae results := View{width: Fill height: Fit flow: Down on_render: || { if screen == "origin" { Link{text: "◎ Your location" on_click: || use_my_location()} } if screen == "origin" || screen == "stop" { Link{text: "‹ Back to route" on_click: || { finding = ""; show("route") }} } - if q != "" && loaded_q != q { Label{text: "Searching…" draw_text.color: secondary draw_text.text_style.font_size: 12} } - else if q != "" && hits.len() == 0 { Label{text: "No places found." draw_text.color: secondary draw_text.text_style.font_size: 12} } + if q != "" && loaded_q != q { Label{text: "Searching…" draw_text.color: secondary draw_text.text_style.font_size: 13} } + else if q != "" && hits.len() == 0 { Label{text: "No places found." draw_text.color: secondary draw_text.text_style.font_size: 13} } for h in hits { HitRow{on_tap: |x, y| pick(h) name.text: h.name label.text: h.label} } if q == "" && recents.len() > 0 { - Label{text: "Recent" draw_text.color: secondary draw_text.text_style.font_size: 11} + Label{text: "Recent" draw_text.color: secondary draw_text.text_style.font_size: 13} for r in recents { HitRow{on_tap: |x, y| pick(r) name.text: r.name label.text: r.label} } } }} @@ -292,8 +320,8 @@ View{width: Fill height: Fill flow: Overlay show_bg: true draw_bg.color: #xe8eae pname := Label{width: Fill text: "" draw_text.color: ink draw_text.text_style: theme.font_bold{font_size: 20}} Link{text: "Close" on_click: || show("search")} } - pcat := Label{width: Fill text: "" draw_text.color: #xe37400 draw_text.text_style.font_size: 12} - paddr := Label{width: Fill text: "" draw_text.color: secondary draw_text.text_style.font_size: 12} + pcat := Label{width: Fill text: "" draw_text.color: #xe37400 draw_text.text_style.font_size: 13} + paddr := Label{width: Fill text: "" draw_text.color: secondary draw_text.text_style.font_size: 13} peta := Label{width: Fill text: "" draw_text.color: #x188038 draw_text.text_style.font_size: 13} Primary{text: "Directions" on_click: || show("route")} } @@ -308,14 +336,14 @@ View{width: Fill height: Fill flow: Overlay show_bg: true draw_bg.color: #xe8eae rdist := Label{text: "" draw_text.color: secondary draw_text.text_style.font_size: 13} } View{width: Fill height: Fit flow: Right align: Align{y: 0.5} - Label{text: "From " draw_text.color: secondary draw_text.text_style.font_size: 12} + Label{text: "From " draw_text.color: secondary draw_text.text_style.font_size: 13} 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")} } 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} - Label{text: "Stop " draw_text.color: secondary draw_text.text_style.font_size: 12} + Label{text: "Stop " draw_text.color: secondary draw_text.text_style.font_size: 13} Label{width: Fill text: st.name max_lines: 1 draw_text.color: ink draw_text.text_style.font_size: 14} Link{text: "Remove" on_click: || remove_stop(i)} } @@ -323,7 +351,7 @@ View{width: Fill height: Fill flow: Overlay show_bg: true draw_bg.color: #xe8eae if stops.len() < 2 { Link{text: "+ Add stop" on_click: || add_stop()} } }} View{width: Fill height: Fit flow: Right align: Align{y: 0.5} - Label{text: "To " draw_text.color: secondary draw_text.text_style.font_size: 12} + Label{text: "To " draw_text.color: secondary draw_text.text_style.font_size: 13} rto := Label{width: Fill text: "" max_lines: 1 draw_text.color: ink draw_text.text_style.font_size: 14} } View{width: Fill height: Fit flow: Right spacing: 8 diff --git a/apps/news/bundle/main.splash b/apps/news/bundle/main.splash index fb6cd2e5..59d86d71 100644 --- a/apps/news/bundle/main.splash +++ b/apps/news/bundle/main.splash @@ -1,3 +1,40 @@ +// BEGIN shared app interface +// Shared system-app interface. Run tools/sync-app-interface.py after editing. +// Bundles carry this prelude so they work in the contained standalone host too. +// Resolve the current theme after a host restyle, not a retained prelude alias. +let theme = mod.theme +let ui_ink = theme.color_text +let ui_page = theme.color_bg_app +let ui_light = dot(ui_ink, #x36b61200) < 0.5 +let ui_surface = if ui_light { #xffffff } else { mix(ui_page, ui_ink, 0.06) } +let ui_field = mix(ui_page, ui_ink, 0.075) +let ui_hover = mix(ui_page, ui_ink, 0.12) +let ui_border = mix(ui_page, ui_ink, 0.22) +let ui_muted = mix(ui_ink, ui_page, 0.24) +let ui_link = mix(ui_ink, #x356bce, 0.25) +let ui_primary = #x285cd6 +let ui_success = mix(ui_ink, #x28a55b, 0.20) +let ui_danger = mix(ui_ink, #xe13b53, 0.20) +let UiButton = ButtonFlat{height: 44 margin: 0 padding: Inset{left: 14 right: 14} + draw_bg +: {border_radius: 12.0 border_size: 0.0 color: #x00000000 color_hover: ui_hover color_down: ui_field color_focus: ui_hover} + draw_text +: {color: ui_link color_hover: ui_link color_down: ui_link color_focus: ui_link text_style: theme.font_bold{font_size: 14}} +} +let UiPrimary = UiButton{ + draw_bg +: {color: ui_primary color_hover: #x3468df color_down: #x204cb8 color_focus: #x204cb8} + draw_text +: {color: #xffffff color_hover: #xffffff color_down: #xffffff color_focus: #xffffff} +} +let UiPill = UiButton{draw_bg +: {color: ui_surface border_radius: 22.0} draw_text +: {color: ui_ink color_hover: ui_ink color_down: ui_ink color_focus: ui_ink}} +let UiField = TextInput{width: Fill height: 48 margin: 0 padding: Inset{left: 14 right: 14 top: 12 bottom: 12} + draw_bg +: {color: ui_field color_hover: ui_field color_focus: ui_field color_empty: ui_field + border_color: ui_border border_color_hover: ui_link border_color_focus: ui_link border_color_empty: ui_border border_radius: 12.0} + draw_text +: {color: ui_ink color_hover: ui_ink color_focus: ui_ink color_empty: ui_muted color_empty_hover: ui_muted color_empty_focus: ui_muted text_style: theme.font_regular{font_size: 15}} + draw_selection.color: #x557dcc55 +} +let UiCard = RoundedView{width: Fill height: Fit flow: Down spacing: 12 padding: 20 + show_bg: true draw_bg +: {color: ui_surface border_radius: 16.0}} +let UiCaption = Label{padding: 0 flow: Flow.Right{wrap: true} width: Fill draw_text +: {color: ui_muted text_style: theme.font_regular{font_size: 13 line_spacing: 1.4}}} +let UiTitle = Label{padding: 0 flow: Flow.Right{wrap: true} width: Fill draw_text +: {color: ui_ink text_style: theme.font_bold{font_size: 28 line_spacing: 1.2}}} +// END shared app interface let sources = [ {id: "hn" label: "Hacker News" kind: "hn" url: "https://hn.algolia.com/api/v1/search?tags=front_page&hitsPerPage=30"} {id: "techmeme" label: "TechMeme" kind: "digest" url: "https://www.techmeme.com/feed.xml"} @@ -5,10 +42,10 @@ let sources = [ ] let tabs = [ {id: "today" label: "Today"} + {id: "saved" label: "Saved"} {id: "hn" label: "HN"} {id: "techmeme" label: "TechMeme"} {id: "google" label: "Google"} - {id: "saved" label: "Saved"} ] let rows_by = {hn: [] techmeme: [] google: []} let failed_by = {hn: false techmeme: false google: false} @@ -27,6 +64,8 @@ let reading = nil let use_service = false let service_waiting = 0 let service_relist = false +let service_follow_until = 0 +let service_fetching = false // A run where a source failed (the network not up yet, say) is tried again // on its own: after 15 s, then 30 s, 1 min … at most every 15 min. let retry_delay = 15 @@ -230,8 +269,13 @@ fn refresh_service(due){ busy = true sync_status() list_service() + if !due { service_follow_until = time_now() + 60 } host.request("news.refresh", {due: due}, fn(r){ if !use_service { return } + // The host can already be fetching at app startup. Its busy reply is + // not completion; follow it with due-only requests until it finishes. + // These respect source intervals and never force another network run. + service_fetching = r.is_ok && optional(r.data, "busy", false) && time_now() < service_follow_until if busy { service_relist = true } else { busy = true @@ -312,9 +356,9 @@ fn date_line(){ fn sync_status(){ let text = "" - if busy { text = "Updating…" } + if busy || service_fetching { text = "Updating…" } else if tab != "today" && tab != "saved" && failed_by[tab] && rows_by[tab].len() == 0 { - text = "Couldn't load this source. Pull the refresh button to try again." + text = "Couldn't load this source. Tap Refresh to try again." } else if updated > 0 { text = "Updated " + ago(updated) + " ago" } if text == "Updated now ago" { text = "Updated just now" } @@ -373,8 +417,9 @@ fn close_story(){ } fn empty_text(){ + if busy || service_fetching { return "Loading your headlines…" } + if query != "" { return "No stories match your search." } if tab == "saved" { return "Long-press a story, or tap Save while reading, to keep it here." } - if query != "" { return "No stories match." } "No stories yet." } @@ -389,37 +434,34 @@ fn boot(){ } start_timeout(0.05, || boot()) start_interval(900, || refresh_due()) +start_interval(2, || { + if use_service && service_fetching && !busy { refresh_due() } +}) -let ink = #x1c1c1e -let secondary = #x8e8e93 -let accent = #xe0245e -let ground = #xf2f2f7 +let ink = ui_ink +let secondary = ui_muted +let accent = ui_link +let ground = ui_page // The source tabs sit on the page, which shows the host's light or dark // background: their text, and the open tab's pill, follow the theme. -let TabButton = ButtonFlat{height: 32 padding: Inset{left: 11 right: 11} - draw_bg +: {border_radius: 16.0 color: #x00000000 color_hover: #x0000000a color_down: #x00000014 border_size: 0.0} - draw_text +: {color: theme.color_text color_hover: theme.color_text color_down: theme.color_text text_style +: {font_size: 13}} -} +let TabButton = UiPill{} let TabButtonActive = TabButton{ draw_bg +: {color: theme.color_text color_hover: theme.color_text color_down: theme.color_text} draw_text +: {color: theme.color_bg_app color_hover: theme.color_bg_app color_down: theme.color_bg_app} } -let BarButton = ButtonFlat{height: 36 padding: Inset{left: 10 right: 10} - draw_bg +: {color: #x00000000 color_hover: #x0000000a color_down: #x00000014 border_size: 0.0} - draw_text +: {color: accent color_hover: accent color_down: accent text_style +: {font_size: 15}} -} +let BarButton = UiButton{} -let StoryHead = View{width: Fill height: Fit flow: Down spacing: 4 - meta := Label{width: Fill text: "" max_lines: 1 draw_text.color: secondary draw_text.text_style.font_size: 11} - title := Label{width: Fill text: "" draw_text.color: ink draw_text.text_style: theme.font_bold{font_size: 16}} +let StoryHead = View{width: Fill height: Fit flow: Down spacing: 7 + meta := Label{width: Fill text: "" max_lines: 1 draw_text.color: secondary draw_text.text_style.font_size: 13} + title := Label{width: Fill text: "" max_lines: 3 text_overflow: Ellipsis draw_text.color: ink draw_text.text_style: theme.font_bold{font_size: 16 line_spacing: 1.2}} } let StoryText = StoryHead{ - summary := Label{width: Fill text: "" max_lines: 2 draw_text.color: #x3a3a3c draw_text.text_style.font_size: 13} + summary := Label{width: Fill text: "" max_lines: 2 draw_text.color: ui_muted draw_text.text_style.font_size: 13} } let StoryCard = RoundedView{width: Fill height: Fit flow: Right spacing: 12 align: Align{y: 0.0} - padding: Inset{top: 12 bottom: 12 left: 14 right: 14} - new_batch: true show_bg: true draw_bg.color: #xffffff draw_bg.border_radius: 12.0 + padding: Inset{top: 18 bottom: 18 left: 18 right: 18} + new_batch: true show_bg: true draw_bg.color: ui_surface draw_bg.border_radius: 16.0 } let CardText = StoryCard{text := StoryText{}} let CardHead = StoryCard{text := StoryHead{}} @@ -427,25 +469,25 @@ let CardTextImage = StoryCard{text := StoryText{} thumb := Image{width: 76 heigh let CardHeadImage = StoryCard{text := StoryHead{} thumb := Image{width: 76 height: 76 fit: ImageFit.CropToFill}} HostedView{width: Fill height: Fill - full: View{width: Fill height: Fill flow: Overlay show_bg: true draw_bg.color: ground - main := View{width: Fill height: Fill flow: Down + full: View{width: Fill height: Fill flow: Overlay align: Align{x: 0.5} show_bg: true draw_bg.color: ground + main := View{width: Fill max_width: 960 height: Fill flow: Down View{width: Fill height: Fit flow: Right align: Align{y: 1.0} padding: Inset{left: 18 right: 12 top: 14 bottom: 6} View{width: Fill height: Fit flow: Down spacing: 2 - date := Label{text: "" draw_text.color: secondary draw_text.text_style: theme.font_bold{font_size: 12}} + date := Label{text: "" draw_text.color: secondary draw_text.text_style: theme.font_bold{font_size: 13}} Label{text: "News" draw_text.color: theme.color_text draw_text.text_style: theme.font_bold{font_size: 32}} } BarButton{text: "Refresh" on_click: || refresh()} } View{width: Fill height: Fit padding: Inset{left: 16 right: 16 bottom: 8} - search := TextInput{width: Fill height: 36 empty_text: "Search stories" + search := UiField{width: Fill height: 48 empty_text: "Search stories" on_change: |text| set_query(text) - draw_bg +: {color: #xe5e5ea color_hover: #xe5e5ea color_focus: #xe5e5ea color_empty: #xe5e5ea + draw_bg +: {color: ui_field color_hover: ui_field color_focus: ui_field color_empty: ui_field border_color: #x00000000 border_color_hover: #x00000000 border_color_focus: #x00000000 border_color_empty: #x00000000 border_radius: 10.0} draw_text +: {color: ink color_hover: ink color_focus: ink color_empty: secondary color_empty_hover: secondary} } } - tabbar := ScrollXView{width: Fill height: 44 flow: Right spacing: 4 padding: Inset{left: 12 right: 12 top: 4 bottom: 8} + tabbar := ScrollXView{width: Fill height: 60 flow: Right spacing: 4 padding: Inset{left: 12 right: 12 top: 4 bottom: 8} on_render: || { for t in tabs { if t.id == tab { TabButtonActive{text: t.label on_click: || pick(t.id)} } @@ -453,14 +495,14 @@ HostedView{width: Fill height: Fill } } } - status := Label{width: Fill padding: Inset{left: 18 right: 18 bottom: 6} text: "" draw_text.color: secondary draw_text.text_style.font_size: 11} - list := ScrollYView{width: Fill height: Fill flow: Down spacing: 10 padding: Inset{left: 12 right: 12 bottom: 24} + status := Label{width: Fill padding: Inset{left: 18 right: 18 bottom: 6} text: "Loading your headlines…" draw_text.color: secondary draw_text.text_style.font_size: 13} + list := ScrollYView{width: Fill height: Fill flow: Down spacing: 12 padding: Inset{left: 16 right: 16 bottom: 28} on_render: || { let rows = visible_rows() if rows.len() == 0 { View{width: Fill height: 200 align: Align{x: 0.5 y: 0.5} - Label{text: empty_text() draw_text.color: secondary draw_text.text_style.font_size: 13} - } + Label{width: Fill text: empty_text() draw_text.color: secondary draw_text.text_style.font_size: 13} + }; } else for r in rows { GestureView{width: Fill height: Fit @@ -480,28 +522,29 @@ HostedView{width: Fill height: Fill } } } - reader_pane := View{visible: false width: Fill height: Fill flow: Down new_batch: true show_bg: true draw_bg.color: #xffffff + reader_pane := SolidView{visible: false width: Fill height: Fill flow: Down new_batch: true show_bg: true draw_bg.color: ui_surface View{width: Fill height: 52 flow: Right align: Align{y: 0.5} padding: Inset{left: 4 right: 8} spacing: 4 BarButton{text: "‹ News" on_click: || close_story()} - reader_title := Label{width: Fill text: "" max_lines: 1 align: Align{x: 0.5} + View{width: Fill height: 1} + reader_title := Label{visible: false width: Fill text: "" max_lines: 1 align: Align{x: 0.5} draw_text.color: ink draw_text.text_style: theme.font_bold{font_size: 14}} comments := BarButton{text: "Comments" on_click: || open_comments()} save := BarButton{text: "Save" on_click: || { if reading != nil { toggle_save(reading) } }} } - reader_fail := Label{width: Fill padding: Inset{left: 18 right: 18} text: "" draw_text.color: secondary draw_text.text_style.font_size: 12} + reader_fail := Label{width: Fill padding: Inset{left: 18 right: 18} text: "" draw_text.color: secondary draw_text.text_style.font_size: 13} reader := WebReader{width: Fill height: Fill on_error: || ui.reader_fail.set_text(ui.reader.error())} } } tile: View{width: Fill height: Fill flow: Down padding: Inset{left: 14 right: 14 top: 12 bottom: 12} spacing: 8 - new_batch: true show_bg: true draw_bg.color: #xffffff + new_batch: true show_bg: true draw_bg.color: ui_surface Label{text: "News" draw_text.color: accent draw_text.text_style: theme.font_bold{font_size: 13}} tile_list := View{width: Fill height: Fill flow: Down spacing: 8 on_render: || { let rows = today_rows() - if rows.len() == 0 { Label{text: "Headlines appear here." draw_text.color: secondary draw_text.text_style.font_size: 12} } + if rows.len() == 0 { Label{text: "Headlines appear here." draw_text.color: secondary draw_text.text_style.font_size: 13} } else for i r in rows { if i < 3 { View{width: Fill height: Fit flow: Down spacing: 1 - Label{width: Fill text: r.source max_lines: 1 draw_text.color: secondary draw_text.text_style.font_size: 10} + Label{width: Fill text: r.source max_lines: 1 draw_text.color: secondary draw_text.text_style.font_size: 13} Label{width: Fill text: r.title max_lines: 2 draw_text.color: ink draw_text.text_style: theme.font_bold{font_size: 13}} } } diff --git a/apps/news/host-service/tests/script.rs b/apps/news/host-service/tests/script.rs index 83b76f52..fa37dec6 100644 --- a/apps/news/host-service/tests/script.rs +++ b/apps/news/host-service/tests/script.rs @@ -105,11 +105,51 @@ fn a_failed_run_is_retried_with_a_growing_wait() { /// its first call at the top level, `start_timeout(…)`). #[test] fn the_scripts_functions_parse() { - let code = SCRIPT.split_once("\nstart_timeout(").expect("the boot call").0; + let code = SCRIPT.split_once("// END shared app interface\n").expect("shared interface").1 + .split_once("\nstart_timeout(").expect("the boot call").0; let (_value, errors, _vm) = run(&format!("{code}\nnil")); assert!(errors.is_empty(), "{errors:?}"); } +#[test] +fn opening_during_the_hosts_fetch_follows_completion_without_forcing_a_second_fetch() { + let setup = r#" +let clock = 100 +fn time_now(){ clock } +let busy = false +let use_service = true +let service_relist = false +let service_follow_until = 0 +let service_fetching = false +let requested_due = false +let reply = {is_ok: true data: {busy: true}} +fn sync_status(){} +fn list_service(){} +let host = {request: |method, args, callback| {requested_due = args.due; callback(reply)}} +"#; + for (reply, elapsed, following) in [ + ("{is_ok: true data: {busy: true}}", 2, true), + ("{is_ok: true data: {total: 30}}", 2, false), + ("{is_ok: false}", 2, false), + ("{is_ok: true data: {busy: true}}", 61, false), + ] { + let code = format!(r#"{setup} +{} +{} +refresh_service(false) +let initially_following = service_fetching && service_follow_until == 160 +clock = 100 + {elapsed} +reply = {reply} +busy = false +refresh_service(true) +initially_following && requested_due && service_follow_until == 160 && service_fetching == {following} +"#, function("optional"), function("refresh_service")); + let (value, errors, _vm) = run(&code); + assert!(errors.is_empty(), "{reply}: {errors:?}"); + assert_eq!(value.as_bool(), Some(true), "{reply}, elapsed={elapsed}"); + } +} + #[test] fn the_title_follows_the_theme() { // Dark hosts draw News on a dark ground: a fixed dark ink disappeared. diff --git a/apps/photos/bundle/main.splash b/apps/photos/bundle/main.splash index a6214654..c6bbda45 100644 --- a/apps/photos/bundle/main.splash +++ b/apps/photos/bundle/main.splash @@ -1,3 +1,40 @@ +// BEGIN shared app interface +// Shared system-app interface. Run tools/sync-app-interface.py after editing. +// Bundles carry this prelude so they work in the contained standalone host too. +// Resolve the current theme after a host restyle, not a retained prelude alias. +let theme = mod.theme +let ui_ink = theme.color_text +let ui_page = theme.color_bg_app +let ui_light = dot(ui_ink, #x36b61200) < 0.5 +let ui_surface = if ui_light { #xffffff } else { mix(ui_page, ui_ink, 0.06) } +let ui_field = mix(ui_page, ui_ink, 0.075) +let ui_hover = mix(ui_page, ui_ink, 0.12) +let ui_border = mix(ui_page, ui_ink, 0.22) +let ui_muted = mix(ui_ink, ui_page, 0.24) +let ui_link = mix(ui_ink, #x356bce, 0.25) +let ui_primary = #x285cd6 +let ui_success = mix(ui_ink, #x28a55b, 0.20) +let ui_danger = mix(ui_ink, #xe13b53, 0.20) +let UiButton = ButtonFlat{height: 44 margin: 0 padding: Inset{left: 14 right: 14} + draw_bg +: {border_radius: 12.0 border_size: 0.0 color: #x00000000 color_hover: ui_hover color_down: ui_field color_focus: ui_hover} + draw_text +: {color: ui_link color_hover: ui_link color_down: ui_link color_focus: ui_link text_style: theme.font_bold{font_size: 14}} +} +let UiPrimary = UiButton{ + draw_bg +: {color: ui_primary color_hover: #x3468df color_down: #x204cb8 color_focus: #x204cb8} + draw_text +: {color: #xffffff color_hover: #xffffff color_down: #xffffff color_focus: #xffffff} +} +let UiPill = UiButton{draw_bg +: {color: ui_surface border_radius: 22.0} draw_text +: {color: ui_ink color_hover: ui_ink color_down: ui_ink color_focus: ui_ink}} +let UiField = TextInput{width: Fill height: 48 margin: 0 padding: Inset{left: 14 right: 14 top: 12 bottom: 12} + draw_bg +: {color: ui_field color_hover: ui_field color_focus: ui_field color_empty: ui_field + border_color: ui_border border_color_hover: ui_link border_color_focus: ui_link border_color_empty: ui_border border_radius: 12.0} + draw_text +: {color: ui_ink color_hover: ui_ink color_focus: ui_ink color_empty: ui_muted color_empty_hover: ui_muted color_empty_focus: ui_muted text_style: theme.font_regular{font_size: 15}} + draw_selection.color: #x557dcc55 +} +let UiCard = RoundedView{width: Fill height: Fit flow: Down spacing: 12 padding: 20 + show_bg: true draw_bg +: {color: ui_surface border_radius: 16.0}} +let UiCaption = Label{padding: 0 flow: Flow.Right{wrap: true} width: Fill draw_text +: {color: ui_muted text_style: theme.font_regular{font_size: 13 line_spacing: 1.4}}} +let UiTitle = Label{padding: 0 flow: Flow.Right{wrap: true} width: Fill draw_text +: {color: ui_ink text_style: theme.font_bold{font_size: 28 line_spacing: 1.2}}} +// END shared app interface let catalog = [ {id: "stock-aquarium-fish" file: "stock-aquarium-fish.jpg" title: "An underwater world" date: "2026-09-17" location: "Aquarium" people: [] tags: ["animals" "fish" "aquarium" "underwater"] moment: "September snapshots"} {id: "family" file: "family.png" title: "All together" date: "2026-09-15" location: "At home" people: ["Alex" "Sofia" "James" "Rose" "Noah" "Lily"] tags: ["family" "together"] moment: "The people we love"} @@ -692,26 +729,24 @@ start_timeout(0.05, || boot()) // Text on the page uses theme.color_text: the page shows the host's light or // dark background. ink is only for text on the light surfaces this script // paints itself (the tab bar and the text fields). -let ink = #x1c1c1e -let secondary = #x8e8e93 -let accent = #x007aff -let ground = #xffffff +let ink = ui_ink +let secondary = ui_muted +let accent = ui_link +let ground = ui_page -let Flat = ButtonFlat{height: 36 padding: Inset{left: 10 right: 10} - draw_bg +: {color: #x00000000 color_hover: #x0000000a color_down: #x00000014 border_size: 0.0} - draw_text +: {color: accent color_hover: accent color_down: accent text_style +: {font_size: 15}} -} -let Segment = ButtonFlat{height: 36 width: 108 padding: Inset{left: 8 right: 8} align: Align{x: 0.5 y: 0.5} - draw_bg +: {border_radius: 17.0 color: #x00000000 color_hover: #x0000000a color_down: #x00000014 border_size: 0.0} - draw_text +: {color: ink color_hover: ink color_down: ink text_style +: {font_size: 14}} +let Flat = UiButton{} +let MediaButton = UiButton{ + draw_text +: {color: #xffffff color_hover: #xffffff color_down: #xffffff color_focus: #xffffff} + draw_bg +: {color_hover: #xffffff22 color_down: #xffffff33 color_focus: #xffffff22} } +let Segment = UiPill{width: Fill padding: 6 draw_bg +: {color: #x00000000 color_hover: ui_hover color_down: ui_hover color_focus: ui_hover}} let SegmentOn = Segment{ - draw_bg +: {color: #xffffff color_hover: #xffffff color_down: #xf2f2f7} + draw_bg +: {color: ui_surface color_hover: ui_surface color_down: ui_hover} draw_text +: {color: accent color_hover: accent color_down: accent} } -let Heading = View{width: Fill height: Fit flow: Right align: Align{y: 1.0} padding: Inset{top: 18 bottom: 8 left: 4 right: 4} - name := Label{width: Fill text: "" draw_text.color: theme.color_text draw_text.text_style: theme.font_bold{font_size: 20}} - detail := Label{text: "" draw_text.color: secondary draw_text.text_style.font_size: 12} +let Heading = View{width: Fill height: Fit flow: Right align: Align{y: 1.0} padding: Inset{top: 20 bottom: 8} + name := Label{width: Fill padding: 0 text: "" draw_text.color: theme.color_text draw_text.text_style: theme.font_bold{font_size: 20}} + detail := Label{padding: 0 text: "" draw_text.color: secondary draw_text.text_style.font_size: 13} } let Cell = GestureView{width: Fill height: Fit pic := Image{width: Fill height: 132 fit: ImageFit.CropToFill} @@ -723,47 +758,48 @@ let SelectCell = GestureView{width: Fill height: Fit flow: Overlay let AlbumCard = GestureView{width: Fill height: Fit flow: Down spacing: 6 cover := Image{width: Fill height: 164 fit: ImageFit.CropToFill} name := Label{text: "" draw_text.color: theme.color_text draw_text.text_style: theme.font_bold{font_size: 14}} - count := Label{text: "" draw_text.color: secondary draw_text.text_style.font_size: 12} + count := Label{text: "" draw_text.color: secondary draw_text.text_style.font_size: 13} } let PersonChip = GestureView{width: 84 height: Fit flow: Down spacing: 6 align: Align{x: 0.5} face := Image{width: 72 height: 72 fit: ImageFit.CropToFill} - name := Label{text: "" draw_text.color: theme.color_text draw_text.text_style.font_size: 12} + name := Label{text: "" draw_text.color: theme.color_text draw_text.text_style.font_size: 13} } let MemoryCard = GestureView{width: Fill height: 240 flow: Overlay cover := Image{width: Fill height: 240 fit: ImageFit.CropToFill} - caption := View{width: Fill height: Fill flow: Down align: Align{y: 1.0} padding: 16 spacing: 4 show_bg: true draw_bg.color: #x00000066 - kicker := Label{text: "MEMORY" draw_text.color: #xffffffcc draw_text.text_style.font_size: 11} + caption := SolidView{width: Fill height: Fill flow: Down align: Align{y: 1.0} padding: 20 spacing: 6 + draw_bg +: {pixel: fn(){ return vec4(0.0, 0.0, 0.0, 0.22 + 0.66 * self.pos.y) }} + kicker := Label{text: "MEMORY" draw_text.color: #xffffffcc draw_text.text_style.font_size: 13} name := Label{width: Fill text: "" draw_text.color: #xffffff draw_text.text_style: theme.font_bold{font_size: 24}} - detail := Label{text: "" draw_text.color: #xffffffdd draw_text.text_style.font_size: 12} + detail := Label{text: "" draw_text.color: #xffffffdd draw_text.text_style.font_size: 13} } } HostedView{width: Fill height: Fill - full: View{width: Fill height: Fill flow: Overlay show_bg: true draw_bg.color: ground - main := View{width: Fill height: Fill flow: Down - View{width: Fill height: Fit flow: Right align: Align{y: 0.5} padding: Inset{left: 8 right: 8 top: 10} + full: View{width: Fill height: Fill flow: Overlay align: Align{x: 0.5} show_bg: true draw_bg.color: ground + main := View{width: Fill max_width: 960 height: Fill flow: Down + View{width: Fill height: Fit flow: Right align: Align{y: 0.5} padding: Inset{left: 16 right: 16 top: 8} back := View{width: Fit height: Fit Flat{text: "‹ Back" on_click: || go_back_fn()}} View{width: Fill height: 1} create := View{width: Fit height: Fit Flat{text: "+ Album" on_click: || new_album()}} edit := View{width: Fit height: Fit Flat{text: "Edit" on_click: || edit_album()}} } - View{width: Fill height: Fit flow: Down padding: Inset{left: 16 right: 16 bottom: 6} spacing: 2 - title := Label{width: Fill text: "" draw_text.color: theme.color_text draw_text.text_style: theme.font_bold{font_size: 30}} - subtitle := Label{width: Fill text: "" draw_text.color: secondary draw_text.text_style.font_size: 13} - message := Label{width: Fill text: "" draw_text.color: #xff3b30 draw_text.text_style.font_size: 12} + View{width: Fill height: Fit flow: Down padding: Inset{left: 20 right: 20 top: 4 bottom: 16} spacing: 8 + title := Label{width: Fill padding: 0 text: "" draw_text.color: theme.color_text draw_text.text_style: theme.font_bold{font_size: 30}} + subtitle := Label{width: Fill padding: 0 text: "" draw_text.color: secondary draw_text.text_style.font_size: 13} + message := Label{width: Fill text: "" draw_text.color: #xff3b30 draw_text.text_style.font_size: 13} } - searchbar := View{width: Fill height: Fit padding: Inset{left: 16 right: 16 bottom: 6} - TextInput{width: Fill height: 36 empty_text: "Search photos, people, places" + searchbar := View{width: Fill height: Fit padding: Inset{left: 20 right: 20 bottom: 12} + TextInput{width: Fill height: 48 empty_text: "Search photos, people, places" on_change: |text| set_query(text) - draw_bg +: {color: #xefeff4 color_hover: #xefeff4 color_focus: #xefeff4 color_empty: #xefeff4 + draw_bg +: {color: ui_field color_hover: ui_field color_focus: ui_field color_empty: ui_field border_color: #x00000000 border_color_hover: #x00000000 border_color_focus: #x00000000 border_color_empty: #x00000000 border_radius: 10.0} draw_text +: {color: ink color_hover: ink color_focus: ink color_empty: secondary color_empty_hover: secondary} } } - editor_bar := View{width: Fill height: Fit flow: Right spacing: 8 padding: Inset{left: 16 right: 12 bottom: 6} align: Align{y: 0.5} - album_name := TextInput{width: Fill height: 36 empty_text: "Album name" - draw_bg +: {color: #xefeff4 color_hover: #xefeff4 color_focus: #xefeff4 color_empty: #xefeff4 + editor_bar := View{width: Fill height: Fit flow: Right spacing: 8 padding: Inset{left: 20 right: 20 bottom: 12} align: Align{y: 0.5} + album_name := UiField{width: Fill height: 48 empty_text: "Album name" + draw_bg +: {color: ui_field color_hover: ui_field color_focus: ui_field color_empty: ui_field border_color: #x00000000 border_color_hover: #x00000000 border_color_focus: #x00000000 border_color_empty: #x00000000 border_radius: 10.0} draw_text +: {color: ink color_hover: ink color_focus: ink color_empty: secondary color_empty_hover: secondary} @@ -772,12 +808,12 @@ HostedView{width: Fill height: Fill Flat{text: "Delete" on_click: || delete_album()} } zoom := GestureView{width: Fill height: Fill on_pinch: |scale, x, y, phase| pinch_grid(scale, phase) - list := ScrollYView{width: Fill height: Fill flow: Down spacing: 4 padding: Inset{left: 8 right: 8 bottom: 90} + list := ScrollYView{width: Fill height: Fill flow: Down spacing: 8 padding: Inset{left: 20 right: 20 bottom: 24} on_render: || { if route == "collections" { if memories.len() > 0 { - View{width: Fill height: Fit flow: Right align: Align{y: 0.5} padding: Inset{top: 12 left: 4} - Label{width: Fill text: "Memories" draw_text.color: theme.color_text draw_text.text_style: theme.font_bold{font_size: 20}} + View{width: Fill height: Fit flow: Right align: Align{y: 0.5} padding: Inset{top: 8 bottom: 4} + Label{width: Fill padding: 0 text: "Memories" draw_text.color: theme.color_text draw_text.text_style: theme.font_bold{font_size: 20}} Flat{text: "See all" on_click: || go_to("memories", "")} } let m = memories[0] @@ -815,12 +851,12 @@ HostedView{width: Fill height: Fill } } else if route == "memories" { - View{width: Fill height: Fit flow: Down padding: Inset{left: 8 right: 8 top: 8 bottom: 12} spacing: 8 + View{width: Fill height: Fit flow: Down padding: Inset{top: 8 bottom: 12} spacing: 8 Label{width: Fill text: "Create a memory" draw_text.color: theme.color_text draw_text.text_style: theme.font_bold{font_size: 20}} Label{width: Fill text: "Choose a theme, or let AI find a story." draw_text.color: secondary draw_text.text_style.font_size: 13} - TextInput{width: Fill height: 40 text: memory_prompt empty_text: "Summer with family (optional)" + TextInput{width: Fill height: 48 text: memory_prompt empty_text: "Summer with family (optional)" on_change: |text| { memory_prompt = "" + text } - draw_bg +: {color: #xefeff4 color_hover: #xefeff4 color_focus: #xefeff4 color_empty: #xefeff4 border_radius: 10.0 + draw_bg +: {color: ui_field color_hover: ui_field color_focus: ui_field color_empty: ui_field border_radius: 10.0 border_color: #x00000000 border_color_hover: #x00000000 border_color_focus: #x00000000 border_color_empty: #x00000000} draw_text +: {color: ink color_hover: ink color_focus: ink color_empty: secondary color_empty_hover: secondary} } @@ -830,7 +866,7 @@ HostedView{width: Fill height: Fill Flat{text: "Stop waiting" on_click: || cancel_memories()} } else { Flat{text: "Create memories" on_click: || generate_memories()} } } - Label{width: Fill text: "Uses photo details with your configured AI provider." draw_text.color: secondary draw_text.text_style.font_size: 11} + Label{width: Fill text: "Uses photo details with your configured AI provider." draw_text.color: secondary draw_text.text_style.font_size: 13} if memory_status != "" { Label{width: Fill text: memory_status draw_text.color: theme.color_text draw_text.text_style.font_size: 13} } } for index m in memories { @@ -844,7 +880,7 @@ HostedView{width: Fill height: Fill } else { if route == "memory" { - View{width: Fill height: Fit flow: Down spacing: 10 padding: Inset{left: 8 right: 8 top: 8 bottom: 14} + View{width: Fill height: Fit flow: Down spacing: 10 padding: Inset{top: 8 bottom: 16} Label{width: Fill text: route_arg.summary draw_text.color: theme.color_text draw_text.text_style.font_size: 15} Flat{text: "Play memory" on_click: || play_story(route_arg)} } @@ -875,10 +911,10 @@ HostedView{width: Fill height: Fill } } } - tabs := View{width: Fill height: Fit align: Align{x: 0.5} padding: Inset{bottom: 14 top: 6} + tabs := View{width: Fill height: Fit align: Align{x: 0.5} padding: Inset{left: 12 right: 12 bottom: 14 top: 6} on_render: || { - RoundedView{width: Fit height: Fit flow: Right padding: 3 spacing: 2 new_batch: true show_bg: true - draw_bg.color: #xe5e5ea draw_bg.border_radius: 20.0 + RoundedView{width: Fill max_width: 420 height: Fit flow: Right padding: 3 spacing: 2 new_batch: true show_bg: true + draw_bg.color: ui_field draw_bg.border_radius: 20.0 if route == "library" { SegmentOn{text: "Library"} } else { Segment{text: "Library" on_click: || go_to("library", "")} } if route == "collections" { SegmentOn{text: "Collections"} } else { Segment{text: "Collections" on_click: || go_to("collections", "")} } if route == "memories" { SegmentOn{text: "Memories"} } else { Segment{text: "Memories" on_click: || go_to("memories", "")} } @@ -888,9 +924,9 @@ HostedView{width: Fill height: Fill } viewer := SolidView{visible: false width: Fill height: Fill flow: Down new_batch: true draw_bg.color: #000000 View{width: Fill height: Fit flow: Right align: Align{y: 0.5} padding: Inset{left: 4 right: 8 top: 8} - Flat{text: "‹ Done" on_click: || close_viewer()} + MediaButton{text: "‹ Done" on_click: || close_viewer()} viewer_count := Label{width: Fill text: "" align: Align{x: 0.5} draw_text.color: #xffffffaa draw_text.text_style.font_size: 13} - favorite := Flat{text: "♡" draw_text +: {text_style +: {font_size: 22}} on_click: || toggle_favorite()} + favorite := MediaButton{text: "♡" draw_text +: {text_style +: {font_size: 22}} on_click: || toggle_favorite()} } GestureView{width: Fill height: Fill on_swipe: |dx, dy| { if dx < 0 { stop_slides(); step_photo(1) } else if dx > 0 { stop_slides(); step_photo(-1) } else if dy > 0 { close_viewer() } } @@ -905,17 +941,17 @@ HostedView{width: Fill height: Fill }} } View{width: Fill height: Fit flow: Down padding: Inset{left: 18 right: 18 bottom: 24 top: 8} spacing: 3 - viewer_title := Label{width: Fill text: "" draw_text.color: #xffffff draw_text.text_style: theme.font_bold{font_size: 17}} - viewer_meta := Label{width: Fill text: "" draw_text.color: #xffffffaa draw_text.text_style.font_size: 12} + viewer_title := Label{width: Fill padding: 0 text: "" draw_text.color: #xffffff draw_text.text_style: theme.font_bold{font_size: 17}} + viewer_meta := Label{width: Fill text: "" draw_text.color: #xffffffaa draw_text.text_style.font_size: 13} View{width: Fill height: Fit flow: Right align: Align{x: 0.5} spacing: 16 - Flat{text: "Previous" on_click: || { stop_slides(); step_photo(-1) }} - playback := Flat{text: "Play" on_click: || toggle_slides()} - Flat{text: "Next" on_click: || { stop_slides(); step_photo(1) }} + MediaButton{text: "Previous" on_click: || { stop_slides(); step_photo(-1) }} + playback := MediaButton{text: "Play" on_click: || toggle_slides()} + MediaButton{text: "Next" on_click: || { stop_slides(); step_photo(1) }} } } } } - tile: View{width: Fill height: Fill flow: Overlay new_batch: true show_bg: true draw_bg.color: #xffffff + tile: View{width: Fill height: Fill flow: Overlay new_batch: true show_bg: true draw_bg.color: ui_surface tile_grid := View{width: Fill height: Fill flow: Down spacing: 3 on_render: || { for row in grid_rows_of(tile_ids(tile_rows), 3) { View{width: Fill height: Fill flow: Right spacing: 3 diff --git a/apps/youtube/bundle/main.splash b/apps/youtube/bundle/main.splash index da63407e..98fdd417 100644 --- a/apps/youtube/bundle/main.splash +++ b/apps/youtube/bundle/main.splash @@ -1,3 +1,40 @@ +// BEGIN shared app interface +// Shared system-app interface. Run tools/sync-app-interface.py after editing. +// Bundles carry this prelude so they work in the contained standalone host too. +// Resolve the current theme after a host restyle, not a retained prelude alias. +let theme = mod.theme +let ui_ink = theme.color_text +let ui_page = theme.color_bg_app +let ui_light = dot(ui_ink, #x36b61200) < 0.5 +let ui_surface = if ui_light { #xffffff } else { mix(ui_page, ui_ink, 0.06) } +let ui_field = mix(ui_page, ui_ink, 0.075) +let ui_hover = mix(ui_page, ui_ink, 0.12) +let ui_border = mix(ui_page, ui_ink, 0.22) +let ui_muted = mix(ui_ink, ui_page, 0.24) +let ui_link = mix(ui_ink, #x356bce, 0.25) +let ui_primary = #x285cd6 +let ui_success = mix(ui_ink, #x28a55b, 0.20) +let ui_danger = mix(ui_ink, #xe13b53, 0.20) +let UiButton = ButtonFlat{height: 44 margin: 0 padding: Inset{left: 14 right: 14} + draw_bg +: {border_radius: 12.0 border_size: 0.0 color: #x00000000 color_hover: ui_hover color_down: ui_field color_focus: ui_hover} + draw_text +: {color: ui_link color_hover: ui_link color_down: ui_link color_focus: ui_link text_style: theme.font_bold{font_size: 14}} +} +let UiPrimary = UiButton{ + draw_bg +: {color: ui_primary color_hover: #x3468df color_down: #x204cb8 color_focus: #x204cb8} + draw_text +: {color: #xffffff color_hover: #xffffff color_down: #xffffff color_focus: #xffffff} +} +let UiPill = UiButton{draw_bg +: {color: ui_surface border_radius: 22.0} draw_text +: {color: ui_ink color_hover: ui_ink color_down: ui_ink color_focus: ui_ink}} +let UiField = TextInput{width: Fill height: 48 margin: 0 padding: Inset{left: 14 right: 14 top: 12 bottom: 12} + draw_bg +: {color: ui_field color_hover: ui_field color_focus: ui_field color_empty: ui_field + border_color: ui_border border_color_hover: ui_link border_color_focus: ui_link border_color_empty: ui_border border_radius: 12.0} + draw_text +: {color: ui_ink color_hover: ui_ink color_focus: ui_ink color_empty: ui_muted color_empty_hover: ui_muted color_empty_focus: ui_muted text_style: theme.font_regular{font_size: 15}} + draw_selection.color: #x557dcc55 +} +let UiCard = RoundedView{width: Fill height: Fit flow: Down spacing: 12 padding: 20 + show_bg: true draw_bg +: {color: ui_surface border_radius: 16.0}} +let UiCaption = Label{padding: 0 flow: Flow.Right{wrap: true} width: Fill draw_text +: {color: ui_muted text_style: theme.font_regular{font_size: 13 line_spacing: 1.4}}} +let UiTitle = Label{padding: 0 flow: Flow.Right{wrap: true} width: Fill draw_text +: {color: ui_ink text_style: theme.font_bold{font_size: 28 line_spacing: 1.2}}} +// END shared app interface // YouTube: search YouTube, list what exists right now, play a tap in the // system web view. Results come from the runtime's keyless `sys.video` // helper, which reads YouTube's own results page (public Piped/Invidious @@ -153,58 +190,52 @@ fn boot(){ start_timeout(0.05, || boot()) start_interval(0.5, || read_hits()) -let ground = #x0f0f0f -let ink = #xf1f1f1 -let secondary = #xaaaaaa -let raised = #x272727 +let ground = ui_page +let ink = ui_ink +let secondary = ui_muted +let raised = ui_surface let red = #xff0033 -let Chip = ButtonFlat{height: 32 padding: Inset{left: 12 right: 12} - draw_bg +: {border_radius: 8.0 color: raised color_hover: #x3f3f3f color_down: #x505050 border_size: 0.0} - draw_text +: {color: ink color_hover: ink color_down: ink text_style +: {font_size: 13}} -} +let Chip = UiPill{} let ChipOn = Chip{ draw_bg +: {color: ink color_hover: ink color_down: #xd9d9d9} draw_text +: {color: ground color_hover: ground color_down: ground} } -let BarButton = ButtonFlat{height: 40 padding: Inset{left: 10 right: 10} - draw_bg +: {color: #x00000000 color_hover: #xffffff14 color_down: #xffffff24 border_size: 0.0} - draw_text +: {color: ink color_hover: ink color_down: ink text_style +: {font_size: 15}} -} +let BarButton = UiButton{} let VideoRow = GestureView{width: Fill height: Fit flow: Right spacing: 12 padding: Inset{left: 12 right: 12 top: 6 bottom: 6} - frame := View{width: 168 height: 94 flow: Overlay align: Align{x: 1.0 y: 1.0} - thumb := Image{width: 168 height: 94 fit: ImageFit.CropToFill} + frame := View{width: 132 height: 88 flow: Overlay align: Align{x: 1.0 y: 1.0} + thumb := Image{width: 132 height: 88 fit: ImageFit.CropToFill} corner := View{width: Fit height: Fit padding: Inset{right: 4 bottom: 4} badge := RoundedView{width: Fit height: Fit padding: Inset{left: 4 right: 4 top: 1 bottom: 1} new_batch: true show_bg: true draw_bg.color: #x000000cc draw_bg.border_radius: 4.0 - length := Label{text: "" draw_text.color: #xffffff draw_text.text_style: theme.font_bold{font_size: 11}} + length := Label{text: "" draw_text.color: #xffffff draw_text.text_style: theme.font_bold{font_size: 13}} } } } text := View{width: Fill height: Fit flow: Down spacing: 4 title := Label{width: Fill text: "" max_lines: 2 draw_text.color: ink draw_text.text_style: theme.font_bold{font_size: 14}} - meta := Label{width: Fill text: "" max_lines: 2 draw_text.color: secondary draw_text.text_style.font_size: 12} + meta := Label{width: Fill text: "" max_lines: 2 draw_text.color: secondary draw_text.text_style.font_size: 13} } } -SolidView{width: Fill height: Fill flow: Overlay show_bg: true draw_bg.color: ground - main := SolidView{width: Fill height: Fill flow: Down new_batch: true show_bg: true draw_bg.color: ground +SolidView{width: Fill height: Fill flow: Overlay align: Align{x: 0.5} show_bg: true draw_bg.color: ground + main := SolidView{width: Fill max_width: 960 height: Fill flow: Down new_batch: true show_bg: true draw_bg.color: ground View{width: Fill height: Fit flow: Right align: Align{y: 0.5} spacing: 8 padding: Inset{left: 14 right: 12 top: 12 bottom: 8} RoundedView{width: 30 height: 21 align: Align{x: 0.5 y: 0.5} new_batch: true show_bg: true draw_bg.color: red draw_bg.border_radius: 6.0 - Label{text: "▶" draw_text.color: #xffffff draw_text.text_style.font_size: 10} + Label{text: "▶" draw_text.color: #xffffff draw_text.text_style.font_size: 13} } Label{text: "YouTube" draw_text.color: ink draw_text.text_style: theme.font_bold{font_size: 19}} } View{width: Fill height: Fit padding: Inset{left: 12 right: 12 bottom: 8} - search := TextInput{width: Fill height: 40 empty_text: "Search YouTube" + 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: #x303030 border_color_hover: #x303030 border_color_focus: #x3ea6ff border_color_empty: #x303030 + 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} } } - tabs := ScrollXView{width: Fill height: 44 flow: Right spacing: 8 padding: Inset{left: 12 right: 12 top: 2 bottom: 10} + tabs := ScrollXView{width: Fill height: 60 flow: Right spacing: 8 padding: Inset{left: 12 right: 12 top: 2 bottom: 10} on_render: || { if screen == "history" { ChipOn{text: "History" on_click: || show("history")} } else { Chip{text: "History" on_click: || show("history")} } @@ -214,7 +245,7 @@ SolidView{width: Fill height: Fill flow: Overlay show_bg: true draw_bg.color: gr } } } - status := Label{width: Fill padding: Inset{left: 14 right: 14 bottom: 6} text: "" draw_text.color: secondary draw_text.text_style.font_size: 12} + status := Label{width: Fill padding: Inset{left: 14 right: 14 bottom: 6} text: "" draw_text.color: secondary draw_text.text_style.font_size: 13} list := ScrollYView{width: Fill height: Fill flow: Down spacing: 4 padding: Inset{bottom: 24} on_render: || { if screen == "history" { @@ -244,10 +275,10 @@ SolidView{width: Fill height: Fill flow: Overlay show_bg: true draw_bg.color: gr BarButton{text: "‹ Back" on_click: || close_player()} View{width: Fill height: Fit flow: Down spacing: 2 player_title := Label{width: Fill text: "" max_lines: 1 draw_text.color: ink draw_text.text_style: theme.font_bold{font_size: 14}} - player_meta := Label{width: Fill text: "" max_lines: 1 draw_text.color: secondary draw_text.text_style.font_size: 11} + player_meta := Label{width: Fill text: "" max_lines: 1 draw_text.color: secondary draw_text.text_style.font_size: 13} } } - player_fail := Label{width: Fill padding: Inset{left: 14 right: 14} text: "" draw_text.color: secondary draw_text.text_style.font_size: 12} + player_fail := Label{width: Fill padding: Inset{left: 14 right: 14} text: "" draw_text.color: secondary draw_text.text_style.font_size: 13} reader := WebReader{width: Fill height: Fill on_error: || ui.player_fail.set_text(ui.reader.error())} } } diff --git a/crates/shell/src/card_chat.rs b/crates/shell/src/card_chat.rs index 98c5bd8d..87431d41 100644 --- a/crates/shell/src/card_chat.rs +++ b/crates/shell/src/card_chat.rs @@ -11,16 +11,16 @@ script_mod! { width: Fill height: Fill flow: Down drag_scrolling: true auto_tail: true Message := View { width: Fill height: Fit flow: Down spacing: 6 - padding: Inset{left: 20 right: 20 top: 10 bottom: 14} + padding: Inset{left: 24 right: 24 top: 14 bottom: 18} speaker := Label { width: Fill height: Fit - draw_text.text_style: theme.font_bold{font_size: 10.0} + draw_text.text_style: theme.font_bold{font_size: 12.0} draw_text.color: theme.color_text_placeholder } body := Label { width: Fill height: Fit draw_text.wrap: Words - draw_text.text_style: theme.font_regular{font_size: 13.0 line_spacing: 1.35} + draw_text.text_style: theme.font_regular{font_size: 15.0 line_spacing: 1.35} draw_text.color: theme.color_text } } @@ -29,8 +29,8 @@ script_mod! { margin: Inset{left: 44 right: 18 top: 8 bottom: 14} padding: 14 draw_bg +: {color: #3668e81c border_radius: 16} - speaker := Label {width: Fill height: Fit draw_text.text_style: theme.font_bold{font_size: 10}} - body := Label {width: Fill height: Fit draw_text.wrap: Words draw_text.text_style: theme.font_regular{font_size: 13 line_spacing: 1.4}} + speaker := Label {width: Fill height: Fit draw_text.text_style: theme.font_bold{font_size: 12}} + body := Label {width: Fill height: Fit draw_text.wrap: Words draw_text.text_style: theme.font_regular{font_size: 15 line_spacing: 1.4}} } } dock := View { @@ -44,7 +44,7 @@ script_mod! { } status := Label { width: Fill height: Fit - draw_text.text_style: theme.font_regular{font_size: 10.0} + draw_text.text_style: theme.font_regular{font_size: 12.0} draw_text.color: theme.color_text_placeholder text: "Ask about this card or request a change" } @@ -59,7 +59,7 @@ script_mod! { is_multiline: true empty_text: "Message the app…" draw_bg +: {pixel: fn() {return vec4(0.0)}} - draw_text.text_style: theme.font_regular{font_size: 13.0 line_spacing: 1.25} + draw_text.text_style: theme.font_regular{font_size: 15.0 line_spacing: 1.25} draw_cursor.color: #3668e8 draw_selection.color: #3668e840 } @@ -96,7 +96,7 @@ fn composer_height(text: &str, width: f64) -> f64 { // 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() {7.0} else {14.0}).sum(); + 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 diff --git a/crates/shell/src/glance_sheet.rs b/crates/shell/src/glance_sheet.rs index 4ed86bad..366e0358 100644 --- a/crates/shell/src/glance_sheet.rs +++ b/crates/shell/src/glance_sheet.rs @@ -22,14 +22,14 @@ struct MailReview; #[derive(Default)] struct MailToolbar; -pub const SHEET_WIDTH: f64 = 380.0; +pub const SHEET_WIDTH: f64 = 520.0; /// The window's least height, and its card's height before it is measured. pub const SHEET_MIN_HEIGHT: f64 = 160.0; const UNMEASURED_CARD: f64 = 320.0; /// What the window keeps clear of the screen's edges. const MARGIN: f64 = 16.0; -const HEADER: f64 = 44.0; -const PAD: f64 = 12.0; +const HEADER: f64 = 52.0; +const PAD: f64 = 16.0; const CLOSE: f64 = 44.0; const TABS: f64 = 52.0; @@ -66,7 +66,7 @@ script_mod! { draw_bg +: {color: #3668e8 color_hover: #2854c4 color_down: #2148ad border_size: 0 border_radius: 14} draw_text +: {color: #ffffff color_hover: #ffffff color_down: #ffffff} } - hint := Label {width: Fill height: Fit flow: Right {wrap: true} max_lines: 2 text_overflow: Ellipsis draw_text.text_style: theme.font_regular{font_size: 10}} + hint := Label {width: Fill height: Fit flow: Right {wrap: true} max_lines: 2 text_overflow: Ellipsis draw_text.text_style: theme.font_regular{font_size: 12}} } } } @@ -1016,7 +1016,7 @@ mod tests { fn the_window_is_centred_and_sized_to_its_card() { let screen = rect(0.0, 0.0, 1280.0, 800.0); let sheet = sheet_rect(screen, 300.0); - assert_eq!((sheet.pos.x, sheet.size.x, sheet.size.y), (450.0, SHEET_WIDTH, HEADER + 300.0 + PAD)); + assert_eq!((sheet.pos.x, sheet.size.x, sheet.size.y), ((1280.0 - SHEET_WIDTH) * 0.5, SHEET_WIDTH, HEADER + 300.0 + PAD)); assert_eq!(sheet.pos.y, (800.0 - sheet.size.y) * 0.5); let card = card_rect(sheet); assert_eq!(card.size.y, 300.0, "the card fills the window: no empty area below it"); diff --git a/crates/shell/src/module_resize_tests.rs b/crates/shell/src/module_resize_tests.rs index 2696ff26..53fe3242 100644 --- a/crates/shell/src/module_resize_tests.rs +++ b/crates/shell/src/module_resize_tests.rs @@ -11,7 +11,8 @@ View{width: Fill height: Fill} "#; fn photo_model(expression: &str) -> serde_json::Value { - let source = PHOTOS.split_once("\nstart_timeout(").unwrap().0; + let source = PHOTOS.split_once("// END shared app interface\n").unwrap().1 + .split_once("\nstart_timeout(").unwrap().0; let mut host = ScriptVmHost::new((), ()); let mut vm = ScriptVm { host: &mut host, @@ -85,7 +86,11 @@ fn photos_preview_keeps_favorites_first_without_repeating_them() { } fn hosted_card() -> (Cx, WidgetRef, WidgetRef, SplashVmId) { - let mut cx = Cx::new(Box::new(|_, _| {})); + hosted_card_with_handler(Box::new(|_, _| {})) +} + +fn hosted_card_with_handler(handler: Box) -> (Cx, WidgetRef, WidgetRef, SplashVmId) { + let mut cx = Cx::new(handler); cx.with_vm(makepad_widgets::script_mod); let outer = cx.alloc_splash_vm_with_network(false); let root = cx.with_script_vm_id_trusted(outer, |vm| { @@ -105,12 +110,92 @@ fn hosted_card() -> (Cx, WidgetRef, WidgetRef, SplashVmId) { (cx, tile, root, outer) } +#[test] +fn shorter_hosted_viewport_reveals_the_focused_editor_without_moving_its_header() { + use crate::tile::TileHost; + use std::{cell::RefCell, rc::Rc}; + + let receiver = Rc::new(RefCell::new(WidgetRef::empty())); + let events = receiver.clone(); + let (mut cx, tile, _, outer) = hosted_card_with_handler(Box::new(move |cx, event| { + events.borrow().handle_event(cx, event, &mut Scope::empty()); + })); + *receiver.borrow_mut() = tile.clone(); + let root = cx.with_script_vm_id_trusted(outer, |vm| { + let value = script_eval!(vm, { + use mod.prelude.widgets.* + View{width: Fill height: Fill flow: Down + header := Button{width: Fill height: 44 text: "Save"} + scroller := ScrollYView{width: Fill height: Fill flow: Down + View{width: Fill height: 280} + notes := TextInput{width: Fill height: 100 is_multiline: true text: "Keep my draft"} + } + } + }); + WidgetRef::script_from_value(vm, value) + }); + tile.borrow_mut::().unwrap().set_root(&mut cx, 1, outer, root.clone()); + widget_tree::set_ui_root(&mut cx, &root); + draw_card(&mut cx, &tile, 390.0, 600.0); + assert!(tile.borrow_mut::().unwrap().focus_keyboard(&mut cx)); + let notes = root.text_input(&cx, ids!(notes)); + notes.take_key_focus(&mut cx); + // Commit the pending focus as the platform does after input dispatch. + cx.send_trigger(notes.area(), Trigger { id: live_id!(test_focus_settle), from: Area::Empty }); + cx.handle_triggers(); + assert!(cx.has_key_focus(notes.area()), "editor initially takes focus"); + let header = root.button(&cx, ids!(header)).area().rect(&cx); + assert_eq!(root.view(&cx, ids!(scroller)).scroll_pos().y, 0.0); + + draw_card(&mut cx, &tile, 390.0, 280.0); + assert!(cx.has_key_focus(notes.area()), "resizing retains editor focus"); + cx.handle_triggers(); + // The host requests the ordinary smooth focus-scroll, which advances on + // frame events. Deliver frames before inspecting its final geometry. + for frame in 1..=60 { + let last = cx.new_next_frame(); + tile.handle_event(&mut cx, &Event::NextFrame(NextFrameEvent { + frame, + time: frame as f64 / 60.0, + set: (0..=last.0).map(NextFrame).collect(), + }), &mut Scope::empty()); + draw_card(&mut cx, &tile, 390.0, 280.0); + } + + let editor = notes.area().rect(&cx); + assert!(root.view(&cx, ids!(scroller)).scroll_pos().y > 0.0, "editor: {editor:?}"); + assert!(editor.pos.y >= header.pos.y + header.size.y, "editor overlaps fixed header: {editor:?}"); + assert!(editor.pos.y + editor.size.y <= 280.0, "editor is below keyboard: {editor:?}"); + assert_eq!(root.button(&cx, ids!(header)).area().rect(&cx), header); + assert_eq!(notes.text(), "Keep my draft"); + assert!(cx.has_key_focus(notes.area())); +} + fn set_card_body(cx: &mut Cx, root: &WidgetRef, outer: SplashVmId, body: &str) { with_isolate(cx, outer, |cx| { root.splash(cx, ids!(card)).set_text(cx, body) }); } +#[test] +fn permission_dialog_pause_and_resume_leave_a_new_video_ready_for_its_first_source() { + let mut cx = Cx::new(Box::new(|_, _| {})); + let root = cx.with_vm(|vm| { + makepad_widgets::script_mod(vm); + let value = script_eval!(vm, { + use mod.prelude.widgets.* + View{preview := Video{autoplay: false show_controls: false}} + }); + WidgetRef::script_from_value(vm, value) + }); + let preview = root.video(&cx, ids!(preview)); + assert!(preview.is_unprepared()); + root.handle_event(&mut cx, &Event::Pause, &mut Scope::empty()); + root.handle_event(&mut cx, &Event::Resume, &mut Scope::empty()); + assert!(preview.is_unprepared(), "a system permission dialog must not invent a running camera"); + assert!(!preview.is_playing()); +} + fn draw_card(cx: &mut Cx, tile: &WidgetRef, width: f64, height: f64) { let size = dvec2(width, height); let pass = DrawPass::new(cx); @@ -246,3 +331,69 @@ fn photos_preview_fills_the_card_with_its_title_overlaid_at_the_bottom() { assert_eq!(caption.borrow().unwrap().layout.align.y, 1.0); assert_eq!(root.label(&cx, ids!(tile_title)).text(), "Photos"); } + +#[test] +fn hosted_script_colors_follow_appearance_without_losing_input() { + let (mut cx, tile, mut root, outer) = hosted_card(); + let sheet = |dark| desktop_style::StyleSheet::load_with_appearance(desktop_style::DesktopStyle::Ios, dark); + cx.with_script_vm_id_trusted(outer, |vm| { + desktop_style::install(vm, sheet(false)); + vm.with_reload(makepad_widgets::script_mod); + }); + let card = root.splash(&mut cx, ids!(card)); + card.borrow_mut().unwrap().set_stylesheet(&mut cx, sheet(false)); + set_card_body(&mut cx, &root, outer, + "let ink = theme.color_text\nView{label := Label{text: \"Theme\" draw_text.color: ink} input := TextInput{text: \"Draft\"}}" + ); + 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"); + cx.with_script_vm_id_trusted(outer, |vm| { + desktop_style::install(vm, sheet(true)); + vm.with_reload(makepad_widgets::script_mod); + let source = root.script_source(); + root.script_apply(vm, &Apply::ScriptReapply, &mut Scope::empty(), source.into()); + }); + draw_card(&mut cx, &tile, 400.0, 700.0); + let dark = label.borrow().unwrap().draw_text.color; + assert_ne!(light, dark, "restyling must reach the app's nested Splash"); + assert_eq!(root.text_input(&mut cx, ids!(input)).text(), "Unsaved draft"); +} + +#[cfg(feature = "app-hub")] +#[test] +fn card_runner_restyles_nested_script_without_replacing_draft() { + 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::Ios, 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(400.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()); + 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); + 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"); + 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)).text(), "Unsaved draft"); + host.teardown(&mut cx, 1); +} diff --git a/crates/shell/src/module_view.rs b/crates/shell/src/module_view.rs index 0bd0bad1..a6dfc950 100644 --- a/crates/shell/src/module_view.rs +++ b/crates/shell/src/module_view.rs @@ -127,6 +127,10 @@ pub struct MpModuleView { wake_frame: Option, #[rust] script_viewport: Option<(WidgetUid, Vec2d)>, + /// Previous hosted bounds, used to reveal a focused editor when the IME + /// shrinks the app. Resize the app, then scroll its own nearest container. + #[rust] + last_viewport_size: Option, } impl MpModuleView { @@ -155,6 +159,7 @@ impl MpModuleView { self.client = Some(client); self.vm_id = vm_id; self.script_viewport = None; + self.last_viewport_size = None; self.drawn = false; self.stopped = None; self.draw_bg.redraw(cx); @@ -626,6 +631,21 @@ impl Widget for MpModuleView { if drawn.is_none() { self.stop(cx); } + if drawn.is_some() && self.focused && self.takes_key_focus + && self.last_viewport_size.is_some_and(|size| rect.size.y < size.y - 0.5) + { + if let Some(list) = cx.get_current_draw_list_id() { + if let Some((area, stack)) = CxDraw::iterate_nav_stops(cx, list, |cx, stop| { + cx.has_key_focus(stop.area).then_some(stop.area) + }) { + let focused = area.rect(cx); + if focused.pos.y + focused.size.y > rect.pos.y + rect.size.y - 5.0 { + NavControl::send_trigger_to_scroll_stack(cx, stack); + } + } + } + } + self.last_viewport_size = Some(rect.size); self.drawn = true; } if self.stopped.is_some() { diff --git a/crates/shell/src/system_app_theme_tests.rs b/crates/shell/src/system_app_theme_tests.rs index 77706e1c..6e505585 100644 --- a/crates/shell/src/system_app_theme_tests.rs +++ b/crates/shell/src/system_app_theme_tests.rs @@ -10,7 +10,7 @@ const NEWS: &str = include_str!("../../../apps/news/bundle/main.splash"); /// 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; - format!("let ink = {}", after_boot.split_once("\nHostedView{").unwrap().0) + format!("{}\nlet ink = {}", include_str!("../../../apps/interface.splash"), after_boot.split_once("\nHostedView{").unwrap().0) } /// Each colour in `expressions`, evaluated after News's styles in a VM @@ -33,12 +33,31 @@ fn news_colors(style: DesktopStyle, dark: bool, expressions: &[&str]) -> Vec= 4.5, "{at}: {ink:08x} on {surface:08x}: {}", contrast(*ink, *surface)); + } + } + assert!(contrast(0xffffffff, colors[8]) >= 4.5, "{at}: primary action"); + } + } +} + /// WCAG contrast ratio of two opaque `0xRRGGBBAA` colours. fn contrast(a: u32, b: u32) -> f64 { let luminance = |c: u32| { diff --git a/native-apps.json b/native-apps.json index 6aa31c8b..1fa2ff9f 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": "a52d928b0c14d2822c09f6ccd18f44bf7b885221", "local": null }, + "source": { "git": "https://github.com/OctoSense-org/OctoSense-App-Hub", "rev": "f762267864fc3d68631b46924899cff324f7d157", "local": null }, "module": ["octosense_app_hub_app::APP_HUB_MODULE", "octosense_app_hub_app::CARD_MODULE"], "bin": null, "default_features": true, diff --git a/runtime-patches.lock.json b/runtime-patches.lock.json index 0dddc8b7..995c4a37 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": "50371b7e54adf63fa8aa8c0b1acc53226dcff9ba", + "tree": "bc23ca0e6805e6c295c87335352d474e0001b0a9", "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.", @@ -16,7 +16,8 @@ "Android physical touchscreen input has scoped host approval provenance; default and nested synthetic input are untrusted. Compromised OS/root impersonation is outside this app-level boundary; the subsequent desktop patch adds macOS pointer provenance.", "macOS AppKit pointer metadata enables native approval for HID events without a posting process. Synthetic and remote dispatch remain untrusted, including nested callbacks; Windows/Linux physical approval is unsupported.", "Android multiline Enter key events finish the active composition before inserting a newline, retaining the last word; ordinary IME composition commits keep replacement semantics.", - "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." + "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." ], "stacked": [ { @@ -54,6 +55,10 @@ { "patch": "tools/runtime-patches/makepad-auth-webview.patch", "sha256": "61d5e247627bd7a3a883dab6689d6c82b7df7cbc031e3731b82b31f388555131" + }, + { + "patch": "tools/runtime-patches/makepad-camera-permission-wait.patch", + "sha256": "6b1c5998521dbf44e70bc8b90ab025d8f449a8e646c363585f3b172734d478c2" } ] } diff --git a/tools/app-ux/README.md b/tools/app-ux/README.md new file mode 100644 index 00000000..f9a70d7b --- /dev/null +++ b/tools/app-ux/README.md @@ -0,0 +1,100 @@ +# Native app UX checks + +English | [简体中文](README.zh-CN.md) + +These checks run owned, hidden Makepad windows with isolated app data. They +exercise the actual hosted apps through pointer and keyboard input and save +original PNGs, widget snapshots, process logs and result receipts under +`target/app-ux/`. No screenshot is repainted or cropped for acceptance. +Use a fresh output directory for a new run; keep failed attempts separately. + +## Build and run + +Run from the repository root. These commands were exercised on macOS: + +```sh +python3 tools/setup.py +python3 tools/sync-app-interface.py --check +cargo build --locked --release -p octosense --no-default-features --features app-hub,dev-mode +python3 tools/app-ux/calendar_journey.py --output target/app-ux/calendar-final +python3 tools/app-ux/calendar_journey.py --desktop --output target/app-ux/calendar-desktop-separate-frame +python3 tools/app-ux/theme_journey.py --output target/app-ux/theme-final +python3 tools/app-ux/providers_journey.py --output target/app-ux/acceptance-providers-cancel +python3 tools/app-ux/retention_journey.py --output target/app-ux/acceptance-retention-pixels +python3 tools/app-ux/mail_journey.py --output target/app-ux/mail-journey-round2 +python3 tools/app-ux/news_journey.py --output target/app-ux/news-journey-round3 +python3 tools/app-ux/glance_swipe_journey.py --output target/app-ux/glance-curved-native-after +python3 apps/photos/tests/ui.py --binary target/release/octosense --output target/app-ux/photos-journey-round2 +``` + +Calendar creates a Unicode event with multiline notes, reviews it, publishes it +to Glance and checks the authoritative store after restart. The appearance +journey changes light/dark mode while a draft is unsaved, then saves that draft. +Mail uses a synthetic local demo account, reads a message and edits a reply; +it does **not** send mail. News reads live public feeds and saves a story locally. +Photos uses its existing local mock provider to exercise Memories; this does +not prove a real provider call or device photo-library access. + +The desktop Calendar journey activates the hosted window, submits each input +once and waits for a separate frame capture before checking widget geometry. +This avoids repeating an already applied input when the hidden-window control +surface rejects an immediate present. Its timings include PNG capture overhead +and must not be compared with the editor-only benchmark samples. + +The provider journey opens the real host-owned Add model sheet and cancels; it +does not configure or call a provider. The retention journey captures six distinct +Unicode multiline drafts after switching through five other Glance workspaces. +These custom fixture fields are absent from the native widget snapshot, so the +receipt requires a separate visual review of all six original returned images. + +The Glance swipe journey starts with a feed that fits on screen and checks left +swipes at four heights, each beginning with an upward or downward thumb arc. +The regression failed in the old native build and passes in the fixed build. +This preview drives mouse events; the corresponding Android check is +`android_glance_swipe.py`, which requires an explicitly assigned `--serial` and +the separately installed `dev.makepad.octosense.glanceswipe` test package. It +dispatches Android touch events, captures original device PNGs and stops if +another app takes the foreground. It never clears app data or changes Home. + +Capture both widths and appearances: + +```sh +python3 tools/app-ux/capture.py --output target/app-ux/final-phone-light --apps calendar photos mail ai-providers youtube maps --mode phone --settle-seconds 12 +python3 tools/app-ux/capture.py --output target/app-ux/final-phone-dark --apps calendar photos mail ai-providers youtube maps --mode phone --dark --settle-seconds 12 +python3 tools/app-ux/capture.py --output target/app-ux/final-desktop --apps calendar photos mail ai-providers youtube maps apphub --mode desktop --settle-seconds 12 +``` + +Capture success means pixels were obtained, not that every network resource +loaded or the screen passed review. Inspect each original image. Additional +settling time accommodates resource loading; it is not a performance sample. + +`hub_journey.py` drives App Hub's native `preview` example, with `--binary` +pointing to that executable. It browses the labelled preview catalog, searches, +recovers from empty results and opens app details without installing anything. +It accepts `--size 1200x860` and `--dark`. Build the example in the companion +App Hub checkout at the revision pinned in this repository. + +## Performance and acceptance + +`benchmark.py --before --after --output ` compares +24 Calendar editor-open observations per binary. It records p50, p95, maximum, +all samples and binary hashes. This is pointer-dispatch-to-widget-observation +wall time including HTTP overhead, **not GPU frame latency or FPS**. A copied +cold binary can take longer to load its host resources; startup is outside the +samples. Control machine load before interpreting small differences. + +Review task clarity, readable content, keyboard reachability, retained state, +responsiveness and visual consistency separately. Pixel diagnostics cannot +establish a 95/100 UX result or override a broken task. Record the actual author, +driver and reviewer; a self-review is not independent acceptance. + +Phone mode is a desktop host at phone dimensions. Device claims require separate evidence. The assigned Redmi Note 12 +(Android 15) has verified Glance touch dismissal at eight starting paths and +Calendar multiline editing, focus reveal above the soft keyboard, Save with the +keyboard open and saved-detail review in an isolated test package. Physical +iOS/OHOS and assistive-input acceptance remain **unverified**. A preview also does not prove +real email delivery, model execution, map routing or video playback. + +For shared shell changes, also run the packaging and feature checks listed in +the repository [AGENTS.md](../../AGENTS.md). The harnesses quit only their own +processes and do not replace installed applications or personal profiles. diff --git a/tools/app-ux/README.zh-CN.md b/tools/app-ux/README.zh-CN.md new file mode 100644 index 00000000..9d127c7a --- /dev/null +++ b/tools/app-ux/README.zh-CN.md @@ -0,0 +1,88 @@ +# 原生应用 UX 检查 + +[English](README.md) | 简体中文 + +这些检查使用独立应用数据,在隐藏的 Makepad 窗口中通过真实指针与键盘输入 +操作宿主内的应用。原始 PNG、控件快照、进程日志和结果回执保存在 +`target/app-ux/`,不重绘或裁剪验收截图。每次新测试使用新输出目录,失败记录单独保留。 + +## 构建与运行 + +在仓库根目录运行,以下命令已在 macOS 上执行: + +```sh +python3 tools/setup.py +python3 tools/sync-app-interface.py --check +cargo build --locked --release -p octosense --no-default-features --features app-hub,dev-mode +python3 tools/app-ux/calendar_journey.py --output target/app-ux/calendar-final +python3 tools/app-ux/calendar_journey.py --desktop --output target/app-ux/calendar-desktop-separate-frame +python3 tools/app-ux/theme_journey.py --output target/app-ux/theme-final +python3 tools/app-ux/providers_journey.py --output target/app-ux/acceptance-providers-cancel +python3 tools/app-ux/retention_journey.py --output target/app-ux/acceptance-retention-pixels +python3 tools/app-ux/mail_journey.py --output target/app-ux/mail-journey-round2 +python3 tools/app-ux/news_journey.py --output target/app-ux/news-journey-round3 +python3 tools/app-ux/glance_swipe_journey.py --output target/app-ux/glance-curved-native-after +python3 apps/photos/tests/ui.py --binary target/release/octosense --output target/app-ux/photos-journey-round2 +``` + +日历测试创建带中文标题和多行备注的事件,检查详情、发布至 Glance,并在重启后 +核对实际存储。主题测试在草稿尚未保存时切换明暗外观,再保存同一份草稿。 +邮件使用虚构的本地演示账户,阅读邮件并编辑回复,**不发送邮件**。 +新闻读取公开订阅源,将文章收藏到本地。相册使用现有的本地模拟服务商测试 +Memories,不代表真实模型调用或设备相册访问。 + +桌面日历测试先激活宿主窗口,每次输入只派发一次,再通过单独的帧截图等待 +控件坐标更新,避免隐藏窗口拒绝立即呈现时重复已经执行的操作。这组耗时包含 +PNG 截图开销,不能与仅测编辑器打开过程的基准样本直接比较。 + +Glance 滑动测试使用可在一屏内完整显示的总览,覆盖四个高度,以及起步时向上、 +向下偏移的弧线左滑。旧构建会失败,修复版通过。本机预览注入的是鼠标事件; +对应的 Android 测试为 `android_glance_swipe.py`,须指定已分配设备的 `--serial`, +并单独安装 `dev.makepad.octosense.glanceswipe` 测试包。脚本注入 Android 触摸事件、 +保存原始手机截图,其他应用进入前台时立即停止,不清除应用数据或更换 Home。 + +采集两种宽度与明暗外观: + +```sh +python3 tools/app-ux/capture.py --output target/app-ux/final-phone-light --apps calendar photos mail ai-providers youtube maps --mode phone --settle-seconds 12 +python3 tools/app-ux/capture.py --output target/app-ux/final-phone-dark --apps calendar photos mail ai-providers youtube maps --mode phone --dark --settle-seconds 12 +python3 tools/app-ux/capture.py --output target/app-ux/final-desktop --apps calendar photos mail ai-providers youtube maps apphub --mode desktop --settle-seconds 12 +``` + +采集成功只表示拿到了像素,不代表网络资源均已加载或界面已通过评审。必须查看 +原始图片。额外等待用于资源加载,不作为性能样本。 + +`hub_journey.py` 通过 `--binary` 指定应用大厅的原生 `preview` 示例程序, +浏览明确标注的预览目录,执行搜索、空结果恢复和详情查看,不安装应用。 +支持 `--size 1200x860` 和 `--dark`。示例程序须在配套 App Hub 仓库中, +按本仓库固定的版本构建。 + +## 性能与验收 + +`benchmark.py --before --after --output ` 对每个程序 +测量 24 次打开日历编辑器的观测耗时,记录 p50、p95、最大值、全部样本和程序哈希。 +这是从派发指针操作到观察到控件的墙钟时间,包含 HTTP 开销,**不是 GPU 帧耗时或 FPS**。 +复制的旧程序冷启动可能需要更久加载宿主资源;启动不计入交互样本。 +解释微小差异前应控制机器负载。 + +分别评审任务清晰度、内容可读性、键盘可达性、状态保留、响应速度和视觉一致性。 +像素诊断不能证明达到 95/100,也不能抵消失败的核心操作。记录实际作者、驱动者 +和评审者;自评不代表独立验收。 + +手机模式是手机尺寸的桌面宿主。Android、iOS、OHOS 的实际键盘、生命周期、相机、 +触摸和辅助输入验收,在指定设备上运行前均为**未验证**。预览也不能证明真实邮件送达、 +模型执行、地图路线或视频播放。 + +修改共用 Shell 时,还须执行仓库 [AGENTS.md](../../AGENTS.md) 中的打包与功能检查。 +测试只关闭自己启动的进程,不替换已安装的应用或个人配置。 + +## 补充验收 + +`providers_journey.py` 打开真正由宿主管理的新增模型表单并取消,不保存凭据或调用模型。 +`retention_journey.py` 依次编辑六个独立 Glance 工作区,切换五个其他工作区后分别返回。 +这类测试字段不在原生 widget 快照中,因此脚本只报告已采集;必须另行检查六张原始截图, +确认各自的 Unicode 多行草稿仍完整保留。不要把采集成功当作状态验收通过。 + +本次在指定 Redmi Note 12(Android 15)的独立测试包中,已验证八条 Glance 触屏 +关闭路径,以及日历多行编辑、键盘上方自动显示当前输入框、键盘打开时保存及保存后复查。 +这些真机结果不代表 iOS、OpenHarmony、辅助输入或外部服务端到端验证。 diff --git a/tools/app-ux/acceptance-rubric.json b/tools/app-ux/acceptance-rubric.json new file mode 100644 index 00000000..84c83222 --- /dev/null +++ b/tools/app-ux/acceptance-rubric.json @@ -0,0 +1,75 @@ +{ + "target": 95, + "scale": 100, + "scope": "OctoSense App Hub, Glance presentation and existing OctoScript app interfaces; desktop and Android. UI acceptance, not live-service release sign-off.", + "reviewer": "Codex author self-review, not independent human approval", + "anchors": { + "0": "Blocked task or essential content unreadable", + "0.6": "Completes with substantial friction", + "0.8": "Usable but visible inconsistency or coverage gap", + "0.9": "Polished in observed states; minor limitations remain", + "1": "All specified states, widths and repeated journeys verified" + }, + "hard_gates": [ + "No failed core journey", + "No clipped or obscured essential editor/action", + "Physical-device checks are never replaced by desktop phone previews", + "Unverified required cases prevent a final pass" + ], + "dimensions": [ + { + "id": "clarity", + "weight": 20, + "criteria": [ + "One prominent next action in empty and populated states", + "Related actions remain next to their content", + "No dead-end setup controls" + ] + }, + { + "id": "legibility", + "weight": 20, + "criteria": [ + "Readable text and focus in light and dark", + "Essential text and actions fit at phone and desktop widths", + "Shared text tokens meet 4.5:1 contrast" + ] + }, + { + "id": "editing", + "weight": 15, + "criteria": [ + "Unicode and multiline edits save the intended values", + "Native Android keyboard leaves editors and actions reachable", + "Dismissal and return preserve the correct item" + ] + }, + { + "id": "continuity", + "weight": 20, + "criteria": [ + "Six workspaces retain distinct unsent input beyond clean cache capacity", + "Saved events, stories and bookmarks survive restart", + "Light/dark switches retain the active unsaved editor" + ] + }, + { + "id": "responsiveness", + "weight": 10, + "criteria": [ + "Measured repeated editor-open observations with explicit method", + "Bounded background polling stops while editing", + "Touch navigation works from all tested Glance start heights" + ] + }, + { + "id": "consistency", + "weight": 15, + "criteria": [ + "One shared app interface with generated-copy check", + "App Hub, summaries and full apps retain coherent hierarchy", + "Theme changes include map content as well as controls" + ] + } + ] +} diff --git a/tools/app-ux/benchmark.py b/tools/app-ux/benchmark.py new file mode 100644 index 00000000..993301dd --- /dev/null +++ b/tools/app-ux/benchmark.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Compare native editor-open observations on two release binaries (not GPU FPS).""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import statistics +import time +from capture import AppNative + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument('--before', type=Path, required=True) + p.add_argument('--after', type=Path, required=True) + p.add_argument('--output', type=Path, required=True) + args = p.parse_args() + out = args.output.resolve() + out.mkdir(parents=True, exist_ok=True) + results = {} + for name, binary in [('before', args.before), ('after', args.after)]: + state = out / name + os.environ.update(OCTOSENSE_HOME=str(state/'home'), OCTOSENSE_APP_DATA=str(state/'apps'), OCTOS_APP_CORE_DIR=str(state/'core')) + a = AppNative(binary.resolve(), ['--test-action', 'phone:ios', '--test-action', 'launch-calendar'], out, name) + samples = [] + try: + # First launch also loads the host's icon/style resources. A cold + # copied baseline may take longer; startup is outside these samples. + a.wait(lambda: a.find(text='+ Event'), timeout=60) + time.sleep(1) + for _ in range(24): + started = time.perf_counter() + a.click('+ Event') + a.wait(lambda: a.find(identifier='e_title', kind='TextInput')) + samples.append((time.perf_counter() - started) * 1000) + a.click('Cancel') + a.wait(lambda: a.find(text='+ Event')) + time.sleep(.1) + a.check_logs() + finally: + a.close() + results[name] = {'binary_sha256': hashlib.sha256(binary.read_bytes()).hexdigest(), + 'samples_ms': samples, 'p50_ms': statistics.median(samples), + 'p95_ms': sorted(samples)[22], 'max_ms': max(samples)} + results['method'] = 'Monotonic wall time from native pointer dispatch wait=1 to observing the editor. Includes HTTP/selector overhead. Hidden phone-size host, isolated state, same machine; other build jobs may run. Not GPU frame latency or a controlled lab benchmark.' + (out/'receipt.json').write_text(json.dumps(results, indent=2)+'\n') + print(json.dumps(results)) + + +if __name__ == '__main__': + main() diff --git a/tools/app-ux/calendar_journey.py b/tools/app-ux/calendar_journey.py new file mode 100644 index 00000000..aa9def2c --- /dev/null +++ b/tools/app-ux/calendar_journey.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Real input → saved event → detail → Glance → process restart, with timings.""" +import argparse +import json +import os +from pathlib import Path +import statistics +import time +from urllib.error import HTTPError +from capture import AppNative, ROOT + + +class CalendarNative(AppNative): + def call(self, route, **query): + # Hidden desktop windows can reject an immediate GPU present after + # input has already been applied. Do not resend that input: observe + # the next widget state below, and capture actual frames at each + # checkpoint. Timing is input-to-observed-state, never GPU latency. + input_event = route in ('click', 'm', 'k', 't') + if input_event: + query.pop('wait', None) + result = super().call(route, **query) + if input_event: + # Wait on a separate frame request so widget geometry reflects + # the input before the next reachability/scroll calculation. + for attempt in range(15): + try: + super().call('g') + break + except HTTPError as error: + if error.code != 404 or attempt == 14: + raise + time.sleep(.1) + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--binary", type=Path, default=ROOT / "target/release/octosense") + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--desktop", action="store_true") + args = parser.parse_args() + out = args.output.resolve() + out.mkdir(parents=True, exist_ok=True) + state = out / "state" + os.environ.update(OCTOSENSE_HOME=str(state / "home"), OCTOS_APP_CORE_DIR=str(state / "core"), + OCTOSENSE_APP_DATA=str(state / "apps"), OCTOSENSE_LLM_VAULT="file") + actions = [] if args.desktop else ["--test-action", "phone:ios"] + actions += ["--test-action", "launch-calendar"] + title = "Design review · 设计评审" + samples = [] + checks = [] + for attempt in range(2): + native_type = CalendarNative if args.desktop else AppNative + native = native_type(args.binary.resolve(), actions, out, "calendar-" + str(attempt)) + try: + native.label("Calendar") + native.wait(lambda: native.find(text="+ Event")) + native.wait(lambda: (native.find(identifier="day_title", kind="Label") or {}).get("t")) + time.sleep(.8) # Wait for the shell opening transition before pointer input. + if args.desktop: + # Activate the hosted window before measuring its app controls. + # Use the non-interactive heading, not an app action, for setup. + native.click_row(native.label("Calendar")) + if attempt == 0: + # A bounded, repeatable interaction; each reply waits for drawing. + for cycle in range(12): + print("editor cycle", cycle, flush=True) + start = time.perf_counter() + native.click("+ Event") + native.wait(lambda: native.find(identifier="e_title", kind="TextInput")) + samples.append((time.perf_counter() - start) * 1000) + native.click("Cancel") + native.wait(lambda: native.find(text="+ Event")) + time.sleep(.1) + native.click("+ Event") + native.field("e_title", title) + if not args.desktop: + native.call("k", k="press", c="Escape", wait=1) + time.sleep(.3) + native.field("e_place", "Studio · 第二会议室") + if not args.desktop: + native.call("k", k="press", c="Escape", wait=1) + time.sleep(.3) + native.field("e_notes", "Review desktop and phone layouts.\nCheck keyboard, contrast, and saved state.") + if not args.desktop: + native.call("k", k="press", c="Escape", wait=1) + time.sleep(.3) + native.click("Save") + native.wait(lambda: native.find(text="+ Event")) + native.reachable(text=title, kind="Label") + checks.append("Created an event with Unicode title and multiline notes through native inputs") + else: + native.reachable(text=title, kind="Label") + checks.append("Saved event restored after complete process restart") + native.click("View event ›") + native.wait(lambda: native.find(text="Edit")) + native.label("Studio · 第二会议室") + native.reachable(identifier="event_notes", kind="Label") + native.label("Check keyboard, contrast, and saved state.") + if attempt == 0: + native.click("Show in Glance") + native.label("Event shown in Glance.") + checks.append("Same saved event published to Glance through its registered host service") + native.capture("calendar-published") + native.capture("calendar-result-" + str(attempt)) + native.check_logs() + except Exception: + (out / "failed-actions.json").write_text(json.dumps(native.actions, indent=2)) + native.capture("failed-state") + raise + finally: + native.close() + # Read the authoritative store, not the screenshot or assistant prose. + stores = list(state.rglob("events.json")) + assert len(stores) == 1, stores + saved = json.loads(stores[0].read_text()) + assert title in json.dumps(saved, ensure_ascii=False) + ordered = sorted(samples) + receipt = {"checks": checks, "physical_mobile": False, "release_build": True, + "timing_method": "Python monotonic wall time: select + Event, observe editor; includes HTTP and selector overhead, not GPU frame duration", + "immediate_input_frame_wait": not args.desktop, + "separate_frame_capture_after_input": args.desktop, + "samples_ms": samples, "p50_ms": statistics.median(samples), + "p95_ms": ordered[min(len(ordered)-1, int(len(ordered)*.95))], "max_ms": max(samples), + "persisted_store": str(stores[0].relative_to(out))} + (out / "receipt.json").write_text(json.dumps(receipt, ensure_ascii=False, indent=2) + "\n") + print(json.dumps(receipt, ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/tools/app-ux/capture.py b/tools/app-ux/capture.py new file mode 100644 index 00000000..67f690e9 --- /dev/null +++ b/tools/app-ux/capture.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Capture untouched native system-app screens with isolated, disposable state. + +Phone mode is a desktop host at phone dimensions, not a physical-device result. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import sys +import subprocess +import time +from urllib.error import HTTPError + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "tools/connected-e2e")) +from native import Native + + +class AppNative(Native): + def call(self, route, **query): + if route == "m" and query.get("k") == "scroll": + # Deterministic trackpad deltas, not a smoothed wheel animation + # whose first subsequent press is consumed catching its motion. + query.setdefault("precise", 1) + try: + return super().call(route, **query) + except HTTPError as error: + # The native instrument uses 404 for explicit failures too (for + # example an input/frame timeout). Preserve that reason in the + # evidence instead of reducing every failure to "Not Found". + detail = error.read().decode('utf-8', errors='replace') + if detail: + error.msg = detail + raise + + def rows(self): + try: + return super().rows() + except HTTPError as error: + if error.code == 404 and self.child.poll() is None: + return [] # Instrument ready, first native window not ready yet. + raise + + def capture(self, name): + # A grab may arrive between window creation and its first present. + # Retry that explicit 404, never a script error or failed assertion. + for attempt in range(15): + try: + return super().capture(name) + except HTTPError as error: + if error.code != 404 or attempt == 14: + raise + time.sleep(.2) + + def close(self): + try: + super().close() + except subprocess.TimeoutExpired: + self.child.kill() + self.child.wait(timeout=5) + self.log.close() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--binary", type=Path, default=ROOT / "target/release/octosense") + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--apps", nargs="+", default=["calendar", "mail", "news", "photos", "ai-providers", "apphub", "youtube"]) + parser.add_argument("--mode", choices=["desktop", "phone"], default="phone") + parser.add_argument("--dark", action="store_true") + parser.add_argument("--settle-seconds", type=float, default=2, + help="Additional resource-loading time before each capture") + args = parser.parse_args() + out = args.output.resolve() + out.mkdir(parents=True, exist_ok=True) + # A camera bundle is phone-only even in the desktop phone preview. + desktop_apps = json.loads((ROOT / 'desktop/system-apps.json').read_text())['apps'] + unsupported = set(args.apps) - set(desktop_apps) - {'apphub'} + if unsupported: + parser.error('Not packaged in this desktop test binary: ' + ', '.join(sorted(unsupported))) + results = [] + for app in args.apps: + name = app + "-" + args.mode + ("-dark" if args.dark else "-light") + state = out / (name + "-state") + os.environ.update(OCTOSENSE_HOME=str(state / "home"), OCTOS_APP_CORE_DIR=str(state / "core"), + OCTOSENSE_APP_DATA=str(state / "apps"), OCTOSENSE_LLM_VAULT="file", + MAKEPAD_APP_CONFIG=json.dumps({"mail_demo": True})) + actions = ["--test-action", "phone:ios"] if args.mode == "phone" else [] + actions += ["--test-action", "launch-" + app] + instance = AppNative(args.binary.resolve(), actions, out, name) + try: + instance.wait(lambda: len(instance.rows()) > 4, timeout=45) + time.sleep(args.settle_seconds) + if args.dark: + # Appearance controls in the phone preview and the desktop + # shell bar. Desktop submits once, then captures a new frame. + if args.mode == "phone": + instance.call("click", x=332, y=16, wait=1) + else: + instance.call("click", x=252, y=16) + instance.capture(name + "-appearance-transition") + time.sleep(args.settle_seconds) # Map tile styles rebake asynchronously after restyle. + instance.capture(name) + instance.check_logs() + results.append({"screen": name, "status": "captured", "native": True, + "physical_mobile": False, "sha256": hashlib.sha256((out / (name + ".png")).read_bytes()).hexdigest()}) + except Exception as error: + results.append({"screen": name, "status": "failed", "error": str(error)}) + finally: + try: + instance.close() + except subprocess.TimeoutExpired: + instance.child.kill() + instance.child.wait(timeout=5) + instance.log.close() + print(json.dumps(results[-1]), flush=True) + receipt = out / (args.mode + ("-dark" if args.dark else "-light") + ".json") + receipt.write_text(json.dumps({"binary_sha256": hashlib.sha256(args.binary.read_bytes()).hexdigest(), "screens": results}, indent=2) + "\n") + if any(r["status"] == "failed" for r in results): + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/tools/app-ux/glance_journey.py b/tools/app-ux/glance_journey.py new file mode 100644 index 00000000..0136ae42 --- /dev/null +++ b/tools/app-ux/glance_journey.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Expand the Calendar journey's real publication and retain an unsent chat draft.""" +import argparse +import json +import os +from pathlib import Path +import time +from capture import AppNative, ROOT + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument('--binary', type=Path, default=ROOT/'target/release/octosense') + p.add_argument('--calendar-state', type=Path, required=True) + p.add_argument('--output', type=Path, required=True) + args = p.parse_args() + out = args.output.resolve() + out.mkdir(parents=True, exist_ok=True) + state = args.calendar_state.resolve() + if not (state/'apps/.host/calendar/events.json').is_file(): + p.error('Run calendar_journey.py first and use its isolated state directory') + os.environ.update(OCTOSENSE_HOME=str(state/'home'), OCTOSENSE_APP_DATA=str(state/'apps'), + OCTOS_APP_CORE_DIR=str(state/'core'), OCTOSENSE_LLM_VAULT='file') + a = AppNative(args.binary.resolve(), ['--test-action', 'launch-calendar', '--test-action', 'glance'], out, 'glance') + draft = 'Keep this unsent draft · 未发送' + try: + a.wait(lambda: a.find(text='+ Event'), timeout=30) + time.sleep(2) + # Close/reopen using the keyboard so its first published card has focus. + for _ in range(2): + a.call('k', k='press', c='F9', wait=1) + time.sleep(.5) + a.capture('summary') + a.call('k', k='press', c='ReturnKey', wait=1) + a.wait(lambda: a.find(identifier='chat_tab')) + assert 'Design review' in a.log_path.read_text() or 'os.calendar/' in a.log_path.read_text() + a.capture('expanded') + a.click('Chat') + a.field('input', draft) + a.capture('chat-draft') + a.click('Card') + a.click('Chat') + assert a.find(identifier='input', kind='TextInput')['t'] == draft + a.call('k', k='press', c='Escape', wait=1) + time.sleep(.3) + a.call('k', k='press', c='F9', wait=1) + time.sleep(.3) + a.call('k', k='press', c='ReturnKey', wait=1) + a.wait(lambda: a.find(identifier='chat_tab')) + a.click('Chat') + assert a.find(identifier='input', kind='TextInput')['t'] == draft + a.capture('chat-restored') + a.check_logs() + (out/'receipt.json').write_text(json.dumps({'checks': [ + 'Open real Calendar publication using keyboard focus', + 'Expanded Card/Chat retains a Unicode unsent draft', + 'Same draft survives Card/Chat and collapse/reopen'], + 'model_calls': False, 'physical_mobile': False}, indent=2)+'\n') + print('PASS: summary, expand, chat draft, card tab and collapse/reopen') + except Exception: + a.capture('failed-state') + raise + finally: + a.close() + + +if __name__ == '__main__': + main() diff --git a/tools/app-ux/glance_swipe_journey.py b/tools/app-ux/glance_swipe_journey.py new file mode 100644 index 00000000..354dfd02 --- /dev/null +++ b/tools/app-ux/glance_swipe_journey.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Check curved Glance paging gestures in an isolated native phone preview.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import time +from capture import AppNative, ROOT + + +def drag(native, points): + native.call('m', k='down', x=points[0][0], y=points[0][1], wait=1) + for x, y in points[1:]: + native.call('m', k='move', x=x, y=y, wait=1) + time.sleep(.025) + native.call('m', k='up', x=points[-1][0], y=points[-1][1], wait=1) + time.sleep(.5) + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument('--binary', type=Path, default=ROOT/'target/release/octosense') + p.add_argument('--output', type=Path, required=True) + args = p.parse_args() + out = args.output.resolve() + out.mkdir(parents=True, exist_ok=True) + state = out/'state' + os.environ.update(OCTOSENSE_HOME=str(state/'home'), OCTOSENSE_APP_DATA=str(state/'apps'), + OCTOS_APP_CORE_DIR=str(state/'core'), RINX_DATA_DIR=str(state/'rinx'), + OCTOSENSE_LLM_VAULT='file') + binary = args.binary.resolve() + native = AppNative(binary, ['--test-action', 'phone:ios', '--test-action', 'page:-1'], out, 'swipe') + results = [] + try: + native.wait(lambda: len(native.rows()) > 2, timeout=45) + time.sleep(2) + native.capture('overview') + # A vertical drag on a feed that fits should leave this page in place, + # including the desktop preview's legacy navigation path. Subsequent + # left swipes must still start on Glance, not the App Library. + drag(native, [(200, 600), (200, 575), (200, 550), (200, 500)]) + native.capture('after-vertical') + for y in [170, 300, 500, 680]: + for sign in [-1, 1]: + name = f'left-{y}-{sign}' + before = native.log_path.read_text().count('gesture commit Page(Left)') + points = [(300, y)] + [(300 + dx, y + sign*14) for dx in [-4, -24, -80, -160, -210]] + drag(native, points) + passed = native.log_path.read_text().count('gesture commit Page(Left)') == before + 1 + native.capture(name) + results.append({'start_y': y, 'vertical_arc': sign*14, 'returned_home': passed}) + assert passed, f'Curved left swipe blocked at y={y}, arc={sign*14}' + right = native.log_path.read_text().count('gesture commit Page(Right)') + drag(native, [(85, 650), (110, 650), (170, 650), (230, 650), (330, 650)]) + assert native.log_path.read_text().count('gesture commit Page(Right)') == right + 1 + native.check_logs() + finally: + (out/'receipt.json').write_text(json.dumps({ + 'binary_sha256': hashlib.sha256(binary.read_bytes()).hexdigest(), + 'input': 'native mouse events in phone preview', 'physical_mobile': False, + 'gestures': results, + }, indent=2)+'\n') + native.close() + print('PASS: curved left swipes at four heights, both initial vertical directions') + + +if __name__ == '__main__': + main() diff --git a/tools/app-ux/hub_journey.py b/tools/app-ux/hub_journey.py new file mode 100644 index 00000000..778724d2 --- /dev/null +++ b/tools/app-ux/hub_journey.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Read-only App Hub navigation, search, empty-state recovery and detail review.""" +import argparse +import json +import os +from pathlib import Path +import time +from capture import AppNative + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--binary", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--size", default="406x820") + parser.add_argument("--dark", action="store_true") + args = parser.parse_args() + out = args.output.resolve() + out.mkdir(parents=True, exist_ok=True) + os.environ.update(OCTOSENSE_APP_DATA=str(out / "state"), OCTOSENSE_PREVIEW_SIZE=args.size, + MAKEPAD_WIDGET_STYLE="ios-dark" if args.dark else "ios") + native = AppNative(args.binary.resolve(), [], out, "hub") + checks = [] + try: + native.label("App Hub") + time.sleep(1) + # Preview entries are local and have no install authority. + native.click("Live catalog") + native.wait(lambda: native.find(text="Preview catalog")) + native.click_row(native.find(identifier="apps")) + native.label("Apps") + checks.append("Browse the explicitly labelled preview catalog") + native.click_row(native.find(identifier="search_tab")) + native.field("search", "no-app-matches-this-ux-test") + native.label("No matching apps") + native.click("Clear filters") + native.field("search", "maps") + native.label("Maps") + checks.append("Search, reach empty results, clear filters, recover results") + native.click_row(native.reachable(text="Maps", kind="Label")) + native.wait(lambda: native.find(text="‹ Back")) + checks.append("Open app details and preserve a reachable Back action") + native.click("‹ Back") + native.click_row(native.find(identifier="library")) + checks.append("Navigate to Library without changing installed apps") + native.click_row(native.find(identifier="today")) + native.capture("hub-today") + native.check_logs() + (out / "receipt.json").write_text(json.dumps({"checks": checks, "physical_mobile": False, + "size": args.size, "dark": args.dark, "installed_apps_changed": False, + "actions": native.actions}, indent=2) + "\n") + print(json.dumps(checks)) + except Exception: + native.capture("failed-state") + raise + finally: + native.close() + + +if __name__ == "__main__": + main() diff --git a/tools/app-ux/mail_journey.py b/tools/app-ux/mail_journey.py new file mode 100644 index 00000000..d2468e1b --- /dev/null +++ b/tools/app-ux/mail_journey.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Test Mail using its explicit local demo transport, without sending mail.""" +import argparse +import json +import os +from pathlib import Path +import time +from capture import AppNative, ROOT + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument('--binary', type=Path, default=ROOT / 'target/release/octosense') + p.add_argument('--output', type=Path, required=True) + args = p.parse_args() + out = args.output.resolve() + out.mkdir(parents=True, exist_ok=True) + state = out / 'state' + os.environ.update(OCTOSENSE_HOME=str(state/'home'), OCTOSENSE_APP_DATA=str(state/'apps'), + OCTOS_APP_CORE_DIR=str(state/'core'), OCTOSENSE_LLM_VAULT='file', MAKEPAD_APP_CONFIG='{"mail_demo":true}') + native = AppNative(args.binary.resolve(), ['--test-action', 'phone:ios', '--test-action', 'launch-mail'], out, 'mail') + def field(key, text): + if key == 'password': + native.click_row(native.reachable(identifier=key, kind='TextInput')) + native.call('t', t=text, wait=1) + else: + native.field(key, text) + native.call('k', k='press', c='Escape', wait=1) + time.sleep(.3) + try: + native.label('Your inbox, with room to focus.') + time.sleep(.8) + native.click('Add account') + native.label('OctoSense · Add a mail account') + field('address', 'ux-test@example.com') + field('password', 'demo') + native.click('Sign in') + native.label('Dinner on Saturday?') + native.capture('mail-inbox') + native.click_row(native.reachable(text='Dinner on Saturday?', kind='Label')) + native.label('We are thinking of trying the new place') + native.capture('mail-reader') + native.click('Reply') + native.wait(lambda: native.find(identifier='c_body', kind='TextInput')) + field('c_body', 'UX test draft only.\n第二行:检查键盘和文字选择。') + native.capture('mail-draft') + native.click('Cancel') + native.label('Inbox') + native.click('Folders') + native.label('Folders') + native.capture('mail-folders') + native.check_logs() + (out/'receipt.json').write_text(json.dumps({'physical_mobile': False, 'transport': 'local demo', + 'checks': ['Host-owned account sheet', 'Inbox data and reader', 'Reply with multiline Unicode input', 'Cancel and folders'], + 'sent_messages': 0, 'actions': native.actions}, indent=2)+'\n') + print('PASS: local demo Mail account, inbox, read, reply draft, cancel, folders; nothing sent') + except Exception: + native.capture('failed-state') + raise + finally: + native.close() + + +if __name__ == '__main__': + main() diff --git a/tools/app-ux/news_journey.py b/tools/app-ux/news_journey.py new file mode 100644 index 00000000..8e406f9f --- /dev/null +++ b/tools/app-ux/news_journey.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Read-only feed browsing plus app-local saved-story persistence.""" +import argparse +import json +import os +from pathlib import Path +import time +from capture import AppNative, ROOT + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument('--binary', type=Path, default=ROOT/'target/release/octosense') + p.add_argument('--output', type=Path, required=True) + args = p.parse_args() + out = args.output.resolve() + out.mkdir(parents=True, exist_ok=True) + os.environ.update(OCTOSENSE_HOME=str(out/'home'), OCTOSENSE_APP_DATA=str(out/'apps'), OCTOS_APP_CORE_DIR=str(out/'core')) + title = None + for attempt in range(2): + a = AppNative(args.binary.resolve(), ['--test-action', 'phone:ios', '--test-action', 'launch-news'], out, 'news-'+str(attempt)) + try: + a.label('News') + time.sleep(1) + if attempt == 0: + row = a.wait(lambda: next((r for r in a.rows() if r.get('ty') == 'Label' and r.get('i') == 'title' and r.get('t')), None), timeout=40) + title = row['t'] + a.capture('news-feed') + a.click_row(row) + a.click('Save') + a.wait(lambda: a.find(text='Saved')) + a.click('‹ News') + a.click('Saved') + a.label(title) + a.capture('news-saved') + a.field('search', 'no-match-this-native-test') + a.call('k', k='press', c='Escape', wait=1) + time.sleep(.3) + assert not any(r.get('i') == 'title' and r.get('ty') == 'Label' for r in a.rows()) + a.label('No stories match your search.') + a.capture('news-empty-search') + else: + a.click('Saved') + a.label(title) + a.capture('news-restored') + a.check_logs() + except Exception: + a.capture('failed-state') + raise + finally: + a.close() + (out/'receipt.json').write_text(json.dumps({'checks': ['Live feed data', 'Open reader and save locally', 'Saved filter and empty search', 'Saved story retained after process restart'], 'physical_mobile': False, 'saved_title': title}, indent=2)+'\n') + print('PASS: feed, reader, saved state, search and restart') + + +if __name__ == '__main__': + main() diff --git a/tools/app-ux/providers_journey.py b/tools/app-ux/providers_journey.py new file mode 100644 index 00000000..42b2e21d --- /dev/null +++ b/tools/app-ux/providers_journey.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Exercise empty-provider guidance and cancel the real host-owned add sheet.""" +import argparse, json, os, time +from pathlib import Path +from capture import AppNative, ROOT + +p=argparse.ArgumentParser(description=__doc__) +p.add_argument('--output',type=Path,required=True) +a=p.parse_args(); out=a.output.resolve(); out.mkdir(parents=True,exist_ok=True) +os.environ.update(OCTOSENSE_HOME=str(out/'home'),OCTOSENSE_APP_DATA=str(out/'apps'), + OCTOS_APP_CORE_DIR=str(out/'core'),RINX_DATA_DIR=str(out/'rinx'),OCTOSENSE_LLM_VAULT='file') +n=AppNative(ROOT/'target/release/octosense',['--test-action','phone:ios','--test-action','launch-ai-providers'],out,'providers') +try: + n.label('Connect your first model'); time.sleep(.7) + assert not n.find(text='Talk to Octos') + assert not n.find(text='Show QR for phone') + assert n.find(text='Add model')['r'][3] >= 44 + n.capture('empty') + n.click('Add model'); n.wait(lambda:n.find(text='Cancel',identifier='-')) + n.capture('host-add-sheet') + n.click_row(n.find(text='Cancel',identifier='-')); n.label('Connect your first model') + assert not n.find(text='Show QR for phone') + n.capture('cancelled'); n.check_logs() + (out/'receipt.json').write_text(json.dumps({'status':'passed','checks':[ + 'Empty list has one reachable Add model primary action', + 'Unavailable chat and QR export are absent until a model exists', + 'Add model opens the real host-owned provider sheet', + 'Cancel returns without saving a provider'], 'physical_mobile':False,'provider_calls':False},indent=2)+'\n') + print('PASS: empty guidance, host-owned add sheet, cancel without mutation') +except Exception: + n.capture('failed-state'); raise +finally:n.close() diff --git a/tools/app-ux/retention_journey.py b/tools/app-ux/retention_journey.py new file mode 100644 index 00000000..e9b6ca42 --- /dev/null +++ b/tools/app-ux/retention_journey.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Keep six independent native Glance editors beyond the clean cache capacity.""" +import argparse, hashlib, json, os, time +from pathlib import Path +from capture import ROOT +from calendar_journey import CalendarNative + +p = argparse.ArgumentParser(description=__doc__) +p.add_argument('--output', type=Path, required=True) +a = p.parse_args() +out = a.output.resolve(); out.mkdir(parents=True, exist_ok=True) +state = out/'state' +os.environ.update(OCTOSENSE_HOME=str(state/'home'), OCTOSENSE_APP_DATA=str(state/'apps'), + OCTOS_APP_CORE_DIR=str(state/'core'), RINX_DATA_DIR=str(state/'rinx'), + OCTOSENSE_LLM_VAULT='file') +fixtures = [] +for i in range(6): + fixtures.append({'app': 'test.uxretention', 'args': { + 'card_id': 'workspace-'+str(i), 'title': 'Workspace '+str(i), + 'summary': 'Fictional local state check', 'priority': 100-i, 'notify': False, + 'viewport': True, + 'script': (ROOT/'apps/interface.splash').read_text() + '\nView{width: Fill height: Fill flow: Down padding: 20 spacing: 16 ' + 'UiTitle{text: "Workspace '+str(i)+'" draw_text.color: theme.color_text} ' + 'draft := UiField{width: Fill height: 120 is_multiline: true empty_text: "Unsent local draft"}}'}}) +fixture = out/'fixtures.json'; fixture.write_text(json.dumps(fixtures)) +n = CalendarNative(ROOT/'target/release/octosense', ['--test-action', 'glance-fixtures:'+str(fixture)], out, 'retention') +checks=[] +def key(c): n.call('k', k='press', c=c, wait=1) +def open_at(index): + key('F9'); time.sleep(.2) + for _ in range(12): key('ArrowUp') + for _ in range(index): key('ArrowDown') + key('ReturnKey') + n.wait(lambda: ('glance sheet: opened test.uxretention/workspace-'+str(index) in n.log_path.read_text() or 'glance workspace: resume cached test.uxretention/workspace-'+str(index) in n.log_path.read_text())) + time.sleep(.2) +try: + n.wait(lambda: 'glance fixture admission: Ok(6)' in n.log_path.read_text(), timeout=30) + key('F9') # A new publication auto-opens the pointer-only panel; close it first. + for i in range(6): + open_at(i) + n.call('click', x=700, y=265) + n.call('t', t='Keep workspace '+str(i)+' · 未发送\nIndependent local state') + n.capture('edited-'+str(i)); key('Escape') + for i in [0, 5, 1, 4, 2, 3]: + open_at(i) + # Custom Glance script children are not exposed by /snap. Original + # pixels must be visually reviewed; do not claim a machine text assertion. + n.capture('restored-'+str(i)); checks.append(i); key('Escape') + n.check_logs() + (out/'receipt.json').write_text(json.dumps({'status':'captured_for_visual_review','workspaces':checks, + 'inactive_workspaces':5, 'clean_cache_capacity':3, + 'fixture_sha256':hashlib.sha256(fixture.read_bytes()).hexdigest(), + 'model_calls':False,'physical_mobile':False,'external_actions':False, + 'checks':['Six distinct local editors retain Unicode multiline unsent input after visiting five other workspaces']},indent=2)+'\n') + print('CAPTURED: review all six restored editors against their distinct input') +except Exception: + n.capture('failed-state'); raise +finally: + n.close() diff --git a/tools/app-ux/theme_journey.py b/tools/app-ux/theme_journey.py new file mode 100644 index 00000000..690d1441 --- /dev/null +++ b/tools/app-ux/theme_journey.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Switch the live phone appearance with a calendar draft already being edited.""" +import argparse +import json +import os +from pathlib import Path +import time +from capture import AppNative, ROOT + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument('--binary', type=Path, default=ROOT/'target/release/octosense') + p.add_argument('--output', type=Path, required=True) + args = p.parse_args() + out = args.output.resolve() + out.mkdir(parents=True, exist_ok=True) + os.environ.update(OCTOSENSE_HOME=str(out/'home'), OCTOSENSE_APP_DATA=str(out/'apps'), OCTOS_APP_CORE_DIR=str(out/'core')) + a = AppNative(args.binary.resolve(), ['--test-action', 'phone:ios', '--test-action', 'launch-calendar'], out, 'theme') + title = 'Keep this unsaved draft' + try: + a.label('Calendar') + time.sleep(1) + a.click('+ Event') + a.field('e_title', title) + a.call('k', k='press', c='Escape', wait=1) + time.sleep(.3) + for mode in ['dark', 'light']: + a.call('click', x=332, y=16, wait=1) + time.sleep(1) + row = a.find(identifier='e_title', kind='TextInput') + assert row and row.get('t') == title, 'A theme change lost the visible unsaved editor' + a.capture('draft-' + mode) + a.click('Save') + a.label(title) + a.check_logs() + (out/'receipt.json').write_text(json.dumps({'checks': ['Unsaved editor and text retained through dark and light switches', 'Same draft saved after both switches'], 'physical_mobile': False}, indent=2)+'\n') + print('PASS: dark/light appearance switches retain the active editor and its unsaved draft') + except Exception: + a.capture('failed-state') + raise + finally: + a.close() + + +if __name__ == '__main__': + main() diff --git a/tools/runtime-patches/makepad-camera-permission-wait.patch b/tools/runtime-patches/makepad-camera-permission-wait.patch new file mode 100644 index 00000000..c1a9b4f2 --- /dev/null +++ b/tools/runtime-patches/makepad-camera-permission-wait.patch @@ -0,0 +1,65 @@ +diff --git a/widgets/src/camera_preview.rs b/widgets/src/camera_preview.rs +--- a/widgets/src/camera_preview.rs ++++ b/widgets/src/camera_preview.rs +@@ -185,6 +185,8 @@ + #[rust] + asked_at: f64, + #[rust] ++ permission_pending: bool, ++ #[rust] + timer: Option, + #[rust] + drawn: bool, +@@ -227,7 +229,8 @@ + return false; + } + self.wanted = true; +- if self.permission.is_none() { ++ if self.permission.is_none() && !self.permission_pending { ++ self.permission_pending = true; + self.asked_at = cx.seconds_since_app_start(); + cx.request_permission(Permission::Camera); + } +@@ -284,7 +287,12 @@ + self.fail(cx, format!("camera permission {status:?}")); + return; + } +- None if cx.seconds_since_app_start() - self.asked_at < PERMISSION_GRACE_SECONDS => return, ++ // Android always answers its permission dialog. Opening on ++ // a timeout while that dialog is still up creates a second ++ // request, then the hidden-preview watchdog stops a player ++ // that has not been prepared yet. Wait for the real answer. ++ None if cfg!(target_os = "android") ++ || cx.seconds_since_app_start() - self.asked_at < PERMISSION_GRACE_SECONDS => return, + None => self.permission = Some(PermissionStatus::Granted), + } + let Some(choice) = self.cameras.iter().find(|c| c.front == want_front).or(self.cameras.first()).cloned() else { +@@ -547,7 +555,13 @@ + } + match event { + Event::PermissionResult(result) if result.permission == Permission::Camera => { ++ self.permission_pending = false; + self.permission = Some(result.status); ++ if result.status == PermissionStatus::Granted && self.cameras.is_empty() { ++ // Enumeration before the first permission grant can be ++ // empty. Refresh it now that the device may be opened. ++ cx.refresh_video_inputs(); ++ } + self.drive(cx); + } + Event::VideoInputs(VideoInputsEvent { descs }) => { +diff --git a/widgets/src/video.rs b/widgets/src/video.rs +--- a/widgets/src/video.rs ++++ b/widgets/src/video.rs +@@ -1670,7 +1670,10 @@ + } + + fn pause_playback(&mut self, cx: &mut Cx) { +- if self.playback_state != PlaybackState::Paused { ++ // A permission dialog can pause the app before a camera has a source. ++ // Turning Unprepared into Paused makes Resume invent a Playing state, ++ // so the later source assignment and first prepare are both ignored. ++ if self.playback_state == PlaybackState::Playing { + cx.pause_video_playback(self.id); + self.playback_state = PlaybackState::Paused; + } diff --git a/tools/sync-app-interface.py b/tools/sync-app-interface.py new file mode 100644 index 00000000..dfab7707 --- /dev/null +++ b/tools/sync-app-interface.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Keep shipped Splash bundles self-contained with one shared interface prelude.""" +import argparse +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +START = "// BEGIN shared app interface\n" +END = "// END shared app interface\n" + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true") + args = parser.parse_args() + prelude = START + (ROOT / "apps/interface.splash").read_text() + END + stale = [] + for name in ("news", "photos", "mail", "calendar", "maps", "ai-providers", "youtube"): + path = ROOT / "apps" / name / "bundle/main.splash" + text = path.read_text() + body = text.split(END, 1)[1] if text.startswith(START) else text + expected = prelude + body + if text != expected: + stale.append(str(path.relative_to(ROOT))) + if not args.check: + path.write_text(expected) + if args.check and stale: + parser.exit(1, "Run tools/sync-app-interface.py: " + ", ".join(stale) + "\n") + + +if __name__ == "__main__": + main() From d3c74c2b92e951daf55c860d652d1da21b0771dd Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:56:09 -0700 Subject: [PATCH 2/3] Fix camera preview aspect and Android provider QR scanner --- apps/README.md | 21 +- apps/README.zh-CN.md | 13 +- runtime-patches.lock.json | 14 +- .../makepad-qr-preview-size.patch | 276 ++++++++++++++++++ .../makepad-video-fill-aspect.patch | 47 +++ 5 files changed, 361 insertions(+), 10 deletions(-) create mode 100644 tools/runtime-patches/makepad-qr-preview-size.patch create mode 100644 tools/runtime-patches/makepad-video-fill-aspect.patch diff --git a/apps/README.md b/apps/README.md index 4d37519f..ac874ee2 100644 --- a/apps/README.md +++ b/apps/README.md @@ -81,7 +81,12 @@ place. Maps switches its tiles with the host appearance. Camera controls use a dark scrim to remain readable over a bright preview; Android waits for the first-use permission answer before preparing that preview. A permission dialog pauses only a playing video; it cannot turn an unprepared camera into a fake -playing state when the app resumes. +playing state when the app resumes. The preview uses its resolved on-screen +size for aspect-preserving fill, including `Fill` layouts and resized windows; +a rotated camera frame is cropped at the edges instead of stretched. AI providers’ +Android QR scanner chooses a preview/YUV size advertised by the camera before +opening it; the preview keeps that aspect ratio, and callbacks from an earlier +scanner session cannot close a newly opened one. After editing the interface, run `python3 tools/sync-app-interface.py` from the repository root. It embeds the exact prelude in each contained bundle; @@ -119,10 +124,16 @@ reaches only the hosts the manifest lists. runtime). Playback opens YouTube's mobile watch page, which autoplays muted and shows its own "Open App" prompt. Search reads YouTube's results page and depends on its layout. -- **Camera**: on the OnePlus 6 test run (2026-09-25) Camera captured a photo - and released the camera in the background, but the live preview drew pure - black; unresolved. Desktop builds have no camera and the Android emulator - refuses one, so capture is untested elsewhere. +- **Camera**: the Redmi Note 12 test package (2026-10-07) passed delayed + first-use permission, front/back switching, Photo/Video preview selection and + background resume. The stretched `Fill` preview is corrected; native GPU + circle measurements pass, while a clear-scene visual comparison on the phone + is still pending. The OnePlus 6 black preview reported on 2026-09-25 has not + been retested. Still capture was not exercised in this run; Android video + recording remains unimplemented. +- **AI providers camera import**: the Redmi Note 12 scanner opens with supported + preview buffers and passed cancel, system Back, background interruption and + reopening. QR decoding and importing a provider were not exercised in this run. - **Photos Memories**: open **Memories → Create memories**, optionally entering a theme such as “summer with family.” The host's `model.complete` uses the provider configured in **Settings → AI providers** to curate up to three diff --git a/apps/README.zh-CN.md b/apps/README.zh-CN.md index a23edb6a..8803bf4a 100644 --- a/apps/README.zh-CN.md +++ b/apps/README.zh-CN.md @@ -70,6 +70,10 @@ OctoScript-App-Design-Flow 的 `AGENTS.md`,再读 `docs/QUICKSTART.md`), 固定操作栏保留原位。地图图块随宿主切换明暗外观。相机按钮使用深色底板, 在明亮取景画面上仍可阅读;Android 等待首次权限弹窗的结果后才准备预览。 权限弹窗只能暂停正在播放的视频,不能将尚未初始化的相机在返回后误标为正在播放。 +预览按布局完成后的实际显示尺寸等比填充,支持 `Fill` 和窗口缩放; +旋转后的相机画面裁切边缘,不再拉伸。AI providers 的 Android 扫码器 +先选择相机声明支持的预览/YUV 输出尺寸,再打开相机;显示时保持比例, +旧扫码会话的回调不会关闭新打开的扫码器。 修改后在仓库根目录运行 `python3 tools/sync-app-interface.py`,将共用样式 原样嵌入各个独立应用包;CI 通过 `python3 tools/sync-app-interface.py --check` @@ -100,9 +104,12 @@ OctoScript-App-Design-Flow 的 `AGENTS.md`,再读 `docs/QUICKSTART.md`), - **YouTube**:在 OnePlus 6 上测试(2026-09-27),搜索、结果、播放和播放记录都正常; 关闭播放器会结束页面(makepad#43,已在运行时中)。播放打开的是 YouTube 移动版观看页, 它会静音自动播放,并显示自己的“Open App”提示。搜索读取 YouTube 的搜索结果页,依赖其布局。 -- **Camera**:在 OnePlus 6 测试中(2026-09-25),Camera 能拍照并在后台释放 - 相机,但实时预览是纯黑的,尚未解决。桌面构建没有相机,Android 模拟器拒绝 - 提供相机,因此其他环境下拍摄未经测试。 +- **Camera**:Redmi Note 12 独立测试包(2026-10-07)已通过延迟首次授权、 + 前后镜头切换、Photo/Video 预览模式切换和后台恢复。`Fill` 预览拉伸已修正, + 原生 GPU 的圆形尺寸对比通过;手机明亮场景的外观对比仍待验证。 + 2026-09-25 报告的 OnePlus 6 黑屏尚未复测。本轮未测试拍照;Android 录像仍未实现。 +- **AI providers 相机导入**:Redmi Note 12 扫码器使用设备支持的预览尺寸, + 已通过打开、取消、系统返回、后台中断和重新打开;本轮未测试二维码解码及服务商导入。 - **Photos 回忆**:打开 **Memories → Create memories**,可选输入“summer with family”等主题。 宿主的 `model.complete` 使用 **设置 → AI providers** 中配置的服务商,整理最多三个 带标题、简短叙述和有序幻灯片的故事。只发送相片目录的元数据(日期、地点、姓名、 diff --git a/runtime-patches.lock.json b/runtime-patches.lock.json index 995c4a37..7ca508f5 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": "bc23ca0e6805e6c295c87335352d474e0001b0a9", + "tree": "566ae606dee20138d2e3173b48ec75c608a26919", "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.", @@ -17,7 +17,9 @@ "macOS AppKit pointer metadata enables native approval for HID events without a posting process. Synthetic and remote dispatch remain untrusted, including nested callbacks; Windows/Linux physical approval is unsupported.", "Android multiline Enter key events finish the active composition before inserting a newline, retaining the last word; ordinary IME composition commits keep replacement semantics.", "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." + "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." ], "stacked": [ { @@ -59,6 +61,14 @@ { "patch": "tools/runtime-patches/makepad-camera-permission-wait.patch", "sha256": "6b1c5998521dbf44e70bc8b90ab025d8f449a8e646c363585f3b172734d478c2" + }, + { + "patch": "tools/runtime-patches/makepad-video-fill-aspect.patch", + "sha256": "7e6db1f32b3d27c3bf30ec9acbdb85abd33539b619ba59370cbe7b8931a7d17d" + }, + { + "patch": "tools/runtime-patches/makepad-qr-preview-size.patch", + "sha256": "8866e751196dc1b33d8a4cc5de1872cd62999f4d565b1bc903327c3dd8dfa676" } ] } diff --git a/tools/runtime-patches/makepad-qr-preview-size.patch b/tools/runtime-patches/makepad-qr-preview-size.patch new file mode 100644 index 00000000..f954c061 --- /dev/null +++ b/tools/runtime-patches/makepad-qr-preview-size.patch @@ -0,0 +1,276 @@ +diff --git a/tools/cargo_makepad/src/android/java/dev/makepad/android/MakepadActivity.java b/tools/cargo_makepad/src/android/java/dev/makepad/android/MakepadActivity.java +index a7dd7524d..7b9f9e55e 100644 +--- a/tools/cargo_makepad/src/android/java/dev/makepad/android/MakepadActivity.java ++++ b/tools/cargo_makepad/src/android/java/dev/makepad/android/MakepadActivity.java +@@ -1269,6 +1269,7 @@ public class MakepadActivity + private HandlerThread mQrBgThread; + private Handler mQrBgHandler; + private volatile boolean mQrScanning = false; ++ private volatile int mQrGeneration = 0; + // A CAMERA request for the scanner is in flight (dedupes repeat requests). + private boolean mQrPermPending = false; + private long mQrLastFrameMs = 0; +@@ -3387,6 +3388,8 @@ public class MakepadActivity + private void startQrScanner() { + if (mQrScanning) return; + mQrScanning = true; ++ final int generation = ++mQrGeneration; ++ mQrLastFrameMs = 0; + final float d = getResources().getDisplayMetrics().density; + final SurfaceView sv = new SurfaceView(this); + mQrScanOverlay = new FrameLayout(this); +@@ -3416,11 +3419,54 @@ public class MakepadActivity + mQrBgHandler = new Handler(mQrBgThread.getLooper()); + + sv.getHolder().addCallback(new SurfaceHolder.Callback() { ++ private String cameraId; ++ private Size streamSize; ++ private boolean opening; ++ + @Override public void surfaceCreated(SurfaceHolder holder) { +- openQrCamera2(holder.getSurface()); ++ if (!mQrScanning || generation != mQrGeneration) return; ++ try { ++ CameraManager mgr = (CameraManager) getSystemService(Context.CAMERA_SERVICE); ++ String[] ids = mgr.getCameraIdList(); ++ for (String id : ids) { ++ Integer facing = mgr.getCameraCharacteristics(id).get(CameraCharacteristics.LENS_FACING); ++ if (facing != null && facing == CameraCharacteristics.LENS_FACING_BACK) { ++ cameraId = id; ++ break; ++ } ++ } ++ if (cameraId == null && ids.length > 0) cameraId = ids[0]; ++ if (cameraId == null) throw new IllegalStateException("No camera available"); ++ CameraCharacteristics characteristics = mgr.getCameraCharacteristics(cameraId); ++ streamSize = qrStreamSize(characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)); ++ if (streamSize == null) throw new IllegalStateException("No supported QR preview size"); ++ Integer sensor = characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION); ++ final int sensorOrientation = sensor == null ? 0 : sensor; ++ final Size size = streamSize; ++ mQrScanOverlay.addOnLayoutChangeListener(new View.OnLayoutChangeListener() { ++ @Override public void onLayoutChange(View view, int l, int t, int r, int b, ++ int oldL, int oldT, int oldR, int oldB) { ++ fitQrPreview(sv, size, sensorOrientation, r - l, b - t); ++ } ++ }); ++ fitQrPreview(sv, size, sensorOrientation, mQrScanOverlay.getWidth(), mQrScanOverlay.getHeight()); ++ // Camera2 requires a supported buffer size, not the phone's ++ // screen dimensions. Wait for surfaceChanged to confirm it. ++ holder.setFixedSize(size.getWidth(), size.getHeight()); ++ } catch (Exception e) { ++ android.util.Log.e("Makepad", "QR preview configuration failed", e); ++ closeQrScanner(generation, "camera_error"); ++ } ++ } ++ @Override public void surfaceChanged(SurfaceHolder holder, int fmt, int w, int h) { ++ if (!mQrScanning || generation != mQrGeneration || opening || streamSize == null ++ || w != streamSize.getWidth() || h != streamSize.getHeight()) return; ++ opening = true; ++ openQrCamera2(holder.getSurface(), cameraId, streamSize, generation); ++ } ++ @Override public void surfaceDestroyed(SurfaceHolder holder) { ++ closeQrScanner(generation, "interrupted"); + } +- @Override public void surfaceChanged(SurfaceHolder holder, int fmt, int w, int h) {} +- @Override public void surfaceDestroyed(SurfaceHolder holder) {} + }); + } + +@@ -3428,41 +3474,60 @@ public class MakepadActivity + // whose Y plane (luma) is streamed to Rust for a pure-Rust QR decode. Camera2 + // attributes via the app Context, unlike the deprecated `Camera` API (which is + // frame-blocked on some OEMs). +- private void openQrCamera2(final Surface previewSurface) { +- try { +- final CameraManager mgr = (CameraManager) getSystemService(Context.CAMERA_SERVICE); +- String backId = null; +- for (String id : mgr.getCameraIdList()) { +- Integer f = mgr.getCameraCharacteristics(id).get(CameraCharacteristics.LENS_FACING); +- if (f != null && f == CameraCharacteristics.LENS_FACING_BACK) { backId = id; break; } +- } +- if (backId == null) { +- String[] ids = mgr.getCameraIdList(); +- if (ids.length == 0) { closeQrScanner("camera_error"); return; } +- backId = ids[0]; +- } +- StreamConfigurationMap map = mgr.getCameraCharacteristics(backId) +- .get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP); +- Size chosen = new Size(640, 480); +- if (map != null) { +- Size[] sizes = map.getOutputSizes(ImageFormat.YUV_420_888); +- if (sizes != null) { +- for (Size s : sizes) { +- if (s.getWidth() <= 1280 && s.getWidth() * s.getHeight() +- > chosen.getWidth() * chosen.getHeight()) { +- chosen = s; +- } +- } ++ // Both outputs use a size actually advertised for their formats. Keep ++ // the pair within PREVIEW bounds, including on LIMITED camera devices. ++ private static Size qrStreamSize(StreamConfigurationMap map) { ++ if (map == null) return null; ++ Size[] yuv = map.getOutputSizes(ImageFormat.YUV_420_888); ++ Size[] preview = map.getOutputSizes(SurfaceHolder.class); ++ if (yuv == null || preview == null) return null; ++ Size best = null; ++ for (Size size : yuv) { ++ if (size.getWidth() > 1280 || size.getHeight() > 720) continue; ++ for (Size output : preview) { ++ if (size.equals(output) && (best == null ++ || (long) size.getWidth() * size.getHeight() > (long) best.getWidth() * best.getHeight())) { ++ best = size; + } + } +- mQrImageReader = ImageReader.newInstance( ++ } ++ return best; ++ } ++ ++ private void fitQrPreview(SurfaceView preview, Size buffer, int sensorOrientation, int width, int height) { ++ if (width <= 0 || height <= 0) return; ++ Display display = preview.getDisplay(); ++ int displayRotation = (display == null ? Surface.ROTATION_0 : display.getRotation()) * 90; ++ boolean swapped = ((sensorOrientation - displayRotation + 360) % 180) != 0; ++ int shownWidth = swapped ? buffer.getHeight() : buffer.getWidth(); ++ int shownHeight = swapped ? buffer.getWidth() : buffer.getHeight(); ++ float scale = Math.min((float) width / shownWidth, (float) height / shownHeight); ++ int fittedWidth = Math.round(shownWidth * scale); ++ int fittedHeight = Math.round(shownHeight * scale); ++ FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) preview.getLayoutParams(); ++ if (lp.width != fittedWidth || lp.height != fittedHeight || lp.gravity != Gravity.CENTER) { ++ preview.setLayoutParams(new FrameLayout.LayoutParams(fittedWidth, fittedHeight, Gravity.CENTER)); ++ } ++ } ++ ++ private void openQrCamera2(final Surface previewSurface, final String cameraId, ++ final Size chosen, final int generation) { ++ if (!mQrScanning || generation != mQrGeneration) return; ++ try { ++ final CameraManager mgr = (CameraManager) getSystemService(Context.CAMERA_SERVICE); ++ android.util.Log.i("Makepad", "QR preview buffer: " + chosen.getWidth() + "x" + chosen.getHeight()); ++ final Handler handler = mQrBgHandler; ++ final ImageReader imageReader = ImageReader.newInstance( + chosen.getWidth(), chosen.getHeight(), ImageFormat.YUV_420_888, 2); +- mQrImageReader.setOnImageAvailableListener(new ImageReader.OnImageAvailableListener() { ++ mQrImageReader = imageReader; ++ imageReader.setOnImageAvailableListener(new ImageReader.OnImageAvailableListener() { ++ private boolean completed; ++ + @Override public void onImageAvailable(ImageReader reader) { + Image img = null; + try { + img = reader.acquireLatestImage(); +- if (img == null || !mQrScanning) return; ++ if (img == null || completed || !mQrScanning || generation != mQrGeneration) return; + long now = System.currentTimeMillis(); + if (now - mQrLastFrameMs < 200) return; // ~5 fps + mQrLastFrameMs = now; +@@ -3481,8 +3546,8 @@ public class MakepadActivity + } + if (MakepadNative.onQrCameraFrame(luma, w, h)) { + // Rust already posted NativeQrScanned: close silently. +- mQrScanning = false; +- closeQrScanner(null); ++ completed = true; ++ closeQrScanner(generation, null); + } + } catch (Exception e) { + // transient frame errors are fine; keep scanning +@@ -3490,51 +3555,52 @@ public class MakepadActivity + if (img != null) img.close(); + } + } +- }, mQrBgHandler); ++ }, handler); + +- mgr.openCamera(backId, new CameraDevice.StateCallback() { ++ mgr.openCamera(cameraId, new CameraDevice.StateCallback() { + @Override public void onOpened(CameraDevice device) { ++ if (!mQrScanning || generation != mQrGeneration) { device.close(); return; } + mQrCameraDevice = device; + try { + final CaptureRequest.Builder rb = + device.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW); + rb.addTarget(previewSurface); +- rb.addTarget(mQrImageReader.getSurface()); ++ rb.addTarget(imageReader.getSurface()); + rb.set(CaptureRequest.CONTROL_AF_MODE, + CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE); + device.createCaptureSession( +- Arrays.asList(previewSurface, mQrImageReader.getSurface()), ++ Arrays.asList(previewSurface, imageReader.getSurface()), + new CameraCaptureSession.StateCallback() { + @Override public void onConfigured(CameraCaptureSession session) { +- if (mQrCameraDevice == null) return; ++ if (!mQrScanning || generation != mQrGeneration || mQrCameraDevice != device) { session.close(); return; } + mQrCaptureSession = session; +- try { session.setRepeatingRequest(rb.build(), null, mQrBgHandler); } +- catch (Exception e) { closeQrScanner("camera_error"); } ++ try { session.setRepeatingRequest(rb.build(), null, handler); } ++ catch (Exception e) { closeQrScanner(generation, "camera_error"); } + } + @Override public void onConfigureFailed(CameraCaptureSession s) { +- closeQrScanner("camera_error"); ++ closeQrScanner(generation, "camera_error"); + } +- }, mQrBgHandler); ++ }, handler); + } catch (Exception e) { + android.util.Log.e("Makepad", "QR camera2 session failed: " + e); +- closeQrScanner("camera_error"); ++ closeQrScanner(generation, "camera_error"); + } + } + @Override public void onDisconnected(CameraDevice device) { + // Another client took the camera (or it went away): the + // overlay would be a dead black screen, so close it. + device.close(); +- closeQrScanner("camera_error"); ++ closeQrScanner(generation, "camera_error"); + } + @Override public void onError(CameraDevice device, int error) { + android.util.Log.e("Makepad", "QR camera2 error: " + error); + device.close(); +- closeQrScanner("camera_error"); ++ closeQrScanner(generation, "camera_error"); + } +- }, mQrBgHandler); ++ }, handler); + } catch (Exception e) { + android.util.Log.e("Makepad", "QR camera2 open failed: " + e); +- closeQrScanner("camera_error"); ++ closeQrScanner(generation, "camera_error"); + } + } + +@@ -3548,7 +3614,15 @@ public class MakepadActivity + // A non-null `reason` is reported once via onQrCancelled if the scanner was + // still open; null closes silently (after a successful decode). + private void closeQrScanner(final String reason) { ++ closeQrScanner(mQrGeneration, reason); ++ } ++ ++ private void closeQrScanner(final int generation, final String reason) { + runOnUiThread(new Runnable() { public void run() { ++ if (generation != mQrGeneration) return; ++ // Removing the SurfaceView synchronously calls surfaceDestroyed. ++ // Retire this session first so teardown cannot re-enter removal. ++ ++mQrGeneration; + boolean wasScanning = mQrScanning; + mQrScanning = false; + if (wasScanning && reason != null) { +@@ -3565,9 +3639,10 @@ public class MakepadActivity + mQrBgThread = null; + mQrBgHandler = null; + } +- if (mQrScanOverlay != null && mRootLayout != null) { +- mRootLayout.removeView(mQrScanOverlay); +- mQrScanOverlay = null; ++ FrameLayout overlay = mQrScanOverlay; ++ mQrScanOverlay = null; ++ if (overlay != null && mRootLayout != null) { ++ mRootLayout.removeView(overlay); + } + }}); + } diff --git a/tools/runtime-patches/makepad-video-fill-aspect.patch b/tools/runtime-patches/makepad-video-fill-aspect.patch new file mode 100644 index 00000000..84b94603 --- /dev/null +++ b/tools/runtime-patches/makepad-video-fill-aspect.patch @@ -0,0 +1,47 @@ +diff --git a/widgets/src/video.rs b/widgets/src/video.rs +index 55574072b..67f028061 100644 +--- a/widgets/src/video.rs ++++ b/widgets/src/video.rs +@@ -65,7 +65,6 @@ + image_pan: instance(vec2(0.5, 0.5)) + + source_size: uniform(vec2(1.0, 1.0)) +- target_size: uniform(vec2(-1.0, -1.0)) + + // Apply SurfaceTexture ST matrix, then sample OES (see oes_st_c* comment). + sample_oes: fn(coord: vec2) -> vec4 { +@@ -162,9 +161,10 @@ + } + + get_color_scale_pan: fn() { +- // Early return for default scaling and panning, +- // used when walk size is not specified or non-fixed. +- if self.target_size.x <= 0.0 || self.target_size.y <= 0.0 { ++ // Use the resolved drawable, including Fill/Fit and resized ++ // layouts. A declared walk has no pixel size until layout; ++ // treating it as zero stretches the source across the view. ++ if self.rect_size.x <= 0.0 || self.rect_size.y <= 0.0 { + if self.show_thumbnail > 0.0 { + return self.thumbnail_texture.sample_as_bgra(self.pos).xyzw + } else if self.yuv_enabled > 0.5 { +@@ -183,7 +183,7 @@ + let rotated_odd = step(0.5, turns) * step(turns, 1.5) + step(2.5, turns) + let shown_size = mix(self.source_size, vec2(self.source_size.y, self.source_size.x), rotated_odd) + let source_aspect_ratio = shown_size.x / shown_size.y +- let target_aspect_ratio = self.target_size.x / self.target_size.y ++ let target_aspect_ratio = self.rect_size.x / self.rect_size.y + + // Adjust scale based on aspect ratio difference + if source_aspect_ratio != target_aspect_ratio { +@@ -1322,11 +1322,6 @@ fn apply_thumbnail_settings(&mut self, cx: &mut Cx) { + // with a fresh GPU texture containing the decoded image. Allocating a + // throwaway Texture::new(cx) first was just churning GPU resources. + +- let target_w = self.walk.width.to_fixed().unwrap_or(0.0); +- let target_h = self.walk.height.to_fixed().unwrap_or(0.0); +- self.draw_bg +- .set_uniform(cx, id!(target_size), &[target_w as f32, target_h as f32]); +- + if self.show_idle_thumbnail { + let loaded = self.load_thumbnail_image(cx); + // Only enable the thumbnail shader path if a thumbnail was actually loaded; From ee827153b30041be6734ff78258efa5d16f229c7 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 7 Oct 2026 01:13:14 -0700 Subject: [PATCH 3/3] Keep Photos logic tests independent of the shared widget prelude --- apps/photos/tests/memories.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/photos/tests/memories.rs b/apps/photos/tests/memories.rs index d9651708..535daa43 100644 --- a/apps/photos/tests/memories.rs +++ b/apps/photos/tests/memories.rs @@ -7,6 +7,7 @@ const STORY: &str = r#"{title: "By the water" summary: "A few days by the coast. // Only the environment is substituted. All Photos functions below run in // the pinned production VM, including asynchronous reply handling and storage. +// The host-owned theme/widget prelude is exercised by native UI tests. const ENV: &str = r#" use mod.std.assert let files = {} @@ -34,7 +35,8 @@ fn run(body: &str) -> Value { 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 logic = SCRIPT.split_once("\nstart_timeout(").expect("boot boundary").0; + let logic = SCRIPT.split_once("// END shared app interface\n").expect("shared interface").1 + .split_once("\nstart_timeout(").expect("boot boundary").0; let value = vm.with_instruction_limit(4_000_000, |vm| vm.eval(ScriptMod { file: "photos_memories_test.splash".into(), code: format!("{ENV}\n{logic}\n{body}\n;"),