From d17d3f4860fefcb4755e51873ccfc948b87417fe Mon Sep 17 00:00:00 2001 From: Jeremy Date: Mon, 26 Oct 2026 22:18:44 +0000 Subject: [PATCH 01/33] feat(installer): default to a per-user ArtCraft folder --- .github/workflows/packaging-lint.yml | 19 ++ packaging/windows/INSTALLATION.md | 35 ++++ packaging/windows/check-install-policy.ps1 | 195 +++++++++++++++++++++ packaging/windows/figcraft.wxs | 87 +++------ packaging/windows/install-policy.json | 126 +++++++++++++ packaging/windows/install-policy.wxi | 54 ++++++ packaging/windows/package.ps1 | 2 + packaging/windows/test-install-policy.ps1 | 54 ++++++ 8 files changed, 514 insertions(+), 58 deletions(-) create mode 100644 packaging/windows/INSTALLATION.md create mode 100644 packaging/windows/check-install-policy.ps1 create mode 100644 packaging/windows/install-policy.json create mode 100644 packaging/windows/install-policy.wxi create mode 100644 packaging/windows/test-install-policy.ps1 diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 38698902..c1394c40 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -23,3 +23,22 @@ jobs: uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main with: extra-checks-script: packaging/check-app-specific.sh + + windows-install-policy: + timeout-minutes: 10 + name: Windows installation policy (no app build) + runs-on: windows-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install the pinned packaging toolchain + shell: pwsh + run: | + $tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix' + dotnet tool install --tool-path $tools wix --version 5.0.2 + if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' } + $tools >> $env:GITHUB_PATH + & (Join-Path $tools 'wix.exe') extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2 + if ($LASTEXITCODE -ne 0) { throw 'WiX extension installation failed.' } + - name: Build and check non-executable installer fixtures + shell: pwsh + run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests') diff --git a/packaging/windows/INSTALLATION.md b/packaging/windows/INSTALLATION.md new file mode 100644 index 00000000..982a5b42 --- /dev/null +++ b/packaging/windows/INSTALLATION.md @@ -0,0 +1,35 @@ +# Windows installation policy checks + +New installations default to the current account under `Programs\ArtCraft\`. +The first setup page offers installation for everyone. Scope is selected before duplicate +checks and directory costing. An update uses the existing context's remembered exact folder; +`INSTALLFOLDER` supplied explicitly is never replaced. It is a secure MSI property so the +chosen folder reaches the elevated installer. Silent machine updates pass `ALLUSERS=1`. +A cross-context copy is refused; changing context means uninstalling the old copy first. + +App registration follows the selected context. Existing optional shortcuts and file-handler +choices stay app-owned. No new cleanup operation or model/settings migration is introduced. +Packaged resources remain package-owned; separately downloaded shared models are not caches +and are outside this installer's ownership. + +## Repeatable metadata tests + +On Windows with WiX 5.0.2 and its matching UI/Util extensions: + +```powershell +./packaging/windows/test-install-policy.ps1 -OutputDirectory ./target/install-policy-tests +``` + +The script builds x64, x86 and arm64 packages with deliberately non-executable payloads. It +uses the actual package source and compiled database. A safe Windows Installer session checks +24 scope/registration/maintenance cases, remembered and explicit custom folders, sequencing, +upgrade identity and the original registration inventory recorded in `install-policy.json`. +The release packaging script runs the compiled checker before signing. Packaging CI runs the +small fixture builds without compiling the Rust application. + +Never install or distribute the `NOT-INSTALLABLE.msi` fixtures as an app. Passing these checks +does not prove real installation, elevation, legacy-version upgrades, registry writes, optional +shortcut/menu behaviour, cancellation, rollback or uninstall. Those still require the real +payload in an authorised clean Windows test environment. Cross-architecture metadata is not +execution evidence on those architectures. Existing open installer/association PRs must be +reconciled before publication. Nothing here changes macOS/Linux native packaging conventions. diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 new file mode 100644 index 00000000..dd289564 --- /dev/null +++ b/packaging/windows/check-install-policy.ps1 @@ -0,0 +1,195 @@ +<# +.SYNOPSIS + Check the compiled installation policy without installing any product. +.DESCRIPTION + Uses Windows Installer's safe session (OpenPackage option 1). Only property-setting custom + actions are exercised. No AppSearch, install, repair, removal or file-writing action runs. +#> +param([Parameter(Mandatory = $true)] [string] $MsiPath) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$policy = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'install-policy.json') | ConvertFrom-Json +function Read-Rows($Database, [string] $Sql, [int] $Columns) { + $view = $Database.OpenView($Sql) + try { + [void]$view.Execute() + while ($null -ne ($record = $view.Fetch())) { + try { + $values = @(for ($i = 1; $i -le $Columns; $i++) { + $record.GetType().InvokeMember('StringData', 'GetProperty', $null, $record, @($i)) + }) + ,$values + } finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($record) } + } + } finally { + [void]$view.Close() + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($view) + } +} +function Set-SessionProperty($Session, [string] $Name, [string] $Value) { + [void]$Session.GetType().InvokeMember('Property', 'SetProperty', $null, $Session, @($Name, $Value)) +} +function Get-SessionProperty($Session, [string] $Name) { + $Session.GetType().InvokeMember('Property', 'GetProperty', $null, $Session, @($Name)) +} +$installer = $null; $database = $null; $session = $null +try { + $installer = New-Object -ComObject WindowsInstaller.Installer + $file = (Resolve-Path -LiteralPath $MsiPath).Path + $database = $installer.OpenDatabase($file, 0) + $session = $installer.OpenPackage($file, 1) + $properties = @{} + foreach ($row in (Read-Rows $database 'SELECT `Property`, `Value` FROM `Property`' 2)) { $properties[$row[0]] = $row[1] } + $secureProperties = @($properties['SecureCustomProperties'] -split ';') + if ('INSTALLFOLDER' -notin $secureProperties) { + throw 'The selected installation folder must survive elevation (secure INSTALLFOLDER).' + } + if ($policy.mode -eq 'perUser') { + if ($properties.ContainsKey('ALLUSERS')) { throw 'Launcher must remain per-user; machine self-update is not part of this change.' } + if ($properties['UpgradeCode'].Trim('{}') -ne $policy.upgradeCode) { throw 'Launcher upgrade identity changed.' } + $dirs = @{} + foreach ($row in (Read-Rows $database 'SELECT `Directory`, `Directory_Parent`, `DefaultDir` FROM `Directory`' 3)) { $dirs[$row[0]] = @($row[1],$row[2]) } + if ($dirs['INSTALLFOLDER'][0] -ne 'CraftSuiteFolder' -or $dirs['CraftSuiteFolder'][1] -ne 'ArtCraft' -or + $dirs['CraftSuiteFolder'][0] -ne 'LocalProgramsFolder' -or $dirs['LocalProgramsFolder'][0] -ne 'LocalAppDataFolder') { + throw 'Launcher new-install folder must be under the per-user ArtCraft suite root.' + } + foreach ($explicit in @('', 'F:\Chosen exact launcher')) { + Set-SessionProperty $session 'PREVIOUSINSTALLFOLDER' 'E:\Existing exact launcher' + Set-SessionProperty $session 'INSTALLFOLDER' $explicit + $row = @(Read-Rows $database 'SELECT `Condition` FROM `InstallUISequence` WHERE `Action` = ''CraftRestoreLauncherFolder''' 1)[0] + if ($session.EvaluateCondition($row[0]) -eq 1) { + if ($session.DoAction('CraftRestoreLauncherFolder') -ne 1) { throw 'Launcher restore action failed.' } + } + $expected = if ($explicit) { $explicit } else { 'E:\Existing exact launcher' } + if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Launcher custom or existing path was lost.' } + } + $rows = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4) + if (@($rows | Where-Object { $_[0] -ne '1' }).Count) { throw 'Launcher registration must stay under the current account.' } + foreach ($entry in $policy.registry) { + if (@($rows | Where-Object { $_[0] -eq $entry[0] -and $_[1] -eq $entry[1] -and $_[2] -eq $entry[2] -and $_[3] -eq $entry[3] }).Count -ne 1) { + throw 'Launcher registration or shortcut preference changed unexpectedly.' + } + } + Write-Output 'PASS ArtCraft Launcher: per-user scope, suite folder, remembered/custom paths and existing registration. No installation performed.' + return + } + if ($properties['ALLUSERS'] -ne '2' -or $properties['MSIINSTALLPERUSER'] -ne '1') { + throw 'Policy regression: new packages must default to the per-user dual-scope context.' + } + if ($properties['UpgradeCode'].Trim('{}') -ne $policy.upgradeCode) { throw 'The existing upgrade identity must not change.' } + $directories = @{} + foreach ($row in (Read-Rows $database 'SELECT `Directory`, `Directory_Parent`, `DefaultDir` FROM `Directory`' 3)) { + $directories[$row[0]] = @($row[1], $row[2]) + } + if ($directories['INSTALLFOLDER'][0] -ne 'CraftSuiteFolder' -or + $directories['CraftSuiteFolder'][1] -ne 'ArtCraft') { throw 'Expected the app under the ArtCraft suite folder.' } + $programs = $directories['CraftSuiteFolder'][0] + if ($programs -eq 'ProgramFiles6432Folder') { $programs = $directories[$programs][0] } + if ($programs -notin 'ProgramFilesFolder', 'ProgramFiles64Folder') { + throw 'Use Windows Installer native folder redirection, not a hard-coded profile path.' + } + $guards = @(Read-Rows $database 'SELECT `Condition`, `Description` FROM `LaunchCondition`' 2 | + Where-Object { $_[0] -match 'CRAFT_(USER|MACHINE)_DIR' }) + if ($guards.Count -ne 2) { throw 'Expected both duplicate-installation guards.' } + $cases = 0 + foreach ($scope in @('', '2', '1')) { + foreach ($user in @('', 'D:\Existing user app')) { + foreach ($machine in @('', 'E:\Existing machine app')) { + foreach ($installed in @('', '1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session 'Installed' $installed + Set-SessionProperty $session 'CRAFT_USER_DIR' $user + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' $machine + $allowed = $true + foreach ($guard in $guards) { + $value = $session.EvaluateCondition($guard[0]) + if ($value -notin 0, 1) { throw 'Invalid duplicate guard.' } + if ($value -eq 0) { $allowed = $false } + } + $opposite = if ($scope -eq '1') { $user } else { $machine } + $expected = [bool]$installed -or -not [bool]$opposite + if ($allowed -ne $expected) { throw "Scope regression: ALLUSERS='$scope', user=$([bool]$user), machine=$([bool]$machine), maintenance=$([bool]$installed)." } + $cases++ + } + } + } + } + # Execute the actual authored type-51 actions in a safe session, not copied resolver logic. + foreach ($scope in @('', '2', '1')) { + foreach ($explicit in @('', 'F:\Chosen exact app')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session 'INSTALLFOLDER' $explicit + Set-SessionProperty $session 'CRAFT_USER_DIR' 'D:\Existing user app' + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app' + $action = if ($scope -eq '1') { 'CraftRestoreMachineFolder' } else { 'CraftRestoreUserFolder' } + if (-not $explicit) { + if ($session.DoAction($action) -ne 1) { throw 'Remembered-folder property action failed.' } + } + $expected = if ($explicit) { $explicit } elseif ($scope -eq '1') { 'E:\Existing machine app' } else { 'D:\Existing user app' } + if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Custom or remembered folder was not preserved.' } + } + } + $ui = @{} + foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallUISequence`' 2)) { $ui[$row[0]] = [int]$row[1] } + if (-not ($ui['AppSearch'] -lt $ui['CraftScopeDlg'] -and $ui['CraftScopeDlg'] -lt $ui['LaunchConditions'] -and + $ui['CraftScopeDlg'] -lt $ui['CraftRestoreUserFolder'] -and $ui['CraftRestoreUserFolder'] -lt $ui['CostInitialize'])) { + throw 'Scope must be chosen after discovery and before duplicate checks, folder restoration and costing.' + } + $execute = @{} + foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] } + foreach ($action in @('CraftRestoreUserFolder','CraftRestoreMachineFolder')) { + if (-not ($execute['AppSearch'] -lt $execute[$action] -and $execute[$action] -lt $execute['CostInitialize'])) { + throw 'Silent updates must restore the folder before directory costing.' + } + } + # Switching the first-page choice must reset BOTH properties, including a back-and-forth change. + $events = @(Read-Rows $database 'SELECT `Event`, `Argument`, `Condition`, `Ordering` FROM `ControlEvent` WHERE `Dialog_` = ''CraftScopeDlg'' AND `Control_` = ''Next'' ORDER BY `Ordering`' 4) + foreach ($choice in @('user','machine','user')) { + Set-SessionProperty $session 'CRAFT_SCOPE' $choice + foreach ($event in $events) { + if ($event[0] -notmatch '^\[([^\]]+)\]$') { continue } + $property = $Matches[1] + if ($session.EvaluateCondition($event[2]) -eq 1) { + Set-SessionProperty $session $property $(if ($event[1] -eq '{}') { '' } else { $event[1] }) + } + } + $allUsers = Get-SessionProperty $session 'ALLUSERS' + $perUser = Get-SessionProperty $session 'MSIINSTALLPERUSER' + if (($choice -eq 'machine' -and ($allUsers -ne '1' -or $perUser)) -or + ($choice -eq 'user' -and ($allUsers -ne '2' -or $perUser -ne '1'))) { + throw 'Scope selector does not reset the requested installation context.' + } + } + $actions = @(Read-Rows $database 'SELECT `Action`, `Source`, `Target`, `Type` FROM `CustomAction`' 4) + $savedOptions = @($actions | Where-Object { $_[0] -match '^CraftLoadOption[0-9]+M$' }) + foreach ($option in $savedOptions) { + if ([int]$option[3] -ne 51) { throw 'Saved-option test may run only property-setting actions.' } + $number = [regex]::Match($option[0], '[0-9]+').Value + foreach ($scope in @('2','1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session "CRAFT_OPTION_${number}_M" 'saved-machine-choice' + Set-SessionProperty $session "CRAFT_OPTION_${number}_U" 'saved-user-choice' + Set-SessionProperty $session $option[1] '' + $action = "CraftLoadOption$number" + $(if ($scope -eq '1') { 'M' } else { 'U' }) + if ($session.DoAction($action) -ne 1) { throw 'Saved-option property action failed.' } + $expected = if ($scope -eq '1') { 'saved-machine-choice' } else { 'saved-user-choice' } + if ((Get-SessionProperty $session $option[1]) -ne $expected) { throw 'Saved installer choices leaked across contexts.' } + } + } + $registry = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4) + if (@($registry | Where-Object { $_[0] -in '1', '2' }).Count) { throw 'Registration must follow the selected context, not force HKCU or HKLM.' } + if (@($registry | Where-Object { $_[0] -eq '0' -and $_[1] -match '^Software\\Classes(\\|$)' }).Count) { + throw 'HKCR keys must be relative to Classes.' + } + foreach ($entry in $policy.registry) { + $found = @($registry | Where-Object { $_[0] -eq $entry[0] -and $_[1] -eq $entry[1] -and $_[2] -eq $entry[2] -and $_[3] -eq $entry[3] }) + if ($found.Count -ne 1) { throw "Lost or changed registration: $($entry[1]) / $($entry[2])" } + } + $installDir = @($registry | Where-Object { $_[0] -eq '-1' -and $_[1] -eq "Software\$($policy.name)" -and $_[2] -eq 'InstallDir' -and $_[3] -eq '[INSTALLFOLDER]' }) + if ($installDir.Count -ne 1) { throw 'A context-sensitive installation-folder record is required.' } + Write-Output "PASS $($policy.name): $cases scope cases, folder preservation, action sequencing and registry contract. No installation performed." +} finally { + foreach ($com in @($session, $database, $installer)) { + if ($null -ne $com) { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($com) } + } +} diff --git a/packaging/windows/figcraft.wxs b/packaging/windows/figcraft.wxs index 4cc564f2..905e3a06 100644 --- a/packaging/windows/figcraft.wxs +++ b/packaging/windows/figcraft.wxs @@ -1,39 +1,14 @@ - - + - - + + + - + - - - - + @@ -67,8 +41,7 @@ - + @@ -92,14 +65,12 @@ - + - + @@ -110,27 +81,27 @@ - - - - - + + + + + - - + + - - - - - - - - - - + + + + + + + + + + diff --git a/packaging/windows/install-policy.json b/packaging/windows/install-policy.json new file mode 100644 index 00000000..2b4e01f0 --- /dev/null +++ b/packaging/windows/install-policy.json @@ -0,0 +1,126 @@ +{ + "id": "figcraft", + "name": "FigCraft", + "upgradeCode": "7F8B9827-D065-41B7-A514-2A454B21F484", + "sources": [ + "figcraft.wxs" + ], + "icon": "assets/app-icon/figcraft.ico", + "localization": [ + "figcraft.en-us.wxl" + ], + "extensions": [ + "UI", + "Util" + ], + "payloads": [ + "NOTICE", + "figcraft-cli.exe", + "figcraft.exe" + ], + "registry": [ + [ + "-1", + "Software\\Classes\\Figcraft.Image\\shell\\open\\command", + "", + "\"[#FigcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\Classes\\.svg\\OpenWithProgids", + "Figcraft.Image", + "" + ], + [ + "-1", + "Software\\Classes\\.png\\OpenWithProgids", + "Figcraft.Image", + "" + ], + [ + "-1", + "Software\\Classes\\.jpg\\OpenWithProgids", + "Figcraft.Image", + "" + ], + [ + "-1", + "Software\\Classes\\.jpeg\\OpenWithProgids", + "Figcraft.Image", + "" + ], + [ + "-1", + "Software\\Classes\\Figcraft.Sketch\\shell\\open\\command", + "", + "\"[#FigcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\Classes\\.sketch\\OpenWithProgids", + "Figcraft.Sketch", + "" + ], + [ + "-1", + "Software\\Classes\\Applications\\figcraft.exe", + "FriendlyAppName", + "FigCraft" + ], + [ + "-1", + "Software\\Classes\\Applications\\figcraft.exe\\shell\\open\\command", + "", + "\"[#FigcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\Classes\\Applications\\figcraft.exe\\SupportedTypes", + ".figcraft", + "" + ], + [ + "-1", + "Software\\Classes\\Applications\\figcraft.exe\\SupportedTypes", + ".svg", + "" + ], + [ + "-1", + "Software\\Classes\\Applications\\figcraft.exe\\SupportedTypes", + ".png", + "" + ], + [ + "-1", + "Software\\Classes\\Applications\\figcraft.exe\\SupportedTypes", + ".jpg", + "" + ], + [ + "-1", + "Software\\Classes\\Applications\\figcraft.exe\\SupportedTypes", + ".jpeg", + "" + ], + [ + "-1", + "Software\\Classes\\Applications\\figcraft.exe\\SupportedTypes", + ".sketch", + "" + ], + [ + "-1", + "Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\figcraft.exe", + "", + "[#FigcraftExe]" + ], + [ + "-1", + "Software\\FigCraft", + "InstallDir", + "[INSTALLFOLDER]" + ] + ], + "mode": "dualScope" +} diff --git a/packaging/windows/install-policy.wxi b/packaging/windows/install-policy.wxi new file mode 100644 index 00000000..c7cdb271 --- /dev/null +++ b/packaging/windows/install-policy.wxi @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index dd3a17b8..a4ba751b 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -165,6 +165,8 @@ Invoke-Native 'wix build' { Invoke-Native 'MSI shortcut icon validation (ICE50)' { wix msi validate $Msi -ice ICE50 -intermediateFolder (Join-Path $Stage 'msi-validation') } +# Check the compiled scope, path and registration contract before signing. +& (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $Msi # wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets. Remove-Item -Force -ErrorAction SilentlyContinue ([IO.Path]::ChangeExtension($Msi, '.wixpdb')) & (Join-Path $PSScriptRoot 'sign.ps1') $Msi diff --git a/packaging/windows/test-install-policy.ps1 b/packaging/windows/test-install-policy.ps1 new file mode 100644 index 00000000..21199ca7 --- /dev/null +++ b/packaging/windows/test-install-policy.ps1 @@ -0,0 +1,54 @@ +<# +.SYNOPSIS + Build non-executable installer metadata fixtures; never install them. +#> +param( + [Parameter(Mandatory = $true)] [string] $OutputDirectory, + [ValidateSet('x64','x86','arm64')] [string[]] $Architectures = @('x64','x86','arm64') +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$policy = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'install-policy.json') | ConvertFrom-Json +[void](New-Item -ItemType Directory -Force -Path $OutputDirectory) +$output = (Resolve-Path -LiteralPath $OutputDirectory).Path +Add-Type -AssemblyName System.Drawing +foreach ($arch in $Architectures) { + $stage = Join-Path $output $arch + [void](New-Item -ItemType Directory -Force -Path $stage) + foreach ($name in $policy.payloads) { + $destination = Join-Path $stage $name + [void](New-Item -ItemType Directory -Force -Path ([IO.Path]::GetDirectoryName($destination))) + Set-Content -LiteralPath $destination -Encoding Ascii -Value 'NON-EXECUTABLE MSI METADATA TEST FIXTURE' + } + foreach ($folder in @('docs','models','notices')) { + $path = Join-Path $stage $folder + [void](New-Item -ItemType Directory -Force -Path $path) + Set-Content -LiteralPath (Join-Path $path "$folder-fixture.txt") -Value 'Metadata test fixture, not a user file or model.' + } + foreach ($model in @('metadata-one.rten','metadata-two.rten')) { + Set-Content -LiteralPath (Join-Path (Join-Path $stage 'models') $model) -Encoding Ascii -Value 'NON-EXECUTABLE MODEL METADATA FIXTURE' + } + foreach ($spec in @(@('banner.bmp',493,58),@('dialog.bmp',493,312))) { + $bitmap = [Drawing.Bitmap]::new($spec[1],$spec[2],[Drawing.Imaging.PixelFormat]::Format24bppRgb) + try { $bitmap.Save((Join-Path $stage $spec[0]),[Drawing.Imaging.ImageFormat]::Bmp) } + finally { $bitmap.Dispose() } + } + Set-Content -LiteralPath (Join-Path $stage 'licence.rtf') -Encoding Ascii -Value '{\rtf1 Metadata test fixture.}' + $sources = @($policy.sources | ForEach-Object { Join-Path $PSScriptRoot $_ }) + $msi = Join-Path $stage "$($policy.id)-NOT-INSTALLABLE.msi" + $extensions = @($policy.extensions | ForEach-Object { '-ext'; "WixToolset.$_.wixext" }) + & wix build @sources -arch $arch -wx @extensions ` + -culture en-US -d 'Version=0.0.1' -d 'DisplayVersion=0.0.1-metadata-test' ` + -d "BinDir=$stage" -d "IconPath=$(Join-Path $root $policy.icon)" ` + -d "DocsDir=$(Join-Path $stage 'docs')" -d "ModelsDir=$(Join-Path $stage 'models')" ` + -d "AppNoticesDir=$(Join-Path $stage 'notices')" -d 'HasCraftFontLicences=1' -d 'HasCli=1' ` + -d "BannerBmp=$(Join-Path $stage 'banner.bmp')" -d "DialogBmp=$(Join-Path $stage 'dialog.bmp')" ` + -d "UiBannerBmp=$(Join-Path $stage 'banner.bmp')" -d "UiDialogBmp=$(Join-Path $stage 'dialog.bmp')" ` + -d "LicenseRtf=$(Join-Path $stage 'licence.rtf')" -o $msi @($policy.localization | ForEach-Object { '-loc'; Join-Path $PSScriptRoot $_ }) + if ($LASTEXITCODE -ne 0) { throw "WiX validation failed for $arch (exit $LASTEXITCODE)." } + $shell = (Get-Process -Id $PID).Path + & $shell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $msi + if ($LASTEXITCODE -ne 0) { throw "Compiled installer policy failed for $arch (exit $LASTEXITCODE)." } + Write-Output "PASS $arch metadata-only package. This is not an executable installer." +} From 1288e40a5381913a2593465a3c764f0769b38d41 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 10:50:10 +0000 Subject: [PATCH 02/33] fix(installer): address scope and ARM64 review feedback --- .github/workflows/windows-arm64.yml | 6 +- packaging/windows/check-install-policy.ps1 | 66 +++++++++++++++++++++- packaging/windows/install-policy.wxi | 6 +- 3 files changed, 72 insertions(+), 6 deletions(-) diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 4cceb922..00011442 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -26,8 +26,10 @@ permissions: jobs: windows-arm64: - # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. - uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main + # Immutable supporting workflow revision until the new install contract lands upstream. + uses: Jeremy8776/craft-common-actions/.github/workflows/windows-arm64.yml@f4666d5cbf35f9bdeda24f1f88c7fb5c6adf5276 with: app-name: figcraft app-dir: FigCraft + install-relative-path: Programs/ArtCraft/FigCraft + install-scope: perUser diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 index dd289564..00b683d7 100644 --- a/packaging/windows/check-install-policy.ps1 +++ b/packaging/windows/check-install-policy.ps1 @@ -131,9 +131,12 @@ try { } $ui = @{} foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallUISequence`' 2)) { $ui[$row[0]] = [int]$row[1] } - if (-not ($ui['AppSearch'] -lt $ui['CraftScopeDlg'] -and $ui['CraftScopeDlg'] -lt $ui['LaunchConditions'] -and + if (-not ($ui['AppSearch'] -lt $ui['CraftPreferMachineScope'] -and + $ui['CraftPreferMachineScope'] -lt $ui['CraftScopeDlg'] -and + $ui['CraftScopeDlg'] -lt $ui['FindRelatedProducts'] -and + $ui['FindRelatedProducts'] -lt $ui['LaunchConditions'] -and $ui['CraftScopeDlg'] -lt $ui['CraftRestoreUserFolder'] -and $ui['CraftRestoreUserFolder'] -lt $ui['CostInitialize'])) { - throw 'Scope must be chosen after discovery and before duplicate checks, folder restoration and costing.' + throw 'Scope must be chosen after AppSearch and before FindRelatedProducts, launch checks and costing.' } $execute = @{} foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] } @@ -144,7 +147,18 @@ try { } # Switching the first-page choice must reset BOTH properties, including a back-and-forth change. $events = @(Read-Rows $database 'SELECT `Event`, `Argument`, `Condition`, `Ordering` FROM `ControlEvent` WHERE `Dialog_` = ''CraftScopeDlg'' AND `Control_` = ''Next'' ORDER BY `Ordering`' 4) - foreach ($choice in @('user','machine','user')) { + # Model the full-UI upgrade entry with an existing machine-wide copy. Run + # the actual preference action, then the compiled Next events before discovery. + Set-SessionProperty $session 'ALLUSERS' '2' + Set-SessionProperty $session 'CRAFT_SCOPE' 'user' + Set-SessionProperty $session 'CRAFT_USER_DIR' '' + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app' + $preference = @(Read-Rows $database 'SELECT `Condition` FROM `InstallUISequence` WHERE `Action` = ''CraftPreferMachineScope''' 1)[0] + if ($session.EvaluateCondition($preference[0]) -ne 1 -or $session.DoAction('CraftPreferMachineScope') -ne 1 -or + (Get-SessionProperty $session 'CRAFT_SCOPE') -ne 'machine') { + throw 'Full-UI machine upgrades must initially select the existing machine context.' + } + foreach ($choice in @('machine','user','machine','user')) { Set-SessionProperty $session 'CRAFT_SCOPE' $choice foreach ($event in $events) { if ($event[0] -notmatch '^\[([^\]]+)\]$') { continue } @@ -176,6 +190,52 @@ try { if ((Get-SessionProperty $session $option[1]) -ne $expected) { throw 'Saved installer choices leaked across contexts.' } } } + # Follow the compiled control conditions, not the source's intended appearance. + $controls = @(Read-Rows $database 'SELECT `Dialog_`, `Control`, `Type`, `Attributes`, `Text` FROM `Control`' 5) + $conditions = @() + if (@(Read-Rows $database 'SELECT `Name` FROM `_Tables` WHERE `Name` = ''ControlCondition''' 1).Count) { + $conditions = @(Read-Rows $database 'SELECT `Dialog_`, `Control_`, `Action`, `Condition` FROM `ControlCondition`' 4) + } + function Test-ControlVisible($Control) { + $visible = ([int]$Control[3] -band 1) -ne 0 + foreach ($condition in $conditions) { + if ($condition[0] -eq $Control[0] -and $condition[1] -eq $Control[1] -and $session.EvaluateCondition($condition[3]) -eq 1) { + if ($condition[2] -eq 'Show') { $visible = $true } + if ($condition[2] -eq 'Hide') { $visible = $false } + } + } + return $visible + } + foreach ($button in @($controls | Where-Object { $_[0] -in 'InstallWelcome','InstallMaintenance','DesktopShortcutDlg' -and ([int]$_[3] -band 8388608) -ne 0 })) { + $plain = @($controls | Where-Object { $_[0] -eq $button[0] -and $_[1] -eq ($button[1] + 'User') }) + if ($plain.Count -ne 1 -or ([int]$plain[0][3] -band 8388608)) { throw 'Scope-aware buttons need an unshielded per-user counterpart.' } + foreach ($scope in @('', '2', '1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + if ((Test-ControlVisible $button) -ne ($scope -eq '1') -or (Test-ControlVisible $plain[0]) -ne ($scope -ne '1')) { + throw 'The elevation shield must follow the selected scope.' + } + } + $dialog = $button[0]; $shieldId = $button[1]; $plainId = $plain[0][1] + $shieldEvents = @(Read-Rows $database "SELECT ``Event``, ``Argument``, ``Condition``, ``Ordering`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$shieldId' ORDER BY ``Ordering``" 4 | ForEach-Object { $_ -join '|' }) + $plainEvents = @(Read-Rows $database "SELECT ``Event``, ``Argument``, ``Condition``, ``Ordering`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$plainId' ORDER BY ``Ordering``" 4 | ForEach-Object { $_ -join '|' }) + if (($shieldEvents -join "`n") -ne ($plainEvents -join "`n")) { throw 'Per-user and machine buttons must perform the same operation.' } + } + foreach ($scope in @('', '2', '1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + foreach ($text in @($controls | Where-Object { $_[4] -like '*for all users*' })) { + if ((Test-ControlVisible $text) -ne ($scope -eq '1')) { throw 'The welcome-page scope text must follow the selected scope.' } + } + } + if ($policy.id -in 'deckcraft','filmcraft','soundcraft') { + foreach ($route in @(@('WelcomeDlg','Next','InstallDirDlg'),@('InstallDirDlg','Back','WelcomeDlg'))) { + $dialog = $route[0]; $control = $route[1] + $routes = @(Read-Rows $database "SELECT ``Argument``, ``Condition`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$control' AND ``Event`` = 'NewDialog' ORDER BY ``Ordering``" 2) + Set-SessionProperty $session 'Installed' '' + $destination = '' + foreach ($event in $routes) { if ($session.EvaluateCondition($event[1]) -eq 1) { $destination = $event[0] } } + if ($destination -ne $route[2]) { throw 'Skipping the licence page must work in both navigation directions.' } + } + } $registry = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4) if (@($registry | Where-Object { $_[0] -in '1', '2' }).Count) { throw 'Registration must follow the selected context, not force HKCU or HKLM.' } if (@($registry | Where-Object { $_[0] -eq '0' -and $_[1] -match '^Software\\Classes(\\|$)' }).Count) { diff --git a/packaging/windows/install-policy.wxi b/packaging/windows/install-policy.wxi index c7cdb271..64bf3935 100644 --- a/packaging/windows/install-policy.wxi +++ b/packaging/windows/install-policy.wxi @@ -50,5 +50,9 @@ - + + + + + From 8d1460c3c03414607a3ee251007b68b4994ca97b Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 11:45:56 +0000 Subject: [PATCH 03/33] fix(installer): harden install-policy checks and packaging-lint workflow --- .github/workflows/packaging-lint.yml | 9 +++++++-- packaging/windows/check-install-policy.ps1 | 13 ++++++++++--- packaging/windows/test-install-policy.ps1 | 2 +- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index c1394c40..da85536c 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -30,6 +30,8 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install the pinned packaging toolchain shell: pwsh run: | @@ -37,8 +39,11 @@ jobs: dotnet tool install --tool-path $tools wix --version 5.0.2 if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' } $tools >> $env:GITHUB_PATH - & (Join-Path $tools 'wix.exe') extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2 - if ($LASTEXITCODE -ne 0) { throw 'WiX extension installation failed.' } + $policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json + foreach ($extension in @($policy.extensions)) { + & (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2" + if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." } + } - name: Build and check non-executable installer fixtures shell: pwsh run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests') diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 index 00b683d7..451295a2 100644 --- a/packaging/windows/check-install-policy.ps1 +++ b/packaging/windows/check-install-policy.ps1 @@ -122,9 +122,11 @@ try { Set-SessionProperty $session 'CRAFT_USER_DIR' 'D:\Existing user app' Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app' $action = if ($scope -eq '1') { 'CraftRestoreMachineFolder' } else { 'CraftRestoreUserFolder' } - if (-not $explicit) { - if ($session.DoAction($action) -ne 1) { throw 'Remembered-folder property action failed.' } - } + # Evaluate the compiled condition, so the test exercises it instead of calling the action directly. + $restore = @(Read-Rows $database ('SELECT `Condition` FROM `InstallExecuteSequence` WHERE `Action` = ''{0}''' -f $action) 1)[0] + $selected = $session.EvaluateCondition($restore[0]) -eq 1 + if ($selected -eq [bool]$explicit) { throw "$action must run only when no folder was supplied (condition: $($restore[0]))." } + if ($selected -and $session.DoAction($action) -ne 1) { throw 'Remembered-folder property action failed.' } $expected = if ($explicit) { $explicit } elseif ($scope -eq '1') { 'E:\Existing machine app' } else { 'D:\Existing user app' } if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Custom or remembered folder was not preserved.' } } @@ -145,6 +147,11 @@ try { throw 'Silent updates must restore the folder before directory costing.' } } + foreach ($resolver in @('CraftUFromLegacyExe','CraftMFromLegacyExe')) { + if (-not ($execute['AppSearch'] -lt $execute[$resolver] -and $execute[$resolver] -lt $execute['LaunchConditions'])) { + throw 'Both legacy folder resolvers must run after AppSearch and before the launch checks.' + } + } # Switching the first-page choice must reset BOTH properties, including a back-and-forth change. $events = @(Read-Rows $database 'SELECT `Event`, `Argument`, `Condition`, `Ordering` FROM `ControlEvent` WHERE `Dialog_` = ''CraftScopeDlg'' AND `Control_` = ''Next'' ORDER BY `Ordering`' 4) # Model the full-UI upgrade entry with an existing machine-wide copy. Run diff --git a/packaging/windows/test-install-policy.ps1 b/packaging/windows/test-install-policy.ps1 index 21199ca7..d455d19d 100644 --- a/packaging/windows/test-install-policy.ps1 +++ b/packaging/windows/test-install-policy.ps1 @@ -38,7 +38,7 @@ foreach ($arch in $Architectures) { $sources = @($policy.sources | ForEach-Object { Join-Path $PSScriptRoot $_ }) $msi = Join-Path $stage "$($policy.id)-NOT-INSTALLABLE.msi" $extensions = @($policy.extensions | ForEach-Object { '-ext'; "WixToolset.$_.wixext" }) - & wix build @sources -arch $arch -wx @extensions ` + & wix build @sources -arch $arch @extensions ` -culture en-US -d 'Version=0.0.1' -d 'DisplayVersion=0.0.1-metadata-test' ` -d "BinDir=$stage" -d "IconPath=$(Join-Path $root $policy.icon)" ` -d "DocsDir=$(Join-Path $stage 'docs')" -d "ModelsDir=$(Join-Path $stage 'models')" ` From bdb7e846060964510dcef1e49d235d0c74dda30b Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:28:10 -0400 Subject: [PATCH 04/33] fix(ui): dropdown menus grow back after a search with no matches (#1429) * feat(ui-egui): font family menu keeps its search field and grows back after a filter clears Repin craft-libs to the shared dropdown (craft-ui), move the font family menu to craft_ui::combo::Menu with the search in a fixed header, and use craft_ui::ComboBox for the credits sort. clippy bans egui::ComboBox. * chore(deps): craft-libs 60d60d48 (dropdown fixes) * chore(deps): craft-libs 4d7b82f7 (dropdown fixes) * chore(deps): craft-libs 92d392cf (#387 merged) * fix(ui-egui): craft-ui on every target (wasm uses the shared ComboBox too) --- Cargo.lock | 231 +++++++++++-- Cargo.toml | 10 +- clippy.toml | 3 + crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/dialogs/credits.rs | 2 +- crates/ui-egui/src/font_menu_regrow_tests.rs | 122 +++++++ crates/ui-egui/src/lib.rs | 2 + .../ui-egui/src/panels/typography/fields.rs | 10 +- .../ui-egui/src/panels/typography/glyphs.rs | 31 +- .../ui-egui/src/panels/typography/picker.rs | 322 ++++++++++-------- crates/ui-egui/src/widgets/style_picker.rs | 35 +- 11 files changed, 579 insertions(+), 190 deletions(-) create mode 100644 crates/ui-egui/src/font_menu_regrow_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 413011ab..555ed9af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -942,12 +942,60 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" + +[[package]] +name = "craft-fonts-build" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" + +[[package]] +name = "craft-i18n" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "fixed_decimal", + "icu_decimal", + "icu_locale_core", + "icu_locale_fallback", + "icu_normalizer", + "icu_plurals", + "icu_properties", + "icu_provider", + "icu_provider_adapters", + "icu_provider_blob", + "log", + "objc2-foundation 0.3.2", + "sys-locale", + "unicode-bidi", + "windows-registry", + "writeable", +] + +[[package]] +name = "craft-ui" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "craft-fonts-build", + "craft-i18n", + "egui", + "epaint", + "js-sys", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "skrifa 0.44.0", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "chrono", "dunce", @@ -972,7 +1020,7 @@ dependencies = [ [[package]] name = "craft-window" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "wayland-client", ] @@ -1844,6 +1892,7 @@ version = "0.11.2" dependencies = [ "base64 0.22.1", "chrono", + "craft-ui", "craft-update", "egui", "egui_extras", @@ -1913,6 +1962,17 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" +[[package]] +name = "fixed_decimal" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae" +dependencies = [ + "displaydoc", + "smallvec", + "writeable", +] + [[package]] name = "fixedbitset" version = "0.5.7" @@ -2399,9 +2459,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.4.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -2412,43 +2472,110 @@ dependencies = [ ] [[package]] -name = "icu_locale_core" -version = "2.4.0" +name = "icu_decimal" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_locale_core", + "icu_pattern", + "icu_plurals", + "icu_provider", + "serde", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", ] [[package]] -name = "icu_normalizer" -version = "2.4.0" +name = "icu_locale_fallback" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", "icu_properties", "icu_provider", "smallvec", + "utf16_iter", + "utf8_iter", + "write16", "zerovec", ] [[package]] name = "icu_normalizer_data" -version = "2.4.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_pattern" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc43ce0e76ee8cb0482dc6e2dbb9b76278e8038f45daabf2a2877577e2ceb18f" +dependencies = [ + "displaydoc", + "either", + "serde", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_plurals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_provider", + "serde", + "zerovec", +] [[package]] name = "icu_properties" -version = "2.4.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ "displaydoc", "icu_collections", @@ -2461,18 +2588,21 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.4.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.4.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", + "postcard", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -2480,6 +2610,30 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_provider_adapters" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820" +dependencies = [ + "icu_locale_fallback", + "icu_provider", +] + +[[package]] +name = "icu_provider_blob" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e" +dependencies = [ + "icu_provider", + "postcard", + "serde", + "writeable", + "zerotrie", + "zerovec", +] + [[package]] name = "idna" version = "1.1.0" @@ -2731,7 +2885,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "base64 0.22.1", "bumpalo", @@ -2844,6 +2998,9 @@ name = "litemap" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +dependencies = [ + "serde_core", +] [[package]] name = "litrs" @@ -3684,6 +3841,8 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -4681,6 +4840,18 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "js-sys", + "libc", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "tar" version = "0.4.46" @@ -4855,6 +5026,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -5165,6 +5337,12 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + [[package]] name = "utf8-zero" version = "0.8.1" @@ -6152,11 +6330,20 @@ dependencies = [ "read-fonts 0.39.2", ] +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + [[package]] name = "writeable" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +dependencies = [ + "either", +] [[package]] name = "x11-dl" @@ -6424,8 +6611,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", + "litemap", + "serde_core", "yoke", "zerofrom", + "zerovec", ] [[package]] @@ -6434,6 +6624,7 @@ version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", diff --git a/Cargo.toml b/Cargo.toml index f6fca285..fd2fbb81 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,13 +35,15 @@ figcraft-plugin = { path = "crates/plugin" } # craft-libs crates all pin one rev (craftrules standards/shared-libraries.md): bump them together. # Which paths stored in a document may be followed (craft-libs). -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } # Who draws the window frame on Linux — the compositor or the app — so every Craft app shows the # same native titlebar per desktop (craft-libs). FigCraft only reads the probe result and maps it # to its own `TitleBar`, so it does not enable craft-window's optional `egui` helpers (which would # pull a second egui family version). -craft-window = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] } +craft-window = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } +# Shared dropdown menus: a combo box whose menu grows back after shrinking (craft-libs). +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } # foundation kurbo = { version = "0.13", features = ["serde"] } @@ -160,4 +162,4 @@ winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457 # craft-libs' vendored krilla 0.8.2: decoded PNGs are capped at 1 GiB and reserved fallibly, and a # panic while drawing unwinds without aborting. -krilla = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" } +krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } diff --git a/clippy.toml b/clippy.toml index 724e05c5..36cc87a4 100644 --- a/clippy.toml +++ b/clippy.toml @@ -4,3 +4,6 @@ allow-unwrap-in-tests = true allow-expect-in-tests = true allow-panic-in-tests = true too-many-arguments-threshold = 10 +disallowed-types = [ + { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" }, +] diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 04f09a37..78447d7b 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -9,6 +9,7 @@ description = "FigCraft egui frontend (the Figma-style UI: canvas, toolbar, laye workspace = true [dependencies] +craft-ui = { workspace = true } figcraft-geom = { workspace = true } figcraft-color = { workspace = true } figcraft-doc = { workspace = true } diff --git a/crates/ui-egui/src/dialogs/credits.rs b/crates/ui-egui/src/dialogs/credits.rs index 90df5ee3..957c86b2 100644 --- a/crates/ui-egui/src/dialogs/credits.rs +++ b/crates/ui-egui/src/dialogs/credits.rs @@ -267,7 +267,7 @@ fn contributors_in(ui: &mut egui::Ui, contributors: &[Contributor], total_commit } ui.separator(); ui.label(RichText::new(tr("Sort")).color(t.text_secondary)); - egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| { for k in SortKey::ALL { if ui.selectable_label(v.key == k, k.label().0).clicked() { v.key = k; diff --git a/crates/ui-egui/src/font_menu_regrow_tests.rs b/crates/ui-egui/src/font_menu_regrow_tests.rs new file mode 100644 index 00000000..b4a9d880 --- /dev/null +++ b/crates/ui-egui/src/font_menu_regrow_tests.rs @@ -0,0 +1,122 @@ +//! The font family menu follows its list's height while it is open: filtering to nothing shrinks +//! it, clearing the filter grows it back, and the search field stays where it is (egui#5225). +use egui::accesskit::{Node, Role}; +use egui::{Event, Key, Modifiers, Pos2, RawInput, Rect, vec2}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::{FigApp, NoServices}; + +struct Panel { + app: FigApp, + ctx: egui::Context, + time: f64, +} + +impl Panel { + fn new() -> Self { + let mut s = Session::new(); + let text = s.execute("text.create", &json!({"text": "Typography"})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [text]})).unwrap(); + let mut p = Self { app: FigApp::new(s, Box::new(NoServices)), ctx: egui::Context::default(), time: 0.0 }; + crate::theme::install_fonts(&p.ctx, false, 0); + crate::theme::apply(&p.ctx, crate::theme::Tokens::light()); + p.ctx.enable_accesskit(); + for _ in 0..3 { + p.frame(vec![]); + } + p + } + + fn frame(&mut self, events: Vec) -> Vec { + self.time += 1.0 / 60.0; + let input = + RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 800.0))), time: Some(self.time), events, ..Default::default() }; + let mut out = self.ctx.run_ui(input, |ui| { + let ds = self.app.session.ds().unwrap(); + let doc = ds.doc.clone(); + let sel = ds.selection.clone(); + crate::typography::typography(&mut self.app, ui, &doc, &sel); + }); + out.textures_delta.clear(); + out.platform_output.accesskit_update.map(|u| u.nodes.into_iter().map(|(_, n)| n).collect()).unwrap_or_default() + } + + /// Settled frames; returns the last frame's accessibility tree. + fn settle(&mut self, events: Vec) -> Vec { + let mut nodes = self.frame(events); + for _ in 0..4 { + nodes = self.frame(vec![]); + } + nodes + } + + fn click(&mut self, at: Pos2) { + self.frame(vec![Event::PointerMoved(at)]); + let button = |pressed| Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + self.frame(vec![button(true)]); + self.settle(vec![button(false)]); + } + + /// The popup's rectangle: the one foreground area that wasn't there before the menu opened. + fn menu_rect(&self, before: &[egui::LayerId]) -> Rect { + let layers: Vec = self.ctx.memory(|m| m.layer_ids().collect()); + layers + .into_iter() + .filter(|l| l.order == egui::Order::Foreground && !before.contains(l)) + .filter_map(|l| self.ctx.memory(|m| m.area_rect(l.id))) + .max_by(|a, b| a.height().total_cmp(&b.height())) + .expect("the font menu is open") + } +} + +fn centre(n: &Node) -> Pos2 { + let b = n.bounds().unwrap(); + Pos2::new(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) +} + +/// The top of the search field: the text input as wide as `menu` (the panel's own fields are narrower). +fn search_top(nodes: &[Node], menu: Rect) -> f32 { + let field = nodes + .iter() + .filter(|n| n.role() == Role::TextInput) + .filter_map(|n| n.bounds()) + .find(|b| menu.contains(Pos2::new(b.x0 as f32 + 1.0, b.y0 as f32 + 1.0)) && b.x1 - b.x0 > f64::from(menu.width()) - 40.0) + .expect("the menu has a search field"); + field.y0 as f32 +} + +#[test] +fn the_font_menu_regrows_after_a_search_matches_nothing() { + figcraft_text::scan_system_fonts(); + if figcraft_text::family_names().len() < 3 { + eprintln!("fewer than three font families; skipping"); + return; + } + let mut p = Panel::new(); + let nodes = p.settle(vec![]); + let family = + nodes.iter().find(|n| n.role() == Role::ComboBox && n.value() == Some("Inter")).expect("the Typography section has a font family field"); + let before: Vec = p.ctx.memory(|m| m.layer_ids().collect()); + p.click(centre(family)); + let nodes = p.settle(vec![]); + let open = p.menu_rect(&before); + let top = search_top(&nodes, open); + assert!(open.height() > 100.0, "the menu lists fonts: {open:?}"); + assert!(open.width() >= 240.0, "the menu keeps the 240 minimum width under a narrower field: {open:?}"); + + let type_text = |p: &mut Panel, s: &str| p.settle(vec![Event::Text(s.into())]); + let nodes = type_text(&mut p, "zzzzqqqq"); + let none = p.menu_rect(&before); + assert!(none.height() < open.height() - 20.0, "no match shrinks the menu: {open:?} → {none:?}"); + assert_eq!(search_top(&nodes, none), top, "the search field stays put while the list is empty"); + + let mut nodes = nodes; + for _ in 0.."zzzzqqqq".len() { + let key = Event::Key { key: Key::Backspace, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; + nodes = p.settle(vec![key]); + } + let again = p.menu_rect(&before); + assert!((again.height() - open.height()).abs() < 1.0, "clearing the search grows the menu back: {open:?} → {again:?}"); + assert_eq!(search_top(&nodes, again), top, "the search field stays put when the list grows back"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1eb714e9..4c1ed337 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -78,6 +78,8 @@ mod export_preview_tests; #[cfg(test)] mod field_tests; #[cfg(test)] +mod font_menu_regrow_tests; +#[cfg(test)] mod frame_label_tests; #[cfg(test)] mod gap_handle_tests; diff --git a/crates/ui-egui/src/panels/typography/fields.rs b/crates/ui-egui/src/panels/typography/fields.rs index fb5d7ad8..86fae14e 100644 --- a/crates/ui-egui/src/panels/typography/fields.rs +++ b/crates/ui-egui/src/panels/typography/fields.rs @@ -22,21 +22,17 @@ pub(super) fn font_rows( row(ui, 32.0, |ui| { let w = ui.available_width(); let resp = font_dropdown(app, ui, ids, "fontFamily", family.as_deref(), w, editing); - menu_popup(&resp).width(w.max(240.0)).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { - if !editing && apply_variable_item(ui, ids, "fontFamily", &resp) { - return; - } - let pick = font_picker(ui, family.as_deref(), resp.clicked()); + let before_search = |ui: &mut Ui| !editing && apply_variable_item(ui, ids, "fontFamily", &resp); + if let Some(pick) = font_picker(ui, &resp, family.as_deref(), w.max(240.0), before_search) { if let Some(f) = pick.picked { remember_family(ui.ctx(), &f); apply_style(app, "fontFamily", json!(f)); - ui.close(); } else if let Some(h) = pick.hovered.or(pick.highlighted) { // The canvas shows the hovered family (or the one the arrow keys are on); // closing without picking ends the preview. app.preview_text(ui.ctx(), "text.style", text_style_params(ids, range, json!({"fontFamily": h}))); } - }); + } }); // Style + size. diff --git a/crates/ui-egui/src/panels/typography/glyphs.rs b/crates/ui-egui/src/panels/typography/glyphs.rs index 13e0f6ef..3b71b7c4 100644 --- a/crates/ui-egui/src/panels/typography/glyphs.rs +++ b/crates/ui-egui/src/panels/typography/glyphs.rs @@ -191,12 +191,33 @@ mod tests { assert_eq!(parse_letter_spacing("x", Some(LetterSpacing::Percent(0.0))), None); } - /// One frame of the font picker with `events`. + /// One frame of the font picker under a field, with `events`. `opened` clicks the field first + /// (a press frame, then the release frame this returns). fn pick(ctx: &egui::Context, current: &str, opened: bool, events: Vec) -> FontPick { - let mut out = FontPick::default(); - let mut shown = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| out = font_picker(ui, Some(current), opened)); - shown.textures_delta.clear(); - out + let field = egui::Rect::from_min_size(egui::pos2(10.0, 10.0), egui::vec2(200.0, 24.0)); + let frame = |events: Vec| { + let mut out = FontPick::default(); + let mut shown = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + let resp = ui.interact(field, egui::Id::unique("font_field"), egui::Sense::click()); + out = font_picker(ui, &resp, Some(current), 240.0, |_| false).unwrap_or_default(); + }); + shown.textures_delta.clear(); + out + }; + if opened { + let button = |pressed| egui::Event::PointerButton { + pos: field.center(), + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }; + // The field is laid out once before the pointer can hit it. + frame(vec![]); + frame(vec![egui::Event::PointerMoved(field.center())]); + frame(vec![button(true)]); + return frame(vec![button(false)]); + } + frame(events) } fn key(key: egui::Key) -> egui::Event { diff --git a/crates/ui-egui/src/panels/typography/picker.rs b/crates/ui-egui/src/panels/typography/picker.rs index 824fc43e..c7d8bea2 100644 --- a/crates/ui-egui/src/panels/typography/picker.rs +++ b/crates/ui-egui/src/panels/typography/picker.rs @@ -41,149 +41,199 @@ pub(crate) struct FontPick { pub hovered: Option, } -/// The searchable family list: click a family, or step through it with Up and Down and press Enter. -/// Hovering a family previews it on the canvas (closing without picking restores the original). -pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) -> FontPick { +/// The most the font menu is tall (its search field and the "Apply variable" row above the list +/// included); the list scrolls past that. +const MENU_HEIGHT: f32 = LIST_HEIGHT + 40.0; + +/// What the menu's header worked out for its list. +#[derive(Default)] +struct Header { + rows: Vec, + /// The row Up and Down are on. + hi: Option, + /// The rows Up and Down moved this frame (the list scrolls to the row they are on). + step: i64, + /// Enter picked this family. + entered: Option, +} + +/// The searchable family menu under `anchor`: click a family, or step through it with Up and Down +/// and press Enter. Hovering a family previews it on the canvas (closing without picking restores +/// the original). `before_search` draws the rows above the search field and returns true when one +/// of them handled the menu (it closed it). None while the menu is closed. +/// +/// The search field and those rows stay in place; only the list scrolls. Headers ("Recent", +/// "All fonts") are as tall as family rows, so the list is uniform and virtualised. +pub(crate) fn font_picker( + ui: &Ui, + anchor: &egui::Response, + current: Option<&str>, + width: f32, + before_search: impl FnOnce(&mut Ui) -> bool, +) -> Option { let t = Tokens::get(ui.ctx()); + let just_opened = anchor.clicked(); + let hi_id = Id::unique("font_highlight"); let q_id = Id::unique("font_query"); - // The row Up and Down are on, and the search it is a row of. - let hi_id = Id::unique("font_highlight"); - let mut q: String = ui.data(|d| d.get_temp(q_id)).unwrap_or_default(); - if just_opened { - q.clear(); - ui.data_mut(|d| d.remove::<(String, usize)>(hi_id)); - } - // Taken before the search field sees them (it would move its cursor). - let step = ui.input_mut(|i| { - i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown) as i64 - - i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp) as i64 - }); - let search = ui.add(egui::TextEdit::singleline(&mut q).hint_text(tr("Search fonts")).desired_width(ui.available_width()).font(regular(11.5))); - crate::widgets::select_all_on_focus(ui, &search, &q); - if just_opened { - search.request_focus(); - } - ui.data_mut(|d| d.insert_temp(q_id, q.clone())); - let ql = q.trim().to_lowercase(); - let all = figcraft_text::family_names(); - let matches: Vec = all.iter().filter(|f| ql.is_empty() || f.to_lowercase().contains(&ql)).cloned().collect(); - let mut rows: Vec = Vec::new(); - if ql.is_empty() { - let recent: Vec = recent_families(ui.ctx()).into_iter().filter(|f| all.iter().any(|a| a.eq_ignore_ascii_case(f))).collect(); - if !recent.is_empty() { - rows.push(PickRow::Header(tr("Recent"))); - rows.extend(recent.into_iter().map(PickRow::Family)); - rows.push(PickRow::Header(tr("All fonts"))); - } - } - if matches.is_empty() { - widgets::label(ui, tr("No fonts match"), t.text_tertiary); - return FontPick::default(); - } - rows.extend(matches.into_iter().map(PickRow::Family)); - let family = |i: usize| match rows.get(i) { - Some(PickRow::Family(f)) => Some(f.clone()), - _ => None, - }; - // A new search starts again; the first step starts from the current family. - let mut hi = ui.data(|d| d.get_temp::<(String, usize)>(hi_id)).filter(|(hq, _)| *hq == q).map(|(_, i)| i); - let stride = ROW + ui.spacing().item_spacing.y; - let mut offset = None; - if step != 0 { - let next = |i: usize| if step > 0 { i.checked_add(1) } else { i.checked_sub(1) }; - let mut from = - hi.or_else(|| rows.iter().rposition(|r| matches!(r, PickRow::Family(f) if current.is_some_and(|c| c.eq_ignore_ascii_case(f))))); - for _ in 0..step.unsigned_abs() { - let mut at = match from { - Some(i) => next(i), - None if step > 0 => Some(0), - None => rows.len().checked_sub(1), - }; - // Past the headers; at either end it stays where it was. - while let Some(i) = at.filter(|i| *i < rows.len() && family(*i).is_none()) { - at = next(i); - } - if let Some(i) = at.filter(|i| family(*i).is_some()) { - from = Some(i); - hi = Some(i); - } - } - if let Some(i) = hi { - // Scrolled just enough to show it (the list is shorter than its tallest where the - // window has less room). - let (seen, height): (f32, f32) = ui.data(|d| d.get_temp(hi_id.with("view"))).unwrap_or((0.0, LIST_HEIGHT)); - let top = i as f32 * stride; - offset = Some(seen.max(top + stride - height).min(top)); - } - } - match hi { - Some(i) => ui.data_mut(|d| { - d.insert_temp(hi_id, (q.clone(), i)); - }), - None => ui.data_mut(|d| d.remove::<(String, usize)>(hi_id)), - } - if search.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { - let first = rows.iter().find_map(|r| match r { - PickRow::Family(f) => Some(f.clone()), - PickRow::Header(_) => None, - }); - return FontPick { picked: hi.and_then(family).or(first), highlighted: None, hovered: None }; - } - let mut budget = 4; - let mut picked = None; - let mut hovered_family = None; - let mut area = egui::ScrollArea::vertical().max_height(LIST_HEIGHT).auto_shrink([false, true]); - if let Some(y) = offset { - area = area.vertical_scroll_offset(y); - } - let shown = area.show_rows(ui, ROW, rows.len(), |ui, range| { - for (i, r) in rows.iter().enumerate().skip(range.start).take(range.len()) { - let w = ui.available_width(); - let (rect, resp) = ui.allocate_exact_size(vec2(w, ROW), if matches!(r, PickRow::Family(_)) { Sense::click() } else { Sense::hover() }); - match r { - PickRow::Header(h) => { - ui.painter().text(pos2(rect.min.x + 8.0, rect.center().y), Align2::LEFT_CENTER, *h, semibold(10.5), t.text_secondary); + craft_ui::combo::Menu::new(ui, anchor) + .height(MENU_HEIGHT) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .style(crate::right::menu_style) + .show_rows_with_header( + |ui| { + // The menu is at least `width` wide, even under a narrower field. + ui.set_min_width(width); + if before_search(ui) { + return Header::default(); + } + let mut q: String = ui.data(|d| d.get_temp(q_id)).unwrap_or_default(); + if just_opened { + q.clear(); + ui.data_mut(|d| d.remove::<(String, usize)>(hi_id)); + } + // Taken before the search field sees them (it would move its cursor). + let step = ui.input_mut(|i| { + i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown) as i64 + - i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp) as i64 + }); + let search = + ui.add(egui::TextEdit::singleline(&mut q).hint_text(tr("Search fonts")).desired_width(ui.available_width()).font(regular(11.5))); + crate::widgets::select_all_on_focus(ui, &search, &q); + if just_opened { + search.request_focus(); } - PickRow::Family(f) => { - let hovered = resp.hovered() || hi == Some(i); - if hovered { - ui.painter().rect_filled(rect, CornerRadius::same(5), t.accent); + ui.data_mut(|d| d.insert_temp(q_id, q.clone())); + let ql = q.trim().to_lowercase(); + let all = figcraft_text::family_names(); + let matches: Vec = all.iter().filter(|f| ql.is_empty() || f.to_lowercase().contains(&ql)).cloned().collect(); + let mut rows: Vec = Vec::new(); + if ql.is_empty() { + let recent: Vec = + recent_families(ui.ctx()).into_iter().filter(|f| all.iter().any(|a| a.eq_ignore_ascii_case(f))).collect(); + if !recent.is_empty() { + rows.push(PickRow::Header(tr("Recent"))); + rows.extend(recent.into_iter().map(PickRow::Family)); + rows.push(PickRow::Header(tr("All fonts"))); } - let col = if hovered { t.accent_on } else { t.text }; - if current.is_some_and(|c| c.eq_ignore_ascii_case(f)) { - icons::paint( - ui.painter(), - Rect::from_center_size(pos2(rect.min.x + 10.0, rect.center().y), vec2(12.0, 12.0)), - Icon::Check, - col, - ); + } + if matches.is_empty() { + widgets::label(ui, tr("No fonts match"), t.text_tertiary); + return Header::default(); + } + rows.extend(matches.into_iter().map(PickRow::Family)); + let family = |i: usize| match rows.get(i) { + Some(PickRow::Family(f)) => Some(f.clone()), + _ => None, + }; + // A new search starts again; the first step starts from the current family. + let mut hi = ui.data(|d| d.get_temp::<(String, usize)>(hi_id)).filter(|(hq, _)| *hq == q).map(|(_, i)| i); + if step != 0 { + let next = |i: usize| if step > 0 { i.checked_add(1) } else { i.checked_sub(1) }; + let mut from = hi + .or_else(|| rows.iter().rposition(|r| matches!(r, PickRow::Family(f) if current.is_some_and(|c| c.eq_ignore_ascii_case(f))))); + for _ in 0..step.unsigned_abs() { + let mut at = match from { + Some(i) => next(i), + None if step > 0 => Some(0), + None => rows.len().checked_sub(1), + }; + // Past the headers; at either end it stays where it was. + while let Some(i) = at.filter(|i| *i < rows.len() && family(*i).is_none()) { + at = next(i); + } + if let Some(i) = at.filter(|i| family(*i).is_some()) { + from = Some(i); + hi = Some(i); + } } - let text_min = pos2(rect.min.x + 22.0, rect.min.y); - match crate::font_preview::family(ui.ctx(), f, 13.0, &mut budget) { - Some(tex) => { - crate::font_preview::paint( - ui.painter(), - &tex, - pos2(text_min.x, rect.center().y), - rect.max.x - text_min.x - 6.0, - ROW, - col, - ); + } + match hi { + Some(i) => ui.data_mut(|d| { + d.insert_temp(hi_id, (q.clone(), i)); + }), + None => ui.data_mut(|d| d.remove::<(String, usize)>(hi_id)), + } + let mut entered = None; + if search.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + let first = rows.iter().find_map(|r| match r { + PickRow::Family(f) => Some(f.clone()), + PickRow::Header(_) => None, + }); + entered = hi.and_then(family).or(first); + ui.close(); + } + Header { rows, hi, step, entered } + }, + ROW, + |h| h.rows.len(), + |ui, range, h| { + if let Some(f) = h.entered { + return FontPick { picked: Some(f), highlighted: None, hovered: None }; + } + if let (Some(i), true) = (h.hi, h.step != 0) { + // Scrolled just enough to show the row Up or Down moved to; it may be out of + // the range laid out, so its rect comes from the list's top. + let stride = ROW + ui.spacing().item_spacing.y; + let top = ui.max_rect().top() - range.start as f32 * stride + i as f32 * stride; + ui.scroll_to_rect(Rect::from_min_size(pos2(ui.max_rect().left(), top), vec2(ui.max_rect().width(), ROW)), None); + } + let mut budget = 4; + let mut picked = None; + let mut hovered_family = None; + let hi = h.hi; + for (i, r) in h.rows.iter().enumerate().skip(range.start).take(range.len()) { + let w = ui.available_width(); + let (rect, resp) = + ui.allocate_exact_size(vec2(w, ROW), if matches!(r, PickRow::Family(_)) { Sense::click() } else { Sense::hover() }); + match r { + PickRow::Header(label) => { + ui.painter().text(pos2(rect.min.x + 8.0, rect.center().y), Align2::LEFT_CENTER, *label, semibold(10.5), t.text_secondary); } - None => { - ui.painter().text(pos2(text_min.x, rect.center().y), Align2::LEFT_CENTER, f, regular(12.0), col); + PickRow::Family(f) => { + let hovered = resp.hovered() || hi == Some(i); + if hovered { + ui.painter().rect_filled(rect, CornerRadius::same(5), t.accent); + } + let col = if hovered { t.accent_on } else { t.text }; + if current.is_some_and(|c| c.eq_ignore_ascii_case(f)) { + icons::paint( + ui.painter(), + Rect::from_center_size(pos2(rect.min.x + 10.0, rect.center().y), vec2(12.0, 12.0)), + Icon::Check, + col, + ); + } + let text_min = pos2(rect.min.x + 22.0, rect.min.y); + match crate::font_preview::family(ui.ctx(), f, 13.0, &mut budget) { + Some(tex) => { + crate::font_preview::paint( + ui.painter(), + &tex, + pos2(text_min.x, rect.center().y), + rect.max.x - text_min.x - 6.0, + ROW, + col, + ); + } + None => { + ui.painter().text(pos2(text_min.x, rect.center().y), Align2::LEFT_CENTER, f, regular(12.0), col); + } + } + if resp.clicked() { + picked = Some(f.clone()); + ui.close(); + } + if resp.hovered() { + hovered_family = Some(f.clone()); + } } - } - if resp.clicked() { - picked = Some(f.clone()); - } - if resp.hovered() { - hovered_family = Some(f.clone()); } } - } - } - }); - ui.data_mut(|d| d.insert_temp(hi_id.with("view"), (shown.state.clamped_offset().y, shown.inner_rect.height()))); - FontPick { highlighted: hi.and_then(family), picked, hovered: hovered_family } + let highlighted = hi.and_then(|i| match h.rows.get(i) { + Some(PickRow::Family(f)) => Some(f.clone()), + _ => None, + }); + FontPick { highlighted, picked, hovered: hovered_family } + }, + ) } diff --git a/crates/ui-egui/src/widgets/style_picker.rs b/crates/ui-egui/src/widgets/style_picker.rs index f97dfeb9..04e29397 100644 --- a/crates/ui-egui/src/widgets/style_picker.rs +++ b/crates/ui-egui/src/widgets/style_picker.rs @@ -857,25 +857,26 @@ fn text_property_fields(ui: &mut Ui, doc: &Document, style: &Style, def: &TextSt /// The font menu: Apply variable, then the installed fonts. Picking a font keeps its style when the /// family has one, else falls back to Regular or the first style. -fn font_family_menu(id: &str, def: &TextStyle, family_resp: &Response, out: &mut Frame) { - crate::right::menu_popup(family_resp).width(260.0).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { - if crate::right::menu_item(ui, tr("Apply variable"), "") { +fn font_family_menu(ui: &Ui, id: &str, def: &TextStyle, family_resp: &Response, out: &mut Frame) { + let apply_variable = |ui: &mut Ui| { + let picked = crate::right::menu_item(ui, tr("Apply variable"), ""); + if picked { crate::variable_ui::open_style(ui, id, "fontFamily", VariableType::String, family_resp.rect.left_bottom()); ui.close(); - return; + } else { + ui.separator(); } - ui.separator(); - if let Some(f) = crate::typography::font_picker(ui, Some(&def.font_family), family_resp.clicked()).picked { - set_text(def, id, out, |d| { - d.font_family = f.clone(); - let styles = figcraft_text::family_styles(&f); - if !styles.is_empty() && !styles.contains(&d.font_style) { - d.set_font_style(styles.iter().find(|s| s.eq_ignore_ascii_case("Regular")).or(styles.first()).cloned().unwrap_or_default()); - } - }); - ui.close(); - } - }); + picked + }; + if let Some(f) = crate::typography::font_picker(ui, family_resp, Some(&def.font_family), 260.0, apply_variable).and_then(|p| p.picked) { + set_text(def, id, out, |d| { + d.font_family = f.clone(); + let styles = figcraft_text::family_styles(&f); + if !styles.is_empty() && !styles.contains(&d.font_style) { + d.set_font_style(styles.iter().find(|s| s.eq_ignore_ascii_case("Regular")).or(styles.first()).cloned().unwrap_or_default()); + } + }); + } } /// The font style menu: Apply variable, then the styles of the current font, the current one checked. @@ -909,7 +910,7 @@ fn text_properties(ui: &mut Ui, doc: &Document, style: &Style, def: &TextStyle, // The dropdowns' menus open outside the dark scope (they're regular popups). let (family_resp, style_resp) = crate::theme::on_dark(ui, |ui| text_property_fields(ui, doc, style, def, out)); if let Some(family_resp) = family_resp { - font_family_menu(id, def, &family_resp, out); + font_family_menu(ui, id, def, &family_resp, out); } if let Some(sr) = style_resp { font_style_menu(id, def, &sr, out); From 2893518c843130ffda10313626c38118b3d16dca Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 15:32:22 +0000 Subject: [PATCH 05/33] fix(installer): resolve the wizard scope and folder correctly Real msiexec runs (per-user and all-users, silent and wizard) found two defects that the metadata fixtures cannot see: - "Only for me" published ALLUSERS=2, so FindRelatedProducts treated the run as per-machine and skipped the existing per-user copy. An interactive upgrade left two registered products and uninstall left files behind. The page now leaves ALLUSERS unset for a per-user choice. - Windows Installer resolves Program Files once, for the context it started in, so choosing "Everyone" in the wizard installed into the user profile with a machine-wide registration. The wizard now sets the new-install folder itself after the choice (Program Files, Program Files (x86) for 32-bit, or the per-user Programs folder). Silent installs are unchanged. check-install-policy.ps1 now fails if either regresses. --- packaging/windows/check-install-policy.ps1 | 24 +++++++++++++++++++++- packaging/windows/install-policy.wxi | 15 +++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 index 451295a2..a4a14bc6 100644 --- a/packaging/windows/check-install-policy.ps1 +++ b/packaging/windows/check-install-policy.ps1 @@ -140,6 +140,28 @@ try { $ui['CraftScopeDlg'] -lt $ui['CraftRestoreUserFolder'] -and $ui['CraftRestoreUserFolder'] -lt $ui['CostInitialize'])) { throw 'Scope must be chosen after AppSearch and before FindRelatedProducts, launch checks and costing.' } + # Windows Installer resolves Program Files for the context it started in, before the wizard's scope choice. + # The wizard therefore sets the new-install folder itself, and only in the wizard. + $defaults = @(Read-Rows $database 'SELECT `Action`, `Sequence`, `Condition` FROM `InstallUISequence`' 3 | Where-Object { $_[0] -like 'CraftDefault*' }) + if ($defaults.Count -lt 2) { throw 'The wizard must set the new-install folder after the scope page; Program Files is resolved before the choice.' } + foreach ($row in $defaults) { + if (-not ($ui['CraftScopeDlg'] -lt [int]$row[1] -and [int]$row[1] -lt $ui['CostInitialize'])) { throw "$($row[0]) must run after the scope page and before costing." } + } + if (@(Read-Rows $database 'SELECT `Action` FROM `InstallExecuteSequence`' 1 | Where-Object { $_[0] -like 'CraftDefault*' }).Count) { + throw 'Silent installs must not use the wizard-only default folder actions.' + } + foreach ($scope in @('1', '')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session 'INSTALLFOLDER' '' + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' '' + Set-SessionProperty $session 'CRAFT_USER_DIR' '' + foreach ($row in @($defaults | Sort-Object { [int]$_[1] })) { + if ($session.EvaluateCondition($row[2]) -eq 1 -and $session.DoAction($row[0]) -ne 1) { throw "$($row[0]) failed." } + } + $folder = Get-SessionProperty $session 'INSTALLFOLDER' + $wanted = if ($scope -eq '1') { '^[A-Za-z]:\\Program Files( \(x86\))?\\ArtCraft\\[^\\]+\\$' } else { '^' + [regex]::Escape($env:LOCALAPPDATA) + '\\Programs\\ArtCraft\\[^\\]+\\$' } + if ($folder -notmatch $wanted) { throw "Wizard default folder for ALLUSERS='$scope' is '$folder'." } + } $execute = @{} foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] } foreach ($action in @('CraftRestoreUserFolder','CraftRestoreMachineFolder')) { @@ -177,7 +199,7 @@ try { $allUsers = Get-SessionProperty $session 'ALLUSERS' $perUser = Get-SessionProperty $session 'MSIINSTALLPERUSER' if (($choice -eq 'machine' -and ($allUsers -ne '1' -or $perUser)) -or - ($choice -eq 'user' -and ($allUsers -ne '2' -or $perUser -ne '1'))) { + ($choice -eq 'user' -and ($allUsers -or $perUser -ne '1'))) { throw 'Scope selector does not reset the requested installation context.' } } diff --git a/packaging/windows/install-policy.wxi b/packaging/windows/install-policy.wxi index 64bf3935..3a212ef3 100644 --- a/packaging/windows/install-policy.wxi +++ b/packaging/windows/install-policy.wxi @@ -1,6 +1,9 @@ + @@ -25,6 +28,16 @@ + + + + + + + + + @@ -42,7 +55,7 @@ - + From adb1b4303322b245817fa3bf9e8a172dea311075 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:41:39 +0100 Subject: [PATCH 06/33] Speed up raster export: faster blur and spread passes Shadows and layer blurs dominated PNG/JPG/WebP export time (gdb stack sampling of the example files: about 45% in fx::box_blur_h, 13% in fx::morph). Both now produce the same bytes, faster: - fx::blur runs its three box passes per row while the row is in cache, works on whole pixels instead of bounds-checked bytes, skips fully transparent rows, transposes in cache-friendly column blocks, and splits rows and transposes across rayon on desktop for pixmaps of 64K pixels and more, only when not already on a rayon worker (a band of render_view stays on its thread: no nested parallelism). - fx::morph (shadow spread on layers that aren't one outline) uses the van Herk / Gil-Werman running max/min: constant work per pixel instead of 2r + 1. Integer arithmetic and pass order are unchanged; new tests compare both against the previous implementations byte for byte, including sizes on the parallel path. An ignored timing test exports every top-level frame of examples/ as PNG, JPG and WebP at 2x through export_file: cargo test --release -p figcraft-engine --lib export_timings -- --ignored --nocapture Ryzen, 16 threads, Linux; median of 7 (min): PNG 2195 ms (2182) -> 992 ms (989) -55% JPG 2880 ms (2560) -> 1550 ms (1535) -46% WebP 1964 ms (1901) -> 1022 ms (1018) -48% total 7035 ms (6677) -> 3567 ms (3555) -49% All 80 exported files (PNG, JPG, WebP, PDF, SVG) are byte-identical. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/export_bench_tests.rs | 96 ++++++ crates/engine/src/lib.rs | 2 + crates/render/src/fx.rs | 410 +++++++++++++++++++----- 3 files changed, 428 insertions(+), 80 deletions(-) create mode 100644 crates/engine/src/export_bench_tests.rs diff --git a/crates/engine/src/export_bench_tests.rs b/crates/engine/src/export_bench_tests.rs new file mode 100644 index 00000000..56c6c40f --- /dev/null +++ b/crates/engine/src/export_bench_tests.rs @@ -0,0 +1,96 @@ +//! Export timings over the example files: every top-level frame of each `examples/*.figcraft` +//! exported as PNG, JPG and WebP at 2x through [`export_file`], as Export and `figcraft-cli export` +//! do. Run in release: +//! `cargo test --release -p figcraft-engine --lib export_timings -- --ignored --nocapture`. +//! +//! `FIGCRAFT_EXPORT_BENCH_RUNS` sets the timed runs (default 7; one warm-up run first), +//! `FIGCRAFT_EXPORT_BENCH_FORMATS` the formats (default `png,jpg,webp`) and +//! `FIGCRAFT_EXPORT_BENCH_OUT` a folder to write the files of the last run to (to compare outputs). + +use std::time::Instant; + +use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId}; + +use crate::cmd::file::{export_file, load_path}; + +/// The example documents and their top-level frames. +fn examples() -> Vec<(String, Document, Vec)> { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples"); + let mut paths: Vec<_> = + std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().path()).filter(|p| p.extension().is_some_and(|e| e == "figcraft")).collect(); + paths.sort(); + paths + .into_iter() + .map(|p| { + let (loaded, _) = load_path(&p.to_string_lossy(), None).unwrap(); + let doc = loaded.doc; + let frames: Vec = doc.pages.iter().flat_map(|pg| doc.top_frames(*pg)).collect(); + (p.file_stem().unwrap().to_string_lossy().into_owned(), doc, frames) + }) + .collect() +} + +fn format(name: &str) -> ExportFormat { + match name { + "png" => ExportFormat::Png, + "jpg" => ExportFormat::Jpg, + "webp" => ExportFormat::Webp, + "svg" => ExportFormat::Svg, + "pdf" => ExportFormat::Pdf, + other => panic!("unknown format {other}"), + } +} + +/// Export every frame in `format` at 2x: (milliseconds, files). +fn run(docs: &[(String, Document, Vec)], format: ExportFormat) -> (f64, Vec<(String, Vec)>) { + let setting = ExportSetting { format, ..Default::default() }; + let mut files = Vec::new(); + let t = Instant::now(); + for (name, doc, frames) in docs { + for f in frames { + let (bytes, _) = export_file(doc, &[*f], &setting, 2.0).unwrap(); + files.push((format!("{name}-{}.{}", f.0, format.ext()), bytes)); + } + } + (t.elapsed().as_secs_f64() * 1000.0, files) +} + +#[test] +#[ignore = "timing: run in release with --ignored --nocapture"] +fn export_timings() { + let runs: usize = std::env::var("FIGCRAFT_EXPORT_BENCH_RUNS").ok().and_then(|v| v.parse().ok()).unwrap_or(7).max(1); + let formats = std::env::var("FIGCRAFT_EXPORT_BENCH_FORMATS").unwrap_or_else(|_| "png,jpg,webp".into()); + let out = std::env::var("FIGCRAFT_EXPORT_BENCH_OUT").ok(); + let docs = examples(); + let frames: usize = docs.iter().map(|d| d.2.len()).sum(); + println!("{} documents, {frames} frames, {runs} runs", docs.len()); + let mut total = vec![0.0; runs]; + for name in formats.split(',').map(str::trim).filter(|f| !f.is_empty()) { + let fmt = format(name); + let _ = run(&docs, fmt); + let mut times = Vec::with_capacity(runs); + let mut last = Vec::new(); + for t in &mut total { + let (ms, files) = run(&docs, fmt); + *t += ms; + times.push(ms); + last = files; + } + if let Some(dir) = &out { + std::fs::create_dir_all(dir).unwrap(); + for (file, bytes) in &last { + std::fs::write(std::path::Path::new(dir).join(file), bytes).unwrap(); + } + } + report(name, &mut times); + } + report("total", &mut total); +} + +/// Print the median and minimum of `times` (and every run). +fn report(name: &str, times: &mut [f64]) { + let runs: Vec = times.iter().map(|t| format!("{t:.1}")).collect(); + times.sort_by(f64::total_cmp); + let median = times[times.len() / 2]; + println!("{name:>6}: median {median:8.1} ms min {:8.1} ms runs [{}]", times[0], runs.join(", ")); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 60f240cb..ea135ca1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -256,6 +256,8 @@ mod effects_tests; #[cfg(test)] mod export_behaviour_tests; #[cfg(test)] +mod export_bench_tests; +#[cfg(test)] mod export_depth_tests; #[cfg(test)] mod extended_chain_tests; diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 5d65667c..fd5f3764 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -21,53 +21,107 @@ fn box_sizes(sigma: f64) -> [usize; 3] { [r(0), r(1), r(2)] } -fn box_blur_h(src: &[u8], dst: &mut [u8], w: usize, h: usize, r: usize) { +/// Pixmaps from this many pixels blur and transpose in parallel (desktop). +#[cfg(not(target_arch = "wasm32"))] +const PARALLEL_PIXELS: usize = 1 << 16; + +/// Whether `pixels` go across rayon: large pixmaps, and only from outside its pool. A blur on a +/// pool thread (a band of `render_view`) is already one of several running in parallel. +#[cfg(not(target_arch = "wasm32"))] +fn parallel(pixels: usize) -> bool { + pixels >= PARALLEL_PIXELS && rayon::current_thread_index().is_none() +} + +/// Rows (or transposed columns) per parallel job. +const ROWS_PER_JOB: usize = 16; + +/// Run `job` on matching chunks of ROWS_PER_JOB rows of `src` and `dst` (`w` pixels each), in +/// parallel for large pixmaps on desktop (see [`parallel`]). +fn row_jobs(src: &[[u8; 4]], dst: &mut [[u8; 4]], w: usize, job: impl Fn(&[[u8; 4]], &mut [[u8; 4]]) + Sync + Send) { + let n = w.saturating_mul(ROWS_PER_JOB).max(1); + #[cfg(not(target_arch = "wasm32"))] + if parallel(dst.len()) { + use rayon::prelude::*; + src.par_chunks(n).zip(dst.par_chunks_mut(n)).for_each(|(s, d)| job(s, d)); + return; + } + src.chunks(n).zip(dst.chunks_mut(n)).for_each(|(s, d)| job(s, d)); +} + +/// One row box-blurred by radius `r` (transparent past its ends) into `dst`, the same length. +fn box_blur_row(src: &[[u8; 4]], dst: &mut [[u8; 4]], r: usize) { if r == 0 { dst.copy_from_slice(src); return; } let div = (2 * r + 1) as u32; - for y in 0..h { - let row = y * w * 4; - let mut acc = [0u32; 4]; - // Edge: transparent outside. - for x in 0..=r.min(w.saturating_sub(1)) { - for c in 0..4 { - acc[c] += u32::from(src.get(row + x * 4 + c).copied().unwrap_or(0)); + let mut acc = [0u32; 4]; + for p in src.iter().take(r + 1) { + for (a, c) in acc.iter_mut().zip(p) { + *a += u32::from(*c); + } + } + for (x, d) in dst.iter_mut().enumerate() { + *d = acc.map(|a| (a / div) as u8); + if let Some(p) = src.get(x + r + 1) { + for (a, c) in acc.iter_mut().zip(p) { + *a += u32::from(*c); } } - for x in 0..w { - for c in 0..4 { - if let Some(d) = dst.get_mut(row + x * 4 + c) { - *d = (acc[c] / div) as u8; - } - } - let add = x + r + 1; - if add < w { - for c in 0..4 { - acc[c] += u32::from(src.get(row + add * 4 + c).copied().unwrap_or(0)); - } - } - if x >= r { - let sub = x - r; - for c in 0..4 { - acc[c] = acc[c].saturating_sub(u32::from(src.get(row + sub * 4 + c).copied().unwrap_or(0))); - } + if let Some(p) = x.checked_sub(r).and_then(|i| src.get(i)) { + for (a, c) in acc.iter_mut().zip(p) { + *a = a.saturating_sub(u32::from(*c)); } } } } -fn transpose(src: &[u8], dst: &mut [u8], w: usize, h: usize) { - for y in 0..h { - for x in 0..w { - let s = (y * w + x) * 4; - let d = (x * h + y) * 4; - if let (Some(sp), Some(dp)) = (src.get(s..s + 4), dst.get_mut(d..d + 4)) { - dp.copy_from_slice(sp); +/// Box-blur each `w`-pixel row of `src` by each of `radii` in turn into `dst`. A row is blurred +/// through all passes while it is in cache; a transparent row stays transparent. +fn box_blur_rows(src: &[[u8; 4]], dst: &mut [[u8; 4]], w: usize, radii: [usize; 3]) { + if w == 0 { + return; + } + row_jobs(src, dst, w, |s, d| { + let mut t = vec![[0u8; 4]; w]; + for (s, d) in s.chunks(w).zip(d.chunks_mut(w)) { + if s.iter().all(|p| *p == [0; 4]) { + d.fill([0; 4]); + continue; + } + let t = t.get_mut(..s.len()).unwrap_or_default(); + box_blur_row(s, d, radii[0]); + box_blur_row(d, t, radii[1]); + box_blur_row(t, d, radii[2]); + } + }); +} + +/// `src` (`w` × `h` pixels) transposed into `dst` (`h` × `w`), a block of columns at a time. +fn transpose(src: &[[u8; 4]], dst: &mut [[u8; 4]], w: usize, h: usize) { + if w == 0 || h == 0 { + return; + } + // Job `i` writes rows `x0..` of `dst` (ROWS_PER_JOB of them): columns `x0..` of `src`. + let job = |(i, d): (usize, &mut [[u8; 4]])| { + let (x0, cols) = (i * ROWS_PER_JOB, d.len() / h); + for (y, row) in src.chunks(w).enumerate() { + let Some(part) = row.get(x0..x0 + cols) else { continue }; + for (k, p) in part.iter().enumerate() { + if let Some(o) = d.get_mut(k * h + y) { + *o = *p; + } } } + }; + let n = h * ROWS_PER_JOB; + #[cfg(not(target_arch = "wasm32"))] + if parallel(dst.len()) { + use rayon::prelude::*; + dst.par_chunks_mut(n).enumerate().for_each(job); + return; } + dst.chunks_mut(n).enumerate().for_each(job); } /// The largest blur standard deviation drawn (pixels): far wider than any layer (at most 16,384 @@ -76,7 +130,8 @@ fn transpose(src: &[u8], dst: &mut [u8], w: usize, h: usize) { /// in full at any zoom. pub const MAX_SIGMA: f64 = 1_000_000.0; -/// Gaussian-like blur of the whole pixmap with standard deviation `sigma` (pixels). +/// Gaussian-like blur of the whole pixmap with standard deviation `sigma` (pixels): three box +/// passes along the rows, then three down the columns (as rows of the transposed pixmap). pub fn blur(p: &mut Pixmap, sigma: f64) { if !(sigma.is_finite() && sigma > 0.3) { return; @@ -84,20 +139,49 @@ pub fn blur(p: &mut Pixmap, sigma: f64) { let sigma = sigma.min(MAX_SIGMA); let (w, h) = (p.width() as usize, p.height() as usize); let radii = box_sizes(sigma); - let mut a = p.data().to_vec(); - let mut b = vec![0u8; a.len()]; - for r in radii { - box_blur_h(&a, &mut b, w, h, r); - std::mem::swap(&mut a, &mut b); - } + let n = p.data().len() / 4; + let mut a = vec![[0u8; 4]; n]; + let mut b = vec![[0u8; 4]; n]; + box_blur_rows(p.data().as_chunks::<4>().0, &mut a, w, radii); transpose(&a, &mut b, w, h); - std::mem::swap(&mut a, &mut b); - for r in radii { - box_blur_h(&a, &mut b, h, w, r); - std::mem::swap(&mut a, &mut b); + box_blur_rows(&b, &mut a, h, radii); + transpose(&a, p.data_mut().as_chunks_mut::<4>().0, h, w); +} + +/// For each `x`, the largest (`grow`) or smallest value of `src` over `x - r ..= x + r` (clipped +/// to its ends) into `out`, in constant time per value whatever `r` (van Herk / Gil-Werman: the +/// window spans at most two blocks of `2r + 1`, from the running extremes within each block). +/// `scratch` holds the working lines. +fn window_extremes(src: &[u8], out: &mut [u8], r: usize, grow: bool, scratch: &mut [Vec; 3]) { + let op = |a: u8, b: u8| if grow { a.max(b) } else { a.min(b) }; + // Past the ends: the value that changes nothing (the window is clipped there). + let id = if grow { 0 } else { 255 }; + let [pad, fwd, back] = scratch; + pad.clear(); + pad.resize(r, id); + pad.extend_from_slice(src); + pad.resize(src.len() + 2 * r, id); + let k = 2 * r + 1; + fwd.clear(); + for block in pad.chunks(k) { + let mut m = id; + fwd.extend(block.iter().map(|v| { + m = op(m, *v); + m + })); + } + back.clear(); + back.resize(pad.len(), id); + for (block, b) in pad.chunks(k).zip(back.chunks_mut(k)) { + let mut m = id; + for (v, o) in block.iter().zip(b.iter_mut()).rev() { + m = op(m, *v); + *o = m; + } + } + for (x, o) in out.iter_mut().enumerate() { + *o = op(back.get(x).copied().unwrap_or(id), fwd.get(x + 2 * r).copied().unwrap_or(id)); } - transpose(&a, &mut b, h, w); - p.data_mut().copy_from_slice(&b); } /// Grow (`r > 0`) or shrink (`r < 0`) the alpha of a pixmap by `r` pixels (square structuring @@ -109,47 +193,38 @@ pub fn morph(p: &mut Pixmap, r: i32) { let grow = r > 0; let r = r.unsigned_abs().min(400) as usize; let (w, h) = (p.width() as usize, p.height() as usize); - let alpha: Vec = p.data().as_chunks::<4>().0.iter().map(|c| c.get(3).copied().unwrap_or(0)).collect(); - let pass = |src: &[u8], w: usize, h: usize, horizontal: bool| -> Vec { - let mut out = vec![0u8; src.len()]; - for y in 0..h { - for x in 0..w { - let mut v: u8 = if grow { 0 } else { 255 }; - let (lo, hi) = (x.saturating_sub(r), (x + r).min(w.saturating_sub(1))); - for k in lo..=hi { - let idx = if horizontal { y * w + k } else { k * h + y }; - let s = src.get(idx).copied().unwrap_or(0); - v = if grow { v.max(s) } else { v.min(s) }; - } - // Shrinking treats outside as empty. - if !grow && (x < r || x + r >= w) { - v = 0; - } - let o = if horizontal { y * w + x } else { x * h + y }; - if let Some(d) = out.get_mut(o) { - *d = v; + if w == 0 || h == 0 { + return; + } + let alpha: Vec = p.data().as_chunks::<4>().0.iter().map(|c| c[3]).collect(); + let mut scratch: [Vec; 3] = Default::default(); + // Shrinking treats outside as empty: the first and last `r` of each line go. + let edges = |line: &mut [u8]| { + if !grow { + let n = line.len(); + for (i, v) in line.iter_mut().enumerate() { + if i < r || i + r >= n { + *v = 0; } } } - out }; - let a1 = pass(&alpha, w, h, true); - // Vertical pass: treat columns as rows by swapping roles. + let mut a1 = vec![0u8; alpha.len()]; + for (s, o) in alpha.chunks(w).zip(a1.chunks_mut(w)) { + window_extremes(s, o, r, grow, &mut scratch); + edges(o); + } + // Down the columns. + let (mut col, mut out) = (vec![0u8; h], vec![0u8; h]); let mut a2 = vec![0u8; a1.len()]; for x in 0..w { - for y in 0..h { - let mut v: u8 = if grow { 0 } else { 255 }; - let (lo, hi) = (y.saturating_sub(r), (y + r).min(h.saturating_sub(1))); - for k in lo..=hi { - let s = a1.get(k * w + x).copied().unwrap_or(0); - v = if grow { v.max(s) } else { v.min(s) }; - } - if !grow && (y < r || y + r >= h) { - v = 0; - } - if let Some(d) = a2.get_mut(y * w + x) { - *d = v; - } + for (c, v) in col.iter_mut().zip(a1.iter().skip(x).step_by(w)) { + *c = *v; + } + window_extremes(&col, &mut out, r, grow, &mut scratch); + edges(&mut out); + for (d, v) in a2.iter_mut().skip(x).step_by(w).zip(&out) { + *d = *v; } } for (px, a) in p.data_mut().as_chunks_mut::<4>().0.iter_mut().zip(a2) { @@ -236,6 +311,181 @@ pub fn plus_darker(dst: &mut Pixmap, at: (i32, i32), src: &Pixmap, opacity: f32) mod tests { use super::*; + /// The blur as first written (one box pass at a time over the whole pixmap, bounds-checked + /// byte by byte): the faster one must give the same bytes. + fn reference_blur(p: &mut Pixmap, sigma: f64) { + fn box_blur_h(src: &[u8], dst: &mut [u8], w: usize, h: usize, r: usize) { + if r == 0 { + dst.copy_from_slice(src); + return; + } + let div = (2 * r + 1) as u32; + for y in 0..h { + let row = y * w * 4; + let mut acc = [0u32; 4]; + for x in 0..=r.min(w.saturating_sub(1)) { + for c in 0..4 { + acc[c] += u32::from(src.get(row + x * 4 + c).copied().unwrap_or(0)); + } + } + for x in 0..w { + for c in 0..4 { + if let Some(d) = dst.get_mut(row + x * 4 + c) { + *d = (acc[c] / div) as u8; + } + } + let add = x + r + 1; + if add < w { + for c in 0..4 { + acc[c] += u32::from(src.get(row + add * 4 + c).copied().unwrap_or(0)); + } + } + if x >= r { + let sub = x - r; + for c in 0..4 { + acc[c] = acc[c].saturating_sub(u32::from(src.get(row + sub * 4 + c).copied().unwrap_or(0))); + } + } + } + } + } + fn transpose(src: &[u8], dst: &mut [u8], w: usize, h: usize) { + for y in 0..h { + for x in 0..w { + let (s, d) = ((y * w + x) * 4, (x * h + y) * 4); + dst[d..d + 4].copy_from_slice(&src[s..s + 4]); + } + } + } + if !(sigma.is_finite() && sigma > 0.3) { + return; + } + let (w, h) = (p.width() as usize, p.height() as usize); + let radii = box_sizes(sigma.min(MAX_SIGMA)); + let mut a = p.data().to_vec(); + let mut b = vec![0u8; a.len()]; + for r in radii { + box_blur_h(&a, &mut b, w, h, r); + std::mem::swap(&mut a, &mut b); + } + transpose(&a, &mut b, w, h); + std::mem::swap(&mut a, &mut b); + for r in radii { + box_blur_h(&a, &mut b, h, w, r); + std::mem::swap(&mut a, &mut b); + } + transpose(&a, &mut b, h, w); + p.data_mut().copy_from_slice(&b); + } + + /// The spread as first written (every pixel looks at its whole window). + fn reference_morph(p: &mut Pixmap, r: i32) { + if r == 0 { + return; + } + let grow = r > 0; + let r = r.unsigned_abs().min(400) as usize; + let (w, h) = (p.width() as usize, p.height() as usize); + let alpha: Vec = p.data().chunks(4).map(|c| c[3]).collect(); + let pick = |v: u8, s: u8| if grow { v.max(s) } else { v.min(s) }; + let mut a1 = vec![0u8; alpha.len()]; + for y in 0..h { + for x in 0..w { + let mut v: u8 = if grow { 0 } else { 255 }; + for k in x.saturating_sub(r)..=(x + r).min(w - 1) { + v = pick(v, alpha[y * w + k]); + } + a1[y * w + x] = if !grow && (x < r || x + r >= w) { 0 } else { v }; + } + } + let mut a2 = vec![0u8; a1.len()]; + for x in 0..w { + for y in 0..h { + let mut v: u8 = if grow { 0 } else { 255 }; + for k in y.saturating_sub(r)..=(y + r).min(h - 1) { + v = pick(v, a1[k * w + x]); + } + a2[y * w + x] = if !grow && (y < r || y + r >= h) { 0 } else { v }; + } + } + for (px, a) in p.data_mut().chunks_mut(4).zip(a2) { + px.copy_from_slice(&[a, a, a, a]); + } + } + + /// A `w` × `h` pixmap of premultiplied noise in a few rectangles, transparent elsewhere (whole + /// transparent rows and columns, as layers have around their content). + fn noise(w: u32, h: u32, seed: u32) -> Pixmap { + let mut p = Pixmap::new(w, h).unwrap(); + let mut s = seed.wrapping_mul(2_654_435_761).max(1); + let mut next = || { + s ^= s << 13; + s ^= s >> 17; + s ^= s << 5; + s + }; + let (wu, hu) = (w as usize, h as usize); + for (x0, y0, x1, y1) in [(wu / 8, hu / 6, wu / 2, hu / 2), (wu / 3, hu / 2, wu.saturating_sub(3), hu * 5 / 6), (0, 0, 2, hu)] { + for y in y0..y1.min(hu) { + for x in x0..x1.min(wu) { + let a = (next() % 256) as u8; + let c = |v: u32| ((v % 256) * u32::from(a) / 255) as u8; + let i = (y * wu + x) * 4; + p.data_mut()[i..i + 4].copy_from_slice(&[c(next()), c(next()), c(next()), a]); + } + } + } + p + } + + #[test] + fn blur_matches_the_reference_bytes() { + // Small (one thread), wide, tall and large enough to blur in parallel. + for (i, (w, h)) in [(1, 1), (7, 3), (40, 31), (301, 17), (13, 290), (300, 230)].into_iter().enumerate() { + for sigma in [0.2, 0.5, 1.0, 2.5, 7.3, 24.0, 160.0, 5000.0] { + let mut fast = noise(w, h, i as u32 + 1); + let mut slow = fast.clone(); + blur(&mut fast, sigma); + reference_blur(&mut slow, sigma); + assert!(fast.data() == slow.data(), "{w}×{h} sigma {sigma}"); + } + } + } + + /// On a rayon pool thread (a band of a parallel render) the blur stays on that thread and + /// gives the same bytes. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn blur_on_a_pool_thread_matches_the_reference_bytes() { + let (fast, slow) = rayon::join( + || { + assert!(rayon::current_thread_index().is_some()); + let mut p = noise(300, 230, 3); + blur(&mut p, 9.5); + p + }, + || { + let mut p = noise(300, 230, 3); + reference_blur(&mut p, 9.5); + p + }, + ); + assert!(fast.data() == slow.data()); + } + + #[test] + fn morph_matches_the_reference_bytes() { + for (i, (w, h)) in [(1, 1), (5, 9), (40, 31), (301, 17), (13, 290), (300, 230)].into_iter().enumerate() { + for r in [-500, -40, -6, -2, -1, 1, 2, 5, 33, 400, 1000] { + let mut fast = noise(w, h, i as u32 + 7); + let mut slow = fast.clone(); + morph(&mut fast, r); + reference_morph(&mut slow, r); + assert!(fast.data() == slow.data(), "{w}×{h} r {r}"); + } + } + } + #[test] fn blur_spreads_and_conserves() { let mut p = Pixmap::new(64, 64).unwrap(); From 738b21883788de8183664c6f25d5616d4bfce812 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 19:08:54 +0000 Subject: [PATCH 07/33] fix(installer): check the exact architecture-specific machine folder The pre-signing check matched any folder named Program Files, so it rejected a valid MSI when Windows resolves Program Files elsewhere, accepted the 64-bit root for an x86 package and accepted a different app name. Build the expected folder from the package architecture's resolved root and the policy name. --- packaging/windows/check-install-policy.ps1 | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 index a4a14bc6..cc9416e9 100644 --- a/packaging/windows/check-install-policy.ps1 +++ b/packaging/windows/check-install-policy.ps1 @@ -32,6 +32,12 @@ function Set-SessionProperty($Session, [string] $Name, [string] $Value) { function Get-SessionProperty($Session, [string] $Name) { $Session.GetType().InvokeMember('Property', 'GetProperty', $null, $Session, @($Name)) } +function Get-PackageTemplate($Database) { + # Summary information property 7 is the platform: "Intel;1033", "x64;1033" or "Arm64;1033". + $summary = $Database.GetType().InvokeMember('SummaryInformation', 'GetProperty', $null, $Database, @(0)) + try { [string]$summary.GetType().InvokeMember('Property', 'GetProperty', $null, $summary, @(7)) } + finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($summary) } +} $installer = $null; $database = $null; $session = $null try { $installer = New-Object -ComObject WindowsInstaller.Installer @@ -150,6 +156,12 @@ try { if (@(Read-Rows $database 'SELECT `Action` FROM `InstallExecuteSequence`' 1 | Where-Object { $_[0] -like 'CraftDefault*' }).Count) { throw 'Silent installs must not use the wizard-only default folder actions.' } + # The machine default comes from the environment of the package architecture, not from a folder name. + # x86 packages use Program Files (x86) on 64-bit Windows; x64 and Arm64 packages use the 64-bit root. + $machineRoot = if ((Get-PackageTemplate $database) -match '^Intel') { + if (Get-SessionProperty $session 'VersionNT64') { ${env:ProgramFiles(x86)} } else { $env:ProgramFiles } + } elseif ($env:ProgramW6432) { $env:ProgramW6432 } else { $env:ProgramFiles } + $machineFolder = (Join-Path $machineRoot "ArtCraft\$($policy.name)") + '\' foreach ($scope in @('1', '')) { Set-SessionProperty $session 'ALLUSERS' $scope Set-SessionProperty $session 'INSTALLFOLDER' '' @@ -159,8 +171,8 @@ try { if ($session.EvaluateCondition($row[2]) -eq 1 -and $session.DoAction($row[0]) -ne 1) { throw "$($row[0]) failed." } } $folder = Get-SessionProperty $session 'INSTALLFOLDER' - $wanted = if ($scope -eq '1') { '^[A-Za-z]:\\Program Files( \(x86\))?\\ArtCraft\\[^\\]+\\$' } else { '^' + [regex]::Escape($env:LOCALAPPDATA) + '\\Programs\\ArtCraft\\[^\\]+\\$' } - if ($folder -notmatch $wanted) { throw "Wizard default folder for ALLUSERS='$scope' is '$folder'." } + $wanted = if ($scope -eq '1') { $machineFolder } else { (Join-Path $env:LOCALAPPDATA "Programs\ArtCraft\$($policy.name)") + '\' } + if ($folder -ine $wanted) { throw "Wizard default folder for ALLUSERS='$scope' is '$folder', expected '$wanted'." } } $execute = @{} foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] } From 2ed7aee28d9f365232e1e26db105f2546abf351e Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 27 Oct 2026 20:51:59 +0000 Subject: [PATCH 08/33] ci: use storytold/craft-common-actions at the merged installer-policy commit --- .github/workflows/windows-arm64.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 00011442..267479eb 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -27,7 +27,7 @@ permissions: jobs: windows-arm64: # Immutable supporting workflow revision until the new install contract lands upstream. - uses: Jeremy8776/craft-common-actions/.github/workflows/windows-arm64.yml@f4666d5cbf35f9bdeda24f1f88c7fb5c6adf5276 + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f with: app-name: figcraft app-dir: FigCraft From c420613efaa31e073a6f546e5cc71a2ba7e50ce6 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 26 Oct 2026 20:09:30 -0400 Subject: [PATCH 09/33] chore(clippy): enforce strict workspace lint policy --- Cargo.toml | 3 +++ clippy.toml | 1 + 2 files changed, 4 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index fd2fbb81..84ed164b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -102,6 +102,9 @@ webbrowser = "1" unsafe_code = "forbid" [workspace.lints.clippy] +cognitive_complexity = "deny" +too_many_arguments = "deny" +wildcard_imports = "deny" dbg_macro = "warn" # Style lints from newer toolchains that fight index-heavy pixel code. needless_range_loop = "allow" diff --git a/clippy.toml b/clippy.toml index 36cc87a4..305f94bd 100644 --- a/clippy.toml +++ b/clippy.toml @@ -7,3 +7,4 @@ too-many-arguments-threshold = 10 disallowed-types = [ { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" }, ] +cognitive-complexity-threshold = 30 From e1a223bb1f9e5176ad3384c101cc370904f21a20 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 11:33:22 +0000 Subject: [PATCH 10/33] refactor(layout): split flow layout phases --- crates/layout/src/lib.rs | 273 ++++++++++++++++++++++----------------- 1 file changed, 157 insertions(+), 116 deletions(-) diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 02e7bdaf..468a5536 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -333,6 +333,154 @@ struct Item { base_off: f64, } +/// Split flow items into lines, preserving a single empty line when wrapping is off. +fn flow_lines(items: &[Item], wraps: bool, gap: f64, inner_main: f64) -> Vec> { + if !wraps { + return vec![(0..items.len()).collect()]; + } + let mut lines = Vec::new(); + let mut current = Vec::new(); + let mut used = 0.0; + for (i, item) in items.iter().enumerate() { + let size = if item.fill_main { item.min_main.unwrap_or(0.0) } else { item.main }; + let added = if current.is_empty() { size } else { gap.max(0.0) + size }; + if !current.is_empty() && used + added > inner_main + 1e-6 { + lines.push(std::mem::take(&mut current)); + used = size; + } else { + used += added; + } + current.push(i); + } + if !current.is_empty() { + lines.push(current); + } + lines +} + +/// Resolve each line's main extent and baseline-adjusted cross extent. +fn flow_line_extents(items: &mut [Item], lines: &[Vec], gap: f64) -> Vec<(f64, f64)> { + let mut extents = Vec::with_capacity(lines.len()); + for line in lines { + let gaps = gap * line.len().saturating_sub(1) as f64; + let line_base = line.iter().filter_map(|i| items.get(*i)).filter_map(|item| item.baseline).fold(0.0, f64::max); + for i in line { + if let Some(item) = items.get_mut(*i) { + item.base_off = item.baseline.map(|baseline| line_base - baseline).unwrap_or(0.0); + } + } + let in_line = || line.iter().filter_map(|i| items.get(*i)); + let main_used = in_line().map(|item| item.main).sum::() + gaps; + let all_fill = in_line().all(|item| item.fill_cross); + let cross = in_line().filter(|item| all_fill || !item.fill_cross).map(|item| item.base_off + item.cross).fold(0.0, f64::max); + extents.push((main_used, cross)); + } + extents +} + +/// Resolve cross-axis line spacing and alignment before positioning the flow items. +fn align_flow_lines(items: &[Item], extents: &mut [(f64, f64)], al: &AutoLayout, inner_cross: f64, cross_gap: f64) -> (f64, f64) { + if extents.len() <= 1 { + return (0.0, cross_gap); + } + let lines_cross: f64 = extents.iter().map(|line| line.1).sum(); + let between = (extents.len() - 1) as f64; + let spread = (inner_cross - lines_cross) / between; + if al.content_align == ContentAlign::SpaceBetween && spread > 0.0 { + return (0.0, spread); + } + let cross_gap = if al.content_align == ContentAlign::SpaceBetween { 0.0 } else { cross_gap }; + let free = inner_cross - lines_cross - cross_gap * between; + if free > 0.0 && items.iter().all(|item| item.fill_cross) { + let extra = free / extents.len() as f64; + extents.iter_mut().for_each(|line| line.1 += extra); + return (0.0, cross_gap); + } + let offset = match al.cross_align { + CrossAlign::Min | CrossAlign::Baseline => 0.0, + CrossAlign::Center => free / 2.0, + CrossAlign::Max => free, + }; + (offset, cross_gap) +} + +/// Collect the size and sizing constraints of the children in flow order. +fn flow_items(doc: &Document, kids: &[NodeId], horizontal: bool, hug_main: bool, hug_cross: bool) -> Vec { + let mut items = Vec::with_capacity(kids.len()); + for child in kids { + let Some(node) = doc.node(*child) else { continue }; + let (w, h) = outer_size(doc, *child); + let cl = &node.child_layout; + let (sm, sc) = if horizontal { (cl.sizing_h, cl.sizing_v) } else { (cl.sizing_v, cl.sizing_h) }; + let (min_main, max_main, min_cross, max_cross) = + if horizontal { (cl.min_w, cl.max_w, cl.min_h, cl.max_h) } else { (cl.min_h, cl.max_h, cl.min_w, cl.max_w) }; + items.push(Item { + id: *child, + main: if horizontal { w } else { h }, + cross: if horizontal { h } else { w }, + // Fill needs room to fill: on an axis the frame hugs it reads as fixed. + fill_main: sm == Sizing::Fill && !hug_main, + fill_cross: sc == Sizing::Fill && !hug_cross, + min_main, + max_main, + min_cross, + max_cross, + baseline: None, + base_off: 0.0, + }); + } + items +} + +/// Position one resolved flow line, resizing wrapped fill children across the line. +fn place_flow_line(doc: &mut Document, items: &[Item], line: &[usize], al: &AutoLayout, m: &dyn Measure, geometry: FlowLineGeometry) { + let FlowLineGeometry { horizontal, wraps, main_start, inner_main, cross_pos, line_cross, used, gap } = geometry; + let total = used - gap * line.len().saturating_sub(1) as f64; + let (mut pos, step_gap) = main_offsets(al.main_align, inner_main, total, line.len(), gap); + pos += main_start; + for i in line { + let Some(it) = items.get(*i) else { continue }; + let cross_size = if it.fill_cross { clamp_size(line_cross, it.min_cross, it.max_cross) } else { it.cross }; + let off = match al.cross_align { + CrossAlign::Min => 0.0, + // Baseline applies to horizontal layouts; elsewhere it reads as top/left. + CrossAlign::Baseline => it.base_off, + CrossAlign::Center => (line_cross - cross_size) / 2.0, + CrossAlign::Max => line_cross - cross_size, + }; + if it.fill_cross && wraps { + let (w, h) = if horizontal { (it.main, cross_size) } else { (cross_size, it.main) }; + resize_child(doc, it.id, w, h, m); + } + let (x, y) = if horizontal { (pos, cross_pos + off) } else { (cross_pos + off, pos) }; + place(doc, it.id, x, y); + pos += it.main + step_gap; + } +} + +struct FlowLineGeometry { + horizontal: bool, + wraps: bool, + main_start: f64, + inner_main: f64, + cross_pos: f64, + line_cross: f64, + used: f64, + gap: f64, +} + +/// Distribute a line's remaining main-axis space among its fill children. +fn distribute_flow_lines(items: &mut [Item], lines: &[Vec], inner_main: f64, gap: f64, auto_gap: bool) { + for line in lines { + let fixed: f64 = line.iter().filter_map(|i| items.get(*i)).filter(|item| !item.fill_main).map(|item| item.main).sum(); + let gaps = gap * line.len().saturating_sub(1) as f64; + let fills: Vec = line.iter().copied().filter(|i| items.get(*i).is_some_and(|item| item.fill_main)).collect(); + if !fills.is_empty() { + distribute(items, fills, inner_main - fixed - if auto_gap { 0.0 } else { gaps }); + } + } +} + /// Lay out one auto layout frame's children and size the frame if it hugs. pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) { let Some(_nested) = Nested::enter() else { return }; @@ -364,29 +512,7 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) { let hug_cross = if horizontal { hug_h } else { hug_w }; let baseline = horizontal && al.cross_align == CrossAlign::Baseline; - let mut items: Vec = Vec::with_capacity(kids.len()); - for c in &kids { - let Some(n) = doc.node(*c) else { continue }; - let (w, h) = outer_size(doc, *c); - let cl = &n.child_layout; - let (sm, sc) = if horizontal { (cl.sizing_h, cl.sizing_v) } else { (cl.sizing_v, cl.sizing_h) }; - let (min_main, max_main, min_cross, max_cross) = - if horizontal { (cl.min_w, cl.max_w, cl.min_h, cl.max_h) } else { (cl.min_h, cl.max_h, cl.min_w, cl.max_w) }; - items.push(Item { - id: *c, - main: if horizontal { w } else { h }, - cross: if horizontal { h } else { w }, - // Fill needs room to fill: on an axis the frame hugs it reads as fixed. - fill_main: sm == Sizing::Fill && !hug_main, - fill_cross: sc == Sizing::Fill && !hug_cross, - min_main, - max_main, - min_cross, - max_cross, - baseline: None, - base_off: 0.0, - }); - } + let mut items = flow_items(doc, &kids, horizontal, hug_main, hug_cross); let gap = finite_or_zero(al.gap); let auto_gap = al.main_align.is_auto_gap(); @@ -405,37 +531,10 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) { } // Split into lines along the main axis (rows of a horizontal layout, columns of a vertical one). - let mut lines: Vec> = Vec::new(); - if wraps { - let mut cur: Vec = Vec::new(); - let mut used = 0.0; - for (i, it) in items.iter().enumerate() { - let sz = if it.fill_main { it.min_main.unwrap_or(0.0) } else { it.main }; - let add = if cur.is_empty() { sz } else { gap.max(0.0) + sz }; - if !cur.is_empty() && used + add > inner_main_fixed + 1e-6 { - lines.push(std::mem::take(&mut cur)); - used = sz; - } else { - used += add; - } - cur.push(i); - } - if !cur.is_empty() { - lines.push(cur); - } - } else { - lines.push((0..items.len()).collect()); - } + let lines = flow_lines(&items, wraps, gap, inner_main_fixed); // Share each line's free space among its fill children. - for line in &lines { - let fixed: f64 = line.iter().filter_map(|i| items.get(*i)).filter(|it| !it.fill_main).map(|it| it.main).sum(); - let gaps = gap * line.len().saturating_sub(1) as f64; - let fills: Vec = line.iter().copied().filter(|i| items.get(*i).is_some_and(|it| it.fill_main)).collect(); - if !fills.is_empty() { - distribute(&mut items, fills, inner_main_fixed - fixed - if auto_gap { 0.0 } else { gaps }); - } - } + distribute_flow_lines(&mut items, &lines, inner_main_fixed, gap, auto_gap); // Fill children take their main size now, so their cross size (wrapped text, a hugging frame) // is final before the lines and the frame are sized. for it in items.iter_mut().filter(|it| it.fill_main) { @@ -452,23 +551,7 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) { } // Line extents: (main used, cross size). - let mut line_extents: Vec<(f64, f64)> = Vec::new(); - for line in &lines { - let gaps = gap * line.len().saturating_sub(1) as f64; - // Baseline alignment: the line's baseline sits at the deepest first baseline. - let line_base = line.iter().filter_map(|i| items.get(*i)).filter_map(|it| it.baseline).fold(0.0, f64::max); - for i in line { - if let Some(it) = items.get_mut(*i) { - it.base_off = it.baseline.map(|b| line_base - b).unwrap_or(0.0); - } - } - let in_line = || line.iter().filter_map(|i| items.get(*i)); - let main_used: f64 = in_line().map(|it| it.main).sum::() + gaps; - // A line of nothing but fill-across children keeps their own size. - let all_fill = in_line().all(|it| it.fill_cross); - let cross: f64 = in_line().filter(|it| all_fill || !it.fill_cross).map(|it| it.base_off + it.cross).fold(0.0, f64::max); - line_extents.push((main_used, cross)); - } + let mut line_extents = flow_line_extents(&mut items, &lines, gap); // Container size (on a hugging axis fill children can't fill: they count with their own size). let content_main = line_extents.iter().map(|l| l.0).fold(0.0, f64::max); @@ -500,57 +583,15 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) { // Position. Several lines share the free space across the flow: packed lines sit where the // cross alignment says (lines of nothing but fill children stretch over it instead), or the // space goes between them. - let mut cross_pos = if horizontal { pt } else { pl }; + let (cross_offset, cross_gap) = align_flow_lines(&items, &mut line_extents, &al, inner_cross, cross_gap); + let mut cross_pos = (if horizontal { pt } else { pl }) + cross_offset; let single = lines.len() == 1; - let mut cross_gap = cross_gap; - if lines.len() > 1 { - let lines_cross: f64 = line_extents.iter().map(|l| l.1).sum(); - let between = (lines.len() - 1) as f64; - let spread = (inner_cross - lines_cross) / between; - if al.content_align == ContentAlign::SpaceBetween && spread > 0.0 { - cross_gap = spread; - } else { - if al.content_align == ContentAlign::SpaceBetween { - cross_gap = 0.0; - } - let free = inner_cross - lines_cross - cross_gap * between; - if free > 0.0 && items.iter().all(|it| it.fill_cross) { - let extra = free / lines.len() as f64; - line_extents.iter_mut().for_each(|l| l.1 += extra); - } else { - cross_pos += match al.cross_align { - CrossAlign::Min | CrossAlign::Baseline => 0.0, - CrossAlign::Center => free / 2.0, - CrossAlign::Max => free, - }; - } - } - } for (li, line) in lines.iter().enumerate() { let (used, line_cross) = line_extents.get(li).copied().unwrap_or((0.0, 0.0)); let line_cross = if single { inner_cross } else { line_cross }; - let total = used - gap * line.len().saturating_sub(1) as f64; - let (mut pos, step_gap) = main_offsets(al.main_align, inner_main, total, line.len(), gap); - pos += if horizontal { pl } else { pt }; - for i in line { - let Some(it) = items.get(*i) else { continue }; - let cross_size = if it.fill_cross { clamp_size(line_cross, it.min_cross, it.max_cross) } else { it.cross }; - let off = match al.cross_align { - CrossAlign::Min => 0.0, - // Baseline applies to horizontal layouts; elsewhere it reads as top/left. - CrossAlign::Baseline => it.base_off, - CrossAlign::Center => (line_cross - cross_size) / 2.0, - CrossAlign::Max => line_cross - cross_size, - }; - // Fill across a wrapped line: its size is known only now. - if it.fill_cross && wraps { - let (w, h) = if horizontal { (it.main, cross_size) } else { (cross_size, it.main) }; - resize_child(doc, it.id, w, h, m); - } - let (x, y) = if horizontal { (pos, cross_pos + off) } else { (cross_pos + off, pos) }; - place(doc, it.id, x, y); - pos += it.main + step_gap; - } + let geometry = + FlowLineGeometry { horizontal, wraps, main_start: if horizontal { pl } else { pt }, inner_main, cross_pos, line_cross, used, gap }; + place_flow_line(doc, &items, line, &al, m, geometry); cross_pos += line_cross + cross_gap; } } From 10bb163626ba9a9cd3bd120aebcd4a11ac4a51bf Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 11:33:22 +0000 Subject: [PATCH 11/33] fix(xtask): apply workspace lint policy --- xtask/Cargo.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 59bfb3c9..1dbc3fc2 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -5,6 +5,9 @@ edition.workspace = true license.workspace = true publish = false +[lints] +workspace = true + [dependencies] serde_json = { workspace = true } figcraft-engine = { workspace = true } From 8591d44909e1f54e7d6d78549995fb8b49fc6140 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 12:07:26 +0000 Subject: [PATCH 12/33] refactor(doc): declare node property dependencies --- crates/doc/src/node.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 2a284b05..3146047a 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -1,7 +1,10 @@ //! Nodes: one struct with the properties every node can have, and a kind with per-type data. use crate::NodeId; -use crate::props::*; +use crate::props::{ + AutoLayout, ChildLayout, Constraints, Corners, Effect, EffectKind, ExportSetting, LayoutGrid, SPREAD_MITER_LIMIT, StrokeAlign, StrokeCap, + StrokeProps, +}; use crate::proto::{Interaction, Overflow, ScrollPosition}; use crate::styles::{CollectionId, ModeId, StyleRefs, VariableId}; use crate::text::TextData; From 99afdd99abee629136062d0f0a45f6b4056984fe Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 12:59:22 +0000 Subject: [PATCH 13/33] docs: document workspace lint inheritance --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index b2384311..fe6134a6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,6 +62,7 @@ code. - No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!` in shipped code (workspace clippy lints deny them; tests may use them). Return `Result`/`Option`; use `.get()` for data-derived indexes; slice strings only at char boundaries; check floats are finite; cap sizes. +- New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`; production crate roots must begin with the required crate-level lint attribute. - `figcraft_engine::guard` turns a panicking command into `EngineError::Internal` and the document is rolled back; the desktop app also guards each UI frame and writes recovery copies. These are safety nets, not substitutes for `Result`. From c618e402fa89a6af3552ed1fec33f03fafc46a66 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 14:00:36 +0000 Subject: [PATCH 14/33] refactor(format): replace wildcard imports --- crates/format/src/fig/export/assets.rs | 5 ++++- crates/format/src/fig/export/common.rs | 6 +++++- crates/format/src/fig/export/component.rs | 2 +- crates/format/src/fig/export/layer.rs | 6 +++++- crates/format/src/fig/export/paint.rs | 5 ++++- crates/format/src/fig/export/prototype.rs | 5 ++++- crates/format/src/fig/export/shape.rs | 6 +++++- crates/format/src/fig/kiwi.rs | 2 +- crates/format/src/fig/map/component.rs | 2 +- crates/format/src/fig/map/connector.rs | 2 +- crates/format/src/fig/map/document.rs | 2 +- crates/format/src/fig/map/layout.rs | 2 +- crates/format/src/fig/map/paint.rs | 2 +- crates/format/src/fig/map/proto.rs | 2 +- crates/format/src/fig/map/text.rs | 2 +- crates/format/src/fig/map/variables.rs | 2 +- 16 files changed, 37 insertions(+), 16 deletions(-) diff --git a/crates/format/src/fig/export/assets.rs b/crates/format/src/fig/export/assets.rs index 00c84639..bd269556 100644 --- a/crates/format/src/fig/export/assets.rs +++ b/crates/format/src/fig/export/assets.rs @@ -1,5 +1,8 @@ use super::common::{layout_grid, node_field, prop_type, resolved_type, scope_name, variant_pairs}; -use super::*; +use super::{ + AUX, Action, Affine, Bound, Color, Exporter, Fields, Kv, LAYERS, MAX_NODES, Node, NodeId, NodeKind, Overlay, OverlayPosition, Paint, StyleKind, + TextData, VariableValue, arr, b, color, e, fl, guid, matrix, o, position, root_fields, s, u, xy, +}; impl<'d> Exporter<'d> { /// GUIDs for every layer (pages first, then depth-first), component property definitions, diff --git a/crates/format/src/fig/export/common.rs b/crates/format/src/fig/export/common.rs index 2853dd72..4a97eecb 100644 --- a/crates/format/src/fig/export/common.rs +++ b/crates/format/src/fig/export/common.rs @@ -1,4 +1,8 @@ -use super::*; +use super::{ + Bound, ExportSize, Fields, GAMEPAD_BUTTONS, GridAlign, GridKind, KeyMods, Kv, LetterSpacing, LineHeight, MAX_NETWORK, Node, NodeKind, Paint, + PropDef, SCHEMA, TextCase, TextDecoration, TextStyle, VariableType, VectorNetwork, WindingRule, arr, b, color, e, face_style, fl, guid, int, o, + s, xy, +}; /// Longest kept description of such a layer (bytes of JSON). const MAX_STASH: usize = 1 << 20; diff --git a/crates/format/src/fig/export/component.rs b/crates/format/src/fig/export/component.rs index 7d88dcc8..2342386b 100644 --- a/crates/format/src/fig/export/component.rs +++ b/crates/format/src/fig/export/component.rs @@ -1,7 +1,7 @@ use super::common::{ bool_data, changed, prop_type, resolved_alike, scaled, sort_position, symbol_data, text_data, text_value, var_data, variant_pairs, }; -use super::*; +use super::{AUX, BTreeMap, Exporter, Fields, Kv, MAX_NODES, Node, NodeId, NodeKind, PropDef, Value, arr, b, e, fl, guid, o, s}; impl<'d> Exporter<'d> { /// A component property definition of `owner` (a component, or a set), `index`-th in its list. diff --git a/crates/format/src/fig/export/layer.rs b/crates/format/src/fig/export/layer.rs index 9925042f..55fd6dd9 100644 --- a/crates/format/src/fig/export/layer.rs +++ b/crates/format/src/fig/export/layer.rs @@ -1,5 +1,9 @@ use super::common::{export_setting, layout_grid, outlined, prop_type, variant_pairs}; -use super::*; +use super::{ + AUX, AutoLayout, BoolOp, Bound, ContentAlign, CrossAlign, Exporter, Fields, HConstraint, Kv, LAYERS, LayoutMode, MAX_DEPTH, MainAlign, MaskType, + Node, NodeId, NodeKind, PropDef, STASH, Sizing, TextData, TextStyle, VConstraint, WindingRule, arr, b, blend_name, color, e, fl, grid_sizing, + guid, int, matrix, o, position, root_fields, s, u, xy, +}; impl<'d> Exporter<'d> { /// Write layer `id` (child `index` of the layer with GUID `parent_guid`) and its subtree. diff --git a/crates/format/src/fig/export/paint.rs b/crates/format/src/fig/export/paint.rs index f0f40c18..26718e44 100644 --- a/crates/format/src/fig/export/paint.rs +++ b/crates/format/src/fig/export/paint.rs @@ -1,5 +1,8 @@ use super::common::one_sided; -use super::*; +use super::{ + Affine, Bound, EffectKind, Exporter, Fields, GradientKind, ImageScaleMode, Kv, MAX_IMAGE, Node, NodeKind, NoiseMode, Paint, PaintKind, + StrokeAlign, StrokeCap, arr, b, blend_name, cap_name, color, e, fl, join_name, matrix, o, s, sha1_hex, xy, +}; impl<'d> Exporter<'d> { /// A paint list: a layer's fills (`fills`) or strokes with the variables `bound` to them. diff --git a/crates/format/src/fig/export/prototype.rs b/crates/format/src/fig/export/prototype.rs index 866b03c4..01c20db0 100644 --- a/crates/format/src/fig/export/prototype.rs +++ b/crates/format/src/fig/export/prototype.rs @@ -1,5 +1,8 @@ use super::common::key_codes; -use super::*; +use super::{ + Action, Direction, Easing, Exporter, Fields, Interaction, Kv, Node, NodeId, Overflow, OverlayPosition, ScrollPosition, Transition, Trigger, arr, + b, color, e, fl, int, o, position, s, +}; impl<'d> Exporter<'d> { /// Interactions, scrolling, flow starts and the overlay settings this layer is opened with. diff --git a/crates/format/src/fig/export/shape.rs b/crates/format/src/fig/export/shape.rs index 7ef79a78..ba420527 100644 --- a/crates/format/src/fig/export/shape.rs +++ b/crates/format/src/fig/export/shape.rs @@ -1,5 +1,9 @@ use super::common::{style_fields, unsupported_decoration_look}; -use super::*; +use super::{ + Affine, BezPath, Bound, Exporter, Fields, Kv, ListStyle, MAX_CANVAS, MAX_CHARS, MAX_NETWORK, Node, NodeKind, Paint, StrokeAlign, StrokeCap, + StrokeJoin, TextAlignH, TextAlignV, TextAutoResize, TextData, TextStyle, VectorNetwork, WindingRule, arr, b, cap_name, e, finite, fl, int, + join_name, network, o, path_blob, s, u, xy, +}; impl<'d> Exporter<'d> { /// Text: its style, box settings, and characters with their styled runs. `fills`: the layer's diff --git a/crates/format/src/fig/kiwi.rs b/crates/format/src/fig/kiwi.rs index 0f431082..82bdc6cd 100644 --- a/crates/format/src/fig/kiwi.rs +++ b/crates/format/src/fig/kiwi.rs @@ -400,7 +400,7 @@ impl<'s> Decoder<'s, '_> { /// An encoder for the same format: the `.fig` export writes its schema and message with it (and /// test fixtures are built with it). Values are checked against the schema; it never panics. pub mod encode { - use super::*; + use super::{BUILTINS, Field, Kind, KiwiError, Kv, MAX_DEPTH, Schema, Type, err}; #[derive(Default)] pub struct Writer(pub Vec); diff --git a/crates/format/src/fig/map/component.rs b/crates/format/src/fig/map/component.rs index abc2225e..227c18aa 100644 --- a/crates/format/src/fig/map/component.rs +++ b/crates/format/src/fig/map/component.rs @@ -1,6 +1,6 @@ use super::layer::own_text; use super::text::char_styles; -use super::*; +use super::{BTreeMap, BTreeSet, Kv, MAX_DEPTH, Mapper, Obj, Rc, Value, View, guid, json}; /// An override row names a layer of the main by its current GUID or by the `overrideKey` the /// layer had when the row was written: the key becomes the current GUID, unless the layer is diff --git a/crates/format/src/fig/map/connector.rs b/crates/format/src/fig/map/connector.rs index 172616dc..cd17680f 100644 --- a/crates/format/src/fig/map/connector.rs +++ b/crates/format/src/fig/map/connector.rs @@ -1,5 +1,5 @@ use super::layer::{cap_name, own_text, svg_path, valid_size}; -use super::*; +use super::{Affine, Kv, Mapper, NO_STYLE, Obj, Point, Value, View, guid, json, matrix, network, num, rows}; impl<'a> Mapper<'a> { /// A connector's end caps, and when it has no saved outline or network a straight line diff --git a/crates/format/src/fig/map/document.rs b/crates/format/src/fig/map/document.rs index d31ef5c6..69f221c1 100644 --- a/crates/format/src/fig/map/document.rs +++ b/crates/format/src/fig/map/document.rs @@ -1,7 +1,7 @@ use super::component::{assigned, prop_guid}; use super::paint::color; use super::variables::variable_value; -use super::*; +use super::{Affine, BTreeMap, BTreeSet, Kv, MAX_DEPTH, Mapper, Obj, Value, View, copy_str, guid, json, rows}; impl<'a> Mapper<'a> { /// The DOCUMENT with its pages. diff --git a/crates/format/src/fig/map/layout.rs b/crates/format/src/fig/map/layout.rs index f4d50891..7900c453 100644 --- a/crates/format/src/fig/map/layout.rs +++ b/crates/format/src/fig/map/layout.rs @@ -1,5 +1,5 @@ use super::paint::{bound, color}; -use super::*; +use super::{BTreeMap, BTreeSet, Kv, MAX_GRID_TRACK_VALUE, MAX_GRID_TRACKS, Mapper, Obj, Value, View, copy_num, guid, json, num}; impl<'a> Mapper<'a> { pub(super) fn auto_layout(&mut self, o: &mut Obj, v: &View<'a>) { diff --git a/crates/format/src/fig/map/paint.rs b/crates/format/src/fig/map/paint.rs index fe8cfa7b..808e6fc8 100644 --- a/crates/format/src/fig/map/paint.rs +++ b/crates/format/src/fig/map/paint.rs @@ -1,6 +1,6 @@ use super::layer::cap_name; use super::variables::alias; -use super::*; +use super::{Affine, Kv, Mapper, NO_STYLE, Obj, Point, Value, View, copy_num, copy_str, guid, json, matrix, num, rows, vec2}; fn filter_number(value: &Kv) -> Option { let number = match value { diff --git a/crates/format/src/fig/map/proto.rs b/crates/format/src/fig/map/proto.rs index d3f5c022..9238cafb 100644 --- a/crates/format/src/fig/map/proto.rs +++ b/crates/format/src/fig/map/proto.rs @@ -1,6 +1,6 @@ use super::paint::color; use super::variables::variable_data; -use super::*; +use super::{Kv, MAX_ACTION_DEPTH, Mapper, Obj, Value, View, copy_str, guid, json, num, ref_guid}; impl<'a> Mapper<'a> { pub(super) fn prototype(&mut self, o: &mut Obj, v: &View<'a>) { diff --git a/crates/format/src/fig/map/text.rs b/crates/format/src/fig/map/text.rs index 7431fbb5..6227c4d0 100644 --- a/crates/format/src/fig/map/text.rs +++ b/crates/format/src/fig/map/text.rs @@ -1,5 +1,5 @@ use super::paint::{paint, style_link}; -use super::*; +use super::{Kv, Mapper, Obj, Value, View, copy_num, copy_str, json, network, num}; /// The value from the last override layer holding a valid one for an optional text setting; invalid instance rows fall through. fn text_num(v: &View, key: &str) -> Option { diff --git a/crates/format/src/fig/map/variables.rs b/crates/format/src/fig/map/variables.rs index d27c834b..8d891ded 100644 --- a/crates/format/src/fig/map/variables.rs +++ b/crates/format/src/fig/map/variables.rs @@ -1,5 +1,5 @@ use super::paint::color; -use super::*; +use super::{Kv, MAX_EXPR_DEPTH, Mapper, Obj, Value, View, guid, json, num, ref_guid}; impl<'a> Mapper<'a> { pub(super) fn variable_bindings(&mut self, o: &mut Obj, v: &View<'a>) { From 6ebcf6b5112ee08681401e481bb1aa3af4058cdf Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 14:00:38 +0000 Subject: [PATCH 15/33] refactor(text): split shaping and layout finishing --- crates/text/src/layout.rs | 580 +++++++++++++++++++++----------------- 1 file changed, 319 insertions(+), 261 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index ca5f0362..8bef740c 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -322,9 +322,16 @@ pub(crate) fn visual_order(levels: &[u8]) -> Vec { order } -/// Lay out `text` for a box of `box_width` (used when the text wraps or aligns; for auto width -/// pass `None`). `box_height` is used for truncation only. -pub fn layout(text: &TextData, box_width: Option, box_height: Option) -> TextLayout { +struct ShapedText { + chars: Vec, + styles: Vec, + faces: Vec>>, + fallbacks: Vec>, + items: Vec, + bidi: Option, +} + +fn shape_text(text: &TextData) -> ShapedText { let chars: Vec = text.characters.chars().collect(); let spans = text.spans(); let mut styles: Vec = Vec::new(); @@ -365,7 +372,6 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) // Shape each span, split by paragraph, bidi run and fallback face. let bidi = Bidi::of(&text.characters); let level_at = |i: usize| bidi.as_ref().and_then(|b| b.levels.get(i)).copied().unwrap_or(0); - let rtl_para_at = |i: usize| bidi.as_ref().and_then(|b| b.rtl_para.get(i)).copied().unwrap_or(false); // A grapheme cluster (emoji sequence, letter with marks) is shaped with one face. let cluster_start = crate::breaks::grapheme_starts(&text.characters); let mut items: Vec = Vec::with_capacity(chars.len()); @@ -431,40 +437,21 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) } } - // List markers sit in a gutter (one more per nesting level) and wrapped lines hang at the - // text start; a hanging list puts the outermost gutter outside the box. Paragraph indent - // applies to plain paragraphs only. Right-to-left paragraphs mirror both: the gutter and the - // indent are on the right, the text ends where they start. - let lists: Vec = (0..text.paragraph_count()).map(|p| text.para_list(p)).collect(); - let markers = list_markers(&lists); - let step = list_step(&lists, &markers, styles.first(), faces.first().cloned().flatten().as_ref()); - let hang_list = if text.hanging_list { step } else { 0.0 }; - // Paragraph indent applies to start-aligned text only (left, or right for a right-to-left paragraph). - let indent_px = if text.paragraph_indent.is_finite() { text.paragraph_indent.clamp(0.0, 10_000.0) } else { 0.0 }; - let indent_at = |char_idx: usize| { - let start_aligned = if rtl_para_at(char_idx) { text.align_h == TextAlignH::Right } else { text.align_h == TextAlignH::Left }; - if start_aligned { indent_px } else { 0.0 } - }; - let list_of = |p: usize| lists.get(p).copied().unwrap_or_default(); - // The start indent of a line of paragraph `p` (`first`: its first line, starting at char `ci`). - let line_indent_of = |p: usize, first: bool, ci: usize| { - let pl = list_of(p); - match pl.list { - ListStyle::None if first => indent_at(ci), - ListStyle::None => 0.0, - _ => step * (f64::from(pl.indent) + 1.0) - hang_list, - } - }; - // Hanging punctuation: at the start edge of left-aligned and justified lines, at the end edge - // of right-aligned and justified ones (left-to-right paragraphs). - let hang_start = text.hanging_punctuation && matches!(text.align_h, TextAlignH::Left | TextAlignH::Justified); - let hang_end = text.hanging_punctuation && matches!(text.align_h, TextAlignH::Right | TextAlignH::Justified); + ShapedText { chars, styles, faces, fallbacks, items, bidi } +} - // Line breaking. - let wrap = match text.auto_resize { - TextAutoResize::WidthAndHeight => None, - _ => box_width.filter(|w| w.is_finite() && *w > 0.0), - }; +fn line_break_after(items: &[Item], end: usize) -> bool { + end.checked_sub(1).and_then(|i| items.get(i)).is_some_and(|item| is_line_break(item.ch)) +} + +fn break_lines( + items: &[Item], + wrap: Option, + line_indent_of: &impl Fn(usize, bool, usize) -> f64, + rtl_para_at: &impl Fn(usize) -> bool, + hang_start: bool, + hang_end: bool, +) -> Vec<(usize, usize, bool, usize)> { let mut lines: Vec<(usize, usize, bool, usize)> = Vec::new(); // item range, paragraph end, paragraph let mut line_start = 0; let mut i = 0; @@ -521,230 +508,49 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) } i += 1; } - let break_after = |e: usize| e.checked_sub(1).and_then(|i| items.get(i)).is_some_and(|it| is_line_break(it.ch)); - if line_start < items.len() || lines.is_empty() || lines.last().is_some_and(|l| l.2 || break_after(l.1)) { + if line_start < items.len() || lines.is_empty() || lines.last().is_some_and(|l| l.2 || line_break_after(items, l.1)) { lines.push((line_start, items.len(), true, para)); } - // Line metrics and positions. - let default_style = styles.first().cloned().unwrap_or_default(); - let default_face = faces.first().cloned().flatten(); - let mut out = TextLayout { styles: styles.clone(), ..Default::default() }; - let mut y = 0.0; - let mut para_start_line = true; - let max_lines = if text.max_lines > 0 && text.truncates() { text.max_lines as usize } else { usize::MAX }; - // A style's vertical metrics (ascent, descent, line height, cap height) come from its own - // font, never a fallback face, so lines mixing scripts keep the spacing of lines that don't. - let metrics = |st_idx: usize| -> [f64; 4] { - let st = styles.get(st_idx).unwrap_or(&default_style); - let size = sanitize_size(st.font_size); - match faces.get(st_idx).and_then(Option::as_ref).or(default_face.as_ref()) { - Some(f) => { - let k = size / f.upem; - [f.ascent * k, f.descent * k, whole_px(st.line_height.resolve(size, natural_ratio(f))), f.cap_height * k] - } - None => [size * 0.8, size * 0.2, whole_px(st.line_height.resolve(size, 1.2)), size * 0.7], - } - }; - // An item's glyph with its pen position at `pen` on the line at `baseline` (none for a space - // the font has no glyph for). - let glyph_at = |it: &Item, pen: f64, baseline: f64| { - let size = styles.get(it.style).map(|st| sanitize_size(st.font_size)).unwrap_or(12.0) * it.scale; - (it.gid != 0 || !it.is_space()).then(|| Glyph { - face: it.face.clone(), - coords: it.coords.clone(), - gid: it.gid, - x: pen + it.dx, - y: baseline - it.dy, - size, - advance: it.adv, - char_start: it.char_start, - char_end: it.char_end, - style: it.style, - rtl: it.level % 2 == 1, - warp: Affine::IDENTITY, - }) - }; - let mut first_cap = 0.0; - for (li, (s, e, para_end, para)) in lines.iter().enumerate() { - if li >= max_lines { - out.truncated = true; - break; - } - // Metrics from the styles present on the line (or the style at the break for empty lines). - let [mut asc, mut desc, mut lh, mut cap] = [0.0_f64; 4]; - let mut seen = usize::MAX; - for st in items.get(*s..*e).unwrap_or(&[]).iter().map(|it| it.style) { - if st != seen { - seen = st; - let m = metrics(st); - (asc, desc, lh, cap) = (asc.max(m[0]), desc.max(m[1]), lh.max(m[2]), cap.max(m[3])); - } - } - if seen == usize::MAX { - [asc, desc, lh, cap] = metrics(items.get(s.saturating_sub(1)).map_or(0, |it| it.style)); - } - if li == 0 { - first_cap = cap; - } - if para_start_line && li > 0 { - // Between two items of a list: the list spacing. - let in_list = |p: usize| list_of(p).list != ListStyle::None; - let gap = if in_list(*para) && para.checked_sub(1).is_some_and(in_list) { text.list_spacing } else { text.paragraph_spacing }; - y += if gap.is_finite() { gap.clamp(0.0, 10_000.0) } else { 0.0 }; - } - let baseline = y + (lh - (asc + desc)) / 2.0 + asc; - // Trailing spaces don't count for width/alignment. - let mut vis_end = *e; - while vis_end > *s && items.get(vis_end - 1).is_some_and(|it| it.is_space() || is_line_break(it.ch)) { - vis_end -= 1; - } - // Letter spacing separates glyphs: none after the line's last visible one. - let end_spacing = vis_end.checked_sub(1).filter(|i| *i >= *s).and_then(|i| items.get(i)).map_or(0.0, |it| it.spacing); - let width: f64 = items.get(*s..vis_end).unwrap_or(&[]).iter().map(|it| it.adv).sum::() - end_spacing; - let first_char = items.get(*s).map(|it| it.char_start).unwrap_or(0); - let rtl_line = rtl_para_at(first_char); - let line_indent = line_indent_of(*para, para_start_line, first_char); - // Hanging punctuation at the line's edges (the visible text's first and last chars). - let hang_l = items.get(*s).filter(|it| hang_start && !rtl_line && vis_end > *s && opens(it.ch)).map_or(0.0, |it| it.adv); - let hang_r = - items.get(vis_end.wrapping_sub(1)).filter(|it| hang_end && !rtl_line && vis_end > *s + 1 && closes(it.ch)).map_or(0.0, |it| it.adv); - let gstart = out.glyphs.len(); - // Justify: extra space per gap. - let gaps = items.get(*s..vis_end).unwrap_or(&[]).iter().filter(|it| it.stretches()).count(); - let justify_extra = match (text.align_h, wrap) { - (TextAlignH::Justified, Some(maxw)) if !para_end && !break_after(*e) && gaps > 0 => { - ((maxw - line_indent + hang_l + hang_r - width) / gaps as f64).max(0.0) - } - _ => 0.0, - }; - // Where the visible text ends once justified. - let text_end = width + justify_extra * gaps as f64; - // A right-to-left line's trailing spaces sit at its visual left (rule L1, below) and hang - // outside it, so its visible text spans [0, text_end] with the gutter and indent after it. - let trailing: f64 = items - .get(vis_end..*e) - .unwrap_or(&[]) - .iter() - .filter(|it| !is_line_break(it.ch)) - .map(|it| it.adv + if it.stretches() { justify_extra } else { 0.0 }) - .sum(); - let mut pen = if rtl_line { -trailing } else { line_indent - hang_l }; - // List marker (before the line's text; zero-length char range so carets skip it). - let pl = list_of(*para); - if para_start_line && pl.list != ListStyle::None { - let marker = markers.get(*para).cloned().unwrap_or_default(); - let st_idx = items.get(*s).map(|it| it.style).unwrap_or(0); - let at = items.get(*s).map(|it| it.char_start).unwrap_or(chars.len()); - // The marker takes the paragraph style's own font (not a fallback the line's first - // char needed, e.g. for Hebrew), so every bullet and number of a list matches; a - // nested bullet the font lacks comes from a fallback font. - let has = |f: &Arc| marker.chars().all(|c| f.has_char(c)); - let own = faces.get(st_idx).cloned().flatten().filter(has); - let marker_face = match (own, styles.get(st_idx)) { - (Some(f), Some(st)) => { - let coords = f.coords(&f.axis_values(st)); - Some((f, coords)) - } - _ => fallbacks - .iter() - .find(|f| has(f)) - .map(|f| (f.clone(), no_coords())) - .or_else(|| items.get(*s).map(|it| (it.face.clone(), it.coords.clone()))) - .or_else(|| default_face.clone().map(|f| (f, no_coords()))), - }; - if let (Some(st), Some((face, coords))) = (styles.get(st_idx), marker_face) { - let size = sanitize_size(st.font_size); - let k = size / face.upem; - let numbered = pl.list == ListStyle::Numbered; - // Right to left, the number reads "1." with its full stop on the left (".1"). - let marker = if rtl_line && numbered { format!(".{}", marker.trim_end_matches('.')) } else { marker }; - let advance = marker_advance(&face, &coords, &marker, size); - // Numbers end (start, right to left) half an em from the text; bullets sit at the - // gutter's outer edge. - let gutter = step * f64::from(pl.indent) - hang_list; - let mut mx = match (rtl_line, numbered) { - (false, true) => gutter + (step - size * 0.5 - advance).max(0.0), - (false, false) => gutter, - (true, true) => text_end + size * 0.5, - (true, false) => text_end + (step - advance).max(0.0), - }; - for c in marker.chars() { - let gid = face.glyph_for(c); - let adv = face.advance_at(gid, &coords) * k; - out.glyphs.push(Glyph { - face: face.clone(), - coords: coords.clone(), - gid, - x: mx, - y: baseline, - size, - advance: adv, - char_start: at, - char_end: at, - style: st_idx, - rtl: false, - warp: Affine::IDENTITY, - }); - mx += adv; - } - } - } - // Right-to-left runs are placed in visual order; trailing spaces take the paragraph's - // direction (rule L1). - let line_items = items.get(*s..*e).unwrap_or(&[]); - let order: Vec = if bidi.is_some() { - let para_level = u8::from(rtl_line); - let levels: Vec = line_items.iter().enumerate().map(|(k, it)| if *s + k >= vis_end { para_level } else { it.level }).collect(); - visual_order(&levels) - } else { - (0..line_items.len()).collect() - }; - for it in order.iter().filter_map(|k| line_items.get(*k)) { - if is_line_break(it.ch) { - continue; - } - out.glyphs.extend(glyph_at(it, pen, baseline)); - pen += it.adv; - if it.stretches() { - pen += justify_extra; - } - } - let char_start = items.get(*s).map(|it| it.char_start).unwrap_or_else(|| chars.len()); - let char_end = items.get(e.saturating_sub(1)).map(|it| it.char_end).unwrap_or(char_start).max(char_start); - out.lines.push(Line { - glyph_start: gstart, - glyph_end: out.glyphs.len(), - char_start, - char_end, - top: y, - height: lh, - baseline, - width: width + line_indent - hang_l - hang_r, - x: 0.0, - para_end: *para_end, - break_after: break_after(*e), - rtl: rtl_line, - }); - y += lh; - para_start_line = *para_end; - } - out.width = out.lines.iter().map(|l| l.width).fold(0.0, f64::max); - // Vertical trim: the box starts at the first line's cap height and ends at the last baseline. - let trim = text.leading_trim == LeadingTrim::CapHeight && text.path.is_none(); - if trim && let Some(first) = out.lines.first() { - let shift = first.baseline - first_cap; - for l in &mut out.lines { - l.top -= shift; - l.baseline -= shift; - } - for g in &mut out.glyphs { - g.y -= shift; - } - } - let bottom = |l: &Line| if trim { l.baseline } else { l.top + l.height }; - out.height = out.lines.last().map_or(y, bottom).max(0.0); + lines +} +#[derive(Clone, Copy)] +struct LayoutFinish<'a> { + text: &'a TextData, + box_width: Option, + box_height: Option, + wrap: Option, + lines: &'a [(usize, usize, bool, usize)], + items: &'a [Item], + styles: &'a [TextStyle], + faces: &'a [Option>], + first_cap: f64, + flow_height: f64, +} + +fn glyph_at(styles: &[TextStyle], item: &Item, pen: f64, baseline: f64) -> Option { + let size = styles.get(item.style).map(|st| sanitize_size(st.font_size)).unwrap_or(12.0) * item.scale; + (item.gid != 0 || !item.is_space()).then(|| Glyph { + face: item.face.clone(), + coords: item.coords.clone(), + gid: item.gid, + x: pen + item.dx, + y: baseline - item.dy, + size, + advance: item.adv, + char_start: item.char_start, + char_end: item.char_end, + style: item.style, + rtl: item.level % 2 == 1, + warp: Affine::IDENTITY, + }) +} + +fn truncate_layout(out: &mut TextLayout, finish: &LayoutFinish<'_>) { + let LayoutFinish { text, box_height, wrap, lines, items, styles, .. } = *finish; + let trim = text.leading_trim == LeadingTrim::CapHeight && text.path.is_none(); + let bottom = |line: &Line| if trim { line.baseline } else { line.top + line.height }; // Truncation by height. if matches!(text.auto_resize, TextAutoResize::Truncate) && let Some(h) = box_height @@ -766,7 +572,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) let wrapped_on = last.and_then(|li| Some((out.lines.get(li)?, lines.get(li)?, lines.get(li + 1)?))); if let (Some(maxw), Some((line, &(_, e, para_end, _), &(ns, ne, _, _)))) = (wrap, wrapped_on) && !para_end - && !break_after(e) + && !line_break_after(items, e) && !line.rtl { let baseline = line.baseline; @@ -776,7 +582,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) break; } if !is_line_break(it.ch) { - out.glyphs.extend(glyph_at(it, pen, baseline)); + out.glyphs.extend(glyph_at(styles, it, pen, baseline)); pen += it.adv; } } @@ -785,7 +591,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) l.glyph_end = gend; } } - add_ellipsis(&mut out, wrap); + add_ellipsis(out, wrap); // Carets and selections reach every char the line shows, borrowed ones included. if let Some(l) = out.lines.last() { let shown = @@ -795,6 +601,26 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) } } } +} + +fn finish_layout(out: &mut TextLayout, finish: LayoutFinish<'_>) { + let LayoutFinish { text, box_width, faces, first_cap, flow_height, .. } = finish; + // Vertical trim: the box starts at the first line's cap height and ends at the last baseline. + let trim = text.leading_trim == LeadingTrim::CapHeight && text.path.is_none(); + if trim && let Some(first) = out.lines.first() { + let shift = first.baseline - first_cap; + for l in &mut out.lines { + l.top -= shift; + l.baseline -= shift; + } + for g in &mut out.glyphs { + g.y -= shift; + } + } + let bottom = |l: &Line| if trim { l.baseline } else { l.top + l.height }; + out.height = out.lines.last().map_or(flow_height, bottom).max(0.0); + + truncate_layout(out, &finish); // Horizontal alignment within the box. let align_width = match text.auto_resize { @@ -891,8 +717,240 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) out.decorations = decos; out.styles.extend(deco_styles); if let Some(tp) = &text.path { - crate::onpath::place(&mut out, tp, text.align_h); + crate::onpath::place(out, tp, text.align_h); } +} + +/// Lay out `text` for a box of `box_width` (used when the text wraps or aligns; for auto width +/// pass `None`). `box_height` is used for truncation only. +pub fn layout(text: &TextData, box_width: Option, box_height: Option) -> TextLayout { + let ShapedText { chars, styles, faces, fallbacks, items, bidi } = shape_text(text); + let rtl_para_at = |i: usize| bidi.as_ref().and_then(|b| b.rtl_para.get(i)).copied().unwrap_or(false); + + // List markers sit in a gutter (one more per nesting level) and wrapped lines hang at the + // text start; a hanging list puts the outermost gutter outside the box. Paragraph indent + // applies to plain paragraphs only. Right-to-left paragraphs mirror both: the gutter and the + // indent are on the right, the text ends where they start. + let lists: Vec = (0..text.paragraph_count()).map(|p| text.para_list(p)).collect(); + let markers = list_markers(&lists); + let step = list_step(&lists, &markers, styles.first(), faces.first().cloned().flatten().as_ref()); + let hang_list = if text.hanging_list { step } else { 0.0 }; + // Paragraph indent applies to start-aligned text only (left, or right for a right-to-left paragraph). + let indent_px = if text.paragraph_indent.is_finite() { text.paragraph_indent.clamp(0.0, 10_000.0) } else { 0.0 }; + let indent_at = |char_idx: usize| { + let start_aligned = if rtl_para_at(char_idx) { text.align_h == TextAlignH::Right } else { text.align_h == TextAlignH::Left }; + if start_aligned { indent_px } else { 0.0 } + }; + let list_of = |p: usize| lists.get(p).copied().unwrap_or_default(); + // The start indent of a line of paragraph `p` (`first`: its first line, starting at char `ci`). + let line_indent_of = |p: usize, first: bool, ci: usize| { + let pl = list_of(p); + match pl.list { + ListStyle::None if first => indent_at(ci), + ListStyle::None => 0.0, + _ => step * (f64::from(pl.indent) + 1.0) - hang_list, + } + }; + // Hanging punctuation: at the start edge of left-aligned and justified lines, at the end edge + // of right-aligned and justified ones (left-to-right paragraphs). + let hang_start = text.hanging_punctuation && matches!(text.align_h, TextAlignH::Left | TextAlignH::Justified); + let hang_end = text.hanging_punctuation && matches!(text.align_h, TextAlignH::Right | TextAlignH::Justified); + + let wrap = match text.auto_resize { + TextAutoResize::WidthAndHeight => None, + _ => box_width.filter(|w| w.is_finite() && *w > 0.0), + }; + let lines = break_lines(&items, wrap, &line_indent_of, &rtl_para_at, hang_start, hang_end); + // Line metrics and positions. + let default_style = styles.first().cloned().unwrap_or_default(); + let default_face = faces.first().cloned().flatten(); + let mut out = TextLayout { styles: styles.clone(), ..Default::default() }; + let mut y = 0.0; + let mut para_start_line = true; + let max_lines = if text.max_lines > 0 && text.truncates() { text.max_lines as usize } else { usize::MAX }; + // A style's vertical metrics (ascent, descent, line height, cap height) come from its own + // font, never a fallback face, so lines mixing scripts keep the spacing of lines that don't. + let metrics = |st_idx: usize| -> [f64; 4] { + let st = styles.get(st_idx).unwrap_or(&default_style); + let size = sanitize_size(st.font_size); + match faces.get(st_idx).and_then(Option::as_ref).or(default_face.as_ref()) { + Some(f) => { + let k = size / f.upem; + [f.ascent * k, f.descent * k, whole_px(st.line_height.resolve(size, natural_ratio(f))), f.cap_height * k] + } + None => [size * 0.8, size * 0.2, whole_px(st.line_height.resolve(size, 1.2)), size * 0.7], + } + }; + let mut first_cap = 0.0; + for (li, (s, e, para_end, para)) in lines.iter().enumerate() { + if li >= max_lines { + out.truncated = true; + break; + } + // Metrics from the styles present on the line (or the style at the break for empty lines). + let [mut asc, mut desc, mut lh, mut cap] = [0.0_f64; 4]; + let mut seen = usize::MAX; + for st in items.get(*s..*e).unwrap_or(&[]).iter().map(|it| it.style) { + if st != seen { + seen = st; + let m = metrics(st); + (asc, desc, lh, cap) = (asc.max(m[0]), desc.max(m[1]), lh.max(m[2]), cap.max(m[3])); + } + } + if seen == usize::MAX { + [asc, desc, lh, cap] = metrics(items.get(s.saturating_sub(1)).map_or(0, |it| it.style)); + } + if li == 0 { + first_cap = cap; + } + if para_start_line && li > 0 { + // Between two items of a list: the list spacing. + let in_list = |p: usize| list_of(p).list != ListStyle::None; + let gap = if in_list(*para) && para.checked_sub(1).is_some_and(in_list) { text.list_spacing } else { text.paragraph_spacing }; + y += if gap.is_finite() { gap.clamp(0.0, 10_000.0) } else { 0.0 }; + } + let baseline = y + (lh - (asc + desc)) / 2.0 + asc; + // Trailing spaces don't count for width/alignment. + let mut vis_end = *e; + while vis_end > *s && items.get(vis_end - 1).is_some_and(|it| it.is_space() || is_line_break(it.ch)) { + vis_end -= 1; + } + // Letter spacing separates glyphs: none after the line's last visible one. + let end_spacing = vis_end.checked_sub(1).filter(|i| *i >= *s).and_then(|i| items.get(i)).map_or(0.0, |it| it.spacing); + let width: f64 = items.get(*s..vis_end).unwrap_or(&[]).iter().map(|it| it.adv).sum::() - end_spacing; + let first_char = items.get(*s).map(|it| it.char_start).unwrap_or(0); + let rtl_line = rtl_para_at(first_char); + let line_indent = line_indent_of(*para, para_start_line, first_char); + // Hanging punctuation at the line's edges (the visible text's first and last chars). + let hang_l = items.get(*s).filter(|it| hang_start && !rtl_line && vis_end > *s && opens(it.ch)).map_or(0.0, |it| it.adv); + let hang_r = + items.get(vis_end.wrapping_sub(1)).filter(|it| hang_end && !rtl_line && vis_end > *s + 1 && closes(it.ch)).map_or(0.0, |it| it.adv); + let gstart = out.glyphs.len(); + // Justify: extra space per gap. + let gaps = items.get(*s..vis_end).unwrap_or(&[]).iter().filter(|it| it.stretches()).count(); + let justify_extra = match (text.align_h, wrap) { + (TextAlignH::Justified, Some(maxw)) if !para_end && !line_break_after(&items, *e) && gaps > 0 => { + ((maxw - line_indent + hang_l + hang_r - width) / gaps as f64).max(0.0) + } + _ => 0.0, + }; + // Where the visible text ends once justified. + let text_end = width + justify_extra * gaps as f64; + // A right-to-left line's trailing spaces sit at its visual left (rule L1, below) and hang + // outside it, so its visible text spans [0, text_end] with the gutter and indent after it. + let trailing: f64 = items + .get(vis_end..*e) + .unwrap_or(&[]) + .iter() + .filter(|it| !is_line_break(it.ch)) + .map(|it| it.adv + if it.stretches() { justify_extra } else { 0.0 }) + .sum(); + let mut pen = if rtl_line { -trailing } else { line_indent - hang_l }; + // List marker (before the line's text; zero-length char range so carets skip it). + let pl = list_of(*para); + if para_start_line && pl.list != ListStyle::None { + let marker = markers.get(*para).cloned().unwrap_or_default(); + let st_idx = items.get(*s).map(|it| it.style).unwrap_or(0); + let at = items.get(*s).map(|it| it.char_start).unwrap_or(chars.len()); + // The marker takes the paragraph style's own font (not a fallback the line's first + // char needed, e.g. for Hebrew), so every bullet and number of a list matches; a + // nested bullet the font lacks comes from a fallback font. + let has = |f: &Arc| marker.chars().all(|c| f.has_char(c)); + let own = faces.get(st_idx).cloned().flatten().filter(has); + let marker_face = match (own, styles.get(st_idx)) { + (Some(f), Some(st)) => { + let coords = f.coords(&f.axis_values(st)); + Some((f, coords)) + } + _ => fallbacks + .iter() + .find(|f| has(f)) + .map(|f| (f.clone(), no_coords())) + .or_else(|| items.get(*s).map(|it| (it.face.clone(), it.coords.clone()))) + .or_else(|| default_face.clone().map(|f| (f, no_coords()))), + }; + if let (Some(st), Some((face, coords))) = (styles.get(st_idx), marker_face) { + let size = sanitize_size(st.font_size); + let k = size / face.upem; + let numbered = pl.list == ListStyle::Numbered; + // Right to left, the number reads "1." with its full stop on the left (".1"). + let marker = if rtl_line && numbered { format!(".{}", marker.trim_end_matches('.')) } else { marker }; + let advance = marker_advance(&face, &coords, &marker, size); + // Numbers end (start, right to left) half an em from the text; bullets sit at the + // gutter's outer edge. + let gutter = step * f64::from(pl.indent) - hang_list; + let mut mx = match (rtl_line, numbered) { + (false, true) => gutter + (step - size * 0.5 - advance).max(0.0), + (false, false) => gutter, + (true, true) => text_end + size * 0.5, + (true, false) => text_end + (step - advance).max(0.0), + }; + for c in marker.chars() { + let gid = face.glyph_for(c); + let adv = face.advance_at(gid, &coords) * k; + out.glyphs.push(Glyph { + face: face.clone(), + coords: coords.clone(), + gid, + x: mx, + y: baseline, + size, + advance: adv, + char_start: at, + char_end: at, + style: st_idx, + rtl: false, + warp: Affine::IDENTITY, + }); + mx += adv; + } + } + } + // Right-to-left runs are placed in visual order; trailing spaces take the paragraph's + // direction (rule L1). + let line_items = items.get(*s..*e).unwrap_or(&[]); + let order: Vec = if bidi.is_some() { + let para_level = u8::from(rtl_line); + let levels: Vec = line_items.iter().enumerate().map(|(k, it)| if *s + k >= vis_end { para_level } else { it.level }).collect(); + visual_order(&levels) + } else { + (0..line_items.len()).collect() + }; + for it in order.iter().filter_map(|k| line_items.get(*k)) { + if is_line_break(it.ch) { + continue; + } + out.glyphs.extend(glyph_at(&styles, it, pen, baseline)); + pen += it.adv; + if it.stretches() { + pen += justify_extra; + } + } + let char_start = items.get(*s).map(|it| it.char_start).unwrap_or_else(|| chars.len()); + let char_end = items.get(e.saturating_sub(1)).map(|it| it.char_end).unwrap_or(char_start).max(char_start); + out.lines.push(Line { + glyph_start: gstart, + glyph_end: out.glyphs.len(), + char_start, + char_end, + top: y, + height: lh, + baseline, + width: width + line_indent - hang_l - hang_r, + x: 0.0, + para_end: *para_end, + break_after: line_break_after(&items, *e), + rtl: rtl_line, + }); + y += lh; + para_start_line = *para_end; + } + out.width = out.lines.iter().map(|l| l.width).fold(0.0, f64::max); + finish_layout( + &mut out, + LayoutFinish { text, box_width, box_height, wrap, lines: &lines, items: &items, styles: &styles, faces: &faces, first_cap, flow_height: y }, + ); + out } From 4268bd859f95fc195063284bfcd8d6f9298223dc Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 14:00:44 +0000 Subject: [PATCH 16/33] refactor(fig): split mapping and lint test helpers --- crates/format/src/fig/assets_tests.rs | 43 ++++--- crates/format/src/fig/map/layer.rs | 109 ++++++++++++++---- .../format/src/fig/source_identity_tests.rs | 8 ++ crates/format/src/fig/tests.rs | 51 +++++--- .../format/src/rest/proto_variable_tests.rs | 28 +++-- 5 files changed, 178 insertions(+), 61 deletions(-) diff --git a/crates/format/src/fig/assets_tests.rs b/crates/format/src/fig/assets_tests.rs index a271a908..cf115c8f 100644 --- a/crates/format/src/fig/assets_tests.rs +++ b/crates/format/src/fig/assets_tests.rs @@ -274,19 +274,30 @@ fn variables_modes_and_bindings_round_trip() { #[test] fn variables_are_written_as_nodes_on_a_hidden_page() { let nodes = written(&themed()); - let hidden = named(&nodes, "Internal Only Canvas"); + assert_hidden_theme(&nodes); + assert_variable_values(&nodes); + assert_variable_bindings(&nodes); +} + +fn assert_hidden_theme(nodes: &[Value]) { + let hidden = named(nodes, "Internal Only Canvas"); assert_eq!((hidden["type"].as_str(), hidden["internalOnly"].as_bool(), hidden["visible"].as_bool()), (Some("CANVAS"), Some(true), Some(false))); - let set = named(&nodes, "Theme"); + let set = named(nodes, "Theme"); assert_eq!(set["type"], "VARIABLE_SET"); assert_eq!(set["parentIndex"]["guid"], hidden["guid"]); let modes = set["variableSetModes"].as_array().unwrap(); assert_eq!(modes.iter().map(|m| m["name"].as_str().unwrap()).collect::>(), ["Light", "Dark"]); - let bg = named(&nodes, "color/bg"); +} + +fn assert_variable_values(nodes: &[Value]) { + let set = named(nodes, "Theme"); + let modes = set["variableSetModes"].as_array().unwrap(); + let bg = named(nodes, "color/bg"); assert_eq!((bg["type"].as_str(), bg["variableResolvedType"].as_str()), (Some("VARIABLE"), Some("COLOR"))); assert_eq!(bg["variableSetID"]["guid"], set["guid"]); assert_eq!(bg["description"], "Behind everything"); assert_eq!(bg["variableScopes"], json!(["FRAME_FILL", "SHAPE_FILL"])); - assert_eq!(named(&nodes, "color/fg")["variableScopes"], json!(["TEXT_FILL", "STROKE", "EFFECT_COLOR"])); + assert_eq!(named(nodes, "color/fg")["variableScopes"], json!(["TEXT_FILL", "STROKE", "EFFECT_COLOR"])); let values = bg["variableDataValues"]["entries"].as_array().unwrap(); assert_eq!(values.len(), 2); assert_eq!(values[1]["modeID"], modes[1]["id"]); @@ -295,19 +306,25 @@ fn variables_are_written_as_nodes_on_a_hidden_page() { (Some("COLOR"), Some(0.25)) ); // An alias names the variable's node; every value says the type it resolves to. - let accent = &named(&nodes, "color/accent")["variableDataValues"]["entries"]; + let accent = &named(nodes, "color/accent")["variableDataValues"]["entries"]; assert_eq!(accent[0]["variableData"]["dataType"], "ALIAS"); assert_eq!(accent[0]["variableData"]["resolvedDataType"], "COLOR"); - assert_eq!(accent[0]["variableData"]["value"]["alias"]["guid"], named(&nodes, "color/fg")["guid"]); - assert_eq!(named(&nodes, "title")["variableDataValues"]["entries"][1]["variableData"]["value"]["textValue"], "Grüße 😀"); - assert_eq!(named(&nodes, "shown")["variableDataValues"]["entries"][1]["variableData"]["value"]["boolValue"], false); - assert_eq!(named(&nodes, "space/gap")["variableDataValues"]["entries"][0]["variableData"]["value"]["floatValue"], 8.0); + assert_eq!(accent[0]["variableData"]["value"]["alias"]["guid"], named(nodes, "color/fg")["guid"]); + assert_eq!(named(nodes, "title")["variableDataValues"]["entries"][1]["variableData"]["value"]["textValue"], "Grüße 😀"); + assert_eq!(named(nodes, "shown")["variableDataValues"]["entries"][1]["variableData"]["value"]["boolValue"], false); + assert_eq!(named(nodes, "space/gap")["variableDataValues"]["entries"][0]["variableData"]["value"]["floatValue"], 8.0); +} + +fn assert_variable_bindings(nodes: &[Value]) { + let bg = named(nodes, "color/bg"); + let set = named(nodes, "Theme"); + let modes = set["variableSetModes"].as_array().unwrap(); // Bindings: on the paint, the gradient stop, the effect and the grid, and in the layer's map. - let card = named(&nodes, "Card"); + let card = named(nodes, "Card"); assert_eq!(card["fillPaints"][0]["colorVar"]["value"]["alias"]["guid"], bg["guid"]); assert_eq!(card["variableModeBySetMap"]["entries"][0]["variableSetID"]["guid"], set["guid"]); assert_eq!(card["variableModeBySetMap"]["entries"][0]["variableModeID"], modes[1]["id"]); - assert_eq!(card["layoutGrids"][0]["gutterSizeVar"]["value"]["alias"]["guid"], named(&nodes, "space/gap")["guid"]); + assert_eq!(card["layoutGrids"][0]["gutterSizeVar"]["value"]["alias"]["guid"], named(nodes, "space/gap")["guid"]); let fields: Vec<&str> = card["variableConsumptionMap"]["entries"].as_array().unwrap().iter().map(|e| e["variableField"].as_str().unwrap()).collect(); for f in @@ -315,7 +332,7 @@ fn variables_are_written_as_nodes_on_a_hidden_page() { { assert!(fields.contains(&f), "{f} in {fields:?}"); } - let swatch = named(&nodes, "Swatch"); + let swatch = named(nodes, "Swatch"); assert!(swatch["fillPaints"][1]["stopsVar"][0].get("colorVar").is_none()); assert_eq!(swatch["fillPaints"][1]["stopsVar"][1]["colorVar"]["value"]["alias"]["guid"], bg["guid"]); assert_eq!(swatch["fillPaints"][1]["stopsVar"].as_array().unwrap().len(), 2); @@ -324,7 +341,7 @@ fn variables_are_written_as_nodes_on_a_hidden_page() { assert_eq!(swatch["effects"][0][key]["dataType"], "ALIAS", "{key}"); } // The page sets a mode too. - assert_eq!(named(&nodes, "Page 1")["variableModeBySetMap"]["entries"][0]["variableSetID"]["guid"], named(&nodes, "Spacing")["guid"]); + assert_eq!(named(nodes, "Page 1")["variableModeBySetMap"]["entries"][0]["variableSetID"]["guid"], named(nodes, "Spacing")["guid"]); } #[test] diff --git a/crates/format/src/fig/map/layer.rs b/crates/format/src/fig/map/layer.rs index e4793d48..e750a5e4 100644 --- a/crates/format/src/fig/map/layer.rs +++ b/crates/format/src/fig/map/layer.rs @@ -2,7 +2,10 @@ use super::component::{Entry, Inst, OverrideRow, prop_ref}; use super::connector::connector_has_saved_label; use super::layout::{layout_grid, tracks}; use super::paint::{effect, export_setting, paint, paint_bound, stroke}; -use super::*; +use super::{ + Affine, BTreeMap, BTreeSet, Kv, MAX_COMMANDS, MAX_DEPTH, Mapper, Obj, Value, View, copy_num, copy_str, guid, json, matrix, network, num, rows, + size, +}; /// How the layers of a resized container follow it when the file gives no derived layout for /// them: a frame's by their constraints, from its main's size to its own; a group's scale with it. @@ -61,6 +64,22 @@ impl Fit { } } +struct LayerChildren<'s, 'a> { + id: &'s str, + inst: Option<&'s Inst<'a>>, + base: &'s Kv<'a>, + child_space: Affine, + view: &'s View<'a>, + object: &'s mut Obj, + ty: &'s str, + rest_id: &'s str, + connector_snapshot: bool, + connector_terminal: bool, + depth: usize, + included: Option<&'s BTreeSet>, + slot_content: Option, +} + impl<'a> Mapper<'a> { /// Layer `id` (inside instance `inst` when expanding one) and its subtree. `space`: from the /// parent's coordinates to the enclosing frame's (groups and booleans place their children in @@ -136,16 +155,7 @@ impl<'a> Mapper<'a> { connector_snapshot && (!connector_has_saved_label(&view) || depth >= MAX_DEPTH || self.count.saturating_add(1) >= self.budget()); // A supported connector snapshot has its own note below instead of the generic stand-in one. let stand = stand_in(own).filter(|_| !connector_snapshot); - let ty = match own { - "ROUNDED_RECTANGLE" => "RECTANGLE", - "SYMBOL" => "COMPONENT", - "FRAME" if view.flag("isStateGroup") == Some(true) => "COMPONENT_SET", - "FRAME" if view.flag("resizeToFit") == Some(true) => "GROUP", - "VARIABLE" | "VARIABLE_SET" => return None, - "CONNECTOR" if connector_snapshot && connector_terminal => "VECTOR", - "CONNECTOR" if connector_snapshot => "FRAME", - t => stand.map_or(t, |s| s.0), - }; + let ty = Self::mapped_type(own, &view, connector_snapshot, connector_terminal, stand)?; if let Some((_, what)) = stand.filter(|_| own != "TEXT_PATH") { self.warn(format!("{} layers import as {what}", own.to_ascii_lowercase().replace('_', " "))); } @@ -225,18 +235,59 @@ impl<'a> Mapper<'a> { source.insert("slotOverride".into(), json!(true)); } let child_space = if matches!(ty, "GROUP" | "BOOLEAN_OPERATION") { space * view.transform().unwrap_or_default() } else { Affine::IDENTITY }; - let mut kids = if connector_snapshot && !connector_terminal { self.connector_snapshot_children(&view, &rest_id) } else { Vec::new() }; + let mut kids = self.map_layer_children(LayerChildren { + id, + inst, + base, + child_space, + view: &view, + object: &mut o, + ty, + rest_id: &rest_id, + connector_snapshot, + connector_terminal, + depth, + included, + slot_content, + }); + if stand.is_some_and(|s| s.0 == "FRAME") { + kids.splice(0..0, self.shape_with_text(&mut o, &view, own, &rest_id)); + kids.extend(self.inner_text(&o, &view, &rest_id, own == "STICKY")); + } + if !kids.is_empty() { + o.insert("children".into(), Value::Array(kids)); + } + Some(Value::Object(o)) + } + + fn map_layer_children(&mut self, context: LayerChildren<'_, 'a>) -> Vec { + let LayerChildren { + id, + inst, + base, + child_space, + view, + object: o, + ty, + rest_id, + connector_snapshot, + connector_terminal, + depth, + included, + slot_content, + } = context; + let mut kids = if connector_snapshot && !connector_terminal { self.connector_snapshot_children(view, rest_id) } else { Vec::new() }; if connector_terminal { // The saved vector is flattened into the connector node when no child slot remains. } else if ty == "INSTANCE" { - if let Some((sub, sym)) = self.instance(&mut o, &view, id, inst, &rest_id) { + if let Some((sub, sym)) = self.instance(o, view, id, inst, rest_id) { let main = self.nodes.get(&sym).map(|m| View::of(m)); let fit = Fit::frame(main.as_ref().and_then(View::size), view.size()); // An instance that records no auto layout of its own has its main's. let view = match main { Some(m) if view.get("stackMode").is_none() && m.get("stackMode").is_some() => { let layered = View { fitted: view.fitted, ..m.with(view.layers.iter().copied()) }; - self.auto_layout(&mut o, &layered); + self.auto_layout(o, &layered); layered } _ => view.clone(), @@ -252,7 +303,7 @@ impl<'a> Mapper<'a> { content_inst.slot_content = true; } for c in self.children(&content) { - kids.extend(self.layer_filtered(&c, content_inst.as_ref(), child_space, Some(&view), None, depth + 1, included)); + kids.extend(self.layer_filtered(&c, content_inst.as_ref(), child_space, Some(view), None, depth + 1, included)); } } else { // Inside an instance a layer's children are its main's: they follow it from the main's @@ -263,17 +314,29 @@ impl<'a> Mapper<'a> { (Some(_), _) => Fit::frame(View::of(base).size(), view.size()), }; for c in self.children(id) { - kids.extend(self.layer_filtered(&c, inst, child_space, Some(&view), fit, depth + 1, included)); + kids.extend(self.layer_filtered(&c, inst, child_space, Some(view), fit, depth + 1, included)); } } - if stand.is_some_and(|s| s.0 == "FRAME") { - kids.splice(0..0, self.shape_with_text(&mut o, &view, own, &rest_id)); - kids.extend(self.inner_text(&o, &view, &rest_id, own == "STICKY")); + kids + } + + fn mapped_type<'s>( + own: &'s str, + view: &View<'a>, + connector_snapshot: bool, + connector_terminal: bool, + stand: Option<(&'static str, &'static str)>, + ) -> Option<&'s str> { + match own { + "ROUNDED_RECTANGLE" => Some("RECTANGLE"), + "SYMBOL" => Some("COMPONENT"), + "FRAME" if view.flag("isStateGroup") == Some(true) => Some("COMPONENT_SET"), + "FRAME" if view.flag("resizeToFit") == Some(true) => Some("GROUP"), + "VARIABLE" | "VARIABLE_SET" => None, + "CONNECTOR" if connector_snapshot && connector_terminal => Some("VECTOR"), + "CONNECTOR" if connector_snapshot => Some("FRAME"), + t => Some(stand.map_or(t, |s| s.0)), } - if !kids.is_empty() { - o.insert("children".into(), Value::Array(kids)); - } - Some(Value::Object(o)) } /// A non-rectangular shape-with-text's shape, as a vector under its frame (which gives up its diff --git a/crates/format/src/fig/source_identity_tests.rs b/crates/format/src/fig/source_identity_tests.rs index 927b0227..09ec0c60 100644 --- a/crates/format/src/fig/source_identity_tests.rs +++ b/crates/format/src/fig/source_identity_tests.rs @@ -983,6 +983,11 @@ fn assert_external_namespace(doc: &Document) { let copied_frame = child(doc, copied_body, 0); assert_eq!(doc.node(frame).unwrap().source, None, "materialized ordinary layers are owned by the frame"); assert_eq!(doc.node(copied_frame).unwrap().source, Some(frame)); + assert_external_slots(doc, frame, copied_frame, main); + assert_external_copy_state(doc, frame, copied_frame); +} + +fn assert_external_slots(doc: &Document, frame: NodeId, copied_frame: NodeId, main: NodeId) { for index in 0..2 { let slot = child(doc, frame, index); let copied_slot = child(doc, copied_frame, index); @@ -999,6 +1004,9 @@ fn assert_external_namespace(doc: &Document) { assert_eq!(doc.node(child(doc, current, 0)).unwrap().source, Some(child(doc, inherited, 0))); assert_eq!(doc.node(child(doc, current, 0)).unwrap().transform.translation().x, 4.0); } +} + +fn assert_external_copy_state(doc: &Document, frame: NodeId, copied_frame: NodeId) { let old = doc.node(child(doc, frame, 0)).unwrap(); let current = doc.node(child(doc, copied_frame, 0)).unwrap(); assert!(!old.visible && old.is_mask && old.opacity < 1.0); diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 35d8d08a..4ebb117f 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -630,63 +630,82 @@ fn imports_the_fixture() { assert!(is_fig(&bytes)); let (doc, warnings) = import(&bytes, "fallback").unwrap(); assert_eq!(doc.name, "Fixture"); + assert_fixture_layers(&doc); + assert_fixture_styles(&doc); + assert_fixture_variables(&doc); + assert_fixture_components(&doc); + assert_fixture_prototype(&doc, &warnings); +} + +fn assert_fixture_layers(doc: &Document) { // The internal page is skipped. assert_eq!(doc.pages.len(), 1); let page = doc.node(doc.pages[0]).unwrap(); assert_eq!(page.name, "Page A"); // Siblings by position: the frame comes first even though it's listed after the component. assert_eq!(doc.node(page.children[0]).unwrap().name, "Screen"); - let screen = named(&doc, "Screen"); + let screen = named(doc, "Screen"); let names: Vec<&str> = screen.children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect(); assert_eq!(names, ["Photo", "Title", "Group", "Styled", "Card", "Button copy", "Note"]); // Image fill from the archive. - let photo = named(&doc, "Photo"); + let photo = named(doc, "Photo"); let figcraft_doc::color::PaintKind::Image(img) = &photo.fills[0].kind else { panic!("not an image fill") }; assert_eq!(doc.images.get(&img.image).map(|i| (i.width, i.height)), Some((2, 1))); assert_eq!((photo.corners.radii[0], photo.stroke.weight), (6.0, 2.0)); // Text with a bold run over "the". - let NodeKind::Text(t) = &named(&doc, "Title").kind else { panic!("not text") }; + let NodeKind::Text(t) = &named(doc, "Title").kind else { panic!("not text") }; assert_eq!(t.characters, "Hi there"); assert_eq!(t.style.font_size, 16.0); assert_eq!(t.leading_trim, figcraft_doc::LeadingTrim::CapHeight, "vertical trim (#466)"); assert_eq!(t.runs.len(), 1); assert_eq!((t.runs[0].start, t.runs[0].end, t.runs[0].style.font_style.as_str()), (3, 6, "Bold")); // The group's children keep their place on the page (group at 100, 100). - assert!(matches!(named(&doc, "Group").kind, NodeKind::Group)); - let tri = named(&doc, "Tri"); + assert!(matches!(named(doc, "Group").kind, NodeKind::Group)); + let tri = named(doc, "Tri"); assert!(matches!(&tri.kind, NodeKind::Vector { network } if !network.regions.is_empty())); let abs = doc.world_transform(tri.id).translation(); assert!((abs.x - 105.0).abs() < 1e-6 && (abs.y - 106.0).abs() < 1e-6, "{abs:?}"); - assert!(matches!(named(&doc, "Star").kind, NodeKind::Star { count: 6, .. })); - // The linked style supplies the paint. - let styled = named(&doc, "Styled"); + assert!(matches!(named(doc, "Star").kind, NodeKind::Star { count: 6, .. })); +} + +fn assert_fixture_styles(doc: &Document) { + let styled = named(doc, "Styled"); assert_eq!(styled.styles.fill.as_deref(), Some("1:3")); assert!(doc.styles.iter().any(|s| s.name == "Brand")); let figcraft_doc::color::PaintKind::Solid { color } = styled.fills[0].kind else { panic!("not solid") }; assert_eq!((color.r, color.g, color.b), (0.0, 0.0, 1.0)); - // Variables, the fill binding and the explicit mode. +} + +fn assert_fixture_variables(doc: &Document) { + let screen = named(doc, "Screen"); assert_eq!(doc.variables.collections.len(), 1); assert_eq!(doc.variables.variables[0].kind, VariableType::Color); - assert_eq!(named(&doc, "Card").bound.get("fill").map(String::as_str), Some("1:5")); + assert_eq!(named(doc, "Card").bound.get("fill").map(String::as_str), Some("1:5")); assert_eq!(screen.var_modes.get("1:4").map(String::as_str), Some("1:91")); - // The component keeps its property; the instance is expanded with the value applied. - let button = named(&doc, "Button"); +} + +fn assert_fixture_components(doc: &Document) { + let button = named(doc, "Button"); assert!(matches!(&button.kind, NodeKind::Component { props, .. } if props.contains_key("Show icon"))); - let inst = named(&doc, "Button copy"); + let inst = named(doc, "Button copy"); assert!( matches!(&inst.kind, NodeKind::Instance { component, props } if *component == button.id && props.get("Show icon") == Some(&Value::Bool(false))) ); let icon = doc.node(inst.children[0]).unwrap(); assert_eq!(icon.name, "Renamed icon"); assert!(!icon.visible); - // Prototype: the click navigates to the component; the flow starts at the screen. - let it = &photo.interactions[0]; +} + +fn assert_fixture_prototype(doc: &Document, warnings: &[String]) { + let button = named(doc, "Button"); + let screen = named(doc, "Screen"); + let it = &named(doc, "Photo").interactions[0]; assert!(matches!(it.actions[0], figcraft_doc::proto::Action::Navigate { dest, .. } if dest == button.id)); assert_eq!(it.duration_ms, 250); let Some(NodeKind::Page { flows, .. }) = doc.node(doc.pages[0]).map(|p| &p.kind) else { panic!("no page") }; assert_eq!((flows[0].name.as_str(), flows[0].start), ("Main flow", screen.id)); // The sticky note comes in as a frame, with a warning. - assert!(matches!(named(&doc, "Note").kind, NodeKind::Frame)); + assert!(matches!(named(doc, "Note").kind, NodeKind::Frame)); assert!(warnings.iter().any(|w| w.contains("sticky layers import as frames")), "{warnings:?}"); } diff --git a/crates/format/src/rest/proto_variable_tests.rs b/crates/format/src/rest/proto_variable_tests.rs index cbaa3749..d2fdde9a 100644 --- a/crates/format/src/rest/proto_variable_tests.rs +++ b/crates/format/src/rest/proto_variable_tests.rs @@ -209,8 +209,14 @@ fn embedded_variables_match_separate_ones() { #[test] fn interactions_triggers_actions_and_transitions() { let (doc, warnings) = import_with(&file_json(), Some(&variables_json()), &no_images).unwrap(); - let id = |n: &str| named(&doc, n).id; - let button = named(&doc, "Button"); + assert_interactions(&doc, &warnings); + assert_legacy_transition(&doc); + assert_interactions_without_variables(); +} + +fn assert_interactions(doc: &Document, warnings: &[String]) { + let id = |n: &str| named(doc, n).id; + let button = named(doc, "Button"); let its = &button.interactions; assert_eq!(its.len(), 8, "{its:#?}"); @@ -239,18 +245,22 @@ fn interactions_triggers_actions_and_transitions() { assert_eq!(its[7].trigger, Trigger::MouseUp); assert_eq!(its[7].actions, [Action::ScrollTo { dest: id("Home"), offset: [0.0, 0.0] }], "unresolved destinations are dropped"); - // The legacy single transition. - let details = named(&doc, "Details"); + assert!(!has(warnings, "modifier keys"), "{warnings:?}"); + for w in ["video triggers", "without a destination"] { + assert!(has(warnings, w), "{w}: {warnings:?}"); + } +} + +fn assert_legacy_transition(doc: &Document) { + let id = |n: &str| named(doc, n).id; + let details = named(doc, "Details"); assert_eq!(details.interactions.len(), 1); let legacy = &details.interactions[0]; assert_eq!((&legacy.trigger, &legacy.actions[..]), (&Trigger::OnClick, &[Action::Navigate { dest: id("Home"), preserve_scroll: false }][..])); assert_eq!((legacy.transition, legacy.duration_ms, legacy.easing), (Transition::Dissolve, 200, Easing::EaseIn)); +} - assert!(!has(&warnings, "modifier keys"), "{warnings:?}"); - for w in ["video triggers", "without a destination"] { - assert!(has(&warnings, w), "{w}: {warnings:?}"); - } - // Without variables, set-variable actions have nothing to set. +fn assert_interactions_without_variables() { let (doc, warnings) = import(&file_json(), &no_images).unwrap(); assert_eq!(named(&doc, "Button").interactions.len(), 6); assert!(has(&warnings, "set-variable actions") && has(&warnings, "set-variable-mode actions"), "{warnings:?}"); From bafda992a79dad79acf43d677daec43067eb5280 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 18:03:24 +0000 Subject: [PATCH 17/33] docs: clarify crate lint placement --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index fe6134a6..f61412ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,7 +62,7 @@ code. - No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!` in shipped code (workspace clippy lints deny them; tests may use them). Return `Result`/`Option`; use `.get()` for data-derived indexes; slice strings only at char boundaries; check floats are finite; cap sizes. -- New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`; production crate roots must begin with the required crate-level lint attribute. +- New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes before modules or other crate items; crate-level `//!` documentation may precede them. - `figcraft_engine::guard` turns a panicking command into `EngineError::Internal` and the document is rolled back; the desktop app also guards each UI frame and writes recovery copies. These are safety nets, not substitutes for `Result`. From 23f57c86607669ad28b98be2e6792ab389220bcb Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 16:00:01 +0000 Subject: [PATCH 18/33] refactor(engine): simplify command handling for strict clippy --- crates/engine/src/cmd/arrange.rs | 4 +- crates/engine/src/cmd/comment.rs | 4 +- crates/engine/src/cmd/component.rs | 11 +- crates/engine/src/cmd/component/instance.rs | 7 +- crates/engine/src/cmd/component/props.rs | 7 +- crates/engine/src/cmd/component/variants.rs | 5 +- crates/engine/src/cmd/create.rs | 4 +- crates/engine/src/cmd/edit.rs | 7 +- crates/engine/src/cmd/edit/clipboard.rs | 5 +- crates/engine/src/cmd/edit/props.rs | 5 +- crates/engine/src/cmd/edit/select.rs | 4 +- crates/engine/src/cmd/file.rs | 7 +- crates/engine/src/cmd/file/load.rs | 2 +- crates/engine/src/cmd/file/version.rs | 2 +- crates/engine/src/cmd/image.rs | 2 +- crates/engine/src/cmd/layout.rs | 2 +- crates/engine/src/cmd/library.rs | 9 +- crates/engine/src/cmd/lists.rs | 4 +- crates/engine/src/cmd/page.rs | 4 +- crates/engine/src/cmd/props.rs | 1117 ++++++++++--------- crates/engine/src/cmd/proto.rs | 10 +- crates/engine/src/cmd/proto_actions.rs | 6 +- crates/engine/src/cmd/section.rs | 2 +- crates/engine/src/cmd/structure.rs | 7 +- crates/engine/src/cmd/styles.rs | 7 +- crates/engine/src/cmd/text.rs | 7 +- crates/engine/src/cmd/variables.rs | 7 +- crates/engine/src/cmd/variables_io.rs | 4 +- crates/engine/src/cmd/view.rs | 4 +- 29 files changed, 698 insertions(+), 568 deletions(-) diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index 8f8e40d2..4806afb3 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -1,12 +1,12 @@ //! Arrange: align, distribute, tidy, z-order, flips, rotations and numeric transforms. -use figcraft_doc::*; +use figcraft_doc::{Document, EffectKind, LetterSpacing, LineHeight, Node, NodeId, NodeKind, Sizing, TextAutoResize, TextStyle}; use figcraft_geom::{Affine, Point, Rect}; use serde_json::{Value, json}; use super::edit::{roots, z_sorted}; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{boolean, get, num, num_or, req_id, req_num, req_targets, string}; use crate::{Result, Session, bad, spec}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/comment.rs b/crates/engine/src/cmd/comment.rs index d1d360f4..8cff0c42 100644 --- a/crates/engine/src/cmd/comment.rs +++ b/crates/engine/src/cmd/comment.rs @@ -4,11 +4,11 @@ //! message). `author` is who is acting (default: the name from Preferences, `pref.userName`): only //! a message's author edits or deletes it, and only the thread's author deletes the thread. -use figcraft_doc::*; +use figcraft_doc::{Comment, Document, Message, Node, NodeId, NodeKind, geom}; use serde_json::{Value, json}; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{boolean, get, id, id_of, num, req_index, req_num, req_string, string}; use crate::{Session, bad, spec}; /// The name comments are signed with until the user sets theirs. diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 5abe79b8..0f59f41e 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -2,13 +2,18 @@ use std::collections::{BTreeMap, BTreeSet}; -use figcraft_doc::*; +use figcraft_doc::{Document, Node, NodeId, NodeKind, PropDef, VariableType, VariableValue}; use serde_json::{Value, json}; use super::edit::{roots, z_sorted}; use super::{CommandSpec, ok}; -use crate::components::*; -use crate::params::*; +use crate::components::{ + OVERRIDE_GROUPS, PROP_BINDING, SLOT, copy_group, create_instance, create_instance_at, default_props, default_variant, exposed_copies, + find_variant, inside_instance, instances_of, is_slot, keep_text_overrides, owning_component, owning_instance, prop_default_value, + rebase_instance, root_instance, set_boolean_default, slot_edited, slot_name, slots_of, takes_value, unlink_layers, variant_conflicts, + variant_name, variant_options, variant_props, variant_set, +}; +use crate::params::{boolean, get, id, id_of, num, req_id, req_num, req_string, req_targets, string, targets}; use crate::{EngineError, Result, Session, bad, spec}; mod instance; diff --git a/crates/engine/src/cmd/component/instance.rs b/crates/engine/src/cmd/component/instance.rs index ffc5ec1c..f6600c10 100644 --- a/crates/engine/src/cmd/component/instance.rs +++ b/crates/engine/src/cmd/component/instance.rs @@ -1,5 +1,10 @@ use super::props::{Owner, add_prop_for, delete_prop, prop_defs}; -use super::*; +use super::{ + BTreeMap, BTreeSet, Document, EngineError, NodeId, NodeKind, OVERRIDE_GROUPS, PROP_BINDING, PropDef, Result, SLOT, Session, Value, bad, boolean, + copy_group, create_instance_at, default_props, get, id_of, inside_instance, is_slot, json, keep_text_overrides, main_of, ok, owning_component, + owning_instance, rebase_instance, req_id, req_num, req_targets, root_instance, slot_edited, slot_name, slots_of, string, targets, unlink_layers, + variant_options, +}; pub(super) fn detach(s: &mut Session, p: &Value) -> Result { let cmd = "component.detach"; diff --git a/crates/engine/src/cmd/component/props.rs b/crates/engine/src/cmd/component/props.rs index 3f2d7002..bb3dba4c 100644 --- a/crates/engine/src/cmd/component/props.rs +++ b/crates/engine/src/cmd/component/props.rs @@ -1,6 +1,11 @@ use super::instance::preferred_arg; use super::variants::{fit_set, wrap_in_set}; -use super::*; +use super::{ + BTreeMap, Document, NodeId, NodeKind, PROP_BINDING, PropDef, Result, SLOT, Session, Value, VariableType, VariableValue, bad, boolean, + default_variant, find_variant, get, id, id_of, inside_instance, instances_of, json, main_of, ok, owning_component, prop_default_value, + req_string, req_targets, set_boolean_default, slot_edited, slots_of, string, takes_value, targets, variant_conflicts, variant_name, + variant_options, variant_props, variant_set, +}; /// Whether instance `id` has a component property `name` that a `kind` variable can drive (a /// boolean property takes a boolean variable, a text or variant property a string one). diff --git a/crates/engine/src/cmd/component/variants.rs b/crates/engine/src/cmd/component/variants.rs index f6e7ac6c..5f9e218a 100644 --- a/crates/engine/src/cmd/component/variants.rs +++ b/crates/engine/src/cmd/component/variants.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + BTreeMap, BTreeSet, Document, EngineError, Node, NodeId, NodeKind, Result, Session, Value, bad, inside_instance, json, owning_instance, + req_targets, roots, set_name_from, string, targets, variant_name, variant_options, variant_props, variant_set, z_sorted, +}; /// Wrap components in a new component set (named after the first) placed around them. pub(super) fn wrap_in_set(cmd: &str, doc: &mut Document, ids: &[NodeId]) -> Result { diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 414a2310..2e64ce04 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -1,12 +1,12 @@ //! Creating nodes. use figcraft_color::{ImageScaleMode, Paint}; -use figcraft_doc::*; +use figcraft_doc::{Document, Node, NodeId, NodeKind, StrokeCap, TextAutoResize, TextData, VectorNetwork}; use figcraft_geom::Point; use serde_json::{Value, json}; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{boolean, f64_of, get, id, num, num_or, opt_id, req_targets, string}; use crate::{Result, Session, bad, spec}; /// Frame presets shown by the Frame tool (category, name, width, height). diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 7ec63c69..2d51b958 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -2,12 +2,15 @@ use std::collections::{BTreeMap, BTreeSet}; -use figcraft_doc::*; +use figcraft_doc::{Document, Effect, ExportSetting, ImageData, LayoutGrid, Node, NodeId, NodeKind}; use figcraft_geom::Affine; use serde_json::{Map, Value, json}; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{ + blend, boolean, color, get, id, id_of, num, num_or, opt_index, paint_of, parse, req_id, req_index, req_num, req_string, req_targets, string, + targets, +}; use crate::{BatchStep, EngineError, Result, Session, bad, spec}; mod clipboard; diff --git a/crates/engine/src/cmd/edit/clipboard.rs b/crates/engine/src/cmd/edit/clipboard.rs index d0e7b2dc..0df97532 100644 --- a/crates/engine/src/cmd/edit/clipboard.rs +++ b/crates/engine/src/cmd/edit/clipboard.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Affine, BTreeMap, BTreeSet, Document, EngineError, ImageData, Node, NodeId, NodeKind, Result, Session, Value, bad, delete, id, json, num, ok, + req_targets, roots, string, targets, z_sorted, +}; /// The copied form of the selection. pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<()> { diff --git a/crates/engine/src/cmd/edit/props.rs b/crates/engine/src/cmd/edit/props.rs index f793b50c..91855c2e 100644 --- a/crates/engine/src/cmd/edit/props.rs +++ b/crates/engine/src/cmd/edit/props.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + BTreeMap, Effect, EngineError, Map, Node, NodeId, NodeKind, Result, Session, Value, bad, blend, boolean, color, get, json, num, ok, opt_index, + paint_of, parse, req_id, req_index, req_string, req_targets, roots, string, targets, z_sorted, +}; pub(super) fn batch_rename(s: &mut Session, p: &Value) -> Result { let cmd = "object.batchRename"; diff --git a/crates/engine/src/cmd/edit/select.rs b/crates/engine/src/cmd/edit/select.rs index 03bba890..ca1da3bb 100644 --- a/crates/engine/src/cmd/edit/select.rs +++ b/crates/engine/src/cmd/edit/select.rs @@ -1,4 +1,6 @@ -use super::*; +use super::{ + Affine, BTreeMap, BTreeSet, Node, NodeId, Result, Session, Value, bad, boolean, json, num, num_or, ok, req_num, req_string, req_targets, roots, +}; pub(super) fn layers_at(s: &mut Session, p: &Value) -> Result { let (x, y) = (req_num("select.layersAt", p, "x")?, req_num("select.layersAt", p, "y")?); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index fed4bff1..7c901293 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -3,12 +3,15 @@ use std::sync::Arc; use base64::Engine as _; -use figcraft_doc::*; +use figcraft_doc::{ + BOARD_PAGE, Document, ExportFormat, ExportSetting, ExportSize, FileKind, LineHeight, Node, NodeId, NodeKind, PrintSettings, StyleKind, TextStyle, + VdCompat, +}; use figcraft_format::Version; use serde_json::{Value, json}; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{bool_of, boolean, get, id, num, num_or, req_index, req_string, string, targets}; use crate::{DocState, EngineError, Result, Session, bad, spec}; mod export; diff --git a/crates/engine/src/cmd/file/load.rs b/crates/engine/src/cmd/file/load.rs index 071c651e..a033475e 100644 --- a/crates/engine/src/cmd/file/load.rs +++ b/crates/engine/src/cmd/file/load.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{DocState, Document, EngineError, LineHeight, NodeId, NodeKind, Result, StyleKind, TextStyle, Value, bad, get, json}; /// The recovery copy at `path` as a document with unsaved changes and no path. pub(crate) fn recovered(path: &str) -> Result { diff --git a/crates/engine/src/cmd/file/version.rs b/crates/engine/src/cmd/file/version.rs index 3d311a44..a46fab8e 100644 --- a/crates/engine/src/cmd/file/version.rs +++ b/crates/engine/src/cmd/file/version.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, DocState, Document, EngineError, Result, Session, Value, Version, bad, get, json, req_index, string}; pub(super) fn version_json(index: usize, v: &Version) -> Value { let mut j = json!({"id": v.id, "index": index, "name": v.name, "description": v.description, "time": v.time}); diff --git a/crates/engine/src/cmd/image.rs b/crates/engine/src/cmd/image.rs index bb11f011..c82e30d1 100644 --- a/crates/engine/src/cmd/image.rs +++ b/crates/engine/src/cmd/image.rs @@ -5,7 +5,7 @@ use figcraft_doc::{Document, ImagePreset}; use serde_json::{Value, json}; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{f64_of, get, num, req_index, req_string, req_targets, string, targets}; use crate::{Result, Session, bad, spec}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 5a0a4000..e22ca498 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -6,7 +6,7 @@ use serde_json::Value; use super::props::sizing; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{get, num, req_num, req_targets, string}; use crate::{Result, Session, bad, spec}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 7892102d..0c7edbb2 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -5,16 +5,19 @@ use std::collections::BTreeSet; use std::sync::Arc; -use figcraft_doc::*; +use figcraft_doc::{Collection, Document, ImportedComponent, ImportedRecord, LibraryLink, LibraryManifest, NodeId, PublishedLibrary, VariableId}; use serde_json::{Value, json}; use figcraft_collab::message::{LibraryReply, LibraryReq}; use super::CommandSpec; -use crate::library::*; +use crate::library::{ + Pending, Selection, absolute_path, accept_updates, asset_root, default_selection, find_link, import, import_styles, import_variables, + is_remote_variable, pending, prune, publish, publishable, published_collections, published_variables, +}; use crate::library_server::{archive, is_server_link, read_archive, shown_path}; use crate::live::{NetReply, NetRequest, PublishOptions}; -use crate::params::*; +use crate::params::{boolean, get, id_of, num, req_string, string}; use crate::{EngineError, Result, Session, bad, spec}; /// Longest library description kept (chars). diff --git a/crates/engine/src/cmd/lists.rs b/crates/engine/src/cmd/lists.rs index 287c9ddd..e53291b6 100644 --- a/crates/engine/src/cmd/lists.rs +++ b/crates/engine/src/cmd/lists.rs @@ -6,11 +6,11 @@ //! Every list is stored bottom first: index 0 paints (or applies) first, the last one is on top; //! the Design panel shows them in reverse, the topmost row first. -use figcraft_doc::*; +use figcraft_doc::{Document, LayoutGrid, Node, NodeId}; use serde_json::Value; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{req_index, req_targets}; use crate::{Result, Session, spec}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index 08e65b0b..44c7b367 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -1,11 +1,11 @@ //! Pages. use figcraft_doc::proto::Flow; -use figcraft_doc::*; +use figcraft_doc::{Guide, NodeId, NodeKind}; use serde_json::{Value, json}; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{boolean, color, id, req_index, req_num, req_string, string}; use crate::{Session, bad, spec}; /// After a page edit: the file keeps a page that isn't a divider, and the canvas shows one (the diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 30a10393..469fcb01 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -1,11 +1,15 @@ //! Property editing: `node.set` and the shorthand keys every surface uses. use figcraft_color::{Color, Paint}; -use figcraft_doc::*; +use figcraft_doc::{ + AutoLayout, Brush, ChildLayout, DecorationStyle, DevStatus, Document, Effect, GridTrack, LayoutMode, LetterSpacing, LineHeight, ListStyle, + MAX_LIST_INDENT, MaskType, Node, NodeId, NodeKind, Sizing, StrokeCap, StrokeProps, TextAutoResize, TextCase, TextData, TextDecoration, + WidthPoint, WidthPreset, WidthProfile, style_weight, valid_axis_tag, valid_feature_tag, +}; use figcraft_geom::{Affine, Point}; use serde_json::{Map, Value, json}; -use crate::params::*; +use crate::params::{bool_of, color_of, f64_of, get, paints_of}; use crate::{Result, bad}; /// The per-side stroke weight keys, in `StrokeProps::sides` order (top, right, bottom, left). @@ -409,6 +413,587 @@ fn fix_axis(doc: &mut Document, id: NodeId, is_w: bool) -> Result<()> { Ok(()) } +/// The `geometry` properties of `apply`; `false` when `k` is not one of them. +fn apply_geometry( + cmd: &str, + doc: &mut Document, + id: NodeId, + props: &Map, + k: &str, + v: &Value, + path_offset: &mut (f64, f64), +) -> Result { + match k { + "path" => *path_offset = set_path(cmd, doc, id, v, props)?, + // A new path takes the size asked for as its scale (`set_path`); the size still fixes + // its auto layout axis. + "width" | "height" | "w" | "h" if props.contains_key("path") => fix_axis(doc, id, k.starts_with('w'))?, + "x" | "y" => { + let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?; + let val = val + if k == "x" { path_offset.0 } else { path_offset.1 }; + // X/Y are the node's own origin (its rotated top-left for rotated nodes), in the + // frame it is in (groups have no space of their own). + let n = doc.get(id)?; + let group = n.parent.filter(|p| doc.node(*p).is_some_and(|pn| pn.kind.bounds_follow_children())); + let space = group.map_or(Affine::IDENTITY, |g| doc.frame_relative(g)); + let c = (space * n.transform).as_coeffs(); + let (mut tx, mut ty) = (c[4], c[5]); + if k == "x" { + tx = val; + } else { + ty = val; + } + doc.get_mut(id)?.transform = space.inverse() * Affine::new([c[0], c[1], c[2], c[3], tx, ty]); + } + "width" | "height" | "w" | "h" => { + let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0); + let is_w = k.starts_with('w'); + // With the proportions locked (the Layout panel's link) and only one side set, the + // other side follows the node's ratio; both sides in one `node.set` stay as typed. + let (nw, nh) = { + let n = doc.get(id)?; + let (ow, oh) = (n.width, n.height); + let (other, other_short) = if is_w { ("height", "h") } else { ("width", "w") }; + let linked = n.constrain_proportions && !props.contains_key(other) && !props.contains_key(other_short) && ow > 1e-9 && oh > 1e-9; + let derived = if is_w { oh * val / ow } else { ow * val / oh }; + match (is_w, linked && derived.is_finite()) { + (true, true) => (val, derived), + (false, true) => (derived, val), + (true, false) => (val, oh), + (false, false) => (ow, val), + } + }; + resize_node(doc, id, nw, nh)?; + // Explicit size makes text fixed in that dimension. + if let Some(t) = doc.get_mut(id)?.kind.text_mut() { + t.fix_size(is_w, !is_w); + } + fix_axis(doc, id, is_w)?; + } + "rotation" => { + let deg = f64_of(v).ok_or_else(|| bad(cmd, "`rotation` must be degrees"))?; + let n = doc.get_mut(id)?; + n.transform = figcraft_geom::set_rotation_about_center(n.transform, n.width, n.height, deg); + } + _ => return Ok(false), + } + Ok(true) +} + +/// The `look` properties of `apply`; `false` when `k` is not one of them. +fn apply_look(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -> Result { + match k { + "name" => doc.get_mut(id)?.name = string_val(v).ok_or_else(|| bad(cmd, "`name` must be a string"))?, + "visible" => doc.get_mut(id)?.visible = v.as_bool().ok_or_else(|| bad(cmd, "`visible` must be a boolean"))?, + "locked" => doc.get_mut(id)?.locked = v.as_bool().ok_or_else(|| bad(cmd, "`locked` must be a boolean"))?, + "opacity" => { + let o = f64_of(v).ok_or_else(|| bad(cmd, "`opacity` must be a number"))?; + let o = if o > 1.0 { o / 100.0 } else { o }; + doc.get_mut(id)?.opacity = o.clamp(0.0, 1.0) as f32; + } + "blend" | "blendMode" => { + let b = v.as_str().and_then(figcraft_color::BlendMode::from_name).ok_or_else(|| bad(cmd, "unknown blend mode"))?; + doc.get_mut(id)?.blend = b; + } + "fills" => set_paints(doc.get_mut(id)?, true, paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?), + "fill" => { + if paint_none(v) { + set_paints(doc.get_mut(id)?, true, vec![]); + } else { + let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour, \"none\" or null"))?; + set_paints(doc.get_mut(id)?, true, vec![Paint::solid(c)]); + } + } + "strokes" => set_paints(doc.get_mut(id)?, false, paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?), + "stroke" => { + if paint_none(v) { + set_paints(doc.get_mut(id)?, false, vec![]); + } else { + let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour, \"none\" or null"))?; + set_paints(doc.get_mut(id)?, false, vec![Paint::solid(c)]); + } + } + "strokeWeight" => doc.get_mut(id)?.stroke.weight = f64_of(v).ok_or_else(|| bad(cmd, "`strokeWeight` must be a number"))?.max(0.0), + "strokeAlign" => doc.get_mut(id)?.stroke.align = enum_of(v).ok_or_else(|| bad(cmd, "strokeAlign: inside|center|outside"))?, + "strokeSides" => { + doc.get_mut(id)?.stroke.sides = if v.is_null() { None } else { Some(four(v).ok_or_else(|| bad(cmd, "strokeSides: [t,r,b,l]"))?) } + } + "strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight" | "strokeLeftWeight" => { + let i = STROKE_SIDES.iter().position(|x| *x == k).unwrap_or(0); + let w = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0); + let st = &mut doc.get_mut(id)?.stroke; + // One side set on its own turns on individual strokes, the others at the weight. + let mut sides = st.sides.unwrap_or([st.weight; 4]); + if let Some(slot) = sides.get_mut(i) { + *slot = w; + } + st.sides = Some(sides); + } + "strokeCap" => { + let c: StrokeCap = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?; + let n = doc.get_mut(id)?; + n.stroke.cap_start = c; + n.stroke.cap_end = c; + } + "startCap" => doc.get_mut(id)?.stroke.cap_start = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?, + "endCap" => doc.get_mut(id)?.stroke.cap_end = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?, + "strokeJoin" => doc.get_mut(id)?.stroke.join = enum_of(v).ok_or_else(|| bad(cmd, "strokeJoin: miter|bevel|round"))?, + "miterLimit" => doc.get_mut(id)?.stroke.miter_limit = f64_of(v).unwrap_or(4.0).clamp(1.0, 100.0), + "miterAngle" => { + let deg = f64_of(v).ok_or_else(|| bad(cmd, "`miterAngle` must be degrees"))?; + doc.get_mut(id)?.stroke.miter_limit = StrokeProps::miter_limit_for(deg); + } + "dashes" => { + let d: Vec = v.as_array().map(|a| a.iter().filter_map(f64_of).map(|x| x.max(0.0)).collect()).unwrap_or_default(); + doc.get_mut(id)?.stroke.dashes = d; + } + "widthProfile" => doc.get_mut(id)?.stroke.width_profile = width_profile_of(v).ok_or_else(|| bad(cmd, WIDTH_PROFILE_HELP))?, + "brush" | "strokeBrush" => doc.get_mut(id)?.stroke.brush = brush_of(v).ok_or_else(|| bad(cmd, BRUSH_HELP))?, + _ => return Ok(false), + } + Ok(true) +} + +/// The `corners` properties of `apply`; `false` when `k` is not one of them. +fn apply_corners(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -> Result { + match k { + "cornerRadius" => { + let r = four(v).ok_or_else(|| bad(cmd, "cornerRadius: n or [tl,tr,br,bl]"))?.map(|x| x.max(0.0)); + let n = doc.get_mut(id)?; + n.corners.radii = r; + // A vector's corner radius rounds every point (edit mode then sets them one by one). + if let NodeKind::Vector { network } = &mut n.kind { + network.vertices.iter_mut().for_each(|p| p.radius = r[0]); + crate::vectoredit::refit(doc, id)?; + } + } + "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => { + let i = ["topLeftRadius", "topRightRadius", "bottomRightRadius", "bottomLeftRadius"].iter().position(|x| *x == k).unwrap_or(0); + let r = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0); + if let Some(slot) = doc.get_mut(id)?.corners.radii.get_mut(i) { + *slot = r; + } + } + "cornerSmoothing" => doc.get_mut(id)?.corners.smoothing = f64_of(v).unwrap_or(0.0).clamp(0.0, 1.0), + "effects" => doc.get_mut(id)?.effects = effects_from(cmd, v)?, + "clip" | "clipsContent" => doc.get_mut(id)?.clip = v.as_bool().ok_or_else(|| bad(cmd, "`clip` must be a boolean"))?, + "isMask" => doc.get_mut(id)?.is_mask = v.as_bool().unwrap_or(false), + "devStatus" => { + let st = if v.is_null() { + Some(DevStatus::None) + } else { + v.as_str().and_then(|s| DevStatus::ALL.into_iter().find(|d| d.id().eq_ignore_ascii_case(s))) + }; + let st = st.ok_or_else(|| bad(cmd, "devStatus: none|readyForDev|completed"))?; + if !st.is_none() && !crate::devinspect::has_status(doc, id) { + return Err(bad(cmd, "devStatus: only sections and frames at the top of a page or section take one")); + } + let n = doc.get_mut(id)?; + n.dev_status = st; + // Marking a layer (again) starts from its current contents. + n.dev_changed = false; + } + "maskType" => { + let t = v.as_str().and_then(|s| MaskType::ALL.into_iter().find(|m| m.id().eq_ignore_ascii_case(s))); + doc.get_mut(id)?.mask_type = t.ok_or_else(|| bad(cmd, "maskType: alpha|vector|luminance"))?; + } + _ => return Ok(false), + } + Ok(true) +} + +/// The `layout` properties of `apply`; `false` when `k` is not one of them. +fn apply_layout(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -> Result { + match k { + "layout" | "layoutMode" => { + let n = doc.get_mut(id)?; + if !n.kind.is_frame_like() { + return Err(bad(cmd, "auto layout needs a frame")); + } + match v { + Value::Null => { + n.layout = None; + drop_layout_bindings(n); + } + Value::String(s) if s.eq_ignore_ascii_case("none") => { + n.layout = None; + drop_layout_bindings(n); + } + Value::String(s) => { + let mode = match s.to_ascii_lowercase().as_str() { + "horizontal" | "row" => LayoutMode::Horizontal, + "vertical" | "column" => LayoutMode::Vertical, + "grid" => LayoutMode::Grid, + _ => return Err(bad(cmd, "layout: none|horizontal|vertical|grid")), + }; + ensure_layout(n).mode = mode; + } + Value::Object(_) => n.layout = Some(serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("layout: {e}")))?), + _ => return Err(bad(cmd, "layout: none|horizontal|vertical|grid|{...}")), + } + } + "gap" | "itemSpacing" | "columnGap" => { + layout_mut(cmd, k, doc.get_mut(id)?)?.gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))? + } + "crossGap" | "counterAxisSpacing" | "rowGap" => { + layout_mut(cmd, k, doc.get_mut(id)?)?.cross_gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))? + } + "padding" => { + let p = four(v).ok_or_else(|| bad(cmd, "padding: n or [t,r,b,l]"))?; + layout_mut(cmd, k, doc.get_mut(id)?)?.padding = p.map(|x| x.max(0.0)); + } + "paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" => { + let i = ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"].iter().position(|x| *x == k).unwrap_or(0); + let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0); + if let Some(slot) = layout_mut(cmd, k, doc.get_mut(id)?)?.padding.get_mut(i) { + *slot = val; + } + } + "paddingHorizontal" => { + let val = f64_of(v).unwrap_or(0.0).max(0.0); + let l = layout_mut(cmd, k, doc.get_mut(id)?)?; + l.padding[1] = val; + l.padding[3] = val; + } + "paddingVertical" => { + let val = f64_of(v).unwrap_or(0.0).max(0.0); + let l = layout_mut(cmd, k, doc.get_mut(id)?)?; + l.padding[0] = val; + l.padding[2] = val; + } + "mainAlign" | "primaryAxisAlignItems" => { + layout_mut(cmd, k, doc.get_mut(id)?)?.main_align = + enum_of(v).ok_or_else(|| bad(cmd, "mainAlign: min|center|max|spaceBetween|spaceAround|spaceEvenly"))? + } + "crossAlign" | "counterAxisAlignItems" => { + layout_mut(cmd, k, doc.get_mut(id)?)?.cross_align = enum_of(v).ok_or_else(|| bad(cmd, "crossAlign: min|center|max|baseline"))? + } + "contentAlign" | "counterAxisAlignContent" => { + layout_mut(cmd, k, doc.get_mut(id)?)?.content_align = enum_of(v).ok_or_else(|| bad(cmd, "contentAlign: auto|spaceBetween"))? + } + "wrap" => layout_mut(cmd, k, doc.get_mut(id)?)?.wrap = v.as_bool().unwrap_or(false), + "gridColumns" | "gridRows" => { + let count = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.clamp(1.0, 1000.0) as u32; + let l = layout_mut(cmd, k, doc.get_mut(id)?)?; + // Tracks past the new count go away (new tracks are 1fr). + let (n, tracks) = if k == "gridColumns" { (&mut l.grid_columns, &mut l.column_tracks) } else { (&mut l.grid_rows, &mut l.row_tracks) }; + *n = count; + tracks.truncate(count as usize); + } + "strokesIncluded" => layout_mut(cmd, k, doc.get_mut(id)?)?.strokes_included = v.as_bool().unwrap_or(false), + "reverseZ" => layout_mut(cmd, k, doc.get_mut(id)?)?.reverse_z = v.as_bool().unwrap_or(false), + _ => return Ok(false), + } + Ok(true) +} + +/// The `sizing` properties of `apply`; `false` when `k` is not one of them. +fn apply_sizing(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -> Result { + match k { + "sizingH" | "layoutSizingHorizontal" => { + let s = sizing(v).ok_or_else(|| bad(cmd, "sizing: fixed|hug|fill"))?; + set_sizing(cmd, doc, id, s, true)?; + } + "sizingV" | "layoutSizingVertical" => { + let s = sizing(v).ok_or_else(|| bad(cmd, "sizing: fixed|hug|fill"))?; + set_sizing(cmd, doc, id, s, false)?; + } + "absolute" | "ignoreAutoLayout" => { + let on = bool_of(v).ok_or_else(|| bad(cmd, "`absolute` must be a boolean"))?; + let cl = &mut doc.get_mut(id)?.child_layout; + cl.absolute = on; + // An absolute child can't fill: it keeps its current size. + if on { + for s in [&mut cl.sizing_h, &mut cl.sizing_v] { + if *s == Sizing::Fill { + *s = Sizing::Fixed; + } + } + } + } + "minWidth" | "maxWidth" | "minHeight" | "maxHeight" => { + let val = match v { + Value::Null => None, + other => Some(f64_of(other).ok_or_else(|| bad(cmd, format!("`{k}` must be a number or null")))?.clamp(0.0, 1e7)), + }; + set_limit(&mut doc.get_mut(id)?.child_layout, k, val); + } + "columnTracks" | "gridColumnTracks" | "rowTracks" | "gridRowTracks" => { + let a = v.as_array().ok_or_else(|| bad(cmd, format!("`{k}` must be an array of tracks")))?; + let tracks = a.iter().map(track_of).collect::>>().ok_or_else(|| bad(cmd, TRACK_HELP))?; + let l = layout_mut(cmd, k, doc.get_mut(id)?)?; + if k.contains("olumn") { + l.grid_columns = (tracks.len() as u32).clamp(1, 1000); + l.column_tracks = tracks; + } else { + l.grid_rows = (tracks.len() as u32).clamp(1, 1000); + l.row_tracks = tracks; + } + } + "gridColumn" | "gridColumnAnchorIndex" | "gridRow" | "gridRowAnchorIndex" => { + let val = match v { + Value::Null => None, + Value::String(s) if s.eq_ignore_ascii_case("auto") => None, + other => { + Some(f64_of(other).ok_or_else(|| bad(cmd, format!("`{k}` must be a 0-based index, \"auto\" or null")))?.clamp(0.0, 999.0) as u32) + } + }; + let cl = &mut doc.get_mut(id)?.child_layout; + if k.starts_with("gridColumn") { + cl.grid_column = val; + } else { + cl.grid_row = val; + } + } + "gridAlignH" | "gridChildHorizontalAlign" | "gridAlignV" | "gridChildVerticalAlign" => { + let val = match v { + Value::Null => None, + Value::String(s) if s.eq_ignore_ascii_case("auto") => None, + other => Some(enum_of(other).ok_or_else(|| bad(cmd, format!("`{k}`: min|center|max|auto")))?), + }; + let cl = &mut doc.get_mut(id)?.child_layout; + if k.contains('H') { + cl.grid_align_h = val; + } else { + cl.grid_align_v = val; + } + } + "gridColumnSpan" | "gridRowSpan" => { + let span = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.clamp(1.0, 1000.0) as u32; + let cl = &mut doc.get_mut(id)?.child_layout; + if k == "gridColumnSpan" { + cl.grid_column_span = span; + } else { + cl.grid_row_span = span; + } + } + "constraints" => { + let n = doc.get_mut(id)?; + if let Some(h) = v.get("horizontal") { + n.constraints.horizontal = enum_of(h).ok_or_else(|| bad(cmd, "horizontal: left|right|leftRight|center|scale"))?; + } + if let Some(vv) = v.get("vertical") { + n.constraints.vertical = enum_of(vv).ok_or_else(|| bad(cmd, "vertical: top|bottom|topBottom|center|scale"))?; + } + } + _ => return Ok(false), + } + Ok(true) +} + +/// The `meta` properties of `apply`; `false` when `k` is not one of them. +fn apply_meta(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -> Result { + match k { + "grids" | "layoutGrids" => doc.get_mut(id)?.grids = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("grids: {e}")))?, + "exports" | "exportSettings" => doc.get_mut(id)?.exports = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("exports: {e}")))?, + "interactions" => doc.get_mut(id)?.interactions = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("interactions: {e}")))?, + "overflow" => doc.get_mut(id)?.overflow = enum_of(v).ok_or_else(|| bad(cmd, "overflow: none|horizontal|vertical|both"))?, + "scrollPosition" => doc.get_mut(id)?.scroll_position = enum_of(v).ok_or_else(|| bad(cmd, "scrollPosition: scrolls|fixed|sticky"))?, + "constrainProportions" => doc.get_mut(id)?.constrain_proportions = v.as_bool().unwrap_or(false), + _ => return Ok(false), + } + Ok(true) +} + +/// The `text` properties of `apply`; `false` when `k` is not one of them. +fn apply_text(cmd: &str, doc: &mut Document, id: NodeId, props: &Map, k: &str, v: &Value) -> Result { + match k { + "characters" | "text" => { + let s = string_val(v).ok_or_else(|| bad(cmd, "`characters` must be a string"))?; + let n = doc.get_mut(id)?; + let t = text_mut(cmd, n)?; + let len = t.char_count(); + t.replace_range(0, len, &normalize_line_endings(&s)); + n.overrides.insert("characters".into()); + } + "fontFamily" + | "fontStyle" + | "fontWeight" + | "fontSize" + | "lineHeight" + | "letterSpacing" + | "textDecoration" + | "textCase" + | "hyperlink" + | "textFills" + | "fontVariations" + | "variationSettings" + | "textDecorationStyle" + | "textDecorationThickness" + | "textDecorationOffset" + | "textDecorationSkipInk" + | "textDecorationColor" => { + let n = doc.get_mut(id)?; + let t = text_mut(cmd, n)?; + let (start, end) = range_of(props, t.char_count()); + apply_text_style(cmd, t, start, end, k, v)?; + } + "textAlign" | "textAlignHorizontal" => { + text_mut(cmd, doc.get_mut(id)?)?.align_h = enum_of(v).ok_or_else(|| bad(cmd, "textAlign: left|center|right|justified"))? + } + "textAlignVertical" => { + text_mut(cmd, doc.get_mut(id)?)?.align_v = enum_of(v).ok_or_else(|| bad(cmd, "textAlignVertical: top|middle|bottom"))? + } + "textAutoResize" => { + text_mut(cmd, doc.get_mut(id)?)?.auto_resize = + enum_of(v).ok_or_else(|| bad(cmd, "textAutoResize: widthAndHeight|height|none|truncate"))? + } + "paragraphSpacing" => text_mut(cmd, doc.get_mut(id)?)?.paragraph_spacing = f64_of(v).unwrap_or(0.0).max(0.0), + "paragraphIndent" => text_mut(cmd, doc.get_mut(id)?)?.paragraph_indent = f64_of(v).unwrap_or(0.0).max(0.0), + "listStyle" | "indentation" => { + let t = text_mut(cmd, doc.get_mut(id)?)?; + let (start, end) = range_of(props, t.char_count()); + if k == "listStyle" { + let l: ListStyle = enum_of(v).ok_or_else(|| bad(cmd, "listStyle: none|bulleted|numbered"))?; + t.set_para_lists(start, end, |p| p.list = l); + } else { + let d = f64_of(v).filter(|d| d.is_finite()).ok_or_else(|| bad(cmd, "indentation: a list level 0-8"))?; + t.set_para_lists(start, end, |p| p.indent = d.clamp(0.0, f64::from(MAX_LIST_INDENT)) as u8); + } + } + "listSpacing" => text_mut(cmd, doc.get_mut(id)?)?.list_spacing = f64_of(v).filter(|s| s.is_finite()).unwrap_or(0.0).clamp(0.0, 10_000.0), + "hangingPunctuation" | "hangingList" => { + let on = v.as_bool().ok_or_else(|| bad(cmd, format!("`{k}` must be true or false")))?; + let t = text_mut(cmd, doc.get_mut(id)?)?; + *if k == "hangingList" { &mut t.hanging_list } else { &mut t.hanging_punctuation } = on; + } + "leadingTrim" => text_mut(cmd, doc.get_mut(id)?)?.leading_trim = enum_of(v).ok_or_else(|| bad(cmd, "leadingTrim: none|capHeight"))?, + "maxLines" => text_mut(cmd, doc.get_mut(id)?)?.max_lines = f64_of(v).unwrap_or(0.0).clamp(0.0, 10_000.0) as u32, + "features" | "textFeatures" | "fontFeatures" | "openTypeFeatures" => { + let f = features_of(cmd, v)?; + let t = text_mut(cmd, doc.get_mut(id)?)?; + t.features.clear(); + for (k, on) in f { + if let Some(on) = on { + t.features.insert(k, on); + } + } + } + "start" | "end" => {} + _ => return Ok(false), + } + Ok(true) +} + +/// The `shape` properties of `apply`; `false` when `k` is not one of them. +fn apply_shape(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -> Result { + match k { + "pointCount" => { + let c = f64_of(v).ok_or_else(|| bad(cmd, "`pointCount` must be a number"))?.round(); + match &mut doc.get_mut(id)?.kind { + // Stars have 3 to 60 points; polygons keep the geometry's 120 (silent). + NodeKind::Star { count, .. } => *count = c.clamp(3.0, 60.0) as u32, + NodeKind::Polygon { count } => *count = c.clamp(3.0, 120.0) as u32, + _ => return Err(bad(cmd, "pointCount applies to polygons and stars")), + } + } + "innerRadius" | "ratio" => { + let r = f64_of(v).unwrap_or(0.38); + let r = if r > 1.0 { r / 100.0 } else { r }; + match &mut doc.get_mut(id)?.kind { + NodeKind::Star { inner_ratio, .. } => *inner_ratio = r.clamp(0.0, 1.0), + _ => return Err(bad(cmd, "innerRadius applies to stars")), + } + } + "arcStart" | "arcSweep" | "arcInner" => { + let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?; + match &mut doc.get_mut(id)?.kind { + NodeKind::Ellipse { start, sweep, inner } => match k { + "arcStart" => *start = val.to_radians(), + "arcSweep" => *sweep = val.clamp(-360.0, 360.0).to_radians(), + _ => *inner = (if val > 1.0 { val / 100.0 } else { val }).clamp(0.0, 0.99), + }, + _ => return Err(bad(cmd, "arc applies to ellipses")), + } + } + "description" => match &mut doc.get_mut(id)?.kind { + NodeKind::Component { description, .. } | NodeKind::ComponentSet { description } => *description = string_val(v).unwrap_or_default(), + _ => return Err(bad(cmd, "description applies to components")), + }, + "pluginData" => { + let n = doc.get_mut(id)?; + if let Some(o) = v.as_object() { + for (pk, pv) in o { + if pv.is_null() { + n.plugin_data.remove(pk); + } else { + n.plugin_data.insert(pk.clone(), string_val(pv).unwrap_or_else(|| pv.to_string())); + } + } + } + } + "fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => { + let sid = string_val(v); + crate::cmd::styles::apply_style_ref(doc, id, k, sid.as_deref())?; + } + "boundVariables" => { + let n = doc.get_mut(id)?; + if let Some(o) = v.as_object() { + for (field, var) in o { + match var.as_str() { + Some(vid) => { + if n.bound.get(field).map(String::as_str) != Some(vid) { + crate::cmd::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, true); + } + n.bound.insert(field.clone(), vid.to_string()); + } + None => { + if n.bound.remove(field).is_some() { + crate::cmd::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, false); + } + } + } + } + } + } + "variableModes" => { + let n = doc.get_mut(id)?; + if let Some(o) = v.as_object() { + for (coll, mode) in o { + match mode.as_str() { + Some(m) => { + n.var_modes.insert(coll.clone(), m.to_string()); + } + None => { + n.var_modes.remove(coll); + } + } + } + } + } + "booleanOperation" if !matches!(doc.get(id)?.kind, NodeKind::BooleanOp { .. }) => set_operand_op(doc, id, v, cmd)?, + "booleanOperation" => { + if let NodeKind::BooleanOp { op } = &mut doc.get_mut(id)?.kind { + *op = enum_of(v).ok_or_else(|| bad(cmd, BOOL_OP_HELP))?; + } + } + "operandOperation" => set_operand_op(doc, id, v, cmd)?, + "sectionContentsHidden" => { + if let NodeKind::Section { contents_hidden } = &mut doc.get_mut(id)?.kind { + *contents_hidden = v.as_bool().unwrap_or(false); + } + } + "background" => { + let c = color_of(v).ok_or_else(|| bad(cmd, "`background` must be a colour"))?; + match &mut doc.get_mut(id)?.kind { + NodeKind::Page { background, .. } => *background = c, + _ => return Err(bad(cmd, "background applies to pages")), + } + } + "componentProperties" => { + if let NodeKind::Instance { props: p, .. } = &mut doc.get_mut(id)?.kind + && let Some(o) = v.as_object() + { + for (pk, pv) in o { + p.insert(pk.clone(), pv.clone()); + } + } + } + _ => return Ok(false), + } + Ok(true) +} + /// Apply a property map to one node. `x`/`y` are relative to the parent. pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map) -> Result<()> { let mut unknown = Vec::new(); @@ -418,524 +1003,18 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map path_offset = set_path(cmd, doc, id, v, props)?, - // A new path takes the size asked for as its scale (`set_path`); the size still fixes - // its auto layout axis. - "width" | "height" | "w" | "h" if props.contains_key("path") => fix_axis(doc, id, k.starts_with('w'))?, - "x" | "y" => { - let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?; - let val = val + if k == "x" { path_offset.0 } else { path_offset.1 }; - // X/Y are the node's own origin (its rotated top-left for rotated nodes), in the - // frame it is in (groups have no space of their own). - let n = doc.get(id)?; - let group = n.parent.filter(|p| doc.node(*p).is_some_and(|pn| pn.kind.bounds_follow_children())); - let space = group.map_or(Affine::IDENTITY, |g| doc.frame_relative(g)); - let c = (space * n.transform).as_coeffs(); - let (mut tx, mut ty) = (c[4], c[5]); - if k == "x" { - tx = val; - } else { - ty = val; - } - doc.get_mut(id)?.transform = space.inverse() * Affine::new([c[0], c[1], c[2], c[3], tx, ty]); - } - "width" | "height" | "w" | "h" => { - let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0); - let is_w = k.starts_with('w'); - // With the proportions locked (the Layout panel's link) and only one side set, the - // other side follows the node's ratio; both sides in one `node.set` stay as typed. - let (nw, nh) = { - let n = doc.get(id)?; - let (ow, oh) = (n.width, n.height); - let (other, other_short) = if is_w { ("height", "h") } else { ("width", "w") }; - let linked = n.constrain_proportions && !props.contains_key(other) && !props.contains_key(other_short) && ow > 1e-9 && oh > 1e-9; - let derived = if is_w { oh * val / ow } else { ow * val / oh }; - match (is_w, linked && derived.is_finite()) { - (true, true) => (val, derived), - (false, true) => (derived, val), - (true, false) => (val, oh), - (false, false) => (ow, val), - } - }; - resize_node(doc, id, nw, nh)?; - // Explicit size makes text fixed in that dimension. - if let Some(t) = doc.get_mut(id)?.kind.text_mut() { - t.fix_size(is_w, !is_w); - } - fix_axis(doc, id, is_w)?; - } - "rotation" => { - let deg = f64_of(v).ok_or_else(|| bad(cmd, "`rotation` must be degrees"))?; - let n = doc.get_mut(id)?; - n.transform = figcraft_geom::set_rotation_about_center(n.transform, n.width, n.height, deg); - } - "name" => doc.get_mut(id)?.name = string_val(v).ok_or_else(|| bad(cmd, "`name` must be a string"))?, - "visible" => doc.get_mut(id)?.visible = v.as_bool().ok_or_else(|| bad(cmd, "`visible` must be a boolean"))?, - "locked" => doc.get_mut(id)?.locked = v.as_bool().ok_or_else(|| bad(cmd, "`locked` must be a boolean"))?, - "opacity" => { - let o = f64_of(v).ok_or_else(|| bad(cmd, "`opacity` must be a number"))?; - let o = if o > 1.0 { o / 100.0 } else { o }; - doc.get_mut(id)?.opacity = o.clamp(0.0, 1.0) as f32; - } - "blend" | "blendMode" => { - let b = v.as_str().and_then(figcraft_color::BlendMode::from_name).ok_or_else(|| bad(cmd, "unknown blend mode"))?; - doc.get_mut(id)?.blend = b; - } - "fills" => set_paints(doc.get_mut(id)?, true, paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?), - "fill" => { - if paint_none(v) { - set_paints(doc.get_mut(id)?, true, vec![]); - } else { - let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour, \"none\" or null"))?; - set_paints(doc.get_mut(id)?, true, vec![Paint::solid(c)]); - } - } - "strokes" => set_paints(doc.get_mut(id)?, false, paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?), - "stroke" => { - if paint_none(v) { - set_paints(doc.get_mut(id)?, false, vec![]); - } else { - let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour, \"none\" or null"))?; - set_paints(doc.get_mut(id)?, false, vec![Paint::solid(c)]); - } - } - "strokeWeight" => doc.get_mut(id)?.stroke.weight = f64_of(v).ok_or_else(|| bad(cmd, "`strokeWeight` must be a number"))?.max(0.0), - "strokeAlign" => doc.get_mut(id)?.stroke.align = enum_of(v).ok_or_else(|| bad(cmd, "strokeAlign: inside|center|outside"))?, - "strokeSides" => { - doc.get_mut(id)?.stroke.sides = if v.is_null() { None } else { Some(four(v).ok_or_else(|| bad(cmd, "strokeSides: [t,r,b,l]"))?) } - } - "strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight" | "strokeLeftWeight" => { - let i = STROKE_SIDES.iter().position(|x| *x == k).unwrap_or(0); - let w = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0); - let st = &mut doc.get_mut(id)?.stroke; - // One side set on its own turns on individual strokes, the others at the weight. - let mut sides = st.sides.unwrap_or([st.weight; 4]); - if let Some(slot) = sides.get_mut(i) { - *slot = w; - } - st.sides = Some(sides); - } - "strokeCap" => { - let c: StrokeCap = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?; - let n = doc.get_mut(id)?; - n.stroke.cap_start = c; - n.stroke.cap_end = c; - } - "startCap" => doc.get_mut(id)?.stroke.cap_start = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?, - "endCap" => doc.get_mut(id)?.stroke.cap_end = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?, - "strokeJoin" => doc.get_mut(id)?.stroke.join = enum_of(v).ok_or_else(|| bad(cmd, "strokeJoin: miter|bevel|round"))?, - "miterLimit" => doc.get_mut(id)?.stroke.miter_limit = f64_of(v).unwrap_or(4.0).clamp(1.0, 100.0), - "miterAngle" => { - let deg = f64_of(v).ok_or_else(|| bad(cmd, "`miterAngle` must be degrees"))?; - doc.get_mut(id)?.stroke.miter_limit = StrokeProps::miter_limit_for(deg); - } - "dashes" => { - let d: Vec = v.as_array().map(|a| a.iter().filter_map(f64_of).map(|x| x.max(0.0)).collect()).unwrap_or_default(); - doc.get_mut(id)?.stroke.dashes = d; - } - "widthProfile" => doc.get_mut(id)?.stroke.width_profile = width_profile_of(v).ok_or_else(|| bad(cmd, WIDTH_PROFILE_HELP))?, - "brush" | "strokeBrush" => doc.get_mut(id)?.stroke.brush = brush_of(v).ok_or_else(|| bad(cmd, BRUSH_HELP))?, - "cornerRadius" => { - let r = four(v).ok_or_else(|| bad(cmd, "cornerRadius: n or [tl,tr,br,bl]"))?.map(|x| x.max(0.0)); - let n = doc.get_mut(id)?; - n.corners.radii = r; - // A vector's corner radius rounds every point (edit mode then sets them one by one). - if let NodeKind::Vector { network } = &mut n.kind { - network.vertices.iter_mut().for_each(|p| p.radius = r[0]); - crate::vectoredit::refit(doc, id)?; - } - } - "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => { - let i = ["topLeftRadius", "topRightRadius", "bottomRightRadius", "bottomLeftRadius"].iter().position(|x| *x == k).unwrap_or(0); - let r = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0); - if let Some(slot) = doc.get_mut(id)?.corners.radii.get_mut(i) { - *slot = r; - } - } - "cornerSmoothing" => doc.get_mut(id)?.corners.smoothing = f64_of(v).unwrap_or(0.0).clamp(0.0, 1.0), - "effects" => doc.get_mut(id)?.effects = effects_from(cmd, v)?, - "clip" | "clipsContent" => doc.get_mut(id)?.clip = v.as_bool().ok_or_else(|| bad(cmd, "`clip` must be a boolean"))?, - "isMask" => doc.get_mut(id)?.is_mask = v.as_bool().unwrap_or(false), - "devStatus" => { - let st = if v.is_null() { - Some(DevStatus::None) - } else { - v.as_str().and_then(|s| DevStatus::ALL.into_iter().find(|d| d.id().eq_ignore_ascii_case(s))) - }; - let st = st.ok_or_else(|| bad(cmd, "devStatus: none|readyForDev|completed"))?; - if !st.is_none() && !crate::devinspect::has_status(doc, id) { - return Err(bad(cmd, "devStatus: only sections and frames at the top of a page or section take one")); - } - let n = doc.get_mut(id)?; - n.dev_status = st; - // Marking a layer (again) starts from its current contents. - n.dev_changed = false; - } - "maskType" => { - let t = v.as_str().and_then(|s| MaskType::ALL.into_iter().find(|m| m.id().eq_ignore_ascii_case(s))); - doc.get_mut(id)?.mask_type = t.ok_or_else(|| bad(cmd, "maskType: alpha|vector|luminance"))?; - } - "layout" | "layoutMode" => { - let n = doc.get_mut(id)?; - if !n.kind.is_frame_like() { - return Err(bad(cmd, "auto layout needs a frame")); - } - match v { - Value::Null => { - n.layout = None; - drop_layout_bindings(n); - } - Value::String(s) if s.eq_ignore_ascii_case("none") => { - n.layout = None; - drop_layout_bindings(n); - } - Value::String(s) => { - let mode = match s.to_ascii_lowercase().as_str() { - "horizontal" | "row" => LayoutMode::Horizontal, - "vertical" | "column" => LayoutMode::Vertical, - "grid" => LayoutMode::Grid, - _ => return Err(bad(cmd, "layout: none|horizontal|vertical|grid")), - }; - ensure_layout(n).mode = mode; - } - Value::Object(_) => n.layout = Some(serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("layout: {e}")))?), - _ => return Err(bad(cmd, "layout: none|horizontal|vertical|grid|{...}")), - } - } - "gap" | "itemSpacing" | "columnGap" => { - layout_mut(cmd, k, doc.get_mut(id)?)?.gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))? - } - "crossGap" | "counterAxisSpacing" | "rowGap" => { - layout_mut(cmd, k, doc.get_mut(id)?)?.cross_gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))? - } - "padding" => { - let p = four(v).ok_or_else(|| bad(cmd, "padding: n or [t,r,b,l]"))?; - layout_mut(cmd, k, doc.get_mut(id)?)?.padding = p.map(|x| x.max(0.0)); - } - "paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" => { - let i = ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"].iter().position(|x| *x == k).unwrap_or(0); - let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0); - if let Some(slot) = layout_mut(cmd, k, doc.get_mut(id)?)?.padding.get_mut(i) { - *slot = val; - } - } - "paddingHorizontal" => { - let val = f64_of(v).unwrap_or(0.0).max(0.0); - let l = layout_mut(cmd, k, doc.get_mut(id)?)?; - l.padding[1] = val; - l.padding[3] = val; - } - "paddingVertical" => { - let val = f64_of(v).unwrap_or(0.0).max(0.0); - let l = layout_mut(cmd, k, doc.get_mut(id)?)?; - l.padding[0] = val; - l.padding[2] = val; - } - "mainAlign" | "primaryAxisAlignItems" => { - layout_mut(cmd, k, doc.get_mut(id)?)?.main_align = - enum_of(v).ok_or_else(|| bad(cmd, "mainAlign: min|center|max|spaceBetween|spaceAround|spaceEvenly"))? - } - "crossAlign" | "counterAxisAlignItems" => { - layout_mut(cmd, k, doc.get_mut(id)?)?.cross_align = enum_of(v).ok_or_else(|| bad(cmd, "crossAlign: min|center|max|baseline"))? - } - "contentAlign" | "counterAxisAlignContent" => { - layout_mut(cmd, k, doc.get_mut(id)?)?.content_align = enum_of(v).ok_or_else(|| bad(cmd, "contentAlign: auto|spaceBetween"))? - } - "wrap" => layout_mut(cmd, k, doc.get_mut(id)?)?.wrap = v.as_bool().unwrap_or(false), - "gridColumns" | "gridRows" => { - let count = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.clamp(1.0, 1000.0) as u32; - let l = layout_mut(cmd, k, doc.get_mut(id)?)?; - // Tracks past the new count go away (new tracks are 1fr). - let (n, tracks) = - if k == "gridColumns" { (&mut l.grid_columns, &mut l.column_tracks) } else { (&mut l.grid_rows, &mut l.row_tracks) }; - *n = count; - tracks.truncate(count as usize); - } - "strokesIncluded" => layout_mut(cmd, k, doc.get_mut(id)?)?.strokes_included = v.as_bool().unwrap_or(false), - "reverseZ" => layout_mut(cmd, k, doc.get_mut(id)?)?.reverse_z = v.as_bool().unwrap_or(false), - "sizingH" | "layoutSizingHorizontal" => { - let s = sizing(v).ok_or_else(|| bad(cmd, "sizing: fixed|hug|fill"))?; - set_sizing(cmd, doc, id, s, true)?; - } - "sizingV" | "layoutSizingVertical" => { - let s = sizing(v).ok_or_else(|| bad(cmd, "sizing: fixed|hug|fill"))?; - set_sizing(cmd, doc, id, s, false)?; - } - "absolute" | "ignoreAutoLayout" => { - let on = bool_of(v).ok_or_else(|| bad(cmd, "`absolute` must be a boolean"))?; - let cl = &mut doc.get_mut(id)?.child_layout; - cl.absolute = on; - // An absolute child can't fill: it keeps its current size. - if on { - for s in [&mut cl.sizing_h, &mut cl.sizing_v] { - if *s == Sizing::Fill { - *s = Sizing::Fixed; - } - } - } - } - "minWidth" | "maxWidth" | "minHeight" | "maxHeight" => { - let val = match v { - Value::Null => None, - other => Some(f64_of(other).ok_or_else(|| bad(cmd, format!("`{k}` must be a number or null")))?.clamp(0.0, 1e7)), - }; - set_limit(&mut doc.get_mut(id)?.child_layout, k, val); - } - "columnTracks" | "gridColumnTracks" | "rowTracks" | "gridRowTracks" => { - let a = v.as_array().ok_or_else(|| bad(cmd, format!("`{k}` must be an array of tracks")))?; - let tracks = a.iter().map(track_of).collect::>>().ok_or_else(|| bad(cmd, TRACK_HELP))?; - let l = layout_mut(cmd, k, doc.get_mut(id)?)?; - if k.contains("olumn") { - l.grid_columns = (tracks.len() as u32).clamp(1, 1000); - l.column_tracks = tracks; - } else { - l.grid_rows = (tracks.len() as u32).clamp(1, 1000); - l.row_tracks = tracks; - } - } - "gridColumn" | "gridColumnAnchorIndex" | "gridRow" | "gridRowAnchorIndex" => { - let val = match v { - Value::Null => None, - Value::String(s) if s.eq_ignore_ascii_case("auto") => None, - other => { - Some(f64_of(other).ok_or_else(|| bad(cmd, format!("`{k}` must be a 0-based index, \"auto\" or null")))?.clamp(0.0, 999.0) - as u32) - } - }; - let cl = &mut doc.get_mut(id)?.child_layout; - if k.starts_with("gridColumn") { - cl.grid_column = val; - } else { - cl.grid_row = val; - } - } - "gridAlignH" | "gridChildHorizontalAlign" | "gridAlignV" | "gridChildVerticalAlign" => { - let val = match v { - Value::Null => None, - Value::String(s) if s.eq_ignore_ascii_case("auto") => None, - other => Some(enum_of(other).ok_or_else(|| bad(cmd, format!("`{k}`: min|center|max|auto")))?), - }; - let cl = &mut doc.get_mut(id)?.child_layout; - if k.contains('H') { - cl.grid_align_h = val; - } else { - cl.grid_align_v = val; - } - } - "gridColumnSpan" | "gridRowSpan" => { - let span = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.clamp(1.0, 1000.0) as u32; - let cl = &mut doc.get_mut(id)?.child_layout; - if k == "gridColumnSpan" { - cl.grid_column_span = span; - } else { - cl.grid_row_span = span; - } - } - "constraints" => { - let n = doc.get_mut(id)?; - if let Some(h) = v.get("horizontal") { - n.constraints.horizontal = enum_of(h).ok_or_else(|| bad(cmd, "horizontal: left|right|leftRight|center|scale"))?; - } - if let Some(vv) = v.get("vertical") { - n.constraints.vertical = enum_of(vv).ok_or_else(|| bad(cmd, "vertical: top|bottom|topBottom|center|scale"))?; - } - } - "grids" | "layoutGrids" => doc.get_mut(id)?.grids = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("grids: {e}")))?, - "exports" | "exportSettings" => { - doc.get_mut(id)?.exports = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("exports: {e}")))? - } - "interactions" => { - doc.get_mut(id)?.interactions = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("interactions: {e}")))? - } - "overflow" => doc.get_mut(id)?.overflow = enum_of(v).ok_or_else(|| bad(cmd, "overflow: none|horizontal|vertical|both"))?, - "scrollPosition" => doc.get_mut(id)?.scroll_position = enum_of(v).ok_or_else(|| bad(cmd, "scrollPosition: scrolls|fixed|sticky"))?, - "constrainProportions" => doc.get_mut(id)?.constrain_proportions = v.as_bool().unwrap_or(false), - "characters" | "text" => { - let s = string_val(v).ok_or_else(|| bad(cmd, "`characters` must be a string"))?; - let n = doc.get_mut(id)?; - let t = text_mut(cmd, n)?; - let len = t.char_count(); - t.replace_range(0, len, &normalize_line_endings(&s)); - n.overrides.insert("characters".into()); - } - "fontFamily" - | "fontStyle" - | "fontWeight" - | "fontSize" - | "lineHeight" - | "letterSpacing" - | "textDecoration" - | "textCase" - | "hyperlink" - | "textFills" - | "fontVariations" - | "variationSettings" - | "textDecorationStyle" - | "textDecorationThickness" - | "textDecorationOffset" - | "textDecorationSkipInk" - | "textDecorationColor" => { - let n = doc.get_mut(id)?; - let t = text_mut(cmd, n)?; - let (start, end) = range_of(props, t.char_count()); - apply_text_style(cmd, t, start, end, k, v)?; - } - "textAlign" | "textAlignHorizontal" => { - text_mut(cmd, doc.get_mut(id)?)?.align_h = enum_of(v).ok_or_else(|| bad(cmd, "textAlign: left|center|right|justified"))? - } - "textAlignVertical" => { - text_mut(cmd, doc.get_mut(id)?)?.align_v = enum_of(v).ok_or_else(|| bad(cmd, "textAlignVertical: top|middle|bottom"))? - } - "textAutoResize" => { - text_mut(cmd, doc.get_mut(id)?)?.auto_resize = - enum_of(v).ok_or_else(|| bad(cmd, "textAutoResize: widthAndHeight|height|none|truncate"))? - } - "paragraphSpacing" => text_mut(cmd, doc.get_mut(id)?)?.paragraph_spacing = f64_of(v).unwrap_or(0.0).max(0.0), - "paragraphIndent" => text_mut(cmd, doc.get_mut(id)?)?.paragraph_indent = f64_of(v).unwrap_or(0.0).max(0.0), - "listStyle" | "indentation" => { - let t = text_mut(cmd, doc.get_mut(id)?)?; - let (start, end) = range_of(props, t.char_count()); - if k == "listStyle" { - let l: ListStyle = enum_of(v).ok_or_else(|| bad(cmd, "listStyle: none|bulleted|numbered"))?; - t.set_para_lists(start, end, |p| p.list = l); - } else { - let d = f64_of(v).filter(|d| d.is_finite()).ok_or_else(|| bad(cmd, "indentation: a list level 0-8"))?; - t.set_para_lists(start, end, |p| p.indent = d.clamp(0.0, f64::from(MAX_LIST_INDENT)) as u8); - } - } - "listSpacing" => text_mut(cmd, doc.get_mut(id)?)?.list_spacing = f64_of(v).filter(|s| s.is_finite()).unwrap_or(0.0).clamp(0.0, 10_000.0), - "hangingPunctuation" | "hangingList" => { - let on = v.as_bool().ok_or_else(|| bad(cmd, format!("`{k}` must be true or false")))?; - let t = text_mut(cmd, doc.get_mut(id)?)?; - *if k == "hangingList" { &mut t.hanging_list } else { &mut t.hanging_punctuation } = on; - } - "leadingTrim" => text_mut(cmd, doc.get_mut(id)?)?.leading_trim = enum_of(v).ok_or_else(|| bad(cmd, "leadingTrim: none|capHeight"))?, - "maxLines" => text_mut(cmd, doc.get_mut(id)?)?.max_lines = f64_of(v).unwrap_or(0.0).clamp(0.0, 10_000.0) as u32, - "features" | "textFeatures" | "fontFeatures" | "openTypeFeatures" => { - let f = features_of(cmd, v)?; - let t = text_mut(cmd, doc.get_mut(id)?)?; - t.features.clear(); - for (k, on) in f { - if let Some(on) = on { - t.features.insert(k, on); - } - } - } - "start" | "end" => {} - "pointCount" => { - let c = f64_of(v).ok_or_else(|| bad(cmd, "`pointCount` must be a number"))?.round(); - match &mut doc.get_mut(id)?.kind { - // Stars have 3 to 60 points; polygons keep the geometry's 120 (silent). - NodeKind::Star { count, .. } => *count = c.clamp(3.0, 60.0) as u32, - NodeKind::Polygon { count } => *count = c.clamp(3.0, 120.0) as u32, - _ => return Err(bad(cmd, "pointCount applies to polygons and stars")), - } - } - "innerRadius" | "ratio" => { - let r = f64_of(v).unwrap_or(0.38); - let r = if r > 1.0 { r / 100.0 } else { r }; - match &mut doc.get_mut(id)?.kind { - NodeKind::Star { inner_ratio, .. } => *inner_ratio = r.clamp(0.0, 1.0), - _ => return Err(bad(cmd, "innerRadius applies to stars")), - } - } - "arcStart" | "arcSweep" | "arcInner" => { - let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?; - match &mut doc.get_mut(id)?.kind { - NodeKind::Ellipse { start, sweep, inner } => match k { - "arcStart" => *start = val.to_radians(), - "arcSweep" => *sweep = val.clamp(-360.0, 360.0).to_radians(), - _ => *inner = (if val > 1.0 { val / 100.0 } else { val }).clamp(0.0, 0.99), - }, - _ => return Err(bad(cmd, "arc applies to ellipses")), - } - } - "description" => match &mut doc.get_mut(id)?.kind { - NodeKind::Component { description, .. } | NodeKind::ComponentSet { description } => *description = string_val(v).unwrap_or_default(), - _ => return Err(bad(cmd, "description applies to components")), - }, - "pluginData" => { - let n = doc.get_mut(id)?; - if let Some(o) = v.as_object() { - for (pk, pv) in o { - if pv.is_null() { - n.plugin_data.remove(pk); - } else { - n.plugin_data.insert(pk.clone(), string_val(pv).unwrap_or_else(|| pv.to_string())); - } - } - } - } - "fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => { - let sid = string_val(v); - crate::cmd::styles::apply_style_ref(doc, id, k, sid.as_deref())?; - } - "boundVariables" => { - let n = doc.get_mut(id)?; - if let Some(o) = v.as_object() { - for (field, var) in o { - match var.as_str() { - Some(vid) => { - if n.bound.get(field).map(String::as_str) != Some(vid) { - crate::cmd::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, true); - } - n.bound.insert(field.clone(), vid.to_string()); - } - None => { - if n.bound.remove(field).is_some() { - crate::cmd::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, false); - } - } - } - } - } - } - "variableModes" => { - let n = doc.get_mut(id)?; - if let Some(o) = v.as_object() { - for (coll, mode) in o { - match mode.as_str() { - Some(m) => { - n.var_modes.insert(coll.clone(), m.to_string()); - } - None => { - n.var_modes.remove(coll); - } - } - } - } - } - "booleanOperation" if !matches!(doc.get(id)?.kind, NodeKind::BooleanOp { .. }) => set_operand_op(doc, id, v, cmd)?, - "booleanOperation" => { - if let NodeKind::BooleanOp { op } = &mut doc.get_mut(id)?.kind { - *op = enum_of(v).ok_or_else(|| bad(cmd, BOOL_OP_HELP))?; - } - } - "operandOperation" => set_operand_op(doc, id, v, cmd)?, - "sectionContentsHidden" => { - if let NodeKind::Section { contents_hidden } = &mut doc.get_mut(id)?.kind { - *contents_hidden = v.as_bool().unwrap_or(false); - } - } - "background" => { - let c = color_of(v).ok_or_else(|| bad(cmd, "`background` must be a colour"))?; - match &mut doc.get_mut(id)?.kind { - NodeKind::Page { background, .. } => *background = c, - _ => return Err(bad(cmd, "background applies to pages")), - } - } - "componentProperties" => { - if let NodeKind::Instance { props: p, .. } = &mut doc.get_mut(id)?.kind - && let Some(o) = v.as_object() - { - for (pk, pv) in o { - p.insert(pk.clone(), pv.clone()); - } - } - } - other => unknown.push(other.to_string()), + if apply_geometry(cmd, doc, id, props, k, v, &mut path_offset)? + || apply_look(cmd, doc, id, k, v)? + || apply_corners(cmd, doc, id, k, v)? + || apply_layout(cmd, doc, id, k, v)? + || apply_sizing(cmd, doc, id, k, v)? + || apply_meta(cmd, doc, id, k, v)? + || apply_text(cmd, doc, id, props, k, v)? + || apply_shape(cmd, doc, id, k, v)? + { + continue; } + unknown.push(k.to_string()); } if !unknown.is_empty() { return Err(bad(cmd, format!("unknown properties: {} (known: {KEYS})", unknown.join(", ")))); diff --git a/crates/engine/src/cmd/proto.rs b/crates/engine/src/cmd/proto.rs index ce7219f4..c451eeb2 100644 --- a/crates/engine/src/cmd/proto.rs +++ b/crates/engine/src/cmd/proto.rs @@ -1,11 +1,14 @@ //! Prototype commands: interactions, flows, settings, presenting. -use figcraft_doc::proto::*; -use figcraft_doc::*; +use figcraft_doc::proto::{ + Action, DeviceRotation, Easing, Expr, Flow, Interaction, MAX_DELAY_MS, MAX_EXPR_DEPTH, OverlayPosition, PrototypeSettings, Transition, Trigger, + walk_mut, +}; +use figcraft_doc::{Document, NodeId, NodeKind, VariableValue, Variables}; use serde_json::{Value, json}; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{boolean, color, f64_of, get, id, num, parse, req_id, req_num, req_targets, string}; use crate::{Result, Session, bad, spec}; pub fn specs() -> Vec { @@ -498,6 +501,7 @@ fn set(s: &mut Session, p: &Value) -> Result { mod tests { use super::*; use crate::params::id_of; + use figcraft_doc::proto::{Direction, Overflow, ScrollPosition}; fn frame(s: &mut Session, x: f64) -> NodeId { let r = s.execute("node.create", &json!({"type": "frame", "x": x, "y": 0, "width": 100, "height": 200})).unwrap(); diff --git a/crates/engine/src/cmd/proto_actions.rs b/crates/engine/src/cmd/proto_actions.rs index d288a7d7..95a53a9d 100644 --- a/crates/engine/src/cmd/proto_actions.rs +++ b/crates/engine/src/cmd/proto_actions.rs @@ -10,12 +10,12 @@ use std::collections::BTreeMap; -use figcraft_doc::proto::*; -use figcraft_doc::*; +use figcraft_doc::proto::{Action, ConditionalBlock, Expr, ExprOp, MAX_ACTION_DEPTH, walk}; +use figcraft_doc::{Variable, VariableType, VariableValue, Variables}; use serde_json::{Value, json}; use super::CommandSpec; -use crate::params::*; +use crate::params::{bool_of, f64_of, get, id, parse, req_index, req_targets, string}; use crate::{Result, Session, bad, spec}; /// Most actions in one list (an interaction's, or a block's). diff --git a/crates/engine/src/cmd/section.rs b/crates/engine/src/cmd/section.rs index 08a5a7cc..28b02c53 100644 --- a/crates/engine/src/cmd/section.rs +++ b/crates/engine/src/cmd/section.rs @@ -7,7 +7,7 @@ use serde_json::{Value, json}; use super::CommandSpec; use crate::devinspect::{dev_items, has_status}; -use crate::params::*; +use crate::params::{boolean, get, req_targets, string, targets}; use crate::{Result, Session, bad, spec}; /// Longest change reason kept. diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index 700825f2..05823cab 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -1,13 +1,16 @@ //! Structure: groups, frames, masks, boolean groups, flatten, outline stroke, auto layout, pages. use figcraft_color::{Color, Paint}; -use figcraft_doc::*; +use figcraft_doc::{ + AutoLayout, BoolOp, Corners, Document, LayoutMode, Node, NodeId, NodeKind, Region, Segment, Sizing, StrokeAlign, StrokeJoin, StrokeProps, + VectorNetwork, WindingRule, proto, +}; use figcraft_geom::{Affine, BezPath, Rect, Shape}; use serde_json::{Value, json}; use super::edit::{roots, z_sorted}; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{f64_of, get, id_of, num_or, req_id, req_index, req_num, req_targets, string, targets}; use crate::{EngineError, Result, Session, bad, spec}; /// The layer structure commands: group, frame, mask, boolean operations, flatten, auto layout diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index 9c2922d2..0066602a 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -3,11 +3,14 @@ use std::collections::BTreeMap; use figcraft_color::{Color, Paint, PaintKind}; -use figcraft_doc::*; +use figcraft_doc::{ + Collection, CollectionId, Document, Effect, EffectKind, Mode, ModeId, Node, NodeId, NodeKind, Style, StyleId, StyleKind, TextData, TextStyle, + Variable, VariableId, VariableType, VariableValue, Variables, trim_num, +}; use serde_json::{Value, json}; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{color_of, f64_of, get, id, num, paints_of, req_string, req_targets, string, targets}; use crate::{EngineError, Result, Session, bad, spec}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 3822c5c5..e8433fa5 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -1,11 +1,14 @@ //! Text menu and text editing commands. -use figcraft_doc::*; +use figcraft_doc::{ + LetterSpacing, LineHeight, ListStyle, NodeId, NodeKind, TextAlignH, TextAutoResize, TextCase, TextData, TextDecoration, TextPath, TextStyle, + VariableId, +}; use serde_json::{Map, Value, json}; use super::props::{styled_name, weight_name}; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{boolean, get, id, num, req_id, req_num, req_string, req_targets, string, targets}; use crate::{Result, Session, bad, spec, textedit}; fn finite(v: f64) -> f64 { diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index 53b62ee4..155fe519 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -4,12 +4,15 @@ use std::collections::BTreeSet; -use figcraft_doc::*; +use figcraft_doc::{ + CODE_PLATFORMS, Collection, CollectionId, Document, ModeId, NodeId, NodeKind, Variable, VariableId, VariableType, VariableValue, Variables, + normalize_scopes, scope_known, +}; use serde_json::{Value, json}; use super::lists::BoundList; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{boolean, get, num, req_string, string}; use crate::{Result, Session, bad, spec}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/variables_io.rs b/crates/engine/src/cmd/variables_io.rs index 6bd5bb40..b9e73f13 100644 --- a/crates/engine/src/cmd/variables_io.rs +++ b/crates/engine/src/cmd/variables_io.rs @@ -4,13 +4,13 @@ use std::collections::{BTreeMap, BTreeSet}; -use figcraft_doc::*; +use figcraft_doc::{Collection, CollectionId, Document, Mode, ModeId, Variable, VariableId, VariableValue, Variables, trim_num}; use serde_json::{Value, json}; use super::CommandSpec; use crate::codegen::{css_var_name, css_var_of}; use crate::library::{is_remote_collection, local_collections}; -use crate::params::*; +use crate::params::{get, string}; use crate::{EngineError, Result, Session, bad, spec}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 7364557f..97b947a5 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -1,10 +1,10 @@ //! View menu, preferences and tools. View state is per document and drivable like everything else. -use figcraft_doc::*; +use figcraft_doc::NodeId; use serde_json::{Value, json}; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{boolean, get, num, num_or, req_index, req_num, req_string, string}; use crate::tools::ToolId; use crate::{EngineError, Result, Session, ViewState, bad, spec}; From 5e166839635bc59c2dc2269f0cb65d5f19e6dcfd Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 16:00:01 +0000 Subject: [PATCH 19/33] refactor(engine): simplify canvas and board handlers --- crates/engine/src/boards/board/connectors.rs | 5 +++- crates/engine/src/boards/board/derived.rs | 5 +++- crates/engine/src/boards/board/edit.rs | 6 ++++- crates/engine/src/boards/board/items.rs | 8 ++++++- crates/engine/src/boards/board/tools.rs | 5 +++- crates/engine/src/canvas/drop.rs | 2 +- crates/engine/src/canvas/shapebuilder.rs | 4 ++-- crates/engine/src/canvas/tools.rs | 2 +- crates/engine/src/canvas/tools/click.rs | 5 +++- crates/engine/src/canvas/tools/drag.rs | 6 ++++- crates/engine/src/canvas/tools/draw.rs | 4 +++- crates/engine/src/canvas/tools/handles.rs | 2 +- crates/engine/src/canvas/tools/keys.rs | 2 +- crates/engine/src/canvas/tools/pen.rs | 5 +++- crates/engine/src/canvas/tools/pointer.rs | 5 +++- crates/engine/src/canvas/vectoredit.rs | 4 ++-- crates/engine/src/canvas/vectoredit/ops.rs | 2 +- .../engine/src/canvas/vectoredit/pointer.rs | 23 +++++++++++-------- .../engine/src/canvas/vectoredit/transform.rs | 2 +- crates/engine/src/canvas/vectoredit/width.rs | 4 ++-- .../canvas/vectoredit/width_sides_tests.rs | 1 + .../src/canvas/vectoredit/width_tests.rs | 1 + 22 files changed, 72 insertions(+), 31 deletions(-) diff --git a/crates/engine/src/boards/board/connectors.rs b/crates/engine/src/boards/board/connectors.rs index 753550e5..14760331 100644 --- a/crates/engine/src/boards/board/connectors.rs +++ b/crates/engine/src/boards/board/connectors.rs @@ -1,7 +1,10 @@ use super::derived::{ends_now, place_route, route}; use super::edit::set_label; use super::items::add; -use super::*; +use super::{ + BoardItem, Connector, Document, End, LINE, LINE_WEIGHT, NodeId, NodeKind, Paint, Point, Result, Session, Side, StrokeCap, StrokeJoin, Value, + VectorNetwork, bad, cap_of, get, id_of, json, num, req_targets, require, routing_of, string, +}; /// One connector end from the params: a layer on this page, or a point. fn end_param(cmd: &str, s: &Session, p: &Value, key: &str, side_key: &str) -> Result { diff --git a/crates/engine/src/boards/board/derived.rs b/crates/engine/src/boards/board/derived.rs index ac73df1e..9899632f 100644 --- a/crates/engine/src/boards/board/derived.rs +++ b/crates/engine/src/boards/board/derived.rs @@ -1,6 +1,9 @@ use super::edit::set_font_size; use super::items::{owner_of_text, shape_padding, sticky_font_size, text_of}; -use super::*; +use super::{ + Affine, BTreeSet, BezPath, BoardItem, Connector, Document, End, NodeId, NodeKind, PathEl, Point, Rect, Routing, Shape, Side, Vec2, VectorNetwork, + is_board, +}; // --------------------------------------------------------------------------------------------- // Derived data (the normalize pass). diff --git a/crates/engine/src/boards/board/edit.rs b/crates/engine/src/boards/board/edit.rs index 1770852c..9456256e 100644 --- a/crates/engine/src/boards/board/edit.rs +++ b/crates/engine/src/boards/board/edit.rs @@ -1,7 +1,11 @@ use super::connectors::connector_of; use super::derived::ends_now; use super::items::{add, add_text, shape_padding, text_of}; -use super::*; +use super::{ + Affine, AutoLayout, BTreeMap, BoardItem, BoardShape, Color, Connector, DocError, Document, End, INK, NodeId, NodeKind, Paint, Result, Routing, + Session, Sizing, StrokeCap, TextAlignH, TextAutoResize, TextData, Value, VectorNetwork, bad, cap_of, color_param, json, line_shade, num, + req_string, req_targets, require, routing_of, shape_of, string, +}; /// The label of connector `id`, if it has one. pub fn label_of(doc: &Document, id: NodeId) -> Option { diff --git a/crates/engine/src/boards/board/items.rs b/crates/engine/src/boards/board/items.rs index 6d96d34b..b76fc898 100644 --- a/crates/engine/src/boards/board/items.rs +++ b/crates/engine/src/boards/board/items.rs @@ -1,4 +1,10 @@ -use super::*; +use super::{ + AUTHOR_INK, Affine, AutoLayout, BlendMode, BoardItem, BoardShape, Color, Constraints, CrossAlign, Document, Effect, EffectKind, EngineError, + HConstraint, HIGHLIGHTER, INK, LayoutMode, MARKER, MainAlign, Node, NodeId, NodeKind, Paint, Result, SHAPE, STAMP_SIZE, STICKY, STICKY_GAP, + STICKY_PAD, Session, Shadow, Sizing, StrokeAlign, StrokeCap, StrokeJoin, TextAlignH, TextAutoResize, TextData, VConstraint, Value, VectorNetwork, + author, bad, boolean, color_param, get, id, id_of, json, line_shade, num, num_or, parent_of, req_num, require, shape_of, stamp_of, string, + textedit, +}; /// A new layer at the top of `parent`, at `at` in it, made by `f`. pub(super) fn add( diff --git a/crates/engine/src/boards/board/tools.rs b/crates/engine/src/boards/board/tools.rs index 2dc904c9..cb3fc005 100644 --- a/crates/engine/src/boards/board/tools.rs +++ b/crates/engine/src/boards/board/tools.rs @@ -2,7 +2,10 @@ use super::connectors::{end_json, other_end}; use super::derived::ends_now; use super::edit::label_of; use super::items::edit_text; -use super::*; +use super::{ + BoardItem, Document, ERASER_PX, End, EngineError, Gesture, NodeId, Point, PointerEvent, Result, SHAPE, STICKY, Session, Side, ToolId, id_of, + is_board, json, +}; // --------------------------------------------------------------------------------------------- // The board tools. diff --git a/crates/engine/src/canvas/drop.rs b/crates/engine/src/canvas/drop.rs index d5e8b3cf..9e054630 100644 --- a/crates/engine/src/canvas/drop.rs +++ b/crates/engine/src/canvas/drop.rs @@ -1,7 +1,7 @@ //! Where something dropped at a page point lands: the frame under the point (or the page) and, in //! an auto layout frame, the child slot by pointer position plus the insertion line to draw. -use figcraft_doc::*; +use figcraft_doc::{Document, LayoutMode, NodeId, NodeKind}; use figcraft_geom::Point; use serde::Serialize; diff --git a/crates/engine/src/canvas/shapebuilder.rs b/crates/engine/src/canvas/shapebuilder.rs index 99d59b45..74a4a9d1 100644 --- a/crates/engine/src/canvas/shapebuilder.rs +++ b/crates/engine/src/canvas/shapebuilder.rs @@ -21,14 +21,14 @@ use std::collections::BTreeSet; use std::sync::Arc; -use figcraft_doc::*; +use figcraft_doc::{Corners, Document, Node, NodeId, NodeKind, StrokeProps, VectorNetwork, WindingRule}; use figcraft_geom::kurbo::PathEl; use figcraft_geom::{Affine, BezPath, Point, Rect, Shape}; use serde_json::{Value, json}; use crate::cmd::CommandSpec; use crate::cmd::edit::{roots, z_sorted}; -use crate::params::*; +use crate::params::{f64_of, get, string, targets}; use crate::tools::{Cursor, Gesture, PointerEvent, PointerKind, ToolId}; use crate::{EngineError, Result, Session, bad, spec}; diff --git a/crates/engine/src/canvas/tools.rs b/crates/engine/src/canvas/tools.rs index fce14f7c..b1dc3eff 100644 --- a/crates/engine/src/canvas/tools.rs +++ b/crates/engine/src/canvas/tools.rs @@ -6,7 +6,7 @@ use std::collections::BTreeMap; use figcraft_color::{ImageScaleMode, PaintKind}; use figcraft_doc::geometry::select_target; -use figcraft_doc::*; +use figcraft_doc::{Document, LayoutMode, Node, NodeId, NodeKind, Sizing, StrokeAlign, StrokeCap, TextAutoResize, TextData, TextPath, VectorNetwork}; use figcraft_geom::{Affine, Point, Rect, Vec2}; use serde::Serialize; use serde_json::json; diff --git a/crates/engine/src/canvas/tools/click.rs b/crates/engine/src/canvas/tools/click.rs index 8f21aaca..a8291f0f 100644 --- a/crates/engine/src/canvas/tools/click.rs +++ b/crates/engine/src/canvas/tools/click.rs @@ -1,5 +1,8 @@ use super::handles::{PATH_REACH_PX, label_at, locked_in, path_candidates, readable_text_fills}; -use super::*; +use super::{ + Affine, Document, Gesture, Node, NodeId, NodeKind, Point, PointerEvent, Rect, Result, Session, Sizing, TextAutoResize, TextData, TextPath, + ToolId, frame_at, json, select_target, textedit, within_slop, +}; impl Session { /// Handle a double-click gesture: finish a pen path, rename or click labels/widgets, diff --git a/crates/engine/src/canvas/tools/drag.rs b/crates/engine/src/canvas/tools/drag.rs index 0d406673..d1db8f92 100644 --- a/crates/engine/src/canvas/tools/drag.rs +++ b/crates/engine/src/canvas/tools/drag.rs @@ -2,7 +2,11 @@ use super::handles::{ SNAP_PX, handle_at, in_quad, label_at, locked_in, on_corner_square, paints_below, resized_box, scale_factor, selection_box, selection_frame, shape_hint, }; -use super::*; +use super::{ + Affine, ArcHandle, ArcHint, Cursor, Document, Gesture, Guide, Handle, ImageScaleMode, LayoutMode, NodeId, NodeKind, PaintKind, Point, + PointerEvent, Rect, ResizeStart, Result, ScaleHint, Session, Sizing, Targets, TextAutoResize, ToolId, Vec2, move_world, roots, select_target, + textedit, within_slop, +}; impl Session { pub(super) fn move_down(&mut self, ev: &PointerEvent) -> Result<()> { diff --git a/crates/engine/src/canvas/tools/draw.rs b/crates/engine/src/canvas/tools/draw.rs index b594ae27..36a12bc2 100644 --- a/crates/engine/src/canvas/tools/draw.rs +++ b/crates/engine/src/canvas/tools/draw.rs @@ -1,6 +1,8 @@ use super::handles::SNAP_PX; use super::pen::take_enclosed; -use super::*; +use super::{ + Affine, Cursor, Gesture, Node, NodeId, NodeKind, Point, PointerEvent, Rect, Result, Session, StrokeCap, Targets, ToolId, Vec2, frame_at, textedit, +}; impl Session { pub(super) fn draw_down(&mut self, ev: &PointerEvent) -> Result<()> { diff --git a/crates/engine/src/canvas/tools/handles.rs b/crates/engine/src/canvas/tools/handles.rs index fd765f66..7ccaca40 100644 --- a/crates/engine/src/canvas/tools/handles.rs +++ b/crates/engine/src/canvas/tools/handles.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Affine, BTreeMap, Cursor, Document, Handle, NodeId, NodeKind, Point, ShapeHandle, ShapeHint, roots}; /// The frame whose name label (see [`ToolState::labels`]) is under `p` on `page`: the topmost /// one drawn, if it can be selected (still there, on the page, visible and unlocked). diff --git a/crates/engine/src/canvas/tools/keys.rs b/crates/engine/src/canvas/tools/keys.rs index 591f1479..a369463c 100644 --- a/crates/engine/src/canvas/tools/keys.rs +++ b/crates/engine/src/canvas/tools/keys.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Document, Gesture, Node, NodeId, Overlay, Point, PointerEvent, PointerKind, Result, Session, ShapeHandle, ToolId, json, textedit}; impl Session { /// Keys the current mode handles before menu shortcuts (vector edit mode's tools and point diff --git a/crates/engine/src/canvas/tools/pen.rs b/crates/engine/src/canvas/tools/pen.rs index 319c11b7..4857e83c 100644 --- a/crates/engine/src/canvas/tools/pen.rs +++ b/crates/engine/src/canvas/tools/pen.rs @@ -1,5 +1,8 @@ use super::handles::{PENCIL_STROKE, selected_end_at}; -use super::*; +use super::{ + Affine, Document, Gesture, Guide, Node, NodeId, NodeKind, PenPath, Point, PointerEvent, Result, Session, StrokeAlign, ToolId, Vec2, + VectorNetwork, at_45_degrees, frame_at, json, pen_handles, +}; /// Pen pressure along drawn samples as width profile points `[t, scale]`: `t` by length, at most /// 32 points, medium pressure (0.5) at the stroke weight, from a tenth to twice it. `None` when no diff --git a/crates/engine/src/canvas/tools/pointer.rs b/crates/engine/src/canvas/tools/pointer.rs index 985e3326..550c4a66 100644 --- a/crates/engine/src/canvas/tools/pointer.rs +++ b/crates/engine/src/canvas/tools/pointer.rs @@ -1,5 +1,8 @@ use super::handles::{handle_at, label_at, locked_in, on_corner_square, resize_cursor, selected_end_at}; -use super::*; +use super::{ + Cursor, Document, Gesture, NodeId, NodeKind, Overlay, PenCursor, Point, PointerEvent, PointerKind, Rect, Result, Session, ToolId, at_45_degrees, + frame_at, json, select_target, +}; impl Session { /// The pointer left the canvas: what hovering there showed goes (the distances Alt or Dev Mode diff --git a/crates/engine/src/canvas/vectoredit.rs b/crates/engine/src/canvas/vectoredit.rs index 03b22d22..fc263b4f 100644 --- a/crates/engine/src/canvas/vectoredit.rs +++ b/crates/engine/src/canvas/vectoredit.rs @@ -24,14 +24,14 @@ mod width_sides_tests; #[cfg(test)] mod width_tests; -use figcraft_doc::*; +use figcraft_doc::{Corners, Document, HandleMirroring, NodeId, NodeKind, StrokeAlign, StrokeCap, StrokeJoin, VectorNetwork, Vertex, WindingRule}; use figcraft_geom::{Affine, Point, Rect, Vec2}; use serde::Serialize; use serde_json::{Value, json}; use crate::cmd::CommandSpec; use crate::cmd::props::enum_of; -use crate::params::*; +use crate::params::{boolean, f64_of, get, id, num, num_or, req_num, string, targets}; use crate::{EngineError, Result, Session, bad, spec}; pub(crate) use pointer::VGesture; diff --git a/crates/engine/src/canvas/vectoredit/ops.rs b/crates/engine/src/canvas/vectoredit/ops.rs index 935f033a..8832e05d 100644 --- a/crates/engine/src/canvas/vectoredit/ops.rs +++ b/crates/engine/src/canvas/vectoredit/ops.rs @@ -2,7 +2,7 @@ //! deleting, cutting and joining. Pure functions on a [`VectorNetwork`]; the session wrappers in //! `vectoredit.rs` re-fit the node afterwards. -use figcraft_doc::*; +use figcraft_doc::{HandleMirroring, Region, Segment, VectorNetwork, Vertex, WindingRule}; use figcraft_geom::kurbo::{CubicBez, ParamCurve, ParamCurveNearest}; use figcraft_geom::{Affine, Point, Vec2}; diff --git a/crates/engine/src/canvas/vectoredit/pointer.rs b/crates/engine/src/canvas/vectoredit/pointer.rs index d977ce87..ebd8e2e3 100644 --- a/crates/engine/src/canvas/vectoredit/pointer.rs +++ b/crates/engine/src/canvas/vectoredit/pointer.rs @@ -5,7 +5,7 @@ use std::sync::Arc; -use figcraft_doc::*; +use figcraft_doc::{Document, HandleMirroring, NodeId, NodeKind, VectorNetwork, Vertex}; use figcraft_geom::{Affine, Point, Rect, Vec2}; use super::point_snap; @@ -510,20 +510,25 @@ impl Session { Ok(()) } + /// Another edited layer under the press (or, with the paint bucket, whose area it is in). + fn pressed_layer(doc: &Document, ve: &VectorEdit, ev: &PointerEvent, tol: f64) -> Option { + match hit_any(doc, ve, ev.p(), tol) { + Some((id, _)) => Some(id), + None if ve.tool == VTool::Paint => { + let inside = |id: NodeId| network(doc, id).is_some_and(|n| n.face_at(doc.world_transform(id).inverse() * ev.p()).is_some()); + (!inside(ve.id)).then(|| ve.others.iter().map(|o| o.id).find(|id| inside(*id))).flatten() + } + None => None, + } + } + /// Returns false when the click left edit mode (the caller then runs the normal tool). fn vector_down(&mut self, ve: &VectorEdit, ev: &PointerEvent) -> Result { let doc = self.doc()?.clone(); let tol = 6.0 / ev.zoom.max(1e-6); // A press on another edited layer's point, handle or segment (or, with the paint bucket, // area) makes it the active layer; a pen drawing a path keeps to its own layer. - let other = match hit_any(&doc, ve, ev.p(), tol) { - Some((id, _)) => Some(id), - None if ve.tool == VTool::Paint => { - let inside = |id: NodeId| network(&doc, id).is_some_and(|n| n.face_at(doc.world_transform(id).inverse() * ev.p()).is_some()); - (!inside(ve.id)).then(|| ve.others.iter().map(|o| o.id).find(|id| inside(*id))).flatten() - } - None => None, - }; + let other = Self::pressed_layer(&doc, ve, ev, tol); let switched; let ve = match other { Some(id) if id != ve.id && !(ve.tool == VTool::Pen && ve.anchor.is_some()) => { diff --git a/crates/engine/src/canvas/vectoredit/transform.rs b/crates/engine/src/canvas/vectoredit/transform.rs index 5efb5343..0f87a0bd 100644 --- a/crates/engine/src/canvas/vectoredit/transform.rs +++ b/crates/engine/src/canvas/vectoredit/transform.rs @@ -11,7 +11,7 @@ use serde_json::{Value, json}; use super::{VHit, VTool, VectorEdit, default_points, edit, editing, indices, network, ops, selected_points, selection_position}; use crate::cmd::CommandSpec; -use crate::params::*; +use crate::params::{boolean, f64_of, get, num, num_or, req_num}; use crate::tools::{Handle, handle_at_corners}; use crate::{Result, Session, bad, spec}; diff --git a/crates/engine/src/canvas/vectoredit/width.rs b/crates/engine/src/canvas/vectoredit/width.rs index ba51b043..13c7be0c 100644 --- a/crates/engine/src/canvas/vectoredit/width.rs +++ b/crates/engine/src/canvas/vectoredit/width.rs @@ -10,7 +10,7 @@ //! The points live in the stroke's [`WidthProfile`] and apply to every subpath (positions are //! fractions of each subpath's length). -use figcraft_doc::*; +use figcraft_doc::{Document, NodeId, WidthProfile, WidthSide}; use figcraft_geom::{Point, Vec2}; use figcraft_pathops::width::{Polyline, nearest, polylines}; use serde::Serialize; @@ -18,7 +18,7 @@ use serde_json::{Value, json}; use super::{editing, set_state}; use crate::cmd::CommandSpec; -use crate::params::*; +use crate::params::{get, num, req_index, req_num, string}; use crate::tools::{Gesture, PointerEvent}; use crate::{Result, Session, bad, spec}; diff --git a/crates/engine/src/canvas/vectoredit/width_sides_tests.rs b/crates/engine/src/canvas/vectoredit/width_sides_tests.rs index bf2dbeb0..937245ae 100644 --- a/crates/engine/src/canvas/vectoredit/width_sides_tests.rs +++ b/crates/engine/src/canvas/vectoredit/width_sides_tests.rs @@ -7,6 +7,7 @@ use serde_json::{Value, json}; use super::*; use crate::EngineError; use crate::tools::{PointerEvent, PointerKind}; +use figcraft_doc::{ExportFormat, WidthSide}; fn ex(s: &mut Session, c: &str, p: Value) -> Value { s.execute(c, &p).unwrap_or_else(|e| panic!("{c}: {e}")) diff --git a/crates/engine/src/canvas/vectoredit/width_tests.rs b/crates/engine/src/canvas/vectoredit/width_tests.rs index 54b4b0e2..8c89d4c6 100644 --- a/crates/engine/src/canvas/vectoredit/width_tests.rs +++ b/crates/engine/src/canvas/vectoredit/width_tests.rs @@ -6,6 +6,7 @@ use serde_json::{Value, json}; use super::*; use crate::EngineError; use crate::tools::{PointerEvent, PointerKind}; +use figcraft_doc::{ExportFormat, WidthPreset, WidthProfile, WidthSide}; fn ex(s: &mut Session, c: &str, p: Value) -> Value { s.execute(c, &p).unwrap_or_else(|e| panic!("{c}: {e}")) From d87f9effe1f2aabd83d6906cf172897e35ba64b7 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 16:00:01 +0000 Subject: [PATCH 20/33] refactor(engine): simplify asset and code generation helpers --- crates/engine/src/assets/components.rs | 4 +- crates/engine/src/assets/components/diff.rs | 2 +- crates/engine/src/assets/components/guard.rs | 5 +- .../engine/src/assets/components/instances.rs | 5 +- crates/engine/src/assets/components/patch.rs | 194 ++++---- crates/engine/src/assets/components/rebase.rs | 2 +- crates/engine/src/assets/components/sync.rs | 274 +++++----- crates/engine/src/assets/components/tests.rs | 29 +- .../engine/src/assets/components/variants.rs | 2 +- crates/engine/src/assets/library.rs | 6 +- crates/engine/src/assets/library/tests.rs | 202 ++++---- crates/engine/src/assets/library_swap.rs | 52 +- crates/engine/src/assets/playground.rs | 2 +- crates/engine/src/assets/style_vars.rs | 5 +- crates/engine/src/automation/inspect.rs | 470 +++++++++--------- crates/engine/src/content/synthetic_ds.rs | 5 +- .../engine/src/content/templates/dashboard.rs | 2 +- .../src/content/templates/design_system.rs | 2 +- .../engine/src/content/templates/icon_set.rs | 2 +- .../engine/src/content/templates/landing.rs | 2 +- crates/engine/src/content/templates/mobile.rs | 2 +- crates/engine/src/content/templates/slides.rs | 2 +- crates/engine/src/content/templates/social.rs | 2 +- .../engine/src/content/templates/wireframe.rs | 2 +- crates/engine/src/dev/annotations.rs | 4 +- crates/engine/src/dev/code_connect.rs | 4 +- crates/engine/src/dev/codegen.rs | 7 +- crates/engine/src/dev/codegen/arkui.rs | 225 +++++---- crates/engine/src/dev/codegen/compose.rs | 2 +- crates/engine/src/dev/codegen/css.rs | 241 +++++---- crates/engine/src/dev/codegen/html.rs | 134 ++--- crates/engine/src/dev/codegen/swift.rs | 2 +- crates/engine/src/dev/component_codegen.rs | 4 +- crates/engine/src/dev/dev_resources.rs | 4 +- crates/engine/src/dev/devcompare.rs | 2 +- crates/engine/src/dev/devinspect.rs | 7 +- 36 files changed, 1062 insertions(+), 849 deletions(-) diff --git a/crates/engine/src/assets/components.rs b/crates/engine/src/assets/components.rs index 3932efff..3192c3f1 100644 --- a/crates/engine/src/assets/components.rs +++ b/crates/engine/src/assets/components.rs @@ -33,7 +33,9 @@ use std::collections::{BTreeMap, BTreeSet}; -use figcraft_doc::*; +use figcraft_doc::{ + ChildLayout, ContentAlign, CrossAlign, DocError, Document, MainAlign, Node, NodeId, NodeKind, PropDef, Sizing, TextAutoResize, TextData, +}; use figcraft_geom::Affine; use serde_json::Value; diff --git a/crates/engine/src/assets/components/diff.rs b/crates/engine/src/assets/components/diff.rs index bf742fed..41c1a424 100644 --- a/crates/engine/src/assets/components/diff.rs +++ b/crates/engine/src/assets/components/diff.rs @@ -1,5 +1,5 @@ use super::instances::PROP_BINDING; -use super::*; +use super::{Document, Node, NodeId, NodeKind, PropDef, Sizing, TextAutoResize, TextData, inside_instance, owning_component, variant_set}; /// The property groups whose value layer `n` takes from the same variables as its main layer /// `src` (they differ only where the two resolve in different variable modes). diff --git a/crates/engine/src/assets/components/guard.rs b/crates/engine/src/assets/components/guard.rs index b320b7c4..0424beff 100644 --- a/crates/engine/src/assets/components/guard.rs +++ b/crates/engine/src/assets/components/guard.rs @@ -1,7 +1,10 @@ use super::diff::geometry_derived; use super::rebase::rebase_instance; use super::variants::instance_issues; -use super::*; +use super::{ + ContentAlign, CrossAlign, Document, MainAlign, Node, NodeId, NodeKind, SLOT, Sizing, enclosing_instance, inside_instance, is_instance, + owning_instance, structure_locked, +}; /// Slot `slot` (inside an instance) takes the instance's own content: the layers it shows (the /// main's default content until now) stop mirroring the main, and instances among them stand diff --git a/crates/engine/src/assets/components/instances.rs b/crates/engine/src/assets/components/instances.rs index 9fd4e2c2..ac7ff4ec 100644 --- a/crates/engine/src/assets/components/instances.rs +++ b/crates/engine/src/assets/components/instances.rs @@ -1,6 +1,9 @@ use super::diff::{binding_group, bindings_of}; use super::sync::{insert_linked, sync_instance_depth}; -use super::*; +use super::{ + Affine, BTreeMap, ChildLayout, DocError, Document, Node, NodeId, NodeKind, Value, default_instance_name, default_props, holds_content, is_slot, + slot_edited, +}; /// Create an instance of `component` under `parent` at `(x, y)` (parent space). pub fn create_instance(doc: &mut Document, component: NodeId, parent: NodeId, x: f64, y: f64) -> Result { diff --git a/crates/engine/src/assets/components/patch.rs b/crates/engine/src/assets/components/patch.rs index 8c61f0b8..8ae01bc1 100644 --- a/crates/engine/src/assets/components/patch.rs +++ b/crates/engine/src/assets/components/patch.rs @@ -2,7 +2,12 @@ use super::diff::{binding_group, bindings_of, diff_groups, follow_boolean_defaul use super::guard::{own_slot_content, relink_copy}; use super::instances::{copy_group, field_group, sync_instance}; use super::sync::insert_linked; -use super::*; +use super::{ + BTreeMap, BTreeSet, Document, MAX_DEPTH, Node, NodeId, NodeKind, default_instance_name, holds_content, inside_instance, owning_component, + owning_instance, root_instance, scope, slot_edited, slot_scope, +}; + +type OverrideGroups = (Vec<&'static str>, Vec<&'static str>, Vec<&'static str>); /// After an edit: record overrides inside instances, then rebuild instances whose main changed, /// whose properties or main were switched, or whose overrides were reset. @@ -14,93 +19,9 @@ pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId] } } let changed = changed.as_slice(); - // 0. A slot whose layers were added, removed or reordered holds the instance's own content - // from now on; copies of instance layers placed elsewhere stop mirroring their main. - for &id in changed { - let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue }; - if n.children == b.children || owning_instance(doc, id).is_none() { - continue; - } - if let Some(slot) = slot_scope(doc, id) - && !slot_edited(doc, slot) - { - own_slot_content(doc, slot); - } - } - for &id in changed { - if !before.contains(id) { - relink_copy(doc, id); - } - } + adopt_slot_content(doc, before, changed); let mut resync: BTreeSet = BTreeSet::new(); - // 1. Overrides for layers edited inside instances. - for &id in changed { - let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue }; - let Some(inst) = owning_instance(doc, id) else { continue }; - let top_root = inst == id && !inside_instance(doc, id); - if top_root - && let (NodeKind::Instance { component: c1, props: p1 }, NodeKind::Instance { component: c2, props: p2 }) = (&n.kind, &b.kind) - && (c1 != c2 || p1 != p2) - { - // Switching the main or a property value of an instance isn't an override. - resync.insert(id); - } - let raw = diff_groups(b, n); - let mut groups = raw.clone(); - // An instance's own place, resizing, export settings, interactions and variable modes are - // its own, not overrides. - if top_root { - groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions" | "modes")); - } - if groups.iter().any(|g| matches!(*g, "size" | "position")) { - groups.retain(|g| !matches!(*g, "size" | "position") || !geometry_derived(doc, before, id, g)); - } - // A value equal to the main's isn't an override (setting it back clears the override). - let mut cleared = Vec::new(); - if let Some(src) = n.source.and_then(|s| doc.node(s)) { - let mut differs = diff_groups(src, n); - if inst == id { - differs.retain(|g| *g != "name"); - if n.name != default_instance_name(doc, src.id) { - differs.push("name"); - } - } - // A value from the same variable as the main's (resolved in this layer's modes) isn't an - // override either. - let from_variables = variable_groups(n, src); - groups.retain(|g| differs.contains(g) && !from_variables.contains(g)); - // Binding another variable (or none) where the main binds one is an override. - for (k, _) in b.bound.iter().chain(n.bound.iter()) { - let Some(g) = binding_group(k) else { continue }; - let rebound = b.bound.get(k) != n.bound.get(k) && !bindings_of(n, g).eq(bindings_of(src, g)); - if rebound && !groups.contains(&g) { - groups.push(g); - } - } - cleared = - raw.iter().filter(|g| !matches!(**g, "size" | "position") && !differs.contains(g) && n.overrides.contains(**g)).copied().collect(); - if top_root { - cleared.retain(|g| !matches!(*g, "component" | "props")); - } - } - let lost = b.overrides.iter().any(|g| !n.overrides.contains(g)); - let nested_switch = !top_root && raw.iter().any(|g| matches!(*g, "component" | "props")); - if (!groups.is_empty() || !cleared.is_empty()) - && let Some(m) = doc.node_mut(id) - { - for g in groups { - m.overrides.insert(g.to_string()); - } - for g in cleared { - m.overrides.remove(g); - } - } - if (lost || nested_switch) - && let Some(r) = root_instance(doc, id) - { - resync.insert(r); - } - } + record_overrides(doc, before, changed, &mut resync); // 2. Components whose layers changed, were added or removed. Value-only edits (paint, text, // visibility, …) are carried to the copies directly; anything else rebuilds the instances. let mut comps: BTreeSet = BTreeSet::new(); @@ -172,6 +93,105 @@ pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId] rebuild_instances(doc, &comps); } +/// Slot content changes: layers added, removed or reordered in a slot become the instance's own. +fn adopt_slot_content(doc: &mut Document, before: &Document, changed: &[NodeId]) { + // 0. A slot whose layers were added, removed or reordered holds the instance's own content + // from now on; copies of instance layers placed elsewhere stop mirroring their main. + for &id in changed { + let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue }; + if n.children == b.children || owning_instance(doc, id).is_none() { + continue; + } + if let Some(slot) = slot_scope(doc, id) + && !slot_edited(doc, slot) + { + own_slot_content(doc, slot); + } + } + for &id in changed { + if !before.contains(id) { + relink_copy(doc, id); + } + } +} + +/// Overrides for layers edited inside instances; the instances to rebuild go into `resync`. +fn record_overrides(doc: &mut Document, before: &Document, changed: &[NodeId], resync: &mut BTreeSet) { + for &id in changed { + let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue }; + let Some(inst) = owning_instance(doc, id) else { continue }; + let top_root = inst == id && !inside_instance(doc, id); + if top_root + && let (NodeKind::Instance { component: c1, props: p1 }, NodeKind::Instance { component: c2, props: p2 }) = (&n.kind, &b.kind) + && (c1 != c2 || p1 != p2) + { + // Switching the main or a property value of an instance isn't an override. + resync.insert(id); + } + let (raw, groups, cleared) = override_groups(doc, before, id, n, b, inst, top_root); + let lost = b.overrides.iter().any(|g| !n.overrides.contains(g)); + let nested_switch = !top_root && raw.iter().any(|g| matches!(*g, "component" | "props")); + if (!groups.is_empty() || !cleared.is_empty()) + && let Some(m) = doc.node_mut(id) + { + for g in groups { + m.overrides.insert(g.to_string()); + } + for g in cleared { + m.overrides.remove(g); + } + } + if (lost || nested_switch) + && let Some(r) = root_instance(doc, id) + { + resync.insert(r); + } + } +} + +/// `(raw, groups, cleared)` of layer `id` edited from `b` to `n`: the raw changed groups, the ones +/// that are now overrides and the overrides that were set back to the main's value. +fn override_groups(doc: &Document, before: &Document, id: NodeId, n: &Node, b: &Node, inst: NodeId, top_root: bool) -> OverrideGroups { + let raw = diff_groups(b, n); + let mut groups = raw.clone(); + // An instance's own place, resizing, export settings, interactions and variable modes are + // its own, not overrides. + if top_root { + groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions" | "modes")); + } + if groups.iter().any(|g| matches!(*g, "size" | "position")) { + groups.retain(|g| !matches!(*g, "size" | "position") || !geometry_derived(doc, before, id, g)); + } + // A value equal to the main's isn't an override (setting it back clears the override). + let mut cleared = Vec::new(); + if let Some(src) = n.source.and_then(|s| doc.node(s)) { + let mut differs = diff_groups(src, n); + if inst == id { + differs.retain(|g| *g != "name"); + if n.name != default_instance_name(doc, src.id) { + differs.push("name"); + } + } + // A value from the same variable as the main's (resolved in this layer's modes) isn't an + // override either. + let from_variables = variable_groups(n, src); + groups.retain(|g| differs.contains(g) && !from_variables.contains(g)); + // Binding another variable (or none) where the main binds one is an override. + for (k, _) in b.bound.iter().chain(n.bound.iter()) { + let Some(g) = binding_group(k) else { continue }; + let rebound = b.bound.get(k) != n.bound.get(k) && !bindings_of(n, g).eq(bindings_of(src, g)); + if rebound && !groups.contains(&g) { + groups.push(g); + } + } + cleared = raw.iter().filter(|g| !matches!(**g, "size" | "position") && !differs.contains(g) && n.overrides.contains(**g)).copied().collect(); + if top_root { + cleared.retain(|g| !matches!(*g, "component" | "props")); + } + } + (raw, groups, cleared) +} + #[cfg(test)] thread_local! { /// Tests: rebuild instances even for edits that could be patched (to compare both ways). diff --git a/crates/engine/src/assets/components/rebase.rs b/crates/engine/src/assets/components/rebase.rs index 748a99d3..ea9cb6f3 100644 --- a/crates/engine/src/assets/components/rebase.rs +++ b/crates/engine/src/assets/components/rebase.rs @@ -1,6 +1,6 @@ use super::diff::{diff_groups, variable_groups}; use super::instances::{field_group, path_map}; -use super::*; +use super::{BTreeMap, BTreeSet, Document, MAX_DEPTH, Node, NodeId, NodeKind, default_instance_name, holds_content}; /// Bounded source and parent lookups while a rebase reads an unchanged document. pub(super) struct RebaseSources<'a> { diff --git a/crates/engine/src/assets/components/sync.rs b/crates/engine/src/assets/components/sync.rs index c502322d..e81bf437 100644 --- a/crates/engine/src/assets/components/sync.rs +++ b/crates/engine/src/assets/components/sync.rs @@ -1,6 +1,9 @@ use super::diff::diff_groups; use super::instances::{Carry, apply_prop, carry_overrides, carry_prop_bindings, copy_group, field_group, follow_variant_resizing, path_map}; -use super::*; +use super::{ + Affine, BTreeMap, BTreeSet, DocError, Document, MAX_DEPTH, Node, NodeId, NodeKind, SLOT, Sizing, Value, default_instance_name, default_props, + holds_content, inside_instance, is_slot, owning_component, variant_set, +}; /// `spare`: layers the outer rebuild didn't reuse that were below `inst` (a swapped nested /// instance): its layers the outer main doesn't have, matched here so they keep ids and overrides. @@ -16,31 +19,10 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize // A missing main, or an instance inside its own main (would recurse forever). return; } - let mut eff_props = default_props(&comp); - for (k, v) in root_props { - if eff_props.contains_key(k) { - eff_props.insert(k.clone(), v.clone()); - } - } + let eff_props = effective_props(&comp, root_props); // Old layers (kept alive for their overrides), by (parent, source), by source alone (when only // one layer mirrors it) and, only when those don't match, by name path. - let mut old_ids = old_layers(doc, inst); - old_ids.extend(spare.keys().copied()); - old_ids.sort(); - old_ids.dedup(); - let mut old: BTreeMap> = spare; - let mut by_source: BTreeMap<(NodeId, NodeId), NodeId> = BTreeMap::new(); - let mut by_src: BTreeMap> = BTreeMap::new(); - for i in &old_ids { - let Some(n) = doc.node_arc(*i).or_else(|| old.get(i)).cloned() else { continue }; - if let Some(s) = n.source { - if let Some(p) = n.parent { - by_source.entry((p, s)).or_insert(*i); - } - by_src.entry(s).and_modify(|e| *e = None).or_insert(Some(*i)); - } - old.insert(*i, n); - } + let (mut old, by_source, by_src) = index_old_layers(doc, inst, spare); let mut by_path: Option> = None; let mut main_paths: Option> = None; // Ids for the copies: reuse the matching old layer's id (a layer the main moved to another @@ -124,23 +106,7 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize // Component properties: the main layer's bindings use this instance's values; layers of // nested instances use the nested instance's values (one level per source hop). let enc = enclosing.get(&parent).cloned().unwrap_or_default(); - let mut chain: Vec<&Node> = vec![orig]; - while chain.len() < enc.len() { - let Some(next) = chain.last().and_then(|c| c.source).and_then(|x| doc.node(x)) else { break }; - chain.push(next); - } - for (j, level) in chain.iter().enumerate().rev() { - let Some(props) = enc.get(j).and_then(|e| inst_props.get(e)) else { continue }; - for (field, prop) in &level.prop_refs { - let g = field_group(field); - if n.overrides.contains(g) || (j > 0 && orig.overrides.contains(g)) { - continue; - } - if let Some(val) = props.get(prop) { - apply_prop(doc, &mut n, field, val); - } - } - } + apply_enclosing_props(doc, &mut n, orig, &enc, &inst_props); if let NodeKind::Instance { component: c, props } = &n.kind { inst_props.insert(nid, props.clone()); let mut e = enc; @@ -167,6 +133,155 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize p.children.retain(|c| *c != id); } } + let fresh = fresh_root(doc, &mut root, &comp, inst, component, &map); + if let Some(r) = doc.node_mut(inst) { + *r = fresh; + } + // A resized instance: children follow their constraints (auto layout re-flows later). + let (iw, ih) = doc.node(inst).map(|n| (n.width, n.height)).unwrap_or((comp.width, comp.height)); + if (iw - comp.width).abs() > 1e-9 || (ih - comp.height).abs() > 1e-9 { + figcraft_layout::apply_constraints(doc, inst, comp.width, comp.height); + remeasure = map.values().copied().collect(); + } + follow_geometry_overrides(doc, &src, &map, &old, &mut remeasure); + remeasure_text(doc, remeasure); + rebuild_nested(doc, nested, &old, &used, depth); +} + +/// Geometry overrides of children win over constraints. +fn follow_geometry_overrides( + doc: &mut Document, + src: &[NodeId], + map: &BTreeMap, + old: &BTreeMap>, + remeasure: &mut Vec, +) { + for s in src { + let Some(nid) = map.get(s).copied() else { continue }; + let Some(o) = old.get(&nid) else { continue }; + // A frame that kept the size its own resizing gave it: its layers (copied at their main + // layers' places) follow their constraints from its main layer's size. + if o.overrides.contains("resizing") + && !o.overrides.contains("size") + && doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none()) + && let Some((mw, mh)) = doc.node(*s).map(|m| (m.width, m.height)) + { + figcraft_layout::apply_constraints(doc, nid, mw, mh); + } + if !o.overrides.contains("size") && !o.overrides.contains("position") { + continue; + } + remeasure.push(nid); + let Some((ow, oh)) = doc.node(nid).map(|n| (n.width, n.height)) else { continue }; + if o.overrides.contains("size") { + // Through set_size so vector networks scale with the box. + figcraft_layout::set_size(doc, nid, o.width, o.height); + if doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none()) { + figcraft_layout::apply_constraints(doc, nid, ow, oh); + } + } + if let Some(n) = doc.node_mut(nid) { + carry_overrides(o, n, Carry::Geometry); + } + } +} + +/// Nested instances showing another main than the main's copy (swapped): rebuild from it, +/// with the old layers below them this rebuild didn't reuse. +fn rebuild_nested(doc: &mut Document, nested: Vec, old: &BTreeMap>, used: &BTreeSet, depth: usize) { + let unused: BTreeMap> = + old.iter().filter(|(i, _)| !used.contains(*i) && !doc.contains(**i)).map(|(i, x)| (*i, x.clone())).collect(); + for n in nested { + let mut below: BTreeMap> = unused + .iter() + .filter(|(_, x)| { + let mut cur = x.parent; + for _ in 0..=MAX_DEPTH * 4 { + match cur { + Some(p) if p == n => return true, + Some(p) => cur = unused.get(&p).and_then(|a| a.parent), + None => return false, + } + } + false + }) + .map(|(i, x)| (*i, x.clone())) + .collect(); + take_placing_overrides(doc, n, old, &mut below); + sync_instance_depth(doc, n, depth + 1, below); + } +} + +fn apply_enclosing_props(doc: &Document, n: &mut Node, orig: &Node, enc: &[NodeId], inst_props: &BTreeMap>) { + let mut chain: Vec<&Node> = vec![orig]; + while chain.len() < enc.len() { + let Some(next) = chain.last().and_then(|c| c.source).and_then(|x| doc.node(x)) else { break }; + chain.push(next); + } + for (j, level) in chain.iter().enumerate().rev() { + let Some(props) = enc.get(j).and_then(|e| inst_props.get(e)) else { continue }; + for (field, prop) in &level.prop_refs { + let g = field_group(field); + if n.overrides.contains(g) || (j > 0 && orig.overrides.contains(g)) { + continue; + } + if let Some(val) = props.get(prop) { + apply_prop(doc, n, field, val); + } + } + } +} + +/// Text whose characters or style differ from the main re-measures. +fn remeasure_text(doc: &mut Document, remeasure: Vec) { + for nid in remeasure { + if let Some(n) = doc.node(nid) + && let Some((w, h)) = figcraft_text::auto_size(n) + && ((w - n.width).abs() > 1e-6 || (h - n.height).abs() > 1e-6) + { + figcraft_layout::set_size(doc, nid, w, h); + } + } +} + +/// The component's property defaults with the instance's values for the ones it has. +fn effective_props(comp: &Node, root_props: &BTreeMap) -> BTreeMap { + let mut eff_props = default_props(comp); + for (k, v) in root_props { + if eff_props.contains_key(k) { + eff_props.insert(k.clone(), v.clone()); + } + } + eff_props +} + +type OldIndex = (BTreeMap>, BTreeMap<(NodeId, NodeId), NodeId>, BTreeMap>); + +/// The old layers of `inst` (and `spare`) by id, by (parent, source) and by source alone (`None` +/// when several layers mirror it). +fn index_old_layers(doc: &Document, inst: NodeId, spare: BTreeMap>) -> OldIndex { + let mut old_ids = old_layers(doc, inst); + old_ids.extend(spare.keys().copied()); + old_ids.sort(); + old_ids.dedup(); + let mut old = spare; + let mut by_source: BTreeMap<(NodeId, NodeId), NodeId> = BTreeMap::new(); + let mut by_src: BTreeMap> = BTreeMap::new(); + for i in &old_ids { + let Some(n) = doc.node_arc(*i).or_else(|| old.get(i)).cloned() else { continue }; + if let Some(s) = n.source { + if let Some(p) = n.parent { + by_source.entry((p, s)).or_insert(*i); + } + by_src.entry(s).and_modify(|e| *e = None).or_insert(Some(*i)); + } + old.insert(*i, n); + } + (old, by_source, by_src) +} + +/// The rebuilt root of instance `inst` of `comp`. +fn fresh_root(doc: &Document, root: &mut Node, comp: &Node, inst: NodeId, component: NodeId, map: &BTreeMap) -> Node { // Root: follows the main except its own placement, name, size and overrides. let order: Vec = comp.children.iter().filter_map(|c| map.get(c).copied()).filter(|c| doc.contains(*c)).collect(); let mut fresh = comp.clone(); @@ -175,7 +290,7 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize fresh.children = order; fresh.transform = root.transform; fresh.kind = root.kind.clone(); - carry_prop_bindings(&root, &mut fresh); + carry_prop_bindings(root, &mut fresh); // A nested instance keeps mirroring its layer of the outer main. let nested_copy = inside_instance(doc, inst); fresh.source = if nested_copy { root.source } else { Some(component) }; @@ -212,81 +327,12 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize fresh.name = if renamed { root.name.clone() } else { default_instance_name(doc, component) }; // Switched to another variant: its own overrides stay where both variants agreed. if let Some(prev) = root.source.and_then(|p| doc.node(p)).filter(|p| sibling_variants(doc, p.id, component)) { - let differs = diff_groups(prev, &comp); + let differs = diff_groups(prev, comp); root.overrides.retain(|g| g == "name" || !differs.contains(&g.as_str())); follow_variant_resizing(&mut fresh.child_layout, &prev.child_layout, &comp.child_layout); } - carry_overrides(&root, &mut fresh, Carry::Root); - if let Some(r) = doc.node_mut(inst) { - *r = fresh; - } - // A resized instance: children follow their constraints (auto layout re-flows later). - let (iw, ih) = doc.node(inst).map(|n| (n.width, n.height)).unwrap_or((comp.width, comp.height)); - if (iw - comp.width).abs() > 1e-9 || (ih - comp.height).abs() > 1e-9 { - figcraft_layout::apply_constraints(doc, inst, comp.width, comp.height); - remeasure = map.values().copied().collect(); - } - // Geometry overrides of children win over constraints. - for s in &src { - let Some(nid) = map.get(s).copied() else { continue }; - let Some(o) = old.get(&nid) else { continue }; - // A frame that kept the size its own resizing gave it: its layers (copied at their main - // layers' places) follow their constraints from its main layer's size. - if o.overrides.contains("resizing") - && !o.overrides.contains("size") - && doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none()) - && let Some((mw, mh)) = doc.node(*s).map(|m| (m.width, m.height)) - { - figcraft_layout::apply_constraints(doc, nid, mw, mh); - } - if !o.overrides.contains("size") && !o.overrides.contains("position") { - continue; - } - remeasure.push(nid); - let Some((ow, oh)) = doc.node(nid).map(|n| (n.width, n.height)) else { continue }; - if o.overrides.contains("size") { - // Through set_size so vector networks scale with the box. - figcraft_layout::set_size(doc, nid, o.width, o.height); - if doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none()) { - figcraft_layout::apply_constraints(doc, nid, ow, oh); - } - } - if let Some(n) = doc.node_mut(nid) { - carry_overrides(o, n, Carry::Geometry); - } - } - // Text whose characters or style differ from the main re-measures. - for nid in remeasure { - if let Some(n) = doc.node(nid) - && let Some((w, h)) = figcraft_text::auto_size(n) - && ((w - n.width).abs() > 1e-6 || (h - n.height).abs() > 1e-6) - { - figcraft_layout::set_size(doc, nid, w, h); - } - } - // Nested instances showing another main than the main's copy (swapped): rebuild from it, - // with the old layers below them this rebuild didn't reuse. - let unused: BTreeMap> = - old.iter().filter(|(i, _)| !used.contains(*i) && !doc.contains(**i)).map(|(i, x)| (*i, x.clone())).collect(); - for n in nested { - let mut below: BTreeMap> = unused - .iter() - .filter(|(_, x)| { - let mut cur = x.parent; - for _ in 0..=MAX_DEPTH * 4 { - match cur { - Some(p) if p == n => return true, - Some(p) => cur = unused.get(&p).and_then(|a| a.parent), - None => return false, - } - } - false - }) - .map(|(i, x)| (*i, x.clone())) - .collect(); - take_placing_overrides(doc, n, &old, &mut below); - sync_instance_depth(doc, n, depth + 1, below); - } + carry_overrides(root, &mut fresh, Carry::Root); + fresh } /// Before swapped nested instance `n` is rebuilt from its new main: each of its old layers (the diff --git a/crates/engine/src/assets/components/tests.rs b/crates/engine/src/assets/components/tests.rs index 30254651..82aabc63 100644 --- a/crates/engine/src/assets/components/tests.rs +++ b/crates/engine/src/assets/components/tests.rs @@ -383,6 +383,22 @@ fn resized_instances_follow_constraints_and_auto_layout() { healthy(&s); } +/// The tail of `component_properties_apply_to_instances`: unbind, delete, then a variant property. +fn unbind_delete_and_vary(s: &mut Session, c: NodeId, i: NodeId, title: NodeId, dot: NodeId) { + // Unbind and delete. + ex(s, "component.bindProperty", json!({"layer": dot, "property": null, "field": "visible"})); + assert!(node(s, copy_of(s, i, dot)).visible); + ex(s, "component.deleteProperty", json!({"id": c, "name": "Heading"})); + assert!(node(s, title).prop_refs.is_empty()); + assert!(matches!(&node(s, i).kind, NodeKind::Instance { props, .. } if !props.contains_key("Heading"))); + let desc = ex(s, "component.properties", json!({"id": i})); + assert_eq!(desc["properties"].as_array().unwrap().len(), 1); + // A variant property on a lone component makes it a component set. + let set = idv(&ex(s, "component.addProperty", json!({"id": c, "name": "Theme", "type": "variant", "default": "Light"}))); + assert!(matches!(node(s, set).kind, NodeKind::ComponentSet { .. })); + assert_eq!(node(s, c).name, "Theme=Light"); +} + #[test] fn component_properties_apply_to_instances() { let mut s = Session::new(); @@ -414,18 +430,7 @@ fn component_properties_apply_to_instances() { assert!(s.execute("component.bindProperty", &json!({"layer": dot, "property": "Heading"})).is_err()); assert!(s.execute("component.bindProperty", &json!({"layer": ititle, "property": "Heading"})).is_err()); assert!(s.execute("component.addProperty", &json!({"id": c, "name": "Heading", "type": "text"})).is_err()); - // Unbind and delete. - ex(&mut s, "component.bindProperty", json!({"layer": dot, "property": null, "field": "visible"})); - assert!(node(&s, copy_of(&s, i, dot)).visible); - ex(&mut s, "component.deleteProperty", json!({"id": c, "name": "Heading"})); - assert!(node(&s, title).prop_refs.is_empty()); - assert!(matches!(&node(&s, i).kind, NodeKind::Instance { props, .. } if !props.contains_key("Heading"))); - let desc = ex(&mut s, "component.properties", json!({"id": i})); - assert_eq!(desc["properties"].as_array().unwrap().len(), 1); - // A variant property on a lone component makes it a component set. - let set = idv(&ex(&mut s, "component.addProperty", json!({"id": c, "name": "Theme", "type": "variant", "default": "Light"}))); - assert!(matches!(node(&s, set).kind, NodeKind::ComponentSet { .. })); - assert_eq!(node(&s, c).name, "Theme=Light"); + unbind_delete_and_vary(&mut s, c, i, title, dot); healthy(&s); } diff --git a/crates/engine/src/assets/components/variants.rs b/crates/engine/src/assets/components/variants.rs index 40ac23ea..bc0d25b7 100644 --- a/crates/engine/src/assets/components/variants.rs +++ b/crates/engine/src/assets/components/variants.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{BTreeMap, Document, MAX_DEPTH, Node, NodeId, NodeKind, holds_content, inside_instance}; /// A set's default variant: the one in its top-left corner (nearest to it, then the top-most, then /// the left-most). diff --git a/crates/engine/src/assets/library.rs b/crates/engine/src/assets/library.rs index 56b0fc07..24699ef2 100644 --- a/crates/engine/src/assets/library.rs +++ b/crates/engine/src/assets/library.rs @@ -15,7 +15,11 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::Arc; use figcraft_doc::proto::Action; -use figcraft_doc::*; +use figcraft_doc::{ + Collection, CollectionId, Document, ImportedComponent, ImportedRecord, ImportedStyle, LibraryAsset, LibraryCollection, LibraryLink, + LibraryManifest, LibraryStyle, LibraryVariable, Node, NodeId, NodeKind, PropDef, PublishedLibrary, Style, StyleKind, Variable, VariableId, + VariableValue, Variables, node_images, paint_images, used_images, +}; use serde_json::Value; use crate::components::inside_instance; diff --git a/crates/engine/src/assets/library/tests.rs b/crates/engine/src/assets/library/tests.rs index 27c9e616..c8ef2e49 100644 --- a/crates/engine/src/assets/library/tests.rs +++ b/crates/engine/src/assets/library/tests.rs @@ -107,6 +107,49 @@ fn publish_to(s: &mut Session, path: &str) -> String { r["id"].as_str().unwrap().to_string() } +/// `publish_manifest_roundtrip`: the file saved and read back keeps the manifest. +fn saved_and_read_back(doc: &Document, lib: &PublishedLibrary) { + let m = &lib.manifest; + // Saved and read back: the author's file and a reader of the library see the same manifest. + let bytes = figcraft_format::save_bytes(doc, None).unwrap(); + let (back, issues) = figcraft_format::load_bytes(&bytes).unwrap(); + assert!(issues.is_empty(), "{issues:?}"); + assert_eq!(back.published.as_ref().map(|p| &p.manifest), Some(m)); + let read = figcraft_format::read_library(std::io::Cursor::new(&bytes)).unwrap().unwrap(); + assert_eq!(read.manifest, *m); + assert_eq!(read.doc.node_count(), lib.doc.node_count()); + // Older files and files never published have no library; readers that don't know libraries + // are unaffected (the entries are extra archive members). + let plain = figcraft_format::save_bytes(&Document::new("x"), None).unwrap(); + assert!(figcraft_format::read_library(std::io::Cursor::new(&plain)).unwrap().is_none()); + assert!(figcraft_format::load_bytes(&plain).unwrap().0.published.is_none()); +} + +/// `publish_manifest_roundtrip`: a republish bumps only what changed. +fn republish_bumps_touched(s: &mut Session, k: &Kit) { + // Nothing changed: refused. A change bumps only what it touches (and what uses it). + let e = s.execute("library.publish", &json!({})).unwrap_err(); + assert!(e.to_string().contains("no changes"), "{e}"); + ex(s, "node.set", json!({"id": k.card, "props": {"fill": "#eeeeee"}})); + let r = ex(s, "library.publish", json!({})); + assert_eq!(r["version"], 2); + let m = s.doc().unwrap().published.clone().unwrap().manifest.clone(); + assert_eq!(m.component(k.card).map(|a| a.version), Some(2)); + assert_eq!(m.component(k.button).map(|a| a.version), Some(1), "untouched assets keep their version"); + assert_eq!(m.description, "Our kit", "the description carries over"); + // Excluding an asset; a used component still comes along (not offered). + let r = ex(s, "library.publish", json!({"exclude": ["Button"]})); + assert_eq!(r["components"], 1); + let m = s.doc().unwrap().published.clone().unwrap().manifest.clone(); + assert!(m.component(k.button).is_some_and(|a| !a.published)); + // Unknown assets are an error, not ignored. + assert!(s.execute("library.publish", &json!({"include": ["Nope"]})).is_err()); + assert!(s.execute("library.publish", &json!({"include": "Button"})).is_err()); + // Undo takes the publish back. + s.undo().unwrap(); + assert_eq!(s.doc().unwrap().published.as_ref().map(|p| p.manifest.version), Some(2)); +} + #[test] fn publish_manifest_roundtrip() { let mut s = Session::new(); @@ -126,41 +169,74 @@ fn publish_manifest_roundtrip() { assert!(lib.doc.node(k.label).is_some()); assert!(lib.doc.validate().is_empty()); assert_eq!(lib.doc.styles.len(), 1); - // Saved and read back: the author's file and a reader of the library see the same manifest. - let bytes = figcraft_format::save_bytes(doc, None).unwrap(); - let (back, issues) = figcraft_format::load_bytes(&bytes).unwrap(); - assert!(issues.is_empty(), "{issues:?}"); - assert_eq!(back.published.as_ref().map(|p| &p.manifest), Some(m)); - let read = figcraft_format::read_library(std::io::Cursor::new(&bytes)).unwrap().unwrap(); - assert_eq!(read.manifest, *m); - assert_eq!(read.doc.node_count(), lib.doc.node_count()); - // Older files and files never published have no library; readers that don't know libraries - // are unaffected (the entries are extra archive members). - let plain = figcraft_format::save_bytes(&Document::new("x"), None).unwrap(); - assert!(figcraft_format::read_library(std::io::Cursor::new(&plain)).unwrap().is_none()); - assert!(figcraft_format::load_bytes(&plain).unwrap().0.published.is_none()); + saved_and_read_back(doc, &lib); - // Nothing changed: refused. A change bumps only what it touches (and what uses it). - let e = s.execute("library.publish", &json!({})).unwrap_err(); - assert!(e.to_string().contains("no changes"), "{e}"); - ex(&mut s, "node.set", json!({"id": k.card, "props": {"fill": "#eeeeee"}})); - let r = ex(&mut s, "library.publish", json!({})); - assert_eq!(r["version"], 2); - let m = s.doc().unwrap().published.clone().unwrap().manifest.clone(); - assert_eq!(m.component(k.card).map(|a| a.version), Some(2)); - assert_eq!(m.component(k.button).map(|a| a.version), Some(1), "untouched assets keep their version"); - assert_eq!(m.description, "Our kit", "the description carries over"); - // Excluding an asset; a used component still comes along (not offered). - let r = ex(&mut s, "library.publish", json!({"exclude": ["Button"]})); - assert_eq!(r["components"], 1); - let m = s.doc().unwrap().published.clone().unwrap().manifest.clone(); - assert!(m.component(k.button).is_some_and(|a| !a.published)); - // Unknown assets are an error, not ignored. - assert!(s.execute("library.publish", &json!({"include": ["Nope"]})).is_err()); - assert!(s.execute("library.publish", &json!({"include": "Button"})).is_err()); - // Undo takes the publish back. - s.undo().unwrap(); - assert_eq!(s.doc().unwrap().published.as_ref().map(|p| p.manifest.version), Some(2)); + republish_bumps_touched(&mut s, &k); +} + +/// `add_place_and_update_keep_overrides`: library copies refuse every edit and roll back. +fn assert_remote_read_only(s: &mut Session, b: NodeId, remote_button: NodeId) { + let unchanged = s.doc().unwrap().clone(); + let e = s.execute("node.set", &json!({"id": remote_button, "props": {"fill": "#ff0000"}})).unwrap_err(); + assert!(e.to_string().contains("can't be edited"), "{e}"); + assert_eq!(s.doc().unwrap(), &unchanged, "a refused remote edit rolls back"); + for (cmd, args) in + [("node.reparent", json!({"ids": [remote_button], "parent": s.page().unwrap()})), ("edit.delete", json!({"ids": [remote_button]}))] + { + assert!(s.execute(cmd, &args).is_err(), "{cmd} must preserve read-only copies"); + assert_eq!(s.doc().unwrap(), &unchanged, "refused {cmd} rolls back"); + } + let style = s.doc().unwrap().styles[0].id.clone(); + assert!(s.execute("style.update", &json!({"style": style, "name": "Mine"})).is_err()); + assert!(s.execute("style.delete", &json!({"style": style})).is_err()); + let e = s.execute("component.goToMain", &json!({"id": b})).unwrap_err(); + assert!(e.to_string().contains("library"), "{e}"); + assert!(s.execute("select.set", &json!({"ids": [remote_button]})).is_err(), "library copies can't be selected"); + assert!(s.doc().unwrap().pages.contains(&s.page().unwrap())); + assert!(s.execute("component.pushChanges", &json!({"id": b})).is_err()); + assert_eq!(fill(s, remote_button), "0000FF"); +} + +/// `add_place_and_update_keep_overrides`: the library changes and publishes; the copies follow. Returns the Card's instance. +fn library_updates_flow(s: &mut Session, lib: &mut Session, k: &Kit, lid: &str, [b, b_label, remote_button, main]: [NodeId; 4]) -> NodeId { + // The library changes the Button (fill via its style, and its label) and publishes again. + ex(lib, "style.update", json!({"style": "Brand", "value": "#00ff00"})); + ex(lib, "text.set", json!({"id": k.label, "characters": "Go"})); + ex(lib, "library.publish", json!({})); + ex(lib, "file.save", json!({})); + + let u = ex(s, "library.updates", json!({})); + assert_eq!(u["count"], 3, "Button, Card and the style: {u}"); + let label_ids: Vec = s.doc().unwrap().subtree(b); + ex(s, "library.acceptUpdates", json!({})); + assert_eq!(fill(s, b), "00FF00", "the instance follows the updated main"); + assert_eq!(chars(s, b_label), "Buy", "its override is kept"); + assert_eq!(s.doc().unwrap().subtree(b), label_ids, "layer ids are kept"); + assert_eq!(fill(s, remote_button), "00FF00"); + let style = s.doc().unwrap().styles[0].clone(); + assert!(matches!(&style.kind, StyleKind::Paint { paints } if paints[0].solid_color().unwrap().to_hex() == "00FF00")); + let doc = s.doc().unwrap(); + assert_eq!(doc.libraries.link(lid).map(|l| l.version), Some(2)); + // The Card's nested Button follows too. + let card_inst = doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { component, .. } if component == main)).map(|n| n.id).unwrap(); + let nested = node(s, card_inst).children[0]; + assert_eq!(fill(s, nested), "00FF00"); + assert_eq!(ex(s, "library.updates", json!({}))["count"], 0); + healthy(s); + // A structural change: the Button gains a layer (the Card's nested Button too). + ex(lib, "shape.ellipse", json!({"x": 80, "y": 10, "width": 8, "height": 8, "parent": k.button, "props": {"name": "Dot"}})); + ex(lib, "library.publish", json!({})); + ex(lib, "file.save", json!({})); + ex(s, "library.acceptUpdates", json!({"ids": [lid]})); + assert_eq!(node(s, b).children.len(), 2); + assert_eq!(node(s, nested).children.len(), 2); + assert_eq!(chars(s, b_label), "Buy", "overrides survive a structural update"); + assert_eq!(s.doc().unwrap().libraries.link(lid).map(|l| l.version), Some(3)); + healthy(s); + // Overrides still work after the update; resetting shows the library's text. + ex(s, "component.resetInstance", json!({"ids": [b]})); + assert_eq!(chars(s, node(s, b).children[0]), "Go"); + card_inst } #[test] @@ -211,63 +287,9 @@ fn add_place_and_update_keep_overrides() { NodeKind::Instance { component, .. } => component, _ => panic!("not an instance"), }; - let unchanged = s.doc().unwrap().clone(); - let e = s.execute("node.set", &json!({"id": remote_button, "props": {"fill": "#ff0000"}})).unwrap_err(); - assert!(e.to_string().contains("can't be edited"), "{e}"); - assert_eq!(s.doc().unwrap(), &unchanged, "a refused remote edit rolls back"); - for (cmd, args) in - [("node.reparent", json!({"ids": [remote_button], "parent": s.page().unwrap()})), ("edit.delete", json!({"ids": [remote_button]}))] - { - assert!(s.execute(cmd, &args).is_err(), "{cmd} must preserve read-only copies"); - assert_eq!(s.doc().unwrap(), &unchanged, "refused {cmd} rolls back"); - } - let style = s.doc().unwrap().styles[0].id.clone(); - assert!(s.execute("style.update", &json!({"style": style, "name": "Mine"})).is_err()); - assert!(s.execute("style.delete", &json!({"style": style})).is_err()); - let e = s.execute("component.goToMain", &json!({"id": b})).unwrap_err(); - assert!(e.to_string().contains("library"), "{e}"); - assert!(s.execute("select.set", &json!({"ids": [remote_button]})).is_err(), "library copies can't be selected"); - assert!(s.doc().unwrap().pages.contains(&s.page().unwrap())); - assert!(s.execute("component.pushChanges", &json!({"id": b})).is_err()); - assert_eq!(fill(&s, remote_button), "0000FF"); + assert_remote_read_only(&mut s, b, remote_button); - // The library changes the Button (fill via its style, and its label) and publishes again. - ex(&mut lib, "style.update", json!({"style": "Brand", "value": "#00ff00"})); - ex(&mut lib, "text.set", json!({"id": k.label, "characters": "Go"})); - ex(&mut lib, "library.publish", json!({})); - ex(&mut lib, "file.save", json!({})); - - let u = ex(&mut s, "library.updates", json!({})); - assert_eq!(u["count"], 3, "Button, Card and the style: {u}"); - let label_ids: Vec = s.doc().unwrap().subtree(b); - ex(&mut s, "library.acceptUpdates", json!({})); - assert_eq!(fill(&s, b), "00FF00", "the instance follows the updated main"); - assert_eq!(chars(&s, b_label), "Buy", "its override is kept"); - assert_eq!(s.doc().unwrap().subtree(b), label_ids, "layer ids are kept"); - assert_eq!(fill(&s, remote_button), "00FF00"); - let style = s.doc().unwrap().styles[0].clone(); - assert!(matches!(&style.kind, StyleKind::Paint { paints } if paints[0].solid_color().unwrap().to_hex() == "00FF00")); - let doc = s.doc().unwrap(); - assert_eq!(doc.libraries.link(&lid).map(|l| l.version), Some(2)); - // The Card's nested Button follows too. - let card_inst = doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { component, .. } if component == main)).map(|n| n.id).unwrap(); - let nested = node(&s, card_inst).children[0]; - assert_eq!(fill(&s, nested), "00FF00"); - assert_eq!(ex(&mut s, "library.updates", json!({}))["count"], 0); - healthy(&s); - // A structural change: the Button gains a layer (the Card's nested Button too). - ex(&mut lib, "shape.ellipse", json!({"x": 80, "y": 10, "width": 8, "height": 8, "parent": k.button, "props": {"name": "Dot"}})); - ex(&mut lib, "library.publish", json!({})); - ex(&mut lib, "file.save", json!({})); - ex(&mut s, "library.acceptUpdates", json!({"ids": [lid]})); - assert_eq!(node(&s, b).children.len(), 2); - assert_eq!(node(&s, nested).children.len(), 2); - assert_eq!(chars(&s, b_label), "Buy", "overrides survive a structural update"); - assert_eq!(s.doc().unwrap().libraries.link(&lid).map(|l| l.version), Some(3)); - healthy(&s); - // Overrides still work after the update; resetting shows the library's text. - ex(&mut s, "component.resetInstance", json!({"ids": [b]})); - assert_eq!(chars(&s, node(&s, b).children[0]), "Go"); + let card_inst = library_updates_flow(&mut s, &mut lib, &k, &lid, [b, b_label, remote_button, main]); // Saved and reopened, the document keeps its links, copies and records. let user_path = path_str(&d.join("app.figcraft")); diff --git a/crates/engine/src/assets/library_swap.rs b/crates/engine/src/assets/library_swap.rs index 1220d24c..1dfc1691 100644 --- a/crates/engine/src/assets/library_swap.rs +++ b/crates/engine/src/assets/library_swap.rs @@ -6,7 +6,7 @@ use std::collections::{BTreeMap, BTreeSet}; -use figcraft_doc::*; +use figcraft_doc::{Document, ImportedComponent, ImportedRecord, LibraryManifest, NodeId, NodeKind, PublishedLibrary, VariableValue}; use serde_json::Value; use crate::cmd::component::{main_of, swap_instance}; @@ -41,6 +41,35 @@ fn b_main(doc: &Document, a_main: NodeId, a_root: NodeId, b_root: NodeId) -> Opt } } +/// Local style id → the library's style id, matched by name and type (the rest go to `out.unmatched`). +fn match_styles(doc: &Document, recs: &[ImportedRecord], m: &LibraryManifest, out: &mut Swapped) -> BTreeMap { + let mut keys = BTreeMap::new(); + for r in recs { + let Some(st) = doc.style(&r.local) else { continue }; + let kind = st.kind.type_name(); + match m.styles.iter().find(|x| x.published && x.name == st.name && x.kind == kind) { + Some(x) => { + keys.insert(r.local.clone(), x.id.clone()); + } + None => out.unmatched.push(format!("style {}", st.name)), + } + } + keys +} + +/// (local component root, the library's component id) by name; the rest go to `out.unmatched`. +fn match_components(doc: &Document, records: &[ImportedComponent], m: &LibraryManifest, out: &mut Swapped) -> Vec<(NodeId, NodeId)> { + let mut pairs = Vec::new(); + for r in records { + let name = doc.node(r.root).map(|n| n.name.clone()).unwrap_or_default(); + match m.components.iter().find(|a| a.published && a.name == name) { + Some(a) => pairs.push((r.root, a.id)), + None => out.unmatched.push(format!("component {name}")), + } + } + pairs +} + /// Swap the copies of library `from` in `doc` to library `to` by names (see the module docs). pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Result { let m = &to.manifest; @@ -64,14 +93,7 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re }) .collect(); let records: Vec = doc.libraries.components.iter().filter(|r| r.library == from && shown.contains(&r.root)).cloned().collect(); - let mut pairs = Vec::new(); - for r in &records { - let name = doc.node(r.root).map(|n| n.name.clone()).unwrap_or_default(); - match m.components.iter().find(|a| a.published && a.name == name) { - Some(a) => pairs.push((r.root, a.id)), - None => out.unmatched.push(format!("component {name}")), - } - } + let pairs = match_components(doc, &records, m, &mut out); let keys: Vec = pairs.iter().map(|(_, k)| *k).collect(); let map = if keys.is_empty() { BTreeMap::new() } else { import(doc, to, &keys, false)? }; // A's mains (each variant) and roots → B's. @@ -121,17 +143,7 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re // Styles the document's own layers link to: by name and type. let linked: BTreeSet = doc.nodes().filter(|n| outside(doc, n.id)).flat_map(|n| style_links(n).cloned()).collect(); let style_recs: Vec = doc.libraries.styles.iter().filter(|r| r.library == from && linked.contains(&r.local)).cloned().collect(); - let mut style_keys: BTreeMap = BTreeMap::new(); - for r in &style_recs { - let Some(st) = doc.style(&r.local) else { continue }; - let kind = st.kind.type_name(); - match m.styles.iter().find(|x| x.published && x.name == st.name && x.kind == kind) { - Some(x) => { - style_keys.insert(r.local.clone(), x.id.clone()); - } - None => out.unmatched.push(format!("style {}", st.name)), - } - } + let style_keys = match_styles(doc, &style_recs, m, &mut out); let imported = import_styles(doc, to, &style_keys.values().cloned().collect(), false); let styles: BTreeMap = style_keys.iter().filter_map(|(a, k)| imported.get(k).map(|b| (a.clone(), b.clone()))).collect(); out.styles = styles.len(); diff --git a/crates/engine/src/assets/playground.rs b/crates/engine/src/assets/playground.rs index 90a627b0..5c05c3b9 100644 --- a/crates/engine/src/assets/playground.rs +++ b/crates/engine/src/assets/playground.rs @@ -8,7 +8,7 @@ use serde_json::{Map, Value, json}; use crate::cmd::CommandSpec; use crate::codegen::CodeLang; -use crate::params::*; +use crate::params::{get, id_of, num, string, targets}; use crate::{EngineError, Result, Session, bad, spec}; /// The component a playground for `target` shows: an instance's component, a component or a diff --git a/crates/engine/src/assets/style_vars.rs b/crates/engine/src/assets/style_vars.rs index b2e5c182..7fb30fd0 100644 --- a/crates/engine/src/assets/style_vars.rs +++ b/crates/engine/src/assets/style_vars.rs @@ -8,7 +8,10 @@ use std::borrow::Cow; use std::collections::{BTreeMap, BTreeSet}; use figcraft_color::PaintKind; -use figcraft_doc::*; +use figcraft_doc::{ + CollectionId, Document, EffectKind, LetterSpacing, LineHeight, ModeId, Node, NodeId, NodeKind, Style, StyleId, StyleKind, TextStyle, VariableId, + VariableType, VariableValue, Variables, +}; use crate::cmd::lists::{BoundList, list_binding, stop_binding}; diff --git a/crates/engine/src/automation/inspect.rs b/crates/engine/src/automation/inspect.rs index 7dccd706..886d2d85 100644 --- a/crates/engine/src/automation/inspect.rs +++ b/crates/engine/src/automation/inspect.rs @@ -1,6 +1,6 @@ //! Document inspection as JSON, so agents can verify their work without screenshots. -use figcraft_doc::*; +use figcraft_doc::{Document, Node, NodeId, NodeKind, StrokeJoin, TextData, TextDecoration}; use serde_json::{Value, json}; use crate::Session; @@ -8,7 +8,6 @@ use crate::Session; /// A node and (to `depth`) its children, with the properties a designer would check. pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { let Some(n) = doc.node(id) else { return Value::Null }; - let c = doc.frame_relative(id).as_coeffs(); let mut v = json!({ "id": n.id, "name": n.name, @@ -16,226 +15,259 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { }); let Some(o) = v.as_object_mut() else { return v }; if !n.is_page() { - o.insert("x".into(), json!(round(c[4]))); - o.insert("y".into(), json!(round(c[5]))); - o.insert("width".into(), json!(round(n.width))); - o.insert("height".into(), json!(round(n.height))); - let rot = figcraft_geom::rotation_degrees(n.transform); - if rot.abs() > 1e-6 { - o.insert("rotation".into(), json!(round(rot))); - } - if let Some(b) = doc.world_bounds(id) { - o.insert("absoluteBounds".into(), json!({"x": round(b.x0), "y": round(b.y0), "width": round(b.width()), "height": round(b.height())})); - } - if !n.visible { - o.insert("visible".into(), json!(false)); - } - if n.locked { - o.insert("locked".into(), json!(true)); - } - if n.opacity < 1.0 { - o.insert("opacity".into(), json!(n.opacity)); - } - if !matches!(n.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) { - o.insert("blend".into(), json!(n.blend)); - } - if !n.fills.is_empty() { - o.insert("fills".into(), paints(&n.fills)); - } - if !n.strokes.is_empty() { - o.insert("strokes".into(), paints(&n.strokes)); - o.insert("strokeWeight".into(), json!(n.stroke.weight)); - o.insert("strokeAlign".into(), json!(n.stroke.align)); - if n.stroke.sides.is_some() { - o.insert("strokeSides".into(), json!(n.stroke.sides)); - } - if !n.stroke.dashes.is_empty() { - o.insert("dashes".into(), json!(n.stroke.dashes)); - } - if n.stroke.join != StrokeJoin::Miter { - o.insert("strokeJoin".into(), json!(n.stroke.join)); - } else if n.stroke.miter_limit != 4.0 { - o.insert("miterAngle".into(), json!((n.stroke.miter_angle() * 100.0).round() / 100.0)); - } - if let Some(p) = n.stroke.profile() { - let points = crate::cmd::props::width_points_json(Some(p)); - o.insert("widthProfile".into(), json!({"preset": p.preset().map(|x| x.id()), "points": points})); - } - if let Some(b) = &n.stroke.brush { - o.insert("brush".into(), camel(serde_json::to_value(b).unwrap_or(Value::Null))); - } - } - if !n.effects.is_empty() { - o.insert("effects".into(), camel(serde_json::to_value(&n.effects).unwrap_or(Value::Null))); - } - if n.corners.radii.iter().any(|r| *r > 0.0) { - o.insert( - "cornerRadius".into(), - match n.corners.uniform() { - Some(r) => json!(r), - None => json!(n.corners.radii), - }, - ); - if n.corners.smoothing > 0.0 { - o.insert("cornerSmoothing".into(), json!(n.corners.smoothing)); - } - } - if let Some(l) = &n.layout { - o.insert("layout".into(), camel(serde_json::to_value(l).unwrap_or(Value::Null))); - } - let parent_al = n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()); - if n.layout.is_some() || parent_al || matches!(n.kind, NodeKind::Text(_)) { - o.insert("sizing".into(), json!({"horizontal": n.child_layout.sizing_h, "vertical": n.child_layout.sizing_v})); - } - let cl = &n.child_layout; - if cl.absolute { - o.insert("absolute".into(), json!(true)); - } - for (k, v) in [("minWidth", cl.min_w), ("maxWidth", cl.max_w), ("minHeight", cl.min_h), ("maxHeight", cl.max_h)] { - if let Some(v) = v { - o.insert(k.into(), json!(v)); - } - } - if let Some(cell) = parent_al - .then(|| n.parent.and_then(|p| figcraft_layout::grid_geometry(doc, p))) - .flatten() - .and_then(|g| g.cells.into_iter().find(|c| c.id == n.id)) - { - o.insert( - "gridCell".into(), - json!({"column": cell.column, "row": cell.row, "columnSpan": cell.column_span, "rowSpan": cell.row_span, - "anchored": cl.grid_column.is_some() || cl.grid_row.is_some(), "alignH": cl.grid_align_h, "alignV": cl.grid_align_v}), - ); - } - if (!parent_al || cl.absolute) && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.kind.is_frame_like()) { - o.insert("constraints".into(), serde_json::to_value(n.constraints).unwrap_or(Value::Null)); - } - if n.kind.is_frame_like() { - o.insert("clip".into(), json!(n.clip)); - } - if !n.dev_status.is_none() { - o.insert("devStatus".into(), json!(n.dev_status.id())); - } - if n.dev_changed { - o.insert("devChanged".into(), json!(true)); - } - if !n.dev_note.is_empty() { - o.insert("devNote".into(), json!(n.dev_note)); - } - if let NodeKind::Section { contents_hidden } = n.kind { - o.insert("sectionContentsHidden".into(), json!(contents_hidden)); - } - if n.is_mask { - o.insert("isMask".into(), json!(true)); - o.insert("maskType".into(), json!(n.mask_type.id())); - } - if !n.grids.is_empty() { - o.insert("grids".into(), camel(serde_json::to_value(&n.grids).unwrap_or(Value::Null))); - } - if !n.exports.is_empty() { - o.insert("exports".into(), serde_json::to_value(&n.exports).unwrap_or(Value::Null)); - } - if let Some(b) = &n.board { - o.insert("board".into(), serde_json::to_value(b).unwrap_or(Value::Null)); - } - if !n.annotations.is_empty() { - o.insert("annotations".into(), serde_json::to_value(&n.annotations).unwrap_or(Value::Null)); - } - if !n.code_links.is_empty() { - o.insert("codeLinks".into(), camel(serde_json::to_value(&n.code_links).unwrap_or(Value::Null))); - } - if !n.measurements.is_empty() { - o.insert("measurements".into(), serde_json::to_value(&n.measurements).unwrap_or(Value::Null)); - } - let dev = crate::dev_resources::list_json(doc, n.id); - if dev.as_array().is_some_and(|a| !a.is_empty()) { - o.insert("devResources".into(), dev); - } - if !n.interactions.is_empty() { - o.insert("interactions".into(), serde_json::to_value(&n.interactions).unwrap_or(Value::Null)); - } - if !n.styles.is_empty() { - o.insert("styles".into(), serde_json::to_value(&n.styles).unwrap_or(Value::Null)); - } - if !n.bound.is_empty() { - o.insert("boundVariables".into(), json!(n.bound)); - } - if !n.overrides.is_empty() { - o.insert("overrides".into(), json!(n.overrides)); - } - if let Some(slot) = crate::components::slot_name(doc, n.id) { - o.insert("slot".into(), json!(slot)); - } - // An operand combining by an operation of its own. - if let Some(op) = n.bool_op.filter(|_| crate::booleans::group_of(doc, n.id).is_some()) { - o.insert("operandOperation".into(), json!(op)); - } + base_props(doc, n, id, o); + paint_props(n, o); + layout_props(doc, n, o); + dev_props(doc, n, o); } // Layer data written with `node.set {props: {pluginData}}` (pages too). if !n.plugin_data.is_empty() { o.insert("pluginData".into(), json!(n.plugin_data)); } match &n.kind { - NodeKind::Text(t) => { - o.insert("characters".into(), json!(t.characters)); - o.insert("fontFamily".into(), json!(t.style.font_family)); - o.insert("fontStyle".into(), json!(t.style.font_style)); - o.insert("fontSize".into(), json!(t.style.font_size)); - if !t.style.variations.is_empty() { - o.insert("fontVariations".into(), json!(t.style.variations)); - } - o.insert("lineHeight".into(), json!(t.style.line_height.label())); - o.insert("letterSpacing".into(), json!(t.style.letter_spacing.label())); - o.insert("textAlign".into(), json!(t.align_h)); - o.insert("textAutoResize".into(), json!(t.auto_resize)); - if t.max_lines > 0 { - o.insert("maxLines".into(), json!(t.max_lines)); - } - if let Some(tp) = &t.path { - o.insert("textPath".into(), json!(tp)); - } - if t.style.decoration != TextDecoration::None { - o.insert("textDecoration".into(), json!(t.style.decoration)); - } - if let Some(l) = t.style.decoration_look.as_deref() { - o.insert("textDecorationStyle".into(), json!(l.style)); - o.insert("textDecorationThickness".into(), json!(l.thickness.label())); - o.insert("textDecorationOffset".into(), json!(l.offset.label())); - o.insert("textDecorationSkipInk".into(), json!(l.skip_ink)); - o.insert("textDecorationColor".into(), l.color.map_or(json!("auto"), |c| json!(format!("#{}", c.to_hex())))); - } - // Lists: one style for the layer, or each paragraph's type, nesting level and start. - if t.paragraphs.is_empty() { - if t.list != figcraft_doc::ListStyle::None { - o.insert("listStyle".into(), json!(t.list)); - } - } else { - o.insert("lineTypes".into(), t.paragraphs.iter().map(|p| json!(p.list)).collect()); - o.insert("lineIndentations".into(), t.paragraphs.iter().map(|p| json!(p.indent)).collect()); - if t.paragraphs.iter().any(|p| p.start_offset > 0) { - o.insert("listStartOffsets".into(), t.paragraphs.iter().map(|p| json!(p.start_offset)).collect()); - } - } - if t.list_spacing != 0.0 { - o.insert("listSpacing".into(), json!(t.list_spacing)); - } - for (k, on) in [("hangingPunctuation", t.hanging_punctuation), ("hangingList", t.hanging_list)] { - if on { - o.insert(k.into(), json!(true)); - } - } - if t.leading_trim != figcraft_doc::LeadingTrim::None { - o.insert("leadingTrim".into(), json!(t.leading_trim)); - } - if !t.runs.is_empty() { - o.insert("styledRuns".into(), serde_json::to_value(&t.runs).unwrap_or(Value::Null)); - } - // Chars linked to text styles: [start, end, style id] (the layer as a whole: `styles.text`). - let linked: Vec = t.spans().into_iter().filter_map(|(a, b, s)| s.style_id.as_ref().map(|id| json!([a, b, id]))).collect(); - if !linked.is_empty() { - o.insert("textStyleRanges".into(), Value::Array(linked)); - } + NodeKind::Text(t) => text_props(t, o), + _ => kind_props(doc, n, o), + } + if !n.children.is_empty() { + if depth > 0 { + o.insert("children".into(), Value::Array(n.children.iter().map(|c| node_json(doc, *c, depth - 1)).collect())); + } else { + o.insert("childCount".into(), json!(n.children.len())); } + } + v +} + +fn base_props(doc: &Document, n: &Node, id: NodeId, o: &mut serde_json::Map) { + let c = doc.frame_relative(id).as_coeffs(); + o.insert("x".into(), json!(round(c[4]))); + o.insert("y".into(), json!(round(c[5]))); + o.insert("width".into(), json!(round(n.width))); + o.insert("height".into(), json!(round(n.height))); + let rot = figcraft_geom::rotation_degrees(n.transform); + if rot.abs() > 1e-6 { + o.insert("rotation".into(), json!(round(rot))); + } + if let Some(b) = doc.world_bounds(id) { + o.insert("absoluteBounds".into(), json!({"x": round(b.x0), "y": round(b.y0), "width": round(b.width()), "height": round(b.height())})); + } + if !n.visible { + o.insert("visible".into(), json!(false)); + } + if n.locked { + o.insert("locked".into(), json!(true)); + } + if n.opacity < 1.0 { + o.insert("opacity".into(), json!(n.opacity)); + } + if !matches!(n.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) { + o.insert("blend".into(), json!(n.blend)); + } +} + +fn paint_props(n: &Node, o: &mut serde_json::Map) { + if !n.fills.is_empty() { + o.insert("fills".into(), paints(&n.fills)); + } + if !n.strokes.is_empty() { + o.insert("strokes".into(), paints(&n.strokes)); + o.insert("strokeWeight".into(), json!(n.stroke.weight)); + o.insert("strokeAlign".into(), json!(n.stroke.align)); + if n.stroke.sides.is_some() { + o.insert("strokeSides".into(), json!(n.stroke.sides)); + } + if !n.stroke.dashes.is_empty() { + o.insert("dashes".into(), json!(n.stroke.dashes)); + } + if n.stroke.join != StrokeJoin::Miter { + o.insert("strokeJoin".into(), json!(n.stroke.join)); + } else if n.stroke.miter_limit != 4.0 { + o.insert("miterAngle".into(), json!((n.stroke.miter_angle() * 100.0).round() / 100.0)); + } + if let Some(p) = n.stroke.profile() { + let points = crate::cmd::props::width_points_json(Some(p)); + o.insert("widthProfile".into(), json!({"preset": p.preset().map(|x| x.id()), "points": points})); + } + if let Some(b) = &n.stroke.brush { + o.insert("brush".into(), camel(serde_json::to_value(b).unwrap_or(Value::Null))); + } + } + if !n.effects.is_empty() { + o.insert("effects".into(), camel(serde_json::to_value(&n.effects).unwrap_or(Value::Null))); + } + if n.corners.radii.iter().any(|r| *r > 0.0) { + o.insert( + "cornerRadius".into(), + match n.corners.uniform() { + Some(r) => json!(r), + None => json!(n.corners.radii), + }, + ); + if n.corners.smoothing > 0.0 { + o.insert("cornerSmoothing".into(), json!(n.corners.smoothing)); + } + } +} + +fn layout_props(doc: &Document, n: &Node, o: &mut serde_json::Map) { + if let Some(l) = &n.layout { + o.insert("layout".into(), camel(serde_json::to_value(l).unwrap_or(Value::Null))); + } + let parent_al = n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()); + if n.layout.is_some() || parent_al || matches!(n.kind, NodeKind::Text(_)) { + o.insert("sizing".into(), json!({"horizontal": n.child_layout.sizing_h, "vertical": n.child_layout.sizing_v})); + } + let cl = &n.child_layout; + if cl.absolute { + o.insert("absolute".into(), json!(true)); + } + for (k, v) in [("minWidth", cl.min_w), ("maxWidth", cl.max_w), ("minHeight", cl.min_h), ("maxHeight", cl.max_h)] { + if let Some(v) = v { + o.insert(k.into(), json!(v)); + } + } + if let Some(cell) = parent_al + .then(|| n.parent.and_then(|p| figcraft_layout::grid_geometry(doc, p))) + .flatten() + .and_then(|g| g.cells.into_iter().find(|c| c.id == n.id)) + { + o.insert( + "gridCell".into(), + json!({"column": cell.column, "row": cell.row, "columnSpan": cell.column_span, "rowSpan": cell.row_span, + "anchored": cl.grid_column.is_some() || cl.grid_row.is_some(), "alignH": cl.grid_align_h, "alignV": cl.grid_align_v}), + ); + } + if (!parent_al || cl.absolute) && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.kind.is_frame_like()) { + o.insert("constraints".into(), serde_json::to_value(n.constraints).unwrap_or(Value::Null)); + } + if n.kind.is_frame_like() { + o.insert("clip".into(), json!(n.clip)); + } +} + +fn dev_props(doc: &Document, n: &Node, o: &mut serde_json::Map) { + if !n.dev_status.is_none() { + o.insert("devStatus".into(), json!(n.dev_status.id())); + } + if n.dev_changed { + o.insert("devChanged".into(), json!(true)); + } + if !n.dev_note.is_empty() { + o.insert("devNote".into(), json!(n.dev_note)); + } + if let NodeKind::Section { contents_hidden } = n.kind { + o.insert("sectionContentsHidden".into(), json!(contents_hidden)); + } + if n.is_mask { + o.insert("isMask".into(), json!(true)); + o.insert("maskType".into(), json!(n.mask_type.id())); + } + if !n.grids.is_empty() { + o.insert("grids".into(), camel(serde_json::to_value(&n.grids).unwrap_or(Value::Null))); + } + if !n.exports.is_empty() { + o.insert("exports".into(), serde_json::to_value(&n.exports).unwrap_or(Value::Null)); + } + if let Some(b) = &n.board { + o.insert("board".into(), serde_json::to_value(b).unwrap_or(Value::Null)); + } + if !n.annotations.is_empty() { + o.insert("annotations".into(), serde_json::to_value(&n.annotations).unwrap_or(Value::Null)); + } + if !n.code_links.is_empty() { + o.insert("codeLinks".into(), camel(serde_json::to_value(&n.code_links).unwrap_or(Value::Null))); + } + if !n.measurements.is_empty() { + o.insert("measurements".into(), serde_json::to_value(&n.measurements).unwrap_or(Value::Null)); + } + let dev = crate::dev_resources::list_json(doc, n.id); + if dev.as_array().is_some_and(|a| !a.is_empty()) { + o.insert("devResources".into(), dev); + } + if !n.interactions.is_empty() { + o.insert("interactions".into(), serde_json::to_value(&n.interactions).unwrap_or(Value::Null)); + } + if !n.styles.is_empty() { + o.insert("styles".into(), serde_json::to_value(&n.styles).unwrap_or(Value::Null)); + } + if !n.bound.is_empty() { + o.insert("boundVariables".into(), json!(n.bound)); + } + if !n.overrides.is_empty() { + o.insert("overrides".into(), json!(n.overrides)); + } + if let Some(slot) = crate::components::slot_name(doc, n.id) { + o.insert("slot".into(), json!(slot)); + } + // An operand combining by an operation of its own. + if let Some(op) = n.bool_op.filter(|_| crate::booleans::group_of(doc, n.id).is_some()) { + o.insert("operandOperation".into(), json!(op)); + } +} + +fn text_props(t: &TextData, o: &mut serde_json::Map) { + o.insert("characters".into(), json!(t.characters)); + o.insert("fontFamily".into(), json!(t.style.font_family)); + o.insert("fontStyle".into(), json!(t.style.font_style)); + o.insert("fontSize".into(), json!(t.style.font_size)); + if !t.style.variations.is_empty() { + o.insert("fontVariations".into(), json!(t.style.variations)); + } + o.insert("lineHeight".into(), json!(t.style.line_height.label())); + o.insert("letterSpacing".into(), json!(t.style.letter_spacing.label())); + o.insert("textAlign".into(), json!(t.align_h)); + o.insert("textAutoResize".into(), json!(t.auto_resize)); + if t.max_lines > 0 { + o.insert("maxLines".into(), json!(t.max_lines)); + } + if let Some(tp) = &t.path { + o.insert("textPath".into(), json!(tp)); + } + if t.style.decoration != TextDecoration::None { + o.insert("textDecoration".into(), json!(t.style.decoration)); + } + if let Some(l) = t.style.decoration_look.as_deref() { + o.insert("textDecorationStyle".into(), json!(l.style)); + o.insert("textDecorationThickness".into(), json!(l.thickness.label())); + o.insert("textDecorationOffset".into(), json!(l.offset.label())); + o.insert("textDecorationSkipInk".into(), json!(l.skip_ink)); + o.insert("textDecorationColor".into(), l.color.map_or(json!("auto"), |c| json!(format!("#{}", c.to_hex())))); + } + // Lists: one style for the layer, or each paragraph's type, nesting level and start. + if t.paragraphs.is_empty() { + if t.list != figcraft_doc::ListStyle::None { + o.insert("listStyle".into(), json!(t.list)); + } + } else { + o.insert("lineTypes".into(), t.paragraphs.iter().map(|p| json!(p.list)).collect()); + o.insert("lineIndentations".into(), t.paragraphs.iter().map(|p| json!(p.indent)).collect()); + if t.paragraphs.iter().any(|p| p.start_offset > 0) { + o.insert("listStartOffsets".into(), t.paragraphs.iter().map(|p| json!(p.start_offset)).collect()); + } + } + if t.list_spacing != 0.0 { + o.insert("listSpacing".into(), json!(t.list_spacing)); + } + for (k, on) in [("hangingPunctuation", t.hanging_punctuation), ("hangingList", t.hanging_list)] { + if on { + o.insert(k.into(), json!(true)); + } + } + if t.leading_trim != figcraft_doc::LeadingTrim::None { + o.insert("leadingTrim".into(), json!(t.leading_trim)); + } + if !t.runs.is_empty() { + o.insert("styledRuns".into(), serde_json::to_value(&t.runs).unwrap_or(Value::Null)); + } + // Chars linked to text styles: [start, end, style id] (the layer as a whole: `styles.text`). + let linked: Vec = t.spans().into_iter().filter_map(|(a, b, s)| s.style_id.as_ref().map(|id| json!([a, b, id]))).collect(); + if !linked.is_empty() { + o.insert("textStyleRanges".into(), Value::Array(linked)); + } +} + +fn kind_props(doc: &Document, n: &Node, o: &mut serde_json::Map) { + match &n.kind { NodeKind::Ellipse { start, sweep, inner } if (*sweep).abs() < std::f64::consts::TAU - 1e-9 || *inner > 0.0 => { o.insert("arc".into(), json!({"start": start.to_degrees(), "sweep": sweep.to_degrees(), "inner": inner})); } @@ -276,14 +308,6 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { } _ => {} } - if !n.children.is_empty() { - if depth > 0 { - o.insert("children".into(), Value::Array(n.children.iter().map(|c| node_json(doc, *c, depth - 1)).collect())); - } else { - o.insert("childCount".into(), json!(n.children.len())); - } - } - v } fn round(v: f64) -> f64 { diff --git a/crates/engine/src/content/synthetic_ds.rs b/crates/engine/src/content/synthetic_ds.rs index c3beacd5..bb62871c 100644 --- a/crates/engine/src/content/synthetic_ds.rs +++ b/crates/engine/src/content/synthetic_ds.rs @@ -11,7 +11,10 @@ use std::collections::BTreeMap; use figcraft_color::{Color, Paint}; -use figcraft_doc::*; +use figcraft_doc::{ + AutoLayout, Collection, CollectionId, CrossAlign, DocError, DocResult, Document, LayoutMode, Mode, ModeId, Node, NodeId, NodeKind, PropDef, + Sizing, Style, StyleKind, TextAutoResize, TextData, TextStyle, Variable, VariableType, VariableValue, +}; use serde_json::Value; use crate::components::{create_instance, sync_instance}; diff --git a/crates/engine/src/content/templates/dashboard.rs b/crates/engine/src/content/templates/dashboard.rs index 23ed506a..099b37bf 100644 --- a/crates/engine/src/content/templates/dashboard.rs +++ b/crates/engine/src/content/templates/dashboard.rs @@ -6,7 +6,7 @@ use figcraft_doc::NodeId; use serde_json::{Value, json}; use super::glyphs::{self, Glyph}; -use super::kit::*; +use super::kit::{ACCENT, BORDER, BOTTOM_EDGE, CARD, H, ICON, INK, Kit, MUTED, ON_BRAND, PAGE, PRIMARY, RIGHT_EDGE, SOFT, V, merged}; use crate::Result; const NAV: [(&str, Glyph); 6] = [ diff --git a/crates/engine/src/content/templates/design_system.rs b/crates/engine/src/content/templates/design_system.rs index 6f47712c..f56f5356 100644 --- a/crates/engine/src/content/templates/design_system.rs +++ b/crates/engine/src/content/templates/design_system.rs @@ -6,7 +6,7 @@ use figcraft_doc::NodeId; use serde_json::{Value, json}; -use super::kit::*; +use super::kit::{H, Kit, TYPE, V, merged}; use crate::Result; const THEME: &str = "Theme"; diff --git a/crates/engine/src/content/templates/icon_set.rs b/crates/engine/src/content/templates/icon_set.rs index 2d6671d4..caf390d5 100644 --- a/crates/engine/src/content/templates/icon_set.rs +++ b/crates/engine/src/content/templates/icon_set.rs @@ -6,7 +6,7 @@ use figcraft_doc::NodeId; use serde_json::json; use super::glyphs; -use super::kit::*; +use super::kit::{BORDER, CARD, H, ICON, INK, Kit, MUTED, PAGE, V, merged}; use crate::Result; const STROKE: &str = "Icon/Stroke"; diff --git a/crates/engine/src/content/templates/landing.rs b/crates/engine/src/content/templates/landing.rs index 7edaef03..a3b7c85f 100644 --- a/crates/engine/src/content/templates/landing.rs +++ b/crates/engine/src/content/templates/landing.rs @@ -6,7 +6,7 @@ use figcraft_doc::NodeId; use serde_json::{Value, json}; use super::glyphs::{self, Glyph}; -use super::kit::*; +use super::kit::{BORDER, BOTTOM_EDGE, CARD, H, ICON, INK, Kit, MUTED, ON_BRAND, PAGE, PRIMARY, SOFT, SUNKEN, TOP_EDGE, V, merged, shadow}; use crate::Result; /// The widest the content gets. diff --git a/crates/engine/src/content/templates/mobile.rs b/crates/engine/src/content/templates/mobile.rs index 863d3725..77e6ba6d 100644 --- a/crates/engine/src/content/templates/mobile.rs +++ b/crates/engine/src/content/templates/mobile.rs @@ -6,7 +6,7 @@ use figcraft_doc::NodeId; use serde_json::json; use super::glyphs::{self, Glyph}; -use super::kit::*; +use super::kit::{BORDER, CARD, H, ICON, INK, Kit, MUTED, ON_BRAND, PAGE, PRIMARY, SUNKEN, TOP_EDGE, V, shadow}; use crate::Result; const W: f64 = 390.0; diff --git a/crates/engine/src/content/templates/slides.rs b/crates/engine/src/content/templates/slides.rs index 322b3407..54fd376c 100644 --- a/crates/engine/src/content/templates/slides.rs +++ b/crates/engine/src/content/templates/slides.rs @@ -6,7 +6,7 @@ use figcraft_doc::NodeId; use serde_json::{Value, json}; use super::glyphs; -use super::kit::*; +use super::kit::{ACCENT, BORDER, BOTTOM_EDGE, CARD, DEEP, H, INK, Kit, MUTED, ON_BRAND, PAGE, PRIMARY, TOP_EDGE, V, merged}; use crate::Result; const W: f64 = 1920.0; diff --git a/crates/engine/src/content/templates/social.rs b/crates/engine/src/content/templates/social.rs index 61525260..73b82735 100644 --- a/crates/engine/src/content/templates/social.rs +++ b/crates/engine/src/content/templates/social.rs @@ -6,7 +6,7 @@ use figcraft_doc::NodeId; use serde_json::json; use super::glyphs; -use super::kit::*; +use super::kit::{ACCENT, DEEP, H, INK, Kit, ON_BRAND, V}; use crate::Result; /// (name, width, height, x, y) on the canvas. diff --git a/crates/engine/src/content/templates/wireframe.rs b/crates/engine/src/content/templates/wireframe.rs index 68426c07..1eb13893 100644 --- a/crates/engine/src/content/templates/wireframe.rs +++ b/crates/engine/src/content/templates/wireframe.rs @@ -6,7 +6,7 @@ use figcraft_doc::NodeId; use serde_json::{Value, json}; use super::glyphs; -use super::kit::*; +use super::kit::{BOTTOM_EDGE, H, Kit, TOP_EDGE, TYPE, V, merged}; use crate::Result; const WIRE_INK: &str = "Wire/Ink"; diff --git a/crates/engine/src/dev/annotations.rs b/crates/engine/src/dev/annotations.rs index e6937d0e..0b27d03e 100644 --- a/crates/engine/src/dev/annotations.rs +++ b/crates/engine/src/dev/annotations.rs @@ -7,12 +7,12 @@ use std::sync::LazyLock; -use figcraft_doc::*; +use figcraft_doc::{Annotation, AnnotationCategory, Document, NodeId}; use serde_json::{Value, json}; use crate::cmd::CommandSpec; use crate::devinspect::{Prop, Section, properties}; -use crate::params::*; +use crate::params::{color_of, get, id, req_index, req_string, string, targets}; use crate::{bad, spec}; /// Most annotations on one layer. diff --git a/crates/engine/src/dev/code_connect.rs b/crates/engine/src/dev/code_connect.rs index 271193e0..8524c25a 100644 --- a/crates/engine/src/dev/code_connect.rs +++ b/crates/engine/src/dev/code_connect.rs @@ -10,14 +10,14 @@ //! keeps JSX's braces: `size={size}` → `size="Large"`, `{label}` → `{"Buy"}`), and //! `{prop ? "a" : "b"}` picks one of two strings. Any other `{…}` stays as written. -use figcraft_doc::*; +use figcraft_doc::{CodeLink, Document, NodeId, NodeKind}; use serde_json::{Value, json}; use crate::cmd::CommandSpec; use crate::codegen::{CodeLang, Ctx}; use crate::component_codegen::{Component, Param, ParamKind, describe, instance_values, js_string, main_of, value_expr}; use crate::components::{slots_of, variant_set}; -use crate::params::*; +use crate::params::{get, id_of, targets}; use crate::{EngineError, Result, Session, bad, spec}; /// Longest template (chars). diff --git a/crates/engine/src/dev/codegen.rs b/crates/engine/src/dev/codegen.rs index 093a8fd9..37dae893 100644 --- a/crates/engine/src/dev/codegen.rs +++ b/crates/engine/src/dev/codegen.rs @@ -6,12 +6,15 @@ //! output is a starting point, not a pixel-exact translation. use figcraft_color::{Color, PaintKind}; -use figcraft_doc::*; +use figcraft_doc::{ + ContentAlign, CrossAlign, Document, EffectKind, LayoutMode, LetterSpacing, LineHeight, MainAlign, Node, NodeId, NodeKind, Shadow, Sizing, + TextAlignH, TextCase, TextDecoration, Variable, +}; use serde_json::json; use crate::cmd::CommandSpec; use crate::component_codegen::{Component, Param}; -use crate::params::*; +use crate::params::{get, id_of, num_or, string, targets}; use crate::{bad, spec}; mod arkui; diff --git a/crates/engine/src/dev/codegen/arkui.rs b/crates/engine/src/dev/codegen/arkui.rs index 1d9816f6..6ce54ca9 100644 --- a/crates/engine/src/dev/codegen/arkui.rs +++ b/crates/engine/src/dev/codegen/arkui.rs @@ -1,5 +1,8 @@ use super::swift::one_line; -use super::*; +use super::{ + CodeLang, Color, CrossAlign, Ctx, LayoutMode, LetterSpacing, LineHeight, MainAlign, Node, NodeId, NodeKind, TextAlignH, TextCase, TextDecoration, + drop_shadow, first_fill, indent, literal, num, radius, stroke_color, +}; // ------------------------------------------------------------------ ArkUI (ArkTS) @@ -37,8 +40,121 @@ pub(crate) fn arkui(ctx: &Ctx, id: NodeId, depth: usize, out: &mut String, extra arkui_layer(ctx, n, depth, out, extra); } -fn arkui_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[String]) { +fn text_layer(ctx: &Ctx, n: &Node, t: &figcraft_doc::TextData, pad: &str, out: &mut String, mods: &mut Vec) { + let text = ctx.param(n, "characters").map_or_else(|| literal(&t.characters, false), |p| format!("this.{}", p.ident)); + out.push_str(&format!("{pad}Text({text})\n")); + let s = &t.style; + mods.push(format!(".fontSize({})", num(s.font_size))); + mods.push(format!(".fontWeight({})", s.weight().clamp(100, 900))); + mods.push(format!(".fontFamily({})", literal(&s.font_family, false))); + if s.italic() { + mods.push(".fontStyle(FontStyle.Italic)".into()); + } + if let Some(c) = first_fill(n) { + mods.push(format!(".fontColor({})", ark_color(c))); + } + match s.line_height { + LineHeight::Pixels(v) => mods.push(format!(".lineHeight({})", num(v))), + LineHeight::Percent(p) => mods.push(format!(".lineHeight('{}%')", num(p))), + LineHeight::Auto => {} + } + match s.letter_spacing { + LetterSpacing::Pixels(v) if v != 0.0 => mods.push(format!(".letterSpacing({})", num(v))), + LetterSpacing::Percent(p) if p != 0.0 => mods.push(format!(".letterSpacing('{}%')", num(p))), + _ => {} + } + match t.align_h { + TextAlignH::Left => {} + TextAlignH::Center => mods.push(".textAlign(TextAlign.Center)".into()), + TextAlignH::Right => mods.push(".textAlign(TextAlign.End)".into()), + TextAlignH::Justified => mods.push(".textAlign(TextAlign.JUSTIFY)".into()), + } + // Aligning needs a box to align in: the text layer's own width. + if !matches!(t.align_h, TextAlignH::Left) { + mods.push(format!(".width({})", num(n.width))); + } + match s.case { + TextCase::Upper => mods.push(".textCase(TextCase.UpperCase)".into()), + TextCase::Lower => mods.push(".textCase(TextCase.LowerCase)".into()), + _ => {} + } + match s.decoration { + TextDecoration::Underline => mods.push(".decoration({ type: TextDecorationType.Underline })".into()), + TextDecoration::Strikethrough => mods.push(".decoration({ type: TextDecorationType.LineThrough })".into()), + TextDecoration::None => {} + } +} + +fn frame_layer(ctx: &Ctx, n: &Node, depth: usize, pad: &str, out: &mut String, mods: &mut Vec) { let doc = ctx.doc; + let open = match n.layout.as_ref().map(|l| (l.mode, l)) { + Some((LayoutMode::Vertical, l)) => format!("Column({{ space: {} }})", num(l.gap)), + Some((_, l)) => format!("Row({{ space: {} }})", num(l.gap)), + None => "Stack({ alignContent: Alignment.TopStart })".to_string(), + }; + out.push_str(&format!("{pad}{open} {{\n")); + match ctx.param(n, "slot") { + // A slot shows the instance's content, or else the main's (its default builder). + Some(p) => out.push_str(&format!("{pad} this.{}()\n", p.ident)), + None => { + for c in ctx.kids(n) { + // Free children sit at their position inside a Stack, and so does a child + // that ignores its auto layout parent. + let pos: Vec = match doc.node(c).filter(|cn| n.layout.is_none() || cn.child_layout.absolute) { + Some(cn) => { + let [_, _, _, _, x, y] = cn.transform.as_coeffs(); + vec![format!(".position({{ x: {}, y: {} }})", num(x), num(y))] + } + None => Vec::new(), + }; + arkui(ctx, c, depth + 1, out, &pos); + } + } + } + out.push_str(&format!("{pad}}}\n")); + if let Some(l) = &n.layout { + layout_mods(l, mods); + } + mods.push(format!(".width({})", num(n.width))); + mods.push(format!(".height({})", num(n.height))); + if let Some(c) = first_fill(n) { + mods.push(format!(".backgroundColor({})", ark_color(c))); + } + if radius(n) > 0.0 { + mods.push(".clip(true)".into()); + } + ark_radius(n, mods); +} + +fn layout_mods(l: &figcraft_doc::AutoLayout, mods: &mut Vec) { + let [t, r, b, lft] = l.padding; + if [t, r, b, lft].iter().any(|v| *v > 0.0) { + mods.push(format!(".padding({{ top: {}, right: {}, bottom: {}, left: {} }})", num(t), num(r), num(b), num(lft))); + } + let justify = match l.main_align { + MainAlign::Min => None, + MainAlign::Center => Some("FlexAlign.Center"), + MainAlign::Max => Some("FlexAlign.End"), + MainAlign::SpaceBetween => Some("FlexAlign.SpaceBetween"), + MainAlign::SpaceAround => Some("FlexAlign.SpaceAround"), + MainAlign::SpaceEvenly => Some("FlexAlign.SpaceEvenly"), + }; + if let Some(j) = justify { + mods.push(format!(".justifyContent({j})")); + } + let cross = match l.cross_align { + CrossAlign::Min => None, + CrossAlign::Center => Some("Center"), + CrossAlign::Max => Some(if l.mode == LayoutMode::Vertical { "End" } else { "Bottom" }), + CrossAlign::Baseline => None, + }; + if let Some(c) = cross { + let ty = if l.mode == LayoutMode::Vertical { "HorizontalAlign" } else { "VerticalAlign" }; + mods.push(format!(".alignItems({ty}.{c})")); + } +} + +fn arkui_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[String]) { let pad = indent(depth); if ctx.too_deep(depth) { out.push_str(&format!("{pad}// {} …\n", one_line(&n.name))); @@ -68,48 +184,7 @@ fn arkui_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[Str let mut mods: Vec = Vec::new(); match &n.kind { NodeKind::Text(t) => { - let text = ctx.param(n, "characters").map_or_else(|| literal(&t.characters, false), |p| format!("this.{}", p.ident)); - out.push_str(&format!("{pad}Text({text})\n")); - let s = &t.style; - mods.push(format!(".fontSize({})", num(s.font_size))); - mods.push(format!(".fontWeight({})", s.weight().clamp(100, 900))); - mods.push(format!(".fontFamily({})", literal(&s.font_family, false))); - if s.italic() { - mods.push(".fontStyle(FontStyle.Italic)".into()); - } - if let Some(c) = first_fill(n) { - mods.push(format!(".fontColor({})", ark_color(c))); - } - match s.line_height { - LineHeight::Pixels(v) => mods.push(format!(".lineHeight({})", num(v))), - LineHeight::Percent(p) => mods.push(format!(".lineHeight('{}%')", num(p))), - LineHeight::Auto => {} - } - match s.letter_spacing { - LetterSpacing::Pixels(v) if v != 0.0 => mods.push(format!(".letterSpacing({})", num(v))), - LetterSpacing::Percent(p) if p != 0.0 => mods.push(format!(".letterSpacing('{}%')", num(p))), - _ => {} - } - match t.align_h { - TextAlignH::Left => {} - TextAlignH::Center => mods.push(".textAlign(TextAlign.Center)".into()), - TextAlignH::Right => mods.push(".textAlign(TextAlign.End)".into()), - TextAlignH::Justified => mods.push(".textAlign(TextAlign.JUSTIFY)".into()), - } - // Aligning needs a box to align in: the text layer's own width. - if !matches!(t.align_h, TextAlignH::Left) { - mods.push(format!(".width({})", num(n.width))); - } - match s.case { - TextCase::Upper => mods.push(".textCase(TextCase.UpperCase)".into()), - TextCase::Lower => mods.push(".textCase(TextCase.LowerCase)".into()), - _ => {} - } - match s.decoration { - TextDecoration::Underline => mods.push(".decoration({ type: TextDecorationType.Underline })".into()), - TextDecoration::Strikethrough => mods.push(".decoration({ type: TextDecorationType.LineThrough })".into()), - TextDecoration::None => {} - } + text_layer(ctx, n, t, &pad, out, &mut mods); } NodeKind::Ellipse { .. } => { out.push_str(&format!("{pad}Ellipse({{ width: {}, height: {} }})\n", num(n.width), num(n.height))); @@ -131,67 +206,7 @@ fn arkui_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[Str mods.push(format!(".height({})", num(n.height))); } k if k.is_frame_like() || matches!(k, NodeKind::Group) => { - let open = match n.layout.as_ref().map(|l| (l.mode, l)) { - Some((LayoutMode::Vertical, l)) => format!("Column({{ space: {} }})", num(l.gap)), - Some((_, l)) => format!("Row({{ space: {} }})", num(l.gap)), - None => "Stack({ alignContent: Alignment.TopStart })".to_string(), - }; - out.push_str(&format!("{pad}{open} {{\n")); - match ctx.param(n, "slot") { - // A slot shows the instance's content, or else the main's (its default builder). - Some(p) => out.push_str(&format!("{pad} this.{}()\n", p.ident)), - None => { - for c in ctx.kids(n) { - // Free children sit at their position inside a Stack, and so does a child - // that ignores its auto layout parent. - let pos: Vec = match doc.node(c).filter(|cn| n.layout.is_none() || cn.child_layout.absolute) { - Some(cn) => { - let [_, _, _, _, x, y] = cn.transform.as_coeffs(); - vec![format!(".position({{ x: {}, y: {} }})", num(x), num(y))] - } - None => Vec::new(), - }; - arkui(ctx, c, depth + 1, out, &pos); - } - } - } - out.push_str(&format!("{pad}}}\n")); - if let Some(l) = &n.layout { - let [t, r, b, lft] = l.padding; - if [t, r, b, lft].iter().any(|v| *v > 0.0) { - mods.push(format!(".padding({{ top: {}, right: {}, bottom: {}, left: {} }})", num(t), num(r), num(b), num(lft))); - } - let justify = match l.main_align { - MainAlign::Min => None, - MainAlign::Center => Some("FlexAlign.Center"), - MainAlign::Max => Some("FlexAlign.End"), - MainAlign::SpaceBetween => Some("FlexAlign.SpaceBetween"), - MainAlign::SpaceAround => Some("FlexAlign.SpaceAround"), - MainAlign::SpaceEvenly => Some("FlexAlign.SpaceEvenly"), - }; - if let Some(j) = justify { - mods.push(format!(".justifyContent({j})")); - } - let cross = match l.cross_align { - CrossAlign::Min => None, - CrossAlign::Center => Some("Center"), - CrossAlign::Max => Some(if l.mode == LayoutMode::Vertical { "End" } else { "Bottom" }), - CrossAlign::Baseline => None, - }; - if let Some(c) = cross { - let ty = if l.mode == LayoutMode::Vertical { "HorizontalAlign" } else { "VerticalAlign" }; - mods.push(format!(".alignItems({ty}.{c})")); - } - } - mods.push(format!(".width({})", num(n.width))); - mods.push(format!(".height({})", num(n.height))); - if let Some(c) = first_fill(n) { - mods.push(format!(".backgroundColor({})", ark_color(c))); - } - if radius(n) > 0.0 { - mods.push(".clip(true)".into()); - } - ark_radius(n, &mut mods); + frame_layer(ctx, n, depth, &pad, out, &mut mods); } _ => { out.push_str(&format!("{pad}// {}: export as an image asset\n{pad}Image($r('app.media.asset'))\n", n.kind.label())); diff --git a/crates/engine/src/dev/codegen/compose.rs b/crates/engine/src/dev/codegen/compose.rs index 5915ca9b..fd3ff01a 100644 --- a/crates/engine/src/dev/codegen/compose.rs +++ b/crates/engine/src/dev/codegen/compose.rs @@ -1,5 +1,5 @@ use super::swift::one_line; -use super::*; +use super::{CodeLang, Color, Ctx, LayoutMode, Node, NodeId, NodeKind, drop_shadow, first_fill, indent, literal, num, radius, stroke_color}; // ------------------------------------------------------------------ Jetpack Compose diff --git a/crates/engine/src/dev/codegen/css.rs b/crates/engine/src/dev/codegen/css.rs index c7982652..ef8fe43b 100644 --- a/crates/engine/src/dev/codegen/css.rs +++ b/crates/engine/src/dev/codegen/css.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + ContentAlign, CrossAlign, Document, EffectKind, LayoutMode, LetterSpacing, LineHeight, MainAlign, Node, NodeId, NodeKind, PaintKind, TextAlignH, + TextCase, TextDecoration, Variable, css_borders, num, +}; // ------------------------------------------------------------------ CSS @@ -85,51 +88,7 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo put("height", tok(doc, n, &["height"], px(n.height), t)); } if let Some(l) = &n.layout { - put("display", "flex".into()); - put("flex-direction", if l.mode == LayoutMode::Vertical { "column" } else { "row" }.into()); - if l.wrap { - put("flex-wrap", "wrap".into()); - } - put("gap", tok(doc, n, &["gap"], px(l.gap), t)); - let [pt, pr, pb, pl] = l.padding; - let sides = [ - tok(doc, n, &["paddingTop", "paddingVertical", "padding"], px(pt), t), - tok(doc, n, &["paddingRight", "paddingHorizontal", "padding"], px(pr), t), - tok(doc, n, &["paddingBottom", "paddingVertical", "padding"], px(pb), t), - tok(doc, n, &["paddingLeft", "paddingHorizontal", "padding"], px(pl), t), - ]; - put("padding", sides.join(" ")); - let justify = match l.main_align { - MainAlign::Min => "flex-start", - MainAlign::Center => "center", - MainAlign::Max => "flex-end", - MainAlign::SpaceBetween => "space-between", - MainAlign::SpaceAround => "space-around", - MainAlign::SpaceEvenly => "space-evenly", - }; - put("justify-content", justify.into()); - let align = match l.cross_align { - CrossAlign::Min => "flex-start", - CrossAlign::Center => "center", - CrossAlign::Max => "flex-end", - CrossAlign::Baseline => "baseline", - }; - put("align-items", align.into()); - if l.wrap { - // The lines: spread, or packed where the items align (baseline: at the start). - let spread = l.content_align == ContentAlign::SpaceBetween; - put( - "align-content", - if spread { - "space-between" - } else if align == "baseline" { - "flex-start" - } else { - align - } - .into(), - ); - } + layout_decls(doc, n, l, t, &mut put); } if matches!(n.kind, NodeKind::Ellipse { .. }) { put("border-radius", "50%".into()); @@ -169,6 +128,65 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo for (key, value) in css_borders(doc, n, t) { put(&key, value); } + effect_decls(n, &mut put); + if let NodeKind::Text(tx) = &n.kind { + text_decls(doc, n, tx, t, &mut put); + } + out +} + +/// The flex declarations of auto layout `l` on `n`. +fn layout_decls(doc: &Document, n: &Node, l: &figcraft_doc::AutoLayout, t: &mut Tokens, put: &mut impl FnMut(&str, String)) { + let px = |v: f64| format!("{}px", num(v)); + put("display", "flex".into()); + put("flex-direction", if l.mode == LayoutMode::Vertical { "column" } else { "row" }.into()); + if l.wrap { + put("flex-wrap", "wrap".into()); + } + put("gap", tok(doc, n, &["gap"], px(l.gap), t)); + let [pt, pr, pb, pl] = l.padding; + let sides = [ + tok(doc, n, &["paddingTop", "paddingVertical", "padding"], px(pt), t), + tok(doc, n, &["paddingRight", "paddingHorizontal", "padding"], px(pr), t), + tok(doc, n, &["paddingBottom", "paddingVertical", "padding"], px(pb), t), + tok(doc, n, &["paddingLeft", "paddingHorizontal", "padding"], px(pl), t), + ]; + put("padding", sides.join(" ")); + let justify = match l.main_align { + MainAlign::Min => "flex-start", + MainAlign::Center => "center", + MainAlign::Max => "flex-end", + MainAlign::SpaceBetween => "space-between", + MainAlign::SpaceAround => "space-around", + MainAlign::SpaceEvenly => "space-evenly", + }; + put("justify-content", justify.into()); + let align = match l.cross_align { + CrossAlign::Min => "flex-start", + CrossAlign::Center => "center", + CrossAlign::Max => "flex-end", + CrossAlign::Baseline => "baseline", + }; + put("align-items", align.into()); + if l.wrap { + // The lines: spread, or packed where the items align (baseline: at the start). + let spread = l.content_align == ContentAlign::SpaceBetween; + put( + "align-content", + if spread { + "space-between" + } else if align == "baseline" { + "flex-start" + } else { + align + } + .into(), + ); + } +} + +/// Shadows and blurs of `n`. +fn effect_decls(n: &Node, put: &mut impl FnMut(&str, String)) { let shadows: Vec = n .effects .iter() @@ -196,75 +214,82 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo _ => {} } } - if let NodeKind::Text(tx) = &n.kind { - let s = &tx.style; - put("font-family", tok(doc, n, &["fontFamily"], format!("\"{}\"", s.font_family.replace('"', "'")), t)); - put("font-size", tok(doc, n, &["fontSize"], px(s.font_size), t)); - put("font-weight", s.weight().to_string()); - if s.italic() { - put("font-style", "italic".into()); +} + +/// The type declarations of text layer `n`. +fn text_decls(doc: &Document, n: &Node, tx: &figcraft_doc::TextData, t: &mut Tokens, put: &mut impl FnMut(&str, String)) { + let px = |v: f64| format!("{}px", num(v)); + let s = &tx.style; + put("font-family", tok(doc, n, &["fontFamily"], format!("\"{}\"", s.font_family.replace('"', "'")), t)); + put("font-size", tok(doc, n, &["fontSize"], px(s.font_size), t)); + put("font-weight", s.weight().to_string()); + if s.italic() { + put("font-style", "italic".into()); + } + match s.line_height { + LineHeight::Pixels(v) => put("line-height", tok(doc, n, &["lineHeight"], px(v), t)), + LineHeight::Percent(p) => put("line-height", format!("{}%", num(p))), + LineHeight::Auto => {} + } + match s.letter_spacing { + LetterSpacing::Pixels(v) if v != 0.0 => put("letter-spacing", tok(doc, n, &["letterSpacing"], px(v), t)), + LetterSpacing::Percent(p) if p != 0.0 => put("letter-spacing", format!("{}em", num(p / 100.0))), + _ => {} + } + match tx.align_h { + TextAlignH::Left => {} + TextAlignH::Center => put("text-align", "center".into()), + TextAlignH::Right => put("text-align", "right".into()), + TextAlignH::Justified => put("text-align", "justify".into()), + } + match s.case { + TextCase::Original => {} + TextCase::Upper => put("text-transform", "uppercase".into()), + TextCase::Lower => put("text-transform", "lowercase".into()), + TextCase::Title => put("text-transform", "capitalize".into()), + TextCase::SmallCaps => put("font-variant", "small-caps".into()), + } + match s.decoration { + TextDecoration::None => {} + TextDecoration::Underline => put("text-decoration", "underline".into()), + TextDecoration::Strikethrough => put("text-decoration", "line-through".into()), + } + decoration_decls(s, put); + if tx.leading_trim == figcraft_doc::LeadingTrim::CapHeight { + put("text-box", "trim-both cap alphabetic".into()); + } +} + +fn decoration_decls(s: &figcraft_doc::TextStyle, put: &mut impl FnMut(&str, String)) { + let px = |v: f64| format!("{}px", num(v)); + if s.decoration != TextDecoration::None { + let l = s.look(); + let len = |m: LineHeight| match m { + LineHeight::Auto => None, + LineHeight::Pixels(v) => Some(px(v)), + LineHeight::Percent(p) => Some(format!("{}%", num(p))), + }; + match l.style { + figcraft_doc::DecorationStyle::Solid => {} + figcraft_doc::DecorationStyle::Dotted => put("text-decoration-style", "dotted".into()), + figcraft_doc::DecorationStyle::Wavy => put("text-decoration-style", "wavy".into()), } - match s.line_height { - LineHeight::Pixels(v) => put("line-height", tok(doc, n, &["lineHeight"], px(v), t)), - LineHeight::Percent(p) => put("line-height", format!("{}%", num(p))), - LineHeight::Auto => {} + if let Some(v) = len(l.thickness) { + put("text-decoration-thickness", v); } - match s.letter_spacing { - LetterSpacing::Pixels(v) if v != 0.0 => put("letter-spacing", tok(doc, n, &["letterSpacing"], px(v), t)), - LetterSpacing::Percent(p) if p != 0.0 => put("letter-spacing", format!("{}em", num(p / 100.0))), - _ => {} + if let Some(c) = l.color { + put("text-decoration-color", c.to_css()); } - match tx.align_h { - TextAlignH::Left => {} - TextAlignH::Center => put("text-align", "center".into()), - TextAlignH::Right => put("text-align", "right".into()), - TextAlignH::Justified => put("text-align", "justify".into()), - } - match s.case { - TextCase::Original => {} - TextCase::Upper => put("text-transform", "uppercase".into()), - TextCase::Lower => put("text-transform", "lowercase".into()), - TextCase::Title => put("text-transform", "capitalize".into()), - TextCase::SmallCaps => put("font-variant", "small-caps".into()), - } - match s.decoration { - TextDecoration::None => {} - TextDecoration::Underline => put("text-decoration", "underline".into()), - TextDecoration::Strikethrough => put("text-decoration", "line-through".into()), - } - if s.decoration != TextDecoration::None { - let l = s.look(); - let len = |m: LineHeight| match m { - LineHeight::Auto => None, - LineHeight::Pixels(v) => Some(px(v)), - LineHeight::Percent(p) => Some(format!("{}%", num(p))), - }; - match l.style { - figcraft_doc::DecorationStyle::Solid => {} - figcraft_doc::DecorationStyle::Dotted => put("text-decoration-style", "dotted".into()), - figcraft_doc::DecorationStyle::Wavy => put("text-decoration-style", "wavy".into()), + if s.decoration == TextDecoration::Underline { + if let Some(v) = len(l.offset) { + put("text-underline-offset", v); } - if let Some(v) = len(l.thickness) { - put("text-decoration-thickness", v); + // Browsers skip ink by default; the canvas only when asked. + if !l.skip_ink { + put("text-decoration-skip-ink", "none".into()); } - if let Some(c) = l.color { - put("text-decoration-color", c.to_css()); - } - if s.decoration == TextDecoration::Underline { - if let Some(v) = len(l.offset) { - put("text-underline-offset", v); - } - // Browsers skip ink by default; the canvas only when asked. - if !l.skip_ink { - put("text-decoration-skip-ink", "none".into()); - } - } - } - if tx.leading_trim == figcraft_doc::LeadingTrim::CapHeight { - put("text-box", "trim-both cap alphabetic".into()); } } - out } /// A gradient fill on a `w`×`h` box as a CSS background: linear along its handles' direction diff --git a/crates/engine/src/dev/codegen/html.rs b/crates/engine/src/dev/codegen/html.rs index 3ae746f1..d1b144ca 100644 --- a/crates/engine/src/dev/codegen/html.rs +++ b/crates/engine/src/dev/codegen/html.rs @@ -1,5 +1,5 @@ use super::css::{Tokens, css_decls}; -use super::*; +use super::{CodeLang, Ctx, Document, LayoutMode, Node, NodeId, NodeKind, Param, Sizing, indent, num, with_v_if}; // ------------------------------------------------------------------ HTML and React markup @@ -127,6 +127,32 @@ pub(crate) struct Markup<'c, 'a> { pub fluid_leaves: f64, } +/// How `n` sits in its parent: in the flex flow, or placed at its position. +fn placement_decls(n: &Node, parent: Option<&Node>, root: bool, in_flow: bool) -> String { + let mut decls = String::new(); + if root { + decls.push_str("position: relative;\n"); + } else if in_flow { + let vertical = parent.and_then(|p| p.layout.as_ref()).is_some_and(|l| l.mode == LayoutMode::Vertical); + let (main, cross) = + if vertical { (n.child_layout.sizing_v, n.child_layout.sizing_h) } else { (n.child_layout.sizing_h, n.child_layout.sizing_v) }; + if main == Sizing::Fill { + decls.push_str("flex: 1 1 0;\n"); + } + if cross == Sizing::Fill { + decls.push_str("align-self: stretch;\n"); + } + } else { + let c = n.transform.as_coeffs(); + decls.push_str(&format!("position: absolute;\nleft: {}px;\ntop: {}px;\n", num(c[4]), num(c[5]))); + let rot = figcraft_geom::rotation_degrees(n.transform); + if rot.abs() > 0.01 { + decls.push_str(&format!("transform: rotate({}deg);\ntransform-origin: 0 0;\n", num(-rot))); + } + } + decls +} + impl<'c, 'a> Markup<'c, 'a> { pub fn new(ctx: &'c Ctx<'a>, lang: CodeLang) -> Self { Markup { @@ -196,6 +222,52 @@ impl<'c, 'a> Markup<'c, 'a> { self.rules.push((path.to_string(), class.to_string(), decls)); } + /// An instance as a call to its component (see `element`); true when it was written. + fn instance_call(&mut self, n: &Node, depth: usize, path: &str, class: &str, decls: &str, root: bool) -> bool { + let pad = self.pad(depth); + let attr = if self.lang == CodeLang::React { "className" } else { "class" }; + let connected = self.inline && crate::code_connect::link(self.ctx.doc, n.id, CodeLang::React).is_some(); + if self.code() && (!self.inline || connected) && !root && matches!(n.kind, NodeKind::Instance { .. }) { + let at = depth + usize::from(!decls.is_empty()); + let run = if self.lang == CodeLang::Leptos { ".run()" } else { "" }; + let (inner, css) = match self.ctx.param(n, "mainComponent") { + Some(p) if self.lang == CodeLang::Vue => (Some(format!("{}\n", self.pad(at), self.ident(p))), String::new()), + Some(p) => (Some(format!("{}{{{}{run}}}\n", self.pad(at), self.ident(p))), String::new()), + None => self.ctx.call(n, self.lang, at).map_or((None, String::new()), |(code, css)| (Some(code), css)), + }; + self.styles.push_str(&css); + if let Some(inner) = inner { + // A Vue guard set by `node` rides on the call when there is no wrapper. + let guard = if self.lang == CodeLang::Vue { self.v_if.take() } else { None }; + if decls.is_empty() { + self.markup.push_str(&with_v_if(&inner, guard.as_deref())); + } else { + let guard = guard.map(|g| format!(" v-if=\"{g}\"")).unwrap_or_default(); + self.markup.push_str(&format!("{pad}
\n{inner}{pad}
\n")); + self.rule(path, class, decls.to_string()); + } + return true; + } + } + false + } + + fn text_body(&self, n: &Node, t: &figcraft_doc::TextData) -> String { + match (self.ctx.param(n, "characters").filter(|_| self.code()), self.lang) { + (Some(p), CodeLang::Leptos) => format!("{{{}.clone()}}", self.ident(p)), + (Some(p), CodeLang::Vue) => format!("{{{{ {} }}}}", self.ident(p)), + (Some(p), _) => format!("{{{}}}", p.ident), + (None, CodeLang::React) => jsx_text(&t.characters), + // `view!` takes text as string literals and void elements self-closed. + (None, CodeLang::Leptos) => { + t.characters.chars().take(10_000).collect::().lines().map(|l| format!("{l:?}")).collect::>().join("
") + } + // A Vue template reads `{{ }}` as interpolation, so braces are escaped. + (None, CodeLang::Vue) => html_escape(&t.characters).replace('{', "{").replace('}', "}"), + (None, _) => html_escape(&t.characters), + } + } + fn element(&mut self, n: &Node, parent: Option<&Node>, depth: usize, path: &str) { let doc = self.ctx.doc; let root = n.id == self.ctx.root; @@ -215,53 +287,13 @@ impl<'c, 'a> Markup<'c, 'a> { let pad = self.pad(depth); let attr = if self.lang == CodeLang::React { "className" } else { "class" }; // How it sits in its parent: in the flex flow, or placed at its position. - let mut decls = String::new(); let in_flow = parent.is_some_and(|p| p.layout.is_some()) && !n.child_layout.absolute; - if root { - decls.push_str("position: relative;\n"); - } else if in_flow { - let vertical = parent.and_then(|p| p.layout.as_ref()).is_some_and(|l| l.mode == LayoutMode::Vertical); - let (main, cross) = - if vertical { (n.child_layout.sizing_v, n.child_layout.sizing_h) } else { (n.child_layout.sizing_h, n.child_layout.sizing_v) }; - if main == Sizing::Fill { - decls.push_str("flex: 1 1 0;\n"); - } - if cross == Sizing::Fill { - decls.push_str("align-self: stretch;\n"); - } - } else { - let c = n.transform.as_coeffs(); - decls.push_str(&format!("position: absolute;\nleft: {}px;\ntop: {}px;\n", num(c[4]), num(c[5]))); - let rot = figcraft_geom::rotation_degrees(n.transform); - if rot.abs() > 0.01 { - decls.push_str(&format!("transform: rotate({}deg);\ntransform-origin: 0 0;\n", num(-rot))); - } - } + let mut decls = placement_decls(n, parent, root, in_flow); // Nested instances are calls (component code), in a wrapper that places them when needed. // Page code inlines instances, except those connected to code (codeConnect.set): their // snippet, e.g. a design-system `