diff --git a/.git-plop.toml b/.git-plop.toml new file mode 100644 index 0000000..03a74d7 --- /dev/null +++ b/.git-plop.toml @@ -0,0 +1,115 @@ +[branch] +name = "{{name | slugify}}" + +[branch.validation] + +[[branch.fields]] +name = "name" +required = true +type = "text" + +[branch.fields.validation] +min_length = 3 + +[commit] +title = """ +{{type}} +{%- if scope -%} + ({{scope}}) +{%- endif -%} +{%- if breaking -%} + ! +{%- endif -%} +: {{subject | first_upper | trim_end_matches(pat='.') }}""" +description = "{{description | wrap(size=72) }}" +auto_signed = false +auto_add = false + +[commit.validation] +# pattern = "^(feat|fix|docs|style|refactor|perf|test|build|chore|revert)(\\(.\\))?!?: (.){1, 72}$" + +[[commit.fields]] +name = "type" +prompt = "Select the type of change that you're committing" +required = true +type = "list" +allow_custom_value = false + +[[commit.fields.values]] +value = "feat" +description = "โœจ a new feature" + +[[commit.fields.values]] +value = "fix" +description = "๐Ÿ› a bug fix" + +[[commit.fields.values]] +value = "docs" +description = "๐Ÿ“ documentation" + +[[commit.fields.values]] +value = "style" +description = "๐ŸŽจ code-stylish change like formatting" + +[[commit.fields.values]] +value = "refactor" +description = "โ™ป๏ธ code refactoring" + +[[commit.fields.values]] +value = "perf" +description = "โšก๏ธ performance improvements" + +[[commit.fields.values]] +value = "test" +description = "๐Ÿงช adding missing test or correct existing tests" + +[[commit.fields.values]] +value = "build" +description = "๐Ÿ‘ท changes related to project build" + +[[commit.fields.values]] +value = "ci" +description = "๐Ÿ’š changes related to CI/CD" + +[[commit.fields.values]] +value = "chore" +description = "๐Ÿ”ง other changes" + +[[commit.fields.values]] +value = "revert" +description = "โช revert a previous commit" + +[[commit.fields]] +name = "breaking" +prompt = "Are there any breaking changes?" +default = false +required = true +type = "confirm" +affirmative = "Yes ๐Ÿ’ฅ" +negative = "No" + +[[commit.fields]] +name = "scope" +prompt = "What is the scope of this change?" +description = "\te.g. component or file name" +required = false +type = "text" + +[commit.fields.validation] + +[[commit.fields]] +name = "subject" +prompt = "If applied, this commit will ..." +description = "Short, imperative tense description of the change" +required = true +type = "text" + +[commit.fields.validation] + +[[commit.fields]] +name = "description" +prompt = "Description of the change" +required = false +type = "text" + +[commit.fields.validation] diff --git a/.gitignore b/.gitignore index dc0d833..2c38172 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,78 @@ +# Rust build artifacts target/ +# macOS system files +.DS_Store +.AppleDouble +.LSOverride +Icon +._* +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent +.AppleDB +.AppleDesktop + +# Xcode user-specific files +**/xcuserdata/ +**/*.xcuserstate +**/*.xcbkptlist +**/*.xcscheme +**/xcshareddata/WorkspaceSettings.xcsettings +**/project.xcworkspace/xcshareddata/ +**/project.xcworkspace/xcuserdata/ + +# Swift Package Manager +.swiftpm/ +.build/ +**/Package.resolved + +# iOS build artifacts +*.ipa +*.dSYM +*.dSYM.zip +DerivedData/ +build/ + +# Simulator and device logs +simulator_logs/ +device_logs/ + +# Provisioning profiles +*.mobileprovision +*.provisionprofile + +# General development temp files +*.swp +*.swo +*~ +.tmp +*.tmp +*.bak +*.orig + +# IDE files +.vscode/ +.idea/ +*.sublime-* + +# Log files +*.log + +# Backup and temporary files with conflict markers +.!*!* + +# UniFFI generated files (should be regenerated during build) +**/TobogganCore.swift +**/tobogganFFI.h +**/tobogganFFI.modulemap +**/libtoboggan_ios_core.a +**/*.xcframework + +idea.md +*.local.* +TODO.md diff --git a/.mise-tasks/build/ios b/.mise-tasks/build/ios new file mode 100755 index 0000000..0c10f85 --- /dev/null +++ b/.mise-tasks/build/ios @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +#MISE description="Build iOS application with Rust core and Swift bindings and configure Xcode project" + +set -eu + +# Build for iOS targets +echo "๐Ÿ”จ Building Rust library for iOS targets..." + +# Add iOS targets if not already installed +rustup target add aarch64-apple-ios +rustup target add x86_64-apple-ios +rustup target add aarch64-apple-ios-sim + +echo "๐Ÿ“ฑ Building for iOS device (aarch64-apple-ios)..." +cargo build -p toboggan-ios --release --target aarch64-apple-ios + +# Build for iOS simulator (x86_64 and arm64) +echo "๐Ÿ–ฅ๏ธ Building for iOS Simulator (x86_64-apple-ios)..." +cargo build -p toboggan-ios --release --target x86_64-apple-ios + +echo "๐Ÿ–ฅ๏ธ Building for iOS Simulator (aarch64-apple-ios-sim)..." +cargo build -p toboggan-ios --release --target aarch64-apple-ios-sim + +echo "๐Ÿ› ๏ธ Building the uniffi-bindgen helper..." +cargo build -p toboggan-ios --release --bin uniffi-bindgen + + +echo "๐ŸŽ Now you can build in XCode the iOS application" \ No newline at end of file diff --git a/.mise-tasks/build/wasm b/.mise-tasks/build/wasm new file mode 100755 index 0000000..d5df4c9 --- /dev/null +++ b/.mise-tasks/build/wasm @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +#MISE description="Auto-format code and apply clippy suggested fixes" + +set -eu + +RUST_LOG="info,wasm_pack=warn" +WASM_PROJECT="toboggan-web/toboggan-wasm" + +# Build WASM +wasm-pack build --target web --no-pack --no-opt --release --out-dir pkg $WASM_PROJECT + +# Try to compress +wasm-opt --enable-bulk-memory --enable-nontrapping-float-to-int -Os -o $WASM_PROJECT/pkg/toboggan_wasm_bg.wasm $WASM_PROJECT/pkg/toboggan_wasm_bg.wasm + +# Show file size +echo "Build complete! File sizes:" +ls -lh $WASM_PROJECT/pkg/toboggan_wasm_bg*.wasm +echo "Gzipped size:" +gzip -c $WASM_PROJECT/pkg/toboggan_wasm_bg.wasm | wc -c | awk '{print $1/1024 " KB"}' diff --git a/.mise-tasks/check b/.mise-tasks/check deleted file mode 100755 index bf3aa0f..0000000 --- a/.mise-tasks/check +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Run comprehensive pre-push checks: format, lint, and test" - -set -eux - -# Check formatting -cargo fmt --all --check - -# Check no lint warning -cargo clippy --all-targets --all-features -- -D warnings - -# Run tests -cargo nextest run - -# Run doc tests -cargo test --doc - diff --git a/.mise-tasks/check/_default b/.mise-tasks/check/_default new file mode 100755 index 0000000..99d2021 --- /dev/null +++ b/.mise-tasks/check/_default @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +#MISE description="Run comprehensive pre-push checks: Rust, web, and iOS" + +set -eu + +echo "๐Ÿš€ Running comprehensive checks for all platforms..." + +# Run Rust checks +echo "==================== RUST CHECKS ====================" +mise check:rust + +# Run web checks +echo "==================== WEB CHECKS =====================" +mise check:web + +# Run iOS checks +echo "==================== iOS CHECKS =====================" +mise check:ios + +echo "" +echo "๐ŸŽ‰ All platform checks completed successfully!" +echo "โœ… Rust: formatting, linting, unit tests, doctests" +echo "โœ… Web: Biome linting and formatting" +echo "โœ… iOS: SwiftLint analysis" +echo "" +echo "Ready for commit! ๐Ÿš€" \ No newline at end of file diff --git a/.mise-tasks/check/ios b/.mise-tasks/check/ios new file mode 100755 index 0000000..20c70f4 --- /dev/null +++ b/.mise-tasks/check/ios @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +#MISE description="Run iOS-specific checks with SwiftLint" + +set -eu + +echo "๐Ÿ“ฑ Running iOS checks..." + +# Check Swift code with SwiftLint +if command -v swiftlint >/dev/null 2>&1; then + echo "๐Ÿ” Checking Swift code with SwiftLint..." + if [ -d "TobogganApp" ]; then + if swiftlint lint --config .swiftlint.yml; then + echo "โœ… SwiftLint check completed successfully" + else + echo "โš ๏ธ SwiftLint check failed or SourceKit unavailable, skipping Swift linting" + echo "Note: This may happen in CI environments or when Xcode tools are not properly configured" + fi + else + echo "โ„น๏ธ No Swift code found in TobogganApp, skipping SwiftLint check" + fi +else + echo "โš ๏ธ SwiftLint not found, skipping Swift linting (install with: mise install swiftlint)" +fi + +echo "โœ… All iOS checks completed!" \ No newline at end of file diff --git a/.mise-tasks/check/rust b/.mise-tasks/check/rust new file mode 100755 index 0000000..cac990f --- /dev/null +++ b/.mise-tasks/check/rust @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +#MISE description="Run Rust-specific checks: format, lint, and test" + +set -eu + +echo "๐Ÿฆ€ Running Rust checks..." + +# Check formatting +echo "๐Ÿ“ Checking Rust formatting..." +cargo +nightly fmt --all --check + +# Check no lint warning +echo "๐Ÿ” Running Rust clippy..." +cargo clippy --all-targets --all-features -- -D warnings + +# Run tests +echo "๐Ÿงช Running Rust unit tests..." +cargo nextest run + +# Run doc tests +echo "๐Ÿ“š Running Rust doctests..." +cargo test --doc + +echo "โœ… All Rust checks passed!" \ No newline at end of file diff --git a/.mise-tasks/check/web b/.mise-tasks/check/web new file mode 100755 index 0000000..28f40ea --- /dev/null +++ b/.mise-tasks/check/web @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +#MISE description="Run web-specific checks with Biome" + +set -eu + +echo "๐ŸŒ Running web checks..." + +# Check toboggan-web with Biome +echo "๐Ÿ”ง Checking toboggan-web with Biome..." +cd toboggan-web && npm run check && cd .. + +echo "โœ… All web checks passed!" \ No newline at end of file diff --git a/.mise-tasks/fix b/.mise-tasks/fix deleted file mode 100755 index 8ea2ea0..0000000 --- a/.mise-tasks/fix +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Auto-format code and apply clippy suggested fixes" - -set -eux - -# Format -cargo fmt - -# Fix lint -cargo fix --allow-dirty --allow-staged diff --git a/.mise-tasks/fix/_default b/.mise-tasks/fix/_default new file mode 100755 index 0000000..0c096dd --- /dev/null +++ b/.mise-tasks/fix/_default @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +#MISE description="Auto-fix code for all platforms: Rust, web, and iOS" + +set -eu + +echo "๐Ÿš€ Running comprehensive auto-fixes for all platforms..." + +# Run Rust fixes +echo "==================== RUST FIXES =====================" +mise fix:rust + +# Run web fixes +echo "==================== WEB FIXES ======================" +mise fix:web + +# Run iOS fixes +echo "==================== iOS FIXES ======================" +mise fix:ios + +echo "" +echo "๐ŸŽ‰ All platform auto-fixes completed successfully!" +echo "โœ… Rust: formatting, clippy fixes" +echo "โœ… Web: Biome auto-fixes" +echo "โœ… iOS: SwiftLint auto-fixes" +echo "" +echo "Code has been auto-fixed! ๐Ÿ› ๏ธ" \ No newline at end of file diff --git a/.mise-tasks/fix/ios b/.mise-tasks/fix/ios new file mode 100755 index 0000000..e7dfc6e --- /dev/null +++ b/.mise-tasks/fix/ios @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +#MISE description="Auto-fix iOS Swift code with SwiftLint" + +set -eu + +echo "๐Ÿ“ฑ Running iOS auto-fixes..." + +# Fix Swift code with SwiftLint +if command -v swiftlint >/dev/null 2>&1; then + echo "๐Ÿ› ๏ธ Auto-fixing Swift code with SwiftLint..." + if [ -d "TobogganApp" ]; then + if swiftlint --fix --config .swiftlint.yml; then + echo "โœ… SwiftLint auto-fix completed successfully" + else + echo "โš ๏ธ SwiftLint auto-fix failed or SourceKit unavailable, skipping Swift auto-fix" + echo "Note: This may happen in CI environments or when Xcode tools are not properly configured" + fi + else + echo "โ„น๏ธ No Swift code found in TobogganApp, skipping SwiftLint fix" + fi +else + echo "โš ๏ธ SwiftLint not found, skipping Swift auto-fix (install with: mise install swiftlint)" +fi + +echo "โœ… All iOS auto-fixes completed!" \ No newline at end of file diff --git a/.mise-tasks/fix/rust b/.mise-tasks/fix/rust new file mode 100755 index 0000000..3db5b00 --- /dev/null +++ b/.mise-tasks/fix/rust @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +#MISE description="Auto-format Rust code and apply clippy suggested fixes" + +set -eu + +echo "๐Ÿ”ง Running Rust auto-fixes..." + +# Format code +echo "๐Ÿ“ Auto-formatting Rust code..." +cargo +nightly fmt +pushd ./toboggan-web/toboggan-wasm +cargo +nightly fmt +popd + +# Apply clippy suggestions +echo "๐Ÿ› ๏ธ Applying clippy auto-fixes..." +cargo fix --allow-dirty --allow-staged +pushd ./toboggan-web/toboggan-wasm +cargo fix --allow-dirty --allow-staged +popd + +echo "โœ… All Rust auto-fixes applied!" \ No newline at end of file diff --git a/.mise-tasks/fix/web b/.mise-tasks/fix/web new file mode 100755 index 0000000..fc11d39 --- /dev/null +++ b/.mise-tasks/fix/web @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +#MISE description="Auto-fix web code with Biome" + +set -eu + +echo "๐ŸŒ Running web auto-fixes..." + +# Fix toboggan-web with Biome +echo "๐Ÿ”ง Auto-fixing toboggan-web with Biome..." +cd toboggan-web && npm run check:fix && cd .. + +echo "โœ… All web auto-fixes applied!" \ No newline at end of file diff --git a/.mise-tasks/lint b/.mise-tasks/lint deleted file mode 100755 index 35151b9..0000000 --- a/.mise-tasks/lint +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Run clippy linting with strict warnings as errors" - -set -eux - -# Tests -cargo clippy --all-targets --all-features -- -D warnings diff --git a/.mise-tasks/lint/_default b/.mise-tasks/lint/_default new file mode 100755 index 0000000..bf47489 --- /dev/null +++ b/.mise-tasks/lint/_default @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +#MISE description="Run linting for all platforms: Rust, web, and iOS" + +set -eu + +echo "๐Ÿš€ Running comprehensive linting for all platforms..." + +# Run Rust linting +echo "==================== RUST LINTING ===================" +mise lint:rust + +# Run web linting +echo "==================== WEB LINTING ====================" +mise lint:web + +# Run iOS linting +echo "==================== iOS LINTING ====================" +mise lint:ios + +echo "" +echo "๐ŸŽ‰ All platform linting completed successfully!" +echo "โœ… Rust: clippy checks" +echo "โœ… Web: Biome linting" +echo "โœ… iOS: SwiftLint analysis" +echo "" +echo "All linting checks passed! ๐Ÿ”" \ No newline at end of file diff --git a/.mise-tasks/lint/ios b/.mise-tasks/lint/ios new file mode 100755 index 0000000..44285ad --- /dev/null +++ b/.mise-tasks/lint/ios @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +#MISE description="Run iOS Swift linting with SwiftLint" + +set -eu + +echo "๐Ÿ“ฑ Running iOS linting..." + +# Lint Swift code with SwiftLint +if command -v swiftlint >/dev/null 2>&1; then + echo "๐Ÿ” Linting Swift code with SwiftLint..." + if [ -d "TobogganApp" ]; then + if swiftlint lint --config .swiftlint.yml; then + echo "โœ… SwiftLint check completed successfully" + else + echo "โš ๏ธ SwiftLint check failed or SourceKit unavailable, skipping Swift linting" + echo "Note: This may happen in CI environments or when Xcode tools are not properly configured" + fi + else + echo "โ„น๏ธ No Swift code found in TobogganApp, skipping SwiftLint check" + fi +else + echo "โš ๏ธ SwiftLint not found, skipping Swift linting (install with: mise install swiftlint)" +fi + +echo "โœ… All iOS linting checks completed!" \ No newline at end of file diff --git a/.mise-tasks/lint/rust b/.mise-tasks/lint/rust new file mode 100755 index 0000000..072d309 --- /dev/null +++ b/.mise-tasks/lint/rust @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +#MISE description="Run Rust clippy linting with strict warnings as errors" + +set -eu + +echo "๐Ÿ” Running Rust linting..." + +# Rust linting with clippy +echo "๐Ÿฆ€ Running clippy with strict warnings as errors..." +cargo clippy --all-targets --all-features -- -D warnings + +echo "โœ… All Rust linting checks passed!" \ No newline at end of file diff --git a/.mise-tasks/lint/web b/.mise-tasks/lint/web new file mode 100755 index 0000000..ffeebeb --- /dev/null +++ b/.mise-tasks/lint/web @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +#MISE description="Run web linting checks with Biome" + +set -eu + +echo "๐ŸŒ Running web linting..." + +# Lint toboggan-web with Biome (check without fixing) +echo "๐Ÿ”ง Checking toboggan-web with Biome (lint-only)..." +cd toboggan-web && npm run check && cd .. + +echo "โœ… All web linting checks passed!" \ No newline at end of file diff --git a/.mise-tasks/openapi b/.mise-tasks/openapi new file mode 100755 index 0000000..99a1728 --- /dev/null +++ b/.mise-tasks/openapi @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +#MISE description="Generate and lint OpenAPI specification" + +set -eux + +# Run OpenAPI generation driven by tests +cargo test --test generate_openapi -- should_generate_openapi --exact + +# Lint generated openapi.json using npx +npx --yes @stoplight/spectral-cli lint toboggan-server/openapi.json diff --git a/.mise-tasks/serve b/.mise-tasks/serve new file mode 100755 index 0000000..8f3be55 --- /dev/null +++ b/.mise-tasks/serve @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +#MISE description="Launch the backend in dev mode" + +set -eu + +# Ensure web frontend is built first (required for server) +echo "๐ŸŒ Ensuring web frontend is built..." +mise build:web + +echo "๐Ÿš€ Starting server in dev mode..." +bacon run-long -- -p toboggan-server -- --public-dir ../riir/public/ --watch ../riir/riir.toml diff --git a/.mise-tasks/test b/.mise-tasks/test deleted file mode 100755 index b7ce8cf..0000000 --- a/.mise-tasks/test +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Run all tests using nextest and include doctests" - -set -eux - -# Tests -cargo nextest run --no-fail-fast - -# Tests in docs -cargo test --doc - diff --git a/.mise-tasks/test/_default b/.mise-tasks/test/_default new file mode 100755 index 0000000..da1206d --- /dev/null +++ b/.mise-tasks/test/_default @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +#MISE description="Run tests for all platforms: Rust, web, and iOS" + +set -eu + +echo "๐Ÿš€ Running comprehensive tests for all platforms..." + +# Run Rust tests +echo "==================== RUST TESTS =====================" +mise test:rust + +# Run web tests +echo "==================== WEB TESTS ======================" +mise test:web + +# Run iOS tests +echo "==================== iOS TESTS ======================" +mise test:ios + +echo "" +echo "๐ŸŽ‰ All platform tests completed successfully!" +echo "โœ… Rust: nextest + doctests" +echo "โœ… Web: npm tests (if available)" +echo "โœ… iOS: xcodebuild tests (if available)" +echo "" +echo "All tests passed! ๐Ÿงช" \ No newline at end of file diff --git a/.mise-tasks/test/ios b/.mise-tasks/test/ios new file mode 100755 index 0000000..8879901 --- /dev/null +++ b/.mise-tasks/test/ios @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +#MISE description="Run iOS application tests to verify UniFFI integration" + +set -eu + +XCODE_PROJECT="TobogganApp" + +echo "๐Ÿ“ฑ Running iOS tests..." + +# Navigate to TobogganApp directory +if [ -d "$XCODE_PROJECT" ]; then + cd "$XCODE_PROJECT" + + PROJECT_PATH="$(pwd)/$XCODE_PROJECT.xcodeproj" + + echo "๐Ÿ” Running iOS unit tests to verify UniFFI integration..." + echo " ๐Ÿ“ Project: $PROJECT_PATH" + + # Check if xcodebuild is available + if command -v xcodebuild >/dev/null 2>&1; then + echo "๐Ÿงช Running iOS unit tests..." + if xcodebuild test -project "$XCODE_PROJECT.xcodeproj" -scheme "$XCODE_PROJECT" -destination 'platform=iOS Simulator,name=iPhone 16' -quiet; then + echo "โœ… iOS tests passed!" + else + echo "โš ๏ธ iOS tests failed or simulator unavailable" + echo "๐Ÿ’ก Manual test command:" + echo " xcodebuild test -project $XCODE_PROJECT.xcodeproj -scheme $XCODE_PROJECT -destination 'platform=iOS Simulator,name=iPhone 16'" + fi + else + echo "โš ๏ธ xcodebuild not found, skipping iOS tests" + echo "๐Ÿ’ก To run tests manually:" + echo " xcodebuild test -project $XCODE_PROJECT.xcodeproj -scheme $XCODE_PROJECT -destination 'platform=iOS Simulator,name=iPhone 16'" + fi + + cd .. +else + echo "โ„น๏ธ No TobogganApp directory found, skipping iOS tests" +fi + +echo "โœ… All iOS tests completed!" \ No newline at end of file diff --git a/.mise-tasks/test/rust b/.mise-tasks/test/rust new file mode 100755 index 0000000..5b03159 --- /dev/null +++ b/.mise-tasks/test/rust @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +#MISE description="Run Rust tests using nextest and doctests" + +set -eu + +echo "๐Ÿงช Running Rust tests..." + +# Run unit tests with nextest (faster parallel execution) +echo "๐Ÿฆ€ Running Rust unit tests with nextest..." +cargo nextest run --no-fail-fast + +# Run doctests +echo "๐Ÿ“š Running Rust doctests..." +cargo test --doc + +echo "โœ… All Rust tests passed!" \ No newline at end of file diff --git a/.mise-tasks/test/web b/.mise-tasks/test/web new file mode 100755 index 0000000..0df7903 --- /dev/null +++ b/.mise-tasks/test/web @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +#MISE description="Run web tests" + +set -eu + +echo "๐ŸŒ Running web tests..." + +# Check if toboggan-web has test script +echo "๐Ÿ”ง Checking for web tests..." +cd toboggan-web +if npm run | grep -q "test"; then + echo "๐Ÿงช Running web tests..." + npm run test +else + echo "โ„น๏ธ No test script found in toboggan-web package.json, skipping web tests" +fi +cd .. + +echo "โœ… All web tests completed!" \ No newline at end of file diff --git a/.mise.toml b/.mise.toml index 5d07529..39ce4fa 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,9 +1,21 @@ [env] RUST_LOG = "info" +TOBOGGAN_HOST = "0.0.0.0" + [settings] experimental = true cargo = { binstall = true } [tools] +"cargo:bacon" = "latest" +"cargo:cargo-machete" = "latest" "cargo:cargo-nextest" = "0.9" +"cargo:wasm-pack" = "latest" +"npm:rolldown-vite" = "latest" +"npm:vite" = "latest" +"npm:wasm-opt" = "latest" +swiftlint = "latest" +"pipx:esptool" = "latest" +"cargo:espup" = "latest" +"npm:@stoplight/spectral-cli" = "latest" diff --git a/.spectral.yaml b/.spectral.yaml new file mode 100644 index 0000000..2b48fc6 --- /dev/null +++ b/.spectral.yaml @@ -0,0 +1,10 @@ +extends: ["spectral:oas"] + +# Override rules to handle WebSocket endpoints +overrides: + - files: + - "**openapi.json" + rules: + # WebSocket endpoints don't have traditional HTTP responses + oas3-schema: warn + operation-success-response: warn diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..99a0ce1 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,141 @@ +# SwiftLint Configuration for Toboggan Project + +# File patterns to include +included: + - TobogganApp/TobogganApp + - TobogganApp/TobogganAppTests + - TobogganApp/TobogganAppUITests + +# File patterns to exclude +excluded: + - TobogganApp/TobogganApp.xcodeproj + - TobogganApp/TobogganApp.xcworkspace + - TobogganApp/TobogganApp/toboggan.swift # Auto-generated UniFFI bindings + - target + - .build + - build + +# Rules to disable +disabled_rules: + - trailing_whitespace # Handled by other formatters + +# Rules to enable (opt-in rules) +opt_in_rules: + - array_init + - attributes + - closure_end_indentation + - closure_spacing + - collection_alignment + - contains_over_filter_count + - contains_over_filter_is_empty + - contains_over_first_not_nil + - convenience_type + - empty_collection_literal + - empty_count + - empty_string + - explicit_init + - fallthrough + - fatal_error_message + - first_where + - force_unwrapping + - function_default_parameter_at_end + - identical_operands + - implicit_return + - joined_default_parameter + - last_where + - legacy_random + - literal_expression_end_indentation + - lower_acl_than_parent + - modifier_order + - multiline_arguments + - multiline_function_chains + - multiline_literal_brackets + - multiline_parameters + - multiline_parameters_brackets + - operator_usage_whitespace + - overridden_super_call + - override_in_extension + - pattern_matching_keywords + - prefer_self_type_over_type_of_self + - prefer_zero_over_explicit_init + - prefixed_toplevel_constant + - prohibited_super_call + - reduce_into + - redundant_nil_coalescing + - redundant_type_annotation + - sorted_first_last + - sorted_imports + - static_operator + - toggle_bool + - trailing_closure + - unavailable_function + - unneeded_parentheses_in_closure_argument + - unowned_variable_capture + - untyped_error_in_catch + - vertical_parameter_alignment_on_call + - vertical_whitespace_closing_braces + - vertical_whitespace_opening_braces + - yoda_condition + +# Line length configuration +line_length: + warning: 120 + error: 150 + ignores_function_declarations: true + ignores_comments: true + ignores_urls: true + +# Function body length +function_body_length: + warning: 60 + error: 100 + +# Function parameter count +function_parameter_count: + warning: 8 + error: 10 + +# Type body length +type_body_length: + warning: 300 + error: 400 + +# File length +file_length: + warning: 400 + error: 500 + +# Type name configuration +type_name: + min_length: 2 + max_length: 40 + +# Identifier name configuration +identifier_name: + min_length: 2 + max_length: 40 + excluded: + - id + - to + - vm + +# Nesting configuration +nesting: + type_level: + warning: 2 + error: 3 + function_level: + warning: 5 + error: 7 + +# Cyclomatic complexity +cyclomatic_complexity: + warning: 10 + error: 15 + +# Force unwrapping configuration +force_unwrapping: + severity: warning + +# Reporter configuration (for CI/CD) +reporter: "github-actions-logging" \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index d2d440e..1ff933b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,25 +3,7525 @@ version = 4 [[package]] -name = "toboggan-core" -version = "0.1.0" +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] [[package]] -name = "toboggan-desktop" -version = "0.1.0" +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] -name = "toboggan-esp32" -version = "0.1.0" +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] [[package]] -name = "toboggan-ios" -version = "0.1.0" +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] -name = "toboggan-server" -version = "0.1.0" +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-activity" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef6978589202a00cd7e118380c448a08b6ed394c3a8df3a430d0898e3a42d046" +dependencies = [ + "android-properties", + "bitflags 2.10.0", + "cc", + "cesu8", + "jni", + "jni-sys", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys 0.6.0+11769913", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.60.2", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.37.3+1.3.251" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39e9c3835d686b0a6084ab4234fcd1b07dbf6e4767dce60874b12356a25ecd4a" +dependencies = [ + "libloading 0.7.4", +] + +[[package]] +name = "askama" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4744ed2eef2645831b441d8f5459689ade2ab27c854488fbab1fbe94fce1a7" +dependencies = [ + "askama_derive", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d661e0f57be36a5c14c48f78d09011e67e0cb618f269cca9f2fd8d15b68c46ac" +dependencies = [ + "askama_parser", + "basic-toml", + "memchr", + "proc-macro2", + "quote", + "rustc-hash 2.1.1", + "serde", + "serde_derive", + "syn 2.0.109", +] + +[[package]] +name = "askama_parser" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf315ce6524c857bb129ff794935cf6d42c82a6cff60526fe2a63593de4d0d4f" +dependencies = [ + "memchr", + "serde", + "serde_derive", + "winnow", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.2", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.2", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.2", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a18ed336352031311f4e0b4dd2ff392d4fbb370777c9d18d7fc9d7359f73871" +dependencies = [ + "axum-core", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link 0.2.1", +] + +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "bincode_derive", + "serde", + "unty", +] + +[[package]] +name = "bincode_derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" +dependencies = [ + "virtue", +] + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec 0.6.3", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bon" +version = "3.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebeb9aaf9329dff6ceb65c689ca3db33dbf15f324909c60e4e5eef5701ce31b1" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77e9d642a7e3a318e37c2c9427b5a6a48aa1ad55dcd986f3034ab2239045a645" +dependencies = [ + "darling 0.21.3", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.109", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.10.0", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb9f6e1368bd4621d2c86baa7e37de77a938adf5221e5dd3d6133340101b309e" +dependencies = [ + "bitflags 2.10.0", + "polling", + "rustix 1.1.2", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.3", + "rustix 1.1.2", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "camino" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "276a59bf2b2c967788139340c9f0c5b12d7fd6630315c15c217e559de85d2609" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.17", +] + +[[package]] +name = "caseless" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8" +dependencies = [ + "unicode-normalization", +] + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35900b6c8d709fb1d854671ae27aeaa9eec2f8b01b364e1619a40da3e6fe2afe" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link 0.2.1", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.5.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", + "terminal_size", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "clawspec-core" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c984daf11e2aee1144421f34c2d1d156f2a8e31f25b0814d77bea7874ffded19" +dependencies = [ + "backon", + "base64", + "cruet", + "derive_more", + "headers", + "http", + "indexmap", + "mime", + "percent-encoding", + "regex", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "slug", + "tokio", + "tracing", + "url", + "utoipa", + "uuid", + "zeroize", +] + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "clipboard_macos" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b7f4aaa047ba3c3630b080bb9860894732ff23e2aee290a418909aa6d5df38f" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + +[[package]] +name = "clipboard_wayland" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "003f886bc4e2987729d10c1db3424e7f80809f3fc22dbc16c685738887cb37b8" +dependencies = [ + "smithay-clipboard", +] + +[[package]] +name = "clipboard_x11" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4274ea815e013e0f9f04a2633423e14194e408a0576c943ce3d14ca56c50031c" +dependencies = [ + "thiserror 1.0.69", + "x11rb", +] + +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width 0.1.14", +] + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "com" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e17887fd17353b65b1b2ef1c526c83e26cd72e74f598a8dc1bee13a48f3d9f6" +dependencies = [ + "com_macros", +] + +[[package]] +name = "com_macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d375883580a668c7481ea6631fc1a8863e33cc335bf56bfad8d7e6d4b04b13a5" +dependencies = [ + "com_macros_support", + "proc-macro2", + "syn 1.0.109", +] + +[[package]] +name = "com_macros_support" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad899a1087a9296d5644792d7cb72b8e34c1bec8e7d4fbc002230169a6e8710c" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "comfy-table" +version = "7.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b03b7db8e0b4b2fdad6c551e634134e99ec000e5c8c3b6856c65e8bbaded7a3b" +dependencies = [ + "crossterm 0.29.0", + "unicode-segmentation", + "unicode-width 0.2.0", +] + +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "comrak" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13263e1b6ee0147fb4dce60678f8b9634deac47c9b87c3dd5cdb959cc0334d3" +dependencies = [ + "bon", + "caseless", + "clap", + "emojis", + "entities", + "fmt2io", + "jetscii", + "shell-words", + "syntect", + "typed-arena", + "unicode_categories", + "xdg", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "convert_case" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.10.1", + "core-graphics-types 0.2.0", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "cosmic-text" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59fd57d82eb4bfe7ffa9b1cec0c05e2fd378155b47f255a67983cb4afe0e80c2" +dependencies = [ + "bitflags 2.10.0", + "fontdb", + "log", + "rangemap", + "rayon", + "rustc-hash 1.1.0", + "rustybuzz", + "self_cell", + "swash", + "sys-locale", + "ttf-parser 0.21.1", + "unicode-bidi", + "unicode-linebreak", + "unicode-script", + "unicode-segmentation", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.10.0", + "crossterm_winapi", + "mio", + "parking_lot 0.12.5", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.10.0", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot 0.12.5", + "rustix 1.1.2", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "cruet" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7a9ae414b9768aada1b316493261653e41af05c9d2ccc9c504a8fc051c6a790" +dependencies = [ + "once_cell", + "regex", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] [[package]] -name = "toboggan-web" +name = "ctor-lite" version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f791803201ab277ace03903de1594460708d2d54df6053f2d9e82f592b19e3b" + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "d3d12" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e3d747f100290a1ca24b752186f61f6637e1deffe3bf6320de6fcb29510a307" +dependencies = [ + "bitflags 2.10.0", + "libloading 0.8.9", + "winapi", +] + +[[package]] +name = "dark-light" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a76fa97167fa740dcdbfe18e8895601e1bc36525f09b044e00916e717c03a3c" +dependencies = [ + "dconf_rs", + "detect-desktop-environment", + "dirs", + "objc", + "rust-ini", + "web-sys", + "winreg", + "zbus", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.109", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.109", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core 0.9.12", +] + +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + +[[package]] +name = "dconf_rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7046468a81e6a002061c01e6a7c83139daf91b11c30e66795b13217c2d885c8b" + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_more" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn 2.0.109", + "unicode-xid", +] + +[[package]] +name = "detect-desktop-environment" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21d8ad60dd5b13a4ee6bd8fa2d5d88965c597c67bce32b5fc49c94f55cb50810" + +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "dlib" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +dependencies = [ + "libloading 0.8.9", +] + +[[package]] +name = "dlv-list" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0688c2a7f92e427f44895cd63841bff7b29f8d7a1648b9e7e07a4a365b2e1257" + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "drm" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98888c4bbd601524c11a7ed63f814b8825f420514f78e96f752c437ae9cbb5d1" +dependencies = [ + "bitflags 2.10.0", + "bytemuck", + "drm-ffi", + "drm-fourcc", + "rustix 0.38.44", +] + +[[package]] +name = "drm-ffi" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97c98727e48b7ccb4f4aea8cfe881e5b07f702d17b7875991881b41af7278d53" +dependencies = [ + "drm-sys", + "rustix 0.38.44", +] + +[[package]] +name = "drm-fourcc" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aafbcdb8afc29c1a7ee5fbe53b5d62f4565b35a042a662ca9fecd0b54dae6f4" + +[[package]] +name = "drm-sys" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd39dde40b6e196c2e8763f23d119ddb1a8714534bf7d77fa97a65b0feda3986" +dependencies = [ + "libc", + "linux-raw-sys 0.6.5", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "emojis" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99e1f1df1f181f2539bac8bf027d31ca5ffbf9e559e3f2d09413b9107b5c02f4" +dependencies = [ + "phf", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endi" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" + +[[package]] +name = "entities" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "etagere" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc89bf99e5dc15954a60f707c1e09d7540e5cd9af85fa75caa0b510bc08c5342" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "euclid" +version = "0.22.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad9cdb4b747e485a12abb0e6566612956c7a1bafa3bdb8d682c5b6d403589e48" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" + +[[package]] +name = "flate2" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fmt2io" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b6129284da9f7e5296cc22183a63f24300e945e297705dcc0672f7df01d62c8" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "font-types" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3971f9a5ca983419cdc386941ba3b9e1feba01a0ab888adf78739feb2798492" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0299020c3ef3f60f526a4f64ab4a3d4ce116b1acbf24cdd22da0068e5d81dc3" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser 0.20.0", +] + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs-err" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" +dependencies = [ + "autocfg", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", + "num_cpus", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.2", + "windows-link 0.2.1", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glam" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "glow" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd348e04c43b32574f2de31c8bb397d96c9fcfa1371bd4ca6d8bdc464ab121b1" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8098adac955faa2d31079b65dc48841251f69efd3ac25477903fc424362ead" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "goblin" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" +dependencies = [ + "log", + "plain", + "scroll", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" +dependencies = [ + "bitflags 2.10.0", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "gpu-allocator" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f56f6318968d03c18e1bcf4857ff88c61157e9da8e47c5f29055d60e1228884" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "winapi", + "windows", +] + +[[package]] +name = "gpu-descriptor" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc11df1ace8e7e564511f53af41f3e42ddc95b56fd07b3f4445d2a6048bc682c" +dependencies = [ + "bitflags 2.10.0", + "gpu-descriptor-types", + "hashbrown 0.14.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bf0b36e6f090b7e1d8a4b49c0cb81c1f8376f72198c65dd3ad9ff3556b8b78c" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "guillotiere" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62d5865c036cb1393e23c50693df631d3f5d7bcca4c04fe4cc0fd592e74a782" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "h2" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash 0.8.12", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "hassle-rs" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af2a7e73e1f34c48da31fb668a907f250794837e08faa144fd24f0b8b741e890" +dependencies = [ + "bitflags 2.10.0", + "com", + "libc", + "libloading 0.8.9", + "thiserror 1.0.69", + "widestring", + "winapi", +] + +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "hyper" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec 1.15.1", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "iced" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88acfabc84ec077eaf9ede3457ffa3a104626d79022a9bf7f296093b1d60c73f" +dependencies = [ + "iced_core", + "iced_futures", + "iced_renderer", + "iced_widget", + "iced_winit", + "thiserror 1.0.69", +] + +[[package]] +name = "iced_core" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0013a238275494641bf8f1732a23a808196540dc67b22ff97099c044ae4c8a1c" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "dark-light", + "glam", + "log", + "num-traits", + "once_cell", + "palette", + "rustc-hash 2.1.1", + "smol_str", + "thiserror 1.0.69", + "web-time", +] + +[[package]] +name = "iced_futures" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c04a6745ba2e80f32cf01e034fd00d853aa4f4cd8b91888099cb7aaee0d5d7c" +dependencies = [ + "futures", + "iced_core", + "log", + "rustc-hash 2.1.1", + "tokio", + "wasm-bindgen-futures", + "wasm-timer", +] + +[[package]] +name = "iced_glyphon" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41c3bb56f1820ca252bc1d0994ece33d233a55657c0c263ea7cb16895adbde82" +dependencies = [ + "cosmic-text", + "etagere", + "lru", + "rustc-hash 2.1.1", + "wgpu", +] + +[[package]] +name = "iced_graphics" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba25a18cfa6d5cc160aca7e1b34f73ccdff21680fa8702168c09739767b6c66f" +dependencies = [ + "bitflags 2.10.0", + "bytemuck", + "cosmic-text", + "half", + "iced_core", + "iced_futures", + "log", + "once_cell", + "raw-window-handle", + "rustc-hash 2.1.1", + "thiserror 1.0.69", + "unicode-segmentation", +] + +[[package]] +name = "iced_renderer" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73558208059f9e622df2bf434e044ee2f838ce75201a023cf0ca3e1244f46c2a" +dependencies = [ + "iced_graphics", + "iced_tiny_skia", + "iced_wgpu", + "log", + "thiserror 1.0.69", +] + +[[package]] +name = "iced_runtime" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "348b5b2c61c934d88ca3b0ed1ed913291e923d086a66fa288ce9669da9ef62b5" +dependencies = [ + "bytes", + "iced_core", + "iced_futures", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "iced_tiny_skia" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c625d368284fcc43b0b36b176f76eff1abebe7959dd58bd8ce6897d641962a50" +dependencies = [ + "bytemuck", + "cosmic-text", + "iced_graphics", + "kurbo", + "log", + "rustc-hash 2.1.1", + "softbuffer", + "tiny-skia", +] + +[[package]] +name = "iced_wgpu" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15708887133671d2bcc6c1d01d1f176f43a64d6cdc3b2bf893396c3ee498295f" +dependencies = [ + "bitflags 2.10.0", + "bytemuck", + "futures", + "glam", + "guillotiere", + "iced_glyphon", + "iced_graphics", + "log", + "once_cell", + "rustc-hash 2.1.1", + "thiserror 1.0.69", + "wgpu", +] + +[[package]] +name = "iced_widget" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81429e1b950b0e4bca65be4c4278fea6678ea782030a411778f26fa9f8983e1d" +dependencies = [ + "iced_renderer", + "iced_runtime", + "num-traits", + "once_cell", + "rustc-hash 2.1.1", + "thiserror 1.0.69", + "unicode-segmentation", +] + +[[package]] +name = "iced_winit" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f44cd4e1c594b6334f409282937bf972ba14d31fedf03c23aa595d982a2fda28" +dependencies = [ + "iced_futures", + "iced_graphics", + "iced_runtime", + "log", + "rustc-hash 2.1.1", + "thiserror 1.0.69", + "tracing", + "wasm-bindgen-futures", + "web-sys", + "winapi", + "window_clipboard", + "winit", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec 1.15.1", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec 1.15.1", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +dependencies = [ + "equivalent", + "hashbrown 0.16.0", + "serde", + "serde_core", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "inotify" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" +dependencies = [ + "bitflags 2.10.0", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "instability" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435d80800b936787d62688c927b6490e887c7ef5ff9ce922c6c6050fca75eb9a" +dependencies = [ + "darling 0.20.11", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "jetscii" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47f142fe24a9c9944451e8349de0a56af5f3e7226dc46f3ed4d4ecc0b85af75e" + +[[package]] +name = "jiff" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49cce2b81f2098e7e3efc35bc2e0a6b7abec9d34128283d7a26fa8f32a6dbb35" +dependencies = [ + "jiff-static", + "js-sys", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "jiff-static" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "980af8b43c3ad5d8d349ace167ec8170839f753a42d233ba19e08afe1850fa69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading 0.8.9", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + +[[package]] +name = "kurbo" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1618d4ebd923e97d67e7cd363d80aef35fe961005cbbbb3d2dad8bdd1bc63440" +dependencies = [ + "arrayvec", + "smallvec 1.15.1", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + +[[package]] +name = "libredox" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +dependencies = [ + "bitflags 2.10.0", + "libc", + "redox_syscall 0.5.18", +] + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a385b1be4e5c3e362ad2ffa73c392e53f031eaa5b7d648e64cd87f27f6063d7" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lucide-icons" +version = "0.553.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8c6db94476c2580065b90ed1cc98931a4dbc7947dce951b17d3749c435314e9" +dependencies = [ + "iced", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "memmap2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "metal" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c43f73953f8cbe511f021b58f18c3ce1c3d1ae13fe953293e13345bf83217f25" +dependencies = [ + "bitflags 2.10.0", + "block", + "core-graphics-types 0.1.3", + "foreign-types 0.5.0", + "log", + "objc", + "paste", +] + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color 3.0.2", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "naga" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e3524642f53d9af419ab5e8dd29d3ba155708267667c2f3f06c88c9e130843" +dependencies = [ + "bit-set 0.5.3", + "bitflags 2.10.0", + "codespan-reporting", + "hexf-parse", + "indexmap", + "log", + "num-traits", + "rustc-hash 1.1.0", + "spirv", + "termcolor", + "thiserror 1.0.69", + "unicode-xid", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.10.0", + "jni-sys", + "log", + "ndk-sys 0.6.0+11769913", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases 0.2.1", + "libc", + "memoffset", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.10.0", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e0826a989adedc2a244799e823aece04662b66609d96af8dff7ac6df9a8925d" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_enum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", + "objc_exception", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.10.0", + "block2", + "libc", + "objc2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.10.0", + "block2", + "objc2", + "objc2-core-location", + "objc2-foundation", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.10.0", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2", + "objc2", + "objc2-contacts", + "objc2-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.10.0", + "block2", + "dispatch", + "libc", + "objc2", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.10.0", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.10.0", + "block2", + "objc2", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.10.0", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.10.0", + "block2", + "objc2", + "objc2-core-location", + "objc2-foundation", +] + +[[package]] +name = "objc_exception" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4" +dependencies = [ + "cc", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "onig" +version = "6.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" +dependencies = [ + "bitflags 2.10.0", + "libc", + "once_cell", + "onig_sys", +] + +[[package]] +name = "onig_sys" +version = "69.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "orbclient" +version = "0.3.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "247ad146e19b9437f8604c21f8652423595cf710ad108af40e77d3ae6e96b827" +dependencies = [ + "libredox", +] + +[[package]] +name = "ordered-multimap" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccd746e37177e1711c20dd619a1620f34f5c8b569c53590a72dedd5344d8924a" +dependencies = [ + "dlv-list", + "hashbrown 0.12.3", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser 0.25.1", +] + +[[package]] +name = "owo-colors" +version = "4.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" +dependencies = [ + "supports-color 2.1.0", + "supports-color 3.0.2", +] + +[[package]] +name = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "approx", + "fast-srgb8", + "palette_derive", + "phf", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core 0.8.6", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core 0.9.12", +] + +[[package]] +name = "parking_lot_core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" +dependencies = [ + "cfg-if", + "instant", + "libc", + "redox_syscall 0.2.16", + "smallvec 1.15.1", + "winapi", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec 1.15.1", + "windows-link 0.2.1", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.1", +] + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64", + "indexmap", + "quick-xml 0.38.3", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.109", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" + +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + +[[package]] +name = "quick-xml" +version = "0.38.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases 0.2.1", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.1", + "rustls", + "socket2", + "thiserror 2.0.17", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash 2.1.1", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases 0.2.1", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "range-alloc" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d6831663a5098ea164f89cff59c6284e95f4e3c76ce9848d4529f5ccca9bde" + +[[package]] +name = "rangemap" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93e7e49bb0bf967717f7bd674458b3d6b0c5f48ec7e3038166026a69fc22223" + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.10.0", + "cassowary", + "compact_str", + "crossterm 0.28.1", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "read-fonts" +version = "0.22.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69aacb76b5c29acfb7f90155d39759a29496aebb49395830e928a9703d2eec2f" +dependencies = [ + "bytemuck", + "font-types", +] + +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "reqwest" +version = "0.12.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmp" +version = "0.8.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "228ed7c16fa39782c3b3468e974aec2795e9089153cd08ee2e9aefb3613334c4" +dependencies = [ + "byteorder", + "num-traits", + "paste", +] + +[[package]] +name = "rmp-serde" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52e599a477cf9840e92f2cde9a7189e67b42c57532749bf90aea6ec10facd4db" +dependencies = [ + "byteorder", + "rmp", + "serde", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rust-embed" +version = "8.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "947d7f3fad52b283d261c4c99a084937e2fe492248cb9a68a8435a861b8798ca" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa2c8c9e8711e10f9c4fd2d64317ef13feaab820a4c51541f1a8c8e2e851ab2" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.109", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b161f275cb337fe0a44d924a5f4df0ed69c2c39519858f931ce61c779d3475" +dependencies = [ + "sha2", + "walkdir", +] + +[[package]] +name = "rust-ini" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6d5f2436026b4f6e79dc829837d467cc7e9a55ee40e750d716713540715a2df" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rustybuzz" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfb9cf8877777222e4a3bc7eb247e398b56baba500c38c1c46842431adc8b55c" +dependencies = [ + "bitflags 2.10.0", + "bytemuck", + "libm", + "smallvec 1.15.1", + "ttf-parser 0.21.1", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "saphyr-parser" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb771b59f6b1985d1406325ec28f97cfb14256abcec4fdfb37b36a1766d6af7" +dependencies = [ + "arraydeque", + "hashlink", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scroll" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit 0.19.2", + "tiny-skia", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "self_cell" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16c2f82143577edb4921b71ede051dac62ca3c16084e918bf7b40c96ae10eb33" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-saphyr" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd76af9505b2498740576f95f60b3b4e2c469b5b677a8d2dd1d2da18b58193de" +dependencies = [ + "base64", + "nohash-hasher", + "num-traits", + "ryu", + "saphyr-parser", + "serde", + "serde_json", + "smallvec 2.0.0-alpha.11", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_norway" +version = "0.9.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e408f29489b5fd500fab51ff1484fc859bb655f32c671f307dcd733b72e8168c" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml-norway", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "serde_spanned" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shell-words" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "skrifa" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1c44ad1f6c5bdd4eefed8326711b7dbda9ea45dfd36068c427d332aa382cbe" +dependencies = [ + "bytemuck", + "read-fonts", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "slotmap" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbff4acf519f630b3a3ddcfaea6c06b42174d9a44bc70c620e9ed1649d58b82a" +dependencies = [ + "version_check", +] + +[[package]] +name = "slug" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724" +dependencies = [ + "deunicode", + "wasm-bindgen", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smallvec" +version = "2.0.0-alpha.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87b96efa4bd6bdd2ff0c6615cc36fc4970cbae63cfd46ddff5cee35a1b4df570" + +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.10.0", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.10.0", + "calloop 0.14.3", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.2", + "thiserror 2.0.17", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08" +dependencies = [ + "as-raw-xcb-connection", + "bytemuck", + "cfg_aliases 0.2.1", + "core-graphics 0.24.0", + "drm", + "fastrand", + "foreign-types 0.5.0", + "js-sys", + "log", + "memmap2", + "objc2", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall 0.5.18", + "rustix 0.38.44", + "tiny-xlib", + "wasm-bindgen", + "wayland-backend", + "wayland-client", + "wayland-sys", + "web-sys", + "windows-sys 0.59.0", + "x11rb", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.109", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "supports-color" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6398cde53adc3c4557306a96ce67b302968513830a77a95b2b17305d9719a89" +dependencies = [ + "is-terminal", + "is_ci", +] + +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f44ed3c63152de6a9f90acbea1a110441de43006ea51bcce8f436196a288b" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + +[[package]] +name = "svg_fmt" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" + +[[package]] +name = "swash" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbd59f3f359ddd2c95af4758c18270eddd9c730dde98598023cdabff472c2ca2" +dependencies = [ + "skrifa", + "yazi", + "zeno", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f17c7e013e88258aa9543dcbe81aca68a667a9ac37cd69c9fbc07858bfe0e2f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "syntect" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" +dependencies = [ + "bincode 1.3.3", + "fancy-regex", + "flate2", + "fnv", + "once_cell", + "onig", + "plist", + "regex-syntax", + "serde", + "serde_derive", + "serde_json", + "thiserror 2.0.17", + "walkdir", + "yaml-rust", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix 1.1.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "terminal_size" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" +dependencies = [ + "rustix 1.1.2", + "windows-sys 0.60.2", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width 0.2.0", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tiny-xlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0324504befd01cab6e0c994f34b2ffa257849ee019d3fb3b64fb2c858887d89e" +dependencies = [ + "as-raw-xcb-connection", + "ctor-lite", + "libloading 0.8.9", + "pkg-config", + "tracing", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "toboggan-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "bincode 2.0.1", + "ciborium", + "clap", + "comfy-table", + "comrak", + "derive_more", + "humantime", + "miette", + "owo-colors", + "regex", + "rmp-serde", + "serde", + "serde-saphyr", + "serde_json", + "tempfile", + "toboggan-core", + "toml", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "toboggan-client" +version = "0.1.0" +dependencies = [ + "derive_more", + "futures", + "reqwest", + "serde", + "serde_json", + "toboggan-core", + "tokio", + "tokio-tungstenite", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "toboggan-core" +version = "0.1.0" +dependencies = [ + "derive_more", + "getrandom 0.3.4", + "humantime", + "jiff", + "serde", + "serde_json", + "tracing", + "utoipa", + "uuid", +] + +[[package]] +name = "toboggan-desktop" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-stream", + "iced", + "lucide-icons", + "toboggan-client", + "toboggan-core", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "toboggan-ios" +version = "0.1.0" +dependencies = [ + "toboggan-client", + "toboggan-core", + "tokio", + "uniffi", +] + +[[package]] +name = "toboggan-server" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "clap", + "clawspec-core", + "dashmap", + "derive_more", + "futures", + "mime_guess", + "notify", + "rust-embed", + "serde", + "serde_json", + "toboggan-core", + "tokio", + "tokio-tungstenite", + "toml", + "tower-http", + "tracing", + "tracing-subscriber", + "utoipa", + "utoipa-scalar", +] + +[[package]] +name = "toboggan-tui" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "crossterm 0.29.0", + "derive_more", + "ratatui", + "toboggan-client", + "toboggan-core", + "tokio", + "tracing", + "tracing-subscriber", + "tui-logger", +] + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot 0.12.5", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "iri-string", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec 1.15.1", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "ttf-parser" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4" + +[[package]] +name = "ttf-parser" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "tui-logger" +version = "0.17.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "382b7ea88082dbe2236ed1e942552b1bfc59e98fdc5d0599f11a627aae9ee2be" +dependencies = [ + "chrono", + "env_filter", + "lazy_static", + "log", + "parking_lot 0.12.5", + "ratatui", + "tracing", + "tracing-subscriber", + "unicode-segmentation", +] + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.2", + "sha1", + "thiserror 2.0.17", + "utf-8", +] + +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset", + "tempfile", + "winapi", +] + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cb788ffebc92c5948d0e997106233eeb1d8b9512f93f41651f52b6c5f5af86" + +[[package]] +name = "unicode-ccc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1df77b101bcc4ea3d78dafc5ad7e4f58ceffe0b2b16bf446aeb50b6cb4157656" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb421b350c9aff471779e262955939f565ec18b86c15364e6bdf0d662ca7c1f" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + +[[package]] +name = "uniffi" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c866f627c3f04c3df068b68bb2d725492caaa539dd313e2a9d26bb85b1a32f4e" +dependencies = [ + "anyhow", + "camino", + "cargo_metadata", + "clap", + "uniffi_bindgen", + "uniffi_build", + "uniffi_core", + "uniffi_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_bindgen" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c8ca600167641ebe7c8ba9254af40492dda3397c528cc3b2f511bd23e8541a5" +dependencies = [ + "anyhow", + "askama", + "camino", + "cargo_metadata", + "fs-err", + "glob", + "goblin", + "heck", + "indexmap", + "once_cell", + "serde", + "tempfile", + "textwrap", + "toml", + "uniffi_internal_macros", + "uniffi_meta", + "uniffi_pipeline", + "uniffi_udl", +] + +[[package]] +name = "uniffi_build" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e55c05228f4858bb258f651d21d743fcc1fe5a2ec20d3c0f9daefddb105ee4d" +dependencies = [ + "anyhow", + "camino", + "uniffi_bindgen", +] + +[[package]] +name = "uniffi_core" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7a5a038ebffe8f4cf91416b154ef3c2468b18e828b7009e01b1b99938089f9" +dependencies = [ + "anyhow", + "bytes", + "once_cell", + "static_assertions", +] + +[[package]] +name = "uniffi_internal_macros" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c2a6f93e7b73726e2015696ece25ca0ac5a5f1cf8d6a7ab5214dd0a01d2edf" +dependencies = [ + "anyhow", + "indexmap", + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "uniffi_macros" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64c6309fc36c7992afc03bc0c5b059c656bccbef3f2a4bc362980017f8936141" +dependencies = [ + "camino", + "fs-err", + "once_cell", + "proc-macro2", + "quote", + "serde", + "syn 2.0.109", + "toml", + "uniffi_meta", +] + +[[package]] +name = "uniffi_meta" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a138823392dba19b0aa494872689f97d0ee157de5852e2bec157ce6de9cdc22" +dependencies = [ + "anyhow", + "siphasher 0.3.11", + "uniffi_internal_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_pipeline" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c27c4b515d25f8e53cc918e238c39a79c3144a40eaf2e51c4a7958973422c29" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "tempfile", + "uniffi_internal_macros", +] + +[[package]] +name = "uniffi_udl" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0adacdd848aeed7af4f5af7d2f621d5e82531325d405e29463482becfdeafca" +dependencies = [ + "anyhow", + "textwrap", + "uniffi_meta", + "weedle2", +] + +[[package]] +name = "unsafe-libyaml-norway" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39abd59bf32521c7f2301b52d05a6a2c975b6003521cbd0c6dc1582f0a22104" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "utoipa" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "serde_norway", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", + "uuid", +] + +[[package]] +name = "utoipa-scalar" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59559e1509172f6b26c1cdbc7247c4ddd1ac6560fe94b584f81ee489b141f719" +dependencies = [ + "axum", + "serde", + "serde_json", + "utoipa", +] + +[[package]] +name = "uuid" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "virtue" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.109", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-timer" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be0ecb0db480561e9a7642b5d3e4187c128914e58aa84330b9493e3eb68c5e7f" +dependencies = [ + "futures", + "js-sys", + "parking_lot 0.11.2", + "pin-utils", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wayland-backend" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.2", + "scoped-tls", + "smallvec 1.15.1", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" +dependencies = [ + "bitflags 2.10.0", + "rustix 1.1.2", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.10.0", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "447ccc440a881271b19e9989f75726d60faa09b95b0200a9b7eb5cc47c3eeb29" +dependencies = [ + "rustix 1.1.2", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dfe33d551eb8bffd03ff067a8b44bb963919157841a99957151299a6307d19c" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a07a14257c077ab3279987c4f8bb987851bf57081b93710381daea94f2c2c032" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3" +dependencies = [ + "proc-macro2", + "quick-xml 0.37.5", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weedle2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" +dependencies = [ + "nom", +] + +[[package]] +name = "wgpu" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbd7311dbd2abcfebaabf1841a2824ed7c8be443a0f29166e5d3c6a53a762c01" +dependencies = [ + "arrayvec", + "cfg-if", + "cfg_aliases 0.1.1", + "js-sys", + "log", + "naga", + "parking_lot 0.12.5", + "profiling", + "raw-window-handle", + "smallvec 1.15.1", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28b94525fc99ba9e5c9a9e24764f2bc29bad0911a7446c12f446a8277369bf3a" +dependencies = [ + "arrayvec", + "bit-vec 0.6.3", + "bitflags 2.10.0", + "cfg_aliases 0.1.1", + "codespan-reporting", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot 0.12.5", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec 1.15.1", + "thiserror 1.0.69", + "web-sys", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-hal" +version = "0.19.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfabcfc55fd86611a855816326b2d54c3b2fd7972c27ce414291562650552703" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set 0.5.3", + "bitflags 2.10.0", + "block", + "cfg_aliases 0.1.1", + "core-graphics-types 0.1.3", + "d3d12", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "hassle-rs", + "js-sys", + "khronos-egl", + "libc", + "libloading 0.8.9", + "log", + "metal", + "naga", + "ndk-sys 0.5.0+25.2.9519653", + "objc", + "once_cell", + "parking_lot 0.12.5", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "rustc-hash 1.1.0", + "smallvec 1.15.1", + "thiserror 1.0.69", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "winapi", +] + +[[package]] +name = "wgpu-types" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b671ff9fb03f78b46ff176494ee1ebe7d603393f42664be55b64dc8d53969805" +dependencies = [ + "bitflags 2.10.0", + "js-sys", + "web-sys", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window_clipboard" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6d692d46038c433f9daee7ad8757e002a4248c20b0a3fbc991d99521d3bcb6d" +dependencies = [ + "clipboard-win", + "clipboard_macos", + "clipboard_wayland", + "clipboard_x11", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core 0.52.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winit" +version = "0.30.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66d4b9ed69c4009f6321f762d6e61ad8a2389cd431b97cb1e146812e9e6c732" +dependencies = [ + "ahash 0.8.12", + "android-activity", + "atomic-waker", + "bitflags 2.10.0", + "block2", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases 0.2.1", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics 0.23.2", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading 0.8.9", + "once_cell", + "rustix 1.1.2", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + +[[package]] +name = "xdg" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213b7324336b53d2414b2db8537e56544d981803139155afa84f76eeebb7a546" + +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.10.0", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "yazi" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c94451ac9513335b5e23d7a8a2b61a7102398b8cca5160829d313e84c9d98be1" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", + "synstructure", +] + +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand 0.8.5", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.109", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + +[[package]] +name = "zeno" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd15f8e0dbb966fd9245e7498c7e9e5055d9e5c8b676b95bd67091cd11a1e697" + +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.109", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] diff --git a/Cargo.toml b/Cargo.toml index ebd0010..a6fa33a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,15 +1,92 @@ [workspace] -members = ["toboggan-*"] +members = [ + "toboggan-core", + "toboggan-server", + "toboggan-cli", + "toboggan-client", + "toboggan-tui", + "toboggan-desktop", + "toboggan-ios", +] +exclude = ["toboggan-esp32", "toboggan-py"] resolver = "2" [workspace.package] +edition = "2024" rust-version = "1.88" license = "MIT OR Apache-2.0" authors = ["Igor Laborie "] +repository = "https://github.com/ilaborie/toboggan" + +[workspace.dependencies] +# Core dependencies +derive_more = { version = "2.0.1", default-features = false } +jiff = { version = "0.2.15", default-features = false } +serde = { version = "1.0.219", default-features = false } +uuid = { version = "1.18.1", default-features = false } +humantime = "2.1.0" +getrandom = "0.3.4" + +# Server dependencies +axum = "0.8.4" +tokio = "1.46.1" +toml = "0.9.8" +tracing = "0.1.41" +tracing-subscriber = "0.3.19" +clap = "4.5.51" +anyhow = "1.0.99" +serde_json = "1.0.140" +serde-saphyr = "0.0.7" +ciborium = "0.2.2" +rmp-serde = "1.3.0" +bincode = { version = "2.0.1", features = ["serde"] } +tower-http = "0.6.6" +futures = "0.3" +dashmap = "6.1.0" + +# OpenAPI dependencies +utoipa = "5.0" +utoipa-scalar = "0.3" + +# Dev dependencies +clawspec-core = "0.2.0" +tempfile = "3.22.0" + +# Other dependencies +comrak = "0.47.0" +miette = "7.6.0" +comfy-table = "7.1" + +# Desktop dependencies +iced = { version = "0.13.1", features = ["tokio", "advanced"] } +tokio-tungstenite = "0.28.0" +reqwest = { version = "0.12.23", features = ["json"] } +lucide-icons = "0.553.0" +async-stream = "0.3" + +# Other dependencies +regex = "1.11" + +# Server-specific dependencies +rust-embed = "8.9.0" +mime_guess = "2.0.5" +notify = "8.2.0" + +# CLI-specific dependencies +owo-colors = "4.1.0" + +# TUI-specific dependencies +ratatui = "0.29" +crossterm = "0.29.0" +tui-logger = "0.17.3" + +# iOS-specific dependencies +uniffi = "0.30.0" + [workspace.lints.rust] unsafe_code = "deny" -missing_docs = "warn" +missing_docs = "allow" [workspace.lints.clippy] perf = { level = "warn", priority = -1 } @@ -30,4 +107,5 @@ rc_mutex = "warn" unnecessary_safety_doc = "warn" unwrap_used = "warn" +missing-errors-doc = "allow" module_name_repetitions = "allow" diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..4b00e82 --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2025 Igor Laborie + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..b6a0b48 --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Igor Laborie + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..54937e8 --- /dev/null +++ b/README.md @@ -0,0 +1,281 @@ +# Toboggan ๐Ÿ›ท + +A modern, multi-platform presentation system built in Rust with real-time synchronization across devices. + +[![License](https://img.shields.io/badge/license-MIT%2FApache--2.0-blue.svg)](LICENSE) +[![Rust](https://img.shields.io/badge/rust-2024-orange.svg)](https://www.rust-lang.org) + +## Overview + +Toboggan is a presentation system that allows you to create, serve, and control slide-based presentations across multiple platforms. Write your slides in Markdown or TOML, serve them via a WebSocket-enabled server, and present from any client - web browser, terminal, desktop app, or mobile device. + +**Note**: This is an educational and fun project created to explore Rust's capabilities across different platforms - from embedded systems to web browsers. While fully functional, it's designed primarily for learning and experimentation rather than production use. It's a playground to demonstrate how Rust can target everything from microcontrollers to iOS apps! + +## Key Features + +- **๐Ÿ“ Simple Content Creation**: Write presentations in Markdown or TOML format +- **๐Ÿ”„ Real-time Synchronization**: Multi-client synchronization via WebSocket protocol +- **๐ŸŒ Multi-platform Clients**: Web, Terminal, Desktop, iOS, and embedded support +- **๐ŸŽฏ Educational Focus**: Perfect for exploring Rust ecosystem + + +## Quick Start + +### Install from source + +```bash +# Clone the repository +git clone https://github.com/ilaborie/toboggan +cd toboggan + +# Build all components +cargo build --release + +# Run the server with example presentation +cargo run -p toboggan-server +``` + +### Create a presentation + +```bash +# Convert Markdown to TOML +cargo run -p toboggan-cli -- examples/my-talk.md -o my-talk.toml + +# Or create TOML directly +cat > my-talk.toml << 'EOF' +date = "2025-01-26" + +[title] +type = "Text" +text = "My Presentation" + +[[slides]] +kind = "Cover" +[slides.title] +type = "Text" +text = "Welcome" +EOF +``` + +### Serve and present + +```bash +# Start the server +cargo run -p toboggan-server -- my-talk.toml + +# Open web interface +open http://localhost:8080 + +# Or use terminal client +cargo run -p toboggan-tui +``` + +## Building + +### Prerequisites + +- Rust 1.83+ (2024 edition) +- Node.js 20+ (for web frontend) +- `mise` (optional, for task automation) + +### Build all components + +```bash +# Using mise (recommended) +mise check # Format, lint, and test +mise build # Build all components + +# Or using cargo directly +cargo build --release +cargo test +cargo fmt +cargo clippy +``` + +### Platform-specific builds + +#### Web (WASM) +```bash +mise build:wasm +# Or manually: +cd toboggan-web/toboggan-wasm +wasm-pack build --target web --release +``` + +#### iOS +```bash +mise build:ios +# Or manually: +cd toboggan-ios +./build.sh +``` + +#### Desktop +```bash +cargo build -p toboggan-desktop --release +``` + +#### Terminal UI +```bash +cargo build -p toboggan-tui --release +``` + +## Architecture + +Toboggan is designed as a modular system with clear separation of concerns. The architecture follows Clean Architecture principles with well-defined boundaries between components. + +### Workspace Components + +``` +toboggan/ +โ”œโ”€โ”€ toboggan-core/ # Core domain models and business logic +โ”œโ”€โ”€ toboggan-server/ # Axum server with WebSocket & REST +โ”œโ”€โ”€ toboggan-client/ # Shared client library with WebSocket support +โ”œโ”€โ”€ toboggan-cli/ # Command-line tool for Markdown โ†’ TOML conversion +โ”œโ”€โ”€ toboggan-web/ # Web frontend with TypeScript and WASM client +โ”œโ”€โ”€ toboggan-tui/ # Terminal UI client using ratatui +โ”œโ”€โ”€ toboggan-desktop/ # Native desktop app using iced framework +โ”œโ”€โ”€ toboggan-ios/ # iOS Rust library with UniFFI bindings +โ”œโ”€โ”€ TobogganApp/ # Native iOS app using SwiftUI +โ””โ”€โ”€ toboggan-esp32/ # ESP32 embedded client (excluded from workspace) +``` + +### Core Design Principles + +- **WebSocket Protocol**: JSON-based real-time communication +- **Memory Safety**: Zero (direct) unsafe code, comprehensive error handling +- **Cross-platform**: Single codebase targeting multiple platforms +- **Modular Design**: Clear separation between server, clients, and core logic + +## Client Applications + +Toboggan supports multiple client types, each optimized for different use cases and platforms. + +### Web Browser (`toboggan-web`) +- **Technology**: TypeScript frontend with WASM client +- **Features**: Modern web interface, keyboard shortcuts, responsive design +- **Usage**: Open `http://localhost:8080` when server is running +- **Platform**: Any modern web browser + +### Terminal UI (`toboggan-tui`) +- **Technology**: [ratatui](https://ratatui.rs/) with crossterm +- **Features**: Full-featured terminal interface, presenter view, slide navigation +- **Usage**: `cargo run -p toboggan-tui` +- **Platform**: Linux, macOS, Windows terminals + +### Desktop Application (`toboggan-desktop`) +- **Technology**: [iced](https://github.com/iced-rs/iced) native GUI framework +- **Features**: Native desktop experience with system integration +- **Usage**: `cargo run -p toboggan-desktop` +- **Platform**: Linux, macOS, Windows native + +### iOS Application (`TobogganApp/`) +- **Technology**: SwiftUI with Rust core via UniFFI +- **Features**: Native iOS interface, gesture controls, AirPlay support +- **Usage**: Build and run from Xcode +- **Platform**: iOS 16+ devices and simulator + +### Embedded Client (`toboggan-esp32`) +- **Technology**: ESP-IDF with embedded-graphics +- **Hardware**: ESP32-S3-BOX-3B development board +- **Features**: WiFi connectivity, LCD display, LED indicators +- **Platform**: ESP32 microcontrollers + +## WebSocket Protocol + +Toboggan uses a simple JSON-based WebSocket protocol for real-time synchronization: + +### Commands (Client โ†’ Server) +- `Next`, `Previous`, `First`, `Last` - Navigation +- `Goto { slide: N }` - Jump to specific slide +- `Play`, `Pause`, `Resume` - Presentation control +- `Register { client_id }` - Client registration + +### Notifications (Server โ†’ Clients) +- `State { current_slide, state }` - Presentation state updates +- `Error { message }` - Error notifications +- `Pong` - Heartbeat response + +## Development + +### Running tests +```bash +cargo test # All tests +cargo nextest run # Faster parallel tests +cargo test -p toboggan-core # Specific crate +``` + +### Code quality +```bash +cargo fmt # Format code +cargo clippy # Lint code +mise check # All checks +``` + +### Documentation +```bash +cargo doc --open # Generate and open docs +``` + +## Contributing + +We welcome contributions to Toboggan! Here's how you can help: + +### Getting Started +1. Fork the repository +2. Create a feature branch: `git checkout -b feat/my-feature` +3. Make your changes following the project guidelines +4. Run tests: `mise check` or `cargo test` +5. Submit a pull request + +### Development Guidelines +- **Code Quality**: All code must pass `cargo fmt`, `cargo clippy`, and tests +- **Safety**: No `unsafe` code allowed (enforced by lints) +- **Error Handling**: Use `Result` and `Option`, avoid `unwrap()` in favor of `expect()` +- **Documentation**: Document public APIs and complex logic +- **Testing**: Add tests for new features and bug fixes + +## License + +Licensed under either of: + +- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE)) +- MIT license ([LICENSE-MIT](LICENSE-MIT)) + +at your option. + +## Acknowledgments + +Built with excellent Rust crates including: + +**Core Infrastructure** +- [tokio](https://github.com/tokio-rs/tokio) - Async runtime powering the server and clients +- [axum](https://github.com/tokio-rs/axum) - Web framework for the REST API and WebSocket server +- [serde](https://github.com/serde-rs/serde) - Serialization framework for all data structures +- [anyhow](https://github.com/dtolnay/anyhow) - Flexible error handling across the project + +**Client Platforms** +- [wasm-bindgen](https://github.com/rustwasm/wasm-bindgen) - WebAssembly bindings for browser +- [web-sys](https://github.com/rustwasm/wasm-bindgen) - Browser API bindings for WASM +- [gloo](https://github.com/rustwasm/gloo) - Toolkit for building WASM applications +- [ratatui](https://github.com/ratatui-org/ratatui) - Terminal UI framework +- [crossterm](https://github.com/crossterm-rs/crossterm) - Cross-platform terminal manipulation +- [iced](https://github.com/iced-rs/iced) - Native desktop GUI framework +- [uniffi](https://github.com/mozilla/uniffi-rs) - Rust-Swift interoperability for iOS +- [esp-idf-svc](https://github.com/esp-rs/esp-idf-svc) - ESP-IDF services for ESP32 +- [embedded-graphics](https://github.com/embedded-graphics/embedded-graphics) - 2D graphics for embedded displays +- [mipidsi](https://github.com/almindor/mipidsi) - MIPI Display Interface driver + +**Networking & Communication** +- [tokio-tungstenite](https://github.com/snapview/tokio-tungstenite) - Async WebSocket implementation +- [reqwest](https://github.com/seanmonstar/reqwest) - HTTP client for API calls +- [tower-http](https://github.com/tower-rs/tower-http) - HTTP middleware and services + +**Utilities** +- [clap](https://github.com/clap-rs/clap) - Command-line argument parsing +- [tracing](https://github.com/tokio-rs/tracing) - Structured application logging +- [jiff](https://github.com/BurntSushi/jiff) - Date and time handling +- [toml](https://github.com/toml-rs/toml) - TOML configuration parsing +- [comrak](https://github.com/kivikakk/comrak) - Markdown parsing and rendering + +And many more amazing crates that make Rust development a joy! \ No newline at end of file diff --git a/TobogganApp/.gitignore b/TobogganApp/.gitignore new file mode 100644 index 0000000..5c093d9 --- /dev/null +++ b/TobogganApp/.gitignore @@ -0,0 +1,4 @@ +Generated/ +TobogganApp/libtoboggan.a +TobogganApp/toboggan.swift +TobogganApp/toboggan.udl \ No newline at end of file diff --git a/TobogganApp/README.md b/TobogganApp/README.md new file mode 100644 index 0000000..077c9a3 --- /dev/null +++ b/TobogganApp/README.md @@ -0,0 +1,219 @@ +# TobogganApp + +Native iOS client for Toboggan presentations built with SwiftUI, providing a professional presentation experience on iOS devices. + +## Features + +### Presentation Experience +- **Native SwiftUI Interface**: Modern iOS design with system integration +- **Presenter View**: Dedicated view showing current slide, notes, and next slide preview +- **Real-time Synchronization**: WebSocket-based multi-client synchronization +- **Gesture Controls**: Swipe navigation and tap-to-advance +- **External Display Support**: AirPlay and wired external display compatibility + +### Content Support +- **Rich Content Rendering**: Full HTML and Markdown slide rendering +- **Responsive Layout**: Adapts to different device orientations and sizes +- **Accessibility**: VoiceOver support for all interface elements +- **Dark Mode**: Full support for system dark mode preferences + +## Architecture + +TobogganApp follows modern iOS architecture patterns for maintainability and performance: + +### Design Patterns +- **MVVM (Model-View-ViewModel)**: Clear separation between UI and business logic +- **SwiftUI**: Declarative UI with reactive data binding +- **Combine Framework**: Reactive programming for state management +- **Coordinator Pattern**: Navigation flow management + +### Core Components +- **SwiftUI Views**: Native iOS UI components with system styling +- **ViewModels**: Business logic and state management +- **Services**: Network communication and data persistence +- **Mock Types**: Development-time mocks for rapid UI iteration + +### Development Modes +1. **Mock Mode** (Current): Swift-only implementation with mock data + - Fast compilation and iteration + - SwiftUI previews without external dependencies + - Ideal for UI development and testing + +2. **Production Mode** (Future): Integration with Rust core via UniFFI + - Real WebSocket connectivity to Toboggan server + - Shared business logic with other Toboggan clients + - Full presentation synchronization + +## Getting Started + +### Prerequisites +- **Xcode 15.0+** - Latest stable version recommended +- **iOS 16.0+** - Minimum deployment target +- **macOS 13.0+** - For Xcode and development tools + +### Building the App + +#### Option 1: Using Mise (Recommended) +```bash +# From the workspace root +mise build:ios +``` + +#### Option 2: Manual Build +```bash +# Navigate to iOS Rust library directory +cd toboggan-ios + +# Build the iOS framework (when needed for production mode) +./build.sh + +# Return to workspace root +cd .. +``` + +#### Option 3: iOS-Only Development +```bash +# For UI-only development, no Rust build required +open TobogganApp/TobogganApp.xcodeproj +``` + +### Running the App + +1. Open `TobogganApp/TobogganApp.xcodeproj` in Xcode +2. Select your target device or simulator +3. Press `Cmd+R` to build and run + +## Development Workflow + +### Rapid UI Development + +The app uses mock types for fast development cycles: + +```swift +// Mock types enable SwiftUI previews +struct MockSlide: SlideProtocol { + let id = UUID() + let title = "Sample Slide" + let content = "Mock content for development" +} +``` + +**Benefits:** +- Fast build times (no Rust compilation) +- SwiftUI previews work instantly +- Easy UI iteration and testing +- No external server dependencies + +### Project Structure + +``` +TobogganApp/ +โ”œโ”€โ”€ TobogganApp.xcodeproj/ # Xcode project +โ”œโ”€โ”€ TobogganApp/ +โ”‚ โ”œโ”€โ”€ TobogganApp.swift # App entry point +โ”‚ โ”œโ”€โ”€ Views/ # SwiftUI views +โ”‚ โ”‚ โ”œโ”€โ”€ ContentView.swift # Main container view +โ”‚ โ”‚ โ”œโ”€โ”€ SlideView.swift # Individual slide display +โ”‚ โ”‚ โ””โ”€โ”€ PresenterView.swift # Presenter mode interface +โ”‚ โ”œโ”€โ”€ ViewModels/ # Business logic layer +โ”‚ โ”‚ โ”œโ”€โ”€ PresentationViewModel.swift +โ”‚ โ”‚ โ””โ”€โ”€ SlideViewModel.swift +โ”‚ โ”œโ”€โ”€ Models/ # Data types and protocols +โ”‚ โ”‚ โ”œโ”€โ”€ SlideProtocol.swift +โ”‚ โ”‚ โ””โ”€โ”€ PresentationProtocol.swift +โ”‚ โ”œโ”€โ”€ Services/ # External integrations +โ”‚ โ”‚ โ””โ”€โ”€ WebSocketService.swift +โ”‚ โ”œโ”€โ”€ Utils/ # Utilities and helpers +โ”‚ โ”‚ โ”œโ”€โ”€ MockTypes.swift # Development mocks +โ”‚ โ”‚ โ””โ”€โ”€ Extensions.swift # Swift extensions +โ”‚ โ””โ”€โ”€ Resources/ # Assets and localizations +โ”œโ”€โ”€ Tests/ # Unit and UI tests +โ””โ”€โ”€ README.md # This file +``` + +## Testing + +### Unit Tests +```bash +# Run unit tests in Xcode +Cmd+U + +# Or from command line +xcodebuild test -scheme TobogganApp -destination 'platform=iOS Simulator,name=iPhone 15' +``` + +### UI Tests +The app includes UI tests for critical presentation flows: +- Slide navigation +- Presenter view transitions +- External display handling + +## Future Integration with Rust Core + +When ready to integrate with the real Toboggan server: + +### Prerequisites for Production Mode +- Rust toolchain with iOS targets installed +- UniFFI-generated Swift bindings +- Built `toboggan-ios` framework + +### Integration Steps +1. **Build Rust Framework**: Run `mise build:ios` to generate iOS bindings +2. **Replace Mock Types**: Swap `MockTypes.swift` with real UniFFI-generated types +3. **Update Services**: Connect `WebSocketService` to real Toboggan server +4. **Test Integration**: Verify real-time synchronization works +5. **Update UI**: Adapt views to handle real data and error states + +### Benefits of Rust Integration +- Shared business logic with other Toboggan clients +- Real WebSocket connectivity and synchronization +- Consistent presentation behavior across platforms +- Type-safe communication with server + +## Contributing to iOS Development + +### Code Style Guidelines +- Follow Swift API Design Guidelines +- Use SwiftLint for code consistency +- Maintain SwiftUI best practices +- Document complex business logic + +### Common Development Tasks +- **Adding new views**: Create in `Views/` directory with associated ViewModel +- **Updating mock data**: Modify `MockTypes.swift` for development +- **Testing UI changes**: Use SwiftUI previews for rapid iteration +- **Adding features**: Follow MVVM pattern with proper separation + +### Performance Considerations +- Use `@StateObject` and `@ObservedObject` appropriately +- Minimize view re-rendering with proper state management +- Optimize image and content loading for smooth scrolling +- Test on physical devices for real performance + +## Troubleshooting + +### Common Issues + +**Build Errors:** +- Ensure Xcode is up to date (15.0+) +- Clean build folder: Product โ†’ Clean Build Folder +- Reset simulators if needed + +**Preview Issues:** +- Restart Xcode if SwiftUI previews stop working +- Check that mock types conform to required protocols +- Verify preview data is properly initialized + +**Runtime Issues:** +- Check console logs in Xcode for error messages +- Verify mock data matches expected formats +- Test on different device sizes and orientations + +## License + +Part of the Toboggan project. Licensed under either of: + +- Apache License, Version 2.0 ([LICENSE-APACHE](../LICENSE-APACHE)) +- MIT license ([LICENSE-MIT](../LICENSE-MIT)) + +at your option. \ No newline at end of file diff --git a/TobogganApp/TobogganApp-Bridging-Header.h b/TobogganApp/TobogganApp-Bridging-Header.h new file mode 100644 index 0000000..bb14c63 --- /dev/null +++ b/TobogganApp/TobogganApp-Bridging-Header.h @@ -0,0 +1,12 @@ +/* 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 http://mozilla.org/MPL/2.0/. */ + + +#ifndef TobogganApp_Bridging_Header_h +#define TobogganApp_Bridging_Header_h + +//#import "tobogganFFI.h" +#import "TobogganApp/tobogganFFI.h" + +#endif /* TobogganApp_Bridging_Header_h */ diff --git a/TobogganApp/TobogganApp.xcodeproj/project.pbxproj b/TobogganApp/TobogganApp.xcodeproj/project.pbxproj new file mode 100644 index 0000000..bc54efc --- /dev/null +++ b/TobogganApp/TobogganApp.xcodeproj/project.pbxproj @@ -0,0 +1,620 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXContainerItemProxy section */ + F8C2FD032E5097D40088D80A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = F8C2FCED2E5097D30088D80A /* Project object */; + proxyType = 1; + remoteGlobalIDString = F8C2FCF42E5097D30088D80A; + remoteInfo = TobogganApp; + }; + F8C2FD0D2E5097D40088D80A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = F8C2FCED2E5097D30088D80A /* Project object */; + proxyType = 1; + remoteGlobalIDString = F8C2FCF42E5097D30088D80A; + remoteInfo = TobogganApp; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + F84CF58B2E58510800755914 /* toboggan.udl */ = {isa = PBXFileReference; lastKnownFileType = text; name = toboggan.udl; path = "../toboggan-ios/src/toboggan.udl"; sourceTree = SOURCE_ROOT; }; + F84CF58C2E58510900755915 /* toboggan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = toboggan.swift; path = Generated/toboggan.swift; sourceTree = SOURCE_ROOT; }; + F8C2FCF52E5097D30088D80A /* TobogganApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TobogganApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + F8C2FD022E5097D40088D80A /* TobogganAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TobogganAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + F8C2FD0C2E5097D40088D80A /* TobogganAppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TobogganAppUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + F8C2FCF72E5097D30088D80A /* TobogganApp */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = TobogganApp; + sourceTree = ""; + }; + F8C2FD052E5097D40088D80A /* TobogganAppTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = TobogganAppTests; + sourceTree = ""; + }; + F8C2FD0F2E5097D40088D80A /* TobogganAppUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = TobogganAppUITests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + F8C2FCF22E5097D30088D80A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F8C2FCFF2E5097D40088D80A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F8C2FD092E5097D40088D80A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + F8C2FCEC2E5097D30088D80A = { + isa = PBXGroup; + children = ( + F84CF58B2E58510800755914 /* toboggan.udl */, + F84CF58C2E58510900755915 /* toboggan.swift */, + F8C2FCF72E5097D30088D80A /* TobogganApp */, + F8C2FD052E5097D40088D80A /* TobogganAppTests */, + F8C2FD0F2E5097D40088D80A /* TobogganAppUITests */, + F8C2FCF62E5097D30088D80A /* Products */, + ); + sourceTree = ""; + }; + F8C2FCF62E5097D30088D80A /* Products */ = { + isa = PBXGroup; + children = ( + F8C2FCF52E5097D30088D80A /* TobogganApp.app */, + F8C2FD022E5097D40088D80A /* TobogganAppTests.xctest */, + F8C2FD0C2E5097D40088D80A /* TobogganAppUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + F8C2FCF42E5097D30088D80A /* TobogganApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = F8C2FD162E5097D40088D80A /* Build configuration list for PBXNativeTarget "TobogganApp" */; + buildPhases = ( + F8C2FCF12E5097D30088D80A /* Sources */, + F84CF58E2E58558400755914 /* Build Universal Binary for Toboggan */, + F8C2FCF22E5097D30088D80A /* Frameworks */, + F8C2FCF32E5097D30088D80A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + F8C2FCF72E5097D30088D80A /* TobogganApp */, + ); + name = TobogganApp; + packageProductDependencies = ( + ); + productName = TobogganApp; + productReference = F8C2FCF52E5097D30088D80A /* TobogganApp.app */; + productType = "com.apple.product-type.application"; + }; + F8C2FD012E5097D40088D80A /* TobogganAppTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = F8C2FD192E5097D40088D80A /* Build configuration list for PBXNativeTarget "TobogganAppTests" */; + buildPhases = ( + F8C2FCFE2E5097D40088D80A /* Sources */, + F8C2FCFF2E5097D40088D80A /* Frameworks */, + F8C2FD002E5097D40088D80A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + F8C2FD042E5097D40088D80A /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + F8C2FD052E5097D40088D80A /* TobogganAppTests */, + ); + name = TobogganAppTests; + packageProductDependencies = ( + ); + productName = TobogganAppTests; + productReference = F8C2FD022E5097D40088D80A /* TobogganAppTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + F8C2FD0B2E5097D40088D80A /* TobogganAppUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = F8C2FD1C2E5097D40088D80A /* Build configuration list for PBXNativeTarget "TobogganAppUITests" */; + buildPhases = ( + F8C2FD082E5097D40088D80A /* Sources */, + F8C2FD092E5097D40088D80A /* Frameworks */, + F8C2FD0A2E5097D40088D80A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + F8C2FD0E2E5097D40088D80A /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + F8C2FD0F2E5097D40088D80A /* TobogganAppUITests */, + ); + name = TobogganAppUITests; + packageProductDependencies = ( + ); + productName = TobogganAppUITests; + productReference = F8C2FD0C2E5097D40088D80A /* TobogganAppUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + F8C2FCED2E5097D30088D80A /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1640; + LastUpgradeCheck = 2600; + TargetAttributes = { + F8C2FCF42E5097D30088D80A = { + CreatedOnToolsVersion = 16.4; + }; + F8C2FD012E5097D40088D80A = { + CreatedOnToolsVersion = 16.4; + TestTargetID = F8C2FCF42E5097D30088D80A; + }; + F8C2FD0B2E5097D40088D80A = { + CreatedOnToolsVersion = 16.4; + TestTargetID = F8C2FCF42E5097D30088D80A; + }; + }; + }; + buildConfigurationList = F8C2FCF02E5097D30088D80A /* Build configuration list for PBXProject "TobogganApp" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = F8C2FCEC2E5097D30088D80A; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + ); + preferredProjectObjectVersion = 77; + productRefGroup = F8C2FCF62E5097D30088D80A /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + F8C2FCF42E5097D30088D80A /* TobogganApp */, + F8C2FD012E5097D40088D80A /* TobogganAppTests */, + F8C2FD0B2E5097D40088D80A /* TobogganAppUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + F8C2FCF32E5097D30088D80A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F8C2FD002E5097D40088D80A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F8C2FD0A2E5097D40088D80A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + F84CF58E2E58558400755914 /* Build Universal Binary for Toboggan */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Build Universal Binary for Toboggan"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "bash $SRCROOT/xc-universal-binary.sh toboggan-ios $SRCROOT/.. $CONFIGURATION\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + F8C2FCF12E5097D30088D80A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F8C2FCFE2E5097D40088D80A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F8C2FD082E5097D40088D80A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + F8C2FD042E5097D40088D80A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = F8C2FCF42E5097D30088D80A /* TobogganApp */; + targetProxy = F8C2FD032E5097D40088D80A /* PBXContainerItemProxy */; + }; + F8C2FD0E2E5097D40088D80A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = F8C2FCF42E5097D30088D80A /* TobogganApp */; + targetProxy = F8C2FD0D2E5097D40088D80A /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + F8C2FD142E5097D40088D80A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.5; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + F8C2FD152E5097D40088D80A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.5; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + F8C2FD172E5097D40088D80A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = Toboggan; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UIStatusBarStyle = ""; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/TobogganApp", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.toboggan.TobogganApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "TobogganApp-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + F8C2FD182E5097D40088D80A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = Toboggan; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UIStatusBarStyle = ""; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/TobogganApp", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.toboggan.TobogganApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "TobogganApp-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + F8C2FD1A2E5097D40088D80A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.toboggan.TobogganAppTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TobogganApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TobogganApp"; + }; + name = Debug; + }; + F8C2FD1B2E5097D40088D80A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.toboggan.TobogganAppTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TobogganApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TobogganApp"; + }; + name = Release; + }; + F8C2FD1D2E5097D40088D80A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.toboggan.TobogganAppUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = TobogganApp; + }; + name = Debug; + }; + F8C2FD1E2E5097D40088D80A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.toboggan.TobogganAppUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = TobogganApp; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + F8C2FCF02E5097D30088D80A /* Build configuration list for PBXProject "TobogganApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F8C2FD142E5097D40088D80A /* Debug */, + F8C2FD152E5097D40088D80A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F8C2FD162E5097D40088D80A /* Build configuration list for PBXNativeTarget "TobogganApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F8C2FD172E5097D40088D80A /* Debug */, + F8C2FD182E5097D40088D80A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F8C2FD192E5097D40088D80A /* Build configuration list for PBXNativeTarget "TobogganAppTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F8C2FD1A2E5097D40088D80A /* Debug */, + F8C2FD1B2E5097D40088D80A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F8C2FD1C2E5097D40088D80A /* Build configuration list for PBXNativeTarget "TobogganAppUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F8C2FD1D2E5097D40088D80A /* Debug */, + F8C2FD1E2E5097D40088D80A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = F8C2FCED2E5097D30088D80A /* Project object */; +} diff --git a/TobogganApp/TobogganApp.xcodeproj/project.pbxproj.backup b/TobogganApp/TobogganApp.xcodeproj/project.pbxproj.backup new file mode 100644 index 0000000..e745c13 --- /dev/null +++ b/TobogganApp/TobogganApp.xcodeproj/project.pbxproj.backup @@ -0,0 +1,572 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + F8C2FD472E50D0090088D80A /* TobogganCore.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = F8C2FD462E50D0090088D80A /* TobogganCore.xcframework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + F8C2FD032E5097D40088D80A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = F8C2FCED2E5097D30088D80A /* Project object */; + proxyType = 1; + remoteGlobalIDString = F8C2FCF42E5097D30088D80A; + remoteInfo = TobogganApp; + }; + F8C2FD0D2E5097D40088D80A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = F8C2FCED2E5097D30088D80A /* Project object */; + proxyType = 1; + remoteGlobalIDString = F8C2FCF42E5097D30088D80A; + remoteInfo = TobogganApp; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + F8C2FCF52E5097D30088D80A /* TobogganApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TobogganApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + F8C2FD022E5097D40088D80A /* TobogganAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TobogganAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + F8C2FD0C2E5097D40088D80A /* TobogganAppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TobogganAppUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + F8C2FD462E50D0090088D80A /* TobogganCore.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = TobogganCore.xcframework; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + F8C2FCF72E5097D30088D80A /* TobogganApp */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = TobogganApp; + sourceTree = ""; + }; + F8C2FD052E5097D40088D80A /* TobogganAppTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = TobogganAppTests; + sourceTree = ""; + }; + F8C2FD0F2E5097D40088D80A /* TobogganAppUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = TobogganAppUITests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + F8C2FCF22E5097D30088D80A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + F8C2FD472E50D0090088D80A /* TobogganCore.xcframework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F8C2FCFF2E5097D40088D80A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F8C2FD092E5097D40088D80A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + F8C2FCEC2E5097D30088D80A = { + isa = PBXGroup; + children = ( + F8C2FCF72E5097D30088D80A /* TobogganApp */, + F8C2FD052E5097D40088D80A /* TobogganAppTests */, + F8C2FD0F2E5097D40088D80A /* TobogganAppUITests */, + F8C2FCF62E5097D30088D80A /* Products */, + F8C2FD462E50D0090088D80A /* TobogganCore.xcframework */, + ); + sourceTree = ""; + }; + F8C2FCF62E5097D30088D80A /* Products */ = { + isa = PBXGroup; + children = ( + F8C2FCF52E5097D30088D80A /* TobogganApp.app */, + F8C2FD022E5097D40088D80A /* TobogganAppTests.xctest */, + F8C2FD0C2E5097D40088D80A /* TobogganAppUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + F8C2FCF42E5097D30088D80A /* TobogganApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = F8C2FD162E5097D40088D80A /* Build configuration list for PBXNativeTarget "TobogganApp" */; + buildPhases = ( + F8C2FCF12E5097D30088D80A /* Sources */, + F8C2FCF22E5097D30088D80A /* Frameworks */, + F8C2FCF32E5097D30088D80A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + F8C2FCF72E5097D30088D80A /* TobogganApp */, + ); + name = TobogganApp; + packageProductDependencies = ( + ); + productName = TobogganApp; + productReference = F8C2FCF52E5097D30088D80A /* TobogganApp.app */; + productType = "com.apple.product-type.application"; + }; + F8C2FD012E5097D40088D80A /* TobogganAppTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = F8C2FD192E5097D40088D80A /* Build configuration list for PBXNativeTarget "TobogganAppTests" */; + buildPhases = ( + F8C2FCFE2E5097D40088D80A /* Sources */, + F8C2FCFF2E5097D40088D80A /* Frameworks */, + F8C2FD002E5097D40088D80A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + F8C2FD042E5097D40088D80A /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + F8C2FD052E5097D40088D80A /* TobogganAppTests */, + ); + name = TobogganAppTests; + packageProductDependencies = ( + ); + productName = TobogganAppTests; + productReference = F8C2FD022E5097D40088D80A /* TobogganAppTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + F8C2FD0B2E5097D40088D80A /* TobogganAppUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = F8C2FD1C2E5097D40088D80A /* Build configuration list for PBXNativeTarget "TobogganAppUITests" */; + buildPhases = ( + F8C2FD082E5097D40088D80A /* Sources */, + F8C2FD092E5097D40088D80A /* Frameworks */, + F8C2FD0A2E5097D40088D80A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + F8C2FD0E2E5097D40088D80A /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + F8C2FD0F2E5097D40088D80A /* TobogganAppUITests */, + ); + name = TobogganAppUITests; + packageProductDependencies = ( + ); + productName = TobogganAppUITests; + productReference = F8C2FD0C2E5097D40088D80A /* TobogganAppUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + F8C2FCED2E5097D30088D80A /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1640; + LastUpgradeCheck = 1640; + TargetAttributes = { + F8C2FCF42E5097D30088D80A = { + CreatedOnToolsVersion = 16.4; + }; + F8C2FD012E5097D40088D80A = { + CreatedOnToolsVersion = 16.4; + TestTargetID = F8C2FCF42E5097D30088D80A; + }; + F8C2FD0B2E5097D40088D80A = { + CreatedOnToolsVersion = 16.4; + TestTargetID = F8C2FCF42E5097D30088D80A; + }; + }; + }; + buildConfigurationList = F8C2FCF02E5097D30088D80A /* Build configuration list for PBXProject "TobogganApp" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = F8C2FCEC2E5097D30088D80A; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + ); + preferredProjectObjectVersion = 77; + productRefGroup = F8C2FCF62E5097D30088D80A /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + F8C2FCF42E5097D30088D80A /* TobogganApp */, + F8C2FD012E5097D40088D80A /* TobogganAppTests */, + F8C2FD0B2E5097D40088D80A /* TobogganAppUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + F8C2FCF32E5097D30088D80A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F8C2FD002E5097D40088D80A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F8C2FD0A2E5097D40088D80A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + F8C2FCF12E5097D30088D80A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F8C2FCFE2E5097D40088D80A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F8C2FD082E5097D40088D80A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + F8C2FD042E5097D40088D80A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = F8C2FCF42E5097D30088D80A /* TobogganApp */; + targetProxy = F8C2FD032E5097D40088D80A /* PBXContainerItemProxy */; + }; + F8C2FD0E2E5097D40088D80A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = F8C2FCF42E5097D30088D80A /* TobogganApp */; + targetProxy = F8C2FD0D2E5097D40088D80A /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + F8C2FD142E5097D40088D80A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.5; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + F8C2FD152E5097D40088D80A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.5; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + F8C2FD172E5097D40088D80A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/TobogganApp", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.toboggan.TobogganApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + F8C2FD182E5097D40088D80A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/TobogganApp", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.toboggan.TobogganApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + F8C2FD1A2E5097D40088D80A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.toboggan.TobogganAppTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TobogganApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TobogganApp"; + }; + name = Debug; + }; + F8C2FD1B2E5097D40088D80A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.toboggan.TobogganAppTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TobogganApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TobogganApp"; + }; + name = Release; + }; + F8C2FD1D2E5097D40088D80A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.toboggan.TobogganAppUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = TobogganApp; + }; + name = Debug; + }; + F8C2FD1E2E5097D40088D80A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.toboggan.TobogganAppUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = TobogganApp; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + F8C2FCF02E5097D30088D80A /* Build configuration list for PBXProject "TobogganApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F8C2FD142E5097D40088D80A /* Debug */, + F8C2FD152E5097D40088D80A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F8C2FD162E5097D40088D80A /* Build configuration list for PBXNativeTarget "TobogganApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F8C2FD172E5097D40088D80A /* Debug */, + F8C2FD182E5097D40088D80A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F8C2FD192E5097D40088D80A /* Build configuration list for PBXNativeTarget "TobogganAppTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F8C2FD1A2E5097D40088D80A /* Debug */, + F8C2FD1B2E5097D40088D80A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F8C2FD1C2E5097D40088D80A /* Build configuration list for PBXNativeTarget "TobogganAppUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F8C2FD1D2E5097D40088D80A /* Debug */, + F8C2FD1E2E5097D40088D80A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = F8C2FCED2E5097D30088D80A /* Project object */; +} diff --git a/TobogganApp/TobogganApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/TobogganApp/TobogganApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/TobogganApp/TobogganApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/TobogganApp/TobogganApp/App/ContentView.swift b/TobogganApp/TobogganApp/App/ContentView.swift new file mode 100644 index 0000000..a07a9f7 --- /dev/null +++ b/TobogganApp/TobogganApp/App/ContentView.swift @@ -0,0 +1,46 @@ +// +// ContentView.swift +// TobogganApp +// +// Created by Igor Laborie on 16/08/2025. +// + +import SwiftUI + +// Main ContentView that orchestrates the presentation remote control UI +struct ContentView: View { + @StateObject private var viewModel = PresentationViewModel() + + var body: some View { + NavigationView { + VStack(spacing: 0) { + // Top Section: Title and controls + TopBarView() + .environmentObject(viewModel) + .padding() + + // Middle Section: Current Slide Display (expands to fill space) + CurrentSlideView() + .environmentObject(viewModel) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal) + + // Bottom Section: Navigation Controls (always at bottom) + NavigationControlsView() + .environmentObject(viewModel) + .padding() + } + .navigationTitle("Toboggan - iOS") + .navigationBarTitleDisplayMode(.inline) + } + .alert("Connection Error", isPresented: $viewModel.showErrorAlert) { + Button("OK", role: .cancel) { } + } message: { + Text(viewModel.errorMessage) + } + } +} + +#Preview { + ContentView() +} diff --git a/TobogganApp/TobogganApp/Assets.xcassets/AccentColor.colorset/Contents.json b/TobogganApp/TobogganApp/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/TobogganApp/TobogganApp/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TobogganApp/TobogganApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/TobogganApp/TobogganApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/TobogganApp/TobogganApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TobogganApp/TobogganApp/Assets.xcassets/Contents.json b/TobogganApp/TobogganApp/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/TobogganApp/TobogganApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TobogganApp/TobogganApp/TobogganAppApp.swift b/TobogganApp/TobogganApp/TobogganAppApp.swift new file mode 100644 index 0000000..486d601 --- /dev/null +++ b/TobogganApp/TobogganApp/TobogganAppApp.swift @@ -0,0 +1,17 @@ +// +// TobogganAppApp.swift +// TobogganApp +// +// Created by Igor Laborie on 16/08/2025. +// + +import SwiftUI + +@main +struct TobogganAppApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/TobogganApp/TobogganApp/Utils/ViewExtensions.swift b/TobogganApp/TobogganApp/Utils/ViewExtensions.swift new file mode 100644 index 0000000..e4b16cf --- /dev/null +++ b/TobogganApp/TobogganApp/Utils/ViewExtensions.swift @@ -0,0 +1,41 @@ +// +// ViewExtensions.swift +// TobogganApp +// +// Created by Igor Laborie on 23/08/2025. +// + +import SwiftUI + +// MARK: - Common View Modifiers +extension View { + func cardBackground() -> some View { + self.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) + } + + func thinCardBackground() -> some View { + self.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16)) + } +} + +// MARK: - Common Button Modifiers +extension View { + func tobogganButton(style: TobogganButtonType = .secondary) -> some View { + Group { + if style == .primary { + self + .buttonStyle(BorderedProminentButtonStyle()) + .controlSize(.large) + } else { + self + .buttonStyle(BorderedButtonStyle()) + .controlSize(.large) + } + } + } +} + +enum TobogganButtonType { + case primary + case secondary +} diff --git a/TobogganApp/TobogganApp/ViewModels/PresentationViewModel.swift b/TobogganApp/TobogganApp/ViewModels/PresentationViewModel.swift new file mode 100644 index 0000000..9eb2fcc --- /dev/null +++ b/TobogganApp/TobogganApp/ViewModels/PresentationViewModel.swift @@ -0,0 +1,326 @@ +// +// PresentationViewModel.swift +// TobogganApp +// +// Created by Igor Laborie on 16/08/2025. +// + +import SwiftUI + +// MARK: - Connection Status Extension +extension ConnectionStatus { + var displayText: String { + switch self { + case .connecting: + return "Connecting..." + case .connected: + return "Connected" + case .closed: + return "Disconnected" + case .reconnecting: + return "Reconnecting..." + case .error: + return "Connection Error" + } + } + + var color: Color { + switch self { + case .connected: + return .green + case .connecting, .reconnecting: + return .orange + default: + return .red + } + } +} + +// Notification handler implementation +final class NotificationHandler: ClientNotificationHandler, @unchecked Sendable { + weak var viewModel: PresentationViewModel? + + init(viewModel: PresentationViewModel?) { + self.viewModel = viewModel + } + + func onStateChange(state: State) { + viewModel?.handleStateChange(state) + } + + func onTalkChange(state: State) { + viewModel?.handleTalkChange(state) + } + + func onConnectionStatusChange(status: ConnectionStatus) { + viewModel?.handleConnectionStatusChange(status) + } + + func onError(error: String) { + viewModel?.handleError(error) + } +} + +// Shared configuration for TobogganClient +enum TobogganConfig { + static let shared = ClientConfig( + url: "http://127.0.0.1:8080", + maxRetries: 3, + retryDelay: 1.0 + ) +} + +// ViewModel following KISS principle with TobogganCore integration +class PresentationViewModel: ObservableObject { + @Published var presentationTitle = "Presentation title - Date" + @Published var nextSlideTitle = "Next Title" + @Published var isPlaying = false + @Published var connectionStatus: ConnectionStatus = .closed + @Published var currentSlideIndex: Int? + @Published var totalSlides: Int = 0 + @Published var currentSlide: Slide? + @Published var duration: TimeInterval = 0 + @Published var canGoPrevious = false + @Published var canGoNext = false + + // Error dialog state + @Published var showErrorAlert = false + @Published var errorMessage = "" + + var formattedDuration: String { + let minutes = Int(duration) / 60 + let seconds = Int(duration) % 60 + return String(format: "%02d:%02d", minutes, seconds) + } + + private let tobogganClient: TobogganClient + private var currentState: State? + private let notificationHandler: NotificationHandler + private var pendingStateUpdate: State? + private var talkLoaded = false + + init() { + // Initialize notification handler and client synchronously + self.notificationHandler = NotificationHandler(viewModel: nil) + self.tobogganClient = TobogganClient( + config: TobogganConfig.shared, + handler: self.notificationHandler + ) + self.notificationHandler.viewModel = self + + // Connect asynchronously + connectAndFetchTalk() + } + + // MARK: - TobogganCore Integration + + private func connectAndFetchTalk() { + // Connect and fetch talk info on background thread + DispatchQueue.global(qos: .background).async { [weak self] in + guard let self = self else { return } + + // Update connection status + Task { @MainActor in + self.connectionStatus = .connecting + } + + // Connect to server + self.tobogganClient.connect() + + self.fetchTalkInfoDirect(client: self.tobogganClient) + } + } + + private func fetchTalkInfoDirect(client: TobogganClient) { + if let talk = client.getTalk() { + // Note: talk.slides contains slide IDs (which happen to be titles in current implementation) + // We don't store them - we'll fetch slides on demand using client.getSlide() + + Task { @MainActor in + // Update presentation title + presentationTitle = "\(talk.title) - \(talk.date)" + + // Store the count of slides + totalSlides = talk.slides.count + + // Mark talk as loaded + talkLoaded = true + + // Process any pending state updates + if let pendingState = pendingStateUpdate { + handleStateChange(pendingState) + pendingStateUpdate = nil + } + } + } else { + Task { @MainActor in + handleError("Could not fetch talk information from server") + } + } + } + + // MARK: - Notification Handlers + + func handleStateChange(_ state: State) { + Task { @MainActor in + currentState = state + + switch state { + case .`init`(let totalSlides): + // In init state, we don't have a current slide yet + self.totalSlides = Int(totalSlides) + updatePresentationState(currentSlideIndex: nil) + case let .running(previous, current, next, totalDuration): + updatePresentationState( + currentSlideIndex: Int(current), + isPlaying: true, + duration: totalDuration, + previousSlideIndex: previous.map(Int.init), + nextSlideIndex: next.map(Int.init) + ) + case let .paused(previous, current, next, totalDuration): + updatePresentationState( + currentSlideIndex: Int(current), + duration: totalDuration, + previousSlideIndex: previous.map(Int.init), + nextSlideIndex: next.map(Int.init) + ) + case let .done(previous, current, totalDuration): + updatePresentationState( + currentSlideIndex: Int(current), + duration: totalDuration, + previousSlideIndex: previous.map(Int.init) + ) + } + } + } + + func handleTalkChange(_ state: State) { + print("๐Ÿ“ Presentation updated - refetching talk metadata and slides") + + // Refetch talk information in background + DispatchQueue.global(qos: .background).async { [weak self] in + guard let self = self else { return } + self.fetchTalkInfoDirect(client: self.tobogganClient) + } + + // Update state to reflect new slide position (server already adjusted) + handleStateChange(state) + } + + private func updatePresentationState( + currentSlideIndex: Int?, + isPlaying: Bool = false, + duration: TimeInterval = 0, + previousSlideIndex: Int? = nil, + nextSlideIndex: Int? = nil + ) { + self.isPlaying = isPlaying + self.duration = duration + self.canGoPrevious = (previousSlideIndex != nil) + self.canGoNext = (nextSlideIndex != nil) + + // Update current slide (if we have one) + if let currentIdx = currentSlideIndex { + updateSlideFromState(slideIndex: currentIdx) + } else { + // In init state - no current slide yet + currentSlide = nil + self.currentSlideIndex = nil + } + + // Update next slide title by fetching it on demand + if let nextIdx = nextSlideIndex, + let nextSlide = tobogganClient.getSlide(index: UInt32(nextIdx)) { + nextSlideTitle = nextSlide.title + } else { + nextSlideTitle = "" + } + } + + func handleConnectionStatusChange(_ status: ConnectionStatus) { + Task { @MainActor in + switch status { + case .connecting: + connectionStatus = .connecting + case .connected: + connectionStatus = .connected + case .closed: + connectionStatus = .closed + case .reconnecting: + connectionStatus = .reconnecting + case .error: + connectionStatus = .error + } + } + } + + func handleError(_ error: String) { + Task { @MainActor in + connectionStatus = .error + errorMessage = error + showErrorAlert = true + } + } + + private func updateSlideFromState(slideIndex: Int) { + // Check if talk info has been loaded + if !talkLoaded { + pendingStateUpdate = currentState + return + } + + // Fetch the slide on demand using tobogganClient + if let slide = tobogganClient.getSlide(index: UInt32(slideIndex)) { + currentSlide = slide + + // Set the slide index directly + self.currentSlideIndex = slideIndex + } else { + Task { @MainActor in + handleError("Could not fetch slide with index '\(slideIndex)'") + } + } + } + + // MARK: - Actions + + func nextSlide() { + tobogganClient.sendCommand(command: .next) + // Update local index optimistically + if let current = currentSlideIndex, current < totalSlides - 1 { + currentSlideIndex = current + 1 + } else if currentSlideIndex == nil { + // If we're in init state and going next, we start at first slide + currentSlideIndex = 0 + } + } + + func previousSlide() { + tobogganClient.sendCommand(command: .previous) + } + + func firstSlide() { + tobogganClient.sendCommand(command: .first) + } + + func lastSlide() { + tobogganClient.sendCommand(command: .last) + } + + func togglePlayPause() { + if isPlaying { + tobogganClient.sendCommand(command: .pause) + } else { + tobogganClient.sendCommand(command: .resume) + } + } + + func blink() { + tobogganClient.sendCommand(command: .blink) + } + + deinit { + // Client will be cleaned up automatically when deallocated + } +} diff --git a/TobogganApp/TobogganApp/Views/CurrentSlideView.swift b/TobogganApp/TobogganApp/Views/CurrentSlideView.swift new file mode 100644 index 0000000..6fb6a94 --- /dev/null +++ b/TobogganApp/TobogganApp/Views/CurrentSlideView.swift @@ -0,0 +1,68 @@ +// +// CurrentSlideView.swift +// TobogganApp +// +// Created by Igor Laborie on 16/08/2025. +// + +import SwiftUI + +struct CurrentSlideView: View { + @EnvironmentObject var viewModel: PresentationViewModel + + private var slideProgressText: String { + if let currentIndex = viewModel.currentSlideIndex { + return "\(currentIndex + 1) of \(viewModel.totalSlides)" + } else { + return "Ready (\(viewModel.totalSlides) slides)" + } + } + + private var slideTitle: String { + if let slide = viewModel.currentSlide { + return slide.title + } else { + return "Ready to Start" + } + } + + var body: some View { + VStack(spacing: 16) { + // First line: Duration and slide progress + HStack { + Text(viewModel.formattedDuration) + .font(.subheadline) + .foregroundStyle(.secondary) + + Spacer() + + Text(slideProgressText) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + // Center: Slide title + VStack { + Spacer() + + Text(slideTitle) + .font(.largeTitle) + .fontWeight(.bold) + .foregroundStyle(viewModel.currentSlide != nil ? .primary : .secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .padding() + .thinCardBackground() + } +} + +#Preview { + CurrentSlideView() + .environmentObject(PresentationViewModel()) + .padding() +} diff --git a/TobogganApp/TobogganApp/Views/NavigationControlsView.swift b/TobogganApp/TobogganApp/Views/NavigationControlsView.swift new file mode 100644 index 0000000..0e07d21 --- /dev/null +++ b/TobogganApp/TobogganApp/Views/NavigationControlsView.swift @@ -0,0 +1,85 @@ +// +// NavigationControlsView.swift +// TobogganApp +// +// Created by Igor Laborie on 16/08/2025. +// + +import SwiftUI + +struct NavigationControlsView: View { + @EnvironmentObject var viewModel: PresentationViewModel + + var body: some View { + VStack(spacing: 20) { + // Play/Pause and Blink controls + HStack(spacing: 16) { + Button { + viewModel.togglePlayPause() + } label: { + Label(viewModel.isPlaying ? "Pause" : "Resume", + systemImage: viewModel.isPlaying ? "pause.fill" : "play.fill") + } + .tobogganButton(style: .primary) + .accessibilityHint("Start or pause the presentation") + + Button { + viewModel.blink() + } label: { + Label("Blink", systemImage: "bolt.fill") + } + .tobogganButton(style: .secondary) + .accessibilityHint("Send blink effect") + } + + // Next slide preview + VStack(alignment: .leading, spacing: 8) { + Text("Next Slide") + .font(.subheadline) + .fontWeight(.medium) + .foregroundStyle(.secondary) + + Text(viewModel.nextSlideTitle) + .font(.body) + .foregroundStyle(.primary) + .multilineTextAlignment(.leading) + .lineLimit(3) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding() + .cardBackground() + + // Navigation controls + HStack(spacing: 16) { + Button { + viewModel.previousSlide() + } label: { + Label("Previous", systemImage: "chevron.left") + } + .tobogganButton(style: .secondary) + .disabled(!viewModel.canGoPrevious) + .accessibilityHint("Go to previous slide") + + Spacer() + + Button { + viewModel.nextSlide() + } label: { + Label("Next", systemImage: "chevron.right") + .labelStyle(.titleAndIcon) + } + .tobogganButton(style: .primary) + .disabled(!viewModel.canGoNext) + .accessibilityHint("Go to next slide") + } + } + .padding() + .thinCardBackground() + } +} + +#Preview { + NavigationControlsView() + .environmentObject(PresentationViewModel()) + .padding() +} diff --git a/TobogganApp/TobogganApp/Views/TopBarView.swift b/TobogganApp/TobogganApp/Views/TopBarView.swift new file mode 100644 index 0000000..26f04d9 --- /dev/null +++ b/TobogganApp/TobogganApp/Views/TopBarView.swift @@ -0,0 +1,53 @@ +// +// TopBarView.swift +// TobogganApp +// +// Created by Igor Laborie on 16/08/2025. +// + +import SwiftUI + +struct TopBarView: View { + @EnvironmentObject var viewModel: PresentationViewModel + + var body: some View { + VStack(spacing: 12) { + // Presentation title + Text(viewModel.presentationTitle) + .font(.headline) + .fontWeight(.medium) + .foregroundStyle(.primary) + .multilineTextAlignment(.center) + .lineLimit(2) + + // Connection status + Text(viewModel.connectionStatus.displayText) + .font(.caption) + .foregroundStyle(viewModel.connectionStatus.color) + .animation(.easeInOut, value: viewModel.connectionStatus) + + // First/Last navigation buttons + HStack(spacing: 16) { + Button("First Slide") { + viewModel.firstSlide() + } + .tobogganButton(style: .secondary) + + Spacer() + + Button("Last Slide") { + viewModel.lastSlide() + } + .tobogganButton(style: .secondary) + } + } + .padding() + .cardBackground() + } +} + +#Preview { + TopBarView() + .environmentObject(PresentationViewModel()) + .padding() +} diff --git a/TobogganApp/TobogganAppTests/TobogganAppTests.swift b/TobogganApp/TobogganAppTests/TobogganAppTests.swift new file mode 100644 index 0000000..31f5550 --- /dev/null +++ b/TobogganApp/TobogganAppTests/TobogganAppTests.swift @@ -0,0 +1,55 @@ +// +// TobogganAppTests.swift +// TobogganAppTests +// +// Created by Igor Laborie on 16/08/2025. +// + +import Testing +@testable import TobogganApp + +struct TobogganAppTests { + @Test + func testUniffiInitialization() async { + // Test that UniFFI initialization works without checksum mismatch + let config = ClientConfig( + url: "http://localhost:8080", + maxRetries: 3, + retryDelay: 1000 + ) + + let testHandler = TestNotificationHandler() + // This should not crash with UniFFI checksum mismatch + let client = TobogganClient(config: config, handler: testHandler) + + // Verify client was created successfully + #expect(client.isConnected() == false) // Should be false initially + } + + @Test + func testCommandEnum() async { + // Test that Command enum values work correctly + let commands: [Command] = [.next, .previous, .first, .last, .pause, .resume, .blink] + + // Should not crash when accessing enum values + #expect(commands.count == 8) + } +} + +final class TestNotificationHandler: ClientNotificationHandler, @unchecked Sendable { + init() { + print("๐Ÿ”” iOS: NotificationHandler initialized") + } + + func onStateChange(state: State) { + print("๐Ÿ”” iOS: NotificationHandler.onStateChange called with state: \(state)") + } + + func onConnectionStatusChange(status: ConnectionStatus) { + print("๐Ÿ”” iOS: NotificationHandler.onConnectionStatusChange called with status: \(status)") + } + + func onError(error: String) { + print("๐Ÿ”” iOS: NotificationHandler.onError called with error: \(error)") + } +} diff --git a/TobogganApp/TobogganAppUITests/TobogganAppUITests.swift b/TobogganApp/TobogganAppUITests/TobogganAppUITests.swift new file mode 100644 index 0000000..f70e687 --- /dev/null +++ b/TobogganApp/TobogganAppUITests/TobogganAppUITests.swift @@ -0,0 +1,40 @@ +// +// TobogganAppUITests.swift +// TobogganAppUITests +// +// Created by Igor Laborie on 16/08/2025. +// + +import XCTest + +final class TobogganAppUITests: XCTestCase { + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests itโ€™s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + @MainActor + func testExample() throws { + // UI tests must launch the application that they test. + let app = XCUIApplication() + app.launch() + + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + @MainActor + func testLaunchPerformance() throws { + // This measures how long it takes to launch your application. + measure(metrics: [XCTApplicationLaunchMetric()]) { + XCUIApplication().launch() + } + } +} diff --git a/TobogganApp/TobogganAppUITests/TobogganAppUITestsLaunchTests.swift b/TobogganApp/TobogganAppUITests/TobogganAppUITestsLaunchTests.swift new file mode 100644 index 0000000..9db1989 --- /dev/null +++ b/TobogganApp/TobogganAppUITests/TobogganAppUITestsLaunchTests.swift @@ -0,0 +1,32 @@ +// +// TobogganAppUITestsLaunchTests.swift +// TobogganAppUITests +// +// Created by Igor Laborie on 16/08/2025. +// + +import XCTest + +final class TobogganAppUITestsLaunchTests: XCTestCase { + override static var runsForEachTargetApplicationUIConfiguration: Bool { + true + } + + override func setUpWithError() throws { + continueAfterFailure = false + } + + @MainActor + func testLaunch() throws { + let app = XCUIApplication() + app.launch() + + // Insert steps here to perform after app launch but before taking a screenshot, + // such as logging into a test account or navigating somewhere in the app + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Launch Screen" + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/TobogganApp/xc-universal-binary.sh b/TobogganApp/xc-universal-binary.sh new file mode 100755 index 0000000..4351b68 --- /dev/null +++ b/TobogganApp/xc-universal-binary.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +set -eEuv + +function error_help() +{ + ERROR_MSG="It looks like something went wrong building the Example App Universal Binary." + echo "error: ${ERROR_MSG}" +} +trap error_help ERR + +# XCode tries to be helpful and overwrites the PATH. Reset that. +PATH="$(bash -l -c 'echo $PATH')" + +# This should be invoked from inside xcode, not manually +if [[ "${#}" -ne 3 ]] +then + echo "Usage (note: only call inside xcode!):" + echo "path/to/build-scripts/xc-universal-binary.sh " + exit 1 +fi +# what to pass to cargo build -p, e.g. logins_ffi +FFI_TARGET=${1} +# path to source code root +SRC_ROOT=${2} +# buildvariant from our xcconfigs +BUILDVARIANT=$(echo "${3}" | tr '[:upper:]' '[:lower:]') + +RELFLAG= +if [[ "${BUILDVARIANT}" != "debug" ]]; then + RELFLAG=--release +fi + +# Note: We don't set LIBRARY_PATH to iOS SDK paths because that would interfere +# with proc macro compilation, which needs to run on the host system (macOS) + +IS_SIMULATOR=0 +if [ "${LLVM_TARGET_TRIPLE_SUFFIX-}" = "-simulator" ]; then + IS_SIMULATOR=1 +fi + +TARGET_DIR="target" +BUILT_PRODUCTS_DIR="${SRCROOT}/TobogganApp" + +# Ensure the destination directory exists +mkdir -p "${BUILT_PRODUCTS_DIR}" + +# Change to the correct working directory +cd "${SRC_ROOT}" + +# The actual library name is based on the lib.name in Cargo.toml, not the package name +LIB_NAME="toboggan" + +# Build Rust library for all architectures first, then generate Swift bindings +# This ensures the bindings are generated after all compilation is complete + +UDL_FILE="${SRC_ROOT}/src/toboggan.udl" +BINDINGS_DIR="${SRCROOT}/TobogganApp" + +# Ensure the bindings directory exists +mkdir -p "${BINDINGS_DIR}" + +echo "Building Rust library for all architectures..." + +for arch in $ARCHS; do + case "$arch" in + x86_64) + if [ $IS_SIMULATOR -eq 0 ]; then + echo "Building for x86_64, but not a simulator build. What's going on?" >&2 + exit 2 + fi + + # Intel iOS simulator + export CFLAGS_x86_64_apple_ios="-target x86_64-apple-ios" + $HOME/.cargo/bin/cargo rustc -p "${FFI_TARGET}" --lib --crate-type staticlib $RELFLAG --target x86_64-apple-ios + + RUST_LIB_PATH="${TARGET_DIR}/x86_64-apple-ios/$([[ "${BUILDVARIANT}" != "debug" ]] && echo "release" || echo "debug")/lib${LIB_NAME}.a" + # Copy the built library to where Xcode expects it + cp "$RUST_LIB_PATH" "${BUILT_PRODUCTS_DIR}/" + # Also copy to project directory for linker search path + cp "$RUST_LIB_PATH" "${SRCROOT}/TobogganApp/" + ;; + + arm64) + if [ $IS_SIMULATOR -eq 0 ]; then + # Hardware iOS targets + # export CFLAGS_aarch64_apple_ios="-target aarch64-apple-ios" + $HOME/.cargo/bin/cargo rustc -p "${FFI_TARGET}" --lib --crate-type staticlib $RELFLAG --target aarch64-apple-ios + + RUST_LIB_PATH="${TARGET_DIR}/aarch64-apple-ios/$([[ "${BUILDVARIANT}" != "debug" ]] && echo "release" || echo "debug")/lib${LIB_NAME}.a" + # Copy the built library to where Xcode expects it + cp "$RUST_LIB_PATH" "${BUILT_PRODUCTS_DIR}/" + # Also copy to project directory for linker search path + cp "$RUST_LIB_PATH" "${SRCROOT}/TobogganApp/" + else + # M1 iOS simulator + # export CFLAGS_aarch64_apple_ios_sim="-target aarch64-apple-ios-simulator" + $HOME/.cargo/bin/cargo rustc -p "${FFI_TARGET}" --lib --crate-type staticlib $RELFLAG --target aarch64-apple-ios-sim + + RUST_LIB_PATH="${TARGET_DIR}/aarch64-apple-ios-sim/$([[ "${BUILDVARIANT}" != "debug" ]] && echo "release" || echo "debug")/lib${LIB_NAME}.a" + # Copy the built library to where Xcode expects it + cp "$RUST_LIB_PATH" "${BUILT_PRODUCTS_DIR}/" + # Also copy to project directory for linker search path + cp "$RUST_LIB_PATH" "${SRCROOT}/TobogganApp/" + + # Generate Swift bindings using the EXACT same library we just compiled + RUST_UDL_PATH="${SRC_ROOT}/toboggan-ios/src/toboggan.udl" + echo "๐ŸŽ Generate for sim: $RUST_UDL_PATH (using compiled library metadata)" + $HOME/.cargo/bin/cargo run $RELFLAG -p "${FFI_TARGET}" --bin uniffi-bindgen -- generate --library --language swift --out-dir "${SRCROOT}/TobogganApp/" "$RUST_LIB_PATH" + fi + esac +done + +echo "All architectures built successfully! $ARCHS - $IS_SIMULATOR" +# Swift bindings are now handled by Xcode UDL Build Rule + +echo "Build script completed - Rust library built and ready for UDL Build Rule to generate Swift bindings" diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..41bbb29 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,211 @@ +# Toboggan Examples + +This directory contains comprehensive examples demonstrating different approaches to creating presentations with Toboggan. These examples showcase the flexibility of the Toboggan CLI and various content authoring strategies. + +## Overview + +The examples demonstrate two primary authoring approaches: +1. **Flat Markdown Files** - Single file with slide separators +2. **Structured Folders** - Hierarchical organization with modular content + +Both approaches generate equivalent TOML files that can be served by the Toboggan server. + +## RIIR Talk Examples + +The "Peut-on RIIR de tout ?" (Can we RIIR everything?) talk is provided in two different formats to demonstrate the flexibility of the Toboggan CLI: + +### Flat File Format: `riir-flat.md` + +A single Markdown file containing the entire presentation. This format is ideal for: +- Simple presentations +- Quick prototyping +- Version control (single file to track) +- Easy sharing + +**Structure:** +- `# Title` - Talk title +- `> Notes` - Speaker notes for the cover slide +- `---` - Slide separators +- `## Heading` - Part slides (section dividers) +- `### Heading` - Regular slide titles + +**Usage:** +```bash +# Basic usage +cargo run --package toboggan-cli -- examples/riir-flat.md -o examples/riir-flat-output.toml + +# With custom date +cargo run --package toboggan-cli -- examples/riir-flat.md --date 2024-12-25 -o examples/riir-flat-output.toml +``` + +### Folder-Based Format: `riir-folder/` + +A directory structure where each folder represents a section and each file represents a slide. This format is ideal for: +- Complex presentations +- Team collaboration +- Modular content management +- Rich media integration + +**Structure:** +``` +riir-folder/ +โ”œโ”€โ”€ title.md # Talk title (or use folder name) +โ”œโ”€โ”€ _cover.md # Cover slide content +โ”œโ”€โ”€ 01-introduction/ # Section folder +โ”‚ โ”œโ”€โ”€ _part.md # Part slide for this section +โ”‚ โ””โ”€โ”€ 01-slide.md # Regular slides +โ”œโ”€โ”€ 02-success-stories/ # Another section +โ”‚ โ”œโ”€โ”€ _part.md +โ”‚ โ”œโ”€โ”€ 01-tools.md +โ”‚ โ””โ”€โ”€ 02-reasons.md +โ””โ”€โ”€ ... +``` + +**Special Files:** +- `title.md` / `title.txt` - Talk title (fallback to folder name) +- `_cover.md` - Cover slide (special styling) +- `_part.md` - Part slide within a folder (section divider) +- `*.md` / `*.html` - Regular slides (sorted by filename) + +**Usage:** +```bash +# Basic usage (uses today's date) +cargo run --package toboggan-cli -- examples/riir-folder -o examples/riir-folder-output.toml + +# With custom date +cargo run --package toboggan-cli -- examples/riir-folder --date 2024-12-25 -o examples/riir-folder-output.toml +``` + +## Generated Outputs + +Both approaches generate equivalent TOML files that can be served by the Toboggan server: + +- `riir-flat-output.toml` - Generated from the flat file +- `riir-folder-output-fixed.toml` - Generated from the folder structure + +## Folder-Based Features + +The folder-based approach provides additional capabilities: + +### 1. **Hierarchical Organization** +- Folders automatically become Part slides +- Contents are processed in alphabetical order +- Clear separation of concerns + +### 2. **Flexible Content Types** +- `.md` files - Markdown content (converted to HTML) +- `.html` files - Raw HTML content +- Mixed content types in the same presentation + +### 3. **Special File Handling** +- `_cover.md` - Creates a Cover slide +- `_part.md` - Customizes the Part slide for a folder +- Date management via `--date` CLI argument + +### 4. **Team Collaboration** +- Different team members can work on different sections +- Easy to reorganize content by renaming folders/files +- Git-friendly structure with granular change tracking + +## Converting Between Formats + +You can use the CLI to convert between formats: + +1. **Markdown to TOML**: Direct conversion for serving +2. **Folder to TOML**: Structured conversion with automatic organization +3. **Manual conversion**: Extract sections from flat file into folders for better organization + +## Best Practices + +### Flat File Format +- Use clear section breaks with `---` +- Keep speaker notes in blockquotes `>` +- Use heading levels consistently (H2 for parts, H3 for slides) + +### Folder-Based Format +- Use numbered prefixes for ordering (01-, 02-, etc.) +- Keep folder names descriptive but concise +- Place shared resources in a dedicated folder +- Use consistent naming conventions across the team + +Both formats support the full range of Toboggan features including HTML content, speaker notes, and different slide types. + +## Quick Reference + +### Command Line Usage + +```bash +# Convert flat Markdown file +toboggan-cli presentation.md -o talk.toml + +# Convert folder structure +toboggan-cli presentation-folder/ -o talk.toml + +# With custom metadata +toboggan-cli slides/ --title "My Talk" --date "2024-12-31" -o talk.toml + +# Using workspace tooling +cargo run -p toboggan-cli -- examples/riir-flat.md -o output.toml +``` + +### Serving Presentations + +After conversion, serve the generated TOML file: + +```bash +# Start the Toboggan server with your presentation +cargo run -p toboggan-server -- talk.toml + +# Access via web browser +open http://localhost:8080 + +# Or connect with terminal client +cargo run -p toboggan-tui +``` + +## Workflow Integration + +### Development Workflow +1. **Create Content**: Write slides in Markdown or organize in folders +2. **Convert**: Use `toboggan-cli` to generate TOML +3. **Preview**: Serve with `toboggan-server` and view in browser/TUI +4. **Iterate**: Edit source files and re-convert as needed +5. **Present**: Use any Toboggan client for final presentation + +### Content Management Strategies +- **Single Author**: Use flat Markdown for simple presentations +- **Team Collaboration**: Use folder structure for distributed development +- **Version Control**: Both formats work well with Git +- **Asset Management**: Place images and media in dedicated folders + +## Advanced Usage Examples + +### Custom Date and Metadata +```bash +# Generate talk for specific conference date +toboggan-cli keynote/ \ + --title "$(cat keynote/title.txt) - RustConf 2024" \ + --date "2024-09-10" \ + -o rustconf-keynote.toml +``` + +### Batch Processing +```bash +# Convert multiple presentations +for dir in presentations/*/; do + name=$(basename "$dir") + toboggan-cli "$dir" \ + --date "$(date '+%Y-%m-%d')" \ + -o "output/${name}.toml" +done +``` + +### CI/CD Integration +```yaml +# GitHub Actions example +- name: Build Presentations + run: | + find presentations/ -name "*.md" | while read file; do + toboggan-cli "$file" -o "dist/$(basename "$file" .md).toml" + done +``` \ No newline at end of file diff --git a/examples/humantime_config.toml b/examples/humantime_config.toml new file mode 100644 index 0000000..6058a60 --- /dev/null +++ b/examples/humantime_config.toml @@ -0,0 +1,15 @@ +# Example configuration using humantime durations +[retry] +max_retries = 5 +initial_retry_delay = "2s" +max_retry_delay = "1m" +backoff_factor = 1.5 +use_jitter = true + +# Various humantime examples: +# initial_retry_delay = "500ms" # 500 milliseconds +# initial_retry_delay = "2s" # 2 seconds +# initial_retry_delay = "30s" # 30 seconds +# max_retry_delay = "1m" # 1 minute +# max_retry_delay = "5m" # 5 minutes +# max_retry_delay = "1h" # 1 hour \ No newline at end of file diff --git a/examples/riir-flat-newyear.toml b/examples/riir-flat-newyear.toml new file mode 100644 index 0000000..0bc667b --- /dev/null +++ b/examples/riir-flat-newyear.toml @@ -0,0 +1,510 @@ +date = "2024-01-01" + +[title] +type = "Text" +text = "Peut-on RIIR de tout ?" + +[[slides]] +kind = "Cover" +style = [] + +[slides.title] +type = "Empty" + +[slides.body] +type = "Empty" + +[slides.notes] +type = "Text" +text = "Rewriting It In Rust - De la startup aux multinationales" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "Introduction" + +[slides.body] +type = "Html" +raw = """ +

RIIR : โ€œHave you considered Rewriting It In Rust?โ€

+

Une question qui fait sourireโ€ฆ mais qui cache une rรฉalitรฉ : Rust gagne du terrain partout.

""" +alt = """ +**RIIR** : โ€œHave you considered Rewriting It In Rust?โ€ +Une question qui fait sourireโ€ฆ mais qui cache une rรฉalitรฉ : Rust gagne du terrain partout.""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "1. Les Success Stories du RIIR" + +[slides.body] +type = "Html" +raw = """ +

Des rรฉรฉcritures qui ont fait leurs preuves

+

Pourquoi ces rรฉรฉcritures rรฉussissent ?

+
    +
  • +

    ripgrep (rg) : grep rรฉรฉcrit en Rust

    +
      +
    • 10x plus rapide que grep classique
    • +
    • Recherche rรฉcursive native
    • +
    • Support Unicode complet
    • +
    +
  • +
  • +

    fd : find rรฉรฉcrit en Rust

    +
      +
    • Interface plus intuitive
    • +
    • Performances supรฉrieures
    • +
    • Respect des .gitignore par dรฉfaut
    • +
    +
  • +
  • +

    Fish Shell : Shell moderne

    +
      +
    • Autocomplรฉtion intelligente
    • +
    • Sรฉcuritรฉ mรฉmoire
    • +
    • Configuration simple
    • +
    +
  • +
  • +

    Performance : Compilation native + optimisations

    +
  • +
  • +

    Sรฉcuritรฉ : Zรฉro segfault, gestion mรฉmoire automatique

    +
  • +
  • +

    Ergonomie : APIs modernes et intuitives

    +
  • +
  • +

    Fiabilitรฉ : System de types expressif

    +
  • +
""" +alt = """ +Des rรฉรฉcritures qui ont fait leurs preuves +Pourquoi ces rรฉรฉcritures rรฉussissent ? +- +**ripgrep** (`rg`) : grep rรฉรฉcrit en Rust + - 10x plus rapide que grep classique + - Recherche rรฉcursive native + - Support Unicode complet + +- +**fd** : find rรฉรฉcrit en Rust + - Interface plus intuitive + - Performances supรฉrieures + - Respect des .gitignore par dรฉfaut + +- +**Fish Shell** : Shell moderne + - Autocomplรฉtion intelligente + - Sรฉcuritรฉ mรฉmoire + - Configuration simple + +- +**Performance** : Compilation native + optimisations + +- +**Sรฉcuritรฉ** : Zรฉro segfault, gestion mรฉmoire automatique + +- +**Ergonomie** : APIs modernes et intuitives + +- +**Fiabilitรฉ** : System de types expressif""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "2. Rust, le couteau suisse moderne" + +[slides.body] +type = "Html" +raw = """ +

Au-delร  des outils CLI

+

Les forces de Rust

+

Rust ne se limite pas aux applications terminal :

+

Web & Backend

+
    +
  • Actix-web, Axum : Serveurs web haute performance
  • +
  • Diesel, SQLx : ORMs type-safe
  • +
  • Tokio : Runtime async de rรฉfรฉrence
  • +
+

Applications Desktop

+
    +
  • Tauri : Alternative ร  Electron
  • +
  • egui, iced : GUI natives
  • +
  • Bevy : Moteur de jeu en ECS
  • +
+

Microcontrรดleurs & IoT

+
    +
  • Embassy : Framework async pour embedded
  • +
  • Support natif ARM, RISC-V
  • +
  • Consommation mรฉmoire optimisรฉe
  • +
+

Blockchain & Crypto

+
    +
  • Solana : Runtime blockchain
  • +
  • Substrate : Framework pour blockchains
  • +
  • Performances critiques + sรฉcuritรฉ
  • +
+
    +
  1. Zero-cost abstractions : Performance sans compromis
  2. +
  3. Memory safety : Pas de garbage collector, pas de segfault
  4. +
  5. Concurrence : Ownership model + async/await
  6. +
  7. ร‰cosystรจme : Cargo + crates.io
  8. +
  9. Cross-platform : Linux, macOS, Windows, WASM, mobile
  10. +
""" +alt = """ +Au-delร  des outils CLI +Les forces de Rust +Rust ne se limite pas aux applications terminal : +#### Web & Backend +- **Actix-web**, **Axum** : Serveurs web haute performance +- **Diesel**, **SQLx** : ORMs type-safe +- **Tokio** : Runtime async de rรฉfรฉrence +#### Applications Desktop +- **Tauri** : Alternative ร  Electron +- **egui**, **iced** : GUI natives +- **Bevy** : Moteur de jeu en ECS +#### Microcontrรดleurs & IoT +- **Embassy** : Framework async pour embedded +- Support natif ARM, RISC-V +- Consommation mรฉmoire optimisรฉe +#### Blockchain & Crypto +- **Solana** : Runtime blockchain +- **Substrate** : Framework pour blockchains +- Performances critiques + sรฉcuritรฉ +- **Zero-cost abstractions** : Performance sans compromis +- **Memory safety** : Pas de garbage collector, pas de segfault +- **Concurrence** : Ownership model + async/await +- **ร‰cosystรจme** : Cargo + crates.io +- **Cross-platform** : Linux, macOS, Windows, WASM, mobile""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "3. Rust sโ€™intรจgre partout" + +[slides.body] +type = "Html" +raw = """ +

WebAssembly (WASM)

+

Python avec PyO3 + Maturin

+

Mobile avec UniFFI

+

Autres intรฉgrations

+
use wasm_bindgen::prelude::*;
+
+#[wasm_bindgen]
+pub fn process_data(input: &str) -> String {
+    // Logique mรฉtier en Rust
+    format!("Processed: {}", input)
+}
+
+
    +
  • Performance native dans le navigateur
  • +
  • Interopรฉrabilitรฉ JavaScript seamless
  • +
  • Utilisรฉ par Figma, Discord, Dropbox
  • +
+
use pyo3::prelude::*;
+
+#[pyfunction]
+fn compute_heavy_task(data: Vec<f64>) -> PyResult<f64> {
+    // Calculs intensifs en Rust
+    Ok(data.iter().sum())
+}
+
+#[pymodule]
+fn mymodule(_py: Python, m: &PyModule) -> PyResult<()> {
+    m.add_function(wrap_pyfunction!(compute_heavy_task, m)?)?;
+    Ok(())
+}
+
+
    +
  • Accรฉlรฉration des parties critiques
  • +
  • Distribution via pip
  • +
  • Exemples : Pydantic v2, Polars
  • +
+
// Logique mรฉtier partagรฉe
+pub struct UserService {
+    // ...
+}
+
+impl UserService {
+    pub fn authenticate(&self, token: String) -> Result<User, Error> {
+        // ...
+    }
+}
+
+
    +
  • +

    Code partagรฉ iOS/Android

    +
  • +
  • +

    Bindings automatiques Swift/Kotlin

    +
  • +
  • +

    Utilisรฉ par Mozilla Firefox

    +
  • +
  • +

    Node.js : NAPI-RS

    +
  • +
  • +

    Ruby : magnus, rutie

    +
  • +
  • +

    C/C++ : FFI direct

    +
  • +
  • +

    Java : JNI

    +
  • +
  • +

    Go : CGO

    +
  • +
""" +alt = """ +WebAssembly (WASM) +Python avec PyO3 + Maturin +Mobile avec UniFFI +Autres intรฉgrations +``` +use wasm_bindgen::prelude::*; + +#[wasm_bindgen] +pub fn process_data(input: &str) -> String { + // Logique mรฉtier en Rust + format!("Processed: {}", input) +} +``` +- Performance native dans le navigateur +- Interopรฉrabilitรฉ JavaScript seamless +- Utilisรฉ par Figma, Discord, Dropbox +``` +use pyo3::prelude::*; + +#[pyfunction] +fn compute_heavy_task(data: Vec) -> PyResult { + // Calculs intensifs en Rust + Ok(data.iter().sum()) +} + +#[pymodule] +fn mymodule(_py: Python, m: &PyModule) -> PyResult<()> { + m.add_function(wrap_pyfunction!(compute_heavy_task, m)?)?; + Ok(()) +} +``` +- Accรฉlรฉration des parties critiques +- Distribution via pip +- Exemples : Pydantic v2, Polars +``` +// Logique mรฉtier partagรฉe +pub struct UserService { + // ... +} + +impl UserService { + pub fn authenticate(&self, token: String) -> Result { + // ... + } +} +``` +- +Code partagรฉ iOS/Android + +- +Bindings automatiques Swift/Kotlin + +- +Utilisรฉ par Mozilla Firefox + +- +**Node.js** : NAPI-RS + +- +**Ruby** : magnus, rutie + +- +**C/C++** : FFI direct + +- +**Java** : JNI + +- +**Go** : CGO""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "4. Rust en startup : Retour dโ€™expรฉrience" + +[slides.body] +type = "Html" +raw = """ +

Pourquoi choisir Rust en startup ?

+

Stratรฉgie dโ€™adoption progressive

+

Success stories startup

+

Avantages

+
    +
  • Performance : Moins de serveurs = coรปts rรฉduits
  • +
  • Fiabilitรฉ : Moins de bugs en production
  • +
  • Productivitรฉ : Dรฉtection dโ€™erreurs ร  la compilation
  • +
  • ร‰volutivitรฉ : Refactoring sรปr et confiant
  • +
+

Dรฉfis

+
    +
  • Courbe dโ€™apprentissage : Concepts ownership/borrowing
  • +
  • ร‰cosystรจme : Plus jeune que Java/.NET
  • +
  • Recrutement : Dรฉveloppeurs Rust plus rares
  • +
+
    +
  1. Microservices critiques : Performance-sensitive
  2. +
  3. Outils internes : CLI, scripts automation
  4. +
  5. Extensions : Plugins Python/Node.js
  6. +
  7. Migration graduelle : Module par module
  8. +
+
    +
  • Discord : Backend haute performance
  • +
  • Dropbox : Storage engine
  • +
  • Figma : Moteur de rendu WASM
  • +
  • Vercel : Bundlers (SWC, Turbo)
  • +
""" +alt = """ +Pourquoi choisir Rust en startup ? +Stratรฉgie dโ€™adoption progressive +Success stories startup +#### Avantages +- **Performance** : Moins de serveurs = coรปts rรฉduits +- **Fiabilitรฉ** : Moins de bugs en production +- **Productivitรฉ** : Dรฉtection dโ€™erreurs ร  la compilation +- **ร‰volutivitรฉ** : Refactoring sรปr et confiant +#### Dรฉfis +- **Courbe dโ€™apprentissage** : Concepts ownership/borrowing +- **ร‰cosystรจme** : Plus jeune que Java/.NET +- **Recrutement** : Dรฉveloppeurs Rust plus rares +- **Microservices critiques** : Performance-sensitive +- **Outils internes** : CLI, scripts automation +- **Extensions** : Plugins Python/Node.js +- **Migration graduelle** : Module par module +- **Discord** : Backend haute performance +- **Dropbox** : Storage engine +- **Figma** : Moteur de rendu WASM +- **Vercel** : Bundlers (SWC, Turbo)""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "Conclusion" + +[slides.body] +type = "Html" +raw = """ +

RIIR : Pas quโ€™un mรจme

+

Quand envisager Rust ?

+

Le futur est rouillรฉ ? ๐Ÿฆ€

+
    +
  • Rรฉalitรฉ technique : Gains mesurables performance/fiabilitรฉ
  • +
  • ร‰cosystรจme mature : Outils production-ready
  • +
  • Adoption croissante : Startups โ†’ GAFAM
  • +
+

โœ… OUI pour :

+
    +
  • Performance critique
  • +
  • Sรฉcuritรฉ prioritaire
  • +
  • Code partagรฉ multi-plateformes
  • +
  • Outils systรจme
  • +
+

โŒ NON pour :

+
    +
  • Prototypage rapide
  • +
  • ร‰quipe junior exclusive
  • +
  • Deadline trรจs serrรฉe
  • +
  • Domain mรฉtier complexe
  • +
+

Rust nโ€™est pas la solution ร  tout, mais il repousse les limites du possible.

+

Question finale : โ€œHave you considered Rewriting It In Rust?โ€

+

Peut-รชtre que la rรฉponse nโ€™est plus si farfelueโ€ฆ

""" +alt = """ +RIIR : Pas quโ€™un mรจme +Quand envisager Rust ? +Le futur est rouillรฉ ? ๐Ÿฆ€ +- **Rรฉalitรฉ technique** : Gains mesurables performance/fiabilitรฉ +- **ร‰cosystรจme mature** : Outils production-ready +- **Adoption croissante** : Startups โ†’ GAFAM +โœ… **OUI** pour : +- Performance critique +- Sรฉcuritรฉ prioritaire +- Code partagรฉ multi-plateformes +- Outils systรจme +โŒ **NON** pour : +- Prototypage rapide +- ร‰quipe junior exclusive +- Deadline trรจs serrรฉe +- Domain mรฉtier complexe +Rust nโ€™est pas la solution ร  tout, mais il repousse les limites du possible. +**Question finale** : *โ€œHave you considered Rewriting It In Rust?โ€* +Peut-รชtre que la rรฉponse nโ€™est plus si farfelueโ€ฆ""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "Ressources" + +[slides.body] +type = "Html" +raw = """ +

Merci pour votre attention !

+""" +alt = """ +*Merci pour votre attention !* +- Rust Book +- RIIR repository +- Are we X yet? +- This Week in Rust""" + +[slides.notes] +type = "Empty" diff --git a/examples/riir-flat-output.toml b/examples/riir-flat-output.toml new file mode 100644 index 0000000..22111ca --- /dev/null +++ b/examples/riir-flat-output.toml @@ -0,0 +1,510 @@ +date = "2025-07-26" + +[title] +type = "Text" +text = "Peut-on RIIR de tout ?" + +[[slides]] +kind = "Cover" +style = [] + +[slides.title] +type = "Empty" + +[slides.body] +type = "Empty" + +[slides.notes] +type = "Text" +text = "Rewriting It In Rust - De la startup aux multinationales" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "Introduction" + +[slides.body] +type = "Html" +raw = """ +

RIIR : โ€œHave you considered Rewriting It In Rust?โ€

+

Une question qui fait sourireโ€ฆ mais qui cache une rรฉalitรฉ : Rust gagne du terrain partout.

""" +alt = """ +**RIIR** : โ€œHave you considered Rewriting It In Rust?โ€ +Une question qui fait sourireโ€ฆ mais qui cache une rรฉalitรฉ : Rust gagne du terrain partout.""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "1. Les Success Stories du RIIR" + +[slides.body] +type = "Html" +raw = """ +

Des rรฉรฉcritures qui ont fait leurs preuves

+

Pourquoi ces rรฉรฉcritures rรฉussissent ?

+
    +
  • +

    ripgrep (rg) : grep rรฉรฉcrit en Rust

    +
      +
    • 10x plus rapide que grep classique
    • +
    • Recherche rรฉcursive native
    • +
    • Support Unicode complet
    • +
    +
  • +
  • +

    fd : find rรฉรฉcrit en Rust

    +
      +
    • Interface plus intuitive
    • +
    • Performances supรฉrieures
    • +
    • Respect des .gitignore par dรฉfaut
    • +
    +
  • +
  • +

    Fish Shell : Shell moderne

    +
      +
    • Autocomplรฉtion intelligente
    • +
    • Sรฉcuritรฉ mรฉmoire
    • +
    • Configuration simple
    • +
    +
  • +
  • +

    Performance : Compilation native + optimisations

    +
  • +
  • +

    Sรฉcuritรฉ : Zรฉro segfault, gestion mรฉmoire automatique

    +
  • +
  • +

    Ergonomie : APIs modernes et intuitives

    +
  • +
  • +

    Fiabilitรฉ : System de types expressif

    +
  • +
""" +alt = """ +Des rรฉรฉcritures qui ont fait leurs preuves +Pourquoi ces rรฉรฉcritures rรฉussissent ? +- +**ripgrep** (`rg`) : grep rรฉรฉcrit en Rust + - 10x plus rapide que grep classique + - Recherche rรฉcursive native + - Support Unicode complet + +- +**fd** : find rรฉรฉcrit en Rust + - Interface plus intuitive + - Performances supรฉrieures + - Respect des .gitignore par dรฉfaut + +- +**Fish Shell** : Shell moderne + - Autocomplรฉtion intelligente + - Sรฉcuritรฉ mรฉmoire + - Configuration simple + +- +**Performance** : Compilation native + optimisations + +- +**Sรฉcuritรฉ** : Zรฉro segfault, gestion mรฉmoire automatique + +- +**Ergonomie** : APIs modernes et intuitives + +- +**Fiabilitรฉ** : System de types expressif""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "2. Rust, le couteau suisse moderne" + +[slides.body] +type = "Html" +raw = """ +

Au-delร  des outils CLI

+

Les forces de Rust

+

Rust ne se limite pas aux applications terminal :

+

Web & Backend

+
    +
  • Actix-web, Axum : Serveurs web haute performance
  • +
  • Diesel, SQLx : ORMs type-safe
  • +
  • Tokio : Runtime async de rรฉfรฉrence
  • +
+

Applications Desktop

+
    +
  • Tauri : Alternative ร  Electron
  • +
  • egui, iced : GUI natives
  • +
  • Bevy : Moteur de jeu en ECS
  • +
+

Microcontrรดleurs & IoT

+
    +
  • Embassy : Framework async pour embedded
  • +
  • Support natif ARM, RISC-V
  • +
  • Consommation mรฉmoire optimisรฉe
  • +
+

Blockchain & Crypto

+
    +
  • Solana : Runtime blockchain
  • +
  • Substrate : Framework pour blockchains
  • +
  • Performances critiques + sรฉcuritรฉ
  • +
+
    +
  1. Zero-cost abstractions : Performance sans compromis
  2. +
  3. Memory safety : Pas de garbage collector, pas de segfault
  4. +
  5. Concurrence : Ownership model + async/await
  6. +
  7. ร‰cosystรจme : Cargo + crates.io
  8. +
  9. Cross-platform : Linux, macOS, Windows, WASM, mobile
  10. +
""" +alt = """ +Au-delร  des outils CLI +Les forces de Rust +Rust ne se limite pas aux applications terminal : +#### Web & Backend +- **Actix-web**, **Axum** : Serveurs web haute performance +- **Diesel**, **SQLx** : ORMs type-safe +- **Tokio** : Runtime async de rรฉfรฉrence +#### Applications Desktop +- **Tauri** : Alternative ร  Electron +- **egui**, **iced** : GUI natives +- **Bevy** : Moteur de jeu en ECS +#### Microcontrรดleurs & IoT +- **Embassy** : Framework async pour embedded +- Support natif ARM, RISC-V +- Consommation mรฉmoire optimisรฉe +#### Blockchain & Crypto +- **Solana** : Runtime blockchain +- **Substrate** : Framework pour blockchains +- Performances critiques + sรฉcuritรฉ +- **Zero-cost abstractions** : Performance sans compromis +- **Memory safety** : Pas de garbage collector, pas de segfault +- **Concurrence** : Ownership model + async/await +- **ร‰cosystรจme** : Cargo + crates.io +- **Cross-platform** : Linux, macOS, Windows, WASM, mobile""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "3. Rust sโ€™intรจgre partout" + +[slides.body] +type = "Html" +raw = """ +

WebAssembly (WASM)

+

Python avec PyO3 + Maturin

+

Mobile avec UniFFI

+

Autres intรฉgrations

+
use wasm_bindgen::prelude::*;
+
+#[wasm_bindgen]
+pub fn process_data(input: &str) -> String {
+    // Logique mรฉtier en Rust
+    format!("Processed: {}", input)
+}
+
+
    +
  • Performance native dans le navigateur
  • +
  • Interopรฉrabilitรฉ JavaScript seamless
  • +
  • Utilisรฉ par Figma, Discord, Dropbox
  • +
+
use pyo3::prelude::*;
+
+#[pyfunction]
+fn compute_heavy_task(data: Vec<f64>) -> PyResult<f64> {
+    // Calculs intensifs en Rust
+    Ok(data.iter().sum())
+}
+
+#[pymodule]
+fn mymodule(_py: Python, m: &PyModule) -> PyResult<()> {
+    m.add_function(wrap_pyfunction!(compute_heavy_task, m)?)?;
+    Ok(())
+}
+
+
    +
  • Accรฉlรฉration des parties critiques
  • +
  • Distribution via pip
  • +
  • Exemples : Pydantic v2, Polars
  • +
+
// Logique mรฉtier partagรฉe
+pub struct UserService {
+    // ...
+}
+
+impl UserService {
+    pub fn authenticate(&self, token: String) -> Result<User, Error> {
+        // ...
+    }
+}
+
+
    +
  • +

    Code partagรฉ iOS/Android

    +
  • +
  • +

    Bindings automatiques Swift/Kotlin

    +
  • +
  • +

    Utilisรฉ par Mozilla Firefox

    +
  • +
  • +

    Node.js : NAPI-RS

    +
  • +
  • +

    Ruby : magnus, rutie

    +
  • +
  • +

    C/C++ : FFI direct

    +
  • +
  • +

    Java : JNI

    +
  • +
  • +

    Go : CGO

    +
  • +
""" +alt = """ +WebAssembly (WASM) +Python avec PyO3 + Maturin +Mobile avec UniFFI +Autres intรฉgrations +``` +use wasm_bindgen::prelude::*; + +#[wasm_bindgen] +pub fn process_data(input: &str) -> String { + // Logique mรฉtier en Rust + format!("Processed: {}", input) +} +``` +- Performance native dans le navigateur +- Interopรฉrabilitรฉ JavaScript seamless +- Utilisรฉ par Figma, Discord, Dropbox +``` +use pyo3::prelude::*; + +#[pyfunction] +fn compute_heavy_task(data: Vec) -> PyResult { + // Calculs intensifs en Rust + Ok(data.iter().sum()) +} + +#[pymodule] +fn mymodule(_py: Python, m: &PyModule) -> PyResult<()> { + m.add_function(wrap_pyfunction!(compute_heavy_task, m)?)?; + Ok(()) +} +``` +- Accรฉlรฉration des parties critiques +- Distribution via pip +- Exemples : Pydantic v2, Polars +``` +// Logique mรฉtier partagรฉe +pub struct UserService { + // ... +} + +impl UserService { + pub fn authenticate(&self, token: String) -> Result { + // ... + } +} +``` +- +Code partagรฉ iOS/Android + +- +Bindings automatiques Swift/Kotlin + +- +Utilisรฉ par Mozilla Firefox + +- +**Node.js** : NAPI-RS + +- +**Ruby** : magnus, rutie + +- +**C/C++** : FFI direct + +- +**Java** : JNI + +- +**Go** : CGO""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "4. Rust en startup : Retour dโ€™expรฉrience" + +[slides.body] +type = "Html" +raw = """ +

Pourquoi choisir Rust en startup ?

+

Stratรฉgie dโ€™adoption progressive

+

Success stories startup

+

Avantages

+
    +
  • Performance : Moins de serveurs = coรปts rรฉduits
  • +
  • Fiabilitรฉ : Moins de bugs en production
  • +
  • Productivitรฉ : Dรฉtection dโ€™erreurs ร  la compilation
  • +
  • ร‰volutivitรฉ : Refactoring sรปr et confiant
  • +
+

Dรฉfis

+
    +
  • Courbe dโ€™apprentissage : Concepts ownership/borrowing
  • +
  • ร‰cosystรจme : Plus jeune que Java/.NET
  • +
  • Recrutement : Dรฉveloppeurs Rust plus rares
  • +
+
    +
  1. Microservices critiques : Performance-sensitive
  2. +
  3. Outils internes : CLI, scripts automation
  4. +
  5. Extensions : Plugins Python/Node.js
  6. +
  7. Migration graduelle : Module par module
  8. +
+
    +
  • Discord : Backend haute performance
  • +
  • Dropbox : Storage engine
  • +
  • Figma : Moteur de rendu WASM
  • +
  • Vercel : Bundlers (SWC, Turbo)
  • +
""" +alt = """ +Pourquoi choisir Rust en startup ? +Stratรฉgie dโ€™adoption progressive +Success stories startup +#### Avantages +- **Performance** : Moins de serveurs = coรปts rรฉduits +- **Fiabilitรฉ** : Moins de bugs en production +- **Productivitรฉ** : Dรฉtection dโ€™erreurs ร  la compilation +- **ร‰volutivitรฉ** : Refactoring sรปr et confiant +#### Dรฉfis +- **Courbe dโ€™apprentissage** : Concepts ownership/borrowing +- **ร‰cosystรจme** : Plus jeune que Java/.NET +- **Recrutement** : Dรฉveloppeurs Rust plus rares +- **Microservices critiques** : Performance-sensitive +- **Outils internes** : CLI, scripts automation +- **Extensions** : Plugins Python/Node.js +- **Migration graduelle** : Module par module +- **Discord** : Backend haute performance +- **Dropbox** : Storage engine +- **Figma** : Moteur de rendu WASM +- **Vercel** : Bundlers (SWC, Turbo)""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "Conclusion" + +[slides.body] +type = "Html" +raw = """ +

RIIR : Pas quโ€™un mรจme

+

Quand envisager Rust ?

+

Le futur est rouillรฉ ? ๐Ÿฆ€

+
    +
  • Rรฉalitรฉ technique : Gains mesurables performance/fiabilitรฉ
  • +
  • ร‰cosystรจme mature : Outils production-ready
  • +
  • Adoption croissante : Startups โ†’ GAFAM
  • +
+

โœ… OUI pour :

+
    +
  • Performance critique
  • +
  • Sรฉcuritรฉ prioritaire
  • +
  • Code partagรฉ multi-plateformes
  • +
  • Outils systรจme
  • +
+

โŒ NON pour :

+
    +
  • Prototypage rapide
  • +
  • ร‰quipe junior exclusive
  • +
  • Deadline trรจs serrรฉe
  • +
  • Domain mรฉtier complexe
  • +
+

Rust nโ€™est pas la solution ร  tout, mais il repousse les limites du possible.

+

Question finale : โ€œHave you considered Rewriting It In Rust?โ€

+

Peut-รชtre que la rรฉponse nโ€™est plus si farfelueโ€ฆ

""" +alt = """ +RIIR : Pas quโ€™un mรจme +Quand envisager Rust ? +Le futur est rouillรฉ ? ๐Ÿฆ€ +- **Rรฉalitรฉ technique** : Gains mesurables performance/fiabilitรฉ +- **ร‰cosystรจme mature** : Outils production-ready +- **Adoption croissante** : Startups โ†’ GAFAM +โœ… **OUI** pour : +- Performance critique +- Sรฉcuritรฉ prioritaire +- Code partagรฉ multi-plateformes +- Outils systรจme +โŒ **NON** pour : +- Prototypage rapide +- ร‰quipe junior exclusive +- Deadline trรจs serrรฉe +- Domain mรฉtier complexe +Rust nโ€™est pas la solution ร  tout, mais il repousse les limites du possible. +**Question finale** : *โ€œHave you considered Rewriting It In Rust?โ€* +Peut-รชtre que la rรฉponse nโ€™est plus si farfelueโ€ฆ""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "Ressources" + +[slides.body] +type = "Html" +raw = """ +

Merci pour votre attention !

+""" +alt = """ +*Merci pour votre attention !* +- Rust Book +- RIIR repository +- Are we X yet? +- This Week in Rust""" + +[slides.notes] +type = "Empty" diff --git a/examples/riir-flat.md b/examples/riir-flat.md new file mode 100644 index 0000000..4db7e1d --- /dev/null +++ b/examples/riir-flat.md @@ -0,0 +1,218 @@ +# Peut-on RIIR de tout ? + +> Rewriting It In Rust - De la startup aux multinationales + +--- + +## Introduction + +**RIIR** : "Have you considered Rewriting It In Rust?" + +Une question qui fait sourireโ€ฆ mais qui cache une rรฉalitรฉ : Rust gagne du terrain partout. + +--- + +## 1. Les Success Stories du RIIR + +Des rรฉรฉcritures qui ont fait leurs preuves + +Pourquoi ces rรฉรฉcritures rรฉussissent ? + +- **ripgrep** (`rg`) : grep rรฉรฉcrit en Rust + - 10x plus rapide que grep classique + - Recherche rรฉcursive native + - Support Unicode complet + +- **fd** : find rรฉรฉcrit en Rust + - Interface plus intuitive + - Performances supรฉrieures + - Respect des .gitignore par dรฉfaut + +- **Fish Shell** : Shell moderne + - Autocomplรฉtion intelligente + - Sรฉcuritรฉ mรฉmoire + - Configuration simple + +- **Performance** : Compilation native + optimisations +- **Sรฉcuritรฉ** : Zรฉro segfault, gestion mรฉmoire automatique +- **Ergonomie** : APIs modernes et intuitives +- **Fiabilitรฉ** : System de types expressif + +--- + +## 2. Rust, le couteau suisse moderne + +Au-delร  des outils CLI + +Les forces de Rust + +Rust ne se limite pas aux applications terminal : + +#### Web & Backend +- **Actix-web**, **Axum** : Serveurs web haute performance +- **Diesel**, **SQLx** : ORMs type-safe +- **Tokio** : Runtime async de rรฉfรฉrence + +#### Applications Desktop +- **Tauri** : Alternative ร  Electron +- **egui**, **iced** : GUI natives +- **Bevy** : Moteur de jeu en ECS + +#### Microcontrรดleurs & IoT +- **Embassy** : Framework async pour embedded +- Support natif ARM, RISC-V +- Consommation mรฉmoire optimisรฉe + +#### Blockchain & Crypto +- **Solana** : Runtime blockchain +- **Substrate** : Framework pour blockchains +- Performances critiques + sรฉcuritรฉ + +1. **Zero-cost abstractions** : Performance sans compromis +2. **Memory safety** : Pas de garbage collector, pas de segfault +3. **Concurrence** : Ownership model + async/await +4. **ร‰cosystรจme** : Cargo + crates.io +5. **Cross-platform** : Linux, macOS, Windows, WASM, mobile + +--- + +## 3. Rust s'intรจgre partout + +WebAssembly (WASM) + +Python avec PyO3 + Maturin + +Mobile avec UniFFI + +Autres intรฉgrations + +```rust +use wasm_bindgen::prelude::*; + +#[wasm_bindgen] +pub fn process_data(input: &str) -> String { + // Logique mรฉtier en Rust + format!("Processed: {}", input) +} +``` + +- Performance native dans le navigateur +- Interopรฉrabilitรฉ JavaScript seamless +- Utilisรฉ par Figma, Discord, Dropbox + +```rust +use pyo3::prelude::*; + +#[pyfunction] +fn compute_heavy_task(data: Vec) -> PyResult { + // Calculs intensifs en Rust + Ok(data.iter().sum()) +} + +#[pymodule] +fn mymodule(_py: Python, m: &PyModule) -> PyResult<()> { + m.add_function(wrap_pyfunction!(compute_heavy_task, m)?)?; + Ok(()) +} +``` + +- Accรฉlรฉration des parties critiques +- Distribution via pip +- Exemples : Pydantic v2, Polars + +```rust +// Logique mรฉtier partagรฉe +pub struct UserService { + // ... +} + +impl UserService { + pub fn authenticate(&self, token: String) -> Result { + // ... + } +} +``` + +- Code partagรฉ iOS/Android +- Bindings automatiques Swift/Kotlin +- Utilisรฉ par Mozilla Firefox + +- **Node.js** : NAPI-RS +- **Ruby** : magnus, rutie +- **C/C++** : FFI direct +- **Java** : JNI +- **Go** : CGO + +--- + +## 4. Rust en startup : Retour d'expรฉrience + +Pourquoi choisir Rust en startup ? + +Stratรฉgie d'adoption progressive + +Success stories startup + +#### Avantages +- **Performance** : Moins de serveurs = coรปts rรฉduits +- **Fiabilitรฉ** : Moins de bugs en production +- **Productivitรฉ** : Dรฉtection d'erreurs ร  la compilation +- **ร‰volutivitรฉ** : Refactoring sรปr et confiant + +#### Dรฉfis +- **Courbe d'apprentissage** : Concepts ownership/borrowing +- **ร‰cosystรจme** : Plus jeune que Java/.NET +- **Recrutement** : Dรฉveloppeurs Rust plus rares + +1. **Microservices critiques** : Performance-sensitive +2. **Outils internes** : CLI, scripts automation +3. **Extensions** : Plugins Python/Node.js +4. **Migration graduelle** : Module par module + +- **Discord** : Backend haute performance +- **Dropbox** : Storage engine +- **Figma** : Moteur de rendu WASM +- **Vercel** : Bundlers (SWC, Turbo) + +--- + +## Conclusion + +RIIR : Pas qu'un mรจme + +Quand envisager Rust ? + +Le futur est rouillรฉ ? ๐Ÿฆ€ + +- **Rรฉalitรฉ technique** : Gains mesurables performance/fiabilitรฉ +- **ร‰cosystรจme mature** : Outils production-ready +- **Adoption croissante** : Startups โ†’ GAFAM + +โœ… **OUI** pour : +- Performance critique +- Sรฉcuritรฉ prioritaire +- Code partagรฉ multi-plateformes +- Outils systรจme + +โŒ **NON** pour : +- Prototypage rapide +- ร‰quipe junior exclusive +- Deadline trรจs serrรฉe +- Domain mรฉtier complexe + +Rust n'est pas la solution ร  tout, mais il repousse les limites du possible. + +**Question finale** : *"Have you considered Rewriting It In Rust?"* + +Peut-รชtre que la rรฉponse n'est plus si farfelueโ€ฆ + +--- + +## Ressources + +*Merci pour votre attention !* + +- [Rust Book](https://doc.rust-lang.org/book/) +- [RIIR repository](https://github.com/ansuz/RIIR) +- [Are we X yet?](https://wiki.mozilla.org/Areweyet) +- [This Week in Rust](https://this-week-in-rust.org/) \ No newline at end of file diff --git a/examples/riir-folder-christmas.toml b/examples/riir-folder-christmas.toml new file mode 100644 index 0000000..bf3bbbc --- /dev/null +++ b/examples/riir-folder-christmas.toml @@ -0,0 +1,720 @@ +date = "2024-12-25" + +[title] +type = "Text" +text = "Peut-on RIIR de tout ?" + +[[slides]] +kind = "Cover" +style = [] + +[slides.title] +type = "Text" +text = "Peut-on RIIR de tout ?" + +[slides.body] +type = "Empty" + +[slides.notes] +type = "Text" +text = "Rewriting It In Rust - De la startup aux multinationales" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "Introduction" + +[slides.body] +type = "Empty" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "RIIR Definition" + +[slides.body] +type = "Html" +raw = """ +

RIIR : โ€œHave you considered Rewriting It In Rust?โ€

+

Une question qui fait sourireโ€ฆ mais qui cache une rรฉalitรฉ : Rust gagne du terrain partout.

""" +alt = """ +**RIIR** : โ€œHave you considered Rewriting It In Rust?โ€ +Une question qui fait sourireโ€ฆ mais qui cache une rรฉalitรฉ : Rust gagne du terrain partout.""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "1. Les Success Stories du RIIR" + +[slides.body] +type = "Html" +raw = """ +

Des rรฉรฉcritures qui ont fait leurs preuves

+

Pourquoi ces rรฉรฉcritures rรฉussissent ?

""" +alt = """ +Des rรฉรฉcritures qui ont fait leurs preuves +Pourquoi ces rรฉรฉcritures rรฉussissent ?""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Outils CLI Success Stories" + +[slides.body] +type = "Html" +raw = """ +
    +
  • +

    ripgrep (rg) : grep rรฉรฉcrit en Rust

    +
      +
    • 10x plus rapide que grep classique
    • +
    • Recherche rรฉcursive native
    • +
    • Support Unicode complet
    • +
    +
  • +
  • +

    fd : find rรฉรฉcrit en Rust

    +
      +
    • Interface plus intuitive
    • +
    • Performances supรฉrieures
    • +
    • Respect des .gitignore par dรฉfaut
    • +
    +
  • +
  • +

    Fish Shell : Shell moderne

    +
      +
    • Autocomplรฉtion intelligente
    • +
    • Sรฉcuritรฉ mรฉmoire
    • +
    • Configuration simple
    • +
    +
  • +
""" +alt = """ +- +**ripgrep** (`rg`) : grep rรฉรฉcrit en Rust + - 10x plus rapide que grep classique + - Recherche rรฉcursive native + - Support Unicode complet + +- +**fd** : find rรฉรฉcrit en Rust + - Interface plus intuitive + - Performances supรฉrieures + - Respect des .gitignore par dรฉfaut + +- +**Fish Shell** : Shell moderne + - Autocomplรฉtion intelligente + - Sรฉcuritรฉ mรฉmoire + - Configuration simple""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Pourquoi รงa marche ?" + +[slides.body] +type = "Html" +raw = """ +
    +
  • Performance : Compilation native + optimisations
  • +
  • Sรฉcuritรฉ : Zรฉro segfault, gestion mรฉmoire automatique
  • +
  • Ergonomie : APIs modernes et intuitives
  • +
  • Fiabilitรฉ : System de types expressif
  • +
""" +alt = """ +- **Performance** : Compilation native + optimisations +- **Sรฉcuritรฉ** : Zรฉro segfault, gestion mรฉmoire automatique +- **Ergonomie** : APIs modernes et intuitives +- **Fiabilitรฉ** : System de types expressif""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "2. Rust, le couteau suisse moderne" + +[slides.body] +type = "Html" +raw = """ +

Au-delร  des outils CLI

+

Les forces de Rust

""" +alt = """ +Au-delร  des outils CLI +Les forces de Rust""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Rust partout" + +[slides.body] +type = "Html" +raw = """ +

Rust ne se limite pas aux applications terminal :

+

Web & Backend

+
    +
  • Actix-web, Axum : Serveurs web haute performance
  • +
  • Diesel, SQLx : ORMs type-safe
  • +
  • Tokio : Runtime async de rรฉfรฉrence
  • +
+

Applications Desktop

+
    +
  • Tauri : Alternative ร  Electron
  • +
  • egui, iced : GUI natives
  • +
  • Bevy : Moteur de jeu en ECS
  • +
+

Microcontrรดleurs & IoT

+
    +
  • Embassy : Framework async pour embedded
  • +
  • Support natif ARM, RISC-V
  • +
  • Consommation mรฉmoire optimisรฉe
  • +
+

Blockchain & Crypto

+
    +
  • Solana : Runtime blockchain
  • +
  • Substrate : Framework pour blockchains
  • +
  • Performances critiques + sรฉcuritรฉ
  • +
""" +alt = """ +Rust ne se limite pas aux applications terminal : +#### Web & Backend +- **Actix-web**, **Axum** : Serveurs web haute performance +- **Diesel**, **SQLx** : ORMs type-safe +- **Tokio** : Runtime async de rรฉfรฉrence +#### Applications Desktop +- **Tauri** : Alternative ร  Electron +- **egui**, **iced** : GUI natives +- **Bevy** : Moteur de jeu en ECS +#### Microcontrรดleurs & IoT +- **Embassy** : Framework async pour embedded +- Support natif ARM, RISC-V +- Consommation mรฉmoire optimisรฉe +#### Blockchain & Crypto +- **Solana** : Runtime blockchain +- **Substrate** : Framework pour blockchains +- Performances critiques + sรฉcuritรฉ""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Les forces de Rust" + +[slides.body] +type = "Html" +raw = """ +
    +
  1. Zero-cost abstractions : Performance sans compromis
  2. +
  3. Memory safety : Pas de garbage collector, pas de segfault
  4. +
  5. Concurrence : Ownership model + async/await
  6. +
  7. ร‰cosystรจme : Cargo + crates.io
  8. +
  9. Cross-platform : Linux, macOS, Windows, WASM, mobile
  10. +
""" +alt = """ +- **Zero-cost abstractions** : Performance sans compromis +- **Memory safety** : Pas de garbage collector, pas de segfault +- **Concurrence** : Ownership model + async/await +- **ร‰cosystรจme** : Cargo + crates.io +- **Cross-platform** : Linux, macOS, Windows, WASM, mobile""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "3. Rust sโ€™intรจgre partout" + +[slides.body] +type = "Html" +raw = """ +

WebAssembly (WASM)

+

Python avec PyO3 + Maturin

+

Mobile avec UniFFI

+

Autres intรฉgrations

""" +alt = """ +WebAssembly (WASM) +Python avec PyO3 + Maturin +Mobile avec UniFFI +Autres intรฉgrations""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "WebAssembly (WASM)" + +[slides.body] +type = "Html" +raw = """ +
use wasm_bindgen::prelude::*;
+
+#[wasm_bindgen]
+pub fn process_data(input: &str) -> String {
+    // Logique mรฉtier en Rust
+    format!("Processed: {}", input)
+}
+
+
    +
  • Performance native dans le navigateur
  • +
  • Interopรฉrabilitรฉ JavaScript seamless
  • +
  • Utilisรฉ par Figma, Discord, Dropbox
  • +
""" +alt = """ +``` +use wasm_bindgen::prelude::*; + +#[wasm_bindgen] +pub fn process_data(input: &str) -> String { + // Logique mรฉtier en Rust + format!("Processed: {}", input) +} +``` +- Performance native dans le navigateur +- Interopรฉrabilitรฉ JavaScript seamless +- Utilisรฉ par Figma, Discord, Dropbox""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Python avec PyO3" + +[slides.body] +type = "Html" +raw = """ +
use pyo3::prelude::*;
+
+#[pyfunction]
+fn compute_heavy_task(data: Vec<f64>) -> PyResult<f64> {
+    // Calculs intensifs en Rust
+    Ok(data.iter().sum())
+}
+
+#[pymodule]
+fn mymodule(_py: Python, m: &PyModule) -> PyResult<()> {
+    m.add_function(wrap_pyfunction!(compute_heavy_task, m)?)?;
+    Ok(())
+}
+
+
    +
  • Accรฉlรฉration des parties critiques
  • +
  • Distribution via pip
  • +
  • Exemples : Pydantic v2, Polars
  • +
""" +alt = """ +``` +use pyo3::prelude::*; + +#[pyfunction] +fn compute_heavy_task(data: Vec) -> PyResult { + // Calculs intensifs en Rust + Ok(data.iter().sum()) +} + +#[pymodule] +fn mymodule(_py: Python, m: &PyModule) -> PyResult<()> { + m.add_function(wrap_pyfunction!(compute_heavy_task, m)?)?; + Ok(()) +} +``` +- Accรฉlรฉration des parties critiques +- Distribution via pip +- Exemples : Pydantic v2, Polars""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Mobile avec UniFFI" + +[slides.body] +type = "Html" +raw = """ +
// Logique mรฉtier partagรฉe
+pub struct UserService {
+    // ...
+}
+
+impl UserService {
+    pub fn authenticate(&self, token: String) -> Result<User, Error> {
+        // ...
+    }
+}
+
+
    +
  • Code partagรฉ iOS/Android
  • +
  • Bindings automatiques Swift/Kotlin
  • +
  • Utilisรฉ par Mozilla Firefox
  • +
""" +alt = """ +``` +// Logique mรฉtier partagรฉe +pub struct UserService { + // ... +} + +impl UserService { + pub fn authenticate(&self, token: String) -> Result { + // ... + } +} +``` +- Code partagรฉ iOS/Android +- Bindings automatiques Swift/Kotlin +- Utilisรฉ par Mozilla Firefox""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Autres intรฉgrations" + +[slides.body] +type = "Html" +raw = """ +
    +
  • Node.js : NAPI-RS
  • +
  • Ruby : magnus, rutie
  • +
  • C/C++ : FFI direct
  • +
  • Java : JNI
  • +
  • Go : CGO
  • +
""" +alt = """ +- **Node.js** : NAPI-RS +- **Ruby** : magnus, rutie +- **C/C++** : FFI direct +- **Java** : JNI +- **Go** : CGO""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "4. Rust en startup : Retour dโ€™expรฉrience" + +[slides.body] +type = "Html" +raw = """ +

Pourquoi choisir Rust en startup ?

+

Stratรฉgie dโ€™adoption progressive

+

Success stories startup

""" +alt = """ +Pourquoi choisir Rust en startup ? +Stratรฉgie dโ€™adoption progressive +Success stories startup""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Avantages et dรฉfis" + +[slides.body] +type = "Html" +raw = """ +

Avantages

+
    +
  • Performance : Moins de serveurs = coรปts rรฉduits
  • +
  • Fiabilitรฉ : Moins de bugs en production
  • +
  • Productivitรฉ : Dรฉtection dโ€™erreurs ร  la compilation
  • +
  • ร‰volutivitรฉ : Refactoring sรปr et confiant
  • +
+

Dรฉfis

+
    +
  • Courbe dโ€™apprentissage : Concepts ownership/borrowing
  • +
  • ร‰cosystรจme : Plus jeune que Java/.NET
  • +
  • Recrutement : Dรฉveloppeurs Rust plus rares
  • +
""" +alt = """ +#### Avantages +- **Performance** : Moins de serveurs = coรปts rรฉduits +- **Fiabilitรฉ** : Moins de bugs en production +- **Productivitรฉ** : Dรฉtection dโ€™erreurs ร  la compilation +- **ร‰volutivitรฉ** : Refactoring sรปr et confiant +#### Dรฉfis +- **Courbe dโ€™apprentissage** : Concepts ownership/borrowing +- **ร‰cosystรจme** : Plus jeune que Java/.NET +- **Recrutement** : Dรฉveloppeurs Rust plus rares""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Stratรฉgie dโ€™adoption" + +[slides.body] +type = "Html" +raw = """ +
    +
  1. Microservices critiques : Performance-sensitive
  2. +
  3. Outils internes : CLI, scripts automation
  4. +
  5. Extensions : Plugins Python/Node.js
  6. +
  7. Migration graduelle : Module par module
  8. +
""" +alt = """ +- **Microservices critiques** : Performance-sensitive +- **Outils internes** : CLI, scripts automation +- **Extensions** : Plugins Python/Node.js +- **Migration graduelle** : Module par module""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Success stories startup" + +[slides.body] +type = "Html" +raw = """ +
    +
  • Discord : Backend haute performance
  • +
  • Dropbox : Storage engine
  • +
  • Figma : Moteur de rendu WASM
  • +
  • Vercel : Bundlers (SWC, Turbo)
  • +
""" +alt = """ +- **Discord** : Backend haute performance +- **Dropbox** : Storage engine +- **Figma** : Moteur de rendu WASM +- **Vercel** : Bundlers (SWC, Turbo)""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "Conclusion" + +[slides.body] +type = "Html" +raw = """ +

RIIR : Pas quโ€™un mรจme

+

Quand envisager Rust ?

+

Le futur est rouillรฉ ? ๐Ÿฆ€

""" +alt = """ +RIIR : Pas quโ€™un mรจme +Quand envisager Rust ? +Le futur est rouillรฉ ? ๐Ÿฆ€""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "La rรฉalitรฉ RIIR" + +[slides.body] +type = "Html" +raw = """ +
    +
  • Rรฉalitรฉ technique : Gains mesurables performance/fiabilitรฉ
  • +
  • ร‰cosystรจme mature : Outils production-ready
  • +
  • Adoption croissante : Startups โ†’ GAFAM
  • +
""" +alt = """ +- **Rรฉalitรฉ technique** : Gains mesurables performance/fiabilitรฉ +- **ร‰cosystรจme mature** : Outils production-ready +- **Adoption croissante** : Startups โ†’ GAFAM""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Quand choisir Rust ?" + +[slides.body] +type = "Html" +raw = """ +

โœ… OUI pour :

+
    +
  • Performance critique
  • +
  • Sรฉcuritรฉ prioritaire
  • +
  • Code partagรฉ multi-plateformes
  • +
  • Outils systรจme
  • +
+

โŒ NON pour :

+
    +
  • Prototypage rapide
  • +
  • ร‰quipe junior exclusive
  • +
  • Deadline trรจs serrรฉe
  • +
  • Domain mรฉtier complexe
  • +
""" +alt = """ +โœ… **OUI** pour : +- Performance critique +- Sรฉcuritรฉ prioritaire +- Code partagรฉ multi-plateformes +- Outils systรจme +โŒ **NON** pour : +- Prototypage rapide +- ร‰quipe junior exclusive +- Deadline trรจs serrรฉe +- Domain mรฉtier complexe""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Question finale" + +[slides.body] +type = "Html" +raw = """ +

Rust nโ€™est pas la solution ร  tout, mais il repousse les limites du possible.

+

Question finale : โ€œHave you considered Rewriting It In Rust?โ€

+

Peut-รชtre que la rรฉponse nโ€™est plus si farfelueโ€ฆ

""" +alt = """ +Rust nโ€™est pas la solution ร  tout, mais il repousse les limites du possible. +**Question finale** : *โ€œHave you considered Rewriting It In Rust?โ€* +Peut-รชtre que la rรฉponse nโ€™est plus si farfelueโ€ฆ""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "Ressources" + +[slides.body] +type = "Empty" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Liens utiles" + +[slides.body] +type = "Html" +raw = """ +

Merci pour votre attention !

+""" +alt = """ +*Merci pour votre attention !* +- Rust Book +- RIIR repository +- Are we X yet? +- This Week in Rust""" + +[slides.notes] +type = "Empty" diff --git a/examples/riir-folder-no-date.toml b/examples/riir-folder-no-date.toml new file mode 100644 index 0000000..cd80be8 --- /dev/null +++ b/examples/riir-folder-no-date.toml @@ -0,0 +1,720 @@ +date = "2025-07-26" + +[title] +type = "Text" +text = "Peut-on RIIR de tout ?" + +[[slides]] +kind = "Cover" +style = [] + +[slides.title] +type = "Text" +text = "Peut-on RIIR de tout ?" + +[slides.body] +type = "Empty" + +[slides.notes] +type = "Text" +text = "Rewriting It In Rust - De la startup aux multinationales" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "Introduction" + +[slides.body] +type = "Empty" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "RIIR Definition" + +[slides.body] +type = "Html" +raw = """ +

RIIR : โ€œHave you considered Rewriting It In Rust?โ€

+

Une question qui fait sourireโ€ฆ mais qui cache une rรฉalitรฉ : Rust gagne du terrain partout.

""" +alt = """ +**RIIR** : โ€œHave you considered Rewriting It In Rust?โ€ +Une question qui fait sourireโ€ฆ mais qui cache une rรฉalitรฉ : Rust gagne du terrain partout.""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "1. Les Success Stories du RIIR" + +[slides.body] +type = "Html" +raw = """ +

Des rรฉรฉcritures qui ont fait leurs preuves

+

Pourquoi ces rรฉรฉcritures rรฉussissent ?

""" +alt = """ +Des rรฉรฉcritures qui ont fait leurs preuves +Pourquoi ces rรฉรฉcritures rรฉussissent ?""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Outils CLI Success Stories" + +[slides.body] +type = "Html" +raw = """ +
    +
  • +

    ripgrep (rg) : grep rรฉรฉcrit en Rust

    +
      +
    • 10x plus rapide que grep classique
    • +
    • Recherche rรฉcursive native
    • +
    • Support Unicode complet
    • +
    +
  • +
  • +

    fd : find rรฉรฉcrit en Rust

    +
      +
    • Interface plus intuitive
    • +
    • Performances supรฉrieures
    • +
    • Respect des .gitignore par dรฉfaut
    • +
    +
  • +
  • +

    Fish Shell : Shell moderne

    +
      +
    • Autocomplรฉtion intelligente
    • +
    • Sรฉcuritรฉ mรฉmoire
    • +
    • Configuration simple
    • +
    +
  • +
""" +alt = """ +- +**ripgrep** (`rg`) : grep rรฉรฉcrit en Rust + - 10x plus rapide que grep classique + - Recherche rรฉcursive native + - Support Unicode complet + +- +**fd** : find rรฉรฉcrit en Rust + - Interface plus intuitive + - Performances supรฉrieures + - Respect des .gitignore par dรฉfaut + +- +**Fish Shell** : Shell moderne + - Autocomplรฉtion intelligente + - Sรฉcuritรฉ mรฉmoire + - Configuration simple""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Pourquoi รงa marche ?" + +[slides.body] +type = "Html" +raw = """ +
    +
  • Performance : Compilation native + optimisations
  • +
  • Sรฉcuritรฉ : Zรฉro segfault, gestion mรฉmoire automatique
  • +
  • Ergonomie : APIs modernes et intuitives
  • +
  • Fiabilitรฉ : System de types expressif
  • +
""" +alt = """ +- **Performance** : Compilation native + optimisations +- **Sรฉcuritรฉ** : Zรฉro segfault, gestion mรฉmoire automatique +- **Ergonomie** : APIs modernes et intuitives +- **Fiabilitรฉ** : System de types expressif""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "2. Rust, le couteau suisse moderne" + +[slides.body] +type = "Html" +raw = """ +

Au-delร  des outils CLI

+

Les forces de Rust

""" +alt = """ +Au-delร  des outils CLI +Les forces de Rust""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Rust partout" + +[slides.body] +type = "Html" +raw = """ +

Rust ne se limite pas aux applications terminal :

+

Web & Backend

+
    +
  • Actix-web, Axum : Serveurs web haute performance
  • +
  • Diesel, SQLx : ORMs type-safe
  • +
  • Tokio : Runtime async de rรฉfรฉrence
  • +
+

Applications Desktop

+
    +
  • Tauri : Alternative ร  Electron
  • +
  • egui, iced : GUI natives
  • +
  • Bevy : Moteur de jeu en ECS
  • +
+

Microcontrรดleurs & IoT

+
    +
  • Embassy : Framework async pour embedded
  • +
  • Support natif ARM, RISC-V
  • +
  • Consommation mรฉmoire optimisรฉe
  • +
+

Blockchain & Crypto

+
    +
  • Solana : Runtime blockchain
  • +
  • Substrate : Framework pour blockchains
  • +
  • Performances critiques + sรฉcuritรฉ
  • +
""" +alt = """ +Rust ne se limite pas aux applications terminal : +#### Web & Backend +- **Actix-web**, **Axum** : Serveurs web haute performance +- **Diesel**, **SQLx** : ORMs type-safe +- **Tokio** : Runtime async de rรฉfรฉrence +#### Applications Desktop +- **Tauri** : Alternative ร  Electron +- **egui**, **iced** : GUI natives +- **Bevy** : Moteur de jeu en ECS +#### Microcontrรดleurs & IoT +- **Embassy** : Framework async pour embedded +- Support natif ARM, RISC-V +- Consommation mรฉmoire optimisรฉe +#### Blockchain & Crypto +- **Solana** : Runtime blockchain +- **Substrate** : Framework pour blockchains +- Performances critiques + sรฉcuritรฉ""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Les forces de Rust" + +[slides.body] +type = "Html" +raw = """ +
    +
  1. Zero-cost abstractions : Performance sans compromis
  2. +
  3. Memory safety : Pas de garbage collector, pas de segfault
  4. +
  5. Concurrence : Ownership model + async/await
  6. +
  7. ร‰cosystรจme : Cargo + crates.io
  8. +
  9. Cross-platform : Linux, macOS, Windows, WASM, mobile
  10. +
""" +alt = """ +- **Zero-cost abstractions** : Performance sans compromis +- **Memory safety** : Pas de garbage collector, pas de segfault +- **Concurrence** : Ownership model + async/await +- **ร‰cosystรจme** : Cargo + crates.io +- **Cross-platform** : Linux, macOS, Windows, WASM, mobile""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "3. Rust sโ€™intรจgre partout" + +[slides.body] +type = "Html" +raw = """ +

WebAssembly (WASM)

+

Python avec PyO3 + Maturin

+

Mobile avec UniFFI

+

Autres intรฉgrations

""" +alt = """ +WebAssembly (WASM) +Python avec PyO3 + Maturin +Mobile avec UniFFI +Autres intรฉgrations""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "WebAssembly (WASM)" + +[slides.body] +type = "Html" +raw = """ +
use wasm_bindgen::prelude::*;
+
+#[wasm_bindgen]
+pub fn process_data(input: &str) -> String {
+    // Logique mรฉtier en Rust
+    format!("Processed: {}", input)
+}
+
+
    +
  • Performance native dans le navigateur
  • +
  • Interopรฉrabilitรฉ JavaScript seamless
  • +
  • Utilisรฉ par Figma, Discord, Dropbox
  • +
""" +alt = """ +``` +use wasm_bindgen::prelude::*; + +#[wasm_bindgen] +pub fn process_data(input: &str) -> String { + // Logique mรฉtier en Rust + format!("Processed: {}", input) +} +``` +- Performance native dans le navigateur +- Interopรฉrabilitรฉ JavaScript seamless +- Utilisรฉ par Figma, Discord, Dropbox""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Python avec PyO3" + +[slides.body] +type = "Html" +raw = """ +
use pyo3::prelude::*;
+
+#[pyfunction]
+fn compute_heavy_task(data: Vec<f64>) -> PyResult<f64> {
+    // Calculs intensifs en Rust
+    Ok(data.iter().sum())
+}
+
+#[pymodule]
+fn mymodule(_py: Python, m: &PyModule) -> PyResult<()> {
+    m.add_function(wrap_pyfunction!(compute_heavy_task, m)?)?;
+    Ok(())
+}
+
+
    +
  • Accรฉlรฉration des parties critiques
  • +
  • Distribution via pip
  • +
  • Exemples : Pydantic v2, Polars
  • +
""" +alt = """ +``` +use pyo3::prelude::*; + +#[pyfunction] +fn compute_heavy_task(data: Vec) -> PyResult { + // Calculs intensifs en Rust + Ok(data.iter().sum()) +} + +#[pymodule] +fn mymodule(_py: Python, m: &PyModule) -> PyResult<()> { + m.add_function(wrap_pyfunction!(compute_heavy_task, m)?)?; + Ok(()) +} +``` +- Accรฉlรฉration des parties critiques +- Distribution via pip +- Exemples : Pydantic v2, Polars""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Mobile avec UniFFI" + +[slides.body] +type = "Html" +raw = """ +
// Logique mรฉtier partagรฉe
+pub struct UserService {
+    // ...
+}
+
+impl UserService {
+    pub fn authenticate(&self, token: String) -> Result<User, Error> {
+        // ...
+    }
+}
+
+
    +
  • Code partagรฉ iOS/Android
  • +
  • Bindings automatiques Swift/Kotlin
  • +
  • Utilisรฉ par Mozilla Firefox
  • +
""" +alt = """ +``` +// Logique mรฉtier partagรฉe +pub struct UserService { + // ... +} + +impl UserService { + pub fn authenticate(&self, token: String) -> Result { + // ... + } +} +``` +- Code partagรฉ iOS/Android +- Bindings automatiques Swift/Kotlin +- Utilisรฉ par Mozilla Firefox""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Autres intรฉgrations" + +[slides.body] +type = "Html" +raw = """ +
    +
  • Node.js : NAPI-RS
  • +
  • Ruby : magnus, rutie
  • +
  • C/C++ : FFI direct
  • +
  • Java : JNI
  • +
  • Go : CGO
  • +
""" +alt = """ +- **Node.js** : NAPI-RS +- **Ruby** : magnus, rutie +- **C/C++** : FFI direct +- **Java** : JNI +- **Go** : CGO""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "4. Rust en startup : Retour dโ€™expรฉrience" + +[slides.body] +type = "Html" +raw = """ +

Pourquoi choisir Rust en startup ?

+

Stratรฉgie dโ€™adoption progressive

+

Success stories startup

""" +alt = """ +Pourquoi choisir Rust en startup ? +Stratรฉgie dโ€™adoption progressive +Success stories startup""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Avantages et dรฉfis" + +[slides.body] +type = "Html" +raw = """ +

Avantages

+
    +
  • Performance : Moins de serveurs = coรปts rรฉduits
  • +
  • Fiabilitรฉ : Moins de bugs en production
  • +
  • Productivitรฉ : Dรฉtection dโ€™erreurs ร  la compilation
  • +
  • ร‰volutivitรฉ : Refactoring sรปr et confiant
  • +
+

Dรฉfis

+
    +
  • Courbe dโ€™apprentissage : Concepts ownership/borrowing
  • +
  • ร‰cosystรจme : Plus jeune que Java/.NET
  • +
  • Recrutement : Dรฉveloppeurs Rust plus rares
  • +
""" +alt = """ +#### Avantages +- **Performance** : Moins de serveurs = coรปts rรฉduits +- **Fiabilitรฉ** : Moins de bugs en production +- **Productivitรฉ** : Dรฉtection dโ€™erreurs ร  la compilation +- **ร‰volutivitรฉ** : Refactoring sรปr et confiant +#### Dรฉfis +- **Courbe dโ€™apprentissage** : Concepts ownership/borrowing +- **ร‰cosystรจme** : Plus jeune que Java/.NET +- **Recrutement** : Dรฉveloppeurs Rust plus rares""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Stratรฉgie dโ€™adoption" + +[slides.body] +type = "Html" +raw = """ +
    +
  1. Microservices critiques : Performance-sensitive
  2. +
  3. Outils internes : CLI, scripts automation
  4. +
  5. Extensions : Plugins Python/Node.js
  6. +
  7. Migration graduelle : Module par module
  8. +
""" +alt = """ +- **Microservices critiques** : Performance-sensitive +- **Outils internes** : CLI, scripts automation +- **Extensions** : Plugins Python/Node.js +- **Migration graduelle** : Module par module""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Success stories startup" + +[slides.body] +type = "Html" +raw = """ +
    +
  • Discord : Backend haute performance
  • +
  • Dropbox : Storage engine
  • +
  • Figma : Moteur de rendu WASM
  • +
  • Vercel : Bundlers (SWC, Turbo)
  • +
""" +alt = """ +- **Discord** : Backend haute performance +- **Dropbox** : Storage engine +- **Figma** : Moteur de rendu WASM +- **Vercel** : Bundlers (SWC, Turbo)""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "Conclusion" + +[slides.body] +type = "Html" +raw = """ +

RIIR : Pas quโ€™un mรจme

+

Quand envisager Rust ?

+

Le futur est rouillรฉ ? ๐Ÿฆ€

""" +alt = """ +RIIR : Pas quโ€™un mรจme +Quand envisager Rust ? +Le futur est rouillรฉ ? ๐Ÿฆ€""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "La rรฉalitรฉ RIIR" + +[slides.body] +type = "Html" +raw = """ +
    +
  • Rรฉalitรฉ technique : Gains mesurables performance/fiabilitรฉ
  • +
  • ร‰cosystรจme mature : Outils production-ready
  • +
  • Adoption croissante : Startups โ†’ GAFAM
  • +
""" +alt = """ +- **Rรฉalitรฉ technique** : Gains mesurables performance/fiabilitรฉ +- **ร‰cosystรจme mature** : Outils production-ready +- **Adoption croissante** : Startups โ†’ GAFAM""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Quand choisir Rust ?" + +[slides.body] +type = "Html" +raw = """ +

โœ… OUI pour :

+
    +
  • Performance critique
  • +
  • Sรฉcuritรฉ prioritaire
  • +
  • Code partagรฉ multi-plateformes
  • +
  • Outils systรจme
  • +
+

โŒ NON pour :

+
    +
  • Prototypage rapide
  • +
  • ร‰quipe junior exclusive
  • +
  • Deadline trรจs serrรฉe
  • +
  • Domain mรฉtier complexe
  • +
""" +alt = """ +โœ… **OUI** pour : +- Performance critique +- Sรฉcuritรฉ prioritaire +- Code partagรฉ multi-plateformes +- Outils systรจme +โŒ **NON** pour : +- Prototypage rapide +- ร‰quipe junior exclusive +- Deadline trรจs serrรฉe +- Domain mรฉtier complexe""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Question finale" + +[slides.body] +type = "Html" +raw = """ +

Rust nโ€™est pas la solution ร  tout, mais il repousse les limites du possible.

+

Question finale : โ€œHave you considered Rewriting It In Rust?โ€

+

Peut-รชtre que la rรฉponse nโ€™est plus si farfelueโ€ฆ

""" +alt = """ +Rust nโ€™est pas la solution ร  tout, mais il repousse les limites du possible. +**Question finale** : *โ€œHave you considered Rewriting It In Rust?โ€* +Peut-รชtre que la rรฉponse nโ€™est plus si farfelueโ€ฆ""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "Ressources" + +[slides.body] +type = "Empty" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Liens utiles" + +[slides.body] +type = "Html" +raw = """ +

Merci pour votre attention !

+""" +alt = """ +*Merci pour votre attention !* +- Rust Book +- RIIR repository +- Are we X yet? +- This Week in Rust""" + +[slides.notes] +type = "Empty" diff --git a/examples/riir-folder-output-fixed.toml b/examples/riir-folder-output-fixed.toml new file mode 100644 index 0000000..0ba9f4f --- /dev/null +++ b/examples/riir-folder-output-fixed.toml @@ -0,0 +1,720 @@ +date = "2025-07-20" + +[title] +type = "Text" +text = "Peut-on RIIR de tout ?" + +[[slides]] +kind = "Cover" +style = [] + +[slides.title] +type = "Text" +text = "Peut-on RIIR de tout ?" + +[slides.body] +type = "Empty" + +[slides.notes] +type = "Text" +text = "Rewriting It In Rust - De la startup aux multinationales" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "Introduction" + +[slides.body] +type = "Empty" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "RIIR Definition" + +[slides.body] +type = "Html" +raw = """ +

RIIR : โ€œHave you considered Rewriting It In Rust?โ€

+

Une question qui fait sourireโ€ฆ mais qui cache une rรฉalitรฉ : Rust gagne du terrain partout.

""" +alt = """ +**RIIR** : โ€œHave you considered Rewriting It In Rust?โ€ +Une question qui fait sourireโ€ฆ mais qui cache une rรฉalitรฉ : Rust gagne du terrain partout.""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "1. Les Success Stories du RIIR" + +[slides.body] +type = "Html" +raw = """ +

Des rรฉรฉcritures qui ont fait leurs preuves

+

Pourquoi ces rรฉรฉcritures rรฉussissent ?

""" +alt = """ +Des rรฉรฉcritures qui ont fait leurs preuves +Pourquoi ces rรฉรฉcritures rรฉussissent ?""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Outils CLI Success Stories" + +[slides.body] +type = "Html" +raw = """ +
    +
  • +

    ripgrep (rg) : grep rรฉรฉcrit en Rust

    +
      +
    • 10x plus rapide que grep classique
    • +
    • Recherche rรฉcursive native
    • +
    • Support Unicode complet
    • +
    +
  • +
  • +

    fd : find rรฉรฉcrit en Rust

    +
      +
    • Interface plus intuitive
    • +
    • Performances supรฉrieures
    • +
    • Respect des .gitignore par dรฉfaut
    • +
    +
  • +
  • +

    Fish Shell : Shell moderne

    +
      +
    • Autocomplรฉtion intelligente
    • +
    • Sรฉcuritรฉ mรฉmoire
    • +
    • Configuration simple
    • +
    +
  • +
""" +alt = """ +- +**ripgrep** (`rg`) : grep rรฉรฉcrit en Rust + - 10x plus rapide que grep classique + - Recherche rรฉcursive native + - Support Unicode complet + +- +**fd** : find rรฉรฉcrit en Rust + - Interface plus intuitive + - Performances supรฉrieures + - Respect des .gitignore par dรฉfaut + +- +**Fish Shell** : Shell moderne + - Autocomplรฉtion intelligente + - Sรฉcuritรฉ mรฉmoire + - Configuration simple""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Pourquoi รงa marche ?" + +[slides.body] +type = "Html" +raw = """ +
    +
  • Performance : Compilation native + optimisations
  • +
  • Sรฉcuritรฉ : Zรฉro segfault, gestion mรฉmoire automatique
  • +
  • Ergonomie : APIs modernes et intuitives
  • +
  • Fiabilitรฉ : System de types expressif
  • +
""" +alt = """ +- **Performance** : Compilation native + optimisations +- **Sรฉcuritรฉ** : Zรฉro segfault, gestion mรฉmoire automatique +- **Ergonomie** : APIs modernes et intuitives +- **Fiabilitรฉ** : System de types expressif""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "2. Rust, le couteau suisse moderne" + +[slides.body] +type = "Html" +raw = """ +

Au-delร  des outils CLI

+

Les forces de Rust

""" +alt = """ +Au-delร  des outils CLI +Les forces de Rust""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Rust partout" + +[slides.body] +type = "Html" +raw = """ +

Rust ne se limite pas aux applications terminal :

+

Web & Backend

+
    +
  • Actix-web, Axum : Serveurs web haute performance
  • +
  • Diesel, SQLx : ORMs type-safe
  • +
  • Tokio : Runtime async de rรฉfรฉrence
  • +
+

Applications Desktop

+
    +
  • Tauri : Alternative ร  Electron
  • +
  • egui, iced : GUI natives
  • +
  • Bevy : Moteur de jeu en ECS
  • +
+

Microcontrรดleurs & IoT

+
    +
  • Embassy : Framework async pour embedded
  • +
  • Support natif ARM, RISC-V
  • +
  • Consommation mรฉmoire optimisรฉe
  • +
+

Blockchain & Crypto

+
    +
  • Solana : Runtime blockchain
  • +
  • Substrate : Framework pour blockchains
  • +
  • Performances critiques + sรฉcuritรฉ
  • +
""" +alt = """ +Rust ne se limite pas aux applications terminal : +#### Web & Backend +- **Actix-web**, **Axum** : Serveurs web haute performance +- **Diesel**, **SQLx** : ORMs type-safe +- **Tokio** : Runtime async de rรฉfรฉrence +#### Applications Desktop +- **Tauri** : Alternative ร  Electron +- **egui**, **iced** : GUI natives +- **Bevy** : Moteur de jeu en ECS +#### Microcontrรดleurs & IoT +- **Embassy** : Framework async pour embedded +- Support natif ARM, RISC-V +- Consommation mรฉmoire optimisรฉe +#### Blockchain & Crypto +- **Solana** : Runtime blockchain +- **Substrate** : Framework pour blockchains +- Performances critiques + sรฉcuritรฉ""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Les forces de Rust" + +[slides.body] +type = "Html" +raw = """ +
    +
  1. Zero-cost abstractions : Performance sans compromis
  2. +
  3. Memory safety : Pas de garbage collector, pas de segfault
  4. +
  5. Concurrence : Ownership model + async/await
  6. +
  7. ร‰cosystรจme : Cargo + crates.io
  8. +
  9. Cross-platform : Linux, macOS, Windows, WASM, mobile
  10. +
""" +alt = """ +- **Zero-cost abstractions** : Performance sans compromis +- **Memory safety** : Pas de garbage collector, pas de segfault +- **Concurrence** : Ownership model + async/await +- **ร‰cosystรจme** : Cargo + crates.io +- **Cross-platform** : Linux, macOS, Windows, WASM, mobile""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "3. Rust sโ€™intรจgre partout" + +[slides.body] +type = "Html" +raw = """ +

WebAssembly (WASM)

+

Python avec PyO3 + Maturin

+

Mobile avec UniFFI

+

Autres intรฉgrations

""" +alt = """ +WebAssembly (WASM) +Python avec PyO3 + Maturin +Mobile avec UniFFI +Autres intรฉgrations""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "WebAssembly (WASM)" + +[slides.body] +type = "Html" +raw = """ +
use wasm_bindgen::prelude::*;
+
+#[wasm_bindgen]
+pub fn process_data(input: &str) -> String {
+    // Logique mรฉtier en Rust
+    format!("Processed: {}", input)
+}
+
+
    +
  • Performance native dans le navigateur
  • +
  • Interopรฉrabilitรฉ JavaScript seamless
  • +
  • Utilisรฉ par Figma, Discord, Dropbox
  • +
""" +alt = """ +``` +use wasm_bindgen::prelude::*; + +#[wasm_bindgen] +pub fn process_data(input: &str) -> String { + // Logique mรฉtier en Rust + format!("Processed: {}", input) +} +``` +- Performance native dans le navigateur +- Interopรฉrabilitรฉ JavaScript seamless +- Utilisรฉ par Figma, Discord, Dropbox""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Python avec PyO3" + +[slides.body] +type = "Html" +raw = """ +
use pyo3::prelude::*;
+
+#[pyfunction]
+fn compute_heavy_task(data: Vec<f64>) -> PyResult<f64> {
+    // Calculs intensifs en Rust
+    Ok(data.iter().sum())
+}
+
+#[pymodule]
+fn mymodule(_py: Python, m: &PyModule) -> PyResult<()> {
+    m.add_function(wrap_pyfunction!(compute_heavy_task, m)?)?;
+    Ok(())
+}
+
+
    +
  • Accรฉlรฉration des parties critiques
  • +
  • Distribution via pip
  • +
  • Exemples : Pydantic v2, Polars
  • +
""" +alt = """ +``` +use pyo3::prelude::*; + +#[pyfunction] +fn compute_heavy_task(data: Vec) -> PyResult { + // Calculs intensifs en Rust + Ok(data.iter().sum()) +} + +#[pymodule] +fn mymodule(_py: Python, m: &PyModule) -> PyResult<()> { + m.add_function(wrap_pyfunction!(compute_heavy_task, m)?)?; + Ok(()) +} +``` +- Accรฉlรฉration des parties critiques +- Distribution via pip +- Exemples : Pydantic v2, Polars""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Mobile avec UniFFI" + +[slides.body] +type = "Html" +raw = """ +
// Logique mรฉtier partagรฉe
+pub struct UserService {
+    // ...
+}
+
+impl UserService {
+    pub fn authenticate(&self, token: String) -> Result<User, Error> {
+        // ...
+    }
+}
+
+
    +
  • Code partagรฉ iOS/Android
  • +
  • Bindings automatiques Swift/Kotlin
  • +
  • Utilisรฉ par Mozilla Firefox
  • +
""" +alt = """ +``` +// Logique mรฉtier partagรฉe +pub struct UserService { + // ... +} + +impl UserService { + pub fn authenticate(&self, token: String) -> Result { + // ... + } +} +``` +- Code partagรฉ iOS/Android +- Bindings automatiques Swift/Kotlin +- Utilisรฉ par Mozilla Firefox""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Autres intรฉgrations" + +[slides.body] +type = "Html" +raw = """ +
    +
  • Node.js : NAPI-RS
  • +
  • Ruby : magnus, rutie
  • +
  • C/C++ : FFI direct
  • +
  • Java : JNI
  • +
  • Go : CGO
  • +
""" +alt = """ +- **Node.js** : NAPI-RS +- **Ruby** : magnus, rutie +- **C/C++** : FFI direct +- **Java** : JNI +- **Go** : CGO""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "4. Rust en startup : Retour dโ€™expรฉrience" + +[slides.body] +type = "Html" +raw = """ +

Pourquoi choisir Rust en startup ?

+

Stratรฉgie dโ€™adoption progressive

+

Success stories startup

""" +alt = """ +Pourquoi choisir Rust en startup ? +Stratรฉgie dโ€™adoption progressive +Success stories startup""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Avantages et dรฉfis" + +[slides.body] +type = "Html" +raw = """ +

Avantages

+
    +
  • Performance : Moins de serveurs = coรปts rรฉduits
  • +
  • Fiabilitรฉ : Moins de bugs en production
  • +
  • Productivitรฉ : Dรฉtection dโ€™erreurs ร  la compilation
  • +
  • ร‰volutivitรฉ : Refactoring sรปr et confiant
  • +
+

Dรฉfis

+
    +
  • Courbe dโ€™apprentissage : Concepts ownership/borrowing
  • +
  • ร‰cosystรจme : Plus jeune que Java/.NET
  • +
  • Recrutement : Dรฉveloppeurs Rust plus rares
  • +
""" +alt = """ +#### Avantages +- **Performance** : Moins de serveurs = coรปts rรฉduits +- **Fiabilitรฉ** : Moins de bugs en production +- **Productivitรฉ** : Dรฉtection dโ€™erreurs ร  la compilation +- **ร‰volutivitรฉ** : Refactoring sรปr et confiant +#### Dรฉfis +- **Courbe dโ€™apprentissage** : Concepts ownership/borrowing +- **ร‰cosystรจme** : Plus jeune que Java/.NET +- **Recrutement** : Dรฉveloppeurs Rust plus rares""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Stratรฉgie dโ€™adoption" + +[slides.body] +type = "Html" +raw = """ +
    +
  1. Microservices critiques : Performance-sensitive
  2. +
  3. Outils internes : CLI, scripts automation
  4. +
  5. Extensions : Plugins Python/Node.js
  6. +
  7. Migration graduelle : Module par module
  8. +
""" +alt = """ +- **Microservices critiques** : Performance-sensitive +- **Outils internes** : CLI, scripts automation +- **Extensions** : Plugins Python/Node.js +- **Migration graduelle** : Module par module""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Success stories startup" + +[slides.body] +type = "Html" +raw = """ +
    +
  • Discord : Backend haute performance
  • +
  • Dropbox : Storage engine
  • +
  • Figma : Moteur de rendu WASM
  • +
  • Vercel : Bundlers (SWC, Turbo)
  • +
""" +alt = """ +- **Discord** : Backend haute performance +- **Dropbox** : Storage engine +- **Figma** : Moteur de rendu WASM +- **Vercel** : Bundlers (SWC, Turbo)""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "Conclusion" + +[slides.body] +type = "Html" +raw = """ +

RIIR : Pas quโ€™un mรจme

+

Quand envisager Rust ?

+

Le futur est rouillรฉ ? ๐Ÿฆ€

""" +alt = """ +RIIR : Pas quโ€™un mรจme +Quand envisager Rust ? +Le futur est rouillรฉ ? ๐Ÿฆ€""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "La rรฉalitรฉ RIIR" + +[slides.body] +type = "Html" +raw = """ +
    +
  • Rรฉalitรฉ technique : Gains mesurables performance/fiabilitรฉ
  • +
  • ร‰cosystรจme mature : Outils production-ready
  • +
  • Adoption croissante : Startups โ†’ GAFAM
  • +
""" +alt = """ +- **Rรฉalitรฉ technique** : Gains mesurables performance/fiabilitรฉ +- **ร‰cosystรจme mature** : Outils production-ready +- **Adoption croissante** : Startups โ†’ GAFAM""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Quand choisir Rust ?" + +[slides.body] +type = "Html" +raw = """ +

โœ… OUI pour :

+
    +
  • Performance critique
  • +
  • Sรฉcuritรฉ prioritaire
  • +
  • Code partagรฉ multi-plateformes
  • +
  • Outils systรจme
  • +
+

โŒ NON pour :

+
    +
  • Prototypage rapide
  • +
  • ร‰quipe junior exclusive
  • +
  • Deadline trรจs serrรฉe
  • +
  • Domain mรฉtier complexe
  • +
""" +alt = """ +โœ… **OUI** pour : +- Performance critique +- Sรฉcuritรฉ prioritaire +- Code partagรฉ multi-plateformes +- Outils systรจme +โŒ **NON** pour : +- Prototypage rapide +- ร‰quipe junior exclusive +- Deadline trรจs serrรฉe +- Domain mรฉtier complexe""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Question finale" + +[slides.body] +type = "Html" +raw = """ +

Rust nโ€™est pas la solution ร  tout, mais il repousse les limites du possible.

+

Question finale : โ€œHave you considered Rewriting It In Rust?โ€

+

Peut-รชtre que la rรฉponse nโ€™est plus si farfelueโ€ฆ

""" +alt = """ +Rust nโ€™est pas la solution ร  tout, mais il repousse les limites du possible. +**Question finale** : *โ€œHave you considered Rewriting It In Rust?โ€* +Peut-รชtre que la rรฉponse nโ€™est plus si farfelueโ€ฆ""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "Ressources" + +[slides.body] +type = "Empty" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Liens utiles" + +[slides.body] +type = "Html" +raw = """ +

Merci pour votre attention !

+""" +alt = """ +*Merci pour votre attention !* +- Rust Book +- RIIR repository +- Are we X yet? +- This Week in Rust""" + +[slides.notes] +type = "Empty" diff --git a/examples/riir-folder-output.toml b/examples/riir-folder-output.toml new file mode 100644 index 0000000..f1f60a8 --- /dev/null +++ b/examples/riir-folder-output.toml @@ -0,0 +1,720 @@ +date = "2025-07-20" + +[title] +type = "Text" +text = "Peut-on RIIR de tout ?" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "Introduction" + +[slides.body] +type = "Empty" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "RIIR Definition" + +[slides.body] +type = "Html" +raw = """ +

RIIR : โ€œHave you considered Rewriting It In Rust?โ€

+

Une question qui fait sourireโ€ฆ mais qui cache une rรฉalitรฉ : Rust gagne du terrain partout.

""" +alt = """ +**RIIR** : โ€œHave you considered Rewriting It In Rust?โ€ +Une question qui fait sourireโ€ฆ mais qui cache une rรฉalitรฉ : Rust gagne du terrain partout.""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "1. Les Success Stories du RIIR" + +[slides.body] +type = "Html" +raw = """ +

Des rรฉรฉcritures qui ont fait leurs preuves

+

Pourquoi ces rรฉรฉcritures rรฉussissent ?

""" +alt = """ +Des rรฉรฉcritures qui ont fait leurs preuves +Pourquoi ces rรฉรฉcritures rรฉussissent ?""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Outils CLI Success Stories" + +[slides.body] +type = "Html" +raw = """ +
    +
  • +

    ripgrep (rg) : grep rรฉรฉcrit en Rust

    +
      +
    • 10x plus rapide que grep classique
    • +
    • Recherche rรฉcursive native
    • +
    • Support Unicode complet
    • +
    +
  • +
  • +

    fd : find rรฉรฉcrit en Rust

    +
      +
    • Interface plus intuitive
    • +
    • Performances supรฉrieures
    • +
    • Respect des .gitignore par dรฉfaut
    • +
    +
  • +
  • +

    Fish Shell : Shell moderne

    +
      +
    • Autocomplรฉtion intelligente
    • +
    • Sรฉcuritรฉ mรฉmoire
    • +
    • Configuration simple
    • +
    +
  • +
""" +alt = """ +- +**ripgrep** (`rg`) : grep rรฉรฉcrit en Rust + - 10x plus rapide que grep classique + - Recherche rรฉcursive native + - Support Unicode complet + +- +**fd** : find rรฉรฉcrit en Rust + - Interface plus intuitive + - Performances supรฉrieures + - Respect des .gitignore par dรฉfaut + +- +**Fish Shell** : Shell moderne + - Autocomplรฉtion intelligente + - Sรฉcuritรฉ mรฉmoire + - Configuration simple""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Pourquoi รงa marche ?" + +[slides.body] +type = "Html" +raw = """ +
    +
  • Performance : Compilation native + optimisations
  • +
  • Sรฉcuritรฉ : Zรฉro segfault, gestion mรฉmoire automatique
  • +
  • Ergonomie : APIs modernes et intuitives
  • +
  • Fiabilitรฉ : System de types expressif
  • +
""" +alt = """ +- **Performance** : Compilation native + optimisations +- **Sรฉcuritรฉ** : Zรฉro segfault, gestion mรฉmoire automatique +- **Ergonomie** : APIs modernes et intuitives +- **Fiabilitรฉ** : System de types expressif""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "2. Rust, le couteau suisse moderne" + +[slides.body] +type = "Html" +raw = """ +

Au-delร  des outils CLI

+

Les forces de Rust

""" +alt = """ +Au-delร  des outils CLI +Les forces de Rust""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Rust partout" + +[slides.body] +type = "Html" +raw = """ +

Rust ne se limite pas aux applications terminal :

+

Web & Backend

+
    +
  • Actix-web, Axum : Serveurs web haute performance
  • +
  • Diesel, SQLx : ORMs type-safe
  • +
  • Tokio : Runtime async de rรฉfรฉrence
  • +
+

Applications Desktop

+
    +
  • Tauri : Alternative ร  Electron
  • +
  • egui, iced : GUI natives
  • +
  • Bevy : Moteur de jeu en ECS
  • +
+

Microcontrรดleurs & IoT

+
    +
  • Embassy : Framework async pour embedded
  • +
  • Support natif ARM, RISC-V
  • +
  • Consommation mรฉmoire optimisรฉe
  • +
+

Blockchain & Crypto

+
    +
  • Solana : Runtime blockchain
  • +
  • Substrate : Framework pour blockchains
  • +
  • Performances critiques + sรฉcuritรฉ
  • +
""" +alt = """ +Rust ne se limite pas aux applications terminal : +#### Web & Backend +- **Actix-web**, **Axum** : Serveurs web haute performance +- **Diesel**, **SQLx** : ORMs type-safe +- **Tokio** : Runtime async de rรฉfรฉrence +#### Applications Desktop +- **Tauri** : Alternative ร  Electron +- **egui**, **iced** : GUI natives +- **Bevy** : Moteur de jeu en ECS +#### Microcontrรดleurs & IoT +- **Embassy** : Framework async pour embedded +- Support natif ARM, RISC-V +- Consommation mรฉmoire optimisรฉe +#### Blockchain & Crypto +- **Solana** : Runtime blockchain +- **Substrate** : Framework pour blockchains +- Performances critiques + sรฉcuritรฉ""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Les forces de Rust" + +[slides.body] +type = "Html" +raw = """ +
    +
  1. Zero-cost abstractions : Performance sans compromis
  2. +
  3. Memory safety : Pas de garbage collector, pas de segfault
  4. +
  5. Concurrence : Ownership model + async/await
  6. +
  7. ร‰cosystรจme : Cargo + crates.io
  8. +
  9. Cross-platform : Linux, macOS, Windows, WASM, mobile
  10. +
""" +alt = """ +- **Zero-cost abstractions** : Performance sans compromis +- **Memory safety** : Pas de garbage collector, pas de segfault +- **Concurrence** : Ownership model + async/await +- **ร‰cosystรจme** : Cargo + crates.io +- **Cross-platform** : Linux, macOS, Windows, WASM, mobile""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "3. Rust sโ€™intรจgre partout" + +[slides.body] +type = "Html" +raw = """ +

WebAssembly (WASM)

+

Python avec PyO3 + Maturin

+

Mobile avec UniFFI

+

Autres intรฉgrations

""" +alt = """ +WebAssembly (WASM) +Python avec PyO3 + Maturin +Mobile avec UniFFI +Autres intรฉgrations""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "WebAssembly (WASM)" + +[slides.body] +type = "Html" +raw = """ +
use wasm_bindgen::prelude::*;
+
+#[wasm_bindgen]
+pub fn process_data(input: &str) -> String {
+    // Logique mรฉtier en Rust
+    format!("Processed: {}", input)
+}
+
+
    +
  • Performance native dans le navigateur
  • +
  • Interopรฉrabilitรฉ JavaScript seamless
  • +
  • Utilisรฉ par Figma, Discord, Dropbox
  • +
""" +alt = """ +``` +use wasm_bindgen::prelude::*; + +#[wasm_bindgen] +pub fn process_data(input: &str) -> String { + // Logique mรฉtier en Rust + format!("Processed: {}", input) +} +``` +- Performance native dans le navigateur +- Interopรฉrabilitรฉ JavaScript seamless +- Utilisรฉ par Figma, Discord, Dropbox""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Python avec PyO3" + +[slides.body] +type = "Html" +raw = """ +
use pyo3::prelude::*;
+
+#[pyfunction]
+fn compute_heavy_task(data: Vec<f64>) -> PyResult<f64> {
+    // Calculs intensifs en Rust
+    Ok(data.iter().sum())
+}
+
+#[pymodule]
+fn mymodule(_py: Python, m: &PyModule) -> PyResult<()> {
+    m.add_function(wrap_pyfunction!(compute_heavy_task, m)?)?;
+    Ok(())
+}
+
+
    +
  • Accรฉlรฉration des parties critiques
  • +
  • Distribution via pip
  • +
  • Exemples : Pydantic v2, Polars
  • +
""" +alt = """ +``` +use pyo3::prelude::*; + +#[pyfunction] +fn compute_heavy_task(data: Vec) -> PyResult { + // Calculs intensifs en Rust + Ok(data.iter().sum()) +} + +#[pymodule] +fn mymodule(_py: Python, m: &PyModule) -> PyResult<()> { + m.add_function(wrap_pyfunction!(compute_heavy_task, m)?)?; + Ok(()) +} +``` +- Accรฉlรฉration des parties critiques +- Distribution via pip +- Exemples : Pydantic v2, Polars""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Mobile avec UniFFI" + +[slides.body] +type = "Html" +raw = """ +
// Logique mรฉtier partagรฉe
+pub struct UserService {
+    // ...
+}
+
+impl UserService {
+    pub fn authenticate(&self, token: String) -> Result<User, Error> {
+        // ...
+    }
+}
+
+
    +
  • Code partagรฉ iOS/Android
  • +
  • Bindings automatiques Swift/Kotlin
  • +
  • Utilisรฉ par Mozilla Firefox
  • +
""" +alt = """ +``` +// Logique mรฉtier partagรฉe +pub struct UserService { + // ... +} + +impl UserService { + pub fn authenticate(&self, token: String) -> Result { + // ... + } +} +``` +- Code partagรฉ iOS/Android +- Bindings automatiques Swift/Kotlin +- Utilisรฉ par Mozilla Firefox""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Autres intรฉgrations" + +[slides.body] +type = "Html" +raw = """ +
    +
  • Node.js : NAPI-RS
  • +
  • Ruby : magnus, rutie
  • +
  • C/C++ : FFI direct
  • +
  • Java : JNI
  • +
  • Go : CGO
  • +
""" +alt = """ +- **Node.js** : NAPI-RS +- **Ruby** : magnus, rutie +- **C/C++** : FFI direct +- **Java** : JNI +- **Go** : CGO""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "4. Rust en startup : Retour dโ€™expรฉrience" + +[slides.body] +type = "Html" +raw = """ +

Pourquoi choisir Rust en startup ?

+

Stratรฉgie dโ€™adoption progressive

+

Success stories startup

""" +alt = """ +Pourquoi choisir Rust en startup ? +Stratรฉgie dโ€™adoption progressive +Success stories startup""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Avantages et dรฉfis" + +[slides.body] +type = "Html" +raw = """ +

Avantages

+
    +
  • Performance : Moins de serveurs = coรปts rรฉduits
  • +
  • Fiabilitรฉ : Moins de bugs en production
  • +
  • Productivitรฉ : Dรฉtection dโ€™erreurs ร  la compilation
  • +
  • ร‰volutivitรฉ : Refactoring sรปr et confiant
  • +
+

Dรฉfis

+
    +
  • Courbe dโ€™apprentissage : Concepts ownership/borrowing
  • +
  • ร‰cosystรจme : Plus jeune que Java/.NET
  • +
  • Recrutement : Dรฉveloppeurs Rust plus rares
  • +
""" +alt = """ +#### Avantages +- **Performance** : Moins de serveurs = coรปts rรฉduits +- **Fiabilitรฉ** : Moins de bugs en production +- **Productivitรฉ** : Dรฉtection dโ€™erreurs ร  la compilation +- **ร‰volutivitรฉ** : Refactoring sรปr et confiant +#### Dรฉfis +- **Courbe dโ€™apprentissage** : Concepts ownership/borrowing +- **ร‰cosystรจme** : Plus jeune que Java/.NET +- **Recrutement** : Dรฉveloppeurs Rust plus rares""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Stratรฉgie dโ€™adoption" + +[slides.body] +type = "Html" +raw = """ +
    +
  1. Microservices critiques : Performance-sensitive
  2. +
  3. Outils internes : CLI, scripts automation
  4. +
  5. Extensions : Plugins Python/Node.js
  6. +
  7. Migration graduelle : Module par module
  8. +
""" +alt = """ +- **Microservices critiques** : Performance-sensitive +- **Outils internes** : CLI, scripts automation +- **Extensions** : Plugins Python/Node.js +- **Migration graduelle** : Module par module""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Success stories startup" + +[slides.body] +type = "Html" +raw = """ +
    +
  • Discord : Backend haute performance
  • +
  • Dropbox : Storage engine
  • +
  • Figma : Moteur de rendu WASM
  • +
  • Vercel : Bundlers (SWC, Turbo)
  • +
""" +alt = """ +- **Discord** : Backend haute performance +- **Dropbox** : Storage engine +- **Figma** : Moteur de rendu WASM +- **Vercel** : Bundlers (SWC, Turbo)""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "Conclusion" + +[slides.body] +type = "Html" +raw = """ +

RIIR : Pas quโ€™un mรจme

+

Quand envisager Rust ?

+

Le futur est rouillรฉ ? ๐Ÿฆ€

""" +alt = """ +RIIR : Pas quโ€™un mรจme +Quand envisager Rust ? +Le futur est rouillรฉ ? ๐Ÿฆ€""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "La rรฉalitรฉ RIIR" + +[slides.body] +type = "Html" +raw = """ +
    +
  • Rรฉalitรฉ technique : Gains mesurables performance/fiabilitรฉ
  • +
  • ร‰cosystรจme mature : Outils production-ready
  • +
  • Adoption croissante : Startups โ†’ GAFAM
  • +
""" +alt = """ +- **Rรฉalitรฉ technique** : Gains mesurables performance/fiabilitรฉ +- **ร‰cosystรจme mature** : Outils production-ready +- **Adoption croissante** : Startups โ†’ GAFAM""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Quand choisir Rust ?" + +[slides.body] +type = "Html" +raw = """ +

โœ… OUI pour :

+
    +
  • Performance critique
  • +
  • Sรฉcuritรฉ prioritaire
  • +
  • Code partagรฉ multi-plateformes
  • +
  • Outils systรจme
  • +
+

โŒ NON pour :

+
    +
  • Prototypage rapide
  • +
  • ร‰quipe junior exclusive
  • +
  • Deadline trรจs serrรฉe
  • +
  • Domain mรฉtier complexe
  • +
""" +alt = """ +โœ… **OUI** pour : +- Performance critique +- Sรฉcuritรฉ prioritaire +- Code partagรฉ multi-plateformes +- Outils systรจme +โŒ **NON** pour : +- Prototypage rapide +- ร‰quipe junior exclusive +- Deadline trรจs serrรฉe +- Domain mรฉtier complexe""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Question finale" + +[slides.body] +type = "Html" +raw = """ +

Rust nโ€™est pas la solution ร  tout, mais il repousse les limites du possible.

+

Question finale : โ€œHave you considered Rewriting It In Rust?โ€

+

Peut-รชtre que la rรฉponse nโ€™est plus si farfelueโ€ฆ

""" +alt = """ +Rust nโ€™est pas la solution ร  tout, mais il repousse les limites du possible. +**Question finale** : *โ€œHave you considered Rewriting It In Rust?โ€* +Peut-รชtre que la rรฉponse nโ€™est plus si farfelueโ€ฆ""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "Ressources" + +[slides.body] +type = "Empty" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Liens utiles" + +[slides.body] +type = "Html" +raw = """ +

Merci pour votre attention !

+""" +alt = """ +*Merci pour votre attention !* +- Rust Book +- RIIR repository +- Are we X yet? +- This Week in Rust""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Cover" +style = [] + +[slides.title] +type = "Text" +text = "Peut-on RIIR de tout ?" + +[slides.body] +type = "Empty" + +[slides.notes] +type = "Text" +text = "Rewriting It In Rust - De la startup aux multinationales" diff --git a/examples/riir-folder/01-introduction/01-riir-definition.md b/examples/riir-folder/01-introduction/01-riir-definition.md new file mode 100644 index 0000000..13f21a0 --- /dev/null +++ b/examples/riir-folder/01-introduction/01-riir-definition.md @@ -0,0 +1,5 @@ +# RIIR Definition + +**RIIR** : "Have you considered Rewriting It In Rust?" + +Une question qui fait sourireโ€ฆ mais qui cache une rรฉalitรฉ : Rust gagne du terrain partout. \ No newline at end of file diff --git a/examples/riir-folder/01-introduction/_part.md b/examples/riir-folder/01-introduction/_part.md new file mode 100644 index 0000000..f6ecaa6 --- /dev/null +++ b/examples/riir-folder/01-introduction/_part.md @@ -0,0 +1 @@ +# Introduction \ No newline at end of file diff --git a/examples/riir-folder/02-success-stories/01-tools.md b/examples/riir-folder/02-success-stories/01-tools.md new file mode 100644 index 0000000..8e357a1 --- /dev/null +++ b/examples/riir-folder/02-success-stories/01-tools.md @@ -0,0 +1,16 @@ +# Outils CLI Success Stories + +- **ripgrep** (`rg`) : grep rรฉรฉcrit en Rust + - 10x plus rapide que grep classique + - Recherche rรฉcursive native + - Support Unicode complet + +- **fd** : find rรฉรฉcrit en Rust + - Interface plus intuitive + - Performances supรฉrieures + - Respect des .gitignore par dรฉfaut + +- **Fish Shell** : Shell moderne + - Autocomplรฉtion intelligente + - Sรฉcuritรฉ mรฉmoire + - Configuration simple \ No newline at end of file diff --git a/examples/riir-folder/02-success-stories/02-reasons.md b/examples/riir-folder/02-success-stories/02-reasons.md new file mode 100644 index 0000000..72b3909 --- /dev/null +++ b/examples/riir-folder/02-success-stories/02-reasons.md @@ -0,0 +1,6 @@ +# Pourquoi รงa marche ? + +- **Performance** : Compilation native + optimisations +- **Sรฉcuritรฉ** : Zรฉro segfault, gestion mรฉmoire automatique +- **Ergonomie** : APIs modernes et intuitives +- **Fiabilitรฉ** : System de types expressif \ No newline at end of file diff --git a/examples/riir-folder/02-success-stories/_part.md b/examples/riir-folder/02-success-stories/_part.md new file mode 100644 index 0000000..53c986c --- /dev/null +++ b/examples/riir-folder/02-success-stories/_part.md @@ -0,0 +1,5 @@ +# 1. Les Success Stories du RIIR + +Des rรฉรฉcritures qui ont fait leurs preuves + +Pourquoi ces rรฉรฉcritures rรฉussissent ? \ No newline at end of file diff --git a/examples/riir-folder/03-rust-ecosystem/01-domains.md b/examples/riir-folder/03-rust-ecosystem/01-domains.md new file mode 100644 index 0000000..e59586f --- /dev/null +++ b/examples/riir-folder/03-rust-ecosystem/01-domains.md @@ -0,0 +1,23 @@ +# Rust partout + +Rust ne se limite pas aux applications terminal : + +#### Web & Backend +- **Actix-web**, **Axum** : Serveurs web haute performance +- **Diesel**, **SQLx** : ORMs type-safe +- **Tokio** : Runtime async de rรฉfรฉrence + +#### Applications Desktop +- **Tauri** : Alternative ร  Electron +- **egui**, **iced** : GUI natives +- **Bevy** : Moteur de jeu en ECS + +#### Microcontrรดleurs & IoT +- **Embassy** : Framework async pour embedded +- Support natif ARM, RISC-V +- Consommation mรฉmoire optimisรฉe + +#### Blockchain & Crypto +- **Solana** : Runtime blockchain +- **Substrate** : Framework pour blockchains +- Performances critiques + sรฉcuritรฉ \ No newline at end of file diff --git a/examples/riir-folder/03-rust-ecosystem/02-strengths.md b/examples/riir-folder/03-rust-ecosystem/02-strengths.md new file mode 100644 index 0000000..07f53a7 --- /dev/null +++ b/examples/riir-folder/03-rust-ecosystem/02-strengths.md @@ -0,0 +1,7 @@ +# Les forces de Rust + +1. **Zero-cost abstractions** : Performance sans compromis +2. **Memory safety** : Pas de garbage collector, pas de segfault +3. **Concurrence** : Ownership model + async/await +4. **ร‰cosystรจme** : Cargo + crates.io +5. **Cross-platform** : Linux, macOS, Windows, WASM, mobile \ No newline at end of file diff --git a/examples/riir-folder/03-rust-ecosystem/_part.md b/examples/riir-folder/03-rust-ecosystem/_part.md new file mode 100644 index 0000000..6ab5d4e --- /dev/null +++ b/examples/riir-folder/03-rust-ecosystem/_part.md @@ -0,0 +1,5 @@ +# 2. Rust, le couteau suisse moderne + +Au-delร  des outils CLI + +Les forces de Rust \ No newline at end of file diff --git a/examples/riir-folder/04-integration/01-wasm.md b/examples/riir-folder/04-integration/01-wasm.md new file mode 100644 index 0000000..d5c74e1 --- /dev/null +++ b/examples/riir-folder/04-integration/01-wasm.md @@ -0,0 +1,15 @@ +# WebAssembly (WASM) + +```rust +use wasm_bindgen::prelude::*; + +#[wasm_bindgen] +pub fn process_data(input: &str) -> String { + // Logique mรฉtier en Rust + format!("Processed: {}", input) +} +``` + +- Performance native dans le navigateur +- Interopรฉrabilitรฉ JavaScript seamless +- Utilisรฉ par Figma, Discord, Dropbox \ No newline at end of file diff --git a/examples/riir-folder/04-integration/02-python.md b/examples/riir-folder/04-integration/02-python.md new file mode 100644 index 0000000..034d873 --- /dev/null +++ b/examples/riir-folder/04-integration/02-python.md @@ -0,0 +1,21 @@ +# Python avec PyO3 + +```rust +use pyo3::prelude::*; + +#[pyfunction] +fn compute_heavy_task(data: Vec) -> PyResult { + // Calculs intensifs en Rust + Ok(data.iter().sum()) +} + +#[pymodule] +fn mymodule(_py: Python, m: &PyModule) -> PyResult<()> { + m.add_function(wrap_pyfunction!(compute_heavy_task, m)?)?; + Ok(()) +} +``` + +- Accรฉlรฉration des parties critiques +- Distribution via pip +- Exemples : Pydantic v2, Polars \ No newline at end of file diff --git a/examples/riir-folder/04-integration/03-mobile.md b/examples/riir-folder/04-integration/03-mobile.md new file mode 100644 index 0000000..156a1e0 --- /dev/null +++ b/examples/riir-folder/04-integration/03-mobile.md @@ -0,0 +1,18 @@ +# Mobile avec UniFFI + +```rust +// Logique mรฉtier partagรฉe +pub struct UserService { + // ... +} + +impl UserService { + pub fn authenticate(&self, token: String) -> Result { + // ... + } +} +``` + +- Code partagรฉ iOS/Android +- Bindings automatiques Swift/Kotlin +- Utilisรฉ par Mozilla Firefox \ No newline at end of file diff --git a/examples/riir-folder/04-integration/04-others.md b/examples/riir-folder/04-integration/04-others.md new file mode 100644 index 0000000..143595c --- /dev/null +++ b/examples/riir-folder/04-integration/04-others.md @@ -0,0 +1,7 @@ +# Autres intรฉgrations + +- **Node.js** : NAPI-RS +- **Ruby** : magnus, rutie +- **C/C++** : FFI direct +- **Java** : JNI +- **Go** : CGO \ No newline at end of file diff --git a/examples/riir-folder/04-integration/_part.md b/examples/riir-folder/04-integration/_part.md new file mode 100644 index 0000000..3d33d0c --- /dev/null +++ b/examples/riir-folder/04-integration/_part.md @@ -0,0 +1,9 @@ +# 3. Rust s'intรจgre partout + +WebAssembly (WASM) + +Python avec PyO3 + Maturin + +Mobile avec UniFFI + +Autres intรฉgrations \ No newline at end of file diff --git a/examples/riir-folder/05-startup/01-pros-cons.md b/examples/riir-folder/05-startup/01-pros-cons.md new file mode 100644 index 0000000..3969dad --- /dev/null +++ b/examples/riir-folder/05-startup/01-pros-cons.md @@ -0,0 +1,12 @@ +# Avantages et dรฉfis + +#### Avantages +- **Performance** : Moins de serveurs = coรปts rรฉduits +- **Fiabilitรฉ** : Moins de bugs en production +- **Productivitรฉ** : Dรฉtection d'erreurs ร  la compilation +- **ร‰volutivitรฉ** : Refactoring sรปr et confiant + +#### Dรฉfis +- **Courbe d'apprentissage** : Concepts ownership/borrowing +- **ร‰cosystรจme** : Plus jeune que Java/.NET +- **Recrutement** : Dรฉveloppeurs Rust plus rares \ No newline at end of file diff --git a/examples/riir-folder/05-startup/02-strategy.md b/examples/riir-folder/05-startup/02-strategy.md new file mode 100644 index 0000000..f7d0f5d --- /dev/null +++ b/examples/riir-folder/05-startup/02-strategy.md @@ -0,0 +1,6 @@ +# Stratรฉgie d'adoption + +1. **Microservices critiques** : Performance-sensitive +2. **Outils internes** : CLI, scripts automation +3. **Extensions** : Plugins Python/Node.js +4. **Migration graduelle** : Module par module \ No newline at end of file diff --git a/examples/riir-folder/05-startup/03-success-stories.md b/examples/riir-folder/05-startup/03-success-stories.md new file mode 100644 index 0000000..fc6be5d --- /dev/null +++ b/examples/riir-folder/05-startup/03-success-stories.md @@ -0,0 +1,6 @@ +# Success stories startup + +- **Discord** : Backend haute performance +- **Dropbox** : Storage engine +- **Figma** : Moteur de rendu WASM +- **Vercel** : Bundlers (SWC, Turbo) \ No newline at end of file diff --git a/examples/riir-folder/05-startup/_part.md b/examples/riir-folder/05-startup/_part.md new file mode 100644 index 0000000..cea3c80 --- /dev/null +++ b/examples/riir-folder/05-startup/_part.md @@ -0,0 +1,7 @@ +# 4. Rust en startup : Retour d'expรฉrience + +Pourquoi choisir Rust en startup ? + +Stratรฉgie d'adoption progressive + +Success stories startup \ No newline at end of file diff --git a/examples/riir-folder/06-conclusion/01-reality.md b/examples/riir-folder/06-conclusion/01-reality.md new file mode 100644 index 0000000..8cc29ce --- /dev/null +++ b/examples/riir-folder/06-conclusion/01-reality.md @@ -0,0 +1,5 @@ +# La rรฉalitรฉ RIIR + +- **Rรฉalitรฉ technique** : Gains mesurables performance/fiabilitรฉ +- **ร‰cosystรจme mature** : Outils production-ready +- **Adoption croissante** : Startups โ†’ GAFAM \ No newline at end of file diff --git a/examples/riir-folder/06-conclusion/02-when.md b/examples/riir-folder/06-conclusion/02-when.md new file mode 100644 index 0000000..0ed989e --- /dev/null +++ b/examples/riir-folder/06-conclusion/02-when.md @@ -0,0 +1,13 @@ +# Quand choisir Rust ? + +โœ… **OUI** pour : +- Performance critique +- Sรฉcuritรฉ prioritaire +- Code partagรฉ multi-plateformes +- Outils systรจme + +โŒ **NON** pour : +- Prototypage rapide +- ร‰quipe junior exclusive +- Deadline trรจs serrรฉe +- Domain mรฉtier complexe \ No newline at end of file diff --git a/examples/riir-folder/06-conclusion/03-final.md b/examples/riir-folder/06-conclusion/03-final.md new file mode 100644 index 0000000..d77e515 --- /dev/null +++ b/examples/riir-folder/06-conclusion/03-final.md @@ -0,0 +1,7 @@ +# Question finale + +Rust n'est pas la solution ร  tout, mais il repousse les limites du possible. + +**Question finale** : *"Have you considered Rewriting It In Rust?"* + +Peut-รชtre que la rรฉponse n'est plus si farfelueโ€ฆ \ No newline at end of file diff --git a/examples/riir-folder/06-conclusion/_part.md b/examples/riir-folder/06-conclusion/_part.md new file mode 100644 index 0000000..a5de45f --- /dev/null +++ b/examples/riir-folder/06-conclusion/_part.md @@ -0,0 +1,7 @@ +# Conclusion + +RIIR : Pas qu'un mรจme + +Quand envisager Rust ? + +Le futur est rouillรฉ ? ๐Ÿฆ€ \ No newline at end of file diff --git a/examples/riir-folder/07-resources/01-links.md b/examples/riir-folder/07-resources/01-links.md new file mode 100644 index 0000000..5f8058b --- /dev/null +++ b/examples/riir-folder/07-resources/01-links.md @@ -0,0 +1,8 @@ +# Liens utiles + +*Merci pour votre attention !* + +- [Rust Book](https://doc.rust-lang.org/book/) +- [RIIR repository](https://github.com/ansuz/RIIR) +- [Are we X yet?](https://wiki.mozilla.org/Areweyet) +- [This Week in Rust](https://this-week-in-rust.org/) \ No newline at end of file diff --git a/examples/riir-folder/07-resources/_part.md b/examples/riir-folder/07-resources/_part.md new file mode 100644 index 0000000..e812bc6 --- /dev/null +++ b/examples/riir-folder/07-resources/_part.md @@ -0,0 +1 @@ +# Ressources \ No newline at end of file diff --git a/examples/riir-folder/_cover.md b/examples/riir-folder/_cover.md new file mode 100644 index 0000000..ffc4946 --- /dev/null +++ b/examples/riir-folder/_cover.md @@ -0,0 +1,3 @@ +# Peut-on RIIR de tout ? + +> Rewriting It In Rust - De la startup aux multinationales \ No newline at end of file diff --git a/examples/riir-folder/title.md b/examples/riir-folder/title.md new file mode 100644 index 0000000..c55ba04 --- /dev/null +++ b/examples/riir-folder/title.md @@ -0,0 +1 @@ +Peut-on RIIR de tout ? \ No newline at end of file diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..2a732ff --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,5 @@ +# Rustfmt configuration +edition = "2024" +unstable_features = true +imports_granularity = "Module" +group_imports = "StdExternalCrate" \ No newline at end of file diff --git a/toboggan-cli/Cargo.toml b/toboggan-cli/Cargo.toml new file mode 100644 index 0000000..256edbb --- /dev/null +++ b/toboggan-cli/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "toboggan-cli" +version = "0.1.0" +edition.workspace = true +description = "Command-line interface for creating and converting Toboggan presentation files" +keywords = ["presentation", "slides", "markdown", "toml", "cli"] +categories = ["command-line-utilities", "text-processing"] +repository = "https://github.com/ilaborie/toboggan" +documentation = "https://docs.rs/toboggan-cli" +readme = "README.md" +rust-version.workspace = true +license.workspace = true +authors.workspace = true + +[[bin]] +name = "toboggan-cli" +path = "src/main.rs" + +[dependencies] +toboggan-core = {path = "../toboggan-core", features = ["std"]} + +toml = {workspace = true} +serde_json = {workspace = true} +serde-saphyr = {workspace = true} +ciborium = {workspace = true} +rmp-serde = {workspace = true} +bincode = {workspace = true} +tracing = {workspace = true} +tracing-subscriber = { workspace = true, features = ["env-filter"] } +clap = { workspace = true, features = ["env", "derive"] } +serde = {workspace = true, features = ["derive"]} +miette = { workspace =true, features = ["fancy"] } +derive_more = { workspace =true, features = ["error", "from", "display"]} +comrak = {workspace = true} +regex = {workspace = true} +owo-colors = { workspace = true, features = ["supports-colors"] } +humantime = {workspace = true} +comfy-table = {workspace = true} + +[dev-dependencies] +tempfile = { workspace = true } +anyhow = { workspace = true } + +[lints] +workspace = true diff --git a/toboggan-cli/README.md b/toboggan-cli/README.md new file mode 100644 index 0000000..7ace803 --- /dev/null +++ b/toboggan-cli/README.md @@ -0,0 +1,378 @@ +# Toboggan CLI + +Convert Markdown presentations to Toboggan format with advanced features for speaker notes, progressive reveals, and presentation statistics. + +[![Crates.io](https://img.shields.io/crates/v/toboggan-cli.svg)](https://crates.io/crates/toboggan-cli) +[![Documentation](https://docs.rs/toboggan-cli/badge.svg)](https://docs.rs/toboggan-cli) +[![License](https://img.shields.io/badge/license-MIT%2FApache--2.0-blue.svg)](https://github.com/ilaborie/toboggan) + +## Installation + +```bash +# From source +cargo install --path toboggan-cli + +# From crates.io (when published) +cargo install toboggan-cli +``` + +## Quick Start + +```bash +# Convert a presentation folder to TOML +toboggan-cli slides/ -o presentation.toml + +# Override title and date +toboggan-cli slides/ --title "My Talk" --date "2025-03-15" -o talk.toml + +# Use different output format +toboggan-cli slides/ -f json -o presentation.json +``` + +## CLI Options + +``` +toboggan-cli [OPTIONS] + +Arguments: + Input folder containing presentation files + +Options: + -o, --output Output file (default: stdout) + -t, --title Override presentation title + -d, --date <DATE> Override date (YYYY-MM-DD format) + -f, --format <FORMAT> Output format: toml, json, yaml, cbor, msgpack, bincode + (auto-detected from file extension if not specified) + --theme <THEME> Syntax highlighting theme (default: base16-ocean.light) + --list-themes List all available syntax highlighting themes + --no-counter Disable automatic numbering of parts and slides + --no-stats Disable presentation statistics display + --wpm <WPM> Speaking rate in words per minute (default: 150) + --exclude-notes-from-duration Exclude speaker notes from duration calculations + -h, --help Print help information +``` + +## Folder Structure + +Toboggan CLI processes a folder hierarchy to create presentations: + +``` +my-presentation/ +โ”œโ”€โ”€ _cover.md # Cover slide (optional, contains title/date) +โ”œโ”€โ”€ _footer.md # Footer content for all slides (optional) +โ”œโ”€โ”€ 01-intro/ # Part folder (becomes section divider) +โ”‚ โ”œโ”€โ”€ _part.md # Part slide content +โ”‚ โ”œโ”€โ”€ slide1.md # Regular slides +โ”‚ โ””โ”€โ”€ slide2.md +โ”œโ”€โ”€ 02-main/ +โ”‚ โ”œโ”€โ”€ _part.md +โ”‚ โ””โ”€โ”€ content.md +โ””โ”€โ”€ 99-conclusion.md # Standalone slide +``` + +### Special Files + +| File | Purpose | +|------|---------| +| `_cover.md` | Cover slide with presentation title and date | +| `_part.md` | Section divider slide within a folder | +| `_footer.md` | Footer content applied to all slides | + +### Processing Rules + +- Files are processed in **alphabetical order** +- Folders become **Part slides** (section dividers) +- Both `.md` and `.html` files are supported +- Hidden files (starting with `.`) are ignored +- Inline HTML tags (like `<abbr>`, `<mark>`) are preserved + +## Frontmatter + +Add TOML frontmatter to any slide using `+++` delimiters, the content should be TOML: + +```markdown ++++ +title = "Custom Slide Title" +skip = false # Set to true to exclude from output +classes = ["centered", "dark"] # CSS classes +css = "background: linear-gradient(...);" # Inline CSS +css_file = "path/to/styles.css" # External CSS file +grid = true # Enable grid layout +duration = "2m 30s" # Slide duration (or use seconds: 150) ++++ + +# Slide Content +Your content here... +``` + +### Cover Slide Frontmatter + +The `_cover.md` file can set presentation-wide metadata: + +```markdown ++++ +title = "My Awesome Presentation" +date = "2025-03-15" ++++ + +# Welcome +Subtitle or opening content +``` + +## Advanced Features + +### Progressive Reveals (Steps) + +Use `<!-- pause -->` comments to reveal content step-by-step: + +```markdown +# Key Points + +First point appears immediately + +<!-- pause --> +Second point appears on next step + +<!-- pause: highlight --> +Third point appears with highlight class +``` + +### Grid Layouts + +Create multi-column layouts with `<!-- cell -->` comments: + +```markdown ++++ +grid = true ++++ + +# Two Column Slide + +<!-- cell --> +Left column content +- Point 1 +- Point 2 + +<!-- cell: highlight --> +Right column content with highlight class +- Point A +- Point B +``` + +### Speaker Notes + +Add presenter notes that won't be shown during presentation: + +```markdown +# Main Slide Content + +Your visible content here + +<!-- notes --> +These are speaker notes: +- Remember to mention the demo +- Ask for questions +- Time check: should be at 10 minutes +``` + +### Code Blocks from Files + +Include code from external files: + +```markdown +# Code Example + +Here's our implementation: + +<!-- code:rust:src/main.rs --> + +This will be replaced with the contents of src/main.rs +``` + +## Presentation Statistics + +The CLI provides comprehensive statistics about your presentation: + +### Overview Metrics +- Total slides and parts +- Word count (body + optional notes) +- Bullet points and images +- Estimated duration at your speaking rate + +### Part Breakdown +Shows distribution of content across sections: +- Slides per part +- Words and percentage of total +- Estimated duration per part + +### Duration Scenarios +Calculates presentation length for different speaking rates: +- Slow (110 WPM) +- Normal (150 WPM) +- Fast (170 WPM) +- Custom (your --wpm setting) +- Additional time for images (5 seconds each) + +### Recommendations + +The tool provides smart recommendations when: + +| Condition | Recommendation | +|-----------|----------------| +| Duration > 50 minutes | Consider splitting into multiple presentations | +| Duration < 2 minutes | Presentation might be too short | +| One part > 50% of content | Consider splitting that part | +| > 100 words/slide average | High density - use more slides with less text | +| < 20 words/slide average | Low density - slides might need more content | + +## Live Development with Bacon + +For live updates while editing your presentation, use [bacon](https://dystroy.org/bacon/): + +### Setup + +Create a `bacon.toml` in your project root: + +```toml +default_job = "toboggan" + +[jobs.toboggan] +command = ["toboggan-cli", "./slides/", "--output", "presentation.toml"] +need_stdout = true +allow_warnings = true +default_watch = false +watch = ["slides"] # Watch your presentation folder +``` + +### Usage + +```bash +# Install bacon if needed +cargo install bacon + +# Run with live reload +bacon + +# Or run specific job +bacon toboggan +``` + +Now your `presentation.toml` will automatically rebuild whenever you edit files in the `slides/` folder! + +## Output Formats + +Toboggan CLI supports multiple output formats: + +| Format | Extension | Description | +|--------|-----------|-------------| +| TOML | `.toml` | Default, human-readable | +| JSON | `.json` | Web-friendly, readable | +| YAML | `.yaml`, `.yml` | Alternative readable format | +| CBOR | `.cbor` | Compact binary, standardized | +| MessagePack | `.msgpack` | Ultra-compact binary | +| Bincode | `.bincode`, `.bin` | Rust-native, fastest | + +Format is auto-detected from file extension or specify with `-f`: + +```bash +# Auto-detect from extension +toboggan-cli slides/ -o presentation.json + +# Explicit format +toboggan-cli slides/ -f yaml -o output.txt +``` + +## Examples + +### Basic Presentation + +```bash +# Create structure +mkdir -p my-talk/01-intro + +# Create cover +cat > my-talk/_cover.md << 'EOF' ++++ +title = "Introduction to Rust" +date = "2025-03-15" ++++ + +# Welcome to Rust Programming +EOF + +# Create part +echo "# Chapter 1: Getting Started" > my-talk/01-intro/_part.md + +# Create slide +cat > my-talk/01-intro/hello.md << 'EOF' +# Hello World + +<!-- pause --> +```rust +fn main() { + println!("Hello, world!"); +} +``` + +<!-- notes --> +Explain that println! is a macro, not a function +EOF + +# Convert +toboggan-cli my-talk/ -o presentation.toml +``` + +### Batch Processing + +```bash +#!/bin/bash +# Convert all presentations in a directory + +for dir in presentations/*/; do + name=$(basename "$dir") + toboggan-cli "$dir" \ + --date "$(date +%Y-%m-%d)" \ + --wpm 130 \ + -o "output/${name}.toml" +done +``` + +### CI/CD Integration + +```yaml +# GitHub Actions example +- name: Build Presentations + run: | + for dir in presentations/*/; do + toboggan-cli "$dir" -f json -o "dist/$(basename "$dir").json" + done +``` + +## Troubleshooting + +### Common Issues + +**Missing syntax highlighting** +- Use `--list-themes` to see available themes +- Specify language in code blocks: ` ```rust` + +**Incorrect duration estimates** +- Adjust `--wpm` to match your speaking pace +- Use `--exclude-notes-from-duration` if notes are just reminders + +**Files processed in wrong order** +- Prefix with numbers: `01-intro.md`, `02-main.md` +- Use folders for logical grouping + +## Contributing + +Contributions welcome! Please see the [main repository](https://github.com/ilaborie/toboggan) for guidelines. + +## License + +Licensed under either of: + +- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE)) +- MIT license ([LICENSE-MIT](LICENSE-MIT)) + +at your option. diff --git a/toboggan-cli/examples/parse_slide.rs b/toboggan-cli/examples/parse_slide.rs new file mode 100644 index 0000000..124c22f --- /dev/null +++ b/toboggan-cli/examples/parse_slide.rs @@ -0,0 +1,27 @@ +use comrak::{Arena, Options, parse_document}; +use toboggan_cli::Result; +use toboggan_cli::parser::SlideContentParser; +use toboggan_core::Content; + +#[allow(clippy::print_stdout, clippy::result_large_err)] +fn main() -> Result<()> { + let raw = include_str!("./slide.md"); + + let arena = Arena::new(); + let options = Options::default(); + + let doc = parse_document(&arena, raw, &options); + + let content_parser = SlideContentParser::new(); + let (slide, _) = + content_parser.parse_with_defaults(doc.children(), Some("example-slide"), None)?; + + println!("{slide:#?}"); + println!("==="); + + if let Content::Html { raw, .. } = slide.body { + println!("{raw}"); + } + + Ok(()) +} diff --git a/toboggan-cli/examples/slide.md b/toboggan-cli/examples/slide.md new file mode 100644 index 0000000..80fac6d --- /dev/null +++ b/toboggan-cli/examples/slide.md @@ -0,0 +1,39 @@ ++++ +title= "xxx" +classes= ["wer", "sdf"] +css = """ +.step { color:red;} +""" ++++ + +# Title + +A paragraph not _very_ **long**. + +<!-- pause --> + +```rust +fn main() { + println!("Hello"); +} +``` + +<!-- pause --> + +1. test +2. test again + +--- + +why not + +<!-- A comment --> + +--- + +more + +<!-- notes --> + +- Speaker notes +- sdfsdf diff --git a/toboggan-cli/rustfmt.toml b/toboggan-cli/rustfmt.toml new file mode 100644 index 0000000..2a732ff --- /dev/null +++ b/toboggan-cli/rustfmt.toml @@ -0,0 +1,5 @@ +# Rustfmt configuration +edition = "2024" +unstable_features = true +imports_granularity = "Module" +group_imports = "StdExternalCrate" \ No newline at end of file diff --git a/toboggan-cli/src/available_themes.txt b/toboggan-cli/src/available_themes.txt new file mode 100644 index 0000000..003b3a8 --- /dev/null +++ b/toboggan-cli/src/available_themes.txt @@ -0,0 +1,31 @@ +Available syntax highlighting themes: + +Base16 themes: + base16-ocean.dark + base16-ocean.light (default) + base16-mocha.dark + base16-eighties.dark + +Classic themes: + InspiredGitHub + Solarized (dark) + Solarized (light) + +Monokai themes: + Monokai + Monokai Extended + Monokai Extended Light + Monokai Extended Bright + +Other popular themes: + gruvbox-dark + gruvbox-light + Nord + Dracula + OneHalfDark + OneHalfLight + ayu-dark + ayu-light + ayu-mirage + +Note: Theme names are case-sensitive. diff --git a/toboggan-cli/src/display.rs b/toboggan-cli/src/display.rs new file mode 100644 index 0000000..6e10c1b --- /dev/null +++ b/toboggan-cli/src/display.rs @@ -0,0 +1,375 @@ +use std::io::{IsTerminal, Write}; + +use owo_colors::OwoColorize; +use toboggan_core::SlideKind; + +use crate::{ParseResult, SlideProcessingResult}; + +#[derive(Debug, Clone)] +pub struct DisplayConfig { + pub use_colors: bool, +} + +impl Default for DisplayConfig { + fn default() -> Self { + Self { + use_colors: Self::should_use_colors(), + } + } +} + +impl DisplayConfig { + #[must_use] + pub fn should_use_colors() -> bool { + if std::env::var("NO_COLOR").is_ok() { + return false; + } + + std::io::stdout().is_terminal() + } + + #[must_use] + pub fn no_colors() -> Self { + Self { use_colors: false } + } + + #[must_use] + pub fn with_colors() -> Self { + Self { use_colors: true } + } +} + +pub struct DisplayFormatter { + config: DisplayConfig, +} + +impl DisplayFormatter { + #[must_use] + pub fn new() -> Self { + Self { + config: DisplayConfig::default(), + } + } + + #[must_use] + pub fn with_config(config: DisplayConfig) -> Self { + Self { config } + } + + pub fn display_results<W: Write>( + &self, + results: &ParseResult, + writer: &mut W, + ) -> std::io::Result<()> { + self.display_header(results, writer)?; + self.display_slides(results, writer)?; + self.display_summary(results, writer)?; + Ok(()) + } + + fn display_header<W: Write>( + &self, + results: &ParseResult, + writer: &mut W, + ) -> std::io::Result<()> { + write!(writer, "Talk: ")?; + if self.config.use_colors { + write!(writer, "{}", results.talk_metadata.title.bold().blue())?; + } else { + write!(writer, "{}", results.talk_metadata.title)?; + } + writeln!(writer)?; + Ok(()) + } + + fn display_slides<W: Write>( + &self, + results: &ParseResult, + writer: &mut W, + ) -> std::io::Result<()> { + for slide_result in &results.slides { + self.display_slide_result(slide_result, writer)?; + } + Ok(()) + } + + fn display_slide_result<W: Write>( + &self, + slide_result: &SlideProcessingResult, + writer: &mut W, + ) -> std::io::Result<()> { + match slide_result { + SlideProcessingResult::Processed(slide) => { + let title = slide.title.to_string(); + + let indent = match slide.kind { + SlideKind::Part | SlideKind::Cover => "", + SlideKind::Standard => " ", + }; + write!(writer, "{indent}")?; + self.write_slide_title(&title, slide.kind, false, writer)?; + writeln!(writer)?; + } + SlideProcessingResult::Skipped(slide) => { + let title = slide.title.to_string(); + + let indent = match slide.kind { + SlideKind::Part => "", + _ => " ", // 2 spaces for standard and cover slides + }; + write!(writer, "{indent}")?; + self.write_status_indicator("[SKIP]", StatusColor::Yellow, writer)?; + write!(writer, " ")?; + self.write_slide_title(&title, slide.kind, true, writer)?; + writeln!(writer)?; + } + SlideProcessingResult::Ignored(description) => { + self.write_status_indicator("[IGNORE]", StatusColor::Gray, writer)?; + write!(writer, " ")?; + if self.config.use_colors { + write!(writer, "{}", description.dimmed())?; + } else { + write!(writer, "{description}")?; + } + writeln!(writer)?; + } + SlideProcessingResult::Error(description) => { + self.write_status_indicator("[ERROR]", StatusColor::Red, writer)?; + write!(writer, " {description}")?; + writeln!(writer)?; + } + } + Ok(()) + } + + fn write_slide_title<W: Write>( + &self, + title: &str, + kind: SlideKind, + is_skipped: bool, + writer: &mut W, + ) -> std::io::Result<()> { + if self.config.use_colors { + match kind { + SlideKind::Cover => { + if is_skipped { + write!(writer, "{}", title.dimmed().blue())?; + } else { + write!(writer, "{}", title.bold().blue())?; + } + } + SlideKind::Part => { + if is_skipped { + write!(writer, "{}", title.dimmed().green())?; + } else { + write!(writer, "{}", title.bold().green())?; + } + } + SlideKind::Standard => { + if is_skipped { + write!(writer, "{}", title.dimmed())?; + } else { + write!(writer, "{title}")?; + } + } + } + } else { + write!(writer, "{title}")?; + } + Ok(()) + } + + fn write_status_indicator<W: Write>( + &self, + text: &str, + color: StatusColor, + writer: &mut W, + ) -> std::io::Result<()> { + if self.config.use_colors { + match color { + StatusColor::Green => { + write!(writer, "{}", text.green())?; + } + StatusColor::Yellow => { + write!(writer, "{}", text.yellow())?; + } + StatusColor::Gray => { + write!(writer, "{}", text.bright_black())?; + } + StatusColor::Red => { + write!(writer, "{}", text.red())?; + } + } + } else { + write!(writer, "{text}")?; + } + Ok(()) + } + + fn display_summary<W: Write>( + &self, + results: &ParseResult, + writer: &mut W, + ) -> std::io::Result<()> { + let stats = results.stats(); + + if stats.total() == 0 { + writeln!(writer, "\nNo slides found.")?; + return Ok(()); + } + + writeln!(writer)?; // Empty line before summary + + let summary_parts = vec![ + (stats.processed, "processed", StatusColor::Green), + (stats.skipped, "skipped", StatusColor::Yellow), + (stats.ignored, "ignored", StatusColor::Gray), + (stats.errors, "errors", StatusColor::Red), + ]; + + let summary_parts: Vec<_> = summary_parts + .into_iter() + .filter(|(count, _, _)| *count > 0) + .collect(); + + if !summary_parts.is_empty() { + write!(writer, "Summary: ")?; + for (i, (count, label, color)) in summary_parts.iter().enumerate() { + if i > 0 { + write!(writer, ", ")?; + } + let text = format!("{count} {label}"); + self.write_status_indicator(&text, *color, writer)?; + } + writeln!(writer)?; + } + + Ok(()) + } +} + +impl Default for DisplayFormatter { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug, Clone, Copy)] +enum StatusColor { + Green, + Yellow, + Gray, + Red, +} + +pub fn suggest_output_file<W: Write>(writer: &mut W) -> std::io::Result<()> { + writeln!(writer)?; + writeln!( + writer, + "๐Ÿ’ก Tip: Use '-o filename.toml' to save the presentation to a file." + )?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::io::Cursor; + + use toboggan_core::Slide; + + use super::*; + use crate::TalkMetadata; + + fn create_test_results() -> ParseResult { + let talk_metadata = TalkMetadata { + title: "Test Presentation".to_string(), + date: toboggan_core::Date::today(), + footer: None, + head: None, + }; + + let slides = vec![ + SlideProcessingResult::Processed(Slide::new("Introduction")), + SlideProcessingResult::Processed(Slide::part("Overview")), + SlideProcessingResult::Processed(Slide::new("First Topic")), + SlideProcessingResult::Skipped(Slide::new("Optional Content")), + SlideProcessingResult::Processed(Slide::new("Second Topic")), + SlideProcessingResult::Ignored("Invalid file format".to_string()), + SlideProcessingResult::Error("Parse error in slide".to_string()), + ]; + + ParseResult { + talk_metadata, + slides, + } + } + + #[test] + #[allow(clippy::expect_used)] + fn test_display_results_no_colors() { + let results = create_test_results(); + let config = DisplayConfig::no_colors(); + let formatter = DisplayFormatter::with_config(config); + let mut output = Cursor::new(Vec::new()); + + formatter + .display_results(&results, &mut output) + .expect("Failed to display results"); + + let output_str = + String::from_utf8(output.into_inner()).expect("Failed to convert output to UTF-8"); + assert!(output_str.contains("Talk: Test Presentation")); + assert!(output_str.contains("Introduction")); + assert!(output_str.contains("[SKIP]")); + assert!(output_str.contains("[IGNORE]")); + assert!(output_str.contains("[ERROR]")); + assert!(output_str.contains("Summary:")); + } + + #[test] + #[allow(clippy::expect_used)] + fn test_display_results_basic() { + let results = create_test_results(); + let config = DisplayConfig { use_colors: false }; + let formatter = DisplayFormatter::with_config(config); + let mut output = Cursor::new(Vec::new()); + + formatter + .display_results(&results, &mut output) + .expect("Failed to display results"); + + let output_str = + String::from_utf8(output.into_inner()).expect("Failed to convert output to UTF-8"); + assert!(output_str.contains("Talk: Test Presentation")); + assert!(output_str.contains("Introduction")); + assert!(output_str.contains("Overview")); + assert!(output_str.contains("First Topic")); + assert!(output_str.contains("[SKIP] Optional Content")); + assert!(output_str.contains("Second Topic")); + assert!(output_str.contains("Summary:")); + } + + #[test] + fn test_stats_calculation() { + let results = create_test_results(); + let stats = results.stats(); + + assert_eq!(stats.processed, 4); + assert_eq!(stats.skipped, 1); + assert_eq!(stats.ignored, 1); + assert_eq!(stats.errors, 1); + assert_eq!(stats.total(), 7); + } + + #[test] + #[allow(clippy::expect_used)] + fn test_suggest_output_file() { + let mut output = Cursor::new(Vec::new()); + suggest_output_file(&mut output).expect("Failed to suggest output file"); + + let output_str = + String::from_utf8(output.into_inner()).expect("Failed to convert output to UTF-8"); + assert!(output_str.contains("Tip:")); + assert!(output_str.contains("-o filename.toml")); + } +} diff --git a/toboggan-cli/src/error.rs b/toboggan-cli/src/error.rs new file mode 100644 index 0000000..1941cca --- /dev/null +++ b/toboggan-cli/src/error.rs @@ -0,0 +1,274 @@ +use std::io; +use std::path::PathBuf; + +use miette::{Diagnostic, NamedSource, SourceSpan}; + +pub type Result<T> = std::result::Result<T, TobogganCliError>; + +#[derive(Debug, derive_more::Display, derive_more::Error, Diagnostic)] +pub enum TobogganCliError { + #[display("Failed to read directory: {}", path.display())] + #[diagnostic( + code(toboggan_cli::read_directory), + help("Ensure the directory exists and you have read permissions") + )] + ReadDirectory { path: PathBuf, source: io::Error }, + + #[display("Failed to read file: {}", path.display())] + #[diagnostic( + code(toboggan_cli::read_file), + help("Ensure the file exists and you have read permissions") + )] + ReadFile { path: PathBuf, source: io::Error }, + + #[display("Failed to create file: {}", path.display())] + #[diagnostic( + code(toboggan_cli::create_file), + help("Ensure you have write permissions in the target directory") + )] + CreateFile { path: PathBuf, source: io::Error }, + + #[display("Failed to write to file: {}", path.display())] + #[diagnostic( + code(toboggan_cli::write_file), + help("Ensure you have write permissions and sufficient disk space") + )] + WriteFile { path: PathBuf, source: io::Error }, + + #[display("Failed to parse markdown file: {}", src.name())] + #[diagnostic( + code(toboggan_cli::parse_markdown), + help( + "Check your markdown syntax. Common issues: unclosed code blocks, invalid frontmatter" + ) + )] + ParseMarkdown { + #[source_code] + src: NamedSource<String>, + #[label("error occurred here")] + span: SourceSpan, + message: String, + }, + + #[display("Failed to parse frontmatter in file: {}", src.name())] + #[diagnostic( + code(toboggan_cli::parse_frontmatter), + help("Frontmatter must be valid TOML format between '+++' markers") + )] + ParseFrontmatter { + #[source_code] + src: NamedSource<String>, + #[label("invalid TOML syntax")] + span: SourceSpan, + + source: toml::de::Error, + }, + + #[display("Failed to format markdown file: {}", src.name())] + #[diagnostic( + code(toboggan_cli::format_commonmark), + help("The markdown content could not be formatted. This might indicate corrupted AST") + )] + FormatCommonmark { + #[source_code] + src: NamedSource<String>, + #[label("formatting failed here")] + span: SourceSpan, + message: String, + }, + + #[display("Invalid date format: '{input}'")] + #[diagnostic( + code(toboggan_cli::invalid_date_format), + help("Date must be in YYYY-MM-DD format (e.g., 2024-01-15)") + )] + InvalidDateFormat { input: String }, + + #[display("Invalid date component: {component}='{value}'")] + #[diagnostic( + code(toboggan_cli::invalid_date_component), + help( + "Date components must be valid integers: year (e.g., 2024), month (1-12), day (1-31)" + ) + )] + InvalidDateComponent { + component: String, + value: String, + source: std::num::ParseIntError, + }, + + #[display("Failed to create valid date from year={year}, month={month}, day={day}")] + #[diagnostic( + code(toboggan_cli::invalid_date), + help("Check that the date components form a valid calendar date") + )] + InvalidDate { year: i16, month: i8, day: i8 }, + + #[display("Input path is not a directory: {}", path.display())] + #[diagnostic( + code(toboggan_cli::not_a_directory), + help( + "toboggan-cli only processes folder structures.\n\n\ + Please organize your presentation in a folder with the following structure:\n\n\ + my-talk/\n\ + โ”œโ”€โ”€ _cover.md # Cover slide with title and date in frontmatter\n\ + โ”œโ”€โ”€ 01-intro/ # Section folder\n\ + โ”‚ โ”œโ”€โ”€ _part.md # Section divider\n\ + โ”‚ โ””โ”€โ”€ slides.md # Content slides\n\ + โ””โ”€โ”€ conclusion.md # Final slide\n\n\ + Use frontmatter in _cover.md for title and date:\n\ + +++\n\ + title = \"My Presentation\"\n\ + date = \"2024-03-15\"\n\ + +++" + ) + )] + NotADirectory { path: PathBuf }, + + #[display("No title found for presentation")] + #[diagnostic( + code(toboggan_cli::missing_title), + help("Add a title in the frontmatter, first heading, or use --title flag") + )] + MissingTitle, + + #[display("Failed to serialize to {format}")] + #[diagnostic( + code(toboggan_cli::serialize), + help("The presentation structure could not be converted to {format} format") + )] + Serialize { format: String, message: String }, + + #[display("Failed to compile regex pattern")] + #[diagnostic( + code(toboggan_cli::regex_compile), + help("This is likely a bug in the application. Please report it.") + )] + RegexCompile { source: regex::Error }, + + #[display("Failed to parse command-line arguments")] + #[diagnostic( + code(toboggan_cli::cli_parse), + help("Run with --help to see available options") + )] + CliParse { source: clap::Error }, +} + +impl TobogganCliError { + /// Helper to create a `NamedSource` with consistent naming + fn create_named_source(file_path: &str, content: String) -> NamedSource<String> { + NamedSource::new(file_path, content) + } + + #[must_use] + pub fn parse_markdown( + file_path: &str, + content: String, + span: SourceSpan, + message: String, + ) -> Self { + Self::ParseMarkdown { + src: Self::create_named_source(file_path, content), + span, + message, + } + } + + #[must_use] + pub fn parse_frontmatter( + file_path: &str, + content: String, + span: SourceSpan, + source: toml::de::Error, + ) -> Self { + Self::ParseFrontmatter { + src: Self::create_named_source(file_path, content), + span, + source, + } + } + + #[must_use] + pub fn format_commonmark( + file_path: &str, + content: String, + span: SourceSpan, + message: String, + ) -> Self { + Self::FormatCommonmark { + src: Self::create_named_source(file_path, content), + span, + message, + } + } +} + +impl TobogganCliError { + #[must_use] + pub fn read_directory(path: PathBuf, source: io::Error) -> Self { + Self::ReadDirectory { path, source } + } + + #[must_use] + pub fn read_file(path: PathBuf, source: io::Error) -> Self { + Self::ReadFile { path, source } + } + + #[must_use] + pub fn create_file(path: PathBuf, source: io::Error) -> Self { + Self::CreateFile { path, source } + } + + #[must_use] + pub fn write_file(path: PathBuf, source: io::Error) -> Self { + Self::WriteFile { path, source } + } +} + +impl From<regex::Error> for TobogganCliError { + fn from(source: regex::Error) -> Self { + Self::RegexCompile { source } + } +} + +impl From<clap::Error> for TobogganCliError { + fn from(source: clap::Error) -> Self { + Self::CliParse { source } + } +} + +impl From<toml::ser::Error> for TobogganCliError { + fn from(source: toml::ser::Error) -> Self { + Self::Serialize { + format: "TOML".to_string(), + message: source.to_string(), + } + } +} + +impl From<serde_json::Error> for TobogganCliError { + fn from(source: serde_json::Error) -> Self { + Self::Serialize { + format: "JSON".to_string(), + message: source.to_string(), + } + } +} + +impl From<serde_saphyr::Error> for TobogganCliError { + fn from(source: serde_saphyr::Error) -> Self { + Self::Serialize { + format: "YAML".to_string(), + message: source.to_string(), + } + } +} + +impl From<std::io::Error> for TobogganCliError { + fn from(source: std::io::Error) -> Self { + Self::ReadFile { + path: PathBuf::from("<unknown>"), + source, + } + } +} diff --git a/toboggan-cli/src/lib.rs b/toboggan-cli/src/lib.rs new file mode 100644 index 0000000..b2a423b --- /dev/null +++ b/toboggan-cli/src/lib.rs @@ -0,0 +1,399 @@ +#![allow(clippy::result_large_err)] + +use std::fs::File; +use std::io::{BufWriter, Write}; +use std::path::{Path, PathBuf}; + +use toboggan_core::{Date, Slide, Talk}; +use tracing::debug; + +pub mod error; +pub use self::error::{Result, TobogganCliError}; + +pub mod parser; +use parser::FolderParser; + +pub mod output; + +mod settings; +pub use self::settings::*; + +pub mod display; + +pub mod stats; + +#[derive(Debug, Clone)] +pub enum SlideProcessingResult { + Processed(Slide), + Skipped(Slide), + Ignored(String), + Error(String), +} + +#[derive(Debug, Clone)] +pub struct TalkMetadata { + pub title: String, + pub date: Date, + pub footer: Option<String>, + pub head: Option<String>, +} + +impl Default for TalkMetadata { + fn default() -> Self { + Self { + title: "Unknown Talk".to_string(), + date: Date::today(), + footer: None, + head: None, + } + } +} + +#[derive(Debug, Clone)] +pub struct ParseResult { + pub talk_metadata: TalkMetadata, + pub slides: Vec<SlideProcessingResult>, +} + +impl ParseResult { + #[must_use] + pub fn to_talk(&self) -> Talk { + let mut talk = Talk::new(&self.talk_metadata.title); + talk.date = self.talk_metadata.date; + talk.footer.clone_from(&self.talk_metadata.footer); + talk.head.clone_from(&self.talk_metadata.head); + + for slide_result in &self.slides { + if let SlideProcessingResult::Processed(slide) = slide_result { + talk.slides.push(slide.clone()); + } + } + + talk + } + + #[must_use] + pub fn stats(&self) -> ParseStats { + let mut stats = ParseStats::default(); + + for slide_result in &self.slides { + match slide_result { + SlideProcessingResult::Processed(_) => stats.processed += 1, + SlideProcessingResult::Skipped(_) => stats.skipped += 1, + SlideProcessingResult::Ignored(_) => stats.ignored += 1, + SlideProcessingResult::Error(_) => stats.errors += 1, + } + } + + stats + } +} + +#[derive(Debug, Clone, Default)] +pub struct ParseStats { + pub processed: usize, + pub skipped: usize, + pub ignored: usize, + pub errors: usize, +} + +impl ParseStats { + #[must_use] + pub fn total(&self) -> usize { + self.processed + self.skipped + self.ignored + self.errors + } +} + +pub fn add_counters_to_slides(parse_result: &mut ParseResult) { + let mut part_number = 0; + let mut slide_in_part = 0; + let mut in_part = false; + + for slide_result in &mut parse_result.slides { + match slide_result { + SlideProcessingResult::Processed(slide) => { + if slide.kind == toboggan_core::SlideKind::Part { + part_number += 1; + slide_in_part = 0; + in_part = true; + } else if in_part { + slide_in_part += 1; + } + } + SlideProcessingResult::Skipped(slide) => { + if slide.kind == toboggan_core::SlideKind::Part { + in_part = false; + } + } + _ => {} + } + + match slide_result { + SlideProcessingResult::Processed(slide) => { + let counter = match slide.kind { + toboggan_core::SlideKind::Part => format!("{part_number}. "), + _ if in_part => format!("{part_number}.{slide_in_part} "), + _ => String::new(), + }; + if !counter.is_empty() { + slide.title = format!("{counter}{}", slide.title).into(); + } + } + SlideProcessingResult::Skipped(_slide) => {} + _ => {} + } + } +} + +#[doc(hidden)] +#[allow(clippy::print_stdout)] +pub fn run(settings: &Settings) -> Result<()> { + if settings.list_themes { + list_available_themes(); + return Ok(()); + } + + let input = validate_input(settings.input.as_ref())?; + let parse_result = parse_presentation(input, settings)?; + display_results(&parse_result, settings)?; + + if let Some(output) = &settings.output { + write_output(&parse_result, output, settings)?; + } else { + display::suggest_output_file(&mut std::io::stdout())?; + } + + Ok(()) +} + +fn validate_input(input: Option<&PathBuf>) -> Result<&PathBuf> { + let input = input.ok_or_else(|| TobogganCliError::NotADirectory { + path: PathBuf::from("no input provided"), + })?; + + if !input.is_dir() { + return Err(TobogganCliError::NotADirectory { + path: input.clone(), + }); + } + + Ok(input) +} + +fn parse_presentation(input: &Path, settings: &Settings) -> Result<ParseResult> { + debug!("Processing folder-based talk from {}", input.display()); + + let parser = FolderParser::new(input.to_path_buf(), settings.theme.clone())?; + let mut parse_result = parser.parse(settings.title.clone(), settings.date)?; + + if !settings.no_counter { + add_counters_to_slides(&mut parse_result); + } + + Ok(parse_result) +} + +fn display_results(parse_result: &ParseResult, settings: &Settings) -> Result<()> { + let display_formatter = display::DisplayFormatter::new(); + display_formatter.display_results(parse_result, &mut std::io::stdout())?; + + if !settings.no_stats { + let stats = stats::PresentationStats::from_parse_result( + parse_result, + settings.wpm, + !settings.exclude_notes_from_duration, + ); + stats.display( + &mut std::io::stdout(), + display::DisplayConfig::should_use_colors(), + )?; + } + + Ok(()) +} + +#[allow(clippy::print_stderr)] +fn write_output(parse_result: &ParseResult, output: &Path, settings: &Settings) -> Result<()> { + let format = settings.resolve_format(); + let talk = parse_result.to_talk(); + let serialized = output::serialize_talk(&talk, &format)?; + + write_talk(output, &serialized)?; + + // Count slides excluding Part slides (section dividers) + let content_slide_count = talk + .slides + .iter() + .filter(|slide| slide.kind != toboggan_core::SlideKind::Part) + .count(); + if content_slide_count > 0 { + eprintln!( + "\nโœ… Successfully wrote {} slides to {}", + content_slide_count, + output.display() + ); + } else { + eprintln!("\nโš ๏ธ No slides were processed successfully. File not written."); + } + + Ok(()) +} + +fn write_talk(out: &Path, content: &[u8]) -> Result<()> { + let writer = File::create(out) + .map_err(|source| TobogganCliError::create_file(out.to_path_buf(), source))?; + let mut writer = BufWriter::new(writer); + writer + .write_all(content) + .map_err(|source| TobogganCliError::write_file(out.to_path_buf(), source))?; + + Ok(()) +} + +#[allow(clippy::print_stdout)] +fn list_available_themes() { + println!("{}", include_str!("available_themes.txt")); +} + +fn parse_date_string(date_str: &str) -> Result<Date> { + let regex = regex::Regex::new(r"^(\d{4})-(\d{1,2})-(\d{1,2})$")?; + + if let Some(caps) = regex.captures(date_str) { + let year = + caps[1] + .parse::<i16>() + .map_err(|source| TobogganCliError::InvalidDateComponent { + component: "year".to_string(), + value: caps[1].to_string(), + source, + })?; + let month = + caps[2] + .parse::<i8>() + .map_err(|source| TobogganCliError::InvalidDateComponent { + component: "month".to_string(), + value: caps[2].to_string(), + source, + })?; + let day = + caps[3] + .parse::<i8>() + .map_err(|source| TobogganCliError::InvalidDateComponent { + component: "day".to_string(), + value: caps[3].to_string(), + source, + })?; + let date = Date::new(year, month, day).map_err(|_| TobogganCliError::InvalidDate { + year, + month, + day, + })?; + + Ok(date) + } else { + Err(TobogganCliError::InvalidDateFormat { + input: date_str.to_string(), + }) + } +} + +#[cfg(test)] +mod tests { + use toboggan_core::Slide; + + use super::*; + + fn create_test_parse_result_for_counter() -> ParseResult { + let talk_metadata = TalkMetadata { + title: "Test Presentation".to_string(), + date: toboggan_core::Date::today(), + footer: None, + head: None, + }; + + let slides = vec![ + SlideProcessingResult::Processed(Slide::new("Introduction")), + SlideProcessingResult::Processed(Slide::part("Part One")), + SlideProcessingResult::Processed(Slide::new("Topic A")), + SlideProcessingResult::Skipped(Slide::new("Optional Topic")), + SlideProcessingResult::Processed(Slide::new("Topic B")), + SlideProcessingResult::Processed(Slide::part("Part Two")), + SlideProcessingResult::Processed(Slide::new("Topic C")), + ]; + + ParseResult { + talk_metadata, + slides, + } + } + + #[test] + #[allow(clippy::indexing_slicing)] + fn test_add_counters_to_slides() { + let mut parse_result = create_test_parse_result_for_counter(); + add_counters_to_slides(&mut parse_result); + + // Check the titles have counters added + if let SlideProcessingResult::Processed(slide) = &parse_result.slides[0] { + assert_eq!(slide.title.to_string(), "Introduction"); + } + if let SlideProcessingResult::Processed(slide) = &parse_result.slides[1] { + assert_eq!(slide.title.to_string(), "1. Part One"); + } + if let SlideProcessingResult::Processed(slide) = &parse_result.slides[2] { + assert_eq!(slide.title.to_string(), "1.1 Topic A"); + } + if let SlideProcessingResult::Skipped(slide) = &parse_result.slides[3] { + assert_eq!(slide.title.to_string(), "Optional Topic"); // Skipped slides don't get counters + } + if let SlideProcessingResult::Processed(slide) = &parse_result.slides[4] { + assert_eq!(slide.title.to_string(), "1.2 Topic B"); // Continues numbering after skip + } + if let SlideProcessingResult::Processed(slide) = &parse_result.slides[5] { + assert_eq!(slide.title.to_string(), "2. Part Two"); + } + if let SlideProcessingResult::Processed(slide) = &parse_result.slides[6] { + assert_eq!(slide.title.to_string(), "2.1 Topic C"); + } + } + + #[test] + #[allow(clippy::indexing_slicing)] + fn test_counter_logic_with_skipped_parts() { + let talk_metadata = TalkMetadata { + title: "Test Presentation".to_string(), + date: toboggan_core::Date::today(), + footer: None, + head: None, + }; + + let slides = vec![ + SlideProcessingResult::Processed(Slide::part("Part One")), + SlideProcessingResult::Processed(Slide::new("Topic A")), + SlideProcessingResult::Skipped(Slide::part("Skipped Part")), + SlideProcessingResult::Processed(Slide::new("Topic B")), + ]; + + let mut parse_result = ParseResult { + talk_metadata, + slides, + }; + + add_counters_to_slides(&mut parse_result); + + // Check the titles + if let SlideProcessingResult::Processed(slide) = &parse_result.slides[0] { + assert_eq!(slide.title.to_string(), "1. Part One"); + } + if let SlideProcessingResult::Processed(slide) = &parse_result.slides[1] { + assert_eq!(slide.title.to_string(), "1.1 Topic A"); + } + if let SlideProcessingResult::Skipped(slide) = &parse_result.slides[2] { + assert_eq!(slide.title.to_string(), "Skipped Part"); // Skipped parts don't get counters + } + if let SlideProcessingResult::Processed(slide) = &parse_result.slides[3] { + // This should still be in part context even though the part was skipped + assert_eq!(slide.title.to_string(), "Topic B"); + } + } +} diff --git a/toboggan-cli/src/main.rs b/toboggan-cli/src/main.rs new file mode 100644 index 0000000..e903549 --- /dev/null +++ b/toboggan-cli/src/main.rs @@ -0,0 +1,27 @@ +use clap::Parser; +use clap::error::ErrorKind; +use miette::IntoDiagnostic; +use toboggan_cli::{Settings, run}; + +fn main() -> miette::Result<()> { + tracing_subscriber::fmt().pretty().init(); + + let settings = match Settings::try_parse() { + Ok(settings) => settings, + Err(err) => match err.kind() { + ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => { + // Let clap handle help and version output normally + let _ = err.print(); + std::process::exit(0); + } + _ => { + // Convert other errors to miette diagnostics + return Err(miette::miette!(err)); + } + }, + }; + + run(&settings).into_diagnostic()?; + + Ok(()) +} diff --git a/toboggan-cli/src/output/binary.rs b/toboggan-cli/src/output/binary.rs new file mode 100644 index 0000000..22a720c --- /dev/null +++ b/toboggan-cli/src/output/binary.rs @@ -0,0 +1,231 @@ +use toboggan_core::Talk; + +use super::renderer::{RenderMetrics, measure_render}; +use crate::error::Result; + +pub struct BinaryRenderer; + +impl BinaryRenderer { + pub fn cbor(talk: &Talk) -> Result<Vec<u8>> { + let (result, _) = measure_render(|| { + let mut buffer = Vec::new(); + ciborium::ser::into_writer(talk, &mut buffer).map_err(|err| { + crate::error::TobogganCliError::Serialize { + format: "CBOR".to_string(), + message: err.to_string(), + } + })?; + Ok::<Vec<u8>, crate::error::TobogganCliError>(buffer) + }); + result + } + + pub fn msgpack(talk: &Talk) -> Result<Vec<u8>> { + let (result, _) = measure_render(|| { + rmp_serde::to_vec(talk).map_err(|err| crate::error::TobogganCliError::Serialize { + format: "MessagePack".to_string(), + message: err.to_string(), + }) + }); + result + } + + pub fn bincode(talk: &Talk) -> Result<Vec<u8>> { + let (result, _) = measure_render(|| { + bincode::serde::encode_to_vec(talk, bincode::config::standard()).map_err(|err| { + crate::error::TobogganCliError::Serialize { + format: "Bincode".to_string(), + message: err.to_string(), + } + }) + }); + result + } + + pub fn metrics(talk: &Talk, format: &str) -> Result<RenderMetrics> { + let input_size = std::mem::size_of_val(talk); + + let (output, render_time) = match format { + "cbor" => { + let (result, time) = measure_render(|| Self::cbor(talk)); + (result?.len(), time) + } + "msgpack" => { + let (result, time) = measure_render(|| Self::msgpack(talk)); + (result?.len(), time) + } + "bincode" => { + let (result, time) = measure_render(|| Self::bincode(talk)); + (result?.len(), time) + } + _ => { + return Err(crate::error::TobogganCliError::Serialize { + format: format.to_string(), + message: "Unsupported binary format".to_string(), + }); + } + }; + + Ok(RenderMetrics::new(input_size, output, render_time)) + } + + pub fn compare_compression(talk: &Talk) -> Result<Vec<(String, RenderMetrics)>> { + let formats = ["cbor", "msgpack", "bincode"]; + let mut results = Vec::new(); + + for format in &formats { + let metrics = Self::metrics(talk, format)?; + results.push(((*format).to_string(), metrics)); + } + + // Sort by compression ratio (best compression first) + results.sort_by(|first, second| { + first + .1 + .compression_ratio + .partial_cmp(&second.1.compression_ratio) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + Ok(results) + } +} + +#[cfg(test)] +mod tests { + use anyhow::Context; + use toboggan_core::{Content, Date, Slide, Talk}; + + use super::*; + + fn create_test_talk() -> anyhow::Result<Talk> { + let mut talk = Talk::new("Test Talk"); + talk.date = Date::new(2024, 12, 25).with_context(|| "Failed to create test date")?; + + // Add some slides to make the test more realistic + let mut slide1 = Slide::new(Content::text("Slide 1")); + slide1.body = Content::text("Content 1"); + let mut slide2 = Slide::new(Content::text("Slide 2")); + slide2.body = Content::text("Content 2"); + talk.slides.extend([slide1, slide2]); + + Ok(talk) + } + + #[test] + fn test_cbor_rendering() -> anyhow::Result<()> { + let talk = create_test_talk()?; + let output = BinaryRenderer::cbor(&talk)?; + + // CBOR output should be binary data + assert!(!output.is_empty()); + + // Verify we can deserialize it back + let _result: std::result::Result<Talk, _> = ciborium::de::from_reader(&output[..]); + // Just verify the deserialization doesn't panic, we don't need to check the result + + Ok(()) + } + + #[test] + fn test_msgpack_rendering() -> anyhow::Result<()> { + let talk = create_test_talk()?; + let output = BinaryRenderer::msgpack(&talk)?; + + // MessagePack output should be binary data + assert!(!output.is_empty()); + + // Just verify it's binary data (for now, skip deserialization due to enum complexity) + assert!(output.len() > 50); // Should have reasonable size + + Ok(()) + } + + #[test] + fn test_bincode_rendering() -> anyhow::Result<()> { + let talk = create_test_talk()?; + let output = BinaryRenderer::bincode(&talk)?; + + // Bincode output should be binary data + assert!(!output.is_empty()); + + // Just verify it's binary data (for now, skip deserialization due to enum complexity) + assert!(output.len() > 50); // Should have reasonable size + + Ok(()) + } + + #[test] + fn test_compression_comparison() -> anyhow::Result<()> { + let talk = create_test_talk()?; + let comparison = BinaryRenderer::compare_compression(&talk)?; + + // Should have results for all 3 formats + assert_eq!(comparison.len(), 3); + + // Each format should have a name and metrics + for (name, metrics) in &comparison { + assert!(["cbor", "msgpack", "bincode"].contains(&name.as_str())); + assert!(metrics.output_size > 0); + assert!(metrics.compression_ratio > 0.0); + } + + Ok(()) + } + + #[test] + fn test_binary_vs_text_size() -> anyhow::Result<()> { + let talk = create_test_talk()?; + + // Get sizes of different formats + let cbor_size = BinaryRenderer::cbor(&talk)?.len(); + let msgpack_size = BinaryRenderer::msgpack(&talk)?.len(); + let bincode_size = BinaryRenderer::bincode(&talk)?.len(); + let json_size = crate::output::TextRenderer::json(&talk)?.len(); + + // Binary formats should generally be more compact than JSON + // (though this isn't guaranteed for very small payloads) + // Note: Removed println! to comply with clippy::print_stdout lint + let _ = (cbor_size, msgpack_size, bincode_size, json_size); + + // All formats should produce non-empty output + assert!(cbor_size > 0); + assert!(msgpack_size > 0); + assert!(bincode_size > 0); + assert!(json_size > 0); + + Ok(()) + } + + #[test] + fn test_renderer_traits() -> anyhow::Result<()> { + let talk = create_test_talk()?; + + // Since we removed the renderer structs, we can only test the static methods + assert!(BinaryRenderer::cbor(&talk).is_ok()); + assert!(BinaryRenderer::msgpack(&talk).is_ok()); + assert!(BinaryRenderer::bincode(&talk).is_ok()); + Ok(()) + } + + #[test] + fn test_metrics() -> anyhow::Result<()> { + let talk = create_test_talk()?; + + let cbor_metrics = BinaryRenderer::metrics(&talk, "cbor")?; + let msgpack_metrics = BinaryRenderer::metrics(&talk, "msgpack")?; + let bincode_metrics = BinaryRenderer::metrics(&talk, "bincode")?; + + // All should have positive output sizes + assert!(cbor_metrics.output_size > 0); + assert!(msgpack_metrics.output_size > 0); + assert!(bincode_metrics.output_size > 0); + + // All should have measured render times (u64 is always >= 0, so just check they exist) + let _ = cbor_metrics.render_time_ms; + let _ = msgpack_metrics.render_time_ms; + let _ = bincode_metrics.render_time_ms; + + Ok(()) + } +} diff --git a/toboggan-cli/src/output/html.rs b/toboggan-cli/src/output/html.rs new file mode 100644 index 0000000..2e20e75 --- /dev/null +++ b/toboggan-cli/src/output/html.rs @@ -0,0 +1,292 @@ +use toboggan_core::{Content, Slide, SlideKind, Style, Talk}; + +use crate::error::Result; + +// CSS from toboggan-web/src/reset.css +const RESET_CSS: &str = include_str!("../../../toboggan-web/src/reset.css"); + +// CSS from toboggan-web/src/main.css +const MAIN_CSS: &str = include_str!("../../../toboggan-web/src/main.css"); + +// CSS from toboggan-web/toboggan-wasm/src/components/slide/style.css +// Adapted to remove :host and shadow DOM specific styles +const SLIDE_CSS: &str = + include_str!("../../../toboggan-web/toboggan-wasm/src/components/slide/style.css"); + +// Print CSS for one slide per page +const PRINT_CSS: &str = include_str!("../print.css"); + +/// Escape HTML special characters +fn escape_html(text: &str) -> String { + text.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +/// Render content to HTML string +/// This replicates the logic from toboggan-web/toboggan-wasm/src/utils/dom.rs +fn render_content(content: &Content, wrapper: Option<&str>) -> String { + let inner = match content { + Content::Empty => String::new(), + Content::Text { text } => escape_html(text), + Content::Html { raw, .. } => raw.clone(), + Content::Grid { style, cells } => { + let Style { classes, .. } = style; + let classes = classes.join(" "); + let body: String = cells + .iter() + .map(|content| render_content(content, None)) + .collect(); + format!(r#"<div class="cell {classes}">{body}</div>"#) + } + }; + + if let Some(wrapper) = wrapper { + format!("<{wrapper}>{inner}</{wrapper}>") + } else { + inner + } +} + +/// Render a slide to HTML +/// This replicates the logic from toboggan-web/toboggan-wasm/src/components/slide/mod.rs +fn render_slide(slide: &Slide) -> String { + // Build classes: slide style classes + slide kind class + let mut classes = slide.style.classes.clone(); + + let kind_class = match slide.kind { + SlideKind::Cover => "cover", + SlideKind::Part => "part", + SlideKind::Standard => "standard", + }; + classes.push(kind_class.to_string()); + + let class_string = classes.join(" "); + + // Build inline style attribute if present + let style_attr = if let Some(style) = &slide.style.style { + format!(r#" style="{style}""#) + } else { + String::new() + }; + + // Render title and body + let title = render_content(&slide.title, None); + let body = render_content(&slide.body, Some("article")); + + let content = if title.is_empty() { + body + } else { + format!("<h2>{title}</h2>{body}") + }; + + format!(r#"<section class="{class_string}"{style_attr}>{content}</section>"#) +} + +/// Generate a complete static HTML document from a Talk +/// +/// # Arguments +/// +/// * `talk` - The presentation data +/// * `custom_head_html` - Optional custom HTML to insert at the end of the `<head>` element +#[allow(clippy::unnecessary_wraps)] +pub fn generate_html(talk: &Talk, custom_head_html: Option<&str>) -> Result<Vec<u8>> { + // Render all slides + let slides_html = + talk.slides + .iter() + .map(render_slide) + .fold(String::new(), |mut acc, slide_html| { + use std::fmt::Write; + let _ = write!(acc, r#"<div class="toboggan-slide">{slide_html}</div>"#); + acc + }); + + // Adapt SLIDE_CSS to remove :host selector and adjust for non-shadow-DOM usage + let adapted_slide_css = SLIDE_CSS + .replace(":host {", ".toboggan-slide {") + .replace(":host(", ".toboggan-slide("); + + // Build custom head HTML section if provided + let custom_head = custom_head_html.map_or(String::new(), |html| format!(" {html}\n")); + + // Build the complete HTML document + let html = format!( + r#"<!doctype html> +<html lang="en"> + +<head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>{title} + + + + + + + +{custom_head} + + +
+{slides_html} +
+ + +"#, + title = escape_html(&talk.title), + reset_css = RESET_CSS, + main_css = MAIN_CSS, + slide_css = adapted_slide_css, + print_css = PRINT_CSS, + custom_head = custom_head, + slides_html = slides_html + ); + + Ok(html.into_bytes()) +} + +#[cfg(test)] +mod tests { + use toboggan_core::Date; + + use super::*; + + #[test] + fn test_escape_html() { + assert_eq!(escape_html("Hello"), "Hello"); + assert_eq!( + escape_html(""), + "<script>alert('XSS')</script>" + ); + assert_eq!(escape_html("A & B"), "A & B"); + } + + #[test] + fn test_render_empty_content() { + let content = Content::Empty; + assert_eq!(render_content(&content, None), ""); + } + + #[test] + fn test_render_text_content() { + let content = Content::Text { + text: "Hello ".to_string(), + }; + assert_eq!(render_content(&content, None), "Hello <world>"); + } + + #[test] + fn test_render_html_content() { + let content = Content::Html { + raw: "

Hello

".to_string(), + style: Style::default(), + alt: None, + }; + assert_eq!(render_content(&content, None), "

Hello

"); + } + + #[test] + fn test_render_grid_content() { + let content = Content::Grid { + style: Style { + classes: vec!["test-class".to_string()], + style: None, + }, + cells: vec![ + Content::Text { + text: "Cell 1".to_string(), + }, + Content::Text { + text: "Cell 2".to_string(), + }, + ], + }; + let html = render_content(&content, None); + assert!(html.contains(r#"
"#)); + assert!(html.contains("Cell 1")); + assert!(html.contains("Cell 2")); + } + + #[test] + fn test_render_content_with_wrapper() { + let content = Content::Text { + text: "Hello".to_string(), + }; + assert_eq!( + render_content(&content, Some("article")), + "
Hello
" + ); + } + + #[test] + fn test_generate_html() -> anyhow::Result<()> { + let mut talk = Talk::new("Test Presentation"); + talk.date = Date::new(2024, 1, 1)?; + + let slide = Slide { + kind: SlideKind::Cover, + title: Content::Text { + text: "Welcome".to_string(), + }, + body: Content::Html { + raw: "

Hello World

".to_string(), + style: Style::default(), + alt: None, + }, + notes: Content::Empty, + style: Style::default(), + }; + talk.slides.push(slide); + + let html_bytes = generate_html(&talk, None)?; + let html = String::from_utf8_lossy(&html_bytes); + + // Check basic structure + assert!(html.contains("")); + assert!(html.contains("Test Presentation")); + assert!(html.contains(r#"
"#)); + assert!(html.contains(r#"
Welcome")); + assert!(html.contains("

Hello World

")); + + Ok(()) + } + + #[test] + fn test_generate_html_with_custom_head() -> anyhow::Result<()> { + let mut talk = Talk::new("Test"); + talk.date = Date::new(2024, 1, 1)?; + + let custom_html = r#" + "#; + + let html_bytes = generate_html(&talk, Some(custom_html))?; + let html = String::from_utf8_lossy(&html_bytes); + + // Check custom HTML is present in head + assert!(html.contains(r#""#)); + assert!(html.contains(r"")); + // Verify it's before closing head tag + let head_close_pos = html + .find("") + .ok_or_else(|| anyhow::anyhow!("Should have closing head tag"))?; + let custom_pos = html + .find("Test Author") + .ok_or_else(|| anyhow::anyhow!("Should have custom content"))?; + assert!(custom_pos < head_close_pos, "Custom HTML should be in head"); + + Ok(()) + } +} diff --git a/toboggan-cli/src/output/mod.rs b/toboggan-cli/src/output/mod.rs new file mode 100644 index 0000000..5120c36 --- /dev/null +++ b/toboggan-cli/src/output/mod.rs @@ -0,0 +1,111 @@ +mod renderer; +pub use self::renderer::{OutputRenderer, RenderError}; + +mod text; +pub use self::text::TextRenderer; + +mod binary; +pub use self::binary::BinaryRenderer; + +mod html; + +use toboggan_core::Talk; + +use crate::error::Result; +use crate::settings::OutputFormat; + +pub fn serialize_talk(talk: &Talk, format: &OutputFormat) -> Result> { + match format { + OutputFormat::Toml => TextRenderer::toml(talk), + OutputFormat::Json => TextRenderer::json(talk), + OutputFormat::Yaml => TextRenderer::yaml(talk), + + OutputFormat::Cbor => BinaryRenderer::cbor(talk), + OutputFormat::MessagePack => BinaryRenderer::msgpack(talk), + OutputFormat::Bincode => BinaryRenderer::bincode(talk), + + OutputFormat::Html => html::generate_html(talk, talk.head.as_deref()), + } +} + +#[must_use] +pub fn get_extension(format: &OutputFormat) -> &'static str { + match format { + OutputFormat::Toml => "toml", + OutputFormat::Json => "json", + OutputFormat::Yaml => "yaml", + OutputFormat::Cbor => "cbor", + OutputFormat::MessagePack => "msgpack", + OutputFormat::Bincode => "bin", + OutputFormat::Html => "html", + } +} + +#[must_use] +pub fn is_binary_format(format: &OutputFormat) -> bool { + matches!( + format, + OutputFormat::Cbor | OutputFormat::MessagePack | OutputFormat::Bincode + ) +} + +#[cfg(test)] +mod tests { + use anyhow::Context; + use toboggan_core::{Date, Talk}; + + use super::*; + + fn create_test_talk() -> anyhow::Result { + let mut talk = Talk::new("Test Talk"); + talk.date = Date::new(2024, 12, 25).with_context(|| "Failed to create test date")?; + Ok(talk) + } + + #[test] + fn test_all_formats_serialize() -> anyhow::Result<()> { + let talk = create_test_talk()?; + + // Test all formats can serialize without error + let formats = [ + OutputFormat::Toml, + OutputFormat::Json, + OutputFormat::Yaml, + OutputFormat::Cbor, + OutputFormat::MessagePack, + OutputFormat::Bincode, + OutputFormat::Html, + ]; + + for format in &formats { + let result = serialize_talk(&talk, format); + assert!(result.is_ok(), "Failed to serialize format: {format:?}"); + if let Ok(output) = result { + assert!(!output.is_empty(), "Empty output for format: {format:?}"); + } + } + Ok(()) + } + + #[test] + fn test_extensions() { + assert_eq!(get_extension(&OutputFormat::Toml), "toml"); + assert_eq!(get_extension(&OutputFormat::Json), "json"); + assert_eq!(get_extension(&OutputFormat::Yaml), "yaml"); + assert_eq!(get_extension(&OutputFormat::Cbor), "cbor"); + assert_eq!(get_extension(&OutputFormat::MessagePack), "msgpack"); + assert_eq!(get_extension(&OutputFormat::Bincode), "bin"); + assert_eq!(get_extension(&OutputFormat::Html), "html"); + } + + #[test] + fn test_binary_format_detection() { + assert!(!is_binary_format(&OutputFormat::Toml)); + assert!(!is_binary_format(&OutputFormat::Json)); + assert!(!is_binary_format(&OutputFormat::Yaml)); + assert!(!is_binary_format(&OutputFormat::Html)); + assert!(is_binary_format(&OutputFormat::Cbor)); + assert!(is_binary_format(&OutputFormat::MessagePack)); + assert!(is_binary_format(&OutputFormat::Bincode)); + } +} diff --git a/toboggan-cli/src/output/renderer.rs b/toboggan-cli/src/output/renderer.rs new file mode 100644 index 0000000..feac31e --- /dev/null +++ b/toboggan-cli/src/output/renderer.rs @@ -0,0 +1,73 @@ +//! Common traits and error handling for output renderers + +use toboggan_core::Talk; + +use crate::error::Result; + +#[derive(Debug, derive_more::Display, derive_more::Error)] +pub enum RenderError { + #[display("Serialization failed: {message}")] + SerializationFailed { message: String }, + + #[display("Unsupported format: {format}")] + UnsupportedFormat { format: String }, + + #[display("Output too large: {size} bytes")] + OutputTooLarge { size: usize }, +} + +pub trait OutputRenderer { + type Output; + + /// Render a talk to the output format + /// + /// # Errors + /// Returns an error if serialization fails + fn render(&self, talk: &Talk) -> Result; + + /// Get the MIME type for this format + fn mime_type(&self) -> &'static str; + + /// Get the file extension for this format + fn extension(&self) -> &'static str; + + /// Check if this format is binary + fn is_binary(&self) -> bool; +} + +#[derive(Debug, Clone)] +pub struct RenderMetrics { + pub input_size: usize, + pub output_size: usize, + pub compression_ratio: f64, + pub render_time_ms: u64, +} + +impl RenderMetrics { + #[allow(clippy::cast_precision_loss)] + pub fn new(input_size: usize, output_size: usize, render_time_ms: u64) -> Self { + let compression_ratio = if input_size > 0 { + output_size as f64 / input_size as f64 + } else { + 1.0 + }; + + Self { + input_size, + output_size, + compression_ratio, + render_time_ms, + } + } +} + +#[allow(clippy::cast_possible_truncation)] +pub fn measure_render(func: F) -> (T, u64) +where + F: FnOnce() -> T, +{ + let start = std::time::Instant::now(); + let result = func(); + let duration = start.elapsed().as_millis() as u64; + (result, duration) +} diff --git a/toboggan-cli/src/output/text.rs b/toboggan-cli/src/output/text.rs new file mode 100644 index 0000000..b6b7320 --- /dev/null +++ b/toboggan-cli/src/output/text.rs @@ -0,0 +1,173 @@ +use toboggan_core::Talk; + +use super::renderer::{RenderMetrics, measure_render}; +use crate::error::Result; + +pub struct TextRenderer; + +impl TextRenderer { + pub fn toml(talk: &Talk) -> Result> { + let (result, _) = measure_render(|| toml::to_string_pretty(talk)); + Ok(result?.into_bytes()) + } + + pub fn json(talk: &Talk) -> Result> { + let (result, _) = measure_render(|| serde_json::to_string_pretty(talk)); + Ok(result?.into_bytes()) + } + + pub fn json_compact(talk: &Talk) -> Result> { + let (result, _) = measure_render(|| serde_json::to_string(talk)); + Ok(result?.into_bytes()) + } + + pub fn yaml(talk: &Talk) -> Result> { + let (result, _) = measure_render(|| serde_saphyr::to_string(talk)); + Ok(result + .map_err(|err| crate::error::TobogganCliError::Serialize { + format: "YAML".to_string(), + message: err.to_string(), + })? + .into_bytes()) + } + + pub fn metrics(talk: &Talk, format: &str) -> Result { + let input_size = std::mem::size_of_val(talk); + + let (output, render_time) = match format { + "toml" => { + let (result, time) = measure_render(|| toml::to_string_pretty(talk)); + (result?.len(), time) + } + "json" => { + let (result, time) = measure_render(|| serde_json::to_string_pretty(talk)); + (result?.len(), time) + } + "yaml" => { + let (result, time) = measure_render(|| serde_saphyr::to_string(talk)); + ( + result + .map_err(|err| crate::error::TobogganCliError::Serialize { + format: "YAML".to_string(), + message: err.to_string(), + })? + .len(), + time, + ) + } + _ => { + return Err(crate::error::TobogganCliError::Serialize { + format: format.to_string(), + message: "Unsupported text format".to_string(), + }); + } + }; + + Ok(RenderMetrics::new(input_size, output, render_time)) + } +} + +#[cfg(test)] +mod tests { + use anyhow::Context; + use toboggan_core::{Date, Talk}; + + use super::*; + + fn create_test_talk() -> anyhow::Result { + let mut talk = Talk::new("Test Talk"); + talk.date = Date::new(2024, 12, 25) + .map_err(|err| anyhow::anyhow!("Failed to create test date: {err}"))?; + Ok(talk) + } + + #[test] + fn test_toml_rendering() -> anyhow::Result<()> { + let talk = create_test_talk()?; + let output = TextRenderer::toml(&talk)?; + let content = String::from_utf8(output) + .map_err(|err| anyhow::anyhow!("Failed to convert TOML output to UTF-8: {err}"))?; + + assert!(content.contains("title = \"Test Talk\"")); + // Date format might vary, just check it contains the date + assert!(content.contains("2024") && content.contains("12") && content.contains("25")); + Ok(()) + } + + #[test] + fn test_json_rendering() -> anyhow::Result<()> { + let talk = create_test_talk()?; + let output = TextRenderer::json(&talk)?; + let content = String::from_utf8(output) + .map_err(|err| anyhow::anyhow!("Failed to convert JSON output to UTF-8: {err}"))?; + + assert!(content.contains("\"title\": \"Test Talk\"")); + assert!(content.contains("\"date\": \"2024-12-25\"")); + Ok(()) + } + + #[test] + fn test_yaml_rendering() -> anyhow::Result<()> { + let talk = create_test_talk()?; + let output = TextRenderer::yaml(&talk)?; + let content = String::from_utf8(output) + .map_err(|err| anyhow::anyhow!("Failed to convert YAML output to UTF-8: {err}"))?; + + assert!(content.contains("title: Test Talk")); + // Date format might vary, just check it contains the date + assert!(content.contains("2024") && content.contains("12") && content.contains("25")); + Ok(()) + } + + #[test] + fn test_json_compact_vs_pretty() -> anyhow::Result<()> { + let talk = create_test_talk()?; + let pretty = TextRenderer::json(&talk)?; + let compact = TextRenderer::json_compact(&talk)?; + + // Compact should be smaller + assert!(compact.len() < pretty.len()); + + // Both should contain the same data + let pretty_str = String::from_utf8(pretty) + .with_context(|| "Failed to convert pretty JSON output to UTF-8")?; + let compact_str = String::from_utf8(compact) + .with_context(|| "Failed to convert compact JSON output to UTF-8")?; + assert!(pretty_str.contains("Test Talk")); + assert!(compact_str.contains("Test Talk")); + + Ok(()) + } + + #[test] + fn test_renderer_traits() -> anyhow::Result<()> { + let talk = create_test_talk()?; + + // Since we removed the renderer structs, we can only test the static methods + assert!(TextRenderer::toml(&talk).is_ok()); + assert!(TextRenderer::json(&talk).is_ok()); + assert!(TextRenderer::yaml(&talk).is_ok()); + Ok(()) + } + + #[test] + fn test_metrics() -> anyhow::Result<()> { + let talk = create_test_talk()?; + + let toml_metrics = TextRenderer::metrics(&talk, "toml")?; + let json_metrics = TextRenderer::metrics(&talk, "json")?; + let yaml_metrics = TextRenderer::metrics(&talk, "yaml")?; + + // All should have positive output sizes + assert!(toml_metrics.output_size > 0); + assert!(json_metrics.output_size > 0); + assert!(yaml_metrics.output_size > 0); + + // All should have measured render times (u64 is always >= 0, so just check they exist) + let _ = toml_metrics.render_time_ms; + let _ = json_metrics.render_time_ms; + let _ = yaml_metrics.render_time_ms; + + Ok(()) + } +} diff --git a/toboggan-cli/src/parser/comments.rs b/toboggan-cli/src/parser/comments.rs new file mode 100644 index 0000000..fa52846 --- /dev/null +++ b/toboggan-cli/src/parser/comments.rs @@ -0,0 +1,198 @@ +use std::path::PathBuf; + +use crate::parser::CssClasses; + +#[derive(Debug, Clone, PartialEq)] +pub(super) enum CommentType { + Pause(CssClasses), + Cell(CssClasses), + Notes, + Code { info: String, path: PathBuf }, + Unknown, +} + +const MARKER_PAUSE: &str = "pause"; +const MARKER_CELL: &str = "cell"; +const MARKER_NOTES: &str = "notes"; +const MARKER_CODE: &str = "code"; + +fn parse_comment(html: &str) -> Option<&str> { + let html = html.trim(); + if !html.starts_with("") { + return None; + } + let html = html + .trim_start_matches("") + .trim(); + Some(html) +} + +pub(super) fn parse_comment_content(html: &str) -> CommentType { + let Some(comment_content) = parse_comment(html) else { + return CommentType::Unknown; + }; + + if comment_content.starts_with(MARKER_PAUSE) { + let classes_str = comment_content.trim_start_matches(MARKER_PAUSE); + let classes = parse_classes(classes_str); + CommentType::Pause(classes) + } else if comment_content.starts_with(MARKER_CELL) { + let classes_str = comment_content.trim_start_matches(MARKER_CELL); + let classes = parse_classes(classes_str); + CommentType::Cell(classes) + } else if comment_content.to_lowercase().starts_with(MARKER_NOTES) { + CommentType::Notes + } else if comment_content.starts_with(MARKER_CODE) { + parse_code_comment(comment_content) + } else { + CommentType::Unknown + } +} + +fn parse_classes(html: &str) -> CssClasses { + let trimmed = html.trim(); + if !trimmed.starts_with(':') { + return CssClasses::default(); + } + trimmed + .trim_start_matches(':') + .split_whitespace() + .map(ToString::to_string) + .collect() +} + +fn parse_code_comment(comment_content: &str) -> CommentType { + let content_after_code = comment_content.trim_start_matches(MARKER_CODE).trim(); + + // Remove leading ':' if present + let content_after_code = content_after_code + .strip_prefix(':') + .unwrap_or(content_after_code); + + // Split on ':' to get info and path parts + let parts: Vec<&str> = content_after_code.splitn(2, ':').collect(); + + if let (Some(info_part), Some(path_part)) = (parts.first(), parts.get(1)) { + let info = info_part.trim().to_string(); + let path = PathBuf::from(path_part.trim()); + CommentType::Code { info, path } + } else { + // If we can't parse properly, fall back to Unknown + CommentType::Unknown + } +} + +pub(super) fn parse_pause(html: &str) -> Option { + match parse_comment_content(html) { + CommentType::Pause(classes) => Some(classes), + _ => None, + } +} + +pub(super) fn parse_cell(html: &str) -> Option { + match parse_comment_content(html) { + CommentType::Cell(classes) => Some(classes), + _ => None, + } +} + +pub(super) fn is_notes(html: &str) -> bool { + matches!(parse_comment_content(html), CommentType::Notes) +} + +pub(super) fn parse_code(html: &str) -> Option<(String, PathBuf)> { + match parse_comment_content(html) { + CommentType::Code { info, path } => Some((info, path)), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_comment_type_parsing() { + // Test pause comment + let pause_comment = ""; + if let CommentType::Pause(classes) = parse_comment_content(pause_comment) { + assert_eq!(classes, vec!["highlight".to_string()]); + } else { + panic!("Expected Pause variant"); + } + + // Test cell comment + let cell_comment = ""; + if let CommentType::Cell(classes) = parse_comment_content(cell_comment) { + assert_eq!(classes, vec!["special".to_string()]); + } else { + panic!("Expected Cell variant"); + } + + // Test notes comment + let notes_comment = ""; + assert_eq!(parse_comment_content(notes_comment), CommentType::Notes); + + // Test notes comment with different case + let notes_comment_upper = ""; + assert_eq!( + parse_comment_content(notes_comment_upper), + CommentType::Notes + ); + + // Test code comment + let code_comment = ""; + if let CommentType::Code { info, path } = parse_comment_content(code_comment) { + assert_eq!(info, "rust"); + assert_eq!(path, PathBuf::from("src/main.rs")); + } else { + panic!("Expected Code variant"); + } + + // Test code comment with spaces + let code_comment_spaces = ""; + if let CommentType::Code { info, path } = parse_comment_content(code_comment_spaces) { + assert_eq!(info, "javascript"); + assert_eq!(path, PathBuf::from("app.js")); + } else { + panic!("Expected Code variant with spaces"); + } + + // Test malformed code comment (missing path) + let malformed_code_comment = ""; + assert_eq!( + parse_comment_content(malformed_code_comment), + CommentType::Unknown + ); + + // Test unknown comment + let unknown_comment = ""; + assert_eq!(parse_comment_content(unknown_comment), CommentType::Unknown); + + // Test non-comment + let not_comment = "regular text"; + assert_eq!(parse_comment_content(not_comment), CommentType::Unknown); + } + + #[test] + #[allow(clippy::expect_used)] + fn test_backward_compatibility() { + // Test that legacy functions still work + let pause_comment = ""; + let classes = parse_pause(pause_comment).expect("a pause"); + assert_eq!(classes, vec!["class1".to_string(), "class2".to_string()]); + + let cell_comment = ""; + let classes = parse_cell(cell_comment).expect("a cell"); + assert_eq!(classes, vec!["grid".to_string()]); + + let notes_comment = ""; + assert!(is_notes(notes_comment)); + + let code_comment = ""; + let (info, path) = parse_code(code_comment).expect("a code"); + assert_eq!(info, "python"); + assert_eq!(path, PathBuf::from("script.py")); + } +} diff --git a/toboggan-cli/src/parser/config.rs b/toboggan-cli/src/parser/config.rs new file mode 100644 index 0000000..c4c7795 --- /dev/null +++ b/toboggan-cli/src/parser/config.rs @@ -0,0 +1,61 @@ +use comrak::Options; +use comrak::options::Plugins; +use comrak::plugins::syntect::{SyntectAdapter, SyntectAdapterBuilder}; + +use crate::parser::FRONT_MATTER_DELIMITER; + +/// Get standardized Markdown parsing options +#[must_use] +pub(super) fn default_options() -> Options<'static> { + let mut options = Options::default(); + + // Enable extensions + options.extension.strikethrough = true; + options.extension.table = true; + options.extension.autolink = true; + options.extension.tasklist = true; + options.extension.superscript = true; + options.extension.footnotes = true; + options.extension.description_lists = true; + options.extension.front_matter_delimiter = Some(FRONT_MATTER_DELIMITER.to_string()); + options.extension.alerts = true; + options.extension.subscript = true; + options.extension.spoiler = true; + options.extension.greentext = true; + + options.render.r#unsafe = true; + + options +} + +#[must_use] +pub(super) fn default_plugins() -> Plugins<'static> { + Plugins::default() +} + +#[must_use] +pub(super) fn create_syntax_highlighter(theme: &str) -> SyntectAdapter { + SyntectAdapterBuilder::new().theme(theme).build() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_options() { + let options = default_options(); + assert!(options.extension.strikethrough); + assert!(options.extension.table); + assert_eq!( + options.extension.front_matter_delimiter, + Some(FRONT_MATTER_DELIMITER.to_string()) + ); + } + + #[test] + fn test_default_plugins() { + let _plugins = default_plugins(); + // Just verify it doesn't panic and returns successfully + } +} diff --git a/toboggan-cli/src/parser/content.rs b/toboggan-cli/src/parser/content.rs new file mode 100644 index 0000000..ab83d00 --- /dev/null +++ b/toboggan-cli/src/parser/content.rs @@ -0,0 +1,808 @@ +use std::path::Path; +use std::string::ToString; + +use comrak::nodes::NodeValue; +use comrak::options::Plugins; +use comrak::{Options, format_commonmark}; +use miette::SourceSpan; +use toboggan_core::{Content, Slide, SlideKind}; + +use crate::error::{Result, TobogganCliError}; +use crate::parser::comments::{is_notes, parse_cell, parse_code, parse_pause}; +use crate::parser::directory::{extract_node_text, parse_frontmatter}; +use crate::parser::{ + ContentRenderer, CssClasses, DEFAULT_SLIDE_TITLE, FrontMatter, HtmlRenderer, MarkdownNode, + default_options, +}; + +#[derive(Debug, Clone, Default)] +pub struct InnerContent { + before_steps: String, + steps: Vec<(String, CssClasses)>, + next_step: Option<(String, CssClasses)>, +} + +impl InnerContent { + fn handle<'a>(&mut self, elt: &'a MarkdownNode<'a>, file_name: &str) -> Result<()> { + // Check if this is a code comment and handle it differently + let mut code_block_md = None; + { + let data = elt.data.borrow(); + if let NodeValue::HtmlBlock(html) = &data.value + && let Some((info, path)) = parse_code(&html.literal) + { + // Instead of modifying the AST, generate markdown directly + let file_content = std::fs::read_to_string(&path) + .map_err(|source| TobogganCliError::read_file(path.clone(), source))?; + + // Generate a fenced code block markdown + code_block_md = Some(format!("```{info}\n{file_content}\n```\n")); + } + } + + let md = if let Some(code_md) = code_block_md { + // Use the generated code block markdown + code_md + } else { + // Regular processing + let data = &elt.data.borrow().value; + let mut buffer = String::new(); + let options = default_options(); + format_commonmark(elt, &options, &mut buffer).map_err(|source| { + TobogganCliError::FormatCommonmark { + src: miette::NamedSource::new(file_name, format!("{data:?}")), + span: SourceSpan::from((0, 1)), + message: source.to_string(), + } + })?; + let mut md = buffer; + + if let NodeValue::HtmlBlock(html) = data { + // Detect new pause + if let Some(classes) = parse_pause(&html.literal) { + if let Some(current) = self.next_step.take() { + self.steps.push(current); + } + self.next_step = Some((String::default(), classes)); + md = String::new(); + } + } + + md + }; + + if let Some((next, _)) = &mut self.next_step { + next.push_str(&md); + } else { + self.before_steps.push_str(&md); + } + + Ok(()) + } + + fn render_with(&self, renderer: &R) -> Content { + let all_steps: Vec<_> = self + .steps + .iter() + .chain(self.next_step.iter()) + .cloned() + .collect(); + renderer.render_steps(&self.before_steps, &all_steps) + } +} + +#[derive(Debug, Clone, Default)] +pub struct CellsContent { + cells: Vec<(InnerContent, CssClasses)>, + next_cell: Option<(InnerContent, CssClasses)>, +} + +impl CellsContent { + fn handle<'a>(&mut self, elt: &'a MarkdownNode<'a>, file_name: &str) -> Result<()> { + let data = &elt.data.borrow().value; + if let NodeValue::HtmlBlock(html) = data { + // Detect new cell + if let Some(classes) = parse_cell(&html.literal) { + if let Some(current) = self.next_cell.take() { + self.cells.push(current); + } + self.next_cell = Some((InnerContent::default(), classes)); + } + } + + if let Some((next, _)) = &mut self.next_cell { + next.handle(elt, file_name)?; + } else { + let mut next = InnerContent::default(); + next.handle(elt, file_name)?; + self.next_cell = Some((next, CssClasses::default())); + } + + Ok(()) + } + + fn render_with(&self, renderer: &R) -> Content { + let cell_contents: Vec<_> = self + .cells + .iter() + .chain(self.next_cell.iter()) + .map(|(inner_content, classes)| { + // For now, we'll extract the content as markdown + // In a full refactor, we'd have the renderer handle InnerContent directly + let mut content = inner_content.before_steps.clone(); + for (step, _) in &inner_content.steps { + content.push('\n'); + content.push_str(step); + } + if let Some((next_step, _)) = &inner_content.next_step { + content.push('\n'); + content.push_str(next_step); + } + (content, classes.clone()) + }) + .collect(); + + renderer.render_cells(&cell_contents) + } +} + +#[derive(Debug, Clone)] +pub enum SlideContentParser { + Init, + Base { + fm: FrontMatter, + title: Option, + inner: InnerContent, + }, + Grid { + fm: FrontMatter, + title: Option, + cells: CellsContent, + }, + Notes { + fm: FrontMatter, + title: Option, + inner: InnerContent, + notes: InnerContent, + }, + GridNotes { + fm: FrontMatter, + title: Option, + cells: CellsContent, + notes: InnerContent, + }, +} + +impl SlideContentParser { + #[must_use] + pub fn new() -> Self { + Self::Init + } + + /// Handle frontmatter parsing and state transitions + fn handle_frontmatter(&mut self, content: &str, file_name: &str) -> Result<()> { + let frontmatter = parse_frontmatter(content, file_name)?; + + if frontmatter.grid { + *self = Self::Grid { + fm: frontmatter, + title: None, + cells: CellsContent::default(), + }; + } else if let Self::Base { fm, .. } = self { + *fm = frontmatter; + } + Ok(()) + } + + /// Handle heading nodes and extract titles + fn handle_heading(&mut self, heading_text: String) { + match self { + Self::Base { title, .. } | Self::Grid { title, .. } => { + if title.is_none() && !heading_text.is_empty() { + *title = Some(heading_text); + } + } + _ => {} + } + } + + /// Transition to notes state + fn transition_to_notes(&mut self) { + match self { + Self::Base { fm, title, inner } => { + *self = Self::Notes { + fm: fm.clone(), + title: title.clone(), + inner: inner.clone(), + notes: InnerContent::default(), + }; + } + Self::Grid { fm, title, cells } => { + *self = Self::GridNotes { + fm: fm.clone(), + title: title.clone(), + cells: cells.clone(), + notes: InnerContent::default(), + }; + } + _ => {} + } + } + + fn handle<'a>(&mut self, elt: &'a MarkdownNode<'a>, file_name: &str) -> Result<()> { + let data = &elt.data.borrow().value; + match self { + Self::Init => { + *self = Self::Base { + fm: FrontMatter::default(), + title: None, + inner: InnerContent::default(), + }; + self.handle(elt, file_name)?; + } + Self::Base { inner, .. } => match data { + NodeValue::FrontMatter(content) => { + self.handle_frontmatter(content, file_name)?; + } + NodeValue::Heading(_) => { + let heading_text = extract_node_text(elt); + self.handle_heading(heading_text); + // Need to access inner after self is borrowed mutably + if let Self::Base { inner, .. } = self { + inner.handle(elt, file_name)?; + } + } + NodeValue::HtmlBlock(html) if is_notes(&html.literal) => { + self.transition_to_notes(); + } + _ => inner.handle(elt, file_name)?, + }, + Self::Grid { fm, title, cells } => match data { + NodeValue::HtmlBlock(html) if is_notes(&html.literal) => { + let new_state = Self::GridNotes { + fm: fm.clone(), + title: title.clone(), + cells: cells.clone(), + notes: InnerContent::default(), + }; + *self = new_state; + } + NodeValue::Heading(_) if title.is_none() => { + *title = Some(extract_node_text(elt)); + cells.handle(elt, file_name)?; + } + _ => cells.handle(elt, file_name)?, + }, + Self::Notes { notes, .. } | Self::GridNotes { notes, .. } => { + notes.handle(elt, file_name)?; + } + } + + Ok(()) + } + + fn front_matter(&self) -> FrontMatter { + match self { + Self::Init => FrontMatter::default(), + Self::Base { fm, .. } + | Self::Grid { fm, .. } + | Self::Notes { fm, .. } + | Self::GridNotes { fm, .. } => fm.clone(), + } + } + + fn title(&self) -> Option { + match self { + Self::Init => None, + Self::Base { fm, title, .. } + | Self::Grid { fm, title, .. } + | Self::Notes { fm, title, .. } + | Self::GridNotes { fm, title, .. } => fm.title.clone().or_else(|| title.clone()), + } + } + + fn notes(&self, renderer: &HtmlRenderer) -> Content { + match self { + Self::Init | Self::Base { .. } | Self::Grid { .. } => Content::Empty, + Self::Notes { notes, .. } | Self::GridNotes { notes, .. } => { + notes.render_with(renderer) + } + } + } + + fn body(&self, renderer: &HtmlRenderer) -> Content { + match self { + Self::Init => Content::Empty, + Self::Base { inner, .. } | Self::Notes { inner, .. } => inner.render_with(renderer), + Self::Grid { cells, .. } | Self::GridNotes { cells, .. } => cells.render_with(renderer), + } + } + + pub fn parse<'a, I>( + mut self, + iterator: I, + options: &Options, + plugins: &Plugins, + name: Option<&str>, + path: Option<&Path>, + ) -> Result<(Slide, FrontMatter)> + where + I: Iterator>, + { + let file_name = path.map_or_else( + || "".to_string(), + |path| path.to_string_lossy().to_string(), + ); + + for elt in iterator { + self.handle(elt, &file_name)?; + } + + let front_matter = self.front_matter(); + let style = front_matter.to_style()?; + let renderer = HtmlRenderer::new(options, plugins, style.clone()); + + let result = Slide { + kind: SlideKind::Standard, + style, + title: Content::Text { + text: self + .title() + .or_else(|| name.map(ToString::to_string)) + .unwrap_or_else(|| DEFAULT_SLIDE_TITLE.to_string()), + }, + body: self.body(&renderer), + notes: self.notes(&renderer), + }; + + Ok((result, front_matter)) + } + + /// Convenience method to parse with default options and plugins + pub fn parse_with_defaults<'a, I>( + self, + iterator: I, + name: Option<&str>, + path: Option<&Path>, + ) -> Result<(Slide, FrontMatter)> + where + I: Iterator>, + { + use crate::parser::{default_options, default_plugins}; + let options = default_options(); + let plugins = default_plugins(); + self.parse(iterator, &options, &plugins, name, path) + } +} + +impl Default for SlideContentParser { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use comrak::{Arena, parse_document}; + use toboggan_core::Content; + + use super::*; + use crate::parser::{default_options, default_plugins}; + + fn parse_markdown_content(content: &str) -> Result<(Slide, FrontMatter)> { + let arena = Arena::new(); + let options = default_options(); + let plugins = default_plugins(); + + let root = parse_document(&arena, content, &options); + + let parser = SlideContentParser::new(); + parser.parse(root.children(), &options, &plugins, None, None) + } + + #[test] + fn test_basic_slide_parsing() -> Result<()> { + let markdown = "# Test Title\n\nThis is basic content."; + let (slide, _) = parse_markdown_content(markdown)?; + + assert_eq!(slide.title.to_string(), "Test Title"); + assert!(matches!(slide.body, Content::Html { .. })); + + Ok(()) + } + + #[test] + fn test_frontmatter_parsing() -> Result<()> { + let markdown = r#"+++ +title = "Frontmatter Title" +classes = ["custom-class"] ++++ + +# Main Title + +Content here."#; + + let (_slide, front_matter) = parse_markdown_content(markdown)?; + + assert_eq!(front_matter.title, Some("Frontmatter Title".to_string())); + assert_eq!(front_matter.classes, vec!["custom-class"]); + assert!(!front_matter.grid); + + Ok(()) + } + + #[test] + fn test_grid_parsing() -> Result<()> { + let markdown = r"+++ +grid = true ++++ + +# Grid Title + + +First cell content + + +Second cell content"; + + let (slide, front_matter) = parse_markdown_content(markdown)?; + + assert!(front_matter.grid); + if let Content::Html { raw, .. } = slide.body { + assert!(raw.contains("cell-0")); + assert!(raw.contains("cell-1")); + } + + Ok(()) + } + + #[test] + fn test_notes_parsing() -> Result<()> { + let markdown = r"# Title + +Main content here. + + + +These are speaker notes."; + + let (slide, _) = parse_markdown_content(markdown)?; + + assert!(matches!(slide.notes, Content::Html { .. })); + if let Content::Html { raw, .. } = slide.notes { + assert!(raw.contains("speaker notes")); + } + + Ok(()) + } + + #[test] + fn test_pause_parsing() -> Result<()> { + let markdown = r"# Title + +Before pause. + + + +After pause."; + + let (slide, _) = parse_markdown_content(markdown)?; + + if let Content::Html { raw, .. } = slide.body { + assert!(raw.contains("step-0")); + assert!(raw.contains("Before pause")); + assert!(raw.contains("After pause")); + } + + Ok(()) + } + + #[test] + fn test_pause_with_classes() -> Result<()> { + let markdown = r"# Title + +Content before. + + + +Highlighted content."; + + let (slide, _) = parse_markdown_content(markdown)?; + + if let Content::Html { raw, .. } = slide.body { + assert!(raw.contains("highlight")); + } + + Ok(()) + } + + #[test] + fn test_grid_with_notes() -> Result<()> { + let markdown = r"+++ +grid = true ++++ + +# Grid with Notes + + +Cell 1 + + +Cell 2 + + +Grid notes here."; + + let (slide, front_matter) = parse_markdown_content(markdown)?; + + assert!(front_matter.grid); + assert!(matches!(slide.notes, Content::Html { .. })); + + Ok(()) + } + + #[test] + fn test_filename_as_default_title() -> Result<()> { + let markdown = "Content without title"; + + let arena = Arena::new(); + let options = default_options(); + let plugins = default_plugins(); + let root = parse_document(&arena, markdown, &options); + + let parser = SlideContentParser::new(); + let (slide, _) = + parser.parse(root.children(), &options, &plugins, Some("my-slide"), None)?; + + assert_eq!(slide.title.to_string(), "my-slide"); + + Ok(()) + } + + #[test] + fn test_explicit_title_precedence_over_filename() -> Result<()> { + let markdown = "# Explicit Title\n\nContent here"; + + let arena = Arena::new(); + let options = default_options(); + let plugins = default_plugins(); + let root = parse_document(&arena, markdown, &options); + + let parser = SlideContentParser::new(); + let (slide, _) = + parser.parse(root.children(), &options, &plugins, Some("filename"), None)?; + + // Explicit title should take precedence over filename + assert_eq!(slide.title.to_string(), "Explicit Title"); + + Ok(()) + } + + #[test] + fn test_empty_content() -> Result<()> { + let markdown = ""; + let (slide, _) = parse_markdown_content(markdown)?; + + assert_eq!(slide.title.to_string(), ""); + + Ok(()) + } + + #[test] + fn test_title_precedence() -> Result<()> { + let markdown = r#"+++ +title = "FM Title" ++++ + +# Markdown Title + +Content."#; + + let (slide, _) = parse_markdown_content(markdown)?; + + // Frontmatter title should take precedence + assert_eq!(slide.title.to_string(), "FM Title"); + + Ok(()) + } + + #[test] + fn test_alt_text_generation() -> Result<()> { + let markdown = "# Title\n\nSome **bold** text with *emphasis*."; + let (slide, _) = parse_markdown_content(markdown)?; + + if let Content::Html { alt: Some(alt), .. } = slide.body { + assert!(alt.contains("bold")); + assert!(alt.contains("emphasis")); + } + + Ok(()) + } + + #[test] + fn test_css_style_in_markdown() -> Result<()> { + // Test that CSS can be included as a style block in markdown + let markdown = r#"+++ +title = "CSS Style Test" +classes = ["custom", "styled"] ++++ + +# Test Title + + + +Content with inline CSS."#; + + let (slide, front_matter) = parse_markdown_content(markdown)?; + + // Check that the style contains the classes + let style = front_matter.to_style()?; + assert_eq!(style.classes, vec!["custom", "styled"]); + + // Check that the slide style also contains the classes + assert_eq!(slide.style.classes, vec!["custom", "styled"]); + + // The body should contain the style block as HTML + let body_str = slide.body.to_string(); + assert!(body_str.contains("

More text

"; + let cleaned = remove_inner_tag_content(html); + assert!(!cleaned.contains("color")); + assert!(cleaned.contains("Some text")); + assert!(cleaned.contains("More text")); + + // Test svg tag removal + let html = r#"
Text
More
"#; + let cleaned = remove_inner_tag_content(html); + assert!(!cleaned.contains("path")); + assert!(!cleaned.contains("M0,0")); + assert!(cleaned.contains("Text")); + assert!(cleaned.contains("More")); + + // Test script tag removal + let html = r#"

Content

End

"#; + let cleaned = remove_inner_tag_content(html); + assert!(!cleaned.contains("console")); + assert!(!cleaned.contains("log")); + assert!(cleaned.contains("Content")); + assert!(cleaned.contains("End")); + + // Test case insensitivity + let html = r"

Text

More

"; + let cleaned = remove_inner_tag_content(html); + assert!(!cleaned.contains("body{}")); + + // Test figure tag removal + let html = r#"

Text

Caption text

More

"#; + let cleaned = remove_inner_tag_content(html); + assert!(!cleaned.contains("Caption text")); + assert!(!cleaned.contains("figcaption")); + assert!(cleaned.contains("Text")); + assert!(cleaned.contains("More")); + + // Test multiple tags + let html = r"

A

B

x

C

D

z

E

"; + let cleaned = remove_inner_tag_content(html); + assert!(!cleaned.contains("a{}")); + assert!(!cleaned.contains(">x<")); + assert!(!cleaned.contains(">y<")); + assert!(!cleaned.contains(">z<")); + assert!(cleaned.contains('A')); + assert!(cleaned.contains('B')); + assert!(cleaned.contains('C')); + assert!(cleaned.contains('D')); + assert!(cleaned.contains('E')); + } + + #[test] + fn test_word_counting_excludes_inner_tags() { + // Style tag content should not be counted + let html_content = Content::Html { + raw: r"

Hello world

" + .to_string(), + style: Style::default(), + alt: None, + }; + let stats = analyze_content(&html_content); + assert_eq!( + stats.words, 2, + "Should only count 'Hello world', not CSS properties" + ); + + // SVG content should not be counted + let html_content = Content::Html { + raw: r#"

One two three

"#.to_string(), + style: Style::default(), + alt: None, + }; + let stats = analyze_content(&html_content); + assert_eq!(stats.words, 3, "Should only count 'One two three', not SVG"); + + // Script content should not be counted + let html_content = Content::Html { + raw: r"

Test content

".to_string(), + style: Style::default(), + alt: None, + }; + let stats = analyze_content(&html_content); + assert_eq!( + stats.words, 2, + "Should only count 'Test content', not JavaScript" + ); + + // Figure content (including figcaption) should not be counted as words + let html_content = Content::Html { + raw: r#"

Main text here

This is a long caption with many words
"#.to_string(), + style: Style::default(), + alt: None, + }; + let stats = analyze_content(&html_content); + assert_eq!( + stats.words, 3, + "Should only count 'Main text here', not figcaption content" + ); + assert_eq!(stats.images, 2, "Should count both figure and img"); + } + + #[test] + fn test_statistics_calculation() { + let talk_metadata = TalkMetadata { + title: "Test Talk".to_string(), + date: toboggan_core::Date::today(), + footer: None, + head: None, + }; + + let intro_slide = Slide::new("Introduction").with_body(Content::Text { + text: "Hello world this is a test".to_string(), + }); + + let part_slide = Slide::part("Part One").with_body(Content::Text { + text: "Part introduction".to_string(), + }); + + let slides = vec![ + SlideProcessingResult::Processed(intro_slide), + SlideProcessingResult::Processed(part_slide), + ]; + + let parse_result = ParseResult { + talk_metadata, + slides, + }; + + let stats = PresentationStats::from_parse_result(&parse_result, 150, false); + + assert_eq!(stats.total_slides, 1); // Only non-part slides + assert_eq!(stats.total_parts, 1); + assert!(stats.total_words > 0); + // Verify intro slide is counted in the breakdown + let total_in_parts: usize = stats.slides_per_part.values().sum(); + assert_eq!( + total_in_parts, stats.total_slides, + "All slides should be in parts" + ); + } + + #[test] + fn test_diagram_slide_counting() { + let talk_metadata = TalkMetadata { + title: "Test Talk".to_string(), + date: toboggan_core::Date::today(), + footer: None, + head: None, + }; + + // Simulate a diagram slide with counter: title="3.5 Diagram" body=SVG + let mut diagram_slide = Slide::new("Diagram").with_body(Content::Html { + raw: r#"Label"#.to_string(), + style: Style::default(), + alt: None, + }); + // Simulate counter being added (like add_counters_to_slides does) + diagram_slide.title = "3.5 Diagram".into(); + + let part_slide = Slide::part("Part 3"); + + let slides = vec![ + SlideProcessingResult::Processed(part_slide), + SlideProcessingResult::Processed(diagram_slide), + ]; + + let parse_result = ParseResult { + talk_metadata, + slides, + }; + + let stats = PresentationStats::from_parse_result(&parse_result, 150, false); + + assert_eq!(stats.total_slides, 1, "Should have 1 slide"); + assert_eq!(stats.total_images, 1, "Should count SVG as 1 image"); + // Counter "3.5 " is stripped, only "Diagram" counted + assert_eq!( + stats.total_words, 1, + "Title '3.5 Diagram' counts as 1 word (counter stripped, text inside SVG excluded)" + ); + } + + #[test] + fn test_strip_slide_counter() { + assert_eq!(strip_slide_counter("3.5 Diagram"), "Diagram"); + assert_eq!(strip_slide_counter("1. Introduction"), "Introduction"); + assert_eq!(strip_slide_counter("Diagram"), "Diagram"); + assert_eq!(strip_slide_counter("10.20 Test"), "Test"); + assert_eq!(strip_slide_counter(""), ""); + } + + #[test] + fn test_notes_not_double_counted_in_duration() { + let talk_metadata = TalkMetadata { + title: "Test Talk".to_string(), + date: toboggan_core::Date::today(), + footer: None, + head: None, + }; + + // Create a slide with content words (2 in title + 6 in body = 8 total) and 4 notes words + let slide = Slide::new("Test Slide") + .with_body(Content::Text { + text: "one two three four five six".to_string(), + }) + .with_notes(Content::Text { + text: "note1 note2 note3 note4".to_string(), + }); + + let slides = vec![SlideProcessingResult::Processed(slide)]; + + let parse_result = ParseResult { + talk_metadata, + slides, + }; + + // Test with notes included in duration + let stats = PresentationStats::from_parse_result(&parse_result, 150, true); + + assert_eq!( + stats.total_words, 8, + "total_words should count title + body (2 + 6 = 8)" + ); + assert_eq!( + stats.total_notes_words, 4, + "notes should be tracked separately" + ); + + let duration = stats.duration_estimates(); + // Duration should be based on 8 + 4 = 12 words at 150 WPM = 0.08 minutes = 4.8 seconds + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let expected_seconds = ((12.0 / 150.0) * 60.0) as u64; + assert_eq!( + duration.custom.as_secs(), + expected_seconds, + "duration should include both content and notes words exactly once" + ); + + // Test with notes NOT included in duration + let stats_no_notes = PresentationStats::from_parse_result(&parse_result, 150, false); + let duration_no_notes = stats_no_notes.duration_estimates(); + // Duration should be based on 8 words only at 150 WPM = 0.0533 minutes = 3.2 seconds + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let expected_seconds_no_notes = ((8.0 / 150.0) * 60.0) as u64; + assert_eq!( + duration_no_notes.custom.as_secs(), + expected_seconds_no_notes, + "duration should only include content words when notes are excluded" + ); + } +} diff --git a/toboggan-cli/test_slides/_cover.md b/toboggan-cli/test_slides/_cover.md new file mode 100644 index 0000000..2f4b519 --- /dev/null +++ b/toboggan-cli/test_slides/_cover.md @@ -0,0 +1 @@ +# Cover diff --git a/toboggan-cli/test_slides/data.json b/toboggan-cli/test_slides/data.json new file mode 100644 index 0000000..4c88631 --- /dev/null +++ b/toboggan-cli/test_slides/data.json @@ -0,0 +1 @@ +data file diff --git a/toboggan-cli/test_slides/readme.txt b/toboggan-cli/test_slides/readme.txt new file mode 100644 index 0000000..bfa69df --- /dev/null +++ b/toboggan-cli/test_slides/readme.txt @@ -0,0 +1 @@ +not a slide diff --git a/toboggan-cli/test_slides/slide1.md b/toboggan-cli/test_slides/slide1.md new file mode 100644 index 0000000..0e1f511 --- /dev/null +++ b/toboggan-cli/test_slides/slide1.md @@ -0,0 +1 @@ +# Slide 1 diff --git a/toboggan-client/Cargo.toml b/toboggan-client/Cargo.toml new file mode 100644 index 0000000..394a8ed --- /dev/null +++ b/toboggan-client/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "toboggan-client" +version = "0.1.0" +description = "Async WebSocket and HTTP client library for connecting to Toboggan presentation servers" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +toboggan-core = {path = "../toboggan-core", features = ["std"]} + +tracing = {workspace = true} +serde = { workspace = true } +serde_json = { workspace = true } +derive_more = { workspace = true, features = ["from", "error", "display"] } + +# Async dependencies (always available now) +tokio = { workspace = true, features = ["sync", "time"] } +tokio-tungstenite = { workspace = true } +futures = { workspace = true } +reqwest = { workspace = true, features = ["json"] } + +[dev-dependencies] +tokio= { workspace = true, features = ["macros", "rt-multi-thread"] } +tracing-subscriber = { workspace = true } + +[lints] +workspace = true diff --git a/toboggan-client/examples/test_client.rs b/toboggan-client/examples/test_client.rs new file mode 100644 index 0000000..5093f12 --- /dev/null +++ b/toboggan-client/examples/test_client.rs @@ -0,0 +1,41 @@ +use std::time::Duration; + +use toboggan_client::{TobogganApi, TobogganConfig, WebSocketClient}; +use toboggan_core::{ClientConfig, Command}; +use tokio::sync::mpsc; +use tracing::info; + +#[allow(clippy::expect_used)] +#[tokio::main] +async fn main() { + tracing_subscriber::fmt().pretty().init(); + + let config = TobogganConfig::default(); + let client_id = config.client_id(); + let api_url = config.api_url(); + let websocket = config.websocket(); + + let api = TobogganApi::new(api_url); + + let (tx, rx) = mpsc::unbounded_channel(); + let (mut com, mut rx_msg) = WebSocketClient::new(tx.clone(), rx, client_id, websocket); + + let read_messages = tokio::spawn(async move { + while let Some(msg) = rx_msg.recv().await { + info!(?msg, "receive WS message"); + } + }); + + let talk = api.talk().await.expect("loading talk"); + info!(?talk, "๐ŸŽ™๏ธ Talk"); + + com.connect().await; + + for _ in 0..10 { + tokio::time::sleep(Duration::from_secs(1)).await; + tx.send(Command::Next).expect("sending next"); + } + + let _ = tokio::time::timeout(Duration::from_secs(20), read_messages).await; + info!("๐Ÿ‘‹ Bye"); +} diff --git a/toboggan-client/rustfmt.toml b/toboggan-client/rustfmt.toml new file mode 100644 index 0000000..2a732ff --- /dev/null +++ b/toboggan-client/rustfmt.toml @@ -0,0 +1,5 @@ +# Rustfmt configuration +edition = "2024" +unstable_features = true +imports_granularity = "Module" +group_imports = "StdExternalCrate" \ No newline at end of file diff --git a/toboggan-client/src/api.rs b/toboggan-client/src/api.rs new file mode 100644 index 0000000..fc4712f --- /dev/null +++ b/toboggan-client/src/api.rs @@ -0,0 +1,70 @@ +use serde::Serialize; +use serde::de::DeserializeOwned; +use toboggan_core::{Command, Notification, Slide, SlidesResponse, TalkResponse}; + +#[derive(Debug, derive_more::Error, derive_more::From, derive_more::Display)] +pub enum TobogganApiError { + ReqwestError(reqwest::Error), +} + +#[derive(Debug, Clone)] +pub struct TobogganApi { + client: reqwest::Client, + api_url: String, +} + +impl TobogganApi { + pub fn new(api_url: impl Into) -> Self { + let api_url = api_url.into(); + let client = reqwest::Client::new(); + Self { client, api_url } + } + + fn build_url(&self, path: &str) -> String { + format!( + "{}/{}", + self.api_url.trim_end_matches('/'), + path.trim_start_matches('/') + ) + } + + async fn get(&self, path: &str) -> Result + where + T: DeserializeOwned, + { + let url = self.build_url(path); + let response = self.client.get(&url).send().await?; + let response = response.error_for_status()?; + let result = response.json().await?; + Ok(result) + } + + async fn post(&self, path: &str, body: &B) -> Result + where + B: Serialize, + R: DeserializeOwned, + { + let url = self.build_url(path); + let response = self.client.post(&url).json(body).send().await?; + let response = response.error_for_status()?; + let result = response.json().await?; + Ok(result) + } + + pub async fn talk(&self) -> Result { + self.get("/api/talk").await + } + + pub async fn slides(&self) -> Result { + self.get("/api/slides").await + } + + pub async fn slide(&self, slide_index: usize) -> Result { + let path = format!("/api/slides/{slide_index}"); + self.get(&path).await + } + + pub async fn command(&self, command: Command) -> Result { + self.post("/api/command", &command).await + } +} diff --git a/toboggan-client/src/communication.rs b/toboggan-client/src/communication.rs new file mode 100644 index 0000000..cb24964 --- /dev/null +++ b/toboggan-client/src/communication.rs @@ -0,0 +1,410 @@ +use std::mem; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use futures::stream::{SplitSink, SplitStream}; +use futures::{SinkExt, StreamExt}; +use toboggan_core::timeouts::PING_PERIOD; +use toboggan_core::{ClientId, Command, Notification, State}; +use tokio::net::TcpStream; +use tokio::sync::{Mutex, mpsc}; +use tokio::task::JoinHandle; +use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async}; +use tracing::{debug, error, info, warn}; + +use crate::TobogganWebsocketConfig; + +type WebSocket = WebSocketStream>; + +#[derive(Debug, Clone, derive_more::Display)] +pub enum ConnectionStatus { + #[display("๐Ÿ“ก Connecting...")] + Connecting, + #[display("๐Ÿ›œ Connected")] + Connected, + #[display("๐Ÿšช Closed")] + Closed, + #[display("โ›“๏ธโ€๐Ÿ’ฅ Reconnecting in {}s {attempt}/{max_attempt}", delay.as_secs())] + Reconnecting { + attempt: usize, + max_attempt: usize, + delay: Duration, + }, + #[display("๐Ÿ’ฅ Error: {message}")] + Error { message: String }, +} + +#[derive(Debug, Clone)] +pub enum CommunicationMessage { + ConnectionStatusChange { status: ConnectionStatus }, + StateChange { state: State }, + TalkChange { state: State }, + Error { error: String }, +} + +#[derive(Clone)] +struct ConnectionState { + retry_count: usize, + retry_delay: Duration, + is_disposed: bool, +} + +impl Default for ConnectionState { + fn default() -> Self { + Self { + retry_count: 0, + retry_delay: Duration::from_secs(1), + is_disposed: false, + } + } +} + +const RECONNECT_DELAY: Duration = Duration::from_secs(5); + +pub struct WebSocketClient { + client_id: ClientId, + config: TobogganWebsocketConfig, + tx_msg: mpsc::UnboundedSender, + tx_cmd: mpsc::UnboundedSender, + rx_cmd: Arc>>, + state: Arc>, + ping_task: Option>, + last_ping: Arc>>, +} + +impl WebSocketClient { + #[must_use] + pub fn new( + tx_cmd: mpsc::UnboundedSender, + rx_cmd: mpsc::UnboundedReceiver, + client_id: ClientId, + config: TobogganWebsocketConfig, + ) -> (Self, mpsc::UnboundedReceiver) { + let (tx_msg, rx_msg) = mpsc::unbounded_channel(); + + let state = ConnectionState::default(); + let state = Arc::new(Mutex::new(state)); + + let rx_cmd = Arc::new(Mutex::new(rx_cmd)); + + let result = Self { + client_id, + config, + tx_msg, + tx_cmd, + rx_cmd, + state, + ping_task: None, + last_ping: Arc::default(), + }; + (result, rx_msg) + } + + fn send_status_change(&self, status: ConnectionStatus) { + debug!(%status, "๐Ÿ—ฟconnection status"); + let _ = self + .tx_msg + .send(CommunicationMessage::ConnectionStatusChange { status }); + } + + pub async fn connect(&mut self) { + let state = self.state.lock().await; + if state.is_disposed { + warn!("Illegal disposed state, cannot connect"); + return; + } + mem::drop(state); + + self.attempt_connection().await; + } + + async fn attempt_connection(&mut self) { + self.send_status_change(ConnectionStatus::Connecting); + + let (ws, _) = match connect_async(&self.config.websocket_url).await { + Ok(ws) => ws, + Err(error) => { + error!(?error, "Failed to open WebSocket"); + self.send_status_change(ConnectionStatus::Error { + message: error.to_string(), + }); + self.schedule_reconnect().await; + return; + } + }; + + let (write, read) = ws.split(); + + self.handle_connection_open().await; + + let rx_cmd = Arc::clone(&self.rx_cmd); + tokio::spawn(handle_outgoing_commands(rx_cmd, write)); + + let tx_msg_clone = self.tx_msg.clone(); + let state_clone = self.state.clone(); + let config = self.config.clone(); + let last_ping = Arc::clone(&self.last_ping); + tokio::spawn(handle_incoming_messages( + read, + tx_msg_clone, + state_clone, + config, + last_ping, + )); + } + + async fn handle_connection_open(&mut self) { + { + let mut state = self.state.lock().await; + state.retry_count = 0; + state.retry_delay = self.config.retry_delay; + } + + self.start_pinging(); + + self.send_status_change(ConnectionStatus::Connected); + } + + async fn schedule_reconnect(&mut self) { + let (retry_count, retry_delay, max_retries) = { + let mut state = self.state.lock().await; + if state.is_disposed { + return; + } + + if state.retry_count >= self.config.max_retries { + let message = format!("Max retries reached! ({})", self.config.max_retries); + self.send_status_change(ConnectionStatus::Error { message }); + return; + } + + state.retry_count += 1; + (state.retry_count, RECONNECT_DELAY, self.config.max_retries) + }; + + self.send_status_change(ConnectionStatus::Reconnecting { + attempt: retry_count, + max_attempt: max_retries, + delay: retry_delay, + }); + + let tx_msg_clone = self.tx_msg.clone(); + let config = self.config.clone(); + let client_id = self.client_id; + let state_clone = Arc::clone(&self.state); + let tx_cmd = self.tx_cmd.clone(); + let rx_cmd = Arc::clone(&self.rx_cmd); + let last_ping = Arc::clone(&self.last_ping); + + tokio::spawn(async move { + tokio::time::sleep(retry_delay).await; + let state = state_clone.lock().await; + if !state.is_disposed { + mem::drop(state); + reconnect_with_channel( + &config, + client_id, + tx_msg_clone, + &tx_cmd, + rx_cmd, + last_ping, + ) + .await; + } + }); + } + + fn start_pinging(&mut self) { + if let Some(task) = self.ping_task.take() { + task.abort(); + } + + let tx_cmd = self.tx_cmd.clone(); + let last_ping = Arc::clone(&self.last_ping); + let mut interval = tokio::time::interval(PING_PERIOD); + let task = tokio::spawn(async move { + loop { + interval.tick().await; + let mut last_ping_guard = last_ping.lock().await; + *last_ping_guard = Some(Instant::now()); + mem::drop(last_ping_guard); + let _ = tx_cmd.send(Command::Ping); + } + }); + self.ping_task = Some(task); + } +} + +impl Drop for WebSocketClient { + fn drop(&mut self) { + if let Ok(mut state) = self.state.try_lock() { + state.is_disposed = true; + } + + if let Ok(mut last_ping) = self.last_ping.try_lock() { + last_ping.take(); + } + + if let Some(task) = self.ping_task.take() { + task.abort(); + } + } +} + +async fn reconnect_with_channel( + config: &TobogganWebsocketConfig, + client: ClientId, + tx_msg: mpsc::UnboundedSender, + tx_cmd: &mpsc::UnboundedSender, + rx_cmd: Arc>>, + last_ping: Arc>>, +) { + info!("Attempting to reconnect..."); + + let (ws, _) = match connect_async(&config.websocket_url).await { + Ok(ws) => ws, + Err(error) => { + error!(?error, "Reconnection failed"); + return; + } + }; + + let (write, read) = ws.split(); + + debug!("๐Ÿ—ฟconnection status: {}", ConnectionStatus::Connected); + let _ = tx_msg.send(CommunicationMessage::ConnectionStatusChange { + status: ConnectionStatus::Connected, + }); + let _ = tx_cmd.send(Command::Register { client }); + + tokio::spawn(handle_outgoing_commands(rx_cmd, write)); + tokio::spawn(async move { + let mut read = read; + while let Some(msg) = read.next().await { + if let Ok(msg) = msg { + handle_ws_message(msg, &tx_msg, last_ping.clone()).await; + } + } + }); +} + +async fn handle_outgoing_commands( + rx_cmd: Arc>>, + mut write: SplitSink, +) { + loop { + let cmd = { + let mut rx_cmd = rx_cmd.lock().await; + rx_cmd.recv().await + }; + let Some(cmd) = cmd else { + break; + }; + let json = match serde_json::to_string(&cmd) { + Ok(json) => json, + Err(error) => { + error!(?error, ?cmd, "Failed to serialize command"); + continue; + } + }; + let item = Message::text(json); + + if let Err(error) = write.send(item).await { + error!(?error, "Failed to send WS command"); + break; + } + } +} + +async fn handle_incoming_messages( + mut read: SplitStream, + tx_msg: mpsc::UnboundedSender, + state: Arc>, + config: TobogganWebsocketConfig, + last_ping: Arc>>, +) { + while let Some(msg) = read.next().await { + match msg { + Ok(msg) => { + handle_ws_message(msg, &tx_msg, last_ping.clone()).await; + } + Err(error) => { + error!(?error, "Failed to read WS incoming message"); + let message = error.to_string(); + let status = ConnectionStatus::Error { message }; + debug!(%status, "๐Ÿ—ฟconnection status"); + let _ = tx_msg.send(CommunicationMessage::ConnectionStatusChange { status }); + break; + } + } + } + + warn!("โš ๏ธ WebSocket connection closed, will attempt reconnection in 5 seconds"); + debug!("๐Ÿ—ฟconnection status: {}", ConnectionStatus::Closed); + let _ = tx_msg.send(CommunicationMessage::ConnectionStatusChange { + status: ConnectionStatus::Closed, + }); + + let (retry_count, retry_delay, should_reconnect) = { + let mut state_ref = state.lock().await; + if state_ref.is_disposed || state_ref.retry_count >= config.max_retries { + return; + } + + state_ref.retry_count += 1; + (state_ref.retry_count, RECONNECT_DELAY, true) + }; + + if should_reconnect { + let status = ConnectionStatus::Reconnecting { + attempt: retry_count, + max_attempt: config.max_retries, + delay: retry_delay, + }; + debug!(%status, "๐Ÿ—ฟconnection status"); + let _ = tx_msg.send(CommunicationMessage::ConnectionStatusChange { status }); + } +} + +async fn handle_ws_message( + message: Message, + tx: &mpsc::UnboundedSender, + last_ping: Arc>>, +) { + let Message::Text(message_text) = message else { + error!(?message, "unexpected message kind"); + return; + }; + + let notification = match serde_json::from_str::(&message_text) { + Ok(notification) => notification, + Err(error) => { + error!(?error, ?message_text, "Failed to deserialize notification"); + return; + } + }; + + match notification { + Notification::State { state } => { + let _ = tx.send(CommunicationMessage::StateChange { state }); + } + Notification::TalkChange { state } => { + info!("๐Ÿ“ Talk changed, clients should refetch Talk and Slides"); + let _ = tx.send(CommunicationMessage::TalkChange { state }); + } + Notification::Error { message } => { + let _ = tx.send(CommunicationMessage::Error { error: message }); + } + Notification::Pong => { + let mut lock = last_ping.lock().await; + if let Some(instant) = lock.take() { + let elapsed = instant.elapsed(); + debug!(?elapsed, "โฑ๏ธ Ping"); + } + } + Notification::Blink => { + info!("๐Ÿ”” Blink"); + } + } +} diff --git a/toboggan-client/src/config.rs b/toboggan-client/src/config.rs new file mode 100644 index 0000000..92bd44f --- /dev/null +++ b/toboggan-client/src/config.rs @@ -0,0 +1,76 @@ +use std::time::Duration; + +use toboggan_core::{BaseClientConfig, ClientConfig, ClientId, RetryConfig}; + +#[derive(Debug, Clone, Default)] +pub struct TobogganConfig { + base: BaseClientConfig, +} + +impl TobogganConfig { + #[must_use] + pub fn new(host: &str, port: u16) -> Self { + Self { + base: BaseClientConfig::new(host, port), + } + } + + #[must_use] + pub fn with_retry(mut self, retry: RetryConfig) -> Self { + self.base = self.base.with_retry(retry); + self + } + + /// Get the WebSocket configuration for compatibility + #[must_use] + pub fn websocket(&self) -> TobogganWebsocketConfig { + TobogganWebsocketConfig::from(&self.base) + } +} + +impl ClientConfig for TobogganConfig { + fn client_id(&self) -> ClientId { + self.base.client_id() + } + + fn api_url(&self) -> &str { + self.base.api_url() + } + + fn websocket_url(&self) -> &str { + self.base.websocket_url() + } +} + +impl From for TobogganConfig { + fn from(base: BaseClientConfig) -> Self { + Self { base } + } +} + +/// WebSocket configuration (kept for backward compatibility) +#[derive(Debug, Clone)] +pub struct TobogganWebsocketConfig { + pub websocket_url: String, + pub max_retries: usize, + pub retry_delay: Duration, + pub max_retry_delay: Duration, +} + +impl From<&BaseClientConfig> for TobogganWebsocketConfig { + fn from(config: &BaseClientConfig) -> Self { + Self { + websocket_url: config.websocket_url.clone(), + max_retries: config.retry.max_retries, + retry_delay: config.retry.initial_retry_delay().into(), + max_retry_delay: config.retry.max_retry_delay().into(), + } + } +} + +impl Default for TobogganWebsocketConfig { + fn default() -> Self { + let base = BaseClientConfig::default(); + Self::from(&base) + } +} diff --git a/toboggan-client/src/lib.rs b/toboggan-client/src/lib.rs new file mode 100644 index 0000000..f8ffd7c --- /dev/null +++ b/toboggan-client/src/lib.rs @@ -0,0 +1,8 @@ +mod api; +pub use self::api::*; + +mod communication; +pub use self::communication::*; + +mod config; +pub use self::config::*; diff --git a/toboggan-core/Cargo.toml b/toboggan-core/Cargo.toml index 2551531..e61e065 100644 --- a/toboggan-core/Cargo.toml +++ b/toboggan-core/Cargo.toml @@ -1,6 +1,34 @@ [package] name = "toboggan-core" version = "0.1.0" +description = "Core types and domain logic for the Toboggan presentation system (no_std compatible)" edition = "2024" +rust-version.workspace = true +license.workspace = true +authors.workspace = true + +[features] +default = ["std", "tracing"] +tracing = ["dep:tracing"] +std = ["jiff/std", "serde/std"] +js = ["jiff/js", "uuid/js", "getrandom/wasm_js"] +openapi = ["dep:utoipa", "utoipa/uuid"] +test-utils = [] + [dependencies] +jiff = { workspace = true, features = ["serde"] } +serde = { workspace = true, features = ["derive"] } +uuid = { workspace = true, features = ["serde", "v4"] } +derive_more = { workspace = true, features = ["add", "from", "deref"] } +humantime = { workspace = true } +getrandom = { workspace = true } + +tracing = { workspace = true, optional = true } +utoipa = { workspace = true, optional = true } + +[dev-dependencies] +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/toboggan-core/README.md b/toboggan-core/README.md new file mode 100644 index 0000000..1fa824a --- /dev/null +++ b/toboggan-core/README.md @@ -0,0 +1,333 @@ +# toboggan-core + +[![Crates.io](https://img.shields.io/crates/v/toboggan-core.svg)](https://crates.io/crates/toboggan-core) [![Docs.rs](https://docs.rs/toboggan-core/badge.svg)](https://docs.rs/toboggan-core) [![License](https://img.shields.io/badge/license-MIT%2FApache--2.0-blue.svg)](https://github.com/yourusername/toboggan) + +A `no_std` compatible presentation system for creating and managing slide-based talks. + +## Overview + +Toboggan Core provides the foundational types and abstractions for the Toboggan presentation ecosystem. It's designed to work across different environments: from embedded systems to WebAssembly browsers to traditional desktop applications. + +## Features + +- **`no_std` compatible** - Works in constrained environments +- **Multi-platform** - Supports embedded, WASM, and standard library environments +- **Rich content types** - Text, HTML, iframes, terminals, and layout containers +- **Type-safe** - Extensive use of Rust's type system for correctness +- **Serializable** - All types can be serialized with serde +- **Well-documented** - Comprehensive API documentation with examples + +## Quick Start + +Add to your `Cargo.toml`: + +```toml +[dependencies] +toboggan-core = "0.1.0" +``` + +Create a simple presentation: + +```rust +use toboggan_core::{Talk, Slide, Content, Date, SlideKind}; + +// Create a new presentation +let mut talk = Talk::builder() + .title("My Rust Conference Talk") + .date(Date::new(2025, 1, 26)) + .build(); + +// Add a cover slide +let cover_slide = Slide::builder() + .kind(SlideKind::Cover) + .title("Welcome to Rust") + .body(Content::from("An introduction to systems programming")) + .notes("Start with energy, make eye contact") + .build(); + +// Add content slides +let intro_slide = Slide::builder() + .title("Why Rust?") + .body(Content::html_with_alt( + r#"
    +
  • Memory safety without garbage collection
  • +
  • Zero-cost abstractions
  • +
  • Fearless concurrency
  • +
"#, + "Key benefits of Rust: memory safety, zero-cost abstractions, fearless concurrency" + )) + .notes("Emphasize the unique value proposition") + .build(); + +// Add slides to presentation +talk.add_slide(cover_slide); +talk.add_slide(intro_slide); + +// Presentation is ready to serialize or serve +``` + +## Feature Flags + +### Standard Features + +- **`std`** (default): Full standard library support + - File system access for loading content from files + - Terminal slides for live demonstrations + - Full UUID generation for client IDs + +### No-std Features + +- **`alloc`**: Heap allocation support for `no_std` environments + - Required for dynamic collections and most functionality + - Automatically enabled by `std` feature + +### Platform-specific Features + +- **`js`**: WebAssembly support with JavaScript bindings + - System time access via JavaScript APIs + - Secure random number generation in browsers + +### Optional Features + +- **`openapi`**: Enables OpenAPI schema generation +- **`test-utils`**: Testing utilities for development + +## Multi-platform Usage + +### Standard Library (Desktop, Server) + +```toml +[dependencies] +toboggan-core = { version = "0.1.0", features = ["std"] } +``` + +### WebAssembly (Browser) + +```toml +[dependencies] +toboggan-core = { version = "0.1.0", default-features = false, features = ["alloc", "js"] } +``` + +### Embedded Systems (no_std with alloc) + +```toml +[dependencies] +toboggan-core = { version = "0.1.0", default-features = false, features = ["alloc"] } +``` + +## Content Types + +Toboggan supports rich content through the [`Content`] enum, designed for accessibility and multi-platform rendering: + +```rust +use toboggan_core::Content; + +// Simple text content +let text = Content::from("Plain text content"); + +// HTML with accessibility fallback +let html = Content::html_with_alt( + r#"
+ Sales chart +

Q4 growth: +25%

+
"#, + "Sales chart showing 25% growth in Q4" +); + +// Markdown content (converted to HTML) +let markdown = Content::markdown_with_alt( + r#"## Key Points + +- **Performance**: Zero-cost abstractions +- **Safety**: Memory safety without GC +- **Concurrency**: Fearless parallelism"#, + "Three key points about Rust performance, safety, and concurrency" +); + +// Layout containers for complex layouts +let two_column = Content::hbox("1fr 1fr", vec![ + Content::from("Left column content"), + Content::html("Right column") +]); + +let three_row = Content::vbox("auto 1fr auto", vec![ + Content::from("Header"), + Content::html("
Main content area
"), + Content::from("Footer") +]); + +// Nested layouts for complex designs +let complex_layout = Content::hbox("2fr 1fr", vec![ + Content::vbox("auto 1fr", vec![ + Content::from("Main content title"), + Content::markdown("## Details\n\nDetailed information here") + ]), + Content::vbox("1fr", vec![ + Content::html("") + ]) +]); +``` + +### File-based Content (std only) + +Load content directly from files with automatic type detection: + +```rust +use std::path::Path; +use toboggan_core::Content; + +// Automatically converts markdown to HTML +let content = Content::from(Path::new("slides/intro.md")); + +// Uses HTML directly +let html = Content::from(Path::new("slides/chart.html")); + +// Treats as plain text +let text = Content::from(Path::new("notes.txt")); +``` + +## Presentation State + +Manage presentation state with built-in support for timing and navigation: + +```rust +use toboggan_core::{State, SlideId}; +use std::time::Duration; + +let slide1 = SlideId::next(); +let mut state = State::Paused { + current: slide1, + total_duration: Duration::ZERO, +}; + +// Resume presentation +state.auto_resume(); +// Now state is State::Running with timing information +``` + +## API Reference + +### Core Types + +The toboggan-core crate provides several key types for building presentations: + +#### `Talk` - Presentation Container + +```rust +use toboggan_core::{Talk, Date}; + +// Create a new presentation +let talk = Talk::builder() + .title("My Conference Talk") + .date(Date::new(2024, 12, 31)) + .build(); + +// Or create with constructor +let talk = Talk::new("Simple Talk"); +``` + +#### `Slide` - Individual Presentation Slide + +```rust +use toboggan_core::{Slide, SlideKind, Content}; + +// Different slide types +let cover = Slide::builder() + .kind(SlideKind::Cover) + .title("Welcome") + .build(); + +let content_slide = Slide::builder() + .kind(SlideKind::Default) + .title("Main Topic") + .body(Content::from("Slide content here")) + .notes("Speaker notes") + .build(); + +let section = Slide::builder() + .kind(SlideKind::Part) + .title("Section 2") + .build(); +``` + +#### `Content` - Rich Content System + +```rust +use toboggan_core::Content; + +// Text content +Content::from("Simple text"); + +// HTML with accessibility +Content::html_with_alt( + "

Rich HTML

", + "Accessible description" +); + +// Layout containers +Content::hbox("1fr 2fr", vec![/* content */]); +Content::vbox("auto 1fr auto", vec![/* content */]); +``` + +#### `State` - Presentation Runtime State + +```rust +use toboggan_core::{State, SlideId}; + +// State management for presentation control +match state { + State::Init => { /* Initial state */ }, + State::Paused { current, .. } => { /* Presentation paused */ }, + State::Running { current, started, .. } => { /* Active presentation */ }, + State::Done { current, .. } => { /* Presentation finished */ }, +} +``` + +### Serialization Support + +All core types support serde serialization for network transport and storage: + +```rust +use serde_json; + +// Serialize presentation to JSON +let json = serde_json::to_string(&talk)?; + +// Deserialize from JSON +let talk: Talk = serde_json::from_str(&json)?; + +// Also supports TOML, MessagePack, etc. +``` + +## Architecture + +### Memory Safety + +- **No `unsafe` code** - Enforced by workspace lints +- **Comprehensive error handling** - Uses `Result` and `Option` appropriately +- **Descriptive error messages** - Avoids `unwrap()` in favor of `expect()` + +### Performance + +- **Zero-cost abstractions** - Efficient code generation +- **Atomic operations** - Thread-safe ID generation +- **Efficient serialization** - Optimized for network transmission + +### Compatibility + +- **Rust 2024 edition** - Uses latest language features +- **WASM-compatible** - Works in `wasm32-unknown-unknown` target +- **Thread-safe** - Safe to use in multi-threaded environments + +## Examples + +See the [`examples/`](../examples/) directory for complete examples of using toboggan-core in different environments. + +## License + +This project is licensed under either of + +- Apache License, Version 2.0, ([LICENSE-APACHE](../LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0) +- MIT license ([LICENSE-MIT](../LICENSE-MIT) or http://opensource.org/licenses/MIT) + +at your option. diff --git a/toboggan-core/rustfmt.toml b/toboggan-core/rustfmt.toml new file mode 100644 index 0000000..2a732ff --- /dev/null +++ b/toboggan-core/rustfmt.toml @@ -0,0 +1,5 @@ +# Rustfmt configuration +edition = "2024" +unstable_features = true +imports_granularity = "Module" +group_imports = "StdExternalCrate" \ No newline at end of file diff --git a/toboggan-core/src/command.rs b/toboggan-core/src/command.rs new file mode 100644 index 0000000..cf411b8 --- /dev/null +++ b/toboggan-core/src/command.rs @@ -0,0 +1,44 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct ClientId(Uuid); + +impl ClientId { + #[cfg(any(feature = "std", feature = "js"))] + #[allow(clippy::new_without_default)] + #[must_use] + pub fn new() -> Self { + #[cfg(feature = "std")] + { + Self(Uuid::new_v4()) + } + #[cfg(all(not(feature = "std"), feature = "js"))] + { + let mut bytes = [0u8; 16]; + getrandom::getrandom(&mut bytes).expect("Failed to generate random bytes for UUID"); + Self(Uuid::from_bytes(bytes)) + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(tag = "command")] +pub enum Command { + Register { client: ClientId }, + Unregister { client: ClientId }, + Ping, + // Navigation + First, + Last, + GoTo { slide: usize }, + Next, + Previous, + // Status + Pause, + Resume, + // Effect + Blink, +} diff --git a/toboggan-core/src/config.rs b/toboggan-core/src/config.rs new file mode 100644 index 0000000..72c83af --- /dev/null +++ b/toboggan-core/src/config.rs @@ -0,0 +1,213 @@ +use serde::{Deserialize, Serialize}; + +use crate::{ClientId, Duration}; + +pub trait ClientConfig { + fn client_id(&self) -> ClientId; + fn api_url(&self) -> &str; + fn websocket_url(&self) -> &str; +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RetryConfig { + pub max_retries: usize, + pub initial_retry_delay: Duration, + pub max_retry_delay: Duration, + pub backoff_factor: f32, + pub use_jitter: bool, +} + +impl Default for RetryConfig { + fn default() -> Self { + Self { + max_retries: 10, + initial_retry_delay: Duration::from_secs(1), + max_retry_delay: Duration::from_secs(30), + backoff_factor: 2.0, + use_jitter: true, + } + } +} + +impl RetryConfig { + #[must_use] + pub const fn new( + max_retries: usize, + initial_retry_delay: Duration, + max_retry_delay: Duration, + backoff_factor: f32, + use_jitter: bool, + ) -> Self { + Self { + max_retries, + initial_retry_delay, + max_retry_delay, + backoff_factor, + use_jitter, + } + } + + #[must_use] + #[allow(clippy::cast_precision_loss)] + #[allow(clippy::cast_possible_truncation)] + #[allow(clippy::cast_sign_loss)] + pub fn calculate_delay(&self, attempt: usize) -> u64 { + let initial_ms = self.initial_retry_delay.as_millis() as u64; + let max_ms = self.max_retry_delay.as_millis() as u64; + + if attempt == 0 { + return initial_ms; + } + + let mut delay = initial_ms as f32; + for _ in 0..attempt { + delay *= self.backoff_factor; + } + + let mut delay = delay.min(max_ms as f32) as u64; + + if self.use_jitter { + // Add up to 20% jitter + let mut random_byte = [0u8; 1]; + let _ = getrandom::fill(&mut random_byte); + let jitter = f32::from(random_byte[0] % 20) / 100.0; + delay = (delay as f32 * (1.0 + jitter)) as u64; + } + + delay + } + + #[must_use] + pub const fn initial_retry_delay(&self) -> Duration { + self.initial_retry_delay + } + + #[must_use] + pub const fn max_retry_delay(&self) -> Duration { + self.max_retry_delay + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BaseClientConfig { + pub client_id: ClientId, + pub api_url: String, + pub websocket_url: String, + pub retry: RetryConfig, +} + +impl BaseClientConfig { + #[must_use] + pub fn new(host: &str, port: u16) -> Self { + let api_url = format!("http://{host}:{port}"); + let websocket_url = format!("ws://{host}:{port}/api/ws"); + Self { + client_id: ClientId::new(), + api_url, + websocket_url, + retry: RetryConfig::default(), + } + } + + #[must_use] + pub fn localhost() -> Self { + Self::new("localhost", 8080) + } + + #[must_use] + pub fn with_retry(mut self, retry: RetryConfig) -> Self { + self.retry = retry; + self + } +} + +impl ClientConfig for BaseClientConfig { + fn client_id(&self) -> ClientId { + self.client_id + } + + fn api_url(&self) -> &str { + &self.api_url + } + + fn websocket_url(&self) -> &str { + &self.websocket_url + } +} + +impl Default for BaseClientConfig { + fn default() -> Self { + Self::localhost() + } +} + +/// Connection status constants for consistency across clients +pub mod connection_timeouts { + use std::time::Duration; + + pub const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30); + pub const CONNECTION_TIMEOUT: Duration = Duration::from_secs(10); + pub const PING_INTERVAL: Duration = Duration::from_secs(25); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_retry_config_delay_calculation() { + let config = RetryConfig::default(); + + assert_eq!(config.calculate_delay(0), 1_000); + + // With exponential backoff factor of 2.0 + let delay1 = config.calculate_delay(1); + assert!((2_000..=2_400).contains(&delay1)); // With jitter + + // Should not exceed max delay + let delay_max = u128::from(config.calculate_delay(100)); + let max_delay_ms = config.max_retry_delay().as_millis(); + assert!(delay_max <= max_delay_ms + (max_delay_ms / 5)); + } + + #[test] + #[allow(clippy::unwrap_used)] + fn test_humantime_serialization() { + let config = RetryConfig { + max_retries: 5, + initial_retry_delay: Duration::from_secs(2), + max_retry_delay: Duration::from_secs(60), + backoff_factor: 1.5, + use_jitter: false, + }; + + let serialized = serde_json::to_string(&config).unwrap(); + let deserialized: RetryConfig = serde_json::from_str(&serialized).unwrap(); + + assert_eq!(config.max_retries, deserialized.max_retries); + assert_eq!(config.initial_retry_delay, deserialized.initial_retry_delay); + assert_eq!(config.max_retry_delay, deserialized.max_retry_delay); + assert!((config.backoff_factor - deserialized.backoff_factor).abs() < f32::EPSILON); + assert_eq!(config.use_jitter, deserialized.use_jitter); + } + + #[test] + #[allow(clippy::unwrap_used)] + fn test_humantime_parsing() { + let json = r#"{ + "max_retries": 3, + "initial_retry_delay": "1s", + "max_retry_delay": "30s", + "backoff_factor": 2.0, + "use_jitter": true + }"#; + + let config: RetryConfig = serde_json::from_str(json).unwrap(); + + assert_eq!(config.max_retries, 3); + assert_eq!(config.initial_retry_delay, Duration::from_secs(1)); + assert_eq!(config.max_retry_delay, Duration::from_secs(30)); + assert!((config.backoff_factor - 2.0).abs() < f32::EPSILON); + assert!(config.use_jitter); + } +} diff --git a/toboggan-core/src/content.rs b/toboggan-core/src/content.rs new file mode 100644 index 0000000..2897cb0 --- /dev/null +++ b/toboggan-core/src/content.rs @@ -0,0 +1,99 @@ +use std::fmt::Display; + +use serde::{Deserialize, Serialize}; + +use crate::Style; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(tag = "type")] +pub enum Content { + #[default] + Empty, + Text { + text: String, + }, + Html { + raw: String, + #[serde(default, skip_serializing_if = "Style::is_default")] + style: Style, + #[serde(skip_serializing_if = "Option::is_none")] + alt: Option, + }, + Grid { + cells: Vec, + style: Style, + }, +} + +impl Content { + pub(crate) fn is_empty(&self) -> bool { + matches!(self, Self::Empty) + } + + pub fn text(text: impl Into) -> Self { + let text = text.into(); + Self::Text { text } + } + + pub fn html(raw: impl Into) -> Self { + let style = Style::default(); + let raw = raw.into(); + let alt = None; + Self::Html { raw, alt, style } + } + + pub fn html_with_alt(raw: impl Into, alt: impl Into) -> Self { + let style = Style::default(); + let raw = raw.into(); + let alt = Some(alt.into()); + Self::Html { raw, alt, style } + } + + pub fn grid(cells: impl IntoIterator) -> Self { + let style = Style::default(); + let cells = Vec::from_iter(cells); + Self::Grid { style, cells } + } +} + +impl From<&str> for Content { + fn from(text: &str) -> Self { + Self::text(text) + } +} + +impl From for Content { + fn from(text: String) -> Self { + Self::text(text) + } +} + +impl Display for Content { + fn fmt(&self, fmt: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Empty => write!(fmt, ""), + Self::Text { text } => write!(fmt, "{text}"), + Self::Html { raw, alt, .. } => { + if let Some(alt) = alt { + write!(fmt, "{alt}") + } else { + write!(fmt, "{raw}") + } + } + Self::Grid { cells, .. } => { + let mut first = true; + for cell in cells { + if first { + first = false; + } else { + write!(fmt, " - ")?; + } + + write!(fmt, "{cell}")?; + } + Ok(()) + } + } + } +} diff --git a/toboggan-core/src/lib.rs b/toboggan-core/src/lib.rs index b93cf3f..dca0ee7 100644 --- a/toboggan-core/src/lib.rs +++ b/toboggan-core/src/lib.rs @@ -1,14 +1,25 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); - } -} +mod time; +pub use self::time::*; + +mod config; +pub use self::config::*; + +pub mod timeouts; + +mod content; +pub use self::content::*; + +mod state; +pub use self::state::*; + +mod slide; +pub use self::slide::*; + +mod talk; +pub use self::talk::*; + +mod command; +pub use self::command::*; + +mod notification; +pub use self::notification::*; diff --git a/toboggan-core/src/notification.rs b/toboggan-core/src/notification.rs new file mode 100644 index 0000000..3d9f477 --- /dev/null +++ b/toboggan-core/src/notification.rs @@ -0,0 +1,42 @@ +use std::fmt::Debug; + +use serde::{Deserialize, Serialize}; + +use crate::State; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(tag = "type")] +pub enum Notification { + State { state: State }, + Error { message: String }, + Pong, + Blink, + TalkChange { state: State }, +} + +impl Notification { + pub const BLINK: Self = Self::Blink; + pub const PONG: Self = Self::Pong; + + #[must_use] + pub fn state(state: State) -> Self { + Self::State { state } + } + + #[must_use] + pub fn talk_change(state: State) -> Self { + Self::TalkChange { state } + } + + pub fn error(err: impl Debug) -> Self { + let message = format!("{err:?}"); + Self::Error { message } + } +} + +impl From for Notification { + fn from(state: State) -> Self { + Self::State { state } + } +} diff --git a/toboggan-core/src/slide.rs b/toboggan-core/src/slide.rs new file mode 100644 index 0000000..21fb01c --- /dev/null +++ b/toboggan-core/src/slide.rs @@ -0,0 +1,112 @@ +use std::fmt::Display; + +use serde::{Deserialize, Serialize}; + +use crate::Content; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(default)] +pub struct Slide { + pub kind: SlideKind, + #[serde(skip_serializing_if = "Style::is_default")] + pub style: Style, + #[serde(skip_serializing_if = "Content::is_empty")] + pub title: Content, + pub body: Content, + #[serde(skip_serializing_if = "Content::is_empty")] + pub notes: Content, +} + +impl Slide { + pub fn new(title: impl Into) -> Self { + let title = title.into(); + Self { + title, + ..Default::default() + } + } + + pub fn cover(title: impl Into) -> Self { + let title = title.into(); + Self { + kind: SlideKind::Cover, + title, + ..Default::default() + } + } + + pub fn part(title: impl Into) -> Self { + let title = title.into(); + Self { + kind: SlideKind::Part, + title, + ..Default::default() + } + } + + #[must_use] + pub fn with_style_classes(mut self, classes: impl IntoIterator) -> Self { + self.style.classes = Vec::from_iter(classes); + self + } + + #[must_use] + pub fn with_body(mut self, body: impl Into) -> Self { + self.body = body.into(); + self + } + + #[must_use] + pub fn with_notes(mut self, notes: impl Into) -> Self { + self.notes = notes.into(); + self + } +} + +impl Display for Slide { + fn fmt(&self, fmt: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + if let Content::Empty = self.title { + write!(fmt, "{}", self.body) + } else { + write!(fmt, "{}", self.title) + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub enum SlideKind { + Cover, + Part, + #[default] + Standard, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct Style { + #[serde(skip_serializing_if = "Vec::is_empty")] + pub classes: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub style: Option, +} + +impl Style { + pub(crate) fn is_default(&self) -> bool { + self.classes.is_empty() && self.style.is_none() + } +} + +impl Display for Style { + fn fmt(&self, fmt: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let classes = self.classes.join(" "); + write!(fmt, "{classes}") + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct SlidesResponse { + pub slides: Vec, +} diff --git a/toboggan-core/src/state.rs b/toboggan-core/src/state.rs new file mode 100644 index 0000000..40496e8 --- /dev/null +++ b/toboggan-core/src/state.rs @@ -0,0 +1,309 @@ +use serde::{Deserialize, Serialize}; + +use crate::{Duration, Timestamp}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(tag = "state")] +pub enum State { + #[default] + Init, + Paused { + current: Option, + total_duration: Duration, + }, + Running { + since: Timestamp, + current: usize, + total_duration: Duration, + }, + Done { + current: usize, + total_duration: Duration, + }, +} + +impl State { + #[must_use] + pub fn current(&self) -> Option { + match self { + Self::Init => None, + Self::Paused { current, .. } => *current, + Self::Running { current, .. } | Self::Done { current, .. } => Some(*current), + } + } + + #[must_use] + pub fn is_first_slide(&self, total_slides: usize) -> bool { + total_slides > 0 && self.current() == Some(0) + } + + #[must_use] + pub fn is_last_slide(&self, total_slides: usize) -> bool { + if total_slides == 0 { + return false; + } + self.current() == Some(total_slides - 1) + } + + #[must_use] + pub fn next(&self, total_slides: usize) -> Option { + let current = self.current()?; + (current + 1 < total_slides).then(|| current + 1) + } + + #[must_use] + pub fn previous(&self, _total_slides: usize) -> Option { + let current = self.current()?; + (current > 0).then(|| current - 1) + } + + pub fn auto_resume(&mut self) { + if let Self::Paused { + current: Some(slide_index), + total_duration, + } = self + { + *self = Self::Running { + since: Timestamp::now(), + current: *slide_index, + total_duration: *total_duration, + }; + } + } + + pub fn update_slide(&mut self, slide_index: usize) { + let total_duration = self.calculate_total_duration(); + match self { + Self::Init => { + // When navigating from Init state, go to Running + *self = Self::Running { + since: Timestamp::now(), + current: slide_index, + total_duration: Duration::default(), + }; + } + Self::Running { since, .. } => { + *self = Self::Running { + since: *since, + current: slide_index, + total_duration, + }; + } + Self::Paused { .. } => { + *self = Self::Paused { + current: Some(slide_index), + total_duration, + }; + } + Self::Done { .. } => { + // When navigating from Done state, go back to Paused + *self = Self::Paused { + current: Some(slide_index), + total_duration, + }; + } + } + } + + #[must_use] + pub fn calculate_total_duration(&self) -> Duration { + match self { + Self::Init => Duration::default(), + Self::Paused { total_duration, .. } | Self::Done { total_duration, .. } => { + *total_duration + } + Self::Running { + since, + total_duration, + .. + } => *total_duration + since.elapsed(), + } + } +} + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn test_current() { + let state = State::default(); + assert_eq!(state.current(), None); + + let state = State::Paused { + current: Some(0), + total_duration: Duration::from_secs(0), + }; + assert_eq!(state.current(), Some(0)); + + let state = State::Running { + since: Timestamp::now(), + current: 0, + total_duration: Duration::from_secs(0), + }; + assert_eq!(state.current(), Some(0)); + + let state = State::Done { + current: 0, + total_duration: Duration::from_secs(0), + }; + assert_eq!(state.current(), Some(0)); + } + + #[test] + fn test_is_first_slide() { + let total_slides = 3; + + // Test with first slide + let state = State::Paused { + current: Some(0), + total_duration: Duration::from_secs(0), + }; + assert!(state.is_first_slide(total_slides)); + assert!(!state.is_last_slide(total_slides)); + + // Test with middle slide + let state = State::Running { + since: Timestamp::now(), + current: 1, + total_duration: Duration::from_secs(0), + }; + assert!(!state.is_first_slide(total_slides)); + assert!(!state.is_last_slide(total_slides)); + + // Test with last slide + let state = State::Done { + current: 2, + total_duration: Duration::from_secs(0), + }; + assert!(!state.is_first_slide(total_slides)); + assert!(state.is_last_slide(total_slides)); + } + + #[test] + fn test_with_empty_slide_order() { + let total_slides = 0; + + let state = State::Paused { + current: Some(0), + total_duration: Duration::from_secs(0), + }; + assert!(!state.is_first_slide(total_slides)); + assert!(!state.is_last_slide(total_slides)); + } + + #[test] + fn test_with_single_slide() { + let total_slides = 1; + + let state = State::Paused { + current: Some(0), + total_duration: Duration::from_secs(0), + }; + assert!(state.is_first_slide(total_slides)); + assert!(state.is_last_slide(total_slides)); + } + + #[test] + fn test_next() { + let total_slides = 3; + + // Test next from first slide + let state = State::Paused { + current: Some(0), + total_duration: Duration::from_secs(0), + }; + assert_eq!(state.next(total_slides), Some(1)); + + // Test next from middle slide + let state = State::Running { + since: Timestamp::now(), + current: 1, + total_duration: Duration::from_secs(0), + }; + assert_eq!(state.next(total_slides), Some(2)); + + // Test next from last slide + let state = State::Done { + current: 2, + total_duration: Duration::from_secs(0), + }; + assert_eq!(state.next(total_slides), None); + } + + #[test] + fn test_previous() { + let total_slides = 3; + + // Test previous from first slide + let state = State::Paused { + current: Some(0), + total_duration: Duration::from_secs(0), + }; + assert_eq!(state.previous(total_slides), None); + + // Test previous from middle slide + let state = State::Running { + since: Timestamp::now(), + current: 1, + total_duration: Duration::from_secs(0), + }; + assert_eq!(state.previous(total_slides), Some(0)); + + // Test previous from last slide + let state = State::Done { + current: 2, + total_duration: Duration::from_secs(0), + }; + assert_eq!(state.previous(total_slides), Some(1)); + } + + #[test] + fn test_next_previous_with_empty_order() { + let total_slides = 0; + + let state = State::Paused { + current: Some(0), + total_duration: Duration::from_secs(0), + }; + assert_eq!(state.next(total_slides), None); + assert_eq!(state.previous(total_slides), None); + } + + #[test] + fn test_next_previous_with_single_slide() { + let total_slides = 1; + + let state = State::Paused { + current: Some(0), + total_duration: Duration::from_secs(0), + }; + assert_eq!(state.next(total_slides), None); + assert_eq!(state.previous(total_slides), None); + } + + #[test] + fn test_state_serialization_format() { + let paused_state = State::Paused { + current: Some(0), + total_duration: Duration::from_secs(10), + }; + + let running_state = State::Running { + since: Timestamp::now(), + current: 0, + total_duration: Duration::from_secs(5), + }; + + // Test that the states are constructed correctly with internally tagged serde format + assert_eq!(paused_state.current(), Some(0)); + + assert_eq!(running_state.current(), Some(0)); + + // Verify the states have the expected variants + assert!(matches!(paused_state, State::Paused { .. })); + assert!(matches!(running_state, State::Running { .. })); + } +} diff --git a/toboggan-core/src/talk.rs b/toboggan-core/src/talk.rs new file mode 100644 index 0000000..c508f6f --- /dev/null +++ b/toboggan-core/src/talk.rs @@ -0,0 +1,101 @@ +use serde::{Deserialize, Serialize}; + +use crate::{Date, Slide}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Talk { + pub title: String, + pub date: Date, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub footer: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub head: Option, + pub slides: Vec, +} + +impl Talk { + pub fn new(title: impl Into) -> Self { + let title = title.into(); + let date = Date::today(); + let slides = Vec::new(); + let footer = None; + let head = None; + + Self { + title, + date, + footer, + head, + slides, + } + } + + #[must_use] + pub fn with_date(mut self, date: Date) -> Self { + self.date = date; + self + } + + #[must_use] + pub fn with_footer(mut self, footer: impl Into) -> Self { + self.footer = Some(footer.into()); + self + } + + #[must_use] + pub fn with_head(mut self, head: impl Into) -> Self { + self.head = Some(head.into()); + self + } + + #[must_use] + pub fn add_slide(mut self, slide: Slide) -> Self { + self.slides.push(slide); + self + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct TalkResponse { + pub title: String, + pub date: Date, + #[serde(skip_serializing_if = "Option::is_none")] + pub footer: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub head: Option, + pub titles: Vec, +} + +impl Default for TalkResponse { + fn default() -> Self { + Self { + title: String::default(), + date: Date::today(), + footer: None, + head: None, + titles: vec![], + } + } +} + +impl From for TalkResponse { + fn from(value: Talk) -> Self { + let Talk { + title, + date, + footer, + head, + slides, + } = value; + let titles = slides.iter().map(|it| it.title.to_string()).collect(); + + Self { + title, + date, + footer, + head, + titles, + } + } +} diff --git a/toboggan-core/src/time.rs b/toboggan-core/src/time.rs new file mode 100644 index 0000000..a59c95c --- /dev/null +++ b/toboggan-core/src/time.rs @@ -0,0 +1,210 @@ +use std::fmt::{self, Display, Formatter}; + +use serde::{Deserialize, Serialize}; + +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + Default, + derive_more::Add, + derive_more::Deref, + derive_more::From, +)] +pub struct Duration(std::time::Duration); + +impl Duration { + pub const ZERO: Self = Self(std::time::Duration::ZERO); + + #[must_use] + pub fn from_secs(secs: u64) -> Self { + Self(std::time::Duration::from_secs(secs)) + } + + #[must_use] + pub fn from_millis(millis: u64) -> Self { + Self(std::time::Duration::from_millis(millis)) + } +} + +impl From for std::time::Duration { + fn from(value: Duration) -> Self { + value.0 + } +} + +impl Display for Duration { + fn fmt(&self, fmt: &mut Formatter<'_>) -> fmt::Result { + let secs = self.0.as_secs(); + let mins = secs / 60; + let secs = secs - (60 * mins); + write!(fmt, "{mins:02}:{secs:02}") + } +} + +// Custom serialization/deserialization +pub mod duration_serde { + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + use super::Duration; + + impl Serialize for Duration { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + humantime::format_duration(self.0) + .to_string() + .serialize(serializer) + } + } + + impl<'de> Deserialize<'de> for Duration { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let duration = String::deserialize(deserializer)?; + humantime::parse_duration(&duration) + .map(Duration) + .map_err(serde::de::Error::custom) + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct Timestamp(pub jiff::Timestamp); + +impl Timestamp { + #[must_use] + pub fn now() -> Self { + Self(jiff::Timestamp::now()) + } + + #[must_use] + pub fn elapsed(&self) -> Duration { + let signed_duration = jiff::Timestamp::now().duration_since(self.0); + let duration = TryInto::::try_into(signed_duration) + .unwrap_or(core::time::Duration::ZERO); + Duration(duration) + } +} + +impl Display for Timestamp { + fn fmt(&self, fmt: &mut Formatter<'_>) -> fmt::Result { + Display::fmt(&self.0, fmt) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct Date(jiff::civil::Date); + +impl Date { + pub fn new(year: i16, month: i8, day: i8) -> Result { + jiff::civil::Date::new(year, month, day).map(Self) + } + + #[must_use] + pub fn today() -> Self { + let now = jiff::Zoned::now(); + Self(now.date()) + } + + #[cfg(feature = "tracing")] + #[must_use] + pub fn ymd(year: i16, month: i8, day: i8) -> Date { + Date::new(year, month, day).unwrap_or_else(|error| { + tracing::warn!(?error, year, month, day, "fail to build date"); + Self::today() + }) + } +} + +impl Display for Date { + fn fmt(&self, fmt: &mut Formatter<'_>) -> fmt::Result { + Display::fmt(&self.0, fmt) + } +} + +#[cfg(feature = "openapi")] +mod openapi { + use std::borrow::Cow; + + use utoipa::openapi::schema::Schema; + use utoipa::openapi::{KnownFormat, ObjectBuilder, RefOr, SchemaFormat, Type}; + use utoipa::{PartialSchema, ToSchema}; + + use super::{Date, Duration, Timestamp}; + + impl ToSchema for Duration { + fn name() -> Cow<'static, str> { + Cow::Borrowed("Duration") + } + } + + impl PartialSchema for Duration { + fn schema() -> RefOr { + RefOr::T(Schema::Object( + ObjectBuilder::new() + .schema_type(Type::Object) + .property( + "secs", + RefOr::T(Schema::Object( + ObjectBuilder::new() + .schema_type(Type::Number) + .format(Some(SchemaFormat::KnownFormat(KnownFormat::Int64))) + .build(), + )), + ) + .property( + "nanos", + RefOr::T(Schema::Object( + ObjectBuilder::new() + .schema_type(Type::Number) + .format(Some(SchemaFormat::KnownFormat(KnownFormat::Int64))) + .build(), + )), + ) + .build(), + )) + } + } + + impl ToSchema for Timestamp { + fn name() -> Cow<'static, str> { + Cow::Borrowed("Timestamp") + } + } + + impl PartialSchema for Timestamp { + fn schema() -> RefOr { + RefOr::T(Schema::Object( + ObjectBuilder::new() + .schema_type(Type::String) + .format(Some(SchemaFormat::KnownFormat(KnownFormat::DateTime))) + .build(), + )) + } + } + + impl ToSchema for Date { + fn name() -> Cow<'static, str> { + Cow::Borrowed("Date") + } + } + + impl PartialSchema for Date { + fn schema() -> RefOr { + RefOr::T(Schema::Object( + ObjectBuilder::new() + .schema_type(Type::String) + .format(Some(SchemaFormat::KnownFormat(KnownFormat::Date))) + .build(), + )) + } + } +} diff --git a/toboggan-core/src/timeouts.rs b/toboggan-core/src/timeouts.rs new file mode 100644 index 0000000..9d3988d --- /dev/null +++ b/toboggan-core/src/timeouts.rs @@ -0,0 +1,28 @@ +//! Shared timeout and interval constants for Toboggan +//! +//! This module provides centralized configuration for all timing-related +//! constants used across the client, server, and other components. + +#[cfg(all(not(feature = "std"), feature = "js"))] +use core::time::Duration; +#[cfg(feature = "std")] +use std::time::Duration; + +/// Interval between server heartbeat pings to clients +/// +/// The server sends periodic pings to keep the WebSocket connection alive +/// and detect disconnected clients. +pub const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30); + +/// Interval between client pings to server +/// +/// Clients send pings to measure round-trip time and keep the connection alive. +pub const PING_PERIOD: Duration = Duration::from_secs(10); + +/// Maximum time to wait for a connection response +pub const CONNECTION_TIMEOUT: Duration = Duration::from_secs(60); + +/// Interval for cleanup tasks +/// +/// How often the server checks for and removes disconnected clients. +pub const CLEANUP_INTERVAL: Duration = Duration::from_secs(30); diff --git a/toboggan-desktop/.gitignore b/toboggan-desktop/.gitignore deleted file mode 100644 index ea8c4bf..0000000 --- a/toboggan-desktop/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/target diff --git a/toboggan-desktop/Cargo.toml b/toboggan-desktop/Cargo.toml index 2446d8d..739caad 100644 --- a/toboggan-desktop/Cargo.toml +++ b/toboggan-desktop/Cargo.toml @@ -1,6 +1,25 @@ [package] name = "toboggan-desktop" version = "0.1.0" -edition = "2024" +description = "Cross-platform desktop application for Toboggan presentations using iced" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true [dependencies] +toboggan-core = { path = "../toboggan-core", features = ["std"] } +toboggan-client = { path = "../toboggan-client" } + +iced = { workspace = true } +lucide-icons = { workspace = true, features = ["iced"] } + +tokio = { workspace = true, features = ["full"] } +async-stream = { workspace = true } + +anyhow = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } + +[lints] +workspace = true diff --git a/toboggan-desktop/rustfmt.toml b/toboggan-desktop/rustfmt.toml new file mode 100644 index 0000000..2a732ff --- /dev/null +++ b/toboggan-desktop/rustfmt.toml @@ -0,0 +1,5 @@ +# Rustfmt configuration +edition = "2024" +unstable_features = true +imports_granularity = "Module" +group_imports = "StdExternalCrate" \ No newline at end of file diff --git a/toboggan-desktop/src/app.rs b/toboggan-desktop/src/app.rs new file mode 100644 index 0000000..4421cad --- /dev/null +++ b/toboggan-desktop/src/app.rs @@ -0,0 +1,436 @@ +use std::sync::OnceLock; + +use iced::{Element, Subscription, Task, Theme, keyboard}; +use toboggan_client::{ + CommunicationMessage, ConnectionStatus, TobogganApi, TobogganApiError, TobogganConfig, + WebSocketClient, +}; +use toboggan_core::{ + ClientConfig, ClientId, Command as TobogganCommand, SlidesResponse, Talk, TalkResponse, +}; +use tokio::sync::{broadcast, mpsc}; +use tracing::{debug, error, info}; + +use crate::message::Message; +use crate::state::AppState; +use crate::views; + +// Global channel for forwarding WebSocket messages to Iced +static MESSAGE_CHANNEL: OnceLock> = OnceLock::new(); + +pub struct App { + config: TobogganConfig, + state: AppState, + websocket_client: Option, + cmd_sender: Option>, + api: TobogganApi, + client_id: ClientId, +} + +impl App { + /// Creates a new app instance. + /// + /// # Panics + /// Panics if the message channel has already been initialized. + pub fn new(config: TobogganConfig) -> (Self, Task) { + let api_client = TobogganApi::new(config.api_url()); + let client_id = config.client_id(); + + // Initialize the global message channel for WebSocket message forwarding + let (tx, _) = broadcast::channel(1000); + assert!( + MESSAGE_CHANNEL.set(tx).is_ok(), + "Failed to initialize message channel - already initialized" + ); + + let app = Self { + config, + state: AppState::default(), + websocket_client: None, + cmd_sender: None, + api: api_client.clone(), + client_id, + }; + + // Load talk and slides immediately, then connect + let api_for_loading = api_client.clone(); + ( + app, + Task::batch([ + Task::perform( + async move { + let talk = api_for_loading.talk().await?; + let slides = api_for_loading.slides().await?; + Ok::<_, TobogganApiError>((talk, slides)) + }, + |result| match result { + Ok((talk, slides)) => Message::TalkAndSlidesLoaded(talk, slides), + Err(err) => Message::LoadError(err.to_string()), + }, + ), + Task::perform(async {}, |()| Message::Connect), + ]), + ) + } + + pub fn update(&mut self, message: Message) -> Task { + match message { + Message::Connect => self.handle_connect(), + + Message::Disconnect => self.handle_disconnect(), + + Message::TalkLoaded(talk_response) => self.handle_talk_loaded(&talk_response), + + Message::TalkAndSlidesLoaded(talk_response, slides_response) => { + self.handle_talk_and_slides_loaded(&talk_response, &slides_response) + } + + Message::TalkChangeComplete(talk_response, slides_response, state) => { + self.handle_talk_change_complete(&talk_response, &slides_response, &state) + } + + Message::Communication(message) => self.handle_websocket_message(message), + + Message::SlideLoaded(id, slide) => { + debug!("Slide loaded: {}", id); + if let Some(existing_slide) = self.state.slides.get_mut(id) { + *existing_slide = slide; + } else { + // Extend the Vec if needed + self.state.slides.resize(id + 1, slide.clone()); + if let Some(target_slide) = self.state.slides.get_mut(id) { + *target_slide = slide; + } + } + Task::none() + } + + Message::LoadError(error) => { + error!("Load error: {}", error); + self.state.error_message = Some(error); + Task::none() + } + + Message::SendCommand(command) => self.send_command(command), + + Message::ToggleHelp => { + self.state.show_help = !self.state.show_help; + Task::none() + } + + Message::ToggleSidebar => { + self.state.show_sidebar = !self.state.show_sidebar; + Task::none() + } + + Message::ToggleFullscreen => { + self.state.fullscreen = !self.state.fullscreen; + Task::none() + } + + Message::KeyPressed(key, modifiers) => self.handle_keyboard(key, modifiers), + + Message::WindowResized(_, _) | Message::Tick => Task::none(), + } + } + + #[must_use] + pub fn view(&self) -> Element<'_, Message> { + views::main_view(&self.state) + } + + #[must_use] + pub fn theme(&self) -> Theme { + Theme::default() + } + + pub fn subscription(&self) -> Subscription { + let keyboard_subscription = iced::keyboard::on_key_press(|key, modifiers| { + Some(Message::KeyPressed(key, modifiers)) + }); + + let tick_subscription = + iced::time::every(std::time::Duration::from_secs(1)).map(|_| Message::Tick); + + let websocket_subscription = websocket_message_subscription(); + + Subscription::batch(vec![ + keyboard_subscription, + tick_subscription, + websocket_subscription, + ]) + } +} + +impl App { + fn handle_connect(&mut self) -> Task { + info!("Connecting to server..."); + let (tx_cmd, rx_cmd) = mpsc::unbounded_channel(); + let (mut ws_client, mut rx_msg) = WebSocketClient::new( + tx_cmd.clone(), + rx_cmd, + self.client_id, + self.config.websocket(), + ); + + self.cmd_sender = Some(tx_cmd.clone()); + + // Send register command + let _ = tx_cmd.send(TobogganCommand::Register { + client: self.client_id, + }); + + // Start WebSocket connection and message forwarding in background + tokio::spawn(async move { + // Start connection + ws_client.connect().await; + + // Forward all WebSocket messages to Iced via broadcast channel + while let Some(msg) = rx_msg.recv().await { + info!("Received WebSocket message: {:?}", msg); + + // Forward the message to the global broadcast channel + if let Some(sender) = MESSAGE_CHANNEL.get() + && let Err(send_error) = sender.send(msg) + { + error!("Failed to forward WebSocket message: {}", send_error); + } + } + }); + + Task::none() + } + + fn handle_disconnect(&mut self) -> Task { + info!("Disconnecting from server..."); + self.websocket_client = None; + self.cmd_sender = None; + self.state.connection_status = ConnectionStatus::Closed; + + // Auto-reconnect after disconnect + Task::perform( + async { tokio::time::sleep(tokio::time::Duration::from_millis(100)).await }, + |()| Message::Connect, + ) + } + + fn handle_talk_loaded(&mut self, talk_response: &TalkResponse) -> Task { + info!("Talk loaded: {}", talk_response.title); + // For now, create a simplified talk from the response + let talk = Talk { + title: talk_response.title.clone(), + date: talk_response.date, + footer: talk_response.footer.clone(), + head: talk_response.head.clone(), + slides: vec![], // We'll load slides separately + }; + self.state.talk = Some(talk); + Task::none() + } + + fn handle_talk_and_slides_loaded( + &mut self, + talk_response: &TalkResponse, + slides_response: &SlidesResponse, + ) -> Task { + info!( + "Talk and slides loaded: {} ({} slides)", + talk_response.title, + slides_response.slides.len() + ); + // Create talk with actual slides + let talk = Talk { + title: talk_response.title.clone(), + date: talk_response.date, + footer: talk_response.footer.clone(), + head: talk_response.head.clone(), + slides: slides_response.slides.clone(), + }; + self.state.talk = Some(talk); + + // Store all slides in the Vec + self.state.slides.clone_from(&slides_response.slides); + + Task::none() + } + + fn handle_talk_change_complete( + &mut self, + talk_response: &TalkResponse, + slides_response: &SlidesResponse, + state: &toboggan_core::State, + ) -> Task { + info!( + "๐Ÿ“ Talk change complete: {} ({} slides)", + talk_response.title, + slides_response.slides.len() + ); + + // Update talk and slides + let talk = Talk { + title: talk_response.title.clone(), + date: talk_response.date, + footer: talk_response.footer.clone(), + head: talk_response.head.clone(), + slides: slides_response.slides.clone(), + }; + self.state.talk = Some(talk); + self.state.slides.clone_from(&slides_response.slides); + + // Now update state atomically with the fresh data + self.state.presentation_state = Some(state.clone()); + if let Some(slide_idx) = state.current() { + self.state.current_slide_index = Some(slide_idx); + } + + Task::none() + } + + fn handle_websocket_message(&mut self, message: CommunicationMessage) -> Task { + match message { + CommunicationMessage::ConnectionStatusChange { status } => { + self.state.connection_status = status.clone(); + info!("Connection status changed: {:?}", status); + + // Load talk data when connection is established (formerly in handle_connection_status_change) + if matches!(status, ConnectionStatus::Connected) { + let api = self.api.clone(); + Task::perform(async move { api.talk().await }, |result| match result { + Ok(talk) => Message::TalkLoaded(talk), + Err(load_error) => Message::LoadError(load_error.to_string()), + }) + } else { + Task::none() + } + } + CommunicationMessage::StateChange { state } => { + debug!("State change received: {:?}", state); + self.state.presentation_state = Some(state.clone()); + if let Some(slide_idx) = state.current() { + self.state.current_slide_index = Some(slide_idx); + + // Ensure slides are loaded from talk data + if let Some(talk) = &self.state.talk + && self.state.slides.is_empty() + && !talk.slides.is_empty() + { + self.state.slides = talk.slides.clone(); + } + } + Task::none() + } + CommunicationMessage::TalkChange { state } => { + info!("๐Ÿ“ Presentation updated, reloading talk and slides"); + + // DON'T update state immediately - wait for data to be fetched + // Refetch talk and slides from server, then update everything atomically + let api = self.api.clone(); + let state_for_update = state.clone(); + Task::perform( + async move { + let talk_result = api.talk().await; + let slides_result = api.slides().await; + (talk_result, slides_result, state_for_update) + }, + |(talk_result, slides_result, state)| match (talk_result, slides_result) { + (Ok(talk), Ok(slides)) => Message::TalkChangeComplete(talk, slides, state), + (Err(err), _) | (_, Err(err)) => Message::LoadError(err.to_string()), + }, + ) + } + CommunicationMessage::Error { error } => { + error!("WebSocket error: {}", error); + self.state.error_message = Some(error.clone()); + Task::none() + } + } + } + + fn send_command(&mut self, command: TobogganCommand) -> Task { + if let Some(sender) = &self.cmd_sender + && let Err(send_error) = sender.send(command) + { + error!("Failed to send command: {}", send_error); + } + Task::none() + } + + fn handle_keyboard( + &mut self, + key: keyboard::Key, + modifiers: keyboard::Modifiers, + ) -> Task { + match key { + keyboard::Key::Named( + keyboard::key::Named::ArrowRight | keyboard::key::Named::Space, + ) if !self.state.show_help => self.send_command(TobogganCommand::Next), + keyboard::Key::Named(keyboard::key::Named::ArrowLeft) if !self.state.show_help => { + self.send_command(TobogganCommand::Previous) + } + keyboard::Key::Named(keyboard::key::Named::Home) if !self.state.show_help => { + self.send_command(TobogganCommand::First) + } + keyboard::Key::Named(keyboard::key::Named::End) if !self.state.show_help => { + self.send_command(TobogganCommand::Last) + } + keyboard::Key::Character(character) if character == "h" || character == "?" => { + self.state.show_help = !self.state.show_help; + Task::none() + } + keyboard::Key::Character(character) if character == "s" && !self.state.show_help => { + self.state.show_sidebar = !self.state.show_sidebar; + Task::none() + } + keyboard::Key::Character(character) + if (character == "p" || character == "P") && !self.state.show_help => + { + self.send_command(TobogganCommand::Pause) + } + keyboard::Key::Character(character) + if (character == "r" || character == "R") && !self.state.show_help => + { + self.send_command(TobogganCommand::Resume) + } + keyboard::Key::Character(character) + if (character == "b" || character == "B") && !self.state.show_help => + { + self.send_command(TobogganCommand::Blink) + } + keyboard::Key::Named(keyboard::key::Named::F11) => { + self.state.fullscreen = !self.state.fullscreen; + Task::none() + } + keyboard::Key::Named(keyboard::key::Named::Escape) if self.state.show_help => { + self.state.show_help = false; + Task::none() + } + keyboard::Key::Named(keyboard::key::Named::Escape) + if self.state.error_message.is_some() => + { + self.state.error_message = None; + Task::none() + } + keyboard::Key::Character(character) if character == "q" && modifiers.command() => { + iced::window::close(iced::window::Id::unique()) + } + _ => Task::none(), + } + } +} + +// Create a subscription for WebSocket messages +fn websocket_message_subscription() -> Subscription { + Subscription::run(|| { + async_stream::stream! { + if let Some(channel) = MESSAGE_CHANNEL.get() { + let mut rx = channel.subscribe(); + + loop { + if let Ok(message) = rx.recv().await { + yield Message::Communication(message); + } + } + } + } + }) +} diff --git a/toboggan-desktop/src/constants.rs b/toboggan-desktop/src/constants.rs new file mode 100644 index 0000000..b2bdc85 --- /dev/null +++ b/toboggan-desktop/src/constants.rs @@ -0,0 +1,33 @@ +use iced::Color; + +// Font sizes - consider using theme-based typography in the future +pub const FONT_SIZE_SMALL: u16 = 12; +pub const FONT_SIZE_MEDIUM: u16 = 14; +pub const FONT_SIZE_LARGE: u16 = 16; +pub const FONT_SIZE_TITLE: u16 = 18; + +// Legacy colors - prefer theme.extended_palette() colors when possible +pub const COLOR_MUTED: Color = Color::from_rgb(0.6, 0.6, 0.6); + +// Spacing +pub const SPACING_SMALL: u16 = 4; +pub const SPACING_MEDIUM: u16 = 8; +pub const SPACING_LARGE: u16 = 12; + +// Padding values +pub const PADDING_SMALL: [u16; 2] = [2, 4]; +pub const PADDING_MEDIUM: [u16; 2] = [3, 6]; +pub const PADDING_CONTAINER: u16 = 6; +pub const PADDING_SLIDE_CONTENT: u16 = 20; + +// Border radius +pub const BORDER_RADIUS: f32 = 4.0; +pub const BORDER_WIDTH: f32 = 1.0; + +// Icon sizes +pub const ICON_SIZE_SMALL: u16 = 14; +pub const ICON_SIZE_MEDIUM: u16 = 16; + +// Component dimensions +pub const SLIDE_NOTES_HEIGHT: f32 = 150.0; +pub const SLIDE_NOTES_SCROLL_HEIGHT: f32 = 130.0; diff --git a/toboggan-desktop/src/lib.rs b/toboggan-desktop/src/lib.rs new file mode 100644 index 0000000..3ee49cd --- /dev/null +++ b/toboggan-desktop/src/lib.rs @@ -0,0 +1,9 @@ +mod app; +pub use app::App; + +mod constants; +mod message; +mod state; +mod styles; +mod views; +mod widgets; diff --git a/toboggan-desktop/src/main.rs b/toboggan-desktop/src/main.rs index e7a11a9..806d967 100644 --- a/toboggan-desktop/src/main.rs +++ b/toboggan-desktop/src/main.rs @@ -1,3 +1,31 @@ -fn main() { - println!("Hello, world!"); +use anyhow::{Context, Result}; +use iced::Settings; +use toboggan_client::TobogganConfig; +use toboggan_desktop::App; + +fn main() -> Result<()> { + // Setup logging + tracing_subscriber::fmt::init(); + + // Setup Lucide icons font + let lucide_font = lucide_icons::LUCIDE_FONT_BYTES; + + let config = TobogganConfig::default(); + + // Run the application + iced::application("Toboggan Desktop", App::update, App::view) + .settings(Settings::default()) + .window(iced::window::Settings { + size: iced::Size::new(1280.0, 720.0), + resizable: true, + decorations: true, + ..Default::default() + }) + .font(lucide_font) + .subscription(App::subscription) + .theme(App::theme) + .run_with(move || App::new(config)) + .context("Running application")?; + + Ok(()) } diff --git a/toboggan-desktop/src/message.rs b/toboggan-desktop/src/message.rs new file mode 100644 index 0000000..51ca045 --- /dev/null +++ b/toboggan-desktop/src/message.rs @@ -0,0 +1,34 @@ +use iced::keyboard::{Key, Modifiers}; +use toboggan_client::CommunicationMessage; +use toboggan_core::{Command as TobogganCommand, Slide, SlidesResponse, State, TalkResponse}; + +// All WebSocket commands are now unified under SendCommand variant +#[derive(Debug, Clone)] +pub enum Message { + // Connection events - user actions only + Connect, + Disconnect, + + // Command execution - unified variant for all WebSocket commands + SendCommand(TobogganCommand), + + // Data loading + TalkLoaded(TalkResponse), + TalkAndSlidesLoaded(TalkResponse, SlidesResponse), + TalkChangeComplete(TalkResponse, SlidesResponse, State), + SlideLoaded(usize, Slide), + LoadError(String), + + // WebSocket message handling + Communication(CommunicationMessage), + + // UI events + ToggleHelp, + ToggleSidebar, + ToggleFullscreen, + KeyPressed(Key, Modifiers), + WindowResized(f32, f32), + + // Tick for periodic updates + Tick, +} diff --git a/toboggan-desktop/src/state.rs b/toboggan-desktop/src/state.rs new file mode 100644 index 0000000..aafe416 --- /dev/null +++ b/toboggan-desktop/src/state.rs @@ -0,0 +1,52 @@ +use toboggan_client::ConnectionStatus; +use toboggan_core::{Slide, State as PresentationState, Talk}; + +#[derive(Debug, Clone)] +pub struct AppState { + pub connection_status: ConnectionStatus, + pub talk: Option, + pub slides: Vec, + pub presentation_state: Option, + pub current_slide_index: Option, + pub show_help: bool, + pub show_sidebar: bool, + pub fullscreen: bool, + pub error_message: Option, +} + +impl Default for AppState { + fn default() -> Self { + Self { + connection_status: ConnectionStatus::Closed, + talk: None, + slides: Vec::new(), + presentation_state: None, + current_slide_index: None, + show_help: false, + show_sidebar: true, + fullscreen: false, + error_message: None, + } + } +} + +impl AppState { + pub fn current_slide(&self) -> Option<&Slide> { + self.current_slide_index + .and_then(|idx| self.slides.get(idx)) + } + + pub fn next_slide(&self) -> Option<&Slide> { + if let Some(current_idx) = self.current_slide_index { + let next_idx = current_idx + 1; + self.slides.get(next_idx) + } else { + None + } + } + + pub fn slide_index(&self) -> Option<(usize, usize)> { + self.current_slide_index + .map(|current_idx| (current_idx + 1, self.slides.len())) + } +} diff --git a/toboggan-desktop/src/styles.rs b/toboggan-desktop/src/styles.rs new file mode 100644 index 0000000..461a13d --- /dev/null +++ b/toboggan-desktop/src/styles.rs @@ -0,0 +1,66 @@ +use iced::widget::container; +use iced::{Background, Border, Theme}; + +use crate::constants::{BORDER_RADIUS, BORDER_WIDTH}; + +// Container styles +pub fn card_container() -> impl Fn(&Theme) -> container::Style { + |theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.base.color)), + border: Border { + color: palette.background.strong.color, + width: BORDER_WIDTH, + radius: BORDER_RADIUS.into(), + }, + ..Default::default() + } + } +} + +pub fn footer_container() -> impl Fn(&Theme) -> container::Style { + |theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.weak.color)), + border: Border { + color: palette.background.strong.color, + width: BORDER_WIDTH, + radius: 0.0.into(), + }, + ..Default::default() + } + } +} + +pub fn error_container() -> impl Fn(&Theme) -> container::Style { + |theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.danger.strong.color)), + text_color: Some(palette.danger.strong.text), + border: Border { + color: iced::Color::TRANSPARENT, + width: 0.0, + radius: BORDER_RADIUS.into(), + }, + ..Default::default() + } + } +} + +pub fn preview_container() -> impl Fn(&Theme) -> container::Style { + |theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.weak.color)), + border: Border { + color: iced::Color::TRANSPARENT, + width: 0.0, + radius: BORDER_RADIUS.into(), + }, + ..Default::default() + } + } +} diff --git a/toboggan-desktop/src/views/content.rs b/toboggan-desktop/src/views/content.rs new file mode 100644 index 0000000..18ac843 --- /dev/null +++ b/toboggan-desktop/src/views/content.rs @@ -0,0 +1,36 @@ +use iced::Element; +use iced::widget::column; +use toboggan_core::Content; + +use crate::constants::SPACING_MEDIUM; +use crate::message::Message; + +pub fn render_content(content: &Content) -> String { + match content { + Content::Empty => String::new(), + Content::Text { text } => text.clone(), + Content::Html { raw, alt, .. } => alt.as_ref().unwrap_or(raw).clone(), + Content::Grid { cells, .. } => cells + .iter() + .map(render_content) + .collect::>() + .join(" "), + } +} + +pub fn render_content_element(content: &Content) -> Element<'_, Message> { + match content { + Content::Empty => iced::widget::text("").size(20).into(), + Content::Text { text } => iced::widget::text(text).size(20).into(), + Content::Html { raw, alt, .. } => iced::widget::text(alt.as_ref().unwrap_or(raw)) + .size(20) + .into(), + Content::Grid { cells, .. } => { + let mut col_content = column![].spacing(SPACING_MEDIUM); + for content_item in cells { + col_content = col_content.push(render_content_element(content_item)); + } + col_content.into() + } + } +} diff --git a/toboggan-desktop/src/views/footer.rs b/toboggan-desktop/src/views/footer.rs new file mode 100644 index 0000000..96dc573 --- /dev/null +++ b/toboggan-desktop/src/views/footer.rs @@ -0,0 +1,184 @@ +use iced::widget::{container, row}; +use iced::{Element, Length}; +use lucide_icons::iced::{ + icon_bell, icon_chevron_left, icon_chevron_right, icon_loader, icon_pause, icon_play, + icon_refresh_cw, icon_skip_back, icon_skip_forward, icon_wifi, icon_wifi_off, icon_x, +}; +use toboggan_client::ConnectionStatus; + +use crate::constants::{ + ICON_SIZE_MEDIUM, ICON_SIZE_SMALL, PADDING_CONTAINER, SPACING_MEDIUM, SPACING_SMALL, +}; +use crate::message::Message; +use crate::state::AppState; +use crate::styles; +use crate::widgets::{ + NavButtonPosition, create_icon_button, create_nav_button, create_simple_button, + create_status_row, create_status_row_with_button, +}; + +fn connection_status_view(status: &ConnectionStatus) -> Element<'_, Message> { + match status { + ConnectionStatus::Closed => create_status_row_with_button( + icon_wifi_off().size(ICON_SIZE_MEDIUM).into(), + "Disconnected", + create_simple_button("Connect", Message::Connect).into(), + ) + .into(), + ConnectionStatus::Connecting => { + create_status_row(icon_loader().size(ICON_SIZE_MEDIUM).into(), "Connecting...").into() + } + ConnectionStatus::Connected => create_status_row_with_button( + icon_wifi().size(ICON_SIZE_MEDIUM).into(), + "Connected", + create_icon_button( + icon_refresh_cw().size(ICON_SIZE_SMALL).into(), + "Reconnect", + Message::Disconnect, + ) + .style(iced::widget::button::secondary) + .into(), + ) + .into(), + ConnectionStatus::Reconnecting { + attempt, + max_attempt, + .. + } => { + let reconnecting_text = format!("Reconnecting... ({attempt}/{max_attempt})"); + iced::widget::row![ + icon_refresh_cw().size(ICON_SIZE_MEDIUM), + iced::widget::text(reconnecting_text).size(12) + ] + .spacing(SPACING_SMALL) + .align_y(iced::Alignment::Center) + } + .into(), + ConnectionStatus::Error { message } => { + let error_text = format!("Error: {message}"); + iced::widget::row![ + icon_x().size(ICON_SIZE_MEDIUM), + iced::widget::text(error_text).size(12), + iced::widget::button(iced::widget::text("Retry").size(11)) + .on_press(Message::Connect) + .padding([2, 4]) + ] + .spacing(SPACING_SMALL) + .align_y(iced::Alignment::Center) + } + .into(), + } +} + +fn navigation_controls_view() -> Element<'static, Message> { + row![ + create_nav_button( + icon_skip_back().size(ICON_SIZE_MEDIUM).into(), + "First", + Message::SendCommand(toboggan_core::Command::First), + NavButtonPosition::Leading + ), + create_nav_button( + icon_chevron_left().size(ICON_SIZE_MEDIUM).into(), + "Previous", + Message::SendCommand(toboggan_core::Command::Previous), + NavButtonPosition::Leading + ), + create_nav_button( + icon_chevron_right().size(ICON_SIZE_MEDIUM).into(), + "Next", + Message::SendCommand(toboggan_core::Command::Next), + NavButtonPosition::Trailing + ), + create_nav_button( + icon_skip_forward().size(ICON_SIZE_MEDIUM).into(), + "Last", + Message::SendCommand(toboggan_core::Command::Last), + NavButtonPosition::Trailing + ), + ] + .spacing(SPACING_SMALL) + .align_y(iced::Alignment::Center) + .into() +} + +fn presentation_controls_view(state: &AppState) -> Element<'_, Message> { + let pause_resume_button = match &state.presentation_state { + Some(toboggan_core::State::Running { .. }) => { + // Show pause button when presentation is running + create_icon_button( + icon_pause().size(ICON_SIZE_MEDIUM).into(), + "Pause", + Message::SendCommand(toboggan_core::Command::Pause), + ) + } + Some(toboggan_core::State::Paused { .. }) => { + // Show resume (play) button when presentation is paused + create_icon_button( + icon_play().size(ICON_SIZE_MEDIUM).into(), + "Resume", + Message::SendCommand(toboggan_core::Command::Resume), + ) + } + _ => { + // Default to pause button for Init/Done states + create_icon_button( + icon_pause().size(ICON_SIZE_MEDIUM).into(), + "Pause", + Message::SendCommand(toboggan_core::Command::Pause), + ) + } + }; + + let blink_button = create_icon_button( + icon_bell().size(ICON_SIZE_MEDIUM).into(), + "Blink", + Message::SendCommand(toboggan_core::Command::Blink), + ); + + row![pause_resume_button, blink_button] + .spacing(SPACING_SMALL) + .align_y(iced::Alignment::Center) + .into() +} + +pub fn view(state: &AppState) -> Element<'_, Message> { + let connection_status = connection_status_view(&state.connection_status); + let navigation_controls = navigation_controls_view(); + let presentation_controls = presentation_controls_view(state); + + let slide_counter = if let Some((current, total)) = state.slide_index() { + let counter_text = format!("Slide {current} / {total}"); + iced::widget::text(counter_text).size(12) + } else { + iced::widget::text("No slides").size(12) + }; + + let help_hint = iced::widget::text("Press 'h' for help") + .size(11) + .color(crate::constants::COLOR_MUTED); + + container( + row![ + connection_status, + container( + row![ + navigation_controls, + container(presentation_controls).padding([0, SPACING_MEDIUM]) + ] + .spacing(SPACING_MEDIUM) + .align_y(iced::Alignment::Center) + ) + .width(Length::Fill) + .center_x(iced::Length::Fill), + slide_counter, + help_hint, + ] + .spacing(SPACING_MEDIUM) + .align_y(iced::Alignment::Center), + ) + .width(Length::Fill) + .padding(PADDING_CONTAINER) + .style(styles::footer_container()) + .into() +} diff --git a/toboggan-desktop/src/views/help.rs b/toboggan-desktop/src/views/help.rs new file mode 100644 index 0000000..371a696 --- /dev/null +++ b/toboggan-desktop/src/views/help.rs @@ -0,0 +1,56 @@ +use iced::widget::{column, container}; +use iced::{Element, Length}; + +use crate::constants::{FONT_SIZE_LARGE, FONT_SIZE_MEDIUM, FONT_SIZE_SMALL, FONT_SIZE_TITLE}; +use crate::message::Message; +use crate::widgets::{create_muted_text, create_text}; + +pub fn view() -> Element<'static, Message> { + let help_content = column![ + create_text("Toboggan Desktop Help", 24), + create_text("", FONT_SIZE_SMALL), + create_text("Keyboard Shortcuts:", FONT_SIZE_TITLE), + create_text("", 8), + create_text("Navigation:", FONT_SIZE_LARGE), + create_text(" โ†’ / Space Next slide", FONT_SIZE_MEDIUM), + create_text(" โ† Previous slide", FONT_SIZE_MEDIUM), + create_text(" Home First slide", FONT_SIZE_MEDIUM), + create_text(" End Last slide", FONT_SIZE_MEDIUM), + create_text("", 8), + create_text("Presentation Control:", FONT_SIZE_LARGE), + create_text(" p / P Pause presentation", FONT_SIZE_MEDIUM), + create_text(" r / R Resume presentation", FONT_SIZE_MEDIUM), + create_text(" b / B Blink (bell/notification)", FONT_SIZE_MEDIUM), + create_text("", 8), + create_text("View:", FONT_SIZE_LARGE), + create_text(" h / ? Toggle this help", FONT_SIZE_MEDIUM), + create_text(" s Toggle sidebar", FONT_SIZE_MEDIUM), + create_text(" F11 Toggle fullscreen", FONT_SIZE_MEDIUM), + create_text(" Escape Close help/error", FONT_SIZE_MEDIUM), + create_text("", 8), + create_text("Application:", FONT_SIZE_LARGE), + create_text(" Cmd+Q Quit application", FONT_SIZE_MEDIUM), + create_text("", FONT_SIZE_SMALL), + create_muted_text("Press Escape to close this help"), + ] + .spacing(4) + .padding(30); + + container(help_content) + .width(Length::Fill) + .height(Length::Fill) + .center_x(iced::Length::Fill) + .center_y(iced::Length::Fill) + .style(|_theme: &iced::Theme| iced::widget::container::Style { + background: Some(iced::Background::Color(iced::Color::from_rgba( + 0.98, 0.98, 0.98, 0.95, + ))), + border: iced::Border { + color: iced::Color::TRANSPARENT, + width: 0.0, + radius: 8.0.into(), + }, + ..Default::default() + }) + .into() +} diff --git a/toboggan-desktop/src/views/mod.rs b/toboggan-desktop/src/views/mod.rs new file mode 100644 index 0000000..d8388c2 --- /dev/null +++ b/toboggan-desktop/src/views/mod.rs @@ -0,0 +1,61 @@ +mod content; +mod footer; +mod help; +mod sidebar; +mod slide; + +use iced::widget::{column, container, row}; +use iced::{Element, Length}; + +use crate::constants::{ + FONT_SIZE_MEDIUM, PADDING_CONTAINER, PADDING_SLIDE_CONTENT, SPACING_MEDIUM, +}; +use crate::message::Message; +use crate::state::AppState; +use crate::styles; +use crate::widgets::create_text; + +pub fn main_view(state: &AppState) -> Element<'_, Message> { + let main_content = if state.show_help { + help::view() + } else { + presentation_view(state) + }; + + if let Some(error) = &state.error_message { + container(column![ + main_content, + container(create_text(error, FONT_SIZE_MEDIUM)) + .style(styles::error_container()) + .padding(PADDING_CONTAINER) + .width(Length::Fill) + ]) + .width(Length::Fill) + .height(Length::Fill) + .into() + } else { + main_content + } +} + +fn presentation_view(state: &AppState) -> Element<'_, Message> { + let mut layout = row![]; + + if state.show_sidebar { + layout = layout.push(sidebar::view(state)); + } + + let main_area = column![slide::view(state), footer::view(state),].spacing(SPACING_MEDIUM); + + layout = layout.push( + container(main_area) + .width(Length::Fill) + .height(Length::Fill) + .padding(PADDING_SLIDE_CONTENT), + ); + + container(layout) + .width(Length::Fill) + .height(Length::Fill) + .into() +} diff --git a/toboggan-desktop/src/views/sidebar.rs b/toboggan-desktop/src/views/sidebar.rs new file mode 100644 index 0000000..b937263 --- /dev/null +++ b/toboggan-desktop/src/views/sidebar.rs @@ -0,0 +1,72 @@ +use iced::widget::{button, column, container, scrollable}; +use iced::{Element, Length}; + +use super::content; +use crate::constants::{PADDING_CONTAINER, SPACING_MEDIUM, SPACING_SMALL}; +use crate::message::Message; +use crate::state::AppState; +use crate::styles; +use crate::widgets::{create_body_text, create_title_text}; + +pub fn view(state: &AppState) -> Element<'_, Message> { + let mut sidebar_content = column![create_title_text("Slides")] + .spacing(SPACING_MEDIUM) + .padding(PADDING_CONTAINER); + + if state.talk.is_some() && !state.slides.is_empty() { + let mut slides_list = column![].spacing(SPACING_SMALL); + + for (index, slide) in state.slides.iter().enumerate() { + let is_current = Some(index) == state.current_slide_index; + + let slide_text = if matches!(&slide.title, toboggan_core::Content::Text { text } if text.is_empty()) + { + format!("{}. Slide {}", index + 1, index) + } else { + format!("{}. {}", index + 1, content::render_content(&slide.title)) + }; + + let slide_button = iced::widget::button(iced::widget::text(slide_text)) + .on_press(Message::SendCommand(toboggan_core::Command::GoTo { + slide: index, + })) + .padding([4, 8]) + .style(if is_current { + |theme: &iced::Theme, status| button::primary(theme, status) + } else { + |theme: &iced::Theme, status| button::secondary(theme, status) + }); + + slides_list = slides_list.push(slide_button); + } + + sidebar_content = sidebar_content.push(scrollable(slides_list).height(Length::Fill)); + } + + // Next slide preview + if let Some(next_slide) = state.next_slide() { + sidebar_content = sidebar_content.push( + container( + column![ + create_body_text("Next Slide"), + container(if matches!(&next_slide.title, toboggan_core::Content::Text { text } if text.is_empty()) { + iced::widget::text("No title").size(12) + } else { + let title_content = content::render_content(&next_slide.title); + iced::widget::text(title_content).size(12) + }) + .padding(SPACING_SMALL) + .style(styles::preview_container()) + ] + .spacing(SPACING_SMALL), + ) + .padding(PADDING_CONTAINER), + ); + } + + container(sidebar_content) + .width(Length::Fixed(250.0)) + .height(Length::Fill) + .style(styles::card_container()) + .into() +} diff --git a/toboggan-desktop/src/views/slide.rs b/toboggan-desktop/src/views/slide.rs new file mode 100644 index 0000000..7a9ebc2 --- /dev/null +++ b/toboggan-desktop/src/views/slide.rs @@ -0,0 +1,93 @@ +use iced::widget::{column, container, scrollable}; +use iced::{Element, Length}; +use toboggan_core::Content; + +use super::content; +use crate::constants::{ + COLOR_MUTED, FONT_SIZE_LARGE, PADDING_CONTAINER, PADDING_SLIDE_CONTENT, SLIDE_NOTES_HEIGHT, + SLIDE_NOTES_SCROLL_HEIGHT, SPACING_LARGE, SPACING_SMALL, +}; +use crate::message::Message; +use crate::state::AppState; +use crate::styles; + +pub fn view(state: &AppState) -> Element<'_, Message> { + if let Some(slide) = state.current_slide() { + let mut content_column = column![] + .spacing(SPACING_LARGE) + .padding(PADDING_SLIDE_CONTENT); + + // Slide kind indicator + let kind_text = match slide.kind { + toboggan_core::SlideKind::Cover => "COVER", + toboggan_core::SlideKind::Part => "PART", + toboggan_core::SlideKind::Standard => "", + }; + + if !kind_text.is_empty() { + content_column = + content_column.push(iced::widget::text(kind_text).size(12).color(COLOR_MUTED)); + } + + // Title + if !matches!(&slide.title, Content::Text { text } if text.is_empty()) { + let title_content = content::render_content(&slide.title); + content_column = content_column.push(iced::widget::text(title_content).size(32)); + } + + // Body + if !matches!(&slide.body, Content::Text { text } if text.is_empty()) { + content_column = content_column.push( + scrollable( + container(content::render_content_element(&slide.body)) + .width(Length::Fill) + .padding(PADDING_CONTAINER), + ) + .height(Length::FillPortion(3)), + ); + } + + // Speaker Notes + if !matches!(&slide.notes, Content::Text { text } if text.is_empty()) { + content_column = content_column.push( + container( + column![ + iced::widget::text("Speaker Notes") + .size(FONT_SIZE_LARGE) + .color(COLOR_MUTED), + container( + scrollable( + container(content::render_content_element(&slide.notes)) + .padding(PADDING_CONTAINER) + .width(Length::Fill) + ) + .height(Length::Fixed(SLIDE_NOTES_SCROLL_HEIGHT)) + ) + .height(Length::Fixed(SLIDE_NOTES_HEIGHT)) + .style(styles::preview_container()) + ] + .spacing(SPACING_SMALL), + ) + .width(Length::Fill), + ); + } + + container(content_column) + .width(Length::Fill) + .height(Length::Fill) + .center_x(iced::Length::Fill) + .center_y(iced::Length::Fill) + .into() + } else { + container( + iced::widget::text("No slide loaded") + .size(24) + .color(COLOR_MUTED), + ) + .width(Length::Fill) + .height(Length::Fill) + .center_x(iced::Length::Fill) + .center_y(iced::Length::Fill) + .into() + } +} diff --git a/toboggan-desktop/src/widgets.rs b/toboggan-desktop/src/widgets.rs new file mode 100644 index 0000000..5d9cbe5 --- /dev/null +++ b/toboggan-desktop/src/widgets.rs @@ -0,0 +1,87 @@ +use iced::widget::{Button, Row, Text, button, row, text}; +use iced::{Alignment, Element}; + +use crate::constants::{ + COLOR_MUTED, FONT_SIZE_MEDIUM, FONT_SIZE_SMALL, FONT_SIZE_TITLE, PADDING_MEDIUM, PADDING_SMALL, + SPACING_MEDIUM, SPACING_SMALL, +}; +use crate::message::Message; + +// Helper function to create text with consistent styling +pub fn create_text(content: &str, size: u16) -> Text<'_> { + text(content).size(size) +} + +pub fn create_muted_text(content: &str) -> Text<'_> { + text(content).size(FONT_SIZE_SMALL).color(COLOR_MUTED) +} + +// Theme-aware text functions +pub fn create_title_text(content: &str) -> Text<'_> { + text(content).size(FONT_SIZE_TITLE) +} + +pub fn create_body_text(content: &str) -> Text<'_> { + text(content).size(FONT_SIZE_MEDIUM) +} + +// Helper function to create icon-text buttons +pub fn create_icon_button<'a>( + icon: Element<'a, Message>, + label: &'a str, + message: Message, +) -> Button<'a, Message> { + button( + row![icon, text(label).size(FONT_SIZE_MEDIUM)] + .spacing(SPACING_SMALL) + .align_y(Alignment::Center), + ) + .on_press(message) + .padding(PADDING_MEDIUM) +} + +pub fn create_simple_button(label: &str, message: Message) -> Button<'_, Message> { + button(text(label).size(FONT_SIZE_MEDIUM)) + .on_press(message) + .padding(PADDING_SMALL) +} + +// Helper function to create status rows with icon and text +pub fn create_status_row<'a>(icon: Element<'a, Message>, status_text: &'a str) -> Row<'a, Message> { + row![icon, text(status_text).size(FONT_SIZE_MEDIUM)] + .spacing(SPACING_MEDIUM) + .align_y(Alignment::Center) +} + +pub fn create_status_row_with_button<'a>( + icon: Element<'a, Message>, + status_text: &'a str, + button_elem: Element<'a, Message>, +) -> Row<'a, Message> { + row![icon, text(status_text).size(FONT_SIZE_MEDIUM), button_elem] + .spacing(SPACING_MEDIUM) + .align_y(Alignment::Center) +} + +// Navigation button helper +pub fn create_nav_button<'a>( + icon: Element<'a, Message>, + label: &'a str, + message: Message, + position: NavButtonPosition, +) -> Button<'a, Message> { + let content = match position { + NavButtonPosition::Leading => row![icon, text(label).size(FONT_SIZE_MEDIUM)], + NavButtonPosition::Trailing => row![text(label).size(FONT_SIZE_MEDIUM), icon], + }; + + button(content.spacing(SPACING_SMALL).align_y(Alignment::Center)) + .on_press(message) + .padding(PADDING_MEDIUM) +} + +#[derive(Copy, Clone)] +pub enum NavButtonPosition { + Leading, + Trailing, +} diff --git a/toboggan-esp32/.gitignore b/toboggan-esp32/.gitignore deleted file mode 100644 index ea8c4bf..0000000 --- a/toboggan-esp32/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/target diff --git a/toboggan-esp32/Cargo.toml b/toboggan-esp32/Cargo.toml deleted file mode 100644 index 7ebd18d..0000000 --- a/toboggan-esp32/Cargo.toml +++ /dev/null @@ -1,6 +0,0 @@ -[package] -name = "toboggan-esp32" -version = "0.1.0" -edition = "2024" - -[dependencies] diff --git a/toboggan-esp32/src/main.rs b/toboggan-esp32/src/main.rs deleted file mode 100644 index e7a11a9..0000000 --- a/toboggan-esp32/src/main.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - println!("Hello, world!"); -} diff --git a/toboggan-ios/Cargo.lock b/toboggan-ios/Cargo.lock new file mode 100644 index 0000000..ed3d64f --- /dev/null +++ b/toboggan-ios/Cargo.lock @@ -0,0 +1,2532 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "anstyle" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" + +[[package]] +name = "anyhow" +version = "1.0.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" + +[[package]] +name = "askama" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4744ed2eef2645831b441d8f5459689ade2ab27c854488fbab1fbe94fce1a7" +dependencies = [ + "askama_derive", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d661e0f57be36a5c14c48f78d09011e67e0cb618f269cca9f2fd8d15b68c46ac" +dependencies = [ + "askama_parser", + "basic-toml", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "serde", + "serde_derive", + "syn", +] + +[[package]] +name = "askama_parser" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf315ce6524c857bb129ff794935cf6d42c82a6cff60526fe2a63593de4d0d4f" +dependencies = [ + "memchr", + "serde", + "serde_derive", + "winnow", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "backtrace" +version = "0.3.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets 0.52.6", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "camino" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d07aa9a93b00c76f71bc35d598bed923f6d4f3a9ca5c24b7737ae1a292841c0" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "cc" +version = "1.2.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ee0f8803222ba5a7e2777dd72ca451868909b1ac410621b676adf07280e9b5f" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" + +[[package]] +name = "clap" +version = "4.5.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc0e74a703892159f5ae7d3aac52c8e6c392f5ae5f359c70b5881d60aaac318" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3e7f4214277f3c7aa526a59dd3fbe306a370daee1f8b7b8c987069cd8e888a8" +dependencies = [ + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14cb31bb0a7d536caef2639baa7fad459e15c3144efefa6dbd1c84562c4739f6" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + +[[package]] +name = "derive_more" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs-err" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" +dependencies = [ + "autocfg", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getopts" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cba6ae63eb948698e300f645f87c70f76630d505f23b8907cf1e193ee85048c1" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "goblin" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" +dependencies = [ + "log", + "plain", + "scroll", +] + +[[package]] +name = "h2" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "io-uring" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" +dependencies = [ + "bitflags", + "cfg-if", + "libc", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "jiff" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde", +] + +[[package]] +name = "jiff-static" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.175" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.59.0", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "openssl" +version = "0.10.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d61789d7719defeb74ea5fe81f2fdfdbd28a803847077cecce2ff14e1472f6f1" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" +dependencies = [ + "bitflags", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.3", +] + +[[package]] +name = "reqwest" +version = "0.12.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "rustls" +version = "0.23.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "schannel" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "scroll" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +dependencies = [ + "serde", +] + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.142" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "030fedb782600dcbd6f02d479bf0d817ac3bb40d644745b769d6a96bc3afc5a7" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +dependencies = [ + "fastrand", + "getrandom 0.3.3", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", +] + +[[package]] +name = "thiserror" +version = "2.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b0949c3a6c842cbde3f1686d6eea5a010516deb7085f79db747562d4102f41e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc5b44b4ab9c2fdd0e0512e6bece8388e214c0749f5862b114cc5b7a25daf227" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "toboggan-client" +version = "0.1.0" +dependencies = [ + "derive_more", + "futures", + "reqwest", + "serde", + "serde_json", + "toboggan-core", + "tokio", + "tokio-tungstenite", + "tracing", +] + +[[package]] +name = "toboggan-core" +version = "0.1.0" +dependencies = [ + "derive_more", + "getrandom 0.2.16", + "jiff", + "once_cell", + "pulldown-cmark", + "serde", + "uuid", +] + +[[package]] +name = "toboggan-ios" +version = "0.1.0" +dependencies = [ + "derive_more", + "toboggan-client", + "toboggan-core", + "tokio", + "tracing", + "tracing-subscriber", + "uniffi", +] + +[[package]] +name = "tokio" +version = "1.47.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +dependencies = [ + "backtrace", + "bytes", + "io-uring", + "libc", + "mio", + "pin-project-lite", + "slab", + "socket2", + "tokio-macros", + "windows-sys 0.59.0", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "489a59b6730eda1b0171fcfda8b121f4bee2b35cba8645ca35c5f7ba3eb736c1" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unicode-width" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "uniffi" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6d968cb62160c11f2573e6be724ef8b1b18a277aededd17033f8a912d73e2b4" +dependencies = [ + "anyhow", + "camino", + "cargo_metadata", + "clap", + "uniffi_bindgen", + "uniffi_build", + "uniffi_core", + "uniffi_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_bindgen" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b39ef1acbe1467d5d210f274fae344cb6f8766339330cb4c9688752899bf6b" +dependencies = [ + "anyhow", + "askama", + "camino", + "cargo_metadata", + "fs-err", + "glob", + "goblin", + "heck", + "indexmap", + "once_cell", + "serde", + "tempfile", + "textwrap", + "toml", + "uniffi_internal_macros", + "uniffi_meta", + "uniffi_pipeline", + "uniffi_udl", +] + +[[package]] +name = "uniffi_build" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6683e6b665423cddeacd89a3f97312cf400b2fb245a26f197adaf65c45d505b2" +dependencies = [ + "anyhow", + "camino", + "uniffi_bindgen", +] + +[[package]] +name = "uniffi_core" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d990b553d6b9a7ee9c3ae71134674739913d52350b56152b0e613595bb5a6f" +dependencies = [ + "anyhow", + "bytes", + "once_cell", + "static_assertions", +] + +[[package]] +name = "uniffi_internal_macros" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f4f224becf14885c10e6e400b95cc4d1985738140cb194ccc2044563f8a56b" +dependencies = [ + "anyhow", + "indexmap", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "uniffi_macros" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b481d385af334871d70904e6a5f129be7cd38c18fcf8dd8fd1f646b426a56d58" +dependencies = [ + "camino", + "fs-err", + "once_cell", + "proc-macro2", + "quote", + "serde", + "syn", + "toml", + "uniffi_meta", +] + +[[package]] +name = "uniffi_meta" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10f817868a3b171bb7bf259e882138d104deafde65684689b4694c846d322491" +dependencies = [ + "anyhow", + "siphasher", + "uniffi_internal_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_pipeline" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b147e133ad7824e32426b90bc41fda584363563f2ba747f590eca1fd6fd14e6" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "tempfile", + "uniffi_internal_macros", +] + +[[package]] +name = "uniffi_udl" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caed654fb73da5abbc7a7e9c741532284532ba4762d6fe5071372df22a41730a" +dependencies = [ + "anyhow", + "textwrap", + "uniffi_meta", + "weedle2", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f33196643e165781c20a5ead5582283a7dacbb87855d867fbc2df3f81eddc1be" +dependencies = [ + "getrandom 0.3.3", + "serde", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "weedle2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" +dependencies = [ + "nom", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.3", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "winnow" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags", +] + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/toboggan-ios/Cargo.toml b/toboggan-ios/Cargo.toml index 1f75c10..2350c66 100644 --- a/toboggan-ios/Cargo.toml +++ b/toboggan-ios/Cargo.toml @@ -1,6 +1,45 @@ [package] name = "toboggan-ios" version = "0.1.0" -edition = "2024" +description = "iOS library for Toboggan presentations with UniFFI bindings for Swift" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true + + +[lib] +crate-type = ["lib", "cdylib", "staticlib"] +name = "toboggan" + +[[bin]] +name = "uniffi-bindgen" +path = "./uniffi-bindgen.rs" + +[[bin]] +name = "uniffi-bindgen-swift" +path = "./uniffi-bindgen-swift.rs" + [dependencies] +toboggan-core = { path = "../toboggan-core", features = ["std"] } +toboggan-client = { path = "../toboggan-client" } + +uniffi = { workspace = true, features = ["cli"] } +tokio = { workspace = true, features = [ + "rt", + "rt-multi-thread", + "macros", + "sync", + "time", +] } + +[dev-dependencies] + +[build-dependencies] +uniffi = { workspace = true, features = ["build"] } + + +[lints] +workspace = true diff --git a/toboggan-ios/README.md b/toboggan-ios/README.md new file mode 100644 index 0000000..e166e3e --- /dev/null +++ b/toboggan-ios/README.md @@ -0,0 +1,415 @@ +# Toboggan iOS Rust Core + +Rust implementation providing the core functionality for iOS integration with Toboggan presentations. This crate serves as the bridge between the Toboggan ecosystem and native iOS applications through UniFFI bindings. + +## Status & Integration Strategy + +๐Ÿ”„ **Future Integration Phase**: This Rust code provides a complete, production-ready foundation for real Toboggan server connectivity. The current iOS app (in `../TobogganApp/`) uses a pure Swift implementation with mock data to enable rapid UI development and iteration. + +**Current Approach Benefits:** +- Fast SwiftUI development cycles without Rust compilation delays +- Immediate SwiftUI previews and hot-reload functionality +- Independent iOS UI development and testing +- Easy onboarding for Swift developers + +**Future Integration Benefits:** +- Shared business logic with other Toboggan clients +- Real-time WebSocket synchronization +- Type-safe communication via UniFFI +- Consistent presentation behavior across platforms + +## Overview + +This Rust library provides a complete iOS-specific implementation of the Toboggan client with: + +### Core Functionality +- **WebSocket Client**: Async connection to Toboggan server with automatic reconnection +- **Presentation Control**: Full command system (Next, Previous, Play, Pause, etc.) +- **State Management**: Thread-safe presentation state synchronization +- **Error Handling**: Comprehensive error types with user-friendly messages + +### iOS Integration +- **UniFFI Bindings**: Automatic Swift interface generation +- **Type Safety**: Strong typing across the Rust-Swift boundary +- **Async Support**: Native async/await patterns for iOS +- **Memory Management**: Automatic reference counting integration + +### Network Features +- **Connection Management**: Robust WebSocket handling with retry logic +- **Command Queue**: Offline command buffering and synchronization +- **State Reconciliation**: Automatic state sync when reconnecting +- **Error Recovery**: Graceful handling of network interruptions + +## Architecture + +### Core Design Principles +- **Single Responsibility**: Each module has a clear, focused purpose +- **Async-First**: All operations are non-blocking and reactive +- **Type Safety**: Extensive use of Rust's type system for correctness +- **Error Transparency**: Clear error propagation to Swift layer + +### Module Structure + +#### `client.rs` - WebSocket Client Implementation +```rust +pub struct TobogganClient { + // WebSocket connection management + // Command queue and state synchronization + // Automatic reconnection logic +} + +impl TobogganClient { + pub async fn connect(&self, url: String) -> Result<()>; + pub async fn send_command(&self, command: Command) -> Result<()>; + pub fn subscribe_to_state(&self) -> StateStream; +} +``` + +#### `command.rs` - Presentation Commands +```rust +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum Command { + Next, Previous, First, Last, + Goto { slide: SlideId }, + Play, Pause, Resume, + Register { client_id: ClientId }, +} +``` + +#### `state.rs` - Presentation State Management +```rust +#[derive(Clone, Debug)] +pub enum PresentationState { + Disconnected, + Connected { server_state: State }, + Error { message: String }, +} +``` + +#### `config.rs` - Client Configuration +```rust +#[derive(Clone, Debug)] +pub struct ClientConfig { + pub server_url: String, + pub reconnect_interval: Duration, + pub command_timeout: Duration, +} +``` + +#### `error.rs` - Comprehensive Error Handling +```rust +#[derive(Debug, thiserror::Error)] +pub enum TobogganError { + #[error("Connection failed: {0}")] + ConnectionError(String), + #[error("Invalid command: {0}")] + CommandError(String), + // ... other error variants +} +``` + +### UniFFI Integration + +The `toboggan.udl` file defines the complete interface for Swift bindings: + +```webidl +namespace toboggan { + TobogganClient create_client(ClientConfig config); +}; + +interface TobogganClient { + [Async] + void connect(string url); + + [Async] + void send_command(Command command); + + StateStream subscribe_to_state(); +}; +``` + +**Benefits of UniFFI:** +- Automatic Swift binding generation +- Memory safety across language boundaries +- Native Swift async/await support +- Automatic error handling conversion +- Type-safe data serialization + +## Dependencies + +- **toboggan-core**: Core domain models +- **toboggan-client**: Shared client library +- **uniffi**: Rust-Swift interoperability +- **tokio**: Async runtime + +## Building + +### Prerequisites +- **Rust Toolchain**: Latest stable Rust with iOS targets +- **iOS Targets**: Required for cross-compilation +- **UniFFI**: For Swift binding generation + +```bash +# Install iOS targets +rustup target add aarch64-apple-ios x86_64-apple-ios aarch64-apple-ios-sim + +# Install UniFFI CLI (if needed) +cargo install uniffi_bindgen +``` + +### Build Process + +#### Option 1: Using Mise (Recommended) +```bash +# From workspace root - builds for all iOS targets +mise build:ios +``` + +#### Option 2: Manual Build +```bash +# Navigate to iOS library directory +cd toboggan-ios + +# Build iOS framework with script +./build.sh + +# This creates: +# - target/universal-ios/release/TobogganCore.xcframework +# - Generated Swift bindings in target/uniffi/ +``` + +#### Option 3: Individual Target Build +```bash +# Build for specific targets +cargo build --target aarch64-apple-ios --release # iOS device (ARM64) +cargo build --target x86_64-apple-ios --release # iOS simulator (Intel) +cargo build --target aarch64-apple-ios-sim --release # iOS simulator (Apple Silicon) +``` + +### Build Output + +The build process generates: +``` +target/ +โ”œโ”€โ”€ uniffi/ +โ”‚ โ”œโ”€โ”€ TobogganCore.swift # Swift interface +โ”‚ โ”œโ”€โ”€ TobogganCore.h # C header +โ”‚ โ””โ”€โ”€ TobogganCore-Bridging-Header.h +โ”œโ”€โ”€ universal-ios/ +โ”‚ โ””โ”€โ”€ release/ +โ”‚ โ””โ”€โ”€ TobogganCore.xcframework/ # Universal iOS framework +โ””โ”€โ”€ [target]/release/ + โ””โ”€โ”€ libtoboggan_ios.a # Static library per target +``` + +## Current Development Approach + +### Phase 1: Swift-Only Implementation (Current) +The iOS app in `../TobogganApp/` uses a pure Swift implementation with several advantages: + +**Architecture:** +```swift +// Current: Pure Swift with mock types +protocol SlideProtocol { /* ... */ } +struct MockSlide: SlideProtocol { /* ... */ } + +// ViewModels use mock types for rapid development +class PresentationViewModel: ObservableObject { + @Published var slides: [MockSlide] = MockData.sampleSlides +} +``` + +**Benefits:** +- **Fast Iteration**: No Rust compilation delays during UI development +- **SwiftUI Previews**: Immediate preview support without dependencies +- **Easy Debugging**: Standard Swift debugging tools and workflow +- **Team Velocity**: Swift developers can work independently + +### Integration Process (Future) +1. Build the Rust library with `./build.sh` +2. Replace mock implementations in Swift with real UniFFI types +3. Update `TobogganCore.swift` to use actual Rust-generated bindings +4. Configure WebSocket connection to real Toboggan server + +## Development + +- **Language**: Rust 2024 edition +- **Linting**: Comprehensive clippy rules enabled +- **Safety**: No unsafe code allowed +- **Testing**: Unit tests for all modules + +## File Structure + +``` +toboggan-ios/ +โ”œโ”€โ”€ src/ +โ”‚ โ”œโ”€โ”€ lib.rs # Main exports and UniFFI setup +โ”‚ โ”œโ”€โ”€ client.rs # WebSocket client +โ”‚ โ”œโ”€โ”€ command.rs # Presentation commands +โ”‚ โ”œโ”€โ”€ config.rs # Configuration types +โ”‚ โ”œโ”€โ”€ error.rs # Error handling +โ”‚ โ”œโ”€โ”€ state.rs # State management +โ”‚ โ”œโ”€โ”€ types.rs # Core data types +โ”‚ โ”œโ”€โ”€ utils.rs # Utility functions +โ”‚ โ”œโ”€โ”€ main.rs # Binary stub +โ”‚ โ””โ”€โ”€ toboggan.udl # UniFFI interface definition +โ”œโ”€โ”€ Cargo.toml # Rust dependencies +โ”œโ”€โ”€ build.sh # iOS build script +โ””โ”€โ”€ README.md # This file +``` + +## Integration Roadmap + +### Phase 2: Rust Integration (Future) + +When ready for production server connectivity: + +#### Integration Steps + +1. **Build Rust Framework** + ```bash + # Generate iOS framework and Swift bindings + mise build:ios + ``` + +2. **Update Xcode Project** + ```bash + # Add framework to iOS project + cp target/universal-ios/release/TobogganCore.xcframework TobogganApp/Frameworks/ + + # Add Swift bindings + cp target/uniffi/TobogganCore.swift TobogganApp/TobogganApp/Generated/ + ``` + +3. **Replace Mock Implementation** + ```swift + // Before: Mock types + struct MockSlide: SlideProtocol { /* ... */ } + + // After: Real Rust types via UniFFI + import TobogganCore + + class PresentationViewModel: ObservableObject { + private let client: TobogganClient + @Published var state: PresentationState = .disconnected + + func connect(to url: String) async { + try await client.connect(url: url) + } + } + ``` + +4. **Configure Networking** + ```swift + let config = ClientConfig( + serverUrl: "ws://localhost:8080/api/ws", + reconnectInterval: Duration.seconds(5), + commandTimeout: Duration.seconds(10) + ) + let client = createClient(config: config) + ``` + +#### Migration Benefits +- **Shared Logic**: Common codebase with other Toboggan clients +- **Real-time Sync**: WebSocket-based multi-client synchronization +- **Type Safety**: UniFFI ensures type-safe boundaries +- **Performance**: Efficient Rust implementation with zero-copy data +- **Reliability**: Robust error handling and automatic reconnection + +## Development Workflow + +### Current Development (Swift-Only) +```bash +# Fast UI iteration - no Rust compilation needed +open TobogganApp/TobogganApp.xcodeproj +# Edit Swift files, use SwiftUI previews, test immediately +``` + +### Future Development (With Rust) +```bash +# 1. Update Rust code +cd toboggan-ios +# Edit .rs files + +# 2. Rebuild framework +mise build:ios + +# 3. Update iOS app +cd ../TobogganApp +# Edit Swift code to use new Rust types + +# 4. Test integration +open TobogganApp.xcodeproj +``` + +### Testing Strategy +- **Unit Tests**: Test Rust logic independently +- **Integration Tests**: Test UniFFI boundaries +- **UI Tests**: Test Swift UI with real data +- **Network Tests**: Test WebSocket connectivity + +## Troubleshooting + +### Build Issues +```bash +# Check iOS targets are installed +rustup target list | grep apple-ios + +# Install missing targets +rustup target add aarch64-apple-ios x86_64-apple-ios aarch64-apple-ios-sim + +# Clean build if needed +cargo clean +./build.sh +``` + +### UniFFI Issues +```bash +# Regenerate bindings +cargo run --bin uniffi-bindgen generate src/toboggan.udl --language swift + +# Check binding compatibility +uniffi-bindgen --version +``` + +### Xcode Integration +- Ensure framework is properly linked in Xcode project +- Verify bridging header is configured correctly +- Check that generated Swift files are in project + +### Network Debugging +```swift +// Enable network logging +let config = ClientConfig( + serverUrl: "ws://localhost:8080/api/ws", + reconnectInterval: Duration.seconds(1), + commandTimeout: Duration.seconds(30) +) + +// Test connection manually +let client = createClient(config: config) +Task { + do { + try await client.connect(url: config.serverUrl) + print("Connected successfully") + } catch { + print("Connection failed: \(error)") + } +} +``` + +## Contributing + +### Code Organization +- Keep business logic in Rust for cross-platform consistency +- Use Swift only for iOS-specific UI and integration +- Follow UniFFI best practices for boundary design +- Maintain comprehensive test coverage + +### Development Guidelines +- Test Rust changes with `cargo test` +- Validate UniFFI bindings generation +- Ensure iOS app builds with new framework +- Test on both simulator and physical devices + +The current pure Swift approach enables rapid UI development, while this Rust foundation provides the infrastructure for production server connectivity when ready. \ No newline at end of file diff --git a/toboggan-ios/build.rs b/toboggan-ios/build.rs new file mode 100644 index 0000000..4205de7 --- /dev/null +++ b/toboggan-ios/build.rs @@ -0,0 +1,4 @@ +#[allow(clippy::unwrap_used)] +fn main() { + uniffi::generate_scaffolding("src/toboggan.udl").unwrap(); +} diff --git a/toboggan-ios/build.sh b/toboggan-ios/build.sh new file mode 100755 index 0000000..341b417 --- /dev/null +++ b/toboggan-ios/build.sh @@ -0,0 +1,70 @@ +#!/bin/bash + +set -eEuvx + +# Configuration +TARGET_DIR="target" +SWIFT_BINDINGS_DIR="$TARGET_DIR/swift" + +# Clean only what needs refreshing (preserve Rust incremental compilation) +echo "๐Ÿงน Cleaning Swift bindings and XCFramework..." +rm -rf "$SWIFT_BINDINGS_DIR" + +# Build for iOS targets +echo "๐Ÿ”จ Building Rust library for iOS targets..." + +# Add iOS targets if not already installed +rustup target add aarch64-apple-ios +rustup target add x86_64-apple-ios +rustup target add aarch64-apple-ios-sim + +# Build for iOS device (arm64) +echo "๐Ÿ“ฑ Building for iOS device (aarch64-apple-ios)..." +cargo build --quiet --release --target aarch64-apple-ios + +# Build for iOS simulator (x86_64 and arm64) +echo "๐Ÿ–ฅ๏ธ Building for iOS Simulator (x86_64-apple-ios)..." +cargo build --quiet --release --target x86_64-apple-ios + +echo "๐Ÿ–ฅ๏ธ Building for iOS Simulator (aarch64-apple-ios-sim)..." +cargo build --quiet --release --target aarch64-apple-ios-sim + +# Generate Swift bindings +echo "๐Ÿ”— Generating Swift bindings..." +mkdir -p "$SWIFT_BINDINGS_DIR" + +# Build uniffi-bindgen for host system first +echo "๐Ÿ”ง Building uniffi-bindgen for host system..." +cargo build --release --bin uniffi-bindgen + +# # CRITICAL: Generate from EXACT SAME library that will be linked +# # This ensures scaffolding and library have matching checksums +# cargo run --release --bin uniffi-bindgen -- \ +# generate \ +# --library target/aarch64-apple-ios/release/libtoboggan.a \ +# --language swift \ +# --out-dir "$SWIFT_BINDINGS_DIR" + +# # Create universal library for simulator +# echo "๐Ÿ”„ Creating universal simulator library..." +# lipo -create \ +# target/x86_64-apple-ios/release/libtoboggan.a \ +# target/aarch64-apple-ios-sim/release/libtoboggan.a \ +# -output target/libtoboggan_sim.a + +# # Note: XCFramework creation removed - using individual files approach per Mozilla UniFFI pattern + +echo "โœ… Build complete!" +echo "๐Ÿ”— Swift bindings available in: $SWIFT_BINDINGS_DIR" +# echo "๐Ÿ“ฑ Universal simulator library: target/aarch64-apple-ios-sim/release/libtoboggan.a" +echo "๐Ÿ“ฑ iOS device library: target/aarch64-apple-ios/release/libtoboggan.a" + +# Test the Swift bindings generation +echo "๐Ÿงช Testing Swift bindings compilation..." +cd "$SWIFT_BINDINGS_DIR" +if command -v swiftc &> /dev/null; then + swiftc -parse *.swift + echo "โœ… Swift bindings compiled successfully" +else + echo "โš ๏ธ swiftc not found, skipping Swift compilation test" +fi \ No newline at end of file diff --git a/toboggan-ios/examples/test_ios.rs b/toboggan-ios/examples/test_ios.rs new file mode 100644 index 0000000..e657f0c --- /dev/null +++ b/toboggan-ios/examples/test_ios.rs @@ -0,0 +1,60 @@ +#![allow(clippy::print_stdout, clippy::print_stderr)] + +use std::sync::Arc; +use std::thread; +use std::time::Duration; + +use toboggan::{ClientConfig, ClientNotificationHandler, ConnectionStatus, State, TobogganClient}; + +fn main() { + let config = ClientConfig { + url: "http://localhost:8080".to_string(), + max_retries: 10, + retry_delay: Duration::from_secs(1), + }; + + let handler = Arc::new(NotificationHandler); + let client = TobogganClient::new(config, handler); + + client.connect(); + println!("state: {:?}", client.get_state()); + println!("talk: {:?}", client.get_talk()); + + thread::sleep(Duration::from_secs(1)); + thread::spawn(move || { + client.send_command(toboggan::Command::First); + + for _ in 0..10 { + client.send_command(toboggan::Command::Next); + thread::sleep(Duration::from_millis(200)); + } + + client.send_command(toboggan::Command::Blink); + thread::sleep(Duration::from_millis(200)); + client.send_command(toboggan::Command::Blink); + }); + + print!("โณ Waiting 20s..."); + thread::sleep(Duration::from_secs(20)); + print!("๐Ÿ‘‹ Bye"); +} + +struct NotificationHandler; + +impl ClientNotificationHandler for NotificationHandler { + fn on_state_change(&self, state: State) { + println!("๐Ÿ—ฟ {state:?}"); + } + + fn on_talk_change(&self, state: State) { + println!("๐Ÿ“ Talk changed: {state:?}"); + } + + fn on_connection_status_change(&self, status: ConnectionStatus) { + println!("๐Ÿ›œ {status:?}"); + } + + fn on_error(&self, error: String) { + eprintln!("๐Ÿšจ {error}"); + } +} diff --git a/toboggan-ios/rustfmt.toml b/toboggan-ios/rustfmt.toml new file mode 100644 index 0000000..2a732ff --- /dev/null +++ b/toboggan-ios/rustfmt.toml @@ -0,0 +1,5 @@ +# Rustfmt configuration +edition = "2024" +unstable_features = true +imports_granularity = "Module" +group_imports = "StdExternalCrate" \ No newline at end of file diff --git a/toboggan-ios/src/client.rs b/toboggan-ios/src/client.rs new file mode 100644 index 0000000..3740081 --- /dev/null +++ b/toboggan-ios/src/client.rs @@ -0,0 +1,270 @@ +#![allow(clippy::print_stdout, clippy::missing_panics_doc, clippy::expect_used)] + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use toboggan_client::{ + CommunicationMessage, TobogganApi, TobogganWebsocketConfig, WebSocketClient, +}; +use toboggan_core::{ClientId, Command as CoreCommand}; +use tokio::runtime::Runtime; +use tokio::sync::{Mutex, mpsc}; + +use super::{ClientNotificationHandler, Command, Slide, State, Talk}; + +/// The toboggan client +#[derive(Debug, Clone, uniffi::Record)] +pub struct ClientConfig { + /// The server URL, like `http://localhost:8080` + pub url: String, + + /// The maximum number of retry if the connection is not working + pub max_retries: u32, + + /// The delay between retries + pub retry_delay: Duration, +} + +#[derive(uniffi::Object)] +pub struct TobogganClient { + talk: Arc>>, + state: Arc>>, + slides: Arc>>, + is_connected: Arc, + + handler: Arc, + + api: TobogganApi, + tx: mpsc::UnboundedSender, + ws: Arc>, + rx_msg: Arc>>, + + runtime: Runtime, +} + +impl TobogganClient { + async fn read_incoming_messages( + handler: Arc, + api: TobogganApi, + shared_talk: Arc>>, + shared_slides: Arc>>, + shared_state: Arc>>, + is_connected: Arc, + rx: &mut mpsc::UnboundedReceiver, + ) { + while let Some(msg) = rx.recv().await { + println!("๐Ÿฆ€ Receiving: {msg:?}"); + match msg { + CommunicationMessage::ConnectionStatusChange { status } => { + let connected = matches!(status, toboggan_client::ConnectionStatus::Connected); + is_connected.store(connected, Ordering::Relaxed); + handler.on_connection_status_change(status.into()); + } + CommunicationMessage::StateChange { state: new_state } => { + // Get current total_slides from shared slides + let total_slides = shared_slides.lock().await.len(); + let state_value = State::new(total_slides, &new_state); + { + let mut state_guard = shared_state.lock().await; + *state_guard = Some(state_value.clone()); + } + handler.on_state_change(state_value); + } + CommunicationMessage::TalkChange { state: new_state } => { + println!("๐Ÿ“ Presentation updated - refetching talk and slides"); + + // Refetch talk and slides from server + match tokio::try_join!(api.talk(), api.slides()) { + Ok((new_talk, new_slides)) => { + println!("โœ… Talk and slides refetched successfully"); + + // Update talk + { + let mut talk_guard = shared_talk.lock().await; + *talk_guard = Some(new_talk.into()); + } + + // Update slides + let total_slides = new_slides.slides.len(); + { + let mut slides_guard = shared_slides.lock().await; + slides_guard.clear(); + for slide in new_slides.slides { + slides_guard.push(slide.into()); + } + } + + // Create state with correct total_slides and update + let state_value = State::new(total_slides, &new_state); + { + let mut state_guard = shared_state.lock().await; + *state_guard = Some(state_value.clone()); + } + handler.on_talk_change(state_value); + } + Err(err) => { + println!("๐Ÿšจ Failed to refetch talk and slides: {err}"); + // Still update state even if refetch failed + let total_slides = shared_slides.lock().await.len(); + let state_value = State::new(total_slides, &new_state); + { + let mut state_guard = shared_state.lock().await; + *state_guard = Some(state_value.clone()); + } + handler.on_talk_change(state_value); + } + } + } + CommunicationMessage::Error { error } => { + handler.on_error(error); + } + } + } + } +} + +#[uniffi::export] +impl TobogganClient { + #[uniffi::constructor] + pub fn new(config: ClientConfig, handler: Arc) -> Self { + println!("๐Ÿฆ€ using {config:#?}"); + let ClientConfig { + url, + max_retries, + retry_delay, + } = config; + let api = TobogganApi::new(url.trim_end_matches('/')); + let client_id = ClientId::new(); + + let websocket_url = if url.starts_with("http://") { + format!("ws://{}/api/ws", url.trim_start_matches("http://")) + } else if url.starts_with("https://") { + format!("wss://{}/api/ws", url.trim_start_matches("https://")) + } else { + panic!("invalid url '{url}', expected 'http(s)://:'"); + }; + + let websocket = TobogganWebsocketConfig { + websocket_url, + max_retries: max_retries as usize, + retry_delay, + max_retry_delay: retry_delay * max_retries, + }; + let (tx, rx) = mpsc::unbounded_channel(); + let (ws, rx_msg) = WebSocketClient::new(tx.clone(), rx, client_id, websocket); + + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("having a tokio runtime"); + + Self { + talk: Arc::default(), + state: Arc::default(), + slides: Arc::default(), + is_connected: Arc::default(), + handler, + api, + tx, + ws: Arc::new(Mutex::new(ws)), + rx_msg: Arc::new(Mutex::new(rx_msg)), + runtime, + } + } + + pub fn connect(&self) { + self.runtime.block_on({ + let api = self.api.clone(); + let shared_talk = Arc::clone(&self.talk); + let shared_slides = Arc::clone(&self.slides); + let shared_ws = Arc::clone(&self.ws); + let handler = Arc::clone(&self.handler); + let rx_msg = Arc::clone(&self.rx_msg); + + async move { + // Loading talk + { + let tk = api.talk().await.expect("find a talk"); + println!("๐Ÿฆ€ talk: {tk:#?}"); + let mut talk: tokio::sync::MutexGuard<'_, Option> = + shared_talk.lock().await; + *talk = Some(tk.into()); + } + + // Loading slides + { + let slides = api.slides().await.expect("find a talk").slides; + println!("๐Ÿฆ€ count slides: {}", slides.len()); + let mut sld = shared_slides.lock().await; + for slide in slides { + sld.push(slide.into()); + } + } + + // Connect to WebSocket + let mut ws = shared_ws.lock().await; + ws.connect().await; + println!("๐Ÿฆ€ connected"); + + // Reading incoming messages + let state_for_messages = Arc::clone(&self.state); + let is_connected_for_messages = Arc::clone(&self.is_connected); + let talk_for_messages = Arc::clone(&shared_talk); + let slides_for_messages = Arc::clone(&shared_slides); + let api_for_messages = api.clone(); + tokio::spawn(async move { + let mut rx = rx_msg.lock().await; + Self::read_incoming_messages( + handler, + api_for_messages, + talk_for_messages, + slides_for_messages, + state_for_messages, + is_connected_for_messages, + &mut rx, + ) + .await; + }); + } + }); + } + + #[must_use] + pub fn is_connected(&self) -> bool { + self.is_connected.load(Ordering::Relaxed) + } + + pub fn send_command(&self, command: Command) { + if let Err(error) = self.tx.send(command.into()) { + println!("๐Ÿฆ€ Error sending command {command:?}: {error:#?}"); + } + } + + #[must_use] + pub fn get_state(&self) -> Option { + let state = Arc::clone(&self.state); + self.runtime.block_on(async move { + let st = state.lock().await; + st.as_ref().cloned() + }) + } + + #[must_use] + pub fn get_slide(&self, index: u32) -> Option { + let slides = Arc::clone(&self.slides); + self.runtime.block_on(async move { + let st = slides.lock().await; + st.get(index as usize).cloned() + }) + } + + #[must_use] + pub fn get_talk(&self) -> Option { + let talk = Arc::clone(&self.talk); + self.runtime.block_on(async move { + let st = talk.lock().await; + st.as_ref().cloned() + }) + } +} diff --git a/toboggan-ios/src/lib.rs b/toboggan-ios/src/lib.rs new file mode 100644 index 0000000..4da61d1 --- /dev/null +++ b/toboggan-ios/src/lib.rs @@ -0,0 +1,13 @@ +uniffi::setup_scaffolding!("toboggan"); + +mod slide; +pub use self::slide::*; + +mod talk; +pub use self::talk::*; + +mod client; +pub use self::client::*; + +mod notif; +pub use self::notif::*; diff --git a/toboggan-ios/src/main.rs b/toboggan-ios/src/main.rs deleted file mode 100644 index e7a11a9..0000000 --- a/toboggan-ios/src/main.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - println!("Hello, world!"); -} diff --git a/toboggan-ios/src/notif.rs b/toboggan-ios/src/notif.rs new file mode 100644 index 0000000..58a0610 --- /dev/null +++ b/toboggan-ios/src/notif.rs @@ -0,0 +1,32 @@ +use toboggan_client::ConnectionStatus as CoreConnectionStatus; + +use crate::State; + +#[derive(Debug, Clone, Copy, uniffi::Enum)] +pub enum ConnectionStatus { + Connecting, + Connected, + Closed, + Reconnecting, + Error, +} + +impl From for ConnectionStatus { + fn from(value: CoreConnectionStatus) -> Self { + match value { + CoreConnectionStatus::Connecting => Self::Connecting, + CoreConnectionStatus::Connected => Self::Connected, + CoreConnectionStatus::Closed => Self::Closed, + CoreConnectionStatus::Reconnecting { .. } => Self::Reconnecting, + CoreConnectionStatus::Error { .. } => Self::Error, + } + } +} + +#[uniffi::export(with_foreign)] +pub trait ClientNotificationHandler: Send + Sync { + fn on_state_change(&self, state: State); + fn on_talk_change(&self, state: State); + fn on_connection_status_change(&self, status: ConnectionStatus); + fn on_error(&self, error: String); +} diff --git a/toboggan-ios/src/slide.rs b/toboggan-ios/src/slide.rs new file mode 100644 index 0000000..6f4d3b9 --- /dev/null +++ b/toboggan-ios/src/slide.rs @@ -0,0 +1,33 @@ +use toboggan_core::{Slide as CoreSlide, SlideKind as CoreSlideKind}; + +/// A slide kind +#[derive(Debug, Clone, Copy, uniffi::Enum)] +pub enum SlideKind { + /// Cover + Cover, + /// Part header + Part, + /// Standard + Standard, +} + +/// A slide +#[derive(Debug, Clone, uniffi::Record)] +pub struct Slide { + pub title: String, + pub kind: SlideKind, +} + +impl From for Slide { + fn from(value: CoreSlide) -> Self { + let CoreSlide { kind, title, .. } = value; + Self { + title: title.to_string(), + kind: match kind { + CoreSlideKind::Cover => SlideKind::Cover, + CoreSlideKind::Part => SlideKind::Part, + CoreSlideKind::Standard => SlideKind::Standard, + }, + } + } +} diff --git a/toboggan-ios/src/talk.rs b/toboggan-ios/src/talk.rs new file mode 100644 index 0000000..b4dc004 --- /dev/null +++ b/toboggan-ios/src/talk.rs @@ -0,0 +1,134 @@ +use std::time::Duration; + +use toboggan_core::{Command as CoreCommand, State as CoreState, TalkResponse}; + +/// A talk +#[derive(Debug, Clone, uniffi::Record)] +pub struct Talk { + pub title: String, + pub date: String, + pub slides: Vec, +} + +impl From for Talk { + fn from(value: TalkResponse) -> Self { + let TalkResponse { + title, + date, + titles, + .. + } = value; + + Self { + title: title.clone(), + date: date.to_string(), + slides: titles, + } + } +} + +#[derive(Debug, Clone, uniffi::Enum)] +pub enum State { + Init { + total_slides: u32, + }, + Running { + previous: Option, + current: u32, + next: Option, + total_duration: Duration, + }, + Paused { + previous: Option, + current: u32, + next: Option, + total_duration: Duration, + }, + Done { + previous: Option, + current: u32, + total_duration: Duration, + }, +} + +impl State { + pub(crate) fn new(total_slides: usize, value: &CoreState) -> Self { + assert!(total_slides > 0, "total_slides must be greater than 0"); + #[allow(clippy::cast_possible_truncation)] + // UniFFI requires u32, truncation unlikely for slide counts + let total_slides_u32 = total_slides as u32; + + match *value { + CoreState::Init => Self::Init { + total_slides: total_slides_u32, + }, + CoreState::Paused { + current, + total_duration, + } => { + #[allow(clippy::cast_possible_truncation, clippy::expect_used)] + // UniFFI requires u32, slide indices are typically small + let current_index = current.expect("should have a current index") as u32; + Self::Paused { + previous: (current_index > 0).then(|| current_index - 1), + current: current_index, + next: ((current_index as usize) < total_slides - 1).then(|| current_index + 1), + total_duration: total_duration.into(), + } + } + CoreState::Running { + current, + total_duration, + .. + } => { + #[allow(clippy::cast_possible_truncation)] + // UniFFI requires u32, slide indices are typically small + let current_index = current as u32; + Self::Running { + previous: (current_index > 0).then(|| current_index - 1), + current: current_index, + next: ((current_index as usize) < total_slides - 1).then(|| current_index + 1), + total_duration: total_duration.into(), + } + } + CoreState::Done { + current, + total_duration, + } => { + #[allow(clippy::cast_possible_truncation)] + // UniFFI requires u32, slide indices are typically small + let current_index = current as u32; + Self::Done { + previous: (current_index > 0).then(|| current_index - 1), + current: current_index, + total_duration: total_duration.into(), + } + } + } + } +} + +#[derive(Debug, Clone, Copy, uniffi::Enum)] +pub enum Command { + Next, + Previous, + First, + Last, + Pause, + Resume, + Blink, +} + +impl From for CoreCommand { + fn from(value: Command) -> Self { + match value { + Command::Next => Self::Next, + Command::Previous => Self::Previous, + Command::First => Self::First, + Command::Last => Self::Last, + Command::Resume => Self::Resume, + Command::Pause => Self::Pause, + Command::Blink => Self::Blink, + } + } +} diff --git a/toboggan-ios/src/toboggan.udl b/toboggan-ios/src/toboggan.udl new file mode 100644 index 0000000..e88e4b5 --- /dev/null +++ b/toboggan-ios/src/toboggan.udl @@ -0,0 +1,73 @@ +namespace toboggan {}; + +dictionary ClientConfig { + string url; + u32 max_retries; + duration retry_delay; +}; + + +enum SlideKind { + "Cover", + "Part", + "Standard", +}; + +dictionary Slide { + string title; + SlideKind kind; +}; + +dictionary Talk { + string title; + string date; + sequence slides; +}; + +enum ConnectionStatus { + "Connecting", + "Connected", + "Closed", + "Reconnecting", + "Error", +}; + +enum Command { + "Next", + "Previous", + "First", + "Last", + "Resume", + "Pause", + "Blink", +}; + +[Enum] +interface State { + Init(string next); + Running(u32? previous, u32 current, u32? next, duration total_duration); + Paused(u32? previous, u32 current, u32? next, duration total_duration); + Done(u32? previous, u32 current, duration total_duration); +}; + +[Trait, WithForeign] +interface ClientNotificationHandler { + void on_state_change(State state); + void on_talk_change(State state); + void on_connection_status_change(ConnectionStatus status); + void on_error(string error); +}; + +interface TobogganClient { + constructor(ClientConfig config, ClientNotificationHandler handler); + + void connect(); + + void send_command(Command command); + + State? get_state(); + Slide? get_slide(u32 slide_id); + Talk? get_talk(); + + boolean is_connected(); +}; diff --git a/toboggan-ios/uniffi-bindgen-swift.rs b/toboggan-ios/uniffi-bindgen-swift.rs new file mode 100644 index 0000000..b1b0d73 --- /dev/null +++ b/toboggan-ios/uniffi-bindgen-swift.rs @@ -0,0 +1,3 @@ +fn main() { + uniffi::uniffi_bindgen_swift(); +} diff --git a/toboggan-ios/uniffi-bindgen.rs b/toboggan-ios/uniffi-bindgen.rs new file mode 100644 index 0000000..a01b547 --- /dev/null +++ b/toboggan-ios/uniffi-bindgen.rs @@ -0,0 +1,3 @@ +fn main() { + uniffi::uniffi_bindgen_main(); +} diff --git a/toboggan-py/.github/workflows/CI.yml b/toboggan-py/.github/workflows/CI.yml new file mode 100644 index 0000000..cd89184 --- /dev/null +++ b/toboggan-py/.github/workflows/CI.yml @@ -0,0 +1,181 @@ +# This file is autogenerated by maturin v1.9.6 +# To update, run +# +# maturin generate-ci github +# +name: CI + +on: + push: + branches: + - main + - master + tags: + - '*' + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + linux: + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: ubuntu-22.04 + target: x86_64 + - runner: ubuntu-22.04 + target: x86 + - runner: ubuntu-22.04 + target: aarch64 + - runner: ubuntu-22.04 + target: armv7 + - runner: ubuntu-22.04 + target: s390x + - runner: ubuntu-22.04 + target: ppc64le + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist --find-interpreter + sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} + manylinux: auto + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-linux-${{ matrix.platform.target }} + path: dist + + musllinux: + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: ubuntu-22.04 + target: x86_64 + - runner: ubuntu-22.04 + target: x86 + - runner: ubuntu-22.04 + target: aarch64 + - runner: ubuntu-22.04 + target: armv7 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist --find-interpreter + sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} + manylinux: musllinux_1_2 + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-musllinux-${{ matrix.platform.target }} + path: dist + + windows: + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: windows-latest + target: x64 + - runner: windows-latest + target: x86 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.x + architecture: ${{ matrix.platform.target }} + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist --find-interpreter + sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-windows-${{ matrix.platform.target }} + path: dist + + macos: + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: macos-13 + target: x86_64 + - runner: macos-14 + target: aarch64 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist --find-interpreter + sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-macos-${{ matrix.platform.target }} + path: dist + + sdist: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build sdist + uses: PyO3/maturin-action@v1 + with: + command: sdist + args: --out dist + - name: Upload sdist + uses: actions/upload-artifact@v4 + with: + name: wheels-sdist + path: dist + + release: + name: Release + runs-on: ubuntu-latest + if: ${{ startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' }} + needs: [linux, musllinux, windows, macos, sdist] + permissions: + # Use to sign the release artifacts + id-token: write + # Used to upload release artifacts + contents: write + # Used to generate artifact attestation + attestations: write + steps: + - uses: actions/download-artifact@v4 + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v2 + with: + subject-path: 'wheels-*/*' + - name: Publish to PyPI + if: ${{ startsWith(github.ref, 'refs/tags/') }} + uses: PyO3/maturin-action@v1 + env: + MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} + with: + command: upload + args: --non-interactive --skip-existing wheels-*/* diff --git a/toboggan-py/.gitignore b/toboggan-py/.gitignore new file mode 100644 index 0000000..c8f0442 --- /dev/null +++ b/toboggan-py/.gitignore @@ -0,0 +1,72 @@ +/target + +# Byte-compiled / optimized / DLL files +__pycache__/ +.pytest_cache/ +*.py[cod] + +# C extensions +*.so + +# Distribution / packaging +.Python +.venv/ +env/ +bin/ +build/ +develop-eggs/ +dist/ +eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +include/ +man/ +venv/ +*.egg-info/ +.installed.cfg +*.egg + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt +pip-selfcheck.json + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.cache +nosetests.xml +coverage.xml + +# Translations +*.mo + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# Rope +.ropeproject + +# Django stuff: +*.log +*.pot + +.DS_Store + +# Sphinx documentation +docs/_build/ + +# PyCharm +.idea/ + +# VSCode +.vscode/ + +# Pyenv +.python-version diff --git a/toboggan-py/Cargo.lock b/toboggan-py/Cargo.lock new file mode 100644 index 0000000..db5fe1e --- /dev/null +++ b/toboggan-py/Cargo.lock @@ -0,0 +1,2003 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "cc" +version = "1.2.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37521ac7aabe3d13122dc382493e20c9416f299d2ccd5b3a5340a2570cdeb0f3" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + +[[package]] +name = "derive_more" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "h2" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "hyper" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "jiff" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde", +] + +[[package]] +name = "jiff-static" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "js-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "openssl" +version = "0.10.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.110" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37a6df7eab65fc7bee654a421404947e10a0f7085b6951bf2ea395f4659fb0cf" +dependencies = [ + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f77d387774f6f6eec64a004eac0ed525aab7fa1966d94b42f743797b3e395afb" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dd13844a4242793e02df3e2ec093f540d948299a6a77ea9ce7afd8623f542be" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaf8f9f1108270b90d3676b8679586385430e5c0bb78bb5f043f95499c821a71" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a3b2274450ba5288bc9b8c1b69ff569d1d61189d4bff38f8d22e03d17f932b" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "reqwest" +version = "0.12.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a9586e9ee2b4f8fab52a0048ca7334d7024eef48e2cb9407e3497bb7cab7fa7" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "target-lexicon" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "toboggan-client" +version = "0.1.0" +dependencies = [ + "derive_more", + "futures", + "reqwest", + "serde", + "serde_json", + "toboggan-core", + "tokio", + "tokio-tungstenite", + "tracing", +] + +[[package]] +name = "toboggan-core" +version = "0.1.0" +dependencies = [ + "derive_more", + "getrandom 0.3.4", + "humantime", + "jiff", + "serde", + "tracing", + "uuid", +] + +[[package]] +name = "toboggan-py" +version = "0.1.0" +dependencies = [ + "pyo3", + "toboggan-client", + "toboggan-core", + "tokio", +] + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +dependencies = [ + "getrandom 0.3.4", + "serde", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/toboggan-py/Cargo.toml b/toboggan-py/Cargo.toml new file mode 100644 index 0000000..3b6333c --- /dev/null +++ b/toboggan-py/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "toboggan-py" +version = "0.1.0" +description = "Python bindings for Toboggan presentations using PyO3" +edition = "2024" +rust-version = "1.88" +license = "MIT OR Apache-2.0" +authors = ["Igor Laborie "] +repository = "https://github.com/ilaborie/toboggan" + +[lib] +name = "toboggan_py" +crate-type = ["cdylib"] + +[dependencies] +toboggan-core = { path = "../toboggan-core", default_feature = false, features = ["std"] } +toboggan-client = { path = "../toboggan-client" } + +pyo3 = {version = "0.27.1", features = ["abi3-py38"] } +tokio = { version = "1.48.0", features = ["rt-multi-thread", "sync", "macros"] } diff --git a/toboggan-py/README.md b/toboggan-py/README.md new file mode 100644 index 0000000..42a9273 --- /dev/null +++ b/toboggan-py/README.md @@ -0,0 +1,70 @@ +# toboggan-py + +Python bindings for the Toboggan presentation system, enabling real-time multi-client synchronization. + +Built with Rust using PyO3 and Maturin for high-performance native Python extensions. + +## Features + +- Real-time WebSocket-based presentation synchronization +- Cross-client state sharing (navigation, playback control) +- Async architecture with Tokio runtime +- Type-safe Python API with full type stubs +- ABI3 wheels for forward compatibility (Python 3.8+) + +## Requirements + +- Python 3.8 or higher +- Rust toolchain (for building from source) +- Running Toboggan server (see main repository) + +## Installation + +```bash +# Create virtual environment and install maturin +python -m venv .venv && source .venv/bin/activate +pip install maturin + +# Build and install (development mode) +maturin develop +``` + +## Quick Start + +```python +from toboggan_py import Toboggan + +client = Toboggan("localhost", 8080) + +# Access metadata and navigate +print(f"Talk: {client.talk}, Slides: {client.slides}, State: {client.state}") +client.next() # Navigate to next slide +client.previous() # Navigate to previous slide +``` + +## API Reference + +### `Toboggan(host="localhost", port=8080)` + +**Properties:** `talk`, `slides`, `state` (presentation metadata and synchronized state) +**Methods:** `next()`, `previous()` (slide navigation) + +Type stubs in `toboggan_py.pyi` provide full IDE support and type checking. + +## Development + +```bash +cargo fmt && cargo clippy # Format and lint +maturin develop && python example.py # Build and test +maturin build --release # Build release wheel +``` + +## Troubleshooting + +- **Connection fails:** Ensure server is running. Check `http://localhost:8080/health` +- **Build fails:** Verify Rust is installed: `rustc --version` (update with `rustup update`) +- **Import error:** Rebuild with `maturin develop` after code changes + +## License + +MIT OR Apache-2.0 diff --git a/toboggan-py/example.py b/toboggan-py/example.py new file mode 100644 index 0000000..1d4c31a --- /dev/null +++ b/toboggan-py/example.py @@ -0,0 +1,15 @@ +from time import sleep +from toboggan_py import Toboggan + +tbg = Toboggan("localhost", 8080) + +print(f"toboggan: {tbg}") +print(f"state: {tbg.state}") + +tbg.previous() +sleep(1) +print(f"state after previous: {tbg.state}") + +tbg.next() +sleep(1) +print(f"state after next: {tbg.state}") diff --git a/toboggan-py/pyproject.toml b/toboggan-py/pyproject.toml new file mode 100644 index 0000000..c618a1f --- /dev/null +++ b/toboggan-py/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["maturin>=1.9,<2.0"] +build-backend = "maturin" + +[project] +name = "toboggan-py" +requires-python = ">=3.8" +classifiers = [ + "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] +dynamic = ["version"] + +[tool.maturin] +features = ["pyo3/extension-module"] diff --git a/toboggan-py/src/lib.rs b/toboggan-py/src/lib.rs new file mode 100644 index 0000000..4895d8e --- /dev/null +++ b/toboggan-py/src/lib.rs @@ -0,0 +1,247 @@ +use std::sync::Arc; + +use pyo3::{exceptions::PyConnectionError, prelude::*}; +use tokio::runtime::Runtime; +use tokio::sync::RwLock; +use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; +use tokio::try_join; + +use toboggan_client::{CommunicationMessage, TobogganApi, TobogganConfig, WebSocketClient}; +use toboggan_core::{ClientConfig as _, Command, SlidesResponse, State as TState, TalkResponse}; + +/// Toboggan for Python +#[pymodule] +fn toboggan_py(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + + Ok(()) +} + +/// Presentation metadata. +#[pyclass] +pub struct Talk(TalkResponse); + +#[pymethods] +impl Talk { + fn __repr__(&self) -> String { + format!("{:?}", self.0) + } + + fn __str__(&self) -> String { + self.0.title.clone() + } +} + +/// Collection of slides in the presentation. +#[pyclass] +pub struct Slides(SlidesResponse); + +#[pymethods] +impl Slides { + fn __str__(&self) -> String { + let titles = self + .0 + .slides + .iter() + .map(|slide| slide.to_string()) + .collect::>(); + format!("{titles:?}") + } +} + +/// Current presentation state. +#[pyclass] +pub struct State(TState); + +#[pymethods] +impl State { + fn __repr__(&self) -> String { + format!("{:?}", self.0) + } +} + +/// Toboggan presentation client. +#[pyclass] +struct Toboggan { + config: TobogganConfig, + rt: Runtime, + _ws: WebSocketClient, + tx: UnboundedSender, + talk: Arc>, + slides: Arc>, + state: Arc>, +} + +impl Toboggan { + fn send(&self, command: Command) { + if let Err(err) = self.tx.send(command) { + eprintln!("๐Ÿšจ Oops, fail to send: {err}"); + } + } +} + +#[pymethods] +impl Toboggan { + /// Creates a new Toboggan client and connects to the server. + #[new] + #[pyo3(signature = (host = "localhost", port = 8080))] + pub fn __new__(host: &str, port: u16) -> PyResult { + let config = TobogganConfig::new(host, port); + + let api_url = config.api_url(); + let api = TobogganApi::new(api_url); + + let client_id = config.client_id(); + let ws_config = config.websocket(); + let (tx, rx) = mpsc::unbounded_channel(); + let (mut ws, rx_msg) = WebSocketClient::new(tx.clone(), rx, client_id, ws_config); + + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + + let state = Arc::>::default(); + let talk = Arc::>::default(); + let slides = Arc::>::default(); + + let (initial_talk, initial_slides) = rt + .block_on(async { + let _read_messages = tokio::spawn(handle_state( + Arc::clone(&state), + Arc::clone(&talk), + Arc::clone(&slides), + api.clone(), + rx_msg, + )); + ws.connect().await; + try_join!(api.talk(), api.slides()) + }) + .map_err(|err| PyConnectionError::new_err(err.to_string()))?; + + // Initialize talk and slides + rt.block_on(async { + *talk.write().await = initial_talk; + *slides.write().await = initial_slides; + }); + + Ok(Self { + rt, + config, + _ws: ws, + tx, + talk, + slides, + state, + }) + } + + /// Gets the presentation metadata. + #[getter] + pub fn talk(&self) -> PyResult { + let talk = Arc::clone(&self.talk); + let talk = self.rt.block_on(async { + let guard = talk.read().await; + TalkResponse::clone(&guard) + }); + Ok(Talk(talk)) + } + + /// Gets all slides in the presentation. + #[getter] + pub fn slides(&self) -> PyResult { + let slides = Arc::clone(&self.slides); + let slides = self.rt.block_on(async { + let guard = slides.read().await; + SlidesResponse::clone(&guard) + }); + Ok(Slides(slides)) + } + + /// Gets the current presentation state. + #[getter] + pub fn state(&self) -> PyResult { + let state = Arc::clone(&self.state); + let state = self.rt.block_on(async { + let guard = state.read().await; + TState::clone(&guard) + }); + + Ok(State(state)) + } + + /// Navigates to the previous slide. + pub fn previous(&self) { + self.send(Command::Previous); + } + + /// Navigates to the next slide. + pub fn next(&self) { + self.send(Command::Next); + } + + pub fn __repr__(&self) -> String { + format!("Toboggan({:?})", self.config) + } + + pub fn __str__(&self) -> String { + format!("Toboggan({})", self.config.api_url()) + } +} + +async fn handle_state( + state: Arc>, + talk: Arc>, + slides: Arc>, + api: TobogganApi, + mut rx: UnboundedReceiver, +) { + println!(">>> Start listening incoming messages"); + while let Some(msg) = rx.recv().await { + match msg { + CommunicationMessage::ConnectionStatusChange { status } => { + println!("๐Ÿ“ก {status}"); + } + CommunicationMessage::StateChange { state: new_state } => { + let mut st = state.write().await; + *st = new_state; + } + CommunicationMessage::TalkChange { state: new_state } => { + println!("๐Ÿ“ Presentation updated - refetching talk and slides"); + + // Refetch talk and slides from server + match try_join!(api.talk(), api.slides()) { + Ok((new_talk, new_slides)) => { + // Update talk and slides atomically + { + let mut talk_guard = talk.write().await; + *talk_guard = new_talk; + } + { + let mut slides_guard = slides.write().await; + *slides_guard = new_slides; + } + // Update state after data is refreshed + { + let mut st = state.write().await; + *st = new_state; + } + println!("โœ… Talk and slides updated successfully"); + } + Err(err) => { + eprintln!("๐Ÿšจ Failed to refetch talk and slides: {err}"); + // Still update state even if refetch failed + let mut st = state.write().await; + *st = new_state; + } + } + } + CommunicationMessage::Error { error } => { + eprintln!("๐Ÿšจ Oops: {error}"); + } + } + } + println!("<<< End listening incoming messages"); +} diff --git a/toboggan-py/toboggan_py.pyi b/toboggan-py/toboggan_py.pyi new file mode 100644 index 0000000..e885032 --- /dev/null +++ b/toboggan-py/toboggan_py.pyi @@ -0,0 +1,162 @@ +"""Toboggan for Python. + +This module provides Python bindings for the Toboggan presentation system, +enabling real-time multi-client synchronization via WebSocket connections. +""" + +from typing import final + +__all__ = ["Talk", "Slides", "State", "Toboggan"] + +@final +class Talk: + """Presentation metadata. + + Contains information about the presentation including title, date, + optional footer content, and a list of all slide titles. + + Note: + This class cannot be instantiated directly. Obtain instances + via the `Toboggan.talk` property. + """ + + def __repr__(self) -> str: + """Returns a detailed string representation of the talk metadata.""" + ... + + def __str__(self) -> str: + """Returns the presentation title.""" + ... + +@final +class Slides: + """Collection of slides in the presentation. + + Contains all slides with their content, metadata, and ordering. + Slides can include text, HTML, markdown, iframes, and layout containers. + + Note: + This class cannot be instantiated directly. Obtain instances + via the `Toboggan.slides` property. + """ + + def __str__(self) -> str: + """Returns a list of slide titles.""" + ... + +@final +class State: + """Current presentation state. + + Represents the real-time state of the presentation, synchronized across + all connected clients. The state can be: + - Init: Initial state before presentation starts + - Paused: Presentation is paused with current slide and duration + - Running: Presentation is actively running with current slide and timing + + Note: + This class cannot be instantiated directly. Obtain instances + via the `Toboggan.state` property. + """ + + def __repr__(self) -> str: + """Returns a detailed string representation of the current state.""" + ... + +@final +class Toboggan: + """Toboggan presentation client. + + Main client for connecting to a Toboggan presentation server. + Manages WebSocket communication, state synchronization, and provides + methods for controlling the presentation (navigation, playback). + + The client automatically maintains a persistent connection to the server + and synchronizes state changes across all connected clients in real-time. + + Example: + ```python + from toboggan_py import Toboggan + + # Connect to server + client = Toboggan("localhost", 8080) + + # Access presentation metadata + print(client.talk) + print(client.slides) + + # Navigate slides + client.next() + client.previous() + + # Check current state + print(client.state) + ``` + """ + + def __init__(self, host: str = "localhost", port: int = 8080) -> None: + """Creates a new Toboggan client and connects to the server. + + Args: + host: Server hostname or IP address (default: "localhost") + port: Server port number (default: 8080) + + Raises: + ConnectionError: If connection to server fails or metadata cannot be fetched. + """ + ... + + @property + def talk(self) -> Talk: + """Presentation metadata. + + Returns information about the presentation including title, date, + footer content, and all slide titles. + """ + ... + + @property + def slides(self) -> Slides: + """All slides in the presentation. + + Returns the complete collection of slides with their content, + metadata, and ordering. + """ + ... + + @property + def state(self) -> State: + """Current presentation state. + + Returns the real-time synchronized state showing which slide + is currently displayed and whether the presentation is running, + paused, or in initial state. + + This property reflects the state synchronized across all connected + clients. Changes made by any client will be reflected here. + """ + ... + + def previous(self) -> None: + """Navigates to the previous slide. + + Sends a command to move backward in the presentation. + This change will be synchronized across all connected clients. + """ + ... + + def next(self) -> None: + """Navigates to the next slide. + + Sends a command to move forward in the presentation. + This change will be synchronized across all connected clients. + """ + ... + + def __repr__(self) -> str: + """Returns a detailed string representation of the client.""" + ... + + def __str__(self) -> str: + """Returns a human-readable string representation.""" + ... diff --git a/toboggan-py/uv.lock b/toboggan-py/uv.lock new file mode 100644 index 0000000..7b77d56 --- /dev/null +++ b/toboggan-py/uv.lock @@ -0,0 +1,7 @@ +version = 1 +revision = 3 +requires-python = ">=3.8" + +[[package]] +name = "toboggan-py" +source = { editable = "." } diff --git a/toboggan-server/Cargo.toml b/toboggan-server/Cargo.toml index a824d98..b6f9b30 100644 --- a/toboggan-server/Cargo.toml +++ b/toboggan-server/Cargo.toml @@ -1,6 +1,43 @@ [package] name = "toboggan-server" version = "0.1.0" -edition = "2024" +description = "Axum WebSocket server with REST API and real-time presentation synchronization" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true [dependencies] +toboggan-core = {path = "../toboggan-core", features = ["openapi"]} + +axum = { workspace = true, features = ["ws"] } +tokio = { workspace = true, features = ["full"] } +toml = {workspace = true} +tracing = {workspace = true} +tracing-subscriber = { workspace = true, features = ["env-filter"] } +clap = { workspace = true, features = ["env", "derive"] } +anyhow = {workspace = true} +serde = { workspace = true, features = ["derive"] } +serde_json = {workspace = true} +tower-http = { workspace = true, features = ["trace", "cors", "fs"] } +futures = { workspace = true } +dashmap = { workspace = true } +derive_more = { workspace = true, features = ["display", "error"]} +utoipa = { workspace = true } +utoipa-scalar = { workspace = true, features = ["axum"] } +rust-embed = { workspace = true } +mime_guess = { workspace = true } +notify = { workspace = true } + +[dev-dependencies] +toboggan-core = { path = "../toboggan-core", features = ["openapi", "test-utils"] } +clawspec-core = { workspace = true } +serde = { workspace = true, features = ["derive"] } +tokio-tungstenite = { workspace = true } + +[[test]] +name = "multi_client_sync" +harness = true + +[lints] +workspace = true diff --git a/toboggan-server/README.md b/toboggan-server/README.md new file mode 100644 index 0000000..5109de7 --- /dev/null +++ b/toboggan-server/README.md @@ -0,0 +1,394 @@ +# Toboggan Server + +The Toboggan server is a high-performance, Axum-based web server that serves presentations and provides real-time synchronization across multiple clients via WebSocket. Built with async Rust, it handles concurrent connections efficiently while maintaining presentation state consistency. + +## Features + +- **Real-time Synchronization**: WebSocket-based multi-client communication +- **REST API**: HTTP endpoints for health checks and presentation management +- **Presentation State Machine**: Robust state management with clear transitions +- **Multi-format Support**: Serves TOML presentations with rich content types +- **Concurrent Client Support**: Handles multiple presenters and viewers simultaneously +- **Health Monitoring**: Built-in health checks and monitoring endpoints + +## Quick Start + +### Running the Server + +```bash +# Start server with example presentation +cargo run -p toboggan-server + +# Or with custom presentation +cargo run -p toboggan-server -- path/to/presentation.toml + +# With custom host and port +cargo run -p toboggan-server -- --host 0.0.0.0 --port 3000 presentation.toml +``` + +### Client Connections + +Once running, clients can connect via: +- **Web Interface**: http://localhost:8080 +- **WebSocket**: ws://localhost:8080/api/ws +- **Health Check**: http://localhost:8080/api/health + +## REST API + +### Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/` | Web interface (static files) | +| `GET` | `/api/health` | Server health check | +| `GET` | `/api/ws` | WebSocket upgrade endpoint | +| `GET` | `/api/presentation` | Current presentation metadata | + +### Health Check Response + +```json +{ + "status": "ok", + "timestamp": "2025-01-26T12:00:00Z", + "clients": 3, + "presentation": "My Talk" +} +``` + +## WebSocket Protocol + +The server uses a JSON-based WebSocket protocol for real-time communication with clients. + +### Client โ†’ Server (Commands) + +```json +// Navigation Commands +{ "type": "Next" } +{ "type": "Previous" } +{ "type": "First" } +{ "type": "Last" } +{ "type": "Goto", "slide": 5 } + +// Presentation Control +{ "type": "Play" } +{ "type": "Pause" } +{ "type": "Resume" } + +// Client Management +{ "type": "Register", "client_id": "presenter-abc123" } +{ "type": "Unregister", "client_id": "presenter-abc123" } + +// Heartbeat +{ "type": "Ping", "timestamp": 1735210800000 } +``` + +### Server โ†’ Client (Notifications) + +```json +// State Updates +{ + "type": "State", + "timestamp": 1735210800000, + "state": { + "type": "Running", + "current": 3, + "started": 1735210750000, + "total_duration": 45000 + } +} + +// Error Messages +{ + "type": "Error", + "message": "Invalid slide index: 10" +} + +// Heartbeat Response +{ + "type": "Pong", + "timestamp": 1735210800000 +} +``` + +### Connection Flow + +1. **Client Connects**: WebSocket handshake at `/api/ws` +2. **Registration**: Client sends `Register` command with unique ID +3. **State Sync**: Server sends current presentation state +4. **Command Processing**: Client sends navigation/control commands +5. **State Broadcast**: Server broadcasts state updates to all clients +6. **Heartbeat**: Periodic ping/pong for connection health + +### Error Handling + +The server provides descriptive error messages for invalid commands: + +```json +// Invalid slide index +{ "type": "Error", "message": "Slide index 15 out of range (0-10)" } + +// Command not available in current state +{ "type": "Error", "message": "Cannot play: presentation is already running" } + +// Malformed command +{ "type": "Error", "message": "Invalid command format: missing 'type' field" } +``` + +## Presentation State Machine + +The presentation system uses a state machine with four states: `Init`, `Paused`, `Running`, and `Done`. Below is a diagram showing how commands transition between states: + +```mermaid +stateDiagram-v2 + [*] --> Init: Initial state + + Init --> Running: First/Last/GoTo/Next/Previous + + Paused --> Running: First/Last/GoTo*/Next*/Previous* + Paused --> Paused: Next (on last slide) + Paused --> Paused: Previous (on first slide) + Paused --> Paused: GoTo (to last slide when already on last) + + Running --> Running: First/Last/GoTo/Next/Previous + Running --> Paused: Pause + Running --> Done: Next (on last slide) + + Done --> Paused: Previous/First/Last/GoTo + + note right of Init + All navigation commands from Init + go to first slide and start Running + end note + + note right of Paused + * Navigation from Paused starts Running + except when already on last slide + * **First command resets timestamp to zero** + end note + + note right of Running + Navigation commands update current slide + while maintaining Running state. + **First command resets timestamp to zero.** + end note + + note right of Done + Navigation from Done goes to Paused + to allow resuming the presentation + end note +``` + +## Command Behavior Details + +### From `Init` State +- **All navigation commands** (`First`, `Last`, `GoTo`, `Next`, `Previous`) โ†’ Navigate to **first slide** and transition to `Running` + +### From `Paused` State +- **`First`** โ†’ Navigate to first slide and transition to `Running` +- **`Last`** โ†’ Navigate to last slide and transition to `Running` (unless already on last slide, then stay `Paused`) +- **`GoTo(slide)`** โ†’ Navigate to specified slide and transition to `Running` (unless going to last slide while already on last, then stay `Paused`) +- **`Next`** โ†’ Navigate to next slide and transition to `Running` (unless on last slide, then stay `Paused`) +- **`Previous`** โ†’ Navigate to previous slide and transition to `Running` (unless on last slide, then stay `Paused`) +- **`Resume`** โ†’ Transition to `Running` with current slide + +### From `Running` State +- **`First`** โ†’ Navigate to first slide (stay `Running`) +- **`Last`** โ†’ Navigate to last slide (stay `Running`) +- **`GoTo(slide)`** โ†’ Navigate to specified slide (stay `Running`) +- **`Next`** โ†’ Navigate to next slide (stay `Running`), or transition to `Done` if on last slide +- **`Previous`** โ†’ Navigate to previous slide (stay `Running`) +- **`Pause`** โ†’ Transition to `Paused` with current slide + +### From `Done` State +- **All navigation commands** โ†’ Navigate to requested slide and transition to `Paused` + +## Special Commands +- **`Ping`** โ†’ Returns `Pong` (no state change) +- **`Register`/`Unregister`** โ†’ Handled separately via WebSocket (no state change) + +## State Properties + +Each state maintains different information: +- **`Init`**: No slide information +- **`Paused`**: Current slide + total duration +- **`Running`**: Current slide + start timestamp + total duration +- **`Done`**: Current slide + total duration + +## Configuration + +### Command Line Options + +```bash +toboggan-server [OPTIONS] [PRESENTATION] + +Arguments: + [PRESENTATION] Path to presentation TOML file + +Options: + -h, --host Host to bind to [default: 127.0.0.1] + -p, --port Port to bind to [default: 8080] + --help Print help + --version Print version +``` + +### Environment Variables + +```bash +# Server configuration +export TOBOGGAN_HOST=0.0.0.0 +export TOBOGGAN_PORT=3000 + +# Logging +export RUST_LOG=info # Basic logging +export RUST_LOG=toboggan_server=debug # Debug server logs +export RUST_LOG=trace # Verbose logging +``` + +## Development + +### Building and Testing + +```bash +# Build the server +cargo build -p toboggan-server + +# Run tests +cargo test -p toboggan-server + +# Run with debug logging +RUST_LOG=debug cargo run -p toboggan-server + +# Build optimized release +cargo build -p toboggan-server --release +``` + +### Testing WebSocket Connections + +```bash +# Test WebSocket with websocat +websocat ws://localhost:8080/api/ws + +# Send commands interactively +{"type": "Register", "client_id": "test-client"} +{"type": "Next"} +{"type": "Pause"} +``` + +## Deployment + +### Docker Deployment + +```dockerfile +FROM rust:1.88 as builder +WORKDIR /app +COPY . . +RUN cargo build --release -p toboggan-server + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y ca-certificates +COPY --from=builder /app/target/release/toboggan-server /usr/local/bin/ +COPY presentation.toml /app/ +EXPOSE 8080 +CMD ["toboggan-server", "/app/presentation.toml"] +``` + +### Reverse Proxy (nginx) + +```nginx +server { + listen 80; + server_name your-domain.com; + + location / { + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location /api/ws { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + } +} +``` + +## Monitoring + +### Health Checks + +```bash +# Basic health check +curl http://localhost:8080/api/health + +# Example response +{ + "status": "ok", + "timestamp": "2025-01-26T12:00:00Z", + "clients": 3, + "presentation": "My Conference Talk" +} +``` + +### Logging + +The server uses structured logging: + +```bash +# Log levels +RUST_LOG=error # Errors only +RUST_LOG=warn # Warnings and errors +RUST_LOG=info # Info, warnings, errors +RUST_LOG=debug # Debug info + above +RUST_LOG=trace # All logs (very verbose) +``` + +## Architecture + +### Core Components + +- **WebSocket Handler**: Manages real-time client connections +- **State Manager**: Thread-safe presentation state with `Arc>` +- **Command Processor**: Handles presentation navigation and control +- **Broadcast System**: Distributes state updates to all clients +- **REST API**: HTTP endpoints for health checks and metadata + +### Concurrency Model + +- **Async-first**: All operations are non-blocking using tokio +- **Shared State**: Presentation state shared safely across connections +- **Client Isolation**: Each WebSocket connection handled independently +- **Atomic Updates**: State changes are atomic and consistent + +## Troubleshooting + +### Common Issues + +**Server Won't Start:** +```bash +# Check if port is already in use +lsof -i :8080 + +# Try different port +cargo run -p toboggan-server -- --port 3000 presentation.toml +``` + +**WebSocket Connection Failed:** +```bash +# Check server is running +curl http://localhost:8080/api/health + +# Test WebSocket endpoint manually +websocat ws://localhost:8080/api/ws +``` + +**Presentation Not Loading:** +```bash +# Validate TOML syntax +toml_verify presentation.toml + +# Check file permissions and existence +ls -la presentation.toml +``` \ No newline at end of file diff --git a/toboggan-server/build.rs b/toboggan-server/build.rs new file mode 100644 index 0000000..a281010 --- /dev/null +++ b/toboggan-server/build.rs @@ -0,0 +1,59 @@ +#![allow(clippy::expect_used)] + +use std::path::Path; + +fn main() { + // Check that the web frontend dist folder exists and is not empty + let web_dist_path = Path::new("../toboggan-web/dist"); + + assert!( + web_dist_path.exists(), + "\n\nโŒ ERROR: Web frontend dist folder not found!\n\ + \n\ + The toboggan-server embeds web assets at compile time using RustEmbed.\n\ + You must build the web frontend BEFORE compiling the server.\n\ + \n\ + Please run:\n\ + \n\ + 1. Build web frontend:\n\ + cd toboggan-web && npm run build && cd ..\n\ + \n\ + 2. Then build the server:\n\ + cargo build -p toboggan-server\n\ + \n\ + Or use the mise tasks which handle the correct order:\n\ + mise build:rust # Builds web first, then Rust\n\ + mise serve # Ensures web is built before running server\n\ + \n" + ); + + // Check that the dist folder contains the essential files + let index_html = web_dist_path.join("index.html"); + assert!( + index_html.exists(), + "\n\nโŒ ERROR: Web frontend dist folder is incomplete!\n\ + \n\ + The dist/index.html file is missing. The web frontend may not have\n\ + been built correctly.\n\ + \n\ + Please rebuild the web frontend:\n\ + cd toboggan-web && npm run build && cd ..\n\ + \n" + ); + + // Tell Cargo to rerun this build script if the dist folder changes + println!("cargo:rerun-if-changed=../toboggan-web/dist"); + + // Also rerun if any files in the dist folder change + // This ensures the server is rebuilt when the web frontend is rebuilt + if web_dist_path.exists() { + for entry in std::fs::read_dir(web_dist_path) + .expect("Failed to read dist directory") + .flatten() + { + println!("cargo:rerun-if-changed={}", entry.path().display()); + } + } + + println!("cargo:warning=โœ… Web frontend dist folder found and valid"); +} diff --git a/toboggan-server/examples/generate_toml.rs b/toboggan-server/examples/generate_toml.rs new file mode 100644 index 0000000..8dbb30d --- /dev/null +++ b/toboggan-server/examples/generate_toml.rs @@ -0,0 +1,50 @@ +use std::fs; + +use toboggan_core::{Content, Date, Slide, Talk}; + +fn main() -> anyhow::Result<()> { + let talk = Talk::new("Peut-on RIIR de tout ?") + .with_date(Date::ymd(2025, 11, 13)) + .add_slide(Slide::cover("Peut-on RIIR de tout ?")) + .add_slide(slide( + "Introduction", + r#" +

+RIIR : "Have you considered Rewriting It In Rust?" +

+

+Une question qui fait sourire... mais qui cache une rรฉalitรฉ : Rust gagne du terrain partout. +

+ "#, + )) + .add_slide(Slide::part("1. Les Success Stories du RIIR")) + .add_slide(slide( + "Des rรฉรฉcritures qui ont fait leurs preuves", + r" + +- **ripgrep** (`rg`) : grep rรฉรฉcrit en Rust + - 10x plus rapide que grep classique + - Recherche rรฉcursive native + - Support Unicode complet + +- **fd** : find rรฉรฉcrit en Rust + - Interface plus intuitive + - Performances supรฉrieures + - Respect des .gitignore par dรฉfaut + +- **Fish Shell** : Shell moderne + - Autocomplรฉtion intelligente + - Sรฉcuritรฉ mรฉmoire + - Configuration simple + ", + )); + + let toml = toml::to_string_pretty(&talk)?; + fs::write("./talk.toml", toml)?; + + Ok(()) +} + +fn slide(title: &str, body: &str) -> Slide { + Slide::new(title).with_body(Content::html(body)) +} diff --git a/toboggan-server/openapi.json b/toboggan-server/openapi.json new file mode 100644 index 0000000..3490969 --- /dev/null +++ b/toboggan-server/openapi.json @@ -0,0 +1,764 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "toboggan-server", + "description": "Axum WebSocket server with REST API and real-time presentation synchronization", + "contact": { + "name": "Igor Laborie", + "email": "ilaborie@gmail.com" + }, + "license": { + "name": "MIT OR Apache-2.0", + "identifier": "MIT OR Apache-2.0" + }, + "version": "0.1.0" + }, + "servers": [ + { + "url": "http://localhost:8080", + "description": "Local development server" + } + ], + "paths": { + "/api/command": { + "post": { + "tags": [ + "command" + ], + "description": "Create command", + "operationId": "post-api-command", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Command" + }, + "example": { + "command": "Pause" + } + } + } + }, + "responses": { + "200": { + "description": "Status code 200", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Notification" + } + } + } + } + } + } + }, + "/api/slides": { + "get": { + "tags": [ + "slides" + ], + "description": "Retrieve slides", + "operationId": "get-api-slides", + "parameters": [], + "responses": { + "200": { + "description": "Status code 200", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SlidesResponse" + } + } + } + } + } + } + }, + "/api/talk": { + "get": { + "tags": [ + "talk" + ], + "description": "Retrieve talk", + "operationId": "get-api-talk", + "parameters": [], + "responses": { + "200": { + "description": "Status code 200", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TalkResponse" + } + } + } + } + } + } + }, + "/health": { + "get": { + "tags": [ + "health" + ], + "description": "Retrieve health", + "operationId": "get-health", + "parameters": [], + "responses": { + "200": { + "description": "Status code 200", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponse" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ClientId": { + "type": "string", + "format": "uuid" + }, + "Command": { + "oneOf": [ + { + "type": "object", + "required": [ + "client", + "command" + ], + "properties": { + "client": { + "$ref": "#/components/schemas/ClientId" + }, + "command": { + "type": "string", + "enum": [ + "Register" + ] + } + } + }, + { + "type": "object", + "required": [ + "client", + "command" + ], + "properties": { + "client": { + "$ref": "#/components/schemas/ClientId" + }, + "command": { + "type": "string", + "enum": [ + "Unregister" + ] + } + } + }, + { + "type": "object", + "required": [ + "command" + ], + "properties": { + "command": { + "type": "string", + "enum": [ + "Ping" + ] + } + } + }, + { + "type": "object", + "required": [ + "command" + ], + "properties": { + "command": { + "type": "string", + "enum": [ + "First" + ] + } + } + }, + { + "type": "object", + "required": [ + "command" + ], + "properties": { + "command": { + "type": "string", + "enum": [ + "Last" + ] + } + } + }, + { + "type": "object", + "required": [ + "slide", + "command" + ], + "properties": { + "command": { + "type": "string", + "enum": [ + "GoTo" + ] + }, + "slide": { + "type": "integer", + "minimum": 0 + } + } + }, + { + "type": "object", + "required": [ + "command" + ], + "properties": { + "command": { + "type": "string", + "enum": [ + "Next" + ] + } + } + }, + { + "type": "object", + "required": [ + "command" + ], + "properties": { + "command": { + "type": "string", + "enum": [ + "Previous" + ] + } + } + }, + { + "type": "object", + "required": [ + "command" + ], + "properties": { + "command": { + "type": "string", + "enum": [ + "Pause" + ] + } + } + }, + { + "type": "object", + "required": [ + "command" + ], + "properties": { + "command": { + "type": "string", + "enum": [ + "Resume" + ] + } + } + }, + { + "type": "object", + "required": [ + "command" + ], + "properties": { + "command": { + "type": "string", + "enum": [ + "Blink" + ] + } + } + } + ] + }, + "Content": { + "oneOf": [ + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "Empty" + ] + } + } + }, + { + "type": "object", + "required": [ + "text", + "type" + ], + "properties": { + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "Text" + ] + } + } + }, + { + "type": "object", + "required": [ + "raw", + "type" + ], + "properties": { + "alt": { + "type": [ + "string", + "null" + ] + }, + "raw": { + "type": "string" + }, + "style": { + "$ref": "#/components/schemas/Style" + }, + "type": { + "type": "string", + "enum": [ + "Html" + ] + } + } + }, + { + "type": "object", + "required": [ + "cells", + "style", + "type" + ], + "properties": { + "cells": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Content" + } + }, + "style": { + "$ref": "#/components/schemas/Style" + }, + "type": { + "type": "string", + "enum": [ + "Grid" + ] + } + } + } + ] + }, + "Date": { + "type": "string", + "format": "date" + }, + "Duration": { + "type": "object", + "properties": { + "nanos": { + "type": "number", + "format": "int64" + }, + "secs": { + "type": "number", + "format": "int64" + } + } + }, + "HealthResponse": { + "type": "object", + "required": [ + "status", + "started_at", + "elapsed", + "talk", + "active_clients" + ], + "properties": { + "active_clients": { + "type": "integer", + "minimum": 0 + }, + "elapsed": { + "$ref": "#/components/schemas/Duration" + }, + "started_at": { + "$ref": "#/components/schemas/Timestamp" + }, + "status": { + "$ref": "#/components/schemas/HealthResponseStatus" + }, + "talk": { + "type": "string" + } + } + }, + "HealthResponseStatus": { + "type": "string", + "enum": [ + "Ok", + "Oops" + ] + }, + "Notification": { + "oneOf": [ + { + "type": "object", + "required": [ + "state", + "type" + ], + "properties": { + "state": { + "$ref": "#/components/schemas/State" + }, + "type": { + "type": "string", + "enum": [ + "State" + ] + } + } + }, + { + "type": "object", + "required": [ + "message", + "type" + ], + "properties": { + "message": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "Error" + ] + } + } + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "Pong" + ] + } + } + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "Blink" + ] + } + } + }, + { + "type": "object", + "required": [ + "state", + "type" + ], + "properties": { + "state": { + "$ref": "#/components/schemas/State" + }, + "type": { + "type": "string", + "enum": [ + "TalkChange" + ] + } + } + } + ] + }, + "Slide": { + "type": "object", + "properties": { + "body": { + "oneOf": [ + { + "$ref": "#/components/schemas/Content" + } + ], + "default": { + "type": "Empty" + } + }, + "kind": { + "oneOf": [ + { + "$ref": "#/components/schemas/SlideKind" + } + ], + "default": "Standard" + }, + "notes": { + "oneOf": [ + { + "$ref": "#/components/schemas/Content" + } + ], + "default": { + "type": "Empty" + } + }, + "style": { + "oneOf": [ + { + "$ref": "#/components/schemas/Style" + } + ], + "default": {} + }, + "title": { + "oneOf": [ + { + "$ref": "#/components/schemas/Content" + } + ], + "default": { + "type": "Empty" + } + } + } + }, + "SlideKind": { + "type": "string", + "enum": [ + "Cover", + "Part", + "Standard" + ] + }, + "SlidesResponse": { + "type": "object", + "required": [ + "slides" + ], + "properties": { + "slides": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Slide" + } + } + } + }, + "State": { + "oneOf": [ + { + "type": "object", + "required": [ + "state" + ], + "properties": { + "state": { + "type": "string", + "enum": [ + "Init" + ] + } + } + }, + { + "type": "object", + "required": [ + "total_duration", + "state" + ], + "properties": { + "current": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "state": { + "type": "string", + "enum": [ + "Paused" + ] + }, + "total_duration": { + "$ref": "#/components/schemas/Duration" + } + } + }, + { + "type": "object", + "required": [ + "since", + "current", + "total_duration", + "state" + ], + "properties": { + "current": { + "type": "integer", + "minimum": 0 + }, + "since": { + "$ref": "#/components/schemas/Timestamp" + }, + "state": { + "type": "string", + "enum": [ + "Running" + ] + }, + "total_duration": { + "$ref": "#/components/schemas/Duration" + } + } + }, + { + "type": "object", + "required": [ + "current", + "total_duration", + "state" + ], + "properties": { + "current": { + "type": "integer", + "minimum": 0 + }, + "state": { + "type": "string", + "enum": [ + "Done" + ] + }, + "total_duration": { + "$ref": "#/components/schemas/Duration" + } + } + } + ] + }, + "Style": { + "type": "object", + "properties": { + "classes": { + "type": "array", + "items": { + "type": "string" + } + }, + "style": { + "type": [ + "string", + "null" + ] + } + } + }, + "TalkResponse": { + "type": "object", + "required": [ + "title", + "date", + "titles" + ], + "properties": { + "date": { + "$ref": "#/components/schemas/Date" + }, + "footer": { + "type": [ + "string", + "null" + ] + }, + "head": { + "type": [ + "string", + "null" + ] + }, + "title": { + "type": "string" + }, + "titles": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "tags": [ + { + "name": "command" + }, + { + "name": "health" + }, + { + "name": "slides" + }, + { + "name": "talk" + } + ] +} \ No newline at end of file diff --git a/toboggan-server/riir.toml b/toboggan-server/riir.toml new file mode 100644 index 0000000..b002dde --- /dev/null +++ b/toboggan-server/riir.toml @@ -0,0 +1,440 @@ +date = "2025-07-20" + +[title] +type = "Text" +text = "Peut-on RIIR de tout ?" + +[[slides]] +kind = "Cover" +style = [] + +[slides.title] +type = "Empty" + +[slides.body] +type = "Empty" + +[slides.notes] +type = "Text" +text = "Rewriting It In Rust - De la startup aux multinationales" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "Introduction" + +[slides.body] +type = "Html" +raw = """ +

RIIR : โ€œHave you considered Rewriting It In Rust?โ€

+

Une question qui fait sourireโ€ฆ mais qui cache une rรฉalitรฉ : Rust gagne du terrain partout.

""" +alt = """ +**RIIR** : โ€œHave you considered Rewriting It In Rust?โ€ +Une question qui fait sourireโ€ฆ mais qui cache une rรฉalitรฉ : Rust gagne du terrain partout.""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "1. Les Success Stories du RIIRDes rรฉรฉcritures qui ont fait leurs preuvesPourquoi ces rรฉรฉcritures rรฉussissent ?" + +[slides.body] +type = "Html" +raw = """ +
    +
  • +

    ripgrep (rg) : grep rรฉรฉcrit en Rust

    +
      +
    • 10x plus rapide que grep classique
    • +
    • Recherche rรฉcursive native
    • +
    • Support Unicode complet
    • +
    +
  • +
  • +

    fd : find rรฉรฉcrit en Rust

    +
      +
    • Interface plus intuitive
    • +
    • Performances supรฉrieures
    • +
    • Respect des .gitignore par dรฉfaut
    • +
    +
  • +
  • +

    Fish Shell : Shell moderne

    +
      +
    • Autocomplรฉtion intelligente
    • +
    • Sรฉcuritรฉ mรฉmoire
    • +
    • Configuration simple
    • +
    +
  • +
+
    +
  • Performance : Compilation native + optimisations
  • +
  • Sรฉcuritรฉ : Zรฉro segfault, gestion mรฉmoire automatique
  • +
  • Ergonomie : APIs modernes et intuitives
  • +
  • Fiabilitรฉ : System de types expressif
  • +
""" +alt = """ +- +**ripgrep** (`rg`) : grep rรฉรฉcrit en Rust + - 10x plus rapide que grep classique + - Recherche rรฉcursive native + - Support Unicode complet + +- +**fd** : find rรฉรฉcrit en Rust + - Interface plus intuitive + - Performances supรฉrieures + - Respect des .gitignore par dรฉfaut + +- +**Fish Shell** : Shell moderne + - Autocomplรฉtion intelligente + - Sรฉcuritรฉ mรฉmoire + - Configuration simple + +- **Performance** : Compilation native + optimisations +- **Sรฉcuritรฉ** : Zรฉro segfault, gestion mรฉmoire automatique +- **Ergonomie** : APIs modernes et intuitives +- **Fiabilitรฉ** : System de types expressif""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "2. Rust, le couteau suisse moderneAu-delร  des outils CLILes forces de Rust" + +[slides.body] +type = "Html" +raw = """ +

Rust ne se limite pas aux applications terminal :

+

Web & Backend

+
    +
  • Actix-web, Axum : Serveurs web haute performance
  • +
  • Diesel, SQLx : ORMs type-safe
  • +
  • Tokio : Runtime async de rรฉfรฉrence
  • +
+

Applications Desktop

+
    +
  • Tauri : Alternative ร  Electron
  • +
  • egui, iced : GUI natives
  • +
  • Bevy : Moteur de jeu en ECS
  • +
+

Microcontrรดleurs & IoT

+
    +
  • Embassy : Framework async pour embedded
  • +
  • Support natif ARM, RISC-V
  • +
  • Consommation mรฉmoire optimisรฉe
  • +
+

Blockchain & Crypto

+
    +
  • Solana : Runtime blockchain
  • +
  • Substrate : Framework pour blockchains
  • +
  • Performances critiques + sรฉcuritรฉ
  • +
+
    +
  1. Zero-cost abstractions : Performance sans compromis
  2. +
  3. Memory safety : Pas de garbage collector, pas de segfault
  4. +
  5. Concurrence : Ownership model + async/await
  6. +
  7. ร‰cosystรจme : Cargo + crates.io
  8. +
  9. Cross-platform : Linux, macOS, Windows, WASM, mobile
  10. +
""" +alt = """ +Rust ne se limite pas aux applications terminal : +#### Web & Backend +- **Actix-web**, **Axum** : Serveurs web haute performance +- **Diesel**, **SQLx** : ORMs type-safe +- **Tokio** : Runtime async de rรฉfรฉrence +#### Applications Desktop +- **Tauri** : Alternative ร  Electron +- **egui**, **iced** : GUI natives +- **Bevy** : Moteur de jeu en ECS +#### Microcontrรดleurs & IoT +- **Embassy** : Framework async pour embedded +- Support natif ARM, RISC-V +- Consommation mรฉmoire optimisรฉe +#### Blockchain & Crypto +- **Solana** : Runtime blockchain +- **Substrate** : Framework pour blockchains +- Performances critiques + sรฉcuritรฉ +- **Zero-cost abstractions** : Performance sans compromis +- **Memory safety** : Pas de garbage collector, pas de segfault +- **Concurrence** : Ownership model + async/await +- **ร‰cosystรจme** : Cargo + crates.io +- **Cross-platform** : Linux, macOS, Windows, WASM, mobile""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "3. Rust sโ€™intรจgre partoutWebAssembly (WASM)Python avec PyO3 + MaturinMobile avec UniFFIAutres intรฉgrations" + +[slides.body] +type = "Html" +raw = """ +
use wasm_bindgen::prelude::*;
+
+#[wasm_bindgen]
+pub fn process_data(input: &str) -> String {
+    // Logique mรฉtier en Rust
+    format!("Processed: {}", input)
+}
+
+
    +
  • Performance native dans le navigateur
  • +
  • Interopรฉrabilitรฉ JavaScript seamless
  • +
  • Utilisรฉ par Figma, Discord, Dropbox
  • +
+
use pyo3::prelude::*;
+
+#[pyfunction]
+fn compute_heavy_task(data: Vec<f64>) -> PyResult<f64> {
+    // Calculs intensifs en Rust
+    Ok(data.iter().sum())
+}
+
+#[pymodule]
+fn mymodule(_py: Python, m: &PyModule) -> PyResult<()> {
+    m.add_function(wrap_pyfunction!(compute_heavy_task, m)?)?;
+    Ok(())
+}
+
+
    +
  • Accรฉlรฉration des parties critiques
  • +
  • Distribution via pip
  • +
  • Exemples : Pydantic v2, Polars
  • +
+
// Logique mรฉtier partagรฉe
+pub struct UserService {
+    // ...
+}
+
+impl UserService {
+    pub fn authenticate(&self, token: String) -> Result<User, Error> {
+        // ...
+    }
+}
+
+
    +
  • Code partagรฉ iOS/Android
  • +
  • Bindings automatiques Swift/Kotlin
  • +
  • Utilisรฉ par Mozilla Firefox
  • +
+
    +
  • Node.js : NAPI-RS
  • +
  • Ruby : magnus, rutie
  • +
  • C/C++ : FFI direct
  • +
  • Java : JNI
  • +
  • Go : CGO
  • +
""" +alt = """ +``` +use wasm_bindgen::prelude::*; + +#[wasm_bindgen] +pub fn process_data(input: &str) -> String { + // Logique mรฉtier en Rust + format!("Processed: {}", input) +} +``` +- Performance native dans le navigateur +- Interopรฉrabilitรฉ JavaScript seamless +- Utilisรฉ par Figma, Discord, Dropbox +``` +use pyo3::prelude::*; + +#[pyfunction] +fn compute_heavy_task(data: Vec) -> PyResult { + // Calculs intensifs en Rust + Ok(data.iter().sum()) +} + +#[pymodule] +fn mymodule(_py: Python, m: &PyModule) -> PyResult<()> { + m.add_function(wrap_pyfunction!(compute_heavy_task, m)?)?; + Ok(()) +} +``` +- Accรฉlรฉration des parties critiques +- Distribution via pip +- Exemples : Pydantic v2, Polars +``` +// Logique mรฉtier partagรฉe +pub struct UserService { + // ... +} + +impl UserService { + pub fn authenticate(&self, token: String) -> Result { + // ... + } +} +``` +- Code partagรฉ iOS/Android +- Bindings automatiques Swift/Kotlin +- Utilisรฉ par Mozilla Firefox +- **Node.js** : NAPI-RS +- **Ruby** : magnus, rutie +- **C/C++** : FFI direct +- **Java** : JNI +- **Go** : CGO""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "4. Rust en startup : Retour dโ€™expรฉriencePourquoi choisir Rust en startup ?Stratรฉgie dโ€™adoption progressiveSuccess stories startup" + +[slides.body] +type = "Html" +raw = """ +

Avantages

+
    +
  • Performance : Moins de serveurs = coรปts rรฉduits
  • +
  • Fiabilitรฉ : Moins de bugs en production
  • +
  • Productivitรฉ : Dรฉtection dโ€™erreurs ร  la compilation
  • +
  • ร‰volutivitรฉ : Refactoring sรปr et confiant
  • +
+

Dรฉfis

+
    +
  • Courbe dโ€™apprentissage : Concepts ownership/borrowing
  • +
  • ร‰cosystรจme : Plus jeune que Java/.NET
  • +
  • Recrutement : Dรฉveloppeurs Rust plus rares
  • +
+
    +
  1. Microservices critiques : Performance-sensitive
  2. +
  3. Outils internes : CLI, scripts automation
  4. +
  5. Extensions : Plugins Python/Node.js
  6. +
  7. Migration graduelle : Module par module
  8. +
+
    +
  • Discord : Backend haute performance
  • +
  • Dropbox : Storage engine
  • +
  • Figma : Moteur de rendu WASM
  • +
  • Vercel : Bundlers (SWC, Turbo)
  • +
""" +alt = """ +#### Avantages +- **Performance** : Moins de serveurs = coรปts rรฉduits +- **Fiabilitรฉ** : Moins de bugs en production +- **Productivitรฉ** : Dรฉtection dโ€™erreurs ร  la compilation +- **ร‰volutivitรฉ** : Refactoring sรปr et confiant +#### Dรฉfis +- **Courbe dโ€™apprentissage** : Concepts ownership/borrowing +- **ร‰cosystรจme** : Plus jeune que Java/.NET +- **Recrutement** : Dรฉveloppeurs Rust plus rares +- **Microservices critiques** : Performance-sensitive +- **Outils internes** : CLI, scripts automation +- **Extensions** : Plugins Python/Node.js +- **Migration graduelle** : Module par module +- **Discord** : Backend haute performance +- **Dropbox** : Storage engine +- **Figma** : Moteur de rendu WASM +- **Vercel** : Bundlers (SWC, Turbo)""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "ConclusionRIIR : Pas quโ€™un mรจmeQuand envisager Rust ?Le futur est rouillรฉ ? ๐Ÿฆ€" + +[slides.body] +type = "Html" +raw = """ +
    +
  • Rรฉalitรฉ technique : Gains mesurables performance/fiabilitรฉ
  • +
  • ร‰cosystรจme mature : Outils production-ready
  • +
  • Adoption croissante : Startups โ†’ GAFAM
  • +
+

โœ… OUI pour :

+
    +
  • Performance critique
  • +
  • Sรฉcuritรฉ prioritaire
  • +
  • Code partagรฉ multi-plateformes
  • +
  • Outils systรจme
  • +
+

โŒ NON pour :

+
    +
  • Prototypage rapide
  • +
  • ร‰quipe junior exclusive
  • +
  • Deadline trรจs serrรฉe
  • +
  • Domain mรฉtier complexe
  • +
+

Rust nโ€™est pas la solution ร  tout, mais il repousse les limites du possible.

+

Question finale : โ€œHave you considered Rewriting It In Rust?โ€

+

Peut-รชtre que la rรฉponse nโ€™est plus si farfelueโ€ฆ

""" +alt = """ +- **Rรฉalitรฉ technique** : Gains mesurables performance/fiabilitรฉ +- **ร‰cosystรจme mature** : Outils production-ready +- **Adoption croissante** : Startups โ†’ GAFAM +โœ… **OUI** pour : +- Performance critique +- Sรฉcuritรฉ prioritaire +- Code partagรฉ multi-plateformes +- Outils systรจme +โŒ **NON** pour : +- Prototypage rapide +- ร‰quipe junior exclusive +- Deadline trรจs serrรฉe +- Domain mรฉtier complexe +Rust nโ€™est pas la solution ร  tout, mais il repousse les limites du possible. +**Question finale** : *โ€œHave you considered Rewriting It In Rust?โ€* +Peut-รชtre que la rรฉponse nโ€™est plus si farfelueโ€ฆ""" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "Ressources" + +[slides.body] +type = "Html" +raw = """ +

Merci pour votre attention !

+""" +alt = """ +*Merci pour votre attention !* +- Rust Book +- RIIR repository +- Are we X yet? +- This Week in Rust""" + +[slides.notes] +type = "Empty" diff --git a/toboggan-server/rustfmt.toml b/toboggan-server/rustfmt.toml new file mode 100644 index 0000000..2a732ff --- /dev/null +++ b/toboggan-server/rustfmt.toml @@ -0,0 +1,5 @@ +# Rustfmt configuration +edition = "2024" +unstable_features = true +imports_granularity = "Module" +group_imports = "StdExternalCrate" \ No newline at end of file diff --git a/toboggan-server/src/domain/health.rs b/toboggan-server/src/domain/health.rs new file mode 100644 index 0000000..05df5e2 --- /dev/null +++ b/toboggan-server/src/domain/health.rs @@ -0,0 +1,18 @@ +use serde::{Deserialize, Serialize}; +use toboggan_core::{Duration, Timestamp}; +use utoipa::ToSchema; + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct HealthResponse { + pub status: HealthResponseStatus, + pub started_at: Timestamp, + pub elapsed: Duration, + pub talk: String, + pub active_clients: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub enum HealthResponseStatus { + Ok, + Oops, +} diff --git a/toboggan-server/src/domain/mod.rs b/toboggan-server/src/domain/mod.rs new file mode 100644 index 0000000..29989a3 --- /dev/null +++ b/toboggan-server/src/domain/mod.rs @@ -0,0 +1,2 @@ +mod health; +pub use self::health::*; diff --git a/toboggan-server/src/error.rs b/toboggan-server/src/error.rs new file mode 100644 index 0000000..157465b --- /dev/null +++ b/toboggan-server/src/error.rs @@ -0,0 +1,27 @@ +use axum::Json; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use serde::Serialize; +use serde_json::json; + +#[derive(Debug, Serialize, derive_more::Display, derive_more::Error)] +#[non_exhaustive] +pub enum ApiError { + #[display("Maximum number of clients exceeded")] + TooManyClients, +} + +impl IntoResponse for ApiError { + fn into_response(self) -> axum::response::Response { + let status = match &self { + ApiError::TooManyClients => StatusCode::TOO_MANY_REQUESTS, + }; + let message = self.to_string(); + let payload = json!({ + "message": message, + "error": self, + }); + + (status, Json(payload)).into_response() + } +} diff --git a/toboggan-server/src/lib.rs b/toboggan-server/src/lib.rs new file mode 100644 index 0000000..8b97a7a --- /dev/null +++ b/toboggan-server/src/lib.rs @@ -0,0 +1,164 @@ +use std::fs; +use std::net::SocketAddr; +use std::path::Path; +use std::time::Duration; + +use anyhow::Context; +use toboggan_core::Talk; +use tracing::{info, instrument, warn}; +use utoipa::openapi::OpenApi; + +mod settings; +pub use self::settings::*; + +mod error; +pub use self::error::*; + +mod domain; +pub use self::domain::*; + +mod state; +pub use self::state::*; + +mod router; +pub use self::router::{routes_with_cors, *}; + +mod static_assets; +pub use self::static_assets::*; + +mod watcher; +pub use self::watcher::*; + +#[doc(hidden)] +#[instrument] +pub async fn launch(settings: Settings) -> anyhow::Result<()> { + info!(?settings, "launching server..."); + let Settings { + host, + port, + ref talk, + max_clients, + .. + } = settings; + + let talk = load_talk(talk).context("Loading talk")?; + + let addr = SocketAddr::from((host, port)); + let listener = tokio::net::TcpListener::bind(addr) + .await + .with_context(|| format!("Connecting to {addr} ..."))?; + + let state = TobogganState::new(talk, max_clients).context("build state")?; + + let cleanup_state = state.clone(); + let cleanup_interval = settings.cleanup_interval(); + tokio::spawn(async move { + cleanup_state.cleanup_clients_task(cleanup_interval).await; + info!("Cleanup task completed"); + }); + + if settings.watch { + let watch_state = state.clone(); + let watch_path = settings.talk.clone(); + start_watch_task(watch_path, watch_state).context("Starting file watcher")?; + } + + let openapi = create_openapi()?; + + let router = routes_with_cors( + settings.allowed_origins.as_deref(), + settings.public_dir.clone(), + openapi, + ) + .with_state(state); + let shutdown_signal = setup_shutdown_signal(settings.shutdown_timeout()); + + axum::serve(listener, router.into_make_service()) + .with_graceful_shutdown(shutdown_signal) + .await + .context("Axum server")?; + + info!("Server shutdown complete"); + + Ok(()) +} + +#[instrument] +fn load_talk(path: &Path) -> anyhow::Result { + let content = fs::read_to_string(path) + .with_context(|| format!("Reading talk file {}", path.display()))?; + let result = toml::from_str(&content).context("Parsing talk")?; + Ok(result) +} + +async fn setup_shutdown_signal(timeout: Duration) { + let ctrl_c = async { + if let Err(err) = tokio::signal::ctrl_c().await { + warn!("Failed to install Ctrl+C handler: {err}"); + } + }; + + #[cfg(unix)] + let terminate = async { + match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + Ok(mut signal) => { + signal.recv().await; + } + Err(err) => { + warn!("Failed to install SIGTERM handler: {err}"); + } + } + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + () = ctrl_c => { + info!("Received Ctrl+C, initiating graceful shutdown..."); + } + () = terminate => { + info!("Received SIGTERM, initiating graceful shutdown..."); + } + } + + info!( + "Waiting up to {} seconds for graceful shutdown", + timeout.as_secs() + ); + + info!("Shutdown signal processed, server will now terminate gracefully"); +} + +fn create_openapi() -> anyhow::Result { + let json_content = include_str!("../openapi.json"); + let openapi = serde_json::from_str(json_content).context("reading openapi.json file")?; + Ok(openapi) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[allow(clippy::expect_used)] + fn test_create_openapi() { + let result = create_openapi(); + + assert!(result.is_ok(), "create_openapi should succeed: {result:?}"); + + let openapi = result.expect("should have OpenApi"); + + // Check that paths are present (from the generated openapi.yml) + assert!( + !openapi.paths.paths.is_empty(), + "should have API paths from openapi.yml" + ); + + // Check that schemas are present + assert!( + openapi.components.is_some(), + "should have component schemas" + ); + } +} diff --git a/toboggan-server/src/main.rs b/toboggan-server/src/main.rs index e7a11a9..50cead8 100644 --- a/toboggan-server/src/main.rs +++ b/toboggan-server/src/main.rs @@ -1,3 +1,23 @@ -fn main() { - println!("Hello, world!"); +use anyhow::Context; +use clap::Parser; +use toboggan_server::{Settings, launch}; +use tracing_subscriber::EnvFilter; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let settings = Settings::try_parse().context("parsing arguments")?; + + // Validate settings before initializing logging + if let Err(msg) = settings.validate() { + return Err(anyhow::anyhow!("validating settings: {msg}")); + } + + tracing_subscriber::fmt() + .pretty() + .with_env_filter(EnvFilter::from_default_env()) + .init(); + + launch(settings).await?; + + Ok(()) } diff --git a/toboggan-server/src/router/api/mod.rs b/toboggan-server/src/router/api/mod.rs new file mode 100644 index 0000000..b59ddf2 --- /dev/null +++ b/toboggan-server/src/router/api/mod.rs @@ -0,0 +1,78 @@ +use std::time::Instant; + +use axum::Json; +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use serde::{Deserialize, Serialize}; +use toboggan_core::{Command, Notification, SlidesResponse, TalkResponse}; +use tracing::{info, warn}; + +use crate::TobogganState; + +#[derive(Debug, Serialize, Deserialize)] +pub(super) struct TalkParam { + #[serde(default)] + footer: bool, + #[serde(default)] + head: bool, +} + +pub(super) async fn get_talk( + State(state): State, + Query(param): Query, +) -> impl IntoResponse { + let talk = state.talk().await; + let mut result = TalkResponse::from(talk); + if !param.footer { + result.footer.take(); + } + if !param.head { + result.head.take(); + } + + Json(result) +} + +pub(super) async fn get_slides(State(state): State) -> impl IntoResponse { + let slides = state.slides().await; + let result = SlidesResponse { slides }; + + Json(result) +} + +pub(super) async fn get_slide_by_index( + State(state): State, + Path(index): Path, +) -> impl IntoResponse { + state + .slide_by_index(index) + .await + .map(Json) + .ok_or(StatusCode::NOT_FOUND) +} + +pub(super) async fn post_command( + State(state): State, + Json(command): Json, +) -> impl IntoResponse { + let start_time = Instant::now(); + + let result = state.handle_command(&command).await; + let duration_ms = start_time.elapsed().as_millis(); + match &result { + Notification::Error { message, .. } => { + warn!(?command, %message, ?duration_ms, "Command failed"); + } + notification => { + info!( + ?command, + ?duration_ms, + ?notification, + "Command processed successfully" + ); + } + } + + Json(result) +} diff --git a/toboggan-server/src/router/mod.rs b/toboggan-server/src/router/mod.rs new file mode 100644 index 0000000..cc03949 --- /dev/null +++ b/toboggan-server/src/router/mod.rs @@ -0,0 +1,130 @@ +use std::path::PathBuf; + +use axum::extract::State; +use axum::http::{Method, StatusCode, Uri, header}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use tower_http::cors::{Any, CorsLayer}; +use tower_http::services::ServeDir; +use tower_http::trace::TraceLayer; +use tracing::error; +use utoipa::openapi::OpenApi; +use utoipa_scalar::{Scalar, Servable}; + +use crate::{TobogganState, WebAssets}; + +mod api; +mod ws; + +pub fn routes(assets_dir: Option, openapi: OpenApi) -> Router { + routes_with_cors(None, assets_dir, openapi) +} + +pub fn routes_with_cors( + allowed_origins: Option<&[String]>, + assets_dir: Option, + openapi: OpenApi, +) -> Router { + let cors = create_cors_layer(allowed_origins); + + let mut router = Router::new() + .nest( + "/api", + Router::new() + .route("/talk", get(api::get_talk)) + .route("/slides", get(api::get_slides)) + .route("/slides/{index}", get(api::get_slide_by_index)) + .route("/command", post(api::post_command)) + .route("/ws", get(ws::websocket_handler)), + ) + .layer(TraceLayer::new_for_http()) + .route("/health", get(health)) + .merge(Scalar::with_url("/doc", openapi)) + .layer(cors); + + // Add local assets directory if provided (for presentation images/files) + // Use /public to avoid conflict with embedded web assets + if let Some(assets_dir) = assets_dir { + router = router.nest_service("/public", ServeDir::new(assets_dir)); + } + + // Serve embedded web assets (catch-all for SPA) + router = router.fallback(serve_embedded_web_assets); + + router +} + +async fn serve_embedded_web_assets(uri: Uri) -> Response { + let path = uri.path().trim_start_matches('/'); + + // Try to serve the requested file + if let Some(content) = WebAssets::get(path) { + let mime = mime_guess::from_path(path).first_or_octet_stream(); + return ( + StatusCode::OK, + [(header::CONTENT_TYPE, mime.as_ref())], + content.data, + ) + .into_response(); + } + + // For SPA: serve index.html for all non-asset routes + if let Some(index) = WebAssets::get("index.html") { + return ( + StatusCode::OK, + [(header::CONTENT_TYPE, "text/html")], + index.data, + ) + .into_response(); + } + + // Fallback if index.html is not found + (StatusCode::NOT_FOUND, "Not found").into_response() +} + +async fn health(State(state): State) -> impl IntoResponse { + let start_time = std::time::Instant::now(); + let health_data = state.health().await; + + tracing::debug!( + duration_ms = start_time.elapsed().as_millis(), + active_clients = health_data.active_clients, + "Health check completed" + ); + + Json(health_data) +} + +fn create_cors_layer(allowed_origins: Option<&[String]>) -> CorsLayer { + let mut cors = CorsLayer::new() + .allow_methods([Method::GET, Method::POST]) + .allow_headers([ + axum::http::header::CONTENT_TYPE, + axum::http::header::AUTHORIZATION, + ]); + + match allowed_origins { + Some(origins) if !origins.is_empty() => { + let parsed_origins: Result, _> = + origins.iter().map(|origin| origin.parse()).collect(); + + match parsed_origins { + Ok(origins) => { + tracing::info!(?origins, "CORS configured with specific origins"); + cors = cors.allow_origin(origins); + } + Err(err) => { + error!("Invalid CORS origin format: {err}, falling back to Any"); + cors = cors.allow_origin(Any); + } + } + } + _ => { + cors = cors.allow_origin(Any); + tracing::warn!("CORS configured to allow any origin - not recommended for production"); + } + } + + cors +} diff --git a/toboggan-server/src/router/ws.rs b/toboggan-server/src/router/ws.rs new file mode 100644 index 0000000..cf611ba --- /dev/null +++ b/toboggan-server/src/router/ws.rs @@ -0,0 +1,222 @@ +use std::time::Duration; + +use axum::extract::State; +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use axum::response::Response; +use futures::{SinkExt, StreamExt}; +use toboggan_core::timeouts::HEARTBEAT_INTERVAL; +use toboggan_core::{ClientId, Command, Notification}; +use tracing::{error, info, warn}; + +use crate::TobogganState; + +pub async fn websocket_handler( + ws: WebSocketUpgrade, + State(state): State, +) -> Response { + ws.on_upgrade(move |socket| handle_websocket(socket, state)) +} + +async fn handle_websocket(socket: WebSocket, state: TobogganState) { + let client_id = ClientId::new(); + info!(?client_id, "New WebSocket connection established"); + + let notification_rx = match state.register_client(client_id).await { + Ok(rx) => rx, + Err(err) => { + error!(?client_id, "Failed to register client: {err}"); + return; + } + }; + + let (mut ws_sender, ws_receiver) = socket.split(); + + if let Err(()) = send_initial_state(&mut ws_sender, &state, client_id).await { + return; + } + + let (notification_tx, notification_rx_internal) = + tokio::sync::mpsc::unbounded_channel::(); + let error_notification_tx = notification_tx.clone(); + + let watcher_task = + spawn_notification_watcher_task(notification_rx, notification_tx.clone(), client_id); + let sender_task = + spawn_notification_sender_task(notification_rx_internal, ws_sender, client_id); + let receiver_task = + spawn_message_receiver_task(ws_receiver, state.clone(), error_notification_tx, client_id); + let heartbeat_task = spawn_heartbeat_task(notification_tx, client_id, HEARTBEAT_INTERVAL); + + tokio::select! { + _ = watcher_task => { + info!(?client_id, "Watcher task completed"); + } + _ = sender_task => { + info!(?client_id, "Sender task completed"); + } + _ = receiver_task => { + info!(?client_id, "Receiver task completed"); + } + _ = heartbeat_task => { + info!(?client_id, "Heartbeat task completed"); + } + } + + state.unregister_client(client_id); + info!( + ?client_id, + "Client unregistered and WebSocket connection closed" + ); +} + +async fn send_initial_state( + ws_sender: &mut futures::stream::SplitSink, + state: &TobogganState, + client_id: ClientId, +) -> Result<(), ()> { + let initial_notification = { + let current_state = state.current_state().await; + Notification::state(current_state.clone()) + }; + + if let Ok(msg) = serde_json::to_string(&initial_notification) + && let Err(err) = ws_sender.send(Message::Text(msg.into())).await + { + error!(?client_id, ?err, "Failed to send initial state to client"); + return Err(()); + } + + Ok(()) +} + +fn spawn_notification_watcher_task( + mut notification_rx: tokio::sync::watch::Receiver, + notification_tx: tokio::sync::mpsc::UnboundedSender, + client_id: ClientId, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + while notification_rx.changed().await.is_ok() { + let notification = notification_rx.borrow().clone(); + if notification_tx.send(notification).is_err() { + warn!( + ?client_id, + "Failed to send notification to internal channel, receiver may be closed" + ); + break; + } + } + info!(?client_id, "Notification watcher task finished"); + }) +} + +fn spawn_notification_sender_task( + mut notification_rx_internal: tokio::sync::mpsc::UnboundedReceiver, + mut ws_sender: futures::stream::SplitSink, + client_id: ClientId, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + while let Some(notification) = notification_rx_internal.recv().await { + match serde_json::to_string(¬ification) { + Ok(msg) => { + if let Err(err) = ws_sender.send(Message::Text(msg.into())).await { + warn!( + ?client_id, + ?err, + "Failed to send notification to client, connection may be closed" + ); + break; + } + } + Err(err) => { + error!(?client_id, ?err, "Failed to serialize notification"); + } + } + } + info!(?client_id, "Notification sender task finished"); + }) +} + +fn spawn_message_receiver_task( + mut ws_receiver: futures::stream::SplitStream, + state: TobogganState, + error_notification_tx: tokio::sync::mpsc::UnboundedSender, + client_id: ClientId, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + while let Some(msg) = ws_receiver.next().await { + match msg { + Ok(Message::Text(text)) => { + info!(?client_id, message = %text, "Received WebSocket message"); + + match serde_json::from_str::(&text) { + Ok(command) => { + info!(?client_id, ?command, "Processing command"); + + if let Command::Unregister { client } = &command + && *client == client_id + { + info!( + ?client_id, + "Client unregistering itself, closing connection" + ); + break; + } + + let _notification = state.handle_command(&command).await; + } + Err(err) => { + warn!(?client_id, ?err, message = %text, "Failed to parse command from WebSocket message"); + + let error_notification = + Notification::error(format!("Invalid command format: {err}")); + if error_notification_tx.send(error_notification).is_err() { + error!( + ?client_id, + "Failed to send error notification to internal channel" + ); + } + } + } + } + Ok(Message::Binary(_)) => { + warn!(?client_id, "Received binary message, ignoring"); + } + Ok(Message::Close(_)) => { + info!(?client_id, "WebSocket connection closed by client"); + break; + } + Ok(Message::Ping(_) | Message::Pong(_)) => {} + Err(err) => { + warn!(?client_id, ?err, "WebSocket error"); + break; + } + } + } + info!(?client_id, "Message receiver task finished"); + }) +} + +fn spawn_heartbeat_task( + notification_tx: tokio::sync::mpsc::UnboundedSender, + client_id: ClientId, + heartbeat_interval: Duration, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let mut interval = tokio::time::interval(heartbeat_interval); + + loop { + interval.tick().await; + + let ping_notification = Notification::PONG; + if notification_tx.send(ping_notification).is_err() { + info!( + ?client_id, + "Heartbeat task stopping - notification channel closed" + ); + break; + } + } + + info!(?client_id, "Heartbeat task finished"); + }) +} diff --git a/toboggan-server/src/settings.rs b/toboggan-server/src/settings.rs new file mode 100644 index 0000000..1f5055c --- /dev/null +++ b/toboggan-server/src/settings.rs @@ -0,0 +1,100 @@ +use std::net::{IpAddr, Ipv4Addr}; +use std::path::PathBuf; +use std::time::Duration; + +#[derive(Debug, clap::Parser)] +pub struct Settings { + /// The host to bind to + #[clap(long, env = "TOBOGGAN_HOST", default_value_t = IpAddr::V4(Ipv4Addr::LOCALHOST))] + pub host: IpAddr, + + /// The port to bind to + #[clap(long, env = "TOBOGGAN_PORT", default_value_t = 8080)] + pub port: u16, + + /// The talk file to serve + pub talk: PathBuf, + + /// Maximum number of concurrent WebSocket clients + #[clap(long, env = "TOBOGGAN_MAX_CLIENTS", default_value_t = 100)] + pub max_clients: usize, + + /// WebSocket heartbeat interval in seconds + #[clap(long, env = "TOBOGGAN_HEARTBEAT_INTERVAL", default_value_t = 30)] + pub heartbeat_interval_secs: u64, + + /// Graceful shutdown timeout in seconds + #[clap(long, env = "TOBOGGAN_SHUTDOWN_TIMEOUT", default_value_t = 30)] + pub shutdown_timeout_secs: u64, + + /// Client cleanup interval in seconds + #[clap(long, env = "TOBOGGAN_CLEANUP_INTERVAL", default_value_t = 60)] + pub cleanup_interval_secs: u64, + + /// Allowed CORS origins (comma-separated) + #[clap(long, env = "TOBOGGAN_CORS_ORIGINS", value_delimiter = ',')] + pub allowed_origins: Option>, + + /// Optional local public folder for presentation files (served at /public/) + /// Example: --public-dir ./public for images, videos, etc. + #[clap(long, env = "TOBOGGAN_PUBLIC_DIR")] + pub public_dir: Option, + + /// Enable watch mode to automatically reload the talk file when it changes + #[clap(long, env = "TOBOGGAN_WATCH")] + pub watch: bool, +} + +impl Settings { + #[must_use] + pub fn heartbeat_interval(&self) -> Duration { + Duration::from_secs(self.heartbeat_interval_secs) + } + + #[must_use] + pub fn shutdown_timeout(&self) -> Duration { + Duration::from_secs(self.shutdown_timeout_secs) + } + + #[must_use] + pub fn cleanup_interval(&self) -> Duration { + Duration::from_secs(self.cleanup_interval_secs) + } + + /// # Errors + /// Returns error if configuration is invalid + pub fn validate(&self) -> Result<(), String> { + if self.max_clients == 0 { + return Err("max_clients must be greater than 0".to_string()); + } + + if self.heartbeat_interval_secs == 0 { + return Err("heartbeat_interval_secs must be greater than 0".to_string()); + } + + if !self.talk.exists() { + return Err(format!("Talk file does not exist: {}", self.talk.display())); + } + + if self.talk.extension().is_none_or(|ext| ext != "toml") { + return Err("Talk file must have .toml extension".to_string()); + } + + if let Some(ref assets_dir) = self.public_dir { + if !assets_dir.exists() { + return Err(format!( + "Assets directory does not exist: {}", + assets_dir.display() + )); + } + if !assets_dir.is_dir() { + return Err(format!( + "Assets path is not a directory: {}", + assets_dir.display() + )); + } + } + + Ok(()) + } +} diff --git a/toboggan-server/src/state.rs b/toboggan-server/src/state.rs new file mode 100644 index 0000000..bbc7c57 --- /dev/null +++ b/toboggan-server/src/state.rs @@ -0,0 +1,465 @@ +use std::sync::Arc; + +use anyhow::bail; +use dashmap::DashMap; +use toboggan_core::{ClientId, Command, Duration, Notification, Slide, State, Talk, Timestamp}; +use tokio::sync::{RwLock, watch}; +use tracing::{info, warn}; + +use crate::{ApiError, HealthResponse, HealthResponseStatus}; + +#[derive(Clone)] +pub struct TobogganState { + started_at: Timestamp, + talk: Arc>, + current_state: Arc>, + clients: Arc>>, + max_clients: usize, +} + +impl TobogganState { + pub fn new(talk: Talk, max_clients: usize) -> anyhow::Result { + let started = Timestamp::now(); + + if talk.slides.is_empty() { + bail!("Empty talk, need at least one slide, got {talk:#?}"); + } + + info!( + "\n=== Slides ===\n{}", + talk.slides + .iter() + .enumerate() + .map(|(index, slide)| format!("[{index:02}] {slide}")) + .collect::>() + .join("\n") + ); + + let current_state = State::default(); + let current_state = Arc::new(RwLock::new(current_state)); + + Ok(Self { + started_at: started, + talk: Arc::new(RwLock::new(talk)), + current_state, + clients: Arc::new(DashMap::new()), + max_clients, + }) + } + + pub(crate) async fn health(&self) -> HealthResponse { + let status = HealthResponseStatus::Ok; + let started_at = self.started_at; + let elapsed = started_at.elapsed(); + let talk = self.talk.read().await; + let name = talk.title.clone(); + let active_clients = self.clients.len(); + + HealthResponse { + status, + started_at, + elapsed, + talk: name, + active_clients, + } + } + + pub(crate) async fn talk(&self) -> Talk { + self.talk.read().await.clone() + } + + pub(crate) async fn slides(&self) -> Vec { + let talk = self.talk.read().await; + talk.slides.clone() + } + + pub(crate) async fn slide_by_index(&self, index: usize) -> Option { + let talk = self.talk.read().await; + talk.slides.get(index).cloned() + } + + async fn total_slides(&self) -> usize { + let talk = self.talk.read().await; + talk.slides.len() + } + + pub(crate) async fn current_state(&self) -> State { + let state = self.current_state.read().await; + + state.clone() + } + + /// Registers a new client for notifications + /// + /// # Errors + /// Returns an error if the maximum number of clients is exceeded + pub async fn register_client( + &self, + client_id: ClientId, + ) -> Result, ApiError> { + self.cleanup_disconnected_clients(); + + if !self.has_capacity() { + return Err(ApiError::TooManyClients); + } + + let initial_notification = self.create_initial_notification().await; + + let (tx, rx) = watch::channel(initial_notification); + + self.clients.insert(client_id, tx); + tracing::info!( + ?client_id, + active_clients = self.clients.len(), + "Client registered" + ); + + Ok(rx) + } + + fn cleanup_disconnected_clients(&self) { + let initial_count = self.clients.len(); + + self.clients.retain(|client_id, tx| { + let is_connected = !tx.is_closed(); + if !is_connected { + tracing::debug!(?client_id, "Removing disconnected client"); + } + is_connected + }); + + let removed_count = initial_count - self.clients.len(); + if removed_count > 0 { + tracing::info!( + removed_count, + active_clients = self.clients.len(), + "Cleaned up disconnected clients" + ); + } + } + + pub async fn cleanup_clients_task(&self, cleanup_interval: std::time::Duration) { + let mut interval = tokio::time::interval(cleanup_interval); + + loop { + interval.tick().await; + self.cleanup_disconnected_clients(); + } + } + + pub fn unregister_client(&self, client_id: ClientId) { + self.clients.remove(&client_id); + tracing::info!( + ?client_id, + active_clients = self.clients.len(), + "Client unregistered" + ); + } + + fn transition_to_running(state: &mut State, slide_index: usize, reset_duration: bool) { + let total_duration = if reset_duration { + Duration::ZERO + } else { + match state { + State::Init => Duration::ZERO, + _ => state.calculate_total_duration(), + } + }; + + *state = State::Running { + since: Timestamp::now(), + current: slide_index, + total_duration, + }; + } + + async fn command_first(&self, state: &mut State) -> Notification { + let total_slides = self.total_slides().await; + if total_slides == 0 { + return Notification::error("No slides available".to_string()); + } + + let should_transition = matches!(state, State::Init | State::Paused { .. }) + || !state.is_first_slide(total_slides); + + if should_transition { + Self::transition_to_running(state, 0, true); + } + + Notification::state(state.clone()) + } + + async fn command_last(&self, state: &mut State) -> Notification { + let total_slides = self.total_slides().await; + if total_slides == 0 { + return Notification::error("No slides available".to_string()); + } + + let last_index = total_slides - 1; + Self::navigate_to_slide(state, last_index, total_slides); + Notification::state(state.clone()) + } + + async fn command_goto(&self, state: &mut State, slide_index: usize) -> Notification { + let total_slides = self.total_slides().await; + if slide_index >= total_slides { + return Notification::error(format!( + "Slide index {slide_index} not found, total slides: {total_slides}" + )); + } + + Self::navigate_to_slide(state, slide_index, total_slides); + Notification::state(state.clone()) + } + + async fn command_next(&self, state: &mut State) -> Notification { + let total_slides = self.total_slides().await; + if total_slides == 0 { + warn!("No slides available when handling Next"); + return Notification::error("No slides available".to_string()); + } + + match state { + State::Init => Self::transition_to_running(state, 0, false), + State::Paused { .. } => { + if let Some(next_slide) = state.next(total_slides) { + Self::transition_to_running(state, next_slide, false); + } + } + _ => Self::handle_next_in_running_state(state, total_slides), + } + + Notification::state(state.clone()) + } + + async fn command_previous(&self, state: &mut State) -> Notification { + let total_slides = self.total_slides().await; + if total_slides == 0 { + return Notification::error("No slides available".to_string()); + } + + match state { + State::Init => Self::transition_to_running(state, 0, false), + State::Paused { .. } => { + if let Some(prev_slide) = state.previous(total_slides) { + Self::transition_to_running(state, prev_slide, false); + } + } + _ => { + if let Some(prev_slide) = state.previous(total_slides) { + state.update_slide(prev_slide); + } + } + } + + Notification::state(state.clone()) + } + + fn command_pause(state: &mut State) -> Notification { + if let State::Running { current, .. } = *state { + let total_duration = state.calculate_total_duration(); + *state = State::Paused { + current: Some(current), + total_duration, + }; + } + Notification::state(state.clone()) + } + + fn command_resume(state: &mut State) -> Notification { + if matches!(*state, State::Paused { .. }) { + state.auto_resume(); + } + Notification::state(state.clone()) + } + + fn command_blink() -> Notification { + Notification::BLINK + } + + pub async fn handle_command(&self, command: &Command) -> Notification { + let start_time = std::time::Instant::now(); + let mut state = self.current_state.write().await; + + #[allow(clippy::match_same_arms)] + let notification = match command { + Command::Register { .. } => Notification::state(state.clone()), + Command::Unregister { .. } => Notification::state(state.clone()), + Command::First => self.command_first(&mut state).await, + Command::Last => self.command_last(&mut state).await, + Command::GoTo { slide } => self.command_goto(&mut state, *slide).await, + Command::Next => self.command_next(&mut state).await, + Command::Previous => self.command_previous(&mut state).await, + Command::Pause => Self::command_pause(&mut state), + Command::Resume => Self::command_resume(&mut state), + Command::Blink => Self::command_blink(), + Command::Ping => Notification::PONG, + }; + + self.notify_all_clients(¬ification); + drop(state); + + tracing::debug!( + ?command, + duration_ms = start_time.elapsed().as_millis(), + active_clients = self.clients.len(), + "Command handled and broadcast completed" + ); + + notification + } + + fn navigate_to_slide(state: &mut State, target_slide: usize, total_slides: usize) { + match state { + State::Init => { + Self::transition_to_running(state, target_slide, false); + } + State::Paused { .. } => { + if state.is_last_slide(total_slides) && target_slide == total_slides - 1 { + state.update_slide(target_slide); + } else { + Self::transition_to_running(state, target_slide, false); + } + } + _ => { + state.update_slide(target_slide); + } + } + } + + fn handle_next_in_running_state(state: &mut State, total_slides: usize) { + if let Some(current) = state.current() { + if let Some(next_slide) = state.next(total_slides) { + state.update_slide(next_slide); + } else if state.is_last_slide(total_slides) { + let total_duration = state.calculate_total_duration(); + *state = State::Done { + current, + total_duration, + }; + } + } else { + Self::transition_to_running(state, 0, false); + } + } + + fn has_capacity(&self) -> bool { + self.clients.len() < self.max_clients + } + + async fn create_initial_notification(&self) -> Notification { + let current_state = self.current_state.read().await; + Notification::state(current_state.clone()) + } + + fn notify_all_clients(&self, notification: &Notification) { + for client_entry in self.clients.iter() { + let client_id = client_entry.key(); + let sender = client_entry.value(); + if sender.send(notification.clone()).is_err() { + tracing::warn!( + ?client_id, + "Failed to send notification to client, client may have disconnected" + ); + } + } + } + + pub async fn reload_talk(&self, new_talk: Talk) -> anyhow::Result<()> { + if new_talk.slides.is_empty() { + bail!("Cannot reload talk with empty slides"); + } + + let mut state = self.current_state.write().await; + let current_slide_index = state.current().unwrap_or(0); + + let old_talk = self.talk.read().await; + let current_slide = old_talk.slides.get(current_slide_index); + + // Preserve slide position: by title -> by index -> fallback to first + let new_slide_index = Self::preserve_slide_position( + current_slide, + current_slide_index, + &old_talk.slides, + &new_talk.slides, + ); + + info!( + old_slide = current_slide_index, + new_slide = new_slide_index, + old_title = ?current_slide.map(|slide| &slide.title), + new_title = ?new_talk.slides.get(new_slide_index).map(|slide| &slide.title), + "Talk reloaded" + ); + + // Update slide index in current state + state.update_slide(new_slide_index); + drop(old_talk); + + // Replace the talk + let mut talk = self.talk.write().await; + *talk = new_talk; + drop(talk); + + // Send TalkChange notification to all clients + let notification = Notification::talk_change(state.clone()); + self.notify_all_clients(¬ification); + + Ok(()) + } + + fn preserve_slide_position( + current_slide: Option<&Slide>, + current_index: usize, + old_slides: &[Slide], + new_slides: &[Slide], + ) -> usize { + if let Some(slide) = current_slide { + // Try to match by title (exact match first, then case-insensitive if text) + if let Some(position) = new_slides + .iter() + .position(|new_slide| new_slide.title == slide.title) + { + return position; + } + + // For text titles, try case-insensitive comparison + if let Some(position) = Self::find_by_title_text(&slide.title, new_slides) { + return position; + } + } + + // Try to preserve index if slide count unchanged + if old_slides.len() == new_slides.len() && current_index < new_slides.len() { + return current_index; + } + + // Fallback to first slide + 0 + } + + fn find_by_title_text(title: &toboggan_core::Content, slides: &[Slide]) -> Option { + use toboggan_core::Content; + + let title_text = match title { + Content::Text { text } => text.to_lowercase(), + Content::Html { alt: Some(alt), .. } => alt.to_lowercase(), + Content::Html { raw, .. } => raw.to_lowercase(), + _ => return None, + }; + + slides.iter().position(|slide| { + let slide_text = match &slide.title { + Content::Text { text } => text.to_lowercase(), + Content::Html { alt: Some(alt), .. } => alt.to_lowercase(), + Content::Html { raw, .. } => raw.to_lowercase(), + _ => String::new(), + }; + slide_text == title_text + }) + } +} + +#[cfg(test)] +#[path = "state_tests.rs"] +mod tests; diff --git a/toboggan-server/src/state_tests.rs b/toboggan-server/src/state_tests.rs new file mode 100644 index 0000000..30ebeb4 --- /dev/null +++ b/toboggan-server/src/state_tests.rs @@ -0,0 +1,494 @@ +#[cfg(test)] +#[allow(clippy::module_inception, clippy::unwrap_used)] +mod tests { + use toboggan_core::{ClientId, Command, Date, Duration, Notification, Slide, State, Talk}; + + use crate::TobogganState; + + fn create_test_talk() -> Talk { + Talk::new("Test Talk") + .with_date(Date::ymd(2025, 1, 1)) + .add_slide(Slide::cover("Cover Slide")) + .add_slide(Slide::new("Second Slide")) + .add_slide(Slide::new("Third Slide")) + } + + #[tokio::test] + async fn test_register_command() { + let talk = create_test_talk(); + let state = TobogganState::new(talk, 100).unwrap(); + let client_id = ClientId::new(); + + let notification = state + .handle_command(&Command::Register { client: client_id }) + .await; + + match notification { + Notification::State { + state: inner_state, .. + } => match inner_state { + State::Init => {} + _ => panic!("Expected initial state (Init)"), + }, + _ => panic!("Expected State notification"), + } + } + + #[tokio::test] + async fn test_unregister_command() { + let talk = create_test_talk(); + let state = TobogganState::new(talk, 100).unwrap(); + let client_id = ClientId::new(); + + let notification = state + .handle_command(&Command::Unregister { client: client_id }) + .await; + + match notification { + Notification::State { .. } => {} + _ => panic!("Expected State notification"), + } + } + + #[tokio::test] + async fn test_first_command() { + let talk = create_test_talk(); + let state = TobogganState::new(talk, 100).unwrap(); + + // Move to last slide first (this will go to Running from Init) + state.handle_command(&Command::Last).await; + + // Then go back to first (this should go to Running since we're not in Init anymore) + let notification = state.handle_command(&Command::First).await; + + match notification { + Notification::State { + state: inner_state, .. + } => match inner_state { + State::Running { current, .. } => { + assert_eq!(current, 0); // First slide index + } + _ => panic!("Expected Running state"), + }, + _ => panic!("Expected State notification"), + } + } + + #[tokio::test] + async fn test_last_command() { + let talk = create_test_talk(); + let state = TobogganState::new(talk, 100).unwrap(); + + let notification = state.handle_command(&Command::Last).await; + + match notification { + Notification::State { + state: inner_state, .. + } => match inner_state { + State::Running { current, .. } => { + // From Init state, Last command should go to last slide (index 2 for 3 slides) + assert_eq!(current, 2); // Last slide index (3 slides = indices 0,1,2) + } + _ => panic!("Expected Running state"), + }, + _ => panic!("Expected State notification"), + } + } + + #[tokio::test] + async fn test_goto_valid_slide() { + let talk = create_test_talk(); + let state = TobogganState::new(talk, 100).unwrap(); + let target_slide = 1; // Index 1 (second slide) + + let notification = state + .handle_command(&Command::GoTo { + slide: target_slide, + }) + .await; + + match notification { + Notification::State { + state: inner_state, .. + } => match inner_state { + State::Running { current, .. } => { + assert_eq!(current, target_slide); + } + _ => panic!("Expected Running state"), + }, + _ => panic!("Expected State notification"), + } + } + + #[tokio::test] + async fn test_goto_invalid_slide() { + let talk = create_test_talk(); + let state = TobogganState::new(talk, 100).unwrap(); + let invalid_slide = 999; // Index out of bounds + + let notification = state + .handle_command(&Command::GoTo { + slide: invalid_slide, + }) + .await; + + match notification { + Notification::Error { message, .. } => { + assert!(message.contains("not found") || message.contains("out of bounds")); + } + _ => panic!("Expected Error notification"), + } + } + + #[tokio::test] + async fn test_next_command() { + let talk = create_test_talk(); + let state = TobogganState::new(talk, 100).unwrap(); + + let notification = state.handle_command(&Command::Next).await; + + match notification { + Notification::State { + state: inner_state, .. + } => match inner_state { + State::Running { current, .. } => { + // From Init state, Next command should go to first slide + assert_eq!(current, 0); // First slide index + } + _ => panic!("Expected Running state"), + }, + _ => panic!("Expected State notification"), + } + } + + #[tokio::test] + async fn test_next_at_last_slide() { + let talk = create_test_talk(); + let state = TobogganState::new(talk, 100).unwrap(); + + // Go to last slide (from Init this will go to first slide) + state.handle_command(&Command::Last).await; + + // Navigate to last slide properly + state.handle_command(&Command::Last).await; + + // Try to go next from last slide + let notification = state.handle_command(&Command::Next).await; + + match notification { + Notification::State { + state: inner_state, .. + } => match inner_state { + State::Done { .. } => {} + _ => panic!("Expected Done state"), + }, + _ => panic!("Expected State notification"), + } + } + + #[tokio::test] + async fn test_previous_command() { + let talk = create_test_talk(); + let state = TobogganState::new(talk, 100).unwrap(); + + // Move to first slide (from Init) + state.handle_command(&Command::Next).await; + + // Move to second slide + state.handle_command(&Command::Next).await; + + // Then go back to first + let notification = state.handle_command(&Command::Previous).await; + + match notification { + Notification::State { + state: inner_state, .. + } => match inner_state { + State::Running { current, .. } => { + assert_eq!(current, 0); // First slide index + } + _ => panic!("Expected Running state"), + }, + _ => panic!("Expected State notification"), + } + } + + #[tokio::test] + async fn test_previous_at_first_slide() { + let talk = create_test_talk(); + let state = TobogganState::new(talk, 100).unwrap(); + + // From Init state, Previous command should go to first slide + let notification = state.handle_command(&Command::Previous).await; + + match notification { + Notification::State { + state: inner_state, .. + } => match inner_state { + State::Running { current, .. } => { + assert_eq!(current, 0); // First slide index + } + _ => panic!("Expected Running state"), + }, + _ => panic!("Expected State notification"), + } + } + + #[tokio::test] + async fn test_pause_command() { + let talk = create_test_talk(); + let state = TobogganState::new(talk, 100).unwrap(); + + // Get to Running state first by using a navigation command + state.handle_command(&Command::Next).await; + + // Then pause + let notification = state.handle_command(&Command::Pause).await; + + match notification { + Notification::State { + state: inner_state, .. + } => match inner_state { + State::Paused { .. } => {} + _ => panic!("Expected Paused state"), + }, + _ => panic!("Expected State notification"), + } + } + + #[tokio::test] + async fn test_resume_command() { + let talk = create_test_talk(); + let state = TobogganState::new(talk, 100).unwrap(); + + // Get to Running state first, then pause + state.handle_command(&Command::Next).await; + state.handle_command(&Command::Pause).await; + + // Now resume + let notification = state.handle_command(&Command::Resume).await; + + match notification { + Notification::State { + state: inner_state, .. + } => match inner_state { + State::Running { .. } => {} + _ => panic!("Expected Running state"), + }, + _ => panic!("Expected State notification"), + } + } + + #[tokio::test] + async fn test_ping_command() { + let talk = create_test_talk(); + let state = TobogganState::new(talk, 100).unwrap(); + + let notification = state.handle_command(&Command::Ping).await; + + match notification { + Notification::Pong => {} + _ => panic!("Expected Pong notification"), + } + } + + #[tokio::test] + async fn test_state_preservation_during_navigation() { + let talk = create_test_talk(); + let state = TobogganState::new(talk, 100).unwrap(); + + // Start in Running state + state.handle_command(&Command::Next).await; + + // Navigate while running + let notification = state.handle_command(&Command::Next).await; + + match notification { + Notification::State { + state: inner_state, .. + } => match inner_state { + State::Running { .. } => {} + _ => panic!("Expected to remain in Running state"), + }, + _ => panic!("Expected State notification"), + } + } + + #[tokio::test] + async fn test_navigation_from_done_state() { + let talk = create_test_talk(); + let state = TobogganState::new(talk, 100).unwrap(); + + // Go to first slide (from Init), then navigate to last slide + state.handle_command(&Command::Next).await; + state.handle_command(&Command::Last).await; + + // Go next from last slide to reach Done state + state.handle_command(&Command::Next).await; + + // Navigate from Done state + let notification = state.handle_command(&Command::Previous).await; + + match notification { + Notification::State { + state: inner_state, .. + } => match inner_state { + State::Paused { .. } => {} + _ => panic!("Expected Paused state when navigating from Done"), + }, + _ => panic!("Expected State notification"), + } + } + + #[tokio::test] + async fn test_duration_tracking() { + let talk = create_test_talk(); + let state = TobogganState::new(talk, 100).unwrap(); + + // Start tracking by getting to Running state + state.handle_command(&Command::Next).await; + + // Wait a tiny bit to ensure duration > 0 + tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; + + // Pause and check duration + let notification = state.handle_command(&Command::Pause).await; + + match notification { + Notification::State { + state: inner_state, .. + } => match inner_state { + State::Paused { total_duration, .. } => { + assert!(total_duration > Duration::from_secs(0)); + } + _ => panic!("Expected Paused state"), + }, + _ => panic!("Expected State notification"), + } + } + + #[tokio::test] + async fn test_init_to_running_transition() { + let talk = create_test_talk(); + let state = TobogganState::new(talk, 100).unwrap(); + + // Verify initial state is Init + let initial_state = state.current_state().await; + assert!(matches!(initial_state, State::Init)); + + // Send Next command + let notification = state.handle_command(&Command::Next).await; + + // Verify notification contains Running state + match notification { + Notification::State { + state: inner_state, .. + } => match inner_state { + State::Running { current, .. } => { + assert_eq!(current, 0); // First slide index + } + _ => panic!("Expected Running state in notification, got: {inner_state:?}"), + }, + _ => panic!("Expected State notification"), + } + + // Verify current state is also Running + let current_state = state.current_state().await; + assert!(matches!(current_state, State::Running { .. })); + } + + #[tokio::test] + async fn test_paused_navigation_to_running() { + let talk = create_test_talk(); + let state = TobogganState::new(talk, 100).unwrap(); + + // Start running and move to second slide + state.handle_command(&Command::Next).await; // Init -> Running (first slide) + state.handle_command(&Command::Next).await; // Running (second slide) + + // Pause + let notification = state.handle_command(&Command::Pause).await; + match notification { + Notification::State { + state: inner_state, .. + } => assert!(matches!(inner_state, State::Paused { .. })), + _ => panic!("Expected State notification"), + } + + // Now navigate next - should go to Running + let notification = state.handle_command(&Command::Next).await; + match notification { + Notification::State { + state: inner_state, .. + } => match inner_state { + State::Running { current, .. } => { + // Should be on third (last) slide + assert_eq!(current, 2); // Index 2 (third slide) + } + _ => panic!("Expected Running state, got: {inner_state:?}"), + }, + _ => panic!("Expected State notification"), + } + } + + #[tokio::test] + async fn test_paused_on_last_slide_next_stays_paused() { + let talk = create_test_talk(); + let state = TobogganState::new(talk, 100).unwrap(); + + // Go to first slide, then navigate to last slide + state.handle_command(&Command::Next).await; // Init -> Running (first slide) + state.handle_command(&Command::Last).await; // Running -> Running (last slide) + + // Pause + state.handle_command(&Command::Pause).await; + + // Try next from last slide - should stay paused (no next slide available) + let notification = state.handle_command(&Command::Next).await; + match notification { + Notification::State { + state: inner_state, .. + } => assert!(matches!(inner_state, State::Paused { .. })), + _ => panic!("Expected State notification"), + } + } + + #[tokio::test] + async fn test_first_command_resets_timestamp() { + let talk = create_test_talk(); + let state = TobogganState::new(talk, 100).unwrap(); + + // Start running and navigate to second slide + state.handle_command(&Command::Next).await; // Init -> Running (first slide) + state.handle_command(&Command::Next).await; // Running (second slide) + + // Wait a bit to accumulate some duration + tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; + + // Pause to capture current duration + let pause_notification = state.handle_command(&Command::Pause).await; + let Notification::State { + state: State::Paused { total_duration, .. }, + .. + } = pause_notification + else { + panic!("Expected Paused state"); + }; + + // Verify we have some accumulated duration + assert!(total_duration > Duration::from_secs(0)); + + // Now go to first slide - this should reset the timestamp + let first_notification = state.handle_command(&Command::First).await; + match first_notification { + Notification::State { + state: State::Running { total_duration, .. }, + .. + } => { + // Duration should be reset to zero + assert_eq!(total_duration, Duration::ZERO); + } + _ => panic!("Expected Running state after First command"), + } + } +} diff --git a/toboggan-server/src/static_assets.rs b/toboggan-server/src/static_assets.rs new file mode 100644 index 0000000..c525e0c --- /dev/null +++ b/toboggan-server/src/static_assets.rs @@ -0,0 +1,6 @@ +use rust_embed::RustEmbed; + +/// Embedded web assets from toboggan-web/dist +#[derive(RustEmbed)] +#[folder = "../toboggan-web/dist"] +pub struct WebAssets; diff --git a/toboggan-server/src/watcher.rs b/toboggan-server/src/watcher.rs new file mode 100644 index 0000000..9af171b --- /dev/null +++ b/toboggan-server/src/watcher.rs @@ -0,0 +1,79 @@ +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::Context; +use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher as _}; +use tokio::sync::mpsc; +use tracing::{error, info, warn}; + +use crate::TobogganState; + +const DEBOUNCE_DURATION: Duration = Duration::from_millis(300); + +pub fn start_watch_task(talk_path: PathBuf, state: TobogganState) -> anyhow::Result<()> { + info!(path = %talk_path.display(), "Starting file watcher"); + + let (tx, mut rx) = mpsc::channel::>(100); + + let mut watcher: RecommendedWatcher = notify::recommended_watcher(move |res| { + if tx.blocking_send(res).is_err() { + error!("Failed to send file watcher event - channel closed"); + } + }) + .context("Failed to create file watcher")?; + + watcher + .watch(&talk_path, RecursiveMode::NonRecursive) + .with_context(|| format!("Failed to watch file: {}", talk_path.display()))?; + + tokio::spawn(async move { + let mut last_reload = tokio::time::Instant::now(); + let _watcher = watcher; // Keep watcher alive + + while let Some(event_result) = rx.recv().await { + match event_result { + Ok(event) => { + if should_reload(&event) { + let now = tokio::time::Instant::now(); + let elapsed = now.duration_since(last_reload); + + if elapsed >= DEBOUNCE_DURATION { + info!("File change detected, reloading talk"); + if let Err(err) = reload_talk(&talk_path, &state).await { + error!("Failed to reload talk: {err:?}"); + } else { + last_reload = now; + } + } + } + } + Err(err) => { + warn!("File watcher error: {err}"); + } + } + } + + info!("File watcher task stopped"); + }); + + Ok(()) +} + +fn should_reload(event: &Event) -> bool { + matches!( + event.kind, + EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_) + ) +} + +async fn reload_talk(path: &Path, state: &TobogganState) -> anyhow::Result<()> { + let content = tokio::fs::read_to_string(path) + .await + .with_context(|| format!("Reading talk file {}", path.display()))?; + + let new_talk = toml::from_str(&content).context("Parsing talk TOML")?; + + state.reload_talk(new_talk).await?; + + Ok(()) +} diff --git a/toboggan-server/talk.toml b/toboggan-server/talk.toml new file mode 100644 index 0000000..c006a03 --- /dev/null +++ b/toboggan-server/talk.toml @@ -0,0 +1,88 @@ +date = "2025-11-13" + +[title] +type = "Text" +text = "Peut-on RIIR de tout ?" + +[[slides]] +kind = "Cover" +style = [] + +[slides.title] +type = "Text" +text = "Peut-on RIIR de tout ?" + +[slides.body] +type = "Empty" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Introduction" + +[slides.body] +type = "Html" +raw = """ + +

+RIIR : "Have you considered Rewriting It In Rust?" +

+

+Une question qui fait sourire... mais qui cache une rรฉalitรฉ : Rust gagne du terrain partout. +

+ """ + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Part" +style = [] + +[slides.title] +type = "Text" +text = "1. Les Success Stories du RIIR" + +[slides.body] +type = "Empty" + +[slides.notes] +type = "Empty" + +[[slides]] +kind = "Standard" +style = [] + +[slides.title] +type = "Text" +text = "Des rรฉรฉcritures qui ont fait leurs preuves" + +[slides.body] +type = "Html" +raw = """ + + +- **ripgrep** (`rg`) : grep rรฉรฉcrit en Rust + - 10x plus rapide que grep classique + - Recherche rรฉcursive native + - Support Unicode complet + +- **fd** : find rรฉรฉcrit en Rust + - Interface plus intuitive + - Performances supรฉrieures + - Respect des .gitignore par dรฉfaut + +- **Fish Shell** : Shell moderne + - Autocomplรฉtion intelligente + - Sรฉcuritรฉ mรฉmoire + - Configuration simple + """ + +[slides.notes] +type = "Empty" diff --git a/toboggan-server/tests/common/mod.rs b/toboggan-server/tests/common/mod.rs new file mode 100644 index 0000000..50d3ec6 --- /dev/null +++ b/toboggan-server/tests/common/mod.rs @@ -0,0 +1,25 @@ +#[allow(dead_code)] +use toboggan_core::{Date, Slide, Talk}; + +#[allow(dead_code)] +pub fn create_test_talk() -> Talk { + Talk::new("Test Talk") + .with_date(Date::ymd(2025, 1, 1)) + .add_slide(Slide::cover("Cover Slide")) + .add_slide(Slide::new("Second Slide")) + .add_slide(Slide::new("Third Slide")) +} + +#[allow(dead_code)] +pub fn create_multi_slide_talk() -> Talk { + Talk::new("Multi-Client Sync Test Talk") + .with_date(Date::ymd(2025, 1, 25)) + .add_slide(Slide::cover("Cover Slide").with_body("This is the cover slide")) + .add_slide( + Slide::new("First Content Slide") + .with_body("This is the first content slide") + .with_notes("Notes for first slide"), + ) + .add_slide(Slide::new("Second Content Slide").with_body("This is the second content slide")) + .add_slide(Slide::new("Final Slide").with_body("This is the final slide")) +} diff --git a/toboggan-server/tests/generate_openapi.rs b/toboggan-server/tests/generate_openapi.rs new file mode 100644 index 0000000..f728441 --- /dev/null +++ b/toboggan-server/tests/generate_openapi.rs @@ -0,0 +1,280 @@ +use std::net::TcpListener; +use std::time::Duration as StdDuration; + +use anyhow::Context; +use clawspec_core::test_client::{TestClient, TestServer, TestServerConfig}; +use clawspec_core::{ApiClient, register_schemas}; +use serde_json::{Value, json}; +use toboggan_core::{ + ClientId, Command, Content, Date, Duration, Notification, Slide, SlideKind, SlidesResponse, + State, Style, Talk, TalkResponse, Timestamp, +}; +use toboggan_server::{HealthResponse, HealthResponseStatus, TobogganState, routes}; +use utoipa::openapi::{ContactBuilder, InfoBuilder, LicenseBuilder, OpenApi, ServerBuilder}; + +#[derive(Debug, Clone)] +struct TobogganTestServer { + router: axum::Router, +} + +impl TobogganTestServer { + #[allow(clippy::unwrap_used)] + fn new() -> Self { + // Create a test talk + let talk = Talk::new("Test Presentation") + .with_date(Date::ymd(2025, 1, 20)) + .add_slide(Slide::cover("Welcome").with_body("This is a test presentation")) + .add_slide( + Slide::new("Content Slide") + .with_body(Content::html("

Hello World

")) + .with_notes("Some notes for the presenter"), + ); + + let state = TobogganState::new(talk, 100).unwrap(); + let router = routes(None, OpenApi::default()).with_state(state); + + Self { router } + } +} + +impl TestServer for TobogganTestServer { + type Error = std::io::Error; + + fn config(&self) -> TestServerConfig { + // Configure OpenAPI metadata + let info = InfoBuilder::new() + .title(env!("CARGO_PKG_NAME")) + .version(env!("CARGO_PKG_VERSION")) + .description(Some(env!("CARGO_PKG_DESCRIPTION"))) + .contact(Some( + ContactBuilder::new() + .name(Some("Igor Laborie")) + .email(Some("ilaborie@gmail.com")) + .build(), + )) + .license(Some( + LicenseBuilder::new() + .name("MIT OR Apache-2.0") + .identifier(Some("MIT OR Apache-2.0")) + .build(), + )) + .build(); + + let server = ServerBuilder::new() + .url("http://localhost:8080") + .description(Some("Local development server")) + .build(); + + let api_client_builder = ApiClient::builder().with_info(info).add_server(server); + + TestServerConfig { + api_client: Some(api_client_builder), + min_backoff_delay: StdDuration::from_millis(25), + max_backoff_delay: StdDuration::from_secs(2), + backoff_jitter: true, + max_retry_attempts: 15, + } + } + + async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> { + listener.set_nonblocking(true)?; + let listener = tokio::net::TcpListener::from_std(listener)?; + axum::serve(listener, self.router.clone().into_make_service()).await + } +} + +/// Create test application with clawspec `TestClient` with proper `OpenAPI` metadata +async fn create_test_app() -> anyhow::Result> { + let test_server = TobogganTestServer::new(); + let client = TestClient::start(test_server) + .await + .map_err(|err| anyhow::anyhow!("Failed to start test server: {err:?}"))?; + Ok(client) +} + +#[tokio::test] +async fn should_generate_openapi() -> anyhow::Result<()> { + let mut app = create_test_app().await?; + + // Register all schemas that have ToSchema implemented + register_schemas!( + app, + Content, + Date, + Duration, + HealthResponse, + HealthResponseStatus, + Notification, + Slide, + SlideKind, + SlidesResponse, + State, + Style, + TalkResponse, + Timestamp, + ClientId, + ) + .await; + + // Test all endpoints to generate comprehensive OpenAPI spec + basic_api_operations(&mut app).await?; + test_command_operations(&mut app).await?; + demonstrate_websocket_endpoint(&mut app).await?; + + // Generate and save OpenAPI specification + // Using JSON format for safer deserialization in the server + app.write_openapi("./openapi.json") + .await + .context("writing openapi.json file")?; + + Ok(()) +} + +#[allow(clippy::unwrap_used, clippy::indexing_slicing)] +async fn basic_api_operations(app: &mut TestClient) -> anyhow::Result<()> { + // Test health endpoint using Value for clawspec compatibility + let _health_response = app + .get("/health")? + .await? + .as_json::() + .await?; + + // Test get talk endpoint + let _talk_response = app + .get("/api/talk")? + .await? + .as_json::() + .await?; + + // Test get slides endpoint + let _slides_response = app + .get("/api/slides")? + .await? + .as_json::() + .await?; + + Ok(()) +} + +#[allow(clippy::unwrap_used, clippy::indexing_slicing)] +async fn test_command_operations(app: &mut TestClient) -> anyhow::Result<()> { + // Test ping command + let ping_command = Command::Ping; + let _ping_response = app + .post("/api/command")? + .json(&ping_command)? + .await? + .as_json::() + .await?; + + // Test register command + let register_command = Command::Register { + client: ClientId::new(), + }; + let _register_response = app + .post("/api/command")? + .json(®ister_command)? + .await? + .as_json::() + .await?; + + // Test navigation commands + let commands = vec![ + Command::Next, + Command::Previous, + Command::First, + Command::Last, + Command::Resume, + Command::Pause, + ]; + + for command in commands { + let _response = app + .post("/api/command")? + .json(&command)? + .await? + .as_json::() + .await?; + } + + Ok(()) +} + +async fn demonstrate_websocket_endpoint( + app: &mut TestClient, +) -> anyhow::Result<()> { + let _response = app.get("/api/ws")?.without_collection().await?; + + Ok(()) +} + +#[cfg(test)] +mod command_variants { + use super::*; + + #[tokio::test] + #[allow(clippy::unwrap_used, clippy::indexing_slicing)] + async fn test_all_command_variants() -> anyhow::Result<()> { + let app = create_test_app().await?; + + // Test all command variants to ensure they're documented in OpenAPI + let commands = vec![ + json!({"command": "Ping"}), + json!({"command": "First"}), + json!({"command": "Last"}), + json!({"command": "Next"}), + json!({"command": "Previous"}), + json!({"command": "Pause"}), + json!({"command": "Resume"}), + json!({ + "command": "Register", + "client": "550e8400-e29b-41d4-a716-446655440000", + "renderer": "Html" + }), + json!({ + "command": "Unregister", + "client": "550e8400-e29b-41d4-a716-446655440000" + }), + ]; + + for command in commands { + let response: Value = app + .post("/api/command")? + .json(&command)? + .await? + .as_json() + .await?; + + // All valid commands should return proper Notification responses + let response_type = response["type"].as_str().unwrap(); + assert!( + response_type == "State" || response_type == "Pong" || response_type == "Error", + "Unexpected response type: {response_type}" + ); + + // Check that the response has the expected structure for each type + match response_type { + "State" => { + // State notifications should have a state field + assert!( + response["state"].is_object(), + "State notification should have state field" + ); + } + "Error" => { + // Error notifications should have a message field + assert!( + response["message"].is_string(), + "Error notification should have message field" + ); + } + "Pong" => { + // Pong notifications have no additional fields + } + _ => panic!("Unexpected response type: {response_type}"), + } + } + + Ok(()) + } +} diff --git a/toboggan-server/tests/multi_client_sync.rs b/toboggan-server/tests/multi_client_sync.rs new file mode 100644 index 0000000..aa3c86f --- /dev/null +++ b/toboggan-server/tests/multi_client_sync.rs @@ -0,0 +1,219 @@ +#![allow(clippy::print_stdout)] +#![allow(clippy::expect_used)] +#![allow(clippy::unwrap_used)] +#![allow(clippy::match_wildcard_for_single_variants)] +#![allow(clippy::too_many_lines)] + +mod common; + +use std::sync::atomic::{AtomicU8, Ordering}; + +use anyhow::bail; +use common::create_multi_slide_talk; +use futures::stream::SplitSink; +use futures::{SinkExt, StreamExt}; +use toboggan_core::{ClientId, Command, Notification}; +use toboggan_server::{TobogganState, routes_with_cors}; +use tokio::net::{TcpListener, TcpStream}; +use tokio_tungstenite::tungstenite::protocol::Message as TungsteniteMessage; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async}; +use utoipa::openapi::OpenApi; + +static GLOBAL_TEST_COUNTER: AtomicU8 = AtomicU8::new(0); + +async fn create_test_server() -> (String, TobogganState) { + let talk = create_multi_slide_talk(); + let state = TobogganState::new(talk, 100).unwrap(); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server_url = format!("ws://127.0.0.1:{}/api/ws", addr.port()); + + let app = routes_with_cors(None, None, OpenApi::default()).with_state(state.clone()); + + tokio::spawn(async move { + axum::serve(listener, app.into_make_service()) + .await + .expect("Server failed"); + }); + + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + (server_url, state) +} + +async fn connect_and_register_client( + server_url: &str, + client_name: &str, +) -> anyhow::Result<( + SplitSink>, TungsteniteMessage>, + futures::stream::SplitStream>>, + ClientId, +)> { + let (ws_stream, _) = connect_async(server_url).await?; + let (mut ws_sender, mut ws_receiver) = ws_stream.split(); + + let client = ClientId::new(); + + let register_cmd = Command::Register { client }; + let register_msg = serde_json::to_string(®ister_cmd)?; + ws_sender + .send(TungsteniteMessage::text(register_msg)) + .await?; + + println!("[{client_name}] Sent registration for client: {client:?}"); + + if let Some(msg) = ws_receiver.next().await { + let msg = msg?; + if let TungsteniteMessage::Text(text) = msg { + let notification: Notification = serde_json::from_str(&text)?; + match notification { + Notification::State { state, .. } => { + println!("[{client_name}] Received initial state: {state:?}"); + } + _ => { + bail!("Expected State notification, got: {notification:?}"); + } + } + } + } + + Ok((ws_sender, ws_receiver, client)) +} + +async fn send_command_and_get_response( + ws_sender: &mut SplitSink>, TungsteniteMessage>, + ws_receiver: &mut futures::stream::SplitStream>>, + command: Command, + client_name: &str, +) -> anyhow::Result { + let cmd_msg = serde_json::to_string(&command)?; + ws_sender.send(TungsteniteMessage::text(cmd_msg)).await?; + println!("[{client_name}] Sent command: {command:?}"); + + loop { + if let Some(msg) = ws_receiver.next().await { + let msg = msg?; + if let TungsteniteMessage::Text(text) = msg { + let notification: Notification = serde_json::from_str(&text)?; + println!("[{client_name}] Received notification: {notification:?}"); + + match notification { + Notification::Pong => { + println!( + "[{client_name}] Ignoring pong, waiting for state notification..." + ); + } + _ => return Ok(notification), + } + } + } else { + bail!("No response received"); + } + } +} + +async fn wait_for_notification( + ws_receiver: &mut futures::stream::SplitStream>>, + client_name: &str, + timeout_ms: u64, +) -> anyhow::Result { + wait_for_recent_notification(ws_receiver, client_name, timeout_ms).await +} + +async fn wait_for_recent_notification( + ws_receiver: &mut futures::stream::SplitStream>>, + client_name: &str, + timeout_ms: u64, +) -> anyhow::Result { + let timeout = tokio::time::timeout(std::time::Duration::from_millis(timeout_ms), async { + loop { + if let Some(msg) = ws_receiver.next().await { + let msg = msg?; + if let TungsteniteMessage::Text(text) = msg { + let notification: Notification = serde_json::from_str(&text)?; + println!("[{client_name}] Received notification: {notification:?}"); + + match ¬ification { + Notification::Pong => { + println!( + "[{client_name}] Ignoring pong, waiting for state notification..." + ); + } + Notification::State { .. } => { + println!("[{client_name}] Received state notification"); + return Ok(notification); + } + _ => return Ok(notification), + } + } + } else { + bail!("No notification received"); + } + } + }); + + match timeout.await { + Ok(result) => result, + Err(err) => bail!("[{client_name}] Timeout waiting for notification: {err}"), + } +} + +#[tokio::test] +async fn test_client_disconnect_and_reconnect_sync() { + let _test_id = GLOBAL_TEST_COUNTER.fetch_add(1, Ordering::SeqCst); + + let (server_url, _state) = create_test_server().await; + + let (mut client1_sender, mut client1_receiver, _client1_id) = + connect_and_register_client(&server_url, "Client1") + .await + .expect("Failed to connect client 1"); + + let (client2_sender, mut client2_receiver, _client2_id) = + connect_and_register_client(&server_url, "Client2") + .await + .expect("Failed to connect client 2"); + + println!("Both clients connected successfully"); + + println!("\n=== Client 1 navigates to slide 2 ==="); + let _next_notification = send_command_and_get_response( + &mut client1_sender, + &mut client1_receiver, + Command::Next, + "Client1", + ) + .await + .expect("Failed to navigate next"); + + let _client2_sync = wait_for_notification(&mut client2_receiver, "Client2", 1000) + .await + .expect("Client 2 should receive sync"); + + drop(client2_sender); + drop(client2_receiver); + println!("Client 2 disconnected"); + + println!("\n=== Client 1 navigates to slide 3 (Client 2 disconnected) ==="); + let _next2_notification = send_command_and_get_response( + &mut client1_sender, + &mut client1_receiver, + Command::Next, + "Client1", + ) + .await + .expect("Failed to navigate next again"); + + println!("\n=== Client 2 reconnects ==="); + let (_client2_sender_new, _client2_receiver_new, _client2_id_new) = + connect_and_register_client(&server_url, "Client2-Reconnected") + .await + .expect("Failed to reconnect client 2"); + + println!("โœ… Client 2 successfully reconnected and received current state"); + + println!("\n๐ŸŽ‰ Client disconnect/reconnect test passed!"); + println!("โœ… Disconnected clients are properly cleaned up"); + println!("โœ… Reconnected clients receive the current state immediately"); +} diff --git a/toboggan-server/tests/state_serialization.rs b/toboggan-server/tests/state_serialization.rs new file mode 100644 index 0000000..7f443ad --- /dev/null +++ b/toboggan-server/tests/state_serialization.rs @@ -0,0 +1,51 @@ +use toboggan_core::{Duration, State, Timestamp}; + +#[test] +#[allow(clippy::unwrap_used, clippy::print_stdout)] // Acceptable in test code +fn test_state_serialization_format() { + let slide_index = 1; // Use slide index instead of SlideId + + let paused_state = State::Paused { + current: Some(slide_index), + total_duration: Duration::from_secs(10), + }; + + let running_state = State::Running { + since: Timestamp::now(), + current: slide_index, + total_duration: Duration::from_secs(5), + }; + + let paused_json = serde_json::to_string_pretty(&paused_state).unwrap(); + let running_json = serde_json::to_string_pretty(&running_state).unwrap(); + + println!("Paused state JSON:\n{paused_json}"); + println!("Running state JSON:\n{running_json}"); + + // Test round-trip + let paused_deserialized: State = serde_json::from_str(&paused_json).unwrap(); + let running_deserialized: State = serde_json::from_str(&running_json).unwrap(); + + match paused_deserialized { + State::Paused { + current, + total_duration, + } => { + assert_eq!(current, Some(slide_index)); + assert_eq!(total_duration, Duration::from_secs(10)); + } + _ => panic!("Expected Paused state"), + } + + match running_deserialized { + State::Running { + current, + total_duration, + .. + } => { + assert_eq!(current, slide_index); + assert_eq!(total_duration, Duration::from_secs(5)); + } + _ => panic!("Expected Running state"), + } +} diff --git a/toboggan-tui/Cargo.toml b/toboggan-tui/Cargo.toml new file mode 100644 index 0000000..a928ed9 --- /dev/null +++ b/toboggan-tui/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "toboggan-tui" +version = "0.1.0" +description = "Terminal user interface (TUI) for Toboggan presentations using ratatui" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +toboggan-core = {path = "../toboggan-core", features = ["std"]} +toboggan-client = {path = "../toboggan-client"} + +ratatui = { workspace = true } +crossterm = { workspace = true } +tokio = { workspace = true, features = ["full"] } +anyhow = { workspace = true } +clap = { workspace = true, features = ["env", "derive"] } +tracing= { workspace = true } +tracing-subscriber= { workspace = true } +tui-logger = { workspace = true, features = ["tracing-support"]} +derive_more = { workspace = true, features = ["display"] } + +[lints] +workspace = true diff --git a/toboggan-tui/rustfmt.toml b/toboggan-tui/rustfmt.toml new file mode 100644 index 0000000..2a732ff --- /dev/null +++ b/toboggan-tui/rustfmt.toml @@ -0,0 +1,5 @@ +# Rustfmt configuration +edition = "2024" +unstable_features = true +imports_granularity = "Module" +group_imports = "StdExternalCrate" \ No newline at end of file diff --git a/toboggan-tui/src/app.rs b/toboggan-tui/src/app.rs new file mode 100644 index 0000000..c1fdc05 --- /dev/null +++ b/toboggan-tui/src/app.rs @@ -0,0 +1,153 @@ +use std::cell::RefCell; +use std::io::Stdout; +use std::rc::Rc; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use crossterm::event::{self, Event}; +use ratatui::prelude::*; +use toboggan_client::{TobogganApi, TobogganConfig}; +use toboggan_core::{ClientConfig, Notification}; +use tokio::sync::mpsc; +use tracing::{debug, info}; + +use crate::connection_handler::ConnectionHandler; +use crate::events::AppEvent; +use crate::state::AppState; +use crate::ui::PresenterComponents; + +type TerminalType = Terminal>; + +const EVENT_POLL_TIMEOUT: Duration = Duration::from_millis(50); +const TICK_DELAY: Duration = Duration::from_millis(250); + +pub struct App { + state: Rc>, + terminal: TerminalType, + event_rx: mpsc::UnboundedReceiver, + event_tx: mpsc::UnboundedSender, + connection_handler: ConnectionHandler, + api: TobogganApi, +} + +impl App { + /// Create a new TUI application. + /// + /// # Errors + /// + /// Returns an error if terminal setup fails. + pub async fn new(terminal: TerminalType, config: &TobogganConfig) -> Result { + let api = TobogganApi::new(config.api_url()); + let talk = api.talk().await.context("fetching talk")?; + let slides = api.slides().await.context("fetching slides")?; + let (event_tx, event_rx) = mpsc::unbounded_channel(); + let state = AppState::new(talk, slides.slides); + let state = Rc::new(RefCell::new(state)); + let connection_handler = ConnectionHandler::new(config.clone(), event_tx.clone()); + + debug!("Config: {config:#?}"); + + Ok(Self { + state, + terminal, + event_rx, + event_tx, + connection_handler, + api, + }) + } + + /// Run the TUI application. + /// + /// # Errors + /// + /// Returns an error if the application fails to run. + pub fn run(&mut self) -> Result<()> { + info!("Starting Toboggan TUI Presenter"); + self.connection_handler.start(); + self.start_keyboard_handler(); + + let mut last_tick = Instant::now(); + 'main_loop: loop { + self.render_app().context("render")?; + + // Handle crossterm events (resize, etc.) + if crossterm::event::poll(EVENT_POLL_TIMEOUT).context("poll event")? + && let Ok(Event::Resize(cols, rows)) = event::read() + { + let mut state = self.state.borrow_mut(); + state.terminal_size = (cols, rows); + } + + // Handle app events + while let Ok(app_event) = self.event_rx.try_recv() { + // Intercept TalkChange to trigger refetch + if let AppEvent::NotificationReceived(ref notification) = app_event + && matches!(notification, Notification::TalkChange { .. }) + { + info!("๐Ÿ“ TalkChange received - spawning refetch task"); + let api = self.api.clone(); + let tx = self.event_tx.clone(); + tokio::spawn(async move { + match tokio::try_join!(api.talk(), api.slides()) { + Ok((talk, slides)) => { + info!("โœ… Talk and slides refetched"); + let _ = tx.send(AppEvent::TalkAndSlidesRefetched( + Box::new(talk), + slides.slides, + )); + } + Err(err) => { + let _ = + tx.send(AppEvent::Error(format!("Failed to refetch: {err}"))); + } + } + }); + } + + let mut state = self.state.borrow_mut(); + if state + .handle_event(app_event, &self.connection_handler) + .is_break() + { + break 'main_loop; // Quit requested + } + } + + // Send tick event periodically + if last_tick.elapsed() >= TICK_DELAY { + let _ = self.event_tx.send(AppEvent::Tick); + last_tick = Instant::now(); + } + } + + Ok(()) + } + + fn render_app(&mut self) -> Result<()> { + let shared_state = Rc::clone(&self.state); + self.terminal + .draw(move |frame| { + let components = PresenterComponents::default(); + let mut state = shared_state.borrow_mut(); + frame.render_stateful_widget(&components, frame.area(), &mut state); + }) + .context("drawing")?; + + Ok(()) + } + + fn start_keyboard_handler(&self) { + let tx = self.event_tx.clone(); + tokio::spawn(async move { + loop { + if let Ok(event) = event::read() + && let Event::Key(key) = event + && tx.send(AppEvent::Key(key)).is_err() + { + break; + } + } + }); + } +} diff --git a/toboggan-tui/src/config.rs b/toboggan-tui/src/config.rs new file mode 100644 index 0000000..38852a2 --- /dev/null +++ b/toboggan-tui/src/config.rs @@ -0,0 +1,23 @@ +//! Configuration for the TUI application using toboggan-client. +//! +//! Re-exports the shared configuration types from toboggan-client. + +pub use toboggan_client::TobogganConfig as Config; +use toboggan_core::ClientConfig; + +/// Create config from command line arguments, using toboggan-client defaults +#[must_use] +pub fn build_config(websocket_url: Option, api_url: Option) -> Config { + match (websocket_url, api_url) { + (Some(ws_url), Some(api_url)) => Config::new(api_url, ws_url), + (Some(ws_url), None) => { + let config = Config::default(); + Config::new(config.api_url().to_string(), ws_url) + } + (None, Some(api_url)) => { + let config = Config::default(); + Config::new(api_url, config.websocket_url().to_string()) + } + (None, None) => Config::default(), + } +} diff --git a/toboggan-tui/src/connection_handler.rs b/toboggan-tui/src/connection_handler.rs new file mode 100644 index 0000000..c54df15 --- /dev/null +++ b/toboggan-tui/src/connection_handler.rs @@ -0,0 +1,84 @@ +// Re-export toboggan-client types +pub use toboggan_client::{CommunicationMessage, WebSocketClient}; +use toboggan_client::{ConnectionStatus, TobogganConfig}; +use toboggan_core::{ClientConfig, Command, Notification}; +use tokio::sync::mpsc; +use tracing::{error, info}; + +use crate::events::AppEvent; + +pub struct ConnectionHandler { + config: TobogganConfig, + event_tx: mpsc::UnboundedSender, + command_tx: Option>, +} + +impl ConnectionHandler { + #[must_use] + pub fn new(config: TobogganConfig, event_tx: mpsc::UnboundedSender) -> Self { + Self { + config, + event_tx, + command_tx: None, + } + } + + /// Start WebSocket client connection using toboggan-client + pub fn start(&mut self) { + info!("Starting WebSocket client using toboggan-client"); + + let (command_tx, command_rx) = mpsc::unbounded_channel(); + self.command_tx = Some(command_tx.clone()); + + let client_id = self.config.client_id(); + let websocket_config = self.config.websocket(); + + let (mut ws_client, mut message_rx) = + WebSocketClient::new(command_tx.clone(), command_rx, client_id, websocket_config); + + let event_tx = self.event_tx.clone(); + let _ = event_tx.send(AppEvent::ConnectionStatus(ConnectionStatus::Closed)); + + // Start the WebSocket client + tokio::spawn(async move { + ws_client.connect().await; + }); + + // Handle messages from the WebSocket client + let event_tx_clone = self.event_tx.clone(); + tokio::spawn(async move { + while let Some(message) = message_rx.recv().await { + match message { + CommunicationMessage::ConnectionStatusChange { status } => { + let _ = event_tx_clone.send(AppEvent::ConnectionStatus(status)); + } + CommunicationMessage::StateChange { state } => { + let _ = event_tx_clone.send(AppEvent::NotificationReceived( + Notification::State { state }, + )); + } + CommunicationMessage::TalkChange { state } => { + tracing::info!("๐Ÿ“ Presentation updated"); + let _ = event_tx_clone.send(AppEvent::NotificationReceived( + Notification::TalkChange { state }, + )); + } + CommunicationMessage::Error { error } => { + let _ = event_tx_clone.send(AppEvent::Error(error)); + } + } + } + }); + } + + /// Send a command through the WebSocket connection + pub fn send_command(&self, command: &Command) { + if let Some(command_tx) = &self.command_tx { + if let Err(err) = command_tx.send(command.clone()) { + error!("Failed to send command through WebSocket: {err}"); + } + } else { + error!("WebSocket command channel not available"); + } + } +} diff --git a/toboggan-tui/src/events.rs b/toboggan-tui/src/events.rs new file mode 100644 index 0000000..cbb566f --- /dev/null +++ b/toboggan-tui/src/events.rs @@ -0,0 +1,125 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use toboggan_client::ConnectionStatus; +use toboggan_core::{Command, Notification, Slide, TalkResponse}; + +#[derive(Debug, Clone)] +pub enum AppEvent { + Key(KeyEvent), + Tick, + + // Could refactor to use toboggan_client::CommunicationMessage for consistency + NotificationReceived(Notification), + ConnectionStatus(ConnectionStatus), + TalkAndSlidesRefetched(Box, Vec), + Error(String), +} + +#[derive(Debug, Clone, Copy, derive_more::Display)] +pub enum AppAction { + First, + Previous, + Next, + Last, + Pause, + #[display("Slide {_0}")] + Goto(u8), + Resume, + #[display("โ™ช")] + Blink, + + #[display("Show log")] + ShowLog, + Close, + Quit, + Help, +} + +impl AppAction { + pub(crate) fn from_key(event: KeyEvent) -> Option { + let action = match event.code { + KeyCode::Char('q' | 'Q') => Self::Quit, + KeyCode::Char('c') if event.modifiers.contains(KeyModifiers::CONTROL) => Self::Quit, + KeyCode::Char('h' | 'H' | '?') => Self::Help, + KeyCode::Left | KeyCode::Up => Self::Previous, + KeyCode::Right | KeyCode::Down | KeyCode::Char(' ') => Self::Next, + KeyCode::Home => Self::First, + KeyCode::End => Self::Last, + KeyCode::Char('p' | 'P') => Self::Pause, + KeyCode::Char('r' | 'R') => Self::Resume, + KeyCode::Char('b' | 'B') => Self::Blink, + KeyCode::Char(ch @ '1'..='9') => + { + #[allow(clippy::expect_used)] + Self::Goto(ch.to_string().parse().expect("1..=9 should parse")) + } + KeyCode::Char('l' | 'L') => Self::ShowLog, + KeyCode::Esc => Self::Close, + _ => { + return None; + } + }; + Some(action) + } + + pub(crate) fn key(self) -> &'static str { + match self { + Self::First => "Home", + Self::Previous => "Left", + Self::Next => "Right", + Self::Last => "End", + Self::Pause => "p", + Self::Goto(_) => "1..n", + Self::Resume => "r", + Self::Blink => "b", + Self::ShowLog => "l", + Self::Close => "Esc", + Self::Quit => "q", + Self::Help => "?", + } + } + + pub(crate) fn details(self) -> ActionDetails { + match self { + Self::First => ActionDetails::new(vec!["Home"], "Go to first slide"), + Self::Previous => ActionDetails::new(vec!["Left", "Up"], "Go to previous slide"), + Self::Next => ActionDetails::new(vec!["Right", "Down", "Space"], "Go to next slide"), + Self::Last => ActionDetails::new(vec!["End"], "Go to last slide"), + Self::Pause => ActionDetails::new(vec!["p", "P"], "Pause"), + Self::Goto(_) => ActionDetails::new(vec!["1..n"], "Go to slide n"), + Self::Resume => ActionDetails::new(vec!["r", "R"], "Resume"), + Self::Blink => ActionDetails::new(vec!["b", "B"], "Bell or Blink"), + Self::ShowLog => ActionDetails::new(vec!["l", "L"], "Show logs"), + Self::Close => ActionDetails::new(vec!["Esc"], "Close popup"), + Self::Quit => ActionDetails::new(vec!["q", "Q", "Ctrl-c"], "Quit"), + Self::Help => ActionDetails::new(vec!["?", "h", "H"], "Show help"), + } + } + + pub(crate) fn command(self) -> Option { + let cmd = match self { + Self::First => Command::First, + Self::Previous => Command::Previous, + Self::Next => Command::Next, + Self::Last => Command::Last, + Self::Pause => Command::Pause, + Self::Resume => Command::Resume, + Self::Blink => Command::Blink, + Self::Goto(id) => Command::GoTo { slide: id.into() }, + Self::ShowLog | Self::Close | Self::Quit | Self::Help => { + return None; + } + }; + Some(cmd) + } +} + +pub struct ActionDetails { + pub(crate) keys: Vec<&'static str>, + pub(crate) description: &'static str, +} + +impl ActionDetails { + pub fn new(keys: Vec<&'static str>, description: &'static str) -> Self { + Self { keys, description } + } +} diff --git a/toboggan-tui/src/lib.rs b/toboggan-tui/src/lib.rs new file mode 100644 index 0000000..3a8c661 --- /dev/null +++ b/toboggan-tui/src/lib.rs @@ -0,0 +1,9 @@ +#![allow(clippy::missing_errors_doc)] + +mod app; +pub use self::app::*; + +pub(crate) mod connection_handler; +pub(crate) mod events; +pub(crate) mod state; +pub(crate) mod ui; diff --git a/toboggan-tui/src/main.rs b/toboggan-tui/src/main.rs new file mode 100644 index 0000000..c58a400 --- /dev/null +++ b/toboggan-tui/src/main.rs @@ -0,0 +1,42 @@ +use anyhow::{Context, Result}; +use clap::Parser; +use toboggan_client::TobogganConfig; +use toboggan_tui::App; +use tracing_subscriber::prelude::*; + +#[derive(Parser)] +#[command(name = "toboggan-tui")] +#[command(about = "Terminal-based Toboggan presentation client")] +struct Cli { + #[arg(long, default_value = "localhost")] + host: String, + + #[arg(long, default_value = "8080")] + port: u16, +} + +#[tokio::main] +async fn main() -> Result<()> { + let Cli { host, port } = Cli::parse(); + + // Setup tui-logger + tracing_subscriber::registry() + .with(tui_logger::TuiTracingSubscriberLayer) + .init(); + tui_logger::init_logger(tui_logger::LevelFilter::Debug).context("init tui_logger")?; + + // Create config using toboggan-client shared config + let config = TobogganConfig::new(&host, port); + + // Run the app + let terminal = ratatui::init(); + let result = { + App::new(terminal, &config) + .await + .context("create app") + .and_then(|mut app| app.run()) + }; + ratatui::restore(); + + result +} diff --git a/toboggan-tui/src/state.rs b/toboggan-tui/src/state.rs new file mode 100644 index 0000000..508d3ec --- /dev/null +++ b/toboggan-tui/src/state.rs @@ -0,0 +1,166 @@ +use std::ops::ControlFlow; + +use toboggan_client::ConnectionStatus; +use toboggan_core::{Notification, Slide, State, TalkResponse}; +use tracing::{debug, info}; + +use crate::connection_handler::ConnectionHandler; +use crate::events::{AppAction, AppEvent}; + +#[derive(Debug, Clone, Default)] +pub enum AppDialog { + Help, + Log, + Error(String), + #[default] + None, +} + +pub struct AppState { + // pub(crate) config: Config, + pub(crate) connection_status: ConnectionStatus, + pub(crate) current_slide: Option, + + pub(crate) talk: TalkResponse, + pub(crate) slides: Vec, + + pub(crate) presentation_state: State, + + pub(crate) dialog: AppDialog, + pub(crate) terminal_size: (u16, u16), +} + +impl AppState { + #[must_use] + pub fn new(talk: TalkResponse, slides: Vec) -> Self { + Self { + connection_status: ConnectionStatus::Closed, + current_slide: None, + talk, + slides, + presentation_state: State::Init, + dialog: AppDialog::None, + terminal_size: (80, 24), + } + } + + pub(crate) fn is_connected(&self) -> bool { + matches!(self.connection_status, ConnectionStatus::Connected) + } + + pub(crate) fn current(&self) -> usize { + self.current_slide.unwrap_or_default() + } + + pub(crate) fn count(&self) -> usize { + self.talk.titles.len() + } + + pub(crate) fn is_first_slide(&self) -> bool { + self.current_slide == Some(0) + } + + pub(crate) fn is_last_slide(&self) -> bool { + if let Some(current) = self.current_slide { + current == self.slides.len().saturating_sub(1) + } else { + false + } + } + + pub(crate) fn current_slide(&self) -> Option<&Slide> { + let current_index = self.current_slide?; + self.slides.get(current_index) + } + + pub(crate) fn next_slide(&self) -> Option<&Slide> { + let current_index = self.current_slide?; + self.slides.get(current_index + 1) + } + + // Event handling methods + pub fn handle_event( + &mut self, + event: AppEvent, + connection_handler: &ConnectionHandler, + ) -> ControlFlow<()> { + // if !matches!(event, AppEvent::Tick) { + // debug!("Handling event: {event:?}"); + // } + + match event { + AppEvent::Key(key) => { + debug!("Handling key event: {key:?}"); + let action = AppAction::from_key(key); + if let Some(action) = action { + return self.handle_action(action, connection_handler); + } + } + AppEvent::ConnectionStatus(status) => { + info!("{status}"); + if let ConnectionStatus::Error { message } = &status { + self.dialog = AppDialog::Error(message.clone()); + } + self.connection_status = status; + } + AppEvent::NotificationReceived(notification) => { + self.handle_notification(notification); + } + AppEvent::TalkAndSlidesRefetched(talk, slides) => { + info!("๐Ÿ“ Updating talk and slides from refetch"); + self.talk = *talk; + self.slides = slides; + } + AppEvent::Error(error) => { + self.dialog = AppDialog::Error(error); + } + AppEvent::Tick => {} + } + + ControlFlow::Continue(()) + } + + fn handle_action( + &mut self, + action: AppAction, + connection_handler: &ConnectionHandler, + ) -> ControlFlow<()> { + self.dialog = match action { + AppAction::Close => AppDialog::None, + AppAction::Help => AppDialog::Help, + AppAction::ShowLog => AppDialog::Log, + AppAction::Quit => { + return ControlFlow::Break(()); + } + _ => { + if let Some(cmd) = action.command() { + connection_handler.send_command(&cmd); + } + AppDialog::None + } + }; + ControlFlow::Continue(()) + } + + fn handle_notification(&mut self, notification: Notification) { + match notification { + Notification::State { state } => { + self.current_slide = state.current(); + self.presentation_state = state; + } + Notification::TalkChange { state } => { + // Presentation updated - state already has correct slide position + self.current_slide = state.current(); + self.presentation_state = state; + } + Notification::Pong | Notification::Blink => { + // Pong: heartbeat response, no UI action needed + // Blink: visual effect not implemented in TUI + // TODO https://github.com/junkdog/tachyonfx + } + Notification::Error { message } => { + self.dialog = AppDialog::Error(message); + } + } + } +} diff --git a/toboggan-tui/src/ui/mod.rs b/toboggan-tui/src/ui/mod.rs new file mode 100644 index 0000000..b0b9d8b --- /dev/null +++ b/toboggan-tui/src/ui/mod.rs @@ -0,0 +1,114 @@ +use ratatui::layout::Flex; +use ratatui::prelude::*; +use ratatui::symbols::border; +use ratatui::widgets::{Block, Clear, Paragraph, Wrap}; +use tui_logger::TuiLoggerWidget; + +use crate::state::{AppDialog, AppState}; +use crate::ui::styles::layout; +use crate::ui::widgets::{ + CurrentSlide, HelpPanel, NextSlidePreview, ProgressBar, SlideList, SpeakerNotes, TitleBar, +}; + +pub mod styles; +pub mod widgets; + +#[derive(Default)] +pub struct PresenterComponents { + title_bar: TitleBar, + progress_bar: ProgressBar, + slide_list: SlideList, + current_slide: CurrentSlide, + next_slide_preview: NextSlidePreview, + speaker_notes: SpeakerNotes, + help_panel: HelpPanel, +} + +impl PresenterComponents {} + +impl StatefulWidget for &PresenterComponents { + type State = AppState; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + let main_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + layout::TOP_BAR, + layout::MAIN_CONTENT, + layout::SPEAKER_NOTES, + // layout::LOG_PANEL, // Log panel + ]); + let [top_area, content_area, notes_area] = main_layout.areas(area); + + let content_layout = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(layout::SLIDE_LIST_PERCENTAGE), + Constraint::Percentage(layout::CURRENT_SLIDE_PERCENTAGE), + Constraint::Percentage(layout::NEXT_SLIDE_PERCENTAGE), + ]); + let [slides_area, current_area, next_area] = content_layout.areas(content_area); + + // Topbar - split into title and progress areas + let top_layout = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Min(layout::CONTROL_TITLE_MIN_WIDTH), + Constraint::Length(layout::CONTROL_PROGRESS_WIDTH), + ]); + let [title_area, progress_area] = top_layout.areas(top_area); + + (&self.title_bar).render(title_area, buf, state); + (&self.progress_bar).render(progress_area, buf, state); + + // Main content area - 3 columns + (&self.slide_list).render(slides_area, buf, state); + (&self.current_slide).render(current_area, buf, state); + (&self.next_slide_preview).render(next_area, buf, state); + + // Notes + (&self.speaker_notes).render(notes_area, buf, state); + + // Dialogs + match &state.dialog { + AppDialog::Help => { + let area = popup_area(area, 52, 22); + Clear.render(area, buf); + (&self.help_panel).render(area, buf); + } + AppDialog::Log => { + // let area = popup_area(area, 80, 40); + Clear.render(area, buf); + let block = Block::bordered().title(" Logs").border_set(border::ROUNDED); + TuiLoggerWidget::default() + .block(block) + .style_debug(styles::log::DEBUG) + .style_info(styles::log::INFO) + .style_warn(styles::log::WARN) + .style_error(styles::log::ERROR) + .render(area, buf); + } + AppDialog::Error(error) => { + let area = popup_area(area, 60, 8); + Clear.render(area, buf); + let block = Block::bordered() + .title(" ๐Ÿšจ Error ") + .border_set(border::ROUNDED); + let content = Line::from(Span::styled(error, styles::colors::RED)); + Paragraph::new(content) + .block(block) + .wrap(Wrap { trim: true }) + .render(area, buf); + } + AppDialog::None => {} + } + } +} + +fn popup_area(area: Rect, x: u16, y: u16) -> Rect { + let vertical = Layout::vertical([Constraint::Length(y)]).flex(Flex::Center); + let horizontal = Layout::horizontal([Constraint::Length(x)]).flex(Flex::Center); + let [area] = vertical.areas(area); + let [area] = horizontal.areas(area); + area +} diff --git a/toboggan-tui/src/ui/styles.rs b/toboggan-tui/src/ui/styles.rs new file mode 100644 index 0000000..c13d680 --- /dev/null +++ b/toboggan-tui/src/ui/styles.rs @@ -0,0 +1,110 @@ +use ratatui::style::{Modifier, Style}; + +pub mod colors { + use ratatui::style::Color; + + pub const WHITE: Color = Color::White; + pub const BLACK: Color = Color::Black; + pub const GRAY: Color = Color::Gray; + + pub const GREEN: Color = Color::Green; + pub const RED: Color = Color::Red; + pub const YELLOW: Color = Color::Yellow; + pub const BLUE: Color = Color::Blue; + pub const CYAN: Color = Color::Cyan; + pub const MAGENTA: Color = Color::Magenta; +} + +pub mod action { + use super::{Modifier, Style, colors}; + + pub const KEY: Style = Style::new().fg(colors::CYAN); + pub const DESCRIPTION: Style = Style::new().fg(colors::GRAY); + pub const TITLE: Style = Style::new().add_modifier(Modifier::BOLD); +} + +pub mod log { + use super::{Style, colors}; + + pub const DEBUG: Style = Style::new().fg(colors::GREEN); + pub const INFO: Style = Style::new().fg(colors::BLUE); + pub const WARN: Style = Style::new().fg(colors::YELLOW); + pub const ERROR: Style = Style::new().fg(colors::RED); +} + +/// Talk state styles +pub mod state { + use super::{Style, colors}; + + pub const PAUSED: Style = Style::new().fg(colors::YELLOW); + pub const RUNNING: Style = Style::new().fg(colors::GRAY); + pub const DONE: Style = Style::new().fg(colors::GREEN); +} + +/// Slide kind specific styles +pub mod slide { + use ratatui::text::Span; + use toboggan_core::SlideKind; + + use super::{Modifier, Style, colors}; + + pub const COVER_STYLE: Style = Style::new().fg(colors::YELLOW).add_modifier(Modifier::BOLD); + + pub const PART_STYLE: Style = Style::new() + .fg(colors::MAGENTA) + .add_modifier(Modifier::BOLD); + + pub const STANDARD_STYLE: Style = Style::new().fg(colors::WHITE).add_modifier(Modifier::BOLD); + + /// Get style and indicator for a slide kind + #[must_use] + pub fn get_slide_kind_span<'a>(kind: SlideKind) -> Span<'a> { + match kind { + SlideKind::Cover => Span::styled(" [COVER]", COVER_STYLE), + SlideKind::Part => Span::styled(" [PART]", PART_STYLE), + SlideKind::Standard => Span::styled("", STANDARD_STYLE), + } + } +} + +/// List and selection styles +pub mod list { + use super::{Modifier, Style, colors}; + + pub const CURRENT_SLIDE_STYLE: Style = Style::new() + .fg(colors::BLACK) + .bg(colors::YELLOW) + .add_modifier(Modifier::BOLD); + + pub const NORMAL_SLIDE_STYLE: Style = Style::new().fg(colors::WHITE); +} + +/// General UI styles +pub mod ui { + use super::{Modifier, Style, colors}; + + pub const NO_CONTENT_STYLE: Style = + Style::new().fg(colors::GRAY).add_modifier(Modifier::ITALIC); +} + +/// Layout constraints commonly used +pub mod layout { + use ratatui::layout::Constraint; + + // Control bar layout + pub const CONTROL_BAR_HEIGHT: u16 = 3; + pub const SPEAKER_NOTES_HEIGHT: u16 = 16; + // Control bar horizontal layout + pub const CONTROL_TITLE_MIN_WIDTH: u16 = 20; + pub const CONTROL_PROGRESS_WIDTH: u16 = 30; + + // Main content area percentages + pub const SLIDE_LIST_PERCENTAGE: u16 = 20; + pub const CURRENT_SLIDE_PERCENTAGE: u16 = 50; + pub const NEXT_SLIDE_PERCENTAGE: u16 = 30; + + // Common constraints + pub const TOP_BAR: Constraint = Constraint::Length(CONTROL_BAR_HEIGHT); + pub const MAIN_CONTENT: Constraint = Constraint::Min(8); + pub const SPEAKER_NOTES: Constraint = Constraint::Length(SPEAKER_NOTES_HEIGHT); +} diff --git a/toboggan-tui/src/ui/widgets/current_slide.rs b/toboggan-tui/src/ui/widgets/current_slide.rs new file mode 100644 index 0000000..6c564ab --- /dev/null +++ b/toboggan-tui/src/ui/widgets/current_slide.rs @@ -0,0 +1,60 @@ +use ratatui::prelude::*; +use ratatui::symbols::border; +use ratatui::widgets::{Block, Paragraph, Wrap}; + +use crate::events::AppAction; +use crate::state::AppState; +use crate::ui::styles; +use crate::ui::widgets::line_from_actions; + +#[derive(Debug, Default)] +pub struct CurrentSlide {} + +impl StatefulWidget for &CurrentSlide { + type State = AppState; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + let Some(slide) = state.current_slide() else { + super::render_no_content(area, buf, "no slide active", border::DOUBLE); + return; + }; + + let title = Line::from(vec![ + Span::raw(" "), + Span::raw(slide.title.to_string()), + Span::raw(" "), + ]); + + let actions = slide_actions(state); + let bottom = line_from_actions(&actions); + + let kind = styles::slide::get_slide_kind_span(slide.kind); + + let block = Block::bordered() + .title(Line::from(kind).right_aligned()) + .title(title.bold()) + .title_bottom(bottom.centered()) + .border_set(border::DOUBLE); + + let content_text = slide.body.to_string(); + let content = super::format_content_lines(&content_text); + Paragraph::new(content) + .block(block) + .wrap(Wrap { trim: true }) + .render(area, buf); + } +} + +fn slide_actions(state: &AppState) -> Vec { + let mut actions = vec![]; + + if !state.is_first_slide() { + actions.extend([AppAction::First, AppAction::Previous]); + } + + if !state.is_last_slide() { + actions.extend([AppAction::Next, AppAction::Last]); + } + + actions +} diff --git a/toboggan-tui/src/ui/widgets/help_panel.rs b/toboggan-tui/src/ui/widgets/help_panel.rs new file mode 100644 index 0000000..2864fbd --- /dev/null +++ b/toboggan-tui/src/ui/widgets/help_panel.rs @@ -0,0 +1,75 @@ +use ratatui::prelude::*; +use ratatui::symbols::border; +use ratatui::widgets::{Block, Paragraph}; + +use crate::events::{ActionDetails, AppAction}; +use crate::ui::styles; + +#[derive(Debug, Default)] +pub struct HelpPanel {} + +impl Widget for &HelpPanel { + fn render(self, area: Rect, buf: &mut Buffer) { + let block = Block::bordered() + .title(" Help ") + .border_set(border::ROUNDED); + + let mut content = vec![]; + content.extend(build_lines( + "Navigation", + &[ + AppAction::First, + AppAction::Previous, + AppAction::Goto(1), + AppAction::Next, + AppAction::Last, + ], + )); + + content.extend(build_lines( + "Presentation", + &[AppAction::Pause, AppAction::Resume, AppAction::Blink], + )); + + content.extend(build_lines( + "Application", + &[ + AppAction::Close, + AppAction::ShowLog, + AppAction::Quit, + AppAction::Help, + ], + )); + + Paragraph::new(content).block(block).render(area, buf); + } +} + +fn build_lines<'a>(title: &'a str, actions: &'a [AppAction]) -> Vec> { + let mut lines = vec![]; + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!(" โ– {title}"), + styles::action::TITLE, + ))); + for action in actions { + let ActionDetails { keys, description } = action.details(); + let mut keys_len = 0; + let mut spans = vec![]; + spans.push(Span::raw(" ")); + for key in keys { + spans.push(Span::raw(" ")); + let key = format!("[{key}]"); + keys_len += key.len() + 1; + spans.push(Span::styled(key, styles::action::KEY)); + } + + spans.push(Span::raw(" ".repeat(24 - keys_len))); + spans.push(Span::raw(" ยท ")); + spans.push(Span::styled(description, styles::action::DESCRIPTION)); + + lines.push(Line::from(spans)); + } + + lines +} diff --git a/toboggan-tui/src/ui/widgets/mod.rs b/toboggan-tui/src/ui/widgets/mod.rs new file mode 100644 index 0000000..d6a105b --- /dev/null +++ b/toboggan-tui/src/ui/widgets/mod.rs @@ -0,0 +1,74 @@ +use ratatui::prelude::*; + +use crate::events::AppAction; +use crate::ui::styles; + +mod title_bar; +pub use self::title_bar::TitleBar; + +mod progress_bar; +pub use self::progress_bar::ProgressBar; + +mod slide_list; +pub use self::slide_list::SlideList; + +mod current_slide; +pub use self::current_slide::CurrentSlide; + +mod next_slide_preview; +pub use self::next_slide_preview::NextSlidePreview; + +mod speaker_notes; +pub use self::speaker_notes::SpeakerNotes; + +mod help_panel; +use ratatui::symbols::border; +use ratatui::widgets::{Block, Paragraph}; + +pub use self::help_panel::HelpPanel; + +/// Helper function to render "no content" message +pub(crate) fn render_no_content( + area: Rect, + buf: &mut Buffer, + message: &str, + border_set: border::Set, +) { + let title = Line::from(Span::styled( + format!(" <{message}> "), + styles::ui::NO_CONTENT_STYLE, + )); + let block = Block::bordered().title(title).border_set(border_set); + Paragraph::new(vec![]).block(block).render(area, buf); +} + +/// Helper function to convert content text to lines +pub(crate) fn format_content_lines(content: &str) -> Vec> { + content.lines().map(Line::from).collect() +} + +fn line_from_actions(actions: &[AppAction]) -> Line<'_> { + if actions.is_empty() { + return Line::default(); + } + + let mut spans = vec![Span::raw(" ")]; + let mut first = true; + for action in actions { + if first { + first = false; + } else { + spans.push(Span::raw(" ยท ")); + } + + let key = action.key(); + spans.push(Span::styled(format!("[{key}] "), styles::action::KEY)); + spans.push(Span::styled( + action.to_string(), + styles::action::DESCRIPTION, + )); + } + spans.push(Span::raw(" ")); + + Line::from(spans) +} diff --git a/toboggan-tui/src/ui/widgets/next_slide_preview.rs b/toboggan-tui/src/ui/widgets/next_slide_preview.rs new file mode 100644 index 0000000..e54e805 --- /dev/null +++ b/toboggan-tui/src/ui/widgets/next_slide_preview.rs @@ -0,0 +1,34 @@ +use ratatui::prelude::*; +use ratatui::symbols::border; +use ratatui::widgets::{Block, Paragraph, Wrap}; + +use crate::state::AppState; + +#[derive(Debug, Default)] +pub struct NextSlidePreview {} + +impl StatefulWidget for &NextSlidePreview { + type State = AppState; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + let Some(slide) = state.next_slide() else { + let content = vec![]; + Paragraph::new(content).render(area, buf); + return; + }; + + let block = Block::bordered() + .title(Line::from(" Next ")) + .border_set(border::PLAIN); + + let area = area.inner(Margin::new(1, 1)); + let title = slide.title.to_string(); + let content = Line::from(title); + + Paragraph::new(content) + .block(block) + .centered() + .wrap(Wrap { trim: true }) + .render(area, buf); + } +} diff --git a/toboggan-tui/src/ui/widgets/progress_bar.rs b/toboggan-tui/src/ui/widgets/progress_bar.rs new file mode 100644 index 0000000..817ba83 --- /dev/null +++ b/toboggan-tui/src/ui/widgets/progress_bar.rs @@ -0,0 +1,65 @@ +use ratatui::prelude::*; +use ratatui::symbols::border; +use ratatui::widgets::{Block, LineGauge}; +use toboggan_core::State; + +use crate::state::AppState; +use crate::ui::styles; + +#[derive(Debug, Default)] +pub struct ProgressBar {} + +impl StatefulWidget for &ProgressBar { + type State = AppState; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + let current = state.current(); + let count = state.count(); + + let title = Line::from(vec![ + Span::raw(" Slide "), + Span::raw(format!("{current:02}")), + Span::raw("/"), + Span::raw(format!("{count:02}")), + Span::raw(" "), + ]); + + let bottom = match state.presentation_state { + State::Init => Line::default(), + State::Paused { total_duration, .. } => Line::from(vec![ + Span::raw(" "), + Span::styled("Paused", styles::state::PAUSED), + Span::raw(" - "), + Span::raw(format!("{total_duration}")), + Span::raw(" "), + ]), + State::Running { total_duration, .. } => Line::from(vec![ + Span::raw(" "), + Span::styled("Running", styles::state::RUNNING), + Span::raw(" - "), + Span::raw(format!("{total_duration}")), + Span::raw(" "), + ]), + State::Done { total_duration, .. } => Line::from(vec![ + Span::raw(" "), + Span::styled("๐ŸŽ‰ Done", styles::state::DONE), + Span::raw(" - "), + Span::raw(format!("{total_duration}")), + Span::raw(" "), + ]), + }; + let block = Block::bordered() + .title(title.centered()) + .title_bottom(bottom.centered()) + .border_set(border::PLAIN); + + #[allow(clippy::cast_precision_loss)] + LineGauge::default() + .line_set(symbols::line::THICK) + .ratio(current as f64 / count as f64) + .filled_style(styles::colors::BLUE) + .unfilled_style(styles::colors::BLACK) + .block(block) + .render(area, buf); + } +} diff --git a/toboggan-tui/src/ui/widgets/slide_list.rs b/toboggan-tui/src/ui/widgets/slide_list.rs new file mode 100644 index 0000000..fcaa607 --- /dev/null +++ b/toboggan-tui/src/ui/widgets/slide_list.rs @@ -0,0 +1,47 @@ +use ratatui::prelude::*; +use ratatui::symbols::border; +use ratatui::widgets::{Block, List, ListItem, ListState}; + +use crate::state::AppState; +use crate::ui::styles; + +#[derive(Debug, Default)] +pub struct SlideList {} + +impl StatefulWidget for &SlideList { + type State = AppState; + + #[allow(clippy::cast_possible_truncation)] + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + let selected = state.current_slide; + + // Create list items for each slide + let items: Vec = state + .talk + .titles + .iter() + .enumerate() + .map(|(index, text)| build_list_item(selected == Some(index), index + 1, text)) + .collect(); + + let block = Block::bordered() + .title(Line::from(" Slides ")) + .border_set(border::THICK); + + let mut list_state = ListState::default().with_selected(selected); + let list = List::new(items).block(block); + StatefulWidget::render(list, area, buf, &mut list_state); + } +} + +fn build_list_item(current: bool, number: usize, title: &str) -> ListItem<'_> { + // let truncated_title = content_renderer::truncate_text(title, 25); + let content = format!("{number:2}. {title}"); + let style = if current { + styles::list::CURRENT_SLIDE_STYLE + } else { + styles::list::NORMAL_SLIDE_STYLE + }; + + ListItem::new(Line::from(Span::styled(content, style))) +} diff --git a/toboggan-tui/src/ui/widgets/speaker_notes.rs b/toboggan-tui/src/ui/widgets/speaker_notes.rs new file mode 100644 index 0000000..6dd8834 --- /dev/null +++ b/toboggan-tui/src/ui/widgets/speaker_notes.rs @@ -0,0 +1,30 @@ +use ratatui::prelude::*; +use ratatui::symbols::border; +use ratatui::widgets::{Block, Paragraph, Wrap}; + +use crate::state::AppState; + +#[derive(Debug, Default)] +pub struct SpeakerNotes {} + +impl StatefulWidget for &SpeakerNotes { + type State = AppState; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + let Some(slide) = state.current_slide() else { + super::render_no_content(area, buf, "no slide active", border::THICK); + return; + }; + + let block = Block::bordered() + .title(Line::from(" Notes ")) + .border_set(border::THICK); + + let content_text = slide.notes.to_string(); + let content = super::format_content_lines(&content_text); + Paragraph::new(content) + .block(block) + .wrap(Wrap { trim: true }) + .render(area, buf); + } +} diff --git a/toboggan-tui/src/ui/widgets/title_bar.rs b/toboggan-tui/src/ui/widgets/title_bar.rs new file mode 100644 index 0000000..d423056 --- /dev/null +++ b/toboggan-tui/src/ui/widgets/title_bar.rs @@ -0,0 +1,66 @@ +use ratatui::prelude::*; +use ratatui::symbols::border; +use ratatui::widgets::{Block, Paragraph}; +use toboggan_core::State; + +use crate::events::AppAction; +use crate::state::AppState; +use crate::ui::styles::colors; +use crate::ui::widgets::line_from_actions; + +#[derive(Debug, Default)] +pub struct TitleBar {} + +impl StatefulWidget for &TitleBar { + type State = AppState; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + let color = if state.is_connected() { + colors::GREEN + } else { + colors::RED + }; + + let title = Line::from(vec![ + Span::raw(" "), + Span::raw(state.connection_status.to_string()), + Span::raw(" "), + ]); + let actions = global_actions(state); + let bottom = line_from_actions(&actions); + + let block = Block::bordered() + .border_style(Style::default().fg(color)) + .title(title.centered()) + .title_bottom(bottom.centered()) + .border_set(border::DOUBLE); + + let title = state.talk.title.clone(); + let date = state.talk.date.to_string(); + let content = Line::from(vec![ + Span::raw(title).bold(), + Span::raw(" - "), + Span::raw(date), + ]); + + Paragraph::new(content) + .centered() + .block(block) + .render(area, buf); + } +} + +fn global_actions(state: &AppState) -> Vec { + let mut actions = vec![AppAction::Blink]; + match state.presentation_state { + State::Paused { .. } => actions.push(AppAction::Resume), + State::Running { .. } => actions.push(AppAction::Pause), + State::Init | State::Done { .. } => {} + } + + actions.push(AppAction::ShowLog); + actions.push(AppAction::Quit); + actions.push(AppAction::Help); + + actions +} diff --git a/toboggan-web/.env b/toboggan-web/.env new file mode 100644 index 0000000..c3598ac --- /dev/null +++ b/toboggan-web/.env @@ -0,0 +1,12 @@ +# Toboggan Web Configuration + +# WebSocket base URL (without /api/ws suffix) +VITE_WS_BASE_URL=ws://localhost:8080/api/ws + +# API base URL for REST endpoints +VITE_API_BASE_URL=http://localhost:8080 + +# WebSocket connection retry configuration +VITE_WS_MAX_RETRIES=5 +VITE_WS_INITIAL_RETRY_DELAY=1000 +VITE_WS_MAX_RETRY_DELAY=30000 \ No newline at end of file diff --git a/toboggan-web/.gitignore b/toboggan-web/.gitignore index ea8c4bf..a547bf3 100644 --- a/toboggan-web/.gitignore +++ b/toboggan-web/.gitignore @@ -1 +1,24 @@ -/target +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/toboggan-web/.helix/languages.toml b/toboggan-web/.helix/languages.toml new file mode 100644 index 0000000..30e1504 --- /dev/null +++ b/toboggan-web/.helix/languages.toml @@ -0,0 +1,84 @@ +[[language]] +name = "html" +formatter = { command = "npx", args = [ + "@biomejs/biome", + "format", + "--stdin-file-path", + "file.html", +] } +auto-format = true + +[[language]] +name = "typescript" +formatter = { command = "npx", args = [ + "@biomejs/biome", + "format", + "--stdin-file-path", + "file.ts", +] } +auto-format = true +language-servers = ["typescript-language-server"] + +[language-server.typescript-language-server] +command = "typescript-language-server" +args = ["--stdio"] +config.hostInfo = "helix" +config.typescript.inlayHints.includeInlayParameterNameHints = "all" +config.typescript.inlayHints.includeInlayParameterNameHintsWhenArgumentMatchesName = true +config.typescript.inlayHints.includeInlayFunctionParameterTypeHints = true +config.typescript.inlayHints.includeInlayVariableTypeHints = true +config.typescript.inlayHints.includeInlayVariableTypeHintsWhenTypeMatchesName = true +config.typescript.inlayHints.includeInlayPropertyDeclarationTypeHints = true +config.typescript.inlayHints.includeInlayFunctionLikeReturnTypeHints = true +config.typescript.inlayHints.includeInlayEnumMemberValueHints = true + +[[language]] +name = "tsx" +formatter = { command = "npx", args = [ + "@biomejs/biome", + "format", + "--stdin-file-path", + "file.tsx", +] } +auto-format = true +language-servers = ["typescript-language-server"] + +[[language]] +name = "javascript" +formatter = { command = "npx", args = [ + "@biomejs/biome", + "format", + "--stdin-file-path", + "file.js", +] } +auto-format = true + +[[language]] +name = "jsx" +formatter = { command = "npx", args = [ + "@biomejs/biome", + "format", + "--stdin-file-path", + "file.jsx", +] } +auto-format = true + +[[language]] +name = "css" +formatter = { command = "npx", args = [ + "@biomejs/biome", + "format", + "--stdin-file-path", + "file.css", +] } +auto-format = true + +[[language]] +name = "json" +formatter = { command = "npx", args = [ + "@biomejs/biome", + "format", + "--stdin-file-path", + "file.json", +] } +auto-format = true diff --git a/toboggan-web/Cargo.toml b/toboggan-web/Cargo.toml deleted file mode 100644 index 57f384f..0000000 --- a/toboggan-web/Cargo.toml +++ /dev/null @@ -1,6 +0,0 @@ -[package] -name = "toboggan-web" -version = "0.1.0" -edition = "2024" - -[dependencies] diff --git a/toboggan-web/assets/apple-touch-icon.png b/toboggan-web/assets/apple-touch-icon.png new file mode 100644 index 0000000..a1e2873 Binary files /dev/null and b/toboggan-web/assets/apple-touch-icon.png differ diff --git a/toboggan-web/assets/favicon-192.png b/toboggan-web/assets/favicon-192.png new file mode 100644 index 0000000..c10ab21 Binary files /dev/null and b/toboggan-web/assets/favicon-192.png differ diff --git a/toboggan-web/assets/favicon-512.png b/toboggan-web/assets/favicon-512.png new file mode 100644 index 0000000..65e48fa Binary files /dev/null and b/toboggan-web/assets/favicon-512.png differ diff --git a/toboggan-web/assets/favicon.ico b/toboggan-web/assets/favicon.ico new file mode 100644 index 0000000..1bd647a Binary files /dev/null and b/toboggan-web/assets/favicon.ico differ diff --git a/toboggan-web/assets/manifest.json b/toboggan-web/assets/manifest.json new file mode 100644 index 0000000..dbda2fd --- /dev/null +++ b/toboggan-web/assets/manifest.json @@ -0,0 +1,21 @@ +{ + "name": "Toboggan Presentation", + "short_name": "Toboggan", + "description": "A modern presentation system", + "icons": [ + { + "src": "/favicon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/favicon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "start_url": "/", + "display": "standalone", + "theme_color": "#0891b2", + "background_color": "#0f172a" +} diff --git a/toboggan-web/biome.json b/toboggan-web/biome.json new file mode 100644 index 0000000..cfbfd37 --- /dev/null +++ b/toboggan-web/biome.json @@ -0,0 +1,149 @@ +{ + "root": false, + "$schema": "https://biomejs.dev/schemas/2.3.4/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": [ + "**/src/**/*", + "**/*.ts", + "**/*.js", + "**/*.json", + "!**/node_modules/**", + "!**/dist/**", + "!**/*.d.ts" + ] + }, + "formatter": { + "enabled": true, + "formatWithErrors": false, + "indentStyle": "space", + "indentWidth": 2, + "lineEnding": "lf", + "lineWidth": 100, + "attributePosition": "auto" + }, + "assist": { "actions": { "source": { "organizeImports": "on" } } }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "complexity": { + "noExtraBooleanCast": "error", + "noUselessCatch": "error", + "noUselessTypeConstraint": "error", + "noAdjacentSpacesInRegex": "error", + "noArguments": "error" + }, + "correctness": { + "noConstAssign": "error", + "noConstantCondition": "error", + "noEmptyCharacterClassInRegex": "error", + "noEmptyPattern": "error", + "noGlobalObjectCalls": "error", + "noInvalidConstructorSuper": "error", + "noNonoctalDecimalEscape": "error", + "noPrecisionLoss": "error", + "noSelfAssign": "error", + "noSetterReturn": "error", + "noSwitchDeclarations": "error", + "noUndeclaredVariables": "error", + "noUnreachable": "error", + "noUnreachableSuper": "error", + "noUnsafeFinally": "error", + "noUnsafeOptionalChaining": "error", + "noUnusedLabels": "error", + "noUnusedPrivateClassMembers": "warn", + "noUnusedVariables": "error", + "useIsNan": "error", + "useValidForDirection": "error", + "useYield": "error", + "noInvalidBuiltinInstantiation": "error", + "useValidTypeof": "error" + }, + "security": { + "noDangerouslySetInnerHtml": "error", + "noGlobalEval": "error" + }, + "style": { + "useConst": "error", + "useDefaultParameterLast": "error", + "useExponentiationOperator": "error", + "useTemplate": "error" + }, + "suspicious": { + "noArrayIndexKey": "warn", + "noAssignInExpressions": "error", + "noAsyncPromiseExecutor": "error", + "noCatchAssign": "error", + "noClassAssign": "error", + "noCompareNegZero": "error", + "noControlCharactersInRegex": "error", + "noDebugger": "warn", + "noDoubleEquals": "error", + "noDuplicateCase": "error", + "noDuplicateClassMembers": "error", + "noDuplicateObjectKeys": "error", + "noDuplicateParameters": "error", + "noEmptyBlockStatements": "error", + "noExplicitAny": "warn", + "noExtraNonNullAssertion": "error", + "noFallthroughSwitchClause": "error", + "noFunctionAssign": "error", + "noGlobalAssign": "error", + "noImportAssign": "error", + "noMisleadingCharacterClass": "error", + "noPrototypeBuiltins": "error", + "noRedeclare": "error", + "noShadowRestrictedNames": "error", + "noUnsafeNegation": "error", + "useGetterReturn": "error", + "noWith": "error", + "noVar": "error" + }, + "nursery": { + "useSortedClasses": "off" + } + } + }, + "javascript": { + "formatter": { + "jsxQuoteStyle": "double", + "quoteProperties": "asNeeded", + "trailingCommas": "es5", + "semicolons": "always", + "arrowParentheses": "always", + "bracketSpacing": true, + "bracketSameLine": false, + "quoteStyle": "double", + "attributePosition": "auto" + } + }, + "json": { + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "parser": { + "allowComments": true, + "allowTrailingCommas": false + } + }, + "overrides": [ + { + "includes": ["**/*.config.ts", "**/*.config.js", "**/vite.config.ts"], + "linter": { + "rules": { + "style": { + "useNodejsImportProtocol": "off" + } + } + } + } + ] +} diff --git a/toboggan-web/index.html b/toboggan-web/index.html new file mode 100644 index 0000000..5f42136 --- /dev/null +++ b/toboggan-web/index.html @@ -0,0 +1,32 @@ + + + + + + Toboggan Presentation + + + + + + + + +
+ + + diff --git a/toboggan-web/package-lock.json b/toboggan-web/package-lock.json new file mode 100644 index 0000000..55b7c4a --- /dev/null +++ b/toboggan-web/package-lock.json @@ -0,0 +1,1087 @@ +{ + "name": "toboggan-web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "toboggan-web", + "version": "0.0.0", + "devDependencies": { + "@biomejs/biome": "^2.3.4", + "@playwright/test": "^1.56.1", + "typescript": "~5.8.3", + "vite": "npm:rolldown-vite@latest" + } + }, + "node_modules/@biomejs/biome": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.5.tgz", + "integrity": "sha512-HvLhNlIlBIbAV77VysRIBEwp55oM/QAjQEin74QQX9Xb259/XP/D5AGGnZMOyF1el4zcvlNYYR3AyTMUV3ILhg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.3.5", + "@biomejs/cli-darwin-x64": "2.3.5", + "@biomejs/cli-linux-arm64": "2.3.5", + "@biomejs/cli-linux-arm64-musl": "2.3.5", + "@biomejs/cli-linux-x64": "2.3.5", + "@biomejs/cli-linux-x64-musl": "2.3.5", + "@biomejs/cli-win32-arm64": "2.3.5", + "@biomejs/cli-win32-x64": "2.3.5" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.5.tgz", + "integrity": "sha512-fLdTur8cJU33HxHUUsii3GLx/TR0BsfQx8FkeqIiW33cGMtUD56fAtrh+2Fx1uhiCsVZlFh6iLKUU3pniZREQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.5.tgz", + "integrity": "sha512-qpT8XDqeUlzrOW8zb4k3tjhT7rmvVRumhi2657I2aGcY4B+Ft5fNwDdZGACzn8zj7/K1fdWjgwYE3i2mSZ+vOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.5.tgz", + "integrity": "sha512-u/pybjTBPGBHB66ku4pK1gj+Dxgx7/+Z0jAriZISPX1ocTO8aHh8x8e7Kb1rB4Ms0nA/SzjtNOVJ4exVavQBCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.5.tgz", + "integrity": "sha512-eGUG7+hcLgGnMNl1KHVZUYxahYAhC462jF/wQolqu4qso2MSk32Q+QrpN7eN4jAHAg7FUMIo897muIhK4hXhqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.5.tgz", + "integrity": "sha512-XrIVi9YAW6ye0CGQ+yax0gLfx+BFOtKaNX74n+xHWla6Cl6huUmcKNO7HPx7BiKnJUzrxXY1qYlm7xMvi08X4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.5.tgz", + "integrity": "sha512-awVuycTPpVTH/+WDVnEEYSf6nbCBHf/4wB3lquwT7puhNg8R4XvonWNZzUsfHZrCkjkLhFH/vCZK5jHatD9FEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.5.tgz", + "integrity": "sha512-DlBiMlBZZ9eIq4H7RimDSGsYcOtfOIfZOaI5CqsWiSlbTfqbPVfWtCf92wNzx8GNMbu1s7/g3ZZESr6+GwM/SA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.5.tgz", + "integrity": "sha512-nUmR8gb6yvrKhtRgzwo/gDimPwnO5a4sCydf8ZS2kHIJhEmSmk+STsusr1LHTuM//wXppBawvSQi2xFXJCdgKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@emnapi/core": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz", + "integrity": "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", + "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.5.tgz", + "integrity": "sha512-TBr9Cf9onSAS2LQ2+QHx6XcC6h9+RIzJgbqG3++9TUZSH204AwEy5jg3BTQ0VATsyoGj4ee49tN/y6rvaOOtcg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.5.0", + "@emnapi/runtime": "^1.5.0", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@oxc-project/runtime": { + "version": "0.89.0", + "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.89.0.tgz", + "integrity": "sha512-vP7SaoF0l09GAYuj4IKjfyJodRWC09KdLy8NmnsdUPAsWhPz+2hPTLfEr5+iObDXSNug1xfTxtkGjBLvtwBOPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.89.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.89.0.tgz", + "integrity": "sha512-yuo+ECPIW5Q9mSeNmCDC2im33bfKuwW18mwkaHMQh8KakHYDzj4ci/q7wxf2qS3dMlVVCIyrs3kFtH5LmnlYnw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@playwright/test": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", + "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.56.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-beta.38", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.38.tgz", + "integrity": "sha512-AE3HFQrjWCKLFZD1Vpiy+qsqTRwwoil1oM5WsKPSmfQ5fif/A+ZtOZetF32erZdsR7qyvns6qHEteEsF6g6rsQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-beta.38", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-beta.38.tgz", + "integrity": "sha512-RaoWOKc0rrFsVmKOjQpebMY6c6/I7GR1FBc25v7L/R7NlM0166mUotwGEv7vxu7ruXH4SJcFeVrfADFUUXUmmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-beta.38", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-beta.38.tgz", + "integrity": "sha512-Ymojqc2U35iUc8NFU2XX1WQPfBRRHN6xHcrxAf9WS8BFFBn8pDrH5QPvH1tYs3lDkw6UGGbanr1RGzARqdUp1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-beta.38", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-beta.38.tgz", + "integrity": "sha512-0ermTQ//WzSI0nOL3z/LUWMNiE9xeM5cLGxjewPFEexqxV/0uM8/lNp9QageQ8jfc/VO1OURsGw34HYO5PaL8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-beta.38", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-beta.38.tgz", + "integrity": "sha512-GADxzVUTCTp6EWI52831A29Tt7PukFe94nhg/SUsfkI33oTiNQtPxyLIT/3oRegizGuPSZSlrdBurkjDwxyEUQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-beta.38", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-beta.38.tgz", + "integrity": "sha512-SKO7Exl5Yem/OSNoA5uLHzyrptUQ8Hg70kHDxuwEaH0+GUg+SQe9/7PWmc4hFKBMrJGdQtii8WZ0uIz9Dofg5Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-beta.38", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-beta.38.tgz", + "integrity": "sha512-SOo6+WqhXPBaShLxLT0eCgH17d3Yu1lMAe4mFP0M9Bvr/kfMSOPQXuLxBcbBU9IFM9w3N6qP9xWOHO+oUJvi8Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-beta.38", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-beta.38.tgz", + "integrity": "sha512-yvsQ3CyrodOX+lcoi+lejZGCOvJZa9xTsNB8OzpMDmHeZq3QzJfpYjXSAS6vie70fOkLVJb77UqYO193Cl8XBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-beta.38", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-beta.38.tgz", + "integrity": "sha512-84qzKMwUwikfYeOuJ4Kxm/3z15rt0nFGGQArHYIQQNSTiQdxGHxOkqXtzPFqrVfBJUdxBAf+jYzR1pttFJuWyg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-beta.38", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-beta.38.tgz", + "integrity": "sha512-QrNiWlce01DYH0rL8K3yUBu+lNzY+B0DyCbIc2Atan6/S6flxOL0ow5DLQvMamOI/oKhrJ4xG+9MkMb9dDHbLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-beta.38", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-beta.38.tgz", + "integrity": "sha512-fnLtHyjwEsG4/aNV3Uv3Qd1ZbdH+CopwJNoV0RgBqrcQB8V6/Qdikd5JKvnO23kb3QvIpP+dAMGZMv1c2PJMzw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.0.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-beta.38", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-beta.38.tgz", + "integrity": "sha512-19cTfnGedem+RY+znA9J6ARBOCEFD4YSjnx0p5jiTm9tR6pHafRfFIfKlTXhun+NL0WWM/M0eb2IfPPYUa8+wg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-ia32-msvc": { + "version": "1.0.0-beta.38", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.0.0-beta.38.tgz", + "integrity": "sha512-HcICm4YzFJZV+fI0O0bFLVVlsWvRNo/AB9EfUXvNYbtAxakCnQZ15oq22deFdz6sfi9Y4/SagH2kPU723dhCFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-beta.38", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-beta.38.tgz", + "integrity": "sha512-4Qx6cgEPXLb0XsCyLoQcUgYBpfL0sjugftob+zhUH0EOk/NVCAIT+h0NJhY+jn7pFpeKxhNMqhvTNx3AesxIAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.38", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz", + "integrity": "sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/ansis": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.1.0.tgz", + "integrity": "sha512-BGcItUBWSMRgOCe+SVZJ+S7yTRG0eGt9cXAHev72yuGcY23hnLA7Bky5L/xLyPINoSN95geovfBkqoTlNZYa7w==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/detect-libc": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.0.tgz", + "integrity": "sha512-vEtk+OcP7VBRtQZ1EJ3bdgzSfBjgnEalLTp5zjJrS+2Z1w2KZly4SBdac/WDU3hhsNAZ9E8SC96ME4Ey8MZ7cg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", + "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-darwin-arm64": "1.30.1", + "lightningcss-darwin-x64": "1.30.1", + "lightningcss-freebsd-x64": "1.30.1", + "lightningcss-linux-arm-gnueabihf": "1.30.1", + "lightningcss-linux-arm64-gnu": "1.30.1", + "lightningcss-linux-arm64-musl": "1.30.1", + "lightningcss-linux-x64-gnu": "1.30.1", + "lightningcss-linux-x64-musl": "1.30.1", + "lightningcss-win32-arm64-msvc": "1.30.1", + "lightningcss-win32-x64-msvc": "1.30.1" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", + "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", + "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", + "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", + "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", + "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", + "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", + "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", + "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", + "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", + "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", + "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.56.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", + "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-beta.38", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-beta.38.tgz", + "integrity": "sha512-58frPNX55Je1YsyrtPJv9rOSR3G5efUZpRqok94Efsj0EUa8dnqJV3BldShyI7A+bVPleucOtzXHwVpJRcR0kQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.89.0", + "@rolldown/pluginutils": "1.0.0-beta.38", + "ansis": "^4.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-beta.38", + "@rolldown/binding-darwin-arm64": "1.0.0-beta.38", + "@rolldown/binding-darwin-x64": "1.0.0-beta.38", + "@rolldown/binding-freebsd-x64": "1.0.0-beta.38", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.38", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.38", + "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.38", + "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.38", + "@rolldown/binding-linux-x64-musl": "1.0.0-beta.38", + "@rolldown/binding-openharmony-arm64": "1.0.0-beta.38", + "@rolldown/binding-wasm32-wasi": "1.0.0-beta.38", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.38", + "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.38", + "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.38" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "name": "rolldown-vite", + "version": "7.1.11", + "resolved": "https://registry.npmjs.org/rolldown-vite/-/rolldown-vite-7.1.11.tgz", + "integrity": "sha512-33L3z0NvLLyg2avZsEuLrKR33l8+tALVw9tYpSvW/4Zj7tYeRs5O9bodPL/NsmfUzLSjVIoG+GdADVeil0HNiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/runtime": "0.89.0", + "fdir": "^6.5.0", + "lightningcss": "^1.30.1", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rolldown": "1.0.0-beta.38", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "esbuild": "^0.25.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + } + } +} diff --git a/toboggan-web/package.json b/toboggan-web/package.json new file mode 100644 index 0000000..9f97e42 --- /dev/null +++ b/toboggan-web/package.json @@ -0,0 +1,24 @@ +{ + "name": "toboggan-web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "serve": "vite preview --port 8000", + "lint": "biome lint src/", + "lint:fix": "biome lint --write src/", + "format": "biome format src/", + "format:fix": "biome format --write src/", + "check": "biome check src/", + "check:fix": "biome check --write src/" + }, + "devDependencies": { + "@biomejs/biome": "^2.3.4", + "@playwright/test": "^1.56.1", + "typescript": "~5.8.3", + "vite": "npm:rolldown-vite@latest" + } +} diff --git a/toboggan-web/pnpm-lock.yaml b/toboggan-web/pnpm-lock.yaml new file mode 100644 index 0000000..dd5dec7 --- /dev/null +++ b/toboggan-web/pnpm-lock.yaml @@ -0,0 +1,614 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@biomejs/biome': + specifier: ^2.3.4 + version: 2.3.4 + '@playwright/test': + specifier: ^1.56.1 + version: 1.56.1 + typescript: + specifier: ~5.8.3 + version: 5.8.3 + vite: + specifier: npm:rolldown-vite@latest + version: rolldown-vite@7.2.4 + +packages: + + '@biomejs/biome@2.3.4': + resolution: {integrity: sha512-TU08LXjBHdy0mEY9APtEtZdNQQijXUDSXR7IK1i45wgoPD5R0muK7s61QcFir6FpOj/RP1+YkPx5QJlycXUU3w==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.3.4': + resolution: {integrity: sha512-w40GvlNzLaqmuWYiDU6Ys9FNhJiclngKqcGld3iJIiy2bpJ0Q+8n3haiaC81uTPY/NA0d8Q/I3Z9+ajc14102Q==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.3.4': + resolution: {integrity: sha512-3s7TLVtjJ7ni1xADXsS7x7GMUrLBZXg8SemXc3T0XLslzvqKj/dq1xGeBQ+pOWQzng9MaozfacIHdK2UlJ3jGA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.3.4': + resolution: {integrity: sha512-IruVGQRwMURivWazchiq7gKAqZSFs5so6gi0hJyxk7x6HR+iwZbO2IxNOqyLURBvL06qkIHs7Wffl6Bw30vCbQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-arm64@2.3.4': + resolution: {integrity: sha512-y7efHyyM2gYmHy/AdWEip+VgTMe9973aP7XYKPzu/j8JxnPHuSUXftzmPhkVw0lfm4ECGbdBdGD6+rLmTgNZaA==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-x64-musl@2.3.4': + resolution: {integrity: sha512-mzKFFv/w66e4/jCobFmD3kymCqG+FuWE7sVa4Yjqd9v7qt2UhXo67MSZKY9Ih18V2IwPzRKQPCw6KwdZs6AXSA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-linux-x64@2.3.4': + resolution: {integrity: sha512-gKfjWR/6/dfIxPJCw8REdEowiXCkIpl9jycpNVHux8aX2yhWPLjydOshkDL6Y/82PcQJHn95VCj7J+BRcE5o1Q==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-win32-arm64@2.3.4': + resolution: {integrity: sha512-5TJ6JfVez+yyupJ/iGUici2wzKf0RrSAxJhghQXtAEsc67OIpdwSKAQboemILrwKfHDi5s6mu7mX+VTCTUydkw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.3.4': + resolution: {integrity: sha512-FGCijXecmC4IedQ0esdYNlMpx0Jxgf4zceCaMu6fkjWyjgn50ZQtMiqZZQ0Q/77yqPxvtkgZAvt5uGw0gAAjig==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@emnapi/core@1.7.0': + resolution: {integrity: sha512-pJdKGq/1iquWYtv1RRSljZklxHCOCAJFJrImO5ZLKPJVJlVUcs8yFwNQlqS0Lo8xT1VAXXTCZocF9n26FWEKsw==} + + '@emnapi/runtime@1.7.0': + resolution: {integrity: sha512-oAYoQnCYaQZKVS53Fq23ceWMRxq5EhQsE0x0RdQ55jT7wagMu5k+fS39v1fiSLrtrLQlXwVINenqhLMtTrV/1Q==} + + '@emnapi/wasi-threads@1.1.0': + resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + + '@napi-rs/wasm-runtime@1.0.7': + resolution: {integrity: sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==} + + '@oxc-project/runtime@0.96.0': + resolution: {integrity: sha512-34lh4o9CcSw09Hx6fKihPu85+m+4pmDlkXwJrLvN5nMq5JrcGhhihVM415zDqT8j8IixO1PYYdQZRN4SwQCncg==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@oxc-project/types@0.96.0': + resolution: {integrity: sha512-r/xkmoXA0xEpU6UGtn18CNVjXH6erU3KCpCDbpLmbVxBFor1U9MqN5Z2uMmCHJuXjJzlnDR+hWY+yPoLo8oHDw==} + + '@playwright/test@1.56.1': + resolution: {integrity: sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==} + engines: {node: '>=18'} + hasBin: true + + '@rolldown/binding-android-arm64@1.0.0-beta.49': + resolution: {integrity: sha512-xKQEOmqOet0vFHt/aqcoQGWvoDJhfSO8EBhuST0CDnxQRmnVzbI8keeeX62vi53ZyICKZxczyfx4A8dUY3dqKw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-beta.49': + resolution: {integrity: sha512-kN0N/8m8HUYO13PqlIwxcXD7fu2E6GKu0J4iH7wUJw3T3QK+nvrc20rxtTZ0J6sA1sGCE8UYvvvnurDwMUp0dg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-beta.49': + resolution: {integrity: sha512-29qmvsgY2A4ymfy8sQkFFOFc13m04SLUcYn1iil41gpkYrAspBLkvsOQMHPCs3rQCOImgweT4tFotqTAonwphQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-beta.49': + resolution: {integrity: sha512-fY+esrHjgt6+RAnDPuUk39RvFNmYhJekGyC6wr0HWXGTBed07Feap9BrYINSh6x5xFlNpOPs6tImKnV0zVDuWQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.49': + resolution: {integrity: sha512-oQNAqB+XrRM2AZaSPyudQETsPhzCZqgPICQu80fJuNyBFYoz6nonNNZtm3BJ9uP+HZfUk9NfOn9vPoCNuk6gAw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.49': + resolution: {integrity: sha512-bJinAiuWUJvlBxPa8ZmRnWkmmAoUlSWtZT4pRkWi/QX3HlgHfUUbhF+d7aZLciai+iFfbiPqOwCL2tqNXXrUsA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.49': + resolution: {integrity: sha512-gwueY8EJU7afq5tNwKSjYy5JqTR/0MNzZfv6s5dX+rMgeUpTNhwIToLO1F41TPYEa+6LRTXUWG23DO/ONPzUJA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.49': + resolution: {integrity: sha512-VXYkjzzEZh5N5Ue1IEcBgL8RuJu5jWrIKmg8WY6hhCbnNJ1IOsObT4HFW+rE8ZaKNjoIXzImoiYi1UAkKiQRYA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.0-beta.49': + resolution: {integrity: sha512-S5Yw6g/ftiW7MpNpnOM5vSIlDzGuohDY8y7VOI47+92HhO6WqsNfcMkDZXm3G5l6YIfUNStGBV86NWrzasp+sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.0.0-beta.49': + resolution: {integrity: sha512-bhRoMO2oP46W1UDd/PTrSdoIYfvLS2jiFAned0SOzOO0tcait9u+b9i8h4ZugbT2IK4qUXNezovbHJs7hKJOEQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-beta.49': + resolution: {integrity: sha512-Owp6Y1RQ84UMOV8hrg5e1Fmu8Po1IUXWytAHUtPcc00+ty6Gr9g5GgLLw0oblu7QovBr4848ozvkMcEj3vDKgA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.49': + resolution: {integrity: sha512-dnheX8aXsN9P12uwPOW3TVvqSnQ1cfjKQlYgU2dTkrRpnco0kTGvqE1nEWybGukTyuPdzVvrGElgSGEJ7crcSQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.49': + resolution: {integrity: sha512-Blt1aODXiAuEdZBqHYXGJwVFlonXKkVEJy5hhxOgnAVi/0mzFNWDxc8qVlxl7dpQjQdboW/wXdgMHpTDfomicg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.49': + resolution: {integrity: sha512-sSu4qUlL/62QJrR3P+Bd+EblD8tUpnovUz65qow3PA7YxH+f5NFDbCJMR1m5b8zBuVZwZIHfzbuawz+Vl34/xg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-beta.49': + resolution: {integrity: sha512-HLlu3Qn3ePmNCbfehwKWXQMzX/2rzcL6Jmpo+Dl3xnq46TGMyJAgO+IsS8ka7IDLeD3wcoOhjJwxTdIdbrFhGw==} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + lightningcss-android-arm64@1.30.2: + resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.30.2: + resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.30.2: + resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.30.2: + resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.30.2: + resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.30.2: + resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.30.2: + resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.30.2: + resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.30.2: + resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.30.2: + resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.30.2: + resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.30.2: + resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} + engines: {node: '>= 12.0.0'} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + playwright-core@1.56.1: + resolution: {integrity: sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.56.1: + resolution: {integrity: sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==} + engines: {node: '>=18'} + hasBin: true + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + rolldown-vite@7.2.4: + resolution: {integrity: sha512-5qmUAr3W/1dCGBF7Bazj9BJFJPdKFMZ4KDn358cwCSEFq5gRXF5r7S6hO/zq20QuBNDEyDQOiURL895/PfQNEg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + esbuild: ^0.25.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + rolldown@1.0.0-beta.49: + resolution: {integrity: sha512-Bfmdn3ZqyCwi1LxG39KBrSlil9a/xnrOrAj+jqqN2YTR/WJIEOOfwNKgDALQvr0xlO9bG/i1C883KGd4nd7SrA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + engines: {node: '>=14.17'} + hasBin: true + +snapshots: + + '@biomejs/biome@2.3.4': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.3.4 + '@biomejs/cli-darwin-x64': 2.3.4 + '@biomejs/cli-linux-arm64': 2.3.4 + '@biomejs/cli-linux-arm64-musl': 2.3.4 + '@biomejs/cli-linux-x64': 2.3.4 + '@biomejs/cli-linux-x64-musl': 2.3.4 + '@biomejs/cli-win32-arm64': 2.3.4 + '@biomejs/cli-win32-x64': 2.3.4 + + '@biomejs/cli-darwin-arm64@2.3.4': + optional: true + + '@biomejs/cli-darwin-x64@2.3.4': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.3.4': + optional: true + + '@biomejs/cli-linux-arm64@2.3.4': + optional: true + + '@biomejs/cli-linux-x64-musl@2.3.4': + optional: true + + '@biomejs/cli-linux-x64@2.3.4': + optional: true + + '@biomejs/cli-win32-arm64@2.3.4': + optional: true + + '@biomejs/cli-win32-x64@2.3.4': + optional: true + + '@emnapi/core@1.7.0': + dependencies: + '@emnapi/wasi-threads': 1.1.0 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.7.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.1.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@napi-rs/wasm-runtime@1.0.7': + dependencies: + '@emnapi/core': 1.7.0 + '@emnapi/runtime': 1.7.0 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@oxc-project/runtime@0.96.0': {} + + '@oxc-project/types@0.96.0': {} + + '@playwright/test@1.56.1': + dependencies: + playwright: 1.56.1 + + '@rolldown/binding-android-arm64@1.0.0-beta.49': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-beta.49': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-beta.49': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-beta.49': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.49': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.49': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.49': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.49': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-beta.49': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-beta.49': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-beta.49': + dependencies: + '@napi-rs/wasm-runtime': 1.0.7 + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.49': + optional: true + + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.49': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.49': + optional: true + + '@rolldown/pluginutils@1.0.0-beta.49': {} + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + detect-libc@2.1.2: {} + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + lightningcss-android-arm64@1.30.2: + optional: true + + lightningcss-darwin-arm64@1.30.2: + optional: true + + lightningcss-darwin-x64@1.30.2: + optional: true + + lightningcss-freebsd-x64@1.30.2: + optional: true + + lightningcss-linux-arm-gnueabihf@1.30.2: + optional: true + + lightningcss-linux-arm64-gnu@1.30.2: + optional: true + + lightningcss-linux-arm64-musl@1.30.2: + optional: true + + lightningcss-linux-x64-gnu@1.30.2: + optional: true + + lightningcss-linux-x64-musl@1.30.2: + optional: true + + lightningcss-win32-arm64-msvc@1.30.2: + optional: true + + lightningcss-win32-x64-msvc@1.30.2: + optional: true + + lightningcss@1.30.2: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.30.2 + lightningcss-darwin-arm64: 1.30.2 + lightningcss-darwin-x64: 1.30.2 + lightningcss-freebsd-x64: 1.30.2 + lightningcss-linux-arm-gnueabihf: 1.30.2 + lightningcss-linux-arm64-gnu: 1.30.2 + lightningcss-linux-arm64-musl: 1.30.2 + lightningcss-linux-x64-gnu: 1.30.2 + lightningcss-linux-x64-musl: 1.30.2 + lightningcss-win32-arm64-msvc: 1.30.2 + lightningcss-win32-x64-msvc: 1.30.2 + + nanoid@3.3.11: {} + + picocolors@1.1.1: {} + + picomatch@4.0.3: {} + + playwright-core@1.56.1: {} + + playwright@1.56.1: + dependencies: + playwright-core: 1.56.1 + optionalDependencies: + fsevents: 2.3.2 + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rolldown-vite@7.2.4: + dependencies: + '@oxc-project/runtime': 0.96.0 + fdir: 6.5.0(picomatch@4.0.3) + lightningcss: 1.30.2 + picomatch: 4.0.3 + postcss: 8.5.6 + rolldown: 1.0.0-beta.49 + tinyglobby: 0.2.15 + optionalDependencies: + fsevents: 2.3.3 + + rolldown@1.0.0-beta.49: + dependencies: + '@oxc-project/types': 0.96.0 + '@rolldown/pluginutils': 1.0.0-beta.49 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-beta.49 + '@rolldown/binding-darwin-arm64': 1.0.0-beta.49 + '@rolldown/binding-darwin-x64': 1.0.0-beta.49 + '@rolldown/binding-freebsd-x64': 1.0.0-beta.49 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.49 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.49 + '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.49 + '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.49 + '@rolldown/binding-linux-x64-musl': 1.0.0-beta.49 + '@rolldown/binding-openharmony-arm64': 1.0.0-beta.49 + '@rolldown/binding-wasm32-wasi': 1.0.0-beta.49 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.49 + '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.49 + '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.49 + + source-map-js@1.2.1: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tslib@2.8.1: + optional: true + + typescript@5.8.3: {} diff --git a/toboggan-web/src/main.css b/toboggan-web/src/main.css new file mode 100644 index 0000000..fcb9a29 --- /dev/null +++ b/toboggan-web/src/main.css @@ -0,0 +1,103 @@ +:root { + /* Typography */ + --font-slide-title: system-ui, -apple-system, sans-serif; + --font-slide-body: system-ui, -apple-system, sans-serif; + --font-slide-mono: ui-monospace, monospace; + --font-weight-semibold: 600; + --font-weight-bold: bold; + + --bg: white; + --fg: black; + --accent: teal; + --accent2: purple; + + /* Status/Semantic Colors */ + --color-success: #10b981; + --color-info: #3b82f6; + --color-warning: #d97706; + --color-error: #dc2626; + + /* Transparency */ + --color-border-transparent: rgba(255, 255, 255, 0.3); + --color-hover-bg: rgba(0, 0, 0, 0.1); + + /* Icon Sizes */ + --icon-size-sm: 1em; + --icon-size-md: 1.5em; + + /* Component Dimensions */ + --button-padding: 0.5em; + --button-size: calc(var(--icon-size-sm) + 2 * var(--button-padding)); + + /* Spacing */ + --spacing-xs: 0.25em; + --spacing-sm: 0.5em; + --spacing-md: 1em; + + /* Border Radius */ + --radius-sm: 0.25em; + + /* Animation Durations */ + --transition-fast: 0.2s; + --animation-fast: 0.3s; + --toast-duration: 10s; + + /* Steps */ + --step-transition-duration: var(--transition-fast); + --step-transition-timing: ease-in-out; + --step-opacity-hidden: 0; + --step-opacity-revealed: 0.9; + --step-opacity-active: 1; + + /* Toast Status Icons */ + /* check-circle */ + --icon-success: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='M22 11.08V12a10 10 0 1 1-5.93-9.14'/%3e%3cpath d='m9 11 3 3L22 4'/%3e%3c/svg%3e"); + /* alert-triangle */ + --icon-warning: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z'/%3e%3cpath d='M12 9v4'/%3e%3cpath d='m12 17 .01 0'/%3e%3c/svg%3e"); + /* x-circle */ + --icon-error: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3ccircle cx='12' cy='12' r='10'/%3e%3cpath d='m15 9-6 6'/%3e%3cpath d='m9 9 6 6'/%3e%3c/svg%3e"); + /* info */ + --icon-info: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3ccircle cx='12' cy='12' r='10'/%3e%3cpath d='M12 16v-4'/%3e%3cpath d='m12 8 .01 0'/%3e%3c/svg%3e"); +} + +html { + font-size: 32px; +} + +body { + position: relative; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + height: 100vh; + width: 100vw; + font-family: var(--font-slide-body); + overflow: hidden; +} + +main { + flex: 1; + display: flex; + flex-direction: column; + height: 100%; + overflow-y: auto; + overflow-x: hidden; + background: var(--bg); +} + +.toboggan-slide { + flex: 1; + overflow-y: auto; +} + +.toboggan-footer { + flex-shrink: 0; +} + +.toboggan-toast { + position: fixed; + z-index: 1000; + bottom: 1rem; + right: 1rem; +} diff --git a/toboggan-web/src/main.rs b/toboggan-web/src/main.rs deleted file mode 100644 index e7a11a9..0000000 --- a/toboggan-web/src/main.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - println!("Hello, world!"); -} diff --git a/toboggan-web/src/main.ts b/toboggan-web/src/main.ts new file mode 100644 index 0000000..222fbf1 --- /dev/null +++ b/toboggan-web/src/main.ts @@ -0,0 +1,54 @@ +import tobogganWasm, { + AppConfig, + start_app, + WebSocketConfig, +} from "../toboggan-wasm/pkg/toboggan_wasm"; + +import "./reset.css"; +import "./main.css"; +import "./state.css"; + +// Initialize the application when the DOM is loaded +document.addEventListener("DOMContentLoaded", async () => { + await tobogganWasm(); + const elt = document.querySelector("main"); + if (!elt) { + console.error("๐Ÿšจ Missing
element"); + return; + } + + const config = new AppConfig(); + config.api_base_url = getEnvVar("VITE_API_BASE_URL", location.origin); + + const wsUrl = getEnvVar("VITE_WS_BASE_URL", `ws://${location.host}/api/ws`); + config.websocket = new WebSocketConfig(wsUrl); + config.websocket.max_retries = getEnvNumber("VITE_WS_MAX_RETRIES", 5); + config.websocket.initial_retry_delay = getEnvNumber( + "VITE_WS_INITIAL_RETRY_DELAY", + 1000, + ); + config.websocket.max_retry_delay = getEnvNumber( + "VITE_WS_MAX_RETRY_DELAY", + 30000, + ); + + start_app(config, elt); +}); + +/** + * Get environment variable with fallback + */ +const getEnvVar = (key: keyof ImportMetaEnv, defaultValue: string): string => + import.meta.env[key] ?? defaultValue; + +/** + * Get environment variable as number with fallback + */ +const getEnvNumber = ( + key: keyof ImportMetaEnv, + defaultValue: number, +): number => { + const value = import.meta.env[key]; + const parsed = value ? parseInt(value, 10) : NaN; + return Number.isNaN(parsed) ? defaultValue : parsed; +}; diff --git a/toboggan-web/src/reset.css b/toboggan-web/src/reset.css new file mode 100644 index 0000000..9ed1778 --- /dev/null +++ b/toboggan-web/src/reset.css @@ -0,0 +1,111 @@ +*, +*::before, +*::after { + box-sizing: border-box; + min-width: 0; + margin: 0; + padding: 0; +} + +*:focus:not(:is(input, textarea)) { + outline: none; +} + +*:focus-visible { + outline: 1px solid blue; +} + +@media (prefers-reduced-motion: no-preference) { + html { + interpolate-size: allow-keywords; + } +} + +html { + height: 100%; + line-height: 1.5; + font-synthesis: none; + -webkit-font-smoothing: subpixel-antialiased; + -moz-osx-font-smoothing: auto; + -moz-text-size-adjust: none; + -webkit-text-size-adjust: none; + text-size-adjust: none; + + color: black; +} +body { + height: 100%; + overscroll-behavior: none; + accent-color: black; + background: white; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +img, +picture, +video, +canvas, +svg { + display: block; + max-width: 100%; +} + +input, +button, +textarea, +select { + font: inherit; +} + +p, +h1, +h2, +h3, +h4, +h5, +h6, +strong { + overflow-wrap: break-word; +} + +p { + text-wrap: pretty; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + text-wrap: balance; +} + +input, +textarea, +button, +button:disabled { + font: inherit; + color: inherit; + border: none; + border-radius: 0; + background: none; +} + +fieldset { + border: none; +} + +a { + text-decoration: none; + color: inherit; +} + +ul, +ol { + list-style: none; +} +main { + isolation: isolate; +} diff --git a/toboggan-web/src/state.css b/toboggan-web/src/state.css new file mode 100644 index 0000000..23b4611 --- /dev/null +++ b/toboggan-web/src/state.css @@ -0,0 +1,42 @@ +.toboggan-slide { + transition: margin 0.4s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Modern slide entrance with depth and motion blur */ +.running .toboggan-slide { + animation: slideEntrance 0.7s cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes slideEntrance { + from { + transform: translateX(40px) scale(0.96); + opacity: 0; + filter: blur(10px); + } + 50% { + filter: blur(5px); + } + to { + transform: translateX(0) scale(1); + opacity: 1; + filter: blur(0); + } +} + +/* Celebration animation for presentation completion */ +.done .toboggan-slide { + animation: celebrate 1s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +@keyframes celebrate { + 0%, + 100% { + transform: scale(1); + } + 30% { + transform: scale(1.05); + } + 60% { + transform: scale(0.98); + } +} diff --git a/toboggan-web/src/vite-env.d.ts b/toboggan-web/src/vite-env.d.ts new file mode 100644 index 0000000..27bbeb3 --- /dev/null +++ b/toboggan-web/src/vite-env.d.ts @@ -0,0 +1,13 @@ +/// + +interface ImportMetaEnv { + readonly VITE_WS_BASE_URL?: string; + readonly VITE_API_BASE_URL?: string; + readonly VITE_WS_MAX_RETRIES?: string; + readonly VITE_WS_INITIAL_RETRY_DELAY?: string; + readonly VITE_WS_MAX_RETRY_DELAY?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/toboggan-web/toboggan-wasm/.cargo/config.toml b/toboggan-web/toboggan-wasm/.cargo/config.toml new file mode 100644 index 0000000..08301c1 --- /dev/null +++ b/toboggan-web/toboggan-wasm/.cargo/config.toml @@ -0,0 +1,6 @@ +[build] +target = "wasm32-unknown-unknown" + +[target.wasm32-unknown-unknown] +runner = "wasm-bindgen-test-runner" +rustflags = ["--cfg", "getrandom_backend=\"wasm_js\""] diff --git a/toboggan-web/toboggan-wasm/.gitignore b/toboggan-web/toboggan-wasm/.gitignore new file mode 100644 index 0000000..d7f4a5f --- /dev/null +++ b/toboggan-web/toboggan-wasm/.gitignore @@ -0,0 +1,4 @@ +/target +plop/ + +!.helix/ diff --git a/toboggan-web/toboggan-wasm/.helix/languages.toml b/toboggan-web/toboggan-wasm/.helix/languages.toml new file mode 100644 index 0000000..0d383c0 --- /dev/null +++ b/toboggan-web/toboggan-wasm/.helix/languages.toml @@ -0,0 +1,30 @@ +[[language]] +name = "rust" +auto-format = true +roots = ["Cargo.toml", "Cargo.lock"] + +[language-server.rust-analyzer] +command = "rust-analyzer" + +[language-server.rust-analyzer.config] +# Target WebAssembly +cargo.target = "wasm32-unknown-unknown" + +# Build all crates (cdylib and rlib as specified in Cargo.toml) +cargo.buildScripts.enable = true +cargo.allTargets = false + +# Check with clippy +checkOnSave.command = "clippy" + +# Inlay hints configuration +inlayHints.bindingModeHints.enable = false +inlayHints.closingBraceHints.minLines = 10 + +# Proc macro support +procMacro.enable = true +procMacro.attributes.enable = true + +# Diagnostics +diagnostics.disabled = [] +diagnostics.experimental.enable = true \ No newline at end of file diff --git a/toboggan-web/toboggan-wasm/Cargo.lock b/toboggan-web/toboggan-wasm/Cargo.lock new file mode 100644 index 0000000..1ca6c40 --- /dev/null +++ b/toboggan-web/toboggan-wasm/Cargo.lock @@ -0,0 +1,658 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "derive_more" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "gloo" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d15282ece24eaf4bd338d73ef580c6714c8615155c4190c781290ee3fa0fd372" +dependencies = [ + "gloo-console", + "gloo-events", + "gloo-net", + "gloo-timers", + "gloo-utils", +] + +[[package]] +name = "gloo-console" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a17868f56b4a24f677b17c8cb69958385102fa879418052d60b50bc1727e261" +dependencies = [ + "gloo-utils", + "js-sys", + "serde", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-events" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c26fb45f7c385ba980f5fa87ac677e363949e065a083722697ef1b2cc91e41" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-net" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43aaa242d1239a8822c15c645f02166398da4f8b5c4bae795c1f5b44e9eee173" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils", + "http", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "gloo-utils" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "jiff" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49" +dependencies = [ + "jiff-static", + "js-sys", + "log", + "portable-atomic", + "portable-atomic-util", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "jiff-static" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "js-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "syn" +version = "2.0.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toboggan-core" +version = "0.1.0" +dependencies = [ + "derive_more", + "getrandom", + "humantime", + "jiff", + "serde", + "tracing", + "uuid", +] + +[[package]] +name = "toboggan-wasm" +version = "0.1.0" +dependencies = [ + "console_error_panic_hook", + "futures", + "gloo", + "js-sys", + "serde", + "serde-wasm-bindgen", + "toboggan-core", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "uuid" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +dependencies = [ + "getrandom", + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" diff --git a/toboggan-web/toboggan-wasm/Cargo.toml b/toboggan-web/toboggan-wasm/Cargo.toml new file mode 100644 index 0000000..af15edb --- /dev/null +++ b/toboggan-web/toboggan-wasm/Cargo.toml @@ -0,0 +1,88 @@ +[package] +name = "toboggan-wasm" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +license = "MIT OR Apache-2.0" +authors = ["Igor Laborie "] +description = "WebAssembly client for Toboggan presentation system" +repository = "https://github.com/ilaborie/toboggan" + +[lib] +crate-type = ["cdylib", "rlib"] + +[workspace] +# This empty workspace table prevents the package from inheriting workspace settings + + +[dependencies] +toboggan-core = { path = "../../toboggan-core", features = ["js"] } + +wasm-bindgen = "0.2.104" +gloo = { version = "0.11.0", default-features = false, features = [ + "timers", + "events", + "console", + "utils", + "net", +] } +web-sys = { version = "0.3.81", features = [ + "HtmlElement", + "KeyboardEvent", + "Element", + "HtmlElement", + "CssStyleDeclaration", + "Document", + "Node", + "NodeList", + "ShadowRoot", + "ShadowRootInit", + "ShadowRootMode", + "AudioContext", + "AudioDestinationNode", + "AudioParam", + "OscillatorNode", + "GainNode", + "OscillatorType", +] } +js-sys = "0.3.81" +serde-wasm-bindgen = "0.6.5" +wasm-bindgen-futures = "0.4.54" +futures = "0.3.31" +serde = "1.0.228" +console_error_panic_hook = "0.1.7" + +[dev-dependencies] + +[lints.rust] +missing_docs = "allow" + +[lints.clippy] +perf = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } + +undocumented_unsafe_blocks = "deny" + +dbg_macro = "warn" +expect_used = "warn" +if_then_some_else_none = "warn" +indexing_slicing = "warn" +large_include_file = "warn" +min_ident_chars = "warn" +print_stderr = "warn" +print_stdout = "warn" +rc_buffer = "warn" +rc_mutex = "warn" +unnecessary_safety_doc = "warn" +unwrap_used = "warn" + +new_without_default = "allow" +module_name_repetitions = "allow" + +# WASM size optimizations +[profile.release] +opt-level = "z" # Optimize for size even more aggressively +lto = "fat" # More aggressive LTO +codegen-units = 1 # Single codegen unit for better optimization +panic = "abort" # Smaller panic handling +strip = true # Strip symbols for smaller size diff --git a/toboggan-web/toboggan-wasm/README.md b/toboggan-web/toboggan-wasm/README.md new file mode 100644 index 0000000..51c1f58 --- /dev/null +++ b/toboggan-web/toboggan-wasm/README.md @@ -0,0 +1,400 @@ +# Toboggan WASM Client + +WebAssembly client for the Toboggan presentation system, providing real-time presentation control and display capabilities in web browsers. Built with Rust and compiled to WASM for performance and safety. + +## Features + +### Core Functionality +- **Real-time WebSocket Communication**: Bi-directional communication with Toboggan server +- **Presentation State Management**: Synchronized presentation state across all clients +- **Command Processing**: Handle navigation and control commands (Next, Previous, Play, Pause) +- **Error Handling**: Robust error handling with user-friendly error messages +- **Retry Logic**: Automatic reconnection with exponential backoff + +### Web Integration +- **JavaScript Bindings**: Seamless integration with TypeScript/JavaScript frontends +- **Browser Compatibility**: Works in all modern browsers with WebAssembly support +- **Memory Efficiency**: Optimized WASM build with minimal memory footprint +- **Security**: XSS prevention through content sanitization +- **Performance**: Native-speed execution with Rust's zero-cost abstractions + +### Development Features +- **Hot Reload Support**: Fast development cycles with live rebuilding +- **Debug Support**: Console logging and error reporting +- **TypeScript Definitions**: Full type safety for web development + +## Architecture + +### Core Components + +```rust +// Main WASM interface +#[wasm_bindgen] +pub struct TobogganClient { + inner: Arc>, +} + +#[wasm_bindgen] +impl TobogganClient { + #[wasm_bindgen(constructor)] + pub fn new(server_url: String) -> TobogganClient; + + pub async fn connect(&self) -> Result<(), JsValue>; + pub async fn send_command(&self, command: &str) -> Result<(), JsValue>; + pub fn set_state_callback(&self, callback: js_sys::Function); +} +``` + +### Integration with Web Frontend + +```typescript +import { TobogganClient } from './pkg/toboggan_wasm'; + +// Initialize client +const client = new TobogganClient('ws://localhost:8080/api/ws'); + +// Set up state updates +client.set_state_callback((state) => { + console.log('Presentation state updated:', state); + // Update UI based on new state +}); + +// Connect to server +await client.connect(); + +// Send commands +await client.send_command('{"type": "Next"}'); +await client.send_command('{"type": "Pause"}'); +``` + +## Development Workflow + +### Live Development with Bacon + +For rapid development cycles, use `bacon wasm` to automatically rebuild on changes: + +```bash +# Start live build watcher +bacon wasm +``` + +**Benefits:** +- **Instant Feedback**: Immediate compilation results on file save +- **Error Reporting**: Clear error messages with source locations +- **Build Optimization**: Automatic `wasm-opt` optimization +- **Size Monitoring**: Track WASM bundle size changes + +**Output:** +``` +โœ“ Compiling toboggan-wasm +โœ“ Running wasm-pack build +โœ“ Optimizing with wasm-opt +๐Ÿ“ฆ WASM size: 245KB โ†’ 189KB (optimized) +``` + +### Manual Build Process + +#### Option 1: Using Mise (Recommended) +```bash +# Build optimized WASM package +mise build:wasm +``` + +#### Option 2: Direct wasm-pack Build +```bash +# Development build (faster, larger) +wasm-pack build --target web --dev + +# Release build (optimized, smaller) +wasm-pack build --target web --release +``` + +#### Option 3: Custom Build with Optimization +```bash +# Build with maximum optimization +wasm-pack build --target web --release +wasm-opt pkg/toboggan_wasm_bg.wasm -O4 -o pkg/toboggan_wasm_bg_opt.wasm +``` + +### Build Configuration + +The build is configured via `Cargo.toml`: + +```toml +[lib] +crate-type = ["cdylib"] + +[dependencies] +wasm-bindgen = { version = "0.2", features = ["serde-serialize"] } +web-sys = { version = "0.3", features = ["console", "WebSocket"] } +js-sys = "0.3" +serde = { version = "1.0", features = ["derive"] } +serde-wasm-bindgen = "0.6" + +[package.metadata.wasm-pack.profile.release] +wasm-opt = ["-O4", "--enable-mutable-globals"] +``` + +## Package Output + +Built files are generated in the `pkg/` directory: + +``` +pkg/ +โ”œโ”€โ”€ toboggan_wasm.js # JavaScript bindings and glue code +โ”œโ”€โ”€ toboggan_wasm.d.ts # TypeScript type definitions +โ”œโ”€โ”€ toboggan_wasm_bg.wasm # Main WASM binary +โ”œโ”€โ”€ toboggan_wasm_bg_opt.wasm # Optimized WASM binary +โ”œโ”€โ”€ toboggan_wasm_bg.wasm.d.ts # WASM-specific type definitions +โ”œโ”€โ”€ package.json # NPM package configuration +โ””โ”€โ”€ README.md # Package documentation +``` + +### File Descriptions + +- **`toboggan_wasm.js`**: Main entry point with JavaScript bindings +- **`toboggan_wasm.d.ts`**: TypeScript definitions for all exported functions +- **`toboggan_wasm_bg.wasm`**: WebAssembly binary with Rust logic +- **`toboggan_wasm_bg_opt.wasm`**: Size-optimized version (use in production) + +## Integration Examples + +### React Integration + +```typescript +import React, { useEffect, useState } from 'react'; +import { TobogganClient } from '../pkg/toboggan_wasm'; + +export function PresentationController() { + const [client, setClient] = useState(null); + const [state, setState] = useState(null); + + useEffect(() => { + const initClient = async () => { + const wasmClient = new TobogganClient('ws://localhost:8080/api/ws'); + + wasmClient.set_state_callback((newState) => { + setState(JSON.parse(newState)); + }); + + await wasmClient.connect(); + setClient(wasmClient); + }; + + initClient().catch(console.error); + }, []); + + const sendCommand = async (command: string) => { + if (client) { + await client.send_command(JSON.stringify({ type: command })); + } + }; + + return ( +
+ + + + {state &&
Current slide: {state.current}
} +
+ ); +} +``` + +### Vanilla JavaScript Integration + +```javascript +import init, { TobogganClient } from './pkg/toboggan_wasm.js'; + +async function startPresentation() { + // Initialize WASM module + await init(); + + // Create client + const client = new TobogganClient('ws://localhost:8080/api/ws'); + + // Set up state handler + client.set_state_callback((state) => { + const parsedState = JSON.parse(state); + document.getElementById('slide-counter').textContent = + `Slide ${parsedState.current + 1}`; + }); + + // Connect to server + try { + await client.connect(); + console.log('Connected to Toboggan server'); + } catch (error) { + console.error('Connection failed:', error); + } + + // Set up navigation buttons + document.getElementById('next-btn').addEventListener('click', () => { + client.send_command('{"type": "Next"}'); + }); + + document.getElementById('prev-btn').addEventListener('click', () => { + client.send_command('{"type": "Previous"}'); + }); +} + +startPresentation().catch(console.error); +``` + +## Performance Optimization + +### Build Optimization + +```bash +# Enable all optimizations +export RUSTFLAGS="-C opt-level=s -C lto=fat -C codegen-units=1" + +# Build with optimizations +wasm-pack build --target web --release + +# Further optimize with wasm-opt +wasm-opt pkg/toboggan_wasm_bg.wasm -Os -o pkg/toboggan_wasm_bg_opt.wasm +``` + +### Bundle Size Optimization + +Current optimized sizes: +- **WASM Binary**: ~180KB (gzipped: ~65KB) +- **JavaScript Glue**: ~15KB (gzipped: ~5KB) +- **Total Package**: ~195KB (gzipped: ~70KB) + +**Size Reduction Techniques:** +- Conditional compilation for debug features +- Minimal external dependencies +- Efficient serialization with `serde` +- Dead code elimination with `wee_alloc` + +### Runtime Performance + +- **Zero-cost abstractions**: Rust's performance guarantees +- **Memory efficiency**: Manual memory management via `wasm-bindgen` +- **Minimal JavaScript overhead**: Direct WASM function calls +- **Async/await support**: Non-blocking WebSocket operations + +## Testing + +### Unit Tests + +```bash +# Run Rust unit tests +cargo test + +# Test WASM bindings +wasm-pack test --headless --firefox +``` + +### Browser Testing + +```bash +# Test in Chrome +wasm-pack test --headless --chrome + +# Test in Firefox +wasm-pack test --headless --firefox + +# Interactive testing in browser +wasm-pack test --firefox +``` + +### Integration Testing + +```javascript +// Example integration test +describe('Toboggan WASM Client', () => { + let client; + + beforeAll(async () => { + await init(); // Initialize WASM + client = new TobogganClient('ws://localhost:8080/api/ws'); + }); + + test('should connect to server', async () => { + await expect(client.connect()).resolves.toBeUndefined(); + }); + + test('should send commands', async () => { + await expect( + client.send_command('{"type": "Next"}') + ).resolves.toBeUndefined(); + }); +}); +``` + +## Debugging + +### Development Setup + +```rust +// Enable console logging in development +#[cfg(feature = "console_error_panic_hook")] +console_error_panic_hook::set_once(); + +// Log to browser console +web_sys::console::log_1(&"WASM client initialized".into()); +``` + +### Browser DevTools + +```javascript +// Enable WASM debugging in Chrome DevTools +// 1. Open DevTools +// 2. Go to Settings > Experiments +// 3. Enable "WebAssembly Debugging" +// 4. Restart DevTools + +// Debug WASM in the Sources panel +debugger; // JavaScript breakpoint +// WASM breakpoints available in Sources > wasm:// +``` + +### Error Handling + +```rust +// Convert Rust errors to JavaScript +#[wasm_bindgen] +pub fn risky_operation() -> Result { + match some_fallible_operation() { + Ok(result) => Ok(result), + Err(e) => Err(JsValue::from_str(&format!("Operation failed: {}", e))) + } +} +``` + +## Contributing + +### Development Setup + +```bash +# Install dependencies +rustup target add wasm32-unknown-unknown +cargo install wasm-pack + +# Clone and setup +git clone https://github.com/ilaborie/toboggan +cd toboggan/toboggan-web/toboggan-wasm + +# Start development +bacon wasm # Live rebuild +``` + +### Code Guidelines + +- **Safety First**: No `unsafe` code (enforced by workspace lints) +- **Error Handling**: Comprehensive error propagation to JavaScript +- **Performance**: Minimize allocations and optimize hot paths +- **Compatibility**: Test across major browsers (Chrome, Firefox, Safari) +- **Documentation**: Document all public APIs with examples + +### Testing Requirements + +- All public functions must have unit tests +- Browser compatibility tests required for new features +- Integration tests for WebSocket functionality +- Performance benchmarks for critical paths + +The WASM client provides a high-performance, memory-safe foundation for web-based Toboggan presentation clients. \ No newline at end of file diff --git a/toboggan-web/toboggan-wasm/bacon.toml b/toboggan-web/toboggan-wasm/bacon.toml new file mode 100644 index 0000000..b001a51 --- /dev/null +++ b/toboggan-web/toboggan-wasm/bacon.toml @@ -0,0 +1,120 @@ +# This is a configuration file for the bacon tool +# +# Complete help on configuration: https://dystroy.org/bacon/config/ +# +#ย You may check the current default at +# https://github.com/Canop/bacon/blob/main/defaults/default-bacon.toml + +default_job = "check" +env.CARGO_TERM_COLOR = "always" + +[jobs.check] +command = ["cargo", "check"] +need_stdout = false + +[jobs.check-all] +command = ["cargo", "check", "--all-targets"] +need_stdout = false + +# Run clippy on the default target +[jobs.clippy] +command = ["cargo", "clippy"] +need_stdout = false + +# Run clippy on all targets +# To disable some lints, you may change the job this way: +# [jobs.clippy-all] +# command = [ +# "cargo", "clippy", +# "--all-targets", +# "--", +# "-A", "clippy::bool_to_int_with_if", +# "-A", "clippy::collapsible_if", +# "-A", "clippy::derive_partial_eq_without_eq", +# ] +# need_stdout = false +[jobs.clippy-all] +command = ["cargo", "clippy", "--all-targets"] +need_stdout = false + +# This job lets you run +# - all tests: bacon test +# - a specific test: bacon test -- config::test_default_files +# - the tests of a package: bacon test -- -- -p config +[jobs.test] +command = ["cargo", "test"] +need_stdout = true + +[jobs.nextest] +command = [ + "cargo", + "nextest", + "run", + "--hide-progress-bar", + "--failure-output", + "final", +] +need_stdout = true +analyzer = "nextest" + +[jobs.doc] +command = ["cargo", "doc", "--no-deps"] +need_stdout = false + +# If the doc compiles, then it opens in your browser and bacon switches +# to the previous job +[jobs.doc-open] +command = ["cargo", "doc", "--no-deps", "--open"] +need_stdout = false +on_success = "back" # so that we don't open the browser at each change + +# You can run your application and have the result displayed in bacon, +# if it makes sense for this crate. +[jobs.run] +command = [ + "cargo", + "run", + # put launch parameters for your program behind a `--` separator +] +need_stdout = true +allow_warnings = true +background = true + +# Run your long-running application (eg server) and have the result displayed in bacon. +# For programs that never stop (eg a server), `background` is set to false +# to have the cargo run output immediately displayed instead of waiting for +# program's end. +# 'on_change_strategy' is set to `kill_then_restart` to have your program restart +# on every change (an alternative would be to use the 'F5' key manually in bacon). +# If you often use this job, it makes sense to override the 'r' key by adding +# a binding `r = job:run-long` at the end of this file . +# A custom kill command such as the one suggested below is frequently needed to kill +# long running programs (uncomment it if you need it) +[jobs.run-long] +command = [ + "cargo", + "run", + # put launch parameters for your program behind a `--` separator +] +need_stdout = true +allow_warnings = true +background = false +on_change_strategy = "kill_then_restart" +# kill = ["pkill", "-TERM", "-P"]' + +# This parameterized job runs the example of your choice, as soon +# as the code compiles. +# Call it as +# bacon ex -- my-example +[jobs.wasm] +command = ["mise", "build:wasm"] +need_stdout = true +allow_warnings = true + +# You may define here keybindings that would be specific to +# a project, for example a shortcut to launch a specific job. +# Shortcuts to internal functions (scrolling, toggling, etc.) +# should go in your personal global prefs.toml file instead. +[keybindings] +# alt-m = "job:my-job" +c = "job:clippy-all" # comment this to have 'c' run clippy on only the default target diff --git a/toboggan-web/toboggan-wasm/rustfmt.toml b/toboggan-web/toboggan-wasm/rustfmt.toml new file mode 100644 index 0000000..2a732ff --- /dev/null +++ b/toboggan-web/toboggan-wasm/rustfmt.toml @@ -0,0 +1,5 @@ +# Rustfmt configuration +edition = "2024" +unstable_features = true +imports_granularity = "Module" +group_imports = "StdExternalCrate" \ No newline at end of file diff --git a/toboggan-web/toboggan-wasm/src/app.rs b/toboggan-web/toboggan-wasm/src/app.rs new file mode 100644 index 0000000..bfe9305 --- /dev/null +++ b/toboggan-web/toboggan-wasm/src/app.rs @@ -0,0 +1,511 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use futures::StreamExt; +use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender, unbounded}; +use gloo::console::{debug, error, info}; +use toboggan_core::{Command, State}; +use wasm_bindgen::UnwrapThrowExt; +use wasm_bindgen_futures::spawn_local; +use web_sys::HtmlElement; + +use crate::{ + AppConfig, CommunicationMessage, CommunicationService, ConnectionStatus, KeyboardService, + StateClassMapper, ToastType, TobogganApi, TobogganFooterElement, TobogganSlideElement, + TobogganToastElement, WasmElement, create_html_element, inject_head_html, play_tada, +}; + +/// Holds metadata about the presentation +#[derive(Debug, Clone, Default)] +struct PresentationMeta { + /// Total number of slides in the presentation + total_slides: usize, +} + +/// Tracks state recovery information for reconnection scenarios +#[derive(Debug, Clone, Default)] +struct RecoveryState { + /// Last known state before disconnection + last_known_state: Option, + /// Whether we're waiting to attempt state restoration after reconnection + pending_restoration: bool, +} + +#[derive(Debug, Clone)] +pub(crate) enum Action { + Command(Command), + NextStep, + PreviousStep, +} + +#[derive(Default)] +struct TobogganElements { + slide: TobogganSlideElement, + footer: TobogganFooterElement, + toast: TobogganToastElement, +} + +pub struct App { + api: Rc, + kbd: KeyboardService, + com: Rc>, + client_id: toboggan_core::ClientId, + elements: Rc>, + rx_msg: Option>, + rx_action: Option>, + tx_cmd: Option>, + root_element: Option>, +} + +impl App { + pub fn new(config: AppConfig) -> Self { + let AppConfig { + client_id, + api_base_url, + websocket, + keymap, + } = config; + + let api = Rc::new(TobogganApi::new(&api_base_url)); + let (tx_cmd, rx_cmd) = unbounded(); + let (tx_msg, rx_msg) = unbounded(); + let (tx_action, rx_action) = unbounded(); + + let kbd = KeyboardService::new(tx_action, keymap.unwrap_or_default()); + let com = CommunicationService::new(client_id, websocket, tx_msg, tx_cmd.clone(), rx_cmd); + let com = Rc::new(RefCell::new(com)); + + Self { + api, + kbd, + com, + client_id, + elements: Rc::new(RefCell::new(TobogganElements::default())), + rx_msg: Some(rx_msg), + rx_action: Some(rx_action), + tx_cmd: Some(tx_cmd), + root_element: None, + } + } +} + +impl WasmElement for App { + fn render(&mut self, host: &HtmlElement) { + let Some(rx_msg) = self.rx_msg.take() else { + error!("Render should be called only once"); + return; + }; + let Some(rx_action) = self.rx_action.take() else { + error!("Render should be called only once"); + return; + }; + + // Store root element for state class updates + let root_element = Rc::new(host.clone()); + self.root_element = Some(root_element.clone()); + + // Set initial state class + let current_classes = host.class_name(); + let new_classes = if current_classes.is_empty() { + "init".to_string() + } else { + format!("{current_classes} init") + }; + host.set_class_name(&new_classes); + + { + let mut elements = self.elements.borrow_mut(); + + let el = create_html_element("div"); + el.set_class_name("toboggan-slide"); + elements.slide.render(&el); + host.append_child(&el).unwrap_throw(); + + let el = create_html_element("div"); + el.set_class_name("toboggan-toast"); + elements.toast.render(&el); + host.append_child(&el).unwrap_throw(); + + let el = create_html_element("footer"); + el.set_class_name("toboggan-footer"); + elements.footer.render(&el); + host.append_child(&el).unwrap_throw(); + } + + self.kbd.start(); + + let com = Rc::clone(&self.com); + spawn_local(async move { + com.borrow_mut().connect(); + }); + + let tx_cmd = self.tx_cmd.take().unwrap_throw(); + let presentation_meta = Rc::new(RefCell::new(PresentationMeta::default())); + spawn_local(handle_messages( + self.api.clone(), + rx_msg, + self.elements.clone(), + self.client_id, + tx_cmd.clone(), + root_element, + presentation_meta, + )); + + spawn_local(handle_actions(rx_action, self.elements.clone(), tx_cmd)); + } +} + +async fn handle_messages( + api: Rc, + mut rx: UnboundedReceiver, + elements: Rc>, + client_id: toboggan_core::ClientId, + tx_cmd: UnboundedSender, + root_element: Rc, + presentation_meta: Rc>, +) { + let recovery_state = Rc::new(RefCell::new(RecoveryState::default())); + + while let Some(msg) = rx.next().await { + match msg { + CommunicationMessage::ConnectionStatusChange { status } => { + handle_connection_status( + &status, + &api, + &elements, + client_id, + &tx_cmd, + &recovery_state, + &presentation_meta, + ) + .await; + } + CommunicationMessage::StateChange { state } => { + handle_state_change( + state, + &api, + &elements, + &root_element, + &tx_cmd, + &recovery_state, + &presentation_meta, + ) + .await; + } + CommunicationMessage::TalkChange { state } => { + handle_talk_change( + state, + &api, + &elements, + &root_element, + &tx_cmd, + &recovery_state, + &presentation_meta, + ) + .await; + } + CommunicationMessage::Error { error } => { + elements.borrow().toast.toast(ToastType::Error, &error); + } + } + } +} + +async fn handle_connection_status( + status: &ConnectionStatus, + api: &Rc, + elements: &Rc>, + client_id: toboggan_core::ClientId, + tx_cmd: &UnboundedSender, + recovery_state: &Rc>, + presentation_meta: &Rc>, +) { + { + let elems = elements.borrow(); + + match status { + ConnectionStatus::Connecting => { + elems + .toast + .toast(ToastType::Info, "Connecting to server..."); + } + ConnectionStatus::Connected => { + elems.toast.toast(ToastType::Success, "Connected to server"); + } + ConnectionStatus::Closed => { + elems.toast.toast(ToastType::Error, "Connection closed"); + } + ConnectionStatus::Reconnecting { + attempt, + max_attempt, + delay, + } => { + let message = format!( + "Reconnecting in {}s ({attempt}/{max_attempt})", + delay.as_secs() + ); + elems.toast.toast(ToastType::Warning, &message); + } + ConnectionStatus::Error { message } => { + elems.toast.toast(ToastType::Error, message); + } + } + } + + if matches!(status, ConnectionStatus::Connected) { + // Mark that we should attempt recovery when we receive the next state + recovery_state.borrow_mut().pending_restoration = true; + + let _ = tx_cmd.unbounded_send(Command::Register { client: client_id }); + + if let Ok(talk) = api.get_talk().await { + // Update presentation metadata with total slides count + presentation_meta.borrow_mut().total_slides = talk.titles.len(); + + let mut elem = elements.borrow_mut(); + elem.footer.set_content(talk.footer.clone()); + drop(elem); + + // Inject custom head HTML if provided + inject_head_html(talk.head.as_deref()); + } else { + error!("Failed to fetch talk"); + } + } +} + +async fn handle_state_change( + state: State, + api: &Rc, + elements: &Rc>, + root_element: &Rc, + tx_cmd: &UnboundedSender, + recovery_state: &Rc>, + presentation_meta: &Rc>, +) { + // Auto-start presentation when in Init state + if matches!(state, State::Init) { + info!("Auto-starting presentation from Init state"); + let _ = tx_cmd.unbounded_send(Command::First); + return; + } + + // Try to restore previous slide position after reconnection + if try_restore_slide_position(&state, elements, tx_cmd, recovery_state) { + return; // We'll receive a new StateChange after GoTo command + } + + // Save current state for future reconnection recovery + recovery_state.borrow_mut().last_known_state = Some(state.clone()); + + // Update UI to reflect current state + update_root_state_class(&state, root_element, presentation_meta); + update_slide_display(&state, api, elements).await; + show_completion_toast_if_done(&state, elements); +} + +async fn handle_talk_change( + state: State, + api: &Rc, + elements: &Rc>, + root_element: &Rc, + _tx_cmd: &UnboundedSender, + recovery_state: &Rc>, + presentation_meta: &Rc>, +) { + info!("๐Ÿ“ Presentation updated, reloading talk metadata"); + + // Notify user that presentation was updated + elements + .borrow() + .toast + .toast(ToastType::Info, "๐Ÿ“ Presentation updated"); + + // Re-fetch talk metadata + match api.get_talk().await { + Ok(talk) => { + // Update presentation metadata with total slides count + presentation_meta.borrow_mut().total_slides = talk.titles.len(); + + let mut elem = elements.borrow_mut(); + elem.footer.set_content(talk.footer.clone()); + drop(elem); + + // Inject custom head HTML if provided + inject_head_html(talk.head.as_deref()); + } + Err(err) => { + error!("Failed to refetch talk after TalkChange:", err.to_string()); + elements + .borrow() + .toast + .toast(ToastType::Error, "Failed to reload presentation metadata"); + } + } + + // Save current state for future re-connection recovery + recovery_state.borrow_mut().last_known_state = Some(state.clone()); + + // Update UI to reflect current state (server has already adjusted slide position) + update_root_state_class(&state, root_element, presentation_meta); + update_slide_display(&state, api, elements).await; + show_completion_toast_if_done(&state, elements); +} + +/// Attempts to restore slide position after re-connection +/// Returns true if restoration was attempted (caller should return early) +fn try_restore_slide_position( + state: &State, + elements: &Rc>, + tx_cmd: &UnboundedSender, + recovery_state: &Rc>, +) -> bool { + let mut recovery = recovery_state.borrow_mut(); + + // Not pending restoration? Nothing to do + if !recovery.pending_restoration { + return false; + } + + recovery.pending_restoration = false; + + // Server has active state? Respect it (server wasn't restarted) + if !matches!(state, State::Init) { + debug!( + "Skipping restoration - server has active state:", + state.to_css_class() + ); + return false; + } + + // Extract last known state or return + let Some(last_state) = &recovery.last_known_state else { + return false; + }; + + // Extract slide position from last state or return + let Some(slide_id) = last_state.current() else { + return false; + }; + + info!( + "Attempting to restore to slide", + slide_id, "after reconnection" + ); + + // Send GoTo command to restore position + elements.borrow().toast.toast( + ToastType::Info, + &format!("Restoring to slide {slide_id}..."), + ); + + if tx_cmd + .unbounded_send(Command::GoTo { slide: slide_id }) + .is_err() + { + error!("Failed to send GoTo command for restoration"); + return false; + } + + info!("Sent GoTo command to restore to slide", slide_id); + true +} + +/// Updates root element CSS class to reflect current state +fn update_root_state_class( + state: &State, + root_element: &HtmlElement, + presentation_meta: &Rc>, +) { + let state_class = state.to_css_class(); + let current_classes = root_element.class_name(); + + // Remove old state classes and add new one + let classes: Vec<&str> = current_classes + .split_whitespace() + .filter(|class| !matches!(*class, "init" | "paused" | "running" | "done")) + .collect(); + + let new_classes = if classes.is_empty() { + state_class.to_string() + } else { + format!("{} {state_class}", classes.join(" ")) + }; + + root_element.set_class_name(&new_classes); + + // Update CSS custom properties for slide tracking + let current_slide = state.current().map_or(0, |idx| idx + 1); // 1-based for display + let total_slides = presentation_meta.borrow().total_slides; + + let style = root_element.style(); + let _ = style.set_property("--current-slide", ¤t_slide.to_string()); + let _ = style.set_property("--total-slides", &total_slides.to_string()); +} + +/// Fetches and displays the slide corresponding to current state +async fn update_slide_display( + state: &State, + api: &Rc, + elements: &Rc>, +) { + let Some(slide_id) = state.current() else { + debug!("No current slide, clearing slide component"); + elements.borrow_mut().slide.set_slide(None); + return; + }; + + let state_class = state.to_css_class(); + debug!("Fetching slide", slide_id, "for state", state_class); + + let Ok(slide) = api.get_slide(slide_id).await else { + error!("Failed to fetch slide", slide_id); + return; + }; + + elements.borrow_mut().slide.set_slide(Some(slide)); +} + +/// Shows completion toast if presentation is done +fn show_completion_toast_if_done(state: &State, elements: &Rc>) { + if matches!(state, State::Done { .. }) { + debug!("Showing done toast"); + let elements = elements.borrow(); + elements.toast.toast(ToastType::Success, "๐ŸŽ‰ Done"); + play_tada(); + } +} + +async fn handle_actions( + mut rx: UnboundedReceiver, + elements: Rc>, + tx_cmd: UnboundedSender, +) { + while let Some(action) = rx.next().await { + match action { + Action::Command(cmd) => { + if tx_cmd.unbounded_send(cmd).is_err() { + error!("Failed to send command"); + } + } + Action::NextStep => { + let mut elems = elements.borrow_mut(); + if !elems.slide.next_step() { + // No more steps, go to next slide + if tx_cmd.unbounded_send(Command::Next).is_err() { + error!("Failed to send next command"); + } + } + } + Action::PreviousStep => { + let mut elems = elements.borrow_mut(); + if !elems.slide.previous_step() { + // No steps to go back, go to previous slide + if tx_cmd.unbounded_send(Command::Previous).is_err() { + error!("Failed to send previous command"); + } + } + } + } + } +} diff --git a/toboggan-web/toboggan-wasm/src/components/footer/mod.rs b/toboggan-web/toboggan-wasm/src/components/footer/mod.rs new file mode 100644 index 0000000..0abbe61 --- /dev/null +++ b/toboggan-web/toboggan-wasm/src/components/footer/mod.rs @@ -0,0 +1,45 @@ +use web_sys::{Element, HtmlElement}; + +use crate::components::WasmElement; +use crate::{create_and_append_element, create_shadow_root_with_style, dom_try}; + +const CSS: &str = include_str!("style.css"); + +#[derive(Debug, Default)] +pub struct TobogganFooterElement { + container: Option, + content: Option, +} + +impl TobogganFooterElement { + pub fn set_content(&mut self, content: Option) { + self.content = content; + self.render_content(); + } + + fn render_content(&mut self) { + let Some(container) = &self.container else { + return; + }; + + let html = self.content.as_deref().unwrap_or(""); + container.set_inner_html(html); + } +} + +impl WasmElement for TobogganFooterElement { + fn render(&mut self, host: &HtmlElement) { + let root = dom_try!( + create_shadow_root_with_style(host, CSS), + "create shadow root" + ); + + let container: Element = dom_try!( + create_and_append_element(&root, "footer"), + "create footer element" + ); + + self.container = Some(container); + self.render_content(); + } +} diff --git a/toboggan-web/toboggan-wasm/src/components/footer/style.css b/toboggan-web/toboggan-wasm/src/components/footer/style.css new file mode 100644 index 0000000..4a843a9 --- /dev/null +++ b/toboggan-web/toboggan-wasm/src/components/footer/style.css @@ -0,0 +1,14 @@ +:host { + display: block; + font-family: var(--font-family-primary); + /* inversed colors */ + color: var(--bg); + background-color: var(--fg); +} + +/* Default content when footer is empty */ +footer:empty::before { + content: "๐Ÿ› Toboggan Presentation System"; + font-style: normal; + color: inherit; +} diff --git a/toboggan-web/toboggan-wasm/src/components/mod.rs b/toboggan-web/toboggan-wasm/src/components/mod.rs new file mode 100644 index 0000000..1061bc5 --- /dev/null +++ b/toboggan-web/toboggan-wasm/src/components/mod.rs @@ -0,0 +1,14 @@ +use web_sys::HtmlElement; + +mod footer; +pub use self::footer::*; + +mod slide; +pub use self::slide::*; + +mod toast; +pub use self::toast::*; + +pub(crate) trait WasmElement { + fn render(&mut self, host: &HtmlElement); +} diff --git a/toboggan-web/toboggan-wasm/src/components/slide/mod.rs b/toboggan-web/toboggan-wasm/src/components/slide/mod.rs new file mode 100644 index 0000000..3e8a3c6 --- /dev/null +++ b/toboggan-web/toboggan-wasm/src/components/slide/mod.rs @@ -0,0 +1,198 @@ +use toboggan_core::{Slide, SlideKind}; +use wasm_bindgen::JsCast; +use web_sys::{Element, HtmlElement}; + +use crate::components::WasmElement; +use crate::{create_and_append_element, create_shadow_root_with_style, dom_try, render_content}; + +const CSS: &str = include_str!("style.css"); + +#[derive(Debug, Default)] +pub struct TobogganSlideElement { + container: Option, + slide: Option, +} + +impl TobogganSlideElement { + pub fn set_slide(&mut self, slide: Option) { + self.slide = slide; + self.render_slide(); + self.reset_steps(); + } + + fn render_slide(&mut self) { + let Some(container) = &self.container else { + return; + }; + + let content = if let Some(slide) = &self.slide { + // Apply style classes and add slide kind class + let mut classes = slide.style.classes.clone(); + + // Add slide kind as CSS class + let kind_class = match slide.kind { + SlideKind::Cover => "cover", + SlideKind::Part => "part", + SlideKind::Standard => "standard", + }; + classes.push(kind_class.to_string()); + + let class_string = classes.join(" "); + container.set_class_name(&class_string); + + // Apply inline style if present + if let Some(style) = &slide.style.style { + let _ = container.set_attribute("style", style); + } else { + let _ = container.remove_attribute("style"); + } + + let title = render_content(&slide.title, None); + let body = render_content(&slide.body, Some("article")); + + if title.is_empty() { + body + } else { + format!("

{title}

{body}") + } + } else { + // Clear any previous styles + container.set_class_name(""); + let _ = container.remove_attribute("style"); + "
".to_string() + }; + + container.set_inner_html(&content); + } + + fn reset_steps(&self) { + let Some(container) = &self.container else { + return; + }; + + let steps = container.query_selector_all(".step").ok(); + if let Some(steps) = steps { + for i in 0..steps.length() { + if let Some(step) = steps.item(i) + && let Ok(element) = step.dyn_into::() + { + let class_name = element.class_name(); + let new_classes = class_name + .split_whitespace() + .filter(|content| *content != "step-done" && *content != "step-current") + .collect::>() + .join(" "); + element.set_class_name(&new_classes); + } + } + } + } + + fn update_current_step(&self) { + let Some(container) = &self.container else { + return; + }; + + // Remove step-current from all steps + if let Ok(steps) = container.query_selector_all(".step") { + for i in 0..steps.length() { + if let Some(step) = steps.item(i) + && let Ok(element) = step.dyn_into::() + { + let class_name = element.class_name(); + let new_classes = class_name + .split_whitespace() + .filter(|content| *content != "step-current") + .collect::>() + .join(" "); + element.set_class_name(&new_classes); + } + } + } + + // Add step-current to the last step-done + if let Ok(done_steps) = container.query_selector_all(".step.step-done") + && done_steps.length() > 0 + { + let last_index = done_steps.length() - 1; + if let Some(step) = done_steps.item(last_index) + && let Ok(element) = step.dyn_into::() + { + let class_name = element.class_name(); + element.set_class_name(&format!("{class_name} step-current")); + } + } + } + + pub fn next_step(&mut self) -> bool { + let Some(container) = &self.container else { + return false; + }; + + let steps = match container.query_selector_all(".step") { + Ok(steps) if steps.length() > 0 => steps, + _ => return false, + }; + + for i in 0..steps.length() { + if let Some(step) = steps.item(i) + && let Ok(element) = step.dyn_into::() + { + let class_name = element.class_name(); + if !class_name.contains("step-done") { + element.set_class_name(&format!("{class_name} step-done")); + self.update_current_step(); + return true; + } + } + } + + // All steps are done + false + } + + pub fn previous_step(&mut self) -> bool { + let Some(container) = &self.container else { + return false; + }; + + let done_steps = match container.query_selector_all(".step.step-done") { + Ok(steps) if steps.length() > 0 => steps, + _ => return false, + }; + + let last_index = done_steps.length() - 1; + if let Some(step) = done_steps.item(last_index) + && let Ok(element) = step.dyn_into::() + { + let class_name = element.class_name(); + let new_classes = class_name + .split_whitespace() + .filter(|content| *content != "step-done") + .collect::>() + .join(" "); + element.set_class_name(&new_classes); + self.update_current_step(); + return true; + } + + false + } +} + +impl WasmElement for TobogganSlideElement { + fn render(&mut self, host: &HtmlElement) { + let root = dom_try!( + create_shadow_root_with_style(host, CSS), + "create shadow root" + ); + + let container: Element = dom_try!( + create_and_append_element(&root, "section"), + "create section element" + ); + + self.container = Some(container); + self.render_slide(); + } +} diff --git a/toboggan-web/toboggan-wasm/src/components/slide/style.css b/toboggan-web/toboggan-wasm/src/components/slide/style.css new file mode 100644 index 0000000..118ef90 --- /dev/null +++ b/toboggan-web/toboggan-wasm/src/components/slide/style.css @@ -0,0 +1,199 @@ +:host { + color: var(--fg); + background-color: var(--bg); +} + +/* Fonts */ +h1, +h2, +h3, +h4, +h5, +h6 { + font-family: var(--font-slide-title); + margin: 0.1em 0; +} + +h1 { + font-size: 350%; + text-align: center; +} + +h2 { + font-size: 200%; + text-align: center; +} + +h3 { + font-size: 150%; +} + +h4 { + font-size: 115%; +} + +h5 { + font-size: 110%; +} + +h5 { + font-size: 105%; +} + +ul, +ol, +dl { + margin: 0.1em; + list-style: none; +} + +article { + font-family: var(--font-slide-body); + position: relative; +} + +pre, +code { + color: var(--accent2); + text-shadow: 1px 1px rgba(0, 0, 0, 0.2); + font-family: var(--font-slide-mono); +} + +/* Layout */ +section { + margin: 1rem auto 0; + width: 80vw; + min-height: calc(100% - 1rem); + max-height: calc(100% - 1rem); + overflow: auto; + display: flex; + flex-direction: column; + + h2 { + flex: 0 0 auto; + } + + article { + flex: 1 1 auto; + display: flex; + flex-direction: column; + justify-content: space-evenly; + } +} + +/* abbr */ +abbr { + text-decoration-line: none; + cursor: help; + + &:hover, + &:active { + color: var(--accent); + text-shadow: 1px 1px rgba(0, 0, 0, 0.2); + text-decoration: underline dotted; + } +} + +/* Links */ +a, +a:visited { + color: var(--accent); + text-shadow: 1px 1px rgba(0, 0, 0, 0.2); + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} + +/* Links */ +code { + color: var(--accent2); + border: thin solid transparent; + border-radius: 0.25em; + padding: 0 0.125em; + + &:hover { + border-color: rgba(0, 0, 0, 0.25); + } +} + +/* Common layout */ + +.block { + --offset: 0.25em; + background: var(--bg); + color: var(--fg); + font-weight: bold; + display: inline; + padding: 0.1em calc(var(--offset) * 2); + clip-path: polygon( + var(--offset) 0, + 100% 0, + calc(100% - var(--offset)) 100%, + 0 100% + ); +} + +.two-columns { + columns: 2; +} + +/* Steps */ +.step { + opacity: var(--step-opacity-hidden); + transition: opacity var(--step-transition-duration) + var(--step-transition-timing); + + &.step-done { + opacity: var(--step-opacity-revealed); + + &:nth-last-of-type { + opacity: var(--step-opacity-active); + } + } +} + +.step a { + pointer-events: none; +} + +.step.step-done a { + pointer-events: auto; +} + +.step-line-through .step.step-done:not(.step-current) { + opacity: 0.9; + text-decoration: line-through 0.25em; +} + +/* Predefined style (can be used in frontmatter like `classes = ["no_title"]`) */ +section.no_title > h2 { + display: none; +} + +section.spread-steps { + article > h3 { + padding: 0.5em 0; + flex: 0 0 auto; + } + + article > .step { + flex: 1 1 auto; + display: flex; + flex-direction: column; + justify-content: center; + } +} + +section.cover, +section.center { + align-items: center; + justify-content: space-between; + + article { + justify-content: center; + } +} + +/* TODO GRID */ diff --git a/toboggan-web/toboggan-wasm/src/components/toast/mod.rs b/toboggan-web/toboggan-wasm/src/components/toast/mod.rs new file mode 100644 index 0000000..a6912d1 --- /dev/null +++ b/toboggan-web/toboggan-wasm/src/components/toast/mod.rs @@ -0,0 +1,130 @@ +use std::fmt::Display; + +use gloo::events::EventListener; +use gloo::timers::callback::Timeout; +use gloo::utils::document; +use wasm_bindgen::prelude::*; +use web_sys::HtmlElement; + +use crate::components::WasmElement; +use crate::{create_and_append_element, create_shadow_root_with_style, dom_try}; + +const CSS: &str = include_str!("style.css"); + +#[derive(Debug, Clone, Copy)] +pub enum ToastType { + Error, + Warning, + Info, + Success, +} + +impl Display for ToastType { + fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Error => write!(fmt, "error"), + Self::Warning => write!(fmt, "warning"), + Self::Info => write!(fmt, "info"), + Self::Success => write!(fmt, "success"), + } + } +} + +#[derive(Debug)] +pub struct TobogganToastElement { + container: Option, + duration_ms: u32, +} + +impl Default for TobogganToastElement { + fn default() -> Self { + Self { + container: None, + duration_ms: 3000, + } + } +} + +impl TobogganToastElement { + pub fn toast(&self, toast_type: ToastType, message: &str) { + let Some(container) = &self.container else { + return; + }; + + let toast = dom_try!( + document() + .create_element("output") + .map(|el| el.dyn_into::().unwrap_throw()), + "create toast element" + ); + + dom_try!(toast.set_attribute("role", "status"), "set role"); + toast.set_class_name(&toast_type.to_string()); + toast.set_inner_html(&format!( + r#"

{message}

"# + )); + + if let Ok(Some(btn)) = toast.query_selector("button") { + let btn = btn.dyn_into::().unwrap_throw(); + let toast_clone = toast.clone(); + let container_clone = container.clone(); + EventListener::new(&btn, "click", move |_| { + let _ = container_clone.remove_child(&toast_clone); + }) + .forget(); + } + + Self::animate_toast_entry(container, &toast); + let _ = container.append_child(&toast); + + let toast_clone = toast.clone(); + Timeout::new(self.duration_ms, move || { + if let Some(parent) = toast_clone.parent_node() { + let _ = parent.remove_child(&toast_clone); + } + }) + .forget(); + } + + fn animate_toast_entry(container: &HtmlElement, _toast: &HtmlElement) { + if container.child_element_count() > 0 { + let first = container.offset_height(); + let last = container.offset_height() + 50; // Estimate + let invert = last - first; + + if invert != 0 { + let style = container.style(); + style + .set_property("transform", &format!("translateY({invert}px)")) + .ok(); + style + .set_property("transition", "transform 150ms ease-out") + .ok(); + let _ = container.offset_height(); // Force reflow + style.set_property("transform", "translateY(0)").ok(); + + let container_clone = container.clone(); + Timeout::new(200, move || { + container_clone.style().set_property("transition", "").ok(); + }) + .forget(); + } + } + } +} + +impl WasmElement for TobogganToastElement { + fn render(&mut self, host: &HtmlElement) { + let root = dom_try!( + create_shadow_root_with_style(host, CSS), + "create shadow root" + ); + + let container: HtmlElement = dom_try!( + create_and_append_element(&root, "footer"), + "create footer element" + ); + + self.container = Some(container); + } +} diff --git a/toboggan-web/toboggan-wasm/src/components/toast/style.css b/toboggan-web/toboggan-wasm/src/components/toast/style.css new file mode 100644 index 0000000..2a7fcdc --- /dev/null +++ b/toboggan-web/toboggan-wasm/src/components/toast/style.css @@ -0,0 +1,153 @@ +.toaster { + display: grid; + justify-items: end; + justify-content: end; +} + +output { + --duration: var(--toast-duration); + --travel-distance: 0; + + margin-bottom: var(--spacing-sm); + will-change: transform; + animation: + fade-in var(--animation-fast) ease, + slide-in var(--animation-fast) ease, + fade-out var(--animation-fast) ease var(--duration); + max-inline-size: min(40ch 50vw); + padding: var(--spacing-xs) var(--spacing-sm); + border-radius: var(--radius-sm); + border: 0.125rem solid var(--color-border-transparent); + display: grid; + grid-template-columns: 1fr 1.5rem; + gap: var(--spacing-md); + align-items: center; + + strong { + text-transform: capitalize; + } + + p { + margin: 0; + display: flex; + align-items: center; + } +} + +output { + color: var(--bg); + + &.error { + background-color: var(--color-error); + } + + &.warning { + background-color: var(--color-warning); + } + + &.info { + background-color: var(--color-info); + } + + &.success { + background-color: var(--color-success); + } +} + +button { + background: transparent; + color: var(--bg); + align-self: center; + justify-self: center; + border: thin solid transparent; + border-radius: var(--radius-sm); + --size: var(--icon-size-md); + width: var(--size); + height: var(--size); + display: flex; + justify-content: center; + align-items: center; + padding: 0; + + &:focus &:active &:hover { + box-shadow: none; + } +} + +/* Close button icon via CSS - X character */ +button.close::before { + content: "ร—"; + font-size: 1.25rem; + line-height: 1; +} + +/* Toast type indicators via CSS pseudo-elements - Lucide icons */ +output.error p::before { + content: ""; + width: var(--icon-size-sm); + height: var(--icon-size-sm); + display: inline-block; + background-size: contain; + background-repeat: no-repeat; + background-position: center; + margin-right: var(--spacing-sm); + flex-shrink: 0; + background-image: var(--icon-error); +} + +output.warning p::before { + content: ""; + width: var(--icon-size-sm); + height: var(--icon-size-sm); + display: inline-block; + background-size: contain; + background-repeat: no-repeat; + background-position: center; + margin-right: var(--spacing-sm); + flex-shrink: 0; + background-image: var(--icon-warning); +} + +output.info p::before { + content: ""; + width: var(--icon-size-sm); + height: var(--icon-size-sm); + display: inline-block; + background-size: contain; + background-repeat: no-repeat; + background-position: center; + margin-right: var(--spacing-sm); + flex-shrink: 0; + background-image: var(--icon-info); +} + +output.success p::before { + content: ""; + width: var(--icon-size-sm); + height: var(--icon-size-sm); + display: inline-block; + background-size: contain; + background-repeat: no-repeat; + background-position: center; + margin-right: var(--spacing-sm); + flex-shrink: 0; + background-image: var(--icon-success); +} + +@keyframes fade-in { + from { + opacity: 0; + } +} + +@keyframes fade-out { + to { + opacity: 0; + } +} + +@keyframes slide-in { + from { + transform: translateY(var(--travel-distance 10px)); + } +} diff --git a/toboggan-web/toboggan-wasm/src/config.rs b/toboggan-web/toboggan-wasm/src/config.rs new file mode 100644 index 0000000..f9a7b88 --- /dev/null +++ b/toboggan-web/toboggan-wasm/src/config.rs @@ -0,0 +1,85 @@ +use gloo::utils::document; +use toboggan_core::ClientId; +use wasm_bindgen::prelude::*; + +use crate::KeyboardMapping; + +#[wasm_bindgen] +#[derive(Debug, Clone)] +pub struct WebSocketConfig { + pub(crate) url: String, + pub max_retries: usize, + pub initial_retry_delay: usize, + pub max_retry_delay: usize, +} + +#[wasm_bindgen] +impl WebSocketConfig { + #[wasm_bindgen(constructor)] + #[must_use] + pub fn new(url: String) -> Self { + Self { + url, + max_retries: 5, + initial_retry_delay: 1_000, + max_retry_delay: 30_000, + } + } +} + +#[wasm_bindgen] +#[derive(Debug, Clone)] +pub struct AppConfig { + pub(crate) client_id: ClientId, + pub(crate) api_base_url: String, + pub(crate) websocket: WebSocketConfig, + pub(crate) keymap: Option, +} + +impl Default for AppConfig { + fn default() -> Self { + Self::new() + } +} + +#[wasm_bindgen] +impl AppConfig { + #[wasm_bindgen(constructor)] + #[must_use] + pub fn new() -> Self { + let client_id = ClientId::new(); + let location = document().location().unwrap_throw(); + let api_base_url = location.origin().unwrap_throw(); + let ws_url = format!("ws://{}/api/ws", location.host().unwrap_throw()); + let websocket = WebSocketConfig::new(ws_url); + + Self { + client_id, + api_base_url, + websocket, + keymap: None, + } + } + + #[wasm_bindgen(setter)] + pub fn set_api_base_url(&mut self, url: String) { + self.api_base_url = url; + } + + #[wasm_bindgen(getter)] + #[must_use] + pub fn api_base_url(&self) -> String { + self.api_base_url.clone() + } + + #[wasm_bindgen(setter)] + pub fn set_websocket(&mut self, websocket: WebSocketConfig) { + self.websocket = websocket; + } + + #[wasm_bindgen(getter)] + #[must_use] + pub fn websocket(&self) -> WebSocketConfig { + self.websocket.clone() + } +} diff --git a/toboggan-web/toboggan-wasm/src/lib.rs b/toboggan-web/toboggan-wasm/src/lib.rs new file mode 100644 index 0000000..9d54b4b --- /dev/null +++ b/toboggan-web/toboggan-wasm/src/lib.rs @@ -0,0 +1,36 @@ +use gloo::console::{debug, info}; +use wasm_bindgen::prelude::*; +use web_sys::HtmlElement; + +mod services; +pub(crate) use self::services::{ + CommunicationMessage, CommunicationService, ConnectionStatus, KeyboardMapping, KeyboardService, + TobogganApi, +}; + +mod app; +pub(crate) use crate::app::Action; + +mod components; +use crate::app::App; +pub(crate) use crate::components::{ + ToastType, TobogganFooterElement, TobogganSlideElement, + TobogganToastElement, WasmElement, +}; + +mod config; +pub use crate::config::*; + +#[macro_use] +mod utils; +pub use crate::utils::*; + +#[wasm_bindgen] +pub fn start_app(config: AppConfig, elt: &HtmlElement) { + console_error_panic_hook::set_once(); + info!("๐Ÿš€ Staring toboggan-wasm application"); + debug!("๐ŸŽ›๏ธ Configuration\n", format!("{config:#?}")); + + let mut app = App::new(config); + app.render(elt); +} diff --git a/toboggan-web/toboggan-wasm/src/services/api.rs b/toboggan-web/toboggan-wasm/src/services/api.rs new file mode 100644 index 0000000..ec1520f --- /dev/null +++ b/toboggan-web/toboggan-wasm/src/services/api.rs @@ -0,0 +1,44 @@ +use gloo::net::Error; +use gloo::net::http::Request; +use serde::de::DeserializeOwned; +use toboggan_core::{Slide, TalkResponse}; + +/// Client for interacting with the Toboggan API +#[derive(Debug, Clone)] +pub struct TobogganApi { + api_base_url: String, +} + +impl TobogganApi { + /// Creates a new API client with the given base URL + #[must_use] + pub fn new(api_base_url: &str) -> Self { + Self { + api_base_url: api_base_url.trim_end_matches('/').to_string(), + } + } + + /// Makes a GET request to the specified path and deserializes the response + async fn get(&self, path: &str) -> Result + where + T: DeserializeOwned, + { + let url = format!("{}/{}", self.api_base_url, path.trim_start_matches('/')); + Request::get(&url).send().await?.json().await + } + + /// Fetches the current talk + pub async fn get_talk(&self) -> Result { + self.get("api/talk?footer=true&head=true").await + } + + // /// Fetches all slides + // pub async fn get_slides(&self) -> Result, Error> { + // self.get("api/slides").await + // } + + /// Fetches a specific slide by index + pub async fn get_slide(&self, index: usize) -> Result { + self.get(&format!("api/slides/{index}")).await + } +} diff --git a/toboggan-web/toboggan-wasm/src/services/communication.rs b/toboggan-web/toboggan-wasm/src/services/communication.rs new file mode 100644 index 0000000..b229790 --- /dev/null +++ b/toboggan-web/toboggan-wasm/src/services/communication.rs @@ -0,0 +1,258 @@ +use std::time::Duration; + +use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender}; +use futures::{SinkExt, StreamExt}; +use gloo::console::{debug, error, info}; +use gloo::net::websocket::Message; +use gloo::net::websocket::futures::WebSocket; +use gloo::timers::callback::{Interval, Timeout}; +use js_sys::JSON; +use toboggan_core::{ClientId, Command, Notification}; +use wasm_bindgen::UnwrapThrowExt; +use wasm_bindgen_futures::spawn_local; + +use crate::config::WebSocketConfig; +use crate::play_chime; +use crate::services::{CommunicationMessage, ConnectionStatus}; +use crate::utils::Timer; + +const PING_INTERVAL_MS: u32 = 60_000; // 1 minute + +pub struct CommunicationService { + client_id: ClientId, + tx_msg: UnboundedSender, + tx_cmd: UnboundedSender, + rx_cmd: Option>, + config: WebSocketConfig, + retry_count: usize, + retry_delay: u32, + ping_interval: Option, +} + +impl CommunicationService { + pub fn new( + client_id: ClientId, + config: WebSocketConfig, + tx_msg: UnboundedSender, + tx_cmd: UnboundedSender, + rx_cmd: UnboundedReceiver, + ) -> Self { + let retry_delay = config.initial_retry_delay.try_into().unwrap_or(1000); + Self { + client_id, + config, + tx_msg, + tx_cmd, + rx_cmd: Some(rx_cmd), + retry_count: 0, + retry_delay, + ping_interval: None, + } + } + + pub fn connect(&mut self) { + self.send_status(ConnectionStatus::Connecting); + + let ws = match WebSocket::open(&self.config.url) { + Ok(ws) => ws, + Err(err) => { + error!("Failed to open WebSocket:", err.to_string()); + self.send_status(ConnectionStatus::Error { + message: err.to_string(), + }); + self.schedule_reconnect(); + return; + } + }; + + let (write, read) = ws.split(); + + // Reset retry state on successful connection + self.retry_count = 0; + self.retry_delay = self.config.initial_retry_delay.try_into().unwrap_or(1000); + + self.start_pinging(); + self.send_status(ConnectionStatus::Connected); + + // Handle outgoing messages + if let Some(rx_cmd) = self.rx_cmd.take() { + spawn_local(handle_outgoing_commands(rx_cmd, write)); + } + + // Handle incoming messages + let tx_msg = self.tx_msg.clone(); + let client_id = self.client_id; + let config = self.config.clone(); + spawn_local(async move { + handle_incoming_messages(read, tx_msg, client_id, config).await; + }); + } + + fn send_status(&self, status: ConnectionStatus) { + debug!("Connection status:", status.to_string()); + let _ = self + .tx_msg + .unbounded_send(CommunicationMessage::ConnectionStatusChange { status }); + } + + fn schedule_reconnect(&mut self) { + if self.retry_count >= self.config.max_retries { + self.send_status(ConnectionStatus::Error { + message: format!("Max retries ({}) reached", self.config.max_retries), + }); + return; + } + + self.retry_count += 1; + let delay = self.retry_delay; + + // Exponential backoff + self.retry_delay = + (self.retry_delay * 2).min(self.config.max_retry_delay.try_into().unwrap_or(30_000)); + + self.send_status(ConnectionStatus::Reconnecting { + attempt: self.retry_count, + max_attempt: self.config.max_retries, + delay: Duration::from_millis(delay.into()), + }); + + let mut service = Self::new( + self.client_id, + self.config.clone(), + self.tx_msg.clone(), + self.tx_cmd.clone(), + // Create a new receiver channel for reconnection + futures::channel::mpsc::unbounded().1, + ); + + Timeout::new(delay, move || { + info!("Attempting reconnection..."); + service.connect(); + }) + .forget(); + } + + fn start_pinging(&mut self) { + self.stop_pinging(); + + let tx_cmd = self.tx_cmd.clone(); + let interval = Interval::new(PING_INTERVAL_MS, move || { + let _timer = Timer::new("ping-latency"); + let _ = tx_cmd.unbounded_send(Command::Ping); + }); + + self.ping_interval = Some(interval); + } + + fn stop_pinging(&mut self) { + if let Some(interval) = self.ping_interval.take() { + interval.cancel(); + } + } +} + +impl Drop for CommunicationService { + fn drop(&mut self) { + self.stop_pinging(); + } +} + +async fn handle_outgoing_commands( + mut rx_cmd: UnboundedReceiver, + mut write: futures::stream::SplitSink, +) { + while let Some(cmd) = rx_cmd.next().await { + let json = serde_wasm_bindgen::to_value(&cmd).unwrap_throw(); + let json_str = JSON::stringify(&json) + .unwrap_throw() + .as_string() + .unwrap_or_default(); + + if write.send(Message::Text(json_str)).await.is_err() { + error!("Failed to send command"); + break; + } + } +} + +async fn handle_incoming_messages( + mut read: futures::stream::SplitStream, + tx_msg: UnboundedSender, + client_id: ClientId, + config: WebSocketConfig, +) { + while let Some(msg) = read.next().await { + match msg { + Ok(msg) => process_message(msg, &tx_msg), + Err(err) => { + error!("WebSocket error:", err.to_string()); + tx_msg + .unbounded_send(CommunicationMessage::ConnectionStatusChange { + status: ConnectionStatus::Error { + message: err.to_string(), + }, + }) + .ok(); + break; + } + } + } + + // Connection closed + tx_msg + .unbounded_send(CommunicationMessage::ConnectionStatusChange { + status: ConnectionStatus::Closed, + }) + .ok(); + + // Schedule reconnection + let mut service = CommunicationService::new( + client_id, + config, + tx_msg, + futures::channel::mpsc::unbounded().0, + futures::channel::mpsc::unbounded().1, + ); + service.schedule_reconnect(); +} + +fn process_message(message: Message, tx: &UnboundedSender) { + let text = match message { + Message::Text(txt) => txt, + Message::Bytes(bytes) => String::from_utf8_lossy(&bytes).to_string(), + }; + + let json = match JSON::parse(&text) { + Ok(json) => json, + Err(err) => { + error!("Failed to parse message:", err); + return; + } + }; + + let notification = match serde_wasm_bindgen::from_value::(json) { + Ok(n) => n, + Err(err) => { + error!("Failed to deserialize notification:", err.to_string()); + return; + } + }; + + match notification { + Notification::State { state } => { + let _ = tx.unbounded_send(CommunicationMessage::StateChange { state }); + } + Notification::TalkChange { state } => { + let _ = tx.unbounded_send(CommunicationMessage::TalkChange { state }); + } + Notification::Error { message } => { + let _ = tx.unbounded_send(CommunicationMessage::Error { error: message }); + } + Notification::Pong => { + // Ping response received - timer will be dropped automatically + } + Notification::Blink => { + play_chime(); + } + } +} diff --git a/toboggan-web/toboggan-wasm/src/services/keyboard.rs b/toboggan-web/toboggan-wasm/src/services/keyboard.rs new file mode 100644 index 0000000..a2ecf8a --- /dev/null +++ b/toboggan-web/toboggan-wasm/src/services/keyboard.rs @@ -0,0 +1,73 @@ +use std::collections::HashMap; + +use futures::channel::mpsc::UnboundedSender; +use gloo::console::{debug, error, info}; +use gloo::events::EventListener; +use gloo::utils::window; +use toboggan_core::Command; +use wasm_bindgen::JsCast; +use web_sys::KeyboardEvent; + +use crate::Action; + +#[derive(Debug, Clone)] +pub struct KeyboardMapping(HashMap<&'static str, Action>); + +impl Default for KeyboardMapping { + fn default() -> Self { + let mapping = HashMap::from([ + ("ArrowLeft", Action::Command(Command::Previous)), + ("ArrowUp", Action::PreviousStep), + ("ArrowRight", Action::Command(Command::Next)), + ("ArrowDown", Action::NextStep), + (" ", Action::NextStep), + ("Home", Action::Command(Command::First)), + ("End", Action::Command(Command::Last)), + ("p", Action::Command(Command::Pause)), + ("P", Action::Command(Command::Pause)), + ("r", Action::Command(Command::Resume)), + ("R", Action::Command(Command::Resume)), + ("b", Action::Command(Command::Blink)), + ("B", Action::Command(Command::Blink)), + ]); + Self(mapping) + } +} + +impl KeyboardMapping { + pub fn get(&self, key: &str) -> Option { + self.0.get(key).cloned() + } +} + +pub struct KeyboardService { + tx: UnboundedSender, + mapping: KeyboardMapping, +} + +impl KeyboardService { + pub fn new(tx: UnboundedSender, mapping: KeyboardMapping) -> Self { + Self { tx, mapping } + } + + pub fn start(&mut self) { + let tx = self.tx.clone(); + let mapping = self.mapping.clone(); + + let listener = EventListener::new(&window(), "keydown", move |event| { + if let Some(keyboard_event) = event.dyn_ref::() { + let key = keyboard_event.key(); + if let Some(action) = mapping.get(&key) { + if tx.unbounded_send(action).is_err() { + error!("Failed to send keyboard action"); + } + } else { + debug!("No mapping for key:", &key); + } + } + }); + + listener.forget(); + info!("โŒจ๏ธ Keyboard service started"); + } +} diff --git a/toboggan-web/toboggan-wasm/src/services/mod.rs b/toboggan-web/toboggan-wasm/src/services/mod.rs new file mode 100644 index 0000000..997d624 --- /dev/null +++ b/toboggan-web/toboggan-wasm/src/services/mod.rs @@ -0,0 +1,56 @@ +use std::fmt::Display; +use std::time::Duration; + +use toboggan_core::State; + +mod api; +pub use self::api::TobogganApi; + +mod communication; +pub use self::communication::CommunicationService; + +mod keyboard; +pub use self::keyboard::*; + +#[derive(Debug, Clone)] +pub enum ConnectionStatus { + Connecting, + Connected, + Closed, + Reconnecting { + attempt: usize, + max_attempt: usize, + delay: Duration, + }, + Error { + message: String, + }, +} + +impl Display for ConnectionStatus { + fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Connecting => write!(fmt, "๐Ÿ“ก Connecting..."), + Self::Connected => write!(fmt, "๐Ÿ›œ Connected"), + Self::Closed => write!(fmt, "๐Ÿšช Closed"), + Self::Error { message } => write!(fmt, "๐Ÿ’ฅ Error: {message}"), + Self::Reconnecting { + attempt, + max_attempt, + delay, + } => write!( + fmt, + "โ›“๏ธโ€๐Ÿ’ฅ Reconnecting in {}s {attempt}/{max_attempt}", + delay.as_secs() + ), + } + } +} + +#[derive(Debug, Clone)] +pub enum CommunicationMessage { + ConnectionStatusChange { status: ConnectionStatus }, + StateChange { state: State }, + TalkChange { state: State }, + Error { error: String }, +} diff --git a/toboggan-web/toboggan-wasm/src/utils/audio.rs b/toboggan-web/toboggan-wasm/src/utils/audio.rs new file mode 100644 index 0000000..13cd918 --- /dev/null +++ b/toboggan-web/toboggan-wasm/src/utils/audio.rs @@ -0,0 +1,114 @@ +use wasm_bindgen::UnwrapThrowExt; +use web_sys::{AudioContext, GainNode, OscillatorNode, OscillatorType}; + +const G5: f32 = 784.0; +const C5: f32 = 523.25; +const C4: f32 = 261.63; +const E4: f32 = 329.63; +const G4: f32 = 392.0; + +fn create_oscillator( + context: &AudioContext, + frequency: f32, + start_time: f64, + duration: f64, + volume: f32, +) -> Option<(OscillatorNode, GainNode)> { + let oscillator = context.create_oscillator().ok()?; + let gain = context.create_gain().ok()?; + + oscillator.connect_with_audio_node(&gain).ok(); + oscillator.set_type(OscillatorType::Sine); + + oscillator + .frequency() + .set_value_at_time(frequency, start_time) + .ok()?; + + let gain_param = gain.gain(); + gain_param.set_value_at_time(0.0, start_time).ok()?; + gain_param + .linear_ramp_to_value_at_time(volume, start_time + 0.02) + .ok()?; + gain_param + .exponential_ramp_to_value_at_time(0.01, start_time + duration) + .ok()?; + + oscillator.start_with_when(start_time).ok()?; + oscillator.stop_with_when(start_time + duration).ok()?; + + Some((oscillator, gain)) +} + +pub fn play_chime() { + let Ok(context) = AudioContext::new() else { + return; + }; + let Ok(oscillator) = context.create_oscillator() else { + return; + }; + let Ok(gain) = context.create_gain() else { + return; + }; + + oscillator.connect_with_audio_node(&gain).unwrap_throw(); + gain.connect_with_audio_node(&context.destination()) + .unwrap_throw(); + + let now = context.current_time(); + + oscillator + .frequency() + .set_value_at_time(G5, now) + .unwrap_throw(); + oscillator + .frequency() + .set_value_at_time(C5, now + 0.1) + .unwrap_throw(); + + gain.gain().set_value_at_time(0.3, now).unwrap_throw(); + gain.gain() + .exponential_ramp_to_value_at_time(0.01, now + 0.5) + .unwrap_throw(); + + oscillator.start_with_when(now).unwrap_throw(); + oscillator.stop_with_when(now + 0.5).unwrap_throw(); +} + +pub fn play_tada() { + let Ok(context) = AudioContext::new() else { + return; + }; + let Ok(master_gain) = context.create_gain() else { + return; + }; + + master_gain + .connect_with_audio_node(&context.destination()) + .unwrap_throw(); + let now = context.current_time(); + master_gain + .gain() + .set_value_at_time(0.3, now) + .unwrap_throw(); + + let notes = [ + (C4, 0.0, 0.15), + (E4, 0.1, 0.15), + (G4, 0.2, 0.2), + (C5, 0.3, 0.4), + ]; + + for (freq, start, duration) in notes { + if let Some((_, gain)) = create_oscillator(&context, freq, now + start, duration, 0.4) { + gain.connect_with_audio_node(&master_gain).unwrap_throw(); + } + } + + if let Some((harmonic, harmonic_gain)) = create_oscillator(&context, G5, now + 0.3, 0.6, 0.2) { + harmonic.set_type(OscillatorType::Triangle); + harmonic_gain + .connect_with_audio_node(&master_gain) + .unwrap_throw(); + } +} diff --git a/toboggan-web/toboggan-wasm/src/utils/components.rs b/toboggan-web/toboggan-wasm/src/utils/components.rs new file mode 100644 index 0000000..eeaff25 --- /dev/null +++ b/toboggan-web/toboggan-wasm/src/utils/components.rs @@ -0,0 +1,30 @@ +use gloo::utils::document; +use wasm_bindgen::prelude::*; +use web_sys::{HtmlElement, Node, ShadowRoot, ShadowRootInit, ShadowRootMode}; + +pub(crate) fn create_shadow_root_with_style( + host: &HtmlElement, + css: &str, +) -> Result { + let shadow_mode = ShadowRootInit::new(ShadowRootMode::Open); + let root = host.attach_shadow(&shadow_mode)?; + + let style_el = document().create_element("style")?; + style_el.set_text_content(Some(css)); + root.append_child(&style_el)?; + + Ok(root) +} + +pub(crate) fn create_and_append_element(parent: &Node, tag: &str) -> Result +where + T: JsCast + Clone, +{ + let element = document().create_element(tag)?; + parent.append_child(&element)?; + Ok(element.dyn_into::()?) +} + +pub trait StateClassMapper { + fn to_css_class(&self) -> &'static str; +} diff --git a/toboggan-web/toboggan-wasm/src/utils/dom.rs b/toboggan-web/toboggan-wasm/src/utils/dom.rs new file mode 100644 index 0000000..2033284 --- /dev/null +++ b/toboggan-web/toboggan-wasm/src/utils/dom.rs @@ -0,0 +1,92 @@ +use gloo::console::error; +use gloo::utils::document; +use toboggan_core::{Content, Style}; +use wasm_bindgen::prelude::*; +use web_sys::{Element, HtmlElement}; + +fn escape_html(html: &str) -> String { + let div = document().create_element("div").unwrap_throw(); + div.set_text_content(Some(html)); + div.inner_html() +} + +#[must_use] +pub fn create_html_element(tag: &str) -> HtmlElement { + let result = document().create_element(tag).unwrap_throw(); + result.dyn_into().unwrap_throw() +} + +#[must_use] +pub fn render_content(content: &Content, wrapper: Option<&str>) -> String { + let inner = match content { + Content::Empty => String::new(), + Content::Text { text } => escape_html(text), + Content::Html { raw, .. } => raw.clone(), + Content::Grid { style, cells } => { + let Style { classes, .. } = style; + let classes = classes.join(" "); + let body = cells + .iter() + .map(|content| render_content(content, None)) + .collect::(); + format!(r#"
{body}
"#) + } + }; + + if let Some(wrapper) = wrapper { + format!("<{wrapper}>{inner}",) + } else { + inner + } +} + +pub fn apply_slide_styles(container: &Element, style: &Style) { + // Apply CSS classes + if style.classes.is_empty() { + container.set_class_name(""); + } else { + let classes = style.classes.join(" "); + container.set_class_name(&classes); + } +} + +/// Injects custom head HTML into document.head +/// Removes any previously injected elements and adds new ones with data-toboggan-head marker +pub fn inject_head_html(head_html: Option<&str>) { + let Some(head) = document().head() else { + error!("Could not get document head"); + return; + }; + + // Remove previously injected elements + let selector = "[data-toboggan-head]"; + if let Ok(existing) = head.query_selector_all(selector) { + for i in 0..existing.length() { + if let Some(node) = existing.get(i) { + let _ = head.remove_child(&node); + } + } + } + + // If no new head HTML, we're done + let Some(html) = head_html else { + return; + }; + + // Create temporary container to parse HTML + let temp = document().create_element("div").unwrap_throw(); + temp.set_inner_html(html); + + // Move each child to document.head with marker attribute + while let Some(child) = temp.first_child() { + // Add marker attribute if it's an element + if let Some(element) = child.dyn_ref::() { + let _ = element.set_attribute("data-toboggan-head", "true"); + } + + // Move to head + if head.append_child(&child).is_err() { + error!("Failed to append element to head"); + } + } +} diff --git a/toboggan-web/toboggan-wasm/src/utils/errors.rs b/toboggan-web/toboggan-wasm/src/utils/errors.rs new file mode 100644 index 0000000..8869395 --- /dev/null +++ b/toboggan-web/toboggan-wasm/src/utils/errors.rs @@ -0,0 +1,35 @@ +use gloo::console::error; +use wasm_bindgen::JsValue; + +/// Log DOM errors without panicking +pub fn log_dom_error(operation: &str, error: &JsValue) { + let error_msg = error + .as_string() + .unwrap_or_else(|| "Unknown error".to_string()); + error!("DOM operation failed:", operation, "Error:", error_msg); +} + +/// Simplified macro for DOM operations with error logging +#[macro_export] +macro_rules! dom_try { + ($operation:expr, $op_name:expr) => { + match $operation { + Ok(val) => val, + Err(err) => { + $crate::utils::errors::log_dom_error($op_name, &err); + return; + } + } + }; +} + +/// Simplified macro for safe Option unwrapping +#[macro_export] +macro_rules! unwrap_or_return { + ($option:expr) => { + match $option { + Some(val) => val, + None => return, + } + }; +} diff --git a/toboggan-web/toboggan-wasm/src/utils/mod.rs b/toboggan-web/toboggan-wasm/src/utils/mod.rs new file mode 100644 index 0000000..ffcbd6a --- /dev/null +++ b/toboggan-web/toboggan-wasm/src/utils/mod.rs @@ -0,0 +1,16 @@ +mod audio; +pub use self::audio::*; + +mod components; +pub use self::components::*; + +mod dom; +pub use self::dom::*; + +pub mod errors; + +mod render; +pub use self::render::*; + +mod timer; +pub use self::timer::*; diff --git a/toboggan-web/toboggan-wasm/src/utils/render.rs b/toboggan-web/toboggan-wasm/src/utils/render.rs new file mode 100644 index 0000000..fe5c3b2 --- /dev/null +++ b/toboggan-web/toboggan-wasm/src/utils/render.rs @@ -0,0 +1,45 @@ +use toboggan_core::State; + +use crate::{ConnectionStatus, StateClassMapper}; + +/// Maps State enum to CSS class names +impl StateClassMapper for State { + fn to_css_class(&self) -> &'static str { + match self { + State::Init => "init", + State::Paused { .. } => "paused", + State::Running { .. } => "running", + State::Done { .. } => "done", + } + } +} + +/// Maps `ConnectionStatus` enum to CSS class names +impl StateClassMapper for ConnectionStatus { + fn to_css_class(&self) -> &'static str { + match self { + ConnectionStatus::Connecting => "connecting", + ConnectionStatus::Connected => "connected", + ConnectionStatus::Closed => "closed", + ConnectionStatus::Reconnecting { .. } => "reconnecting", + ConnectionStatus::Error { .. } => "error", + } + } +} + +/// Format slide information as "current/total" string +#[must_use] +pub fn format_slide_info(current: Option, total: Option) -> String { + let current_str = current.map_or_else(|| "-".to_string(), |current| (current + 1).to_string()); + let total_str = total.map_or_else(|| "-".to_string(), |total| total.to_string()); + format!("{current_str}/{total_str}") +} + +/// Format duration as HH:MM:SS string +#[must_use] +pub fn format_duration(seconds: u64) -> String { + let hours = seconds / 3600; + let minutes = (seconds % 3600) / 60; + let secs = seconds % 60; + format!("{hours:02}:{minutes:02}:{secs:02}") +} diff --git a/toboggan-web/toboggan-wasm/src/utils/timer.rs b/toboggan-web/toboggan-wasm/src/utils/timer.rs new file mode 100644 index 0000000..7bc9815 --- /dev/null +++ b/toboggan-web/toboggan-wasm/src/utils/timer.rs @@ -0,0 +1,18 @@ +/// RAII timer that automatically measures performance +pub struct Timer { + label: &'static str, +} + +impl Timer { + #[must_use] + pub fn new(label: &'static str) -> Self { + web_sys::console::time_with_label(label); + Self { label } + } +} + +impl Drop for Timer { + fn drop(&mut self) { + web_sys::console::time_end_with_label(self.label); + } +} diff --git a/toboggan-web/tsconfig.json b/toboggan-web/tsconfig.json new file mode 100644 index 0000000..53b05a0 --- /dev/null +++ b/toboggan-web/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable", "ESNext.Intl"], + "module": "ESNext", + "skipLibCheck": true, + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noImplicitOverride": true, + "exactOptionalPropertyTypes": true + }, + "include": ["src"], + "typeRoots": ["node_modules/@types", "src/types"] +} diff --git a/toboggan-web/vite.config.ts b/toboggan-web/vite.config.ts new file mode 100644 index 0000000..7ef7453 --- /dev/null +++ b/toboggan-web/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + root: ".", + publicDir: "assets", + build: { + outDir: "dist", + rollupOptions: { + input: { + main: "./index.html", + }, + }, + }, + server: { + port: 8000, + proxy: { + "/public": "http://localhost:8080", + }, + }, +});