diff --git a/crates/base/src/hover_card.rs b/crates/base/src/hover_card.rs index ddd6dfe228..57ca6ef0d1 100644 --- a/crates/base/src/hover_card.rs +++ b/crates/base/src/hover_card.rs @@ -18,11 +18,13 @@ type ContentBuilder = Box< >; type OpenChangeHandler = Rc; -/// An unstyled hover-triggered popup with delayed open and close behavior. +/// An unstyled popup with delayed hover behavior on desktop. +/// On iOS and Android, tapping toggles it and tapping outside dismisses it. #[derive(IntoElement)] pub struct HoverCard { id: ElementId, anchor: Anchor, + tap_to_open: bool, trigger: Option, content: Option, open_delay: Duration, @@ -35,6 +37,7 @@ impl HoverCard { Self { id: id.into(), anchor: Anchor::TopCenter, + tap_to_open: crate::is_mobile(), trigger: None, content: None, open_delay: Duration::from_secs_f64(0.6), @@ -221,11 +224,22 @@ impl RenderOnce for HoverCard { let trigger = self.trigger.unwrap_or_else(|| div().into_any_element()); let popup = Popup::new( self.id, - div().id("trigger").child(trigger).on_hover( - window.listener_for(&state, |state, hovered, window, cx| { - state.on_trigger_hover(*hovered, window, cx) + div() + .id("trigger") + .child(trigger) + .when(self.tap_to_open, |trigger| { + trigger.on_click(window.listener_for(&state, move |state, _, window, cx| { + state.cancel_tasks(); + // Toggle the state rendered by this trigger even if + // outside dismissal handles the same release first. + state.set_open(!open, window, cx); + })) + }) + .when(!self.tap_to_open, |trigger| { + trigger.on_hover(window.listener_for(&state, |state, hovered, window, cx| { + state.on_trigger_hover(*hovered, window, cx) + })) }), - ), ) .anchor(self.anchor); @@ -237,7 +251,17 @@ impl RenderOnce for HoverCard { let hover = window.listener_for(&state, |state, hovered, window, cx| { state.on_content_hover(*hovered, window, cx) }); - popup.content(state.update(cx, |state, cx| content(state, window, cx).on_hover(hover))) + let dismiss = window.listener_for(&state, |state, _, window, cx| { + state.cancel_tasks(); + state.set_open(false, window, cx); + }); + popup.content(state.update(cx, |state, cx| { + content(state, window, cx) + .when(self.tap_to_open, |content| { + content.on_mouse_up_out(gpui::MouseButton::Left, dismiss) + }) + .when(!self.tap_to_open, |content| content.on_hover(hover)) + })) }) } } @@ -253,6 +277,7 @@ mod tests { #[derive(Default)] struct Harness { open_changes: Rc>>, + tap_to_open: bool, } impl Render for Harness { @@ -260,6 +285,10 @@ mod tests { let delay = Duration::from_millis(100); let open_changes = self.open_changes.clone(); HoverCard::new("hover-card") + .map(|mut card| { + card.tap_to_open = self.tap_to_open; + card + }) .open_delay(delay) .close_delay(delay) .on_open_change(move |open, _, _| open_changes.borrow_mut().push(*open)) @@ -305,7 +334,10 @@ mod tests { let open_changes = Rc::new(RefCell::new(Vec::new())); let (_, cx) = cx.add_window_view({ let open_changes = open_changes.clone(); - move |_, _| Harness { open_changes } + move |_, _| Harness { + open_changes, + ..Default::default() + } }); cx.update(|window, cx| window.draw(cx).clear(cx)); @@ -326,4 +358,49 @@ mod tests { cx.update(|window, cx| window.draw(cx).clear(cx)); assert_eq!(*open_changes.borrow(), vec![true, false]); } + #[gpui::test] + fn tap_card_ignores_hover_and_toggles_and_dismisses(cx: &mut TestAppContext) { + let changes = Rc::new(RefCell::new(Vec::new())); + let (_, cx) = cx.add_window_view({ + let changes = changes.clone(); + move |_, _| Harness { + tap_to_open: true, + open_changes: changes, + } + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + cx.simulate_mouse_move(point(px(10.), px(10.)), None, Default::default()); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.run_until_parked(); + assert!(changes.borrow().is_empty()); + + cx.simulate_click(point(px(10.), px(10.)), Default::default()); + cx.update(|window, cx| { + window.draw(cx).clear(cx); + window.draw(cx).clear(cx); + }); + assert!(cx.debug_bounds("hover-card-content").is_some()); + cx.simulate_mouse_move(point(px(100.), px(100.)), None, Default::default()); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.run_until_parked(); + assert_eq!(*changes.borrow(), vec![true]); + + cx.simulate_click(point(px(10.), px(10.)), Default::default()); + cx.update(|window, cx| window.draw(cx).clear(cx)); + assert!(cx.debug_bounds("hover-card-content").is_none()); + assert_eq!(*changes.borrow(), vec![true, false]); + + cx.simulate_click(point(px(10.), px(10.)), Default::default()); + cx.update(|window, cx| { + window.draw(cx).clear(cx); + window.draw(cx).clear(cx); + }); + let bounds = cx.debug_bounds("hover-card-content").unwrap(); + cx.simulate_click(bounds.center(), Default::default()); + assert_eq!(*changes.borrow(), vec![true, false, true]); + cx.simulate_click(point(px(100.), px(100.)), Default::default()); + cx.update(|window, cx| window.draw(cx).clear(cx)); + assert!(cx.debug_bounds("hover-card-content").is_none()); + assert_eq!(*changes.borrow(), vec![true, false, true, false]); + } } diff --git a/crates/base/src/lib.rs b/crates/base/src/lib.rs index c628795bab..409354dbbf 100644 --- a/crates/base/src/lib.rs +++ b/crates/base/src/lib.rs @@ -195,6 +195,14 @@ pub use virtual_list::{VirtualList, VirtualListScrollHandle, h_virtual_list, v_v use gpui::App; +/// Returns whether the application is compiled for iOS or Android. +/// +/// This is a compile-time platform check, not a screen-size or input-device check. +#[inline] +pub const fn is_mobile() -> bool { + cfg!(any(target_os = "ios", target_os = "android")) +} + /// Initializes global infrastructure owned by the base layer. pub fn init(cx: &mut App) { let _ = Theme::global_mut(cx); diff --git a/crates/base/src/tooltip.rs b/crates/base/src/tooltip.rs index 3046c2e1a6..1bd9452dbe 100644 --- a/crates/base/src/tooltip.rs +++ b/crates/base/src/tooltip.rs @@ -91,7 +91,11 @@ pub enum TooltipTransition { } /// Per-window tooltip provider and overlay. +/// +/// Show requests are ignored on iOS and Android, where touch input must not +/// open hover tooltips. This does not control GPUI's native `.tooltip()` API. pub struct TooltipOverlay { + enabled: bool, content: Option, previous_bounds: Option>, epoch: usize, @@ -106,6 +110,7 @@ pub struct TooltipOverlay { impl TooltipOverlay { pub fn new() -> Self { Self { + enabled: !crate::is_mobile(), content: None, previous_bounds: None, epoch: 0, @@ -137,6 +142,11 @@ impl TooltipOverlay { window: &mut Window, cx: &mut Context, ) { + // Gate both delayed display and the immediate grace-period switch. + // Keep this in Base so every managed component shares the policy. + if !self.enabled { + return; + } self.hide_task = None; let was_visible = self.content.is_some(); if was_visible || self.had_recent_tooltip { @@ -305,4 +315,33 @@ mod tests { fn tooltip_priority_exceeds_popup_layer() { assert!(TOOLTIP_PRIORITY > crate::POPUP_PRIORITY); } + + #[gpui::test] + fn disabled_provider_ignores_delayed_and_immediate_requests(cx: &mut gpui::TestAppContext) { + let state = cx.update(|cx| { + cx.new(|_| TooltipOverlay { + enabled: false, + ..TooltipOverlay::new() + }) + }); + let cx = cx.add_empty_window(); + for had_recent_tooltip in [false, true] { + cx.update(|window, cx| { + state.update(cx, |tooltip, cx| { + tooltip.had_recent_tooltip = had_recent_tooltip; + tooltip.request_show( + TooltipRequest::new(bounds(0., 0., 20., 20.), |_, _| { + panic!("disabled tooltips must not build content") + }), + window, + cx, + ); + assert!(tooltip.content.is_none()); + assert!(tooltip.show_task.is_none()); + assert!(tooltip.hide_task.is_none()); + assert_eq!(tooltip.animation_epoch, 0); + }); + }); + } + } } diff --git a/crates/component/src/hover_card.rs b/crates/component/src/hover_card.rs index 9b0e1609c1..a9395f10fe 100644 --- a/crates/component/src/hover_card.rs +++ b/crates/component/src/hover_card.rs @@ -13,7 +13,8 @@ use crate::{StyledExt as _, popover::Popover}; /// A hover card element that displays content when hovering over a trigger element. /// /// Similar to Popover but triggered by mouse hover instead of click, with configurable delays -/// for showing and hiding the content. +/// for showing and hiding the content. On iOS and Android, tapping the trigger +/// toggles the card and tapping outside dismisses it; hover delays are ignored. #[derive(IntoElement)] pub struct HoverCard { id: ElementId, diff --git a/crates/kit/Cargo.toml b/crates/kit/Cargo.toml index 2b57771bd3..c1fb13009f 100644 --- a/crates/kit/Cargo.toml +++ b/crates/kit/Cargo.toml @@ -68,11 +68,13 @@ tree-sitter-zig = ["component", "gpui-component/tree-sitter-zig"] [dependencies] gpui.workspace = true -gpui_platform.workspace = true gpui-base.workspace = true gpui-component = { workspace = true, optional = true } gpui-kit-assets = { workspace = true, optional = true } +[target.'cfg(not(any(target_os = "ios", target_os = "android")))'.dependencies] +gpui_platform.workspace = true + [target.'cfg(target_family = "wasm")'.dependencies] gpui_web.workspace = true diff --git a/crates/kit/src/lib.rs b/crates/kit/src/lib.rs index 46fa42e07c..aefd824b39 100644 --- a/crates/kit/src/lib.rs +++ b/crates/kit/src/lib.rs @@ -8,12 +8,13 @@ //! | Path | Crate | Feature | //! | --------------- | ----------------- | ---------------- | //! | `gpui_kit::*` | `gpui` | always | -//! | [`platform`] | `gpui_platform` | always | +//! | `platform` | `gpui_platform` | desktop / web | //! | [`base`] | `gpui-base` | always | //! | [`component`] | `gpui-component` | `component` (on) | //! | [`assets`] | `gpui-kit-assets` | `assets` (on) | //! -//! [`application`] opens the platform and [`init`] initializes the enabled +//! On desktop and web, `application` opens the platform. Mobile applications +//! supply their backend to `Application::with_platform`. [`init`] initializes the enabled //! layers: //! //! ```no_run @@ -103,9 +104,11 @@ pub use ::gpui; pub mod test; pub use ::gpui_base as base; +#[cfg(not(any(target_os = "ios", target_os = "android")))] pub use ::gpui_platform as platform; #[cfg(target_family = "wasm")] pub use ::gpui_web as web; +pub use gpui_base::is_mobile; /// The styled component library. /// @@ -141,6 +144,8 @@ pub use ::gpui_component as component; #[cfg(feature = "assets")] pub use ::gpui_kit_assets as assets; +// Mobile applications provide their platform with `Application::with_platform`. +#[cfg(not(any(target_os = "ios", target_os = "android")))] pub use ::gpui_platform::application; /// Initializes every enabled layer. Call it once, before using anything else. diff --git a/website/base/primitives/hover-card.md b/website/base/primitives/hover-card.md index b6f00f6ae5..e308d1dfb2 100644 --- a/website/base/primitives/hover-card.md +++ b/website/base/primitives/hover-card.md @@ -10,6 +10,8 @@ A delayed floating card associated with a pointer or keyboard trigger. Like every `gpui-base` primitive, Hover Card supplies behavior and semantic structure without imposing a product visual language. Apply GPUI styles and compose the exported parts to match your design system. +On iOS and Android, the trigger toggles the card on click and an outside click dismisses it. Hover and its open/close delays are ignored. + ## Example The [single native Cargo entrypoint](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/native/src/bin/components.rs) selects this primitive from the [shared showcase implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/mod.rs). The same showcase is compiled once for the WASM preview above. diff --git a/website/component/hover-card.md b/website/component/hover-card.md index 64972d013a..5a2ab6af08 100644 --- a/website/component/hover-card.md +++ b/website/component/hover-card.md @@ -9,6 +9,8 @@ HoverCard component for displaying rich content that appears when the mouse hove This is most like the [Popover] component, but triggered by hover instead of click, and with timing controls for a smoother user experience. +On iOS and Android, tap the trigger to open or close the card. Tapping outside closes it; tapping inside keeps it open. Hover delays do not apply. Tooltip hints remain disabled; see [Mobile](/docs/mobile). + ## Import ```rust diff --git a/website/component/tooltip.md b/website/component/tooltip.md index e8b57a2f7a..7eb7cc1f0d 100644 --- a/website/component/tooltip.md +++ b/website/component/tooltip.md @@ -7,6 +7,10 @@ description: Display helpful information on hover or focus, with support for key A versatile tooltip component that displays helpful information when hovering over or focusing on elements. Supports text content, custom elements, keyboard shortcuts, different trigger methods, and positioning options. +## Mobile behavior + +On iOS and Android, tooltips managed by the GPUI Base overlay are disabled. Shared components may keep their tooltip configuration, but mobile actions still need visible or accessible labels. Direct GPUI `.tooltip()` calls, including the basic `div()` example below, bypass this overlay and are not disabled by this policy. See [Mobile](/docs/mobile) for integration guidance. + ## Import ```rust diff --git a/website/docs/installation.md b/website/docs/installation.md index 51f751e3be..bf20ad655e 100644 --- a/website/docs/installation.md +++ b/website/docs/installation.md @@ -48,6 +48,8 @@ gpui-kit = "0.6" `gpui-kit` depends on the matching GPUI crates for you, so your application never lists GPUI itself. `use gpui_kit::*;` is GPUI, and the layers are reachable by name: `gpui_kit::component` (the styled components), `gpui_kit::base`, `gpui_kit::assets` and `gpui_kit::platform`. +For experimental iOS support and Swift UIView embedding, see [Mobile](/docs/mobile). Mobile uses `gpui-pre-mobile` and a different application bootstrap from the desktop setup above. + ## Faster development builds Debug builds compile GPUI, the component library and the text stack without diff --git a/website/docs/mobile.md b/website/docs/mobile.md new file mode 100644 index 0000000000..753400cffb --- /dev/null +++ b/website/docs/mobile.md @@ -0,0 +1,138 @@ +--- +title: Mobile +description: Build an iOS application or embed GPUI Kit in a Swift UIKit container with the experimental gpui-pre-mobile platform. +order: -2.4 +--- + +# Mobile + +Mobile support builds on [gpui-mobile](https://github.com/itsbalamurali/gpui-mobile), created by [itsbalamurali](https://github.com/itsbalamurali) and developed with the community. Credit for the original mobile platform belongs to that project and its contributors. The platform supplies the window, touch input, text system, and GPU surface; GPUI and GPUI Kit still own the Rust view tree and components. + +GPUI Kit currently uses `gpui-pre-mobile`, a temporary compatibility package maintained in the [Longbridge fork](https://github.com/longbridge/gpui-mobile). It adapts the original project for crate packaging and publication alongside `gpui-pre`, and tracks newer GPUI versions to keep the integration compatible. Once the community `gpui-mobile` completes the integration and GPUI is published as a crate, we plan to switch this guide and its dependencies to the community `gpui-mobile`. + +The current integration is experimental. The Swift-hosted iOS example has been built and exercised in the iOS simulator. Android has a platform implementation, but the GPUI Kit integration described here has not been validated on Android or a physical iPhone. + +## Run the iOS example + +Start with the compatibility fork’s [Swift container example](https://github.com/longbridge/gpui-mobile/tree/0b882efdac7f524e0bb0b1d4c886b2aa752f9f20/example). It includes a conversation UI with `Message`, `Bubble`, `TextView`, `Input`, thought summaries, and copy actions. Its responses are local sample data; it does not connect to an AI service. + +On an Apple Silicon Mac, install Xcode with an iOS simulator runtime, Rust, and XcodeGen: + +```sh +brew install xcodegen +rustup target add aarch64-apple-ios-sim + +git clone https://github.com/longbridge/gpui-mobile.git +cd gpui-mobile +git checkout 0b882efdac7f524e0bb0b1d4c886b2aa752f9f20 +cd example +./build.sh ios --simulator +``` + +The script builds the Rust static library, generates the Xcode project, and installs and launches the app in a simulator. Add `--no-run` to build only. The example targets iOS 16 or later; this is a deployment setting, not a claim that every supported OS version has been tested. + +For device development, install the `aarch64-apple-ios` Rust target and configure your own development team and signing in `example/ios/project.yml`. Re-generate the project after changing that file. Simulator execution does not establish device performance or release readiness. + +## Dependencies + +`gpui-pre-mobile` is the Cargo package name; the Rust library is `gpui_mobile`. Use a Git dependency while evaluating this integration. The package's `0.1.0` manifest version does not imply a crates.io release. + +```toml +[lib] +crate-type = ["staticlib", "rlib"] + +[dependencies] +gpui-mobile = { package = "gpui-pre-mobile", git = "https://github.com/longbridge/gpui-mobile", rev = "0b882efdac7f524e0bb0b1d4c886b2aa752f9f20" } +gpui = { package = "gpui-pre", version = "=0.3.4", default-features = false } +gpui-kit = { git = "https://github.com/longbridge/gpui-kit", rev = "7d9efcd2069f9eaa6eb3ba6345aac4aa7d87c9f7", default-features = false, features = ["component"] } +``` + +These revisions reproduce the example's dependency baseline. The Kit revision includes mobile platform gating but predates mobile tooltip suppression. To use your local GPUI Kit checkout, replace the Kit dependency with: + +```toml +gpui-kit = { path = "../gpui-kit/crates/kit", default-features = false, features = ["component"] } +``` + +Adjust the path relative to your application's manifest. Keep the GPUI core and renderer on the same release: the pinned mobile platform uses `gpui-pre` and `gpui-pre-wgpu` at `0.3.4`. + +Unlike the desktop [Getting Started](/docs/getting-started) setup, mobile does not use `gpui_kit::application()` or `gpui_kit::platform`. Those desktop platform exports are excluded on iOS and Android. The mobile host initializes GPUI, calls `gpui_kit::init(cx)`, and mounts a single `component::Root` around the application's content. + +## Embed a view in UIKit + +UIKit owns the native window, navigation, safe areas, and keyboard layout. The example's `GPUITextView` is a Swift `UIView` wrapper around the GPUI platform's child `UIViewController`. Despite its name, it hosts a whole Rust conversation view, not just one `TextView` element. + +Use these files together as the integration reference: + +| File | Responsibility | +| --- | --- | +| [App.swift](https://github.com/longbridge/gpui-mobile/blob/0b882efdac7f524e0bb0b1d4c886b2aa752f9f20/example/ios/App.swift) | Native window, view wrapper, child controller containment, layout, and frame scheduling | +| [Embedding.h](https://github.com/longbridge/gpui-mobile/blob/0b882efdac7f524e0bb0b1d4c886b2aa752f9f20/example/ios/Embedding.h) | Swift bridging declarations for Rust callbacks | +| [src/lib.rs](https://github.com/longbridge/gpui-mobile/blob/0b882efdac7f524e0bb0b1d4c886b2aa752f9f20/example/src/lib.rs) | Application callback, Kit initialization, and Rust root view | +| [project.yml](https://github.com/longbridge/gpui-mobile/blob/0b882efdac7f524e0bb0b1d4c886b2aa752f9f20/example/ios/project.yml) | Rust build phase, static library linkage, frameworks, and bridging header | + +The startup sequence is: + +1. Call `gpui_ios_set_embedded()` before creating the GPUI application so the platform does not create a second native window. +2. Call the example-defined `gpui_ios_register_app()`. It registers a Rust callback with `gpui_mobile::ios::ffi::set_app_callback` that initializes Kit and opens the GPUI root. +3. Call `gpui_ios_run_demo()` to start the embedded application, then obtain its window and child controller with `gpui_ios_get_window()` and `gpui_ios_view_controller()`. +4. Attach the controller using UIKit containment: `addChild`, add its view, then `didMove(toParent:)`. + +`gpui_ios_register_app()` belongs to the example, not the platform library. Adapt its callback to construct your own Rust view. The `run_demo` name is the current bridge entry point; it runs the registered application callback. + +Once the example's `GPUITextView` wrapper is included in your app, a native controller can constrain it like any other view: + +```swift +let content = GPUITextView(frame: .zero) +content.translatesAutoresizingMaskIntoConstraints = false +view.addSubview(content) +content.attach(to: self) + +NSLayoutConstraint.activate([ + content.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + content.leadingAnchor.constraint(equalTo: view.leadingAnchor), + content.trailingAnchor.constraint(equalTo: view.trailingAnchor), + content.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor), +]) +``` + +This fragment uses the example wrapper; `GPUITextView` is not an SDK-provided UIKit class. Copy its containment and layout behavior along with the declarations and build settings, rather than copying only the constraints. + +### Lifetime, resizing, and frames + +The platform retains the `ApplicationHandle` returned by `Application::run_embedded`. It must outlive callbacks and rendered views. The current bridge supports one GPUI view for the application's lifetime; it does not provide independently destroyable views, multiple instances, or reusable collection-view cells. + +In `layoutSubviews`, update the child controller's frame only when nonzero bounds change, call `gpui_ios_layout_view`, then request a frame. The example performs those operations inside a Core Animation transaction with implicit animations disabled, keeping the Metal surface and GPUI viewport in sync during layout changes. + +The host drives `gpui_ios_request_frame` through a `CADisplayLink` while visible and invalidates the link when the controller disappears. Forward application active/inactive callbacks as shown in `App.swift`. Keep UIKit and bridge calls on the main thread. + +## Platform-specific behavior + +`gpui_kit::is_mobile()` is an inline `const fn` that returns `true` for iOS and Android targets. It checks the compilation target, not window width or whether a mouse is connected. + +```rust +if gpui_kit::is_mobile() { + // Use touch-friendly interaction. +} +``` + +## Design for mobile + +Share component behavior and content with desktop, while adapting the screen to touch and a narrow viewport: + +- Let the native container handle navigation, safe areas, and keyboard avoidance. Avoid stacking a second title bar or duplicating safe-area padding inside Rust. +- Give each conversation one vertical scroll owner. For a `TextView` within that scroller, use `.w_full().min_w_0().scrollable(false)` so text and images fit the available width. +- Keep the composer compact when empty. Use a single-line input when multiline composition is unnecessary, and ensure the keyboard does not cover the send action. +- HoverCard opens and closes by tapping its trigger on iOS and Android. Tap outside to dismiss it; moving a finger does not open the card. +- Make actions discoverable by touch. Keep copy actions aligned with the reply and use a brief checkmark after copying. Do not rely on hover text to explain an action. +- Prefer short paragraphs and purposeful headings. Let code, tables, and images support the conversation rather than presenting every Markdown format in each reply. +- Use Kit theme colors, type sizes, and spacing consistently. Check long replies, wide code, image loading, and Chinese or other scripts at the actual device width. + +GPUI Base disables its tooltip overlay on iOS and Android. This covers Kit tooltips routed through that overlay, not direct GPUI `.tooltip()` calls. The pinned baseline above predates that change. Do not add native GPUI hover tooltips to mobile views. + +## Validation and current limits + +For an application integration, check launch and return from the background, keyboard show/hide, viewport resizing, text selection and copying, scroll behavior, and touch feedback. Inspect the actual rendered screen rather than relying only on a Rust compile check. + +Measure rendering on a physical device with a release build and Xcode Instruments before making performance claims. Simulator results are useful for layout and interaction, but are not device frame-time measurements. + +Android uses a separate activity and surface lifecycle. The repository contains an Android example, but this guide does not establish Android Kit compatibility or native Android `View` embedding. Validate those paths separately before depending on them. diff --git a/website/zh-CN/base/primitives/hover-card.md b/website/zh-CN/base/primitives/hover-card.md index bda21e1df0..e82239d4fa 100644 --- a/website/zh-CN/base/primitives/hover-card.md +++ b/website/zh-CN/base/primitives/hover-card.md @@ -10,6 +10,8 @@ order: 13 和所有 GPUI Base 原语一样,Hover Card 只提供行为和语义结构,不规定产品视觉语言。请使用 GPUI 样式并组合导出的部件,使其符合你的设计系统。 +iOS 和 Android 上,点击触发元素切换卡片开关,点击外部关闭;忽略悬停及其打开、关闭延迟。 + ## 示例 原生示例和页面上方的 WASM 预览共用同一份实现: diff --git a/website/zh-CN/component/hover-card.md b/website/zh-CN/component/hover-card.md index ed34962ba7..0164c7a1bb 100644 --- a/website/zh-CN/component/hover-card.md +++ b/website/zh-CN/component/hover-card.md @@ -9,6 +9,8 @@ HoverCard 用于在鼠标悬停到触发元素时显示富内容浮层,适合 它和 [Popover] 很像,但触发方式是 hover 而不是 click,并且提供了更细的时间控制。 +iOS 和 Android 上点击触发元素即可打开或关闭卡片,点击外部关闭,点击内容区域保持打开。移动端不使用悬停延迟;Tooltip 提示仍保持禁用。参阅[移动端](/zh-CN/docs/mobile)。 + ## 导入 ```rust diff --git a/website/zh-CN/component/tooltip.md b/website/zh-CN/component/tooltip.md index 01e5b06574..d1ba2eb8af 100644 --- a/website/zh-CN/component/tooltip.md +++ b/website/zh-CN/component/tooltip.md @@ -7,6 +7,10 @@ description: 在悬停或聚焦时显示提示信息,支持快捷键和自定 Tooltip 用于在鼠标悬停或元素获得焦点时显示补充信息。它支持纯文本、自定义内容、快捷键信息以及多种触发方式,适合做解释说明、状态提示和操作说明。 +## 移动端行为 + +iOS 和 Android 上会禁用由 GPUI Base overlay 管理的 tooltip。共享组件可以保留 tooltip 配置,但移动端操作仍应提供可见或可访问的标签。直接使用 GPUI `.tooltip()` 的调用(包括下方的基础 `div()` 示例)不经过此 overlay,因此不受该策略限制。集成方式请参阅[移动端](/zh-CN/docs/mobile)。 + ## 导入 ```rust diff --git a/website/zh-CN/docs/installation.md b/website/zh-CN/docs/installation.md index 8e9c91b8b0..8c4d327779 100644 --- a/website/zh-CN/docs/installation.md +++ b/website/zh-CN/docs/installation.md @@ -8,6 +8,8 @@ order: -1 在开始使用 `gpui-component` 构建应用之前,需要先准备对应的开发环境并安装依赖。 +实验性的 iOS 支持与 Swift UIView 嵌入方式请参阅[移动端](/zh-CN/docs/mobile)。移动端使用 `gpui-pre-mobile`,应用启动方式与桌面端不同。 + ## 系统要求 目前可以在 macOS、Windows 和 Linux 上进行开发。 diff --git a/website/zh-CN/docs/mobile.md b/website/zh-CN/docs/mobile.md new file mode 100644 index 0000000000..e83e3197c7 --- /dev/null +++ b/website/zh-CN/docs/mobile.md @@ -0,0 +1,138 @@ +--- +title: 移动端 +description: 使用实验性的 gpui-pre-mobile 平台构建 iOS 应用,或将 GPUI Kit 嵌入 Swift UIKit 容器。 +order: -2.4 +--- + +# 移动端 + +移动端支持基于 [gpui-mobile](https://github.com/itsbalamurali/gpui-mobile),由 [itsbalamurali](https://github.com/itsbalamurali) 创建并与社区共同开发。原始移动平台的成果归功于该项目的作者和贡献者。移动平台负责窗口、触摸输入、文本系统和 GPU 渲染表面,GPUI 与 GPUI Kit 继续管理 Rust 视图树和组件。 + +GPUI Kit 目前使用 `gpui-pre-mobile`,这是在 [Longbridge fork](https://github.com/longbridge/gpui-mobile) 中维护的临时兼容包。它基于原项目进行打包适配,用于配合 `gpui-pre` 发布 crate,并持续跟进最新的 GPUI 版本、保持集成兼容。待社区 `gpui-mobile` 完成接入、GPUI 也发布 crate 后,我们计划将本文及相关依赖更新为社区的 `gpui-mobile`。 + +目前该集成仍处于实验阶段。Swift 托管的 iOS 示例已在 iOS 模拟器中构建并运行。仓库中也有 Android 平台实现,但本文介绍的 GPUI Kit 集成尚未在 Android 或实体 iPhone 上验证。 + +## 运行 iOS 示例 + +从兼容 fork 中的 [Swift 容器示例](https://github.com/longbridge/gpui-mobile/tree/0b882efdac7f524e0bb0b1d4c886b2aa752f9f20/example) 开始。它使用 `Message`、`Bubble`、`TextView`、`Input`、思考摘要和复制操作组成聊天界面。回复来自本地示例数据,没有接入 AI 服务。 + +在 Apple Silicon Mac 上安装 Xcode、iOS 模拟器运行时、Rust 和 XcodeGen: + +```sh +brew install xcodegen +rustup target add aarch64-apple-ios-sim + +git clone https://github.com/longbridge/gpui-mobile.git +cd gpui-mobile +git checkout 0b882efdac7f524e0bb0b1d4c886b2aa752f9f20 +cd example +./build.sh ios --simulator +``` + +脚本会构建 Rust 静态库、生成 Xcode 工程,并在模拟器中安装和启动应用。添加 `--no-run` 可以只构建。示例的最低部署版本为 iOS 16;这项配置不代表所有支持的系统版本都经过测试。 + +真机开发还需要安装 `aarch64-apple-ios` Rust target,并在 `example/ios/project.yml` 中设置自己的开发团队和签名信息。修改后重新生成工程。模拟器运行结果不能代替真机性能测试或发布验证。 + +## 依赖配置 + +`gpui-pre-mobile` 是 Cargo 包名,Rust 库名为 `gpui_mobile`。评估阶段使用 Git 依赖;清单中的 `0.1.0` 版本号不代表已经发布到 crates.io。 + +```toml +[lib] +crate-type = ["staticlib", "rlib"] + +[dependencies] +gpui-mobile = { package = "gpui-pre-mobile", git = "https://github.com/longbridge/gpui-mobile", rev = "0b882efdac7f524e0bb0b1d4c886b2aa752f9f20" } +gpui = { package = "gpui-pre", version = "=0.3.4", default-features = false } +gpui-kit = { git = "https://github.com/longbridge/gpui-kit", rev = "7d9efcd2069f9eaa6eb3ba6345aac4aa7d87c9f7", default-features = false, features = ["component"] } +``` + +这些提交固定了示例的依赖基线。Kit 提交包含移动平台条件编译支持,但尚未包含移动端 tooltip 禁用逻辑。要使用本地 GPUI Kit 检出,可以替换 Kit 依赖: + +```toml +gpui-kit = { path = "../gpui-kit/crates/kit", default-features = false, features = ["component"] } +``` + +路径相对于应用的 Cargo 清单,请按实际目录调整。GPUI 核心与渲染器应使用同一版本:上述移动平台固定使用 `0.3.4` 的 `gpui-pre` 和 `gpui-pre-wgpu`。 + +与桌面端[快速开始](/zh-CN/docs/getting-started)不同,移动端不使用 `gpui_kit::application()` 或 `gpui_kit::platform`。这些桌面平台导出在 iOS 和 Android 上被排除。移动宿主负责初始化 GPUI、调用 `gpui_kit::init(cx)`,并在应用内容外挂载一个 `component::Root`。 + +## 嵌入 UIKit 视图 + +UIKit 管理原生窗口、导航、安全区域和键盘布局。示例中的 `GPUITextView` 是一个 Swift `UIView` 包装器,内部托管 GPUI 平台的子 `UIViewController`。虽然名字叫 `GPUITextView`,它承载的是完整的 Rust 聊天视图,而不只是一个 `TextView` 元素。 + +集成时请一起参考以下文件: + +| 文件 | 职责 | +| --- | --- | +| [App.swift](https://github.com/longbridge/gpui-mobile/blob/0b882efdac7f524e0bb0b1d4c886b2aa752f9f20/example/ios/App.swift) | 原生窗口、视图包装、子控制器容纳、布局与帧调度 | +| [Embedding.h](https://github.com/longbridge/gpui-mobile/blob/0b882efdac7f524e0bb0b1d4c886b2aa752f9f20/example/ios/Embedding.h) | Swift 调用 Rust 所需的桥接声明 | +| [src/lib.rs](https://github.com/longbridge/gpui-mobile/blob/0b882efdac7f524e0bb0b1d4c886b2aa752f9f20/example/src/lib.rs) | 应用回调、Kit 初始化与 Rust 根视图 | +| [project.yml](https://github.com/longbridge/gpui-mobile/blob/0b882efdac7f524e0bb0b1d4c886b2aa752f9f20/example/ios/project.yml) | Rust 构建步骤、静态库链接、系统框架与桥接头文件 | + +启动顺序如下: + +1. 在创建 GPUI 应用前调用 `gpui_ios_set_embedded()`,避免平台再创建一个原生窗口。 +2. 调用示例定义的 `gpui_ios_register_app()`。它通过 `gpui_mobile::ios::ffi::set_app_callback` 注册 Rust 回调,在回调中初始化 Kit 并打开 GPUI 根视图。 +3. 调用 `gpui_ios_run_demo()` 启动嵌入式应用,然后通过 `gpui_ios_get_window()` 和 `gpui_ios_view_controller()` 获取窗口与子控制器。 +4. 按 UIKit 的容纳规则调用 `addChild`、添加子视图,再调用 `didMove(toParent:)`。 + +`gpui_ios_register_app()` 属于示例,不是平台库提供的函数。请修改它的回调来创建自己的 Rust 视图。`run_demo` 是当前桥接入口的名称,实际执行的是已注册的应用回调。 + +将示例的 `GPUITextView` 包装器加入项目后,原生控制器可以像布局其他视图一样设置约束: + +```swift +let content = GPUITextView(frame: .zero) +content.translatesAutoresizingMaskIntoConstraints = false +view.addSubview(content) +content.attach(to: self) + +NSLayoutConstraint.activate([ + content.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + content.leadingAnchor.constraint(equalTo: view.leadingAnchor), + content.trailingAnchor.constraint(equalTo: view.trailingAnchor), + content.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor), +]) +``` + +这段代码依赖示例包装器,`GPUITextView` 并不是 SDK 提供的 UIKit 类。移植时应保留它的子控制器容纳和布局逻辑,以及配套声明和构建配置,而不只是复制约束。 + +### 生命周期、尺寸与帧调度 + +平台持有 `Application::run_embedded` 返回的 `ApplicationHandle`,确保其生命周期覆盖回调和渲染视图。目前桥接支持一个随应用存活的 GPUI 视图,尚未提供独立销毁、多实例或集合视图单元格复用接口。 + +在 `layoutSubviews` 中,仅当非零边界尺寸发生变化时更新子控制器的 frame,调用 `gpui_ios_layout_view`,再请求渲染一帧。示例将这些操作放在禁用隐式动画的 Core Animation 事务内,使布局变化时 Metal 表面与 GPUI 视口保持同步。 + +宿主在界面可见时使用 `CADisplayLink` 驱动 `gpui_ios_request_frame`,在控制器消失时停止 display link。同时按 `App.swift` 转发应用激活与失活事件。UIKit 和桥接调用均应在主线程执行。 + +## 平台判断 + +`gpui_kit::is_mobile()` 是带 `#[inline]` 的 `const fn`,在 iOS 和 Android 目标上返回 `true`。它判断编译目标,不判断窗口宽度或是否连接鼠标。 + +```rust +if gpui_kit::is_mobile() { + // 使用适合触摸的交互。 +} +``` + +## 移动界面设计 + +可以与桌面端共享组件行为和内容,但应针对触摸操作与窄屏调整界面: + +- 由原生容器处理导航、安全区域和键盘避让。不要在 Rust 内容中重复添加标题栏或安全区域内边距。 +- 一段对话只由一个容器负责纵向滚动。位于该容器内的 `TextView` 使用 `.w_full().min_w_0().scrollable(false)`,使文字与图片适应可用宽度。 +- 输入为空时保持紧凑。如果不需要多行输入,就使用单行输入框,并确保键盘不会遮挡发送操作。 +- iOS 和 Android 上点击 HoverCard 的触发元素切换开关,点击外部关闭;移动手指不会打开卡片。 +- 让操作可以通过触摸发现。复制按钮与回复正文对齐,复制成功后短暂显示对勾,不依赖悬停提示解释操作。 +- 使用短段落和有意义的标题。代码、表格和图片应服务于对话,不必在每条回复中罗列所有 Markdown 格式。 +- 一致使用 Kit 的主题颜色、字号和间距。在真实设备宽度下检查长回复、宽代码、图片加载和中文等不同文字。 + +GPUI Base 在 iOS 和 Android 上禁用其 tooltip overlay。这只覆盖通过该 overlay 显示的 Kit 提示,不影响直接调用 GPUI `.tooltip()` 的代码。上述固定依赖基线尚不包含这一修改。移动视图中不要添加 GPUI 原生悬停提示。 + +## 验证与当前限制 + +集成到应用后,应检查启动和后台恢复、键盘显示与隐藏、视口尺寸变化、文本选择与复制、滚动及触摸反馈。除了 Rust 编译检查,也应观察实际渲染界面。 + +在做出性能结论前,使用实体设备、Release 构建和 Xcode Instruments 测量。模拟器适合验证布局与交互,但它的结果不是设备帧耗时。 + +Android 使用独立的 Activity 与渲染表面生命周期。仓库包含 Android 示例,但本文不代表 Android Kit 兼容性或嵌入原生 Android `View` 的能力已经得到验证。采用这些路径前需要单独评估。