Skip to content
Merged
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
11 changes: 11 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,14 @@ exclude = ["crates/draw-py"]
lto = true
strip = true
codegen-units = 1

# Surgical clippy opt-ins. We don't enable clippy::pedantic wholesale — the
# f32/f64 cast and must_use_candidate lints drown real signal. These are the
# specific lints that caught bugs or unclear intent during review.
[workspace.lints.clippy]
manual_let_else = "warn"
missing_errors_doc = "warn"
missing_panics_doc = "warn"
redundant_closure_for_method_calls = "warn"
uninlined_format_args = "warn"
semicolon_if_nothing_returned = "warn"
3 changes: 3 additions & 0 deletions crates/draw-app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,6 @@ objc2-foundation = { version = "0.3", features = ["NSString"], default-features
objc2 = "0.6"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net"] }
wry = "0.55"

[lints]
workspace = true
13 changes: 13 additions & 0 deletions crates/draw-app/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,19 @@ fn setup_macos_menu() {
#[cfg(not(target_os = "macos"))]
fn setup_macos_menu() {}

/// Launch the desktop app wrapping the embedded webapp in a native webview.
///
/// If `open_id` is set, the window opens that drawing on launch.
///
/// # Errors
/// Returns an error if the webview event loop or window cannot be created,
/// the embedded webapp fails to report back a bound port, or the webview
/// process terminates abnormally.
///
/// # Panics
/// Panics if the background tokio runtime for the embedded webapp cannot be
/// built or its listener cannot bind a port. These are treated as fatal
/// because the desktop app has no UI to report them.
pub fn run_app(open_id: Option<String>) -> anyhow::Result<()> {
// Start the axum webapp on a random available port in a background thread
let (port_tx, port_rx) = std::sync::mpsc::channel::<u16>();
Expand Down
3 changes: 3 additions & 0 deletions crates/draw-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,6 @@ dkdc-draw-core = { version = "0.2.1", path = "../draw-core" }
dkdc-draw-app = { version = "0.2.1", path = "../draw-app", optional = true }
dkdc-draw-webapp = { version = "0.2.1", path = "../draw-webapp", optional = true }
clap = { version = "4.5", features = ["derive"] }

[lints]
workspace = true
9 changes: 9 additions & 0 deletions crates/draw-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,15 @@ pub enum Command {
},
}

/// Parse `argv` as the draw CLI and dispatch the matching subcommand.
///
/// Clap handles `--help` / `--version` / argument errors itself by calling
/// `process::exit`, so those paths never return here.
///
/// # Errors
/// Returns an error if a subcommand fails: I/O errors loading or exporting a
/// drawing, a requested drawing id not found, or the embedded webapp/app
/// failing to launch.
pub fn run_cli(argv: impl IntoIterator<Item = impl Into<String>>) -> Result<()> {
let argv: Vec<String> = argv.into_iter().map(Into::into).collect();

Expand Down
3 changes: 3 additions & 0 deletions crates/draw-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,6 @@ uuid = { version = "1", features = ["v4"] }

[dev-dependencies]
tempfile = "3"

[lints]
workspace = true
2 changes: 1 addition & 1 deletion crates/draw-core/src/element.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ impl Element {
}

pub fn set_position(&mut self, x: f64, y: f64) {
with_element!(self, e => { e.x = x; e.y = y; })
with_element!(self, e => { e.x = x; e.y = y; });
}

pub fn opacity(&self) -> f64 {
Expand Down
10 changes: 10 additions & 0 deletions crates/draw-core/src/export_png.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,21 @@ use crate::export_svg::export_svg;
const DEFAULT_SCALE: f32 = 2.0;

/// Export a document as PNG bytes at the default 2x scale.
///
/// See [`export_png_with_scale`] for error conditions.
///
/// # Errors
/// Returns an error under the same conditions as [`export_png_with_scale`].
pub fn export_png(doc: &Document) -> anyhow::Result<Vec<u8>> {
export_png_with_scale(doc, DEFAULT_SCALE)
}

/// Export a document as PNG bytes at a given scale factor.
///
/// # Errors
/// Returns an error if the intermediate SVG cannot be parsed, the scaled
/// dimensions round to zero, allocating the pixmap fails, or PNG encoding
/// fails.
pub fn export_png_with_scale(doc: &Document, scale: f32) -> anyhow::Result<Vec<u8>> {
let svg_string = export_svg(doc);

Expand Down
35 changes: 18 additions & 17 deletions crates/draw-core/src/export_svg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,29 +22,27 @@ pub fn export_svg(doc: &Document) -> String {
let w = bounds.width + padding * 2.0;
let h = bounds.height + padding * 2.0;

let mut svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{x} {y} {w} {h}" width="{w}" height="{h}">"#
);
svg.push('\n');

// Collect defs (clipPaths for hachure fills)
let mut defs = String::new();
let mut body = String::new();
let mut clip_id: usize = 0;

for element in &doc.elements {
let (element_svg, element_defs) = render_element(element, &mut clip_id);
defs.push_str(&element_defs);
svg.push_str(&element_svg);
svg.push('\n');
body.push_str(&element_svg);
body.push('\n');
}

// Insert defs block before elements if needed
let mut svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{x} {y} {w} {h}" width="{w}" height="{h}">"#
);
svg.push('\n');
if !defs.is_empty() {
let defs_block = format!(" <defs>\n{defs} </defs>\n");
let insert_pos = svg.find('\n').unwrap() + 1;
svg.insert_str(insert_pos, &defs_block);
svg.push_str(" <defs>\n");
svg.push_str(&defs);
svg.push_str(" </defs>\n");
}

svg.push_str(&body);
svg.push_str("</svg>");
svg
}
Expand Down Expand Up @@ -352,10 +350,13 @@ fn render_hachure_group(clip_id: &str, bounds: &Bounds, fill: &FillStyle, opacit
));
}

// TODO: opacity="…" and style="opacity:…" both set opacity on the same
// <g>; the inline `style` wins in the CSS cascade and the attribute is
// effectively dead. Kept as-is to preserve output byte-equivalence with
// prior behavior; address in a follow-up.
format!(
r#" <g clip-path="url(#{clip_id})" opacity="{}" style="opacity:{HACHURE_OPACITY}">
{lines} </g>"#,
opacity
r#" <g clip-path="url(#{clip_id})" opacity="{opacity}" style="opacity:{HACHURE_OPACITY}">
{lines} </g>"#
)
}

Expand All @@ -371,7 +372,7 @@ fn fill_attr(color: &str, style: &FillType) -> String {
fn stroke_attrs(color: &str, width: f64, dash: &[f64]) -> String {
let mut s = format!(r#"stroke="{color}" stroke-width="{width}""#);
if !dash.is_empty() {
let dash_str: Vec<String> = dash.iter().map(|d| d.to_string()).collect();
let dash_str: Vec<String> = dash.iter().map(f64::to_string).collect();
s.push_str(&format!(r#" stroke-dasharray="{}""#, dash_str.join(",")));
}
s
Expand Down
2 changes: 1 addition & 1 deletion crates/draw-core/src/geometry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ pub fn find_nearest_snap_point(
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) {
if dist < threshold && best.as_ref().is_none_or(|b| dist < b.3) {
best = Some((el.id().to_string(), cp.x, cp.y, dist));
}
}
Expand Down
5 changes: 2 additions & 3 deletions crates/draw-core/src/render/draw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -271,9 +271,8 @@ impl super::Renderer {
transform: &Transform,
opacity: f32,
) {
let mut clip_mask = match Mask::new(pixmap.width(), pixmap.height()) {
Some(m) => m,
None => return,
let Some(mut clip_mask) = Mask::new(pixmap.width(), pixmap.height()) else {
return;
};
clip_mask.fill_path(clip_path, FillRule::Winding, true, *transform);

Expand Down
8 changes: 8 additions & 0 deletions crates/draw-core/src/render/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,14 @@ impl Renderer {

// ── Main render entry point ─────────────────────────────────────

/// Render the document to a pixmap.
///
/// # Panics
/// Panics if `tiny_skia::Pixmap::new` fails to allocate. Width and height
/// are clamped to at least 1 via `.max(1)`, so the remaining failure modes
/// are allocator OOM or dimensions whose `width * height * 4` overflows
/// `i32::MAX` — conditions the caller is responsible for avoiding via
/// [`RenderConfig`] sizing.
pub fn render(
&self,
doc: &Document,
Expand Down
21 changes: 21 additions & 0 deletions crates/draw-core/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ use anyhow::{Context, Result};

use crate::document::Document;

/// Return the directory where drawings are stored, creating it if needed.
///
/// # Errors
/// Returns an error if the platform config directory cannot be determined or
/// the directory cannot be created.
pub fn storage_dir() -> Result<PathBuf> {
let dir = dirs::config_dir()
.context("could not determine config directory")?
Expand All @@ -14,6 +19,10 @@ pub fn storage_dir() -> Result<PathBuf> {
Ok(dir)
}

/// Serialize a document to `path` as pretty-printed JSON.
///
/// # Errors
/// Returns an error if JSON serialization fails or the file cannot be written.
pub fn save(doc: &Document, path: &Path) -> Result<()> {
let json = serde_json::to_string_pretty(doc)?;
if let Some(parent) = path.parent() {
Expand All @@ -23,19 +32,31 @@ pub fn save(doc: &Document, path: &Path) -> Result<()> {
Ok(())
}

/// Save a document under [`storage_dir`] as `<doc.id>.draw.json`.
///
/// # Errors
/// Returns an error if the storage directory cannot be resolved or writing fails.
pub fn save_to_storage(doc: &Document) -> Result<PathBuf> {
let dir = storage_dir()?;
let path = dir.join(format!("{}.draw.json", doc.id));
save(doc, &path)?;
Ok(path)
}

/// Load a document from a `.draw.json` file.
///
/// # Errors
/// Returns an error if the file cannot be read or the JSON is malformed.
pub fn load(path: &Path) -> Result<Document> {
let json = fs::read_to_string(path).context("could not read drawing file")?;
let doc: Document = serde_json::from_str(&json).context("invalid drawing file")?;
Ok(doc)
}

/// List all drawings in [`storage_dir`] as `(name, path)` tuples, sorted by name.
///
/// # Errors
/// Returns an error if the storage directory cannot be resolved or read.
pub fn list_drawings() -> Result<Vec<(String, PathBuf)>> {
let dir = storage_dir()?;
let mut drawings = vec![];
Expand Down
10 changes: 10 additions & 0 deletions crates/draw-py/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,13 @@ dkdc-draw = { version = "0.2.1", path = "../draw-cli", features = ["webapp", "ap
dkdc-draw-core = { version = "0.2.1", path = "../draw-core" }
pyo3 = { version = "0.28", features = ["extension-module", "abi3-py311"] }
serde_json = "1"

# draw-py is its own cargo workspace so it can't `workspace = true` onto the
# outer workspace lints — mirror the same surgical opt-ins here.
[lints.clippy]
manual_let_else = "warn"
missing_errors_doc = "warn"
missing_panics_doc = "warn"
redundant_closure_for_method_calls = "warn"
uninlined_format_args = "warn"
semicolon_if_nothing_returned = "warn"
2 changes: 1 addition & 1 deletion crates/draw-py/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ fn to_py_err(e: anyhow::Error) -> PyErr {

#[pyfunction]
fn run_cli(argv: Vec<String>) -> PyResult<()> {
draw::run_cli(argv.iter().map(|s| s.as_str())).map_err(to_py_err)
draw::run_cli(argv.iter().map(String::as_str)).map_err(to_py_err)
}

#[pyfunction]
Expand Down
3 changes: 3 additions & 0 deletions crates/draw-wasm/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,6 @@ wasm-bindgen = "0.2"
uuid = { version = "1", features = ["js"] }

[dev-dependencies]

[lints]
workspace = true
28 changes: 12 additions & 16 deletions crates/draw-wasm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ impl DrawEngine {

/// Render the current state and return RGBA pixel data.
pub fn render(&self) -> Vec<u8> {
let sel_refs: Vec<&str> = self.selected_ids.iter().map(|s| s.as_str()).collect();
let sel_refs: Vec<&str> = self.selected_ids.iter().map(String::as_str).collect();
let mut pixmap = self.renderer.render(
&self.document,
&self.viewport,
Expand Down Expand Up @@ -184,7 +184,7 @@ impl DrawEngine {
draw_core::HandlePosition::SouthWest => "SouthWest",
draw_core::HandlePosition::SouthEast => "SouthEast",
};
format!(r#"{{"id":"{}","handle":"{}"}}"#, id, handle_str)
format!(r#"{{"id":"{id}","handle":"{handle_str}"}}"#)
}
None => String::new(),
}
Expand Down Expand Up @@ -214,7 +214,7 @@ impl DrawEngine {
pub fn screen_to_world(&self, sx: f64, sy: f64) -> String {
let wx = (sx - self.viewport.scroll_x) / self.viewport.zoom;
let wy = (sy - self.viewport.scroll_y) / self.viewport.zoom;
format!(r#"{{"x":{},"y":{}}}"#, wx, wy)
format!(r#"{{"x":{wx},"y":{wy}}}"#)
}

pub fn scroll_x(&self) -> f64 {
Expand Down Expand Up @@ -366,22 +366,18 @@ impl DrawEngine {
/// (stroke, fill, font, opacity, etc.) to merge into the element.
pub fn update_element_style(&mut self, id: &str, style_json: &str) -> bool {
// Snapshot "before" for undo
let before = self.document.get_element(id).cloned();
let before = match before {
Some(b) => b,
None => return false,
let Some(before) = self.document.get_element(id).cloned() else {
return false;
};

// Parse the style update as a generic JSON value
let updates: serde_json::Value = match serde_json::from_str(style_json) {
Ok(v) => v,
Err(_) => return false,
let Ok(updates) = serde_json::from_str::<serde_json::Value>(style_json) else {
return false;
};

// Serialize element, merge updates, deserialize back
let mut elem_val = match serde_json::to_value(&before) {
Ok(v) => v,
Err(_) => return false,
let Ok(mut elem_val) = serde_json::to_value(&before) else {
return false;
};

if let (Some(obj), Some(upd)) = (elem_val.as_object_mut(), updates.as_object()) {
Expand Down Expand Up @@ -512,7 +508,7 @@ impl DrawEngine {

/// Get all element IDs as a JSON array.
pub fn get_all_element_ids(&self) -> String {
let ids: Vec<&str> = self.document.elements.iter().map(|el| el.id()).collect();
let ids: Vec<&str> = self.document.elements.iter().map(Element::id).collect();
serde_json::to_string(&ids).unwrap_or_else(|_| "[]".to_string())
}

Expand All @@ -523,7 +519,7 @@ impl DrawEngine {
.elements
.iter()
.filter(|el| el.group_id() == Some(group_id))
.map(|el| el.id())
.map(Element::id)
.collect();
serde_json::to_string(&ids).unwrap_or_else(|_| "[]".to_string())
}
Expand Down Expand Up @@ -626,7 +622,7 @@ impl DrawEngine {
exclude_id,
) {
Some((element_id, x, y)) => {
format!(r#"{{"element_id":"{}","x":{},"y":{}}}"#, element_id, x, y)
format!(r#"{{"element_id":"{element_id}","x":{x},"y":{y}}}"#)
}
None => String::new(),
}
Expand Down
3 changes: 3 additions & 0 deletions crates/draw-webapp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,6 @@ open = "5"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal"] }

[lints]
workspace = true
5 changes: 2 additions & 3 deletions crates/draw-webapp/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,8 @@ fn main() {
Ok(s) if s.success() => {}
Ok(s) => {
panic!(
"wasm-pack build failed with exit code: {}. \
Make sure wasm-pack is installed: cargo install wasm-pack",
s
"wasm-pack build failed with exit code: {s}. \
Make sure wasm-pack is installed: cargo install wasm-pack"
);
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
Expand Down
Loading
Loading