From e1d32c9cda205a2b87e23bdc2a707cf42a281d52 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Mon, 17 Aug 2026 04:40:08 +0200 Subject: [PATCH] feat(windows): host MapView in a XAML Island --- Cargo.lock | 50 +- changelog.d/8270-windows-mapview.md | 12 + crates/perry-ui-windows/Cargo.toml | 18 + crates/perry-ui-windows/src/app.rs | 3 + .../perry-ui-windows/src/ffi/rich_pdf_map.rs | 5 +- .../perry-ui-windows/src/widgets/map_view.rs | 493 ++++++++++++++---- .../compile/link/windows_app.manifest | 10 + .../commands/compile/windows_link_tests.rs | 14 + docs/src/ui/widgets.md | 10 +- 9 files changed, 507 insertions(+), 108 deletions(-) create mode 100644 changelog.d/8270-windows-mapview.md diff --git a/Cargo.lock b/Cargo.lock index 29ad8e0e54..f01cc489d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6495,7 +6495,8 @@ dependencies = [ "png", "qrcodegen", "webview2-com", - "windows", + "windows 0.35.0", + "windows 0.62.2", "windows-core", ] @@ -9976,7 +9977,7 @@ checksum = "3f89fca7a704cee10dcb3654c1dbb8941d1783132f1917358af75bec37a7d7e6" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.62.2", "windows-core", ] @@ -9998,7 +9999,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3a07132775117d6065853d9d1178157b8c90e228de47129d6bce2c7edebedfb" dependencies = [ "thiserror 2.0.18", - "windows", + "windows 0.62.2", "windows-core", ] @@ -10060,6 +10061,19 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08746b4b7ac95f708b3cccceb97b7f9a21a8916dd47fc99b0e6aaf7208f26fd7" +dependencies = [ + "windows_aarch64_msvc 0.35.0", + "windows_i686_gnu 0.35.0", + "windows_i686_msvc 0.35.0", + "windows_x86_64_gnu 0.35.0", + "windows_x86_64_msvc 0.35.0", +] + [[package]] name = "windows" version = "0.62.2" @@ -10322,6 +10336,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db3bc5134e8ce0da5d64dcec3529793f1d33aee5a51fc2b4662e0f881dd463e6" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -10346,6 +10366,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0343a6f35bf43a07b009b8591b78b10ea03de86b06f48e28c96206cd0f453b50" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -10382,6 +10408,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1acdcbf4ca63d8e7a501be86fee744347186275ec2754d129ddeab7a1e3a02e4" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -10406,6 +10438,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "893c0924c5a990ec73cd2264d1c0cba1773a929e1a3f5dbccffd769f8c4edebb" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -10454,6 +10492,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a29bd61f32889c822c99a8fdf2e93378bd2fae4d7efd2693fab09fcaaf7eff4b" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" diff --git a/changelog.d/8270-windows-mapview.md b/changelog.d/8270-windows-mapview.md new file mode 100644 index 0000000000..9e9262a021 --- /dev/null +++ b/changelog.d/8270-windows-mapview.md @@ -0,0 +1,12 @@ +### feat(windows): render MapView with a native MapControl + +Windows `MapView` now replaces its text placeholder with a native +`Windows.UI.Xaml.Controls.Maps.MapControl` hosted in a XAML Island. The existing +cross-platform API drives the native center and zoom, titled `MapIcon` pins, +pin clearing, and Road/Aerial/AerialWithRoads styles. The island follows Perry's +Win32 layout and message loop, and is released with its host widget. + +Perry's embedded Windows UI manifest now opts unpackaged applications into the +Windows 10 1903 compatibility context required by XAML Islands. Map-service-token +setup through `PERRY_MAP_SERVICE_TOKEN` and `PERRY_BING_MAPS_KEY` is documented. +Fixes #559. diff --git a/crates/perry-ui-windows/Cargo.toml b/crates/perry-ui-windows/Cargo.toml index 1d4b5a6323..408b07b532 100644 --- a/crates/perry-ui-windows/Cargo.toml +++ b/crates/perry-ui-windows/Cargo.toml @@ -89,5 +89,23 @@ windows = { version = "0.62", features = [ "Win32_Networking_WinHttp", ] } +# UWP XAML Islands + MapControl (#559). The current windows crate no longer +# projects Windows.UI.Xaml, so keep the last Microsoft-generated projection +# that does, under an explicit alias. It is isolated to map_view.rs; HWNDs are +# bridged through their raw pointer value so no windows-core types cross the +# version boundary. +windows-xaml = { package = "windows", version = "0.35", features = [ + "Foundation", + "Foundation_Collections", + "Devices_Geolocation", + "UI_Xaml", + "UI_Xaml_Controls", + "UI_Xaml_Controls_Maps", + "UI_Xaml_Hosting", + "Win32_Foundation", + "Win32_System_WinRT_Xaml", + "Win32_UI_WindowsAndMessaging", +] } + [features] geisterhand = [] diff --git a/crates/perry-ui-windows/src/app.rs b/crates/perry-ui-windows/src/app.rs index 58ebd49348..0a77fbe5bd 100644 --- a/crates/perry-ui-windows/src/app.rs +++ b/crates/perry-ui-windows/src/app.rs @@ -309,6 +309,9 @@ unsafe fn dispatch_ui_message(msg: &MSG) { }); return; } + if crate::widgets::map_view::pre_translate_message(msg) { + return; + } if (msg.message == WM_KEYDOWN || msg.message == WM_SYSKEYDOWN) && try_handle_shortcut(msg.wParam.0 as u16) { diff --git a/crates/perry-ui-windows/src/ffi/rich_pdf_map.rs b/crates/perry-ui-windows/src/ffi/rich_pdf_map.rs index ebf1967e2f..03aad02226 100644 --- a/crates/perry-ui-windows/src/ffi/rich_pdf_map.rs +++ b/crates/perry-ui-windows/src/ffi/rich_pdf_map.rs @@ -63,10 +63,7 @@ pub extern "C" fn perry_ui_pdf_view_set_scale(h: i64, s: f64) { widgets::pdf_view::set_scale(h, s) } -// MapView (#517 / #559) — Win32 stub-with-state. STATIC label shows -// the current region + pin count. Real WinUI MapControl in XAML -// Islands needs Windows App SDK + WinUI 3 stack + Bing Maps API key -// — tracked under #559 as multi-day follow-up. +// MapView (#517 / #559) — native MapControl hosted in a XAML Island. #[no_mangle] pub extern "C" fn perry_ui_map_view_create(w: f64, h: f64) -> i64 { widgets::map_view::create(w, h) diff --git a/crates/perry-ui-windows/src/widgets/map_view.rs b/crates/perry-ui-windows/src/widgets/map_view.rs index e70bb85187..d65919fb87 100644 --- a/crates/perry-ui-windows/src/widgets/map_view.rs +++ b/crates/perry-ui-windows/src/widgets/map_view.rs @@ -1,44 +1,44 @@ -//! MapView widget — Win32 stub-with-state implementation (#559). +//! Native Windows MapView (#559). //! -//! Per #559's scope notes, the right Windows backend is the WinUI -//! `Windows.UI.Xaml.Controls.Maps.MapControl` hosted in a XAML Island -//! (`DesktopWindowXamlSource`) parented to the Perry HWND chain. That -//! requires the Windows App SDK + WinUI 3 stack as a hard dependency -//! and a Bing Maps API key from the user — significant new -//! infrastructure that doesn't fit a single-session sweep. -//! -//! v1 ships the same FFI shape the issue specifies (`set_region`, -//! `add_pin`, `clear_pins`, `set_map_type`) backed by a real STATIC -//! widget that displays the current region + pin list as text: -//! `[Map @ 37.78,-122.42 — span 0.05×0.05 — 3 pins]`. Layout takes -//! the requested space; values from setters visibly update the -//! label so user code's nav flow exercises the API. -//! -//! Real WinUI / MapLibre / WebView2-MapLibre paths land in a -//! follow-up — tracked under #559. Mirrors the v0.5.771 GTK4-audit -//! "stubs matching the macOS shape exactly for link stability" -//! pattern documented for tabbar / vbox / etc. +//! The registered Perry widget remains an ordinary child HWND so it participates +//! in the existing Win32 layout tree. A `DesktopWindowXamlSource` attaches a +//! XAML Island to that host and renders `Windows.UI.Xaml.Controls.Maps.MapControl` +//! inside it. The source, map, and native interop interface are retained for the +//! widget lifetime; a host-window subclass keeps the island sized to its parent. use std::cell::RefCell; use std::collections::HashMap; +#[cfg(target_os = "windows")] +use windows::core::PCWSTR; #[cfg(target_os = "windows")] use windows::Win32::Foundation::*; #[cfg(target_os = "windows")] +use windows::Win32::System::Com::{CoInitializeEx, COINIT_APARTMENTTHREADED}; +#[cfg(target_os = "windows")] use windows::Win32::System::LibraryLoader::GetModuleHandleW; #[cfg(target_os = "windows")] use windows::Win32::System::SystemServices::SS_CENTER; #[cfg(target_os = "windows")] +use windows::Win32::UI::Shell::{DefSubclassProc, RemoveWindowSubclass, SetWindowSubclass}; +#[cfg(target_os = "windows")] use windows::Win32::UI::WindowsAndMessaging::*; +#[cfg(target_os = "windows")] +use windows_xaml::core::{Interface, HSTRING}; +#[cfg(target_os = "windows")] +use windows_xaml::Devices::Geolocation::{BasicGeoposition, Geopoint}; +#[cfg(target_os = "windows")] +use windows_xaml::Win32::System::WinRT::Xaml::IDesktopWindowXamlSourceNative2; +#[cfg(target_os = "windows")] +use windows_xaml::UI::Xaml::Controls::Maps::{MapControl, MapElement, MapIcon, MapStyle}; +#[cfg(target_os = "windows")] +use windows_xaml::UI::Xaml::Hosting::{DesktopWindowXamlSource, WindowsXamlManager}; + use super::{alloc_control_id, register_widget, WidgetKind}; -#[derive(Clone)] -struct Pin { - lat: f64, - lon: f64, - title: String, -} +#[cfg(target_os = "windows")] +const MAP_SUBCLASS_ID: usize = 0x5045_5252_595F_4D41; struct MapState { lat: f64, @@ -46,11 +46,36 @@ struct MapState { lat_span: f64, lon_span: f64, map_type: i64, - pins: Vec, + pin_count: usize, + init_error: Option, + #[cfg(target_os = "windows")] + backend: Option, +} + +#[cfg(target_os = "windows")] +struct XamlMapBackend { + // Retaining the source owns the island and its content. + source: DesktopWindowXamlSource, + native: IDesktopWindowXamlSourceNative2, + map: MapControl, + island_hwnd: HWND, +} + +#[cfg(target_os = "windows")] +impl Drop for XamlMapBackend { + fn drop(&mut self) { + let _ = self.source.Close(); + } } thread_local! { static MAPS: RefCell> = RefCell::new(HashMap::new()); + #[cfg(target_os = "windows")] + static HWND_TO_HANDLE: RefCell> = RefCell::new(HashMap::new()); + // Every manager holds a reference to the per-thread XAML framework. Keep + // one alive for as long as Perry's UI thread can own MapViews. + #[cfg(target_os = "windows")] + static XAML_MANAGER: RefCell> = const { RefCell::new(None) }; } #[cfg(target_os = "windows")] @@ -70,6 +95,119 @@ fn str_from_header(ptr: *const u8) -> String { } } +/// Convert a MapKit-style latitude/longitude span to the corresponding map +/// zoom. This matches Perry's GTK4 backend and clamps to MapControl's range. +fn zoom_from_span(lat_span: f64, lon_span: f64) -> f64 { + if !lat_span.is_finite() || !lon_span.is_finite() { + return 20.0; + } + let span = lat_span.abs().max(lon_span.abs()); + if span <= 0.0 { + return 20.0; + } + (360.0 / span).log2().clamp(1.0, 20.0) +} + +#[cfg(target_os = "windows")] +fn xaml_map_style(style: i64) -> MapStyle { + match style { + 1 => MapStyle::Aerial, + 2 => MapStyle::AerialWithRoads, + _ => MapStyle::Road, + } +} + +#[cfg(target_os = "windows")] +fn geopoint(lat: f64, lon: f64) -> windows_xaml::core::Result { + Geopoint::Create(BasicGeoposition { + Latitude: lat.clamp(-90.0, 90.0), + Longitude: lon.clamp(-180.0, 180.0), + Altitude: 0.0, + }) +} + +#[cfg(target_os = "windows")] +fn ensure_xaml_initialized() -> Result<(), String> { + // XAML Islands require an STA. CoInitializeEx is idempotent for a thread + // already initialized in the same apartment model. + unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) } + .ok() + .map_err(|err| format!("COM STA initialization failed: {err}"))?; + + XAML_MANAGER.with(|slot| { + if slot.borrow().is_some() { + return Ok(()); + } + let manager = WindowsXamlManager::InitializeForCurrentThread() + .map_err(|err| format!("XAML initialization failed: {err}"))?; + *slot.borrow_mut() = Some(manager); + Ok(()) + }) +} + +#[cfg(target_os = "windows")] +fn configured_map_token() -> Option { + // PERRY_MAP_SERVICE_TOKEN is the cross-version name. Keep the explicit + // Bing alias for existing Windows/UWP deployments. + std::env::var("PERRY_MAP_SERVICE_TOKEN") + .ok() + .filter(|value| !value.trim().is_empty()) + .or_else(|| { + std::env::var("PERRY_BING_MAPS_KEY") + .ok() + .filter(|value| !value.trim().is_empty()) + }) +} + +#[cfg(target_os = "windows")] +fn create_xaml_backend(host: HWND, width: i32, height: i32) -> Result { + ensure_xaml_initialized()?; + + // Create/attach the source before constructing UIElement-derived content; + // this is the ordering required by the XAML hosting API. + let source = DesktopWindowXamlSource::new() + .map_err(|err| format!("DesktopWindowXamlSource creation failed: {err}"))?; + let native: IDesktopWindowXamlSourceNative2 = source + .cast() + .map_err(|err| format!("XAML Island interop unavailable: {err}"))?; + let xaml_host = windows_xaml::Win32::Foundation::HWND(host.0 as isize); + unsafe { native.AttachToWindow(xaml_host) } + .map_err(|err| format!("XAML Island attachment failed: {err}"))?; + + let island = unsafe { native.WindowHandle() } + .map_err(|err| format!("XAML Island HWND lookup failed: {err}"))?; + let island_hwnd = HWND(island.0 as *mut _); + + let map = MapControl::new().map_err(|err| format!("MapControl creation failed: {err}"))?; + if let Some(token) = configured_map_token() { + map.SetMapServiceToken(HSTRING::from(token)) + .map_err(|err| format!("MapControl token setup failed: {err}"))?; + } + source + .SetContent(&map) + .map_err(|err| format!("MapControl hosting failed: {err}"))?; + + unsafe { + SetWindowPos( + island_hwnd, + None, + 0, + 0, + width.max(1), + height.max(1), + SWP_NOACTIVATE | SWP_NOZORDER | SWP_SHOWWINDOW, + ) + } + .map_err(|err| format!("XAML Island sizing failed: {err}"))?; + + Ok(XamlMapBackend { + source, + native, + map, + island_hwnd, + }) +} + pub fn create(width: f64, height: f64) -> i64 { let control_id = alloc_control_id(); let w = if width > 0.0 { width as i32 } else { 400 }; @@ -78,14 +216,16 @@ pub fn create(width: f64, height: f64) -> i64 { #[cfg(target_os = "windows")] { let class_name = to_wide("STATIC"); - let window_text = to_wide("[Map — region not set]"); - unsafe { + let window_text = to_wide("[Map - initializing native control]"); + let host = unsafe { let hinstance = GetModuleHandleW(None).unwrap(); - let hwnd = CreateWindowExW( + CreateWindowExW( WINDOW_EX_STYLE::default(), - windows::core::PCWSTR(class_name.as_ptr()), - windows::core::PCWSTR(window_text.as_ptr()), - WINDOW_STYLE(WS_CHILD.0 | WS_VISIBLE.0 | WS_BORDER.0 | SS_CENTER.0), + PCWSTR(class_name.as_ptr()), + PCWSTR(window_text.as_ptr()), + WINDOW_STYLE( + WS_CHILD.0 | WS_VISIBLE.0 | WS_CLIPCHILDREN.0 | WS_CLIPSIBLINGS.0 | SS_CENTER.0, + ), 0, 0, w, @@ -95,32 +235,43 @@ pub fn create(width: f64, height: f64) -> i64 { Some(HINSTANCE::from(hinstance)), None, ) - .unwrap(); - - let handle = register_widget(hwnd, WidgetKind::Image, control_id); - MAPS.with(|m| { - m.borrow_mut().insert( - handle, - MapState { - lat: 0.0, - lon: 0.0, - lat_span: 0.0, - lon_span: 0.0, - map_type: 0, - pins: Vec::new(), - }, - ); - }); - handle } + .unwrap(); + + let handle = register_widget(host, WidgetKind::Image, control_id); + let backend = create_xaml_backend(host, w, h); + let init_error = backend.as_ref().err().cloned(); + MAPS.with(|maps| { + maps.borrow_mut().insert( + handle, + MapState { + lat: 0.0, + lon: 0.0, + lat_span: 0.0, + lon_span: 0.0, + map_type: 0, + pin_count: 0, + init_error, + backend: backend.ok(), + }, + ); + }); + HWND_TO_HANDLE.with(|map| { + map.borrow_mut().insert(host.0 as isize, handle); + }); + unsafe { + let _ = SetWindowSubclass(host, Some(map_host_subclass_proc), MAP_SUBCLASS_ID, 0); + } + refresh_placeholder(handle); + handle } #[cfg(not(target_os = "windows"))] { let _ = (w, h); let handle = register_widget(0, WidgetKind::Image, control_id); - MAPS.with(|m| { - m.borrow_mut().insert( + MAPS.with(|maps| { + maps.borrow_mut().insert( handle, MapState { lat: 0.0, @@ -128,7 +279,8 @@ pub fn create(width: f64, height: f64) -> i64 { lat_span: 0.0, lon_span: 0.0, map_type: 0, - pins: Vec::new(), + pin_count: 0, + init_error: Some("native MapControl is only available on Windows".to_string()), }, ); }); @@ -136,41 +288,32 @@ pub fn create(width: f64, height: f64) -> i64 { } } -fn refresh_label(handle: i64) { +fn refresh_placeholder(handle: i64) { #[cfg(target_os = "windows")] { - let display = MAPS.with(|m| { - m.borrow().get(&handle).map(|state| { - let map_type_name = match state.map_type { - 1 => "Aerial", - 2 => "Hybrid", - _ => "Standard", - }; - if state.lat_span == 0.0 && state.lon_span == 0.0 { - format!( - "[Map ({}) — region not set — {} pins]", - map_type_name, - state.pins.len() - ) - } else { - format!( - "[Map ({}) @ {:.4},{:.4} — span {:.3}×{:.3} — {} pins]", - map_type_name, - state.lat, - state.lon, - state.lat_span, - state.lon_span, - state.pins.len() - ) - } - }) + let display = MAPS.with(|maps| { + let maps = maps.borrow(); + let state = maps.get(&handle)?; + if state.backend.is_some() { + return None; + } + if let Some(error) = &state.init_error { + return Some(format!("[Map unavailable: {error}]")); + } + let map_type = match state.map_type { + 1 => "Aerial", + 2 => "Aerial with roads", + _ => "Road", + }; + Some(format!( + "[Map ({map_type}) @ {:.4},{:.4} - span {:.3}x{:.3} - {} pins]", + state.lat, state.lon, state.lat_span, state.lon_span, state.pin_count + )) }); - if let Some(text) = display { - if let Some(hwnd) = super::get_hwnd(handle) { - let wide = to_wide(&text); - unsafe { - let _ = SetWindowTextW(hwnd, windows::core::PCWSTR(wide.as_ptr())); - } + if let (Some(text), Some(hwnd)) = (display, super::get_hwnd(handle)) { + let wide = to_wide(&text); + unsafe { + let _ = SetWindowTextW(hwnd, PCWSTR(wide.as_ptr())); } } } @@ -181,41 +324,195 @@ fn refresh_label(handle: i64) { } pub fn set_region(handle: i64, lat: f64, lon: f64, lat_span: f64, lon_span: f64) { - MAPS.with(|m| { - if let Some(state) = m.borrow_mut().get_mut(&handle) { + #[cfg(target_os = "windows")] + let map = MAPS.with(|maps| { + let mut maps = maps.borrow_mut(); + let state = maps.get_mut(&handle)?; + state.lat = lat; + state.lon = lon; + state.lat_span = lat_span; + state.lon_span = lon_span; + state.backend.as_ref().map(|backend| backend.map.clone()) + }); + #[cfg(not(target_os = "windows"))] + MAPS.with(|maps| { + if let Some(state) = maps.borrow_mut().get_mut(&handle) { state.lat = lat; state.lon = lon; state.lat_span = lat_span; state.lon_span = lon_span; } }); - refresh_label(handle); + + #[cfg(target_os = "windows")] + if let Some(map) = map { + if let Ok(center) = geopoint(lat, lon) { + let _ = map.SetCenter(center); + let _ = map.SetZoomLevel(zoom_from_span(lat_span, lon_span)); + } + } + refresh_placeholder(handle); } pub fn add_pin(handle: i64, lat: f64, lon: f64, title_ptr: *const u8) { let title = str_from_header(title_ptr); - MAPS.with(|m| { - if let Some(state) = m.borrow_mut().get_mut(&handle) { - state.pins.push(Pin { lat, lon, title }); + #[cfg(target_os = "windows")] + let map = MAPS.with(|maps| { + let mut maps = maps.borrow_mut(); + let state = maps.get_mut(&handle)?; + state.pin_count += 1; + state.backend.as_ref().map(|backend| backend.map.clone()) + }); + #[cfg(not(target_os = "windows"))] + MAPS.with(|maps| { + if let Some(state) = maps.borrow_mut().get_mut(&handle) { + state.pin_count += 1; } }); - refresh_label(handle); + + #[cfg(target_os = "windows")] + if let Some(map) = map { + if let (Ok(location), Ok(icon), Ok(elements)) = + (geopoint(lat, lon), MapIcon::new(), map.MapElements()) + { + let _ = icon.SetLocation(location); + let _ = icon.SetTitle(HSTRING::from(title)); + let element = MapElement::from(&icon); + let _ = elements.Append(element); + } + } + #[cfg(not(target_os = "windows"))] + let _ = (lat, lon, title); + refresh_placeholder(handle); } pub fn clear_pins(handle: i64) { - MAPS.with(|m| { - if let Some(state) = m.borrow_mut().get_mut(&handle) { - state.pins.clear(); + #[cfg(target_os = "windows")] + let map = MAPS.with(|maps| { + let mut maps = maps.borrow_mut(); + let state = maps.get_mut(&handle)?; + state.pin_count = 0; + state.backend.as_ref().map(|backend| backend.map.clone()) + }); + #[cfg(not(target_os = "windows"))] + MAPS.with(|maps| { + if let Some(state) = maps.borrow_mut().get_mut(&handle) { + state.pin_count = 0; } }); - refresh_label(handle); + + #[cfg(target_os = "windows")] + if let Some(map) = map { + if let Ok(elements) = map.MapElements() { + let _ = elements.Clear(); + } + } + refresh_placeholder(handle); } pub fn set_map_type(handle: i64, style: i64) { - MAPS.with(|m| { - if let Some(state) = m.borrow_mut().get_mut(&handle) { + #[cfg(target_os = "windows")] + let map = MAPS.with(|maps| { + let mut maps = maps.borrow_mut(); + let state = maps.get_mut(&handle)?; + state.map_type = style; + state.backend.as_ref().map(|backend| backend.map.clone()) + }); + #[cfg(not(target_os = "windows"))] + MAPS.with(|maps| { + if let Some(state) = maps.borrow_mut().get_mut(&handle) { state.map_type = style; } }); - refresh_label(handle); + + #[cfg(target_os = "windows")] + if let Some(map) = map { + let _ = map.SetStyle(xaml_map_style(style)); + } + refresh_placeholder(handle); +} + +/// Let every live XAML Island inspect a message before the Win32 dispatcher. +/// This is required for keyboard and pointer interaction hosted through +/// `DesktopWindowXamlSource`. +#[cfg(target_os = "windows")] +pub fn pre_translate_message(msg: &MSG) -> bool { + let sources = MAPS.with(|maps| { + maps.borrow() + .values() + .filter_map(|state| state.backend.as_ref().map(|backend| backend.native.clone())) + .collect::>() + }); + let old_msg: windows_xaml::Win32::UI::WindowsAndMessaging::MSG = + unsafe { std::mem::transmute_copy(msg) }; + for source in sources { + let mut handled = windows_xaml::Win32::Foundation::BOOL(0); + if unsafe { source.PreTranslateMessage(&old_msg, &mut handled) }.is_ok() + && handled.as_bool() + { + return true; + } + } + false +} + +#[cfg(target_os = "windows")] +unsafe extern "system" fn map_host_subclass_proc( + hwnd: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, + _id: usize, + _refdata: usize, +) -> LRESULT { + match msg { + WM_SIZE => { + let handle = HWND_TO_HANDLE.with(|map| map.borrow().get(&(hwnd.0 as isize)).copied()); + if let Some(handle) = handle { + let island = MAPS.with(|maps| { + maps.borrow() + .get(&handle) + .and_then(|state| state.backend.as_ref().map(|backend| backend.island_hwnd)) + }); + if let Some(island) = island { + let mut rect = RECT::default(); + if GetClientRect(hwnd, &mut rect).is_ok() { + let _ = SetWindowPos( + island, + None, + 0, + 0, + (rect.right - rect.left).max(1), + (rect.bottom - rect.top).max(1), + SWP_NOACTIVATE | SWP_NOZORDER | SWP_SHOWWINDOW, + ); + } + } + } + } + WM_NCDESTROY => { + let handle = HWND_TO_HANDLE.with(|map| map.borrow_mut().remove(&(hwnd.0 as isize))); + if let Some(handle) = handle { + MAPS.with(|maps| { + maps.borrow_mut().remove(&handle); + }); + } + let _ = RemoveWindowSubclass(hwnd, Some(map_host_subclass_proc), MAP_SUBCLASS_ID); + } + _ => {} + } + DefSubclassProc(hwnd, msg, wparam, lparam) +} + +#[cfg(test)] +mod tests { + use super::zoom_from_span; + + #[test] + fn zoom_conversion_matches_cross_platform_map_contract() { + assert_eq!(zoom_from_span(360.0, 360.0), 1.0); + assert_eq!(zoom_from_span(0.0, 0.0), 20.0); + assert!((zoom_from_span(0.05, 0.05) - 12.813_781).abs() < 0.000_01); + assert_eq!(zoom_from_span(f64::NAN, 10.0), 20.0); + } } diff --git a/crates/perry/src/commands/compile/link/windows_app.manifest b/crates/perry/src/commands/compile/link/windows_app.manifest index 5d199992a9..138256227d 100644 --- a/crates/perry/src/commands/compile/link/windows_app.manifest +++ b/crates/perry/src/commands/compile/link/windows_app.manifest @@ -31,4 +31,14 @@ + + + + + + diff --git a/crates/perry/src/commands/compile/windows_link_tests.rs b/crates/perry/src/commands/compile/windows_link_tests.rs index a4718f7b3d..4219fea70e 100644 --- a/crates/perry/src/commands/compile/windows_link_tests.rs +++ b/crates/perry/src/commands/compile/windows_link_tests.rs @@ -202,6 +202,20 @@ fn app_manifest_runs_as_invoker() { ); } +// Issue #559: an unpackaged Win32 process must opt into the Windows 10 1903 +// compatibility context before DesktopWindowXamlSource will initialize. +#[test] +fn app_manifest_enables_xaml_islands() { + assert!( + WINDOWS_APP_MANIFEST.contains("urn:schemas-microsoft-com:compatibility.v1"), + "manifest must declare the Windows compatibility namespace" + ); + assert!( + WINDOWS_APP_MANIFEST.contains(""), + "manifest must opt into the Windows 10 1903 XAML Islands context" + ); +} + // Issue #6023: only MSVC link.exe needs the mt.exe reachability treatment — // lld-link embeds manifests in-process. The classifier keys on the program's // file stem so bare names and vswhere-resolved absolute paths both match. diff --git a/docs/src/ui/widgets.md b/docs/src/ui/widgets.md index b75ed0eee4..090ce5a34c 100644 --- a/docs/src/ui/widgets.md +++ b/docs/src/ui/widgets.md @@ -342,9 +342,13 @@ commandPaletteShow(); ### MapView (issue #517) Wraps `MKMapView` on macOS / iOS / visionOS / tvOS, `libshumate` on GTK4, -Google Maps SDK on Android (requires API key in -`AndroidManifest.xml`), and the SwiftUI `Map` view on watchOS. Windows -remains a stub (WinUI MapControl needs XAML Islands integration). +Google Maps SDK on Android (requires API key in `AndroidManifest.xml`), the +SwiftUI `Map` view on watchOS, and the native Windows `MapControl` in a XAML +Island. On Windows, obtain a Bing Maps key as described in Microsoft's +[maps authentication guide](https://learn.microsoft.com/windows/uwp/maps-and-location/authentication-key), +then set `PERRY_MAP_SERVICE_TOKEN` (or the legacy `PERRY_BING_MAPS_KEY` alias) +before launching the app. The token is read when each MapView is created and +assigned to `MapControl.MapServiceToken`. ```typescript,no-test import {