diff --git a/crates/draw-core/src/element.rs b/crates/draw-core/src/element.rs index bcee124..1f1487e 100644 --- a/crates/draw-core/src/element.rs +++ b/crates/draw-core/src/element.rs @@ -123,6 +123,13 @@ impl ShapeElement { } } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Binding { + pub element_id: String, + pub focus: f64, + pub gap: f64, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct LineElement { pub id: String, @@ -141,6 +148,10 @@ pub struct LineElement { pub locked: bool, #[serde(default)] pub group_id: Option, + #[serde(default)] + pub start_binding: Option, + #[serde(default)] + pub end_binding: Option, } impl LineElement { @@ -156,6 +167,8 @@ impl LineElement { opacity: 1.0, locked: false, group_id: None, + start_binding: None, + end_binding: None, } } } diff --git a/crates/draw-core/src/geometry.rs b/crates/draw-core/src/geometry.rs index eec88bb..e5189c8 100644 --- a/crates/draw-core/src/geometry.rs +++ b/crates/draw-core/src/geometry.rs @@ -87,6 +87,148 @@ pub fn generate_hachure_lines( lines } +use crate::element::Element; + +/// Connection point on a shape where arrows can snap. +#[derive(Debug, Clone)] +pub struct ConnectionPoint { + pub x: f64, + pub y: f64, +} + +/// Compute the 8 connection points for a shape element. +/// Returns 4 edge midpoints + 4 corners (or shape-specific points). +/// Returns empty vec for non-shape elements (Line, Arrow, FreeDraw, Text). +pub fn connection_points(el: &Element) -> Vec { + match el { + Element::Rectangle(e) => { + let (x, y, w, h) = normalize_bounds(e.x, e.y, e.width, e.height); + rectangle_connection_points(x, y, w, h) + } + Element::Ellipse(e) => { + let (x, y, w, h) = normalize_bounds(e.x, e.y, e.width, e.height); + ellipse_connection_points(x, y, w, h) + } + Element::Diamond(e) => { + let (x, y, w, h) = normalize_bounds(e.x, e.y, e.width, e.height); + diamond_connection_points(x, y, w, h) + } + _ => Vec::new(), + } +} + +fn rectangle_connection_points(x: f64, y: f64, w: f64, h: f64) -> Vec { + vec![ + // Edge midpoints + ConnectionPoint { x: x + w / 2.0, y }, // top center + ConnectionPoint { + x: x + w, + y: y + h / 2.0, + }, // right center + ConnectionPoint { + x: x + w / 2.0, + y: y + h, + }, // bottom center + ConnectionPoint { x, y: y + h / 2.0 }, // left center + // Corners + ConnectionPoint { x, y }, // top-left + ConnectionPoint { x: x + w, y }, // top-right + ConnectionPoint { x: x + w, y: y + h }, // bottom-right + ConnectionPoint { x, y: y + h }, // bottom-left + ] +} + +fn ellipse_connection_points(x: f64, y: f64, w: f64, h: f64) -> Vec { + let cx = x + w / 2.0; + let cy = y + h / 2.0; + let rx = w / 2.0; + let ry = h / 2.0; + let cos45 = std::f64::consts::FRAC_PI_4.cos(); + let sin45 = std::f64::consts::FRAC_PI_4.sin(); + vec![ + // Cardinal points (edge midpoints of bounding box on ellipse perimeter) + ConnectionPoint { x: cx, y: cy - ry }, // top + ConnectionPoint { x: cx + rx, y: cy }, // right + ConnectionPoint { x: cx, y: cy + ry }, // bottom + ConnectionPoint { x: cx - rx, y: cy }, // left + // 45-degree points on ellipse perimeter + ConnectionPoint { + x: cx + rx * cos45, + y: cy - ry * sin45, + }, // top-right + ConnectionPoint { + x: cx + rx * cos45, + y: cy + ry * sin45, + }, // bottom-right + ConnectionPoint { + x: cx - rx * cos45, + y: cy + ry * sin45, + }, // bottom-left + ConnectionPoint { + x: cx - rx * cos45, + y: cy - ry * sin45, + }, // top-left + ] +} + +fn diamond_connection_points(x: f64, y: f64, w: f64, h: f64) -> Vec { + let cx = x + w / 2.0; + let cy = y + h / 2.0; + vec![ + // Vertices (the 4 tips of the diamond) + ConnectionPoint { x: cx, y }, // top vertex + ConnectionPoint { x: x + w, y: cy }, // right vertex + ConnectionPoint { x: cx, y: y + h }, // bottom vertex + ConnectionPoint { x, y: cy }, // left vertex + // Edge midpoints (between adjacent vertices) + ConnectionPoint { + x: cx + w / 4.0, + y: y + h / 4.0, + }, // top-right edge mid + ConnectionPoint { + x: cx + w / 4.0, + y: cy + h / 4.0, + }, // bottom-right edge mid + ConnectionPoint { + x: cx - w / 4.0, + y: cy + h / 4.0, + }, // bottom-left edge mid + ConnectionPoint { + x: cx - w / 4.0, + y: y + h / 4.0, + }, // top-left edge mid + ] +} + +/// Find the nearest connection point within `threshold` world-coordinate distance. +/// `wx, wy` are the world coordinates to snap to. +/// `exclude_id` is the element to skip (the arrow being drawn). +/// Returns `(element_id, snap_x, snap_y)` or None. +pub fn find_nearest_snap_point( + elements: &[Element], + wx: f64, + wy: f64, + threshold: f64, + exclude_id: &str, +) -> Option<(String, f64, f64)> { + let mut best: Option<(String, f64, f64, f64)> = None; // (id, x, y, dist) + + for el in elements { + if el.id() == exclude_id { + continue; + } + let pts = connection_points(el); + for cp in &pts { + let dist = ((cp.x - wx).powi(2) + (cp.y - wy).powi(2)).sqrt(); + if dist < threshold && (best.is_none() || dist < best.as_ref().unwrap().3) { + best = Some((el.id().to_string(), cp.x, cp.y, dist)); + } + } + } + + best.map(|(id, x, y, _)| (id, x, y)) +} + #[cfg(test)] mod tests { use super::*; @@ -133,4 +275,86 @@ mod tests { // diag ~14.1, gap 1000 — only one or zero lines assert!(lines.len() <= 1); } + + #[test] + fn rectangle_connection_points_count() { + use crate::element::{Element, ShapeElement}; + let el = Element::Rectangle(ShapeElement::new("r1".into(), 0.0, 0.0, 100.0, 50.0)); + let pts = connection_points(&el); + assert_eq!(pts.len(), 8); + // Top center + assert!((pts[0].x - 50.0).abs() < 1e-10); + assert!((pts[0].y - 0.0).abs() < 1e-10); + } + + #[test] + fn ellipse_connection_points_count() { + use crate::element::{Element, ShapeElement}; + let el = Element::Ellipse(ShapeElement::new("e1".into(), 0.0, 0.0, 100.0, 60.0)); + let pts = connection_points(&el); + assert_eq!(pts.len(), 8); + // Top point should be at center-x, top of ellipse + assert!((pts[0].x - 50.0).abs() < 1e-10); + assert!((pts[0].y - 0.0).abs() < 1e-10); + } + + #[test] + fn diamond_connection_points_count() { + use crate::element::{Element, ShapeElement}; + let el = Element::Diamond(ShapeElement::new("d1".into(), 0.0, 0.0, 100.0, 80.0)); + let pts = connection_points(&el); + assert_eq!(pts.len(), 8); + // Top vertex + assert!((pts[0].x - 50.0).abs() < 1e-10); + assert!((pts[0].y - 0.0).abs() < 1e-10); + } + + #[test] + fn find_snap_point_within_threshold() { + use crate::element::{Element, ShapeElement}; + let elements = vec![Element::Rectangle(ShapeElement::new( + "r1".into(), + 100.0, + 100.0, + 80.0, + 60.0, + ))]; + // Top-center of rectangle is at (140, 100) + let result = find_nearest_snap_point(&elements, 142.0, 102.0, 15.0, ""); + assert!(result.is_some()); + let (id, sx, sy) = result.unwrap(); + assert_eq!(id, "r1"); + assert!((sx - 140.0).abs() < 1e-10); + assert!((sy - 100.0).abs() < 1e-10); + } + + #[test] + fn find_snap_point_outside_threshold() { + use crate::element::{Element, ShapeElement}; + let elements = vec![Element::Rectangle(ShapeElement::new( + "r1".into(), + 100.0, + 100.0, + 80.0, + 60.0, + ))]; + // Far from any connection point + let result = find_nearest_snap_point(&elements, 0.0, 0.0, 15.0, ""); + assert!(result.is_none()); + } + + #[test] + fn find_snap_excludes_self() { + use crate::element::{Element, ShapeElement}; + let elements = vec![Element::Rectangle(ShapeElement::new( + "r1".into(), + 100.0, + 100.0, + 80.0, + 60.0, + ))]; + // Within threshold but excluded + let result = find_nearest_snap_point(&elements, 140.0, 100.0, 15.0, "r1"); + assert!(result.is_none()); + } } diff --git a/crates/draw-core/src/lib.rs b/crates/draw-core/src/lib.rs index 98523c9..aba6982 100644 --- a/crates/draw-core/src/lib.rs +++ b/crates/draw-core/src/lib.rs @@ -10,7 +10,7 @@ pub mod storage; pub mod style; pub use document::Document; -pub use element::{Element, FreeDrawElement, LineElement, ShapeElement, TextElement}; +pub use element::{Binding, Element, FreeDrawElement, LineElement, ShapeElement, TextElement}; pub use export_png::{export_png, export_png_with_scale}; pub use export_svg::export_svg; pub use point::{Bounds, Point, ViewState}; diff --git a/crates/draw-core/src/render/draw.rs b/crates/draw-core/src/render/draw.rs index 1632b9a..cd4bf23 100644 --- a/crates/draw-core/src/render/draw.rs +++ b/crates/draw-core/src/render/draw.rs @@ -357,6 +357,30 @@ impl super::Renderer { } } +impl super::Renderer { + /// Draw a snap indicator dot at the given world coordinates. + pub fn draw_snap_indicator( + &self, + pixmap: &mut Pixmap, + viewport: &crate::point::ViewState, + wx: f64, + wy: f64, + ) { + let vt = super::viewport_transform(viewport, self.config.pixel_ratio); + let scale = viewport.zoom as f32 * self.config.pixel_ratio; + let radius = super::HANDLE_RADIUS / scale; + + let accent = Color::from_rgba8(super::ACCENT_R, super::ACCENT_G, super::ACCENT_B, 255); + let mut fill_paint = Paint::default(); + fill_paint.set_color(accent); + fill_paint.anti_alias = true; + + if let Some(path) = super::path::build_circle_path(wx as f32, wy as f32, radius) { + pixmap.fill_path(&path, &fill_paint, FillRule::Winding, vt, None); + } + } +} + fn draw_arrowhead_path( pixmap: &mut Pixmap, ah: &geometry::ArrowheadPoints, diff --git a/crates/draw-py/Cargo.lock b/crates/draw-py/Cargo.lock index 227a188..eaea609 100644 --- a/crates/draw-py/Cargo.lock +++ b/crates/draw-py/Cargo.lock @@ -625,6 +625,9 @@ dependencies = [ "axum", "dkdc-draw-core", "dkdc-draw-webapp", + "objc2", + "objc2-app-kit", + "objc2-foundation", "tao", "tokio", "wry", diff --git a/crates/draw-wasm/src/lib.rs b/crates/draw-wasm/src/lib.rs index 78336a4..895d00b 100644 --- a/crates/draw-wasm/src/lib.rs +++ b/crates/draw-wasm/src/lib.rs @@ -26,6 +26,7 @@ pub struct DrawEngine { selection_box: Option, history: History, pixel_ratio: f32, + snap_indicator: Option<(f64, f64)>, } // Methods exposed to JS via wasm_bindgen (wasm32 only) AND available natively. @@ -53,6 +54,7 @@ impl DrawEngine { selection_box: None, history: History::new(), pixel_ratio, + snap_indicator: None, } } @@ -95,12 +97,19 @@ impl DrawEngine { /// Render the current state and return RGBA pixel data. pub fn render(&self) -> Vec { let sel_refs: Vec<&str> = self.selected_ids.iter().map(|s| s.as_str()).collect(); - let pixmap = self.renderer.render( + let mut pixmap = self.renderer.render( &self.document, &self.viewport, &sel_refs, self.selection_box, ); + + // Draw snap indicator overlay + if let Some((sx, sy)) = self.snap_indicator { + self.renderer + .draw_snap_indicator(&mut pixmap, &self.viewport, sx, sy); + } + pixmap.data().to_vec() } @@ -300,6 +309,8 @@ impl DrawEngine { if let Some(el) = self.document.get_element_mut(id) { el.set_position(x, y); } + // Update any arrows bound to this element + self.update_bound_arrows(id); } /// Resize an element to the given bounds. @@ -347,6 +358,8 @@ impl DrawEngine { } } } + // Update any arrows bound to this element + self.update_bound_arrows(id); } /// Update an element's style from JSON. The JSON should contain style fields @@ -597,6 +610,71 @@ impl DrawEngine { pub fn element_count(&self) -> usize { self.document.elements.len() } + + // ── Arrow snap / binding ─────────────────────────────────────── + + /// Find the nearest snap point on any shape element within threshold. + /// `wx, wy` are world coordinates. `exclude_id` is the element to skip + /// (typically the arrow being drawn). `threshold` is in world-coordinate distance. + /// Returns JSON `{"element_id":"...","x":...,"y":...}` or empty string if no snap. + pub fn find_snap_target(&self, wx: f64, wy: f64, threshold: f64, exclude_id: &str) -> String { + match draw_core::geometry::find_nearest_snap_point( + &self.document.elements, + wx, + wy, + threshold, + exclude_id, + ) { + Some((element_id, x, y)) => { + format!(r#"{{"element_id":"{}","x":{},"y":{}}}"#, element_id, x, y) + } + None => String::new(), + } + } + + /// Set binding on an arrow endpoint. `endpoint` is "start" or "end". + /// `binding_json` is JSON `{"element_id":"...","focus":0,"gap":0}` or empty to clear. + pub fn set_arrow_binding( + &mut self, + arrow_id: &str, + endpoint: &str, + binding_json: &str, + ) -> bool { + let binding: Option = if binding_json.is_empty() { + None + } else { + match serde_json::from_str(binding_json) { + Ok(b) => Some(b), + Err(_) => return false, + } + }; + + if let Some(Element::Arrow(line)) = self.document.get_element_mut(arrow_id) { + match endpoint { + "start" => { + line.start_binding = binding; + true + } + "end" => { + line.end_binding = binding; + true + } + _ => false, + } + } else { + false + } + } + + /// Set the snap indicator position (world coordinates) to show during arrow creation. + pub fn set_snap_indicator(&mut self, x: f64, y: f64) { + self.snap_indicator = Some((x, y)); + } + + /// Clear the snap indicator. + pub fn clear_snap_indicator(&mut self) { + self.snap_indicator = None; + } } // ── Private helpers (not exposed to JS) ───────────────────────────────── @@ -674,6 +752,102 @@ impl DrawEngine { } } } + + /// Recompute endpoint positions for all arrows bound to the given element. + fn update_bound_arrows(&mut self, target_id: &str) { + // Collect arrow IDs and their binding info first to avoid borrow conflicts + let updates: Vec<(String, Option, Option)> = self + .document + .elements + .iter() + .filter_map(|el| { + if let Element::Arrow(line) = el { + let start_bound = line + .start_binding + .as_ref() + .filter(|b| b.element_id == target_id) + .map(|b| b.element_id.clone()); + let end_bound = line + .end_binding + .as_ref() + .filter(|b| b.element_id == target_id) + .map(|b| b.element_id.clone()); + if start_bound.is_some() || end_bound.is_some() { + Some((line.id.clone(), start_bound, end_bound)) + } else { + None + } + } else { + None + } + }) + .collect(); + + if updates.is_empty() { + return; + } + + // Get target element's current connection points + let target_points: Vec<(f64, f64)> = self + .document + .get_element(target_id) + .map(|el| { + draw_core::geometry::connection_points(el) + .into_iter() + .map(|cp| (cp.x, cp.y)) + .collect() + }) + .unwrap_or_default(); + + if target_points.is_empty() { + return; + } + + for (arrow_id, start_bound, end_bound) in updates { + if let Some(Element::Arrow(line)) = self.document.get_element_mut(&arrow_id) { + if line.points.len() < 2 { + continue; + } + + if start_bound.is_some() { + // Find nearest connection point to current start + let start_abs_x = line.points[0].x + line.x; + let start_abs_y = line.points[0].y + line.y; + let nearest = find_nearest_point(&target_points, start_abs_x, start_abs_y); + // Update: the first point is at (0,0) relative, so move element position + let last_idx = line.points.len() - 1; + let end_abs_x = line.points[last_idx].x + line.x; + let end_abs_y = line.points[last_idx].y + line.y; + line.x = nearest.0; + line.y = nearest.1; + line.points[0] = draw_core::Point::new(0.0, 0.0); + line.points[last_idx] = + draw_core::Point::new(end_abs_x - nearest.0, end_abs_y - nearest.1); + } + + if end_bound.is_some() { + let last_idx = line.points.len() - 1; + let end_abs_x = line.points[last_idx].x + line.x; + let end_abs_y = line.points[last_idx].y + line.y; + let nearest = find_nearest_point(&target_points, end_abs_x, end_abs_y); + line.points[last_idx] = + draw_core::Point::new(nearest.0 - line.x, nearest.1 - line.y); + } + } + } + } +} + +fn find_nearest_point(points: &[(f64, f64)], x: f64, y: f64) -> (f64, f64) { + points + .iter() + .min_by(|a, b| { + let da = (a.0 - x).powi(2) + (a.1 - y).powi(2); + let db = (b.0 - x).powi(2) + (b.1 - y).powi(2); + da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal) + }) + .copied() + .unwrap_or((x, y)) } // ── Native tests ──────────────────────────────────────────────────────── diff --git a/crates/draw-webapp/frontend/interactions.js b/crates/draw-webapp/frontend/interactions.js index 02d8fd4..3af7781 100644 --- a/crates/draw-webapp/frontend/interactions.js +++ b/crates/draw-webapp/frontend/interactions.js @@ -12,6 +12,8 @@ class Interactions { this.resizeOrigin = null; this.drawingPoints = []; this.creatingElement = null; // JSON object for element being created (preview) + this._startSnap = null; // {element_id, x, y} if arrow start snapped to a shape + this._endSnap = null; // {element_id, x, y} if arrow end snapped to a shape this.bind(); } @@ -142,7 +144,24 @@ class Interactions { // Shape creation tools this.state = 'creating'; - const snapped = this.app.snapPoint(world.x, world.y); + this._startSnap = null; + this._endSnap = null; + let snapped = this.app.snapPoint(world.x, world.y); + + // For arrow/line tools, check if start point snaps to a shape connection point + if (tool === 'arrow' || tool === 'line') { + const threshold = 15 / (this.engine.zoom() || 1); + const snapResult = this.engine.find_snap_target(world.x, world.y, threshold, ''); + if (snapResult) { + try { + const snap = JSON.parse(snapResult); + snapped = { x: snap.x, y: snap.y }; + this._startSnap = snap; + this.engine.set_snap_indicator(snap.x, snap.y); + } catch (_) {} + } + } + const el = this.createElementAt(tool, snapped.x, snapped.y); this.creatingElement = el; this.startWorld = snapped; @@ -164,9 +183,26 @@ class Interactions { } if (this.state === 'creating' && this.creatingElement) { - const snapped = this.app.snapPoint(world.x, world.y); + let snapped = this.app.snapPoint(world.x, world.y); const el = this.creatingElement; if (el.type === 'Line' || el.type === 'Arrow') { + // Check for connection-point snap on the endpoint + const threshold = 15 / (this.engine.zoom() || 1); + const snapResult = this.engine.find_snap_target(world.x, world.y, threshold, el.id); + if (snapResult) { + try { + const snap = JSON.parse(snapResult); + snapped = { x: snap.x, y: snap.y }; + this._endSnap = snap; + this.engine.set_snap_indicator(snap.x, snap.y); + } catch (_) { + this._endSnap = null; + this.engine.clear_snap_indicator(); + } + } else { + this._endSnap = null; + this.engine.clear_snap_indicator(); + } el.points = [ { x: 0, y: 0 }, { x: snapped.x - el.x, y: snapped.y - el.y }, @@ -276,6 +312,9 @@ class Interactions { // Undo the add action that was recorded this.engine.undo(); this.creatingElement = null; + this._startSnap = null; + this._endSnap = null; + this.engine.clear_snap_indicator(); this.state = 'idle'; this.dc.markDirty(); return; @@ -287,6 +326,9 @@ class Interactions { this.engine.remove_element(el.id); this.engine.undo(); this.creatingElement = null; + this._startSnap = null; + this._endSnap = null; + this.engine.clear_snap_indicator(); this.state = 'idle'; this.dc.markDirty(); return; @@ -297,6 +339,22 @@ class Interactions { // the final geometry so undo/redo records the correct shape. this.engine.undo(); this.engine.add_element(JSON.stringify(el)); + + // Attach arrow bindings if snapped to connection points + if ((el.type === 'Arrow' || el.type === 'Line') && (this._startSnap || this._endSnap)) { + if (this._startSnap) { + this.engine.set_arrow_binding(el.id, 'start', + JSON.stringify({ element_id: this._startSnap.element_id, focus: 0, gap: 0 })); + } + if (this._endSnap) { + this.engine.set_arrow_binding(el.id, 'end', + JSON.stringify({ element_id: this._endSnap.element_id, focus: 0, gap: 0 })); + } + } + this._startSnap = null; + this._endSnap = null; + this.engine.clear_snap_indicator(); + this.engine.clear_selection(); this.engine.add_to_selection(el.id); this.creatingElement = null; @@ -508,9 +566,9 @@ class Interactions { case 'diamond': return { type: 'Diamond', id, x: wx, y: wy, width: 0, height: 0, angle: 0, stroke, fill, opacity, locked: false, group_id: null }; case 'line': - return { type: 'Line', id, x: wx, y: wy, points: [{ x: 0, y: 0 }], stroke, start_arrowhead: null, end_arrowhead: null, opacity, locked: false, group_id: null }; + return { type: 'Line', id, x: wx, y: wy, points: [{ x: 0, y: 0 }], stroke, start_arrowhead: null, end_arrowhead: null, opacity, locked: false, group_id: null, start_binding: null, end_binding: null }; case 'arrow': - return { type: 'Arrow', id, x: wx, y: wy, points: [{ x: 0, y: 0 }], stroke, start_arrowhead: null, end_arrowhead: "arrow", opacity, locked: false, group_id: null }; + return { type: 'Arrow', id, x: wx, y: wy, points: [{ x: 0, y: 0 }], stroke, start_arrowhead: null, end_arrowhead: "arrow", opacity, locked: false, group_id: null, start_binding: null, end_binding: null }; default: return { type: 'Rectangle', id, x: wx, y: wy, width: 0, height: 0, angle: 0, stroke, fill, opacity, locked: false, group_id: null }; } diff --git a/uv.lock b/uv.lock index 0e9e887..e8f386e 100644 --- a/uv.lock +++ b/uv.lock @@ -5,7 +5,7 @@ requires-python = ">=3.11" [[package]] name = "colorama" version = "0.4.6" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, @@ -37,7 +37,7 @@ dev = [ [[package]] name = "iniconfig" version = "2.3.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, @@ -46,7 +46,7 @@ wheels = [ [[package]] name = "maturin" version = "1.12.6" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/0c/18/8b2eebd3ea086a5ec73d7081f95ec64918ceda1900075902fc296ea3ad55/maturin-1.12.6.tar.gz", hash = "sha256:d37be3a811a7f2ee28a0fa0964187efa50e90f21da0c6135c27787fa0b6a89db", size = 269165, upload-time = "2026-03-01T14:54:04.21Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/71/8b/9ddfde8a485489e3ebdc50ee3042ef1c854f00dfea776b951068f6ffe451/maturin-1.12.6-py3-none-linux_armv6l.whl", hash = "sha256:6892b4176992fcc143f9d1c1c874a816e9a041248eef46433db87b0f0aff4278", size = 9789847, upload-time = "2026-03-01T14:54:09.172Z" }, @@ -67,7 +67,7 @@ wheels = [ [[package]] name = "packaging" version = "26.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, @@ -76,7 +76,7 @@ wheels = [ [[package]] name = "pluggy" version = "1.6.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, @@ -85,7 +85,7 @@ wheels = [ [[package]] name = "pygments" version = "2.20.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, @@ -94,7 +94,7 @@ wheels = [ [[package]] name = "pytest" version = "9.0.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "iniconfig" }, @@ -110,7 +110,7 @@ wheels = [ [[package]] name = "ruff" version = "0.15.9" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/e6/97/e9f1ca355108ef7194e38c812ef40ba98c7208f47b13ad78d023caa583da/ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2", size = 4617361, upload-time = "2026-04-02T18:17:20.829Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0b/1f/9cdfd0ac4b9d1e5a6cf09bedabdf0b56306ab5e333c85c87281273e7b041/ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1", size = 10511206, upload-time = "2026-04-02T18:16:41.574Z" }, @@ -135,7 +135,7 @@ wheels = [ [[package]] name = "ty" version = "0.0.28" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/19/c2/a60543fb172ac7adaa3ae43b8db1d0dcd70aa67df254b70bf42f852a24f6/ty-0.0.28.tar.gz", hash = "sha256:1fbde7bc5d154d6f047b570d95665954fa83b75a0dce50d88cf081b40a27ea32", size = 5447781, upload-time = "2026-04-02T21:34:33.556Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/fe/15/c2aa3d4633e6153a2e300d7dd0ebdedf904a60241d1922566f31c5f7f211/ty-0.0.28-py3-none-linux_armv6l.whl", hash = "sha256:6dbfb27524195ab1715163d7be065cc45037509fe529d9763aff6732c919f0d8", size = 10556282, upload-time = "2026-04-02T21:35:04.165Z" },