Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
ce88aa7
wip: backup of local v0.6.5 work (keystore v2, smart playlists, M3U, …
ZenQuantDev Oct 27, 2026
3581b30
Merge v0.6.5 backup: keystore v2, smart playlists, M3U, provider regi…
ZenQuantDev Oct 27, 2026
c98290b
chore: retarget to .NET 10 LTS (EOL Nov 10 for net8)
ZenQuantDev Oct 27, 2026
3bf8b68
ci: replace broken workflows with Windows+Ubuntu matrix
ZenQuantDev Oct 27, 2026
3715e57
ci: add Windows+Ubuntu matrix workflow
ZenQuantDev Oct 27, 2026
47dc0aa
Merge .NET 10 migration and CI workflow into v0.6.5
ZenQuantDev Oct 27, 2026
6eae258
feat: centralize supported sites, proxy routing, and source detection
ZenQuantDev Oct 29, 2026
42bac9d
feat: add generic yt-dlp metadata fetcher, async JS probe, and downlo…
ZenQuantDev Oct 29, 2026
da7d8f3
fix: Keystore v2 migration, directory creation, and secure deletion c…
ZenQuantDev Oct 29, 2026
e8fb45a
feat: add Sources tab UI, consent gates, and provider registry founda…
ZenQuantDev Oct 29, 2026
d8fd2d7
feat: add localization strings for Sources tab
ZenQuantDev Oct 29, 2026
02dc7ae
docs: finalize v0.6.5-beta release notes, privacy policy, and package…
ZenQuantDev Oct 29, 2026
e591a85
feat(hardening): v0.6.5-beta.1 - source routing, keystore safety, dow…
ZenQuantDev Oct 30, 2026
3374ab9
fix: force UTF-8 encoding for yt-dlp, recover files by video ID, and …
ZenQuantDev Oct 30, 2026
fb7a340
fix: globalization, culture-invariant parsing, non-english search, do…
ZenQuantDev Oct 31, 2026
8353b2d
feat(beta.2): data safety, auto-relink, security hardening, and globa…
ZenQuantDev Nov 1, 2026
5d24ffb
fix(playback/library): queue coalescing, navigator caching, and relin…
ZenQuantDev Nov 4, 2026
46a6e0a
fix: ui watchdog, consent gate, crossfade state sync, and scrobble gu…
ZenQuantDev Nov 5, 2026
025226d
feat(downloads): consent dialog on every download entry point, single…
ZenQuantDev Nov 5, 2026
9faa006
fix(updates): honest status, pre-release detection, parallel dependen…
ZenQuantDev Nov 5, 2026
302ebc6
refactor(settings): merge Plugins and Sources into Integrations, add …
ZenQuantDev Nov 5, 2026
32ad56e
fix(diagnostics): start UiHangDetector after the main window opens
ZenQuantDev Nov 5, 2026
44434fa
fix(updates): extract versioning helper with tests, honest winget exi…
ZenQuantDev Nov 6, 2026
1e42119
fix(playback): run native Stop off UI thread, prevent ghost rows on c…
ZenQuantDev Nov 6, 2026
1343cb6
feat(library): unified FileNamer and OS-native Recycle Bin for safe d…
ZenQuantDev Nov 6, 2026
de8765b
feat(settings): add TagWriteMode (Never/DownloadsOnly/Always) with ce…
ZenQuantDev Nov 6, 2026
8638c23
feat(library): implement N3 import modes (Keep/Copy) with FileOperati…
ZenQuantDev Nov 6, 2026
f6e9351
feat(library): implement N6 safe export pipeline (copy, rename, retag…
ZenQuantDev Nov 6, 2026
bc51187
fix(filenamer): cross-platform invalid chars, surrogate-safe truncati…
ZenQuantDev Nov 6, 2026
d3574fd
fix(recyclebin): allow dotted names, argv-based process args, working…
ZenQuantDev Nov 6, 2026
c346c40
fix(journal): debounced atomic flush, flush on exit, honest doc comment
ZenQuantDev Nov 6, 2026
8204ab6
fix(library): central CanWriteTags, filename-marker managed rule, rel…
ZenQuantDev Nov 6, 2026
fc7550d
fix(export): run off the UI thread, restore TagLib globals, skip plac…
ZenQuantDev Nov 6, 2026
af82021
fix(import): duplicate-check before copy, honor ImportMode pref
ZenQuantDev Nov 6, 2026
40943a3
ci: run on feature branches, enforce banned APIs (RS0030)
ZenQuantDev Nov 7, 2026
d0d34e2
fix(keystore): prevent Linux data loss, fallback on lock, reject newe…
ZenQuantDev Nov 7, 2026
4be9e2a
test(plugins): make parallel and blocking init tests deterministic
ZenQuantDev Nov 7, 2026
8389e68
fix(safety): confirm and move orphaned files
ZenQuantDev Nov 7, 2026
52c752a
feat(consent): gate package manager installs
ZenQuantDev Nov 7, 2026
e5107cd
test(safety): cover library file invariants
ZenQuantDev Nov 7, 2026
6d94624
fix(keystore): disclose fallback and clear secret service
ZenQuantDev Nov 7, 2026
30ac785
fix(localization): complete settings and dialogs in EN/RU
ZenQuantDev Nov 7, 2026
6b39c47
fix(consent): disable silent winget/pip fallbacks for yt-dlp updates
ZenQuantDev Nov 7, 2026
526d680
docs: beta.2 changelog, README file-safety, roadmap sync, master guid…
ZenQuantDev Nov 7, 2026
928d3ae
ui: phase 1 - typography and spacing token standardization
ZenQuantDev Nov 7, 2026
ca144d7
ui: phase 2 - replace all sliders with WaveSeekBar
ZenQuantDev Nov 7, 2026
350fd6c
ui: phase 3 - motion system and window transitions
ZenQuantDev Nov 7, 2026
8ce4e82
fix: use supported Avalonia tab transition property
ZenQuantDev Nov 7, 2026
caa7bc2
ui: remove experimental gates from stable features, add Force All toggle
ZenQuantDev Nov 7, 2026
4986da5
feat(ui): dropdown fixes, effects tier gating, rail animation, typogr…
ZenQuantDev Nov 8, 2026
d861de7
feat(ui): integrations hub, effects tier UI, settings rail improvements
ZenQuantDev Nov 9, 2026
e4ea22a
feat(plugins): extract Contracts SDK, add discovery engine and OpenWe…
ZenQuantDev Nov 9, 2026
5babe37
feat(ui): add page transitions, plugin status fixes, and attention in…
ZenQuantDev Nov 9, 2026
7f6955f
feat(ui): add fade transitions to tabs, filters, and track list
ZenQuantDev Nov 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: CI

on:
push:
branches: [ main, "release/**", "feature/**" ]
pull_request:
branches: [ main ]
workflow_dispatch:

permissions:
contents: read

jobs:
build-and-test:
strategy:
fail-fast: false
matrix:
os: [windows-latest, ubuntu-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 20

steps:
- uses: actions/checkout@v4

- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
global-json-file: global.json

- name: Restore dependencies
run: dotnet restore

- name: Build
run: dotnet build --no-restore -warnaserror:RS0030

- name: Test
run: dotnet test --no-build --verbosity normal --logger trx --results-directory TestResults --filter "Category!=NeedsVlc"

- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results-${{ matrix.os }}
path: TestResults/**/*.trx
21 changes: 0 additions & 21 deletions .github/workflows/cl.yml

This file was deleted.

17 changes: 0 additions & 17 deletions .github/workflows/security.yml

This file was deleted.

11 changes: 11 additions & 0 deletions App.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,16 @@
<FluentTheme />
<StyleInclude Source="avares://NullWave/Themes/ControlStyles.axaml"/>
<materialIcons:MaterialIconStyles />

<!-- Global Font Fallback for Non-English Characters -->
<!-- TextBlock covers standalone text. -->
<Style Selector="TextBlock">
<Setter Property="FontFamily" Value="Segoe UI, Roboto, Noto Sans, Microsoft YaHei, Apple Color Emoji, Segoe UI Emoji" />
</Style>
<!-- TemplatedControl covers text inside Buttons, TextBoxes, Labels, etc.
It automatically cascades down to their internal TextPresenters. -->
<Style Selector="TemplatedControl">
<Setter Property="FontFamily" Value="Segoe UI, Roboto, Noto Sans, Microsoft YaHei, Apple Color Emoji, Segoe UI Emoji" />
</Style>
</Application.Styles>
</Application>
7 changes: 6 additions & 1 deletion App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ public override void Initialize()

public override void OnFrameworkInitializationCompleted()
{
// Register ComboBox scroll guard (closes dropdowns when user scrolls outside them)
NullWave.Helpers.Attached.ComboBoxScrollGuard.Register();

// Attach hardware detection to effects tier resolver so it re-evaluates when detection completes
_ = EffectsTierResolver.Shared.AttachDetection(NullWave.Services.SmartSorting.HardwareDetector.GetCachedInfoAsync());
var prefs = PreferencesService.Shared;
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
// FIX (What's New every launch): read + persist LastSeenVersion BEFORE
Expand All @@ -49,7 +55,6 @@ public override void OnFrameworkInitializationCompleted()
// raced with the MainViewModel instance, which then wrote its stale
// in-memory LastSeenVersion back to disk, re-triggering the dialog
// on every single launch.
var prefs = PreferencesService.Shared;
var currentVersion = GetAppVersion();
bool showWhatsNew = prefs.Current.LastSeenVersion != currentVersion;

Expand Down
83 changes: 83 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,89 @@
All notable changes to NullWave will be documented in this file.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## [0.6.5-beta.2] - 07-Nov-2026

### Added
- **Plugin Contracts SDK**: Extracted `NullWave.Plugins.Contracts` as a standalone NuGet-packageable project containing `IPlugin`, `IPluginHost`, `PluginManifest`, `ProviderKind`, and `PluginState` — the public API surface for third-party plugin authors.
- **Plugin Discovery Engine**: `PluginDiscovery` scans the `plugins/` directory at startup, loads assemblies via isolated `AssemblyLoadContext`, validates manifests, and initializes plugins with per-plugin `PluginHost` instances.
- **OpenWeather Reference Plugin**: First external plugin (`NullWave.Plugins.OpenWeather`) demonstrating the full plugin lifecycle — manifest declaration, `AssemblyLoadContext` isolation, host API usage, and graceful state transitions.
- **Built-in Plugin Manifest Factory**: `BuiltInPluginManifest.FromBuiltIn()` generates manifests from existing `IProvider` implementations, bridging the legacy provider system with the new plugin architecture.
- **Plugin Host Implementation**: `PluginHost` provides plugins with scoped access to API keys (permission-checked), logging (prefixed), toast notifications, and future library/process APIs.
- **Provider Interface Contracts**: `IDownloadProvider`, `IMetadataProvider`, `IAIProvider`, `IWeatherProvider` now extend `IProvider : IPlugin` from the Contracts project, unifying the provider and plugin type hierarchies.
- **Unified FileNamer**: one naming rule for download completion, Sync, import and export;
255-byte UTF-8 limit, FAT-safe characters, surrogate-safe truncation, keeps the [nw-id] suffix.
- **TagWriteMode**: Never / Downloads only (default) / Always, through one central rule.
- **Import modes**: Keep in place (default) or Copy into the managed library, with a duplicate
check before any copy; operations recorded in file-ops.json (audit only; undo not implemented).
- **Recycle Bin removal**: removal can send the file to the OS Recycle Bin / Trash; unmanaged
files are never recycled or deleted.
- **Safe export**: copies are renamed and retagged (ID3v2.3) with a relative M3U8; originals
are never modified.
- **Install consent**: package-manager installs show the exact command and need your confirmation;
declining runs nothing; the choice can be remembered.
- **Keystore honesty**: a notice appears when the weaker fallback key storage is in use; deleting
keys on Linux also clears the Secret Service entry.
- **CI matrix**: build and full test suite on Windows and Ubuntu with banned-API enforcement.

### Changed
- **Plugin Lifecycle Signature**: `IPlugin.InitializeAsync` now accepts `IPluginHost` as the first parameter, giving plugins access to host services without tight coupling to the main app.
- **EffectsTierResolver Singleton**: Converted to a process-wide singleton (`EffectsTierResolver.Shared`) to ensure consistent tier resolution across the main app, Settings window, and wallpaper service.
- **Plugin State Preservation**: `PluginManager` no longer overwrites `Unavailable` or `Disabled` states with `Error` when initialization returns false — plugins that intentionally decline startup (missing API keys, disabled by preference) retain their honest state.
- **Type Migration**: `IPlugin`, `PluginState`, `ProviderKind`, and `PluginManifest` moved from `NullWave.Services.Plugins` to `NullWave.Plugins.Contracts` namespace, establishing a clean dependency boundary between host and plugins.
- **Settings ViewModel**: Added `EffectsTierLabel`, `CanForceStandardTier`, and `ForceStandardEffectsTierCommand` for the new Effects Tier visibility card.
- **Settings reorganization**: Plugins and Sources merged into Integrations; new Downloads tab;
Advanced dissolved into Maintenance and Help.
- **Honest updates**: exact package-manager exit codes, parallel dependency checks, pre-release
detection. The yt-dlp updater no longer falls back silently to winget; update it manually.
- **Localization**: Downloads, Integrations, Maintenance, General and new dialogs fully translated
EN/RU; the experimental banner describes only available features.

### Fixed
- **Dependency Isolation**: Plugins load in separate `AssemblyLoadContext` instances, preventing dependency version conflicts between plugins and the host app.
- **Permission-Gated API Access**: `PluginHost.GetApiKey()` checks the plugin's declared `Permissions.AccessApiKeys` before returning credentials, with all access logged for audit.
- **Manifest-Driven Discovery**: Plugins declare their identity, capabilities, and permissions in `plugin.json`, enabling future store integration and automated security scanning.
- **Orphan sweep safety**: previews and confirms before moving orphaned files to `_orphans`; files
are never deleted.
- **Keystore (Linux)**: data-loss path closed; locked or missing secret store falls back to the
legacy protector; unknown or newer formats open read-only and are never quarantined; temp files
are created 0600.
- **Consent ghost rows**: consent is verified before the library row is inserted.
- **Playback stalls**: native LibVLC Stop runs off the UI thread under a generation lock.

## [0.6.5-beta.1] - 31-Oct-2026

### Added
- **Keystore v2**: Migrated from machine-ID-derived keys to OS-native secret storage. Windows uses DPAPI, Linux uses Secret Service when available, with safe legacy fallback. Existing keys auto-migrate on first launch.
- **Download Consent Gate**: Downloads now require explicit first-time consent. A dialog explains that NullWave uses yt-dlp and that you're responsible for respecting copyright.
- **.NET 10 LTS**: Retargeted from .NET 8 to .NET 10 LTS (supported until Nov 2028).
- **Smart Playlists**: Three virtual, auto-updating playlists in the "AI Playlists" folder (Recently Added, Most Played, Forgotten Favorites).
- **M3U Playlist Support**: Full import/export with preview dialog, relative/absolute paths, and stream URL preservation.
- **Provider Registry Foundation**: Architecture for managing content providers (Download, Metadata, Mood, Weather) with active-selection semantics (UI preview in this release, routing in v0.6.6).
- **New Audio Sources**: Added native yt-dlp support for Bandcamp, Audiomack, Archive.org, and Mixcloud.

### Changed
- **Radio Detection**: Early-return for known music sources prevents false positives (e.g., `radiohead.bandcamp.com` or `/track/streams-of-light`).
- **yt-dlp Runtime**: Bun removed (deprecated). Detection order: Deno (≥2.3.0) → Node (≥22.0.0).
- **429 Detection**: Tightened to match `HTTP Error 429` only, preventing false backoff.
- **Privacy Documentation**: `PRIVACY.md` now lists all network hosts contacted by the app.
- **Test Suite**: Expanded to 533 passing tests.

### Fixed
- **List Parameter Stripping**: YouTube URLs with `list=` parameter are now safely stripped using a proper URI parser, allowing single-track downloads from playlist pages without breaking the URL.
- **Semaphore Shrink-Debt**: Fixed an edge case where changing the concurrent download limit mid-download would permanently stall the queue.
- **Keystore Data Safety**: "Delete All Keys" now securely removes backup and quarantined files. First-run directory creation is guaranteed.
- **Provider Descriptors**: All 4 providers now expose `Descriptor` properties for registry integration.
- **CA2024 Warning**: Fixed `reader.EndOfStream` usage in async methods.
- **Culture-Invariant Parsing**: Fixed `FormatException` crashes on non-US systems (e.g., Russian/German) by enforcing `CultureInfo.InvariantCulture` across hardware detection, Spotify parsing, M3U import, and sleep timers.
- **Non-English Search**: Added accent-insensitive search (`é`→`e`, `ё`→`е`) and FTS5 normalization. Searching "cafe" now finds "café".
- **Font Fallbacks**: Added global font fallback chain (Segoe UI, Noto Sans, Microsoft YaHei) to properly render Cyrillic, CJK, and emoji characters in the UI.
- **Download File Recovery**: Injected a unique `[nw-{hash}]` tag into yt-dlp output templates, guaranteeing file recovery even if stdout path printing fails on Bandcamp/Mixcloud.
- **OGG Format Fix**: Mapped "ogg" to "vorbis" for yt-dlp, preventing `invalid audio format` errors.
- **Metadata Race Conditions**: Fixed edge cases where fast consecutive URL pastes could overwrite the wrong track's metadata or input fields.
- **Live Toast Activities**: Download and playlist progress toasts no longer auto-dismiss while the operation is still running.
- **YouTube Fallback**: Added automatic yt-dlp fallback when the YouTube Data API returns `BadRequest` or empty metadata.

---

## [0.6.4] - 25-Oct-2026 🏗️ "Foundations"

Expand Down
16 changes: 14 additions & 2 deletions DISCLAIMER.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,18 @@ OF THIS SOFTWARE.

## Third-Party Services

NullWave interacts with third-party APIs (YouTube Data API, Spotify Web API, SoundCloud
API). Use of these services is governed by their respective terms of service. The
NullWave interacts with third-party APIs (YouTube Data API) and public web pages (Spotify, SoundCloud).
Use of these services is governed by their respective terms of service. The
author(s) of NullWave have no affiliation with these services.

## Third-Party Plugins

NullWave supports third-party plugins that extend its functionality. By installing plugins, you acknowledge that:

1. **Plugins are not developed or maintained by ZenQuant** unless explicitly marked as "Official" or "Verified"
2. **You install plugins at your own risk** - ZenQuant is not liable for damages, data loss, or security breaches caused by third-party plugins
3. **Plugins may request access to sensitive data** including API keys, file system access, and network requests. Review permissions carefully before installation
4. **You are responsible for ensuring plugin licenses are compatible** with your intended use (commercial, educational, personal)
5. **Malicious plugins will be removed** from the store when reported. Use the "Report Plugin" feature to flag suspicious behavior

ZenQuant reserves the right to disable or remove any plugin that violates our terms of service, contains malware, or infringes on intellectual property rights.
28 changes: 15 additions & 13 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,19 @@
</PropertyGroup>
<ItemGroup>
<!-- Main App Packages -->
<PackageVersion Include="Avalonia" Version="12.1.3" />
<PackageVersion Include="Avalonia.Desktop" Version="12.1.3" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.1.3" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.1.3" />
<PackageVersion Include="Avalonia" Version="12.1.4" />
<PackageVersion Include="Avalonia.Desktop" Version="12.1.4" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.1.4" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.1.4" />
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageVersion Include="LibVLCSharp" Version="3.10.1" />
<PackageVersion Include="LibVLCSharp" Version="3.10.2" />
<PackageVersion Include="System.Security.Cryptography.ProtectedData" Version="10.0.14" />
<PackageVersion Include="System.Text.Json" Version="10.0.14" />
<PackageVersion Include="VideoLAN.LibVLC.Windows" Version="3.0.24" />
<PackageVersion Include="Material.Icons.Avalonia" Version="3.0.2" />
<PackageVersion Include="QRCoder" Version="1.8.0" />
<PackageVersion Include="System.Drawing.Common" Version="8.0.0" />
<PackageVersion Include="QRCoder" Version="1.8.1" />
<PackageVersion Include="System.Drawing.Common" Version="10.0.14" />
<PackageVersion Include="Serilog" Version="4.4.0" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
Expand All @@ -28,11 +30,11 @@
<PackageVersion Include="Velopack" Version="1.2.161" />
<PackageVersion Include="System.Text.RegularExpressions" Version="4.3.1" />
<!-- Test Packages -->
<PackageVersion Include="coverlet.collector" Version="6.0.0" />
<PackageVersion Include="Microsoft.Extensions.TimeProvider.Testing" Version="8.0.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageVersion Include="xunit" Version="2.5.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="2.5.3" />
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4" />
<PackageVersion Include="coverlet.collector" Version="10.1.0" />
<PackageVersion Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.11.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="4.0.1" />
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="5.6.0" />
</ItemGroup>
</Project>
69 changes: 69 additions & 0 deletions Helpers/Attached/ComboBoxScrollGuard.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.VisualTree;

namespace NullWave.Helpers.Attached;

/// <summary>
/// Closes open ComboBox dropdowns when the user scrolls outside them.
/// Avalonia's Popup doesn't auto-close on wheel events, causing orphaned dropdowns
/// that drift away from their owner controls when the page scrolls.
/// </summary>
public static class ComboBoxScrollGuard
{
private static readonly HashSet<ComboBox> Open = new();

// Custom IObserver<T> because Avalonia 11+ exposes ComboBox.IsDropDownOpenProperty.Changed
// as a plain IObservable<AvaloniaPropertyChangedEventArgs<bool>>, which has no
// AddClassHandler extension and no Subscribe(Action<T>) overload without System.Reactive.
private sealed class DropDownObserver : IObserver<AvaloniaPropertyChangedEventArgs<bool>>
{
public void OnCompleted() { }
public void OnError(Exception error) { }

public void OnNext(AvaloniaPropertyChangedEventArgs<bool> e)
{
if (e.Sender is ComboBox cb)
{
if (e.NewValue.GetValueOrDefault()) Open.Add(cb);
else Open.Remove(cb);
}
}
}

public static void Register()
{
ComboBox.IsDropDownOpenProperty.Changed.Subscribe(new DropDownObserver());

InputElement.PointerWheelChangedEvent.AddClassHandler<TopLevel>((_, e) =>
{
if (Open.Count == 0) return;

if (e.Source is Visual v &&
v.GetSelfAndVisualAncestors().OfType<Border>().Any(b => b.Name == "PopupBorder"))
return;

foreach (var cb in Open.ToArray())
cb.IsDropDownOpen = false;
}, RoutingStrategies.Tunnel);

ScrollViewer.ScrollChangedEvent.AddClassHandler<TopLevel>((_, e) =>
{
if (Open.Count == 0) return;
if (e.OffsetDelta != default)
{
if (e.Source is Visual v &&
v.GetSelfAndVisualAncestors().OfType<Border>().Any(b => b.Name == "PopupBorder"))
return;

foreach (var cb in Open.ToArray())
cb.IsDropDownOpen = false;
}
});
}
}
Loading
Loading