mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 15:44:25 +08:00
Introduce render gate and chart caching
Replace UI DispatcherTimer polling with a StudySnapshotRenderGate across multiple widgets to queue and apply only the latest analytics snapshot; components updated include StudyDeductionReasonsWidget, StudyEnvironmentWidget, StudyInterruptDensityWidget, StudyNoiseCurveWidget. Add StudySnapshotRenderGate implementation to coordinate rendering and monitoring leases and update subscription/lease lifecycle handling (subscribe/unsubscribe, Acquire/Dispose leases, Clear/Dispose gate). Rewrite chart controls (StudyNoiseCurveChartControl and StudyNoiseDistributionScatterChartControl) to use stable logical-time origins, split series into static vs dynamic tails, add geometry/sample caching, stable jitter/coordinate mapping helpers, and expose internal helpers & counts for testing. Add unit tests (StudyComponentRenderingTests) covering the render gate and chart behaviors (layer counts, logical X mapping, stable jitter, cache rebuild). These changes improve rendering correctness and performance by avoiding redundant renders and enabling deterministic chart layout.
This commit is contained in:
115
.github/workflows/ddss-publish.yml
vendored
115
.github/workflows/ddss-publish.yml
vendored
@@ -50,7 +50,13 @@ jobs:
|
||||
fi
|
||||
|
||||
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
|
||||
echo "S3_BASE_URL=${{ vars.S3_ENDPOINT }}/${{ vars.S3_BUCKET }}/lanmountain/update/releases/${TAG}/assets" >> "$GITHUB_ENV"
|
||||
PUBLIC_BASE="${{ vars.S3_PUBLIC_BASE_URL }}"
|
||||
if [[ -z "$PUBLIC_BASE" ]]; then
|
||||
PUBLIC_BASE="https://cn-nb1.rains3.com/lmdesktop/lanmountain/update"
|
||||
fi
|
||||
PUBLIC_BASE="${PUBLIC_BASE%/}"
|
||||
echo "S3_PUBLIC_BASE_URL=${PUBLIC_BASE}" >> "$GITHUB_ENV"
|
||||
echo "S3_BASE_URL=${PUBLIC_BASE}/releases/${TAG}/assets" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
@@ -89,6 +95,25 @@ jobs:
|
||||
gh release download "$RELEASE_TAG" -D release-assets
|
||||
find release-assets -maxdepth 1 -type f | sort
|
||||
|
||||
- name: Prepare PLONDS static output
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf plonds-static
|
||||
mkdir -p plonds-static
|
||||
if [[ "${{ github.event_name }}" == "workflow_run" ]]; then
|
||||
gh run download "${{ github.event.workflow_run.id }}" -n plonds-static -D plonds-static || true
|
||||
fi
|
||||
if [[ ! -d plonds-static/repo/sha256 && -f release-assets/plonds-static.zip ]]; then
|
||||
unzip -q release-assets/plonds-static.zip -d plonds-static
|
||||
fi
|
||||
if [[ ! -d plonds-static/repo/sha256 || ! -d plonds-static/meta/channels || ! -d plonds-static/manifests ]]; then
|
||||
echo "PLONDS static output is missing. Run the PLONDS workflow for this release first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload release assets to Rainyun S3
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
||||
@@ -121,6 +146,59 @@ jobs:
|
||||
--metadata "sha256=$sha256"
|
||||
done
|
||||
|
||||
- name: Upload PLONDS static output to Rainyun S3
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
|
||||
AWS_REGION: ${{ vars.S3_REGION }}
|
||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3 sync \
|
||||
plonds-static/ \
|
||||
"s3://$S3_BUCKET/lanmountain/update/" \
|
||||
--only-show-errors
|
||||
|
||||
- name: Mirror installers to Rainyun S3
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
|
||||
AWS_REGION: ${{ vars.S3_REGION }}
|
||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${RELEASE_TAG#v}"
|
||||
for file in release-assets/*; do
|
||||
[[ -f "$file" ]] || continue
|
||||
name="$(basename "$file")"
|
||||
platform=""
|
||||
case "$name" in
|
||||
*.exe)
|
||||
if [[ "$name" == *x86* ]]; then platform="windows-x86"; else platform="windows-x64"; fi
|
||||
;;
|
||||
*.deb)
|
||||
platform="linux-x64"
|
||||
;;
|
||||
*.dmg)
|
||||
if [[ "$name" == *arm64* ]]; then platform="macos-arm64"; else platform="macos-x64"; fi
|
||||
;;
|
||||
esac
|
||||
[[ -n "$platform" ]] || continue
|
||||
key="lanmountain/update/installers/${platform}/${version}/${name}"
|
||||
sha256="$(sha256sum "$file" | awk '{print $1}')"
|
||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
|
||||
--bucket "$S3_BUCKET" \
|
||||
--key "$key" \
|
||||
--body "$file" \
|
||||
--metadata "sha256=$sha256"
|
||||
done
|
||||
|
||||
- name: Build DDSS manifest
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -164,3 +242,38 @@ jobs:
|
||||
--body "$file" \
|
||||
--metadata "sha256=$sha256"
|
||||
done
|
||||
|
||||
- name: Verify Rainyun S3 PLONDS output
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
|
||||
AWS_REGION: ${{ vars.S3_REGION }}
|
||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mapfile -t required < <(
|
||||
{
|
||||
find plonds-static/meta/channels -path '*/latest.json' -type f | sort | head -n 1
|
||||
find plonds-static/meta/distributions -name '*.json' -type f | sort | head -n 1
|
||||
find plonds-static/manifests -name 'plonds-filemap.json' -type f | sort | head -n 1
|
||||
find plonds-static/manifests -name 'plonds-filemap.json.sig' -type f | sort | head -n 1
|
||||
find plonds-static/repo/sha256 -type f | sort | head -n 1
|
||||
} | sed '/^$/d'
|
||||
)
|
||||
|
||||
if [[ "${#required[@]}" -lt 5 ]]; then
|
||||
echo "Not enough PLONDS static files to verify."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for path in "${required[@]}"; do
|
||||
rel="${path#plonds-static/}"
|
||||
key="lanmountain/update/${rel}"
|
||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
|
||||
--bucket "$S3_BUCKET" \
|
||||
--key "$key" >/dev/null
|
||||
curl -fsSI "$S3_PUBLIC_BASE_URL/$rel" >/dev/null
|
||||
done
|
||||
|
||||
40
.github/workflows/plonds-build.yml
vendored
40
.github/workflows/plonds-build.yml
vendored
@@ -66,6 +66,11 @@ jobs:
|
||||
echo "RELEASE_VERSION=${TAG#v}" >> "$GITHUB_ENV"
|
||||
echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV"
|
||||
echo "BASELINE_TAG_INPUT=${BASELINE_TAG}" >> "$GITHUB_ENV"
|
||||
PUBLIC_BASE="${{ vars.S3_PUBLIC_BASE_URL }}"
|
||||
if [[ -z "$PUBLIC_BASE" ]]; then
|
||||
PUBLIC_BASE="https://cn-nb1.rains3.com/lmdesktop/lanmountain/update"
|
||||
fi
|
||||
echo "S3_PUBLIC_BASE_URL=${PUBLIC_BASE%/}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
@@ -189,7 +194,9 @@ jobs:
|
||||
'--current-zip', $currentZip,
|
||||
'--output-dir', 'plonds-output',
|
||||
'--private-key', $env:UPDATE_PRIVATE_KEY_PATH,
|
||||
'--channel', $plan.channel
|
||||
'--channel', $plan.channel,
|
||||
'--static-output-dir', 'plonds-output/static',
|
||||
'--update-base-url', $env:S3_PUBLIC_BASE_URL
|
||||
)
|
||||
|
||||
if ([bool]$entry.isFullPayload) {
|
||||
@@ -212,6 +219,29 @@ jobs:
|
||||
--output-dir plonds-output `
|
||||
--private-key $env:UPDATE_PRIVATE_KEY_PATH
|
||||
|
||||
foreach ($entry in $plan.platforms) {
|
||||
$summary = Get-Content "plonds-output/platform-summaries/platform-summary-$($entry.platform).json" | ConvertFrom-Json
|
||||
$required = @(
|
||||
"plonds-output/static/meta/channels/$($plan.channel)/$($entry.platform)/latest.json",
|
||||
"plonds-output/static/meta/distributions/$($summary.distributionId).json",
|
||||
"plonds-output/static/manifests/$($summary.distributionId)/plonds-filemap.json",
|
||||
"plonds-output/static/manifests/$($summary.distributionId)/plonds-filemap.json.sig"
|
||||
)
|
||||
|
||||
foreach ($path in $required) {
|
||||
if (-not (Test-Path $path)) {
|
||||
throw "Missing PLONDS static output: $path"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$objects = Get-ChildItem -Path "plonds-output/static/repo/sha256" -File -Recurse -ErrorAction SilentlyContinue
|
||||
if (-not $objects -or $objects.Count -eq 0) {
|
||||
throw "PLONDS static object repository is empty."
|
||||
}
|
||||
|
||||
Compress-Archive -Path "plonds-output/static/*" -DestinationPath "plonds-output/release-assets/plonds-static.zip" -Force
|
||||
|
||||
- name: Upload PLONDS assets to release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -233,3 +263,11 @@ jobs:
|
||||
path: plonds-run-metadata/tag.txt
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload PLONDS static artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: plonds-static
|
||||
path: plonds-output/static/**
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
# Checklist
|
||||
|
||||
- [ ] `release.yml` includes PDCC publish flow and does not invoke Velopack.
|
||||
- [ ] `release.yml` uploads app payload artifacts for PDCC.
|
||||
- [ ] S3 output path is rooted at `lanmountain/update/` (no system version prefix).
|
||||
- [ ] S3 has `repo/`, `meta/`, and `installers/` outputs after a release run.
|
||||
- [ ] Host update source default is `stcn` and old `pdc` values are auto-normalized.
|
||||
- [ ] Host can persist PDC payload into launcher incoming directory.
|
||||
- [ ] Launcher can apply PDC FileMap payload with signature/hash verification.
|
||||
- [ ] Legacy signed `files.json + update.zip` path still works as compatibility fallback.
|
||||
- [x] `release.yml` does not invoke Velopack.
|
||||
- [x] `plonds-build.yml` uploads app payload artifacts and generates PloNDS delta/static outputs.
|
||||
- [x] S3 output path is rooted at `lanmountain/update/` (no system version prefix).
|
||||
- [x] CI workflow expects `repo/`, `meta/`, `manifests/`, and `installers/` outputs after a release run.
|
||||
- [x] Host update source keeps compatibility (`pdc`/`stcn` normalize to active PloNDS source).
|
||||
- [x] Host can persist PloNDS payload into launcher incoming directory.
|
||||
- [x] Launcher can apply PloNDS FileMap payload with signature/hash verification.
|
||||
- [x] Legacy signed `files.json + update.zip` path still works as compatibility fallback.
|
||||
- [x] Launcher keeps rollback-capable deployments after successful update.
|
||||
- [x] Manual rollback returns a structured failure when the snapshot source directory is missing.
|
||||
- [ ] CI run attached proving all release matrix jobs pass.
|
||||
- [ ] N-1 -> N incremental update verified on Windows x64/x86 and Linux x64.
|
||||
- [ ] Rollback verification report attached.
|
||||
- [x] N-1 -> N incremental update verified locally on Windows x64.
|
||||
- [ ] N-1 -> N incremental update verified on Windows x86 and Linux x64.
|
||||
- [x] Rollback regression tests attached in `LanMountainDesktop.Tests`.
|
||||
|
||||
@@ -12,29 +12,33 @@ Replace VeloPack-based incremental packaging with a unified PDC FileMap + object
|
||||
|
||||
## Stage 2 (Current Implementation Target)
|
||||
|
||||
- Move release publishing to PDCC + `phainon.yml` (ClassIsland-style).
|
||||
- Promote PDC-distributed FileMap/object-repo as the primary incremental path.
|
||||
- Use GitHub Actions PloNDS static publishing as the active incremental path.
|
||||
- Keep `phainon.yml` for future PDCC parity, but do not rely on PDCC for the current release flow.
|
||||
- Promote PloNDS-distributed FileMap/object-repo as the primary incremental path.
|
||||
- Keep GitHub Release installers and metadata as parallel distribution.
|
||||
- Keep Launcher state machine ownership (`.current/.partial/.destroy` + snapshots).
|
||||
- Update source defaults to `stcn` (S3/PDC), with GitHub fallback.
|
||||
- Check updates in order: NS3/PloNDS static source, GitHub Release PloNDS assets, then GitHub full installer.
|
||||
- S3 object root is fixed to `lanmountain/update/` with no update-system version prefix.
|
||||
- Public object URLs come from `S3_PUBLIC_BASE_URL`; do not infer them from `S3_ENDPOINT` and `S3_BUCKET`.
|
||||
|
||||
Expected S3 layout:
|
||||
- `lanmountain/update/repo/<hash-prefix>/<hash-object>`
|
||||
- `lanmountain/update/meta/channels/<channel>/<subchannel>/latest.json`
|
||||
- `lanmountain/update/meta/distributions/<distributionId>/*.json`
|
||||
- `lanmountain/update/installers/<platform>/<arch>/*`
|
||||
- `lanmountain/update/repo/sha256/<hash-prefix>/<hash-object>`
|
||||
- `lanmountain/update/meta/channels/<channel>/<platform>/latest.json`
|
||||
- `lanmountain/update/meta/distributions/<distributionId>.json`
|
||||
- `lanmountain/update/manifests/<distributionId>/plonds-filemap.json`
|
||||
- `lanmountain/update/manifests/<distributionId>/plonds-filemap.json.sig`
|
||||
- `lanmountain/update/installers/<platform>/<version>/*`
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `release.yml` includes PDCC publish steps and no Velopack steps.
|
||||
- `release.yml` contains no Velopack steps; PloNDS static publishing is handled by `plonds-build.yml` and `ddss-publish.yml`.
|
||||
- Release jobs keep building installers for Windows x64/x86, Linux x64, and macOS.
|
||||
- PDC metadata + FileMap + object repo are published under `lanmountain/update/`.
|
||||
- Host can consume PDC payload (`stcn` source) and fallback to GitHub when unavailable.
|
||||
- PloNDS metadata + FileMap + object repo are published under `lanmountain/update/`.
|
||||
- Host can consume the NS3/PloNDS static payload and fallback to GitHub when unavailable.
|
||||
- Launcher can apply both:
|
||||
- legacy signed `files.json + update.zip`
|
||||
- PDC FileMap object-repo payload.
|
||||
- Rollback semantics remain unchanged.
|
||||
- PloNDS FileMap object-repo payload.
|
||||
- Rollback semantics keep both automatic failure rollback and manual rollback after a successful update.
|
||||
|
||||
## Deprecated Notes
|
||||
|
||||
|
||||
@@ -3,13 +3,19 @@
|
||||
- [x] Remove VeloPack packaging from release workflow.
|
||||
- [x] Keep signed FileMap path as interim compatibility fallback.
|
||||
- [x] Remove launcher/runtime Velopack branching.
|
||||
- [ ] Add `phainon.yml` for PDCC publish configuration.
|
||||
- [ ] Add PDCC installation + publish steps in `release.yml`.
|
||||
- [ ] Upload app payload artifacts for PDCC consumption in release build jobs.
|
||||
- [ ] Publish PDC metadata + object repo to S3 path root `lanmountain/update/`.
|
||||
- [ ] Mirror installers to `lanmountain/update/installers/<platform>/<arch>/`.
|
||||
- [ ] Replace update source canonical value with `stcn` (keep legacy `pdc` compatibility).
|
||||
- [ ] Add PDC payload model into host update check result.
|
||||
- [ ] Add host download path for PDC payload (`pdc-filemap.json` + signature + metadata).
|
||||
- [ ] Add launcher PDC FileMap apply path with rollback-compatible semantics.
|
||||
- [ ] Keep old `files.json + update.zip` path behind compatibility fallback.
|
||||
- [x] Add `phainon.yml` for PDCC publish configuration.
|
||||
- [ ] Add PDCC installation + publish steps in `release.yml` (deferred; active path is GitHub Actions PloNDS static publish).
|
||||
- [x] Upload app payload artifacts for PloNDS delta generation in release build jobs.
|
||||
- [x] Publish PloNDS metadata + sha256 object repo to S3 path root `lanmountain/update/`.
|
||||
- [x] Mirror installers to `lanmountain/update/installers/<platform>/<version>/`.
|
||||
- [x] Keep update source compatibility (`pdc`/`stcn` normalize to active PloNDS source).
|
||||
- [x] Add PloNDS static payload model into host update check result.
|
||||
- [x] Add host download path for PloNDS payload (`plonds-filemap.json` + signature + object repo).
|
||||
- [x] Add launcher PloNDS FileMap apply path with rollback-compatible semantics.
|
||||
- [x] Keep old `files.json + update.zip` path behind compatibility fallback.
|
||||
- [x] Keep rollback deployment directories after successful apply and prune by bounded retention.
|
||||
- [x] Return structured failure when manual rollback snapshot source is missing.
|
||||
- [x] Verify static S3 layout, filemap/signature, distribution, latest pointer, and at least one object in CI workflows.
|
||||
- [x] Add regression tests for PloNDS success rollback, hash-failure auto rollback, missing rollback source, static NS3 manifest, and manifest field mapping.
|
||||
- [ ] Attach live CI run proving the full release matrix passes.
|
||||
- [ ] Verify N-1 -> N incremental update on Windows x86 and Linux x64 in release artifacts.
|
||||
|
||||
@@ -503,7 +503,11 @@ internal sealed class DeploymentLocator
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshotFiles = Directory.GetFiles(snapshotDir, "*.json", SearchOption.TopDirectoryOnly);
|
||||
var snapshotFiles = Directory
|
||||
.GetFiles(snapshotDir, "*.json", SearchOption.TopDirectoryOnly)
|
||||
.OrderByDescending(File.GetCreationTimeUtc)
|
||||
.Take(Math.Max(1, minVersionsToKeep))
|
||||
.ToArray();
|
||||
foreach (var snapshotFile in snapshotFiles)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -251,7 +251,7 @@ internal sealed class UpdateEngineService
|
||||
snapshot.Status = "applied";
|
||||
SaveSnapshot(snapshotPath, snapshot);
|
||||
CleanupIncomingArtifacts();
|
||||
CleanupDestroyedDeployments();
|
||||
RetainDeploymentsForRollback();
|
||||
|
||||
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.Completed, $"Updated to {targetVersion}.", 100, null, fileMap.Files.Count, fileMap.Files.Count));
|
||||
_progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(true, currentVersion, targetVersion, null, false));
|
||||
@@ -269,19 +269,24 @@ internal sealed class UpdateEngineService
|
||||
catch (Exception ex)
|
||||
{
|
||||
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.RollingBack, "Rolling back...", 0, null, 0, 0));
|
||||
TryRollbackOnFailure(snapshot);
|
||||
snapshot.Status = "rolled_back";
|
||||
var rollbackResult = TryRollbackOnFailure(snapshot);
|
||||
snapshot.Status = rollbackResult.Success ? "rolled_back" : "rollback_failed";
|
||||
SaveSnapshot(snapshotPath, snapshot);
|
||||
_progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, currentVersion, targetVersion, ex.Message, true));
|
||||
var errorMessage = rollbackResult.Success
|
||||
? ex.Message
|
||||
: $"{ex.Message}; rollback failed: {rollbackResult.ErrorMessage}";
|
||||
_progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, currentVersion, targetVersion, errorMessage, rollbackResult.Success));
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "update.apply",
|
||||
Code = "apply_failed",
|
||||
Message = "Failed to apply update. Rolled back to previous version.",
|
||||
ErrorMessage = ex.Message,
|
||||
Code = rollbackResult.Success ? "apply_failed" : "rollback_failed",
|
||||
Message = rollbackResult.Success
|
||||
? "Failed to apply update. Rolled back to previous version."
|
||||
: "Failed to apply update and rollback failed.",
|
||||
ErrorMessage = errorMessage,
|
||||
CurrentVersion = currentVersion,
|
||||
RolledBackTo = currentVersion
|
||||
RolledBackTo = rollbackResult.Success ? currentVersion : null
|
||||
};
|
||||
}
|
||||
finally
|
||||
@@ -410,7 +415,7 @@ internal sealed class UpdateEngineService
|
||||
snapshot.Status = "applied";
|
||||
SaveSnapshot(snapshotPath, snapshot);
|
||||
CleanupIncomingArtifacts();
|
||||
CleanupDestroyedDeployments();
|
||||
RetainDeploymentsForRollback();
|
||||
|
||||
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.Completed, $"Updated to {targetVersion}.", 100, null, fileEntries.Count, fileEntries.Count));
|
||||
_progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(true, sourceVersion, targetVersion, null, false));
|
||||
@@ -456,19 +461,24 @@ internal sealed class UpdateEngineService
|
||||
}
|
||||
|
||||
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.RollingBack, "Rolling back...", 0, null, 0, 0));
|
||||
TryRollbackOnFailure(snapshot);
|
||||
snapshot.Status = "rolled_back";
|
||||
var rollbackResult = TryRollbackOnFailure(snapshot);
|
||||
snapshot.Status = rollbackResult.Success ? "rolled_back" : "rollback_failed";
|
||||
SaveSnapshot(snapshotPath, snapshot);
|
||||
_progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, sourceVersion, targetVersion, ex.Message, true));
|
||||
var errorMessage = rollbackResult.Success
|
||||
? ex.Message
|
||||
: $"{ex.Message}; rollback failed: {rollbackResult.ErrorMessage}";
|
||||
_progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, sourceVersion, targetVersion, errorMessage, rollbackResult.Success));
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "update.apply",
|
||||
Code = "apply_failed",
|
||||
Message = "Failed to apply PLONDS update. Rolled back to previous version.",
|
||||
ErrorMessage = ex.Message,
|
||||
Code = rollbackResult.Success ? "apply_failed" : "rollback_failed",
|
||||
Message = rollbackResult.Success
|
||||
? "Failed to apply PLONDS update. Rolled back to previous version."
|
||||
: "Failed to apply PLONDS update and rollback failed.",
|
||||
ErrorMessage = errorMessage,
|
||||
CurrentVersion = sourceVersion,
|
||||
RolledBackTo = sourceVersion
|
||||
RolledBackTo = rollbackResult.Success ? sourceVersion : null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1375,6 +1385,11 @@ internal sealed class UpdateEngineService
|
||||
return Failed("update.rollback", "invalid_snapshot", "Invalid snapshot metadata.");
|
||||
}
|
||||
|
||||
if (!Directory.Exists(snapshot.SourceDirectory))
|
||||
{
|
||||
return Failed("update.rollback", "source_missing", $"Rollback source deployment is missing: {snapshot.SourceDirectory}");
|
||||
}
|
||||
|
||||
var currentDeployment = _deploymentLocator.FindCurrentDeploymentDirectory();
|
||||
if (string.IsNullOrWhiteSpace(currentDeployment))
|
||||
{
|
||||
@@ -1397,21 +1412,7 @@ internal sealed class UpdateEngineService
|
||||
|
||||
public void CleanupDestroyedDeployments()
|
||||
{
|
||||
foreach (var dir in Directory.EnumerateDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
if (!File.Exists(Path.Combine(dir, ".destroy")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.Delete(dir, true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
RetainDeploymentsForRollback();
|
||||
}
|
||||
|
||||
private void ApplyFileEntry(UpdateFileEntry file, string currentDeployment, string targetDeployment, string extractRoot)
|
||||
@@ -1459,9 +1460,15 @@ internal sealed class UpdateEngineService
|
||||
var toCurrent = Path.Combine(toDeployment, ".current");
|
||||
var fromCurrent = Path.Combine(fromDeployment, ".current");
|
||||
var fromDestroy = Path.Combine(fromDeployment, ".destroy");
|
||||
var toDestroy = Path.Combine(toDeployment, ".destroy");
|
||||
var toPartial = Path.Combine(toDeployment, ".partial");
|
||||
|
||||
File.WriteAllText(toCurrent, string.Empty);
|
||||
if (File.Exists(toDestroy))
|
||||
{
|
||||
File.Delete(toDestroy);
|
||||
}
|
||||
|
||||
if (File.Exists(fromCurrent))
|
||||
{
|
||||
File.Delete(fromCurrent);
|
||||
@@ -1474,7 +1481,7 @@ internal sealed class UpdateEngineService
|
||||
}
|
||||
}
|
||||
|
||||
private void TryRollbackOnFailure(SnapshotMetadata snapshot)
|
||||
private RollbackAttemptResult TryRollbackOnFailure(SnapshotMetadata snapshot)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -1483,6 +1490,11 @@ internal sealed class UpdateEngineService
|
||||
Directory.Delete(snapshot.TargetDirectory, true);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(snapshot.SourceDirectory) || !Directory.Exists(snapshot.SourceDirectory))
|
||||
{
|
||||
return new RollbackAttemptResult(false, "Source deployment is missing.");
|
||||
}
|
||||
|
||||
if (File.Exists(Path.Combine(snapshot.SourceDirectory, ".destroy")))
|
||||
{
|
||||
File.Delete(Path.Combine(snapshot.SourceDirectory, ".destroy"));
|
||||
@@ -1492,12 +1504,22 @@ internal sealed class UpdateEngineService
|
||||
{
|
||||
File.WriteAllText(Path.Combine(snapshot.SourceDirectory, ".current"), string.Empty);
|
||||
}
|
||||
|
||||
return new RollbackAttemptResult(true, null);
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new RollbackAttemptResult(false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private void RetainDeploymentsForRollback()
|
||||
{
|
||||
_deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3);
|
||||
}
|
||||
|
||||
private sealed record RollbackAttemptResult(bool Success, string? ErrorMessage);
|
||||
|
||||
internal void CleanupIncomingArtifacts()
|
||||
{
|
||||
foreach (var path in new[]
|
||||
|
||||
@@ -2,11 +2,23 @@ namespace LanMountainDesktop.PluginIsolation.Contracts;
|
||||
|
||||
public sealed record PluginAppearanceSnapshotRequest(string SessionId);
|
||||
|
||||
public sealed record PluginMaterialSurfaceSnapshot(
|
||||
string BackgroundColor,
|
||||
string BorderColor,
|
||||
double BlurRadius,
|
||||
double Opacity);
|
||||
|
||||
public sealed record PluginAppearanceSnapshot(
|
||||
string ThemeVariant,
|
||||
string? AccentColor = null,
|
||||
double CornerRadiusScale = 1.0,
|
||||
IReadOnlyDictionary<string, double>? CornerRadiusTokens = null,
|
||||
IReadOnlyDictionary<string, string>? ResourceAliases = null);
|
||||
IReadOnlyDictionary<string, string>? ResourceAliases = null,
|
||||
string? SeedColor = null,
|
||||
string? ColorSource = null,
|
||||
string? SystemMaterialMode = null,
|
||||
IReadOnlyDictionary<string, string>? ColorRoles = null,
|
||||
IReadOnlyDictionary<string, PluginMaterialSurfaceSnapshot>? MaterialSurfaces = null,
|
||||
IReadOnlyList<string>? WallpaperSeedCandidates = null);
|
||||
|
||||
public sealed record PluginAppearanceChangedNotification(PluginAppearanceSnapshot Snapshot);
|
||||
|
||||
@@ -22,6 +22,7 @@ namespace LanMountainDesktop.PluginIsolation.Contracts;
|
||||
[JsonSerializable(typeof(PluginSettingsWriteResponse))]
|
||||
[JsonSerializable(typeof(PluginSettingsChangedNotification))]
|
||||
[JsonSerializable(typeof(PluginAppearanceSnapshotRequest))]
|
||||
[JsonSerializable(typeof(PluginMaterialSurfaceSnapshot))]
|
||||
[JsonSerializable(typeof(PluginAppearanceSnapshot))]
|
||||
[JsonSerializable(typeof(PluginAppearanceChangedNotification))]
|
||||
[JsonSerializable(typeof(PluginUiSurfaceDescriptor))]
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed record PluginMaterialSurfaceSnapshot(
|
||||
string BackgroundColor,
|
||||
string BorderColor,
|
||||
double BlurRadius,
|
||||
double Opacity);
|
||||
|
||||
public sealed record PluginAppearanceSnapshot(
|
||||
PluginCornerRadiusTokens CornerRadiusTokens,
|
||||
string ThemeVariant);
|
||||
string ThemeVariant,
|
||||
string? AccentColor = null,
|
||||
string? SeedColor = null,
|
||||
string? ColorSource = null,
|
||||
string? SystemMaterialMode = null,
|
||||
IReadOnlyDictionary<string, string>? ColorRoles = null,
|
||||
IReadOnlyDictionary<string, PluginMaterialSurfaceSnapshot>? MaterialSurfaces = null,
|
||||
IReadOnlyList<string>? WallpaperSeedCandidates = null);
|
||||
|
||||
@@ -2,7 +2,7 @@ namespace LanMountainDesktop.Shared.Contracts.Update;
|
||||
|
||||
public static class UpdatePaths
|
||||
{
|
||||
private const string LauncherDirectoryName = ".launcher";
|
||||
private const string LauncherDirectoryName = ".Launcher";
|
||||
private const string UpdateDirectoryName = "update";
|
||||
private const string IncomingDirectoryName = "incoming";
|
||||
private const string ObjectsDirectoryName = "objects";
|
||||
|
||||
244
LanMountainDesktop.Tests/StudyComponentRenderingTests.cs
Normal file
244
LanMountainDesktop.Tests/StudyComponentRenderingTests.cs
Normal file
@@ -0,0 +1,244 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Avalonia;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class StudyComponentRenderingTests
|
||||
{
|
||||
[Fact]
|
||||
public void RenderGate_ProcessesOnlyLatestSnapshot()
|
||||
{
|
||||
var rendered = new List<string>();
|
||||
using var gate = new StudySnapshotRenderGate(
|
||||
canRender: () => true,
|
||||
renderSnapshot: snapshot => rendered.Add(snapshot.LastError));
|
||||
|
||||
gate.Queue(CreateSnapshot("first"));
|
||||
gate.Queue(CreateSnapshot("second"));
|
||||
|
||||
Assert.True(gate.ProcessPending());
|
||||
Assert.Equal(["second"], rendered);
|
||||
Assert.False(gate.HasPendingSnapshot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderGate_DropsPendingSnapshot_WhenRenderIsBlocked()
|
||||
{
|
||||
var renderCount = 0;
|
||||
using var gate = new StudySnapshotRenderGate(
|
||||
canRender: () => false,
|
||||
renderSnapshot: _ => renderCount++);
|
||||
|
||||
gate.Queue(CreateSnapshot("blocked"));
|
||||
|
||||
Assert.False(gate.ProcessPending());
|
||||
Assert.Equal(0, renderCount);
|
||||
Assert.False(gate.HasPendingSnapshot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CurveChart_SplitsStableHistoryFromDynamicTail()
|
||||
{
|
||||
var points = CreateRealtimePoints(count: 10, step: TimeSpan.FromSeconds(1));
|
||||
var counts = StudyNoiseCurveChartControl.ResolveLayerSourceCounts(points, TimeSpan.FromSeconds(4));
|
||||
|
||||
Assert.Equal(5, StudyNoiseCurveChartControl.ResolveFirstTailIndex(points, TimeSpan.FromSeconds(4)));
|
||||
Assert.Equal(5, counts.StaticSourceCount);
|
||||
Assert.Equal(6, counts.DynamicSourceCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CurveChart_UsesStableLogicalTimeCoordinates()
|
||||
{
|
||||
var origin = new DateTimeOffset(2026, 5, 6, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
var x = StudyNoiseCurveChartControl.MapTimestampToLogicalX(
|
||||
origin.AddSeconds(3),
|
||||
origin,
|
||||
pixelsPerSecond: 12);
|
||||
|
||||
Assert.Equal(36, x);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DistributionAreaChart_BuildsAreaPathCache()
|
||||
{
|
||||
var points = CreateRealtimePoints(count: 24, step: TimeSpan.FromMilliseconds(500));
|
||||
var control = new StudyNoiseDistributionAreaChartControl();
|
||||
|
||||
control.UpdateSeries(points, baselineDb: 45);
|
||||
control.RebuildCacheForTesting(new Rect(1, 1, 320, 160));
|
||||
|
||||
Assert.True(control.CachedPathCount > 0);
|
||||
Assert.True(control.CachedPathCount <= 4);
|
||||
Assert.True(control.StaticSourceCount > 0);
|
||||
Assert.True(control.DynamicSourceCount > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DistributionAreaChart_UsesStableLogicalTimeCoordinates_WhenNewPointArrives()
|
||||
{
|
||||
var origin = new DateTimeOffset(2026, 5, 6, 12, 0, 0, TimeSpan.Zero);
|
||||
var oldPointTimestamp = origin.AddSeconds(3);
|
||||
|
||||
var before = StudyNoiseDistributionAreaChartControl.MapTimestampToLogicalX(
|
||||
oldPointTimestamp,
|
||||
origin,
|
||||
pixelsPerSecond: 20);
|
||||
var after = StudyNoiseDistributionAreaChartControl.MapTimestampToLogicalX(
|
||||
oldPointTimestamp,
|
||||
origin,
|
||||
pixelsPerSecond: 20);
|
||||
|
||||
Assert.Equal(before, after);
|
||||
Assert.Equal(60, after);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DistributionAreaChart_ReusesStaticAreaPath_WhenOnlyDynamicTailChanges()
|
||||
{
|
||||
var firstSeries = CreateRealtimePoints(
|
||||
new[]
|
||||
{
|
||||
(0d, 40d),
|
||||
(1d, 43d),
|
||||
(2d, 45d),
|
||||
(3d, 47d),
|
||||
(8d, 52d)
|
||||
});
|
||||
var secondSeries = CreateRealtimePoints(
|
||||
new[]
|
||||
{
|
||||
(0d, 40d),
|
||||
(1d, 43d),
|
||||
(2d, 45d),
|
||||
(3d, 47d),
|
||||
(8d, 52d),
|
||||
(8.05d, 54d)
|
||||
});
|
||||
var control = new StudyNoiseDistributionAreaChartControl();
|
||||
var plot = new Rect(1, 1, 320, 160);
|
||||
|
||||
control.UpdateSeries(firstSeries, baselineDb: 45);
|
||||
control.RebuildCacheForTesting(plot);
|
||||
var staticBuildVersion = control.StaticPathBuildVersion;
|
||||
|
||||
control.UpdateSeries(secondSeries, baselineDb: 45);
|
||||
control.RebuildCacheForTesting(plot);
|
||||
|
||||
Assert.Equal(staticBuildVersion, control.StaticPathBuildVersion);
|
||||
Assert.True(control.DynamicPathBuildVersion > 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DistributionAreaChart_SplitsStaticHistoryFromDynamicTail()
|
||||
{
|
||||
var points = CreateRealtimePoints(count: 10, step: TimeSpan.FromSeconds(1));
|
||||
var counts = StudyNoiseDistributionAreaChartControl.ResolveLayerSourceCounts(
|
||||
points,
|
||||
TimeSpan.FromSeconds(4));
|
||||
|
||||
Assert.Equal(5, counts.StaticSourceCount);
|
||||
Assert.Equal(6, counts.DynamicSourceCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DistributionAreaChart_StaticReportKeepsWholeSeriesStatic()
|
||||
{
|
||||
var points = CreateRealtimePoints(count: 10, step: TimeSpan.FromSeconds(1));
|
||||
var counts = StudyNoiseDistributionAreaChartControl.ResolveLayerSourceCounts(
|
||||
points,
|
||||
TimeSpan.FromSeconds(4),
|
||||
isStaticSeries: true);
|
||||
|
||||
Assert.Equal(10, counts.StaticSourceCount);
|
||||
Assert.Equal(0, counts.DynamicSourceCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DistributionAreaChart_ResolvesLevelsFromBaseline()
|
||||
{
|
||||
Assert.Equal(NoiseDistributionLevel.Quiet, StudyNoiseDistributionAreaChartControl.ResolveLevel(44.9, 45));
|
||||
Assert.Equal(NoiseDistributionLevel.Normal, StudyNoiseDistributionAreaChartControl.ResolveLevel(45, 45));
|
||||
Assert.Equal(NoiseDistributionLevel.Noisy, StudyNoiseDistributionAreaChartControl.ResolveLevel(55, 45));
|
||||
Assert.Equal(NoiseDistributionLevel.Extreme, StudyNoiseDistributionAreaChartControl.ResolveLevel(65, 45));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<NoiseRealtimePoint> CreateRealtimePoints(int count, TimeSpan step)
|
||||
{
|
||||
var start = new DateTimeOffset(2026, 5, 6, 12, 0, 0, TimeSpan.Zero);
|
||||
var points = new List<NoiseRealtimePoint>(count);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var displayDb = 38 + i;
|
||||
points.Add(new NoiseRealtimePoint(
|
||||
Timestamp: start + TimeSpan.FromTicks(step.Ticks * i),
|
||||
Rms: 0.2,
|
||||
Dbfs: -60 + i,
|
||||
DisplayDb: displayDb,
|
||||
Peak: 0.3,
|
||||
IsOverThreshold: displayDb > 50));
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<NoiseRealtimePoint> CreateRealtimePoints(IReadOnlyList<(double OffsetSeconds, double DisplayDb)> samples)
|
||||
{
|
||||
var start = new DateTimeOffset(2026, 5, 6, 12, 0, 0, TimeSpan.Zero);
|
||||
var points = new List<NoiseRealtimePoint>(samples.Count);
|
||||
for (var i = 0; i < samples.Count; i++)
|
||||
{
|
||||
var sample = samples[i];
|
||||
points.Add(new NoiseRealtimePoint(
|
||||
Timestamp: start + TimeSpan.FromSeconds(sample.OffsetSeconds),
|
||||
Rms: 0.2,
|
||||
Dbfs: -60 + i,
|
||||
DisplayDb: sample.DisplayDb,
|
||||
Peak: 0.3,
|
||||
IsOverThreshold: sample.DisplayDb > 50));
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
private static StudyAnalyticsSnapshot CreateSnapshot(string marker)
|
||||
{
|
||||
var config = new StudyAnalyticsConfig();
|
||||
var session = new StudySessionSnapshot(
|
||||
State: StudySessionRuntimeState.Idle,
|
||||
SessionId: null,
|
||||
Label: string.Empty,
|
||||
StartedAt: null,
|
||||
EndedAt: null,
|
||||
Elapsed: TimeSpan.Zero,
|
||||
Metrics: new StudySessionMetrics(
|
||||
CurrentScore: 0,
|
||||
AvgScore: 0,
|
||||
MinScore: 0,
|
||||
MaxScore: 0,
|
||||
WeightedOverRatioDbfs: 0,
|
||||
TotalSegmentCount: 0,
|
||||
EffectiveDuration: TimeSpan.Zero,
|
||||
SliceCount: 0),
|
||||
LastError: string.Empty);
|
||||
|
||||
return new StudyAnalyticsSnapshot(
|
||||
State: StudyAnalyticsRuntimeState.Ready,
|
||||
StreamStatus: NoiseStreamStatus.Initializing,
|
||||
DataMode: StudyDataMode.Realtime,
|
||||
Config: config,
|
||||
LatestRealtimePoint: null,
|
||||
LatestSlice: null,
|
||||
RealtimeBuffer: Array.Empty<NoiseRealtimePoint>(),
|
||||
Session: session,
|
||||
LastSessionReport: null,
|
||||
SelectedSessionReportId: null,
|
||||
SessionHistory: Array.Empty<StudySessionHistoryEntry>(),
|
||||
LastError: marker);
|
||||
}
|
||||
}
|
||||
404
LanMountainDesktop.Tests/UpdateSystemRegressionTests.cs
Normal file
404
LanMountainDesktop.Tests/UpdateSystemRegressionTests.cs
Normal file
@@ -0,0 +1,404 @@
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop;
|
||||
using LanMountainDesktop.Launcher;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Update;
|
||||
using LanMountainDesktop.Shared.Contracts.Update;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class UpdateEngineRollbackRegressionTests : IDisposable
|
||||
{
|
||||
private readonly UpdateTestDirectory _directory = new();
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyPlondsUpdate_KeepsPreviousDeploymentForManualRollback()
|
||||
{
|
||||
var current = _directory.CreateDeployment("1.0.0", "old-state", isCurrent: true);
|
||||
var newState = Encoding.UTF8.GetBytes("new-state");
|
||||
|
||||
_directory.StagePlondsUpdate("1.0.0", "1.1.0", newState, Sha256Hex(newState));
|
||||
|
||||
var service = new UpdateEngineService(new DeploymentLocator(_directory.AppRoot));
|
||||
var result = await service.ApplyPendingUpdateAsync();
|
||||
|
||||
Assert.True(result.Success, result.ErrorMessage);
|
||||
Assert.True(Directory.Exists(current));
|
||||
Assert.False(File.Exists(Path.Combine(current, ".current")));
|
||||
|
||||
var rollback = service.RollbackLatest();
|
||||
|
||||
Assert.True(rollback.Success, rollback.ErrorMessage);
|
||||
Assert.Equal("1.0.0", rollback.RolledBackTo);
|
||||
Assert.True(File.Exists(Path.Combine(current, ".current")));
|
||||
Assert.False(File.Exists(Path.Combine(current, ".destroy")));
|
||||
Assert.Equal("old-state", File.ReadAllText(Path.Combine(current, "state.txt")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyPlondsUpdate_WhenObjectHashMismatches_RollsBackToPreviousDeployment()
|
||||
{
|
||||
var current = _directory.CreateDeployment("1.0.0", "old-state", isCurrent: true);
|
||||
var newState = Encoding.UTF8.GetBytes("new-state");
|
||||
|
||||
_directory.StagePlondsUpdate("1.0.0", "1.1.0", newState, new string('0', 64));
|
||||
|
||||
var service = new UpdateEngineService(new DeploymentLocator(_directory.AppRoot));
|
||||
var result = await service.ApplyPendingUpdateAsync();
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("apply_failed", result.Code);
|
||||
Assert.Equal("1.0.0", result.RolledBackTo);
|
||||
Assert.True(File.Exists(Path.Combine(current, ".current")));
|
||||
Assert.False(File.Exists(Path.Combine(current, ".destroy")));
|
||||
Assert.Equal("old-state", File.ReadAllText(Path.Combine(current, "state.txt")));
|
||||
Assert.Empty(Directory.GetDirectories(_directory.AppRoot, "app-1.1.0-*"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollbackLatest_WhenSnapshotSourceDirectoryIsMissing_ReturnsStructuredFailure()
|
||||
{
|
||||
_directory.CreateDeployment("1.1.0", "new-state", isCurrent: true);
|
||||
_directory.WriteSnapshot(
|
||||
sourceVersion: "1.0.0",
|
||||
sourceDirectory: Path.Combine(_directory.AppRoot, "app-1.0.0-0"),
|
||||
targetVersion: "1.1.0",
|
||||
targetDirectory: Path.Combine(_directory.AppRoot, "app-1.1.0-0"));
|
||||
|
||||
var service = new UpdateEngineService(new DeploymentLocator(_directory.AppRoot));
|
||||
var result = service.RollbackLatest();
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("source_missing", result.Code);
|
||||
Assert.Contains("app-1.0.0-0", result.ErrorMessage);
|
||||
}
|
||||
|
||||
public void Dispose() => _directory.Dispose();
|
||||
|
||||
private static string Sha256Hex(byte[] bytes)
|
||||
{
|
||||
return Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private sealed class UpdateTestDirectory : IDisposable
|
||||
{
|
||||
private readonly string _root;
|
||||
private readonly RSA _rsa = RSA.Create(2048);
|
||||
|
||||
public UpdateTestDirectory()
|
||||
{
|
||||
_root = Path.Combine(Path.GetTempPath(), "LanMountainDesktop.UpdateRegression", Guid.NewGuid().ToString("N"));
|
||||
AppRoot = Path.Combine(_root, "app-root");
|
||||
Directory.CreateDirectory(AppRoot);
|
||||
|
||||
var resolver = new DataLocationResolver(AppRoot);
|
||||
LauncherRoot = resolver.ResolveLauncherDataPath();
|
||||
IncomingRoot = Path.Combine(LauncherRoot, "update", "incoming");
|
||||
SnapshotsRoot = Path.Combine(LauncherRoot, "snapshots");
|
||||
|
||||
Directory.CreateDirectory(Path.Combine(LauncherRoot, "update"));
|
||||
File.WriteAllText(Path.Combine(LauncherRoot, "update", "public-key.pem"), _rsa.ExportSubjectPublicKeyInfoPem());
|
||||
}
|
||||
|
||||
public string AppRoot { get; }
|
||||
|
||||
private string LauncherRoot { get; }
|
||||
|
||||
private string IncomingRoot { get; }
|
||||
|
||||
private string SnapshotsRoot { get; }
|
||||
|
||||
public string CreateDeployment(string version, string state, bool isCurrent)
|
||||
{
|
||||
var deployment = Path.Combine(AppRoot, $"app-{version}-0");
|
||||
Directory.CreateDirectory(deployment);
|
||||
File.WriteAllText(Path.Combine(deployment, ExecutableName), $"exe-{version}");
|
||||
File.WriteAllText(Path.Combine(deployment, "state.txt"), state);
|
||||
|
||||
if (isCurrent)
|
||||
{
|
||||
File.WriteAllText(Path.Combine(deployment, ".current"), string.Empty);
|
||||
}
|
||||
|
||||
return deployment;
|
||||
}
|
||||
|
||||
public void StagePlondsUpdate(string fromVersion, string toVersion, byte[] statePayload, string expectedStateSha256)
|
||||
{
|
||||
Directory.CreateDirectory(IncomingRoot);
|
||||
var objectsRoot = Path.Combine(IncomingRoot, "objects");
|
||||
Directory.CreateDirectory(objectsRoot);
|
||||
|
||||
var objectHash = Convert.ToHexString(SHA256.HashData(statePayload)).ToLowerInvariant();
|
||||
File.WriteAllBytes(Path.Combine(objectsRoot, objectHash), statePayload);
|
||||
|
||||
var currentExecutable = Path.Combine(AppRoot, $"app-{fromVersion}-0", ExecutableName);
|
||||
var fileMap = new PlondsFileMap
|
||||
{
|
||||
DistributionId = $"stable-{PlondsStaticUpdateService.ResolveCurrentPlatform()}-{toVersion}",
|
||||
FromVersion = fromVersion,
|
||||
ToVersion = toVersion,
|
||||
Platform = PlondsStaticUpdateService.ResolveCurrentPlatform(),
|
||||
Files =
|
||||
[
|
||||
new PlondsFileEntry
|
||||
{
|
||||
Path = ExecutableName,
|
||||
Action = "reuse",
|
||||
Sha256 = Sha256File(currentExecutable)
|
||||
},
|
||||
new PlondsFileEntry
|
||||
{
|
||||
Path = "state.txt",
|
||||
Action = "replace",
|
||||
Sha256 = expectedStateSha256,
|
||||
ObjectUrl = $"https://static.example/lanmountain/update/repo/sha256/{objectHash[..2]}/{objectHash}"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var fileMapPath = Path.Combine(IncomingRoot, "plonds-filemap.json");
|
||||
File.WriteAllText(fileMapPath, JsonSerializer.Serialize(fileMap, AppJsonContext.Default.PlondsFileMap));
|
||||
Sign(fileMapPath, Path.Combine(IncomingRoot, "plonds-filemap.sig"));
|
||||
}
|
||||
|
||||
public void WriteSnapshot(string sourceVersion, string sourceDirectory, string targetVersion, string targetDirectory)
|
||||
{
|
||||
Directory.CreateDirectory(SnapshotsRoot);
|
||||
var snapshot = new SnapshotMetadata
|
||||
{
|
||||
SnapshotId = Guid.NewGuid().ToString("N"),
|
||||
SourceVersion = sourceVersion,
|
||||
TargetVersion = targetVersion,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
SourceDirectory = sourceDirectory,
|
||||
TargetDirectory = targetDirectory,
|
||||
Status = "applied"
|
||||
};
|
||||
|
||||
File.WriteAllText(
|
||||
Path.Combine(SnapshotsRoot, $"{snapshot.SnapshotId}.json"),
|
||||
JsonSerializer.Serialize(snapshot, AppJsonContext.Default.SnapshotMetadata));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_rsa.Dispose();
|
||||
if (Directory.Exists(_root))
|
||||
{
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private void Sign(string payloadPath, string signaturePath)
|
||||
{
|
||||
var signature = _rsa.SignData(File.ReadAllBytes(payloadPath), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
File.WriteAllText(signaturePath, Convert.ToBase64String(signature));
|
||||
}
|
||||
|
||||
private static string Sha256File(string path)
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string ExecutableName => OperatingSystem.IsWindows()
|
||||
? "LanMountainDesktop.exe"
|
||||
: "LanMountainDesktop";
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PlondsStaticUpdateServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CheckForUpdatesAsync_ReadsStaticLatestDistributionAndBuildsPayloadUrls()
|
||||
{
|
||||
var platform = PlondsStaticUpdateService.ResolveCurrentPlatform();
|
||||
var handler = new StaticManifestHandler(request =>
|
||||
{
|
||||
var path = request.RequestUri?.AbsolutePath ?? string.Empty;
|
||||
if (path.EndsWith($"/meta/channels/stable/{platform}/latest.json", StringComparison.Ordinal))
|
||||
{
|
||||
return Json("""{"distributionId":"dist-1","version":"1.2.0","channel":"stable","platform":"PLATFORM","publishedAt":"2026-05-06T00:00:00Z"}"""
|
||||
.Replace("PLATFORM", platform));
|
||||
}
|
||||
|
||||
if (path.EndsWith("/meta/distributions/dist-1.json", StringComparison.Ordinal))
|
||||
{
|
||||
return Json("""{"distributionId":"dist-1","version":"1.2.0","sourceVersion":"1.0.0","channel":"stable","platform":"PLATFORM","publishedAt":"2026-05-06T00:00:00Z","fileMapUrl":"https://static.example/lanmountain/update/manifests/dist-1/plonds-filemap.json","fileMapSignatureUrl":"https://static.example/lanmountain/update/manifests/dist-1/plonds-filemap.json.sig"}"""
|
||||
.Replace("PLATFORM", platform));
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.NotFound);
|
||||
});
|
||||
|
||||
using var client = new HttpClient(handler);
|
||||
using var service = new PlondsStaticUpdateService("https://static.example/lanmountain/update", client);
|
||||
|
||||
var result = await service.CheckForUpdatesAsync(new Version(1, 0, 0), includePrerelease: false);
|
||||
|
||||
Assert.True(result.Success, result.ErrorMessage);
|
||||
Assert.True(result.IsUpdateAvailable);
|
||||
Assert.Equal("1.2.0", result.LatestVersionText);
|
||||
Assert.NotNull(result.PlondsPayload);
|
||||
Assert.Equal("dist-1", result.PlondsPayload.DistributionId);
|
||||
Assert.Equal(platform, result.PlondsPayload.SubChannel);
|
||||
Assert.Equal("https://static.example/lanmountain/update/manifests/dist-1/plonds-filemap.json", result.PlondsPayload.FileMapJsonUrl);
|
||||
Assert.Equal("https://static.example/lanmountain/update/manifests/dist-1/plonds-filemap.json.sig", result.PlondsPayload.FileMapSignatureUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckForUpdatesAsync_WhenLatestIsMissing_ReturnsFailureForFallback()
|
||||
{
|
||||
using var client = new HttpClient(new StaticManifestHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound)));
|
||||
using var service = new PlondsStaticUpdateService("https://static.example/lanmountain/update", client);
|
||||
|
||||
var result = await service.CheckForUpdatesAsync(new Version(1, 0, 0), includePrerelease: false);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.False(result.IsUpdateAvailable);
|
||||
Assert.Contains("latest manifest", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveCurrentPlatform_UsesCanonicalNames()
|
||||
{
|
||||
var platform = PlondsStaticUpdateService.ResolveCurrentPlatform();
|
||||
|
||||
Assert.DoesNotContain("win-", platform, StringComparison.OrdinalIgnoreCase);
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
Assert.StartsWith("windows-", platform, StringComparison.Ordinal);
|
||||
}
|
||||
else if (OperatingSystem.IsLinux())
|
||||
{
|
||||
Assert.StartsWith("linux-", platform, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Json(string json)
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class StaticManifestHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(responder(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UpdatePathConsistencyTests
|
||||
{
|
||||
[Fact]
|
||||
public void HostAndSharedUpdatePathsUseLauncherDirectoryCasing()
|
||||
{
|
||||
var incoming = UpdateWorkflowService.GetLauncherIncomingDirectory();
|
||||
var sharedIncoming = UpdatePaths.GetIncomingDirectory("root");
|
||||
|
||||
Assert.Contains($"{Path.DirectorySeparatorChar}.Launcher{Path.DirectorySeparatorChar}", incoming);
|
||||
Assert.Equal(
|
||||
Path.Combine("root", ".Launcher", "update", "incoming"),
|
||||
sharedIncoming);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PlondsApiManifestProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetLatestAsync_MapsCanonicalAndLegacyFileFields()
|
||||
{
|
||||
using var client = new HttpClient(new StaticManifestHandler(request =>
|
||||
{
|
||||
var path = request.RequestUri?.AbsolutePath ?? string.Empty;
|
||||
if (path.EndsWith("/api/plonds/v1/channels/stable/windows-x64/latest", StringComparison.Ordinal))
|
||||
{
|
||||
return Json("""{"distributionId":"dist-2","version":"1.2.0","publishedAt":"2026-05-06T00:00:00Z"}""");
|
||||
}
|
||||
|
||||
if (path.EndsWith("/api/plonds/v1/distributions/dist-2", StringComparison.Ordinal))
|
||||
{
|
||||
return Json("""
|
||||
{
|
||||
"distributionId": "dist-2",
|
||||
"version": "1.2.0",
|
||||
"sourceVersion": "1.1.0",
|
||||
"publishedAt": "2026-05-06T00:00:00Z",
|
||||
"fileMapUrl": "https://static.example/filemap.json",
|
||||
"signatures": [{ "signature": "https://static.example/filemap.json.sig" }],
|
||||
"components": [
|
||||
{
|
||||
"files": [
|
||||
{
|
||||
"path": "LanMountainDesktop.exe",
|
||||
"action": "replace",
|
||||
"sha256": "abc123",
|
||||
"size": 42,
|
||||
"objectUrl": "https://static.example/repo/sha256/ab/abc123",
|
||||
"archiveSha256": "archive123"
|
||||
},
|
||||
{
|
||||
"path": "legacy.dll",
|
||||
"op": "add",
|
||||
"contentHash": "def456",
|
||||
"size": 7
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.NotFound);
|
||||
}));
|
||||
var provider = new PlondsApiManifestProvider("https://static.example", client);
|
||||
|
||||
var manifest = await provider.GetLatestAsync("stable", "windows-x64", new Version(1, 1, 0), CancellationToken.None);
|
||||
|
||||
Assert.NotNull(manifest);
|
||||
Assert.Equal(UpdatePayloadKind.DeltaPlonds, manifest.Kind);
|
||||
Assert.Equal("https://static.example/filemap.json.sig", manifest.FileMapSignatureUrl);
|
||||
Assert.Collection(
|
||||
manifest.Files,
|
||||
first =>
|
||||
{
|
||||
Assert.Equal("replace", first.Action);
|
||||
Assert.Equal("abc123", first.Sha256);
|
||||
Assert.Equal("https://static.example/repo/sha256/ab/abc123", first.ObjectUrl);
|
||||
Assert.Equal("archive123", first.ArchiveSha256);
|
||||
},
|
||||
second =>
|
||||
{
|
||||
Assert.Equal("add", second.Action);
|
||||
Assert.Equal("def456", second.Sha256);
|
||||
});
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Json(string json)
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class StaticManifestHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(responder(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1074,7 +1074,10 @@ public partial class App : Application
|
||||
(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase) &&
|
||||
(changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperPath), StringComparer.OrdinalIgnoreCase) ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperType), StringComparer.OrdinalIgnoreCase) ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), StringComparer.OrdinalIgnoreCase)));
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), StringComparer.OrdinalIgnoreCase) ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeWallpaperColorSource), StringComparer.OrdinalIgnoreCase) ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.UseNativeWallpaperChangeEvents), StringComparer.OrdinalIgnoreCase) ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.SystemWallpaperRefreshIntervalSeconds), StringComparer.OrdinalIgnoreCase)));
|
||||
var languageChanged =
|
||||
refreshAll ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.LanguageCode), StringComparer.OrdinalIgnoreCase);
|
||||
@@ -1881,4 +1884,3 @@ public partial class App : Application
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,10 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
public string? SelectedWallpaperSeed { get; set; }
|
||||
|
||||
public string ThemeWallpaperColorSource { get; set; } = "auto";
|
||||
|
||||
public bool UseNativeWallpaperChangeEvents { get; set; } = true;
|
||||
|
||||
public string ThemeMode { get; set; } = "light";
|
||||
|
||||
public string? WallpaperPath { get; set; }
|
||||
|
||||
75
LanMountainDesktop/Models/MaterialColorModels.cs
Normal file
75
LanMountainDesktop/Models/MaterialColorModels.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
using System.Collections.Generic;
|
||||
using Avalonia.Media;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Shared.Contracts;
|
||||
|
||||
namespace LanMountainDesktop.Models;
|
||||
|
||||
public enum MaterialColorSourceKind
|
||||
{
|
||||
Neutral = 0,
|
||||
CustomSeed = 1,
|
||||
WallpaperAuto = 2,
|
||||
AppWallpaper = 3,
|
||||
SystemWallpaper = 4,
|
||||
Fallback = 5
|
||||
}
|
||||
|
||||
public sealed record MaterialColorPalette(
|
||||
Color Primary,
|
||||
Color Secondary,
|
||||
Color Accent,
|
||||
Color OnAccent,
|
||||
Color AccentLight1,
|
||||
Color AccentLight2,
|
||||
Color AccentLight3,
|
||||
Color AccentDark1,
|
||||
Color AccentDark2,
|
||||
Color AccentDark3,
|
||||
Color SurfaceBase,
|
||||
Color SurfaceRaised,
|
||||
Color SurfaceOverlay,
|
||||
Color TextPrimary,
|
||||
Color TextSecondary,
|
||||
Color TextMuted,
|
||||
Color TextAccent,
|
||||
Color NavText,
|
||||
Color NavSelectedText,
|
||||
Color NavSelectionIndicator,
|
||||
Color NavItemBackground,
|
||||
Color NavItemHoverBackground,
|
||||
Color NavItemSelectedBackground,
|
||||
Color ToggleOn,
|
||||
Color ToggleOff,
|
||||
Color ToggleBorder);
|
||||
|
||||
public sealed record MaterialSurfaceSnapshot(
|
||||
MaterialSurfaceRole Role,
|
||||
Color BackgroundColor,
|
||||
Color BorderColor,
|
||||
double BlurRadius,
|
||||
double Opacity);
|
||||
|
||||
public sealed record MaterialColorSnapshot(
|
||||
bool IsNightMode,
|
||||
string ThemeColorMode,
|
||||
string ThemeWallpaperColorSource,
|
||||
MaterialColorSourceKind ColorSourceKind,
|
||||
string ResolvedSeedSource,
|
||||
AppearanceCornerRadiusTokens CornerRadiusTokens,
|
||||
string? UserThemeColor,
|
||||
string? SelectedWallpaperSeed,
|
||||
Color EffectiveSeedColor,
|
||||
Color AccentColor,
|
||||
MonetPalette MonetPalette,
|
||||
MaterialColorPalette Palette,
|
||||
IReadOnlyList<Color> WallpaperSeedCandidates,
|
||||
string SystemMaterialMode,
|
||||
IReadOnlyList<string> AvailableSystemMaterialModes,
|
||||
bool CanChangeSystemMaterial,
|
||||
bool UseSystemChrome,
|
||||
string? ResolvedWallpaperPath,
|
||||
bool UseNativeWallpaperChangeEvents,
|
||||
bool NativeWallpaperChangeEventsActive,
|
||||
bool WallpaperPollingActive,
|
||||
IReadOnlyDictionary<MaterialSurfaceRole, MaterialSurfaceSnapshot> Surfaces);
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
@@ -55,7 +56,9 @@ public sealed record AppearanceThemeSnapshot(
|
||||
IReadOnlyList<string> AvailableSystemMaterialModes,
|
||||
bool CanChangeSystemMaterial,
|
||||
bool UseSystemChrome,
|
||||
string? ResolvedWallpaperPath);
|
||||
string? ResolvedWallpaperPath,
|
||||
string ThemeWallpaperColorSource = ThemeAppearanceValues.WallpaperColorSourceAuto,
|
||||
bool UseNativeWallpaperChangeEvents = true);
|
||||
|
||||
public interface IAppearanceThemeService
|
||||
{
|
||||
@@ -465,7 +468,7 @@ internal sealed class MaterialSurfaceService : IMaterialSurfaceService
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposable
|
||||
internal sealed class AppearanceThemeService : IAppearanceThemeService, IMaterialColorService, IDisposable
|
||||
{
|
||||
private static readonly Color DefaultAccentColor = Color.Parse("#FF3B82F6");
|
||||
private static readonly Color NeutralFallbackSeedColor = Color.Parse("#FF8A8A8A");
|
||||
@@ -477,9 +480,15 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
|
||||
private string _liveThemeColorMode;
|
||||
private string _liveSystemMaterialMode;
|
||||
private string? _liveSelectedWallpaperSeed;
|
||||
private string _liveThemeWallpaperColorSource;
|
||||
private bool _liveUseNativeWallpaperChangeEvents;
|
||||
private readonly object _paletteGate = new();
|
||||
private readonly Dictionary<string, WallpaperSeedExtractionResult> _wallpaperSeedCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _pendingWallpaperSeedKeys = new(StringComparer.OrdinalIgnoreCase);
|
||||
private Timer? _systemWallpaperPollTimer;
|
||||
private string? _lastObservedWallpaperSourceKey;
|
||||
private bool _nativeWallpaperEventsActive;
|
||||
private bool _wallpaperPollingActive;
|
||||
|
||||
public AppearanceThemeService(
|
||||
ISettingsFacadeService settingsFacade,
|
||||
@@ -497,11 +506,16 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
|
||||
initialThemeState.ThemeColor);
|
||||
_liveSystemMaterialMode = ResolveSupportedMaterialMode(initialThemeState.SystemMaterialMode);
|
||||
_liveSelectedWallpaperSeed = initialThemeState.SelectedWallpaperSeed;
|
||||
_liveThemeWallpaperColorSource = ThemeAppearanceValues.NormalizeWallpaperColorSource(initialThemeState.ThemeWallpaperColorSource);
|
||||
_liveUseNativeWallpaperChangeEvents = initialThemeState.UseNativeWallpaperChangeEvents;
|
||||
_settingsFacade.Settings.Changed += OnSettingsChanged;
|
||||
ConfigureSystemWallpaperMonitoring(initialThemeState);
|
||||
}
|
||||
|
||||
public event EventHandler<AppearanceThemeSnapshot>? Changed;
|
||||
|
||||
public event EventHandler<MaterialColorSnapshot>? MaterialColorChanged;
|
||||
|
||||
public AppearanceThemeSnapshot GetCurrent()
|
||||
{
|
||||
return BuildCurrentSnapshot(queueWallpaperPaletteBuild: true);
|
||||
@@ -527,6 +541,39 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
|
||||
queueWallpaperPaletteBuild: true);
|
||||
}
|
||||
|
||||
public MaterialColorSnapshot GetMaterialColorSnapshot()
|
||||
{
|
||||
return CreateMaterialColorSnapshot(GetCurrent());
|
||||
}
|
||||
|
||||
public MaterialColorSnapshot BuildMaterialColorPreview(ThemeAppearanceSettingsState pendingState)
|
||||
{
|
||||
return CreateMaterialColorSnapshot(BuildPreview(pendingState));
|
||||
}
|
||||
|
||||
public MaterialSurfaceSnapshot GetSurface(MaterialSurfaceRole role)
|
||||
{
|
||||
var surface = GetMaterialSurface(role);
|
||||
return new MaterialSurfaceSnapshot(
|
||||
role,
|
||||
surface.BackgroundColor,
|
||||
surface.BorderColor,
|
||||
surface.BlurRadius,
|
||||
surface.Opacity);
|
||||
}
|
||||
|
||||
public void RefreshWallpaperColors()
|
||||
{
|
||||
lock (_paletteGate)
|
||||
{
|
||||
_wallpaperSeedCache.Clear();
|
||||
_pendingWallpaperSeedKeys.Clear();
|
||||
_lastObservedWallpaperSourceKey = null;
|
||||
}
|
||||
|
||||
RaiseChanged(queueWallpaperPaletteBuild: true);
|
||||
}
|
||||
|
||||
public void ApplyThemeResources(IResourceDictionary resources)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(resources);
|
||||
@@ -582,6 +629,9 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
|
||||
public void Dispose()
|
||||
{
|
||||
_settingsFacade.Settings.Changed -= OnSettingsChanged;
|
||||
StopSystemWallpaperMonitoring();
|
||||
_systemWallpaperPollTimer?.Dispose();
|
||||
_systemWallpaperPollTimer = null;
|
||||
}
|
||||
|
||||
private AppearanceThemeSnapshot BuildCurrentSnapshot(bool queueWallpaperPaletteBuild)
|
||||
@@ -622,6 +672,9 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
|
||||
!changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColorMode), StringComparer.OrdinalIgnoreCase) &&
|
||||
!changedKeys.Contains(nameof(AppSettingsSnapshot.SystemMaterialMode), StringComparer.OrdinalIgnoreCase) &&
|
||||
!changedKeys.Contains(nameof(AppSettingsSnapshot.SelectedWallpaperSeed), StringComparer.OrdinalIgnoreCase) &&
|
||||
!changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeWallpaperColorSource), StringComparer.OrdinalIgnoreCase) &&
|
||||
!changedKeys.Contains(nameof(AppSettingsSnapshot.UseNativeWallpaperChangeEvents), StringComparer.OrdinalIgnoreCase) &&
|
||||
!changedKeys.Contains(nameof(AppSettingsSnapshot.SystemWallpaperRefreshIntervalSeconds), StringComparer.OrdinalIgnoreCase) &&
|
||||
!(respondsToThemeColor &&
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) &&
|
||||
!(respondsToWallpaper &&
|
||||
@@ -638,6 +691,9 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
|
||||
latestThemeState.ThemeColor);
|
||||
_liveSystemMaterialMode = ResolveSupportedMaterialMode(latestThemeState.SystemMaterialMode);
|
||||
_liveSelectedWallpaperSeed = latestThemeState.SelectedWallpaperSeed;
|
||||
_liveThemeWallpaperColorSource = ThemeAppearanceValues.NormalizeWallpaperColorSource(latestThemeState.ThemeWallpaperColorSource);
|
||||
_liveUseNativeWallpaperChangeEvents = latestThemeState.UseNativeWallpaperChangeEvents;
|
||||
ConfigureSystemWallpaperMonitoring(latestThemeState);
|
||||
RaiseChanged(queueWallpaperPaletteBuild: true);
|
||||
}
|
||||
|
||||
@@ -663,6 +719,7 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
|
||||
var wallpaperResolution = ResolveWallpaperPalette(
|
||||
themeState.IsNightMode,
|
||||
wallpaperState,
|
||||
ThemeAppearanceValues.NormalizeWallpaperColorSource(themeState.ThemeWallpaperColorSource),
|
||||
selectedWallpaperSeed,
|
||||
queueWallpaperPaletteBuild);
|
||||
palette = wallpaperResolution.Palette;
|
||||
@@ -701,7 +758,9 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
|
||||
availableModes,
|
||||
_windowMaterialService.CanChangeMode,
|
||||
themeState.UseSystemChrome,
|
||||
resolvedWallpaperPath);
|
||||
resolvedWallpaperPath,
|
||||
ThemeAppearanceValues.NormalizeWallpaperColorSource(themeState.ThemeWallpaperColorSource),
|
||||
themeState.UseNativeWallpaperChangeEvents);
|
||||
}
|
||||
|
||||
private ThemeColorContext CreateThemeContext(AppearanceThemeSnapshot snapshot)
|
||||
@@ -729,10 +788,11 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
|
||||
private WallpaperPaletteResolution ResolveWallpaperPalette(
|
||||
bool nightMode,
|
||||
WallpaperSettingsState wallpaperState,
|
||||
string wallpaperColorSource,
|
||||
string? selectedWallpaperSeed,
|
||||
bool queueWallpaperPaletteBuild)
|
||||
{
|
||||
var source = ResolveWallpaperSeedSource(wallpaperState);
|
||||
var source = ResolveWallpaperSeedSource(wallpaperState, wallpaperColorSource);
|
||||
if (string.Equals(source.SourceKind, "fallback", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return BuildFallbackWallpaperPaletteResolution(nightMode, source.ResolvedWallpaperPath);
|
||||
@@ -935,9 +995,14 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
|
||||
}
|
||||
}
|
||||
|
||||
private WallpaperSeedSourceDescriptor ResolveWallpaperSeedSource(WallpaperSettingsState wallpaperState)
|
||||
private WallpaperSeedSourceDescriptor ResolveWallpaperSeedSource(
|
||||
WallpaperSettingsState wallpaperState,
|
||||
string wallpaperColorSource)
|
||||
{
|
||||
if (string.Equals(wallpaperState.Type, "SolidColor", StringComparison.OrdinalIgnoreCase) &&
|
||||
var normalizedWallpaperColorSource = ThemeAppearanceValues.NormalizeWallpaperColorSource(wallpaperColorSource);
|
||||
|
||||
if (normalizedWallpaperColorSource != ThemeAppearanceValues.WallpaperColorSourceSystem &&
|
||||
string.Equals(wallpaperState.Type, "SolidColor", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.IsNullOrWhiteSpace(wallpaperState.Color) &&
|
||||
Color.TryParse(wallpaperState.Color, out var solidColor))
|
||||
{
|
||||
@@ -954,7 +1019,9 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
|
||||
? null
|
||||
: wallpaperState.WallpaperPath.Trim();
|
||||
var appWallpaperMediaType = _settingsFacade.WallpaperMedia.DetectMediaType(wallpaperPath);
|
||||
if (!string.IsNullOrWhiteSpace(wallpaperPath) && File.Exists(wallpaperPath))
|
||||
if (normalizedWallpaperColorSource != ThemeAppearanceValues.WallpaperColorSourceSystem &&
|
||||
!string.IsNullOrWhiteSpace(wallpaperPath) &&
|
||||
File.Exists(wallpaperPath))
|
||||
{
|
||||
if (appWallpaperMediaType == WallpaperMediaType.Image)
|
||||
{
|
||||
@@ -967,8 +1034,19 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedWallpaperColorSource == ThemeAppearanceValues.WallpaperColorSourceApp)
|
||||
{
|
||||
return new WallpaperSeedSourceDescriptor(
|
||||
"fallback",
|
||||
"fallback",
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
|
||||
var systemWallpaper = _systemWallpaperService.GetWallpaperPath();
|
||||
if (!string.IsNullOrWhiteSpace(systemWallpaper) &&
|
||||
if (normalizedWallpaperColorSource != ThemeAppearanceValues.WallpaperColorSourceApp &&
|
||||
!string.IsNullOrWhiteSpace(systemWallpaper) &&
|
||||
File.Exists(systemWallpaper) &&
|
||||
_settingsFacade.WallpaperMedia.DetectMediaType(systemWallpaper) == WallpaperMediaType.Image)
|
||||
{
|
||||
@@ -991,13 +1069,269 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
|
||||
private void RaiseChanged(bool queueWallpaperPaletteBuild)
|
||||
{
|
||||
var snapshot = BuildCurrentSnapshot(queueWallpaperPaletteBuild);
|
||||
var materialSnapshot = CreateMaterialColorSnapshot(snapshot);
|
||||
if (Dispatcher.UIThread.CheckAccess())
|
||||
{
|
||||
Changed?.Invoke(this, snapshot);
|
||||
MaterialColorChanged?.Invoke(this, materialSnapshot);
|
||||
return;
|
||||
}
|
||||
|
||||
Dispatcher.UIThread.Post(() => Changed?.Invoke(this, snapshot), DispatcherPriority.Background);
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
Changed?.Invoke(this, snapshot);
|
||||
MaterialColorChanged?.Invoke(this, materialSnapshot);
|
||||
}, DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
private MaterialColorSnapshot CreateMaterialColorSnapshot(AppearanceThemeSnapshot snapshot)
|
||||
{
|
||||
var context = CreateThemeContext(snapshot);
|
||||
var appPalette = ThemeColorSystemService.BuildPalette(context);
|
||||
var palette = new LanMountainDesktop.Models.MaterialColorPalette(
|
||||
appPalette.Primary,
|
||||
appPalette.Secondary,
|
||||
appPalette.Accent,
|
||||
appPalette.OnAccent,
|
||||
appPalette.AccentLight1,
|
||||
appPalette.AccentLight2,
|
||||
appPalette.AccentLight3,
|
||||
appPalette.AccentDark1,
|
||||
appPalette.AccentDark2,
|
||||
appPalette.AccentDark3,
|
||||
appPalette.SurfaceBase,
|
||||
appPalette.SurfaceRaised,
|
||||
appPalette.SurfaceOverlay,
|
||||
appPalette.TextPrimary,
|
||||
appPalette.TextSecondary,
|
||||
appPalette.TextMuted,
|
||||
appPalette.TextAccent,
|
||||
appPalette.NavText,
|
||||
appPalette.NavSelectedText,
|
||||
appPalette.NavSelectionIndicator,
|
||||
appPalette.NavItemBackground,
|
||||
appPalette.NavItemHoverBackground,
|
||||
appPalette.NavItemSelectedBackground,
|
||||
appPalette.ToggleOn,
|
||||
appPalette.ToggleOff,
|
||||
appPalette.ToggleBorder);
|
||||
var surfaces = Enum.GetValues<MaterialSurfaceRole>()
|
||||
.Select(role =>
|
||||
{
|
||||
var surface = _materialSurfaceService.GetSurface(context, role);
|
||||
return new MaterialSurfaceSnapshot(
|
||||
role,
|
||||
surface.BackgroundColor,
|
||||
surface.BorderColor,
|
||||
surface.BlurRadius,
|
||||
surface.Opacity);
|
||||
})
|
||||
.ToDictionary(surface => surface.Role);
|
||||
|
||||
return new MaterialColorSnapshot(
|
||||
snapshot.IsNightMode,
|
||||
snapshot.ThemeColorMode,
|
||||
snapshot.ThemeWallpaperColorSource,
|
||||
ResolveMaterialColorSourceKind(snapshot),
|
||||
snapshot.ResolvedSeedSource,
|
||||
snapshot.CornerRadiusTokens,
|
||||
snapshot.UserThemeColor,
|
||||
snapshot.SelectedWallpaperSeed,
|
||||
snapshot.EffectiveSeedColor,
|
||||
snapshot.AccentColor,
|
||||
snapshot.MonetPalette,
|
||||
palette,
|
||||
snapshot.WallpaperSeedCandidates,
|
||||
snapshot.SystemMaterialMode,
|
||||
snapshot.AvailableSystemMaterialModes,
|
||||
snapshot.CanChangeSystemMaterial,
|
||||
snapshot.UseSystemChrome,
|
||||
snapshot.ResolvedWallpaperPath,
|
||||
snapshot.UseNativeWallpaperChangeEvents,
|
||||
_nativeWallpaperEventsActive,
|
||||
_wallpaperPollingActive,
|
||||
surfaces);
|
||||
}
|
||||
|
||||
private static MaterialColorSourceKind ResolveMaterialColorSourceKind(AppearanceThemeSnapshot snapshot)
|
||||
{
|
||||
if (string.Equals(snapshot.ThemeColorMode, ThemeAppearanceValues.ColorModeDefaultNeutral, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return MaterialColorSourceKind.Neutral;
|
||||
}
|
||||
|
||||
if (string.Equals(snapshot.ThemeColorMode, ThemeAppearanceValues.ColorModeSeedMonet, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return MaterialColorSourceKind.CustomSeed;
|
||||
}
|
||||
|
||||
if (!string.Equals(snapshot.ThemeColorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return MaterialColorSourceKind.Fallback;
|
||||
}
|
||||
|
||||
if (string.Equals(snapshot.ResolvedSeedSource, "app_wallpaper", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(snapshot.ResolvedSeedSource, "app_solid", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return string.Equals(snapshot.ThemeWallpaperColorSource, ThemeAppearanceValues.WallpaperColorSourceApp, StringComparison.OrdinalIgnoreCase)
|
||||
? MaterialColorSourceKind.AppWallpaper
|
||||
: MaterialColorSourceKind.WallpaperAuto;
|
||||
}
|
||||
|
||||
if (string.Equals(snapshot.ResolvedSeedSource, "system_wallpaper", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return string.Equals(snapshot.ThemeWallpaperColorSource, ThemeAppearanceValues.WallpaperColorSourceSystem, StringComparison.OrdinalIgnoreCase)
|
||||
? MaterialColorSourceKind.SystemWallpaper
|
||||
: MaterialColorSourceKind.WallpaperAuto;
|
||||
}
|
||||
|
||||
return MaterialColorSourceKind.Fallback;
|
||||
}
|
||||
|
||||
private void ConfigureSystemWallpaperMonitoring(ThemeAppearanceSettingsState themeState)
|
||||
{
|
||||
var colorMode = ThemeAppearanceValues.NormalizeThemeColorMode(themeState.ThemeColorMode, themeState.ThemeColor);
|
||||
var wallpaperColorSource = ThemeAppearanceValues.NormalizeWallpaperColorSource(themeState.ThemeWallpaperColorSource);
|
||||
var shouldMonitor =
|
||||
string.Equals(colorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(wallpaperColorSource, ThemeAppearanceValues.WallpaperColorSourceApp, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!shouldMonitor)
|
||||
{
|
||||
StopSystemWallpaperMonitoring();
|
||||
return;
|
||||
}
|
||||
|
||||
ConfigureNativeWallpaperEvents(themeState.UseNativeWallpaperChangeEvents);
|
||||
ConfigureWallpaperPolling(_settingsFacade.Wallpaper.Get().SystemWallpaperRefreshIntervalSeconds);
|
||||
UpdateObservedWallpaperSourceKey();
|
||||
}
|
||||
|
||||
private void ConfigureNativeWallpaperEvents(bool enabled)
|
||||
{
|
||||
if (!enabled || !OperatingSystem.IsWindows())
|
||||
{
|
||||
UnregisterNativeWallpaperEvents();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_nativeWallpaperEventsActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
RegisterNativeWallpaperEvents();
|
||||
}
|
||||
|
||||
private void UnregisterNativeWallpaperEvents()
|
||||
{
|
||||
if (!_nativeWallpaperEventsActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
UnregisterNativeWallpaperEventsCore();
|
||||
}
|
||||
|
||||
_nativeWallpaperEventsActive = false;
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private void RegisterNativeWallpaperEvents()
|
||||
{
|
||||
try
|
||||
{
|
||||
SystemEvents.UserPreferenceChanged += OnNativeWallpaperPreferenceChanged;
|
||||
_nativeWallpaperEventsActive = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_nativeWallpaperEventsActive = false;
|
||||
AppLogger.Warn("Appearance.WallpaperMonitor", "Failed to subscribe to native wallpaper change events; polling will remain active.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private void UnregisterNativeWallpaperEventsCore()
|
||||
{
|
||||
try
|
||||
{
|
||||
SystemEvents.UserPreferenceChanged -= OnNativeWallpaperPreferenceChanged;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore shutdown-time native event cleanup failures.
|
||||
}
|
||||
}
|
||||
|
||||
private void ConfigureWallpaperPolling(int intervalSeconds)
|
||||
{
|
||||
var normalizedInterval = Math.Clamp(intervalSeconds <= 0 ? 300 : intervalSeconds, 30, 86400);
|
||||
var interval = TimeSpan.FromSeconds(normalizedInterval);
|
||||
_systemWallpaperPollTimer ??= new Timer(OnSystemWallpaperPollTimer);
|
||||
_systemWallpaperPollTimer.Change(interval, interval);
|
||||
_wallpaperPollingActive = true;
|
||||
}
|
||||
|
||||
private void StopSystemWallpaperMonitoring()
|
||||
{
|
||||
UnregisterNativeWallpaperEvents();
|
||||
_systemWallpaperPollTimer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
|
||||
_wallpaperPollingActive = false;
|
||||
_lastObservedWallpaperSourceKey = null;
|
||||
}
|
||||
|
||||
private void OnNativeWallpaperPreferenceChanged(object? sender, UserPreferenceChangedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Category is UserPreferenceCategory.Desktop or UserPreferenceCategory.General)
|
||||
{
|
||||
RefreshWallpaperColors();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSystemWallpaperPollTimer(object? state)
|
||||
{
|
||||
_ = state;
|
||||
|
||||
try
|
||||
{
|
||||
var source = ResolveWallpaperSeedSource(_settingsFacade.Wallpaper.Get(), _liveThemeWallpaperColorSource);
|
||||
var sourceKey = source.SourceKey;
|
||||
if (string.Equals(_lastObservedWallpaperSourceKey, sourceKey, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_lastObservedWallpaperSourceKey = sourceKey;
|
||||
RefreshWallpaperColors();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("Appearance.WallpaperMonitor", "Failed to poll wallpaper color source.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateObservedWallpaperSourceKey()
|
||||
{
|
||||
try
|
||||
{
|
||||
_lastObservedWallpaperSourceKey = ResolveWallpaperSeedSource(
|
||||
_settingsFacade.Wallpaper.Get(),
|
||||
_liveThemeWallpaperColorSource).SourceKey;
|
||||
}
|
||||
catch
|
||||
{
|
||||
_lastObservedWallpaperSourceKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static Color? ResolveSelectedWallpaperSeed(
|
||||
@@ -1071,3 +1405,11 @@ internal static class HostAppearanceThemeProvider
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class HostMaterialColorProvider
|
||||
{
|
||||
public static IMaterialColorService GetOrCreate()
|
||||
{
|
||||
return (IMaterialColorService)HostAppearanceThemeProvider.GetOrCreate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
@@ -31,16 +30,15 @@ public interface IComponentEditorWindowService
|
||||
internal sealed class ComponentEditorWindowService : IComponentEditorWindowService
|
||||
{
|
||||
private readonly ISettingsFacadeService _settingsFacade;
|
||||
private readonly IAppearanceThemeService _appearanceThemeService;
|
||||
private readonly IMaterialColorService _materialColorService;
|
||||
private ComponentEditorWindow? _window;
|
||||
private string? _currentPlacementId;
|
||||
|
||||
public ComponentEditorWindowService(ISettingsFacadeService settingsFacade)
|
||||
{
|
||||
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
||||
_appearanceThemeService = HostAppearanceThemeProvider.GetOrCreate();
|
||||
_settingsFacade.Settings.Changed += OnSettingsChanged;
|
||||
_appearanceThemeService.Changed += OnAppearanceThemeChanged;
|
||||
_materialColorService = HostMaterialColorProvider.GetOrCreate();
|
||||
_materialColorService.MaterialColorChanged += OnMaterialColorChanged;
|
||||
}
|
||||
|
||||
public bool IsOpen => _window is { IsVisible: true };
|
||||
@@ -100,60 +98,29 @@ internal sealed class ComponentEditorWindowService : IComponentEditorWindowServi
|
||||
return window;
|
||||
}
|
||||
|
||||
private void OnSettingsChanged(object? sender, SettingsChangedEvent e)
|
||||
{
|
||||
if (_window is null || e.Scope != SettingsScope.App)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var changedKeys = e.ChangedKeys?.ToArray() ?? [];
|
||||
var liveAppearance = _appearanceThemeService.GetCurrent();
|
||||
if (changedKeys.Length > 0 &&
|
||||
!changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) &&
|
||||
!(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeSeedMonet, StringComparison.OrdinalIgnoreCase) &&
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) &&
|
||||
!(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase) &&
|
||||
(changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperPath), StringComparer.OrdinalIgnoreCase) ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperType), StringComparer.OrdinalIgnoreCase) ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), StringComparer.OrdinalIgnoreCase))) &&
|
||||
!changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyTheme(_window);
|
||||
}
|
||||
|
||||
private void ApplyTheme(ComponentEditorWindow window)
|
||||
{
|
||||
var appearanceSnapshot = _appearanceThemeService.GetCurrent();
|
||||
var themeState = _settingsFacade.Theme.Get();
|
||||
var wallpaperState = _settingsFacade.Wallpaper.Get();
|
||||
var wallpaperMediaType = _settingsFacade.WallpaperMedia.DetectMediaType(
|
||||
appearanceSnapshot.ResolvedWallpaperPath ?? wallpaperState.WallpaperPath);
|
||||
var palette = ComponentEditorMaterialThemeAdapter.Build(
|
||||
themeState,
|
||||
wallpaperState,
|
||||
appearanceSnapshot.MonetPalette,
|
||||
wallpaperMediaType);
|
||||
var snapshot = _materialColorService.GetMaterialColorSnapshot();
|
||||
var palette = ComponentEditorMaterialThemeAdapter.Build(snapshot);
|
||||
|
||||
window.ApplyTheme(palette);
|
||||
window.ApplyChromeMode(themeState.UseSystemChrome);
|
||||
_appearanceThemeService.ApplyWindowMaterial(window, MaterialSurfaceRole.WindowBackground);
|
||||
window.ApplyChromeMode(snapshot.UseSystemChrome);
|
||||
_materialColorService.ApplyWindowMaterial(window, MaterialSurfaceRole.WindowBackground);
|
||||
}
|
||||
|
||||
private void OnAppearanceThemeChanged(object? sender, AppearanceThemeSnapshot e)
|
||||
private void OnMaterialColorChanged(object? sender, MaterialColorSnapshot snapshot)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
|
||||
if (_window is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyTheme(_window);
|
||||
var palette = ComponentEditorMaterialThemeAdapter.Build(snapshot);
|
||||
_window.ApplyTheme(palette);
|
||||
_window.ApplyChromeMode(snapshot.UseSystemChrome);
|
||||
_materialColorService.ApplyWindowMaterial(_window, MaterialSurfaceRole.WindowBackground);
|
||||
}
|
||||
|
||||
private sealed class HostContext : IComponentEditorHostContext
|
||||
|
||||
@@ -127,10 +127,9 @@ public static class DesktopComponentRegistryFactory
|
||||
var pluginSettings = new PluginScopedSettingsService(
|
||||
contribution.Plugin.Manifest.Id,
|
||||
settingsService);
|
||||
var appearanceSnapshot = HostAppearanceThemeProvider.GetOrCreate().GetCurrent();
|
||||
var pluginAppearance = new PluginAppearanceContext(new PluginAppearanceSnapshot(
|
||||
CornerRadiusTokens: PluginCornerRadiusTokens.FromShared(appearanceSnapshot.CornerRadiusTokens),
|
||||
ThemeVariant: appearanceSnapshot.IsNightMode ? "Dark" : "Light"));
|
||||
var pluginAppearance = new PluginAppearanceContext(
|
||||
PluginAppearanceSnapshotMapper.FromMaterialColorSnapshot(
|
||||
HostMaterialColorProvider.GetOrCreate().GetMaterialColorSnapshot()));
|
||||
var pluginContext = new PluginDesktopComponentContext(
|
||||
contribution.Plugin.Manifest,
|
||||
contribution.Plugin.Context.PluginDirectory,
|
||||
|
||||
23
LanMountainDesktop/Services/IMaterialColorService.cs
Normal file
23
LanMountainDesktop/Services/IMaterialColorService.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using System;
|
||||
using Avalonia.Controls;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public interface IMaterialColorService
|
||||
{
|
||||
MaterialColorSnapshot GetMaterialColorSnapshot();
|
||||
|
||||
MaterialColorSnapshot BuildMaterialColorPreview(ThemeAppearanceSettingsState pendingState);
|
||||
|
||||
event EventHandler<MaterialColorSnapshot>? MaterialColorChanged;
|
||||
|
||||
void ApplyThemeResources(IResourceDictionary resources);
|
||||
|
||||
MaterialSurfaceSnapshot GetSurface(MaterialSurfaceRole role);
|
||||
|
||||
void ApplyWindowMaterial(Window window, MaterialSurfaceRole role);
|
||||
|
||||
void RefreshWallpaperColors();
|
||||
}
|
||||
278
LanMountainDesktop/Services/PlondsStaticUpdateService.cs
Normal file
278
LanMountainDesktop/Services/PlondsStaticUpdateService.cs
Normal file
@@ -0,0 +1,278 @@
|
||||
using System.Net.Http;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
internal sealed class PlondsStaticUpdateService : IDisposable
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly bool _ownsHttpClient;
|
||||
private readonly string _baseUrl;
|
||||
|
||||
public PlondsStaticUpdateService(string? baseUrl = null, HttpClient? httpClient = null)
|
||||
{
|
||||
_baseUrl = NormalizeBaseUrl(baseUrl ?? ResolveConfiguredBaseUrl());
|
||||
if (httpClient is null)
|
||||
{
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
_ownsHttpClient = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_ownsHttpClient = false;
|
||||
}
|
||||
|
||||
if (!_httpClient.DefaultRequestHeaders.UserAgent.Any())
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-Updater/1.0");
|
||||
}
|
||||
}
|
||||
|
||||
public Task<UpdateCheckResult> CheckForUpdatesAsync(
|
||||
Version currentVersion,
|
||||
bool includePrerelease,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: false, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
|
||||
Version currentVersion,
|
||||
bool includePrerelease,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_ownsHttpClient)
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ResolveCurrentPlatform()
|
||||
{
|
||||
var os = OperatingSystem.IsWindows()
|
||||
? "windows"
|
||||
: OperatingSystem.IsLinux()
|
||||
? "linux"
|
||||
: OperatingSystem.IsMacOS()
|
||||
? "macos"
|
||||
: "unknown";
|
||||
|
||||
var arch = RuntimeInformation.OSArchitecture switch
|
||||
{
|
||||
Architecture.X86 => "x86",
|
||||
Architecture.Arm => "arm",
|
||||
Architecture.Arm64 => "arm64",
|
||||
_ => "x64"
|
||||
};
|
||||
|
||||
return $"{os}-{arch}";
|
||||
}
|
||||
|
||||
private async Task<UpdateCheckResult> CheckForUpdatesCoreAsync(
|
||||
Version currentVersion,
|
||||
bool includePrerelease,
|
||||
bool isForce,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var currentVersionText = FormatVersion(currentVersion);
|
||||
var channel = includePrerelease ? UpdateSettingsValues.ChannelPreview : UpdateSettingsValues.ChannelStable;
|
||||
var platform = ResolveCurrentPlatform();
|
||||
|
||||
try
|
||||
{
|
||||
var latestUrl = BuildUrl($"meta/channels/{Uri.EscapeDataString(channel)}/{Uri.EscapeDataString(platform)}/latest.json");
|
||||
var latest = await GetJsonAsync<LatestPointerDto>(latestUrl, cancellationToken);
|
||||
if (latest is null || string.IsNullOrWhiteSpace(latest.DistributionId))
|
||||
{
|
||||
return Failed(currentVersionText, isForce, $"PLONDS static latest manifest is unavailable at {latestUrl}.");
|
||||
}
|
||||
|
||||
var distributionUrl = BuildUrl($"meta/distributions/{Uri.EscapeDataString(latest.DistributionId)}.json");
|
||||
var distribution = await GetJsonAsync<DistributionDto>(distributionUrl, cancellationToken);
|
||||
if (distribution is null)
|
||||
{
|
||||
return Failed(currentVersionText, isForce, $"PLONDS static distribution manifest is unavailable at {distributionUrl}.");
|
||||
}
|
||||
|
||||
var latestVersionText = FirstNonEmpty(distribution.Version, latest.Version) ?? "-";
|
||||
var isNewer = TryParseVersion(latestVersionText, out var latestVersion) && latestVersion > currentVersion;
|
||||
var isUpdateAvailable = isForce || isNewer;
|
||||
var payload = isUpdateAvailable
|
||||
? CreatePayload(distribution, latest, channel, platform)
|
||||
: null;
|
||||
|
||||
return new UpdateCheckResult(
|
||||
Success: true,
|
||||
IsUpdateAvailable: isUpdateAvailable,
|
||||
CurrentVersionText: currentVersionText,
|
||||
LatestVersionText: latestVersionText,
|
||||
Release: null,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: null,
|
||||
ForceMode: isForce,
|
||||
PlondsPayload: payload);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Failed(currentVersionText, isForce, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private PlondsUpdatePayload CreatePayload(
|
||||
DistributionDto distribution,
|
||||
LatestPointerDto latest,
|
||||
string channel,
|
||||
string platform)
|
||||
{
|
||||
var distributionId = FirstNonEmpty(distribution.DistributionId, latest.DistributionId) ?? string.Empty;
|
||||
var fileMapUrl = FirstNonEmpty(distribution.FileMapUrl, BuildUrl($"manifests/{Uri.EscapeDataString(distributionId)}/plonds-filemap.json"));
|
||||
var signatureUrl = FirstNonEmpty(distribution.FileMapSignatureUrl, fileMapUrl + ".sig");
|
||||
|
||||
return new PlondsUpdatePayload(
|
||||
DistributionId: distributionId,
|
||||
ChannelId: FirstNonEmpty(distribution.Channel, latest.Channel, channel) ?? channel,
|
||||
SubChannel: FirstNonEmpty(distribution.Platform, latest.Platform, platform) ?? platform,
|
||||
FileMapJson: null,
|
||||
FileMapSignature: null,
|
||||
FileMapJsonUrl: fileMapUrl,
|
||||
FileMapSignatureUrl: signatureUrl);
|
||||
}
|
||||
|
||||
private async Task<T?> GetJsonAsync<T>(string url, CancellationToken cancellationToken)
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
throw new InvalidOperationException($"HTTP {(int)response.StatusCode} from {url}: {Truncate(body, 256)}");
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
return await JsonSerializer.DeserializeAsync<T>(stream, JsonOptions, cancellationToken);
|
||||
}
|
||||
|
||||
private static UpdateCheckResult Failed(string currentVersionText, bool isForce, string message)
|
||||
{
|
||||
return new UpdateCheckResult(
|
||||
Success: false,
|
||||
IsUpdateAvailable: false,
|
||||
CurrentVersionText: currentVersionText,
|
||||
LatestVersionText: "-",
|
||||
Release: null,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: message,
|
||||
ForceMode: isForce);
|
||||
}
|
||||
|
||||
private string BuildUrl(string relativePath)
|
||||
{
|
||||
return $"{_baseUrl}/{relativePath.TrimStart('/')}";
|
||||
}
|
||||
|
||||
private static string ResolveConfiguredBaseUrl()
|
||||
{
|
||||
var environmentValue = Environment.GetEnvironmentVariable(UpdateSettingsValues.PlondsStaticBaseUrlEnvironmentVariable);
|
||||
return string.IsNullOrWhiteSpace(environmentValue)
|
||||
? UpdateSettingsValues.DefaultPlondsStaticBaseUrl
|
||||
: environmentValue;
|
||||
}
|
||||
|
||||
private static string NormalizeBaseUrl(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return UpdateSettingsValues.DefaultPlondsStaticBaseUrl;
|
||||
}
|
||||
|
||||
return value.Trim().TrimEnd('/');
|
||||
}
|
||||
|
||||
private static bool TryParseVersion(string? value, out Version version)
|
||||
{
|
||||
version = new Version(0, 0, 0);
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Version.TryParse(value.Trim().TrimStart('v', 'V'), out var parsed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
version = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string FormatVersion(Version version)
|
||||
{
|
||||
if (version.Revision >= 0)
|
||||
{
|
||||
return version.ToString();
|
||||
}
|
||||
|
||||
return version.Build >= 0
|
||||
? $"{version.Major}.{version.Minor}.{version.Build}"
|
||||
: $"{version.Major}.{version.Minor}";
|
||||
}
|
||||
|
||||
private static string? FirstNonEmpty(params string?[] values)
|
||||
{
|
||||
return values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim();
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return value[..maxLength];
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
|
||||
private sealed record LatestPointerDto(
|
||||
string? DistributionId,
|
||||
string? Version,
|
||||
string? Channel,
|
||||
string? Platform,
|
||||
DateTimeOffset PublishedAt);
|
||||
|
||||
private sealed record DistributionDto(
|
||||
string? DistributionId,
|
||||
string? Version,
|
||||
string? SourceVersion,
|
||||
string? Channel,
|
||||
string? Platform,
|
||||
DateTimeOffset PublishedAt,
|
||||
string? FileMapUrl,
|
||||
string? FileMapSignatureUrl);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Avalonia.Media;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
internal static class PluginAppearanceSnapshotMapper
|
||||
{
|
||||
public static PluginAppearanceSnapshot FromMaterialColorSnapshot(MaterialColorSnapshot snapshot)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
|
||||
return new PluginAppearanceSnapshot(
|
||||
PluginCornerRadiusTokens.FromShared(snapshot.CornerRadiusTokens),
|
||||
snapshot.IsNightMode ? "Dark" : "Light",
|
||||
ToText(snapshot.AccentColor),
|
||||
ToText(snapshot.EffectiveSeedColor),
|
||||
snapshot.ColorSourceKind.ToString(),
|
||||
snapshot.SystemMaterialMode,
|
||||
BuildColorRoles(snapshot),
|
||||
snapshot.Surfaces.ToDictionary(
|
||||
pair => pair.Key.ToString(),
|
||||
pair => new PluginMaterialSurfaceSnapshot(
|
||||
ToText(pair.Value.BackgroundColor),
|
||||
ToText(pair.Value.BorderColor),
|
||||
pair.Value.BlurRadius,
|
||||
pair.Value.Opacity),
|
||||
StringComparer.OrdinalIgnoreCase),
|
||||
snapshot.WallpaperSeedCandidates.Select(ToText).ToArray());
|
||||
}
|
||||
|
||||
public static PluginAppearanceSnapshot FromAppearanceSnapshot(AppearanceThemeSnapshot snapshot)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
|
||||
return new PluginAppearanceSnapshot(
|
||||
PluginCornerRadiusTokens.FromShared(snapshot.CornerRadiusTokens),
|
||||
snapshot.IsNightMode ? "Dark" : "Light",
|
||||
ToText(snapshot.AccentColor),
|
||||
ToText(snapshot.EffectiveSeedColor),
|
||||
snapshot.ResolvedSeedSource,
|
||||
snapshot.SystemMaterialMode,
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["primary"] = ToText(snapshot.MonetPalette.Primary),
|
||||
["secondary"] = ToText(snapshot.MonetPalette.Secondary),
|
||||
["tertiary"] = ToText(snapshot.MonetPalette.Tertiary),
|
||||
["neutral"] = ToText(snapshot.MonetPalette.Neutral),
|
||||
["neutralVariant"] = ToText(snapshot.MonetPalette.NeutralVariant),
|
||||
["accent"] = ToText(snapshot.AccentColor)
|
||||
},
|
||||
null,
|
||||
snapshot.WallpaperSeedCandidates.Select(ToText).ToArray());
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> BuildColorRoles(MaterialColorSnapshot snapshot)
|
||||
{
|
||||
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["primary"] = ToText(snapshot.Palette.Primary),
|
||||
["secondary"] = ToText(snapshot.Palette.Secondary),
|
||||
["accent"] = ToText(snapshot.Palette.Accent),
|
||||
["onAccent"] = ToText(snapshot.Palette.OnAccent),
|
||||
["surfaceBase"] = ToText(snapshot.Palette.SurfaceBase),
|
||||
["surfaceRaised"] = ToText(snapshot.Palette.SurfaceRaised),
|
||||
["surfaceOverlay"] = ToText(snapshot.Palette.SurfaceOverlay),
|
||||
["textPrimary"] = ToText(snapshot.Palette.TextPrimary),
|
||||
["textSecondary"] = ToText(snapshot.Palette.TextSecondary),
|
||||
["textMuted"] = ToText(snapshot.Palette.TextMuted),
|
||||
["textAccent"] = ToText(snapshot.Palette.TextAccent),
|
||||
["toggleOn"] = ToText(snapshot.Palette.ToggleOn),
|
||||
["toggleOff"] = ToText(snapshot.Palette.ToggleOff),
|
||||
["toggleBorder"] = ToText(snapshot.Palette.ToggleBorder)
|
||||
};
|
||||
}
|
||||
|
||||
private static string ToText(Color color)
|
||||
{
|
||||
return color.ToString();
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,9 @@ public sealed record ThemeAppearanceSettingsState(
|
||||
string ThemeColorMode = ThemeAppearanceValues.ColorModeDefaultNeutral,
|
||||
string SystemMaterialMode = ThemeAppearanceValues.MaterialNone,
|
||||
string? SelectedWallpaperSeed = null,
|
||||
string ThemeMode = ThemeAppearanceValues.ThemeModeLight);
|
||||
string ThemeMode = ThemeAppearanceValues.ThemeModeLight,
|
||||
string ThemeWallpaperColorSource = ThemeAppearanceValues.WallpaperColorSourceAuto,
|
||||
bool UseNativeWallpaperChangeEvents = true);
|
||||
public sealed record StatusBarSettingsState(
|
||||
IReadOnlyList<string> TopStatusComponentIds,
|
||||
IReadOnlyList<string> PinnedTaskbarActions,
|
||||
|
||||
@@ -267,7 +267,9 @@ internal sealed class ThemeAppearanceService : IThemeAppearanceService
|
||||
ThemeAppearanceValues.NormalizeThemeColorMode(snapshot.ThemeColorMode, snapshot.ThemeColor),
|
||||
ThemeAppearanceValues.NormalizeSystemMaterialMode(snapshot.SystemMaterialMode),
|
||||
snapshot.SelectedWallpaperSeed,
|
||||
NormalizeThemeMode(snapshot.ThemeMode));
|
||||
NormalizeThemeMode(snapshot.ThemeMode),
|
||||
ThemeAppearanceValues.NormalizeWallpaperColorSource(snapshot.ThemeWallpaperColorSource),
|
||||
snapshot.UseNativeWallpaperChangeEvents);
|
||||
}
|
||||
|
||||
private static string NormalizeThemeMode(string? value)
|
||||
@@ -294,6 +296,7 @@ internal sealed class ThemeAppearanceService : IThemeAppearanceService
|
||||
var normalizedSelectedWallpaperSeed = string.IsNullOrWhiteSpace(state.SelectedWallpaperSeed)
|
||||
? null
|
||||
: state.SelectedWallpaperSeed;
|
||||
var normalizedWallpaperColorSource = ThemeAppearanceValues.NormalizeWallpaperColorSource(state.ThemeWallpaperColorSource);
|
||||
|
||||
if ((snapshot.IsNightMode ?? false) != state.IsNightMode)
|
||||
{
|
||||
@@ -337,6 +340,18 @@ internal sealed class ThemeAppearanceService : IThemeAppearanceService
|
||||
changedKeys.Add(nameof(AppSettingsSnapshot.SelectedWallpaperSeed));
|
||||
}
|
||||
|
||||
if (!string.Equals(snapshot.ThemeWallpaperColorSource, normalizedWallpaperColorSource, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
snapshot.ThemeWallpaperColorSource = normalizedWallpaperColorSource;
|
||||
changedKeys.Add(nameof(AppSettingsSnapshot.ThemeWallpaperColorSource));
|
||||
}
|
||||
|
||||
if (snapshot.UseNativeWallpaperChangeEvents != state.UseNativeWallpaperChangeEvents)
|
||||
{
|
||||
snapshot.UseNativeWallpaperChangeEvents = state.UseNativeWallpaperChangeEvents;
|
||||
changedKeys.Add(nameof(AppSettingsSnapshot.UseNativeWallpaperChangeEvents));
|
||||
}
|
||||
|
||||
var normalizedThemeMode = NormalizeThemeMode(state.ThemeMode);
|
||||
if (!string.Equals(snapshot.ThemeMode, normalizedThemeMode, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@@ -770,6 +785,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
{
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly GitHubReleaseUpdateService _githubReleaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
|
||||
private readonly PlondsStaticUpdateService _plondsStaticUpdateService = new();
|
||||
private readonly PlondsReleaseUpdateService _plondsReleaseUpdateService = new();
|
||||
|
||||
public UpdateSettingsService(ISettingsService settingsService)
|
||||
@@ -869,6 +885,14 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
bool isForce = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var staticResult = isForce
|
||||
? await _plondsStaticUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
||||
: await _plondsStaticUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
if (staticResult.Success && staticResult.PlondsPayload is not null)
|
||||
{
|
||||
return staticResult.PlondsPayload;
|
||||
}
|
||||
|
||||
var result = isForce
|
||||
? await _plondsReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
||||
: await _plondsReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
@@ -912,6 +936,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
public void Dispose()
|
||||
{
|
||||
_githubReleaseUpdateService.Dispose();
|
||||
_plondsStaticUpdateService.Dispose();
|
||||
_plondsReleaseUpdateService.Dispose();
|
||||
}
|
||||
|
||||
@@ -921,6 +946,19 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
bool isForce,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var staticResult = isForce
|
||||
? await _plondsStaticUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
||||
: await _plondsStaticUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
|
||||
if (staticResult.Success)
|
||||
{
|
||||
return staticResult;
|
||||
}
|
||||
|
||||
AppLogger.Warn(
|
||||
"UpdateSettings",
|
||||
$"PLONDS static update check failed and will fallback to GitHub release PLONDS. Error: {staticResult.ErrorMessage}");
|
||||
|
||||
var plondsResult = isForce
|
||||
? await _plondsReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
||||
: await _plondsReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
|
||||
@@ -179,6 +179,7 @@ internal sealed class SettingsPageRegistry : ISettingsPageRegistry, IDisposable
|
||||
services.AddSingleton(_settingsFacade.Settings);
|
||||
services.AddSingleton(_settingsFacade.Catalog);
|
||||
services.AddSingleton<IAppearanceThemeService>(_ => HostAppearanceThemeProvider.GetOrCreate());
|
||||
services.AddSingleton<IMaterialColorService>(_ => HostMaterialColorProvider.GetOrCreate());
|
||||
services.AddSingleton(_hostApplicationLifecycle);
|
||||
services.AddSingleton(_localizationService);
|
||||
services.AddSingleton<ILocationService>(_ => HostLocationServiceProvider.GetOrCreate());
|
||||
|
||||
@@ -242,7 +242,10 @@ internal sealed class SettingsWindowService : ISettingsWindowService
|
||||
(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase) &&
|
||||
(changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperPath), StringComparer.OrdinalIgnoreCase) ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperType), StringComparer.OrdinalIgnoreCase) ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), StringComparer.OrdinalIgnoreCase))) ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), StringComparer.OrdinalIgnoreCase) ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeWallpaperColorSource), StringComparer.OrdinalIgnoreCase) ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.UseNativeWallpaperChangeEvents), StringComparer.OrdinalIgnoreCase) ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.SystemWallpaperRefreshIntervalSeconds), StringComparer.OrdinalIgnoreCase))) ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (languageChanged || devModeChanged)
|
||||
|
||||
@@ -10,6 +10,10 @@ public static class ThemeAppearanceValues
|
||||
public const string ColorModeSeedMonet = "seed_monet";
|
||||
public const string ColorModeWallpaperMonet = "wallpaper_monet";
|
||||
|
||||
public const string WallpaperColorSourceAuto = "auto";
|
||||
public const string WallpaperColorSourceApp = "app";
|
||||
public const string WallpaperColorSourceSystem = "system";
|
||||
|
||||
public const string ColorSchemeFollowSystem = "follow_system";
|
||||
public const string ColorSchemeNative = "native";
|
||||
|
||||
@@ -37,6 +41,13 @@ public static class ThemeAppearanceValues
|
||||
MaterialAcrylic
|
||||
];
|
||||
|
||||
public static readonly IReadOnlyList<string> AllWallpaperColorSources =
|
||||
[
|
||||
WallpaperColorSourceAuto,
|
||||
WallpaperColorSourceApp,
|
||||
WallpaperColorSourceSystem
|
||||
];
|
||||
|
||||
public static string NormalizeThemeColorMode(string? value, string? themeColor = null)
|
||||
{
|
||||
if (string.Equals(value, ColorModeDefaultNeutral, StringComparison.OrdinalIgnoreCase))
|
||||
@@ -79,6 +90,21 @@ public static class ThemeAppearanceValues
|
||||
return MaterialNone;
|
||||
}
|
||||
|
||||
public static string NormalizeWallpaperColorSource(string? value)
|
||||
{
|
||||
if (string.Equals(value, WallpaperColorSourceApp, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return WallpaperColorSourceApp;
|
||||
}
|
||||
|
||||
if (string.Equals(value, WallpaperColorSourceSystem, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return WallpaperColorSourceSystem;
|
||||
}
|
||||
|
||||
return WallpaperColorSourceAuto;
|
||||
}
|
||||
|
||||
public static string ResolveEffectiveSystemMaterialMode(string? value)
|
||||
{
|
||||
var normalized = NormalizeSystemMaterialMode(value);
|
||||
|
||||
@@ -67,7 +67,7 @@ public static class ThemeColorSystemService
|
||||
!isLightBackground));
|
||||
}
|
||||
|
||||
private static AppThemePalette BuildPalette(ThemeColorContext context)
|
||||
public static AppThemePalette BuildPalette(ThemeColorContext context)
|
||||
{
|
||||
var monetPalette = context.MonetPalette;
|
||||
var monetColors = context.MonetColors?.Where(color => color.A > 0).ToArray() ?? [];
|
||||
|
||||
@@ -6,7 +6,7 @@ using LanMountainDesktop.Shared.Contracts.Update;
|
||||
|
||||
namespace LanMountainDesktop.Services.Update;
|
||||
|
||||
internal sealed class PlondsApiManifestProvider : IUpdateManifestProvider
|
||||
internal sealed class PlondsApiManifestProvider : IUpdateManifestProvider, IDisposable
|
||||
{
|
||||
private const string ApiBasePath = "/api/plonds/v1";
|
||||
|
||||
@@ -51,6 +51,12 @@ internal sealed class PlondsApiManifestProvider : IUpdateManifestProvider
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(pointer.DistributionId) ||
|
||||
string.IsNullOrWhiteSpace(pointer.Version))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await FetchDistributionManifestAsync(pointer.DistributionId, pointer.Version, channel, platform, ct);
|
||||
}
|
||||
|
||||
@@ -74,6 +80,14 @@ internal sealed class PlondsApiManifestProvider : IUpdateManifestProvider
|
||||
return Task.FromResult<IReadOnlyList<UpdateManifest>>([]);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_ownsHttpClient)
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<PlondsChannelPointerDto?> GetChannelPointerAsync(
|
||||
string channel,
|
||||
string platform,
|
||||
@@ -142,15 +156,17 @@ internal sealed class PlondsApiManifestProvider : IUpdateManifestProvider
|
||||
|
||||
foreach (var f in component.Files)
|
||||
{
|
||||
var action = FirstNonEmpty(f.Action, f.Op) ?? "add";
|
||||
var sha256 = FirstNonEmpty(f.Sha256, f.ContentHash) ?? string.Empty;
|
||||
files.Add(new UpdateFileEntry(
|
||||
Path: f.Path ?? string.Empty,
|
||||
Action: f.Op ?? "add",
|
||||
Sha256: f.ContentHash ?? string.Empty,
|
||||
Action: action,
|
||||
Sha256: sha256,
|
||||
Size: f.Size,
|
||||
Mode: f.Mode ?? "file-object",
|
||||
ObjectKey: f.ObjectKey,
|
||||
ObjectUrl: null,
|
||||
ArchiveSha256: null,
|
||||
ObjectUrl: f.ObjectUrl,
|
||||
ArchiveSha256: f.ArchiveSha256,
|
||||
Metadata: null));
|
||||
}
|
||||
}
|
||||
@@ -163,7 +179,7 @@ internal sealed class PlondsApiManifestProvider : IUpdateManifestProvider
|
||||
Sha256: m.Sha256,
|
||||
Size: m.Size)).ToArray();
|
||||
|
||||
var fileMapSignatureUrl = dto.Signatures?.FirstOrDefault()?.Signature;
|
||||
var fileMapSignatureUrl = FirstNonEmpty(dto.FileMapSignatureUrl, dto.Signatures?.FirstOrDefault()?.Signature);
|
||||
|
||||
return new UpdateManifest(
|
||||
DistributionId: dto.DistributionId ?? string.Empty,
|
||||
@@ -209,14 +225,15 @@ internal sealed class PlondsApiManifestProvider : IUpdateManifestProvider
|
||||
private sealed record PlondsDistributionDto(
|
||||
string? DistributionId,
|
||||
string? Version,
|
||||
string? SourceVersion,
|
||||
string? Channel,
|
||||
string? Platform,
|
||||
DateTimeOffset PublishedAt,
|
||||
string? FileMapUrl,
|
||||
List<PlondsComponentDto>? Components,
|
||||
List<PlondsMirrorDto>? InstallerMirrors,
|
||||
List<PlondsSignatureDto>? Signatures,
|
||||
string? SourceVersion,
|
||||
string? Channel,
|
||||
string? Platform,
|
||||
DateTimeOffset PublishedAt,
|
||||
string? FileMapUrl,
|
||||
string? FileMapSignatureUrl,
|
||||
List<PlondsComponentDto>? Components,
|
||||
List<PlondsMirrorDto>? InstallerMirrors,
|
||||
List<PlondsSignatureDto>? Signatures,
|
||||
Dictionary<string, string>? Metadata);
|
||||
|
||||
private sealed record PlondsComponentDto(
|
||||
@@ -228,10 +245,14 @@ internal sealed class PlondsApiManifestProvider : IUpdateManifestProvider
|
||||
private sealed record PlondsFileDto(
|
||||
string? Path,
|
||||
string? Op,
|
||||
string? Action,
|
||||
string? ContentHash,
|
||||
string? Sha256,
|
||||
long Size,
|
||||
string? Mode,
|
||||
string? ObjectKey);
|
||||
string? ObjectKey,
|
||||
string? ObjectUrl,
|
||||
string? ArchiveSha256);
|
||||
|
||||
private sealed record PlondsMirrorDto(
|
||||
string? Platform,
|
||||
@@ -244,4 +265,9 @@ internal sealed class PlondsApiManifestProvider : IUpdateManifestProvider
|
||||
string? Algorithm,
|
||||
string? KeyId,
|
||||
string? Signature);
|
||||
|
||||
private static string? FirstNonEmpty(params string?[] values)
|
||||
{
|
||||
return values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ public sealed class UpdateOrchestrator : IDisposable
|
||||
{
|
||||
manifest = await _manifestProvider.GetLatestAsync(
|
||||
channel,
|
||||
"win-x64",
|
||||
LanMountainDesktop.Services.PlondsStaticUpdateService.ResolveCurrentPlatform(),
|
||||
currentVersion,
|
||||
ct);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ public static class UpdateSettingsValues
|
||||
public const string LegacyDownloadSourceStcn = "stcn";
|
||||
public const string DownloadSourceGitHub = "github";
|
||||
public const string DownloadSourceGhProxy = "gh-proxy";
|
||||
public const string PlondsStaticBaseUrlEnvironmentVariable = "LANMOUNTAIN_UPDATE_BASE_URL";
|
||||
public const string DefaultPlondsStaticBaseUrl = "https://cn-nb1.rains3.com/lmdesktop/lanmountain/update";
|
||||
|
||||
public const int DefaultDownloadThreads = 4;
|
||||
public const int MinDownloadThreads = 1;
|
||||
|
||||
@@ -55,7 +55,7 @@ public sealed class UpdateWorkflowService
|
||||
private readonly ISettingsFacadeService _settingsFacade;
|
||||
private readonly string _updatesDirectory;
|
||||
|
||||
private const string LauncherDirectoryName = ".launcher";
|
||||
private const string LauncherDirectoryName = ".Launcher";
|
||||
private const string UpdateDirectoryName = "update";
|
||||
private const string IncomingDirectoryName = "incoming";
|
||||
private const string IncomingObjectsDirectoryName = "objects";
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Avalonia.Media;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
|
||||
namespace LanMountainDesktop.ViewModels;
|
||||
|
||||
public sealed class MaterialSurfacePreviewOption
|
||||
{
|
||||
public MaterialSurfacePreviewOption(string label, MaterialSurfaceSnapshot surface)
|
||||
{
|
||||
Label = label;
|
||||
BackgroundBrush = new SolidColorBrush(surface.BackgroundColor);
|
||||
BorderBrush = new SolidColorBrush(surface.BorderColor);
|
||||
Detail = $"A={surface.BackgroundColor.A:X2} Blur={surface.BlurRadius:0}";
|
||||
}
|
||||
|
||||
public string Label { get; }
|
||||
|
||||
public IBrush BackgroundBrush { get; }
|
||||
|
||||
public IBrush BorderBrush { get; }
|
||||
|
||||
public string Detail { get; }
|
||||
}
|
||||
|
||||
public sealed class MaterialColorRolePreviewOption
|
||||
{
|
||||
public MaterialColorRolePreviewOption(string label, Color color)
|
||||
{
|
||||
Label = label;
|
||||
Value = color.ToString();
|
||||
Brush = new SolidColorBrush(color);
|
||||
}
|
||||
|
||||
public string Label { get; }
|
||||
|
||||
public string Value { get; }
|
||||
|
||||
public IBrush Brush { get; }
|
||||
}
|
||||
|
||||
public sealed partial class MaterialColorSettingsPageViewModel : ViewModelBase
|
||||
{
|
||||
private static readonly Color DefaultSeedColor = Color.Parse("#FF3B82F6");
|
||||
private readonly ISettingsFacadeService _settingsFacade;
|
||||
private readonly IMaterialColorService _materialColorService;
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly string _languageCode;
|
||||
private bool _isInitializing;
|
||||
private string? _selectedWallpaperSeed;
|
||||
|
||||
public MaterialColorSettingsPageViewModel(
|
||||
ISettingsFacadeService settingsFacade,
|
||||
IMaterialColorService materialColorService)
|
||||
{
|
||||
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
||||
_materialColorService = materialColorService ?? throw new ArgumentNullException(nameof(materialColorService));
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode);
|
||||
|
||||
_isInitializing = true;
|
||||
try
|
||||
{
|
||||
RefreshLocalizedText();
|
||||
ColorModes = CreateColorModes();
|
||||
WallpaperColorSources = CreateWallpaperColorSources();
|
||||
RefreshIntervals = CreateRefreshIntervals();
|
||||
RefreshMaterialModes(_materialColorService.GetMaterialColorSnapshot());
|
||||
Load();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isInitializing = false;
|
||||
}
|
||||
|
||||
_materialColorService.MaterialColorChanged += OnMaterialColorChanged;
|
||||
}
|
||||
|
||||
public IReadOnlyList<SelectionOption> ColorModes { get; }
|
||||
|
||||
public IReadOnlyList<SelectionOption> WallpaperColorSources { get; }
|
||||
|
||||
public IReadOnlyList<SelectionOption> RefreshIntervals { get; }
|
||||
|
||||
[ObservableProperty]
|
||||
private SelectionOption _selectedColorMode = new(ThemeAppearanceValues.ColorModeDefaultNeutral, "Default neutral");
|
||||
|
||||
[ObservableProperty]
|
||||
private SelectionOption _selectedWallpaperColorSource = new(ThemeAppearanceValues.WallpaperColorSourceAuto, "Auto");
|
||||
|
||||
[ObservableProperty]
|
||||
private IReadOnlyList<SelectionOption> _systemMaterialModes = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private SelectionOption _selectedSystemMaterialMode = new(ThemeAppearanceValues.MaterialAuto, "Auto");
|
||||
|
||||
[ObservableProperty]
|
||||
private SelectionOption _selectedRefreshInterval = new("5m", "5 minutes");
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _useNativeWallpaperChangeEvents = true;
|
||||
|
||||
[ObservableProperty]
|
||||
private Color _customSeedPickerValue = DefaultSeedColor;
|
||||
|
||||
[ObservableProperty]
|
||||
private IBrush _seedBrush = new SolidColorBrush(DefaultSeedColor);
|
||||
|
||||
[ObservableProperty]
|
||||
private IBrush _accentBrush = new SolidColorBrush(DefaultSeedColor);
|
||||
|
||||
[ObservableProperty]
|
||||
private IBrush _surfaceBrush = new SolidColorBrush(Color.Parse("#FFF7F8FA"));
|
||||
|
||||
[ObservableProperty]
|
||||
private IReadOnlyList<ThemeSeedCandidateOption> _wallpaperSeedCandidates = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private IReadOnlyList<MaterialColorRolePreviewOption> _colorRolePreviews = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private IReadOnlyList<MaterialSurfacePreviewOption> _surfacePreviews = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private string _resolvedSourceText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _resolvedWallpaperPathText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isCustomSeedVisible;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isWallpaperOptionsVisible;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isWallpaperSeedSelectable;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isNativeEventStatusVisible;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _nativeEventStatusText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _pageTitle = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _pageDescription = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _colorSourceLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _colorSourceDescription = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _customSeedLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _wallpaperColorSourceLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _wallpaperSeedLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _systemMaterialLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _systemMaterialDescription = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _nativeWallpaperEventsLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _nativeWallpaperEventsDescription = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _refreshIntervalLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _refreshNowText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _previewHeader = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _sourceStatusHeader = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _semanticColorsHeader = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _surfacesHeader = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _wallpaperSeedCurrentText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _modeNeutralText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _modeCustomText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _modeWallpaperText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _wallpaperSourceAutoText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _wallpaperSourceAppText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _wallpaperSourceSystemText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _materialNoneText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _materialAutoText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _materialMicaText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _materialAcrylicText = string.Empty;
|
||||
|
||||
public void Load()
|
||||
{
|
||||
var theme = _settingsFacade.Theme.Get();
|
||||
var snapshot = _materialColorService.GetMaterialColorSnapshot();
|
||||
RefreshMaterialModes(snapshot);
|
||||
|
||||
SelectedColorMode = ColorModes.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, theme.ThemeColorMode, StringComparison.OrdinalIgnoreCase))
|
||||
?? ColorModes[0];
|
||||
SelectedWallpaperColorSource = WallpaperColorSources.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, theme.ThemeWallpaperColorSource, StringComparison.OrdinalIgnoreCase))
|
||||
?? WallpaperColorSources[0];
|
||||
SelectedSystemMaterialMode = SystemMaterialModes.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, theme.SystemMaterialMode, StringComparison.OrdinalIgnoreCase))
|
||||
?? SystemMaterialModes[0];
|
||||
SelectedRefreshInterval = RefreshIntervals.FirstOrDefault(option =>
|
||||
GetIntervalSeconds(option.Value) == _settingsFacade.Wallpaper.Get().SystemWallpaperRefreshIntervalSeconds)
|
||||
?? RefreshIntervals[2];
|
||||
UseNativeWallpaperChangeEvents = theme.UseNativeWallpaperChangeEvents;
|
||||
_selectedWallpaperSeed = theme.SelectedWallpaperSeed;
|
||||
CustomSeedPickerValue = !string.IsNullOrWhiteSpace(theme.ThemeColor) && Color.TryParse(theme.ThemeColor, out var parsed)
|
||||
? parsed
|
||||
: DefaultSeedColor;
|
||||
|
||||
UpdateVisibility();
|
||||
UpdatePreview(snapshot);
|
||||
}
|
||||
|
||||
partial void OnSelectedColorModeChanged(SelectionOption value)
|
||||
{
|
||||
if (_isInitializing || value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
UpdateVisibility();
|
||||
SaveTheme();
|
||||
}
|
||||
|
||||
partial void OnSelectedWallpaperColorSourceChanged(SelectionOption value)
|
||||
{
|
||||
if (_isInitializing || value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SaveTheme();
|
||||
}
|
||||
|
||||
partial void OnSelectedSystemMaterialModeChanged(SelectionOption value)
|
||||
{
|
||||
if (_isInitializing || value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SaveTheme();
|
||||
}
|
||||
|
||||
partial void OnSelectedRefreshIntervalChanged(SelectionOption value)
|
||||
{
|
||||
if (_isInitializing || value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SaveWallpaperRefreshInterval();
|
||||
}
|
||||
|
||||
partial void OnUseNativeWallpaperChangeEventsChanged(bool value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SaveTheme();
|
||||
}
|
||||
|
||||
partial void OnCustomSeedPickerValueChanged(Color value)
|
||||
{
|
||||
SeedBrush = new SolidColorBrush(value);
|
||||
if (_isInitializing || !IsCustomSeedVisible)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SaveTheme();
|
||||
}
|
||||
|
||||
public void SelectWallpaperSeed(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_selectedWallpaperSeed = value;
|
||||
SaveTheme();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void RefreshWallpaperColors()
|
||||
{
|
||||
_materialColorService.RefreshWallpaperColors();
|
||||
}
|
||||
|
||||
private void SaveTheme()
|
||||
{
|
||||
var current = _settingsFacade.Theme.Get();
|
||||
var colorMode = ThemeAppearanceValues.NormalizeThemeColorMode(SelectedColorMode?.Value, current.ThemeColor);
|
||||
var themeColor = colorMode == ThemeAppearanceValues.ColorModeSeedMonet
|
||||
? CustomSeedPickerValue.ToString()
|
||||
: current.ThemeColor;
|
||||
|
||||
_settingsFacade.Theme.Save(current with
|
||||
{
|
||||
ThemeColorMode = colorMode,
|
||||
ThemeColor = themeColor,
|
||||
SystemMaterialMode = ThemeAppearanceValues.NormalizeSystemMaterialMode(SelectedSystemMaterialMode?.Value),
|
||||
SelectedWallpaperSeed = _selectedWallpaperSeed,
|
||||
ThemeWallpaperColorSource = ThemeAppearanceValues.NormalizeWallpaperColorSource(SelectedWallpaperColorSource?.Value),
|
||||
UseNativeWallpaperChangeEvents = UseNativeWallpaperChangeEvents
|
||||
});
|
||||
}
|
||||
|
||||
private void SaveWallpaperRefreshInterval()
|
||||
{
|
||||
var wallpaper = _settingsFacade.Wallpaper.Get();
|
||||
_settingsFacade.Wallpaper.Save(wallpaper with
|
||||
{
|
||||
SystemWallpaperRefreshIntervalSeconds = GetIntervalSeconds(SelectedRefreshInterval?.Value)
|
||||
});
|
||||
}
|
||||
|
||||
private void OnMaterialColorChanged(object? sender, MaterialColorSnapshot snapshot)
|
||||
{
|
||||
_ = sender;
|
||||
UpdatePreview(snapshot);
|
||||
RefreshMaterialModes(snapshot);
|
||||
}
|
||||
|
||||
private void UpdatePreview(MaterialColorSnapshot snapshot)
|
||||
{
|
||||
AccentBrush = new SolidColorBrush(snapshot.AccentColor);
|
||||
SeedBrush = new SolidColorBrush(snapshot.EffectiveSeedColor);
|
||||
SurfaceBrush = new SolidColorBrush(snapshot.Palette.SurfaceRaised);
|
||||
ResolvedSourceText = ResolveSourceLabel(snapshot);
|
||||
ResolvedWallpaperPathText = string.IsNullOrWhiteSpace(snapshot.ResolvedWallpaperPath)
|
||||
? "-"
|
||||
: snapshot.ResolvedWallpaperPath;
|
||||
NativeEventStatusText = snapshot.NativeWallpaperChangeEventsActive
|
||||
? L("settings.material_color.native_events.active", "Native wallpaper events active")
|
||||
: snapshot.WallpaperPollingActive
|
||||
? L("settings.material_color.native_events.polling", "Polling fallback active")
|
||||
: L("settings.material_color.native_events.inactive", "Wallpaper monitoring inactive");
|
||||
IsNativeEventStatusVisible = IsWallpaperOptionsVisible;
|
||||
|
||||
WallpaperSeedCandidates = snapshot.WallpaperSeedCandidates
|
||||
.Select((color, index) => new ThemeSeedCandidateOption(
|
||||
color.ToString(),
|
||||
string.Format(CultureInfo.CurrentCulture, "{0} {1}", WallpaperSeedLabel, index + 1),
|
||||
color,
|
||||
string.Equals(color.ToString(), snapshot.EffectiveSeedColor.ToString(), StringComparison.OrdinalIgnoreCase)))
|
||||
.ToArray();
|
||||
IsWallpaperSeedSelectable = WallpaperSeedCandidates.Count > 1;
|
||||
|
||||
ColorRolePreviews =
|
||||
[
|
||||
new MaterialColorRolePreviewOption("Accent", snapshot.Palette.Accent),
|
||||
new MaterialColorRolePreviewOption("Primary", snapshot.Palette.Primary),
|
||||
new MaterialColorRolePreviewOption("Secondary", snapshot.Palette.Secondary),
|
||||
new MaterialColorRolePreviewOption("Surface", snapshot.Palette.SurfaceRaised),
|
||||
new MaterialColorRolePreviewOption("Text", snapshot.Palette.TextPrimary),
|
||||
new MaterialColorRolePreviewOption("Toggle", snapshot.Palette.ToggleOn)
|
||||
];
|
||||
|
||||
SurfacePreviews = snapshot.Surfaces.Values
|
||||
.Where(surface => surface.Role is
|
||||
MaterialSurfaceRole.SettingsWindowBackground or
|
||||
MaterialSurfaceRole.DockBackground or
|
||||
MaterialSurfaceRole.DesktopComponentHost or
|
||||
MaterialSurfaceRole.OverlayPanel)
|
||||
.Select(surface => new MaterialSurfacePreviewOption(surface.Role.ToString(), surface))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private void UpdateVisibility()
|
||||
{
|
||||
var colorMode = ThemeAppearanceValues.NormalizeThemeColorMode(SelectedColorMode?.Value);
|
||||
IsCustomSeedVisible = colorMode == ThemeAppearanceValues.ColorModeSeedMonet;
|
||||
IsWallpaperOptionsVisible = colorMode == ThemeAppearanceValues.ColorModeWallpaperMonet;
|
||||
}
|
||||
|
||||
private void RefreshMaterialModes(MaterialColorSnapshot snapshot)
|
||||
{
|
||||
SystemMaterialModes = snapshot.AvailableSystemMaterialModes
|
||||
.Select(value => new SelectionOption(value, ResolveMaterialLabel(value)))
|
||||
.ToArray();
|
||||
|
||||
if (!SystemMaterialModes.Any(option =>
|
||||
string.Equals(option.Value, SelectedSystemMaterialMode?.Value, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
SelectedSystemMaterialMode = SystemMaterialModes.FirstOrDefault()
|
||||
?? new SelectionOption(ThemeAppearanceValues.MaterialNone, MaterialNoneText);
|
||||
}
|
||||
}
|
||||
|
||||
private string ResolveSourceLabel(MaterialColorSnapshot snapshot)
|
||||
{
|
||||
return snapshot.ColorSourceKind switch
|
||||
{
|
||||
MaterialColorSourceKind.Neutral => ModeNeutralText,
|
||||
MaterialColorSourceKind.CustomSeed => ModeCustomText,
|
||||
MaterialColorSourceKind.AppWallpaper => WallpaperSourceAppText,
|
||||
MaterialColorSourceKind.SystemWallpaper => WallpaperSourceSystemText,
|
||||
MaterialColorSourceKind.WallpaperAuto => WallpaperSourceAutoText,
|
||||
_ => L("settings.material_color.source.fallback", "Fallback")
|
||||
};
|
||||
}
|
||||
|
||||
private string ResolveMaterialLabel(string value)
|
||||
{
|
||||
return ThemeAppearanceValues.NormalizeSystemMaterialMode(value) switch
|
||||
{
|
||||
ThemeAppearanceValues.MaterialAuto => MaterialAutoText,
|
||||
ThemeAppearanceValues.MaterialMica => MaterialMicaText,
|
||||
ThemeAppearanceValues.MaterialAcrylic => MaterialAcrylicText,
|
||||
_ => MaterialNoneText
|
||||
};
|
||||
}
|
||||
|
||||
private IReadOnlyList<SelectionOption> CreateColorModes()
|
||||
{
|
||||
return
|
||||
[
|
||||
new SelectionOption(ThemeAppearanceValues.ColorModeDefaultNeutral, ModeNeutralText),
|
||||
new SelectionOption(ThemeAppearanceValues.ColorModeSeedMonet, ModeCustomText),
|
||||
new SelectionOption(ThemeAppearanceValues.ColorModeWallpaperMonet, ModeWallpaperText)
|
||||
];
|
||||
}
|
||||
|
||||
private IReadOnlyList<SelectionOption> CreateWallpaperColorSources()
|
||||
{
|
||||
return
|
||||
[
|
||||
new SelectionOption(ThemeAppearanceValues.WallpaperColorSourceAuto, WallpaperSourceAutoText),
|
||||
new SelectionOption(ThemeAppearanceValues.WallpaperColorSourceApp, WallpaperSourceAppText),
|
||||
new SelectionOption(ThemeAppearanceValues.WallpaperColorSourceSystem, WallpaperSourceSystemText)
|
||||
];
|
||||
}
|
||||
|
||||
private IReadOnlyList<SelectionOption> CreateRefreshIntervals()
|
||||
{
|
||||
return
|
||||
[
|
||||
new SelectionOption("30s", L("settings.wallpaper.refresh.30s", "30 seconds")),
|
||||
new SelectionOption("1m", L("settings.wallpaper.refresh.1m", "1 minute")),
|
||||
new SelectionOption("5m", L("settings.wallpaper.refresh.5m", "5 minutes")),
|
||||
new SelectionOption("10m", L("settings.wallpaper.refresh.10m", "10 minutes")),
|
||||
new SelectionOption("15m", L("settings.wallpaper.refresh.15m", "15 minutes")),
|
||||
new SelectionOption("30m", L("settings.wallpaper.refresh.30m", "30 minutes")),
|
||||
new SelectionOption("1h", L("settings.wallpaper.refresh.1h", "1 hour")),
|
||||
new SelectionOption("2h", L("settings.wallpaper.refresh.2h", "2 hours")),
|
||||
new SelectionOption("4h", L("settings.wallpaper.refresh.4h", "4 hours")),
|
||||
new SelectionOption("8h", L("settings.wallpaper.refresh.8h", "8 hours")),
|
||||
new SelectionOption("12h", L("settings.wallpaper.refresh.12h", "12 hours")),
|
||||
new SelectionOption("24h", L("settings.wallpaper.refresh.24h", "24 hours"))
|
||||
];
|
||||
}
|
||||
|
||||
private static int GetIntervalSeconds(string? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
"30s" => 30,
|
||||
"1m" => 60,
|
||||
"5m" => 300,
|
||||
"10m" => 600,
|
||||
"15m" => 900,
|
||||
"30m" => 1800,
|
||||
"1h" => 3600,
|
||||
"2h" => 7200,
|
||||
"4h" => 14400,
|
||||
"8h" => 28800,
|
||||
"12h" => 43200,
|
||||
"24h" => 86400,
|
||||
_ => 300
|
||||
};
|
||||
}
|
||||
|
||||
private void RefreshLocalizedText()
|
||||
{
|
||||
PageTitle = L("settings.material_color.title", "Material & Color");
|
||||
PageDescription = L("settings.material_color.description", "Unify Monet, wallpaper colors, semantic roles, and material surfaces.");
|
||||
ColorSourceLabel = L("settings.material_color.source.label", "Color source");
|
||||
ColorSourceDescription = L("settings.material_color.source.description", "Choose the single source used by app surfaces, components, and plugins.");
|
||||
CustomSeedLabel = L("settings.material_color.custom_seed.label", "Custom Monet seed");
|
||||
WallpaperColorSourceLabel = L("settings.material_color.wallpaper_source.label", "Wallpaper color source");
|
||||
WallpaperSeedLabel = L("settings.material_color.wallpaper_seed.label", "Seed");
|
||||
SystemMaterialLabel = L("settings.material_color.system_material.label", "System material");
|
||||
SystemMaterialDescription = L("settings.material_color.system_material.description", "Apply the selected material mode to windows and host surfaces.");
|
||||
NativeWallpaperEventsLabel = L("settings.material_color.native_events.label", "Native wallpaper change events");
|
||||
NativeWallpaperEventsDescription = L("settings.material_color.native_events.description", "Use OS wallpaper notifications first and keep polling as fallback.");
|
||||
RefreshIntervalLabel = L("settings.material_color.refresh_interval.label", "Polling interval");
|
||||
RefreshNowText = L("settings.material_color.refresh_now", "Refresh colors");
|
||||
PreviewHeader = L("settings.material_color.preview.header", "Unified preview");
|
||||
SourceStatusHeader = L("settings.material_color.source_status.header", "Resolved source");
|
||||
SemanticColorsHeader = L("settings.material_color.semantic.header", "Semantic colors");
|
||||
SurfacesHeader = L("settings.material_color.surfaces.header", "Material surfaces");
|
||||
WallpaperSeedCurrentText = L("settings.appearance.preview.wallpaper_current", "Current");
|
||||
ModeNeutralText = L("settings.appearance.theme_color_mode.neutral", "Default neutral");
|
||||
ModeCustomText = L("settings.appearance.theme_color_mode.user", "User theme color Monet");
|
||||
ModeWallpaperText = L("settings.appearance.theme_color_mode.wallpaper", "Wallpaper Monet");
|
||||
WallpaperSourceAutoText = L("settings.material_color.wallpaper_source.auto", "Auto");
|
||||
WallpaperSourceAppText = L("settings.material_color.wallpaper_source.app", "App wallpaper");
|
||||
WallpaperSourceSystemText = L("settings.material_color.wallpaper_source.system", "System wallpaper");
|
||||
MaterialNoneText = L("settings.appearance.system_material.none", "None");
|
||||
MaterialAutoText = L("settings.appearance.system_material.auto", "Auto");
|
||||
MaterialMicaText = L("settings.appearance.system_material.mica", "Mica");
|
||||
MaterialAcrylicText = L("settings.appearance.system_material.acrylic", "Acrylic");
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
=> _localizationService.GetString(_languageCode, key, fallback);
|
||||
}
|
||||
@@ -5,7 +5,6 @@ using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Theme;
|
||||
@@ -39,20 +38,20 @@ public partial class StudyDeductionReasonsWidget : UserControl, IDesktopComponen
|
||||
private static readonly Color LightSubstrate = Color.Parse("#FFF1F5FA");
|
||||
|
||||
private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault();
|
||||
private readonly StudyAnalyticsMonitoringLeaseCoordinator _monitoringLeaseCoordinator = StudyAnalyticsMonitoringLeaseCoordinatorFactory.CreateDefault();
|
||||
private LanMountainDesktop.PluginSdk.ISettingsService _settingsService = LanMountainDesktop.Services.Settings.HostSettingsFacadeProvider.GetOrCreate().Settings;
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly DispatcherTimer _uiTimer = new()
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(250)
|
||||
};
|
||||
private readonly StudySnapshotRenderGate _renderGate;
|
||||
|
||||
private double _currentCellSize = 48;
|
||||
private bool _isAttached;
|
||||
private bool _isOnActivePage = true;
|
||||
private bool _isSubscribed;
|
||||
private bool _isCompactMode;
|
||||
private bool _isUltraCompactMode;
|
||||
private bool _studyEnabled = true;
|
||||
private string _languageCode = "zh-CN";
|
||||
private IDisposable? _monitoringLease;
|
||||
|
||||
private readonly record struct DeductionMetrics(
|
||||
double SustainedPenalty,
|
||||
@@ -68,14 +67,14 @@ public partial class StudyDeductionReasonsWidget : UserControl, IDesktopComponen
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
_uiTimer.Tick += OnUiTimerTick;
|
||||
_renderGate = new StudySnapshotRenderGate(CanRenderSnapshot, ApplySnapshot);
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
SizeChanged += OnSizeChanged;
|
||||
ActualThemeVariantChanged += OnActualThemeVariantChanged;
|
||||
ReloadLanguageCode();
|
||||
ApplyCellSize(_currentCellSize);
|
||||
RefreshVisual();
|
||||
ApplySnapshot(_studyAnalyticsService.GetSnapshot());
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
@@ -87,26 +86,41 @@ public partial class StudyDeductionReasonsWidget : UserControl, IDesktopComponen
|
||||
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
|
||||
{
|
||||
_ = isEditMode;
|
||||
var wasOnActivePage = _isOnActivePage;
|
||||
_isOnActivePage = isOnActivePage;
|
||||
UpdateTimerState();
|
||||
UpdateMonitoringLeaseState();
|
||||
if (isOnActivePage && !wasOnActivePage)
|
||||
{
|
||||
RefreshVisual();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = true;
|
||||
ReloadLanguageCode();
|
||||
if (_studyEnabled)
|
||||
if (!_isSubscribed)
|
||||
{
|
||||
_ = _studyAnalyticsService.StartOrResumeMonitoring();
|
||||
_studyAnalyticsService.SnapshotUpdated += OnStudySnapshotUpdated;
|
||||
_isSubscribed = true;
|
||||
}
|
||||
UpdateTimerState();
|
||||
|
||||
UpdateMonitoringLeaseState();
|
||||
RefreshVisual();
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = false;
|
||||
_uiTimer.Stop();
|
||||
_monitoringLease?.Dispose();
|
||||
_monitoringLease = null;
|
||||
_renderGate.Clear();
|
||||
|
||||
if (_isSubscribed)
|
||||
{
|
||||
_studyAnalyticsService.SnapshotUpdated -= OnStudySnapshotUpdated;
|
||||
_isSubscribed = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
@@ -120,27 +134,47 @@ public partial class StudyDeductionReasonsWidget : UserControl, IDesktopComponen
|
||||
RefreshVisual();
|
||||
}
|
||||
|
||||
private void OnUiTimerTick(object? sender, EventArgs e)
|
||||
private void OnStudySnapshotUpdated(object? sender, StudyAnalyticsSnapshotChangedEventArgs e)
|
||||
{
|
||||
RefreshVisual();
|
||||
}
|
||||
|
||||
private void UpdateTimerState()
|
||||
{
|
||||
if (_isAttached && _isOnActivePage)
|
||||
if (!_isAttached || !_isOnActivePage)
|
||||
{
|
||||
if (!_uiTimer.IsEnabled)
|
||||
{
|
||||
_uiTimer.Start();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_uiTimer.Stop();
|
||||
_renderGate.Queue(e.Snapshot);
|
||||
}
|
||||
|
||||
private bool CanRenderSnapshot()
|
||||
{
|
||||
return _isAttached && _isOnActivePage;
|
||||
}
|
||||
|
||||
private void UpdateMonitoringLeaseState()
|
||||
{
|
||||
if (!_studyEnabled)
|
||||
{
|
||||
_monitoringLease?.Dispose();
|
||||
_monitoringLease = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var shouldMonitor = _isAttached && _isOnActivePage;
|
||||
if (shouldMonitor)
|
||||
{
|
||||
_monitoringLease ??= _monitoringLeaseCoordinator.AcquireLease();
|
||||
return;
|
||||
}
|
||||
|
||||
_monitoringLease?.Dispose();
|
||||
_monitoringLease = null;
|
||||
}
|
||||
|
||||
private void RefreshVisual()
|
||||
{
|
||||
_renderGate.Queue(_studyAnalyticsService.GetSnapshot());
|
||||
}
|
||||
|
||||
private void ApplySnapshot(StudyAnalyticsSnapshot snapshot)
|
||||
{
|
||||
var panelColor = ResolvePanelBackgroundColor();
|
||||
ApplyTypographyByBackground(panelColor);
|
||||
@@ -154,8 +188,6 @@ public partial class StudyDeductionReasonsWidget : UserControl, IDesktopComponen
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshot = _studyAnalyticsService.GetSnapshot();
|
||||
|
||||
var isSessionRunning = snapshot.Session.State == StudySessionRuntimeState.Running;
|
||||
var isSessionReport = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null;
|
||||
var isSessionView = isSessionRunning || isSessionReport;
|
||||
|
||||
@@ -3,7 +3,6 @@ using System.Globalization;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
@@ -17,10 +16,7 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg
|
||||
private LanMountainDesktop.PluginSdk.ISettingsService _appSettingsService = LanMountainDesktop.Services.Settings.HostSettingsFacadeProvider.GetOrCreate().Settings;
|
||||
private IComponentInstanceSettingsStore _componentSettingsService = HostComponentSettingsStoreProvider.GetOrCreate();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly DispatcherTimer _uiTimer = new()
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(250)
|
||||
};
|
||||
private readonly StudySnapshotRenderGate _renderGate;
|
||||
|
||||
private double _currentCellSize = 48;
|
||||
private bool _showDisplayDb = true;
|
||||
@@ -29,6 +25,7 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg
|
||||
private string _languageCode = "zh-CN";
|
||||
private bool _isAttached;
|
||||
private bool _isOnActivePage = true;
|
||||
private bool _isSubscribed;
|
||||
private bool _isDisposed;
|
||||
private bool _studyEnabled = true;
|
||||
private IDisposable? _monitoringLease;
|
||||
@@ -37,7 +34,7 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
_uiTimer.Tick += OnUiTimerTick;
|
||||
_renderGate = new StudySnapshotRenderGate(CanRenderSnapshot, ApplySnapshot);
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
SizeChanged += OnSizeChanged;
|
||||
@@ -45,7 +42,7 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg
|
||||
|
||||
ReloadDisplaySettings();
|
||||
ApplyCellSize(_currentCellSize);
|
||||
RefreshVisual();
|
||||
ApplySnapshot(_studyAnalyticsService.GetSnapshot());
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
@@ -77,13 +74,12 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg
|
||||
{
|
||||
RefreshVisual();
|
||||
}
|
||||
|
||||
UpdateTimerState();
|
||||
}
|
||||
|
||||
public void RefreshFromSettings()
|
||||
{
|
||||
ReloadDisplaySettings();
|
||||
UpdateMonitoringLeaseState();
|
||||
RefreshVisual();
|
||||
}
|
||||
|
||||
@@ -91,8 +87,13 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg
|
||||
{
|
||||
_isAttached = true;
|
||||
ReloadDisplaySettings();
|
||||
if (!_isSubscribed)
|
||||
{
|
||||
_studyAnalyticsService.SnapshotUpdated += OnStudySnapshotUpdated;
|
||||
_isSubscribed = true;
|
||||
}
|
||||
|
||||
UpdateMonitoringLeaseState();
|
||||
UpdateTimerState();
|
||||
RefreshVisual();
|
||||
}
|
||||
|
||||
@@ -101,7 +102,13 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg
|
||||
_isAttached = false;
|
||||
_monitoringLease?.Dispose();
|
||||
_monitoringLease = null;
|
||||
_uiTimer.Stop();
|
||||
_renderGate.Clear();
|
||||
|
||||
if (_isSubscribed)
|
||||
{
|
||||
_studyAnalyticsService.SnapshotUpdated -= OnStudySnapshotUpdated;
|
||||
_isSubscribed = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
@@ -115,20 +122,19 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg
|
||||
RefreshVisual();
|
||||
}
|
||||
|
||||
private void OnUiTimerTick(object? sender, EventArgs e)
|
||||
private void OnStudySnapshotUpdated(object? sender, StudyAnalyticsSnapshotChangedEventArgs e)
|
||||
{
|
||||
RefreshVisual();
|
||||
}
|
||||
|
||||
private void UpdateTimerState()
|
||||
{
|
||||
if (_isAttached && _isOnActivePage)
|
||||
if (!_isAttached || !_isOnActivePage)
|
||||
{
|
||||
_uiTimer.Start();
|
||||
return;
|
||||
}
|
||||
|
||||
_uiTimer.Stop();
|
||||
_renderGate.Queue(e.Snapshot);
|
||||
}
|
||||
|
||||
private bool CanRenderSnapshot()
|
||||
{
|
||||
return _isAttached && _isOnActivePage;
|
||||
}
|
||||
|
||||
private void UpdateMonitoringLeaseState()
|
||||
@@ -140,7 +146,8 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isAttached)
|
||||
var shouldMonitor = _isAttached && _isOnActivePage;
|
||||
if (shouldMonitor)
|
||||
{
|
||||
_monitoringLease ??= _monitoringLeaseCoordinator.AcquireLease();
|
||||
return;
|
||||
@@ -166,6 +173,11 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg
|
||||
}
|
||||
|
||||
private void RefreshVisual()
|
||||
{
|
||||
_renderGate.Queue(_studyAnalyticsService.GetSnapshot());
|
||||
}
|
||||
|
||||
private void ApplySnapshot(StudyAnalyticsSnapshot snapshot)
|
||||
{
|
||||
if (!_studyEnabled)
|
||||
{
|
||||
@@ -178,7 +190,6 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshot = _studyAnalyticsService.GetSnapshot();
|
||||
var isSessionReport = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null;
|
||||
|
||||
StatusTitleTextBlock.Text = L("study.environment.status_label", "Environment");
|
||||
@@ -380,13 +391,18 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg
|
||||
|
||||
_isDisposed = true;
|
||||
|
||||
_uiTimer.Stop();
|
||||
_uiTimer.Tick -= OnUiTimerTick;
|
||||
_renderGate.Dispose();
|
||||
AttachedToVisualTree -= OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree -= OnDetachedFromVisualTree;
|
||||
SizeChanged -= OnSizeChanged;
|
||||
ActualThemeVariantChanged -= OnActualThemeVariantChanged;
|
||||
|
||||
if (_isSubscribed)
|
||||
{
|
||||
_studyAnalyticsService.SnapshotUpdated -= OnStudySnapshotUpdated;
|
||||
_isSubscribed = false;
|
||||
}
|
||||
|
||||
_monitoringLease?.Dispose();
|
||||
_monitoringLease = null;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ using System.Globalization;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Theme;
|
||||
@@ -41,14 +40,12 @@ public partial class StudyInterruptDensityWidget : UserControl, IDesktopComponen
|
||||
private readonly StudyAnalyticsMonitoringLeaseCoordinator _monitoringLeaseCoordinator = StudyAnalyticsMonitoringLeaseCoordinatorFactory.CreateDefault();
|
||||
private LanMountainDesktop.PluginSdk.ISettingsService _settingsService = LanMountainDesktop.Services.Settings.HostSettingsFacadeProvider.GetOrCreate().Settings;
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly DispatcherTimer _uiTimer = new()
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(250)
|
||||
};
|
||||
private readonly StudySnapshotRenderGate _renderGate;
|
||||
|
||||
private double _currentCellSize = 48;
|
||||
private bool _isAttached;
|
||||
private bool _isOnActivePage = true;
|
||||
private bool _isSubscribed;
|
||||
private bool _isCompactMode;
|
||||
private bool _isUltraCompactMode;
|
||||
private bool _studyEnabled = true;
|
||||
@@ -79,14 +76,14 @@ public partial class StudyInterruptDensityWidget : UserControl, IDesktopComponen
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
_uiTimer.Tick += OnUiTimerTick;
|
||||
_renderGate = new StudySnapshotRenderGate(CanRenderSnapshot, ApplySnapshot);
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
SizeChanged += OnSizeChanged;
|
||||
ActualThemeVariantChanged += OnActualThemeVariantChanged;
|
||||
ReloadLanguageCode();
|
||||
ApplyCellSize(_currentCellSize);
|
||||
RefreshVisual();
|
||||
ApplySnapshot(_studyAnalyticsService.GetSnapshot());
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
@@ -98,17 +95,26 @@ public partial class StudyInterruptDensityWidget : UserControl, IDesktopComponen
|
||||
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
|
||||
{
|
||||
_ = isEditMode;
|
||||
var wasOnActivePage = _isOnActivePage;
|
||||
_isOnActivePage = isOnActivePage;
|
||||
UpdateMonitoringLeaseState();
|
||||
UpdateTimerState();
|
||||
if (isOnActivePage && !wasOnActivePage)
|
||||
{
|
||||
RefreshVisual();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = true;
|
||||
ReloadLanguageCode();
|
||||
if (!_isSubscribed)
|
||||
{
|
||||
_studyAnalyticsService.SnapshotUpdated += OnStudySnapshotUpdated;
|
||||
_isSubscribed = true;
|
||||
}
|
||||
|
||||
UpdateMonitoringLeaseState();
|
||||
UpdateTimerState();
|
||||
RefreshVisual();
|
||||
}
|
||||
|
||||
@@ -117,7 +123,13 @@ public partial class StudyInterruptDensityWidget : UserControl, IDesktopComponen
|
||||
_isAttached = false;
|
||||
_monitoringLease?.Dispose();
|
||||
_monitoringLease = null;
|
||||
_uiTimer.Stop();
|
||||
_renderGate.Clear();
|
||||
|
||||
if (_isSubscribed)
|
||||
{
|
||||
_studyAnalyticsService.SnapshotUpdated -= OnStudySnapshotUpdated;
|
||||
_isSubscribed = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
@@ -131,24 +143,19 @@ public partial class StudyInterruptDensityWidget : UserControl, IDesktopComponen
|
||||
RefreshVisual();
|
||||
}
|
||||
|
||||
private void OnUiTimerTick(object? sender, EventArgs e)
|
||||
private void OnStudySnapshotUpdated(object? sender, StudyAnalyticsSnapshotChangedEventArgs e)
|
||||
{
|
||||
RefreshVisual();
|
||||
}
|
||||
|
||||
private void UpdateTimerState()
|
||||
{
|
||||
if (_isAttached && _isOnActivePage)
|
||||
if (!_isAttached || !_isOnActivePage)
|
||||
{
|
||||
if (!_uiTimer.IsEnabled)
|
||||
{
|
||||
_uiTimer.Start();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_uiTimer.Stop();
|
||||
_renderGate.Queue(e.Snapshot);
|
||||
}
|
||||
|
||||
private bool CanRenderSnapshot()
|
||||
{
|
||||
return _isAttached && _isOnActivePage;
|
||||
}
|
||||
|
||||
private void UpdateMonitoringLeaseState()
|
||||
@@ -172,6 +179,11 @@ public partial class StudyInterruptDensityWidget : UserControl, IDesktopComponen
|
||||
}
|
||||
|
||||
private void RefreshVisual()
|
||||
{
|
||||
_renderGate.Queue(_studyAnalyticsService.GetSnapshot());
|
||||
}
|
||||
|
||||
private void ApplySnapshot(StudyAnalyticsSnapshot snapshot)
|
||||
{
|
||||
var panelColor = ResolvePanelBackgroundColor();
|
||||
ApplyTypographyByBackground(panelColor);
|
||||
@@ -186,8 +198,6 @@ public partial class StudyInterruptDensityWidget : UserControl, IDesktopComponen
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshot = _studyAnalyticsService.GetSnapshot();
|
||||
|
||||
var isSessionRunning = snapshot.Session.State == StudySessionRuntimeState.Running;
|
||||
var isSessionReport = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null;
|
||||
var isSessionView = isSessionRunning || isSessionReport;
|
||||
|
||||
@@ -10,6 +10,10 @@ namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public sealed class StudyNoiseCurveChartControl : Control
|
||||
{
|
||||
private const double MinDisplayDb = 20;
|
||||
private const double MaxDisplayDb = 100;
|
||||
private const double DynamicTailSeconds = 4;
|
||||
|
||||
private static readonly IBrush GridBrush = new SolidColorBrush(Color.Parse("#324E6780"));
|
||||
private static readonly IBrush AxisBrush = new SolidColorBrush(Color.Parse("#5C6D86A1"));
|
||||
private static readonly IBrush LineBrush = new SolidColorBrush(Color.Parse("#FF52AEEA"));
|
||||
@@ -20,11 +24,24 @@ public sealed class StudyNoiseCurveChartControl : Control
|
||||
|
||||
private IReadOnlyList<NoiseRealtimePoint> _points = Array.Empty<NoiseRealtimePoint>();
|
||||
private Point[]? _pointBuffer;
|
||||
private StreamGeometry? _lineGeometry;
|
||||
private StreamGeometry? _fillGeometry;
|
||||
private StreamGeometry? _gridGeometry;
|
||||
private StreamGeometry? _axisGeometry;
|
||||
private StreamGeometry? _staticLineGeometry;
|
||||
private StreamGeometry? _staticFillGeometry;
|
||||
private StreamGeometry? _dynamicLineGeometry;
|
||||
private StreamGeometry? _dynamicFillGeometry;
|
||||
private Rect _cachedPlot;
|
||||
private Rect _cachedGridPlot;
|
||||
private DateTimeOffset _logicalOrigin;
|
||||
private DateTimeOffset _lastSeriesStart;
|
||||
private DateTimeOffset _lastSeriesEnd;
|
||||
private double _cachedPixelsPerSecond;
|
||||
private double _viewportTranslateX;
|
||||
private bool _hasLogicalOrigin;
|
||||
private bool _geometryDirty = true;
|
||||
private int _lastSeriesSignature;
|
||||
private int _staticSourceCount;
|
||||
private int _dynamicSourceCount;
|
||||
|
||||
public void UpdateSeries(IReadOnlyList<NoiseRealtimePoint>? points)
|
||||
{
|
||||
@@ -35,6 +52,7 @@ public sealed class StudyNoiseCurveChartControl : Control
|
||||
return;
|
||||
}
|
||||
|
||||
UpdateLogicalOrigin(nextPoints);
|
||||
_points = nextPoints;
|
||||
_lastSeriesSignature = nextSignature;
|
||||
_geometryDirty = true;
|
||||
@@ -49,16 +67,90 @@ public sealed class StudyNoiseCurveChartControl : Control
|
||||
_pointBuffer = null;
|
||||
}
|
||||
|
||||
_lineGeometry = null;
|
||||
_fillGeometry = null;
|
||||
_staticLineGeometry = null;
|
||||
_staticFillGeometry = null;
|
||||
_dynamicLineGeometry = null;
|
||||
_dynamicFillGeometry = null;
|
||||
_geometryDirty = true;
|
||||
}
|
||||
|
||||
internal int StaticSourceCount => _staticSourceCount;
|
||||
|
||||
internal int DynamicSourceCount => _dynamicSourceCount;
|
||||
|
||||
internal void RebuildCacheForTesting(Rect plot)
|
||||
{
|
||||
if (_points.Count >= 2)
|
||||
{
|
||||
EnsureGeometry(plot);
|
||||
}
|
||||
}
|
||||
|
||||
internal static double ResolveVisibleDurationSeconds(IReadOnlyList<NoiseRealtimePoint> points)
|
||||
{
|
||||
if (points.Count < 2)
|
||||
{
|
||||
return 12;
|
||||
}
|
||||
|
||||
var duration = (points[^1].Timestamp - points[0].Timestamp).TotalSeconds;
|
||||
if (double.IsNaN(duration) || double.IsInfinity(duration) || duration <= 1)
|
||||
{
|
||||
duration = 12;
|
||||
}
|
||||
|
||||
return Math.Clamp(duration, 4, 60);
|
||||
}
|
||||
|
||||
internal static int ResolveFirstTailIndex(IReadOnlyList<NoiseRealtimePoint> points, TimeSpan tailDuration)
|
||||
{
|
||||
if (points.Count <= 1)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var cutoff = points[^1].Timestamp - tailDuration;
|
||||
for (var i = 0; i < points.Count; i++)
|
||||
{
|
||||
if (points[i].Timestamp >= cutoff)
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return points.Count - 1;
|
||||
}
|
||||
|
||||
internal static (int StaticSourceCount, int DynamicSourceCount) ResolveLayerSourceCounts(
|
||||
IReadOnlyList<NoiseRealtimePoint> points,
|
||||
TimeSpan tailDuration)
|
||||
{
|
||||
if (points.Count < 2)
|
||||
{
|
||||
return (0, 0);
|
||||
}
|
||||
|
||||
var firstTailIndex = ResolveFirstTailIndex(points, tailDuration);
|
||||
var dynamicStartIndex = Math.Max(0, firstTailIndex - 1);
|
||||
var staticCount = firstTailIndex >= 2 ? firstTailIndex : 0;
|
||||
var dynamicCount = points.Count - dynamicStartIndex >= 2 ? points.Count - dynamicStartIndex : 0;
|
||||
return (staticCount, dynamicCount);
|
||||
}
|
||||
|
||||
internal static double MapTimestampToLogicalX(DateTimeOffset timestamp, DateTimeOffset origin, double pixelsPerSecond)
|
||||
{
|
||||
return Math.Max(0, (timestamp - origin).TotalSeconds * pixelsPerSecond);
|
||||
}
|
||||
|
||||
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
ReleasePointBuffer();
|
||||
_lineGeometry = null;
|
||||
_fillGeometry = null;
|
||||
_gridGeometry = null;
|
||||
_axisGeometry = null;
|
||||
_staticLineGeometry = null;
|
||||
_staticFillGeometry = null;
|
||||
_dynamicLineGeometry = null;
|
||||
_dynamicFillGeometry = null;
|
||||
_geometryDirty = true;
|
||||
base.OnDetachedFromVisualTree(e);
|
||||
}
|
||||
@@ -86,53 +178,194 @@ public sealed class StudyNoiseCurveChartControl : Control
|
||||
}
|
||||
|
||||
EnsureGeometry(plot);
|
||||
if (_lineGeometry is null || _fillGeometry is null)
|
||||
if (_staticLineGeometry is null &&
|
||||
_staticFillGeometry is null &&
|
||||
_dynamicLineGeometry is null &&
|
||||
_dynamicFillGeometry is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
context.DrawGeometry(FillBrush, pen: null, _fillGeometry);
|
||||
context.DrawGeometry(brush: null, pen: LinePen, _lineGeometry);
|
||||
using (context.PushClip(plot))
|
||||
using (context.PushTransform(Matrix.CreateTranslation(_viewportTranslateX, 0)))
|
||||
{
|
||||
if (_staticFillGeometry is not null)
|
||||
{
|
||||
context.DrawGeometry(FillBrush, pen: null, _staticFillGeometry);
|
||||
}
|
||||
|
||||
if (_dynamicFillGeometry is not null)
|
||||
{
|
||||
context.DrawGeometry(FillBrush, pen: null, _dynamicFillGeometry);
|
||||
}
|
||||
|
||||
if (_staticLineGeometry is not null)
|
||||
{
|
||||
context.DrawGeometry(brush: null, pen: LinePen, _staticLineGeometry);
|
||||
}
|
||||
|
||||
if (_dynamicLineGeometry is not null)
|
||||
{
|
||||
context.DrawGeometry(brush: null, pen: LinePen, _dynamicLineGeometry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void DrawGrid(DrawingContext context, Rect plot)
|
||||
private void UpdateLogicalOrigin(IReadOnlyList<NoiseRealtimePoint> nextPoints)
|
||||
{
|
||||
if (nextPoints.Count == 0)
|
||||
{
|
||||
_hasLogicalOrigin = false;
|
||||
_lastSeriesStart = default;
|
||||
_lastSeriesEnd = default;
|
||||
return;
|
||||
}
|
||||
|
||||
var nextStart = nextPoints[0].Timestamp;
|
||||
var nextEnd = nextPoints[^1].Timestamp;
|
||||
if (!_hasLogicalOrigin)
|
||||
{
|
||||
ResetLogicalOrigin(nextStart);
|
||||
}
|
||||
else
|
||||
{
|
||||
var overlapsPreviousSeries = nextStart <= _lastSeriesEnd && nextEnd >= _lastSeriesStart;
|
||||
if (!overlapsPreviousSeries || nextStart < _logicalOrigin)
|
||||
{
|
||||
ResetLogicalOrigin(nextStart);
|
||||
}
|
||||
}
|
||||
|
||||
_lastSeriesStart = nextStart;
|
||||
_lastSeriesEnd = nextEnd;
|
||||
}
|
||||
|
||||
private void ResetLogicalOrigin(DateTimeOffset origin)
|
||||
{
|
||||
_logicalOrigin = origin;
|
||||
_hasLogicalOrigin = true;
|
||||
_staticLineGeometry = null;
|
||||
_staticFillGeometry = null;
|
||||
_dynamicLineGeometry = null;
|
||||
_dynamicFillGeometry = null;
|
||||
_geometryDirty = true;
|
||||
}
|
||||
|
||||
private void DrawGrid(DrawingContext context, Rect plot)
|
||||
{
|
||||
if (_gridGeometry is null || _axisGeometry is null || _cachedGridPlot != plot)
|
||||
{
|
||||
_cachedGridPlot = plot;
|
||||
(_gridGeometry, _axisGeometry) = BuildGridGeometry(plot);
|
||||
}
|
||||
|
||||
context.DrawGeometry(brush: null, pen: GridPen, _gridGeometry);
|
||||
context.DrawGeometry(brush: null, pen: AxisPen, _axisGeometry);
|
||||
}
|
||||
|
||||
private static (StreamGeometry Grid, StreamGeometry Axis) BuildGridGeometry(Rect plot)
|
||||
{
|
||||
const int horizontalDivisions = 4;
|
||||
const int verticalDivisions = 4;
|
||||
|
||||
for (var i = 0; i <= horizontalDivisions; i++)
|
||||
var grid = new StreamGeometry();
|
||||
using (var builder = grid.Open())
|
||||
{
|
||||
var y = plot.Top + plot.Height * (i / (double)horizontalDivisions);
|
||||
context.DrawLine(GridPen, new Point(plot.Left, y), new Point(plot.Right, y));
|
||||
for (var i = 0; i <= horizontalDivisions; i++)
|
||||
{
|
||||
var y = plot.Top + plot.Height * (i / (double)horizontalDivisions);
|
||||
AddLine(builder, new Point(plot.Left, y), new Point(plot.Right, y));
|
||||
}
|
||||
|
||||
for (var i = 0; i <= verticalDivisions; i++)
|
||||
{
|
||||
var x = plot.Left + plot.Width * (i / (double)verticalDivisions);
|
||||
AddLine(builder, new Point(x, plot.Top), new Point(x, plot.Bottom));
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i <= verticalDivisions; i++)
|
||||
var axis = new StreamGeometry();
|
||||
using (var builder = axis.Open())
|
||||
{
|
||||
var x = plot.Left + plot.Width * (i / (double)verticalDivisions);
|
||||
context.DrawLine(GridPen, new Point(x, plot.Top), new Point(x, plot.Bottom));
|
||||
AddLine(builder, new Point(plot.Left, plot.Top), new Point(plot.Left, plot.Bottom));
|
||||
AddLine(builder, new Point(plot.Left, plot.Bottom), new Point(plot.Right, plot.Bottom));
|
||||
}
|
||||
|
||||
context.DrawLine(AxisPen, new Point(plot.Left, plot.Top), new Point(plot.Left, plot.Bottom));
|
||||
context.DrawLine(AxisPen, new Point(plot.Left, plot.Bottom), new Point(plot.Right, plot.Bottom));
|
||||
return (grid, axis);
|
||||
}
|
||||
|
||||
private static void AddLine(StreamGeometryContext builder, Point start, Point end)
|
||||
{
|
||||
builder.BeginFigure(start, isFilled: false);
|
||||
builder.LineTo(end);
|
||||
builder.EndFigure(isClosed: false);
|
||||
}
|
||||
|
||||
private void EnsureGeometry(Rect plot)
|
||||
{
|
||||
if (!_geometryDirty && _cachedPlot == plot)
|
||||
var visibleDurationSeconds = ResolveVisibleDurationSeconds(_points);
|
||||
var pixelsPerSecond = plot.Width / visibleDurationSeconds;
|
||||
var latestLogicalX = MapTimestampToLogicalX(_points[^1].Timestamp, _logicalOrigin, pixelsPerSecond);
|
||||
_viewportTranslateX = plot.Right - latestLogicalX;
|
||||
|
||||
if (!_geometryDirty &&
|
||||
_cachedPlot == plot &&
|
||||
Math.Abs(_cachedPixelsPerSecond - pixelsPerSecond) < 0.001)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_cachedPlot = plot;
|
||||
_lineGeometry = null;
|
||||
_fillGeometry = null;
|
||||
_cachedPixelsPerSecond = pixelsPerSecond;
|
||||
_staticLineGeometry = null;
|
||||
_staticFillGeometry = null;
|
||||
_dynamicLineGeometry = null;
|
||||
_dynamicFillGeometry = null;
|
||||
_staticSourceCount = 0;
|
||||
_dynamicSourceCount = 0;
|
||||
|
||||
var firstTailIndex = ResolveFirstTailIndex(_points, TimeSpan.FromSeconds(DynamicTailSeconds));
|
||||
var dynamicStartIndex = Math.Max(0, firstTailIndex - 1);
|
||||
var staticEndExclusive = firstTailIndex;
|
||||
|
||||
if (staticEndExclusive >= 2)
|
||||
{
|
||||
(_staticLineGeometry, _staticFillGeometry, _staticSourceCount) = BuildLayerGeometry(
|
||||
startIndex: 0,
|
||||
endExclusive: staticEndExclusive,
|
||||
plot,
|
||||
pixelsPerSecond);
|
||||
}
|
||||
|
||||
if (_points.Count - dynamicStartIndex >= 2)
|
||||
{
|
||||
(_dynamicLineGeometry, _dynamicFillGeometry, _dynamicSourceCount) = BuildLayerGeometry(
|
||||
dynamicStartIndex,
|
||||
_points.Count,
|
||||
plot,
|
||||
pixelsPerSecond);
|
||||
}
|
||||
|
||||
_geometryDirty = false;
|
||||
}
|
||||
|
||||
private (StreamGeometry? Line, StreamGeometry? Fill, int SourceCount) BuildLayerGeometry(
|
||||
int startIndex,
|
||||
int endExclusive,
|
||||
Rect plot,
|
||||
double pixelsPerSecond)
|
||||
{
|
||||
var sourceCount = endExclusive - startIndex;
|
||||
if (sourceCount < 2)
|
||||
{
|
||||
return (null, null, sourceCount);
|
||||
}
|
||||
|
||||
var maxSamples = Math.Clamp((int)Math.Floor(plot.Width), 56, 360);
|
||||
var pointCount = BuildPlotPoints(plot, maxSamples);
|
||||
var pointCount = BuildPlotPoints(startIndex, endExclusive, plot, pixelsPerSecond, maxSamples);
|
||||
if (pointCount < 2 || _pointBuffer is null)
|
||||
{
|
||||
_geometryDirty = false;
|
||||
return;
|
||||
return (null, null, sourceCount);
|
||||
}
|
||||
|
||||
var lineGeometry = new StreamGeometry();
|
||||
@@ -163,14 +396,17 @@ public sealed class StudyNoiseCurveChartControl : Control
|
||||
builder.EndFigure(true);
|
||||
}
|
||||
|
||||
_lineGeometry = lineGeometry;
|
||||
_fillGeometry = fillGeometry;
|
||||
_geometryDirty = false;
|
||||
return (lineGeometry, fillGeometry, sourceCount);
|
||||
}
|
||||
|
||||
private int BuildPlotPoints(Rect plot, int maxSamples)
|
||||
private int BuildPlotPoints(
|
||||
int startIndex,
|
||||
int endExclusive,
|
||||
Rect plot,
|
||||
double pixelsPerSecond,
|
||||
int maxSamples)
|
||||
{
|
||||
var sourceCount = _points.Count;
|
||||
var sourceCount = endExclusive - startIndex;
|
||||
if (sourceCount <= 1)
|
||||
{
|
||||
return 0;
|
||||
@@ -186,7 +422,7 @@ public sealed class StudyNoiseCurveChartControl : Control
|
||||
|
||||
for (var i = 0; i < sourceCount; i++)
|
||||
{
|
||||
_pointBuffer[i] = MapToPlot(plot, _points[i], _points[0].Timestamp, _points[^1].Timestamp);
|
||||
_pointBuffer[i] = MapToPlot(plot, _points[startIndex + i], pixelsPerSecond);
|
||||
}
|
||||
|
||||
return sourceCount;
|
||||
@@ -201,25 +437,23 @@ public sealed class StudyNoiseCurveChartControl : Control
|
||||
}
|
||||
|
||||
var outputIndex = 0;
|
||||
var startTimestamp = _points[0].Timestamp;
|
||||
var endTimestamp = _points[^1].Timestamp;
|
||||
_pointBuffer[outputIndex++] = MapToPlot(plot, _points[0], startTimestamp, endTimestamp);
|
||||
_pointBuffer[outputIndex++] = MapToPlot(plot, _points[startIndex], pixelsPerSecond);
|
||||
|
||||
var middleCount = sourceCount - 2;
|
||||
var bucketWidth = middleCount / (double)bucketCount;
|
||||
var lastSourceIndex = 0;
|
||||
var lastSourceIndex = startIndex;
|
||||
|
||||
for (var bucket = 0; bucket < bucketCount; bucket++)
|
||||
{
|
||||
var rangeStart = 1 + (int)Math.Floor(bucket * bucketWidth);
|
||||
var rangeEnd = 1 + (int)Math.Floor((bucket + 1) * bucketWidth);
|
||||
var rangeStart = startIndex + 1 + (int)Math.Floor(bucket * bucketWidth);
|
||||
var rangeEnd = startIndex + 1 + (int)Math.Floor((bucket + 1) * bucketWidth);
|
||||
if (bucket == bucketCount - 1)
|
||||
{
|
||||
rangeEnd = sourceCount - 1;
|
||||
rangeEnd = endExclusive - 1;
|
||||
}
|
||||
|
||||
rangeStart = Math.Clamp(rangeStart, 1, sourceCount - 2);
|
||||
rangeEnd = Math.Clamp(rangeEnd, rangeStart + 1, sourceCount - 1);
|
||||
rangeStart = Math.Clamp(rangeStart, startIndex + 1, endExclusive - 2);
|
||||
rangeEnd = Math.Clamp(rangeEnd, rangeStart + 1, endExclusive - 1);
|
||||
|
||||
var minIndex = rangeStart;
|
||||
var maxIndex = rangeStart;
|
||||
@@ -246,7 +480,7 @@ public sealed class StudyNoiseCurveChartControl : Control
|
||||
{
|
||||
if (minIndex != lastSourceIndex)
|
||||
{
|
||||
_pointBuffer[outputIndex++] = MapToPlot(plot, _points[minIndex], startTimestamp, endTimestamp);
|
||||
_pointBuffer[outputIndex++] = MapToPlot(plot, _points[minIndex], pixelsPerSecond);
|
||||
lastSourceIndex = minIndex;
|
||||
}
|
||||
|
||||
@@ -258,41 +492,31 @@ public sealed class StudyNoiseCurveChartControl : Control
|
||||
|
||||
if (first != lastSourceIndex)
|
||||
{
|
||||
_pointBuffer[outputIndex++] = MapToPlot(plot, _points[first], startTimestamp, endTimestamp);
|
||||
_pointBuffer[outputIndex++] = MapToPlot(plot, _points[first], pixelsPerSecond);
|
||||
lastSourceIndex = first;
|
||||
}
|
||||
|
||||
if (second != lastSourceIndex)
|
||||
{
|
||||
_pointBuffer[outputIndex++] = MapToPlot(plot, _points[second], startTimestamp, endTimestamp);
|
||||
_pointBuffer[outputIndex++] = MapToPlot(plot, _points[second], pixelsPerSecond);
|
||||
lastSourceIndex = second;
|
||||
}
|
||||
}
|
||||
|
||||
var finalIndex = sourceCount - 1;
|
||||
var finalIndex = endExclusive - 1;
|
||||
if (finalIndex != lastSourceIndex)
|
||||
{
|
||||
_pointBuffer[outputIndex++] = MapToPlot(plot, _points[finalIndex], startTimestamp, endTimestamp);
|
||||
_pointBuffer[outputIndex++] = MapToPlot(plot, _points[finalIndex], pixelsPerSecond);
|
||||
}
|
||||
|
||||
return outputIndex;
|
||||
}
|
||||
|
||||
private static Point MapToPlot(
|
||||
Rect plot,
|
||||
NoiseRealtimePoint point,
|
||||
DateTimeOffset start,
|
||||
DateTimeOffset end)
|
||||
private Point MapToPlot(Rect plot, NoiseRealtimePoint point, double pixelsPerSecond)
|
||||
{
|
||||
const double minDisplayDb = 20;
|
||||
const double maxDisplayDb = 100;
|
||||
|
||||
var rangeTicks = Math.Max(1, (end - start).Ticks);
|
||||
var offsetTicks = Math.Clamp((point.Timestamp - start).Ticks, 0, rangeTicks);
|
||||
var x = plot.Left + plot.Width * (offsetTicks / (double)rangeTicks);
|
||||
|
||||
var clampedDb = Math.Clamp(point.DisplayDb, minDisplayDb, maxDisplayDb);
|
||||
var normalized = (clampedDb - minDisplayDb) / (maxDisplayDb - minDisplayDb);
|
||||
var x = MapTimestampToLogicalX(point.Timestamp, _logicalOrigin, pixelsPerSecond);
|
||||
var clampedDb = Math.Clamp(point.DisplayDb, MinDisplayDb, MaxDisplayDb);
|
||||
var normalized = (clampedDb - MinDisplayDb) / (MaxDisplayDb - MinDisplayDb);
|
||||
var y = plot.Bottom - normalized * plot.Height;
|
||||
return new Point(x, y);
|
||||
}
|
||||
@@ -341,6 +565,7 @@ public sealed class StudyNoiseCurveChartControl : Control
|
||||
return HashCode.Combine(
|
||||
points.Count,
|
||||
first.Timestamp.UtcTicks,
|
||||
Math.Round(first.DisplayDb, 2),
|
||||
last.Timestamp.UtcTicks,
|
||||
Math.Round(last.DisplayDb, 2));
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ using System.Globalization;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Theme;
|
||||
@@ -52,18 +51,12 @@ public partial class StudyNoiseCurveWidget : UserControl, IDesktopComponentWidge
|
||||
private static readonly Color DarkSubstrate = Color.Parse("#FF0B1220");
|
||||
private static readonly Color LightSubstrate = Color.Parse("#FFF1F5FA");
|
||||
|
||||
private readonly object _snapshotSync = new();
|
||||
private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault();
|
||||
private readonly StudyAnalyticsMonitoringLeaseCoordinator _monitoringLeaseCoordinator = StudyAnalyticsMonitoringLeaseCoordinatorFactory.CreateDefault();
|
||||
private LanMountainDesktop.PluginSdk.ISettingsService _settingsService = LanMountainDesktop.Services.Settings.HostSettingsFacadeProvider.GetOrCreate().Settings;
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly DispatcherTimer _renderTimer = new()
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(33)
|
||||
};
|
||||
private readonly StudySnapshotRenderGate _renderGate;
|
||||
|
||||
private StudyAnalyticsSnapshot? _pendingSnapshot;
|
||||
private bool _hasPendingSnapshot;
|
||||
private double _currentCellSize = 48;
|
||||
private string _languageCode = "zh-CN";
|
||||
private bool _isAttached;
|
||||
@@ -86,7 +79,7 @@ public partial class StudyNoiseCurveWidget : UserControl, IDesktopComponentWidge
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
_renderTimer.Tick += OnRenderTimerTick;
|
||||
_renderGate = new StudySnapshotRenderGate(CanRenderSnapshot, ApplySnapshot, AfterSnapshotRendered);
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
SizeChanged += OnSizeChanged;
|
||||
@@ -141,14 +134,8 @@ public partial class StudyNoiseCurveWidget : UserControl, IDesktopComponentWidge
|
||||
|
||||
if (isOnActivePage && !wasOnActivePage)
|
||||
{
|
||||
lock (_snapshotSync)
|
||||
{
|
||||
_pendingSnapshot = _studyAnalyticsService.GetSnapshot();
|
||||
_hasPendingSnapshot = true;
|
||||
}
|
||||
_renderGate.Queue(_studyAnalyticsService.GetSnapshot());
|
||||
}
|
||||
|
||||
UpdateRenderLoopState();
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
@@ -164,13 +151,7 @@ public partial class StudyNoiseCurveWidget : UserControl, IDesktopComponentWidge
|
||||
|
||||
UpdateMonitoringLeaseState();
|
||||
|
||||
lock (_snapshotSync)
|
||||
{
|
||||
_pendingSnapshot = _studyAnalyticsService.GetSnapshot();
|
||||
_hasPendingSnapshot = true;
|
||||
}
|
||||
|
||||
UpdateRenderLoopState();
|
||||
_renderGate.Queue(_studyAnalyticsService.GetSnapshot());
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
@@ -178,7 +159,7 @@ public partial class StudyNoiseCurveWidget : UserControl, IDesktopComponentWidge
|
||||
_isAttached = false;
|
||||
_monitoringLease?.Dispose();
|
||||
_monitoringLease = null;
|
||||
_renderTimer.Stop();
|
||||
_renderGate.Clear();
|
||||
|
||||
if (_isSubscribed)
|
||||
{
|
||||
@@ -200,45 +181,26 @@ public partial class StudyNoiseCurveWidget : UserControl, IDesktopComponentWidge
|
||||
ApplyTypographyByBackground(panelColor);
|
||||
ApplyStatusBadgeStyle(StatusVisualKind.Default, panelColor);
|
||||
|
||||
lock (_snapshotSync)
|
||||
{
|
||||
_pendingSnapshot = _studyAnalyticsService.GetSnapshot();
|
||||
_hasPendingSnapshot = true;
|
||||
}
|
||||
|
||||
if (!_renderTimer.IsEnabled)
|
||||
{
|
||||
OnRenderTimerTick(this, EventArgs.Empty);
|
||||
}
|
||||
_renderGate.Queue(_studyAnalyticsService.GetSnapshot());
|
||||
}
|
||||
|
||||
private void OnStudySnapshotUpdated(object? sender, StudyAnalyticsSnapshotChangedEventArgs e)
|
||||
{
|
||||
lock (_snapshotSync)
|
||||
{
|
||||
_pendingSnapshot = e.Snapshot;
|
||||
_hasPendingSnapshot = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRenderTimerTick(object? sender, EventArgs e)
|
||||
{
|
||||
StudyAnalyticsSnapshot? snapshot = null;
|
||||
lock (_snapshotSync)
|
||||
{
|
||||
if (_hasPendingSnapshot)
|
||||
{
|
||||
snapshot = _pendingSnapshot;
|
||||
_hasPendingSnapshot = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (snapshot is null)
|
||||
if (!_isAttached || !_isOnActivePage)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ApplySnapshot(snapshot);
|
||||
_renderGate.Queue(e.Snapshot);
|
||||
}
|
||||
|
||||
private bool CanRenderSnapshot()
|
||||
{
|
||||
return _isAttached && _isOnActivePage;
|
||||
}
|
||||
|
||||
private void AfterSnapshotRendered()
|
||||
{
|
||||
_framesSinceCompaction++;
|
||||
if (_framesSinceCompaction >= 900)
|
||||
{
|
||||
@@ -247,21 +209,6 @@ public partial class StudyNoiseCurveWidget : UserControl, IDesktopComponentWidge
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateRenderLoopState()
|
||||
{
|
||||
if (_isAttached && _isOnActivePage)
|
||||
{
|
||||
if (!_renderTimer.IsEnabled)
|
||||
{
|
||||
_renderTimer.Start();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_renderTimer.Stop();
|
||||
}
|
||||
|
||||
private void UpdateMonitoringLeaseState()
|
||||
{
|
||||
if (!_studyEnabled)
|
||||
@@ -271,7 +218,8 @@ public partial class StudyNoiseCurveWidget : UserControl, IDesktopComponentWidge
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isAttached)
|
||||
var shouldMonitor = _isAttached && _isOnActivePage;
|
||||
if (shouldMonitor)
|
||||
{
|
||||
_monitoringLease ??= _monitoringLeaseCoordinator.AcquireLease();
|
||||
return;
|
||||
@@ -612,8 +560,7 @@ public partial class StudyNoiseCurveWidget : UserControl, IDesktopComponentWidge
|
||||
|
||||
_isDisposed = true;
|
||||
|
||||
_renderTimer.Stop();
|
||||
_renderTimer.Tick -= OnRenderTimerTick;
|
||||
_renderGate.Dispose();
|
||||
AttachedToVisualTree -= OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree -= OnDetachedFromVisualTree;
|
||||
SizeChanged -= OnSizeChanged;
|
||||
|
||||
@@ -0,0 +1,896 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using LanMountainDesktop.Models;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public sealed class StudyNoiseDistributionAreaChartControl : Control
|
||||
{
|
||||
private const double DynamicTailSeconds = 4;
|
||||
private const double RealtimeVisibleSeconds = 12;
|
||||
|
||||
private static readonly IBrush GridBrush = new SolidColorBrush(Color.Parse("#304E6780"));
|
||||
private static readonly IBrush AxisBrush = new SolidColorBrush(Color.Parse("#5C6D86A1"));
|
||||
private static readonly Pen GridPen = new(GridBrush, 1);
|
||||
private static readonly Pen AxisPen = new(AxisBrush, 1.1);
|
||||
|
||||
private static readonly IBrush QuietBandBrush = new SolidColorBrush(Color.Parse("#1834D399"));
|
||||
private static readonly IBrush NormalBandBrush = new SolidColorBrush(Color.Parse("#1760A5FA"));
|
||||
private static readonly IBrush NoisyBandBrush = new SolidColorBrush(Color.Parse("#16F59E0B"));
|
||||
private static readonly IBrush ExtremeBandBrush = new SolidColorBrush(Color.Parse("#18EF4444"));
|
||||
private static readonly IBrush AreaFillBrush = CreateAreaGradientBrush(0x56);
|
||||
private static readonly IBrush DynamicAreaFillBrush = CreateAreaGradientBrush(0x78);
|
||||
private static readonly IBrush LatestGlowBrush = new SolidColorBrush(Color.Parse("#5852D6FF"));
|
||||
private static readonly IBrush LatestPointBrush = new SolidColorBrush(Color.Parse("#FFFFFFFF"));
|
||||
private static readonly Pen StaticLinePen = new(new SolidColorBrush(Color.Parse("#C452AEEA")), 1.35);
|
||||
private static readonly Pen DynamicLinePen = new(new SolidColorBrush(Color.Parse("#FF8BE8FF")), 1.9);
|
||||
private static readonly Pen LatestPointPen = new(new SolidColorBrush(Color.Parse("#FF52D6FF")), 1.4);
|
||||
|
||||
private IReadOnlyList<NoiseRealtimePoint> _points = Array.Empty<NoiseRealtimePoint>();
|
||||
private Point[]? _pointBuffer;
|
||||
private StreamGeometry? _gridGeometry;
|
||||
private StreamGeometry? _axisGeometry;
|
||||
private StreamGeometry? _staticLineGeometry;
|
||||
private StreamGeometry? _staticFillGeometry;
|
||||
private StreamGeometry? _dynamicLineGeometry;
|
||||
private StreamGeometry? _dynamicFillGeometry;
|
||||
private Rect _cachedGridPlot;
|
||||
private Rect _cachedPlot;
|
||||
private DateTimeOffset _logicalOrigin;
|
||||
private DateTimeOffset _lastSeriesStart;
|
||||
private DateTimeOffset _lastSeriesEnd;
|
||||
private double _baselineDb = 45;
|
||||
private double _cachedBaselineDb = 45;
|
||||
private double _cachedPixelsPerSecond;
|
||||
private double _viewportTranslateX;
|
||||
private bool _hasLogicalOrigin;
|
||||
private bool _isStaticSeries;
|
||||
private bool _staticGeometryDirty = true;
|
||||
private bool _dynamicGeometryDirty = true;
|
||||
private int _lastSeriesSignature;
|
||||
private int _cachedStaticEndExclusive = -1;
|
||||
private int _cachedDynamicStartIndex = -1;
|
||||
private int _staticSourceCount;
|
||||
private int _dynamicSourceCount;
|
||||
private int _staticPathBuildVersion;
|
||||
private int _dynamicPathBuildVersion;
|
||||
private int _cachedPathCountForTesting;
|
||||
|
||||
public void UpdateSeries(IReadOnlyList<NoiseRealtimePoint>? points, double baselineDb)
|
||||
{
|
||||
UpdateSeries(points, baselineDb, isStaticSeries: false);
|
||||
}
|
||||
|
||||
public void UpdateSeries(IReadOnlyList<NoiseRealtimePoint>? points, double baselineDb, bool isStaticSeries)
|
||||
{
|
||||
var nextPoints = points ?? Array.Empty<NoiseRealtimePoint>();
|
||||
var nextBaselineDb = Math.Clamp(baselineDb, 20, 85);
|
||||
var nextSignature = ComputeSeriesSignature(nextPoints, nextBaselineDb, isStaticSeries);
|
||||
if (ReferenceEquals(_points, nextPoints) &&
|
||||
Math.Abs(_baselineDb - nextBaselineDb) < 0.001 &&
|
||||
_isStaticSeries == isStaticSeries &&
|
||||
_lastSeriesSignature == nextSignature)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var baselineChanged = Math.Abs(_baselineDb - nextBaselineDb) >= 0.001;
|
||||
var modeChanged = _isStaticSeries != isStaticSeries;
|
||||
UpdateLogicalOrigin(nextPoints, baselineChanged || modeChanged);
|
||||
|
||||
_points = nextPoints;
|
||||
_baselineDb = nextBaselineDb;
|
||||
_isStaticSeries = isStaticSeries;
|
||||
_lastSeriesSignature = nextSignature;
|
||||
_dynamicGeometryDirty = true;
|
||||
if (baselineChanged || modeChanged || nextPoints.Count < 2)
|
||||
{
|
||||
_staticGeometryDirty = true;
|
||||
}
|
||||
|
||||
InvalidateVisual();
|
||||
}
|
||||
|
||||
public void CompactCaches()
|
||||
{
|
||||
ReleasePointBuffer();
|
||||
_staticLineGeometry = null;
|
||||
_staticFillGeometry = null;
|
||||
_dynamicLineGeometry = null;
|
||||
_dynamicFillGeometry = null;
|
||||
_staticGeometryDirty = true;
|
||||
_dynamicGeometryDirty = true;
|
||||
_cachedStaticEndExclusive = -1;
|
||||
_cachedDynamicStartIndex = -1;
|
||||
_cachedPathCountForTesting = 0;
|
||||
}
|
||||
|
||||
internal int StaticSourceCount => _staticSourceCount;
|
||||
|
||||
internal int DynamicSourceCount => _dynamicSourceCount;
|
||||
|
||||
internal int CachedPathCount
|
||||
{
|
||||
get
|
||||
{
|
||||
var count = 0;
|
||||
if (_staticLineGeometry is not null)
|
||||
{
|
||||
count++;
|
||||
}
|
||||
|
||||
if (_staticFillGeometry is not null)
|
||||
{
|
||||
count++;
|
||||
}
|
||||
|
||||
if (_dynamicLineGeometry is not null)
|
||||
{
|
||||
count++;
|
||||
}
|
||||
|
||||
if (_dynamicFillGeometry is not null)
|
||||
{
|
||||
count++;
|
||||
}
|
||||
|
||||
return count > 0 ? count : _cachedPathCountForTesting;
|
||||
}
|
||||
}
|
||||
|
||||
internal int StaticPathBuildVersion => _staticPathBuildVersion;
|
||||
|
||||
internal int DynamicPathBuildVersion => _dynamicPathBuildVersion;
|
||||
|
||||
internal void RebuildCacheForTesting(Rect plot)
|
||||
{
|
||||
if (_points.Count >= 2)
|
||||
{
|
||||
EnsureGeometryPlanForTesting(plot);
|
||||
}
|
||||
}
|
||||
|
||||
internal static double ResolveVisibleDurationSeconds(IReadOnlyList<NoiseRealtimePoint> points)
|
||||
{
|
||||
if (points.Count < 2)
|
||||
{
|
||||
return RealtimeVisibleSeconds;
|
||||
}
|
||||
|
||||
var duration = (points[^1].Timestamp - points[0].Timestamp).TotalSeconds;
|
||||
if (double.IsNaN(duration) || double.IsInfinity(duration) || duration <= 1)
|
||||
{
|
||||
duration = RealtimeVisibleSeconds;
|
||||
}
|
||||
|
||||
return Math.Clamp(duration, 4, 60);
|
||||
}
|
||||
|
||||
internal static int ResolveFirstTailIndex(IReadOnlyList<NoiseRealtimePoint> points, TimeSpan tailDuration)
|
||||
{
|
||||
if (points.Count <= 1)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var cutoff = points[^1].Timestamp - tailDuration;
|
||||
for (var i = 0; i < points.Count; i++)
|
||||
{
|
||||
if (points[i].Timestamp >= cutoff)
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return points.Count - 1;
|
||||
}
|
||||
|
||||
internal static (int StaticSourceCount, int DynamicSourceCount) ResolveLayerSourceCounts(
|
||||
IReadOnlyList<NoiseRealtimePoint> points,
|
||||
TimeSpan tailDuration,
|
||||
bool isStaticSeries = false)
|
||||
{
|
||||
if (points.Count < 2)
|
||||
{
|
||||
return (0, 0);
|
||||
}
|
||||
|
||||
if (isStaticSeries)
|
||||
{
|
||||
return (points.Count, 0);
|
||||
}
|
||||
|
||||
var firstTailIndex = ResolveFirstTailIndex(points, tailDuration);
|
||||
var dynamicStartIndex = Math.Max(0, firstTailIndex - 1);
|
||||
var staticCount = firstTailIndex >= 2 ? firstTailIndex : 0;
|
||||
var dynamicCount = points.Count - dynamicStartIndex >= 2 ? points.Count - dynamicStartIndex : 0;
|
||||
return (staticCount, dynamicCount);
|
||||
}
|
||||
|
||||
internal static double MapTimestampToLogicalX(DateTimeOffset timestamp, DateTimeOffset origin, double pixelsPerSecond)
|
||||
{
|
||||
return Math.Max(0, (timestamp - origin).TotalSeconds * pixelsPerSecond);
|
||||
}
|
||||
|
||||
internal static NoiseDistributionLevel ResolveLevel(double displayDb, double baselineDb)
|
||||
{
|
||||
var quietUpper = baselineDb;
|
||||
var normalUpper = baselineDb + 10d;
|
||||
var noisyUpper = baselineDb + 20d;
|
||||
|
||||
if (displayDb < quietUpper)
|
||||
{
|
||||
return NoiseDistributionLevel.Quiet;
|
||||
}
|
||||
|
||||
if (displayDb < normalUpper)
|
||||
{
|
||||
return NoiseDistributionLevel.Normal;
|
||||
}
|
||||
|
||||
if (displayDb < noisyUpper)
|
||||
{
|
||||
return NoiseDistributionLevel.Noisy;
|
||||
}
|
||||
|
||||
return NoiseDistributionLevel.Extreme;
|
||||
}
|
||||
|
||||
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
ReleasePointBuffer();
|
||||
_gridGeometry = null;
|
||||
_axisGeometry = null;
|
||||
_staticLineGeometry = null;
|
||||
_staticFillGeometry = null;
|
||||
_dynamicLineGeometry = null;
|
||||
_dynamicFillGeometry = null;
|
||||
_staticGeometryDirty = true;
|
||||
_dynamicGeometryDirty = true;
|
||||
base.OnDetachedFromVisualTree(e);
|
||||
}
|
||||
|
||||
public override void Render(DrawingContext context)
|
||||
{
|
||||
base.Render(context);
|
||||
|
||||
var bounds = Bounds;
|
||||
if (bounds.Width <= 2 || bounds.Height <= 2)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var plot = new Rect(
|
||||
x: 1,
|
||||
y: 1,
|
||||
width: Math.Max(1, bounds.Width - 2),
|
||||
height: Math.Max(1, bounds.Height - 2));
|
||||
|
||||
DrawLevelBands(context, plot);
|
||||
DrawGrid(context, plot);
|
||||
|
||||
if (_points.Count < 2)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EnsureGeometry(plot);
|
||||
|
||||
using (context.PushClip(plot))
|
||||
using (context.PushTransform(Matrix.CreateTranslation(_viewportTranslateX, 0)))
|
||||
{
|
||||
if (_staticFillGeometry is not null)
|
||||
{
|
||||
context.DrawGeometry(AreaFillBrush, pen: null, _staticFillGeometry);
|
||||
}
|
||||
|
||||
if (_dynamicFillGeometry is not null)
|
||||
{
|
||||
context.DrawGeometry(DynamicAreaFillBrush, pen: null, _dynamicFillGeometry);
|
||||
}
|
||||
|
||||
if (_staticLineGeometry is not null)
|
||||
{
|
||||
context.DrawGeometry(brush: null, pen: StaticLinePen, _staticLineGeometry);
|
||||
}
|
||||
|
||||
if (_dynamicLineGeometry is not null)
|
||||
{
|
||||
context.DrawGeometry(brush: null, pen: DynamicLinePen, _dynamicLineGeometry);
|
||||
}
|
||||
|
||||
DrawLatestPoint(context, plot);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateLogicalOrigin(IReadOnlyList<NoiseRealtimePoint> nextPoints, bool forceReset)
|
||||
{
|
||||
if (nextPoints.Count == 0)
|
||||
{
|
||||
ResetSeriesState();
|
||||
return;
|
||||
}
|
||||
|
||||
var nextStart = nextPoints[0].Timestamp;
|
||||
var nextEnd = nextPoints[^1].Timestamp;
|
||||
if (!_hasLogicalOrigin || forceReset)
|
||||
{
|
||||
ResetLogicalOrigin(nextStart);
|
||||
}
|
||||
else
|
||||
{
|
||||
var overlapsPreviousSeries = nextStart <= _lastSeriesEnd && nextEnd >= _lastSeriesStart;
|
||||
if (!overlapsPreviousSeries || nextStart < _logicalOrigin)
|
||||
{
|
||||
ResetLogicalOrigin(nextStart);
|
||||
}
|
||||
}
|
||||
|
||||
_lastSeriesStart = nextStart;
|
||||
_lastSeriesEnd = nextEnd;
|
||||
}
|
||||
|
||||
private void ResetLogicalOrigin(DateTimeOffset origin)
|
||||
{
|
||||
_logicalOrigin = origin;
|
||||
_hasLogicalOrigin = true;
|
||||
_staticLineGeometry = null;
|
||||
_staticFillGeometry = null;
|
||||
_dynamicLineGeometry = null;
|
||||
_dynamicFillGeometry = null;
|
||||
_staticSourceCount = 0;
|
||||
_dynamicSourceCount = 0;
|
||||
_cachedStaticEndExclusive = -1;
|
||||
_cachedDynamicStartIndex = -1;
|
||||
_cachedPathCountForTesting = 0;
|
||||
_staticGeometryDirty = true;
|
||||
_dynamicGeometryDirty = true;
|
||||
}
|
||||
|
||||
private void ResetSeriesState()
|
||||
{
|
||||
_hasLogicalOrigin = false;
|
||||
_lastSeriesStart = default;
|
||||
_lastSeriesEnd = default;
|
||||
_staticLineGeometry = null;
|
||||
_staticFillGeometry = null;
|
||||
_dynamicLineGeometry = null;
|
||||
_dynamicFillGeometry = null;
|
||||
_staticSourceCount = 0;
|
||||
_dynamicSourceCount = 0;
|
||||
_cachedStaticEndExclusive = -1;
|
||||
_cachedDynamicStartIndex = -1;
|
||||
_cachedPathCountForTesting = 0;
|
||||
_staticGeometryDirty = true;
|
||||
_dynamicGeometryDirty = true;
|
||||
}
|
||||
|
||||
private void DrawLevelBands(DrawingContext context, Rect plot)
|
||||
{
|
||||
var quietTop = MapDbToY(plot, _baselineDb);
|
||||
var normalTop = MapDbToY(plot, _baselineDb + 10d);
|
||||
var noisyTop = MapDbToY(plot, _baselineDb + 20d);
|
||||
|
||||
context.DrawRectangle(ExtremeBandBrush, null, new Rect(plot.Left, plot.Top, plot.Width, Math.Max(0, noisyTop - plot.Top)));
|
||||
context.DrawRectangle(NoisyBandBrush, null, new Rect(plot.Left, noisyTop, plot.Width, Math.Max(0, normalTop - noisyTop)));
|
||||
context.DrawRectangle(NormalBandBrush, null, new Rect(plot.Left, normalTop, plot.Width, Math.Max(0, quietTop - normalTop)));
|
||||
context.DrawRectangle(QuietBandBrush, null, new Rect(plot.Left, quietTop, plot.Width, Math.Max(0, plot.Bottom - quietTop)));
|
||||
}
|
||||
|
||||
private void DrawGrid(DrawingContext context, Rect plot)
|
||||
{
|
||||
if (_gridGeometry is null || _axisGeometry is null || _cachedGridPlot != plot)
|
||||
{
|
||||
_cachedGridPlot = plot;
|
||||
(_gridGeometry, _axisGeometry) = BuildGridGeometry(plot);
|
||||
}
|
||||
|
||||
context.DrawGeometry(brush: null, pen: GridPen, _gridGeometry);
|
||||
context.DrawGeometry(brush: null, pen: AxisPen, _axisGeometry);
|
||||
}
|
||||
|
||||
private static (StreamGeometry Grid, StreamGeometry Axis) BuildGridGeometry(Rect plot)
|
||||
{
|
||||
const int horizontalDivisions = 4;
|
||||
const int verticalDivisions = 4;
|
||||
|
||||
var grid = new StreamGeometry();
|
||||
using (var builder = grid.Open())
|
||||
{
|
||||
for (var i = 0; i <= horizontalDivisions; i++)
|
||||
{
|
||||
var y = plot.Top + plot.Height * (i / (double)horizontalDivisions);
|
||||
AddLine(builder, new Point(plot.Left, y), new Point(plot.Right, y));
|
||||
}
|
||||
|
||||
for (var i = 0; i <= verticalDivisions; i++)
|
||||
{
|
||||
var x = plot.Left + plot.Width * (i / (double)verticalDivisions);
|
||||
AddLine(builder, new Point(x, plot.Top), new Point(x, plot.Bottom));
|
||||
}
|
||||
}
|
||||
|
||||
var axis = new StreamGeometry();
|
||||
using (var builder = axis.Open())
|
||||
{
|
||||
AddLine(builder, new Point(plot.Left, plot.Top), new Point(plot.Left, plot.Bottom));
|
||||
AddLine(builder, new Point(plot.Left, plot.Bottom), new Point(plot.Right, plot.Bottom));
|
||||
}
|
||||
|
||||
return (grid, axis);
|
||||
}
|
||||
|
||||
private static void AddLine(StreamGeometryContext builder, Point start, Point end)
|
||||
{
|
||||
builder.BeginFigure(start, isFilled: false);
|
||||
builder.LineTo(end);
|
||||
builder.EndFigure(isClosed: false);
|
||||
}
|
||||
|
||||
private void EnsureGeometry(Rect plot)
|
||||
{
|
||||
var visibleDurationSeconds = _isStaticSeries
|
||||
? ResolveVisibleDurationSeconds(_points)
|
||||
: RealtimeVisibleSeconds;
|
||||
var pixelsPerSecond = plot.Width / Math.Max(0.001, visibleDurationSeconds);
|
||||
var latestLogicalX = MapTimestampToLogicalX(_points[^1].Timestamp, _logicalOrigin, pixelsPerSecond);
|
||||
_viewportTranslateX = plot.Right - latestLogicalX;
|
||||
|
||||
var metricsChanged = _cachedPlot != plot ||
|
||||
Math.Abs(_cachedPixelsPerSecond - pixelsPerSecond) >= 0.001 ||
|
||||
Math.Abs(_cachedBaselineDb - _baselineDb) >= 0.001;
|
||||
if (metricsChanged)
|
||||
{
|
||||
_cachedPlot = plot;
|
||||
_cachedPixelsPerSecond = pixelsPerSecond;
|
||||
_cachedBaselineDb = _baselineDb;
|
||||
_staticGeometryDirty = true;
|
||||
_dynamicGeometryDirty = true;
|
||||
}
|
||||
|
||||
var firstTailIndex = _isStaticSeries
|
||||
? _points.Count
|
||||
: ResolveFirstTailIndex(_points, TimeSpan.FromSeconds(DynamicTailSeconds));
|
||||
var dynamicStartIndex = _isStaticSeries ? _points.Count : Math.Max(0, firstTailIndex - 1);
|
||||
var staticEndExclusive = firstTailIndex;
|
||||
|
||||
if (staticEndExclusive != _cachedStaticEndExclusive)
|
||||
{
|
||||
_staticGeometryDirty = true;
|
||||
}
|
||||
|
||||
if (dynamicStartIndex != _cachedDynamicStartIndex)
|
||||
{
|
||||
_dynamicGeometryDirty = true;
|
||||
}
|
||||
|
||||
if (_staticGeometryDirty)
|
||||
{
|
||||
RebuildStaticGeometry(plot, pixelsPerSecond, staticEndExclusive);
|
||||
}
|
||||
|
||||
if (_dynamicGeometryDirty)
|
||||
{
|
||||
RebuildDynamicGeometry(plot, pixelsPerSecond, dynamicStartIndex);
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureGeometryPlanForTesting(Rect plot)
|
||||
{
|
||||
var visibleDurationSeconds = _isStaticSeries
|
||||
? ResolveVisibleDurationSeconds(_points)
|
||||
: RealtimeVisibleSeconds;
|
||||
var pixelsPerSecond = plot.Width / Math.Max(0.001, visibleDurationSeconds);
|
||||
var latestLogicalX = MapTimestampToLogicalX(_points[^1].Timestamp, _logicalOrigin, pixelsPerSecond);
|
||||
_viewportTranslateX = plot.Right - latestLogicalX;
|
||||
|
||||
var metricsChanged = _cachedPlot != plot ||
|
||||
Math.Abs(_cachedPixelsPerSecond - pixelsPerSecond) >= 0.001 ||
|
||||
Math.Abs(_cachedBaselineDb - _baselineDb) >= 0.001;
|
||||
if (metricsChanged)
|
||||
{
|
||||
_cachedPlot = plot;
|
||||
_cachedPixelsPerSecond = pixelsPerSecond;
|
||||
_cachedBaselineDb = _baselineDb;
|
||||
_staticGeometryDirty = true;
|
||||
_dynamicGeometryDirty = true;
|
||||
}
|
||||
|
||||
var firstTailIndex = _isStaticSeries
|
||||
? _points.Count
|
||||
: ResolveFirstTailIndex(_points, TimeSpan.FromSeconds(DynamicTailSeconds));
|
||||
var dynamicStartIndex = _isStaticSeries ? _points.Count : Math.Max(0, firstTailIndex - 1);
|
||||
var staticEndExclusive = firstTailIndex;
|
||||
|
||||
if (staticEndExclusive != _cachedStaticEndExclusive)
|
||||
{
|
||||
_staticGeometryDirty = true;
|
||||
}
|
||||
|
||||
if (dynamicStartIndex != _cachedDynamicStartIndex)
|
||||
{
|
||||
_dynamicGeometryDirty = true;
|
||||
}
|
||||
|
||||
if (_staticGeometryDirty)
|
||||
{
|
||||
_staticSourceCount = staticEndExclusive >= 2 ? staticEndExclusive : 0;
|
||||
_cachedStaticEndExclusive = staticEndExclusive;
|
||||
_staticGeometryDirty = false;
|
||||
_staticPathBuildVersion++;
|
||||
}
|
||||
|
||||
if (_dynamicGeometryDirty)
|
||||
{
|
||||
_dynamicSourceCount = !_isStaticSeries && _points.Count - dynamicStartIndex >= 2
|
||||
? _points.Count - dynamicStartIndex
|
||||
: 0;
|
||||
_cachedDynamicStartIndex = dynamicStartIndex;
|
||||
_dynamicGeometryDirty = false;
|
||||
_dynamicPathBuildVersion++;
|
||||
}
|
||||
|
||||
_cachedPathCountForTesting = (_staticSourceCount >= 2 ? 2 : 0) + (_dynamicSourceCount >= 2 ? 2 : 0);
|
||||
}
|
||||
|
||||
private void RebuildStaticGeometry(Rect plot, double pixelsPerSecond, int staticEndExclusive)
|
||||
{
|
||||
_staticLineGeometry = null;
|
||||
_staticFillGeometry = null;
|
||||
_staticSourceCount = 0;
|
||||
|
||||
if (staticEndExclusive >= 2)
|
||||
{
|
||||
(_staticLineGeometry, _staticFillGeometry, _staticSourceCount) = BuildLayerGeometry(
|
||||
startIndex: 0,
|
||||
endExclusive: staticEndExclusive,
|
||||
plot,
|
||||
pixelsPerSecond);
|
||||
}
|
||||
|
||||
_cachedStaticEndExclusive = staticEndExclusive;
|
||||
_staticGeometryDirty = false;
|
||||
_staticPathBuildVersion++;
|
||||
}
|
||||
|
||||
private void RebuildDynamicGeometry(Rect plot, double pixelsPerSecond, int dynamicStartIndex)
|
||||
{
|
||||
_dynamicLineGeometry = null;
|
||||
_dynamicFillGeometry = null;
|
||||
_dynamicSourceCount = 0;
|
||||
|
||||
if (!_isStaticSeries && _points.Count - dynamicStartIndex >= 2)
|
||||
{
|
||||
(_dynamicLineGeometry, _dynamicFillGeometry, _dynamicSourceCount) = BuildLayerGeometry(
|
||||
dynamicStartIndex,
|
||||
_points.Count,
|
||||
plot,
|
||||
pixelsPerSecond);
|
||||
}
|
||||
|
||||
_cachedDynamicStartIndex = dynamicStartIndex;
|
||||
_dynamicGeometryDirty = false;
|
||||
_dynamicPathBuildVersion++;
|
||||
}
|
||||
|
||||
private (StreamGeometry? Line, StreamGeometry? Fill, int SourceCount) BuildLayerGeometry(
|
||||
int startIndex,
|
||||
int endExclusive,
|
||||
Rect plot,
|
||||
double pixelsPerSecond)
|
||||
{
|
||||
var sourceCount = endExclusive - startIndex;
|
||||
if (sourceCount < 2)
|
||||
{
|
||||
return (null, null, sourceCount);
|
||||
}
|
||||
|
||||
var maxSamples = Math.Clamp((int)Math.Floor(plot.Width), 64, 420);
|
||||
var pointCount = BuildPlotPoints(startIndex, endExclusive, plot, pixelsPerSecond, maxSamples);
|
||||
if (pointCount < 2 || _pointBuffer is null)
|
||||
{
|
||||
return (null, null, sourceCount);
|
||||
}
|
||||
|
||||
var lineGeometry = new StreamGeometry();
|
||||
using (var builder = lineGeometry.Open())
|
||||
{
|
||||
AddSmoothedPath(builder, _pointBuffer, pointCount, isFilled: false);
|
||||
}
|
||||
|
||||
var fillGeometry = new StreamGeometry();
|
||||
using (var builder = fillGeometry.Open())
|
||||
{
|
||||
var first = _pointBuffer[0];
|
||||
builder.BeginFigure(new Point(first.X, plot.Bottom), true);
|
||||
builder.LineTo(first);
|
||||
AddSmoothedSegments(builder, _pointBuffer, pointCount);
|
||||
|
||||
var last = _pointBuffer[pointCount - 1];
|
||||
builder.LineTo(new Point(last.X, plot.Bottom));
|
||||
builder.LineTo(new Point(first.X, plot.Bottom));
|
||||
builder.EndFigure(true);
|
||||
}
|
||||
|
||||
return (lineGeometry, fillGeometry, sourceCount);
|
||||
}
|
||||
|
||||
private static void AddSmoothedPath(StreamGeometryContext builder, Point[] points, int pointCount, bool isFilled)
|
||||
{
|
||||
builder.BeginFigure(points[0], isFilled);
|
||||
AddSmoothedSegments(builder, points, pointCount);
|
||||
builder.EndFigure(isClosed: false);
|
||||
}
|
||||
|
||||
private static void AddSmoothedSegments(StreamGeometryContext builder, Point[] points, int pointCount)
|
||||
{
|
||||
if (pointCount < 2)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (pointCount == 2)
|
||||
{
|
||||
builder.LineTo(points[1]);
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < pointCount - 1; i++)
|
||||
{
|
||||
var p0 = i == 0 ? points[i] : points[i - 1];
|
||||
var p1 = points[i];
|
||||
var p2 = points[i + 1];
|
||||
var p3 = i + 2 < pointCount ? points[i + 2] : p2;
|
||||
|
||||
var control1 = new Point(
|
||||
p1.X + (p2.X - p0.X) / 6d,
|
||||
p1.Y + (p2.Y - p0.Y) / 6d);
|
||||
var control2 = new Point(
|
||||
p2.X - (p3.X - p1.X) / 6d,
|
||||
p2.Y - (p3.Y - p1.Y) / 6d);
|
||||
|
||||
builder.CubicBezierTo(control1, control2, p2);
|
||||
}
|
||||
}
|
||||
|
||||
private int BuildPlotPoints(
|
||||
int startIndex,
|
||||
int endExclusive,
|
||||
Rect plot,
|
||||
double pixelsPerSecond,
|
||||
int maxSamples)
|
||||
{
|
||||
var sourceCount = endExclusive - startIndex;
|
||||
if (sourceCount <= 1)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (sourceCount <= maxSamples)
|
||||
{
|
||||
EnsurePointBufferCapacity(sourceCount);
|
||||
if (_pointBuffer is null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (var i = 0; i < sourceCount; i++)
|
||||
{
|
||||
_pointBuffer[i] = MapToPlot(plot, _points[startIndex + i], pixelsPerSecond);
|
||||
}
|
||||
|
||||
return sourceCount;
|
||||
}
|
||||
|
||||
var bucketCount = Math.Max(1, (maxSamples - 2) / 2);
|
||||
var targetCapacity = 2 + bucketCount * 2;
|
||||
EnsurePointBufferCapacity(targetCapacity);
|
||||
if (_pointBuffer is null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var outputIndex = 0;
|
||||
_pointBuffer[outputIndex++] = MapToPlot(plot, _points[startIndex], pixelsPerSecond);
|
||||
|
||||
var middleCount = sourceCount - 2;
|
||||
var bucketWidth = middleCount / (double)bucketCount;
|
||||
var lastSourceIndex = startIndex;
|
||||
|
||||
for (var bucket = 0; bucket < bucketCount; bucket++)
|
||||
{
|
||||
var rangeStart = startIndex + 1 + (int)Math.Floor(bucket * bucketWidth);
|
||||
var rangeEnd = startIndex + 1 + (int)Math.Floor((bucket + 1) * bucketWidth);
|
||||
if (bucket == bucketCount - 1)
|
||||
{
|
||||
rangeEnd = endExclusive - 1;
|
||||
}
|
||||
|
||||
rangeStart = Math.Clamp(rangeStart, startIndex + 1, endExclusive - 2);
|
||||
rangeEnd = Math.Clamp(rangeEnd, rangeStart + 1, endExclusive - 1);
|
||||
|
||||
var minIndex = rangeStart;
|
||||
var maxIndex = rangeStart;
|
||||
var minValue = _points[rangeStart].DisplayDb;
|
||||
var maxValue = minValue;
|
||||
|
||||
for (var i = rangeStart + 1; i < rangeEnd; i++)
|
||||
{
|
||||
var value = _points[i].DisplayDb;
|
||||
if (value < minValue)
|
||||
{
|
||||
minValue = value;
|
||||
minIndex = i;
|
||||
}
|
||||
|
||||
if (value > maxValue)
|
||||
{
|
||||
maxValue = value;
|
||||
maxIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (minIndex == maxIndex)
|
||||
{
|
||||
if (minIndex != lastSourceIndex)
|
||||
{
|
||||
_pointBuffer[outputIndex++] = MapToPlot(plot, _points[minIndex], pixelsPerSecond);
|
||||
lastSourceIndex = minIndex;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var first = minIndex < maxIndex ? minIndex : maxIndex;
|
||||
var second = minIndex < maxIndex ? maxIndex : minIndex;
|
||||
|
||||
if (first != lastSourceIndex)
|
||||
{
|
||||
_pointBuffer[outputIndex++] = MapToPlot(plot, _points[first], pixelsPerSecond);
|
||||
lastSourceIndex = first;
|
||||
}
|
||||
|
||||
if (second != lastSourceIndex)
|
||||
{
|
||||
_pointBuffer[outputIndex++] = MapToPlot(plot, _points[second], pixelsPerSecond);
|
||||
lastSourceIndex = second;
|
||||
}
|
||||
}
|
||||
|
||||
var finalIndex = endExclusive - 1;
|
||||
if (finalIndex != lastSourceIndex)
|
||||
{
|
||||
_pointBuffer[outputIndex++] = MapToPlot(plot, _points[finalIndex], pixelsPerSecond);
|
||||
}
|
||||
|
||||
return outputIndex;
|
||||
}
|
||||
|
||||
private Point MapToPlot(Rect plot, NoiseRealtimePoint point, double pixelsPerSecond)
|
||||
{
|
||||
var x = MapTimestampToLogicalX(point.Timestamp, _logicalOrigin, pixelsPerSecond);
|
||||
var y = MapDbToY(plot, point.DisplayDb);
|
||||
return new Point(x, y);
|
||||
}
|
||||
|
||||
private double MapDbToY(Rect plot, double displayDb)
|
||||
{
|
||||
var minDb = _baselineDb - 5d;
|
||||
var maxDb = _baselineDb + 25d;
|
||||
var normalized = (displayDb - minDb) / Math.Max(0.001, maxDb - minDb);
|
||||
normalized = Math.Clamp(normalized, 0, 1);
|
||||
return plot.Bottom - normalized * plot.Height;
|
||||
}
|
||||
|
||||
private void DrawLatestPoint(DrawingContext context, Rect plot)
|
||||
{
|
||||
if (_points.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var latest = _points[^1];
|
||||
var center = MapToPlot(plot, latest, _cachedPixelsPerSecond);
|
||||
var level = ResolveLevel(latest.DisplayDb, _baselineDb);
|
||||
var levelBrush = GetLevelBrush(level);
|
||||
var radius = Math.Clamp(Math.Min(plot.Width, plot.Height) / 34d, 3.5, 8);
|
||||
|
||||
context.DrawEllipse(LatestGlowBrush, null, center, radius * 2.8, radius * 2.8);
|
||||
context.DrawEllipse(levelBrush, LatestPointPen, center, radius, radius);
|
||||
context.DrawEllipse(LatestPointBrush, null, center, radius * 0.36, radius * 0.36);
|
||||
}
|
||||
|
||||
private static IBrush GetLevelBrush(NoiseDistributionLevel level)
|
||||
{
|
||||
return level switch
|
||||
{
|
||||
NoiseDistributionLevel.Quiet => new SolidColorBrush(Color.Parse("#FF34D399")),
|
||||
NoiseDistributionLevel.Normal => new SolidColorBrush(Color.Parse("#FF60A5FA")),
|
||||
NoiseDistributionLevel.Noisy => new SolidColorBrush(Color.Parse("#FFF59E0B")),
|
||||
NoiseDistributionLevel.Extreme => new SolidColorBrush(Color.Parse("#FFEF4444")),
|
||||
_ => new SolidColorBrush(Color.Parse("#FF60A5FA"))
|
||||
};
|
||||
}
|
||||
|
||||
private void EnsurePointBufferCapacity(int required)
|
||||
{
|
||||
if (required <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_pointBuffer is not null && _pointBuffer.Length >= required)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var next = ArrayPool<Point>.Shared.Rent(required);
|
||||
if (_pointBuffer is not null)
|
||||
{
|
||||
ArrayPool<Point>.Shared.Return(_pointBuffer, clearArray: false);
|
||||
}
|
||||
|
||||
_pointBuffer = next;
|
||||
}
|
||||
|
||||
private void ReleasePointBuffer()
|
||||
{
|
||||
if (_pointBuffer is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ArrayPool<Point>.Shared.Return(_pointBuffer, clearArray: false);
|
||||
_pointBuffer = null;
|
||||
}
|
||||
|
||||
private static IBrush CreateAreaGradientBrush(byte alpha)
|
||||
{
|
||||
return new LinearGradientBrush
|
||||
{
|
||||
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
|
||||
EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative),
|
||||
GradientStops = new GradientStops
|
||||
{
|
||||
new GradientStop(Color.FromArgb(alpha, 0xEF, 0x44, 0x44), 0.00),
|
||||
new GradientStop(Color.FromArgb(alpha, 0xF5, 0x9E, 0x0B), 0.28),
|
||||
new GradientStop(Color.FromArgb(alpha, 0x60, 0xA5, 0xFA), 0.62),
|
||||
new GradientStop(Color.FromArgb(alpha, 0x34, 0xD3, 0x99), 1.00)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static int ComputeSeriesSignature(
|
||||
IReadOnlyList<NoiseRealtimePoint> points,
|
||||
double baselineDb,
|
||||
bool isStaticSeries)
|
||||
{
|
||||
if (points.Count == 0)
|
||||
{
|
||||
return HashCode.Combine(0, Math.Round(baselineDb, 2), isStaticSeries);
|
||||
}
|
||||
|
||||
var first = points[0];
|
||||
var last = points[^1];
|
||||
return HashCode.Combine(
|
||||
points.Count,
|
||||
first.Timestamp.UtcTicks,
|
||||
Math.Round(first.DisplayDb, 2),
|
||||
last.Timestamp.UtcTicks,
|
||||
Math.Round(last.DisplayDb, 2),
|
||||
Math.Round(baselineDb, 2),
|
||||
isStaticSeries);
|
||||
}
|
||||
}
|
||||
|
||||
public enum NoiseDistributionLevel
|
||||
{
|
||||
Quiet = 0,
|
||||
Normal = 1,
|
||||
Noisy = 2,
|
||||
Extreme = 3
|
||||
}
|
||||
@@ -1,376 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using LanMountainDesktop.Models;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public sealed class StudyNoiseDistributionScatterChartControl : Control
|
||||
{
|
||||
private readonly record struct SampledPoint(double X, double Y, NoiseDistributionLevel Level);
|
||||
|
||||
private static readonly IBrush GridBrush = new SolidColorBrush(Color.Parse("#2E5E7A96"));
|
||||
private static readonly IBrush AxisBrush = new SolidColorBrush(Color.Parse("#5C6D86A1"));
|
||||
private static readonly Pen GridPen = new(GridBrush, 1);
|
||||
private static readonly Pen AxisPen = new(AxisBrush, 1.1);
|
||||
|
||||
private static readonly IBrush QuietBrush = new SolidColorBrush(Color.Parse("#FF34D399"));
|
||||
private static readonly IBrush NormalBrush = new SolidColorBrush(Color.Parse("#FF60A5FA"));
|
||||
private static readonly IBrush NoisyBrush = new SolidColorBrush(Color.Parse("#FFF59E0B"));
|
||||
private static readonly IBrush ExtremeBrush = new SolidColorBrush(Color.Parse("#FFEF4444"));
|
||||
private static readonly byte[] CloudAlphas = [44, 58, 72, 86];
|
||||
private static readonly byte[] GlowAlphas = [26, 36];
|
||||
private static readonly IBrush[][] CloudBrushes = CreateBrushTable(CloudAlphas);
|
||||
private static readonly IBrush[][] GlowBrushes = CreateBrushTable(GlowAlphas);
|
||||
|
||||
private IReadOnlyList<NoiseRealtimePoint> _points = Array.Empty<NoiseRealtimePoint>();
|
||||
private SampledPoint[] _sampledPoints = Array.Empty<SampledPoint>();
|
||||
private int _sampledPointCount;
|
||||
private double _baselineDb = 45;
|
||||
private Rect _cachedPlot;
|
||||
private bool _sampleCacheDirty = true;
|
||||
private int _lastSeriesSignature;
|
||||
|
||||
public void UpdateSeries(IReadOnlyList<NoiseRealtimePoint>? points, double baselineDb)
|
||||
{
|
||||
var nextPoints = points ?? Array.Empty<NoiseRealtimePoint>();
|
||||
var nextBaselineDb = Math.Clamp(baselineDb, 20, 85);
|
||||
var nextSignature = ComputeSeriesSignature(nextPoints, nextBaselineDb);
|
||||
if (ReferenceEquals(_points, nextPoints) &&
|
||||
Math.Abs(_baselineDb - nextBaselineDb) < 0.001 &&
|
||||
_lastSeriesSignature == nextSignature)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_points = nextPoints;
|
||||
_baselineDb = nextBaselineDb;
|
||||
_lastSeriesSignature = nextSignature;
|
||||
_sampleCacheDirty = true;
|
||||
InvalidateVisual();
|
||||
}
|
||||
|
||||
public override void Render(DrawingContext context)
|
||||
{
|
||||
base.Render(context);
|
||||
|
||||
var bounds = Bounds;
|
||||
if (bounds.Width <= 2 || bounds.Height <= 2)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var plot = new Rect(
|
||||
x: 1,
|
||||
y: 1,
|
||||
width: Math.Max(1, bounds.Width - 2),
|
||||
height: Math.Max(1, bounds.Height - 2));
|
||||
|
||||
DrawGrid(context, plot);
|
||||
|
||||
if (_points.Count < 2)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EnsureSampleCache(plot);
|
||||
if (_sampledPointCount < 2)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DrawElectronCloud(context, plot);
|
||||
}
|
||||
|
||||
private void DrawElectronCloud(DrawingContext context, Rect plot)
|
||||
{
|
||||
var cloudLayers = CloudAlphas.Length;
|
||||
var baseRadius = Math.Clamp(Math.Min(plot.Width, plot.Height) / 45d, 3, 12);
|
||||
|
||||
for (var layer = cloudLayers - 1; layer >= 0; layer--)
|
||||
{
|
||||
var layerRatio = cloudLayers == 1 ? 0d : layer / (double)(cloudLayers - 1);
|
||||
var layerRadius = baseRadius * (1.2 + layerRatio * 0.8);
|
||||
var layerBrushes = CloudBrushes[layer];
|
||||
|
||||
for (var i = 0; i < _sampledPointCount; i++)
|
||||
{
|
||||
var pt = _sampledPoints[i];
|
||||
var jitterX = ComputeJitter(pt.X * 1000 + layer) * layerRadius * 0.3;
|
||||
var jitterY = ComputeJitter(pt.Y * 1000 + layer) * layerRadius * 0.3;
|
||||
|
||||
context.DrawEllipse(
|
||||
layerBrushes[(int)pt.Level],
|
||||
pen: null,
|
||||
center: new Point(pt.X + jitterX, pt.Y + jitterY),
|
||||
radiusX: layerRadius,
|
||||
radiusY: layerRadius * 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
var glowLayers = GlowAlphas.Length;
|
||||
for (var layer = glowLayers - 1; layer >= 0; layer--)
|
||||
{
|
||||
var layerRatio = glowLayers == 1 ? 0d : layer / (double)(glowLayers - 1);
|
||||
var layerRadius = baseRadius * (0.8 + layerRatio * 0.6);
|
||||
var layerBrushes = GlowBrushes[layer];
|
||||
for (var i = 0; i < _sampledPointCount; i++)
|
||||
{
|
||||
var pt = _sampledPoints[i];
|
||||
context.DrawEllipse(
|
||||
layerBrushes[(int)pt.Level],
|
||||
pen: null,
|
||||
center: new Point(pt.X, pt.Y),
|
||||
radiusX: layerRadius,
|
||||
radiusY: layerRadius * 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
var latest = _sampledPoints[_sampledPointCount - 1];
|
||||
for (var i = 3; i >= 0; i--)
|
||||
{
|
||||
var radius = baseRadius * (1.5 + i * 0.8);
|
||||
var alpha = (byte)(30 - i * 6);
|
||||
var glowBrush = GetAlphaBrush(latest.Level, alpha);
|
||||
context.DrawEllipse(glowBrush, null, new Point(latest.X, latest.Y), radius, radius * 0.6);
|
||||
}
|
||||
|
||||
context.DrawEllipse(
|
||||
GetLevelBrush(latest.Level),
|
||||
new Pen(Brushes.White, 1.5),
|
||||
new Point(latest.X, latest.Y),
|
||||
baseRadius + 1,
|
||||
baseRadius * 0.7 + 1);
|
||||
|
||||
context.DrawEllipse(
|
||||
Brushes.White,
|
||||
null,
|
||||
new Point(latest.X, latest.Y),
|
||||
2,
|
||||
2);
|
||||
}
|
||||
|
||||
private void EnsureSampleCache(Rect plot)
|
||||
{
|
||||
if (!_sampleCacheDirty && _cachedPlot == plot)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_cachedPlot = plot;
|
||||
_sampledPointCount = BuildSampledPoints(plot);
|
||||
_sampleCacheDirty = false;
|
||||
}
|
||||
|
||||
private static void DrawGrid(DrawingContext context, Rect plot)
|
||||
{
|
||||
const int verticalDivisions = 4;
|
||||
|
||||
for (var i = 0; i <= verticalDivisions; i++)
|
||||
{
|
||||
var x = plot.Left + plot.Width * (i / (double)verticalDivisions);
|
||||
context.DrawLine(GridPen, new Point(x, plot.Top), new Point(x, plot.Bottom));
|
||||
}
|
||||
|
||||
for (var i = 0; i <= 4; i++)
|
||||
{
|
||||
var y = plot.Top + plot.Height * (i / 4d);
|
||||
context.DrawLine(GridPen, new Point(plot.Left, y), new Point(plot.Right, y));
|
||||
}
|
||||
|
||||
context.DrawLine(AxisPen, new Point(plot.Left, plot.Top), new Point(plot.Left, plot.Bottom));
|
||||
context.DrawLine(AxisPen, new Point(plot.Left, plot.Bottom), new Point(plot.Right, plot.Bottom));
|
||||
}
|
||||
|
||||
private static double MapX(Rect plot, DateTimeOffset timestamp, DateTimeOffset start, long totalTicks)
|
||||
{
|
||||
var offsetTicks = Math.Clamp((timestamp - start).Ticks, 0, totalTicks);
|
||||
return plot.Left + plot.Width * (offsetTicks / (double)totalTicks);
|
||||
}
|
||||
|
||||
private double MapYContinuous(Rect plot, double displayDb)
|
||||
{
|
||||
var minDb = _baselineDb - 5;
|
||||
var maxDb = _baselineDb + 25;
|
||||
var dbRange = maxDb - minDb;
|
||||
if (dbRange <= 0)
|
||||
{
|
||||
dbRange = 30;
|
||||
}
|
||||
|
||||
var normalizedDb = (displayDb - minDb) / dbRange;
|
||||
normalizedDb = Math.Clamp(normalizedDb, 0, 1);
|
||||
|
||||
return plot.Bottom - (normalizedDb * plot.Height);
|
||||
}
|
||||
|
||||
private static double ComputeJitter(double value)
|
||||
{
|
||||
var hash = (ulong)(value * 1000000);
|
||||
hash ^= hash >> 33;
|
||||
hash *= 0xff51afd7ed558ccdUL;
|
||||
hash ^= hash >> 33;
|
||||
hash *= 0xc4ceb9fe1a85ec53UL;
|
||||
hash ^= hash >> 33;
|
||||
var normalized = (hash & 0xFFFF) / 65535d;
|
||||
return (normalized * 2d) - 1d;
|
||||
}
|
||||
|
||||
private static NoiseDistributionLevel ResolveLevel(double displayDb, double baselineDb)
|
||||
{
|
||||
var quietUpper = baselineDb;
|
||||
var normalUpper = baselineDb + 10d;
|
||||
var noisyUpper = baselineDb + 20d;
|
||||
|
||||
if (displayDb < quietUpper)
|
||||
{
|
||||
return NoiseDistributionLevel.Quiet;
|
||||
}
|
||||
|
||||
if (displayDb < normalUpper)
|
||||
{
|
||||
return NoiseDistributionLevel.Normal;
|
||||
}
|
||||
|
||||
if (displayDb < noisyUpper)
|
||||
{
|
||||
return NoiseDistributionLevel.Noisy;
|
||||
}
|
||||
|
||||
return NoiseDistributionLevel.Extreme;
|
||||
}
|
||||
|
||||
private static IBrush GetLevelBrush(NoiseDistributionLevel level)
|
||||
{
|
||||
return level switch
|
||||
{
|
||||
NoiseDistributionLevel.Quiet => QuietBrush,
|
||||
NoiseDistributionLevel.Normal => NormalBrush,
|
||||
NoiseDistributionLevel.Noisy => NoisyBrush,
|
||||
NoiseDistributionLevel.Extreme => ExtremeBrush,
|
||||
_ => NormalBrush
|
||||
};
|
||||
}
|
||||
|
||||
private static IBrush GetLevelBrushWithAlpha(NoiseDistributionLevel level, byte alpha)
|
||||
{
|
||||
return level switch
|
||||
{
|
||||
NoiseDistributionLevel.Quiet => new SolidColorBrush(Color.FromArgb(alpha, 0x34, 0xD3, 0x99)),
|
||||
NoiseDistributionLevel.Normal => new SolidColorBrush(Color.FromArgb(alpha, 0x60, 0xA5, 0xFA)),
|
||||
NoiseDistributionLevel.Noisy => new SolidColorBrush(Color.FromArgb(alpha, 0xF5, 0x9E, 0x0B)),
|
||||
NoiseDistributionLevel.Extreme => new SolidColorBrush(Color.FromArgb(alpha, 0xEF, 0x44, 0x44)),
|
||||
_ => new SolidColorBrush(Color.FromArgb(alpha, 0x60, 0xA5, 0xFA))
|
||||
};
|
||||
}
|
||||
|
||||
private int BuildSampledPoints(Rect plot)
|
||||
{
|
||||
if (_points.Count < 2)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var maxSamples = Math.Clamp((int)Math.Ceiling(plot.Width / 2d), 48, 144);
|
||||
var targetCount = Math.Min(_points.Count, maxSamples);
|
||||
if (_sampledPoints.Length < targetCount)
|
||||
{
|
||||
_sampledPoints = new SampledPoint[targetCount];
|
||||
}
|
||||
|
||||
var start = _points[0].Timestamp;
|
||||
var end = _points[^1].Timestamp;
|
||||
var totalTicks = Math.Max(1, (end - start).Ticks);
|
||||
var step = _points.Count <= targetCount
|
||||
? 1d
|
||||
: (_points.Count - 1d) / Math.Max(1d, targetCount - 1d);
|
||||
|
||||
var outputIndex = 0;
|
||||
var lastSourceIndex = -1;
|
||||
for (var i = 0; i < targetCount; i++)
|
||||
{
|
||||
var sourceIndex = i == targetCount - 1
|
||||
? _points.Count - 1
|
||||
: (int)Math.Round(i * step);
|
||||
sourceIndex = Math.Clamp(sourceIndex, 0, _points.Count - 1);
|
||||
if (sourceIndex == lastSourceIndex)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var point = _points[sourceIndex];
|
||||
_sampledPoints[outputIndex++] = new SampledPoint(
|
||||
MapX(plot, point.Timestamp, start, totalTicks),
|
||||
MapYContinuous(plot, point.DisplayDb),
|
||||
ResolveLevel(point.DisplayDb, _baselineDb));
|
||||
lastSourceIndex = sourceIndex;
|
||||
}
|
||||
|
||||
return outputIndex;
|
||||
}
|
||||
|
||||
private static int ComputeSeriesSignature(IReadOnlyList<NoiseRealtimePoint> points, double baselineDb)
|
||||
{
|
||||
if (points.Count == 0)
|
||||
{
|
||||
return HashCode.Combine(0, baselineDb);
|
||||
}
|
||||
|
||||
var first = points[0];
|
||||
var last = points[^1];
|
||||
return HashCode.Combine(
|
||||
points.Count,
|
||||
first.Timestamp.UtcTicks,
|
||||
last.Timestamp.UtcTicks,
|
||||
Math.Round(last.DisplayDb, 2),
|
||||
Math.Round(baselineDb, 2));
|
||||
}
|
||||
|
||||
private static IBrush[][] CreateBrushTable(IReadOnlyList<byte> alphas)
|
||||
{
|
||||
var table = new IBrush[alphas.Count][];
|
||||
for (var i = 0; i < alphas.Count; i++)
|
||||
{
|
||||
table[i] =
|
||||
[
|
||||
GetLevelBrushWithAlpha(NoiseDistributionLevel.Quiet, alphas[i]),
|
||||
GetLevelBrushWithAlpha(NoiseDistributionLevel.Normal, alphas[i]),
|
||||
GetLevelBrushWithAlpha(NoiseDistributionLevel.Noisy, alphas[i]),
|
||||
GetLevelBrushWithAlpha(NoiseDistributionLevel.Extreme, alphas[i])
|
||||
];
|
||||
}
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
private static IBrush GetAlphaBrush(NoiseDistributionLevel level, byte alpha)
|
||||
{
|
||||
for (var i = 0; i < CloudAlphas.Length; i++)
|
||||
{
|
||||
if (CloudAlphas[i] == alpha)
|
||||
{
|
||||
return CloudBrushes[i][(int)level];
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < GlowAlphas.Length; i++)
|
||||
{
|
||||
if (GlowAlphas[i] == alpha)
|
||||
{
|
||||
return GlowBrushes[i][(int)level];
|
||||
}
|
||||
}
|
||||
|
||||
return GetLevelBrushWithAlpha(level, alpha);
|
||||
}
|
||||
}
|
||||
|
||||
public enum NoiseDistributionLevel
|
||||
{
|
||||
Quiet = 0,
|
||||
Normal = 1,
|
||||
Noisy = 2,
|
||||
Extreme = 3
|
||||
}
|
||||
@@ -83,10 +83,10 @@
|
||||
FontSize="10" />
|
||||
</Grid>
|
||||
|
||||
<local:StudyNoiseDistributionScatterChartControl x:Name="ChartControl"
|
||||
Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
MinHeight="64" />
|
||||
<local:StudyNoiseDistributionAreaChartControl x:Name="ChartControl"
|
||||
Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
MinHeight="64" />
|
||||
|
||||
<Grid Grid.Row="1"
|
||||
Grid.Column="1"
|
||||
|
||||
@@ -5,7 +5,6 @@ using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Theme;
|
||||
@@ -39,17 +38,14 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
||||
private static readonly Color DarkSubstrate = Color.Parse("#FF0B1220");
|
||||
private static readonly Color LightSubstrate = Color.Parse("#FFF1F5FA");
|
||||
|
||||
private readonly object _snapshotSync = new();
|
||||
private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault();
|
||||
private readonly StudyAnalyticsMonitoringLeaseCoordinator _monitoringLeaseCoordinator = StudyAnalyticsMonitoringLeaseCoordinatorFactory.CreateDefault();
|
||||
private LanMountainDesktop.PluginSdk.ISettingsService _settingsService = LanMountainDesktop.Services.Settings.HostSettingsFacadeProvider.GetOrCreate().Settings;
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly StudySnapshotRenderGate _renderGate;
|
||||
|
||||
private double _currentCellSize = 48;
|
||||
private StudyAnalyticsSnapshot? _pendingSnapshot;
|
||||
private string _languageCode = "zh-CN";
|
||||
private bool _dispatchQueued;
|
||||
private bool _hasPendingSnapshot;
|
||||
private bool _isAttached;
|
||||
private bool _isOnActivePage = true;
|
||||
private bool _isDisposed;
|
||||
@@ -72,6 +68,7 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
_renderGate = new StudySnapshotRenderGate(CanRenderSnapshot, ApplySnapshot);
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
SizeChanged += OnSizeChanged;
|
||||
@@ -80,7 +77,7 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
||||
ApplyCellSize(_currentCellSize);
|
||||
ApplyDefaultXAxisLabels();
|
||||
ApplyLocalizedAxisLabels();
|
||||
QueueSnapshotForRender(_studyAnalyticsService.GetSnapshot());
|
||||
_renderGate.Queue(_studyAnalyticsService.GetSnapshot());
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
@@ -99,7 +96,7 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
||||
|
||||
if (isOnActivePage && !wasOnActivePage)
|
||||
{
|
||||
QueueSnapshotForRender(_studyAnalyticsService.GetSnapshot());
|
||||
_renderGate.Queue(_studyAnalyticsService.GetSnapshot());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +112,7 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
||||
}
|
||||
|
||||
UpdateMonitoringLeaseState();
|
||||
QueueSnapshotForRender(_studyAnalyticsService.GetSnapshot());
|
||||
_renderGate.Queue(_studyAnalyticsService.GetSnapshot());
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
@@ -123,6 +120,7 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
||||
_isAttached = false;
|
||||
_monitoringLease?.Dispose();
|
||||
_monitoringLease = null;
|
||||
_renderGate.Clear();
|
||||
|
||||
if (_isSubscribed)
|
||||
{
|
||||
@@ -139,7 +137,7 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
||||
|
||||
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
|
||||
{
|
||||
QueueSnapshotForRender(_studyAnalyticsService.GetSnapshot());
|
||||
_renderGate.Queue(_studyAnalyticsService.GetSnapshot());
|
||||
}
|
||||
|
||||
private void UpdateMonitoringLeaseState()
|
||||
@@ -151,7 +149,8 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isAttached)
|
||||
var shouldMonitor = _isAttached && _isOnActivePage;
|
||||
if (shouldMonitor)
|
||||
{
|
||||
_monitoringLease ??= _monitoringLeaseCoordinator.AcquireLease();
|
||||
return;
|
||||
@@ -164,46 +163,17 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
||||
private void OnStudySnapshotUpdated(object? sender, StudyAnalyticsSnapshotChangedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
QueueSnapshotForRender(e.Snapshot);
|
||||
}
|
||||
|
||||
private void QueueSnapshotForRender(StudyAnalyticsSnapshot snapshot)
|
||||
{
|
||||
lock (_snapshotSync)
|
||||
{
|
||||
_pendingSnapshot = snapshot;
|
||||
_hasPendingSnapshot = true;
|
||||
if (_dispatchQueued)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_dispatchQueued = true;
|
||||
}
|
||||
|
||||
Dispatcher.UIThread.Post(ProcessPendingSnapshot, DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
private void ProcessPendingSnapshot()
|
||||
{
|
||||
StudyAnalyticsSnapshot? snapshot = null;
|
||||
lock (_snapshotSync)
|
||||
{
|
||||
_dispatchQueued = false;
|
||||
if (_hasPendingSnapshot)
|
||||
{
|
||||
snapshot = _pendingSnapshot;
|
||||
_pendingSnapshot = null;
|
||||
_hasPendingSnapshot = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!_isAttached || !_isOnActivePage || snapshot is null)
|
||||
if (!_isAttached || !_isOnActivePage)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ApplySnapshot(snapshot);
|
||||
_renderGate.Queue(e.Snapshot);
|
||||
}
|
||||
|
||||
private bool CanRenderSnapshot()
|
||||
{
|
||||
return _isAttached && _isOnActivePage;
|
||||
}
|
||||
|
||||
private void ApplySnapshot(StudyAnalyticsSnapshot snapshot)
|
||||
@@ -235,7 +205,7 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
||||
? StudySessionReportProjection.BuildSyntheticRealtimePoints(snapshot.LastSessionReport, snapshot.Config)
|
||||
: snapshot.RealtimeBuffer;
|
||||
|
||||
ChartControl.UpdateSeries(points, snapshot.Config.BaselineDb);
|
||||
ChartControl.UpdateSeries(points, snapshot.Config.BaselineDb, isSessionReport);
|
||||
UpdateXAxisLabels(points);
|
||||
|
||||
var stats = ComputeDistributionStats(points, snapshot.Config.BaselineDb);
|
||||
@@ -670,6 +640,7 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
||||
DetachedFromVisualTree -= OnDetachedFromVisualTree;
|
||||
SizeChanged -= OnSizeChanged;
|
||||
ActualThemeVariantChanged -= OnActualThemeVariantChanged;
|
||||
_renderGate.Dispose();
|
||||
|
||||
if (_isSubscribed)
|
||||
{
|
||||
|
||||
@@ -5,7 +5,6 @@ using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Theme;
|
||||
@@ -43,16 +42,14 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi
|
||||
private readonly StudyAnalyticsMonitoringLeaseCoordinator _monitoringLeaseCoordinator = StudyAnalyticsMonitoringLeaseCoordinatorFactory.CreateDefault();
|
||||
private LanMountainDesktop.PluginSdk.ISettingsService _settingsService = LanMountainDesktop.Services.Settings.HostSettingsFacadeProvider.GetOrCreate().Settings;
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly DispatcherTimer _uiTimer = new()
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(250)
|
||||
};
|
||||
private readonly StudySnapshotRenderGate _renderGate;
|
||||
|
||||
private readonly Queue<(DateTimeOffset Timestamp, double Score)> _realtimeHistory = new();
|
||||
|
||||
private double _currentCellSize = 48;
|
||||
private bool _isAttached;
|
||||
private bool _isOnActivePage = true;
|
||||
private bool _isSubscribed;
|
||||
private bool _isCompactMode;
|
||||
private bool _isUltraCompactMode;
|
||||
private bool _isExpandedMode;
|
||||
@@ -64,14 +61,14 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
_uiTimer.Tick += OnUiTimerTick;
|
||||
_renderGate = new StudySnapshotRenderGate(CanRenderSnapshot, ApplySnapshot);
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
SizeChanged += OnSizeChanged;
|
||||
ActualThemeVariantChanged += OnActualThemeVariantChanged;
|
||||
ReloadLanguageCode();
|
||||
ApplyCellSize(_currentCellSize);
|
||||
RefreshVisual();
|
||||
ApplySnapshot(_studyAnalyticsService.GetSnapshot());
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
@@ -83,17 +80,26 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi
|
||||
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
|
||||
{
|
||||
_ = isEditMode;
|
||||
var wasOnActivePage = _isOnActivePage;
|
||||
_isOnActivePage = isOnActivePage;
|
||||
UpdateMonitoringLeaseState();
|
||||
UpdateTimerState();
|
||||
if (isOnActivePage && !wasOnActivePage)
|
||||
{
|
||||
RefreshVisual();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = true;
|
||||
ReloadLanguageCode();
|
||||
if (!_isSubscribed)
|
||||
{
|
||||
_studyAnalyticsService.SnapshotUpdated += OnStudySnapshotUpdated;
|
||||
_isSubscribed = true;
|
||||
}
|
||||
|
||||
UpdateMonitoringLeaseState();
|
||||
UpdateTimerState();
|
||||
RefreshVisual();
|
||||
}
|
||||
|
||||
@@ -102,7 +108,13 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi
|
||||
_isAttached = false;
|
||||
_monitoringLease?.Dispose();
|
||||
_monitoringLease = null;
|
||||
_uiTimer.Stop();
|
||||
_renderGate.Clear();
|
||||
|
||||
if (_isSubscribed)
|
||||
{
|
||||
_studyAnalyticsService.SnapshotUpdated -= OnStudySnapshotUpdated;
|
||||
_isSubscribed = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
@@ -116,24 +128,19 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi
|
||||
RefreshVisual();
|
||||
}
|
||||
|
||||
private void OnUiTimerTick(object? sender, EventArgs e)
|
||||
private void OnStudySnapshotUpdated(object? sender, StudyAnalyticsSnapshotChangedEventArgs e)
|
||||
{
|
||||
RefreshVisual();
|
||||
}
|
||||
|
||||
private void UpdateTimerState()
|
||||
{
|
||||
if (_isAttached && _isOnActivePage)
|
||||
if (!_isAttached || !_isOnActivePage)
|
||||
{
|
||||
if (!_uiTimer.IsEnabled)
|
||||
{
|
||||
_uiTimer.Start();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_uiTimer.Stop();
|
||||
_renderGate.Queue(e.Snapshot);
|
||||
}
|
||||
|
||||
private bool CanRenderSnapshot()
|
||||
{
|
||||
return _isAttached && _isOnActivePage;
|
||||
}
|
||||
|
||||
private void UpdateMonitoringLeaseState()
|
||||
@@ -157,6 +164,11 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi
|
||||
}
|
||||
|
||||
private void RefreshVisual()
|
||||
{
|
||||
_renderGate.Queue(_studyAnalyticsService.GetSnapshot());
|
||||
}
|
||||
|
||||
private void ApplySnapshot(StudyAnalyticsSnapshot snapshot)
|
||||
{
|
||||
ApplyLocalizedLabels();
|
||||
|
||||
@@ -172,8 +184,6 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshot = _studyAnalyticsService.GetSnapshot();
|
||||
|
||||
var realtimeScore = ComputeRealtimeScore(snapshot);
|
||||
if (snapshot.DataMode == StudyDataMode.Realtime && realtimeScore is { } score)
|
||||
{
|
||||
@@ -181,6 +191,13 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi
|
||||
}
|
||||
|
||||
var isSessionRunning = snapshot.Session.State == StudySessionRuntimeState.Running;
|
||||
var isSessionReport = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null;
|
||||
if (isSessionReport)
|
||||
{
|
||||
ApplySessionReportMode(snapshot, panelColor);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSessionRunning)
|
||||
{
|
||||
ApplySessionMode(snapshot, realtimeScore, panelColor);
|
||||
|
||||
@@ -52,15 +52,17 @@ public partial class StudySessionControlWidget : UserControl, IDesktopComponentW
|
||||
private readonly StudyAnalyticsMonitoringLeaseCoordinator _monitoringLeaseCoordinator = StudyAnalyticsMonitoringLeaseCoordinatorFactory.CreateDefault();
|
||||
private LanMountainDesktop.PluginSdk.ISettingsService _settingsService = LanMountainDesktop.Services.Settings.HostSettingsFacadeProvider.GetOrCreate().Settings;
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly StudySnapshotRenderGate _renderGate;
|
||||
private readonly DispatcherTimer _uiTimer = new()
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(250)
|
||||
Interval = TimeSpan.FromSeconds(1)
|
||||
};
|
||||
|
||||
private double _currentCellSize = 48;
|
||||
private string _languageCode = "zh-CN";
|
||||
private bool _isAttached;
|
||||
private bool _isOnActivePage = true;
|
||||
private bool _isSubscribed;
|
||||
private bool _isDisposed;
|
||||
private bool _isCompactMode;
|
||||
private bool _isUltraCompactMode;
|
||||
@@ -73,6 +75,7 @@ public partial class StudySessionControlWidget : UserControl, IDesktopComponentW
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
_renderGate = new StudySnapshotRenderGate(CanRenderSnapshot, ApplySnapshot);
|
||||
_uiTimer.Tick += OnUiTimerTick;
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
@@ -81,7 +84,7 @@ public partial class StudySessionControlWidget : UserControl, IDesktopComponentW
|
||||
|
||||
ReloadLanguageCode();
|
||||
ApplyCellSize(_currentCellSize);
|
||||
RefreshVisual();
|
||||
ApplySnapshot(_studyAnalyticsService.GetSnapshot());
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
@@ -93,15 +96,26 @@ public partial class StudySessionControlWidget : UserControl, IDesktopComponentW
|
||||
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
|
||||
{
|
||||
_ = isEditMode;
|
||||
var wasOnActivePage = _isOnActivePage;
|
||||
_isOnActivePage = isOnActivePage;
|
||||
UpdateMonitoringLeaseState();
|
||||
UpdateTimerState();
|
||||
if (isOnActivePage && !wasOnActivePage)
|
||||
{
|
||||
RefreshVisual();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = true;
|
||||
ReloadLanguageCode();
|
||||
if (!_isSubscribed)
|
||||
{
|
||||
_studyAnalyticsService.SnapshotUpdated += OnStudySnapshotUpdated;
|
||||
_isSubscribed = true;
|
||||
}
|
||||
|
||||
UpdateMonitoringLeaseState();
|
||||
UpdateTimerState();
|
||||
RefreshVisual();
|
||||
@@ -113,6 +127,13 @@ public partial class StudySessionControlWidget : UserControl, IDesktopComponentW
|
||||
_monitoringLease?.Dispose();
|
||||
_monitoringLease = null;
|
||||
_uiTimer.Stop();
|
||||
_renderGate.Clear();
|
||||
|
||||
if (_isSubscribed)
|
||||
{
|
||||
_studyAnalyticsService.SnapshotUpdated -= OnStudySnapshotUpdated;
|
||||
_isSubscribed = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
@@ -131,6 +152,21 @@ public partial class StudySessionControlWidget : UserControl, IDesktopComponentW
|
||||
RefreshVisual();
|
||||
}
|
||||
|
||||
private void OnStudySnapshotUpdated(object? sender, StudyAnalyticsSnapshotChangedEventArgs e)
|
||||
{
|
||||
if (!_isAttached || !_isOnActivePage)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_renderGate.Queue(e.Snapshot);
|
||||
}
|
||||
|
||||
private bool CanRenderSnapshot()
|
||||
{
|
||||
return _isAttached && _isOnActivePage;
|
||||
}
|
||||
|
||||
private void UpdateTimerState()
|
||||
{
|
||||
if (_isAttached && _isOnActivePage)
|
||||
@@ -191,6 +227,11 @@ public partial class StudySessionControlWidget : UserControl, IDesktopComponentW
|
||||
}
|
||||
|
||||
private void RefreshVisual()
|
||||
{
|
||||
_renderGate.Queue(_studyAnalyticsService.GetSnapshot());
|
||||
}
|
||||
|
||||
private void ApplySnapshot(StudyAnalyticsSnapshot snapshot)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var panelColor = ResolvePanelBackgroundColor();
|
||||
@@ -205,8 +246,6 @@ public partial class StudySessionControlWidget : UserControl, IDesktopComponentW
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshot = _studyAnalyticsService.GetSnapshot();
|
||||
|
||||
if (_transientMessage is not null && now > _transientMessageExpireAt)
|
||||
{
|
||||
_transientMessage = null;
|
||||
@@ -486,9 +525,16 @@ public partial class StudySessionControlWidget : UserControl, IDesktopComponentW
|
||||
|
||||
_uiTimer.Stop();
|
||||
_uiTimer.Tick -= OnUiTimerTick;
|
||||
_renderGate.Dispose();
|
||||
AttachedToVisualTree -= OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree -= OnDetachedFromVisualTree;
|
||||
SizeChanged -= OnSizeChanged;
|
||||
ActualThemeVariantChanged -= OnActualThemeVariantChanged;
|
||||
|
||||
if (_isSubscribed)
|
||||
{
|
||||
_studyAnalyticsService.SnapshotUpdated -= OnStudySnapshotUpdated;
|
||||
_isSubscribed = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using FluentIcons.Avalonia;
|
||||
using FluentIcons.Common;
|
||||
using LanMountainDesktop.Models;
|
||||
@@ -49,6 +48,7 @@ public partial class StudySessionHistoryWidget : UserControl, IDesktopComponentW
|
||||
private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault();
|
||||
private LanMountainDesktop.PluginSdk.ISettingsService _settingsService = LanMountainDesktop.Services.Settings.HostSettingsFacadeProvider.GetOrCreate().Settings;
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly StudySnapshotRenderGate _renderGate;
|
||||
|
||||
private double _currentCellSize = 48;
|
||||
private string _languageCode = "zh-CN";
|
||||
@@ -70,6 +70,7 @@ public partial class StudySessionHistoryWidget : UserControl, IDesktopComponentW
|
||||
public StudySessionHistoryWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
_renderGate = new StudySnapshotRenderGate(CanRenderSnapshot, ApplySnapshotFromGate);
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
SizeChanged += OnSizeChanged;
|
||||
@@ -145,26 +146,30 @@ public partial class StudySessionHistoryWidget : UserControl, IDesktopComponentW
|
||||
|
||||
private void OnStudySnapshotUpdated(object? sender, StudyAnalyticsSnapshotChangedEventArgs e)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
if (!_isAttached || !_isOnActivePage)
|
||||
{
|
||||
if (!_isAttached)
|
||||
{
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_loadingSessionId) &&
|
||||
string.Equals(e.Snapshot.SelectedSessionReportId, _loadingSessionId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_loadingSessionId = null;
|
||||
SetTransientStatus(L("study.session_history.loaded", "Data loaded"), 1.5);
|
||||
}
|
||||
_renderGate.Queue(e.Snapshot);
|
||||
}
|
||||
|
||||
_currentSnapshot = e.Snapshot;
|
||||
if (_isOnActivePage)
|
||||
{
|
||||
RenderSnapshot(e.Snapshot);
|
||||
}
|
||||
}, DispatcherPriority.Background);
|
||||
private bool CanRenderSnapshot()
|
||||
{
|
||||
return _isAttached && _isOnActivePage;
|
||||
}
|
||||
|
||||
private void ApplySnapshotFromGate(StudyAnalyticsSnapshot snapshot)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_loadingSessionId) &&
|
||||
string.Equals(snapshot.SelectedSessionReportId, _loadingSessionId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_loadingSessionId = null;
|
||||
SetTransientStatus(L("study.session_history.loaded", "Data loaded"), 1.5);
|
||||
}
|
||||
|
||||
_currentSnapshot = snapshot;
|
||||
RenderSnapshot(snapshot);
|
||||
}
|
||||
|
||||
private void RefreshFromService()
|
||||
@@ -793,6 +798,7 @@ public partial class StudySessionHistoryWidget : UserControl, IDesktopComponentW
|
||||
DetachedFromVisualTree -= OnDetachedFromVisualTree;
|
||||
SizeChanged -= OnSizeChanged;
|
||||
ActualThemeVariantChanged -= OnActualThemeVariantChanged;
|
||||
_renderGate.Dispose();
|
||||
DialogCancelButton.Click -= (_, _) => CloseDialog();
|
||||
DialogConfirmButton.Click -= (_, _) => ConfirmDialog();
|
||||
DialogRenameTextBox.KeyDown -= OnDialogRenameTextBoxKeyDown;
|
||||
|
||||
106
LanMountainDesktop/Views/Components/StudySnapshotRenderGate.cs
Normal file
106
LanMountainDesktop/Views/Components/StudySnapshotRenderGate.cs
Normal file
@@ -0,0 +1,106 @@
|
||||
using System;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Models;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
internal sealed class StudySnapshotRenderGate : IDisposable
|
||||
{
|
||||
private readonly object _syncRoot = new();
|
||||
private readonly Func<bool> _canRender;
|
||||
private readonly Action<StudyAnalyticsSnapshot> _renderSnapshot;
|
||||
private readonly Action? _afterRender;
|
||||
|
||||
private StudyAnalyticsSnapshot? _pendingSnapshot;
|
||||
private bool _hasPendingSnapshot;
|
||||
private bool _dispatchQueued;
|
||||
private bool _isDisposed;
|
||||
|
||||
public StudySnapshotRenderGate(
|
||||
Func<bool> canRender,
|
||||
Action<StudyAnalyticsSnapshot> renderSnapshot,
|
||||
Action? afterRender = null)
|
||||
{
|
||||
_canRender = canRender ?? throw new ArgumentNullException(nameof(canRender));
|
||||
_renderSnapshot = renderSnapshot ?? throw new ArgumentNullException(nameof(renderSnapshot));
|
||||
_afterRender = afterRender;
|
||||
}
|
||||
|
||||
internal bool HasPendingSnapshot
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
return _hasPendingSnapshot;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Queue(StudyAnalyticsSnapshot snapshot)
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
if (_isDisposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingSnapshot = snapshot;
|
||||
_hasPendingSnapshot = true;
|
||||
if (_dispatchQueued)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_dispatchQueued = true;
|
||||
}
|
||||
|
||||
Dispatcher.UIThread.Post(() => ProcessPending(), DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
public bool ProcessPending()
|
||||
{
|
||||
StudyAnalyticsSnapshot? snapshot;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
_dispatchQueued = false;
|
||||
if (_isDisposed || !_hasPendingSnapshot)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
snapshot = _pendingSnapshot;
|
||||
_pendingSnapshot = null;
|
||||
_hasPendingSnapshot = false;
|
||||
}
|
||||
|
||||
if (snapshot is null || !_canRender())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_renderSnapshot(snapshot);
|
||||
_afterRender?.Invoke();
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
_pendingSnapshot = null;
|
||||
_hasPendingSnapshot = false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
_pendingSnapshot = null;
|
||||
_hasPendingSnapshot = false;
|
||||
_isDisposed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -632,6 +632,8 @@ public partial class MainWindow : Window
|
||||
ThemeColorMode = latestThemeState.ThemeColorMode,
|
||||
SystemMaterialMode = latestThemeState.SystemMaterialMode,
|
||||
SelectedWallpaperSeed = latestThemeState.SelectedWallpaperSeed,
|
||||
ThemeWallpaperColorSource = latestThemeState.ThemeWallpaperColorSource,
|
||||
UseNativeWallpaperChangeEvents = latestThemeState.UseNativeWallpaperChangeEvents,
|
||||
UseSystemChrome = latestThemeState.UseSystemChrome,
|
||||
CornerRadiusStyle = latestThemeState.CornerRadiusStyle,
|
||||
WallpaperPath = latestWallpaperState.WallpaperPath,
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:LanMountainDesktop.ViewModels"
|
||||
xmlns:controls="using:LanMountainDesktop.Controls"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
x:Class="LanMountainDesktop.Views.SettingsPages.AppearanceSettingsPage"
|
||||
x:DataType="vm:AppearanceSettingsPageViewModel">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||
|
||||
<controls:IconText Icon="Color"
|
||||
Text="{Binding ThemeHeader}"
|
||||
Margin="0,0,0,4" />
|
||||
|
||||
<ui:FASettingsExpander Header="{Binding ThemeModeLabel}"
|
||||
Description="{Binding ThemeModeDescription}">
|
||||
Description="{Binding ThemeModeDescription}">
|
||||
<ui:FASettingsExpander.IconSource>
|
||||
<ui:FAFontIconSource Glyph="󰔄" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
|
||||
</ui:FASettingsExpander.IconSource>
|
||||
@@ -40,33 +38,15 @@
|
||||
</ui:FASettingsExpander.Footer>
|
||||
</ui:FASettingsExpander>
|
||||
|
||||
<ui:FASettingsExpander Header="{Binding ThemeColorModeLabel}"
|
||||
Description="{Binding ThemeColorSourceDescription}">
|
||||
<ui:FASettingsExpander Header="{Binding CornerRadiusStyleLabel}"
|
||||
Description="{Binding CornerRadiusStyleDescription}">
|
||||
<ui:FASettingsExpander.IconSource>
|
||||
<ui:FAFontIconSource Glyph="󰅼" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
|
||||
</ui:FASettingsExpander.IconSource>
|
||||
<ui:FASettingsExpander.Footer>
|
||||
<ComboBox Width="240"
|
||||
ItemsSource="{Binding ThemeColorModes}"
|
||||
SelectedItem="{Binding SelectedThemeColorMode}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SelectionOption">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</ui:FASettingsExpander.Footer>
|
||||
</ui:FASettingsExpander>
|
||||
|
||||
<ui:FASettingsExpander Header="{Binding SystemMaterialLabel}"
|
||||
Description="{Binding SystemMaterialDescription}">
|
||||
<ui:FASettingsExpander.IconSource>
|
||||
<ui:FAFontIconSource Glyph="󱩈" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
|
||||
<ui:FAFontIconSource Glyph="󰄦" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
|
||||
</ui:FASettingsExpander.IconSource>
|
||||
<ui:FASettingsExpander.Footer>
|
||||
<ComboBox Width="220"
|
||||
ItemsSource="{Binding SystemMaterialModes}"
|
||||
SelectedItem="{Binding SelectedSystemMaterialMode}">
|
||||
ItemsSource="{Binding CornerRadiusStyleOptions}"
|
||||
SelectedItem="{Binding SelectedCornerRadiusStyle}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SelectionOption">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
@@ -75,190 +55,6 @@
|
||||
</ComboBox>
|
||||
</ui:FASettingsExpander.Footer>
|
||||
</ui:FASettingsExpander>
|
||||
|
||||
<ui:FASettingsExpander Header="{Binding ThemeColorLabel}"
|
||||
Description="{Binding ThemeColorSourceDescription}">
|
||||
<ui:FASettingsExpander.IconSource>
|
||||
<ui:FAFontIconSource Glyph="󰢈" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
|
||||
</ui:FASettingsExpander.IconSource>
|
||||
<ui:FASettingsExpanderItem>
|
||||
<StackPanel Spacing="12">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="12"
|
||||
IsVisible="{Binding ShowNeutralPreview}">
|
||||
<StackPanel Width="96"
|
||||
Spacing="6">
|
||||
<Border Height="54"
|
||||
Background="{Binding NeutralLightPreviewBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusSm}" />
|
||||
<TextBlock Text="{Binding PreviewNeutralLightLabel}"
|
||||
HorizontalAlignment="Center"
|
||||
TextAlignment="Center" />
|
||||
</StackPanel>
|
||||
<StackPanel Width="96"
|
||||
Spacing="6">
|
||||
<Border Height="54"
|
||||
Background="{Binding NeutralDarkPreviewBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusSm}" />
|
||||
<TextBlock Text="{Binding PreviewNeutralDarkLabel}"
|
||||
HorizontalAlignment="Center"
|
||||
TextAlignment="Center" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="12"
|
||||
IsVisible="{Binding ShowMonetPreview}">
|
||||
<StackPanel Width="92"
|
||||
Spacing="6">
|
||||
<Border Height="54"
|
||||
Background="{Binding PrimarySwatchBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusSm}" />
|
||||
<TextBlock Text="{Binding PreviewPrimaryLabel}"
|
||||
HorizontalAlignment="Center"
|
||||
TextAlignment="Center" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Width="92"
|
||||
Spacing="6">
|
||||
<Border Height="54"
|
||||
Background="{Binding SecondarySwatchBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusSm}" />
|
||||
<TextBlock Text="{Binding PreviewSecondaryLabel}"
|
||||
HorizontalAlignment="Center"
|
||||
TextAlignment="Center" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Width="92"
|
||||
Spacing="6">
|
||||
<Border Height="54"
|
||||
Background="{Binding TertiarySwatchBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusSm}" />
|
||||
<TextBlock Text="{Binding PreviewTertiaryLabel}"
|
||||
HorizontalAlignment="Center"
|
||||
TextAlignment="Center" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Width="92"
|
||||
Spacing="6">
|
||||
<Border Height="54"
|
||||
Background="{Binding NeutralSwatchBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusSm}" />
|
||||
<TextBlock Text="{Binding PreviewNeutralLabel}"
|
||||
HorizontalAlignment="Center"
|
||||
TextAlignment="Center" />
|
||||
</StackPanel>
|
||||
|
||||
<Button x:Name="CustomSeedButton"
|
||||
Width="92"
|
||||
Padding="0"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
HorizontalAlignment="Left"
|
||||
IsVisible="{Binding IsThemeColorEditable}">
|
||||
<Button.Flyout>
|
||||
<Flyout Placement="BottomEdgeAlignedLeft"
|
||||
Closed="OnCustomSeedFlyoutClosed">
|
||||
<StackPanel Width="300"
|
||||
Spacing="12">
|
||||
<ColorPicker Color="{Binding CustomSeedPickerValue}" />
|
||||
<Button Content="{Binding SeedApplyButtonText}"
|
||||
HorizontalAlignment="Right"
|
||||
Click="OnApplyCustomSeedClick" />
|
||||
</StackPanel>
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
<StackPanel Width="92"
|
||||
Spacing="6">
|
||||
<Border Height="54"
|
||||
Background="{Binding SeedSwatchBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusSm}" />
|
||||
<TextBlock Text="{Binding PreviewSeedLabel}"
|
||||
HorizontalAlignment="Center"
|
||||
TextAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button x:Name="WallpaperSeedButton"
|
||||
Width="92"
|
||||
Padding="0"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
HorizontalAlignment="Left"
|
||||
IsVisible="{Binding IsWallpaperMode}"
|
||||
IsEnabled="{Binding IsWallpaperSeedSelectable}">
|
||||
<Button.Flyout>
|
||||
<Flyout Placement="BottomEdgeAlignedLeft">
|
||||
<StackPanel Width="280"
|
||||
Spacing="12">
|
||||
<TextBlock Text="{Binding WallpaperSeedFlyoutTitle}"
|
||||
FontWeight="SemiBold" />
|
||||
<ItemsControl ItemsSource="{Binding WallpaperSeedCandidates}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ThemeSeedCandidateOption">
|
||||
<Button Padding="0"
|
||||
Margin="0,0,8,8"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Click="OnWallpaperSeedCandidateClick">
|
||||
<StackPanel Width="76"
|
||||
Spacing="6">
|
||||
<Border Height="44"
|
||||
Background="{Binding Brush}"
|
||||
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusXs}" />
|
||||
<TextBlock Text="{Binding Label}"
|
||||
HorizontalAlignment="Center"
|
||||
TextAlignment="Center"
|
||||
TextWrapping="Wrap" />
|
||||
<TextBlock Text="{Binding $parent[vm:AppearanceSettingsPageViewModel].WallpaperSeedCurrentText}"
|
||||
HorizontalAlignment="Center"
|
||||
FontSize="10"
|
||||
IsVisible="{Binding IsSelected}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
<StackPanel Width="92"
|
||||
Spacing="6">
|
||||
<Border Height="54"
|
||||
Background="{Binding SeedSwatchBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusSm}" />
|
||||
<TextBlock Text="{Binding PreviewSeedLabel}"
|
||||
HorizontalAlignment="Center"
|
||||
TextAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ui:FASettingsExpanderItem>
|
||||
</ui:FASettingsExpander>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:LanMountainDesktop.ViewModels"
|
||||
xmlns:controls="using:LanMountainDesktop.Controls"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
x:Class="LanMountainDesktop.Views.SettingsPages.MaterialColorSettingsPage"
|
||||
x:DataType="vm:MaterialColorSettingsPageViewModel">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||
<controls:IconText Icon="Color"
|
||||
Text="{Binding PageTitle}"
|
||||
Margin="0,0,0,4" />
|
||||
|
||||
<ui:FASettingsExpander Header="{Binding ColorSourceLabel}"
|
||||
Description="{Binding ColorSourceDescription}">
|
||||
<ui:FASettingsExpander.IconSource>
|
||||
<ui:FAFontIconSource Glyph="󰅼" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
|
||||
</ui:FASettingsExpander.IconSource>
|
||||
<ui:FASettingsExpander.Footer>
|
||||
<ComboBox Width="240"
|
||||
ItemsSource="{Binding ColorModes}"
|
||||
SelectedItem="{Binding SelectedColorMode}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SelectionOption">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</ui:FASettingsExpander.Footer>
|
||||
</ui:FASettingsExpander>
|
||||
|
||||
<ui:FASettingsExpander Header="{Binding CustomSeedLabel}"
|
||||
IsVisible="{Binding IsCustomSeedVisible}">
|
||||
<ui:FASettingsExpander.IconSource>
|
||||
<ui:FAFontIconSource Glyph="󰢈" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
|
||||
</ui:FASettingsExpander.IconSource>
|
||||
<ui:FASettingsExpanderItem>
|
||||
<Grid ColumnDefinitions="96,*"
|
||||
ColumnSpacing="16">
|
||||
<Border Width="96"
|
||||
Height="64"
|
||||
Background="{Binding SeedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusSm}" />
|
||||
<ColorPicker Grid.Column="1"
|
||||
Color="{Binding CustomSeedPickerValue}" />
|
||||
</Grid>
|
||||
</ui:FASettingsExpanderItem>
|
||||
</ui:FASettingsExpander>
|
||||
|
||||
<ui:FASettingsExpander Header="{Binding WallpaperColorSourceLabel}"
|
||||
IsVisible="{Binding IsWallpaperOptionsVisible}">
|
||||
<ui:FASettingsExpander.IconSource>
|
||||
<ui:FAFontIconSource Glyph="󰙼" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
|
||||
</ui:FASettingsExpander.IconSource>
|
||||
<ui:FASettingsExpander.Footer>
|
||||
<ComboBox Width="220"
|
||||
ItemsSource="{Binding WallpaperColorSources}"
|
||||
SelectedItem="{Binding SelectedWallpaperColorSource}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SelectionOption">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</ui:FASettingsExpander.Footer>
|
||||
<ui:FASettingsExpanderItem>
|
||||
<StackPanel Spacing="12">
|
||||
<ItemsControl ItemsSource="{Binding WallpaperSeedCandidates}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ThemeSeedCandidateOption">
|
||||
<Button Padding="0"
|
||||
Margin="0,0,8,8"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Click="OnWallpaperSeedCandidateClick">
|
||||
<StackPanel Width="84"
|
||||
Spacing="6">
|
||||
<Border Height="48"
|
||||
Background="{Binding Brush}"
|
||||
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusXs}" />
|
||||
<TextBlock Text="{Binding Label}"
|
||||
HorizontalAlignment="Center"
|
||||
TextAlignment="Center"
|
||||
TextWrapping="Wrap" />
|
||||
<TextBlock Text="{Binding $parent[vm:MaterialColorSettingsPageViewModel].WallpaperSeedCurrentText}"
|
||||
HorizontalAlignment="Center"
|
||||
FontSize="10"
|
||||
IsVisible="{Binding IsSelected}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</ui:FASettingsExpanderItem>
|
||||
</ui:FASettingsExpander>
|
||||
|
||||
<ui:FASettingsExpander Header="{Binding NativeWallpaperEventsLabel}"
|
||||
Description="{Binding NativeWallpaperEventsDescription}"
|
||||
IsVisible="{Binding IsWallpaperOptionsVisible}">
|
||||
<ui:FASettingsExpander.IconSource>
|
||||
<ui:FAFontIconSource Glyph="󰅨" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
|
||||
</ui:FASettingsExpander.IconSource>
|
||||
<ui:FASettingsExpander.Footer>
|
||||
<ToggleSwitch IsChecked="{Binding UseNativeWallpaperChangeEvents}" />
|
||||
</ui:FASettingsExpander.Footer>
|
||||
<ui:FASettingsExpanderItem>
|
||||
<TextBlock Text="{Binding NativeEventStatusText}"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
</ui:FASettingsExpanderItem>
|
||||
</ui:FASettingsExpander>
|
||||
|
||||
<ui:FASettingsExpander Header="{Binding RefreshIntervalLabel}"
|
||||
IsVisible="{Binding IsWallpaperOptionsVisible}">
|
||||
<ui:FASettingsExpander.IconSource>
|
||||
<ui:FAFontIconSource Glyph="󰅨" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
|
||||
</ui:FASettingsExpander.IconSource>
|
||||
<ui:FASettingsExpander.Footer>
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="8">
|
||||
<ComboBox Width="140"
|
||||
ItemsSource="{Binding RefreshIntervals}"
|
||||
SelectedItem="{Binding SelectedRefreshInterval}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SelectionOption">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<Button Classes="settings-accent-button"
|
||||
Command="{Binding RefreshWallpaperColorsCommand}"
|
||||
ToolTip.Tip="{Binding RefreshNowText}"
|
||||
VerticalAlignment="Center"
|
||||
Padding="12,8">
|
||||
<fi:SymbolIcon Symbol="ArrowSync"
|
||||
IconVariant="Regular" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</ui:FASettingsExpander.Footer>
|
||||
</ui:FASettingsExpander>
|
||||
|
||||
<ui:FASettingsExpander Header="{Binding SystemMaterialLabel}"
|
||||
Description="{Binding SystemMaterialDescription}">
|
||||
<ui:FASettingsExpander.IconSource>
|
||||
<ui:FAFontIconSource Glyph="󱩈" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
|
||||
</ui:FASettingsExpander.IconSource>
|
||||
<ui:FASettingsExpander.Footer>
|
||||
<ComboBox Width="220"
|
||||
ItemsSource="{Binding SystemMaterialModes}"
|
||||
SelectedItem="{Binding SelectedSystemMaterialMode}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SelectionOption">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</ui:FASettingsExpander.Footer>
|
||||
</ui:FASettingsExpander>
|
||||
|
||||
<ui:FASettingsExpander Header="{Binding PreviewHeader}">
|
||||
<ui:FASettingsExpander.IconSource>
|
||||
<ui:FAFontIconSource Glyph="󰔄" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
|
||||
</ui:FASettingsExpander.IconSource>
|
||||
<ui:FASettingsExpanderItem>
|
||||
<StackPanel Spacing="16">
|
||||
<Grid ColumnDefinitions="120,*"
|
||||
ColumnSpacing="16">
|
||||
<Border Height="72"
|
||||
Background="{Binding AccentBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}" />
|
||||
<StackPanel Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="4">
|
||||
<TextBlock Text="{Binding SourceStatusHeader}"
|
||||
FontWeight="SemiBold" />
|
||||
<TextBlock Text="{Binding ResolvedSourceText}"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
<TextBlock Text="{Binding ResolvedWallpaperPathText}"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource AdaptiveTextMutedBrush}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Text="{Binding SemanticColorsHeader}"
|
||||
FontWeight="SemiBold" />
|
||||
<ItemsControl ItemsSource="{Binding ColorRolePreviews}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:MaterialColorRolePreviewOption">
|
||||
<StackPanel Width="112"
|
||||
Margin="0,0,10,10"
|
||||
Spacing="6">
|
||||
<Border Height="48"
|
||||
Background="{Binding Brush}"
|
||||
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusXs}" />
|
||||
<TextBlock Text="{Binding Label}"
|
||||
TextAlignment="Center" />
|
||||
<TextBlock Text="{Binding Value}"
|
||||
TextAlignment="Center"
|
||||
FontSize="10"
|
||||
Foreground="{DynamicResource AdaptiveTextMutedBrush}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<TextBlock Text="{Binding SurfacesHeader}"
|
||||
FontWeight="SemiBold" />
|
||||
<ItemsControl ItemsSource="{Binding SurfacePreviews}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:MaterialSurfacePreviewOption">
|
||||
<StackPanel Width="176"
|
||||
Margin="0,0,10,10"
|
||||
Spacing="6">
|
||||
<Border Height="58"
|
||||
Background="{Binding BackgroundBrush}"
|
||||
BorderBrush="{Binding BorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusSm}" />
|
||||
<TextBlock Text="{Binding Label}"
|
||||
TextAlignment="Center" />
|
||||
<TextBlock Text="{Binding Detail}"
|
||||
TextAlignment="Center"
|
||||
FontSize="10"
|
||||
Foreground="{DynamicResource AdaptiveTextMutedBrush}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</ui:FASettingsExpanderItem>
|
||||
</ui:FASettingsExpander>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,45 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.ViewModels;
|
||||
|
||||
namespace LanMountainDesktop.Views.SettingsPages;
|
||||
|
||||
[SettingsPageInfo(
|
||||
"material-color",
|
||||
"Material & Color",
|
||||
SettingsPageCategory.Appearance,
|
||||
IconKey = "Color",
|
||||
SortOrder = 8,
|
||||
TitleLocalizationKey = "settings.material_color.title",
|
||||
DescriptionLocalizationKey = "settings.material_color.description")]
|
||||
public partial class MaterialColorSettingsPage : SettingsPageBase
|
||||
{
|
||||
public MaterialColorSettingsPage()
|
||||
: this(new MaterialColorSettingsPageViewModel(
|
||||
HostSettingsFacadeProvider.GetOrCreate(),
|
||||
HostMaterialColorProvider.GetOrCreate()))
|
||||
{
|
||||
}
|
||||
|
||||
public MaterialColorSettingsPage(MaterialColorSettingsPageViewModel viewModel)
|
||||
{
|
||||
ViewModel = viewModel;
|
||||
DataContext = ViewModel;
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
public MaterialColorSettingsPageViewModel ViewModel { get; }
|
||||
|
||||
private void OnWallpaperSeedCandidateClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = e;
|
||||
|
||||
if (sender is Button { DataContext: ThemeSeedCandidateOption option })
|
||||
{
|
||||
ViewModel.SelectWallpaperSeed(option.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -336,6 +336,7 @@ public sealed class PluginLoader
|
||||
RegisterHostService<ISettingsService>(services, hostServices);
|
||||
RegisterHostService<ISettingsCatalog>(services, hostServices);
|
||||
RegisterHostService<IAppearanceThemeService>(services, hostServices);
|
||||
RegisterHostService<IMaterialColorService>(services, hostServices);
|
||||
RegisterHostService<IExternalIpcNotificationPublisher>(services, hostServices);
|
||||
|
||||
return services;
|
||||
@@ -347,17 +348,19 @@ public sealed class PluginLoader
|
||||
CornerRadiusTokens: new PluginCornerRadiusTokens(6, 12, 14, 20, 28, 32, 36, 24),
|
||||
ThemeVariant: "Unknown");
|
||||
|
||||
if (hostServices?.GetService(typeof(IAppearanceThemeService)) is not IAppearanceThemeService appearanceThemeService)
|
||||
{
|
||||
return defaultSnapshot;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var hostSnapshot = appearanceThemeService.GetCurrent();
|
||||
return new PluginAppearanceSnapshot(
|
||||
CornerRadiusTokens: PluginCornerRadiusTokens.FromShared(hostSnapshot.CornerRadiusTokens),
|
||||
ThemeVariant: hostSnapshot.IsNightMode ? "Dark" : "Light");
|
||||
if (hostServices?.GetService(typeof(IMaterialColorService)) is IMaterialColorService materialColorService)
|
||||
{
|
||||
return PluginAppearanceSnapshotMapper.FromMaterialColorSnapshot(materialColorService.GetMaterialColorSnapshot());
|
||||
}
|
||||
|
||||
if (hostServices?.GetService(typeof(IAppearanceThemeService)) is IAppearanceThemeService appearanceThemeService)
|
||||
{
|
||||
return PluginAppearanceSnapshotMapper.FromAppearanceSnapshot(appearanceThemeService.GetCurrent());
|
||||
}
|
||||
|
||||
return defaultSnapshot;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -33,6 +33,7 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
private readonly ISettingsFacadeService _settingsFacade;
|
||||
private readonly SettingsCatalogService _settingsCatalogService;
|
||||
private readonly PublicIpcHostService? _publicIpcHostService;
|
||||
private readonly IMaterialColorService _materialColorService;
|
||||
private readonly List<LoadedPlugin> _loadedPlugins = [];
|
||||
private readonly List<PluginLoadResult> _loadResults = [];
|
||||
private readonly List<PluginCatalogEntry> _catalog = [];
|
||||
@@ -51,6 +52,7 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
_packageManager = new PluginRuntimePackageManager(this);
|
||||
_settingsFacade = settingsFacade ?? new SettingsFacadeService();
|
||||
_publicIpcHostService = publicIpcHostService;
|
||||
_materialColorService = HostMaterialColorProvider.GetOrCreate();
|
||||
_settingsCatalogService = _settingsFacade.Catalog as SettingsCatalogService
|
||||
?? new SettingsCatalogService();
|
||||
if (_settingsFacade is SettingsFacadeService concreteFacade)
|
||||
@@ -67,6 +69,7 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
_publicIpcHostService);
|
||||
_loaderOptions = CreateOptions();
|
||||
_loader = new PluginLoader(_loaderOptions);
|
||||
_materialColorService.MaterialColorChanged += OnMaterialColorChanged;
|
||||
}
|
||||
|
||||
public string PluginsDirectory { get; }
|
||||
@@ -423,6 +426,7 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_materialColorService.MaterialColorChanged -= OnMaterialColorChanged;
|
||||
UnloadInstalledPlugins();
|
||||
_sharedContractManager.Dispose();
|
||||
if (_settingsFacade is IDisposable disposable && !ReferenceEquals(_settingsFacade, HostSettingsFacadeProvider.GetOrCreate()))
|
||||
@@ -431,6 +435,32 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private void OnMaterialColorChanged(object? sender, MaterialColorSnapshot snapshot)
|
||||
{
|
||||
_ = sender;
|
||||
|
||||
var pluginSnapshot = PluginAppearanceSnapshotMapper.FromMaterialColorSnapshot(snapshot);
|
||||
var changedProperties = new[]
|
||||
{
|
||||
AppearanceProperty.ThemeVariant,
|
||||
AppearanceProperty.AccentColor,
|
||||
AppearanceProperty.Wallpaper,
|
||||
AppearanceProperty.SystemMaterialMode,
|
||||
AppearanceProperty.ColorSource,
|
||||
AppearanceProperty.ColorRoles,
|
||||
AppearanceProperty.MaterialSurfaces,
|
||||
AppearanceProperty.WallpaperSeedCandidates
|
||||
};
|
||||
|
||||
foreach (var loadedPlugin in _loadedPlugins)
|
||||
{
|
||||
if (loadedPlugin.RuntimeContext.Appearance is PluginAppearanceContext appearanceContext)
|
||||
{
|
||||
appearanceContext.UpdateSnapshot(pluginSnapshot, changedProperties);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void UnloadInstalledPlugins()
|
||||
{
|
||||
for (var i = _loadedPlugins.Count - 1; i >= 0; i--)
|
||||
@@ -1016,6 +1046,7 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly ISettingsCatalog _settingsCatalog;
|
||||
private readonly IAppearanceThemeService _appearanceThemeService;
|
||||
private readonly IMaterialColorService _materialColorService;
|
||||
private readonly IExternalIpcNotificationPublisher? _externalIpcNotificationPublisher;
|
||||
|
||||
public PluginHostServiceProvider(
|
||||
@@ -1034,6 +1065,7 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
_settingsService = settingsService;
|
||||
_settingsCatalog = settingsCatalog;
|
||||
_appearanceThemeService = HostAppearanceThemeProvider.GetOrCreate();
|
||||
_materialColorService = HostMaterialColorProvider.GetOrCreate();
|
||||
_externalIpcNotificationPublisher = externalIpcNotificationPublisher;
|
||||
}
|
||||
|
||||
@@ -1074,6 +1106,11 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
return _appearanceThemeService;
|
||||
}
|
||||
|
||||
if (serviceType == typeof(IMaterialColorService))
|
||||
{
|
||||
return _materialColorService;
|
||||
}
|
||||
|
||||
if (serviceType == typeof(IExternalIpcNotificationPublisher))
|
||||
{
|
||||
return _externalIpcNotificationPublisher;
|
||||
|
||||
@@ -11,4 +11,6 @@ public sealed record PlondsDeltaBuildOptions(
|
||||
string? BaselineVersion = null,
|
||||
string? BaselineTag = null,
|
||||
string? BaselinePayloadZip = null,
|
||||
bool IsFullPayload = false);
|
||||
bool IsFullPayload = false,
|
||||
string? StaticOutputRoot = null,
|
||||
string? UpdateBaseUrl = null);
|
||||
|
||||
@@ -53,7 +53,13 @@ public sealed class PlondsDeltaBuilder
|
||||
? new Dictionary<string, PayloadUtilities.FileFingerprint>(StringComparer.OrdinalIgnoreCase)
|
||||
: PayloadUtilities.ScanDirectory(baselineExtractRoot);
|
||||
var currentManifest = PayloadUtilities.ScanDirectory(currentExtractRoot);
|
||||
var fileEntries = BuildFileEntries(previousManifest, currentManifest, objectsRoot);
|
||||
var updateBaseUrl = string.IsNullOrWhiteSpace(options.UpdateBaseUrl)
|
||||
? null
|
||||
: options.UpdateBaseUrl.TrimEnd('/');
|
||||
var repoBaseUrl = string.IsNullOrWhiteSpace(updateBaseUrl)
|
||||
? null
|
||||
: $"{updateBaseUrl}/repo/sha256";
|
||||
var fileEntries = BuildFileEntries(previousManifest, currentManifest, objectsRoot, repoBaseUrl);
|
||||
|
||||
var updateAssetName = $"update-{options.Platform}.zip";
|
||||
var fileMapAssetName = $"plonds-filemap-{options.Platform}.json";
|
||||
@@ -76,6 +82,7 @@ public sealed class PlondsDeltaBuilder
|
||||
["isFullPayload"] = useFullPayload ? "true" : "false"
|
||||
};
|
||||
|
||||
var generatedAt = DateTimeOffset.UtcNow;
|
||||
var component = new ComponentDocument(
|
||||
Name: "app",
|
||||
Version: options.CurrentVersion,
|
||||
@@ -95,7 +102,7 @@ public sealed class PlondsDeltaBuilder
|
||||
Platform: options.Platform,
|
||||
Arch: PayloadUtilities.ResolveArch(options.Platform),
|
||||
Channel: options.Channel,
|
||||
GeneratedAt: DateTimeOffset.UtcNow,
|
||||
GeneratedAt: generatedAt,
|
||||
Metadata: metadata,
|
||||
Components: [component],
|
||||
Files: fileEntries);
|
||||
@@ -103,6 +110,20 @@ public sealed class PlondsDeltaBuilder
|
||||
PayloadUtilities.WriteJson(fileMapPath, fileMap);
|
||||
_signer.SignFile(fileMapPath, options.PrivateKeyPath, fileMapSignaturePath);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.StaticOutputRoot) && !string.IsNullOrWhiteSpace(updateBaseUrl))
|
||||
{
|
||||
WriteStaticLayout(
|
||||
options,
|
||||
component,
|
||||
objectsRoot,
|
||||
distributionId,
|
||||
fileMapPath,
|
||||
fileMapSignaturePath,
|
||||
Path.GetFullPath(options.StaticOutputRoot),
|
||||
updateBaseUrl,
|
||||
generatedAt);
|
||||
}
|
||||
|
||||
var summary = new PlondsReleasePlatformEntry(
|
||||
Platform: options.Platform,
|
||||
DistributionId: distributionId,
|
||||
@@ -135,7 +156,8 @@ public sealed class PlondsDeltaBuilder
|
||||
private static List<FileEntryDocument> BuildFileEntries(
|
||||
IReadOnlyDictionary<string, PayloadUtilities.FileFingerprint> previousManifest,
|
||||
IReadOnlyDictionary<string, PayloadUtilities.FileFingerprint> currentManifest,
|
||||
string objectsRoot)
|
||||
string objectsRoot,
|
||||
string? repoBaseUrl)
|
||||
{
|
||||
var result = new List<FileEntryDocument>();
|
||||
|
||||
@@ -152,12 +174,16 @@ public sealed class PlondsDeltaBuilder
|
||||
Size: current.Size,
|
||||
ObjectPath: null,
|
||||
ObjectKey: null,
|
||||
ObjectUrl: null,
|
||||
Metadata: null));
|
||||
continue;
|
||||
}
|
||||
|
||||
var action = previousManifest.ContainsKey(path) ? "replace" : "add";
|
||||
var objectPath = PayloadUtilities.CopyObject(current.FullPath, objectsRoot, current.Sha256);
|
||||
var objectUrl = string.IsNullOrWhiteSpace(repoBaseUrl)
|
||||
? null
|
||||
: $"{repoBaseUrl.TrimEnd('/')}/{objectPath}";
|
||||
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["mode"] = "file-object"
|
||||
@@ -174,6 +200,7 @@ public sealed class PlondsDeltaBuilder
|
||||
Size: current.Size,
|
||||
ObjectPath: objectPath,
|
||||
ObjectKey: objectPath,
|
||||
ObjectUrl: objectUrl,
|
||||
Metadata: metadata));
|
||||
}
|
||||
|
||||
@@ -191,12 +218,87 @@ public sealed class PlondsDeltaBuilder
|
||||
Size: 0,
|
||||
ObjectPath: null,
|
||||
ObjectKey: null,
|
||||
ObjectUrl: null,
|
||||
Metadata: null));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void WriteStaticLayout(
|
||||
PlondsDeltaBuildOptions options,
|
||||
ComponentDocument component,
|
||||
string objectsRoot,
|
||||
string distributionId,
|
||||
string fileMapPath,
|
||||
string fileMapSignaturePath,
|
||||
string staticOutputRoot,
|
||||
string updateBaseUrl,
|
||||
DateTimeOffset generatedAt)
|
||||
{
|
||||
var repoRoot = Path.Combine(staticOutputRoot, "repo", "sha256");
|
||||
var manifestRoot = Path.Combine(staticOutputRoot, "manifests", distributionId);
|
||||
var distributionRoot = Path.Combine(staticOutputRoot, "meta", "distributions");
|
||||
var channelRoot = Path.Combine(staticOutputRoot, "meta", "channels", options.Channel, options.Platform);
|
||||
|
||||
CopyDirectory(objectsRoot, repoRoot);
|
||||
Directory.CreateDirectory(manifestRoot);
|
||||
File.Copy(fileMapPath, Path.Combine(manifestRoot, "plonds-filemap.json"), overwrite: true);
|
||||
File.Copy(fileMapSignaturePath, Path.Combine(manifestRoot, "plonds-filemap.json.sig"), overwrite: true);
|
||||
|
||||
var fileMapUrl = $"{updateBaseUrl}/manifests/{Uri.EscapeDataString(distributionId)}/plonds-filemap.json";
|
||||
var distribution = new DistributionDocument(
|
||||
DistributionId: distributionId,
|
||||
Version: options.CurrentVersion,
|
||||
SourceVersion: options.BaselineVersion ?? "0.0.0",
|
||||
Channel: options.Channel,
|
||||
Platform: options.Platform,
|
||||
Arch: PayloadUtilities.ResolveArch(options.Platform),
|
||||
PublishedAt: generatedAt,
|
||||
FileMapUrl: fileMapUrl,
|
||||
FileMapSignatureUrl: fileMapUrl + ".sig",
|
||||
Components: [component],
|
||||
InstallerMirrors: [],
|
||||
Capabilities: ["file-object"],
|
||||
Metadata: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["protocol"] = "PLONDS",
|
||||
["releaseTag"] = options.CurrentTag,
|
||||
["baselineTag"] = options.BaselineTag ?? string.Empty,
|
||||
["baselineVersion"] = options.BaselineVersion ?? "0.0.0",
|
||||
["targetVersion"] = options.CurrentVersion,
|
||||
["isFullPayload"] = options.IsFullPayload ? "true" : "false"
|
||||
});
|
||||
|
||||
var latest = new LatestPointerDocument(
|
||||
DistributionId: distributionId,
|
||||
Version: options.CurrentVersion,
|
||||
Channel: options.Channel,
|
||||
Platform: options.Platform,
|
||||
PublishedAt: generatedAt);
|
||||
|
||||
PayloadUtilities.WriteJson(Path.Combine(distributionRoot, distributionId + ".json"), distribution);
|
||||
PayloadUtilities.WriteJson(Path.Combine(channelRoot, "latest.json"), latest);
|
||||
}
|
||||
|
||||
private static void CopyDirectory(string sourceDir, string destinationDir)
|
||||
{
|
||||
Directory.CreateDirectory(destinationDir);
|
||||
foreach (var directory in Directory.EnumerateDirectories(sourceDir, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(sourceDir, directory);
|
||||
Directory.CreateDirectory(Path.Combine(destinationDir, relativePath));
|
||||
}
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(sourceDir, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(sourceDir, file);
|
||||
var destinationPath = Path.Combine(destinationDir, relativePath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
|
||||
File.Copy(file, destinationPath, overwrite: true);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record FileMapDocument(
|
||||
string FormatVersion,
|
||||
string DistributionId,
|
||||
@@ -224,5 +326,35 @@ public sealed class PlondsDeltaBuilder
|
||||
long Size,
|
||||
string? ObjectPath,
|
||||
string? ObjectKey,
|
||||
string? ObjectUrl,
|
||||
IReadOnlyDictionary<string, string>? Metadata);
|
||||
|
||||
private sealed record DistributionDocument(
|
||||
string DistributionId,
|
||||
string Version,
|
||||
string SourceVersion,
|
||||
string Channel,
|
||||
string Platform,
|
||||
string Arch,
|
||||
DateTimeOffset PublishedAt,
|
||||
string FileMapUrl,
|
||||
string FileMapSignatureUrl,
|
||||
IReadOnlyList<ComponentDocument> Components,
|
||||
IReadOnlyList<InstallerMirrorDocument> InstallerMirrors,
|
||||
IReadOnlyList<string> Capabilities,
|
||||
IReadOnlyDictionary<string, string>? Metadata);
|
||||
|
||||
private sealed record LatestPointerDocument(
|
||||
string DistributionId,
|
||||
string Version,
|
||||
string Channel,
|
||||
string Platform,
|
||||
DateTimeOffset PublishedAt);
|
||||
|
||||
private sealed record InstallerMirrorDocument(
|
||||
string Platform,
|
||||
string? Url,
|
||||
string? FileName,
|
||||
string? Sha256,
|
||||
long Size);
|
||||
}
|
||||
|
||||
@@ -135,7 +135,9 @@ internal static class PlondsCli
|
||||
BaselineVersion: Get(options, "baseline-version"),
|
||||
BaselineTag: Get(options, "baseline-tag"),
|
||||
BaselinePayloadZip: Get(options, "baseline-zip"),
|
||||
IsFullPayload: bool.TryParse(Get(options, "is-full-payload", "false"), out var isFullPayload) && isFullPayload));
|
||||
IsFullPayload: bool.TryParse(Get(options, "is-full-payload", "false"), out var isFullPayload) && isFullPayload,
|
||||
StaticOutputRoot: Get(options, "static-output-dir"),
|
||||
UpdateBaseUrl: Get(options, "update-base-url")));
|
||||
|
||||
Console.WriteLine($"Built PLONDS delta for {result.Platform}: {result.UpdateArchivePath}");
|
||||
Console.WriteLine(result.FileMapPath);
|
||||
@@ -211,7 +213,7 @@ internal static class PlondsCli
|
||||
{
|
||||
Console.WriteLine("PLONDS Tool");
|
||||
Console.WriteLine(" pack-payload --source-dir <dir> --output-zip <file>");
|
||||
Console.WriteLine(" build-delta --platform <platform> --current-version <v> --current-tag <tag> --current-zip <file> --output-dir <dir> --private-key <pem> [--baseline-tag <tag>] [--baseline-version <v>] [--baseline-zip <file>] [--is-full-payload]");
|
||||
Console.WriteLine(" build-delta --platform <platform> --current-version <v> --current-tag <tag> --current-zip <file> --output-dir <dir> --private-key <pem> [--baseline-tag <tag>] [--baseline-version <v>] [--baseline-zip <file>] [--is-full-payload] [--static-output-dir <dir>] [--update-base-url <url>]");
|
||||
Console.WriteLine(" build-index --release-tag <tag> --version <v> --platform-summaries-dir <dir> --output-dir <dir> --private-key <pem> [--channel <channel>]");
|
||||
Console.WriteLine(" build-ddss --release-tag <tag> --assets-dir <dir> --output-dir <dir> --private-key <pem> --repository <owner/repo> [--s3-base-url <url>]");
|
||||
Console.WriteLine(" sign --manifest <file> --private-key <pem> [--output <file>]");
|
||||
|
||||
Reference in New Issue
Block a user