diff --git a/Cargo.lock b/Cargo.lock index d2549d79..a6ecdbc0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -115,7 +115,7 @@ dependencies = [ [[package]] name = "andromeda" -version = "0.1.8" +version = "0.1.9" dependencies = [ "andromeda-core", "andromeda-runtime", @@ -167,7 +167,7 @@ dependencies = [ [[package]] name = "andromeda-core" -version = "0.1.8" +version = "0.1.9" dependencies = [ "anyhow", "anymap", @@ -191,7 +191,7 @@ dependencies = [ [[package]] name = "andromeda-runtime" -version = "0.1.8" +version = "0.1.9" dependencies = [ "andromeda-core", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 9e0f5a2d..12809c00 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ authors = ["the Andromeda team"] edition = "2024" license = "Mozilla Public License 2.0" repository = "https://github.com/tryandromeda/andromeda" -version = "0.1.8" +version = "0.1.9" [workspace.dependencies] andromeda-core = { path = "crates/core" } diff --git a/crates/cli/src/bundle.rs b/crates/cli/src/bundle.rs index b6bdaf4d..cb9f30b8 100644 --- a/crates/cli/src/bundle.rs +++ b/crates/cli/src/bundle.rs @@ -96,8 +96,6 @@ fn should_transform_typescript(input_path: &Path, output_path: &Path) -> bool { .and_then(|s| s.to_str()) .unwrap_or(""); - // Transform TypeScript to JavaScript if: - // - Input is .ts/.tsx and output is .js/.jsx matches!( (input_ext, output_ext), ("ts", "js") | ("tsx", "jsx") | ("ts", "jsx") | ("tsx", "js") diff --git a/crates/cli/src/check.rs b/crates/cli/src/check.rs index 655fc944..6e727f62 100644 --- a/crates/cli/src/check.rs +++ b/crates/cli/src/check.rs @@ -1,50 +1,29 @@ -// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. -// If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. - +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. #![allow(unused_assignments)] use crate::config::AndromedaConfig; -use crate::error::{CliError, CliResult}; +use crate::error::CliResult; use crate::helper::find_formattable_files; use console::Style; use miette as oxc_miette; use miette::{Diagnostic, NamedSource, SourceSpan}; -use owo_colors::OwoColorize; use oxc_allocator::Allocator; use oxc_parser::Parser; use oxc_semantic::SemanticBuilder; use oxc_span::SourceType; +use serde::Serialize; +use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; -/// Type checking error types with rich diagnostic information -#[allow(dead_code)] +/// Type checking error types with rich diagnostic information. #[derive(Diagnostic, Debug, Clone)] +#[non_exhaustive] pub enum TypeCheckError { - /// Type mismatch error - #[diagnostic( - code(andromeda::check::type_mismatch), - help( - "๐Ÿ” Ensure the value matches the expected type.\n๐Ÿ’ก Check variable assignments and function return types.\n๐Ÿ“– Use type assertions (as Type) or type guards if needed." - ), - url("https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#type-annotations") - )] - TypeMismatch { - expected: String, - actual: String, - #[label("Type '{actual}' is not assignable to type '{expected}'")] - span: SourceSpan, - #[source_code] - source_code: NamedSource, - }, - /// Unknown identifier - #[diagnostic( - code(andromeda::check::unknown_identifier), - help( - "๐Ÿ” Check for typos in the identifier name.\n๐Ÿ’ก Ensure the variable is declared before use.\n๐Ÿ“– Import the identifier if it's from another module." - ), - url("https://www.typescriptlang.org/docs/handbook/variable-declarations.html") - )] + /// Unknown identifier (could not be resolved against any scope or ambient declaration) + #[diagnostic(code(andromeda::check::unknown_identifier))] UnknownIdentifier { name: String, #[label("Cannot find name '{name}'")] @@ -52,113 +31,8 @@ pub enum TypeCheckError { #[source_code] source_code: NamedSource, }, - /// Property does not exist on type - #[diagnostic( - code(andromeda::check::property_not_found), - help( - "๐Ÿ” Check the property name for typos.\n๐Ÿ’ก Ensure the object has the expected shape.\n๐Ÿ“– Use optional chaining (?.) if the property might not exist." - ), - url("https://www.typescriptlang.org/docs/handbook/2/objects.html#property-access") - )] - PropertyNotFound { - property: String, - object_type: String, - #[label("Property '{property}' does not exist on type '{object_type}'")] - span: SourceSpan, - #[source_code] - source_code: NamedSource, - }, - /// Function call with incorrect arguments - #[allow(dead_code)] - #[diagnostic( - code(andromeda::check::argument_mismatch), - help( - "๐Ÿ” Check the function signature and provide the correct number of arguments.\n๐Ÿ’ก Use optional parameters (?) or default parameters if applicable.\n๐Ÿ“– Consider function overloads if the function supports multiple call signatures." - ), - url("https://www.typescriptlang.org/docs/handbook/2/functions.html") - )] - ArgumentMismatch { - function_name: String, - expected_args: usize, - actual_args: usize, - #[label( - "Expected {expected_args} arguments for function '{function_name}', but got {actual_args}" - )] - span: SourceSpan, - #[source_code] - source_code: NamedSource, - }, - /// Return type mismatch - #[allow(dead_code)] - #[diagnostic( - code(andromeda::check::return_type_mismatch), - help( - "๐Ÿ” Ensure the return value matches the function's return type.\n๐Ÿ’ก Check all code paths return the expected type.\n๐Ÿ“– Use union types if multiple return types are valid." - ), - url( - "https://www.typescriptlang.org/docs/handbook/2/functions.html#return-type-annotations" - ) - )] - ReturnTypeMismatch { - expected: String, - actual: String, - #[label("Return type '{actual}' is not assignable to expected return type '{expected}'")] - span: SourceSpan, - #[source_code] - source_code: NamedSource, - }, - /// Cannot assign to readonly property - #[diagnostic( - code(andromeda::check::readonly_assignment), - help( - "๐Ÿ” Remove the assignment to the readonly property.\n๐Ÿ’ก Use a different approach like creating a new object.\n๐Ÿ“– Consider if the property should be mutable instead." - ), - url("https://www.typescriptlang.org/docs/handbook/2/objects.html#readonly-properties") - )] - ReadonlyAssignment { - property: String, - #[label("Cannot assign to '{property}' because it is a read-only property")] - span: SourceSpan, - #[source_code] - source_code: NamedSource, - }, - /// Missing type annotation - #[diagnostic( - code(andromeda::check::missing_type_annotation), - help( - "๐Ÿ” Add a type annotation to the identifier.\n๐Ÿ’ก Use type inference when possible, explicit types when needed.\n๐Ÿ“– Consider using const assertions for literal types." - ), - url("https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#type-annotations") - )] - MissingTypeAnnotation { - identifier: String, - #[label("Missing type annotation for '{identifier}'")] - span: SourceSpan, - #[source_code] - source_code: NamedSource, - }, - /// Unreachable code - #[diagnostic( - code(andromeda::check::unreachable_code), - help( - "๐Ÿ” Remove the unreachable code.\n๐Ÿ’ก Check for early returns or conditions that prevent execution.\n๐Ÿ“– Consider restructuring the code flow." - ), - url("https://www.typescriptlang.org/docs/handbook/2/narrowing.html") - )] - UnreachableCode { - #[label("This code is unreachable")] - span: SourceSpan, - #[source_code] - source_code: NamedSource, - }, /// Parse error - #[diagnostic( - code(andromeda::check::parse_error), - help( - "๐Ÿ” Fix the syntax error in the code.\n๐Ÿ’ก Check for missing brackets, semicolons, or incorrect syntax.\n๐Ÿ“– Ensure proper TypeScript/JavaScript syntax." - ), - url("https://www.typescriptlang.org/docs/handbook/intro.html") - )] + #[diagnostic(code(andromeda::check::parse_error))] ParseError { message: String, #[label("Syntax error: {message}")] @@ -166,14 +40,8 @@ pub enum TypeCheckError { #[source_code] source_code: NamedSource, }, - /// Semantic error - #[diagnostic( - code(andromeda::check::semantic_error), - help( - "๐Ÿ” Fix the semantic error in the code.\n๐Ÿ’ก Check variable declarations, scope, and language semantics.\n๐Ÿ“– Ensure proper language usage and structure." - ), - url("https://www.typescriptlang.org/docs/handbook/intro.html") - )] + /// Semantic error (anything from oxc_semantic that isn't a name resolution issue) + #[diagnostic(code(andromeda::check::semantic_error))] SemanticError { message: String, #[label("Semantic error: {message}")] @@ -182,13 +50,7 @@ pub enum TypeCheckError { source_code: NamedSource, }, /// Unused variable - #[diagnostic( - code(andromeda::check::unused_variable), - help( - "๐Ÿ” Remove the unused variable or prefix with underscore (_) to indicate intentional.\n๐Ÿ’ก Consider if the variable is needed for future use.\n๐Ÿ“– Use ESLint's no-unused-vars rule to catch these automatically." - ), - url("https://www.typescriptlang.org/docs/handbook/variable-declarations.html") - )] + #[diagnostic(code(andromeda::check::unused_variable))] UnusedVariable { name: String, #[label("Variable '{name}' is declared but never used")] @@ -198,58 +60,33 @@ pub enum TypeCheckError { }, } +impl TypeCheckError { + /// The stable diagnostic code for serialised output. + pub fn code(&self) -> &'static str { + match self { + TypeCheckError::UnknownIdentifier { .. } => "andromeda::check::unknown_identifier", + TypeCheckError::ParseError { .. } => "andromeda::check::parse_error", + TypeCheckError::SemanticError { .. } => "andromeda::check::semantic_error", + TypeCheckError::UnusedVariable { .. } => "andromeda::check::unused_variable", + } + } + + pub fn span(&self) -> SourceSpan { + match self { + TypeCheckError::UnknownIdentifier { span, .. } => *span, + TypeCheckError::ParseError { span, .. } => *span, + TypeCheckError::SemanticError { span, .. } => *span, + TypeCheckError::UnusedVariable { span, .. } => *span, + } + } +} + impl std::fmt::Display for TypeCheckError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - TypeCheckError::TypeMismatch { - expected, actual, .. - } => { - write!(f, "Type '{actual}' is not assignable to type '{expected}'") - } TypeCheckError::UnknownIdentifier { name, .. } => { write!(f, "Cannot find name '{name}'") } - TypeCheckError::PropertyNotFound { - property, - object_type, - .. - } => { - write!( - f, - "Property '{property}' does not exist on type '{object_type}'" - ) - } - TypeCheckError::ArgumentMismatch { - function_name, - expected_args, - actual_args, - .. - } => { - write!( - f, - "Expected {expected_args} arguments for function '{function_name}', but got {actual_args}" - ) - } - TypeCheckError::ReturnTypeMismatch { - expected, actual, .. - } => { - write!( - f, - "Return type '{actual}' is not assignable to expected return type '{expected}'" - ) - } - TypeCheckError::ReadonlyAssignment { property, .. } => { - write!( - f, - "Cannot assign to '{property}' because it is a read-only property" - ) - } - TypeCheckError::MissingTypeAnnotation { identifier, .. } => { - write!(f, "Missing type annotation for '{identifier}'") - } - TypeCheckError::UnreachableCode { .. } => { - write!(f, "Unreachable code detected") - } TypeCheckError::ParseError { message, .. } => { write!(f, "Parse error: {message}") } @@ -265,16 +102,213 @@ impl std::fmt::Display for TypeCheckError { impl std::error::Error for TypeCheckError {} -/// Type check a single TypeScript file -#[allow(clippy::result_large_err)] -pub fn check_file_with_config( - path: &PathBuf, - config_override: Option, -) -> CliResult> { - let content = - fs::read_to_string(path).map_err(|e| CliError::file_read_error(path.clone(), e))?; +/// JSON-friendly view of a single diagnostic, for `--json` output. +#[derive(Debug, Serialize)] +pub struct JsonDiagnostic { + pub path: String, + pub code: &'static str, + pub message: String, + pub line: usize, + pub column: usize, + pub offset: usize, + pub length: usize, +} + +impl JsonDiagnostic { + fn from_error(path: &Path, source: &str, err: &TypeCheckError) -> Self { + let span = err.span(); + let offset = span.offset(); + let length = span.len(); + let (line, column) = offset_to_line_column(source, offset); + Self { + path: path.display().to_string(), + code: err.code(), + message: err.to_string(), + line, + column, + offset, + length, + } + } +} - check_file_content_with_config(path, &content, config_override) +fn offset_to_line_column(source: &str, offset: usize) -> (usize, usize) { + let mut line = 1usize; + let mut col = 1usize; + let mut byte = 0usize; + for ch in source.chars() { + if byte >= offset { + break; + } + if ch == '\n' { + line += 1; + col = 1; + } else { + col += 1; + } + byte += ch.len_utf8(); + } + (line, col) +} + +/// Names that are always considered globally available, so they should not trigger `UnknownIdentifier` +const BUILTIN_GLOBALS: &[&str] = &[ + "globalThis", + "undefined", + "NaN", + "Infinity", + "this", + "arguments", + "eval", + "Object", + "Function", + "Array", + "String", + "Boolean", + "Number", + "BigInt", + "Symbol", + "Date", + "RegExp", + "Error", + "TypeError", + "RangeError", + "SyntaxError", + "ReferenceError", + "EvalError", + "URIError", + "AggregateError", + "Promise", + "Proxy", + "Reflect", + "JSON", + "Math", + "Map", + "Set", + "WeakMap", + "WeakSet", + "WeakRef", + "FinalizationRegistry", + "Iterator", + "AsyncIterator", + "ArrayBuffer", + "SharedArrayBuffer", + "DataView", + "Atomics", + "Int8Array", + "Uint8Array", + "Uint8ClampedArray", + "Int16Array", + "Uint16Array", + "Int32Array", + "Uint32Array", + "Float16Array", + "Float32Array", + "Float64Array", + "BigInt64Array", + "BigUint64Array", + "parseInt", + "parseFloat", + "isNaN", + "isFinite", + "encodeURI", + "encodeURIComponent", + "decodeURI", + "decodeURIComponent", + "console", + "self", + "performance", + "setTimeout", + "setInterval", + "clearTimeout", + "clearInterval", + "queueMicrotask", + "structuredClone", + "fetch", + "Request", + "Response", + "Headers", + "URL", + "URLPattern", + "URLSearchParams", + "TextEncoder", + "TextEncoderStream", + "TextDecoder", + "TextDecoderStream", + "ReadableStream", + "WritableStream", + "TransformStream", + "ByteLengthQueuingStrategy", + "CountQueuingStrategy", + "Blob", + "File", + "FormData", + "AbortController", + "AbortSignal", + "Event", + "EventTarget", + "CustomEvent", + "MessageEvent", + "ErrorEvent", + "CloseEvent", + "BroadcastChannel", + "MessageChannel", + "MessagePort", + "crypto", + "Crypto", + "SubtleCrypto", + "CryptoKey", + "atob", + "btoa", + "alert", + "confirm", + "prompt", + "localStorage", + "sessionStorage", + "Storage", + "WebSocket", + "Worker", + "DOMException", + "Navigator", + "navigator", + "clientInformation", + "Image", + "ImageData", + "ImageBitmap", + "OffscreenCanvas", + "Path2D", + "CanvasGradient", + "CanvasPattern", + "CanvasRenderingContext2D", + "DOMMatrix", + "DOMMatrixReadOnly", + "TextMetrics", + "createImageBitmap", + "Database", + "DatabaseSync", + "StatementSync", + "sqlite", + "Cron", + "Window", + "createWindow", + "Andromeda", + "__andromeda__", + "assert", + "assertEquals", + "assertNotEquals", + "assertThrows", + "constants", + "exports", +]; + +fn known_globals() -> &'static HashSet<&'static str> { + use std::sync::OnceLock; + static GLOBALS: OnceLock> = OnceLock::new(); + GLOBALS.get_or_init(|| BUILTIN_GLOBALS.iter().copied().collect()) +} + +fn is_known_global(name: &str) -> bool { + known_globals().contains(name) } /// Type check file content directly @@ -291,92 +325,57 @@ pub fn check_file_content_with_config( return Ok(Vec::new()); } + let is_declaration = source_type.is_typescript_definition(); + let ret = Parser::new(&allocator, content, source_type).parse(); let program = &ret.program; - let mut type_errors = Vec::new(); - - if !ret.errors.is_empty() { - for error in &ret.errors { - let source_name = path.display().to_string(); - let named_source = NamedSource::new(source_name, content.to_string()); - - if let Some(labels) = &error.labels - && let Some(label) = labels.first() - { - let span = SourceSpan::new(label.offset().into(), label.len()); - - type_errors.push(TypeCheckError::ParseError { - message: error.to_string(), - span, - source_code: named_source, - }); - } - } - } - - let semantic_ret = SemanticBuilder::new() - .with_check_syntax_error(true) - .with_cfg(true) - .build(program); + let mut type_errors: Vec = Vec::new(); let source_name = path.display().to_string(); let named_source = NamedSource::new(source_name, content.to_string()); - for error in &semantic_ret.errors { + for error in &ret.errors { if let Some(labels) = &error.labels && let Some(label) = labels.first() { let span = SourceSpan::new(label.offset().into(), label.len()); - - let error_message = error.to_string(); - - if error_message.contains("Cannot find name") { - if let Some(name) = extract_identifier_from_error(&error_message) { - type_errors.push(TypeCheckError::UnknownIdentifier { - name, - span, - source_code: named_source.clone(), - }); - } - } else if error_message.contains("Property") && error_message.contains("does not exist") - { - if let (Some(property), Some(object_type)) = - extract_property_error_info(&error_message) - { - type_errors.push(TypeCheckError::PropertyNotFound { - property, - object_type, - span, - source_code: named_source.clone(), - }); - } - } else if error_message.contains("not assignable to") { - if let (Some(actual), Some(expected)) = extract_type_mismatch_info(&error_message) { - type_errors.push(TypeCheckError::TypeMismatch { - expected, - actual, - span, - source_code: named_source.clone(), - }); - } - } else { - type_errors.push(TypeCheckError::SemanticError { - message: error.to_string(), - span, - source_code: named_source.clone(), - }); - } + type_errors.push(TypeCheckError::ParseError { + message: error.to_string(), + span, + source_code: named_source.clone(), + }); } } - let _semantic = &semantic_ret.semantic; - let scoping = _semantic.scoping(); + let semantic_ret = SemanticBuilder::new() + .with_check_syntax_error(true) + .with_cfg(true) + .build(program); + + let semantic = &semantic_ret.semantic; + let scoping = semantic.scoping(); + + let mut unresolved_spans: HashSet<(u32, u32)> = HashSet::new(); for reference_id_list in scoping.root_unresolved_references_ids() { for reference_id in reference_id_list { let reference = scoping.get_reference(reference_id); - let name = _semantic.reference_name(reference); - let ref_span = _semantic.reference_span(reference); + + if reference.flags().is_type_only() { + continue; + } + + let name = semantic.reference_name(reference); + + if is_known_global(name) { + continue; + } + + let ref_span = semantic.reference_span(reference); + let key = (ref_span.start, ref_span.end); + if !unresolved_spans.insert(key) { + continue; + } let span = SourceSpan::new((ref_span.start as usize).into(), ref_span.size() as usize); @@ -388,215 +387,78 @@ pub fn check_file_content_with_config( } } - for symbol_id in scoping.symbol_ids() { - if scoping.symbol_is_unused(symbol_id) { - let name = scoping.symbol_name(symbol_id); - let symbol_span = scoping.symbol_span(symbol_id); + for error in &semantic_ret.errors { + let Some(labels) = &error.labels else { + continue; + }; + let Some(label) = labels.first() else { + continue; + }; - if !name.starts_with('_') { - let span = SourceSpan::new( - (symbol_span.start as usize).into(), - symbol_span.size() as usize, - ); - - type_errors.push(TypeCheckError::UnusedVariable { - name: name.to_string(), - span, - source_code: named_source.clone(), - }); - } + let start = label.offset() as u32; + let end = start + label.len() as u32; + if unresolved_spans.contains(&(start, end)) { + continue; } - } - Ok(type_errors) -} - -/// Helper function to extract identifier name from error message -fn extract_identifier_from_error(error_message: &str) -> Option { - let patterns = [ - "Cannot find name '", - "Identifier '", - "Variable '", - "Function '", - "Property '", - "Type '", - "Interface '", - "Class '", - "Enum '", - "Namespace '", - "Module '", - ]; - - for pattern in &patterns { - if let Some(start_pos) = error_message.find(pattern) { - let content_start = start_pos + pattern.len(); - if let Some(end_pos) = error_message[content_start..].find("'") { - let identifier = &error_message[content_start..content_start + end_pos]; - if !identifier.is_empty() - && identifier - .chars() - .all(|c| c.is_alphanumeric() || c == '_' || c == '$') - { - return Some(identifier.to_string()); - } - } - } + let span = SourceSpan::new(label.offset().into(), label.len()); + type_errors.push(TypeCheckError::SemanticError { + message: error.to_string(), + span, + source_code: named_source.clone(), + }); } - let mut in_quotes = false; - let mut current_identifier = String::new(); - - for ch in error_message.chars() { - if ch == '\'' { - if in_quotes && !current_identifier.is_empty() { - if current_identifier - .chars() - .all(|c| c.is_alphanumeric() || c == '_' || c == '$') - { - return Some(current_identifier); - } - current_identifier.clear(); + if !is_declaration { + for symbol_id in scoping.symbol_ids() { + if !scoping.symbol_is_unused(symbol_id) { + continue; } - in_quotes = !in_quotes; - } else if in_quotes { - current_identifier.push(ch); - } - } - - None -} - -/// Helper function to extract property error information -fn extract_property_error_info(error_message: &str) -> (Option, Option) { - if let Some(prop_start) = error_message.find("Property '") { - let prop_content_start = prop_start + "Property '".len(); - if let Some(prop_end) = error_message[prop_content_start..].find("'") { - let property = - error_message[prop_content_start..prop_content_start + prop_end].to_string(); - - if let Some(type_start) = error_message.find("on type '") { - let type_content_start = type_start + "on type '".len(); - if let Some(type_end) = error_message[type_content_start..].find("'") { - let object_type = error_message - [type_content_start..type_content_start + type_end] - .to_string(); - return (Some(property), Some(object_type)); - } + let name = scoping.symbol_name(symbol_id); + if name.starts_with('_') { + continue; } - - return (Some(property), Some("unknown".to_string())); - } - } - - if error_message.contains("property does not exist on") { - let parts: Vec<&str> = error_message.split('\'').collect(); - if parts.len() >= 4 { - let property = parts[1].to_string(); - let object_type = if parts.len() >= 6 { - parts[3].to_string() - } else { - "unknown".to_string() - }; - return (Some(property), Some(object_type)); + let symbol_span = scoping.symbol_span(symbol_id); + let span = SourceSpan::new( + (symbol_span.start as usize).into(), + symbol_span.size() as usize, + ); + type_errors.push(TypeCheckError::UnusedVariable { + name: name.to_string(), + span, + source_code: named_source.clone(), + }); } } - if let Some(property) = extract_identifier_from_error(error_message) { - return (Some(property), Some("unknown".to_string())); - } - - (None, None) + Ok(type_errors) } -/// Helper function to extract type mismatch information -fn extract_type_mismatch_info(error_message: &str) -> (Option, Option) { - if let Some(actual_start) = error_message.find("Type '") { - let actual_content_start = actual_start + "Type '".len(); - if let Some(actual_end) = error_message[actual_content_start..].find("'") { - let actual_type = - error_message[actual_content_start..actual_content_start + actual_end].to_string(); - - if let Some(expected_start) = error_message.find("assignable to type '") { - let expected_content_start = expected_start + "assignable to type '".len(); - if let Some(expected_end) = error_message[expected_content_start..].find("'") { - let expected_type = error_message - [expected_content_start..expected_content_start + expected_end] - .to_string(); - return (Some(actual_type), Some(expected_type)); - } - } - } - } - - if error_message.contains("is not assignable to") { - let parts: Vec<&str> = error_message.split('\'').collect(); - if parts.len() >= 4 { - let actual_type = parts[1].to_string(); - let expected_type = if parts.len() >= 6 { - parts[3].to_string() - } else { - "unknown".to_string() - }; - return (Some(actual_type), Some(expected_type)); - } - } - - if error_message.contains("Cannot assign") { - let parts: Vec<&str> = error_message.split('\'').collect(); - if parts.len() >= 4 { - let actual_type = parts[1].to_string(); - let expected_type = if parts.len() >= 6 { - parts[3].to_string() - } else { - "unknown".to_string() - }; - return (Some(actual_type), Some(expected_type)); - } - } - - if error_message.contains("Return type") && error_message.contains("not assignable to") { - let parts: Vec<&str> = error_message.split('\'').collect(); - if parts.len() >= 4 { - let actual_type = parts[1].to_string(); - let expected_type = if parts.len() >= 6 { - parts[3].to_string() - } else { - "unknown".to_string() - }; - return (Some(actual_type), Some(expected_type)); - } - } - - let quoted_types: Vec<&str> = error_message - .split('\'') - .enumerate() - .filter_map(|(i, part)| if i % 2 == 1 { Some(part) } else { None }) - .collect(); - - if quoted_types.len() >= 2 { - return ( - Some(quoted_types[0].to_string()), - Some(quoted_types[1].to_string()), - ); - } else if quoted_types.len() == 1 { - return ( - Some(quoted_types[0].to_string()), - Some("unknown".to_string()), - ); - } - - (Some("unknown".to_string()), Some("unknown".to_string())) +/// Output format for `andromeda check`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CheckOutputFormat { + Pretty, + Json, + Quiet, } /// Type check multiple files -#[allow(clippy::result_large_err)] +#[allow(clippy::result_large_err, dead_code)] #[hotpath::measure] pub fn check_files_with_config( paths: &[PathBuf], config_override: Option, +) -> CliResult<()> { + check_files_with_options(paths, config_override, CheckOutputFormat::Pretty) +} + +#[allow(clippy::result_large_err)] +pub fn check_files_with_options( + paths: &[PathBuf], + config_override: Option, + format: CheckOutputFormat, ) -> CliResult<()> { let files_to_check: Vec = if paths.is_empty() { - // If no paths provided, check all TypeScript files in current directory find_formattable_files(&[PathBuf::from(".")]) .unwrap_or_default() .into_iter() @@ -617,60 +479,57 @@ pub fn check_files_with_config( }; if files_to_check.is_empty() { - let warning = Style::new().yellow().bold().apply_to("โš ๏ธ"); - let msg = Style::new() - .yellow() - .apply_to("No TypeScript files found to type-check."); - println!("{warning} {msg}"); + if format == CheckOutputFormat::Pretty { + let warning = Style::new().yellow().apply_to("Warning"); + eprintln!("{warning} No matching files found."); + } return Ok(()); } - let count = Style::new().cyan().apply_to(files_to_check.len()); - println!("Found {count} file(s) to type-check"); - println!("{}", Style::new().dim().apply_to("โ”€".repeat(40))); - - let mut total_errors = 0; - let mut files_with_errors = 0; + let mut total_errors = 0usize; + let mut files_with_read_errors = 0usize; for path in &files_to_check { - match check_file_with_config(path, config_override.clone()) { - Ok(type_errors) => { - if !type_errors.is_empty() { - files_with_errors += 1; - total_errors += type_errors.len(); + if format == CheckOutputFormat::Pretty { + let label = Style::new().green().apply_to("Check"); + eprintln!("{label} {}", path.display()); + } + + let content = match fs::read_to_string(path) { + Ok(c) => c, + Err(e) => { + files_with_read_errors += 1; + if format == CheckOutputFormat::Pretty { + let prefix = Style::new().red().bold().apply_to("error"); + eprintln!("{prefix}: Failed to read {}: {e}", path.display()); } - display_type_check_results(path, &type_errors); + continue; + } + }; + + match check_file_content_with_config(path, &content, config_override.clone()) { + Ok(type_errors) => { + total_errors += type_errors.len(); + emit_results(path, &content, &type_errors, format); } Err(e) => { - let error_icon = Style::new().red().bold().apply_to("โŒ"); - let file = Style::new().cyan().apply_to(path.display()); - println!("{error_icon} {file}: Failed to type-check - {e}"); - files_with_errors += 1; + total_errors += 1; + if format == CheckOutputFormat::Pretty { + let prefix = Style::new().red().bold().apply_to("error"); + eprintln!("{prefix}: Failed to type-check {}: {e}", path.display()); + } } } } - println!(); - if total_errors == 0 { - let success = Style::new().green().bold().apply_to("โœ…"); - let complete_msg = Style::new() - .green() - .bold() - .apply_to("Type checking complete"); - let files_count = Style::new().green().bold().apply_to(files_to_check.len()); - println!("{success} {complete_msg}: All {files_count} files passed type checking!"); - } else { - let warning = Style::new().yellow().bold().apply_to("โš ๏ธ"); - let complete_msg = Style::new() - .yellow() - .bold() - .apply_to("Type checking complete"); - let error_count = Style::new().red().bold().apply_to(total_errors); - let file_count = Style::new().red().bold().apply_to(files_with_errors); - println!("{warning} {complete_msg}: Found {error_count} error(s) in {file_count} file(s)"); - return Err(CliError::runtime_error_simple( - "Type checking completed with errors", - )); + if format == CheckOutputFormat::Pretty && total_errors > 0 { + eprintln!(); + let plural = if total_errors == 1 { "error" } else { "errors" }; + eprintln!("Found {total_errors} {plural}."); + } + + if total_errors > 0 || files_with_read_errors > 0 { + std::process::exit(1); } Ok(()) @@ -679,56 +538,44 @@ pub fn check_files_with_config( /// Maximum number of type errors to display per file before truncating. const MAX_DISPLAY_ERRORS: usize = 20; -/// Display type check results to the console with rich diagnostics -fn display_type_check_results(path: &Path, type_errors: &[TypeCheckError]) { - if !type_errors.is_empty() { - let error_icon = Style::new().red().bold().apply_to("โŒ"); - let file = Style::new().cyan().apply_to(path.display()); - let error_count = Style::new().red().bold().apply_to(type_errors.len()); - let error_text = if type_errors.len() == 1 { - "error" - } else { - "errors" - }; +fn emit_results( + path: &Path, + content: &str, + type_errors: &[TypeCheckError], + format: CheckOutputFormat, +) { + match format { + CheckOutputFormat::Pretty => display_type_check_results(type_errors), + CheckOutputFormat::Json => emit_json_results(path, content, type_errors), + CheckOutputFormat::Quiet => {} + } +} - println!("{error_icon} {file}: {error_count} type {error_text}"); +fn emit_json_results(path: &Path, content: &str, type_errors: &[TypeCheckError]) { + for err in type_errors { + let diag = JsonDiagnostic::from_error(path, content, err); + if let Ok(line) = serde_json::to_string(&diag) { + println!("{line}"); + } + } +} - let errors_to_show = type_errors.len().min(MAX_DISPLAY_ERRORS); +/// Display type check diagnostics. Silent when there are none. +fn display_type_check_results(type_errors: &[TypeCheckError]) { + if type_errors.is_empty() { + return; + } - for (i, error) in type_errors.iter().take(errors_to_show).enumerate() { - if type_errors.len() > 1 { - println!(); - println!( - " {} Issue {} of {}:", - "๐Ÿ“".cyan(), - (i + 1).to_string().bright_cyan(), - type_errors.len().to_string().bright_cyan() - ); - println!(" {}", "โ”€".repeat(25).cyan()); - } - let report = oxc_miette::Report::new(error.clone()); - let report_str = format!("{report:?}"); - for line in report_str.lines() { - println!(" {line}"); - } - } + let errors_to_show = type_errors.len().min(MAX_DISPLAY_ERRORS); - let remaining = type_errors.len() - errors_to_show; - if remaining > 0 { - println!(); - println!( - " {} {} more issue{} not shown", - "โš ๏ธ".bright_yellow(), - remaining.to_string().bright_yellow(), - if remaining == 1 { "" } else { "s" } - ); - } + for error in type_errors.iter().take(errors_to_show) { + let report = oxc_miette::Report::new(error.clone()); + eprintln!("{report:?}"); + } - println!(); - } else { - let ok = Style::new().green().bold().apply_to("โœ…"); - let file = Style::new().cyan().apply_to(path.display()); - let msg = Style::new().white().dim().apply_to("No type errors found."); - println!("{ok} {file}: {msg}"); + let remaining = type_errors.len() - errors_to_show; + if remaining > 0 { + let plural = if remaining == 1 { "" } else { "s" }; + eprintln!("... {remaining} more diagnostic{plural} not shown."); } } diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index 53e72f38..f06a2941 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -297,12 +297,9 @@ impl ConfigManager { pub fn load_or_default(start_dir: Option<&Path>) -> AndromedaConfig { if let Some((config_path, _)) = Self::find_config_file(start_dir) { match Self::load_config(&config_path) { - Ok(config) => { - // println!("๐Ÿ“ Using config file: {}", config_path.display()); - config - } + Ok(config) => config, Err(err) => { - eprintln!("โš ๏ธ Config file error: {err}"); + eprintln!("Config file error: {err}"); eprintln!(" Using default configuration"); AndromedaConfig::default() } @@ -404,7 +401,7 @@ impl ConfigManager { pub fn create_default_config(path: &Path, format: ConfigFormat) -> CliResult<()> { let config = AndromedaConfig::default(); Self::save_config(&config, path, format)?; - println!("โœ… Created default config file: {}", path.display()); + println!("Created default config file: {}", path.display()); Ok(()) } diff --git a/crates/cli/src/error.rs b/crates/cli/src/error.rs index 272a0aad..7dcc8ecf 100644 --- a/crates/cli/src/error.rs +++ b/crates/cli/src/error.rs @@ -18,11 +18,10 @@ use thiserror::Error; #[derive(Error, Diagnostic, Debug)] #[allow(clippy::result_large_err)] pub enum CliError { - #[error("๐Ÿ“ File not found: {}", path.display())] + #[error("File not found: {}", path.display())] #[diagnostic( code(andromeda::cli::file_not_found), - help("๐Ÿ’ก Make sure the file exists and you have permission to read it"), - url("https://doc.rust-lang.org/std/fs/fn.read_to_string.html") + help("Make sure the file exists and you have permission to read it.") )] FileNotFound { path: PathBuf, @@ -30,10 +29,10 @@ pub enum CliError { source: std::io::Error, }, - #[error("๐Ÿ“„ File reading error: {}", path.display())] + #[error("Failed to read file: {}", path.display())] #[diagnostic( code(andromeda::cli::file_read_error), - help("๐Ÿ’ก Check file permissions and ensure the file is not corrupted") + help("Check file permissions and ensure the file is not corrupted.") )] FileReadError { path: PathBuf, @@ -41,11 +40,10 @@ pub enum CliError { source: std::io::Error, }, - #[error("๐Ÿšจ JavaScript/TypeScript parsing failed in {file_path}")] + #[error("Parsing failed in {file_path}")] #[diagnostic( code(andromeda::cli::parse_error), - help("๐Ÿ’ก Syntax errors detected. Check for missing semicolons, brackets, or quotes."), - url("https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Grammar_and_types") + help("Check for missing semicolons, brackets, or quotes.") )] ParseError { diagnostics: Vec, @@ -55,12 +53,8 @@ pub enum CliError { error_spans: Vec, }, - #[error("โšก JavaScript runtime error: {message}")] - #[diagnostic( - code(andromeda::cli::runtime_error), - help("๐Ÿ’ก Check the stack trace for the exact error location and cause"), - url("https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors") - )] + #[error("{message}")] + #[diagnostic(code(andromeda::cli::runtime_error))] RuntimeError { message: String, file_path: Option, @@ -68,14 +62,14 @@ pub enum CliError { column: Option, #[source_code] source_code: Option>, - #[label("โš ๏ธ Error occurred here")] + #[label("error occurred here")] error_span: Option, }, #[error("Compilation failed: {reason}")] #[diagnostic( code(andromeda::cli::compile_error), - help("Try checking if you have write permissions for the output directory") + help("Check that you have write permissions for the output directory.") )] CompileError { reason: String, diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 65d8595f..9fc2896b 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -28,7 +28,7 @@ use helper::{find_formattable_files_for_format, find_formattable_files_for_lint} mod lint; use lint::lint_file_with_config; mod check; -use check::check_files_with_config; +use check::{CheckOutputFormat, check_files_with_options}; mod config; mod lsp; mod task; @@ -153,6 +153,17 @@ enum Command { /// The file(s) or directory(ies) to type-check #[arg(required = false)] paths: Vec, + + /// Emit one JSON object per diagnostic (ndjson) on stdout. Progress and + /// summary lines are still written to stderr. Mutually exclusive with + /// --quiet. + #[arg(long, conflicts_with = "quiet")] + json: bool, + + /// Suppress all output; rely on the exit code to signal status. Mutually + /// exclusive with --json. + #[arg(long)] + quiet: bool, }, /// Start Language Server Protocol (LSP) server @@ -470,11 +481,18 @@ fn run_main() -> CliResult<()> { Ok(()) } } - Command::Check { paths } => { - // Load configuration + Command::Check { paths, json, quiet } => { let config = ConfigManager::load_or_default(None); - check_files_with_config(&paths, Some(config)) + let format = if json { + CheckOutputFormat::Json + } else if quiet { + CheckOutputFormat::Quiet + } else { + CheckOutputFormat::Pretty + }; + + check_files_with_options(&paths, Some(config), format) } Command::Lsp => { run_lsp_server().map_err(|e| { diff --git a/crates/core/src/error.rs b/crates/core/src/error.rs index 4f61527b..7c189cd0 100644 --- a/crates/core/src/error.rs +++ b/crates/core/src/error.rs @@ -16,15 +16,14 @@ pub enum RuntimeError { #[diagnostic( code(andromeda::fs::io_error), help( - "๐Ÿ” Check that the file exists and you have proper permissions.\n๐Ÿ’ก Try running with elevated permissions if needed.\n๐Ÿ“‚ Verify the path is correct and accessible." - ), - url("https://doc.rust-lang.org/std/fs/index.html") + "Check that the file exists, the path is correct, and you have permission to access it." + ) )] FsError { operation: String, path: String, error_message: String, - #[label("๐Ÿ“ File operation failed here")] + #[label("file operation failed here")] error_location: Option, #[source_code] source_code: Option>, @@ -33,17 +32,14 @@ pub enum RuntimeError { /// Parse errors from JavaScript/TypeScript source #[diagnostic( code(andromeda::parse::syntax_error), - help( - "๐Ÿ” Check the syntax of your JavaScript/TypeScript code.\n๐Ÿ’ก Look for missing semicolons, brackets, or quotes.\n๐Ÿ“– Refer to the JavaScript/TypeScript language specification." - ), - url("https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Grammar_and_types") + help("Check for missing semicolons, brackets, or quotes.") )] ParseError { errors: Vec, source_path: String, #[source_code] source_code: NamedSource, - #[label("โŒ Parse error occurred here")] + #[label("parse error occurred here")] primary_error_span: Option, related_spans: Vec, }, @@ -52,13 +48,12 @@ pub enum RuntimeError { #[diagnostic( code(andromeda::runtime::execution_error), help( - "๐Ÿ” Check the runtime state and ensure all required resources are available.\n๐Ÿ’ก Verify that all variables are properly initialized.\n๐Ÿ› Review the call stack for the error source." - ), - url("https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors") + "Verify that all variables are initialized and review the call stack for the error source." + ) )] RuntimeError { message: String, - #[label("โšก Runtime error occurred here")] + #[label("runtime error occurred here")] location: Option, #[source_code] source_code: Option>, @@ -73,14 +68,13 @@ pub enum RuntimeError { #[diagnostic( code(andromeda::extension::load_error), help( - "๐Ÿ” Ensure the extension is properly configured and dependencies are available.\n๐Ÿ’ก Check that the extension exports are correctly defined.\n๐Ÿ“ฆ Verify all required dependencies are installed." - ), - url("https://docs.andromeda.dev/extensions") + "Check that the extension is configured correctly and all required dependencies are installed." + ) )] ExtensionError { extension_name: String, message: String, - #[label("๐Ÿงฉ Extension error occurred here")] + #[label("extension error occurred here")] error_location: Option, #[source_code] extension_source: Option>, @@ -91,16 +85,13 @@ pub enum RuntimeError { /// Resource management errors #[diagnostic( code(andromeda::resource::invalid_rid), - help( - "๐Ÿ” Ensure the resource ID is valid and the resource hasn't been closed.\n๐Ÿ’ก Check for race conditions in resource cleanup.\n๐Ÿ”’ Verify resource lifecycle management." - ), - url("https://docs.andromeda.dev/resources") + help("Ensure the resource ID is valid and the resource has not been closed.") )] ResourceError { rid: u32, operation: String, message: String, - #[label("๐Ÿ—‚๏ธ Resource operation failed here")] + #[label("resource operation failed here")] error_location: Option, #[source_code] source_code: Option>, @@ -111,15 +102,12 @@ pub enum RuntimeError { /// Task management errors #[diagnostic( code(andromeda::task::task_error), - help( - "๐Ÿ” Check task state and ensure proper cleanup.\n๐Ÿ’ก Verify async task handling and synchronization.\nโฑ๏ธ Check for deadlocks or infinite loops." - ), - url("https://docs.andromeda.dev/tasks") + help("Check task state and async synchronization for deadlocks or improper cleanup.") )] TaskError { task_id: u32, message: String, - #[label("โš™๏ธ Task error occurred here")] + #[label("task error occurred here")] error_location: Option, #[source_code] source_code: Option>, @@ -130,16 +118,13 @@ pub enum RuntimeError { /// Network/HTTP errors #[diagnostic( code(andromeda::network::request_error), - help( - "๐Ÿ” Check network connectivity and request parameters.\n๐Ÿ’ก Verify the URL format and endpoint availability.\n๐ŸŒ Check firewall and proxy settings." - ), - url("https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API") + help("Check the URL format, network connectivity, and any proxy or firewall settings.") )] NetworkError { url: String, operation: String, error_message: String, - #[label("๐ŸŒ Network error occurred here")] + #[label("network error occurred here")] error_location: Option, #[source_code] source_code: Option>, @@ -152,15 +137,12 @@ pub enum RuntimeError { /// Encoding/Decoding errors #[diagnostic( code(andromeda::encoding::decode_error), - help( - "๐Ÿ” Ensure the input data is properly formatted.\n๐Ÿ’ก Check the encoding format and character set.\n๐Ÿ“„ Verify data integrity and completeness." - ), - url("https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder") + help("Verify the input data is well-formed and matches the expected encoding.") )] EncodingError { format: String, message: String, - #[label("๐Ÿ”ค Encoding error occurred here")] + #[label("encoding error occurred here")] error_location: Option, #[source_code] source_code: Option>, @@ -172,15 +154,12 @@ pub enum RuntimeError { /// Configuration errors #[diagnostic( code(andromeda::config::invalid_config), - help( - "๐Ÿ” Check the configuration file format and required fields.\n๐Ÿ’ก Verify JSON/TOML syntax and field types.\n๐Ÿ“‹ Ensure all required configuration options are present." - ), - url("https://docs.andromeda.dev/configuration") + help("Verify the configuration file syntax and that all required fields are present.") )] ConfigError { field: String, message: String, - #[label("โš™๏ธ Configuration error occurred here")] + #[label("configuration error occurred here")] error_location: Option, #[source_code] config_source: Option>, @@ -194,15 +173,14 @@ pub enum RuntimeError { #[diagnostic( code(andromeda::types::mismatch), help( - "๐Ÿ” Check the types of your variables and function parameters.\n๐Ÿ’ก Ensure type compatibility between operations.\n๐Ÿ“ Consider explicit type conversions if needed." - ), - url("https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures") + "Check the types of variables and function parameters; consider an explicit conversion." + ) )] TypeError { message: String, expected_type: String, actual_type: String, - #[label("โŒ Type error occurred here")] + #[label("type error occurred here")] error_location: Option, #[source_code] source_code: Option>, @@ -214,15 +192,12 @@ pub enum RuntimeError { /// Memory and performance related errors #[diagnostic( code(andromeda::memory::allocation_error), - help( - "๐Ÿ” Check memory usage and allocation patterns.\n๐Ÿ’ก Consider reducing memory footprint or optimizing algorithms.\n๐Ÿ“Š Monitor for memory leaks and excessive allocations." - ), - url("https://docs.andromeda.dev/performance") + help("Reduce memory footprint or investigate possible leaks and excessive allocations.") )] MemoryError { message: String, operation: String, - #[label("๐Ÿ’พ Memory error occurred here")] + #[label("memory error occurred here")] error_location: Option, #[source_code] source_code: Option>, @@ -234,14 +209,11 @@ pub enum RuntimeError { /// Module not found error #[diagnostic( code(andromeda::module::not_found), - help( - "๐Ÿ” Check that the module path is correct and the file exists.\n๐Ÿ’ก Verify import specifiers match actual file names.\n๐Ÿ“ฆ Ensure dependencies are installed." - ), - url("https://docs.andromeda.dev/modules") + help("Check that the module path is correct and the file exists.") )] ModuleNotFound { specifier: String, - #[label("๐Ÿ“ฆ Module not found")] + #[label("module not found")] error_location: Option, #[source_code] source_code: Option>, @@ -252,15 +224,12 @@ pub enum RuntimeError { /// Module parse error #[diagnostic( code(andromeda::module::parse_error), - help( - "๐Ÿ” Check the syntax of the module source code.\n๐Ÿ’ก Look for missing semicolons, brackets, or quotes.\n๐Ÿ“– Ensure the file is valid JavaScript/TypeScript." - ), - url("https://docs.andromeda.dev/modules") + help("Check the syntax of the module source code.") )] ModuleParseError { path: String, message: String, - #[label("โŒ Module parse error")] + #[label("module parse error")] error_location: Option, #[source_code] source_code: Option>, @@ -269,16 +238,13 @@ pub enum RuntimeError { /// Module resolution error #[diagnostic( code(andromeda::module::resolution_error), - help( - "๐Ÿ” Check import paths and module specifiers.\n๐Ÿ’ก Verify relative paths are correct.\n๐Ÿ“‚ Ensure the module resolution algorithm can find the file." - ), - url("https://docs.andromeda.dev/modules#resolution") + help("Check import paths and verify the resolver can find the file.") )] ModuleResolutionError { message: String, specifier: Option, referrer: Option, - #[label("๐Ÿ” Resolution failed here")] + #[label("resolution failed here")] error_location: Option, #[source_code] source_code: Option>, @@ -287,15 +253,12 @@ pub enum RuntimeError { /// Module runtime error #[diagnostic( code(andromeda::module::runtime_error), - help( - "๐Ÿ” Check the module's execution logic.\n๐Ÿ’ก Verify all imports are correctly resolved.\n๐Ÿ› Review the module's dependencies." - ), - url("https://docs.andromeda.dev/modules#runtime") + help("Review the module's execution logic and verify all imports resolve correctly.") )] ModuleRuntimeError { path: String, message: String, - #[label("โšก Module runtime error")] + #[label("module runtime error")] error_location: Option, #[source_code] source_code: Option>, @@ -304,14 +267,11 @@ pub enum RuntimeError { /// Circular import detected #[diagnostic( code(andromeda::module::circular_import), - help( - "๐Ÿ” Review the import chain to find the cycle.\n๐Ÿ’ก Consider restructuring your modules to avoid circular dependencies.\n๐Ÿ“ฆ Use dynamic imports or dependency injection to break cycles." - ), - url("https://docs.andromeda.dev/modules#circular-imports") + help("Restructure modules to break the cycle, or use dynamic imports.") )] CircularImport { cycle: String, - #[label("๐Ÿ”„ Circular import detected")] + #[label("circular import detected")] error_location: Option, #[source_code] source_code: Option>, @@ -323,14 +283,13 @@ pub enum RuntimeError { #[diagnostic( code(andromeda::module::import_not_found), help( - "๐Ÿ” Check that the export exists in the source module.\n๐Ÿ’ก Verify the export name matches exactly (case-sensitive).\n๐Ÿ“ฆ Ensure the module exports what you're trying to import." - ), - url("https://docs.andromeda.dev/modules#exports") + "Verify the export name matches exactly (case-sensitive) and is exported by the module." + ) )] ImportNotFound { import: String, module: String, - #[label("โ“ Import not found")] + #[label("import not found")] error_location: Option, #[source_code] source_code: Option>, @@ -341,15 +300,12 @@ pub enum RuntimeError { /// Ambiguous export in module #[diagnostic( code(andromeda::module::ambiguous_export), - help( - "๐Ÿ” The export name is defined multiple times.\n๐Ÿ’ก Use explicit re-exports to resolve ambiguity.\n๐Ÿ“ฆ Check for conflicting star exports." - ), - url("https://docs.andromeda.dev/modules#exports") + help("Use explicit re-exports to disambiguate, and check for conflicting star exports.") )] AmbiguousExport { export: String, module: String, - #[label("โš ๏ธ Ambiguous export")] + #[label("ambiguous export")] error_location: Option, #[source_code] source_code: Option>, @@ -360,14 +316,11 @@ pub enum RuntimeError { /// Module already loaded #[diagnostic( code(andromeda::module::already_loaded), - help( - "๐Ÿ” The module has already been loaded.\n๐Ÿ’ก This may indicate a configuration issue.\n๐Ÿ“ฆ Check for duplicate module registrations." - ), - url("https://docs.andromeda.dev/modules#caching") + help("Check for duplicate module registrations.") )] ModuleAlreadyLoaded { specifier: String, - #[label("๐Ÿ“ฆ Module already loaded")] + #[label("module already loaded")] error_location: Option, #[source_code] source_code: Option>, @@ -376,15 +329,12 @@ pub enum RuntimeError { /// Invalid module specifier #[diagnostic( code(andromeda::module::invalid_specifier), - help( - "๐Ÿ” Check the module specifier format.\n๐Ÿ’ก Use relative paths (./), absolute paths (/), or bare specifiers.\n๐Ÿ“ฆ Ensure URLs are properly formatted." - ), - url("https://docs.andromeda.dev/modules#specifiers") + help("Use a relative path (./), absolute path (/), bare specifier, or a well-formed URL.") )] InvalidModuleSpecifier { specifier: String, reason: Option, - #[label("โŒ Invalid module specifier")] + #[label("invalid module specifier")] error_location: Option, #[source_code] source_code: Option>, @@ -393,15 +343,12 @@ pub enum RuntimeError { /// Module I/O error #[diagnostic( code(andromeda::module::io_error), - help( - "๐Ÿ” Check file permissions and disk space.\n๐Ÿ’ก Verify the file is not locked by another process.\n๐Ÿ“‚ Ensure the path is accessible." - ), - url("https://docs.andromeda.dev/modules#loading") + help("Check file permissions, disk space, and that the path is accessible.") )] ModuleIoError { message: String, path: Option, - #[label("๐Ÿ“ I/O error")] + #[label("I/O error")] error_location: Option, #[source_code] source_code: Option>, @@ -410,25 +357,19 @@ pub enum RuntimeError { /// LLM provider not initialized #[diagnostic( code(andromeda::llm::not_initialized), - help( - "๐Ÿ” Initialize the LLM provider before requesting suggestions.\n๐Ÿ’ก Call init_llm_provider() or init_copilot_provider() first.\n๐Ÿ”ง Check that the llm feature is enabled." - ), - url("https://docs.andromeda.dev/llm-suggestions") + help("Call init_llm_provider() or init_copilot_provider() before requesting suggestions.") )] LlmNotInitialized, /// LLM provider error #[diagnostic( code(andromeda::llm::provider_error), - help( - "๐Ÿ” Check the LLM provider configuration.\n๐Ÿ’ก Verify API keys and credentials are valid.\n๐ŸŒ Ensure network connectivity to the LLM service." - ), - url("https://docs.andromeda.dev/llm-suggestions") + help("Verify the LLM provider configuration, API keys, and network connectivity.") )] LlmProviderError { message: String, provider_name: Option, - #[label("๐Ÿค– LLM provider error")] + #[label("LLM provider error")] error_location: Option, #[source_code] source_code: Option>, @@ -437,14 +378,11 @@ pub enum RuntimeError { /// LLM request timeout #[diagnostic( code(andromeda::llm::timeout), - help( - "๐Ÿ” The LLM request took too long.\n๐Ÿ’ก Try increasing the timeout duration.\n๐ŸŒ Check network latency to the LLM service." - ), - url("https://docs.andromeda.dev/llm-suggestions#configuration") + help("Increase the timeout duration or check network latency to the LLM service.") )] LlmTimeout { timeout_ms: u64, - #[label("โฑ๏ธ Request timed out")] + #[label("request timed out")] error_location: Option, #[source_code] source_code: Option>, @@ -453,10 +391,7 @@ pub enum RuntimeError { /// LLM suggestions disabled #[diagnostic( code(andromeda::llm::disabled), - help( - "๐Ÿ” LLM suggestions are currently disabled.\n๐Ÿ’ก Enable them in the configuration.\n๐Ÿ”ง Set enabled: true in LlmSuggestionConfig." - ), - url("https://docs.andromeda.dev/llm-suggestions#configuration") + help("Set enabled: true in LlmSuggestionConfig to turn on LLM suggestions.") )] LlmDisabled, @@ -464,13 +399,12 @@ pub enum RuntimeError { #[diagnostic( code(andromeda::llm::auth_error), help( - "๐Ÿ” Check your authentication credentials.\n๐Ÿ’ก Verify API keys or tokens are valid and not expired.\n๐Ÿ”‘ Ensure GITHUB_TOKEN is set for Copilot integration." - ), - url("https://docs.andromeda.dev/llm-suggestions#authentication") + "Verify API keys or tokens are valid; ensure GITHUB_TOKEN is set for Copilot integration." + ) )] LlmAuthenticationError { message: String, - #[label("๐Ÿ”‘ Authentication failed")] + #[label("authentication failed")] error_location: Option, #[source_code] source_code: Option>, @@ -479,14 +413,11 @@ pub enum RuntimeError { /// LLM network error #[diagnostic( code(andromeda::llm::network_error), - help( - "๐Ÿ” Check network connectivity.\n๐Ÿ’ก Verify firewall and proxy settings.\n๐ŸŒ Ensure the LLM service endpoint is accessible." - ), - url("https://docs.andromeda.dev/llm-suggestions#troubleshooting") + help("Check network connectivity, firewall and proxy settings, and the LLM endpoint URL.") )] LlmNetworkError { message: String, - #[label("๐ŸŒ Network error")] + #[label("network error")] error_location: Option, #[source_code] source_code: Option>, @@ -496,14 +427,13 @@ pub enum RuntimeError { #[diagnostic( code(andromeda::window::error), help( - "๐Ÿ” Check that the window feature is enabled and the window hasn't been closed.\n๐Ÿ’ก Verify the platform supports native windowing.\n๐ŸชŸ Ensure window operations run on the main thread." - ), - url("https://docs.andromeda.dev/window") + "Verify the window feature is enabled, the platform supports native windowing, and operations run on the main thread." + ) )] WindowError { operation: String, message: String, - #[label("๐ŸชŸ Window operation failed here")] + #[label("window operation failed here")] error_location: Option, #[source_code] source_code: Option>, @@ -513,13 +443,13 @@ pub enum RuntimeError { #[diagnostic( code(andromeda::internal::error), help( - "๐Ÿ” This is an internal error that should not occur.\n๐Ÿ’ก Please report this issue on GitHub.\n๐Ÿ› Include the error message and reproduction steps." + "This is an internal error. Please report it on GitHub with the error message and reproduction steps." ), url("https://github.com/aspect-build/andromeda/issues") )] InternalError { message: String, - #[label("๐Ÿ› Internal error")] + #[label("internal error")] error_location: Option, #[source_code] source_code: Option>, @@ -1402,41 +1332,19 @@ impl ErrorReporter { /// Print a formatted error with full miette reporting pub fn print_error(error: &RuntimeError) { - eprintln!(); - eprintln!( - "{} {}", - "๐Ÿšจ".red().bold(), - "Andromeda Runtime Error".bright_red().bold() - ); - eprintln!("{}", "โ”€".repeat(50).red()); eprintln!("{:?}", oxc_miette::Report::new(error.clone())); } /// Print multiple errors with enhanced formatting pub fn print_errors(errors: &[RuntimeError]) { - eprintln!(); - eprintln!( - "{} {} ({} error{})", - "๐Ÿšจ".red().bold(), - "Andromeda Runtime Errors".bright_red().bold(), - errors.len(), - if errors.len() == 1 { "" } else { "s" } - ); - eprintln!("{}", "โ”€".repeat(50).red()); - - for (i, error) in errors.iter().enumerate() { - if errors.len() > 1 { - eprintln!(); - eprintln!( - "{} Error {} of {}:", - "๐Ÿ“".cyan(), - (i + 1).to_string().bright_cyan(), - errors.len().to_string().bright_cyan() - ); - eprintln!("{}", "โ”€".repeat(30).cyan()); - } + for error in errors { eprintln!("{:?}", oxc_miette::Report::new(error.clone())); } + if errors.len() > 1 { + eprintln!(); + let plural = if errors.len() == 1 { "" } else { "s" }; + eprintln!("Found {} error{}.", errors.len(), plural); + } } /// Create a formatted error report as string with full context @@ -1450,18 +1358,12 @@ impl ErrorReporter { pub fn create_detailed_report(error: &RuntimeError) -> String { let mut report = String::new(); - // Header with emoji and color - report.push_str(&format!("{} Andromeda Error Report\n", "๐Ÿ”".bright_blue())); - report.push_str(&format!("{}\n", "โ•".repeat(60).blue())); - - // Main error display report.push_str(&format!("{:?}\n", oxc_miette::Report::new(error.clone()))); - // Additional context based on error type match error { RuntimeError::ParseError { errors, .. } => { - report.push_str(&format!("\n{} Parse Details:\n", "๐Ÿ“".bright_yellow())); - report.push_str(&format!("Total errors found: {}\n", errors.len())); + report.push_str("\nParse details:\n"); + report.push_str(&format!(" Total errors: {}\n", errors.len())); for (i, err) in errors.iter().enumerate() { report.push_str(&format!(" {}. {}\n", i + 1, err)); } @@ -1472,12 +1374,12 @@ impl ErrorReporter { .. } => { if !stack.is_empty() { - report.push_str(&format!("\n{} Stack Trace:\n", "๐Ÿ“š".bright_magenta())); + report.push_str("\nStack trace:\n"); report.push_str(stack); report.push('\n'); } if !variable_context.is_empty() { - report.push_str(&format!("\n{} Variable Context:\n", "๐Ÿ”".bright_cyan())); + report.push_str("\nVariable context:\n"); for (name, value) in variable_context { report.push_str(&format!( " {} = {}\n", @@ -1492,12 +1394,16 @@ impl ErrorReporter { request_headers, .. } => { - report.push_str(&format!("\n{} Network Details:\n", "๐ŸŒ".bright_green())); - report.push_str(&format!("Status Code: {code}\n")); + report.push_str("\nNetwork details:\n"); + report.push_str(&format!(" Status code: {code}\n")); if !request_headers.is_empty() { - report.push_str("Request Headers:\n"); + report.push_str(" Request headers:\n"); for (key, value) in request_headers { - report.push_str(&format!(" {}: {}\n", key.bright_white(), value.dimmed())); + report.push_str(&format!( + " {}: {}\n", + key.bright_white(), + value.dimmed() + )); } } } @@ -1506,36 +1412,35 @@ impl ErrorReporter { heap_info: Some(heap), .. } => { - report.push_str(&format!("\n{} Memory Information:\n", "๐Ÿ’พ".bright_red())); - report.push_str(&format!("Memory Stats: {stats}\n")); - report.push_str(&format!("Heap Info: {heap}\n")); + report.push_str("\nMemory information:\n"); + report.push_str(&format!(" Memory stats: {stats}\n")); + report.push_str(&format!(" Heap info: {heap}\n")); } RuntimeError::CircularImport { involved_modules, .. } if !involved_modules.is_empty() => { - report.push_str(&format!("\n{} Involved Modules:\n", "๐Ÿ”„".bright_yellow())); + report.push_str("\nInvolved modules:\n"); for module in involved_modules { - report.push_str(&format!(" โ€ข {}\n", module)); + report.push_str(&format!(" - {}\n", module)); } } RuntimeError::ImportNotFound { available_exports, .. } if !available_exports.is_empty() => { - report.push_str(&format!("\n{} Available Exports:\n", "๐Ÿ“ฆ".bright_green())); + report.push_str("\nAvailable exports:\n"); for export in available_exports { - report.push_str(&format!(" โ€ข {}\n", export)); + report.push_str(&format!(" - {}\n", export)); } } RuntimeError::ModuleNotFound { suggestions, .. } if !suggestions.is_empty() => { - report.push_str(&format!("\n{} Did you mean:\n", "๐Ÿ’ก".bright_yellow())); + report.push_str("\nDid you mean:\n"); for suggestion in suggestions { - report.push_str(&format!(" โ€ข {}\n", suggestion)); + report.push_str(&format!(" - {}\n", suggestion)); } } _ => {} } - report.push_str(&format!("\n{}\n", "โ•".repeat(60).blue())); report } } diff --git a/crates/core/src/error_reporting.rs b/crates/core/src/error_reporting.rs index d575481f..55c87703 100644 --- a/crates/core/src/error_reporting.rs +++ b/crates/core/src/error_reporting.rs @@ -3,6 +3,7 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. use miette::{MietteHandlerOpts, Report}; +#[cfg(feature = "llm")] use owo_colors::OwoColorize; use std::sync::Once; @@ -111,13 +112,6 @@ pub fn print_error(error: E) where E: std::error::Error + miette::Diagnostic + Send + Sync + 'static, { - eprintln!(); - eprintln!( - "{} {}", - "๐Ÿšจ".red().bold(), - "Andromeda Error".bright_red().bold() - ); - eprintln!("{}", "โ”€".repeat(50).red()); eprintln!("{:?}", Report::new(error)); } @@ -153,18 +147,10 @@ where { use crate::llm_suggestions::{ErrorContext, get_error_suggestion, is_llm_initialized}; - eprintln!(); - eprintln!( - "{} {}", - "๐Ÿšจ".red().bold(), - "Andromeda Error".bright_red().bold() - ); - eprintln!("{}", "โ”€".repeat(50).red()); eprintln!("{:?}", Report::new(error)); - // Try to get LLM suggestion if available if is_llm_initialized() { - let error_message = format!("{}", std::io::Error::other("placeholder")); // We'll use the actual error + let error_message = format!("{}", std::io::Error::other("placeholder")); let mut context = ErrorContext::new(&error_message); if let Some(source) = source_code { @@ -178,12 +164,10 @@ where if let Some(suggestion) = get_error_suggestion(&context) { eprintln!(); eprintln!( - "{} {} {}", - "๐Ÿ’ก".bright_yellow(), - "AI Suggestion".bright_yellow().bold(), + "{} {}", + "AI suggestion".yellow().bold(), format!("(via {})", suggestion.provider_name).dimmed() ); - eprintln!("{}", "โ”€".repeat(50).yellow()); eprintln!("{}", suggestion.suggestion); } } @@ -208,27 +192,17 @@ pub fn print_error_with_context(error: E, context: &crate::llm_suggestions::E where E: std::error::Error + miette::Diagnostic + Send + Sync + 'static, { - eprintln!(); - eprintln!( - "{} {}", - "๐Ÿšจ".red().bold(), - "Andromeda Error".bright_red().bold() - ); - eprintln!("{}", "โ”€".repeat(50).red()); eprintln!("{:?}", Report::new(error)); - // Try to get LLM suggestion if available if crate::llm_suggestions::is_llm_initialized() && let Some(suggestion) = crate::llm_suggestions::get_error_suggestion(context) { eprintln!(); eprintln!( - "{} {} {}", - "๐Ÿ’ก".bright_yellow(), - "AI Suggestion".bright_yellow().bold(), + "{} {}", + "AI suggestion".yellow().bold(), format!("(via {})", suggestion.provider_name).dimmed() ); - eprintln!("{}", "โ”€".repeat(50).yellow()); eprintln!("{}", suggestion.suggestion); } } @@ -290,10 +264,8 @@ where } if let Some(suggestion) = get_error_suggestion(&context) { - output.push_str("\n\n๐Ÿ’ก AI Suggestion "); + output.push_str("\n\nAI suggestion "); output.push_str(&format!("(via {}):\n", suggestion.provider_name)); - output.push_str(&"โ”€".repeat(50)); - output.push('\n'); output.push_str(&suggestion.suggestion); } } @@ -338,27 +310,12 @@ where return; } - eprintln!(); - eprintln!( - "{} {} ({} error{})", - "๐Ÿšจ".red().bold(), - "Andromeda Errors".bright_red().bold(), - errors.len(), - if errors.len() == 1 { "" } else { "s" } - ); - eprintln!("{}", "โ”€".repeat(50).red()); - - for (i, error) in errors.iter().enumerate() { - if errors.len() > 1 { - eprintln!(); - eprintln!( - "{} Error {} of {}:", - "๐Ÿ“".cyan(), - (i + 1).to_string().bright_cyan(), - errors.len().to_string().bright_cyan() - ); - eprintln!("{}", "โ”€".repeat(30).cyan()); - } + for error in errors { eprintln!("{:?}", Report::new(error.clone())); } + + if errors.len() > 1 { + eprintln!(); + eprintln!("Found {} errors.", errors.len()); + } } diff --git a/crates/runtime/src/ext/canvas/context2d.rs b/crates/runtime/src/ext/canvas/context2d.rs index 1f27a11d..0e1d32b9 100644 --- a/crates/runtime/src/ext/canvas/context2d.rs +++ b/crates/runtime/src/ext/canvas/context2d.rs @@ -557,9 +557,6 @@ pub fn internal_canvas_close_path<'gc>( let res: &mut CanvasResources = storage.get_mut().unwrap(); let mut data = res.canvases.get_mut(rid).unwrap(); - // Close the CURRENT subpath by appending a copy of its first point. - // The GPU render path reads data.current_path directly, so without this - // the closing edge of a stroked polygon is never drawn. let subpath_start = data.subpath_starts.last().copied().unwrap_or(0); if subpath_start < data.current_path.len() { let first = data.current_path[subpath_start].clone(); @@ -613,8 +610,6 @@ pub fn internal_canvas_fill_rect<'gc>( }; if has_renderer { - // Convert Nova VM Number to f64 for GPU renderer - // Use into_f64() method that Nova VM provides let x_val = x.into_f64(agent); let y_val = y.into_f64(agent); let width_val = width.into_f64(agent); @@ -694,11 +689,6 @@ pub fn internal_canvas_move_to<'gc>( let y_f64 = y.into_f64(agent); // Start a new subpath in the current path. - // - // Canvas 2D paths can contain multiple disconnected subpaths. Record - // the index at which this subpath begins so `fill`/`stroke` can render - // each subpath independently instead of drawing one giant polygon that - // zig-zags between subpath endpoints. let start_idx = data.current_path.len(); data.subpath_starts.push(start_idx); data.current_path @@ -920,16 +910,10 @@ pub fn internal_canvas_rect<'gc>( let width_f64 = width.into_f64(agent); let height_f64 = height.into_f64(agent); - // Per the Canvas 2D spec, `rect` creates a new implicit subpath. - // Record the subpath start so a preceding moveTo/lineTo block doesn't - // get zig-zag-joined to these four corners under fan triangulation. let subpath_start = data.current_path.len(); data.subpath_starts.push(subpath_start); - // Add rectangle to current path as four corners, plus a fifth point - // duplicating the first. Per the HTML Canvas 2D spec, `rect` emits a - // CLOSED subpath โ€” the duplicate closes it so `stroke()` walks all four - // edges instead of stopping at the third segment. + // Add rectangle to current path as four corners, plus a fifth point duplicating the first data.current_path .push(crate::ext::canvas::renderer::Point { x: x_f64, y: y_f64 }); data.current_path.push(crate::ext::canvas::renderer::Point { @@ -999,10 +983,6 @@ pub fn internal_canvas_set_stroke_style<'gc>( let rid = Rid::from_index(rid_val); let style = args.get(1); - // If the style is a number it's a gradient or pattern rid; if it's a - // string it's a CSS color. Resolve each path separately so that - // `ctx.strokeStyle = gradient` actually propagates to the canvas's - // `stroke_style` field (matches fillStyle's behavior). let is_number = style.is_number(); let fill_rid = if is_number { style.to_uint32(agent, gc.reborrow()).unbind().ok() @@ -1347,6 +1327,13 @@ pub fn internal_canvas_save<'gc>( text_align: data.text_align, text_baseline: data.text_baseline, direction: data.direction, + letter_spacing: data.letter_spacing.clone(), + word_spacing: data.word_spacing.clone(), + font_kerning: data.font_kerning, + font_stretch: data.font_stretch, + font_variant_caps: data.font_variant_caps, + text_rendering: data.text_rendering, + lang: data.lang.clone(), }; data.state_stack.push(current_state); @@ -1374,13 +1361,6 @@ pub fn internal_canvas_restore<'gc>( let res: &mut CanvasResources = storage.get_mut().unwrap(); let mut data = res.canvases.get_mut(rid).unwrap(); - // Restore state from stack if available. The spec requires - // restore() to revert EVERY field that save() captured โ€” previously - // this path only restored 8 of 19 fields, so shadow state, line - // cap/join/miter, and all text attributes leaked across save/restore - // boundaries. The most visible symptom: a fill drawn with shadows - // inside a save()/restore() block still left a shadow trail behind - // subsequent fills that expected no shadow. if let Some(saved_state) = data.state_stack.pop() { data.fill_style = saved_state.fill_style; data.stroke_style = saved_state.stroke_style; @@ -1401,6 +1381,13 @@ pub fn internal_canvas_restore<'gc>( data.text_align = saved_state.text_align; data.text_baseline = saved_state.text_baseline; data.direction = saved_state.direction; + data.letter_spacing = saved_state.letter_spacing; + data.word_spacing = saved_state.word_spacing; + data.font_kerning = saved_state.font_kerning; + data.font_stretch = saved_state.font_stretch; + data.font_variant_caps = saved_state.font_variant_caps; + data.text_rendering = saved_state.text_rendering; + data.lang = saved_state.lang; } // Add restore command to command list @@ -1730,9 +1717,8 @@ pub fn process_all_commands<'gc>( composite_operation: CompositeOperation::default(), clip_path: None, }, - ); // White background + ); } - // Handle other commands that don't directly affect rendering CanvasCommand::Arc { .. } | CanvasCommand::ArcTo { .. } | CanvasCommand::Rect { .. } @@ -1760,7 +1746,7 @@ pub fn process_all_commands<'gc>( | CanvasCommand::CreateRadialGradient { .. } | CanvasCommand::CreateConicGradient { .. } => { // These commands would need more complex state management - // For now, we'll skip them in this basic implementation + // For now, we'll skip them } CanvasCommand::DrawImage { image_rid, diff --git a/crates/runtime/src/ext/canvas/font_system.rs b/crates/runtime/src/ext/canvas/font_system.rs index 71372429..0cd232b6 100644 --- a/crates/runtime/src/ext/canvas/font_system.rs +++ b/crates/runtime/src/ext/canvas/font_system.rs @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. -use cosmic_text::{FontSystem, Style, Weight, fontdb}; +use cosmic_text::{FontSystem, Stretch, Style, Weight, fontdb}; /// Font descriptor matching Canvas2D font properties #[derive(Debug, Clone)] @@ -11,15 +11,16 @@ pub struct FontDescriptor { pub size: f32, pub weight: u16, pub style: Style, + pub stretch: Stretch, } -// Manual implementations for hash/eq to handle f32 impl PartialEq for FontDescriptor { fn eq(&self, other: &Self) -> bool { self.family == other.family && self.size.to_bits() == other.size.to_bits() && self.weight == other.weight && self.style == other.style + && self.stretch == other.stretch } } @@ -31,6 +32,7 @@ impl std::hash::Hash for FontDescriptor { self.size.to_bits().hash(state); self.weight.hash(state); self.style.hash(state); + self.stretch.hash(state); } } @@ -41,10 +43,19 @@ impl Default for FontDescriptor { size: 10.0, weight: 400, style: Style::Normal, + stretch: Stretch::Normal, } } } +/// Per-shape parameters drawn from `CanvasTextDrawingStyles` properties +/// that aren't captured in the font string itself. +#[derive(Debug, Clone, Copy, Default)] +pub struct ShapingParams { + pub letter_spacing_px: f32, + pub word_spacing_px: f32, +} + /// Font manager for loading system fonts and parsing CSS font strings pub struct FontManager { pub font_system: FontSystem, diff --git a/crates/runtime/src/ext/canvas/mod.rs b/crates/runtime/src/ext/canvas/mod.rs index 9b752d0a..5292ddd1 100644 --- a/crates/runtime/src/ext/canvas/mod.rs +++ b/crates/runtime/src/ext/canvas/mod.rs @@ -243,6 +243,13 @@ struct CanvasData<'gc> { text_align: state::TextAlign, text_baseline: state::TextBaseline, direction: state::Direction, + letter_spacing: String, + word_spacing: String, + font_kerning: state::FontKerning, + font_stretch: state::FontStretch, + font_variant_caps: state::FontVariantCaps, + text_rendering: state::TextRendering, + lang: String, } struct CanvasResources<'gc> { @@ -531,6 +538,90 @@ impl CanvasExt { 1, false, ), + ExtensionOp::new( + "internal_canvas_set_letter_spacing", + Self::internal_canvas_set_letter_spacing, + 2, + false, + ), + ExtensionOp::new( + "internal_canvas_get_letter_spacing", + Self::internal_canvas_get_letter_spacing, + 1, + false, + ), + ExtensionOp::new( + "internal_canvas_set_word_spacing", + Self::internal_canvas_set_word_spacing, + 2, + false, + ), + ExtensionOp::new( + "internal_canvas_get_word_spacing", + Self::internal_canvas_get_word_spacing, + 1, + false, + ), + ExtensionOp::new( + "internal_canvas_set_font_kerning", + Self::internal_canvas_set_font_kerning, + 2, + false, + ), + ExtensionOp::new( + "internal_canvas_get_font_kerning", + Self::internal_canvas_get_font_kerning, + 1, + false, + ), + ExtensionOp::new( + "internal_canvas_set_font_stretch", + Self::internal_canvas_set_font_stretch, + 2, + false, + ), + ExtensionOp::new( + "internal_canvas_get_font_stretch", + Self::internal_canvas_get_font_stretch, + 1, + false, + ), + ExtensionOp::new( + "internal_canvas_set_font_variant_caps", + Self::internal_canvas_set_font_variant_caps, + 2, + false, + ), + ExtensionOp::new( + "internal_canvas_get_font_variant_caps", + Self::internal_canvas_get_font_variant_caps, + 1, + false, + ), + ExtensionOp::new( + "internal_canvas_set_text_rendering", + Self::internal_canvas_set_text_rendering, + 2, + false, + ), + ExtensionOp::new( + "internal_canvas_get_text_rendering", + Self::internal_canvas_get_text_rendering, + 1, + false, + ), + ExtensionOp::new( + "internal_canvas_set_lang", + Self::internal_canvas_set_lang, + 2, + false, + ), + ExtensionOp::new( + "internal_canvas_get_lang", + Self::internal_canvas_get_lang, + 1, + false, + ), ExtensionOp::new( "internal_canvas_measure_text", Self::internal_canvas_measure_text, @@ -952,6 +1043,13 @@ impl CanvasExt { text_align: state::TextAlign::default(), text_baseline: state::TextBaseline::default(), direction: state::Direction::default(), + letter_spacing: "0px".to_string(), + word_spacing: "0px".to_string(), + font_kerning: state::FontKerning::default(), + font_stretch: state::FontStretch::default(), + font_variant_caps: state::FontVariantCaps::default(), + text_rendering: state::TextRendering::default(), + lang: "inherit".to_string(), }); // Renderer uses the wgpu device acquired above (shared with the @@ -1425,9 +1523,7 @@ impl CanvasExt { } /// Internal op to save canvas as PNG file - /// Encode the rendered canvas as PNG bytes and return them as a - /// comma-separated-decimal string. TS wraps this into a Uint8Array - /// (matches the existing `internal_image_data_get_data` pattern). + /// Encode the rendered canvas as PNG bytes and return them as a comma-separated-decimal string. fn internal_canvas_encode_png<'gc>( agent: &mut Agent, _this: Value<'_>, @@ -3686,8 +3782,8 @@ impl CanvasExt { let storage = host_data.storage.borrow(); let res: &CanvasResources = storage.get().unwrap(); - // For now, return empty array - full implementation would return Uint8ClampedArray - // This requires proper TypedArray support in the runtime + // For now, return empty array + // TODO: return Uint8ClampedArray if let Some(image_data) = res.images.get(rid) && let Some(data) = &image_data.data { @@ -3915,8 +4011,6 @@ impl CanvasExt { Ok(Value::from_f64(agent, limit, gc.nogc()).unbind()) } - // ========== PHASE 2 IMPLEMENTATIONS: SHADOWS ========== - /// Internal op to set shadowBlur property fn internal_canvas_set_shadow_blur<'gc>( agent: &mut Agent, @@ -4148,8 +4242,6 @@ impl CanvasExt { Ok(Value::from_f64(agent, offset_y, gc.nogc()).unbind()) } - // ========== TEXT PROPERTIES ========== - /// Internal op to set font property fn internal_canvas_set_font<'gc>( agent: &mut Agent, @@ -4392,6 +4484,339 @@ impl CanvasExt { Ok(Value::from_string(agent, direction_str.to_string(), gc.nogc()).unbind()) } + fn internal_canvas_set_letter_spacing<'gc>( + agent: &mut Agent, + _this: Value<'_>, + args: ArgumentsList<'_, '_>, + mut gc: GcScope<'gc, '_>, + ) -> JsResult<'gc, Value<'gc>> { + let rid_val = args.get(0).to_int32(agent, gc.reborrow()).unbind()? as u32; + let rid = Rid::from_index(rid_val); + let s_val = args.get(1).to_string(agent, gc.reborrow()).unbind()?; + let s = s_val.as_str(agent).unwrap_or("").to_string(); + if !state::is_valid_spacing_length(&s) { + return Ok(Value::Undefined); + } + let host_data = agent + .get_host_data() + .downcast_ref::>() + .unwrap(); + let mut storage = host_data.storage.borrow_mut(); + let res: &mut CanvasResources = storage.get_mut().unwrap(); + if let Some(mut canvas) = res.canvases.get_mut(rid) { + canvas.letter_spacing = s; + } + Ok(Value::Undefined) + } + + fn internal_canvas_get_letter_spacing<'gc>( + agent: &mut Agent, + _this: Value<'_>, + args: ArgumentsList<'_, '_>, + mut gc: GcScope<'gc, '_>, + ) -> JsResult<'gc, Value<'gc>> { + let rid_val = args.get(0).to_int32(agent, gc.reborrow()).unbind()? as u32; + let rid = Rid::from_index(rid_val); + let host_data = agent + .get_host_data() + .downcast_ref::>() + .unwrap(); + let storage = host_data.storage.borrow(); + let res: &CanvasResources = storage.get().unwrap(); + let value = res + .canvases + .get(rid) + .map(|c| c.letter_spacing.clone()) + .unwrap_or_else(|| "0px".to_string()); + drop(storage); + Ok(Value::from_string(agent, value, gc.nogc()).unbind()) + } + + fn internal_canvas_set_word_spacing<'gc>( + agent: &mut Agent, + _this: Value<'_>, + args: ArgumentsList<'_, '_>, + mut gc: GcScope<'gc, '_>, + ) -> JsResult<'gc, Value<'gc>> { + let rid_val = args.get(0).to_int32(agent, gc.reborrow()).unbind()? as u32; + let rid = Rid::from_index(rid_val); + let s_val = args.get(1).to_string(agent, gc.reborrow()).unbind()?; + let s = s_val.as_str(agent).unwrap_or("").to_string(); + if !state::is_valid_spacing_length(&s) { + return Ok(Value::Undefined); + } + let host_data = agent + .get_host_data() + .downcast_ref::>() + .unwrap(); + let mut storage = host_data.storage.borrow_mut(); + let res: &mut CanvasResources = storage.get_mut().unwrap(); + if let Some(mut canvas) = res.canvases.get_mut(rid) { + canvas.word_spacing = s; + } + Ok(Value::Undefined) + } + + fn internal_canvas_get_word_spacing<'gc>( + agent: &mut Agent, + _this: Value<'_>, + args: ArgumentsList<'_, '_>, + mut gc: GcScope<'gc, '_>, + ) -> JsResult<'gc, Value<'gc>> { + let rid_val = args.get(0).to_int32(agent, gc.reborrow()).unbind()? as u32; + let rid = Rid::from_index(rid_val); + let host_data = agent + .get_host_data() + .downcast_ref::>() + .unwrap(); + let storage = host_data.storage.borrow(); + let res: &CanvasResources = storage.get().unwrap(); + let value = res + .canvases + .get(rid) + .map(|c| c.word_spacing.clone()) + .unwrap_or_else(|| "0px".to_string()); + drop(storage); + Ok(Value::from_string(agent, value, gc.nogc()).unbind()) + } + + fn internal_canvas_set_font_kerning<'gc>( + agent: &mut Agent, + _this: Value<'_>, + args: ArgumentsList<'_, '_>, + mut gc: GcScope<'gc, '_>, + ) -> JsResult<'gc, Value<'gc>> { + let rid_val = args.get(0).to_int32(agent, gc.reborrow()).unbind()? as u32; + let rid = Rid::from_index(rid_val); + let s_val = args.get(1).to_string(agent, gc.reborrow()).unbind()?; + let s = s_val.as_str(agent).unwrap_or(""); + let Some(parsed) = state::FontKerning::parse(s) else { + return Ok(Value::Undefined); + }; + let host_data = agent + .get_host_data() + .downcast_ref::>() + .unwrap(); + let mut storage = host_data.storage.borrow_mut(); + let res: &mut CanvasResources = storage.get_mut().unwrap(); + if let Some(mut canvas) = res.canvases.get_mut(rid) { + canvas.font_kerning = parsed; + } + Ok(Value::Undefined) + } + + fn internal_canvas_get_font_kerning<'gc>( + agent: &mut Agent, + _this: Value<'_>, + args: ArgumentsList<'_, '_>, + mut gc: GcScope<'gc, '_>, + ) -> JsResult<'gc, Value<'gc>> { + let rid_val = args.get(0).to_int32(agent, gc.reborrow()).unbind()? as u32; + let rid = Rid::from_index(rid_val); + let host_data = agent + .get_host_data() + .downcast_ref::>() + .unwrap(); + let storage = host_data.storage.borrow(); + let res: &CanvasResources = storage.get().unwrap(); + let value = res + .canvases + .get(rid) + .map(|c| c.font_kerning.as_str()) + .unwrap_or("auto"); + drop(storage); + Ok(Value::from_string(agent, value.to_string(), gc.nogc()).unbind()) + } + + fn internal_canvas_set_font_stretch<'gc>( + agent: &mut Agent, + _this: Value<'_>, + args: ArgumentsList<'_, '_>, + mut gc: GcScope<'gc, '_>, + ) -> JsResult<'gc, Value<'gc>> { + let rid_val = args.get(0).to_int32(agent, gc.reborrow()).unbind()? as u32; + let rid = Rid::from_index(rid_val); + let s_val = args.get(1).to_string(agent, gc.reborrow()).unbind()?; + let s = s_val.as_str(agent).unwrap_or(""); + let Some(parsed) = state::FontStretch::parse(s) else { + return Ok(Value::Undefined); + }; + let host_data = agent + .get_host_data() + .downcast_ref::>() + .unwrap(); + let mut storage = host_data.storage.borrow_mut(); + let res: &mut CanvasResources = storage.get_mut().unwrap(); + if let Some(mut canvas) = res.canvases.get_mut(rid) { + canvas.font_stretch = parsed; + } + Ok(Value::Undefined) + } + + fn internal_canvas_get_font_stretch<'gc>( + agent: &mut Agent, + _this: Value<'_>, + args: ArgumentsList<'_, '_>, + mut gc: GcScope<'gc, '_>, + ) -> JsResult<'gc, Value<'gc>> { + let rid_val = args.get(0).to_int32(agent, gc.reborrow()).unbind()? as u32; + let rid = Rid::from_index(rid_val); + let host_data = agent + .get_host_data() + .downcast_ref::>() + .unwrap(); + let storage = host_data.storage.borrow(); + let res: &CanvasResources = storage.get().unwrap(); + let value = res + .canvases + .get(rid) + .map(|c| c.font_stretch.as_str()) + .unwrap_or("normal"); + drop(storage); + Ok(Value::from_string(agent, value.to_string(), gc.nogc()).unbind()) + } + + fn internal_canvas_set_font_variant_caps<'gc>( + agent: &mut Agent, + _this: Value<'_>, + args: ArgumentsList<'_, '_>, + mut gc: GcScope<'gc, '_>, + ) -> JsResult<'gc, Value<'gc>> { + let rid_val = args.get(0).to_int32(agent, gc.reborrow()).unbind()? as u32; + let rid = Rid::from_index(rid_val); + let s_val = args.get(1).to_string(agent, gc.reborrow()).unbind()?; + let s = s_val.as_str(agent).unwrap_or(""); + let Some(parsed) = state::FontVariantCaps::parse(s) else { + return Ok(Value::Undefined); + }; + let host_data = agent + .get_host_data() + .downcast_ref::>() + .unwrap(); + let mut storage = host_data.storage.borrow_mut(); + let res: &mut CanvasResources = storage.get_mut().unwrap(); + if let Some(mut canvas) = res.canvases.get_mut(rid) { + canvas.font_variant_caps = parsed; + } + Ok(Value::Undefined) + } + + fn internal_canvas_get_font_variant_caps<'gc>( + agent: &mut Agent, + _this: Value<'_>, + args: ArgumentsList<'_, '_>, + mut gc: GcScope<'gc, '_>, + ) -> JsResult<'gc, Value<'gc>> { + let rid_val = args.get(0).to_int32(agent, gc.reborrow()).unbind()? as u32; + let rid = Rid::from_index(rid_val); + let host_data = agent + .get_host_data() + .downcast_ref::>() + .unwrap(); + let storage = host_data.storage.borrow(); + let res: &CanvasResources = storage.get().unwrap(); + let value = res + .canvases + .get(rid) + .map(|c| c.font_variant_caps.as_str()) + .unwrap_or("normal"); + drop(storage); + Ok(Value::from_string(agent, value.to_string(), gc.nogc()).unbind()) + } + + fn internal_canvas_set_text_rendering<'gc>( + agent: &mut Agent, + _this: Value<'_>, + args: ArgumentsList<'_, '_>, + mut gc: GcScope<'gc, '_>, + ) -> JsResult<'gc, Value<'gc>> { + let rid_val = args.get(0).to_int32(agent, gc.reborrow()).unbind()? as u32; + let rid = Rid::from_index(rid_val); + let s_val = args.get(1).to_string(agent, gc.reborrow()).unbind()?; + let s = s_val.as_str(agent).unwrap_or(""); + let Some(parsed) = state::TextRendering::parse(s) else { + return Ok(Value::Undefined); + }; + let host_data = agent + .get_host_data() + .downcast_ref::>() + .unwrap(); + let mut storage = host_data.storage.borrow_mut(); + let res: &mut CanvasResources = storage.get_mut().unwrap(); + if let Some(mut canvas) = res.canvases.get_mut(rid) { + canvas.text_rendering = parsed; + } + Ok(Value::Undefined) + } + + fn internal_canvas_get_text_rendering<'gc>( + agent: &mut Agent, + _this: Value<'_>, + args: ArgumentsList<'_, '_>, + mut gc: GcScope<'gc, '_>, + ) -> JsResult<'gc, Value<'gc>> { + let rid_val = args.get(0).to_int32(agent, gc.reborrow()).unbind()? as u32; + let rid = Rid::from_index(rid_val); + let host_data = agent + .get_host_data() + .downcast_ref::>() + .unwrap(); + let storage = host_data.storage.borrow(); + let res: &CanvasResources = storage.get().unwrap(); + let value = res + .canvases + .get(rid) + .map(|c| c.text_rendering.as_str()) + .unwrap_or("auto"); + drop(storage); + Ok(Value::from_string(agent, value.to_string(), gc.nogc()).unbind()) + } + + fn internal_canvas_set_lang<'gc>( + agent: &mut Agent, + _this: Value<'_>, + args: ArgumentsList<'_, '_>, + mut gc: GcScope<'gc, '_>, + ) -> JsResult<'gc, Value<'gc>> { + let rid_val = args.get(0).to_int32(agent, gc.reborrow()).unbind()? as u32; + let rid = Rid::from_index(rid_val); + let s_val = args.get(1).to_string(agent, gc.reborrow()).unbind()?; + let s = s_val.as_str(agent).unwrap_or("").to_string(); + let host_data = agent + .get_host_data() + .downcast_ref::>() + .unwrap(); + let mut storage = host_data.storage.borrow_mut(); + let res: &mut CanvasResources = storage.get_mut().unwrap(); + if let Some(mut canvas) = res.canvases.get_mut(rid) { + canvas.lang = s; + } + Ok(Value::Undefined) + } + + fn internal_canvas_get_lang<'gc>( + agent: &mut Agent, + _this: Value<'_>, + args: ArgumentsList<'_, '_>, + mut gc: GcScope<'gc, '_>, + ) -> JsResult<'gc, Value<'gc>> { + let rid_val = args.get(0).to_int32(agent, gc.reborrow()).unbind()? as u32; + let rid = Rid::from_index(rid_val); + let host_data = agent + .get_host_data() + .downcast_ref::>() + .unwrap(); + let storage = host_data.storage.borrow(); + let res: &CanvasResources = storage.get().unwrap(); + let value = res + .canvases + .get(rid) + .map(|c| c.lang.clone()) + .unwrap_or_else(|| "inherit".to_string()); + drop(storage); + Ok(Value::from_string(agent, value, gc.nogc()).unbind()) + } + /// Internal op to measure text and return TextMetrics fn internal_canvas_measure_text<'gc>( agent: &mut Agent, @@ -4412,26 +4837,37 @@ impl CanvasExt { let res: &CanvasResources = storage.get().unwrap(); let canvas = res.canvases.get(rid).unwrap(); let font_string = canvas.font.clone(); + let letter_spacing_text = canvas.letter_spacing.clone(); + let word_spacing_text = canvas.word_spacing.clone(); + let font_stretch = canvas.font_stretch; drop(storage); - // Parse the font string - let font_descriptor = match font_system::FontManager::parse_font_string(&font_string) { + let mut font_descriptor = match font_system::FontManager::parse_font_string(&font_string) { Ok(descriptor) => descriptor, Err(_) => { - // Return default metrics on parse error let json = r#"{"width":0,"actualBoundingBoxLeft":0,"actualBoundingBoxRight":0,"fontBoundingBoxAscent":0,"fontBoundingBoxDescent":0,"actualBoundingBoxAscent":0,"actualBoundingBoxDescent":0,"emHeightAscent":0,"emHeightDescent":0,"hangingBaseline":0,"alphabeticBaseline":0,"ideographicBaseline":0}"#; return Ok(Value::from_string(agent, json.to_string(), gc.nogc()).unbind()); } }; + font_descriptor.stretch = font_stretch.to_cosmic(); + let spacing = font_system::ShapingParams { + letter_spacing_px: state::resolve_spacing_length( + &letter_spacing_text, + font_descriptor.size as f64, + ) as f32, + word_spacing_px: state::resolve_spacing_length( + &word_spacing_text, + font_descriptor.size as f64, + ) as f32, + }; - // Create a font manager for measurement let mut font_manager = font_system::FontManager::new(); - // Measure the text let metrics = match text_metrics::TextMetrics::measure( text, &mut font_manager, &font_descriptor, + spacing, ) { Ok(metrics) => metrics, Err(_) => { @@ -4505,16 +4941,25 @@ impl CanvasExt { let res: &mut CanvasResources = storage.get_mut().unwrap(); if let Some(canvas) = res.canvases.get(rid) { - // Get current font and parse it - let font_descriptor = match font_system::FontManager::parse_font_string(&canvas.font) { - Ok(desc) => desc, - Err(_) => return Ok(Value::Undefined), // Silently fail on invalid font + let mut font_descriptor = + match font_system::FontManager::parse_font_string(&canvas.font) { + Ok(desc) => desc, + Err(_) => return Ok(Value::Undefined), + }; + font_descriptor.stretch = canvas.font_stretch.to_cosmic(); + let spacing = font_system::ShapingParams { + letter_spacing_px: state::resolve_spacing_length( + &canvas.letter_spacing, + font_descriptor.size as f64, + ) as f32, + word_spacing_px: state::resolve_spacing_length( + &canvas.word_spacing, + font_descriptor.size as f64, + ) as f32, }; - // Create FontManager and TextRenderer let mut text_renderer = text::TextRenderer::new(); - // Get fill color from fill_style (defaulting to black) let color = match &canvas.fill_style { FillStyle::Color { r, g, b, a } => [ (r * 255.0) as u8, @@ -4522,14 +4967,17 @@ impl CanvasExt { (b * 255.0) as u8, (a * 255.0) as u8, ], - _ => [0, 0, 0, 255], // Default to black for gradients/patterns + _ => [0, 0, 0, 255], }; - // Render text to bitmap - let rendered = match text_renderer.render_text_to_bitmap(&text, &font_descriptor, color) - { + let rendered = match text_renderer.render_text_to_bitmap( + &text, + &font_descriptor, + color, + spacing, + ) { Ok(result) => result, - Err(_) => return Ok(Value::Undefined), // Silently fail on render error + Err(_) => return Ok(Value::Undefined), }; if rendered.width == 0 || rendered.height == 0 { @@ -4638,17 +5086,27 @@ impl CanvasExt { let res: &mut CanvasResources = storage.get_mut().unwrap(); if let Some(canvas) = res.canvases.get(rid) { - // Get current font and parse it - let font_descriptor = match font_system::FontManager::parse_font_string(&canvas.font) { - Ok(desc) => desc, - Err(_) => return Ok(Value::Undefined), + let mut font_descriptor = + match font_system::FontManager::parse_font_string(&canvas.font) { + Ok(desc) => desc, + Err(_) => return Ok(Value::Undefined), + }; + font_descriptor.stretch = canvas.font_stretch.to_cosmic(); + let spacing = font_system::ShapingParams { + letter_spacing_px: state::resolve_spacing_length( + &canvas.letter_spacing, + font_descriptor.size as f64, + ) as f32, + word_spacing_px: state::resolve_spacing_length( + &canvas.word_spacing, + font_descriptor.size as f64, + ) as f32, }; - // For now, stroke text is rendered the same as fill text - // TODO: Implement actual text stroking with line width + // TODO: Stroke text path tessellation; for now + // the stroke colour is filled at the path outline. let mut text_renderer = text::TextRenderer::new(); - // Get stroke color from stroke_style (defaulting to black) let color = match &canvas.stroke_style { FillStyle::Color { r, g, b, a } => [ (r * 255.0) as u8, @@ -4656,11 +5114,15 @@ impl CanvasExt { (b * 255.0) as u8, (a * 255.0) as u8, ], - _ => [0, 0, 0, 255], // Default to black for gradients/patterns + _ => [0, 0, 0, 255], }; - let rendered = match text_renderer.render_text_to_bitmap(&text, &font_descriptor, color) - { + let rendered = match text_renderer.render_text_to_bitmap( + &text, + &font_descriptor, + color, + spacing, + ) { Ok(result) => result, Err(_) => return Ok(Value::Undefined), }; diff --git a/crates/runtime/src/ext/canvas/mod.ts b/crates/runtime/src/ext/canvas/mod.ts index fe3da69e..e399f3b4 100644 --- a/crates/runtime/src/ext/canvas/mod.ts +++ b/crates/runtime/src/ext/canvas/mod.ts @@ -1327,6 +1327,13 @@ class CanvasRenderingContext2D { __andromeda__.internal_canvas_set_text_align(this.#rid, "start"); __andromeda__.internal_canvas_set_text_baseline(this.#rid, "alphabetic"); __andromeda__.internal_canvas_set_direction(this.#rid, "inherit"); + __andromeda__.internal_canvas_set_letter_spacing(this.#rid, "0px"); + __andromeda__.internal_canvas_set_word_spacing(this.#rid, "0px"); + __andromeda__.internal_canvas_set_font_kerning(this.#rid, "auto"); + __andromeda__.internal_canvas_set_font_stretch(this.#rid, "normal"); + __andromeda__.internal_canvas_set_font_variant_caps(this.#rid, "normal"); + __andromeda__.internal_canvas_set_text_rendering(this.#rid, "auto"); + __andromeda__.internal_canvas_set_lang(this.#rid, "inherit"); this.#lineDashOffset = 0; this.#imageSmoothingEnabled = true; this.#imageSmoothingQuality = "low"; @@ -1490,6 +1497,62 @@ class CanvasRenderingContext2D { __andromeda__.internal_canvas_set_direction(this.#rid, value); } + get letterSpacing(): string { + return __andromeda__.internal_canvas_get_letter_spacing(this.#rid); + } + + set letterSpacing(value: string) { + __andromeda__.internal_canvas_set_letter_spacing(this.#rid, value); + } + + get wordSpacing(): string { + return __andromeda__.internal_canvas_get_word_spacing(this.#rid); + } + + set wordSpacing(value: string) { + __andromeda__.internal_canvas_set_word_spacing(this.#rid, value); + } + + get fontKerning(): string { + return __andromeda__.internal_canvas_get_font_kerning(this.#rid); + } + + set fontKerning(value: string) { + __andromeda__.internal_canvas_set_font_kerning(this.#rid, value); + } + + get fontStretch(): string { + return __andromeda__.internal_canvas_get_font_stretch(this.#rid); + } + + set fontStretch(value: string) { + __andromeda__.internal_canvas_set_font_stretch(this.#rid, value); + } + + get fontVariantCaps(): string { + return __andromeda__.internal_canvas_get_font_variant_caps(this.#rid); + } + + set fontVariantCaps(value: string) { + __andromeda__.internal_canvas_set_font_variant_caps(this.#rid, value); + } + + get textRendering(): string { + return __andromeda__.internal_canvas_get_text_rendering(this.#rid); + } + + set textRendering(value: string) { + __andromeda__.internal_canvas_set_text_rendering(this.#rid, value); + } + + get lang(): string { + return __andromeda__.internal_canvas_get_lang(this.#rid); + } + + set lang(value: string) { + __andromeda__.internal_canvas_set_lang(this.#rid, value); + } + /** * Returns a TextMetrics object containing the measured dimensions of the specified text. */ diff --git a/crates/runtime/src/ext/canvas/state.rs b/crates/runtime/src/ext/canvas/state.rs index 19decc4a..bdea71b3 100644 --- a/crates/runtime/src/ext/canvas/state.rs +++ b/crates/runtime/src/ext/canvas/state.rs @@ -37,6 +37,202 @@ pub enum Direction { Inherit, } +/// fontKerning values for Canvas2D (auto/normal/none). +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum FontKerning { + #[default] + Auto, + Normal, + None, +} + +impl FontKerning { + pub fn parse(s: &str) -> Option { + match s { + "auto" => Some(Self::Auto), + "normal" => Some(Self::Normal), + "none" => Some(Self::None), + _ => None, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Normal => "normal", + Self::None => "none", + } + } +} + +/// fontStretch values for Canvas2D (the nine CSS keywords). +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum FontStretch { + UltraCondensed, + ExtraCondensed, + Condensed, + SemiCondensed, + #[default] + Normal, + SemiExpanded, + Expanded, + ExtraExpanded, + UltraExpanded, +} + +impl FontStretch { + pub fn parse(s: &str) -> Option { + match s { + "ultra-condensed" => Some(Self::UltraCondensed), + "extra-condensed" => Some(Self::ExtraCondensed), + "condensed" => Some(Self::Condensed), + "semi-condensed" => Some(Self::SemiCondensed), + "normal" => Some(Self::Normal), + "semi-expanded" => Some(Self::SemiExpanded), + "expanded" => Some(Self::Expanded), + "extra-expanded" => Some(Self::ExtraExpanded), + "ultra-expanded" => Some(Self::UltraExpanded), + _ => None, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + Self::UltraCondensed => "ultra-condensed", + Self::ExtraCondensed => "extra-condensed", + Self::Condensed => "condensed", + Self::SemiCondensed => "semi-condensed", + Self::Normal => "normal", + Self::SemiExpanded => "semi-expanded", + Self::Expanded => "expanded", + Self::ExtraExpanded => "extra-expanded", + Self::UltraExpanded => "ultra-expanded", + } + } + + pub fn to_cosmic(self) -> cosmic_text::Stretch { + match self { + Self::UltraCondensed => cosmic_text::Stretch::UltraCondensed, + Self::ExtraCondensed => cosmic_text::Stretch::ExtraCondensed, + Self::Condensed => cosmic_text::Stretch::Condensed, + Self::SemiCondensed => cosmic_text::Stretch::SemiCondensed, + Self::Normal => cosmic_text::Stretch::Normal, + Self::SemiExpanded => cosmic_text::Stretch::SemiExpanded, + Self::Expanded => cosmic_text::Stretch::Expanded, + Self::ExtraExpanded => cosmic_text::Stretch::ExtraExpanded, + Self::UltraExpanded => cosmic_text::Stretch::UltraExpanded, + } + } +} + +/// fontVariantCaps values for Canvas2D. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum FontVariantCaps { + #[default] + Normal, + SmallCaps, + AllSmallCaps, + PetiteCaps, + AllPetiteCaps, + Unicase, + TitlingCaps, +} + +impl FontVariantCaps { + pub fn parse(s: &str) -> Option { + match s { + "normal" => Some(Self::Normal), + "small-caps" => Some(Self::SmallCaps), + "all-small-caps" => Some(Self::AllSmallCaps), + "petite-caps" => Some(Self::PetiteCaps), + "all-petite-caps" => Some(Self::AllPetiteCaps), + "unicase" => Some(Self::Unicase), + "titling-caps" => Some(Self::TitlingCaps), + _ => None, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + Self::Normal => "normal", + Self::SmallCaps => "small-caps", + Self::AllSmallCaps => "all-small-caps", + Self::PetiteCaps => "petite-caps", + Self::AllPetiteCaps => "all-petite-caps", + Self::Unicase => "unicase", + Self::TitlingCaps => "titling-caps", + } + } +} + +/// textRendering values for Canvas2D. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum TextRendering { + #[default] + Auto, + OptimizeSpeed, + OptimizeLegibility, + GeometricPrecision, +} + +impl TextRendering { + pub fn parse(s: &str) -> Option { + match s { + "auto" => Some(Self::Auto), + "optimizeSpeed" => Some(Self::OptimizeSpeed), + "optimizeLegibility" => Some(Self::OptimizeLegibility), + "geometricPrecision" => Some(Self::GeometricPrecision), + _ => None, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + Self::Auto => "auto", + Self::OptimizeSpeed => "optimizeSpeed", + Self::OptimizeLegibility => "optimizeLegibility", + Self::GeometricPrecision => "geometricPrecision", + } + } +} + +/// Returns true if `s` is a valid CSS `` for the canvas spacing +/// properties. +pub fn is_valid_spacing_length(s: &str) -> bool { + let s = s.trim(); + if s.is_empty() { + return false; + } + let (num, unit) = if let Some(stripped) = s.strip_suffix("px") { + (stripped, "px") + } else if let Some(stripped) = s.strip_suffix("em") { + (stripped, "em") + } else { + return false; + }; + let _ = unit; + num.trim() + .parse::() + .map(|n| n.is_finite()) + .unwrap_or(false) +} + +/// Resolve a spacing length string to pixels. Returns 0.0 for invalid input. +/// `font_size_px` is used to resolve `em` units. +pub fn resolve_spacing_length(s: &str, font_size_px: f64) -> f64 { + let s = s.trim(); + if let Some(num) = s.strip_suffix("px") { + num.trim().parse::().unwrap_or(0.0) + } else if let Some(num) = s.strip_suffix("em") { + num.trim() + .parse::() + .map(|n| n * font_size_px) + .unwrap_or(0.0) + } else { + 0.0 + } +} + /// Canvas drawing state that can be saved and restored #[derive(Clone)] pub struct CanvasState { @@ -61,6 +257,13 @@ pub struct CanvasState { pub text_align: TextAlign, pub text_baseline: TextBaseline, pub direction: Direction, + pub letter_spacing: String, + pub word_spacing: String, + pub font_kerning: FontKerning, + pub font_stretch: FontStretch, + pub font_variant_caps: FontVariantCaps, + pub text_rendering: TextRendering, + pub lang: String, } impl Default for CanvasState { @@ -108,6 +311,13 @@ impl CanvasState { text_align: TextAlign::default(), text_baseline: TextBaseline::default(), direction: Direction::default(), + letter_spacing: "0px".to_string(), + word_spacing: "0px".to_string(), + font_kerning: FontKerning::default(), + font_stretch: FontStretch::default(), + font_variant_caps: FontVariantCaps::default(), + text_rendering: TextRendering::default(), + lang: "inherit".to_string(), } } } diff --git a/crates/runtime/src/ext/canvas/text.rs b/crates/runtime/src/ext/canvas/text.rs index f7534da1..0245c51d 100644 --- a/crates/runtime/src/ext/canvas/text.rs +++ b/crates/runtime/src/ext/canvas/text.rs @@ -4,15 +4,9 @@ use cosmic_text::{Buffer, Placement, SwashCache}; -use crate::ext::canvas::font_system::{FontDescriptor, FontManager}; +use crate::ext::canvas::font_system::{FontDescriptor, FontManager, ShapingParams}; /// Result of rasterizing a line of text. -/// -/// The bitmap is sized to its actual ink bounds (so descenders are never -/// clipped). `baseline_y` is the row inside the bitmap where the alphabetic -/// baseline sits, and `x_offset` is the column where the pen origin (x = 0 in -/// glyph coordinates) sits. Callers use these to land the baseline at a -/// specific canvas coordinate regardless of which glyphs happen to appear. pub struct RenderedText { pub bitmap: Vec, pub width: u32, @@ -47,6 +41,8 @@ struct TextCacheKey { text: String, font_descriptor: FontDescriptor, color: [u8; 4], + letter_spacing_bits: u32, + word_spacing_bits: u32, } /// Cached texture data for rendered text @@ -83,11 +79,14 @@ impl TextRenderer { text: &str, font_descriptor: &FontDescriptor, color: [u8; 4], + spacing: ShapingParams, ) -> Result { let cache_key = TextCacheKey { text: text.to_string(), font_descriptor: font_descriptor.clone(), color, + letter_spacing_bits: spacing.letter_spacing_px.to_bits(), + word_spacing_bits: spacing.word_spacing_px.to_bits(), }; if let Some(cached) = self.texture_cache.get(&cache_key) { @@ -111,6 +110,7 @@ impl TextRenderer { let attrs = cosmic_text::Attrs::new() .family(cosmic_text::Family::Name(&font_descriptor.family)) .weight(cosmic_text::Weight(font_descriptor.weight)) + .stretch(font_descriptor.stretch) .style(font_descriptor.style); buffer.set_text( @@ -151,9 +151,12 @@ impl TextRenderer { }); }; + let prefix_offset = build_prefix_offset(text, spacing); + let total_extra = prefix_offset.last().copied().unwrap_or(0.0); + let mut glyph_draws: Vec<(i32, i32, cosmic_text::CacheKey, Placement)> = Vec::new(); let mut min_x: f32 = 0.0; - let mut max_x: f32 = advance_width; + let mut max_x: f32 = advance_width + total_extra; let mut min_y: f32 = baseline_buffer_y - ascent; let mut max_y: f32 = baseline_buffer_y + descent; @@ -167,7 +170,8 @@ impl TextRenderer { let Some(image) = image_opt else { continue }; let placement = image.placement; - let pen_x = physical.x; + let extra = lookup_offset_for_byte(&prefix_offset, glyph.start, text); + let pen_x = physical.x + extra.round() as i32; let pen_y = run.line_y.round() as i32 + physical.y; let ink_left = (pen_x + placement.left) as f32; @@ -216,6 +220,8 @@ impl TextRenderer { Self::blit_glyph(&mut bitmap, width, height, image, draw_x, draw_y, color); } + let final_advance = advance_width + total_extra; + self.texture_cache.put( cache_key, CachedTextTexture { @@ -226,7 +232,7 @@ impl TextRenderer { x_offset, ascent, descent, - advance_width, + advance_width: final_advance, }, ); @@ -238,7 +244,7 @@ impl TextRenderer { x_offset, ascent, descent, - advance_width, + advance_width: final_advance, }) } @@ -295,3 +301,43 @@ impl Default for TextRenderer { Self::new() } } + +/// Build a prefix-sum table mapping char index โ†’ cumulative spacing offset. +/// Slot `i` is the offset that should be added to a glyph whose source byte +/// position lands on the i-th character of `text`. Slot `len(chars)` exists +/// so callers can compute the total post-text extent. +fn build_prefix_offset(text: &str, spacing: ShapingParams) -> Vec { + let chars: Vec = text.chars().collect(); + let mut prefix = Vec::with_capacity(chars.len() + 1); + let mut whitespace_seen = 0usize; + for (i, ch) in chars.iter().enumerate() { + prefix.push( + spacing.letter_spacing_px * i as f32 + spacing.word_spacing_px * whitespace_seen as f32, + ); + if ch.is_whitespace() { + whitespace_seen += 1; + } + } + let final_offset = spacing.letter_spacing_px * chars.len() as f32 + + spacing.word_spacing_px * whitespace_seen as f32; + prefix.push(final_offset); + prefix +} + +/// Map a glyph's byte-start offset back to the prefix-offset table built by +/// `build_prefix_offset`. Returns 0.0 when `text` is empty. +fn lookup_offset_for_byte(prefix: &[f32], byte_start: usize, text: &str) -> f32 { + if prefix.is_empty() || text.is_empty() { + return 0.0; + } + let mut char_idx = 0usize; + for (idx, (b, _)) in text.char_indices().enumerate() { + if b >= byte_start { + char_idx = idx; + break; + } + char_idx = idx + 1; + } + let i = char_idx.min(prefix.len() - 1); + prefix[i] +} diff --git a/crates/runtime/src/ext/canvas/text_metrics.rs b/crates/runtime/src/ext/canvas/text_metrics.rs index 69ed17b0..971b2a3e 100644 --- a/crates/runtime/src/ext/canvas/text_metrics.rs +++ b/crates/runtime/src/ext/canvas/text_metrics.rs @@ -4,7 +4,7 @@ use cosmic_text::Buffer; -use crate::ext::canvas::font_system::{FontDescriptor, FontManager}; +use crate::ext::canvas::font_system::{FontDescriptor, FontManager, ShapingParams}; /// Metrics for measured text. #[derive(Debug, Clone)] @@ -30,6 +30,7 @@ impl TextMetrics { text: &str, font_manager: &mut FontManager, font_descriptor: &FontDescriptor, + spacing: ShapingParams, ) -> Result { let mut buffer = Buffer::new( &mut font_manager.font_system, @@ -39,6 +40,7 @@ impl TextMetrics { let attrs = cosmic_text::Attrs::new() .family(cosmic_text::Family::Name(&font_descriptor.family)) .weight(cosmic_text::Weight(font_descriptor.weight)) + .stretch(font_descriptor.stretch) .style(font_descriptor.style); buffer.set_text( @@ -100,10 +102,13 @@ impl TextMetrics { let _ = baseline_y; + let extra = spacing_extra(text, spacing); + let width = advance_width + extra; + Ok(Self { - width: advance_width, + width, actual_bounding_box_left: (-ink_left).max(0.0), - actual_bounding_box_right: ink_right, + actual_bounding_box_right: ink_right + extra, font_bounding_box_ascent: ascent, font_bounding_box_descent: descent, actual_bounding_box_ascent: ink_top_above, @@ -116,3 +121,17 @@ impl TextMetrics { }) } } + +/// Total horizontal spacing added by `letterSpacing` and `wordSpacing` over +/// `text`, in pixels. `letterSpacing` applies between every adjacent character +/// pair; `wordSpacing` applies once per whitespace cluster. +pub fn spacing_extra(text: &str, spacing: ShapingParams) -> f32 { + let chars: Vec = text.chars().collect(); + if chars.is_empty() { + return 0.0; + } + let letter_extra = spacing.letter_spacing_px * (chars.len().saturating_sub(1)) as f32; + let word_count = chars.iter().filter(|c| c.is_whitespace()).count(); + let word_extra = spacing.word_spacing_px * word_count as f32; + letter_extra + word_extra +} diff --git a/crates/runtime/src/ext/ffi/mod.rs b/crates/runtime/src/ext/ffi/mod.rs index c7499d17..4815efff 100644 --- a/crates/runtime/src/ext/ffi/mod.rs +++ b/crates/runtime/src/ext/ffi/mod.rs @@ -288,9 +288,7 @@ impl FfiExt { if let Value::Object(_obj) = symbols_obj { // TODO: Implement full symbol definition parsing - // This requires careful Nova VM GC scope management that will be completed - // in the next phase to avoid the complex borrowing conflicts. - // For now, we'll parse definitions lazily in get_symbol when they're requested + // This requires careful Nova VM GC scope management } let host_data = agent.get_host_data(); diff --git a/examples/games/flappy.ts b/examples/games/flappy.ts new file mode 100644 index 00000000..56f9ad58 --- /dev/null +++ b/examples/games/flappy.ts @@ -0,0 +1,483 @@ +const WIDTH = 480; +const HEIGHT = 640; +const HEADER_H = 56; +const GROUND_H = 64; +const PLAY_TOP = HEADER_H; +const PLAY_BOTTOM = HEIGHT - GROUND_H; + +const BIRD_X = 130; +const BIRD_R = 14; +const GRAVITY = 1500; +const FLAP_VY = -480; +const MAX_VY = 700; + +const PIPE_W = 64; +const PIPE_GAP = 150; +const PIPE_SPEED = 200; +const PIPE_SPACING = 220; + +const BEST_KEY = "andromeda.flappy.best"; + +const COLORS = { + skyTop: "#4ec0e8", + skyMid: "#8fd6f0", + skyLow: "#cfeef7", + hillFar: "#79c98a", + hillNear: "#5cb073", + cloud: "#ffffff", + ground: "#dec07a", + groundDark: "#a98a4a", + grass: "#7ec850", + grassDark: "#5ea63d", + pipe: "#5fbf3c", + pipeLight: "#86d35a", + pipeDark: "#2f8512", + pipeStroke: "#1d4f08", + bird: "#fde047", + birdBelly: "#fef3a8", + birdStroke: "#a16207", + beak: "#fb923c", + beakStroke: "#c2410c", + text: "#0b1320", + textMuted: "rgba(11,19,32,0.6)", + panel: "rgba(255,255,255,0.9)", +}; + +type Phase = "ready" | "playing" | "dead"; + +interface Pipe { + x: number; + gapY: number; + passed: boolean; +} + +interface Cloud { + x: number; + y: number; + scale: number; + speed: number; +} + +interface State { + phase: Phase; + score: number; + best: number; + bird: { y: number; vy: number; angle: number; wingPhase: number }; + pipes: Pipe[]; + clouds: Cloud[]; + spawn: number; + groundOffset: number; + hillScroll: number; + flashGold: number; +} + +const HILL_FAR_STEP = 160; +const HILL_FAR_HEIGHT = 70; +const HILL_NEAR_STEP = 110; +const HILL_NEAR_HEIGHT = 44; + +function loadBest(): number { + const n = Number(localStorage.getItem(BEST_KEY)); + return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0; +} +function saveBest(n: number) { + localStorage.setItem(BEST_KEY, String(n)); +} + +function makeClouds(): Cloud[] { + const out: Cloud[] = []; + for (let i = 0; i < 5; i++) { + out.push({ + x: (WIDTH / 5) * i + Math.random() * 40, + y: HEADER_H + 20 + Math.random() * 140, + scale: 0.8 + Math.random() * 0.4, + speed: 12 + Math.random() * 8, + }); + } + return out; +} + +function fresh(best: number): State { + return { + phase: "ready", + score: 0, + best, + bird: { y: HEIGHT / 2, vy: 0, angle: 0, wingPhase: 0 }, + pipes: [], + clouds: makeClouds(), + spawn: 0, + groundOffset: 0, + hillScroll: 0, + flashGold: 0, + }; +} + +const win = Andromeda.createWindow({ + title: "Andromeda Flap", + width: WIDTH, + height: HEIGHT, +}); +const canvas = new OffscreenCanvas(WIDTH, HEIGHT); +const ctx = canvas.getContext("2d")!; +let state = fresh(loadBest()); +let last = Date.now(); + +function flap() { + if (state.phase === "dead") { + state = fresh(state.best); + return; + } + if (state.phase === "ready") state.phase = "playing"; + state.bird.vy = FLAP_VY; + state.bird.wingPhase = 0; +} + +win.addEventListener("keydown", (e: any) => { + const c: string = e.detail.code; + if (c === "Escape") return win.close(); + if (c === "Space" || c === "ArrowUp" || c === "KeyW") { + if (!e.detail.repeat) flap(); + } + if (c === "KeyR") state = fresh(state.best); +}); +win.addEventListener("mousedown", (e: any) => { + if (e.detail.button === 0) flap(); +}); + +function spawnPipe() { + const margin = 60; + const gapY = margin + + Math.random() * (PLAY_BOTTOM - PLAY_TOP - PIPE_GAP - margin * 2); + state.pipes.push({ x: WIDTH + 20, gapY, passed: false }); +} + +function update() { + const now = Date.now(); + const dt = Math.min(0.05, (now - last) / 1000); + last = now; + + state.groundOffset = (state.groundOffset + PIPE_SPEED * dt) % 32; + state.hillScroll += PIPE_SPEED * dt; + if (state.flashGold > 0) state.flashGold -= dt; + + for (const c of state.clouds) { + c.x -= c.speed * dt; + if (c.x + 80 * c.scale < 0) { + c.x = WIDTH + 20; + c.y = HEADER_H + 20 + Math.random() * 140; + } + } + + state.bird.wingPhase = (state.bird.wingPhase + dt * 18) % (Math.PI * 2); + + if (state.phase === "ready") { + state.bird.y = HEIGHT / 2 + Math.sin(now / 250) * 8; + return; + } + + if (state.phase === "playing") { + state.bird.vy = Math.min(MAX_VY, state.bird.vy + GRAVITY * dt); + state.bird.y += state.bird.vy * dt; + state.bird.angle = Math.max(-0.5, Math.min(1.2, state.bird.vy / 500)); + + state.spawn -= PIPE_SPEED * dt; + if ( + state.pipes.length === 0 || + WIDTH - state.pipes[state.pipes.length - 1].x >= PIPE_SPACING + ) { + spawnPipe(); + } + + for (const p of state.pipes) { + p.x -= PIPE_SPEED * dt; + if (!p.passed && p.x + PIPE_W < BIRD_X) { + p.passed = true; + state.score++; + if (state.score > state.best) { + state.best = state.score; + saveBest(state.best); + state.flashGold = 0.6; + } + } + } + state.pipes = state.pipes.filter((p) => p.x + PIPE_W > -10); + + if (state.bird.y + BIRD_R > PLAY_BOTTOM) { + state.bird.y = PLAY_BOTTOM - BIRD_R; + state.phase = "dead"; + } + if (state.bird.y - BIRD_R < PLAY_TOP) { + state.bird.y = PLAY_TOP + BIRD_R; + state.bird.vy = 0; + } + + for (const p of state.pipes) { + const inX = BIRD_X + BIRD_R > p.x && BIRD_X - BIRD_R < p.x + PIPE_W; + if (!inX) continue; + const top = PLAY_TOP + p.gapY; + const bot = top + PIPE_GAP; + if (state.bird.y - BIRD_R < top || state.bird.y + BIRD_R > bot) { + state.phase = "dead"; + break; + } + } + } else if (state.phase === "dead") { + if (state.bird.y < PLAY_BOTTOM - BIRD_R) { + state.bird.vy = Math.min(MAX_VY, state.bird.vy + GRAVITY * dt); + state.bird.y = Math.min( + PLAY_BOTTOM - BIRD_R, + state.bird.y + state.bird.vy * dt, + ); + state.bird.angle = Math.min(1.4, state.bird.angle + 4 * dt); + } + } +} + +function drawSky() { + const grad = ctx.createLinearGradient(0, 0, 0, PLAY_BOTTOM); + grad.addColorStop(0, COLORS.skyTop); + grad.addColorStop(0.6, COLORS.skyMid); + grad.addColorStop(1, COLORS.skyLow); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, WIDTH, PLAY_BOTTOM); +} + +function drawHills( + color: string, + baseY: number, + height: number, + step: number, + scrollMul: number, +) { + const offset = ((state.hillScroll * scrollMul) % step + step) % step; + ctx.fillStyle = color; + ctx.beginPath(); + ctx.moveTo(-step, baseY + 1); + for (let x = -step - offset; x <= WIDTH + step; x += step) { + const cx = x + step / 2; + ctx.quadraticCurveTo(cx, baseY - height * 2, x + step, baseY); + } + ctx.lineTo(WIDTH + step, baseY + 1); + ctx.closePath(); + ctx.fill(); +} + +function drawHillsBackground() { + drawHills( + COLORS.hillFar, + PLAY_BOTTOM, + HILL_FAR_HEIGHT, + HILL_FAR_STEP, + 0.12, + ); + drawHills( + COLORS.hillNear, + PLAY_BOTTOM, + HILL_NEAR_HEIGHT, + HILL_NEAR_STEP, + 0.3, + ); +} + +function drawCloud(x: number, y: number, scale: number) { + ctx.save(); + ctx.translate(x, y); + ctx.scale(scale, scale); + ctx.fillStyle = COLORS.cloud; + ctx.beginPath(); + ctx.arc(14, 14, 13, 0, Math.PI * 2); + ctx.arc(30, 9, 16, 0, Math.PI * 2); + ctx.arc(48, 14, 13, 0, Math.PI * 2); + ctx.arc(36, 20, 14, 0, Math.PI * 2); + ctx.arc(20, 20, 11, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); +} + +function drawClouds() { + for (const c of state.clouds) drawCloud(c.x, c.y, c.scale); +} + +function drawGround() { + // Dirt base + ctx.fillStyle = COLORS.ground; + ctx.fillRect(0, PLAY_BOTTOM + 10, WIDTH, GROUND_H - 10); + + // Subtle scrolling stripes + ctx.fillStyle = COLORS.groundDark; + for (let x = -state.groundOffset; x < WIDTH; x += 32) { + ctx.fillRect(x, PLAY_BOTTOM + 30, 16, 3); + } + + // Grass strip + ctx.fillStyle = COLORS.grass; + ctx.fillRect(0, PLAY_BOTTOM, WIDTH, 10); + + // Grass tufts + ctx.fillStyle = COLORS.grassDark; + for (let x = -state.groundOffset; x < WIDTH; x += 16) { + ctx.fillRect(x, PLAY_BOTTOM - 2, 3, 2); + } +} + +function drawPipe(p: Pipe) { + const top = PLAY_TOP + p.gapY; + const bot = top + PIPE_GAP; + + drawPipeColumn(p.x, PLAY_TOP, top - PLAY_TOP - 22); + drawPipeColumn(p.x, bot + 22, PLAY_BOTTOM - (bot + 22)); + + drawPipeCap(p.x, top - 24); + drawPipeCap(p.x, bot); +} + +function drawPipeColumn(x: number, y: number, h: number) { + if (h <= 0) return; + ctx.fillStyle = COLORS.pipe; + ctx.fillRect(x, y, PIPE_W, h); + // Highlight stripe + ctx.fillStyle = COLORS.pipeLight; + ctx.fillRect(x + 8, y, 6, h); + // Dark edge + ctx.fillStyle = COLORS.pipeDark; + ctx.fillRect(x + PIPE_W - 6, y, 4, h); + // Outline + ctx.strokeStyle = COLORS.pipeStroke; + ctx.lineWidth = 1.5; + ctx.strokeRect(x + 0.5, y, PIPE_W - 1, h); +} + +function drawPipeCap(x: number, y: number) { + const cw = PIPE_W + 8; + const ch = 24; + const cx = x - 4; + ctx.fillStyle = COLORS.pipe; + ctx.fillRect(cx, y, cw, ch); + ctx.fillStyle = COLORS.pipeLight; + ctx.fillRect(cx + 6, y + 4, 6, ch - 8); + ctx.fillStyle = COLORS.pipeDark; + ctx.fillRect(cx + cw - 6, y, 4, ch); + ctx.strokeStyle = COLORS.pipeStroke; + ctx.lineWidth = 1.5; + ctx.strokeRect(cx + 0.5, y + 0.5, cw - 1, ch - 1); +} + +function drawBird() { + ctx.save(); + ctx.translate(BIRD_X, state.bird.y); + ctx.rotate(state.bird.angle); + + // Body + ctx.fillStyle = COLORS.bird; + ctx.beginPath(); + ctx.arc(0, 0, BIRD_R, 0, Math.PI * 2); + ctx.fill(); + + // Belly highlight + ctx.fillStyle = COLORS.birdBelly; + ctx.beginPath(); + ctx.ellipse(-1, 5, BIRD_R - 5, 4, 0, 0, Math.PI * 2); + ctx.fill(); + + // Outline + ctx.strokeStyle = COLORS.birdStroke; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.arc(0, 0, BIRD_R, 0, Math.PI * 2); + ctx.stroke(); + + // Wing โ€” animated + const wingY = Math.sin(state.bird.wingPhase) * 5; + ctx.fillStyle = "#ffffff"; + ctx.beginPath(); + ctx.ellipse(-2, 2 + wingY, 7, 4, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + + // Eye + ctx.fillStyle = "#ffffff"; + ctx.beginPath(); + ctx.arc(5, -4, 4, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = "#000"; + ctx.beginPath(); + ctx.arc(6, -4, 2, 0, Math.PI * 2); + ctx.fill(); + + // Beak + ctx.fillStyle = COLORS.beak; + ctx.beginPath(); + ctx.moveTo(BIRD_R - 2, -3); + ctx.lineTo(BIRD_R + 9, -1); + ctx.lineTo(BIRD_R + 9, 3); + ctx.lineTo(BIRD_R - 2, 5); + ctx.closePath(); + ctx.fill(); + ctx.strokeStyle = COLORS.beakStroke; + ctx.lineWidth = 1; + ctx.stroke(); + + ctx.restore(); +} + +function drawHeader() { + ctx.fillStyle = "rgba(11,19,32,0.55)"; + ctx.fillRect(0, 0, WIDTH, HEADER_H); + ctx.fillStyle = "#e7e9ee"; + ctx.font = "600 11px sans-serif"; + ctx.textAlign = "left"; + ctx.textBaseline = "alphabetic"; + ctx.fillText("SCORE", 16, 20); + ctx.fillText("BEST", WIDTH - 80, 20); + ctx.fillStyle = state.flashGold > 0 ? "#fde047" : "#ffffff"; + ctx.font = "700 24px ui-monospace, monospace"; + ctx.fillText(String(state.score).padStart(3, "0"), 16, 44); + ctx.fillStyle = "#ffffff"; + ctx.fillText(String(state.best).padStart(3, "0"), WIDTH - 80, 44); +} + +function drawCenterCard(title: string, subtitle: string) { + const w = 280; + const h = 140; + const x = (WIDTH - w) / 2; + const y = (HEIGHT - h) / 2 - 20; + ctx.fillStyle = COLORS.panel; + ctx.beginPath(); + ctx.roundRect(x, y, w, h, 14); + ctx.fill(); + ctx.fillStyle = COLORS.text; + ctx.font = "700 24px sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(title, x + w / 2, y + 50); + ctx.fillStyle = COLORS.textMuted; + ctx.font = "13px sans-serif"; + ctx.fillText(subtitle, x + w / 2, y + 92); +} + +function render() { + drawSky(); + drawClouds(); + drawHillsBackground(); + for (const p of state.pipes) drawPipe(p); + drawGround(); + drawBird(); + drawHeader(); + + if (state.phase === "ready") { + drawCenterCard("Tap to start", "space / click / โ†‘ to flap"); + } else if (state.phase === "dead") { + const newBest = state.score === state.best && state.best > 0; + drawCenterCard( + newBest ? "New best!" : "Game Over", + `${state.score} ยท best ${state.best} ยท click to retry`, + ); + } +} + +await Andromeda.Window.mainloop(() => { + update(); + render(); + win.presentCanvas(canvas); +}); diff --git a/tests/js/canvas/_harness.js b/tests/js/canvas/_harness.js new file mode 100644 index 00000000..045d758f --- /dev/null +++ b/tests/js/canvas/_harness.js @@ -0,0 +1,142 @@ +const _results = []; + +function test(fn, name) { + const label = name || `test_${_results.length + 1}`; + try { + fn(); + _results.push({ name: label, pass: true }); + } catch (e) { + _results.push({ + name: label, + pass: false, + message: String(e && e.stack || e), + }); + } +} + +function asyncTest(fn, name) { + const label = name || `test_${_results.length + 1}`; + const slot = { name: label, pass: false, pending: true }; + _results.push(slot); + return Promise.resolve() + .then(fn) + .then(() => { + slot.pass = true; + slot.pending = false; + }) + .catch((e) => { + slot.pass = false; + slot.pending = false; + slot.message = String(e && e.stack || e); + }); +} + +function assertEqual(actual, expected, msg) { + if (actual !== expected) { + throw new Error( + `${msg || "assertEqual"}: expected ${stringify(expected)}, got ${ + stringify(actual) + }`, + ); + } +} + +function assertNotEqual(actual, expected, msg) { + if (actual === expected) { + throw new Error( + `${msg || "assertNotEqual"}: both were ${stringify(actual)}`, + ); + } +} + +function assertCloseTo(actual, expected, tolerance, msg) { + if (typeof actual !== "number" || Number.isNaN(actual)) { + throw new Error( + `${msg || "assertCloseTo"}: actual is not a number (${actual})`, + ); + } + if (Math.abs(actual - expected) > tolerance) { + throw new Error( + `${ + msg || "assertCloseTo" + }: expected ${expected} ยฑ${tolerance}, got ${actual}`, + ); + } +} + +function assertGreaterThan(actual, threshold, msg) { + if (!(actual > threshold)) { + throw new Error( + `${msg || "assertGreaterThan"}: expected > ${threshold}, got ${actual}`, + ); + } +} + +function assertTruthy(value, msg) { + if (!value) { + throw new Error(`${msg || "assertTruthy"}: got ${stringify(value)}`); + } +} + +function assertFalsy(value, msg) { + if (value) { + throw new Error(`${msg || "assertFalsy"}: got ${stringify(value)}`); + } +} + +function assertThrows(fn, msg) { + try { + fn(); + } catch (_e) { + return; + } + throw new Error(`${msg || "assertThrows"}: did not throw`); +} + +function stringify(value) { + if (typeof value === "string") return JSON.stringify(value); + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + if (value === null || value === undefined) return String(value); + try { + return JSON.stringify(value); + } catch (_e) { + return String(value); + } +} + +function report() { + let passed = 0; + let failed = 0; + for (const r of _results) { + if (r.pending) { + console.error(`PENDING: ${r.name}`); + failed++; + continue; + } + if (r.pass) { + passed++; + } else { + failed++; + console.error(`FAIL: ${r.name}\n ${r.message}`); + } + } + console.log(`\n${passed}/${_results.length} passed`); + if (failed > 0) { + throw new Error(`${failed} canvas test(s) failed`); + } +} + +globalThis.canvasHarness = { + test, + asyncTest, + assertEqual, + assertNotEqual, + assertCloseTo, + assertGreaterThan, + assertTruthy, + assertFalsy, + assertThrows, + report, +}; diff --git a/tests/js/canvas/composite.test.js b/tests/js/canvas/composite.test.js new file mode 100644 index 00000000..ef732a73 --- /dev/null +++ b/tests/js/canvas/composite.test.js @@ -0,0 +1,67 @@ +// deno-lint-ignore-file no-undef +import "./_harness.js"; +const { test, assertEqual, report } = globalThis.canvasHarness; + +function ctx() { + return new OffscreenCanvas(32, 32).getContext("2d"); +} + +const MODES = [ + "source-over", + "source-in", + "source-out", + "source-atop", + "destination-over", + "destination-in", + "destination-out", + "destination-atop", + "lighter", + "copy", + "xor", + "multiply", + "screen", + "overlay", + "darken", + "lighten", + "color-dodge", + "color-burn", + "hard-light", + "soft-light", + "difference", + "exclusion", + "hue", + "saturation", + "color", + "luminosity", +]; + +for (const mode of MODES) { + test(() => { + const c = ctx(); + c.globalCompositeOperation = mode; + assertEqual(c.globalCompositeOperation, mode); + }, `globalCompositeOperation round-trips ${mode}`); +} + +test(() => { + const c = ctx(); + c.globalCompositeOperation = "source-over"; + c.globalCompositeOperation = "not-a-real-mode"; + assertEqual(c.globalCompositeOperation, "source-over"); +}, "invalid globalCompositeOperation keeps previous value"); + +test(() => { + const c = ctx(); + c.globalAlpha = 0.42; + assertEqual(Math.round(c.globalAlpha * 100) / 100, 0.42); +}, "globalAlpha round-trips a fractional value"); + +test(() => { + const c = ctx(); + c.globalAlpha = -1; + assertEqual(c.globalAlpha, 0); + c.globalAlpha = 5; + assertEqual(c.globalAlpha, 1); +}, "globalAlpha is clamped to [0, 1]"); + +report(); diff --git a/tests/js/canvas/paths.test.js b/tests/js/canvas/paths.test.js new file mode 100644 index 00000000..16636b29 --- /dev/null +++ b/tests/js/canvas/paths.test.js @@ -0,0 +1,57 @@ +// deno-lint-ignore-file no-undef +import "./_harness.js"; +const { test, assertEqual, assertTruthy, assertFalsy, report } = globalThis.canvasHarness; + +function ctx() { + return new OffscreenCanvas(100, 100).getContext("2d"); +} + +test(() => { + const c = ctx(); + c.beginPath(); + c.rect(10, 10, 30, 30); + assertTruthy(c.isPointInPath(20, 20)); + assertFalsy(c.isPointInPath(50, 50)); +}, "isPointInPath: rect contains center, not far point"); + +test(() => { + const c = ctx(); + c.beginPath(); + c.arc(50, 50, 20, 0, Math.PI * 2); + assertTruthy(c.isPointInPath(50, 50)); + assertFalsy(c.isPointInPath(50, 80)); +}, "isPointInPath: arc inclusion"); + +test(() => { + const c = ctx(); + c.lineWidth = 8; + c.beginPath(); + c.moveTo(10, 50); + c.lineTo(90, 50); + assertTruthy(c.isPointInStroke(50, 50)); + assertFalsy(c.isPointInStroke(50, 80)); +}, "isPointInStroke uses current lineWidth"); + +test(() => { + const p = new Path2D(); + p.rect(0, 0, 10, 10); + p.moveTo(20, 20); + p.lineTo(30, 20); + assertEqual(typeof p.addPath, "function"); +}, "Path2D exposes the spec methods"); + +test(() => { + const p = new Path2D("M10 10 L20 10 L20 20 Z"); + const c = ctx(); + assertTruthy(c.isPointInPath(p, 15, 12)); + assertFalsy(c.isPointInPath(p, 50, 50)); +}, "Path2D from SVG path string is hit-testable"); + +test(() => { + const c = ctx(); + c.beginPath(); + c.roundRect(10, 10, 50, 50, 5); + assertTruthy(c.isPointInPath(35, 35)); +}, "roundRect produces a closed shape"); + +report(); diff --git a/tests/js/canvas/state.test.js b/tests/js/canvas/state.test.js new file mode 100644 index 00000000..73f76ae5 --- /dev/null +++ b/tests/js/canvas/state.test.js @@ -0,0 +1,116 @@ +// deno-lint-ignore-file no-undef +import "./_harness.js"; +const { test, assertEqual, assertCloseTo, report } = globalThis.canvasHarness; + +function ctx() { + return new OffscreenCanvas(64, 64).getContext("2d"); +} + +test(() => { + const c = ctx(); + c.lineWidth = 4; + c.save(); + c.lineWidth = 12; + assertEqual(c.lineWidth, 12); + c.restore(); + assertEqual(c.lineWidth, 4); +}, "save/restore preserves lineWidth"); + +test(() => { + const c = ctx(); + c.fillStyle = "#abcdef"; + const beforeSave = c.fillStyle; + c.save(); + c.fillStyle = "#123456"; + c.restore(); + assertEqual(typeof c.fillStyle, "string"); + assertEqual(c.fillStyle, beforeSave); +}, "save/restore preserves fillStyle string"); + +test(() => { + const c = ctx(); + c.lineCap = "round"; + c.lineJoin = "bevel"; + c.miterLimit = 7; + c.save(); + c.lineCap = "square"; + c.lineJoin = "miter"; + c.miterLimit = 2; + c.restore(); + assertEqual(c.lineCap, "round"); + assertEqual(c.lineJoin, "bevel"); + assertEqual(c.miterLimit, 7); +}, "save/restore preserves line styles"); + +test(() => { + const c = ctx(); + c.setLineDash([5, 5, 2]); + c.lineDashOffset = 3; + c.save(); + c.setLineDash([1, 1]); + c.lineDashOffset = 9; + c.restore(); + const dash = c.getLineDash(); + assertEqual(dash.length, 6); + assertEqual(dash[0], 5); + assertEqual(dash[1], 5); + assertEqual(dash[2], 2); + assertEqual(dash[3], 5); + assertEqual(dash[4], 5); + assertEqual(dash[5], 2); +}, "setLineDash normalizes odd-length and getLineDash returns even-length copy"); + +test(() => { + const c = ctx(); + c.globalAlpha = 0.5; + c.globalCompositeOperation = "multiply"; + c.save(); + c.globalAlpha = 0.1; + c.globalCompositeOperation = "screen"; + c.restore(); + assertCloseTo(c.globalAlpha, 0.5, 1e-6); + assertEqual(c.globalCompositeOperation, "multiply"); +}, "save/restore preserves globalAlpha and globalCompositeOperation"); + +test(() => { + const c = ctx(); + c.shadowBlur = 8; + c.shadowColor = "#ff0000"; + c.shadowOffsetX = 2; + c.shadowOffsetY = -3; + c.save(); + c.shadowBlur = 0; + c.shadowColor = "#000000"; + c.shadowOffsetX = 0; + c.shadowOffsetY = 0; + c.restore(); + assertEqual(c.shadowBlur, 8); + assertEqual(c.shadowOffsetX, 2); + assertEqual(c.shadowOffsetY, -3); +}, "save/restore preserves shadow properties"); + +test(() => { + const c = ctx(); + c.font = "20px sans-serif"; + c.textAlign = "center"; + c.textBaseline = "middle"; + c.save(); + c.font = "10px serif"; + c.textAlign = "left"; + c.textBaseline = "top"; + c.restore(); + assertEqual(c.textAlign, "center"); + assertEqual(c.textBaseline, "middle"); +}, "save/restore preserves text alignment properties"); + +test(() => { + const c = ctx(); + c.lineWidth = 5; + c.fillStyle = "#abcdef"; + c.shadowBlur = 3; + c.reset(); + assertEqual(c.lineWidth, 1); + assertEqual(c.shadowBlur, 0); +}, "reset() restores defaults"); + +report(); diff --git a/tests/js/canvas/text_styles.test.js b/tests/js/canvas/text_styles.test.js new file mode 100644 index 00000000..aec8ceac --- /dev/null +++ b/tests/js/canvas/text_styles.test.js @@ -0,0 +1,257 @@ +// deno-lint-ignore-file no-undef +import "./_harness.js"; +const { + test, + assertEqual, + assertCloseTo, + assertGreaterThan, + report, +} = globalThis.canvasHarness; + +function ctx() { + return new OffscreenCanvas(64, 64).getContext("2d"); +} + +test(() => { + const c = ctx(); + assertEqual(c.letterSpacing, "0px"); + assertEqual(c.wordSpacing, "0px"); + assertEqual(c.fontKerning, "auto"); + assertEqual(c.fontStretch, "normal"); + assertEqual(c.fontVariantCaps, "normal"); + assertEqual(c.textRendering, "auto"); + assertEqual(c.lang, "inherit"); +}, "text-style properties have spec defaults"); + +test(() => { + const c = ctx(); + c.letterSpacing = "2px"; + assertEqual(c.letterSpacing, "2px"); + c.letterSpacing = "0.5em"; + assertEqual(c.letterSpacing, "0.5em"); +}, "letterSpacing round-trips lengths"); + +test(() => { + const c = ctx(); + c.wordSpacing = "10px"; + assertEqual(c.wordSpacing, "10px"); + c.wordSpacing = "-3px"; + assertEqual(c.wordSpacing, "-3px"); +}, "wordSpacing round-trips lengths including negative"); + +test(() => { + const c = ctx(); + for (const v of ["auto", "normal", "none"]) { + c.fontKerning = v; + assertEqual(c.fontKerning, v); + } +}, "fontKerning round-trips all keywords"); + +test(() => { + const c = ctx(); + for ( + const v of [ + "ultra-condensed", + "extra-condensed", + "condensed", + "semi-condensed", + "normal", + "semi-expanded", + "expanded", + "extra-expanded", + "ultra-expanded", + ] + ) { + c.fontStretch = v; + assertEqual(c.fontStretch, v); + } +}, "fontStretch round-trips all keywords"); + +test(() => { + const c = ctx(); + for ( + const v of [ + "normal", + "small-caps", + "all-small-caps", + "petite-caps", + "all-petite-caps", + "unicase", + "titling-caps", + ] + ) { + c.fontVariantCaps = v; + assertEqual(c.fontVariantCaps, v); + } +}, "fontVariantCaps round-trips all keywords"); + +test(() => { + const c = ctx(); + for ( + const v of [ + "auto", + "optimizeSpeed", + "optimizeLegibility", + "geometricPrecision", + ] + ) { + c.textRendering = v; + assertEqual(c.textRendering, v); + } +}, "textRendering round-trips all keywords"); + +test(() => { + const c = ctx(); + c.lang = "ja"; + assertEqual(c.lang, "ja"); + c.lang = "en-US"; + assertEqual(c.lang, "en-US"); + c.lang = ""; + assertEqual(c.lang, ""); + c.lang = "inherit"; + assertEqual(c.lang, "inherit"); +}, "lang round-trips arbitrary strings"); + +test(() => { + const c = ctx(); + c.letterSpacing = "5px"; + c.letterSpacing = "garbage"; + assertEqual(c.letterSpacing, "5px"); + c.wordSpacing = "8px"; + c.wordSpacing = "not-a-length"; + assertEqual(c.wordSpacing, "8px"); +}, "invalid spacing values keep previous value"); + +test(() => { + const c = ctx(); + c.fontKerning = "normal"; + c.fontKerning = "wat"; + assertEqual(c.fontKerning, "normal"); + c.fontStretch = "condensed"; + c.fontStretch = "huge"; + assertEqual(c.fontStretch, "condensed"); + c.fontVariantCaps = "small-caps"; + c.fontVariantCaps = "BIG"; + assertEqual(c.fontVariantCaps, "small-caps"); + c.textRendering = "geometricPrecision"; + c.textRendering = "fast"; + assertEqual(c.textRendering, "geometricPrecision"); +}, "invalid keyword values keep previous value"); + +test(() => { + const c = ctx(); + c.letterSpacing = "3px"; + c.wordSpacing = "5px"; + c.fontKerning = "none"; + c.fontStretch = "expanded"; + c.fontVariantCaps = "small-caps"; + c.textRendering = "optimizeLegibility"; + c.lang = "ja"; + c.save(); + c.letterSpacing = "0px"; + c.wordSpacing = "0px"; + c.fontKerning = "auto"; + c.fontStretch = "normal"; + c.fontVariantCaps = "normal"; + c.textRendering = "auto"; + c.lang = "inherit"; + c.restore(); + assertEqual(c.letterSpacing, "3px"); + assertEqual(c.wordSpacing, "5px"); + assertEqual(c.fontKerning, "none"); + assertEqual(c.fontStretch, "expanded"); + assertEqual(c.fontVariantCaps, "small-caps"); + assertEqual(c.textRendering, "optimizeLegibility"); + assertEqual(c.lang, "ja"); +}, "save/restore round-trips every text-style property"); + +test(() => { + const c = ctx(); + c.letterSpacing = "9px"; + c.fontKerning = "none"; + c.lang = "ja"; + c.reset(); + assertEqual(c.letterSpacing, "0px"); + assertEqual(c.fontKerning, "auto"); + assertEqual(c.lang, "inherit"); +}, "reset() restores text-style defaults"); + +test(() => { + const c = ctx(); + c.font = "20px sans-serif"; + c.letterSpacing = "0.5em"; + assertEqual(c.letterSpacing, "0.5em"); +}, "letterSpacing keeps original units"); + +test(() => { + const c = ctx(); + c.font = "32px sans-serif"; + c.letterSpacing = "0px"; + const baseline = c.measureText("Hello").width; + + c.letterSpacing = "5px"; + const wider = c.measureText("Hello").width; + + assertGreaterThan(wider, baseline); + assertCloseTo(wider - baseline, 20, 0.5, "letterSpacing applies (n-1) times"); +}, "letterSpacing widens measureText proportionally"); + +test(() => { + const c = ctx(); + c.font = "24px sans-serif"; + c.wordSpacing = "0px"; + const baseline = c.measureText("a b c").width; + + c.wordSpacing = "10px"; + const wider = c.measureText("a b c").width; + + assertGreaterThan(wider, baseline); + assertCloseTo(wider - baseline, 20, 0.5, "wordSpacing applies per space"); +}, "wordSpacing widens measureText per whitespace"); + +test(() => { + const c = ctx(); + c.font = "24px sans-serif"; + c.letterSpacing = "3px"; + c.wordSpacing = "10px"; + const both = c.measureText("a b").width; + + c.letterSpacing = "0px"; + c.wordSpacing = "0px"; + const neither = c.measureText("a b").width; + + assertGreaterThan(both, neither); + assertCloseTo(both - neither, 16, 0.5, "combined spacing is additive"); +}, "letterSpacing and wordSpacing combine"); + +test(() => { + const c = ctx(); + c.font = "20px sans-serif"; + c.letterSpacing = "20px"; + const m = c.measureText(""); + assertEqual(m.width, 0); +}, "empty string measureText returns 0 even with spacing"); + +test(() => { + const c = ctx(); + c.font = "20px sans-serif"; + const baseline = c.measureText("Hello").width; + c.letterSpacing = "0.5em"; + const wider = c.measureText("Hello").width; + assertCloseTo( + wider - baseline, + 40, + 0.5, + "em resolves against current font size", + ); +}, "letterSpacing in em units resolves against font size"); + +test(() => { + const c = ctx(); + c.font = "20px sans-serif"; + c.fontStretch = "condensed"; + const m = c.measureText("Hello"); + assertGreaterThan(m.width, 0); +}, "fontStretch does not break measureText when face is unavailable"); + +report(); diff --git a/tests/js/canvas/transforms.test.js b/tests/js/canvas/transforms.test.js new file mode 100644 index 00000000..4a471551 --- /dev/null +++ b/tests/js/canvas/transforms.test.js @@ -0,0 +1,83 @@ +// deno-lint-ignore-file no-undef +import "./_harness.js"; +const { test, assertCloseTo, assertEqual, report } = globalThis.canvasHarness; + +function ctx() { + return new OffscreenCanvas(64, 64).getContext("2d"); +} + +function approxIdentity(m) { + assertCloseTo(m.a, 1, 1e-6, "a"); + assertCloseTo(m.b, 0, 1e-6, "b"); + assertCloseTo(m.c, 0, 1e-6, "c"); + assertCloseTo(m.d, 1, 1e-6, "d"); + assertCloseTo(m.e, 0, 1e-6, "e"); + assertCloseTo(m.f, 0, 1e-6, "f"); +} + +test(() => { + const c = ctx(); + approxIdentity(c.getTransform()); +}, "getTransform returns identity by default"); + +test(() => { + const c = ctx(); + c.translate(10, 20); + c.scale(2, 3); + const m = c.getTransform(); + assertCloseTo(m.a, 2, 1e-6); + assertCloseTo(m.d, 3, 1e-6); + assertCloseTo(m.e, 10, 1e-6); + assertCloseTo(m.f, 20, 1e-6); +}, "translate then scale composes correctly"); + +test(() => { + const c = ctx(); + c.translate(5, 5); + c.save(); + c.translate(20, 30); + c.restore(); + const m = c.getTransform(); + assertCloseTo(m.e, 5, 1e-6); + assertCloseTo(m.f, 5, 1e-6); +}, "save/restore preserves transform"); + +test(() => { + const c = ctx(); + c.translate(10, 0); + c.resetTransform(); + approxIdentity(c.getTransform()); +}, "resetTransform restores identity"); + +test(() => { + const c = ctx(); + c.setTransform(2, 0, 0, 2, 5, 7); + const m = c.getTransform(); + assertCloseTo(m.a, 2, 1e-6); + assertCloseTo(m.d, 2, 1e-6); + assertCloseTo(m.e, 5, 1e-6); + assertCloseTo(m.f, 7, 1e-6); +}, "setTransform writes the full matrix"); + +test(() => { + const c = ctx(); + c.translate(1, 2); + c.rotate(Math.PI / 2); + const m = c.getTransform(); + assertCloseTo(Math.abs(m.a), 0, 1e-6); + assertCloseTo(Math.abs(m.d), 0, 1e-6); + assertCloseTo(m.e, 1, 1e-6); + assertCloseTo(m.f, 2, 1e-6); +}, "rotate after translate keeps origin"); + +test(() => { + const c = ctx(); + assertEqual(typeof c.transform, "function"); + c.transform(1, 0, 0, 1, 5, 5); + c.transform(1, 0, 0, 1, 5, 5); + const m = c.getTransform(); + assertCloseTo(m.e, 10, 1e-6); + assertCloseTo(m.f, 10, 1e-6); +}, "transform composes successively"); + +report(); diff --git a/types/global.d.ts b/types/global.d.ts index e873a2ad..04df7bf3 100644 --- a/types/global.d.ts +++ b/types/global.d.ts @@ -769,6 +769,30 @@ type CanvasTextBaseline = | "ideographic" | "bottom"; type CanvasDirection = "ltr" | "rtl" | "inherit"; +type CanvasFontKerning = "auto" | "normal" | "none"; +type CanvasFontStretch = + | "ultra-condensed" + | "extra-condensed" + | "condensed" + | "semi-condensed" + | "normal" + | "semi-expanded" + | "expanded" + | "extra-expanded" + | "ultra-expanded"; +type CanvasFontVariantCaps = + | "normal" + | "small-caps" + | "all-small-caps" + | "petite-caps" + | "all-petite-caps" + | "unicase" + | "titling-caps"; +type CanvasTextRendering = + | "auto" + | "optimizeSpeed" + | "optimizeLegibility" + | "geometricPrecision"; type ImageSmoothingQuality = "low" | "medium" | "high"; type CanvasPatternRepetition = | "repeat" @@ -876,6 +900,20 @@ declare class CanvasRenderingContext2D { textBaseline: CanvasTextBaseline; /** Gets or sets the text direction. Default `"inherit"`. */ direction: CanvasDirection; + /** Letter spacing as a CSS `` (e.g. `"2px"`, `"0.5em"`). Default `"0px"`. */ + letterSpacing: string; + /** Word spacing as a CSS ``. Default `"0px"`. */ + wordSpacing: string; + /** Font kerning. Default `"auto"`. */ + fontKerning: CanvasFontKerning; + /** Font stretch (one of the nine CSS keywords). Default `"normal"`. */ + fontStretch: CanvasFontStretch; + /** Font variant caps. Default `"normal"`. */ + fontVariantCaps: CanvasFontVariantCaps; + /** Text rendering hint. Default `"auto"`. */ + textRendering: CanvasTextRendering; + /** BCP 47 language tag, or `"inherit"`. Default `"inherit"`. */ + lang: string; /** Resets the context to its default state per HTML spec. */ reset(): void; diff --git a/types/internals.d.ts b/types/internals.d.ts index 9a59c05a..94e82c67 100644 --- a/types/internals.d.ts +++ b/types/internals.d.ts @@ -1082,6 +1082,21 @@ declare namespace __andromeda__ { */ export function internal_canvas_get_direction(rid: number): string; + export function internal_canvas_set_letter_spacing(rid: number, value: string): void; + export function internal_canvas_get_letter_spacing(rid: number): string; + export function internal_canvas_set_word_spacing(rid: number, value: string): void; + export function internal_canvas_get_word_spacing(rid: number): string; + export function internal_canvas_set_font_kerning(rid: number, value: string): void; + export function internal_canvas_get_font_kerning(rid: number): string; + export function internal_canvas_set_font_stretch(rid: number, value: string): void; + export function internal_canvas_get_font_stretch(rid: number): string; + export function internal_canvas_set_font_variant_caps(rid: number, value: string): void; + export function internal_canvas_get_font_variant_caps(rid: number): string; + export function internal_canvas_set_text_rendering(rid: number, value: string): void; + export function internal_canvas_get_text_rendering(rid: number): string; + export function internal_canvas_set_lang(rid: number, value: string): void; + export function internal_canvas_get_lang(rid: number): string; + /** * The `internal_canvas_measure_text` function measures the dimensions of the specified text. * Returns a JSON string containing TextMetrics data with all measurement properties.