fix(gpui): node cards scale with zoom; explorer icon grid shows thumbnails

- node_graph: card geometry stays graph-space; paint/hit-test/wire
  endpoints scale by zoom so nodes no longer overlap at zoom != 1
- project_explorer: icon grid aligns items to start (no full-height
  selection stretch), loads thumbnails from filesystem paths and
  observes the data source for late arrivals
This commit is contained in:
2026-08-18 12:41:04 +08:00
parent 27c6c393c3
commit 447353ebbd
3 changed files with 76 additions and 38 deletions
+19 -5
View File
@@ -313,13 +313,24 @@ impl<D: NodeGraphDataSource + 'static> NodeGraphView<D> {
/// the topmost (last-painted) node wins.
fn hit_test(&self, position: Point<Pixels>, 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<D: NodeGraphDataSource + 'static> NodeGraphView<D> {
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<D: NodeGraphDataSource + 'static> NodeGraphView<D> {
} 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<D: NodeGraphDataSource + 'static> NodeGraphView<D> {
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(
+47 -32
View File
@@ -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<Pixels>, 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<Pixels>,
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),
+10 -1
View File
@@ -184,6 +184,9 @@ impl<D: ProjectDataSource> ProjectExplorer<D> {
_window: &mut Window,
cx: &mut Context<Self>,
) -> 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<D: ProjectDataSource> Render for ProjectExplorer<D> {
))
.flex()
.flex_wrap()
.items_start()
.gap_2()
.p_2()
.overflow_y_scroll();
@@ -422,7 +426,12 @@ impl<D: ProjectDataSource> Render for ProjectExplorer<D> {
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))