Author SHA1 Message Date
yenru0 66e2a957d9 gpui_windows: Report the global cursor state for drag tracking
Implement Platform::global_cursor_state with GetCursorPos scaled by the
DPI of the monitor under the cursor, and the left button state from
GetAsyncKeyState, so drags released outside every window reach the
tear-off handler on Windows too.
2026-10-16 16:55:38 +09:00
yenru0 b3cca826c8 workspace: Move tabs across windows by drag and drop
Dropping a tab on another window's pane, or releasing it outside every
window, previously had no defined behavior: the item entity would be
re-parented into a pane whose project it does not belong to, or the
drag would simply stick. Both gestures now transfer the item by
round-tripping it through the workspace database: the source item is
serialized, recreated in the target pane via the SerializableItem
registry, and closed in its source window. Items that are dirty or not
serializable snap back instead.

A release outside every window opens a new window for the item, sized
like the source window and placed at the cursor, clamped to the
display. Workspace::new_local_with_bounds exposes the window placement
for that; new_local keeps its previous behavior.
2026-10-14 05:10:05 +09:00
yenru0 a7b8190c24 gpui: Track in-app drags outside of every window
Platforms that can query the global cursor position (X11 via
QueryPointer, tests via an injected state) now report it through
Platform::global_cursor_state. While a drag is active, App samples that
state to keep drag_hover_window current: the drag ghost paints only in
the window under the cursor and in no window while the cursor is
outside all of them, fixing stale ghosts left behind in the source
window.

When the button is released outside every window, no window receives
the mouse-up, so the drag used to stick until the next click. The poll
now detects that release and hands the drag to a handler registered
with App::set_drag_dropped_outside_handler, letting the workspace layer
decide what a tear-off means.
2026-10-14 05:09:54 +09:00
yenru0 935e2b0bd0 image_viewer: Remove PDF annotation and form editing
Congratsbot / check-author (push) Skipped
Congratsbot / congrats (push) Waiting for needed jobs
deploy_nightly_docs / deploy_docs (push) Skipped
run_tests / orchestrate (push) Skipped
run_tests / check_style (push) Skipped
run_tests / clippy_windows (push) Skipped
run_tests / clippy_linux (push) Skipped
run_tests / clippy_mac (push) Skipped
run_tests / clippy_mac_x86_64 (push) Skipped
run_tests / run_tests_windows (push) Skipped
run_tests / run_tests_linux (push) Skipped
run_tests / run_tests_mac (push) Skipped
run_tests / miri_scheduler (push) Skipped
run_tests / doctests (push) Skipped
run_tests / check_workspace_binaries (push) Skipped
run_tests / build_visual_tests_binary (push) Skipped
run_tests / check_wasm (push) Skipped
run_tests / check_dependencies (push) Skipped
run_tests / check_docs (push) Skipped
run_tests / check_licenses (push) Skipped
run_tests / check_scripts (push) Skipped
run_tests / check_remote_server_linux_x86_64 (push) Skipped
run_tests / check_remote_server_mac_aarch64 (push) Skipped
run_tests / check_remote_server_windows_x86_64 (push) Skipped
run_tests / check_postgres_and_protobuf_migrations (push) Skipped
run_tests / extension_tests (push) Skipped
run_tests / tests_pass (push) Skipped
The annotation and form-filling surface is being dropped, so remove it
end to end rather than leave it half-maintained.

pdf_viewer loses the editing APIs and everything only they used:
add_ink_annotation, add_highlight_annotation, delete_annotation,
apply_form_edits, the form widget and annotation extraction, the
PdfFormWidget/PdfFormEdit/PdfAnnotation types, the document
serialization helper, and the pdfium guards and constants those
functions were the sole users of. Rendering, text, links, outline and
metadata are untouched; annotation rendering stays on because it paints
markup already embedded in viewed documents.

image_viewer loses the tool palette, form widget overlays, inline form
editors, pen canvas, option menus, the annotate toolbar button, the
Undo Edit and Delete Annotation actions and their key bindings, plus
the associated state and handlers. Text selection, find, links,
thumbnails, outline, properties, rotation, presentation and Save a Copy
remain.
2026-10-08 14:28:45 +09:00
yenru0 4ac68ee5af image_viewer: Make the full page width reachable when zoomed
Three layout issues kept horizontal scrolling from working when a page
was wider than the viewport: the viewport scrollbar was vertical-only,
so nothing could be dragged; centering the pages hid the left half of
an overflowing page, because scroll offsets cannot go positive; and the
centering came from a stretched flex child, which made the container
report no horizontal overflow at all.

The container now aligns children to the start so they keep their
content width, and pages center themselves with an auto margin only
while they still fit. The scrollbar covers both axes.

The interaction test asserts the invariants: the page's left edge
tracks the viewport edge plus the scroll offset, and the scroll range
spans the full page-minus-viewport overflow.
2026-10-08 14:08:30 +09:00
yenru0 0030767614 image_viewer: Fix PDF zoom lag and silent annotation no-ops
Zoom lag: page rasterization ran synchronously on the UI thread with the
exact zoom in the cache key, so every wheel tick re-rendered every
visible page before the next frame. Rasterization now happens in a
background task at a quantized zoom bucket (x1.25) while the closest
already-rendered bitmap stays on screen scaled to the layout; completed
renders swap in via notify. Frames the cache refuses (larger than the
budget) are never re-rendered, which previously spun an infinite
render loop.

The mock fallback renderer skipped the MAX_PAGE_DIMENSION clamp the
native path applies, so its frames grew past the cache budget at high
zoom and were refused on every attempt; the cap is now shared, and
lowered from 8192 to 4096 (a 1080p viewport is oversampled ~4x at that
point, and the worst-case frame drops from 207MB to 67MB).

Annotations: the bundled libpdfium.so was never loaded because the
loader only tried working-directory-relative paths; it now also
resolves next to the executable, matching the bundled layout. Ink,
highlight, and delete editing returned Ok with unchanged bytes when no
native engine was present, so strokes silently did nothing; they now
return errors that surface through the existing logging.
2026-10-08 12:00:01 +09:00
yenru0 67b9fe8b73 image_viewer: Cover pinch zoom in the PDF interaction test
Assert that a pinch gesture scales the PDF zoom by the gesture delta,
pinning the on_pinch wiring across real event dispatch.
2026-10-03 21:14:03 +09:00
yenru0 157c4646bb image_viewer: Add horizontal panning to the PDF viewer
The PDF scroll container only scrolled vertically, so zoomed-in pages
could not be moved sideways. Switch it to two-axis scrolling: ctrl+wheel
and pinch zoom now anchor both axes, middle-drag pans both directions,
and the container shows a closed-hand cursor while panning. The anchor
scroll math is shared per axis.
2026-10-03 20:42:04 +09:00
yenru0 5fd4113081 image_viewer: Add anchored zoom and pan to the PDF viewer
The PDF viewport lacked the interaction set the image view already had:
ctrl/cmd+wheel and pinch zoomed nothing (plain wheel just scrolled),
middle-drag did not pan, and the zoom actions only applied image-mode
pan math, which does nothing for the scroll-based PDF layout.

- Ctrl/cmd+wheel zooms around the cursor: the native scroll handler on
  the same element runs first in the bubble phase, so the applied wheel
  delta is undone before anchoring the zoom.
- Pinch zooms around the touch point; zoom actions anchor the viewport
  center.
- Middle-drag pans, keeping left-drag for text selection and pen input.
- extract_document_details now falls back to a single mock US-Letter
  page when no native libpdfium is present, mirroring the mock
  rasterizer, so PDFs open on machines without the engine instead of
  failing with an image-format error.
- The annotation mouse test gates on native pdfium like the channel
  order test, since mock pages carry no text.

Anchor math lives in pdf_anchored_scroll_y with unit tests, and a
gpui integration test drives real wheel/drag events to assert the
document point under the cursor stays put while zooming.
2026-10-03 20:33:08 +09:00
yenru0 5ee6aa1e50 image_viewer: Fix swapped red and blue channels in PDF pages
GPUI's RenderImage contract is BGRA (assets.rs) and every platform atlas
uploads the bytes raw into BGRA8 textures. All other decode paths swap
R/B before building a RenderImage; the PDF path fed pdfium's RGBA buffer
straight through, so PDF pages rendered with swapped channels on every
platform (white/gray unaffected, colors wrong).

Swap R/B at both RenderImage construction sites (page view + thumbnails)
and pin pdf_viewer's RGBA raster contract with a native channel-order
regression test (skips when no libpdfium is present).
2026-10-03 20:09:25 +09:00
yenru0 312b37539c gwen: Bundle pdfium in macOS builds
Issue Triage Queue Board / Sync triage queue board (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (push) Has been cancelled
release_nightly / run_tests_linux (push) Has been cancelled
release_nightly / bundle_linux_aarch64 (push) Has been cancelled
release_nightly / bundle_linux_x86_64 (push) Has been cancelled
release_nightly / build_static_bwrap_linux_aarch64 (push) Has been cancelled
release_nightly / build_static_bwrap_linux_x86_64 (push) Has been cancelled
release_nightly / bundle_mac_aarch64 (push) Has been cancelled
release_nightly / bundle_mac_x86_64 (push) Has been cancelled
release_nightly / bundle_windows_aarch64 (push) Has been cancelled
release_nightly / bundle_windows_x86_64 (push) Has been cancelled
release_nightly / build_nix_mac_aarch64 (push) Has been cancelled
release_nightly / update_nightly_tag (push) Has been cancelled
release_nightly / notify_on_failure (push) Has been cancelled
Congratsbot / check-author (push) Has been cancelled
deploy_nightly_docs / deploy_docs (push) Has been cancelled
run_tests / orchestrate (push) Has been cancelled
run_tests / check_style (push) Has been cancelled
Update Duplicate Magnets Issue / update-duplicate-magnets (push) Has been cancelled
Congratsbot / congrats (push) Has been cancelled
run_tests / clippy_windows (push) Has been cancelled
run_tests / clippy_linux (push) Has been cancelled
run_tests / clippy_mac (push) Has been cancelled
run_tests / clippy_mac_x86_64 (push) Has been cancelled
run_tests / run_tests_windows (push) Has been cancelled
run_tests / run_tests_linux (push) Has been cancelled
run_tests / run_tests_mac (push) Has been cancelled
run_tests / miri_scheduler (push) Has been cancelled
run_tests / doctests (push) Has been cancelled
run_tests / check_workspace_binaries (push) Has been cancelled
run_tests / build_visual_tests_binary (push) Has been cancelled
run_tests / check_wasm (push) Has been cancelled
run_tests / check_dependencies (push) Has been cancelled
run_tests / check_docs (push) Has been cancelled
run_tests / check_licenses (push) Has been cancelled
run_tests / check_scripts (push) Has been cancelled
run_tests / check_remote_server_linux_x86_64 (push) Has been cancelled
run_tests / check_remote_server_mac_aarch64 (push) Has been cancelled
run_tests / check_remote_server_windows_x86_64 (push) Has been cancelled
run_tests / check_postgres_and_protobuf_migrations (push) Has been cancelled
run_tests / extension_tests (push) Has been cancelled
run_tests / tests_pass (push) Has been cancelled
Guild Stale Assignments / sweep (push) Has been cancelled
Community PR Cleanup / cleanup (push) Has been cancelled
PR Board Meta Fields Refresh / refresh (push) Has been cancelled
Hotfix Review Monitor / check-hotfix-reviews (push) Has been cancelled
Track duplicate bot effectiveness / classify-closed-issue (push) Has been cancelled
Track duplicate bot effectiveness / classify-open (push) Has been cancelled
Stale PR Review Reminder / check-stale-prs (push) Has been cancelled
Update Weekly Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Bump collab-staging Tag / update-collab-staging-tag (push) Has been cancelled
Close Stale Issues / stale (push) Has been cancelled
compliance_check / scheduled_compliance_check (push) Has been cancelled
release_nightly / check_nightly_tag (push) Has been cancelled
Update All Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Guild Weekly Shipped / digest (push) Has been cancelled
script/bundle-mac now downloads the pinned pdfium-mac tarball from
bblanchon/pdfium-binaries into Zed.app/Contents/Frameworks, matching
what bundle-linux and bundle-windows.ps1 already do, so the bundled
pdf_viewer no longer falls back to synthetic rendering on macOS. The
runtime loader probes ../Frameworks next to the executable.
2026-10-01 10:37:32 +09:00
26 changed files with 1485 additions and 2488 deletions

No files matched your search

-1
View File
@@ -1630,7 +1630,6 @@
"ctrl-g": "image_viewer::FindNext",
"ctrl-shift-g": "image_viewer::FindPrevious",
"ctrl-s": "image_viewer::SaveCopyAs",
"ctrl-z": "image_viewer::UndoEdit",
"ctrl-shift-t": "image_viewer::ToggleTextPanel"
},
},
-1
View File
@@ -1682,7 +1682,6 @@
"cmd-g": "image_viewer::FindNext",
"cmd-shift-g": "image_viewer::FindPrevious",
"cmd-s": "image_viewer::SaveCopyAs",
"cmd-z": "image_viewer::UndoEdit",
"cmd-shift-t": "image_viewer::ToggleTextPanel"
},
},
-1
View File
@@ -1605,7 +1605,6 @@
"ctrl-g": "image_viewer::FindNext",
"ctrl-shift-g": "image_viewer::FindPrevious",
"ctrl-s": "image_viewer::SaveCopyAs",
"ctrl-z": "image_viewer::UndoEdit",
"ctrl-shift-t": "image_viewer::ToggleTextPanel"
},
},
+120 -6
View File
@@ -44,14 +44,14 @@ use crate::{
Action, ActionBuildError, ActionRegistry, ActivityGuard, Any, AnyView, AnyWindowHandle,
AppContext, Arena, ArenaBox, Asset, AssetSource, BackgroundExecutor, Bounds, ClipboardItem,
ClipboardReadError, CursorStyle, DispatchPhase, DisplayId, EventEmitter, ExternalDragPayload,
FocusHandle, FocusMap, ForegroundExecutor, Global, KeyBinding, KeyContext, Keymap, Keystroke,
LayoutId, Menu, MenuItem, MissingGlyph, OwnedMenu, PathPromptOptions, Pixels, Platform,
PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, Point, Priority,
PromptBuilder, PromptButton, PromptHandle, PromptLevel, Render, RenderImage,
FocusHandle, FocusMap, ForegroundExecutor, Global, GlobalCursorState, KeyBinding, KeyContext,
Keymap, Keystroke, LayoutId, Menu, MenuItem, MissingGlyph, OwnedMenu, PathPromptOptions,
Pixels, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, Point,
Priority, PromptBuilder, PromptButton, PromptHandle, PromptLevel, Render, RenderImage,
RenderablePromptHandle, Reservation, ScreenCaptureSource, SharedString, SubscriberSet,
Subscription, SvgRenderer, SystemNotification, SystemNotificationResponse, Task,
TextRenderingMode, TextSystem, ThermalState, Window, WindowAppearance, WindowButtonLayout,
WindowHandle, WindowId, WindowInvalidator,
TextRenderingMode, TextSystem, ThermalState, Window, WindowAppearance, WindowBounds,
WindowButtonLayout, WindowHandle, WindowId, WindowInvalidator,
colors::{Colors, GlobalColors},
hash, init_app_menus,
};
@@ -738,6 +738,10 @@ enum PlatformOwnedDragState {
RestoredInSourceWindow,
}
/// How often the global cursor state is sampled while an in-app drag is
/// active, to notice releases that happen outside of every window.
const DRAG_POLL_INTERVAL: Duration = Duration::from_millis(30);
/// Contains the state of the full application, and passed as a reference to a variety of callbacks.
/// Other [Context] derefs to this type.
/// You need a reference to an `App` to access the state of a [Entity].
@@ -749,6 +753,10 @@ pub struct App {
pub(crate) actions: Rc<ActionRegistry>,
pub(crate) active_drag: Option<AnyDrag>,
platform_owned_drag: Option<PlatformOwnedDrag>,
pub(crate) drag_hover_window: Option<Option<WindowId>>,
drag_poll_running: bool,
drag_poll_task: Option<Task<()>>,
drag_dropped_outside_handler: Option<Box<dyn FnMut(&AnyDrag, Point<Pixels>, &mut App)>>,
pub(crate) background_executor: BackgroundExecutor,
pub(crate) foreground_executor: ForegroundExecutor,
#[cfg(feature = "profiler")]
@@ -875,6 +883,10 @@ impl App {
pending_updates: 0,
active_drag: None,
platform_owned_drag: None,
drag_hover_window: None,
drag_poll_running: false,
drag_poll_task: None,
drag_dropped_outside_handler: None,
background_executor,
foreground_executor,
#[cfg(feature = "profiler")]
@@ -2636,6 +2648,7 @@ impl App {
pub fn stop_active_drag(&mut self, window: &mut Window) -> bool {
if self.active_drag.is_some() {
self.active_drag = None;
self.drag_hover_window = None;
if self.platform_owned_drag.as_ref().is_some_and(|drag| {
drag.source_window == window.window_handle().window_id()
&& matches!(&drag.state, PlatformOwnedDragState::RestoredInSourceWindow)
@@ -2649,6 +2662,107 @@ impl App {
}
}
/// Registers a handler invoked when an in-app drag is released outside of
/// every application window, on platforms that report the global cursor
/// state.
pub fn set_drag_dropped_outside_handler(
&mut self,
handler: impl FnMut(&AnyDrag, Point<Pixels>, &mut App) + 'static,
) {
self.drag_dropped_outside_handler = Some(Box::new(handler));
}
pub(crate) fn should_paint_drag_in(&self, window_id: WindowId) -> bool {
match self.drag_hover_window {
None => true,
Some(hovered_window) => hovered_window == Some(window_id),
}
}
pub(crate) fn set_drag_hover_window(&mut self, hovered: Option<Option<WindowId>>) {
if self.drag_hover_window == hovered {
return;
}
let previous = mem::replace(&mut self.drag_hover_window, hovered).flatten();
let next = hovered.flatten();
for window_id in [previous, next].into_iter().flatten() {
if let Some(handle) = self.window_handles.get(&window_id).copied() {
handle
.update(self, |_, window, _| window.refresh())
.log_err();
}
}
}
pub(crate) fn ensure_drag_poll_running(&mut self) {
if self.drag_poll_running || self.active_drag.is_none() {
return;
}
if self.platform.global_cursor_state().is_none() {
return;
}
self.drag_poll_running = true;
self.drag_poll_task = Some(self.spawn(async move |cx| {
loop {
cx.background_executor.timer(DRAG_POLL_INTERVAL).await;
let Some(state) = cx.update(|cx: &mut App| cx.platform.global_cursor_state())
else {
break;
};
let finished = cx.update(|cx: &mut App| cx.poll_drag_state(state));
if finished {
break;
}
}
cx.update(|cx: &mut App| cx.drag_poll_running = false);
}));
}
fn poll_drag_state(&mut self, state: GlobalCursorState) -> bool {
if self.active_drag.is_none() {
self.set_drag_hover_window(None);
return true;
}
let hovered_window = self.window_under_point(state.position);
self.set_drag_hover_window(Some(hovered_window));
if state.left_button_pressed {
return false;
}
if hovered_window.is_none()
&& let Some(drag) = self.active_drag.take()
{
self.set_drag_hover_window(None);
let mut handler = self.drag_dropped_outside_handler.take();
if let Some(handler) = handler.as_mut() {
handler(&drag, state.position, self);
}
self.drag_dropped_outside_handler = handler;
self.refresh_windows();
}
true
}
fn window_under_point(&mut self, position: Point<Pixels>) -> Option<WindowId> {
let ordered_handles = self
.platform
.window_stack()
.unwrap_or_else(|| self.window_handles.values().copied().collect());
for handle in ordered_handles {
let Ok(bounds) = handle.update(self, |_, window, _| window.window_bounds()) else {
continue;
};
let bounds = match bounds {
WindowBounds::Windowed(bounds)
| WindowBounds::Maximized(bounds)
| WindowBounds::Fullscreen(bounds) => bounds,
};
if bounds.contains(&position) {
return Some(handle.window_id());
}
}
None
}
pub(crate) fn hand_active_drag_to_platform(&mut self, source_window: WindowId) -> bool {
let Some(drag) = self.active_drag.take() else {
return false;
+13 -6
View File
@@ -1,12 +1,13 @@
use crate::{
Action, AnyView, AnyWindowHandle, App, AppCell, AppContext, AsyncApp, AvailableSpace,
BackgroundExecutor, BorrowAppContext, Bounds, Capslock, ClipboardItem, DrawPhase, Drawable,
Element, Empty, EntityId, EventEmitter, ForegroundExecutor, Global, InputEvent, Keystroke,
Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent,
Pixels, Platform, PlatformTextSystem, Point, Render, Result, SharedString, Size,
SystemNotification, SystemNotificationResponse, Task, TestDispatcher, TestPlatform,
TestScreenCaptureSource, TestWindow, TextSystem, VisualContext, Window, WindowBounds,
WindowHandle, WindowOptions, WindowVisibility, app::GpuiMode, window::ElementArenaScope,
Element, Empty, EntityId, EventEmitter, ForegroundExecutor, Global, GlobalCursorState,
InputEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent,
MouseMoveEvent, MouseUpEvent, Pixels, Platform, PlatformTextSystem, Point, Render, Result,
SharedString, Size, SystemNotification, SystemNotificationResponse, Task, TestDispatcher,
TestPlatform, TestScreenCaptureSource, TestWindow, TextSystem, VisualContext, Window,
WindowBounds, WindowHandle, WindowOptions, WindowVisibility, app::GpuiMode,
window::ElementArenaScope,
};
use anyhow::{anyhow, bail};
use futures::{Stream, StreamExt, channel::oneshot};
@@ -500,6 +501,12 @@ impl TestAppContext {
self.test_platform.set_screen_capture_sources(sources);
}
/// Sets the global cursor state reported by the test platform, for
/// exercising drags that leave every window.
pub fn set_global_cursor_state(&self, state: Option<GlobalCursorState>) {
self.test_platform.set_global_cursor_state(state);
}
/// Returns all windows open in the test.
pub fn windows(&self) -> Vec<AnyWindowHandle> {
self.app.borrow().windows()
+17
View File
@@ -182,6 +182,16 @@ pub fn guess_compositor() -> &'static str {
}
}
/// The global cursor position and mouse button state, used to follow
/// in-app drags once the cursor leaves every application window.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct GlobalCursorState {
/// The cursor position in global display coordinates.
pub position: Point<Pixels>,
/// Whether the left mouse button is currently pressed.
pub left_button_pressed: bool,
}
#[expect(missing_docs)]
pub trait Platform: 'static {
fn background_executor(&self) -> BackgroundExecutor;
@@ -203,6 +213,13 @@ pub trait Platform: 'static {
None
}
/// The global cursor position and button state, for platforms that can
/// query it without an active pointer grab. Returning `None` disables
/// following drags outside of the application's windows.
fn global_cursor_state(&self) -> Option<GlobalCursorState> {
None
}
fn is_screen_capture_supported(&self) -> bool {
false
}
+16 -5
View File
@@ -4,11 +4,11 @@ use crate::NoopTextSystem;
use crate::PathPromptOptions;
use crate::{
ActivityGuard, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DevicePixels,
DummyKeyboardMapper, ForegroundExecutor, Keymap, OwnedMenu, Platform, PlatformDisplay,
PlatformHeadlessRenderer, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem,
PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, SharedString,
SourceMetadata, SystemNotification, SystemNotificationResponse, Task, TestDisplay, TestWindow,
ThermalState, WindowAppearance, WindowParams, size,
DummyKeyboardMapper, ForegroundExecutor, GlobalCursorState, Keymap, OwnedMenu, Platform,
PlatformDisplay, PlatformHeadlessRenderer, PlatformKeyboardLayout, PlatformKeyboardMapper,
PlatformTextSystem, PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream,
SharedString, SourceMetadata, SystemNotification, SystemNotificationResponse, Task,
TestDisplay, TestWindow, ThermalState, WindowAppearance, WindowParams, size,
};
use anyhow::Result;
#[cfg(any(test, feature = "test-support"))]
@@ -42,6 +42,7 @@ pub(crate) struct TestPlatform {
#[cfg(any(test, feature = "test-support"))]
pub(crate) prompts: RefCell<TestPrompts>,
screen_capture_sources: RefCell<Vec<TestScreenCaptureSource>>,
pub(crate) global_cursor_state: RefCell<Option<GlobalCursorState>>,
pub opened_url: RefCell<Option<String>>,
pub(crate) system_notifications: RefCell<TestSystemNotifications>,
pub text_system: Arc<dyn PlatformTextSystem>,
@@ -153,6 +154,7 @@ impl TestPlatform {
#[cfg(any(test, feature = "test-support"))]
prompts: Default::default(),
screen_capture_sources: Default::default(),
global_cursor_state: Default::default(),
active_cursor: Default::default(),
active_display: Rc::new(TestDisplay::new()),
active_window: Default::default(),
@@ -174,6 +176,11 @@ impl TestPlatform {
})
}
/// Sets the global cursor state reported by [`Platform::global_cursor_state`].
pub fn set_global_cursor_state(&self, state: Option<GlobalCursorState>) {
*self.global_cursor_state.borrow_mut() = state;
}
#[cfg(any(test, feature = "test-support"))]
pub(crate) fn simulate_new_path_selection(
&self,
@@ -489,6 +496,10 @@ impl Platform for TestPlatform {
.map(|window| window.0.lock().handle)
}
fn global_cursor_state(&self) -> Option<GlobalCursorState> {
*self.global_cursor_state.borrow()
}
fn open_window(
&self,
handle: AnyWindowHandle,
+3 -1
View File
@@ -1442,7 +1442,9 @@ pub fn font_name_with_fallbacks_shared<'a>(
// IBM Plex/Lilex, and so retained here for backward compatibility.
match name.as_str() {
".SystemUIFont" => system,
".GwenSans" | ".ZedSans" | "Zed Plex Sans" => const { &SharedString::new_static("IBM Plex Sans") },
".GwenSans" | ".ZedSans" | "Zed Plex Sans" => {
const { &SharedString::new_static("IBM Plex Sans") }
}
".GwenMono" | ".ZedMono" | "Zed Plex Mono" => const { &SharedString::new_static("Lilex") },
_ => name,
}
+70 -5
View File
@@ -3563,7 +3563,9 @@ impl Window {
element.prepaint_as_root(Point::default(), root_size.into(), self, cx);
prompt_element = Some(element);
self.prompt = Some(prompt);
} else if let Some(active_drag) = cx.active_drag.take() {
} else if cx.should_paint_drag_in(self.handle.window_id())
&& let Some(active_drag) = cx.active_drag.take()
{
let mut element = active_drag.view.clone().into_any_element();
let offset = self.mouse_position() - active_drag.cursor_offset;
element.prepaint_as_root(offset, AvailableSpace::min_size(), self, cx);
@@ -5789,14 +5791,17 @@ impl Window {
self.rendered_frame.mouse_listeners = mouse_listeners;
if cx.has_active_drag() {
cx.ensure_drag_poll_running();
if event.is::<MouseMoveEvent>() {
// If this was a mouse move event, redraw the window so that the
// active drag can follow the mouse cursor.
cx.set_drag_hover_window(Some(Some(self.handle.window_id())));
self.refresh();
} else if event.is::<MouseUpEvent>() {
// If this was a mouse up event, cancel the active drag and redraw
// the window.
cx.active_drag = None;
cx.set_drag_hover_window(None);
self.refresh();
}
}
@@ -7640,15 +7645,16 @@ mod tests {
cell::{Cell, RefCell},
path::PathBuf,
rc::Rc,
sync::Arc,
time::Duration,
};
use crate::{
AnyWindowHandle, AppContext as _, Bounds, ContentMask, Context, DispatchPhase,
AnyDrag, AnyWindowHandle, AppContext as _, Bounds, ContentMask, Context, DispatchPhase,
DragMoveEvent, Empty, ExternalDragPayload, ExternalPaths, FileDragPaths, FileDropEvent,
FocusHandle, InputEvent as _, InteractiveElement as _, IntoElement, KeyDownEvent,
Keystroke, LongPressEvent, MouseButton, MouseDownEvent, MouseMoveEvent, ParentElement,
Pixels, PlatformInput, Point, Render, RequestFrameOptions, ScaledPixels,
FocusHandle, GlobalCursorState, InputEvent as _, InteractiveElement as _, IntoElement,
KeyDownEvent, Keystroke, LongPressEvent, MouseButton, MouseDownEvent, MouseMoveEvent,
ParentElement, Pixels, PlatformInput, Point, Render, RequestFrameOptions, ScaledPixels,
StatefulInteractiveElement as _, Styled, TestAppContext, TouchDragEvent, TouchEvent,
TouchId, TouchPhase, Underline, UnderlineStyle, Window, WindowAppearance, WindowOptions,
canvas, div, hsla, point, px, size,
@@ -9109,6 +9115,65 @@ mod tests {
})
.unwrap();
}
#[gpui::test]
fn test_drag_release_outside_windows_notifies_handler(cx: &mut TestAppContext) {
let dropped_position = Rc::new(RefCell::new(None));
let observed_position = dropped_position.clone();
cx.update(|cx| {
cx.set_drag_dropped_outside_handler(move |_drag, position, _cx| {
*observed_position.borrow_mut() = Some(position);
});
});
let window: AnyWindowHandle = cx.add_window(|_, _| EmptyView).into();
cx.set_global_cursor_state(Some(GlobalCursorState {
position: point(px(10.), px(10.)),
left_button_pressed: true,
}));
cx.update_window(window, |_, window, cx| {
cx.active_drag = Some(AnyDrag {
value: Arc::new("payload"),
view: cx.new(|_| Empty).into(),
cursor_offset: point(px(0.), px(0.)),
cursor_style: None,
external_payload_source: None,
});
cx.ensure_drag_poll_running();
window.refresh();
})
.unwrap();
cx.executor().advance_clock(Duration::from_millis(50));
cx.run_until_parked();
assert_eq!(
cx.read(|cx| cx.drag_hover_window),
Some(Some(window.window_id()))
);
// While the button stays pressed outside every window, the drag lives
// on but no window is asked to paint the ghost.
cx.set_global_cursor_state(Some(GlobalCursorState {
position: point(px(10_000.), px(10_000.)),
left_button_pressed: true,
}));
cx.executor().advance_clock(Duration::from_millis(50));
cx.run_until_parked();
assert_eq!(cx.read(|cx| cx.drag_hover_window), Some(None));
assert!(cx.read(|cx| cx.active_drag.is_some()));
// Releasing outside every window hands the drag to the handler.
cx.set_global_cursor_state(Some(GlobalCursorState {
position: point(px(10_000.), px(10_000.)),
left_button_pressed: false,
}));
cx.executor().advance_clock(Duration::from_millis(50));
cx.run_until_parked();
assert_eq!(
*dropped_position.borrow(),
Some(point(px(10_000.), px(10_000.)))
);
assert!(cx.read(|cx| cx.active_drag.is_none()));
}
}
#[cfg(all(test, any(feature = "inspector", debug_assertions)))]
@@ -7,8 +7,8 @@ use gpui_util::ResultExt;
use crate::linux::headless::window::{HeadlessDisplay, HeadlessWindow};
use crate::linux::{LinuxClient, LinuxCommon, LinuxKeyboardLayout};
use gpui::{
AnyWindowHandle, CursorStyle, DisplayId, PlatformDisplay, PlatformKeyboardLayout,
PlatformWindow, WindowParams,
AnyWindowHandle, CursorStyle, DisplayId, GlobalCursorState, PlatformDisplay,
PlatformKeyboardLayout, PlatformWindow, WindowParams,
};
pub struct HeadlessClientState {
@@ -97,6 +97,10 @@ impl LinuxClient for HeadlessClient {
None
}
fn global_cursor_state(&self) -> Option<GlobalCursorState> {
None
}
fn open_window(
&self,
_handle: AnyWindowHandle,
+9 -4
View File
@@ -33,10 +33,10 @@ use xkbcommon::xkb::{self, Keycode, Keysym, State};
use crate::linux::{LinuxDispatcher, PriorityQueueCalloopReceiver};
use gpui::{
Action, ActivityGuard, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle,
DisplayId, ForegroundExecutor, Keymap, Menu, MenuItem, OwnedMenu, PathPromptOptions, Platform,
PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem,
PlatformWindow, Result, RunnableVariant, Task, ThermalState, WindowAppearance,
WindowButtonLayout, WindowParams,
DisplayId, ForegroundExecutor, GlobalCursorState, Keymap, Menu, MenuItem, OwnedMenu,
PathPromptOptions, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper,
PlatformTextSystem, PlatformWindow, Result, RunnableVariant, Task, ThermalState,
WindowAppearance, WindowButtonLayout, WindowParams,
};
#[cfg(any(feature = "wayland", feature = "x11"))]
use gpui::{Pixels, Point, px};
@@ -101,6 +101,7 @@ pub(crate) trait LinuxClient {
fn read_from_clipboard(&self) -> Option<ClipboardItem>;
fn active_window(&self) -> Option<AnyWindowHandle>;
fn window_stack(&self) -> Option<Vec<AnyWindowHandle>>;
fn global_cursor_state(&self) -> Option<GlobalCursorState>;
fn run(&self);
#[cfg(any(feature = "wayland", feature = "x11"))]
@@ -436,6 +437,10 @@ impl<P: LinuxClient + 'static> Platform for LinuxPlatform<P> {
self.inner.window_stack()
}
fn global_cursor_state(&self) -> Option<GlobalCursorState> {
self.inner.global_cursor_state()
}
fn open_window(
&self,
handle: AnyWindowHandle,
@@ -97,9 +97,9 @@ use crate::linux::{
};
use gpui::{
AnyWindowHandle, Bounds, Capslock, CursorStyle, DevicePixels, DisplayId, ExternalDragPayload,
FileDragPaths, FileDropEvent, ForegroundExecutor, KeyDownEvent, KeyUpEvent, Keystroke,
Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseExitEvent, MouseMoveEvent,
MouseUpEvent, NavigationDirection, Pixels, PlatformDisplay, PlatformInput,
FileDragPaths, FileDropEvent, ForegroundExecutor, GlobalCursorState, KeyDownEvent, KeyUpEvent,
Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseExitEvent,
MouseMoveEvent, MouseUpEvent, NavigationDirection, Pixels, PlatformDisplay, PlatformInput,
PlatformKeyboardLayout, PlatformWindow, Point, ScrollDelta, ScrollWheelEvent, SharedString,
Size, TouchPhase, WindowButtonLayout, WindowKind, WindowParams, point, profiler, px, size,
};
@@ -1368,6 +1368,10 @@ impl LinuxClient for WaylandClient {
None
}
fn global_cursor_state(&self) -> Option<GlobalCursorState> {
None
}
fn compositor_name(&self) -> &'static str {
"Wayland"
}
+23 -4
View File
@@ -60,10 +60,11 @@ use crate::linux::{
use crate::linux::{LinuxCommon, LinuxKeyboardLayout, X11Window, modifiers_from_xinput_info};
use gpui::{
AnyWindowHandle, Bounds, ClipboardItem, CursorStyle, DisplayId, FileDropEvent, Keystroke,
Modifiers, ModifiersChangedEvent, MouseButton, Pixels, PlatformDisplay, PlatformInput,
PlatformKeyboardLayout, PlatformWindow, Point, RequestFrameOptions, ScrollDelta, Size,
TouchPhase, WindowButtonLayout, WindowParams, WindowVisibility, point, px,
AnyWindowHandle, Bounds, ClipboardItem, CursorStyle, DisplayId, FileDropEvent,
GlobalCursorState, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, Pixels,
PlatformDisplay, PlatformInput, PlatformKeyboardLayout, PlatformWindow, Point,
RequestFrameOptions, ScrollDelta, Size, TouchPhase, WindowButtonLayout, WindowParams,
WindowVisibility, point, px,
};
use gpui_wgpu::{CompositorGpuHint, GpuContext};
@@ -1878,6 +1879,24 @@ impl LinuxClient for X11Client {
Some(handles)
}
fn global_cursor_state(&self) -> Option<GlobalCursorState> {
let state = self.0.borrow();
let root = state.xcb_connection.setup().roots[state.x_root_index].root;
let scale_factor = state.scale_factor;
let reply = get_reply(
|| "Failed to query pointer position",
state.xcb_connection.query_pointer(root),
)
.log_err()?;
Some(GlobalCursorState {
position: point(
px(reply.root_x as f32 / scale_factor),
px(reply.root_y as f32 / scale_factor),
),
left_button_pressed: reply.mask.contains(xproto::KeyButMask::BUTTON1),
})
}
fn window_identifier(&self) -> impl Future<Output = Option<WindowIdentifier>> + Send + 'static {
let state = self.0.borrow();
state
+1 -1
View File
@@ -201,7 +201,7 @@ fn generate_uuid(device_name: &[u16]) -> Uuid {
Uuid::new_v5(&Uuid::NAMESPACE_DNS, &name)
}
fn get_scale_factor_for_monitor(monitor: HMONITOR) -> Result<f32> {
pub(crate) fn get_scale_factor_for_monitor(monitor: HMONITOR) -> Result<f32> {
let mut dpi_x = 0;
let mut dpi_y = 0;
unsafe { GetDpiForMonitor(monitor, MDT_EFFECTIVE_DPI, &mut dpi_x, &mut dpi_y) }?;
+21
View File
@@ -636,6 +636,27 @@ impl Platform for WindowsPlatform {
.map(|inner| inner.handle)
}
fn global_cursor_state(&self) -> Option<GlobalCursorState> {
let mut point: POINT = unsafe { std::mem::zeroed() };
unsafe { GetCursorPos(&mut point) }
.context("unable to get cursor position")
.log_err()?;
let scale_factor = unsafe {
let monitor = MonitorFromPoint(point, MONITOR_DEFAULTTONEAREST);
if monitor.is_invalid() {
1.0
} else {
get_scale_factor_for_monitor(monitor).log_err()?
}
};
let left_button_pressed =
unsafe { GetAsyncKeyState(VK_LBUTTON.0 as i32) } as u16 & 0x8000 != 0;
Some(GlobalCursorState {
position: logical_point(point.x as f32, point.y as f32, scale_factor),
left_button_pressed,
})
}
fn open_window(
&self,
handle: AnyWindowHandle,
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -52,8 +52,9 @@ A native, high performance PDF viewing engine and GPUI workspace item engineered
## 2. Scope Boundaries
- **Continuous Scroll & Text Selection**: Supports fluid high frame rate document browsing and visual text selection.
- **Excluded from initial scope**:
- **Excluded from scope**:
- Interactive PDF form field editing.
- Annotation authoring, editing and deletion.
- *Rationale*: Delivering an ultra fast, stutter free viewing experience with zero UI locks takes precedence.
---
+20
View File
@@ -191,6 +191,26 @@ impl<T: CacheablePage> PageLruCache<T> {
false
}
/// Returns true if an entry with exactly this key is currently cached.
pub fn contains(&self, key: &CacheKey) -> bool {
self.entries.contains_key(key)
}
/// Returns the cached page for `page_index` whose zoom is closest to `zoom_factor`,
/// ignoring LRU order, so a zoom change can keep showing the previous bitmap while a
/// fresh one renders.
pub fn closest_to_zoom(&self, page_index: usize, zoom_factor: f32) -> Option<&T> {
self.entries
.iter()
.filter(|(key, _)| key.page_index == page_index)
.min_by(|(a, _), (b, _)| {
let distance_a = (a.zoom_bucket as f32 - zoom_factor * 100.0).abs();
let distance_b = (b.zoom_bucket as f32 - zoom_factor * 100.0).abs();
distance_a.total_cmp(&distance_b)
})
.map(|(_, value)| value)
}
/// Clears all entries from the cache and resets memory tracking.
pub fn clear(&mut self) {
self.entries.clear();
+2 -3
View File
@@ -22,9 +22,8 @@ pub mod watcher;
pub use cache::{CacheKey, CacheablePage, DEFAULT_MEMORY_BUDGET_BYTES, PageLruCache, RenderedPage};
pub use document::{PageDimensions, PdfDocument};
pub use pdfium::{
PdfAnnotationColor, PdfAnnotationInfo, PdfAnnotationKind, PdfCharBox, PdfDocumentDetails,
PdfFormEdit, PdfFormWidget, PdfFormWidgetKind, PdfLinkAnnotation, PdfMetadata, PdfOutlineItem,
PdfPageDetails, PdfTextSegment, PdfiumEngine,
PdfCharBox, PdfDocumentDetails, PdfLinkAnnotation, PdfMetadata, PdfOutlineItem, PdfPageDetails,
PdfTextSegment, PdfiumEngine,
};
pub use rasterizer::{LuminosityToneMapper, PageRasterizer, RasterizerOptions};
pub use settings::{DefaultZoomPolicy, PageLayoutMode, PdfViewerSettings};
File diff suppressed because it is too large. Load diff
+11 -1
View File
@@ -5,6 +5,7 @@
use crate::cache::RenderedPage;
use crate::document::PageDimensions;
use crate::pdfium::compute_target_dimensions;
use anyhow::Result;
/// Rendering and tone mapping configuration.
@@ -160,7 +161,16 @@ impl PageRasterizer {
dimensions: PageDimensions,
options: RasterizerOptions,
) -> Result<RenderedPage> {
let (width, height) = dimensions.to_pixel_size(options.zoom_factor, options.target_dpi);
// Mirror the native renderer's clamp: an unclamped frame at high zoom can grow
// past the page cache budget, which would make the cache refuse the entry and
// the caller re-render it forever.
let (width, height) = compute_target_dimensions(
dimensions.width,
dimensions.height,
options.target_dpi,
options.zoom_factor,
);
let (width, height) = (width.max(1) as u32, height.max(1) as u32);
let total_bytes = (width as usize) * (height as usize) * 4;
// Create standard white paper background with an inner border
+82 -1
View File
@@ -1,4 +1,5 @@
use pdf_viewer::{PageLayoutMode, PdfToolbarState, PdfView, PdfViewerSettings};
use pdf_viewer::rasterizer::RasterizerOptions;
use pdf_viewer::{PageLayoutMode, PdfToolbarState, PdfView, PdfViewerSettings, PdfiumEngine};
use std::time::Duration;
const MINIMAL_PDF: &[u8] = b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\nxref\n0 4\n0000000000 65535 f \n0000000010 00000 n \n0000000060 00000 n \n0000000117 00000 n \ntrailer\n<< /Size 4 /Root 1 0 R >>\nstartxref\n185\n%%EOF\n";
@@ -105,3 +106,83 @@ fn test_toolbar_synchronization_with_view_state() {
assert!(toolbar_state.dark_mode);
assert_eq!(toolbar_state.layout_mode, PageLayoutMode::Continuous);
}
/// Builds a single-page PDF whose entire canvas is filled pure red, with a fully
/// computed xref table so no repair parsing is involved.
fn red_page_pdf() -> Vec<u8> {
let content = b"1 0 0 rg 0 0 612 792 re f";
let objects: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << >> /Contents 4 0 R >>"
.to_vec(),
format!(
"<< /Length {} >>\nstream\n{}\nendstream",
content.len(),
String::from_utf8_lossy(content)
)
.into_bytes(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::with_capacity(objects.len());
for (index, object) in objects.iter().enumerate() {
offsets.push(out.len() as u32);
out.extend_from_slice(format!("{} 0 obj\n", index + 1).as_bytes());
out.extend_from_slice(object);
out.extend_from_slice(b"\nendobj\n");
}
let xref_offset = out.len() as u32;
out.extend_from_slice(format!("xref\n0 {}\n", objects.len() + 1).as_bytes());
out.extend_from_slice(b"0000000000 65535 f \n");
for offset in &offsets {
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(
format!(
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{}\n%%EOF",
objects.len() + 1,
xref_offset
)
.as_bytes(),
);
out
}
/// Guards against PDFium's native BGRA bitmap order leaking through: the rasterizer
/// must hand out RGBA buffers, so a red page must read as high-R/low-B. The mock
/// renderer also draws red, so this only proves something when a native engine
/// bound; without libpdfium the test cannot exercise the real byte order.
#[test]
fn test_native_render_outputs_rgba_channel_order() {
let engine = PdfiumEngine::new();
if !engine.is_native_available() {
eprintln!("skipping: native libpdfium not available on this machine");
return;
}
let rendered = engine
.render_page_from_bytes(&red_page_pdf(), 0, RasterizerOptions::default())
.expect("native render should succeed");
let stride = rendered.width as usize * 4;
assert_eq!(rendered.rgba_buffer.len(), stride * rendered.height as usize);
for (x, y) in [
(rendered.width as usize / 2, rendered.height as usize / 2),
(rendered.width as usize / 4, rendered.height as usize / 4),
(
rendered.width as usize * 3 / 4,
rendered.height as usize * 3 / 4,
),
] {
let pixel = &rendered.rgba_buffer[y * stride + x * 4..y * stride + x * 4 + 4];
let (r, g, b, a) = (pixel[0], pixel[1], pixel[2], pixel[3]);
assert!(
r > 200 && b < 60,
"buffer is not RGBA-ordered at ({x},{y}): R={r} G={g} B={b} A={a}"
);
assert_eq!(a, 255, "opaque page should carry alpha 255");
}
}
+19 -21
View File
@@ -7,16 +7,16 @@ use std::{
use fs::Fs;
use gpui::{
point, px, relative, size, BackgroundExecutor, Bounds, Context, Corners, Element,
ElementId, Empty, GlobalElementId, InspectorElementId, IntoElement, LayoutId,
ParentElement, Pixels, Render, RenderImage, Style, Styled, Subscription, Task, Window,
BackgroundExecutor, Bounds, Context, Corners, Element, ElementId, Empty, GlobalElementId,
InspectorElementId, IntoElement, LayoutId, ParentElement, Pixels, Render, RenderImage, Style,
Styled, Subscription, Task, Window, point, px, relative, size,
};
use util::ResultExt;
use image::Frame;
use settings::{
BackgroundImageFit, BackgroundImagePosition, RegisterSetting, Settings, SettingsStore,
};
use smallvec::SmallVec;
use util::ResultExt;
/// Default opacity of the editor and terminal surfaces drawn on top of
/// the background image. Lower values let the image show through more.
@@ -125,11 +125,9 @@ impl BackgroundImage {
let path = path.clone();
let path_for_check = path.clone();
let cache_dir = cache_dir.clone();
let load_task = executor
.clone()
.spawn(async move {
load_background_image(&fs, &path, &cache_dir, executor.clone()).await
});
let load_task = executor.clone().spawn(async move {
load_background_image(&fs, &path, &cache_dir, executor.clone()).await
});
let image = load_task.await;
this.update(cx, |this, cx| {
@@ -335,7 +333,7 @@ async fn load_background_image(
);
load_from_cache(fs, path, cache_dir).await
}
}
},
futures::future::Either::Left((Err(error), _)) => {
log::info!(
"failed to load background image at {}: {error}",
@@ -441,9 +439,7 @@ mod tests {
cx: &mut TestAppContext,
fs: Arc<FakeFs>,
) -> gpui::Entity<BackgroundImage> {
cx.update(|cx| {
cx.new(|cx| BackgroundImage::new(fs, Arc::from(Path::new(CACHE_DIR)), cx))
})
cx.update(|cx| cx.new(|cx| BackgroundImage::new(fs, Arc::from(Path::new(CACHE_DIR)), cx)))
}
/// A minimal 2x2 PNG of the given color.
@@ -523,7 +519,8 @@ mod tests {
settings_store(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_file("/background.png", png_bytes(image::Rgba([255, 0, 0, 255]))).await;
fs.insert_file("/background.png", png_bytes(image::Rgba([255, 0, 0, 255])))
.await;
let background_image = new_background_image(cx, fs.clone());
cx.run_until_parked();
@@ -581,11 +578,10 @@ mod tests {
settings_store(cx);
let fs = FakeFs::new(cx.executor());
let cache_path = cache_path_for(
Path::new("/remote/background.png"),
Path::new(CACHE_DIR),
);
fs.write(&cache_path, &png_bytes(image::Rgba([255, 0, 0, 255]))).await.unwrap();
let cache_path = cache_path_for(Path::new("/remote/background.png"), Path::new(CACHE_DIR));
fs.write(&cache_path, &png_bytes(image::Rgba([255, 0, 0, 255])))
.await
.unwrap();
let background_image = new_background_image(cx, fs);
set_background_path(cx, Some("/remote/background.png"));
@@ -618,7 +614,8 @@ mod tests {
settings_store(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_file("/broken.png", b"not an image".to_vec()).await;
fs.insert_file("/broken.png", b"not an image".to_vec())
.await;
let background_image = new_background_image(cx, fs);
set_background_path(cx, Some("/broken.png"));
@@ -634,7 +631,8 @@ mod tests {
settings_store(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_file("/background.png", png_bytes(image::Rgba([255, 0, 0, 255]))).await;
fs.insert_file("/background.png", png_bytes(image::Rgba([255, 0, 0, 255])))
.await;
let background_image = new_background_image(cx, fs);
set_background_path(cx, Some("/background.png"));
+262 -7
View File
@@ -1,7 +1,8 @@
use crate::{
CloseWindow, NewCenterTerminal, NewFile, NewTerminal, OpenInTerminal, OpenOptions,
OpenTerminal, OpenVisible, SplitDirection, ToggleFileFinder, ToggleProjectSymbols, ToggleZoom,
Workspace, WorkspaceItemBuilder, ZoomIn, ZoomOut,
OpenTerminal, OpenVisible, SerializableItemRegistry, SplitDirection, ToggleFileFinder,
ToggleProjectSymbols, ToggleZoom, Workspace, WorkspaceId, WorkspaceItemBuilder, ZoomIn,
ZoomOut,
focus_follows_mouse::FocusFollowsMouse as _,
invalid_item_view::InvalidItemView,
item::{
@@ -14,7 +15,7 @@ use crate::{
toolbar::Toolbar,
workspace_settings::{AutosaveSetting, FocusFollowsMouse, TabBarSettings, WorkspaceSettings},
};
use anyhow::Result;
use anyhow::{Result, anyhow};
use collections::{BTreeSet, HashMap, HashSet, VecDeque};
use futures::{StreamExt, stream::FuturesUnordered};
use git::{CopyFilePermalink, OpenFilePermalink};
@@ -3884,9 +3885,27 @@ impl Pane {
let from_pane = dragged_tab.pane.clone();
let dragged_item = dragged_tab.item.boxed_clone();
self.workspace
.update(cx, |_, cx| {
cx.defer_in(window, move |workspace, window, cx| {
let from_workspace_id =
from_pane.read_with(cx, |pane, _| pane.workspace.entity_id());
if from_workspace_id != cx.entity_id() {
let to_pane = to_pane.downgrade();
if let Some(transfer) =
prepare_item_transfer(&*dragged_item, &from_pane, cx)
{
cx.spawn_in(window, async move |_, cx| {
complete_item_transfer(transfer, to_pane, ix, cx)
.await
.log_err();
})
.detach();
}
return;
}
if let Some(split_direction) = split_direction {
to_pane = workspace.split_pane(to_pane, split_direction, window, cx);
}
@@ -5080,18 +5099,108 @@ impl Render for DraggedTab {
}
}
/// The pieces needed to recreate an item in another workspace by round
/// tripping it through the workspace database. Item entities are bound to
/// their workspace's project, so moves across windows cannot move the entity
/// itself.
pub(crate) struct ItemTransfer {
kind: Arc<str>,
source_workspace_id: WorkspaceId,
item_id: EntityId,
source_pane: Entity<Pane>,
serialize_task: Task<Result<()>>,
}
/// Prepares to recreate `item` in another workspace. Returns `None` when the
/// item has unsaved changes or is not serializable, in which case the drag
/// snaps back instead of moving the entity across projects.
pub(crate) fn prepare_item_transfer(
item: &dyn ItemHandle,
source_pane: &Entity<Pane>,
cx: &mut App,
) -> Option<ItemTransfer> {
if item.is_dirty(cx) {
return None;
}
let serializable =
SerializableItemRegistry::view_to_serializable_item_handle(item.to_any_view(), cx)?;
let source_workspace = source_pane.read(cx).workspace.upgrade()?;
let source_workspace_id = source_workspace.read(cx).database_id()?;
let serialize_task = source_workspace.update(cx, |workspace, cx| {
serializable.serialize(workspace, false, cx)
})?;
Some(ItemTransfer {
kind: Arc::from(serializable.serialized_item_kind()),
source_workspace_id,
item_id: item.item_id(),
source_pane: source_pane.clone(),
serialize_task,
})
}
/// Recreates a transferred item at index `ix` of `to_pane`, then closes the
/// source item in its own window.
pub(crate) async fn complete_item_transfer(
transfer: ItemTransfer,
to_pane: WeakEntity<Pane>,
ix: usize,
cx: &mut AsyncWindowContext,
) -> Result<()> {
transfer.serialize_task.await.log_err();
let project = to_pane
.update(cx, |pane, _| pane.project.upgrade())?
.ok_or_else(|| anyhow!("target pane's project was dropped"))?;
let workspace = to_pane.update(cx, |pane, _| pane.workspace.clone())?;
let item = to_pane
.update_in(cx, |_, window, cx| {
SerializableItemRegistry::deserialize(
&transfer.kind,
project,
workspace,
transfer.source_workspace_id,
transfer.item_id.as_u64(),
window,
cx,
)
})?
.await?;
let to_pane = to_pane
.upgrade()
.ok_or_else(|| anyhow!("target pane was dropped"))?;
to_pane.update_in(cx, |pane, window, cx| {
pane.add_item(item, true, true, None, window, cx);
})?;
let last_index = to_pane.read_with(cx, |pane, _| pane.items.len().saturating_sub(1));
if ix < last_index {
to_pane.update_in(cx, |_, window, cx| {
move_item(&to_pane, &to_pane, transfer.item_id, ix, true, window, cx);
})?;
}
let source_workspace = transfer
.source_pane
.read_with(cx, |pane, _| pane.workspace.clone());
source_workspace
.update_in(cx, |_, window, cx| {
transfer.source_pane.update(cx, |pane, cx| {
pane.close_item_by_id(transfer.item_id, SaveIntent::Close, window, cx)
})
})?
.await
.log_err();
Ok(())
}
#[cfg(test)]
mod tests {
use std::{cell::Cell, iter::zip, num::NonZero, rc::Rc};
use std::{cell::Cell, iter::zip, num::NonZero, rc::Rc, sync::Arc};
use super::*;
use crate::{
Member,
Member, MultiWorkspace,
item::test::{TestItem, TestProjectItem},
};
use gpui::{
AppContext, Axis, Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent,
TestAppContext, VisualTestContext, size,
AnyDrag, AppContext, Axis, Empty, Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent,
MouseUpEvent, TestAppContext, VisualTestContext, point, px, size,
};
use project::FakeFs;
use settings::SettingsStore;
@@ -9664,4 +9773,150 @@ mod tests {
}
}
}
#[gpui::test]
async fn test_tab_drop_across_workspaces_transfers_item(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project_a = Project::test(fs.clone(), None, cx).await;
let project_b = Project::test(fs.clone(), None, cx).await;
cx.update(crate::register_serializable_item::<TestItem>);
let (workspace_a, cx_a) =
cx.add_window_view(|window, cx| Workspace::test_new(project_a.clone(), window, cx));
workspace_a.update(cx_a, |workspace, _| {
workspace.database_id = Some(WorkspaceId(1))
});
let pane_a = workspace_a.read_with(cx_a, |workspace, _| workspace.active_pane().clone());
let item = cx_a.new(|cx| TestItem::new(cx).with_serialize(|| Some(Task::ready(Ok(())))));
pane_a.update_in(cx_a, |pane, window, cx| {
pane.add_item(Box::new(item.clone()), true, false, None, window, cx)
});
let source_workspace_id =
workspace_a.read_with(cx_a, |workspace, _| workspace.database_id());
let window_b =
cx_a.add_window(|window, cx| Workspace::test_new(project_b.clone(), window, cx));
let cx_b = &mut VisualTestContext::from_window(window_b.into(), cx_a);
cx_b.run_until_parked();
let pane_b = window_b
.update(cx_b, |workspace, _, _| workspace.active_pane().clone())
.unwrap();
let dragged_tab = DraggedTab {
pane: pane_a.clone(),
item: Box::new(item.clone()),
ix: 0,
detail: 0,
is_active: true,
};
pane_b.update_in(cx_b, |pane, window, cx| {
pane.handle_tab_drop(&dragged_tab, 0, false, window, cx)
});
cx_a.run_until_parked();
assert_eq!(pane_a.read_with(cx_a, |pane, _| pane.items_len()), 0);
let transferred = pane_b
.read_with(cx_b, |pane, _| pane.items().next().cloned())
.expect("item moved to the other workspace")
.downcast::<TestItem>()
.expect("transferred item is a test item");
assert_eq!(
transferred.read_with(cx_b, |item, _| item.workspace_id),
source_workspace_id
);
}
#[allow(clippy::arc_with_non_send_sync)]
#[gpui::test]
async fn test_tab_dropped_outside_opens_new_window(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs.clone(), None, cx).await;
cx.update(crate::register_serializable_item::<TestItem>);
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
workspace.update(cx, |workspace, _| {
workspace.database_id = Some(WorkspaceId(1))
});
let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
let item = cx.new(|cx| TestItem::new(cx).with_serialize(|| Some(Task::ready(Ok(())))));
pane.update_in(cx, |pane, window, cx| {
pane.add_item(Box::new(item.clone()), true, false, None, window, cx)
});
let source_workspace_id = workspace.read_with(cx, |workspace, _| workspace.database_id());
let dragged_tab = DraggedTab {
pane: pane.clone(),
item: Box::new(item.clone()),
ix: 0,
detail: 0,
is_active: true,
};
let drag = AnyDrag {
value: Arc::new(dragged_tab),
view: cx.new(|_| Empty).into(),
cursor_offset: point(px(0.), px(0.)),
cursor_style: None,
external_payload_source: None,
};
cx.update(|_, cx| crate::handle_tab_dropped_outside(&drag, point(px(400.), px(300.)), cx));
cx.run_until_parked();
let mut transferred_workspace_id = None;
for window in cx.windows() {
let Some(handle) = window.downcast::<MultiWorkspace>() else {
continue;
};
let workspace = handle
.update(cx, |multi_workspace, _, _| {
multi_workspace.workspace().clone()
})
.unwrap();
let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
if let Some(item) = pane.read_with(cx, |pane, _| pane.items().next().cloned()) {
transferred_workspace_id = item
.downcast::<TestItem>()
.map(|item| item.read_with(cx, |item, _| item.workspace_id));
}
}
assert_eq!(transferred_workspace_id, Some(source_workspace_id));
assert_eq!(pane.read_with(cx, |pane, _| pane.items_len()), 0);
}
#[allow(clippy::arc_with_non_send_sync)]
#[gpui::test]
async fn test_dirty_tab_dropped_outside_keeps_item(cx: &mut TestAppContext) {
init_test(cx);
cx.update(crate::register_serializable_item::<TestItem>);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs.clone(), None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
let item = cx.new(|cx| TestItem::new(cx).with_dirty(true));
pane.update_in(cx, |pane, window, cx| {
pane.add_item(Box::new(item.clone()), true, false, None, window, cx)
});
let dragged_tab = DraggedTab {
pane: pane.clone(),
item: Box::new(item.clone()),
ix: 0,
detail: 0,
is_active: true,
};
let drag = AnyDrag {
value: Arc::new(dragged_tab),
view: cx.new(|_| Empty).into(),
cursor_offset: point(px(0.), px(0.)),
cursor_style: None,
external_payload_source: None,
};
cx.update(|_, cx| crate::handle_tab_dropped_outside(&drag, point(px(400.), px(300.)), cx));
cx.run_until_parked();
assert_eq!(cx.windows().len(), 1);
assert_eq!(pane.read_with(cx, |pane, _| pane.items_len()), 1);
}
}
+114 -9
View File
@@ -62,13 +62,13 @@ use futures::{
future::{Shared, try_join_all},
};
use gpui::{
Action, AnyEntity, AnyView, AnyWeakView, App, AppContext, AsyncApp, AsyncWindowContext, Axis,
Bounds, ClipboardItem, Context, CursorStyle, Decorations, DragMoveEvent, Entity, EntityId,
EventEmitter, FocusHandle, Focusable, Global, HitboxBehavior, Hsla, KeyContext, Keystroke,
ManagedView, MouseButton, PathPromptOptions, Point, PromptLevel, Render, ResizeEdge, Size,
Stateful, Subscription, SystemWindowTabController, Task, TaskExt, Tiling, WeakEntity,
WindowBounds, WindowHandle, WindowId, WindowOptions, actions, canvas, point, relative, size,
transparent_black,
Action, AnyDrag, AnyEntity, AnyView, AnyWeakView, App, AppContext, AsyncApp,
AsyncWindowContext, Axis, Bounds, ClipboardItem, Context, CursorStyle, Decorations,
DragMoveEvent, Entity, EntityId, EventEmitter, FocusHandle, Focusable, Global, HitboxBehavior,
Hsla, KeyContext, Keystroke, ManagedView, MouseButton, PathPromptOptions, Pixels, Point,
PromptLevel, Render, ResizeEdge, Size, Stateful, Subscription, SystemWindowTabController, Task,
TaskExt, Tiling, WeakEntity, WindowBounds, WindowHandle, WindowId, WindowOptions, actions,
canvas, point, px, relative, size, transparent_black,
};
pub use history_manager::*;
pub use item::{
@@ -166,8 +166,8 @@ pub use workspace_settings::{
};
use zed_actions::{Spawn, feedback::FileBugReport, theme::ToggleMode};
use crate::{dock::PanelSizeState, item::ItemBufferKind, notifications::NotificationId};
use crate::background_image::BackgroundImage;
use crate::{dock::PanelSizeState, item::ItemBufferKind, notifications::NotificationId};
use crate::{
persistence::{
SerializedAxis,
@@ -999,6 +999,10 @@ pub fn init(app_state: Arc<AppState>, cx: &mut App) {
cx.on_app_quit(flush_windows_serialization_on_quit).detach();
cx.set_drag_dropped_outside_handler(|drag, position, cx| {
handle_tab_dropped_outside(drag, position, cx);
});
cx.on_action(|_: &CloseWindow, cx| Workspace::close_global(cx))
.on_action(|_: &Reload, cx| reload(cx))
.on_action(|action: &Open, cx: &mut App| {
@@ -2178,6 +2182,30 @@ impl Workspace {
init: Option<Box<dyn FnOnce(&mut Workspace, &mut Window, &mut Context<Workspace>) + Send>>,
open_mode: OpenMode,
cx: &mut App,
) -> Task<anyhow::Result<OpenResult>> {
Self::new_local_with_bounds(
abs_paths,
app_state,
requesting_window,
env,
init,
open_mode,
None,
cx,
)
}
/// Same as [`Workspace::new_local`], but places a newly created window at
/// `window_bounds_override` instead of the persisted or cascaded defaults.
pub fn new_local_with_bounds(
abs_paths: Vec<PathBuf>,
app_state: Arc<AppState>,
requesting_window: Option<WindowHandle<MultiWorkspace>>,
env: Option<HashMap<String, String>>,
init: Option<Box<dyn FnOnce(&mut Workspace, &mut Window, &mut Context<Workspace>) + Send>>,
open_mode: OpenMode,
window_bounds_override: Option<Bounds<Pixels>>,
cx: &mut App,
) -> Task<anyhow::Result<OpenResult>> {
let project_handle = Project::local(
app_state.client.clone(),
@@ -2315,7 +2343,8 @@ impl Workspace {
})?;
(window, workspace)
} else {
let window_bounds_override = window_bounds_env_override();
let window_bounds_override =
window_bounds_override.or_else(window_bounds_env_override);
let (window_bounds, display) = if let Some(bounds) = window_bounds_override {
(Some(WindowBounds::Windowed(bounds)), None)
@@ -12290,6 +12319,82 @@ pub fn clone_active_item(
.detach();
}
/// Opens a new window holding the dragged tab's item, for drags released
/// outside of every application window.
fn handle_tab_dropped_outside(drag: &AnyDrag, position: Point<Pixels>, cx: &mut App) {
let Some(dragged_tab) = drag.value.downcast_ref::<DraggedTab>() else {
return;
};
let source_pane = dragged_tab.pane.clone();
let Some(transfer) = prepare_item_transfer(&*dragged_tab.item, &source_pane, cx) else {
return;
};
let Some(source_workspace) = source_pane.read(cx).workspace.upgrade() else {
return;
};
let paths = source_workspace
.read(cx)
.root_paths(cx)
.into_iter()
.map(|path| path.to_path_buf())
.collect();
let app_state = source_workspace.read(cx).app_state.clone();
let window_bounds = tear_off_window_bounds(position, cx);
let open_task = Workspace::new_local_with_bounds(
paths,
app_state,
None,
None,
None,
OpenMode::NewWindow,
Some(window_bounds),
cx,
);
cx.spawn(async move |cx| {
let opened = open_task.await?;
let to_pane = opened
.workspace
.update(cx, |workspace, _| workspace.active_pane().downgrade());
opened.window.update(cx, |_, window, cx| {
window
.spawn(cx, async move |cx| {
complete_item_transfer(transfer, to_pane, 0, cx)
.await
.log_err();
})
.detach();
})?;
anyhow::Ok(())
})
.detach_and_log_err(cx);
}
/// Bounds for a window torn off at `position`: the active window's size with
/// its origin at the cursor, clamped inside the display's visible area.
fn tear_off_window_bounds(position: Point<Pixels>, cx: &mut App) -> Bounds<Pixels> {
let size = cx
.active_window()
.and_then(|window| {
window
.update(cx, |_, window, _| window.window_bounds().get_bounds().size)
.ok()
})
.unwrap_or_else(|| size(px(1024.), px(768.)));
let Some(display) = cx.primary_display() else {
return Bounds::new(position, size);
};
let visible = display.visible_bounds();
let max_origin = point(
(visible.origin.x + visible.size.width - size.width).max(visible.origin.x),
(visible.origin.y + visible.size.height - size.height).max(visible.origin.y),
);
let origin = point(
position.x.clamp(visible.origin.x, max_origin.x),
position.y.clamp(visible.origin.y, max_origin.y),
);
Bounds::new(origin, size)
}
#[derive(Debug)]
pub struct WorkspacePosition {
pub window_bounds: Option<WindowBounds>,
+41
View File
@@ -146,6 +146,7 @@ fi
GIT_VERSION="v2.43.3"
GIT_VERSION_SHA="fa29823"
PDFIUM_TAG="chromium/8066"
function download_and_unpack() {
local url=$1
@@ -192,6 +193,42 @@ function download_git() {
rm -rf "$tmp_dir"
}
function download_pdfium() {
local architecture=$1
local target_path=$2
case "$architecture" in
aarch64-apple-darwin)
pdfium_flavor="mac-arm64"
pdfium_sha256="336219e80580b93c6523f44db7dc1de59cc497b13a7390ddac84223f68ca162b"
;;
x86_64-apple-darwin)
pdfium_flavor="mac-x64"
pdfium_sha256="841ecac278cdd46288dd065873522cf72f3996560d8978f473d336f01d59942c"
;;
*)
echo "Unsupported architecture for pdfium: $architecture"
exit 1
;;
esac
# sha256sum is not present on stock macOS, so checksums go through shasum.
pdfium_tgz="${target_dir}/pdfium-${pdfium_flavor}.tgz"
if ! echo "${pdfium_sha256} ${pdfium_tgz}" | shasum -a 256 -c >/dev/null 2>&1; then
mkdir -p "${target_dir}"
curl --silent --fail --location --output "${pdfium_tgz}" \
"https://github.com/bblanchon/pdfium-binaries/releases/download/${PDFIUM_TAG}/pdfium-${pdfium_flavor}.tgz"
echo "${pdfium_sha256} ${pdfium_tgz}" | shasum -a 256 -c
fi
pdfium_tmp=$(mktemp -d)
tar -xzf "${pdfium_tgz}" -C "${pdfium_tmp}" "lib/libpdfium.dylib" "LICENSE" "licenses"
cp "${pdfium_tmp}/lib/libpdfium.dylib" "${target_path}"
mkdir -p "${app_path}/Contents/Resources/pdfium-licenses"
cp "${pdfium_tmp}/LICENSE" "${pdfium_tmp}/licenses/"* "${app_path}/Contents/Resources/pdfium-licenses/"
rm -rf "${pdfium_tmp}"
}
function sign_app_binaries() {
rm -rf "${app_path}/Contents/Frameworks"
mkdir -p "${app_path}/Contents/Frameworks"
@@ -199,6 +236,10 @@ function sign_app_binaries() {
echo "Downloading git binary"
download_git "${target_triple}" "${app_path}/Contents/MacOS/git"
# PDFium is dlopen'd at runtime by the bundled pdf_viewer for native PDF viewing.
echo "Downloading pdfium library"
download_pdfium "${target_triple}" "${app_path}/Contents/Frameworks/libpdfium.dylib"
# Note: The app identifier for our development builds is the same as the app identifier for nightly.
cp crates/zed/contents/$channel/embedded.provisionprofile "${app_path}/Contents/"