From 95a761561b49ac8f1cdde55f3584aa82486ad967 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:58:13 -0700 Subject: [PATCH 1/9] fix(shell): re-assert a module tile's pending keyboard focus after it draws A focus that cannot land at launch (the tile is not drawn yet) is kept in `pending_focus` and re-asserted when the client's first frame arrives, on the PresentableDraw path. A module tile never sends a frame, so its pending focus was never retried: ModuleView stayed unfocused, its gate dropped every key, and a module opened from the dock or launcher took no typing until the person clicked into it. After the UI handles a Draw, retry a pending focus that belongs to a module client. On success it clears; otherwise nothing forces a redraw, so a tile that still cannot take it just tries again on the next natural draw. Found with the Terminal module: typed keys never reached its root (a probe in its key path logged nothing) until the tile was clicked; with this change `echo` typed right after opening it from the dock ran. Co-Authored-By: Claude Opus 5.5 --- crates/shell/src/lib.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/shell/src/lib.rs b/crates/shell/src/lib.rs index 49d4f62a..6e31769e 100644 --- a/crates/shell/src/lib.rs +++ b/crates/shell/src/lib.rs @@ -5114,6 +5114,23 @@ impl App { } else { self.ui.handle_event(cx, event, &mut Scope::empty()); } + // A focus that couldn't land at launch (tile not yet drawn) is + // re-asserted for a process tile when its first frame arrives + // (PresentableDraw). A module tile sends no frame, so retry after the + // draw that may have created it; nothing forces a redraw, so a tile + // that still cannot take it just tries again on the next draw. + if let (Event::Draw(_), Some(client)) = (event, self.pending_focus) { + if self.module_host.is_module(client) { + let focused = self + .desk(cx) + .borrow_mut::() + .and_then(|mut d| d.with_tile(cx, client, |cx, v| v.focus_keyboard(cx))) + .unwrap_or(false); + if focused { + self.pending_focus = None; + } + } + } self.sync_phone_keyboard(cx); // Style reloads and phone capture teardown may retire draw lists during // this event. Remove their pass roots before upstream scans GPU demand. From 36795a9a2d56242539c68df6e5958dd0c6b386f6 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:58:13 -0700 Subject: [PATCH 2/9] feat(desktop): ship Terminal as a system app (app-terminal) Terminal was a developer program: built on first launch from the Makepad checkout, then run as a child process. It now links in-process like Rinx: the new `app-terminal` feature (on in the desktop's default set) registers makepad-terminal's TERMINAL_MODULE, and `terminal` joins robrix and finance as a module hosted in-process by default unless the person switched it (`terminal: Process` in wm/apps.splash keeps the process form, whose config/apps.json row stays). - Opens instantly: no compile on first launch, no swapchain round trip. - On macOS its PTY helper is `octosense` itself (Cx::pre_start answers `--exec-pty`), so nothing ships beside the binary. - It declares `process` and `clipboard` and no `octos.*` service. The assistant gets read_screen and read_scrollback only; `run` (an unsandboxed shell, rated Risk::Act) is not offered by a default system app. - Phone builds are unchanged: `mobile-apps` does not include it. Builds on the current makepad pin (75e3e24d), which already carries TERMINAL_MODULE, the PTY-helper fallback and the macOS signal-wake fix (OctoSense-org/makepad #40, #41); Cargo.toml pins makepad-terminal there. Tabs, panes, settings and terminal-ctl (makepad #47) arrive with a later pin bump. Verified locally with those makepad commits applied under .sources/makepad: - cargo check --locked -p octosense (and --features mobile-apps) - bash tools/check-shell-graph.sh -p octosense - cargo test --locked -p octosense-shell --features app-terminal: 333 passed - from phone/: cargo test --locked --features mobile-apps -p octosense-shell: 342 passed - hidden desktop over MAKEPAD_REMOTE: Terminal opens from the dock as "in-process, terminal"; the shell's parent is octosense; octoscode's composer stayed clean on 3 of 3 launches; closing the tile ended zsh and octoscode and freed the isolate; two Terminals ran two shells. Rebased onto main: cargo build --locked -p octosense and tools/check-shell-graph.sh pass against the pinned makepad with the reviewed runtime patches; cargo test -p octosense-shell --features app-terminal: 345 passed. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 14 ++++++++++++++ Cargo.toml | 2 ++ crates/shell/Cargo.toml | 4 ++++ crates/shell/src/apps.rs | 27 +++++++++++++++++++++++++-- desktop/Cargo.toml | 7 +++++-- desktop/README.md | 3 ++- desktop/README.zh-CN.md | 3 ++- 7 files changed, 54 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 11f1173c..d3ccfe95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5548,6 +5548,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" @@ -7517,6 +7530,7 @@ dependencies = [ "makepad-sheets", "makepad-strict-json", "makepad-studio-protocol", + "makepad-terminal", "makepad-widgets", "makepad-wm-api", "makepad-wm-theme", diff --git a/Cargo.toml b/Cargo.toml index ba2189fc..6a822263 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,6 +68,7 @@ makepad-wm-api = { git = "https://github.com/OctoSense-org/makepad.git", rev = " makepad-ai-services = { git = "https://github.com/OctoSense-org/makepad.git", rev = "f08eec5378fa3f83902a2794dacda03e829a0ef0" } 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-terminal = { 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 } @@ -196,6 +197,7 @@ makepad-wm-api = { path = ".sources/makepad/libs/wm_api" } 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-terminal = { path = ".sources/makepad/apps/terminal" } 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" } diff --git a/crates/shell/Cargo.toml b/crates/shell/Cargo.toml index e5ee0798..19f41d03 100644 --- a/crates/shell/Cargo.toml +++ b/crates/shell/Cargo.toml @@ -39,6 +39,7 @@ makepad-strict-json = { workspace = true } makepad-app-module = { workspace = true } makepad-wm-theme = { workspace = true } makepad-sheets = { workspace = true, optional = true } +makepad-terminal = { workspace = true, optional = true } octoscript-makepad = { workspace = true } # The L0 checker the glance service admits published cards with (glance.rs). octoscript-ui-l0 = { workspace = true } @@ -88,6 +89,9 @@ app-rinx = ["dep:rinx", "octos-core"] app-hub = ["dep:octosense-app-hub-app", "dep:octosense-appstore", "dep:octosense-mail-service", "dep:octosense-news-service", "octosense-ai-host/llm"] app-reference = ["dep:octosense-reference"] app-sheets = ["dep:makepad-sheets"] +# The terminal as a system app: linked in-process and module-hosted by +# default on the desktop (apps.rs), unlike the other desktop modules. +app-terminal = ["dep:makepad-terminal"] # Native Photos, News and OctosMap modules; without them these are the system # script apps (`os.photos`, `os.news`, `os.maps`, ADR 0004). app-photos = ["dep:octosense-photos"] diff --git a/crates/shell/src/apps.rs b/crates/shell/src/apps.rs index 18c7831f..d3a40d00 100644 --- a/crates/shell/src/apps.rs +++ b/crates/shell/src/apps.rs @@ -60,6 +60,11 @@ fn linked_modules() -> Vec<&'static dyn AppModule> { out.push(&octosense_reference::REFERENCE_MODULE); #[cfg(any(feature = "app-sheets", native_mobile))] out.push(&makepad_sheets::SHEETS_MODULE); + // Terminal is a system app on the desktop: a login shell in a tile. Its + // PTY helper on macOS is this executable (`--exec-pty`, handled in + // `Cx::pre_start`), so it needs no second binary shipped beside it. + #[cfg(feature = "app-terminal")] + out.push(&makepad_terminal::TERMINAL_MODULE); // The native Photos library, albums and Memories. Without it Photos is // the system app (`os.photos`); a linked module of the same id wins. #[cfg(feature = "app-photos")] @@ -391,7 +396,7 @@ impl AppRegistry { // the person switched them. // (Rinx is Robrix renamed: under the old id the desktop fell through // to a process launch and failed with "binary not found: rinx".) - if matches!(id, "rinx" | "finance") && self.module(id).is_some() && !self.overrides.contains_key(id) { + if matches!(id, "rinx" | "finance" | "terminal") && self.module(id).is_some() && !self.overrides.contains_key(id) { return Hosting::Module; } // A system or installed app has no process form anywhere: the `card` @@ -596,7 +601,8 @@ mod tests { let registry = AppRegistry::load(Path::new("/nonexistent/apps.splash"), &["--module".to_string(), "sheets".to_string(), "--module".to_string(), "files".to_string()]); // files has no linked module: the flag cannot make it one. assert_eq!(registry.hosting("files"), Hosting::Process); - assert_eq!(registry.hosting("terminal"), Hosting::Process); + #[cfg(not(feature = "app-terminal"))] + assert_eq!(registry.hosting("terminal"), Hosting::Process, "no linked terminal: a process"); #[cfg(feature = "app-sheets")] { assert_eq!(registry.hosting("sheets"), Hosting::Module); @@ -606,6 +612,23 @@ mod tests { } } + /// Terminal is a system app: linked, it is in-process by default on every + /// target, and a person who switched it to a process keeps that. + #[cfg(feature = "app-terminal")] + #[test] + fn the_linked_terminal_is_a_module_unless_switched() { + let plain = AppRegistry::default(); + assert!(plain.linked_ids().contains(&"terminal")); + assert_eq!(plain.hosting("terminal"), Hosting::Module, "a system app: in-process by default"); + let mut switched = AppRegistry::default(); + switched.overrides.insert("terminal".into(), Hosting::Process); + if crate::host::processes_available() { + assert_eq!(switched.hosting("terminal"), Hosting::Process, "the person's switch wins"); + } + let term: &'static dyn AppModule = &makepad_terminal::TERMINAL_MODULE; + assert!(module_open(term, &card_row("terminal".into(), "Terminal".into(), Vec::new())).is_ok()); + } + #[test] fn rinx_is_module_hosted_on_the_desktop_by_default() { let plain = AppRegistry::default(); diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 35cacbce..1202c351 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -45,8 +45,9 @@ octosense-shell = { workspace = true } [features] # App Hub is part of the standard launcher: the store, the Card runner and # the system apps (News, Photos, Maps, Mail, AI providers; Camera is phone-only) it runs; -# the octos kernel is a shell service in every standard build. -default = ["app-hub", "octos-core", "app-rinx"] +# the octos kernel is a shell service in every standard build. Terminal is a +# system app too, linked in-process. +default = ["app-hub", "octos-core", "app-rinx", "app-terminal"] # The embedded apps, also usable on the desktop with --module. AppCard is not # among them: it ships only with `--features app-appcard`. mobile-apps = ["octosense-shell/mobile-apps"] @@ -58,6 +59,8 @@ octos-core = ["octosense-shell/octos-core"] app-hub = ["octosense-shell/app-hub"] app-reference = ["octosense-shell/app-reference"] app-sheets = ["octosense-shell/app-sheets"] +# The Terminal system app: a login shell in a tile, in-process by default. +app-terminal = ["octosense-shell/app-terminal"] # Native Photos, News and OctosMap modules instead of the system script apps. app-photos = ["octosense-shell/app-photos"] app-news = ["octosense-shell/app-news"] diff --git a/desktop/README.md b/desktop/README.md index 4fd0f2f2..31b1ba85 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -82,6 +82,7 @@ A relocatable `.app`, installers and a Linux session compositor are not provided | `app-rinx` | on | Links [Rinx](https://github.com/hagency-org/Rinx), the Matrix client, as a module; implies `octos-core` (its assistant is the shell's). | | `app-reference` | off | Links Reference (`../apps/reference`) as a module. | | `app-sheets` | off | Links Makepad's Sheets as a module. | +| `app-terminal` | on | Links Makepad's Terminal as a system app: a login shell in a tile, in-process by default (a `terminal: Process` line in `wm/apps.splash` under the state directory switches it to a process). The assistant gets its read tools (`read_screen`, `read_scrollback`); `run` is not offered. On macOS the shell's PTY helper is `octosense` itself, so no second binary ships beside it. | | `app-photos` | off | Links Makepad's native Photos module; it replaces the Photos system app of the same id (for comparison). | | `app-appcard` | off | Links the AppCard assistant module (`../apps/appcard/module`); implies `octos-core`. Opt-in on every target, phones included; not shipped for now. | | `app-aichat` | off | Links Makepad's AI chat as a module, without its model engine. | @@ -187,7 +188,7 @@ Open **App Hub**, choose the app, **Get**, scroll to **Install**, then **Open**: ### Developer programs and the catalog -`config/apps.json` lists Reference and Makepad's own apps (Browser, Files, Terminal, Sheets, Notes, Calendar, Director under the id `studio`, and more). The Image, PDF and AI helpers also appear in the launcher unless their ids (`image`, `pdf`, `aichat`) are listed in `wm/launcher.hides` under the state directory. +`config/apps.json` lists Reference and Makepad's own apps (Browser, Files, Terminal, Sheets, Notes, Calendar, Director under the id `studio`, and more). Terminal is also linked (`app-terminal`, on by default) and opens in-process; its `config/apps.json` row is the process form a person can switch to. The Image, PDF and AI helpers also appear in the launcher unless their ids (`image`, `pdf`, `aichat`) are listed in `wm/launcher.hides` under the state directory. Catalog lookup: `--apps ` if given, else `~/.octosense/apps.json` if it exists, else `config/apps.json`. A catalog is a JSON array; each entry picks one launch target: diff --git a/desktop/README.zh-CN.md b/desktop/README.zh-CN.md index da74748f..ae1f67d3 100644 --- a/desktop/README.zh-CN.md +++ b/desktop/README.zh-CN.md @@ -82,6 +82,7 @@ cargo run --release -p octosense | `app-rinx` | 开 | 以模块形式链接 Matrix 客户端 [Rinx](https://github.com/hagency-org/Rinx);隐含 `octos-core`(它的助手就是 Shell 的助手)。 | | `app-reference` | 关 | 以模块形式链接 Reference(`../apps/reference`)。 | | `app-sheets` | 关 | 以模块形式链接 Makepad 的 Sheets。 | +| `app-terminal` | 开 | 以系统应用形式链接 Makepad 的 Terminal:在磁贴中运行的登录 Shell,默认在进程内运行(在状态目录下的 `wm/apps.splash` 中写一行 `terminal: Process` 可改为独立进程)。助手只获得读取工具(`read_screen`、`read_scrollback`),不提供 `run`。在 macOS 上,Shell 的 PTY 辅助程序就是 `octosense` 本身,因此无需在旁边附带第二个二进制文件。 | | `app-photos` | 关 | 链接 Makepad 的原生 Photos 模块;它会替换同 id 的相册系统应用(用于对比)。 | | `app-appcard` | 关 | 链接 AppCard 助手模块(`../apps/appcard/module`);隐含 `octos-core`。在所有目标平台(包括手机)上都需显式启用;目前不随产品发布。 | | `app-aichat` | 关 | 以模块形式链接 Makepad 的 AI chat,不含其模型引擎。 | @@ -187,7 +188,7 @@ OCTOSENSE_HUB= OCTOSENSE_HUB_ANCHOR= \ ### 开发者程序与目录 -`config/apps.json` 列出 Reference 和 Makepad 自带的应用(Browser、Files、Terminal、Sheets、Notes、Calendar、id 为 `studio` 的 Director 等)。Image、PDF 和 AI 辅助应用也会出现在 launcher 中,除非它们的 id(`image`、`pdf`、`aichat`)写在状态目录下的 `wm/launcher.hides` 中。 +`config/apps.json` 列出 Reference 和 Makepad 自带的应用(Browser、Files、Terminal、Sheets、Notes、Calendar、id 为 `studio` 的 Director 等)。Terminal 同时以链接方式提供(`app-terminal`,默认开启),在进程内打开;它在 `config/apps.json` 中的条目是可以切换成的独立进程形式。Image、PDF 和 AI 辅助应用也会出现在 launcher 中,除非它们的 id(`image`、`pdf`、`aichat`)写在状态目录下的 `wm/launcher.hides` 中。 目录查找顺序:给了 `--apps ` 就用它;否则若存在 `~/.octosense/apps.json` 就用它;否则用 `config/apps.json`。目录是一个 JSON 数组,每个条目选择一种启动目标: From ae04d90950d396ad58de56bb10de501eb8eaa5eb Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:17:48 -0700 Subject: [PATCH 3/9] fix(shell): the tile that last took the keyboard releases it on a focus change Focusing a process tile moves key focus to it (its forwarding needs that), but a module tile deliberately never moves key focus: its own widgets claim it. Nothing told the previously focused tile to let go, so after switching between the tabs of a window group the hidden tab kept the keyboard and every key typed into the visible one was dropped. Track the client whose tile last took the keyboard (`keyboard_holder`); in `focus_client`, release that tile (TileHost::release_keyboard) before focusing another. The layout already reports the new client as focused by then, so the previous one is tracked here rather than read from it. Needs the matching makepad-terminal change (claim the keyboard on a key that arrives while nobody holds it, OctoSense-org/makepad#40). Verified: Omarchy style, a two-tab Terminal group; after Cmd+Alt+Tab text typed runs in the tab now shown, both directions. cargo test --locked -p octosense-shell --features app-terminal: 333 passed; from phone/: --features mobile-apps: 342 passed; cargo check --locked -p octosense. Co-Authored-By: Claude Opus 5.5 --- crates/shell/src/lib.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/shell/src/lib.rs b/crates/shell/src/lib.rs index 6e31769e..669ace35 100644 --- a/crates/shell/src/lib.rs +++ b/crates/shell/src/lib.rs @@ -311,6 +311,12 @@ pub struct App { /// re-asserted when that client's first frame arrives. #[rust] pub pending_focus: Option, + /// The client whose tile last took the keyboard. It lets go before + /// another tile takes it: a module tile never moves key focus itself + /// (its widgets claim it), so without this the hidden tab of a group + /// kept holding the keyboard and every key was dropped. + #[rust] + pub keyboard_holder: Option, /// `--gallery`: the shell-surface gallery instead of a desktop. #[rust] pub gallery: bool, @@ -1702,12 +1708,22 @@ impl App { // Area only after its first draw — a focus at launch time lands on // nothing. Keep it pending and re-assert when the child's first // frame arrives (the PresentableDraw path below). + if let Some(previous) = self.keyboard_holder.filter(|previous| *previous != client) { + let _ = self + .desk(cx) + .borrow_mut::() + .and_then(|mut d| d.with_tile(cx, previous, |cx, v| v.release_keyboard(cx))); + self.keyboard_holder = None; + } let focused = self .desk(cx) .borrow_mut::() .and_then(|mut d| d.with_tile(cx, client, |cx, v| v.focus_keyboard(cx))) .unwrap_or(false); self.pending_focus = if focused { None } else { Some(client) }; + if focused { + self.keyboard_holder = Some(client); + } self.update_bar(cx); self.redraw_all(cx); } @@ -5128,6 +5144,7 @@ impl App { .unwrap_or(false); if focused { self.pending_focus = None; + self.keyboard_holder = Some(client); } } } From a16a9a11668edf04e8b6874ff15b6a2acda1cc0c Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:25:25 -0700 Subject: [PATCH 4/9] chore(apps): delete the native News, Maps and Photos crates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0004 §1 (#110): the script apps are the only versions. The native crates were kept "for comparison" behind `app-news`, `app-photos` and `app-maps`; nothing shipped them. - apps/{news,maps,photos}/native, their workspace members and pins, the shell's optional deps, `app-*` features and place in `mobile-apps`, the desktop and phone forwards, and their linked_modules() lines. The makepad-map-nav pin and patch go with Maps (nothing else used it). - Photos' sample library, which Home mounts into the script app, moves to apps/photos/resources/ with its provenance (SOURCES.md, generated-scenes.json); phone/system-apps.json points there. The native crate's catalog and icons go. - The phone catalogs' native News/Maps/Photos rows, phone.yml's builds and tests of the three crates, phone/docs/{photos,maps}.md, and the README and ADR mentions (both languages). Cargo.lock updated by cargo metadata (four packages removed). Verified: cargo check --locked -p octosense; from phone/: cargo check --locked -p octosense-home --features mobile-apps. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/phone.yml | 9 +- Cargo.lock | 44 - Cargo.toml | 8 - apps/maps/native/Cargo.toml | 31 - apps/maps/native/resources/icons/arrive.svg | 1 - apps/maps/native/resources/icons/back.svg | 1 - apps/maps/native/resources/icons/bike.svg | 1 - apps/maps/native/resources/icons/car.svg | 1 - apps/maps/native/resources/icons/close.svg | 1 - apps/maps/native/resources/icons/compass.svg | 1 - apps/maps/native/resources/icons/depart.svg | 1 - .../native/resources/icons/directions.svg | 1 - apps/maps/native/resources/icons/food.svg | 1 - apps/maps/native/resources/icons/layers.svg | 1 - .../native/resources/icons/locate-active.svg | 1 - apps/maps/native/resources/icons/locate.svg | 1 - apps/maps/native/resources/icons/lodging.svg | 1 - apps/maps/native/resources/icons/place.svg | 1 - apps/maps/native/resources/icons/recenter.svg | 1 - .../native/resources/icons/roundabout.svg | 1 - apps/maps/native/resources/icons/search.svg | 1 - .../native/resources/icons/sharp-left.svg | 1 - .../native/resources/icons/sharp-right.svg | 1 - apps/maps/native/resources/icons/shop.svg | 1 - .../native/resources/icons/slight-left.svg | 1 - .../native/resources/icons/slight-right.svg | 1 - apps/maps/native/resources/icons/straight.svg | 1 - apps/maps/native/resources/icons/street.svg | 1 - apps/maps/native/resources/icons/swap.svg | 1 - apps/maps/native/resources/icons/transit.svg | 1 - .../maps/native/resources/icons/turn-left.svg | 1 - .../native/resources/icons/turn-right.svg | 1 - apps/maps/native/resources/icons/uturn.svg | 1 - apps/maps/native/resources/icons/walk.svg | 1 - apps/maps/native/src/geo.rs | 478 --- apps/maps/native/src/guidance.rs | 480 --- apps/maps/native/src/http.rs | 167 - apps/maps/native/src/http_tests.rs | 249 -- apps/maps/native/src/lib.rs | 33 - apps/maps/native/src/main.rs | 196 - apps/maps/native/src/model.rs | 1423 ------- apps/maps/native/src/module.rs | 164 - apps/maps/native/src/places.rs | 352 -- apps/maps/native/src/routing.rs | 599 --- apps/maps/native/src/sheet.rs | 195 - apps/maps/native/src/test_support.rs | 70 - apps/maps/native/src/view.rs | 3332 ----------------- .../maps/native/tests/fixtures/osrm-bike.json | 253 -- apps/maps/native/tests/fixtures/osrm-car.json | 155 - .../maps/native/tests/fixtures/osrm-foot.json | 729 ---- .../native/tests/fixtures/osrm-noroute.json | 4 - .../native/tests/fixtures/photon-empty.json | 4 - .../native/tests/fixtures/photon-reverse.json | 36 - .../native/tests/fixtures/photon-search.json | 95 - apps/maps/native/tests/fixtures/tls/README.md | 7 - .../tests/fixtures/tls/localhost-key.der | Bin 1219 -> 0 bytes .../native/tests/fixtures/tls/localhost.der | Bin 746 -> 0 bytes apps/news/native/Cargo.toml | 13 - .../native/resources/icons/chevron-left.svg | 1 - .../native/resources/icons/chevron-right.svg | 1 - apps/news/native/resources/icons/close.svg | 1 - apps/news/native/resources/icons/feed.svg | 1 - .../news/native/resources/icons/following.svg | 1 - .../native/resources/icons/google-news.svg | 1 - apps/news/native/resources/icons/hn.svg | 1 - apps/news/native/resources/icons/more.svg | 1 - .../news/native/resources/icons/octosense.svg | 1 - apps/news/native/resources/icons/refresh.svg | 1 - apps/news/native/resources/icons/saved.svg | 1 - apps/news/native/resources/icons/search.svg | 1 - apps/news/native/resources/icons/techmeme.svg | 1 - apps/news/native/resources/icons/today.svg | 1 - apps/news/native/src/ai.rs | 204 - apps/news/native/src/feed.rs | 997 ----- apps/news/native/src/hn.rs | 130 - apps/news/native/src/lib.rs | 18 - apps/news/native/src/main.rs | 179 - apps/news/native/src/model.rs | 1927 ---------- apps/news/native/src/module.rs | 171 - apps/news/native/src/reader.rs | 323 -- apps/news/native/src/test_support.rs | 65 - apps/news/native/src/view.rs | 2027 ---------- apps/news/native/tests/fixtures/atom.xml | 23 - apps/news/native/tests/fixtures/google.xml | 23 - apps/news/native/tests/fixtures/hn.json | 34 - apps/news/native/tests/fixtures/techmeme.xml | 28 - apps/photos/native/Cargo.toml | 12 - apps/photos/native/build.rs | 29 - apps/photos/native/resources/catalog.json | 1203 ------ .../native/resources/icons/chevron-left.svg | 1 - .../native/resources/icons/chevron-right.svg | 1 - apps/photos/native/resources/icons/search.svg | 1 - .../photos/native/resources/stock-photos.json | 871 ----- apps/photos/native/src/lib.rs | 67 - apps/photos/native/src/main.rs | 25 - apps/photos/native/src/model.rs | 213 -- apps/photos/native/src/ui.rs | 207 - apps/photos/native/src/view.rs | 1462 -------- apps/photos/native/src/zoom.rs | 336 -- apps/photos/native/tests/library.rs | 143 - apps/photos/native/tests/ui.rs | 38 - apps/photos/{native => }/resources/SOURCES.md | 2 +- .../resources/generated-scenes.json | 0 .../resources/photos/01-adult-man.png | Bin .../resources/photos/02-adult-woman.png | Bin .../resources/photos/03-older-man.png | Bin .../resources/photos/04-older-woman.png | Bin .../{native => }/resources/photos/05-boy.png | Bin .../{native => }/resources/photos/06-girl.png | Bin .../resources/photos/alex-forest.png | Bin .../photos/alex-noah-lily-stadium-wide.png | Bin .../resources/photos/alex-office.png | Bin .../resources/photos/alex-sofia-cafe.png | Bin .../resources/photos/alex-stadium.png | Bin .../{native => }/resources/photos/alpine.jpg | Bin .../{native => }/resources/photos/beach.jpg | Bin .../{native => }/resources/photos/coast.jpg | Bin .../{native => }/resources/photos/desert.jpg | Bin .../photos/family-four-beach-wide.png | Bin .../{native => }/resources/photos/family.png | Bin .../{native => }/resources/photos/forest.jpg | Bin .../photos/grandparents-children-trail.png | Bin .../resources/photos/james-garden.png | Bin .../resources/photos/james-harbor.png | Bin .../resources/photos/james-library.png | Bin .../resources/photos/james-rose-garden.png | Bin .../{native => }/resources/photos/lake.jpg | Bin .../resources/photos/lily-beach.png | Bin .../resources/photos/lily-meadow.png | Bin .../resources/photos/lily-school.png | Bin .../{native => }/resources/photos/meadow.jpg | Bin .../resources/photos/mountain.jpg | Bin .../resources/photos/night-sky.jpg | Bin .../resources/photos/noah-football.png | Bin .../resources/photos/noah-school.png | Bin .../resources/photos/noah-trail.png | Bin .../{native => }/resources/photos/ocean.jpg | Bin .../resources/photos/rose-garden.png | Bin .../resources/photos/rose-market.png | Bin .../resources/photos/rose-pier.png | Bin .../resources/photos/sofia-beach.png | Bin .../resources/photos/sofia-market.png | Bin .../resources/photos/sofia-office.png | Bin .../photos/sofia-rose-lily-market-wide.png | Bin .../resources/photos/stock-aquarium-fish.jpg | Bin .../photos/stock-bicycle-benches.jpg | Bin .../photos/stock-breakfast-table.jpg | Bin .../resources/photos/stock-bridge-arches.jpg | Bin .../photos/stock-butterfly-wildflowers.jpg | Bin .../photos/stock-city-basketball.jpg | Bin .../resources/photos/stock-city-bicycles.jpg | Bin .../resources/photos/stock-coffee-camera.jpg | Bin .../resources/photos/stock-court-lines.jpg | Bin .../photos/stock-downtown-avenue.jpg | Bin .../resources/photos/stock-elephant-egret.jpg | Bin .../photos/stock-elephant-waterhole.jpg | Bin .../resources/photos/stock-forest-cascade.jpg | Bin .../resources/photos/stock-fruit-market.jpg | Bin .../resources/photos/stock-garden-petals.jpg | Bin .../resources/photos/stock-garden-zinnias.jpg | Bin .../photos/stock-glass-skybridge.jpg | Bin .../photos/stock-hiking-together.jpg | Bin .../resources/photos/stock-home-guitar.jpg | Bin .../resources/photos/stock-koi-pond.jpg | Bin .../photos/stock-library-shelves.jpg | Bin .../resources/photos/stock-morning-coffee.jpg | Bin .../photos/stock-mossy-waterfall.jpg | Bin .../resources/photos/stock-mountain-walk.jpg | Bin .../resources/photos/stock-music-practice.jpg | Bin .../photos/stock-neighborhood-street.jpg | Bin .../resources/photos/stock-pets-at-play.jpg | Bin .../resources/photos/stock-railway-yard.jpg | Bin .../photos/stock-street-companions.jpg | Bin .../resources/photos/stock-summer-fruit.jpg | Bin .../photos/stock-sunset-sailboat.jpg | Bin .../resources/photos/stock-train-journey.jpg | Bin .../resources/photos/sunlit-woods.jpg | Bin .../resources/photos/woodland.jpg | Bin crates/shell/Cargo.toml | 10 +- crates/shell/src/apps.rs | 13 +- desktop/Cargo.toml | 4 - desktop/README.md | 3 +- desktop/README.zh-CN.md | 3 +- ...4-system-apps-are-contained-script-apps.md | 4 +- phone/Cargo.toml | 3 - phone/README.md | 11 +- phone/README.zh-CN.md | 5 +- phone/config/apps.json | 24 - phone/config/apps.makepad.json | 16 - phone/docs/maps.md | 217 -- phone/docs/photos.md | 216 -- phone/system-apps.json | 2 +- 192 files changed, 16 insertions(+), 20445 deletions(-) delete mode 100644 apps/maps/native/Cargo.toml delete mode 100644 apps/maps/native/resources/icons/arrive.svg delete mode 100644 apps/maps/native/resources/icons/back.svg delete mode 100644 apps/maps/native/resources/icons/bike.svg delete mode 100644 apps/maps/native/resources/icons/car.svg delete mode 100644 apps/maps/native/resources/icons/close.svg delete mode 100644 apps/maps/native/resources/icons/compass.svg delete mode 100644 apps/maps/native/resources/icons/depart.svg delete mode 100644 apps/maps/native/resources/icons/directions.svg delete mode 100644 apps/maps/native/resources/icons/food.svg delete mode 100644 apps/maps/native/resources/icons/layers.svg delete mode 100644 apps/maps/native/resources/icons/locate-active.svg delete mode 100644 apps/maps/native/resources/icons/locate.svg delete mode 100644 apps/maps/native/resources/icons/lodging.svg delete mode 100644 apps/maps/native/resources/icons/place.svg delete mode 100644 apps/maps/native/resources/icons/recenter.svg delete mode 100644 apps/maps/native/resources/icons/roundabout.svg delete mode 100644 apps/maps/native/resources/icons/search.svg delete mode 100644 apps/maps/native/resources/icons/sharp-left.svg delete mode 100644 apps/maps/native/resources/icons/sharp-right.svg delete mode 100644 apps/maps/native/resources/icons/shop.svg delete mode 100644 apps/maps/native/resources/icons/slight-left.svg delete mode 100644 apps/maps/native/resources/icons/slight-right.svg delete mode 100644 apps/maps/native/resources/icons/straight.svg delete mode 100644 apps/maps/native/resources/icons/street.svg delete mode 100644 apps/maps/native/resources/icons/swap.svg delete mode 100644 apps/maps/native/resources/icons/transit.svg delete mode 100644 apps/maps/native/resources/icons/turn-left.svg delete mode 100644 apps/maps/native/resources/icons/turn-right.svg delete mode 100644 apps/maps/native/resources/icons/uturn.svg delete mode 100644 apps/maps/native/resources/icons/walk.svg delete mode 100644 apps/maps/native/src/geo.rs delete mode 100644 apps/maps/native/src/guidance.rs delete mode 100644 apps/maps/native/src/http.rs delete mode 100644 apps/maps/native/src/http_tests.rs delete mode 100644 apps/maps/native/src/lib.rs delete mode 100644 apps/maps/native/src/main.rs delete mode 100644 apps/maps/native/src/model.rs delete mode 100644 apps/maps/native/src/module.rs delete mode 100644 apps/maps/native/src/places.rs delete mode 100644 apps/maps/native/src/routing.rs delete mode 100644 apps/maps/native/src/sheet.rs delete mode 100644 apps/maps/native/src/test_support.rs delete mode 100644 apps/maps/native/src/view.rs delete mode 100644 apps/maps/native/tests/fixtures/osrm-bike.json delete mode 100644 apps/maps/native/tests/fixtures/osrm-car.json delete mode 100644 apps/maps/native/tests/fixtures/osrm-foot.json delete mode 100644 apps/maps/native/tests/fixtures/osrm-noroute.json delete mode 100644 apps/maps/native/tests/fixtures/photon-empty.json delete mode 100644 apps/maps/native/tests/fixtures/photon-reverse.json delete mode 100644 apps/maps/native/tests/fixtures/photon-search.json delete mode 100644 apps/maps/native/tests/fixtures/tls/README.md delete mode 100644 apps/maps/native/tests/fixtures/tls/localhost-key.der delete mode 100644 apps/maps/native/tests/fixtures/tls/localhost.der delete mode 100644 apps/news/native/Cargo.toml delete mode 100644 apps/news/native/resources/icons/chevron-left.svg delete mode 100644 apps/news/native/resources/icons/chevron-right.svg delete mode 100644 apps/news/native/resources/icons/close.svg delete mode 100644 apps/news/native/resources/icons/feed.svg delete mode 100644 apps/news/native/resources/icons/following.svg delete mode 100644 apps/news/native/resources/icons/google-news.svg delete mode 100644 apps/news/native/resources/icons/hn.svg delete mode 100644 apps/news/native/resources/icons/more.svg delete mode 100644 apps/news/native/resources/icons/octosense.svg delete mode 100644 apps/news/native/resources/icons/refresh.svg delete mode 100644 apps/news/native/resources/icons/saved.svg delete mode 100644 apps/news/native/resources/icons/search.svg delete mode 100644 apps/news/native/resources/icons/techmeme.svg delete mode 100644 apps/news/native/resources/icons/today.svg delete mode 100644 apps/news/native/src/ai.rs delete mode 100644 apps/news/native/src/feed.rs delete mode 100644 apps/news/native/src/hn.rs delete mode 100644 apps/news/native/src/lib.rs delete mode 100644 apps/news/native/src/main.rs delete mode 100644 apps/news/native/src/model.rs delete mode 100644 apps/news/native/src/module.rs delete mode 100644 apps/news/native/src/reader.rs delete mode 100644 apps/news/native/src/test_support.rs delete mode 100644 apps/news/native/src/view.rs delete mode 100644 apps/news/native/tests/fixtures/atom.xml delete mode 100644 apps/news/native/tests/fixtures/google.xml delete mode 100644 apps/news/native/tests/fixtures/hn.json delete mode 100644 apps/news/native/tests/fixtures/techmeme.xml delete mode 100644 apps/photos/native/Cargo.toml delete mode 100644 apps/photos/native/build.rs delete mode 100644 apps/photos/native/resources/catalog.json delete mode 100644 apps/photos/native/resources/icons/chevron-left.svg delete mode 100644 apps/photos/native/resources/icons/chevron-right.svg delete mode 100644 apps/photos/native/resources/icons/search.svg delete mode 100644 apps/photos/native/resources/stock-photos.json delete mode 100644 apps/photos/native/src/lib.rs delete mode 100644 apps/photos/native/src/main.rs delete mode 100644 apps/photos/native/src/model.rs delete mode 100644 apps/photos/native/src/ui.rs delete mode 100644 apps/photos/native/src/view.rs delete mode 100644 apps/photos/native/src/zoom.rs delete mode 100644 apps/photos/native/tests/library.rs delete mode 100644 apps/photos/native/tests/ui.rs rename apps/photos/{native => }/resources/SOURCES.md (97%) rename apps/photos/{native => }/resources/generated-scenes.json (100%) rename apps/photos/{native => }/resources/photos/01-adult-man.png (100%) rename apps/photos/{native => }/resources/photos/02-adult-woman.png (100%) rename apps/photos/{native => }/resources/photos/03-older-man.png (100%) rename apps/photos/{native => }/resources/photos/04-older-woman.png (100%) rename apps/photos/{native => }/resources/photos/05-boy.png (100%) rename apps/photos/{native => }/resources/photos/06-girl.png (100%) rename apps/photos/{native => }/resources/photos/alex-forest.png (100%) rename apps/photos/{native => }/resources/photos/alex-noah-lily-stadium-wide.png (100%) rename apps/photos/{native => }/resources/photos/alex-office.png (100%) rename apps/photos/{native => }/resources/photos/alex-sofia-cafe.png (100%) rename apps/photos/{native => }/resources/photos/alex-stadium.png (100%) rename apps/photos/{native => }/resources/photos/alpine.jpg (100%) rename apps/photos/{native => }/resources/photos/beach.jpg (100%) rename apps/photos/{native => }/resources/photos/coast.jpg (100%) rename apps/photos/{native => }/resources/photos/desert.jpg (100%) rename apps/photos/{native => }/resources/photos/family-four-beach-wide.png (100%) rename apps/photos/{native => }/resources/photos/family.png (100%) rename apps/photos/{native => }/resources/photos/forest.jpg (100%) rename apps/photos/{native => }/resources/photos/grandparents-children-trail.png (100%) rename apps/photos/{native => }/resources/photos/james-garden.png (100%) rename apps/photos/{native => }/resources/photos/james-harbor.png (100%) rename apps/photos/{native => }/resources/photos/james-library.png (100%) rename apps/photos/{native => }/resources/photos/james-rose-garden.png (100%) rename apps/photos/{native => }/resources/photos/lake.jpg (100%) rename apps/photos/{native => }/resources/photos/lily-beach.png (100%) rename apps/photos/{native => }/resources/photos/lily-meadow.png (100%) rename apps/photos/{native => }/resources/photos/lily-school.png (100%) rename apps/photos/{native => }/resources/photos/meadow.jpg (100%) rename apps/photos/{native => }/resources/photos/mountain.jpg (100%) rename apps/photos/{native => }/resources/photos/night-sky.jpg (100%) rename apps/photos/{native => }/resources/photos/noah-football.png (100%) rename apps/photos/{native => }/resources/photos/noah-school.png (100%) rename apps/photos/{native => }/resources/photos/noah-trail.png (100%) rename apps/photos/{native => }/resources/photos/ocean.jpg (100%) rename apps/photos/{native => }/resources/photos/rose-garden.png (100%) rename apps/photos/{native => }/resources/photos/rose-market.png (100%) rename apps/photos/{native => }/resources/photos/rose-pier.png (100%) rename apps/photos/{native => }/resources/photos/sofia-beach.png (100%) rename apps/photos/{native => }/resources/photos/sofia-market.png (100%) rename apps/photos/{native => }/resources/photos/sofia-office.png (100%) rename apps/photos/{native => }/resources/photos/sofia-rose-lily-market-wide.png (100%) rename apps/photos/{native => }/resources/photos/stock-aquarium-fish.jpg (100%) rename apps/photos/{native => }/resources/photos/stock-bicycle-benches.jpg (100%) rename apps/photos/{native => }/resources/photos/stock-breakfast-table.jpg (100%) rename apps/photos/{native => }/resources/photos/stock-bridge-arches.jpg (100%) rename apps/photos/{native => }/resources/photos/stock-butterfly-wildflowers.jpg (100%) rename apps/photos/{native => }/resources/photos/stock-city-basketball.jpg (100%) rename apps/photos/{native => }/resources/photos/stock-city-bicycles.jpg (100%) rename apps/photos/{native => }/resources/photos/stock-coffee-camera.jpg (100%) rename apps/photos/{native => }/resources/photos/stock-court-lines.jpg (100%) rename apps/photos/{native => }/resources/photos/stock-downtown-avenue.jpg (100%) rename apps/photos/{native => }/resources/photos/stock-elephant-egret.jpg (100%) rename apps/photos/{native => }/resources/photos/stock-elephant-waterhole.jpg (100%) rename apps/photos/{native => }/resources/photos/stock-forest-cascade.jpg (100%) rename apps/photos/{native => }/resources/photos/stock-fruit-market.jpg (100%) rename apps/photos/{native => }/resources/photos/stock-garden-petals.jpg (100%) rename apps/photos/{native => }/resources/photos/stock-garden-zinnias.jpg (100%) rename apps/photos/{native => }/resources/photos/stock-glass-skybridge.jpg (100%) rename apps/photos/{native => }/resources/photos/stock-hiking-together.jpg (100%) rename apps/photos/{native => }/resources/photos/stock-home-guitar.jpg (100%) rename apps/photos/{native => }/resources/photos/stock-koi-pond.jpg (100%) rename apps/photos/{native => }/resources/photos/stock-library-shelves.jpg (100%) rename apps/photos/{native => }/resources/photos/stock-morning-coffee.jpg (100%) rename apps/photos/{native => }/resources/photos/stock-mossy-waterfall.jpg (100%) rename apps/photos/{native => }/resources/photos/stock-mountain-walk.jpg (100%) rename apps/photos/{native => }/resources/photos/stock-music-practice.jpg (100%) rename apps/photos/{native => }/resources/photos/stock-neighborhood-street.jpg (100%) rename apps/photos/{native => }/resources/photos/stock-pets-at-play.jpg (100%) rename apps/photos/{native => }/resources/photos/stock-railway-yard.jpg (100%) rename apps/photos/{native => }/resources/photos/stock-street-companions.jpg (100%) rename apps/photos/{native => }/resources/photos/stock-summer-fruit.jpg (100%) rename apps/photos/{native => }/resources/photos/stock-sunset-sailboat.jpg (100%) rename apps/photos/{native => }/resources/photos/stock-train-journey.jpg (100%) rename apps/photos/{native => }/resources/photos/sunlit-woods.jpg (100%) rename apps/photos/{native => }/resources/photos/woodland.jpg (100%) delete mode 100644 phone/docs/maps.md delete mode 100644 phone/docs/photos.md diff --git a/.github/workflows/phone.yml b/.github/workflows/phone.yml index cc768b27..29383f8d 100644 --- a/.github/workflows/phone.yml +++ b/.github/workflows/phone.yml @@ -61,7 +61,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 @@ -73,7 +73,7 @@ jobs: - 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 +81,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/Cargo.lock b/Cargo.lock index d3ccfe95..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" @@ -7459,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" @@ -7500,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" @@ -7541,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 6a822263..70cb41e4 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", @@ -70,7 +67,6 @@ makepad-strict-json = { git = "https://github.com/OctoSense-org/makepad.git", re makepad-app-module = { git = "https://github.com/OctoSense-org/makepad.git", rev = "f08eec5378fa3f83902a2794dacda03e829a0ef0" } makepad-terminal = { 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 @@ -117,9 +113,6 @@ 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" } @@ -199,7 +192,6 @@ makepad-strict-json = { path = ".sources/makepad/libs/strict_json" } makepad-app-module = { path = ".sources/makepad/libs/app_module" } makepad-terminal = { path = ".sources/makepad/apps/terminal" } 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. 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 f6d71fd87bc2c1ec7ef2d14be26f281d215d0bc6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1219 zcmV;!1U&mNf&{+;0RS)!1_>&LNQUrsW5^Br2+u}0)hbn0Qkm0@1S@! z94{AY%GXK}@_>h#rOM8tag+Ft#;5E|jVp-8+|eKibQ&>xNXNxqmm;zYd82pr!ymz8 z{4*5RKA4j;)ZUIC-*vb{42KJL&H)i>MyWu9w6A=z|bveebW9G?C zVgVJ5o@U|7ujw?ocHLlk5vVcUwfoq?r(=V#xfc@PFW9$!O2I01uL1)B009Dm0RRpi z0X7!?deq#15t-~*(P!3<+R~#8(f(_Upj`OP*%9$HQ$yFmUwLM`1L=Po-CyaU{4lI* zbI;rlR-(!)7Ib>2^t3v8faH^@leO*)-g?C)%}!_C$L?Jt*Li7SO5jR#DbJJUZnx9T zL*SI_SO`lB2Wq7|7}8$ZRX&szYqL8-k;$^i#In*r=k1yzTZ?Q z?8&+t&0@$@5i#qZ-%T9Mw1*9DtPbnwsWEHv>4AL_hkX<5+cuy8vHovgb>BxdLMMI8%qtV5A@wr zAhWH;0&xvARW;hT2wKqYN1GuX$}5=P^cSK*ZPq!oso_k2lJBz?WN|jBA56{DiJ_@C3bkTWXuI@ z#YPZy#ztXh$y`oVZ=w7ZIxaU+b0IH8ZYZY9qa=mdb_1V!pycfW{OnqaK%?+DuMuC% zcN=wlnXP<<3sWifLg9KNCc8ns9t6LlZ36;tl zZOsCKfdKtjw_Kp`24Uocp#G|P5=D~uPiZ(_x^JdG;MKa7pE~PG&O6CKbjHp*9k7#& zV8%8KYwNO-#{v>`T`u;(ivD_yXVi8fJ_L*>?5NEY5M!1A%bh3|eJp hZUTO%keUpIehhZ+iaL|HS2WD%23~KXa)(`OUjnf7Q@a2F 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 cc3439ba1e3de6a2bb9f593ccf4ad73adc3bc7ca..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 746 zcmXqLVtQuK#CUE2GZP~d6DLEP(~=I)b^Wmhylk9WZ60mkc^MhGSs4sO4228?*qB3E zn0YvJ@{<#DGV+T{4CKUljm!)zjf@S=jSY>Bqr`cQL0khU7l*DUMkQq98Ce;an;7{S zfX?7zYGPz$_;JkP{elWp8GZ4rQ`fx&KQ*+^Tzcxka2{K4cu#v<43X3RO%!~|-i>(bT4Mcz;E-T2wYrx%v6zV)6QDH{L|13-f z3it+4)5K(Yj=dn*V=E+2}Rd}?w6;O zJk;(#)u(%E@4qz--VffhD=#p*snlhmRy`rj_VKGcc~{mKuQiw#ukVWOtEglTT;2LB zq@Yl(I{MJE{Y?(i@k>^x&OX_c|K_r`J1b9KaL)b~Gl_@kD#l@d?5d``Q9rRIc{0aJ z@eNjNhm2F(Crk_dA^1As=s$5ZmyjsQ+@70 z$eL`&<~tkbn!gAO*w*{wnEvyl+;ZRkv9I{KwQ+Upj~wmwJ~h*-nRf(BZ3(Y3XRpvQ}o4v{$p|g*H1G% 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("")?.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!(""); - 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("