diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml
index d69d4557..2cf4f6a6 100644
--- a/.github/workflows/desktop.yml
+++ b/.github/workflows/desktop.yml
@@ -12,6 +12,7 @@ on:
- 'crates/**'
- 'apps/**'
- 'Cargo.toml'
+ - 'native-apps.json'
- 'Cargo.lock'
- '.cargo/**'
- 'native-runtime.lock.json'
@@ -24,6 +25,7 @@ on:
- 'crates/**'
- 'apps/**'
- 'Cargo.toml'
+ - 'native-apps.json'
- 'Cargo.lock'
- '.cargo/**'
- 'native-runtime.lock.json'
@@ -80,3 +82,5 @@ jobs:
python3 -m unittest discover -s tools -p 'test_*.py'
- name: One Makepad, App Hub, octos and Rinx in the locked graph
run: python3 tools/setup.py --check --cargo
+ - name: The native apps' generated places match native-apps.json
+ run: python3 tools/native_apps.py --check
diff --git a/.github/workflows/phone.yml b/.github/workflows/phone.yml
index cc768b27..6364070f 100644
--- a/.github/workflows/phone.yml
+++ b/.github/workflows/phone.yml
@@ -13,6 +13,7 @@ on:
- 'crates/**'
- 'apps/**'
- 'Cargo.toml'
+ - 'native-apps.json'
- 'Cargo.lock'
- '.cargo/**'
- 'native-runtime.lock.json'
@@ -25,6 +26,7 @@ on:
- 'crates/**'
- 'apps/**'
- 'Cargo.toml'
+ - 'native-apps.json'
- 'Cargo.lock'
- '.cargo/**'
- 'native-runtime.lock.json'
@@ -61,7 +63,7 @@ jobs:
# default and mobile-apps builds link it, on desktop and Android.
cargo check --locked -p octosense-home
cargo check --locked -p octosense-home --features mobile-apps
- cargo check --locked -p octosense-home -p octosense-reference -p octosense-appcard -p octosense-news -p octosense-photos -p octosense-maps --features mobile-apps,app-appcard
+ cargo check --locked -p octosense-home -p octosense-reference -p octosense-appcard --features mobile-apps,app-appcard
# The graph guards every shell passes (tools/check-shell-graph.sh):
# the AI services (octosense-ai-host, the octos kernel, app-peers)
# in the default and mobile-apps graphs, host and Android; AppCard's
@@ -70,10 +72,12 @@ jobs:
bash ../tools/check-shell-graph.sh -p octosense-home
- name: Verify the single runtime graph
run: python3 tools/setup.py --check --cargo
+ - name: The native apps' generated places match native-apps.json
+ run: python3 tools/native_apps.py --check
- name: Build Home, App Hub admission and runtime policy tests
working-directory: phone
timeout-minutes: 60
- run: cargo test --locked --features mobile-apps -p octosense-shell -p octosense-home -p octosense-app-policy -p octosense-app-hub -p octosense-news -p octosense-appcard -p octosense-ai-host --no-run
+ run: cargo test --locked --features mobile-apps -p octosense-shell -p octosense-home -p octosense-app-policy -p octosense-app-hub -p octosense-appcard -p octosense-ai-host --no-run
- name: Test Home, App Hub admission and runtime policy
working-directory: phone
timeout-minutes: 30
@@ -81,11 +85,8 @@ jobs:
# octosense-app-hub-app is App Hub's shared shell crate, a git
# dependency with dev-dependencies: cargo cannot test it from here;
# OctoSense-App-Hub's CI tests it.
- cargo test --locked --features mobile-apps -p octosense-shell -p octosense-home -p octosense-app-policy -p octosense-app-hub -p octosense-news -p octosense-appcard -p octosense-ai-host
+ cargo test --locked --features mobile-apps -p octosense-shell -p octosense-home -p octosense-app-policy -p octosense-app-hub -p octosense-appcard -p octosense-ai-host
# The opt-in AppCard build: its module registers into the shell.
cargo test --locked --features mobile-apps,app-appcard -p octosense-shell appcard
- # Maps' isolate tests need init_cx_os(), whose macOS game-input setup
- # traps off the main thread, where libtest runs tests (RUNTIME-01).
- cargo test --locked -p octosense-maps -- --skip view::tests --skip module::tests
cargo test --locked -p makepad-widgets splash_policy
cargo test --locked -p makepad-script-std gate::tests
diff --git a/AGENTS.md b/AGENTS.md
index feff2547..9933bcc9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -20,7 +20,8 @@
| A system app | `apps//bundle/` | App Hub's `card-host --bundle apps//bundle --system`; then in a shell |
| A host service (`mail`, `llm`, `model`) | `apps/mail/host-service/`, `apps/ai-providers/` | `cargo test --locked -p octosense-mail-service -p octosense-llm-service` |
| AppCard (opt-in) | `apps/appcard/` | [apps/appcard/AGENTS.md](apps/appcard/AGENTS.md), `apps.yml` |
-| An external pin (Makepad, OctoScript, App Hub, octos, Rinx) | root `Cargo.toml` `[workspace.dependencies]`, `native-runtime.lock.json`, `runtime-patches.lock.json` | `python3 tools/setup.py --update`, then `--check --cargo` |
+| A native app (App Hub, Rinx, Terminal, Sheets, Reference, AppCard): its crate, pin, features, hosting per target, sandbox, storage and agent grants | `native-apps.json` only; `python3 tools/native_apps.py` writes the marked blocks in the `Cargo.toml`s, `crates/shell/src/native_apps.rs` and `Cargo.lock` | `python3 tools/native_apps.py --check`, `python3 -m unittest discover -s tools -p 'test_*.py'` |
+| An external pin (Makepad, OctoScript, App Hub, octos, Rinx) | root `Cargo.toml` `[workspace.dependencies]` (a native app's in `native-apps.json`), `native-runtime.lock.json`, `runtime-patches.lock.json` | `python3 tools/setup.py --update`, then `--check --cargo` and `python3 tools/native_apps.py --check` |
| A decision | `docs/adr/` (next free number) | — |
Start every session with `python3 tools/setup.py` (it prepares `.sources/`, and changes nothing that is already right).
diff --git a/Cargo.lock b/Cargo.lock
index 11f1173c..f54d479d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5329,10 +5329,6 @@ dependencies = [
"makepad-filesystem-watcher",
]
-[[package]]
-name = "makepad-map-nav"
-version = "0.1.0"
-
[[package]]
name = "makepad-markdown"
version = "0.1.0"
@@ -5548,6 +5544,19 @@ dependencies = [
"makepad-live-id",
]
+[[package]]
+name = "makepad-terminal"
+version = "0.1.0"
+dependencies = [
+ "makepad-ai-services",
+ "makepad-app-module",
+ "makepad-strict-json",
+ "makepad-widgets",
+ "makepad-wm-api",
+ "makepad-wm-theme",
+ "windows 0.62.2",
+]
+
[[package]]
name = "makepad-tsdf"
version = "0.1.0"
@@ -7446,33 +7455,6 @@ dependencies = [
"webpki-roots 1.0.9",
]
-[[package]]
-name = "octosense-maps"
-version = "0.1.0"
-dependencies = [
- "libc",
- "makepad-ai-services",
- "makepad-app-module",
- "makepad-map-nav",
- "makepad-widgets",
- "makepad-wm-api",
- "makepad-wm-theme",
- "reqwest 0.12.28",
- "rustls",
- "tokio",
-]
-
-[[package]]
-name = "octosense-news"
-version = "0.1.0"
-dependencies = [
- "makepad-ai-services",
- "makepad-app-module",
- "makepad-widgets",
- "makepad-wm-api",
- "makepad-wm-theme",
-]
-
[[package]]
name = "octosense-news-service"
version = "0.1.0"
@@ -7487,16 +7469,6 @@ dependencies = [
"url",
]
-[[package]]
-name = "octosense-photos"
-version = "0.1.0"
-dependencies = [
- "makepad-app-module",
- "makepad-widgets",
- "serde",
- "serde_json",
-]
-
[[package]]
name = "octosense-reference"
version = "0.1.0"
@@ -7517,6 +7489,7 @@ dependencies = [
"makepad-sheets",
"makepad-strict-json",
"makepad-studio-protocol",
+ "makepad-terminal",
"makepad-widgets",
"makepad-wm-api",
"makepad-wm-theme",
@@ -7527,10 +7500,7 @@ dependencies = [
"octosense-appcard",
"octosense-appstore",
"octosense-mail-service",
- "octosense-maps",
- "octosense-news",
"octosense-news-service",
- "octosense-photos",
"octosense-reference",
"rinx",
"serde",
diff --git a/Cargo.toml b/Cargo.toml
index ba2189fc..6dbbf7e7 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -27,9 +27,6 @@ members = [
"crates/shell",
"crates/toolbox",
"apps/reference",
- "apps/news/native",
- "apps/photos/native",
- "apps/maps/native",
"apps/news/host-service",
"apps/mail/host-service",
"apps/ai-providers/config",
@@ -57,6 +54,20 @@ license = "Apache-2.0"
authors = ["octos-app contributors"]
[workspace.dependencies]
+# --- The native apps, declared in native-apps.json (ADR 0004 §1): Rinx
+# (Matrix chats, Moments, articles, mini apps; the library as a hosted
+# module only, never its standalone entry or its own kernel, Rinx ADR 0007),
+# Terminal, App Hub (the store and the Card runner) and the opt-in Sheets,
+# Reference and AppCard. A git source shares its repository's revision with
+# the pins below; tools/native_apps.py writes this block and checks that.
+# BEGIN native-apps: pins (generated by tools/native_apps.py from native-apps.json; do not edit)
+rinx = { git = "https://github.com/hagency-org/Rinx.git", rev = "68afcf796d303aaf646eeb832d65c450a56c92b5", default-features = false, features = ["octosense-module"] }
+octosense-reference = { path = "apps/reference" }
+makepad-sheets = { git = "https://github.com/OctoSense-org/makepad.git", rev = "f08eec5378fa3f83902a2794dacda03e829a0ef0", default-features = false }
+makepad-terminal = { git = "https://github.com/OctoSense-org/makepad.git", rev = "f08eec5378fa3f83902a2794dacda03e829a0ef0" }
+octosense-appcard = { path = "apps/appcard/module" }
+octosense-app-hub-app = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "e8601b80ce104db2e48208094714bdcffdce6b5a" }
+# END native-apps: pins
# --- Makepad (OctoSense-org/makepad), the revision Octoscript-Makepad's
# runtime.json pins (native-runtime.lock.json); resolved to .sources/makepad.
makepad-widgets = { git = "https://github.com/OctoSense-org/makepad.git", rev = "f08eec5378fa3f83902a2794dacda03e829a0ef0" }
@@ -69,8 +80,6 @@ makepad-ai-services = { git = "https://github.com/OctoSense-org/makepad.git", re
makepad-strict-json = { git = "https://github.com/OctoSense-org/makepad.git", rev = "f08eec5378fa3f83902a2794dacda03e829a0ef0" }
makepad-app-module = { git = "https://github.com/OctoSense-org/makepad.git", rev = "f08eec5378fa3f83902a2794dacda03e829a0ef0" }
makepad-wm-theme = { git = "https://github.com/OctoSense-org/makepad.git", rev = "f08eec5378fa3f83902a2794dacda03e829a0ef0" }
-makepad-map-nav = { git = "https://github.com/OctoSense-org/makepad.git", rev = "f08eec5378fa3f83902a2794dacda03e829a0ef0" }
-makepad-sheets = { git = "https://github.com/OctoSense-org/makepad.git", rev = "f08eec5378fa3f83902a2794dacda03e829a0ef0", default-features = false }
makepad-aichat = { git = "https://github.com/OctoSense-org/makepad.git", rev = "f08eec5378fa3f83902a2794dacda03e829a0ef0", default-features = false }
# --- OctoScript (Octoscript 68f6a9df) and its Makepad runtime
# (Octoscript-Makepad 65d30a09, native-runtime.lock.json).
@@ -82,9 +91,8 @@ octoscript-capabilities = { git = "https://github.com/OctoSense-org/Octoscript.g
octoscript-schema = { git = "https://github.com/OctoSense-org/Octoscript.git", rev = "68f6a9df55692b5d8ef8873a12721e279a3f40d6" }
octoscript-makepad = { git = "https://github.com/OctoSense-org/Octoscript-Makepad.git", rev = "5fc3681026247761d2e2e16e15b0c4dff3a1c521" }
octoscript-node = { git = "https://github.com/OctoSense-org/Octoscript-Makepad.git", rev = "5fc3681026247761d2e2e16e15b0c4dff3a1c521" }
-# --- App Hub: the shells' store/Card runner/system apps crate and the host
-# service registry the Mail and llm services register into. One revision.
-octosense-app-hub-app = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "e8601b80ce104db2e48208094714bdcffdce6b5a" }
+# --- 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 = "e8601b80ce104db2e48208094714bdcffdce6b5a" }
# --- octos: ONE source for every octos crate (octos-org/octos main at the
# merge of octos#2601, the `serve --host-managed` follow-ups). Move octos-core, octos-cli
@@ -96,9 +104,6 @@ octos-cli = { git = "https://github.com/octos-org/octos.git", rev = "5e7577f0cb9
# toolbox scope) for the toolbox's `octos-engine` feature. Same source and
# revision as above.
octos-research = { git = "https://github.com/octos-org/octos.git", rev = "5e7577f0cb92cf618b965745434b0200e2b82865", default-features = false }
-# --- Rinx (Matrix chats, Moments, articles, mini apps) as a hosted module:
-# the library only, never its standalone entry or its own kernel (Rinx ADR 0007).
-rinx = { git = "https://github.com/hagency-org/Rinx.git", rev = "68afcf796d303aaf646eeb832d65c450a56c92b5", default-features = false, features = ["octosense-module"] }
# --- AppCard's inline renderers.
streaming-markdown-kit = { git = "https://github.com/Project-Robius-China/streaming-markdown-kit.git", branch = "main" }
makepad-diagram-kit = { git = "https://github.com/Project-Robius-China/makepad-diagram-kit.git", branch = "main" }
@@ -114,11 +119,6 @@ octosense-llm-config = { path = "apps/ai-providers/config" }
octosense-llm-service = { path = "apps/ai-providers/host-service" }
octosense-mail-service = { path = "apps/mail/host-service" }
octosense-news-service = { path = "apps/news/host-service" }
-octosense-reference = { path = "apps/reference" }
-octosense-appcard = { path = "apps/appcard/module" }
-octosense-news = { path = "apps/news/native" }
-octosense-photos = { path = "apps/photos/native" }
-octosense-maps = { path = "apps/maps/native" }
octos-app = { path = "apps/appcard/app/app", default-features = false }
octos-app-store = { path = "apps/appcard/app/crates/octos-app-store" }
octos-app-transport = { path = "apps/appcard/app/crates/octos-app-transport" }
@@ -197,12 +197,15 @@ makepad-ai-services = { path = ".sources/makepad/libs/ai/services" }
makepad-strict-json = { path = ".sources/makepad/libs/strict_json" }
makepad-app-module = { path = ".sources/makepad/libs/app_module" }
makepad-wm-theme = { path = ".sources/makepad/libs/wm_theme" }
-makepad-map-nav = { path = ".sources/makepad/libs/map_nav" }
-makepad-sheets = { path = ".sources/makepad/apps/sheets" }
makepad-aichat = { path = ".sources/makepad/apps/aichat" }
# Rinx's article editor: the Markdown and math components.
makepad-markdown = { path = ".sources/makepad/libs/markdown" }
makepad-markdown-widgets = { path = ".sources/makepad/libs/markdown_widgets" }
+# The native apps from Makepad (native-apps.json `source.local`).
+# BEGIN native-apps: patch https://github.com/OctoSense-org/makepad.git (generated by tools/native_apps.py from native-apps.json; do not edit)
+makepad-sheets = { path = ".sources/makepad/apps/sheets" }
+makepad-terminal = { path = ".sources/makepad/apps/terminal" }
+# END native-apps: patch https://github.com/OctoSense-org/makepad.git
# AppCard's inline-renderer kits name makepad-widgets by the ZhangHanDong URL.
[patch."https://github.com/ZhangHanDong/makepad.git"]
diff --git a/apps/README.md b/apps/README.md
index 4c106e6f..afc7f222 100644
--- a/apps/README.md
+++ b/apps/README.md
@@ -25,11 +25,12 @@ them. They live in `apps/` of the [OctoSense repository](../README.md); until
assistant, a Rust module (`octos-app`) that the shells link in-process and
that runs on the shell's octos kernel. It is **opt-in**: both shells link
it only with `--features app-appcard`, and it is not shipped by default.
-- **Native comparison modules** (`news/native`, `photos/native`,
- `maps/native`) and **Reference** (`reference`): Rust modules Home links
- behind features (`app-news`, `app-photos`, `app-maps`, `app-reference`);
- the desktop links only Reference this way (its `app-photos` is Makepad's
- own Photos module).
+- **Reference** (`reference`): a Rust module the shells link behind
+ `app-reference` (always on phones). Every native app the shells link is
+ declared in [`../native-apps.json`](../native-apps.json). News, Photos and
+ Maps are script apps only: their earlier native modules were deleted
+ (native-apps ADR 0004 §1, [#110](https://github.com/OctoSense-org/OctoSense/pull/110)). Photos' sample library, which Home mounts, is in
+ `photos/resources/`.
The shell services these apps rely on are next door:
[`../crates/kernel`](../crates/kernel) (the octos kernel service, see
@@ -77,7 +78,7 @@ host's mail service, `llm` reaches the host's LLM-provider service. `net` reache
- **Photos**: the bundle ships only 75 thumbnails (`bundle/thumbs/`, about
2 MB). The full-size files the viewer shows are served at
`{{assets}}/photos/...` only when a shell mounts them: Home mounts
- `photos/native/resources/photos` (about 87 MB, `phone/system-apps.json`);
+ `photos/resources/photos` (about 87 MB, `phone/system-apps.json`);
the desktop mounts nothing (`desktop/system-apps.json`), so the viewer has
no full-size image there.
- **Maps**: on the OnePlus 6 (2026-09-27) search, place, route, adding and
@@ -120,7 +121,7 @@ standalone launcher and ROM image). Each packaging:
"schema": 1,
"source": "../apps",
"apps": ["news", "photos", "maps", "camera", "mail", "ai-providers"],
- "assets": { "photos": { "photos": "../apps/photos/native/resources/photos" } }
+ "assets": { "photos": { "photos": "../apps/photos/resources/photos" } }
}
```
diff --git a/apps/README.zh-CN.md b/apps/README.zh-CN.md
index c2a2dc7d..bf110d2e 100644
--- a/apps/README.zh-CN.md
+++ b/apps/README.zh-CN.md
@@ -23,10 +23,11 @@ OctoSense-System-Apps 仓库(已归档)。
一个由 Shell 进程内链接的 Rust 模块(`octos-app`),运行在 Shell 的
octos 内核之上。它**需显式启用**:两个 Shell 只有在使用 `--features app-appcard`
时才链接它,默认不随产品发布。
-- **原生对比模块**(`news/native`、`photos/native`、`maps/native`)和
- **Reference**(`reference`):Home 通过 feature(`app-news`、`app-photos`、
- `app-maps`、`app-reference`)链接的 Rust 模块;桌面端只以这种方式链接
- Reference(它的 `app-photos` 是 Makepad 自带的 Photos 模块)。
+- **Reference**(`reference`):Shell 通过 `app-reference` 链接的 Rust
+ 模块(手机上始终链接)。Shell 链接的所有原生应用都在
+ [`../native-apps.json`](../native-apps.json) 中声明。News、Photos 和 Maps
+ 只有脚本应用版本:早期的原生模块已删除(原生应用 ADR 0004 §1,[#110](https://github.com/OctoSense-org/OctoSense/pull/110))。Home 挂载的
+ Photos 示例图库位于 `photos/resources/`。
这些应用依赖的 Shell 服务就在旁边:
[`../crates/kernel`](../crates/kernel)(octos 内核服务,见 [octos 内核](#octos-内核))和
@@ -68,7 +69,7 @@ OctoScript-App-Design-Flow 的 `AGENTS.md`,再读 `docs/QUICKSTART.md`),
提供相机,因此其他环境下拍摄未经测试。
- **Photos**:bundle 只带 75 张缩略图(`bundle/thumbs/`,约 2 MB)。查看器
显示的原图只有在 Shell 挂载后才会出现在 `{{assets}}/photos/...`:Home
- 挂载 `photos/native/resources/photos`(约 87 MB,见 `phone/system-apps.json`);
+ 挂载 `photos/resources/photos`(约 87 MB,见 `phone/system-apps.json`);
桌面端不挂载任何目录(`desktop/system-apps.json`),所以那里的查看器没有原图。
- **Maps**:在 OnePlus 6 上(2026-09-27)搜索、地点详情、路线、添加和移除途经点、
逐向导航驾驶以及 2D 视图都正常。3D 驾驶视图会画出路线但没有地图瓦片,手机和桌面
@@ -102,7 +103,7 @@ OctoScript-App-Design-Flow 的 `AGENTS.md`,再读 `docs/QUICKSTART.md`),
"schema": 1,
"source": "../apps",
"apps": ["news", "photos", "maps", "camera", "mail", "ai-providers"],
- "assets": { "photos": { "photos": "../apps/photos/native/resources/photos" } }
+ "assets": { "photos": { "photos": "../apps/photos/resources/photos" } }
}
```
diff --git a/apps/maps/native/Cargo.toml b/apps/maps/native/Cargo.toml
deleted file mode 100644
index 4194a629..00000000
--- a/apps/maps/native/Cargo.toml
+++ /dev/null
@@ -1,31 +0,0 @@
-[package]
-name = "octosense-maps"
-version = "0.1.0"
-edition = "2021"
-license = "Apache-2.0"
-publish = false
-
-[dependencies]
-# The arrival time is the C library's local time (`localtime_r`), as the
-# shell's clock is.
-libc = "0.2"
-# `maps`: the MapView vector-tile renderer and its archive readers.
-makepad-widgets = { workspace = true, features = ["maps"] }
-makepad-app-module = { workspace = true }
-makepad-wm-theme = { workspace = true }
-makepad-wm-api = { workspace = true }
-# The executor half of the module contract: its manifest and call types.
-makepad-ai-services = { workspace = true }
-# The route, its maneuvers and the session that map-matches a position onto
-# it. No dependencies of its own; the routes come from OSRM (routing.rs).
-makepad-map-nav = { workspace = true }
-
-# Android 9's Java TLS provider cannot reach TLS-1.3-only routing servers.
-[target.'cfg(target_os = "android")'.dependencies]
-reqwest = { version = "0.12.28", default-features = false, features = ["rustls-tls-webpki-roots"] }
-tokio = { version = "1.53.1", features = ["rt", "net", "sync", "time", "macros"] }
-
-[dev-dependencies]
-reqwest = { version = "0.12.28", default-features = false, features = ["rustls-tls-webpki-roots"] }
-tokio = { version = "1.53.1", features = ["rt", "net", "sync", "time", "macros"] }
-rustls = { version = "0.23.44", default-features = false, features = ["ring", "std"] }
diff --git a/apps/maps/native/resources/icons/arrive.svg b/apps/maps/native/resources/icons/arrive.svg
deleted file mode 100644
index 775f7a68..00000000
--- a/apps/maps/native/resources/icons/arrive.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/maps/native/resources/icons/back.svg b/apps/maps/native/resources/icons/back.svg
deleted file mode 100644
index 7c6c6446..00000000
--- a/apps/maps/native/resources/icons/back.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/maps/native/resources/icons/bike.svg b/apps/maps/native/resources/icons/bike.svg
deleted file mode 100644
index 9b46889d..00000000
--- a/apps/maps/native/resources/icons/bike.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/maps/native/resources/icons/car.svg b/apps/maps/native/resources/icons/car.svg
deleted file mode 100644
index 510a42a4..00000000
--- a/apps/maps/native/resources/icons/car.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/maps/native/resources/icons/close.svg b/apps/maps/native/resources/icons/close.svg
deleted file mode 100644
index d9334a21..00000000
--- a/apps/maps/native/resources/icons/close.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/maps/native/resources/icons/compass.svg b/apps/maps/native/resources/icons/compass.svg
deleted file mode 100644
index d43dcd75..00000000
--- a/apps/maps/native/resources/icons/compass.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/maps/native/resources/icons/depart.svg b/apps/maps/native/resources/icons/depart.svg
deleted file mode 100644
index 14b5ecfe..00000000
--- a/apps/maps/native/resources/icons/depart.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/maps/native/resources/icons/directions.svg b/apps/maps/native/resources/icons/directions.svg
deleted file mode 100644
index 229eb783..00000000
--- a/apps/maps/native/resources/icons/directions.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/maps/native/resources/icons/food.svg b/apps/maps/native/resources/icons/food.svg
deleted file mode 100644
index 7efddddc..00000000
--- a/apps/maps/native/resources/icons/food.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/maps/native/resources/icons/layers.svg b/apps/maps/native/resources/icons/layers.svg
deleted file mode 100644
index 070cddda..00000000
--- a/apps/maps/native/resources/icons/layers.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/maps/native/resources/icons/locate-active.svg b/apps/maps/native/resources/icons/locate-active.svg
deleted file mode 100644
index 74dab5fb..00000000
--- a/apps/maps/native/resources/icons/locate-active.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/maps/native/resources/icons/locate.svg b/apps/maps/native/resources/icons/locate.svg
deleted file mode 100644
index 55ef4c64..00000000
--- a/apps/maps/native/resources/icons/locate.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/maps/native/resources/icons/lodging.svg b/apps/maps/native/resources/icons/lodging.svg
deleted file mode 100644
index 2125ec17..00000000
--- a/apps/maps/native/resources/icons/lodging.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/maps/native/resources/icons/place.svg b/apps/maps/native/resources/icons/place.svg
deleted file mode 100644
index 1075d053..00000000
--- a/apps/maps/native/resources/icons/place.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/maps/native/resources/icons/recenter.svg b/apps/maps/native/resources/icons/recenter.svg
deleted file mode 100644
index e1fe27fe..00000000
--- a/apps/maps/native/resources/icons/recenter.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/maps/native/resources/icons/roundabout.svg b/apps/maps/native/resources/icons/roundabout.svg
deleted file mode 100644
index 61057ae7..00000000
--- a/apps/maps/native/resources/icons/roundabout.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/maps/native/resources/icons/search.svg b/apps/maps/native/resources/icons/search.svg
deleted file mode 100644
index a333fc6b..00000000
--- a/apps/maps/native/resources/icons/search.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/maps/native/resources/icons/sharp-left.svg b/apps/maps/native/resources/icons/sharp-left.svg
deleted file mode 100644
index c489ad87..00000000
--- a/apps/maps/native/resources/icons/sharp-left.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/maps/native/resources/icons/sharp-right.svg b/apps/maps/native/resources/icons/sharp-right.svg
deleted file mode 100644
index 192807c1..00000000
--- a/apps/maps/native/resources/icons/sharp-right.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/maps/native/resources/icons/shop.svg b/apps/maps/native/resources/icons/shop.svg
deleted file mode 100644
index 37e987cd..00000000
--- a/apps/maps/native/resources/icons/shop.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/maps/native/resources/icons/slight-left.svg b/apps/maps/native/resources/icons/slight-left.svg
deleted file mode 100644
index 9e633157..00000000
--- a/apps/maps/native/resources/icons/slight-left.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/maps/native/resources/icons/slight-right.svg b/apps/maps/native/resources/icons/slight-right.svg
deleted file mode 100644
index 68ca113e..00000000
--- a/apps/maps/native/resources/icons/slight-right.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/maps/native/resources/icons/straight.svg b/apps/maps/native/resources/icons/straight.svg
deleted file mode 100644
index 7476e7d5..00000000
--- a/apps/maps/native/resources/icons/straight.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/maps/native/resources/icons/street.svg b/apps/maps/native/resources/icons/street.svg
deleted file mode 100644
index bfefffbb..00000000
--- a/apps/maps/native/resources/icons/street.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/maps/native/resources/icons/swap.svg b/apps/maps/native/resources/icons/swap.svg
deleted file mode 100644
index 1b7e5d12..00000000
--- a/apps/maps/native/resources/icons/swap.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/maps/native/resources/icons/transit.svg b/apps/maps/native/resources/icons/transit.svg
deleted file mode 100644
index bfdbe008..00000000
--- a/apps/maps/native/resources/icons/transit.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/maps/native/resources/icons/turn-left.svg b/apps/maps/native/resources/icons/turn-left.svg
deleted file mode 100644
index b406cc4c..00000000
--- a/apps/maps/native/resources/icons/turn-left.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/maps/native/resources/icons/turn-right.svg b/apps/maps/native/resources/icons/turn-right.svg
deleted file mode 100644
index 465ac209..00000000
--- a/apps/maps/native/resources/icons/turn-right.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/maps/native/resources/icons/uturn.svg b/apps/maps/native/resources/icons/uturn.svg
deleted file mode 100644
index 242e68fa..00000000
--- a/apps/maps/native/resources/icons/uturn.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/maps/native/resources/icons/walk.svg b/apps/maps/native/resources/icons/walk.svg
deleted file mode 100644
index fe6fbc58..00000000
--- a/apps/maps/native/resources/icons/walk.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/maps/native/src/geo.rs b/apps/maps/native/src/geo.rs
deleted file mode 100644
index 7439aa68..00000000
--- a/apps/maps/native/src/geo.rs
+++ /dev/null
@@ -1,478 +0,0 @@
-//! Distances and bearings (the framework's), the polyline decoder, the
-//! camera that fits a box into the part of the viewport the panels leave
-//! free, and distance and duration text in metric and imperial.
-
-pub use makepad_map_nav::geo::{
- bearing_deg, cumulative_distances, haversine_m, lon_lat_to_norm, norm_to_lon_lat, LonLat,
-};
-
-/// `MapView`'s world is this many pixels across at zoom 0 and doubles per
-/// zoom level (`widgets/src/map/geometry.rs`, `TILE_SIZE`).
-const WORLD_PX_AT_ZOOM_0: f64 = 256.0;
-
-/// Decode a polyline5 string (Google's encoding, OSRM's `geometries=polyline`)
-/// into points. The encoding is latitude first; the points are not.
-pub fn decode_polyline5(encoded: &str) -> Vec {
- let mut bytes = encoded.bytes();
- let mut points = Vec::with_capacity(encoded.len() / 4);
- let (mut lat, mut lon) = (0i64, 0i64);
- // A torn tail ends the line at the last whole point.
- while let (Some(dlat), Some(dlon)) = (next_delta(&mut bytes), next_delta(&mut bytes)) {
- // A sum that leaves the type ends the line, as a torn tail does.
- let (Some(next_lat), Some(next_lon)) = (lat.checked_add(dlat), lon.checked_add(dlon))
- else {
- break;
- };
- (lat, lon) = (next_lat, next_lon);
- points.push(LonLat::new(lon as f64 * 1e-5, lat as f64 * 1e-5));
- }
- points
-}
-
-/// One zigzag varint of the encoding: five bits a byte, low group first,
-/// bit 5 set on all but the last. `None` when the bytes run out first.
-fn next_delta(bytes: &mut impl Iterator- ) -> Option {
- let (mut value, mut shift) = (0i64, 0u32);
- loop {
- let group = i64::from(bytes.next()?.checked_sub(63)?);
- value |= (group & 0x1f) << shift;
- shift += 5;
- if group < 0x20 {
- break;
- }
- // The whole globe is 26 bits of fifth decimals; nothing the
- // services send is longer than this, and a hostile string is.
- if shift > 35 {
- return None;
- }
- }
- Some(if value & 1 == 1 {
- !(value >> 1)
- } else {
- value >> 1
- })
-}
-
-/// A box in degrees.
-#[derive(Clone, Copy, Debug, PartialEq)]
-pub struct Bounds {
- pub west: f64,
- pub south: f64,
- pub east: f64,
- pub north: f64,
-}
-
-impl Bounds {
- /// The box around `points`; `None` for none.
- pub fn of(points: &[LonLat]) -> Option {
- let first = points.first()?;
- let start = Bounds {
- west: first.lon,
- south: first.lat,
- east: first.lon,
- north: first.lat,
- };
- Some(points.iter().fold(start, |b, p| Bounds {
- west: b.west.min(p.lon),
- south: b.south.min(p.lat),
- east: b.east.max(p.lon),
- north: b.north.max(p.lat),
- }))
- }
-
- pub fn center(&self) -> LonLat {
- LonLat::new(
- (self.west + self.east) * 0.5,
- (self.south + self.north) * 0.5,
- )
- }
-}
-
-/// The parts of the viewport something else covers, in pixels.
-#[derive(Clone, Copy, Debug, Default, PartialEq)]
-pub struct Insets {
- pub top: f64,
- pub bottom: f64,
- pub left: f64,
- pub right: f64,
-}
-
-#[derive(Clone, Copy, Debug, PartialEq)]
-pub struct Camera {
- pub center: LonLat,
- pub zoom: f64,
-}
-
-/// The flat, north-up camera that shows `bounds` in the room `insets` leave
-/// free in a `viewport` of (width, height) pixels, zoomed in no further than
-/// `max_zoom`. The centre is shifted so the box sits in the middle of the
-/// free room rather than the middle of the viewport.
-pub fn fit_camera(bounds: &Bounds, viewport: (f64, f64), insets: &Insets, max_zoom: f64) -> Camera {
- // Mercator, where the map is linear: north has the smaller y.
- let (west, north) = lon_lat_to_norm(LonLat::new(bounds.west, bounds.north));
- let (east, south) = lon_lat_to_norm(LonLat::new(bounds.east, bounds.south));
- // Panels that cover everything still leave a pixel, so the zoom stays a number.
- let free_w = (viewport.0 - insets.left - insets.right).max(1.0);
- let free_h = (viewport.1 - insets.top - insets.bottom).max(1.0);
- // A span of nothing (one point) fits at any zoom: the cap decides.
- let zoom_for = |free: f64, span: f64| {
- if span > 0.0 {
- (free / (WORLD_PX_AT_ZOOM_0 * span)).log2()
- } else {
- f64::INFINITY
- }
- };
- let zoom = zoom_for(free_w, east - west)
- .min(zoom_for(free_h, south - north))
- .clamp(0.0, max_zoom);
- // The free room's middle is off the viewport's by half the difference
- // of the opposite insets; the camera moves the other way by as much.
- let world = WORLD_PX_AT_ZOOM_0 * 2f64.powf(zoom);
- let shift_x = (insets.left - insets.right) * 0.5 / world;
- let shift_y = (insets.top - insets.bottom) * 0.5 / world;
- Camera {
- center: norm_to_lon_lat(
- (west + east) * 0.5 - shift_x,
- (north + south) * 0.5 - shift_y,
- ),
- zoom,
- }
-}
-
-#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
-pub enum Units {
- #[default]
- Metric,
- Imperial,
-}
-
-impl Units {
- /// What road signs use in the country with this ISO 3166 alpha-2 code.
- pub fn for_country(code: &str) -> Units {
- match code.to_ascii_uppercase().as_str() {
- "US" | "GB" | "LR" | "MM" => Units::Imperial,
- _ => Units::Metric,
- }
- }
-}
-
-/// `350 m`, `2.4 km`, `12 km`; `500 ft`, `0.3 mi`, `12 mi`.
-pub fn distance_text(meters: f64, units: Units) -> String {
- let meters = meters.max(0.0);
- match units {
- Units::Metric => {
- let rounded = round_to(meters, if meters < 100.0 { 5.0 } else { 10.0 });
- if rounded < 1_000.0 {
- format!("{rounded:.0} m")
- } else {
- large_text(meters / 1_000.0, "km")
- }
- }
- Units::Imperial => {
- let miles = meters / METERS_PER_MILE;
- if miles < 0.1 {
- let feet = meters * FEET_PER_METER;
- format!(
- "{:.0} ft",
- round_to(feet, if feet < 100.0 { 10.0 } else { 50.0 })
- )
- } else {
- large_text(miles, "mi")
- }
- }
- }
-}
-
-const FEET_PER_METER: f64 = 3.280_84;
-const METERS_PER_MILE: f64 = 1_609.344;
-
-fn round_to(value: f64, step: f64) -> f64 {
- (value / step).round() * step
-}
-
-/// Kilometres or miles: a decimal under ten, whole numbers from there.
-fn large_text(value: f64, unit: &str) -> String {
- // Rounded first, so 9.96 reads `10`, not `10.0`.
- let tenths = (value * 10.0).round() / 10.0;
- if tenths < 10.0 {
- format!("{tenths:.1} {unit}")
- } else {
- format!("{value:.0} {unit}")
- }
-}
-
-/// `1 min`, `8 min`, `1 hr`, `1 hr 44 min`.
-pub fn duration_text(seconds: f64) -> String {
- let minutes = whole_minutes(seconds).max(1);
- match (minutes / 60, minutes % 60) {
- (0, m) => format!("{m} min"),
- (h, 0) => format!("{h} hr"),
- (h, m) => format!("{h} hr {m} min"),
- }
-}
-
-fn whole_minutes(seconds: f64) -> u32 {
- (seconds.max(0.0) / 60.0).round() as u32
-}
-
-/// The clock time `seconds` after `now_minutes_of_day`, as `3:42 PM`.
-pub fn arrival_text(now_minutes_of_day: u32, seconds: f64) -> String {
- const DAY: u32 = 24 * 60;
- let at = (now_minutes_of_day % DAY + whole_minutes(seconds) % DAY) % DAY;
- let (hour, minute) = (at / 60, at % 60);
- let half = if hour < 12 { "AM" } else { "PM" };
- let hour12 = if hour % 12 == 0 { 12 } else { hour % 12 };
- format!("{hour12}:{minute:02} {half}")
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- const SAN_JOSE: LonLat = LonLat {
- lon: -121.8863,
- lat: 37.3382,
- };
- const SAN_FRANCISCO: LonLat = LonLat {
- lon: -122.4194,
- lat: 37.7749,
- };
-
- fn close(a: f64, b: f64, tolerance: f64) -> bool {
- (a - b).abs() <= tolerance
- }
-
- /// Where `point` lands in a viewport showing `camera`, in pixels from the
- /// viewport's top left: the projection `fit_camera` has to agree with.
- fn project(camera: &Camera, viewport: (f64, f64), point: LonLat) -> (f64, f64) {
- let world = WORLD_PX_AT_ZOOM_0 * 2f64.powf(camera.zoom);
- let (cx, cy) = lon_lat_to_norm(camera.center);
- let (px, py) = lon_lat_to_norm(point);
- (
- (px - cx) * world + viewport.0 * 0.5,
- (py - cy) * world + viewport.1 * 0.5,
- )
- }
-
- #[test]
- fn the_frameworks_distance_is_the_great_circle() {
- // 67.6 km between the two city centres.
- assert!(close(haversine_m(SAN_JOSE, SAN_FRANCISCO), 67_600.0, 700.0));
- }
-
- #[test]
- fn decodes_the_documented_sample() {
- // The example in Google's description of the format.
- let points = decode_polyline5("_p~iF~ps|U_ulLnnqC_mqNvxq`@");
- assert_eq!(points.len(), 3);
- let expected = [(-120.2, 38.5), (-120.95, 40.7), (-126.453, 43.252)];
- for (point, (lon, lat)) in points.iter().zip(expected) {
- assert!(
- close(point.lon, lon, 1e-5) && close(point.lat, lat, 1e-5),
- "{point:?}"
- );
- }
- }
-
- #[test]
- fn decodes_nothing_from_nothing_and_stops_at_a_torn_tail() {
- assert!(decode_polyline5("").is_empty());
- // The sample cut in the middle of its second point: the first stands.
- assert_eq!(decode_polyline5("_p~iF~ps|U_ulL").len(), 1);
- }
-
- #[test]
- fn a_hostile_line_ends_where_it_stops_making_sense() {
- // A varint that never ends, and deltas as large as the bound allows,
- // over and over: no panic, and nothing after the nonsense.
- assert!(decode_polyline5(&"~".repeat(64)).is_empty());
- let huge = "~~~~~~]".repeat(200_000);
- let points = decode_polyline5(&huge);
- assert!(points.len() <= 100_000);
- }
-
- #[test]
- fn bounds_wrap_their_points() {
- assert_eq!(Bounds::of(&[]), None);
- let bounds = Bounds::of(&[SAN_JOSE, SAN_FRANCISCO]).unwrap();
- assert_eq!(
- bounds,
- Bounds {
- west: -122.4194,
- south: 37.3382,
- east: -121.8863,
- north: 37.7749
- }
- );
- let center = bounds.center();
- assert!(close(center.lon, -122.15285, 1e-9) && close(center.lat, 37.55655, 1e-9));
- }
-
- #[test]
- fn a_fitted_box_fills_the_free_room_and_no_more() {
- let viewport = (400.0, 800.0);
- let insets = Insets {
- top: 150.0,
- bottom: 250.0,
- left: 24.0,
- right: 24.0,
- };
- let bounds = Bounds::of(&[SAN_JOSE, SAN_FRANCISCO]).unwrap();
- let camera = fit_camera(&bounds, viewport, &insets, 17.0);
- let (left, top) = project(
- &camera,
- viewport,
- LonLat {
- lon: bounds.west,
- lat: bounds.north,
- },
- );
- let (right, bottom) = project(
- &camera,
- viewport,
- LonLat {
- lon: bounds.east,
- lat: bounds.south,
- },
- );
- // Inside the free room on every side.
- assert!(
- left >= insets.left - 0.5 && right <= viewport.0 - insets.right + 0.5,
- "{left} {right}"
- );
- assert!(
- top >= insets.top - 0.5 && bottom <= viewport.1 - insets.bottom + 0.5,
- "{top} {bottom}"
- );
- // And touching it on the tighter axis.
- let fills_x = close(right - left, viewport.0 - insets.left - insets.right, 1.0);
- let fills_y = close(bottom - top, viewport.1 - insets.top - insets.bottom, 1.0);
- assert!(fills_x || fills_y, "{} x {}", right - left, bottom - top);
- // Centred in the free room, not in the viewport.
- assert!(close(
- (left + right) * 0.5,
- (insets.left + viewport.0 - insets.right) * 0.5,
- 1.0
- ));
- assert!(close(
- (top + bottom) * 0.5,
- (insets.top + viewport.1 - insets.bottom) * 0.5,
- 1.0
- ));
- }
-
- #[test]
- fn a_wide_box_is_bound_by_its_width_and_a_tall_one_by_its_height() {
- let viewport = (400.0, 400.0);
- let none = Insets::default();
- let wide = Bounds {
- west: -122.0,
- south: 37.0,
- east: -121.0,
- north: 37.1,
- };
- let tall = Bounds {
- west: -122.0,
- south: 37.0,
- east: -121.9,
- north: 38.0,
- };
- let wide_cam = fit_camera(&wide, viewport, &none, 20.0);
- let tall_cam = fit_camera(&tall, viewport, &none, 20.0);
- let span = |cam: &Camera, b: &Bounds| {
- let (l, t) = project(
- cam,
- viewport,
- LonLat {
- lon: b.west,
- lat: b.north,
- },
- );
- let (r, bt) = project(
- cam,
- viewport,
- LonLat {
- lon: b.east,
- lat: b.south,
- },
- );
- (r - l, bt - t)
- };
- let (w, h) = span(&wide_cam, &wide);
- assert!(close(w, 400.0, 1.0) && h < 400.0);
- let (w, h) = span(&tall_cam, &tall);
- assert!(close(h, 400.0, 1.0) && w < 400.0);
- }
-
- #[test]
- fn a_single_point_and_a_room_of_nothing_both_stop_at_the_zoom_cap() {
- let point = Bounds::of(&[SAN_JOSE]).unwrap();
- let camera = fit_camera(&point, (400.0, 800.0), &Insets::default(), 16.0);
- assert_eq!(camera.zoom, 16.0);
- assert!(
- close(camera.center.lon, SAN_JOSE.lon, 1e-9)
- && close(camera.center.lat, SAN_JOSE.lat, 1e-9)
- );
- // Panels that leave no room cannot drive the zoom to nonsense.
- let covered = Insets {
- top: 500.0,
- bottom: 500.0,
- ..Insets::default()
- };
- let camera = fit_camera(
- &Bounds::of(&[SAN_JOSE, SAN_FRANCISCO]).unwrap(),
- (400.0, 800.0),
- &covered,
- 16.0,
- );
- assert!(camera.zoom.is_finite() && camera.zoom <= 16.0 && camera.zoom >= 0.0);
- }
-
- #[test]
- fn units_follow_the_road_signs() {
- for code in ["US", "us", "GB", "LR", "MM"] {
- assert_eq!(Units::for_country(code), Units::Imperial, "{code}");
- }
- for code in ["NL", "DE", "CA", "", "USA"] {
- assert_eq!(Units::for_country(code), Units::Metric, "{code}");
- }
- }
-
- #[test]
- fn metric_distances() {
- assert_eq!(distance_text(0.0, Units::Metric), "0 m");
- assert_eq!(distance_text(14.0, Units::Metric), "15 m");
- assert_eq!(distance_text(348.0, Units::Metric), "350 m");
- assert_eq!(distance_text(999.0, Units::Metric), "1.0 km");
- assert_eq!(distance_text(2_440.0, Units::Metric), "2.4 km");
- assert_eq!(distance_text(12_400.0, Units::Metric), "12 km");
- }
-
- #[test]
- fn imperial_distances() {
- assert_eq!(distance_text(30.0, Units::Imperial), "100 ft");
- assert_eq!(distance_text(150.0, Units::Imperial), "500 ft");
- // A tenth of a mile is where feet stop.
- assert_eq!(distance_text(170.0, Units::Imperial), "0.1 mi");
- assert_eq!(distance_text(2_092.0, Units::Imperial), "1.3 mi");
- assert_eq!(distance_text(19_956.0, Units::Imperial), "12 mi");
- }
-
- #[test]
- fn durations() {
- assert_eq!(duration_text(0.0), "1 min");
- assert_eq!(duration_text(29.0), "1 min");
- assert_eq!(duration_text(629.0), "10 min");
- assert_eq!(duration_text(3_600.0), "1 hr");
- assert_eq!(duration_text(6_234.0), "1 hr 44 min");
- }
-
- #[test]
- fn arrival_times() {
- assert_eq!(arrival_text(15 * 60 + 32, 600.0), "3:42 PM");
- assert_eq!(arrival_text(0, 0.0), "12:00 AM");
- assert_eq!(arrival_text(11 * 60 + 59, 60.0), "12:00 PM");
- // Past midnight wraps.
- assert_eq!(arrival_text(23 * 60 + 50, 1_200.0), "12:10 AM");
- }
-}
diff --git a/apps/maps/native/src/guidance.rs b/apps/maps/native/src/guidance.rs
deleted file mode 100644
index de4580ca..00000000
--- a/apps/maps/native/src/guidance.rs
+++ /dev/null
@@ -1,480 +0,0 @@
-//! Guidance: the navigation library's session around a route, fed by live
-//! fixes or by the simulated drive, one `NavTick` per position — where the
-//! puck goes, how the camera turns, what the banner says. After the
-//! framework's `apps/route/src/nav.rs`, with one leg, the clock passed in
-//! (so it tests without a platform) and the banner read off the step list.
-
-use crate::geo::{bearing_deg, LonLat};
-use crate::routing::{Arrow, Directions};
-use makepad_map_nav::geo::bearing_delta_deg;
-use makepad_map_nav::graph::Route;
-use makepad_map_nav::nav::{ManeuverKind, NavSession, NavState};
-
-/// A preview runs the route this many times faster than the drive would be…
-const SIM_SPEED_MULT: f64 = 6.0;
-/// …but always lasts between these, whatever the route: a walk across town
-/// is not an eighteen-minute preview, a hop next door not a blink.
-const PREVIEW_SECONDS: std::ops::RangeInclusive = 20.0..=90.0;
-/// How far ahead on the line the travel bearing is taken.
-const LOOK_AHEAD_M: f64 = 12.0;
-/// How fast the camera turns onto the travel bearing: 1/e of the way per
-/// this fraction of a second.
-const ROTATION_EASE_PER_S: f64 = 3.0;
-/// Until the drive has gone this far, the route's first turn stands. OSRM
-/// often sets off a metre or two before a corner, inside the session's own
-/// look-ahead, which would pass over that turn without ever naming it.
-const START_ZONE_M: f64 = 10.0;
-/// Still off the route this long after asking for a new one (the request
-/// failed, or the new route is no better): ask again.
-const REROUTE_RETRY_S: f64 = 15.0;
-
-/// What one position means for the screen.
-#[derive(Clone, Debug)]
-pub struct NavTick {
- /// Where the puck goes: on the line while on the route, the raw fix off it.
- pub position: LonLat,
- pub heading: Option,
- /// The heading-up map rotation, eased.
- pub rotation: f64,
- /// Line points before this are behind the puck.
- pub progress_index: usize,
- pub state: NavState,
- pub arrow: Option,
- pub banner: String,
- pub banner_distance_m: f64,
- pub remaining_m: f64,
- pub remaining_s: f64,
- /// Ask for a new route from `position` now.
- pub needs_reroute: bool,
-}
-
-pub struct ActiveNav {
- session: NavSession,
- directions: Directions,
- simulate: bool,
- sim_progress_m: f64,
- /// Seconds of guidance so far: the session's monotonic clock.
- clock_s: f64,
- rotation: f64,
- reroute_asked_at: Option,
-}
-
-impl ActiveNav {
- pub fn new(directions: Directions, simulate: bool) -> Self {
- // The map starts turned the way the route sets off.
- let rotation = travel_bearing(&directions.route, 0.0).unwrap_or(0.0);
- ActiveNav {
- session: NavSession::new(directions.route.clone()),
- directions,
- simulate,
- sim_progress_m: 0.0,
- clock_s: 0.0,
- rotation,
- reroute_asked_at: None,
- }
- }
-
- pub fn simulate(&self) -> bool {
- self.simulate
- }
-
- pub fn directions(&self) -> &Directions {
- &self.directions
- }
-
- /// Advance the simulated drive by `dt` seconds.
- pub fn tick_sim(&mut self, dt: f64) -> NavTick {
- // A stalled frame does not jump the puck down the road.
- let dt = dt.clamp(0.0, 0.5);
- let route = &self.directions.route;
- let preview_s = (route.duration_s / SIM_SPEED_MULT)
- .clamp(*PREVIEW_SECONDS.start(), *PREVIEW_SECONDS.end());
- self.sim_progress_m =
- (self.sim_progress_m + route.length_m / preview_s * dt).min(route.length_m);
- let pos = point_at(route, self.sim_progress_m);
- let heading = travel_bearing(route, self.sim_progress_m);
- self.feed(pos, heading, dt)
- }
-
- /// A position, real or simulated, `dt` seconds after the last.
- pub fn feed(&mut self, pos: LonLat, heading: Option, dt: f64) -> NavTick {
- let dt = dt.max(0.0);
- self.clock_s += dt;
- let status = self.session.update(pos, self.clock_s);
- let route = &self.directions.route;
-
- // Heading-up: ease onto the compass when the fix has one, else onto
- // the street being driven; the shortest way round.
- if let Some(target) = heading.or_else(|| travel_bearing(route, status.progress_m)) {
- let blend = 1.0 - (-dt * ROTATION_EASE_PER_S).exp();
- self.rotation = (self.rotation + bearing_delta_deg(self.rotation, target) * blend)
- .rem_euclid(360.0);
- }
-
- // One ask per episode off the route, and another only if it lasts.
- if status.state == NavState::Navigating {
- self.reroute_asked_at = None;
- }
- let may_ask = self
- .reroute_asked_at
- .map_or(true, |at| self.clock_s - at >= REROUTE_RETRY_S);
- let needs_reroute = status.needs_reroute && !self.simulate && may_ask;
- if needs_reroute {
- self.reroute_asked_at = Some(self.clock_s);
- }
-
- // At the very start the first turn, however close; then the
- // session's pick.
- let first_turn = route
- .maneuvers
- .iter()
- .position(|m| m.kind != ManeuverKind::Depart);
- let next = if status.progress_m < START_ZONE_M {
- first_turn.filter(|&m| route.maneuvers[m].dist_m >= status.progress_m)
- } else {
- None
- }
- .or(status.next_maneuver);
- let banner_distance_m = next
- .map(|m| (route.maneuvers[m].dist_m - status.progress_m).max(0.0))
- .unwrap_or(status.remaining_m);
- let (arrow, banner) = if status.state == NavState::Arrived {
- (Some(Arrow::Arrive), "You have arrived".to_string())
- } else {
- // The turn's line in the list says it better than its kind does.
- let step = next
- .and_then(|m| self.directions.maneuver_steps.get(m))
- .and_then(|&s| self.directions.steps.get(s));
- (
- step.map(|s| s.arrow),
- step.map(|s| s.text.clone()).unwrap_or_default(),
- )
- };
- NavTick {
- position: status.matched,
- heading,
- rotation: self.rotation,
- progress_index: route.cum_dist_m.partition_point(|&c| c < status.progress_m),
- state: status.state,
- arrow,
- banner,
- banner_distance_m,
- remaining_m: status.remaining_m,
- remaining_s: status.remaining_s,
- needs_reroute,
- }
- }
-
- /// The reroute's answer: guidance starts over on the new route. When
- /// the asking may resume is left as it was: a driver in a car park is
- /// still off the new line, which starts at the nearest road, and would
- /// otherwise ask again every few seconds. Back on a route forgets it.
- pub fn replace_route(&mut self, directions: Directions) {
- self.session = NavSession::new(directions.route.clone());
- self.directions = directions;
- self.sim_progress_m = 0.0;
- }
-}
-
-/// The point `dist_m` along the route's line.
-pub fn point_at(route: &Route, dist_m: f64) -> LonLat {
- let (points, cum) = (&route.points, &route.cum_dist_m);
- let Some(last) = points.last() else {
- return LonLat::new(0.0, 0.0);
- };
- let next = cum.partition_point(|&c| c < dist_m);
- if next == 0 {
- return points[0];
- }
- if next >= points.len() {
- return *last;
- }
- let (a, b) = (points[next - 1], points[next]);
- let t = (dist_m - cum[next - 1]) / (cum[next] - cum[next - 1]).max(1e-9);
- LonLat::new(a.lon + (b.lon - a.lon) * t, a.lat + (b.lat - a.lat) * t)
-}
-
-/// The bearing of the line a little ahead of `dist_m`; `None` at its end,
-/// where there is nothing ahead to point at.
-fn travel_bearing(route: &Route, dist_m: f64) -> Option {
- (dist_m + 1.0 < route.length_m).then(|| {
- bearing_deg(
- point_at(route, dist_m),
- point_at(route, (dist_m + LOOK_AHEAD_M).min(route.length_m)),
- )
- })
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use crate::geo::{cumulative_distances, haversine_m};
- use crate::routing::Step;
- use makepad_map_nav::graph::TravelMode;
- use makepad_map_nav::nav::Maneuver;
-
- /// Roughly 50 m of latitude, and of longitude at this latitude.
- const STEP_LAT: f64 = 0.00045;
- const STEP_LON: f64 = 0.000566;
- const ORIGIN: LonLat = LonLat {
- lon: -121.9,
- lat: 37.3,
- };
-
- /// 500 m north, a right turn onto Elm Street, 500 m east: a vertex every
- /// 50 m, a hundred seconds to drive.
- fn l_shaped() -> Directions {
- let mut points: Vec = (0..=10)
- .map(|i| LonLat::new(ORIGIN.lon, ORIGIN.lat + STEP_LAT * i as f64))
- .collect();
- let corner = *points.last().unwrap();
- points.extend((1..=10).map(|i| LonLat::new(corner.lon + STEP_LON * i as f64, corner.lat)));
- let cum_dist_m = cumulative_distances(&points).unwrap();
- let length_m = *cum_dist_m.last().unwrap();
- let maneuver = |kind, index: usize, name: &str| Maneuver {
- kind,
- at: points[index],
- name: name.into(),
- dist_m: cum_dist_m[index],
- point_index: index,
- };
- let maneuvers = vec![
- maneuver(ManeuverKind::Depart, 0, "Oak Street"),
- maneuver(ManeuverKind::TurnRight, 10, "Elm Street"),
- maneuver(ManeuverKind::Arrive, 20, "Elm Street"),
- ];
- let step = |arrow, text: &str, distance_m| Step {
- arrow,
- text: text.into(),
- distance_m,
- };
- Directions {
- route: Route {
- mode: TravelMode::Car,
- points,
- cum_dist_m,
- length_m,
- duration_s: 100.0,
- maneuvers,
- },
- steps: vec![
- step(Arrow::Depart, "Head north on Oak Street", 500.0),
- step(Arrow::Right, "Turn right onto Elm Street", 500.0),
- step(Arrow::Arrive, "Arrive at your destination", 0.0),
- ],
- maneuver_steps: vec![0, 1, 2],
- }
- }
-
- #[test]
- fn a_point_along_the_line() {
- let route = l_shaped().route;
- assert_eq!(point_at(&route, -5.0), route.points[0]);
- assert_eq!(point_at(&route, 1e9), *route.points.last().unwrap());
- // Halfway up the first street.
- let halfway = point_at(&route, 250.0);
- assert!(haversine_m(halfway, route.points[5]) < 1.0);
- }
-
- #[test]
- fn the_preview_drives_to_the_end_and_never_backwards() {
- let mut nav = ActiveNav::new(l_shaped(), true);
- let mut last_index = 0;
- let mut ticks = 0;
- let arrived = loop {
- let tick = nav.tick_sim(0.25);
- assert!(tick.progress_index >= last_index);
- assert!(!tick.needs_reroute, "a preview never leaves its route");
- last_index = tick.progress_index;
- ticks += 1;
- if tick.state == NavState::Arrived || ticks > 1_000 {
- break tick;
- }
- };
- assert_eq!(arrived.state, NavState::Arrived);
- assert_eq!(arrived.banner, "You have arrived");
- assert_eq!(arrived.arrow, Some(Arrow::Arrive));
- // A hundred-second drive previews in the floor of twenty.
- assert!(
- (20.0 / 0.25 - 12.0..=20.0 / 0.25 + 2.0).contains(&(ticks as f64)),
- "{ticks} ticks"
- );
- }
-
- /// The L, but setting off two metres before a first corner, as OSRM's
- /// routes often do: a left onto Oak Street, then the L as before.
- fn corner_at_the_start() -> Directions {
- let mut directions = l_shaped();
- let first = directions.route.points[0];
- let before = LonLat::new(first.lon - STEP_LON * 0.04, first.lat);
- directions.route.points.insert(0, before);
- directions.route.cum_dist_m = cumulative_distances(&directions.route.points).unwrap();
- directions.route.length_m = *directions.route.cum_dist_m.last().unwrap();
- let cum = directions.route.cum_dist_m.clone();
- for maneuver in directions.route.maneuvers.iter_mut().skip(1) {
- maneuver.point_index += 1;
- maneuver.dist_m = cum[maneuver.point_index];
- }
- let corner = Maneuver {
- kind: ManeuverKind::TurnLeft,
- at: first,
- name: "Oak Street".into(),
- dist_m: cum[1],
- point_index: 1,
- };
- directions.route.maneuvers.insert(1, corner);
- directions.steps.insert(
- 1,
- Step {
- arrow: Arrow::Left,
- text: "Turn left onto Oak Street".into(),
- distance_m: 500.0,
- },
- );
- directions.maneuver_steps = vec![0, 1, 2, 3];
- directions
- }
-
- #[test]
- fn a_turn_at_the_very_start_is_announced_before_the_next_one() {
- let directions = corner_at_the_start();
- assert!(
- directions.route.maneuvers[1].dist_m < 3.0,
- "inside the session's look-ahead"
- );
- let mut nav = ActiveNav::new(directions.clone(), false);
- let standing = nav.feed(directions.route.points[0], None, 1.0);
- assert_eq!(standing.banner, "Turn left onto Oak Street");
- assert_eq!(standing.arrow, Some(Arrow::Left));
- assert!(standing.banner_distance_m < 3.0);
- // Round the corner and up the street: the L's own turn is next.
- let tick = nav.feed(point_at(&directions.route, 60.0), None, 1.0);
- assert_eq!(tick.banner, "Turn right onto Elm Street");
- }
-
- #[test]
- fn the_banner_names_the_next_turn_and_counts_down_to_it() {
- let mut nav = ActiveNav::new(l_shaped(), true);
- let first = nav.tick_sim(0.25);
- assert_eq!(first.banner, "Turn right onto Elm Street");
- assert_eq!(first.arrow, Some(Arrow::Right));
- assert!(
- first.banner_distance_m < 500.0 && first.banner_distance_m > 400.0,
- "{}",
- first.banner_distance_m
- );
- let second = nav.tick_sim(0.25);
- assert!(second.banner_distance_m < first.banner_distance_m);
- assert!(second.remaining_m < first.remaining_m && second.remaining_s < first.remaining_s);
- // Past the corner the banner moves on to the arrival.
- let after = (0..60)
- .map(|_| nav.tick_sim(0.25))
- .find(|tick| tick.arrow == Some(Arrow::Arrive))
- .unwrap();
- assert_eq!(after.banner, "Arrive at your destination");
- }
-
- #[test]
- fn a_live_drive_turns_the_map_onto_the_street_even_without_a_compass() {
- let directions = l_shaped();
- let route = directions.route.clone();
- let mut nav = ActiveNav::new(directions, false);
- // Up Oak Street: north is up. 350 is as good as 10.
- let mut tick = nav.feed(point_at(&route, 100.0), None, 1.0);
- for _ in 0..3 {
- tick = nav.feed(point_at(&route, 150.0), None, 1.0);
- }
- assert!(
- bearing_delta_deg(tick.rotation, 0.0).abs() < 2.0,
- "{}",
- tick.rotation
- );
- // Round the corner and along Elm Street, in hops the session's
- // matching window follows: east is up.
- for along in [350.0, 550.0, 750.0, 760.0, 770.0, 780.0] {
- tick = nav.feed(point_at(&route, along), None, 1.0);
- }
- assert!(
- bearing_delta_deg(tick.rotation, 90.0).abs() < 2.0,
- "{}",
- tick.rotation
- );
- // A compass, when there is one, wins.
- for _ in 0..4 {
- tick = nav.feed(point_at(&route, 800.0), Some(80.0), 1.0);
- }
- assert!(
- bearing_delta_deg(tick.rotation, 80.0).abs() < 2.0,
- "{}",
- tick.rotation
- );
- }
-
- #[test]
- fn off_the_route_asks_for_a_new_one_once_then_again_only_after_a_while() {
- let directions = l_shaped();
- let on_route = point_at(&directions.route, 100.0);
- let astray = LonLat::new(on_route.lon - 2.0 * STEP_LON, on_route.lat);
- let mut nav = ActiveNav::new(directions, false);
- assert!(!nav.feed(on_route, None, 1.0).needs_reroute);
- // A hundred metres west of Oak Street, second after second.
- let asks: Vec = (0..30)
- .map(|_| nav.feed(astray, None, 1.0).needs_reroute)
- .collect();
- let asked_at: Vec = asks
- .iter()
- .enumerate()
- .filter(|(_, ask)| **ask)
- .map(|(i, _)| i)
- .collect();
- // Once after the session's grace, once more after the retry interval.
- assert_eq!(asked_at.len(), 2, "{asked_at:?}");
- assert!(asked_at[0] >= 4 && asked_at[0] <= 6, "{asked_at:?}");
- assert!(
- asked_at[1] - asked_at[0] >= REROUTE_RETRY_S as usize,
- "{asked_at:?}"
- );
- // Off the route the puck is where the fix is.
- let tick = nav.feed(astray, None, 1.0);
- assert_eq!(tick.state, NavState::OffRoute);
- assert_eq!(tick.position, astray);
- }
-
- #[test]
- fn a_new_route_the_driver_is_still_off_does_not_ask_again_at_once() {
- let directions = l_shaped();
- let on_route = point_at(&directions.route, 300.0);
- let car_park = LonLat::new(on_route.lon - 2.0 * STEP_LON, on_route.lat);
- let mut nav = ActiveNav::new(directions, false);
- nav.feed(on_route, None, 1.0);
- assert!((0..8).any(|_| nav.feed(car_park, None, 1.0).needs_reroute));
- // The answer starts at the road; the car is still in the car park.
- nav.replace_route(l_shaped());
- let asks = (0..10)
- .filter(|_| nav.feed(car_park, None, 1.0).needs_reroute)
- .count();
- assert_eq!(asks, 0, "not before the retry interval");
- let later = (0..10)
- .filter(|_| nav.feed(car_park, None, 1.0).needs_reroute)
- .count();
- assert_eq!(later, 1, "once it has lasted");
- }
-
- #[test]
- fn back_on_the_route_forgets_the_asking_and_a_new_route_starts_over() {
- let directions = l_shaped();
- let route = directions.route.clone();
- let on_route = point_at(&route, 300.0);
- let astray = LonLat::new(on_route.lon - 2.0 * STEP_LON, on_route.lat);
- let mut nav = ActiveNav::new(directions, false);
- nav.feed(on_route, None, 1.0);
- assert!((0..8).any(|_| nav.feed(astray, None, 1.0).needs_reroute));
- // The reroute's answer: progress starts over on it.
- nav.replace_route(l_shaped());
- let tick = nav.feed(point_at(&route, 20.0), None, 1.0);
- assert_eq!(tick.state, NavState::Navigating);
- assert!(tick.progress_index <= 1, "{}", tick.progress_index);
- // Astray again: a fresh episode asks again, without the retry wait.
- assert!((0..8).any(|_| nav.feed(astray, None, 1.0).needs_reroute));
- }
-}
diff --git a/apps/maps/native/src/http.rs b/apps/maps/native/src/http.rs
deleted file mode 100644
index 2b8676a4..00000000
--- a/apps/maps/native/src/http.rs
+++ /dev/null
@@ -1,167 +0,0 @@
-//! Android service requests use rustls: Android 9's Java HTTPS provider
-//! cannot negotiate TLS 1.3 with the routing service. Other platforms keep
-//! using the framework transport, as do the map renderer's tile requests.
-
-#[cfg(not(any(target_os = "android", test)))]
-#[derive(Default)]
-pub(crate) struct MapHttp;
-
-#[cfg(any(target_os = "android", test))]
-pub(crate) use native::MapHttp;
-
-#[cfg(any(target_os = "android", test))]
-mod native {
- use crate::model::{body_text, plain_error, MAX_BODY_BYTES, USER_AGENT};
- use makepad_widgets::{makepad_platform::thread::ToUIReceiver, LiveId};
- use reqwest::{Client, ClientBuilder};
- use std::{collections::HashMap, error::Error, time::Duration};
- use tokio::sync::oneshot;
-
- pub(crate) struct Reply {
- pub id: LiveId,
- pub body: Result,
- }
-
- #[derive(Default)]
- pub(crate) struct MapHttp {
- // Dropping a sender cancels its worker, including on widget drop.
- pending: HashMap>,
- replies: ToUIReceiver,
- }
-
- fn client_builder() -> ClientBuilder {
- Client::builder()
- // Select explicitly even if another dependency enables native TLS.
- .use_rustls_tls()
- .https_only(true)
- .redirect(reqwest::redirect::Policy::none())
- .user_agent(USER_AGENT)
- .connect_timeout(Duration::from_secs(10))
- .timeout(Duration::from_secs(30))
- }
-
- fn transport_error(error: reqwest::Error) -> String {
- if error.is_timeout() {
- return "timed out".into();
- }
- // The useful TLS/DNS cause is nested. Do not expose trip coordinates
- // from the request URL in a displayed error.
- let error = error.without_url();
- let mut message = error.to_string();
- let mut source = error.source();
- while let Some(cause) = source {
- message.push_str(": ");
- message.push_str(&cause.to_string());
- source = cause.source();
- }
- plain_error(&message)
- }
-
- async fn fetch(client: &Client, url: &str, limit: usize) -> Result {
- let mut response = client
- .get(url)
- .header(reqwest::header::ACCEPT, "application/json")
- .send()
- .await
- .map_err(transport_error)?;
- let status = response.status().as_u16();
- if status != 200 {
- return Err(format!("HTTP {status}"));
- }
- if response
- .content_length()
- .is_some_and(|len| len > limit as u64)
- {
- return Err("response too large".into());
- }
- let mut body = Vec::new();
- while let Some(chunk) = response.chunk().await.map_err(transport_error)? {
- if chunk.len() > limit.saturating_sub(body.len()) {
- return Err("response too large".into());
- }
- body.extend_from_slice(&chunk);
- }
- body_text(status, Some(&body)).map(str::to_owned)
- }
-
- impl MapHttp {
- #[cfg(target_os = "android")]
- pub fn send(&mut self, id: LiveId, url: String) {
- match client_builder().build() {
- Ok(client) => self.send_with_client(id, url, client),
- Err(error) => {
- self.cancel(id);
- let (cancel, _) = oneshot::channel();
- self.pending.insert(id, cancel);
- let _ = self.replies.sender().send(Reply {
- id,
- body: Err(transport_error(error)),
- });
- }
- }
- }
-
- fn send_with_client(&mut self, id: LiveId, url: String, client: Client) {
- self.cancel(id);
- let (cancel, cancelled) = oneshot::channel();
- self.pending.insert(id, cancel);
- let replies = self.replies.sender();
- let worker = std::thread::Builder::new()
- .name("octosmap-https".into())
- .spawn(move || {
- let runtime = match tokio::runtime::Builder::new_current_thread()
- .enable_all()
- .build()
- {
- Ok(runtime) => runtime,
- Err(_) => {
- let _ = replies.send(Reply {
- id,
- body: Err("could not start network request".into()),
- });
- return;
- }
- };
- let body = runtime.block_on(async {
- tokio::select! {
- biased;
- _ = cancelled => None,
- body = fetch(&client, &url, MAX_BODY_BYTES) => Some(body),
- }
- });
- // A platform DNS lookup may still be finishing in the
- // blocking pool; it must not hold up worker shutdown.
- runtime.shutdown_background();
- if let Some(body) = body {
- let _ = replies.send(Reply { id, body });
- }
- });
- if worker.is_err() {
- let _ = self.replies.sender().send(Reply {
- id,
- body: Err("could not start network request".into()),
- });
- }
- }
-
- pub fn cancel(&mut self, id: LiveId) {
- self.pending.remove(&id);
- }
-
- pub fn drain(&mut self) -> Vec {
- let mut replies = Vec::new();
- while let Ok(reply) = self.replies.try_recv() {
- // Cancellation also rejects a reply already queued for the UI.
- if self.pending.remove(&reply.id).is_some() {
- replies.push(reply);
- }
- }
- replies
- }
- }
-
- #[cfg(test)]
- mod tests {
- include!("http_tests.rs");
- }
-}
diff --git a/apps/maps/native/src/http_tests.rs b/apps/maps/native/src/http_tests.rs
deleted file mode 100644
index 83d5c30a..00000000
--- a/apps/maps/native/src/http_tests.rs
+++ /dev/null
@@ -1,249 +0,0 @@
-use super::*;
-use crate::{
- geo::LonLat,
- routing::{self, Mode},
-};
-use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
-use std::{
- io::{Read, Write},
- net::TcpListener,
- sync::{mpsc, Arc},
- time::{Duration, Instant},
-};
-
-const CERT: &[u8] = include_bytes!("../tests/fixtures/tls/localhost.der");
-const KEY: &[u8] = include_bytes!("../tests/fixtures/tls/localhost-key.der");
-const CAR: &str = include_str!("../tests/fixtures/osrm-car.json");
-
-fn trusted_client() -> reqwest::Client {
- client_builder()
- .add_root_certificate(reqwest::Certificate::from_der(CERT).unwrap())
- .build()
- .unwrap()
-}
-
-fn run(future: F) -> F::Output {
- tokio::runtime::Builder::new_current_thread()
- .enable_all()
- .build()
- .unwrap()
- .block_on(future)
-}
-
-// Only TLS 1.3 is accepted, matching the routing service. The stalled mode
-// lets cancellation tests observe the actual network connection closing.
-fn serve(response: Option>) -> (String, mpsc::Receiver, mpsc::Receiver<()>) {
- let listener = TcpListener::bind("127.0.0.1:0").unwrap();
- let url = format!(
- "https://localhost:{}/route",
- listener.local_addr().unwrap().port()
- );
- let config = rustls::ServerConfig::builder_with_provider(Arc::new(
- rustls::crypto::ring::default_provider(),
- ))
- .with_protocol_versions(&[&rustls::version::TLS13])
- .unwrap()
- .with_no_client_auth()
- .with_single_cert(
- vec![CertificateDer::from(CERT.to_vec())],
- PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(KEY.to_vec())),
- )
- .unwrap();
- let (request_tx, request_rx) = mpsc::channel();
- let (closed_tx, closed_rx) = mpsc::channel();
- std::thread::spawn(move || {
- let (socket, _) = listener.accept().unwrap();
- socket
- .set_read_timeout(Some(Duration::from_secs(3)))
- .unwrap();
- socket
- .set_write_timeout(Some(Duration::from_secs(3)))
- .unwrap();
- let connection = rustls::ServerConnection::new(Arc::new(config)).unwrap();
- let mut tls = rustls::StreamOwned::new(connection, socket);
- let mut head = Vec::new();
- let mut byte = [0];
- while !head.ends_with(b"\r\n\r\n") && head.len() < 16384 {
- if tls.read_exact(&mut byte).is_err() {
- return;
- }
- head.push(byte[0]);
- }
- assert_eq!(
- tls.conn.protocol_version(),
- Some(rustls::ProtocolVersion::TLSv1_3)
- );
- let _ = request_tx.send(String::from_utf8(head).unwrap());
- if let Some(response) = response {
- let _ = tls.write_all(&response);
- let _ = tls.flush();
- } else {
- let _ = tls.read(&mut byte);
- let _ = closed_tx.send(());
- }
- });
- (url, request_rx, closed_rx)
-}
-
-fn ok(body: &[u8]) -> Vec {
- let mut response = format!(
- "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
- body.len()
- )
- .into_bytes();
- response.extend_from_slice(body);
- response
-}
-
-#[test]
-fn tls13_route_is_downloaded_and_parsed_with_service_headers() {
- let (url, request, _) = serve(Some(ok(CAR.as_bytes())));
- let body = run(fetch(&trusted_client(), &url, MAX_BODY_BYTES)).unwrap();
- assert!(!routing::parse(Mode::Car, &body)
- .unwrap()
- .route
- .points
- .is_empty());
- let head = request
- .recv_timeout(Duration::from_secs(2))
- .unwrap()
- .to_lowercase();
- assert!(head.contains("user-agent: octosmap/0.1"));
- assert!(head.contains("accept: application/json"));
- assert!(
- routing::route_url(Mode::Car, LonLat::new(0.0, 0.0), LonLat::new(1.0, 1.0))
- .starts_with("https://")
- );
-}
-
-#[test]
-fn untrusted_certificate_is_rejected() {
- let (url, _, _) = serve(Some(ok(b"{}")));
- let client = client_builder().build().unwrap();
- assert_eq!(
- run(fetch(&client, &url, MAX_BODY_BYTES)).unwrap_err(),
- "secure connection failed"
- );
-}
-
-#[test]
-fn trusted_certificate_for_a_different_hostname_is_rejected() {
- let (url, _, _) = serve(Some(ok(b"{}")));
- let url = url.replace("localhost", "127.0.0.1");
- assert_eq!(
- run(fetch(&trusted_client(), &url, MAX_BODY_BYTES)).unwrap_err(),
- "secure connection failed"
- );
-}
-
-#[test]
-fn cleartext_and_redirects_are_not_followed() {
- let client = trusted_client();
- assert!(run(fetch(&client, "http://127.0.0.1:9/route", MAX_BODY_BYTES)).is_err());
- let (url, _, _) = serve(Some(
- b"HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:9/route\r\nContent-Length: 0\r\n\r\n"
- .to_vec(),
- ));
- assert_eq!(
- run(fetch(&client, &url, MAX_BODY_BYTES)).unwrap_err(),
- "HTTP 302"
- );
-}
-
-#[test]
-fn declared_and_chunked_bodies_cannot_exceed_the_limit() {
- let client = trusted_client();
- let (url, _, _) = serve(Some(ok(b"123456789")));
- assert!(run(fetch(&client, &url, 8))
- .unwrap_err()
- .contains("response too large"));
- let (url, _, _) = serve(Some(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\n12345\r\n5\r\n67890\r\n0\r\n\r\n".to_vec()));
- assert!(run(fetch(&client, &url, 8))
- .unwrap_err()
- .contains("response too large"));
-}
-
-#[test]
-fn invalid_utf8_and_unsuccessful_statuses_are_reported() {
- let client = trusted_client();
- let (url, _, _) = serve(Some(ok(&[255])));
- assert_eq!(
- run(fetch(&client, &url, MAX_BODY_BYTES)).unwrap_err(),
- "response is not UTF-8"
- );
- let (url, _, _) = serve(Some(
- b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n".to_vec(),
- ));
- assert_eq!(
- run(fetch(&client, &url, MAX_BODY_BYTES)).unwrap_err(),
- "HTTP 503"
- );
-}
-
-#[test]
-fn a_stalled_response_times_out() {
- let (url, _, _) = serve(None);
- let client = client_builder()
- .add_root_certificate(reqwest::Certificate::from_der(CERT).unwrap())
- .timeout(Duration::from_millis(150))
- .build()
- .unwrap();
- let start = Instant::now();
- assert_eq!(
- run(fetch(&client, &url, MAX_BODY_BYTES)).unwrap_err(),
- "timed out"
- );
- assert!(start.elapsed() < Duration::from_secs(2));
-}
-
-#[test]
-fn cancel_and_drop_close_in_flight_requests_without_a_reply() {
- for shutdown in [false, true] {
- let (url, request, closed) = serve(None);
- let mut transport = MapHttp::default();
- let id = LiveId(100);
- transport.send_with_client(id, url, trusted_client());
- request.recv_timeout(Duration::from_secs(2)).unwrap();
- if shutdown {
- drop(transport);
- } else {
- transport.cancel(id);
- closed.recv_timeout(Duration::from_secs(2)).unwrap();
- assert!(transport.drain().is_empty());
- continue;
- }
- closed.recv_timeout(Duration::from_secs(2)).unwrap();
- }
-}
-
-#[test]
-fn a_completed_reply_keeps_its_request_id_and_cancelled_replies_are_discarded() {
- let (url, request, _) = serve(Some(ok(b"{}")));
- let mut transport = MapHttp::default();
- let id = LiveId(101);
- transport.send_with_client(id, url, trusted_client());
- request.recv_timeout(Duration::from_secs(2)).unwrap();
- let deadline = Instant::now() + Duration::from_secs(2);
- loop {
- let mut replies = transport.drain();
- if let Some(reply) = replies.pop() {
- assert_eq!(reply.id, id);
- assert_eq!(reply.body.unwrap(), "{}");
- break;
- }
- assert!(Instant::now() < deadline);
- std::thread::sleep(Duration::from_millis(5));
- }
- let (cancel, _) = tokio::sync::oneshot::channel();
- transport.pending.insert(id, cancel);
- transport
- .replies
- .sender()
- .send(Reply {
- id,
- body: Ok("stale".into()),
- })
- .unwrap();
- transport.cancel(id);
- assert!(transport.drain().is_empty());
-}
diff --git a/apps/maps/native/src/lib.rs b/apps/maps/native/src/lib.rs
deleted file mode 100644
index cbb3b8fd..00000000
--- a/apps/maps/native/src/lib.rs
+++ /dev/null
@@ -1,33 +0,0 @@
-//! OctosMap for OctoSense: a full-screen map under a search bar, a place
-//! sheet, directions by car, on foot and by bike, and turn-by-turn
-//! navigation. The same crate is a standalone window (`src/main.rs`), a
-//! process-hosted tile, and an in-process `AppModule` (`module.rs`) that
-//! phones link automatically.
-pub use makepad_widgets;
-
-pub mod geo;
-pub mod guidance;
-mod http;
-pub mod model;
-pub mod module;
-pub mod places;
-pub mod routing;
-pub mod sheet;
-#[cfg(test)]
-pub(crate) mod test_support;
-pub mod view;
-
-pub use module::{MapsModule, MAPS_MODULE};
-pub use view::MapsView;
-
-/// The hosted vector archive the map reads over HTTP range requests: the
-/// one the framework's route app falls back to when it has no local map.
-pub const HOSTED_TILES: &str = "https://makepad.nl/maps/world-20260903.mkmap";
-
-/// The sea, which the base archive leaves to two overlays of its own: a
-/// coarse one for the far zooms and a fine one for the near, both drawn as
-/// the map's `ocean` layer, as the route app hosts them.
-pub const HOSTED_OCEAN: [&str; 2] = [
- "https://makepad.nl/maps/overlays/ocean-low-20260903.mkmap/",
- "https://makepad.nl/maps/overlays/ocean-high-20260904.mkmap/",
-];
diff --git a/apps/maps/native/src/main.rs b/apps/maps/native/src/main.rs
deleted file mode 100644
index 779aa13c..00000000
--- a/apps/maps/native/src/main.rs
+++ /dev/null
@@ -1,196 +0,0 @@
-//! OctosMap as a plain full-window Makepad app. Everything that makes it a
-//! maps app lives in `MapsView` (`src/view.rs`) and none of it depends on
-//! this binary, so `cargo test -p octosense-maps` covers it without a
-//! window.
-
-pub use makepad_widgets;
-use makepad_widgets::*;
-use octosense_maps::geo::LonLat;
-use octosense_maps::model::force_skin;
-use octosense_maps::view::MapsView;
-
-app_main!(App);
-
-/// The zoom `--at` opens on: a neighbourhood.
-const AT_ZOOM: f64 = 15.0;
-
-script_mod! {
- use mod.prelude.widgets.*
- use mod.widgets.*
-
- startup() do #(App::script_component(vm)) {
- ui: Root {
- main_window := Window {
- window.title: "OctosMap"
- window.inner_size: vec2(480, 800)
- pass +: { clear_color: theme.color_bg_app }
- body +: {
- padding: 0 margin: 0 spacing: 0
- maps := MapsView {}
- }
- }
- }
- }
-}
-
-#[derive(Script, ScriptHook)]
-pub struct App {
- #[live]
- ui: WidgetRef,
- /// `--show place:`: the first result opens when it lands.
- #[rust]
- open_first_result: bool,
- /// `--show directions:`: and then its directions.
- #[rust]
- then_directions: bool,
- /// `--show preview:`: and then a preview, once there is a route.
- #[rust]
- then_preview: bool,
-}
-
-/// ` lat,lon`, the order a person reads coordinates in.
-fn coordinates_arg(args: &[String], flag: &str) -> Option {
- let value = args.get(args.iter().position(|a| a == flag)? + 1)?;
- let (lat, lon) = value.split_once(',')?;
- Some(LonLat::new(
- lon.trim().parse().ok()?,
- lat.trim().parse().ok()?,
- ))
-}
-
-impl MatchEvent for App {
- fn handle_startup(&mut self, cx: &mut Cx) {
- // Dev flags for looking at the app in a plain window: `--phone`
- // sizes the window like the phone viewport, `--at lat,lon` opens
- // the map there.
- let args: Vec = std::env::args().collect();
- if args.iter().any(|a| a == "--phone") {
- // The host gives the app the whole rect; the window's own
- // caption bar would take the top of it.
- let mut win = self.ui.widget(cx, ids!(main_window));
- script_apply_eval!(cx, win, { show_caption_bar: false });
- self.ui
- .window(cx, ids!(main_window))
- .resize(cx, dvec2(402.0, 780.0));
- }
- // Storage before the first event reaches the view, so its start
- // reads the saved state in the same pass.
- let storage = cx.storage("maps");
- if let Some(mut view) = self.ui.widget(cx, ids!(maps)).borrow_mut::() {
- view.set_storage(cx, storage);
- if let Some(center) = coordinates_arg(&args, "--at") {
- view.set_initial_camera(center, AT_ZOOM);
- }
- }
- makepad_wm_api::set_title(cx, "OctosMap");
- // `--fix lat,lon`: the device is there, for a desk with no GPS. The
- // locate button is pressed and the fix delivered as the platform
- // delivers one.
- if let Some(fix) = coordinates_arg(&args, "--fix") {
- if let Some(mut view) = self.ui.widget(cx, ids!(maps)).borrow_mut::() {
- view.ask_location(cx);
- }
- let update = LocationUpdateEvent {
- lon: fix.lon,
- lat: fix.lat,
- accuracy_m: 10.0,
- altitude_m: None,
- speed_mps: None,
- heading_deg: None,
- time: 0.0,
- };
- self.ui
- .handle_event(cx, &Event::LocationUpdate(update), &mut Scope::empty());
- }
- // `--show `: open on a state a screenshot wants, without
- // driving the window there by hand. `layers`; `search:`, the
- // results of a search; `place:`, its first result's sheet;
- // `directions:`, the routes to it; `preview:`, a
- // simulated drive of the first of them.
- let show = args
- .iter()
- .position(|a| a == "--show")
- .and_then(|i| args.get(i + 1))
- .cloned();
- if let (Some(state), Some(mut view)) = (
- show,
- self.ui.widget(cx, ids!(maps)).borrow_mut::(),
- ) {
- match state.split_once(':') {
- None if state == "layers" => view.open_layers(cx),
- Some(("search", query)) => view.search_for(cx, query),
- Some(("place", query)) => {
- view.search_for(cx, query);
- self.open_first_result = true;
- }
- Some(("directions", query)) => {
- view.search_for(cx, query);
- self.open_first_result = true;
- self.then_directions = true;
- }
- Some(("preview", query)) => {
- view.search_for(cx, query);
- self.open_first_result = true;
- self.then_directions = true;
- self.then_preview = true;
- }
- _ => log!("maps: --show {state} is not a state"),
- }
- }
- }
-
- // The view handles its own actions; nothing reaches the app.
- fn handle_actions(&mut self, _cx: &mut Cx, _actions: &Actions) {}
-}
-
-impl AppMain for App {
- fn script_mod(vm: &mut ScriptVm) -> ScriptValue {
- // Dev flags for looking at the skins in a plain window: a window of
- // its own has no host palette, so `--dark` forces the dark skin and
- // `--light` the light one. Before the crate's module, which splices
- // the skin's colours when it is evaluated.
- let args: Vec = std::env::args().collect();
- if args.iter().any(|a| a == "--dark") {
- force_skin(Some(false));
- } else if args.iter().any(|a| a == "--light") {
- force_skin(Some(true));
- }
- makepad_widgets::script_mod(vm);
- makepad_wm_theme::apply(vm);
- octosense_maps::view::script_mod(vm);
- self::script_mod(vm)
- }
-
- fn handle_event(&mut self, cx: &mut Cx, event: &Event) {
- // The window manager asked politely (SUPER+W): go now.
- if let Event::Custom(json) = event {
- if let Some(makepad_wm_api::WmEvent::CloseRequested) =
- makepad_wm_api::WmEvent::parse(json)
- {
- cx.quit();
- return;
- }
- }
- self.match_event(cx, event);
- self.ui.handle_event(cx, event, &mut Scope::empty());
- if self.then_preview && !self.open_first_result {
- if let Some(mut view) = self.ui.widget(cx, ids!(maps)).borrow_mut::() {
- if view.model().directions().is_some() {
- self.then_preview = false;
- view.start_navigation(cx, true);
- }
- }
- }
- if self.open_first_result {
- if let Some(mut view) = self.ui.widget(cx, ids!(maps)).borrow_mut::() {
- if !view.model().results.is_empty() {
- self.open_first_result = false;
- view.pick_result(cx, 0);
- if std::mem::take(&mut self.then_directions) {
- view.open_directions(cx);
- }
- }
- }
- }
- }
-}
diff --git a/apps/maps/native/src/model.rs b/apps/maps/native/src/model.rs
deleted file mode 100644
index 3a2aef4e..00000000
--- a/apps/maps/native/src/model.rs
+++ /dev/null
@@ -1,1423 +0,0 @@
-//! What the app knows and where the person is in it, with no widget in
-//! sight: the screen state machine, the search, the two ends of a route and
-//! a route per mode, the requests in flight (so a superseded reply is
-//! dropped and its request cancelled), the settings the app keeps, and the
-//! skin.
-
-use crate::geo::{LonLat, Units};
-use crate::guidance::ActiveNav;
-use crate::places::{self, Place};
-use crate::routing::{self, Directions, Mode, RouteError};
-use makepad_widgets::makepad_micro_serde::*;
-use makepad_widgets::*;
-use std::sync::atomic::{AtomicU8, Ordering};
-
-/// A long route's reply is a few hundred kilobytes of line and steps.
-pub const MAX_BODY_BYTES: usize = 4 * 1024 * 1024;
-/// The name every request carries: the services' terms ask who is calling.
-pub const USER_AGENT: &str = "OctosMap/0.1 (OctoSense)";
-/// The storage key of `MapsModel::saved_state`.
-pub const STATE_KEY: &str = "state";
-
-/// Where the map opens before it has ever been anywhere: the Bay Area.
-pub const DEFAULT_CENTER: LonLat = LonLat {
- lon: -122.0,
- lat: 37.4,
-};
-pub const DEFAULT_ZOOM: f64 = 9.5;
-
-/// Which end of the route a search is picking.
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub enum SearchTarget {
- Destination,
- Origin,
-}
-
-#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
-pub enum Screen {
- #[default]
- Explore,
- Search {
- target: SearchTarget,
- },
- Place,
- Directions,
- Navigating,
- Arrived,
-}
-
-/// One end of a route.
-#[derive(Clone, Debug, Default, PartialEq)]
-pub enum End {
- /// Wherever the device is: a position once there is a fix.
- #[default]
- MyLocation,
- Place(Place),
-}
-
-impl End {
- pub fn label(&self) -> &str {
- match self {
- End::MyLocation => "Your location",
- End::Place(place) => &place.name,
- }
- }
-}
-
-#[derive(Clone, Debug, Default)]
-pub enum RouteState {
- #[default]
- Idle,
- Loading,
- Ready(Directions),
- /// In words for the person.
- Failed(String),
-}
-
-impl RouteState {
- pub fn directions(&self) -> Option<&Directions> {
- match self {
- RouteState::Ready(directions) => Some(directions),
- _ => None,
- }
- }
-}
-
-#[derive(Clone, Debug, Default, PartialEq)]
-pub enum SearchState {
- #[default]
- Idle,
- Loading,
- /// The reply landed; `results` may still be empty.
- Done,
- Failed(String),
-}
-
-/// What a request in flight is for.
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub enum Request {
- Search,
- /// Naming a dropped pin.
- Reverse,
- Route(Mode),
- /// A new route from where the driver went astray.
- Reroute,
-}
-
-/// What the app keeps between runs.
-#[derive(Clone, Debug, PartialEq)]
-pub struct Settings {
- /// `None`: whatever the destination's country drives in.
- pub units: Option,
- /// `None`: whatever the skin is.
- pub dark_map: Option,
- pub buildings_3d: bool,
- pub labels: bool,
- /// The last camera, so the app reopens where it was.
- pub center: LonLat,
- pub zoom: f64,
-}
-
-impl Default for Settings {
- fn default() -> Self {
- Settings {
- units: None,
- dark_map: None,
- buildings_3d: true,
- labels: true,
- center: DEFAULT_CENTER,
- zoom: DEFAULT_ZOOM,
- }
- }
-}
-
-#[derive(Default)]
-pub struct MapsModel {
- screen: Screen,
- /// Where Search goes back to.
- search_return: Screen,
- pub query: String,
- pub results: Vec,
- pub search_state: SearchState,
- /// The place the sheet is about.
- place: Option,
- origin: End,
- destination: End,
- routes: [RouteState; 3],
- mode: Mode,
- /// A reroute's answer, until guidance takes it.
- reroute: Option,
- /// Navigation left the routes behind: they are asked for again.
- rerouted: bool,
- fix: Option,
- pub settings: Settings,
- in_flight: Vec<(LiveId, Request)>,
- cancelled: Vec,
- seq: u64,
-}
-
-impl MapsModel {
- pub fn screen(&self) -> Screen {
- self.screen
- }
-
- pub fn place(&self) -> Option<&Place> {
- self.place.as_ref()
- }
-
- pub fn origin(&self) -> &End {
- &self.origin
- }
-
- pub fn destination(&self) -> &End {
- &self.destination
- }
-
- pub fn mode(&self) -> Mode {
- self.mode
- }
-
- pub fn route(&self, mode: Mode) -> &RouteState {
- &self.routes[mode.index()]
- }
-
- /// The selected mode's route, when it has one.
- pub fn directions(&self) -> Option<&Directions> {
- self.route(self.mode).directions()
- }
-
- pub fn fix(&self) -> Option {
- self.fix
- }
-
- pub fn set_fix(&mut self, fix: LonLat) {
- self.fix = Some(fix);
- }
-
- /// Where an end is; `None` for the device's location before a fix.
- pub fn end_pos(&self, end: &End) -> Option {
- match end {
- End::MyLocation => self.fix,
- End::Place(place) => Some(place.pos),
- }
- }
-
- /// Whether Directions is waiting on a fix to route from or to.
- pub fn needs_fix(&self) -> bool {
- self.screen == Screen::Directions && self.ends().is_none()
- }
-
- /// Both ends' positions, once both have one.
- fn ends(&self) -> Option<(LonLat, LonLat)> {
- Some((
- self.end_pos(&self.origin)?,
- self.end_pos(&self.destination)?,
- ))
- }
-
- /// Miles or kilometres: the person's choice, else the country's habit.
- pub fn units(&self) -> Units {
- let of_place = || {
- self.place
- .as_ref()
- .map(|place| Units::for_country(&place.country_code))
- };
- self.settings.units.or_else(of_place).unwrap_or_default()
- }
-
- /// The units for one place that is not the selected one (a result row).
- pub fn units_for(&self, place: &Place) -> Units {
- self.settings
- .units
- .unwrap_or_else(|| Units::for_country(&place.country_code))
- }
-
- fn next_id(&mut self, what: &str) -> LiveId {
- self.seq += 1;
- LiveId::from_str(&format!("maps_{what}_{}", self.seq))
- }
-
- fn begin(&mut self, what: &str, request: Request) -> LiveId {
- let id = self.next_id(what);
- self.in_flight.push((id, request));
- id
- }
-
- /// Requests of this sort are no longer wanted: their replies will be
- /// dropped, and the view cancels them (`superseded`).
- fn supersede(&mut self, unwanted: impl Fn(Request) -> bool) {
- let cancelled = &mut self.cancelled;
- self.in_flight.retain(|(id, request)| {
- let gone = unwanted(*request);
- if gone {
- cancelled.push(*id);
- }
- !gone
- });
- }
-
- fn is_in_flight(&self, wanted: impl Fn(Request) -> bool) -> bool {
- self.in_flight.iter().any(|(_, request)| wanted(*request))
- }
-
- /// An end moved: every route was for the old pair.
- fn clear_routes(&mut self) {
- self.routes = Default::default();
- self.supersede(|request| matches!(request, Request::Route(_)));
- }
-
- // --- search ---
-
- pub fn open_search(&mut self, target: SearchTarget) {
- if !matches!(self.screen, Screen::Search { .. }) {
- self.search_return = self.screen;
- }
- self.set_query("");
- self.screen = Screen::Search { target };
- }
-
- pub fn search_target(&self) -> Option {
- match self.screen {
- Screen::Search { target } => Some(target),
- _ => None,
- }
- }
-
- /// The field changed. A blank one clears the list at once.
- pub fn set_query(&mut self, query: &str) {
- self.query = query.to_string();
- if query.trim().is_empty() {
- self.results.clear();
- self.search_state = SearchState::Idle;
- self.supersede(|request| request == Request::Search);
- }
- }
-
- /// The request for the current query, biased to `near`; `None` for a
- /// blank one. Any search still in flight is superseded.
- pub fn begin_search(&mut self, near: LonLat) -> Option<(LiveId, String)> {
- let query = self.query.trim().to_string();
- if query.is_empty() {
- return None;
- }
- self.supersede(|request| request == Request::Search);
- self.search_state = SearchState::Loading;
- Some((
- self.begin("search", Request::Search),
- places::search_url(&query, near),
- ))
- }
-
- /// A row of the list was picked, for whichever end Search was opened.
- pub fn pick_result(&mut self, index: usize) {
- let (Some(target), Some(place)) = (self.search_target(), self.results.get(index).cloned())
- else {
- return;
- };
- self.supersede(|request| request == Request::Search);
- match (target, self.search_return) {
- (SearchTarget::Origin, _) => {
- self.origin = End::Place(place);
- self.clear_routes();
- self.screen = Screen::Directions;
- }
- // Directions' own destination row: the trip keeps its origin.
- (SearchTarget::Destination, Screen::Directions) => {
- self.destination = End::Place(place.clone());
- self.place = Some(place);
- self.clear_routes();
- self.screen = Screen::Directions;
- }
- (SearchTarget::Destination, _) => self.select_place(place),
- }
- }
-
- // --- place ---
-
- /// Show `place` in the sheet. A new place is a new trip: the origin is
- /// the device again and the routes are forgotten.
- pub fn select_place(&mut self, place: Place) {
- // A lookup still naming the last pin would name this place instead.
- self.supersede(|request| request == Request::Reverse);
- self.clear_routes();
- self.origin = End::MyLocation;
- self.place = Some(place);
- self.screen = Screen::Place;
- }
-
- /// A long press: a pin at once, and the request that will name it.
- pub fn drop_pin(&mut self, at: LonLat) -> (LiveId, String) {
- self.select_place(places::dropped_pin(at));
- (
- self.begin("reverse", Request::Reverse),
- places::reverse_url(at),
- )
- }
-
- // --- directions ---
-
- pub fn open_directions(&mut self) {
- let Some(place) = self.place.clone() else {
- return;
- };
- // Back from Directions and in again: the same trip, its routes kept.
- if self.destination != End::Place(place.clone()) {
- // After a swap the place was the origin: not a trip from it to it.
- if self.origin == End::Place(place.clone()) {
- self.origin = End::MyLocation;
- }
- self.destination = End::Place(place);
- self.clear_routes();
- }
- self.screen = Screen::Directions;
- }
-
- pub fn swap_ends(&mut self) {
- std::mem::swap(&mut self.origin, &mut self.destination);
- self.clear_routes();
- }
-
- pub fn set_mode(&mut self, mode: Mode) {
- self.mode = mode;
- }
-
- /// The next route to ask for, one at a time: the selected mode first,
- /// then the others so every tab shows its time. `None` while one is in
- /// flight, when all are asked for, off the Directions screen, or while
- /// an end has no position.
- pub fn next_route_request(&mut self) -> Option<(LiveId, Mode, String)> {
- if self.screen != Screen::Directions
- || self.is_in_flight(|request| matches!(request, Request::Route(_)))
- {
- return None;
- }
- let (from, to) = self.ends()?;
- let idle = |mode: &Mode| matches!(self.routes[mode.index()], RouteState::Idle);
- let mode = Some(self.mode)
- .filter(idle)
- .or_else(|| Mode::ALL.into_iter().find(idle))?;
- self.routes[mode.index()] = RouteState::Loading;
- Some((
- self.begin("route", Request::Route(mode)),
- mode,
- routing::route_url(mode, from, to),
- ))
- }
-
- /// Ask again for a mode that failed.
- pub fn retry_route(&mut self, mode: Mode) {
- if matches!(self.routes[mode.index()], RouteState::Failed(_)) {
- self.routes[mode.index()] = RouteState::Idle;
- }
- }
-
- // --- navigation ---
-
- /// Guidance over the selected mode's route; `None` without one.
- pub fn start_navigation(&mut self, simulate: bool) -> Option {
- if self.screen != Screen::Directions {
- return None;
- }
- let directions = self.directions()?.clone();
- self.rerouted = false;
- self.screen = Screen::Navigating;
- Some(ActiveNav::new(directions, simulate))
- }
-
- /// A new route from `from` to the destination, in the selected mode.
- /// `None` off the Navigating screen or while one is already asked for.
- pub fn begin_reroute(&mut self, from: LonLat) -> Option<(LiveId, String)> {
- if self.screen != Screen::Navigating
- || self.is_in_flight(|request| request == Request::Reroute)
- {
- return None;
- }
- let to = self.end_pos(&self.destination)?;
- Some((
- self.begin("reroute", Request::Reroute),
- routing::route_url(self.mode, from, to),
- ))
- }
-
- pub fn take_reroute(&mut self) -> Option {
- self.reroute.take()
- }
-
- pub fn arrive(&mut self) {
- if self.screen == Screen::Navigating {
- self.screen = Screen::Arrived;
- }
- }
-
- /// The drive is over, by arrival or by End.
- fn end_navigation(&mut self) {
- self.supersede(|request| request == Request::Reroute);
- self.reroute = None;
- // A rerouted route starts somewhere along the way, not at the origin.
- if std::mem::take(&mut self.rerouted) {
- self.clear_routes();
- }
- }
-
- // --- requests ---
-
- pub fn owns(&self, id: LiveId) -> Option {
- self.in_flight
- .iter()
- .find(|(owned, _)| *owned == id)
- .map(|(_, request)| *request)
- }
-
- /// Land a reply: `Ok` is the body's text, `Err` why there is none.
- /// Returns what it was for, or `None` for a request no longer wanted.
- pub fn complete(&mut self, id: LiveId, body: Result) -> Option {
- let at = self.in_flight.iter().position(|(owned, _)| *owned == id)?;
- let (_, request) = self.in_flight.remove(at);
- match request {
- Request::Search => match body.and_then(|text| places::parse(&text)) {
- Ok(results) => {
- self.results = results;
- self.search_state = SearchState::Done;
- }
- // The list keeps what it had: a failure is not an empty answer.
- Err(why) => self.search_state = SearchState::Failed(why),
- },
- Request::Reverse => {
- // Nothing found, or no answer: the pin stays a pin.
- let found = body
- .and_then(|text| places::parse(&text))
- .ok()
- .and_then(|places| places.into_iter().next());
- if let (Some(found), Some(pin)) = (found, self.place.clone()) {
- // Where the finger was, not where the street's own point is.
- let named = Place {
- pos: pin.pos,
- ..found
- };
- // An end of the route that is this pin has its name too:
- // Directions may have opened before the answer came.
- for end in [&mut self.origin, &mut self.destination] {
- if *end == End::Place(pin.clone()) {
- *end = End::Place(named.clone());
- }
- }
- self.place = Some(named);
- }
- }
- Request::Route(mode) => self.routes[mode.index()] = route_state(mode, body),
- Request::Reroute => {
- if let RouteState::Ready(directions) = route_state(self.mode, body) {
- self.reroute = Some(directions.clone());
- self.routes[self.mode.index()] = RouteState::Ready(directions);
- self.rerouted = true;
- }
- }
- }
- Some(request)
- }
-
- /// The device lost its fix (a test's way to a drive that starts without one).
- #[cfg(test)]
- pub(crate) fn forget_fix(&mut self) {
- self.fix = None;
- }
-
- /// What is in flight, oldest first.
- #[cfg(test)]
- pub(crate) fn in_flight(&self) -> Vec<(LiveId, Request)> {
- self.in_flight.clone()
- }
-
- /// Requests that were superseded since the last call: cancel them.
- pub fn superseded(&mut self) -> Vec {
- std::mem::take(&mut self.cancelled)
- }
-
- /// Everything in flight, for a shutdown.
- pub fn cancel_all(&mut self) -> Vec {
- let mut ids = std::mem::take(&mut self.cancelled);
- ids.extend(self.in_flight.drain(..).map(|(id, _)| id));
- ids
- }
-
- /// One step back; `false` at Explore, where the host takes over.
- pub fn back(&mut self) -> bool {
- let after_place = if self.place.is_some() {
- Screen::Place
- } else {
- Screen::Explore
- };
- self.screen = match self.screen {
- Screen::Explore => return false,
- Screen::Search { .. } => {
- self.set_query("");
- self.search_return
- }
- Screen::Place => {
- self.supersede(|request| request == Request::Reverse);
- self.clear_routes();
- self.place = None;
- Screen::Explore
- }
- Screen::Directions => after_place,
- Screen::Navigating => {
- self.end_navigation();
- Screen::Directions
- }
- Screen::Arrived => {
- self.end_navigation();
- after_place
- }
- };
- true
- }
-
- // --- what the app keeps ---
-
- pub fn saved_state(&self) -> Vec {
- let settings = &self.settings;
- SavedState {
- center_lon: Some(settings.center.lon),
- center_lat: Some(settings.center.lat),
- zoom: Some(settings.zoom),
- imperial: settings.units.map(|units| units == Units::Imperial),
- dark_map: settings.dark_map,
- buildings_3d: Some(settings.buildings_3d),
- labels: Some(settings.labels),
- }
- .serialize_json()
- .into_bytes()
- }
-
- /// Bytes that are not a saved state leave the defaults.
- pub fn load_state(&mut self, bytes: &[u8]) {
- let Some(saved) = std::str::from_utf8(bytes)
- .ok()
- .and_then(|text| SavedState::deserialize_json_lenient(text).ok())
- else {
- return;
- };
- let settings = &mut self.settings;
- settings.units = saved.imperial.map(|imperial| {
- if imperial {
- Units::Imperial
- } else {
- Units::Metric
- }
- });
- settings.dark_map = saved.dark_map;
- settings.buildings_3d = saved.buildings_3d.unwrap_or(settings.buildings_3d);
- settings.labels = saved.labels.unwrap_or(settings.labels);
- // A camera only when all of it is on the map.
- if let (Some(lon), Some(lat), Some(zoom)) = (saved.center_lon, saved.center_lat, saved.zoom)
- {
- if (-180.0..=180.0).contains(&lon)
- && (-85.0..=85.0).contains(&lat)
- && (1.0..=22.0).contains(&zoom)
- {
- settings.center = LonLat::new(lon, lat);
- settings.zoom = zoom;
- }
- }
- }
-}
-
-/// `Settings` on disk. Every field optional: an older or a newer file still
-/// reads, and a choice never made stays unmade.
-// `pub`, not private: the micro_serde derives parse only a plain `pub`.
-#[derive(Clone, Debug, Default, SerJson, DeJson)]
-pub struct SavedState {
- pub center_lon: Option,
- pub center_lat: Option,
- pub zoom: Option,
- pub imperial: Option,
- pub dark_map: Option,
- pub buildings_3d: Option,
- pub labels: Option,
-}
-
-/// A routing reply as the tab's state, failures in words for the person.
-fn route_state(mode: Mode, body: Result) -> RouteState {
- match body.map(|text| routing::parse(mode, &text)) {
- Ok(Ok(directions)) => RouteState::Ready(directions),
- Ok(Err(RouteError::NoRoute)) => RouteState::Failed("No route found".into()),
- Ok(Err(RouteError::Malformed(_))) => {
- RouteState::Failed("Couldn't read the directions".into())
- }
- Err(why) => RouteState::Failed(format!("Couldn't get directions: {why}")),
- }
-}
-
-/// How long the locate button waits for a first fix before giving up.
-pub const LOCATION_FIX_TIMEOUT_SECONDS: f64 = 20.0;
-
-/// The locate button's state, after the framework's route app: nothing
-/// asked for, waiting for a first fix, or fixes flowing.
-#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
-pub enum LocationState {
- #[default]
- Idle,
- Waiting,
- Active,
-}
-
-/// What a tap on the locate button (or Directions wanting a fix) means.
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub enum LocationAsk {
- /// Start the platform's updates and the timeout.
- Start,
- /// Fixes are flowing: fly to the last one.
- Recenter,
- /// Already waiting.
- Ignore,
-}
-
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub enum LocationFix {
- First,
- Update,
- /// A fix nobody asked for: updates were stopped, one was still on its way.
- Ignore,
-}
-
-impl LocationState {
- pub fn asked(&mut self) -> LocationAsk {
- match self {
- LocationState::Idle => {
- *self = LocationState::Waiting;
- LocationAsk::Start
- }
- LocationState::Waiting => LocationAsk::Ignore,
- LocationState::Active => LocationAsk::Recenter,
- }
- }
-
- pub fn received_fix(&mut self) -> LocationFix {
- match self {
- LocationState::Idle => LocationFix::Ignore,
- LocationState::Waiting => {
- *self = LocationState::Active;
- LocationFix::First
- }
- LocationState::Active => LocationFix::Update,
- }
- }
-
- /// An error or the timeout: back to idle. `false` when nothing was
- /// wanted, so a stray error says nothing to the person.
- pub fn failed(&mut self) -> bool {
- std::mem::take(self) != LocationState::Idle
- }
-}
-
-/// The text of a fetched body, or why it is not one: the trust boundary
-/// between the network and the parsers. Only a 200 whose body is present,
-/// under the cap and UTF-8 gets through.
-pub fn body_text(status_code: u16, body: Option<&[u8]>) -> Result<&str, String> {
- if status_code != 200 {
- return Err(format!("HTTP {status_code}"));
- }
- let body = body.ok_or_else(|| "empty response".to_string())?;
- if body.len() > MAX_BODY_BYTES {
- return Err(format!("response too large ({} bytes)", body.len()));
- }
- std::str::from_utf8(body).map_err(|_| "response is not UTF-8".to_string())
-}
-
-/// A transport error in plain words, including nested Java/rustls causes.
-pub fn plain_error(message: &str) -> String {
- let lower = message.to_lowercase();
- let known = [
- // A certificate hostname mismatch can mention a DNS name without
- // being a DNS lookup failure.
- (
- &["sslhandshake", "certificate", "ssl", "tls"][..],
- "secure connection failed",
- ),
- (
- &[
- "unknownhost",
- "unable to resolve host",
- "no address associated",
- "dns",
- ][..],
- "no internet connection",
- ),
- (&["timed out", "timeout"][..], "timed out"),
- (
- &[
- "connectexception",
- "failed to connect",
- "connection refused",
- "unreachable",
- "econnreset",
- "connection reset",
- ][..],
- "could not connect",
- ),
- ];
- for (needles, words) in known {
- if needles.iter().any(|needle| lower.contains(needle)) {
- return words.to_string();
- }
- }
- // The last exception in a chain carries the message; drop the wrappers.
- let last = message
- .rsplit(": ")
- .find(|part| !part.trim().is_empty())
- .unwrap_or(message)
- .trim();
- if last.is_empty() {
- message.trim().to_string()
- } else {
- last.to_string()
- }
-}
-
-/// The app's colours: Google Maps' light look, or its dark counterpart when
-/// the host is dark.
-#[derive(Clone, Copy, Debug, PartialEq)]
-pub struct Skin {
- pub light: bool,
- /// The search page, behind its rows.
- pub ground: Vec4f,
- /// The search bar, the sheet, the round buttons, the top card.
- pub card: Vec4f,
- pub ink: Vec4f,
- /// Addresses, captions, idle icons.
- pub secondary: Vec4f,
- pub hairline: Vec4f,
- /// A field inside a card; the sheet's handle.
- pub field: Vec4f,
- /// Directions, the selected tab, the locate button when following.
- pub accent: Vec4f,
- /// Text and icons on the accent.
- pub on_accent: Vec4f,
- /// The navigation banner, behind its white text.
- pub banner: Vec4f,
- /// The time left, as text on a card: the banner's green, light enough
- /// to read on the dark card.
- pub go: Vec4f,
- /// The place pin, End, errors.
- pub alert: Vec4f,
-}
-
-impl Skin {
- pub fn for_mode(light: bool) -> Self {
- let c = Vec4f::from_u32;
- if light {
- Skin {
- light,
- ground: c(0xffffffff),
- card: c(0xffffffff),
- ink: c(0x202124ff),
- secondary: c(0x5f6368ff),
- hairline: c(0xdadce0ff),
- field: c(0xf1f3f4ff),
- accent: c(0x1a73e8ff),
- on_accent: c(0xffffffff),
- banner: c(0x0b8043ff),
- go: c(0x0b8043ff),
- alert: c(0xd93025ff),
- }
- } else {
- Skin {
- light,
- ground: c(0x202124ff),
- card: c(0x303134ff),
- ink: c(0xe8eaedff),
- secondary: c(0x9aa0a6ff),
- hairline: c(0x3c4043ff),
- field: c(0x3c4043ff),
- accent: c(0x8ab4f8ff),
- on_accent: c(0x202124ff),
- banner: c(0x0d652dff),
- go: c(0x81c995ff),
- alert: c(0xf28b82ff),
- }
- }
- }
-
- /// The skin for this isolate: a forced one (`force_skin`, the
- /// standalone window's `--dark` and `--light`), else the host palette's
- /// light or dark mode, else light. The host re-runs the crate's
- /// `script_mod` on a style change, so the answer is re-read then.
- pub fn for_vm(vm: &mut ScriptVm) -> Self {
- let light = forced_light()
- .or_else(|| makepad_wm_theme::current_for_vm(vm).map(|p| p.light_mode))
- .unwrap_or(true);
- LAST_SKIN.store(if light { 1 } else { 2 }, Ordering::Relaxed);
- Self::for_mode(light)
- }
-
- /// The skin the last `for_vm` picked: what the Rust side tints with
- /// between DSL applies. Light before any.
- pub fn current() -> Self {
- Self::for_mode(LAST_SKIN.load(Ordering::Relaxed) != 2)
- }
-}
-
-/// 0: not forced; 1: light; 2: dark.
-static FORCED_SKIN: AtomicU8 = AtomicU8::new(0);
-/// The last skin `Skin::for_vm` resolved, the same encoding.
-static LAST_SKIN: AtomicU8 = AtomicU8::new(0);
-
-/// Force the skin for every isolate in this process, before the crate's
-/// `script_mod` runs; `None` follows the palette again.
-pub fn force_skin(light: Option) {
- FORCED_SKIN.store(
- match light {
- None => 0,
- Some(true) => 1,
- Some(false) => 2,
- },
- Ordering::Relaxed,
- );
-}
-
-fn forced_light() -> Option {
- match FORCED_SKIN.load(Ordering::Relaxed) {
- 1 => Some(true),
- 2 => Some(false),
- _ => None,
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use crate::places::PlaceKind;
-
- const SEARCH_REPLY: &str = include_str!("../tests/fixtures/photon-search.json");
- const REVERSE_REPLY: &str = include_str!("../tests/fixtures/photon-reverse.json");
- const EMPTY_REPLY: &str = include_str!("../tests/fixtures/photon-empty.json");
- const CAR: &str = include_str!("../tests/fixtures/osrm-car.json");
- const BIKE: &str = include_str!("../tests/fixtures/osrm-bike.json");
- const FOOT: &str = include_str!("../tests/fixtures/osrm-foot.json");
- const NO_ROUTE: &str = include_str!("../tests/fixtures/osrm-noroute.json");
-
- const SAN_JOSE: LonLat = LonLat {
- lon: -121.8863,
- lat: 37.3382,
- };
-
- fn place(name: &str, country: &str) -> Place {
- Place {
- name: name.into(),
- pos: LonLat::new(-121.9552, 37.3541),
- country_code: country.into(),
- ..Place::default()
- }
- }
-
- /// A model on the Directions screen, from a fix to a place.
- fn at_directions() -> MapsModel {
- let mut model = MapsModel::default();
- model.set_fix(SAN_JOSE);
- model.select_place(place("Santa Clara University", "US"));
- model.open_directions();
- model
- }
-
- /// Land `body` for the next route request, which must be for `mode`.
- fn land_route(model: &mut MapsModel, mode: Mode, body: &str) {
- let (id, asked, _) = model.next_route_request().expect("a route to ask for");
- assert_eq!(asked, mode);
- assert_eq!(
- model.complete(id, Ok(body.to_string())),
- Some(Request::Route(mode))
- );
- }
-
- #[test]
- fn a_search_fills_the_list_and_a_pick_opens_the_place() {
- let mut model = MapsModel::default();
- model.open_search(SearchTarget::Destination);
- assert_eq!(
- model.screen(),
- Screen::Search {
- target: SearchTarget::Destination
- }
- );
- // Nothing typed: nothing to ask.
- assert!(model.begin_search(SAN_JOSE).is_none());
- model.set_query(" ");
- assert!(model.begin_search(SAN_JOSE).is_none());
- model.set_query("santa clara university");
- let (id, url) = model.begin_search(SAN_JOSE).unwrap();
- assert!(
- url.contains("q=santa%20clara%20university") && url.contains("lat=37.3382"),
- "{url}"
- );
- assert_eq!(model.search_state, SearchState::Loading);
- assert_eq!(
- model.complete(id, Ok(SEARCH_REPLY.into())),
- Some(Request::Search)
- );
- assert_eq!(model.search_state, SearchState::Done);
- assert_eq!(model.results.len(), 3);
- model.pick_result(0);
- assert_eq!(model.screen(), Screen::Place);
- assert_eq!(model.place().unwrap().name, "Santa Clara University");
- // A row that is not there is not a pick.
- model.open_search(SearchTarget::Destination);
- model.pick_result(99);
- assert_eq!(
- model.screen(),
- Screen::Search {
- target: SearchTarget::Destination
- }
- );
- }
-
- #[test]
- fn a_newer_search_supersedes_the_older_and_its_reply_is_dropped() {
- let mut model = MapsModel::default();
- model.open_search(SearchTarget::Destination);
- model.set_query("santa");
- let (first, _) = model.begin_search(SAN_JOSE).unwrap();
- model.set_query("santa clara");
- let (second, _) = model.begin_search(SAN_JOSE).unwrap();
- assert_ne!(first, second);
- assert_eq!(model.superseded(), vec![first]);
- assert!(model.superseded().is_empty(), "handed out once");
- assert_eq!(model.owns(first), None);
- assert_eq!(model.complete(first, Ok(SEARCH_REPLY.into())), None);
- assert!(model.results.is_empty());
- assert_eq!(
- model.complete(second, Ok(EMPTY_REPLY.into())),
- Some(Request::Search)
- );
- assert_eq!(model.search_state, SearchState::Done);
- assert!(model.results.is_empty());
- // Clearing the field forgets the list and whatever was in flight.
- model.set_query("santa clara u");
- let (third, _) = model.begin_search(SAN_JOSE).unwrap();
- model.set_query("");
- assert_eq!(model.superseded(), vec![third]);
- assert_eq!(model.search_state, SearchState::Idle);
- }
-
- #[test]
- fn a_failed_search_says_why_and_keeps_the_last_list() {
- let mut model = MapsModel::default();
- model.open_search(SearchTarget::Destination);
- model.set_query("santa clara");
- let (id, _) = model.begin_search(SAN_JOSE).unwrap();
- model.complete(id, Ok(SEARCH_REPLY.into()));
- let (id, _) = model.begin_search(SAN_JOSE).unwrap();
- model.complete(id, Err("no internet connection".into()));
- assert_eq!(
- model.search_state,
- SearchState::Failed("no internet connection".into())
- );
- assert_eq!(model.results.len(), 3);
- // A body that is not Photon's is a failure too.
- let (id, _) = model.begin_search(SAN_JOSE).unwrap();
- model.complete(id, Ok("".into()));
- assert!(matches!(model.search_state, SearchState::Failed(_)));
- }
-
- #[test]
- fn a_dropped_pin_opens_at_once_and_the_lookup_names_it() {
- let mut model = MapsModel::default();
- let (id, url) = model.drop_pin(SAN_JOSE);
- assert!(url.contains("/reverse?lat=37.338200"), "{url}");
- assert_eq!(model.screen(), Screen::Place);
- assert_eq!(model.place().unwrap().name, "Dropped pin");
- assert_eq!(
- model.complete(id, Ok(REVERSE_REPLY.into())),
- Some(Request::Reverse)
- );
- let named = model.place().unwrap();
- assert_eq!(named.name, "East Santa Clara Street");
- assert_eq!(named.kind, PlaceKind::Street);
- assert_eq!(named.country_code, "US");
- // The pin stays where the finger was, not where the street's point is.
- assert_eq!(named.pos, SAN_JOSE);
- // A lookup that finds nothing leaves the pin a pin; one that lands
- // after the person moved on names nothing.
- let (id, _) = model.drop_pin(SAN_JOSE);
- model.complete(id, Ok(EMPTY_REPLY.into()));
- assert_eq!(model.place().unwrap().name, "Dropped pin");
- let (stale, _) = model.drop_pin(SAN_JOSE);
- model.select_place(place("Elsewhere", "US"));
- assert_eq!(model.superseded(), vec![stale]);
- assert_eq!(model.complete(stale, Ok(REVERSE_REPLY.into())), None);
- assert_eq!(model.place().unwrap().name, "Elsewhere");
- }
-
- #[test]
- fn routes_are_asked_for_one_at_a_time_the_selected_mode_first() {
- let mut model = at_directions();
- assert_eq!(model.screen(), Screen::Directions);
- assert_eq!(model.origin(), &End::MyLocation);
- assert_eq!(model.destination().label(), "Santa Clara University");
- model.set_mode(Mode::Walk);
- let (id, mode, url) = model.next_route_request().unwrap();
- assert_eq!(mode, Mode::Walk);
- assert!(
- url.contains("/routed-foot/")
- && url.contains("-121.886300,37.338200;-121.955200,37.354100"),
- "{url}"
- );
- assert!(matches!(model.route(Mode::Walk), RouteState::Loading));
- // One in flight: nothing more to ask.
- assert!(model.next_route_request().is_none());
- assert_eq!(
- model.complete(id, Ok(FOOT.into())),
- Some(Request::Route(Mode::Walk))
- );
- assert!(model.directions().is_some());
- // Then the other tabs, in their order.
- land_route(&mut model, Mode::Car, CAR);
- land_route(&mut model, Mode::Bike, BIKE);
- assert!(model.next_route_request().is_none());
- for mode in Mode::ALL {
- assert!(model.route(mode).directions().is_some(), "{mode:?}");
- }
- }
-
- #[test]
- fn no_route_fails_its_own_tab_only_and_can_be_retried() {
- let mut model = at_directions();
- land_route(&mut model, Mode::Car, NO_ROUTE);
- assert!(
- matches!(model.route(Mode::Car), RouteState::Failed(why) if why == "No route found")
- );
- assert!(model.directions().is_none());
- land_route(&mut model, Mode::Walk, FOOT);
- let (id, mode, _) = model.next_route_request().unwrap();
- assert_eq!(mode, Mode::Bike);
- model.complete(id, Err("timed out".into()));
- assert!(
- matches!(model.route(Mode::Bike), RouteState::Failed(why) if why.contains("timed out"))
- );
- assert!(model.route(Mode::Walk).directions().is_some());
- assert!(model.next_route_request().is_none());
- model.retry_route(Mode::Bike);
- land_route(&mut model, Mode::Bike, BIKE);
- assert!(model.route(Mode::Bike).directions().is_some());
- }
-
- #[test]
- fn no_fix_no_route_until_there_is_one_or_a_start_is_chosen() {
- let mut model = MapsModel::default();
- model.select_place(place("Santa Clara University", "US"));
- model.open_directions();
- assert!(model.needs_fix());
- assert!(model.next_route_request().is_none());
- // The person picks a starting point instead.
- model.open_search(SearchTarget::Origin);
- model.set_query("santa clara");
- let (id, _) = model.begin_search(SAN_JOSE).unwrap();
- model.complete(id, Ok(SEARCH_REPLY.into()));
- model.pick_result(1);
- assert_eq!(model.screen(), Screen::Directions);
- assert_eq!(model.origin().label(), "Santa Clara University Library");
- assert_eq!(model.destination().label(), "Santa Clara University");
- assert!(!model.needs_fix());
- assert!(model.next_route_request().is_some());
- // Or a fix arrives.
- let mut model = MapsModel::default();
- model.select_place(place("Santa Clara University", "US"));
- model.open_directions();
- model.set_fix(SAN_JOSE);
- assert!(!model.needs_fix());
- assert!(model.next_route_request().is_some());
- }
-
- #[test]
- fn changing_an_end_forgets_the_routes() {
- let mut model = at_directions();
- land_route(&mut model, Mode::Car, CAR);
- let (in_flight, _, _) = model.next_route_request().unwrap();
- model.swap_ends();
- assert_eq!(model.origin().label(), "Santa Clara University");
- assert_eq!(model.destination(), &End::MyLocation);
- assert!(matches!(model.route(Mode::Car), RouteState::Idle));
- // The route that was being fetched was for the old ends.
- assert_eq!(model.superseded(), vec![in_flight]);
- assert_eq!(model.complete(in_flight, Ok(FOOT.into())), None);
- let (_, mode, url) = model.next_route_request().unwrap();
- assert_eq!(mode, Mode::Car);
- assert!(
- url.contains("-121.955200,37.354100;-121.886300,37.338200"),
- "{url}"
- );
- // A new destination from Directions' own search does the same.
- let mut model = at_directions();
- land_route(&mut model, Mode::Car, CAR);
- model.open_search(SearchTarget::Destination);
- model.set_query("library");
- let (id, _) = model.begin_search(SAN_JOSE).unwrap();
- model.complete(id, Ok(SEARCH_REPLY.into()));
- model.pick_result(1);
- assert_eq!(model.screen(), Screen::Directions);
- assert_eq!(
- model.destination().label(),
- "Santa Clara University Library"
- );
- assert!(matches!(model.route(Mode::Car), RouteState::Idle));
- }
-
- #[test]
- fn a_swap_and_a_way_back_do_not_make_a_trip_from_a_place_to_itself() {
- let mut model = at_directions();
- model.swap_ends();
- assert!(model.back());
- assert_eq!(model.screen(), Screen::Place);
- model.open_directions();
- assert_eq!(model.origin(), &End::MyLocation);
- assert_eq!(model.destination().label(), "Santa Clara University");
- }
-
- #[test]
- fn a_pin_named_after_directions_opened_names_the_routes_end_too() {
- let mut model = MapsModel::default();
- model.set_fix(LonLat::new(-121.9, 37.35));
- let (id, _) = model.drop_pin(SAN_JOSE);
- model.open_directions();
- assert_eq!(model.destination().label(), "Dropped pin");
- land_route(&mut model, Mode::Car, CAR);
- model.complete(id, Ok(REVERSE_REPLY.into()));
- assert_eq!(model.destination().label(), "East Santa Clara Street");
- // The same trip: back and in again keeps its route.
- assert!(model.back());
- model.open_directions();
- assert!(model.route(Mode::Car).directions().is_some());
- }
-
- #[test]
- fn back_walks_every_screen_down_to_explore_and_then_declines() {
- let mut model = at_directions();
- land_route(&mut model, Mode::Car, CAR);
- assert!(model.start_navigation(true).is_some());
- assert_eq!(model.screen(), Screen::Navigating);
- assert!(model.back());
- assert_eq!(model.screen(), Screen::Directions);
- // Search from Directions goes back to Directions.
- model.open_search(SearchTarget::Origin);
- assert!(model.back());
- assert_eq!(model.screen(), Screen::Directions);
- assert!(model.back());
- assert_eq!(model.screen(), Screen::Place);
- // Search from Place goes back to Place.
- model.open_search(SearchTarget::Destination);
- assert!(model.back());
- assert_eq!(model.screen(), Screen::Place);
- assert!(model.back());
- assert_eq!(model.screen(), Screen::Explore);
- assert!(model.place().is_none());
- model.open_search(SearchTarget::Destination);
- assert!(model.back());
- assert_eq!(model.screen(), Screen::Explore);
- assert!(!model.back(), "the host's turn");
- }
-
- #[test]
- fn navigation_needs_a_route_and_arrival_ends_at_the_place() {
- let mut model = at_directions();
- assert!(model.start_navigation(false).is_none(), "no route yet");
- assert_eq!(model.screen(), Screen::Directions);
- land_route(&mut model, Mode::Car, CAR);
- let nav = model.start_navigation(false).unwrap();
- assert!(!nav.simulate());
- model.arrive();
- assert_eq!(model.screen(), Screen::Arrived);
- assert!(model.back());
- assert_eq!(model.screen(), Screen::Place);
- assert_eq!(model.place().unwrap().name, "Santa Clara University");
- // Arriving off the Navigating screen is nothing.
- model.arrive();
- assert_eq!(model.screen(), Screen::Place);
- }
-
- #[test]
- fn a_reroute_is_asked_for_once_and_its_answer_waits_for_guidance() {
- let mut model = at_directions();
- assert!(model.begin_reroute(SAN_JOSE).is_none(), "not navigating");
- land_route(&mut model, Mode::Car, CAR);
- model.start_navigation(false).unwrap();
- let astray = LonLat::new(-121.9, 37.34);
- let (id, url) = model.begin_reroute(astray).unwrap();
- assert!(
- url.contains("/routed-car/")
- && url.contains("-121.900000,37.340000;-121.955200,37.354100"),
- "{url}"
- );
- assert!(model.begin_reroute(astray).is_none(), "one at a time");
- assert_eq!(model.complete(id, Ok(CAR.into())), Some(Request::Reroute));
- assert!(model.take_reroute().is_some());
- assert!(model.take_reroute().is_none());
- // A failed one leaves nothing to take, and the next may be asked.
- let (id, _) = model.begin_reroute(astray).unwrap();
- assert_eq!(
- model.complete(id, Err("timed out".into())),
- Some(Request::Reroute)
- );
- assert!(model.take_reroute().is_none());
- assert!(model.begin_reroute(astray).is_some());
- // Ending the drive forgets routes that no longer start where it did.
- model.back();
- assert_eq!(model.screen(), Screen::Directions);
- assert!(matches!(model.route(Mode::Car), RouteState::Idle));
- }
-
- #[test]
- fn units_follow_the_destination_until_the_person_chooses() {
- let mut model = MapsModel::default();
- assert_eq!(model.units(), Units::Metric);
- model.select_place(place("Santa Clara University", "US"));
- assert_eq!(model.units(), Units::Imperial);
- model.select_place(place("Rijksmuseum", "NL"));
- assert_eq!(model.units(), Units::Metric);
- model.settings.units = Some(Units::Imperial);
- assert_eq!(model.units(), Units::Imperial);
- }
-
- /// WCAG relative luminance of an opaque colour.
- fn luminance(colour: Vec4f) -> f32 {
- let linear = |v: f32| {
- if v <= 0.04045 {
- v / 12.92
- } else {
- ((v + 0.055) / 1.055).powf(2.4)
- }
- };
- 0.2126 * linear(colour.x) + 0.7152 * linear(colour.y) + 0.0722 * linear(colour.z)
- }
-
- fn contrast(a: Vec4f, b: Vec4f) -> f32 {
- let (la, lb) = (luminance(a), luminance(b));
- (la.max(lb) + 0.05) / (la.min(lb) + 0.05)
- }
-
- #[test]
- fn text_reads_against_its_card_in_both_skins() {
- for skin in [Skin::for_mode(true), Skin::for_mode(false)] {
- for (name, colour) in [
- ("ink", skin.ink),
- ("secondary", skin.secondary),
- ("accent", skin.accent),
- ("go", skin.go),
- ("alert", skin.alert),
- ] {
- let ratio = contrast(colour, skin.card);
- assert!(
- ratio >= 3.0,
- "{name} on the card, light={}: contrast {ratio:.2}",
- skin.light
- );
- }
- assert!(contrast(Vec4f::from_u32(0xffffffff), skin.banner) >= 4.5);
- }
- }
-
- #[test]
- fn a_result_is_measured_in_its_own_countrys_units() {
- let mut model = MapsModel::default();
- // No place is selected while the results are listed.
- assert_eq!(
- model.units_for(&place("Saratoga Library", "US")),
- Units::Imperial
- );
- assert_eq!(model.units_for(&place("Rijksmuseum", "NL")), Units::Metric);
- model.settings.units = Some(Units::Metric);
- assert_eq!(
- model.units_for(&place("Saratoga Library", "US")),
- Units::Metric
- );
- }
-
- #[test]
- fn the_settings_survive_a_restart_and_garbage_does_not_touch_them() {
- let mut model = MapsModel::default();
- model.settings = Settings {
- units: Some(Units::Imperial),
- dark_map: Some(true),
- buildings_3d: false,
- labels: true,
- center: SAN_JOSE,
- zoom: 14.5,
- };
- let bytes = model.saved_state();
- let mut restored = MapsModel::default();
- restored.load_state(&bytes);
- assert_eq!(restored.settings, model.settings);
- // Choices never made stay unmade.
- let mut fresh = MapsModel::default();
- fresh.load_state(&MapsModel::default().saved_state());
- assert_eq!(fresh.settings, Settings::default());
- for garbage in [
- &b"not json"[..],
- &b""[..],
- &br#"{"zoom":"high","center_lat":1e400}"#[..],
- &[0xff, 0xfe][..],
- ] {
- let mut model = MapsModel::default();
- model.load_state(garbage);
- assert_eq!(model.settings, Settings::default());
- }
- // A camera off the map is not restored.
- let mut model = MapsModel::default();
- model.load_state(br#"{"center_lon":500.0,"center_lat":95.0,"zoom":99.0}"#);
- assert_eq!(model.settings, Settings::default());
- }
-
- #[test]
- fn the_locate_button_asks_once_then_recentres() {
- let mut location = LocationState::default();
- // A fix or an error nobody asked for is nothing.
- assert_eq!(location.received_fix(), LocationFix::Ignore);
- assert!(!location.failed());
- assert_eq!(location.asked(), LocationAsk::Start);
- assert_eq!(location.asked(), LocationAsk::Ignore, "already waiting");
- assert_eq!(location.received_fix(), LocationFix::First);
- assert_eq!(location.received_fix(), LocationFix::Update);
- assert_eq!(location.asked(), LocationAsk::Recenter);
- // Denied, unavailable or timed out: the next tap starts over.
- assert!(location.failed());
- assert_eq!(location, LocationState::Idle);
- assert_eq!(location.asked(), LocationAsk::Start);
- assert!(location.failed());
- assert!(!location.failed());
- }
-
- #[test]
- fn shutting_down_cancels_whatever_is_in_flight() {
- let mut model = at_directions();
- let (route, _, _) = model.next_route_request().unwrap();
- model.open_search(SearchTarget::Origin);
- model.set_query("library");
- let (search, _) = model.begin_search(SAN_JOSE).unwrap();
- let mut ids = model.cancel_all();
- ids.sort();
- let mut expected = vec![route, search];
- expected.sort();
- assert_eq!(ids, expected);
- assert_eq!(model.owns(route), None);
- }
-
- #[test]
- fn only_a_utf8_200_under_the_cap_gets_through() {
- assert_eq!(body_text(200, Some(b"{}")), Ok("{}"));
- assert_eq!(body_text(404, Some(b"{}")), Err("HTTP 404".to_string()));
- assert_eq!(body_text(200, None), Err("empty response".to_string()));
- assert!(body_text(200, Some(&[0xff, 0xfe])).is_err());
- assert!(body_text(200, Some(&vec![b' '; MAX_BODY_BYTES + 1])).is_err());
- }
-
- #[test]
- fn transport_errors_in_plain_words() {
- assert_eq!(
- plain_error("java.util.concurrent.CompletionException: java.net.UnknownHostException: Unable to resolve host"),
- "no internet connection"
- );
- assert_eq!(plain_error("request timed out"), "timed out");
- assert_eq!(
- plain_error("java.io.IOException: something odd"),
- "something odd"
- );
- }
-}
diff --git a/apps/maps/native/src/module.rs b/apps/maps/native/src/module.rs
deleted file mode 100644
index 66a0a4b7..00000000
--- a/apps/maps/native/src/module.rs
+++ /dev/null
@@ -1,164 +0,0 @@
-//! OctosMap as a module: the app the window manager seats in a tile
-//! in-process, in an isolate of its own.
-//!
-//! `register` puts this crate's widget family and root type into the
-//! isolate the host prepared; `create` mints one `MapsView{}` root there.
-//! The widget owns its requests and cancels them at shutdown. On Android,
-//! service requests use a rustls worker for TLS 1.3 support on older phones;
-//! other platforms and map tiles use the framework's HTTP request API.
-//! Location fixes come from the platform on every host.
-
-use crate::view::MapsView;
-use makepad_ai_services::wire::{ServiceCall, ServiceManifest, ToolResult};
-use makepad_app_module::*;
-use makepad_widgets::*;
-
-pub struct MapsModule;
-
-/// The one linked instance of the module description: immutable, no state.
-pub static MAPS_MODULE: MapsModule = MapsModule;
-
-impl AppModule for MapsModule {
- fn id(&self) -> &'static str {
- "maps"
- }
-
- fn label(&self) -> &'static str {
- "OctosMap"
- }
-
- fn register(&self, vm: &mut ScriptVm) {
- crate::view::script_mod(vm);
- }
-
- fn open_schema(&self) -> OpenSchema {
- OpenSchema::new(1)
- }
-
- fn create(
- &self,
- vm: &mut ScriptVm,
- _open: ValidatedOpen,
- handles: InstanceHandles,
- ) -> InstanceParts {
- let value = script_eval!(vm, {
- use mod.widgets.*
- MapsView {}
- });
- let root = WidgetRef::script_from_value(vm, value);
- if let Some(mut view) = root.borrow_mut::() {
- // The instance's disk is its storage jail: the settings and the
- // last camera live there, on every host the same way.
- view.set_storage(vm.cx_mut(), handles.storage);
- }
- let shutdown_root = root.clone();
- InstanceParts {
- root,
- executor: Box::new(MapsExecutor),
- shutdown: Box::new(move |vm| {
- if let Some(mut view) = shutdown_root.borrow_mut::() {
- view.shutdown(vm.cx_mut());
- }
- }),
- }
- }
-
- /// The settings live in the storage jail; the tiles, the searches and
- /// the routes are fetched over HTTP; the puck is the device's location.
- fn capabilities(&self) -> &'static [&'static str] {
- &["storage", "net", "location"]
- }
-}
-
-/// No tools yet: the assistant is told to use the app itself.
-struct MapsExecutor;
-
-impl ServiceExecutor for MapsExecutor {
- fn manifest(&self) -> ServiceManifest {
- ServiceManifest::new(
- "maps",
- "OctosMap",
- "Maps, places, directions and turn-by-turn navigation.",
- )
- }
-
- fn execute(&mut self, _cx: &mut Cx, call: &ServiceCall) -> ExecOutcome {
- ExecOutcome::Done(ToolResult::unavailable(
- &call.call_id,
- "Use the OctosMap interface",
- ))
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use crate::model::Screen;
- use crate::test_support::Isolate;
-
- #[test]
- fn the_module_describes_itself_and_opens_empty() {
- let m = &MAPS_MODULE;
- assert_eq!(m.id(), "maps");
- assert_eq!(m.label(), "OctosMap");
- assert_eq!(m.capabilities(), &["storage", "net", "location"]);
- let schema = m.open_schema();
- assert_eq!(schema.version, 1);
- assert!(schema.empty_open().is_ok(), "no argument is required");
- assert!(
- schema.validate(r#"{"place":"home"}"#, &[]).is_err(),
- "nothing is passed in"
- );
- }
-
- /// The whole contract without a window manager: an instance in an
- /// isolate of its own, its jail read on the start, no tool to call,
- /// teardown in the host's order.
- #[test]
- fn the_module_mints_its_root_in_a_fresh_isolate() {
- let mut iso = Isolate::new();
- let storage = iso.cx.storage("maps.test");
- let (replies, _upstream) = ReplySink::pair();
- let handles = InstanceHandles {
- scope: InstanceScope::new(1, 1),
- storage,
- viewport: Viewport {
- size: dvec2(400.0, 700.0),
- },
- replies,
- windows: Default::default(),
- };
- let open = MAPS_MODULE.open_schema().empty_open().unwrap();
- let InstanceParts {
- root,
- executor,
- shutdown,
- } = iso.with_vm(|vm| {
- let parts = MAPS_MODULE.create(vm, open, handles);
- let errors = vm.take_errors();
- assert!(
- errors.is_empty(),
- "the isolate evaluated the view: {errors:?}"
- );
- parts
- });
- assert!(
- root.borrow::().is_some(),
- "the root is a MapsView"
- );
- let manifest = executor.manifest();
- assert_eq!(manifest.id, "maps");
- assert!(manifest.tools.is_empty(), "no tools yet");
-
- // The first event starts it: Explore, and the jail being read.
- iso.entered(|cx| root.handle_event(cx, &Event::Custom(String::new()), &mut Scope::empty()));
- {
- let view = root.borrow::().unwrap();
- assert_eq!(view.model().screen(), Screen::Explore);
- assert!(view.has_pending_load(), "the jail is read on the start");
- }
- drop(executor);
- iso.with_vm(|vm| shutdown(vm));
- iso.teardown(root);
- }
-}
diff --git a/apps/maps/native/src/places.rs b/apps/maps/native/src/places.rs
deleted file mode 100644
index 1b4b2198..00000000
--- a/apps/maps/native/src/places.rs
+++ /dev/null
@@ -1,352 +0,0 @@
-//! Places from Photon (photon.komoot.io, OpenStreetMap data, no key): the
-//! search and reverse URLs, and their GeoJSON replies as `Place` rows with
-//! the lines the interface shows.
-
-use crate::geo::LonLat;
-use makepad_widgets::makepad_micro_serde::*;
-
-const SEARCH: &str = "https://photon.komoot.io/api/";
-const REVERSE: &str = "https://photon.komoot.io/reverse";
-/// A page of results: what fits a phone without scrolling far.
-const SEARCH_LIMIT: usize = 8;
-
-/// What a place is, as far as its row's icon goes.
-#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
-pub enum PlaceKind {
- #[default]
- Place,
- Food,
- Shop,
- Transit,
- Lodging,
- Street,
-}
-
-#[derive(Clone, Debug, Default, PartialEq)]
-pub struct Place {
- pub name: String,
- /// `University`, `Street`, `Marketing office`; empty when unknown.
- pub category: String,
- pub kind: PlaceKind,
- /// `500 El Camino Real, Santa Clara, California`; empty when unknown.
- pub address: String,
- pub pos: LonLat,
- /// ISO 3166 alpha-2, upper case; empty when unknown.
- pub country_code: String,
- /// Label and value rows for the sheet: a phone number, opening hours.
- pub details: Vec<(String, String)>,
-}
-
-/// Places matching `query`, the ones near `near` first.
-pub fn search_url(query: &str, near: LonLat) -> String {
- format!(
- "{SEARCH}?q={}&limit={SEARCH_LIMIT}&lang=en&lat={:.4}&lon={:.4}",
- percent_encode(query),
- near.lat,
- near.lon
- )
-}
-
-/// Everything but RFC 3986's unreserved characters, byte by byte.
-fn percent_encode(text: &str) -> String {
- let mut out = String::with_capacity(text.len());
- for byte in text.bytes() {
- if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
- out.push(byte as char);
- } else {
- out.push_str(&format!("%{byte:02X}"));
- }
- }
- out
-}
-
-/// What is at `at`.
-pub fn reverse_url(at: LonLat) -> String {
- format!("{REVERSE}?lat={:.6}&lon={:.6}&lang=en", at.lat, at.lon)
-}
-
-/// A reply of either endpoint, in the service's order.
-pub fn parse(json: &str) -> Result, String> {
- // Lenient: Photon sends fields we do not model (ids, extents, postcodes).
- let reply: Reply = DeJson::deserialize_json_lenient(json).map_err(|e| format!("{e:?}"))?;
- let features = reply.features.ok_or("not a Photon reply: no features")?;
- Ok(features.into_iter().filter_map(place).collect())
-}
-
-#[derive(Clone, Debug, Default, DeJson)]
-struct Reply {
- features: Option>,
-}
-
-#[derive(Clone, Debug, Default, DeJson)]
-struct Feature {
- geometry: Option,
- properties: Option,
-}
-
-#[derive(Clone, Debug, Default, DeJson)]
-struct Geometry {
- coordinates: Option>,
-}
-
-#[derive(Clone, Debug, Default, DeJson)]
-struct Properties {
- name: Option,
- housenumber: Option,
- street: Option,
- city: Option,
- state: Option,
- countrycode: Option,
- osm_key: Option,
- osm_value: Option,
-}
-
-/// One feature as a row; `None` when it has no point to show it at, or
-/// nothing at all to call it.
-fn place(feature: Feature) -> Option {
- let pos = match feature.geometry?.coordinates?.as_slice() {
- [lon, lat, ..] if lon.is_finite() && lat.is_finite() => LonLat::new(*lon, *lat),
- _ => return None,
- };
- let p = feature.properties.unwrap_or_default();
- let street_line = match (filled(&p.housenumber), filled(&p.street)) {
- (Some(number), Some(street)) => Some(format!("{number} {street}")),
- (None, Some(street)) => Some(street.to_string()),
- (_, None) => None,
- };
- let name = filled(&p.name)
- .map(str::to_string)
- .or_else(|| street_line.clone())?;
- // A part that only repeats the name says nothing.
- let address = [street_line.as_deref(), filled(&p.city), filled(&p.state)]
- .into_iter()
- .flatten()
- .filter(|part| *part != name)
- .collect::>()
- .join(", ");
- let (key, value) = (
- filled(&p.osm_key).unwrap_or(""),
- filled(&p.osm_value).unwrap_or(""),
- );
- Some(Place {
- name,
- category: category(key, value),
- kind: kind(key, value),
- address,
- pos,
- country_code: filled(&p.countrycode).unwrap_or("").to_ascii_uppercase(),
- details: Vec::new(),
- })
-}
-
-/// The text of a field that has some.
-fn filled(field: &Option) -> Option<&str> {
- field
- .as_deref()
- .map(str::trim)
- .filter(|text| !text.is_empty())
-}
-
-fn category(key: &str, value: &str) -> String {
- match (key, value) {
- ("", _) => String::new(),
- ("highway", "bus_stop") => "Bus stop".into(),
- ("highway", _) => "Street".into(),
- ("office", "yes" | "") => "Office".into(),
- ("office", kind) => format!("{} office", humanise(kind)),
- // `building=yes`: the key is all that is known.
- (_, "yes" | "") => humanise(key),
- (_, kind) => humanise(kind),
- }
-}
-
-fn kind(key: &str, value: &str) -> PlaceKind {
- match (key, value) {
- (
- "amenity",
- "restaurant" | "cafe" | "fast_food" | "bar" | "pub" | "food_court" | "ice_cream"
- | "biergarten",
- ) => PlaceKind::Food,
- ("shop", _) => PlaceKind::Shop,
- ("railway" | "public_transport" | "aeroway", _)
- | ("highway", "bus_stop")
- | ("amenity", "bus_station" | "ferry_terminal") => PlaceKind::Transit,
- ("tourism", "hotel" | "motel" | "hostel" | "guest_house" | "apartment" | "camp_site") => {
- PlaceKind::Lodging
- }
- ("highway", _) => PlaceKind::Street,
- _ => PlaceKind::Place,
- }
-}
-
-/// `fast_food` as `Fast food`.
-fn humanise(tag: &str) -> String {
- let spaced = tag.replace('_', " ");
- let mut chars = spaced.chars();
- match chars.next() {
- Some(first) => first.to_uppercase().chain(chars).collect(),
- None => String::new(),
- }
-}
-
-/// A long press, before (or without) the reverse lookup's answer.
-pub fn dropped_pin(at: LonLat) -> Place {
- Place {
- name: "Dropped pin".into(),
- address: coordinates_text(at),
- pos: at,
- ..Place::default()
- }
-}
-
-/// A tapped map pin (`MapViewAction::PinTapped`) with the attributes the
-/// layer knows: `name` titles it, the rest are the sheet's detail rows.
-pub fn from_pin_info(lon: f64, lat: f64, info: &[(String, String)]) -> Place {
- let text = |value: &String| {
- Some(value.trim())
- .filter(|v| !v.is_empty())
- .map(str::to_string)
- };
- let name = info
- .iter()
- .find(|(key, _)| key == "name")
- .and_then(|(_, value)| text(value));
- let details = info
- .iter()
- .filter(|(key, _)| key != "name")
- .filter_map(|(key, value)| Some((humanise(key), text(value)?)))
- .collect();
- Place {
- name: name.unwrap_or_else(|| "Pin".into()),
- pos: LonLat::new(lon, lat),
- details,
- ..Place::default()
- }
-}
-
-/// `37.33820, -121.88630`: latitude first, as people read coordinates.
-pub fn coordinates_text(at: LonLat) -> String {
- format!("{:.5}, {:.5}", at.lat, at.lon)
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- const SEARCH_REPLY: &str = include_str!("../tests/fixtures/photon-search.json");
- const REVERSE_REPLY: &str = include_str!("../tests/fixtures/photon-reverse.json");
- const EMPTY_REPLY: &str = include_str!("../tests/fixtures/photon-empty.json");
-
- #[test]
- fn a_university_with_its_address() {
- let places = parse(SEARCH_REPLY).unwrap();
- assert_eq!(places.len(), 3);
- let place = &places[0];
- assert_eq!(place.name, "Santa Clara University");
- assert_eq!(place.category, "University");
- assert_eq!(place.kind, PlaceKind::Place);
- assert_eq!(place.address, "500 El Camino Real, Santa Clara, California");
- assert_eq!(place.country_code, "US");
- assert!(
- (place.pos.lon - -121.936544).abs() < 1e-9 && (place.pos.lat - 37.3486243).abs() < 1e-9
- );
- // An office reads as one.
- assert_eq!(places[2].category, "Marketing office");
- }
-
- #[test]
- fn a_street_is_a_street_and_does_not_repeat_itself() {
- let places = parse(REVERSE_REPLY).unwrap();
- assert_eq!(places.len(), 1);
- assert_eq!(places[0].name, "East Santa Clara Street");
- assert_eq!(places[0].category, "Street");
- assert_eq!(places[0].kind, PlaceKind::Street);
- // The street is the name, so the address starts at the city.
- assert_eq!(places[0].address, "San Jose, California");
- }
-
- #[test]
- fn nothing_found_is_not_an_error_and_a_torn_body_is() {
- assert_eq!(parse(EMPTY_REPLY), Ok(vec![]));
- assert!(parse(&SEARCH_REPLY[..SEARCH_REPLY.len() / 2]).is_err());
- assert!(parse("502 Bad Gateway").is_err());
- }
-
- #[test]
- fn a_feature_with_no_name_takes_its_address_and_one_with_no_point_is_dropped() {
- let unnamed = r#"{"features":[
- {"geometry":{"type":"Point","coordinates":[-121.9,37.3]},
- "properties":{"osm_key":"building","osm_value":"yes","housenumber":"12","street":"Oak Street","city":"San Jose","countrycode":"us"}},
- {"geometry":{"type":"Point","coordinates":[-121.9]},"properties":{"name":"Half a point"}},
- {"properties":{"name":"No geometry"}}
- ]}"#;
- let places = parse(unnamed).unwrap();
- assert_eq!(places.len(), 1);
- assert_eq!(places[0].name, "12 Oak Street");
- assert_eq!(places[0].category, "Building");
- assert_eq!(places[0].address, "San Jose");
- assert_eq!(places[0].country_code, "US");
- }
-
- #[test]
- fn kinds_pick_the_icon() {
- let reply = |key: &str, value: &str| {
- format!(
- r#"{{"features":[{{"geometry":{{"coordinates":[1.0,2.0]}},"properties":{{"name":"X","osm_key":"{key}","osm_value":"{value}"}}}}]}}"#
- )
- };
- let kind = |key: &str, value: &str| parse(&reply(key, value)).unwrap()[0].kind;
- assert_eq!(kind("amenity", "restaurant"), PlaceKind::Food);
- assert_eq!(kind("amenity", "cafe"), PlaceKind::Food);
- assert_eq!(kind("shop", "supermarket"), PlaceKind::Shop);
- assert_eq!(kind("railway", "station"), PlaceKind::Transit);
- assert_eq!(kind("highway", "bus_stop"), PlaceKind::Transit);
- assert_eq!(kind("tourism", "hotel"), PlaceKind::Lodging);
- assert_eq!(kind("highway", "residential"), PlaceKind::Street);
- assert_eq!(kind("place", "city"), PlaceKind::Place);
- // Underscores are spaces in a category.
- assert_eq!(
- parse(&reply("amenity", "fast_food")).unwrap()[0].category,
- "Fast food"
- );
- }
-
- #[test]
- fn urls_escape_what_the_person_typed() {
- let url = search_url("café & bar", LonLat::new(-121.95, 37.35));
- assert_eq!(url, "https://photon.komoot.io/api/?q=caf%C3%A9%20%26%20bar&limit=8&lang=en&lat=37.3500&lon=-121.9500");
- assert_eq!(
- reverse_url(LonLat::new(-121.8863, 37.3382)),
- "https://photon.komoot.io/reverse?lat=37.338200&lon=-121.886300&lang=en"
- );
- }
-
- #[test]
- fn a_dropped_pin_is_named_by_where_it_is() {
- let pin = dropped_pin(LonLat::new(-121.8863, 37.3382));
- assert_eq!(pin.name, "Dropped pin");
- assert_eq!(pin.address, "37.33820, -121.88630");
- assert_eq!(pin.kind, PlaceKind::Place);
- }
-
- #[test]
- fn a_tapped_pin_keeps_what_the_layer_knows() {
- let info = vec![
- ("name".to_string(), "Fastned".to_string()),
- ("opening_hours".to_string(), "24/7".to_string()),
- ("phone".to_string(), "+31 20 123".to_string()),
- ("empty".to_string(), " ".to_string()),
- ];
- let place = from_pin_info(4.9, 52.37, &info);
- assert_eq!(place.name, "Fastned");
- assert_eq!(
- place.details,
- vec![
- ("Opening hours".to_string(), "24/7".to_string()),
- ("Phone".to_string(), "+31 20 123".to_string())
- ]
- );
- // No name: the pin still opens.
- assert_eq!(from_pin_info(4.9, 52.37, &[]).name, "Pin");
- }
-}
diff --git a/apps/maps/native/src/routing.rs b/apps/maps/native/src/routing.rs
deleted file mode 100644
index a10b4349..00000000
--- a/apps/maps/native/src/routing.rs
+++ /dev/null
@@ -1,599 +0,0 @@
-//! Routes from OSRM (routing.openstreetmap.de, a server per profile, no
-//! key), as the navigation library's `Route` — so its session map-matches
-//! them — plus the step list the sheet shows.
-
-use crate::geo::{cumulative_distances, decode_polyline5, haversine_m, LonLat};
-use makepad_map_nav::graph::{Route, TravelMode};
-use makepad_map_nav::nav::{Maneuver, ManeuverKind};
-use makepad_widgets::makepad_micro_serde::*;
-
-const ROUTING: &str = "https://routing.openstreetmap.de";
-/// A maneuver sits on the line's own vertex, give or take the encoding's
-/// fifth decimal.
-const ON_VERTEX_M: f64 = 3.0;
-
-#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
-pub enum Mode {
- #[default]
- Car,
- Walk,
- Bike,
-}
-
-impl Mode {
- /// In the order the tabs show them.
- pub const ALL: [Mode; 3] = [Mode::Car, Mode::Walk, Mode::Bike];
-
- pub fn index(self) -> usize {
- match self {
- Mode::Car => 0,
- Mode::Walk => 1,
- Mode::Bike => 2,
- }
- }
-
- pub fn label(self) -> &'static str {
- match self {
- Mode::Car => "Drive",
- Mode::Walk => "Walk",
- Mode::Bike => "Bike",
- }
- }
-
- pub fn travel_mode(self) -> TravelMode {
- match self {
- Mode::Car => TravelMode::Car,
- Mode::Walk => TravelMode::Foot,
- Mode::Bike => TravelMode::Bike,
- }
- }
-
- /// The server that routes this mode.
- fn profile(self) -> &'static str {
- match self {
- Mode::Car => "routed-car",
- Mode::Walk => "routed-foot",
- Mode::Bike => "routed-bike",
- }
- }
-}
-
-/// The arrow a step or the banner shows.
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub enum Arrow {
- Depart,
- Arrive,
- Straight,
- SlightLeft,
- Left,
- SharpLeft,
- SlightRight,
- Right,
- SharpRight,
- UTurn,
- Roundabout,
-}
-
-impl Arrow {
- pub fn of(kind: ManeuverKind) -> Arrow {
- match kind {
- ManeuverKind::Depart => Arrow::Depart,
- ManeuverKind::Arrive => Arrow::Arrive,
- ManeuverKind::TurnSlightLeft => Arrow::SlightLeft,
- ManeuverKind::TurnLeft => Arrow::Left,
- ManeuverKind::TurnSharpLeft => Arrow::SharpLeft,
- ManeuverKind::TurnSlightRight => Arrow::SlightRight,
- ManeuverKind::TurnRight => Arrow::Right,
- ManeuverKind::TurnSharpRight => Arrow::SharpRight,
- ManeuverKind::UTurn => Arrow::UTurn,
- ManeuverKind::RoundaboutExit(_) => Arrow::Roundabout,
- }
- }
-}
-
-/// One line of the directions list.
-#[derive(Clone, Debug, PartialEq)]
-pub struct Step {
- pub arrow: Arrow,
- /// `Turn left onto North 5th Street`.
- pub text: String,
- /// How far this step goes before the next one.
- pub distance_m: f64,
-}
-
-#[derive(Clone, Debug)]
-pub struct Directions {
- pub route: Route,
- pub steps: Vec,
- /// For each of `route.maneuvers`, its line in `steps`: the banner reads
- /// the step's text, which knows more than the maneuver's kind does.
- pub maneuver_steps: Vec,
-}
-
-#[derive(Clone, Debug, PartialEq)]
-pub enum RouteError {
- /// The service answered, and there is no way between the two points.
- NoRoute,
- Malformed(String),
-}
-
-/// The route from `from` to `to` for `mode`, with its steps and whole line.
-pub fn route_url(mode: Mode, from: LonLat, to: LonLat) -> String {
- // `driving` is the path's fixed word on these servers: the host picks the profile.
- format!(
- "{ROUTING}/{}/route/v1/driving/{:.6},{:.6};{:.6},{:.6}?overview=full&steps=true&geometries=polyline",
- mode.profile(),
- from.lon,
- from.lat,
- to.lon,
- to.lat
- )
-}
-
-pub fn parse(mode: Mode, json: &str) -> Result {
- let malformed = |what: &str| RouteError::Malformed(what.to_string());
- // Lenient: OSRM sends fields we do not model (waypoints, weights, intersections).
- let reply: Reply = DeJson::deserialize_json_lenient(json)
- .map_err(|e| RouteError::Malformed(format!("{e:?}")))?;
- match reply.code.as_deref() {
- Some("Ok") => {}
- Some("NoRoute" | "NoSegment") => return Err(RouteError::NoRoute),
- code => {
- return Err(RouteError::Malformed(format!(
- "{}: {}",
- code.unwrap_or("no code"),
- reply.message.unwrap_or_default()
- )));
- }
- }
- let wire = reply
- .routes
- .unwrap_or_default()
- .into_iter()
- .next()
- .ok_or_else(|| malformed("no routes"))?;
- let points = decode_polyline5(wire.geometry.as_deref().unwrap_or(""));
- if points.len() < 2 {
- return Err(malformed("a line of fewer than two points"));
- }
- // Our own sum over the line, not OSRM's `distance`: the session measures
- // progress along these same points.
- let cum_dist_m =
- cumulative_distances(&points).ok_or_else(|| malformed("a line too long to follow"))?;
- let length_m = cum_dist_m[cum_dist_m.len() - 1];
-
- let last_point = points.len() - 1;
- let wire_steps: Vec = wire
- .legs
- .unwrap_or_default()
- .into_iter()
- .flat_map(|leg| leg.steps.unwrap_or_default())
- .collect();
- let last_step = wire_steps.len().saturating_sub(1);
- let mut steps: Vec = Vec::new();
- let mut maneuvers: Vec = Vec::new();
- let mut maneuver_steps = Vec::new();
- let mut reached = 0usize;
- for (step_index, wire_step) in wire_steps.into_iter().enumerate() {
- let distance_m = wire_step.distance.unwrap_or(0.0).max(0.0);
- let m = wire_step.maneuver.unwrap_or_default();
- let (kind_name, modifier) = (
- m.kind.as_deref().unwrap_or(""),
- m.modifier.as_deref().unwrap_or(""),
- );
- if is_hint(kind_name) {
- // Not a line of its own: its stretch belongs to the step before.
- if let Some(previous) = steps.last_mut() {
- previous.distance_m += distance_m;
- }
- continue;
- }
- let kind = maneuver_kind(
- kind_name,
- modifier,
- m.exit.unwrap_or(0.0).clamp(0.0, 255.0) as u8,
- );
- let name = wire_step.name.as_deref().map(str::trim).unwrap_or("");
- steps.push(Step {
- arrow: kind.map(Arrow::of).unwrap_or(Arrow::Straight),
- text: step_text(kind, kind_name, modifier, name, m.bearing_after),
- distance_m,
- });
- let at = match m.location.as_deref() {
- Some([lon, lat, ..]) if lon.is_finite() && lat.is_finite() => LonLat::new(*lon, *lat),
- _ => points[reached],
- };
- // Every step moves the search on, a turn or not, so a line that
- // passes a vertex twice pins a later turn to the later pass. Only
- // the first step is the line's start and only the last its end: a
- // leg's own depart or arrive in between is wherever it says.
- reached = match kind {
- Some(ManeuverKind::Depart) if step_index == 0 => 0,
- Some(ManeuverKind::Arrive) if step_index == last_step => last_point,
- _ => vertex_at(&points, reached, at),
- };
- let Some(kind) = kind else { continue };
- maneuvers.push(Maneuver {
- kind,
- at,
- name: name.to_string(),
- dist_m: cum_dist_m[reached],
- point_index: reached,
- });
- maneuver_steps.push(steps.len() - 1);
- }
- let route = Route {
- mode: mode.travel_mode(),
- points,
- cum_dist_m,
- length_m,
- duration_s: wire.duration.unwrap_or(0.0).max(0.0),
- maneuvers,
- };
- Ok(Directions {
- route,
- steps,
- maneuver_steps,
- })
-}
-
-#[derive(Clone, Debug, Default, DeJson)]
-struct Reply {
- code: Option,
- message: Option,
- routes: Option>,
-}
-
-#[derive(Clone, Debug, Default, DeJson)]
-struct RouteWire {
- geometry: Option,
- duration: Option,
- legs: Option>,
-}
-
-#[derive(Clone, Debug, Default, DeJson)]
-struct LegWire {
- steps: Option>,
-}
-
-#[derive(Clone, Debug, Default, DeJson)]
-struct StepWire {
- name: Option,
- distance: Option,
- maneuver: Option,
-}
-
-#[derive(Clone, Debug, Default, DeJson)]
-struct ManeuverWire {
- #[rename(type)]
- kind: Option,
- modifier: Option,
- exit: Option,
- location: Option>,
- bearing_after: Option,
-}
-
-/// The line's vertex a maneuver sits on, looking forward from `from` only,
-/// so a route that crosses itself never sends the indices backwards: the
-/// first vertex on the spot, else the nearest one ahead.
-fn vertex_at(points: &[LonLat], from: usize, at: LonLat) -> usize {
- let mut nearest = (f64::MAX, from);
- for (i, point) in points.iter().enumerate().skip(from) {
- let d = haversine_m(*point, at);
- if d <= ON_VERTEX_M {
- return i;
- }
- if d < nearest.0 {
- nearest = (d, i);
- }
- }
- nearest.1
-}
-
-/// OSRM step types that tell a driver something without being a place in
-/// the list: a lane hint, a notice, the far side of a roundabout.
-fn is_hint(kind: &str) -> bool {
- matches!(
- kind,
- "notification" | "use lane" | "exit roundabout" | "exit rotary"
- )
-}
-
-fn step_text(
- kind: Option,
- kind_name: &str,
- modifier: &str,
- name: &str,
- bearing_after: Option,
-) -> String {
- let onto = |base: String| {
- if name.is_empty() {
- base
- } else {
- format!("{base} onto {name}")
- }
- };
- let side = if modifier.contains("left") {
- Some("left")
- } else if modifier.contains("right") {
- Some("right")
- } else {
- None
- };
- match kind {
- Some(ManeuverKind::Depart) => {
- let heading = format!("Head {}", cardinal(bearing_after.unwrap_or(0.0)));
- if name.is_empty() {
- heading
- } else {
- format!("{heading} on {name}")
- }
- }
- Some(ManeuverKind::Arrive) => match side {
- Some(side) => format!("Arrive at your destination, on the {side}"),
- None => "Arrive at your destination".to_string(),
- },
- Some(roundabout @ ManeuverKind::RoundaboutExit(_)) => onto(roundabout.instruction()),
- Some(turn) => onto(match (kind_name, side) {
- ("fork", Some(side)) => format!("Keep {side}"),
- ("merge", _) => "Merge".to_string(),
- ("on ramp", Some(side)) => format!("Take the ramp on the {side}"),
- ("off ramp", Some(side)) => format!("Take the exit on the {side}"),
- _ => turn_text(turn).to_string(),
- }),
- None if name.is_empty() => "Continue straight".to_string(),
- None => format!("Continue onto {name}"),
- }
-}
-
-fn turn_text(kind: ManeuverKind) -> &'static str {
- match kind {
- ManeuverKind::TurnSlightLeft => "Slight left",
- ManeuverKind::TurnLeft => "Turn left",
- ManeuverKind::TurnSharpLeft => "Sharp left",
- ManeuverKind::TurnSlightRight => "Slight right",
- ManeuverKind::TurnRight => "Turn right",
- ManeuverKind::TurnSharpRight => "Sharp right",
- ManeuverKind::UTurn => "Make a U-turn",
- ManeuverKind::Depart | ManeuverKind::Arrive | ManeuverKind::RoundaboutExit(_) => "Continue",
- }
-}
-
-/// The eight-point compass name of a bearing in degrees.
-fn cardinal(bearing: f64) -> &'static str {
- const NAMES: [&str; 8] = [
- "north",
- "northeast",
- "east",
- "southeast",
- "south",
- "southwest",
- "west",
- "northwest",
- ];
- NAMES[((bearing.rem_euclid(360.0) + 22.5) / 45.0) as usize % 8]
-}
-
-/// An OSRM maneuver as a turn the session announces, or `None` when it is
-/// not one: going straight on, a road changing its name, a lane hint.
-pub fn maneuver_kind(kind: &str, modifier: &str, exit: u8) -> Option {
- match kind {
- "depart" => Some(ManeuverKind::Depart),
- "arrive" => Some(ManeuverKind::Arrive),
- "roundabout" | "rotary" => Some(ManeuverKind::RoundaboutExit(exit.max(1))),
- // Wherever the road or the driver changes direction, the modifier
- // says which way; `straight` (or none) is no turn at all.
- "turn" | "end of road" | "fork" | "merge" | "on ramp" | "off ramp" | "roundabout turn"
- | "new name" | "continue" => match modifier {
- "uturn" => Some(ManeuverKind::UTurn),
- "sharp left" => Some(ManeuverKind::TurnSharpLeft),
- "left" => Some(ManeuverKind::TurnLeft),
- "slight left" => Some(ManeuverKind::TurnSlightLeft),
- "sharp right" => Some(ManeuverKind::TurnSharpRight),
- "right" => Some(ManeuverKind::TurnRight),
- "slight right" => Some(ManeuverKind::TurnSlightRight),
- _ => None,
- },
- _ => None,
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- const CAR: &str = include_str!("../tests/fixtures/osrm-car.json");
- const BIKE: &str = include_str!("../tests/fixtures/osrm-bike.json");
- const FOOT: &str = include_str!("../tests/fixtures/osrm-foot.json");
- const NO_ROUTE: &str = include_str!("../tests/fixtures/osrm-noroute.json");
-
- #[test]
- fn every_mode_parses_into_a_route_the_session_can_follow() {
- for (mode, json) in [(Mode::Car, CAR), (Mode::Bike, BIKE), (Mode::Walk, FOOT)] {
- let directions = parse(mode, json).unwrap();
- let route = &directions.route;
- assert_eq!(route.mode, mode.travel_mode());
- assert!(route.points.len() > 10, "{mode:?}");
- assert_eq!(route.points.len(), route.cum_dist_m.len());
- // The distances run forward and end at the length.
- assert!(
- route.cum_dist_m.windows(2).all(|w| w[1] >= w[0]),
- "{mode:?}"
- );
- assert_eq!(*route.cum_dist_m.last().unwrap(), route.length_m);
- // About 7.7 km, by our own sum over the line.
- assert!(
- (7_000.0..8_500.0).contains(&route.length_m),
- "{mode:?} {}",
- route.length_m
- );
- assert!(route.duration_s > 0.0);
- }
- }
-
- #[test]
- fn the_modes_take_their_own_time() {
- let car = parse(Mode::Car, CAR).unwrap().route.duration_s;
- let bike = parse(Mode::Bike, BIKE).unwrap().route.duration_s;
- let foot = parse(Mode::Walk, FOOT).unwrap().route.duration_s;
- assert!(car < bike && bike < foot, "{car} {bike} {foot}");
- }
-
- #[test]
- fn maneuvers_run_forward_from_depart_to_arrive() {
- for (mode, json) in [(Mode::Car, CAR), (Mode::Bike, BIKE), (Mode::Walk, FOOT)] {
- let route = parse(mode, json).unwrap().route;
- let maneuvers = &route.maneuvers;
- assert_eq!(maneuvers.first().unwrap().kind, ManeuverKind::Depart);
- assert_eq!(maneuvers.first().unwrap().point_index, 0);
- assert_eq!(maneuvers.last().unwrap().kind, ManeuverKind::Arrive);
- assert_eq!(
- maneuvers.last().unwrap().point_index,
- route.points.len() - 1
- );
- assert!(
- maneuvers
- .windows(2)
- .all(|w| w[1].point_index >= w[0].point_index),
- "{mode:?}"
- );
- for m in maneuvers {
- // Its distance is the line's at its point, and its point is where OSRM said.
- assert_eq!(m.dist_m, route.cum_dist_m[m.point_index]);
- assert!(
- haversine_m(m.at, route.points[m.point_index]) < 25.0,
- "{mode:?} {m:?}"
- );
- }
- }
- }
-
- #[test]
- fn steps_read_like_directions() {
- let directions = parse(Mode::Car, CAR).unwrap();
- let steps = &directions.steps;
- assert_eq!(steps.len(), 10);
- assert_eq!(steps[0].arrow, Arrow::Depart);
- assert!(
- steps[0].text.starts_with("Head southwest on "),
- "{}",
- steps[0].text
- );
- assert!(steps[0].distance_m > 0.0);
- let last = steps.last().unwrap();
- assert_eq!(last.arrow, Arrow::Arrive);
- assert!(
- last.text.starts_with("Arrive at your destination"),
- "{}",
- last.text
- );
- // Every turn the session announces points at its own line.
- assert_eq!(
- directions.maneuver_steps.len(),
- directions.route.maneuvers.len()
- );
- for (m, &step) in directions
- .route
- .maneuvers
- .iter()
- .zip(&directions.maneuver_steps)
- {
- assert_eq!(Arrow::of(m.kind), steps[step].arrow, "{m:?}");
- }
- }
-
- #[test]
- fn indices_run_forward_whatever_the_steps_say() {
- // Two legs' worth of steps over the documented sample's three
- // points: a depart and an arrive in the middle, and a step that is
- // no turn between two that are.
- let reply = r#"{"code":"Ok","routes":[{"geometry":"_p~iF~ps|U_ulLnnqC_mqNvxq`@","duration":100,"legs":[{"steps":[
- {"name":"A","distance":10,"maneuver":{"type":"depart","location":[-120.2,38.5]}},
- {"name":"B","distance":10,"maneuver":{"type":"new name","modifier":"straight","location":[-120.95,40.7]}},
- {"name":"B","distance":0,"maneuver":{"type":"arrive","location":[-120.95,40.7]}},
- {"name":"B","distance":10,"maneuver":{"type":"depart","location":[-120.95,40.7]}},
- {"name":"C","distance":10,"maneuver":{"type":"turn","modifier":"left","location":[-126.453,43.252]}},
- {"name":"C","distance":0,"maneuver":{"type":"arrive","location":[-126.453,43.252]}}
- ]}]}]}"#;
- let route = parse(Mode::Car, reply).unwrap().route;
- let indices: Vec = route.maneuvers.iter().map(|m| m.point_index).collect();
- assert_eq!(indices, vec![0, 1, 1, 2, 2], "{:?}", route.maneuvers);
- assert!(route
- .maneuvers
- .windows(2)
- .all(|w| w[1].dist_m >= w[0].dist_m));
- }
-
- #[test]
- fn the_maneuver_table() {
- use ManeuverKind::*;
- let cases = [
- ("turn", "left", 0, Some(TurnLeft)),
- ("turn", "slight right", 0, Some(TurnSlightRight)),
- ("turn", "sharp left", 0, Some(TurnSharpLeft)),
- ("turn", "uturn", 0, Some(UTurn)),
- ("turn", "straight", 0, None),
- ("end of road", "right", 0, Some(TurnRight)),
- ("fork", "slight left", 0, Some(TurnSlightLeft)),
- ("on ramp", "right", 0, Some(TurnRight)),
- ("off ramp", "slight right", 0, Some(TurnSlightRight)),
- ("merge", "slight left", 0, Some(TurnSlightLeft)),
- ("roundabout", "right", 2, Some(RoundaboutExit(2))),
- ("rotary", "", 0, Some(RoundaboutExit(1))),
- ("roundabout turn", "left", 0, Some(TurnLeft)),
- ("depart", "", 0, Some(Depart)),
- ("arrive", "right", 0, Some(Arrive)),
- // A road that bends left under a new name is a turn; one that
- // goes straight on under one is not.
- ("new name", "left", 0, Some(TurnLeft)),
- ("new name", "straight", 0, None),
- ("continue", "straight", 0, None),
- ("continue", "uturn", 0, Some(UTurn)),
- ("notification", "left", 0, None),
- ("use lane", "straight", 0, None),
- ("exit roundabout", "right", 0, None),
- ];
- for (kind, modifier, exit, expected) in cases {
- assert_eq!(
- maneuver_kind(kind, modifier, exit),
- expected,
- "{kind} / {modifier}"
- );
- }
- }
-
- #[test]
- fn no_route_is_its_own_answer_and_garbage_is_malformed() {
- assert_eq!(parse(Mode::Car, NO_ROUTE).unwrap_err(), RouteError::NoRoute);
- assert!(matches!(
- parse(Mode::Car, "504"),
- Err(RouteError::Malformed(_))
- ));
- assert!(matches!(
- parse(Mode::Car, r#"{"code":"Ok","routes":[]}"#),
- Err(RouteError::Malformed(_))
- ));
- // A line of one point is no route to follow.
- let stub = r#"{"code":"Ok","routes":[{"geometry":"_p~iF~ps|U","distance":0,"duration":0,"legs":[{"steps":[]}]}]}"#;
- assert!(matches!(
- parse(Mode::Car, stub),
- Err(RouteError::Malformed(_))
- ));
- assert!(matches!(
- parse(Mode::Car, r#"{"code":"InvalidQuery","message":"bad"}"#),
- Err(RouteError::Malformed(_))
- ));
- }
-
- #[test]
- fn a_url_per_mode() {
- let from = LonLat::new(-121.8863, 37.3382);
- let to = LonLat::new(-121.9552, 37.3541);
- assert_eq!(
- route_url(Mode::Walk, from, to),
- "https://routing.openstreetmap.de/routed-foot/route/v1/driving/-121.886300,37.338200;-121.955200,37.354100?overview=full&steps=true&geometries=polyline"
- );
- assert!(route_url(Mode::Car, from, to).contains("/routed-car/"));
- assert!(route_url(Mode::Bike, from, to).contains("/routed-bike/"));
- }
-}
diff --git a/apps/maps/native/src/sheet.rs b/apps/maps/native/src/sheet.rs
deleted file mode 100644
index bf483a09..00000000
--- a/apps/maps/native/src/sheet.rs
+++ /dev/null
@@ -1,195 +0,0 @@
-//! The bottom sheet's detents: it rests at peek, half or full height, a
-//! drag moves it with the finger between the lowest and the highest, and a
-//! release snaps it to a detent — the nearest, or for a flick the next one
-//! the way the finger was going. Heights only; the view draws and eases.
-
-/// The handle, the title, a line under it and the primary button.
-const PEEK_HEIGHT: f64 = 132.0;
-/// Of the viewport.
-const HALF_FRACTION: f64 = 0.45;
-/// What full height leaves of the viewport above it: the search bar stays.
-const FULL_TOP_GAP: f64 = 96.0;
-/// Pixels a second: faster than this at release is a flick.
-const FLICK_VELOCITY: f64 = 600.0;
-
-#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
-pub enum Detent {
- #[default]
- Peek,
- Half,
- Full,
-}
-
-#[derive(Clone, Debug, Default)]
-pub struct Sheet {
- detent: Detent,
- /// While a finger is on it: the height the drag started from, and the
- /// height now.
- drag: Option<(f64, f64)>,
-}
-
-impl Sheet {
- /// The three resting heights in a viewport this tall, rising.
- pub fn heights(viewport_h: f64) -> [f64; 3] {
- // In a short viewport the three close up rather than cross.
- let half = viewport_h * HALF_FRACTION;
- [
- PEEK_HEIGHT.min(half),
- half,
- (viewport_h - FULL_TOP_GAP).max(half),
- ]
- }
-
- pub fn detent(&self) -> Detent {
- self.detent
- }
-
- pub fn is_dragging(&self) -> bool {
- self.drag.is_some()
- }
-
- /// The height to show now: under the finger while dragging, else the
- /// detent's.
- pub fn height(&self, viewport_h: f64) -> f64 {
- match self.drag {
- Some((_, now)) => now,
- None => Self::heights(viewport_h)[self.detent as usize],
- }
- }
-
- pub fn set(&mut self, detent: Detent) {
- self.detent = detent;
- self.drag = None;
- }
-
- pub fn drag_start(&mut self, viewport_h: f64) {
- let from = self.height(viewport_h);
- self.drag = Some((from, from));
- }
-
- /// The finger is `dy` pixels below where the drag started (negative:
- /// above).
- pub fn drag_by(&mut self, dy: f64, viewport_h: f64) {
- let [lowest, _, highest] = Self::heights(viewport_h);
- if let Some((from, now)) = self.drag.as_mut() {
- // The sheet grows as the finger goes up the screen.
- *now = (*from - dy).clamp(lowest, highest);
- }
- }
-
- /// The finger lifted, moving at `velocity` pixels a second downwards
- /// (negative: upwards). Returns where the sheet comes to rest.
- pub fn release(&mut self, velocity: f64, viewport_h: f64) -> Detent {
- const DETENTS: [Detent; 3] = [Detent::Peek, Detent::Half, Detent::Full];
- let Some((_, at)) = self.drag.take() else {
- return self.detent;
- };
- let heights = Self::heights(viewport_h);
- let index = if velocity <= -FLICK_VELOCITY {
- // Upwards: the first detent above the finger, else the top.
- heights.iter().position(|h| *h > at + 0.5).unwrap_or(2)
- } else if velocity >= FLICK_VELOCITY {
- heights.iter().rposition(|h| *h < at - 0.5).unwrap_or(0)
- } else {
- let distance = |i: &usize| (heights[*i] - at).abs();
- (0..3)
- .min_by(|a, b| distance(a).total_cmp(&distance(b)))
- .unwrap_or(0)
- };
- self.detent = DETENTS[index];
- self.detent
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- const VIEWPORT: f64 = 800.0;
-
- #[test]
- fn three_heights_rising_in_any_viewport() {
- assert_eq!(Sheet::heights(VIEWPORT), [132.0, 360.0, 704.0]);
- // A wide home tile or a split view: still three heights in order,
- // none taller than the room.
- for viewport in [90.0, 200.0, 320.0] {
- let [peek, half, full] = Sheet::heights(viewport);
- assert!(
- peek <= half && half <= full && full <= viewport && peek > 0.0,
- "{viewport}"
- );
- }
- }
-
- #[test]
- fn at_rest_it_is_as_tall_as_its_detent() {
- let mut sheet = Sheet::default();
- assert_eq!(sheet.detent(), Detent::Peek);
- assert_eq!(sheet.height(VIEWPORT), 132.0);
- sheet.set(Detent::Half);
- assert_eq!(sheet.height(VIEWPORT), 360.0);
- sheet.set(Detent::Full);
- assert_eq!(sheet.height(VIEWPORT), 704.0);
- }
-
- #[test]
- fn a_drag_follows_the_finger_between_the_lowest_and_the_highest() {
- let mut sheet = Sheet::default();
- sheet.drag_start(VIEWPORT);
- assert!(sheet.is_dragging());
- sheet.drag_by(-100.0, VIEWPORT);
- assert_eq!(sheet.height(VIEWPORT), 232.0);
- sheet.drag_by(-5_000.0, VIEWPORT);
- assert_eq!(sheet.height(VIEWPORT), 704.0);
- sheet.drag_by(5_000.0, VIEWPORT);
- assert_eq!(sheet.height(VIEWPORT), 132.0);
- // A move with no drag begun is nothing.
- let mut idle = Sheet::default();
- idle.drag_by(-100.0, VIEWPORT);
- assert_eq!(idle.height(VIEWPORT), 132.0);
- }
-
- #[test]
- fn a_slow_release_snaps_to_the_nearest() {
- let mut sheet = Sheet::default();
- sheet.drag_start(VIEWPORT);
- sheet.drag_by(-60.0, VIEWPORT);
- assert_eq!(sheet.release(0.0, VIEWPORT), Detent::Peek);
- assert!(!sheet.is_dragging());
- sheet.drag_start(VIEWPORT);
- sheet.drag_by(-180.0, VIEWPORT);
- assert_eq!(sheet.release(-50.0, VIEWPORT), Detent::Half);
- assert_eq!(sheet.height(VIEWPORT), 360.0);
- sheet.drag_start(VIEWPORT);
- sheet.drag_by(-300.0, VIEWPORT);
- assert_eq!(sheet.release(50.0, VIEWPORT), Detent::Full);
- }
-
- #[test]
- fn a_flick_goes_to_the_next_detent_its_way_and_never_past_it() {
- // Barely moved, but fast upwards: half, not back to peek.
- let mut sheet = Sheet::default();
- sheet.drag_start(VIEWPORT);
- sheet.drag_by(-20.0, VIEWPORT);
- assert_eq!(sheet.release(-2_000.0, VIEWPORT), Detent::Half);
- // However fast, one detent at a time from where the finger is.
- sheet.drag_start(VIEWPORT);
- sheet.drag_by(-10.0, VIEWPORT);
- assert_eq!(sheet.release(-9_000.0, VIEWPORT), Detent::Full);
- // Downwards from just under full: half.
- sheet.drag_start(VIEWPORT);
- sheet.drag_by(30.0, VIEWPORT);
- assert_eq!(sheet.release(2_000.0, VIEWPORT), Detent::Half);
- // Downwards from peek there is nowhere lower.
- sheet.set(Detent::Peek);
- sheet.drag_start(VIEWPORT);
- assert_eq!(sheet.release(2_000.0, VIEWPORT), Detent::Peek);
- }
-
- #[test]
- fn a_release_with_no_drag_keeps_the_detent() {
- let mut sheet = Sheet::default();
- sheet.set(Detent::Half);
- assert_eq!(sheet.release(-5_000.0, VIEWPORT), Detent::Half);
- }
-}
diff --git a/apps/maps/native/src/test_support.rs b/apps/maps/native/src/test_support.rs
deleted file mode 100644
index f6a9cfc4..00000000
--- a/apps/maps/native/src/test_support.rs
+++ /dev/null
@@ -1,70 +0,0 @@
-//! Scaffolding the isolate tests share (`view.rs`, `module.rs`): a `Cx`
-//! with no window, a splash VM of its own with no network and this crate's
-//! widget family registered in it — what the module host prepares for an
-//! instance — a `MapsView` root minted there, and the host's teardown
-//! order: the refs go first, then the isolate. After News' own.
-
-use crate::module::MAPS_MODULE;
-use makepad_app_module::AppModule;
-use makepad_widgets::widget_async::{enter_isolate, leave_isolate, SplashVmId};
-use makepad_widgets::*;
-
-/// One test's `Cx` and the isolate its root lives in.
-pub(crate) struct Isolate {
- pub cx: Cx,
- pub vm_id: SplashVmId,
-}
-
-impl Isolate {
- /// A fresh isolate with the crate's widgets registered, as
- /// `MapsModule::register` puts them there for the host.
- pub(crate) fn new() -> Self {
- let mut cx = Cx::new(Box::new(|_, _| {}));
- cx.init_cx_os();
- cx.with_vm(makepad_widgets::script_mod);
- let vm_id = cx.alloc_splash_vm_with_network(false);
- cx.with_script_vm_id_trusted(vm_id, |vm| MAPS_MODULE.register(vm));
- Isolate { cx, vm_id }
- }
-
- /// `f` with the isolate's VM, as the host calls `register` and `create`.
- pub(crate) fn with_vm(&mut self, f: impl FnOnce(&mut ScriptVm) -> R) -> R {
- self.cx.with_script_vm_id_trusted(self.vm_id, f)
- }
-
- /// `f` with the isolate entered, as the host runs an instance's events.
- pub(crate) fn entered(&mut self, f: impl FnOnce(&mut Cx) -> R) -> R {
- let entry = enter_isolate(&mut self.cx, self.vm_id);
- let out = f(&mut self.cx);
- leave_isolate(&mut self.cx, entry);
- out
- }
-
- /// The host's order: the root goes first, then the isolate. Any other
- /// ref into the isolate (an executor) is the caller's to drop before.
- pub(crate) fn teardown(self, root: WidgetRef) {
- drop(root);
- let Isolate { mut cx, vm_id } = self;
- cx.free_splash_vm(vm_id);
- }
-}
-
-/// A `MapsView {}` root minted in a fresh isolate, which evaluated it
-/// without script errors.
-pub(crate) fn isolate_root() -> (Isolate, WidgetRef) {
- let mut iso = Isolate::new();
- let root = iso.with_vm(|vm| {
- let value = script_eval!(vm, {
- use mod.widgets.*
- MapsView {}
- });
- let root = WidgetRef::script_from_value(vm, value);
- let errors = vm.take_errors();
- assert!(
- errors.is_empty(),
- "the isolate evaluated the view without errors: {errors:?}"
- );
- root
- });
- (iso, root)
-}
diff --git a/apps/maps/native/src/view.rs b/apps/maps/native/src/view.rs
deleted file mode 100644
index 12a57101..00000000
--- a/apps/maps/native/src/view.rs
+++ /dev/null
@@ -1,3332 +0,0 @@
-//! The maps surface, after Google Maps: one `MapView` that is never
-//! rebuilt, full-bleed, and over it a layer per screen that the model shows
-//! or hides — Explore's search pill and round buttons, Search's field and
-//! results, Place's pill and sheet; Directions and Navigation join them.
-//! Everything is drawn in the app's skin (`Skin`): Google Maps' light look,
-//! or its dark counterpart when the host is dark. The requests, the location
-//! updates, the timers and the map's camera are the view's.
-//! `ensure_started` seeds everything on the first event or draw, whichever
-//! the host gives it first: a window has a `Startup` event, a module
-//! instance does not.
-
-use crate::geo::{
- arrival_text, distance_text, duration_text, fit_camera, haversine_m, Bounds, Insets, LonLat,
- Units,
-};
-use crate::guidance::{ActiveNav, NavTick};
-use crate::http::MapHttp;
-use crate::model::{
- body_text, plain_error, LocationAsk, LocationFix, LocationState, MapsModel, Request,
- RouteState, Screen, SearchState, SearchTarget, Skin, LOCATION_FIX_TIMEOUT_SECONDS,
- STATE_KEY,
-};
-#[cfg(not(target_os = "android"))]
-use crate::model::{MAX_BODY_BYTES, USER_AGENT};
-use crate::places::{self, coordinates_text, Place, PlaceKind};
-use crate::routing::{Arrow, Mode};
-use crate::sheet::{Detent, Sheet};
-use crate::{HOSTED_OCEAN, HOSTED_TILES};
-use makepad_map_nav::nav::NavState;
-use makepad_widgets::makepad_platform::event::TouchState;
-use makepad_widgets::makepad_platform::storage::{
- StorageHandle, StorageRequestId, StorageResponse, StorageResult,
-};
-use makepad_widgets::*;
-
-/// How long a status line (`Locating…`, an error) stays up.
-const STATUS_SECONDS: f64 = 4.0;
-/// The zoom the locate button flies to: streets and their names.
-const LOCATE_ZOOM: f64 = 15.5;
-/// The closest a place is shown from: its block.
-const PLACE_ZOOM: f64 = 16.0;
-/// Rotation or tilt, in degrees, under which the map counts as north-up
-/// and flat: the widget's own snap leaves a fraction of a degree.
-const LEVEL_DEGREES: f64 = 1.0;
-/// `MapView::set_theme`: its light and its night style.
-const MAP_THEME_LIGHT: u32 = 0;
-const MAP_THEME_NIGHT: u32 = 1;
-/// The settings are written this long after they last changed: a pan is a
-/// hundred changes, and one write.
-const SAVE_DEBOUNCE_SECONDS: f64 = 2.0;
-/// Typing pauses this long before the search goes out: the service's terms
-/// ask for no request per keystroke.
-const SEARCH_DEBOUNCE_SECONDS: f64 = 0.3;
-/// What the floating pill and its margin take of the top of the viewport.
-const TOP_BAR_HEIGHT: f64 = 70.0;
-/// How much of the rest of the way the sheet's height goes each frame.
-const SHEET_EASE: f64 = 0.28;
-/// The selected place's marker, and a route's two ends.
-const PLACE_MARKER: u64 = 1;
-const ORIGIN_MARKER: u64 = 2;
-const DESTINATION_MARKER: u64 = 3;
-/// What the directions card and its margin take of the top of the viewport.
-const DIRECTIONS_CARD_HEIGHT: f64 = 176.0;
-/// The room a fitted route keeps from the viewport's sides and the panels.
-const ROUTE_MARGIN: f64 = 36.0;
-/// The chase camera: close enough to read the next street, tilted to see
-/// down it.
-const NAV_ZOOM: f64 = 17.0;
-const NAV_TILT: f64 = 55.0;
-/// What the banner and its margin take of the top of the viewport, at its
-/// tallest: two lines of instruction.
-const NAV_BANNER_HEIGHT: f64 = 120.0;
-/// What the navigation bar and its margin take of the bottom.
-const NAV_BAR_HEIGHT: f64 = 96.0;
-/// How far ahead of the puck the chase camera looks, so the puck sits below
-/// the middle and the road ahead fills the screen.
-const NAV_LEAD_M: f64 = 60.0;
-/// The accuracy ring guidance draws: the puck is on the line, not a guess.
-const NAV_PUCK_ACCURACY_M: f64 = 8.0;
-/// A press on the map that moves further than this many pixels is a pan,
-/// not a tap: the chase pauses.
-const PAN_PIXELS: f64 = 8.0;
-/// Between two live fixes when their clocks say nothing useful.
-const DEFAULT_FIX_INTERVAL_S: f64 = 1.0;
-/// A step shorter than this shows no distance: it would read `0 ft`.
-const MIN_STEP_DISTANCE_M: f64 = 5.0;
-/// The closest a short route is shown from.
-const ROUTE_MAX_ZOOM: f64 = 17.0;
-/// The sheet's detail rows: what fits at half height.
-const DETAIL_ROWS: usize = 4;
-
-script_mod! {
- use mod.prelude.widgets.*
- use mod.widgets.*
-
- // The skin's roles, resolved when this module is evaluated: the host
- // re-runs it on a light/dark change and re-applies the root.
- let c_ground = #(Skin::for_vm(vm).ground)
- let c_card = #(Skin::for_vm(vm).card)
- let c_ink = #(Skin::for_vm(vm).ink)
- let c_secondary = #(Skin::for_vm(vm).secondary)
- let c_hairline = #(Skin::for_vm(vm).hairline)
- let c_field = #(Skin::for_vm(vm).field)
- let c_accent = #(Skin::for_vm(vm).accent)
- let c_on_accent = #(Skin::for_vm(vm).on_accent)
- let c_alert = #(Skin::for_vm(vm).alert)
- let c_banner = #(Skin::for_vm(vm).banner)
- let c_go = #(Skin::for_vm(vm).go)
- // On the banner's green, in either skin.
- let c_on_banner = #ffffff
- let c_clear = #00000000
-
- // Text without `Label`'s own padding, so rows are as tall as their text.
- let Text = Label{padding: 0 draw_text +: {color: c_ink text_style: theme.font_regular{font_size: 14}}}
- let Caption = Text{draw_text +: {color: c_secondary text_style: theme.font_regular{font_size: 12}}}
- let Hairline = SolidView{width: Fill height: 0.5 draw_bg.color: c_hairline}
-
- // A button with no face of its own: an icon, or a line of text.
- let Plain = ButtonFlat{padding: 0 margin: 0 text: "" spacing: 0 align: Center
- draw_bg +: {
- border_size: uniform(0.0)
- color: uniform(c_clear) color_hover: uniform(c_clear) color_down: uniform(#00000018) color_focus: uniform(c_clear)
- border_color: uniform(c_clear) border_color_hover: uniform(c_clear) border_color_down: uniform(c_clear) border_color_focus: uniform(c_clear)
- }
- }
- // An icon button inside a bar: back, clear.
- let BarButton = Plain{width: 40 height: 40 icon_walk: Walk{width: 16 height: 16} draw_icon +: {color: c_secondary}}
- // A round button floating over the map: a disc in the card colour with
- // a hairline rim, its icon in the ink.
- let Fab = Plain{width: 48 height: 48 icon_walk: Walk{width: 20 height: 20}
- draw_bg +: {
- border_size: uniform(1.0) border_radius: uniform(24.0)
- color: uniform(c_card) color_hover: uniform(c_card) color_down: uniform(c_field) color_focus: uniform(c_card)
- border_color: uniform(c_hairline) border_color_hover: uniform(c_hairline) border_color_down: uniform(c_hairline) border_color_focus: uniform(c_hairline)
- }
- draw_icon +: {color: c_ink}
- }
- // The sheet's primary action: a pill in the accent.
- let PrimaryButton = Plain{width: Fit height: 40 spacing: 8 padding: Inset{left: 18 right: 20}
- icon_walk: Walk{width: 16 height: 16}
- draw_bg +: {
- border_radius: uniform(20.0)
- color: uniform(c_accent) color_hover: uniform(c_accent) color_down: uniform(c_accent) color_focus: uniform(c_accent)
- }
- draw_icon +: {color: c_on_accent}
- draw_text +: {color: c_on_accent color_hover: c_on_accent color_down: c_on_accent color_focus: c_on_accent text_style: theme.font_bold{font_size: 14}}
- }
- // Beside the primary action: an outline in the accent.
- let SecondaryButton = Plain{width: Fit height: 40 padding: Inset{left: 20 right: 20}
- draw_bg +: {
- border_size: uniform(1.0) border_radius: uniform(20.0)
- border_color: uniform(c_hairline) border_color_hover: uniform(c_hairline) border_color_down: uniform(c_hairline) border_color_focus: uniform(c_hairline)
- }
- draw_text +: {color: c_accent color_hover: c_accent color_down: c_accent color_focus: c_accent text_style: theme.font_bold{font_size: 14}}
- }
- // Ending the drive.
- let DangerButton = PrimaryButton{
- draw_bg +: {color: uniform(c_alert) color_hover: uniform(c_alert) color_down: uniform(c_alert) color_focus: uniform(c_alert)}
- }
- // A line of text in the accent: Retry.
- let TextButton = Plain{width: Fit height: 36 padding: Inset{left: 10 right: 10}
- draw_text +: {color: c_accent color_hover: c_accent color_down: c_accent color_focus: c_accent text_style: theme.font_bold{font_size: 14}}}
- // Anything that floats over the map on a card: the pills, the status
- // line, the sheets.
- let Floating = RoundedShadowView{width: Fill height: Fit
- draw_bg +: {color: c_card border_radius: uniform(24.0) shadow_color: #00000040 shadow_radius: uniform(8.0) shadow_offset: uniform(vec2(0.0, 2.0))}
- }
- // The search field inside its pill: no face of its own.
- let Field = TextInputFlat{width: Fill height: 40 margin: 0 padding: Inset{left: 4 right: 4 top: 10 bottom: 10} empty_text: "Search here"
- draw_bg +: {
- border_radius: 8.0 border_size: 0.0
- color: c_clear color_hover: c_clear color_focus: c_clear color_empty: c_clear
- border_color: c_clear border_color_hover: c_clear border_color_focus: c_clear border_color_empty: c_clear
- }
- draw_text +: {
- color: c_ink color_hover: c_ink color_focus: c_ink
- color_empty: c_secondary color_empty_hover: c_secondary color_empty_focus: c_secondary
- text_style: theme.font_regular{font_size: 16}
- }
- }
- // A switch of the layers sheet: a pill in the accent when on.
- let Switch = Toggle{text: "" margin: 0 padding: 0 width: 42 height: 26
- draw_bg +: {
- size: uniform(26.0)
- border_size: uniform(0.0)
- color: uniform(c_field) color_hover: uniform(c_field) color_down: uniform(c_field) color_focus: uniform(c_field)
- color_active: uniform(c_accent) color_disabled: uniform(c_field)
- border_color: uniform(c_clear) border_color_hover: uniform(c_clear) border_color_active: uniform(c_clear) border_color_focus: uniform(c_clear)
- mark_color: uniform(#ffffff) mark_color_hover: uniform(#ffffff) mark_color_down: uniform(#ffffff)
- mark_color_active: uniform(#ffffff) mark_color_active_hover: uniform(#ffffff)
- }
- }
- // A row of the layers sheet: its label fills the width, so the switch
- // after it sits at the right.
- let SwitchRow = View{width: Fill height: 52 flow: Right align: Align{y: 0.5} padding: Inset{left: 20 right: 16}}
- let SwitchLabel = Text{width: Fill draw_text.text_style: theme.font_regular{font_size: 16}}
-
- // A result's mark: one icon per kind of place, the row showing its own
- // (`show_kind`).
- let KindMark = View{visible: false width: Fit height: Fit}
- let KindIcon = Icon{icon_walk: Walk{width: 18 height: 18} draw_icon +: {color: c_secondary}}
- // One search result: its mark on a disc, its name over its address, and
- // how far it is once there is a fix to measure from.
- let ResultRow = View{width: Fill height: Fit flow: Down
- row := View{width: Fill height: Fit flow: Right spacing: 14 align: Align{y: 0.5} padding: Inset{left: 16 right: 16 top: 11 bottom: 11} cursor: MouseCursor.Hand
- RoundedView{width: 38 height: 38 align: Center draw_bg +: {color: c_field border_radius: uniform(19.0)}
- place := KindMark{KindIcon{draw_icon.svg: crate_resource("self:resources/icons/place.svg")}}
- food := KindMark{KindIcon{draw_icon.svg: crate_resource("self:resources/icons/food.svg")}}
- shop := KindMark{KindIcon{draw_icon.svg: crate_resource("self:resources/icons/shop.svg")}}
- transit := KindMark{KindIcon{draw_icon.svg: crate_resource("self:resources/icons/transit.svg")}}
- lodging := KindMark{KindIcon{draw_icon.svg: crate_resource("self:resources/icons/lodging.svg")}}
- street := KindMark{KindIcon{draw_icon.svg: crate_resource("self:resources/icons/street.svg")}}
- }
- View{width: Fill height: Fit flow: Down spacing: 3
- name := Text{width: Fill max_lines: 1 text_overflow: Ellipsis draw_text.text_style: theme.font_regular{font_size: 16}}
- address := Caption{width: Fill max_lines: 1 text_overflow: Ellipsis draw_text.text_style: theme.font_regular{font_size: 13}}
- }
- distance := Caption{draw_text.text_style: theme.font_regular{font_size: 13}}
- }
- View{width: Fill height: Fit padding: Inset{left: 68} Hairline{}}
- }
- // What the list says when it has no rows: a hint, `Searching…`, `No
- // results`, or what went wrong and a way to try again.
- let StatusRow = View{width: Fill height: Fit flow: Down spacing: 6 align: Align{x: 0.5} padding: Inset{left: 32 right: 32 top: 48 bottom: 24}
- text := Caption{max_lines: 3 align: Align{x: 0.5} draw_text.text_style: theme.font_regular{font_size: 15}}
- retry := TextButton{text: "Retry"}
- }
- // One fact on the sheet: what it is, over what it says.
- let DetailRow = View{visible: false width: Fill height: Fit flow: Down spacing: 2 padding: Inset{left: 20 right: 20 top: 10 bottom: 10}
- label := Caption{}
- value := Text{width: Fill max_lines: 2 text_overflow: Ellipsis draw_text.text_style: theme.font_regular{font_size: 15}}
- }
-
- // One end of the route on the directions card: a dot in its colour and
- // where it is, on a field of its own that opens Search for it.
- let EndRow = RoundedView{width: Fill height: 40 flow: Right spacing: 10 align: Align{y: 0.5} padding: Inset{left: 12 right: 12} cursor: MouseCursor.Hand
- draw_bg +: {color: c_field border_radius: uniform(10.0)}
- }
- let EndDot = RoundedView{width: 10 height: 10 draw_bg +: {color: c_accent border_radius: uniform(5.0)}}
- let EndText = Text{width: Fill max_lines: 1 text_overflow: Ellipsis draw_text.text_style: theme.font_regular{font_size: 15}}
- // A travel mode's tab: its icon and how long that way takes. Each mode
- // has the two faces, and `render` shows the one that fits.
- let ModeTab = Plain{width: Fill height: 36 spacing: 6 icon_walk: Walk{width: 18 height: 18}
- draw_bg +: {border_radius: uniform(18.0)}
- draw_icon +: {color: c_secondary}
- draw_text +: {color: c_secondary color_hover: c_secondary color_down: c_secondary color_focus: c_secondary text_style: theme.font_bold{font_size: 13}}
- }
- let ModeTabOn = ModeTab{visible: false
- draw_bg +: {color: uniform(c_field) color_hover: uniform(c_field) color_down: uniform(c_field) color_focus: uniform(c_field)}
- draw_icon +: {color: c_accent}
- draw_text +: {color: c_accent color_hover: c_accent color_down: c_accent color_focus: c_accent}
- }
- let ModeSlot = View{width: Fill height: Fit flow: Overlay}
- // A step's arrow: one icon per way to go, the row showing its own
- // (`show_arrow`).
- let ArrowMark = View{visible: false width: Fit height: Fit}
- let ArrowIcon = Icon{icon_walk: Walk{width: 22 height: 22} draw_icon +: {color: c_ink}}
- // One line of the directions: its arrow, what to do, and for how far.
- let StepRow = View{width: Fill height: Fit flow: Down
- View{width: Fill height: Fit flow: Right spacing: 14 align: Align{y: 0.5} padding: Inset{left: 20 right: 20 top: 12 bottom: 12}
- View{width: 26 height: 26 align: Center
- depart := ArrowMark{ArrowIcon{draw_icon.svg: crate_resource("self:resources/icons/depart.svg")}}
- arrive := ArrowMark{ArrowIcon{draw_icon.svg: crate_resource("self:resources/icons/arrive.svg")}}
- straight := ArrowMark{ArrowIcon{draw_icon.svg: crate_resource("self:resources/icons/straight.svg")}}
- slight_left := ArrowMark{ArrowIcon{draw_icon.svg: crate_resource("self:resources/icons/slight-left.svg")}}
- left := ArrowMark{ArrowIcon{draw_icon.svg: crate_resource("self:resources/icons/turn-left.svg")}}
- sharp_left := ArrowMark{ArrowIcon{draw_icon.svg: crate_resource("self:resources/icons/sharp-left.svg")}}
- slight_right := ArrowMark{ArrowIcon{draw_icon.svg: crate_resource("self:resources/icons/slight-right.svg")}}
- right := ArrowMark{ArrowIcon{draw_icon.svg: crate_resource("self:resources/icons/turn-right.svg")}}
- sharp_right := ArrowMark{ArrowIcon{draw_icon.svg: crate_resource("self:resources/icons/sharp-right.svg")}}
- uturn := ArrowMark{ArrowIcon{draw_icon.svg: crate_resource("self:resources/icons/uturn.svg")}}
- roundabout := ArrowMark{ArrowIcon{draw_icon.svg: crate_resource("self:resources/icons/roundabout.svg")}}
- }
- View{width: Fill height: Fit flow: Down spacing: 3
- text := Text{width: Fill max_lines: 2 text_overflow: Ellipsis draw_text.text_style: theme.font_regular{font_size: 15}}
- distance := Caption{draw_text.text_style: theme.font_regular{font_size: 13}}
- }
- }
- View{width: Fill height: Fit padding: Inset{left: 60} Hairline{}}
- }
-
- // The banner's arrow: the step's, larger and on the green.
- let BannerArrow = Icon{icon_walk: Walk{width: 36 height: 36} draw_icon +: {color: c_on_banner}}
-
- mod.widgets.MapsViewBase = #(MapsView::register_widget(vm))
- mod.widgets.MapsView = set_type_default() do mod.widgets.MapsViewBase{
- width: Fill height: Fill flow: Overlay
-
- // The one map, under everything, for as long as the app lives.
- // `debug_cam`: the camera readout is a workbench tool.
- map := MapView{width: Fill height: Fill min_zoom: 3.0 max_zoom: 20.0 buildings_3d: true debug_cam: false}
-
- // The licence's line, where the data's readers look for it, on a
- // backing so it reads over any map: on every screen that shows the
- // map, just above whatever is at the bottom of it (`render` and the
- // sheet set the padding). Under the layers, so a sheet on its way
- // up covers it rather than the other way round.
- attribution_slot := View{width: Fill height: Fill flow: Down padding: Inset{left: 12}
- View{width: Fill height: Fill}
- RoundedView{width: Fit height: Fit padding: Inset{left: 6 right: 6 top: 3 bottom: 3}
- draw_bg +: {color: c_card border_radius: uniform(6.0)}
- attribution := Caption{text: "© OpenStreetMap contributors" draw_text.text_style: theme.font_regular{font_size: 10}}
- }
- // As tall as what the line has to clear: a height can be set at
- // run time, a padding cannot (`Inset` is not in that scope).
- attribution_gap := View{width: Fill height: 14}
- }
-
- // Explore: no ground of its own, so the map beneath keeps its touches.
- explore := View{width: Fill height: Fill flow: Down padding: Inset{left: 12 right: 12 top: 10 bottom: 14}
- pill := Floating{height: 48 flow: Right spacing: 12 align: Align{y: 0.5} padding: Inset{left: 16 right: 16} cursor: MouseCursor.Hand
- Icon{icon_walk: Walk{width: 18 height: 18} draw_icon +: {svg: crate_resource("self:resources/icons/search.svg") color: c_secondary}}
- Text{width: Fill max_lines: 1 text: "Search here" draw_text +: {color: c_secondary text_style: theme.font_regular{font_size: 16}}}
- }
- View{width: Fill height: Fit flow: Right padding: Inset{top: 12}
- View{width: Fill height: Fit}
- layers := Fab{width: 44 height: 44 draw_bg +: {border_radius: uniform(22.0)} draw_icon.svg: crate_resource("self:resources/icons/layers.svg")}
- }
- View{width: Fill height: Fill}
- View{width: Fill height: Fit flow: Right align: Align{y: 1.0}
- View{width: Fill height: Fit}
- View{width: Fit height: Fit flow: Down spacing: 12
- compass := Fab{visible: false draw_icon +: {svg: crate_resource("self:resources/icons/compass.svg") color: c_accent}}
- locate := Fab{draw_icon.svg: crate_resource("self:resources/icons/locate.svg")}
- locate_active := Fab{visible: false draw_icon +: {svg: crate_resource("self:resources/icons/locate-active.svg") color: c_accent}}
- }
- }
- }
-
- // Place: the pill names the place, the sheet tells about it. No
- // ground of its own either: the map is still the map.
- place_layer := View{visible: false width: Fill height: Fill flow: Down
- View{width: Fill height: Fit padding: Inset{left: 12 right: 12 top: 10}
- Floating{height: 48 flow: Right align: Align{y: 0.5} padding: Inset{left: 4 right: 4}
- place_back := BarButton{draw_icon.svg: crate_resource("self:resources/icons/back.svg")}
- place_pill := View{width: Fill height: Fill align: Align{y: 0.5} padding: Inset{left: 4 right: 4} cursor: MouseCursor.Hand
- place_pill_text := Text{width: Fill max_lines: 1 text_overflow: Ellipsis draw_text.text_style: theme.font_regular{font_size: 16}}
- }
- place_close := BarButton{draw_icon.svg: crate_resource("self:resources/icons/close.svg")}
- }
- }
- View{width: Fill height: Fill}
- // Its height is the sheet's (`Sheet`), set as it is dragged and
- // eased; what does not fit is clipped, so half shows more than
- // peek and full more than half.
- sheet := Floating{height: 132 flow: Down
- draw_bg +: {border_radius: uniform(20.0)}
- // The part a finger takes hold of: the handle and the title.
- sheet_head := View{width: Fill height: Fit flow: Down cursor: MouseCursor.Hand
- View{width: Fill height: 18 align: Center
- RoundedView{width: 36 height: 4 draw_bg +: {color: c_hairline border_radius: uniform(2.0)}}
- }
- View{width: Fill height: Fit flow: Down spacing: 4 padding: Inset{left: 20 right: 20 bottom: 12}
- sheet_title := Text{width: Fill max_lines: 1 text_overflow: Ellipsis draw_text.text_style: theme.font_bold{font_size: 20}}
- sheet_subtitle := Caption{width: Fill max_lines: 1 text_overflow: Ellipsis draw_text.text_style: theme.font_regular{font_size: 14}}
- }
- }
- View{width: Fill height: Fit padding: Inset{left: 20 right: 20 bottom: 14}
- directions := PrimaryButton{text: "Directions" draw_icon.svg: crate_resource("self:resources/icons/directions.svg")}
- }
- Hairline{}
- address_row := DetailRow{label := Caption{text: "Address"}}
- coordinates_row := DetailRow{visible: true label := Caption{text: "Coordinates"}}
- detail_0 := DetailRow{}
- detail_1 := DetailRow{}
- detail_2 := DetailRow{}
- detail_3 := DetailRow{}
- }
- }
-
- // Directions: the two ends and the modes on a card, the route's
- // summary and its steps on a sheet, the map between them.
- directions_layer := View{visible: false width: Fill height: Fill flow: Down
- View{width: Fill height: Fit padding: Inset{left: 10 right: 10 top: 10}
- Floating{flow: Down spacing: 8 padding: Inset{left: 4 right: 4 top: 8 bottom: 8}
- draw_bg +: {border_radius: uniform(16.0)}
- View{width: Fill height: Fit flow: Right align: Align{y: 0.5}
- directions_back := BarButton{draw_icon.svg: crate_resource("self:resources/icons/back.svg")}
- View{width: Fill height: Fit flow: Down spacing: 6
- origin_row := EndRow{EndDot{} origin_text := EndText{}}
- destination_row := EndRow{EndDot{draw_bg +: {color: c_alert}} destination_text := EndText{}}
- }
- swap := BarButton{draw_icon.svg: crate_resource("self:resources/icons/swap.svg")}
- }
- View{width: Fill height: Fit flow: Right spacing: 6 padding: Inset{left: 8 right: 8}
- ModeSlot{
- mode_car := ModeTab{draw_icon.svg: crate_resource("self:resources/icons/car.svg")}
- mode_car_on := ModeTabOn{draw_icon.svg: crate_resource("self:resources/icons/car.svg")}
- }
- ModeSlot{
- mode_walk := ModeTab{draw_icon.svg: crate_resource("self:resources/icons/walk.svg")}
- mode_walk_on := ModeTabOn{draw_icon.svg: crate_resource("self:resources/icons/walk.svg")}
- }
- ModeSlot{
- mode_bike := ModeTab{draw_icon.svg: crate_resource("self:resources/icons/bike.svg")}
- mode_bike_on := ModeTabOn{draw_icon.svg: crate_resource("self:resources/icons/bike.svg")}
- }
- }
- }
- }
- View{width: Fill height: Fill}
- // The same sheet as a place's, about the route.
- route_sheet := Floating{height: 132 flow: Down
- draw_bg +: {border_radius: uniform(20.0)}
- route_head := View{width: Fill height: Fit flow: Down cursor: MouseCursor.Hand
- View{width: Fill height: 18 align: Center
- RoundedView{width: 36 height: 4 draw_bg +: {color: c_hairline border_radius: uniform(2.0)}}
- }
- View{width: Fill height: Fit flow: Down spacing: 4 padding: Inset{left: 20 right: 20 bottom: 12}
- route_title := Text{width: Fill max_lines: 1 text_overflow: Ellipsis draw_text.text_style: theme.font_bold{font_size: 20}}
- route_subtitle := Caption{width: Fill max_lines: 1 text_overflow: Ellipsis draw_text.text_style: theme.font_regular{font_size: 14}}
- }
- }
- route_actions := View{width: Fill height: Fit flow: Right spacing: 10 padding: Inset{left: 20 right: 20 bottom: 14}
- start := PrimaryButton{visible: false text: "Start" draw_icon.svg: crate_resource("self:resources/icons/recenter.svg")}
- preview := SecondaryButton{visible: false text: "Preview"}
- route_retry := TextButton{visible: false text: "Retry"}
- }
- Hairline{}
- steps := PortalList{width: Fill height: Fill
- Step := StepRow{}
- }
- }
- }
-
- // Navigation: the next turn on a banner, what is left on a bar, the
- // map chasing the puck between them.
- nav_layer := View{visible: false width: Fill height: Fill flow: Down
- View{width: Fill height: Fit padding: Inset{left: 10 right: 10 top: 10}
- banner := RoundedShadowView{width: Fill height: Fit flow: Right spacing: 14 align: Align{y: 0.5} padding: Inset{left: 18 right: 18 top: 14 bottom: 14}
- draw_bg +: {color: c_banner border_radius: uniform(16.0) shadow_color: #00000040 shadow_radius: uniform(8.0) shadow_offset: uniform(vec2(0.0, 2.0))}
- View{width: 44 height: 44 align: Center
- depart := ArrowMark{BannerArrow{draw_icon.svg: crate_resource("self:resources/icons/depart.svg")}}
- arrive := ArrowMark{BannerArrow{draw_icon.svg: crate_resource("self:resources/icons/arrive.svg")}}
- straight := ArrowMark{BannerArrow{draw_icon.svg: crate_resource("self:resources/icons/straight.svg")}}
- slight_left := ArrowMark{BannerArrow{draw_icon.svg: crate_resource("self:resources/icons/slight-left.svg")}}
- left := ArrowMark{BannerArrow{draw_icon.svg: crate_resource("self:resources/icons/turn-left.svg")}}
- sharp_left := ArrowMark{BannerArrow{draw_icon.svg: crate_resource("self:resources/icons/sharp-left.svg")}}
- slight_right := ArrowMark{BannerArrow{draw_icon.svg: crate_resource("self:resources/icons/slight-right.svg")}}
- right := ArrowMark{BannerArrow{draw_icon.svg: crate_resource("self:resources/icons/turn-right.svg")}}
- sharp_right := ArrowMark{BannerArrow{draw_icon.svg: crate_resource("self:resources/icons/sharp-right.svg")}}
- uturn := ArrowMark{BannerArrow{draw_icon.svg: crate_resource("self:resources/icons/uturn.svg")}}
- roundabout := ArrowMark{BannerArrow{draw_icon.svg: crate_resource("self:resources/icons/roundabout.svg")}}
- }
- View{width: Fill height: Fit flow: Down spacing: 2
- banner_distance := Text{width: Fill max_lines: 1 draw_text +: {color: c_on_banner text_style: theme.font_bold{font_size: 24}}}
- banner_text := Text{width: Fill max_lines: 2 text_overflow: Ellipsis draw_text +: {color: c_on_banner text_style: theme.font_regular{font_size: 16}}}
- }
- }
- }
- // No ground of its own: the map keeps its touches.
- View{width: Fill height: Fill flow: Down align: Align{x: 1.0 y: 1.0} padding: Inset{right: 12 bottom: 12}
- recenter := Fab{visible: false draw_icon +: {svg: crate_resource("self:resources/icons/recenter.svg") color: c_accent}}
- }
- View{width: Fill height: Fit padding: Inset{left: 10 right: 10 bottom: 12}
- nav_bar := Floating{flow: Right align: Align{y: 0.5} padding: Inset{left: 20 right: 14 top: 14 bottom: 14}
- draw_bg +: {border_radius: uniform(20.0)}
- View{width: Fill height: Fit flow: Down spacing: 3
- nav_time := Text{width: Fill max_lines: 1 draw_text +: {color: c_go text_style: theme.font_bold{font_size: 22}}}
- nav_rest := Caption{width: Fill max_lines: 1 draw_text.text_style: theme.font_regular{font_size: 14}}
- }
- end := DangerButton{text: "End"}
- done := PrimaryButton{visible: false text: "Done"}
- }
- }
- }
-
- // Search: a page of its own over the map.
- search_layer := View{visible: false width: Fill height: Fill flow: Overlay
- // A `SolidView`: a plain view's ground has no shader at this
- // revision and would draw nothing.
- SolidView{width: Fill height: Fill draw_bg.color: c_ground cursor: MouseCursor.Default}
- View{width: Fill height: Fill flow: Down
- View{width: Fill height: Fit padding: Inset{left: 12 right: 12 top: 10 bottom: 8}
- Floating{height: 48 flow: Right align: Align{y: 0.5} padding: Inset{left: 4 right: 4}
- search_back := BarButton{draw_icon.svg: crate_resource("self:resources/icons/back.svg")}
- search := Field{}
- search_clear := BarButton{visible: false draw_icon.svg: crate_resource("self:resources/icons/close.svg")}
- }
- }
- results := PortalList{width: Fill height: Fill
- Result := ResultRow{}
- Status := StatusRow{}
- }
- }
- }
-
- // What the app has to say in passing, over any screen.
- View{width: Fill height: Fit flow: Down align: Align{x: 0.5}
- // As tall as what the line sits under (`status_top`).
- status_gap := View{width: Fill height: 68}
- status := Floating{visible: false width: Fit padding: Inset{left: 16 right: 16 top: 9 bottom: 9}
- draw_bg +: {border_radius: uniform(18.0)}
- status_text := Text{draw_text.text_style: theme.font_regular{font_size: 13}}
- }
- }
-
- // The layers sheet: a scrim and a card at the bottom.
- layers_layer := View{visible: false width: Fill height: Fill flow: Overlay
- scrim := SolidView{width: Fill height: Fill draw_bg.color: #00000055 cursor: MouseCursor.Default}
- View{width: Fill height: Fill flow: Down align: Align{y: 1.0} padding: Inset{left: 10 right: 10 bottom: 12}
- Floating{flow: Down padding: Inset{top: 8 bottom: 8}
- View{width: Fill height: 48 flow: Right align: Align{y: 0.5} padding: Inset{left: 20 right: 8}
- Text{width: Fill text: "Map details" draw_text.text_style: theme.font_bold{font_size: 17}}
- layers_close := BarButton{draw_icon.svg: crate_resource("self:resources/icons/close.svg")}
- }
- Hairline{}
- SwitchRow{SwitchLabel{text: "Dark map"} dark_map := Switch{}}
- SwitchRow{SwitchLabel{text: "3D buildings"} buildings := Switch{}}
- SwitchRow{SwitchLabel{text: "Labels"} labels := Switch{}}
- SwitchRow{SwitchLabel{text: "Distances in miles"} miles := Switch{}}
- }
- }
- }
- }
-}
-
-/// One entry of the results list: a place, or what the list says instead.
-#[derive(Clone, Debug, PartialEq)]
-pub(crate) enum ResultItem {
- Place(usize),
- Status { text: String, retry: bool },
-}
-
-impl ResultItem {
- fn template(&self) -> LiveId {
- match self {
- ResultItem::Place(_) => live_id!(Result),
- ResultItem::Status { .. } => live_id!(Status),
- }
- }
-}
-
-/// The results list for a search in this state.
-pub(crate) fn result_items(state: &SearchState, results: usize, query: &str) -> Vec {
- let status = |text: &str, retry| {
- vec![ResultItem::Status {
- text: text.to_string(),
- retry,
- }]
- };
- match state {
- // A failure keeps the last list in the model, but says so instead
- // of showing rows that may not match what is typed now.
- SearchState::Failed(why) => status(&format!("Couldn't search: {why}"), true),
- _ if results > 0 => (0..results).map(ResultItem::Place).collect(),
- SearchState::Loading => status("Searching…", false),
- SearchState::Done => status(&format!("No results for “{}”", query.trim()), false),
- SearchState::Idle => status("Search for a place or an address", false),
- }
-}
-
-/// The map, the screens over it and what drives them: one widget that owns
-/// everything the standalone window's `App` would otherwise own, so a module
-/// host gets the same maps a window does.
-#[derive(Script, ScriptHook, Widget)]
-pub struct MapsView {
- #[deref]
- view: View,
- /// Set once, on the first event or draw.
- #[rust]
- started: bool,
- #[rust]
- model: MapsModel,
- #[rust]
- #[allow(dead_code)] // The framework owns service requests on other platforms.
- http: MapHttp,
- #[rust]
- location: LocationState,
- /// Runs while the locate button waits for its first fix.
- #[rust]
- location_timeout: Option,
- /// Takes the status line down again.
- #[rust]
- status_timer: Option,
- /// Runs from the last keystroke to the search it sends.
- #[rust]
- search_timer: Option,
- #[rust]
- layers_open: bool,
- /// The results list as drawn, recomputed by `render`.
- #[rust]
- items: Vec,
- /// Search was just opened: its field takes the key focus (and raises
- /// the keyboard) after its first draw, when it has an area to focus.
- #[rust]
- focus_search: bool,
- #[rust]
- sheet: Sheet,
- /// The sheet's height as drawn, on its way to `sheet`'s.
- #[rust]
- sheet_height: f64,
- /// Runs while the sheet's height is easing.
- #[rust]
- sheet_frame: NextFrame,
- /// The finger on the sheet a frame ago, for the speed it lifts at.
- #[rust]
- sheet_last_move: Option<(f64, f64)>,
- /// Pixels a second, downwards, of the sheet's last drag.
- #[rust]
- sheet_velocity: f64,
- /// The viewport's size at the last draw: the sheet's heights and the
- /// camera's room are fractions of it.
- #[rust]
- viewport: (f64, f64),
- /// The instance's storage jail (module) or its own namespace
- /// (standalone): the settings and the last camera live there.
- #[rust]
- storage: Option,
- /// The read of the saved state, while it is in flight.
- #[rust]
- load: Option,
- /// Writes in flight, so a failure is logged.
- #[rust]
- writes: Vec,
- /// Runs from the last change of the settings to the write that keeps it.
- #[rust]
- save_timer: Option,
- /// The app was told where to open (`--at`): the saved camera stays saved.
- #[rust]
- camera_given: bool,
- /// Guidance, while navigating or previewing.
- #[rust]
- nav: Option,
- /// Runs while a preview drives.
- #[rust]
- nav_frame: NextFrame,
- /// The clock of the preview's last frame, or of the last live fix.
- #[rust]
- nav_clock: Option,
- /// Whether the camera follows the puck: a pan pauses it.
- #[rust]
- chasing: bool,
- /// Where guidance last put the camera: where Recentre goes back to.
- #[rust]
- chase_center: Option,
- /// Where a press on the bare map began, while the camera is chasing.
- #[rust]
- map_press: Option,
- /// A new route is on its way: the banner says so.
- #[rust]
- rerouting: bool,
- /// Whether the map is drawing a route's line.
- #[rust]
- route_on_map: bool,
- /// The map theme last applied, so a skin change re-applies it.
- #[rust]
- map_theme: Option,
-}
-
-impl MapsView {
- pub fn model(&self) -> &MapsModel {
- &self.model
- }
-
- pub fn location(&self) -> LocationState {
- self.location
- }
-
- #[cfg(test)]
- pub(crate) fn layers_open(&self) -> bool {
- self.layers_open
- }
-
- #[cfg(test)]
- pub(crate) fn items(&self) -> &[ResultItem] {
- &self.items
- }
-
- #[cfg(test)]
- pub(crate) fn navigating(&self) -> bool {
- self.nav.is_some()
- }
-
- #[cfg(test)]
- pub(crate) fn sheet_detent(&self) -> Detent {
- self.sheet.detent()
- }
-
- #[cfg(test)]
- pub(crate) fn status_text(&self, cx: &Cx) -> Option {
- self.view
- .widget(cx, ids!(status))
- .visible()
- .then(|| self.view.label(cx, ids!(status_text)).text())
- }
-
- fn map(&self, cx: &Cx) -> MapViewRef {
- self.view.map_view(cx, ids!(map))
- }
-
- /// The timers stop, every request in flight is cancelled and the
- /// platform's location updates end; nothing else this widget owns
- /// outlives its isolate.
- pub fn shutdown(&mut self, cx: &mut Cx) {
- // A change still waiting for its write goes out now.
- if self.save_timer.is_some() {
- self.save_now(cx);
- }
- let timers = [
- self.location_timeout.take(),
- self.status_timer.take(),
- self.search_timer.take(),
- ];
- for timer in timers.into_iter().flatten() {
- cx.stop_timer(timer);
- }
- for id in self.model.cancel_all() {
- self.cancel_request(cx, id);
- }
- // Its frames stop with it.
- self.nav = None;
- if self.location.failed() {
- cx.stop_location_updates();
- }
- }
-
- /// Start on the first event or draw, whichever comes first.
- fn ensure_started(&mut self, cx: &mut Cx) {
- if self.started {
- return;
- }
- self.started = true;
- let map = self.map(cx);
- map.set_source_config(cx, TileSourceConfig::http_archive(HOSTED_TILES));
- let ocean = HOSTED_OCEAN
- .iter()
- .map(|url| OverlaySource::new("ocean", TileSourceConfig::http_archive(*url)))
- .collect();
- map.set_overlays(cx, ocean);
- self.apply_settings(cx);
- let settings = &self.model.settings;
- map.set_center(cx, settings.center.lon, settings.center.lat);
- map.set_map_zoom(cx, settings.zoom);
- if let Some(storage) = self.storage.as_ref() {
- self.load = Some(storage.get(cx, STATE_KEY));
- }
- self.render(cx);
- }
-
- /// A host handed this instance its storage: the settings load from and
- /// save to it from now on. Call once: a second handle is ignored, so the
- /// state is read from one jail only. Before the start `ensure_started`
- /// issues the load; after it the load goes out at once, so a handle
- /// that arrives late is never a silent no-op.
- pub fn set_storage(&mut self, cx: &mut Cx, storage: StorageHandle) {
- if self.storage.is_some() {
- log!("maps: set_storage called twice; keeping the first jail");
- return;
- }
- self.load = self.started.then(|| storage.get(cx, STATE_KEY));
- self.storage = Some(storage);
- }
-
- #[cfg(test)]
- pub(crate) fn has_pending_load(&self) -> bool {
- self.load.is_some()
- }
-
- /// The saved state landed: the switches, and the camera unless the app
- /// was told where to open or the person has already gone somewhere.
- pub(crate) fn apply_saved_state(&mut self, cx: &mut Cx, bytes: &[u8]) {
- let given = self.model.settings.clone();
- self.model.load_state(bytes);
- let moved = self.camera_given || self.model.screen() != Screen::Explore;
- if moved {
- self.model.settings.center = given.center;
- self.model.settings.zoom = given.zoom;
- } else {
- let settings = &self.model.settings;
- let map = self.map(cx);
- map.set_center(cx, settings.center.lon, settings.center.lat);
- map.set_map_zoom(cx, settings.zoom);
- }
- self.apply_settings(cx);
- self.render(cx);
- }
-
- /// The settings changed: they are written once they stop changing.
- fn settings_changed(&mut self, cx: &mut Cx) {
- // No jail, nothing to write; and while the saved state is still
- // being read, the start's own camera move must not overwrite it
- // with the defaults if the read is slow or fails.
- if self.storage.is_none() || self.load.is_some() {
- return;
- }
- if let Some(timer) = self.save_timer.take() {
- cx.stop_timer(timer);
- }
- self.save_timer = Some(cx.start_timeout(SAVE_DEBOUNCE_SECONDS));
- }
-
- fn save_now(&mut self, cx: &mut Cx) {
- if let Some(timer) = self.save_timer.take() {
- cx.stop_timer(timer);
- }
- if let Some(storage) = self.storage.as_ref() {
- let id = storage.set(cx, STATE_KEY, self.model.saved_state());
- self.writes.push(id);
- }
- }
-
- fn on_storage(&mut self, cx: &mut Cx, responses: &[StorageResponse]) {
- for response in responses {
- if self.load == Some(response.request_id) {
- self.load = None;
- match &response.result {
- Ok(StorageResult::Value(Some(bytes))) => self.apply_saved_state(cx, bytes),
- // Nothing saved yet: the defaults stand.
- Ok(_) => {}
- Err(e) => log!("maps: could not read {STATE_KEY}: {e}"),
- }
- } else if let Some(at) = self.writes.iter().position(|id| *id == response.request_id) {
- self.writes.remove(at);
- if let Err(e) = &response.result {
- log!("maps: could not save {STATE_KEY}: {e}");
- }
- }
- }
- }
-
- /// Open the layers sheet, as its button does.
- pub fn open_layers(&mut self, cx: &mut Cx) {
- self.layers_open = true;
- self.render(cx);
- }
-
- /// The camera the app was given to open on (`--at`), before it starts.
- pub fn set_initial_camera(&mut self, center: LonLat, zoom: f64) {
- self.model.settings.center = center;
- self.model.settings.zoom = zoom;
- self.camera_given = true;
- }
-
- /// The layer switches as the map's own state.
- fn apply_settings(&mut self, cx: &mut Cx) {
- let map = self.map(cx);
- let settings = &self.model.settings;
- map.set_buildings_3d(cx, settings.buildings_3d);
- map.set_labels_visible(cx, settings.labels);
- self.apply_map_theme(cx);
- }
-
- /// The map is dark when the person said so, else when the skin is.
- fn apply_map_theme(&mut self, cx: &mut Cx) {
- let dark = self
- .model
- .settings
- .dark_map
- .unwrap_or(!Skin::current().light);
- let theme = if dark {
- MAP_THEME_NIGHT
- } else {
- MAP_THEME_LIGHT
- };
- if self.map_theme != Some(theme) {
- self.map_theme = Some(theme);
- self.map(cx).set_theme(cx, theme);
- }
- }
-
- // ---- Requests ----
-
- /// One GET to a service, named as its terms ask, its body capped.
- fn send(&mut self, cx: &mut Cx, id: LiveId, url: String) {
- #[cfg(target_os = "android")]
- {
- let _ = cx;
- self.http.send(id, url);
- }
- #[cfg(not(target_os = "android"))]
- {
- let mut request = HttpRequest::new(url, HttpMethod::GET);
- request.set_header("Accept".into(), "application/json".into());
- request.set_header("User-Agent".into(), USER_AGENT.into());
- request.max_response_body_bytes = MAX_BODY_BYTES as u64;
- cx.http_request(id, request);
- }
- }
-
- fn cancel_request(&mut self, cx: &mut Cx, id: LiveId) {
- #[cfg(target_os = "android")]
- {
- let _ = cx;
- self.http.cancel(id);
- }
- #[cfg(not(target_os = "android"))]
- cx.cancel_http_request(id);
- }
-
- /// Whatever the model no longer wants an answer to.
- fn cancel_superseded(&mut self, cx: &mut Cx) {
- for id in self.model.superseded() {
- self.cancel_request(cx, id);
- }
- }
-
- /// A reply landed, or failed to. The map's own tile requests, and any
- /// the model has superseded, are not ours: `complete` drops them.
- pub(crate) fn handle_reply(&mut self, cx: &mut Cx, id: LiveId, body: Result) {
- match self.model.complete(id, body) {
- Some(Request::Search) => self.render(cx),
- // The dropped pin has a name now.
- Some(Request::Reverse) => self.render(cx),
- Some(Request::Route(mode)) => {
- // The shown tab's route goes on the map; then the next tab's
- // is asked for, one at a time.
- // Only on Directions: a reply that lands after the person
- // went back to the place is kept for the next visit, and
- // the map stays the place's.
- if mode == self.model.mode() && self.model.screen() == Screen::Directions {
- self.show_route(cx);
- }
- self.pump_routes(cx);
- self.render(cx);
- }
- Some(Request::Reroute) => self.on_reroute(cx),
- None => {}
- }
- }
-
- // ---- Search ----
-
- /// The search page, for one end of a route or for a place to show.
- pub fn open_search(&mut self, cx: &mut Cx, target: SearchTarget) {
- self.model.open_search(target);
- self.cancel_superseded(cx);
- self.view.text_input(cx, ids!(search)).set_text(cx, "");
- self.focus_search = true;
- self.render(cx);
- }
-
- /// The field changed: the list follows at once for a blank field, and
- /// after a pause in the typing otherwise.
- fn set_query(&mut self, cx: &mut Cx, query: &str) {
- self.model.set_query(query);
- self.cancel_superseded(cx);
- if let Some(timer) = self.search_timer.take() {
- cx.stop_timer(timer);
- }
- if !query.trim().is_empty() {
- self.search_timer = Some(cx.start_timeout(SEARCH_DEBOUNCE_SECONDS));
- }
- self.render(cx);
- }
-
- /// Search for `query` as if it had been typed and the pause had passed.
- pub fn search_for(&mut self, cx: &mut Cx, query: &str) {
- self.open_search(cx, SearchTarget::Destination);
- self.view.text_input(cx, ids!(search)).set_text(cx, query);
- self.model.set_query(query);
- self.search_now(cx);
- }
-
- /// Send the search for what is typed, near where the map is looking.
- pub(crate) fn search_now(&mut self, cx: &mut Cx) {
- if let Some(timer) = self.search_timer.take() {
- cx.stop_timer(timer);
- }
- let near = self.model.settings.center;
- if let Some((id, url)) = self.model.begin_search(near) {
- self.cancel_superseded(cx);
- self.send(cx, id, url);
- }
- self.render(cx);
- }
-
- /// A row of the results was picked.
- pub fn pick_result(&mut self, cx: &mut Cx, index: usize) {
- self.model.pick_result(index);
- self.cancel_superseded(cx);
- self.leave_search(cx);
- match self.model.screen() {
- Screen::Place => self.show_place(cx),
- // One of the route's ends was picked.
- Screen::Directions => self.enter_directions(cx),
- _ => {}
- }
- self.render(cx);
- }
-
- /// The field lets go of the keyboard.
- fn leave_search(&mut self, cx: &mut Cx) {
- if let Some(timer) = self.search_timer.take() {
- cx.stop_timer(timer);
- }
- self.focus_search = false;
- cx.set_key_focus(Area::Empty);
- cx.hide_text_ime();
- }
-
- // ---- Place ----
-
- /// The model has a place: pin it, look at it, and bring the sheet up
- /// at peek.
- fn show_place(&mut self, cx: &mut Cx) {
- let Some(place) = self.model.place().cloned() else {
- return;
- };
- self.sheet.set(Detent::Peek);
- self.sheet_height = self.sheet.height(self.viewport.1);
- // On the widget too: the last place may have left it at half or full.
- self.apply_sheet_height(cx);
- let map = self.map(cx);
- let marker = MapMarker::new(
- PLACE_MARKER,
- place.pos.lon,
- place.pos.lat,
- Skin::current().alert,
- );
- map.set_markers(cx, vec![marker]);
- // The place in the middle of the map the pill and the sheet leave.
- let insets = Insets {
- top: TOP_BAR_HEIGHT,
- bottom: self.sheet_height,
- ..Insets::default()
- };
- let point = Bounds {
- west: place.pos.lon,
- east: place.pos.lon,
- south: place.pos.lat,
- north: place.pos.lat,
- };
- // Never closer than the block, never further out than the map is.
- let zoom = self.model.settings.zoom.max(PLACE_ZOOM);
- let camera = fit_camera(&point, self.viewport, &insets, zoom);
- map.fly_to(cx, camera.center.lon, camera.center.lat, camera.zoom);
- }
-
- /// A long press on the map: a pin there, and the lookup that names it.
- pub(crate) fn drop_pin(&mut self, cx: &mut Cx, at: LonLat) {
- let (id, url) = self.model.drop_pin(at);
- self.cancel_superseded(cx);
- self.send(cx, id, url);
- self.show_place(cx);
- self.render(cx);
- }
-
- /// A pin of one of the map's own layers was tapped.
- fn open_pin(&mut self, cx: &mut Cx, lon: f64, lat: f64, info: &[(String, String)]) {
- self.model
- .select_place(places::from_pin_info(lon, lat, info));
- self.cancel_superseded(cx);
- self.show_place(cx);
- self.render(cx);
- }
-
- /// The sheet's texts, from the place it is about.
- fn fill_sheet(&mut self, cx: &mut Cx, place: &Place) {
- self.view
- .label(cx, ids!(place_pill_text))
- .set_text(cx, &place.name);
- self.view
- .label(cx, ids!(sheet_title))
- .set_text(cx, &place.name);
- let distance = self.distance_to(place.pos, self.model.units());
- let subtitle = [place.category.as_str(), distance.as_str()]
- .into_iter()
- .filter(|part| !part.is_empty())
- .collect::>()
- .join(" · ");
- self.view
- .label(cx, ids!(sheet_subtitle))
- .set_text(cx, &subtitle);
- let address_row = self.view.widget(cx, ids!(address_row));
- address_row.set_visible(cx, !place.address.is_empty());
- address_row
- .label(cx, ids!(value))
- .set_text(cx, &place.address);
- self.view
- .widget(cx, ids!(coordinates_row))
- .label(cx, ids!(value))
- .set_text(cx, &coordinates_text(place.pos));
- let rows = [
- ids!(detail_0),
- ids!(detail_1),
- ids!(detail_2),
- ids!(detail_3),
- ];
- for (index, id) in rows.into_iter().enumerate().take(DETAIL_ROWS) {
- let row = self.view.widget(cx, id);
- let detail = place.details.get(index);
- row.set_visible(cx, detail.is_some());
- if let Some((label, value)) = detail {
- row.label(cx, ids!(label)).set_text(cx, label);
- row.label(cx, ids!(value)).set_text(cx, value);
- }
- }
- }
-
- /// How far a point is from the last fix; nothing before there is a fix
- /// to measure from.
- fn distance_to(&self, pos: LonLat, units: Units) -> String {
- self.model
- .fix()
- .map(|fix| distance_text(haversine_m(fix, pos), units))
- .unwrap_or_default()
- }
-
- // ---- Directions ----
-
- /// The Directions screen came up, or one of its ends changed: a fix is
- /// asked for if an end needs one, the routes are asked for, and the map
- /// shows what there is.
- fn enter_directions(&mut self, cx: &mut Cx) {
- self.sheet.set(Detent::Peek);
- self.sheet_height = self.sheet.height(self.viewport.1);
- self.apply_sheet_height(cx);
- if self.model.needs_fix() && self.location == LocationState::Idle {
- self.ask_location(cx);
- }
- self.pump_routes(cx);
- self.show_route(cx);
- self.render(cx);
- }
-
- /// The place's Directions button.
- pub fn open_directions(&mut self, cx: &mut Cx) {
- self.model.open_directions();
- if self.model.screen() == Screen::Directions {
- self.enter_directions(cx);
- }
- }
-
- /// Ask for the next route the model wants, if it wants one now.
- fn pump_routes(&mut self, cx: &mut Cx) {
- self.cancel_superseded(cx);
- if let Some((id, _mode, url)) = self.model.next_route_request() {
- self.send(cx, id, url);
- }
- }
-
- /// The map as Directions wants it: the ends pinned, the shown tab's
- /// route drawn and fitted between the card and the sheet.
- fn show_route(&mut self, cx: &mut Cx) {
- let map = self.map(cx);
- let skin = Skin::current();
- let ends = [
- (
- ORIGIN_MARKER,
- self.model.end_pos(self.model.origin()),
- skin.accent,
- ),
- (
- DESTINATION_MARKER,
- self.model.end_pos(self.model.destination()),
- skin.alert,
- ),
- ];
- let markers = ends
- .into_iter()
- .filter_map(|(id, pos, color)| Some(MapMarker::new(id, pos?.lon, pos?.lat, color)))
- .collect();
- map.set_markers(cx, markers);
- let Some(directions) = self.model.directions() else {
- map.clear_route(cx);
- self.route_on_map = false;
- return;
- };
- let points = &directions.route.points;
- let line: Vec<(f64, f64)> = points.iter().map(|p| (p.lon, p.lat)).collect();
- map.set_route(cx, &line);
- self.route_on_map = true;
- if let Some(bounds) = Bounds::of(points) {
- let insets = Insets {
- top: DIRECTIONS_CARD_HEIGHT + ROUTE_MARGIN,
- bottom: self.sheet.height(self.viewport.1) + ROUTE_MARGIN,
- left: ROUTE_MARGIN,
- right: ROUTE_MARGIN,
- };
- let camera = fit_camera(&bounds, self.viewport, &insets, ROUTE_MAX_ZOOM);
- // The fit is a flat, north-up one.
- map.set_rotation(cx, 0.0);
- map.set_tilt(cx, 0.0);
- map.fly_to(cx, camera.center.lon, camera.center.lat, camera.zoom);
- }
- }
-
- fn clear_route(&mut self, cx: &mut Cx) {
- self.map(cx).clear_route(cx);
- self.route_on_map = false;
- }
-
- /// A mode's tab was picked.
- pub fn set_mode(&mut self, cx: &mut Cx, mode: Mode) {
- self.model.set_mode(mode);
- self.pump_routes(cx);
- self.show_route(cx);
- self.render(cx);
- }
-
- /// The card's swap button.
- pub(crate) fn swap_ends(&mut self, cx: &mut Cx) {
- self.model.swap_ends();
- self.enter_directions(cx);
- }
-
- /// The card's texts, the tabs' times and the sheet's summary.
- fn fill_directions(&mut self, cx: &mut Cx) {
- let origin = self.model.origin().label().to_string();
- let destination = self.model.destination().label().to_string();
- self.view.label(cx, ids!(origin_text)).set_text(cx, &origin);
- self.view
- .label(cx, ids!(destination_text))
- .set_text(cx, &destination);
- let tabs = [
- (Mode::Car, ids!(mode_car), ids!(mode_car_on)),
- (Mode::Walk, ids!(mode_walk), ids!(mode_walk_on)),
- (Mode::Bike, ids!(mode_bike), ids!(mode_bike_on)),
- ];
- let selected = self.model.mode();
- for (mode, idle, on) in tabs {
- let time = match self.model.route(mode) {
- RouteState::Ready(directions) => duration_text(directions.route.duration_s),
- RouteState::Loading => "…".to_string(),
- RouteState::Failed(_) => "—".to_string(),
- RouteState::Idle => String::new(),
- };
- for (id, shown) in [(idle, mode != selected), (on, mode == selected)] {
- let tab = self.view.button(cx, id);
- tab.set_text(cx, &time);
- tab.set_visible(cx, shown);
- }
- }
- let (title, subtitle, retry) = self.route_summary();
- self.view.label(cx, ids!(route_title)).set_text(cx, &title);
- self.view
- .label(cx, ids!(route_subtitle))
- .set_text(cx, &subtitle);
- self.view
- .widget(cx, ids!(route_retry))
- .set_visible(cx, retry);
- let ready = self.model.directions().is_some();
- self.view.widget(cx, ids!(start)).set_visible(cx, ready);
- self.view.widget(cx, ids!(preview)).set_visible(cx, ready);
- self.view.portal_list(cx, ids!(steps)).redraw(cx);
- }
-
- /// The sheet's two lines for the shown tab, and whether Retry belongs.
- fn route_summary(&self) -> (String, String, bool) {
- let mode = self.model.mode();
- if self.model.needs_fix() {
- // An end is the device's location, and there is no fix.
- return if self.location == LocationState::Waiting {
- (
- "Finding your location…".into(),
- "Or choose a starting point above".into(),
- false,
- )
- } else {
- (
- "Choose a starting point".into(),
- "Your location isn't available".into(),
- false,
- )
- };
- }
- match self.model.route(mode) {
- RouteState::Ready(directions) => (
- duration_text(directions.route.duration_s),
- format!(
- "{} · {}",
- distance_text(directions.route.length_m, self.model.units()),
- mode.label()
- ),
- false,
- ),
- // What failed is the title; why, when the model says, goes under
- // it, where a long reason has the room.
- RouteState::Failed(why) => match why.split_once(": ") {
- Some((what, reason)) => (
- what.to_string(),
- // A platform's sentence ends in a full stop; this line goes on.
- format!(
- "{} · {}",
- sentence_case(reason.trim_end_matches('.')),
- mode.label()
- ),
- true,
- ),
- None => (why.clone(), mode.label().to_string(), true),
- },
- RouteState::Idle | RouteState::Loading => (
- "Finding the best route…".into(),
- mode.label().to_string(),
- false,
- ),
- }
- }
-
- fn draw_steps(&mut self, cx: &mut Cx2d, list: &mut PortalList) {
- let steps = self
- .model
- .directions()
- .map(|d| d.steps.clone())
- .unwrap_or_default();
- let units = self.model.units();
- list.set_item_range(cx, 0, steps.len());
- while let Some(index) = list.next_visible_item(cx) {
- let Some(step) = steps.get(index) else {
- continue;
- };
- let item = list.item(cx, index, live_id!(Step));
- item.label(cx, ids!(text)).set_text(cx, &step.text);
- // The arrival goes nowhere further, and a departure of a few
- // steps rounds to nothing worth printing.
- let distance = if step.distance_m >= MIN_STEP_DISTANCE_M {
- distance_text(step.distance_m, units)
- } else {
- String::new()
- };
- item.label(cx, ids!(distance)).set_text(cx, &distance);
- show_arrow(cx, &item, step.arrow);
- item.draw_all(cx, &mut Scope::empty());
- }
- }
-
- fn handle_directions_actions(&mut self, cx: &mut Cx, actions: &Actions) {
- if self.view.button(cx, ids!(directions_back)).clicked(actions) {
- self.back(cx);
- return;
- }
- if self.view.button(cx, ids!(swap)).clicked(actions) {
- self.swap_ends(cx);
- return;
- }
- let layer = self.view.widget(cx, ids!(directions_layer));
- if tapped(cx, &layer, ids!(origin_row), actions) {
- self.open_search(cx, SearchTarget::Origin);
- return;
- }
- if tapped(cx, &layer, ids!(destination_row), actions) {
- self.open_search(cx, SearchTarget::Destination);
- return;
- }
- let tabs = [
- (Mode::Car, ids!(mode_car), ids!(mode_car_on)),
- (Mode::Walk, ids!(mode_walk), ids!(mode_walk_on)),
- (Mode::Bike, ids!(mode_bike), ids!(mode_bike_on)),
- ];
- for (mode, idle, on) in tabs {
- if self.view.button(cx, idle).clicked(actions)
- || self.view.button(cx, on).clicked(actions)
- {
- self.set_mode(cx, mode);
- }
- }
- if self.view.button(cx, ids!(start)).clicked(actions) {
- self.start_navigation(cx, false);
- return;
- }
- if self.view.button(cx, ids!(preview)).clicked(actions) {
- self.start_navigation(cx, true);
- return;
- }
- if self.view.button(cx, ids!(route_retry)).clicked(actions) {
- self.model.retry_route(self.model.mode());
- self.pump_routes(cx);
- self.render(cx);
- }
- self.handle_sheet_drag(cx, actions);
- }
-
- // ---- Navigation ----
-
- /// Start, over live fixes, or Preview, over the simulated drive.
- pub fn start_navigation(&mut self, cx: &mut Cx, simulate: bool) {
- let Some(nav) = self.model.start_navigation(simulate) else {
- return;
- };
- let map = self.map(cx);
- // The destination stays pinned; the origin is where the puck is.
- if let Some(to) = self.model.end_pos(self.model.destination()) {
- let pin = MapMarker::new(DESTINATION_MARKER, to.lon, to.lat, Skin::current().alert);
- map.set_markers(cx, vec![pin]);
- }
- let start = nav.directions().route.points.first().copied();
- self.nav = Some(nav);
- self.nav_clock = None;
- self.rerouting = false;
- self.chasing = true;
- // Set, not flown to: a flight would read as a finger's pan.
- if let Some(start) = start {
- map.set_center(cx, start.lon, start.lat);
- self.chase_center = Some(start);
- }
- map.set_map_zoom(cx, NAV_ZOOM);
- map.set_tilt(cx, NAV_TILT);
- if simulate {
- self.nav_frame = cx.new_next_frame();
- } else if self.location == LocationState::Idle {
- self.ask_location(cx);
- }
- // The first banner, before the first fix or frame.
- let tick = match (self.nav.as_mut(), simulate, self.model.fix()) {
- (Some(nav), true, _) => Some(nav.tick_sim(0.0)),
- (Some(nav), false, Some(fix)) => Some(nav.feed(fix, None, 0.0)),
- _ => None,
- };
- match tick {
- Some(tick) => self.apply_tick(cx, tick),
- // A live start from a chosen place, before any fix: the banner
- // waits in words of its own, not the last drive's.
- None => self.await_first_fix(cx),
- }
- self.render(cx);
- }
-
- /// The banner and the bar before guidance has a position to go on.
- fn await_first_fix(&mut self, cx: &mut Cx) {
- let (time, rest) = match self.nav.as_ref().map(|nav| &nav.directions().route) {
- Some(route) => (
- duration_text(route.duration_s),
- distance_text(route.length_m, self.model.units()),
- ),
- None => Default::default(),
- };
- let banner = self.view.widget(cx, ids!(banner));
- show_arrow(cx, &banner, Arrow::Depart);
- self.view.label(cx, ids!(banner_distance)).set_text(cx, "");
- self.view
- .label(cx, ids!(banner_text))
- .set_text(cx, "Waiting for your location…");
- self.view.label(cx, ids!(nav_time)).set_text(cx, &time);
- self.view.label(cx, ids!(nav_rest)).set_text(cx, &rest);
- }
-
- /// One frame of the preview.
- fn tick_preview(&mut self, cx: &mut Cx, time: f64) {
- let dt = self.nav_clock.map_or(0.0, |last| time - last);
- self.nav_clock = Some(time);
- let Some(nav) = self.nav.as_mut().filter(|nav| nav.simulate()) else {
- return;
- };
- let tick = nav.tick_sim(dt);
- let arrived = tick.state == NavState::Arrived;
- self.apply_tick(cx, tick);
- if !arrived {
- self.nav_frame = cx.new_next_frame();
- }
- }
-
- /// What one position means on screen: the puck, the chase, the route
- /// behind it dimmed, the banner and the bar; a reroute asked for when
- /// guidance wants one; arrival.
- fn apply_tick(&mut self, cx: &mut Cx, tick: NavTick) {
- let map = self.map(cx);
- let at = tick.position;
- map.set_puck(
- cx,
- Some(MapPuck::new(
- at.lon,
- at.lat,
- tick.heading,
- NAV_PUCK_ACCURACY_M,
- )),
- );
- map.set_route_progress(cx, tick.progress_index);
- if self.chasing {
- let ahead = lead(at, tick.rotation, NAV_LEAD_M);
- map.set_center(cx, ahead.lon, ahead.lat);
- map.set_rotation(cx, tick.rotation);
- self.chase_center = Some(ahead);
- }
- if tick.needs_reroute {
- if let Some((id, url)) = self.model.begin_reroute(at) {
- self.send(cx, id, url);
- self.rerouting = true;
- }
- }
- let units = self.model.units();
- let banner = self.view.widget(cx, ids!(banner));
- if self.rerouting {
- self.view.label(cx, ids!(banner_distance)).set_text(cx, "");
- self.view
- .label(cx, ids!(banner_text))
- .set_text(cx, "Rerouting…");
- } else {
- let distance = if tick.state == NavState::Arrived {
- String::new()
- } else {
- distance_text(tick.banner_distance_m, units)
- };
- self.view
- .label(cx, ids!(banner_distance))
- .set_text(cx, &distance);
- self.view
- .label(cx, ids!(banner_text))
- .set_text(cx, &tick.banner);
- }
- if let Some(arrow) = tick.arrow {
- show_arrow(cx, &banner, arrow);
- }
- let (time, rest) = if tick.state == NavState::Arrived {
- // And stays so, whatever fixes follow while the car is parked.
- ("Arrived".to_string(), String::new())
- } else {
- let mut rest = distance_text(tick.remaining_m, units);
- if let Some(now) = local_minutes_of_day() {
- rest = format!("{rest} · {}", arrival_text(now, tick.remaining_s));
- }
- (duration_text(tick.remaining_s), rest)
- };
- self.view.label(cx, ids!(nav_time)).set_text(cx, &time);
- self.view.label(cx, ids!(nav_rest)).set_text(cx, &rest);
- if tick.state == NavState::Arrived && self.model.screen() == Screen::Navigating {
- self.model.arrive();
- self.render(cx);
- }
- self.view.redraw(cx);
- }
-
- /// A live fix while navigating: guidance takes it.
- fn feed_nav(&mut self, cx: &mut Cx, fix: &LocationUpdateEvent) {
- let Some(nav) = self.nav.as_mut().filter(|nav| !nav.simulate()) else {
- return;
- };
- // The fixes' own clock; a platform that sends none, or the same
- // twice, counts a second.
- let dt = self
- .nav_clock
- .map(|last| fix.time - last)
- .filter(|dt| *dt > 0.0 && *dt < 60.0)
- .unwrap_or(DEFAULT_FIX_INTERVAL_S);
- self.nav_clock = Some(fix.time);
- let tick = nav.feed(LonLat::new(fix.lon, fix.lat), fix.heading_deg, dt);
- self.apply_tick(cx, tick);
- }
-
- /// The reroute's reply: guidance starts over on the new route, or the
- /// old one stands and guidance will ask again if the driver stays off it.
- fn on_reroute(&mut self, cx: &mut Cx) {
- self.rerouting = false;
- match (self.model.take_reroute(), self.nav.as_mut()) {
- (Some(directions), Some(nav)) => {
- let line: Vec<(f64, f64)> = directions
- .route
- .points
- .iter()
- .map(|p| (p.lon, p.lat))
- .collect();
- nav.replace_route(directions);
- self.map(cx).set_route(cx, &line);
- }
- (None, Some(_)) => self.show_status(cx, "Couldn't find a new route"),
- _ => {}
- }
- }
-
- /// The drive is over, by End, by Done or by the back gesture: the
- /// camera lies flat again and a preview's puck goes back to the fix.
- fn end_navigation(&mut self, cx: &mut Cx) {
- let Some(nav) = self.nav.take() else {
- return;
- };
- self.rerouting = false;
- self.chasing = false;
- self.chase_center = None;
- let map = self.map(cx);
- map.set_rotation(cx, 0.0);
- map.set_tilt(cx, 0.0);
- map.set_route_progress(cx, 0);
- if nav.simulate() {
- let puck = self
- .model
- .fix()
- .map(|fix| MapPuck::new(fix.lon, fix.lat, None, NAV_PUCK_ACCURACY_M));
- map.set_puck(cx, puck);
- }
- }
-
- /// The camera is back on the puck.
- fn recenter(&mut self, cx: &mut Cx) {
- self.chasing = true;
- let map = self.map(cx);
- map.set_map_zoom(cx, NAV_ZOOM);
- map.set_tilt(cx, NAV_TILT);
- if let Some(at) = self.chase_center {
- map.set_center(cx, at.lon, at.lat);
- }
- self.render(cx);
- }
-
- /// While the camera chases, a finger that takes hold of the bare map and
- /// moves it pauses the chase. Watched on the raw pointer events, without
- /// hit-testing: the map keeps its own capture. (The map reports a
- /// viewport change for guidance's own moves too, a pass late, so its
- /// reports cannot tell the two apart.)
- fn watch_pointer(&mut self, cx: &mut Cx, event: &Event) {
- if self.nav.is_none() || !self.chasing {
- self.map_press = None;
- return;
- }
- match event {
- Event::MouseDown(down) => self.pointer_down(cx, down.abs),
- Event::MouseMove(moved) => self.pointer_moved(cx, moved.abs),
- Event::MouseUp(_) => self.map_press = None,
- Event::TouchUpdate(update) => {
- for touch in &update.touches {
- match touch.state {
- TouchState::Start => self.pointer_down(cx, touch.abs),
- TouchState::Move => self.pointer_moved(cx, touch.abs),
- TouchState::Stop => self.map_press = None,
- TouchState::Stable => {}
- }
- }
- }
- _ => {}
- }
- }
-
- fn pointer_down(&mut self, cx: &mut Cx, abs: Vec2d) {
- let inside = |id: &[LiveId]| {
- let area = self.view.widget(cx, id).area();
- area.is_valid(cx) && area.rect(cx).contains(abs)
- };
- // On the map, and not on what floats over it.
- if inside(ids!(map)) && !inside(ids!(banner)) && !inside(ids!(nav_bar)) {
- self.map_press = Some(abs);
- }
- }
-
- fn pointer_moved(&mut self, cx: &mut Cx, abs: Vec2d) {
- let Some(start) = self.map_press else {
- return;
- };
- if (abs - start).length() > PAN_PIXELS {
- self.map_press = None;
- self.chasing = false;
- self.render(cx);
- }
- }
-
- fn handle_nav_actions(&mut self, cx: &mut Cx, actions: &Actions) {
- if self.view.button(cx, ids!(end)).clicked(actions)
- || self.view.button(cx, ids!(done)).clicked(actions)
- {
- self.back(cx);
- return;
- }
- if self.view.button(cx, ids!(recenter)).clicked(actions) {
- self.recenter(cx);
- }
- }
-
- // ---- The sheet ----
-
- /// The sheet on screen: the place's, or on Directions the route's.
- fn sheet_widget(&self, cx: &Cx) -> WidgetRef {
- if self.model.screen() == Screen::Directions {
- self.view.widget(cx, ids!(route_sheet))
- } else {
- self.view.widget(cx, ids!(sheet))
- }
- }
-
- /// Its height as a property, without asking for a draw.
- fn set_sheet_height(&mut self, cx: &mut Cx) {
- let height = self.sheet_height;
- let mut sheet = self.sheet_widget(cx);
- script_apply_eval!(cx, sheet, { height: #(height) });
- self.place_attribution(cx);
- }
-
- /// The licence's line sits just above what is at the bottom of the
- /// screen, and is not drawn where there is no map to credit.
- fn place_attribution(&mut self, cx: &mut Cx) {
- let screen = self.model.screen();
- let bottom = attribution_bottom(screen, self.sheet_height);
- let mut gap = self.view.widget(cx, ids!(attribution_gap));
- script_apply_eval!(cx, gap, { height: #(bottom) });
- self.view
- .widget(cx, ids!(attribution_slot))
- .set_visible(cx, !matches!(screen, Screen::Search { .. }));
- }
-
- fn apply_sheet_height(&mut self, cx: &mut Cx) {
- self.set_sheet_height(cx);
- self.view.redraw(cx);
- }
-
- /// A finger on the sheet's head: it follows, and snaps on release.
- fn handle_sheet_drag(&mut self, cx: &mut Cx, actions: &Actions) {
- let head = if self.model.screen() == Screen::Directions {
- self.view.view(cx, ids!(route_head))
- } else {
- self.view.view(cx, ids!(sheet_head))
- };
- let viewport_h = self.viewport.1;
- if head.finger_down(actions).is_some() {
- self.sheet.drag_start(viewport_h);
- self.sheet_last_move = None;
- self.sheet_velocity = 0.0;
- }
- if let Some(moved) = head.finger_move(actions) {
- self.sheet
- .drag_by(moved.abs.y - moved.abs_start.y, viewport_h);
- if let Some((y, time)) = self.sheet_last_move {
- let dt = moved.time - time;
- if dt > 0.0 {
- self.sheet_velocity = (moved.abs.y - y) / dt;
- }
- }
- self.sheet_last_move = Some((moved.abs.y, moved.time));
- self.sheet_height = self.sheet.height(viewport_h);
- self.apply_sheet_height(cx);
- }
- if let Some(up) = head.finger_up(actions) {
- if up.was_tap() {
- // A tap on the head steps the sheet up, and from full down.
- let next = match self.sheet.detent() {
- Detent::Peek => Detent::Half,
- Detent::Half => Detent::Full,
- Detent::Full => Detent::Peek,
- };
- self.sheet.set(next);
- } else {
- self.sheet.release(self.sheet_velocity, viewport_h);
- }
- self.sheet_frame = cx.new_next_frame();
- }
- }
-
- /// One frame of the sheet's way to its detent.
- fn ease_sheet(&mut self, cx: &mut Cx) {
- let target = self.sheet.height(self.viewport.1);
- let rest = target - self.sheet_height;
- if rest.abs() < 0.5 {
- self.sheet_height = target;
- } else {
- self.sheet_height += rest * SHEET_EASE;
- self.sheet_frame = cx.new_next_frame();
- }
- self.apply_sheet_height(cx);
- }
-
- // ---- Location ----
-
- /// The locate button, or a screen that wants a fix.
- pub fn ask_location(&mut self, cx: &mut Cx) {
- match self.location.asked() {
- LocationAsk::Start => {
- self.show_status(cx, "Locating…");
- cx.start_location_updates();
- self.location_timeout = Some(cx.start_timeout(LOCATION_FIX_TIMEOUT_SECONDS));
- }
- LocationAsk::Recenter => {
- if let Some(fix) = self.model.fix() {
- self.map(cx).fly_to(cx, fix.lon, fix.lat, LOCATE_ZOOM);
- }
- }
- LocationAsk::Ignore => {}
- }
- self.render(cx);
- }
-
- fn on_location_update(&mut self, cx: &mut Cx, fix: &LocationUpdateEvent) {
- let kind = self.location.received_fix();
- if kind == LocationFix::Ignore {
- return;
- }
- self.model.set_fix(LonLat::new(fix.lon, fix.lat));
- if self.nav.as_ref().is_some_and(|nav| !nav.simulate()) {
- // Guidance draws the puck, on the line.
- if kind == LocationFix::First {
- self.stop_location_timeout(cx);
- self.hide_status(cx);
- }
- self.feed_nav(cx, fix);
- return;
- }
- let map = self.map(cx);
- // A preview's puck is the preview's.
- if self.nav.is_none() {
- map.set_puck(
- cx,
- Some(MapPuck::new(
- fix.lon,
- fix.lat,
- fix.heading_deg,
- fix.accuracy_m,
- )),
- );
- }
- let routing = self.model.screen() == Screen::Directions;
- if kind == LocationFix::First {
- self.stop_location_timeout(cx);
- self.hide_status(cx);
- // A cold start takes seconds: by now the person may be looking
- // at a place, a route or a preview, and the camera is theirs.
- if self.model.screen() == Screen::Explore && self.nav.is_none() {
- map.fly_to(cx, fix.lon, fix.lat, LOCATE_ZOOM);
- }
- self.render(cx);
- }
- if routing {
- // The fix an end was waiting for; nothing to ask otherwise.
- self.pump_routes(cx);
- self.render(cx);
- }
- }
-
- /// An error, or the timeout: say why, once, and let the next tap retry.
- fn fail_location(&mut self, cx: &mut Cx, why: &str) {
- if !self.location.failed() {
- return;
- }
- self.stop_location_timeout(cx);
- cx.stop_location_updates();
- self.show_status(cx, why);
- self.render(cx);
- }
-
- fn stop_location_timeout(&mut self, cx: &mut Cx) {
- if let Some(timer) = self.location_timeout.take() {
- cx.stop_timer(timer);
- }
- }
-
- // ---- The status line ----
-
- fn show_status(&mut self, cx: &mut Cx, text: &str) {
- // Under the pill, the directions card or the banner: never on it.
- let top = status_top(self.model.screen());
- let mut gap = self.view.widget(cx, ids!(status_gap));
- script_apply_eval!(cx, gap, { height: #(top) });
- self.view.label(cx, ids!(status_text)).set_text(cx, text);
- self.view.widget(cx, ids!(status)).set_visible(cx, true);
- if let Some(timer) = self.status_timer.take() {
- cx.stop_timer(timer);
- }
- self.status_timer = Some(cx.start_timeout(STATUS_SECONDS));
- self.view.redraw(cx);
- }
-
- fn hide_status(&mut self, cx: &mut Cx) {
- if let Some(timer) = self.status_timer.take() {
- cx.stop_timer(timer);
- }
- self.view.widget(cx, ids!(status)).set_visible(cx, false);
- self.view.redraw(cx);
- }
-
- // ---- The map's camera ----
-
- /// Whether the map is north-up and flat, give or take the snap.
- fn is_level(&self, cx: &Cx) -> bool {
- let map = self.map(cx);
- let rotation = map.rotation().rem_euclid(360.0);
- rotation.min(360.0 - rotation) < LEVEL_DEGREES && map.tilt().abs() < LEVEL_DEGREES
- }
-
- /// The compass: north-up and flat again.
- fn level_map(&mut self, cx: &mut Cx) {
- let map = self.map(cx);
- map.set_rotation(cx, 0.0);
- map.set_tilt(cx, 0.0);
- self.render(cx);
- }
-
- // ---- Back ----
-
- /// One step back: the layers sheet, then the model's screens. `false`
- /// when nothing was left, so the host handles it.
- pub fn back(&mut self, cx: &mut Cx) -> bool {
- if self.layers_open {
- self.layers_open = false;
- self.render(cx);
- return true;
- }
- let from = self.model.screen();
- if !self.model.back() {
- return false;
- }
- self.cancel_superseded(cx);
- if matches!(from, Screen::Search { .. }) {
- self.leave_search(cx);
- }
- if matches!(from, Screen::Navigating | Screen::Arrived) {
- self.end_navigation(cx);
- }
- match (from, self.model.screen()) {
- // End: the routes again, fitted; asked for again if the drive
- // was rerouted away from them.
- (Screen::Navigating, Screen::Directions) => self.enter_directions(cx),
- // Done: the place the drive was to.
- (Screen::Arrived, Screen::Place) => {
- self.clear_route(cx);
- self.show_place(cx);
- }
- (_, Screen::Explore) => {
- self.map(cx).set_markers(cx, Vec::new());
- self.clear_route(cx);
- }
- // The route goes, the place's pin and sheet come back.
- (Screen::Directions, Screen::Place) => {
- self.clear_route(cx);
- self.show_place(cx);
- }
- // Search left without a pick: the trip is as it was.
- (Screen::Search { .. }, Screen::Directions) => self.enter_directions(cx),
- _ => {}
- }
- self.render(cx);
- true
- }
-
- // ---- Showing the state ----
-
- /// Show the state: which layers are up, the locate button's face, the
- /// compass, the results, the sheet's texts.
- fn render(&mut self, cx: &mut Cx) {
- let screen = self.model.screen();
- let searching = matches!(screen, Screen::Search { .. });
- self.view
- .widget(cx, ids!(explore))
- .set_visible(cx, screen == Screen::Explore);
- self.view
- .widget(cx, ids!(search_layer))
- .set_visible(cx, searching);
- self.view
- .widget(cx, ids!(place_layer))
- .set_visible(cx, screen == Screen::Place);
- self.view
- .widget(cx, ids!(directions_layer))
- .set_visible(cx, screen == Screen::Directions);
- let driving = matches!(screen, Screen::Navigating | Screen::Arrived);
- self.view
- .widget(cx, ids!(nav_layer))
- .set_visible(cx, driving);
- if driving {
- let arrived = screen == Screen::Arrived;
- self.view.widget(cx, ids!(end)).set_visible(cx, !arrived);
- self.view.widget(cx, ids!(done)).set_visible(cx, arrived);
- self.view
- .widget(cx, ids!(recenter))
- .set_visible(cx, !self.chasing && !arrived);
- }
- self.view
- .widget(cx, ids!(layers_layer))
- .set_visible(cx, self.layers_open);
- let following = self.location == LocationState::Active;
- self.view
- .widget(cx, ids!(locate))
- .set_visible(cx, !following);
- self.view
- .widget(cx, ids!(locate_active))
- .set_visible(cx, following);
- let level = self.is_level(cx);
- self.view.widget(cx, ids!(compass)).set_visible(cx, !level);
- if searching {
- self.items = result_items(
- &self.model.search_state,
- self.model.results.len(),
- &self.model.query,
- );
- self.view
- .widget(cx, ids!(search_clear))
- .set_visible(cx, !self.model.query.is_empty());
- let hint = match self.model.search_target() {
- Some(SearchTarget::Origin) => "Choose a starting point",
- _ => "Search here",
- };
- let mut field = self.view.widget(cx, ids!(search));
- script_apply_eval!(cx, field, { empty_text: #(hint) });
- }
- if screen == Screen::Place {
- if let Some(place) = self.model.place().cloned() {
- self.fill_sheet(cx, &place);
- }
- }
- if screen == Screen::Directions {
- self.fill_directions(cx);
- }
- self.place_attribution(cx);
- self.view.redraw(cx);
- }
-
- fn draw_results(&mut self, cx: &mut Cx2d, list: &mut PortalList) {
- list.set_item_range(cx, 0, self.items.len());
- while let Some(index) = list.next_visible_item(cx) {
- let Some(entry) = self.items.get(index).cloned() else {
- continue;
- };
- let item = list.item(cx, index, entry.template());
- match &entry {
- ResultItem::Place(result) => {
- let Some(place) = self.model.results.get(*result) else {
- continue;
- };
- item.label(cx, ids!(name)).set_text(cx, &place.name);
- // A place with no address still says what it is.
- let line = if place.address.is_empty() {
- &place.category
- } else {
- &place.address
- };
- item.label(cx, ids!(address)).set_text(cx, line);
- let units = self.model.units_for(place);
- item.label(cx, ids!(distance))
- .set_text(cx, &self.distance_to(place.pos, units));
- show_kind(cx, &item, place.kind);
- }
- ResultItem::Status { text, retry } => {
- item.label(cx, ids!(text)).set_text(cx, text);
- item.widget(cx, ids!(retry)).set_visible(cx, *retry);
- }
- }
- item.draw_all(cx, &mut Scope::empty());
- }
- }
-
- fn handle_result_actions(&mut self, cx: &mut Cx, actions: &Actions) {
- let list = self.view.portal_list(cx, ids!(results));
- let mut pick = None;
- let mut retry = false;
- for (index, item) in list.items_with_actions(actions) {
- match self.items.get(index) {
- Some(ResultItem::Place(result)) if tapped(cx, &item, ids!(row), actions) => {
- pick = Some(*result);
- }
- Some(ResultItem::Status { retry: true, .. }) => {
- retry |= item.button(cx, ids!(retry)).clicked(actions);
- }
- _ => {}
- }
- }
- if let Some(index) = pick {
- self.pick_result(cx, index);
- } else if retry {
- self.search_now(cx);
- }
- }
-
- /// The layers sheet's switches, as the settings stand.
- fn sync_switches(&mut self, cx: &mut Cx) {
- let settings = &self.model.settings;
- let dark = settings.dark_map.unwrap_or(!Skin::current().light);
- let miles = self.model.units() == Units::Imperial;
- let states = [
- (ids!(dark_map), dark),
- (ids!(buildings), settings.buildings_3d),
- (ids!(labels), settings.labels),
- (ids!(miles), miles),
- ];
- for (id, on) in states {
- let switch = self.view.check_box(cx, id);
- if switch.active(cx) != on {
- // A cut, not a play: the sheet opens showing what is.
- switch.set_active(cx, on, Animate::No);
- self.view.redraw(cx);
- }
- }
- }
-
- fn handle_layers_actions(&mut self, cx: &mut Cx, actions: &Actions) {
- let layer = self.view.widget(cx, ids!(layers_layer));
- if self.view.button(cx, ids!(layers_close)).clicked(actions)
- || tapped(cx, &layer, ids!(scrim), actions)
- {
- self.layers_open = false;
- self.render(cx);
- return;
- }
- let mut changed = false;
- if let Some(on) = self.view.check_box(cx, ids!(dark_map)).changed(actions) {
- self.model.settings.dark_map = Some(on);
- changed = true;
- }
- if let Some(on) = self.view.check_box(cx, ids!(buildings)).changed(actions) {
- self.model.settings.buildings_3d = on;
- changed = true;
- }
- if let Some(on) = self.view.check_box(cx, ids!(labels)).changed(actions) {
- self.model.settings.labels = on;
- changed = true;
- }
- if let Some(on) = self.view.check_box(cx, ids!(miles)).changed(actions) {
- self.model.settings.units = Some(if on { Units::Imperial } else { Units::Metric });
- changed = true;
- }
- if changed {
- self.apply_settings(cx);
- self.settings_changed(cx);
- }
- }
-
- fn handle_explore_actions(&mut self, cx: &mut Cx, actions: &Actions) {
- if self.view.button(cx, ids!(layers)).clicked(actions) {
- self.open_layers(cx);
- }
- if self.view.button(cx, ids!(locate)).clicked(actions)
- || self.view.button(cx, ids!(locate_active)).clicked(actions)
- {
- self.ask_location(cx);
- }
- if self.view.button(cx, ids!(compass)).clicked(actions) {
- self.level_map(cx);
- }
- if tapped(
- cx,
- &self.view.widget(cx, ids!(explore)),
- ids!(pill),
- actions,
- ) {
- self.open_search(cx, SearchTarget::Destination);
- }
- }
-
- fn handle_search_actions(&mut self, cx: &mut Cx, actions: &Actions) {
- if self.view.button(cx, ids!(search_back)).clicked(actions) {
- self.back(cx);
- return;
- }
- let field = self.view.text_input(cx, ids!(search));
- if self.view.button(cx, ids!(search_clear)).clicked(actions) {
- field.set_text(cx, "");
- self.set_query(cx, "");
- self.focus_search = true;
- }
- if let Some(query) = field.changed(actions) {
- self.set_query(cx, &query);
- }
- if field.returned(actions).is_some() {
- self.search_now(cx);
- }
- self.handle_result_actions(cx, actions);
- }
-
- fn handle_place_actions(&mut self, cx: &mut Cx, actions: &Actions) {
- if self.view.button(cx, ids!(place_back)).clicked(actions)
- || self.view.button(cx, ids!(place_close)).clicked(actions)
- {
- self.back(cx);
- return;
- }
- let layer = self.view.widget(cx, ids!(place_layer));
- if tapped(cx, &layer, ids!(place_pill), actions) {
- self.open_search(cx, SearchTarget::Destination);
- return;
- }
- if self.view.button(cx, ids!(directions)).clicked(actions) {
- self.open_directions(cx);
- return;
- }
- self.handle_sheet_drag(cx, actions);
- }
-
- /// What the map itself reports: a long press drops a pin, a layer's pin
- /// opens, a tap on the bare map lets go of the place.
- fn handle_map_actions(&mut self, cx: &mut Cx, actions: &Actions) {
- let map = self.map(cx);
- let screen = self.model.screen();
- if matches!(screen, Screen::Explore | Screen::Place) {
- if let Some((lon, lat)) = map.long_pressed(actions) {
- self.drop_pin(cx, LonLat::new(lon, lat));
- } else if let Some((lon, lat, info)) = map.pin_tapped(actions) {
- self.open_pin(cx, lon, lat, &info);
- } else if screen == Screen::Place && map.tapped(actions).is_some() {
- self.back(cx);
- }
- }
- // The camera moved, by a finger or by the app: the settings keep
- // where it is, and the compass shows when it is not level.
- if let Some((lon, lat, zoom)) = map.viewport_changed(actions) {
- // Where the app reopens: not halfway down a drive.
- if self.nav.is_none() {
- self.model.settings.center = LonLat::new(lon, lat);
- self.model.settings.zoom = zoom;
- self.settings_changed(cx);
- self.render(cx);
- }
- }
- if map.tilt_changed(actions).is_some() {
- self.render(cx);
- }
- }
-}
-
-/// A tap (not the end of a drag) on the child `id` of `parent`.
-fn tapped(cx: &Cx, parent: &WidgetRef, id: &[LiveId], actions: &Actions) -> bool {
- parent
- .widget(cx, id)
- .as_view()
- .finger_up(actions)
- .is_some_and(|up| up.was_tap())
-}
-
-/// Shows the mark of `kind` among a result row's marks and hides the rest.
-fn show_kind(cx: &mut Cx, item: &WidgetRef, kind: PlaceKind) {
- let marks = [
- (ids!(place), PlaceKind::Place),
- (ids!(food), PlaceKind::Food),
- (ids!(shop), PlaceKind::Shop),
- (ids!(transit), PlaceKind::Transit),
- (ids!(lodging), PlaceKind::Lodging),
- (ids!(street), PlaceKind::Street),
- ];
- for (id, mark) in marks {
- item.widget(cx, id).set_visible(cx, mark == kind);
- }
-}
-
-/// `timed out` as `Timed out`.
-fn sentence_case(text: &str) -> String {
- let mut chars = text.chars();
- match chars.next() {
- Some(first) => first.to_uppercase().chain(chars).collect(),
- None => String::new(),
- }
-}
-
-/// How far up from the bottom the licence's line sits: clear of the sheet
-/// or the navigation bar, whichever the screen has.
-fn attribution_bottom(screen: Screen, sheet_height: f64) -> f64 {
- const GAP: f64 = 8.0;
- match screen {
- Screen::Place | Screen::Directions => sheet_height + GAP,
- Screen::Navigating | Screen::Arrived => NAV_BAR_HEIGHT + GAP,
- Screen::Explore | Screen::Search { .. } => 14.0,
- }
-}
-
-/// How far down the status line sits: under what the screen has at its top.
-fn status_top(screen: Screen) -> f64 {
- match screen {
- Screen::Directions => DIRECTIONS_CARD_HEIGHT + 12.0,
- Screen::Navigating | Screen::Arrived => NAV_BANNER_HEIGHT + 12.0,
- Screen::Explore | Screen::Search { .. } | Screen::Place => TOP_BAR_HEIGHT - 2.0,
- }
-}
-
-/// The point `meters` from `from` along `bearing_deg`: near enough on a
-/// plane, at the distances a camera leads by.
-fn lead(from: LonLat, bearing_deg: f64, meters: f64) -> LonLat {
- const M_PER_DEGREE: f64 = 111_320.0;
- let bearing = bearing_deg.to_radians();
- let north = meters * bearing.cos() / M_PER_DEGREE;
- let east = meters * bearing.sin() / (M_PER_DEGREE * from.lat.to_radians().cos().max(0.01));
- LonLat::new(from.lon + east, from.lat + north)
-}
-
-/// The local time of day in minutes, from the C library's local time
-/// (`localtime_r`, the tz data the shell's clock reads); `None` where there
-/// is none, and the bar then shows no arrival time.
-fn local_minutes_of_day() -> Option {
- #[cfg(unix)]
- {
- // SAFETY: `time` accepts a null out-pointer, and `localtime_r`
- // writes only into the zeroed `tm` it is handed.
- let tm = unsafe {
- let now = libc::time(std::ptr::null_mut());
- let mut tm: libc::tm = std::mem::zeroed();
- if libc::localtime_r(&now, &mut tm).is_null() {
- return None;
- }
- tm
- };
- Some((tm.tm_hour * 60 + tm.tm_min).clamp(0, 24 * 60 - 1) as u32)
- }
- #[cfg(not(unix))]
- {
- None
- }
-}
-
-/// Shows the mark of `arrow` among a step row's marks and hides the rest.
-fn show_arrow(cx: &mut Cx, item: &WidgetRef, arrow: Arrow) {
- let marks = [
- (ids!(depart), Arrow::Depart),
- (ids!(arrive), Arrow::Arrive),
- (ids!(straight), Arrow::Straight),
- (ids!(slight_left), Arrow::SlightLeft),
- (ids!(left), Arrow::Left),
- (ids!(sharp_left), Arrow::SharpLeft),
- (ids!(slight_right), Arrow::SlightRight),
- (ids!(right), Arrow::Right),
- (ids!(sharp_right), Arrow::SharpRight),
- (ids!(uturn), Arrow::UTurn),
- (ids!(roundabout), Arrow::Roundabout),
- ];
- for (id, mark) in marks {
- item.widget(cx, id).set_visible(cx, mark == arrow);
- }
-}
-
-impl Widget for MapsView {
- fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
- self.ensure_started(cx);
- self.watch_pointer(cx, event);
- match event {
- Event::BackPressed { handled } => {
- if !handled.get() && self.back(cx) {
- handled.set(true);
- }
- }
- Event::Storage(responses) => self.on_storage(cx, responses),
- #[cfg(target_os = "android")]
- Event::Signal => {
- for reply in self.http.drain() {
- self.handle_reply(cx, reply.id, reply.body);
- }
- }
- Event::LocationUpdate(fix) => self.on_location_update(cx, fix),
- Event::LocationError(error) => {
- let why = match error {
- LocationErrorEvent::PermissionDenied => "Location is off for OctosMap",
- LocationErrorEvent::Unavailable(_) => "Could not find your location",
- };
- self.fail_location(cx, why);
- }
- Event::NetworkResponses(responses) => {
- for response in responses {
- match response {
- NetworkResponse::HttpResponse {
- request_id,
- response,
- } => {
- let body = body_text(response.status_code, response.body.as_deref())
- .map(str::to_string);
- self.handle_reply(cx, *request_id, body);
- }
- NetworkResponse::HttpError { request_id, error } => {
- self.handle_reply(cx, *request_id, Err(plain_error(&error.message)));
- }
- _ => {}
- }
- }
- }
- _ => {}
- }
- if self
- .location_timeout
- .as_ref()
- .is_some_and(|t| t.is_event(event).is_some())
- {
- self.location_timeout = None;
- self.fail_location(cx, "Could not find your location");
- }
- if self
- .status_timer
- .as_ref()
- .is_some_and(|t| t.is_event(event).is_some())
- {
- self.status_timer = None;
- self.hide_status(cx);
- }
- if self
- .search_timer
- .as_ref()
- .is_some_and(|t| t.is_event(event).is_some())
- {
- self.search_timer = None;
- self.search_now(cx);
- }
- if self
- .save_timer
- .as_ref()
- .is_some_and(|t| t.is_event(event).is_some())
- {
- self.save_now(cx);
- }
- if self.sheet_frame.is_event(event).is_some() {
- self.ease_sheet(cx);
- }
- if let Some(frame) = self.nav_frame.is_event(event) {
- self.tick_preview(cx, frame.time);
- }
- if let Event::Actions(actions) = event {
- if self.layers_open {
- self.handle_layers_actions(cx, actions);
- } else {
- match self.model.screen() {
- Screen::Explore => self.handle_explore_actions(cx, actions),
- Screen::Search { .. } => self.handle_search_actions(cx, actions),
- Screen::Place => self.handle_place_actions(cx, actions),
- Screen::Directions => self.handle_directions_actions(cx, actions),
- Screen::Navigating | Screen::Arrived => self.handle_nav_actions(cx, actions),
- }
- }
- self.handle_map_actions(cx, actions);
- }
- self.view.handle_event(cx, event, scope);
- }
-
- fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
- self.ensure_started(cx);
- // The host re-ran the module for a new skin: the map follows it,
- // unless the person chose.
- self.apply_map_theme(cx);
- let size = cx.turtle().rect().size;
- // A zero-size first frame says nothing about the room.
- if size.x >= 1.0 && size.y >= 1.0 && self.viewport != (size.x, size.y) {
- self.viewport = (size.x, size.y);
- if !self.sheet.is_dragging() {
- self.sheet_height = self.sheet.height(size.y);
- self.set_sheet_height(cx);
- }
- }
- let results = self.view.portal_list(cx, ids!(results)).widget_uid();
- let steps = self.view.portal_list(cx, ids!(steps)).widget_uid();
- while let Some(step) = self.view.draw_walk(cx, scope, walk).step() {
- let uid = step.widget_uid();
- if let Some(mut list) = step.as_portal_list().borrow_mut() {
- if uid == results {
- self.draw_results(cx, &mut list);
- } else if uid == steps {
- self.draw_steps(cx, &mut list);
- }
- }
- }
- if self.layers_open {
- // After the draw: a toggle's animator comes up in its default
- // state on its first draw, which would undo a state set before.
- self.sync_switches(cx);
- }
- if self.focus_search && matches!(self.model.screen(), Screen::Search { .. }) {
- // Drawn now: the field has an area for the focus to land on.
- self.focus_search = false;
- self.view.text_input(cx, ids!(search)).take_key_focus(cx);
- }
- DrawStep::done()
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use crate::test_support::isolate_root;
-
- const SEARCH_REPLY: &str = include_str!("../tests/fixtures/photon-search.json");
- const REVERSE_REPLY: &str = include_str!("../tests/fixtures/photon-reverse.json");
- const CAR: &str = include_str!("../tests/fixtures/osrm-car.json");
- const BIKE: &str = include_str!("../tests/fixtures/osrm-bike.json");
- const FOOT: &str = include_str!("../tests/fixtures/osrm-foot.json");
- const NO_ROUTE: &str = include_str!("../tests/fixtures/osrm-noroute.json");
- const SAN_JOSE: LonLat = LonLat {
- lon: -121.8863,
- lat: 37.3382,
- };
-
- /// A `MapsView` in an isolate of its own, started, handed to `test`
- /// inside the isolate; torn down in the host's order afterwards. No
- /// network: nothing lands but what a test injects.
- fn with_view(test: impl FnOnce(&mut Cx, &WidgetRef)) {
- let (mut iso, root) = isolate_root();
- // A property applied at run time is script too, and a name it
- // cannot resolve only logs, unless a sink is installed before it
- // runs: every test ends with nothing in it.
- iso.with_vm(|vm| vm.bx.captured_errors = Some(Vec::new()));
- iso.entered(|cx| {
- root.handle_event(cx, &Event::Custom(String::new()), &mut Scope::empty());
- test(cx, &root);
- root.borrow_mut::().unwrap().shutdown(cx);
- });
- let errors = iso.with_vm(|vm| vm.take_errors());
- assert!(
- errors.is_empty(),
- "script errors while the view ran: {errors:?}"
- );
- iso.teardown(root);
- }
-
- fn back_pressed(cx: &mut Cx, root: &WidgetRef) -> bool {
- // The answer is the event's own cell: a clone would be a copy.
- let event = Event::BackPressed {
- handled: std::cell::Cell::new(false),
- };
- root.handle_event(cx, &event, &mut Scope::empty());
- matches!(event, Event::BackPressed { handled } if handled.get())
- }
-
- fn fix_at(pos: LonLat) -> Event {
- Event::LocationUpdate(LocationUpdateEvent {
- lon: pos.lon,
- lat: pos.lat,
- accuracy_m: 12.0,
- altitude_m: None,
- speed_mps: None,
- heading_deg: None,
- time: 0.0,
- })
- }
-
- /// The one request of this sort in flight.
- fn in_flight(view: &MapsView, wanted: Request) -> LiveId {
- let ids: Vec = view
- .model()
- .in_flight()
- .into_iter()
- .filter(|(_, request)| *request == wanted)
- .map(|(id, _)| id)
- .collect();
- assert_eq!(ids.len(), 1, "{wanted:?} in flight");
- ids[0]
- }
-
- /// Type a query and land `reply` for it.
- fn search(cx: &mut Cx, view: &mut MapsView, query: &str, reply: Result) {
- view.open_search(cx, SearchTarget::Destination);
- view.set_query(cx, query);
- view.search_now(cx);
- let id = in_flight(view, Request::Search);
- view.handle_reply(cx, id, reply);
- }
-
- #[test]
- fn the_view_starts_on_explore_with_its_map() {
- with_view(|cx, root| {
- let view = root.borrow::().unwrap();
- assert_eq!(view.model().screen(), Screen::Explore);
- assert!(view.view.widget(cx, ids!(explore)).visible());
- for hidden in [
- ids!(layers_layer),
- ids!(search_layer),
- ids!(place_layer),
- ids!(compass),
- ids!(locate_active),
- ] {
- assert!(!view.view.widget(cx, hidden).visible());
- }
- assert!(view.view.widget(cx, ids!(locate)).visible());
- assert_eq!(
- view.view.label(cx, ids!(attribution)).text(),
- "© OpenStreetMap contributors"
- );
- assert_eq!(view.status_text(cx), None);
- });
- }
-
- #[test]
- fn back_at_explore_is_the_hosts_and_closes_the_layers_sheet_first() {
- with_view(|cx, root| {
- assert!(!back_pressed(cx, root), "nothing to go back from");
- root.borrow_mut::().unwrap().open_layers(cx);
- assert!(root
- .borrow::()
- .unwrap()
- .view
- .widget(cx, ids!(layers_layer))
- .visible());
- assert!(back_pressed(cx, root));
- let view = root.borrow::().unwrap();
- assert!(!view.layers_open());
- assert!(!view.view.widget(cx, ids!(layers_layer)).visible());
- });
- }
-
- #[test]
- fn the_locate_button_waits_then_follows_the_fix() {
- with_view(|cx, root| {
- root.borrow_mut::().unwrap().ask_location(cx);
- {
- let view = root.borrow::().unwrap();
- assert_eq!(view.location(), LocationState::Waiting);
- assert_eq!(view.status_text(cx).as_deref(), Some("Locating…"));
- }
- root.handle_event(cx, &fix_at(SAN_JOSE), &mut Scope::empty());
- let view = root.borrow::().unwrap();
- assert_eq!(view.location(), LocationState::Active);
- assert_eq!(view.model().fix(), Some(SAN_JOSE));
- assert_eq!(view.status_text(cx), None, "found: nothing more to say");
- assert!(view.view.widget(cx, ids!(locate_active)).visible());
- assert!(!view.view.widget(cx, ids!(locate)).visible());
- });
- }
-
- #[test]
- fn a_location_error_says_why_once_and_lets_the_next_tap_retry() {
- with_view(|cx, root| {
- // An error nobody asked for says nothing.
- let denied = Event::LocationError(LocationErrorEvent::PermissionDenied);
- root.handle_event(cx, &denied, &mut Scope::empty());
- assert_eq!(root.borrow::().unwrap().status_text(cx), None);
- root.borrow_mut::().unwrap().ask_location(cx);
- root.handle_event(cx, &denied, &mut Scope::empty());
- {
- let view = root.borrow::().unwrap();
- assert_eq!(view.location(), LocationState::Idle);
- assert_eq!(
- view.status_text(cx).as_deref(),
- Some("Location is off for OctosMap")
- );
- }
- root.borrow_mut::().unwrap().ask_location(cx);
- assert_eq!(
- root.borrow::().unwrap().location(),
- LocationState::Waiting
- );
- });
- }
-
- #[test]
- fn a_fix_nobody_asked_for_moves_nothing() {
- with_view(|cx, root| {
- root.handle_event(cx, &fix_at(SAN_JOSE), &mut Scope::empty());
- let view = root.borrow::().unwrap();
- assert_eq!(view.location(), LocationState::Idle);
- assert_eq!(view.model().fix(), None);
- });
- }
-
- #[test]
- fn the_list_says_what_the_search_is_doing() {
- let place = |i| ResultItem::Place(i);
- let status = |text: &str, retry| ResultItem::Status {
- text: text.into(),
- retry,
- };
- assert_eq!(
- result_items(&SearchState::Idle, 0, ""),
- vec![status("Search for a place or an address", false)]
- );
- assert_eq!(
- result_items(&SearchState::Loading, 0, "santa"),
- vec![status("Searching…", false)]
- );
- // The last list stays up while the next one loads.
- assert_eq!(
- result_items(&SearchState::Loading, 2, "santa"),
- vec![place(0), place(1)]
- );
- assert_eq!(
- result_items(&SearchState::Done, 3, "santa"),
- vec![place(0), place(1), place(2)]
- );
- assert_eq!(
- result_items(&SearchState::Done, 0, " zzzz "),
- vec![status("No results for “zzzz”", false)]
- );
- assert_eq!(
- result_items(&SearchState::Failed("timed out".into()), 3, "santa"),
- vec![status("Couldn't search: timed out", true)]
- );
- }
-
- #[test]
- fn a_search_fills_the_list_and_a_pick_opens_the_place_with_its_sheet() {
- with_view(|cx, root| {
- let mut view = root.borrow_mut::().unwrap();
- search(
- cx,
- &mut view,
- "santa clara university",
- Ok(SEARCH_REPLY.into()),
- );
- assert!(view.view.widget(cx, ids!(search_layer)).visible());
- assert!(!view.view.widget(cx, ids!(explore)).visible());
- assert!(view.view.widget(cx, ids!(search_clear)).visible());
- assert_eq!(
- view.items(),
- &[
- ResultItem::Place(0),
- ResultItem::Place(1),
- ResultItem::Place(2)
- ]
- );
- view.pick_result(cx, 0);
- assert_eq!(view.model().screen(), Screen::Place);
- assert!(view.view.widget(cx, ids!(place_layer)).visible());
- assert!(!view.view.widget(cx, ids!(search_layer)).visible());
- assert_eq!(view.sheet_detent(), Detent::Peek);
- assert_eq!(
- view.view.label(cx, ids!(sheet_title)).text(),
- "Santa Clara University"
- );
- assert_eq!(
- view.view.label(cx, ids!(place_pill_text)).text(),
- "Santa Clara University"
- );
- // No fix yet: the category alone, no distance.
- assert_eq!(
- view.view.label(cx, ids!(sheet_subtitle)).text(),
- "University"
- );
- let address = view.view.widget(cx, ids!(address_row));
- assert!(address.visible());
- assert_eq!(
- address.label(cx, ids!(value)).text(),
- "500 El Camino Real, Santa Clara, California"
- );
- assert!(!view.view.widget(cx, ids!(detail_0)).visible());
- });
- }
-
- #[test]
- fn a_fix_puts_the_distance_on_the_sheet_in_the_places_units() {
- with_view(|cx, root| {
- root.borrow_mut::().unwrap().ask_location(cx);
- root.handle_event(cx, &fix_at(SAN_JOSE), &mut Scope::empty());
- let mut view = root.borrow_mut::().unwrap();
- search(
- cx,
- &mut view,
- "santa clara university",
- Ok(SEARCH_REPLY.into()),
- );
- view.pick_result(cx, 0);
- // 4.6 km as the crow flies, in a country that reads miles.
- assert_eq!(
- view.view.label(cx, ids!(sheet_subtitle)).text(),
- "University · 2.9 mi"
- );
- });
- }
-
- #[test]
- fn a_failed_search_offers_a_retry_that_asks_again() {
- with_view(|cx, root| {
- let mut view = root.borrow_mut::().unwrap();
- search(cx, &mut view, "santa clara", Err("timed out".into()));
- assert_eq!(
- view.items(),
- &[ResultItem::Status {
- text: "Couldn't search: timed out".into(),
- retry: true
- }]
- );
- view.search_now(cx);
- let id = in_flight(&view, Request::Search);
- view.handle_reply(cx, id, Ok(SEARCH_REPLY.into()));
- assert_eq!(view.items().len(), 3);
- });
- }
-
- #[test]
- fn a_long_press_drops_a_pin_that_the_lookup_names() {
- with_view(|cx, root| {
- let mut view = root.borrow_mut::().unwrap();
- view.drop_pin(cx, SAN_JOSE);
- assert_eq!(view.model().screen(), Screen::Place);
- assert_eq!(view.view.label(cx, ids!(sheet_title)).text(), "Dropped pin");
- let id = in_flight(&view, Request::Reverse);
- view.handle_reply(cx, id, Ok(REVERSE_REPLY.into()));
- assert_eq!(
- view.view.label(cx, ids!(sheet_title)).text(),
- "East Santa Clara Street"
- );
- assert_eq!(view.view.label(cx, ids!(sheet_subtitle)).text(), "Street");
- });
- }
-
- /// A view on Directions to the university, with a fix or without.
- fn to_directions(cx: &mut Cx, root: &WidgetRef, fix: bool) {
- if fix {
- root.borrow_mut::().unwrap().ask_location(cx);
- root.handle_event(cx, &fix_at(SAN_JOSE), &mut Scope::empty());
- }
- let mut view = root.borrow_mut::().unwrap();
- search(
- cx,
- &mut view,
- "santa clara university",
- Ok(SEARCH_REPLY.into()),
- );
- view.pick_result(cx, 0);
- view.open_directions(cx);
- }
-
- /// The route requests in flight, by mode.
- fn routes_in_flight(view: &MapsView) -> Vec {
- view.model()
- .in_flight()
- .into_iter()
- .filter_map(|(_, request)| match request {
- Request::Route(mode) => Some(mode),
- _ => None,
- })
- .collect()
- }
-
- fn land_route(cx: &mut Cx, view: &mut MapsView, mode: Mode, body: &str) {
- let id = in_flight(view, Request::Route(mode));
- view.handle_reply(cx, id, Ok(body.to_string()));
- }
-
- #[test]
- fn directions_ask_for_one_route_at_a_time_and_show_each_tabs_time() {
- with_view(|cx, root| {
- to_directions(cx, root, true);
- let mut view = root.borrow_mut::().unwrap();
- assert_eq!(view.model().screen(), Screen::Directions);
- assert!(view.view.widget(cx, ids!(directions_layer)).visible());
- assert!(!view.view.widget(cx, ids!(place_layer)).visible());
- assert_eq!(
- view.view.label(cx, ids!(origin_text)).text(),
- "Your location"
- );
- assert_eq!(
- view.view.label(cx, ids!(destination_text)).text(),
- "Santa Clara University"
- );
- // The shown tab's route first, and only it.
- assert_eq!(routes_in_flight(&view), vec![Mode::Car]);
- assert_eq!(
- view.view.label(cx, ids!(route_title)).text(),
- "Finding the best route…"
- );
- assert!(view.view.widget(cx, ids!(mode_car_on)).visible());
- assert!(!view.view.widget(cx, ids!(mode_car)).visible());
- assert!(view.view.widget(cx, ids!(mode_walk)).visible());
- land_route(cx, &mut view, Mode::Car, CAR);
- assert_eq!(view.view.label(cx, ids!(route_title)).text(), "10 min");
- assert_eq!(
- view.view.label(cx, ids!(route_subtitle)).text(),
- "4.8 mi · Drive"
- );
- assert_eq!(view.view.button(cx, ids!(mode_car_on)).text(), "10 min");
- // Its reply sent the next tab's.
- assert_eq!(routes_in_flight(&view), vec![Mode::Walk]);
- assert_eq!(view.view.button(cx, ids!(mode_walk)).text(), "…");
- land_route(cx, &mut view, Mode::Walk, FOOT);
- land_route(cx, &mut view, Mode::Bike, BIKE);
- assert!(routes_in_flight(&view).is_empty());
- assert_eq!(view.view.button(cx, ids!(mode_bike)).text(), "35 min");
- // Another tab shows its own route, without asking again.
- view.set_mode(cx, Mode::Walk);
- assert_eq!(view.view.label(cx, ids!(route_title)).text(), "1 hr 44 min");
- assert!(view.view.widget(cx, ids!(mode_walk_on)).visible());
- assert!(routes_in_flight(&view).is_empty());
- });
- }
-
- #[test]
- fn no_route_marks_its_tab_only_and_offers_a_retry() {
- with_view(|cx, root| {
- to_directions(cx, root, true);
- let mut view = root.borrow_mut::().unwrap();
- land_route(cx, &mut view, Mode::Car, NO_ROUTE);
- assert_eq!(
- view.view.label(cx, ids!(route_title)).text(),
- "No route found"
- );
- assert!(view.view.widget(cx, ids!(route_retry)).visible());
- assert_eq!(view.view.button(cx, ids!(mode_car_on)).text(), "—");
- land_route(cx, &mut view, Mode::Walk, FOOT);
- assert_eq!(view.view.button(cx, ids!(mode_walk)).text(), "1 hr 44 min");
- view.set_mode(cx, Mode::Walk);
- assert!(!view.view.widget(cx, ids!(route_retry)).visible());
- });
- }
-
- #[test]
- fn a_route_that_could_not_be_fetched_says_what_failed_and_under_it_why() {
- with_view(|cx, root| {
- to_directions(cx, root, true);
- let mut view = root.borrow_mut::().unwrap();
- let id = in_flight(&view, Request::Route(Mode::Car));
- view.handle_reply(cx, id, Err("could not connect to the server.".into()));
- assert_eq!(
- view.view.label(cx, ids!(route_title)).text(),
- "Couldn't get directions"
- );
- assert_eq!(
- view.view.label(cx, ids!(route_subtitle)).text(),
- "Could not connect to the server · Drive"
- );
- assert!(view.view.widget(cx, ids!(route_retry)).visible());
- assert!(!view.view.widget(cx, ids!(start)).visible());
- });
- }
-
- #[test]
- fn without_a_fix_directions_wait_for_one_or_for_a_chosen_start() {
- with_view(|cx, root| {
- to_directions(cx, root, false);
- {
- let view = root.borrow::().unwrap();
- // Directions asked for the location itself.
- assert_eq!(view.location(), LocationState::Waiting);
- assert!(routes_in_flight(&view).is_empty());
- assert_eq!(
- view.view.label(cx, ids!(route_title)).text(),
- "Finding your location…"
- );
- }
- root.handle_event(cx, &fix_at(SAN_JOSE), &mut Scope::empty());
- assert_eq!(
- routes_in_flight(&root.borrow::().unwrap()),
- vec![Mode::Car]
- );
- });
- with_view(|cx, root| {
- to_directions(cx, root, false);
- let denied = Event::LocationError(LocationErrorEvent::PermissionDenied);
- root.handle_event(cx, &denied, &mut Scope::empty());
- let mut view = root.borrow_mut::().unwrap();
- assert_eq!(
- view.view.label(cx, ids!(route_title)).text(),
- "Choose a starting point"
- );
- // The origin row's search picks one.
- view.open_search(cx, SearchTarget::Origin);
- view.set_query(cx, "library");
- view.search_now(cx);
- let id = in_flight(&view, Request::Search);
- view.handle_reply(cx, id, Ok(SEARCH_REPLY.into()));
- view.pick_result(cx, 1);
- assert_eq!(view.model().screen(), Screen::Directions);
- assert_eq!(
- view.view.label(cx, ids!(origin_text)).text(),
- "Santa Clara University Library"
- );
- assert_eq!(routes_in_flight(&view), vec![Mode::Car]);
- });
- }
-
- #[test]
- fn swapping_the_ends_asks_again_the_other_way() {
- with_view(|cx, root| {
- to_directions(cx, root, true);
- let mut view = root.borrow_mut::().unwrap();
- land_route(cx, &mut view, Mode::Car, CAR);
- view.swap_ends(cx);
- assert_eq!(
- view.view.label(cx, ids!(origin_text)).text(),
- "Santa Clara University"
- );
- assert_eq!(
- view.view.label(cx, ids!(destination_text)).text(),
- "Your location"
- );
- // The walk that was in flight was for the old ends.
- assert_eq!(routes_in_flight(&view), vec![Mode::Car]);
- assert_eq!(
- view.view.label(cx, ids!(route_title)).text(),
- "Finding the best route…"
- );
- });
- }
-
- #[test]
- fn back_from_directions_is_the_place_again() {
- with_view(|cx, root| {
- to_directions(cx, root, true);
- assert!(back_pressed(cx, root));
- let view = root.borrow::().unwrap();
- assert_eq!(view.model().screen(), Screen::Place);
- assert!(view.view.widget(cx, ids!(place_layer)).visible());
- assert!(!view.view.widget(cx, ids!(directions_layer)).visible());
- });
- }
-
- /// Directions with the car's route landed and nothing else in flight.
- fn with_a_route(cx: &mut Cx, root: &WidgetRef) {
- to_directions(cx, root, true);
- let mut view = root.borrow_mut::().unwrap();
- land_route(cx, &mut view, Mode::Car, CAR);
- land_route(cx, &mut view, Mode::Walk, FOOT);
- land_route(cx, &mut view, Mode::Bike, BIKE);
- }
-
- #[test]
- fn start_and_preview_need_a_route() {
- with_view(|cx, root| {
- to_directions(cx, root, true);
- let mut view = root.borrow_mut::().unwrap();
- assert!(!view.view.widget(cx, ids!(start)).visible());
- view.start_navigation(cx, true);
- assert_eq!(
- view.model().screen(),
- Screen::Directions,
- "nothing to drive yet"
- );
- land_route(cx, &mut view, Mode::Car, CAR);
- assert!(view.view.widget(cx, ids!(start)).visible());
- assert!(view.view.widget(cx, ids!(preview)).visible());
- });
- }
-
- #[test]
- fn a_preview_drives_the_banner_to_the_arrival_and_done_is_the_place() {
- with_view(|cx, root| {
- with_a_route(cx, root);
- {
- let mut view = root.borrow_mut::().unwrap();
- view.start_navigation(cx, true);
- assert_eq!(view.model().screen(), Screen::Navigating);
- assert!(view.navigating());
- assert!(view.view.widget(cx, ids!(nav_layer)).visible());
- assert!(!view.view.widget(cx, ids!(directions_layer)).visible());
- assert!(view.view.widget(cx, ids!(end)).visible());
- assert!(!view.view.widget(cx, ids!(done)).visible());
- assert!(!view.view.widget(cx, ids!(recenter)).visible(), "chasing");
- // The first turn, and how far it is.
- assert_eq!(
- view.view.label(cx, ids!(banner_text)).text(),
- "Turn right onto North 5th Street"
- );
- assert_eq!(view.view.label(cx, ids!(nav_time)).text(), "10 min");
- let banner = view.view.widget(cx, ids!(banner));
- assert!(banner.widget(cx, ids!(right)).visible());
- assert!(!banner.widget(cx, ids!(left)).visible());
- // Half-second frames to the end: ninety seconds at the most.
- let mut time = 0.0;
- for _ in 0..400 {
- time += 0.5;
- view.tick_preview(cx, time);
- if view.model().screen() == Screen::Arrived {
- break;
- }
- }
- assert_eq!(view.model().screen(), Screen::Arrived);
- assert_eq!(
- view.view.label(cx, ids!(banner_text)).text(),
- "You have arrived"
- );
- assert_eq!(view.view.label(cx, ids!(nav_time)).text(), "Arrived");
- assert!(view.view.widget(cx, ids!(done)).visible());
- assert!(!view.view.widget(cx, ids!(end)).visible());
- }
- assert!(back_pressed(cx, root), "Done, or the back gesture");
- let view = root.borrow::().unwrap();
- assert_eq!(view.model().screen(), Screen::Place);
- assert!(!view.navigating());
- assert!(view.view.widget(cx, ids!(place_layer)).visible());
- assert!(!view.view.widget(cx, ids!(nav_layer)).visible());
- });
- }
-
- #[test]
- fn a_route_that_lands_after_the_person_left_directions_waits_for_the_next_visit() {
- with_view(|cx, root| {
- to_directions(cx, root, true);
- assert!(back_pressed(cx, root));
- let mut view = root.borrow_mut::().unwrap();
- assert_eq!(view.model().screen(), Screen::Place);
- // The car's route was in flight when the person went back.
- land_route(cx, &mut view, Mode::Car, CAR);
- assert!(!view.route_on_map, "the map is still the place's");
- assert!(
- routes_in_flight(&view).is_empty(),
- "nothing more asked for off Directions"
- );
- // Directions again: the route is there, no request needed for it.
- view.open_directions(cx);
- assert!(view.route_on_map);
- assert_eq!(view.view.label(cx, ids!(route_title)).text(), "10 min");
- assert_eq!(routes_in_flight(&view), vec![Mode::Walk]);
- });
- }
-
- #[test]
- fn a_live_drive_arrives_and_stays_arrived() {
- with_view(|cx, root| {
- with_a_route(cx, root);
- root.borrow_mut::()
- .unwrap()
- .start_navigation(cx, false);
- let route = root
- .borrow::()
- .unwrap()
- .model()
- .directions()
- .unwrap()
- .route
- .clone();
- let fix_along = |meters: f64, second: f64| {
- let mut event = fix_at(crate::guidance::point_at(&route, meters));
- if let Event::LocationUpdate(fix) = &mut event {
- fix.time = 1_000.0 + second;
- }
- event
- };
- // Down the route in hops the session's matching window follows.
- let mut along = 0.0;
- let mut second = 0.0;
- while along < route.length_m {
- along = (along + 200.0).min(route.length_m);
- second += 10.0;
- root.handle_event(cx, &fix_along(along, second), &mut Scope::empty());
- }
- {
- let view = root.borrow::().unwrap();
- assert_eq!(view.model().screen(), Screen::Arrived);
- assert_eq!(view.view.label(cx, ids!(nav_time)).text(), "Arrived");
- assert!(view.view.widget(cx, ids!(done)).visible());
- }
- // Parked: the fixes keep coming, and nothing changes.
- root.handle_event(
- cx,
- &fix_along(route.length_m, second + 10.0),
- &mut Scope::empty(),
- );
- let view = root.borrow::().unwrap();
- assert_eq!(view.view.label(cx, ids!(nav_time)).text(), "Arrived");
- assert_eq!(view.view.label(cx, ids!(nav_rest)).text(), "");
- assert_eq!(
- view.view.label(cx, ids!(banner_text)).text(),
- "You have arrived"
- );
- });
- }
-
- #[test]
- fn the_licences_line_is_on_every_screen_that_shows_the_map() {
- with_view(|cx, root| {
- let slot =
- |view: &MapsView, cx: &Cx| view.view.widget(cx, ids!(attribution_slot)).visible();
- with_a_route(cx, root);
- let mut view = root.borrow_mut::().unwrap();
- assert!(slot(&view, cx), "Directions");
- view.start_navigation(cx, true);
- assert!(slot(&view, cx), "Navigation");
- view.open_search(cx, SearchTarget::Destination);
- assert!(!slot(&view, cx), "Search has no map to credit");
- });
- // Clear of the sheet wherever it is, and of the bar.
- assert_eq!(attribution_bottom(Screen::Explore, 132.0), 14.0);
- assert_eq!(attribution_bottom(Screen::Place, 132.0), 140.0);
- assert_eq!(attribution_bottom(Screen::Directions, 360.0), 368.0);
- assert!(attribution_bottom(Screen::Navigating, 132.0) > NAV_BAR_HEIGHT);
- }
-
- #[test]
- fn a_live_start_with_no_fix_waits_in_words_of_its_own() {
- with_view(|cx, root| {
- // A drive to its end first, so the banner has old words to keep.
- with_a_route(cx, root);
- {
- let mut view = root.borrow_mut::().unwrap();
- view.start_navigation(cx, true);
- let mut time = 0.0;
- while view.model().screen() != Screen::Arrived {
- time += 0.5;
- view.tick_preview(cx, time);
- }
- }
- assert!(back_pressed(cx, root));
- // The fix is lost, and the next drive starts from a chosen place.
- let mut view = root.borrow_mut::().unwrap();
- view.model.forget_fix();
- view.open_directions(cx);
- view.open_search(cx, SearchTarget::Origin);
- view.set_query(cx, "library");
- view.search_now(cx);
- let id = in_flight(&view, Request::Search);
- view.handle_reply(cx, id, Ok(SEARCH_REPLY.into()));
- view.pick_result(cx, 1);
- land_route(cx, &mut view, Mode::Car, CAR);
- view.start_navigation(cx, false);
- assert_eq!(view.model().screen(), Screen::Navigating);
- assert_eq!(
- view.view.label(cx, ids!(banner_text)).text(),
- "Waiting for your location…"
- );
- assert_eq!(view.view.label(cx, ids!(nav_time)).text(), "10 min");
- });
- }
-
- #[test]
- fn nothing_is_written_while_the_saved_state_is_still_being_read() {
- with_view(|cx, root| {
- let mut view = root.borrow_mut::().unwrap();
- let jail = cx.storage("maps.test");
- view.set_storage(cx, jail);
- assert!(view.has_pending_load());
- view.settings_changed(cx);
- assert!(view.save_timer.is_none(), "the read is still out");
- view.load = None;
- view.settings_changed(cx);
- assert!(view.save_timer.is_some());
- });
- }
-
- #[test]
- fn the_status_line_sits_under_what_the_screen_has_at_its_top() {
- assert!(status_top(Screen::Explore) < status_top(Screen::Navigating));
- assert!(status_top(Screen::Navigating) < status_top(Screen::Directions));
- assert!(status_top(Screen::Directions) > DIRECTIONS_CARD_HEIGHT);
- }
-
- #[test]
- fn end_goes_back_to_the_routes() {
- with_view(|cx, root| {
- with_a_route(cx, root);
- root.borrow_mut::()
- .unwrap()
- .start_navigation(cx, true);
- assert!(back_pressed(cx, root));
- let view = root.borrow::().unwrap();
- assert_eq!(view.model().screen(), Screen::Directions);
- assert!(!view.navigating());
- assert!(view.view.widget(cx, ids!(directions_layer)).visible());
- // Nothing was rerouted: the routes are still there.
- assert_eq!(view.view.label(cx, ids!(route_title)).text(), "10 min");
- assert!(routes_in_flight(&view).is_empty());
- });
- }
-
- #[test]
- fn the_chase_camera_leads_the_puck_the_way_it_is_going() {
- let at = LonLat::new(-121.9, 37.3);
- let north = lead(at, 0.0, 60.0);
- assert!((haversine_m(at, north) - 60.0).abs() < 0.5);
- assert!(north.lat > at.lat && (north.lon - at.lon).abs() < 1e-9);
- let east = lead(at, 90.0, 60.0);
- assert!((haversine_m(at, east) - 60.0).abs() < 0.5);
- assert!(east.lon > at.lon && (east.lat - at.lat).abs() < 1e-9);
- assert_eq!(lead(at, 200.0, 0.0), at);
- }
-
- #[test]
- fn a_pan_pauses_the_chase_and_recenter_resumes_it() {
- with_view(|cx, root| {
- with_a_route(cx, root);
- let mut view = root.borrow_mut::().unwrap();
- view.start_navigation(cx, true);
- assert!(view.chasing);
- // A finger on the bare map that barely moves is a tap.
- view.map_press = Some(dvec2(200.0, 400.0));
- view.pointer_moved(cx, dvec2(203.0, 402.0));
- assert!(view.chasing);
- // One that drags is a pan.
- view.pointer_moved(cx, dvec2(240.0, 400.0));
- assert!(!view.chasing);
- assert!(view.view.widget(cx, ids!(recenter)).visible());
- // No press, no pan: a move over the map is not a drag.
- view.recenter(cx);
- assert!(view.chasing);
- assert!(!view.view.widget(cx, ids!(recenter)).visible());
- view.pointer_moved(cx, dvec2(300.0, 300.0));
- assert!(view.chasing);
- });
- }
-
- #[test]
- fn a_live_drive_off_the_route_asks_for_a_new_one_once_and_takes_it() {
- with_view(|cx, root| {
- with_a_route(cx, root);
- root.borrow_mut::()
- .unwrap()
- .start_navigation(cx, false);
- // A kilometre south of the route, second after second.
- let astray = LonLat::new(SAN_JOSE.lon, SAN_JOSE.lat - 0.01);
- for second in 1..=8 {
- let mut event = fix_at(astray);
- if let Event::LocationUpdate(fix) = &mut event {
- fix.time = 1_000.0 + second as f64;
- }
- root.handle_event(cx, &event, &mut Scope::empty());
- }
- let mut view = root.borrow_mut::().unwrap();
- let id = in_flight(&view, Request::Reroute);
- assert_eq!(view.view.label(cx, ids!(banner_text)).text(), "Rerouting…");
- view.handle_reply(cx, id, Ok(CAR.into()));
- assert!(!view.rerouting);
- assert!(view.model().in_flight().is_empty());
- assert!(view.navigating());
- });
- }
-
- #[test]
- fn a_saved_state_restores_the_switches_and_the_camera() {
- let saved = br#"{"center_lon":4.8952,"center_lat":52.3702,"zoom":13.5,"imperial":true,"dark_map":true,"buildings_3d":false,"labels":true}"#;
- with_view(|cx, root| {
- let mut view = root.borrow_mut::().unwrap();
- view.apply_saved_state(cx, saved);
- let settings = &view.model().settings;
- assert_eq!(settings.center, LonLat::new(4.8952, 52.3702));
- assert_eq!(settings.zoom, 13.5);
- assert_eq!(settings.units, Some(Units::Imperial));
- assert_eq!(settings.dark_map, Some(true));
- assert!(!settings.buildings_3d);
- assert_eq!(view.map_theme, Some(MAP_THEME_NIGHT));
- });
- // Told where to open, the app opens there; the switches still load.
- let (mut iso, root) = isolate_root();
- iso.entered(|cx| {
- let mut view = root.borrow_mut::().unwrap();
- view.set_initial_camera(SAN_JOSE, 15.0);
- view.apply_saved_state(cx, saved);
- assert_eq!(view.model().settings.center, SAN_JOSE);
- assert_eq!(view.model().settings.zoom, 15.0);
- assert_eq!(view.model().settings.dark_map, Some(true));
- view.shutdown(cx);
- });
- iso.teardown(root);
- // Somewhere already: a late load does not pull the map away.
- with_view(|cx, root| {
- let mut view = root.borrow_mut::().unwrap();
- view.drop_pin(cx, SAN_JOSE);
- let before = view.model().settings.center;
- view.apply_saved_state(cx, saved);
- assert_eq!(view.model().settings.center, before);
- });
- }
-
- #[test]
- fn the_jail_is_read_once_and_a_second_handle_is_ignored() {
- with_view(|cx, root| {
- let mut view = root.borrow_mut::().unwrap();
- assert!(!view.has_pending_load(), "no jail, nothing to read");
- let jail = cx.storage("maps.test");
- view.set_storage(cx, jail);
- assert!(view.has_pending_load(), "a late handle still loads");
- let other = cx.storage("maps.other");
- view.set_storage(cx, other);
- assert_eq!(view.storage.as_ref().unwrap().namespace(), "maps.test");
- });
- }
-
- #[test]
- fn back_walks_from_the_place_to_explore_and_cancels_what_it_left() {
- with_view(|cx, root| {
- root.borrow_mut::()
- .unwrap()
- .drop_pin(cx, SAN_JOSE);
- // Into Search from the place's pill, and back out to the place.
- root.borrow_mut::()
- .unwrap()
- .open_search(cx, SearchTarget::Destination);
- assert!(back_pressed(cx, root));
- assert_eq!(
- root.borrow::().unwrap().model().screen(),
- Screen::Place
- );
- assert!(back_pressed(cx, root));
- {
- let view = root.borrow::().unwrap();
- assert_eq!(view.model().screen(), Screen::Explore);
- assert!(view.view.widget(cx, ids!(explore)).visible());
- assert!(!view.view.widget(cx, ids!(place_layer)).visible());
- assert!(
- view.model().in_flight().is_empty(),
- "the lookup went with its pin"
- );
- }
- assert!(!back_pressed(cx, root), "the host's turn");
- });
- }
-}
diff --git a/apps/maps/native/tests/fixtures/osrm-bike.json b/apps/maps/native/tests/fixtures/osrm-bike.json
deleted file mode 100644
index e3829131..00000000
--- a/apps/maps/native/tests/fixtures/osrm-bike.json
+++ /dev/null
@@ -1,253 +0,0 @@
-{
- "code": "Ok",
- "routes": [
- {
- "geometry": "wr{bFj||fV?BUNyCtBWP[RYR_@TKHIH]TeAr@_@Vu@f@MHi@^K?KFCBIDKHSLCLEBQLC@OJQLWPMHa@XC@{@l@i@\\IDJVpAnDHPFR^`Ar@hBFRFPv@vBXt@FLFPn@~ABJ^`ABFBFBF@DDJx@|Bb@jAPd@Rf@QDgBhBONSZORMPc@t@e@z@e@~@yBdEMVQd@M`@I\\G`@Cb@A^@ZB^B^F^Lt@HTJ^|@xCFVPt@Pr@`@hBH\\ZxARnABLBRDVHt@Fp@Dz@Dz@DjA@bBC~@ExAC\\CZIdA[hCGb@Kz@e@vDSt@Md@O`@GNKPU^KNEFGFKL{@`AMNQRKLk@l@IJo@x@g@r@KTuB`CkAnAUVOPMLsCbDoBtBW\\EBEDIHGFUTONYFm@^q@f@wA`BWXgA~AwClEe@n@aBzBc@j@gB|BcAtAsAfBGRGJEJMVGPi@rAq@dBKVGN]x@EH]x@Ob@MZc@hA[v@]bAKXg@xAUh@EHQb@Qb@g@nASf@cAjC]|@Q^eAhCw@nBYp@CHKTkBxEmAvCi@vAcAhCCDGPGNKVIRSh@AB?DBLKXCFGRELCPERCPCNEZATC`@Cd@Az@?Z?B?T?JATAbAAV?\\?BCn@AT?PAJARE^EXCRI`@ELIXUj@GJO\\OBi@nAw@fBYdAUp@q@jBIXE^CXAX?V@P@P@RBNDTFRDNDJFJHNHJJLJJJJNHVN`@Rj@V\\RVRb@`@l@p@f@d@rAtAd@`@Rv@@JBN@L?P?NCPEZCZAVA\\@\\@\\F`@D\\BFBLFZGNENGFC@aAZKBWHABADLf@j@vBl@xBDRFT@BTbAr@pC?@DNFT?@dA~D?@FPDR@@dAbE@@DRDPd@hBZpA?@FVJ`@?@h@xBb@dB`@dBFXLb@Hb@B\\MNSRAlCRFE\\EVCNEDKH}Ap@",
- "distance": 7644,
- "duration": 2080,
- "legs": [
- {
- "steps": [
- {
- "name": "East Santa Clara Street",
- "distance": 1.3,
- "duration": 6.5,
- "maneuver": {
- "type": "depart",
- "location": [
- -121.886303,
- 37.338204
- ],
- "bearing_after": 240
- }
- },
- {
- "name": "North 5th Street",
- "distance": 356.4,
- "duration": 85.5,
- "maneuver": {
- "type": "turn",
- "modifier": "right",
- "location": [
- -121.886316,
- 37.338198
- ],
- "bearing_after": 327
- }
- },
- {
- "name": "North 5th Street",
- "distance": 212.1,
- "duration": 62.3,
- "maneuver": {
- "type": "continue",
- "modifier": "straight",
- "location": [
- -121.888393,
- 37.340947
- ],
- "bearing_after": 333
- }
- },
- {
- "name": "East Julian Street",
- "distance": 431.4,
- "duration": 107,
- "maneuver": {
- "type": "turn",
- "modifier": "left",
- "location": [
- -121.889593,
- 37.342571
- ],
- "bearing_after": 236
- }
- },
- {
- "name": "West Julian Street",
- "distance": 161.7,
- "duration": 49.1,
- "maneuver": {
- "type": "new name",
- "modifier": "straight",
- "location": [
- -121.893771,
- 37.340577
- ],
- "bearing_after": 239
- }
- },
- {
- "name": "Coleman Avenue",
- "distance": 2138.1,
- "duration": 519.9,
- "maneuver": {
- "type": "turn",
- "modifier": "right",
- "location": [
- -121.895342,
- 37.339836
- ],
- "bearing_after": 345
- }
- },
- {
- "name": "Coleman Avenue",
- "distance": 2326.9,
- "duration": 713.2,
- "maneuver": {
- "type": "continue",
- "modifier": "slight right",
- "location": [
- -121.91517,
- 37.346281
- ],
- "bearing_after": 344
- }
- },
- {
- "name": "Coleman Avenue",
- "distance": 107,
- "duration": 25.6,
- "maneuver": {
- "type": "continue",
- "modifier": "straight",
- "location": [
- -121.936691,
- 37.357365
- ],
- "bearing_after": 303
- }
- },
- {
- "name": "",
- "distance": 458,
- "duration": 109.8,
- "maneuver": {
- "type": "turn",
- "modifier": "slight left",
- "location": [
- -121.937632,
- 37.357935
- ],
- "bearing_after": 295
- }
- },
- {
- "name": "De La Cruz Boulevard",
- "distance": 114.2,
- "duration": 27.9,
- "maneuver": {
- "type": "turn",
- "modifier": "straight",
- "location": [
- -121.941507,
- 37.356772
- ],
- "bearing_after": 216
- }
- },
- {
- "name": "",
- "distance": 198.4,
- "duration": 48.9,
- "maneuver": {
- "type": "turn",
- "modifier": "slight right",
- "location": [
- -121.942304,
- 37.355963
- ],
- "bearing_after": 244
- }
- },
- {
- "name": "Alviso Street",
- "distance": 86.3,
- "duration": 29.6,
- "maneuver": {
- "type": "turn",
- "modifier": "right",
- "location": [
- -121.944468,
- 37.35574
- ],
- "bearing_after": 298
- }
- },
- {
- "name": "Civic Center Drive",
- "distance": 852.5,
- "duration": 206.9,
- "maneuver": {
- "type": "new name",
- "modifier": "left",
- "location": [
- -121.944913,
- 37.356393
- ],
- "bearing_after": 250
- }
- },
- {
- "name": "",
- "distance": 99.3,
- "duration": 27.4,
- "maneuver": {
- "type": "turn",
- "modifier": "right",
- "location": [
- -121.953734,
- 37.353336
- ],
- "bearing_after": 320
- }
- },
- {
- "name": "",
- "distance": 36,
- "duration": 21.9,
- "maneuver": {
- "type": "turn",
- "modifier": "right",
- "location": [
- -121.954659,
- 37.353424
- ],
- "bearing_after": 281
- }
- },
- {
- "name": "",
- "distance": 64.4,
- "duration": 38.5,
- "maneuver": {
- "type": "fork",
- "modifier": "slight right",
- "location": [
- -121.955039,
- 37.353528
- ],
- "bearing_after": 326
- }
- },
- {
- "name": "",
- "distance": 0,
- "duration": 0,
- "maneuver": {
- "type": "arrive",
- "modifier": "right",
- "location": [
- -121.955335,
- 37.354056
- ],
- "bearing_after": 0
- }
- }
- ]
- }
- ]
- }
- ]
-}
diff --git a/apps/maps/native/tests/fixtures/osrm-car.json b/apps/maps/native/tests/fixtures/osrm-car.json
deleted file mode 100644
index c48f2f5c..00000000
--- a/apps/maps/native/tests/fixtures/osrm-car.json
+++ /dev/null
@@ -1,155 +0,0 @@
-{
- "code": "Ok",
- "routes": [
- {
- "geometry": "wr{bFj||fV?BUNyCtBWP[RYR_@TKHIH]TeAr@_@Vu@f@MHi@^K?KFCBIDKHSLCLEBQLC@OJQLWPMHa@XC@{@l@i@\\IDJVpAnDHPFR^`Ar@hBFRFPv@vBXt@FLFPn@~ABJ^`ABFBFBF@DDJx@|Bb@jAPd@Rf@QDgBhBONSZORMPc@t@e@z@e@~@yBdEMVQd@M`@I\\G`@Cb@A^@ZB^B^F^Lt@HTJ^|@xCFVPt@Pr@`@hBH\\ZxARnABLBRDVHt@Fp@Dz@Dz@DjA@bBC~@ExAC\\CZIdA[hCGb@Kz@e@vDSt@Md@O`@GNKPU^KNEFGFKL{@`AMNQRKLk@l@IJo@x@g@r@KTuB`CkAnAUVOPMLsCbDoBtBW\\EBEDIHGFUTONYFm@^q@f@wA`BWXgA~AwClEe@n@aBzBc@j@gB|BcAtAsAfBGRGJEJMVGPi@rAq@dBKVGN]x@EH]x@Ob@MZc@hA[v@]bAKXg@xAUh@EHQb@Qb@g@nASf@cAjC]|@Q^eAhCw@nBYp@CHKTkBxEmAvCi@vAcAhCCDGPGNKVIRSh@AB?DBLKXCFGRELCPERCPCNEZATC`@Cd@Az@?Z?B?T?JATAbAAV?\\?BCn@AT?PAJARE^EXCRI`@ELIXUj@GJO\\OBi@nAw@fBYdAUp@q@jBIXE^CXAX?V@P@P@RBNDTFRDNDJFJHNHJJLJJJJNHVN`@Rj@V\\RVRb@`@l@p@f@d@rAtAd@`@Rv@@JBN@L?P?NCPEZCZAVA\\@\\@\\F`@D\\BFBLFZRh@T@Zz@N`@FNFLVp@Pj@L`@FVHVLf@FZHXZpALb@x@~CTjA`@xAt@nCf@pB`@zAHZJ`@Jd@DLn@hCXjABNHh@Hh@BTBZH|@Br@@h@@R@f@?x@Aj@WAwAp@{@\\[`@SHo@Xk@VC?",
- "distance": 7685.3,
- "duration": 629.1,
- "legs": [
- {
- "steps": [
- {
- "name": "East Santa Clara Street",
- "distance": 1.3,
- "duration": 4.1,
- "maneuver": {
- "type": "depart",
- "location": [
- -121.886303,
- 37.338204
- ],
- "bearing_after": 240
- }
- },
- {
- "name": "North 5th Street",
- "distance": 568.5,
- "duration": 58.5,
- "maneuver": {
- "type": "turn",
- "modifier": "right",
- "location": [
- -121.886316,
- 37.338198
- ],
- "bearing_after": 327
- }
- },
- {
- "name": "East Julian Street",
- "distance": 593.2,
- "duration": 51.8,
- "maneuver": {
- "type": "turn",
- "modifier": "left",
- "location": [
- -121.889593,
- 37.342571
- ],
- "bearing_after": 237
- }
- },
- {
- "name": "Coleman Avenue",
- "distance": 4572,
- "duration": 333,
- "maneuver": {
- "type": "turn",
- "modifier": "right",
- "location": [
- -121.895342,
- 37.339836
- ],
- "bearing_after": 316
- }
- },
- {
- "name": "",
- "distance": 458,
- "duration": 55,
- "maneuver": {
- "type": "turn",
- "modifier": "slight left",
- "location": [
- -121.937632,
- 37.357935
- ],
- "bearing_after": 295
- }
- },
- {
- "name": "De La Cruz Boulevard",
- "distance": 114.2,
- "duration": 6.3,
- "maneuver": {
- "type": "turn",
- "modifier": "straight",
- "location": [
- -121.941507,
- 37.356772
- ],
- "bearing_after": 216
- }
- },
- {
- "name": "",
- "distance": 219.9,
- "duration": 27.1,
- "maneuver": {
- "type": "turn",
- "modifier": "slight right",
- "location": [
- -121.942304,
- 37.355963
- ],
- "bearing_after": 244
- }
- },
- {
- "name": "El Camino Real",
- "distance": 964.3,
- "duration": 75.2,
- "maneuver": {
- "type": "turn",
- "modifier": "left",
- "location": [
- -121.944675,
- 37.355638
- ],
- "bearing_after": 185
- }
- },
- {
- "name": "Lincoln Street",
- "distance": 193.9,
- "duration": 18.1,
- "maneuver": {
- "type": "turn",
- "modifier": "right",
- "location": [
- -121.954611,
- 37.352457
- ],
- "bearing_after": 341
- }
- },
- {
- "name": "Lincoln Street",
- "distance": 0,
- "duration": 0,
- "maneuver": {
- "type": "arrive",
- "modifier": "right",
- "location": [
- -121.955474,
- 37.354035
- ],
- "bearing_after": 0
- }
- }
- ]
- }
- ]
- }
- ]
-}
diff --git a/apps/maps/native/tests/fixtures/osrm-foot.json b/apps/maps/native/tests/fixtures/osrm-foot.json
deleted file mode 100644
index 7888f2bc..00000000
--- a/apps/maps/native/tests/fixtures/osrm-foot.json
+++ /dev/null
@@ -1,729 +0,0 @@
-{
- "code": "Ok",
- "routes": [
- {
- "geometry": "wr{bFj||fV?BUNDJBHRb@`AjCBFDJHP@BDLFRTj@DNb@jABD?@FPBHBDpAhD@BBFBHBF@B@@lAdDBD@D??BFDJBH@BBFfAxCj@zA@BHRHT@@HTn@fBBB?@BD@F@B@B@BhAzC@@DJDL@Bn@bBDR@@DLHP@Dz@~B@BFRFN@@P^jA~CnAhDPd@Pd@Vr@Pb@Xv@^`AL\\@FNj@@H@JB`B?\\AHB`@AX@`@FvB@h@?`@BrA@VAF@@@R@P?D@FB`ABJ@hABn@EH@f@ATCFBL@T@d@@dA@b@@^DpCBjB@RBbA?N@PBhAA??P?JB?BhBDfB?DA??D?L?L?DJpDB|@ABAD?H@H?F?N@h@DhBF~C?^?B?FCHAJIREDk@t@]d@A?BDA@GJKLGA?DADA@GFiAxAoB`C?DKNILI?u@`AyAlBGF??EFEFCBDDw@|@CDEDs@`AQTUZc@j@MPQREAIJIH?B?Bw@z@UViAvAADGDKNk@r@yAjBA@CBILA@oA~ACD?DWXILAAABA@e@h@KLKL]b@eC`D?BKHIFCBaDbE?BOPOPAAc@h@mB`CIHEFOPUVCDEH]b@qBbCBBKJON_AlAQTSZ_@bAIJ?B@D@F@FIh@GZc@lBCHCLILSRBH@D@B?DMh@ADELEDEDA?@HAZMn@Q|@EHCFEP?JELQx@CJED@BEVETEFeAbFIPERETATm@xCSlAYxAALCJ??_@fBOt@G\\ELILs@p@KFSJOFIB?@DNF^DT?@DBFB?@BDBH@P?r@Ef@q@lDOr@KVOb@GJIL[n@cAjBEJU^c@v@Wb@s@pAILONQHk@XIJCJ?J?J}At@w@`@@NEf@EFDLq@\\KTCXSf@OTGDC@UDCb@SZKZCDADIHPj@QF{@`@MFDRMF\\vANp@CXDLBCf@tBc@RgClA?DDLFT@Di@TgAh@E@GBm@XGHABAH?J@JRt@`@bBA@KFOFA@@BBLDN?BeAf@uAn@dAdE@BDNBL@BC@GDOFC@}CxAdAbEKFOFDPDR}CxAMHQJE?C@oB|@ABAD?D?Bx@`DDFQHWLSHAD??FXHZG?i@VSJm@VA@IDEDABAB?BQFUFDBBBn@dC~@pDH^FX@HFf@Db@BZ?P?^?T?PCd@ALE\\EVCNEDKH}Ap@",
- "distance": 7789.5,
- "duration": 6233.7,
- "legs": [
- {
- "steps": [
- {
- "name": "East Santa Clara Street",
- "distance": 1.3,
- "duration": 3,
- "maneuver": {
- "type": "depart",
- "location": [
- -121.886303,
- 37.338204
- ],
- "bearing_after": 240
- }
- },
- {
- "name": "North 5th Street",
- "distance": 14.8,
- "duration": 11.8,
- "maneuver": {
- "type": "turn",
- "modifier": "right",
- "location": [
- -121.886316,
- 37.338198
- ],
- "bearing_after": 327
- }
- },
- {
- "name": "",
- "distance": 1634.9,
- "duration": 1307.7,
- "maneuver": {
- "type": "turn",
- "modifier": "left",
- "location": [
- -121.886402,
- 37.338312
- ],
- "bearing_after": 237
- }
- },
- {
- "name": "",
- "distance": 96.9,
- "duration": 77.7,
- "maneuver": {
- "type": "fork",
- "modifier": "slight left",
- "location": [
- -121.902723,
- 37.332034
- ],
- "bearing_after": 253
- }
- },
- {
- "name": "",
- "distance": 408.2,
- "duration": 326.5,
- "maneuver": {
- "type": "turn",
- "modifier": "left",
- "location": [
- -121.903792,
- 37.332027
- ],
- "bearing_after": 253
- }
- },
- {
- "name": "",
- "distance": 388.7,
- "duration": 311.2,
- "maneuver": {
- "type": "turn",
- "modifier": "slight left",
- "location": [
- -121.908326,
- 37.331804
- ],
- "bearing_after": 267
- }
- },
- {
- "name": "",
- "distance": 3.2,
- "duration": 2.6,
- "maneuver": {
- "type": "end of road",
- "modifier": "left",
- "location": [
- -121.912459,
- 37.332135
- ],
- "bearing_after": 237
- }
- },
- {
- "name": "",
- "distance": 22.1,
- "duration": 17.7,
- "maneuver": {
- "type": "turn",
- "modifier": "right",
- "location": [
- -121.91249,
- 37.33212
- ],
- "bearing_after": 313
- }
- },
- {
- "name": "",
- "distance": 4.6,
- "duration": 3.7,
- "maneuver": {
- "type": "end of road",
- "modifier": "left",
- "location": [
- -121.912624,
- 37.332273
- ],
- "bearing_after": 278
- }
- },
- {
- "name": "",
- "distance": 151.9,
- "duration": 121.5,
- "maneuver": {
- "type": "turn",
- "modifier": "slight right",
- "location": [
- -121.912675,
- 37.33228
- ],
- "bearing_after": 315
- }
- },
- {
- "name": "",
- "distance": 23,
- "duration": 18.4,
- "maneuver": {
- "type": "turn",
- "modifier": "slight right",
- "location": [
- -121.91386,
- 37.333261
- ],
- "bearing_after": 312
- }
- },
- {
- "name": "",
- "distance": 645,
- "duration": 516,
- "maneuver": {
- "type": "turn",
- "modifier": "left",
- "location": [
- -121.914012,
- 37.333418
- ],
- "bearing_after": 315
- }
- },
- {
- "name": "",
- "distance": 204.1,
- "duration": 163.3,
- "maneuver": {
- "type": "turn",
- "modifier": "right",
- "location": [
- -121.919014,
- 37.337543
- ],
- "bearing_after": 317
- }
- },
- {
- "name": "",
- "distance": 168.3,
- "duration": 134.6,
- "maneuver": {
- "type": "end of road",
- "modifier": "right",
- "location": [
- -121.920589,
- 37.338869
- ],
- "bearing_after": 326
- }
- },
- {
- "name": "",
- "distance": 271.5,
- "duration": 217.2,
- "maneuver": {
- "type": "new name",
- "modifier": "straight",
- "location": [
- -121.921884,
- 37.33997
- ],
- "bearing_after": 46
- }
- },
- {
- "name": "",
- "distance": 144.7,
- "duration": 115.8,
- "maneuver": {
- "type": "new name",
- "modifier": "straight",
- "location": [
- -121.923949,
- 37.341745
- ],
- "bearing_after": 240
- }
- },
- {
- "name": "",
- "distance": 183.1,
- "duration": 146.5,
- "maneuver": {
- "type": "end of road",
- "modifier": "left",
- "location": [
- -121.925153,
- 37.342588
- ],
- "bearing_after": 253
- }
- },
- {
- "name": "",
- "distance": 136.2,
- "duration": 109,
- "maneuver": {
- "type": "turn",
- "modifier": "left",
- "location": [
- -121.926974,
- 37.343162
- ],
- "bearing_after": 267
- }
- },
- {
- "name": "",
- "distance": 2.1,
- "duration": 1.7,
- "maneuver": {
- "type": "end of road",
- "modifier": "left",
- "location": [
- -121.928398,
- 37.343567
- ],
- "bearing_after": 251
- }
- },
- {
- "name": "",
- "distance": 514.1,
- "duration": 411.5,
- "maneuver": {
- "type": "turn",
- "modifier": "slight right",
- "location": [
- -121.928421,
- 37.343561
- ],
- "bearing_after": 289
- }
- },
- {
- "name": "",
- "distance": 547.3,
- "duration": 437.8,
- "maneuver": {
- "type": "turn",
- "modifier": "left",
- "location": [
- -121.933468,
- 37.345519
- ],
- "bearing_after": 250
- }
- },
- {
- "name": "The Alameda",
- "distance": 91.5,
- "duration": 73.2,
- "maneuver": {
- "type": "turn",
- "modifier": "right",
- "location": [
- -121.938523,
- 37.347584
- ],
- "bearing_after": 334
- }
- },
- {
- "name": "",
- "distance": 7,
- "duration": 5.6,
- "maneuver": {
- "type": "turn",
- "modifier": "left",
- "location": [
- -121.938961,
- 37.34833
- ],
- "bearing_after": 258
- }
- },
- {
- "name": "",
- "distance": 29.6,
- "duration": 23.7,
- "maneuver": {
- "type": "fork",
- "modifier": "slight right",
- "location": [
- -121.939038,
- 37.348318
- ],
- "bearing_after": 281
- }
- },
- {
- "name": "",
- "distance": 54.4,
- "duration": 43.5,
- "maneuver": {
- "type": "turn",
- "modifier": "right",
- "location": [
- -121.939353,
- 37.348352
- ],
- "bearing_after": 334
- }
- },
- {
- "name": "",
- "distance": 54,
- "duration": 43.2,
- "maneuver": {
- "type": "fork",
- "modifier": "slight right",
- "location": [
- -121.939744,
- 37.348679
- ],
- "bearing_after": 300
- }
- },
- {
- "name": "",
- "distance": 15.9,
- "duration": 12.7,
- "maneuver": {
- "type": "turn",
- "modifier": "left",
- "location": [
- -121.94012,
- 37.34903
- ],
- "bearing_after": 275
- }
- },
- {
- "name": "",
- "distance": 44.2,
- "duration": 35.3,
- "maneuver": {
- "type": "fork",
- "modifier": "slight right",
- "location": [
- -121.940298,
- 37.349046
- ],
- "bearing_after": 310
- }
- },
- {
- "name": "",
- "distance": 22,
- "duration": 17.6,
- "maneuver": {
- "type": "turn",
- "modifier": "left",
- "location": [
- -121.940685,
- 37.349291
- ],
- "bearing_after": 243
- }
- },
- {
- "name": "",
- "distance": 55.7,
- "duration": 44.6,
- "maneuver": {
- "type": "turn",
- "modifier": "right",
- "location": [
- -121.940908,
- 37.349203
- ],
- "bearing_after": 336
- }
- },
- {
- "name": "",
- "distance": 9.3,
- "duration": 7.4,
- "maneuver": {
- "type": "turn",
- "modifier": "left",
- "location": [
- -121.941162,
- 37.349662
- ],
- "bearing_after": 248
- }
- },
- {
- "name": "Alviso Mall",
- "distance": 8,
- "duration": 6.4,
- "maneuver": {
- "type": "turn",
- "modifier": "right",
- "location": [
- -121.94126,
- 37.349633
- ],
- "bearing_after": 336
- }
- },
- {
- "name": "",
- "distance": 66.7,
- "duration": 53.3,
- "maneuver": {
- "type": "turn",
- "modifier": "left",
- "location": [
- -121.941295,
- 37.349699
- ],
- "bearing_after": 246
- }
- },
- {
- "name": "",
- "distance": 11.8,
- "duration": 9.4,
- "maneuver": {
- "type": "fork",
- "modifier": "slight right",
- "location": [
- -121.941988,
- 37.349465
- ],
- "bearing_after": 281
- }
- },
- {
- "name": "",
- "distance": 66.5,
- "duration": 53.2,
- "maneuver": {
- "type": "turn",
- "modifier": "left",
- "location": [
- -121.942118,
- 37.349488
- ],
- "bearing_after": 223
- }
- },
- {
- "name": "",
- "distance": 104.8,
- "duration": 83.8,
- "maneuver": {
- "type": "turn",
- "modifier": "right",
- "location": [
- -121.942759,
- 37.349244
- ],
- "bearing_after": 334
- }
- },
- {
- "name": "",
- "distance": 23,
- "duration": 18.4,
- "maneuver": {
- "type": "turn",
- "modifier": "left",
- "location": [
- -121.943249,
- 37.350103
- ],
- "bearing_after": 246
- }
- },
- {
- "name": "",
- "distance": 114,
- "duration": 91.2,
- "maneuver": {
- "type": "end of road",
- "modifier": "right",
- "location": [
- -121.943486,
- 37.350018
- ],
- "bearing_after": 334
- }
- },
- {
- "name": "",
- "distance": 90.5,
- "duration": 72.4,
- "maneuver": {
- "type": "turn",
- "modifier": "left",
- "location": [
- -121.944035,
- 37.350941
- ],
- "bearing_after": 277
- }
- },
- {
- "name": "",
- "distance": 38.3,
- "duration": 30.7,
- "maneuver": {
- "type": "turn",
- "modifier": "right",
- "location": [
- -121.94498,
- 37.350666
- ],
- "bearing_after": 333
- }
- },
- {
- "name": "",
- "distance": 95.4,
- "duration": 76.3,
- "maneuver": {
- "type": "turn",
- "modifier": "right",
- "location": [
- -121.945268,
- 37.350767
- ],
- "bearing_after": 334
- }
- },
- {
- "name": "",
- "distance": 114.1,
- "duration": 91.3,
- "maneuver": {
- "type": "turn",
- "modifier": "left",
- "location": [
- -121.945714,
- 37.351549
- ],
- "bearing_after": 244
- }
- },
- {
- "name": "",
- "distance": 115.1,
- "duration": 92,
- "maneuver": {
- "type": "turn",
- "modifier": "right",
- "location": [
- -121.946891,
- 37.351132
- ],
- "bearing_after": 334
- }
- },
- {
- "name": "",
- "distance": 95.2,
- "duration": 76.1,
- "maneuver": {
- "type": "turn",
- "modifier": "left",
- "location": [
- -121.947432,
- 37.352075
- ],
- "bearing_after": 244
- }
- },
- {
- "name": "",
- "distance": 17.7,
- "duration": 14.1,
- "maneuver": {
- "type": "turn",
- "modifier": "right",
- "location": [
- -121.948414,
- 37.351727
- ],
- "bearing_after": 336
- }
- },
- {
- "name": "",
- "distance": 301.2,
- "duration": 241.2,
- "maneuver": {
- "type": "turn",
- "modifier": "left",
- "location": [
- -121.948494,
- 37.351873
- ],
- "bearing_after": 246
- }
- },
- {
- "name": "",
- "distance": 40.5,
- "duration": 32.3,
- "maneuver": {
- "type": "turn",
- "modifier": "right",
- "location": [
- -121.950508,
- 37.35307
- ],
- "bearing_after": 334
- }
- },
- {
- "name": "",
- "distance": 114,
- "duration": 91.1,
- "maneuver": {
- "type": "turn",
- "modifier": "right",
- "location": [
- -121.950712,
- 37.353388
- ],
- "bearing_after": 246
- }
- },
- {
- "name": "",
- "distance": 358.8,
- "duration": 287,
- "maneuver": {
- "type": "turn",
- "modifier": "left",
- "location": [
- -121.951414,
- 37.353991
- ],
- "bearing_after": 341
- }
- },
- {
- "name": "",
- "distance": 64.4,
- "duration": 51.5,
- "maneuver": {
- "type": "fork",
- "modifier": "slight right",
- "location": [
- -121.955039,
- 37.353528
- ],
- "bearing_after": 326
- }
- },
- {
- "name": "",
- "distance": 0,
- "duration": 0,
- "maneuver": {
- "type": "arrive",
- "modifier": "right",
- "location": [
- -121.955335,
- 37.354056
- ],
- "bearing_after": 0
- }
- }
- ]
- }
- ]
- }
- ]
-}
diff --git a/apps/maps/native/tests/fixtures/osrm-noroute.json b/apps/maps/native/tests/fixtures/osrm-noroute.json
deleted file mode 100644
index aca614c1..00000000
--- a/apps/maps/native/tests/fixtures/osrm-noroute.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "code": "NoRoute",
- "message": "Impossible route between points"
-}
diff --git a/apps/maps/native/tests/fixtures/photon-empty.json b/apps/maps/native/tests/fixtures/photon-empty.json
deleted file mode 100644
index fb57225e..00000000
--- a/apps/maps/native/tests/fixtures/photon-empty.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "type": "FeatureCollection",
- "features": []
-}
diff --git a/apps/maps/native/tests/fixtures/photon-reverse.json b/apps/maps/native/tests/fixtures/photon-reverse.json
deleted file mode 100644
index 95b11073..00000000
--- a/apps/maps/native/tests/fixtures/photon-reverse.json
+++ /dev/null
@@ -1,36 +0,0 @@
-{
- "type": "FeatureCollection",
- "features": [
- {
- "type": "Feature",
- "properties": {
- "osm_type": "W",
- "osm_id": 416901142,
- "osm_key": "highway",
- "osm_value": "primary",
- "type": "street",
- "name": "East Santa Clara Street",
- "district": "Horace Mann",
- "city": "San Jose",
- "county": "Santa Clara",
- "state": "California",
- "country": "United States",
- "postcode": "95172",
- "countrycode": "US",
- "extent": [
- -121.8865836,
- 37.3381981,
- -121.8863155,
- 37.3380697
- ]
- },
- "geometry": {
- "type": "Point",
- "coordinates": [
- -121.8864495,
- 37.3381339
- ]
- }
- }
- ]
-}
diff --git a/apps/maps/native/tests/fixtures/photon-search.json b/apps/maps/native/tests/fixtures/photon-search.json
deleted file mode 100644
index 5a78d454..00000000
--- a/apps/maps/native/tests/fixtures/photon-search.json
+++ /dev/null
@@ -1,95 +0,0 @@
-{
- "type": "FeatureCollection",
- "features": [
- {
- "type": "Feature",
- "properties": {
- "osm_type": "R",
- "osm_id": 4648353,
- "osm_key": "amenity",
- "osm_value": "university",
- "type": "house",
- "housenumber": "500",
- "name": "Santa Clara University",
- "street": "El Camino Real",
- "city": "Santa Clara",
- "county": "Santa Clara",
- "state": "California",
- "country": "United States",
- "postcode": "95053",
- "countrycode": "US",
- "extent": [
- -121.9433198,
- 37.35229,
- -121.9299239,
- 37.3449346
- ]
- },
- "geometry": {
- "type": "Point",
- "coordinates": [
- -121.936544,
- 37.3486243
- ]
- }
- },
- {
- "type": "Feature",
- "properties": {
- "osm_type": "W",
- "osm_id": 129357613,
- "osm_key": "amenity",
- "osm_value": "library",
- "type": "house",
- "housenumber": "500",
- "name": "Santa Clara University Library",
- "street": "El Camino Real",
- "city": "Santa Clara",
- "county": "Santa Clara",
- "state": "California",
- "country": "United States",
- "postcode": "95053",
- "countrycode": "US",
- "extent": [
- -121.9386594,
- 37.3487529,
- -121.9372416,
- 37.347977
- ]
- },
- "geometry": {
- "type": "Point",
- "coordinates": [
- -121.9380341,
- 37.348349
- ]
- }
- },
- {
- "type": "Feature",
- "properties": {
- "osm_type": "N",
- "osm_id": 8738747973,
- "osm_key": "office",
- "osm_value": "marketing",
- "type": "house",
- "housenumber": "500",
- "name": "Santa Clara University Relations",
- "street": "El Camino Real",
- "city": "Santa Clara",
- "county": "Santa Clara",
- "state": "California",
- "country": "United States",
- "postcode": "95053",
- "countrycode": "US"
- },
- "geometry": {
- "type": "Point",
- "coordinates": [
- -121.933151,
- 37.3457663
- ]
- }
- }
- ]
-}
diff --git a/apps/maps/native/tests/fixtures/tls/README.md b/apps/maps/native/tests/fixtures/tls/README.md
deleted file mode 100644
index 978f9c29..00000000
--- a/apps/maps/native/tests/fixtures/tls/README.md
+++ /dev/null
@@ -1,7 +0,0 @@
-# Local HTTPS test credentials
-
-`localhost.der` is a self-signed certificate valid only for `localhost`.
-`localhost-key.der` is its PKCS#8 **test-only** private key. These are used by
-the loopback TLS 1.3 regression server, never by the app's production client.
-The certificate expires in September 2036. Tests explicitly trust it where
-needed; production uses the normal WebPKI root certificates.
diff --git a/apps/maps/native/tests/fixtures/tls/localhost-key.der b/apps/maps/native/tests/fixtures/tls/localhost-key.der
deleted file mode 100644
index f6d71fd8..00000000
Binary files a/apps/maps/native/tests/fixtures/tls/localhost-key.der and /dev/null differ
diff --git a/apps/maps/native/tests/fixtures/tls/localhost.der b/apps/maps/native/tests/fixtures/tls/localhost.der
deleted file mode 100644
index cc3439ba..00000000
Binary files a/apps/maps/native/tests/fixtures/tls/localhost.der and /dev/null differ
diff --git a/apps/news/native/Cargo.toml b/apps/news/native/Cargo.toml
deleted file mode 100644
index a2c2eaa8..00000000
--- a/apps/news/native/Cargo.toml
+++ /dev/null
@@ -1,13 +0,0 @@
-[package]
-name = "octosense-news"
-version = "0.1.0"
-edition = "2021"
-license = "Apache-2.0"
-publish = false
-
-[dependencies]
-makepad-widgets = { workspace = true }
-makepad-app-module = { workspace = true }
-makepad-wm-theme = { workspace = true }
-makepad-wm-api = { workspace = true }
-makepad-ai-services = { workspace = true }
diff --git a/apps/news/native/resources/icons/chevron-left.svg b/apps/news/native/resources/icons/chevron-left.svg
deleted file mode 100644
index 4474d123..00000000
--- a/apps/news/native/resources/icons/chevron-left.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/news/native/resources/icons/chevron-right.svg b/apps/news/native/resources/icons/chevron-right.svg
deleted file mode 100644
index b1faaf66..00000000
--- a/apps/news/native/resources/icons/chevron-right.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/news/native/resources/icons/close.svg b/apps/news/native/resources/icons/close.svg
deleted file mode 100644
index d9334a21..00000000
--- a/apps/news/native/resources/icons/close.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/news/native/resources/icons/feed.svg b/apps/news/native/resources/icons/feed.svg
deleted file mode 100644
index 6d231ba5..00000000
--- a/apps/news/native/resources/icons/feed.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/news/native/resources/icons/following.svg b/apps/news/native/resources/icons/following.svg
deleted file mode 100644
index f2e9a658..00000000
--- a/apps/news/native/resources/icons/following.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/news/native/resources/icons/google-news.svg b/apps/news/native/resources/icons/google-news.svg
deleted file mode 100644
index 5582285a..00000000
--- a/apps/news/native/resources/icons/google-news.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/news/native/resources/icons/hn.svg b/apps/news/native/resources/icons/hn.svg
deleted file mode 100644
index 6e1f1166..00000000
--- a/apps/news/native/resources/icons/hn.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/news/native/resources/icons/more.svg b/apps/news/native/resources/icons/more.svg
deleted file mode 100644
index a012fad9..00000000
--- a/apps/news/native/resources/icons/more.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/news/native/resources/icons/octosense.svg b/apps/news/native/resources/icons/octosense.svg
deleted file mode 100644
index 3ca43b31..00000000
--- a/apps/news/native/resources/icons/octosense.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/news/native/resources/icons/refresh.svg b/apps/news/native/resources/icons/refresh.svg
deleted file mode 100644
index 885c1f21..00000000
--- a/apps/news/native/resources/icons/refresh.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/news/native/resources/icons/saved.svg b/apps/news/native/resources/icons/saved.svg
deleted file mode 100644
index c9fe1fdc..00000000
--- a/apps/news/native/resources/icons/saved.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/news/native/resources/icons/search.svg b/apps/news/native/resources/icons/search.svg
deleted file mode 100644
index a333fc6b..00000000
--- a/apps/news/native/resources/icons/search.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/news/native/resources/icons/techmeme.svg b/apps/news/native/resources/icons/techmeme.svg
deleted file mode 100644
index d04d5847..00000000
--- a/apps/news/native/resources/icons/techmeme.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/news/native/resources/icons/today.svg b/apps/news/native/resources/icons/today.svg
deleted file mode 100644
index b75ff5e5..00000000
--- a/apps/news/native/resources/icons/today.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/news/native/src/ai.rs b/apps/news/native/src/ai.rs
deleted file mode 100644
index bcbcf6f4..00000000
--- a/apps/news/native/src/ai.rs
+++ /dev/null
@@ -1,204 +0,0 @@
-//! News on the AI bus: one read tool over the headlines on screen — the
-//! same tool the standalone service answers over its port and the module
-//! executor answers on the root at call time.
-
-use crate::model::{plural, Headline, NewsModel, NO_HEADLINES};
-use crate::view::NewsView;
-use makepad_ai_services::wire::{Risk, ServiceCall, ServiceManifest, ToolDef, ToolResult};
-use makepad_widgets::makepad_micro_serde::*;
-use makepad_widgets::WidgetRef;
-
-pub(crate) const DEFAULT_LIMIT: usize = 10;
-pub(crate) const MAX_LIMIT: usize = 30;
-
-/// The manifest shared by the standalone service and the module executor.
-pub fn manifest() -> ServiceManifest {
- ServiceManifest::new(
- "news",
- "News",
- "Headlines on screen from Hacker News, TechMeme, Google News and the person's own feeds.",
- )
- .with_tool(ToolDef::new(
- "headlines",
- "The headlines on screen. `source` picks one tab by id or label (hn, techmeme, google, or a feed's label); omitted means every source interleaved by rank. `limit` caps the rows (default 10, max 30).",
- r#"{"type":"object","properties":{"source":{"type":"string","description":"A source id or label"},"limit":{"type":"integer","description":"Rows to return, 1-30"}}}"#,
- Risk::Read,
- ))
-}
-
-/// The tool's arguments; a model may send the limit as `10` or `10.0`.
-#[derive(Clone, Debug, Default, DeJson)]
-struct Args {
- source: Option,
- limit: Option,
-}
-
-/// Answer one call against the model. Every branch answers: an unknown
-/// tool or source, or arguments that do not parse, are refused with what
-/// would have been accepted.
-pub fn answer(model: &NewsModel, call: &ServiceCall) -> ToolResult {
- match call.tool.as_str() {
- "headlines" => {
- // No arguments means the defaults; malformed ones are refused
- // rather than quietly answered with everything.
- let args = if call.args.trim().is_empty() {
- Args::default()
- } else {
- match Args::deserialize_json_lenient(&call.args) {
- Ok(args) => args,
- Err(e) => return ToolResult::refused(&call.call_id, format!("bad arguments: {e:?}")),
- }
- };
- let limit = args.limit.map(|l| l.max(1.0) as usize);
- match headlines_text(model, args.source.as_deref(), limit) {
- Ok(text) => ToolResult::ok(&call.call_id, text, ""),
- Err(e) => ToolResult::refused(&call.call_id, e),
- }
- }
- other => ToolResult::refused(&call.call_id, format!("unknown tool `{other}`; this app only has `headlines`")),
- }
-}
-
-/// Answer one call on a `NewsView` root: what the module executor and the
-/// standalone port both do. A root that is not (or no longer) a `NewsView`
-/// answers Unavailable rather than panicking in the caller's event loop.
-pub fn answer_root(root: &WidgetRef, call: &ServiceCall) -> ToolResult {
- root.borrow::()
- .map(|view| answer(view.model(), call))
- .unwrap_or_else(|| ToolResult::unavailable(&call.call_id, "the news view is gone"))
-}
-
-/// The rows of one tab as numbered text with links; an unknown source is
-/// the error. An empty tab answers with its status.
-pub(crate) fn headlines_text(model: &NewsModel, source: Option<&str>, limit: Option) -> Result {
- let tab = match source {
- None => 0,
- Some(name) => model.source_index(name).map(|i| i + 1).ok_or_else(|| {
- let known: Vec = model.sources.iter().map(|s| format!("{} ({})", s.def.id, s.def.label)).collect();
- format!("unknown source `{name}`; sources: {}", known.join(", "))
- })?,
- };
- let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
- let rows = model.rows_for_tab(tab);
- if rows.is_empty() {
- let status = model.status_text(tab);
- return Ok(if status == NO_HEADLINES { status } else { format!("No headlines: {status}") });
- }
- Ok(rows.iter().take(limit).enumerate().map(|(i, h)| row_text(i + 1, h)).collect::>().join("\n"))
-}
-
-/// `3. Title — Source (12 points, 4 comments)` over its link.
-fn row_text(number: usize, row: &Headline) -> String {
- let mut line = format!("{number}. {}", row.title);
- if !row.source.is_empty() {
- line.push_str(&format!(" — {}", row.source));
- }
- let mut extras = Vec::new();
- if let Some(p) = row.points {
- extras.push(plural(p, "point"));
- }
- if let Some(c) = row.comments {
- extras.push(plural(c, "comment"));
- }
- if !extras.is_empty() {
- line.push_str(&format!(" ({})", extras.join(", ")));
- }
- format!("{line}\n {}", row.link)
-}
-
-/// One line of volatile context for the assistant: the tab and its status.
-pub(crate) fn context_line(model: &NewsModel) -> String {
- format!("News: {} tab, {} rows, {}", model.tab_label(model.tab), model.row_count(model.tab), model.status_text(model.tab))
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use makepad_ai_services::wire::ToolOutcome;
-
- fn model_with_rows() -> NewsModel {
- let mut m = NewsModel::new();
- for (i, prefix) in ["h", "t", "g"].iter().enumerate() {
- let rows = (1..=3)
- .map(|n| Headline {
- title: format!("{prefix}{n}"),
- link: format!("https://x/{prefix}{n}"),
- source: "S".into(),
- points: Some(n),
- ..Default::default()
- })
- .collect();
- let (id, _) = m.begin_fetch(i);
- m.complete(id, Ok(rows));
- }
- m
- }
-
- fn call(args: &str) -> ServiceCall {
- ServiceCall { call_id: "c1".into(), tool: "headlines".into(), args: args.into() }
- }
-
- #[test]
- fn the_manifest_declares_one_read_tool() {
- let m = manifest();
- assert_eq!(m.id, "news");
- assert!(m.validate().is_ok());
- assert_eq!(m.tools.len(), 1);
- assert_eq!(m.tools[0].name, "headlines");
- assert_eq!(m.tools[0].risk, Risk::Read);
- }
-
- #[test]
- fn headlines_lists_all_interleaved_by_default_and_one_source_on_request() {
- let m = model_with_rows();
- let text = headlines_text(&m, None, None).unwrap();
- assert!(text.starts_with("1. h1 — S (1 point)\n https://x/h1\n2. t1"), "{text}");
- assert_eq!(text.lines().count(), 18, "9 rows, two lines each");
- let text = headlines_text(&m, Some("techmeme"), Some(2)).unwrap();
- assert_eq!(text.lines().count(), 4);
- assert!(text.contains("t1") && text.contains("t2") && !text.contains("t3"));
- let err = headlines_text(&m, Some("nope"), None).unwrap_err();
- assert!(err.contains("hn") && err.contains("techmeme") && err.contains("google"), "{err}");
- assert_eq!(headlines_text(&NewsModel::new(), None, None).unwrap(), "No headlines yet");
- let mut failed = NewsModel::new();
- let (id, _) = failed.begin_fetch(0);
- failed.complete(id, Err("HTTP 500".into()));
- assert_eq!(headlines_text(&failed, Some("hn"), None).unwrap(), "No headlines: Unavailable");
- }
-
- #[test]
- fn answer_parses_arguments_and_refuses_unknown_tools() {
- let m = model_with_rows();
- let ok = answer(&m, &call(r#"{"source": "hn", "limit": 1}"#));
- assert_eq!(ok.outcome, ToolOutcome::Ok);
- assert!(ok.text.contains("h1") && !ok.text.contains("h2"), "{}", ok.text);
- let ok = answer(&m, &call(""));
- assert!(ok.text.contains("g3"), "empty args mean everything: {}", ok.text);
- let ok = answer(&m, &call(" \n"));
- assert!(ok.text.contains("g3"), "so do blank args: {}", ok.text);
- let bad = answer(&m, &call(r#"{"source":"hn","limit":"5"}"#));
- assert_eq!(bad.outcome, ToolOutcome::Refused, "a malformed limit is not silently every source");
- assert!(bad.text.contains("bad arguments"), "{}", bad.text);
- let bad = answer(&m, &call(r#"{"source":"nope"}"#));
- assert_eq!(bad.outcome, ToolOutcome::Refused);
- assert!(bad.text.contains("unknown source"), "{}", bad.text);
- let bad = answer(&m, &ServiceCall { call_id: "c2".into(), tool: "weather".into(), args: String::new() });
- assert_eq!(bad.outcome, ToolOutcome::Refused);
- assert!(bad.text.contains("headlines"), "the refusal names the one tool that exists: {}", bad.text);
- }
-
- #[test]
- fn answer_root_reports_a_missing_view_as_unavailable() {
- let result = answer_root(&WidgetRef::empty(), &call(""));
- assert_eq!(result.outcome, ToolOutcome::Unavailable);
- assert!(result.text.contains("gone"), "{}", result.text);
- }
-
- #[test]
- fn the_context_line_names_the_tab_its_rows_and_status() {
- let mut m = model_with_rows();
- assert_eq!(context_line(&m), "News: Today tab, 9 rows, Updated just now");
- m.select_tab(2);
- assert_eq!(context_line(&m), "News: TechMeme tab, 3 rows, Updated just now");
- }
-}
diff --git a/apps/news/native/src/feed.rs b/apps/news/native/src/feed.rs
deleted file mode 100644
index 45164e0f..00000000
--- a/apps/news/native/src/feed.rs
+++ /dev/null
@@ -1,997 +0,0 @@
-//! RSS 2.0 and Atom into headline rows without an XML crate: items are
-//! split on `
- Result, String> {
- parse_styled(xml, Style { split_source, digest: false })
-}
-
-/// Parse a digest feed: the outlet comes off the end of each title.
-pub fn parse_digest(xml: &str) -> Result, String> {
- parse_styled(xml, Style { split_source: false, digest: true })
-}
-
-pub fn parse_styled(xml: &str, style: Style) -> Result, String> {
- // A feed that prefixes every Atom element (``) reads like
- // a bare one once the prefix is dropped.
- let unprefixed;
- let xml = if !xml.contains("`.
- let body = &chunk[chunk.find('>')? + 1..];
- let body = body.split_once(close).map_or(body, |(b, _)| b);
- parse_item(body, is_atom, style)
- })
- .collect();
- Ok(rows)
-}
-
-const RSS: (&str, &str, bool) = ("
- ", false);
-const ATOM: (&str, &str, bool) = ("", true);
-
-/// The item element to split on and whether the feed is Atom: the root
-/// element decides (``; `` or ``), else the first item
-/// element found.
-fn feed_kind(xml: &str) -> Option<(&'static str, &'static str, bool)> {
- match root_element(xml) {
- Some("feed") => Some(ATOM),
- Some("rss" | "rdf:RDF") => Some(RSS),
- _ if has_element(xml, "item") => Some(RSS),
- _ if has_element(xml, "entry") => Some(ATOM),
- _ => None,
- }
-}
-
-/// The name of the root element: the first element after the prolog (a
-/// byte-order mark, whitespace, `...?>`, ``, ``).
-/// Looking only there keeps a `` inside a description from naming
-/// the kind.
-fn root_element(xml: &str) -> Option<&str> {
- let prolog_space = |c: char| c.is_whitespace() || c == '\u{feff}';
- let mut rest = xml.trim_start_matches(prolog_space);
- loop {
- let after_prolog = if let Some(after) = rest.strip_prefix("") {
- after.split_once("?>")?.1
- } else if let Some(after) = rest.strip_prefix("")?.1
- } else if let Some(after) = rest.strip_prefix("`; what follows is not an element, so the caller falls
- // back to the item heuristic. Feeds do not carry one.
- after.split_once('>')?.1
- } else {
- break;
- };
- rest = after_prolog.trim_start_matches(prolog_space);
- }
- let name = rest.strip_prefix('<')?;
- let end = name.find(|c: char| c.is_whitespace() || c == '>' || c == '/').unwrap_or(name.len());
- Some(&name[..end]).filter(|n| !n.is_empty())
-}
-
-/// Whether `xml` opens an element named exactly `name`.
-fn has_element(xml: &str, name: &str) -> bool {
- let open = format!("<{name}");
- let mut from = 0;
- while let Some(pos) = xml[from..].find(&open) {
- from += pos + open.len();
- if xml[from..].chars().next().is_some_and(|c| c == '>' || c == '/' || c.is_whitespace()) {
- return true;
- }
- }
- false
-}
-
-/// One `
- ` / `` body as a row; None without a title or an http
-/// link.
-fn parse_item(body: &str, is_atom: bool, style: Style) -> Option {
- let mut title = clean_text(element_text(body, "title")?);
- if title.is_empty() {
- return None;
- }
- let link = if is_atom { atom_link(body) } else { rss_link(body) }?;
- let mut source = if is_atom {
- String::new()
- } else {
- element_text(body, "source").map(clean_text).unwrap_or_default()
- };
- if style.split_source {
- if let Some((head, tail)) = split_google_source(&title) {
- title = head;
- source = tail;
- }
- }
- if style.digest {
- if let Some((head, outlet)) = trailing_outlet(&title) {
- title = head;
- source = outlet;
- }
- }
- let mut summary = element_text(body, "description")
- .or_else(|| element_text(body, "summary"))
- .or_else(|| element_text(body, "content"))
- .or_else(|| element_text(body, "content:encoded"))
- .map(|d| truncate(&clean_text(d), SUMMARY_CHARS))
- .unwrap_or_default();
- // A description of `Outlet: headline` (a digest's): the outlet is the
- // row's publisher when nothing named one, and a description that is
- // only the headline again is no summary.
- if let Some((outlet, rest)) = outlet_prefix(&title, &summary) {
- if source.is_empty() {
- source = outlet;
- }
- summary = rest;
- }
- let published = element_text(body, "pubDate")
- .and_then(|d| parse_rfc822(d.trim()))
- .or_else(|| element_text(body, "published").and_then(|d| parse_rfc3339(d.trim())))
- .or_else(|| element_text(body, "updated").and_then(|d| parse_rfc3339(d.trim())))
- .or_else(|| element_text(body, "dc:date").and_then(|d| parse_rfc3339(d.trim())));
- // The model stamps `source_id` when the rows land: one reader serves
- // every feed.
- Some(Headline {
- title,
- link,
- source,
- source_id: String::new(),
- published,
- points: None,
- comments: None,
- summary,
- discussion: None,
- image: image_url(body),
- })
-}
-
-/// The story's picture, when the item carries one: a ``
-/// that is an image (its `medium` or `type` says so, or neither is given),
-/// a ``, an `` with an image type, else the
-/// first `
` in the description or content. Only an http(s) URL
-/// counts, and it is asked for over https whatever the feed says: feeds
-/// still carry plain http picture links (TechMeme's), the app's own
-/// connections are held to transport security on Apple platforms, and a
-/// picture host without https costs the story its picture, not its row.
-fn image_url(body: &str) -> Option {
- media_image(body, "media:content", true)
- .or_else(|| media_image(body, "media:thumbnail", false))
- .or_else(|| media_image(body, "enclosure", true))
- .or_else(|| inline_image(body))
- .map(|url| match url.strip_prefix("http://") {
- Some(rest) => format!("https://{rest}"),
- None => url,
- })
-}
-
-/// The `url` of the first `` element that is an image. `typed`: the
-/// element carries a `medium` or `type`, and it must say image when it
-/// does (an enclosure must say so; a media:content with neither is taken
-/// as one). Every such element is looked at, not just the first: a feed
-/// may list a video before its still.
-fn media_image(body: &str, tag: &str, typed: bool) -> Option {
- let open = format!("<{tag}");
- let mut from = 0;
- while let Some(pos) = body[from..].find(&open) {
- let at = from + pos;
- let after = &body[at + open.len()..];
- let next = after.chars().next()?;
- from = at + open.len();
- if next != '>' && next != '/' && !next.is_whitespace() {
- continue;
- }
- let tag_end = after.find('>')?;
- let attrs = &after[..tag_end];
- if typed {
- let medium = attribute(attrs, "medium");
- let kind = attribute(attrs, "type");
- let says_image = medium.as_deref() == Some("image") || kind.as_deref().is_some_and(|t| t.starts_with("image/"));
- let says_nothing = medium.is_none() && kind.is_none() && tag == "media:content";
- if !says_image && !says_nothing {
- continue;
- }
- }
- if let Some(url) = attribute(attrs, "url").map(|u| decode_entities(&u)) {
- if is_http_url(&url) {
- return Some(url);
- }
- }
- }
- None
-}
-
-/// The first `
` in the item's HTML description or content that is
-/// not a tiny one (a permalink icon, a tracking pixel: anything whose
-/// `width` or `height` says under `MIN_IMAGE_PX`). Tags and attributes in
-/// any case, values quoted or not.
-fn inline_image(body: &str) -> Option {
- let html = element_text(body, "description")
- .or_else(|| element_text(body, "content:encoded"))
- .or_else(|| element_text(body, "content"))?;
- // CDATA markers dropped and entities decoded: feeds double-encode HTML.
- let html = decode_entities(&html.replace("", ""));
- // ASCII lowercasing keeps every byte offset, so positions found in the
- // lowered copy index the original.
- let lower = html.to_ascii_lowercase();
- let mut from = 0;
- while let Some(pos) = lower[from..].find("
') else { break };
- let attrs = &html[at..at + tag_end];
- from = at + tag_end;
- if is_tiny(attrs) {
- continue;
- }
- if let Some(src) = attribute_ci(attrs, "src").map(|u| decode_entities(&u)) {
- if is_http_url(&src) {
- return Some(src);
- }
- }
- }
- None
-}
-
-/// Under this many pixels on a side an image is an icon, not a picture.
-const MIN_IMAGE_PX: f64 = 50.0;
-
-/// Whether an `
`'s `width` or `height` attribute says it is an icon.
-fn is_tiny(attrs: &str) -> bool {
- ["width", "height"].iter().any(|key| {
- attribute_ci(attrs, key)
- .and_then(|v| v.trim().trim_end_matches("px").trim().parse::().ok())
- .is_some_and(|px| px < MIN_IMAGE_PX)
- })
-}
-
-/// `attribute` for HTML: the name in any case, the value quoted or bare
-/// (ending at whitespace).
-fn attribute_ci(attrs: &str, name: &str) -> Option {
- let lower = attrs.to_ascii_lowercase();
- let key = format!("{}=", name.to_ascii_lowercase());
- let mut from = 0;
- while let Some(pos) = lower[from..].find(&key) {
- let at = from + pos;
- let before_ok = at == 0 || lower[..at].ends_with(|c: char| c.is_whitespace());
- from = at + key.len();
- if !before_ok {
- continue;
- }
- let rest = &attrs[at + key.len()..];
- let value = match rest.chars().next()? {
- quote @ ('"' | '\'') => {
- let value = &rest[1..];
- &value[..value.find(quote)?]
- }
- _ => rest.split(|c: char| c.is_whitespace()).next().unwrap_or(""),
- };
- return Some(value.to_string());
- }
- None
-}
-
-/// `Outlet: headline …` in a description whose remainder opens with the
-/// item's own title: the outlet, and what is left after the headline
-/// (empty when the description was the headline alone). None when the
-/// description does not have that shape.
-fn outlet_prefix(title: &str, summary: &str) -> Option<(String, String)> {
- // The colon alone: a stripped `
` may have left no space after it.
- let (outlet, rest) = summary.split_once(':')?;
- let outlet = outlet.trim();
- let rest = rest.trim_start();
- if !looks_like_name(outlet) {
- return None;
- }
- let title = title.trim().trim_end_matches('…').trim();
- let head: String = title.chars().take(TITLE_MATCH_CHARS).collect();
- if head.is_empty() || !rest.to_lowercase().starts_with(&head.to_lowercase()) {
- return None;
- }
- let rest = rest.trim();
- // What follows the headline, compared character by character (a
- // lowercase form can differ in length from its original, so byte
- // offsets of one never index the other).
- let remainder = strip_prefix_ci(rest, title).unwrap_or("").trim();
- let remainder = remainder.trim_start_matches(['—', '-', '·', ':', ',']).trim();
- Some((outlet.to_string(), remainder.to_string()))
-}
-
-/// `text` after `prefix`, matched case-insensitively character by
-/// character; None when `text` does not start with it.
-fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
- let mut rest = text;
- for want in prefix.chars() {
- let got = rest.chars().next()?;
- if !got.to_lowercase().eq(want.to_lowercase()) {
- return None;
- }
- rest = &rest[got.len_utf8()..];
- }
- Some(rest)
-}
-
-/// A digest title's trailing ` (Author/Outlet)` or ` (Outlet)`: the title
-/// without it, and the outlet (the last `/`-separated part). None when the
-/// title does not end that way, or what is in the parentheses is too long
-/// to be a name.
-fn trailing_outlet(title: &str) -> Option<(String, String)> {
- let title = title.trim();
- let inner_end = title.strip_suffix(')')?;
- let open = inner_end.rfind('(')?;
- let inner = inner_end[open + 1..].trim();
- let head = inner_end[..open].trim_end();
- if inner.is_empty() || head.is_empty() || inner.chars().count() > OUTLET_CHARS || inner.contains('(') {
- return None;
- }
- let outlet = inner.rsplit('/').next().unwrap_or(inner).trim();
- if outlet.is_empty() {
- return None;
- }
- Some((head.to_string(), outlet.to_string()))
-}
-
-/// Whether `text` reads as an outlet's name rather than the start of a
-/// sentence: a few words, each capitalised or a number (`Financial Times`,
-/// `9to5Mac`, `The Verge`), connectors aside; no sentence punctuation.
-fn looks_like_name(text: &str) -> bool {
- let words: Vec<&str> = text.split_whitespace().collect();
- if words.is_empty() || text.chars().count() > OUTLET_CHARS || text.contains(['.', '!', '?']) {
- return false;
- }
- words.iter().all(|word| {
- matches!(*word, "of" | "the" | "and" | "de" | "for" | "&")
- || word.chars().next().is_some_and(|c| c.is_uppercase() || c.is_ascii_digit())
- })
-}
-
-/// An outlet's name is a few words: anything longer is a sentence with a
-/// colon in it.
-const OUTLET_CHARS: usize = 40;
-/// How much of the headline the description must open with.
-const TITLE_MATCH_CHARS: usize = 24;
-
-/// Google News titles end in ` - Source`; the last dash is the separator.
-fn split_google_source(title: &str) -> Option<(String, String)> {
- let (head, tail) = title.rsplit_once(" - ")?;
- let (head, tail) = (head.trim(), tail.trim());
- (!head.is_empty() && !tail.is_empty()).then(|| (head.to_string(), tail.to_string()))
-}
-
-/// The first `...` in `xml` as its attribute text and raw
-/// inner text. A tag name must end at `>`, whitespace or `/`, so `title`
-/// never matches `titleX`. A self-closing element has empty inner text.
-fn element<'a>(xml: &'a str, tag: &str) -> Option<(&'a str, &'a str)> {
- let open = format!("<{tag}");
- let close = format!("{tag}>");
- let mut from = 0;
- while let Some(pos) = xml[from..].find(&open) {
- let at = from + pos;
- let after = &xml[at + open.len()..];
- let next = after.chars().next()?;
- if next != '>' && next != '/' && !next.is_whitespace() {
- from = at + 1;
- continue;
- }
- let tag_end = after.find('>')?;
- let attrs = after[..tag_end].trim_end();
- if let Some(attrs) = attrs.strip_suffix('/') {
- return Some((attrs, ""));
- }
- let content = &after[tag_end + 1..];
- let end = content.find(&close)?;
- return Some((attrs, &content[..end]));
- }
- None
-}
-
-/// The raw inner text of the first `` in `xml`.
-fn element_text<'a>(xml: &'a str, tag: &str) -> Option<&'a str> {
- element(xml, tag).map(|(_, text)| text)
-}
-
-/// An RSS item's `` when it is an http(s) URL, else its `` when
-/// that is one and not marked `isPermaLink="false"`.
-fn rss_link(body: &str) -> Option {
- element_text(body, "link")
- .map(clean_text)
- .filter(|l| is_http_url(l))
- .or_else(|| permalink_guid(body))
-}
-
-fn permalink_guid(body: &str) -> Option {
- let (attrs, text) = element(body, "guid")?;
- if attribute(attrs, "isPermaLink").is_some_and(|v| v.eq_ignore_ascii_case("false")) {
- return None;
- }
- Some(clean_text(text)).filter(|g| is_http_url(g))
-}
-
-/// Atom links are ``; the article is the one
-/// without `rel` or with `rel="alternate"`.
-fn atom_link(xml: &str) -> Option {
- let mut from = 0;
- while let Some(pos) = xml[from..].find("')?;
- let attrs = &after[..tag_end];
- // Past the whole tag: restarting inside it would rescan to its `>`
- // once per ` Option {
- let key = format!("{name}=");
- let mut from = 0;
- while let Some(pos) = attrs[from..].find(&key) {
- let at = from + pos;
- let before_ok = at == 0 || attrs[..at].ends_with(|c: char| c.is_whitespace());
- let rest = &attrs[at + key.len()..];
- from = at + key.len();
- if !before_ok {
- continue;
- }
- let quote = rest.chars().next()?;
- if quote != '"' && quote != '\'' {
- continue;
- }
- let value = &rest[1..];
- let end = value.find(quote)?;
- return Some(value[..end].to_string());
- }
- None
-}
-
-/// CDATA markers dropped (they can sit anywhere in a description), entities
-/// decoded, HTML tags stripped, entities decoded again (feeds double-encode
-/// HTML in descriptions), whitespace collapsed.
-pub(crate) fn clean_text(raw: &str) -> String {
- let text = raw.replace("", "");
- let text = decode_entities(text.trim());
- let text = strip_tags(&text);
- let text = decode_entities(&text);
- text.split_whitespace().collect::>().join(" ")
-}
-
-/// What an angle-bracketed run turns into once stripped.
-enum TagKind {
- /// Wraps words inside a sentence: dropped, so `Example:` keeps
- /// its colon attached.
- Inline,
- /// Separates words or lines: a space, so `line
break` never fuses.
- Block,
- /// Not an HTML tag at all: a title may well say `<3` or `x < y`.
- Text,
-}
-
-const INLINE_TAGS: &[&str] = &[
- "a", "abbr", "b", "big", "cite", "code", "del", "em", "font", "i", "ins", "mark", "q", "s", "small", "span",
- "strike", "strong", "sub", "sup", "tt", "u",
-];
-
-const BLOCK_TAGS: &[&str] = &[
- "article", "aside", "audio", "blockquote", "br", "center", "dd", "div", "dl", "dt", "figcaption", "figure",
- "footer", "h1", "h2", "h3", "h4", "h5", "h6", "header", "hr", "iframe", "img", "li", "nav", "ol", "p", "picture",
- "pre", "section", "source", "table", "tbody", "td", "th", "thead", "tr", "ul", "video",
-];
-
-fn tag_kind(tag: &str) -> TagKind {
- // Comments and processing instructions are markup too.
- if tag.starts_with('!') || tag.starts_with('?') {
- return TagKind::Block;
- }
- let name = tag
- .trim_start_matches('/')
- .split(|c: char| c.is_whitespace() || c == '/')
- .next()
- .unwrap_or("")
- .to_ascii_lowercase();
- if INLINE_TAGS.contains(&name.as_str()) {
- TagKind::Inline
- } else if BLOCK_TAGS.contains(&name.as_str()) {
- TagKind::Block
- } else {
- TagKind::Text
- }
-}
-
-/// Remove known HTML tags (see `TagKind`); an unclosed `<` is text.
-fn strip_tags(text: &str) -> String {
- let mut out = String::with_capacity(text.len());
- let mut rest = text;
- while let Some(open) = rest.find('<') {
- out.push_str(&rest[..open]);
- let after = &rest[open..];
- let Some(close) = after.find('>') else {
- rest = after;
- break;
- };
- match tag_kind(&after[1..close]) {
- TagKind::Inline => {}
- TagKind::Block => out.push(' '),
- TagKind::Text => out.push_str(&after[..=close]),
- }
- rest = &after[close + 1..];
- }
- out.push_str(rest);
- out
-}
-
-/// The scan for an entity's `;` looks at `&` and the ten bytes after it,
-/// so an entity is at most nine characters between `&` and `;`; the
-/// longest wanted, ``, has eight.
-const MAX_ENTITY_LEN: usize = 10;
-
-/// The named entities feeds actually use, plus numeric ones. A numeric
-/// entity for a control character (other than tab, newline and carriage
-/// return) stays as written, so no feed can smuggle one into a label.
-pub(crate) fn decode_entities(text: &str) -> String {
- let mut out = String::with_capacity(text.len());
- let mut rest = text;
- while let Some(pos) = rest.find('&') {
- out.push_str(&rest[..pos]);
- let after = &rest[pos..];
- // Only look as far as an entity can reach, so a wall of `&` stays linear.
- let Some(end) = after.bytes().take(MAX_ENTITY_LEN + 1).position(|b| b == b';') else {
- out.push('&');
- rest = &after[1..];
- continue;
- };
- let entity = &after[1..end];
- let decoded = match entity {
- "amp" => Some('&'),
- "lt" => Some('<'),
- "gt" => Some('>'),
- "quot" => Some('"'),
- "apos" => Some('\''),
- "nbsp" => Some(' '),
- "mdash" => Some('—'),
- "ndash" => Some('–'),
- "hellip" => Some('…'),
- "lsquo" => Some('‘'),
- "rsquo" => Some('’'),
- "ldquo" => Some('“'),
- "rdquo" => Some('”'),
- _ => numeric_entity(entity),
- };
- match decoded {
- Some(c) => {
- out.push(c);
- rest = &after[end + 1..];
- }
- None => {
- out.push('&');
- rest = &after[1..];
- }
- }
- }
- out.push_str(rest);
- out
-}
-
-/// `#65` or `#x41`, digits only (no sign), and never a control character
-/// other than tab, newline or carriage return.
-fn numeric_entity(entity: &str) -> Option {
- let number = entity.strip_prefix('#')?;
- let code = match number.strip_prefix(['x', 'X']) {
- Some(hex) if !hex.is_empty() && hex.bytes().all(|b| b.is_ascii_hexdigit()) => u32::from_str_radix(hex, 16).ok()?,
- Some(_) => return None,
- None if !number.is_empty() && number.bytes().all(|b| b.is_ascii_digit()) => number.parse().ok()?,
- None => return None,
- };
- char::from_u32(code).filter(|c| !c.is_control() || matches!(c, '\n' | '\t' | '\r'))
-}
-
-/// At most `max` characters: cut to `max - 1`, a trailing space dropped, an
-/// ellipsis appended.
-pub(crate) fn truncate(text: &str, max: usize) -> String {
- if text.chars().count() <= max {
- return text.to_string();
- }
- let cut: String = text.chars().take(max.saturating_sub(1)).collect();
- format!("{}…", cut.trim_end())
-}
-
-const MONTHS: [&str; 12] = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
-
-/// `Wed, 16 Sep 2026 08:15:01 -0400`, the weekday and seconds optional,
-/// the zone `GMT`/`UT`/`UTC`/`Z`, `±HHMM`, or a US abbreviation.
-pub(crate) fn parse_rfc822(text: &str) -> Option {
- let mut parts: Vec<&str> = text.split_whitespace().collect();
- if parts.first().is_some_and(|p| p.ends_with(',')) {
- parts.remove(0);
- }
- if parts.len() < 4 {
- return None;
- }
- let day: i64 = parts[0].parse().ok()?;
- let month_name = parts[1].to_lowercase();
- let month = MONTHS.iter().position(|m| month_name.starts_with(m))? as i64 + 1;
- let year: i64 = parts[2].parse().ok()?;
- let year = if year < 100 { year + 2000 } else { year };
- let clock = hms(parts[3])?;
- let offset = parts.get(4).map(|z| zone_offset(z)).unwrap_or(0);
- unix_seconds(year, month, day, clock, offset)
-}
-
-/// `2026-09-16T08:30:00+02:00`, `...Z`, fractional seconds allowed, a
-/// missing zone read as UTC.
-pub(crate) fn parse_rfc3339(text: &str) -> Option {
- let (date, time) = text.split_once(['T', 't', ' '])?;
- let mut d = date.split('-');
- let year: i64 = d.next()?.parse().ok()?;
- let month: i64 = d.next()?.parse().ok()?;
- let day: i64 = d.next()?.parse().ok()?;
- let zone_at = time.find(['Z', 'z', '+']).or_else(|| time.rfind('-')).unwrap_or(time.len());
- let (clock, zone) = time.split_at(zone_at);
- let clock = hms(clock.split('.').next()?)?;
- unix_seconds(year, month, day, clock, zone_offset(zone))
-}
-
-/// Unix seconds for a civil date and clock in a zone `offset` seconds east
-/// of UTC; None when a date field is out of range. A day past its month's
-/// end is let through, as a feed's own clock would have it.
-fn unix_seconds(year: i64, month: i64, day: i64, (h, m, s): (i64, i64, i64), offset: i64) -> Option {
- if !(1..=9999).contains(&year) || !(1..=12).contains(&month) || !(1..=31).contains(&day) {
- return None;
- }
- Some(days_from_civil(year, month, day) * 86_400 + h * 3600 + m * 60 + s - offset)
-}
-
-/// `HH:MM` or `HH:MM:SS`, each field in range (a leap second allowed).
-fn hms(text: &str) -> Option<(i64, i64, i64)> {
- let mut p = text.split(':');
- let h: i64 = p.next()?.parse().ok()?;
- let m: i64 = p.next()?.parse().ok()?;
- let s: i64 = match p.next() {
- Some(s) => s.parse().ok()?,
- None => 0,
- };
- ((0..=23).contains(&h) && (0..=59).contains(&m) && (0..=60).contains(&s)).then_some((h, m, s))
-}
-
-/// Seconds east of UTC for a zone token. Zone names other than the UTC
-/// spellings and the US abbreviations (CET, BST, AEST, ...) are read as
-/// UTC: a time off by a few hours beats no time at all.
-fn zone_offset(zone: &str) -> i64 {
- match zone.to_uppercase().as_str() {
- "GMT" | "UT" | "UTC" | "Z" | "" => 0,
- "EST" => -5 * 3600,
- "EDT" => -4 * 3600,
- "CST" => -6 * 3600,
- "CDT" => -5 * 3600,
- "MST" => -7 * 3600,
- "MDT" => -6 * 3600,
- "PST" => -8 * 3600,
- "PDT" => -7 * 3600,
- z => {
- let sign = if z.starts_with('-') { -1 } else { 1 };
- let digits: String = z.chars().filter(|c| c.is_ascii_digit()).collect();
- if digits.len() < 4 {
- return 0;
- }
- let hh: i64 = digits[..2].parse().unwrap_or(0);
- let mm: i64 = digits[2..4].parse().unwrap_or(0);
- sign * (hh * 3600 + mm * 60)
- }
- }
-}
-
-/// Days since 1970-01-01 for a proleptic Gregorian date.
-pub(crate) fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
- let y = if m <= 2 { y - 1 } else { y };
- let era = (if y >= 0 { y } else { y - 399 }) / 400;
- let yoe = y - era * 400;
- let mp = if m > 2 { m - 3 } else { m + 9 };
- let doy = (153 * mp + 2) / 5 + d - 1;
- let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
- era * 146_097 + doe - 719_468
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- const TECHMEME: &str = include_str!("../tests/fixtures/techmeme.xml");
- const GOOGLE: &str = include_str!("../tests/fixtures/google.xml");
- const ATOM: &str = include_str!("../tests/fixtures/atom.xml");
-
- #[test]
- fn rss_items_become_rows_with_clean_titles_and_summaries() {
- let rows = parse(TECHMEME, false).unwrap();
- assert_eq!(rows.len(), 2, "an item without a title or without a link is skipped");
- assert_eq!(rows[0].title, "Acme & Co launches \"Widget 2\" for $99");
- assert_eq!(rows[0].link, "https://www.techmeme.com/260916/p1#a260916p1");
- assert_eq!(rows[0].summary, "Example: Acme launches Widget 2 — a $99 device.");
- assert_eq!(rows[0].source, "");
- // 08:15:01 -0400 is 12:15:01 UTC on 2026-09-16.
- assert_eq!(rows[0].published, Some(1_789_560_901));
- assert_eq!(rows[1].title, "Second story with ");
- assert_eq!(rows[1].published, Some(1_789_542_000));
- }
-
- #[test]
- fn google_titles_split_their_trailing_source() {
- let rows = parse(GOOGLE, true).unwrap();
- assert_eq!(rows.len(), 2);
- assert_eq!(rows[0].title, "Markets rally as rates hold");
- assert_eq!(rows[0].source, "Reuters");
- assert_eq!(rows[0].summary, "Markets rally as rates hold Reuters");
- assert_eq!(rows[1].title, "A title with - a dash", "only the LAST dash separates the source");
- assert_eq!(rows[1].source, "The Verge");
- let unsplit = parse(GOOGLE, false).unwrap();
- assert_eq!(unsplit[0].title, "Markets rally as rates hold - Reuters");
- assert_eq!(unsplit[0].source, "Reuters", "the element still fills the field");
- }
-
- #[test]
- fn atom_entries_use_the_alternate_link_and_summary_or_content() {
- let rows = parse(ATOM, false).unwrap();
- assert_eq!(rows.len(), 2);
- assert_eq!(rows[0].title, "First & foremost");
- assert_eq!(rows[0].link, "https://blog.example/posts/first", "rel=self is skipped");
- assert_eq!(rows[0].summary, "A short summary.");
- // 08:30 +02:00 is 06:30 UTC.
- assert_eq!(rows[0].published, Some(1_789_540_200));
- assert_eq!(rows[1].link, "https://blog.example/posts/second");
- assert_eq!(rows[1].summary, "Full content here.");
- assert_eq!(rows[1].published, Some(1_789_502_400));
- }
-
- #[test]
- fn the_root_element_names_the_kind_so_an_empty_feed_is_not_an_error() {
- assert!(parse(r#"Empty"#, false).unwrap().is_empty());
- assert!(parse(r#"Empty"#, false).unwrap().is_empty());
- assert!(parse(r#""#, false).unwrap().is_empty());
- }
-
- #[test]
- fn a_prefixed_atom_feed_parses_like_a_bare_one() {
- let xml = r#"PrefixedOnly entry2026-09-16T09:00:00Z"#;
- let rows = parse(xml, false).unwrap();
- assert_eq!(rows.len(), 1);
- assert_eq!(rows[0].title, "Only entry");
- assert_eq!(rows[0].link, "https://p.example/only");
- assert_eq!(rows[0].published, Some(1_789_549_200));
- }
-
- #[test]
- fn an_rss_item_without_a_link_falls_back_to_a_permalink_guid() {
- let xml = r#"
-- Guid onlyhttps://g.example/post
-- Not a permalinkhttps://g.example/id
-- Bare guidhttps://g.example/bare
-- Empty linkhttps://g.example/empty-link
-- Link winshttps://g.example/linkhttps://g.example/guid
-- Opaque guidtag:g.example,2026:1
-- Relative link/post/1https://g.example/relative
-- Relative link only/post/2
-"#;
- let links: Vec = parse(xml, false).unwrap().into_iter().map(|h| h.link).collect();
- assert_eq!(
- links,
- ["https://g.example/post", "https://g.example/bare", "https://g.example/empty-link", "https://g.example/link", "https://g.example/relative"],
- "a non-http link falls through to the guid"
- );
- }
-
- #[test]
- fn an_item_with_only_content_encoded_uses_it_as_the_summary() {
- let xml = r#"- Encodedhttps://e.example/1Full body here.
]]>"#;
- let rows = parse(xml, false).unwrap();
- assert_eq!(rows.len(), 1);
- assert_eq!(rows[0].summary, "Full body here.");
- }
-
- #[test]
- fn the_root_is_the_first_element_after_the_prolog_not_a_tag_inside_a_description() {
- let xml = r#"
-
-
-
-- About feedshttps://r.example/feeds element for Atom.]]>
-"#;
- let rows = parse(xml, false).unwrap();
- assert_eq!(rows.len(), 1, "an RSS feed mentioning is still RSS");
- assert_eq!(rows[0].summary, "Use a element for Atom.");
- assert_eq!(root_element(xml), Some("rss"));
- assert_eq!(root_element("\u{feff} "), Some("feed"));
- assert_eq!(root_element(""), Some("rdf:RDF"));
- assert_eq!(root_element("