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:
@@ -313,13 +313,24 @@ impl<D: NodeGraphDataSource + 'static> NodeGraphView<D> {
|
|||||||
/// the topmost (last-painted) node wins.
|
/// the topmost (last-painted) node wins.
|
||||||
fn hit_test(&self, position: Point<Pixels>, cx: &App) -> HitTarget {
|
fn hit_test(&self, position: Point<Pixels>, cx: &App) -> HitTarget {
|
||||||
let anchor = position - self.viewport.origin;
|
let anchor = position - self.viewport.origin;
|
||||||
|
let zoom = self.state.zoom();
|
||||||
let data = self.data.read(cx);
|
let data = self.data.read(cx);
|
||||||
for node in data.nodes().into_iter().rev() {
|
for node in data.nodes().into_iter().rev() {
|
||||||
let element = NodeElement::from_node(&node, NodeVisualState::default());
|
let element = NodeElement::from_node(&node, NodeVisualState::default());
|
||||||
let screen_pos = self.state.graph_to_screen(node.position());
|
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) {
|
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) {
|
if let Some(port) = element.port_at(local) {
|
||||||
return HitTarget::Port(port);
|
return HitTarget::Port(port);
|
||||||
}
|
}
|
||||||
@@ -761,6 +772,7 @@ impl<D: NodeGraphDataSource + 'static> NodeGraphView<D> {
|
|||||||
order.extend(top);
|
order.extend(top);
|
||||||
|
|
||||||
let mut wires = Vec::new();
|
let mut wires = Vec::new();
|
||||||
|
let zoom = self.state.zoom();
|
||||||
for edge in data.edges() {
|
for edge in data.edges() {
|
||||||
let (from_pos, from_element) = match elements.get(&edge.from_node()) {
|
let (from_pos, from_element) = match elements.get(&edge.from_node()) {
|
||||||
Some(entry) => entry,
|
Some(entry) => entry,
|
||||||
@@ -789,10 +801,12 @@ impl<D: NodeGraphDataSource + 'static> NodeGraphView<D> {
|
|||||||
} else {
|
} else {
|
||||||
WireVisualState::Normal
|
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(
|
wires.push(Wire::new(
|
||||||
edge.id(),
|
edge.id(),
|
||||||
*from_pos + from_anchor,
|
*from_pos + point(from_anchor.x * zoom, from_anchor.y * zoom),
|
||||||
*to_pos + to_anchor,
|
*to_pos + point(to_anchor.x * zoom, to_anchor.y * zoom),
|
||||||
data_type,
|
data_type,
|
||||||
wire_state,
|
wire_state,
|
||||||
));
|
));
|
||||||
@@ -856,7 +870,7 @@ impl<D: NodeGraphDataSource + 'static> NodeGraphView<D> {
|
|||||||
wire.paint(window, zoom);
|
wire.paint(window, zoom);
|
||||||
}
|
}
|
||||||
for (origin, element) in &draw.nodes {
|
for (origin, element) in &draw.nodes {
|
||||||
element.paint(*origin, window, cx);
|
element.paint(*origin, zoom, window, cx);
|
||||||
}
|
}
|
||||||
if let Some(ghost) = &draw.ghost {
|
if let Some(ghost) = &draw.ghost {
|
||||||
paint_ghost(
|
paint_ghost(
|
||||||
|
|||||||
@@ -226,17 +226,28 @@ impl NodeElement {
|
|||||||
/// and toggles, port dots tinted by data type (filled when connected,
|
/// and toggles, port dots tinted by data type (filled when connected,
|
||||||
/// hollow otherwise) with labels, selection outline, disabled dimming and
|
/// hollow otherwise) with labels, selection outline, disabled dimming and
|
||||||
/// the compatible-port glow. `origin` is the card's screen-space top-left
|
/// the compatible-port glow. `origin` is the card's screen-space top-left
|
||||||
/// corner; all geometry within the card is node-local.
|
/// corner; all card geometry is defined in graph space and scaled by
|
||||||
pub(crate) fn paint(&self, origin: Point<Pixels>, window: &mut Window, cx: &mut App) {
|
/// `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 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
|
// Compatible-port glow: a slightly inflated rect behind the card while
|
||||||
// a wire drag offers at least one valid drop target on this node.
|
// a wire drag offers at least one valid drop target on this node.
|
||||||
if self.visual.has_compatible_port {
|
if self.visual.has_compatible_port {
|
||||||
|
let glow_pad = px(2.0) * zoom;
|
||||||
let glow = Bounds::new(
|
let glow = Bounds::new(
|
||||||
point(origin.x - px(2.0), origin.y - px(2.0)),
|
point(origin.x - glow_pad, origin.y - glow_pad),
|
||||||
size(DEFAULT_NODE_WIDTH + px(4.0), self.height() + px(4.0)),
|
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)));
|
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).
|
// Border quad: transparent fill, themed border (accent when selected).
|
||||||
window.paint_quad(PaintQuad {
|
window.paint_quad(PaintQuad {
|
||||||
bounds,
|
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(),
|
background: hsla(0.0, 0.0, 0.0, 0.0).into(),
|
||||||
border_widths: Edges::all(if self.visual.selected {
|
border_widths: Edges::all(if self.visual.selected {
|
||||||
px(1.5)
|
px(1.5)
|
||||||
@@ -264,20 +275,20 @@ impl NodeElement {
|
|||||||
|
|
||||||
// Header bar with the node's accent color (or the theme container
|
// Header bar with the node's accent color (or the theme container
|
||||||
// color), containing the title and the collapse/enable toggles.
|
// 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(
|
window.paint_quad(fill(
|
||||||
header_bounds,
|
header_bounds,
|
||||||
self.header_color.unwrap_or(Hsla::from(colors.container)),
|
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(
|
paint_text(
|
||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
&self.title,
|
&self.title,
|
||||||
px(12.0),
|
px(12.0) * zoom,
|
||||||
point(bounds.left() + px(28.0), text_y),
|
point(bounds.left() + px(28.0) * zoom, text_y),
|
||||||
px(12.0),
|
px(12.0) * zoom,
|
||||||
Hsla::from(colors.text),
|
Hsla::from(colors.text),
|
||||||
TextAlign::Left,
|
TextAlign::Left,
|
||||||
None,
|
None,
|
||||||
@@ -289,9 +300,9 @@ impl NodeElement {
|
|||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
if self.collapsed { "▶" } else { "▼" },
|
if self.collapsed { "▶" } else { "▼" },
|
||||||
px(10.0),
|
px(10.0) * zoom,
|
||||||
point(bounds.left() + px(10.0), text_y),
|
point(bounds.left() + px(10.0) * zoom, text_y),
|
||||||
px(12.0),
|
px(12.0) * zoom,
|
||||||
Hsla::from(colors.text),
|
Hsla::from(colors.text),
|
||||||
TextAlign::Left,
|
TextAlign::Left,
|
||||||
None,
|
None,
|
||||||
@@ -302,9 +313,9 @@ impl NodeElement {
|
|||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
"⏻",
|
"⏻",
|
||||||
px(12.0),
|
px(12.0) * zoom,
|
||||||
point(bounds.right() - px(20.0), text_y),
|
point(bounds.right() - px(20.0) * zoom, text_y),
|
||||||
px(12.0),
|
px(12.0) * zoom,
|
||||||
Hsla::from(colors.text),
|
Hsla::from(colors.text),
|
||||||
TextAlign::Left,
|
TextAlign::Left,
|
||||||
None,
|
None,
|
||||||
@@ -312,21 +323,24 @@ impl NodeElement {
|
|||||||
|
|
||||||
// Port dots and labels, only when the node is expanded.
|
// Port dots and labels, only when the node is expanded.
|
||||||
if !self.collapsed {
|
if !self.collapsed {
|
||||||
let label_font_size = px(11.0);
|
let label_font_size = px(11.0) * zoom;
|
||||||
let label_height = px(12.0);
|
let label_height = px(12.0) * zoom;
|
||||||
for port in self.inputs.iter().chain(self.outputs.iter()) {
|
for port in self.inputs.iter().chain(self.outputs.iter()) {
|
||||||
let Some(anchor) = self.port_anchor(port.id) else {
|
let Some(anchor) = self.port_anchor(port.id) else {
|
||||||
continue;
|
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(
|
let dot_bounds = Bounds::new(
|
||||||
point(anchor.x - PORT_DOT_RADIUS, anchor.y - PORT_DOT_RADIUS),
|
point(center.x - dot_r, center.y - dot_r),
|
||||||
size(PORT_DOT_RADIUS * 2.0, PORT_DOT_RADIUS * 2.0),
|
size(dot_r * 2.0, dot_r * 2.0),
|
||||||
);
|
);
|
||||||
if port.connected {
|
if port.connected {
|
||||||
// Connected dots are solid tinted circles.
|
// Connected dots are solid tinted circles.
|
||||||
window.paint_quad(PaintQuad {
|
window.paint_quad(PaintQuad {
|
||||||
bounds: dot_bounds,
|
bounds: dot_bounds,
|
||||||
corner_radii: Corners::all(PORT_DOT_RADIUS),
|
corner_radii: Corners::all(dot_r),
|
||||||
background: port.color.into(),
|
background: port.color.into(),
|
||||||
border_widths: Edges::all(px(0.0)),
|
border_widths: Edges::all(px(0.0)),
|
||||||
border_color: hsla(0.0, 0.0, 0.0, 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
|
// Unconnected dots are hollow: a tinted ring around the
|
||||||
// card's background color.
|
// card's background color.
|
||||||
window.paint_quad(fill(dot_bounds, port.color));
|
window.paint_quad(fill(dot_bounds, port.color));
|
||||||
|
let inset = px(2.0) * zoom;
|
||||||
let inner = Bounds::new(
|
let inner = Bounds::new(
|
||||||
point(
|
point(dot_bounds.origin.x + inset, dot_bounds.origin.y + inset),
|
||||||
anchor.x - PORT_DOT_RADIUS + px(2.0),
|
|
||||||
anchor.y - PORT_DOT_RADIUS + px(2.0),
|
|
||||||
),
|
|
||||||
size(
|
size(
|
||||||
PORT_DOT_RADIUS * 2.0 - px(4.0),
|
dot_bounds.size.width - inset * 2.0,
|
||||||
PORT_DOT_RADIUS * 2.0 - px(4.0),
|
dot_bounds.size.height - inset * 2.0,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
window.paint_quad(fill(inner, colors.background));
|
window.paint_quad(fill(inner, colors.background));
|
||||||
@@ -357,7 +369,10 @@ impl NodeElement {
|
|||||||
cx,
|
cx,
|
||||||
&port.label,
|
&port.label,
|
||||||
label_font_size,
|
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,
|
label_height,
|
||||||
Hsla::from(colors.text),
|
Hsla::from(colors.text),
|
||||||
TextAlign::Left,
|
TextAlign::Left,
|
||||||
@@ -367,15 +382,15 @@ impl NodeElement {
|
|||||||
// Output labels: right-aligned so they end just left of
|
// Output labels: right-aligned so they end just left of
|
||||||
// the dot. The box origin sits `align_width` left of the
|
// the dot. The box origin sits `align_width` left of the
|
||||||
// dot; the label's right edge lands at the box right.
|
// 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(
|
paint_text(
|
||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
&port.label,
|
&port.label,
|
||||||
label_font_size,
|
label_font_size,
|
||||||
point(
|
point(
|
||||||
anchor.x - PORT_DOT_RADIUS - px(6.0) - align_width,
|
center.x - dot_r - px(6.0) * zoom - align_width,
|
||||||
anchor.y - px(6.0),
|
center.y - px(6.0) * zoom,
|
||||||
),
|
),
|
||||||
label_height,
|
label_height,
|
||||||
Hsla::from(colors.text),
|
Hsla::from(colors.text),
|
||||||
|
|||||||
@@ -184,6 +184,9 @@ impl<D: ProjectDataSource> ProjectExplorer<D> {
|
|||||||
_window: &mut Window,
|
_window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> 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 {
|
Self {
|
||||||
control,
|
control,
|
||||||
data,
|
data,
|
||||||
@@ -386,6 +389,7 @@ impl<D: ProjectDataSource> Render for ProjectExplorer<D> {
|
|||||||
))
|
))
|
||||||
.flex()
|
.flex()
|
||||||
.flex_wrap()
|
.flex_wrap()
|
||||||
|
.items_start()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.p_2()
|
.p_2()
|
||||||
.overflow_y_scroll();
|
.overflow_y_scroll();
|
||||||
@@ -422,7 +426,12 @@ impl<D: ProjectDataSource> Render for ProjectExplorer<D> {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
}))
|
}))
|
||||||
.child(if let Some(thumbnail) = entry.thumbnail.clone() {
|
.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 {
|
} else {
|
||||||
div()
|
div()
|
||||||
.w(px(72.0))
|
.w(px(72.0))
|
||||||
|
|||||||
Reference in New Issue
Block a user