Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
//! A generic, rotary dial that edits a discrete `u32` node parameter (e.g. a polygon's side count).
//!
//! Like [`GenericSliderGizmo`](super::generic_slider_gizmo::GenericSliderGizmo), this is fully
//! data-driven from the [gizmo registry]: it is anchored at the layer's origin and converts the
//! angle the user drags around that origin into integer steps.
//!
//! [gizmo registry]: crate::messages::tool::common_functionality::gizmos::gizmo_registry

use crate::consts::{GIZMO_HIDE_THRESHOLD, NUMBER_OF_POINTS_DIAL_SPOKE_LENGTH};
use crate::messages::frontend::utility_types::MouseCursorIcon;
use crate::messages::message::Message;
use crate::messages::portfolio::document::overlays::utility_types::OverlayContext;
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::portfolio::document::utility_types::network_interface::InputConnector;
use crate::messages::prelude::{DocumentMessageHandler, FrontendMessage, InputPreprocessorMessageHandler, NodeGraphMessage, Responses};
use crate::messages::tool::common_functionality::gizmos::generic_gizmos::read_u32_input;
use crate::messages::tool::common_functionality::gizmos::gizmo_registry::GizmoInfo;
use glam::DVec2;
use graph_craft::ProtoNodeIdentifier;
use graph_craft::document::NodeId;
use graph_craft::document::NodeInput;
use graph_craft::document::value::TaggedValue;
use std::collections::VecDeque;

/// How many degrees of rotation correspond to one integer step.
const DIAL_DEGREES_PER_STEP: f64 = 30.;
/// Viewport radius of the drawn dial indicator.
const DIAL_INDICATOR_RADIUS: f64 = NUMBER_OF_POINTS_DIAL_SPOKE_LENGTH;

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum GenericDialState {
#[default]
Inactive,
Hover,
Dragging,
}

/// A rotary dial bound to one `u32` parameter of one node.
#[derive(Clone, Debug)]
pub struct GenericDialGizmo {
layer: LayerNodeIdentifier,
node_id: NodeId,
identifier: ProtoNodeIdentifier,
info: GizmoInfo,
state: GenericDialState,
/// Parameter value captured when the drag began.
initial_value: u32,
}

impl GenericDialGizmo {
pub fn new(layer: LayerNodeIdentifier, node_id: NodeId, identifier: ProtoNodeIdentifier, info: GizmoInfo) -> Self {
Self {
layer,
node_id,
identifier,
info,
state: GenericDialState::Inactive,
initial_value: 0,
}
}

pub fn is_hovered(&self) -> bool {
self.state == GenericDialState::Hover
}

pub fn is_dragging(&self) -> bool {
self.state == GenericDialState::Dragging
}

pub fn cleanup(&mut self) {
self.state = GenericDialState::Inactive;
}

pub fn handle_click(&mut self) {
if self.state == GenericDialState::Hover {
self.state = GenericDialState::Dragging;
}
}

fn current_value(&self, document: &DocumentMessageHandler) -> Option<u32> {
read_u32_input(self.layer, document, &self.identifier, self.info.parameter_index)
}

/// Hover detection: the dial occupies a disc of `DIAL_INDICATOR_RADIUS` around the layer origin.
pub fn handle_state(&mut self, mouse_position: DVec2, document: &DocumentMessageHandler, responses: &mut VecDeque<Message>) {
if self.state == GenericDialState::Dragging {
return;
}

if self.current_value(document).is_none() {
self.state = GenericDialState::Inactive;
return;
}

let viewport = document.metadata().transform_to_viewport(self.layer);
let center = viewport.transform_point2(DVec2::ZERO);

// Hide the dial when the shape is too small on screen.
let extent = viewport.transform_point2(DVec2::new(1., 0.)).distance(center);
if extent < f64::EPSILON {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Dial remains interactive while hidden due to mismatched visibility thresholds

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_dial_gizmo.rs, line 100:

<comment>Dial remains interactive while hidden due to mismatched visibility thresholds</comment>

<file context>
@@ -0,0 +1,175 @@
+
+		// Hide the dial when the shape is too small on screen.
+		let extent = viewport.transform_point2(DVec2::new(1., 0.)).distance(center);
+		if extent < f64::EPSILON {
+			self.state = GenericDialState::Inactive;
+			return;
</file context>

self.state = GenericDialState::Inactive;
return;
}

if mouse_position.distance(center) <= DIAL_INDICATOR_RADIUS {
if self.state != GenericDialState::Hover {
self.state = GenericDialState::Hover;
// Capture the reference value now, since `handle_click` (which starts the drag) has no
// access to the document.
self.initial_value = self.current_value(document).unwrap_or(0);
responses.add(FrontendMessage::UpdateMouseCursor { cursor: MouseCursorIcon::EWResize });
}
} else if self.state == GenericDialState::Hover {
self.state = GenericDialState::Inactive;
responses.add(FrontendMessage::UpdateMouseCursor { cursor: MouseCursorIcon::Default });
}
}

/// Convert the angle swept around the layer origin (relative to the drag start) into integer
/// steps, clamped to the registry's bounds.
pub fn handle_update(&self, drag_start: DVec2, document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, responses: &mut VecDeque<Message>) {
let viewport = document.metadata().transform_to_viewport(self.layer);
let center = viewport.transform_point2(DVec2::ZERO);

let start_vector = drag_start - center;
let current_vector = input.mouse.position - center;
let (Some(start_dir), Some(current_dir)) = (start_vector.try_normalize(), current_vector.try_normalize()) else {
return;
};

// Signed angle (radians) swept from the drag-start direction to the current direction.
let swept_degrees = start_dir.angle_to(current_dir).to_degrees();
let steps = (swept_degrees / DIAL_DEGREES_PER_STEP).round() as i64;

let min = self.info.min.map(|m| m as i64).unwrap_or(0);
let max = self.info.max.map(|m| m as i64).unwrap_or(i64::MAX);
let new_value = (self.initial_value as i64 + steps).clamp(min, max) as u32;

responses.add(NodeGraphMessage::SetInput {
input_connector: InputConnector::node(self.node_id, self.info.parameter_index),
input: NodeInput::value(TaggedValue::U32(new_value), false),
});
responses.add(NodeGraphMessage::RunDocumentGraph);
}

/// Draw the dial ring and a tick pointing toward the current mouse-derived angle.
pub fn overlays(&self, document: &DocumentMessageHandler, mouse_position: DVec2, overlay_context: &mut OverlayContext) {
if self.state == GenericDialState::Inactive {
return;
}

let viewport = document.metadata().transform_to_viewport(self.layer);
let center = viewport.transform_point2(DVec2::ZERO);

let extent = viewport.transform_point2(DVec2::new(1., 0.)).distance(center);
if extent < GIZMO_HIDE_THRESHOLD / DIAL_INDICATOR_RADIUS {
// Shape is extremely small; skip to avoid a cluttered overlay.
return;
}

overlay_context.circle(center, DIAL_INDICATOR_RADIUS, None, None);

// Tick line pointing from the center toward the mouse, giving the dial a sense of direction.
if let Some(direction) = (mouse_position - center).try_normalize() {
overlay_context.line(center, center + direction * DIAL_INDICATOR_RADIUS, None, None);
}
}

pub fn mouse_cursor_icon(&self) -> Option<MouseCursorIcon> {
match self.state {
GenericDialState::Hover | GenericDialState::Dragging => Some(MouseCursorIcon::EWResize),
GenericDialState::Inactive => None,
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
//! A generic, draggable handle that edits a continuous `f64` node parameter (e.g. a radius).
//!
//! Unlike the hand-written shape gizmos in `shape_gizmos`, this gizmo is fully driven by data
//! from the [gizmo registry](crate::messages::tool::common_functionality::gizmos::gizmo_registry):
//! it knows nothing about the specific node it edits beyond the node id, the parameter index, and
//! the registry's [`GizmoInfo`]. This is what lets any node opt into a slider with zero custom code.

use crate::consts::GIZMO_HIDE_THRESHOLD;
use crate::messages::frontend::utility_types::MouseCursorIcon;
use crate::messages::message::Message;
use crate::messages::portfolio::document::overlays::utility_types::OverlayContext;
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::portfolio::document::utility_types::network_interface::InputConnector;
use crate::messages::prelude::{DocumentMessageHandler, FrontendMessage, InputPreprocessorMessageHandler, NodeGraphMessage, Responses};
use crate::messages::tool::common_functionality::gizmos::generic_gizmos::read_f64_input;
use crate::messages::tool::common_functionality::gizmos::gizmo_registry::{GizmoInfo, PositionHint};

Check warning on line 16 in editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs

View workflow job for this annotation

GitHub Actions / rust-fmt

Diff in /home/runner/work/Graphite/Graphite/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs
use glam::DVec2;
use graph_craft::ProtoNodeIdentifier;
use graph_craft::document::NodeInput;
use graph_craft::document::value::TaggedValue;
use graph_craft::document::NodeId;
use std::collections::VecDeque;

/// Pixel radius within which the mouse is considered to be hovering the handle.
const SLIDER_HANDLE_HOVER_THRESHOLD: f64 = 8.;

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum GenericSliderState {
#[default]
Inactive,
Hover,
Dragging,
}

/// A draggable slider handle bound to one `f64` parameter of one node.
#[derive(Clone, Debug)]
pub struct GenericSliderGizmo {
layer: LayerNodeIdentifier,
node_id: NodeId,
identifier: ProtoNodeIdentifier,
info: GizmoInfo,
state: GenericSliderState,
/// The parameter value captured when the drag began, used as the clamping/anchor reference.
initial_value: f64,
}

impl GenericSliderGizmo {
pub fn new(layer: LayerNodeIdentifier, node_id: NodeId, identifier: ProtoNodeIdentifier, info: GizmoInfo) -> Self {
Self {
layer,
node_id,
identifier,
info,
state: GenericSliderState::Inactive,
initial_value: 0.,
}
}

pub fn is_hovered(&self) -> bool {
self.state == GenericSliderState::Hover
}

pub fn is_dragging(&self) -> bool {
self.state == GenericSliderState::Dragging
}

pub fn cleanup(&mut self) {
self.state = GenericSliderState::Inactive;
}

/// Begin a drag if currently hovered.
pub fn handle_click(&mut self) {
if self.state == GenericSliderState::Hover {
self.state = GenericSliderState::Dragging;
}
}

fn current_value(&self, document: &DocumentMessageHandler) -> Option<f64> {
read_f64_input(self.layer, document, &self.identifier, self.info.parameter_index)
}

/// The handle's anchor point, in the layer's local coordinate space, derived from the current
/// parameter value and the registry's position hint.
fn handle_position_local(&self, value: f64) -> DVec2 {
match self.info.position_hint {
// A length-like parameter: place the handle that far out along the local +X axis.
PositionHint::ParameterDerived => DVec2::new(value.abs(), 0.),
// Generic fall-backs map the value onto the local +X axis as well; bounding-box-aware
// hints are refined as more node types adopt the slider.
_ => DVec2::new(value.abs(), 0.),
}
}

/// Detect hover by measuring the mouse's distance to the handle in viewport space.
pub fn handle_state(&mut self, mouse_position: DVec2, document: &DocumentMessageHandler, responses: &mut VecDeque<Message>) {
// Never override an in-progress drag.
if self.state == GenericSliderState::Dragging {
return;
}

let Some(value) = self.current_value(document) else {
self.state = GenericSliderState::Inactive;
return;
};

let viewport = document.metadata().transform_to_viewport(self.layer);
let center = viewport.transform_point2(DVec2::ZERO);
let handle = viewport.transform_point2(self.handle_position_local(value));

// Hide the gizmo when the shape is too small on screen to interact with reliably.
if handle.distance(center) < GIZMO_HIDE_THRESHOLD {
self.state = GenericSliderState::Inactive;
return;
}

if mouse_position.distance(handle) <= SLIDER_HANDLE_HOVER_THRESHOLD {
if self.state != GenericSliderState::Hover {
self.state = GenericSliderState::Hover;
// Capture the reference value now, since `handle_click` (which starts the drag) has no
// access to the document.
self.initial_value = value;
responses.add(FrontendMessage::UpdateMouseCursor { cursor: MouseCursorIcon::EWResize });
}
} else if self.state == GenericSliderState::Hover {
self.state = GenericSliderState::Inactive;
responses.add(FrontendMessage::UpdateMouseCursor { cursor: MouseCursorIcon::Default });
}
}

/// Update the parameter live while dragging. The new value is the mouse's position projected
/// onto the local +X axis, clamped to the registry's min/max bounds.
pub fn handle_update(&self, document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, responses: &mut VecDeque<Message>) {
let viewport = document.metadata().transform_to_viewport(self.layer);
let local_mouse = viewport.inverse().transform_point2(input.mouse.position);

let mut value = local_mouse.x;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Drag math in handle_update() can flip parameter sign when the mouse crosses the local origin, contradicting the comment that claims sign is preserved. The handle is always rendered at value.abs() on the +X axis, so the update logic should derive magnitude with .abs() before re-applying the original sign.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs, line 136:

<comment>Drag math in `handle_update()` can flip parameter sign when the mouse crosses the local origin, contradicting the comment that claims sign is preserved. The handle is always rendered at `value.abs()` on the +X axis, so the update logic should derive magnitude with `.abs()` before re-applying the original sign.</comment>

<file context>
@@ -0,0 +1,188 @@
+		let viewport = document.metadata().transform_to_viewport(self.layer);
+		let local_mouse = viewport.inverse().transform_point2(input.mouse.position);
+
+		let mut value = local_mouse.x;
+
+		// Preserve the sign of the original value for parameters (like radius) that can be negative.
</file context>


// Preserve the sign of the original value for parameters (like radius) that can be negative.
if self.initial_value.is_sign_negative() {
value = -value;
}

value = self.clamp(value);

responses.add(NodeGraphMessage::SetInput {
input_connector: InputConnector::node(self.node_id, self.info.parameter_index),
input: NodeInput::value(TaggedValue::F64(value), false),
});
responses.add(NodeGraphMessage::RunDocumentGraph);
}

fn clamp(&self, value: f64) -> f64 {
let mut value = value;
if let Some(min) = self.info.min {
value = value.max(min);
}
if let Some(max) = self.info.max {
value = value.min(max);
}
value
}

/// Draw the handle dot, plus a guide line from the layer origin while hovered or dragging.
pub fn overlays(&self, document: &DocumentMessageHandler, overlay_context: &mut OverlayContext) {
if self.state == GenericSliderState::Inactive {
return;
}

let Some(value) = self.current_value(document) else { return };
let viewport = document.metadata().transform_to_viewport(self.layer);
let center = viewport.transform_point2(DVec2::ZERO);
let handle = viewport.transform_point2(self.handle_position_local(value));

if handle.distance(center) < GIZMO_HIDE_THRESHOLD {
return;
}

overlay_context.line(center, handle, None, None);
overlay_context.manipulator_handle(handle, self.state == GenericSliderState::Dragging, None);
}

pub fn mouse_cursor_icon(&self) -> Option<MouseCursorIcon> {
match self.state {
GenericSliderState::Hover | GenericSliderState::Dragging => Some(MouseCursorIcon::EWResize),
GenericSliderState::Inactive => None,
}
}
}
Loading
Loading