diff --git a/crates/gpui/src/node_graph/graph_view.rs b/crates/gpui/src/node_graph/graph_view.rs index ded42a46a8..b342ea84d4 100644 --- a/crates/gpui/src/node_graph/graph_view.rs +++ b/crates/gpui/src/node_graph/graph_view.rs @@ -313,13 +313,24 @@ impl NodeGraphView { /// the topmost (last-painted) node wins. fn hit_test(&self, position: Point, cx: &App) -> HitTarget { let anchor = position - self.viewport.origin; + let zoom = self.state.zoom(); let data = self.data.read(cx); for node in data.nodes().into_iter().rev() { let element = NodeElement::from_node(&node, NodeVisualState::default()); let screen_pos = self.state.graph_to_screen(node.position()); - let bounds = Bounds::new(screen_pos, size(DEFAULT_NODE_WIDTH, element.height())); + // Cards are painted scaled by zoom, so their screen-space bounds + // are the graph-space card size times `zoom`. + let bounds = Bounds::new( + screen_pos, + size(DEFAULT_NODE_WIDTH * zoom, element.height() * zoom), + ); if bounds.contains(&anchor) { - let local = anchor - screen_pos; + // Back to graph-space card-local coordinates for the element's + // (unzoomed) hit helpers. + let local = point( + (anchor.x - screen_pos.x) / zoom, + (anchor.y - screen_pos.y) / zoom, + ); if let Some(port) = element.port_at(local) { return HitTarget::Port(port); } @@ -761,6 +772,7 @@ impl NodeGraphView { order.extend(top); let mut wires = Vec::new(); + let zoom = self.state.zoom(); for edge in data.edges() { let (from_pos, from_element) = match elements.get(&edge.from_node()) { Some(entry) => entry, @@ -789,10 +801,12 @@ impl NodeGraphView { } else { WireVisualState::Normal }; + // Anchors are graph-space card-local offsets; scale them into + // screen space before adding them to the node's screen origin. wires.push(Wire::new( edge.id(), - *from_pos + from_anchor, - *to_pos + to_anchor, + *from_pos + point(from_anchor.x * zoom, from_anchor.y * zoom), + *to_pos + point(to_anchor.x * zoom, to_anchor.y * zoom), data_type, wire_state, )); @@ -856,7 +870,7 @@ impl NodeGraphView { wire.paint(window, zoom); } for (origin, element) in &draw.nodes { - element.paint(*origin, window, cx); + element.paint(*origin, zoom, window, cx); } if let Some(ghost) = &draw.ghost { paint_ghost( diff --git a/crates/gpui/src/node_graph/node_element.rs b/crates/gpui/src/node_graph/node_element.rs index 2f680313e0..37f49f8a75 100644 --- a/crates/gpui/src/node_graph/node_element.rs +++ b/crates/gpui/src/node_graph/node_element.rs @@ -226,17 +226,28 @@ impl NodeElement { /// and toggles, port dots tinted by data type (filled when connected, /// hollow otherwise) with labels, selection outline, disabled dimming and /// the compatible-port glow. `origin` is the card's screen-space top-left - /// corner; all geometry within the card is node-local. - pub(crate) fn paint(&self, origin: Point, window: &mut Window, cx: &mut App) { + /// corner; all card geometry is defined in graph space and scaled by + /// `zoom` here, so cards shrink/grow consistently with node positions. + pub(crate) fn paint( + &self, + origin: Point, + zoom: f32, + window: &mut Window, + cx: &mut App, + ) { let colors = cx.default_colors().clone(); - let bounds = Bounds::new(origin, size(DEFAULT_NODE_WIDTH, self.height())); + let width = DEFAULT_NODE_WIDTH * zoom; + let header_h = HEADER_HEIGHT * zoom; + let dot_r = PORT_DOT_RADIUS * zoom; + let bounds = Bounds::new(origin, size(width, self.height() * zoom)); // Compatible-port glow: a slightly inflated rect behind the card while // a wire drag offers at least one valid drop target on this node. if self.visual.has_compatible_port { + let glow_pad = px(2.0) * zoom; let glow = Bounds::new( - point(origin.x - px(2.0), origin.y - px(2.0)), - size(DEFAULT_NODE_WIDTH + px(4.0), self.height() + px(4.0)), + point(origin.x - glow_pad, origin.y - glow_pad), + size(width + glow_pad * 2.0, bounds.size.height + glow_pad * 2.0), ); window.paint_quad(fill(glow, Hsla::from(colors.selected).opacity(0.2))); } @@ -247,7 +258,7 @@ impl NodeElement { // Border quad: transparent fill, themed border (accent when selected). window.paint_quad(PaintQuad { bounds, - corner_radii: Corners::all(px(4.0)), + corner_radii: Corners::all(px(4.0) * zoom), background: hsla(0.0, 0.0, 0.0, 0.0).into(), border_widths: Edges::all(if self.visual.selected { px(1.5) @@ -264,20 +275,20 @@ impl NodeElement { // Header bar with the node's accent color (or the theme container // color), containing the title and the collapse/enable toggles. - let header_bounds = Bounds::new(origin, size(DEFAULT_NODE_WIDTH, HEADER_HEIGHT)); + let header_bounds = Bounds::new(origin, size(width, header_h)); window.paint_quad(fill( header_bounds, self.header_color.unwrap_or(Hsla::from(colors.container)), )); - let text_y = bounds.top() + px((HEADER_HEIGHT.0 - 12.0) / 2.0); + let text_y = bounds.top() + (header_h - px(12.0) * zoom) * 0.5; paint_text( window, cx, &self.title, - px(12.0), - point(bounds.left() + px(28.0), text_y), - px(12.0), + px(12.0) * zoom, + point(bounds.left() + px(28.0) * zoom, text_y), + px(12.0) * zoom, Hsla::from(colors.text), TextAlign::Left, None, @@ -289,9 +300,9 @@ impl NodeElement { window, cx, if self.collapsed { "▶" } else { "▼" }, - px(10.0), - point(bounds.left() + px(10.0), text_y), - px(12.0), + px(10.0) * zoom, + point(bounds.left() + px(10.0) * zoom, text_y), + px(12.0) * zoom, Hsla::from(colors.text), TextAlign::Left, None, @@ -302,9 +313,9 @@ impl NodeElement { window, cx, "⏻", - px(12.0), - point(bounds.right() - px(20.0), text_y), - px(12.0), + px(12.0) * zoom, + point(bounds.right() - px(20.0) * zoom, text_y), + px(12.0) * zoom, Hsla::from(colors.text), TextAlign::Left, None, @@ -312,21 +323,24 @@ impl NodeElement { // Port dots and labels, only when the node is expanded. if !self.collapsed { - let label_font_size = px(11.0); - let label_height = px(12.0); + let label_font_size = px(11.0) * zoom; + let label_height = px(12.0) * zoom; for port in self.inputs.iter().chain(self.outputs.iter()) { let Some(anchor) = self.port_anchor(port.id) else { continue; }; + // The anchor is graph-space; scale it into the card's + // screen-space rectangle. + let center = point(origin.x + anchor.x * zoom, origin.y + anchor.y * zoom); let dot_bounds = Bounds::new( - point(anchor.x - PORT_DOT_RADIUS, anchor.y - PORT_DOT_RADIUS), - size(PORT_DOT_RADIUS * 2.0, PORT_DOT_RADIUS * 2.0), + point(center.x - dot_r, center.y - dot_r), + size(dot_r * 2.0, dot_r * 2.0), ); if port.connected { // Connected dots are solid tinted circles. window.paint_quad(PaintQuad { bounds: dot_bounds, - corner_radii: Corners::all(PORT_DOT_RADIUS), + corner_radii: Corners::all(dot_r), background: port.color.into(), border_widths: Edges::all(px(0.0)), border_color: hsla(0.0, 0.0, 0.0, 0.0), @@ -336,14 +350,12 @@ impl NodeElement { // Unconnected dots are hollow: a tinted ring around the // card's background color. window.paint_quad(fill(dot_bounds, port.color)); + let inset = px(2.0) * zoom; let inner = Bounds::new( - point( - anchor.x - PORT_DOT_RADIUS + px(2.0), - anchor.y - PORT_DOT_RADIUS + px(2.0), - ), + point(dot_bounds.origin.x + inset, dot_bounds.origin.y + inset), size( - PORT_DOT_RADIUS * 2.0 - px(4.0), - PORT_DOT_RADIUS * 2.0 - px(4.0), + dot_bounds.size.width - inset * 2.0, + dot_bounds.size.height - inset * 2.0, ), ); window.paint_quad(fill(inner, colors.background)); @@ -357,7 +369,10 @@ impl NodeElement { cx, &port.label, label_font_size, - point(anchor.x + PORT_DOT_RADIUS + px(6.0), anchor.y - px(6.0)), + point( + center.x + dot_r + px(6.0) * zoom, + center.y - px(6.0) * zoom, + ), label_height, Hsla::from(colors.text), TextAlign::Left, @@ -367,15 +382,15 @@ impl NodeElement { // Output labels: right-aligned so they end just left of // the dot. The box origin sits `align_width` left of the // dot; the label's right edge lands at the box right. - let align_width = px(100.0); + let align_width = px(100.0) * zoom; paint_text( window, cx, &port.label, label_font_size, point( - anchor.x - PORT_DOT_RADIUS - px(6.0) - align_width, - anchor.y - px(6.0), + center.x - dot_r - px(6.0) * zoom - align_width, + center.y - px(6.0) * zoom, ), label_height, Hsla::from(colors.text), diff --git a/crates/gpui_widgets/src/project_explorer.rs b/crates/gpui_widgets/src/project_explorer.rs index 54ba1f1ea5..892cea6ddf 100644 --- a/crates/gpui_widgets/src/project_explorer.rs +++ b/crates/gpui_widgets/src/project_explorer.rs @@ -184,6 +184,9 @@ impl ProjectExplorer { _window: &mut Window, cx: &mut Context, ) -> Self { + // Re-render when the host's model changes (e.g. a background + // thumbnail finished and the entries now carry one). + cx.observe(&data, |_, _, cx| cx.notify()).detach(); Self { control, data, @@ -386,6 +389,7 @@ impl Render for ProjectExplorer { )) .flex() .flex_wrap() + .items_start() .gap_2() .p_2() .overflow_y_scroll(); @@ -422,7 +426,12 @@ impl Render for ProjectExplorer { cx.notify(); })) .child(if let Some(thumbnail) = entry.thumbnail.clone() { - img(thumbnail).w(px(72.0)).h(px(48.0)).into_any_element() + // A filesystem path: load through the path resource so + // generated thumbnails resolve without an asset source. + img(PathBuf::from(thumbnail.as_ref())) + .w(px(72.0)) + .h(px(48.0)) + .into_any_element() } else { div() .w(px(72.0))