From d9d63e769ba3d42ca470d4dd13d52a3d12f5c60c Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:10:04 +0200 Subject: [PATCH 001/460] Scaffold workspace and checks --- .gitattributes | 1 + .github/dependabot.yml | 17 +++ .github/workflows/ci.yml | 41 ++++++ .gitignore | 1 + CHANGELOG.md | 1 + Cargo.lock | 11 ++ Cargo.toml | 20 +++ LICENSE | 21 +++ README.md | 5 + about.hbs | 282 +++++++++++++++++++++++++++++++++++++++ about.toml | 21 +++ check.ps1 | 106 +++++++++++++++ crates/app/Cargo.toml | 11 ++ crates/app/src/main.rs | 1 + crates/core/Cargo.toml | 10 ++ crates/core/src/lib.rs | 1 + 16 files changed, 550 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 about.hbs create mode 100644 about.toml create mode 100644 check.ps1 create mode 100644 crates/app/Cargo.toml create mode 100644 crates/app/src/main.rs create mode 100644 crates/core/Cargo.toml create mode 100644 crates/core/src/lib.rs diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..62d6f23 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +# Monthly, grouped: one PR per ecosystem. CI on pull_request tests each bump. +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + groups: + actions: + patterns: ["*"] + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "monthly" + groups: + cargo: + patterns: ["*"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..513f768 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +# One pipeline: CI runs the same check.ps1 as the pre-push hook. +# `branches` and `branches-ignore` must never both appear on one event. +name: ci + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + check: + runs-on: windows-latest + # A cold cache once hit 45 minutes. + timeout-minutes: 60 + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + with: + workspaces: . + # Prebuilt and checksum-verified: building cargo-audit from source took + # about 11 minutes per run and pushed cold-cache runs past the timeout. + # Only cargo-audit is unpinned: Dependabot does not update tool + # versions here, and an outdated cargo-audit can fail on a newer + # advisory database. + - uses: taiki-e/install-action@v2 + with: + tool: cargo-about@0.9,cargo-audit + - run: ./check.ps1 + shell: pwsh + - run: cargo build --release --locked -p neumann-control-app + - uses: actions/upload-artifact@v7 + with: + name: neumann-control-exe + path: target/release/neumann-control.exe diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..701cd4c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1 @@ +## [Unreleased] diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..32dd314 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,11 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "neumann-control-app" +version = "0.1.0" + +[[package]] +name = "neumann-control-core" +version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..39ce58b --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,20 @@ +[workspace] +members = ["crates/core", "crates/app"] +resolver = "2" + +[workspace.package] +version = "0.1.0" +edition = "2021" +authors = ["Varitras"] +license = "MIT" +rust-version = "1.95" + +# Dependencies carry no debug info in dev and test builds: their PDBs made up +# most of a target directory that grew past 40 GB; our own crate keeps its own. +[profile.dev.package."*"] +debug = false + +[profile.release] +lto = true +codegen-units = 1 +strip = true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f8fdba0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Varitras + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ffec228 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# neumann-control + +A portable Windows app to find, read and control Neumann KH DSP speakers over Sennheiser SSC. + +Status: in development. diff --git a/about.hbs b/about.hbs new file mode 100644 index 0000000..1b9fbf0 --- /dev/null +++ b/about.hbs @@ -0,0 +1,282 @@ + + + + + + Third-party licenses + + + + +

Third-party licenses

+

Rust crates and fonts bundled into this release.

+ +

Overview

+ + +

Rust crates

+ {{#each licenses}} +
+ {{#if first_of_kind}} +

{{name}}

+ {{/if}} +

Used by:

+ +
{{text}}
+
+ {{/each}} + +

Fonts

+ +
+

Inter 4.1 (SIL Open Font License 1.1)

+
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION AND CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
+
+
+
+

JetBrains Mono NL 2.304 (SIL Open Font License 1.1)

+
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded, 
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
+
+
+ + + diff --git a/about.toml b/about.toml new file mode 100644 index 0000000..a989b18 --- /dev/null +++ b/about.toml @@ -0,0 +1,21 @@ +targets = ["x86_64-pc-windows-msvc"] +ignore-dev-dependencies = true +ignore-build-dependencies = false + +# The set actually present in the release dependency graph of the workspace, +# found by running `cargo about generate` once with a permissive list and +# tightening. OFL-1.1 and Ubuntu-font-1.0 come with egui's default fonts +# (epaint_default_fonts), which stay as the emoji and symbol fallback. +# GPL/LGPL/AGPL are deliberately not accepted; if one appears, the gate fails. +accepted = [ + "MIT", + "Apache-2.0", + "BSD-3-Clause", + "ISC", + "Zlib", + "Unicode-3.0", + "MPL-2.0", + "BSL-1.0", + "OFL-1.1", + "Ubuntu-font-1.0", +] diff --git a/check.ps1 b/check.ps1 new file mode 100644 index 0000000..46d10b0 --- /dev/null +++ b/check.ps1 @@ -0,0 +1,106 @@ +<# +.SYNOPSIS + Definition of done, as one command. CI runs this same script. +#> +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +Set-Location $PSScriptRoot + +function Invoke-Gate { + param([string] $Name, [scriptblock] $Command) + Write-Host "" + Write-Host "=== $Name" -ForegroundColor Cyan + # A block without a native command would leave the exit code of an earlier step in place. + $global:LASTEXITCODE = 0 + & $Command + if ($LASTEXITCODE -ne 0) { + Write-Host "" + Write-Host "FAILED: $Name" -ForegroundColor Red + exit 1 + } +} + +# Fails when the search prints anything; git grep exits 1 for "no match", which is the pass. +function Invoke-Forbid { + param([string] $Name, [scriptblock] $Search) + Write-Host "" + Write-Host "=== $Name" -ForegroundColor Cyan + $global:LASTEXITCODE = 0 + $found = & $Search + if ($LASTEXITCODE -gt 1) { + Write-Host "FAILED: $Name could not search" -ForegroundColor Red + exit 1 + } + if ($found) { + $found | Out-Host + Write-Host "" + Write-Host "FAILED: $Name" -ForegroundColor Red + exit 1 + } +} + +function Assert-Tool { + param([string] $Name, [string] $InstallHint) + if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { + Write-Host "FAILED: $Name is not installed. $InstallHint" -ForegroundColor Red + exit 1 + } +} + +# Splits Rust source into comments, raw strings, character literals and normal string literals, +# leftmost first, so a quote or brace inside one never starts another. Only normal string literals +# are format strings, so the width guard looks at those alone. +$rustToken = [regex]('(?//[^\n]*|/\*[\s\S]*?\*/)' + + '|(?b?r(?#*)"[\s\S]*?"\k)' + + '|(?''(?:\\u\{[0-9a-fA-F]+\}|\\.|[^\\\n''])'')' + + '|(?"(?:\\[\s\S]|[^"\\])*")') + +# Each normal string literal in tracked and new Rust files that matches $Pattern, with its line. +function Find-InRustStrings { + param([regex] $Pattern) + foreach ($path in git ls-files --cached --others --exclude-standard -- '*.rs') { + $content = Get-Content -Raw -LiteralPath $path -Encoding utf8 + if (-not $content) { continue } + foreach ($token in $rustToken.Matches($content)) { + $literal = $token.Groups['string'] + if (-not $literal.Success -or -not $Pattern.IsMatch($literal.Value)) { continue } + $line = ($content.Substring(0, $literal.Index) -split "`n").Count + "${path}:${line}: $($literal.Value)" + } + } +} + +Assert-Tool 'cargo-audit' 'Install with: cargo install cargo-audit --locked' +Assert-Tool 'cargo-about' 'Install with: cargo install cargo-about --locked' + +# A width or precision taken from a variable panics above u16::MAX (Rust 1.87+); pad by building. +# The argument name before `:` excludes escaped JSON such as "{\"a\":\"b$\"}". +Invoke-Forbid 'runtime format widths' { + Find-InRustStrings ('\{[A-Za-z0-9_]*:[^{}"\\]*?(?:[a-z_][a-z0-9_]*|[0-9]+)\$[^{}]*\}' + + '|\{[A-Za-z0-9_]*:[^{}"\\]*\.\*[^{}]*\}') +} +# The index is what gets committed; -text paths (fixtures) skip LF normalisation, so a CR there +# would reach a blob unchanged. +Invoke-Forbid 'carriage returns in staged text files' { + git grep --cached -nIP '\r' +} +# Catches a stray CR in new and unstaged files before they are added. A CR at a line end is a +# CRLF ending, which git normalises to LF on add outside the -text paths. +Invoke-Forbid 'carriage returns inside lines of working files' { + git grep -nIP --untracked '\r(?!$)' +} + +Invoke-Gate 'cargo fmt (check)' { cargo fmt --all -- --check } +# A dependency with an unaccepted licence (e.g. a GPL crate) fails generation, so this doubles +# as the licence gate; build-release.ps1 generates the shipped file from the same config. +Invoke-Gate 'licence gate (cargo about)' { + cargo about generate --workspace --locked --fail -c about.toml about.hbs -o "$env:TEMP\neumann-control-about-check.html" +} +Invoke-Gate 'cargo audit' { cargo audit } +Invoke-Gate 'cargo clippy' { cargo clippy --workspace --all-targets --locked -- -D warnings } +Invoke-Gate 'cargo test' { cargo test --workspace --locked } + +Write-Host "" +Write-Host "All gates green." -ForegroundColor Green diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml new file mode 100644 index 0000000..5043123 --- /dev/null +++ b/crates/app/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "neumann-control-app" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +rust-version.workspace = true + +[[bin]] +name = "neumann-control" +path = "src/main.rs" diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs new file mode 100644 index 0000000..f328e4d --- /dev/null +++ b/crates/app/src/main.rs @@ -0,0 +1 @@ +fn main() {} diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml new file mode 100644 index 0000000..2853f14 --- /dev/null +++ b/crates/core/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "neumann-control-core" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +rust-version.workspace = true + +[features] +test-support = [] diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/crates/core/src/lib.rs @@ -0,0 +1 @@ + From 76cb4749212b819abbb982b80cdf288d544e69ca Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:13:50 +0200 Subject: [PATCH 002/460] Parse SSC replies with nested errors and xid --- Cargo.lock | 100 +++++++ crates/core/Cargo.toml | 4 + crates/core/src/lib.rs | 2 +- crates/core/src/ssc/mod.rs | 1 + crates/core/src/ssc/wire.rs | 226 ++++++++++++++++ .../core/tests/fixtures/recorded_replies.json | 253 ++++++++++++++++++ crates/core/tests/wire.rs | 166 ++++++++++++ 7 files changed, 751 insertions(+), 1 deletion(-) create mode 100644 crates/core/src/ssc/mod.rs create mode 100644 crates/core/src/ssc/wire.rs create mode 100644 crates/core/tests/fixtures/recorded_replies.json create mode 100644 crates/core/tests/wire.rs diff --git a/Cargo.lock b/Cargo.lock index 32dd314..fe6028a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,18 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + [[package]] name = "neumann-control-app" version = "0.1.0" @@ -9,3 +21,91 @@ version = "0.1.0" [[package]] name = "neumann-control-core" version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 2853f14..20f95b4 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -8,3 +8,7 @@ rust-version.workspace = true [features] test-support = [] + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 8b13789..9dfefb4 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1 +1 @@ - +pub mod ssc; diff --git a/crates/core/src/ssc/mod.rs b/crates/core/src/ssc/mod.rs new file mode 100644 index 0000000..349da7c --- /dev/null +++ b/crates/core/src/ssc/mod.rs @@ -0,0 +1 @@ +pub mod wire; diff --git a/crates/core/src/ssc/wire.rs b/crates/core/src/ssc/wire.rs new file mode 100644 index 0000000..d73367d --- /dev/null +++ b/crates/core/src/ssc/wire.rs @@ -0,0 +1,226 @@ +//! SSC v1 wire format: paths, request lines and reply parsing. + +use std::fmt; + +use serde::de::{self, MapAccess, SeqAccess, Visitor}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::{Map, Number, Value}; + +const PATH_SEPARATOR: char = '/'; +const OSC_KEY: &str = "osc"; +const XID_KEY: &str = "xid"; +const ERROR_KEY: &str = "error"; +const DESCRIPTION_KEY: &str = "desc"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum WireError { + InvalidPath, + NonFinite, + Malformed, + NotAnObject, +} + +impl fmt::Display for WireError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let text = match self { + WireError::InvalidPath => "invalid SSC path", + WireError::NonFinite => "number is not finite", + WireError::Malformed => "malformed JSON line", + WireError::NotAnObject => "line is not a JSON object", + }; + formatter.write_str(text) + } +} + +impl std::error::Error for WireError {} + +/// An address such as `audio/out/level`; never empty, no empty segments. +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct SscPath(Vec); + +impl SscPath { + pub fn parse(text: &str) -> Result { + let segments: Vec = text.split(PATH_SEPARATOR).map(str::to_owned).collect(); + if segments.iter().any(String::is_empty) { + return Err(WireError::InvalidPath); + } + Ok(SscPath(segments)) + } + + pub fn segments(&self) -> &[String] { + &self.0 + } +} + +impl fmt::Display for SscPath { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0.join(&PATH_SEPARATOR.to_string())) + } +} + +impl Serialize for SscPath { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_str(self) + } +} + +impl<'de> Deserialize<'de> for SscPath { + fn deserialize>(deserializer: D) -> Result { + let text = String::deserialize(deserializer)?; + SscPath::parse(&text).map_err(de::Error::custom) + } +} + +/// A device error: the `[code, {"desc": text}]` pair of an `osc/error` reply. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DeviceError { + pub code: i64, + pub description: String, +} + +/// Merges `incoming` into `target`: objects recursively, anything else is replaced. +fn merge(target: &mut Value, incoming: Value) { + match (target, incoming) { + (Value::Object(existing), Value::Object(additions)) => { + for (key, value) in additions { + match existing.get_mut(&key) { + Some(slot) => merge(slot, value), + None => { + existing.insert(key, value); + } + } + } + } + (slot, value) => *slot = value, + } +} + +/// A JSON value whose repeated object keys are merged instead of dropped: a device can +/// send two top-level `osc` members in one line, and a standard parser keeps only the last. +struct MergedValue(Value); + +impl<'de> Deserialize<'de> for MergedValue { + fn deserialize>(deserializer: D) -> Result { + deserializer.deserialize_any(MergedVisitor) + } +} + +struct MergedVisitor; + +impl<'de> Visitor<'de> for MergedVisitor { + type Value = MergedValue; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("any JSON value") + } + + fn visit_bool(self, value: bool) -> Result { + Ok(MergedValue(Value::Bool(value))) + } + + fn visit_i64(self, value: i64) -> Result { + Ok(MergedValue(Value::Number(value.into()))) + } + + fn visit_u64(self, value: u64) -> Result { + Ok(MergedValue(Value::Number(value.into()))) + } + + fn visit_f64(self, value: f64) -> Result { + let number = Number::from_f64(value).ok_or_else(|| E::custom("non-finite number"))?; + Ok(MergedValue(Value::Number(number))) + } + + fn visit_str(self, value: &str) -> Result { + Ok(MergedValue(Value::String(value.to_owned()))) + } + + fn visit_string(self, value: String) -> Result { + Ok(MergedValue(Value::String(value))) + } + + fn visit_unit(self) -> Result { + Ok(MergedValue(Value::Null)) + } + + fn visit_seq>(self, mut sequence: A) -> Result { + let mut items = Vec::new(); + while let Some(item) = sequence.next_element::()? { + items.push(item.0); + } + Ok(MergedValue(Value::Array(items))) + } + + fn visit_map>(self, mut entries: A) -> Result { + let mut object = Value::Object(Map::new()); + while let Some((key, value)) = entries.next_entry::()? { + merge(&mut object, Value::Object(Map::from_iter([(key, value.0)]))); + } + Ok(MergedValue(object)) + } +} + +/// Parses one reply line into a JSON object, merging duplicate keys deeply. +pub fn parse_line(line: &[u8]) -> Result { + let text = std::str::from_utf8(line).map_err(|_| WireError::Malformed)?; + let merged: MergedValue = serde_json::from_str(text).map_err(|_| WireError::Malformed)?; + match merged.0 { + Value::Object(_) => Ok(merged.0), + _ => Err(WireError::NotAnObject), + } +} + +/// Builds a request line without terminator. The transaction id lives under `osc`; a path +/// that itself starts with `osc` shares that object. +pub fn request_line(path: &SscPath, value: Value, xid: u64) -> String { + let nested = path.segments().iter().rev().fold(value, |inner, segment| { + Value::Object(Map::from_iter([(segment.clone(), inner)])) + }); + let mut request = serde_json::json!({ OSC_KEY: { XID_KEY: xid } }); + merge(&mut request, nested); + request.to_string() +} + +/// A JSON number for a set request; NaN and infinities have no JSON form. +pub fn number(value: f64) -> Result { + Number::from_f64(value) + .map(Value::Number) + .ok_or(WireError::NonFinite) +} + +/// The value at `path` if present and not `null`. +pub fn extract<'a>(tree: &'a Value, path: &SscPath) -> Option<&'a Value> { + let found = path + .segments() + .iter() + .try_fold(tree, |node, segment| node.get(segment))?; + (!found.is_null()).then_some(found) +} + +/// The transaction id echoed in `osc/xid`, if it is a non-negative integer. +pub fn reply_xid(tree: &Value) -> Option { + tree.get(OSC_KEY)?.get(XID_KEY)?.as_u64() +} + +/// The first `[code, {"desc": text}]` pair under `osc/error`, depth-first. +pub fn device_error(tree: &Value) -> Option { + find_pair(tree.get(OSC_KEY)?.get(ERROR_KEY)?) +} + +fn find_pair(node: &Value) -> Option { + match node { + Value::Array(items) => as_pair(items).or_else(|| items.iter().find_map(find_pair)), + Value::Object(members) => members.values().find_map(find_pair), + _ => None, + } +} + +fn as_pair(items: &[Value]) -> Option { + let [code, detail] = items else { + return None; + }; + Some(DeviceError { + code: code.as_i64()?, + description: detail.get(DESCRIPTION_KEY)?.as_str()?.to_owned(), + }) +} diff --git a/crates/core/tests/fixtures/recorded_replies.json b/crates/core/tests/fixtures/recorded_replies.json new file mode 100644 index 0000000..d341f74 --- /dev/null +++ b/crates/core/tests/fixtures/recorded_replies.json @@ -0,0 +1,253 @@ +{ + "not_found": [ + { + "request": { + "audio": { + "out": { + "dimm": null + } + } + }, + "reply": "{\"osc\":{\"error\":[{\"audio\":{\"out\":{\"dimm\":[404,{\"desc\":\"address not found\"}]}}}]}}" + }, + { + "request": { + "audio": { + "out5": { + "level": null + } + } + }, + "reply": "{\"osc\":{\"error\":[{\"audio\":{\"out5\":[404,{\"desc\":\"address not found\"}]}}]}}" + }, + { + "request": { + "device": { + "save_settings": null + } + }, + "reply": "{\"osc\":{\"error\":[{\"device\":{\"save_settings\":[404,{\"desc\":\"address not found\"}]}}]}}" + } + ], + "kh750_not_found": [ + { + "request": { + "audio": { + "out": { + "phaseinversion": null + } + } + }, + "reply": "{\"osc\":{\"error\":[{\"audio\":{\"out\":{\"phaseinversion\":[404,{\"desc\":\"address not found\"}]}}}]}}" + } + ], + "container": [ + { + "request": { + "device": null + }, + "reply": "{\"osc\":{\"error\":[{\"device\":[404,{\"desc\":\"address not found\"}]}]}}" + } + ], + "bundle": [ + { + "request": { + "audio": { + "out": { + "level": null, + "dimm": null + } + } + }, + "reply": "{\"audio\":{\"out\":{\"level\":100.0}},\"osc\":{\"error\":[{\"audio\":{\"out\":[404,{\"desc\":\"address not found\"}]}}]}}" + } + ], + "set": [ + { + "request": { + "audio": { + "out": { + "mute": false + } + } + }, + "reply": "{\"audio\":{\"out\":{\"mute\":false}}}" + }, + { + "request": { + "audio": { + "out": { + "dimm": 0 + } + } + }, + "reply": "{\"osc\":{\"error\":[{\"audio\":{\"out\":{\"dimm\":[404,{\"desc\":\"address not found\"}]}}}]}}" + }, + { + "request": { + "audio": { + "out": { + "mute": "x" + } + } + }, + "reply": "{\"osc\":{\"error\":[{\"audio\":{\"out\":{\"mute\":[406,{\"desc\":\"not acceptable\"}]}}}]}}" + }, + { + "request": { + "audio": { + "out": { + "mute": false, + "delay": 0 + } + } + }, + "reply": "{\"audio\":{\"out\":{\"mute\":false,\"delay\":0}}}" + }, + { + "request": { + "audio": { + "out": { + "mute": false, + "dimm": 0 + } + } + }, + "reply": "{\"audio\":{\"out\":{\"mute\":false}},\"osc\":{\"error\":[{\"audio\":{\"out\":[404,{\"desc\":\"address not found\"}]}}]}}" + }, + { + "request": { + "audio": { + "out": { + "delay": null, + "mute": false + } + } + }, + "reply": "{\"audio\":{\"out\":{\"mute\":false,\"delay\":0}}}" + }, + { + "request": { + "ui": { + "input_gain": 0.0 + } + }, + "reply": "{\"osc\":{\"error\":[{\"ui\":{\"input_gain\":[405,{\"desc\":\"method not allowed\"}]}}]}}" + }, + { + "request": { + "audio": { + "out": { + "mute": false + } + }, + "ui": { + "input_gain": 0.0 + } + }, + "reply": "{\"audio\":{\"out\":{\"mute\":false}},\"osc\":{\"error\":[{\"ui\":{\"input_gain\":[405,{\"desc\":\"method not allowed\"}]}}]}}" + } + ], + "schema": [ + { + "request": { + "osc": { + "schema": null + } + }, + "reply": "{\"osc\":{\"schema\":[{\"device\":{},\"osc\":{},\"audio\":{},\"m\":{},\"ui\":{},\"warnings\":null}]}}" + }, + { + "request": { + "osc": { + "schema": [ + { + "audio": null + } + ] + } + }, + "reply": "{\"osc\":{\"schema\":[{\"audio\":{\"in\":{},\"out\":{}}}]}}", + "model": "KH 120 II" + }, + { + "request": { + "osc": { + "schema": [ + { + "audio": null + } + ] + } + }, + "reply": "{\"osc\":{\"schema\":[{\"audio\":{\"in\":{},\"out\":{},\"out1\":{},\"out2\":{},\"digital_bypass\":null}}]}}", + "model": "KH 750" + } + ], + "introspection_errors": [ + { + "request": { + "osc": { + "schema": [ + null + ] + } + }, + "reply": "{\"osc\":{\"error\":[{\"osc\":{\"schema\":[454,{\"desc\":\"parameter address not found\"}]}}]}}" + }, + { + "request": { + "osc": { + "schema": [ + { + "audio": {} + } + ] + } + }, + "reply": "{\"osc\":{\"error\":[{\"osc\":{\"schema\":[406,{\"desc\":\"not acceptable\"}]}}]}}" + }, + { + "request": { + "osc": { + "limits": { + "audio": { + "out": { + "level": null + } + } + } + } + }, + "reply": "{\"osc\":{\"error\":[{\"osc\":{\"limits\":[454,{\"desc\":\"parameter address not found\"}]}}]}}" + } + ], + "xid": [ + { + "request": { + "osc": { + "xid": 4711 + }, + "audio": { + "out": { + "level": null + } + } + }, + "reply": "{\"audio\":{\"out\":{\"level\":100.0}},\"osc\":{\"xid\":4711}}" + }, + { + "request": { + "osc": { + "xid": "abc" + }, + "audio": { + "out": { + "mute": null + } + } + }, + "reply": "{\"audio\":{\"out\":{\"mute\":false}},\"osc\":{\"xid\":\"abc\"}}" + } + ] +} diff --git a/crates/core/tests/wire.rs b/crates/core/tests/wire.rs new file mode 100644 index 0000000..09bbcce --- /dev/null +++ b/crates/core/tests/wire.rs @@ -0,0 +1,166 @@ +use neumann_control_core::ssc::wire::{ + device_error, extract, number, parse_line, reply_xid, request_line, SscPath, WireError, +}; +use serde_json::{json, Value}; + +const FIXTURES: &str = include_str!("fixtures/recorded_replies.json"); + +fn group(name: &str) -> Vec<(Value, String)> { + let fixtures: Value = serde_json::from_str(FIXTURES).expect("fixture file is valid JSON"); + fixtures[name] + .as_array() + .unwrap_or_else(|| panic!("fixture group {name} is missing")) + .iter() + .map(|entry| { + ( + entry["request"].clone(), + entry["reply"] + .as_str() + .expect("reply is a string") + .to_owned(), + ) + }) + .collect() +} + +fn path(text: &str) -> SscPath { + SscPath::parse(text).expect("valid path") +} + +#[test] +fn nested_404_is_found_at_any_depth() { + let replies: Vec<_> = group("not_found") + .into_iter() + .chain(group("kh750_not_found")) + .chain(group("container")) + .collect(); + assert!(replies.len() >= 5); + for (_, reply) in replies { + let tree = parse_line(reply.as_bytes()).unwrap(); + let error = device_error(&tree).unwrap_or_else(|| panic!("no error in {reply}")); + assert_eq!(error.code, 404, "{reply}"); + assert_eq!(error.description, "address not found", "{reply}"); + } +} + +#[test] +fn bundle_reply_keeps_value_and_error() { + let (_, reply) = group("bundle").remove(0); + let tree = parse_line(reply.as_bytes()).unwrap(); + assert_eq!( + extract(&tree, &path("audio/out/level")), + Some(&json!(100.0)) + ); + assert_eq!(device_error(&tree).unwrap().code, 404); +} + +#[test] +fn duplicate_osc_members_are_merged() { + let tree = parse_line( + br#"{"osc":{"error":[{"a":[210,{"desc":"partial success"}]}]},"osc":{"xid":7}}"#, + ) + .unwrap(); + assert_eq!(reply_xid(&tree), Some(7)); + assert_eq!(device_error(&tree).unwrap().code, 210); +} + +#[test] +fn duplicate_non_object_member_takes_the_later_value() { + let tree = parse_line(br#"{"a":1,"a":2,"b":{"c":1},"b":{"d":2}}"#).unwrap(); + assert_eq!(tree, json!({"a": 2, "b": {"c": 1, "d": 2}})); +} + +#[test] +fn set_replies_report_their_errors() { + let codes: Vec> = group("set") + .into_iter() + .map(|(_, reply)| device_error(&parse_line(reply.as_bytes()).unwrap()).map(|e| e.code)) + .collect(); + assert!(codes.contains(&None)); + assert!(codes.contains(&Some(404))); + assert!(codes.contains(&Some(405))); + assert!(codes.contains(&Some(406))); +} + +#[test] +fn flat_error_pair_is_found() { + let tree = parse_line(br#"{"osc":{"error":[404,{"desc":"address not found"}]}}"#).unwrap(); + assert_eq!(device_error(&tree).unwrap().code, 404); +} + +#[test] +fn reply_without_error_has_none() { + let tree = parse_line(br#"{"audio":{"out":{"mute":false}}}"#).unwrap(); + assert!(device_error(&tree).is_none()); +} + +#[test] +fn xid_is_read_from_recorded_echo() { + let (_, reply) = group("xid").remove(0); + let tree = parse_line(reply.as_bytes()).unwrap(); + assert_eq!(reply_xid(&tree), Some(4711)); +} + +#[test] +fn string_xid_is_not_a_number() { + let (_, reply) = group("xid").remove(1); + let tree = parse_line(reply.as_bytes()).unwrap(); + assert_eq!(reply_xid(&tree), None); +} + +#[test] +fn extract_ignores_null_and_missing() { + let tree = parse_line(br#"{"osc":{"ping":null}}"#).unwrap(); + assert_eq!(extract(&tree, &path("osc/ping")), None); + assert_eq!(extract(&tree, &path("osc/version")), None); +} + +#[test] +fn request_line_nests_path_and_xid() { + let line = request_line(&path("audio/out/level"), Value::Null, 5); + assert_eq!( + parse_line(line.as_bytes()).unwrap(), + json!({"osc": {"xid": 5}, "audio": {"out": {"level": null}}}) + ); + let line = request_line(&path("osc/schema"), Value::Null, 6); + assert_eq!( + parse_line(line.as_bytes()).unwrap(), + json!({"osc": {"xid": 6, "schema": null}}) + ); + assert!(!line.contains('\n')); +} + +#[test] +fn non_finite_number_is_refused() { + assert_eq!(number(f64::NAN), Err(WireError::NonFinite)); + assert_eq!(number(f64::INFINITY), Err(WireError::NonFinite)); + assert_eq!(number(f64::NEG_INFINITY), Err(WireError::NonFinite)); + assert_eq!(number(1.5), Ok(json!(1.5))); +} + +#[test] +fn non_object_and_garbage_lines_are_errors() { + assert_eq!(parse_line(b"[1]"), Err(WireError::NotAnObject)); + assert_eq!(parse_line(b"\xff"), Err(WireError::Malformed)); + assert_eq!(parse_line(b"{"), Err(WireError::Malformed)); +} + +#[test] +fn path_parse_rejects_empty_segments() { + assert_eq!(SscPath::parse("audio//level"), Err(WireError::InvalidPath)); + assert_eq!(SscPath::parse(""), Err(WireError::InvalidPath)); +} + +#[test] +fn path_displays_and_serializes_as_string() { + let parsed = path("audio/out/level"); + assert_eq!(parsed.to_string(), "audio/out/level"); + assert_eq!(parsed.segments(), ["audio", "out", "level"]); + assert_eq!( + serde_json::to_string(&parsed).unwrap(), + r#""audio/out/level""# + ); + let back: SscPath = serde_json::from_str(r#""audio/out/level""#).unwrap(); + assert_eq!(back, parsed); + assert!(serde_json::from_str::(r#""a//b""#).is_err()); +} From 0e8239d9f91cd2da23c8b14d50e6d1e0d8e227c9 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:18:46 +0200 Subject: [PATCH 003/460] Parse IPv6 speaker addresses and mask them --- crates/core/src/address.rs | 64 ++++++++++++++++++++++++++ crates/core/src/lib.rs | 2 + crates/core/src/mask.rs | 38 ++++++++++++++++ crates/core/tests/address.rs | 88 ++++++++++++++++++++++++++++++++++++ 4 files changed, 192 insertions(+) create mode 100644 crates/core/src/address.rs create mode 100644 crates/core/src/mask.rs create mode 100644 crates/core/tests/address.rs diff --git a/crates/core/src/address.rs b/crates/core/src/address.rs new file mode 100644 index 0000000..9202cba --- /dev/null +++ b/crates/core/src/address.rs @@ -0,0 +1,64 @@ +//! Parsing of IPv6 speaker addresses, including Windows numeric zone indexes. + +use std::fmt; +use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV6}; + +pub const SSC_PORT: u16 = 45; + +const SCOPE_SEPARATOR: char = '%'; +const LINK_LOCAL_PREFIX_MASK: u16 = 0xffc0; +const LINK_LOCAL_PREFIX: u16 = 0xfe80; +const NO_SCOPE: u32 = 0; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AddressError { + Ipv4NotSupported, + MissingScope, + ScopeNotNumeric, + Invalid, +} + +impl fmt::Display for AddressError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let text = match self { + AddressError::Ipv4NotSupported => "IPv4 addresses are not supported", + AddressError::MissingScope => "link-local address needs an interface index scope", + AddressError::ScopeNotNumeric => "scope must be a numeric interface index", + AddressError::Invalid => "not a valid IPv6 address", + }; + formatter.write_str(text) + } +} + +impl std::error::Error for AddressError {} + +pub fn is_link_local(address: &Ipv6Addr) -> bool { + address.segments()[0] & LINK_LOCAL_PREFIX_MASK == LINK_LOCAL_PREFIX +} + +pub fn parse_address(input: &str, port: u16) -> Result { + let trimmed = input.trim(); + let unbracketed = trimmed + .strip_prefix('[') + .and_then(|rest| rest.strip_suffix(']')) + .unwrap_or(trimmed); + let (host, scope_text) = match unbracketed.split_once(SCOPE_SEPARATOR) { + Some((host, scope_text)) => (host, Some(scope_text)), + None => (unbracketed, None), + }; + + if host.parse::().is_ok() { + return Err(AddressError::Ipv4NotSupported); + } + let ip: Ipv6Addr = host.parse().map_err(|_| AddressError::Invalid)?; + let scope = match scope_text { + Some(text) => text + .parse::() + .map_err(|_| AddressError::ScopeNotNumeric)?, + None => NO_SCOPE, + }; + if is_link_local(&ip) && scope == NO_SCOPE { + return Err(AddressError::MissingScope); + } + Ok(SocketAddrV6::new(ip, port, 0, scope)) +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 9dfefb4..2e8b5fa 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1 +1,3 @@ +pub mod address; +pub mod mask; pub mod ssc; diff --git a/crates/core/src/mask.rs b/crates/core/src/mask.rs new file mode 100644 index 0000000..3c04fd8 --- /dev/null +++ b/crates/core/src/mask.rs @@ -0,0 +1,38 @@ +//! Masking of addresses and serial numbers for logs and screenshots. + +use std::net::SocketAddrV6; + +const MASK_PLACEHOLDER: &str = "xx"; +const KEPT_ADDRESS_CHARACTERS: usize = 2; +const KEPT_SERIAL_CHARACTERS: usize = 3; +const ELLIPSIS: char = '…'; +const GROUP_SEPARATOR: char = ':'; +const COMPRESSION_MARKER: &str = "::"; + +/// Keeps the prefix up to the last `::` and drops the groups after it, keeping +/// only the final two characters. Without a `::` only the last group is masked. +pub fn mask_host(address: &SocketAddrV6) -> String { + let text = address.ip().to_string(); + let prefix_length = match text.rfind(COMPRESSION_MARKER) { + Some(index) => index + COMPRESSION_MARKER.len(), + None => text.rfind(GROUP_SEPARATOR).map_or(0, |index| index + 1), + }; + let (prefix, tail) = text.split_at(prefix_length); + let kept_start = tail.len().saturating_sub(KEPT_ADDRESS_CHARACTERS); + let scope = match address.scope_id() { + 0 => String::new(), + scope_id => format!("%{scope_id}"), + }; + format!("{prefix}{MASK_PLACEHOLDER}{}{scope}", &tail[kept_start..]) +} + +pub fn mask_serial(serial: &str) -> String { + let characters: Vec = serial.chars().collect(); + if characters.len() <= KEPT_SERIAL_CHARACTERS { + return serial.to_owned(); + } + let kept: String = characters[characters.len() - KEPT_SERIAL_CHARACTERS..] + .iter() + .collect(); + format!("{ELLIPSIS}{kept}") +} diff --git a/crates/core/tests/address.rs b/crates/core/tests/address.rs new file mode 100644 index 0000000..8bec6f7 --- /dev/null +++ b/crates/core/tests/address.rs @@ -0,0 +1,88 @@ +use std::net::{Ipv6Addr, SocketAddrV6}; + +use neumann_control_core::address::{is_link_local, parse_address, AddressError, SSC_PORT}; +use neumann_control_core::mask::{mask_host, mask_serial}; + +const SIMULATED_ADDRESS: &str = "fe80::2a36:38ff:fe12:3456"; + +#[test] +fn link_local_without_scope_is_refused() { + assert_eq!( + parse_address(SIMULATED_ADDRESS, SSC_PORT), + Err(AddressError::MissingScope) + ); +} + +#[test] +fn interface_name_scope_is_refused() { + assert_eq!( + parse_address("fe80::1%eth0", SSC_PORT), + Err(AddressError::ScopeNotNumeric) + ); +} + +#[test] +fn ipv4_is_refused() { + assert_eq!( + parse_address("192.168.1.10", SSC_PORT), + Err(AddressError::Ipv4NotSupported) + ); +} + +#[test] +fn garbage_is_invalid() { + assert_eq!( + parse_address("speaker", SSC_PORT), + Err(AddressError::Invalid) + ); +} + +#[test] +fn bracketed_and_plain_forms_parse() { + for input in ["fe80::1%12", "[fe80::1%12]", " fe80::1%12 "] { + let address = parse_address(input, SSC_PORT).unwrap(); + assert_eq!(address.scope_id(), 12); + assert_eq!(address.port(), 45); + assert_eq!(*address.ip(), "fe80::1".parse::().unwrap()); + } +} + +#[test] +fn global_address_needs_no_scope() { + let address = parse_address("2001:db8::1", SSC_PORT).unwrap(); + assert_eq!(address.scope_id(), 0); +} + +#[test] +fn link_local_range_is_fe80_slash_10() { + assert!(is_link_local(&"febf::1".parse().unwrap())); + assert!(!is_link_local(&"fec0::1".parse().unwrap())); +} + +#[test] +fn host_mask_keeps_prefix_and_last_two() { + let address = parse_address("fe80::2a36:38ff:fe12:3456%12", SSC_PORT).unwrap(); + assert_eq!(mask_host(&address), "fe80::xx56%12"); +} + +#[test] +fn host_mask_without_scope_has_no_suffix() { + let address = SocketAddrV6::new("2001:db8::abcd".parse().unwrap(), SSC_PORT, 0, 0); + assert_eq!(mask_host(&address), "2001:db8::xxcd"); +} + +#[test] +fn host_mask_of_uncompressed_address_masks_final_group() { + let address = SocketAddrV6::new("2001:db8:1:2:3:4:5:6789".parse().unwrap(), SSC_PORT, 0, 0); + assert_eq!(mask_host(&address), "2001:db8:1:2:3:4:5:xx89"); +} + +#[test] +fn serial_mask_keeps_last_three() { + assert_eq!(mask_serial("SIM0001234"), "…234"); +} + +#[test] +fn short_serial_is_unchanged() { + assert_eq!(mask_serial("123"), "123"); +} From 65653a4c27f0d53aebe4ba61641e8187fa8f999f Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:20:33 +0200 Subject: [PATCH 004/460] Mask the whole interface identifier --- crates/core/src/address.rs | 15 ++++++++++++--- crates/core/src/mask.rs | 26 ++++++++++++-------------- crates/core/tests/address.rs | 26 +++++++++++++++++++++++--- 3 files changed, 47 insertions(+), 20 deletions(-) diff --git a/crates/core/src/address.rs b/crates/core/src/address.rs index 9202cba..439cf3c 100644 --- a/crates/core/src/address.rs +++ b/crates/core/src/address.rs @@ -51,10 +51,11 @@ pub fn parse_address(input: &str, port: u16) -> Result text - .parse::() - .map_err(|_| AddressError::ScopeNotNumeric)?, + Some(text) => parse_scope(text)?, None => NO_SCOPE, }; if is_link_local(&ip) && scope == NO_SCOPE { @@ -62,3 +63,11 @@ pub fn parse_address(input: &str, port: u16) -> Result Result { + if text.is_empty() || !text.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(AddressError::ScopeNotNumeric); + } + text.parse().map_err(|_| AddressError::ScopeNotNumeric) +} diff --git a/crates/core/src/mask.rs b/crates/core/src/mask.rs index 3c04fd8..4c2b2f4 100644 --- a/crates/core/src/mask.rs +++ b/crates/core/src/mask.rs @@ -1,29 +1,27 @@ //! Masking of addresses and serial numbers for logs and screenshots. -use std::net::SocketAddrV6; +use std::net::{Ipv6Addr, SocketAddrV6}; const MASK_PLACEHOLDER: &str = "xx"; -const KEPT_ADDRESS_CHARACTERS: usize = 2; +const SEGMENT_COUNT: usize = 8; +const KEPT_PREFIX_SEGMENTS: usize = 4; +const LOW_BYTE_MASK: u16 = 0x00ff; const KEPT_SERIAL_CHARACTERS: usize = 3; const ELLIPSIS: char = '…'; -const GROUP_SEPARATOR: char = ':'; -const COMPRESSION_MARKER: &str = "::"; -/// Keeps the prefix up to the last `::` and drops the groups after it, keeping -/// only the final two characters. Without a `::` only the last group is masked. +/// Keeps the first four groups (the network prefix) and only the low byte of +/// the last group, so the interface identifier never reaches logs. pub fn mask_host(address: &SocketAddrV6) -> String { - let text = address.ip().to_string(); - let prefix_length = match text.rfind(COMPRESSION_MARKER) { - Some(index) => index + COMPRESSION_MARKER.len(), - None => text.rfind(GROUP_SEPARATOR).map_or(0, |index| index + 1), - }; - let (prefix, tail) = text.split_at(prefix_length); - let kept_start = tail.len().saturating_sub(KEPT_ADDRESS_CHARACTERS); + let segments = address.ip().segments(); + let mut prefix_segments = [0u16; SEGMENT_COUNT]; + prefix_segments[..KEPT_PREFIX_SEGMENTS].copy_from_slice(&segments[..KEPT_PREFIX_SEGMENTS]); + let prefix = Ipv6Addr::from(prefix_segments); + let last_byte = segments[SEGMENT_COUNT - 1] & LOW_BYTE_MASK; let scope = match address.scope_id() { 0 => String::new(), scope_id => format!("%{scope_id}"), }; - format!("{prefix}{MASK_PLACEHOLDER}{}{scope}", &tail[kept_start..]) + format!("{prefix}{MASK_PLACEHOLDER}{last_byte:02x}{scope}") } pub fn mask_serial(serial: &str) -> String { diff --git a/crates/core/tests/address.rs b/crates/core/tests/address.rs index 8bec6f7..74ee922 100644 --- a/crates/core/tests/address.rs +++ b/crates/core/tests/address.rs @@ -72,9 +72,29 @@ fn host_mask_without_scope_has_no_suffix() { } #[test] -fn host_mask_of_uncompressed_address_masks_final_group() { - let address = SocketAddrV6::new("2001:db8:1:2:3:4:5:6789".parse().unwrap(), SSC_PORT, 0, 0); - assert_eq!(mask_host(&address), "2001:db8:1:2:3:4:5:xx89"); +fn host_mask_hides_interface_identifier_of_uncompressed_address() { + let address = parse_address("2001:db8:1:2:2a36:38ff:fe12:3456", SSC_PORT).unwrap(); + let masked = mask_host(&address); + assert_eq!(masked, "2001:db8:1:2::xx56"); + for hidden in ["2a36", "38ff", "fe12"] { + assert!(!masked.contains(hidden)); + } +} + +#[test] +fn scope_with_plus_sign_is_not_numeric() { + assert_eq!( + parse_address("fe80::1%+12", SSC_PORT), + Err(AddressError::ScopeNotNumeric) + ); +} + +#[test] +fn ipv4_mapped_address_is_refused() { + assert_eq!( + parse_address("::ffff:192.168.1.10", SSC_PORT), + Err(AddressError::Ipv4NotSupported) + ); } #[test] From b456738dc450f15d643ed4261ea924f24361b5a7 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:34:27 +0200 Subject: [PATCH 005/460] Add SSC simulator built from recorded replies --- check.ps1 | 4 +- crates/core/Cargo.toml | 4 + crates/core/src/lib.rs | 2 + crates/core/src/simulator/device.rs | 530 ++++++++++++++++++ crates/core/src/simulator/kh120ii.json | 88 +++ crates/core/src/simulator/kh750.json | 201 +++++++ crates/core/src/simulator/mod.rs | 287 ++++++++++ crates/core/src/ssc/wire.rs | 2 +- .../core/tests/fixtures/recorded_replies.json | 18 + crates/core/tests/simulator.rs | 426 ++++++++++++++ 10 files changed, 1559 insertions(+), 3 deletions(-) create mode 100644 crates/core/src/simulator/device.rs create mode 100644 crates/core/src/simulator/kh120ii.json create mode 100644 crates/core/src/simulator/kh750.json create mode 100644 crates/core/src/simulator/mod.rs create mode 100644 crates/core/tests/simulator.rs diff --git a/check.ps1 b/check.ps1 index 46d10b0..ded2fa2 100644 --- a/check.ps1 +++ b/check.ps1 @@ -99,8 +99,8 @@ Invoke-Gate 'licence gate (cargo about)' { cargo about generate --workspace --locked --fail -c about.toml about.hbs -o "$env:TEMP\neumann-control-about-check.html" } Invoke-Gate 'cargo audit' { cargo audit } -Invoke-Gate 'cargo clippy' { cargo clippy --workspace --all-targets --locked -- -D warnings } -Invoke-Gate 'cargo test' { cargo test --workspace --locked } +Invoke-Gate 'cargo clippy' { cargo clippy --workspace --all-targets --all-features --locked -- -D warnings } +Invoke-Gate 'cargo test' { cargo test --workspace --all-features --locked } Write-Host "" Write-Host "All gates green." -ForegroundColor Green diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 20f95b4..bb0a1e3 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -12,3 +12,7 @@ test-support = [] [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" + +[[test]] +name = "simulator" +required-features = ["test-support"] diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 2e8b5fa..6521557 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,3 +1,5 @@ pub mod address; pub mod mask; +#[cfg(feature = "test-support")] +pub mod simulator; pub mod ssc; diff --git a/crates/core/src/simulator/device.rs b/crates/core/src/simulator/device.rs new file mode 100644 index 0000000..e62067f --- /dev/null +++ b/crates/core/src/simulator/device.rs @@ -0,0 +1,530 @@ +//! The simulated speaker: a value tree plus the answering rules recorded from real devices. +//! No I/O here; the server module moves the lines. + +use serde_json::{json, Map, Number, Value}; + +use super::SimModel; +use crate::ssc::wire::{merge, SscPath}; + +const OSC_KEY: &str = "osc"; +const XID_KEY: &str = "xid"; +const ERROR_KEY: &str = "error"; +const SCHEMA_KEY: &str = "schema"; +const LIMITS_KEY: &str = "limits"; +const VERSION_KEY: &str = "version"; +const PING_KEY: &str = "ping"; +const FEATURE_KEY: &str = "feature"; +const PATH_SEPARATOR: &str = "/"; + +const OSC_VERSION: &str = "1.1"; +const OSC_PATTERN_FEATURE: &str = "*?"; +const FEATURE_PATTERN: &str = "pattern"; +const FEATURE_SUBSCRIPTION: &str = "subscription"; + +const TYPE_KEY: &str = "type"; +const TYPE_NUMBER: &str = "Number"; +const TYPE_STRING: &str = "String"; +const TYPE_BOOLEAN: &str = "Boolean"; + +struct ErrorKind { + code: u16, + description: &'static str, +} + +const BAD_REQUEST: ErrorKind = ErrorKind { + code: 400, + description: "message not understood", +}; +const NOT_FOUND: ErrorKind = ErrorKind { + code: 404, + description: "address not found", +}; +const NOT_ALLOWED: ErrorKind = ErrorKind { + code: 405, + description: "method not allowed", +}; +const NOT_ACCEPTABLE: ErrorKind = ErrorKind { + code: 406, + description: "not acceptable", +}; +const PARAMETER_NOT_FOUND: ErrorKind = ErrorKind { + code: 454, + description: "parameter address not found", +}; + +const KH_120_II_TREE: &str = include_str!("kh120ii.json"); +const KH_750_TREE: &str = include_str!("kh750.json"); + +/// Read-only on every model: identity, live metering, derived values. +const READ_ONLY_COMMON: &[&str] = &[ + "device/identity/product", + "device/identity/vendor", + "device/identity/serial", + "device/identity/version", + "device/identity/hw_version", + "device/standby/countdown", + "device/temperature", + "audio/in/current_input", + "m/in/level", + "m/in/clip", + "m/out/level", + "m/out/clip", + "warnings", +]; + +/// Refused with 405 on the KH 120 II in the write tests. +const READ_ONLY_KH_120_II: &[&str] = &[ + "ui/input_gain", + "ui/input_select", + "ui/bass_gain", + "ui/mid_gain", + "ui/treble_gain", + "ui/output_level", +]; + +/// Refused with 405 on the KH 750 in the write tests, plus the sensors the integration +/// models as read-only. `device/standby/enabled` is writable on the KH 120 II only. +const READ_ONLY_KH_750: &[&str] = &[ + "ui/bass_management", + "ui/channel_b_input_mode", + "ui/subwoofer_input_gain", + "ui/subwoofer_low_cut", + "ui/subwoofer_output_level", + "ui/subwoofer_phase", + "ui/subwoofer_phase_inversion", + "audio/digital_bypass", + "audio/out/label", + "audio/out1/label", + "audio/out2/label", + "audio/out1/loudspeaker", + "audio/out2/loudspeaker", + "device/standby/enabled", +]; + +/// The reply to one request, before the transaction id is attached. +pub struct Answer { + pub body: Value, + pub xid: Option, +} + +pub struct SimulatedDevice { + tree: Value, + read_only_for_model: &'static [&'static str], +} + +impl SimulatedDevice { + pub fn new(model: SimModel) -> SimulatedDevice { + let (source, read_only_for_model) = match model { + SimModel::Kh120Ii => (KH_120_II_TREE, READ_ONLY_KH_120_II), + SimModel::Kh750 => (KH_750_TREE, READ_ONLY_KH_750), + }; + SimulatedDevice { + tree: serde_json::from_str(source).expect("embedded value tree is valid JSON"), + read_only_for_model, + } + } + + pub fn value(&self, path: &str) -> Option { + let path = SscPath::parse(path).ok()?; + lookup(&self.tree, path.segments()).cloned() + } + + pub fn set_value(&mut self, path: &str, value: Value) { + let path = SscPath::parse(path).expect("a valid SSC path"); + let mut node = &mut self.tree; + for segment in path.segments() { + if !node.is_object() { + *node = Value::Object(Map::new()); + } + node = node + .as_object_mut() + .expect("made an object above") + .entry(segment.clone()) + .or_insert(Value::Null); + } + *node = value; + } + + pub fn remove_value(&mut self, path: &str) { + let path = SscPath::parse(path).expect("a valid SSC path"); + let Some((name, parents)) = path.segments().split_last() else { + return; + }; + let parent = parents + .iter() + .try_fold(&mut self.tree, |node, segment| node.get_mut(segment)); + if let Some(members) = parent.and_then(Value::as_object_mut) { + members.remove(name); + } + } + + pub fn answer(&mut self, request: &Map) -> Answer { + let xid = request + .get(OSC_KEY) + .and_then(|osc| osc.get(XID_KEY)) + .cloned(); + let body = self.answer_body(request, xid.is_some()); + Answer { body, xid } + } + + fn answer_body(&mut self, request: &Map, has_xid: bool) -> Value { + let Some(osc) = request.get(OSC_KEY) else { + return self.answer_values(request); + }; + let Some(osc) = osc.as_object() else { + return error_reply(&BAD_REQUEST); + }; + if let Some(argument) = osc.get(SCHEMA_KEY) { + return self.schema(argument); + } + if let Some(argument) = osc.get(LIMITS_KEY) { + return self.limits(argument); + } + let Some(methods) = osc_methods(osc) else { + return error_reply(&BAD_REQUEST); + }; + let mut values = request.clone(); + values.remove(OSC_KEY); + if values.is_empty() && methods.is_empty() && !has_xid { + return error_reply(&BAD_REQUEST); + } + let mut body = if values.is_empty() { + Value::Object(Map::new()) + } else { + self.answer_values(&values) + }; + if !methods.is_empty() { + merge(&mut body, json!({ OSC_KEY: methods })); + } + body + } + + fn answer_values(&mut self, request: &Map) -> Value { + let leaves = collect_leaves(request, &[]); + if leaves.is_empty() { + return error_reply(&BAD_REQUEST); + } + let bundled = leaves.len() > 1; + let mut body = Value::Object(Map::new()); + let mut errors = Vec::new(); + for (path, value) in leaves { + match self.answer_leaf(&path, value, bundled) { + Ok(subtree) => merge(&mut body, subtree), + Err(entry) => errors.push(entry), + } + } + if !errors.is_empty() { + body[OSC_KEY] = json!({ ERROR_KEY: errors }); + } + body + } + + /// One leaf of a request: the answer subtree, or the error entry nested at its node. + fn answer_leaf( + &mut self, + path: &[String], + value: Value, + bundled: bool, + ) -> Result { + let current = match lookup(&self.tree, path) { + Some(found) if !found.is_object() => found.clone(), + found => { + let node = if found.is_none() { + &path[..self.first_missing_depth(path)] + } else { + path + }; + // Recorded: inside a bundle the 404 sits at the parent of the failing node. + let node = if bundled && node.len() > 1 { + &node[..node.len() - 1] + } else { + node + }; + return Err(error_entry(node, &NOT_FOUND)); + } + }; + if value.is_null() { + return Ok(nest(path, current)); + } + if self.is_read_only(path) { + return Err(error_entry(path, &NOT_ALLOWED)); + } + if !same_kind(¤t, &value) { + return Err(error_entry(path, &NOT_ACCEPTABLE)); + } + let resulting = resulting_value(¤t, value); + self.store(path, resulting.clone()); + Ok(nest(path, resulting)) + } + + fn first_missing_depth(&self, path: &[String]) -> usize { + let mut node = &self.tree; + for (depth, segment) in path.iter().enumerate() { + match node.get(segment) { + Some(child) => node = child, + None => return depth + 1, + } + } + path.len() + } + + fn is_read_only(&self, path: &[String]) -> bool { + let joined = path.join(PATH_SEPARATOR); + READ_ONLY_COMMON.contains(&joined.as_str()) + || self.read_only_for_model.contains(&joined.as_str()) + } + + fn store(&mut self, path: &[String], value: Value) { + if let Some(slot) = path + .iter() + .try_fold(&mut self.tree, |node, segment| node.get_mut(segment)) + { + *slot = value; + } + } + + /// Everything `osc/schema` describes: the device tree plus the `osc` container. + fn address_tree(&self) -> Value { + let mut tree = self.tree.clone(); + tree[OSC_KEY] = osc_tree(); + tree + } + + fn schema(&self, argument: &Value) -> Value { + let tree = self.address_tree(); + if argument.is_null() { + return osc_reply(SCHEMA_KEY, json!([schema_level(&tree)])); + } + let path = match introspection_path(argument, SCHEMA_KEY) { + Ok(path) => path, + Err(refusal) => return refusal, + }; + let Some(node) = lookup(&tree, &path) else { + return introspection_error(SCHEMA_KEY, &PARAMETER_NOT_FOUND); + }; + let described = if node.is_object() { + schema_level(node) + } else { + Value::Null + }; + osc_reply(SCHEMA_KEY, json!([nest(&path, described)])) + } + + fn limits(&self, argument: &Value) -> Value { + let path = match introspection_path(argument, LIMITS_KEY) { + Ok(path) => path, + Err(refusal) => return refusal, + }; + let tree = self.address_tree(); + let Some(node) = lookup(&tree, &path).filter(|node| !node.is_object()) else { + return introspection_error(LIMITS_KEY, &PARAMETER_NOT_FOUND); + }; + // Only the type is reproduced; the other fields (units, range, options) are not + // recorded per path, and none is invented. + let description = if path[0] == OSC_KEY { + json!({}) + } else { + json!({ TYPE_KEY: limits_type(node) }) + }; + osc_reply(LIMITS_KEY, json!([nest(&path, json!([description]))])) + } +} + +/// The serialized line: the transaction id rides in `osc/xid`. An error reply already has an +/// `osc` member, and real devices then send a second one for the id instead of merging. +pub fn encode_line(body: &Value, xid: Option<&Value>) -> String { + let Some(xid) = xid else { + return body.to_string(); + }; + let has_error = body + .get(OSC_KEY) + .and_then(|osc| osc.get(ERROR_KEY)) + .is_some(); + if has_error { + let text = body.to_string(); + let opened = text.strip_suffix('}').unwrap_or(&text); + return format!("{opened},\"{OSC_KEY}\":{{\"{XID_KEY}\":{xid}}}}}"); + } + let mut tagged = body.clone(); + merge(&mut tagged, json!({ OSC_KEY: { XID_KEY: xid } })); + tagged.to_string() +} + +fn lookup<'a>(tree: &'a Value, path: &[String]) -> Option<&'a Value> { + path.iter() + .try_fold(tree, |node, segment| node.get(segment)) +} + +fn nest(path: &[String], value: Value) -> Value { + path.iter().rev().fold(value, |inner, segment| { + Value::Object(Map::from_iter([(segment.clone(), inner)])) + }) +} + +/// Flattens a request into (path, value) pairs, stopping at non-object values and `{}`. +fn collect_leaves(members: &Map, prefix: &[String]) -> Vec<(Vec, Value)> { + let mut leaves = Vec::new(); + for (name, value) in members { + let mut path = prefix.to_vec(); + path.push(name.clone()); + match value.as_object().filter(|nested| !nested.is_empty()) { + Some(nested) => leaves.extend(collect_leaves(nested, &path)), + None => leaves.push((path, value.clone())), + } + } + leaves +} + +fn error_pair(kind: &ErrorKind) -> Value { + json!([kind.code, { "desc": kind.description }]) +} + +fn error_entry(node: &[String], kind: &ErrorKind) -> Value { + nest(node, error_pair(kind)) +} + +/// A flat error for requests whose real reply was never recorded. +fn error_reply(kind: &ErrorKind) -> Value { + json!({ OSC_KEY: { ERROR_KEY: error_pair(kind) } }) +} + +/// A refused introspection request: the error sits at `osc/`, as recorded. +fn introspection_error(method: &str, kind: &ErrorKind) -> Value { + json!({ OSC_KEY: { ERROR_KEY: [nest(&[OSC_KEY.to_owned(), method.to_owned()], error_pair(kind))] } }) +} + +fn osc_reply(method: &str, payload: Value) -> Value { + json!({ OSC_KEY: { method: payload } }) +} + +/// The address an `osc/schema` or `osc/limits` request names. Recorded: the argument is a +/// list holding one address tree that ends in `null`; `[null]`, `[{}]` or no list is a 454, +/// a tree ending in `{}` a 406. +fn introspection_path(argument: &Value, method: &str) -> Result, Value> { + let tree = argument + .as_array() + .and_then(|items| items.first()) + .and_then(Value::as_object) + .filter(|members| !members.is_empty()) + .ok_or_else(|| introspection_error(method, &PARAMETER_NOT_FOUND))?; + let mut leaves = collect_leaves(tree, &[]); + let (Some((path, end)), true) = (leaves.pop(), leaves.is_empty()) else { + return Err(error_reply(&BAD_REQUEST)); + }; + if !end.is_null() { + return Err(introspection_error(method, &NOT_ACCEPTABLE)); + } + Ok(path) +} + +/// One level of the address tree as `osc/schema` reports it: containers `{}`, leaves `null`. +fn schema_level(node: &Value) -> Value { + let members = node.as_object().into_iter().flatten(); + Value::Object( + members + .map(|(name, child)| { + let described = if child.is_object() { + json!({}) + } else { + Value::Null + }; + (name.clone(), described) + }) + .collect(), + ) +} + +/// The `osc` container as the devices describe it; every method is a leaf. +fn osc_tree() -> Value { + json!({ + "state": { + "auth": { "access": null }, + "prettyprint": null, + "subscribe": null, + "subscribe_reserve": null + }, + "feature": { "timetag": null, "baseaddr": null, "subscription": null, "pattern": null }, + "limits": null, + "schema": null, + "version": null, + "xid": null, + "ping": null, + "error": null + }) +} + +/// The recorded answers of `osc/version`, `osc/ping` and single `osc/feature` gets; `None` +/// for anything else (sets, containers), whose reply was never recorded. +fn osc_methods(osc: &Map) -> Option> { + let mut answer = Map::new(); + for (name, value) in osc.iter().filter(|(name, _)| name.as_str() != XID_KEY) { + match (name.as_str(), value) { + (VERSION_KEY, Value::Null) => answer.insert(name.clone(), json!(OSC_VERSION)), + (PING_KEY, Value::Null) => answer.insert(name.clone(), Value::Null), + (FEATURE_KEY, Value::Object(asked)) if !asked.is_empty() => { + answer.insert(name.clone(), features(asked)?) + } + _ => return None, + }; + } + Some(answer) +} + +fn features(asked: &Map) -> Option { + let mut answer = Map::new(); + for (name, value) in asked { + let known = match name.as_str() { + FEATURE_PATTERN => json!(OSC_PATTERN_FEATURE), + FEATURE_SUBSCRIPTION => json!(true), + _ => return None, + }; + if !value.is_null() { + return None; + } + answer.insert(name.clone(), known); + } + Some(Value::Object(answer)) +} + +fn limits_type(value: &Value) -> &'static str { + match value { + Value::Array(items) => items.first().map_or(TYPE_STRING, limits_type), + Value::Bool(_) => TYPE_BOOLEAN, + Value::Number(_) => TYPE_NUMBER, + _ => TYPE_STRING, + } +} + +/// Whether a set carries the kind of value the leaf holds; lists must keep their length. +fn same_kind(current: &Value, value: &Value) -> bool { + match (current, value) { + (Value::Bool(_), Value::Bool(_)) + | (Value::Number(_), Value::Number(_)) + | (Value::String(_), Value::String(_)) => true, + (Value::Array(held), Value::Array(given)) => { + held.len() == given.len() + && held + .iter() + .zip(given) + .all(|(left, right)| same_kind(left, right)) + } + _ => false, + } +} + +/// A float leaf stays a float: `90` is stored and echoed as `90.0`, as the devices do. +fn resulting_value(current: &Value, value: Value) -> Value { + match (current, &value) { + (Value::Number(held), Value::Number(given)) if held.is_f64() => given + .as_f64() + .and_then(Number::from_f64) + .map_or(value, Value::Number), + (Value::Array(held), Value::Array(given)) => Value::Array( + held.iter() + .zip(given) + .map(|(left, right)| resulting_value(left, right.clone())) + .collect(), + ), + _ => value, + } +} diff --git a/crates/core/src/simulator/kh120ii.json b/crates/core/src/simulator/kh120ii.json new file mode 100644 index 0000000..9a6333f --- /dev/null +++ b/crates/core/src/simulator/kh120ii.json @@ -0,0 +1,88 @@ +{ + "device": { + "auth": { + "exclusive": "" + }, + "identification": { + "visual": false + }, + "identity": { + "hw_version": 1, + "product": "KH 120 II", + "serial": "SIM0001234", + "vendor": "Georg Neumann GmbH", + "version": "1_7_3" + }, + "name": "Simulated KH 120 II", + "network": { + "ether": { + "macs": "08:1b:2c:3d:4e:5f" + } + }, + "restore": "", + "standby": { + "auto_standby_time": 60, + "countdown": 0, + "enabled": true, + "level": -65 + }, + "system": "", + "time": 0, + "timeprecision": 0, + "update": { + "enable": false, + "startover": false + } + }, + "audio": { + "in": { + "current_input": "ANALOG INPUT", + "interface": "ANALOG ONLY" + }, + "out": { + "delay": 0, + "level": 100.0, + "mixer": { + "levels": [0.0, -100.0] + }, + "mute": false, + "mute_persist": false, + "eq2": { + "boost": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + "enabled": [false, false, false, false, false, false, false, false, false, false], + "frequency": [100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0], + "gain": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + "q": [0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7], + "type": ["PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC"] + }, + "eq3": { + "boost": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + "enabled": [false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false], + "frequency": [100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0], + "gain": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + "q": [0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7], + "type": ["PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC"] + }, + "phaseinversion": false + } + }, + "ui": { + "control_mode": "NETWORK", + "bass_gain": "0", + "input_gain": 0.0, + "input_select": "ANALOG", + "logo": { + "brightness": 30 + }, + "mid_gain": "0", + "output_level": "100", + "treble_gain": "0" + }, + "m": { + "in": { + "clip": [false, false], + "level": [-40.0, -41.5] + } + }, + "warnings": ["NO_WARNING"] +} diff --git a/crates/core/src/simulator/kh750.json b/crates/core/src/simulator/kh750.json new file mode 100644 index 0000000..3449af4 --- /dev/null +++ b/crates/core/src/simulator/kh750.json @@ -0,0 +1,201 @@ +{ + "device": { + "auth": { + "exclusive": "" + }, + "identification": { + "visual": false + }, + "identity": { + "hw_version": 1, + "product": "KH 750", + "serial": "SIM0007500", + "vendor": "Georg Neumann GmbH", + "version": "2_1_2" + }, + "name": "Simulated KH 750", + "network": { + "ether": { + "macs": "08:1b:2c:3d:4e:5f" + } + }, + "restore": "", + "standby": { + "auto_standby_time": 60, + "countdown": 0, + "enabled": true, + "level": -65 + }, + "system": "", + "time": 0, + "timeprecision": 0, + "update": { + "enable": false, + "startover": false + }, + "temperature": 310 + }, + "audio": { + "in": { + "current_input": "ANALOG INPUT", + "interface": "ANALOG ONLY" + }, + "digital_bypass": true, + "out": { + "delay": 0, + "level": 100.0, + "mixer": { + "levels": [0.0, -100.0] + }, + "mute": false, + "mute_persist": false, + "eq1": { + "in1": { + "boost": [0.0, 0.0], + "enabled": [true, true], + "frequency": [80.0, 80.0], + "gain": [0.0, 0.0], + "q": [0.7, 0.7], + "type": ["LOWPASS", "LOWPASS"] + }, + "in2": { + "boost": [0.0, 0.0], + "enabled": [true, true], + "frequency": [80.0, 80.0], + "gain": [0.0, 0.0], + "q": [0.7, 0.7], + "type": ["LOWPASS", "LOWPASS"] + } + }, + "eq2": { + "boost": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + "enabled": [false, false, false, false, false, false, false, false, false, false], + "frequency": [100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0], + "gain": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + "q": [0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7], + "type": ["PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC"] + }, + "eq3": { + "boost": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + "enabled": [false, false, false, false, false, false, false, false, false, false], + "frequency": [100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0], + "gain": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + "q": [0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7], + "type": ["PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC"] + }, + "label": "SUBWOOFER" + }, + "out1": { + "delay": 0, + "level": 100.0, + "mixer": { + "levels": [0.0, -100.0] + }, + "mute": false, + "mute_persist": false, + "eq1": { + "in1": { + "boost": [0.0, 0.0], + "enabled": [true, true], + "frequency": [80.0, 80.0], + "gain": [0.0, 0.0], + "q": [0.7, 0.7], + "type": ["HIGHPASS", "HIGHPASS"] + }, + "in2": { + "boost": [0.0, 0.0], + "enabled": [true, true], + "frequency": [80.0, 80.0], + "gain": [0.0, 0.0], + "q": [0.7, 0.7], + "type": ["HIGHPASS", "HIGHPASS"] + } + }, + "eq2": { + "boost": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + "enabled": [false, false, false, false, false, false, false, false, false, false], + "frequency": [100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0], + "gain": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + "q": [0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7], + "type": ["PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC"] + }, + "eq3": { + "boost": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + "enabled": [false, false, false, false, false, false, false, false, false, false], + "frequency": [100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0], + "gain": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + "q": [0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7], + "type": ["PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC"] + }, + "label": "OUT1", + "loudspeaker": "UNKNOWN" + }, + "out2": { + "delay": 0, + "level": 100.0, + "mixer": { + "levels": [0.0, -100.0] + }, + "mute": false, + "mute_persist": false, + "eq1": { + "in1": { + "boost": [0.0, 0.0], + "enabled": [true, true], + "frequency": [80.0, 80.0], + "gain": [0.0, 0.0], + "q": [0.7, 0.7], + "type": ["HIGHPASS", "HIGHPASS"] + }, + "in2": { + "boost": [0.0, 0.0], + "enabled": [true, true], + "frequency": [80.0, 80.0], + "gain": [0.0, 0.0], + "q": [0.7, 0.7], + "type": ["HIGHPASS", "HIGHPASS"] + } + }, + "eq2": { + "boost": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + "enabled": [false, false, false, false, false, false, false, false, false, false], + "frequency": [100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0], + "gain": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + "q": [0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7], + "type": ["PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC"] + }, + "eq3": { + "boost": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + "enabled": [false, false, false, false, false, false, false, false, false, false], + "frequency": [100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0], + "gain": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + "q": [0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7], + "type": ["PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC"] + }, + "label": "OUT2", + "loudspeaker": "UNKNOWN" + } + }, + "ui": { + "control_mode": "NETWORK", + "auto_standby": "ON", + "bass_management": "DISABLED", + "channel_b_input_mode": "Ext.BM LFE (fullrange)", + "subwoofer_input_gain": 2.0, + "subwoofer_low_cut": -0.1, + "subwoofer_output_level": "94", + "subwoofer_phase": "0", + "subwoofer_phase_inversion": "0" + }, + "m": { + "in": { + "clip": [false, false], + "level": [-40.0, -41.5] + }, + "out": { + "clip": [false, false, false], + "level": [-35.0, -35.0, -35.0] + } + }, + "warnings": ["NO_WARNING"] +} diff --git a/crates/core/src/simulator/mod.rs b/crates/core/src/simulator/mod.rs new file mode 100644 index 0000000..d9e0541 --- /dev/null +++ b/crates/core/src/simulator/mod.rs @@ -0,0 +1,287 @@ +//! A simulated Neumann KH speaker for tests: it answers over TCP exactly like the recorded +//! real devices and can be told to misbehave on the next reply. + +mod device; + +use std::collections::{HashMap, VecDeque}; +use std::io::{self, BufRead, BufReader, Write}; +use std::net::{Ipv6Addr, Shutdown, SocketAddr, SocketAddrV6, TcpListener, TcpStream}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::thread::{self, JoinHandle}; +use std::time::Duration; + +use serde_json::{json, Map, Value}; + +use crate::ssc::wire::parse_line; +use device::{encode_line, SimulatedDevice}; + +const LINE_TERMINATOR: &str = "\r\n"; +const LINE_LIMIT_BYTES: usize = 1_048_576; +const FOREIGN_XID_OFFSET: u64 = 1000; +const FILLER_BYTE: char = 'a'; +// Windows does not wake a blocked read when another handle of the socket is shut down, so +// connection threads wake up this often to look for the stop flag. +const READ_POLL_INTERVAL: Duration = Duration::from_millis(50); +// A refused loopback connect takes about 2 s on Windows; waking a finished listener must not. +const WAKE_TIMEOUT: Duration = Duration::from_millis(200); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SimModel { + Kh120Ii, + Kh750, +} + +/// A misbehaviour applied to the next reply only. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Fault { + /// An earlier reply without the request's id arrives before the real one. + StaleLineFirst, + /// A reply with a foreign id (request id + 1000) arrives before the real one. + ForeignXidFirst, + /// The reply leaves out the transaction id. + NoXidEcho, + /// A line longer than the 1 MiB limit replaces the reply. + OverlongLine, + /// The connection is closed instead of answering. + CloseConnection, + /// The request is read and never answered. + Silence, +} + +/// What the connection sends back for one request. +enum Delivery { + Lines(Vec), + Close, + Silent, +} + +struct State { + device: SimulatedDevice, + faults: VecDeque, + requests: Vec, +} + +#[derive(Default)] +struct Connections { + next_id: u64, + streams: HashMap, + workers: Vec>, +} + +struct Shared { + state: Mutex, + connections: Mutex, + stopping: AtomicBool, +} + +impl Shared { + fn state(&self) -> MutexGuard<'_, State> { + self.state.lock().expect("simulator state lock") + } + + fn connections(&self) -> MutexGuard<'_, Connections> { + self.connections.lock().expect("simulator connection lock") + } + + /// Answers one request. A fault still applies the request to the tree: the device + /// received it, only its reply goes wrong. + fn respond(&self, request: Map) -> Delivery { + let mut state = self.state(); + state.requests.push(Value::Object(request.clone())); + let fault = state.faults.pop_front(); + let answer = state.device.answer(&request); + let reply = encode_line(&answer.body, answer.xid.as_ref()); + match fault { + None => Delivery::Lines(vec![reply]), + Some(Fault::CloseConnection) => Delivery::Close, + Some(Fault::Silence) => Delivery::Silent, + Some(Fault::OverlongLine) => Delivery::Lines(vec![overlong_line()]), + Some(Fault::NoXidEcho) => Delivery::Lines(vec![encode_line(&answer.body, None)]), + Some(Fault::ForeignXidFirst) => { + let foreign = + answer.xid.as_ref().and_then(Value::as_u64).unwrap_or(0) + FOREIGN_XID_OFFSET; + let early = encode_line(&answer.body, Some(&json!(foreign))); + Delivery::Lines(vec![early, reply]) + } + Some(Fault::StaleLineFirst) => { + let earlier = json!({"audio": {"out": {"level": null}}}); + let stale = state.device.answer(earlier.as_object().expect("an object")); + Delivery::Lines(vec![encode_line(&stale.body, None), reply]) + } + } + } + + fn converse(&self, stream: TcpStream) { + let _ = stream.set_read_timeout(Some(READ_POLL_INTERVAL)); + let Ok(read_half) = stream.try_clone() else { + return; + }; + let mut reader = BufReader::new(read_half); + let mut writer = &stream; + let mut line = Vec::new(); + loop { + match reader.read_until(b'\n', &mut line) { + Ok(0) => break, + Ok(_) => {} + Err(error) if is_poll_timeout(&error) => { + if self.stopping.load(Ordering::SeqCst) { + break; + } + continue; + } + Err(_) => break, + } + let parsed = parse_line(line.trim_ascii()); + line.clear(); + let Ok(Value::Object(request)) = parsed else { + continue; + }; + let lines = match self.respond(request) { + Delivery::Lines(lines) => lines, + Delivery::Silent => continue, + Delivery::Close => break, + }; + let payload: String = lines + .iter() + .map(|text| format!("{text}{LINE_TERMINATOR}")) + .collect(); + if writer.write_all(payload.as_bytes()).is_err() { + break; + } + } + // The registry keeps a duplicate handle, so dropping this one would not close it. + let _ = stream.shutdown(Shutdown::Both); + } + + fn serve(self: &Arc, stream: TcpStream) { + let _ = stream.set_nodelay(true); + let Ok(registered) = stream.try_clone() else { + return; + }; + let mut connections = self.connections(); + let id = connections.next_id; + connections.next_id += 1; + connections.streams.insert(id, registered); + let shared = Arc::clone(self); + connections.workers.push(thread::spawn(move || { + shared.converse(stream); + shared.connections().streams.remove(&id); + })); + } +} + +fn is_poll_timeout(error: &io::Error) -> bool { + matches!( + error.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut + ) +} + +fn overlong_line() -> String { + FILLER_BYTE.to_string().repeat(LINE_LIMIT_BYTES + 1) +} + +fn accept_loop(shared: Arc, listener: TcpListener) { + for incoming in listener.incoming() { + if shared.stopping.load(Ordering::SeqCst) { + break; + } + if let Ok(stream) = incoming { + shared.serve(stream); + } + } +} + +pub struct Simulator { + shared: Arc, + address: SocketAddrV6, + acceptor: Option>, +} + +impl Simulator { + /// Starts on a free port of the IPv6 loopback address. + pub fn start(model: SimModel) -> Simulator { + let any_port = SocketAddrV6::new(Ipv6Addr::LOCALHOST, 0, 0, 0); + Simulator::start_on(model, any_port).expect("IPv6 loopback is available") + } + + /// Starts on `address`, e.g. the one a stopped simulator had, to model a restart. + pub fn start_on(model: SimModel, address: SocketAddrV6) -> io::Result { + let listener = TcpListener::bind(address)?; + let SocketAddr::V6(bound) = listener.local_addr()? else { + return Err(io::Error::other("listener is not IPv6")); + }; + let shared = Arc::new(Shared { + state: Mutex::new(State { + device: SimulatedDevice::new(model), + faults: VecDeque::new(), + requests: Vec::new(), + }), + connections: Mutex::new(Connections::default()), + stopping: AtomicBool::new(false), + }); + let acceptor = thread::spawn({ + let shared = Arc::clone(&shared); + move || accept_loop(shared, listener) + }); + Ok(Simulator { + shared, + address: bound, + acceptor: Some(acceptor), + }) + } + + pub fn address(&self) -> SocketAddrV6 { + self.address + } + + /// Closes the listener and every open connection; the port is free again on return. + pub fn stop(self) { + drop(self); + } + + /// Queues a fault for the next reply; several queued faults apply one per reply. + pub fn inject(&self, fault: Fault) { + self.shared.state().faults.push_back(fault); + } + + /// Every request received so far, parsed. + pub fn requests(&self) -> Vec { + self.shared.state().requests.clone() + } + + pub fn value(&self, path: &str) -> Option { + self.shared.state().device.value(path) + } + + /// Changes the tree behind the client's back, e.g. to model a knob turned on the speaker. + pub fn set_value(&self, path: &str, value: Value) { + self.shared.state().device.set_value(path, value); + } + + pub fn remove_value(&self, path: &str) { + self.shared.state().device.remove_value(path); + } +} + +impl Drop for Simulator { + fn drop(&mut self) { + self.shared.stopping.store(true, Ordering::SeqCst); + // accept() blocks until a connection arrives, so one is made to let the loop see the flag. + let _ = TcpStream::connect_timeout(&SocketAddr::V6(self.address), WAKE_TIMEOUT); + if let Some(acceptor) = self.acceptor.take() { + let _ = acceptor.join(); + } + let workers = { + let mut connections = self.shared.connections(); + for stream in connections.streams.values() { + let _ = stream.shutdown(Shutdown::Both); + } + std::mem::take(&mut connections.workers) + }; + for worker in workers { + let _ = worker.join(); + } + } +} diff --git a/crates/core/src/ssc/wire.rs b/crates/core/src/ssc/wire.rs index d73367d..e0b09ac 100644 --- a/crates/core/src/ssc/wire.rs +++ b/crates/core/src/ssc/wire.rs @@ -79,7 +79,7 @@ pub struct DeviceError { } /// Merges `incoming` into `target`: objects recursively, anything else is replaced. -fn merge(target: &mut Value, incoming: Value) { +pub fn merge(target: &mut Value, incoming: Value) { match (target, incoming) { (Value::Object(existing), Value::Object(additions)) => { for (key, value) in additions { diff --git a/crates/core/tests/fixtures/recorded_replies.json b/crates/core/tests/fixtures/recorded_replies.json index d341f74..5931fef 100644 --- a/crates/core/tests/fixtures/recorded_replies.json +++ b/crates/core/tests/fixtures/recorded_replies.json @@ -249,5 +249,23 @@ }, "reply": "{\"audio\":{\"out\":{\"mute\":false}},\"osc\":{\"xid\":\"abc\"}}" } + ], + "osc_methods": [ + { + "request": { "osc": { "version": null } }, + "reply": "{\"osc\":{\"version\":\"1.1\"}}" + }, + { + "request": { "osc": { "feature": { "pattern": null } } }, + "reply": "{\"osc\":{\"feature\":{\"pattern\":\"*?\"}}}" + }, + { + "request": { "osc": { "feature": { "subscription": null } } }, + "reply": "{\"osc\":{\"feature\":{\"subscription\":true}}}" + }, + { + "request": { "osc": { "ping": null } }, + "reply": "{\"osc\":{\"ping\":null}}" + } ] } diff --git a/crates/core/tests/simulator.rs b/crates/core/tests/simulator.rs new file mode 100644 index 0000000..6650d25 --- /dev/null +++ b/crates/core/tests/simulator.rs @@ -0,0 +1,426 @@ +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{Ipv6Addr, SocketAddrV6, TcpStream}; +use std::time::Duration; + +use neumann_control_core::simulator::{Fault, SimModel, Simulator}; +use neumann_control_core::ssc::wire::{device_error, parse_line, reply_xid}; +use serde_json::{json, Value}; + +const FIXTURES: &str = include_str!("fixtures/recorded_replies.json"); +const READ_TIMEOUT: Duration = Duration::from_secs(3); +const SILENCE_TIMEOUT: Duration = Duration::from_millis(400); +// Longer than the simulator's read poll interval, so the first half is seen on its own. +const PARTIAL_LINE_PAUSE: Duration = Duration::from_millis(200); +const LINE_LIMIT_BYTES: usize = 1_048_576; +const FOREIGN_XID_OFFSET: u64 = 1000; +const KH_120_II: &str = "KH 120 II"; +const KH_750: &str = "KH 750"; +const KH_120_II_LEAVES: usize = 50; +const KH_750_LEAVES: usize = 129; + +struct Client { + reader: BufReader, + writer: TcpStream, +} + +impl Client { + fn connect(simulator: &Simulator) -> Client { + let stream = TcpStream::connect(simulator.address()).expect("simulator accepts"); + stream.set_read_timeout(Some(READ_TIMEOUT)).unwrap(); + Client { + reader: BufReader::new(stream.try_clone().unwrap()), + writer: stream, + } + } + + fn send(&mut self, request: &Value) { + write!(self.writer, "{request}\r\n").expect("request is written"); + } + + fn read_raw_line(&mut self) -> Option { + let mut line = String::new(); + match self.reader.read_line(&mut line) { + Ok(0) | Err(_) => None, + Ok(_) => Some(line.trim_end().to_owned()), + } + } + + fn read_tree(&mut self) -> Value { + let line = self.read_raw_line().expect("a reply line"); + parse_line(line.as_bytes()).expect("reply is a JSON object") + } + + fn ask(&mut self, request: &Value) -> Value { + self.send(request); + self.read_tree() + } +} + +fn model_of(name: &str) -> SimModel { + match name { + KH_120_II => SimModel::Kh120Ii, + KH_750 => SimModel::Kh750, + other => panic!("unknown model {other}"), + } +} + +fn start_kh120() -> Simulator { + Simulator::start(SimModel::Kh120Ii) +} + +/// The reply with its transaction id removed, and an `osc` object left empty by that dropped. +fn without_xid(mut tree: Value) -> Value { + let Some(osc) = tree.get_mut("osc").and_then(Value::as_object_mut) else { + return tree; + }; + osc.remove("xid"); + if osc.is_empty() { + tree.as_object_mut().unwrap().remove("osc"); + } + tree +} + +fn recorded_entries() -> Vec<(String, Value, String, &'static str)> { + let fixtures: Value = serde_json::from_str(FIXTURES).expect("fixture file is valid JSON"); + let mut entries = Vec::new(); + for (group, members) in fixtures.as_object().unwrap() { + for member in members.as_array().unwrap() { + let model = match (group.as_str(), member["model"].as_str()) { + (_, Some(KH_750)) | ("kh750_not_found", _) => KH_750, + _ => KH_120_II, + }; + entries.push(( + group.clone(), + member["request"].clone(), + member["reply"].as_str().unwrap().to_owned(), + model, + )); + } + } + entries +} + +#[test] +fn simulator_matches_recorded_replies() { + let entries = recorded_entries(); + let groups: std::collections::BTreeSet<_> = + entries.iter().map(|entry| entry.0.as_str()).collect(); + assert!(groups.contains("osc_methods") && groups.contains("schema")); + for (group, request, recorded, model) in &entries { + let simulator = Simulator::start(model_of(model)); + let reply = Client::connect(&simulator).ask(request); + let expected = parse_line(recorded.as_bytes()).unwrap(); + assert_eq!( + without_xid(reply), + without_xid(expected), + "{group} / {model}: {request}" + ); + } +} + +#[test] +fn simulator_echoes_xid() { + let simulator = start_kh120(); + let mut client = Client::connect(&simulator); + let numeric = client.ask(&json!({"osc":{"xid":4711},"audio":{"out":{"level":null}}})); + assert_eq!(reply_xid(&numeric), Some(4711)); + let text = client.ask(&json!({"osc":{"xid":"abc"},"audio":{"out":{"mute":null}}})); + assert_eq!(text["osc"]["xid"], json!("abc")); + let without = client.ask(&json!({"audio":{"out":{"mute":null}}})); + assert!(without.get("osc").is_none(), "no xid was asked for"); +} + +#[test] +fn error_reply_carries_xid_as_second_osc_member() { + let simulator = start_kh120(); + let mut client = Client::connect(&simulator); + client.send(&json!({"osc":{"xid":7},"audio":{"out":{"dimm":null}}})); + let line = client.read_raw_line().unwrap(); + assert_eq!(line.matches("\"osc\"").count(), 2, "{line}"); + let tree = parse_line(line.as_bytes()).unwrap(); + assert_eq!(reply_xid(&tree), Some(7)); + assert_eq!(device_error(&tree).unwrap().code, 404); +} + +#[test] +fn simulator_refuses_read_only_set_with_405() { + let simulator = start_kh120(); + let mut client = Client::connect(&simulator); + let reply = client.ask(&json!({"ui":{"input_gain":-3.0}})); + let error = device_error(&reply).expect("an error reply"); + assert_eq!( + (error.code, error.description.as_str()), + (405, "method not allowed") + ); + assert_eq!(simulator.value("ui/input_gain"), Some(json!(0.0))); +} + +#[test] +fn simulator_refuses_standby_enabled_set_on_kh750_only() { + let kh750 = Simulator::start(SimModel::Kh750); + let refused = Client::connect(&kh750).ask(&json!({"device":{"standby":{"enabled":false}}})); + assert_eq!(device_error(&refused).unwrap().code, 405); + let kh120 = start_kh120(); + let accepted = Client::connect(&kh120).ask(&json!({"device":{"standby":{"enabled":false}}})); + assert!(device_error(&accepted).is_none()); + assert_eq!(kh120.value("device/standby/enabled"), Some(json!(false))); +} + +#[test] +fn valid_set_is_stored_and_echoed() { + let simulator = start_kh120(); + let mut client = Client::connect(&simulator); + let reply = client.ask(&json!({"audio":{"out":{"level":90}}})); + assert_eq!(reply, json!({"audio":{"out":{"level":90.0}}})); + assert_eq!(simulator.value("audio/out/level"), Some(json!(90.0))); + let wrong_list_length = client.ask(&json!({"audio":{"out":{"eq2":{"enabled":[true]}}}})); + assert_eq!(device_error(&wrong_list_length).unwrap().code, 406); + let wrong_type = client.ask(&json!({"audio":{"out":{"mute":"x"}}})); + assert_eq!(device_error(&wrong_type).unwrap().code, 406); + assert_eq!(simulator.value("audio/out/mute"), Some(json!(false))); +} + +#[test] +fn simulator_schema_root_lists_top_level() { + let simulator = start_kh120(); + let reply = Client::connect(&simulator).ask(&json!({"osc":{"schema":null}})); + let level = &reply["osc"]["schema"][0]; + for container in ["device", "osc", "audio", "m", "ui"] { + assert_eq!(level[container], json!({}), "{container}"); + } + assert_eq!(level["warnings"], Value::Null); + assert_eq!(level.as_object().unwrap().len(), 6); + let unknown = Client::connect(&simulator).ask(&json!({"osc":{"schema":[{"nowhere":null}]}})); + assert_eq!(device_error(&unknown).unwrap().code, 454); +} + +fn count_leaves(client: &mut Client, prefix: &[&str], level: &Value) -> usize { + level + .as_object() + .unwrap() + .iter() + .map(|(name, node)| { + if !node.is_object() { + return 1; + } + let mut path = prefix.to_vec(); + path.push(name); + let request = path.iter().rev().fold( + Value::Null, + |inner, segment| json!({ (segment.to_string()): inner }), + ); + let reply = client.ask(&json!({"osc":{"schema":[request]}})); + let mut child = reply["osc"]["schema"][0].clone(); + for segment in &path { + child = child[segment].clone(); + } + count_leaves(client, &path, &child) + }) + .sum() +} + +#[test] +fn simulator_trees_have_the_measured_leaf_counts() { + for (model, expected) in [ + (SimModel::Kh120Ii, KH_120_II_LEAVES), + (SimModel::Kh750, KH_750_LEAVES), + ] { + let simulator = Simulator::start(model); + let mut client = Client::connect(&simulator); + let root = client.ask(&json!({"osc":{"schema":null}}))["osc"]["schema"][0].clone(); + let mut level = root.as_object().unwrap().clone(); + level.remove("osc"); + assert_eq!( + count_leaves(&mut client, &[], &Value::Object(level)), + expected + ); + } +} + +#[test] +fn simulator_reports_limits_type_and_identity() { + let simulator = Simulator::start(SimModel::Kh750); + let mut client = Client::connect(&simulator); + let number = client.ask(&json!({"osc":{"limits":[{"audio":{"out":{"level":null}}}]}})); + assert_eq!( + number["osc"]["limits"][0]["audio"]["out"]["level"], + json!([{"type":"Number"}]) + ); + let list = client.ask(&json!({"osc":{"limits":[{"m":{"in":{"clip":null}}}]}})); + assert_eq!( + list["osc"]["limits"][0]["m"]["in"]["clip"], + json!([{"type":"Boolean"}]) + ); + let text = client.ask(&json!({"osc":{"limits":[{"device":{"name":null}}]}})); + assert_eq!( + text["osc"]["limits"][0]["device"]["name"], + json!([{"type":"String"}]) + ); + assert_eq!( + simulator.value("device/identity/serial"), + Some(json!("SIM0007500")) + ); + assert_eq!( + simulator.value("device/identity/version"), + Some(json!("2_1_2")) + ); + assert_eq!( + simulator.value("device/identity/hw_version"), + Some(json!(1)) + ); +} + +#[test] +fn requests_are_recorded_parsed() { + let simulator = start_kh120(); + let mut client = Client::connect(&simulator); + let request = json!({"osc":{"xid":1},"audio":{"out":{"mute":null}}}); + client.ask(&request); + assert_eq!(simulator.requests(), vec![request]); +} + +#[test] +fn set_value_and_remove_value_change_the_tree() { + let simulator = start_kh120(); + let mut client = Client::connect(&simulator); + simulator.set_value("audio/out/level", json!(42.5)); + let changed = client.ask(&json!({"audio":{"out":{"level":null}}})); + assert_eq!(changed["audio"]["out"]["level"], json!(42.5)); + simulator.remove_value("audio/out/level"); + let removed = client.ask(&json!({"audio":{"out":{"level":null}}})); + assert_eq!(device_error(&removed).unwrap().code, 404); + assert_eq!(simulator.value("audio/out/level"), None); +} + +#[test] +fn fault_close_connection_ends_stream() { + let simulator = start_kh120(); + let mut client = Client::connect(&simulator); + simulator.inject(Fault::CloseConnection); + client.send(&json!({"audio":{"out":{"mute":null}}})); + assert_eq!(client.read_raw_line(), None); + let mut second = Client::connect(&simulator); + let healthy = second.ask(&json!({"audio":{"out":{"mute":null}}})); + assert_eq!( + healthy["audio"]["out"]["mute"], + json!(false), + "fault was spent" + ); +} + +#[test] +fn fault_stale_line_comes_before_the_real_reply() { + let simulator = start_kh120(); + let mut client = Client::connect(&simulator); + simulator.inject(Fault::StaleLineFirst); + client.send(&json!({"osc":{"xid":5},"audio":{"out":{"mute":null}}})); + let stale = client.read_tree(); + assert_ne!(reply_xid(&stale), Some(5)); + let real = client.read_tree(); + assert_eq!(reply_xid(&real), Some(5)); + assert_eq!(real["audio"]["out"]["mute"], json!(false)); +} + +#[test] +fn fault_foreign_xid_comes_before_the_real_reply() { + let simulator = start_kh120(); + let mut client = Client::connect(&simulator); + simulator.inject(Fault::ForeignXidFirst); + client.send(&json!({"osc":{"xid":5},"audio":{"out":{"mute":null}}})); + assert_eq!(reply_xid(&client.read_tree()), Some(5 + FOREIGN_XID_OFFSET)); + assert_eq!(reply_xid(&client.read_tree()), Some(5)); +} + +#[test] +fn fault_no_xid_echo_drops_the_id_once() { + let simulator = start_kh120(); + let mut client = Client::connect(&simulator); + simulator.inject(Fault::NoXidEcho); + let request = json!({"osc":{"xid":5},"audio":{"out":{"mute":null}}}); + assert_eq!(reply_xid(&client.ask(&request)), None); + assert_eq!(reply_xid(&client.ask(&request)), Some(5)); +} + +#[test] +fn fault_overlong_line_exceeds_the_limit() { + let simulator = start_kh120(); + let mut client = Client::connect(&simulator); + simulator.inject(Fault::OverlongLine); + client.send(&json!({"audio":{"out":{"mute":null}}})); + let mut received = Vec::new(); + client + .reader + .by_ref() + .take(LINE_LIMIT_BYTES as u64 + 1) + .read_to_end(&mut received) + .unwrap(); + assert_eq!(received.len(), LINE_LIMIT_BYTES + 1); + assert!(!received.contains(&b'\n')); +} + +#[test] +fn fault_silence_never_answers_that_request() { + let simulator = start_kh120(); + let mut client = Client::connect(&simulator); + simulator.inject(Fault::Silence); + client.send(&json!({"audio":{"out":{"mute":null}}})); + client + .writer + .set_read_timeout(Some(SILENCE_TIMEOUT)) + .unwrap(); + assert_eq!(client.read_raw_line(), None); + assert_eq!(simulator.requests().len(), 1, "the request was read"); +} + +#[test] +fn stop_closes_connections_and_refuses_new_ones() { + let simulator = start_kh120(); + let address = simulator.address(); + let mut client = Client::connect(&simulator); + client.ask(&json!({"osc":{"ping":null}})); + simulator.stop(); + assert_eq!(client.read_raw_line(), None, "open connection is closed"); + assert!(TcpStream::connect(address).is_err()); +} + +#[test] +fn start_on_rebinds_the_same_address() { + let first = start_kh120(); + let address: SocketAddrV6 = first.address(); + assert_eq!(*address.ip(), Ipv6Addr::LOCALHOST); + first.stop(); + let second = Simulator::start_on(SimModel::Kh120Ii, address).expect("port is free again"); + assert_eq!(second.address(), address); + let reply = Client::connect(&second).ask(&json!({"osc":{"ping":null}})); + assert_eq!(reply, json!({"osc":{"ping":null}})); +} + +#[test] +fn two_connections_are_served_in_parallel() { + let simulator = start_kh120(); + let mut first = Client::connect(&simulator); + let mut second = Client::connect(&simulator); + assert_eq!( + first.ask(&json!({"osc":{"version":null}}))["osc"]["version"], + json!("1.1") + ); + assert_eq!( + second.ask(&json!({"osc":{"version":null}}))["osc"]["version"], + json!("1.1") + ); +} + +#[test] +fn request_split_across_reads_is_reassembled() { + let simulator = start_kh120(); + let mut client = Client::connect(&simulator); + let request = json!({"audio":{"out":{"mute":null}}}).to_string(); + let (first, second) = request.split_at(request.len() / 2); + client.writer.write_all(first.as_bytes()).unwrap(); + std::thread::sleep(PARTIAL_LINE_PAUSE); + client + .writer + .write_all(format!("{second}\r\n").as_bytes()) + .unwrap(); + assert_eq!(client.read_tree()["audio"]["out"]["mute"], json!(false)); +} From bed88343276a16388a3098633c24717bd5f319bc Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:40:52 +0200 Subject: [PATCH 006/460] Add blocking SSC client with xid matching --- crates/core/Cargo.toml | 4 + crates/core/src/ssc/client.rs | 375 ++++++++++++++++++++++++++++++++++ crates/core/src/ssc/mod.rs | 1 + crates/core/tests/client.rs | 192 +++++++++++++++++ 4 files changed, 572 insertions(+) create mode 100644 crates/core/src/ssc/client.rs create mode 100644 crates/core/tests/client.rs diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index bb0a1e3..241b658 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -16,3 +16,7 @@ serde_json = "1" [[test]] name = "simulator" required-features = ["test-support"] + +[[test]] +name = "client" +required-features = ["test-support"] diff --git a/crates/core/src/ssc/client.rs b/crates/core/src/ssc/client.rs new file mode 100644 index 0000000..edc33fd --- /dev/null +++ b/crates/core/src/ssc/client.rs @@ -0,0 +1,375 @@ +//! Blocking SSC v1 client: one TCP connection to one speaker, one request in flight. +//! +//! Replies are matched by the transaction id the client sends with every request. The reader +//! enforces every limit while reading, because the peer is a network device that can send +//! anything; after any breach the connection is closed and the client is single-use. + +use std::fmt; +use std::io::{self, Read, Write}; +use std::net::{Shutdown, SocketAddr, SocketAddrV6, TcpStream}; +use std::time::{Duration, Instant}; + +use serde_json::{Map, Value}; + +use crate::mask::mask_host; +use crate::ssc::wire::{ + device_error, extract, number, parse_line, reply_xid, request_line, DeviceError, SscPath, +}; + +pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(3); +pub const RESPONSE_TIMEOUT: Duration = Duration::from_secs(3); +pub const MAX_LINE_BYTES: usize = 1_048_576; +pub const MAX_RESPONSE_LINES: usize = 256; +pub const MAX_RESPONSE_BYTES: usize = 4 * MAX_LINE_BYTES; +pub const MAX_READ_DURATION: Duration = Duration::from_secs(10); + +const READ_CHUNK_BYTES: usize = 8192; +const LINE_TERMINATOR: &str = "\r\n"; +const FIRST_XID: u64 = 1; +const SCHEMA_PATH: &str = "osc/schema"; +const LIMITS_PATH: &str = "osc/limits"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NodeKind { + Container, + Leaf, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SscError { + Connection(String), + Timeout, + Device(DeviceError), + Protocol(String), +} + +impl SscError { + /// Whether the connection is unusable afterwards and the caller has to reconnect. + pub fn is_connection_lost(&self) -> bool { + !matches!(self, SscError::Device(_)) + } +} + +impl fmt::Display for SscError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SscError::Connection(message) => write!(formatter, "connection error: {message}"), + SscError::Timeout => formatter.write_str("the speaker did not answer in time"), + SscError::Device(error) => write!( + formatter, + "the speaker refused the request ({}): {}", + error.code, error.description + ), + SscError::Protocol(message) => write!(formatter, "protocol error: {message}"), + } + } +} + +impl std::error::Error for SscError {} + +/// The socket plus the bytes read from it but not yet handed out as lines. +struct Connection { + stream: TcpStream, + host: String, + pending: Vec, + scanned: usize, +} + +impl Connection { + fn send_line(&mut self, line: &str) -> Result<(), SscError> { + let message = format!("{line}{LINE_TERMINATOR}"); + self.stream + .set_write_timeout(Some(RESPONSE_TIMEOUT)) + .and_then(|()| self.stream.write_all(message.as_bytes())) + .map_err(|error| self.connection_error("write to", &error)) + } + + /// The next line without its terminator. Never buffers more than one line limit. + fn next_line(&mut self, deadline: Instant) -> Result, SscError> { + loop { + if let Some(line) = self.take_line()? { + return Ok(line); + } + if self.pending.len() > MAX_LINE_BYTES + 1 { + return Err(self.line_too_long()); + } + self.fill(deadline)?; + } + } + + fn take_line(&mut self) -> Result>, SscError> { + let unscanned = &self.pending[self.scanned..]; + let Some(offset) = unscanned.iter().position(|&byte| byte == b'\n') else { + self.scanned = self.pending.len(); + return Ok(None); + }; + let end = self.scanned + offset; + let mut line: Vec = self.pending.drain(..=end).collect(); + self.scanned = 0; + line.pop(); + if line.last() == Some(&b'\r') { + line.pop(); + } + if line.len() > MAX_LINE_BYTES { + return Err(self.line_too_long()); + } + Ok(Some(line)) + } + + fn fill(&mut self, deadline: Instant) -> Result<(), SscError> { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(self.read_duration_exceeded()); + } + let wait = remaining.min(RESPONSE_TIMEOUT); + let mut chunk = [0u8; READ_CHUNK_BYTES]; + let outcome = self + .stream + .set_read_timeout(Some(wait)) + .and_then(|()| self.stream.read(&mut chunk)); + match outcome { + Ok(0) => Err(SscError::Connection(format!( + "{} closed the connection", + self.host + ))), + Ok(count) => { + self.pending.extend_from_slice(&chunk[..count]); + Ok(()) + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => Ok(()), + // A wait shortened to the remaining budget is the read-duration limit, not silence. + Err(error) if is_timeout(&error) && wait < RESPONSE_TIMEOUT => { + Err(self.read_duration_exceeded()) + } + Err(error) if is_timeout(&error) => Err(SscError::Timeout), + Err(error) => Err(self.connection_error("read from", &error)), + } + } + + fn connection_error(&self, action: &str, error: &io::Error) -> SscError { + SscError::Connection(format!("could not {action} {}: {error}", self.host)) + } + + fn line_too_long(&self) -> SscError { + SscError::Protocol(format!( + "{} sent a line over {MAX_LINE_BYTES} bytes", + self.host + )) + } + + fn read_duration_exceeded(&self) -> SscError { + SscError::Protocol(format!( + "{} kept sending for more than {} s", + self.host, + MAX_READ_DURATION.as_secs() + )) + } +} + +fn is_timeout(error: &io::Error) -> bool { + matches!( + error.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut + ) +} + +/// What to do with a line that arrived while waiting for the reply to one request. +enum Verdict { + Reply, + Skip, +} + +pub struct Client { + connection: Connection, + next_xid: u64, + closed: bool, +} + +impl Client { + pub fn connect(address: SocketAddrV6) -> Result { + let host = mask_host(&address); + let stream = TcpStream::connect_timeout(&SocketAddr::V6(address), CONNECT_TIMEOUT) + .map_err(|error| match error.kind() { + io::ErrorKind::TimedOut => SscError::Timeout, + _ => SscError::Connection(format!("could not connect to {host}: {error}")), + })?; + // Requests are single small lines; Nagle's delay would add latency to each of them. + let _ = stream.set_nodelay(true); + Ok(Client { + connection: Connection { + stream, + host, + pending: Vec::new(), + scanned: 0, + }, + next_xid: FIRST_XID, + closed: false, + }) + } + + pub fn is_closed(&self) -> bool { + self.closed + } + + pub fn get(&mut self, path: &SscPath) -> Result { + let reply = self.exchange(path, Value::Null)?; + confirmed_value(&reply, path) + } + + /// Sets a value and returns the value the device confirms (it may clamp). + pub fn set(&mut self, path: &SscPath, value: Value) -> Result { + let reply = self.exchange(path, value)?; + confirmed_value(&reply, path) + } + + /// Like `set` for a number; NaN and infinities have no JSON form and are never sent. + pub fn set_number(&mut self, path: &SscPath, value: f64) -> Result { + let value = number(value).map_err(|error| SscError::Protocol(error.to_string()))?; + self.set(path, value) + } + + /// The children of `path` (the root for `None`) and whether each is a container. + pub fn schema(&mut self, path: Option<&SscPath>) -> Result, SscError> { + let argument = path.map_or(Value::Null, introspection_argument); + let reply = self.exchange(&introspection_path(SCHEMA_PATH), argument)?; + match introspected_node(&reply, SCHEMA_PATH, path)? { + Value::Object(children) => Ok(children + .iter() + .map(|(name, child)| (name.clone(), node_kind(child))) + .collect()), + Value::Null => Ok(Vec::new()), + _ => Err(protocol("schema level is not an object")), + } + } + + /// The limits dict of one leaf. + pub fn limits(&mut self, path: &SscPath) -> Result { + let reply = self.exchange( + &introspection_path(LIMITS_PATH), + introspection_argument(path), + )?; + let node = introspected_node(&reply, LIMITS_PATH, Some(path))?; + let description = match node { + Value::Array(items) => items.first(), + other => Some(other), + }; + match description { + Some(found @ Value::Object(_)) => Ok(found.clone()), + _ => Err(protocol("limits reply holds no description")), + } + } + + fn close(&mut self) { + self.closed = true; + let _ = self.connection.stream.shutdown(Shutdown::Both); + } + + /// One request and its reply tree; a device error in the reply becomes `Err`. + fn exchange(&mut self, path: &SscPath, value: Value) -> Result { + if self.closed { + let host = &self.connection.host; + return Err(SscError::Connection(format!( + "connection to {host} is closed" + ))); + } + let result = self.transact(path, value); + if matches!(&result, Err(error) if error.is_connection_lost()) { + self.close(); + } + result + } + + fn transact(&mut self, path: &SscPath, value: Value) -> Result { + let xid = self.next_xid; + self.next_xid += 1; + self.connection.send_line(&request_line(path, value, xid))?; + let reply = self.read_reply(xid, path)?; + match device_error(&reply) { + Some(error) => Err(SscError::Device(error)), + None => Ok(reply), + } + } + + fn read_reply(&mut self, xid: u64, path: &SscPath) -> Result { + let deadline = Instant::now() + MAX_READ_DURATION; + let mut lines = 0usize; + let mut bytes = 0usize; + loop { + let line = self.connection.next_line(deadline)?; + lines += 1; + bytes += line.len() + LINE_TERMINATOR.len(); + if lines > MAX_RESPONSE_LINES || bytes > MAX_RESPONSE_BYTES { + return Err(protocol("response exceeded the line or byte budget")); + } + if line.is_empty() { + continue; + } + let tree = parse_line(&line).map_err(|error| SscError::Protocol(error.to_string()))?; + if let Verdict::Reply = judge(&tree, xid, path) { + return Ok(tree); + } + } + } +} + +/// A line with our id is the reply, one with another id is stale; a line without an id counts +/// as the reply only when it answers the requested path or carries an error. +fn judge(tree: &Value, xid: u64, path: &SscPath) -> Verdict { + match reply_xid(tree) { + Some(received) if received == xid => Verdict::Reply, + Some(_) => Verdict::Skip, + None if extract(tree, path).is_some() || device_error(tree).is_some() => Verdict::Reply, + None => Verdict::Skip, + } +} + +fn confirmed_value(reply: &Value, path: &SscPath) -> Result { + extract(reply, path) + .cloned() + .ok_or_else(|| protocol("reply without the requested path")) +} + +fn protocol(message: &str) -> SscError { + SscError::Protocol(message.to_owned()) +} + +fn introspection_path(text: &str) -> SscPath { + SscPath::parse(text).expect("constant introspection path is valid") +} + +/// `[ {a:{b:null}} ]`: the list holding one address tree that ends in `null`. +fn introspection_argument(path: &SscPath) -> Value { + let tree = path + .segments() + .iter() + .rev() + .fold(Value::Null, |inner, segment| { + Value::Object(Map::from_iter([(segment.clone(), inner)])) + }); + Value::Array(vec![tree]) +} + +/// The node an `osc/schema` or `osc/limits` reply describes: the first list entry, followed +/// down `path` (the entry itself for the root). +fn introspected_node<'a>( + reply: &'a Value, + method: &str, + path: Option<&SscPath>, +) -> Result<&'a Value, SscError> { + let listed = extract(reply, &introspection_path(method)) + .and_then(Value::as_array) + .and_then(|items| items.first()) + .ok_or_else(|| protocol("reply without the requested path"))?; + let segments = path.map_or(&[][..], SscPath::segments); + segments + .iter() + .try_fold(listed, |node, segment| node.get(segment)) + .ok_or_else(|| protocol("reply does not describe the requested path")) +} + +fn node_kind(node: &Value) -> NodeKind { + match node { + Value::Object(_) => NodeKind::Container, + _ => NodeKind::Leaf, + } +} diff --git a/crates/core/src/ssc/mod.rs b/crates/core/src/ssc/mod.rs index 349da7c..70f7ef1 100644 --- a/crates/core/src/ssc/mod.rs +++ b/crates/core/src/ssc/mod.rs @@ -1 +1,2 @@ +pub mod client; pub mod wire; diff --git a/crates/core/tests/client.rs b/crates/core/tests/client.rs new file mode 100644 index 0000000..ccf7595 --- /dev/null +++ b/crates/core/tests/client.rs @@ -0,0 +1,192 @@ +use std::time::{Duration, Instant}; + +use neumann_control_core::simulator::{Fault, SimModel, Simulator}; +use neumann_control_core::ssc::client::{Client, NodeKind, SscError}; +use neumann_control_core::ssc::wire::SscPath; +use serde_json::{json, Value}; + +const SILENCE_DEADLINE: Duration = Duration::from_millis(3500); +const MISSING_PATH_CODE: i64 = 404; +const READ_ONLY_CODE: i64 = 405; +const WRONG_TYPE_CODE: i64 = 406; + +fn path(text: &str) -> SscPath { + SscPath::parse(text).expect("a valid path") +} + +fn start(model: SimModel) -> (Simulator, Client) { + let simulator = Simulator::start(model); + let client = Client::connect(simulator.address()).expect("simulator accepts"); + (simulator, client) +} + +fn device_code(error: SscError) -> i64 { + match error { + SscError::Device(device) => device.code, + other => panic!("expected a device error, got {other:?}"), + } +} + +#[test] +fn get_returns_value() { + let (_simulator, mut client) = start(SimModel::Kh750); + assert_eq!(client.get(&path("audio/out/level")).unwrap(), json!(100.0)); +} + +#[test] +fn set_returns_confirmed_value() { + let (simulator, mut client) = start(SimModel::Kh750); + let confirmed = client.set(&path("audio/out/mute"), json!(true)).unwrap(); + assert_eq!(confirmed, json!(true)); + assert_eq!(simulator.value("audio/out/mute"), Some(json!(true))); +} + +#[test] +fn missing_path_is_device_error_404() { + let (_simulator, mut client) = start(SimModel::Kh750); + let error = client.get(&path("audio/out5/level")).unwrap_err(); + assert_eq!(device_code(error), MISSING_PATH_CODE); + assert!(!client.is_closed()); +} + +#[test] +fn read_only_set_is_device_error_405() { + let (_simulator, mut client) = start(SimModel::Kh750); + let error = client + .set(&path("device/identity/serial"), json!("X")) + .unwrap_err(); + assert_eq!(device_code(error), READ_ONLY_CODE); +} + +#[test] +fn wrong_type_set_is_device_error_406() { + let (_simulator, mut client) = start(SimModel::Kh750); + let error = client + .set(&path("audio/out/level"), json!("loud")) + .unwrap_err(); + assert_eq!(device_code(error), WRONG_TYPE_CODE); +} + +// A stale line without an id is accepted when it carries the requested path, so the stale +// line of the simulator (audio/out/level) must be skipped because it lacks a different path. +#[test] +fn stale_line_is_skipped_by_xid() { + let (simulator, mut client) = start(SimModel::Kh750); + simulator.inject(Fault::StaleLineFirst); + let mute = client.get(&path("audio/out/mute")).unwrap(); + assert_eq!(mute, json!(false)); +} + +#[test] +fn foreign_xid_line_is_skipped() { + let (simulator, mut client) = start(SimModel::Kh750); + simulator.inject(Fault::ForeignXidFirst); + assert_eq!(client.get(&path("audio/out/level")).unwrap(), json!(100.0)); + assert_eq!(client.get(&path("audio/out/mute")).unwrap(), json!(false)); +} + +#[test] +fn reply_without_xid_is_accepted_when_it_has_the_path() { + let (simulator, mut client) = start(SimModel::Kh750); + simulator.inject(Fault::NoXidEcho); + assert_eq!(client.get(&path("audio/out/level")).unwrap(), json!(100.0)); +} + +#[test] +fn overlong_line_closes_connection() { + let (simulator, mut client) = start(SimModel::Kh750); + simulator.inject(Fault::OverlongLine); + let error = client.get(&path("audio/out/level")).unwrap_err(); + assert!(matches!(error, SscError::Protocol(_)), "got {error:?}"); + assert!(client.is_closed()); +} + +#[test] +fn silent_device_times_out() { + let (simulator, mut client) = start(SimModel::Kh750); + simulator.inject(Fault::Silence); + let started = Instant::now(); + let error = client.get(&path("audio/out/level")).unwrap_err(); + assert!(matches!(error, SscError::Timeout), "got {error:?}"); + assert!(started.elapsed() < SILENCE_DEADLINE); + assert!(client.is_closed()); + assert!(error.is_connection_lost()); +} + +#[test] +fn closed_connection_is_connection_error() { + let (simulator, mut client) = start(SimModel::Kh750); + simulator.inject(Fault::CloseConnection); + let error = client.get(&path("audio/out/level")).unwrap_err(); + assert!(matches!(error, SscError::Connection(_)), "got {error:?}"); + assert!(client.is_closed()); +} + +#[test] +fn closed_client_refuses_further_requests() { + let (simulator, mut client) = start(SimModel::Kh750); + simulator.inject(Fault::CloseConnection); + let _ = client.get(&path("audio/out/level")); + let error = client.get(&path("audio/out/level")).unwrap_err(); + assert!(matches!(error, SscError::Connection(_)), "got {error:?}"); +} + +#[test] +fn error_text_never_shows_the_interface_identifier() { + let (simulator, mut client) = start(SimModel::Kh750); + simulator.inject(Fault::CloseConnection); + let error = client.get(&path("audio/out/level")).unwrap_err(); + let text = error.to_string(); + assert!( + !text.contains(&simulator.address().ip().to_string()), + "{text}" + ); +} + +#[test] +fn schema_root_and_audio_level() { + let (_simulator, mut client) = start(SimModel::Kh750); + let root = client.schema(None).unwrap(); + assert!(root.contains(&("audio".to_owned(), NodeKind::Container))); + let audio = client.schema(Some(&path("audio"))).unwrap(); + assert!(audio.contains(&("out1".to_owned(), NodeKind::Container))); + assert!(audio.contains(&("out2".to_owned(), NodeKind::Container))); + assert!(audio.contains(&("digital_bypass".to_owned(), NodeKind::Leaf))); +} + +#[test] +fn limits_returns_the_dict() { + let (_simulator, mut client) = start(SimModel::Kh750); + let limits = client.limits(&path("audio/out/level")).unwrap(); + assert_eq!(limits["type"], json!("Number")); +} + +#[test] +fn limits_of_unknown_path_is_device_error_454() { + let (_simulator, mut client) = start(SimModel::Kh750); + let error = client.limits(&path("audio/out5/level")).unwrap_err(); + assert_eq!(device_code(error), 454); +} + +#[test] +fn non_finite_set_is_refused_before_sending() { + let (simulator, mut client) = start(SimModel::Kh750); + let before = simulator.requests().len(); + let error = client + .set_number(&path("audio/out/level"), f64::NAN) + .unwrap_err(); + assert!(matches!(error, SscError::Protocol(_)), "got {error:?}"); + let error = client + .set_number(&path("audio/out/level"), f64::INFINITY) + .unwrap_err(); + assert!(matches!(error, SscError::Protocol(_)), "got {error:?}"); + assert_eq!(simulator.requests().len(), before); + assert!(!client.is_closed()); +} + +#[test] +fn set_number_sends_a_finite_number() { + let (_simulator, mut client) = start(SimModel::Kh750); + let confirmed: Value = client.set_number(&path("audio/out/level"), 90.0).unwrap(); + assert_eq!(confirmed, json!(90.0)); +} From 843d2be1d1fdd73da883400ef29d723cb7c7743a Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:44:07 +0200 Subject: [PATCH 007/460] Test the client's response budgets --- crates/core/tests/client.rs | 155 +++++++++++++++++++++++++++++++++++- 1 file changed, 151 insertions(+), 4 deletions(-) diff --git a/crates/core/tests/client.rs b/crates/core/tests/client.rs index ccf7595..0c916d8 100644 --- a/crates/core/tests/client.rs +++ b/crates/core/tests/client.rs @@ -1,7 +1,12 @@ +use std::io::{BufRead, BufReader, Write}; +use std::net::{Ipv6Addr, SocketAddrV6, TcpListener, TcpStream}; +use std::thread; use std::time::{Duration, Instant}; use neumann_control_core::simulator::{Fault, SimModel, Simulator}; -use neumann_control_core::ssc::client::{Client, NodeKind, SscError}; +use neumann_control_core::ssc::client::{ + Client, NodeKind, SscError, MAX_LINE_BYTES, MAX_RESPONSE_BYTES, MAX_RESPONSE_LINES, +}; use neumann_control_core::ssc::wire::SscPath; use serde_json::{json, Value}; @@ -67,10 +72,9 @@ fn wrong_type_set_is_device_error_406() { assert_eq!(device_code(error), WRONG_TYPE_CODE); } -// A stale line without an id is accepted when it carries the requested path, so the stale -// line of the simulator (audio/out/level) must be skipped because it lacks a different path. +// Rule 3: a line without an id counts only if it has the requested path. #[test] -fn stale_line_is_skipped_by_xid() { +fn xidless_line_without_the_path_is_skipped() { let (simulator, mut client) = start(SimModel::Kh750); simulator.inject(Fault::StaleLineFirst); let mute = client.get(&path("audio/out/mute")).unwrap(); @@ -190,3 +194,146 @@ fn set_number_sends_a_finite_number() { let confirmed: Value = client.set_number(&path("audio/out/level"), 90.0).unwrap(); assert_eq!(confirmed, json!(90.0)); } + +const SHORT_LINE: &str = "{\"x\":1} +"; +const BIG_LINE_BYTES: usize = 900 * 1024; +const BIG_LINE_COUNT: usize = 5; +const _: () = assert!(BIG_LINE_BYTES < MAX_LINE_BYTES); +const _: () = assert!(BIG_LINE_BYTES * BIG_LINE_COUNT > MAX_RESPONSE_BYTES); +const FOREIGN_XID: u64 = 999_999; +const TALKING_INTERVAL: Duration = Duration::from_secs(2); +const TALKING_LINES: usize = 8; +const TEN_SECOND_CUT_MIN: Duration = Duration::from_secs(9); +const TEN_SECOND_CUT_MAX: Duration = Duration::from_secs(13); + +/// A one-connection fake speaker: reads the request line, then runs `script` with the +/// request's transaction id. Write errors in the script are ignored (the client hangs up). +fn scripted_speaker(script: impl FnOnce(&mut TcpStream, u64) + Send + 'static) -> Client { + let listener = TcpListener::bind(SocketAddrV6::new(Ipv6Addr::LOCALHOST, 0, 0, 0)).unwrap(); + let address = match listener.local_addr().unwrap() { + std::net::SocketAddr::V6(address) => address, + other => panic!("not IPv6: {other}"), + }; + thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = String::new(); + BufReader::new(stream.try_clone().unwrap()) + .read_line(&mut request) + .unwrap(); + let tree: Value = serde_json::from_str(request.trim()).unwrap(); + let xid = tree["osc"]["xid"].as_u64().unwrap(); + script(&mut stream, xid); + }); + Client::connect(address).expect("fake speaker accepts") +} + +fn level_reply(xid: u64) -> String { + format!( + "{{\"audio\":{{\"out\":{{\"level\":1.0}}}},\"osc\":{{\"xid\":{xid}}}}} +" + ) +} + +fn padded_object(total_content_bytes: usize) -> String { + let overhead = "{\"x\":\"\"}".len(); + format!( + "{{\"x\":\"{}\"}}", + "a".repeat(total_content_bytes - overhead) + ) +} + +fn assert_protocol_and_closed(mut client: Client) { + let error = client.get(&path("audio/out/level")).unwrap_err(); + assert!(matches!(error, SscError::Protocol(_)), "got {error:?}"); + assert!(client.is_closed()); +} + +#[test] +fn too_many_lines_closes_connection() { + let client = scripted_speaker(|stream, _| { + for _ in 0..=MAX_RESPONSE_LINES { + if stream.write_all(SHORT_LINE.as_bytes()).is_err() { + break; + } + } + }); + assert_protocol_and_closed(client); +} + +#[test] +fn too_many_bytes_closes_connection() { + let client = scripted_speaker(|stream, _| { + let line = format!( + "{} +", + padded_object(BIG_LINE_BYTES) + ); + for _ in 0..BIG_LINE_COUNT { + if stream.write_all(line.as_bytes()).is_err() { + break; + } + } + }); + assert_protocol_and_closed(client); +} + +#[test] +fn unterminated_stream_over_line_limit_closes_connection() { + let client = scripted_speaker(|stream, _| { + let chunk = vec![b'a'; MAX_LINE_BYTES + 1024]; + let _ = stream.write_all(&chunk); + }); + assert_protocol_and_closed(client); +} + +// The limit applies to the line without its CR LF: exactly MAX_LINE_BYTES is allowed. +#[test] +fn line_of_exactly_the_limit_is_accepted() { + let mut client = scripted_speaker(|stream, xid| { + let line = format!( + "{} +", + padded_object(MAX_LINE_BYTES) + ); + let _ = stream.write_all(line.as_bytes()); + let _ = stream.write_all(level_reply(xid).as_bytes()); + }); + assert_eq!(client.get(&path("audio/out/level")).unwrap(), json!(1.0)); +} + +#[test] +fn line_one_byte_over_the_limit_closes_connection() { + let client = scripted_speaker(|stream, _| { + let line = format!( + "{} +", + padded_object(MAX_LINE_BYTES + 1) + ); + let _ = stream.write_all(line.as_bytes()); + }); + assert_protocol_and_closed(client); +} + +#[test] +fn device_that_keeps_talking_is_cut_after_ten_seconds() { + let client = scripted_speaker(|stream, _| { + let line = format!( + "{{\"osc\":{{\"xid\":{FOREIGN_XID}}}}} +" + ); + for _ in 0..TALKING_LINES { + if stream.write_all(line.as_bytes()).is_err() { + break; + } + thread::sleep(TALKING_INTERVAL); + } + }); + let started = Instant::now(); + assert_protocol_and_closed(client); + let elapsed = started.elapsed(); + assert!( + (TEN_SECOND_CUT_MIN..TEN_SECOND_CUT_MAX).contains(&elapsed), + "cut after {elapsed:?}" + ); +} From 82124f9ceb847165207f60621990779c0e0c588e Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:48:36 +0200 Subject: [PATCH 008/460] Add the path catalog mirrored from neumann-connect --- Cargo.lock | 77 ++++++ crates/core/Cargo.toml | 1 + crates/core/src/catalog.rs | 321 +++++++++++++++++++++++ crates/core/src/lib.rs | 1 + crates/core/src/paths.toml | 487 +++++++++++++++++++++++++++++++++++ crates/core/tests/catalog.rs | 225 ++++++++++++++++ 6 files changed, 1112 insertions(+) create mode 100644 crates/core/src/catalog.rs create mode 100644 crates/core/src/paths.toml create mode 100644 crates/core/tests/catalog.rs diff --git a/Cargo.lock b/Cargo.lock index fe6028a..cf5d929 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,28 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "itoa" version = "1.0.18" @@ -24,6 +46,7 @@ version = "0.1.0" dependencies = [ "serde", "serde_json", + "toml", ] [[package]] @@ -87,6 +110,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + [[package]] name = "syn" version = "3.0.9" @@ -98,12 +130,57 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + [[package]] name = "unicode-ident" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + [[package]] name = "zmij" version = "1.0.23" diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 241b658..f67384c 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -12,6 +12,7 @@ test-support = [] [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" +toml = "1" [[test]] name = "simulator" diff --git a/crates/core/src/catalog.rs b/crates/core/src/catalog.rs new file mode 100644 index 0000000..99c287b --- /dev/null +++ b/crates/core/src/catalog.rs @@ -0,0 +1,321 @@ +//! The path table: which SSC paths exist, on which models, how they are shown, polled and +//! written. All of it is data in `paths.toml`; this module only loads and applies it. + +use std::collections::HashSet; +use std::fmt; +use std::sync::OnceLock; + +use serde::Deserialize; +use serde_json::Value; + +use crate::ssc::wire::SscPath; + +const EMBEDDED_CATALOG: &str = include_str!("paths.toml"); +const PRINTABLE_ASCII: std::ops::RangeInclusive = ' '..='~'; +const KELVIN_OFFSET: f64 = 273.15; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Group { + Output, + Output1, + Output2, + Input, + Standby, + Device, + RearPanel, + Metering, +} + +impl Group { + pub fn title(&self) -> &'static str { + match self { + Group::Output => "Ausgang", + Group::Output1 => "Ausgang 1", + Group::Output2 => "Ausgang 2", + Group::Input => "Eingang", + Group::Standby => "Standby", + Group::Device => "Gerät", + Group::RearPanel => "Rückseite", + Group::Metering => "Messung", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Kind { + Number, + Switch, + Select, + Text, + Display, + Meter, + Clip, +} + +impl Kind { + fn can_be_written(&self) -> bool { + matches!( + self, + Kind::Number | Kind::Switch | Kind::Select | Kind::Text + ) + } +} + +/// `Once` marks identity values that are read when connecting. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Poll { + Fast, + Slow, + Meter, + Once, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ModelClass { + All, + Monitor, + Subwoofer, + Logo, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Conversion { + KelvinToCelsius, +} + +impl Conversion { + pub fn apply(&self, value: f64) -> f64 { + match self { + Conversion::KelvinToCelsius => value - KELVIN_OFFSET, + } + } +} + +/// Product lists behind the model classes; a monitor is any product that is not a subwoofer. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +pub struct ModelClasses { + pub subwoofer: Vec, + pub logo: Vec, +} + +impl ModelClasses { + pub fn is_subwoofer(&self, product: &str) -> bool { + self.subwoofer.iter().any(|known| known == product) + } + + pub fn contains(&self, class: ModelClass, product: &str) -> bool { + match class { + ModelClass::All => true, + ModelClass::Monitor => !self.is_subwoofer(product), + ModelClass::Subwoofer => self.is_subwoofer(product), + ModelClass::Logo => self.logo.iter().any(|known| known == product), + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum ValueError { + OutOfRange { min: f64, max: f64 }, + NotAnOption, + TooLong(usize), + NotPrintableAscii, + WrongType, +} + +impl fmt::Display for ValueError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ValueError::OutOfRange { min, max } => write!(formatter, "value outside {min}..{max}"), + ValueError::NotAnOption => formatter.write_str("value is not one of the options"), + ValueError::TooLong(limit) => write!(formatter, "text longer than {limit} characters"), + ValueError::NotPrintableAscii => formatter.write_str("text is not printable ASCII"), + ValueError::WrongType => formatter.write_str("value has the wrong type"), + } + } +} + +impl std::error::Error for ValueError {} + +#[derive(Clone, Debug, PartialEq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Entry { + pub path: SscPath, + pub label: String, + pub group: Group, + pub kind: Kind, + pub poll: Poll, + pub class: ModelClass, + #[serde(default)] + pub unit: Option, + #[serde(default)] + pub min: Option, + #[serde(default)] + pub max: Option, + /// Upper bound on subwoofer models, where it differs from `max`. + #[serde(default)] + pub max_subwoofer: Option, + #[serde(default)] + pub step: Option, + #[serde(default)] + pub max_length: Option, + #[serde(default)] + pub options: Vec, + #[serde(default)] + pub writable_on: Vec, + #[serde(default)] + pub conversion: Option, +} + +impl Entry { + pub fn is_writable(&self, product: &str) -> bool { + self.writable_on.iter().any(|known| known == product) + } + + pub fn max_for(&self, product: &str, classes: &ModelClasses) -> Option { + if classes.is_subwoofer(product) && self.max_subwoofer.is_some() { + return self.max_subwoofer; + } + self.max + } + + pub fn validate( + &self, + value: &Value, + product: &str, + classes: &ModelClasses, + ) -> Result<(), ValueError> { + match self.kind { + Kind::Number => self.validate_number(value, self.max_for(product, classes)), + Kind::Switch => value.as_bool().map(|_| ()).ok_or(ValueError::WrongType), + Kind::Select => self.validate_option(value), + Kind::Text => self.validate_text(value), + Kind::Display | Kind::Meter | Kind::Clip => Err(ValueError::WrongType), + } + } + + fn validate_number(&self, value: &Value, max: Option) -> Result<(), ValueError> { + let number = value + .as_f64() + .filter(|candidate| candidate.is_finite()) + .ok_or(ValueError::WrongType)?; + let below = self.min.is_some_and(|min| number < min); + let above = max.is_some_and(|max| number > max); + if below || above { + return Err(ValueError::OutOfRange { + min: self.min.unwrap_or(f64::NEG_INFINITY), + max: max.unwrap_or(f64::INFINITY), + }); + } + Ok(()) + } + + fn validate_option(&self, value: &Value) -> Result<(), ValueError> { + let text = value.as_str().ok_or(ValueError::WrongType)?; + if self.options.iter().any(|option| option == text) { + return Ok(()); + } + Err(ValueError::NotAnOption) + } + + fn validate_text(&self, value: &Value) -> Result<(), ValueError> { + let text = value.as_str().ok_or(ValueError::WrongType)?; + if !text + .chars() + .all(|character| PRINTABLE_ASCII.contains(&character)) + { + return Err(ValueError::NotPrintableAscii); + } + match self.max_length { + Some(limit) if text.len() > limit => Err(ValueError::TooLong(limit)), + _ => Ok(()), + } + } +} + +#[derive(Debug, PartialEq)] +pub enum CatalogError { + Syntax(String), + DuplicatePath(SscPath), + Invalid { path: SscPath, reason: &'static str }, +} + +impl fmt::Display for CatalogError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + CatalogError::Syntax(message) => write!(formatter, "catalog syntax: {message}"), + CatalogError::DuplicatePath(path) => write!(formatter, "catalog lists {path} twice"), + CatalogError::Invalid { path, reason } => write!(formatter, "catalog {path}: {reason}"), + } + } +} + +impl std::error::Error for CatalogError {} + +#[derive(Debug, Deserialize)] +struct CatalogFile { + models: ModelClasses, + entry: Vec, +} + +#[derive(Debug)] +pub struct Catalog { + pub entries: Vec, + pub classes: ModelClasses, +} + +impl Catalog { + /// Panics only when the embedded file is invalid; a test pins that it is not. + pub fn embedded() -> &'static Catalog { + static EMBEDDED: OnceLock = OnceLock::new(); + EMBEDDED.get_or_init(|| Catalog::parse(EMBEDDED_CATALOG).expect("embedded paths.toml")) + } + + pub fn parse(text: &str) -> Result { + let file: CatalogFile = + toml::from_str(text).map_err(|error| CatalogError::Syntax(error.to_string()))?; + let mut seen = HashSet::new(); + for entry in &file.entry { + if !seen.insert(entry.path.clone()) { + return Err(CatalogError::DuplicatePath(entry.path.clone())); + } + check_entry(entry)?; + } + Ok(Catalog { + entries: file.entry, + classes: file.models, + }) + } + + pub fn entries_for(&self, product: &str) -> Vec<&Entry> { + self.entries + .iter() + .filter(|entry| self.classes.contains(entry.class, product)) + .collect() + } +} + +fn check_entry(entry: &Entry) -> Result<(), CatalogError> { + let invalid = |reason| CatalogError::Invalid { + path: entry.path.clone(), + reason, + }; + if !entry.kind.can_be_written() && !entry.writable_on.is_empty() { + return Err(invalid("read-only kind lists writable models")); + } + if entry.kind == Kind::Select && entry.options.is_empty() { + return Err(invalid("select without options")); + } + if entry.kind == Kind::Text && entry.max_length.is_none() { + return Err(invalid("text without max_length")); + } + let reversed = matches!((entry.min, entry.max), (Some(min), Some(max)) if min > max); + if reversed { + return Err(invalid("min above max")); + } + Ok(()) +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 6521557..4fc5910 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,4 +1,5 @@ pub mod address; +pub mod catalog; pub mod mask; #[cfg(feature = "test-support")] pub mod simulator; diff --git a/crates/core/src/paths.toml b/crates/core/src/paths.toml new file mode 100644 index 0000000..7c77523 --- /dev/null +++ b/crates/core/src/paths.toml @@ -0,0 +1,487 @@ +# Path catalog of neumann-control. +# +# This file mirrors custom_components/neumann_kh/const.py of neumann-connect (Home Assistant +# integration), synced from version 1.19.1. Model knowledge lives here and nowhere in code: +# which model has a path (`class`), where it may be written (`writable_on`, exact product +# strings as the device reports them) and how often it is polled (`poll`). A product that is +# not listed in `writable_on` is read-only, so unmeasured models never get a write. +# +# Labels are German UI copy. Out of scope (not listed): EQ, restore, save_settings, out5, +# mixer, mute_persist, update, auth, system, time, macs. + +[models] +# Models with subwoofer features; every other product counts as a monitor. +subwoofer = ["KH 750", "KH 750 DSP"] +# Models with a logo brightness setting. +logo = ["KH 80", "KH 80 DSP", "KH 150", "KH 150 AES67", "KH 120", "KH 120 II", "KH 120 II AES67"] + + +[[entry]] +path = "device/identity/product" +label = "Produkt" +group = "device" +kind = "display" +poll = "once" +class = "all" + +[[entry]] +path = "device/identity/version" +label = "Firmware-Version" +group = "device" +kind = "display" +poll = "once" +class = "all" + +[[entry]] +path = "device/identity/serial" +label = "Seriennummer" +group = "device" +kind = "display" +poll = "once" +class = "all" + +[[entry]] +path = "device/identity/vendor" +label = "Hersteller" +group = "device" +kind = "display" +poll = "once" +class = "all" + +[[entry]] +path = "device/identity/hw_version" +label = "Hardware-Version" +group = "device" +kind = "display" +poll = "slow" +class = "all" + +[[entry]] +path = "audio/out/level" +label = "Pegel" +group = "output" +kind = "number" +poll = "fast" +class = "all" +unit = "dB SPL" +min = 0 +max = 120 +writable_on = ["KH 120 II", "KH 750"] + +[[entry]] +path = "audio/out/delay" +label = "Verzögerung" +group = "output" +kind = "number" +poll = "fast" +class = "all" +unit = "1/48 kHz" +min = 0 +max = 5760 +max_subwoofer = 1000 +step = 1 +writable_on = ["KH 120 II", "KH 750"] + +[[entry]] +path = "audio/out/mute" +label = "Stumm" +group = "output" +kind = "switch" +poll = "fast" +class = "all" +writable_on = ["KH 120 II", "KH 750"] + +[[entry]] +path = "audio/out/phaseinversion" +label = "Phase invertiert" +group = "output" +kind = "switch" +poll = "fast" +class = "monitor" +writable_on = ["KH 120 II"] + +[[entry]] +path = "audio/out/label" +label = "Bezeichnung" +group = "output" +kind = "display" +poll = "slow" +class = "subwoofer" + +[[entry]] +path = "audio/out1/level" +label = "Pegel" +group = "output1" +kind = "number" +poll = "fast" +class = "subwoofer" +unit = "dB SPL" +min = 0 +max = 120 +writable_on = ["KH 750"] + +[[entry]] +path = "audio/out1/delay" +label = "Verzögerung" +group = "output1" +kind = "number" +poll = "fast" +class = "subwoofer" +unit = "1/48 kHz" +min = 0 +max = 1000 +step = 1 +writable_on = ["KH 750"] + +[[entry]] +path = "audio/out1/mute" +label = "Stumm" +group = "output1" +kind = "switch" +poll = "fast" +class = "subwoofer" +writable_on = ["KH 750"] + +[[entry]] +path = "audio/out1/label" +label = "Bezeichnung" +group = "output1" +kind = "display" +poll = "slow" +class = "subwoofer" + +[[entry]] +path = "audio/out1/loudspeaker" +label = "Lautsprecher" +group = "output1" +kind = "display" +poll = "slow" +class = "subwoofer" + +[[entry]] +path = "audio/out2/level" +label = "Pegel" +group = "output2" +kind = "number" +poll = "fast" +class = "subwoofer" +unit = "dB SPL" +min = 0 +max = 120 +writable_on = ["KH 750"] + +[[entry]] +path = "audio/out2/delay" +label = "Verzögerung" +group = "output2" +kind = "number" +poll = "fast" +class = "subwoofer" +unit = "1/48 kHz" +min = 0 +max = 1000 +step = 1 +writable_on = ["KH 750"] + +[[entry]] +path = "audio/out2/mute" +label = "Stumm" +group = "output2" +kind = "switch" +poll = "fast" +class = "subwoofer" +writable_on = ["KH 750"] + +[[entry]] +path = "audio/out2/label" +label = "Bezeichnung" +group = "output2" +kind = "display" +poll = "slow" +class = "subwoofer" + +[[entry]] +path = "audio/out2/loudspeaker" +label = "Lautsprecher" +group = "output2" +kind = "display" +poll = "slow" +class = "subwoofer" + +[[entry]] +path = "audio/in/interface" +label = "Eingangs-Interface" +group = "input" +kind = "select" +poll = "slow" +class = "all" +options = ["ANALOG ONLY", "DIGITAL ONLY", "DIGITAL DISCARDS ANALOG"] +writable_on = ["KH 120 II", "KH 750"] + +[[entry]] +path = "audio/in/current_input" +label = "Aktueller Eingang" +group = "input" +kind = "display" +poll = "slow" +class = "all" + +[[entry]] +path = "device/standby/enabled" +label = "Auto-Standby" +group = "standby" +kind = "switch" +poll = "fast" +class = "all" +writable_on = ["KH 120 II"] + +[[entry]] +path = "device/standby/auto_standby_time" +label = "Standby-Zeit" +group = "standby" +kind = "number" +poll = "fast" +class = "all" +unit = "min" +min = 1 +max = 240 +step = 1 +writable_on = ["KH 120 II"] + +[[entry]] +path = "device/standby/level" +label = "Standby-Pegel" +group = "standby" +kind = "number" +poll = "fast" +class = "all" +unit = "dBu" +min = -80 +max = -55 +step = 1 +writable_on = ["KH 120 II"] + +[[entry]] +path = "device/standby/countdown" +label = "Countdown" +group = "standby" +kind = "display" +poll = "fast" +class = "all" +unit = "min" + +[[entry]] +path = "device/identification/visual" +label = "Identifizieren" +group = "device" +kind = "switch" +poll = "fast" +class = "all" +writable_on = ["KH 120 II", "KH 750"] + +[[entry]] +path = "ui/control_mode" +label = "Steuermodus" +group = "device" +kind = "select" +poll = "fast" +class = "all" +options = ["NETWORK", "LOCAL"] +writable_on = ["KH 120 II", "KH 750"] + +[[entry]] +path = "ui/logo/brightness" +label = "Logo-Helligkeit" +group = "device" +kind = "number" +poll = "fast" +class = "logo" +unit = "%" +min = 0 +max = 125 +step = 1 +writable_on = ["KH 120 II"] + +[[entry]] +path = "device/name" +label = "Name" +group = "device" +kind = "text" +poll = "slow" +class = "all" +max_length = 52 +writable_on = ["KH 120 II", "KH 750"] + +[[entry]] +path = "warnings" +label = "Warnungen" +group = "device" +kind = "display" +poll = "fast" +class = "all" + +[[entry]] +path = "device/temperature" +label = "Temperatur" +group = "device" +kind = "display" +poll = "fast" +class = "subwoofer" +unit = "K" +conversion = "kelvin_to_celsius" + +[[entry]] +path = "ui/input_gain" +label = "Eingangsverstärkung" +group = "rear_panel" +kind = "display" +poll = "slow" +class = "monitor" +unit = "dB" + +[[entry]] +path = "ui/output_level" +label = "Ausgangspegel" +group = "rear_panel" +kind = "display" +poll = "slow" +class = "monitor" +unit = "dB SPL" + +[[entry]] +path = "ui/bass_gain" +label = "Bass" +group = "rear_panel" +kind = "display" +poll = "slow" +class = "monitor" +unit = "dB" + +[[entry]] +path = "ui/mid_gain" +label = "Mitten" +group = "rear_panel" +kind = "display" +poll = "slow" +class = "monitor" +unit = "dB" + +[[entry]] +path = "ui/treble_gain" +label = "Höhen" +group = "rear_panel" +kind = "display" +poll = "slow" +class = "monitor" +unit = "dB" + +[[entry]] +path = "ui/input_select" +label = "Eingangswahl" +group = "rear_panel" +kind = "display" +poll = "slow" +class = "monitor" + +[[entry]] +path = "audio/digital_bypass" +label = "Digital-Bypass" +group = "rear_panel" +kind = "display" +poll = "slow" +class = "subwoofer" + +[[entry]] +path = "ui/bass_management" +label = "Bassmanagement" +group = "rear_panel" +kind = "display" +poll = "slow" +class = "subwoofer" + +[[entry]] +path = "ui/channel_b_input_mode" +label = "Kanal-B-Eingang" +group = "rear_panel" +kind = "display" +poll = "slow" +class = "subwoofer" + +[[entry]] +path = "ui/subwoofer_input_gain" +label = "Eingangsverstärkung" +group = "rear_panel" +kind = "display" +poll = "slow" +class = "subwoofer" +unit = "dB" + +[[entry]] +path = "ui/subwoofer_low_cut" +label = "Low-Cut" +group = "rear_panel" +kind = "display" +poll = "slow" +class = "subwoofer" +unit = "dB" + +[[entry]] +path = "ui/subwoofer_output_level" +label = "Ausgangspegel" +group = "rear_panel" +kind = "display" +poll = "slow" +class = "subwoofer" +unit = "dB SPL" + +[[entry]] +path = "ui/subwoofer_phase" +label = "Phase" +group = "rear_panel" +kind = "display" +poll = "slow" +class = "subwoofer" +unit = "°" + +[[entry]] +path = "ui/subwoofer_phase_inversion" +label = "Phasenumkehr" +group = "rear_panel" +kind = "display" +poll = "slow" +class = "subwoofer" +unit = "°" + +[[entry]] +path = "m/in/level" +label = "Eingangspegel" +group = "metering" +kind = "meter" +poll = "meter" +class = "all" +unit = "dB" + +[[entry]] +path = "m/in/clip" +label = "Eingang übersteuert" +group = "metering" +kind = "clip" +poll = "meter" +class = "all" + +[[entry]] +path = "m/out/level" +label = "Ausgangspegel" +group = "metering" +kind = "meter" +poll = "meter" +class = "subwoofer" +unit = "dB" + +[[entry]] +path = "m/out/clip" +label = "Ausgang übersteuert" +group = "metering" +kind = "clip" +poll = "meter" +class = "subwoofer" diff --git a/crates/core/tests/catalog.rs b/crates/core/tests/catalog.rs new file mode 100644 index 0000000..0855387 --- /dev/null +++ b/crates/core/tests/catalog.rs @@ -0,0 +1,225 @@ +use neumann_control_core::catalog::{Catalog, Entry, ValueError}; +use neumann_control_core::ssc::wire::SscPath; +use serde_json::json; + +const MONITOR: &str = "KH 120 II"; +const SUBWOOFER: &str = "KH 750"; +const UNKNOWN_PRODUCT: &str = "KH 80"; + +fn path(text: &str) -> SscPath { + SscPath::parse(text).unwrap() +} + +fn entry(text: &str) -> &'static Entry { + let wanted = path(text); + Catalog::embedded() + .entries + .iter() + .find(|candidate| candidate.path == wanted) + .unwrap_or_else(|| panic!("{text} is not in the catalog")) +} + +fn has(product: &str, text: &str) -> bool { + let wanted = path(text); + Catalog::embedded() + .entries_for(product) + .iter() + .any(|candidate| candidate.path == wanted) +} + +#[test] +fn embedded_catalog_loads() { + assert!(!Catalog::embedded().entries.is_empty()); +} + +#[test] +fn paths_are_unique() { + let mut seen = std::collections::HashSet::new(); + for candidate in &Catalog::embedded().entries { + assert!(seen.insert(&candidate.path), "{} twice", candidate.path); + } +} + +#[test] +fn writable_models_have_the_path() { + let catalog = Catalog::embedded(); + for candidate in &catalog.entries { + for product in &candidate.writable_on { + assert!( + catalog.entries_for(product).contains(&candidate), + "{} is writable on {product} but not offered there", + candidate.path + ); + } + } +} + +#[test] +fn kh750_has_no_phase_inversion_and_has_out1() { + assert!(!has(SUBWOOFER, "audio/out/phaseinversion")); + assert!(has(SUBWOOFER, "audio/out1/level")); + assert!(has(MONITOR, "audio/out/phaseinversion")); + assert!(!has(MONITOR, "audio/out1/level")); +} + +#[test] +fn kh120ii_has_logo_but_kh750_not() { + assert!(has(MONITOR, "ui/logo/brightness")); + assert!(!has(SUBWOOFER, "ui/logo/brightness")); +} + +#[test] +fn unknown_model_is_read_only() { + for candidate in &Catalog::embedded().entries { + assert!( + !candidate.is_writable(UNKNOWN_PRODUCT), + "{}", + candidate.path + ); + } +} + +#[test] +fn auto_standby_switch_is_writable_on_the_monitor_only() { + let switch = entry("device/standby/enabled"); + assert!(switch.is_writable(MONITOR)); + assert!(!switch.is_writable(SUBWOOFER)); +} + +#[test] +fn level_out_of_range_is_refused() { + let catalog = Catalog::embedded(); + let result = entry("audio/out/level").validate(&json!(120.5), MONITOR, &catalog.classes); + assert_eq!( + result, + Err(ValueError::OutOfRange { + min: 0.0, + max: 120.0 + }) + ); + assert_eq!( + entry("audio/out/level").validate(&json!(120), MONITOR, &catalog.classes), + Ok(()) + ); +} + +#[test] +fn delay_max_depends_on_model() { + let catalog = Catalog::embedded(); + let delay = entry("audio/out/delay"); + assert_eq!(delay.max_for(MONITOR, &catalog.classes), Some(5760.0)); + assert_eq!(delay.max_for(SUBWOOFER, &catalog.classes), Some(1000.0)); + assert_eq!( + delay.validate(&json!(5760), MONITOR, &catalog.classes), + Ok(()) + ); + assert!(delay + .validate(&json!(1001), SUBWOOFER, &catalog.classes) + .is_err()); +} + +#[test] +fn name_with_umlaut_is_refused() { + let catalog = Catalog::embedded(); + let result = + entry("device/name").validate(&json!("Lautsprecher Küche"), MONITOR, &catalog.classes); + assert_eq!(result, Err(ValueError::NotPrintableAscii)); +} + +#[test] +fn name_longer_than_52_is_refused() { + let catalog = Catalog::embedded(); + let name = entry("device/name"); + let longest = "a".repeat(52); + assert_eq!( + name.validate(&json!(longest), MONITOR, &catalog.classes), + Ok(()) + ); + let too_long = "a".repeat(53); + assert_eq!( + name.validate(&json!(too_long), MONITOR, &catalog.classes), + Err(ValueError::TooLong(52)) + ); +} + +#[test] +fn unknown_option_is_refused() { + let catalog = Catalog::embedded(); + let mode = entry("ui/control_mode"); + assert_eq!( + mode.validate(&json!("REMOTE"), MONITOR, &catalog.classes), + Err(ValueError::NotAnOption) + ); + assert_eq!( + mode.validate(&json!("LOCAL"), MONITOR, &catalog.classes), + Ok(()) + ); +} + +#[test] +fn wrong_json_type_is_refused() { + let catalog = Catalog::embedded(); + assert_eq!( + entry("audio/out/mute").validate(&json!(1), MONITOR, &catalog.classes), + Err(ValueError::WrongType) + ); + assert_eq!( + entry("audio/out/level").validate(&json!("loud"), MONITOR, &catalog.classes), + Err(ValueError::WrongType) + ); + assert_eq!( + entry("device/temperature").validate(&json!(300), SUBWOOFER, &catalog.classes), + Err(ValueError::WrongType) + ); +} + +#[test] +fn temperature_is_converted_from_kelvin() { + let temperature = entry("device/temperature"); + assert_eq!(temperature.unit.as_deref(), Some("K")); + assert!(temperature.conversion.is_some()); +} + +#[test] +fn duplicate_path_in_text_is_an_error() { + let text = r#" + [models] + subwoofer = ["KH 750"] + logo = [] + + [[entry]] + path = "audio/out/mute" + label = "Stumm" + group = "output" + kind = "switch" + poll = "fast" + class = "all" + + [[entry]] + path = "audio/out/mute" + label = "Stumm" + group = "output" + kind = "switch" + poll = "fast" + class = "all" + "#; + assert!(Catalog::parse(text).is_err()); +} + +#[test] +fn select_without_options_is_an_error() { + let text = r#" + [models] + subwoofer = [] + logo = [] + + [[entry]] + path = "ui/control_mode" + label = "Steuermodus" + group = "device" + kind = "select" + poll = "fast" + class = "all" + "#; + assert!(Catalog::parse(text).is_err()); +} From 2c38a84b24180ed60cea9b648d9f81bd4186a3a6 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:50:40 +0200 Subject: [PATCH 009/460] Validate catalog steps and tighten tests --- crates/core/src/catalog.rs | 15 +++++++++++++++ crates/core/tests/catalog.rs | 34 ++++++++++++++++++++++++++++++---- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/crates/core/src/catalog.rs b/crates/core/src/catalog.rs index 99c287b..183b25a 100644 --- a/crates/core/src/catalog.rs +++ b/crates/core/src/catalog.rs @@ -12,6 +12,8 @@ use crate::ssc::wire::SscPath; const EMBEDDED_CATALOG: &str = include_str!("paths.toml"); const PRINTABLE_ASCII: std::ops::RangeInclusive = ' '..='~'; +/// Relative to the step size; absorbs binary rounding of decimal steps. +const STEP_TOLERANCE: f64 = 1e-9; const KELVIN_OFFSET: f64 = 273.15; #[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] @@ -122,6 +124,7 @@ impl ModelClasses { pub enum ValueError { OutOfRange { min: f64, max: f64 }, NotAnOption, + NotAStep, TooLong(usize), NotPrintableAscii, WrongType, @@ -132,6 +135,7 @@ impl fmt::Display for ValueError { match self { ValueError::OutOfRange { min, max } => write!(formatter, "value outside {min}..{max}"), ValueError::NotAnOption => formatter.write_str("value is not one of the options"), + ValueError::NotAStep => formatter.write_str("value is not a whole number of steps"), ValueError::TooLong(limit) => write!(formatter, "text longer than {limit} characters"), ValueError::NotPrintableAscii => formatter.write_str("text is not printable ASCII"), ValueError::WrongType => formatter.write_str("value has the wrong type"), @@ -211,6 +215,17 @@ impl Entry { max: max.unwrap_or(f64::INFINITY), }); } + self.validate_step(number) + } + + fn validate_step(&self, number: f64) -> Result<(), ValueError> { + let Some(step) = self.step.filter(|step| *step > 0.0) else { + return Ok(()); + }; + let steps = (number - self.min.unwrap_or(0.0)) / step; + if (steps - steps.round()).abs() > STEP_TOLERANCE { + return Err(ValueError::NotAStep); + } Ok(()) } diff --git a/crates/core/tests/catalog.rs b/crates/core/tests/catalog.rs index 0855387..91a437b 100644 --- a/crates/core/tests/catalog.rs +++ b/crates/core/tests/catalog.rs @@ -1,4 +1,4 @@ -use neumann_control_core::catalog::{Catalog, Entry, ValueError}; +use neumann_control_core::catalog::{Catalog, CatalogError, Conversion, Entry, ValueError}; use neumann_control_core::ssc::wire::SscPath; use serde_json::json; @@ -177,7 +177,9 @@ fn wrong_json_type_is_refused() { fn temperature_is_converted_from_kelvin() { let temperature = entry("device/temperature"); assert_eq!(temperature.unit.as_deref(), Some("K")); - assert!(temperature.conversion.is_some()); + assert_eq!(temperature.conversion, Some(Conversion::KelvinToCelsius)); + let celsius = Conversion::KelvinToCelsius.apply(313.0); + assert!((celsius - 39.85).abs() < 1e-9, "{celsius}"); } #[test] @@ -203,7 +205,12 @@ fn duplicate_path_in_text_is_an_error() { poll = "fast" class = "all" "#; - assert!(Catalog::parse(text).is_err()); + match Catalog::parse(text) { + Err(CatalogError::DuplicatePath(duplicate)) => { + assert_eq!(duplicate, path("audio/out/mute")) + } + other => panic!("expected DuplicatePath, got {other:?}"), + } } #[test] @@ -221,5 +228,24 @@ fn select_without_options_is_an_error() { poll = "fast" class = "all" "#; - assert!(Catalog::parse(text).is_err()); + match Catalog::parse(text) { + Err(CatalogError::Invalid { path: invalid, .. }) => { + assert_eq!(invalid, path("ui/control_mode")) + } + other => panic!("expected Invalid, got {other:?}"), + } +} + +#[test] +fn delay_must_be_whole_steps() { + let catalog = Catalog::embedded(); + let delay = entry("audio/out/delay"); + assert_eq!( + delay.validate(&json!(1.5), MONITOR, &catalog.classes), + Err(ValueError::NotAStep) + ); + assert_eq!( + delay.validate(&json!(2.0), MONITOR, &catalog.classes), + Ok(()) + ); } From cd80295335247da4ff1dd05d28426c594fb60b05 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:59:03 +0200 Subject: [PATCH 010/460] Add speaker worker and schema walk --- Cargo.lock | 7 + crates/core/Cargo.toml | 5 + crates/core/src/lib.rs | 3 + crates/core/src/poll_schedule.rs | 107 ++++++ crates/core/src/schema_walk.rs | 97 ++++++ crates/core/src/speaker.rs | 434 ++++++++++++++++++++++++ crates/core/tests/speaker.rs | 559 +++++++++++++++++++++++++++++++ 7 files changed, 1212 insertions(+) create mode 100644 crates/core/src/poll_schedule.rs create mode 100644 crates/core/src/schema_walk.rs create mode 100644 crates/core/src/speaker.rs create mode 100644 crates/core/tests/speaker.rs diff --git a/Cargo.lock b/Cargo.lock index cf5d929..f95113a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -30,6 +30,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + [[package]] name = "memchr" version = "2.8.3" @@ -44,6 +50,7 @@ version = "0.1.0" name = "neumann-control-core" version = "0.1.0" dependencies = [ + "log", "serde", "serde_json", "toml", diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index f67384c..6005ca9 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -10,6 +10,7 @@ rust-version.workspace = true test-support = [] [dependencies] +log = "0.4" serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "1" @@ -21,3 +22,7 @@ required-features = ["test-support"] [[test]] name = "client" required-features = ["test-support"] + +[[test]] +name = "speaker" +required-features = ["test-support"] diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 4fc5910..3d6f95c 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,6 +1,9 @@ pub mod address; pub mod catalog; pub mod mask; +mod poll_schedule; +pub mod schema_walk; #[cfg(feature = "test-support")] pub mod simulator; +pub mod speaker; pub mod ssc; diff --git a/crates/core/src/poll_schedule.rs b/crates/core/src/poll_schedule.rs new file mode 100644 index 0000000..a913f2c --- /dev/null +++ b/crates/core/src/poll_schedule.rs @@ -0,0 +1,107 @@ +//! When the speaker worker reads which path: meters on their own tick, the fast and slow paths +//! one at a time in turn, so a long round of them never delays the meters. + +use std::time::{Duration, Instant}; + +use crate::catalog::{Entry, Poll}; +use crate::speaker::Timing; +use crate::ssc::wire::SscPath; + +#[derive(Debug, PartialEq)] +pub enum Due { + Meters(Vec), + Path(SscPath), + Nothing, +} + +struct Slot { + path: SscPath, + interval: Duration, + due: Instant, +} + +pub struct Schedule { + meters: Vec, + meter_interval: Duration, + next_meter: Instant, + slots: Vec, + cursor: usize, +} + +impl Schedule { + /// `Once` entries are left out: they are read when connecting only. + pub fn new(entries: &[&Entry], timing: &Timing, now: Instant) -> Schedule { + let mut meters = Vec::new(); + let mut slots = Vec::new(); + for entry in entries { + let interval = match entry.poll { + Poll::Meter => { + meters.push(entry.path.clone()); + continue; + } + Poll::Once => continue, + Poll::Fast => timing.fast, + Poll::Slow => timing.slow, + }; + slots.push(Slot { + path: entry.path.clone(), + interval, + due: now + interval, + }); + } + Schedule { + meters, + meter_interval: timing.meter, + next_meter: now + timing.meter, + slots, + cursor: 0, + } + } + + /// How long until something is due; with nothing left to poll, one meter interval. + pub fn time_until_due(&self, now: Instant) -> Duration { + let meter_due = (!self.meters.is_empty()).then_some(self.next_meter); + let earliest = self + .slots + .iter() + .map(|slot| slot.due) + .chain(meter_due) + .min(); + earliest.map_or(self.meter_interval, |due| { + due.saturating_duration_since(now) + }) + } + + /// The due item, rescheduled from `now`. Meters go first; of the due paths only the next + /// one in turn, so every due path gets its read before any path gets a second one. + pub fn take_due(&mut self, now: Instant) -> Due { + let meters_due = !self.meters.is_empty() && self.next_meter <= now; + if meters_due { + self.next_meter = now + self.meter_interval; + return Due::Meters(self.meters.clone()); + } + let Some(index) = self.next_due_slot(now) else { + return Due::Nothing; + }; + self.cursor = (index + 1) % self.slots.len(); + let slot = &mut self.slots[index]; + slot.due = now + slot.interval; + Due::Path(slot.path.clone()) + } + + /// Stops polling `path`, e.g. because the speaker does not have it. + pub fn forget(&mut self, path: &SscPath) { + self.meters.retain(|meter| meter != path); + self.slots.retain(|slot| &slot.path != path); + if self.cursor >= self.slots.len() { + self.cursor = 0; + } + } + + fn next_due_slot(&self, now: Instant) -> Option { + let count = self.slots.len(); + (0..count) + .map(|offset| (self.cursor + offset) % count) + .find(|&index| self.slots[index].due <= now) + } +} diff --git a/crates/core/src/schema_walk.rs b/crates/core/src/schema_walk.rs new file mode 100644 index 0000000..50d67ad --- /dev/null +++ b/crates/core/src/schema_walk.rs @@ -0,0 +1,97 @@ +//! Reads every value a speaker has by walking `osc/schema`, for the "read everything" view. + +use std::collections::VecDeque; + +use serde_json::Value; + +use crate::ssc::client::{Client, NodeKind, SscError}; +use crate::ssc::wire::SscPath; + +/// Bounds the walk, because the tree comes from a network device that can describe anything. +pub const MAX_SCHEMA_NODES: usize = 500; +pub const MAX_SCHEMA_DEPTH: usize = 10; + +/// Holds the protocol's own methods (`osc/schema`, `osc/limits`, ...), not device values. +const PROTOCOL_CONTAINER: &str = "osc"; +const PATH_SEPARATOR: char = '/'; + +#[derive(Clone, Debug, PartialEq)] +pub struct LeafReport { + pub path: SscPath, + /// The value, or the device's refusal as text. + pub value: Result, + /// `None` when the device has no limits for the leaf. + pub limits: Option, +} + +/// Every leaf breadth-first from the root with its value and limits. A device error on one +/// leaf stays in that leaf's report; a lost connection or a tree over the bounds fails it all. +pub fn read_all(client: &mut Client) -> Result, SscError> { + leaf_paths(client)? + .into_iter() + .map(|path| report(client, path)) + .collect() +} + +fn leaf_paths(client: &mut Client) -> Result, SscError> { + let mut leaves = Vec::new(); + let mut containers: VecDeque> = VecDeque::from([None]); + let mut visited = 0usize; + while let Some(container) = containers.pop_front() { + for (name, kind) in client.schema(container.as_ref())? { + if container.is_none() && name == PROTOCOL_CONTAINER { + continue; + } + visited += 1; + if visited > MAX_SCHEMA_NODES { + return Err(SscError::Protocol(format!( + "schema has more than {MAX_SCHEMA_NODES} nodes" + ))); + } + let path = child_path(container.as_ref(), &name)?; + let depth = path.segments().len(); + match kind { + NodeKind::Leaf => leaves.push(path), + NodeKind::Container if depth >= MAX_SCHEMA_DEPTH => { + return Err(SscError::Protocol(format!( + "schema is deeper than {MAX_SCHEMA_DEPTH} levels" + ))); + } + NodeKind::Container => containers.push_back(Some(path)), + } + } + } + Ok(leaves) +} + +fn report(client: &mut Client, path: SscPath) -> Result { + let value = match client.get(&path) { + Ok(value) => Ok(value), + Err(refusal @ SscError::Device(_)) => Err(refusal.to_string()), + Err(lost) => return Err(lost), + }; + let limits = match client.limits(&path) { + Ok(limits) => Some(limits), + Err(SscError::Device(_)) => None, + Err(lost) => return Err(lost), + }; + Ok(LeafReport { + path, + value, + limits, + }) +} + +/// A node name holding the separator would silently become several segments. +fn child_path(parent: Option<&SscPath>, name: &str) -> Result { + if name.contains(PATH_SEPARATOR) { + return Err(SscError::Protocol(format!( + "schema names a node with a '{PATH_SEPARATOR}'" + ))); + } + let text = match parent { + Some(parent) => format!("{parent}{PATH_SEPARATOR}{name}"), + None => name.to_owned(), + }; + SscPath::parse(&text).map_err(|error| SscError::Protocol(error.to_string())) +} diff --git a/crates/core/src/speaker.rs b/crates/core/src/speaker.rs new file mode 100644 index 0000000..59687c5 --- /dev/null +++ b/crates/core/src/speaker.rs @@ -0,0 +1,434 @@ +//! The worker that owns the connection to one speaker: one thread per speaker, commands from +//! the app before polling, and everything it learns sent back as events. + +use std::collections::HashSet; +use std::net::SocketAddrV6; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; +use std::sync::Arc; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use serde_json::Value; + +use crate::catalog::{Catalog, Entry}; +use crate::mask::{mask_host, mask_serial}; +use crate::poll_schedule::{Due, Schedule}; +use crate::schema_walk::{self, LeafReport}; +use crate::ssc::client::{Client, SscError}; +use crate::ssc::wire::{DeviceError, SscPath}; + +const DEFAULT_METER_INTERVAL: Duration = Duration::from_millis(100); +const DEFAULT_FAST_INTERVAL: Duration = Duration::from_secs(2); +const DEFAULT_SLOW_INTERVAL: Duration = Duration::from_secs(30); +const DEFAULT_RECONNECT_INTERVAL: Duration = Duration::from_secs(10); + +const PRODUCT_PATH: &str = "device/identity/product"; +const VERSION_PATH: &str = "device/identity/version"; +const SERIAL_PATH: &str = "device/identity/serial"; +const NAME_PATH: &str = "device/name"; + +const NOT_FOUND_CODE: i64 = 404; +const NOT_CONNECTED_MESSAGE: &str = "the speaker is not connected"; +const THREAD_NAME: &str = "speaker"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Target { + pub address: SocketAddrV6, + /// When set, a speaker reporting another serial is not used. + pub expected_serial: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Timing { + pub meter: Duration, + pub fast: Duration, + pub slow: Duration, + pub reconnect: Duration, +} + +impl Default for Timing { + fn default() -> Timing { + Timing { + meter: DEFAULT_METER_INTERVAL, + fast: DEFAULT_FAST_INTERVAL, + slow: DEFAULT_SLOW_INTERVAL, + reconnect: DEFAULT_RECONNECT_INTERVAL, + } + } +} + +/// The value of a `Set` is checked against the catalog by the app before it is sent. +#[derive(Clone, Debug, PartialEq)] +pub enum Command { + Set { path: SscPath, value: Value }, + ReadAll, + Stop, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Identity { + pub product: String, + pub version: String, + pub serial: String, + pub name: String, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Event { + Connected(Identity), + /// Sent once per outage; the text is the masked error. + Unreachable(String), + Value { + path: SscPath, + value: Value, + }, + /// The speaker does not have the path; it is not polled again on this connection. + Absent(SscPath), + /// The speaker refused the write; a `Value` with the unchanged value follows. + WriteRejected { + path: SscPath, + error: DeviceError, + }, + /// The write did not reach the speaker or its answer got lost. + WriteFailed { + path: SscPath, + message: String, + }, + SerialMismatch { + expected: String, + found: String, + }, + AllValues(Vec), + AllValuesFailed(String), +} + +pub struct SpeakerHandle { + commands: Sender, + thread: Option>, +} + +impl SpeakerHandle { + pub fn send(&self, command: Command) { + // After Stop or a panic the worker is gone and the command has no one to run it; + // Drop logs a panic when joining. + let _ = self.commands.send(command); + } +} + +impl Drop for SpeakerHandle { + fn drop(&mut self) { + let _ = self.commands.send(Command::Stop); + let Some(thread) = self.thread.take() else { + return; + }; + if thread.join().is_err() { + log::error!("speaker worker panicked"); + } + } +} + +/// Starts the worker thread. `wake` runs after every event, e.g. to repaint the UI. +pub fn spawn( + target: Target, + timing: Timing, + wake: Arc, +) -> (SpeakerHandle, Receiver) { + let (command_sender, commands) = mpsc::channel(); + let (events, event_receiver) = mpsc::channel(); + let worker = Worker { + host: mask_host(&target.address), + target, + timing, + commands, + outbox: Outbox { events, wake }, + reported: None, + }; + let thread = thread::Builder::new() + .name(THREAD_NAME.to_owned()) + .spawn(move || worker.run()) + .expect("the operating system starts a thread"); + let handle = SpeakerHandle { + commands: command_sender, + thread: Some(thread), + }; + (handle, event_receiver) +} + +struct Outbox { + events: Sender, + wake: Arc, +} + +impl Outbox { + fn emit(&self, event: Event) { + // A dropped receiver means the app is closing; the handle's Drop stops this thread. + let _ = self.events.send(event); + (self.wake)(); + } +} + +/// Why the worker is disconnected, so each reason is reported once and not on every retry. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Disconnect { + Unreachable, + SerialMismatch, +} + +/// Why a connected session ended. +enum Interruption { + Stopped, + Lost(SscError), +} + +struct Stopped; + +/// The state of one connection; a reconnect starts a new one. +struct Session { + client: Client, + schedule: Schedule, + absent: HashSet, +} + +struct Worker { + target: Target, + host: String, + timing: Timing, + commands: Receiver, + outbox: Outbox, + reported: Option, +} + +impl Worker { + fn run(mut self) { + loop { + if self.connect_and_serve().is_err() { + return; + } + if self.wait_disconnected(self.timing.reconnect).is_err() { + return; + } + } + } + + /// One connection attempt and, if the speaker is the expected one, the session on it. + fn connect_and_serve(&mut self) -> Result<(), Stopped> { + let connected = Client::connect(self.target.address).and_then(|mut client| { + let identity = read_identity(&mut client)?; + Ok((client, identity)) + }); + let (client, identity) = match connected { + Ok(connected) => connected, + Err(error) => { + self.report_unreachable(&error); + return Ok(()); + } + }; + if let Some(expected) = self.mismatched_serial(&identity) { + self.report_mismatch(expected, identity.serial); + return Ok(()); + } + log::info!("connected to {}", self.host); + self.reported = None; + let entries = Catalog::embedded().entries_for(&identity.product); + self.outbox.emit(Event::Connected(identity)); + let mut session = Session { + client, + schedule: Schedule::new(&entries, &self.timing, Instant::now()), + absent: HashSet::new(), + }; + match self.serve(&mut session, &entries) { + Interruption::Stopped => Err(Stopped), + Interruption::Lost(error) => { + self.report_unreachable(&error); + Ok(()) + } + } + } + + fn serve(&self, session: &mut Session, entries: &[&Entry]) -> Interruption { + for entry in entries { + if let Err(interruption) = self.read(session, &entry.path) { + return interruption; + } + } + loop { + if let Err(interruption) = self.step(session) { + return interruption; + } + } + } + + /// A pending command, or else what the schedule has due; waits for whichever comes first. + fn step(&self, session: &mut Session) -> Result<(), Interruption> { + let wait = session.schedule.time_until_due(Instant::now()); + match self.commands.recv_timeout(wait) { + Ok(command) => return self.execute(session, command), + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => return Err(Interruption::Stopped), + } + match session.schedule.take_due(Instant::now()) { + Due::Meters(paths) => paths.iter().try_for_each(|path| self.read(session, path)), + Due::Path(path) => self.read(session, &path), + Due::Nothing => Ok(()), + } + } + + fn execute(&self, session: &mut Session, command: Command) -> Result<(), Interruption> { + match command { + Command::Set { path, value } => self.write(session, path, value), + Command::ReadAll => self.read_all(session), + Command::Stop => Err(Interruption::Stopped), + } + } + + fn read(&self, session: &mut Session, path: &SscPath) -> Result<(), Interruption> { + match session.client.get(path) { + Ok(value) => self.outbox.emit(Event::Value { + path: path.clone(), + value, + }), + Err(SscError::Device(error)) if error.code == NOT_FOUND_CODE => { + self.mark_absent(session, path); + } + // Nothing the user could act on; the next poll asks again. + Err(SscError::Device(error)) => log::warn!( + "{} refused to read {path} ({}): {}", + self.host, + error.code, + error.description + ), + Err(lost) => return Err(Interruption::Lost(lost)), + } + Ok(()) + } + + fn write( + &self, + session: &mut Session, + path: SscPath, + value: Value, + ) -> Result<(), Interruption> { + let error = match session.client.set(&path, value) { + Ok(confirmed) => { + self.outbox.emit(Event::Value { + path, + value: confirmed, + }); + return Ok(()); + } + Err(error) => error, + }; + match error { + SscError::Device(error) if error.code == NOT_FOUND_CODE => { + self.mark_absent(session, &path); + Ok(()) + } + // The device kept its old value (read-only, wrong type, ...); show that one again. + SscError::Device(error) => { + self.outbox.emit(Event::WriteRejected { + path: path.clone(), + error, + }); + self.read(session, &path) + } + lost => { + self.outbox.emit(Event::WriteFailed { + path, + message: lost.to_string(), + }); + Err(Interruption::Lost(lost)) + } + } + } + + fn read_all(&self, session: &mut Session) -> Result<(), Interruption> { + let error = match schema_walk::read_all(&mut session.client) { + Ok(reports) => { + self.outbox.emit(Event::AllValues(reports)); + return Ok(()); + } + Err(error) => error, + }; + self.outbox.emit(Event::AllValuesFailed(error.to_string())); + if error.is_connection_lost() { + return Err(Interruption::Lost(error)); + } + Ok(()) + } + + fn mark_absent(&self, session: &mut Session, path: &SscPath) { + if !session.absent.insert(path.clone()) { + return; + } + session.schedule.forget(path); + self.outbox.emit(Event::Absent(path.clone())); + } + + /// Waits between connection attempts, answering commands at once since none can run. + fn wait_disconnected(&self, duration: Duration) -> Result<(), Stopped> { + let deadline = Instant::now() + duration; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + match self.commands.recv_timeout(remaining) { + Ok(Command::Set { path, .. }) => self.outbox.emit(Event::WriteFailed { + path, + message: NOT_CONNECTED_MESSAGE.to_owned(), + }), + Ok(Command::ReadAll) => self + .outbox + .emit(Event::AllValuesFailed(NOT_CONNECTED_MESSAGE.to_owned())), + Ok(Command::Stop) | Err(RecvTimeoutError::Disconnected) => return Err(Stopped), + Err(RecvTimeoutError::Timeout) => return Ok(()), + } + } + } + + fn mismatched_serial(&self, identity: &Identity) -> Option { + self.target + .expected_serial + .clone() + .filter(|expected| *expected != identity.serial) + } + + fn report_unreachable(&mut self, error: &SscError) { + if self.reported == Some(Disconnect::Unreachable) { + return; + } + self.reported = Some(Disconnect::Unreachable); + let reason = error.to_string(); + log::info!("{} is unreachable: {reason}", self.host); + self.outbox.emit(Event::Unreachable(reason)); + } + + fn report_mismatch(&mut self, expected: String, found: String) { + if self.reported == Some(Disconnect::SerialMismatch) { + return; + } + self.reported = Some(Disconnect::SerialMismatch); + log::info!( + "{} has serial {}, expected {}", + self.host, + mask_serial(&found), + mask_serial(&expected) + ); + self.outbox.emit(Event::SerialMismatch { expected, found }); + } +} + +fn read_identity(client: &mut Client) -> Result { + Ok(Identity { + product: read_text(client, PRODUCT_PATH)?, + version: read_text(client, VERSION_PATH)?, + serial: read_text(client, SERIAL_PATH)?, + name: read_text(client, NAME_PATH)?, + }) +} + +/// A string value as it is; any other value in its JSON form. +fn read_text(client: &mut Client, path: &str) -> Result { + let path = SscPath::parse(path).expect("constant identity path is valid"); + match client.get(&path)? { + Value::String(text) => Ok(text), + other => Ok(other.to_string()), + } +} diff --git a/crates/core/tests/speaker.rs b/crates/core/tests/speaker.rs new file mode 100644 index 0000000..f0ac5f9 --- /dev/null +++ b/crates/core/tests/speaker.rs @@ -0,0 +1,559 @@ +use std::net::{Ipv6Addr, SocketAddrV6, TcpListener}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::mpsc::{Receiver, RecvTimeoutError}; +use std::sync::Arc; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use neumann_control_core::catalog::Catalog; +use neumann_control_core::schema_walk::read_all; +use neumann_control_core::simulator::{Fault, SimModel, Simulator}; +use neumann_control_core::speaker::{ + spawn, Command, Event, Identity, SpeakerHandle, Target, Timing, +}; +use neumann_control_core::ssc::client::Client; +use neumann_control_core::ssc::wire::SscPath; +use serde_json::{json, Value}; + +const EVENT_DEADLINE: Duration = Duration::from_secs(5); +// A refused loopback connect takes about 2 s on Windows, so a restart needs a few of them. +const RECOVERY_DEADLINE: Duration = Duration::from_secs(15); +const REQUEST_POLL_INTERVAL: Duration = Duration::from_millis(10); +const QUIET_PERIOD: Duration = Duration::from_millis(300); +const STOP_DEADLINE: Duration = Duration::from_secs(1); +const LONG_INTERVAL: Duration = Duration::from_secs(60); +const READ_ONLY_CODE: i64 = 405; +const KH_120_II_LEAVES: usize = 50; +const KH_120_II_PRODUCT: &str = "KH 120 II"; +const KH_120_II_SERIAL: &str = "SIM0001234"; + +fn path(text: &str) -> SscPath { + SscPath::parse(text).expect("a valid path") +} + +fn short_timing() -> Timing { + Timing { + meter: Duration::from_millis(20), + fast: Duration::from_millis(50), + slow: Duration::from_millis(200), + reconnect: Duration::from_millis(100), + } +} + +fn quiet_timing() -> Timing { + Timing { + meter: LONG_INTERVAL, + fast: LONG_INTERVAL, + slow: LONG_INTERVAL, + reconnect: Duration::from_millis(100), + } +} + +fn no_wake() -> Arc { + Arc::new(|| {}) +} + +fn start_worker( + address: SocketAddrV6, + expected_serial: Option<&str>, + timing: Timing, +) -> (SpeakerHandle, Receiver) { + let target = Target { + address, + expected_serial: expected_serial.map(str::to_owned), + }; + spawn(target, timing, no_wake()) +} + +/// Every event up to and including the first that matches; panics when none comes in time. +fn collect_until( + events: &Receiver, + deadline: Duration, + matches: impl Fn(&Event) -> bool, +) -> Vec { + let end = Instant::now() + deadline; + let mut seen = Vec::new(); + loop { + let remaining = end.saturating_duration_since(Instant::now()); + match events.recv_timeout(remaining) { + Ok(event) => { + let found = matches(&event); + seen.push(event); + if found { + return seen; + } + } + Err(error) => panic!("no matching event ({error:?}); seen: {seen:?}"), + } + } +} + +fn is_connected(event: &Event) -> bool { + matches!(event, Event::Connected(_)) +} + +fn is_unreachable(event: &Event) -> bool { + matches!(event, Event::Unreachable(_)) +} + +fn is_value_of(event: &Event, wanted: &SscPath) -> bool { + matches!(event, Event::Value { path, .. } if path == wanted) +} + +/// Whether a parsed request names `wanted`, read or write. +fn request_names(request: &Value, wanted: &SscPath) -> bool { + wanted + .segments() + .iter() + .try_fold(request, |node, segment| node.get(segment)) + .is_some() +} + +fn count_requests(simulator: &Simulator, wanted: &SscPath) -> usize { + simulator + .requests() + .iter() + .filter(|request| request_names(request, wanted)) + .count() +} + +fn wait_for_requests(simulator: &Simulator, wanted: &SscPath, count: usize, deadline: Duration) { + let end = Instant::now() + deadline; + while count_requests(simulator, wanted) < count { + assert!( + Instant::now() < end, + "fewer than {count} requests for {wanted}" + ); + thread::sleep(REQUEST_POLL_INTERVAL); + } +} + +/// The last path the worker reads when connecting: once its value arrives, polling has begun. +fn last_initial_path(product: &str) -> SscPath { + Catalog::embedded() + .entries_for(product) + .last() + .expect("the catalog lists entries") + .path + .clone() +} + +/// Accepts connections and closes them at once: a peer that is there but never answers a +/// request, so every attempt fails fast. +struct ClosingListener { + address: SocketAddrV6, + accepted: Arc, + stopping: Arc, + thread: Option>, +} + +impl ClosingListener { + fn start() -> ClosingListener { + let listener = TcpListener::bind(SocketAddrV6::new(Ipv6Addr::LOCALHOST, 0, 0, 0)) + .expect("IPv6 loopback is available"); + let std::net::SocketAddr::V6(address) = listener.local_addr().expect("a bound address") + else { + panic!("listener is not IPv6"); + }; + let accepted = Arc::new(AtomicUsize::new(0)); + let stopping = Arc::new(AtomicBool::new(false)); + let thread = thread::spawn({ + let accepted = Arc::clone(&accepted); + let stopping = Arc::clone(&stopping); + move || { + for incoming in listener.incoming() { + if stopping.load(Ordering::SeqCst) { + break; + } + drop(incoming); + accepted.fetch_add(1, Ordering::SeqCst); + } + } + }); + ClosingListener { + address, + accepted, + stopping, + thread: Some(thread), + } + } + + fn wait_for_accepts(&self, count: usize) { + let end = Instant::now() + EVENT_DEADLINE; + while self.accepted.load(Ordering::SeqCst) < count { + assert!( + Instant::now() < end, + "fewer than {count} connection attempts" + ); + thread::sleep(REQUEST_POLL_INTERVAL); + } + } +} + +impl Drop for ClosingListener { + fn drop(&mut self) { + self.stopping.store(true, Ordering::SeqCst); + let _ = std::net::TcpStream::connect(self.address); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +#[test] +fn worker_reports_identity_then_values() { + let simulator = Simulator::start(SimModel::Kh120Ii); + let woken = Arc::new(AtomicUsize::new(0)); + let wake: Arc = Arc::new({ + let woken = Arc::clone(&woken); + move || { + woken.fetch_add(1, Ordering::SeqCst); + } + }); + let target = Target { + address: simulator.address(), + expected_serial: Some(KH_120_II_SERIAL.to_owned()), + }; + let (_handle, events) = spawn(target, short_timing(), wake); + + let first = events.recv_timeout(EVENT_DEADLINE).expect("an event"); + assert_eq!( + first, + Event::Connected(Identity { + product: KH_120_II_PRODUCT.to_owned(), + version: "1_7_3".to_owned(), + serial: KH_120_II_SERIAL.to_owned(), + name: "Simulated KH 120 II".to_owned(), + }) + ); + let level = path("audio/out/level"); + let seen = collect_until(&events, EVENT_DEADLINE, |event| is_value_of(event, &level)); + let expected = Event::Value { + path: level, + value: simulator + .value("audio/out/level") + .expect("the sim has a level"), + }; + assert_eq!(seen.last(), Some(&expected)); + assert!(woken.load(Ordering::SeqCst) > seen.len()); +} + +#[test] +fn set_command_emits_confirmed_value() { + let simulator = Simulator::start(SimModel::Kh120Ii); + let (handle, events) = start_worker(simulator.address(), None, short_timing()); + collect_until(&events, EVENT_DEADLINE, is_connected); + + let mute = path("audio/out/mute"); + handle.send(Command::Set { + path: mute.clone(), + value: json!(true), + }); + collect_until(&events, EVENT_DEADLINE, |event| { + *event + == Event::Value { + path: mute.clone(), + value: json!(true), + } + }); + assert_eq!(simulator.value("audio/out/mute"), Some(json!(true))); +} + +#[test] +fn read_only_set_emits_rejection_and_reread() { + let simulator = Simulator::start(SimModel::Kh750); + let (handle, events) = start_worker(simulator.address(), None, quiet_timing()); + collect_until(&events, EVENT_DEADLINE, is_connected); + + let standby = path("device/standby/enabled"); + let current = simulator + .value("device/standby/enabled") + .expect("the sim has the path"); + let refused = Value::Bool(!current.as_bool().expect("a switch")); + handle.send(Command::Set { + path: standby.clone(), + value: refused, + }); + let seen = collect_until(&events, EVENT_DEADLINE, |event| { + matches!(event, Event::WriteRejected { .. }) + }); + let Some(Event::WriteRejected { path, error }) = seen.last() else { + unreachable!("collect_until returns the match last"); + }; + assert_eq!(path, &standby); + assert_eq!(error.code, READ_ONLY_CODE); + + let reread = events.recv_timeout(EVENT_DEADLINE).expect("the re-read"); + assert_eq!( + reread, + Event::Value { + path: standby, + value: current, + } + ); +} + +#[test] +fn absent_path_is_reported_once_and_not_polled_again() { + let simulator = Simulator::start(SimModel::Kh120Ii); + simulator.remove_value("audio/out/mute"); + let (_handle, events) = start_worker(simulator.address(), None, short_timing()); + + let mute = path("audio/out/mute"); + let mut seen = collect_until(&events, EVENT_DEADLINE, |event| { + *event == Event::Absent(mute.clone()) + }); + // Another fast path read three more times proves several fast rounds went by. + let level = path("audio/out/level"); + for _ in 0..3 { + seen.extend(collect_until(&events, EVENT_DEADLINE, |event| { + is_value_of(event, &level) + })); + } + + let absent_reports = seen + .iter() + .filter(|event| **event == Event::Absent(mute.clone())) + .count(); + assert_eq!(absent_reports, 1); + assert_eq!(count_requests(&simulator, &mute), 1); +} + +#[test] +fn commands_run_before_polling() { + let simulator = Simulator::start(SimModel::Kh120Ii); + let busy = Timing { + meter: LONG_INTERVAL, + fast: Duration::from_millis(1), + slow: Duration::from_millis(1), + reconnect: Duration::from_millis(100), + }; + let (handle, events) = start_worker(simulator.address(), None, busy); + // A fast path read twice means the connect-time read is over and polling runs nonstop. + let level = path("audio/out/level"); + collect_until(&events, EVENT_DEADLINE, |event| is_value_of(event, &level)); + collect_until(&events, EVENT_DEADLINE, |event| is_value_of(event, &level)); + + let before = simulator.requests().len(); + handle.send(Command::Set { + path: level.clone(), + value: json!(90.0), + }); + collect_until(&events, EVENT_DEADLINE, |event| { + *event + == Event::Value { + path: level.clone(), + value: json!(90.0), + } + }); + + let requests = simulator.requests(); + let set_index = requests + .iter() + .position(|request| request["audio"]["out"]["level"] == json!(90.0)) + .expect("the set reached the simulator"); + // At most the one poll that was already under way may come first. + assert!( + set_index <= before + 1, + "set was request {set_index}, {before} requests before it was sent" + ); +} + +#[test] +fn meters_are_polled_between_slow_paths() { + let simulator = Simulator::start(SimModel::Kh120Ii); + let busy = Timing { + meter: Duration::from_millis(50), + fast: Duration::from_millis(1), + slow: Duration::from_millis(1), + reconnect: Duration::from_millis(100), + }; + let (_handle, events) = start_worker(simulator.address(), None, busy); + let last = last_initial_path(KH_120_II_PRODUCT); + collect_until(&events, EVENT_DEADLINE, |event| is_value_of(event, &last)); + + let meter = path("m/in/level"); + let slow = path("device/name"); + let meters_before = count_requests(&simulator, &meter); + let slow_before = count_requests(&simulator, &slow); + let end = Instant::now() + Duration::from_secs(1); + while Instant::now() < end && count_requests(&simulator, &meter) < meters_before + 10 { + thread::sleep(REQUEST_POLL_INTERVAL); + } + + assert!(count_requests(&simulator, &meter) >= meters_before + 10); + assert!(count_requests(&simulator, &slow) > slow_before); +} + +#[test] +fn worker_recovers_after_simulator_restart() { + let simulator = Simulator::start(SimModel::Kh120Ii); + let address = simulator.address(); + let (_handle, events) = start_worker(address, None, short_timing()); + collect_until(&events, EVENT_DEADLINE, is_connected); + + simulator.stop(); + let seen = collect_until(&events, EVENT_DEADLINE, is_unreachable); + let Some(Event::Unreachable(reason)) = seen.last() else { + unreachable!("collect_until returns the match last"); + }; + assert!(!reason.contains(&address.ip().to_string())); + + let _restarted = Simulator::start_on(SimModel::Kh120Ii, address).expect("the port is free"); + let seen = collect_until(&events, RECOVERY_DEADLINE, is_connected); + assert!(!seen.iter().any(is_unreachable), "outage reported twice"); +} + +#[test] +fn unreachable_is_reported_once_per_outage() { + let listener = ClosingListener::start(); + let (_handle, events) = start_worker(listener.address, None, short_timing()); + + collect_until(&events, EVENT_DEADLINE, is_unreachable); + listener.wait_for_accepts(3); + + let further: Vec = events.try_iter().collect(); + assert_eq!(further, Vec::new()); +} + +#[test] +fn commands_while_unreachable_fail_at_once() { + let listener = ClosingListener::start(); + let timing = Timing { + reconnect: LONG_INTERVAL, + ..short_timing() + }; + let (handle, events) = start_worker(listener.address, None, timing); + collect_until(&events, EVENT_DEADLINE, is_unreachable); + + let level = path("audio/out/level"); + handle.send(Command::Set { + path: level.clone(), + value: json!(90.0), + }); + handle.send(Command::ReadAll); + let failed = events + .recv_timeout(EVENT_DEADLINE) + .expect("a write failure"); + assert!(matches!(failed, Event::WriteFailed { path, .. } if path == level)); + let failed = events + .recv_timeout(EVENT_DEADLINE) + .expect("a read-all failure"); + assert!(matches!(failed, Event::AllValuesFailed(_))); +} + +#[test] +fn connection_loss_during_set_reports_write_failed() { + let simulator = Simulator::start(SimModel::Kh120Ii); + let (handle, events) = start_worker(simulator.address(), None, quiet_timing()); + let last = last_initial_path(KH_120_II_PRODUCT); + collect_until(&events, EVENT_DEADLINE, |event| is_value_of(event, &last)); + + simulator.inject(Fault::CloseConnection); + let mute = path("audio/out/mute"); + handle.send(Command::Set { + path: mute.clone(), + value: json!(true), + }); + let failed = events + .recv_timeout(EVENT_DEADLINE) + .expect("a write failure"); + assert!(matches!(failed, Event::WriteFailed { path, .. } if path == mute)); + let lost = events.recv_timeout(EVENT_DEADLINE).expect("the outage"); + assert!(is_unreachable(&lost)); +} + +#[test] +fn serial_mismatch_is_reported() { + let simulator = Simulator::start(SimModel::Kh120Ii); + let (_handle, events) = start_worker(simulator.address(), Some("OTHER"), short_timing()); + + let first = events.recv_timeout(EVENT_DEADLINE).expect("an event"); + assert_eq!( + first, + Event::SerialMismatch { + expected: "OTHER".to_owned(), + found: KH_120_II_SERIAL.to_owned(), + } + ); + let serial = path("device/identity/serial"); + wait_for_requests(&simulator, &serial, 2, EVENT_DEADLINE); + match events.recv_timeout(QUIET_PERIOD) { + Err(RecvTimeoutError::Timeout) => {} + other => panic!("a mismatched speaker must stay silent, got {other:?}"), + } +} + +#[test] +fn read_all_lists_every_leaf() { + let simulator = Simulator::start(SimModel::Kh120Ii); + let mut client = Client::connect(simulator.address()).expect("simulator accepts"); + + let reports = read_all(&mut client).expect("the walk succeeds"); + + assert_eq!(reports.len(), KH_120_II_LEAVES); + let mut paths: Vec = reports.iter().map(|leaf| leaf.path.to_string()).collect(); + paths.sort(); + paths.dedup(); + assert_eq!(paths.len(), KH_120_II_LEAVES); + assert!(paths.iter().all(|text| !text.starts_with("osc/"))); + let level = reports + .iter() + .find(|leaf| leaf.path == path("audio/out/level")) + .expect("the level is a leaf"); + assert_eq!( + level.value, + Ok(simulator + .value("audio/out/level") + .expect("the sim has a level")) + ); + assert_eq!(level.limits, Some(json!({"type": "Number"}))); +} + +#[test] +fn read_all_command_reports_all_values() { + let simulator = Simulator::start(SimModel::Kh120Ii); + let (handle, events) = start_worker(simulator.address(), None, short_timing()); + collect_until(&events, EVENT_DEADLINE, is_connected); + + handle.send(Command::ReadAll); + let seen = collect_until(&events, EVENT_DEADLINE, |event| { + matches!(event, Event::AllValues(_) | Event::AllValuesFailed(_)) + }); + let Some(Event::AllValues(reports)) = seen.last() else { + panic!("expected all values, got {:?}", seen.last()); + }; + assert_eq!(reports.len(), KH_120_II_LEAVES); +} + +#[test] +fn drop_stops_the_thread() { + let simulator = Simulator::start(SimModel::Kh120Ii); + let (handle, events) = start_worker(simulator.address(), None, short_timing()); + collect_until(&events, EVENT_DEADLINE, is_connected); + + let started = Instant::now(); + drop(handle); + assert!(started.elapsed() < STOP_DEADLINE); + // The worker owned the only sender; a finished thread leaves the channel closed. + let _: Vec = events.try_iter().collect(); + assert_eq!( + events.recv_timeout(Duration::ZERO), + Err(RecvTimeoutError::Disconnected) + ); +} + +#[test] +fn drop_stops_the_thread_while_waiting_to_reconnect() { + let listener = ClosingListener::start(); + let timing = Timing { + reconnect: LONG_INTERVAL, + ..short_timing() + }; + let (handle, events) = start_worker(listener.address, None, timing); + collect_until(&events, EVENT_DEADLINE, is_unreachable); + + let started = Instant::now(); + drop(handle); + assert!(started.elapsed() < STOP_DEADLINE); +} From 1b2f721b9fbb054e27b6dedf358542fdc232999d Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:12:44 +0200 Subject: [PATCH 011/460] Stop the worker promptly and keep it on walk errors --- crates/core/src/schema_walk.rs | 71 ++++++++++++--- crates/core/src/simulator/mod.rs | 11 ++- crates/core/src/speaker.rs | 107 +++++++++++++--------- crates/core/src/speaker/inbox.rs | 51 +++++++++++ crates/core/tests/speaker.rs | 151 +++++++++++++++++++++++++++++-- 5 files changed, 328 insertions(+), 63 deletions(-) create mode 100644 crates/core/src/speaker/inbox.rs diff --git a/crates/core/src/schema_walk.rs b/crates/core/src/schema_walk.rs index 50d67ad..1597989 100644 --- a/crates/core/src/schema_walk.rs +++ b/crates/core/src/schema_walk.rs @@ -24,27 +24,58 @@ pub struct LeafReport { pub limits: Option, } +#[derive(Clone, Debug, PartialEq)] +pub enum WalkError { + /// The connection is gone; the caller has to reconnect. + Connection(SscError), + /// The device or the shape of its tree ended the walk; the connection is still usable. + Refused(String), + /// `should_stop` asked to end the walk. + Stopped, +} + +impl From for WalkError { + fn from(error: SscError) -> WalkError { + match error { + SscError::Device(_) => WalkError::Refused(error.to_string()), + lost => WalkError::Connection(lost), + } + } +} + /// Every leaf breadth-first from the root with its value and limits. A device error on one -/// leaf stays in that leaf's report; a lost connection or a tree over the bounds fails it all. -pub fn read_all(client: &mut Client) -> Result, SscError> { - leaf_paths(client)? - .into_iter() - .map(|path| report(client, path)) - .collect() +/// leaf stays in that leaf's report. `should_stop` is asked before every request, so a caller +/// waiting to shut down waits for one request at most. +pub fn read_all( + client: &mut Client, + should_stop: &dyn Fn() -> bool, +) -> Result, WalkError> { + let leaves = leaf_paths(client, should_stop)?; + let mut reports = Vec::with_capacity(leaves.len()); + for path in leaves { + reports.push(report(client, path, should_stop)?); + } + Ok(reports) } -fn leaf_paths(client: &mut Client) -> Result, SscError> { +fn leaf_paths( + client: &mut Client, + should_stop: &dyn Fn() -> bool, +) -> Result, WalkError> { let mut leaves = Vec::new(); let mut containers: VecDeque> = VecDeque::from([None]); let mut visited = 0usize; while let Some(container) = containers.pop_front() { + if should_stop() { + return Err(WalkError::Stopped); + } for (name, kind) in client.schema(container.as_ref())? { if container.is_none() && name == PROTOCOL_CONTAINER { continue; } visited += 1; if visited > MAX_SCHEMA_NODES { - return Err(SscError::Protocol(format!( + return Err(WalkError::Refused(format!( "schema has more than {MAX_SCHEMA_NODES} nodes" ))); } @@ -53,7 +84,7 @@ fn leaf_paths(client: &mut Client) -> Result, SscError> { match kind { NodeKind::Leaf => leaves.push(path), NodeKind::Container if depth >= MAX_SCHEMA_DEPTH => { - return Err(SscError::Protocol(format!( + return Err(WalkError::Refused(format!( "schema is deeper than {MAX_SCHEMA_DEPTH} levels" ))); } @@ -64,16 +95,26 @@ fn leaf_paths(client: &mut Client) -> Result, SscError> { Ok(leaves) } -fn report(client: &mut Client, path: SscPath) -> Result { +fn report( + client: &mut Client, + path: SscPath, + should_stop: &dyn Fn() -> bool, +) -> Result { + if should_stop() { + return Err(WalkError::Stopped); + } let value = match client.get(&path) { Ok(value) => Ok(value), Err(refusal @ SscError::Device(_)) => Err(refusal.to_string()), - Err(lost) => return Err(lost), + Err(lost) => return Err(WalkError::Connection(lost)), }; + if should_stop() { + return Err(WalkError::Stopped); + } let limits = match client.limits(&path) { Ok(limits) => Some(limits), Err(SscError::Device(_)) => None, - Err(lost) => return Err(lost), + Err(lost) => return Err(WalkError::Connection(lost)), }; Ok(LeafReport { path, @@ -83,9 +124,9 @@ fn report(client: &mut Client, path: SscPath) -> Result { } /// A node name holding the separator would silently become several segments. -fn child_path(parent: Option<&SscPath>, name: &str) -> Result { +fn child_path(parent: Option<&SscPath>, name: &str) -> Result { if name.contains(PATH_SEPARATOR) { - return Err(SscError::Protocol(format!( + return Err(WalkError::Refused(format!( "schema names a node with a '{PATH_SEPARATOR}'" ))); } @@ -93,5 +134,5 @@ fn child_path(parent: Option<&SscPath>, name: &str) -> Result Some(parent) => format!("{parent}{PATH_SEPARATOR}{name}"), None => name.to_owned(), }; - SscPath::parse(&text).map_err(|error| SscError::Protocol(error.to_string())) + SscPath::parse(&text).map_err(|error| WalkError::Refused(error.to_string())) } diff --git a/crates/core/src/simulator/mod.rs b/crates/core/src/simulator/mod.rs index d9e0541..d72cf47 100644 --- a/crates/core/src/simulator/mod.rs +++ b/crates/core/src/simulator/mod.rs @@ -60,6 +60,7 @@ struct State { device: SimulatedDevice, faults: VecDeque, requests: Vec, + reply_delay: Duration, } #[derive(Default)] @@ -137,7 +138,9 @@ impl Shared { let Ok(Value::Object(request)) = parsed else { continue; }; - let lines = match self.respond(request) { + let delivery = self.respond(request); + thread::sleep(self.state().reply_delay); + let lines = match delivery { Delivery::Lines(lines) => lines, Delivery::Silent => continue, Delivery::Close => break, @@ -217,6 +220,7 @@ impl Simulator { device: SimulatedDevice::new(model), faults: VecDeque::new(), requests: Vec::new(), + reply_delay: Duration::ZERO, }), connections: Mutex::new(Connections::default()), stopping: AtomicBool::new(false), @@ -251,6 +255,11 @@ impl Simulator { self.shared.state().requests.clone() } + /// Models a slow device: every later reply waits this long. + pub fn set_reply_delay(&self, delay: Duration) { + self.shared.state().reply_delay = delay; + } + pub fn value(&self, path: &str) -> Option { self.shared.state().device.value(path) } diff --git a/crates/core/src/speaker.rs b/crates/core/src/speaker.rs index 59687c5..3395ccc 100644 --- a/crates/core/src/speaker.rs +++ b/crates/core/src/speaker.rs @@ -1,6 +1,8 @@ //! The worker that owns the connection to one speaker: one thread per speaker, commands from //! the app before polling, and everything it learns sent back as events. +mod inbox; + use std::collections::HashSet; use std::net::SocketAddrV6; use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; @@ -13,9 +15,10 @@ use serde_json::Value; use crate::catalog::{Catalog, Entry}; use crate::mask::{mask_host, mask_serial}; use crate::poll_schedule::{Due, Schedule}; -use crate::schema_walk::{self, LeafReport}; +use crate::schema_walk::{self, LeafReport, WalkError}; use crate::ssc::client::{Client, SscError}; use crate::ssc::wire::{DeviceError, SscPath}; +use inbox::Inbox; const DEFAULT_METER_INTERVAL: Duration = Duration::from_millis(100); const DEFAULT_FAST_INTERVAL: Duration = Duration::from_secs(2); @@ -82,9 +85,10 @@ pub enum Event { path: SscPath, value: Value, }, - /// The speaker does not have the path; it is not polled again on this connection. + /// The speaker does not have the path; it is not polled again on this connection, and + /// every `Set` to it is answered with this again. Absent(SscPath), - /// The speaker refused the write; a `Value` with the unchanged value follows. + /// The speaker refused the write; a re-read follows. WriteRejected { path: SscPath, error: DeviceError, @@ -139,7 +143,7 @@ pub fn spawn( host: mask_host(&target.address), target, timing, - commands, + inbox: Inbox::new(commands), outbox: Outbox { events, wake }, reported: None, }; @@ -177,6 +181,7 @@ enum Disconnect { /// Why a connected session ended. enum Interruption { Stopped, + /// The connection is gone, or the speaker cannot be used on it. Lost(SscError), } @@ -193,7 +198,7 @@ struct Worker { target: Target, host: String, timing: Timing, - commands: Receiver, + inbox: Inbox, outbox: Outbox, reported: Option, } @@ -212,17 +217,21 @@ impl Worker { /// One connection attempt and, if the speaker is the expected one, the session on it. fn connect_and_serve(&mut self) -> Result<(), Stopped> { - let connected = Client::connect(self.target.address).and_then(|mut client| { - let identity = read_identity(&mut client)?; - Ok((client, identity)) - }); - let (client, identity) = match connected { - Ok(connected) => connected, + let mut client = match Client::connect(self.target.address) { + Ok(client) => client, Err(error) => { self.report_unreachable(&error); return Ok(()); } }; + let identity = match self.read_identity(&mut client) { + Ok(identity) => identity, + Err(Interruption::Stopped) => return Err(Stopped), + Err(Interruption::Lost(error)) => { + self.report_unreachable(&error); + return Ok(()); + } + }; if let Some(expected) = self.mismatched_serial(&identity) { self.report_mismatch(expected, identity.serial); return Ok(()); @@ -261,7 +270,7 @@ impl Worker { /// A pending command, or else what the schedule has due; waits for whichever comes first. fn step(&self, session: &mut Session) -> Result<(), Interruption> { let wait = session.schedule.time_until_due(Instant::now()); - match self.commands.recv_timeout(wait) { + match self.inbox.next(wait) { Ok(command) => return self.execute(session, command), Err(RecvTimeoutError::Timeout) => {} Err(RecvTimeoutError::Disconnected) => return Err(Interruption::Stopped), @@ -282,13 +291,18 @@ impl Worker { } fn read(&self, session: &mut Session, path: &SscPath) -> Result<(), Interruption> { + if self.inbox.stop_requested() { + return Err(Interruption::Stopped); + } match session.client.get(path) { Ok(value) => self.outbox.emit(Event::Value { path: path.clone(), value, }), Err(SscError::Device(error)) if error.code == NOT_FOUND_CODE => { - self.mark_absent(session, path); + if self.forget(session, path) { + self.outbox.emit(Event::Absent(path.clone())); + } } // Nothing the user could act on; the next poll asks again. Err(SscError::Device(error)) => log::warn!( @@ -319,8 +333,10 @@ impl Worker { Err(error) => error, }; match error { + // Reported even when already known: the app waits for an answer to its write. SscError::Device(error) if error.code == NOT_FOUND_CODE => { - self.mark_absent(session, &path); + self.forget(session, &path); + self.outbox.emit(Event::Absent(path)); Ok(()) } // The device kept its old value (read-only, wrong type, ...); show that one again. @@ -342,26 +358,31 @@ impl Worker { } fn read_all(&self, session: &mut Session) -> Result<(), Interruption> { - let error = match schema_walk::read_all(&mut session.client) { + let should_stop = || self.inbox.stop_requested(); + match schema_walk::read_all(&mut session.client, &should_stop) { Ok(reports) => { self.outbox.emit(Event::AllValues(reports)); - return Ok(()); + Ok(()) } - Err(error) => error, - }; - self.outbox.emit(Event::AllValuesFailed(error.to_string())); - if error.is_connection_lost() { - return Err(Interruption::Lost(error)); + Err(WalkError::Refused(reason)) => { + self.outbox.emit(Event::AllValuesFailed(reason)); + Ok(()) + } + Err(WalkError::Connection(error)) => { + self.outbox.emit(Event::AllValuesFailed(error.to_string())); + Err(Interruption::Lost(error)) + } + Err(WalkError::Stopped) => Err(Interruption::Stopped), } - Ok(()) } - fn mark_absent(&self, session: &mut Session, path: &SscPath) { + /// Stops polling `path`; false when it was already known to be absent. + fn forget(&self, session: &mut Session, path: &SscPath) -> bool { if !session.absent.insert(path.clone()) { - return; + return false; } session.schedule.forget(path); - self.outbox.emit(Event::Absent(path.clone())); + true } /// Waits between connection attempts, answering commands at once since none can run. @@ -369,7 +390,7 @@ impl Worker { let deadline = Instant::now() + duration; loop { let remaining = deadline.saturating_duration_since(Instant::now()); - match self.commands.recv_timeout(remaining) { + match self.inbox.next(remaining) { Ok(Command::Set { path, .. }) => self.outbox.emit(Event::WriteFailed { path, message: NOT_CONNECTED_MESSAGE.to_owned(), @@ -413,22 +434,26 @@ impl Worker { ); self.outbox.emit(Event::SerialMismatch { expected, found }); } -} -fn read_identity(client: &mut Client) -> Result { - Ok(Identity { - product: read_text(client, PRODUCT_PATH)?, - version: read_text(client, VERSION_PATH)?, - serial: read_text(client, SERIAL_PATH)?, - name: read_text(client, NAME_PATH)?, - }) -} + /// A device error counts as lost too: without its identity the speaker cannot be used. + fn read_identity(&self, client: &mut Client) -> Result { + Ok(Identity { + product: self.read_text(client, PRODUCT_PATH)?, + version: self.read_text(client, VERSION_PATH)?, + serial: self.read_text(client, SERIAL_PATH)?, + name: self.read_text(client, NAME_PATH)?, + }) + } -/// A string value as it is; any other value in its JSON form. -fn read_text(client: &mut Client, path: &str) -> Result { - let path = SscPath::parse(path).expect("constant identity path is valid"); - match client.get(&path)? { - Value::String(text) => Ok(text), - other => Ok(other.to_string()), + /// A string value as it is; any other value in its JSON form. + fn read_text(&self, client: &mut Client, path: &str) -> Result { + if self.inbox.stop_requested() { + return Err(Interruption::Stopped); + } + let path = SscPath::parse(path).expect("constant identity path is valid"); + match client.get(&path).map_err(Interruption::Lost)? { + Value::String(text) => Ok(text), + other => Ok(other.to_string()), + } } } diff --git a/crates/core/src/speaker/inbox.rs b/crates/core/src/speaker/inbox.rs new file mode 100644 index 0000000..7f70829 --- /dev/null +++ b/crates/core/src/speaker/inbox.rs @@ -0,0 +1,51 @@ +//! The worker's side of the command channel. + +use std::cell::{Cell, RefCell}; +use std::collections::VecDeque; +use std::sync::mpsc::{Receiver, RecvTimeoutError, TryRecvError}; +use std::time::Duration; + +use super::Command; + +/// The command channel plus the commands taken off it early while looking for a `Stop`, kept +/// in arrival order. +pub(super) struct Inbox { + receiver: Receiver, + pending: RefCell>, + stop_requested: Cell, +} + +impl Inbox { + pub(super) fn new(receiver: Receiver) -> Inbox { + Inbox { + receiver, + pending: RefCell::new(VecDeque::new()), + stop_requested: Cell::new(false), + } + } + + /// Looks without waiting, so long work between polls can end early; a closed channel + /// counts as `Stop`. + pub(super) fn stop_requested(&self) -> bool { + while !self.stop_requested.get() { + match self.receiver.try_recv() { + Ok(Command::Stop) | Err(TryRecvError::Disconnected) => { + self.stop_requested.set(true); + } + Ok(command) => self.pending.borrow_mut().push_back(command), + Err(TryRecvError::Empty) => return false, + } + } + true + } + + pub(super) fn next(&self, timeout: Duration) -> Result { + if self.stop_requested.get() { + return Ok(Command::Stop); + } + if let Some(command) = self.pending.borrow_mut().pop_front() { + return Ok(command); + } + self.receiver.recv_timeout(timeout) + } +} diff --git a/crates/core/tests/speaker.rs b/crates/core/tests/speaker.rs index f0ac5f9..00b7bf6 100644 --- a/crates/core/tests/speaker.rs +++ b/crates/core/tests/speaker.rs @@ -6,7 +6,7 @@ use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; use neumann_control_core::catalog::Catalog; -use neumann_control_core::schema_walk::read_all; +use neumann_control_core::schema_walk::{read_all, WalkError, MAX_SCHEMA_DEPTH, MAX_SCHEMA_NODES}; use neumann_control_core::simulator::{Fault, SimModel, Simulator}; use neumann_control_core::speaker::{ spawn, Command, Event, Identity, SpeakerHandle, Target, Timing, @@ -21,6 +21,8 @@ const RECOVERY_DEADLINE: Duration = Duration::from_secs(15); const REQUEST_POLL_INTERVAL: Duration = Duration::from_millis(10); const QUIET_PERIOD: Duration = Duration::from_millis(300); const STOP_DEADLINE: Duration = Duration::from_secs(1); +// Slow enough that a walk or connect-time read takes many seconds. +const SLOW_REPLY: Duration = Duration::from_millis(200); const LONG_INTERVAL: Duration = Duration::from_secs(60); const READ_ONLY_CODE: i64 = 405; const KH_120_II_LEAVES: usize = 50; @@ -235,7 +237,12 @@ fn worker_reports_identity_then_values() { .expect("the sim has a level"), }; assert_eq!(seen.last(), Some(&expected)); - assert!(woken.load(Ordering::SeqCst) > seen.len()); + // wake runs after the event is sent, so it may trail the receiver by a moment. + let end = Instant::now() + EVENT_DEADLINE; + while woken.load(Ordering::SeqCst) < seen.len() + 1 { + assert!(Instant::now() < end, "wake was not called for every event"); + thread::sleep(REQUEST_POLL_INTERVAL); + } } #[test] @@ -319,6 +326,25 @@ fn absent_path_is_reported_once_and_not_polled_again() { assert_eq!(count_requests(&simulator, &mute), 1); } +#[test] +fn set_on_absent_path_reports_absent_again() { + let simulator = Simulator::start(SimModel::Kh120Ii); + simulator.remove_value("audio/out/mute"); + let (handle, events) = start_worker(simulator.address(), None, quiet_timing()); + let mute = path("audio/out/mute"); + collect_until(&events, EVENT_DEADLINE, |event| { + *event == Event::Absent(mute.clone()) + }); + + handle.send(Command::Set { + path: mute.clone(), + value: json!(true), + }); + collect_until(&events, EVENT_DEADLINE, |event| { + *event == Event::Absent(mute.clone()) + }); +} + #[test] fn commands_run_before_polling() { let simulator = Simulator::start(SimModel::Kh120Ii); @@ -334,11 +360,11 @@ fn commands_run_before_polling() { collect_until(&events, EVENT_DEADLINE, |event| is_value_of(event, &level)); collect_until(&events, EVENT_DEADLINE, |event| is_value_of(event, &level)); - let before = simulator.requests().len(); handle.send(Command::Set { path: level.clone(), value: json!(90.0), }); + let after_send = simulator.requests().len(); collect_until(&events, EVENT_DEADLINE, |event| { *event == Event::Value { @@ -354,8 +380,8 @@ fn commands_run_before_polling() { .expect("the set reached the simulator"); // At most the one poll that was already under way may come first. assert!( - set_index <= before + 1, - "set was request {set_index}, {before} requests before it was sent" + set_index <= after_send + 1, + "set was request {set_index}, {after_send} requests when it was sent" ); } @@ -489,7 +515,7 @@ fn read_all_lists_every_leaf() { let simulator = Simulator::start(SimModel::Kh120Ii); let mut client = Client::connect(simulator.address()).expect("simulator accepts"); - let reports = read_all(&mut client).expect("the walk succeeds"); + let reports = read_all(&mut client, &|| false).expect("the walk succeeds"); assert_eq!(reports.len(), KH_120_II_LEAVES); let mut paths: Vec = reports.iter().map(|leaf| leaf.path.to_string()).collect(); @@ -557,3 +583,116 @@ fn drop_stops_the_thread_while_waiting_to_reconnect() { drop(handle); assert!(started.elapsed() < STOP_DEADLINE); } + +#[test] +fn drop_during_read_all_returns_promptly() { + let simulator = Simulator::start(SimModel::Kh120Ii); + let (handle, events) = start_worker(simulator.address(), None, quiet_timing()); + let last = last_initial_path(KH_120_II_PRODUCT); + collect_until(&events, EVENT_DEADLINE, |event| is_value_of(event, &last)); + + simulator.set_reply_delay(SLOW_REPLY); + handle.send(Command::ReadAll); + wait_for_requests(&simulator, &path("osc/schema"), 1, EVENT_DEADLINE); + + let started = Instant::now(); + drop(handle); + assert!(started.elapsed() < STOP_DEADLINE); +} + +#[test] +fn drop_during_connect_read_returns_promptly() { + let simulator = Simulator::start(SimModel::Kh120Ii); + simulator.set_reply_delay(SLOW_REPLY); + let (handle, events) = start_worker(simulator.address(), None, quiet_timing()); + collect_until(&events, EVENT_DEADLINE, is_connected); + + let started = Instant::now(); + drop(handle); + assert!(started.elapsed() < STOP_DEADLINE); +} + +#[test] +fn oversized_schema_fails_read_all_but_keeps_connection() { + let simulator = Simulator::start(SimModel::Kh120Ii); + let deep = (0..=MAX_SCHEMA_DEPTH) + .map(|level| format!("level{level}")) + .collect::>() + .join("/"); + simulator.set_value(&deep, json!(1)); + let (handle, events) = start_worker(simulator.address(), None, quiet_timing()); + collect_until(&events, EVENT_DEADLINE, is_connected); + + handle.send(Command::ReadAll); + let seen = collect_until(&events, EVENT_DEADLINE, |event| { + matches!(event, Event::AllValues(_) | Event::AllValuesFailed(_)) + }); + assert!(matches!(seen.last(), Some(Event::AllValuesFailed(_)))); + + let mute = path("audio/out/mute"); + handle.send(Command::Set { + path: mute.clone(), + value: json!(true), + }); + let seen = collect_until(&events, EVENT_DEADLINE, |event| { + *event + == Event::Value { + path: mute.clone(), + value: json!(true), + } + }); + assert!(!seen.iter().any(is_unreachable)); +} + +#[test] +fn too_many_schema_nodes_are_refused() { + let simulator = Simulator::start(SimModel::Kh120Ii); + for index in 0..=MAX_SCHEMA_NODES { + simulator.set_value(&format!("wide/node{index}"), json!(index)); + } + let mut client = Client::connect(simulator.address()).expect("simulator accepts"); + + let outcome = read_all(&mut client, &|| false); + + assert!(matches!(outcome, Err(WalkError::Refused(_)))); + assert!(!client.is_closed()); +} + +#[test] +fn read_all_stops_when_asked() { + let simulator = Simulator::start(SimModel::Kh120Ii); + let mut client = Client::connect(simulator.address()).expect("simulator accepts"); + + let outcome = read_all(&mut client, &|| true); + + assert_eq!(outcome, Err(WalkError::Stopped)); + assert!(simulator.requests().is_empty()); +} + +#[test] +fn commands_queued_during_long_work_keep_their_order() { + let simulator = Simulator::start(SimModel::Kh120Ii); + simulator.set_reply_delay(Duration::from_millis(20)); + let (handle, events) = start_worker(simulator.address(), None, quiet_timing()); + collect_until(&events, EVENT_DEADLINE, is_connected); + + // Sent while the connect-time read is still running, so the worker sets them aside. + let level = path("audio/out/level"); + for value in [80.0, 90.0] { + handle.send(Command::Set { + path: level.clone(), + value: json!(value), + }); + } + let last_set = Event::Value { + path: level.clone(), + value: json!(90.0), + }; + let seen = collect_until(&events, RECOVERY_DEADLINE, |event| *event == last_set); + let first_set = Event::Value { + path: level, + value: json!(80.0), + }; + assert!(seen.contains(&first_set), "the first set did not run first"); + assert_eq!(simulator.value("audio/out/level"), Some(json!(90.0))); +} From 7d85bdd89c3fe0d7b9dc13858f0101a250186da1 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:16:24 +0200 Subject: [PATCH 012/460] Discover speakers via mDNS --- Cargo.lock | 139 ++++++++++++++++++++++++ crates/core/Cargo.toml | 1 + crates/core/examples/scan.rs | 28 +++++ crates/core/src/discovery.rs | 186 +++++++++++++++++++++++++++++++++ crates/core/src/lib.rs | 1 + crates/core/tests/discovery.rs | 81 ++++++++++++++ 6 files changed, 436 insertions(+) create mode 100644 crates/core/examples/scan.rs create mode 100644 crates/core/src/discovery.rs create mode 100644 crates/core/tests/discovery.rs diff --git a/Cargo.lock b/Cargo.lock index f95113a..e386b0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,12 +8,51 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "if-addrs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "indexmap" version = "2.14.2" @@ -30,18 +69,60 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "mdns-sd" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "127d3355da0615643c88c885e29a1ccb29ca4b1ca1f2e44b3caed2e728a796aa" +dependencies = [ + "fastrand", + "flume", + "if-addrs", + "log", + "mio", + "socket-pktinfo", + "socket2", +] + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + [[package]] name = "neumann-control-app" version = "0.1.0" @@ -51,6 +132,7 @@ name = "neumann-control-core" version = "0.1.0" dependencies = [ "log", + "mdns-sd", "serde", "serde_json", "toml", @@ -74,6 +156,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "serde" version = "1.0.229" @@ -126,6 +214,36 @@ dependencies = [ "serde_core", ] +[[package]] +name = "socket-pktinfo" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612942246d0cc239cfd83af1dfd39be47f649208a3524e5e9da651910128e0ac" +dependencies = [ + "libc", + "socket2", + "windows-sys", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + [[package]] name = "syn" version = "3.0.9" @@ -182,6 +300,27 @@ version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "winnow" version = "1.0.4" diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 6005ca9..8db7b32 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -11,6 +11,7 @@ test-support = [] [dependencies] log = "0.4" +mdns-sd = "0.21" serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "1" diff --git a/crates/core/examples/scan.rs b/crates/core/examples/scan.rs new file mode 100644 index 0000000..23ff96b --- /dev/null +++ b/crates/core/examples/scan.rs @@ -0,0 +1,28 @@ +//! Prints the speakers found on the LAN, with serial and address masked. + +use neumann_control_core::discovery::{scan, SCAN_DURATION}; +use neumann_control_core::mask::{mask_host, mask_serial}; + +const UNKNOWN: &str = "unknown"; + +fn main() { + match scan(SCAN_DURATION) { + Ok(speakers) => { + println!("{} speaker(s) found", speakers.len()); + for speaker in speakers { + let serial = speaker + .serial + .as_deref() + .map_or_else(|| UNKNOWN.to_string(), mask_serial); + println!( + "{} | {} | {} | {}", + speaker.name, + speaker.product.as_deref().unwrap_or(UNKNOWN), + serial, + mask_host(&speaker.address) + ); + } + } + Err(message) => eprintln!("scan failed: {message}"), + } +} diff --git a/crates/core/src/discovery.rs b/crates/core/src/discovery.rs new file mode 100644 index 0000000..73e4c7e --- /dev/null +++ b/crates/core/src/discovery.rs @@ -0,0 +1,186 @@ +//! Speaker discovery over mDNS. The speakers announce `_ssc._tcp.local.`, are reachable over IPv6 +//! only, and need a link-local address together with the interface index as scope. + +use std::net::{Ipv6Addr, SocketAddrV6}; +use std::time::{Duration, Instant}; + +use mdns_sd::{IfKind, ScopedIp, ServiceDaemon, ServiceEvent}; + +use crate::address::is_link_local; + +pub const SCAN_DURATION: Duration = Duration::from_secs(4); +pub const MAX_SERVICES: usize = 32; +const SERVICE_TYPE: &str = "_ssc._tcp.local."; +const MAX_TXT_CHARACTERS: usize = 64; +const TXT_PRODUCT_KEY: &str = "model"; +const TXT_SERIAL_KEY: &str = "id"; +const NO_SCOPE: u32 = 0; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Found { + pub name: String, + pub product: Option, + pub serial: Option, + pub address: SocketAddrV6, +} + +/// Link-local with a scope wins; a link-local address without a scope cannot be connected to on +/// Windows and is skipped; a global address is the fallback. +pub fn pick_address(addresses: &[(Ipv6Addr, u32)], port: u16) -> Option { + let scoped_link_local = addresses + .iter() + .find(|(address, scope)| is_link_local(address) && *scope != NO_SCOPE); + let global = addresses + .iter() + .find(|(address, _)| !is_link_local(address)); + scoped_link_local + .or(global) + .map(|(address, scope)| SocketAddrV6::new(*address, port, 0, *scope)) +} + +/// One entry per serial (a later link-local address replaces an earlier one, a later global +/// address never replaces a link-local one); entries without serial are deduplicated by address. +pub fn merge_found(found: Vec) -> Vec { + let mut merged: Vec = Vec::new(); + for candidate in found { + let existing = merged + .iter_mut() + .find(|entry| same_speaker(entry, &candidate)); + let Some(entry) = existing else { + merged.push(candidate); + continue; + }; + let keeps_link_local = + is_link_local(entry.address.ip()) && !is_link_local(candidate.address.ip()); + if !keeps_link_local { + *entry = candidate; + } + } + merged +} + +fn same_speaker(left: &Found, right: &Found) -> bool { + match (&left.serial, &right.serial) { + (Some(left_serial), Some(right_serial)) => left_serial == right_serial, + (None, None) => left.address == right.address, + _ => false, + } +} + +fn instance_name<'a>(fullname: &'a str, service_type: &str) -> &'a str { + let suffix = format!(".{service_type}"); + fullname.strip_suffix(&suffix).unwrap_or(fullname) +} + +fn capped(text: &str) -> String { + text.chars().take(MAX_TXT_CHARACTERS).collect() +} + +fn found_from_parts( + fullname: &str, + service_type: &str, + port: u16, + addresses: &[(Ipv6Addr, u32)], + product: Option<&str>, + serial: Option<&str>, +) -> Option { + Some(Found { + name: capped(instance_name(fullname, service_type)), + product: product.map(capped), + serial: serial.map(capped), + address: pick_address(addresses, port)?, + }) +} + +/// Browses for `duration` and returns every speaker that resolved with a usable address. +/// Errors are fixed texts: the underlying messages could carry network addresses. +pub fn scan(duration: Duration) -> Result, String> { + let daemon = ServiceDaemon::new().map_err(|_| "mDNS could not be started".to_string())?; + let result = browse(&daemon, duration); + // The scan is over either way; a failed shutdown leaves nothing to recover. + let _ = daemon.shutdown(); + result.map(merge_found) +} + +fn browse(daemon: &ServiceDaemon, duration: Duration) -> Result, String> { + daemon + .disable_interface(IfKind::IPv4) + .map_err(|_| "mDNS could not be restricted to IPv6".to_string())?; + let events = daemon + .browse(SERVICE_TYPE) + .map_err(|_| "mDNS browsing could not be started".to_string())?; + let deadline = Instant::now() + duration; + let mut found = Vec::new(); + while found.len() < MAX_SERVICES { + let remaining = deadline.saturating_duration_since(Instant::now()); + let Ok(event) = events.recv_timeout(remaining) else { + break; + }; + let ServiceEvent::ServiceResolved(service) = event else { + continue; + }; + let addresses: Vec<(Ipv6Addr, u32)> = service + .get_addresses() + .iter() + .filter_map(|address| match address { + ScopedIp::V6(scoped) => Some((*scoped.addr(), scoped.scope_id().index)), + _ => None, + }) + .collect(); + let entry = found_from_parts( + service.get_fullname(), + &service.ty_domain, + service.get_port(), + &addresses, + service.get_property_val_str(TXT_PRODUCT_KEY), + service.get_property_val_str(TXT_SERIAL_KEY), + ); + found.extend(entry); + } + Ok(found) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn instance_name_loses_the_service_suffix() { + assert_eq!( + instance_name("KH 120 II 1._ssc._tcp.local.", SERVICE_TYPE), + "KH 120 II 1" + ); + assert_eq!(instance_name("plain", SERVICE_TYPE), "plain"); + } + + #[test] + fn txt_values_are_capped() { + let long = "x".repeat(MAX_TXT_CHARACTERS + 10); + let address: Ipv6Addr = "2001:db8::1".parse().unwrap(); + let found = found_from_parts( + "a._ssc._tcp.local.", + SERVICE_TYPE, + 45, + &[(address, 0)], + Some(&long), + Some(&long), + ) + .unwrap(); + assert_eq!(found.product.unwrap().chars().count(), MAX_TXT_CHARACTERS); + assert_eq!(found.serial.unwrap().chars().count(), MAX_TXT_CHARACTERS); + } + + #[test] + fn service_without_usable_address_is_dropped() { + let link_local: Ipv6Addr = "fe80::1".parse().unwrap(); + let found = found_from_parts( + "a._ssc._tcp.local.", + SERVICE_TYPE, + 45, + &[(link_local, 0)], + None, + None, + ); + assert_eq!(found, None); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 3d6f95c..3bb7bea 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,5 +1,6 @@ pub mod address; pub mod catalog; +pub mod discovery; pub mod mask; mod poll_schedule; pub mod schema_walk; diff --git a/crates/core/tests/discovery.rs b/crates/core/tests/discovery.rs new file mode 100644 index 0000000..474500e --- /dev/null +++ b/crates/core/tests/discovery.rs @@ -0,0 +1,81 @@ +use std::net::{Ipv6Addr, SocketAddrV6}; + +use neumann_control_core::discovery::{merge_found, pick_address, Found}; + +const PORT: u16 = 45; +const SIMULATED_LINK_LOCAL: &str = "fe80::2a36:38ff:fe12:3456"; +const SIMULATED_GLOBAL: &str = "2001:db8::2a36:38ff:fe12:3456"; + +fn address(text: &str) -> Ipv6Addr { + text.parse().unwrap() +} + +fn found(serial: Option<&str>, text: &str, scope: u32) -> Found { + Found { + name: "Speaker".into(), + product: Some("KH 120 II".into()), + serial: serial.map(String::from), + address: SocketAddrV6::new(address(text), PORT, 0, scope), + } +} + +#[test] +fn link_local_with_scope_is_preferred() { + let addresses = [ + (address(SIMULATED_GLOBAL), 0), + (address(SIMULATED_LINK_LOCAL), 7), + ]; + let picked = pick_address(&addresses, PORT).unwrap(); + assert_eq!(*picked.ip(), address(SIMULATED_LINK_LOCAL)); + assert_eq!(picked.scope_id(), 7); + assert_eq!(picked.port(), PORT); +} + +#[test] +fn link_local_without_scope_is_ignored() { + let addresses = [(address(SIMULATED_LINK_LOCAL), 0)]; + assert_eq!(pick_address(&addresses, PORT), None); +} + +#[test] +fn global_is_used_when_no_link_local() { + let addresses = [ + (address(SIMULATED_LINK_LOCAL), 0), + (address(SIMULATED_GLOBAL), 0), + ]; + let picked = pick_address(&addresses, PORT).unwrap(); + assert_eq!(*picked.ip(), address(SIMULATED_GLOBAL)); + assert_eq!(picked.scope_id(), 0); +} + +#[test] +fn same_serial_is_merged() { + let merged = merge_found(vec![ + found(Some("SIM0001234"), SIMULATED_GLOBAL, 0), + found(Some("SIM0001234"), SIMULATED_LINK_LOCAL, 7), + found(Some("SIM0007500"), SIMULATED_GLOBAL, 0), + ]); + assert_eq!(merged.len(), 2); + assert_eq!(*merged[0].address.ip(), address(SIMULATED_LINK_LOCAL)); + assert_eq!(merged[0].address.scope_id(), 7); +} + +#[test] +fn link_local_is_kept_when_a_global_comes_later() { + let merged = merge_found(vec![ + found(Some("SIM0001234"), SIMULATED_LINK_LOCAL, 7), + found(Some("SIM0001234"), SIMULATED_GLOBAL, 0), + ]); + assert_eq!(merged.len(), 1); + assert_eq!(*merged[0].address.ip(), address(SIMULATED_LINK_LOCAL)); +} + +#[test] +fn entries_without_serial_are_kept_by_address() { + let merged = merge_found(vec![ + found(None, SIMULATED_LINK_LOCAL, 7), + found(None, SIMULATED_LINK_LOCAL, 7), + found(None, SIMULATED_GLOBAL, 0), + ]); + assert_eq!(merged.len(), 2); +} From 966deb2729fcf2071def3055ce786948142d2338 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:18:38 +0200 Subject: [PATCH 013/460] Harden discovery against repeated and empty records --- crates/core/src/discovery.rs | 73 ++++++++++++++++++++++++++++++++---- 1 file changed, 66 insertions(+), 7 deletions(-) diff --git a/crates/core/src/discovery.rs b/crates/core/src/discovery.rs index 73e4c7e..70d0fcd 100644 --- a/crates/core/src/discovery.rs +++ b/crates/core/src/discovery.rs @@ -73,7 +73,23 @@ fn instance_name<'a>(fullname: &'a str, service_type: &str) -> &'a str { } fn capped(text: &str) -> String { - text.chars().take(MAX_TXT_CHARACTERS).collect() + text.chars() + .filter(|character| !character.is_control()) + .take(MAX_TXT_CHARACTERS) + .collect() +} + +/// A TXT value that is empty after cleaning counts as missing, so two malformed speakers are +/// never merged under the same empty serial. +fn txt_value(text: Option<&str>) -> Option { + text.map(capped).filter(|value| !value.is_empty()) +} + +/// Adds one resolved speaker; mdns-sd re-emits resolutions per interface and refresh, so the +/// speaker cap has to count the merged list, not the events. +fn add_found(mut found: Vec, candidate: Found) -> Vec { + found.push(candidate); + merge_found(found) } fn found_from_parts( @@ -86,20 +102,21 @@ fn found_from_parts( ) -> Option { Some(Found { name: capped(instance_name(fullname, service_type)), - product: product.map(capped), - serial: serial.map(capped), + product: txt_value(product), + serial: txt_value(serial), address: pick_address(addresses, port)?, }) } -/// Browses for `duration` and returns every speaker that resolved with a usable address. +/// Browses for `duration` and returns every speaker that resolved with a usable address. It may +/// return early once `MAX_SERVICES` distinct speakers are found. /// Errors are fixed texts: the underlying messages could carry network addresses. pub fn scan(duration: Duration) -> Result, String> { let daemon = ServiceDaemon::new().map_err(|_| "mDNS could not be started".to_string())?; let result = browse(&daemon, duration); // The scan is over either way; a failed shutdown leaves nothing to recover. let _ = daemon.shutdown(); - result.map(merge_found) + result } fn browse(daemon: &ServiceDaemon, duration: Duration) -> Result, String> { @@ -110,7 +127,7 @@ fn browse(daemon: &ServiceDaemon, duration: Duration) -> Result, Stri .browse(SERVICE_TYPE) .map_err(|_| "mDNS browsing could not be started".to_string())?; let deadline = Instant::now() + duration; - let mut found = Vec::new(); + let mut found: Vec = Vec::new(); while found.len() < MAX_SERVICES { let remaining = deadline.saturating_duration_since(Instant::now()); let Ok(event) = events.recv_timeout(remaining) else { @@ -135,7 +152,9 @@ fn browse(daemon: &ServiceDaemon, duration: Duration) -> Result, Stri service.get_property_val_str(TXT_PRODUCT_KEY), service.get_property_val_str(TXT_SERIAL_KEY), ); - found.extend(entry); + if let Some(entry) = entry { + found = add_found(found, entry); + } } Ok(found) } @@ -170,6 +189,46 @@ mod tests { assert_eq!(found.serial.unwrap().chars().count(), MAX_TXT_CHARACTERS); } + fn sample(serial: Option<&str>, last_segment: u16) -> Found { + let address = Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, last_segment); + Found { + name: "Speaker".into(), + product: None, + serial: serial.map(String::from), + address: SocketAddrV6::new(address, 45, 0, 0), + } + } + + #[test] + fn repeated_events_do_not_fill_the_cap() { + let mut found = Vec::new(); + for _ in 0..MAX_SERVICES + 5 { + found = add_found(found, sample(Some("SIM0001234"), 1)); + } + assert_eq!(found.len(), 1); + } + + #[test] + fn empty_serial_is_treated_as_missing() { + let address: Ipv6Addr = "2001:db8::1".parse().unwrap(); + let found = found_from_parts( + "a._ssc._tcp.local.", + SERVICE_TYPE, + 45, + &[(address, 0)], + Some(""), + Some(""), + ) + .unwrap(); + assert_eq!(found.serial, None); + assert_eq!(found.product, None); + } + + #[test] + fn control_characters_are_removed_from_txt() { + assert_eq!(capped("KH\u{0}1\n20\u{1b}"), "KH120"); + } + #[test] fn service_without_usable_address_is_dropped() { let link_local: Ipv6Addr = "fe80::1".parse().unwrap(); From d3e9937eafdd4ff327f85a30d82a29bc022777b8 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:26:20 +0200 Subject: [PATCH 014/460] Store manual speakers and window geometry --- Cargo.lock | 78 +++++++++++++++++++++++++ crates/core/Cargo.toml | 3 + crates/core/src/lib.rs | 1 + crates/core/src/settings.rs | 104 +++++++++++++++++++++++++++++++++ crates/core/tests/settings.rs | 107 ++++++++++++++++++++++++++++++++++ 5 files changed, 293 insertions(+) create mode 100644 crates/core/src/settings.rs create mode 100644 crates/core/tests/settings.rs diff --git a/Cargo.lock b/Cargo.lock index e386b0f..aed83c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,34 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + [[package]] name = "equivalent" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -37,6 +59,17 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -75,6 +108,12 @@ version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "lock_api" version = "0.4.14" @@ -135,9 +174,16 @@ dependencies = [ "mdns-sd", "serde", "serde_json", + "tempfile", "toml", ] +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -156,6 +202,25 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -255,6 +320,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "toml" version = "1.1.8+spec-1.1.0" diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 8db7b32..8c9b5bd 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -27,3 +27,6 @@ required-features = ["test-support"] [[test]] name = "speaker" required-features = ["test-support"] + +[dev-dependencies] +tempfile = "3" diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 3bb7bea..d4b5422 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -4,6 +4,7 @@ pub mod discovery; pub mod mask; mod poll_schedule; pub mod schema_walk; +pub mod settings; #[cfg(feature = "test-support")] pub mod simulator; pub mod speaker; diff --git a/crates/core/src/settings.rs b/crates/core/src/settings.rs new file mode 100644 index 0000000..70a0bb5 --- /dev/null +++ b/crates/core/src/settings.rs @@ -0,0 +1,104 @@ +//! Persistent settings stored next to the executable. + +use std::ffi::OsString; +use std::fs::{self, File}; +use std::io::{self, ErrorKind, Write}; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +pub const SETTINGS_FILE: &str = "settings.json"; +pub const LOG_FILE: &str = "neumann-control.log"; + +const TEMPORARY_SUFFIX: &str = ".tmp"; +const QUARANTINE_SUFFIX: &str = ".bad"; + +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct Settings { + pub manual: Vec, + pub window: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ManualSpeaker { + pub address: String, + pub port: u16, + #[serde(default)] + pub serial: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct WindowGeometry { + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, +} + +/// Loads the settings; a missing file yields the defaults, an unreadable or +/// malformed one is moved aside (never overwriting an earlier one) and also +/// yields the defaults. +pub fn load(path: &Path) -> Settings { + let bytes = match fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == ErrorKind::NotFound => return Settings::default(), + Err(error) => { + log::warn!( + "settings file could not be read ({}); using defaults", + error.kind() + ); + quarantine(path); + return Settings::default(); + } + }; + match serde_json::from_slice(&bytes) { + Ok(settings) => settings, + Err(error) => { + // Only the error category is logged: the message can quote file contents, + // which include speaker addresses. + log::warn!( + "settings file is malformed ({:?}); using defaults", + error.classify() + ); + quarantine(path); + Settings::default() + } + } +} + +/// Writes through a temporary file in the same directory so a crash never +/// leaves a truncated settings file behind. +pub fn save(settings: &Settings, path: &Path) -> io::Result<()> { + let temporary = with_suffix(path, TEMPORARY_SUFFIX); + let serialized = serde_json::to_vec_pretty(settings)?; + let mut file = File::create(&temporary)?; + file.write_all(&serialized)?; + file.sync_all()?; + drop(file); + fs::rename(&temporary, path) +} + +fn quarantine(path: &Path) { + let target = free_quarantine_path(path); + if let Err(error) = fs::rename(path, &target) { + log::warn!("settings file could not be moved aside ({})", error.kind()); + } +} + +fn free_quarantine_path(path: &Path) -> PathBuf { + let first = with_suffix(path, QUARANTINE_SUFFIX); + if !first.exists() { + return first; + } + (1..) + .map(|number| with_suffix(&first, &format!(".{number}"))) + .find(|candidate| !candidate.exists()) + .expect("an unbounded range always yields a free name") +} + +fn with_suffix(path: &Path, suffix: &str) -> PathBuf { + let mut name: OsString = path.as_os_str().to_owned(); + name.push(suffix); + PathBuf::from(name) +} diff --git a/crates/core/tests/settings.rs b/crates/core/tests/settings.rs new file mode 100644 index 0000000..a231c33 --- /dev/null +++ b/crates/core/tests/settings.rs @@ -0,0 +1,107 @@ +use neumann_control_core::settings::{ + load, save, ManualSpeaker, Settings, WindowGeometry, SETTINGS_FILE, +}; +use std::fs; + +fn sample() -> Settings { + Settings { + manual: vec![ + ManualSpeaker { + address: "fe80::2a36:38ff:fe12:3456".to_string(), + port: 45, + serial: Some("SIM0001234".to_string()), + }, + ManualSpeaker { + address: "192.0.2.10".to_string(), + port: 45, + serial: None, + }, + ], + window: Some(WindowGeometry { + x: 10.0, + y: 20.0, + width: 800.0, + height: 600.0, + }), + } +} + +#[test] +fn missing_file_gives_default() { + let directory = tempfile::tempdir().unwrap(); + let loaded = load(&directory.path().join(SETTINGS_FILE)); + assert_eq!(loaded, Settings::default()); +} + +#[test] +fn round_trip_keeps_values() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(SETTINGS_FILE); + save(&sample(), &path).unwrap(); + assert_eq!(load(&path), sample()); + assert!(!directory.path().join("settings.json.tmp").exists()); +} + +#[test] +fn save_replaces_existing_file() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(SETTINGS_FILE); + save(&Settings::default(), &path).unwrap(); + save(&sample(), &path).unwrap(); + assert_eq!(load(&path), sample()); +} + +#[test] +fn malformed_file_is_moved_aside() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(SETTINGS_FILE); + fs::write(&path, "{ not json").unwrap(); + assert_eq!(load(&path), Settings::default()); + assert!(!path.exists()); + assert_eq!( + fs::read_to_string(directory.path().join("settings.json.bad")).unwrap(), + "{ not json" + ); +} + +#[test] +fn existing_bad_file_is_never_overwritten() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(SETTINGS_FILE); + fs::write(directory.path().join("settings.json.bad"), "first").unwrap(); + fs::write(&path, "second").unwrap(); + assert_eq!(load(&path), Settings::default()); + assert_eq!( + fs::read_to_string(directory.path().join("settings.json.bad")).unwrap(), + "first" + ); + assert_eq!( + fs::read_to_string(directory.path().join("settings.json.bad.1")).unwrap(), + "second" + ); +} + +#[test] +fn wrong_type_is_malformed() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(SETTINGS_FILE); + fs::write(&path, r#"{"manual": "oops"}"#).unwrap(); + assert_eq!(load(&path), Settings::default()); + assert!(directory.path().join("settings.json.bad").exists()); +} + +#[test] +fn unknown_and_missing_fields_are_tolerated() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(SETTINGS_FILE); + fs::write( + &path, + r#"{"future": 1, "manual": [{"address": "192.0.2.10", "port": 45, "extra": true}]}"#, + ) + .unwrap(); + let loaded = load(&path); + assert_eq!(loaded.manual.len(), 1); + assert_eq!(loaded.manual[0].serial, None); + assert_eq!(loaded.window, None); + assert!(path.exists()); +} From 255cc8596da854cc7cb125cb87b94c57c661c9f5 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:29:36 +0200 Subject: [PATCH 015/460] Add app state for speaker cards --- Cargo.lock | 4 + crates/app/Cargo.toml | 8 + crates/app/src/lib.rs | 2 + crates/app/src/state.rs | 459 ++++++++++++++++++++++++++++++++++++++++ crates/app/src/texts.rs | 36 ++++ 5 files changed, 509 insertions(+) create mode 100644 crates/app/src/lib.rs create mode 100644 crates/app/src/state.rs create mode 100644 crates/app/src/texts.rs diff --git a/Cargo.lock b/Cargo.lock index aed83c7..a0e42ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -165,6 +165,10 @@ dependencies = [ [[package]] name = "neumann-control-app" version = "0.1.0" +dependencies = [ + "neumann-control-core", + "serde_json", +] [[package]] name = "neumann-control-core" diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 5043123..f021dcd 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -9,3 +9,11 @@ rust-version.workspace = true [[bin]] name = "neumann-control" path = "src/main.rs" + +[lib] +name = "neumann_control_app" +path = "src/lib.rs" + +[dependencies] +neumann-control-core = { path = "../core" } +serde_json = "1" diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs new file mode 100644 index 0000000..fc8450b --- /dev/null +++ b/crates/app/src/lib.rs @@ -0,0 +1,2 @@ +pub mod state; +pub mod texts; diff --git a/crates/app/src/state.rs b/crates/app/src/state.rs new file mode 100644 index 0000000..5c1429e --- /dev/null +++ b/crates/app/src/state.rs @@ -0,0 +1,459 @@ +//! UI-independent state of the speaker cards: what the worker events add up to, which targets +//! to talk to, and the time-based behaviour of meters and the fader wheel. +//! All times are seconds of the UI clock passed in by the caller. + +use std::collections::{BTreeMap, BTreeSet}; +use std::net::SocketAddrV6; + +use neumann_control_core::address::parse_address; +use neumann_control_core::discovery::Found; +use neumann_control_core::mask::mask_serial; +use neumann_control_core::settings::ManualSpeaker; +use neumann_control_core::speaker::{Event, Identity}; +use neumann_control_core::ssc::wire::SscPath; +use serde_json::Value; + +use crate::texts; + +pub const PEAK_HOLD_SECONDS: f64 = 1.5; +pub const METER_FLOOR_DB: f64 = -60.0; +pub const METER_CEILING_DB: f64 = 6.0; +pub const WHEEL_STEP_DB: f64 = 0.5; +pub const WHEEL_IDLE_SECONDS: f64 = 0.3; +pub const FADER_MIN_DB: f64 = 0.0; +pub const FADER_MAX_DB: f64 = 120.0; + +const METER_PATHS: [&str; 2] = ["m/in/level", "m/out/level"]; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SpeakerKey { + Serial(String), + Address(SocketAddrV6), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Status { + Connecting, + Connected, + Unreachable, +} + +#[derive(Clone, Debug)] +pub struct SpeakerState { + pub key: SpeakerKey, + pub address: SocketAddrV6, + pub identity: Option, + pub values: BTreeMap, + pub absent: BTreeSet, + pub status: Status, + pub message: Option, + pub expanded: bool, + pub meters: Vec, + pub fader: FaderState, +} + +impl SpeakerState { + pub fn new(key: SpeakerKey, address: SocketAddrV6) -> SpeakerState { + SpeakerState { + key, + address, + identity: None, + values: BTreeMap::new(), + absent: BTreeSet::new(), + status: Status::Connecting, + message: None, + expanded: false, + meters: Vec::new(), + fader: FaderState::default(), + } + } + + pub fn apply(&mut self, event: Event, now: f64) { + match event { + Event::Connected(identity) => { + if !identity.serial.is_empty() { + self.key = SpeakerKey::Serial(identity.serial.clone()); + } + self.identity = Some(identity); + self.status = Status::Connected; + self.message = None; + } + // The masked reason is for the log; the card only shows the generic text. + Event::Unreachable(_) => { + self.status = Status::Unreachable; + self.message = Some(texts::UNREACHABLE.to_owned()); + } + Event::Value { path, value } => { + self.absent.remove(&path); + self.feed_meters(&path, &value, now); + self.values.insert(path, value); + } + Event::Absent(path) => { + self.values.remove(&path); + self.absent.insert(path); + } + Event::WriteRejected { error, .. } => { + self.message = Some(texts::describe_rejection(error.code)); + } + Event::WriteFailed { .. } => { + self.message = Some(texts::WRITE_FAILED.to_owned()); + } + Event::SerialMismatch { found, .. } => { + self.status = Status::Unreachable; + self.message = Some(texts::serial_mismatch(&mask_serial(&found))); + } + // The full-value dump has no state to land in until the report view exists. + Event::AllValues(_) | Event::AllValuesFailed(_) => {} + } + } + + fn feed_meters(&mut self, path: &SscPath, value: &Value, now: f64) { + let path_text = path.to_string(); + if !METER_PATHS.contains(&path_text.as_str()) { + return; + } + let Some(levels) = value.as_array() else { + return; + }; + if self.meters.len() < levels.len() { + self.meters.resize_with(levels.len(), MeterState::default); + } + for (meter, level) in self.meters.iter_mut().zip(levels) { + meter.update(level.as_f64().unwrap_or(METER_FLOOR_DB), now); + } + } +} + +/// Manual entries first (settings order), then discovered speakers not yet listed. A serial +/// present on both sides is one target at the discovered address; entries without serial are +/// identified by address. Manual entries whose address does not parse are skipped here and stay +/// untouched in the settings. +pub fn merge_targets(manual: &[ManualSpeaker], found: &[Found]) -> Vec<(SpeakerKey, SocketAddrV6)> { + let mut targets: Vec<(SpeakerKey, SocketAddrV6)> = Vec::new(); + for speaker in manual { + let Ok(address) = parse_address(&speaker.address, speaker.port) else { + continue; + }; + let key = match &speaker.serial { + Some(serial) => SpeakerKey::Serial(serial.clone()), + None => SpeakerKey::Address(address), + }; + push_or_replace(&mut targets, key, address, false); + } + for entry in found { + let key = match &entry.serial { + Some(serial) => SpeakerKey::Serial(serial.clone()), + None => SpeakerKey::Address(entry.address), + }; + push_or_replace(&mut targets, key, entry.address, true); + } + targets +} + +fn push_or_replace( + targets: &mut Vec<(SpeakerKey, SocketAddrV6)>, + key: SpeakerKey, + address: SocketAddrV6, + replace_address: bool, +) { + match targets.iter_mut().find(|(existing, _)| *existing == key) { + Some(existing) if replace_address => existing.1 = address, + Some(_) => {} + None => targets.push((key, address)), + } +} + +/// Maps a dB value linearly from the floor..ceiling range to 0..1; the device reports silence +/// as -160, which lands below the floor and gives 0. +pub fn fraction(db: f64) -> f32 { + let position = (db - METER_FLOOR_DB) / (METER_CEILING_DB - METER_FLOOR_DB); + position.clamp(0.0, 1.0) as f32 +} + +#[derive(Clone, Copy, Debug)] +pub struct MeterState { + level: f64, + peak: f64, + peak_since: f64, +} + +impl Default for MeterState { + fn default() -> MeterState { + MeterState { + level: METER_FLOOR_DB, + peak: METER_FLOOR_DB, + peak_since: 0.0, + } + } +} + +impl MeterState { + pub fn update(&mut self, level: f64, now: f64) { + self.level = level; + let hold_expired = now - self.peak_since >= PEAK_HOLD_SECONDS; + if level >= self.peak || hold_expired { + self.peak = level; + self.peak_since = now; + } + } + + pub fn level(&self) -> f64 { + self.level + } + + pub fn peak(&self) -> f64 { + self.peak + } +} + +/// Collects mouse-wheel steps so one write goes out after the wheel went quiet. +#[derive(Clone, Copy, Debug, Default)] +pub struct FaderState { + pending: Option, + last_step: f64, +} + +impl FaderState { + pub fn on_wheel(&mut self, steps: i32, current: f64, now: f64) { + let base = self.pending.unwrap_or(current); + let target = base + f64::from(steps) * WHEEL_STEP_DB; + self.pending = Some(target.clamp(FADER_MIN_DB, FADER_MAX_DB)); + self.last_step = now; + } + + pub fn due(&mut self, now: f64) -> Option { + let idle = now - self.last_step >= WHEEL_IDLE_SECONDS; + if self.pending.is_some() && idle { + return self.pending.take(); + } + None + } + + pub fn shown(&self, device: f64) -> f64 { + self.pending.unwrap_or(device) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use neumann_control_core::ssc::wire::DeviceError; + use serde_json::json; + + const SERIAL: &str = "SIM0001234"; + const OTHER_SERIAL: &str = "SIM0007500"; + const ADDRESS_TEXT: &str = "fe80::2a36:38ff:fe12:3456%7"; + const OTHER_ADDRESS_TEXT: &str = "fe80::2a36:38ff:fe12:3457%7"; + const PORT: u16 = 45; + + fn address(text: &str) -> SocketAddrV6 { + parse_address(text, PORT).unwrap() + } + + fn manual(text: &str, serial: Option<&str>) -> ManualSpeaker { + ManualSpeaker { + address: text.to_owned(), + port: PORT, + serial: serial.map(str::to_owned), + } + } + + fn found(text: &str, serial: Option<&str>) -> Found { + Found { + name: "KH 120".to_owned(), + product: None, + serial: serial.map(str::to_owned), + address: address(text), + } + } + + fn state() -> SpeakerState { + SpeakerState::new( + SpeakerKey::Address(address(ADDRESS_TEXT)), + address(ADDRESS_TEXT), + ) + } + + fn path(text: &str) -> SscPath { + SscPath::parse(text).unwrap() + } + + #[test] + fn same_serial_merges_into_one_target() { + let targets = merge_targets( + &[manual(ADDRESS_TEXT, Some(SERIAL))], + &[found(OTHER_ADDRESS_TEXT, Some(SERIAL))], + ); + assert_eq!( + targets, + vec![( + SpeakerKey::Serial(SERIAL.to_owned()), + address(OTHER_ADDRESS_TEXT) + )] + ); + } + + #[test] + fn manual_without_serial_stays_by_address() { + let targets = merge_targets( + &[manual(ADDRESS_TEXT, None), manual("not an address", None)], + &[found(OTHER_ADDRESS_TEXT, Some(OTHER_SERIAL))], + ); + assert_eq!( + targets, + vec![ + ( + SpeakerKey::Address(address(ADDRESS_TEXT)), + address(ADDRESS_TEXT) + ), + ( + SpeakerKey::Serial(OTHER_SERIAL.to_owned()), + address(OTHER_ADDRESS_TEXT) + ), + ] + ); + } + + #[test] + fn found_without_serial_dedupes_by_address() { + let targets = merge_targets(&[manual(ADDRESS_TEXT, None)], &[found(ADDRESS_TEXT, None)]); + assert_eq!(targets.len(), 1); + } + + #[test] + fn connected_upgrades_key_to_serial_and_keeps_address() { + let mut speaker = state(); + speaker.apply( + Event::Connected(Identity { + product: "KH 120 II".to_owned(), + version: "1".to_owned(), + serial: SERIAL.to_owned(), + name: "left".to_owned(), + }), + 0.0, + ); + assert_eq!(speaker.key, SpeakerKey::Serial(SERIAL.to_owned())); + assert_eq!(speaker.address, address(ADDRESS_TEXT)); + assert_eq!(speaker.status, Status::Connected); + } + + #[test] + fn unreachable_event_greys_card_and_keeps_values() { + let mut speaker = state(); + speaker.apply( + Event::Value { + path: path("device/name"), + value: json!("left"), + }, + 0.0, + ); + speaker.apply(Event::Unreachable("masked".to_owned()), 1.0); + assert_eq!(speaker.status, Status::Unreachable); + assert_eq!(speaker.message.as_deref(), Some(texts::UNREACHABLE)); + assert_eq!(speaker.values.len(), 1); + } + + #[test] + fn absent_event_removes_value() { + let mut speaker = state(); + speaker.apply( + Event::Value { + path: path("device/name"), + value: json!("left"), + }, + 0.0, + ); + speaker.apply(Event::Absent(path("device/name")), 1.0); + assert!(speaker.values.is_empty()); + assert!(speaker.absent.contains(&path("device/name"))); + } + + #[test] + fn write_rejected_sets_german_message() { + let mut speaker = state(); + speaker.apply( + Event::WriteRejected { + path: path("device/name"), + error: DeviceError { + code: 405, + description: String::new(), + }, + }, + 0.0, + ); + assert_eq!( + speaker.message.as_deref(), + Some("Gerät lehnt ab: nur lesbar") + ); + } + + #[test] + fn serial_mismatch_masks_found_serial() { + let mut speaker = state(); + speaker.apply( + Event::SerialMismatch { + expected: SERIAL.to_owned(), + found: OTHER_SERIAL.to_owned(), + }, + 0.0, + ); + assert_eq!(speaker.status, Status::Unreachable); + let message = speaker.message.unwrap(); + assert!(message.contains(&mask_serial(OTHER_SERIAL))); + assert!(!message.contains(OTHER_SERIAL)); + } + + #[test] + fn meter_value_feeds_one_meter_per_channel() { + let mut speaker = state(); + speaker.apply( + Event::Value { + path: path("m/out/level"), + value: json!([-12.0, -20.5]), + }, + 0.0, + ); + assert_eq!(speaker.meters.len(), 2); + assert_eq!(speaker.meters[1].level(), -20.5); + } + + #[test] + fn peak_holds_then_falls_after_1_5_s() { + let mut meter = MeterState::default(); + meter.update(-6.0, 0.0); + meter.update(-30.0, 1.0); + assert_eq!(meter.peak(), -6.0); + assert_eq!(meter.level(), -30.0); + meter.update(-30.0, 1.6); + assert_eq!(meter.peak(), -30.0); + } + + #[test] + fn meter_fraction_clamps_below_floor_and_above_ceiling() { + assert_eq!(fraction(-160.0), 0.0); + assert_eq!(fraction(10.0), 1.0); + assert_eq!(fraction(-27.0), 0.5); + } + + #[test] + fn wheel_steps_debounce_to_one_value() { + let mut fader = FaderState::default(); + fader.on_wheel(1, 80.0, 1.0); + fader.on_wheel(1, 80.0, 1.1); + fader.on_wheel(1, 80.0, 1.2); + assert_eq!(fader.shown(80.0), 81.5); + assert_eq!(fader.due(1.4), None); + assert_eq!(fader.due(1.5), Some(81.5)); + assert_eq!(fader.due(2.0), None); + assert_eq!(fader.shown(80.0), 80.0); + } + + #[test] + fn wheel_clamps_at_120() { + let mut fader = FaderState::default(); + fader.on_wheel(10, 119.0, 0.0); + assert_eq!(fader.due(1.0), Some(120.0)); + fader.on_wheel(-1000, 1.0, 2.0); + assert_eq!(fader.due(3.0), Some(0.0)); + } +} diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs new file mode 100644 index 0000000..794a69d --- /dev/null +++ b/crates/app/src/texts.rs @@ -0,0 +1,36 @@ +//! German UI strings; the only place user-visible text lives. + +use neumann_control_core::catalog::ValueError; + +pub const UNREACHABLE: &str = "nicht erreichbar (aus oder Standby)"; +pub const WRITE_FAILED: &str = "Schreiben fehlgeschlagen"; +const SERIAL_MISMATCH_PREFIX: &str = "Anderes Gerät unter dieser Adresse (Seriennummer "; +const SERIAL_MISMATCH_SUFFIX: &str = ")"; + +const REJECTED_READ_ONLY_CODE: i64 = 405; +const REJECTED_WRONG_VALUE_CODE: i64 = 406; + +pub fn serial_mismatch(masked_serial: &str) -> String { + format!("{SERIAL_MISMATCH_PREFIX}{masked_serial}{SERIAL_MISMATCH_SUFFIX}") +} + +pub fn describe_value_error(error: &ValueError) -> String { + match error { + ValueError::OutOfRange { min, max } => format!("Wert außerhalb von {min} bis {max}."), + ValueError::NotAnOption => "Wert ist keine der Auswahlmöglichkeiten.".to_owned(), + ValueError::NotAStep => "Wert passt nicht zur Schrittweite.".to_owned(), + ValueError::TooLong(limit) => format!("Höchstens {limit} Zeichen."), + ValueError::NotPrintableAscii => { + "Nur Buchstaben A–Z, Ziffern und Satzzeichen ohne Umlaute.".to_owned() + } + ValueError::WrongType => "Wert hat den falschen Typ.".to_owned(), + } +} + +pub fn describe_rejection(code: i64) -> String { + match code { + REJECTED_READ_ONLY_CODE => "Gerät lehnt ab: nur lesbar".to_owned(), + REJECTED_WRONG_VALUE_CODE => "Gerät lehnt ab: falscher Wert".to_owned(), + other => format!("Gerät lehnt ab (Code {other})"), + } +} From d2bae4999f53e8267e411a99127775580018c07b Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:36:27 +0200 Subject: [PATCH 016/460] Keep meters apart and merge manual speakers by address --- crates/app/src/state.rs | 199 +++++++++++++++++++++++++++++++--------- 1 file changed, 157 insertions(+), 42 deletions(-) diff --git a/crates/app/src/state.rs b/crates/app/src/state.rs index 5c1429e..39ce893 100644 --- a/crates/app/src/state.rs +++ b/crates/app/src/state.rs @@ -4,8 +4,10 @@ use std::collections::{BTreeMap, BTreeSet}; use std::net::SocketAddrV6; +use std::ops::RangeInclusive; use neumann_control_core::address::parse_address; +use neumann_control_core::catalog::Catalog; use neumann_control_core::discovery::Found; use neumann_control_core::mask::mask_serial; use neumann_control_core::settings::ManualSpeaker; @@ -20,10 +22,10 @@ pub const METER_FLOOR_DB: f64 = -60.0; pub const METER_CEILING_DB: f64 = 6.0; pub const WHEEL_STEP_DB: f64 = 0.5; pub const WHEEL_IDLE_SECONDS: f64 = 0.3; -pub const FADER_MIN_DB: f64 = 0.0; -pub const FADER_MAX_DB: f64 = 120.0; -const METER_PATHS: [&str; 2] = ["m/in/level", "m/out/level"]; +const INPUT_METER_PATH: &str = "m/in/level"; +const OUTPUT_METER_PATH: &str = "m/out/level"; +const OUTPUT_LEVEL_PATH: &str = "audio/out/level"; #[derive(Clone, Debug, PartialEq, Eq)] pub enum SpeakerKey { @@ -47,8 +49,11 @@ pub struct SpeakerState { pub absent: BTreeSet, pub status: Status, pub message: Option, + /// The path a write message is about; a fresh value for it clears the message. + message_path: Option, pub expanded: bool, - pub meters: Vec, + pub input_meters: Vec, + pub output_meters: Vec, pub fader: FaderState, } @@ -62,8 +67,10 @@ impl SpeakerState { absent: BTreeSet::new(), status: Status::Connecting, message: None, + message_path: None, expanded: false, - meters: Vec::new(), + input_meters: Vec::new(), + output_meters: Vec::new(), fader: FaderState::default(), } } @@ -76,14 +83,18 @@ impl SpeakerState { } self.identity = Some(identity); self.status = Status::Connected; - self.message = None; + self.set_message(None, None); } // The masked reason is for the log; the card only shows the generic text. Event::Unreachable(_) => { self.status = Status::Unreachable; - self.message = Some(texts::UNREACHABLE.to_owned()); + self.set_message(Some(texts::UNREACHABLE.to_owned()), None); + self.reset_meters(); } Event::Value { path, value } => { + if self.message_path.as_ref() == Some(&path) { + self.set_message(None, None); + } self.absent.remove(&path); self.feed_meters(&path, &value, now); self.values.insert(path, value); @@ -92,41 +103,56 @@ impl SpeakerState { self.values.remove(&path); self.absent.insert(path); } - Event::WriteRejected { error, .. } => { - self.message = Some(texts::describe_rejection(error.code)); + Event::WriteRejected { path, error } => { + let text = texts::describe_rejection(error.code); + self.set_message(Some(text), Some(path)); } - Event::WriteFailed { .. } => { - self.message = Some(texts::WRITE_FAILED.to_owned()); + Event::WriteFailed { path, .. } => { + self.set_message(Some(texts::WRITE_FAILED.to_owned()), Some(path)); } Event::SerialMismatch { found, .. } => { self.status = Status::Unreachable; - self.message = Some(texts::serial_mismatch(&mask_serial(&found))); + let text = texts::serial_mismatch(&mask_serial(&found)); + self.set_message(Some(text), None); } // The full-value dump has no state to land in until the report view exists. Event::AllValues(_) | Event::AllValuesFailed(_) => {} } } - fn feed_meters(&mut self, path: &SscPath, value: &Value, now: f64) { - let path_text = path.to_string(); - if !METER_PATHS.contains(&path_text.as_str()) { - return; + fn set_message(&mut self, message: Option, path: Option) { + self.message = message; + self.message_path = path; + } + + fn reset_meters(&mut self) { + let meters = self.input_meters.iter_mut().chain(&mut self.output_meters); + for meter in meters { + meter.reset(); } + } + + fn feed_meters(&mut self, path: &SscPath, value: &Value, now: f64) { + let meters = match path.to_string().as_str() { + INPUT_METER_PATH => &mut self.input_meters, + OUTPUT_METER_PATH => &mut self.output_meters, + _ => return, + }; let Some(levels) = value.as_array() else { return; }; - if self.meters.len() < levels.len() { - self.meters.resize_with(levels.len(), MeterState::default); + if meters.len() < levels.len() { + meters.resize_with(levels.len(), MeterState::default); } - for (meter, level) in self.meters.iter_mut().zip(levels) { + for (meter, level) in meters.iter_mut().zip(levels) { meter.update(level.as_f64().unwrap_or(METER_FLOOR_DB), now); } } } -/// Manual entries first (settings order), then discovered speakers not yet listed. A serial -/// present on both sides is one target at the discovered address; entries without serial are -/// identified by address. Manual entries whose address does not parse are skipped here and stay +/// Manual entries first (settings order), then discovered speakers not yet listed. A speaker +/// present on both sides (same serial, or same address) is one target at the discovered +/// address, keyed by the serial once either side knows it. Manual entries whose address does not parse are skipped here and stay /// untouched in the settings. pub fn merge_targets(manual: &[ManualSpeaker], found: &[Found]) -> Vec<(SpeakerKey, SocketAddrV6)> { let mut targets: Vec<(SpeakerKey, SocketAddrV6)> = Vec::new(); @@ -154,13 +180,35 @@ fn push_or_replace( targets: &mut Vec<(SpeakerKey, SocketAddrV6)>, key: SpeakerKey, address: SocketAddrV6, - replace_address: bool, + from_found: bool, ) { - match targets.iter_mut().find(|(existing, _)| *existing == key) { - Some(existing) if replace_address => existing.1 = address, - Some(_) => {} - None => targets.push((key, address)), + let position = targets + .iter() + .position(|(existing, existing_address)| *existing == key || *existing_address == address); + let Some(position) = position else { + targets.push((key, address)); + return; + }; + if !from_found { + return; } + if matches!(key, SpeakerKey::Serial(_)) { + targets[position].0 = key; + } + targets[position].1 = address; +} + +/// The catalog range of the output level, which the wheel fader drives. +pub fn output_level_range() -> RangeInclusive { + let path = SscPath::parse(OUTPUT_LEVEL_PATH).expect("constant path"); + let entry = Catalog::embedded() + .entries + .iter() + .find(|entry| entry.path == path) + .expect("catalog has the output level"); + let min = entry.min.expect("output level has a minimum"); + let max = entry.max.expect("output level has a maximum"); + min..=max } /// Maps a dB value linearly from the floor..ceiling range to 0..1; the device reports silence @@ -201,9 +249,17 @@ impl MeterState { self.level } - pub fn peak(&self) -> f64 { + /// The held peak, or the current level once the hold ran out. + pub fn peak(&self, now: f64) -> f64 { + if now - self.peak_since >= PEAK_HOLD_SECONDS { + return self.level; + } self.peak } + + pub fn reset(&mut self) { + *self = MeterState::default(); + } } /// Collects mouse-wheel steps so one write goes out after the wheel went quiet. @@ -214,10 +270,11 @@ pub struct FaderState { } impl FaderState { - pub fn on_wheel(&mut self, steps: i32, current: f64, now: f64) { + /// `range` is the allowed value range of the faded entry. + pub fn on_wheel(&mut self, steps: i32, current: f64, range: RangeInclusive, now: f64) { let base = self.pending.unwrap_or(current); let target = base + f64::from(steps) * WHEEL_STEP_DB; - self.pending = Some(target.clamp(FADER_MIN_DB, FADER_MAX_DB)); + self.pending = Some(target.clamp(*range.start(), *range.end())); self.last_step = now; } @@ -274,6 +331,13 @@ mod tests { ) } + fn value_event(text: &str, value: Value) -> Event { + Event::Value { + path: path(text), + value, + } + } + fn path(text: &str) -> SscPath { SscPath::parse(text).unwrap() } @@ -413,8 +477,8 @@ mod tests { }, 0.0, ); - assert_eq!(speaker.meters.len(), 2); - assert_eq!(speaker.meters[1].level(), -20.5); + assert_eq!(speaker.output_meters.len(), 2); + assert_eq!(speaker.output_meters[1].level(), -20.5); } #[test] @@ -422,10 +486,58 @@ mod tests { let mut meter = MeterState::default(); meter.update(-6.0, 0.0); meter.update(-30.0, 1.0); - assert_eq!(meter.peak(), -6.0); + assert_eq!(meter.peak(1.0), -6.0); assert_eq!(meter.level(), -30.0); - meter.update(-30.0, 1.6); - assert_eq!(meter.peak(), -30.0); + assert_eq!(meter.peak(1.25), -6.0); + assert_eq!(meter.peak(1.5), -30.0); + } + + #[test] + fn input_and_output_meters_are_kept_apart() { + let mut speaker = state(); + speaker.apply(value_event("m/in/level", json!([-10.0])), 0.0); + speaker.apply(value_event("m/out/level", json!([-20.0, -30.0])), 0.0); + assert_eq!(speaker.input_meters.len(), 1); + assert_eq!(speaker.input_meters[0].level(), -10.0); + assert_eq!(speaker.output_meters.len(), 2); + } + + #[test] + fn unreachable_resets_meters() { + let mut speaker = state(); + speaker.apply(value_event("m/out/level", json!([-3.0])), 0.0); + speaker.apply(Event::Unreachable("masked".to_owned()), 0.5); + assert_eq!(speaker.output_meters[0].level(), METER_FLOOR_DB); + assert_eq!(speaker.output_meters[0].peak(0.5), METER_FLOOR_DB); + } + + #[test] + fn write_message_clears_when_the_path_reports_a_value() { + let mut speaker = state(); + speaker.apply( + Event::WriteFailed { + path: path("audio/out/level"), + message: "lost".to_owned(), + }, + 0.0, + ); + assert_eq!(speaker.message.as_deref(), Some(texts::WRITE_FAILED)); + speaker.apply(value_event("device/name", json!("left")), 1.0); + assert!(speaker.message.is_some()); + speaker.apply(value_event("audio/out/level", json!(80.0)), 2.0); + assert_eq!(speaker.message, None); + } + + #[test] + fn manual_address_and_found_serial_merge_into_one_target() { + let targets = merge_targets( + &[manual(ADDRESS_TEXT, None)], + &[found(ADDRESS_TEXT, Some(SERIAL))], + ); + assert_eq!( + targets, + vec![(SpeakerKey::Serial(SERIAL.to_owned()), address(ADDRESS_TEXT))] + ); } #[test] @@ -437,23 +549,26 @@ mod tests { #[test] fn wheel_steps_debounce_to_one_value() { + let range = output_level_range(); let mut fader = FaderState::default(); - fader.on_wheel(1, 80.0, 1.0); - fader.on_wheel(1, 80.0, 1.1); - fader.on_wheel(1, 80.0, 1.2); + fader.on_wheel(1, 80.0, range.clone(), 1.0); + fader.on_wheel(1, 80.0, range.clone(), 1.125); + fader.on_wheel(1, 80.0, range, 1.25); assert_eq!(fader.shown(80.0), 81.5); - assert_eq!(fader.due(1.4), None); - assert_eq!(fader.due(1.5), Some(81.5)); + assert_eq!(fader.due(1.5), None); + assert_eq!(fader.due(1.625), Some(81.5)); assert_eq!(fader.due(2.0), None); assert_eq!(fader.shown(80.0), 80.0); } #[test] fn wheel_clamps_at_120() { + let range = output_level_range(); + assert_eq!(range, 0.0..=120.0); let mut fader = FaderState::default(); - fader.on_wheel(10, 119.0, 0.0); + fader.on_wheel(10, 119.0, range.clone(), 0.0); assert_eq!(fader.due(1.0), Some(120.0)); - fader.on_wheel(-1000, 1.0, 2.0); + fader.on_wheel(-1000, 1.0, range, 2.0); assert_eq!(fader.due(3.0), Some(0.0)); } } From 5aa64c094b353765949ec317508a924bac1f9b90 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:02:49 +0200 Subject: [PATCH 017/460] Show speakers as channel-strip cards --- .gitattributes | 1 + Cargo.lock | 4542 +++++++++++++++++++- about.hbs | 100 +- crates/app/Cargo.toml | 8 + crates/app/assets/fonts/Inter-Regular.ttf | Bin 0 -> 411640 bytes crates/app/assets/fonts/Inter-SemiBold.ttf | Bin 0 -> 419744 bytes crates/app/assets/fonts/OFL-Inter.txt | 92 + crates/app/assets/fonts/SOURCES.md | 18 + crates/app/src/app.rs | 323 ++ crates/app/src/lib.rs | 4 + crates/app/src/main.rs | 89 +- crates/app/src/spawner.rs | 63 + crates/app/src/state.rs | 100 +- crates/app/src/texts.rs | 54 + crates/app/src/ui_kit/fonts.rs | 90 + crates/app/src/ui_kit/mod.rs | 2 + crates/app/src/ui_kit/theme.rs | 172 + crates/app/src/views/add_address.rs | 126 + crates/app/src/views/card.rs | 292 ++ crates/app/src/views/mod.rs | 3 + crates/app/src/views/toolbar.rs | 49 + crates/app/tests/views.rs | 399 ++ 22 files changed, 6204 insertions(+), 323 deletions(-) create mode 100644 crates/app/assets/fonts/Inter-Regular.ttf create mode 100644 crates/app/assets/fonts/Inter-SemiBold.ttf create mode 100644 crates/app/assets/fonts/OFL-Inter.txt create mode 100644 crates/app/assets/fonts/SOURCES.md create mode 100644 crates/app/src/app.rs create mode 100644 crates/app/src/spawner.rs create mode 100644 crates/app/src/ui_kit/fonts.rs create mode 100644 crates/app/src/ui_kit/mod.rs create mode 100644 crates/app/src/ui_kit/theme.rs create mode 100644 crates/app/src/views/add_address.rs create mode 100644 crates/app/src/views/card.rs create mode 100644 crates/app/src/views/mod.rs create mode 100644 crates/app/src/views/toolbar.rs create mode 100644 crates/app/tests/views.rs diff --git a/.gitattributes b/.gitattributes index 6313b56..3a68bdb 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ * text=auto eol=lf +*.ttf binary diff --git a/Cargo.lock b/Cargo.lock index a0e42ef..e0d48fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,442 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "accesskit" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "uuid", +] + +[[package]] +name = "accesskit_atspi_common" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +dependencies = [ + "accesskit", + "accesskit_consumer 0.36.0", + "atspi-common", + "phf", + "serde", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_macos" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +dependencies = [ + "accesskit", + "accesskit_consumer 0.38.0", + "hashbrown 0.16.1", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +dependencies = [ + "accesskit", + "accesskit_consumer 0.35.0", + "hashbrown 0.16.1", + "static_assertions", + "windows", + "windows-core", +] + +[[package]] +name = "accesskit_winit" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +dependencies = [ + "accesskit", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.2", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.21", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "arboard" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.61.2", + "x11rb", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.5", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.5", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.5", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atspi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" +dependencies = [ + "atspi-common", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-proxies" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bit-set" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.2" @@ -9,408 +445,4072 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] -name = "cfg-if" -version = "1.0.5" +name = "block2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.3", +] [[package]] -name = "equivalent" -version = "1.0.3" +name = "block2" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.5", +] [[package]] -name = "errno" -version = "0.3.14" +name = "blocking" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" dependencies = [ - "libc", - "windows-sys", + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", ] [[package]] -name = "fastrand" -version = "2.5.0" +name = "bumpalo" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.2", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" +dependencies = [ + "bitflags 2.13.2", + "polling", + "rustix 1.1.5", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.5", + "rustix 1.1.5", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "ecolor" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +dependencies = [ + "bytemuck", + "emath", +] + +[[package]] +name = "eframe" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "egui-wgpu", + "egui-winit", + "egui_glow", + "glutin", + "glutin-winit", + "image", + "js-sys", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "pollster", + "profiling", + "raw-window-handle", + "static_assertions", + "wasm-bindgen", + "web-sys", + "web-time", + "wgpu", + "windows-sys 0.61.2", + "winit", +] + +[[package]] +name = "egui" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +dependencies = [ + "accesskit", + "ahash", + "bitflags 2.13.2", + "emath", + "epaint", + "itertools", + "log", + "nohash-hasher", + "profiling", + "smallvec", + "unicode-segmentation", + "web-sys", +] + +[[package]] +name = "egui-wgpu" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "epaint", + "log", + "profiling", + "thiserror 2.0.21", + "type-map", + "web-time", + "wgpu", + "winit", +] + +[[package]] +name = "egui-winit" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +dependencies = [ + "accesskit_winit", + "arboard", + "bytemuck", + "egui", + "log", + "objc2 0.6.5", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "profiling", + "raw-window-handle", + "smithay-clipboard", + "web-time", + "webbrowser", + "winit", +] + +[[package]] +name = "egui_glow" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +dependencies = [ + "bytemuck", + "egui", + "glow", + "log", + "profiling", + "winit", +] + +[[package]] +name = "egui_kittest" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +dependencies = [ + "eframe", + "egui", + "kittest", + "log", + "serde", + "toml", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "emath" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "epaint" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +dependencies = [ + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "font-types", + "harfrust", + "log", + "nohash-hasher", + "parking_lot", + "profiling", + "self_cell", + "skrifa", + "smallvec", + "unicode-general-category", + "unicode-segmentation", + "vello_cpu", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fearless_simd" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.12.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835ae9d781464a79026cde387f9cbc650c9a4909d7cf7e658b127e3c35d2f35d" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.5", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glifo" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +dependencies = [ + "bytemuck", + "foldhash", + "hashbrown 0.17.1", + "log", + "peniko", + "skrifa", + "smallvec", + "vello_common", +] + +[[package]] +name = "glow" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.13.2", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-allocator" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" +dependencies = [ + "ash", + "hashbrown 0.16.1", + "log", + "presser", + "thiserror 2.0.21", + "windows", +] + +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "read-fonts", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "if-addrs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "tiff", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kittest" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +dependencies = [ + "accesskit", + "accesskit_consumer 0.35.0", +] + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "bitflags 2.13.2", + "libc", + "plain", + "redox_syscall 0.9.5", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "mdns-sd" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "127d3355da0615643c88c885e29a1ccb29ca4b1ca1f2e44b3caed2e728a796aa" +dependencies = [ + "fastrand", + "flume", + "if-addrs", + "log", + "mio", + "socket-pktinfo", + "socket2", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "naga" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.17.1", + "indexmap", + "libm", + "log", + "naga-types", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.21", + "unicode-ident", +] + +[[package]] +name = "naga-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "rustc-hash 1.1.0", + "thiserror 2.0.21", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "neumann-control-app" +version = "0.1.0" +dependencies = [ + "eframe", + "egui_kittest", + "log", + "neumann-control-core", + "serde_json", + "simplelog", + "tempfile", +] + +[[package]] +name = "neumann-control-core" +version = "0.1.0" +dependencies = [ + "log", + "mdns-sd", + "serde", + "serde_json", + "tempfile", + "toml", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num_threads" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4" +dependencies = [ + "libc", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d5490aaf8f1d7cf7688dfa9b0ce07900e168852c45cd2c03f534dfd27cfd0b" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "libc", + "objc2 0.5.3", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.5", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.3", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-float" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336" + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_syscall" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit 0.19.2", + "tiny-skia", +] + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simplelog" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16257adbfaef1ee58b1363bdc0664c9b8e1e30aed86049635fb5f147d065a9c0" +dependencies = [ + "log", + "termcolor", + "time", +] + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.14.5", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.5", + "thiserror 2.0.21", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "socket-pktinfo" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612942246d0cc239cfd83af1dfd39be47f649208a3524e5e9da651910128e0ac" +dependencies = [ + "libc", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.3", +] + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vello_common" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "log", + "peniko", + "smallvec", + "thiserror 2.0.21", +] + +[[package]] +name = "vello_cpu" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +dependencies = [ + "bytemuck", + "glifo", + "hashbrown 0.17.1", + "vello_common", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.5", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.2", + "rustix 1.1.5", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.2", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.5", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webbrowser" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" +dependencies = [ + "jni", + "log", + "ndk-context", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "url", + "web-sys", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e" +dependencies = [ + "arrayvec", + "bitflags 2.13.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.13.2", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "indexmap", + "log", + "naga", + "naga-types", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.21", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-wasm", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-wasm" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d260ca2ca8adc654f513a869bf3cac0b47f5b8d3f87803d6942015f8de597aae" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.13.2", + "block2 0.6.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "glow", + "glutin_wgl_sys", + "gpu-allocator", + "hashbrown 0.17.1", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "naga", + "naga-types", + "ndk-sys", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "raw-window-metal", + "renderdoc-sys", + "smallvec", + "static_assertions", + "thiserror 2.0.21", + "wasm-bindgen", + "wayland-sys", + "web-sys", + "wgpu-naga-bridge", + "wgpu-types", + "windows", + "windows-core", + "windows-result", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081" +dependencies = [ + "naga", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "js-sys", + "log", + "naga-types", + "raw-window-handle", + "static_assertions", + "web-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] [[package]] -name = "flume" -version = "0.12.0" +name = "windows-targets" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "futures-core", - "futures-sink", - "spin", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] -name = "futures-core" -version = "0.3.34" +name = "windows-threading" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] [[package]] -name = "futures-sink" -version = "0.3.34" +name = "windows_aarch64_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] -name = "getrandom" -version = "0.4.3" +name = "windows_aarch64_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "libc", - "r-efi", -] +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] -name = "hashbrown" -version = "0.17.1" +name = "windows_i686_gnu" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] -name = "if-addrs" -version = "0.15.0" +name = "windows_i686_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" -dependencies = [ - "libc", - "windows-sys", -] +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] -name = "indexmap" -version = "2.14.2" +name = "windows_i686_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" -dependencies = [ - "equivalent", - "hashbrown", -] +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] -name = "itoa" -version = "1.0.18" +name = "windows_x86_64_gnu" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] -name = "libc" -version = "0.2.191" +name = "windows_x86_64_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] -name = "linux-raw-sys" -version = "0.12.1" +name = "windows_x86_64_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] -name = "lock_api" -version = "0.4.14" +name = "winit" +version = "0.30.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" dependencies = [ - "scopeguard", + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.2", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", ] [[package]] -name = "log" -version = "0.4.34" +name = "winnow" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] [[package]] -name = "mdns-sd" -version = "0.21.5" +name = "wit-bindgen" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "127d3355da0615643c88c885e29a1ccb29ca4b1ca1f2e44b3caed2e728a796aa" -dependencies = [ - "fastrand", - "flume", - "if-addrs", - "log", - "mio", - "socket-pktinfo", - "socket2", -] +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] -name = "memchr" -version = "2.8.3" +name = "writeable" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] -name = "mio" -version = "1.2.4" +name = "x11-dl" +version = "2.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" dependencies = [ "libc", - "log", - "wasi", - "windows-sys", + "once_cell", + "pkg-config", ] [[package]] -name = "neumann-control-app" -version = "0.1.0" +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ - "neumann-control-core", - "serde_json", + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.5", + "x11rb-protocol", ] [[package]] -name = "neumann-control-core" -version = "0.1.0" -dependencies = [ - "log", - "mdns-sd", - "serde", - "serde_json", - "tempfile", - "toml", -] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" [[package]] -name = "once_cell" -version = "1.21.4" +name = "xcursor" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" [[package]] -name = "proc-macro2" -version = "1.0.107" +name = "xkbcommon-dl" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" dependencies = [ - "unicode-ident", + "bitflags 2.13.2", + "dlib", + "log", + "once_cell", + "xkeysym", ] [[package]] -name = "quote" -version = "1.0.47" +name = "xkeysym" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" [[package]] -name = "r-efi" -version = "6.0.0" +name = "xml-rs" +version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] -name = "rustix" -version = "1.1.5" +name = "yoke" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys", + "stable_deref_trait", + "yoke-derive", + "zerofrom", ] [[package]] -name = "scopeguard" -version = "1.2.0" +name = "yoke-derive" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] [[package]] -name = "serde" -version = "1.0.229" +name = "zbus" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" dependencies = [ - "serde_core", - "serde_derive", + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.5", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", ] [[package]] -name = "serde_core" -version = "1.0.229" +name = "zbus-lockstep" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" dependencies = [ - "serde_derive", + "zbus_xml", + "zvariant", ] [[package]] -name = "serde_derive" -version = "1.0.229" +name = "zbus-lockstep-macros" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", + "zbus-lockstep", + "zbus_xml", + "zvariant", ] [[package]] -name = "serde_json" -version = "1.0.154" +name = "zbus_macros" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", + "zbus_names", + "zvariant", + "zvariant_utils", ] [[package]] -name = "serde_spanned" -version = "1.1.2" +name = "zbus_names" +version = "4.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd" dependencies = [ - "serde_core", + "serde", + "winnow", + "zvariant", ] [[package]] -name = "socket-pktinfo" -version = "0.4.1" +name = "zbus_xml" +version = "5.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612942246d0cc239cfd83af1dfd39be47f649208a3524e5e9da651910128e0ac" +checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" dependencies = [ - "libc", - "socket2", - "windows-sys", + "serde", + "winnow", + "zbus_names", + "zvariant", ] [[package]] -name = "socket2" -version = "0.6.5" +name = "zcheapstr" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" dependencies = [ - "libc", - "windows-sys", + "serde", ] [[package]] -name = "spin" -version = "0.9.9" +name = "zerocopy" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" dependencies = [ - "lock_api", + "zerocopy-derive", ] [[package]] -name = "syn" -version = "3.0.9" +name = "zerocopy-derive" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" dependencies = [ "proc-macro2", "quote", - "unicode-ident", + "syn 2.0.119", ] [[package]] -name = "tempfile" -version = "3.27.0" +name = "zerofrom" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ - "fastrand", - "getrandom", - "once_cell", - "rustix", - "windows-sys", + "zerofrom-derive", ] [[package]] -name = "toml" -version = "1.1.8+spec-1.1.0" +name = "zerofrom-derive" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ - "indexmap", - "serde_core", - "serde_spanned", - "toml_datetime", - "toml_parser", - "toml_writer", - "winnow", + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", ] [[package]] -name = "toml_datetime" -version = "1.1.2+spec-1.1.0" +name = "zerotrie" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ - "serde_core", + "displaydoc", + "yoke", + "zerofrom", ] [[package]] -name = "toml_parser" -version = "1.1.5+spec-1.1.0" +name = "zerovec" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ - "winnow", + "yoke", + "zerofrom", + "zerovec-derive", ] [[package]] -name = "toml_writer" -version = "1.1.3+spec-1.1.0" +name = "zerovec-derive" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] [[package]] -name = "unicode-ident" -version = "1.0.27" +name = "zlib-rs" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" [[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" +name = "zmij" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] -name = "windows-link" -version = "0.2.1" +name = "zune-core" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" [[package]] -name = "windows-sys" -version = "0.61.2" +name = "zune-jpeg" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ - "windows-link", + "zune-core", ] [[package]] -name = "winnow" -version = "1.0.4" +name = "zvariant" +version = "5.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] [[package]] -name = "zmij" -version = "1.0.23" +name = "zvariant_derive" +version = "5.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.9", + "winnow", +] diff --git a/about.hbs b/about.hbs index 1b9fbf0..8ca5fdc 100644 --- a/about.hbs +++ b/about.hbs @@ -83,7 +83,8 @@ {{/each}}

Fonts

- +

Inter 4.1 (SIL Open Font License 1.1)

Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
@@ -168,103 +169,6 @@ TERMINATION
 This license becomes null and void if any of the above conditions are
 not met.
 
-DISCLAIMER
-THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
-MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
-OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
-COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
-INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
-DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
-OTHER DEALINGS IN THE FONT SOFTWARE.
-
-
-
-

JetBrains Mono NL 2.304 (SIL Open Font License 1.1)

-
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
-
-This Font Software is licensed under the SIL Open Font License, Version 1.1.
-This license is copied below, and is also available with a FAQ at:
-https://scripts.sil.org/OFL
-
-
------------------------------------------------------------
-SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
------------------------------------------------------------
-
-PREAMBLE
-The goals of the Open Font License (OFL) are to stimulate worldwide
-development of collaborative font projects, to support the font creation
-efforts of academic and linguistic communities, and to provide a free and
-open framework in which fonts may be shared and improved in partnership
-with others.
-
-The OFL allows the licensed fonts to be used, studied, modified and
-redistributed freely as long as they are not sold by themselves. The
-fonts, including any derivative works, can be bundled, embedded, 
-redistributed and/or sold with any software provided that any reserved
-names are not used by derivative works. The fonts and derivatives,
-however, cannot be released under any other type of license. The
-requirement for fonts to remain under this license does not apply
-to any document created using the fonts or their derivatives.
-
-DEFINITIONS
-"Font Software" refers to the set of files released by the Copyright
-Holder(s) under this license and clearly marked as such. This may
-include source files, build scripts and documentation.
-
-"Reserved Font Name" refers to any names specified as such after the
-copyright statement(s).
-
-"Original Version" refers to the collection of Font Software components as
-distributed by the Copyright Holder(s).
-
-"Modified Version" refers to any derivative made by adding to, deleting,
-or substituting -- in part or in whole -- any of the components of the
-Original Version, by changing formats or by porting the Font Software to a
-new environment.
-
-"Author" refers to any designer, engineer, programmer, technical
-writer or other person who contributed to the Font Software.
-
-PERMISSION & CONDITIONS
-Permission is hereby granted, free of charge, to any person obtaining
-a copy of the Font Software, to use, study, copy, merge, embed, modify,
-redistribute, and sell modified and unmodified copies of the Font
-Software, subject to the following conditions:
-
-1) Neither the Font Software nor any of its individual components,
-in Original or Modified Versions, may be sold by itself.
-
-2) Original or Modified Versions of the Font Software may be bundled,
-redistributed and/or sold with any software, provided that each copy
-contains the above copyright notice and this license. These can be
-included either as stand-alone text files, human-readable headers or
-in the appropriate machine-readable metadata fields within text or
-binary files as long as those fields can be easily viewed by the user.
-
-3) No Modified Version of the Font Software may use the Reserved Font
-Name(s) unless explicit written permission is granted by the corresponding
-Copyright Holder. This restriction only applies to the primary font name as
-presented to the users.
-
-4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
-Software shall not be used to promote, endorse or advertise any
-Modified Version, except to acknowledge the contribution(s) of the
-Copyright Holder(s) and the Author(s) or with their explicit written
-permission.
-
-5) The Font Software, modified or unmodified, in part or in whole,
-must be distributed entirely under this license, and must not be
-distributed under any other license. The requirement for fonts to
-remain under this license does not apply to any document created
-using the Font Software.
-
-TERMINATION
-This license becomes null and void if any of the above conditions are
-not met.
-
 DISCLAIMER
 THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
 EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml
index f021dcd..1845c45 100644
--- a/crates/app/Cargo.toml
+++ b/crates/app/Cargo.toml
@@ -17,3 +17,11 @@ path = "src/lib.rs"
 [dependencies]
 neumann-control-core = { path = "../core" }
 serde_json = "1"
+eframe = "0.36"
+log = "0.4"
+simplelog = "0.12"
+
+[dev-dependencies]
+egui_kittest = { version = "0.36", features = ["eframe"] }
+neumann-control-core = { path = "../core", features = ["test-support"] }
+tempfile = "3"
diff --git a/crates/app/assets/fonts/Inter-Regular.ttf b/crates/app/assets/fonts/Inter-Regular.ttf
new file mode 100644
index 0000000000000000000000000000000000000000..b7aaca8de1fe458399e17311d35d543a1fa2f984
GIT binary patch
literal 411640
zcmeFa2bdK__C8!)T~*yZGr*7rM8f2m8vtcSauQ)c7Eu8aMG*tRjEJZ#B8v$X6Cxlg
ztAbep5fMSqMN|Y9K@r26)9zw7&AJOH9?r(
zF66%zmXM-0DE#aVp#P5J8E_X`X~MD^TekqGS@#0(v)%`OX#EWQRrV2<>?`L1ACZp$
zAD3?c*UOE-O>#4EtK0_MF82eAZOGew?IFOS_Ibcj_7%XZ>Pq0%YBF$&Lh96g3aM6$)N{ZW)LP&M^&jAW)lT47YBz9?+5_CH@u0P>
z5g%Pk*8-;MRA4<_AJ|Yg0v@581M_qqFs#GCR{CgQfkr%ad)*$`QFj4$)5ijj*T)0@
zqW=OsQJ)ArSq}i7rcVP7)kA@!HA$c?G!ItOl+zuL0Ma^}x5x
zTfleBd%%y)$H1Ltx3C@Q7-2bsox#9MotuHTI@5r6J9h)`aqa;=?VxNpZ#wIMn;n!5
z=N;!A;Fr!m;Lpy_!gjSgQdsW!F7D-i;(h}B-2E^3U%I9^P@lUS2QY@!s*k6TB0E
zCwj};6QI6@KoGNk2o|Fh~oLE;@=N
zxRb;j@u(OghKtc+jJQfn7IzBOv#8%7G4|Z6$6h4Hj~YATLNWfLv&UT`UJ?4-F{tOJ
zTsUIvCE|9*mo7eg?1f_X#TQxRU9;)+W%T&qog@8-6x(G<0c%cZ4gJ3j4TY2c?UpC!E+1
zIN-z+fRHvN(j{RccqZgBIwE8$a>PmxhfzV@>A7R3KD
zu$6oZU=QgNTTcQ$*%M)*TYS6NKVODA9oi}NbT0*&0qH0o>66p5fraejw0J+>m4>kV
z5ZULk)cKj|87E~<%8(g7;oQvmS(%w#GhDdA8Er~jUq1KEIwNCOM$gRM8GRF6`%IJ3
z6%J=mYv2}V4A1DAc}8jY3rZ={=xeK;H8-OH+&H+t2;CD0T}{oHl+h$(X2$%C^o-n$
ztr=l*nd?e5oRmS1;<|?J$VXSc>l*MVW+>Swok3|kdDkgn(Oq;wE|vZwD@sWak;(B_
z<>|;LWqFaE2sOJL_xuQzu!LXYoyvxaOiK{1;YH+%vVxI$WjPgHM;2AYjps!+#;zjh
z)s+23OgRoSBA=J#$6uGtgLuE1(vgK_B@c(zvO250K2tQW(!UDNnmwPg3d)zunx#4N
zsIMt9EixotXQW?bL;N+-k=cBrykWF<K>bUZgQEV8q>l5#_G2Un}b-=U^-Wu>>K;UU-Y
z0Ax!=qj_1%_|u58HbxPN*0z5}qz=!$Bj+c^{g2IWYT;s*QBe-VzY;4;jQ4?D9ZJ1)
zAorXd$U$IR+_q9F?M4k8CkfPw@}s?_Hi#ssnAp!P#?UcvMI9T+v7*
z>i5T-6Q6V9sPC}HpiNCRMlMaXlE~|cAtU!CULW{XWwm-S{%MK-MJB~xRYynr`qBEO
zI{AwIDRlMibF^$nz9lVw%Ur(HMoLE172m1m+ymF+kKe1l1C6>PSrv^k{czFtF40~q
z)!D03pM&r?@D!KauX=E;}}k@?&SrqoU@d+0N43#!PJV7x@f+H{+lhc5;-Pc=p>s%mM!=kuOST
zSP!uEEBS{Z@(u3!av7avE+b$2jL7aX9%*zP`LGQ4fG#8VACP!C;e)LcNKs^ZIdv5<
zqN$JbYU0y}a_=~8H6*L6J;+h
z%?|G)yuvySm(i#jaru%*Li9X{;vQis)WYdc`Y
ziE#AC`V;-B^NagWccY87H3zifqhU!b?!#$h9_jVZ6w3uA#}b^&rUL&q
zaVoaqj8RaBPmr2AI6ORsWHA%#E*=uEi5cPzu~WQgC0RF%kFcU_o^^)xi1m#%&f0DL
zVy&`&wtuzWRGu1SZO}b+PuUo&eT+Q9YwWd^=~#R7i0tOA^ES%SSogFGV@u08Xn`%~
zRJ7ifz6&;JnY%>;;LZ5g7l;-TE1S^5TRN9~Av2kA1^5nga?w{H-`PTvHnRzHDcK364+k-;-^oN0ytrBR`ng=s($JS>$u`
zB97&_ztYD={-gUu_UN~Zcj|YFrS0ELF>jyC&~+i?}YH3gr^rq7V3|2-%oIS8o2`Z
zobCJ)xx)P?j*U1rBbPi#ya|anA@QcEgzn~(8o)6-4Kct_4^fX960qJRwo`$$BM2II4Vj4m`fe_O~ZM_NU
z+Kl7l(kDbuZysW>!nGswT!rJGppmPH$qFwuGS91v(v*t(OvZhR^ag~41nyO&w<8Z-
zA?MqaPJ})VZ_l`FWJkwRrKOm=i%HM`E?YIqj9vybtfF1
zadbiax<<0}u{ifYYL3IX7mnUI`rtSoM_>5=1?PV7?~n5%(6I<%7e|Kal{l}%^+v=b
z3C|%JM{UrJaBhmS&`k8gion+#OZ0NOAeXx0=mxqwj$?83z;PT7O2IIke}jJ{vM_mo
zz}h%*zCBAN>nyBbJW3y}yXfO|FI}YjV4dO#dWybIU$1Y_Q}r$SR(+ekOW&g((Tnvm
z{i0s3SLl`cRlP>9)9dvH{ZGA7zoobOb(H=>Z`WVzZ}hi%xBgD=)j#T=^sjoq{!JGf
z%h*O4V;tie&m@^-lVWO_+9uW1HH}O&bAq|q%*0B?$IRnqp;=~LG|SB=zvD$T!c*yR9
zRjp6*O4g@%CF^pmVVy2ksy3>P*sKO&?djXo+;#1`EQt3u!9RiW=uzKZvG
zJ?MwL9`s)(U;^SJ6EY$3v1w&miBC)i(@}hCPB*8Ef15MR8RBzumN`rO$6R186x(<$
z=60;Vl;SJrGUqa}%emaSTzu_}cgBlvoWD6!#JA1@XMy<6S>!Addz>ZC67jwBvh#}A
z>+E)RiyxgIvHtQWx0YK={Nkp%^~JAln%h`J+}3WPCEda9V9Rw+bI-6m_bm5ptCl;_
zz0OK?=edtqjopRrLhDHPIrllMsk_2mVKsACx+|^b9v+_6!b|aLTj^Mf8L+Zw<)xMF
zUGLps<#=~{_gHz}-@U(ELGO8QsTJ~;d&{ks-b!zk)yjLtd&N46R#I9=dvAMNtU~Wc
z??-s(`;udFNR3(l&8aRnzK)WpI8g+mI56`ogkLE)If@wkraYFe1!3I@ln
z3(hGRU2sXkxPl1B2RYxG9CLDs-P0R^+x89*SF6_>u1l
zrbnsRxnOqT)XH7K+=BUqnT44JPZE}K`Gw_OAyU=7up?4^d%+@pTCEC}7S1Smsc=rg
z>Vma}4;E}Je6(Oo!G{H(70v*Ed%>=PJq7y;7Z(&4E-qAsNrlS_Q*oq0!&KZaKkoVZ
zPb8Y7lz*R>=8K=gSW5i3`te&;SXj6Yv5IPJg{yuDU08zMp{FBwBvS-lQQ;*T)`
z5v-GMZZ(uy@>r{b93qEUL#QsXPLt2d#n#nwiG1DqE3KKgZl^W#)^t0~?rqJm2igOz
z*X_IQnbsRv5C4v}9_!)vNypxA@0U$z4ZJ*3wNve6Q`JlLlFewnx@=Bs(dALPzOFBi
zrus&q$b*^--lz(xqajud5
zoNJwH8zB4oPRj~kf%7W
zIj_mVSO=aYPo>&Op6MRpHkD_)*=|sdaF21j$cx;bZjrpg?duMdS5eI*r?}_1Bjt_m
zW$u;oR`+^$nw;+5?cO8rb7#AA3^8Mr!l24EypzKY>(HZ6MSkVXN
zZ~)wKVvsmP^h7K6j5q=9*K6W_@dnD`b531M^YJ;x$%*Q)yFTm(sjSb~_CM5L)L-n?wBN&iRh_I(w%1V0YrjUjKJ3@A(__5-8SU|~|4nvc|4jQjRI>U(
z6{{@l;;>aqvK-Zlc5$fIWIyUCvLAIcwb!Z*wb!bUc5$d1Us*11w$JH}xXN8&xdw5;lNBbhwBd~-NwZyIM2GvV$*d44sK#3oz74}FxtLwQ-+@*Rb
zm1#YU%CtV4%CtU*%CtV0%CsJVGQCTWrd<>I0#QeJS=WD~
zvaYYA9TEC^+WDYwpq&r;MwI@`^iAFk-VOQ|?sE!8v0Li8-M5IvhLM9+bRtkw6^9tk~{_DJZ5u}9)#JnFWE-X8sw_k;I?e%kxV`&mEZ{pS6q7bVFgsh>^KNf;j`
zxk;{GLN=qHC!5hrVJ|aqoWXVWGh!(3WO@=-G0CdO_HZ@Z!}YYE$(qiVu!;6FS-W8a
z!(~@_4%{j7Je1|(M3<&X_17dFrjV`ta^O7_Vp|0z`ZRVwXvQbF47q{6h@Nwp$NfR_8z(~!k(
zCpDa`LY>D}F`jlisoQC{le&v`JE>V}FYIKt#zqJ=kF90CHabZ?!Z!0H+ssq6^GQ98
zolgzb0^LYAQVU@}&DA2dndfQelUm9)vz+!ksTH*6Nxe*ap44jE^Q2y-Jx^*4?RiqK
z(Vi#u2JLxLYuUQqq+LyFJ?&~z|79EdlJ+yH?`c1i*0hsJyR?%D1p+&nbS>J+r0ZZO
z(@I^>ylh_9^=UtoZb18)bVJ(Dq#MzGCf%5JGU+2|50h>}wyImu9wwbmdzf@5+QX!~
z&>kk;mG&^{ZnTF<52Za!`b^rxq=%8M>a%F~lD>p?FX>IRd&v~gz9mygwrARr?U^F7
zJ=2@^Dw*TGTrb!3BMUSqkOi9lWP#=++Mi@jru|7~AX%dsMAm3dA!{^)X&;g~mG&W-
zp|lUl495J;a-Q#^uX-E+`#<6lYvEnrGcd+oxd#vAGo8ccY&7z
zs{?BT8e&m=@R**cT`cs$fzumGlPFV%mdELDxG(wu6gKC+@R3R19TfgZQ+Lh0j_f>+jqh4
zQR)ECBSRyDMIqopx`#v6x?n%ZP$}OLMe5#Y#rv(=K@`anHifrTB6@vx3}Mjv9sX>8Q{kl%CV$
zB-+BKbEpUSkF$l1g;alBksL`5XIV*=AJ3}+|3&9PC>T;XHW#;DR
zy`I}Dw=lObsxP;xPgUffn_B8RGSxl!N!}~LPvTrK>%4<;!OjSknYk~sI7?+EWu<1N
zWi`#pjA{zLPA<17@JkKdj6j0R?H9Yw9gsUDcUbOuxfkS)$sM0NF?V9l_S^x18D+VF
z86|g};-4$Qdf@j*+@|K6c>1uT+S~4oHex~7wnwZC$CT7=|geBnR$Z(
zi-XJa`UjVj%NrD2j_W=ZxRhW@*2I|0J3U56sgPTl3oHvPi}%YL?qB7N4D1c;&6*N~
z_L$4N2=|&&xeM0I8=H4k-Xw&mm*}8re{g@^^?BFRIk-O>Gdc&C$6xt$-Ypg7?BV7G
zR>fT2oq2bn*5uUX&CHuwQ7VItD|dPK=RF*39&DcXIAIx=9W3VpYkZfLAEgfL%sCe<
zpK~?k1lHv(39N(L6xfuvfH3NU5Bn~#wR#5~TLT}Jb31~=^Ool=&)JjrdftYdeR*%^
zy`T39+$&`{!QsKNsMWRwuMbYj`!a8L@G8_!OAg#+weszx=2(+_3chk+F7HQjNYn7F
zF;R!SydT!QKX@l<lco_$4?p-=!8mzjl6u{3iM7Ssn9p^TYXV^4sV4%|9o<
zYktqH?)f)@J}G~2{u$s*%O9P8N&dL}3Hg)3pO!y8e|A<;{@nce`A_CA%6}<;Oa9WV
ze);=At2F?py5Ev7f3f}m@#H?ZDm`{GU
zQ;!07dLM0}$-02*i(ozE^%Pi6b67!KKa0{&J)Kyu1Yxul7uehG=zj)7&-y05hUN~rw39Ri_Vw{)ZSVbJ)rLRlhl)g28b^1r?pQrCg
z{|0VN`Z~yz;RcGrtJ3#o^Z@=6UX`(goe1>HSb{6VS1I9D;Z<4pN#7>CgG|-UkxIkoP8OmbsFk5qB4HuwC!hq
zl3|xAGhL=Q1T;{1gvi;F*&wq?W_mzn<_1zpB4-QfCkl6I6IztnKC>&ZCyu^>VWITQ
zljwjOoOwnlJuoJej_Y$mxtXIgM-$?>B$P{z&bd=UYeQ=@$KjYj6wZ@`}9-^{sm6<(YP>k7=se3IRw%%y=D;boaGWv&h{BNy73
zxd(0w#*b?=_k=d$8b@ejmICj?%+E5nXYPVOk1j(S15aaoNn^~^(%SQB^efP}Lf>Xp
zU>%J;`6`n+)aO{0)ernu-2Whn!mN%=lOJ4G_pBnEA%(VO0NSHrq0!u`jt>qGjYfNM
zf#1fWogE(Yy$qFOdj8;9#`97koh&F;+M$N3tG&hC+ht9X}vx_=eC$UkTI$?l(a
zN%kNLljsxP8{V5e9ByRxMd7_^6LK2(=|P+y4X4G@<#V&ILLQ%&eLv;2-?C*-qI{==
zsO;;>C;Jw{n9II1dnR4^A>fbj{?m%d#8KyEFJSj@_TxA&$zFozA10T*JbO9t6*}Nv
z&)yIU2eaYRhFtdB*>4l#c)x^$bM`0M+d}O_?X$lmWNLTG`NvS#sLS5(Q#lgpbSV#V
zTzaB#!zdTXMbAX#)b@E%B*|#r!M7wVhjF7aF&%!s`tmuo!^3exjFN5p3GSUcVjuu(wvuaR_Ck@&Cl7Gvn6Ls
zXf^8d4+FzmW(S(K%+C4DZ`*UW=j_Va1Gg`yI9G+{hu7pLh5F{EhSrAX)Wo4T*ECM<
z!SI7L1BZ?@s5tj1O5_1J*aTc|EB_pinAhuA;@dr!9Y2ADCl!5aR04cD{PY33)G%Cb
zQEpK&KE<6#f$oTN$
z+=-#u)V~R>&7DHMolsBAu1yWC4)qN64K~I++wH-|$aT!N@+Tkg|kXU}NnjONAi
zO#FOQUgMIP8_eC%yiMLKc|9=m(LZlc-syS6^G4=fgxLU|55Tzpmb`~C!k$T^KUyP7jqvN9YqQ4h*C55ymz&I-1Pm
zqZ={iIfq9`6F^PIIB6P2N;iU{(b8Hz*5b_LrqWT#!LIa{S2e0
z?HD)hDj7YIKRJw|X#B+eGW1T+r$`UwmeijUxISqg>Pmgu;DYdCJ|0JoX9n`a>jJIN
zgT4yAzK(*kq-!#HDXaBQe+1v>nZ$1ObS;PHk(((uO^hXsiVyA?shHnS=_V5hUau0?drIdwt
zP!6L;S%o@u84%+X^n|yDKMH@2{`!vaH)xrEX(?KomMJakwM37gWp-MFmO-ot>4CLS
zx8l{1v+P+yVx2@X*6`DcT*WJLjc$zg(8Vp)_(U)0x^
z>B~e@tbo2vG}E*6ED_Li^xs8LKcpWLM`^6k7DsDZqg{yg(9emsSQEWcwAU}|mqk}v
z6)n19UG!#gELKEs6~|*$^rxb){(PB7OL)R7;
z(kf^%)-*DW#5mIwYrC(&YKUIqO4G+2FRn8E%n4$GImw(PCYn>sU~!EZYpxU5nj7(s
z*SoMX;&w61+-0VV+2$TIQ_L~5&AsA&^LO)*m}?#}kBW!PBC|-$!>a1#V!q=#DdI6F
z-N_bDV_o$z;#sV%?jROpef4qTIcFGFi?6_L6Cqx5ZC8m`vCew4c+GvseOIjaQoTlE
zV^X7}Mq(@0T?fQ_wDwwj!0#~qS90IvzT%_g0a#=HF`maQ{5+1}=Mm=T;ox~R6ZyJ1
zo=RKTQ1$Z`{MH8${?;;xMuDUCpVmCakE_hnUiiWxeo>?eLv_2^aI#4Fjvn-`S?4^K`NJnx+n*p=9lzKB2TYGDY3bH*eDZa(N~8&|HQ05H~efn+c%*YW@m(
zqL~Q#8gq^4XeOCSqOG|WC0L>aWBnsm{@*Nu{Kn$OT$;ObX>N|ve2-|4T>&%2F(}is
zMPqX>N_I;w+4Z<&XLHF8aLKMuy9Kag2>S(qRI(i|*$zr}XVBO^AUvlVO1a`vuCSNj
z7h#-Vo&7>%SHW*01v?9hMUoS7A|eGl41`Ex8*pI*8$>egHxR~s%iScjyBSttU={C(
zH1}QDhK6m_6|Ps$t0!uC^}YHc*K6Q40Nv1Q2s#axQ;RJ}!*cS4mxQKPD7GK}9l#FT
zPi?jz1^bzXRhT10ZML7I*nUij<@hxj*+v7ljpl3{o!B;V**4O!pQ9Ii$wJz&g#`H>
z)D74|nzM!EvW29vg|uS}X~y-R#dad~jT&!4W@}OUW{vkDv&~37P2>H~Y&l9#*V9Ey
zwx9ZJKTX+w>a+bcWy|qx#%
zEUFp5&AYWZ7H{+R%yFit$YAU0%+_^;>1+N1KG|3Ywy|SOe{&)@WL-AfRb#d&n=Pt6
zTT~%il+6}pvqiOHi)v`5ncGAQa|hl8p2v38$jmS^u#1qat1Vksfth7yVYvjg7S7KL5Y*$%qS2=7~S!`E1Y**Q(b`=nYElP2VWPFPf
z)^AIYV#}g82Uu1T;css`O@OaB*gt5|n>H=6m-&ydOk<{L&wBK&(?6Lsyyt#6p@m(3
zpf%OE;wOXmP+N~N{{|oRFYrB6WA~^$lgf+ON3=DUedj8?hg%LOw01JB3B^y$d5rM}
z#-kaN3B}!nzb(>@f$IpZ$?Us>@o3U7_7N@5WL&5526s7{)6xlkmh~o~Y{hsf$A3hah`lje6yZtgMFdUzsQ~fyod2<#zn=H>U$WE
zCe$}YeujLj$R1z{xVjZXG1xkfj
z$7#VXS?FPT)RPqIT1I-;fF)07Y{^J(Wx?}hdIX`NcamFXBO~iEtBDpj(sM-L0=Fj;
z=g%0T?N15SH0D1ou}j)4A#`>Vx-Qdu8TS*K3z@#2L+#^G{TRD5ho73@lCMr=&WYHA
zXIWoxDH<&EamRqX7kImBkF@-Qa$EkO@XW1rqU}cNd7xo_GUGq!DaaopBS?m5yAc*K
zLx%augbqIo=W?rnb>e;Q>Rh=N@eflR9DYV_HpSe{FH8A3IiKY15b@ElchuRC$cu7=4Slk!YJsqufQb?8=J;}&6qFBFT3sN5O+WPF}wHpoYD$HjzV1M|}u*?z5Oh?WDGGl%I(OwVAPP8MoyWL(I&
zfKdFx^rGLsqB}CZlJR-=-S8XoM=mB584AF7`bLKETiRl@Hifc=|kZJ&o
z>yR6IDC2#M&oS~-(6=*v2O~c(J%i~h8JiHQ?o3Z-WNXr6nfA-l6sB)syr1y~MlP9p
zGSQOjN%3nB64b)?h2k7epGU59+-F6LavG8Z!ELcaU8e3xrWXo}Uf4q7yM
zEywCwj@390`vK#>2{DU8cno6(V}Q{1<2IXU=ot^Zl44bU#;~YlTseT|@HYK!9W2{f9uwr^
z<$=&IjfEVO7g+NPoDU}uN1ep{>siC~>|4MiqgGB^Xl~`4%XkhUjiG>fggmxtMPn;D
zh}u^0CE_-Z`1U*+L)r6~e-UdN#o9)(wowjg8%6R!(m9HCj$)moSm%RgCHO^*LzzF6
zC5N))P?j0W+J>^Wp`^{4jhQjan#owic&AT0=$Bfv31uJd39uarwj*mhbAIM=;?Hz1
z@iWCAh?D}M8A#*Dvq;kVz(Z7J53RwRlZFtQ>3K~cUwbm=#$=X1lksNa%MqNe
z5uBnC%pbui8o?)&)zhQba<48`$NKQpA=fjbNVjq`;edLR|0+bVZ
zJLgGPmidtJ66W+}`fJAP2-T@fpUXHxH$^JA%*g3{$1#j|6I!q7&wyVON*gmw7~7)H
zYgrSRUchwF;LB##i$sez%mUy#^B>^)5(Gz1V#$?^&ogdg?9DixP;AAluVt-b&NGDa
z6sG4eE@7O-_#WNGdX4d)j2j81ZK1ZeUa;_OH){gZ3z!bd05~raja~7@6hn#eomfw_
zoWwFK8J}m|#@L&2I-%HVVYb6s#hhmd_xUiXd|^!-{98x&75#
z_V7;xIEGt5%$|Z)=djFOjJGo0$#^@XABSt0=CX=eUeY;(@lwW{8S64~yQ60^eFvfW
zi0QF}>R6^ZjhH7UJvTAl!07jjZeg1HEtoAPnbCx3aj13UUXWeKDFS^VBuK7eecU}f?_GmVgD-q3asUBvWImK@5VhLR-Og6mPnIR4^i);yVgN3bOK
z&8(-010+l5o|~0sJ_6m1G}uKPs)+B~ox^esDY*udA?A-_ZCnfCUPwFY5Ylihr=mWm
z>l;G*J*Foxtr%08vySmP#?{OpNr+w(P!8cZbme$*D$u6T6Ku_RG9&i^q=4V!yq@)hFPio>#`5Uj&H91jWBtJQJ=2dHV{7LBOnmEOjvJ@N?#jL=v-}Vr
zxxMR3XmOtbbIVk6Mi57G?IBNQ`fH!Vq2A_NUuC+IDHrJvehGvxEkN1jQ`PW6aA9>Q#rh(94E@SDoPxNGmV)Oe`n${8aIJ=
zs@3R+H^>RR!w|pdJE9{4BE#vLeu;0?r*{|An~V9IIR9dbLMQy_`%{nrKQh_(i0tHh
zBuGUs@OI*z@$ZTyod`*Ddu4x=I{adB^RFvMiUtgKJIY!oiY8I7A&=F>R
z!=2-EorMse<|S_zf8YN~2TH*Cu%HzpIX)*4w!~5*<9rHV+oKuO(yt*#?-{{Y5g~m(
z-q+1B^{;$eir(Mxe$577
z;zd5gU7#oNSMoOX2Bo2zmr`+p@$WIN;BD*ly~rx-sfu)#s6Vr$)+))@xSJk6a@OHf
zRjsBf^C1?x{F8|JRZnqw;2b-g@7J&Jz7?d0OMHdG2g#grkl_v{SOw`xP?L_gnWw{A
z783F=dd3@K!R0m<{1}&>gMURx|6cVX3oo(tVF>j({Cx>{!hSx%DelM8++xNt%aiFP
zEUK@gG_jbDe<1F#HS%XZP0oyzUZ}rzddoOe~02New{7y@fFM0l%Aq!c+lULdX^A~75yJm_IENd5Q~I5I$kI;93|vc
z)aR`u7jUX3L;ifU8dPti{V0v!PS%82`#l(Z$7(WC0co)upD(YQr>oAGj$(}|_4a=EH};#>8q
zd{2x~BRmKdg@tMYg7_0}wY6=_6ypjnuTe&t3M@j`JWrMdVh
z-lyht#j!qsTCW=GEL+xTF4u4W%cdD4OPW`k9bbwodt&8#5U~`;&lZ>52{sGOjJ{lC
z53b)Yp>gIr50C7M@_Zj4+fQQHQoqARM8{Sg50}C!^pvEh3JLZqmk$X65@#>ycn;%)x?+mSeXB9s^XgI
zegzC6kJ|MS86mq$J;3FF%zW=6|ec?Fh9?J>Tb1s`s){W-OG+on52E{JOO&zR|=
zUSnMQljvXeDz0^_pmi-D%d+81?ppc%YS;=?toIPPevh>BxJA`Rdp?!T?EYE*Z%5jQ6{|@n*OYRJ-JJ?^vaU*T$Z&I%lv53k<21nyeocNsZ=XPUp;1F#}
zLU&?qS@H6|WK?flxE1toY5A1LB;dO|t`>-SRBO2$=M^;H61{K4MEe8mH#w#ZK2bYl
zYb%NS0{VB@?}0=bm+y&0W*6^|>hSyOkrl=3s!R|6D;wXhkA{!-uS>7#-{geRY-e$O%{vW=#m&wEbvbaV-ymvqTJH+@0N$9cQ|d)d;^qp>4_=-O#KKKHvh-|=7P5CJsyV#fC5wgPdE(noaHk5x|X1zmACC}Us=!f1|dJ!EDlFp47W1|%NKe|Kos-!;}3&qk=$=I3XI2_KTFXmI0
zipIYj|9zhm9gP>yiyRY`iSnY?xC8unv{z;xF(yG2k3uf>c~}XAF-gsSu{)Q9Es0w!
zbXkcwpUIJTBh#?LrWyVpAdmYsnNf<}|o{Ls>MV39be)n`b`s^9PKhf_0VYNnDt^zf$=6+J4yhc921WOQ`k>nJ-*WM<=2NKX#79>d=gT5Fc;3j1gb7ftj4QO@_*OAd=D6_4=^V9fAtqj
zPn3!j`8$;dQ;G)IFzg{(%Rg3DZS?*LeS5=*sKPmrP)htzqU#k2+1+ja?
zja|#Eor=jEZmPQHbGYyK=j!{Pti=vPeNA&6ksl(MHII$FiHhH$N2|SS`Xfz$<}Wgd
z`UbHTHWZh#>yG}+_#NoIOUE{yOG1_z2OX%s^75s3DDPEMPBd;cl|M{!k?jeJYGe(O
z^$B4rxL^sM8!Yi`TTUq6mpuOv}f4_U&{9elcoi?wsUe}-f
zfTQi~p}p_vPfG=jEh`&e(%PTs`kzFZs;;6*AsGto%Z?LEN_Bx5-($z+nIaRI_>10y
z-YFjYomryKk5}SscEZ)~Rq_wV%|ZD=RCRq&rk*Psn69g}&WdV_@=q&r0Q=1J;F`=Z^Ky6iXa*2B+YoP285Q*Of
zU)75G1B*)4_1S^xsz5qApHYF=;b%qmCFrb^a8>4_bpJ&B9C4E}LZz3HPfJN5YWZ~J
z`zQ~-PHMb)~vK7vYt5KvTNm{_c?s$
z-~SGWFRqE2B3~t5|KV3s+=uU})B3fC#9A>^tP@{~RaTOk{j(5yMf$fr`dhv2X2^CUD*-xrAs*}A)bx~dH<*K{tYp+lzsQ&irYJj@jUaPK9SJ+>w
z`_=vSS8A@BYwuDIsYmUv)#K_p`+N1g+Ga=8cHKa=)@izl8m61-=IUIXr}NZEjW5Wn
zQMynUsta^`-CkX&JL#_KBKm^78iTLKAFnQ>Z^WxB@OAk9YP`NcU!bni7wL=C)%p^B
ziJCxPidTQd7l3Y46Y2Z!YAU|}{(!m}Unlyzx=qj1^VIG5+WVvG4*j@(T-~Lg)K9AE
z`11QAbvM5J{+zl8-+W)GX3^K))dTdkcQqGZd*7@cr0=<_$LV|S>Ir;d?%(QZy-jad
z&)`e$U#Ug-Zu>s9ME`2EdKuqdudUYM8|!t|W_(?}nR>_Mo3Q%Ov^H(jr{);bR{h7c
zH|^CIrlaYuw$qo^)z|bzb@i<|*_^C)n}KGa`p%qUCaOL3{dC=gzMrmJ(AUp(rsFy(
zI?G9SvUNUv{am-Cub<<4kDZ<4bZdO~e28x640DF*4$ha(m%5{~%h{(pIls6<_rbT$
z`{)zh{_fxPJ?=y9Bl^GYLU*y=cyj~{9
z>+SV6x!wS8kje9gdP7an8|Do&A@5Rej0t;Vy|Jd1ca3+0Y3<$QO*8GiJG?tgSMM(G
zF4N7s+nZ&&d-r;COfT;NZ?5U>&GY7&S`ZBw@n!e0#{z_kF
zHxrZlCHFJeBo9a)XeJ41?S++g$5)(>6@7#yju!**RhU8eB8(;e3TKJy#m&H5;F^kC
z;Vf|*tXjdUUkCjLY`YO`djsfyicO$53w$R>Y!UB*-YW2AGx0CDMq-=z0=NUt5oMTt)*HgJ)>`XCGi$xIS!7yoTknYm
z*8A3WQQP{;+6Da9`WCp``b9Lcew9sxBaf6v3R^annV_@eu|moovIoBW(^DP~>?_X$
zXM`Lf3aHKkeUrRNgymE@6}~sin?c_qZv%Y?zU_){WXZe0nU3%JO4McZ!FfbJB3${X
zd`uYmxLgFzv+_AnS1yrDMV5R)E(86dd=b9OlZU$?1zQ?7&jdbu8)4fsNN7HZ9Zf__WB1>d*jyTGjyU&urq`Z4em`H5&DKb6}+
ze<8m>*zNLL(7WaLp!drCpo?Wh*tW1O;o8#nKquKrqN$yXug}`3ZTkvo|HbYH`UHHr
z*|PiFCxJfM9svG8dobwJ?9+s?Pq)tlJ<7g9q}bza`qC!qW$>@Drvh)br-O61Jrj4C
zWzQ04*t6~DKrgYEAk-Fni%7EHwci!m-iohBC)w}YABYUp@Vz}z~5)@
zgUo*WH_*k(6-O#hCE@#N$tqc7s}z+Yl&Y<2gM%6$bUjs1^hLcN63tXtwFJMlYAp)Y
zQR*ns04+fq@fTH~P#RTR)fW7AsvSbLSNLAK>YzFxR7cejoKC6}IGt5zQ3q`XN~}6o
z9SgdLIu3MC)f03tg>QVR-l{kB^ii-ibv(Wfu2f&u7j!>`+*AEke{cq>fzUii4MM0>
z)TKhIF=~vk)n)23ajF`t#)?kra&@^#QRCD&(VW^O(Bsu)(0@~Z6Qk4=d|lh7R!XE%
zD+NR=1$Y&F4aWZsHL{4Z6Zm1p}r6&sO@S8WOk~ZkpEJBDMIQiwM(>6U*qe-QhlSohwoms7rsBJ
zpWwSsp)6@sHNt31TS987ZP7w2t-!}mxY}qVPSlQeg`+*441S7EfqX4pOVri1bsf;D
zI#q;pU0oNx^>jVxsjusUZlD{86x~p#fo_DaV@Z94J`(ax@rA}De80DuI7v6x%|!vV
z$D$#%$0CPXV$hv*XJ8lIMRd?zbyv}oT4m8&_s~5=XWdKp0>21fiyy9g>)zsIeY`#%
zoW8m*B++I=vY+k;$rJPm;vC&y_ZP?M3-P`1Jbkgg7YG4M
z)lMucmrN09Gjs6bl+M~aRdJ}
zOB`V&zN?mMY@$(#-;$?FPXQxvfCAcH4;hZh>1U>bS?aU4-X$b-RjYZa4Q>
zk?Hnuj}r~to^Fw-?e=#2h$iUg4HS+$$UOyN2fJqo>7MDHDZ=hBcNpli+_S+y$2~{X
zbXh?kLgJ9qo=5#y#J?2r?JDs9oL5+$$04D)+CTC%ThGiu*VBI>=n_
zP7|(sn|qr`a&LF<6dCSa?sU<@z1zJ9oSE({ailxjeE?zSx^tob@9rbexzJq*T;wi7
zN}qL~MH&~oixK}N?h^2ycb^9b{ZWKk<*vdVUv^&sz1m$3dX2kAq`9xTuY-QWT?_h6
z_f632-Sxl??gr7y{inN8hp%C$ySv<7
z!lHhyQ0Uic(8e>OzUO!z=p+wyte4`Yh!n4uS4$YLwukl+ec!qw;MMo)iz2U~*AR$)
zusEK2z@iTIfFaYx>mr(>AB^6S*TXvwVbKo;e}FdtItO|K#j)NXZxA@>6T=sMV&J9T
zr9yjSJmfj`jKR6an*^D^dDj82_pV1gZ}4uwU2gJj5)Hhm9{ND}txim=l>
z^!L2mJ@l-o4=tK`)4k~kHN%?$yvMr-VP|@?L~Zn{?}f}9Zw~Z4;5`7}x!zpR=wSmN
z^&UeU9`_zc9G>u=0{x8l3~+(B0Cz#3TO^^+y%ec?!FvHR%e)uCS?R3;=N}%Pl=rIl
z8t6B?H$boT)`}M1o8Ft?tn=`lOzNFO{#|b?d_VR+27jkV&*E!uH{$%Ahp#bvd%V4f
z33~0qqF%d5r(V0rre3=^f_m*Dn|ke{fO_r1MX!B;XqY@Oc_4bxQZ_~3y)zorF6hBK
z+=Fk39{kDp2GIaGg}(ei(1XMopwAS;M4C7YJ$ncJ`l-O1(VH(sZ+<3LM9qS0B%Tsa
z3nQL^t0fk|+1$TxCa7oMjQjM>xHsR7d-Kh>AK#37@Gg4rjYVVY2rE;xu(GTy^trpE
z@9v@R-V^=xURE!0npI?70eU=o^A7jsL+H&<0_R%v>2tVG--!G4eYj8G2z~n5=&9d}
zo_#~^**8GX{xMMtefr0RYdv8-Ax^iRwEh8lwe`9<+Ijxa?rZzWp;k4C@WmOUg&FwIqn9Lz)v
z6~pA&@?4R|{eFw)DnvHsDsBZ%lhdH*HhG&U;(mV-`u%r;e;0cH621TFB17IS?*?ZE
zMgkca3Csh3KE?w!#siOn^BBehIXoVaJRb0PJP_vbfEPC&@OV5B#&}@0@OUH;#z+9;
z9J#i14B+t?Ak1R`4`YC>@O@9dCz^2YzX|vLC3^p#g0l@{fSl4Xz;@8zVkFS6WF$ai
z0EsbxKwsR#NFW0v0RxU>J0b_OFBmfOs6b-Qr9Su#FgEZoHaG%(`X+W0alU<|js7lX
zW%`P2j22D;eX@Nr^bEi_Asgd_fzUGuqlF~n`zO#e#>nO|Ml!}27W>Ns*&$j5pFS
zpOl1NGL1KmDj9FoQniFrGTxw(Mi3*7uxO}Ss#cJs@kTcuZ#3ocMpukC3Pode3`QLd
zOU4^C(nyILX>`LF19h%C4x@{1Ji4${QJE3NP|R9IM-*pZ?rMPO%HxV4#uaEU)nJS(
z>hQRtA&)E0<8j4VJg!LP5rxI$ha-9Xa3qf(j^y#fkvx9TYJplHu8bQ+SQtMn#n>P=
zahj1T8Doa+B31ntV}>LiGqmF|LmtKq-ylWbs&6q`*sXSpCh9x&9XNY1
zerSU6!w;Z;#3;gG6tNHV&+2E;zo=h8|EhikyS)iS4v$A27DgR)!KsIlhQ%Wdi5b2$
za2n}G;55c~qb0^0M<8qy-2@yObtLnsV>pjGy7Q>xLX0|E|9|AY4V;a2{{R2E?yoa5
z#u($AGh=4X-I#=sBuQ3sCM(G$$%;vm8Y@Xivh5_5>Liuwtd(w*Bsr2TnO16%D9LtX
z+ioPak|arz%>ViMoS8AAw)W=t{r-QyxgO8!?p*V^uIqFAetoXbjkwrqi}vUx>jJTl
zr2-kH6i82{K+aGKq*N)8?n-rBu2e^Ns|Tv%45c@^E2VLTQW}@annEmfY6@9XQ^;B>
zja;QP8Y`udtCU7qDUDpEG#V?Vk!wk%5mp)_PHo~$(N`&rxHZd~#r}QPeWIJJUt~@F
zB5SEPPPFD)bGZw(jI60;WG(f^7)$DnQcLQMwn}g0qBp8=|2b60V5K_3N_FHa)e%;z
zqq9;SCo9#_NvV#`N_Cv9RL2QQb)2kJM;lbfhbWD$Xph!Pd*mwZ(Mf5Kvz7MfqqN7_
zN_+HC+M}`39=S?;gq8NlRobJm(jK`=do)(sBUfpUu+kp6N_#X$dsrwCsXb0t+T%2(
zJ;F+RoU63QiAsB%tF*`IN_(89w8tq*dz32eF#^?btmt63uv>_;?3QSc4oZ7mgx+W?
zJe0=qsD*aujk9DOD!)o~ahcK;SK6o8r--piT?|m_Vw_SJ<~fc
zbLVpra=t)&1eEs3LwkHF3eX18Q&>`)W4v8uSQslCTpj+&Qu;y!ZNK4VuZG{>M
zC^eF&)JOx=$SG)m&TeOMyn8Cj#8t|qg;FNRDrHirlu4FSCNZTH9q;*8o%{ie(6+EI{?cGs?b3L3Ft_pOpTA`GIogsn^Or4o?T>bSgCR#Q
znUm$Y^!mtO>z~P-@Uxc5oj0F8lMv6V7wQ8uS#taTtpERsyJQWkS!QMp%WL0obnQFl
zwa;tcU`XxJp)GNfC1+{VoTh6t6YT5qID2-}ocv<7wNokTT)1BTCYOeV
zwR@Sv4lP+T*{@AOdPA8m<@?Gdy-mBL*PN@pauJQUz{m4+_eiQO$=FR+f
zed?$GKWkY1--~myrm988vGm>#*BLc_Q~PHBWSfpPne*z$o?oBI8RzOREvMu!$l5@jGdWhCdEmJ14JOKTpRp|9mpL7w#V?IV
zXE)DSMt+Q6WKL#p0_W2)Qs&+h8cbA2#hJf&$tuiPov|WwLdJ@tEjb6XT4!$jv86#W
zvpFG9lQM$Itf@c8H|6!*^A|1+XCI0U+;fq~GB!2Hl}l!JdVTD|;++}qvuw}Unz21|
zROaLmS7c_V7w*AzYQHRNUnXRYXW5svJZpJ)Z)WGrrzNGmrCW_2O1oU^cB2D5`|+Qn
zGIpgG!Z|mq>wjaZy|3J6ZIBD^!J8af8nnvp&5_DMWO`G+$~(w6s(Y&~hb)$~%~0Pq
zkS-`U(yks}Lhe*?x~>#YmtzekrpqQ-CZ$UvS;oi^t6gLmu}BxwdlSfM)vq!?tjiIJ
z59v4g!T;6#58NiN#c-vCV9uq
zjq>`;jp?lnd3|i_*d4}_5cgCvJY1t8{MMs=krybQP+r}kb-iJHh%HDO=NpmLu
zFV9u-ALH0-!@2E@Go)J|S6A_0m9*e_q&lU|#Z~D_UtE#bwdK0*(mm~O)k*zzo?RC&
zi@2|VyBYe^+?Qvi1qny;ygW+(rrlRvfr}NmSi$%EM%86CZjGZ6$RpL2oUi2Fs`$oU
z^US;vM>TFK4rG#U8Kui&C~Z)Zzr!&eq0k0xWgVTD5}SqPgkG9^PunQpRr1-
zlj^KZh@}X{appo;#FG$uRvve;wOQ3w_;wZlSB?+G%yufw#?kiXPbx$P^Ja5+&T)ik
z(hjNrxsMR24TTS?EBLjFC*-?Y{cx*&H}&j}o>`qTg1D6Ss}#Rh;9{lvKUY2V71fE_
zcacwSb>SI%RwvXOxYD1^PCVPGgc2jLTgS0YKUqRP8Q&G8f4#>Y-mgv(;>6$TDrbN7
z9QRdN4>JDoEkh)Dvjj0EK`13?(Uc&zBxtFWa4&*kA|ZUWamCuebG*)blU`h-I_aAD
zXbv=zceI}3*NxoqukaS3`7zJ6Po|-RU)#K|9pH``KY3LK&zTA8N}esQeEn$^gW^0}
zT?*m(;yhoR=cC4;-j#9QqV~$MGT#5JD^&{l(JN*At#wnTPw((6>tgQ)!v1v?{wmer
zLlyW?1wK@P4^@cFA9>EYyW_7P)xA{~vFA{{dJlKshkEgg(5xUdD+tXBLbHPJgSUuv
zw9s3}SEqIQ8h8yh!e8MHcvGySrPw-JT&$yZYaQQ;*NJQuBI}4hufd`4lkw?L_+=2M
zGa*1MI#^xh%F)%b4~xa`@!#p(MgHeuF`6f>;z_G`(kh;`N;H)JDdTWm8u%su-AJ5E
zsF(9~^lF$73&p*BPEYXe_i7#CROkY?ihK2Cgk4RZuD@QLz(3-&(zsXTlFH_oq<3>R
zJO~c~v#Od8!y_;k=D}kyA0CGVGHr`n-Ict_OR$Ew@#{}=kHzp5JOfKX-nRdAaarc7NnB3ia#93vc@8em!R0x)JO`KO;PMZB2XAj|;bf^lzk
z8w2aZ_(ye>QBAzjKnKcE(}kH>2nz|HMF+oDmKA+038TK&7V^H2tIG+aa`N+pD6p>J
z%#Cmp+ziaSLY|wlo`*yEtt>B7)^7L;zJ`6|sNC6RoC=#e+ZHf`V{KXLG$1BtLqlTn
z8^qp%|3u2GZ>RidDs0L-C3&YL@08@7lDtz=l_SryJ^Y=Lyi<~QO7c!g-f0f+l;oX~
zyi<~QN~%~~ujJ>wlDt=v_e%0!Ng?l6#oem7TNQV!;%-&ktqLEkUt&ogq&ES41z$tj
z2dlVS6?d!RZdKf^%05(@rMO!acdO!VRotyg6v@x$@~4%IO>u^xiW`}Xc^?1Jc*VC#4nBb^)KgG?-7U7)~8Gn
zN6opg3{qGIDJ+8&mO)D8WRW_H>ZiiJzC|R%9qsV5!v1Q)e5ZW@gt<3=r<+?H!l>e+)K+ar02B;8keOQR!g0CNL
zTY5*>mj7y~zeI?ysTcaM)x`i0TiwG}_psGHY;{k@2<-JEW5p%un1K!Ni8eoY^7s#)
zJpOMz`HN!gVR7~)EY>y3a!tqG*VwO#zdU`Mr;qdWah_gUwesne#eGBL9#0<^
zZGZ6eKgZvHnY}GFTYa1RkpC;&8=X{}0*~hZ@p}G$$UaZ|e_Z+h@jpAhAH=edDU>q3
z&Z02bH-Yk<+*?{0DJ%?GLvf^q@$*vc;i>f=Qs%wzDk=7-Qte5!cNDaC6speVS++Ix
zHu}r#GvhP;k%Ybrz68eDrB9NeJ)khIhKX#-$Nx_2b1A?m;yJzjc^l8rQV16
z)@Y+9XniJVS0;O+2-u4O7>o_deaagY7uw2JsxsJnf9f##Q4$E~Mmg_i_!#H%w
zIPFnD-)S$yTG+(#-{2kiJG=|;!Taz5Y=#eE3w#7y;bZs&J_Blv&<5kso#SMkmU%LC
zX8$yxCeb{H<9^lSu!zTD5s!1bP%3jC9Wf3aF%AoP92W98ANe0v@;G$GICRB0-{&0f
z1ayaQ7p3?ayy-92NWAGR-gFjkI?KT7GTwr>fs)(!1pWb7ImV}erDJ>sSUbk&uoJ$3
zUGODf@flcr#vZ`xGxowaZ~(r?I+yiyEvskY1GDgfL)z>dj?rhe^^(?93d`mdD6rKg@s_>mz_|7bRXBNIQOZ38b%83W%#Dj9;
zK{@fDoOn=9JSZm~loJoii3jDxgL2|QIq{$z-$~&+DSRiTVc%-hacOVBoA4IA4VzHO
zCN#aM6_>m-jB#9qM;zyGBktBX3i62SgN0RuFBz`1`AJTWq@tqX4
zZgW)q-)_!*1*lAMR{h*fe+2sz+ta}ItkX<)
zu|EUu25d>RQx97s6K+TIm&5J?J
zi$TqcLCuTt1T2IUEP^LtF<=SQX|;dVKE`(bDIModAvN9r=nxkj;!Xv$hOajg=1t&=l(HQU1S9litSu
zoglU8Qnoy|wG67@IgonwFKkzW)T-OqZU?@2U~{E)tGstwyQb>2D=`J_b4b7PT-aW9
z+SU06pSu)zPnY*}c~9)Iln)Et*9}gG^PwEZz&v=Sx+>!YXpc`{`2S4&yaL}PI(N)9Crn3!pOH1iyNvw1jQl%6{+%HIPLO|BkbhT@e^-!ySCD@v$iEZh-wAPr&{-Q`f(16n
zx+xcYkO7&H1q~n@8bS^>J~HbpA7sbQ`YZ}BYpkiZvogXm87pq(pM$vD@FQB
zk-k!-uN3JkMfysSzEY$kSxZ|%I;tQYRgjJ8OHqRDtDKh2>a<9T@!S%KwQQ+fCDUjnX*59^tssq7V2xH`jaFfeR$+}+kxnZ~
zr
z+Dwr)Q>4ulX){IIOp!KIq)qB|pf$9Cws1VOgA<@VoCqD@BjO*rX;_k%{eXVu6|1-sV)e8BhQw3cySNd4x$GVUkCf
zWfAj<6IKQgFD&AK)fk$9A5MjCwEKv&{WbJr{|f43Z-krRW=H^jg2g%xol$P_
zKIn>aQdAZxDvK0#3MuLoQq(EbN>@@VT}iEUCAHF(s#bbi^=#|Y>P_g5+16)lvER@h
z<$P}`M|YI-y`>!8QEm&+!2%nc>Nb?ZK}z8u+M}FrFXeoDDR=guMZSjp)u%Y$(XQzL
z`M*(}MQY7*FC%Xs1(yTyz?}eB!$i0SrjW8pi73JZiZFp9OrQu8D8dAaFo7aWxP+BU
z__&0NOSrf%!#a4i`V><4Dei0BZv(u}{u|hcZ?gY3WdnZf!;eY%S)}|dUtjil2H#wG
z6dr^5@HjjH!~-Aq@e%I6H{mU`QJ}gK%ba#;;(664UCSlUFi6)H`B|cvpPH6`wFN)Ez>O2{l9W(P#W_)m!{^`iWnw$q}9AahAT`
zT7X{OZ)j~8L#{wORrcN1Cev%Xzjgzo#r;NmmOk3Uv?|#zH|yuge$D#%^lR>;-z@tw>(lAQe4c&>J(q{+chg&Wy#AojZs%1d;1#A=;3?0QA&T_F~;eP4RoCmH)onNjdRp^
zb^T;dUgKOfUR{4RUfm!yUfrd#H?EO1KesgFkMxT58H?!snrHlp{;dJydD)xQctQ4N
zHD072YsmPE6|o}5DyxOn!dNYPuo^GXYqg89M)p)S*2+Gr#yZ*m)OeMir#Bnx>1TSI
zvC+E2y2E(Gy4$+jcvJQ>HQu6!>3zoAvTv#JH`%w;ct`dvHU2L9kQ(pOhx94qJvm;T
z@qz3yYJ4nvj2fTNV|1Odjb5U!8{1{iP-Ca;6KZ@xpU{tuU2?oSW4HCW^|`T^UY}nZ
z-^f0m#&`7Y)Qto5<#bFT`)`_t?73;0^wTUfEjwg~%nW)*US(#=9+75C*%#70PWFW~
zTg$$X<_WSFq}f6Ce>6{)Js-`^vgf0DsvMWj>>~R)nq~BG+-aUcf5xxPp0f9%dA9SN
zD@>36i5cd3^hs=JULgA+nnUGCDCR|sfzr|(P9MWl%&X{E*u$)(=ip#7K@Y)e%vtpH
zn{GZ!pT7Iem*r@7=3DgFTWtPaj%H`RCr1x4Kaiu@nVaQkcIL-&d=T?9+5gVmL7%%f
z&7HEhowpAgWdArz
zm;K``Lyl=@nX)&W<;c~z@nw^y=d$d^v^kuu(YAVOEvqEwlJ1Zi4
zu30fTj-6E^$FZ|o$}u&p7>#WYQubK5L+1JeKDo3-k
zPLtzeSZB$;W>#-GE{1is>}6(ovX_~4j_hS-^^?8KtaIgPc2<9FHe+DKMK?Gdx`SNg
z{4Zxf59kN7MOS|~4+g+*U?7|igJ3X}!v!z|E(BUjiHl$u&}vH1YD(~3Nn8pQFamxH
zBjGX_1((BUxB|w&l`s~@!Fad|CcxEj4NQR>;6}I!rozo|D?A7f!5nxP9)Y>=D9nS$
zU_Lw!3*ZS@2q{Fq%8WXe{V|Jvt_!H0ySkQt@(1J`n3$!8=RX{5;K`TT-8%T2V
zxS%zepf#Cz5&i-zVHKWwh~b{C|L9!L7$b3=>lt4p&ZvTMeKy<=
z4*>B)e+Y;lj3*Ljbiuej4<3X0@Hi|0;)PDU(1{m}Iw)HR#u*tTt`kl=VWhtUe}{MB
zJ@^1N!-ueiFP9&&-3p(;KVTbt3fti`*a4rzPWS@$z}K)BzJY!4E$oNy-~iul1!!;<
zj0gOeRvB@FcQ$xugLgJ~XJZ=h-o|ve1MY;oU;-(KW
zARAi2aX`Fc9FI7oc*ITOlz9qt1;Uw8JK`qcY!c3l+7UNS2f7?m#0
zhzfB=FO%Q3;|_7q!5!x`z%2*27||>4?10Y!f2U`CoF4UYXE%HW__>=2S->;WCqC}x
zKqJV7#*hbo2tW{;LLqQpw-`bYh6qF<1}%a2b$MTU%*Wk+a4uW`L*PQFfD!Op7ztD0
z2DlM!f~i1QySKotkO0ESeE=Q=!pJ3zT*8Rn>v2ZaiZg;%oc`l+`i;lwH6HhwfWOgS
zJnnOW@Su;l>gzxc@Hk^t#p&f8XC$h)?{{!5TnCfkdf*utT`Eo=?YM6m+z!MadO^qO
z10DB$3fti`koU!J8LuhMcua9dV~R5tQ(Rm4e`U!d+ocdH|7%MhLg+tI@-!g?iT(eh
zrB4Up{ZE!a8abs#PN|VoVn@X_QlUmF)JTOIsZb*oYNSAoTvB_KoVy!zhd7Xv{o5th
zJW7|xaA!U|4h!H3SO}zgRa%kj*0p_Sjz`sYME2NthVx5d8B~F61t;5+EQjYgUI8xv
z{Zv$+ik0lIg4IyJea)wE+edWm%&rPBYFQj3zB5hsscdJX0LMYMez1{bqG9AsOp
z3brHQw=fb&FaNPp>sU$`Sz^)F?k7vF8@T3ZB^Q>^&nms@ms}p2$wM=FXeN(5)k8CR
zXeJNMicaqdi9wnEDZt}z&co-gmx$r2El3qTLu=)rhi>xFO&+?*Lo<1lTppUqLo<13CJ)Wzp_x21lZR&V&`chh$wM=FXeJNM=)rhi>xFO&+?*LpOQo
zCJ)`@p_@E(lZS5d&`ln?$wN1J=q3-{vM$JhYaF
z*7DF=9$L#oXUUSwqvY}^xjafPkCMxy~rR4I^cOLrAL*IGmI}d&5q3=BOork{j^bcVRd<0wJ
z6Zi*ggHK^Qd@4ovgGp6TONALLvMNLEf2lr
zp|?ErmWST*&|4mQ%R_H@=q(Su<)ODc^p=O-^3YozBLfenbPCu{qD&G*Qja3=JEv!FMe4Sm3azHkm;&;0+cX7Cou6RFQ`Yc33;~tN}e0Urdz!R_#Qm_b~gvIa_EP+44
z)9?%|g=b+IRKaubXIKu;!wPr-UWC8EN>~M};U!o@Ywnj}ZFRX$joN=fGyn4%-ujyP
zNS%CmfAPbui&gZzS*5Szxzn0>6_&*+EQ?k2x>-f9n^phQnpi)`vsF_ItAhb1SYQLS
zfd;jKh7U5tDkGC^7NA)TY6p#mKrN`z2&e^Rur*s(So&HwVE5v?^p2ue*G)8P)d6Yhc;fF?HYfqNkd)b5&#fV5#Q
zhNpnEVN!==J`2<%nN{!{{27)5*0@PMlKBF>2!8?6i@6F`!%MIRw(=f~j$(eo{y$(F
zY{wei$@UA_1z*B$_zJ$}x4rNU?Bn=b*bm>q0r(z>=QQbxTf}dR_-thW@mX21IassA
zVXHAvk7-f%(dw|=*-qQA9n`aY4&3{QuL65G_cd_Ox~~HbXbVPM_|Y!}P52%6f6-S0
z+WNW8pcIY)+KRcyLJMe#Tg0`xF9@|QBTwYF4nRC}I|6OpT;iEa+dh{zZ!T^7Xgyhe
z)GrRCyLuMysw3)ZF5~x6a5)gy7-2W=QbxHG;TpINckhB3Knv)fRkuSKqP|K{b}*Yq
zoDp;5F7bjHJmSpU5og|x^cM?3+c};383PE*BfeS?u8ee>{(2!>d3Ip`RIy6c1NCK}
z{D*lnj{X&6N=;im(#H{BGwz@zH};?pTTr#(BmVpDV}CB&N8vG;50AqWu!QTE!m~hp
zXReDlqr@KiBJw)t-{kl$_!PFoXFzjiT&
zbua7HPkV0Ug=0r)E11P$jkbaL88&O%n4e*v&Va*ujvmy*dUKYxw8rUbG}n9REA&3J
z@)@EpVm8P{`e>K{l`s{iLlPbU=2X*{a{U^8gT6`MY)18MwCmYt4KoDoXj~)52pVCd
zxzU!nA-ftq%%IVSSqFv~ml~su2}Y$c)tJt_0S_4Sj77#$V}-HC*uWeCo0$n1J(edU#H
zk2mvJw=)Y^pJ0Yrw>L{!pJ=vX-N9_f`XsX>>yBoZE5{AJ((HcaxY1XdJ;zGd`b@ld
z%;n4+(1G5Q?SygqY5l_Hl(Ww66*f2ZJ^QS%)k=l0R@hqbNmporz3F3Xps?k!Z&Lc$
zohD3qoU>I}a%(-EKDPR&x7iD7&M&ArUzXlB%&T!PC+(h9Uh{kFx~+^~jJaI%JM-+x
zV{&43f9v6z?TYl)rYVs8-DZ?=xpi9AY;R0&na@a_XSSmBwr|aLWX*eQOdsOSuF
zn&YuG_pRAB7*+Fp^V3^OD)s)gTWenW`mA@@x;#fTy``j6$CPxYJa5iVUzf>TN%DE+
z*mQE6UE{CWd(y{@|DS&EsWtbhxjxW5ecr7J7dpPCpZmu2Hgj5fo5y@~(%mMEXeYP9
z8ux3%*Xoo$Z}q6zGRnEU&Kj29I-}FujGFrzW75aWYLvc?5%lEscFq6QK9BK9&H1(I
ztnC{cM$Cfh?EVEvbt}!Q)eE(kV0oG@cyK17hFN^ir
zj0>)bK70w*s6QxS-Pb2$>N&plto!*ovp(0?oppa-FV^Rg%W7hPZxCzx8?$b#*7Ol(
zpPs+0@jBMIYJI9&pRU%8oUxqm?#MTeD}N|+WN4h>$T-u(8NvG3&ZVq-I>T9?ss8H}
zweGCeUDWzltaY^~w;e<$b=;M@4w9Z|Bif1+n7M}z%A_<)`G!wqa@;`V2|u}OfhZKk
zBFwx#C8AU`7cE3OZl9VxD|=D)%Ir04VyQ7I%h_s+1bSn
zJ2!kKXF;Q-4f;a*KVw5-;&d*QRm!n`2{XE
z`7gLBefvNCoPnR=rk39aztZv-ncptILw+ajtn*B=jb*-sqmhiQsNA5=Q4cO{x+}k9(_NeoHywaG
z#EpW@xX_k25F)@Ct?5wCt(U(*%bIH#$G%`ozD(N!zvrrgdE`>#)s^zxaq?XLE#R;D
zPv&vrh`AK_ue4k8jJzMSkQLlha8GcBa;+&>3>R)VD|~JE+QKn~YYW#l{iMx)&JGi9
zenGz5R1ajy4&PLAm;A}5zko{|WqJ5|RxO0#524~ASdzLlWW}aUA
zZhN6~uk$8nyK}a;Rc`mOue1MPFLsj7Tb%92*)y&4?K7CsW4isAbGP$X^=^7#T97af
z_TW9`yXt`{L3}pYvF2DKY|Z?catW+dZ(me%UH`y-={InE&6%E*<>@n&`*JV8BZDQB
zrhJ|rS2NyV<4S32yt@So#0!-
z4j!mo_*GsV!FQ)Iw&Bgp7<-FxtC28nGo~50W2@c47Xv*wl$_*Vk))^7Oz}rCOWY@B
zi~Gd`?yu#216_sYpU*b^bRD^VnaI)?>q|4wVkWKC`dap1!d`qye<`zf%~SLdPH_C8
z#s7eMj#$mF?SvZ~P=E1HR=+pHw-1O0M|()kJ-NG0A!JogIa^aYoD>jIX
zz}_D&{;;~{Sl(bd;~Rc~W&5SRn^x<4Xtlmq|3=@Zf2;4;zoXUgLp;eFf$jyvkJ&C7
zG+7$xarA|&m?zPmV?Sa)YR|Lh+Y9U`>=g6AJZV2|KVv^@FSY+iBx=vhoi=D8i
z*|*zw*mv18?0f8c?WBF5J==c3zTci@&$R!@lZ_RP?E<^$&vCOLM-(wf%xAQp{@mHg
z+%UUnL%rMiO3p{gTrjkxcB+{W_Iu}`%cSkD=IYD}^PT;mz0CR6IgpM>8u3G;%+&B%
z4f{&Nw$!lOG)L$~h0H*7Ch3fM)vfcbLDpbW(HW$%UyHrs8?jG(EB1@;
z!~yZWI4G*ghBZxR!Zee~Zf&OPbTyyb(_MxiZeVWam|mhc(@XVZP&mixE%cUpEB!b*
z^Bj8Tc)gu|g5F*~QSYFig!(yI@1&oich*nUyXe2tyXvRuF?Xi>M|T$Q=aMe!{ur6E
z-k+9H?*N1Gup^lh*8OSHuBrQDM(%ole0#3<$5-fje|(Rw_s5s%dViXdeM?r{lBpX+UMJY?7?=q
zeStm1zR(_OUt|xnFSdu+2miRUpp@qFecUO;b-71j&Zi?Tn5waQv;y=1Mi
zUbePa-&*^bi|c^(y>(E|#>HG*x}1ZHUK?5TyNEbZIq#Iy%qew_ahf~FI>$S$oi?M>Tlt1?QiGr;P2$`
z;_v1!^Y`@k_V@Mo_Yd@!`-l36`$zak`N#Oj`zQLZ^-uQS=)c83&3~u=9{)`LZ2v?4
zx&Hb7h5n8H#r~)L%lymzFZx&e*ZS9U<}Lp_{`dV`{Ga%@`*#X0x`q4yGk5-pEB@X7
zz5f0Fg8`i>lQRO@f!u&UP!K2%bPGfSrGXZK)`50`4uMXAE}SU~^bGV4^bPb63=A;C
zE_NF8WuwS`#Y}bGtkan#yNr42dXUq;XT49aoGtXpxsMsE=P*q&pOxY
zZ=GiiAcr4jU2I)yRiG6wVQ#$NGAG{U%#L@NHJW}&7g$5A3$3BnMb;SWN^7h&&KhrB
zWlgZIwkBHFSd*;ZS=U;X)^*lo>w0U7b%UHekGb=1mUHIOlj--&mp9G2-I`AMtd*b_
z3$UGI)5y#?j$O+!s~BZz-ts?+%s*cLo;Z
z-%~i(zc<(}&@;bx{>I?J!i->-K%ZbT&_91$aBkuI{{4YLfhEBu!55ppBf}^^JHI&a
zTtTbA&|uH}A%xh<{P{)xKt`~C;L`jt`OEXC6m|{{4UEcf9T=N`Ft{NwQHCf_X5hvW
z|6XjLbZH^aJQRG$za5K+@>4y%5u6^J?th;%jJl=HXfaf4{*9DZ?V}58&J5(tSoWng
zb|Nlg14pH$jZGIzTbf!_+Sz^hrB95SBCg?TsX}5>d#C?PE6CixCKT0XxS
zm?@VgSglQVa-Do$GjO9^nylyC=DKrZ0xV5daBgGGIW0=~2Zsy8pXbkuq~m}Q>>KPG
zL2m`IGNX02b`UKT>=Lc#1JN9=X~8vYaZf&rMQEv|NgwVqwB|0Be_wiO(v`D)>&|W=
zCtzvPmb0>6ApPWTVC}sTK0rvnA+*Rcbr!!3FvoOYd0=^@o=@_`T40{goAj4^T&cpd
zN!JMB7r2xEW?OUC@Q?M6ZPH$7d22Xx=xn*ayb14=xAd^HJ^VeIAjlsMqbyvu3_HXj}ORg&3UuQ7`f!N
z6I$cdoI7-WU=x#6FJDfYQBxcmv<9~2wI)8L#vE1j(cJ6l`a2|B#I1m5e=pkpG^O*C9
zGuL@k?agFmjI-2v)>-CMInOzNc9uKO
zJ1d+QoEM$HI4hl1&T8i+XN~i+v({PXyyCp-tan~>HaM?48*9~w^R~0e`J3|&%io=M
zo%fvgoe!K3oh?5&+U$Jd{KNUk+3I|(_O>~n%9xfr_t4Ua@++rS@5}h%W|3w$W7X^+W$&Y$GqFT+utN&5L7E3z2YaEghXqFmCk3YlX9OPz
zE(k6Sw8tjtibc{ZxPdaYb+99L$$&t4U|3*8V02)7U{YXmU@A2rD}&vFd4UB
z0&@ckf;$3>gZuN{z*6$Vm4UT^4S}};?*_I6wgs4hICPMfx<{!&_;+n(Ux$BJ@1^U_
ze#)vpT5ZF>tE#B6s@2T{>y&f%I_*5JPCegNr=RE6DdK0nvk+8C~5*Zm!#ySzYtoCO+Z*+NZezH%O1_e7Are
z)rHJ$+S5JL?d6{3F7xSbkv3*tpY67F
zkC$`4${Amo@3oWL!R_dt?4Crw>{FQewF`5-c6Cp4PiMB*?rz*=$z@!_dIuiJJ3Df9mE{4_cQ0|VCH`v;$BGa?xAwtS9iFa_tpK(
z=eQN_2%qcz)*b0S;PWYexy&8q%WyAuN4sO(tK4zS1UufH;LBt#*op2nzAX1z_xH>Q
z`#ZPN*T9|ZUhiJ#%a-*@_~#V&X7>j7CU>fPqs#aZ?yX1h-P@Qo_I7u=dnfb8-sR44
z@0N4M(u;gGGra!I-Q>RGzU%(o-N^i{>zUQ{b^4UQ?QWn?`78EAc9px$-R^$szVB=3
ze(tVtcQUi%XdT5Jwx*hyyVN9I)(ePys-r
zxu2)?*3YQ{P_gk}i;Gm&8CO$|X~uZDrnR9)?U5GR02={oI`kH7lD}Ha;6>%)mvXId
zR@d`&BeYW4Uz*m6>(CRSZSoiSt%U#Bq1NBBHni!+NJc8=7cF1@cPI(+ezRb#e`aBjyP^)#7_5bp)rgALpyF&HuW%
zzr=c4)djssO@QPwB>
zRn%zD!}44!pIeUGf?0l@F;%?An2;_*z8$`N6W7VBHFdT8I%d@W
zZ#8Fk2KQ-n)buD*qpUC4rDmz;s-xDJQ}<2{ysV2~YCnrxS^AUuGy1c%a9O3ltTX$P
z{<{8#{-#|ZnuK%0twWnadqNL~mWRfM5}_W1S2)zZ_&_MT_?6;!ix(9yFHROeTrA^c
zWBcZ#*5*@(Pffn8)4!iu{pUn((e9!H#ddK{aY1oOaqHraMT?7;6}?!rwrFF~J4IWH
zwijJnG=(=e_09AlVf1eOWA^_+KM_mjf_$0O@n;zmG!+vx6%#aBKO|!`DN2{V!2T|4
z7i;Q+csflq;XN1YPpNlZrdRR4D|zFWXxH;H-)q+Cuj=b--(9|q{-k033|}q{O$yx@
znjV^oA1w?m4ZRq8CG=Kkb7(uhb};OQbHjz<(s0{wr*QXh@9==|(D2Cc_;6*YeW-J&
zd#G2aUuY01VI;oWJk(mgwf3DJ;H~rZ0_OB6)C-wWzK`2ig!#>)js7Z9&`b2|(irRL
zQ<00Mk}tw)4)%y>`NO+}Zwb!`&koNEFAhH!ULD>LekZ&&yfeHnqDQhK{zy2|BGNw6
zCDJ3(H!>(PJTf{mF)}4GEs_jp@Z1IAXt+hV9d>NX(3a4S(B81%d|tRX+&tVa+&Nqp
z?h_su9u^)Io)DfKPK56XKMT64=Ch=Y6*_Hg2(MMb
zBD`Ma%iTKK6PtwCYr-YQzRs+0Z_t`JOMjF0#2I`qD!?U;FaCFl`?MUbDdRj%*6w6<
zrcd>~MgwE8G0M2!xJ~9T939
z{bKHW6Fs}K)Yt25m5b&u>eG87SO0)sO8Jc1)L(Qk1{%Y}$tvY`Cneu0${3yL4KcuY
z%lMo44P!L@gE_=LHNFuej048^^gvWOd}upcQc`Fq5OYJ+Nrd*mUf2iwf%+IiL<^IP
zgn7TP3FLxd7swgHS&$7mkPFnFgvrChK`4MiD26aZ0i78}Z-<*h3upzcp)F8D5pEA1
zpd)mG&d>$ALN}leAzTLJ;^Cgq3wlEz=nMUzKMa6@FbK+F2n>Z`FdQz05ik-)!Dtu*
zV_`f@fQc{(u7ygN3{&7nm~>+av}whl1S@F$4IwGufhik
z=NB$1Twb`QaAV=Sg`X7eD%@XW7G)O&i=suXiaHc^W#;mJMdd}87L6&IRCHs}^rD$X
z4;L*gT1sB|O3_+dX_RXDeBQQ@+}m4)jIHx+Iv+)=o
zW^(K_+HvS%KXG3tH;%H765n<5F>>5+YvQ;bZcpsi$v?ZY?n;c-$=`ah?j>U}rO171
zJx8tYS8HMs=ZQb89~6dk2RDpx$8Z-?g%R!+?m*L5}6*kCo(JYP-I?YA;%9#=0_Gqo{l^hc`>pE
z-(tz+ivpv3O~^b$xRHgCC6Q&36_M4ES0WoDn>eGkk@q88BikdpB6~Tbwvhu-J?ciY
zqj>^RD7VpqXgFFLZ53@NeCchpW3)@Od$eb?PqaVhdPI9i`$Y#vheU@*M@Gl+1;ZB|
z5gi>JADtAP9Gx1S7M&5DDKet_Vj`M|-Wg3sXGb57&W|pNKF!&=(FM`P(WTMl(UsA)
z(GAhJqVI~#=)2J^(QVP4(LK@qF%h$4S+U$$khs9yrONVMT4VcuWebrN-4)#%JwT%X
zH$pSUY^dhmCYUvF1UG_-f6u9>L;&iWulHHr{fPDM!B(xmeEou;nuC
zOcBO<&ZnhJPOM9GYjpdMxg4YY3$c*#_;6d^K~r}y)g3H#2Up!8OWmQdx{QX36mYg^gB^dfXiFXEOX7H3vH3E%fTwugdYQM{LORN|zO^mhhmrbt
zpRjX0@1u#9SXtflo|NvxDbMd@6vrpk=#85hrO{$U#xo6%lKc`%@Vh9vHyIx?qT<0H
zwFd9!ebY8~fUwDn6~;>N@%GWeXf)b9+B(`k+9}#KS{CgU?He5sEsqYXjS(X7!!w8C
zNc4dtV}l6BHpMo_w#9bE_LYdE$BS4ocW)7E8|x739P7q^_<5data+?;tbMFgtZS?+
z)+^RGHXv3W8x|W88yy=Tn-rTIn;M%In-QBCdq6a8wxro}%~m#hrP&+J-fgzE*^Xv=
zVh_jW#}>t&jy)H9F}5bQKK4fJ9iHQp*pArl*uL1o60;R!MG2u%x)8q@+bj+ma3?olClv
z^eE|F(ywG-$&ixaB_m75luRhOwq#1lEhW=S?kSnY|BWaaT{6C8Qpx0!sU_1&W|Yh<
zd7xx&$%2x_B}+?|m#i#VTe6|#t&(?3wv=ou*;%ruWPdZ!%oe({ou)0i^O=u4*V#cY
z3f=jfeiXX1leWIP^96k=bZ3{3D{1dRIYFBb+DFmuqXp|hc1zZS?N+SIZORqefl!Lj
z4utsUFg7Cl7ulpXXE!~Xb>}PkH0!hwIf4B>K6D=KL^`m()IN!Ig-zOVzNV*!PRo&#
z+22b`W1Ti6lt#`s%nqbG`{)s^)28GU)|c6xS&y<$WqrBLmq6N>{EGDzHszM}p8Ge}
z@4K5=f9Sr$dW%cjLTju0ch>W*;jACGE@Az=H5!+zY0s?FY_#?RJ^fm;I};PVTUKSm&^R4ei(NvKbwc
z{Yls?Gwd_1bJ@R^Hf{IVXIcH(|2?hS?zMYc=doW&JGZ1QOJ@re@ql%Kv)^4WY}))h
zZQp0jwLRLl+qD0A#-_Bl`&t)sG!;EQ%lh6v&l=CsjkJ!NNvoA}W$SeNCe->LX~ELp
zx|;k=>i!?pfKaP`Cb_T7cQaMKn@zs^uxLn5_&C=;MO{pQ@P1wdl`77cttptBa;vpf
z6j?jS8;i+_vqVSP7D9Bgzqh{^#4!=Ix7#~~meDU`2GDp796P_h*pL
zXUxfXg!N+?kF#Esfo98Cl7U^5@m$8AS+B@=f%RW9u!%EX%XpvlhZ!HT{wU)k)*okl
z%=)VgEXR!RG7hk=&ZwqonVD&_wliJU8JQWZb21yT&dY4Vx;V3#bvTn;JhNG5GuFq*
zUJRLSGmmHetIS`q?w8q*bwy?c>nk#^kXDm8Rh-5+!u=Rk=4NplJ=a%h8CpXvUn|jC
zQO_ku2Cmd@Q)2_K(bj1j=|QyXsQLY`>wU+#i@LHWsBw6T67BPJ2_RdZ*f&$EHE;@?
zrcR;L&N+d*cf&^;{F7(st}z~D-BUb9Kc<@JpsdQ%L-oq5+iFTI*~av3EZ#3_O09gO
zfRd}W+`8vb$z?X@UC!n?&fq!zTmDen4o8)Qss)a0h4p=1`>XoC5~l4{+Gl$F;EC@2
zkA}^^@=Ur2)z!g7se|dJ4->~=C)^-~6OeX$s+FQJZNI&~Dw#SH2w
zR*9K}$;;vaS&t^>P%rVmm`4ch6i@31^aJ#BK4|n4tBwA~K&{9aWQ^CM*cjJnr!uPZ
zRIRJ54bghZ+7PV|p|?*PX?$bG5SqtHPoL_X
z=Ukvq^9i4=-|GwVMRPWz06(Jt*|*lWp7CNd#(z4j*5*g7wZGxB)aIzz-=NNF8;spH
z3SXsm=bu_{hgxvSx>IS#k#~}wkE|7{ZH4v|EzN4{jWki*v$$VzdGV#iV~Qsg-&j1o
zcqVnE3yYT)zeug=Tg97;w-@gzJ{WRCxuL>PX{c?e6Lo>TLj$NqA4#owW$2dBjL_`R
zJnGAy3#|@qptfvlXlH0&SV!Uc!(kL%dsJo*l;t3F+i3Lo6jWL={7`rS%4`Kn?2YjI
z;ccj|BkCPcQ$r#nP*T^TpYB98%|$CMi>!>Sk8Fx;iR_5%jf!XnN~ajxwOzC`c55H>
z%&_RF=mf0OMD!jk)A`XQXp}Y4jo6@{pbPfLOw>jYJF^vbXV+NISU>E`OJifOEpLoX
z$F6)hwlKC7>+zM?TUd_UV|%a~U2MKWtiZMkTLwik&yUq!Jr%M#=1wd02AK
zm8`~!d#7Y87TmsOdb6x%{$}B3Et<7&)}>jGW__CtYBs#t=w=g}O=&i*S+dzf%@#C!
zy4i|mYq4G5Z?>)3ZmdwdG^eznw4}6kX~)uTrM*h~mkuc%Q98Et+R~||cb3j7om;x7
zs0H;Lor=0q(sB@V}UB@j&cT(%}P|^G&>Zo@Y?Jw4gGm3Mm#f}!YC~jBW
zskmEFF10tsMJ1%dQz>Pr*(CL?qIO@_*k-C4+d^$7UxZqy9HynJ>ni}>b|LoqaD3LudgQb^9obmo-TTe
z^TYr~jTx(VY9=X3q>T
zAVENqfB{q#R4^+>1k5?-gb}l36%2?uqabEM%vmvDK$sy(9LyQG@9MRC4thN2`|iEZ
z^MC&6Tff@1tGcUJt?ubvUDf?Xun><}6|4!?vPR=)S)bvY3mMU_=jywru9NHHdb+-D
zPe!;$x>4>3&V{G268SWDx;uk&;uY>nRwV0Mm+@|*o9^y*_qcmmo&1P<)Xik({EN(s
zml^UeGb{cL=Eg5@U${kXv0LJnx^LV%_iY%4Io<&9O!(|(GhpRfNbWr?F3i?l9<4~v
zhYRDc$#2`mps9h;!DY#WKB|?tWUm8zUO-I$K1Jam-ywfIKr3y>n!ff$JXn7?)08-k}Jy9
z<|@26S2tHLS3lPv*D%*8*ErWC*EH8G*F5#b|4*Kn7bM`WJ!1%bgtc)6{jc9W23ium;=V+MBYu
zb~%1)Q)*qVcBDTPUx(A^p4|`nI4O
zSWi*dZcXg>LQJ-z-^z9*M!77
z`&Zh(+N*7D%)M4^|6faNo@z{7ljpCw{+cc;TeEHby?%fHhUox*$@|ys{`+^lB>Hdp
z|61aozD?KG{?}6c=Ki<;#J)xUmiqVh{HGiy$0%4dD-CB8IvJNJsHDngO`larpOxlz
zGQORDKf91VyO2Jc^aOulJA9MHX4S;9Z)JP>t!y!iW#7tHHBGjg#q?WB7QU63ek-x;
z+ic5hx8_@^t!zc+qv)0;mX*!w%y)%vC8pm>Ov{qL=G$z`l9qj&ZJDjrl$9-4q-O#-
z7yX`Vms(VHR^jvPg3c-E+=9+S+ZQaE#iWd;NhysDL8B5AjX*fF#pH9PP((u_dd@c=T{Pg793u*Xb8orc<%(fzZZW_LvhV#`Y+Y8e0!Zc+3niR&b2^qg8
z9G!-jrr~92czGILk%m{M;ZV>ISXbnzU6mykWAv2U672kMTFz+QQTKp9W;~l02?Obc;d2z-xA*8`pLJ^
zt`d(9lF;;*6tg|yI*Sj)t9izxZ0#-L)s~|mUT^VgQt;|zY)~$Ff-=H&Ji(AX2J-C$
zw}+&=Jte2Z=i_RJ&&TT>-b)I;nv4xQNS?5x#AQ4<8OLu_@|c0#oo;9=eYwRfUOtwc5ueL{~dFG|xzLI|XYWl70tJ=OU-}207wj6NeCKCdVT-rF{c6}ue@4At1XQbagk$(GR`t3~lmS@)l2~Xc9yu%EUoMtHT
z@%(}pD~VMn@Wglthovb8rlsh?qK4*R;;YOd#3OSz-;Ou;q&e?RbKV&H0FIEqIjp*x)fr*#J+#TfUN&7D*|$
zSW3A{;*;#9@~yp$aFxBBlnFf9HHg>RD~WHiS4n<5M)EVZ&6at(xa@tUlnPc!Dfx{?
zovR4%;JM>cpLJ=@YQo6TJM3M0XO*LOIF_Y%);fA8+2lq_S;vt=eU40PPEE)|O~_B(
zk|~b*kcaw^hx*Vu>cf_=CVi~klJsiZg>bd)Dy3{U*@E4Q_!`??%Gw^p*V~>_)^Z$C
z79Zf)sZF@r)gfHPb4F#qb&0RxS);PY`o!0}2I(FfrlpQg^Pj-Co4cCrO*Sr>KpW^STKK1Ii#d_v0rU{H
zXX~`{Tf+73JK2`|o_MueOSsO_f0V5zoD@7KIfLg3R|hXh*HQyM(LFd!&pvZxD{<34?6Md_cI)d`QYf
z^AX`x#tx~2`HXNfqlnbgd`x_T`Gojd^C|I7=56fNl5s|tjAg5M6nns&*@N`;6hkj__B%!P7Ra}2
zg47d&)Dr^P+SmYpz!KsK69e*dM3A4qCinyPIFE1F2j>%?99%%WI=GOnbqX#b>>ON7
zxMgq&VV6Mavo?_WYzn9k=fE=wmj`DNE(^{kPZXR(*phc`xMVzSJuB5U=4??~aZdWV
z+MI|ltivKFrMlWa#Fp3ChY45PN7&kU`zYa9`<}2-KO8fuN`jzuCpRYLhaNJJ8}+jY(N=nh>9CO3BmF
z6ceAo)6&GR8W7)P8WN7<$!S4RGrJ4vYwRGx)pjuH<7Fh49@&xjSi39nb#^!66YcJV
zf7pS9ci4kSUvCd4&K*s{33e#qRJ$kXYwcddH`#p&#|8CBS!4esTpiE~j^Gx=s{?9|
z-%33v1x=|*>wsFZuMMP345%C4OU?0HYL4Gx6Fe4c;IUW$j};4au^S1uVE7^^>JV7M
z{epn7V<5Jg5Qt@d4C)g893*UQZID_Z5L=B4nv=7cYf1VV*NSkpD2Egk4-Y-%fC?38%WYd7iS!EBnfP+ICE+r8!dZH(F`|dAk!P8u$6AZ@
zbG2!OPQ+JuE*HAS6jbvwYEbyB`57_b`JLGaR~DO9lQz31
zHm4>wwDi(u~!z$%8Jdhw5+VyP1D43Su86nBTZRaR+f>OnzXE}SW)wd9kWU7;nVlsqE1TI_(z3Fdon;&^+b<(vg?(gpmT$BDW_Fg}
z^J*)JX<3PBS&8X>C8lL1ru&r?~Rl1=-VyEgHu
z9LzrZ4&B$xl_x3Z=5FNajk&qDn%k-5s
zjw-pWWLnAdB@0TvDOpwWQ+{e`-O|RTC8h04`wAmJ+L!HLHmvOMveU|LEW54j@v^7V6;U$x?%Q%(?pXEb%Nv$AWsOt2
z@=mOB>R!H0`Oxyi%P%Rvs{ESrTUn{}MEO(Y&zFD5N~BN97nd(D|E7F(>!5Yqx^C+R
ztTxKG?$o+BtBOXnKC1OCZ6fB_wQtj@&FD5`+FaM>gSKtj9^3YVwyP_qS3FcPv*PKB
z7b{+_c%$N-iVrGQRIG1jnESSEyOHgVYIj1rY3&=fZ`%HV_D8nAy8Zh0zjtWZu}`PC
zQ^QWJI<@V5VdqP>+|;GneRtl!>A^0Qrn0EAQ{`@zdsZG=IlS`t%F&f$D#uk$uDrYQ
z#maXpS5*E~6;~xyb*mayHLGe})wZf*)z($pRUJ?@yy}RmSE?3QRV{gA$>&QJR_Cgd
z>IT(~s+(3ieqaSASN$sJiOsS2k|q$=mX;%uF+n+1>1G
z!2!X=!Bxy~zR$&f>Em|v@z!ofw+nNc&UEK9|K)m~*n78I$a@ribAN`T!)br%<3aTC
zp7ikn^zo=dAI~ZIyksToCjZReURtlT8Ee_MC>_A6_Pq*yd^mG2uP&WYI-6OSpOt=H
z`hB|Uxn5bp2TRDtX=-)@-G%2
zyzGnRSFz7)_@)<}wR|SwwB_3@-+uYP<<~C1Z257^k6D&yPYoeiW|#iD^wFh{FCDk^
zTG`67ez4Wj&z63)l;?k~*m7ydCC@Iov+B;uzdYHt>g2^AS6x`OYt`VY+Lfy+S1f(0
z^0vy-%2tclFJ4)hEGac+k^lO{g}W~7ws5b7yL_?y(+j>Hu;7X#7LPn{#NzgY$|jX9
zD*wfp)|`Rc)M>k@qM~AJV=6`g=i-hBcUUep>9DxN!VaHw__)LS?T$639qnoRN!z$$
zj}E7`YtycMJI>?suhE^2-fVnQ;~Sf{Y}!gbw|W)kwc3wxA`_R|74fuq&e)Z~ZNV(J
zGW;_7mFJl6!J4S6ayv@?>|bvCEJk1VVP5zy=w3X{ZBee0CpK5tTIpN=D+iNXK7r2)
z$kX}w=g}kX%QB2UM|c3FD<6#|I@!>e5SZXmMi;IsPVzYhi2c179Rk^H&2nz#m5()xOrOfiN#|{
zzauR{SUg?7jlW~o-g~TgDTzO1ZNR(o#0{RW`aSbLXEO8i@qb(|Q^iUnc^1n$X23Bj
zYX0A^KDje`GP)~HSoZMce^>|b-&bwk&Dw=~@W}63p_-4%qQ|1r=!rNVFN}-hX3^?6
zj_bsgantDM^jXTEyPM-y(en5k=14b>*08?e#duycg)5?%tC5gvlv>Q~-rbB~B!3hm
z`0sGf;63w(Z)LZ%yVyZor|e{RjDEBO?L+oq`v|jxzYM)QJE&*>48ouVGk}}0mS<>i
zTyT7F0yBQ)NhB`>v%>xPYuD1XawWV?sA=?*Ywm7!Q`}T$=&lZS<=#jUqo1`I`>eyr
z=Me5V4&{F19^7r*lY5N^1c&$*!2#w-M){9ojQ?mx_>WCC*cQP`+cH>XTLr6q
zyWksJ9#q*buD0#!>ez0s*lx|^(|2`wyPGSuySp-bwi|3Ob%)x^++p@|H{9Onj<+|t
z6XY31cAPuO-sUc~x4X-H2RGV2<|f(4-EDS;yWKwF?yyg~JMBz2&A#itvR}D1%$oi#
zboTo&v_FIq>uq*2yZC0oK;O|HX7*tn&Jc4bZ(3;?9OSn#=lfD$!d&-qb5q!fwpHq
z+)Dea+vLl9YrnJKB^nS7^oRN({$ziaKii+<&osk>BYcHz6MW{|vs$N-DGr?ZICz;g
zJLPtkyURZ1rrX)>Zu_*m$3Elk4X)&w`n63x#+SDb%FKAI+M%ws-NUu<
z<$kB&W8a*2Nsly>xSRTz?_#&&KA&gIyMIt_k8(UEpONVXj7uNK-IwFJ_wot%uRaZ4
zv3t6-mab9$F;Znx-IN}u7myBtqoQ~34z``mG%^;`K-yfgC+
z-`$)S9BXR_Z`n)SzV>=|fL-cV1ua>J)F;^44-19`2l{RO0KY?cc6big-RJUjoe6%|
z;I?p??;G43Ob_l3o(-q+q@AU#Zdm5`bH>?V6Ib&&uH<)iyZQtCfo_;Th&N~*><)5A
zxue~&emlRn-=3@b3*0UKM1PX|)P3oGWo1<0ck~1OZhlX{hwqn*{So0=!R`K3S#=d`
zaQpb({m@_(Yo&(!gWbi!I6ufA>5uY*gZqN}<1YRJp0M7>U+B;G=kY}K{?S*_!lK^M
zJ4L;Uwkz7UXzQXrNvEW9vSrdSUKB4c>X)oYzDZUVZC|uaQQxBOMLmmpBt4TJ$u3Ek
zWUHiGT*WN+W%0^*ar{LxG#Qe#;o0qd3yFvO4LN^o~AAx+YsE
zeUiROzhs+a+hn_BduGM=PX;7ABs(TMB?FV4<2BqT9nU?Vb&S)G<6h9@WK#TXG9{Us
zR3z=}_Wm0`-+$~siN3LQ;;-TrrggN!Txe#PFYWTMQFt4_$p(i>SY$4+znRCw+U5(}
z%xr5r*#2Q1%NxAxg<)NLzg-sAvkSudVFUA(T@elo&kQf;x6(7bnd{W>wD9!sjPTO%
zvT#Z`)qfB^>Ob@!g^#&y!pFlI;jHkfaCZ1~_?BB9z7xL7dVpu$w&8Q(d;T52z<(ZY
z5q1bWhHv{X{AaA{`qY2vzY6vZKC!3xg-Ls}uk9Gl^zZuj{QKrAGbY^6UmWgXZ!s&)
zDq9>53I~Tf_)BeHe--bB?8ZAJ2l{z_6K)f37w#DD6b=k|OJulfxLde;&?;OWt_Z&gR|l7dYr=J5wSO@DHvB34HT>P}
z5^i#P1^Y$D{SaBVEDEDI@=+4ij_O23QG=*q)F`+txIDPa-xoD?XR@mA-Qd%xUQ|Dr
z<{$PCxgVp*U+o|9*F<&wwf<3moqx<!MV7PD8%8&E2
z{CKyUzsJ9ov}HZ*`N;*zg~>(9Imx+pyKud~H!8O0yCLD0;YvT+KM)Ry8i!kjYyAZO
zlx-4zANCA?u#LkX&BNgz=F#xaaA?#d+%szCC;Hibl7HGy_Rsj6{j>Jd$c5WR&BN`Z
zT-ZOV6%GhygnLCT{4M@Df2*J3pAUP5-}$NG&*mZC3i(L5(ck7@uv>(`u|{#Lpu}_!
z@@7EL+FTu65$^3@^tbz${2hLdzthhR_la8CZT!o2TR+d9?grU2{VVn?|EiznU*mV!
zPwsbqbN}If@pt*x{U!c>Ki$6(b_rM6&!WKJ?cd}*2lAGKt$4FR@AQ2JBUlB`8x#CW
zSudQ-W4-W;$*ak0$?M4*$(zZ@h`C~unZt4~vsUg49x?YbV`Vy{9S<;Pv1;>SJI6g@-*TVXx7`B!o?B?&cVF8N+!yvkx5$3v7Tb^A68ni;
zY8Sb6_Qx=`KZQB_b66{QoE1Qot~zKO+~kk8Q~WVJ8?D$h;XZ0p?xHs19%^&$ptj)t
zY0Drst+<0)5*U-`?rACaPRr~ja~d-SPG`Qr8O#t-=xh>u|JR6khKa
zhxhwRx07EIzT%gLpZVoR-l*2g^yaNaeN11|FT5eVF}x`p8;%RdhZDky<^}VjdCAN%
zbIr?Uo_Qr)5Plwh5q=qd6)p_F4i|;_urw?S%fr@To3L$I5uO~LVmg`5W=qr6Y?EB(
zS0tAvHzn7Hlfud2&EYNKt>$ubg?ZLIXI@RNNUln*4j%{~3?K4U;ltr0W|>)TR+w*+
zE0ZzFHQ}q_YvJpDS@=ZwWcWt-rd?y#+I6f2z9?Olbwju$Txw6Yr-mhA!?0a)V{&;?
zmXxzDhq2P=>7+Diovw|QXQk;=(>{)C#hx`g&nJzd%4l)YF#aL_D4rjE5`7wd7JVLl
z5q+7|i&jRfk|xo_Xll|pX_hod>L+!h=i+OkY0>oPmgv@Kax^|Jkrh8lb5@A8NLnWQ
zCasc^B+v62yZ+_zcbmvtPI~_1yH$9j3eQuJH>@1UvpSBBPw{8)gpN@>lSAIp
za*Vv6CD}Xb7IlyMMV+Evya%v-)FPbVIodMXDry&1M6IK(
zqc&0BXxn5T)~db0eA<)aPvcK=Hm+x{v)9HA;)Zek_^tT;_yg8N&y8P+UyWajUyt8l
zRrK5OJMp{md+`g2_a`JtQpDP%7Ocf-$vUKxnl(wIa!2Jx#uZ^vTps;UcnbOS+`hT}
zbNjJU`mo&a+=$%axn8-xxvg{Cr1a$1bMIWgl!3Wj;&!?1dCp}qYny7b)~Pw~E7iA9
zedzXgheS&X>-3J`DLC>TC3!!LzR5=3V{1|B^^d_ez$=ag5l6A6b
zuW0Y|+350&be@c!J~>>T625|`h4YN?XsJo;`k)!lxw|I$Emvt?xA*Y;>1%is+;zeA
z!Hx0PJXL)r&lF$1*&CbIr0;CX-q`d*?$@Gyb
z_@WbP1x05Rotcg{`({95HVY|TGLCrp`=jxCiFhiC#SqX!VlXm=YWdK7J|L?vj2
z63e#RDbYBzy%N2PZUG(G&h04U20|?FN$R9T-=p$-9-^<&Eft>6VYu2(i58(y25GS>{vk3b}FzdIAuyI?1{JsgN+32nQVFE)M=8P4Xn|l1@ne)=JbJ7W+<_ja1W%yHE5Qfo2}-a4
zJy8iN(UX*5BYLvpBJ>o6Cjc9Bs^X*%Pg9)q!|93>d!L~=sn?l`I}bfeaTCz97551$
zeF;wNe6HfEP$>g$1$w^Xq;3}|(cS2UN;Ct#NQv%2FIJ*o(P>KjJt})?AYO<*sKmwSLrUBXeOQTBqmL+YjLJSBu7f_V
z#FePD2jZrv><^-!QSq~xr0>B;Vr#}xwYAkIKED?2?~LS;eFI~t#>)Oc{+?Lb2QaQ`Y_XF5J*Z0@Sk=L+N)%7u+4>6Oue`QK
zk#n<^_CRnVDs=@pPus>ya1u%%3v$l3*jixhme)Bca{iXTe?o8yD*F?EIToeQ1jcxI
z5vsyU5o1df#(jCvxMIek_>jO@u(4$dt4WM4R~R2Qwzb0g5@Xva!7XT8Mb3k^LSY=)
z*mjDX+iZJUivF)k^
zbJ1>!9Mg6yg{LtY+g;(^+=f*ZDa>3jtgTR_&wD9>l>COej9=j+Ge`SWYBDNW>}JE9
zZAG@Rx8lT)#74r|u&?4||N9lV6y0BOhoT1*xC|YpxWmu`3tWyKq`2Ye!3Aza4^iCl
zsBBBP38Xx@6HqB5jD^D$C&%E30&*;jRNP7Er~k^DTy}(TL48=`D&n)mRdX~bvEA1!gD~X?@u?KNY11dIxK>S+l3-UXGwOuK`87h4bf%y9sitmVCsc`qlu;wd8
zuIpL(m6HB`xSBWwL(pp!xdyk_Dsqi)uTy+W^m-)_Uz7R)caw}2KLfe0OnnRg^5XyE
zPr$tHXv+|KB;)I^UMNYptBUW5`C(`ujp*WZ9<<`d>Q(TA~O(JJ!DurWW3nwc_kWv
zzMw<{(H9jjKK7F0#rNhY{$zA6%ws!ZlUEe(A{zUeBEJP$U7Hddfxe;m3iQnaZBX$K
z2tGsKR=l+RodQy~cNOkR8rHX_m}2yOg*%nTexR^c&9LG%C3qSAsDRXezT(86C7&R+
z{6ul$JD(O1yGotFi61Q}AT}1?1=d9wEBzsey}wjgDW&J2uTZi5LWQ+c#(u4E|J2w;
zim8h(R)X4SrDE!#RSNf6nK!7&IEP)T1ZC(l#oUN4SGXT*>6&1e`>cc99
z)z8MRE+EIk8if@+#;z@}2f9viZP04Pi#^vXekb%>B@p|5r*I$G*zXk?Gq67>!ASH+
z#Y{qfQn+7i?9Yn)R1ZuQezS2W
zLYarkK0toM$?v6e@nW-s0AKTBk3-;4eCcfTFeQ|B4OhZ*QSm?FO&C#N0y;+Ej
ze7@ojLN8F9M1r8KLhtEdV}J=L~m5wujoyR+klQ$!T=qw_#IK{d+-C%iHa8=pQL#4
z*~y9*U%pxK{ZOeBby)=u9QxsPsJ)^+4wg*?)~zbc8;^EcQ;dVkIrMv2+3vr5cxxNu%+A7yHa{a<{H;n77B)6fo-}NE=c63MB
ziTEYxE-;Gto#+v83i18WGvO@a73kTDj2FspW5Lr`axRwZ8jqjKxkHE~UA7Ch4tj+W
z<7e(lC0>DErI^;}7$uVQs};FF=B`o94D?#X;Jfa6#U6v+sK{?7cast}LdEV7O53GB
zz?7rXmtY2?Vt4v2ls=LE03&UZeggSjBKM^PxyL1cZ-kIObJ7Q3zCgv+AmidZuOmh7
zZOb@XI+oS}6}yA&kKUn#VjrwZw=eD8i#WX#q5
zq0U=S1)*a7935j_4NelN(q3-NWqOC9PK=!4c%Og9wYP3ep_
zP>gK9q2ez>8x`n^Hdf4)Xp;i6ou&o4qs~zitU7!D00s^
z%queH9hL&;Gk+P{2HJwOu|n}<&~^pzXBi(%*$%~rLTTTQuq8-4b_J<7$nWoPE0B8O
z+hNZFtI=MHTxW+;S78lo4br}KAoT$MAS&$?z6Gg=fKP?n7WfsFItjnS_KN(i2>Tbn
zhr$7h+Y6OG2KjB4wt;rwE+*Vb5nm1mDv?EZR@^dFwhNK?n)E9~F*+EqK_ou48|+S8
z{Ah?0iGK}+JxLe8kb3M*9mM|oC~}P)?yLCwP^lk8;tTsL^0!XTUvmDFYq;=0MXs&F
zgWzED*Fz6cf@$cXihmeAOz{t)!xcGR!^4#bLxdv~e>FN%@sFUR6n_nRgc8YdaHQg|
zMUPVaqv+9!zYaY{@sFX$D*k%(IK@AX9A874tB9cY!}pu_>5GQL%;aC&+Ok42AoZNa`SM6ZV7$
zm8co|km9A?vJddH(MJ?7HjsUQe;R#E@nVn175@x6L-AspClvoI`lMn%MQ19JLuVD(
z78QFz)Et$43v%9){XvvNWgmi^!(_V<)k2>uFaUjC38XGB6xa)WQHffhFDd>ObdKU>
z8*|}h+ITBEPw}$7R|@n(UoG$*`kLaUZ(lF)Gx~;N#IA1^*no=PgAu#FU0@^nj^f2H
z-c`KR{XIqIJB05S_znF)3I0SsRE+rKM~aN^hVvEE9sO95vEJ|##fYt>KS0KG!_O3R
zHM&5Nah>q<0(+xhC|+#&rQ&Z#zf!!|b)n+#K)+VJ*m#lR??e|XUhG|2U>~$fiCUsd
z6uS+&RPpqUjM)h?CnEhjx}5%!zFMK!)6s7fHway+$o=YYmExs8S1a}`bdBPrZ{>If
zKMh@{cU$Q@;3&=hMX`lE6Sn1CVN)(_Q6@NFnN%8n*)K!uF757&n
zY+hm%e+pVh@u#6J6_1@uc2eT@D7F@2e6?gS>`J^NI#h|bME6wUtx%3hA?|_F_d?tY
zJwS1p()*!@C^7wBGD?X@phqb2ap;k76!}j?Z&Tt9D19l!ozdr&cmO&_@%Tu|
zT);09QT#yQErX_nzAt%;xTJB+3du;6W42^IalR|z_$tBPiPUR>l6-`I4&RV|G%EXm
zgfbwmHK?n{*ixqPaT2~2%qxBSX
zFj`*;4o4d(hW^YqRDuzxv=a<{mv5}dn0LO3VopMvDl-0^Z>E@&QLziiIC#E=V({C1
zOGU=T^Q{zfDvEOp!MP}g7R+^MsUqXb`7*^!M#~i$^Ub$b%)MwEMaEY1Z54ALTA|2z
zYrdUg?nm1zGX9$1LNU|P4vLHu=Q}Fq0ko4MW3>6sioyT#TPnfRD968G-bA}9!Bc2A
z#frT--UJyd%y(C;*uIA%V~6>kij{uorN}s9zPBRt{_`@w>eEJy@}&_&UgVUS9kgth8@u
z#fcB@qF8C`AjOG44pywRdsoGYt#(sn9$$WUMaJ{;LlnC|I#hAeS9>Tj?<~*xO>ok0
zdntApy0;?ZdHH=5nU9v=S8>vx`zcm@QuYB(wk_KQdmbv=0w?>C@?ft*4^rGnRE{^W
zW6(ns86V6as@SX1!xR}W%nw)WHR$1rI}#nC$eg3RY!_r~F)!N!nSYd*^59NIr3~0f
z=uwJ04Lw@1lhI=ocRG5kVsA!|Q`{No@rukV%AcUf7-arL#ZE&{Qe-?bf3jllLZyBn
zW0QHQ6Uh9dywn3^oH8%_2KziJ`vVzc%b%&(7tpg58F$N{t=JdQa}*he%S%6j%+1M5
z-++wC<@ED)ue(BE@}%UaZ)+(MuE=Z_1BW?0e{?ii|(yFH`LM=;exx
zMdigtAaii?Vi#~V()ZvNqt`0-WAr-3EkUnW>?i0Aid%}_
zsK`8;{7s5mhmKY3kLWl>#+>rw75fu9L6LE&y!aE?pHcB4ka4NJ_zlQCs=W9L$QX0}
z7R6Pfw<@k0oubGbiu_b1kb2#&_@mJ~6f1ef&wy2`CNI7PM#_pmfz0p7iw}Wmg5Isj
zoR0iGifM}8tH|7w{C$dPhTg9P(#8iA(;OAwfy@s@u=2`HX_Qjv>_{7gmW)Z}L=rWAcjk-0Vb*@`JcpH}Q9^clsR
zhCZtV=b_IjMvkrL6}iuxmtzSG$5{SFMeaH0UsBAO=o}@u5S^=-v(T57;39OMV$Mci
zQRIGg{#C`CgTAK7ee3+|ia8g3LkUKsZz|?9^esj17v|;I1G4~qN0Ix7`F9n25GuzZ
zxGw1Xiai+pKyh7BImW;qf_|j9Zs>f)9*W9w1#Ti3$bg?4$m-3a0Jp!##dey><*>ko>Paz83o+WnIvWBK`?6)QIQMUnCS
z{I7}?d;F%zcz^zP#Y-Rkq4;CbKNT;1v_bL5p&J!1eY8pO#~V2eO)2LI8NV#$yii(~
zgf?hBXiPfCRB1CPA^jq>4BC@^1-b=vB^~>eb_4n|yc4DWO8b+JFO&{|o$0&n(7`Yi
zAETb7d%-aBKaU=ySo*CLKNjpt^f1NJZ>5JTmVPT80mo35W1>{HbqaCnT6(Gy%J-)!
z{%iDfB^-^Op?LhU^h`y@^-9lD{9^QMMaHp8&ry6Odafe(K}*k5JpEECHUb&5DZN1P
zOVJAz8RsayNMS9#DV2JRAwS1R>D6$PF@}CD9Sb*;egk?7U`z7?It{3cjFFT+0FM*T
zqcdO@ar(D(Hat!IWb_$$jQ~Bf
zB_v`ij-^tL4;iN{#qUesATITN6W(I`m!q89N;$8YXHm{+f}u?5drBhyBmD=-Rp&J?(LdA~QK*kD7KZmc_<}y_J7?LYd=~qawZ>jVpB-fyoKwrsNTB-C6
zcquE;Co*OOF6b>oT>C@_=ULm
z#;=N%e)&zY;-|kWa$l(Q4@K@xmHw&7J)hDIN^&E*QAx0E8O<&$Vr=0jv>w!_J|vbk
zP@<<$wk1UPM_D6iOdKC6Yoa8jXj3I=jW&bk=#lQIy|CuOp~
za_UeEmGTfhgSJtk=h3!GBKxmUqDqvu2@$p`Yp*0yUwl-Ee?YszR>Y+a-JvJ>KS6uJ
z*2F(Y`zVRnpfB_zoqj9ZMoFZt+bYpSbUP)Qif*qY*rlw$l86lkD2do|2PMJ2WjiWK
zU34cU;utF%sKoeK+0IHd4c$eFrlW&kFm<~H-BpPuqq`~5cyxCq#vjUtz)$>QCsv;CF+1a29Hyp9_SNFv<3R4B4e&)
zGhr5aI-^f1(N^d)O4JU0R*5Rm=ai^5`hpT|jlQTva;(3kM19dYO0+FHS4s9kzfuy(
zyHH7FdtbvM<~^Q-u2d4)#(
zmn(_bfa6Q>C!kltRrqj%UZceHMfq4IR|_4dMY%SlR(^Hup|
z;5f>OO&)}Yh>Klkz!SueKxZnsQRplshd-C&heB>7iocaVPn-c3I#6Qzwl)4E#1&|)$lR0Gb(NU@Ze3rA@q^a1
zO^AL#v5^qBMQNuHm!kNB5Vt{lE74MPuoC@((qBUK9eN}jMZ6lto~^NCZW?;ClDiwd
zMafO)vfL>-e56gN+F%zUw+~9&gxoL`pKZhWCU+Q09fjPX=x8N(06Ip=
z9f)41cKjf}OX9M|ir=Zwh$l-St(|~s5`lAmi
z(M#x)O0E}*p9ne5Clzc*$Zd^2t>ieKD)18_O~+p<#AhU(<3Y&vL^($ZxjyJzCD$8$
zS;_T7Uscld*N8)!{yK5U4Mg8ia=V~!D>1gLct^=?kJ45lw;j4diJnE*E4dSlp-4(@
zS2Ti{{G1EgZL8$)k#-}M+-~SmN^W=bcwigJ7AQ6pa+GV|P|0;in<}{;=mAP@XY@!V
zHv}CGS5x*{l)4E?G5V8|^XSh?PSSr-a@Qun#VGQy6
zQ2MHJ9C6w2cqLkbPJl_IAC68|q9agg&)uZcKb6>Bh^2k^0^5sQp!dTA#P>xXgolYA
zggyd~5|{curo_jhk1O%X=nQy*Jg1@@vz0T6Ux9Ky7vgKtrm0v5dv||x0CXckEQi&fzs{q@?(vGFDoVe6|
zg%XS1egJ&Ei2kdJm1GTy-wVlFG*OD^!>Za)O!_*su2Qr&S|1vc{w>-Rnh|H)Rpro{
zIQyt-t0cdm9hKx)v=eNN&(rp*KG2sq?W*br+YzU4syL@q9YFjIbQl~=oc2}WH&ysh
zjy6@%-$Kl`s$Nl|x6t{3eR5@}l!2msP};wQ?bV7?MzgLe@iuOgN
zoi~yHcXTY=N+0cq;`c%#=~I;=`n&ozB`5a09quFl;i$BM{!actKURu{pr62J+cm|+bs&&ZVkHK5CjA^=s&9`dG&A?V|eGuhZMaP)f
z)C%KT$1>~Iy0Xw6W6F3IXc^;*fvH_5sC|s7V~UbGMaReykPMTHXH1@$JzEYKCsm4a
z@#z0+>U7Y6;r;q`>(;hyK3}U=7!KNH=Yc!zxWj;c{rmObe!FeA>DISf-#%OS?$xtL
z_pRD?ZQHfWmYq9w?65`qb`|+H`8KV~%SuaHwQSL>X_Lma8rEvqpnko&#dT^IB|glB
zIX-oWn|HG1^~-Jj^7=xX#yxuVXxyfkYh2!|&DOoj8}~?Kz1qv?UOgnCv6DP$EGMar
z>+^*Ze%-KFFk@paJ7D8nJ7Der{yK(NRr_LR{aqj5RmapfI
z9x!0zh+37un@yP8j+SsVIsYX6k{t#_b1Mf-BRYcrH_RO{4H-S4a@vLgqg~|)PAR4*
z=YYMV`FLAB(|JILIyN+Rh~cfAHVlsCTy((Dh-cl}VRjEL8fuX(SHFJ!xTtxj9=3;V
zzK-X0r#AS{?c;r`+x>eU*-tdTX8(WPevNH-8|!~)Q>_{sC)MV7-M;&qjW^4)
z&c3gC{;jWS_7$%+qy9slyR-K4F4+9Pw?%w0cTP>7asSj;8@?5P&Dxvvvv;XIHy-k@
zbbM-@egC&z#HZ{tzwPoDpQ@=RZ#jMXpX*R-K~{&NPPX+ww`H?Do&Ohkw)~HIX4dSh
z%Rl9jcI66nHtp3nYueX9+Ls+(0Ie*FeTMXfqnm;RM$(~!4h
z*&azGzdOg-8;*D*zH!5X
zKc?Dg8>R=tH;#zrUu_5e@k~(b=&SfMv~j*|vXKi|@h$b6`87WAd-1>fMB(q`U;QyV
zF7f66Y|H=tcW%_i2LJL|)84NLYNb7_zRN+qX*1tV^A>z}v%KoVl6SLXVPk{MeOPk4
z{^^$0m)+*ROy13WInB%Osv4jEd*01_+O!j&wmG)G6UI#chX3mrVY{38cDmi&)9tE{
z|9!ig`8eCf$7Q?09W~pn@pZPlnXk)sB^UKopO@{{_&he-?C*tNVZUU1w&uF0wkaQw
zuZ2Hy)x(g)eD@B@5v>OJ5w)i{=B|$
zO_e)|YpS|j!E+QCZ>(i1EgUt~tBV^C!sG@@LC@?25VPCe?EcW=_9
zaU+iH#`!?BO+|TkuF2cAYg67!V{OW31@&%w=ZJx)?6AvqhaIzU#>U(2@W&<$xo*V9
zTkPO-?;A62$lWg`!dEosHLRo+n;s?*6wtjnp>Tc8Ye~w07f*
z93yOJMMcpaE2=OXn@xRkUI9bxYH-ylZ#QwS2=~^~(Rv{J2=_
z#*OX2cfz(D11E0Y4I368a=E?j-?}0^csJYSZ@m$9x@Pd7du-MvHGPu$6!y;gDErK@
zdj6)h(Xr7AQ*O2y(8mOhUnJ3JEaIZ@V#8ZFM*csny$75XRnj-yr@L?8nYoj5U~(d-
zVHiP$D-tEBL`efPqC`o81PLPuA|P1>R0K?@h^T|E3EW-N`sfDps=KSMfkj|l-E|*f
zZqNI#I^BKyh8cOk?|q)%uIrh)r|ZHnE;l$m6$8(Qo
zEIVAc{^M1vKVDb=__B=SxsL|YpBLj!pAq9;Sp3P}>o*VDVE%mKxcT#KgEwDy_h)d4
zFrIi(>`tYlGR8kL8GDx^gcc|8&J50j$LrC(4T_g;#;Zsc2vwVj<6&cO7)o7?jeWbe
z<)yfzXJw=%C13>|MaNk83W~C-d-Sa8o|S>aQG&Z;Yl{)$v0YmRlqDA=9Bge(`$nu-
z8+)g!UT02^TpRXexjBmV0K5#(2F}f1Rd4D&+xRGz5@*OhiC3zFyJJLSTb9FynE@N7ROzH#;~$d%
zYegwfS5dFrselS!ht
zt-ySxrD^5qsgj%Y%n>%#@yIy}>Buo;c|3Pe;{Hrq;f4FnZ%F6REXGtb4
zdP^MFJI4^`J_aW&@$7t@ty<@7d2|)8uQlL|>8iHMIZ?bEX4J_d>e&+A_0)`XRWB{U
zLaGW>oT=369x5%t@Rs&b)!&^E9Ugz&`~YX`u{?(lKU!aRn9r8!`Q~}^(bK2RLoY1;
zbPqA0@SZp!jN1lpx_C<;Y3na1F+k#w^^7vbGAf0V
zmXhGpRruB7JUBqb(4iKl<;6t`)NFCFy3Xt(zWPK|KlHHq8#7LLMY8aj=gfflt>@jR
z&1b&-hxt_FAyF-oM1ttX--gUKrs3PE5TO&Ls4g&Y+(EO2o)$!Op#`8fM28sfhXI_I
zlL37Qjabp$>xgBGvbH$3&?X`5x3K=Z*@XqL3w4&gHuh5Jx|SeitY;XXjnRCbcPg(&
z=z69x>q-f;T`Y=(!QBq~fU|1DG(_CH8_i8_80gB)QDD-7Fp!e}5U@ZAPjhf^9CBxe
z@_xi`)4>bVu^+%84_o6~@+G!Ot}*r*IgSCZW_sXRx5wd;Wt^2tIS!xBPU8x|3APc2
z;CDWyi@zg@YHTcHhv}P{UjPG|-$RiMxhL-RqJK|IYjJRZpQ%vd5V02W
zGf5np@y;Fj)!aI$(X7?6zY+#G>=lS^*f_A?gnx_J!rjs555k}xF$#=1I5+I&L#i}1vJHtlWS591D-)mal@wu?=X^M&#h{9p1shtP&$
z$S24&0A9-@=?9qlAPsySl)wEFoqQcH0H#xmFyteXd(t@?46WToCq4%b4Oq`hADQC}
z#J#1tpFRY7J`KK#JZ1aPqX{s$yTRcj)cHw1)e|oaL@}l(idb*#s5n8^HoO+lFUbii
zl>8Jj)!??s^LNE>)OG(CKN;^Y$<9uP%*xKo&dbe7&q_yN0eGab6yg8!6?X8{l%^EJ
zI6xplgG{0Sc+=8~Q_|8(Q$)X-*VIKlAMCm4q0K@u|F}(@3w&6!cC&eN&Gt8sANRcb
z(o2@SIj*bX8#6aBP5j%8XW6zMdza_PHVw#Y>U6$i9H{mg=4?}s(dutbZd3e2@{T;A6OEpi`wzYvn9!yTDQcCSPC+3-a6KWThnW6}D8A#<(wl
z-F3b*gj`{9GRXwjGTt!XKXgd6J+Tj0zr0sFzqfDghpSe9a{G$oH>l<+^^$$&ne-RL
zxbNtSaPz0zw;sC9{P~sR=D7_sURnF_1J9e^-%0#QIRA$4dv_4ce3v6C`J6nH=5y@3
zkoVmyj9rLO$SJ2O2w
z5m(+WqDx%JiZjFRyma+-UEm{DzxMM{_Pw`t8jKq;+P?bEib4#F7E4*bFfDLrmGLd-
zyhMT7C2kGnF$ae1BRS7@5G>p_S#G8guSgiSgHN)&U>%7YAkQZcz>2a-2~V@
z&*AqnQ5NFxh~)PgQbeU1+&22l=BLs4{pG*MnJd&w@65}X^?Y=O|N8ZZ18Jh)}r
z63X*;|02(KRqpqvx_Q163`et2{qU*Izh45~lU@BVk%UKY)pPe&lakKR*of3+G3|
zL_0sw)1E%iB|fFnUxxF9hwb(0DmHEGaKoc&+BhxTwyH|O%@O~B&6m=6TuVHDyy*we
zyMYzzt>-$c4+mfr5q~~xPN8`r8xDX!jkvu&p~S$FIw~NL@pt|%%Lw{A?#klo(6b!{
zW@n2qB&Xy&+hG-l%PIKCUARD}s(Q5hhNlnbdB}OTDfh^E-1jxJf5<$$#L}OKNImoO
z5Y{s)+mP>ro@A@T0}P+8=81z>$LCR|gUG7zsng+qGsX@l6P;64U{X_TA!t)4z@`=%
zrA%S`cePD%FAQtX`}0BX<4c!Xl-CmV-MgC=^ACHe`SW
z;SjWAv>|~Bk`aDhD{UF`6%XFxh3^71MLx+u{eI?+M)MT}2`t{g_y8TQ5W*Kg4S-Kj
zI{DixFt>z9osPfgQ@n<%>qShmVvctuC<%P~NQv%;w{G2i{N_*7&;R&RU=}c*qwZ>odH9}t
z9#+=`Rz3LHTuD25Ug28=O>su*v2)|Fo=W9FYj+l7H{V$fw037P_VXR#Kx=mv?`IOG
zwL6RV-xf?OcNXvG7R*6xik)lVx{uJhyeBP~7Vazzb1~-{91sDXN9G#PJWl)-^IXii
z1~!b0yAq5Qccr_J=Lf#~9Ef)J`9=C#GBmg+i21tc&9UTJjGw$K;%YA=<+Oi*bl#z83~pn~Z4>Q%h4-5;BBIb>f`vF+jelRQv5PM&c>lT88YhM`l
z1%{213xauF`@^sa3>zgUgkdQsg!jn>8<`u@h&Z{UTNH}B!yhf>hggWSpeZ^2Oy~jf
zu-o1NP0>;`#BK$Sp;!pN!@16$ccqTecH=h{%4>l2Dz8D4;9NDo1;_m))(04ee%fX}
z8?FzU{{Wae_AN=6@5~n^j4LNC6EOG6`3HiczJ6!3r5?;?J=pvobGmvl%R-*RINO{S
zp__cbU?J;D3G-d^;z%8b@8q~(PAP4|VVd6s%;+FY+>Yis5is8arl*&DYBoNJ5illT
z+Ol0PVa_zSkAOigwOZ}^)~@C);V?>uIZPb0*RB|G_dJI!z@FTN^={`oFwc*E>c6~}
zol(|;=!+_XI2fnRXCw|mo!2P?nT+&nJ=&pE*7`QSY0;~o^^H|=$)|$Kr49O82CQ{yC
z0B<&52#-T=s1>X?UW=wTerYf1H}3ua<-LWBgHKND03Pv?uZv7bbqXvvnVQyOq=)^W
zFdpHKnj~V)|Ehm5|5wBu($^e1bbb{AJ>Wf_m*w3lU*9
zg&)on!9d=P50Q7uBy&39Z~fg1dG~6>Igz-K&yT-@m{Uc>c?*X*rM&Y?Fo-$jw*W)(
zPJZib^NAKMPej;B$XUJVqSTg0DP9!l>UcOmts)I|~M9*RgZLY)T`;f}z#h_DwiUz6J9G
z!$kPXNxpjKvm8354q%w5b|+v+4%sjfa>&|?Af2P~4DV^Hyw`jQVE)E(=#(Re(xFN%
zokB58-h(>EH8Q5byvuqBRB`s7{PL1VB4h#N(6r{$+FvkliqgT~R)IeiIYJHa56h&6
zI!bC_*KlG&9K1^kicKh~!BbLEje3*x;0;JEe(iscZ@zKi=HvfOKl$NRS8dq3b%Wma
z{Kp@{l}NbYJNgOYX%PqP$xrZ61TPZIuYrpN36Eh4fk7
zU%dQt;9+&*7p8_lx~ZCHuikZq_#$v~(=+PZe>bz_ni}CZ#ka7gBn(jgzypjE-6JY_
ztr*8zcZ!^dwT8hMr^N3fVc_p@<|kW3yd7~iDbK+LgFJ_iLt#J@@VSAg2r97<1Bu0D
z9J!Jza@karTmeB2G8PTzyLmCkR}z)PlvKKx)2&>_->CEk^eIhI;ELa`6lpz
zdimF8?4Lc|_Xn1!w+A{06!pOXn2`1bz5(PIV=wh8q78foWW%#>z%$Wv8W|_ppOoZ;
z*cc7@bDRhZR|=vr(w9ORDQ!}89};1)KUJb<&n$0+;kCCYRUI_`&$U-{pVrfytj_<&
zR9?V6yHs6MpLW^iqO{&--=;sPhyP@jTe#4EFwf9FF%EheaPugI{x%rl2&6g{oNsZ5
zNAVyF*ZO#IM&of6IiBY}v}28BytfotL|#{-&SlOeWD6|ne$tARp5$XmYlEE9`%0M5d>FIV(`0D$ThO9XeM4VQ
zG>O};qxw?rg|QyLDcPnKiF37{?l*#A;E#*~V@{;?
zxnLfOpfApfx4#WTel(AaV}oD#w+78`6qq6djpPXO-q?bOH>?ehG6*O^AenECucohR@8m_L|+lg)E&M`CFWC
z_x#_9>sx@Ky3MH;&uqK@225%lsJ
zi(A;#$5-4+yaby8FB2BqAsc{P6!C;HSgkJa#x?$q3qE^YZMJ92qvZ9nRGb
zSHWi*M3y|Ja6iAt?&mHxmz{9X^#!qfz{PDLY*62O*@{+(;Umn+EtELpFwV!Jvdw#Ta
z`i}Y4O-J#~xtBaQ=eh^F(#uy4zY{NC#QfeeuQ@QN(^=gDKaxC`^PI72CEf8|vJpnn
zT~5LvkM0pUkLw;lKKM*NTbM_wl;1+^e1J}QqhUE{l2)*t0Bf&>n@Zz612h>px76ZkDA4E<;l2XW?eJ2f`(@SkzF(p|jIa$etDTN5BCy68@BrYg3
zODlu9x||QMB_j|1@L?nzpAhYlv-{zRbsw!>{n5JhAFp2hv6BP;g8Ar~)8?bkAH7X@
zkG~?kx5;$)a~ps=jLDtTw!HaW&x44rFbrhz
zWy)`YYc~$!yjCes4Lw?P)iuxNXCnF{hUNL=GKDJgQM{}U8_$387b*z9Y2ysa;i{v9
zGHG}WQk|4KE7c-z=+SlvZkI|hzW7U6WPF5!BAkb06+_c!G902iRyIS(jN`}qbIQwv
z(y2pPRe4n#RF0>oCVLfFZ0?|TO}Fk<{Qs0L%@#cJzB4(EI{KD8F4`Rj`dCa&zu~v9
zNPXL+&av$h?$bI-uER3=b;5FuJ33E!7
zHgxD-g}^}0&TL^GI%jg8GorEu7zZDmp-sO8WBggPl`yWe7`86xswnuNb{c2M;jemC
z`VN-UINNu?Q>pavIN5g=zV2F~urEkUDZ^QV?Y?ug~oq2q<
zE$x--TQ5h0!TrnGE4Bxut_KYrUvSu7#NDNOBz?l_PaX_@3-#Ueqtnm@)A0geI<*Lc
z+*L_KU*~8rv@ZK>L1*x}an={TzB{;1@{~|LOAC9<^GcZ3&o#reOfR-6xJR5RoJ)h~27OZBeA<`|T~~>Je*-JFb4aoG!5A7~4wGL@
zTqd9)0TU%I2$)7Wk3e(_FfxZUY##6T5(ZIyf{79bM7%r3f|ni58SWYQiH4CB!^{-zF*9u&hvb>39(=OQ6SaFEpO4
zh^tmcEwIOO+?O?o0sz{}z#bUeCP=*KKw8;jRd*&lEF
zWcl(>w!J8>{OcFu$``EsZG;oHn1QE`n1QXpk$e_MfMdLp!5%_fQE*qG>}^ijMynRv
zYeIE{0E@3EHrR7w7k8Exr=S9gs%lY7E6gKb+;GF^N6ZuAqNnb<>lyPc&%3KXT(|Dy
zhQQbAZ=PCr>yy$h)sOJGT|%^jZ|u$pnaX(a-J4D;hqfd!lnM+1^KC(uEocq
z&5SP&B&G^UnLz`8C$FZ6S5r(L!Zx`%S!hp_+`icGk`u~WjW^3%X<$VH{s&$W{!mjX
zmv!~%DeGs9pNplvIlFBctxa5XX}4*-z#Ct^Y{%I9+XTMWjz8FL;pMkJ8Mr%sFWJ=a
z`FdUlebd1M^|G$37>5Y;rg)Ok1N1xs3}NBNPFXla^86(hjHjVRpz;0`yd#yDm4Ug_
zi&HAP#~@wWU3H6PAS)?YZ(CYS1zr>E4LZ*CUDhsfZI``KRNH0Fpv{6V5r&b9$i>UuDmoxF>a
zP!C_B^hZ~e6|~8aQU(1PLThbVs(7h(xe$`6L
zEP=hQ)p=3PrAfnHt%Dgqm7Sq=u=eYymb!w(CN`Hq!HX>cj`wF27oneHWl@*nF8E?0
z@{y}zv8y75J@GXexK_iPQ;I@%TO4%H%&Mw}V6{slnF^j+Lq;VOB$o}?vMavVKkk0P
z{OZ7d(dNZH|EP&K{Rdv3Soiv#4R6g`h_m_g+8*6h^{&`kSBSfA-tfAZ{P(|zaj&jh
z^!2&7?h^?QKOo}o-);VM_*wI(ZQupagU;ni@N=?~BfZVIBhfjd-cGK{eE9h+Pj60E
zS_&PsZds_9CU?Aj(%{mglNKgBZo5N__{@AOC6y$|(Yx<{nk2}QKd#&LS+~GF>g6JE
zT>WnM+I9PFzC6itq`{ISyCUTXe6_$AvK}z(ciQ0awClI14f`tcx3pH*{HK);5q2HZ
zgYpt+e&~T_<=YnKA^TavY!}h+oEXHDY&G!|`;^?aDEX03@*RTKQYm5R4xx}=lI=R_
zBeo46I=kp8;H;lvUk;rYTB*_8k9o6^YgdHRI>bLPOQ?oknGaTnN8Y0i%gxEw^ed8t
zGDIUM5e}__v^fL413dWqc>Kd7reMQ8;>wf~gs;$p2_9=8VmQ^~6y
zmR4HnElsP0h{Z53gVo>vKK<9}d$vs9@Xndj~+iRDpzms(|yA&CKSQS
zKd#*WLHErsn9rNPI(AI-ZwkDko*B|`^}zM8)_`+1cZxdlO(=_+k;4W#
z*j1r^3CQ2~=-_5jUwER&a4QH=NwI^UJmR3klDz
z+kUieUC%o&*?#Mo)j#BZ{o?2ws{1Xya`1%SnN?NCcddGP#*Akc+_2>KnIooDC$&qy
zX!6z>%U@l{XP?)?=lTvyZtUSR>-Nos!60t(k4PBQe1ax8*O&)(^j;xlOZabhM*VG?
z`?rW8NqbiQmShFvhBF=oH^>3!yDTgAh5Day?&UnEZQOj7VpeeNG5hWLeMQO-a$UCF
zO24J~+4iD!k(|N&{2jt2dMt{+$M!M(CY)c8Aq-Z5Eu({PR)M8$ez9*8%rBigwQrl7
zjs6+QL4F|y>7&-v^g(lj3fQ|;`A_UZa>biSy$mQujbJXAjaS}wb?t%cuGxL9_qPe=m$?gHT|Q&$eZdWM>+Ky^Lg_o2SSEQoQstq(B`fC(t2&)qfj;J)6ORGgO8g4Xx^jkE_cwqA<
zD^~w)>zOlKA9`r(uf%w>{xiMbZ{OK(7OT^K)zI+T{D<#YbIZ;h;t%hie~fX1Eylfa
z(2~SofG`kD6DqZ|ElK=O*rR4+5?1aI`A_0rxEwi
zZ@|mgM;fR3`K(|ZJg`&wXT&+EhmLjq7QDR7^;_a)`P=Q{{wTk-`riRYjw53XH(AxCH2bj$PST7Od;BK|nu
zp8y&mwH@-dgo+f|>SofCx$PG{wDBPD8*ACBe&pN-WKe_Y-^BM{!}q-u8;8Y2Y6tr@
z4wH!~w0DZQl8Dd!Fg?@()Y>rdrW&9f>+lFqMm$-Mx<}9)cgt@6&MDwT3X3>dW^{e4
z^T3v_BRLG6C-dBKmn3h!AS05B+n}lG^uVsv#&6kWb|AhOGRjw!RA!F3b%o~xFi3|U
zom6#iEYL#DL~|GYO>~D&Mis<#e{4!pJPNrG&XvS0#EMYpESr|VAf`nTN?=89YmnzJ
zpf#^+5r8i=X{lr^a&kf{wKd5Sgeey-7i-{MP1KbBQA{^GXc?n6k9y7g?_0+|6?^*h
z`IEL*U3%zGe>!A(n*Jn41wQjkx!2)ezPk`#<19Cy^!P!y42q#z=#kQ*L>LxADarA1
zhDXT|88Q*3jKimvn4w5&IwZ~cqvp@sj!!wdeEE~L$G4g1vp0UfNBgMhqS=p3o%+aZ
z?aiikd%oZ3eD5d374nsy{;D+6<76#+&=O^)>+5vw;jg1T4=z27LB7bhRAv|kmSev(
z0+V0CeIFkM8*=o1kw-byrb!JLAPA5p6l3IiYG=oVne&%)nA
z%x0g2ap@X~5BaDiK4+DmT9}7?{1WDjh^#>jTNix%H?=Si`lr%7ka0hYw^&Yv{T9Ce
zv`9SRzX#vgzx7?KLIyY55Pz3&;&9U`zvLx8uXu|CxsgZy&ukp%4MP<`mwm@*&!xI
z!Vo?#`us~dAGt>iL$L#i^Y`{Xy6GH-^LEu6{aZo$pr^(6k!#1deqxwM`5d)$M%*wh
zoi!IsE8n6sBEJ>&j9^`mGd2ujG-2yf)aG-*`QMOzbRR;cS)A9Sp?jdJ#A*tBQ0$?I
zjb(4lRLW>7Xp@}<*i1?kOE2S|fv9$a;f0n=kc!lI8sd;j3{miA3rNqu8zF&?l6-vO
zh2vT>O1!?8a#EYR^bBMWB;}c<>cYSl`p?q8N{NZ%2-(~Nb{6%G=KhKBk~*0}!OSiA
zAG0}gs~GueR+dneP_GGm_NKTvXHb_egR(`{TY)bT*WWzgZ~Gtobbz+#TxakY&&@H1
zH+jG8-p_8EzXxc@eH}?tv%M?({W`S)5=SrjOYP~l`plWHPt|rb&3*Cr(|IX15$2Q{*^@`kLvmeyYn$cIKz^>%lTE^q?I&TjN1Ml-NVIgp1VYau
z=-mrEaS7<1LpD=J67KLpws2)VBtY*1H4B#p(Agoh=%r?wzRy1D*t<(!
zZr6)De)^H=Q&T=Q&tKHPylh}E^Jn$E`jPpnf5NC9Jw{FNi%SAsn@q8Mw_7rqyvLWlX|w-NM{Fhm;(vrVz`nOwB#h1|*8uqKA#+{rO=esi>EuWNj;c0Bl9
zwqY=*bxwK%E>S7<7bSR+)EnibAjdO1m2w>6%yu1>6gn%PiOPyT1qFS|M8&6p&(wNt
zzFF93-oU~0FBV@kZ42zfH;_T%*^F0Bl9YUZZc=;<0u?Y%5xtVxlqiU8ldNKP&^VaA
z3H$7#kHx*oqTi4Y=XLK{TWKzWb0#oM&0pTXYEM?KKd>xtpL(suub$p4KR+f~A_nkE
z1TE>zx?o^q$k=qqnPnKFH_rndG(pbeqPLuf><9U+Gm7jt9yt&3tDI;1FXIE-NybP+
z=79{s-ni!ZZ75G$vdF!zyTsuZ)=n~C;(S(lIur)>xPi5g1Rtcpa|i1(3&M!@DAtzi
zLQ`f~a@}&q!n#gTNS?#1l@f<0up$Fb44~4k7|#S$r4^@DX`bRNDXlys&A*vnA2+`?
ze|P4@XD^A5U;Olh`MLQ*iDVsp|5CGeV4qqqUSBG@o4=?3EUl4TWUrBV7Wm`eagQ|K
z1Fn_gW3dN2ZpT|eJ15>M_o?;0#5{OPf?^LJi!XVes6BgP=1UlQ2N~ar+K(q@ngxR^
zFkqtA4|t!W+5qvfx{_fcYXic0beHHJx=%JO*l*h+|C!%lFaJoj0oZTU)p1l7r~krf
z2m93FJN)_{dYu~Q7%@nF3+SGqpzmBf*aN;7*VIEBn!5G8
zp&I`K>*jCs8UG%iqceU-1Z^Y?ojZnsuc;*%%HNSNJC(n*Fi#L>M>H7twLlAJp6y~|
z#MxX9UxNzp)vdpF}H9Rn?}*5({ea1(JuPQI>+f
zVQG}b(MT>mz7z4{D3v0`UgCS&LyrvK*Lq;0a(|gG|mMfhMowZW(Z4vhr=+ez#=-HF*-njkn
z+<97I(vNQHrKeKwkL>`96T~IoLmCq8vO#2(0&i1>lfYBzG-Qg>jXDnn0M4JVC*!QY
zlWzonM#QbMLMRnID|;3dQgdIWT$IaOx8-<*-_deBR+8fp&O0=EC{0w6PS>-#I#H88
zNb%9EC$1m1dhdv7t1i81(XtJ>PjuLEbnHc2*NuBBK5g`R|Fn&ReOF8wpL^MY{%dou
z+Bb|vs!rhglF1mlj%KrBxWF-5vn98C4apUD4
z^7=Vv<2(lFEY6y2f_|$MqD~{2i1nQE_MZ3&g#nJx3Vs
zokGU@I=FXsk2^RT#_8X?_d~Vdjks-pa*zMV8K*~~oc%yl)Y=coIcxuDKlC|r+_e^W
z+)wVYJ*PMBjOgu}Gt-_E^_SbCjpI&P>KgB2!y{G+AC=83p7*SA!#Fd*!r2_Fm)f{N
zZcJ0lgYd?^&OArSFKf@^8oyIPw+DONyV-{KmDuAnzXM+x$MEj&1ilZ_20E>`h!4)^
zvFTwLXWIc^$cTN-Kj>xp(hwOz4mV3ixFo|A>Pg4~V=`r1w1H$8?~)85e{NAm2o6Ia
z;~ZfKSt?hPm8h1MbXMUe%&6+4W}#DYMn)e^1~1e{EE#d_YDY#qInI_5xAnbtlavwt
z=J#KtFSUh4d{S|H2#Hy}dtJAPg@pRW?zST#BvuTNLL#V8jbw)LBV=q3R?wOfbw8hQ*3gO)=|GU6Z8eaH#Y
z>d0uAG91rv7|45$m-1F9g9cpQug@hl)m=KZttdlF_^ga1lol#IM2`enSjYjLsw%fs
zWrLH2Yeq50b850E#i61yQB6b4$B@+RSzS}iF+xLpJnP8yV>cff`R_%GmfkLfZLiyI
zZhOgdE5;w*JgVsAVmaWRhoPqdgk7rYyURGR=^=
zmnkx3GeB-4E(5Z7P0Z1Ca<^zBGFAV^50)+eXw#gA{;5gNJe{06XwC552k*aU)|Nqo
zw`6Y-`qNJdZS$Z>1<#y6_(Wmt-~*>#eEzE&UR=ER!~*anubpWGnZ$Z*KGP^{9K8Bv
zaGZ33%J9%*-^y{=a)X_DNRI)Y<>$f(J;rmp=4fZnLHpnuA009dJ!XBgy?fmGCh4(P
zf;hUyceuu{aLwO=GEIpFVYoB=1>)Y(o+AwRP9fvaV;1h6-Qy09&|_YZ-`Nky0)~ey
zh;xra##-ZN$qsYip~vL>&|}vA(V9qC(709Wq;{D(}q?fGmI|Xi$
z_IF8-$#GwaJs$WzxOUc4P@pzE=_!VXo~j2vtfx-ut6*o-`CoAiVwGR#pyOYTizrE0
zt3n%TC{!Fa#VRgQy_P8Ow`&0ohku-_5``_?96Ibp1$5Za1ycqBaolCu2)vM`=wJp%
zE{D~rQp8}>mflDyq4TajG;7VkwDcp5iD~^CZrJ?6vZWt9GV_i>1Mir%cmKicA(IQA
zfQRSL1rrBvMpEh1X494f3r;Lv^y1vFo`3NaXb=ZJ#acmwe#95P+0CoeF0v*^?PB#}
z+b3a=`%0_K
zEfeJ4*~Bgn?%mSAEU7U5piW$K$A`<7et2;9wjqPJ-f-Uo4}oh64(ngo9a8@%f8yXf
z%)rs7%=26J-uRm(HyxjU@}=WvK^pK6^M>)V%|DPgk18*qXMie>a$7Klf$VYd7+~&a
z9pIKd`*5#!!Qfv1cqj~=OXNV<=Mpzyv;!o?41#e8TO&%^TA2g3WJFFBBf`&@OPS(y
z!Mpa0rz^3s#nTmd%DbqgBu7>oH>re`rj4Y&d%gUxCkv<
zT^{Jk5YqOjzNhof3ZAa&u)oync7NT_@b&J4|LOch&i1+Uw+$S)ZT^CtIfpy_Z}v8!
zAAVAJwmq<;+t_)FkI$d`@}e6jcDobiCCRrLpkrsqw<4vzGRWT_@11y*5_Ba`OGzd-
zG8|Ay&9)K)kfER`K$8qg(03|Z;S;G#L3t@AC@OE4TIr<}3;DbWaLs~~5(cB!dfh~t
zij`U#KW;(`IQGQib-??I%kS8DfB$Q9p2+NNepA^&V*cQe%lA$dxotaQIepDq@q6So
zJxMD`6-&)=l{a72ZFp56A&wW5YEDxZs{e|^`er<5(|W0=Na;u!%_&$1dbo@B?ecOd
zpP5=Nz*xrnJ61+B7DW|-%qf(GQCVJ?pOKnKuLt4n9m~mJ(Lbw#H-X>YF|x8++m0J1
zC9Yag(j_6IQ%ZG3_N9iW#my;SFvFwm(zJg4pG-=M*eB9Yv`?%TufQfy&d)?gZ*^u{
zfyd!%m>X>zIW{srL3*?^-ECDl&-NDJciQmuU1xrJg8<)UThR3#wwE!VrAG-*gXg#G
zXGeb`3B{#9@uZcZdm7}SwMr>%{i(V1XLLBWmVW%*98G?y*?X&)e@VdI?J
zt|7O%eA%WCmoEEo(+v#+QWG1Gq^AzL-)sJl15rZYp
zb}u?+@aXu$q_V&)XWf)uB(A|??dyWI{>NoDy4r*843Y*UPY|~*v-D$wKPe3Y1iDdc
z6OVP;3bF)CIsoYv
zp(n~CPa4ixWrd-?xny(U_W9o$;YV!HfE5oM4EUS_rVbm~v#M{`E**!PwbO@gTXORE
z5r=JE&W@!w?NB=ekJ^`ogNORNweRco=Gt=CbE^SvQz3hBZ|b7Q+2go-lTF4l3HH{N
z5jGj$af5OTFjGS9E!IDqh@b6wV3W;`0+SYmfsXixedj`78`p24i=K)6ZS%<(iaA>N
zYo?AUn>i2WQMq_!DTrhx*vtenJ2Mp`S
z7#KeuJVukY!?(P6)2#u9z}Vr`VO@tgK=(Nmsd>F(iZ2EcTYhd)GG%p(KLATQ;KSnu
z(sV+8OvvJ)nHIig{TN=Qar|3pX=x*{N?H$|k8u4^iHozb1hJg|DZd{%g}+ziuOUNV
z{NM&Y1)>*;4A2WsF*EGB901TN*Snt
z4AnjFsLKO_1xtwf(LN3AR#%)WcMiuIt!sQ!Khm`!>c`x?vFYRZ{kI1XgtR?k-a}kg
z@(|wjVjhC;$1_Xvk~zY=5xi8R3=lhp9&H19#y+5o
zx>(vz`hNe&dC>4G+B}ef<9IT@NOIAdYc9fjM5}Wt@$oiFP(056Y-LtI4FB*&x{%`u
z!8Cj!h?%0q#DINTo{ku(O|Od-rSGK|4d^uhn^4oEyTw7}B}w$;k$m5Uycvi^w#rUc
zUOv4x7{*uEem-sxqJ3Mf}AJithqvWY(MmjVB3#;9Jm#i$a*jK
zq0r;6%_Zbak+$(b+y}FjBC1x%%uxA0hyp#lwlh7%FYLOT6rdb7ErmV7Q0t+06VS!w
zl^4bGqNpx9_}%Iif8Ra+i91F&ee8Yl&TF?89;rMNXjbPZ?AbVV(^a@5s_6J)>~dV{
zU02_y9^CrOgdMg`KagS&>h&Zk(6<@?KxQ=ENe1FP6`~TYBdU#R{S$G2G?ZejJ1090
zMqU9J2oGk)`c!WW?pt1UhP+Es7y?s(bu^9LW>X<@J!nQZqNqqwx^!;euBfu8vaAGC
z7nYY~q*lhGR0`Kh#3SemEU%SVs+MV8fYCZd!_(k&EygohrJ0$+TUD|Cm063h>>-!m
zz45X7HD>SId-sGh^;YdQb7YY40ZCj`{hJ^A8ds=!!Y^lC!@a
zYCe`eZVafAZ;sbHie9l3c8oR0iIfSqt4jlvH$nHQfqXwA+R$5mFFShIJ$_0|i#85j
z*ccpVe<;(~ID_)1AnnQD%Jai_jv%G;UAo7~@$L5fcwY}ZWsm!=Q#-rhgY)ckf7ihu
zzGj4@g7cH#R?gq7;9AXg0QNjMKkkF0g7EA=XZ(G&a2GrC1jplEw#Q);IP<^2=A=bi#PA0k9P_gC;vaw*w@(}C!AetVjf{Xy~N!e_h!;$ckZjepW5TEU*-{g`_Xf;576TA(mR`LivY#*W2t4Aio4
z63R&!iNA;4n+!is92I-VqDY7ezR?v5MfVsWK~sxoF1XMu&rh)tvK>*4Kr8^Cx(Obuh4f!kjmAxLh$D1VjYqzw7AQXnad
z{2Wq(l29E`D4_7!Pd)*XJ=*7`BF%e=SV
zV~{5s@EO#lD_4rAt-fXjS2A>JW1Gz&Cb07kbbo6S-XFLEyc);USqZVc+
zYZsyP?9t`ot{1nfEG?$^5!TyTbhf=6Ev}d9VV(Ep|1VY^Z~M`HnE*j|ni@_B(o^O@
zPYw8@Lft_;g?`kxHxHGu$>yOJZ!_<(-UJM6fE~)SQYR8mxyE-XGS6AUlMN-|w+l;$
zGCcBJ<+$%AF(c|cJEPAJAG~WGw&`eo#JCsHo(g4T^S|_R{RX9|wf7*QSt&V^Sel!D
zw?PCnE5mvZvPcv~q=1yNv2Xc$DU*|`@~__gZNu8Hwl9C}megP6Z@FR9OE2zNyH7KJ
z6fZ4%FKLrdk3J*RjkS;5FlGMxZ@m7&o?CX@vgHp8Y#a~jUn-rHG5(RsNWey|-u5D#SSqa@)s3#G)t&`|vMJjDqB+6`T-ZBtQX>A=>
z-O*dzFl#_razVnV9%acfEw8D4#n^eVcdF|2Egd?AE{=7eo{xJAvc>&n&=pkY$ssN@
zD3{K64OGVqxTSb(7wviT_=TIQ6mEWioYEAoZWkjP8*3h)``Dk3-L`ny0d-#h&)f#y
znm_g3_XEAv=Wf|?AM?xx+>!7t@Dd~}?oNBp;2+@USm;-0oMY`PIaYuidn?usQwF)q
zglAi2tbbHK6d@c?IP&NWKMZX=P+k-3fhlGfMHG@_XFw%gsPOa{2}AN>wUezKhI;_n!l5jpboagPgp(PBKaJ#PgJwvE+}HL6m%&;
z#}XjV79`}C)tq9=a5#qL29%YR!lHofLu7aOBqMS!Y(Ya<{jt&Bl3?WeMfc7CAJCTY
zX{(I#UzH81nuBbPLKlraZn;Vh6Y1(;Hx7~4(ltUUEvBX(LBi6(j7Hcf*oPA~WDSvo
zb)xXRmZq&tTB0rb+M)DJsZj1Z1{0Jst?9i^vX>AJhr8W!WrDs+9cQM{E1goG7DoV4
zUJ656vL_FzE|xo;jX}LmIt|0&;NZzXjt0nqqn^m`)>eoLOHQE%(P1$}-z?sem58Y+
z;J9S8u{k~K@U#s}S3KC*nAGY3Z-dQ<7UpI>U$4d49()XD7u#i7phYGfX`pW%@ySFMJGvYoH69d)!iMZekewYPdHy0`
z${Sz_sR)qq{zUu&E|awK>Woq^%Pk9S*^i^zAv;+$h=FP^UG&84N8g<~AUWTc*0pry
zkqwKN9yn&!s@LB*<=ywigJb*Z>fK)Lo;%IibhxofqA_eY5AM{+7}zEePfUf?tz$Xu
zgn}p^yzHrHMHC#c)<^k{(iO_ejSoFG0{WufI!(lC9uF32r5LmX$DCGKR$P>ymzkEB
z5MwCqMSFShP=Me1n(a0U`d_!~injV-Qw1@vB`W+^#y&4Zj^n)mcuw?us;%J{pJ10X
zN4(1!0kbm_M!A{iA-e?9lkLj@Ixm%rcT~R*BPh%@gT6v}V!lj2r0v%Cu!A1J?w0fOxC?h0
z=iS3^!oR)K`gRxK`fczVcqSI=qu{l}vJExB`g_iF@WF#XG69zqcyQ+EvUqkQ<81`JVLuni1_)#PH;Zjpl%h*QYUR#cD
zA&7@<47<^B%=y4JH&#p|JNE#RjdKs$$~_=lS@&Wb@*tXC<@E$%@QzS)y()7Ud98+heiO$A~GF~11?Z}~N@eo}%m32T%tmBW}dZWLd040rO|T6uN%9csqVeYc}r&4TPZuvSZTLr
zrTJB*4eXN3F+cT^#w%Zm!a(Q*H+G_>N1>!s97o4DiBBUKfNO4Qd2vc|43a6h_a@RP
zNP@vLLK3W8UY>wJm3H}u&(0ln`_=bv>QnXXhvuSQmv-uNd5>A@=)hrh^kqv&F1R;E
zED2Q9bs5yJQ~N$7b0MEJJhp@&M?Q55Pl-kH6?{3kW0OM%EQq7qASr-bM?KxW!Ei#!
zOplL?@hZt8nKF>AY$#v@q&WA;U{e4F#Qg{PPp$dyWIr>F
zBQHwi@%T#E2}^BwwiC3_vkLzCyE#!!pk4qxCweO?fWi0AG$)~^J!)@7{AQDy5S*7_
zBj$bC9IW;Y!oXG=7Yai>D&Mt5Pmf3*ttTFZdrBz$4j|rjAs(f70I@leN3)Puxo6eN
z#`25u3lhq*2gcmCO#kHksNsEeeK#84;*BuO-;3$~&jJ*v_-;%W^7IH0-TM1p`S)7-
z`|J&&Z;sOMC&<6|qQ5VG+x2^#0sLK~GnNkz%T<2Vxp+k;JS?b8jDdEDMRRElCxO%T
zk#bZA?!#Tr<1y;7cL?SbAjhkqZ2>!}(^8~zH*~zuneQW3EMi|!CLax$AkLa`XsT0e
zPXk89B6I)BSwlyqH9ql#O^UKrD|`3p+^2sJ^%{}7WYd7&;_E>64)Yz0Hn%MkQ+F)t
zGU)Oy9flMCV4adb|4Fm_HdNDt~S;)A*g@3VNrQ_%qkyPyAN$CyfXB6Zg0v
zJoD$}p*&*oXCCt>{g(I>Fs<{aQVRYY&b%jaJ=1(;#JmCU=jtE~`19^?7`}(lc{BKI
zux!_0cRTO+L3hCrnF`So{T;u3jcQxHwrTLgQui3s^o7WY9{X%8yqsJHOb)C}Sq6+K
z2N~%x)GLL0Hlg7o)+>vEMIC%7;2r0Wl9Dr#
zBYwgEvF}OI?ul#XZZBwiVp-k(DUBN@ELhXHVoLpPz4o02^H)xvwS4qrYXXVtnPoFJ
z&I(kh&65{ySRe3%DM+6%{+*TO{-iRznc#BN4n0Z>DI}*3a<058CD~cYU{JE`Cx^Dl
z^Ou4r$l_L2FA~SR-YHR_5U!$3=aQm)ICLGCqZ7V~^yNBh3}1e_;pu02be(lJh7us?
ze%u|pVmCdSu66q$r(yrCJw&rr#Ortu<<8h8+q{>MUD!kW57;i*>Ot-$5(%R1AuJj+
z;lcrz{~eTdj?jbSXIkhQDHe7K_{)x~8$}A-^fb-tiYTBxDleH^t>po;Y{VI192~+=Vr$Z`!g;aA3q*
znh2VPk8>o5q9XPxt+EMwJ^5Hq5pqWo)36@!7hFvAte+%2B@=|M37v=PpeE})I6!#V
zOy-h=EGY;v-XDvSwzTr@96yN&^te}obEWP`X%{!TPslogEo-z1R&d8z1|NK`!}I(R
zYoK`jdVfYbZtD4HA6-uQ{RWwshHfpUOhI317{Lt@i?SWSe9LL;fJIxK3zV<^?b{X=
zWM@*H1Jd>pxVL=3GHpL7<6UcwX1wYTN#iwi7o%IU#bG;YcH9?f*ox*iD82-`6eu_N
z6RGzuoEUKT<055|qYbuE#chK<8dTN-vdA|zEPmxbH~}#nv``S{*PN{MwB$q|xOUqrsyVAD&SAoV(`vPC4NkhYXYR!&7C?KQTBz$*@S@HgMhKhKcTz
zM(&}5C+69q$afYTAE8{c(Up8>j03%u`vPPTzNsUp&ytmkpewjHaSEKxh%{jXVU@u3
zVcR@t&=viCSL7|z`?&jl6kDoBmTCGkXU+N5qe?YhuMONXYS+-g+sCV`&mUB88FSa*
zK|99>R^yv8Uw9gLD(uZiG8O3NO7}Jt4Ml(R(WwawFB|7FQoDY1&o>PX-|ShiW8lCY
z3%H2XsUAh6&?gU@P1_DEdU@`=6N_aXDd3
zx5W>%br%AU1R)DlCG7=`5mtvr&PPm4MGqy#TyNdAq${e+GN>+ADGDtw!%L3z|FA6W
zha8cB99EqlB=hR8?}lZpj(Kow$)U&EkDVHo;QBU^Sn}cPdxxFAD}w4l`tsQ!`cmF8
ze2+HWUE}V0P_`F%pNQv=2jM9`AotHWg8)sGd5{Q
z4AT#?WNs9A>U*&uI8L!6YyN+^=ka#1$9-iX;|{#9)=ekZJTKehh}$`IV%fHU?X!?v
zXFh8rcO}kui3w3~qkQDoqK#I|IKKKeRXeKI4ikxZE|A(0}{~xW5Jlg*+Xid4lt<7_xw5B4d*5WXX=(YpTmvD>6!xw$#&D(%XvdW;@i+&S)MZIopNw5F8w-R;|T&!2Ep
z`wmyvE+n}NnE`zcSpqrWk`-=w#Wpx
zjkquB_*(0o6lt>r;cr#GjtaldmVYdFgYfHHfWIvSK8WWXVs~(UM;98tD?AW&maLO@
zo&A<4UY>IOxBo?5=Nqx%+WOmuM_PNe`=(CaS1Z1sym#WnJ`7Gn&wCGSRZEws
z*K3IJlMM?au>RXVUuKdzIAENdblc!7ZmYfX-0tMv=kDBZ{_Z~W
z+5P*qf;msmOrH7l9Jx>C*qAdx+_i7?2N`$nIfHTP1-0}TU3F~_Nw60b#inu1BX=b<
zk5-2}`c&@ehCV5Iu3k_C0dt2%h0IS6g~ja_9sR9dQ21gdmDm22h)+B~+rg$ms(GCztY!tHTl6LIs{uI|MsHg{(DK>7X?D
z(>k4stY0RV|C`x^Wf!d7
z1A3ukEJ%TM9}ZhGcXbUDL2J8Y!4az+Rvyw}h+lcnJ)gr4=}i4PWVBlq1qs~Gg6`~f
zfP>~}WiLyLscf1WRA8E-3bb038|lsnbv0>%k2A`a+TrlTR(E!--KgoU)^-@Ol-I?w
zcPQ{Dse6EVUyhSq%;T_&UE_T2X`JdbYMs7_3y#6B!wz36Q7==_fzr@C4NMZ5&B(6~
z)`41Fw`}=&Y%r@-!#WBPpl}9fXITvp(<;glAw@nmqTAt38LF#7n=$Bv8xuoYFQgwa
z)17)>dzu5To+{{gMoH;fla3V
z?3NwW!g98|sioDgW|~!AGkA;Ducmge-7tXKTb{ErHf6UO+2Im=%yBFA1AQ)H^;bOW
zlP>?EM@zuQsqhOZ?D40tj1_mHdl$Qxd8L%5&VLy1kH;TSyj9i%RQlL7+e5ft&=wBVY0RFHMB!RBOXK;^gqy
zwRbuaTE^2>)RBu`xmqZ>Xaf|FQUHpM8iqyH2n1w|ROT*(;1YCJdt!rFHSLipuF(0-
z&*CruH^_w1WXo~JVizleP4)#8O6TV9)$5?wEjl6s20tg$k!Zv;lhowcPo_0Uksr{R
zsmXDlP&y*!Q_TPx(*oy{>?ci>X2^!C?)mZ~PX
zTXL-ZImyo;B6({cX@vQs$pn@((KjiOPC#7Gg2EZrP5PsoQ7~P||pD
z{l(=~$-YawOn!2K`j2ZSmZ<8Zsy^_MZ8kseu;;eb(-gb>6_evliGDpoDqSUu8sO0d
zM<;dZ6PH|7-uv2%)zjM^8K`4swPfNo0hl5jA7dU(r}&t4STL?4YSDlVRkig1QpI?D
zmKGz_tMNTlh0>|O3wdn0gr17huyYYdsyWDnf&OMK=_=ws)>Ys?(p6UNI&waOdDvgm-
zgbEs}S_*s189`RWEGeBxQK%CZpp&{~7CUb_qml;?{AwlilrvQTAU<5WPrUq>j~3i0
zGX7x3$D2QyTUV~y)}+_Ie(!<@E6l#)0dun2`}#?fXN2K!k-r*Em0$ovAfS2%ji$i{
zBpaa>r_v+oh|?z{5}%x%Opi(@DTyiFP<6|)5zpOL;Vl||7_<~oQ;EsGXG9z@_=EY~
zjSD{di}_R2wpA;)B8hA=aOhj{;DUQ!SIf+0GbT?$FhD4Cnonx)L5EdRFU71(beTXJ
z6ZKHDkCA|7W_iSgl9!X7irmmjQEBy3tgNw`jZ{{ag;i6h<4s#e%}{d>e^u~!_PS|{
zH$C`*`Tm?2mppNz;(@H$bEd7EQ2*%e9rNDEzH58CNn@uCxUsV1pi@(Kj=KA%_LtO8
z8rWxOR@<_>Z>--#flJ^Dc|^EYkrk1h<$*g#>mYQzKEmZsZk#!k2USbPv=x{XN>{wK
z+6JAeaMMG2q#cz^xsWBGFGfbXm9(~H%dS=zv-X}@4<{5R^Lvuwh1S<~$sewxr^vB9
zUSBP*?_y=Be{hHP*;#lo9^9bCVh`dV9)cyRaLrL1o$s-nAVS{vi7Lb|wIFYk%f)FI
zi;HDflS;0>wfl=}6_u4$D9OE`jl2BfzE}0mZ+k^{zoG>fPg&>xHG1jd(%9qZfjdsN{K>$p;V%fN>9df%-OBVimw0%8Sd2J0a98-wBo(MUbo+QfhF~
zfRwHDY%u>X+{#lL=e^dj>WxLKXK#Oc&TF?m^w6SBd)MAFaq!Gu8*ZAvQy;bRSl#5q
zOIJKuo|yRB-kV;S^~lEA3wAstY^PEJoVWn?RvHwIQ{y8A3s(9NXG#sv#y!3bl{Ac13UKW&@gG%e7*eq
zr+WG4Uku&1a%laaejTev47q0cgu5T;T6AxEx6wm;j~zB@?ub4^`wZ_p>$(lhFDILS
zP!D)!Kw-4?mk0ZK(DIRmjJC)
z)H((7Q5Z@C)T>a@f8w2n%8oSjN{fd#4^?1acH
zl(3YXLgDVhap|e6?*K;MSQus&9r#s$b218d$4wd;cUOajn~mKp$(F3m4!gJ;mG;+6Z7E9lB)H6Nuk6c+~3QcuKxW$Fnd7nX67&fOr@aS~s%ATrN{
z@@!8U3h?i#TrT2=9_@;otFKe8>P-MMV+DE6zlnHt80s*|L(>g!q3i3ESkLAciA{rmB)FooGWk|kMDE>aZFY4B%U1fP$aiPrrk@WX^l%Aqzl8l(hY`n;G
z0Zqb}`}Ey1V)YT;o-4alj_Y{!s;_o0J#lm5v9z@d
zc0E%*?T;&N-#G6j^R25F*6ph9Gx5%=)a>3pI@R2>U%lJnvsu?%UQx8=*N+R&u50$s
z8Mfq$mzGYx_QtKV>+c@@)#VdLcI;9=Nn@GN&iqN;2wlmtiSC8yqCjOlkWIK*@tq2l
z!7eE!i)lh+6Lluxv`=cXM0xrw4@y%8)4}hNg+o@d{U8LvUiIM60bUP_#lTg2&7TDN
zh0S*gwD}AJNHf9ZEuw*Qp3ynNAH=jVaXO5GFrR3rb3q9~b2F0VHu5kG_6*
zh}ITJR%I^r7;uDDM!48yF{S=%ulYZY=oFXleNR&JJ=i{|t5)}UX22d<2At6gTX~4>
z6vBPCF%>ZfjKfdSMHDE5WmfxOt0n}$O(Xel{T6q|NtlA>q;-1}$gW?h7Bm5#gIR(pQV#oitTX0ZC}APn-W{~Qj}{0`$2@8Gk|d|u*%w=x+Y
z>=oWnQpe%!r%~OPYp+m!wGOLt^>1?RPnv>0dTCadrtpn{4!g*$!@@L}J{sza`~aiX
zJMi5C1-C%RUR2q8NO!^1NA?Y>b#upD8
zwWzTO7q_~?9>RC6HZWb0^U8d28}BXAX*u%+#+fga2);n>8qpH;rr&}u`rsd!^Jbzk
z^9BDF_jnP(7nG~-{FeDb_GxqHlMMo2EU@QizPK@b&y`~2-9He70bj_Tc&>SxpJaT9
zFC;!xyUlzW$`@7-gsYBGnyekAN+V5ax#G~qjT_74`LnE1PB0{&C%MF+*AR&jicE%u
zZxJCk@`|~OOepcXv>LABoJZenKh(xYLO2pD5{o#
z{KXM}N7V_L`ZA@AR?q3B*;yIsspz@GbvAh-&x*3TQqG->uTWAPEZP53lFw!FJSmR^Z6jEU%ZCi^dx
zx&dJBkAspx%`Ba|$quN$SVG}s>h%l+R`yUfu>*reCt+LKC~eA$sr?0Aani8M>^rs=
z4fC=DZ++3V0JWm~7A(B0vFj!IWnXm2ubbV;e}5P9-~}e!xA~5H)rXtzujz2lp}|)b
z4Xg{iK^so?$`5#VvlcdGp)vy>z(rDqJp2
z1T3JKn1Znv?7blN8YMRDVnghJ1yN&<*ki#GqiA9h6BAR6(G;H)qb5G-?Mb5C;rBhW
zdkY4i_xa=ZzJ9#Got@o1bLPyMGp8al2nCWou)&Ze1*GcXp*j4Cba~dgdf;3}-DJ`v
zh$BsqBt^l_KsHm_hA4%P7X{T}7b0q<6T-vg&}qvzNJlYW6Zc&`d$v*zK5@eKZz5@V
z+j84?n6azZ<}PRFc}w~%pGdyY#BT&mlbxSvmwfPa|J8@u9saSk+o2PHX5IcAp)1tZ
zfXEYNZx^}+@qGB;eeCQ{HIKCSdaP$DF70-B;F~uMV9|5nYnMy0>U#gp*bW@;b$PzS
zFIT`xK63&dj~@;CbEsYTu4(PxJ)#{m?0mjU|DW{swYBg7-#(j5f1!uf##DegpPx4NpB=cZ
z4O=nLJT$aU!p_1xU9B#ZkQ7Bd=7ZY7|N_@c25(5n~QsBvhuivX=
zp3L*ay};X7<+HYIeuqWK$8D{z-qy#6=Zi5y+hQN1-~;gJ1Rn?lhllh64|WAP0Ax35ld
zF640ptbKlK!RfNrYPUPrNbPpVT6WMF#tWR+h!&@H7-|vNY5m`
zM9(y7bgR=I?eQVf0Bv)pZM&|`U86;+{aNR>ZkJaOz!QLjxVJ{JAuL2wBa2WmjbfWL
zMW(4hE5u$W<%+GkHucg*Z99AQwpgRz>ud3KC;ir1TWA++>mPY`us_tk*SU^swY#ii
zw2O62?{Znkweae~*F`(otu(wk_$9TA{Y1>6mUmDeOO)VY+T1#MSZ604AqI7f9PF}o
zX`j)a@7!nT`3@S5t`w=(gFOyOR{9uGcsp51x3j8
z6*@SiHwZP8<xjC^v5oCe(zb0$2f0in3PQ%q
z+RugtsWUybe>To8q$35g9nWUWs=)mQX~Y#Plee-1&S$d&6h$bPX-~I}RmnCfo-J^3
zO`EgF@r&BAUN9cu0_}o6I@R76K>uwq$!#&_dZZf<_ccOo%Q{27MZ%s-rjB}2J#%QN
zII+2o+=&7Q#H^ejI$sppJR+iLc;SV`p-sahGsB9`^Fzl=BGbdennskso)+5olO+Wo
z52nnv18yuRxG|7@`(P)#pZm`E@$cqvBVZSB2|fgQ1M9U|x`*F&z>6S?&U&T_0k(!q
z7kFK<=|rnz_SD~4&pO~`Jzd}hY~N}S^HBD!1~CuCIE)&^?7qOa1~c&(4dzm|t*&M#69khcRiNNA-pGby(Qw7W!+rj~3OUww5&!jn-E%@bF@
z%a^mF4|c~t9vR*&VUPTj9P`N&^E2O%ibSjHHvo9Rp+SF)G?6Q2WGj~GyA{~N=WUb5)7pg4yBl!OtL$u
z!w}=Is_EqO7YT{HakjXQlg2}r9l7c(@j;e&lN)rkOi7c4@dP6eK;n`BnE&Xsfx#=R
z1KZ5&#W!w}lG1y8x0JV8iwk@>4@<~wl#Hl^EY5vGqJmmH}=|N8PJ>0kh|Cv
z?$tQeb5FgV-PwBEJ^+HbXomWt#AiaJdRY-(P_(hYjcd>?shy+lXA;CbuE2|NQ
zhm~moJ!jO95lp8(F`b`Z<3Z`W^BcA0KVRxHaIO^O8M
zrhkqIvYQ=;;fVHzkL6r6XI}y9it0zj149EEi!`qIZ1j8vzk3L>8%#L
z3iN?WdVmE515Fm4_Eqar{;EK&4r@)xkXf%!VY{ZjHtR=zu2ZpF7{9{U&bH$;x?=etv~Sxt$#_a
z=g`{M+SfCjq#ga8@BL2N>1-GL1pNg+8SxK(@-S>0SX1Y{V$acF`>}-!Y+~J_{h#lK
zOpDk>d;25DM*KiLSEEsTe+P~rfAquqXf4scYIMU|YL?|L))H;4okXs7FD#}U`vx14
zMPH>M`hMI0gucd@DKP&J-QS6^i8yYv^PH)?D6O(V$%Td)y}
zMP?goFci-90h}3t=R?Yep*}`9MeOG$V2K_kjGN@Azz=w)uzw*k5zH35MrXP<1YZPSP+$NPPB&f3Uqk}AM3<-Vb$y01Qlyvt{MjdcKbs9+?X1f0&p{_`T<9Zmi-G=6wLVcZ5e
z2$|4>u^Sm`<3i6_C-$SE&~%9srMSCZTtc83QT-7O(j9oAbRZ#iKNDh~*n=FHYBG9R
zUaO3!)54=uBcJZuuj{Ho86Ep|asZezaYW}X;hugA8%Od**7*|o$S<7=Q>iuE9-~mK42hCM2N1q^n99}%cd0Q{-Lxxk#pko#tp0Vn{)$43+
z?6{MsIlVX!IN;-l9X_0rm}9YqF~@@cVI^SAXzj$$L_2f@uIE0qS@0}-FR+O1H>XUwxt-s+fWOOk?kwkb3|IMkY}%{S
z^Dj>2FSGBK&7KW?lJEsVP9{hG8X4MUSxzJ4RzupraKT_Ut8Va;5LGzTr)}v~hN~6#
zKf+Cb8(1^Y{fcBFDNj4W;5Iw~P7ArDSrN~HDuvRSs%{|-;LFA0EzFuB$6l7BAKX_|
zzQ~IbpH--txKoj4)qHQ*ZMX|xh-mDzPi3`0ND1T>O8TuRLN}5D?Jg;;u^6SM5{f5L
z@OO+g#u{r03-OCKM{B`B?rx;HkE7tAxb!9}1&*kw9PZyVLr#D%4O~6^gE6e8ir;zu
z5eh*yIDeicA2`6TpI>rg{`?zD3O}Ad|6_LEwleS~*7y4#Sl=^e_`V;$=lfndvXq&s
zE-=&5BL4UJDt^C6et!FQf*l@3Dzdl+^20X4+Ius{ekZf@{t
zmfSquumDt(3hx1dD0e~>u?+pQQcZgBr5akDt}LpkFf^#BuuZP0(B{wZ6y9f|^CKor
zkUI93$=~CRKYwLxqR~N1hHX!|B=JERfnEvJ$`+@Ds2X76ehpgq_V2RLCkC4^K)Q
zmdDmfejT_vWpJ>Hj%$}C4
z$`yhp&KpaN0mwUmQ={}krz2hv!V|IY8f0>Wa6_dnBQuRf(L(ZJFc?LWEyEC0*OCog
zVJ@I3(b)gq(a?~xi~O(8gw3E{baZeq5>F>aCt6~H>jl??_B}`ng8Dt!>U-Bd3SIc?E
zBdqc3ud~KSjPvj}fA96z`Fr@=(D=cxhDCQaZMw7Q!Jn#+{wK|4B?Rq0n9C3;J}VXm
z1q>U8q;W{;qfcQ&xr^Bh1CQC?&&>@c0w^{OpbON8A)ALtF9sM5TTk0o(Eom~?Kr*j
zVm~mD`L;>kJewW&mP^xoV<`vD9b@Bd-@b5q`t>!%*QcM_Q_&GwA3wap9y@l7U%GOQUrz5>!BSF~``f$j
z&7FI1*Jb`^D!&G@1^*ug9L)u+iK@T!E|6yQxPRfSzW1NiWv2hNPnDsXo{_2?!_Me^MB#h*p2KQj
zOr+YBsy~D}lgFwWKu-AQ{zezPNr~=v6^Qc={2^Yp#(_Wdb-|zL+e-A+@JM|fIO6`U
zc$5-6fJbI>zpKv2#B1=o_I1H0Jy$Ig&(-iMyW$?tb-}CXyGZoa@cVFI7yOF8@F;cQ
z`Juk9c&5I}=c2EMZ|dv7H}`kNw@Y6Q@6=bvyEKdY(^`Zq?~id~{4#81v_`SFz5w2k
zC?#hlhLYuhbq2%4eneZfNSCYGM&Y$!4oxy^UJDuinL?h{IF|0+iQp8h&>#_ysamnk
z&f2@0UzS6LY|BOcpB2|<4cR(G4&j$q@7>QodgTKDXun)_jWr(RRq;{Mp1aG+|1K^4
zyL{Q*JxL!`cn#oJu3h72PM>CN0Vd$Y+QWWy7jZ%Ga}Pq5Bo-Nr!T}J6pu
z6{Z@B)VfhpR9r$rfYB|yfyK;dd)GaILIQ+mEecAZ(ZtMhH~v<1OYeGaaj`ApJLWb1
z^6TWGIjx^cFh==gMe|GYlk$Pq2E!h+caO=oYB+|C3g2%qw6^uOU9|I!F=Fi`F>MXj
z$v0G+iOyO{fRyxzJ!+q|$PR?MJ~HOPANv_jO#Z|a^%D~&L3dS!(jz6o-%of4i}R8Y
z_8_g3>pnRWYB4$Bkb&>B7ude{ZTGs3T)S|=x(3~v53irrFfpM~3Njf-Dz_hGy!39X
z=>-M(4W}Jkcx%wa_}E5`EeX#Ou4N$uo&qdh*u8*lv_QFO7!95+&NMM1xB>EtqF89x
zr-C-<>xp0rlZgTwh)-LZs6m0MHQp|RRw}b>ty!Pj%#;7;4&TpuY}mPT13wJOkvF^j
z+U1}26fc>L^OGijny@k{CP?!F1xR$L!fP3`Fzi&2KM@Seg#2m27A0g*`QwAS7|PN(
zL>TZC_?D_7H>dktSho^9C*K0|@oWz^D1+ZzFaj5lDT75E3Ggi`J;~LM4kR(zP*H*6
zhTw!^A#$S3TIlPGcL$l>P2^FDz&8KP0D8aGY%${xi(-_A&1&4^DRa8=!k$wPc5PG4
zwo3^Cy`hEqCS^Mdydh7${}B%pYMF1;#XS9cPHaHJDcv*;umnMWknTs2u+At{1?NEo
zyN$*<2ul$VQ3gJXumC`)FjLZM`r&DUXPG^1HB9^VbjLtTvy*JzUEYR$`6-`#qv{5q
zbOT0$Hs7#+13ayi1xX#
zr{sf&7!57OnuJ_oNECFEnw3mN%qa>6j!#S#K`;QBs94Yl=)+5ZYy$4!=i~&qvg!kN
z_pa?Dxz!!+{s{ofmzUYnY?8d&Hb|of#GYP-&2#t(sM3TF59QOaz|RZcaqb?CQ!o(+
zPLL#%#^NX-UW&wU&OW$+M5_k9uigW?*FfPzOpy6`5F!ySNSX*Mvj!pL4%j3}@9k*K
zdVkJmuw5VVb=`O5S5@V!=?~;hwlG_eyy)IN+gyM>3a=n|`8BbQgC)dGE`M-2*O$_1hJ>WL}=^pk~Rn=u%F5m#aG*B*dvp7BCn52<4-X{;m
zYPM67S@MJAXE9Hhv*uY
zwcLIhpZgE~g*%h#1hRRIf!}2c|`JM7|7ICraA`ad-c3-Ue
zf}
zH1cNh*Hvp-<%5fcCfm0^m}Y2j{~8ZxS%xN{tF4&FyLeXsaZTuTpt1vRM)quq!j&ad
z6BTnR*yclSbE42zoWo}<6Yr#QLUPHs{Ew=$C1Wr2sjBMprkry7FLs${?kahrnY;qk
zgi6zzW9(Zo_L($x^+}8wJV|XQaH7M5y%p`y*|lcB)T}4D4l>|+U1tYArB>Tvh9jUv
zdOOnDVKD%DN<mhXR4|2R0nmg^iWEwCG0poIcJC!DLI2*!r??zaA2m;b%;w02eUG^bzP!ihM#HX@KR?o?5)!*l#A@~TyK%X?79!*7JPM*=`#MlSFvA5zq
zF=!|G0XrI|MT?9iiNPsBh)o^)nYXtjc?Wq1`uWf%0d1BW);`G6E<#{$#h=0#eV9Co
z4f}@q@?XETeObkZmF(MB0vfed{`Ag=e;JzC4%yz=UNnELfDJGV60nf~X_8)7|1Dr6
zTDXjM4^VLZtORgLvf@FLB=!*SNia_aI`H)L4Dj@iHOIveZ)CbSLkO`0!zTX&7Nt-C
za@FRo10rzYR)F_WMjJ$7OYmh!e2WR2fu{PzCJAy-hKg_laKNxINeZ)s#YBeVmJoBC
zCD7tQTc(3KJ0KA}n%SWlmej6yL$7X1z7r_hcddNdZz@t(Ylzz3ynLKoK^90*ZotIpTR9{nHP@X&f*=nC1VAE+2nY`jM1Ow(31;G{
z?_PptG~o|ZXm$WtS*e6$%IvdOeOLKdpTMiO!*UM|F8NDai~}gl7nAaEKFNW69Vhs<
z5g`CZwE)>eWvC?!Xh+5LQGOuPP(T>3xvo-?&jb<69vK#DPh<;)V;l;aVmZNgHz6@y
z=(?L_Vl6?_P36+9WJT1JGV$K
zvw}6dVao+Kxcsoz6QjeppTZg=-L;<-MmrLs*Wk8Hp-Lpv66Fi^e~i%oSdgd_#!z!;
zY@)fbA4!LZ^|!<(LIe|aAD}E$@Lim2)X7!+yuX=WzR9|*TKeo)hZ(QpmyeZ|9g&}5
zwsZ2x#y8h3c|YG4CTDG5vS_2WXVz4x?*Wzs_ql-0R-khNNXzePrRRECW
z89L8t^afo!!XOJI6LR~Qj6`n+6I8c$wKJxKLcSdIMs?VzzNEjOsQTxiH@n6esx+%k
zZQ$TK1*ZGRiZArd2M$2rTxL6<(cR3q7MbO-wsl?$-<>PyE_|QOp>>}2=_b%!-e=n?
z_J5lXx+}Xmo$x-Z<~xLE-9xUg3IJ091p&O)ALY9
z1&76A$!tnVghPU8_d)njL)K7VEO5CX0uoB|AkvciNtPy=q?;2)O3LtQ62ZtKluan;
zu%MuxGY%PkK6~V~QJ?ddj*&7Q>T`yA1~aG6ST*lo#U-E5yHpV24p615Q_lSB26pY5KI>b=*cM01C_9`0G<
z3}gB{Ql(I!A|k61-QiBfK>o*D`ez{owSHn81jWek5M&(i_Ci*7RYLM?sxL}5hr~Fl
z?nv5H`@+;IfD+QFOy@$%TgUnB!@m5+QQwTFzK59Qv<;663B
zHA<13dv?YA_Z9`dz?yvM$yz?0-8w6PeaM=;@FIWv@~+)qP2Ibu__H}m*R^*RO}j;S
zDF9uQelrZ1fdvI8YuMx%I4N}FbT*M?S4@zIB8VcUUPL(6o$y4h6e)~_kvJkOC_3Au
zyn1#8Ee4^Hw0!FV|EWZ6=>wjr0nOOf^DJraUVe>*Rp)EKFAjW>HMPTfbGDsv$_F%<
z_4DQ&s;yw?B-UmIcI$~4gRj)`gbzedvawUhuU6ikgk7-#K&FVu3c?x{8Ux`sVsGz%
z5(o8Ip*;ZYl2Z-De=AOW>1t4uIr$kOPo{PxG)9q!WpL5|_-11rfC
z!)hvwJ&9+q&435IL?#CSEgx}9V=B%T=_Kz25@tJFW21q)uuv3u?FcLb-^y5G@d6&i
z@5RTZHi%1)Ml-9fUGV9~&g336SIx
zrDaH@?(gfR!V(G0lLaM|#2BG^gIg1saG?Sb7C2Z~D3$?^s?O`s@;mLku+mW_PvIFw
zeXzj^m6U{rxKD^->?`}t7SWLWS`!ieX{;|w3EbS1!w18YEHnj#nvLKmNsGv%Je
zpU<6tt8~G>VWI8!Z``}TSH$Rp_nCLY?hA*kJG5{0;KlvYy+YS9!@dJdT|0J1FV@ZW
zf$amug^ly-{a-;UoGlb?tBJ?|`R>J>z_iOZzg`+S%?hu2rmDYlps=
z8SU^x3(ZJ5XhKd&eSD9&?ilAIt~80vheBEyZwFe
zm8R?9NtC~?8+c`iluD8_W!?`)rUW4fz)=L5kw|hu*9;_R)UD`Eg%v#=o^_#;sIL+o
z6`C5Bif4qR#JR)dAAk&P6iiDl2H5h2qXA62BvyhkITJomng?WGeD1Bq3tpeJZ2F4h
z)2j5PBE9MA)MIXtU72^#B9vLd2lZa$JvsK4+{fIpGb
zFWAiP@a|6kW!??dMK-A8T>PUqaFkROxt3dlzej-AAu9syjY#MtL?%)|rvW`AGU-kR
zQAkW?wL~UHC4x11nv955i*VC;69ra-Z8!C^h%E-p8ZrT43(f|FC?U?ueb#<4=l8ct
z*2(oYy}EZ}!RGyM&G~#?=?gEEmhO2WbmpBMcbV*C89R)*?`VNKm|Rlnh&Y`F
z_2aGeB13~Qra%^`M?TaV6DlsEM5BGDuy3iDr1W&xF;zYIjr~%%U|Lbh%snMfubTes
zqWt^?wFks+@M?aKzb2<7(1^ahJgsshjSuTupBH!^(3mLy%lFEc_IW~HVMUFDn1Is-
ze$qTaJP>0N#x{`;Mk6#aCS-jKGW+^?A$1eYlOEM?&qh*f;hFSscMzMY9{dR~PoZb8
z#~ckAH)HPlqG^SNn4sszPYpSk@b;1OKgaax(Qjyc{=0#DRwWG_lAE19zN}ZP?A-oc
z8ZIe4yg6-1w~)|2BN`4Z=_cl5H0C4RIUnBerNDe>)*)IY_W7_cl3MeDBZXr=7=?3qOP~xblEjG>(y#(=LBYqXPKa92n*S{ua`%PDjXy5ZJg;a(#$1hR1LUG6f=`B}&dj
z)M{4j&Bz%+x5P`Ti4qT*5Tyk@4)n!H05BBH6mzDpG9>Ve$VwpH1Z*K=B@>Q^kL=h#
z3qEnpGSX5K<1IKlga(^^CFFh5lR_pWnjF&zD<6r0I-1}mY{!>A;~|+s_@Ih>auq9!X^U}S{Aq9FDw3hRnF`uj;u>xvzmW(p;`T_r$_EN
zyJ$~Vj@4k;v5Z@1Zl2v?@s#`puL&4wzor^U0aE8$HK&3d60`^*fOxDZ>00SG#?YkK
zor3HP*!7F{+EehNVWhCuvwGN-*sMpTR`%wD6IA6$-s-5z^kAdfA2Gc&R{|cvlNaL2
z#FOF4=4hw6)!G-b-F3DvV(aQ`Uo3rJXZsTAojThKAJKoQYkwi^QFr`IYcy6!_8bNqDj2(|(_z7X3cZwUATAea0V0
zyWrE&uH6Ue6<5jM^ILVE;oq=bhQi~MBr&@Og1*Nzt1XFzqa0vVWLR`~bVyKuKWdgi
z>#R#+GV-E}95?<*g$^U7MTW9hW=;G9EdKuToUPx?o%_vJ@skJ38`;5a+YYi$_}Ip<
zgQ1nI!>!i0Scl3=ehPo^Q&B!wEiB*jUoCMi2g+5TK|P)-p88Gd#HU;jP{&2Yqgs^7T3%W&)@q{`;r>y
zLGWJcpDEUmfYYPUrGK5%
zMcdmwR@OAfi?MG*C*Te`wMYXsb%X_Q_wdPo|Q+(u-_%a6^H+a3SfvjnI3Ou~3-puwUxOM{U1Ml~OY-
zFay;~cXvYpNY^b>93+}$8n9(Tm=Rm1UG^psFi2_Jq~){U^8ZXbKBPy5D=
z(cNFRZMgl{i)UZlHGsCySDGb#c)DP7S0B&Er{)fw{fxIyd9j=KtcBy|Y+t!xcA?lr
zp@6OVOFfKr7(&rlSf560^ANC^aO1&pB#>oFkRsM2DQQKbG8mO4vb5UQB^gSr0M{ic
zklJA096q%2gu38$t$c298&ndOuT-~CUN0}V<#s_m;WDEq_!RA%Bx43(0%1op>)8}>
zY{Z;qLh0@}Yg4f@99x3|DNjcr>;bqZAR*4AowIeO1)hbOt|JZp?9a=}KA%0~qZQ*F
zWer-d7{3W6r*o9|k1%=V3eJxn;rEO6vIcqQ^QXK#@z$A_egGW$K0Z&Z$s=eWy^f}D
z67ajUFJdRPwWN&;YXJRWXCGhFw|X$_?C`a$9<1BhA!n({7`uh!EVCcZq7FGr7mbCA
z&qdAxkvEG^aFw&j=p)7+VsdIa(=|#N*tPZWjduzP?rhxoN7|vVl8K@;Ti%E@NBCDe
zTC`gH*TEN=p?Ftm<35w9E?ec;vW2^J9j5vb=)w%h5M77}P=G`JFhYLP68+#j9N#M6@|R!yPeai`%xBl3(XdjOk1`Puv7AOn92XlWcP%>_h3wJ?;h0Yg!+kiP4uI9P5gA+PkWCZ6MvsU5oUib(U0D~kU@V_NBbhS
zq|Wxm(!c6#Um~5Wv%T;U{g=A-r@cgnr-(h~e2z=|GSmKY`_`|{IO_A^K;1gry(w)av-hYAr
z%HIyue*4LrEoX^qnJM-t?
zDL;BNucU3eV*W+#!i{V+FE8QMqIe^pZg{r33X3qWYFc%b@(gi{DmpWYd{ln+el0ZMe`B2-BmcG7!EGL4J^fDVSbX
zkNvxIUen7@&*(BcZ~Vj;M?5=qPWKs4Tu#sH^6!%i*TlxIBc>#k{c@y9n`2)WA9(4&
zuHS#(gWe!QVD>{F{+Gd>Uw2z%JxboaN}uC~N0_t)1jft$m^VoLEy2w=a^h
zNjje6?7x`3RA>7VyRCw7CiX1t`Gt?@ztpwAkT13VMUea
zVuI)McCWJmtlyiw{oE+ulKJ%;HA+Zml+4@C&5zG%U{hw=ddP>X7ysRSaQO6j?7eQo
z6D`Rp(ecB88_l+Wh$%=Q4M|fUglbX3TblCqLzgXKOE;39K1S5b($eoGNnNb5_L?z}
zOoSa?vY=*+?!_byx6PoGYMKs%jE`*6swWA>s>9SEY{l}(;=4_acS|5aIC}0
zCORTaugc(KF_{q82q!jzSyNa$V+NY)Zhw}o?5&eGrjKZupZ(e)9EOx?(JswUATXDkG6es%X+nM+m&Dx{;EnZz-SQh1eggC&x+Dpj0s?H
zf#O+B_IOsMmyILa=bq~JxXFIMVIn*T+iAI(z!E@7dAMH-e8i(_*aP`^xmo_&)x^Oa5P
zWOaK)Ftnua&~W~IjN3Fkw}f!`IA9STpYRJtG%E?Q$czg!@@U~~8kcyWfO1kw(R^Td{ytt-pC^Qm>kdJ*=O{z
zeP+AF{>82I0S;Rtwqq4wNl>AlAzxv`K>TJ>PR(!1Ms*y{w_UP2C1|u-w?`pq^@T?v
zDzlM{UTp>}?bK^!?-6s%
zZh~lW?Ux`}oH{h?0jNC%nT2N_@y`(zhJwR~(L%he<
z$~?CEHh+e_cAJ-NhPQbnyKVd0cAj11DfA9rt#-sagy$g4ydWb#Us>&dOT;Qi%i!R#
zBFsy~<)9yU2?)t3B9Aoc(d=HNF@YiiRwU%-%wsFQ;UgEm`t0ySlhX4_2JRgDmG)}x
z?LXtpw7k=^0@p@un$UY;CqNmCcH9l1FiJ!0NKzCnrUG_xZK+X+3At9=--|UC$i8=-
zkV@s9Qr9U_XbaZfsh}JV&d`Zjao~XB{0!a)KJ>rFlLRKU?3`c^mNWClihLCRpIG+cN7Z
zP&o7?lE`l^EZZXOBQT5C&@QEwnyXJ5WIZ$hS}Pb+siFYH02b>g2}Up0eD;ym?~gWd
z$Q|9Y0|2O$&6VqTZ+8?qXq+K$ARq{$`+v?^i~)jQ(z^dd;7r3s8~JxTF4SPaSv!|N
ztle)HeOz}nZRBcjrR|p!lpszyEp|0LAM{E(4$KjqakO({2xKAGb7G8kG}O+CiQ1u2
zlh=~yoEZ10b0R`m>OLo$?Z-oAKQP+Qi6=B2(uus%4joEay!hp)_{v7{O~w3yeoT}t
zO3u7Xc@tlqspM4eocZd+yi4;i1k7Pe-dXuKa1%*&D}n?3yeUr=>3K1g3LgJ`%jZmrT72ErKYi>!m_4{dVu@`E5
zO!*3M>wHX~^L>Cn!Lzg8p6{Rq%sJw>wDxxJ5!KfPa!!(L{ph$KjQEjpKO*_0rlA6n
zinyP4uYOaxa%{(fCn_tSn9p*qTp`@gn;u}6n<3fV0zYA6CfZy_a3Y0+20!F!f*(s0
z@W`TF#QhZTw>1fgzpd*d(Z}!~-0O^YiTxkqUA9&-@&Eo_Y>n-Sij~typs3-@xJNGQ^u=$jCYhgjPvFBbC1M@3T&!bAn_gaCfnEWz=6HKdi4fEg-scij(u2jn@!^n@
zi+N%tiyvN4FkHU!=H`*Rha#Qho4jCb&%V#%m3SxZ0pOF8H@HZhF*af{>@hYzK5&i-
z_JI=#Zt#wGjj^%A;0Ut8hVU>9v?ojwU?}hK0U8+E92k%b5OYHY3zK|IA-cQMhs@rJ9kWdtmwnagcV#vkvhDaEQ5d}sD!W)V7-~bW-D5fMpR7-*K
zHAp)o!20eC2ns|b@eqEX4tS};Oli$SFgswQ_%#hSJ6M>ZnC$;ouubJF{{b8D2`S8I
zBYdiVKp|l=c*>-#7xZPMTuKz1gVhH%gUAqjXb$GtH8e+p3Ogvk8J6QwD=dd58~C%B
z9dX%PD`me*7I*U6wUa!__OtvNiyQsyv!nSNg|a%VdHvNXTgvexnCqCBd^o^IVRv
zqa(%RXaP9NP;AfCIjg>-ABlW;s!Y)p*uehL7rx97Me9(oj)>MFanM5HM@}bjgzLmV
zs1(_w%E|g|P{)u{l;nkIkXb9Nmph`QSMJ4^=H>K~%Mn
zojZ=Qh4WTZ*+lk=C|YQ{$(D+ug(Uw}i9BwRq%$ykV9yk8edOK;2O@~Dl5r$RNJI$%
zr{Z`G!i$t5fG__zi;%Y*4p1iSCSVTGXaj#5H-qw}XiMRYHyc#SCvDuabo_*4x%q12^KaKOA71C?#C(CRd{F+
z;zs>^V6}3tD@}Lq@ZdB7M-C4$yC|gF(TcbFv#)uCM|RQh=hlWDO#RCt4hdu0g=IT)
zwhW#!u<`nRz?INt8O?w*MQToQM)77@F*QhtMgj$Zs=_r+MVyfXR1%oM11Qc&0GrVS
zrJ!Swmdl4MS){TSM`&o!N!O16BVi;)fRS+8)aTS0V1%j*M$&jW+-OhpyZmy&HyiUx
z+I{#|=3~7^jXsq>DKOp1#@1RH0hPu%dd4%*(gf9VhZBFW)yR3?<;!zdbPHYh{Z{3|uA!6Ul22#0Saf}Jrx#!D+q=>H
z=jEMijt?lsem0lSQ0J&fM@#WLd?lnaiJj&vg>**r-V8EK5%B6mXG)k_gf}U)E!Z=_
zOG$=y9A7Q*W|0yAvT-`4Zy{e~WQl#Bk9wV@d3_sSJib%=?os?@h-@q5it)p@Wmm5T
zE){8NO@eyZco2wbj654%vpT~fFQ-RBKiQ;F6wrGLJFWzA@M@lfOLz3
z6BUBf55lQWlmWmalcc1m0CSYTC<=lU05GQ@ILAzQC6fyd_4VWsE1JzAqHZASA!^1p
z81WOe+yKFH?~iR>7d>|8wWxe^${X`$6keIGp7AVuuEi52i+ewJqkKU{_rh*$#?<9K
zrhodX?-xh%^1bA6b9&XfDaW4mzI@4j{GoBfrh0OdpI`IAYsO4@dAi4|>{*{x`g#8d1e&FsM8WI@b=j-Lpydam6klzOfI(t<|lspMn
zJVTIsK{p*@JQyBq@rf7(k%=it(G!))(Mw)`_Sx5$OfUH~`A~5Fz`?VE4<>)MZhGIz
znVFOKn>YMt+m63Bm=Esv+P<`L_E@j#*SscnZ@heu*Iw2|$?%_fc=)h`+L^l^VC)DO
z!zeNhpa3jn?tr^Dlv!gy?%1!1`=RWX$a-in^c5OJQvHMoZVbgr09DuuAo4&C4~v#7
z3``kWiW2dI{luP8S*xpjHRtCQ)|#I^$Iq~~3O~mrz6SBFPsp#?f{To$Mab2XRa9y$
zk||sqbTARHO+g$*eQD-NxHM3;;Z8@)@Z(8BhGN7_Plh2DtJ6(pBCwU@fx^G%J8oH@XjtNKAdI;gTiVxa+}>L^1{T~kyYRz5;%wj-w}dd4wxViMI2F-tk9YNKsY#QX>eoc3i`+>Y2&=fzDZEOta{jS
z;beql94=j*4}$pQ2hd`r*m$csAj4{Q*Rm(0gaKJHb=PP(?xed0E(8V1+pkn?j?Rqo
z@oVeVBKyRN<13O!Mm6xQAAUrAyu3Pm|8ax+PNO=sk8-#C*<&yJdhSq-XT=!{niu#!
zv;$q%Ba2N`Bt`QH-wQA*AEtx^gKv*DNoZM6
ze2{@Yl<+SdsV50;v#DujQ+&+qPu7UPPoKOK<1eh|zaLK>XT5=ch_fy_RrGw($s*eo
zHu%%Cclosv2nCCBPvt(Jdoq`GqxVTz$Oc;HWQl2$^$fBM7(D@>Ng9pK0&#~}N~2L8
z$ZZHV4Utd^b*Y&!#kiDggfbj)HAuPYMMg1D7d9{Qh0?0j;ru1UE63SEX~mDS_WX3!
zzwuA@uJ~m9_)oY8>#}+My3PEk+<#dGzrpHPEUS>O&b&Np*4z0NTW02O)95P$xcv<4
zDn;s@)gzvHxF;|-4`1+Rn41-~Go&9ktRgHLsKdzFL5JUcC}MU+7I3li4Hl`sQlymR
zq_|jfpxHm3etJ`}J(C4;M2N-Y+~BOSl48wF2cOQwFMkO+!?lHEQqx-7UdCST(TaCX
z{uoLCr4^0hZNHaXG;hd|Lw#}&
z9Fnska)W+`0oFGGs|nf?cyGZ^4TmF;wK&TS!C}(?su8<{o0~m7sjmK`YUU*75WB_i
zrs|tK8z8+nY
zlcS;}DY;>C!?cw8NeOY3yC>Fyhek$(5qXH+B9K_BD^-U;ngC^+@Qa-L%?#3Iq9uq@
zYh+rGhQMkG`eZHsv2#~`Oy0dpmRIdc{_#qUgi-^t?N#QUzck8rQ?^DG>fr7dyoJVWY$4mC`=p(e^;3ydxjT$8-A*e7jBePL@
zqx962qy|Y1lItfX#K&PGV(>at#T1+rX%h70TcqiUq9q$-#S~Z+E5ts0u-##7S}uH%
z51&~Qt%meWizOsIlUYO3PsNXQiu10*4{uU%#!
z1d7i;i
zgd%9XJ7_Efr|LM8y<;#)*z=I^Lh|5)3MB-A5`s{qkBBA!EahWa`TzKfn1E3;MF{
zrV*uYuC}dOez|1gt~PUIuhx7PtTYj=8#QbjD$Ac2%Oh-CimOWbWVV_#-6fOJ-Dq+zz!eBJtiThC!^7b2fmAeL=rS0(7lt8<
z0b{oUYbU{Qu+k+o1gjw_F(Ec4Dl()|Xd`oAtbe?pH|h&Ai^Jh0-AJMVZBvk3W8uT4
z8Ic8tf-ox%zv>R#Q;m`q&6&RbZN_V^efUSaGBw@yU9aJde6O$pukX6@Rp!xC4O;K=R_3aI99s1)V{p&ryOJZ
zl0N*6y?ghTwpc$Qcl#4Oqj6!E4kY|i{7Hgg2F{F;pxXMf;R~$|h1d<^_MWGy-VwNGoC())87#I|85=sAE5D|G9Ppb3Z(z_}`nuUQ0f;On$7iMyi-G<Z#`BC}jBzFW9-1QdabQ-z
zlw_v38z0AR>i$?JB$9d&VGum%5Uf!%g5xkIB!DtOG=K;RnM=etYNR~OKFCH5@yJwj
zW}v?{AS2L|wj~8KM2BD2guJ840TKEdlQlCT(PRn`?wrUL5|Wu|3PwH)tPt`~WDHZA
zpkC|Ue|AunS4KR4$<2M(0i;?&%A4{yYew#4UE6fZ9XPj?666F0HyM`MwQXBlPxkcU
ztFyZxqvhdey0YG_j4S!~PhXhRA~)f|=j~UHL`ocG-%A1UzaTU;m$mtrvdOSr>T%!L
z+xNGsUdnR*;OyEjl`{{{qy?xw5;5F}p0`0S}A;;w~{l-E5XIU9@KR9FM
zy)9u?QSU5T{B~4T*yekyW=uUfcI?SdFPmRs9scz%*6~#{Q~5e^6=Zoe0HI1r?ih-UU8){VZYc;1CzrDVnsCr@
z0q!wM{h?M=>_=Ek&QKiCP)jr-D;3@0?_zRx2W8-JL4Gp&4cOc4{Sm+t9})}gc8JxD
zh(eRHp*g^yLx>_=@*!#0Kn6(W4Sxus1UI-p>75ooU|wr^?uDqT$OF|Is`z!JM@p{R
zP<rDKI-8=T})VF<`r=Ey!
z9^X8@afAAl>j~jX1}Tokne~#!TF5`NRAhr8T~@R77^Fm_&^gUrmAX<3#@MNZrw%zEd`r5W!IL1)J~SnS*;pmuW41;@+k~6YxT_U_OChoMDhmp
zkJ(>raEWVq&?aTzbJc9j0MN?TnuW$zFa@Ma$2G%{cM2?BQe0>dSb&TuFY(=E|BfIpQVv<5`+*85
zkCerQq!_O5$nHg9Pb(9JC{(IRZP5
zHwRk%1L6X$q&Z?Hp)d;85?G3f0O=}Zzrmy+O9wng^hFmEpf2l@Fep8ze9+0(!yD$!
zK6T+Jo9H#Rd)DOkm6cxRUNhQtnvp!tAYBX!?|Xc?ElK{o&#>e*Q*Eu-P;OCH=k#c|
zXn<{mnscVnpeM32aB{~#kxSZe9XehwX!r88D$R)qaDfhq)T}u780KM_NysKmrGbc@
zjp#$&@$83sLp|i}?H%SFipWm0)jx*RLje$qF+`XOAX|_?L7FoB
z^0QRRt=Z^v-3ReG=UDzZws=7I^Sm7Pv&}q@6%QL+-Cq99c6`uK`6rv1BM^g08?k7c
zV;o^pOjcBozZ;zY=s2$^{c%Xc%z{TCm`$YSwpbGrzf`*PN)uNfqf*7D01Uj48B53c0bFJXw{y!@@3c3v((
zgGm{!R^hh>&*PBQQb|5ZutrC!%#=#`8Ntju<9x?pM;i-{50ml`lBhM&Oq`bcs$G)n
z`^Scw&58c8ejY?7Rx{{bH7?L3gcQO4LDr0fMCF6+af$KP9bIz9
zzP(oVe=zZ5!zuUZEXHl$=bV%_U!TP&wO`XXAPdd
zdPr3deb|z}{!Q6C$m?1I4^6cf#*rW`i@=WJ`Ap44{+#Z|@tm*}7@#v~tG)?d84hll
zgp+GJX$QdJJT!gE5+B&<429s?zTUE@2lE^YS(FrD9;UGvIxx_mFd)2%j8ga1RIHQq
z)O4r_G+7)+(|VCqmMxl2qJBo&v0=2qfOSVg{kPQ$k5xT}0y)FP$s1H9#BsR?AI7%M
zD1U2M>q#x!t{6P}<`GZh;kBJt@}uPm3MM+H^CZ3iul)khOi~l%a)$Ba+*`S4$!5c;cK$
zUVEAnjgi%cI4>l3D&g#nBX)qLKLTj^%&6^AnAWNt>`X
zx?)^Wq$8qp2k0HzLa5~ma9v1SnksY%1a=k68m&+oCRzQHsMHur@QBJXwEbG0iJOq|
z4vO7P?5Ca+oA1bEmV>Pn}u*!ip)A3NuG_n09PL-pO(Ej>LL}ys~Q6g-L{G
z305lg1m@HOwp~OPBYQL^m1Jo+`!Y()4^aSiHW<=CdeDM-!X3)f*V6|#cwm+IyJ0c{
znu249bFMiPlO8lfUir=C%O{x^yy+WmR^6)A3jP6WSfTh-b1%#?@-?b$@Xk1?Lsr``
z*uapT2B&Z`HPGt{BqWiA5tsyUT?HzTNR}r(We}aU&A!4-9TJ{fZ4_#264?(Ep+T`I
zsAXVxo{)PjdVh}LwEM2S0js+8D#>ZHI^|eD#m`op?v}PPd)f8b3%(2-eSGqW1-;hg
zIK<_5GJFZdH`K{=|k32*K`Fyh%O
zGZKrkP-Vt+CJMcF#X*ni{8y&Fs}RJE{nd|uo*3D
zKRYvL#{^%u$TQO?A0G`$o_;3M&3D3%oSA3Gzp!R{&-op;&6&PNKEL9*yu!k~=Te6f3ar8ouvwEvz7n+E}zDG%I61f?AvGk;QnQUKoj6tqPCvCcLqR3hZywmFmEKw
zCCN(G=T39r(Rb0HK*S$J1VupkSLZIu7$!$XQ~`F=j6@b(YbUk+hrQEf;;v(?_wJ)@
zct)3h?5J$Kvb`hirhoHYw7K3dug>iNIN^)|MvPpr*yDwN7VWBN1?EM?hCaeu1gsZ*
zJta@X@RPyS0&x}^VOVjXN_-{Zz+~gJm*aP-Z=|()e(rFju&3wJenroXrYwm_BxW!Z
zNB<|At<9+%Y!<;MP+jN~3le^a%?ioK+Eiad!Z!?Rl39hfR=U6HSvh~%OPzKvoyBI_
z<}0&#yG>utT>MR7&e6Gf)0P)?E9zCoPI*(~@&qNzu25E(
zAoxQD&^IAj7F<>HlF;3eEZBv}nHF{;s4MJS(G0o!%eHgukIJ{-uGF?6KQHzU&k%bN
z-lxe{slZR56hrweJW#WnXak-yG9sx+tPxrLhw2V8!AQFi86Ff99OHvT1(GAg
zU+LUvw56=+2IOALug@AZgLS{cyT|o!QuJ2d?2nce?A~3l;Dz0RU}WQdILdyptq6;H
z?#hbgAJ5r!ylCZ%hnKCWB%A<7@U&F$AqR})r2!r-16XV_?rK1CUwN7Ua>b1~yN)d*AXk#XNS0QE
z&UOOE{~u-4jraM_EC4bp;_&Y8K5}IDuA@i)v7+{{$5|w@ZnxmC^IMQo-vAAL1$@o2
zuO+*TD&`VuLP^ObHwMRALcJrcf-a8GkX3Pp4*H6%5%TIi)^)qOkmS|-?{0^Dcn;5<
zj4>JwPm6rX8!+Z#@x5gjon>d@`boUkXm|$6lWL@6xSlV*w`xM)%kaHi^luYI-(f|^
z{zCoR_M-0_alK4jZ&%OR{~oTlht*ec!SVkkUgnB%otGVDy>=gI{b*hWtP?N8Bsh7Q
z9si~-a8G#5p7#>V<=R}(be_U?AV
zkDa%P{Mq>K#D8aj|0>|~If38o*3=)G1$?LZ>mt5S;N`4TdY_pLZ`ZtYpKJbm_qpc3
zhyKMizpAmhs
zX7~mkIfqVExkb6_cn86763
z_-+y3x59mhNj>!I7ODS9q~RpoYe~ezu}DcD1)+uXv%&$HRN*-)&dV{NE(G{cCRLYC
zq%N0+Qt$5lv)rT1L6&$?hnK?1!THJZ3VDv)k%AH^BQ7-HIHXYNV)@AR!#ze@Vn_8j
ze0~0quJh(~9WtNaJ9Fjs#4*N`#!(aRUO7|7rp@QK`ppju3}4Wf-=066cpd6t0RDF|
zo){_jq`Mp0C3N^B2t-&U;LZWckqjfTpv4g}hn%Y{1UR;J5SYCeVDFDd8itDQ7=aJI
z#Yi#!LFOnyY5{ONYl=J)nQ(H%?*wa8%GU(n(exmwiQsOQSCG?n?%b|91=kPdOgGKW
zJ;d*=pFW>O_FWJj7(TBbt2b|2*_kVMr;TKlY}EAISI*Sbuvo|ZY|GN;hjB4KJN^*!
zSJMvjw*vk#&&Q*BI8jB8AO@OCT(gL4Tm2rs)W3i6+
zZOf|TKEOt>?D*rMYpjc0U%4s2hbWEXv~EaOhqiUfP33XWD7B5(pdSzD0sDC=MV+J$
z16_V^rg2u``$X~mhY#rcd8rMq=i~a1ztsNzllXp(MW}<6DY*XguXO#Il%@`XKGJCT
zMSQQ6MyNBfPK<_MMgNQJ7i8;u9^Ze{zDs#{em#8uUH{%*ouIbE_dmpU?fpaX{XgRS
zM(Lnhrrg5!KgIVlR;+GNyzu=m`p%5Rn2@AZ~ul@9oYu6a+l&HbD@8_Cd
zwP(sl^!HuEGY7#~Ycl*|zlNen+A}Me3Anm~Yro=|wc)%duTv)|S-AGwKd-R>;P4e(
z`~9ESr0;;wf8*L8_G^m$ednc{xbH7q`wy;_Y2z!?VA;sJs%7dDT>I00pJNPVatd(1
z7uWv6wJjPxw`ljhA=Rj3)emv#@PfeyF_ozAU^09OZ=O`xcuVxc2=T#guu9hpkyY7(
zz!yqxYp&G>f1}Q@Sx^-PtMjxbPU9k#|6np{N?KZfoZN^LI?irOB(K@cn)>dW-4vC%{Jzh*GcdrIb>IK{`Tu^|#hJO!J?A{9J?A-3o&Vh8DVv(d
zOm45+`C{3&idl!o7AngV*5w|2J-%bvgz<>6)gC^sF=N4cNy_p}$d4IcJ+m+!rz7a=
zG}yfk!M?J_i{Lk7Sk>ZYTqV-=LX8)Jn=vy&q$#nV?l*reUfPY&M->m>qKzO5M{zDb
zioGF0UW7O#;L+8@nOr_3f*0jlRonyxM+ezek`tK2fPe7F83P4jC_t6r|JuQe5fbdBGujvIEB
z4T@|no85ZP@)hT6W`0_5fF%W{>P+eXe=K=J^hK
zj`^-rTBP5}b9sN~=A4hfW(udyQl{-T^d6Uw6jK$bOy=YM8`9*7atE$tySrP8Z=pLxP8(
z(ZCSnAXt7QEHkzWk9?yloqMbe42rJD+Boce*5K(~o{U~%
z({$2U-nW7Rc1%31yzlOox^8B{+_;f*N?Xs@)IYg!_1meVi$~8;iZB2)2(2*_R
z-#4+jm^8DyDQ!M@y}jp2i)X?nS-?WJCWCca>v
z#)i7PrL5-W9MuJjR~KC_uB+s&sA_IW|Lb`u_hl+5!BQeFvPtqH5&gI>kykzCM`&NrnjR(Wds@0BX$=a1QIm7GjohYw{s2i_wEpo{12(d5}w~Zt9xGBq??0S7dll!;Wo~*LAk#
zI*RMY?YsHIK0?V_7q_eiE#%%T#C59r!!5<+$iKAY{J^s>JKmK$VDtus5#ctBp&KrRL8+SL+sE+$+Awt4A$c
zf$I;z_5bDj*AM^O`xlMR$G|`E{{#20*8lYWg?U`6H0n&6!$O)+$o8l{(H^19AbTMj
zeJb3T#z3ts=fXOr9|#jcG`%3!hYmKcS6E^jM`40t99~_T(OV`
z*At3bw*~328Evni1GNaRtF8v`*!ECV5%U{WXhnw#
z0X~+ZUMrHHWjEfY#JevBM~Mn+VLgNd_|FtHE}aS4S?Pu23P&f#L=TA~W8i+i9&T`o
zPh)A$(9<#a!n(oFIa(%va59*KMWv030L-Be)a@ilP?hVjt(Vobe4yg6u!Hgd@&|BC
zT#F=oDrrjShioV7_b!XSQtzFyp&@2zjf1}5+I!brsG9qH^VF68hQsNm1>+~4>J%RR
zn{x3{ww5&%?rRAcJZ#&^!`oJ@TV33c8of|yWS_I=pz+>!ZOxa;^}TaHyf8m7JJnQs
z|9?HO_G)Wpc!_y_a^;u*TK4tD`Ohis|5P4S?imnf*lY~<6DOQ|;=<}l50q3Nn@+sr
zC%AWS;&<{xz3=40RKVOh>~J60b-)&K*pWW4R=^xM?4dret$_W&VaNNxsNBaKcA^h#
zY3C%JNxK3*tS3QBzfkXPU*Hagy%Y@2`g`!-WG}r`UC)s&aNBl*G7}^h$;Qd<#uWzc
zP;-)vyQU(~%^BxY8Hr@V=?MXmjcIn@V%8TcOalb&G-20*nhfECjNJ4+ev}bzO-P0Y
zvv_HgE`Hgnc_&c0DW!2{jwBtJIZkfeW;I@t21Oz1ALvJEs#K)N6kQ45rLVAUm#m;E0o~RRoO#`VOT+^)Vg;Xl$%d)JKb+Ahz5j>JIBl>NK`zw#&Keod>
zN&AhtLVOs-dgDzGy7(X~-Wq6ML>G?t7usKMa{D}-$B<(UfE-Jr_=wbBft3*lMO-@*
z=zzDDhu$bw8em=|{;|DXZBq~jLCJErWo4lQFI&q_6*J|_;&*r1u6+6SPVqZyS+uLa
z`*!uhuhw=!cPwR*Qxe(*%$vYGq&8_gxJC3IJr_VS8trKQ`LhxJY*#MZMP!-o_q4~$J;O8R#vvH9PO`OP(aTM7VvpuF|1Q65KrFa
zSW5-wzlkR;W6dTrC#gl+i`A;VfFX&7(Gp{m#eVQ-+N@lp`bA;8a7qr8ZV7r=d8&OS
z6BRg~l4PoktB=8hqWJ@&X&GiW}=DM|`^
z{ez!=Qi7j6B^CeWoy(tzPndrY$$pkGUp7pdEHwaG+dB#j!?ck2pc>{YW|}Wb4QA>)
z*3(wVd8Us7{Lp&*T-DnYZYhxMzQeMhTkEOMvMAZmZi}*?9TxsX@8NyKhvj;&P8vx3$Am)k
zhx|At!N~pNRZAW?EV$9Fnt=;oO&brsOI_i3;eGKdj_(zY*A>DG_0&nvuo=9b-=34Y
zm`lr~XGDMV-%t$rp_~-X>$}S9yGr+R^|`E$7d?;A@aGt}tJ3{exb}2cIV&8W1gG~j
znfSED>yG`KasSe{2qkO17JNS9^?cL^A9^04;m^^|N4z~IY>{@dTHgP=U0YZrZ;yUI
zN!lr31qHFksJ)CL`9yG4)sMQ=SSt!v1OAH(`}6#AKbtf!A^#-|$D
z(D4KNN75v!WGuoWvvTg1uVBnkk2V|iK>y17_!$>iLt`o*=LA>FvB%^n
zk(a|tL+L(x+oQrC*kcGg5W(yIM12dvF}dFM6D&tk!^)DnE~(D{@O>JiOBOvZ#)#M7
z|2Oo!N5vHOn3Mzh0&K->bzg|t-R(;?=+j&kE+Nx5?Hj4S3;rLqhdG)7O8>fSg7`n>
zX)>Pfl6u0g7emTroW#|;W6?4$BeBw?3X=h|0cnW`agSjn$A=)|jf6xxR>kvt@kZn|&-eudAd%Bvz+@LRV1=j>)eMD~n&wx}%^gsDfN1^zZIYBxv>Xp#fSg{i;enSZ*9^zf-KUYBiN%0533+u?O
zlzbQ{cQ#m38dQ}db@%1sLSUf5|H%F7U0~5DZt5sRwP)^Qc~8G*xbl?pw^P8qXT6Z*
zA$1C`SI9=KJ?BZU*E>SzptAUI?h1WbmLr(4!Hk<^AW+DbT`2@Fa;2XBYmU{B=|YURX%8fnz=5m?`V
z_5^3XAUA}Lw;sAEqcBxXJ{gFLVY3O&2k`+AAP2!%;G9VjSGr=&s18N&IkNwv3}pf_
zI@WLkBxNK=p)m3_CWTh~DDHy~n_3-PI^`FZG*_u&HzuE$+@ZWWmz`FOQ-*!t&Yar6
z{kC2CSNr#8LV2sjzRGO1`XC|kQR!4q=npfj+}BJ-4u&R
zuK5W0Ue91VkFXue{3FUd)O`hd>{jTp@9usgw|10?ht8g*c|lp|yjMWy?TB@&CEgXA
zF9KOe7&3-3*`OR&4mYrA{2w;0K{?E(E$sT)q#RGbkU8bIRclaZ+57SN`SJ02dGVG{@!;vx$MO>5^7G>o^2~<(__(~h
zxOn2Mtb3sr=KymSXjTN6wU9QA?q)Q+
zOe*5>0pk0^J!$w*c%~qID%$W54eK#hSuX$Da^O)@J7}%ZnQmo`lTV&n(-W
zo;rS5cx74Z?)0(g!^4WF5HIi;>@+6B3y*9#gkH80-vf4t!)V|4w3~Pr_7*GP?gASQ
zz3LZ&My0h>!`CHBLW)^E3C_g<8|0Ste1ZW*pGl6P;Da)
z;CM$LuHfF)DwzQLXPs=VASUe1u`w@FsoN{T8^4^|3gb8c}rD4)y&2dm5%Xq<#
z2&fQfjRa*KS1)qCA9+adO$cTy1VL+x+0fs!p^ty;{Gl{VY&36TA1Z?^eG@lJThTXn
zIwLJE=imc~%Wo#6xCVg?fS6orP8Kf>t5*ioU;q0xgWvPu||DTa83I64SMW+ZJ2%?}HvaE6wc
zm(Zy6mMO08asZ!KN&c60OF
zKTTfT!rX%M<6}bRD`)2U#V2?#V3qW;wGe$ifY;SR`1i4=pB8wo3jzLa4b=xP|$O_VllfXFPu
zP73H@)g@tI)Sw>W;nJxi$~RY=o3AqOBS)AweN(gp38wpu=w_ZxoKJp0^Y
zm-DV`O0rtA@c)uV*YnQv9-iI&y9S?Lg?WXK*%as1h(#@LG*s-Z&7L~GWkOc-_$fKH
z`zspXpf%Nd?5a)Bx9K!Gy`~N0r_*TpxH-_cftk=dATBLzP0-i>c-$bAy{j>UV3@1x
z)NC+T#f8;!>8{313~PL&dDbKIT+X{SPMXj(etgq}No!oryDUCGtND$_yB#yUTCtzT
ztT}5!3ys;{iUvMra*y>!`>@Q*2;svsB;bqZ4?inC6IJ?
zCM>&943XSPvV@SpqoT=X9V{}UjFOlMuKw8dKHfg?BSkI>Q{%DYO;ZoY98p@Q&MqI7
z(hwP)oR=fA^0`&h%ZDar;BJ+ld}5w-v8!A4%5FAS*(H}1MMY)~2-6#!3zL#27Z(i*
zjq-_eHu$fZFj3vZm;+53=D-mY&`&5f6&Q37-D_bC!)-;rtOJe#LZDD@niYu9cMh%&2sz-vL}w}2&B;}#
zb>TO@wTKS^<%a|O@DX)WaC9~}B50u~I+qE~&f-)-bQTLeJz-V=&8}yFXIDf#y-|NBO(C-aTv%b6>4c>a0i7v+CmdkwujSp31;pMr*>Z}MLB
z%>mKP0)^=VvD6<|fS~
zgqPP=Pg~Y|pOoaLR;QBx*<|Ty_+($jqJ||A_6ZYpdM&~tV4vvph=QiT`vmdH{Qa=Xjqc7tETbG#3|@a*H3IsvLB~c
ze8EAAwYUc?flDAa8b|7LVI>)jL$PWFK{V28fs;=jz}#Aq2|>exhxPaO_JqlJAPdwv
zb0sM0TPfJPYR#f1hCombkX4w&P$y%N^pIP*utoQspLMvTd2V@W*(}e8hW~L6HtvtC%f{Q23lpI^SL
zxoLT8bF;X9{%b2&zdmo?>#JA3KJURZP0eS|v^1Sj=b~J^!JKtI*rl#Qfhq;`f)O=H
zBcw{*mgOXR?$`C)hM89dj}ZFVv-rl%%vPW@?V$n)6T(AdW5pYiF9Opqj|&ZnjSUHp
zAzYX+RlFg7u7y{pQ2!Jrj~l!O@|lO&2pV%JhIt%l%Xe<%MfR<}Bv#_^Y8!Yptqd=e
z0(E8b9Q6VOe_mJ}R8%|@f(Y2Ks)WFah=-TzB(H`s!l~4GWS_QQW6`}29w{9^zDE8)
z@sNDmQnR<;XZ|1due^$C72gH8bPNDpbKy3eV4z`@(*}@wTeWc+Mt-#LAa28n(*Y?h
zESm{e>FH0F9Qkz7qEC;o*EK^rI+p%<-P-qCx$7|Sd=mAO&dfz1>kS081i5U(0=Ib6
zqa}(Q79M1Y3S_-sXpxaFgx&KzdtI%%EkZ9AAe%--r(xGsrAtlLRi1)_pY2SZTeuyz{-sE%JsmK)bt6;m6O||s^%6I#AnAC
zPb8eJOu08#)+8<}*mCmNo8Di!fXGtIjAp3tx~KH
zQ%1+dL`8xz^>8u>VJysntSjQYeb8f~EEoiC%z-(N;1NSr_ML_ap$zG^0|G=W5%EHr
zIQ{{G1N$V@0B{`ZTzD=mV7TXixG_&HS@`t4c~38FdU9-ZsQU;X(^C!iJ~w~!`qF(Jd2WYw&2m=-j2?3yWKW~ZbLjWD?k932onCY1!G
zSUbny(@8$K1HnHc0t=!}Bls-VJq$93sSylSTV6<{$?{%9l2wT9xJX*61qG3cf&!Tk
z6&W}(Xr!;VtFxM`M^D3s1w*$(ZC8RGOI$eec8rVjA#+1oN;E97$R8j!l^#iOop-o0
zcU`*s=m!?R@JnmswSD_PZdh>T^t2VpqnFoCYaOjkYLq%NQ#Za@AK-JMU&Li)>(5CS
zm5_SuiRPgJ?-R-#3*QG&0q+vGxVk|mtr~@Kr}runb`3X1m8@J
zVN}Zrsuy71=qNvGCzCgUE!5|ZB~5JmPJM3W_v=14E-A;_aSVn$W07RwHuW9Qy8
ze{l4luSxDouzcj!FmY}>-UaX4`L1SwyalIpI95Djp&
zbm8%wi?f^mc%&jD^gfG>Jxvg~U6E#q7bOY?pHHi4ugO{D<-@j_hyD-e%v=
zM1!a^-{f^>O3mV{kc|fjQN-VoCd(<<%@q|RsF=#%S$|siJ9?#yzbmli?+SbHcLfvy
zDc6R-0~q|>zyVAc5H&E$&)3t##YqTY0Xl<~zXO3)**gnWp;(1o+#M*b%H74!+PHs7
zRc+>&mf!<{Cl}^#Dt}1Ve{pd34Dab7dFd0R=BlcxlN@x$fx7TP>HV@QhgX`E^6C-s
z_h!VK3@);Ma9~&@?|Y-PREp9*hFjb~VUY=8;z0a(r*qB+ppyaMU$c1_LeN5~K=s?H
zg}18!%{=&(P{0Mt?>oL$Ve%o#h`*obK(B!=PLwl(gpqo+&s@}q032P1k1c~JN|Wb!
z8Xh^L^O!ZGa81#fCnr@@Oe(Ibl9pxGdcH(x+I_s;T%3rM!bGBbp>tNFZ@wk=;;C0(
zJ~Cy0mHAg`PHA$X``-QsmDgCUt}-`oiqw31=Y!|QZ>}4hTNpkdIHQ8Krc5By1tCyc
zC3MN~3`2eXd0<$TD;bp>z3G?K9#47ygEtHmnzw;ly{pg0P!QM?6j
zB-22;HN62*V=$h*vZ_!dvy*GqtereG+cVNR(6jp3F=-Y1ensHydROf-Svxjrg<|Am
zC~lP;F^1$PmuJfEhnQu!t8={v2s7N)B*f>Vokren{7lPL&om0LQzYaE2T?37^8O3x
z?~8H8ln1gv2g_E9=iCOaDBWxuUjpPDq3!a5p!q+y>SVtM-OIBkPIWiVtU-FEqICte
z^+tzBUfVsfvZ7>CMWwj4Xl&%bu*Br0gU6)YUyoIhKfKv|V99-~sb=ED8M6wDaL5ad
z(ke;I*LfE1K;XnJL+*`b4Z=apCrCsAzvtu<#qjQaMrSqB&kF(obi={$Y78Wu!VO1F
zU)OcPVS#5J?%z<}u;uK;jPX_SKa}m*{LiQ6u9yisi3JI!G#cA^81g~dFL4UG3Klec
z$g)@>+LfP&Y0SYT2N8nkcG!Juw0&;pVrYQNT(65^bRdOJz3Mc*xWU{KgJ3_u>?;s=Ee*fWX{MmXBQ5QW0Y
z(ZkKr+sQj9#7%49R`1C0Lt&g^jW#B5jc0c)>1Ui%8WGR&(tK}jS~zKCn)q24X6jt&
zFjr1Op~(SBld)?t7%GAuXc_ZG+XB=Q7b~*Y@T2ZbQGfS@RhS@UB=*t6h&cmR7@<*N
zbmm6ryc*9qGyR*&w=C%GFH*5ShA73~D?cj7mDE>Ih~vQmfrliFP#fnqV`!bP1V~fV
zyRZ6FH5cP%u$T#28(0h4$2zV`Lo9X48=$)m77+i5xOylzKxI{l3z!`mPiR0S9K#Y>
zeFO`^SZKyj|^HCq*l3*@_Vfqem(0Ex2)d3H_c4rb;9r=(_pmSa(FvWv^n>
z)jYa-RPt}CvRU`y+y}-M+bnwvUG@P@=@m<&KrPbA?r8PBD^WzUZ@HqXgoI7#ioH-MV=V36>cB6P$ibC7OKOkEfOjZPU1Tz+M
zaU6g#z~&}8Yas)L;DjV8Ps;c5q`a)e4xk_<_R_0s$RgLED=6JSk=V-Wxdocl_=v%y
zj&lLaCf9rF&RAFhAW+a^VFR*_#s}G8w{_ox>Wr5y%@BK77IUM
zanuSzo&fYzg
zuus3oQvB#CEM86%>B?!8)Mhak)1Zh~Sza~5a32tzZ8_)N?p(^l9!M=km()HL5*wIv
z-q#+8pQH8)kV7TzuI`B14|dQ%LAk*XYAqWbBwF>5#1{%L!kQbloSB$8zVhr**}p|v
zwqhpC4Y$XSO^ag-%)1^)FQj>i1^o`g*g&sf8lc9o2O
zU>nKsgVSu-K3JDMa!0uP`nqdrBSPb&wh-U_MGC>9M1P=+as2o~v1@>n^FWWvBjs*^
z$Rp!c{PeZ+vz`5%9Q~YU6ArhAEzC$;IPw-g$Bo6Oc~@u9w4p<%1;G~zIK|>6k4_k8sG654g7{RdtXE1zf);RH61HXryX@|?HOBG^C%
zy9e_DfF(0*i@@6ldCh0bmEB?@Y}Z?)Ra4kX&DwfpqlU7<)WB=1Q2Q)Dqw>^KA3}VI
zlilo|2Nm&Vtjj$N*GmgoLL{C4=nVVt!tdXD;)&nCd0|Rj-IOV_>ZF!yZ@hW!)8D;u
zZOi60YqvbO=KcrJJ#Zo2xKW%u$k4o?+<^d!WM{bH*!5B)OnkkajLt^TNlWx>h?iB`
zXgy6K8i8bllSyWS%^>CHp_7@zkkl!&df?QYsZ0#2U}pm#T(g+wWIEQ$Kfx#Igv4j8
zVF~Y+VIsMkWMP;>kWZ2Q^HKe^KN8<0?pwKc8DDEu|KCu*LCAXwE1c6k(q9-!sEz?3
zjU1n#$;7xrUWSLTJjal!QAYWCK-3478ANbr4|g7Cl<_#DYM9aZfvrio8~t`*dDRUL
z-&By&ah12d5OgH~>!F`ejke)BYvDr&(3o5G5_EL{TL`kxBYNjj=s9vqR9^s{0Ii@9
zfnDGV6BaY->=zO25kUk4miZ*Q=W~FV-#E80H=q<)t4*vVYy9Ml@fGdQY~Axz_<{xd
z*i+J~R_2_TH#Ba{n7Bn7*33q>jlz3$o6sa?N{Ru#gN3zEh4C=n>P!qn1TdtIk=uYX
zmWoC|*aPqKDtA&CX_|sWV(Y*Q1en_7JsznJ|GN}b^UdIg|N8csTWvL};PG;n&)ZrsjTT@01O^hF!S)aV7c3hRC)_Gdq?6rK(XbcjtUiu4^
z=N`gxS2Z>)gyt9o7)_%C6oOj>{ScvwVXT5dz~V#7a7Y03@L>@8v;GtlmNYS{Nf6h4
zBj+cP5KSDP9(H;6CC%fS*%Jz<&7V>Ftx_beRKDKy*whnJ%YF5OqD}FmvhRIDvw7*q
z;`+MWdy;`0TEluO5FhE#p
za`b}NgPSXDTgxzncdb(ET)}8$3Ro)3DkUL5giZD-$*TGq`P
zkyv0#D~Jnc4{g2{G;CW#D>)sgXUGWbK|g4|mOcemo+lY3F=Ygapb&eBb|03amWYDo
zXdyXD7smdku&y^PwB1^w$9okfKYz%6g9i2s^AGbf;tp&~jE~FEN_4HaQ^Js1%-1onrTu?_G5eA`2K;Jl@BgY
z%-FVI*^&Dvrlb^?jvfswAnI$gq`^FTU?y>|vcwt2ipI)OgAJ2qmLOHojf%(fm4qTs
zBh80#fad^bM?@R;V6ZyodyUQ|V#=%pu*Y5RLe;@3aj~6elwaagRz6&@Z))C*8F{%g
zXG+bfODo2{Q0maNEJQ<#!cNPn0PthHhg_3weBPB1+MPNhqh0G}_FYskhW
z*CI-A#rs(Eprh&&)l!>IsUTt#%2)7ZO(kjKZ%z1Zob-tMCOhFn#4YHqDUhVNhg~@+#6p7?V4v&f*91ErK
zpn-$jTu3Z$Gu=Gurz%n-Q~3tCbrhL&ncQS
zx1eC|?Ba2gYceu$t8Ph|Wy)IQtaGVLORY^zoR*SS=c037m~EPsvNR(mX56@#m<(2L
z8a_NVWyG+t>bhu>MrdCGpAvyR0I{;o9kn+WW^H+NVU6|n4y&JM8H(~udmZZ+C*A3V-aoNM=
zC+@91KJD$(xeN8W+Ps2Uv!$j7>xPWnzC0;o+dW0AVPQ4#+~zm_n;n^CN-CU~oMPDv
ztFRZI<9h+|^I&sHk`(L(xOG9@O&&WqJxSXQN+yD7fUf%vTft({EQK&F1Ps^}q^89s
zRy{@8x#|P76;2}>fBY)i3b`|8UTgu8pnk%C
zm=)fm{5)*mLE@3FqR;S;Rz9{9JS
z{YYLODp@=8N8>Yss!Oi;^?9^X_{==3Va4@)b;5(F5_OTcPc###}qy2oqcXBwaRP6Ns5a6$KIM^CH
z@EL$_^c6>zTBLgT>Lq_|IVckK79Mq)pc9f
zZP>nJ{rU$vM!9JBGH;jUcF_OXk=ymeptr=X4+asEcLWmk5>LoB?>%*D@7_}n@5{;W
z_gUEZzl0{_^3$(wI}v}*gtMVU(Zkh$#Cpk15h10M!kOo2TOwWN;A%As?l
zD-jJL4~Kz4z*}4{M~>`JWOBLO(+@ekdR!U;x!j3eN0;+@u)Ekv)MG!lk1B=j%17d1
zo7*Qu6}rjBxry?a=KdSSbrG?vCZcj)FXVZ!K8S^UEZQ_&6}r(}acNs`VVasf&nih%
zv*+DOl2*^$Vn^o;k2W1RvUut4z{;|EmN}0_Bu*MOJSJ}B{N?x8gcY?+Z8WSyYcnus
zF9V0c0&=0Lg0#C9h+zboFfgE>pAQnbStJf54O5p=4|$SBD%LYbwweo%J-FHcvbGC#mN&=ODCRnm{aZ-m^dsZW5t-5A+g~hDb+EinemyE
zT?~d<`P1*Gxu$-+g*ghKet1I`=BCQRAmfs?QX3cLF({XCRS^;F+9Dyt^=cXkPk2v!
zVHIUWJ~V!>!e!HvE2+C5B-K*|V>&fe65G1?F~@et;-QI?`emk@jw>g`@W16Wq+M{B
zS{vj$IN3irYSF8X>*_l-W9FsKxsUc{75=#dJp1J8qfYb|d)*;dU%Vw(pDOc`ysFr^
zx28|uFFC%we5E+d{NdW=%h$rQbi`>b;BRDJKB9cZkjc{K=KfT@{Q5v*11p06A#k*1=N}c1zE+Z1v*3
z@&P$x@aV$Qbe*?*xZ9Q__NMZ@zs_ZR*rMHeYaN>_g9qoPq)sB*6UdgXd&b#!UqgOt
zmW5Wog#FhanPbPB#u>3{ahJAA&+aG_!
zp?}e#mAhZ9UR4_wy?ojHaV-T+Cs&t_NiHoMoivRW5}h?on0Ghy01{OPZgNDj09(13
zDiEVx<^f$Ok`DIw=ns5#f*W(QBwtVkVvDjW
z#H5T2hi%JJ*H2@Da^8JuL(}%Qtdy~tnW&<8IzF~pD;Gvp>4*5Ma~YcGm|H`E~`nI;p*UgZ}yC~<*6|V85s#NsjPl%{u--`g6dz|5KQ%8t=zi2?Y)p2oA#8SPW>aRtrpiHcXQo%~EFD`SYbK2|P0Tl}
zo)I2%-?G@`j3xOKS7%GCU;d%{_r6xmcGkqk)|REFfSJO&!#_<@9A9^Frcn-D24yRL
z!h9pU8cuu!v$enK;4eUeLkThGRoPPy(bLq1W5dVtRkwK6!6sUDqsNVt;t`Q)t;?Q7jSK#G~RV@CpA}oFQA$NZ!?}ZSTL|_TiF`K3ei2>TF4*;y&{=cJ+Roh%`oPg?GiBViC&z=W(BK&T+xTTG7S)qv-N(TN{(_
z!wE+HT+{hDJ13ooUy*(J8V{FmD_;v=6Ol1wyD2vQJAv@LTdVbP=Wu-SV~MyfHarw+|T?xwqidCsP|)(7Lqe-zxv_f2ugPb^grC
z5w)vl%sMP%xRr1PF~zL&u~86zH+0OHq4+cKfALMNr%w4VRxGq*4DW!Ol@X^{j&xav
zV|B*m5zi#+;E4&B(4dMauEHA&qE}X{iC63=t
zE4)Yj1VH!a`}{q;zeW1M&imk1bmf|Bcu(EGcwce@5z=WNlriId_H$0>FWOz*
zc-YkcpxyeXqy92pzq&uEepgHVSaEjo_q1F8HVM+GKJU*!f3d!Shq}M1zyIL%Z)#=LW9R+poj+N|r#JpP+-nzq^jCkOZ+~e%=zX-akwO?=a<@_G~eJ%4}r!vR!A4F=cn@WNxFjpz^EEvr`P$+rja1gCavijUG%$O^!>CPaiQXGImgGXhbMNQuXiW
z5n>F%1=5|lyQ3Dbptsq+)<$(-Dv$#jzEK=$Ft
z9Q4eg9??#Ceo;+zp3&eTQ63d{ovY}gv7M#3e^b((;iBAC9;GdFY)Z0QkaBMDc43iL
zop*=An6c4ir4zD7C&k5u@mZT(T{^vNdSQOnqzRMKOiANMkB>`?OH7C#HY60GU~Om6
z%kB&Y^_juDnbV*ibNU-IEH>Iq_f74cr&|iLO!v(v?9Y48S^u}IpnDDUS`QAi9-Iz!
zt%vcD2Uj2xYNN24d8=z8p&xTY`XJ_}okL-F;hO7cV2DNzD#>YXnlrnq
zGA}nPE8enFRxE2;-Mo6?g4vC88mCRIoK-ceq&T-CuOcfaD<^w`DK&0<{P@1B#{Vu?
z&Hu$Z`roap9!u=!=(|{AU2E;<&+cZe{jXQvoz@`u#lX%Vw6EZt33oJsM$swucYeAg
zN``p@b1}B^C-GY~}$-cpStK7{%dF
zT(k4k+vaOK8s9>d;S{cy#%DKve9PZ0lp!ANMYmTXQNYPW17*CP<3Z
z{Dd+5f&ad$$q*F{<Bl#Hl%K6$~Fh>+gD4owz0?cC&mY@PGBy4sOZdi}9*U`3pcq+m=SJXv7xI|}MbF+x?<*U4S?hX*Y>oHZlPc_!mx$IS33pCy
ze)~0bHPUO=wI~U9nU4XkGDcSR>f4*Gb&%cCKcsxi{)pxnM9`Lixf(uTv8jO#Eq>bz
z%g#3oYD6Y2BU}8Jw@*EJ`mIK_W*SO#4>(%aj%1A!ORFBc&#D6LnLmkFNw!sr`2)@S
zs%;fwyj#|zXIJzu2(n}(2!fgt%LjITvM^1=CX;R
zz;v{pZ)U1||IJLIg4D;3AI(?IO6LN>K^VYC&KIou-yEe%7}uOrepH{{0Y{
zPKX>FIXG$%vh79qM<5=MpR*s(adbDjyFi8qD|d!J*to;v7Gx+^je>|?k?J5;Rr7%F
zMsQ)TC!ZAexjg%%d9Ir#|DhMy3rCMVckxI`37c42S~BTxVyouL?QQZsw}#1exA%)5
zy|};jz>6;)s6F`n_IVYxW%c!CwUu*~vORlf{8x2;r~eu8dcw6n7*CxrLBjo+p5w=7
zhznKOG}Zj?S$qlF`SLXG$Km|
z`&wM2o^j$LKD(l@9_QiYNQ>M%pTYkGfpEgZWqr|$Gv~j&
zwWwmx^V2r`*P4#b_{{~?FDN&%wZ7zJ0`XKz&j0#-`QWj`CkV|D9?^eNtEWZ
z^f=^63ydYd2PRws(LAQ{6+8uGwszBjkiuOJ2n*SsaKXbHJ&aL<29f+X2m$}0Skz&A
zAQUt_G2p)DBbAyP#LW%wr=8D9T%06}x0R&Di8<%f-WNCH@s+e4NsjLA?v6=2(ymzA
zb-4I{->$CTuV4LkyPdZ@8#DtD6$d_pbgEl=tGClzcb-WX!K!^2`pKkf^8#^ugA(Jh
z@cXTBHWjziuh@1amd3I37RGS^gxIi+KrB(vEOi*2nS@~^Pb1`e?ap@#6BnrfVG%uL
z%(b-%osVrh*QI~lo>NsL@9J=80hZRjY_Fx_Ec6z32=;sH
z?m@lPL@@LdSus3(8`$OVw|Xp8Vj9Hl+6MMcJA0?WynyD830N;5S?A7~VzVM-H*#I7
z!w73%7~>;{chBLZFq$&W9d9B<yLRYGD04+~i#~Rk!Cz|D$}_Aa2zzVgcQ}24Uhsaz^Q-)kCo@7^rV5d->lQ$K*
zC{Dm3#SnYH7j40?cnY2)-DQMdil-R!Dq%rjR4f?KKlfYGe&vITPiuyjhKnCGnFoiD
z9q8!#hP1!k)p6k1aP#0M@q_Tvp*5dYC=iIDcHu9;A&KLFh?6d04WVxZ4h9FDxCDn^
zDIQY(Y0^T8tiV0hO+uV3#A$)M3kV#tn{+K)O_H514hRFn8;j#9hYwt%*4
z)c(7n8JO)@`VSQ``j3cRc+Y09*!G`M;sJH5U?G9LLYn2SN$RNU+nTh$qfW(4{Bx5z
zN*w_fML6J=fjJ-?(0}ZA!G+HO^*@{7KmlDR6v*Nnsj@BoN3dn^9_Tp;Ul*CbWv7vj
zI(t*X`;T*l?^jqNn1%)Q^K?P593r+x4@z;L9p;S;0)38`f&CLjZX?)zzI4$G)LR=iPNpDc|wG2567gRNbtuqOGrdxkx9?7F-yZaY<`w*>;PV-_Q(z9iYDBSL<@M!OFZ)&1-VWd{c8>
zHxPSAd%?(an_BCQwGdQYgw5z$TPM!3$4*3lyZ8X>ZoSz(2LEQO6T`sgSwB}RAc`Ty
z^}$MK!C-E2uI7dlL#8?%%>n0abvgnME)~QDg(nh2X3ODH^i4#2vd8_)5zA&%TDZ4~SuDSjx05AEi;G=H)Z#*QEr$J|^M
zXX$h6ympQJ%4_S^Ut8Hei7+m)PD2CTkuC`};B^}%txgZ45M(fA=WF;CNQHqXC76R9
zSQ&WlR=^DQV5pN|j`m=vlVDE0VK_r;z(-Mg&h}uSi3D@811kexM|HZ|gP~4>x!HrE
zPJ+4jhS9iK+Vi#t11%(6eC)u=EOq*-Fwu5A%TSKm^Xmiqi*QDhq=7$)&WJUCTAhj0
zsyy^B!X>~W0P_Uwk_x+|!fpXp#$jG|U}pfMIz4*Ui6!`%@Thi*{5Wo~+6zb?hP;J5
z_1kBSY`NsUq~l)kP>Zrr@@Vc@jG`bsTZBh78{`uZ3TQ9LJYO8PKQDqqM$|1~_bbcr
z4_mWDX=V4fD9avQ!dA0YOO)koHU3dn;3w_ny3S*|^Pq8};7YN*heqG{XG1)M8V7CfvrRQG#9NP-IIu*y9g{(C!K)Ub=XaQ51rPTsoe$J
z2Fk|_q7Y5@BJHl5f6$J)C>@hdP(OrM@Xr5O-T`5a9_-`JUeS)csLSlArt`BKKaCJ#
z3WO(V57vgHSgb%oP+&;|{*thEkT}}r`^1&vYC>MK@%D>yI@Q&MzeWf(1yM9!+mwx}
z2pR$)P%@ANwL@VT1?tnyk2SJ+IrXev9t(JP-!y{$DfqVwBJZE%Zt3F5+ZTB+Z+@x~
z%`4DX(Wb9d2D40MeB2!*bof@IoPPU7-fc7NTf|i$K_=k)FLi9-UyJD4IByVrO66}M
z4wMlXnM*pjpE*QNM7Cf_DCX2<_I(&^$V4&eNC3-|7SSl#%Qa&+D~jE`
zCm5IRHX?|lMJvQf*~>ppp-OENw`lLseDCx?cG2mb|Hm$ReKP#en#DbRJNAqu_?SQe
zddrEi3Xv$%9hx5Qg@bwTdS53*nIf1P|iiGLLoS|CeIk8j{7UUj;
zif&z%*WO-A)wRoOQNFuQC)iqdj~S%%oc*N8T-GGxuFg#G@pSI*+FvINNWn#878DGw
zfetSONe&4k6`VWbumr)i24Se!sT((BVNuIB9g=iwn4jOU4($*>KRo)TWl@K4qup;v
z_hafySO1(4Q!!A07dVE5jG~e56FrrsS)fS3xr}@EBVHKLFCeT2`1|@8J)FH=y;aO8
zaJL=If`F40MAig^U@z=$FZJ`2*Qz+$q9`6P&m7_>E&2m>;0`!i`E`hiHNX*y3>i2=
zh)p=kLV+Iwyae?33G@r}ba!#S1CB_vZo$mRRhdC#{a_uz+b>yh%b+m>vRybLisL@Q
z?e?pFL;m>3A$}OMX`Q}$t@a|Y)n0&BEPAkmq}O)DbVN!Yi8ne%NbgA?t3a&#J<34q
zDma@At_UgtNjsCN5CX@CIf&9LO0iPN&P(qpIrOWs%MU0Od{OReX^n*Qi
zORJQ#Q|z&&>Qh={w(aPjqUu4f9FV)jg41>L0JEp~ofWr^Vd4$5uel#^)b}V$I2r{X
z=#&Q1$uXcmLgsi<)ENZsfP`L8ILA9^5fNGwAEBc^hCd_zL=WnL6)8&x*r{b4gt`@=5J4(09eMWQ#y`asT3j7+VaLRb7!essSve9$
zCyY)?jERqlkBc2KYQ(7Mk*f4xRjWDZ1Xt$jfxQ7yzsi7G5&_uy3DDsW(UlLxDn1%q
zKQ#D_2%0?aDdZhqa%SbOii%y8RXZyxc2?!gotuL{@tE>P9ZS0P!wi-*Lp)r^l4o=%
zTa*OlO+5HuOGg;HC_Tw8vZGXRcSXhSDpazwK0CL*J~zAGe3K>BDR0)HhC1;uRk57C
zpp1JFUz9g*eakK?nNr>s{71T%?lHEm1nW4jGTj#(WcZNb!-lFuYO!$|*dKXx)PZ%iGqC^BV6u+?#ULsV-1XRr!k*3>x;;EjW(4?#
zAWvtN*9Z@XH$@!O1N>iaZ`@2FQVYig8;gzq3sUbR`4G4m%Mu@y>#xRz}Z
z*P53qi_A+w)4J<3lH|@6p(C@69!?0!L*{yBT51I?#L_%yJ`S27_lr`UG*e0II4^!7)phJQ2Z~?dE$UZSJ-3>%gD?!-zmUiaCb7mlu&EI!7xZXTK
zye`re24~Sl5yl
z)9G~uE-pxnibz#1?k-4C2k$itVjk{JG`&CveJ4%`pAygu13?p;m2?$H^8^(^^LI?%
zCl%69^MA2n>cmYA9_
zT<*+OmR#q!`M5|j9K6VF2vOKY=IkgsAZ__9ZznIwfFq-Ve}N^K-V>2wg`$bYS>scZ
znJ~A0&g_}fr__{}7FSQK&d(iRlvR{wO3qBljE{>R5fvE`HZXt^3!sZSVKf`<3*w`(
zIOsv0YfImueeaHFq(vGYZhr~$Oa2xpI=*rhHe?ZGVyQX+hYDKb{t)=+4ykyLCz%kYML}(@H;WTKjLsB5o+=x
zu~aNwr1Y13@y&dC5xb7}A+KX6@V=UVBHR?h1Gj7JTo<)kt%KIVhpvm38Zn&RwVKb#
z$JhyL&Ft*aql61&j!oiu8(c`Thm&TJ`7~khSMfZ)5mp0$i-ddY4b88JVG{s;VuY|O
z=ez@~20SBq<_c)_5l~#rMZ!Y}j{R*PQ+#aoF*21=k^blxRw2VxkN8uB6ywPVK(=I|
z$lnw~TMQcH5X6Q+rE;_PW=4{MSO@x?nNxiAkFFHJS?6=zRf)-&COg@!d?
z{)nqZ_GTEDAj>Ci$+#-@QlnqM_f7emEo
z%~_fu?YFMBa0A5VBKk8Bl2T-X3J(o|{6Y?c_7OG6Ob3
z4VRhA{^l!3Dk~3P!K+8Alw){L@m<8e!pQJG=t6}cVT5UD5Jm_?g}4bM*5{EDB`Di^
zAr6H3f$1Q+4-h>l?~>db;s1xZ_kfP-NZN*P-!!9$MmeXEG>9MpN+g3wA`2U^Kp>RJ
zAP^ZOK!}_%2$O@!IDlqH2i7)duD+-G-jPPqV87pazyF-y
zyCBq4(_LL%U0qdOU4=UUsaEUHPH)!EPj-RrhM%`eFR*HMT08DG|4r@AmZq3)*YN*qx6UXo>8z5VmLWdeQ_&i
zVa%de6y}soA>=_+?UzflCZE?f1EKy~e%Zi@`racWjsW#vIQ_OT<*jmeHbmtQYHvU#Z9rcO%$e
z)Xq(3J+&97vmUI+bnQjfbGmkx@PsbmM)jjy;c1fo4o}pC)5UCORm|s?~r0`LeS8&IH+4dqI0)8WCz5(Camo
z&@((SHEvDlM--&3u|oJlEk+C(e4muHnQL|o{OWu!Oy!+KI4?nS6-mxfs|DG}z>j{|
zIWd+M;t%2vcM_D3P~cFo3-;extoASTP4_QXO`({#JMBsGT0(H(@&z34xO^FpE*o6F
zKuq#I84v!3B7j2oG2$ZHfYR?;p4jK^*sJU`n0vQ)|KS?k+|-X@rkgK)3}A;bM7l7+
z&fPM(2~(VzuxEl!~Nyx{ZM_^x2b39`)%MK4*YrFa0)TRe(*3Zx{T4Y1vI!q
z1@gRaU`~!TIF{~?Js|zbPG4nXq-}N&Hb#T7KsXDgP?fw7z!@GjwBg*)r4{;JaSxn1
zwXF`6jzasqwnkxVgggJI?5BnX+&V$ILe8CDdG
zHQapQ?pr|Md6r@tBkuvnx$7Bi(H&+_7;?@Km*b|INuUF8N17p?2o&yflgZ?6;t8U0
zwh=WDkOfLhhdG0sujytFID{8I#2|YQX?vv!HT24_$#@ilzy`qeXp9FxHQkJ3Je;V`
z(HQ!8{-`K}@jxNWiTf(#vYKwvd3L&rMjLa86+@a1Mk`m1LMY_!8yvF#hxPRHNu78qB1ZC8lgY$VZ4j
zmgh0aYHiOS`0pV7CFhdF*M8i7e7m-VO~wyqL4O$d`PwV8Rk?<)QEiEkSWcx2QG%Pv
z7@VA*z=-_2JYKB;7{b8|EKnK2_|F4qD8Rxoww}5eIDr#OLHW^jSg_Gaz{^6TqeeC1
z@*~F~*gnHUH5bCO*2p*Sg(!Ch{gzxtaagIeR9I_qDea&^(s>M0DjRQVdD;Q3cKv!t
zk9>CM=1q2xjbd#5daV}KU`E+z&~!(i025B*3@crcUT<)XCd#Q72z6(C3?JKj8khISFwW
z5l-`U<#+e-MtYL~?+Bj=c#gf8r#|0$zq{Jh@!s*y9rO0`%F6QJA27cb92pt>d&iKd
zDEomL^VU0PoHT#&@yKBF>->}U!I9)IG3XC}$z&d5Yv709OZe}O&lBi#oRgyXnalQW
zs37m$p98+Hh-*TpaUAVK>FtV(_5ukT#6>%QXX2uLy|~pS{Qi5izba!Pn)buorB*K6
zn~A@=Y!8tj7+mxd$9{3y-b!5Svb~#F>9QReqFuIExTYtogcjfmpY@{VvV9l5_;rE*
zqoV${qtUvv*TuJ8!oS7#x@^B9e&DkGWAS5`?LSE#uG*FRBsZ7sU8EOWws)1RF5A12
zn$&c@N_X+H%k~};oJua>dr7#lbJ3pVnje(jQlLxtKKJOSFTI3!(SJYbEtl>6#XnrO
z4-gS(>7t*3QfHU#+4pGQEKhI=zs-oT;;x{^s__W>+G<;($oOQ|Y+YQZp0EaCSi%F=ZtRQu`8?SNT
ziH8`f+ju;^#?h3EA29iFEW_k$1J?%=+H`7ziNG#MOxnHde@m~@sq(<7P4@?A^&8H&zvJcG;!Pb!
zGU(Zu=6K-~Dt2hvKib3Y(H<_QH-*RZV>A1N$0%X{)y0D~+J7c5cJOau+KFyLdwq|#
zzsDXmr69XPR~@FS1ID3OB#>Ug^@uAR&&KWD#P3|T_Z7=swhu*q7m{<0`zes%cyrOd
zT59dGeZ2^U)CK%D3Wj&lzW*NZugVCpY6=hiBHv|uGZ9w`F5p9?B$w@R>>HQutq}3!
zs-JG+T$k;0#Tu9G6|U*bD#hPj!mk%^yKLVj{_L{-QE{Ei_SeN%T(-Z(bbX}J`m!sc
zejYY%|5$v-Wj{Z`E#`tg&@XhosZl>l7s=(iC|#vcm;H1z^r^=EbQeE#3E#ue?;6AR
zGW4Lv?O9Tm%YJ%GUM}1F+@qhqlIjw^pLEe>dw&tOVpIN920(VXY9A=IbJ?CPrMPV0
zj1o1j`q?HebJ>2})p~LL;^`*(MFSdg{o;BPncNuWd-3mKeiEO?k9!aX>v}N+bfiL^p;k@d5C%a$-d|Fs&C)cpuHOK^hP&qULn2D
z;o&3a@VdXkAN|i(CIFr?R-ykQ{4mBH#@kox?LF`Vy76}4`AF?_#5{D!bjP(4JfHAN
z_U-?4I4*6GY?%qv!ALwv$@Bp@5=|Iw$S{#xt>=35!-Z?ZT#xVAdh|61L??CYo!+(o
ze0#;N%rra_8J9q3CJ*v_UR67K55hwd@q1^FHYrw0|WeKqW-G
zS)g_h74YmL;4MOIW)y{+@HN2AB>stn6&!7WJ)jet1<`XmJzWPvD1bRz*{a_VbFej~
zPxgITLsl#HJX_(Y=)Rpg_a8fa(@Ea{GwA;-qD_OGod!Yj9639iuH!$QdtN0y!+Y-6
zv2*`1xtmTHxI6XjM0JC4@|ckiv?-C<$_9j$o@qc(^2s{rMS01rnbFbSbbm9zYC-5c
zZb!kH_+6k8TExZElh>wWC{32O(Ws4nZG=Q#Ba~=u!DknRI}%n8^$nWxEk2j#;#DR1
zmOdx`W~TtxkZ%n}M}Mi>P~;L=133plDcIZ}X7{mK_||pyCNe|#QhppZi?|(0b$E&h
z7vHI3VSoP}p0glJ9|!$^C5&aWC^vzq_fLB+m0-*h>tOB(lXMuV5saz6AK4)y(DUIRD`WGKgHW)qw-mNI~k<$zhh#{&K9aRxqp`C1k5wW<<6
zHt4>Rua#p=S~SO6(LDR|vGE;F_gEc2C%QL=xz1KLodb={@zeVO495>+*5ik#7_}S9rV*2~q7QoAw=)~8pZiDN4
zLR~>~V7N`U(|By!TgnjL4|E&ekEg4C>h&KTrb$2Lpv58LcZU53
z*;K_Q>ijMS1J<={;yFhw#(haGQ%Gs(T1oOjTVK)eGO;vL}t`r~xhdB-6v!8@~RZ^%#bIq3WP
z7vUE^Hgl3Q9ERg(?0vRfhjF6mER0>J=W_gZHsK2n_^I
zqmL!(`;yBq8q2Al|GMg@5zHU=gdu*{$GYPRrV&2ZMXBlB0rOwZ%a7?abOpolDXkmJ
zj-YZb2c71gihlU{%w9Lvf^-coyX(%9R4AxAzr(*pbT;_inV%ZNTo?arvZffz^dzUV
z-Vb1!jMV}1sxx0x_+t5*j?(*aTGPfb*V)TW$3j0Hc|W+@_XC(FIK%prH}J83GHAH2Nk5Q1@&?|I17=0je(KI+*t5Xd)p?wUD5#b+mT*g-
zSMB&F^Wrf4jMQO%t1Z!CoYuW@Ki7q~o8X+M4V79F&c;}PX@c_zz+6L)!URU|j-ay&
z20yYi4(qI+HHNJcRvPqj0b6@Ry2j~6{i^1#-op>bwh`2>-ZsWl&3AC6Sl3<$PqI|M
zbKql=ZA|zPO%1#pY8icZZ|zOFLR|;Dy}JSPC}MN?z2NiQrX(1wpKilDV0Q8OIrlRG
z{Ui|%h!!H7?mC;oaijJ=y#EJ{{YM09eLMB(JU@TvXCIlC0mfK2VIMnb=v60)P}IMc
zg@+PEgDQgtW2ygIcyi?k;#(1S^XUI8R21j3$Z211Z3J^2kQ~P8Ormv&;Qj0UTxVnX
z+#Na;;Xu1*B!_{&9CN?U_}puu>4~lD+6jl<95>!>8V%eC-emqw+s|pT4LV|dKMB+i
zQAS`5SBCd-8{uonH*fTj;Jyn;@R2e18_4lEf`9f89UuHnG^y6|MJ`)JYPWezu5Dhs
zpW$2hjZ5I#{kluT;6I%|t{Uqzu*tkZ5%DtO?h?dn9Oe>hC(hP;5@#Fhvjua?<+$iD
z*K2DV_fvaa8!CE{&O!YMHZzCe`f4+*hwC#PW2<9$zZ$*+Dv7H&4Y6M87vt9(RW>}HSL=*v?nO0f$xcP27RXRah&B3@jl0q!(vaE
z@fBlFj3Jp~wBPY^YUg-S-!#6lpJ`0)Z$mrBOCQU5uMl2KlpOSlyP12z5I&}$x^Xp3
zoABAITis@PjAQie#r3|s<#WeF9~Z}?-!)8ny#t9VU+hV7Jb)dtSPdtzHIQC
ze%kYKN@{OP@0(%)JD!j8Gt=+K^mf8mZ~sMDTMy6i(%Y}Iwt73xhC06_r$O5$YhOYz
zb>9VE3@|z@#%`d8)P7a_i#S{T9OIX9ntp}%Xzm098RBm6w|^s;>q4Xs0~t~W1A5i@
zMQJQyaTC1u*8ZZ)8EG@{0I%S0zBbfOvchQpO)=U@F3I_NJNo>}kP^VHPLJY!I(_%Q
zj_)?=^AG2C&X+n4cO31F#=6eNHN_t=Z*%^GROR$^0Yl@eckAY2pRLa&9pCNC=kkH^
z-QG?5&jL~S`659t!ru5w_=eM`ar{wF`xxzxc>$hiA{I8mp#u8V_naRgulSx%;oq$Q
zpMKBrM?0ry>n7jjXE;Bft@tkKLf{$>JC(yWkVT+zA1Bx<;ljOPNq-=mMCuoGamNqL
zQ`ajXw~Y2Xblu@-uY;%gm+I{_CO_lRj}u}Z2-pAS%kBp{{Dma)3q^8`B@ak&my`bu>l{z{PkH>dl52V7?%^e
z?~8Pe#&Tk7BN(tw6FEWn)!mbD{B%9bS=ZQ$9Ijj+k*f8z!QZq7yq)iLy}hZNKs)*b
zhv;)|ASckL(|7CTM8o!aIe~UAC-kwJ$_afeSLY|@Xvm3I^|f?y#`3vvIidIOkQ4ar
z@BEqn6Q74x$H22D0`z0cWC1e?#qeR?wPE{Sfeyovs}5Nk1)`cWV;IBg@E
zzyN2%AJ*Q0IobrK-rt0M!~4&a-4<
zfoDNAmFuJOw`d^Vgd9eDe(kRk-XvjtzSrTQHgg_#$ZPory&Zp3d!3%;_KMza=viYR
z$68Q7im%>o$akUv6{+r$p+7+BJLW1If2AgtSNm4|y{`RV5QBiPor8YLsa@9l`9d4W+uuNYTT@hB`1@q<@x_iczhW^!?Q(cV|sNT
zv0?1+t>$ye+?VGpIX`JqQRX1)2e;Tu9;>$uOKd)W*o66|3oD);HTL9;vSXcWeJA94
zXkBUPh!>FDZ?}Yc@I+y}ThnVd4KR2?)Yn#yLcEVfz*}*=>&CW{^~~YISll3sc-KaW
z9W8hsz8wXQrFBj)SEC00oW%
z=yj`Zjev=v-<=db0iXUMV$}fniOvPGk6;16KK%($q+1TzN5_@+2i9^G`cci7zT^7^
ze5wC_S^u3r$6UY*-*KL*{jLd5?WON<7<`9DbOi%kR>`OeLZ9k*;gq;HUMJZpwi|MR
z&jbHPuVANJuzIoY*eS*|iRf1G)~Sj*7m7FeW-xQePZFJ!DU-V3)Q~I^i4Wkom463~;9cW8pc%AA
z+LubOc?FIx&NIjbjGtn*{!`wq(7pd9^%j1QBOX#5@hbX1pnL28fN{Y8q4%e@Z`vRA
zL;VS1f6))eM;orTug%o)(T3~zAQKk8i1>37(8oA~z~S~)5Q
zIlhmxB1yi=Zkb~FULT^@%n0BiI(R8d+XU&GfHNiR#nuF!uyHEskXnXMB#|D{@U-t;}L0Yrk|S|d@P=B$eV+XO}@S+
z3V{mBNC}XvQd$NrP{76wr@njQ+s`km!c$MVX}6h&+Yl~~z!ziGLh*)qEzYA~Ag#$$
z3wr617MxGwut(RKMjNsb^@#u_EQff+d+7f=>FC(JVlxz}(Uy>47{vUw@3ixLA7ky}
z)Fsk7^Htyi9Z|={aofRhi3BeDfD6&jN9dZ_Stp?hB;pU)kyyb^H4}-r9+4wwdwCIc
zynMWn%)psO?#?tK(ZCt`9i2`pP&18oP|E&{bptL~GX0?WzgRV#X2i=x9BHckqiDk9c4eg&
zqLYO7pN_{wtl6EWc!0m2Q};+Q=2CoaZ~8f~drnD|V${0^p85Tsj>mQg#$m|Oq-<(f
zdWI*{jOm-w`6E6jHjF+bk&jwIxtXDVCAKgjn}fJ0AryuQDbH-!P-)BuSiFb)Djl##
z^|KSz=W#&tvl7{Ok*$i3Pgxj;y(krtu8KqEe}8nktTJS;CFE46qmvCJG(l4XN60{p
zpIznlozAhfZq4YP3~=@BxX2Jpb?w!FcZ#h=`xy?8_WC#&4qxPGua7O^?O1E{Pw+QM
z&O+U~zdnew$-oBF+^7G;v`cd7QeUBgLAMo|QC2DV7
z*Is|8!222E=%?O??$l3-eP_K7o5M$Ocz^U?=R+oVxC$KO*7=O69WD*xu6@9BW^EC4
z^9lW*_5q)UK&ayxABwzCT8mr&Nl4`ai{*i^{!df!R6m>#^!r$x8@%fINCu&P@2{a)
zq9=N(uZj-YM3s}E{2{NFbzew}mQ&v!J%q{PDY#
z$@P2uj`R02JEX%&pGb(25E9({F^I{{pS|6n-DbXA`>}irXD`0t(3fRWleyw==*vu4
zBYnasPmyka5&^`~j=4+Nr444lC@FbB0Ob!rRXR4yFhG?nMcJKfH<^hHP{LX7lg1_-
zbi0U;jov`y`Y}7{vCIJulEQK%x?=@$>BRKKCdU8kC=P8p_Y9dzJfgILsRU#UGfDcy
zVI<9tj5a<+0biz_sV>LgG+wY1ibxBb5`S*!U)+sLMkUDx-(c5S0_<*3%_<|`L}
zKYah<-jlWyUKrnfD6O9`l<;CdDX($bVKtTUdV3i5kH=i7K7{W);JZ_)=J@9He>xTn
zN#+KXq(YKkC^Dc~&OjCRFjttV8UpfqK?gGG&~QNKk8?AM_9|4
znAo_q)VLH(ERAyD@tkjgDe_MH2S00fnesEsu@90~exyCVV|mAlalc=D`3JWB#GOAy
zpRJR6FB||gF)!EnGl~V3XTh#d60$S3+Bg_bRvC&Wf0xm}RIpO3eY#FE?`HAInN4EgBD;3peD$eJYT8SC^k
z`p8#@otKf~EsArhZY+Cb!;Ix6Z>(MV$ujvF>A~d#$~GNZQdV}P`k8f=r_2|WW!L>n
zFO;u;DabSS>555brj0o{yYIM+6{R!R9^O$lYvcS$b54%Y=Sp!VKVz*_x!G^DM&viG
zz#Kz_7@r(cj(J5#hJ~W$8nWxcMH0e7%zRdHVp>{E
zf~fm0($guk2IbX|{ZN(+vk`weq5b=?+>2FwxOC44$rr;OXX2g|1Hetxr1k!vhr}%rqAX-uRZqtx7uURizAm_nD80q0L+TDKA`>;A(nEC1i;G@
z*bLkx(&7Mrp%O4qn=7y;c!b3ZKQpEjp`u)olHI}*n_%UZZAJ=5igp1NT3~75An2-r
zfgEMZ%vJodY1)Yi3r~+;@YzGN*mpJml$+acAGSZVRr~GKe(la9J;rQ*aPHfS9=bAf
z7yHBWiM94Gy-oYHU!JPg{lg^F&X#1K;mG_6k&iOF6?3j%*o;xXf?wd6R+~LVwBqi
z42D?+(5%2(dSM1smdJpqxnH}x|D^Wo){XW<_ShzR}dqV`pn>P>*TB<8Cd(B+rK$XE4@$Egr6d6Fy^a0L5~0-PFURk=@7^|Z-1tUkr7C@sK|;q
zAdELi^$o~mNMPYuOtA=wKn)KeD}q2DMTZK1B@<2ZoUgb}R1)$HKyvYrMu?3L3&Gd{
zwsI4F(gz_$IluoTGXEJKGZ||@yJE(=9Zl->W#9G`ZMpl
z$KQW{-8HJJ3788W-cM;xUGw&9CnCib1_cN-7R7>3e%>*hp6VAJ$C!<);1G
zIaa*svBx$^XNIv??bScN{r(?HxqYntwSB7=(fv2}8f5x@tW%UQhLitP0CM7moyOmg
z?#7A)acNFgH}HXR2Thxc^RUb?EDgueArzfH&CRsHgB9fG!)0O=i=xCfDDq0aQDkZ-
zJ&H1-!>yafMLtr>tE#wW+b`=sda`QMu^|J!T6%}}h*|yI!@v1Y{p_&xM~(T&?%hYk
z$BMH>`Ka0b*(dD74t+iqyf#(ap%lW)?E|TetnzqZsh~m}CICKGFi8cOTUFIKUNHf#
z`3)m?6wtHaYt5h%Sj_aoh4L)Hb<4E^4?W0dWWxjWq+t!t0^xd1x
zck8xw`?Vjm9n!n@ZEE{*Uw*jm?UWi{cX|G{(s`ReUeNal_S_zb&L%G?>04l&v>kztMx^;O8t^nkF73ykxRHG4#oyn|9@
z5Sgf)p5gRM254`!aS&-tn`4Vg_6>VCZD>-?)GprA<&2!IBl9IX7`x?)V@#(@PM-LotFS)XLwJ1rWdBB*jrG
z$|mx=caikeq*$_ksmmTb!Q3`i{zv=A^B;eF_}9Gy&z%{!1ol-zSk!9mn-5b@YiG4P
z&m?NKrdfBov_5w2PI>!VpLE4_BCXdvtx(>D`S>G4vAY*gr}SJ@&6PwG+(L|76eNJK
zC+mb#DCUDEY#Kv3sQ)nc8Q^F2QJ|bLsN9_4Igc4hROMY)p5C9ctJ~$v-4Exl`FV?0
zsO&kit$EyCU*$??YwIbW90XptQ^7tx3%ptjodo0vwV7e*MEQuw1vmit)WGbxn3zbf
zwm>$LpFnSvnB68x6f#nikj^9_K7=w^wia8HYQl&wATDfak;s^(nv9xCoN=Is>1tR8
zs-Hsbf-1(f$Q0VKsR`mN+sLgWM&(UEJ!xv*sFJEXdxCf0*<3Pe;}&bP*neRD^}nf5Insi-)TqZ|qvKdQJW
z>eRZbP0N>W-nfph!Ene<0dtL{>~vw|Y=bHT(MYNcEdYp}H;`lyS7(OF*&-5k>e`nO
zDMs=HT6}_Bm2tW^qFl(tFYL>+n-nZFl-0qBtJJp@HG<)L8TtzzY$nxQ8@)&E;JK5GV<^t
zOP>Bn6bf`i7>aq~awCH;8!t^udQEypkSbbet@JfY5EJh!dw$}#*XNy_)3M{a|DKKh
z`RahO9`jcWD3cN&joR|@vX5$u54|z21C*MtcEuiRtG$ZqycNfDx2`JPF%o!)LJ9QX
zGT`TjlR>X2BlzO@CZ&>FS(9);)A>YoF!bY>We31;2n+F#3W&nj{1U9bbcKh4GPu$(
zI1BqW5c!aGnaa6}ZDXt1|Phs2P@mdOt>XMt=rCTn-Dr>%z{O?NR
zmgem#EZChN94Hmv`QU-<2k>9E;Rk>YqIiN|5{2#hKJ|%-MipiS)RTRozDtlo4VsX$
z*CB5ca6FJ*Rf+7#&`c^Enk2RnPTpb&V7`&UGF#je5PB(OG6&apU2bMG&@fR*j7x}*
z_v2bJRLCVR*=LYkqO=wk4#>Y<>zzqR5QOGl_l#wDlhx+(TN-S^j<%C=e`5M
zee$D@%lW4w^Lnq->?clYH5>L%KRF_nFmEoPDTlUP!Qsl^9?KUo5w7FgHNLlvsY1_M3o7j%*<0xxn
zye&c+TG=`8;Hp&zN8VQ%*D_^Ux!IhyrmEZcg$u`b+q5>rWDW_>Elp{qC3GEa4M}7z
zgQ<>AK8k4kiTTBl4M;8pSm%(I(1ZlyX=7DrxkNe}ibNRWD6bOW>*HpUB_W2zP~~;(
zOfs=`1)|Opckm|Ui`cVpt$I-^yEyyE)rpfnKD6ZJsnSJtQOVS@i4Y^PfgEXJ^lDdEpWu~X(Us!v{kY{aEuh%3OT1Exd7ESYy@^6HY+g3rX&0~
z1A(&)r9ZUpb4-8e+F@n42g@E#5W)`q=}f1P;9K0E>R-tK#V2LCrhmk=r6m-&3&eIYFu0_
zDNPDLJg}N6?SjZ-iCxI|Uw|dZ1gWVfoTpv7xe9a>|jFQ_+ZO`h&}(A
zu6-0WFgdAh*Oc_ZKlLA9HD_K`xIE+Ov!3Q%vi!>3z?*ZKEi}Sx>e`}hX8%5z0cJD<
z_oY9mfuMm+7@z4Kh`HkBz0?deBmzW(0FnG=5Ox}B$?L8MOb?fE#JeL!?ze-o04;{+
z;|HYxSpP8&h=_1&Ot>w=78jRbHIX^zDB{3PBT~Jv3DYe6;8dK>%NaN!3-;vq>K0`U
z>=nLoQ};eNHQ5N`w3N>l?#aVJc~=-S*!_^%TzyP>B_e-smqB!3W_yB*@u~OV576Ht0=A%>qq57QlnYNe2ucgq391U8lnqC#>ZkBS5R2D8_xSpuQf$EK
zm7_OAKWY2^*n7;&`jS^zN}JHu?xEq`tb_AfH|s7fy!N<#v){HQV>b>6?zHryUCqj>
zcID)>S+wVe*CTRPW_!!?y!`r?^>Tl-R@fv)j#|8L^drz`Fz1EZJb3}dd?|?k*H9;h
z?)$ZMx+(7X9I?YT{d4k630FX!25{hwh1w{2IqF#_z`SFN+LXmFm2{gwW`6H~
ztc~q-aO{Ab0cE|0VhVlNj-I=v^UwtUjP`RM+0LHzJi11+db-UxJ*}*CY}weZ*?)$&
z&Fz$woEbw^_R2xKGR!$zn3w79j+vpBHn^hy(>Cmc1WeeaSB`4~byKAh6paBDkU_!C
z00aaDQRR{_N7PFM>VA*}Qj`Xr8~xC|LR=TlYPGSsHfIv`;b-luUs>C>VgyemNH-KDc!~V66x%O
zcouAMX~RPWrblHd5kd|k8~#(nFV9z#OYb5n+qZ?)FNyn4{oq%ui%sMH4PXW-P5W4{
zo}sT~OqDcIo4@Yh2hSCcPnc13-}C|UrSY#3hIa4SK644o0{q7)=1W-&J-vmHo|zIF0)yBvN}Ns@
zs5(^ik;*G0_f&XLfV&BzsRc_UODTp2t6BU}Pen0x$u!oi@)C1aM4D1MB!DE-IMbJcR;-OB3Wy{6b&#ci5XxOvF7
zIW0OD=Vf*271TWItK!XrNxnXe3!#^lClTG%OeoQBkK!Ot6;i+}Q5y}@l5mHoC?@2?
zHkon;!uTWP!!l#*!0-X&t4TrnYy;i^%FPVswK$rYdA)?XdK~Vg9l-V@2an^Y^zt6f
zbB~lne~Vf6=h^F8dofFzU_Zw$YMsUIq(jr&JFY`laeC(i_dowXwM}==o;ts6oAPP-
ztKD8v%3hvv^y5iGk1hLDv`u?rMDmDZrPCfPIy`pE?M=x`zT6R{%@u78@QN48PRBtg
zkz!9Lb)ulczENeeZzOpd%2z=!(S5)KqHhth=Wss@ykTVG)Wec3^cTQz?qS$?As!xW
za-Lb-;d+8wJ-|wLqjX8ii^U|6d(YwF=89N=l=a@HpM5sR)f`#MflZqZ{N`$&ImGMp
zpi})@=?d9fU~mm~CeAPwoTQo%76c#^<6;au)MQeMF~d;`aZLj-000si*LWu&a-#XQ
zpos*L6OE-N!J~wkz+Fb!-~A&cz5_@y>(?ZbU%!$m{m!4i^6IPoN=o`oKEG)58{^-R
z`j=)*n8so{_3M&7f6T@mOwsP{Sld6RbN5Lj3LhRa;qb_QIdMbiJTmwQ_aHcw;UAtX
zOtJsR@U8!cpGwf_G2cJ2wvr0e26>9jC9w+Y_dF
z1dHH)5SUb`uENzt(&}v~#(_(kuxKf`vgq&wlRiGW=)zRx74w3U()n%L%$L4kziN+$
z?*3xYsEwq>m*%aWaeVlwJSg$NsgD0q5eJmy8DNR0B#23PEWs7rbe17;WEs90G(-xK
zA+dgtHYn<-WTeI6hLlW7E7vA*o(2Z^
z2ud2=$k;vG~$WHT;H_6u6S$PeK6#3mcrr9{Nm+VV_*nrVOl>^EDs#ll_wYL-vE_
zi_Srhbm((#$}MQGMY=BTYm3GlArGN*G31zsLoF;bz!qKK<|)F~z~JN-p+E^wppXZ-
z%UB^v2G|O;tlX=~n{rQwwoW8+vNU)LQASX=j57jEI0G{b9nU6#;b448R%T;9YZ69v
z^-Z$$OU&Px`ucj7*Luc-zDYj4ThBa_vM;=azh7eL53jQ09&(56sj0r+@@})&{W$}6
zrPY+qY40cRGI`Hm#f}Zw&^v2WKl?;^LOAAGt@V_TV}Ha5hzmkkHVap9Zd*{z&IdLL
z)G!D>s3w&_9@|7wxZfYL!Qrkk3}H)bTzsO%$K(;w(vPw<)19ZW+YPg;P6xACt4~Wd
zZOUu6mVCW+_DemNwyGSo_QZ*G53%X?4^Asrv~{FM1$_VVrccWH+_$(@$`hM69w7ay
z2E>E?n=cce(w*3P-K}DwJF(CZ)Sp0IO4?3X7;2Lmv|&-|eK7oHtiZ|fR_BF|p{p^j
zb{RHwy>rfh$<6c+LJUP@cwk&moN+(KAb)sy
z-hF50?)j)>(kF)t_jP>RekZACR7AfvljpYCBIn-`cy`L67mKOo_~q4c|-2cODOnKE}3-tdfA9G
zJ5b3YZ^xMtW!FznIx_$Lr8x&DZ8`Pgi%)N#bTDV>`}1kPaXF1S@!D6kpK2yS4v9VPQJfOnv{~|U?!OHatU^YnS4=cY
zo50_NPz=%ylCa6?1rWcr;MXrmMo2NQXcDq>t3v`rc(MJ!3v^|K7yw+hkn21_WETBw
z$L(og5O`+$2QOTH@q-Uuy!`w}Vt!#^q2^auP*5mF2Y<#|TUn{LDOS7kS@7rD`?1<0
zw%E#AiKR6^CtO+`yy6}0iQrY25+rZiI~bM;<7&SXD^&qEJ>xSyP%%efp7@Il4T{ON
zz=i_0V4@=OQ$kF_HA%NJLHNLEYxr>{3XvP{Gw1_}94>I{B
zrU<6G2Qd_80#BlzQe1v9GuiPSU`nym6P>>6>KpLM`Xsk*k!bOYx5lSfEj}I|sCfgO
zNB6gwbQ6;vD#bz+l9wfo3yC!TsztKu_qbnQxpMLGEvyHifS;C)1qiVN<>o_OB-i2ImP?NqlXw}>-#)JzmL`-TTI
zvHDD)_vR(aHC?WUGF8DHkm}A*OCE;dym|9zF>^Hob1EOpx=ier}&p82&uNOrj+_E%x1C*o2ixVI%mC5EN7kHiwF
zilj~?%fxZ^7|T2g%Zx||%m@4dP1JyNZ~;DsUo*}YZ-o*-2NSdTSt)eIgu9+}<8*?o
zbKD_d3MyMs-R_7qL1U4z^Ey2H#;|icdv94d@O4&EFn4n4)WHSpsy91y;>69MI8k}y
z)Wj#oHot%VV4p#Gb4KRR>O|X0NQW&M2Nl8_(m5rw4bL_ST^WY3Q%C{`4K6GsT|mrk
z)~s1jvp_`8Ifo?Z=sCqCyu9uQ)}6(__I|;cWty#;#gDG694*cm!1igSukRXrs7RKz
zSgms6kYQ6e9u$A}urdK_m?F$RWAPEe#h@u2fl&~z(sEK`u(4sA^GgTVdxdVGF*gxy
zf~269pTQ$^P7)|7R>0f|GB-1X35m4!e%^YGTj&Zx42yPm7{$8!A5-sg)NNs|99R)3
z02`m%%n7>D%zj++_|c=)OzYC)@s|@01x_p-y<$}1qg%FIdrRA*{oH#XyIQ^?JLiQx
z5A9nb9^Ap)wf}l=DR16kY+hDozrcjp^>em8CB152`(Xa?toB)ciPqgShJhSdm!Y^5
zd>%4~?tUj`dM21486%tVyI);Ygu$9|x&nqPvi=GfK^m}l1co;P4j89TaVOIzi5Yjq
z@o~WcsFvB9wc;)pvf=)RSHvle?ud8hZXZ>UH|^A_yz5TYw8H!ulTJ?^
zS(vvyH#&Id3oq;p-hTF+G{42vv6E+6cgn@57cV}Vlzygi=i6h(ytQNTGwDf37cV(o
zB=5A&nmlG|%Oe{%;lIrr>7JD8rhca)AP2XT$wF7*A^jxq3k?!YQit}5&1Ds(%J8y^
z^r^BF5h)|r3BqwbFh_#?o+%S29aFh}|1uBNy&Y@l@Y3W?lnk>4iqF$Z+>gwZ@&byVI
zS@r6qiLb0WQSxrdiB&I8yi<@iXUvG%R_mMx$IMHYFSGP{V}{O-i<^}@dQPgC>VK$q
zQ`y*Q%Qn>>@;_F)em?tBi!NVZd(408$q6~V+3!6bm~?8Xf9a`7gSu&~*U$+kby{kL
z>Q(43IxTnTI}IK<(Zmc|B4)*)CAEqTTMg|rn#jnC=%#}?=tw5{sDbW?XdOw0cpRig
zE+;qOurFPLalk2x)7699K@EtRkrKp-X&ARQ3uz>DNZpI7QpV(`
zbaFmh;(p9y%CjISr?WlzFQ}>&ifXw*&}DPxEF*FfneBI_5B`d-Oh7b)bP03~q)OYo
zvKy-2!Jm$Tu9Xm?+*pQQ2!f&5Hsb+FfDMd`35?|tUWR?jLl)@l<-sEX>wb9Q>}P8Z
zyqiCBRQ}ZQ)5Novzx?>}=66Do*z=Xu(`IacsGR%@WNStt=Y%TXz#8j*!XoS!FX)1)
z2+4urE|81U5w}8tp}9uvF`ZYCrTCtg;02e6FSkPD@mNC-k0KjMDXQ5emeky;u9lg7
z>yaZ{Bx?=c5@_qz*Psvr^d!uEj&c%qYzVx@-fjrHhly$Nh6A3?M^bQNp~k`gD=L+U
z5R(-6^Gp>;ku8x@O1w181eS^5Euyd-z>2tBhg6mupXX+Vd3gcN5U-HnAgYq;(+nee
z`dWNZsy(!2n3&>+cc4}Y|9AfPYY)s>Qa)(!6HDhUSo*|1=U{b`Y~@I92jER1?i68W!A(OUar~tz
zR451MA!LL6U;IB(1EDiP-{=1$j~;(@BuD<|^w~52_{W*Erd@kfyOKK06;&VRIlwnNJc_U0GtE$G=ryKtfF@Znt%u_EW&V>?M9
zcYd0+Vbq=igsg;0<#!&fk68KtqrIrdf$oH?>ZDiHhD^eNND>-qgCH4}6K?uiw`v&`
zLA8jfEIr-y#T%8S(^3(*5y0(kx}+k5oQ&}<_=BS@t)1RTo<4EqxP{$=ZS8En-PV)^
zWhUEFZOOAQ?cH-}cAMs@&D&%Km#yjMi^kv{6;&Naj&9LwcFDx{VGj&SKkU^iIX!>u
z;t~Bem-X#izPay+#bfi+lUsQmPQU;Du=W#6X7_3_W<*E6KUcwb`Vr<6D@@bRGj|9*
z(F2MQkGmuZsQ+S|Ub+!UYGMHoEptqnVqBfAi-w=_7}rR=Wv8`MYfCfNe6s@}!WwdG)o*oy{ro2;eZb-@biuvbCE_W!}-lk$Eo8PmOyZMo3
z5qTR1XiE>hm7o9CA@Q(%B;qhJH&J*NF^VHFHy?P0dpr|i@q~%#P>?PBU?B<4Jr3Nm
zr>8kwZFihFJm50-Orj+umcPQ`QYMg0eAz@nIDS|)zHIQ`v&xL^qt~1*dQyAw+Pmj`
zx87XVX@2p8Gpy1#U3;#*bGXevH0`nO8vNXc5|>}v(q{aV69?X>%YWI5y-0UF$SVOq
z4@L1IToFagCtw!219?CzKlJJOf-*m4kl?gU3ga(V2
z>T3Is}&&{>?S0wctYN4n%UOj*&fEyKuyB03)U`sF(V}!PZ3(*fZ
zw=mQYpiPggGPnzre4#KMEPI)EZS!P5OB-7a^F1xGu#U;U?ft!V$50#z`Sz`aJNxzC
zT_jGrdqC_{aBlS2W2N?I#Xggc6bw5z!u~8K2Ap6YNk=$s5r;@iZ5Wq0J1JQQVDy6b
z6cEE70^yw#Tc&N4sSH74JaWGn>NS0E*6`Rlw%x3|c3|-EU2N6p5)b!$DyLZ|s@1l=Y
zm~cmYC&6v@>!6R@YynU4C|_iK`@aQ&=Zj?i+<)J^HbKwJ$o%2Ir##}d3FOFULJ7hG
z6zDd7@H@Ntcp%Q4m_k4Leeh%~
zJ_dxaT*cx#DMLBNZFt;fo9X6y01kOxE}PzLiFm|>IGjR~>+=Oz@Jt{Z-q#1oU4WqA
z&AiQCSWVg-I0Yqc?y4-nP(axm+Kw0Y9scla_pE;Fw4c`x>EELthK$B5cIK&FvX=MG
zD(lC}|C!mQbtds@G3zTX2R>oKm`p$J(+h$qKq!su1C3Ay)#!n7sDMhyP>L$?4G6Qb
zAP@J3EhaZfbb}AcCI-EEFp7k8iAfLSK7;DNepbaR3=YdWhaT~qBaSAG42R;Nl<+oP
zeP4(PY!%tOudT3Wx1vCsxZE0Qq=FjDaL)sQT$<1_&2f}_<
z)eGrjcsM&GgHv?49_PeeQ>>SDe78AkU+B{%wU_U6nT56i@$N1C(^UUU>Gy3spvmm&
zgrVt4_V=XCQ3sT>
zs4Oj(Fdtv~O~CSC{uHq>A8$DgXQ~%MnCfW{vzI8|J-A(XyKcVEt&fP#>)@}`Iyq5n
zUUO|`=Uzl)+~Hs~yFx!zgedsKfC?^fMKPKJW@3245JgZ>*4%^~3Xoz}i`>$p-#?{+?_hx-+S_*z$(&$p}WfKx5#p6)=LyBTIOn@BKgZv5j`sV+L+0j6M|`
z^(n)B%lkBHCOF=x>ErU+3#yF0Y~Vq8xK%cn|Bknk$^_)jS5d2;K1XmIe-ETm?sfd0
zir?SoV|1V~kk?Raf;T=Q2i
zS{9QD_gP%spsm~BAAE82C8|ERV%WH)=jP|q+~ok{^U2TO3u7{p<=;;
z3g@5eUmVrDPv5L7z#lfc5(-?9Kka_t?=hqH-#m8+#3bW+i)n3ZzcZyHR-%QlQTNZZ
z6WmZh-py1AK}FV4rKg8zf{#En!37HcG~S7-Dqnlu9g{#z*YU5u#KGO&%|&qS#gp4B
zhB!wej>VU`D*?l|fFm^)u2lGgK^QL+88`Y9OoXG6rWz;O$k2h~JDtA`Ghz&Ja4Vhd
zZ+I@bys+qr@_@79kKf&PM!WWu*Qe*seQr6y!cT6wdo28H^fy)IZ!GnZ`W$)e#@ycP
z#!1^ho%JZoxN6T=uGr_i%UT_n`N>Xc`~K;l?99=eJ@z5B8-m2@K&$|r)Z
zNjZuhXj1TiR^I_$c(QD$EoM`gp&xOxjO$2TNlFzXJ{RL&f8zY}FCF<8dwp0~&o;?f
zctUw6iTz89r9WcjZ~Z?xaQO3H7$1AagqH|7fx;=W0De$#5^gbInv>cz!NUzMoaljO
zl@6>Ja;GD*-`PC$Bnnz$xTdCKV&%FHgU-hBVKdOCQTMqN&7<8q2G!C}nfS`i%wUL*%q5m+RFW&8TY#l+p$IofQBZ0Q$63ZL`i
zA@Yx7=kPmN>4K#*m#$y%>AIAD-P-pLAJSvtpy&3q
zSn|-poew>niX3ss5lz3|O%Jbv8woUDwzo+}g#`!%3h8@ArSx6e><-tV~9%DgIhSn_5)glzQ
z36O^nh;+WjC=fS*j0(m+4z*a^=#))^obf{lQx`DgZAgg>>Kfg72+oCV&;e;si{LoWU|io(iQez
ztbFj~zP+am)>eowU|huF0@q~3_QVK1GrL9U(GwzWyhNoE85$uaWuY$l0zVMd0^Ehg
zs1*4b5gry2WNGH*3109fvd7EYMv;ZAWfSjdv?SeFv@+Rx;(A!S&b~l1|v~yc+VQnvC@AWeF
z3-L$<@AC)h6Z)0D*BZ5}-wNmTu>jwZ!~dk?z|uIqk*8Pr2)1@C-H1lxgk}S{<0960
zgsVdG!x_v2YlDOQeVcjn=gx2i5qTRucdmQ)jDI+EF!txTJ;SR@@zQ>l0*${Zzd*<1Ss!tbte^6chk5L`E
z{lv-xd!C&za@_t=m0vxq#lpiAyZN6BhVLusKkwuYjL+}A5qClN-oZjbrZp(Q*Nq%i
z2;&#A%oM+Y?MB<)Tkys!OT`4YiiD6TD_xHwxDBy+y2Hn2GfCU4COpxuaR6T`wZI_=L&0v3h6IWX
z6Gg=x=B6kiLJD}|-4I1Z#SS#%btB9t9jPE8i2Jkx0!S7Cv3kt7QbztLXAG52RT*uL
z5sKTj>u1ie5nn#Tp0kf`VIbO^EjDL$0j61IT!k#t@MyyRlGv3n_Gr&BUbqmr6awEQ
z;2R3-7&@e`oaG8`Zd^G7ie#8b1&B0v=x8K<03J$O8dM+;($Wxukpbn097jYTZ8;J}o!wsokFWFRTtjp6;(M%*hU2^m6hW}FmL
z&y(A77~vwkC*;Uc3a}^nmga#(Fn>%Yp$M*R=%(4p$xKMjNXE@!ySA-c+TucSNd}*t
zr&*!QJ6;e_j6Y6js;s3!xvSIdtZ?L!3lCZ69C-}cYmZ*MaH!wpnKLKXd`Ew4+u4NE
z?@t+7Jm>w>{c?-Oj!IgtXYbSUcQ0SDYsi3sL-q|BIB@X3gX@ZBhej?R`_Q93x^(HW
z&-AP#u^+Ypm-+e1F4WOg<+u3n&`{)6B$pNP4yfvP6eY(<8{3a8-Si*aLwO_C>jdxCZAz*A=~1k`+o!cec<$lGSQ4RGfq2F
zCg@VW?)`2C>}&~7GQmQsIClHjm6cy_-*Ka|^2Uy_YX-X8=eiGA{ZCx$X0IAuymDah
zMkXJ7iYXg6YImPHrqyiRpTD=TaBn{QY1GbDszy7}9&`S!u3gE(6X!;G&_NTT3Zu28+9SAOflf#4XtN
z1nGqMQ>POm@RaTWG7O#2Fpf=DHk~DF&t6%Za9>1Nd)tP~sJ9t`ECYG(9g6Q59-5wT
zXXisQ&J{({zQvhp}F7fFg`hXe22U2zrb&TwnL7^nuXA7W@xJP><2dp
zl#2^720&gI4IhZUdi+WtLl*%4OUvyY#DCb)!%&5xNZ)#s!xi+ZxNf7*|T
zBel1-e4|N-7ScrRhX)?+$G)`BuQ{b8o!OLh4qmKyTOm<#sE+a$vPbEpXn?F+m`5LLIq`aXdiB>AXH+xR+=
z++84#gNLfuX{6xd_Z#${$d^7XB73MUG+;?ecKC($#tcWJo;cB8@DGi#DrP85Fo?J_
zATY>rJC?#kf9;r0N>WJV#5GZ8LKEo?z>gQT2c?QuF}nukXlmb0Sy`LVcoD#{e0*9s|hS6P~2%QZvd5E{bEt>8?0frRyLsAqNM&SW`yfrd0Dls^aUmpWg
zvyQ1_ZyHbV>MSxmot5+ho1e}Od3xY>((#Z@Q+3>@Zw@)u`j4aO0|%z#Pn|?PSk*SC
zboRMvQ_s$tIkN4R$Jj;wIbig8)YF#F!_OD+UaP>)JI@0)5%hw8KyJH~No~1IN@*;U{J1L0%C2hJxh$7lpU?@T*){u{c9^Ji9v?s)%?!kQMhY#V1uo5W
z55<#~a7cBEFQSO|L>x$Nu>I*4%w!UaurOFhNm7fTA%V+`V>^xRpyI)#h?g}@6jXGr
zQb>5+ckAI0NEIE0mqV#-l5Fu&Eu&io2U?opWRGN#?%+v&x64gd(PEq%#!atH#r2i(
z+(P&CL7O*RezB_fY~|}SW_`BfhdZ-wom_pk$HV2_cH~ENdA4ugMcE5CRjpX2tsXGD
z#o(;24-77oo|trL%hoR!uyL%g?>B7Ro9k9&RtyeESbVsD?$I7g3Lk#%xrZOx%3h5K
z>J{6f?{G|O1?aR9GTRDm8LwXvUC88^0GLLI{6Wkz@|!TR(h-1V2yYM=doQtt)jxqR
zQjluGOso{6S$9Q}hQpBuhr@N8p04Pj9JtyJlvlcC_WkwZn#(gTt(Y^pqUw{mFYny-
zd{m}(rBBb!{nksKhl1TUeg4CY%WGJc_Wbf44?VO!c-xJKERQ|^$|-HR>>;t~6Y>TX
z2P~(HCY~cgO$D#|>9PZ1k}BN;z}8chN_R741)|%r&T?DmUs$VR61!Mbe}6&nPxMcW
zi;apznmf8kw#HdJaJnL93lF`ZY{3g}jTNPY3~L$^DA3vNdYzf_JT<0#(x~b0O&>M6+}cjgKXm%Cc2m1lKI;dhLG%6hD~ArvcyY{2A3nN|
zd1$xyJ^JBG<6fLb{K{j8l;MzpvE+x~d9p}hit%kmnMFb&1Em4DlVSZ0UqfKSPZh_f
zne>5J|24-4xd_prUV~{ce)MobDi`C?oGhpD2NQIuNd|)Bb8^)s)0U9`4{z@Q7-f~U
zkH7bAlinK%B$=5clL858B%}`MAt8+#N=O1E6h#O{1cH$g=}mMI8%7AIsEB=wy1MJS
zMJ#K_MOSxS*S4s;x=7}o|8wp;Gns_oy8rL{e(r{udF#3No_p^p&pD?Z3_#adV=+rG
zN$DEQ)Eq9k4Y>sN2tS-Q38uKLkhnMl=5S^XAADDaI+khBLmr%tzic=9;RdmHdB
zQRveWi8UBL`HV2q5G5WnS$!sQWMVRGC_wq@C&KdoQ&?hyzU%U;}j&1q}Mf&(+Fwk;{t7PW88
z%iGxAzA-<4qn0owFD&{gR{ib#6B@Fe^oQ>HVDs$CHTxSv0!-F5;`BK;&kS90X7S=P
zD?(@93>^!6V71$&+2H3!Y}Bus^ThkU6dJjG)L
zc44LIJKS&hh!({}mZ6>Pe|st7&gk2MHMuI=uB|iVtU2C);M>EN!{6-fpT2j-v1xmz
zlZ=EtSz+9kHv@DTyuPQfCLBjjE7$(Y^&ud=Nd$&2BL$$={I6Oz39{8d8^Xa&&
zE|U83DgJ!We?j%&?}hwE*Ne^gWQgesVn4C(6nuoa5338ytX`gwf8e)6*rp!r4Yxi-
zjTK-U0k=R_ZHH!n+;PaQhclpN_zC3JgOMA4KEa3i$QXl%RiTWT5HlAVqN68R$^Cs9
z5g`HNwVw7ygC3M2rR{(
z>1YQbSkSjFbyyS{o9RDm)aa;S#2p)BqRgYof*2Vb31}2D4R{im#>Li7j1*E^AvG}8
zWeEl*l14+>%N-{dw4YeKq`$4Le@W{M?cm(GZ27E~mf7O^W$&KoJ@H0I#~b)KxAcxf
z_jr5XbBLYyJ+^P(F^mPcYKRXAzp1tu%+l})!KwFaXP#Hs?0}tc9JlDl={#bpXiRaR
z;ab;u1AEbNUl#6zzZyLR=oGHQZ}mI6j(*eqiq7Xa?uoj6j^lVf9jEh#`p&hu3l9qn#(z*EJKPXrw#Z}o5dd8F*)eg(xg_2ZttuH$$<9p`jlzUu9y
zytZi1DE`~2Q#r93=m({i`_!g6uUC8d^}0U{+a6n{N~oK1=AX1K9QYQG$8({Bc%w|VXiy;Ep#N5{(XUrw3&Znq
zz2}?%@p{}(*VFSnU>BqJ0iNM#x9WQt{eO1R508r+$m#zxr+*U0;SK)2KkLuKIsQDk
zl|K*X4ZptZJkgKy^nR6$1bDyNz^{kS%bI8$#Si#zhwg^ouoa?xbD_(DzXVmB&fkyg
zd7WjI>MUdZr8ul-l)qrF#!gKD$r0jwc{UA&tOY+J&R5T2DwFg%?X~cm?l^GoiY%r*|SIc?&tk~xt_gp^S!%2-F@=bzimE!Xy2bU-K(8_>Ev1M?8zN3a2%mH&5$Ai9pSMZGFEoA2l)xn5ZHqpi6TIvW06B-H5wKt#92(iaIsLj
zYzEs9xuz&}xt=H4X&}-i2*{B+*-Fr>)?@9DYZrI#XWozZ|E0(8rLcW>_C7SNx_{T2
zd&aR}5B!mx9y|Ud%Q$=AODz4q?mz86cxLk_hwnoh=1l0LzT5+G{)bm~F2#QIq7g
zm$r%GA=@1B6y6ibzLJZiN!XWStz$&ILO@v`NTbb=4N0n!SR*h&0kR6=Sx-6dN?0Nn
z9gA(<)4pu?j4>yYZ+Yd0EwA3fz8W_>zi@WStXt=|+{$^PO54NEtG~wTqCNsBQ$|X7
zPM{@+1+uF5-rLH8v@gGTPw9Dgv-ai1Pqho!C$|fml~N@Uex5X;Qh3Ii0YH7CB|-J{
zGRDTB*cWgZCVL^W0gzWV3X{pEz76;5K|9?Ob#fX@ZiwdqAg9VC4rX?pFfblBR?W;=HJ#eC{6kjS=pU|E;bzQ}3I(F*BDjZu~LC5YNeqBFXv7v13@q1R6uWwyb
zw)%MgnzA*bkNcq$_pU8lW6N?s#INGx*JAsVRAIjhHH?5ICdCBOH~!rQRuR_1X32A0
zNaW))OBVLbgO9Lr``Fks4+m-AX&roq@)KcTJWq;`O+%Rz9juQsGz>vL5q8w6IXEpX;+$*>+G7n(SktcUt};H@8o#X_a?xV}kEl>V)r
z1rpML?`QSv)wBBb&{h`<+vO+a*8x)thc2Fm7?N6RwE=4n6^sO|48A~$-If^yR@lQ7
z(s3zc4+TbJE+R4jIu6jY%~+vm>6DemIQ_p=p3xeHlpKc1&I~Nx854&!)BQVhJFaI5
z)U|rnw3U@>Kf^z)e2eyTR<;GU_Vl8>ydwG=I9iZbn4e!bv5-AnpdTx6exC1TliB3$
z7`>Y9+T+^e__;g1v@|`9{45V0?!IFcfJd&>TbKc%k3l4eF_K{x9j`*i2+XP4#H*XQC-yH(5)
zYx!6F#LczZ7vZbEJzRTVt?H6B=3@SKs;Gg=~
z5B$?H_M%V(Ix^Kj&=DiFSbaxFg@zC{fR;HC@gcCp5H&o{TN{Gj#1^s{Q;Zx&pK?bI
zlFct{L=iP|F(IlZCNM^C0ncSLyaf}$@rDE>-P31)lrut$e*XeXe5tUdQ~Sk*3);(z
zme;<>B8Rrp@C|F&Qdd{9u(hIY+U6Rx`CYA7`Zvjss`p|6T<DQJHg)Q
z|AY2vdGysSEms+9wa;HVuBsuSiQxeu(w(B;fYx7`Ib(k7bjy^7MVswwqjq~nVd%KX
z;IJZ$*=jW4cBKgPfH#7X3=Ez#FMY_EziW3xhX6c+94~~cN9b)l;!Wan6#j?0sRn8M
z%gkJ*{psXsI5^AMH?eM9dQ`)m^FFx4Plbi2@wR7oxkmxXX
zK9CNvM@WWN&NUGatXqxDMI_fg^Ah@fH@NDKIEj
z^%$L!gQN{$MI)R*_}8hBn~heMv3r}R*2Q-EZrY`tWf^2}db?jcd%XYpW7nVT-}Cu_
z#Qk6F!Nb?vH!IJfjV7`>hlRow0J)4z&R1H{DC?rXFPu?;g&{u-!NOo)CO>%?#6|i5
zFVJ$pT~o-aqN?n~uD*(OlS@`L`j5YG;EO#|N>-1Xu;AFjhRrW++O@P|U3Kw-9A71N
z&u9BOpQu=zwzayWf5D}D5RIzSXDC!lgm}_Z%mGAIZG2o%AZ-zp6$WwywkgJef?^vE
zh>h{d@n$Xq>$o&G>J$f5&FxUUp>Xp{D+lQt_}!47@>+HXgt(}b_8cGB?S4SM|F3+E
zTf|DKP>F@?W#Kb(H0<@5JD81nytlX(mw5;tA%QaXM?Mj0rVz%G-Kf3w@cqF-4?L{B
zBUXy05MU3NZ367$NL$jbC4o!i^apX#meYiyZ*Lg2RcExj0Y%10r;6H2H{ScJrI-S
z9Jb)hVCY5$Y5A)QU)s3F&+j_U0u~mZk)fTV8@p`h#g+D_+~#9^c08Yt;z~lpX}=mj
z9uAT7&(3|OYn8Y6^5OTm&qXD)ixrF?ul))jy7OJjy}eg;JwuqkMB4>%wEZ#2p;5v_
z&MlO=7_&p7JPYW#W|BNPJODOZ5-;JcjtYz#6fZf)kfGMGGGj?r$xN%8XA$&oD(@;S
z?J6%{Syr~PJfplkBcr^+cA>DdxTv$Ru(PPRv#_fqEw!X1HI4KD@XNQtw_=Sr8WQAZ
zLhFF)keyYXKXCpv`GT|)SU;fJ3g~**Z?8*<)CV0}m6T(j3k-ELwdhf-i_&BruR98W
z9{~yzmN(1=o_-ykF>Ha2NST4|z*2De1z>Jyj!&3OL1q-v(dURVD369(xDrLKLhl8nM^s9K99_r_0D>&uNp|x!PMZ96lzJLDEyD+>faejz$rN4_h
zV%Uih-o>(!Tq}@G`WM?TcmH#rMqz^VK6vThv)?vCc%Z&CyKSa1)f
z^{^8oybob{D8O0!lZ$vk?|1ut;9W%f3N%mT>pg#$Ao6#iKOspCJ2BF`D0xRNN8-$smtmxb%QjQi{OnUVGu16)q53$r>`}C_b+RH5c%sbSD=05$nV#xEx^5+@p
zQM{iVv#*t|YPkGykhKHF7A={n^t^t>XXx_`LOOq50@@CNl}d|oX`hb?%q5aQBo38h
zbQA5VbU;66*;JM+b{x8C@y!Po-gId3&Aboo|6~iqD#?I+AdWGnT#2kU
zfNuzJZQxfku4HX^4{MC)nLUYPB}=@sBm=o1EFLmj;Tx8b>t*sXPN=SK&I%GOx0N?t
zlj|kkIjU?q7JTeUh>1^EehQ>8qB5ysA>v`kf`{#<2ZIEQ0MrWb7P!So7VJA
zui7@3mW2?oLM-B+C{Ci(YK`zjj6@&-exQCyh>(yFY!BAK5p0Sehir-*a)+&tP|IN<
zz9=i8DwIFg096c#gKT66$j-8q>ZJo>Jv9T=T++(wQ
zW@~>~yk_jg((Ff`ZwWmT$!u)BcIv$L@XXtL<{lAWnEcsoEmh3`7%<_m_N+1iS-=cr
z)Fpp-1V%*PJm7pFLm;2(jHWt*FmVUgn_*{$%ybo{L$S=!Qg`^YWPK1i_#d=)S%Q{D
z!wcM)rcA(I94|bj8s1E4(crmcq*o5!0FOCF5|qYAj}EdBI2ix=kVnCV#e+70+O*Ep
zc|*NW;wgfs5B+EnW%x0@;tF1VjS~5&#DX
zJ3~+|kO+WcE=Au_rD*OUgZUVou8Xs}K^b>S$a@;jgT;r~l-=yhM`hvO0U^DlrKJRa
z(zT8+6f`cq#gF0mz$NE5K^%BpKB@gm{eUc8!a;op8xO9A{TlNx5eqQ-jb~hRKO*bd
z7U@DG=%Tn4viMRpWr8FMYBS_tE|;q$ye6WwF9k8uVlwPn*?BE_Ghrn5GMmE?zi6WT
zs9bpGA$1VnGZ9xrGuM(pCKdwjQD~!u4v~c|M@tQ@FPOe)^Q!h~sacy?_oCJf2Co}Y
zzPdfLV0wJDpZv|}wAr=wv*MfMV?C?dI@&*qEsgazJ~KY1*b>J3p-+1b{Sb=y`D(sv
z6;Yj}VDJuBAqQjxs}G@oVXgwFk++erLa+og6tfHm;uqyjROH(Ur6gJo-NT7G3^j5L
zCE4jEv;~Z1d;j{~hdS3^ryZ6ni?gla6{9-d{cCnnyWDr{s*qhb-LWREti~K$VXk6L
zhOAOSv~8BpXrm1$@OFP8lhc?8t_M>~OD;EiBC^c~#1Pjg;2KU1_J3U;(9M)uNUu*)
zLL5?^#wV)p+TIouw?!u>M@J_mffdmwFUoz|8|V|?AEkf!`%$R^zbOAGyw;cbl6il4
zAB1r2%b|W@3~ev3Ie2i*wfpv6TUJz5hTtK&@5m;fo+Gz!^4WO%@_AEy>gUa?_n9(}
z=wSor!K;s|@51jpom&5xzE#znCO(YNtp_AyIB`-?mN-so6mrer7%ss0F=QKnINDD8
zPE+#}fkCEVUXsd<*{~85PzoPrtthf_;><-`=hsK~cF5nD6R+OLs}3Og
zKp#DYJ{m1l@Da%ehv<>J$h@P-o4`6;VI5)PiX27YY9DW?tE1UyHiVyi3eZ
zES{;VwwGsj
z@GR(i#_%Fvb#5aH^!es@c#xvBrA3@9sWmDw|9Wi4e!#igH(C@SX;{!Xe
zgjgG{otoWFBcB6cYBbZOPW>{t6-Y<5hLeUK7D5_28L+`66k{};ODLbJ*#bU6<#?Li
z#3g+l8`m#xo6x)3GpBZXym@B6wOH;uy3VKjjtMM!;M4R;%Nu@E+d4&O6ylRJ+M{T5
zpis!iB!dh~$b1ZWg3C2B+1aRBFe8w61DpZ(296>2B*+}h6LsqIg|9W3H8fo*Q@X8v
z!rbQ0-lVL?=&gnqwexu^y0vH6q=M37?Ro+TvVlDLjJ+NF5bLy9kY{2vrYO}qN4O`K
zgqP4)q{UGw)r(jUCJZ$Nn|Y~U{$4U8>vaivo(psX>RAvOIu)4%rz7igt{XRMdU12d
z_Bp9JzS7qD4P$y2sE6Vb=a0?gM3r_Y7)OoKe!)t+3$%@5qQGn==$F-?&EIKzLr5V+
z2kHqdapGjGBwVII%~|NXx(;DRk5E22P8UyJME!s)tsqfb?HlLU6xG`q5K%EcN`1H6
z({r-6M@%d+CD42o66FWk8bb$c9acUL*@SI`-3iWMO7{#$6Q(0PO!PMLgNyAL6%j55
z+!2Ow(u5!mU(sI;3pz~#XAw0(6QCKfH4D>xM#~;aW133a+P147T&-Pfl*J{o+CGE%
zUftVEbYPC2(avJs_%CeIP8+E0z_0f&GZ+>f`n7yUOw{@;7S@89&jvJ%vy5lD?3i9a
zieSY-`X1;ySZZd{C#<<7esJc3B{L`}$f;Q}SOukEv&CQzmTY&owBCK+gLf|KxZ{C)
z)pzf^5A~-%ec%BW#)61`o7MKNw!+?vadYTXZsRu6m}R()nWB#wWdIB(Zq`=#$NJ3}
zwRKTI#$3Nqo4P#>^`Eo;(rOS1-aTngSC&J%=U7y%ub3HtrcmPZC~c?>?HPdQxMEkCY}M6k7Xv`2~oC2)eAhJ{*D@k!L=>l$O=-@
z64`dv-M%Og)cW{-`Q!EXaNO<3dVvonhfgK*Hi5zlCLXN)KYu7Zci49*^
zF({&JTDUyFY{7=L+7XYQ`Kgsr%d}$)A*&=b26}G)D5H3R+*dj=y_{qg^BgX-6cIE#$9;)cE#M4{k
zq8fcdSYuvD2MwRLiT6WBa-u2SobJ{OMrQQ+k-acvmmg0n_@JDYpe>)W#u&D!%Dp32
z6OIS!49!$+YWv73)w>qYs_=+lhzH}
z9ippp9c#0_?QaZwj`mk)>%uG62yx&+$VSf_eh>LI&gl~m#%hcFkJ$AvR|!bs
z(tjMXD|km}L_Omq*8)#9sL4i7rUPQ}jPQg6y&|bDgFu+ISyP(tXs=pVU9-OO$kM{x
z*v(G`l;)Ham6jG%8y0qkZ+&h}`t;4!FsiTJ7d!hZ?fKwQ&65fm=FG@|b!d>zXk@;3
z>zTy10Xxtp1St-+g4w@
z!LY$=LQzpcAW&#CvWk5-Ffn+qd~*BJP0O||Ye8h`zkdFv-zX%!P
zcZRcqk8^f;dSFS#kf6{s95yg+@=c$v9o+Jfwmdi}*tO*cB5K?~dp>q!TV2Yn2Y&te
zPx^~{4QI8)%!kiwkAF4kw1oO0wl4XM?OVvNKf=GtAS~Ouwn7*k_>|sl6G7)qD8fBx
z8^-J!!QPIGhuVg@v0b}KyQWu(T`o@3C=CMI-3mF!6VLMzQu&H%_D~=%Qmb_#2Wuk)
zXb5oR_6_2ubBCiAC1=cmNYYN?qV3T$_iVp;`H`*nD5Y@epJFSt{cMxAS)4}icmXuu
zuKoq$cN4t>atCO3EY+cSkuIy#7cQ%FrxToIhzxWI@*Jh4%kjgNI*!S3m!53}Zc3ec
zD=Br_6Rf(s5QP`XM>nt?^gN3;dpfmjE+b!AvV%Pi+38)wOfY6M*|BYYEP4BO?af|;
zs$C0>@f3U*>#50D2Oq^d9>P0D2_~VG_iQereh~4GMo`Hns0v8yI+DH96gxUHI1pez
zJ;=&Alc~}=Pw=5HhhgEN*ncn?P##csc$#ewuI^=?i`Py|i}&>2?x&{Rwm700{HZg>2Un-2hvfKq_)Q8*Pl~>Je7q%&c!2ZNUiHtg(>ml&jE~@pgo0$C#ZW=Y
zhHyycXxb13AWsrZgt6r_(t=%tLt#J=N5eogvK1FLUfr;=Y;5=5gOz0!G5Km!ZRy;G
z{N|Xm;ztREnKeKZU>tsmwc=*1uW>>?@1gN{8I5`mPhhNUq8DOUg9Q!2{FKR9YNJ9i
zji#U^Q&1w=^Ed_Q0>^F^Mty?nFjOeq3DwetQO$2cMfHY_^A_}WtC^WoCuDW5*wM1;
zhK=GhOWIhaYD`=}U~T>6nVnVU_`K-Rev#F+P1DwtrW=jaKUZNadkk*_?>nB;8SjBk
zLC!Ji9`pyil}-f*-NVp5!2z!Bp=8pe>Kj0p8fv?+bxl*CF)AQ9Dkc*eM{D~+MOmz-
zr1AcE8}B&)ox{`FAE=>2d&?k15%9M`ub0ciLa7>pTs|$h)ll;Rt0B`4AX81C_7%HV
z`xhpCJDatg=MF6(t$T4Tio&
zvvM24!xv1tdTns%N8<0Nccj^lg&Wsb6)ajjZURDW@t?={;o5WTN##Lq-%CPNdbu!F
zXcMjx?&obg8qG$>C^4gz9zP$*8-`GW9NGh|ND2w}3=i?_L6!{3!<%_~upZ1p32Z(_
zh#5U5W{EFT{Fy;k3`<6_&?puj5*ofFka-50(_A=65Ym&CX8y%_@cy
zU0t{;BlUZE1@2j}sw)-ui(lxMU6Y!XGfsPM>at~16N{|!w^NranVOWJf9ajZtCvl+
z6i<@g(T@~ddRISE%>LZ4bV*}kK|x~Uk|m8v`9u50*?JoFN4B+jBLnPa;iauOM|
z;rj}Zk(h^=R08ZJ8yJc=ZRbym=7*HnO&6{LT~{d)_Ql8v5h6JJ?pP#gAEgx?7$?v!
zwh??%Y&0RBDON$IV9xTHD9=HgDjv#tQZ<_gu1ap~xq4~-H9b2DQuDUlzhf|F~5m~-$f*_noL&7=Prz&JyHyKygt
zRb!9q!@WoNy^;K0e~<`<;x{9=zGLK}by+gCBmGSU^0UhM-I
ztgmaYN}aj==FM~0r5f{rrwPpFAAt&4I1E&?E_O0?S!JvM5=`67c@NS9HjVxW(fRFpT{ih0L
zf<+vXvf728Y>@xP+SOR>ISyF~(~Wol+z#1*WJn1-j}d9aSCpzUnoN{xh#G`yiM0Rm
zZ7?)sjjwMK+t=VduU;(n=Ba1s@n?DdJ;c
z%FY4+fkaPyu);6Ch>!2+@Rk2|^wwoXRaLa)$bS>p@O}x@EqJ=6DvE%Pm-UZIEDHfD
zmi!5d6IU~&3-wD-3KU)qs_dh8rD4Rbj7OA`vpZREcgHWCed@^)_;Lu^Zg$YNP@g$u
zMFVXk^Z|TAT(|>m&|%1NbXhm)mmngDaoPsEi!w%B-d#kVtIJpor%&%UH~OA&(8n#o
zdf(~L^I^P&Ne(`v&ND*WlAu9e-oMUkfM@XYgC8}}4_)Z!L5~r8kl@nJ9$atwWrte_
zYKY5rjPGw8G%g+NL(q762hscWP6Ul`v5nM;&THUS^Meadr*W_+DOuCyJ;_nsPUbtp
z>2!6ZgH8csT>;}}2qXlDd_dbjIGeyXB8ub{6n6ed|mWJdP+~#vk^%?
zg;032Vxz|(FL6*H{F`1L$QLSvun->?OqOA(NZ+r_Zg8j2tCylIk0>
zrd1hJy*5ZwwU-@pUfq_Qlrd)Z+T@hRmM*Q`u>kOX{?GIqh<*d0Ed~z&W)M(I8BSHu
zuX?JDVW1zPc~oRXs42|!U(hcNo#7+Wzr-=^hmr4C10)urM96jOcNluYh&X(gy6{Cp
z0qvBX_$i5P1|TrtQUvB%k=;QefdL-KoW}>ACXE|1gfLND(}bl|{1$UadvttR*?9bk
zCdU`$RiKWH{1_c~{2@-uK^NQvAJh6QY?Lob$fF7#i513PflH{D;a*Wy-OHl3`FMCs
z2!w7l8L5vG6O3a`V@F4Z#)icX?PcE|X>?e|ZQ-{>;nbt&Kr$#*S>n6~GL{!XN(Z{?8xTtB9VIc*^r3
zbkC4yIq0Ka`N7x<@}h>q#+c2*^~|(tm@D-x6Bfvq#1o*+>U*Fb3CZ$tF;~5v
z&)WZ^IED18b(NLts;aN8th}~5ySh3%tGZe~Ue;AnL6Hj;6pNqih|d2g$`oq(>T$3bbljTp
zY0B1x2X-e;ncN4QJ`-+3yyHb-ybvTfq#wd~$C9WoNL_>S10XRZzsl`!3h|qd)GI
zr!>xL0(~u7Ez^J$4sxz4z}diY?{9!vZKMEl7@dMxkaLdfy99ZybtE0Wuw5A?nv&qCIs5x+
zz>mD&I1sW<`dkgTsHcd5-q|Yu!S16Xd$u#)j)DXtU^pjxEZ|X4_qhWBxMrYm95E42
zbGF-=A$VPSQ
z=(Z8hh}EzIQwqOG$l2t-a|w|^jv=^@9C=X`vO(cImtO&=I|q#gG9ki=l0!08x2=%7
zcwxJ9gg9|WFu6LyLsTsxCM`I>eWEcgF=xjOEn`b#O4=u=hNQgS9{C#|&&5TH{H$r}
z%6-P*)kX8!q*R}kJHU?vvGQu|D?>fdMMN00AJ&X}cJV$#PG3;hy3`TfAfQyo{3UiHV8%
z+j{2YHa27>W+qn?4+C>TjtUcy@PNhF<0H@J+;$hOpQ~gU2QgxKjSf@6CY-e#;c4bL=Fy^Y;W6}
z><6LAag5C46lcI4jREKfq*kP5VFg%GQD~1u6e?K+l~xS6Lcv42^g!ons~mb7(dclo
z1O^4TyiR5doV`X%X1KweW3;X8e(kmHd)QmYx?g|2`?zMYz5V{V`|dmU{wbPo&jH_c
zkNs}QP-8gH1yL{qmMMckoB7%R=EU!!09>b0Eb6S@hkq#)pg|Se%
z;t-lZF!)TZdB%nfGsfoSjg@D!t7gtz-NLM?NyR#!^~wjdzpHoQ`J(1J2J%4~|C0UP4ee!WG;ce2TJ-+|0m%nFglusmNs}mq~
zk)_T4S3cTo;BDJi*fXAlz2C&gI@~>JgpY2w4RtXVg(X=s4SHmf!RE8%j>tO5YgkzS(RU!0v$T%3_*)xK(8pA;9D)IDR?xCthvzf??V$Lz!)^r!V)v>YYEp^jl+8d7$UzKQ!#TV>{AwNA1
z`RPT6{Pd!0zLv;G#TQWDHqhyJzoP!)hpH#&ZA54~^_O5utfObG=g4tcKBfKb#q49r
z{qL-~?%c72V_Cn|K9P^k+FfgD%$e92U%Pu2#+6-aRVD(fL~$w(9gJi)D8Tq|CRCyE
zcazJZUHVE
z9ZmRXq%DQC3h9hnfni4EL1%5)Wx+D2)IRxcUe!|h{OEc6LZhQYCl<#q>JEvH4#}f70~dV<
z`kC^aCi1!RLpkM~qw2~l=Sb5fzHls|@7;|X&h;f6%lQKfKY1^U_`Q5bd{bUtQ+&g&
z88dd_Rd`pO?R9A?_8ZkY}N8_smVSGtu)xV%#sQyKAe1XvLx{@Snh6UgpgOJC2IgKXI
zHrTV6w2iEwU^09#^1zWPpFm#*N;bxsO?7U8U^cR1ikj`sv%7!$obtrpy@y$d_BrO0
z0Gidwm(xtN63s%?575f**J{+s_GW6UAZWAbQ>@cw>G$I|zh9f>XsiAOZRKBRO%&zZ
z?FA~$4zk~^p;p$M4#uEafQtrj1&O*ZQL$*UzBp`JHT}dj^4`0}9
z1u{G`A|%Ka<704kP_UU9oJJyedE6yT+n(poXx|>R=H=xTC(q&lWD{@VSGdm2M4Lly-k2b@UjA~HsMMR4gcW_
zf2d8FEawVs8aV&_zqQZrN1G`7o7MKXGuNAp7HMy#k1s79pE7CEzO<6!j8y9+J_kK)
zuWNJcC$Mih*0{8YaLW8o^N!XY)&>q_jpI^~j!$=ND&%Sm4e=90Lx7Owp9I-~WK@K@
z4peuAKB0^lY#y_hl@+Drrlb|5;s2HS0rPjK7N+9=_!SpdHfB#&etfJc(8tr$%QrG&
zbfh<8Hhq*jLwagdgqH{Y=Nldt9vvFs85WM*PKeKY+`d4Kg3PHt0#8LoiWIKI=Y)Sc
zMO&*~s(Vb0+VbI{EeL!B)dMZ^TXtWJl}k^gJj~&|S@3gXC7~TmY{e$b^n3$oCj8gK
zPK*?ZFJ*=sLv6Y5epJGCQpXoB0O&gp64lK@D<16f%O#zMLdp3#0O$90rp@n{$Xzh(l*Tn7xQa9XNJ9
zC}c@Tk7{o{_#mFY8_$31|M&BQjT8cc=MU`O%c9%nzkl|RyL#N8f9Wdu4??nX4y%c?
ze+g*{?=l2V{882{VpTwjI3&iI{v+7P{ku-!(;nyHZ-1_mg$G
zGiT=FDVHkn4w1iO0`DWJgRpAxcc7OTy#t1693nHRFBQOrWfEwkTg>|1WCXO`)xN{c_%&PkKsSD+wGJA;FfXx&9I)@jZZ@0Vt8dO@$a5_a
zs04G$&A`-3Rt^Y_QQWglQ_g*w=x0YVGARZlL`P$=?Wg0Jq46ik({>O{a}E9Vqo<
zZ~@7(BcDfdnFNy==`0<|Wikyqtbj{jRA^iG=2>hDb}vY(8kJB~V2a7|UMVNk7U#^H
zmYiCjIk)8d$ne-$%UE-2i!~u3HgYV;$JiZ|yR~;9YnpY0;bY0|-|qffXTp68wTpOC~6n5EpSNb{J6}@k*;1ov-H(P;r$y;#GiBz$q0oW>iFQ
zAYy*Nc;rSjxI=!
zuI^}_T-_8B7$?718j}k|(%k8#ZPnA_;|dbYax-hGESuF>kcoU+14rde+S`Wpf>9{p
zJe(5^DHxSoAQK1p!(xrm8Lp;7r{*(qOjJY|;zy0l=oI{6&Lyh!4GZo!3++rGm|J&A
zT(;d@xT312yQcf(HK*oU3U8P-qs-d0tsp*fRJn36ZBf&d)syboee+K{B9?`e?b^_`
zsIGWdP<;?Geh&OWdWFZjCJ9@K&L{jRKun<#Bf~1PXY0ACfqn#Re-p3?Org33U@HXL
zVDxlXQ#)}RM4i;jua_0J=}I>sjw~qN6qFJe=86p`q%+h-OBfAL4w~&44IS@CaYw|X
z6gK;X9FsX~hFTeK4qDoNdHn1u`Bj)An1W=*Hdt0kHI4$i4xUEG
zK^{SJ2Pi{4m{x=~gsT8d2n4VLHKq_ti|xTb-h{{_2jCj$b3XXgH71K82L=Vs0EW=8
zfk%5M%wM{G{nEub9=nV33ks(tSmZNOnD*fW#8s_3Iw`#cG1m?AYJXct?R^&QeNg=X
ze%>f&OuRQVHDn?oWU~XZ1#&TLh^khw%@YWl$HXhNT~IAGe_!rdgZtktz!_x{Xb#b8
z%W-TWYL1zYmhGYI*0K(%+&V9#G-~m*o?VALKg!BqB=_C5Dy?M7q_U)v!>dEK-|{)d
z!!w%-JoEK9y76pAayd}!k*|rPncf;
z8%Ed=%@}>wdw59m1<6CI&qxbKE}(S$6YS+OqEZm0IW&V?zWH~wz~nNEXJ)&9&kUsN
zwA5TyG!FbGhQ#Euf;36oDoLTyc?IKTBsz6`*-B^CKL(F4n`{d6ORdx{vCOy--_*%f
z#_+(glkM88Ml@O+=#swCehwZ9MAV(b3KT%{C96ej4Mb`zVo>BEvVdtwst`$HfzL5G
zC^8}_CO9S@#Tp&%^c?2|rf4o=-C!r{@+k0uuI@?56|jH*wb`lb&N=f1r2ejUEfNMC
z>G9cgB=A`_W=_3(&H!=X5$S2|jQSU@k^My9srj7sgO831q5)>dP?QLB7DptvBaZm@
z7z%R76y#N8kw+;iPB6&xevPxyB1v}iAcKs!?Ev#<>XDq*da_e
z+=RJG1B!WTd*$-jcl9rN?7Q}%6n0tcJK9TgVze{<91JLgd4iKdF?Hd;!0L-#7Jdr|
z0F=UlmJ}dK5<=sV&C(I)J(T|u3r283z0zK`t&^>F%mmZ;wNr!Qf<3(>d}GI}e;ub!
zhq0-)RSOrWawk(#(&=@aNA6bNg{|;feFO+S=1;;(AZS98Rc!p#7=a;2EeyPcby43C^aKN(
z!v?xG#PudkNXhiw-WfQmF*iDLjhy7Pefn=DK2_^i-NWmm&~3!Qr;R&h-marMCCh#hqax9ZPQA
z+9)a;S&P;e7e}GxtgO6T`#FW0`{SK5&(HF_;RMgmk|7icr`&q*$L45B0T2hURfzyk
zgRAzHvbAJ5v$g!tUBmOX1Qg~Y#18&0p1sB8;?WfmET-Tnz9wBEdyB-^+t3iqddD|n
zxe;=@s9wF4(^{*>FKveK2|
zL`KYPpY}8KE#)_{VAZbSqiThz8n$apd0K*Dj+78CP7@w3F${Mb3JL%-LfK9-#*a--
zh>MMO&wAqJlo^4X(RUG=+PpN7E`?#A3wCRxdwXq<4aAchC
zY)jOZi1*o#{Lk#W_Z>?({_eGF-#MOqEaxQ_(SMwUpVgMYc%IXc*tl!@w4I;@`(c&l
zZy#lUg!@`|a(?nL84+mDY6%1mpX)d*X~`rsK`DWm4B9q!6U3i;DnMN#=1@_QXUkwv
zz-NjGq2J-5-$A%?g9l_ozvRStO1OZ30$g&!pof(lz>mDFYE=l|C+Fm1A7QOUbtPg_HK5Lg5c|YVW=Pi;9RRLw{jI
zg*!kbvw(AP_4M=%^b8D%bf&3g$mMLNXp?Yf4(UtD>{qgkoXf9Y(B3(5Z}jMg`?WW0
z?{}|YD>ptf=V=t^e9vlSaat1XOS9#V?FHB?1BGO(1t4k4O5-p`V0JqaL^-Q8v3w(C4zpL*`x)YsHw>T6Tqc?VzQkH78NeeLRfoB#dqE&Erm+TZgn
z*_U5e?+`VQx8NK31zyjw@#2%rfsJ=R@7*%;d8J&`ls^Jbh}@I7{^#-qF%TFpRsA%C
zet(PKHT?Hyt$2WdNK^3^X*UE)XP!t%UOyrh7-@ttU&UK&PoM4Ymv-AC@vJU9YdxN2
z7{u*kzoYb;coUTgfMlma
z0kWCrShI+5o=1dj^Nc-(527*N4Lty+9(U&2J#w)Vs13si~gTN&jwn6*zAbVB^q*ImL_on-I+kHhoN|Sa*}ONpI37^ivCaJH&fk!(5#w!(1y%M5036GwiZy|uv!BN
zy5uZ?bIMae8CywiBbWYMsV*$k3!1AAz(6?>%ZdTn+&XJuHk#mm8fiv&f}6Kh4Vm$qLl)H&|H!nDQ`Iqsl$jzCN3^N
zI*fQ>EL}$d>;Gm|R}mIiRm!@`D^`}4uB<2r78p2S!UB{2jW;`R>6jzYR!XGn^rm2I
z)7>fDosXg&!C;y`3JU?C-4PFRpL{`T$DTrd+juU|L_$`?Ly*=PpJYtIwBf;yPVLc*
zS`PRVw;ek9Tidyt*1V`cPRGcf#1EUkXK~q0)DCJh?N0(*_vygwi+v8fK%Y}~8%WUZ
zvX{WlK1x7Mo=}M8P`7km#QRb5jzi}a@S?&LBroEQ5Q91gGkBlX)VVB6nN(=djWHK)U|
zt^|>oOrfCq;GsY&RurCjkRhQo9j4GfL2SbWCo7%*aAY~Jxoy@*5*Vim?AWXw4O9<>8Y5$x!jV7+f3U+vb)v{&r&u}>Wu
zNV-_b#jGweHXzI4FP{<(hM{<*7ftF&?2xAvPbvdCrYP3tC1wct#5*M-x{
z!SjU=aa-tThPW+|%NDs_VL&s64pfN`-llEW#<33V$S0rBoPdAtm%xYZEQ54z)#}i`eP(_!PJg0jLBWrP6l5LOg?K@UH
zWlG7h<9n>xld|&*^W|S2SvzsY^t^S4|4}?Px1c0-JmJ;Qe_dLY_D}m<^v}TopW*mr
z=oPWj2iin60@Z-C2M*&F@Pw^SI;UL=f2^kvX65+y$ut{CKqP!Sk%oE8!PKF)I$+yV
z)1`A9+g^R^jGqmUrJ>*29I4-41sZ^>(rExz*ny#jEOHCx%V)Yw$LC8b=F1j7Up^Z+
zeEED47D9Y}6xVwRMK!0%{|i?UDEu!m|1^;1LA-z6_TtbK;Qgy}74cfj+U}`(U!2sY
z*b9ZbNUyTSla5Sa7Cq+$yh&v6BdM^B+xw=O10*~(9z}@xOw6WvNj5I6>-YQiMO2Im
zi1wAms!4Z!ckda4p-*8m=1_lYf6%62Yo>Rg?hWkg40DR!L6R&u#K|rYioh=LemP)J
zGDboITOdau8GY~~${ZNBZ{P2Vt3}x_+CQ>PoASuX@4lNchbeu?0!L%pVf$K}2Ytj(
zh_f0ghlfK2z{`dtD2&VCZBe*tJ0a7NlF-(1B;7mG;At5%xwS(3wodGjJ!Z9o&umBK
z>$QA!6!<952B#|@$5liA+h4Xub*R@@u
zW*>z$6MNI;e0%8wb*#NteGTUg`2L!HzxtYE-iwz$R?gbD7_Q-KI(n5{Y7~9_BWhV=FKP83Sl9KvoDcK5_Xdd;)0*d{MF;kG9yrjs|3K$KFc|8t0EO_WAp1F6qn)INKlC?~(5XkrfF8U)#4pQe?RjRT6E
z)3Q+hT>RKD79wScFz+#47}ZHa`E%~$fqM)t#wElFI#Dt7RVNEe-&UNI;6{^W37i3M
z9QNg4l33kkRfD^%8t=B9uuxq-Wra>XO>oMkMT5#3czdDRDt_#7R5EzSqWg#G_UJSo
zBW9r5j+C9e%sL5PGkF_qJWPR9tqX^GR@fnh$VZPR@o;p^=$Pm+?!|2=`OOariAc9$
zU?T;M`5k=x=T`_EV~;)hDEb_(8M_)j|K$V^J%u
zWl*AcsHsFQH6E}$OqKeN0jO70soeO&==S{zYF68Dm1d~Um3x_1L;Hf@Xb4^o_iD(^iDj87l_kTr8B<$|Ux#NwaXb|9-LO?U%LVg*o~7Z(_b$#|+viE9klano8W~Dz+?&t
z4nW45!H7>ndGM*^)EFrBA(87I@p%VyX8HZWs86B6JaA|
zY7DJYCSz
zM4JNk0`rEyxuY
zMP^QBI5Xmz+rqYLf2Ie2_@Vx2^wDNskhNeT>#Tw8IW|hvdd$~^U7Pme`ugChOM`k;9`%UfB
z)}&@bwQql~dP@1>IRFTtPA}>xnGAkHq)^N)Wm$TjE1I`dHlznHqs0YYKc{Z&@|Z*l
zkqO2KSLK0V9b!qYV@eha_0<95QVpw>Q~NgbHE!&Fs$}Yvu&QNK%Dt5@HXdL8*j0<}
ze%tg7+t}1yvZ!P4T(yH(&jb%UE*xlH-{^0$}7r9gRb}TCCZql}XV|x4U
zMav#veuDV)Tku^C=R4F+9D(l^%ZQ+cPWc(iRqu%HL
zBnFa2Pv5X%ddkF!De^4DKsK*xWkqQz)Zci&Rn}wwRMK(rzQtMylem8OW8giF4Kja#_^S8jJlGS;df(49G(`l_YD851)@jTR%%63g?)@oK72k13lRV0dPhV;=D9WfP8z*5mV6bx)`Y>kiB-
z>yF&iE9NvDRp0&k>#q$R>lzztGhTinIq-gqLafv&z6v}quUdu*>WHs}>ww!7
z-SN)4;Sjy!a1&AmENrM|i`kHq32-ff{|vF8{qdVK>0geYcTf&91^C5;;?c`&H+Ogc
z(>r&4@uYPyqrh=)e@(tcMT4EO5#h)zvVF7cw>fpT^7?Gv|A}J1_@Fcv*Zh#}8+HvU
zpqnW6iv7K`gbBOFeNvDV{NwK1jr;a(-AecMuzqHe>3!kW5IV38lbt(|xsjb-1XpNJ
zAL|!;ZRs`K~BG56HsLw=myLs*1CHpjrYqP
zUsyfm(%srLzuP=&XD&YW?TJUIj|SU_n&ZT5)I|iZ6OHf;2L?z6aFLl3E&lCMys-u}
ze%z1yo@6JHZ}xHMPq1_mzzlZ*SB(l9SBy0TaKcQP8<`GIB?9#@L)z_`T9@D5RCY~E
zQtr%@#=H$P%dVN1JfEE`oZOX`mTAqIu_P^x=xwt9QC=(WLZ3R~c8E8Bu}u2u(s8rp
z7M(Zm1i&jyZoxb-TBB*^ny9y=Rx)E5B^r3g3_;s6!8PQSzw=Si|1r5`ph|l3QlH&!
ze;j>LAh(DK;u*Zw;JwVAY+s5U9l?0^rF=(c!fY`ErLw1j7VsBslvj-Bz;CwSqpG(v
zrLY$3SFmSAwe_~{a#b#So;H@A8~c|2ORWU?I)y5(qZ5S$&|OXk=q|{e*4WMpWk@n
zm$x;ae(08;Jo@x8rZB%7{(8&JA8sNxI|`~EP(H=m5QSq+8g%hbq3$7Nm(64&dHXZ5
zq)Bd8f?xVR;?tQ^U!Rj(UoUBh75r0uZf;#&ZZ1@1^fN3kN-$qLb&nE@odVt#T5%F<
zfHRq>kyrqKO7Nw-pB6uQ8in27P+%2y*Ow!OgL*VdG-PggSh|2JB0xU&c!W^6>3F(a
zAERFd&FBi3S(BVs(DX(JY#O{0Z`Rd7{$!Hx5k?UaMk4c|_BWz}?I!ZO>)F18V*e0r
z+u?PPg!knEX_BFpj|ay@jEF(Vx-wb>ZcajrxDSoHF^9bb|L1p(^k;0cZO{=Ap$O`N6`V`m4mRZm+bGv@62YAzxATbup;-ov5Oqto
z{v2qQgm3sGw93JVTfCbIN8xY!7xxeUhy9h2cG^Y{Kq556>khg1ihCtS>L|e#T=yW?
z5$wE_>nOo$hRMM#&k^<+T14AupBaqug5AOCk>drVD7{fl+wOUFkCZK#I(l4iLG)}&
zZ!~#pRzUjvB1d{7X1{wnZRc)%R{Q?`YZ~fvoY|F_
zV8#10*(K-_ct2H0iMZ1HLxa7&gh&QZ95=Ec6jjFi{bbkcSxVc^_S+u3t-WUImV;ZS
zy1jmK$Fy0ikFVSE==y2pHCfKKBcId^rstFTAM~-FLaKDp)abMTN+C4^QFQBb0>;lM
zt_$+)2{dB-X)FOE!QLEiH3jjp3^V{Fi9vN6N`GHU9!c4%977rJ*0q$Ws>#a2MmIHN
zyEUw8Pt(gYcU(0$+1V!hJiN<*w6*_Tf9m5otUs&Tb~aqU@A`)Bs;(Qls@&hYWOm!8
zGn@APdP{RfO}6`3&S1}Bw)hgyLh=C5dci3%yrD>I5BIjSp*aTCA$YLRRqcPxN$ZSG
z56X{j)MjjIIeX~(Krh~hdb?QP|7p9bTPIhy?p?a!?nNaNN(S3yL)9<)uhjJrPb+l%
zKsdJKq3D7CkG%VV?`r%X|Np$M^Z(UkvT8Aznp&({wfrfWuIqVS=g;fB
z&biKO=WqxL{*eSXYYzPJK>bD^RBj|cW0rXOBuH80*@xR;gY|Bb48Q;ybi
z{?qx3e{Rje>geB((sQ^8=AXlta}Lknnf;w}fBzi5e3^e8)4XecIQQ5eqnslH6Jq#g
zZv6|a;B|ZOc?*10X_`iQpU55;9-lcBezww#<;cCVH*{bg|&u}ks)BYj*
z7q;BbSQGSn#xLUvjUbdq?G5
z(BtUHBQG&$Er{xNc*4njPds%&_wI)se`>$+Da0WDU|njQZw&hn+tu$Y%lxl&_vh7P&oe(sKdNsuuHty*
zpNDcyWAZ~TPD9y3+-fpq8nNh5nZTpV4|woxd;k4Ec6WdG&pHY9qX%>jDtW-HS0(o4
z|Kc|%{?|_XH(b{TCiS1wx4&6`)%kY}7$2WCF1LMs4gdS=%L=Zq1I}x%4z71jOyu|z
z;$FGo{Db3p>bL7}4cUI)a()LI9G<+l`@XEOhq-ge#`pIEKi&V=S--yvTR&%S_g^!a
zA58x9pH5d;2`b5?zl6IcasT6_ys4o>d-zfIPT}~*zUhs-uX%5
zIEZTXVtNivjto*}g0@31?|g1^K1k#_wk53~*a@dMby
zXd7ufk209CUGR-Jt9oJwJ
zYEh5gty<#|_mh@_)-tW_Fs_xf|DKk;9m`p4dy8#vv+a2<;WV3VZ~NQ6u2p+p^EjR#
z%D(W&^vyii*7s4{kZ~)W?eS08PZR3_=FMrHZ!Tqh{)3kNf26%EgUx#+jPJ2(XVkxE
zJM0I4OUsvkC!g~x@p`hKb`<`X>5lXMcaQsZ{Z)G%U$Fo7!2tH>*|-{WaT(jJC(j1*
z^O5I(wzrja1AIm!SpV(9Sk5?(OHA8$|5?Uj-~3y8lJ8m!kZ`M)$X!~c`~I{rb+-j3fd9CzaW6>GkX{KH+0`~0-Ie6Lo^h~Us4|92+w
z{08Pb4(rJurm(HcQNlj(O>3H;ZkzjewCAy0w)5Z7Ll*E#^vVCjR&}i3IY%aUWbTm6
z|3Ry$JAhT@X86O7ZlP_yd)kKW)BkMvPnN^(BbnA?|9ttpGbPh~N-~2(vn|iV4FBh3
z=&xm`{zbxcIp1GZ4AU7hZf7wpz}GT1I3y2$qaDU~MW&DMgW#}z`X7dQGQoTq&#``=
zlR|hwYng^~S1^5*)mg%<5aI^sw>)D$=aYq?T+8FF!`8g2N%FAD*ea`3Q%e<|!iV^@
z&C?8j@bfTSgGPLWpWFCp15rz!32pJNWO@gx|K!)xQbj#^`gs}a&*v$UL5x4s#xJwO
zZ;P4H5$!)tDbywH<1O2n?ycim_66f_Vm<4B&EF0kS$qBIme+#o{4eu5=JUtx
zhh-Jd`&-4ctui~9Xl^)Qy5qe6{o{UJe^nUzkx2RRicJpHD@QswL|E8Iy|6SIN
zbH{yrj#sgN_)4*qeI&7^!OjS#^=qHUvqD<)wCDX}*z%UnXFqD!_{aFdwzQKN9;NdS
z{7jlZ)BY;<1>UjN@w5707{B#D=Jo6NtJdSnzpD2?$MnnZ{}>?Te
zpO{l++yQQ2owdc_`~HA0tuYAdkt^YBPAMZ%&I-i&?
zbMC{Cc7$=ut=eykxsLCh8;SRrjC4Mfk>(Gw$nvj6&H~pFR&(n)uJsz@EWcewtQxK@
zCK6{Q`z6QI;BX~rhZ5%@hUB&VcKX+ugSp1!`iEbuS94vT?fU1xePoO^ob=CvX$v?P
zTwp&#n|_si^B3yBmwKM#I_@F92UGs<;5u?uaGO?gd`jkc;al1^Oy+l)OZnsITNt*_
z`)9*LDf@T!?f;cL^cl2%FSWeZHhi36KC<~vdM5Bd2HFn3W0oEg&3iDCK{{tn{>h8N2~
z^DNFS(t~N4_9)ifc*^iQ5dSk`z97T=YscWZ^i1h#ZkM@6Plhk?xh$1rKEH94ALU;I
zI&1lyohjovZ;o^Bl^8#7@OtcEnaeve1_b+{HuAaleF)0%Hz1kz7TUXvvh`Bpa(&P9
zrAy3)mT&&(&kNx@cbue~pUcW1|JZS?CMmgC)o28xAm(Lr=VttyFvG0}nL(VVC
z%Sal84v$!{(qNOcVoGHGvz%#
zGw&Hor7UQwh3Fg}}!CH7HSU+IjeFz-V-vp%xJ@P*
z3;E2wCv#D47IEINh+{(+xz{?Ba<#Hn?`qk>WB8J-1;of?{~spfjm0wGI6+bwrW$8U
zsvgX59rGDqDD#P#rB<~3X2b-?=X2bj@249V1@q7M%jlDt?#VUXFO>7=O_rf%r3^NP
z$}nT93^j(!9OF?r&!;bz>G}ehZd}Lb?`D~0+=j>a%M9NV_Rky0Hw|NH+e(IQ)4H$R
ziqmlyGK{%$opG^T&v3>6AC^H~w?EqmmD7!IN!EwUsX8Dh>mOK82eGW(aFk5(>AjL;
zgs>iJut82Tcp7&5w9!MZAb*Z=m@HM_$P&b>jr?YFBcG9tvKTl1U#a6mIn{nmPIVrT
z)9vjH@0aQJMGQOh{nZ#upYB}C{=G(~*xQ)CNpd*9J=Ok{@x`PUN{)TAoaE2*_^~7B
zv^xTi%Z`JtksX{jM!Jrqavqt=F>JhZn~c}DNrkaT9%i_LB7=NBMO@-;BPcddWRu=NRYd->(_THZFA!-E&w#_3z$i_hBZ^~-dY*0TQ4u57cqb?Eh#H?Xcf
z=6{O+5AizG<=4$Q{AQ=Sy3y?>GkwlkoIji;>;GXoP7>OWTMXC#m7B@6N=HSq+Vu^P
z+y9UzYhB;i4z%pC6Iix(Q)$Nl=7VM2+0I)lk72FL{Uv)R*H1fGudV9ZhxVgV$Mwcq
zpZQs>IqC>!o17
zongr3e!r3S`*7&D;UiwcXMR>?f1$3^X(0sSL2`E
zD-NEQb00O>M-aT9+OmcFnvf@a?aN>;ZQ1X%-S7M3US0dWua3iTmfi1n@b6`HykGU#
z`%vxomj2y+qV{`99OJlt|AW2r#55C)c|Exbzui}1nTYujl*Oh;{1`ekCeQka^eN!F#w9NMYs}B8MW_$lq
z`#Si`&|h9%PP?L5wqSoX*ZBD@HSDh|{C)P{w!`nA^V`Ai8T|dq?|Z>@U*r1Mw0<8G{R{p!3C3sqGQ8eC?GO13
zt^O;{<5%lrTCaE7=W$-&ny(LK{)!mZ*{WYV&S!ZfpX$fwwEg>?zbKF(&KLTzu2$Gn
zRlnf9hJxU}vcj*&FYnLSUAXp%=R6E%m5OT9lcBG5dM!h6zVWP&bAxw%f8)4vLxrZQw!~Co1PpkI2
zb6m-%TnWcHj(h%fy?rX5%R{;E{I<+>PT-oR8`mNS6YpTIJtoN{Kb_BMwV5W9t@kC%
zqI}!uZfp0+o#W!rNMaY!+yV%brj2bIDNj?ah*dR>j?Jq9h~c3!QWZj#={A8Muo+5x*a$
zy?om-)bAgq&T9Yj>G#P7`&~PHUW1?0w$Etr82mW(Q{Mr7Uu~a9lfEuH2jkc-H**Xc
z%KExd(w#?`9w3wb{yD#YDfs#3`0KBeVBaXmCBOg4|Lh0*wAkkVa4hiaXg`j2I3@&-
zZA;l#USv7__2eH{{AFZl`ro(Qk-EGTh)4#~M|Ls1u*;If1k-yrW$9jCEZ96<7ao)QU*XdWb
z$(^M6+v-jk>Al2paA~le!S85??^pJRY=e82^oQly7xNDJ3B228gB;_YFT(<7upUnj
zrVV8{&K)D;{Ck9c{~Y~ZY5sQ&pSx7+Em_TV4}X}$b;Tq(j5a0lxhgcuxz1lDli1&r
zIVJ`BF^E6G;`60v)5k%&-)~^&%NWu>;d8n)m^Op5AJBKRS)zT$-7WiB)=B>UO?$U;
z-IMFDD?6Tk0*)8#!&CWOqz5t1{(LXriF0){ze)ASsas{z&v2$B&~I@IWv>p-|6Jy2
zUt+MHU-<02=cBHfI*K-TK?Yq#`naf0Td=&GJXEjE*rSE4tfpOBCxm5bYV~qQ%@c@XmZyvam
zYfi@o6oUD|w6zSgnBIt6P$9CHS5WPZVSEypw{IHmWqdG}!~aX!XtkPtt2X*BNzu>a
zOMH*%xp8A;W5Zy9)C6C+waf66H`IH=AI(I
z^K0qga%9scV(ep9a~vYu(t9y{nMD3;&Sbv7tVEn
zsRzx;ZE2^Iw+sz!L&p7gi2lBV`7Yz6H`7M{xnO=>&0|o+eAeCX;~A&E=C9%B^ZD!9
zk5PwetS62Q!Z*(Ye?S@B{{Hv~_+|WUT1%c$h-N$qxx{CkHBY7>dFT_Umj*r;>+lQv
zV5;85cU4tuzk&LmXJpck5S)Hb%Kh!&j|a7-2lM!8!EGeR_}ipH<7`s>Qy|NQpR=3Ur?ZTOCP{(h89
zf8}6p^I820b^Knwk32wM`e45u)Wv_A&sk6-y}B-9z&s-dC8$Czc5;LdK`fGxg<`O5
z=0-GgG>F6qq#+;WpsYpP>`){k6Gb4d&3@sy2uD29QGg26p^09oFc6cT6>*8lFWm=`Dm>47~w_BjeE$V81u$Ry-}JR!8>AnHAcdJiJjL6kX&
zSeI(ITTY2(0>4!uk8TlezvPBLJ2Wf|vi-b{6SP_UF)&SbjH5756
zOxFw)ppjSJxQIatazH)ZsHYqCbfaAN2qb_q-Sbd}8tmkCLm?P}G|-Ola@1m%NDu1k
zL47@F%VFtQk9zDDIXoQkApP(HRG<#Tj38!21QI}<5rx3w4RkBM=r-2
zFh5d2+(_a^5;u~#N9CbRq!(p+?Gfo6fdotf^LjI{ca2CC=~3~ZTomP^XipUFiE0u#
zIt=9N6NwREJ@u&;>6-)c_9bs$^7bWfG9l%p1n{IR=>a3mrdrC?sa-Fz((
z`#$X1
zfQe{O?|^J9#~M)YKmoA_Qg$F^2U2!m9hyX9!w?7RjHS-lGEiqMbq=D=L9s|e7K%}g
zjc67b90`_Xa3+dSg$C>q8A5$SsBcIH3Q>+)P<9Aq<4BJqJ+2b#P$x2U1X7TL5|BQ0
ztH`iaQ2#K0yh&tu49GW}dWYwO*u#lCoVX(*k%%j9Lzhj
z9=k;*gdi3f$Pt-Hy%RTyq(&hg>BvPX)}vA6EEk1n;GvO8px$$cb55SfB!-iSHz^gA
zn?$)ul$%7kb18RjBodK^e3YXWyF@03A`Y~5GO;F?p$0oerVw`uai3UQ|pcM5T*
z5I2puX%!;pnTSR*NS{jj)EJ~72PIf1a()uBPz=hQPn`4XMbfD|eL2=(lm8dWhywX9
zApZsAzn~f$MWzvNS^+3Otqx6`HiTgW(vXjG5PLdpncmFlbS_FkS_Ww|T+qH5DQ&0T
z;S8fuBQncJ8)oem$qWH;Gs%~k31VlKpbE7h&xPc{+w
za)_O?Rb)PK=SO1`50uUpxtub&l+UGnu0KxsT*~KCKDP?%Q7>`@<*%Up0;U(%isWJO
z2&5nfB_PjY+P`>@$d#cYSH&YutZ#3BXMd2JEOLA}>f@3quX$nq7Er;u2M#45}Nv92TSx+;-n=_1#Y=lTkf
zBHCBPbWtwIQ$&45HJ~p4p$9jHirf?q^4*k*EEJ#=)u=aum+n%R=SA9
z2&90zR_3Dwv~4Bzt!zXym!n~zo;#`M&I&N^PU4qRe`y?&LHyEMQ1@M>AjVzfyNeii
z5n~lGR*`pAxyaqrc{g?5O_{r?@9uS=Tv;wQiQE&4Xb|@v>b$2$=t>Dd=-p8lqd3VG!ifg
zOg~~G0=q;it3)2H6?tqq%CQa&VBX{8dz^faQ}*!;6rw_;inJ=qRZ*^LEB4TB9S!oW
zrjFIwAn)oj(1z9Z*eUX)i%5(>3Nn$85>%lUTd_x^nmVfEkPPas&I4_%UXP7v5_yVv
zYbdjZwmusI$~;S%XDRb6WuB$Xvy^$Z5;fR_-6CttKz(Z|yO#Q%D?llipoJpO7mK`*
ziG0wm7b-+vED~8yyqB^;+g>K-%WFhlNkpSaO#&$YY8YZb`m1Tk0cBq;2X(zl-5bI|
z-q)hhAW~Z`@;YsKo%yfRme+~@Mktv7265lWKpvK3J?cf?%o3@K5P3_Gj-4WJQ_nkL
zppEshbn!Qfyq7QXeiY(CzV~xciuGvZQq)BZXx|6q`5+bK`JfoI>w}FT&WDjm1k)cj
ziF`!4kEnCwR*{b@QG-p`E%HeSqCmV)i1$er3Q!7Seo}`<5cktCj6fRlQGzNoV3)`y
z6A_3*GO|$*>i#Sj#i#(w^BFNeBj#peZjM0;GC|zU#NE6O#NABXEtK0rxh-{|+!o4h
zrQBA^ZB0ZPa!`bFPEFF7ib>Hlj&n2l2n0glrU|3~Nx2ogzC!5QTUoBLjIL&dy4auSoxjcwZ6ws|u_a
z`8o_apbg(J{taonsACs(?4pic)Uk^?c5M>*R*(y(zoV}2YDK;ufqapsNRc0?^M@Ld
zA1V7Ib^b`cA1U_}<$j`FKP4dx#i+(cG>Pnv0P}X!j@@Ob!yb{JDf4p*GDUs~MFp7m
z3uS)I19|o&A|1PUYCUPcQSWc%^l7@F{@)Wox!?Wqdiq0>=?e)%3`*#uBJTcZkiNf$
zC-|g-@-6E`NdhLJmZyUF?+tfRBg$Z&nS^xYin8KF*&{?bDZ=A=!TXr`Rl9O4MR|py
z0>!AndTd0Ks7}P{6bZ_8qFkp;$Fu=h#(RPNJl#a8SQ
z)s6Vwh~164x=lhhib1|^^=J~+JruD>K{kp}0pfHgPWNU};jEV)A&5deXjhLc6rdE<
zs6!*je^?k|kcc$opa|un4re?f4r@dmk&awY=MmMSdM0D1s3S=~lC~UKh%%7($a*j@
zQV@<dkm>+S!|S_NJXtVW6E+v@?o!M&*EZ
zM$yiw8j$a36H!P+1`1G)8f*e_`Vgm29B5--=0(%4=t59uzbI6RIwl^eqUe)Q$5PL+
z%fbAZe3Xdl9|4x3KWPI(5seW@K{|3leFL*l0G4AQ)AS+mXnK%m5P1d>W3Y=zB!YSd
zSBe_KH2nf<$U0GRldxOV&=8c08b-e1qzxzDh-@%#B8Z?TEr=HQiU82Sm
ziW-|QDxpBsxL9lzbv*e_p!^9N(IhI7v=h@sjV}{*67!OZMV*`@>XbZDrzZ0>OU6%2
z5OsPQ$eTjUGiyanAm2n6k=V(P+-T=nOrITtHU4w9upEp}D)Qfij&-6Y=Zl)ca!e@~
zl}7q`nWCnKg5mk$C=``WjP!a@7f|N~)u3G$G@uc?MNK32v@k>=4x2a5l8~{&!Dauq|exiJ)&lYAr{HV1m$Ozf$}qH%gn845;cqEm=%ghkbhPJ
zQjm^p=Knp{h3i9e=d<(GRvrESv+X>GGZ^I+%n=UqujDaQP(G<2)jiU<)L2G
z4Jl|4b>l8k#nGZ}P7`%YCTQcWq%9}LZCRpL5PJpn+)lZYd{K9}pbaZ&-^y|{iz*Ef
zb=L?{t0F+TRa-^fT`Q_ATGTz{zlYfO(zg3zP$jCoK-B%jy`MY}l!$sT3YDTNh*?o5
z>Y;E{i+Y%N4>SLf5umM=2_W{P#Ct3kq&=RAouaDJv0l^@%zI+DsMV>c7sWM)s;0iD
zBC$r))6t@yG0`My&2lu0TI+%#*9GdiU82?{pa>g9J)Z)`U!ZL-WPn&Nl%ocXqFyu+
zg+yea0OhDbqp0;JqL7FT6rdb6XcYC5i6|r@0|h8Y4H`wgY$6JY$Up(gQG-TNub7BJ
zA~H~ba@3$vRE>!!Bq9SWBiAnK)pD?`8%WPPm2A2U$^f0}LeBYA!##!sP$0rmYvnV<5(e)ZEDG=LbpLqI*d6G1(@DZhKCsGq|T
zk2F+(c)yVEmr2+p>emENeorJQ_nV+x)ZQdygEssghV`QMMTy#+rx<()Y8KS$D
zfO*|(uv2t+2%->=RIu#frC5(fG>h&*Jw2$WM*DkdV1^9KFvK7cX~;nl%CQa&*d_W16A_5RB;=tC
zHKKc#f@L}~3&p5L9U9RrIx-T8$ON$?%drl`jwJR`CL%!Wqmq$WQk%J0QwvT}HK9uQ0j6US+Qw_@XX%^j=Hug;gvHF%`
zJsL$vyNE#wh!W1`0vEV~H0-
zycpueq=9%b#ET(b4DtFCuYVjSArED!!A{WwLJ$ku&vlU=P>S_v6g|*I3{pV*2h#q5
z)Hjg&VyQ2d`eG>;OMS7-i=~~h)EP^igQ#;5bq-2KHkO09gEpc`^x!azKnij|`N72I
zKCB*0`60v~5(~-?$v`oxLHQxgqT`qsmxL@7paPVQqwG-14vjzpC_6L{%TbAXP<~h#
zVvvYTFn`z@Y!W?O5Dw}Xo(k$1P94Kb!7>bQ1TjWLVgyoJl!Mx)pArH$@i5lz_9Up=yBq0mMsK!P#iyj?`
zL}Y@xN3X#q>=r$SvSTPaCLXDv{FnliqZYJb4DB0B`^QFuc8sMRW3y3+3Q$i%3{sGT
z<=82D9K&(dqK^+pEYd-_53bL^Y#5pAc1t=GNY8*C#I+F$Ah(!|8L0!qk
zsK9!VH@QjlX)Yo$0x6*UX?sMU9*Ss8LM7@%pFy58$a6+L$d^JrDHR~znae>tCXj!k
z3CePRSf}Qr1XXCjF41SzdF0Kb9eKM%FRBy0I0HG@D*DQF(fPC`pEh#6sIN`}`L3Z&*U&z$1$9BO=q1El
z!uS&6ELo0P(MyT5bhqeht3(%4_PQ{nA{Ugot^(9|-6jz8x@OVKLJ*4tFmG9t=pq+|
zqHl-+ac`uKn=(P1n@Uj)^4>(=V)7P;BOcUMOdEt?j#_NR9?>g9
z5s48X)=J8+%m*>=Ohg)TP=rd+rNk?(7kwA;@1hN>$g_$vtC+vapC-@U31|>q#`HZI
zSR?x0Xwml(znuEYH;R5B47A}v%05Va4^rQQRj7rZzDINgc`GPaLA;7OY!dzOBv9sI
z$~;_#HK5GHlzBuz{zno(U5}9OkwOsrkqVIaky?#*T+Onrrry=mxtcmxZxsFHPSMrLs0R6;V%}3M
z)6+>H?lbX71?%gX9FS)X<7*nRTlBLrs1?07Q}lD8@Q1YNIr6Pz-a5wDk?;9f6oYm?
zUymlyFC?NIdqlrT-WStA`ub2rA{NAXDFIcYUyeYb=vM^UqH7q|(REmC=IPaE&
z_Pv*ZI??avif$+n{XvoF4{6Isv1k_kF~g5{ivA=O#n>wPQ|kV-LG-2(XcYZfn&{0Y
zsBiN|(OY(j=AM+^RxWxw8skw3;(QSW#&;0=OWN}#@xLqr^L8eoQ8f38^w-g-7yS)&
z?n(yfyLOBImbC9e5P=v}3O@)3dA=_d-DILf^bh3ufj0j@+7Fcdk(fVH=TDUTDGj9W
z4nrI=&>;HfI?=zBi~g0EdlFI9hUwozupHF?TO;;}-b?wtd7!?%O#faZx;Y-iZzkWq
zP{e}t{S5bS6y3sdwGg+ZP7DdbRxwny7&;wAXcoip-mlz%bo5biudPs=y2kj^mqjRws2lF02uG@^zbTPOFGrE+D!S$1IXt@|+p<;B6
zKs=b&Ee6TRLLQ1xik)I~Pe229ixHlNU1Ide7vr!Is2Ah#T$Er9Hi9w{F2X@w5nIJL
zf@L^@I7g&olNddT(=!jHSSQAjG4O|*&?H7A`6J06N&d)e6r)*;qnJLbPK;hGTdxsF
zMGlsuT8!QyAXe{WWMQiqQG!S$paczK9GxykAKKU_8cg@i7lUgCBbxN+CNcU=65|*_
znHa|wi4hZtN-_8?r7<80tj7T?+W_huK>2~8puT}MV#J1ka)V-!4B9@ZN{qqe8BCnP
z8Db0}&yYNjkMnCIj(Ks3C;%~r3Npd`Vd02JKFYCGjNu8$0OKQQ&xk@XMrMmK%0w8V
z5Qjvhg1n=0P$0%}0^%J<{^O`So@I@15MwlHqbtPV9Mu?;k7hB(Qf_Q1n9q5qk-+jK
zl%P?JaVCg4t`c?FCC2gO=X}#ReiL?y66th>6!4ZlUcV?>CdjS+Pj*!2&LK=QSEAI@
zd=ixDF`k0fasHg2sn&5Von>;{xFN~HS_$SidCx(0+qfmezIwi
zwsFmK64Kko4gTh5S=+cNQT*LuYkrII@7l&~i88yijXQFf=|98XkLfbrw(QE2F6QjE
z^niq#d2QpJNH1y|58*jPtJ=m7l5n%VuFlfS+}4(UFwZ7xTPG@1A}s%X-hN$&NFVF;
zw(%~cvo(Y1he{W#xNSU)@q63GyUJjzT2A2SaoKW(%wwz0VteFBqzsl=8N^nIlvGk?
zFg#i2GG_+4BH5PnC^MU>>A~FNWIn@8(&q8k9)ISXK#p0=%aRM26DI?R>c>5f)C(!m
zUq({XU{a=$bB1Kd5avygiEkz3wsU2heSx4kw=A2DFzZDnKnz^*~@74Neynk2I
zS+ppUWtmG$4_qS`Qlmd7lBJr*FoRai2`=?Tq+KjC`PXmN91QqlYyvs_wE4l(Vb+qJfAd@!EBWhVw(lOvZ0Tjy`t)*M+3
zrxSH9wfS2jl2-Z4*}BF3t?TEV$Qnpycm}QV^G3>_k&_RI5W~E`Zi_)IyRUVx`e*gF
zx8?%5jP_j^+;gV!uV44&O#AIEmH$(oNYdOt$PrRo^xz~vf8{jEoexxuyIZ##cI
z%;>P!%p_$#ZTNF-`dcWs!}MIH{W8hqygays{b_^0mi&1BKKSSUkVCq^eJAqo9NO#e
z%QMlwAOEu!`RmVL3)w`vRL)}P@6q!(CjD8-vR-}aBk{!+AVwMgpw
zYf29L{y(l`zug^c?zn^#`L+)Jx4&G9TE`Cjm+y7H$R&#X_c3`?UYFNoI`6?Tc!9O0
zY~?6dc`BefsStIL>Z}e{q3RITMIEZbR9Dqa@fKcQqji`%Tt%oOR8Msz-?m4oUaGf>
zQb(&ks;`RXIRnS2V^xgmuLh`rDpn0rgVhifr-n+g8m5M;5puK|sYa>eRJRdHhO;Ks;JT+CFuhP{8YMPp^GSmz;Q_WJD>OwVJU8J(q95q*6%)8Yu
zQS;QLDo4$ie(ExHxyqGe)D>!hS}1!|o?4_9t1DH$x=LNGu2BVQiCU_zRfXz0wM>px
z*Q+9RgSt`Oq>9ze>K1jYTCQ$WE7a|(MBSlQsykJwx=XE6cdIgWkGfagr^?m+yjS!=
zRiPeI535I1rFv9i)MN6tdR$ehC*&Qeleg4r^`xri?*;d&r`0oRje1tCRnMt)>Us5o
zdQq)cFR7Q+E2>7lsy3+CRIPely`kPzb?Pnkwt7d^t9R9V>V4IqK2RU3kJLu>vHC=P
zsy3<5)Mm9sZB?JEZECw}R9~na>Pxj#eWkuu->6;EUwx~-Q{Ss58K8bpKdPV9ZuPVJ
zMg6MwaI(lh<`V5`I`QVu{R8P1d_CQ8JHGdX0Woul2uk^mBTheqO(zU)1aM
zOLCEZS-+xd^s9P!dz^+)`r
z=|=sr{zQMOH|fvxX1zsk)t~Eadb@7aU+5kBOTANnrN7qS=w13-{hj_^H|ZbrkNPLQ
zTmP(o(ZA|F`ZqaI@72HSX1!1E*DdlL&*u|EaXj45>#-OyOv5s4If+-PI)-a_M!@J~
zgct`IosENyP~#B)y}m}6(N+AvJnqIfT{ce_>~4e`J&ePQ!;J{z2&1QQq!DQxW%M$7
z8&Ss5MjxZE5pDD{jxmlkVvPRA0ArvLYYdW;jlsqcBhDCV3^RrsBaD&8C@!^5G2)HU
z#u)j{7;7XLx^Z_^+u6#gK?vAlTmEkY}{hpYAiQyGgcV48zsga#!8+od8bio+-0mX
z?l#IeugW#<8|W=<4vQ^
zc*}U(c*m$W-ZkDc-ZvVI4~!3ukBp7R$Hph}gz>4dNhTVf8JmqQ##ZBVp1Qcr*e+)o
zjm8(o4&zH>r}35XwegLy%lOv#&iLMFGJY_AG=4I68$Zk0#xKUN#vbE0X_haHy}V_%
z+1O|7H(E?Fm0V|PllP{XrpfzLO~-Ui&kUHI%nZ-WRhHE9%1%0k2E9YN?FVkU5_$*nZ3;@^Jue=+1HFV`$C@!_e{+C2
z(2O+)nS;$CW}G?H9A*wTN0=kcQRZ=GygAw&V~#Zw%yH)N<_Tt^d7?SqJjqNlPc~06
zPc@Ux)6CP&Gt3n8Oml)c(M&bZGS4>8F(=8n=DFr%|Cudjnt7f%)jZ!!H!m=!nbT#8
znPJW_XPUEwr^lI@=7qA$oNZoYW|?!$x#q=Ywt0y;&%D&kG3WCJHk@~yelyw)r>^GUPXe9C;jyg4zmuoBCI2<
zp4O38q;-_l%j#`KSw~xatiDz>Pc^;MI>tKIin01z1FV5otTo6QYz?vEtfAI0Yq&MS
z8flHPjxSGpw1`EGyHx(3)*sWMx@%thv_3
zRYQk)-_gvwZvL#U27Fu*ICP~
z>#ZW|2J1%aCac)G*}BEL)mm=dX04#_r+}w7Ea9n1w_7FF9o9@LK)&tgqR)zJD^|1AbRcSqHJtn_ek6Tr8sDw#Z>j~+`>z%``)zX8$pu_3E
zc+#r2p0b{{p0U*R`q}!$`qkQF{l*iOJnMHct!BB;+Q$=u_ggJ;
zuPwHcPW1nd;VDlEwjom`U3%N5ZP~W%*skr_0kP$I=_2RLP8lnqJm;p9JZXo>we~@F
zXZv6~)IP-SVjpUU*$7b3hfhRsoWwZa)+#xQn}wAFVEU1*-7@v_9^zMcCvk%
zeY$;yonoJ9Pp~K2srFg++4ed1B>P-@vOUF4v(K}q+UMKp_67Ddd%B%r&#-6Ov+PX!
zLVLD-k)37FvFF+s+u8Od_B{JiJI9`HUuIu!=h|1;3+#naU$$SdYwTC;4fbnxt^K2Rot8Ax;U49u
zJK;_b=P>7RC&D?x>FFHlL^?+~y`0`olykJx$LZ@tJN=wvoMW9Br@u468R*11gPg(6
z5GT$V>I`#+J0qNt&M4@0{c$IVU@(IHx+v&S}o+
z&KXXMbEY%FndqcCXE|p(=Qxv`bDhb~6erC&&zb6+@1#2yIMbZzPKGnXnd!`OGMx*Z
z+0I2ymNUnh>s;()JC``~oJ*Y?XTEcpbGei2T;VKm7CL#(B4@F4rIYVmpG#JR&+>D=j*I(Ip%oV%Se
z=N{)?=RT+0x!-xfdC;kF9&#Rb9&sw2N1ex<$DJzY31_wQq*LuYb=Er1
zIqRI~ofn)Jo%PO3&dbg#PL1=bv%z`IsdZj=-f-S@>YTTnx1D#Kdgoo|J?DL=!TG@X
z(D}&O=zQ#a;(Y3Caz1l5J6oKs&gafHXS>treBtbHzI1jvUpZep-#ELRZ=LU)@0}*+
z2j@rUCug_wv-6AdtFy=X&Drbx?le35oc&IV-*N70*KkeOa&6afUDtC1ZYMXyJ;?3s
z9_)s?hqztbL)|d9tJ}@(?uNTP+{4_%-3a#xx2Jog8|fb9_Hui>QSQ-hAGfa??e=qz
zagTLl-2UzWcc2^V4sr*(L)p5>nHp5sn(&vhreQ`|K7Ja?*lzMJk|
z;7)U=yBY2bccweb&2%qxXS)}QTm2SR!m3y^&ja%R@ahJN+x`pm_?lSj!x5&N0z0tkNEp~5qZ*gyRm%F#QE8N@N
z688>wrF*Aa>fYt9a_@G_+4;%jJw8t)?Mp9=dN>~cVBQ{bl1Btxi7n~xHayp?gsZYx7K~#eZzgzt#jXU
z-*(?|>)m(V_uTj02KNK^L-!+hqx-S@iTkO$$^FdT>~3+lx}Uq--0g0o`-Qv1{nFj(
ze&v4ce&g*e+KqP(NMK0IUjD=*sX=N;o6>&1Bey#d}pFV-964fck3ao$jGm^a)T;f?f0dB=J2
z-e_-(H`Yt=#(BqkCwPh8iQahcBrnN3**nEM)l2qH^G^59@KU@py$Rk#FV#ECJKHRPTH*-Mhe>=1uo9ycyn1Zg9Ozz017Iy^nYY>7
z;%)Ul_qKW4y+-c~Z-@7#x6}K|``Y`)+vR=hedm4eHF-aHKYBlTyS<;iU%X$vJ>GBL
zUhj9W+1uys_gVrHPyrn<0%pJp*a0Wt2E0HZ&?yiSI4ICLaBv_ra7ds_;Lt!=plhI8
zpnD)Z&?9hI;P601;D|uaz>$H-z)^u-f!=|rz|ny|fxdz0K)=8-fnx(Pf&PI3fq{Y8
zz@Wh3z>q*(U}#`iV0d6eU}Rua;J841V02(iU~C{EFfMR>;DkV8;KacAz)69mz{!DA
z0;dL&1E&Q}51bK537i?25SSQ94V)D?J8({5QsCUcuP9{(|_Dn2_gFXm=Gaios
zbhj+qW682KCY@wN5ClnBAVGtGB{`GKk!zxCviFfavYWln1e9|)*=zPp_THVn?<2d}
zB%5Tj_ZfTtcT{&nl6Sw2{RvgCj#pK$-h1`xRaNuAI<&ghk@cF@vyQF4HLzZ{Zd-S(
zyVe`lH(H;yzRCJ#>vPuUtuI(#w7$jqR_oiWZ@0d~`jYjX);;UHtY2k)xAi^NueN@T
z^=qwPXZ?EXH(0;X`c2kvwtkECz1DBFew+2%t>0mNpY=Pf-(`Kj^}DU#WBp$1_gTN+
z`UBP(5$$&ieD#U$Fk7^%K@lT7Sv<%hq49{;KuYtiNvk4eM`Of6Mxc^|!6R
zWBpz0?^%D}`UloOwEmIxkF9@V{Zs3oS^wPn7uLVD{+0Dp*1xv?jrG&kzqNkG`dRDe
ztbb?yy!8v#FIxZJ`X%c>SpU)bPu737{)_crt^a2Ick6#x|I_+k*8jGC*?QZ0$IjSe
z_P9M^XYHJwwd)l^a+n%v!?KykiUa%MKQ}${5jD6OA%zlskUi*Fa`|TzB1NK+h
zAGE*PF4*Vnud$cy5802~PuL%}KVpBa{iJ=~Ua>FOtM*6jqV3qO?b*J)W|!=;U9s2g
zkJ%r$pRzw;KW%@~zGzqNnq9X8JG3MFlKqT**?!i3&VJthl>LHz#lC94XkW8mvaj1W
z>`&V-+Z*<#{fhl{_Sf6rV1LHGX>ZvLd)sc>J9f+7wfF3OyKTQ}-?BUQfqiIq?IZg&
zyJsKUeS2WPZr`@=*mvzW>~FL`Yk!md&GzT)&)Z+Hzi5ApRqeG8_ZrPXzh!f@yVpHz
z-I}Z(G@8BcVZOTEYrWo@tZMS$uw8WO!6b)4C}CLS&@GzK71%9N=!BB)gjLQ*$oU9;
z$**d;YDM9yhII|O9Ve`5{hHRVY5iI$SM40_H!S|`cMrSATrnyN!l<~eupo|#HHD>t
zsK^6yqM|eu6(h;7Y5khkuW|jN>nWZZ<0<-%4BQV)C>@L{TEC+8D_XCj?N_w@ind?T
z_A93Sdj9#w!S+srx>IyQcd{DtR59!aI{u>4qiA5JXVLXNsaJ8NyyIqTt-d0wl(Miv%z%9N6!
zP{s)zHy3O-deRXma;Jhfxc1G-P#G;`Mzg}CAEEGEK8)Kbx-vwBIz(3}96CC5M_I`+
z>Dn(x`=v7)%1nZ#OuDjIU0JNIB&{pnx*=&P7xo9e!^!aIxZUX<&S;ooI&C^~bkZH^
zLgeVAYgZyiC%vvrQWwhl&YH}YN|RsI*$bo#&N{;&DZC=omFy)Dma91Ho_#4bra#*sY^URtIkY<)s%K!%4f_bDe@%-
zo-M9whuw<(Owv22P&AT$Mprr^8hlbX5lHTD~rV;G1b%*J)c*HWKYx2rCzIbi(^cm8A{t}w|cGPwkk|d2j%IYjG_}Q;6$Dd%F{u4Iw(&E6=?lH
z>jz4dK#8Yf>le!tmscJ8vS~eLme4TJdO1$3gS#&8^%}3YD8l*=+<7@+B}!&Wp^DOq
zl9XXFU|!O|x|d^tFwN8>je&r1tST`FM2ur5E%HoSdHHbR-2&{GZPsl<6K
zaYbJi=b|rs3^uIfo@*Q(HLwDaalnl@UM>H8tU;Vma3eIhmHC8Cuy`<=z+f=>!Y-k&
zeek&t5aeL|Qv+_nix>r#)O!AfRKq?#oE`xhgX=hX0PlIU^(>$
z2c5>CFZ2m?feU36;2=(SWm$xDij2FWgo>^xAB382CdU^!3wlqKG&s;ZZ@q3Al*{B`auufhP$(pl2(m9%>$o%b>~
z2ujpvh>H=fZa0owlh=h3(s3`GxZW7#ZV(UWZ^)8zLs|I-vvTeRZ31R*j4sH3kt@SfePWl%<#uPY@Gzp_7=Xu%{9_n{4XMR6~ui?6y(q
zPDLkeO_>Cf$CMLIi0!866IU$*Z%x&cin8lE
z`JH$bKx-mRq3-)b-S3BvvX~>ig_$N2t}N%&OSzr9-M#(nPFL*;*Dp`DRF}4xC#+V|
z*OrQ4D{dR)74dM*h;?5Q%C-+vqinC+$56M8q3%0E^+kl@dw{s9Sk+W4>pF(IQn;?-
zXEslswC{^aQS{eDy!@b?Ymr>c+&pUZ`m4J)X(5@|U3KuUox?vf#SU02zuP+8TXk~S
z0q-5##~k!~-CM0Wjwcl0Np_+vl^0r;irGD|g}s-W-o23uMaT8>dkM*$NQdA$*}c`{
zrbUwLmWqf96A1}16wxHUSV<_tnirvR!}?lopN44jZ-Q`*M8D3>9C`O
zb(Ns5>5Wd5ZY0BXY0a-e}my1$F^ZCQMoC~iPYHnw@eYO{H8lx>r&w3U_G
z%u2GRyAc#eSTDhRIOXxS3O?yL#+dQB=b$-J`ts&>r?rt7bBD+rTzY#W50e-(z{@o~S=?r=FPf$dixpcwpUOgT@*#}^|DN{=-@wiX(CIxo6-g`Tpm
zrhW>WwL5GEb6lnYijOkp;>MFaw^V0fnwsU9Eo~=7&;iZhUc?#Hl{B!P
z25M;_V1Nnh*P|@D$=qLb%TDfq1($z!TE}qW?TBFLwmgJ-WL|?q@K7i=5zC{uHg(w9
zWm#b4&U%~eF7x$lcf`=-
zX_cJ|mL{zqvRZ?>G@-EyB;OTx4a^lyH@u@8j?h=B74_zXX5kUvH3UTSW#PvTNqJ5T
zT);Xm^<;!%;38estyn2ANR_mqXLnOOr>^dtG)(WD#O{i8P9oU@!x<+Xh}5Eul)zD0
z_Y7q(+ci?_i{Ax1q;js?GMwO-MXr2tA7eM5bF^kckvCtq-$fT&{?}B1dTBxOgItgG
zB>zgzOmDY+yia-(=K;(pZaJLlDZAx+DUMky=X(jqU=Jn5Wl_aUD5t1N8>&egu0`1%
z4F$H_>l7ZRyW~arV{XDeen8u}W}z(UOq6scOFC-N{!Um@>IgMJoyyE!t9Q^i+}Z9N
z!{1EjnmyJRpNrCFnHMb&R74D9y@TicQ9naW92sHTqz=!4S+3Sa0SAER`g9
zMK#x5I-^i29k7(<2D~X8jLPcBA|zV|$9Sg2%Yem+H+2YkQ-;kT&jdo1sj|*cS!bwh
z(v>sHN~BQpL(Mnqgy;y!o)R%sB1X={0E8MaqZmR}U^7q2hzwx^IHzX^2_G5ymDNb^}n5|e`&$#Nsvayyi);L!kei*m9>d-^rnHr23+94X5Wcr(C!`P~i;>il*Ne$yk
z4KbMzGlm(kPQ>%_#w`%YL^+Loi~+lCIg
z+pHCCCzQ+ImJRmpSp2b_*4ffSuTUN3p&ojLa@Yl%eQlx{Z>x3l=|}e61iR=BV-4t`
zSh&8Pznk7>E0aXptnn=A;g>I-9H>fW671$h9r=-$yK9DvT=pv&2Iqqp9`>YqMBGY2*6%I+{fFEtJh8aym--OpX$#~yop
zqdk7LoqMUhchH!)y5F9-dMv&MPdCU>RI{!ewD!`68sbR8frm&;r2e5u)k~y)qNpUZ
z7^%M~QgsuFm(_{%#5FQaRmB|>$(|hf;#P@t?;cgf{Sv7UAd2c!>b8)Ln5WCQr~7Cx
z@bf%oCAzK!zBnI1ZqXFJx{Z8s8^H;!wzN}*6S{e~$0YORNCoa7o;=Q
z5h_!ujpD0|#h3GL^jG^Mvw;36KGmbXo~!%fJVSXm*Whz*{!Q5wdcYou3kCMK_;e!O
zWk-529O-U5l4BT@6Pby`jR>#0+9q;f3|R5V?j1097@^2qq#m?LMLyC4`bb?uksj1X
z>Kcmlz&;YfJ5jBi-G&I{_DC#pJFN~5r(5b9(~w6wt3b2UJ#OL3n-pg7-3D>~zR-%h+0M^OVqv}XE{E;46M{;EC
zMD=p6Pl_hnhI(40AEVECFw>O6v6*z9P)pAvBDJm~Jv53`Cr5I41V4%PO%H@3-CsrO
z?v3<(B2sHR(sPPP&nKdwjNKYtbQr(Wp6JCsePuVX1K{lu`S9v)?wHkOzcH8`2s50~
zaAa)r4FS&4L?5a|g^r2BVFNphUVKh~!)e=Mt=A
z5SAy7_S;kVw@0V26X<)R#m&ONYNJ-L8wYv
z*cz%{m9;r#Vhn~;+a$<&Z
zs_f^zFaAPUJW4k)!r?$-
zIwK6S;h@K-Y5=%0!b<+Abqpm!Y5^eE#a#pL3&Qnmr`drUT@^K=2Ltg)d?7hoZWqgS(>THSi1)$6YE
z-h2uM9l24Q$^Jg15T^2OcYsYNQl^gEcQ`VA*fxc@Vt+SJwGi}gcM(iU)&A}z*QKzH
zn+eD#49GvmeYr!5wAy?7eMBZ1V}Ey6@kw87!OCq~+8q?0QLLP93rdd9C>92$nq91z
zC_a7E>b1K&)EpBR5ux&G6Yg8Wcie(2g6K{FT)VBqPHUICHOoLxJ2|tBEqu#lF<`Rh
z2)fmROOxx(D`>J86QtVasT5{ST1?QJLa8=1>2$gwO*=EvnkG)~-#yxABF|||GbYLH
zim_QxvW)@y@C2f)0h8+gh@?89!~#3zqB141%&&_3jdeK6?s+ee+dNSxkj
zzuw+q%2F{?WDZv)dfht)#cTs^3i0-$j@>v$U)smF5a;fg$uZqA@M6+eGyIVbYvQal
zqtHU!bxoMz{+qJWLsK>$i6+eHa1>lj#-u5y(*x7AQ>oEu(n35$O_(}90zYBE?5!_W
ziyFlx(F&p>{C;)1b*G7yA1)Y8QH3K~6xaP!%tdRvp;KW_Qf|EY+MwO(oJzBZB+ZTT
zO#QEi+7xYmm@m
z;Phx8(go^i0LKv+A6G?}7?^!+0O_E6z&)7X3k+e#OhWFc*FI?3a+i(cGHzMvao|;+
z;#8EMOBEszeLCv2OfFR`y1HPxwdxEsE}vI4c3M44lo%7A#s%lwbc0yG(`xg|gF8Fn
zG|N8^z-Ryh=0@de@gbqI5{a4}5vS=`maIgOW=EvRPKnUmsQfHEC_M8INDlObY6j|N
ze&Ap~A`71*&N5t7DNbOFOi^6Fn!?ags1!%4`S$AJ;Gma3fgnRoo}oj
z!|Kg%OVE^HM*=K{l(#DZ&ejOpmjI_e1idQ3EeUYyK#2zuz`;)t7FP<6BzR2%tg4iF
zECEhM2pUKL_bx%VCBU*v5Zo*jyurb=tl3DIk7Ma292v8`mLnnyA}!8@K$lXA&daJw
zQ7)s4J-|6x_LB@+!Ks{B0f@}9IbaG{vZgbBS=A}3mI7jPVl$ZHDeXh$(%OgM5$!_>
zquPg2Guu&2*^Z;aq!i^@-IiHJK(mT~W)%Sq1p&<}0-99>G^+?`RuRywBA{7CK(mT~
zW)%U=Dgq)W0-99>G^+?`RuRywBA{7CK(mT~W)%U=Dgv5S1T?D%XjZq6&8%+6v$_pT
zblpr0GZfKTrF1gNWLCG6Sw&Sdt6Ij)>UKP^dV5VK9xcAA&|MAB21sSfYOIxr4L1gJ`@xB#KmQFTv|rQQQ=uGo~+3Prs+qp
zt(Gj;67!aeB~h9MZ+;Xm3r~>-6q->iMKc1KW|D4bMv&5sqDr&4jL?igp;;<}XhtB@
zj3P|4xPa1(V5J#Fg=Q2Jn#IM1W?Wim#!;ae7f)zLV47wG8=7$~Aq*EwqG_5@7Qwth
z#1V%sG~%ZREc-S`HjaR0=jDjU1F)$lCYd7}Q>4qz%vmc=LnX5y?a#1~O8nTS4U)#O
z9l`EM0a?_<^8!4&0~ALBZW7Ao(h+ARZfsBwLQ%T3KU}-#8XeQSM;a(yo7S5V2W(FI^EmY
zU}5vc(WX{_eJd-kuHHo@Zc#k5FnV>zES_1w9!S*2@#z8E$#dAB^vJ{#a0Yq`K05&f
zvCbN-XNA>J7N0W^viksst)+#X7VMiAJ>`L~38vG$B<#}OZ@<;<&j@_0)p@Ejm5(HhLC-Tw2q@>8W87uUZKzz|H8so?G%64_AJ
zYNdIGl6teIUb>VF#}Vfy&UBU8d!QeOgCm~cH9VPOxF>wFCJrWdO&nzInp{W&EK^#l7HvA>
z!)6GVWNvBv_I|sMcg&F3*3NeKj$jto9(G2e8EWFIcWaI}v(s)Ibm`aPyAI
zW0WS3ftzH(kI;0hyW2h_
zu|4j?AuX6aIdpM7#Q`0{xIH;saXmQ?a6LIRL8y|e4mkB(p`4^cPf2|_H^2=^(c4}<
zoWlhJnh|usoMhr&fU0phivcW}$5D42-bOGwN1W=eBToPj3fh_+Texd-UJ7FCHdQXZ
z;69Ex==AW+^>tc(d07WoB-z*D;5r<)r-xg}7q#w-;|{R2>&weK*H?D&%M
zFJPG(U;Mm)g{{|jlk6#L6q0(aCKB|zeh64?JF
zRj(p?aByVWR!s$!Y*OY)(?T^HSW1Xil~zSFs#?^fL_3*!XVO~ABu?YP9xba_$#9WQ
zW4MK=hH65w?^;v-epEVDm0Z(r0ydC6G9ywb%^%xlJAB
zPOYQ6V};nbiyIZDm8`U2D4s+xG$9rYg;RoIs>Fg}iX?)eDMm0%jaV==AuSlDiu&FZ
z6v0p+77T?|FicU|)#>PUV<*tUE`a$cUkj@xPjuc%g^LD4Y@*Q^JVMNC_4h
zO-YN4NlJ-~CXI@WCXy9^+t)7d8s-cD{>R2k^1M6e>r;^H%>#hbH
zUyAh>xh1_Jn=0$4uweU<S<0r#>1-)U9}(WK%7Ek$E8>v
zMp*zn@NJyPLBt>#KM=aNUmsalFM^}sjDtfzu;NspY5Z_|grbfpDlIz)N02Fn48yAUYLEALzAbs-=3Grr}6S>0%;L
z+R|FOt7{2r7muhTX#<-YbTh=t?g@hv|fiw}l=NC;>UQBUix&Zi6K;b1k9ZMz%ufLd=;=4L=
z9!Sc{N=S-Va&ZoJC24$k$CcnK3gzdMhg9jzWR9R3q-B+H!9Ld-q1=4(#3_ZPmP|Wd
zF-afilb1cIk|TX~)7^BXo8+b@NotQYD_z2Km}FRZ-ok*sjbVVC+sf#;rJ3%OCr!oA
zG?Wo#NismXf}f@*&e05BY@*Vf?5F2Ixqq(vP~Rc=38CrbLJD6!P4#f3rF1b_1huPc
z>8`FNsG$YY*h=ZHmeO4&p+s-H_z6}DJw3@2
zFn)o=pp+N>R_(PnBq&^
z3QrZ&MM8QI377N8_`@8jg6XBt4-$q$?-HiMMXZF+0VJ#qAYDg!Pn%76xrjT~+~~;F
zPEL97P9!J3566e}=BQ9jqi~pOF4jqrnthG&PnSiTyA6pbMbjRgXVOCq#`lE
zDN47ISXM@C@$zg74twVgBr#^0`7>Eihw3u{LQ%g
zJularH3uOKEyA@GU((=|_rM=ZKGF6u^A??*aQZ0}gU1%9HgVD!K7m0xr*xWJZh_xT
zA#wGggn^WJpXqsgfMbe2BIm(6j6L06TqntSuugnD^iVrVj@Eg<>6<*qNjLPUEK^HL
zo4Q=9DMIr8M`S5s-p7YiBbDAgM_Xc^(mg&eR=nX)F(1Ad#M2-Jy@acZPRi;j;#@E(
zrh9y0SSMvr74_k3nU*30BzgoWQ&@D5FIaSsZvg2A>)CjZukxW=#z*X@EAfWj?5C3y
zvw^QXyv48JIF2tC;l3k;pz^>LUu&ed_?1U)@%g9@pUUIo3p|~5@X5Shx4~x(NUlwV
zqKetmjQ4)*xkd(iq>GN;AjX*l4BQ}L%h
zjmx&jIDH-8yf$^vY#f=N5ztim=
z#aVje8RtGyR31d&X|T}6m4^|ahtf-*?X2mu!?hro+S73>0)K7~=U4qk2O8-J^jRDQ
z@!<4<6!axs<$(goMT+2d0{c~ZT>`}h#51?(==HFJN2TrNv^;4*a;`&Pjo=DS6%U2!
z-oHFj5hQCqJq+mJyBUWzzqtiL^$|B->B>wwa@iWg<^v3{3C|Gn`2Yel$Oja7{;q5y
zZ`(1GIW!dUJ`&_V0TdtW@|5CQg;BYov?U+(^c4q9IC^y`rz
z#&5=}_087><%p8ZYoP}|;zNql*Y`udydNebThq0F@_iAEU-1dYgFe!}r}Wj6SG+@#
zdY;nLi=x~u`cxic^=kF;!+}KQAu}$fN{2mpR11q;5#XCE^#08>W5QCN(K%VXgwizq
z1WKgu#3Ow|9qAM5NS{zg`Wb^rJ_P_R&qIZ&$)o_xE#u{#u!5)G-wJBcR1Y6%ID|1L
z5m?X`r?71}%~3j|)EL$jDTl)tDaVIE66F&hDM&{%im(OUMor+tF0KGcynh>)P+C%J
zx4qlHJHtirnw518NoPcjt4342;iN<*PEH`f)p$uRaYJ@yugnHhs?_3-a
zN~AN>B4pt}$diwdg$kjZ8#^+0fSGa#nQ{n?TGVz#9m5b}C4mb$T)w(4k3jLU1St=#
z!~HIbn#PueXBc5oUo~JopGn))T3nhf%9l!Lr=#fxt>(*nHe9+^w0f(nsJFU`Yio13
z#@%l>8?;_Q4Nu9z*Ug)=5;>%AbTn?wVhV6{-0Wf=psRUBPpRIDJA=dJRic8Ye0b4(M#OwCPG5h!`r#MEiQ-a@iQ=*j-zC9&S<$R+5X$y)eJ#1=?cf>=
zUmoLI|9WSJE}|WG58~S>Mg10%r_aeeUCTVx-JY&#p6YN<*EUaexuC5s!KbIS*A`0|Nl!1O%CD0e2fqs!P(C@1R`en*Mzq1nP7b*k&-b$cfstokI
zD}lbZ4D`KapzkdMRdj)VP%zMMNCf&0Gth5I1ZFLekN-i}tL|63)R(8uNEa*1*Y98Z
za%-nr5^JgGsdeR<&$sAjxFWIA@M@Cf60dMo@#=UY{W@Nx0?JvA3J*b$kEeBj-Eb#^z<>Kr;iyueaz_T
z$H_d=Vnxqzv05sgK5q2%TP~h{u*%c#zIpn^DNnZ&o^B;P{X&?hmW-#Cji(=p@Z{QN
z(UVI+2zB|@OF(eA@N5Kn83{fYg2~;Y>AV=h#VNBD=-cc--)0B;F}6V8W(RV43FS?F
z=0d=-YzO+C*Fd=^P_7A-TLOJo9q7C2K;Kmd`t_o~^h-Yr8R(Z&0{xt3pj;Lxmj%jQ
zfv8KIH0e0>EpVW3fdhRD9H?0m=
za}4l}1Og>b$%LDv&3%+JRO8#x*vwKvym5nN24Eyp0X%PL@9d>=XC*jx2i=C`X-qmh%Y-4n4HchZNRcnb
zI_)O%@ba*~Kq<&QYGAiY2+M!#$p#RH99ZS^#%c8hdy_sNf7U0X}+1EeK!=CRQ5d
z!O4~~+9+k}4k%8MdEt=Q(`qv>gTCPJBtB@)P&c?NGExBe^HI*mMa?{d59-Zpf|U}B}_`f`zD2PX_?0ou1Z%;C8z73O-d6pJd7ia`ZTf+@Of7{RFKO&
z&BI6OhUVczql?uTFU>_)za{IMZ#1gI#?>e3t}fZGZtLB8IAu6)I%X}=jJor6b-U*3
zC06KF{ajh~CS5U8KtA35n0%{@zt;#8-w4Fgqmys;T6g-VAIKv~nANnqJ^JXQwtz^mgyt$*6d9y)UK@48pO`)%2
z5`ZtJ;<~H?cqZyHKrbrE2YC}EK>Bd5)9B$sG(FU%PR#53V!m{WqaVf!htMiDVN)Gg
zM)v-0wHxKzD9yUQRy`pfH69vLaeG0;{UJAljeNzxl8dDJd(m)Er31AAsm%SL$
zMY05e5oKp4Zda4l@EJgM+U<
zl9egLL9=o@P*u)6Chr!#t4PB%y9zH*KGRq%a%Kq=l6}U(-V3qCs@i)H-vki?7$W@!>$?QR*X#W$@ov
zX6&7J@E1dWrT_g1{wC@7XTCr8l}sl4nM~&G&*XmkouAKk-uaogKaCvCd*_#OU&(&v
zonMmN+|MB&ar|xfAIy9q^9B61_)on3m0$kFac`{t_D}rs&zki759EGf;)~G{=EslOos58*76hDUX1VUuf#Z~-u_9&`-=(Qi7&>u-+?#1VZ`IlbV82U@Yk8E!37Vh2H7>+;Q_%ZywifyuEbp+%U5_%+!~I`=Dce
zt9D^HwlXYi?Oqs;uPmQiK6ha_u~OK1bK=a|OszgVT`z2H)lSCG)N3b?Pt=Fw_3L*E
z!W!UY_UfJcDn(b~NK*cg?(
zFwCwDC(aMg);FlV;bZl>7Fa6m6oy~EI?O)t%KeXw&DO(aILwC|=Y|uHZ(jSl4HRCw
zw^0~gy^5rZn@fdZg<_S>&BBS4Mvp#%B!vpYBIOsU32A}6*h;tC(j|XUbt7dhqg|NxyM1F7dN)9E;X)gZY*z}+bj$(zO;e7
zC8CRV<-%}sWjI|we?K!W(>w)mxwZ^;S*|sPva}2&9SGcd0)75Ne@vJhXJBIpY82+MBa8nTZfnw6uI~^GPuA{L0DrcsSf?
z1Q&)2D;PqdFr2SHOCv)BGc;Tv=o+8}p07n9T416SK!YYaK3uGC74B^nhKnHEh2g1{
z=dN#@WOsti4-IEq%XcmePp>@p;>L3?N#fEuq@U*WGb<;VQ}r7gC#O!;hhvS}aPd5e
z99XM%GEYAX_!*8phPjw{{OZOD$sx#9yN4M@vkOn2TShe#2R~3gRe>Z^?j}ZZ3H`r>
z)X~X$mnl5SWKJ)G$n|07>HA}2V=RSdS28D=@$mY_@YHgx5Dw?SezVJ9xmscCN8kJ2
zG5kU1)0tYWMie-Mys^f~nW^)`?>fKqHDK?@FvhdzFAU$aaxzBYdqHms-?wryLE-yX
zPO=m(t(@d2{J_ddp2DwMIhmyJgDWRf6n^!}$ux!MS3nJ+|1iG=N-q~yhhyJB(sE(A
zlFEE6&U{TWFQhV`h%976n+@vqwpgbABA6w@lp6B#z*1mO5rIMq>ru?wubNBDnRLtZ4pm25Dr&~
zXNxPtkDecX6!Pdm6fS{dA1RQ_jmk19_Wx@Mu;YcHYo_>QE*}!d4V@=Xa${%14JbvL
zMK493cPrzs6xO)sYrrOW!iUuaw9g}ZOX-=%eu$Uq;OXVci9dFh###bBFn~wa*@Cb%
zDi?<3mDTq=bzxZfe-#B|H&J{YQ3wwC*rK31!YnwM9Y_`QJTf4O&$+MC_T(v-&id?
zg$12@KQ4(t8c)SA|2Uw_$ck+jnZ$=VZ);g+3!4AJ@RO!BroK+s78y7X4%tlcMd*)J
zqGB|BPkrO+64unhQ=6+N#j!Jx+v-T}wWX^gxwVm8Q#Xz8!5a0I;m6LW3z$l#!C+hof=}`>!E8$|IH|epbmMcr=Qv7mmQ~P-d3ixB^4ON~2^zrlOXwHZ(I+~aSmqCKF
zBKELOAmOK1hb2h)v+t7r9MFxOIXx_+%=0V5bp)RxDuy6;A;KbTsP)1MG4$|LAoG=#
z`pGR8$PFq(kxx?@ioCq?CbVH4u?@tqwj;K=
z^5&SNy@HseeI1n>quj5javb{xD#x+UP&tm>q&7no+oCop(x5gevQ2GLq)BC-L1c%@
zP^3j=D6&gsD6+@>3J}}peo?H={i4{b+%JmV;(k%A!~LSz0r!hyhuklUbwT5&;t4!r
zXm}ABuSv9uXpe|a#fCLN$5?9-r80eqQkemlp$sbXIx2r6#(J9}u5m}ARO7BhsrVbf
z^9e!ujS{5-pOq*T_$HKnI>z_S3^BgXNtB9zUZPa|3#j?D;QOLPslc~LlnQ(+%6>A&
z_iYR@zHgT(75@&2Qt>aL<|hT;cS@8B+>VuP$mL`8T-^qV{{)KEU>*pWOaq?WPI{j4_-*@rs#ZwpOFWMI;znq!O*eHJ%
z8JUYC|5L_~WhOG2;N(MNUwRQ+h%ap@{1JlRn#!1j&83qMQ__!5eLgdmz4)c(buEcK
u*yiTNh1c`D`A_Gc%|4x7%@=Z$b5FiC_RhBrv)_#sK6o>?lg|V}=Kla5n}5>)

literal 0
HcmV?d00001

diff --git a/crates/app/assets/fonts/Inter-SemiBold.ttf b/crates/app/assets/fonts/Inter-SemiBold.ttf
new file mode 100644
index 0000000000000000000000000000000000000000..47f8ab1d68144fc004b310d65d95180d502b449b
GIT binary patch
literal 419744
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�#
z-Z{ByP}k5@gZCoEyjc}NxI`FWIBP3Jnmki-2x
z&{Os39J)lEBmUq-FD-6en3cf~MQ+d9Tiy)1Acn-K9RH&pl@#aZ(zAt8xbhNZ<*k9*
zEV)bhRV9^Py~PnJx_Yv%yklJZKw<0Xp`m$<#)i*V1JEzZIl_F&=Qr>Ww-IfZ0
zs)xPuh^wA#0ntt)yVzAv^SP*BYs1rbo%ZP-1bmnKP+Z>uUOK&J<759$JViE7%)IHc
zuid6~?7V3Wh%ked_<7SFuy|Y9J#U`KREBWF0Tp8^moMHud(Q5gC(R#_MRWpUWlmz+fA6(7EZ$=m=kasawn*+%fbyu(;n~aSnfZZNZVtPq(t7f_7%mQOFBb+
z;goVH2YB3Q_M>DabPH+k7OO(=C=QLlG-MAxMI^vGYXkLF^BE61IU%awTJHg69cn{k
zt)5eQ6YGWU+G_=i0?Tz6w+sU4P=1sVLx5EA~-n@QUFocS|KLTmH&vat)UIVDM)Of
zlQbPI7&x295e!@mGLeq39W4zhD`ir3Rmb+FUCO$&ZCzN9otfg3aI`t?A7jPk6-GrZ
zNqwAnkUB@|hFdt8<=Q~Tb-B9#<_Ft7v^cEC2}@1&hF7*VAGBmeUfRD~r(WHag1A9uGsp7V|>X4`Ez(C*sKyhvUZr?~f
z){JYUhj<R2bVQek@6;s8jnu6y&%TQkoR})Sx=JAL&d6XJAd)p3v{?7WZ}eyt-n3^f)2qmP7(3`dC}k(1nIK93Ahp=&*%VCmXL
zVWoGD1BCVMfDQIB9NrT35~0<;#g}N|M(=QSP0ml@^uZDHB3;PF21Iavw7wVjYOq`6
zEldNjM-TxU52J*i=FG8*eFy!o3+9z#UWP-jva}NPEiU$FmzVjAlM%W`l+2dkIF3hv
zG8-Ex-Lt{($QJ6wr8#;+P{N_l;_@hZd{Wmdf}
zs-A=T3>PS|1GUi$TP(aX^Danc$lg+tqy-q|kr*koTyH>ij31oByk6m2<)^TC>Lrh-
z-ea@LS&18vd_Mbq*uVCVEjLAYpX+K$l~fy-#7$w`M{(5n0vVIOBsrJ``=6jCunD)x
zjq&36@0QykmMQ3oFj((1YWwx<2h!Jc5G+$#oJv<9@wEim2na`>kbT646L3~}g?kKE-+cY7TXI)1`RON^wwAozcic1J
z+-}PrlAoI0_!molfFs2kWcc22mhkEy@xWd}S`$o$+|X@$9T0IC6Rb;NI*dR=&(F(*
zrv+0f?!!#-$p-cxDu#TySiVtk01fa?aTc3^8(TDOv^J!uNRm2tYTvG?yr{gi7!9{A
zE6&cSNXDM1*p(bQX)omT$U@G@tS!l5MpgOakIi0;{+@Z-vQ2kSTWMBJE$blq{L+wf
z)_*@CCvVm*N#_sUpt0A^S)GhU-JWxp!YPo3
z=08`kKVG}RUS~|^>k^^~`5*6f(TdhFqW&NonW!G~*4>eM?tc{UM-!5oY
zY1b3Z4nE}L$5?T{xWL^V-?#Jz*1c%MTK7iYjpfG}*>&J47Q%55bb~$`fjI0$#9<@)
z2&N0UDX;F
z_+gde(>LBYVs7D&?T@j~Lcc&C&0eX_SY?_|KVcqsE2PjxTp=;(a~N~S^gL`l=^ymc
z{q0FG#Asi85oS6+bPQK7<8ajlKZ>xB?^0pNNxj0VsS8qM0{d3VO2w&>Vke{RLh*k-
z_TCBor{q`ryBD3`M}2?V9RqWcKG1yU^*cTg148>j6&2sllj?)sbhuMtBJh3n^nD7o
zaium6r>g;w?%kkBG|=MrZWlsv!fmfzk>IalKMfi#E2V^P+D{|yF+m!)wcFChFvOCdf!PKmtc`d7eW;2fLM^VDXs+8ToC1TgX&6{alzq
zp7|IXyCBcVA0(Y2oxz^9_TM~9rUt>_e1w6(ox|WxTo1!UZoi9qU0%A4e#d#tBwuK2
zDe%^P*P+6JGj`#9z_suVE*l(=WfmT5YNy2)aNia!<*kK$oBUkFG$>KQwUn)tndR3Ga7dd^l?ieHYMT>pL;f#^s&MEgk
z>6tCVY7yr-`(#9jIJEl55Cc35-q9`s@081Hi0^O~^>yJvB3>9CWbr@eFFwBl2DZW)
z>0vQ<65nxKLe#I5-gDKHuUgbM;s!{P1JTZ{cD-*VyWCYzww8cj8`Zv34P>#kQ<%Tw+GR3J*~B
zObJqhF+lRj`+i!s>gXLeeY`Z){Ik>JH>_VXf7)DSAN%+GuhMQ|(lgI9wtW2K*Q|eb
z%a-S+j(Kzax_?|_;rG7!AK3gC2S?C-cwVM3%!3Nz87H|14_K4+PrSyl#?6n&0m5n~
z*Sh+#1M48JezaZ3Jn<(tn~+D4;{=`9aD#qT!|>WprRP=_r0Xqq=iK2_(%#fBp3$*u
zRIiBc(4p!WtY`RH*H9(!`|)s-s`3ePJPZnc?M+E0ZX=~rTp5qfakm|Q;*oE*3gI|u
z@{uR^n8&Z5eEBl@_Ruwmbiex5m+yyq$@fiPKq{4VF4j)?7I+2g2CS(zyw2;xA%TOa
z=W*{D+<%X~6jt1ug+s9Q3C}WVRB$*POHhcIT?Vy+6*Lfj1)Qgmq`_9xb$&BhQav)T
zDrf~`5E<{nL|_G(mQrnD$wCMp=;EgTN)a+m>+N|@Y88+
zdXI+wYDGKUutH=>=t`R4BP$3Ah2%alkerqPtX1+(6h)^2t9u
zx!Y{Eha(a={`ynbs9^j-SHh=E8WtRy3%d?JVXY0r1Y^-y8)O@Iz)P0(deE4!Vy%gW
zgpu#wCYGkt!)%(8FeadBJn}I#jp{%QNuBUF^`iM1o;Z#^gby2MfHz8{&3uCVE94U=
z{}j?wXSOe&tSZD&@h5~DX$C~UqRbZMyB%!5pkT${J5
zW7Khw>vNyuVlCXYIV8@&8{$0?D8tXix5PYm1Aosg4PqmR|2`YP2p+Lc35MX4iAa%p~#joNOy^TWu(I@r_|jt@Ljo;af(XJ5>b^_KLZ#6$2n~U!!aV5EbMS2F&j7A
zte8LOkr0maT4{0HHih|_{$x^W6|8~_Afl2d?79`4)LNNOC89xaV3{~)fnA)K6vqpA
zG?MiD$@Ms38cEmFJ{y_?$QKgs7rW0U4rXmUjC3t;2YYd>b{fO+=b}@di!UG$d=sMuxb(0r6^~7WSd3_V=+jHAfv~whkMIPXHqW&Uo3-fy1
zEeHP)>P9>urA
z;d7~88je11bKrIF`lI~Y(hS@S_ps~Rd>p)u;XmWYL7GjlHvYu_7L+p%IM4qx_<@Pn
zd=lyXElpt-nf@vQxC4e_1q6fLkBz@tr@afvQ*BR5C_ljEVbC-I&x3a3
zl$Ys@cus51!*o(SNeWg*jdBW}K%Vx~%dc~qxbH!$8%)Mn%lDv>n{36
zJ+WseybgRIWRJ!;B73p*wp=8n3&ya8dRjwRW4I6P$Bv#@&&-D{m@IV&R`|RI9C>u4
zjkP@-V~8E4)?F<)Q#K>hzE#A&Jjfuen#tC(ZsrASm-(Lg@C2N5@IHIXY#my{zHTt#
zY9bz?b<=#<%{fxfU^N|fM}cvi2AYBo@RU7nwQ!ReKC#Fl3^=zUBfEs3%2X9)$Xk~e
z;rq4$9F2uz!H2$$LWZxaS_2naSA7oLC~x3&&U4U%>U=)eq8{=On#azyq8||${U9J?
z&#<9+)M=m3qy6INHq3u67|egmqhUa^G}xpXcs*Goz)h%RY6B{o8z+
zsl5l5bmAv&>6ch%vZ8$%=P*dhMzl}*3umq(7K+db7UZ-%*CfjE#G1=;O~QKR0fEvI
ze+CY-O!xTObi{347(lEk__~m^QyDkXbD^24l#QR8$EV-%%_Fr&C;$_9ZqAc
zXZNvk&(M2v&pYSLdcwo54wd)r9;_)X?t!ypZh?Ln0esSd4|Y8JlXQ5dF{l!psmM=(
zdxNfoA@?A6b6|Bbk<>aen3$B4mF)Exbov-&`&wB`K#4EUkfY?vOJd;#n>Q?3%7z
zCi!?Ll*wLZYvM7%6Cm&pmVH3)e$Vrm@Gj*su?GHKvKuzr@Z1*oEm}UoLrrFXxjmiZ
zN_-|&z+M0h>^Im+&sCy$Udxcyf3wc}HJ
zSF6fSgO-EPlUex2b)5D;mf+L{-*s6YAw?b#-ugS;@l`JWIEns#<*m{8i_-f?#NXi(
z)O|A!xZZ~>;O`2Z3DX9yrkjIa9OH;XIB?n`R2*$DEcbel;E;s#s1*nX->703tRCIu
zz6gCHhLOj2N#3ayJ-w9-bRcCQHzAUP20R(P!JL@4rH?a%+iTA|DU{()%
z2n^@^u`Za7KL(^gNhbc4;4I$OnDOY)a>O+(&y?lo#nAz_Z*=AVJe{7L^x65Hcg(!=
zL$mqXq2sSIrwki8M;-U+tm(hpFz4-;LaFixmrs~EF4Rx{Y(&G1>7ii)cf`R6_YTsW
zU|Ml&Wd7KWMmnvWa{k(q<43Q@$v)&^qntvR0GOP)1D%JoO289jjmxruIG16JPXL7s
zWG0oD2HLiQzt?d~TAubex1`fA%s){*PuDIPQQM(xKp&@vW^+{6EeGXA(Or#@p?Rse
z?`vha3H=^tkC}>D(i$@{64tiuiG1v!>o8{aA3!&8>;uDJ`59=5#*Fz(DrkQ&|6fEi
zVhgtr{A$1cn2q^cGw%G5sn-l0KNW*EVlFum>|v9C4%JX!%wKg9>|h1>wa1bzwu-@!
zsUEDKPJra`2XT!i*AXNeeBEpU37k72uX5E>T#MIZwYNyr?QE!ViCo*b$4EB+$aL&rPulNd)TxQzRJT@jSulu5ru
zx$MV%eZ=Le7kWe;O}-f%X-ayrX&N4UY=&w*fL~i_YA_WpNQNV{WE?l5kw_7uS)Q~eZpN|SXE-jgZh~H
z6cd3G(K+TE+q#^Gg(ym5^-^qVaQXw^HTjF{gQ=P54|ofS1ayU;=no|xME56hWKChP
zZNlzEAnJli<;DKYvhF;dQZ1s{wqoN7!0HB_YBN8l(m$|gR42q)Hj2`zo04yZdhAY+
ztn+;1_v?Dqou(qm2l=X25doGr|eZfjMG9~
zer$s!8Y~wmcLB7oXxlnBTZFhI#O`eu08hhD)Yk+yuKE#j@3$<%`FK3mWes7k+PhzY
zy?ajA&OIacZhrJ|n)z47TfZ#)EXA5YmqOxG?DNr}`msEhaAS(ir%x}Na+2E
zE>)-Xm*By}O0aisPvVT=F-k
z`3HS`ssF)>qhs!N!y`8lo>)h=BkdfL
zo_5tkZ;AHdXBlJbtw{T%w^*ES8@SGM!zA`eBRA2(6YZ>#w#M5p*f+9*{AL^nx*PYu
z5LA3q{kPCR=+hd^ZP^mm)BeaEu2J3=9M%0#6)Q@}{MOuF
zJq$mo<3hg8*54Kh0?vJoc9>mc>*34>+L%?2U_0N>X>a7*bae6Jqnk$0J_GwWFPVM%
z>9d_(oU6^_Pd#CV8b6u1Wpcxoi4(UpOx`jPW5{tLU%Nt{ROAdddmHUN+!U5XPowW`g!o{ygIHQb
zHgE&X+L+a$U7%}mR~SZgVU{5k#ca&#)}8)Uk!BQEH}V@Z_>ScSr26qeXk%r-(`R@7
z;;PFA4@*ciwUMQC_~?Z(ljK2rSF}t!;(wwM<#GzS5HyX{yZ2fy<#Na2!MBe@XsvzY
zv~xtdKT=OVe$hU1Ke6fF85_HOUH|7)d_Jz8&Xu>?hYkbZ+3oly%kZ-hJk>kjtq}F-
zNA&k2-#Pr1`#at3_Ha+jW0CLExe|gmyiH*je5Br2PBwspFX;(E5BDC^r4BruC2>gv
zp7MIF_J4A>V+^tDeWg+LHoSqH=LZtBV<6|*s)x_ep%d3nmvFl(YQGc7uxkw0GprHp
zzBsrUzK-_!6a??8@6r?B3#tPR-4)cuTX1-uO3@mxYw!tm)CMfA{|Jx-4CIhs1`;Q`KdeNZQ5}V3hDRFBz~^
zNEYLp`cJ6f_EcTiw;;cOEmTGf?lHJ`o36%uxz3s@POSCRiS0X71}0wHp~KKiuMs+=
zKk0q&D|pStk8YXabB4pa<$TVtxYFTEk~^K`+Lk2;-tZn{b*}nOwrqG8vMmYg7qRsT
z>laHiqy6}$U8Z$
zq;n&oJ{AR;JD;HsdPB4Pmr?iHm7f)}q!cJli?GiNs&2;T4e&wITX%ni_Rdjt9?kb0!1PZ
zyibqZ2*AJ_*j#u`ByunP$XQL8b*1n;a4i6o#_F3UHi{=4@f1x@tmNgsOY5GZd{#
zgG{EOsALdG6Jt!FF(zS*_`nG0LA2tdU4)sZVp?(%-WHpYYPDD%8J^i9%0?PPl%*pL
zWVjsH!jdook$L&dlupeDK0C78U?9w3sG|nepIJD4&fIA=D-ZG=?uvi69G^EeU5r?3
zJwAW6L?*IL0F_vaVm?h*6vEx7)8VJq$JFYs2vzy))V^ov*&S-mcj=;K8Za&Fgd^c&18x_D#j(`BWd3xNf8l*
zy(`;EpAzl3Ux~OG*)H}pwNw0lUoV#3B)PT&!f~6&xh*lO@C$5Z^ouvT&2!0>V6Q?=
z7YV20o^KdqjI~fs7i4%uM@jG;nBSTuJXOl`226O~GYB_&LH
z9$=7oM353^iskG6UORB6X4Hj~-d*%}RzBGi7u(ia6%x71W(-BuQM-o$9
zsY#U_l`6y<=pu+}e_TbZdo#@{8V!Td7tgAH1z09vGnC*wjuivV=clSnRL
z2xTar1QwA%gwj`GLR`L80eS$H{|kY6MnUj9oXCi1Zxxptf`6E&QKMB(-E5F`9szG4
zc9U&XNOi23e4*hPA4s1*iUA^HNuYU4*in&JK{^?X1I0lJEBj?u32WgTwMg%KFn^fk
zuI@i;gnKj;CG5!#kU*m#B~c?X5TT{y=#377HUJ+0LA$`;3$O2VEMo(B2nLHD_(as6
z(v^-(A)u!KKRCb%71}Hv-~#*9hX@a^;STpD$Lko^uY2h#zAmC7;Zub+M6^MUbRnG%
zF;D3wgz1JDtP+Xo2Kca&d+HXP^QAn7qaF(mEF>Pc1dlxpK<|bX)Wc)qA6Ax!)52{K
zX*8140H4T(hCp5sH&M!CxC;md8vX*00aV@cFep(lxn(jj`Qc{oa71ng=mej~gU$p6
zxZ6P>F%WKkwE}iZyyUY%jql=yy_}59D9{a1E8;P^CynA>h^{Y70)&CbcC6mw@0)$-
zfwfSe;}9s$Y#+iNZuVG?K_3KFAALA%LeAVIB&y+Ng{h~L$=G>Ve&A7xjB0a8+dHCx
z$@Up(SNrhdFZt!FXo%Tx)uhE@RXm7VT_jR$k61zu}Y(Uv@X)V-@RQ
zIwn=sW`YhC`nwztQ#*n(WYN*CL70kAd}u649Fe=mS$5x8=(zW?&OMsgNy#0ZV{fB5T`RtMB+%AFB7X4v9&_XG`k=8$K
zVkj9P&sWi(&R6kX_+zDgWg>4{`d+#NmHbiGAA@I#KEl>Q-*w48$bb6?5s
zPsv^WqVMw%pU|M}^GLUWI2ev~{kL_4;h>{)9QmvY0!1FJ8!SLT?hk*)grvs*H-i0AN$YRfKd+jV{9{fH*RsVP<${zVzeA(($s
zg>k(0BlQ^m+>j7d_lpd%qy8i+ql=_jic-RVsCt5xj9^X8QyP8lpWpq$68VFXV}APn
zw=XA-7&!p}OB3<>4jEgXef_j*=LfsCYfF4&N_W^OtT+!-2^%lc4jco>Q3A&DY##;{
z&#K$`
zflQ(kwl<=(n$kMAg+XUUNTDiVSXF^@RfcBa)j|-Z2V_o&qskesRAU6yST)0&W&PhH
zfWWjzNIb7?{GGoW%7R%#)@I0%DYo_1ulZPr7*}60u521tT53c%>KJ48sbm9A
zT-1T|rs5cR5#nx3&T%n8y--?-;Ak9C_K+aV3_wIAN`@-|rAWQ-(Hdnl{_lyZQ!}Sk
z-GuC}2^}jHc9^s(cTbhDzZ-3oU$4H&Suu-T?N*IsSx+&;kHyE-A9^^7fDS?T?tqM4$d7cjR-HFsJeSrltLb
z?Io?Oobb?WL0r51ZeSuSEPbY>snKo@Ov?ArjAv4etmqFp#qdn=Y`oJ~QhA;&?iEBA
zQ@UVW!C2#{?vk65W^_`<49hq|_WwsX&cR;|{8ZCzFkeb6M`kyoeP
z>gd%wdud`PfYw|a&qP5r~CBD{r>CjTPw9p
z8rbETVU7CrYnWCeb!5k`L-d4)*Yt#I*Sowvxk<04jVo7a*0k#j-CjAA8h?ubuhb)B;||l%hv%QFp
z-P^nrsH0A^0RFqAgSS(!fQ}@ker_GeMKhFY1$00?DpDX1nOT{R4>F22u01L0(w$fm5A%
zpJV3LO~co`Y*6JPwB5$f1)o6QHqd7$i=7Q#{&3J}&{u{okH=n|M%GX=))CmWNmqam
zX1MFi5Qjr23WoKPb?QmGqnw{@`|Ciru@II87!q%+7BfJHd^oaOz6#IMnyb=S9`cu3TsHCbhS*(%x)ZyI$drw*b&JVDID(qmOQH
zQNMl11!D$m>c_(BK3y*s5HB_$iOL{g!HI&V#7}R$F6E+oC&T`z$dqDA
z{bBcgFM-T&_a4d0Jg#_fzl>wU^w({cj+A4`_#dq8fCX7BVVY
zKud}~h{uKO_k7oUgB~HjtM0ToWjpCLxZ4Lvy@mxCvFs6uyY+ro^qKycUKN2j+ufe$
zi3Zk_tLYNACvSTms8M#^)eTIim>;?DgWMQt~4Fz
z2OVR`tTM8TOXmldY?>Gk;!oK#4CmM2uW@*K&aX#LFTJriy6D|F8}{Sf6)0y2@>i#r
zHmp^s_C*jZFit7c>Ei*>R7(LN=AApZL2(mq%h+a6mN
zZwPv5T}XP616$6zxNqdkcX-c=38C;@G6;v$Nhxh?{Q(CiAUxv8?_)%H3L~4+pUhl1
z*LvhynL@{!NK~*fn=F&=8;%Pbm&>gB122M7=88oFXZ6;31-xnL9VjLI~ov1O=E<$6{!F~z_B!H}51d!#F
zpJ6X~K7zc}^UL&-Kv{80oRcEog?<|DNhdM#O0d6@}YFTSjq?wcBeZab`{BM~(mAW*YtMI{1G
z327jR0}a5TKrRySJRpGe$9ze+G-r7|@ELXUoEnJoR21?O;0<;bvI1
zKTm6p;DkBE;Fdnw?K?lWEyYb4umJ)!;n6WZ?@
z?NiI}0hp6leV#AuqH???%1%iqZ%uMY(hs?~STReP=Tp0WF0dkf`T#5Pg-7KgpvT`^X1s(i+0Da$lUdklU_B7_
z9@shL{FZ6=Sy#U8k35HBDd{`(!(vCy&x5JImSNiV%LNnP*meBq8558ffh
zxwmwjLLS@^@*qNqFSo`$WPoptdqC;ffn69OcDA#$#yyp|f5N^-RONM$C-(KHVqfPF
zq1e|GF5J0uVM6J)9!wN_X{DG*K`Zv$X=6^xjG;6}xuk6SQcvu%h#{smi|2s%<;4(B
zE%RO9eL&Bom>8k~F|+tvt_!q_wJF1C2svRC#oAOX~KezzJLe9Ifw@!d2ig_^hBI<+7J%o0#hsud5
zC;G*>>)S5J>wOpPSL$!%ta#TR$#21P^EaM-Eci+OB5JoF9(IR^X5ds^^R!be
zduh9nRkHuDo_3rA-nm1r;83Gh3iyxpM(2s_UwWQUJJBiW5xF%UI(^pCJ;T0CGT8fk
z=%#m+v1Mf;=Guk(#C*t`AjOfJV20aVB$>mRQ|d27B73xz@K_zp5iV<+!-vEE!TYhu
z(=o1aTUyL1M)*m6-5Xo2Y}00CE43dGUmkll<;&7jd$c9{4s4iUp7+@deknvjp)9uM
zVCpTliQoJ@#$~g)qEGSLY!kV9)qe8n?v0?A)`*~8@Z(E4Z?t@=9eV;}0-w+>)=1OG
z#$6c`JrB=GAlr3pC}Jlta+sjVkL3e}0DvZIbhNlt`HViRlnq|0*$aFRvyHZAMMu|+
ze(sOaku_tYYDA3&V|MdD3!*C9?NuTR;MZ!^`{J}0FXpj*yj7ozQ(wB+m+gDFnSIjX
zqrQC)wdb{oenFd%H%QgN`dX~~jDMMI_oZ2ZyfE>>T)}+!j8Kn!wVg9MN&xocL`A~q<111q)FW-$Q;FRtM1=945H8UvVd1Wr
z^DLYvW$jy)(}zEI$}hT3zvg;>_r{h>TDDr!0+Plyb9tELmw30>H`mbsKHl`wWSVWd
z0zXyt+$t6bA3r#%l2IFpAWwXQ0*%-Z5EU!kj$$xL8;fyT11O=@4(M4Qf;HIYG)Fl8(@aid
zZR^jj=a&R?4Qg+18a9JPKF>N$V&Mh+#4l-b5;Sd@TLGsHde!Hbp?;vITgNh
zKr(>lfX;11fMD&*x$(9`|5{PnF3wEZ{}*LpX}j1fq8)psnNK_IZ`r;=L9XoM&xej9
zVj^h$WzNZlT!^d#YBhzt~fBl(rVI>Ot64eLV*%3gA$nRi1Ia_IS_O
zYs}(GgXr=-VucY|&k-xs%3kL;Pe%jsq+-l@{tLT;^-#Ny=@+>)A8t_l$r;AMdq-?G
z&0FKjlUy;vdi@P8}&jQ2sZD?wkx!iX?Sz3Bc^wYcN>
zEIbpY1o*TGYYaXqip@ga7-7-ieR=prn4UB^r9_YmTH%#SwkC@M1C9v?(6t!ASSPCu
z-DkDY?r+sPud84Af3w$J+y5J}R*dIk?y+uRvuvd72l|?fwQDMXazFzw(aA0ouJk2b
zcmSOm{$R3`Re*&EGy>cM{zJ?*D1z>i2U3JrIF3Hng-m!A!%zuxAl4z-?Y
z)_u&FiEnpJ&2CuBOWBCo&$e;B-=J_^?b_jDeN2Xa{Ud0Mlrfj82IrK
zk$3CU2m5za**-Xf7Ga%j0ai>1BoDM?%(@8nf=>W=9_kPffebFO5rCAG<_HtUe}^?n
zM0nuffq#k_?l$Kl7JUEhJ5#3IdHX*94L>iOGiTv_(>eYxHsRB$(>@)~PpYBQ2M(G^
zYilLmOZ7%Q*dr>CURAX!9dy+Mz$Bc+R;%uZ$Og(0vFcsiZTSP!xhoGt@GVuyrWN=J
zzFyMy>P-1U|#0qemA#yj)
z8YdHa{QU?PD1{PwQnE62O6bvDnYQdO)tRhXBWxH_wia=mpW3^ZWgq{TW$)R;Pkp@a
z=b!hzfAc2$+b)Rf1Rx40^~Gzd%3u`E`p9hq+*oKQV|Tgz;yxKH=dC
zJ^$e??b9n)^!rz?xEEi(EawfLpmX3l?vmh$l#^9Oy^U((x@S==P)|&LF#;o+=FXJQ
zAGll7?EO_$zxOZ{Unh!`=W)K+@k@fo2Zop-jkH_@Q3#cdB%YX1`Ic%LT_%4
zTfEvsb^e3R%jlVw+O-lJc>0Q}rS;6=xAXWJ_mb4w&CVtpqz{n3LoSj%1Tc
zF$+Qdsu@=PVSXsD1eFb?`v^$Ml!sDEs;PZ^d}O4e#5?1ii3yPvA}b(2$gbEU5%XaU
zhLPLoR`Cp*g?DOZQZu31IeH?xWeDJ}F9{`rm_EbEh!Pgv%T9sG+=N{)W}Ejx1L
zyYHG6`R!&^KK__h+3i<^pZWQZKj!E0GaI0-3VLwr)B|mP@lf%Dd%f
zC*5t5Bdzc=ijXZC`&3h`AuSJg8%>i_?Npp;)+m>i?lxGQ*&<=yv!g6_OuFP2#pWZn
z(>jt(KKa3JR_C)*tj_KOC)gDCiG>^gefa2)8y8+`kjYX`oMN?h?c$%EI>|3*Hn_xw
zr(EB2@xguTC5b)-+B-&{cS`&CwU>R=mqedJ*+<=9;@fA8(Px9`Q&euB?nWOH
zz~+FN<@OnE^jRnR%qp*sWxb`o7}qJ$XLjeKL^J3)T1vBJhXoOX7(0*|@UA(X-!l5V
zCHs`X18RN&SPsPJH_!)}mL>TJ%9dKvU(`#^2e+Ah_^7!2uWK3NnUbH#NiFg66McR7
zDf-qGeI;M1ua{@s=F3+lEdYFF7XRxy>=p*CzI}c8OV8E!i|0x{tG;uA=lbwj^xY`>
zN`61u*N5Mt?>^C2^8Jy%zI>;?+K-~I#yvslR
z^i%%XyXw0qSZvpzEBr_2($6N&{cig7@8(YYY^jsQT?u-I|9IjIKl0vtESKoSUNq%m
z%_h*jKhh8SX?!T6D-q5>u{P3ZQd1NIswT`OI6YF+(!$Mtv6UQFmPV1NGT0_6Tn4Vn
zB2N}(Rh#fLiA_RWrW8k$j6Mr$e)?JU8N*#IlGDtoAx#tc=c-BF-Bj1U3<=FI;9=~>
zgz=$)7d5@9yMr5o!y`+siv3iPZ2~b*kR_&E!u{4Oq6gUlyOzBhLD#8{`YNL_7dDI%
zw;&q{NaGeHlrxnQI$_YLoMsCX5x5QpRYwNk3q;FWIpN}Pk&0S%1gJm9#%ZBSDQ;dw|R8xr6Td`1R}2m#F@
zteem@tTy5U{tC1pg3E&_EC5cyB>W$2d3`(U#hSs^dyXIHubtt)f5BhnpRliaBi4fD
z1EJGBRh{8}s`#Gzta}Ol(L&VY)CS&V^CNyEiFZ0|o4BhtGhY$weFS2hcR`kzO$`O@
zm@m@^^a~AS>uM(d
zm{&Of(w3_u-Id*{I>$YcWB|QqDBcqxF_7s!h^T_$4EN+AxUfK7z$?h7(Zne+9
z{^iE)$&C}0=-}^~9H1kTu<4*@$?=*H@HzzdDMWS+rg)GfkYF}D%%saAT|5O^A?l-9
zs6Lt;cBB#&rZ`=ZwP22RI=}$abasL@;}L4%ZjD`+^Tx-}%_Fp4DPi!l3y%%-A!CJ_G_0En8Zz
zD3RT{Vv8=7Lz-&m_yb_olhAIRpDA
z7I!+t+Es{yO_*NqRpv>7XNCfgxqKH+4O8E$gmC-Lt*_~hYVGrhUsA7qlH586Wsot@&25>PAN)CK62BzftqOA<(w
z=6*0@VsR1t9Eu(4rU7NTKvsHsNbp7}w`eQ)TjCMcC*(6NL}ZXTtey3#yr(*!1+Mky
z4?oj?zjW#0P5u5u-F=hg>c7)EL(ZCsZsazSl*q+|=?(7ybHP&FFHrSSOtKJ_kA$R&
zT1Ti2=t%*<4}{va_!j?>1+H1rdvjBytUXw(#{3LZ{ujLAqWM)_Y7y$X%oqlifLC+D
zt8K(9{V6m+4(ai-UDAXz745LUHDUKkHW`}bCZSnMnGx%u8-aE}oQ9wP9v(IbMnHhk
zN$`(~Q>7fbGj*a{2I~sCYiZ%PSsZ_`W@+!uP51+FO#9$x?&g{7FwyM>CboMj(Jl9;
z`4zl7lJ4t3qfjVUSOr?O@oqBti@M)F6nMg;;TzD&P~}J5lT-j6&}<sn@HZgxA!+a~HRLS6g)h5f(eH1%KW9PARk(6xA=a$>hv&{NT7Gf7{=54sH_e|sj?OTQ+5DEE
zuNl#&oG>AT+R$%qp`G*_R9(PIA`cuaK;jgE9PJ0$0Zk(~IM^N>k!(#%BH77&=z*$Z
z5X3ftp5Kd#9&B@e0cHt$w09*jSPmvh3|>n*2hMr2XOSyNJ#TiHdSodrE+jHZLgPEU+~6t
zZG#Rh(@#noFE!u0MeFXvwkBWxHB!B3kSlaRKiYQ`Lxc!oEENDmn=p~au}{!W25cJ2BBmSvCWcLl0UW?EO}`zhq%t1B;_pEsr5WSHIlFf7=?tg5yri
z+j`0FKC4!a`{vbo$7A_#Q9D`CmXBG@-S6_#5%X>>oHy_53zz@#`Mh~sews|Syx(TOdw9E=~-~-L9fLv&vH1Uk3iu#%=BE?i7#X`uZd-@Q%9u8CLi3N&*
zf%!&fEM4^NksDbSrv~Xza3NyNFjW33wNugs4L~)R3txlfrkjxmm~*e!RLQ^jz;=T_
z(AVv|abw^6H*R23=6u1NYdrSK4Xo06_asOK|FVWlG1eN;o;PFtP@XgH5HL)d0OAX)
zB^|`H;&T$6-7$e|$3n39Z>)2x691i8qUcVGu1WvZs)fU8Fuf3oz=kaaitcl-it
zTsRxspQ%;&gFjc#pZ|*LbYE6G)cp393(U`5mEA9zG+~-JYfDz@cR>g40HDhf*inJC
zf@DL52uhN)|J%Et3UiSC6R}Q-IVeQU6q|
zt$3h5A%h6mwW3O7l6P%+ti)7cQQSvy1RIOZ46lUsI6u9653Bj{78bZOn*SDieBQ>d
zE2vf6XCg0coqIBth1j?9|9B0?lYiPWZ{FwsxP0O3dGi+CoCms9(0$8t4RlkjQd9^d
zSd8F|NL9B~N`cT7@GxQG_Y`KAVbbzR_Keha@3XFV$-sV>Eb4#oTecA_>ecP;`LqAC=_7t_xT;1be(6YJ
z{o4J$V$K`8mlWOyA0tX`n-_zR$x4PYyY;Rh@Ua>MHO%Z|QFg@)59xG79YF2JrEECD
zD0Omz8S8)wKMHnLr*HVH8Wzs~?JKB+%an9i3XI7F90q??u)iOafMk|T35P
zCBx(r<=M%;Ylu1EUQ0IXVSD-I5A5%}c{8YLR``3&b?7Lou=e4N=&KuN?@eLWs2woV
zAJ^<^m#vfExEisZxz4a1dvfbFZeU}dvx=|2%Rf1_=hQcICoa7*RU0(n@N?gSC!??@
zmVqbHkhoMaRYQ=H#vpMOdGAqGUoMb}BX&b#d~A#@EEshz;D3l_(WK4@k4bs*HQXT*
z$JJ}c`1RGgJ?82r(93^~+Q9<1l5E|iOH2CiU~)hO2{4(JhXnaLwi;opMG=W
zx}T>%f4bn=6&1r$3VQR)3IFUnyjr)n+cS-ST(F?dq`X-(>rOOH>oJ=b+VOlA`mE{g8IQmRw_^>=0r<5oGzfKg
zh~su}9JwEG1kj~0N4jWXGZ9|4IGG!Q1d!6t;UOsXa^u>8g+p_BJv+lDjxBaWb)#h5e*Pc7N
zZQIc%&rYo)&KJz9sV4b`^{zDEz(Ry+0&7#Y1H;g0pTXWPx4nSDI$uuzLgiYy?L|hc
z%A?Pp`Go$neEVCbc{mQE^YT2*8(VbFNz(K?eNdP
z?!zz7`Y3&08GaH5h5Vl6C#?rDj>o@Oc6}k%Nqi5nPDZYZ{GO-X^FHHq$aun_n*teW
zS1OTKO^E3cwq?iR>zs
zo&Ip^18WZov@lW&RA&leb6LaXRSrRHo)liHM?05UzFzp{#G>)D-k$i+f-9~68b9}?
z5tCXBo;YF9&?z%`U(>r+{yXfzJ|myYch4JC
z09on6I4BM}L~-UO1;Y^Ni6yEbBB4Pn#(;Zfv`xevAeN{!2H46j0xick7P>tuK}^u+
z`ou#|5=Gg7=LWew2J$=}^>=_$KxZKUbi685Rj`H>B)ZT+bR`1bE!CNjo|ulZ20<3H
z63^oOq+EiEn*#qf{&t9S1rjHLz;(9@nd#!1gDi#BnDy3Mg)glg
zxP55ht@V=@ESxxT;lk)CH@4oq8<^C8_{&Sa%*x7Y>5MA8xbn3dQ!cJpw|@Kf_3Kt(
z+@P{M&c+B}eWWW*avQ>|r2)7~*Ia3cfPg<9nxaV)7h#Wt^~BL&5KKwIRWvR}*!$C2
zx*=FWIyvZt3YQda5&A7CmZv2BAhkKQ(Spm1bLBN}k~|dj3;8n+
z;~}|P8UL!Hy&1+8j*;PRjSvppH?VdQETod5M~ue@%0}eiq`RDs__!!L{1<>OHIfO-
zj7NdlU!&t*4#YbjSy^iRIhUrBeHVtEUs%ihcnta>VfAqL16GyCA
zF=*u2fqjPBZZ~IBW_|fW|MAmDO#eRO%{7%fckI}(NspZ^>O9rCO`{qtwawBY9g311
zi#rWmg54$Of^b@mxd?{6l}xe1i$pubGZ&r>Qf4jyCghomBqcmdDNCVNDl{x&X^A1J
z1Na|EmV&NrE;>6&5c=KSp@T+_$zM60O!%BUmP)p~1s<
z^Pe_akCHARpHrk1l-qyi6Z+38)1S^d`J4sp8L>Zl@T=N2$ler+F^LMp
zwm|wsnQ%!26yerO$CZp%N>Ng3TkB$A6+so{;7}r~DnK-Z)Dp3iTJ7c?
zzb|f5ls~)r$QsSJJk2j@4fyN%FVjh9V2{h|nxqHcK?=x=L4Y=(eRFdg}Qg
zLWaR;lND8qfJ*R_uuU^mb`&K=&hdv-Jw;vQsdV%36}_RTi;g)I^a!~*15YNoiAWu9
zJI$4BpTYK)+g`wo94H!>SR=B3p>nI-{zb~+a@%J{kn
z13c|M<61y5#iHHjLuZ=*b(nrh8^!4U=5sO1zxiB@_HRBHqyC%E#pwU$bHRnu&%NXI
z&@Z_0H=hfxlz#5zi|6}-JEfls{gU`>7W^sY8|^u2_k2&R7UO#~|D;EX@0sts;LA@>
zyZj!sOMFWHxu5mbrkn6nM#Dcx+L9-tz&HzPmDQGF0i_yWAto_4k;)1I#*^v_dv0+C
zD2&YxTalSba~kfq1f;G`f9+mD!M)eS-@K8U$rjC-vxr6G4~+ARqIa=Imj+*AjSwDo
z2tV*cyR^J(H*XfNym{@~P1>_mFA%z8*}9O}rx5x5$xHxY;!_t=QshnB6(*}k>q2U+
zim&?Cg)BLUcl5UO2mhoU;2HjdHc=Y752iGE6XU)ZXX`RXb3vlhE_nts|C(6w?#_Oq%euw&tdldB-
zbwWk|7HB_f(Y*cVazumSIUl24)Rvde@z$2#j`rs*Io|&BOLFl%+#5=MGPS@Obtv7X
zsUk2w+-8A7!4QlQm+SeBZWF*~B3nisVP#hWQ
zblJ#au;E!OMko?ucXDXS;3#r=Qu#CLFfeP*!8)u*>aaEmPsR1CHDiCx
zsj=xct25@bKB!8+$`R&ML7{~!i^un`VKtu&v=+X?`nFo!s@0N~?u~dQ#?zZ$#kK7m
z?7@?A1EOIl4}uSlu;|i3dJ0PCDVCXJ&NjoG4U?11LBO$T-14x}<8m{|lftyIWqV_6
z!gW`svvf39QL5B*q}Z$pS^j=VTN1h%a>(hVttxE1IVPO678$3Mg>YV>R?}f-*HWrB
zANnKzYtq3%-PiZ;vZN<}U+b{q!qvI+?(zq#wk)6Dn$F!1o_3wxSGcBSP(ZCweY%W(
zIyB_;p}^1~<9ZA~`}~+;qr~Y8oTidLb;9w8qKqr#2$-QBKzU{&oCn*P#;96UV?UFL
z(u;={#i9&JL;D)&7NFM))&^63t9A2%$o!7J?~trr8b!rl)oI0}v}LDGxwk!?t?S27
z0@lE=7dmhv`OmDlOG5`HjTh6CBY^pZmY6MyNa0#)kASl;CL+%TkJ17Kh12KZtk+KNV&O@V7%R
z(>r3!38a^Wg^Ie3hF)gq#KL6fqn9C4uV!jmrlFTPX^2w3>rp-zNwGd!FKNBy*Mfpy
zw;W_z%5V0`eP_>_HM`xWQ~c_>f}%g(-pNdh4{pusHuT*3wTItW2csI@(QoK(tOF}f
z9YhWSQx{}VjEt}$%H((4Yr}2i6|^OWCt?{`=&&g@8N%8tTs3qb*QEIr_gT7^d&+gF
z3;D8fICZwnE~+=FCrWtDj9Sdpx8LRqPGz5d<(0ehwauTc8N4+=f9v2ipUJ&RI9S&}
zm%xPz!xlq41@@+Q8~TtZP8>LsBw8OXMrQA+BO(Dxuh;+B^fq6keZkk&tOt0*is?N<
zH!fEPpFi*3B~ZG8g79vdcgjf@Id6uZN57N&G;-0eZ}jQ+B&-BFr;METzgVBr=hAvI
z+Gnus<+c~FHRZM!Dp$&FFH+tsw|(Xl`p@$1Pv?$7&ukW7`W&D3IjmM`yU(~5u$eyX
zPr$99^GH4)c0FH=053lS=9b|n^dr$9n$fd9e5UirpmRP&O4Hhf#p?p@Jr$Hx@(Tp`
z1CA&@Or>?=v1a7RgbWA}afMJ6rvu$lgBFF>%t-ZMRzv9Gl)=>|%7YzBasLQu5|;;n
zyX|!sb=xwoWA`DeH|*TM`0GjhH1fuq4a;v)6UMLkL92?l7X7l_@p**<^R}L_sbafjWm#1ZV-WW?(|>`R
z7NwwuJ%MP}5FuBFC68`vmOOaivn=hb;gn1vvnfnTMivFd_rYw3zhSwsI@BZl4!^{d
zwQHHOZVh8_U8A^K1gpg2)eQGn$KKIjd)VupWArl$5nkY91r0q)eigNNs*okC68YL-
z^}!`qQ>Xh-)E`>7afGG2k`iN3Zpa$y??=AYQqv+%UWdHcW?F^!P}*D(>mY56ql%Chvt;}A@yn;Yv{dXFLBG&tPh&i|YpOXu?WCK^_8Dqn
zx$On&^X0Y|vIFI|7qLaEY8na!YdLq-bvecI=!VWsUp<65BhlkLb!
zcv_sL^c>jMRm1n;8Q&8=_m1-kpL@sqgwMU>e!}P8@ju~nFCXZ0?|4h;_wwTjpL_Wt
zo=>`b+3}a*59Kk+d6)blK0o1mW!DScDaHJl@359W{Pwha>6M>Dc6Q~>^iRb38n0B&
zO%D#R;GQXCQDv5`kUs(pH#Qn^K%#IJp)fJ*1{kx$nS5sGyD!%1xv>DkoQt
zjV5oplEjjvFCFrcA}Pp_rck1+Y%cD{IeZTV_>-b@qB|B&RoX~*QySB8N(XrsC6|X*Aa^~Gv
zH@tgb%?b)-0CL3uZkz(
zTgLZI6A?Aoy!9@B+?k>l$;(Sf=rX!Qu>04B24iW
z%3Bag3;2uj7P4rY5m!8M`S{TZJNMY@qsIHu~_S7xoUEo>x#b_Op<#EAwhK>)fD8hplZJJ=Lyh
zy?)C(cV5(^|CC^}b>Wb_64ioUpsx0l!=l6A}Dtis(dRKk3u(z9-~)
zVA1^_@;o45`T};cHqU2U_(}fhbYKrvV`m9phj5gn{&iU|!h0g=#s>E-mQ+Zvs<*_D
zsYJ*S6`EtR$n_F=Ip`Nz1#3zqHy8^^avWwBF&{wF#7mz+hR2-U|2(&Sz~GnPTk)QJ
zFAu8r-wm67&)lE5aKQL=c)#R>{;60$zsmKaJ>?xg-Pt0jZcZS~LY}1#*B=6h5g~{`
zUQ%mm#jBnLFH!YGs*Xt15o;ZKm|X9g%tHZzz1O*L4Xn&RU~TxhTaDLYW_h(wKJl>T
z)F}`LTzp8aOwbTQyB=&sg84sb~*YsKPhC1os{Vgd24w
zXvulF8mt6^r3P2YA>+xXKs@b2c2dpY-(sY7=``VO*xr8>4`W~Q%6PV1-yMh^%w@ye
z23XxV`=J4NJk>)e@Dkj`tLUVXh27H0>g=tuNwwx9ScJ$8@Yb5Ab4C6arujXSQz@l&
z10n!RzWR}u9
zBJN$DJ0b#xAG;$?+4B3GIlpgVy8M@TllO9?MlbXA;va8)RW_Kf@zh+$HL<;08MUCRa`;ZH{urc__~1HZ-ELp<0oev7=1
z_b$}0dX%>bxo8epRkcIgJA9#9RjtlnX4Bkf_@+V28^QjCJ%O`OKLQ#h9vQ9mn%+I|
z6SW7np}9nRFK;{M9eG`{y*c@LF;?0`+2|ima_8~FY7-A_tbBIMYBzHZ3lGeoPXr$Dqek?oQu
zRkVxzpPBrO)Rg!c!yXB`%{bwFa$b`EF6Tw6XAX#C*8L&t&h)>)jN(bOnzh*gLiEsM
z{zlN_FL`GYvQY2{P|i(Mcu7e_R@6#_7fX+23dfx9e_6edO`GvGnFI^sz
zFe^!I|9_zitNT&9z(=eUi;MWEKd|9PQ=ocT@R8Q;pJ=xM(E}CSDV{$b`wS@{o*W!Z
zux}0ykjNpT{7jl3619=0U}Wk%(hC^siV_32DlytBI33`VsN-d`!|6}GO!V$70VwP~a
zJrc77%^HWB2#&-3mMPJZvM3E=mQbDqpc*^Wh
zK87u_&6+fDy8TuP(+_U`wXpEls3ULG=s9Z3OFJj68`ib@)`K3AE_sWZEL6`s0udzZ
zTJMSiZyg|C$Ki{B9t)g9LXY!@PGuxxRYZh_j8IN3rz7R%$V411A4fNJ&Q16BBb5+O
zRVl2)h2Y6Y`yyr;;-0EY+fyP_KvaY+4fyIJ(+jIVt8~=PWCd9}sm9rcpfob;M$UDA
zo4}&J*+Q=GEtmMu6~0z~?oAfZ-ogNisjB-G_l%$E-|)I0O3SBV^;BVDM%W+L+TZ8u
zc+dYwb9LY)!^tHg0J9vLH&@4mYJmJI3miG-=gaK&pQ0Whl_F%jL^VM7E!I?21H}2l
z-_k4M{T1jeg{RC!1?YrML{WSQpoey=mCGfJcI}NAJA2(u$gBPp+=1FOVeTVITuHfHG`CF&fubf)&Rtc{Dyixn!yQ3=L%V~U1P$cF>LPq<
z*i%7)uoYE^L#(FQQ)OZ`MNMy?J%vpk5spMRc(lBG$`b_d@vd3CX-H8(7kIYRzjiC)
z<$)7t*$`SZCT!M}!87bPQ+TmgM>@E*de>p=ChdG_%c!0;-Z%m}g|7`){*j0t&QVsj
z-c<#ADkudpa~Y|L09H`pLJy^16M;;Wk&5sSk$ptHlHi55XgacwpgZM5F1UoMjge6H
z5yS&!A3fHEY%xX;BS*mKEwYcYYNWf8lVYN*A>=qyDzQo;`v^vQswiZa8;25)WYnrB
zLfME^r9uqA38CAg@c60U@=y5tbAI32Yih$|pGVr8b{ci`rKv|p4(k>dpEamT`^M{A
zZJxDeXS;!Y+P3c9Eeg>9f#i)mkNx%b3D)%e(Nm9(8g*#O*uyQ=_^6MPBCFS8QI(et
z?>lyU|AC8Xo#BpS>VtI_D$q7i0;3Cs4Xb&`>Q>dVDhqgBHk+GDFp%~jbGe<_>c6kD
z1I)BGfERDSrT6{P-G&ww<#KCtza+h(KD0iC?dS6)o)Gxyf;{^`
z$TL@N3W^zu=u=p7aOOZ3lWs1r$GFHjP|ucd2%wr}h0Dwy3uAkMaTUq*ppTmVHnIOx
zb`Ax!_^{gJ=W}(7cj7F|E6LO^m^b3=uAsA!c$+8-LNElBhZnW1BY*a3U>V;d%aI5y~`5Bjr2+2=$MK!znB@$R9Da2#P_cuDBqIsv`Iqe#lrY
zFbV(suWEX`GNaeIqH7tJ>O92`u{-cdWP)8_l
zk4+uKf&mi!`VIaC%j91m;P^1`It~}}H<1@h5FM#oUfRM>vzquv{Z7y{05okk{e<=S
z!aj-{_NGBq1adKQ!N;0}&D2jtEGEu2(j9uk^bdn?zX0a%-_2j>5$MYNsL&wo)W7N5
zBNqxJ-j!xcv4*FnSZ$6#42P1%T^fSEU1{Ppq$z+{i;BYdP<+th$fT^oFvkG4!xFe|
zxu0d?_e=Me#6Pf(?mc-dG@>_no}b#GuBznz#?M}Ti|M;RSaEss`MqiRBj%4gvHQmk
z{bC!|&F0_G9MnbJNpo`qR><01TO6#w6_{?uS*~CvR2{KL6(H}ajJ*h4?~A(_7(`M|
zpdTCJzZYVuBqT1J9HO3?iqbWuU-BFi)Q4TtJg@Gr4m`innCr+!s
zL{kO~A2~jasB5YKh^+>pWiZEXSBt-72e&&Lri55?!y4AxvE%giDt(fZZIzSSbk^%0
zEAG4hh{^w)$<(!tw)t59{YQcWKG#j{+V{uHz=z?8-(N2F3UVysD3K3P4pg*Ih2sb{
zJ0Tpx!4<+k#M#J{=_HbX@&uV*S_cd}$Z3lqKw4%`wVUVeR;U{)Y1)1frYZ3Hv|b0BE5C_``vAm0#MX9&sw`Q_m-@F0XT#gQ0?v`%k1MKLZ)
z?+hXbn%FmX2UU-g736RdSR&2lROHgrf$Go(@^64ibb$Z<9sZ}*c>6ANqN#UbcT8GX
zR5+V|qh>yP>8GDAJ$p$#{^FMfv(Jpb#0o|Xg@czVwZYfFkfT&tc_z10Dhu#WV}1cZ
z0#gw~qv>s+9rsL(-l=$#fjEOV1!jxDpO=WJR-JK`a75k;aMEe
zsG}T~(vwT5+&Nhg#W~KLEC8Tn8f(O&H*N;DtL#uKzSZ^Kl>DXD2e&&l<|qDs_L({}
zRP|t;SF%~lpRT_#u;%jo7x%ifQ>#;u-@AV0l9s6#nly#)XT38eEp;{Mjazszqt&uk
zr9h}YqRP!FP6-nn7(^$+Hat-J>VTGtgikx)I5Pv`H4n^-q|LOuGMi)gk
zv3ji5WUDPX)f!+1+M~q@iECkY2Xm2@OK{$4%*bU^^u0Ht{h`6^ND6v=>iIV2hA2(
zT6)U88xMhI%EKkpM5h7_e*!KH-dAxz#O1~zp91Yz|00pa=qW$#ksIOX2k|K2OAS+_
zpV$XdB4ZTU`tQ63|1YnlZ+_TUpZa}qKW*dptjqV`3;kea$vw+O(38d{fPknfcfvKLGb|m!yc;CSPPQ!w$=o40vxAZUk6Vy`K(PN1B{Wp%_+8ru%95MmVL)t0
zy-X_$2*g>vXMG00
zdWKzNj3<0Ga9k{}!~PLBZr}y=^wNT*EN|PsrHRST#HD-Q;0v|B2`SEm0{%<=rtFO-
z^`{G24*P=+UWF^bxq@#8bJYe2%E&^(Pr8C5RZgwvz(>p?9idkI9Uv-$IiOMzVvV3*
zI37&z@@i@jKgz3qz#m+`%mSZPBUvB!Rc*oEFRHYg|WfujO|)@?SRbJ8Nzq
zb**`J;$P3RK@%C9zy^(jYX0DO8oyh-Bkg0?$q&+psF7o8gcgse9-PUhupm{B*tl$?
z%C(2r23Wi0$g
zaMj~rMU7B305G?teHce_@3uhgQiA)1u;Acs!L-EbODssi#o+(^tsc3t*lpF6s+B7t
zzYd%g+6c5ON2o_1A?}Gk<>(~k=q0Aj>&z<`>t$6F4Jtoobtmtt`RFHu=dYgEqf|i|
zG;j6%=A|kMba(iyP2_)wf_*YsX`kCB5d&mx!a`I{2@VZJ;SEI_8iYcs{uWgPN8{73
zu-cf--ORv(hGv^wA(<8lo=|q3STQ>I*l0!Au>|Z+lxM(y;TUU9l*8o!BqbFdX~BYj
zWSwuapl=$i9a6-TF?i~|wgr~c46`;fh=w)~?1bk8aCfBkBYTx=NZBy6K3_w1
zC)-lOf&muF=s*=2pbnvOLCwlRNHxO^oX~Zoxe+@HO197sOlEiOCSwN=dP!s9e(v9H
z6+0>{&_}q>b?KVG_%Htd`sMq32{Ef5u22sorS)fjHtyD}ef!?04jp{6YWI{6)y_F>
zl6Ico|7w*^&clZt%`+NM7#1`{vc})ToX0_qRKg0Vt@O{$tH%5TazSO|`TaNKQ$Bng7J_ll3oFml(-s+?IlGreMJ3c)nsp^uAF?A{!MvAirINYwg5eX{LL5d3pRSt{8LquSNJDpgg<6fgM_$
z&SD#f+x9TE_7e7Iry!mglamYp1@)^Y;VuL!?OyukQXb2$(;xh(Cb_TBAAWK~VnRyv
z$V!gh1>vk>)e#Nrjjq9Krgo~*0sCP{Nv3Iud80TJD=LUeN{9%e3MDD=aimni&V+RX
z59M?S7Y(He5P=HeK|WIrCM$aqDb8MkYr~w;RfhX
zzx#+^U%!P_cyH43JF6r4-Na?1)dov>@tc!2e0Gw>F50H?6JLB7a)h<{;YZf)qu>ue
z3ZC)R>|uqWJZ8^({I_*MAF`c#xSjv!#!vh|(P8_S5lt9RBhyUte4Ovqk;B<5w|hkd
zd-$8P5i99mGZX4mY?K|2I;t8VwJGw`=raLsWxI+>_ByyJ7ABs}B5+0dub46IQ
zY~c}sbi`?xEk~M*(P)qw0Il5O1mcFp5-#j)Fjhz9P&pxBtq0r$&
zc|QNO(Ld*OZcsc_TRa2cKkA$N!jga1SzKS%`Wk`Vs9W_tp-I>Nx~we-;J^Oo#UonN
z;>>Dc+EUuY-lbv6z@SY))AS%4!QvZb68CHpe^Dr&oF#;vDY$b`
zO=qj&by(lIvCNXPr{t9*{OmhBS+%3DFt*3ZA10kFc>eui%O~9qh+fjOQfICCMN4Sw
z2@Tgr&KMrI=Ib#i8F2Sk)@}dTZ`Z~a%^P=P_qjha)DdrV9)K7$fd{GsfFCClVrQZ}
zS2TI?L;!~jS&_**;1&>gB}6hTAi4>+H2&ZU{s)l3%{*JL
zxb~UV-Pd$=|H(G=Sl)A8_vJm={Hs^_6|fEQ-+?adJybeUngi{wpYl+iN)^Jxf;E4w
zX^_8W^7n%!rAuwqFDTog!e52SN-Y0l7VPI>O0by{uIOy5jTNaP&Q1x!BZgt0YSLn&
z1O^(J@WEjL8m#*6TDC|@VM^cL
z-MhAL+oE&J&P^KCubYySl9O3IqhexwWQ5HM*FH2rt4zBWR)r{78z||EEP1uEk^n7A
znTs;;nhArnCvd??@S#+F8v;9N0a>gOCLb6G=n&E-P0m`jV&1zumNltge_EsJomV#b
zD<_+&tkdiBN_NiPom*|SDQMd5EyxZ6K7q&%dZ^Z$k-vU(?{5!RuVrR4W))JV(4R$d
zb(3o~&CSWJ7qNN%tT*lIM}Y|u$D>k$KHs4q;=8}j`JTW3-r&Y9^7+_ua*DK&MnhPI
z8+A6_Db$OnSwHk-7_El{oF}Vih
z?-t{COt%%&?SKsoy8Rf`571}gHm@QSk0Rdl+9HuhUL)nxlBlCUyv$GRmz(0#1->|x
z29(c(wp;o*Sc}MlmZ4<8fabJ?J0qNAL1PxNcS{qlEx6NYPP%i!JwP@>V|Pd8==(aR
z53Jp9eINetxh8o7S;@lBSub^9yY`b1Z=wXIt{%~7c*R~Ocxe`hC7gB7@kCCvT%ya#puXr
zV?_`l{*#Ff2v^vk9yxxHyBl}neK4>Mh|+tlO*3XXcwFy$co(0+rnYNcJW0LnzS*p!
z`fqoIcMjo|SO@c*lV_^JAh>v*rwDY4p@e30$
zz|=?jYM&pt$9*YTZ+#^8xZUlrJY?txNA`8}3-H@__`{~G6!9QCD$5AnJvHj^(hro|
zAAMw3^vC)ySslqCJ^pBAP6PRR-N)-l~l^G#O->
z4q1h>s>6WsFP=xv1}@r!g?%X*^-lZwr_JLF)|F4Z`nd1P*{hX-&*MK9SvCy`aWof>
zAOCFM(j8uo^5BOj)g2piTq;zu#Yx4(mjYSOokHkaWt8ot(tZzs7c|eAypg&K>x}K}
z5N*^Md_VllZ6v&l9k8P!An1g6dZInvd!UC&u;8(^(L?@YXV;tu0XGBRWIQuhI8_iA
z@$#C*|4^JihPy{}2@MGf@FUiVBn(`#;9%E+9SG@F17V$tAsxjhcb7~dW^og%S3m&F
zpl>m4{Ug&(o#Gh!?BCNKsbzs18rIEH-e(SDR?V2E+)$Bp{AJGz8_jsyd)4X1;Sa0$^OUnEaEWYPc|)R=k@3gIG$@X>v-s2N|oa?HfVP4mB8zwz3fq=(lkjpDMC
ztZ~B5p*1rWO|K>7D8qWgC!J$dQ@N{;y!kwmFCd>X^CVMqg
zZ2kYnMjfvdS}IZ}@hCc0Y}8d&uWZCI(@_R9s5`P={EM{?6PsQU{8H+TGi0Wj9VwwK%c3`4+r8}Q9wteqOurz
zAPc03L$H&zqyRp2@@;QD_{Hkrwd+P=La4k#wtZUKmC{WXZ)DOMYoJdeI-R
z0?Xl>&Xnh{@BAQ>0_KsW4-!al-y}7d2N^tGBiiN|P-Wa4N~knpJ;0xWp!#>ak6X`fwwX^E3^%Y9De
zjsteFuT5(>KV$JPdkW@cWy~%pn46tB+bwG!^LVF;)tr3b#PmTm%C9#n$It$B<@#?H
zf1KCQzi>frK|@|a1B4u)sR5wnEPg&A$5wMTI_^Dsl7C+BMMs1R50`DZ=P@
zPbykDaMi8db2jg+pZ?J1xz&po){JRbNLCQ!!d;d}Wx&2=+9*s1hj+JrEM12tC}z1oaB@b`SmyZS&#Wjp&0UANQvz53yjJ8u|Yl@b!^a(y4;WI`<#gX~}J1a8Yl
zH!Hpc_PI4%5x0tgegtKa3DqUQmE=3beSB(*k#^HxGJGk|j_&i(q8iEp
zfEG-#cEQ>pY9wrCG){D?-+j!gN9lG-X5kD7Rg3AEg`Jv}W7ZI3+D5V5DqbtD
z*z)Td9dopKXhQw=t$R1JipFKqDP_RMPo}T_%Wd?D1w(6QPO08{oYm^DyrN`jC%!&?
z$=h%W!B|aqkt5l$lZKo7@aVwsg9k8OLX_bT<5aCfOr}-=xP{0VnFZkXCzDKwbq&sA
z#RMibzRzC#{q*VE>Mayz&z@Ea_*JCR{txna;M`5{rx+l5#Ji`nkV{a(>wHT-_G%CzF%HG1~Ot7Wm2btuw~~aFZyQV
z?aqdhs4TNxq%3de<(kK
zj!pq*>&<(~%%}486lqHhBXf@Cy@Y&GYF1&DU&V6>DP)#&mX(FF#LKcCJnLYjyTFcCu`|5>DejYhE|huojxxC{2~51wIh9g0H06r@@YEyydCA&
za(H^C8BV!RwVDhLf1v4ml)sM8nGk+U6zAK7=l`G1GAmf`s*Fh8z_z7SuDu8D-(PU&
zz2vh}WD+gFyZp>C8sD(ER-wn&CF^l;G(L$U#jZHyz$S%)WAG#;cary8xr
zoQQv^2OG<|EbZlI863*WT$vrui7c1{FJ7j*ijaN_CJ4@>hsTX7x=p1GirU_^sabEeItl>
zs1}tG9)n~6xhn}Oyr7nnjtS0a`_Kz0^fY6~q+`u}&=}rEn<45Lc=@l;s!Q`&sDj!&
zwf~xOT|BPsiZMN8N1sRG^AP@dCtmwZh%mu}gp#Aa9l*$&gQ-~7#Cn);MUqC
zpRJG~fAt4-!9fa9ge?n|;Gh*MsdPWc)%~130)lz&VYuN#F9tCQE+6o9bhAgh2SX4<
z4=`e8NT(FBGSIjEzR9;e2-l=$&ay|Kpy(h<2Ykt*?9QVqeCzH>x
zv9-VS9EqdLBY#xSpK8Z(8lrjqf_ViB!;VsL72McrP_fzGl1s;%nNJfi+zYgS?0AkY$}HrGgU(tuJ6hIBcia
z-LMF?o}|@`!rQ!UyDCVU18Shb?5!<{_8FnOT!E83-J;xo1PVpVQLsV
zzMCa|Aq@cC(swh}QOv~XG4@9VA9;z~09yUw4$YH3FXo?byrX{Z1Nd6NfBdTT=b!lJ
z!@^B@y0j1QpG}{A<>~TVeEx-h-X#o1Ea`N7{`E_$e}H`^H;AY3`8WP~hcG~1C+Fbv
ze^j^-Bsa*@@%eZD`B8!TpMuYS@b(r8+vNi=nbvAirDZcD3GiwccUjm^@trrIqFvGmITF!FCD10Z&%}`?cvywO%vqfH+NV
zkjeo2tqp94@FVc|HDLc~13Ms`lpFXQemBD;bAOKt=P*Cy;?Vqov8+|cxmF$LLKXuY
zKLprK)Z;i451DW_Pjm&oPXhKQV4GBYZc^(V6mG~1C-iq$eC)khI{Y%w7&CIaui4nFYT!-L*}Eo_dwZQ{UmU=m@3uhqfgM5}d$
zMW%DGqMIJxIgehUFovP3H=U?8vV$y^6eGX`;ya@mwPs3ZT&Q}X?$uD3t}T(
zVyY6$CyAGO`4#t0C>Yc?4m>XMY*t96kolYAaPZf134bjQ5$U<1#^JzgnYrSzEU_;Z
zHvS;#b|92gr_XQEAs`C-a8Le#eIY@Hgm^CC+mQnb5H2$k!Jmp^xtNdFpLSVzGZ;7tCq1FfB8trO1fEQhVcU@)bUs+H6%Lk}$
z8bfpc$m9JrcmTEk&8Gefn|_l1!1sQzV1d79M*(~~f5JgoAX4~}^5gA^HjfIPu~O2Rq*Ywl_&6dmLJ75@l3ZgT$tB9M+Dn8
zO&qiYqtfOA)RL&OAcTvdGPY5>yV1ef3Df?-6PNV&T#OKhEa)+=s$(uE9`S?jr5qi3
zbIs_euWLpJmQ&n(Z*;Ir&FG*}(K`NudHX}_1WyG&B-SPG2|OI20h@>I0N>3yU^zHZ
zVL2h5tRh(-td=_ks8N6nE}{Q25j@|<_)K6rj=
z!-$Lw&ylI+<*9wKq|fTb;a}V8qGt~pI%>q|rN=5p9GSlGrKIr8!D&uP@3OwBWo4;-
z%gk#eVU1`G`6Z3e%Z}4p0k{0oSEK!glWd&-7^-c7^-hRoP5anZ2Hg4v6Ury*E8o>G_l8>~GdYXX(Mj??Vyd+#aNb$!p?
z>VMXLI1*Vgam~ou%tUGvA|;;J%z3mE1|Cv!J(0{
z5E)Ak0TZXXJVe9BRxfMxxSsx-s)6XPk7ZTc*1f_Jthv*B_D?~bWAARnp23#Ewgj2}
zm}sLFEvC|H^u`{|6=CoI69}YBf#M&dXw!hjq)YIhF(BE8c7>XspOhEp;m
z`OR#r><_EhqAQ=WM_AVnSpGNDgG)9|>N9nK-o|HR-O?AT#-Ex~`CtdT6VW|JWNlPF
zxTfq@ma*Av{Pd4{re-OBz1lEi-t;N+Co4lgWqVi`){#B--GXnF^H0`)bfB_RcCU<~
z^RBL6{n_mF$YG6DJyKu0_&f`KT$zdxMGq+rv7XNxdWmy)Zrd^E;pq#W!;t|V<~ZKD
zPw_YKLv7x`d4Q#G*v>Yv(}1n!uw89n;{l7~u-$E7E1NQK*3g@KZyOluVgC~Bkv6dE
zre3hZmw(zoM
zD4D}+O7ff>RMRVgJ4>KeTjOW4gNF^gWWZe=&HqKi3Lf4PIs^ZQ5H|rL3|L-HI+OH%
zrwcvKcX|(hu@wBc5b%EVg-+*<${>~!4gbpd%3L=84C{4P{Y#m9R(b7=GFQxDy@(H4
zfH&Ga`3CG+qDfmyd3+$p>DV3_Cq*g15B{FG;Br0Mi7eY6ObU-71`$I|EAxh+Kqhnz
z3ycnmc6AnDy2s5M;2fw!_B>K5#f8Lqa>2W2oTnP3Q0J+`+p3Mqv9abv-ol0@QE|Ov
z*EFold2Yn0W4Y@WZ;I_57rA2bqWqI%>Q7Cce5$_w)yb1zbxZ5huyjf8k&!ho<*i$~
zEEfjHXQ!W?J?HGSX=mrmK0BT6u})3?;@8^txNSX+Fq;Eo
zun228760h5!!071fr(!${$H{y+V+3hXjOaJKNNrVt?rq>s|Q^M`}SAeGk-i_tG;#4
zDc`c^`Fl$zbTay(4|F%&%qB=hV!kZPa7lyR2j>%TNxGtubKm1Xw!JY=`(0x{@hMch
zd))NJA~NW(9Jhu=<4EP>zM}o}FL-`Yv6Wb;Fvx-=%2!SE6<9IVq=z5lE^f{9hM|=d
z)>tK8`sgFIF4zUE{QcIIg*zy0u3I2>l|LmXko)Ra{`B@_NjBF-zc%;ZuZ{4hxnGNEiPWmT#D8WHho@Kggw*Ju8)Acyl4#4Oui*Bgp%f+AqxGF_+IdIck*%eQTx(r
z+xBItQWh=y9h_EGm6kqyIPag2Wboj1MO&)U(}oXEORrMb>3jCI)KPrtK5;E|YH}8S;DAXyED?(-aMqf4AzF5UqsZI^*kJz7MD!(RaS1tYoc#&D
zu!j#w{ja_L#uqSW`I%x5W0gK*5t2cw!(g3Bz>Iwaa+nIkD=i)#Xy!99z45qI*LWPX
zv=TDYFlhkpf@duyru@zl#91J2f*o!HD-aKW_U8h2qzw%9Y}9&3+rUnmeUK%VtAg)mlenQe2^5lxLrs^jaw2TCWA1^v8UT^7*(LIrZX|
z%!=S_;6%@4W{?8d(IZb`0|Sr5N1;5NiD(}5R@G@P(74dfL}?e>#oPJ&HJKQ5Yo@eI
zjBb1f)zBX5NssaNF7oy+igxDjHE@haKlFWwhJTO#F3Nb(tMzm*w}GP^!Ks|qAHOZf
zb&vT)vt5U0-l8Fy+ck~n5^v{HoAJ>1AsYTY`nkmWW5P6P8f)P5zhBw{ksME9c1hAS
zv9xg?%?WKtGx(f7;eCH%ig7*@Yxr-|8h&JD4N2zu4Rn)sg16DreDkQLajM}8T|KZW
z1Bax_R$019tm51vI6?nt2Zq!U!1?EVj-QJw`8Yn;cyZj2ytS?w4xJ~#)q3n3xr@l_
zfg(}6jV^SbaF%_e(TdQ8qD|7X;R7vda)7z;p!AC(EP()CiNvIp0&OYD*o
zs)V8x6LN($;vfGD{lF0p{p7L-(S;XVt4y%$25XM_$Zw#ZV9mGaSEu=wo#g9={^g_m
z`|JGs>-0UwbrAZ#<#Gc=R@!HEB6t9p=r}WX=6q1P)|e)~z`i}l-hplLMF-`5a9F&~
zCDeJ|R0mNaz&D@@Rg<5;<0rn@*jKvBzEz@uq^=Gu9C0YE=n^T$0eE5x
zeFTK-cz$lMri5UXGj(pyWq~maF*`JHpoj@LiP4M`H{9tcT@yFI%fc={qd)wy^6tlN
z+J$5eVM2JjLUx-qv$3Iw2wp0y9}|TCAu3IPyS(R4M{#50Sm~PDf%4JE&)Pk6S^413
zM;}uc82h`>b8dj1Q^M%G8N=ZR3=T*HkJ3UGT$@oqF66}6BVRjRS)@WCw+rg#UFo$u
zy`(yaD=BSJIDlgb2bP4Z|)H91!9IAovafzd#;H(Ot~(Y)l?I
zP@Tz(tQ9twGluda+-(QWKNhW`<6<-pbW>m)p}^#|V~#&Saz^sn2_ipZiX9jiP5#9c
z=}gDQ4*G>HvJY~ynK0HSt@;#T6X6;1F)AVdUsU_?Ejh&Q_7AW&5+NtP;smIa{Vh3*0cbvkV^RKvQV
zyatxmgYqfaJK7@*PV*A$A>HOdw)q0%QH^bLlFh%$<|*^8Ds!>>H(X>6%5N8y|1kT;
zgS0zE;bGVWoQ6$+7i1XMRX@iF3<1SpV!=sU;|By
zjjp9R(1hH!HAJG(w{8qOvZFWTO5V}l(UIK{1R|I+;9`beft_3eJGCP;1ryic$&(U8
znL!4;@b`lCwx@JY$ZqyjLw0>`WO86a3cDaayC`j1zsQ83o@wF*6%u7ML(-@B>z)u8
z-&Yiuz42_X#L%Fk{-<7uOX(btTdeeW{h3}#p@u;NPQ4hP65^jTNZmI(V2klOY%#oq
zEyTyGzCol=lvs(ydN8Sq0^Ku-&`wUNA%r(XTEMwL*soPMFZhrj&`oQ*l1qR+&tW&(z)r#@V~@rS^?n4bwOvUI?58%c
zcVSnu0k34(l>pY-uH;?Vm2A*>1NMs<)@(cSg77`g;g13P6|nW{yK+6)CCuS8>`Hb?
zWq|$Grrt5wl{_hb4cLF$z)oWhyZ9V_H^Z9kN>0MAWRFIRdVc`c+OFhb*p-}?9|G(q
zV6E*+cEGOWb?GEvBy+Q{E5VMs%tGW`__}I@^qfO_5R4~85ZL&W0BglJ_|Po_t@Yx&
z0csY>7BdNciAZA`CrGt8*)<<3L39@jP2HqZ=v@**_&fCPWoAT9xP@Y*Q+JZipQrk3
z*<$H3-U$vugb`-UTr1sjtu^0zMLcSrDFQyU(9q{eNgewtU=z0%O
zdu{HSj@;(nn@6CIV)d#M%;f|NQ9eGQ{CHv&jf4qVQiQZt^Efyn6TT4gj1=(zEs~(X
zf-~3ZBJ+GmP*9vF@<7LVBKJs$D|^rRW(l)ZZkF71KIHW0VC7F{Gx$#@
zaRm$6yKUjZ9eZgqdv`2cxNR>*-lIXk0Q;p4uwQZ}NtczrQKt110JY2m2y)fX05ljt
z5Dg&6RWv#(7e}*9<=7b3#qqo&>s*82^BL932aY$`18*JK^XQSYY`n7m?2$+J9C=e&
zPh*gUDpo4rfNg^-B=V6$f>6ULu>v99ZCEb(OUkgc7w2KLbQ{6x1D=*zTZ9_Ly<&sd
zjC^C1;Fl;=>Twwf{Q?8s-4It0x~0I#z{oHjWyaUX4S6%{Y$;7VX_#njSSJcINv>?<
zVcJf-TR0yVf-2@rL!OLlnh-Ke+&}I8hf7Nzet+6@_4o8?uT7cq+O&H5`Bm}og-oA1
zF*2w|IXu+2d!)}WRyqV_QD=zx_r@uwr%gLOMg4i;$Pux9$)LD=_xG4nczAdqcH;rW
znm(Yp4xEG_Cc+4g5!k(IZL}Jjc>=-$+UQ{J!E>9C-c5Cn(h0Vr&bG!1LI@pcsm7!}
zy<(yxyLS!q^K}bx4^fdp0r&_RnofagpkG8F;Yv4AD~CkEsHH~)Ok5}%9JnBpPb16}
z=-TB@Q{2%*RnSQXb}8RotgpYwJa+A39`u*;-LBfVHWw9beyi5>TlsL(iMo-GPqcs2
zVPZz&=tTTKW0b?2_G9;rtUEEu^RCLg!Alm^c{5@NiVog9G^wypby8AwpMs>JTZ$*0
zAbM(zyL6$0skQG)AHLp$z6otfBCn5d7m8HXWX8
z*mR+xL}k6=W1>T&LZgU-Yei-Ei7hzqt0q)%_BQXg`$EzdmcMkz!z)*g%}z_p8h3wW
z^rU!g&E&e}SJ=43m}n&I5g}7NEd1VJ2Xud&0xk+rzM2U;6mUOVspDwG&~
z@k&7oxj;jJA98~_IYhNXG&sQ?Q*(BpxiwGBAUTsv4^fA4x`5o8($w15ra2pO7DT?wbJosbO1GU
zqiBW{bTTA(`RQyOiW7|Gg9Zls`Flp%**Fv%GszZ5qP5d3z}ng2)btYi8w>P^`vD6E
z;Dv0Ka9BckVTXsMMlQxLXr~?R+u1tcqo%;kjb3JMUT!>$pS`QSx|A)iG>S^V$oj|~
zZd~|hUQ1$OqTR*utn$0#$G>X~ru7tGn*MK^z{p8eGe@dRiP_|h#270uMv6`704(kD4lF<1!QNi*K6#Hab~H3+3f8#
z!{ErQDWoJA9erIpxHiWih5GG>2PmD&V_;N1UfPpT#;rYM71{=pjh-
z@95>J_t1OLwxPJNXv8MKPD@VuL^?W3aALWAkXnKqBIp9XePQ%swz#@+jk=MhFJ>1@
zdn6lUN@=)rD@%sYx_d{BOpPgz=?gj$G3KN4Cz=mH%6`InBjPoPdYu;G9VpGY58qch
z8@&yJ;pr)&!yMY_m>yros&g5$Y~={I$@)ocj}ma>j?C%J
zs{M}-9X5*3f11=uiqd?>r8DuIv-q#{xy`&27qQSTH~UgIo7}JuhwU0Vbl0$9J1Z)7
z4(mH?SYP~;I^pk1`o0q1KR+zBZ)IiQ)L|GB)})uXTuRa0fIn!sFeB%XFP6dH;Oqp~
zW*=Zd;)4_4w)LIgy<$?k%)$;?B?HCf^JZ1lMB3PSc9!dVbumns95<(?@twM!
zd0U6|tAcmq6)`IE%VBBsW4i8nw9#
z2=0&5Q>=ls*JMFY>H+WO?=2n)sz4YKk*t~32GdPM+V<5@G>|I
zpe0_mjHtpLwZO_Sxki0d0nBB=&ly|;$s&}_c(OB9Lo>MJZ_ej3uh(9*^_o1ecGYVv
zD=DeJ{F-8r+Am1T+0f81DkqK9HjY1h!Oe<
z@W%G*h>-IskWZ7U0tq+ZGJqEtanHCq8MtS7n6MT{y)qf;9lAvr0y=oRxgrw}+^)&d
z1m-0nah&7DsSttyWMmVinnr*3w|#xG5+tcldXrB^
zoTy)^y#Mspu6d>DDXIBhb0hXuj#wy3Jr`H(Jo>L4;r#|>8`BDX=5*UrHgb_x6TPwD
z_G98v2RCC(k8-~m?c9wqQ<8}WC81P!Lsx+uRl$h3w>|#e{YC@92*ArVz}6ZOIZQmc*B^42v@zEO6rEQcReJ
z;aD-#g2c@lQ3e=OC7c@0NTCQKLvpl=ZgWngd317i@N-W+J#WSv_4RMen0G3*vyYR(
zHTjkKGv2D7_U6FpgOVSJ^6l(oAMF`ln>uK^CcJ)7T5WiTaC<{kP>+mE<1e
z>Js2)95dy$`f0CEsZI(GatU$iQ#0jtdeJHc1E=)t<{jnXl;9E8drJTQQxZG-#I=j=
z77;&%a3l(Q;Lo}j?{{QP7LX;?76&isSjd{HGZ54qGZTK{;007!FrxHm$d6O1#3>^6
z^Xtfj;J}XIe&HSns)|&FzRcH#&=uv$H=ueBQw%&09s`g3JQ24cn7kmtbK+Ev5XFW=
zQ~X|SNS$E}&R#Y81Y-?s_P4tq`eNqfk6xHRed_GF_4=MwvhCC(ulabL?iBKxviXmM
z*Od1kTlUj7kM-B)eZ&qdaBDd9?jxf*WZ*~!iO*Ah)flkf>?uz%jzGxAWf;EW%#g4v
z@a7?_Ac}7Zt03~-CQTlUf>4((b2n#=7O^fNE#vw&!~kF#!|if$Xya~q@}0j#p|P>&
zhnvLD#EV-mH7-wTT)_K$ugO8v1ARii#B~ZxKv)=D;c5@G(?6
z#>PELqvpb2y)-}Ha?-wrIW&E!1dBJd@!*GN8QVEQ!-iuNCkC3qVM=mEFDxMDAwg+?
z@~W{IbXG7HgQVIW1>9N+z>W+cp^{G0d)I>)+K~zFNcDo#z>6Ulfk&9D`80@ew#b;^
z?(C)=6P$~zRf(?4PCp+}*u~2&GvL7&m0+Fid0Dqp?s5dz}cB5f~LHYCu1h4%qB4pil~W29%L<5$9+
zh?_(#!7q_j(kS}*cIXn?A;>oeB8&I2cL$9w-%=7)_(=Bg(e>}{DoN|}
zNbbJu7f08h-&vZTII(1NS!`8jB{FdZd$&*N<{@J+P^@4+@Kyo5X~43=G6VUIz*?CF
zmtd8qB3=-ggaJd$fcJuw&o*CSTa}utlfL}s3tYtCHF`9*XD;kR_JKln5oj}AsuQnj
zZe!nf5sv2^iUhGZ!d8cgonXxdwhea&(l=RrgEtLvZbWu|Koy?R2so|k0t=A91WpxI
zGI=-_@~fQM^QU6Ch34I}oH4L%5%34OruN7i63u}oJ?V_;*u}4l%JIP(h0Ykgndie<
z%ik7v^L(7;<7YFmd~u_fZK*FG(mN(9-Qk4K{`rG9l$_S}u1Xy2+0QRIuD4WIS}~&3
zM(g6M@eT0p=$l>>J0xDoD;=>owp&kId&wr)%g>MFt`_z+ncBnPZTy8=qaFchX;*@?
zRT*1f%r~2dX2kg@5psyTMLb{?IhZubUDVI_EQUE-#s;BWA_Ap=Rp-n_o0KQcDhDqH
zm5z3|JAYB@JZ4zd!h!u3?#Rr`%go5jlcuG09Vf*qZ$$J@T3J6~RqDe<+1bT|aMGvgxqK3trYJE%B2yT~-J$x=t)
z)m3OunWV9)I9|z8T^13h-8@o`UlY+&f<$*WBs(DMR7kybmN-UuOkyKAegp8hWN#ii
z{p$X0uPZvKG_%J41;2-tODx=J;Sy=`!R_mxKCr!8a#BL)+#zh)SX$>$X^t49{T}OF
zY1G@{+(m>ogh?|~uPFv{KAtZq4QpA(a)4~DHdhNQ`m@tCm^Ta#OHhM(R
zA!jO8n{VbDNqFJ>$gEkbMn)ESMz#xZ>0;QjMViB|Y}QYychPQ?b-wPMHY@%r?!={1
zIB-Wkumg;RUQ7pPSF$z%U23t`(5NCKCcIx|xgbx?-bgpRnrsrB3=)9k2lz8V^@>HT
zdN>SloFb}}-n5zGR4&AtgNoMD8HK&D_=v|dUJLUEdAJTAoVTdoz(rXV<6Lddzq=(P
zFE=wYKVMurFsn;K_o%wCg!o0Z(^e(rM^10tG;0Z4+dn&Ja7lJfKf-^lG)L+RI(HQ^
z>1~8PrlQ^hWQ~J^7*TAx0{;HVCCc__`3^;R$`=@D<(J^)gH(tbz3JtH_?p;&^zh*n
z%hE5t<6S#s%DRi0iAnkL_i#RrRSqQ=EgGSWV`C!Yxj3|M*FGT7
zNvnrfBV3KZpYym@F<@s@dc>2Z_F}=Z5^vYoh%wrgA`Mt4rU}>jq-rnNJ7UNUb
zED~#GFBBSWK|t8K{7p-e3>S<5>qgrIw2A1(pin1Jn`m1c^ujON
zLL$LDifE<|5TkR_>7i(V0Hft?Obh}%|Cq(-q|O($?&FHptF>Z`v;MrY-E^O?
zuX?u@M$S|W7T2muCt|#oblL?*JJ7Ev=y5U7MMgY^!9tsTCp=Jj^{hQ{F
z5Xj>JVi+AfU>n5mDbsxl{t3&%371=c{r04PvUyTXYh-rqYUT2T!npCNG5Us
z!$^WI1}k73GtRET$ex4+$I$=@94>e_k2vnmsWoVk3m!-B{EPGF2YK5E=t2!?hRTML
z5^ck(&B|vU2v{PnM@Z_DQLNi$K1SlNyJ~(1i9?zS4iFclqz(xyS_N|g&PX$X*f%{z
z6bA#RHSJ2ArK(VY7&!7dWizI%znGPnn1AtA4fCp#rY#zQh{T(4{Y9|xjmvN*@IL9V
z?T_yf
z5N#;$MpVYe4U1rrjmvgU*jHe_VHlIA5NtHKI^d{*qAgdQviTB2
zLGW@e?OphmAU%|MOr(u6jV0moyZ=u2FgUsxJhLx<@cE~u?gpoJ{vO5PE7Dk0g2%vc!*E+3OP$oXHUi8!$+}n%Ou^eSR
zV#~s@90PO4qjk)G5^(et`nMzNXoRK&ebLcDFp|h}hJr^OFkOELXVA&H6GwzOHYKc&
zCb3b<6JkH*7#l3jc>tkdcF#gy>peK%E2KJUzV-yyXTqYG*aS~Zi652fK5IS-h_7N0
zNI+9h8?5OOnpt6td
zdSUIog$3waJ!2_4lrQ00fZPO#CJrB5n>iSHE)H3B`W@9mc-L4jManU11qE<`;1Hqs
zhiXJ~Gy++P11fq8-XWb3)tq)t426eM85Be{rw0OfVI9(?yH93AQ<4TI84J#y-1g|n
z;2HC3*e+?#W+q1^heXBo=stbrvIin6>vD2*xZ==j6=XIhq7`pc85*E84wo7RDu0h!o6CbQj9c-(!AC{Xn!KB+F
zJPZNt`U=yG`j+^&s&Fh2ITR&Xi{&8{OoY%*26kY=fI~(UMuFnEe@;-VV$jT3EeBmE
z1Ob-ua!Kx`Zo}3yhtCGgQ#7%Bb=fCl4vr8nYfFvk1Ih+gf2I_R`;=?Tk5)g08P;Y8
zg~dj8OKMo9d2HiJ&C;=jweYM5u?ZtVw=*y=irYTKsBekeZaTm23g6DjmvdE~L>-G-
zf%&C|6Rb^acazld1CrSqB=?bQJ+q%W^=+O0rYvj|y&J#TzF@(27O8v;Lj$yI
z9+#uAmG%?g%l6n$FoG2!JqAxI0?$zFOahw#c9eWGm^_Sv{4%)^`^puC1I>lSMSoM)
zXlPZ8XU1sLA_>Vcu)8A#N0T1|XMQlTk*xGhAuJBv8=Dw>0vcaat(WD&03w2MGT>Mnxtw0XN>_aP{wxdQDTQ!!3KtpeNoGk=p5
zF*vSe@9^mGFsRj;w5MP;zZ|{0oLyi=y)(;(WrXIme=$lNqzvew^O%$~|B<()y5!o7
zF5Ua~j8FCRiDpkd_D)F8wNvJq@4ixg{sjyBjyNMYwE3h6CXtsU)?%NlR*%g$BEo{>
z-D94cY9j?fsao1}MVV{0E(Q!d792U*10|eWh{C$Eu1bcAN8rW}d=50Pn
zOgy)09ck7gTfFbU{jlr9j!yabxs4|rKGbLRs3_NYuPd6p^X+{IkCu^IDZw_qF8kSs
z-nhQG#t_gjws+>52@7}c%7}@{Opl9A2gwj$Fxe#g@uPR2DLyn*51?ZRGemg|S*;0D
z34TG^3L%>$^ds9mA1^mQcRwB_n#6&oGXP_b0~4w~esQjo#9KY5Fqz!gR(2ICGS3W{RO9Kyhq9p&G|N$vsrVb-JJfh*NfD{8(Ze!E%R+N0l+S
zD3@vXKh{`h=1E3@PJD&V&|gnD>Y>9znn}gKO;r4BJcx<1O%hVd1P(og%rwYUgA0cO
z=(j3RV)O~?LW+aPZe3!+VuB1EJNtD;G({Yt7E2x%!`0h#G1A#-zAY>fY5A6B*CsqQ
zYDQ63Rr$^lRl6!iWb~Um>d*rjL(0?A%Z6n18qmLY@BaOyy2LTY%vuKE+$E(+E<~w3K8ZS^?rz%56P^?
zER_v0@AWp}^Y123b^0uSn%@gzyv7%LwrL#+)R=7eVC6KGv;emeY=Cq
z5N99gfjCrEX_Gko%o;m_87{B++cSss##Jt0lXn3^7F
zSj>0AoPUX|;PBQreqcw_<9noKfZq6iAX(_kkcIjQeT)gPNd*Oftzqy0go>V=wqUfdg7RVDGgm
ztf0q|xJT?a+$^5N;JT6lHzL1r
z)a$Z)CU?lrIT@WFUR)&JnE2R=v}w$HN#EyQE17W%k18f?MY~tf?)}Dl
z^fBTT1%nrdj|AGem-i&jm*%K(B-o?usiq5+&HmwsCw}-r`uKxAAAY#!1C*q8<-L*z
z0=WHy&m@UHaWhLEjWaa`|N0lz6@@LbCv;t(pstRxBS6DSuLQ*QgtUfCesUxPyur5?
zp0Ttb;IVL|xo_bT?0VVr@yD6>V~@c%DP1`&US&BrH$w#Bb|LwBi^pykHv4(kDsM`e
zIHGh~_*Ywj4^&>29C`iDW;o)8x6~hq`ki_GE@pVE_NjhHULOixEA59W=foJX7y1u3
z!&|rCcu5N9^}Cwkt=g|u&PiQ){}E<*tNIg^b1X~DMf=^%aLf8lw;R7P!Mj_73)Frj
zZ$HuuZ`D5FES1(tNQU
z#-D74Thfov0#7l+CCmLW7VUT9^pR?Yx594~;7&*ntJA`c*vj}vVSc@M|7lj?RTy6}
zhvQ1HQlH>Mc>ftz;Um%h3=YpU!&}X-1)gPww;KN(v|q&Q!+OO^|Ae1XKE50?yjA-Z
zpl@~mLdK=mZw2p%@#%Q`5Wn4{{ZYJqh(c9(EBsd|r_jGE@LOPpw`#uv?ROO&0WY))
zU#0Y6w?z1hYM~;v(tkGK&vAHvtMILWU*T|=b6TlC67YX<_&}@hwN0HR4>lG354s2Z
zH_1cT1vqs2R@&dvRE_gH4Dezzyk-6ZQzqi>jD`&lyuP^-q6mHyJfSIWBd(x>AAA4|
zc$A5<@ai3bGTZTcDx>M9_@!jT#pou4Awq)biPIt|gxRP`N(dj87CD_|n3j+zXD71B
zaAKQwKl#+dyPkSt_tK?leQhtDef#n!XU|@G2v(tX%N8iNFp_li_dV~=1yUsB%&{UT
z1P>1TBQjXECLz45=N@JWA#6g}mWQ8t=HcDXJiX^Z*S7=G&iF_6O|!lH)|pQ~d+W_l
zwlUkPBxQEUB-UVfaN!Wl6qLl?lj3kglT03}ND|`_+Q?Xsy2np03#1qVWQ+@0AbanG
z?eRyYxI1r&TUS3d2PK$*6N9~kcC6|+-F*w;fax|8xG)xGPSR$6D%>rB<7RU$Pb#*e|BAuVDLJcakG_3Vii)CAV#C98
z#Us+dW&Ij9_D?M@Jz^VGk>cUoqf6J`D-ybfbPoG}{ye3@4DL;R&@~CM1HhXGX$p+h+nhu^_;(`hmJgVP5_llT2AtiS#E@*hv
z;l~#kXPX>|G>N-KWgxybax$zvk~_mPphZsBkM;ebs_L-T?pWPMF{1IKZFP0qSe|mi
z4o#Z-v&eHr!&n4SSaY(gd*`{Lo+NiOvu%ih3G|bHyt9AQsHb>O*X;iDA9O}TAK&zc
zcn0(G6Y7n29?qhqbut+bQJ!@J=o%_QlJB&UNSPOa47L77aAhvW1y?>^NEOD7QL$98
z*fFIFi}_o*)w((RkBKRrRLi>?!BF5ig^Y*v
z?uI}|!#$c+vkLMV(pnKh>9ihfOU#r}$z?k#X6`%;)-txDjjUj9t`{qwFpjlq!6p
z9pmc41SDtlb@g?yLyj_Mh731qc1czGB$<>7mLd~2F>mR>$oQuZUyO*Du=K#JnB?S`
zn3NQK;*7H7rA4fr^0LG1Y4f(MOONlBmKL7?o(Z%C&JxyMblw|`2suLvNqpNZUb;u7
z#VObVv&3xWkhoF5WoKhGoy}6K^^6u$jYjw{l5hzP-b-YJsdf&f)#=tXG`e%NDjR`&
zQS<7M=F225x?gG0idD}IO^MIkKWD(A{Oq{I;e*y!^y-@x8<*9$cZ4yqdt~oEwu^>m
zPIl0@tL{@Vx7Uco#8K_^4wExeYWh6bJ2EOMF*>q08x`NRYg}B{uJL^Beg~bV5S=nf
zl@q}on#+mEG?#BJbpvt%Ym2xD!XNY2f<&Ce=l@)mvX!V&QU19sZ2~nC**}-%Sy297
zP{qeesZc29w8frr!)X8wkYuqcIJn7+pjlF-1_aQOtP1WZsxc~?Lz~5`IAegVZ(Zu>
z<&Q6qoI0lOknLr?vXQ-|cYJQ9-RkPx`E?0t8z#>=&|%>2Me9yivi$>kL>Cug;}A{Z
zjKGhLQZnB+$j70|sw~A+AXQ}01DArVdDeH0IcpvwB}02n1l3AJH4eKbs;6n!?4M1$
zCN?ESO2)QHSz4t0#*&-2O?tYzZ9vd;(m4wJL!JnQHlE--(=UeKypNQnkBpv|w5y?54uoC3D7_>JSKOO&fqMqD~D
z^$)Zch4$*up2NN5aEN*0L9y3hQ(?#XRrx_21r-g1U_}8UjQ<&e9l;dv}|N5&s
z#zjrPvczQ~6S5;KfpBxGe=DFBj3+YWG^aSb2_
z+LMxq#t?~t1vISc*sbe~AIFXQwWd#u=U2ZyFqPsxW{B&=FEyXw&GaD1X1gjg6Dh$I
zS|XVm;NNrjax+{yW&tk{x3l*&7f^qN8IH7eE%j3Xe~H6aT7?&i+r>wC`>U+N2cZ9R
z9KQOVaQK6x|20Tl%rH(G@!Vth+E92-o9jk{@K*D$6LZ;7Ey-jbGQ(TVFADHF
z4&T`p4tifCjzxr|G1$x2<8iC6ZBsmM9OdfcjF~sII2OA(D~onC_A{Hr=1K$Ze?Ox<
z-_O_wwG3ecr5em*H^w=O_6g=Ot9hTSV)NK6WFOUO_W)jNg4Z^~aeZmlK=u%#?h`x+
zmWgEbrnb|vUrY&)a|BN_iST`AM1PiQne&ua#z6DGy1J_AlPPy1%LX
zpS=CGvWJ!O1xUh@6k6UTKAIej%
z(h#kBz7T#O-Df)OdimdK`8N1JZ*}||tgi2P;C~S>ubw~DzB6xsgZ!+O@euxhx7z+D
zdAzmqpa<*Y*=%(@kYVV?TgA_287D*=dcODgH(MQlBF3|jk6%3xi5?vJd^bzaSZTkY
z>Gyk#e~Z=egZ^zP@5Wv9L!YTU*6l&k9<`na41?Zm`buZidlu4ksUpkB#7BKWC0Lecr5f1wu(;UHp@D(K2`BcBO^r+K9uXSTIVLb9FeEsrgSU&p
z)quw@-jj~z_aqKcRS^%KK?_qu)m4~3w&)uP0#bNhntc7peV7c%O$HD6q}5fg$jn|b
zvU+(&=8D^!Ek2y#QdMbRdQ5a?TFgsBKzfV}jL+l+=ay%UA0j
zm9b*Iii@%`dnLri2Juy^C@UIXJiIVJb5Pcx6k|e0uZ-CE*!Z4t5#fOdMQpi(?)SNZ
zZPzrY&6)6Hm{F~_^
zL&yoREn4Ain5fHWyhn8#8&C+1fm6ueGyy8fsh?UidPpg`yv3O)
zWzMXr4fPEZ9~eEQW=hqt(&{1A1N-NeY69Av4gVb-
z{g0?hTy8;PzxV$q64Q6N&qL-eTKk_+-krBvD3ZUOx-{L;h2pLWwlm_KqMI9UL)Yz%p`A!#nCl46cvWTehtbd1RCYR
zS9okmgKGd>>7&IHCl4^G@x)6H{2||9QFvoVVeSv1{UF}{_Z-)!0lTdA6SewV^qIB8
zo8*$#RnY43E`;qBK5HJtXYw;M2yw<9Jl;D3HqdXp$!%mEasXir{k+V_k3M2yc@Oe$es5U*EjK0f>z9CkcZSg)@%{U=8K!SEVqtvG!or^Mg?Inv
zb10Dil9Fgndb~w3$Kx7%DOZaq``RD*=chH%;t!f@_pVv5ie95HCe?s*P}dwsq(sJdX^z3r_coOY^F;$!5sYz0xlVDD^RI|j9@D$3soXwEyzNDCLp9^
zJ1AJ-#!5b7q_$8a$EtB@3@Wp!24)ji^eW5r?buP=B8?i{r#QV+Pn(T!$gz*?9ur*K
z(7k(ET?4KATqTV?*;K`JMAv_1sl57MSvait`0iY7{7?~^CJMGfM?ObaICu$JblOZs
z0b2xlH4Q>Tno$VgVqz3wK4UgDcJS5-f!1~n#hK9R
zupey6CY0PsLhKoAL}jQk5l+{E*@`x{)o`{DhQc_25@qY5iW8wU@uA?(ZA#LRaOLZC
zno787lr@(wF$RW)`gkLvpAZ@r8rG$ACx7n{pAa_}doKqs49Cvd)wvzyd(cdhb`sI4
z;8l#{C`OGL>F%Ko@v!8{5wzKT(--1I`){r^u4$(ky7y&va?c|#vX}Pe6cps<z4_+0yzQ^=XQKw?4IDmvVBVlnO7A^;fd6StKiE10
zFTq+*;8Q1LNz3_uoQ4m#5Esw<{W9q>{yp%h8LPPdutcZ&`oGvR
z{(W`R51N-XUyH#k8DxfRF6^RMhSkNDCzkQm6@_hxm2^PPhrZt+7>#`bIwHC;{4-!N
z2n953DBzHerO?0}UBji{u;K=gWUjE`?%?C^=VRz#Ft~*pbO^v1Wb26$%fDKB=a=}^1r;ZhFLT!P7CXnTdVO?s(%|i9
z>n5MwHh9HIX70?cc(ON
zZd~TUgsYL_OX5o#uJv9Vt9SXx#V&4X!d10j+vA$d|E+#ETvhwkp7<-pqTd(Sx9#_=
zTb(8sk%QoCNv0yE+1gl{tut2{&}CxGwYdwOr)57kr4_gL^dBgf
z-I>B{OuhYiq5r@O3WQK-5o>oAIQ*aV3FCo#^r?O1&jBX%b
z4o*ze)dlG^flH_mFfNP4Hvu7>UP2InCl2Hc7bN(1!!a}@j>$e7t|lDJ#A4kkmG;HX
z?U}i+&$W%>OOg0`X`CHMMIXDk_q7d;sWeyW*Y;;Kz5IXaR~K#W_pcP~3ANv=8`|`{
zz7ckSyx+5KkD2L(BA>Bb*h^TSt+R_dTZVZeyNdI78`uDf_Di5ta}F&o**kJzaV
zjYf03z@usH94L-0!l5J4s)|OW;(-YS(q^|tCj}9eY3)L&i?|TAAK7aI)7`0)%Ln$q
zy<1wT{JBAVIc7bxxiGN*1!luLB>bHP{{C0`({Dpz2C!$u52Vg)BV>vOtLrc_sqSUY_iMa
z^Zb9m|ND8-WV3V6ojG&n%$YN1&YUCNykhZ*?U>-0x>bBSA+L7!h3@LRs0+0F8aTA$
zIN+YRKU73-Tnz^f2x(1{O;`ddGu-ObPN;DEz+5*_LM*XF1V|x%H6oWnIpZqru2_6y
zJE5H|XeYOJ|MR`!(s<)ZX77F>yLMiJW6D->MMB3|AIlZqHrA1L@^G#H=sOePU!?WF
z3-ABps)Os=f5d%rP8Bc`#49XaW~E(e9BbA4eE-_HXl)Bx>lEv2k#3g8Y7`JxY;{c0
zD8TH519lr21Hu9QN8g#?Pn4(rccJG%0bQr*N-ph4%dYpIheLsMPlw0^
zaXD|KR=8ARw)_)q3sTKV{SN0qDt-6p%v7)Sz!TtykM8eh*L~%sAB!(h0
zgWK{F8{I_2ajNV3QZ6r8xe#$KI3pZG#Ah~~)C9kLN}I|jH;B(ThHN;giI$R<+G21r
zP~0Kz*l?0BLR_$5A`W#7-S94#HGEZim-7tp_G|Qi5pR>p6W$LW{dahad@a!Cw}pIm
zEP;qJE#msNuMa}Ivo7#yk+^uKyWA8nTi|?8j*`ej8WbE_E>|$$XuSj5DI{Txe72WN
zAwj}dzlZWFKI0!{8S3=u_1A@7H1*X->$TMoNhu(3`t<453-w}s0xe1A{4e9=J#_;!
zJ*rcIlku|paD#aAL7*3e(w|EoixbvNY!=%h7H_Z+YPSRjVyDOqG#ki~DT;ylEO3aU
z*aakUoN~Z;4mwN~T|%lSo^Z4!y`y9Lyhf$%rMEDUo%chz(fR0Gv{O{M9a034^OCpY
zB_d>jHnA!C5KXsqLt95%N4pI-n?Kcv7C^2miF-(|a<(JARodRA9ncv4p&X^bh>Ha8
zjvzRU`5<6CO*LwaVv2}^AaF?04+#kg4+#$oHOt%~w!B$n0n>3|P{u_EwY;Qxr5WP*
zSZOJ>6IVGVrO&068V`qvJ2q<5tn?4czzf6DF$>SX>#Ua|Ly|x>Cc98q
z?{t_nyvjt}z$fdwEl6Xe$=c%G4UWHPs%w*4KBZ3%eX45$proa9BI)%1#I(R52nhl_
zaY)RQVjBnj7AAy@1k%Z{6hP>Wg7WRWvjjC<>fXqj(8Q^eIh9-(oOS0_eg_bWj8g`2
zrPrx^N-JjW+&MEld(C%?7k{^=bgG6XSAd<&vvbzWT~`;}T)Fb*0$mjb7H^h)VtT@f
zs|_ML9*ciMHRx8jC5=Ur)GFYQLmXJ~rGA7k<}IR;Hnb+Cb6j0yBN=`WcsfM$Yf7z9
zxRh5m-fFtPGnDhvscXMoy!cz3Y7oBCibnI&J62Y{Gz_oqnmKEyriN+i5cWssgj&#Z
z_eln$3Aq_fIB7nT%eOz;Gw7qwT1`9Mysj
zMY#BRdWQ9faPjx>tU7c$sx!dTGt^1X0zExLo%GCH^%>}riD!%PEY#C8$U%A*=HXd&
zz0Pp$ndnN->UfXZi*S4PwXjcV2OFHxXaT<;(njKtW)uBdIE`nu@GKb5PHWFjYtM@D
zET2CM@$hUPo>84amFoomeIm>_i8w%NXQ{*;pqcEd65kkOJ6GOCoXsiZqE<@
zQ92N>Lg)+?W+_MHcVQ4P9Fuv@K}>!A3KkeK=n$K(E_;d1U^5S?PqFDQsmo@)#HO-o
zhty?k`XP0>x&mKGri?G!VC)V+B0|MOz+nXA885vey=JKMR}s}crmTbEP53-ezHmr`2Bdfx-u&yR
zq1$n3kMtb%L%2}Z!5FE-K!P-=8D?e|3>n9b9ZGX(?X1905p4E2+(fOU){y;{bw`ji
zWPE^bNmzSH9Bs!bv88AyI7uGo_z-;+UHVGzV3g^d
zFteA04i_(xic0J7ZadD2jl^`wA3N~9uh9l+DUiDhViq|A86qga=7#VV2ntOSOS`KM
zvlGcIq>T{Zq8Vfgf&(h=30#(Irh18~h#sd2T7D_lE`2V2Uh?ioKDVA|+N)Pnm|+c1
z|20LN%zi8#(z8j^-n~(TW-4+i(cN26XffF$r!hgxe?(OBl*h+o))|j^Wk8pTHj+u3
zMJuF5Q7|4*tx`lBx_RuH7{~CK=BuV&@`z5=+)5tNbIYCt?B0b=unu!Z62k0|0OY6y
zO9$B(v7(rX3z2Ws{H{{OZxM+u@J6AAojeS{E~F)5Jhj~sF27=XgyF`R@k$iEl0?L(
zMnxq|u2piGs=I`E>cmS<9DHD{ub>$$^j!7ChbSO&C;+?r<3uCW;v3~3Wt0UU=1nxS
z#zSLBwL%3m*b?cymm*eL!50YL#rEI1BMU#UyQMMG8Ea6GwX})VY}Si}JD1GXiejJw
zbSrQP!Mz0BA_qyx$s1{Cd02oEkVwE8DP##NY*TcsVj{yrgUr4m{vjG>6tvtOGb?bC
ztfU|if>p39xokGewKN=EQ54%cP9P;f-E-7|$KmMI)hR+haKvGT3>;y=CLCp3xLj<*i3?lf`SVtC>k&ql
zg`0d0$3aoUF&vOJsOcq{d`laaR+p}ccS`F?7bS>QAXWiKnUMnoKf5=AsvFSQ3dB3)GbtG(DEwqN^{KEh`fJ1E}ar3Yux7j~%Fp!R3`*`ZllDe;(V
zJEm8uddMp;V>wQH9l4G^;y;|YmDUmWJNi3tTm$`-bMF<0@#?s1BEgGSOg6Rg3
zH*Dv~GDJA1dl}NbL?u1hNIwie7W^Rd4|?E4O78&MKZm1?uSG?dN}sxPsYoooaG|IO
zKddP_dQi=pMO{*R75h?`)Lyf+(#w~Nis*-BUATO?_&dA;T)l;(WqpjFBefZwe8s}H
zO!oe|LaLCCd|oT;OKTvTDzZ4#OvKIk_-YXJwiwY{u*Sl10Glt_A7PRy_zR>K#4%!F
z7y`X;&r*alj2}a2U%{KMpn3cFc=r>$y?ye{2!-#{y>a7)4eQmbRg1EcG;Y?oS<@yB
z(;KEYN~@PzFSSAaT6JsHty5b&T0?;302UC0xxwhr-RYsD2OH7CBZB8>Lz#@7CH>&<
zbQ}%PZUc4>Bg^xLJLFB5PXw%Xrpwy!h-y
z)_i=)arHF5o!e1bjUAU>VaH3W?O?m`&cIFC*_-J7=LfaLtL@tka@=N3C#t6>qK1j$
zvs8t8n*Zv<-d5W=uI<3T(&Oy7+5u$7*-HhTo!sE0^#c36YB5ofSVm(bAo@dtki0X@
zESiK63o<-`$_PXO<}QRwlw!i-Sty8z&VfzG2w467jJ`-OMnhoo@-p=kOeU{G;n
z3wD3Oc5@7vN;}oUsYWCzUkc24xNp%2M+N+MD7>*4%xg*5Y-3-DUl9CZa=0USK(prk
zbMF2yVCdk1ckT=v*t~hRBUH?)7iUZDbUNeDKRchxtRH8s7cTxZ;O8Iv4QSfD-=IPL
zn>WeM{^^%&wWZj<-op8H+psRb{@O|IT(@w3T^d{Ff}i1-pQh`%2@&>41-pi}ny4d#s3p>Q3)Nc=|cZq%nxUSa$
zA*66;08MtGGa_z6G^`=sa98l?r|E;ru_7ZfS3ip8ivuT_R1TOkkkA0_q5)$?1IeVqq_XmMYCIC9zf?m-
zbI&{S#oY`hP+2&jZiJpnxck9JGDPTU@8$z5B-%x5$Q*}eC0y1qki}igKZ>bpH1erx
z%=ckNld-$MKN8N;T|=`!(g5OCo{kjs0~%Jdm7(9zhOoy-m4I3z1e-Wdt)bDuQI}(<
z8XjGccG4Gzg9WlRL>hJCb8IauRt+UR*jH-&uAM|DgK(?tYxv6EgM{yZyPG*caf~q2
zJ`JQYvjABN7!?@iFUc0lUxpa*K}L~L>Thr6H6qkEMDoV6k?r-)J5LgkUNiuqMF)2@|UaUQEb9VOTfm^p~pE+9w+#IkuXW-@mNO-Yji}snbdBDwqn{!$X*xE8X
zTRPsdfB%+SvP<9F%CcKgLHrgfX_c*xzIoG%*H8z$L6vM8Fkll^!aq^T4Tf4=s(v*a
z^-vXfc}vS|Y4(63=KDr0YGgLeT`vw7hp$&_
zi{Ilf$F}wCQ^3PU$0h?_%g>Qyw_)3ZmD6=}U=yJPEmoKoLh@7P9Iksy?W;edanw8qU&n@~@d_59O3
zk4JSNh3IFB^WVNNlR|IORvUs3t7%iC3+R5VMM74?4_;c*{Y%anqRFl>H^
zPX<3s1<9rqJza_-P*Qmxv{u6jiX2PWgcV{eov+|Bwf+kB1=<^Qf3qgN0ZuD~?(rYI
zLPLqGnffagwR=K{#`V9fp2qq=t)2${zpP#h;rO3nPn_{zR!`jWpJGp}^Iuj^?DU^i
z|6kSYWH$P=hb1O3A)#Jky-GQ9G|duH97>tT961uV+HtRX81Q*=MDgQMOSeqR8_+DR
zK}X+;OgU{FjmL^PV(vIcXp8Ld9+WdjJpP!RIgUEwxzUcjV(#cwuGw>-^FYs_Jb1Ko
zMAmtj2#~25mMEAbuse4>tVA#{EW!XZ!ZXu|V_O8@;(i*5*i{@C2a1c>7mgPkbHqgP
z4M!)r_*#i_6~C`R43ci;(hl?xNK#00%J{{P1Mne8`N$_t#|7Qtf-jhSOlVGT&4kuc
z_10(ubDUil7m3+wqGJwwL44z?Tym|%KxZPNa0iR|ar7q|^0uA5bsUogn>7a7M?B^`
zGL0dm4$kQcp)F#k038T6e#A>4zaUnCT?OkY2(i`hb~OBg&`YRxY2^vLZ9J(r67$mi
zW?Dm&pobW-5=lkdncv5gCx5K|v3;9*@4}R8tA1a<{(>9aT73mTYV}PFC)u>#@je*qWABN$(9q9NMwtj;XoIy+
z!yj-Su@42>76s{by+m`UGx4}P-pSHK)yA%GVC>{LH>?+ayR+3x
zqP+6%IxjaoaS#R?vJ4;OOd&!pCXX8mq6w27>CFWgV}#*A;LOOd5R5FMM<|#K*78K@
z>1M{vlo?$oZ)z~|h=a1~y^p7iJIUAvc3rhC`+6Zr3(8#u+V?maH!E7$^tkek0u?C$aSnIJx08hyH2qC*EDVCI68fSN2%n$5NXLpaY!SPg2vLGYv8HEJW
z)J}Xt0f8FAP3+P-M>KBEjxYXEZdG#9@v|~=t@@36do_ztzhBK_SoB);J4*Y-&2^5u
z>W>@f$p#k6LN}=2vxp7q4}>S?h?oCk@H9$)ho=|P*`tdxQsr@jBV({t<6zl|BoIEX
zj2FLiM3#({TZtCO&m||>Y#qmF7PFc#S*_xNEM+IVB!-A#rFI?1?^q;oRDS^8z!N@v
z#?io&{I@MaQ+s+jHNCyyQuA-A>CvL;C6*(($#D5ERjFJ2sq~t9TfM!8MXKMe0SSQ~
zkkBojrFJ*^5d~@fFA=U81}k^)mw_V5^PLmJoO1QOKqwkGUqIBrLL-rd6D}sGH8RUokdEzr86x3!M%?atP#VSbn-W0##Nz-Gr#*
z8^lELww0Vr%vSLQntIYnb+kCTWXvn<8YbF0QMp=*%~wSmCOTc#+5mQFL!?tl%qqSI
zrLMw-1UpdF&s}JE{n(>9YrkAk^_NsL)%_9hZvp&y-7vl~_*ZdPNjmY=BHmVVQvmY3
zZrb_bhbei8sg2C4
z8p`s<%cZT5>yzZp`jTv{gcdgKICTgQhYKk=Mh^o1%7oRCdQe`DIN)oIQrlsy%oop)T!Y+6sF!Mo!L$gc-|9|0R{N
zvEl41smZ01`#|703p8$!-v!5clP?I^0^R^%F_MFH?kz5cHwcqJ2Rw1{z%m+*-bU{r
zGX!=732bsk1f?YhXOPXMqe@R)W`UeRHe69RNQX*$(ZE-2kNkXC22A{VdpHk5dji_S
zs_N<;sI?c23{_|k6hfaE5(k;(9;KtDP3)_6)Y|wfY%D}+4c;2JL&(_%e<74sQ<_aa
zur$$hLWKDxUJNXR2`|E0E+G#XtmPE@9xJOP6VHVT2;mDV@_#|ThFJ3Uuu?h_`k@G6
z2q;QF2eesm7ymxHgMV^8+M8c}fAa_G8P?>3&F^!6hXZ9r@GQT9YLT^#5LgWR9|jKX
zp<%3yBY+K^p>TiDd|-z|6kvc%DEdDiprF8)Pj44}*KrB4iu2X#Y#cTI8KJ?(*I{1~
z0<4L8h9iK$?~X}62Gbx|uB)7VAtf!+?$Oy7i%-A^r8NwKCq!xktlU8%um)g5u;z}M
zqwZ15R;~nlwmvsDtnmO|;R-29f`!1cFbsd1C&YoqXSsh+Uk-7<$Y*sH_
zpQZKJ?%3n~k*N+WNf9pn^>nTk=uhWb{t~?yx!K(^>u`C`S>77vFHhy2MR?46fbSz>Ve?R)FToGNL-{gFCFY@g
zrMTQ<`EGH&$MO&4DW1xix0K?sJV5-yV|j!GTdg}jEP;LPvAh-zvpvDLMw|st<lkPT9j{2w;8^PYT;Km~D@P`B^glEzED?bzwWzBOcL%jz~^9hc&J)Y~g+E
zmuZb+g6WTBOkX2S-7SG4HP$W{znT^f5&+-?YfxeIR8Ifn#3Tn`9-n0xkwxidyqHAsK=B?@T<_|Haq1OGq*rh~Vjst|YI$q%JCEg^k$Q*vRTaLE
zxZ7iSKAl~8fL|un@mTKEBdXTFSzm9fmhaZ}j;iG!$d&ady}!^eV2g3bhXshwc`T2R
zQazR@kTK0eJ+*W_q6$8&wK&-${B(UitO`F@9PbgnP}J^YSA}0G{^SvUyLiiE`F>HG
zFIDR~EAH?Jf1W+zvAjsvN2=CyO?=TK{BOFxSp^>Yg-1N0U+nT!kJ3u=@mSv4GaZyR
z;yWJU+ls$=EN?G9^jO|OYU;7P<741EJ*K>~sOhIw>Ci>;IFFRBp68>IAzt=~Po@;@
zvAo-3%Gb*+Ji>35hIlN0$rIml{okUSwiqBKE1)xY%kjaL9An^l|;-@tZyuO~T^_P!1s#
z;VZ5pc-#xJ8xCU+W)_>n5h963((8o7twT|GQQs%M?UKLjNgrS;m=8QRmpuuep(b{3
z92dc=35#Rru1BijxYmb^rv-o;hznizK!77)i(ZCEE!nR;CWflvwS=x+5k$3N`>xGe
z^+Xr?Tl=&P?a;V!r+&TWYP!{G^&R#-OM%~!Q06FUr^S#cX~$JEq7yxL$Tfsy?Kx)1
zS3)T44v3J0oFD}EGDBE`Qtbs%SOa(!@D$G*XR5df3wtn5w>rc330o%k9Z+`*fP5~+
zCDvkjCnPGk>JO(qoDFM%D{b*~(O+^2EyMn~Z1u2iopSryQzE@{^4@Hho7=8!Zm#-H
z+Q(amj>+!Ou3u!2;nk&gbKADf$!Xg*7r3vJmP=QZ`$Ccta}i@x#xy--oirQe-wH`k
zn^A5Os2oHEJo^FgW+C1lOYT~H47%VEL*y(CZGrXFh|R+2x~daf!kitbm6ziknPSh%
z?VLGmm2!Vv@`&ycS?xRYA3bdA$Gra8sQ+7{O{G}4Ize)TZjT$sOSN)&&3$t_bsM%?
znmxAqi0+a7+I7h0HEX!L)a^obopExQUbj1u*@0!NhGlln&C&=f%?3e-jphXHJ_2VP
zBIu&gi(>xS>~V4abT*S=GvoRRPCdby_%2WjH4+l(%D8(Qiu=%Rq~R=Y)p6(FAu+ZJ
zN;J0MvmaQBgwaEe6L-GFbLk38A-<*O#NX^Ez*Xj3ozYQW1GO_^ajel}aU^?hU~SkG
zymgyhL}Zm9ioL<65Vs>96c4dd{?7XHch<4EzkdfVF1wARw0BVdEnzB~La{wWt$vzw
z4G6}xxE!Wfn4!T)RbX7|0iGI+jwi2&`b07gV~98+dS7@P5iLp#+QNCJ{3EZYwGK0d
z*Mn~I{!%@g)XwscydEda3?6O9NeY6Ym?cYiJ)QM>{^IpOI8@Ywe&ur*$WV??S2vu|
z7Vd=+&Q6$dye-hb0%zbe*hqBG$KWO-O4MsQ-G}qBa<)l><{T^P=V0D8-r;oj*6?$o
zdsUd*Y`A+LsBMm)Ru5n}erU4-KeW|@w*?u``{Irtzg#&uu}fiVyKzg&x7_^QI@53Y~((WLAYKE5&%F8nbl0
zBN=l5Fo^T^a~bho5G`JO+{!1oHYW_{sSgY%MpUo3*{u)2&v2e-S^-nFZJJxkle`}2
zHXOfup6aR4e>51kdPaa2?-0K;%s=9HmZ|f5DPTRzCLTkGx2?_DTf%PMwyT`sFeGQF
zUbI~-Jg339jAzw)ZnIJi#${e}{3_xVfyRVaWjRAIrwn`0R(qWm9$+Z$k?BA=J}L`#
z!-wYRMc%LJdOg$K@FBVzIZUArGshhU_>}9HobDUla3&r!a^68b0`U%Dhqe
z#yd`73Es&syDNWY+J`=9^ZK4}inq;NFX;Vr;V+_bH{MoxJz86$i#(;aTnU<;3%Y>uA*b_sjm~V38=b4d+!h^f
zV~Vzn4|!W!J%Dj*D-H8zC2y-pAJcJeZB>Q2&Ca^Fg?bk9df)&F9=j!sbgPGS99Njz
z>^=8-K<9-VXRV&w>;pHPr+}Y7;B8@F%K7;zw|b_4pFiOBIAP|y)l;z^V?WF5abJ&n
zz{6=IwI%$Z^{Z^OTfaCAUn4b``(-)}UB{~l^*X(ZvSw*?qCoHNib
ze*>)ftc0%%8w8xoa@>7TPA9ES*Zu{A_hjP>
zfO$%T5!SJzBu~IIM~P5WzuG~b&f8t5(;$oLFMADhB!~D`e3rj^OWowgw+pJk+(zyg
zF0Wj8lExv2(@Cr6HXF$M?$n_Ohs9`NJ;&iF@B8fsyzgbO0*QI$<&X*LR*oA^*2R3|
zP~b-JM$>&2JWTaKN8C^jpJYNkM47TbD~}AXL&e3|4=P~2w;v;_;|=uvikipP@n>f=
zeC})b97YUPZH%Zq*=I~ya@iqa9GZN<+de0KCRsPy&U61F?f>18=S@;
zvytKettN4RK0XW4r?s4Z8qDpoKdRPK_KVtCOd*|v>JgIBW595IwFSn*^O}yf4F`E2
zRlEZ#i5N}L5aVSyC{ILPC?~wd1y$P<@UA?N-qq|~;K_l`cPs2*ywC1qP4i|6%?V^9
z1+LhKY3nNH9&g80{?MFQ!rLKO%!yKb5YJLM$rN6P!BkA98fSSqJaBi;XKIt%+fWb3
zOKZz@t`J_YE1;ni`FI%KZd!S}>IALbaNb5C1#tL%62kf99i!=4G4
zTk0w1O)$`Fy>Gy5Y6F>>lI{jlp{00bapx&d*d%
zvO+Jv4|g(Nj(+6Gd0IK)hEyuF{{U`I{ISkde)mu3yH&~`RVpXG)Nm+vmRD)(HXH1Y
zKlBy@=f6!l|9ODn?b`JEjV6ciJnu^~>VKN|<+A>68@Kwgx*Aq+`amxNkKPjQ@OP`0
z-xk|x<&Xm_c%8Z)CAM~}6Z)7g2X3NH&|F{JE0<%uSX~{@EO`~j;X4if2W>2pRo8NY
zZ(rW8RU8Mv)3}T7I@m1W;K%FK0fl7#rRc{6Da3$LMy*7X=6kB%RtUY+IT~*XmwVS6P4fn)A=s7d*T~2(U^Mv~v%ll{~IYIT4oOq7U2}qW|vuFM%Ha+Cf{YhC*x}l8Q
zU!e0Xr>)t=UXh0D4SpP^o(6*mB3w6gff<8&a)e-T-&d-Mn5I?NI`ma{u~DW1-z|nHyGe-c+ogY
zNFpCcL__&N@z!AB{ZBM-#jEPR?v{uBVPDAo!CXEp)_C@3jW>a>f$Pokk9?eOAu9!~
zZ9U8GNoNUOCP2s^sLg!M!}#+xQvO9N$8Rbx*R#Cd)5>){D+_u>PpU@=(#p?j@SuU5
z#>)*_IaP;xh&IQS2SSoUf-PA*%401NfBb_#?~UhnPP^(l?Xs%U4zl1cP3M=fSE6-F
z)A|2q-b&?j>8qU_m$Lq-=N&}POw#N5S_S_=w?BdM0%Ll4`OPxBhC83D74>|r)uXjb
z_2_d|uU{`mJ^CEGsq1z}rB>_Ej^3rkr#Pc2%Q
z(Iy5}UR=JO#){)1+r%Wo6MRhA;=Uj44JBFE&3L;U=ECD)z+m^$A6}1;Z2DMS37k=%
z5`j-1PlHj8G`jnS+fFzkW&8vNkv~OILd-P@iT}DGW|SZsWn6aSL@-dCQ^_jf&W-t$
zg+&Le_gHoIx%8;iq$V+o`ZKCV-86UpRKCN#Pn(ctZ%~bbP-+gMxJXn;AO&b7Clhzf
zWV9s9$X_297iWvJ#n^&s80gfI!b8x_VjkbYXyYm0aRUZX8O0F9{lSHwMRf<-+YX%8
zx7%DpnKZ9x?utv(CJ$?#VN-v{2%WJkT%T3F#<*@-BZdxN{$}prW3!%ov3X+qf^HVI
ziH0cATCC5$kdP;ms4UjkctWhN^Mt64uWaMdKM;QbSL6}=3x2~`79?R9PLk>F8U;u-
z;|h=^&fpVWlOcjd8qlLy6{*B8R`x}F;ZrPSOXFsV>Q$Da7Oh{->Zvzl+GI$FF+Td3
z%OlIj3fRzCabCCzo-GzJdLY_Wog2Oo%;4Q4SAizyiZ3*L0Y4AiupjjpOdo$t?}NGY
z?-#W1^c?*GPv8g}?Ua4&##{Uy4uf|d2LoL8%f-Z1WglyJp{B>jF
zGT7=EcEFf}?6BSu`Iy!biN%4?Pw|{BbelV`>yc>qO@xD?>7F7RVN#3;j3^=uoG4~V
zaH6P27a=9wo^wLb6Dgm=AzFU6J&G+C4vBMQ?8OpgfJ+GCnc9A8NUHeUP^SD?l&
zQmTYYTnc`qy1S;{|MSr!al#|sy2HWSM&2Kt9-_hY72zU0i9A47V>*xB8!dFQx2NXd
z-p-JvM8vf=gDi~p_3}YHPD4Jzb{Vpa-6A3|ZG?#Ei0G(DEtqfR6egNKyNC283}i_q
z(hNcxlWH-P#$;G_^ZxDQn;17qqZ}Xpe(HBak6N|s+eXf+U90by8S2q5@egQ%F2GxY
zEe9AIg?1Xn0(h<$h#GP;$YP*X2st3QrGm+}~SK);_jXLT@kdwVZs_kV9)Y)
z(zI3(@Y5;Is0lOp-g#jWPY_r$+m4kVcxiGS0RTeQAe#~@Y@6CsDD!r
zmkVll!~L@H8a`@w4Ij0;_CCJz_tow?9@-zkga3i|%g2T9#(|HMe!RaLuPZb3aq|&+
zYcvg_>105pQ^a4yJqc~_BIr+|EiYnx$^nK`_}@-CovVnm_+UDR^T7k)due7}*^cdPN!7jJt7|EvF9`;LXud2pniN?O1r@MDO5(dc>f?L;ozC#QUd4MzL?8x$R7yjd}NZ>zD
z>`n2#B=BN`Gpv^S-F43+H)Zcrl=~W
z|3(Bmr?wbA|C_hefxn-+XXwiU)Zf*Yba{fYi7`x*PMNL&7tlk)#d#9Laft#hXMhXQ
zCs1f*Z>~|uNIXgx=Ml`o%U~i3as4Js?&jx5^zjSyLl6a5DtSApB-x}iXl`5tvY!?&
zDth?NpCXVm4&AlOr2Yjqb3Cgd2>hae-x-eIpC0l1U!fJH*U}LZL*DvkzMA(pw0iV!
za4}bm8x~h>v8uAX{=0F*qABUPNGlzu8xdj58#g+BuR3OnF=oZ?c!0m=8CFU*(`Gz(
zxIYJW^OOk5X0%n{iKUifNI?=Y>I}EE8AEXVzri!vNi$^qYJf#cIdkaHyzBUfnvcqV
z;L3NJzMgipx5J{z*H1*yMr1i!Qe|N{rk7MeD>=O5{^!f7$E;wEBCKojAN9&pgDqgg
z2uG4z8jg@#u$$rg2*+kuUtX^OR<#^wpMtTxd=ubZe0foRn%9H!3cni;KfqaD;mg9y
z%gW2keJyzT9$uc0ddhtqcsXpXz;iju%gR0NCpA6}BwF;1$rayu9KJ
zgV!_C314BeCwMGPR8N1jTW&Mw@b!3kuw!$D&6>(-cdX%AVWXmY3J4)4taof?^UJ1S
z5743O5efihfeHX=FAan0Pr(M+KvFRxAf9n?5UH9da7H#>l@KTfU_G<#zWC-R_?j(N
z1^LaK`#|s`_JQlG)vZ12mvO@!n@6^46!HDT3DXG|=BYrI9h`c$EUjcnU!^(L#ge7%Mn)k$p=4%*(z*(69+
z(PlJ=Cy_E%PEqL4w4dVcr{$6;N?NS#qoUjD{8Ua0+PxdSLWvd_6xe*^?|9!r
z1&Sw)0IU=hnUog3P-h}$qy!nlx4Ve+5FF@FgN4Y@MxKn0E^AujWQs_rMU-;_kND{OK8;I*h0ID?x!;D&7u?1C?1Ute43X77r7E;2UtA_CZoa-o2j
zSBlY42tlbBWd+fwcpNH*O}@c^P?A_%a)oY8K+NGpYizY>1WLAm(zRJ_FK4b^gc3t+
zN80#PUhXifGtOg0PKpMN(yjiY`1G!at6tbKbHmIpH>|z8TvE%Vts_RwTRUaZqLn+h
z&3Iym!u+JC?*~tO|H-G{4)u*XIQNOSCXV^%ocytq^9RqkFn{=vIb(C4e0Pl2*RklU
zAI8BT2VkKTxeawQ`WO!DMN4}#IJr|`kV1@u0?N}k$OT{-y5&2mW
z!dbY9_bNe5OVj#cv^Hu)fix(LhHSC&&`{#NH`Ff=$T6nx4nOsJqWW9dOU&oF_gKSY
zFQ}iqRr;s=*$Z1g4&KDV-Z}Pj+0vQoZp?i{J@DI)>VY@K%-ttm0Y<&mj!Hamk0)&~
z1d&TZ1HdLC4GKkpDun#PzHlzr27xnA>Y@}ND`SDut%f;1$;S7f>Bu+ej00w~rLi=y
zP6!JL4du8}%&p$c-;lR?z}W4*N4`FN4SUIg7y`NEw_rIy$e17TgoToUhxGA!jnV0ofd1s5KQ(!O
zu&4FMjy@PmF1n)!Nfu;-Bpc=;A(VoR2z86wU(hYgh`|`n0xqiJ1@z2p3ALGEk4ZEb
zWORWdnSi~}w-D-^Es4jY3#D$SD>ade?6-rjt9PGSdT*!dCucbX_SUNDZ;b4{eN4{g
zTqWn3-^;fLfep`(nSceN8S0A47U__tUZ;lvza?RC!4UQJxR6l&T
z&h?tBzTDMgMD!YxKV?4|+H*OBa|y^FZ!w*Z2g*$ws9d|l!@OnS33!;K4;5NzRUM=T1I#0Zzmeo#@Hl@_~
z^DfZ_wscp2OP@)?hh@KN^eg+78@NQe6TrtL08$1KxB_6k!bVP@cq99^Df+s&{%g;@f9ydWD9E{_7*@W8+r
z8mvyho>C3O8;ogO^izl%#ADvAu45tVmn~h>p{u!eh_yrF3+Feg!BcMUm%cBJSi5G;
zT5;;Y_LB6u$@|>fjukI_GX-P=4~|ryRW=$LWB(T+AY%hASt`i9$go3RA}Hvzf{4fl
zgT9uc6I_Ms$Drc?MKfLtz|wCvkqw$r;4KJ@2DJJcSqRe!ZI#EU<`P7i3u7WSrJEOu
zK_x#iVZoxgOUnfHS?LAGTZYENZ+-UcX{0#tlNT2b8#5QUF<~#};+L2=3Bqul-jNvk
zSSVZ~riHim1(BJZ^bS$5`hc!vT@(f*eWXt92)!H~>F0c?jYPKH2cpjwVzb1Xe5#T3
z)Ac7#=X7qG*8W7-Y{~qwG&1!cvyQyYd7Y!
zA;=krlpSoau-7XrXdC-Xouxh?gT@)A6s~=Fz@l18ku9RyTJ`Iz4Zl_ws^4~19~!3=
z&u;q-i`mlnpWioaNaM6f9VEYt{#X$4+nZ3Q7<10-$e}=$>-HpyFh9%1QzZI>B1{@x
zL8y3`{kqK@WD8WFqao}fF*~wjfVDJLCq#Z%{eAU<)eF=I%%|P9KFjYutqxLNoj<)t
z3B1r6CsZH%3A9Mic@XC7P2g2iXd<+>w@fr)&rvM^X%WQ?#7+Z~!)`u`ZgGL0kfeiw
zUar`!PAySLYf!66ohAvkaEe%2Q>;nq3DXz-?eoJ5w{4g9
zNA>8Q(bIfIe(%H++iEvCHhtUWapNv+opG#b?X8neyeD5V_v+fCN8~s2=RP@ScHxtS
z=qBi&1sPfk{fwa~bdhA+f=UAsNh%EyAB4`yB-tb9V+p|Fk%)9A$R3ScAwrB8!vkjV
z&QWMn5-e8z{0QqS5uSaPl{+lSHngB%l%zglGm4fR_-@+kM=_&+>bIuvwodDZOzJo9
z)c6g5ZQk~_`gJ|^Tm4SqFzAX~F@Ft$)m{x72!5!A`H2OKtK&dki)Rn(k(Dtk{*#BP
zTh;CAN1`7K7X#!uC5tHZBi4yG0UN5Vvo>0nBN%MW==(H`@p$EgP>s$ZgUqm5AsQa8
z<>4U|=(;>FE=#9q=>kRy`ennQg!C|jXr|%OMrYpG@rQ@25AW=`J7*7T`BU5%#fwIa
znKfTh&L(a6X!eaVVH;C+H~*fE{3ia(6!oThXVQ*?^CmCcjV5q*NB1^>R}e7JGBXJX
zK1k6OwVl&wEYGw)Q9c=M@nzkDvHVAdTdDxe}qeq)x7I6Qjv;n4x&
zy%L7B-rc)rW^^YyL4Q#?fk#q=6}qNjjYGytgto_ug(O~xkqb?uQW>m+PRWXMUKw%r
z#6s|oQ_x^7C@>)IHC<+nvcuw36@qqNnC=9JApumj-5)ed5mFM85>srn14}hG6d;zu
zkb0Fb&+$pg>0H-OYgk7l<(l&&(h+T9-aqeRjSJOR&fHn@!qUDn-?1P3S
zu4EDQ7xx`7y0&z!m<2yQN*x_?=h)L{$HYW^YKfUMw%3q`a2@TJ(74ajeM+m7&cXec
z;E!bB{!1dxz~P52k41dJc;PvHrSZlf;wm!2?_#CVz94R}lO7RV^FjbbF`JL9P{5q4
zP1hp{uzJL}0HE{zbSQ`&V#_GI6=caKsl#VyK8e99Fce8TJd6p|qQm0D;{*MX2f-j9
zaICgdLgGvt?X*Ku`BbH?2hRY&6$7xg_^*O_nZbT)88XiVcbPk6!KZU(etLev(j|-L
zFI^UF&q;Y%&U>?ZZkt7F+3`12^{FeLJoC)?5BBc;(8<%?L64e38rcAlJ3
zg0K1FT7W4g0BarpT&Y`9jT#8eKxcH8WG<$|wBJT;1dX9#e28gzr5M=h7~XgT<3chN
z!t=|gQ&$ffxwCJd-J=Rtrmc!>RxPnrw-&O}u;BUS%l8dwB1uiVw@t3rjQuM;%o>Zi
zAvN6DB4)CQQ7sf5}Ia#8P=wLBSAP{LpXmGsYbnzV6Xx
zc5Cya4I|&0+k3|Ft=-gWnOh+#AulGYOXW$xF$OWX`o0>&z!Vu8JA$2XcA!W&u#`D|
zoMX6IgRAj$>{Y=!Tn_4sDa|{h!B$NSlhPXTo`isZ
z!bmtal723I$o>&JzVVI^rhl_{?{_oC{Bu@taD&;Clb&LA#^f#@Kdfy;jR6xIs!K10
zX4RR;{GJbf!Le%TAG?AVeYzw_sx^K>P025K(6f1`Hw906F4=p)2Hwv;m~VB^&oF4x
zZS8g_$k3v(TtV+tuy83z4+0|-En8YQ3-oCdju2zvvEi^C1p52HazMd1_35CCjdW{T
zAa*L)iid<63>MxUV~7~C?u*td8?Ej`UJ9$J64z2^(k63gABUN#a986A_}B2`)DLKOU=z)3-*r$zYS
zet#5%g_MES7Z)3x8*tqDN7rc3_^KC+l)`k-EU%tX{pwwd-{#6T0+&dgi=c{&MQ6
zi$31xWqEH&$`{YDmNui3mRO@5uzq5NH^#NtazMl4~U7jS)-F-l_7L4RCKt5dB@!V9l9Z#DMFDE3tI{;YqqHd(Bn
zykYd=ks}X}E@?NQhu0;O@yy#&x8PBS>tteq>(;es{c|OyI0Gh%9e4Q(|MnL(Ku+CLxg}
z^2txLJ}psWDlX*YFm1JhLP4EMhPRDH(bg}q@3OnABqey$#J;nmeoZNU>k*qPaT=f?G0np8S*jm%Tl@uf{GkSboQ-2&3DQ~2mT!Xy~)~hZaIPlwK5#|
zVGQ`8NX`R&8VK3;tZ?u{L=dcDSn60DEJn6?E7H#?WE%sELsXLQ5f~YZ7Gnw-#b9V4
zQze=7U`S#@{kk<%!a_qV<^ar0kc{ihnsQ9s9mY4^74|Yx$&6t}?5Qltn%LnAnOJYa
z#U=0TU4FHzXxujUd6wr`DZQhPDtvQjy_M5eboq3!fAgK$_D<`Dw;!qY?XYU>ye*@e
zGOKmiQ>WNmpX+a^9esVK9-lNOZ`|5(EjqIhpSl_K5*sy*2L)h{1?>)_&#}T(yAKi(
zu|N}c>$Fix2Ky(%1_+f$DdZM!c`hj~bY6iTlhQ*nh|Ajes&FzM$raZ4Y!NcoLKy>T
zhfJ~NX%kNj#58KS8rHOJf^Mi?y|Z}rsPpU9@zPnx3YNWg@3!sJpV)3<0mjw8t1~8^
zSvdCX*}lH)@TnW#>Y`I?MvR|1I&c0vIM+enhNTfV+-lNAnQXx%Q&QTMFaKI+Zs
z3$usyYBaPBwozk0-TKMHbC;eN{^I8>RSlN&o>hxt_KX=kzh_iTX6zd$ZIIYGCGYvPm?j4)mG5JOL&X7UF1>)`R5|EZpS;-kgyT#vsK
zJaj`
z>?n#pN`Qh;4x?EmIo(dldG+$$cPku$7%!mxtAlME%&>~phU
zygA|MG4&U*ZtmuOnY}g@LKuglOqc@w#jLc$T+7!-AF~VVjsYBDFcxCl
zfMa${2ofDeqcWI;r9z`$2@C*$gzOmM0z?KWOHJwrjzrLrhDphFuxUkCFyIukyqcnK
zl#(PxY>)^Sa@V_)N|3&sfjj>TM!
zp^S1+9{5zHfMyUcPS;d!iO(b4W%!oi}Fg
z>^|Yf!A0k$NF~b1L4$|cY(odnSBliLv0Fc%H)qqM^&^iz*=ySHtzGAI-!hajxIeh^
zO*IWC7=$4{h+ia#Jdq;;W^rN%+XvpNCRB^iak83;yj3;%kYK38$eVd?)~@cAm3ZHz
zZf*9lkugB2+vZ2>hQBqh_w?agGrH9nHMFCbQxZFD_i
z#wue5{|FSbQPA{wXHct(V~>2HTlTB*L5Xzmk)Bq5?vX?~B9=`22ufN@oX{j2eq!RE
z&-X5X9qj(4^ItF8qCTNcKl$yj8gCyy)%nzdJ-s{aJNepk2bK=F@P~T7{n^HIwhb?6
zPEEV$wMnGc(s-9W;TWxt_h@~*andi(z?$MqSrNIlc0Mb^|2z}JsvOpvHG`YkOX_>-EB(|`IZv&{E;}wdir6P=ebi6mm|XTf
z>=QpgtA_t7`3(dm#f64Yt`^vF8RELbs1;SU5cWLUkrTKtYRQ41Wo!_cBXF=mR#I+K
zl
znK&w=T8pSbjTXGoaBgHukl7aTk@^FBLAI@IkmBcWIPV*fHK^^%dZjA{*EbtZ`MJ1O!D)_9NK7#Y8hv7FS|CpNnI$wIf(FxmFtuux3)=+K0)1mF>poQQJ@o&bYRxcDxF%z=FGl@4Q@e!OL%
z{jbtny+5w`sqOo|nh2pZ=0LMD$Kl#-BO}|_&mEFDPR=V96*wGkzXUBbE&8A-G`642y@wnIA~w=sb+8(4=T5
zNoyRe_rW?tcTf3aP{AKlb`Py{ko8vIyq3q}GwoeQAqCNhuB|g|x&M;K9#pTtmiOwq
z{{Q+{|8=kCy~b<@uS`8PrN@$9i*}zlv2#hUB|WB`no7LOnRVi)rR?FS1=lu0?cgA)^nz
zNnh_>`0;c$v0Hw@wmvC+c8)9@aq;Jk>a!_Zi`Nf73_g>DE$S5Rk6^*h)+VIe{j70F
zJpx-5TqwwTQU|huZt?Jw2JU*q0}66q7FZ--i3ky4eYSv<=~!XOaA4pz?
zavKW6nFdrPB7qeG5ChUbB0ap{#3%+6qA_K5uds&3GFfTaMBY{`%xRlxq~?VlnB&!9
zQ>Y=NvR0ZH4yACdNChUFhLlubdIgfZ>I0+pK$qS)M#7pcV9nMgR$itzGnp9W
zav?3s$Hy=jehei16ovEIK$*Ht7abb^&Aa?9A0Ojjs9D&vR(hYz9*vyuqb)S*!%Vob
zqDffTKOX|9fX&n#=<9<7IiNhZ29X^I6ND!&
z;gT8S(t^xSw0We&IShlu=XnY9WAbOOR__$ABsJ-t``N3>o#N!=^j_IDrnQ{0xY3I*
zie|O6q21P9X{Y`<Ww$ESd%lj{O1%3iZ7Jk(hj`mue}UnJ#RhlXonLcAr>2JJl13uDHT
zEjIFyG1A>D=K)4iTB3e}K|N#N{Mz7v^t_`aI(~ZVw=d*=^xUM+mNTO?q5rV_VS_qm
zu}}TjqHEXA23wi*-l5U^2Y1{s#oyjGw@a5C+%3c$ONPm9A#@}^oKB?L>-k|6Xx~6G
zqQdK|fDaB+X&nxnx+&JSc$#4>;N$Q|}Z@n=8({F#a&E$%mF
zOh2(-E<32MySRVvXVO<{GxbEjj$O%DM{~D6inz&(_gb@ygknN9D
zGzb{j8ThFN_OFAyz#6pW)mHNw91M(84rIi{F8_j5?GlM!tK83c7Mqm`XN>D&SR
zbb-W7iojBt7;g=+_-pB_p)(0_EY{m;7tr=i)(Z2gW)C0-1sk+eOe5WDK&Q8&anG@5
zpT*$&wd?x+&8S}jhUE5|(6!GAWPm~!MCB^NY(S~R@411<)hG2dMJ<9Hzh+`raS^||H|T-vlcI&J#!(}
z>WO7P8BNfAZLm4E7FKJkmL(=qG)m2y)~+QRj6OJ`H_%CB)l!C1-47xbTmP&~6AUYw
zdwG7Q7gh$L09$bVaLwihfjQH!9E?NZEV5OL#KdHq1^*{_(`?qarpY0?qX&$=x^@VO
z%hi=2Y`*n{lAA0Qno2M%n6S$zGny{8E=^FF84~
zcv{M^?2HlBs*UJ8f0R6DWMP-#v9ZH5`VUJH8wNkGu5H<5$RvA*HD%8SzsQ7@Et^s%
zw5BarmI*Hg4|*oIQ!DmfiwuRbTW;mTeazCu>^`n
zrqeVYbkS%^rDEk$UE7W$aoScVm4AQ>+bAYHOK*#UD;1^eqO&r{bTg#
zoE|yZ1H@OCeK6{)^Bd1as;{vxrj0I`HFYfPGPDP8jkpa4`8$laW-mR8Iphajw;_CO
zVDJ#g%LQ
zT)lcw{IYZw?eO+FV(}_u7~J!v^U6`d7d~Q7*!{iWs4ohkW`7()knx<9D5O$^wIv_X
zyCu@K;kt&1K}OuBG#dLE!97v@34n0GH=@eZY^)Xv#Op92DeB
zeq|O*D*ngKBK#jER{YJvI*eIAzTH3fZJ#!C$G(5Meo>C9ud=S{8?2M#HI}FD5xbOb
zqHpSP*1_=x%U5@cov3e2u%d&vpu<{(EW0;$Xc9DVobzkFibQfny63~y0qsN*@>#d#
zhSLcW_<%C_IDz;h+G?8<;APIY+Ey59(^55S18H_fSn=3p_1^O@1bliRscW1eF@1UT
zwXfv3!#iJqUgk9(`x=b<Ra*VS#0Q}N6(n6yVl$+zEJ~5gZ#5i`OhkHVfbKTf%TvP!~Yl*cZkunj*vxV8obD
zeDAOXwO8}EHjX(mYV^@D2?^@W&s+3^Z%zMfInU9nk@!tXy?r}I9wO%&qZC^F8T@MS
zF9XM6{}1kqnJ^3EcLs7Rl_JrT?!*e&}#1Qd`J?+)3op1Ps_@mKH%_v?m_nZuL-6OFl~d>0D~|
zmYkMwau=~)3-V;lHzTk_j0@~8$&(Q7lF*6ilrcM#JJRw+t~?{W0VXvv_ga&!MoTJ=
za>;;dW7Bamvq1g2ySlvV@;?Kkk@_SxLe4AM^ZW1Yc!N>rrcA$_!{slnZ~f7xkB|%%
z1FY%My6hwP(3yyCUDg*bEMaUFJI!RZRBg)5${FmHl06WvwNN4W2z4vh@MkruJJL*^
zcs#2CbwtL7iFQk962}4I=5RAD*D#A(;L(Hq9QNZj_+_M%HP}XGVPb{lY{iM#dS-L|
zYHzSrsv<_WO*(P?-NZK0k^x3--+q@1@~`w`_kqLRK6fWizcNG(V|NQaojB&(UTPRx
z0!|Q5(k)J7_(al>>y}+CoD@I=Fh;?Mp3>Df^pm!iW;RRRCS6c>NGmQ~DupGE#%hY1
zA?34W+=m9ft+>r1XujuseuIdk0EDw&xW{AOE7{mLkcdG$`wN|1Y4&Pbw#|cEVp1+NNg8
z(zF!Z!r^ga7)Z~aJgl57Zl#<=9nYb|1$ZaPYw8uy$7`za4L`%cN+}N4%2B}MSPDP$
zvy9|2Z$$lI?8whJ%AoM(WjqES=sy{*5UruhSYX@m3iN@WJ28c}+y~++K389$-)V+5
zyIzFtr(UG_Fk)#54u*chPdgaa7K;_BqLGZ)nohqgVz?MH4CcS+VJvvH`jhDRewg|*
zlN{d;QSYo$j+Q)?f1|!&|Hm;r0l#f+^)?IuFxP%M&_F!l^0t*(f
zb0rPQ4=NxXR@8#{g80Q26}Uqy9*!5eeQrMNbMS)XPf!G21VduZWkaq>g9)@(_UnlW
z@j>y5Uli=GP#MyZmYGaMx}Q)Q$=&{S(+W~P?VS)C)hBskfi=5PZE@LWmM&ss=^NQT
zZ!2DNmTsyK10EArp&QMN0hr7K?LiTsQzZ0akWC20FTyk&m`2dlfjUPHP8`d*TyL69
zCq`iqbnpkqRXiPrG%7rS|6HQYg8vMD2v?)Ipofrb;F6XL336`7|G0-Jm?#PHIurZ#WK@!Ny}>;v5st0eaU6LGjL
zigC17S?MMS9wGb4
zHgNt77{Tb%K{&$aIBxE`lpA~|?ZacOPGt<$DW~dnzUW>jFe@P0Bl|nwkuTDBHl2T`
zcxy`#yWb#m=HGjxEsFhapxEzd1NV*)M-6#VdmLUpUvH>*C{gF`wDBV`nnsw7U8x=CkahTPNR}
z-|g(w^&c!^Ps_nqe_F6;(So+0``(&VhW|~1K^pTgV~l+uBFO=QDJU9gCj8{Ct
zTw>cs{I-{uf!sCX$X&xi@FLdZ{G&0H;8-rZU`Oa-P>?C!8suXj6^tI~=}?H@j1}P*
zK=72A6HKw7p%#1V%>JeeE(NaDZ?L!nGcWFsShM~$+BV=W+Up}&BOg#`
zShs>OQSv|mQwh&*Vt!%<7!(OOfkV8XFBDt0Eu7d}iV`;6xSE;iw&PZ_R8AYi8
z6BsZd3^xKYI6D(Fz?&9JvJgWP9fgLGV5dx$V3QF552ycN3oTh@Ot2ZF119B52U#PC
zWtuGO|Ak6h+C;^yjfZCcU)t}>o96vzPSbMsH}7WG6;|;Xkk1jB+|M9q|YellzFkeU!970)9Ng7ZkxCv+@LXq^T1m{sTsH=%TTn$WRYm^{u4P{j>#Uf=Im1^@@KEwy?Oi2)$`^rg9C)$vE%P)?;q0M
zQ{H*@;l&T8CO)us-KlX?sYJ#Kq=+m_8ulWv*PYyX@PqsNIW_w4b(BcZU!>Ns3o
z$MJJe3%-4n<8)wRO#TO9td4Iz8FcW>1Z}91VrJu65A3x;`bLfC&rXJ4_G#d9o`X{`
z827Ou{Q3l~n&bF*!tuc2``EeWtGb^{GbNm&aa4i!5Z>oCKZy2-;D2zPa01m}P~G_>
zJip`VH{nsnv{m}Mv{mkRg=kN!TWK6}1&zE`%Gd!2nHp4ls|)qWnj
zut(9Np)Alff3IsG8(1-+Ti1yd+8JaoCWk&jDO0`!cI;i$17RHGCkMx3xEC)&c1U1L
z8JGyPgw88SqoSyzVePW0@NPxAxej4q=KXxV4dgcLWq?Kol#pCKI0;1fhJJj(&=Yzx
zLF4xLLG9<0_pta!4>ESbto>?!aLL^lK0LMPp)eS!u1Jm-*Bxb97ca7`V>900H+$*#
z7e4*EX@=`4tE+3`aPz190bmPzDNUoi(ek$hbL!vSIa&dW(lp8|tX~J;c97oLtWkT<
zbuN?5ZSLbfcf2sR{!Y1u@>cUauHdhP*LC;?A6CQr`ib}Ty`bwvF$Q@lzy7m621c;s-6C
zwue5OH*w-TKCbW7WaxbC!kc>Lvkbx8Xn^u|8Hgx&E4IwnM>N3BA{qcQMx%yHDz=;@;TKYEaNW;A-$gK*mk5ZgTr`)VlG7ff25%@V`;
zG+VGWh-hOAqvj5V@-S+8+^9Pu${^pZ?KEwfJbZD`732IjH}3s%ZNO>G#iR#<)?eDO
z?(N0KD*AVLB0YPT7Vu0>Mf_c+ygSOHx;g~ac`H@=3ZZ}9v
zmnPXBj7?pvU1g`3H@nw0Dz;llaK5d=8q3Ce7j*mTtAYY=Z7*r7M1S9`q%N6$msXK{
zOMDMBj#xpKhgov-e*a#1`8ylnm
z&SOA{&^ktC@RDTfLwt-IE|Y176lL5zRY*V>6pMcMC4Tfh8mr
z5iMv7w`L}8ZT$OBYp=^gwU-}x^Gegy`2QqLT+B8b@6=A7zW8zgb81r1EAO8^y>g5;
ziS4RgcUm0Fdb)W?!vTyd8j=!p`_N*L=dR>M_Hkm=j7FVNv%;1HZ-0jVvB6dg9*D-s
zLTEKZh~Fiz83#qQ>wKCVbnO^T7IB9oS=&XBC+^eU#8_Wq>#j6Sv0qM{xPwH583bVcuTRt0~R`-yupQfA0~h=9hjWVgyihxtjvrw1kTuFBVhXq3h?*!
zQiLRyPuhOgJZQM*@_!D9n=)0zA$;bMiN!c_H#v-rUEO03aK
z@?_xjA8`TvmhcR}PH}UCc@2R6`gLaG*TFB5J`{5QOuFtBLHCRxMTWM-7@5OaJZ#noR&?Q{wdCRFiUjww?tG6fmRik|2
zp?B(!q}fD!ex0``dWZJ-m$WxLpE>A90Ca)8es~+UpED13_{M<1L8%}h7-3*gAW^vR
zFrL&Vtm7-{T!&UNEfr+%I1
zT&L@L&UNF~m-Op2*KB^B)*N%CDeG&XJc_(H*W_HsTHJ#MjP{V~7yJqDXZ>T|4}m~L
z1RNj;#QWKGV}-%OCb3}cH5N6~;)saIvCd`T+AGWpVqJgjtM$0nAqz2=-?CzMc@?|#
zWZu-QtZBJVYSF7S!{?64nVFLvpR>ytW3_fj9)SG{r>JRgr8?gSVO?IP5G{~}F&?(h
z&Q<;4LYUQL4^j;gUE-iggcViaB~MH-Dc{A*S6}#S#XpX%duOrtm4I1w3wG{U^tkH_
zHYAp1es&L&cW^rS%w2cC$k&NZ&B06=i1Y{)tOp`(4@q%7qAD)h}g8yhXc-84$zoBWKMnVQX2L>p8Jld;OJHwM^6z)rz%e
zDs~NFPr4R5&uRnKu`c4lPqiKxFV>XH8Ibon>2cej^*nAHUV0*Bv{J}5FN>sK{S;&q
zmltLZ1_w=K14Vc`8N7(ci;cGf?hcLg0=T|@U{ofy1
z{d~CgYuJ6rE_RM(pLsxgEo8&rx2)O*=Uk?-plz#;eY@Zx?bJVi*6w&h<=$0{+
zPuWY?)xUDj1Lx#5!e3~!;<`3;HV|(is@{Y2GXWgN>hP4>I82eFsHmq-#
zv~@>fH-sky2iwD6L;m;C(y+p}$Ij5EmhLPq+u2{NyLuFR!_PS2y(7VUa1b2(!Fv-^
zCXs#FL5wI8)&M^Eu7rjJ_#ra1BO)I39dSn;aDPPXh@BF?=?F^f`h@0;{WWA!-H7?!
z)~y{q-~3xFlOH=u7%J@PJ2OX2tv@m0;5!qCWgdUbgO~v8go{99FrEaVPf<@W*A#C+
zt~%h&kbna|6EH&PdJ>F|Y^#JhF}f&7X|dK$2~i1QW~5p$&?vq5C_QIVZjC1-K>?l)
zvZT{zPK-TB^f4yI{%T%OyKZf_rjd0E&A-NK&Zit@WsaNy16pwDW5+XxO?>CzgcJ2s
zM`YglG<&WrC#Q`5Fo)JP<#PxQ!W@7>V5ZXN0G^ZP&~}Bdn?vhL>vM>``AWB&!yl~r
z8P6PUvij^POc8T%z+QoToVC)&z|}XzLFpU^WnPPepapm1p!L!q*BNo3>kP7^3GZqL
z-gO!II|uSuB!BW}Kr|4^chFAz1L8_RDv4?2S>@9S>QWg>3_y*icp(@GxW}zV1`o+3
z{{t7a1H*}@Za^vFu3CQtO%9yF`m(3Au3!LjkiYFs*Sk1Tl5iA$2uI<&*%91Mrf{~%
z*a2ZWk-+4n6{n!1&QL;l!@-?Yp9X_ikB!M8xKiuE5dp$hci`Rtr%3$gRv3{wqm
zJ2emcRr$EHDTq&Zw1cCjqTA-ohLBMCQDaHQ;tw}%`)C2MZ
zc6)ACYCfALC4Oo)Hf}#Zv+2DJ%wPLu*4pLsCq>@&+4_hF-~Qkk?Ijsn_g>}wdX_~`
zJ%rI<9yQQ6JP-H|LF5DQ41`gLRM;m45v$9LUcf8xiU(ZC>EO?3vj{Z<2+UBlm=n#3
zakiM~juCh~BqTOA$OjY{{_#jP0>VW42Afrs;vKeZMR(AJpAx(P^oc^6Ri9tji+%Ib
z{F5muCl@^Voh%hEW-Agb<3^U(vvAfhux6YkL9RV_-&5LG+J`e68)vc*7XHRV=gy5k
zTYc)uU0c5UZp*GGPmMS`j^@JiCn@WIGh>C9bxTjM$y*edEg}%ph=l1>f;+CRgD|(b
z1D65`Cqa?nki)Nw`V+0!tKIIp;nWRZ0giIz280gEV7on<4H6$b_epV=N(V@802ol4
zq8OYhoM~!491uCYJU-LzuUF9s!)`l
z^Y*GgT*tb&)&UNVYH{*Wteq9u0XCzKkOct;xUq)(2*7*6(Cp6oiDs*o2I<6
zW5UVaosKVCde3mJX84q}l*uF4+>$;;i!00uOK0oY;w0!4?qSM~_5V5&x#i1MCB1g;
ziijv#BQ{m691uBT*N7)KMwYE4WwB7&{JTW4p5f%@>NbV*I2Z~WO6Oi<+_VGK%3yYa
zlP1!Kfe%FPR${Q?fW0X|1p;eMCVdBoWk!IqSj;lCH}1MLefk%t=-a$TBfMGG7}Dxr
zdw<)1_FMP=d+YmKZh5xX%wjG}{7uQ?`n{ppQG@XcdL-EZu~Kpfzdp--J*kC1()ENZ
z+}9`Q*HIg(g14vIORDk8zwrT`!u3(D*IR2Tsoxyt?+5?!v)Y~a3m%WgPq{;}wgg9@L-EJ0{s0z7n#lyeB495R;n0afVDtgy~-WK9sv20#2th{e>w+*=EN
z?1_hBqold20poS|1OD+F^eYK9;sR;B`Ud#+2*JkuOHX@)X2I3F)p*1Y)si++Re
zL4nHGw_9{wyT}{1yZ(Ka8@9XteW_^IsbzeSZNb_ggu+8d7wLZJaO^u7^e5Mhdd%M*fNyvad^gRAbItsgwQj%h<67?YL@
z)RBFO%rtEB1C5g&nACWBQhk-Sv!;ek9$GhgsJLMA#XHyB`QG^P@8RR4Ne^t>&e)y}
z?7Xyd{ra6cJU|v}XY;{t=>pkp$OZ&E-t(T^vH((U&*9hY`gOW5Pv!82=L~J0gLDD7
z!hS<0CYv=@3BtAh+dy{Nc=}Dx#eeQmgbNqQ+_ojS+@zf4H)$??n-fz(DS9m|qt*R>(|xys|8f&0p6ny)a}GaeQ4Bl?Zyzp+~EfZ9Po4N1IEHEwh37Up2xAJea+zj))e
z-_g5xMkLaNjJ={CKB+&nQ{KHvJG;9bRf9{_pOfQSpHq)t^PE9C&)-k)wP0SPzgDI5
zSLipiOv?AvUOJC)(i{7^-v~Py?nBP=nx@~!b-I6sK8@StllpJ<6}`Rsw5Pps^`ExK
z^VFW+he~#Q=3XCo=B>`UZv2;8#-E7sSMl**`VWmS1B;u^`@5vSPu`@zPhQF2hx=ar
zuDeg;$9?Kwh2D!+UfsvrL+<6Z4OH=S{@X3bi7M4IM}L$rY&zgZ6^C4<-)PTkJF8UN
z83=O;{CKR&*TNsgB_kOCcoWhVAQguld8OTP$y_PGU1xQ*09i07O)4@=gBy!q$^rtm
zZCbs%=#D41-Ws6&60q*K`_3`*ZQHaTpS}0@jqKA6TjyWcy!jKx_O09c)#|&om(Oo{
zPkZ^Um0w_7_i1~SO7Ocj0UnxGaEWjq;jtPrghzE50$MQ<=!D!k-7rKqXOAdwOw1;1Lg
z12$}b@<`#HH6u1&F7ip?iF9{Si4_q{$$JM3-b$dDMvbXNZqfX
zsw(&ZBN7k;(}E^J-k~TDAB>6zQo{u}lK2_KQfPl#a=6I*&xMz7nlpc6|FCm$>pwnt=;ICSqt11Git7^U?wvaIUJe&c+ITS)
zUIfU>au#N`j4ol0AiIMFv8FfPILyMd?_6&vr(e5W`|0vQ?H{0*`-BzBBqag%om8Q(
z@KjMopWeNSx)-VefqA(ODC$)|-{hn&@R$exgUl4Zl57DZ9~ptvj1bX%q42{xWni6>
z;6Uvs2l^q>1#U2~X+T#4`P9f@{e>-Oj6d`B4Hz8+*sAu$LL$>h0516j_)hRVh^CN@
zhF$kOlHTxFpdwOK-{PLo-FMB;$xcs8O-bw=7Ymp_u;L93BWst{R*zn{
zwze8yM%J!n_4UJQR<5iWQM-85@S2q?Mh+jf__{0XAN9jVty(pT8dcY>Sv{&6Y!;pC
zTU;MX4>s>d055!n$=(m{8vm{as0ec*-}7`Hl8t3Dn`9ToC*Eg$A7Ul%J@2dis(r)G
zX@x8zirzCw3znvdNV7w5AOQ{-God7&ok`QQbFR}*Kl*?;NmN{ywW-xqKWdEhi{5{s
zd{AAe21Eaz^&s49C84DC7;w(1)|0(O0LMuD8}(LM{g-$vKMV1(HmeN17O!Lw8m5Ns
z*Y-d5jCRMyIfJgU)Fbz@yx)rDgF}Z89a=khQJ+4G1_QU95N+&RsR5x?pMt5$f`tS5
z-NY>JrAN4r{HecXDe$Mpef-VW4j7P|TUM6q{?zL81`No9+DiJA
zUnzwx2+ZUOZMm2rBOV#M+D{J|4}p>#Y9x}@u!p24=Xu^xWu@gZ_LVc}xXA>ZJwHYE
z@seZ{JrdO{~ctN4oL0#Cq
z5%NHMkhB1M_POMvd-TJM$Qai*3!zq^HFfB!dd1?fgxL3+HccxIl($jA0h+Eq-v$
zt1Uw0U2qVM1X)Qu$UX!b85+qrU}F)%L!wwqdS!}<17uKI%hnN4{Y7oZ{S?V=w7EM
z58A!?*51WUQq;+e(cLNvvO3?NHMm@hh
zf7);*rp+Sd#7ZMs60GOr8nvUp{i2mh@#r^#c;u*e$EKV@-7)^
zQH7eK5S>P$0*FqXNRk3|;`R5SouKYPkw`t^&Z35$MFLaMoa^q&RR7UiCoFmq&KnaD
zd6)Cd_WR$scl#O5d3yVOuiwA@^c8mI?3Vj~`R9FG&c1$n>;1p~`}Ee+7%Jbh7}VxF
z1A8A`Fes-Uh47o2ky35XTIN%r&Mh+;tFHu%J`)TXX+Sff2myfPr
zmDpeV?w2h-Qm{ENG}JFxdX0VQQnrmuudA)COB``j{6*`OudTFan9Na~LQR?2*HxIr
zlS(1R1o$xp^PG}KeOp;T3suDM;RJ(xHspsdDTX-7W{Ptt{0}8t4N}>8*15m-;a|^u
ztzGQPl3vp;3}6Y5-}f!+GC)eu_T01V9_?wi86RxY$rX38Vr|l0OZc9gkeb*7#D6Ov
zr4XFW)hgKg&G%wK+%Y^HX=ZW{93I2t71l*T@9h1F|9PtlE_7~sUfTRG=$QHdK2Zs>
zGs*hyb4Q9Mo?Vn{`ncOfD#`i?-*fx9>gN;c9l>*#@)}#LLBTPqSH~_6q;Wv*Ut);y
zZ%Z(frIck(pW&-5XN&wN&P8R&^t-;8KK;@u?e*7IO?h_g#`m}Ux+`Y)ubVNa3C**V
z2QfzrX{N#bGYLu>!Vfn?c9e+{-Wtel&n$|nyOHU68qqs0WqL1@!;~Gwxd`*5iU$8P_?^#@XkV
z6rMR`?sEhw?yyF#uj5zZ3wM$}8@n@}9w{GkSS0JDJPWX6kR~&d$J&UtQ{2Pw1VhB^D?Jh}lY5Dp2
z>;tod!cJrEnjqrWiKD{^F8{q_7x$O
z8_j_*rJL^rb^+swW|%`zNXr}~_>>V7D(<+$&;RJF%<>TGrS@b`q6f^xJ%KT!nfg!-mco%>6j^iq9o>f88Y4)nNcDJ;UnlzjduQ`Q
zY}3h;KmN$)GT-%)xUu<>|H*!!S|;s>#CpHFd8UtX#-^8CAF)rgZxRz(gl5C~R3oab
z68xUm
zX5Yg1+D^PNdz`H7z?_e`>t;U=PJmbf`4P?%fVK>jhq5T;Yf<%qA1Qw4i?dljc_uuS
zL6cms>O7O{RX5MXg#B8zw7Ge4>w3bh1NbJeV4w|*>9B#Su^!k^TD-EB?%e&Q_TdTb
z;>w*njgU?vjF=$K#Ws13lnq@;8+r!%h0TWx87FK=e@)%!oaasG8PPII>mX&jYQ*EN
z8l^M5dB{VD3D>~@r79pR1xAd4d&caA_$|-2A>9JxDT6fs)nzYjnql;r$w9!zA`=s}
zuj#=i*F|w_^OJ4PV;s7t^C)h0{Y?zlo=Hk#-CY+Sp8N2MMlbJi*FMwsEGp(ER*;mW
zJu8O0F0LHs?bW#A;kj6sGS^RHdGlYvrAG>R99mM)Gfsy@`4+Hp)x?o<_X6m9$@2-8
z&&Z%i-Rp_iP7X1o1n-sxqd2~
zSX4Bzpn(1@Y${4gE-Fe+DZ*9(KF$ljh&5s?ILz+_hf{I$)QS!txc|ERqx2g51K|yq
zM(taE8?V}E4JM#Y^C;AzHVZj?M!;j>xFFL?{oyMlK*j=g!%^S~<6$xb^{ccZLJ}2x
z^aG%TaSkJ%bklY&RgvOHeEi7D`d(F~IdNG>SB`NE9h@2G*zP;+wl0ZTNrtKWx+G=c
zt!t1QJr4RNMHonZm!9?o{s{w8z6JrIDWM-&Wv{_M{07p~Vkz`+KM%OTaBo9CliPok
z=P=Zj`lG(EZ8LDcoiL^MB>>l5bK?emF&urUmuCLJZkOkLb*Bb}Ev@Ss1X9H{KXJ|6
zaEA%gLBmBZyNPvtN)_mjp22aw=Ee>BGa-gH)4b-)kBHXV`Zp+FZ`v1)eLAKmBIy-A
zs4qd}ePKOUUw7k1eNndJpfx|7)sj#CO~bpGuvjaU`ZYh$N;7EB!a0d(-y6;gbrz;m
zkG9?MJ2#>k3eW|j-Gf*f8}pzmyZrolZ4)E&8v4CWOP134dRFMLqSGo`u<0~0K^%q;
zQK-W=f%?T-G9vKT+_}LlfvK>jQo3u*S?%@1Z0pa|-#Wyy9~4dkpNygY9)!k`OQAH~
zDDW|oh;2l}89cj;1cV4N4V_EWc}HS&u$w3xc>QgC<{;O|OiK~sm-E)A>8CD2=uG*L&tIq2ow4p!t!JeJAHsB;N_0L9AA(dz9ZDEOICW2IscUcD
zXys8v9GbXH^8wJW!zV9YBAq@HW@7DjLhq^w$<$|&l`gKqn&N!&)h0PjWJDI*9Y4~y
zTC&BR+Hcx-%9$$z<%<`td0!HLpM_q<*U5eQ>ZpT?8@vuDOx)@EgoV-TK9D~`lQ!cs
z29D5?`6_^(#8J|
z-YZUhU%UnHog*(5x2lK0o2X}z(+E7J-2vSy6sr)5f0(yt=bYVpX7AoLcelJ^?(W@9
zdw1b?6N#1rYyOHAh-0KQ=)2u}jI<`8#X%g?!_!FB$4K$T31bxRlmLev;m+~m&EktRe
ziVwxbkVX&0W*IgL;yz>rz!#bL5g(3c5T_82)I}>xE_^Iv^5pkz8I_
zKA=x!!|OBFzp{Vap&EHZe&5N%hm6;nCrA8c?dd1V){Vrg02q|(Vk2@ocYxH=tEec#
z8?h25)CT!QvC{u-vfLC3Y!m*fE0)usa^$8V
zQo<@IrjGOvlvGsVEP)7_POKf^!;D5QaO>$6sdUAE@mx*&=ZaEVx?THPvr>et8Hs&^f+%k(*iKZ&jYlbBuJLu3Ar2#dCGzOw|0!#o=Qmwkh;#}U
zH@*H_fTsC3)HcpztZAZ@PB5Ax|EOk7AgQnj-;zZ@1a5=s&JED+xQk&5d;sH7iH57J+$YB)?ivWT
zNj(GpiG0Du&u%R|QMcsHg$v(YGWtaKb%Q4Lozh@VOka_JHN
z71y2rzGdLRTj);^owv((YWvkM;JX+voFR<;U_AI%((QHuz$6eSElM+S?8rTqRJkh5
zk?>*!?ylxxDj>p;RpI(Yc_upR?SVG_^Z^1!dN^CqyaWtbw8jR+Qzo
z#_}qhoN?!v4%?NPnVo|W7oFQha%OUkw{yfqUbwE`!pich#;;sl&MK>JTQO)
zZS*bAbd<$)^Ob*yOdMHOIm%K|DEA*XuHlO=-8+SUlo6e01u%=w1M*SrfciP`R7atN
zQ?)`QVi(~uLiK^%ZYJ&-Lq!EBB+DenP|y%Fa&3>HIDbS+X~g7r-^s{M%aV^CoNiivaL@ewyrS+^wqC5t
zCpoV`5MAZ+8P^rV1023G`4|&$A^`AG@80tjQ^?3E2hyT<*G&y8qfW2;ou}YKvjl3@DTB
zI<#opwna-eZCX;4&2oym!+hkx^cVUrI&}L&-}$#M7}49e&&ZK|eEUG5
zVHX|3p%`j=kg?L!X@JUbU28x*2;@Oxz8fG$iNc`Bq7Ia(Myy5y>>ooCB)Uns1X*W-
zf~_WtN2FpzQL}-dRXqWXO;hq3hfdhVhLv>OvsnJQ-{kphpmt_*Hd}>y7>vWrR;*8?
z&_v_WEl75pA{_adFpt56Qqv34QVSEaq|M?w`RE<90%sjtP;JQ_T@YAQ
z+r>Hr94GdBhkRVyXLylgul@RrC?#2dH|+kVU_@2Jpu?+Bx4ilf#kU7?wU2RY3`)qIeS+bbG!AAjw;P{WXs17H~B2tpUa}1-{%%D==EZc
znqnV76zDu)>WsDpa}E*;_=(COASj&&=@&(M}iq$<05Sw7R81^9{a
zoG#M#kmo2>zeD&a=c*fZm$uQc(?YT<2f&)GrdSIZTzS9Lt)4xVG7$kkA{db8Btm*g
zT*!2;%$txqBs^kh;jjsN*x*E3CtCZwHd|Fe+FM0yAhiuLf3-jK%_lR8vc+?8?vA-kU#~`6Vma4nXx5&1YsDW
zHYzfj$z-z|Y$nN7GqUE?u~R3<
z)Sou>I*(QZ=6B4o`1uq~;
z8xd(M?YMifZ$_10#M15lIX|)!g}s@+P6qkTW?zN!-`3DRJ?IP5gU3P~sQ^4NmFZw^
zzy?XL8$_t6&pL
zvkwyYvXv8R^RgNzEZdP1Z%bCD#3ofVjLFWPSrwZU3&MQ$pYqY>BJ~US&SePs!rwVe
z1){2*k{&C1!4@V8UYO3f1Jy
zu>FA?fTyxEyMHTU)U!nC%u<$zS>jtu9EcvRq%uO5NUw%ss}Ar~IH;;NtZKeBw6+us
z)S^o8Pb(Xz74#mnaEZ3he_mqW@UqBTwA~e%Ii(4`eZBX+#nK#Unex$|j--CXQ^iyg
zPnDkwmSuh?YgK#^lfYPY?qrz-&ZQ8*BVGLlds2Zti9tr2JmDDM6Cc`>TuC%|lR28!
zwas?G`+&9s7>8E2(ddiB2w)ZI6M@sr2MbN)B(2So{#TD+F|An6oUHWJ&UQmqi|JG{Vm!J!4!eSC{Yfbr=);#OM9s2D-K`U&`W
z7uK*7Ap3EB1?`;x(KuCJo)zp&e21(6ph0_iFcLZTNQfOmqJFPx%F0v^l;g!b?0lmB
zfCQNQ(wLXl
z*2>(vlCIY_AN%fIZ#8T{Zq@izi-@t`fyHcCzPB8p7+2t<%wZE;?{~21ti29~;~Jjm
z>t^f0)656Yc*<}NdiGe4j{vG%GaG0tjD(Ck+qB4xZk`_SDpXQkYD%ukF%soACYu3i
zTsS`;79X!yl-1cSF!`=dX((=6y0pAS-CM^Qg&L)9Dj0@wCh>80f@2|NbVP;_eXUS71R$w9ZJw~BJ5NUhVIBW
zxC6eDBu*RYR#in(;pYz{A^c|B`WqpM4qT*=aSoUOE-vaa?B1U=MzM%U-~>8}6>QLMkRwG?54gf(%2s1fCi
zd$cYgBOs=vpj)2wQBv~gp0jKPN9Xx0I@BlM_4lUPtfXVaXE`JK`t(8F66Zp|=3#>u
zG#OmCT+}a|$qFL)D85s~Ttv*pBR(LaOjaq~XNwqtd2`(ur3q>g4Jb`e>m1y&r#lBr
z7z8fD`D8djx{5CmMM
zyLTI2bbe2LJHNy@7okdes}3Ef=>~QQG;Xg$C!<7=wn&?>TS;0V4(94Pye}K?`wH*#
z6;e3nwRs^KE1$K_%;6A2H~@o6X76BbVsIO(QC|(!Ic*X#-zI~c=k3rQousN9aY;7Y=8F+6a`q+v-!Uc=a
zm#=I$aIaL4n#&mIZVgxfN_BfMg)QxMdodhnSC@~OZOPmH3YKxAZva1390JHVfG
zFE-bzx;;#ovZ6NMZty(-o$9%X@hNGs6O|QjiG%A0k4g_7klfj~o1ekAr+?@E3lc3c
z@z|g980&QPW6%<}-2?k0_!}XMkcvpeNJ+IoawQQz0WLT3Hu6j67==3w@?0$#4fh2C
z*X4@rn^fCc2ZW61$VC6@BlP@6eX0UkX-X
zpN>(jK#Xzd726Nh9lUa3CUnC9oOyYYXQ_dz=L~}0E)I<`15aa!@=I>oy2!3Kl$YVP
zf7BAB-P;=6*%FM}yKd==!WeY|8Hy#RbTpyvg1f}U6Z&y|$I^-i%~?2Z@|qo9LkFaE
zA3t^7gn7$1v*LuoO205$Kv3zxzV+ivdpkNr`GpQB9WZiUuOtfw3w%(F^&fAz068a(
zLrNUUiO8x*%a8Sj)z~8-V)>a^8EXq{S$<03O_DO#_L^Ov?4C2i92e#t5N%C^7q0m!7a8$m^V>d
z1E`j1OWh`87j+EpMG6s24+T6=)zBs!;or+YyC8nn_b9MpG~yx{_kOl8p>K?TBs
zp&+3$ICM#DdkNH$nnsfZiEt87N0?DCs%rX#YEnk3GcYo|T#KsfW&WUnM6a)_FUl6!TQUqzAT!SYgzD4VeOFp@0p7+udiMAfv>LPinQ67?z)Rc`xx%u_|y?a;b>#|Jx8np2!^s$|V
z!F(4=kV{KJZgXC%9=d+)@EXuS(0T#NYOuOIr2`~V&c(5MY!o@t+T$QGb?Oin7~pFp
z)ocrS0>
zWyXzlq;&3OI6AI2rz$+<*2%L?A%7R&9XK(|bvV$RpV~9~mT~rM2Z=v|*X8rtIkro=
zkLyx93l5==P$kq0w+MIhxgCyT9ix@#j>*3T8rZ_o36^%HI2;ki_4D)VW#s%h!${Q^4#OUm`#rWuyJ9jr6qq(@!WI2rEN=B
zzuB`J#?GF}l5?|@#bI-1jTsC7@08r^q^pBwu{rf)=$0cp*>}#YvHJI%Bzf0tHhXOS
z?CGgFIjL9vGLzvAcqkRkRP!8$SInA)_hqFxzov!_vu31bJ5scBRg))IB@}j-|5er4
zSe4MN;Oa+%rc4=R?_MNb(64m6da-iqL~4ARxclYr6G7Pi`H%rWR+JxFW%_8WU%j|hJI}KSdsPbedU(E
ztjjp@_FX++9>-2BXz0DsRmYMVv`59V;*G`y&JnE6U%L+}wAmo-@TX(__Z44_>iNo8
ze`z@D*He4H4s8N)(GjRql-!ORK%s<;Kq>18Ygd3gQC8{5mNuN?
zw{HS}bf{@`&yH)KN9jsfAp>P3P9h+c;DVHGv1fkgI)7G7W@nX{B|lH0Gsv7hmGI_L
zVU;4$mYz!o|tAGF7av(!>Y~Z?AdAfrtUct
za%ZgAP>`Cx>A`u?%Av&YkeJ4)@qsyMUBNA*PgjG|K^v`31b*tkA;2Gk4}ynqZ*DL%
zxa~6b>`X(L=6lz(Qi0Op`Sk~;>CdjjvrGB25&YQz3=@FG9}a1G7!sX5Q(Jnfs+;tz
z^6B$^rX4`PSAWG^b1~O2p^!hDsWX${t4hE?;yuHFM#!_!%}-(xSUE$vAfp!|`B4f&
z@c
z2&U9)Sob{nc(YyoLa+&6>M%jZf^3L5wEWA+)T|g(%75wJ2n-M=2ZI1{NA>bjtKmfF
zRS%7t>P1Qm;$`x$eVFfHb=wDFGpOdTG3UW8XrHAs-d?l*pzYd?kwC?}%;q2h^~
zy#ag=+{Q2)n-wvCK#)+Ux^rQ2a51|pJn5*AYCP$37fdp=lEj=k-)aoF^_CGE*+wxv
zAvaUK{ji#qla|CbvQ=YmS?@P-w|eozF-s|#rMLRoduT=VnoR^q`bK~F?DRCpM2@g<4e2t
zEGP6MUlLF8bqNycb?79fDv}&auUj7q-jo1AG5i)54Hhi&JtZ9^?25F8C`FAX)kT4@
z5Fswqy_5zd`M7>5<7`raB3NDKIYFe8G)Ly|G
z`Tj~q_ZTO&8{_qBNMqC&(c)P|1&as*j4?t})qWMjc)1lm#@3ajBwN?7B=@5C0N-7F
zoH_tvA>HE?u-DrS3&HPNg`qUgqEcc70NLd4BN}0SkcCMwK^RmoN(ANY&GDi)Qiyu1
z-j!xEGO@ri7!%bA`t0B!xFQ(65YuW!qkI1)GL92YV0z(}m!;b!l2t?3ulDN5WU
zjjQf9yx5Xw*d;Y;kGRjgx*;VcBW~)F)Rf`V7HdP@2!OBipO4=_<2MLi-YAVJc&n;+
zwSY-r{HnKF8H(|PO^=KSH^ckme=vTj{RzL}_-DC~{WZgP;{b{IqYV3Q18qI|?YQm0
zIwMBR0Z~heCzHf_gAA=4Vrj^Ar>)~oP@osG9P*8)Ljz$~=afTK@>BDCml6)o`AyrJ
zS5c9dS6L|*xW6dxAktW<$aPdyIC3jIpUuslaiQSzH*`9LN%FT+mcaoSi4l@+0wuJ{
zuwSXFp&B0swcgjuM^X)HrPV@foDgqGvL9&arl9Pr^ZqjyF=F&WKD?OiBP)}L)Foa4!gzZBY9Of4WZ7`&*Hjx7@d)RjNY%k3d(_;(W^Hju&JenHq
ztp15!gr%~R-G{gi)HUNL8@f&EoLKyNz!rL@x*pUc#e3@LqZMjcXkm4AVfX54*;&=C
zYgJX(ZdG`sT)r$JzYq|Dg}?KPy*q1w12A6tvQ16&e8Hu;ty&^oG7=h3`@=HR$)W9dk(k_
zpl8h7mnU^5{|=>f_8yxrOuxWUCu(jRcBHmn-`XQXZ`zdjn
zdM7`*f9|97e-#@tx^!Vl$->h93;W~$nSJ|a;!i%+dv;&lFR<_I-mCdFTnD&e?qdOu
z&th+UgkqiI1cIZWNCO5JL1=5Z^y7A!K*}_F1WL~K*Q+12Sleu%BZsqJp`ivg?s%zx
zr@ec}k7mdzd~4+9K8-ne^BJ~H{7w28QfCy02NP7L0#C>~7h{5EO7tu)RR25{FRCSi|IK%4J*BUjS1E7&`M4x(w84&=l8dYreQ%A5Hz{v)
zd+3p(imp@FPs9Sft<{P>VH*n;+?)^v1jG>AFTi!Ng;#^C5%IEe13B-=@I0_k6EKX+
zZrTPB{CFZ5?mR)U7LPlR$&+E*=EyV~a!5(^_M(i$#EjwAsq(q$i?rIAovgNwpCu2I
zj*hIv_)4`3F&LOR*n?rk&v(YIl|EJjFP~A#g&eYXi&moS6{l^o7P2q
zcn(Ha$M@@^-YN_`u7z=?^gH7n(s^*?b%1
z_-kUOFopkxI40MdKe8j5;p>x{9(RL^fVXM?P_yBUI)Lw6PZQkbc-2a=ETV%sM3(yV
z6Mmy$F@?L@Aqp-uD1fjlWw32S%eW1gl(jf*q!Ndv7-o^MP*wAaN)5@KkrQo+&pgJu
zP3)OmV#}MJ9Gi2Pt&;!Y<9$oP6kkWGcS%#fnBbX(BXUxFr|kg@UHx5Nru|@eS_lR2
znj-Y)D=rUsZBx3qn*;#S{M7#mAT5>~$iX5qm%9Dv$QC7m%;wR8P#*H)&
zU|Vx;mPADBe{|K@9)n7AFyevXg-jxqmB=JAsJQna^au&=?o>HNiTFA&ni$*3p9Le$|Hf8YOS);opC3FRxfL7$W99vN4^mD$Dk^i99Y3ypo?BX)3jpUZ;e5mOvvx=Gi{Me*
zu}iE??2;>MJlq*ptr@p^<+wGjUneYIHF3?V2`g8R2i?GWKLvlo4-oTfFy=Zr%Y$pr
z!MJIYj$Ja)y=qMFgjdk=pNQTUp~e9alY-p&__`zmyj02nkiei
z;(97i=V$cmm(ibiH2D^`MgLIyzL8yzy5zu}OPi;PZ{0g
zk;l7_PT8EhXFKL7IsLSb&0X+*J=>C7uB15kV6wzgE(GWmP-(OgmMxThh0i2qf0O8@
zC$Um%5QM(jx*A$HS@DgJ^Q094LPzQexAn@ZI{n#-y6o!csOs#7pWVWm*cO&qX$Rxo}ev(M`
zHfPh8@RRH7nTh4HEM^!!aC+cY|Ff&+X36quuMMMN=c1@l(FF2w@
za3;;!rO5NK&XgB6fuDgt%Dv~=)t3Bw?rdh_i_h8CU)ZzfgY|LGraj8y-g%eB{#BlD
z9oD1AFzb*5W5>cGfxeu7N449zphI55V(RNb@CTd>k$B`W5n(;r3Q2Xa=`%7B5vlT+
zh=4xY$V=`f9uv`xZhA~aEfb*pF!oGE?DOuIS9U6mm)>RRuJ;ek+W$Vs87JiLy*H_e
z0?!>GJLyy0q<<0{gy95!$5K8v*@^w-xg_`()KS&1NbWD-YadWLh(8#{0&-gN
z6mdfzcK&~s*Mani;t#IpUs>_hW6F#7-M5(qX#d300?%R0PU`|{`7cW)0WF0LT{P<#~sR#t*rR0YZr^jlupK!>A$
z|9yF-Wx4poVoO(7Vy=^KGFO~oGD9htF4)~am@C>)L-L46vXh(46&?>T&|KH%59exO
z4&6ql(}y!N-0P!;9Tql|i0t`ba@QtVaAn5_0@fn(r6M^0(!-rTa%JV*ah
z{oH=)Iud;ph8ehP
z5G<(-JQG^V8&M8NJK8I1-b)rlfRDo@1K4p`QiCyIF+EQrzEW?qv3}_3E^;
zLuA*)sF)xhZ*OnEhz{Z5KHgqIJ|n#gl4Fnz4+Ibj+OSR?VxYQHQ8p_;AO9Z2EmWxR
zT?VfMO#yNSg(-24FzxpdT9x*%5vSGk-M{YIJ%(liS>{d6iM?%+^9b>T@u~j5<&R*J
zB@CkKuP)YcAUil3~r*n+rZgfqgsH$sRsHDs=2TjA^yyp#PO#>_4
zd~U%4diSA2+M7sf!<=Tk|K$Jm`%M-Kv$5d)=1r`0be;1J?VrmRw&{SwM1=f?5DNJr
zpv7+hN0#hD6u=__QNhcm0aw@{?k?jACp20333NxVWLrXA^SkV+J@Cl0J0uh0|pko_=x;QkHTZr+43VaC+AA}pwvV83AEV8`wQ1aFNSz`
z+7nU^AytM)X=qZ+hB(EZ9hWWzzU*prHNGhMD?j}+tmM7-N;>?5-nUuy(}pN-L;sb<
z-`6=1uW{?YZkXWk8U>i{jpgx+Bn72wSUUySB<1bNV~UrhnG%NQmHPYKW{T$XI0@Xe
zuz52mt4EH&Y9Y7^Se3jR$gJoFW_)X(ib641z?@o4t^?^TaOjZ??Ux}lk7|EA&Tz<_
zJ2n62?ak-lztfA)Cl~$`f}aRUIXnyjLwc+^c7GXyONn-ITX0J`9?&*xoYjVepzdTn
znFfv#i9gr*Hr*6wcO@Pfo!BcPzNo<3G23^sS9N`E-H4PfmFd$8e~AdT#Mlz;gO3r<(ssHM=1^`8dCJPEEsOm4)iCC3Gk|eJCZ2~QAhwr!N#DsahosT5EyUI&ha?c
z(PYUL)a-E%g;S{JpT}a8dM_GX(Z8~TpDi>$A!^`+F(oB~LjrB`cRga#rOB+JrhMFh
zA*nI>wiq^(jjbG5TUneF$M?NaUZOo?SRh!1o_v2C(b(-6oDZ=qFaVlw;IC^%d{A5N
zCJ@yzJQM+?R%Z1G3^3TW*jxNXu7%bGZ8}Y~U#`X;bNZiXT6d=3oQ-`ld)!((tViMC
zevWSThz@;}?J47{W^TD-$BcvH3xj8y`>dQbZgk)7{qjxKLD21E-wsI6Y7{4&ApDia
z`Y`NF5||-)c9SwRNzcA86DlxJuq6olWXg1G1HcOKgtMqyiW=|*(ad^~aq@M~$O>C>
z(}#*$Gd#3(@<$I~e~rO$*5JgTP=ik=U329wF=-FY&O!(Yc~j?*@=$kjA-_)oZHhX6
zM~=!IRU;Ra1Wy=ugYe_Y@`*6TmXh6LFiVIw^fK)m-#!+(bTZbA{KAiGI}Ca(@*W-9
zc`Pz59O`&cEHVxr++qXTTxl1JOsKhSEHX*h6l5&cLybxz_BFA{xJ|4U7iaZ$I41~T
zDhdgLGe>wR#Ug_Q1jJFasoSX=9`3XXm_C#+c7p?o3C_?Uw%xgLPtMRO^X5*j&owT~
z&&bN}A8nD(NM`K|$NnWgO^1tfr;Of!3eP{Fc=8Xp>)c(Nf51Y=Pfu9OM+y%lzFV&K3zo2PYVZ6J=_Qd<
z2d~<(&G)VJ?DP!z=-zo<9X$&w?tM~JBQVvFjMySI
z4%~ID5n=y61|Vy2ky1D~90PP;h$$B2Wh82Y=UE1C3ehkO=}xFz69pI`=`BQW)bD~a
z!yuNMkeeyp)WOseZOt{cHZx&y+Hwp&D2=PqNj(#m-TIvpcxB{hetip4R3*40>)S0=
zRd%zG$P!kNff`YsFS|{V>cz0s;(jpCC->8uSxRh3aB2xFv4n*t^}noLw9s5dXCLW%
znt@o~hNh{TkOtkM)55gr_EijG4uO}F{n;SOkO
z0b3KMgRDM$?bT3cpQSywEMBr@>*ADzMek5bhU^~QbC8!I<(0CzdSZr!2YsJq>BEbC
zd)M#~MU3xW=_&0m>K9!4+ui28`oj)~+yM&Q>fq-?=M`6^Hdoxa1EMKG0FE6GS%e@6
zN7zQX1nv-MjVwb};5=mXMVR6>%|P?0kt8BD@gL_6XgR4AixY;TXE>)oTXbe!=O;GJ
zYiyS{AV>W|&l{j!lFZlU4R~C7l%Iu;p4fdTGlWoYK$RKC7VH54lna2sHQY^2tfwJX
z#>K@UTc|s%usz%(x{Uk-9l8&?s|TA~6C7(+0;6J65UgM~DaJ7W*ktumhJFm}ajt0%
z;|%gFQMOaSK67>jjI2Q1f?1fS?*Jvn2=Kwc04f0YGB^sCUvHI3`HbNj+c26fWH?dnDC4mMId
z#2Q=|*x<+r?QZT&Df8Sody!Lx>O^J;g~C7dm7}mjV+M*k1F<1GInwh(!4Iq#8J{z7
z>-A@S$
zq$K3h`J<F$WBW^7_OdNr^OtX2Q63tqzF10oR8$0YIrQgTMM8WZ
zJh%~83WNd2Cy0~vC=vnx63R+L6%pfOW1`w7qc?h3Rd_bJoqIsUdD$nO^#hGGwV8f9
zozXVRTC;fb=EX%hIYmV|j;U$(xb(Dmdz$v26njEaQbIyk^9$rqIE`k9*gs%$ckG`P)AT|-kfI{l2BrwLJD(>>&@EYRY^{yJx7;M(
zO_49^HQ3;e5Tl29B=6Tf0GYrFMvK9jK#>e?$mYU5~)u0^Z#$HTzsVY
z#Q&Q<`jO{s8$Z~&>-`P(=h8_Z{U(chOe=@7zx%M*p$Er~IY@np&M23&Ii&gVwl-d@
zFL(h|6vG;V(1_%6>7#m)FP-3{8W2(ghx7)VJTLX8HeUS*al)b-=ZLaOH@xb6;`ZfZ
za)u7cj?F&C*3|bLR7ytPHrG})^sJ(5+Gk!>3vAYeIG-VNtk%xj6+`6$tF4R8IC!?z
znuv8BFCSPu|i*0vBJHs;#FkS6?Qj2237|ZO!)cf>;q?M5iw;zu9z##
z54?c02gMo;h5!RXPCTiAS#}(SnuK|>bYBf;r?g?>Uw+jtJ$9s1$CHn0pSmt?9y4as
zBh&9^uWLUdPR68h8v|^l7n}18A7D=ti|lYbCM%m;-GI~SPC!gX7ScK(uTD6k-nuXc
z1va!XxrMoEeb3i?_F2t4#)tn8d1nJxRk1euXJ+laK_x}S#6m?yB}GL=#g7yf6%`c~
z6^#td4^&h#EJ`y}G%_+vGE6cnGAk-FOEXS~%8KKm@{|>)I43JAPgz-6X~O3IXYIWa
z&B}A```-8d?$zI)S!+I?d1lsHvuB=}HLKCv$^ZCg@;i+jAN-iTZP_*Vt^4W6b=%XI
z-nZsQW6OTpb-O<1U%@=gpZH11Fm<8#sSHnD)DLeo^Zen?-ujsH6V?y1cQ*0=jGXw`Q!lFZqTQvA;#$8JmXr^|oyq<`>!+i@mGIh<>(BgO?7?CZJ9Ax9$S>
z4{Tb}`+awzmFs?l4x(zz`q2vJAt>}C|J}yo6vv>xS{WY8O
z2PMmRKBpHPY*tGKUmDEY=k?c28WRA+>GQ9bY~H8g
z;otB0!HVbE3&Nr}D5gIP_~|>ct0!ywvDP_c9(`)hrB5~=2WB;Ya0{$!XRpyVofc!O
z;`ij7Rs3dRFqgI1t=pKj`}2}6pEk06wEx|#-G6z_r{ot}lMfkN4qk3?IP4GqT>4v~$(CsxE^g6~uCHg6
zIiFAuY8ZqunXT{}G6c1k4%0*b&0AC*TbKRz&wu`D)vs>TIXiavyHBw>Q_Wi6P3m(^
zJFObKuCOZXcK>@Wu-)!k_1b-J_&RG@5y`-SE1eD~zqgRIy5vbw{m$GvMO9{2SN
zPygiJTHB%a4!K15W>X7(u;{dS?oje~Iz3DIo!p^L#aGE#G{v$hKUeFnPS4(9p3MjP
zn&sqXjBO8kPXDxMMDIZ{i`C_q_a3oi?bZEfv~6=m|Jkdri5fB_s&~wgs0Fuo?bxyF
z?RMQQXT5z3S1*2HMtQ#X-i&hlKr(m2Z7+cU``R{z$l5?!N8s
ze>bA&6PB@V?mc_m+(OTqx%TEtGWWpNnOJx*8JW$$yl)H4#_VHn=GnoHM1Ljgw_{-j
zGQ++W%^SCB(#pJ>#Y`DrPxEKnX!8*H?g(~bt=G8MDr%F_VH5YmXip#a)M(%RAouZnb@cR6?dR(|p06A1=j*HL#9u#Oyt8mhy-NH>
zGT5`&=3={n_&48IFc-Z;y>}HB7LIJvhIbWG_|D#`n=airazP*JVyAmr?HqZVXDK|U
zqZzZCg+SPlxx(w1$HIVVdbqP}`(+e+cp$Z-*?Y-6xqUp=F?Vjo!&}=7ZyVIH+0Yqp
zyFY*YIhX&74n1NjTDxC#Pm>k4e7?kP2GXpqGV)EY{LC#yKbI3;KxyPp&yJZ?2Uo$|
z5sZcHOw8xCt$QDSUnLE1*0Fip;qGaBALGk6pHCXC9WOG0WM9|*HQ&2O)$Ye$6&BOZ
z>__4q&XCzzn^lJOvdiJL$Ju?o-=6PN)4!-sKr8S~h}_uXOq9>veBSx?2wtXaR!6IL^y_3W)`pEK*&t52W0%iBJfb?kOp
zd75o~j@5RhedV{ybHA{>z3zTF{j95N?R75JHo5#b_KwS4zHzxe{hafI+8KTu?Dn5p
zwcn1TD|G?uml(@gW|vU-Pjx6*cV>CzUEzbVeLDF-(B7JRD}0Ai137CPi*MkT${!B)`O*g
zOkd`3`Iv-L^R6FD$c%NAWd&*G#a9tV3x~H&qHo*?R^E}t)8TY-s#j(h~HqX>9@}#d_yhf|?gyWFMm5^aGF?^urlX3zXdKOH}0_?Y;iG2_g_4fk96qUbSW`}G^mtca#v
zJIc2ON6`Na5_V+wbaBmYq*%5^Wzh-BW~S9*?$6<{r_3GJMALb#-);5tbz4;jtRVG%
zGt@ZW+iz<5fXR~wFw%25A9=*z{grCrKYUD7pD|~
zH=}TKyP9cu+qCJM)MmR7KXty>+S=V!_4;qT+NSou_L|YtS)*6@|J`dtxIjIB(^%mB
zMfTZQ>vwBCI-b+D?ReLxuRCjU=grGC+&rvjuVKS_^&F<|v31+d7%SCpygizUkWn$Q
zjIr$3UoAg&2(n+IK4+lyhcCtc)_|*i&!JttJL3FEzx7mXjyI_C4MKBVT=vy|0s?!Mc8zFjl*
zTEBmCM>H{3J=@k|%+eUDEYJAb^9-N!0JA@hRo}p@THN`MH{8qO1`ZxMa`2#0u9-@^
z-Rh#q;juk>4v$6NRgQP;wX;pwODDzCj$oGZ*!?kt+0VgT+HqyKC3f$SsY7OV&%{Ap
z&l{(nhlbu^AWuzT^FX%HJ@fLZ*%yl5;hF9s&x~cXt^e}rGiyG5!SPPQt-O6|UO-Jy
zYdm9V)7gJVf6n;c=sClqXG;u%=A#e4R@3x!9T=EVvo6Z7v~14H$PE9_XlV9)|CNU3
zb@Tg^^QJy9b5xvpJ=XQu3svW8Xs*{)j`#JCeq+%uOm=$~o^-3z!rV)
z@^>sGiLctsZqoMLmD$Rt{dwcsQO;b3e@`;DwYH`mYu_%kO~@s|Y;7DE)->!)xdYBq
zZtPp;H~AbZ$V16@P2J9W!dKouWplrQSB(CZHN^3L!YzqIr||ypEeY!Odhev$&)5XV
zKdcwIhW-*DD_Nb+j{`F>o-a=^jm>Z_i#bxsW+K|bMwjM0r3^$Y#{}$(rfyo88B~_0
zTlg|zYNko;yRr>Nl;25B`7dHUYe0j5=Df6RCN$ZU!Slkly>Ud{OWSvuyYC;K7q^Sb
zy=%X@Hb(CW|99zataf#J|6@PKy5G=yh^=>!Jb6}qcc6-?*=1BGwZ6(z>rTGrVivua
zsb{CdOs0BY3b3Pk>z?Py-UrT8^`7g(y9FkSGFQ)BpR?n{g!zJhFSqGjr3UTVH?yx$
z$eAnDvHozUuEZ)VFQ##u+itruNr#;&`L&m>9+J|FTg`jThOi)oci-fpv&tTBZ2XK(
zy!V=)!}6QX>>uTFF(UT!vt~01ZTZ>zI-0wV{r^m<>P0c(SQi)i?!OC`NEt6~*Z_+@
z&!E{tq(SS3ttoIrmOGufCC~fh)s;NwtPLT&8;I0Z!H3-kldPh5`w)EDu!$V^^{MLH`@HtIru>F>vg7~B
z{e+IK{fK__mlAZU)H{|a^^Fx|wKYf`bTH3;A>T9g>#$ml-rCYJz-?d8)o9>J>*rhP
z%vCnsU6!EIo2}okW#ncSOd4yO@6#;3`}JKVmZ5>gsVNYlz
zoovh7d2M%mKA?r=d*PwG?#vsQZFEc3qG|u$4I3VHxc=3vflddk=+TGOs
zTI)098kXFYB|HQa>CyVd$3GFB>_zd|PaQUlV#J
zi|n~;vuMq!ymQm7|}>1v!m%JjYA
zO*^))TLa9#hxRbiEWTrDjQNb3w|{O8c-7fIHo0%?)uZ3P{`&a)SKYbW`Ca6&)hXMP
z*1EGlZvXn``xftBz@#<(!o%Fd$@V?WcSp~252raYRU2#smhW8+W8D4Sci7Ahy{gS_
zSC~6d71??GuC0KwdcT$Xzw#uhnHGTTf
zZm+8WH`}^O7i)&qfOio6uqg3$?*MuZ#-ikqb!M{O7+uY)qHI&=&?#npY61pC=2pZH
z)4R)jW6pfBw{r)-e!Y3g#^vhr{qdT=_WY{*6&r)Ynp_lW&Cr=OIXB$!-N4nuhOIXB
z+pP9AN2#k3_3M2%re$ZqtT=*9uFouAIOuR*weOn+qYov`1XH^9zgK0Y0;_v*)1
zF@?>yJoVky5G&xq(&dou?(45N>DKbWqTY@Xq-%-M0FQOt#P;F<(`1^(0p?43Ym3!J
zy|;!@zKOEJU8-Bid;GSyHE*nAj%GVxkCmaF{V{saF5iB^)DU|{SSx17tL%sQ$bHOv
zHD8jx7l*kQmfq|+VXd32W;F06Cw&&jw@Az%l$8x1n_KOywdUuU1X=DZqkAv+L>(pv
z=$rFxMmptK4rzQ?^=q$d)a}NZlFTx*375%uGmX%&fy=g7nmrnuw`awm-TI-HpUbs>
z%-|+{dkr3$!pEW=d)*TozwSSJV%&irN)FidZw%>CSH5jWc$@!-A?n7yg21URabRpV9BcY+P59I
zIdZAndiQ9_LhI0i?#Ygsd=Zd18+b;>IcD-Cw2NtD9^&?<)~pmd;#Bx};&|sJD4+QX
zqxrvqMqob!w3*eJR?g`MP1{#XtAkNee;UJsP(SjO^#!Q~k!~`x-s29fM*iBJJn&+GDQGjx_0{I~e^Nj&nq^!$O0n
zc~`U6Gn=L)rgn=X$jWCGh?#}7Z#K!IG-gdAV|Ryl4;>aFFl;gN*DZxT+1MP13+eQ2
zjAI)_oB6Cu9aQF`?1KB&t}9sc@rU_8+X`Fua^r2Pn9Izhr)5@WB#$H5-t1OQQ{&IG)S@>HTYalbN%J3@Zyc4JJPI88wA=CY
z^bsSb8;l^?!cy;OyB%d6g|QEWjx+OEDFo6U*z=T>rk&?TXy=SRJ(Ew$&Gt3|WBW16(d%}t)JJDzPCs2h-2Qt9}Y
zb(7z0uU)TiOz%4TWBp}8eP#S_+Va>x&djd)Y(1F9lk;_T=j=zx`Fh{E+53_6
zTkLZoJ$o#2j*S}mXU=c6&$~IkP{UoVl&j(Gyrs{oF`k)U>}US$cestci;Kpuj78_(
zd)Km*ga-$+lY`DZxA&O^Lj4#Fo8=;hS+i2_t!*WRj#Ij;J*WPA^YLaaoEc6pIaqxg
zlXRWjADH|5g43G0{KDI+5Bidl_Hy>BYR+oYW-=zGq2~*#W`4|L6>3H^z;-a^VVTV+
zRAvb_`lfiAxwA8Vbjf)ijyJ!|HdYRgg~p5$*wW|B7FHvcZk;$xHHx3k9#&0~}#;P<^_Onbh3N)NfIANzuHAhXPGD))ktCKs7aMa|A-
zp2xjk$9mPT1~0v1>_9a#IDTyJ_~7tnLppdiF3XDh*Q8sPUK!4(WX#rOs&)YPU{ihB
zzgg#=Ykp3hM=#klW&J(tr)(INzA8PgUX_nan|eiN(W;Fv-8^;psNVIj_f&O)2e+|w
zis`^AM#|a9d|m0Bg0gW|W7XJO&`y0i`q{5FP^l{N%G9o^S^vmnw86{LqP#^>d(5HxNoT~q^H!qx>b$o>Cx)fczSx2
zv-*B_bNBVNeF%n$2FlH~VZ^H6e}^cN5oU*0vkHO&`VVnN3IQ+`YbANQHerWsl9kLxMw{{4+Wb~AXal>v62?`xOsr#6b=!^Vb`ZjC5HR=zp
ztFhy8U3*!H`nHl1la94fy06)v+Sj*l%1Qys
zYAu^RJSD#45dTN4>-5&ho}LwyS7fX)dKWU+{G2Nn+u8Zn^O&EfI-6NxV&9z5UXHhJ
zS#*EI$euUF-{h;WNf%yUp>}?9+I&@$K|k`PKRiS}y)w@>Gvlr}
zb-3n=pvU|_#T#p+ox)D%SL$vi(jw#3;h(N0+)j7qPoo>0F)Gf%Xox;^um5q}a)qmb
zO!d23Ci`VW8my7YuBYK8nS4gS+BeOytEpUb#t;23Funh!(yKnsk+S71u9l{*ozl0?
z?=#ky|1nqDabA}#ew|5+dp=luAJlN1Y%w@0E|{EuBkpg>z0SyBugS@-c({_f-47qY
z%TPi6eqDQ)3IA1Id(d-F?E#1F%3SJs{)OObCi5=@=g-vD@8C*7H^N<=J@NdiWsCnW
z-s36gz6e&zWY#N8#(%Q^$8ZulL0`fGYVYzRo{{lKaZMcEkMb@yZtY%pL03p1?z`}l
z3BxUfcfbw!OH)|q_3w;Z&iNeMZ?xiD%ETu40s
zF39V|eOjN$bI$9B{Z}8Gyw1g&hab-(bBB}tlDUt0R{eQa{drdXc~)Hv5RJ|z&RM#9
z)B1<-j9y0l`-qZzUANLMaqc=u8s4U7AF=t)XyRN517xf73*t_cAZL_Z^>1^4v~k=Q
zzsKEjg>$5OdCmgVq_
zPU7q1aI?PVe~u00?SKuAQ()ZsJc@3Hzk6NZ`T5-Ql)*Ka4ST*kfB71$6W%-pT>oHi
zoELEa=a|BL&iht~1nF1#{rUhm8<%6(ySPut2Es45UE=t4p-n)4n@q3kJ3q&Dtd${-
z&uuo~LT!!BXB>myYsODDIHw?Ungn^n@5BXJ=1o)RaXoQ$uq=lSjvnY1T>A(-4zEC&
zmtx${O&r`j_y%4Dw-@8r5=w}Z=#`hr`Frrx8B9L4kC4xD{T*W=!mSi(VBpxuFqky&
z8C@J3>r8$Y`IQ<={qL=Z0V|J*B-XzS5ePVzm2&L^>KAH=e4`-IzK0@emryR
zs^QvQO8k$wf6p+<@!?tL^~3(_k4;|Z@{vrcr_TlF>G!QncU{A?xdfS}J@`~Mp4BdR
z+K9S#%j(AUv#EU=H{77Vu<`oa;REm=Wp%4Ph`SU_*!3Q2zjfR}K8CrH^dFMw?8NU`
z5Bi-glg_T_&;93lhB>yM^?%D<^+#z;9_Qk_AMJd9eXp+ah5w%G?1|j-@Ox?M!gfn%
zM?GA7CE|~8+%CP&V!vGMcosQ8|Jrta=l_Z8oJHTmi=93XnYYM|7m9PZ+~L^C{q#D0
z5BitRg>s`kUb|H?{94loZB3uDKkiWas)lL6IK%%{_#EDVDrg4n>3bfjz4Z^{AdkH~
zQ=NAqt1EpUT<84~>5O13fi66UL9V|cZ6&cC7Wr8)jguJY%cepXNVrd#|^
z*?#l=qmWt6@6>5o?7W2hzapQv8ON3K8+gP2865BPOqu)bFSJjov=@f^y>6-`nq%h)
zTwmDv+&-lJ_n*&y>zQ_n%=i2*F`l-6pZJ~j{7$Xqcj+K(ujlUN_$|Um;l2v|e$~do
zEAW_e2Ib9^WHv_Ybaq?J@9rbidk2|r{Y2m4*U$f1ziSs7?&^-rclaGx&F@=2?tdfL
z1*SUQ<9gi9Z$h4rS~ugiaM!EcU$wLq7t6%D>t*^FpFkOW$NfB%a?`fke8)3F9UDv>
zXEV6R8{eei-ZAyWEa-H{NaXT-^Bmd!jz8mm3%IV=;b)E?B>hxm#^N?79r$HQC%;LA
zJt|XOAK~619Uat@U#9Ih-E}*4cRO*v2A&be5!y7yk9J>rjkIBtqpPjeGQjDQh17A9
zbBRPdR>^SZXh}479_JRB$T)R?e@hu^;@ab}9kR{UQaaf-D4$*KZQ3QqmW{HwKA%gD
zUn|+}61lCx5M;RJF2^Ue-#ABdY#1f;t=Te@{jygX?6xBj-9DEEd`oY#vyIGm6yUZ+
zCdYRC1X*T3<0jr=!ok^%=f0b~1Y6)5NQ7Hp32gJa=GYf+7RM&eW`ph|V_#4j_2(O
zY*%;5PG^|xG_LC_*~!~h3I4lzAO0)m_@}dn(PZ4o*y^igKa7I|UKZmnwjPq(VX+_f
zMb7KTIV+65%_UsZHI~1$r|m^)?69D-aApGy~g?{x`Mt(7U^}Yhj@VJ?^)dE;T=}}
z81~32YcKIOK$1+f4&Zvv^$6JrV_+-HwH~3qcEKZ(|NlZ6)I;uct1IJ`NExX+%LE-H
z!}L$sqg!AfZVeIChoLFYa4UYZE8#U6Y>s{BR(r{?E@rILK~}1xk^#MRLu}>^X%8C8
zGT8WkBoFo(a{VY1{JxPn;;an6pqd*1P0y+_z(C#zw=VV74?f
zJPrFze39yp*$2+-1t)4UE;yKa^ymF=(-xXGP_y1!<0|%;;JFmq7CWvj(Zo3@TNUU~C@!0{5F299s(=*}N8|vG%>TFkDo0V;2
z^4XVsb#RVL8P}WngfsSP#`up>{>LQS*cI!_cAhc3vCY}x+cVuT#(^>M1}gW?h&(CC{#o?dEu=Dv7x^Zs(Z*L`V66#nP+$c
z9XL1nnDln|IL3-k$sB{@Bh}i=rML#q`DeI5f8%BYeS_VG3ussD`2%tVcb}Va@hZmI9+I6gvfA|leG%rUv43oB
z_#C#m(Tt^eu3g))O$?Exq)GK#B@Z!XxSROd_@y}3$W;92x;~NTu?t@3tY-g;N08yU
z=QcPCxb|jD@u^&kn`p)vj#RGwXN>>NJuz9P*kgNR!|M;1Fuu=1UJT`G$9Tj%t0ui+
z4fCkJW8?G+1x@%kWteS0-AVn})K;Lcr>!`X8|C!L|k4Wu$#E
z*WAo;@cY-6La5v6-`Uje>*p4VFtg*Gy^a|A};F?9v&z%N^^a3vMs}5;L!f{-!MNV}oO!
zkn6F^CGsTq^jhJc<70`_k4Pqd_c}T-rhAET|8p{!Hp#YQVE^dF%Mo|$2eAX;Z|nxF
z;p@Xa{uA|Zv+;9|q5T;vKUpb~X7guhL|LbrXIhUyPMln4>dIy27pnOEeOcx?I`d3{
zIkp-}f#sAN&||teHhC+yvsDv$w=`U~S+6qwCY)z4)1bFuf3VooHy_&#@T|<&yZ8;N
z2Wf7CJVz(VvE}<=FE|$`_PWuvRp@ZD^%>7FwnggU2J4t4yQT{Zu&eI}K80Ar65r}O
zVW;Q3db8*F^eMvHQjOu9-vv){-UioC*hvfjUm9{84Z{eZ4P3+1KXBe35{w?|FOuLx|BZI)d&Z#c
zpcltzXfWeZ1HV-Z)jZ}i%~+N(D8E|_@60$<%#voqRbb@KA&lQJgPXkNZ(zo<#5w&1
zTuuI+Aq8H6Pw+blKXJ^v@23yqQWo>Cn(=SV<+uajP53js3RJz|XODxQ;QS(Z4gN)Z
zCo-mc@t#+IN!*?AwAbbMG%ur`rcSE=3HcnG{7t>~;sn#M=nFfDLl0
z=fMKtdfV55nyRjXH(&%9oy>I_2u}$kzG}p^xhj>&1s>`L(>7TG=
zEjLJ`>Ue2TJ(>Kwb8PB36uy_HHLcAxH+dQR1?j%yU5+_o)lhHl7o}NEe+jBdmB!qE
zK{fN~S3E7Q(|qOi^mEjIZ}Ti7XFW0-!WbA!_%nn*BTcK{)Z?(pdHXH81RVi=w|m*I
zA18bSwviD$seAyNjGzisnbH6c5*rEJ-%SGnHXd5(Gzhe
z)(+6vDvV3L?-Bir+vq2O)%&T_XQ^{@FO}k+kmfZZ^ly9VzAXOk4n0^eKNj0`HoBt+
zdGSxDdpSys0EmP{$OP_RESzFhz)4#85Qu>!SP47e0Fc&6S|@4gC5V$cbZ!FDI!Q~<
zOI)FVTvsL_*HsEtyg3mJ;Sdk#;+F^epweTd@(;sBrav-go2M&t_|=J+J*?9rh`6eI!ZTI9ekk&FEy
z0umqt(EDO^xtP3Kl2=R8wTuDewj@o<9dH1S@=2-yhy-*D&VoWHgJUAC0)hNmkzXrx
zxg;B^;DpGf!7vC?0RKyOK{*^}&t}3x2n&gabjX8!BCP{}JX(_n&62b!hQlIl_do?y
zi-d+kG$aFYLy6liA4){pqi+XfbclpRNP|s4`VOQI!#^wmGJrH;qzOABOc_Er>=Wq}
z4aDh`3Wr2G=K*PF8VQMz0r@~!SM-W-Ksb;t0(lWRPy~m7wB3SX5TpR%-Le4L-N>`sF_G?p
z5C!PkeI@LG18`KNM*u_uVLb@zkqgBlk^T??36KtXun#Il_(_tUVGsvtkPW+_9FB|h
z3W8`z26X9#F1^a&m`LwHK$qU+*PHx$qf2je=}lM^VNszF3&n64P6|saAQ;G}PXUxd
zl}KOw`$hmV`tB3y$8oj(2aq`wnM08|jP%1uKaBLlkT(n+h8>2JBEv%<
z2Ic_ih8MvhI3W^?%vfZ`A~QA*_Cck{2!9BML68F3unWrJxX8#Lhz4{Ug>IubAI15o
zI7ot(B5~v!w+Z$@1yco?B4dy@hWy5m-&pjC$BjQJGLE?8qJXsHNIPx^9Dt)D;|Uv&
z{u2VAgf1N66FHwq`iVI}+=;}USSd0|APfdU3gkhd$mCREiVw&;aSo6!k#va%fIOxk
zdrBmr>r`Y+%@COu4$(mRqzFg={FCwlJ(7+HKe0f(=`n!Z>F72cxzmw59l0}*I|I2h
z5+M@`pcJY^uJ8xsUV+>zkb4DkuP6cJCL=c)xyi`6G8AGVRahDT_|J@objX8!P|45x
z9LRwpAl+5Q@0dsmd8ZI3h4WbgVGsvtunG1+1yqa7E&$TcJ_IL3t`3GlkOJAT3(A48
zIfTuL1me#j{v7<~;y0K0bBRBf`11&#M_%)ifbjX`ks1UM5DV#m&Z#?KA0Q|7s0dwG
zSwNfx#95FG8ITKwPy&bHq{zZR2!|LTkA=uHJHReP-a_OpMBc*VB8&VXR3weKY0;1X
zsUnLjfbb=RF9{Y|itbAjAq~)fX)%<+5jZK59thz;e(A(XCr)|+?16Hqf@+a#f`ELk
zA)jkfAq(;WdDkHC8suGryk*E+hP-9SyEYQYI|G(y0P&Yo*5$;#E>Gn8Fd$84DOA95
zhIjrD3ek`RPrCIfKwj&Sy?z&zLIoTbxy2tMAOX?={cj=vTgd;GN;n~s
zgAO@iK%5+8cqkXyfKIoCLYYV|adNW(9dnVDi>%zEBDW*wcI23y>hB=V
z9mKhVICty;WZY2&)tE4YAOi5avry!&KnRC;khiWbVj^lOhj90{T6W2H8+0@*rs+3W8`z26LVZ#XxwWKSTh!6yjHS2#$+9
z3_D4;lXN>1fV_8-Zf7Zw?h(>G5)4TqyNLH_0F;Y7hWy8f_gEp6i9Al)$4Og+oT8JM
zcmm-tAmhnuk*7%eR0PBV`aMPYo+9m2q8bgaUGQ6Mr}HcN4ao{C1Py
zZqn>70c7t!Ch|-Ggux(4gfz&80@wrPPzBW@dyu;axqHxc54!F_hdudl04m{x$g{}X
z8wtqTn*qq$i>$rK+Ka5c$l8mneL)Zbv5+kCyg(p?Lktk_dE)IqEb;>BN(!L_I4>dW
zPx!sKL*&l~L`u=+B?pl9r9&bwrvu@yaQ+JEULoBpqN0lo6+lIA!rL2UbEJ6ajgZ9ToWtI{zgC20;Z=>&W_RDwK+h`jB9aEJ$Vf17;X2?XN2Qv$?!=cLHt5QqWf9L|9vk#{+N
zHwj4hUMy6Lyifk`R{_TrK@b6iRU|_OKolVF?<-*k>;uaC_Y)!?
z1V9)Jf<#DzO+eU(gndZ-4~hRF@js-TmH1T>zmoWs#IMW*;#cm1QmBGzk$(h1J`}@Y
zI41H>^!aB9L_s{vft8R4MQ{KrMLrTph7us{M<+#&5a$SSJ|^AAr2Ck3ALIA2@k6(d
z(e0Bo$c6&I|I-90hboaOboq?)&r%@^%sIM!Rt85zK6gL}L_sVhLk8qRp~x4}fZrFS
z{lc7=h#U=sbU=@P5&o}AkuOQ}C277S&X>gb@-Unf`6>j6_Z8>I!hyJ7#{+tNL)vdC
z^S3!7$0Gnek0a+eVaJhi9R0p?0O`IX&3EMa9kRY7?7O2P-v>bqqycHZKLE!?ejv{u
zqJX?kWI_Ryiu{O-AIn63BHd4sfSjKbAQg!BGx?q5_~Z#}oXLRPQ`u0BovsLwe;WVO
z6+k}K$f@3hO)3;JAQ$@;e(ne;WLF?$*B%qz_h*-oP3)l%419}S#|gU(h;raM&3A_b
zAOv>6K2d%-qOesc|6NcD6>wba4~jMj#f^ppNQEpQT?67ZI3lW{Kq$mO3S>b(6vH7n
zDyoq`5T_Aw0yqvx7j;oM5Y||r1jIpl8zzz^^IgX-b@?hvAs0W}8Gc
zM_zOEYMub8kOk!1yco*h2%HoZ6bO+(yr6X01jTR&j*Ds$2$4X(Es)a!IV}oAU5pMF
z?}AdO0OGdvhfpBjmI*-omc(yKzAcNP42}T#1_weo#E5Fe@g)JUNz|p05CB_LWdAszV42f@D|;1+WK@(*ZeQ0f6j|=+Fth
zuy?3Vr9fEc3{m0!5DLddT^0i=P$sGix^>~WOO>dul%Z=WQ~>d~l~qJAghLGM64ebo
zyP;>deBiu$x~LvO5CO4(emzLvgY=R3N2Wp+aNd)+J#$3$3Wa@eNL24^QBkDrGYCpV
z^-ToQ_ajX-=l#QhG7JcSG*JUnL=6(i6LmRh2j_|!5(VfVQzmNYN>RgzJ1k7p@B}z4
ziZQVoiJXxqM8$=O8XYZa41Qzr8+%mLI0qnWLV>6R;!i9THHmnWkUOcGg>C4Y7$j;6
z=Ti=dni>c@fa7WTqLMgIDibw5N7RftP%Mfup-RpabtP%9#GM%id7`e0hDuQ>lqH4q
zDTkp7PKcW2fFL05tSE?uW1?m!!W_teY{-K>Z~)Msv7Wj*4w4{M)SO5_@43{&T*@(*
zJm(@~ZaU<`E+FmP3OFHZo&$m)45AfF`||Qh|0)-O;7}+&8QN!T!4C59t|f%U3Wy(^`yH#9`XS_GfA5n2iZ^x
z_}vf=l;eiuqHYWV^0|?;D~P*-uoaYfMG8>n706jZ+!dr-aZJ=of1u1OV}Nuk(R(F%
z-$dG*@V{vgq(LqacGF=|s|Z^~*ec|%B5YLwAafOAHwQx`BmjBcybs9hW@KiOCMyxL
zp%4xL{;QLLyEX(eL}kZAxu|vMyAB!agMjcH^u9F$;$RMB0s7oZ
z*sX`*xTp;QfXofCfLz9WYD2!L+XP5|+b&VL=*-wp-HzYwxEl$-gYy&bziKg?UZ5rCO9JM{$f!(h`Zyc
zs0Rj#dN5PeLkVz5R3Umi91T^XcJ34P2x%W7&s}LiUXPORqZOha!+oqo)Z;0liUe{+
zJrM0EmEi
zNP}D`f^s+}>ZJgPfOtrQTquHaI40`l0EmEiNP}D`f^s+}>XiV9fOtrQTquHaI40^q
z07O7Mq(Lqe0d@XrA{-I*S~z5hdOZTD_p$;}e+hsLQ3vBi{WTi6Uggv!V-EEub@`S*
zP&aQ;hi@Ghbtne%pi=N}c_4G0I@o^du|KnUJ0CfGh
z1jCV=}j?$@~AAm^K7qQ0fBj+6d4e#fbk?~walwW#l@
zhaZsj1Lr^Ff;m3`S5@ehP(H
zm;=Rtyq}5pa|%?6IvEF~KNSw7`^6uMM4e86OhAX~5GWJnCS7fsXbFH6U~^0<6}F0p
zJki=8N<=%7AP1^MJNejE1LkHMhCsIHMsaXlbU?o7i(=rY=*IXrJ|enFEbM@jq5~75
z2smz<3Wr5EO9A3FpCdYm@SsXK2Gycl1VIEKqeU`gKrR$Q36Q=8aW8g22t+|VAnW3l
zkOxI@04m{x=#~Kx27@3G(jXfOU=Nf-6;z834uS}Xg=ENpTquMRI1I-`w{k!TL;>~G
zY9&zSRtH31LRl{*EF=u5r;s$rh630F6;Lg@btuF_GLTPe@@ZWNB|tu{$)}A2LV$eQ
z#KRm|33*Ti2cQy8h;ADIVK4|1Aq}#j0QNvRR6(`q&>)C_SV)Ep$b~{Efx~c2bUO!x
zKorEo99Ri?un#Ilw-*S56QVl=1O6TG55qr<^RP&u9>UUK6YPNs;Qs0857a})Xh;C=
zk&aoA55-UhN8qGr#t*tvIK;p{s1V(m^qtYAa}gX69gZI1qz@_eV?$g>Z5_Q{75I3l_)a{GqCAV`61D1cHReLrOP3kA~mLw>)NK>B{gP$@cEfb`LE
zKv;Ah?1MvaLUe!9_9tzB()Le1LQGq
z2av}=@)&qj^dQPGC=!q{C=Ifq07{_(j*GtBA3`A(k|7I_n3HAVa4|6~`!~kiB
zk#<-P>;TdaI|Ro?4-W!#7><6!(SLXbpvQ3Z7+wO0;e_Z|WW}xo(#IZvD$yfi;Hc=4
z{($_E$RCO9k!64mBdbM^3WWs75*x6CHrOiBUlO
ziF05j5O-n`9DqtVA$n2(pwpyHunS57xszjobd&c$x#&dXByygJoGFxJN-!LQYSB|k
zH!Tq|MJGi8@sp~6u<4;d8DOz
zik@8zrJ}DsBzg|#bE)Hbq?=a>8R6GPh|UNSy&Qd(6X!bOTptA|Mc;5p^o<#!S0uv`(Km&N
zUKK9-X5wU3ie8QXYV=r5*qRd2YZGA(92T9OB)otMd7{@Rh`uEZ%0%Z3f_%}plJBj^
z-Vg#DGmg_6GNDNHZShbcn(>>yJqD1!F%-~uBYAEl9pgE@aTn|Z@Ae~bQuO`gcYipMCMM??W_>JD;$oCezZjNW5jv1%}u%~uFDI67DEI_`+#4C=2BuE9~7UuwQi_1hmO}wW=ArcZG3rgV#koRt6
z?2ZA#cBeo(WQl$T9iAx{&3v1Od|c!`mjpYYLiAqBzBdh4LV@UgMWUaNggn?IdOvdZ
z7m9u%6ws+83P@8zotBWUq+Il$Qs4+27yV*5P+xzhE=&D^uu}3ZCEiP6!0}7RM88bD
zmy!4KJ}85uqF)&V==x>Sh
z?J?2EH;Mi(8cvG-9$kOH{|E9of!_&qK7qa`@;vSus>Jd;ES7&Bl#10L5_X8y
zFc8v#utw3a3y9BLixp5N)U0R9~+#0m?CXh;IiJK}cCgnZa17UKo06M1%aKrkdh
zDv(cS;&dhsV+Jcc8;*;0SsWxoCJ=rZ;g?m2)g=-VARRV|)insB0R6k7f7f!cBK(1{
zZov=@N5tyBQmh`x?2!aV#fn5mf&)+`)&S~#0CET9i8YYpfyHoGEc&?et;ulLwr{8?k~k<&mz)m`4>MjY?<9tUvzw)glV6{^1X
z9yexRj%MEDKxt@=^d2{rK+C6R3;Zdyt>?w~FZ3K!$crtn&V0`z#MN^5-PnUlEuS)IZDK5E-vp?5;B)qp)pqQmvK%)Rzh!dmSurOQXEAbz5tb%%xWnd>$8wHm5jRS@laI-1AifKb(M|f3
zcQ1Tq6LT&pdJ{HDCd)V&SWk90&d-)zU&C|5rV`iawg5erP=X7~`HaNh&ut2Ng;Js=
z=zDfeEg(-55=z;Y;m)yjHf3Lg-%^>!U!&Jz=w5d;iI~Q3xt)5bE!U&}wYL7|8f9?J
zu9X4Oga2nFEJl70`x^dUxVfet%|_bt%oU$(Ym*_@llL;DgnDD7;a<)85^^)wBNVMn
zIX!oRx#}kFWNIKDcLG|OxS?`Ja{O5mA_zP8YV@S+2G8B*(d_JeecH^D>(Fn3egDkn
zugUv*&W#?PdLAi5WukqYLG6Ur)rF^~uB9yIK2Ilwsc~~hq~V%7cRn>Y0lnk@qcmsk
zm2>aC-wrubTXWFE^8{Qgu^eAZDHrn$7~ka_XK-Zh+)4cP)PE@N4ld`+Jd@@g>jYPG
zzL@{!?)g0_q4bZI5I2-~=1IKPzKZ7B-iS2wY+pvJuz;hh?K(CgR};tF8M8R@v;)_2
z)r}6G`{hF7gi`NDdn2KKsp`u)yIe!b-;>)C@-au|sab-1rtaq9vmDLN_?WAjS?_!a
z=O#@&F|W5v*bN;_xs5z?-=4XLGVnL|#AN<1MqhIu&I8}Qe?E;&{h3-wN7A)21=lX0Cu~`7+SXNHF)Zc>?Eh4QAnE?(SK{3YAgk$S}3J%+_=X
z_lbELx{<$;vyArKJP)RAH>L1gwNUaq*C&JL<=1s=^sb+C{Sr>5r(pkcKPyjoj?Vr|
zpMp+=gmM2qNsPb9>vA=p&$9TgqEoq)pYm4?R72HB1*nTuW7R|js-~)$YOaD*3w5z-
z$%nRDsY}$QDnzwbZB$!+>D#IHs)Gts9aSgQS%s_1R2S7%MW}A7yXv7LRZrE6Poqbv
zKC(&mRsB@7bX5J-05wnzQkSd2YKU}FF|=AWYN#3}wQ9JERU_0$>8wVnI5k?0QDarS
z8mGpq2`Yi7_RR)U`E>qX3>s6+7Q8%a?)e1SOR;ruSDs{8U
zQmfS(wN_=Tb!xr3Mdhel=?QgJx2arpJKsFLLv2!bs=L%?m8b4jThu)&Uu{+Qs%@%3
z-KVyz`_&HhfO=3pqzcu;YNvWc?NX1b$JFDhNIfAD>PdN1J*A4()AE*-%NuI9dPeQx
zy~SVDb84^Jr=I6)moKOi^(XbB`m-ukFR7Q+E9!uHRlTNOS7qui>Y)0oDpzl)H`QC}
zkouc?TfL(Wt9R9V>U~wA{;obyAF4|A5A{#=kvgJ2R-dR(Rh9ZoeXhPxN7cX7m+C8Z
zOuDJB)i>%}bzHiu@6`9|2X#XIsD4sEtCQ-K^iaR3)2dq4D7UKR2K$81+G>g9OQH_#
zER*Fn04zF4y(w{E2`(U;rcS&MR(N^x|{BG67kPLL?x$Lymg
z>B%}#PtjBLG@YcU>lykAovg2vzIvvI{k2%lQiJVR=_l^>zAsovCloH|iC7rM^k8(l_fYy;`r)Yjw6>
zr`PLSbdJ7NZ_u}CzQL_G>N}*r-lXr80s1bzS?B4y^%i}P4Al90tG-un(*-g}-zQ(`
z?ec=YU+>Tl=m+&fx==r?cj`yZ|#ogXYCsC~{Ym5OKV}nGqPSV*HmL;L$LazZ#LV6(~y*F|)ozOy0Pe`Me
z5Nc=%EhLoxn|W{VR)+k)-}k}Knt3y`GjHC!_vX!;a^NmHB(Xhlwe?H_e*Y_R9sC^E
z4T&2QH|aSi@w3FuiCYr4CT>gIp7=Suk9SAn&ct1byA$^$?zM&z_a%Oj_+{c()~ShK
zCw`OoZQ^%{-&@0pKP2vl_hdFE{*?H0;xCE6T3=5*ka#fhP~zdlBZ)^7k0l;YJdt=Z
z@wdcNiN7bFPCS!%Ht}5I`NRu}ezT
zH^L7J&$MURv+X(7;qW5HT$t%b;dcK{_RjV$_OA9kdpCP`dk=e0y9s_rb}zfx-rHVa
z?_=+4?`MC+-rrtmx7ds9R{H=uX{W5Ewr{8Hz;3hK?Tp=FciIQq2U$nh2iu3(hgxsL
zFP|;8yX>r;v-5Cod6^yBOYFn#rS=i_GW$sTDEnx8xxK?KA8%?X&E&VeWj&KF2=SKF>ZMewO$``y%)i
zuZ!(V>`U!!_GR|R?2p@*+n=yMX@AQ8wEY?Tv-aog&)Z+Hzi3}!e+k|J__BSa{T2JG
z_SfvMgDtkj{)YWc`&;(6?eEy%wZCV7-~NI9L;FYekL{n>SJ^)Wo90aGEbANKi#Z);
ztuyQ?d%JzLeT{vseVu*1eS>|YeUtq&`)2zV`&Rol`*!>1_8s<}_FeYf_C5B!_I>s*
z>|ffyvVU#=#{RASJNx(cAME?>KiYq?|7`!o{;U0f{h<92+>L+Oegs|~eAIr-e%yY-
zeiB}F`J4Tewc7r>{j~jz{jB|*{k;8x{SW&^`z8Bj`xX0D`=9o|?APqq?KiCB>^JSV
z?0?&DTklv;+yAluYrkW^Yrki|4?l!kV_nJ;41TkXF~*sIANrKcWr}&MmesL(*1#Is
z3^tR^Vzb#CHka+hc4oVoy+F4y;w8bn=N4buzlHn
z>?3S{wve^3MXZ$_z>+M*e3oVbYh&#!!#Y?eJCGg34rYh2L)l?$G3#PkmScHVU?E$=
z4rfc*5o{Sd5`F{uXttcKV8^hP>{zynt!Bru<5{=W!+O{W>_qDXb`m?8ox;|zURGp%
ztlv734X_eh3-5cu%XMr$8?;_zr?L%fh;3xUY=n)n)7TgrXA^9aZDO0*7Pgh0&dy+G
zS|_u!*xBqHb}l;)R>9A*3)qG1BI_mgQFbxAgk8$E>0fPTA7_`dPq0t2Pq9z4&#=$3
z&#}+5FR(ANE7+IVm)Vu>`rzUyPMs^?q&C}U$9@Y
zU$I}a->~1Z-?86YFR(wb``I7apV*(-U)W#S1METe5PO(C!X9OhvB%jH>`C@F_7wX&
zdzwANo@LLm=h+MFAM8c;5__4w!d_+nWdCBXvDeuf>`nF-`!{=={fGURy~EyR@3Hsc
z_r+^?g4>*N&INb4x9~;0l^?*9JjH#U<^gZx?K}g&Z`{cbkOYnC{xAL-f1SU<-{f!cfAhED1#OrA*JAt~c$w;5@a(+D-?zRgEKy^5;J-dpB&@~o
z^3^F;uhlG=;6eyTNZ|@)3F{VXCcK&QoOPJhXl)msb+xFqE*5p7UNneCF+;~`RTxVSmo<43}V_j?AZQUjI5POOyF<ZCfY?tbcjxIp!IF={+w^!0qcx9aS*&Nc$qj@9AbS;94ZbIi$#~n
z!Ye^vf!DWlB5z$H3UHzE)7BTQE37YBS6bf@p>?BJA`TZz#SvneI8q!Xjuy+s3UQ2B
zDUKDZ#A|cqE{3}pXe6@q9oRebz;336sL*}Vn}Qh!(v2?
ziqpiH7#9;_Qfv~N#TKzuoG#7~XNt4L+2R~=t~gJeFD?)lii^ZY#l_+hajDoQE)yRU
z9~YO4Pl!*7Pl->9&xp^8&xy~AFNiOSE5w(?m&KLhE8?r-YvSwT8{(VdTjJZ|JL0?I
zd*b`n&Ef~*hvG-#$KogAD)CcM7E@xoxLRBzt`*mb>%|S?Msbt)nYdZpB5oD8iQC1`
z#U0{KahJGT+#~K4_laMKUy5IeUyI*}--_Rf--|zp`^6u{pTwWVU&LR<1L8sPka*a7
zT|6Ql6^~hOSZ|8Q#S`L5@i+05_`7&oJR_bJ&xz;73*sM^wC)ivikHO8;uZ0#_$PRM
zAGdPiU)JT~HR}QELF*ywVez{4i1n!Tq&SIy_$vQbF?-ZQSS>hb-EOm}>mN`c{M>$73%bgX@G0sZoSZ9^9
z+BwcS-syIFoD-ZAos*oCol~4OPOnpR`ka1ez$rOvopsK7XV5v-+29O08=YZi#2Ix?
zbH`Xb^ovWQ|oNJxyoa>z%oEx2+oS!*2JGVHu
zI=4BuJ3n{saPD;Oa_)BSaqe~QbAI9c()pG1Yv(u4Z=K&czjyxN-0%F+`IGZ!=P%A*
zod=u;orj!;=Jno
z)A^V4n)ABzhV!QLmh*4tZRbDEf1P)ncb)f~_qEerCZsKyC3bXWSeZ48QCE_<$>}bd9XZ09x4x$i)EM0%ACy0f(+#ndAM9EkC4mc
zk@6^cv|KJ%$YbP6d8}L|SIgt%@v>X?$P?s=@+5h(JVmaNy|O6#WWOAcCAn6vlk4T6
zJXLOxLvo`WmLqaho+iiSxSWuaa+BOFx5%yXba{q6Q=TQymgmTG<$3aad4ar8UL-#%
zFP4|cOXW6snf#dixV&6`LVi+yN`6{?Mt)X)PJUi~L4HwQA-^QQEU%PbkzbWxlV6wL
zkl&QwlHZo!k>8cyli!y=kUx|^l0TL|kypu|%Cel2+vU~r8hNd}PF^o>kT=Sk^I
z@)mikyiMLNe=hHkcgnlu-SQrJue?wGLjF?zO8#2@M*dd*PX1p0LEbO_DE}n?EdL_^
zDj$#!%7^5`@)7x{d`vzrpO8<=zsaZM-{sTt8TqVyPChSRkpGY`%9rHJ@)h~2{HOev
zd`-SC-;i(0x8%R&+wwp1zw#aVu6$3v?^Pb=^o`C?Jjp$xW~9F-DBNV?rQfq_jtG4?Qu_VPjpXmPj*jn*SNiI(d~2l
z-2u1cu65VB>)k>3RCj|rg2!yO+3^y4&2#+>f~*cQ1E8;eOKnl>2G-Gwx^I&$*v>zue{~;lA9NpbA9f#cA9WvdA9tT{pLGA`KIQ)1
zecFA-eb#->ecpY+{fGOa`;z;z`-=Oj`%m{@?rZMr?i=o#?pyA^-M8KUxc_zEao=^{
zbKh5%s!<7LE2g*-%286eN-0m(sybD#8dRg2p=PRCYPOoA=Bl04&T1F6tD2{FQ@g7@
z)SjwI%~yM=X0^9kp!QMws{PbQ)c$IrYEg?+t2#g>RZ96Ptpe4i+Eqq%s7`gDI!GO?
z4pE1y!_;EcrLrog@~WUhwL~4RmZ~GvGIgXnN*%41s}<@PwNf3cR;ktMICZ@0Rz2zj
zb)q^+ovcn#YgDf)sy@}P22@F{RqNDxHKRff6I$vF&E>st(kE)B+CF)YOOZhu#rqp(IwYo-KtFBYms~gmf>L&Fwb+fuf-KuU=x2vD4JJg-(E_JuMN8PLLQ@>EZ
zRKHTcR=-idRlifeSAS6Vt3RqgsXwc~sK2TQ)Pw3F^{{$GJ*pm4kE)q4$Iqc_8w>CN(Hdvmk9hlg3%wR^k=N=S;3d73=X+@{@Y=j~FXMH1o!)`o
zLEgdMA>N_hVcue|%gcH>FYgt+&|Bgi?k)9>@RoT;dPjLjd&|8Q-Z9=v?^th@x7s_-
zJKpQ|db|_76TOqXlf6^CHD0e*^!mJhZ@?>gYrS>edT-D>)!X0=c^kc9Z^Rq*PV>gR
zac{z#^fq~$y)E8W?{x1B?@aG3?`-cJ?_BRZ?|kn9)ipLaysp?kIZ^UVbY$JgaA||n
zwXxVgHZm-_`o>C|N=_FgPYw?zQ@Olj!h8Y4LehkOG75c!{dOIu3YeZMbeVhzO+JJH
z=66xKt`35`D9ll4+DR3%^nI4T&(inVcHT8Ky1uB)--(gok#X~3n8XUhWGBH`ahS{!
zj17cI)1g$D#D>CTi1}IiK1<(c&G$(^P4#3=J;@-29S`zRhzN!q^nC|?-$CDXQ2QO!
zeh0PRLG5=$?dQai#f^OfMU74}Rq&mzf*C3U2YKp$lIW3)U_;NOAEfcSjue(p`7B$S
zpvpQjUao&|tbcOj+M&`Gl^YnDDE9Z4h9`7c7&C)9bQQap>cBeFq3KF8)zKyL{YBsq
zDvmuXBx%Tqk_Dtps*v(|zOOijh@?Va=1(_YuW<^*Xa&P)s!+wFfIKG(m3ERob`e7A
zqK_0Vq^RpDVx?4+PI#pVFB;JTjwG;Dluj&`BNodMNpn
zfUxG1RA<^-5*yPcRazSU@qMwRO3PG8ee;v{lGc>a)wHHuQ?fJNnyQOc)tZt^%=omX
zYOA$IrF~!Lo0_AfFu)k_Y_gRQ_B*`8tJoQg{S?vNCwlu4PBe~w7dt%aUXaTmWl(=4
zgNS0NN0Ou
z7cZTri%oSP5C$Cx5sdSm#@^9uhf^P`jSDi9czIYkj6Wr
z+I^Iy02v^iL52v3AyR2l8wDJiLYg#Onj|i5BrX}?t~3miP89~EE&@^)NQ6`&=;BAO9~s$T7lzlFdIQwTKs-b#K<%6iU{2K7(+2kkX^U2~f@o0THZ?2-Ze}mZSU}<)h(|DYvJ7y}fOMi4$zvf;
z-=hVWDxd`iq3I&?Y>qj(&Q(++|k6AM|p$A>8j4@7I
zyI5Ie9G@&TKShLUBf_*rO%Y+*3}O9LDom{0f@2uu#IdGg@7Nt>FS0h;X_(rn_I5(N
zo!GP8v<6IwQ+%q6R6WiONk7#kRvBc?EDT^Yb+!|F?Sx)CjeEv42$aaxYA%MbwXZl{
za#kTF5b<=uUR9jrt2Gac)i|ZBCRSc;SedWZi-5Y-(-?Yne45FVDVZnC^Eef!P|+t-
zq~LwD!=NYB;|x(ELzKuw=|uMobqvK17?AQKZ56Qq3KHZ~$3yJ6A1Qzg5hhdcj<1f6
zINnSf;&?M|?(xxdV_2quCT*%fR(gS~^8#As$pDub$so+Zx#mGb%TzY)^;GL8`cfY|
z3Zlofht)^sRY+YT5%58#SCjEbp^>Ww0;b^@wvl|bkzBQrJhjn;OLPhgRF3Es7P3wc
zna?Mf&Z`qDb04DDNxa%g*ms%{0#y{S6F?H#3A9r1bD2;S(elueR_MZ}3t3{`ESYy%
z!y;f(6N^N;sms9d9im={n7#{9E_9)7;FF!3%Hss)`&55dq*JM#E@VYNnRbfE#ZIxx
zs;NR}mnv4*v?dzQPNsO#Snx*eqry!Fq_~5K&kIm_sj5bTe4AYao1$ovbR;q^h$J+_
z15qlmR3!$+w6O$$G#G;!5iEhaVwIDk5?xe6ldF7!nUF*3<=^WQYp(RF%$Aw#G&l$rxjOkxEZ>
z(4e&ulfd8^%AqENM}Dd4gXhKAUlmX8HmHWp~zQ6OJL0euf3ZX{M&63ZO*Ax9L>
zk@!W6r!=-7ph=Mo+EBcLe1?~_T-2@^EsjmJu3e)i5_@fH3jPgp_!kXv3Rp|5Ee)@0
zO>tNOuN(KqO)xPwvY}LO;#CTmK~9BCmq%K*C)qk+3vXR)c-Kw)Aer*hVqKMFsgSzh
zr`WpI@qVQx*Kbck6qyJ~2wg!<5}=g?MU3W!khr0
z$_BNp3=%618dkzN-49IxXRxZpV7{gKj|9PwI2xD4V0UJ*_FNV}HY7`Dyi*CaNHG1N
z)3gF%wO|k#T5WK!?^HGjgl;$$48ig7iQ?dpDW|Hicj^ul13#LfT$;ouKqCv3p3(TQ
zw4g9e<3f{HAx*5CCQ7H<_y#>*Vgp(=x)Z6+E_Z0--~du`C@%D99Kot1U_;PNF;vyx
zsdP>ZRX7S3EHrks&MnaTwvf(?q1N&7r0)$GGuXs&7(nsqjQUFB)jYpFX8>0>YhoU@
zT`d9#P=>*xlA%$F1D$ao8wc_RFoX?qApE{3Mixxvsm_8Jj!`aX7sD0G!ODwf?SP~J^dD9orK1d{ayd2(
zDDn}ravCAN9x;4fH!{u8G{cH37nqvp`+`wxpe~~@vI;QYM|TaFE0iAb4lOte0ZA>Y
zw^SjTc+htZ0z&yX@xuy^P$ta+K3+be8(Mkb>lt^1hdn2)x(+I7cD2%V1
z(C!LpOhQ}(gEI~h2+5)iiNIlo)(n|68;PVgK)(yDkTQIv6mde3LAeUheGIDs8l$!-
zM0pEv`JMD($$uIN&{$lMf;=BHdeZ#GoZ7LqgX8P9o`n4X!zg~HAjgQ^#8`}D+A?CS
z$}zBp!s0lo!blLOkV#u0leW+nvN7Eiu-!K4a6FDuIuzrk32%G{ZR1B1Wjl>TJB?&J
z^%}K*s?bifjTaU~D29p;UWXOvH&`*cOn8LSmzKV#5p}-)Ihvr#
zXl8D&n2ha_d#R8iqYo@MFa*V<(VKkINTn9MB$?~J-lHHXoitL)C(WX8a=NTen}l%5
zkcvFh=w*P($t>z1G>bA=44RREkYp-DBb1>L%0%hJ85tr{f$|HKAI%e}BS7|uhy@~I
zn6f8xZe9i7ex!t|MlPIEzk&Icej
zYKnl|Mgh8wzzI#ZbSeW*=>Dx^hGYS5serrK5EOjGXbS}BWCFJu4KZ5k$pAN#AdD)usUMOfI3(*Nq>bc|mg8Z@XQu;-HvE(DFGh5qh~?wd9nuzNNbAIqwlYInFNSm)
zA|$_7NG{Qk9C0D3i;x_0A*qcJxdsexB09JIyuD6Bk+?DZnPm3()ubScW+4h6CqjK
zA?;Iyv_BE%Gq76I2OSby2JNwmPoLNg?EvuhpnRlrJ|8#ga-ultOd>PPrf^!{8g@vm
zB{(*Uw6hY@Iw7PTmXOv9A?-bcWJ8CzGzOPMBs!!qX$K{w^+1UGO0X|sbPR+UdvyJv
zgn#SwF02h$^AWApmH8XPh&B;IvS&luObE%Y4QW#$B>Og`&4rNc+>l)1A)U4f(SHiM
zoP-&jSpXAtX_XMtDV&h}vmyG{QX#J5A*Ax;&knadf$G71b=bFz+NX3nsnHfGUGgXf=#GZ^NI8>F
zFTn1@q(bfDem|6>$t2pN#B~|;gT6bX5gWa;TOm>1OnKv>mLD6
zA*`XsO2hqIsVbv6pp@ZG2+f=cq3ITcQGSG;AseKd;euLmjxFGD0gU8G(b23_Hm6Q6
zjg7RLwYda?PPL%tLqytfh$7OT?63SEe#Kq)@ros3@}DW
z*7m^?z7%B{Aj+!O(1sGYG|hJn1V-62s+CfkGh;PV(u`{LQK48Hlr$^e5T(tY)*2$i@sH$<+d7-cxx?FxNUpk#{^r7_)Som4t)vN!}l1Eoi>j3VCf$V3!(Mu#SKC3557
zu!(pZCx<2mM~AjTqB}6SX>h<$Ru_wk)WcDUv5_rN6?H{$Q=qpOzVV9VfXm?c28bJU
zqQQyKiQpMkT%+z!Lzoiluo;3HE2vXKt-(Jkix@^_D?Oovdg=~=Gpc=}lv(kPQQFK{
z?5&+apRiPCI^RxX2w|rlj^7QWWx>&*|;O4MU^5?>n<3kM|seoVR{%tEzufwiWsW3
zgX<>8N&|3Y0y~guQzN6L;l9bCq0$8PDP2D?Hdq{9H&mLIOqvW8y5H8-<;9rU11Bz2
zY8q>(Hez6}w6T)sVU<{}+OmPQ;~GeqHo&auIK&I&;s9(%K>y6AfRX{~PMZYj(C2{1
z!2E7B5R6!qz(>ahHnmT@8Vo
zMF*#wz{NQm5*jM8_!JHqb!IMy2u_H4a@G~lRH~y5IIHTYDMOPzfb}@-S2OesuFIhn
z0FgRl4nzeM&grIpoYi%dECq81P6+g<
zLZC+#0zIk_=uw40k17OuR3Xr#3V|L~2=u5zphpz~J*s`<(Wv%SMzs%W(#OrvFoPmA
zsyba8WjLyR)lr2{qfwmBp&6z}G}92#Oh<`kl`=>(4I<5A8JcDq
zG&Iu@L$gW&qM3$?W;%*A(=nu3r8v^elt!AFDALRnuhL9|ahhp3qM7*$31f;?qj8$)
zEDf6lB5ZNcM1y{MfN|ZH!i6KixbjM&#{=N#J2c5sxG;ruT$!cXk}jxZBS`COjgVq|
z+|vsriogUp;->Yw1o
zc>o>?$@X~m45S%A|(qCwa`-DvFOdRU)~
zX%h>8m{8YXZ}$0Tb5hwcq;E
z;6yFL8%jf)2F-2{7#a9=Tdv@i#wXwu%0y|vEsl+iY}SiD(_$uU12byv#F231r%UJf
zaQq7bFa%>|I^gUbBpO4RK5K6`n@uo3N)#%!Zps0CyrfeIpZSXlKGq
zrTti97o1Il_9sWN_`t|!>|tjrjVY6(1Jq}9jKCq=u6py0S>77cQJa_0U1R&sn%-xV
zHY4i5j-Pax>;{uvT`L${Iuo{=%-K5-!z9j(D*V7la4JXJuO?r2(|}ZA#sC#$cFacG
z-IN7#RzHN3{c!+Wpkj!l8W>*Jx2Au6seePQiS`wThDIi~z;5_paUIalgp;FYfZO28
zl!3>PPukGI0
z{kQ;A`*>vnU}_)z!2nbHcyihg(ESb}(k(#uJHUhou}FaKcSy(K3DErxaK}tsvS_=u
z6*+yN(JWTL`iK(9;;6tgDvrpgM5D1)W1q}Gb2OGxkIs|I7pF~Xe_B@&G2i;7@SLR>JSPssO1APNQn
z6~Q2w1S2Ynt2*jEE$k3#TnnCaBau|LF_MvpK9T=5YROFWATk8UL?$W`iOjSTMr0@@
zE;3P4Ok^l&y2wzXwgTYvwQp7p^$`I44+}5MOSi`Xb3m1Hg0@&rTdbI$>WJmwao4Ce
zb0{`F!?Z*vWMgH6SS_&psOEq@7zYpygD=c?T3#c3Ve&KpI1lxHiB7MtZiqz1z()#_
z4|y{p*Cx^vQf5XesDexdCaF@Q!GP6l{37y=nri1%Dj^rDNu6a<$Km4DkUnZZ9f3-U
zCOaI8HQmS{0GGS~k4$}6w5Ty4j!zzx8RQwj$IeGzB`Y~_0m2M1(@=c^AYRfO
zdyYzC%ZNq}ajGv&L!+U3fj?GD*02E8l0qCCVi(CHoZ&cpyr;;C^qG%n)>khL$G)N}
zV;6j@nYnlu((#@nH&gv^vA6n75T{lU|0X~*kOs)1Q~})N)fLn)pGFWGiYgBJwr8xR
zT%4wbX)VQzp+r$jZE;lFs#Swg7q6{7PD_1Xf;a_nPI^{!m3u}FFs9tf3)sn3aJKSPI92=@PdtxQ0;q1rJ^bt+%rq+@)IIUSk39iFby9L)<
z3_y2d44|Fc#OQEJGmcamo2p#XAV$O~DFWgj1aW%8K3Y^SETZC^9K^?fxW9hn1Gt0W
zR|&0eF2w4~#i?GH)>6C}PJ)DLTO8H4YSo|x;~!=^ssJaWs{kBrjsZxY`pPNg
zSYAgQb2tcI{i-8Qp$@FCe(MK@4D>^s{NNMPRWwvj`o^k+H&b*b3ctPw8UyuZP6IYp
z1-hDVL^=j)p{{~PtP#|PlD^wzu3*Kg*H`iotlU5{AeKk|ChcWvfR`%(RZKyA_2qzA
zh5AlG9IT*U9qDSm8S5$tXUE}meC2D%jr5$c(DaB;%Z0=8mBM&pJ_hO&@i9;zYB!*n
zRpDuzI8YsIxX&Bou^`T4LEJ)uS07>(6yo0$Vtfgw!ehl~l0XbXPZd?5a!!2eGPr4nUQ)0f>J?yhqJec{vGZtWBfSj&_=q9nVCX#9d(fP`_Lh@w)Md
zSFncnNUBpg09Bg85yp5}Ng3)lYsDM)PnK*A)J
z*d@ZevVjv7{g9j;zcIDP+pBz2&Drsr%J$F)+NtKyH@il-;dPvNLm!qEeTAi?Z_QWz
zC?vanNEQ)h7qdAv?W6ylLoFGeqBXvmtjvNx#(eN%kQoLnn67eFG8MCWlCUpWEk9pi0uk*NmQuBA}{ApvqV+w8qC;XpJ8Mh=$R#l{G%e2Q3*tWIf$cS1s-}
zfbaO=62Aks_`w40=g2*m$FsSO{G*v
zAdiE?w&Sq-n&??6H})4tqiq5}#<~
ztP@r@4xDC!16&huzR`rbcDJwCx7C~rm;K`turblU-Zd9HbS49}u*o3nM-M`Dw5!ti
z`k|4L(MlGbc!qr+EQ%K*;A$|^#m9>gKo6o9UE67+Ylm(5yj(~9rV4`kb+CUmQ5*t|
zGz#>YF&%^pr#qwoF7Z!xC;+*Tf_10z-l_U_2`DzGxORiydL15uOQnPTE?#MXWIm){
zjW8diy5OR)KKGB8RIrk^Kwk_Pf_F29J#*(42uhc@;g&9rR0oy
z5HNyfg95I<6Pw`Ob{I(-S-N%aNAIoB-L3*XP*9hYHmQ!bOgBL22MPrL_iD^(5qVkzTg1&v5?mTys-l?QYN5R
zxdOV{M_WKi+WLXeY?(nw^+(S*;jKGJH?jdC3Ti;FG6i`1B^g9lxN$QZj^ra_2RH%%
z(|5RK2Ui9N#w{_pnt%#|w!Q#1TUy%4HB~j%BBUdy26*cb=s@4$eP6iBfdU*5
zM+U^<0iOK_I#78!(@$H1(Eq5v^nLWYAZ}4=^BU;^kN9ZC8PNTq0PhcLBb(9*KYU*V
z`cL&C#{+$^{WQ^+c3$Bg5`LE^dZxpWZ_rQWnOf7?Tw-_yj3wd4jNX|
z7_L6!$(s~X24Ex95SkuCNc-J%&n1OI3F(GuAvD4Pp&5J#jZi^|`^G68JAe)4AT*SN
zFj9-u4yt1?gp87a139LAS0`Qqg~t-GJZK$*cM{b!ELqG5Lztvn4KSbA#%*dUj?E_V
zr4qf;q4cO)%E$X`aOj$20J_@D=rPsX;n%|Ea%rqMq$j)$4RHVre2p8(Njuz}7}rhdGu8F6I8}d*?hW^=#xtDNhSECLi~JyuNA=}zkisbaN;J4
z!g-r03Wsu{C>+)?XG!3`ENWIiU%=&MXIu4@cM6Wdn8Rb{)IXh>(Ff7SM>bYYqa^7q
zq%>V8OVeDICf%K;IW0{(JWX?3nsj-Z=D0NJ^fb+NY0~X!n)A|hvpi4F<>pBd<>@8L
zJiV)uryHMndXX|u@2lkLWy(Cgvy!J5D)aQ-N}gV-%+tFodAe_zr~8(9x^I~$MVF@s
z1@rWVM4s+p=IJeod^8u}Jhju+j@D8#P1aR9dcK98
z;R?}8gIkkEF5wm~DPHPNNU!6C)US}#r-RwNw&Cb(fY)nz)p@#Cou_-%d3wDlAK^vMLgwk^lsrABnI|sG6PM+QyYi?mVW)}u
zL3e@kbQd^JcY*U{mgH%5m?y5x6W8Zy?T`$UTK7S}0@892)w<7cpHJ_C`E>QxC;Qe%
ztdo8rJ3I1m#2r4h#$QA3q*|CZ=>e3X{y9-d!ncyU8|9U7FQO%r=hQ+Kd2zT7TG6P^*CIN80VQ^qwEVm95D7&E_46?Ce
z-{A1Tq^@g*J`#pMEl??;k6?fa0gdPqjqnG~8RL3SCxFj%N4KH&G**$VGlbE88(qBC
zKvemRicVVtd2sV^qEV+n?r0HKtGaxY04|BK5uM=a=ML!m+99x0wOyn$8X|z`S%Il=
z9kNUWfg(ja$+S{|=;K-X5hacD`r^=9`UK7Z53e)h6HD}TA{Br$D#dkhgOI>l!vyf?
zoo)evs9@Df(|O=ziy3V?MQ#C#>qrA~NX66Y8D0kZ0)JQIll`?C4O12}VgT^xhdCQR
zByqBTCgN*;Kt#6Z~K4L}yKHWVR-zqcjHG+vB3B&Mgz4xv`vgr~m&%Lx8;R&>14scVMKK|ELlf$0?%T1_HCuu~s{U|TN+
z!K=P82zDA{5d6|W41yiN7z8hS8Hgsyb`Tg;cG2L~8Fr5#Nw0c!MUmxEWOWoNMv+n!
z8HplW6@3nGrPZ2S$)qCcVMIX5z{b})Eh{qOpgKGqNGhiR25*DDR}qKx=n~wh^9}te
zp-v~jrXz0qG#a7P3`VVbQ8O^1;oJt>2he|WFK`^*shfa|`ZXx4`VCSr8)nCaRX<--
zg2Wk7MQft3W^WjQ*Xsx2;Xuu!8b>H*!GASY&HL}eFNXd<{#OG(9tgkA{zrblWwBE%
z>%CL>Gw;91hTebfz2mBR@4w3LXQ#aXD(3PRARprJE0qtx?}J_mzZQSUd-uQlaw1)m
zd+*+NpO4bTRs1FU{KQwqQ~Wi1bK;6>eU_!`i&_&lwikU@^WJ;+SL&4S-ADDl9Oa9v
z?DH#i-(YWv>MvU@O=W9!ccG`LY1s`{{V~hR;@A_q%fY6RY%l0pd15ydp0};Lsk~wZBrWcl*HrG%v5uaerYS5980`m11T~eDIzOpP
z-oB!{38=BH*i=?4x_cp|N#`jYYuB;%-g&(}Jw5Y)TIJeYf7x2uUAC6#!t(*1x2(K}
zj_t9mctfMruS?v(t-hX~fnra&W?@ec^`NI|0Q%FM?O9ajElq`{GFwoDJ~_D+-DRga
zTb9jP7y%V2Ex6oha`_G5WT+_CuZP3
z0tz|kdT&!uN2#07Epdd4A9Sz{CwLQ;9^OJW$LpDmS)37n+*N4Y?z9&maOLRBqJJN`M;8c+G%{
z8VymJfCl}5czH&yw`p5%Q+WoEZBcn<%d%D7Q*0pLvv;|!)VyU;c~;A^W4f0ei;45*
zL;5U}KD%YgnweYOJvDP?u3S^hmS-%~A_uIMoodj38sSg5W-g3{y@ElY9P&vQ53$@4Ryj{2=ozE1S(~3e1MQn&Q-Kxv+fM!g+fE
zd(VYF&RMvqyi?0mjShDPdh2kPmML3@yS7X*9nNc+;yT=|WlHF9_m(L~hkLY4NgeLl
zGUe)UVGB?L>0cJTK9864r^CIWe>z+M{nOz-&_5mS3;omKe$YQ1egyib!~LOu
zI&5ueI@k!(0WD3v<(+$*Kukn`|i`u&{gp$YTmbVF_^ThYDnKv7=cl
z_W$1!z>bT`el)~W^`f9TuAJI`ir363bc0gVqnM77=YN$6TAJDn=50_L)>HVPngH$d
zq1ftlYwk5>s?HzU+%Xl@%+Y;q2YNsUKJ=Sj5SC)cqH?CCb*FZqxiGaD#;An2~S<(;4y19iH<6q~BG
zvSlx~u(YkUxvA;kZBXYy(~C8=Vtr-NoQ+C1m3y^LS$s_Q)vSp(&AXcI!*}h;YE7#^
zX+!&fL37w!7BJe=#T6v0W+Bc>EY~~GT;?#Tg0f?|;yj4=_JDra@tY!`2Kv7_EOyLm
zh89BTCj_PysP97>0YtP)7NA66FgP$A%pf~p0yP%(Y^9N~;V)^@YFof82S>DM0u1;*
zM2qHwflP-~vdRjSNmEnUyhJyuNAb`o&vXVUUA9(rw>BLN6S~Gefr(IQWhlyG0YFP3
zt75z0AbybZdYfsqK=Usu9~QM{s87Sys|}nTyBrPiV$dJ0nu=k0r(E}nc`&Co9o*A8
zm8_W!a@#d6cjdel({i)Za-(nK_3ijYuBCk7!gzt`qo~1rOZlLM+kpKvk8J}<|6jPl
zfVP&CfMUUPS+nszNckcdXj$yKX6I&*v{sNj>`>S;rC_en+;2Gl|Bj`Xd>DiMzi_dp
zx7K3^H+RgNALEz#J%r~HP{0Q+jHq%rKnE?Hug6T&g?dvV!BQZ>929%7PC&wEwU*mK
z%8&RT>C2$Hn%T3;87OmPOSuz*qcjx@K<=gxCgF%$N4IE(E*}MCUf#0ZvOe#JV8{b34R(TCT3av=bnP
zX(#G(H9GesUCzW#*5yp>6kX25*6211P^?$CsUt<*rjGRKHg%+5mpL3F1G%ZRc0qJZ6I~Z8X8{_AxfY_j%mhYtnMqSdXXrAU;NwFowQe?$`CzDAspAMsoUFMV<~m&RjZRrkcCmZT$eQK3%)XAbzkwSk;FsrD$-~9L
zqDAiEIEs?b6hq=9aWu=aKrG-Uxjx(F?!I`J#d8+VT->nOTkPC!IhF_I=Rk(Fc-sFu
z<3`K2to+p8HJ2O%ONdLl3Eoe`H%Kc==$SXQuTHv2o@>>x#h3K2qLQ!%>*-nCxJj%P
j$B84@p{!Ljai@O&8*1LasLVbNGkpGPJ|L`o-unLlIZ;rO

literal 0
HcmV?d00001

diff --git a/crates/app/assets/fonts/OFL-Inter.txt b/crates/app/assets/fonts/OFL-Inter.txt
new file mode 100644
index 0000000..9b2ca37
--- /dev/null
+++ b/crates/app/assets/fonts/OFL-Inter.txt
@@ -0,0 +1,92 @@
+Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION AND CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/crates/app/assets/fonts/SOURCES.md b/crates/app/assets/fonts/SOURCES.md
new file mode 100644
index 0000000..9d7c438
--- /dev/null
+++ b/crates/app/assets/fonts/SOURCES.md
@@ -0,0 +1,18 @@
+# Embedded fonts
+
+Inter is licensed under the SIL Open Font License 1.1; the licence text sits next to the fonts.
+The fonts are unmodified copies of the files in the official release archive.
+
+## Inter 4.1
+
+- Archive: https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip
+- Archive SHA-256: `9883fdd4a49d4fb66bd8177ba6625ef9a64aa45899767dde3d36aa425756b11e`
+- Licence: `OFL-Inter.txt` (archive file `LICENSE.txt`, SHA-256 `262481e844521b326f5ecd053e59b98c8b2da78c8ee1bdbb6e8174305e54935a`)
+
+| File | Archive path | SHA-256 |
+|---|---|---|
+| `Inter-Regular.ttf` | `extras/ttf/Inter-Regular.ttf` | `40d692fce188e4471e2b3cba937be967878f631ad3ebbbdcd587687c7ebe0c82` |
+| `Inter-SemiBold.ttf` | `extras/ttf/Inter-SemiBold.ttf` | `78a843fade9d4612a5567302fb595b56976eb5fcebf4fea5a5912d638bafcde3` |
+
+egui's default fonts (crate `epaint_default_fonts`) stay behind Inter as the fallback for
+symbols and emoji; their licences are listed with the Rust crates.
diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs
new file mode 100644
index 0000000..ee3997a
--- /dev/null
+++ b/crates/app/src/app.rs
@@ -0,0 +1,323 @@
+//! The window: owns the settings, one worker per speaker and the scan, and wires the views to
+//! them. Worker events are drained once per frame; workers wake the window when they have news.
+
+use std::net::SocketAddrV6;
+use std::path::PathBuf;
+use std::sync::mpsc::{Receiver, TryRecvError};
+use std::sync::Arc;
+use std::thread;
+
+use eframe::egui::{self, Context, ScrollArea, Ui, ViewportInfo};
+use neumann_control_core::address::parse_address;
+use neumann_control_core::catalog::Catalog;
+use neumann_control_core::discovery::{merge_found, Found};
+use neumann_control_core::mask::mask_host;
+use neumann_control_core::settings::{self, ManualSpeaker, Settings, WindowGeometry};
+use neumann_control_core::speaker::{Command, Event, SpeakerHandle, Target};
+
+use crate::spawner::{ScanResult, SpeakerSpawner};
+use crate::state::{merge_targets, SpeakerKey, SpeakerState};
+use crate::texts;
+use crate::ui_kit::{fonts, theme};
+use crate::views::add_address::{self, AddAddressAction, AddAddressForm};
+use crate::views::card;
+use crate::views::toolbar::{self, ToolbarAction, ToolbarFacts};
+
+const CARDS_SCROLL_ID: &str = "cards";
+
+struct Speaker {
+    state: SpeakerState,
+    handle: SpeakerHandle,
+    events: Receiver,
+}
+
+pub struct NeumannApp {
+    settings_path: PathBuf,
+    settings: Settings,
+    spawner: Box,
+    wake: Arc,
+    speakers: Vec,
+    found: Vec,
+    scan: Option>,
+    notice: Option<&'static str>,
+    add_address: Option,
+    window: Option,
+}
+
+impl NeumannApp {
+    /// Loads the settings and starts a worker for every manual speaker.
+    pub fn new(
+        context: &Context,
+        settings_path: PathBuf,
+        spawner: Box,
+    ) -> NeumannApp {
+        fonts::install(context);
+        theme::apply(context);
+        let settings = settings::load(&settings_path);
+        let repaint_context = context.clone();
+        let mut app = NeumannApp {
+            window: settings.window,
+            settings_path,
+            settings,
+            spawner,
+            wake: Arc::new(move || repaint_context.request_repaint()),
+            speakers: Vec::new(),
+            found: Vec::new(),
+            scan: None,
+            notice: None,
+            add_address: None,
+        };
+        app.sync_targets();
+        app
+    }
+
+    /// Starts workers for targets without a card and moves cards whose speaker changed address.
+    fn sync_targets(&mut self) {
+        for (key, address) in merge_targets(&self.settings.manual, &self.found) {
+            let discovered = self.found.iter().find(|found| found.address == address);
+            let existing = self
+                .speakers
+                .iter()
+                .position(|speaker| speaker.state.key == key || speaker.state.address == address);
+            let Some(position) = existing else {
+                let mut state = SpeakerState::new(key.clone(), address);
+                state.discovered = discovered.cloned();
+                let (handle, events) = self.spawn(&key, address);
+                self.speakers.push(Speaker {
+                    state,
+                    handle,
+                    events,
+                });
+                continue;
+            };
+            if discovered.is_some() {
+                self.speakers[position].state.discovered = discovered.cloned();
+            }
+            if self.speakers[position].state.address != address {
+                self.respawn(position, &key, address);
+            }
+        }
+    }
+
+    fn spawn(&self, key: &SpeakerKey, address: SocketAddrV6) -> (SpeakerHandle, Receiver) {
+        let expected_serial = match key {
+            SpeakerKey::Serial(serial) => Some(serial.clone()),
+            SpeakerKey::Address(_) => None,
+        };
+        let target = Target {
+            address,
+            expected_serial,
+        };
+        self.spawner.spawn(target, Arc::clone(&self.wake))
+    }
+
+    fn respawn(&mut self, position: usize, key: &SpeakerKey, address: SocketAddrV6) {
+        log::info!("speaker moved to {}", mask_host(&address));
+        let (handle, events) = self.spawn(key, address);
+        let speaker = &mut self.speakers[position];
+        speaker.state.address = address;
+        speaker.events = events;
+        let old = std::mem::replace(&mut speaker.handle, handle);
+        // Dropping a handle joins its worker, which can sit in a 3 s connect; not on this thread.
+        thread::spawn(move || drop(old));
+    }
+
+    fn drain_events(&mut self, now: f64) {
+        for speaker in &mut self.speakers {
+            while let Ok(event) = speaker.events.try_recv() {
+                speaker.state.apply(event, now);
+            }
+        }
+    }
+
+    fn poll_scan(&mut self) {
+        let Some(receiver) = &self.scan else {
+            return;
+        };
+        let result = match receiver.try_recv() {
+            Ok(result) => result,
+            Err(TryRecvError::Empty) => return,
+            Err(TryRecvError::Disconnected) => Err("the scan ended without a result".to_owned()),
+        };
+        self.scan = None;
+        match result {
+            Ok(found) => {
+                log::info!("scan found {} speakers", found.len());
+                let mut all = std::mem::take(&mut self.found);
+                all.extend(found);
+                self.found = merge_found(all);
+                self.sync_targets();
+            }
+            Err(reason) => {
+                log::warn!("scan failed: {reason}");
+                self.notice = Some(texts::SCAN_FAILED);
+            }
+        }
+    }
+
+    fn add_manual(&mut self, manual: ManualSpeaker) {
+        let Ok(address) = parse_address(&manual.address, manual.port) else {
+            return;
+        };
+        let known = self
+            .settings
+            .manual
+            .iter()
+            .any(|existing| parse_address(&existing.address, existing.port) == Ok(address));
+        if known {
+            return;
+        }
+        log::info!("manual speaker added at {}", mask_host(&address));
+        self.settings.manual.push(manual);
+        self.save_settings();
+        self.sync_targets();
+    }
+
+    fn save_settings(&mut self) {
+        if let Err(error) = settings::save(&self.settings, &self.settings_path) {
+            log::warn!("settings could not be saved ({})", error.kind());
+            self.notice = Some(texts::SETTINGS_NOT_SAVED);
+        }
+    }
+
+    fn show(&mut self, ui: &mut Ui) {
+        let facts = ToolbarFacts {
+            scanning: self.scan.is_some(),
+            speaker_count: self.speakers.len(),
+            notice: self.notice,
+        };
+        match toolbar::show(ui, &facts) {
+            ToolbarAction::Scan => {
+                self.notice = None;
+                self.scan = Some(self.spawner.scan());
+            }
+            ToolbarAction::AddAddress => self.add_address = Some(AddAddressForm::default()),
+            ToolbarAction::None => {}
+        }
+        egui::CentralPanel::default().show(ui, |ui| self.show_cards(ui));
+        self.show_dialog(ui);
+    }
+
+    fn show_cards(&mut self, ui: &mut Ui) {
+        if self.speakers.is_empty() {
+            ui.weak(texts::NO_SPEAKERS);
+            return;
+        }
+        let catalog = Catalog::embedded();
+        ScrollArea::vertical()
+            .id_salt(CARDS_SCROLL_ID)
+            .auto_shrink([false, false])
+            .show(ui, |ui| {
+                ui.horizontal_wrapped(|ui| {
+                    for speaker in &mut self.speakers {
+                        let key = speaker.state.key.clone();
+                        let commands = ui
+                            .push_id(key, |ui| card::show(ui, &mut speaker.state, catalog))
+                            .inner;
+                        for command in commands {
+                            speaker.handle.send(command);
+                        }
+                    }
+                });
+            });
+    }
+
+    fn show_dialog(&mut self, ui: &mut Ui) {
+        let Some(form) = &mut self.add_address else {
+            return;
+        };
+        match add_address::show(ui, form) {
+            AddAddressAction::None => {}
+            AddAddressAction::Cancel => self.add_address = None,
+            AddAddressAction::Add(manual) => {
+                self.add_address = None;
+                self.add_manual(manual);
+            }
+        }
+    }
+}
+
+/// Outer position and inner size in points, as the viewport builder takes them back. `None`
+/// while minimized or maximized, so the file keeps the last normal window.
+pub fn window_geometry(viewport: &ViewportInfo) -> Option {
+    let outer = viewport.outer_rect?;
+    let inner = viewport.inner_rect?;
+    let normal = viewport.minimized != Some(true) && viewport.maximized != Some(true);
+    if !normal {
+        return None;
+    }
+    Some(WindowGeometry {
+        x: outer.min.x,
+        y: outer.min.y,
+        width: inner.width(),
+        height: inner.height(),
+    })
+}
+
+impl eframe::App for NeumannApp {
+    fn logic(&mut self, context: &Context, _frame: &mut eframe::Frame) {
+        let now = context.input(|input| input.time);
+        self.drain_events(now);
+        self.poll_scan();
+        let geometry = context.input(|input| window_geometry(input.viewport()));
+        self.window = geometry.or(self.window);
+    }
+
+    fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) {
+        self.show(ui);
+    }
+
+    fn on_exit(&mut self) {
+        // All workers hear Stop first, so the joins in Drop overlap instead of queueing.
+        for speaker in &self.speakers {
+            speaker.handle.send(Command::Stop);
+        }
+        self.speakers.clear();
+        if self.window.is_some() {
+            self.settings.window = self.window;
+            self.save_settings();
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use eframe::egui::{pos2, vec2, Rect};
+
+    fn viewport() -> ViewportInfo {
+        ViewportInfo {
+            outer_rect: Some(Rect::from_min_size(pos2(-10.0, 20.0), vec2(812.0, 534.0))),
+            inner_rect: Some(Rect::from_min_size(pos2(-6.0, 50.0), vec2(800.0, 500.0))),
+            ..ViewportInfo::default()
+        }
+    }
+
+    #[test]
+    fn geometry_is_outer_position_and_inner_size() {
+        assert_eq!(
+            window_geometry(&viewport()),
+            Some(WindowGeometry {
+                x: -10.0,
+                y: 20.0,
+                width: 800.0,
+                height: 500.0,
+            })
+        );
+    }
+
+    #[test]
+    fn minimized_or_maximized_windows_keep_the_last_normal_geometry() {
+        let minimized = ViewportInfo {
+            minimized: Some(true),
+            ..viewport()
+        };
+        let maximized = ViewportInfo {
+            maximized: Some(true),
+            ..viewport()
+        };
+        assert_eq!(window_geometry(&minimized), None);
+        assert_eq!(window_geometry(&maximized), None);
+        assert_eq!(window_geometry(&ViewportInfo::default()), None);
+    }
+}
diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs
index fc8450b..45202f3 100644
--- a/crates/app/src/lib.rs
+++ b/crates/app/src/lib.rs
@@ -1,2 +1,6 @@
+pub mod app;
+pub mod spawner;
 pub mod state;
 pub mod texts;
+pub mod ui_kit;
+pub mod views;
diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs
index f328e4d..2a48c4e 100644
--- a/crates/app/src/main.rs
+++ b/crates/app/src/main.rs
@@ -1 +1,88 @@
-fn main() {}
+// Release builds are GUI-subsystem so no console window opens next to the app.
+#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
+
+use std::error::Error;
+use std::fs::File;
+use std::path::Path;
+
+use eframe::egui::{pos2, vec2, ViewportBuilder};
+use eframe::egui_wgpu::{SurfaceConfig, WgpuConfiguration, WgpuSetup};
+use eframe::wgpu;
+use neumann_control_app::app::NeumannApp;
+use neumann_control_app::spawner::CoreSpawner;
+use neumann_control_app::texts;
+use neumann_control_core::settings::{self, WindowGeometry, LOG_FILE, SETTINGS_FILE};
+use simplelog::{ConfigBuilder, LevelFilter, WriteLogger};
+
+const WINDOW_SIZE: [f32; 2] = [900.0, 600.0];
+const WINDOW_MIN_SIZE: [f32; 2] = [480.0, 360.0];
+// Our crates only: wgpu logs the graphics adapters with their bus ids at info level.
+const LOG_TARGET_PREFIX: &str = "neumann_control";
+
+/// eframe's default (vsync via Fifo, two queued frames) waits on the remote display's vblank under
+/// Remote Desktop, which ticks at 10-30 Hz there; Mailbox never tears and does not wait. DX12 is
+/// pinned because it always offers Mailbox (wgpu-hal), and egui-wgpu does not check the mode.
+fn wgpu_options() -> WgpuConfiguration {
+    let mut options = WgpuConfiguration {
+        surface: SurfaceConfig {
+            present_mode: wgpu::PresentMode::Mailbox,
+            desired_maximum_frame_latency: Some(1),
+        },
+        ..WgpuConfiguration::default()
+    };
+    if let WgpuSetup::CreateNew(setup) = &mut options.wgpu_setup {
+        setup.instance_descriptor.backends = wgpu::Backends::DX12;
+    }
+    options
+}
+
+/// The saved window, never smaller than the minimum; the default size when none was saved.
+fn viewport(saved: Option) -> ViewportBuilder {
+    let builder = ViewportBuilder::default()
+        .with_title(texts::WINDOW_TITLE)
+        .with_min_inner_size(WINDOW_MIN_SIZE);
+    let Some(saved) = saved else {
+        return builder.with_inner_size(WINDOW_SIZE);
+    };
+    let width = saved.width.max(WINDOW_MIN_SIZE[0]);
+    let height = saved.height.max(WINDOW_MIN_SIZE[1]);
+    builder
+        .with_position(pos2(saved.x, saved.y))
+        .with_inner_size(vec2(width, height))
+}
+
+/// The app still runs without a log, e.g. from a read-only folder.
+fn start_log(directory: &Path) {
+    let Ok(file) = File::create(directory.join(LOG_FILE)) else {
+        return;
+    };
+    let config = ConfigBuilder::new()
+        .add_filter_allow_str(LOG_TARGET_PREFIX)
+        .build();
+    let _ = WriteLogger::init(LevelFilter::Info, config, file);
+}
+
+fn main() -> Result<(), Box> {
+    let executable = std::env::current_exe()?;
+    let directory = executable
+        .parent()
+        .ok_or("the executable has no parent folder")?;
+    start_log(directory);
+    let settings_path = directory.join(SETTINGS_FILE);
+    let saved_window = settings::load(&settings_path).window;
+    let options = eframe::NativeOptions {
+        viewport: viewport(saved_window),
+        wgpu_options: wgpu_options(),
+        ..eframe::NativeOptions::default()
+    };
+    eframe::run_native(
+        texts::WINDOW_TITLE,
+        options,
+        Box::new(move |creation_context| {
+            let context = creation_context.egui_ctx.clone();
+            let spawner = Box::new(CoreSpawner::new(context.clone()));
+            Ok(Box::new(NeumannApp::new(&context, settings_path, spawner)))
+        }),
+    )?;
+    Ok(())
+}
diff --git a/crates/app/src/spawner.rs b/crates/app/src/spawner.rs
new file mode 100644
index 0000000..7ab6dd9
--- /dev/null
+++ b/crates/app/src/spawner.rs
@@ -0,0 +1,63 @@
+//! How the app starts speaker workers and scans; tests swap in the simulator behind the trait.
+
+use std::sync::mpsc::{self, Receiver};
+use std::sync::Arc;
+use std::thread;
+
+use eframe::egui::Context;
+use neumann_control_core::discovery::{self, Found, SCAN_DURATION};
+use neumann_control_core::speaker::{self, Event, SpeakerHandle, Target, Timing};
+
+const SCAN_THREAD_NAME: &str = "scan";
+
+pub type ScanResult = Result, String>;
+
+pub trait SpeakerSpawner {
+    /// Starts a worker for `target`; `wake` runs after each of its events.
+    fn spawn(
+        &self,
+        target: Target,
+        wake: Arc,
+    ) -> (SpeakerHandle, Receiver);
+
+    /// Starts a scan in the background; the receiver gets exactly one result.
+    fn scan(&self) -> Receiver;
+}
+
+/// Real workers with the default timing and a real mDNS scan.
+pub struct CoreSpawner {
+    context: Context,
+}
+
+impl CoreSpawner {
+    /// The context is woken when a scan ends, as the speaker workers do through `wake`.
+    pub fn new(context: Context) -> CoreSpawner {
+        CoreSpawner { context }
+    }
+}
+
+impl SpeakerSpawner for CoreSpawner {
+    fn spawn(
+        &self,
+        target: Target,
+        wake: Arc,
+    ) -> (SpeakerHandle, Receiver) {
+        speaker::spawn(target, Timing::default(), wake)
+    }
+
+    fn scan(&self) -> Receiver {
+        let (sender, receiver) = mpsc::channel();
+        let context = self.context.clone();
+        let started = thread::Builder::new()
+            .name(SCAN_THREAD_NAME.to_owned())
+            .spawn(move || {
+                // The app may have closed meanwhile; then nobody waits for the result.
+                let _ = sender.send(discovery::scan(SCAN_DURATION));
+                context.request_repaint();
+            });
+        if let Err(error) = started {
+            log::error!("scan thread could not be started: {error}");
+        }
+        receiver
+    }
+}
diff --git a/crates/app/src/state.rs b/crates/app/src/state.rs
index 39ce893..f26bb20 100644
--- a/crates/app/src/state.rs
+++ b/crates/app/src/state.rs
@@ -9,7 +9,7 @@ use std::ops::RangeInclusive;
 use neumann_control_core::address::parse_address;
 use neumann_control_core::catalog::Catalog;
 use neumann_control_core::discovery::Found;
-use neumann_control_core::mask::mask_serial;
+use neumann_control_core::mask::{mask_host, mask_serial};
 use neumann_control_core::settings::ManualSpeaker;
 use neumann_control_core::speaker::{Event, Identity};
 use neumann_control_core::ssc::wire::SscPath;
@@ -25,9 +25,9 @@ pub const WHEEL_IDLE_SECONDS: f64 = 0.3;
 
 const INPUT_METER_PATH: &str = "m/in/level";
 const OUTPUT_METER_PATH: &str = "m/out/level";
-const OUTPUT_LEVEL_PATH: &str = "audio/out/level";
+pub const OUTPUT_LEVEL_PATH: &str = "audio/out/level";
 
-#[derive(Clone, Debug, PartialEq, Eq)]
+#[derive(Clone, Debug, PartialEq, Eq, Hash)]
 pub enum SpeakerKey {
     Serial(String),
     Address(SocketAddrV6),
@@ -45,6 +45,8 @@ pub struct SpeakerState {
     pub key: SpeakerKey,
     pub address: SocketAddrV6,
     pub identity: Option,
+    /// What the last scan reported for this speaker; names may be empty.
+    pub discovered: Option,
     pub values: BTreeMap,
     pub absent: BTreeSet,
     pub status: Status,
@@ -63,6 +65,7 @@ impl SpeakerState {
             key,
             address,
             identity: None,
+            discovered: None,
             values: BTreeMap::new(),
             absent: BTreeSet::new(),
             status: Status::Connecting,
@@ -75,6 +78,38 @@ impl SpeakerState {
         }
     }
 
+    /// The speaker's own name, else the announced one, else the model, else the masked
+    /// address; mDNS names and device names can both be empty.
+    pub fn display_name(&self) -> String {
+        let own = self
+            .identity
+            .as_ref()
+            .map(|identity| identity.name.as_str());
+        let announced = self.discovered.as_ref().map(|found| found.name.as_str());
+        [own, announced, self.model()]
+            .into_iter()
+            .flatten()
+            .find(|name| !name.is_empty())
+            .map(str::to_owned)
+            .unwrap_or_else(|| mask_host(&self.address))
+    }
+
+    /// The reported product, else the announced one.
+    pub fn model(&self) -> Option<&str> {
+        let reported = self
+            .identity
+            .as_ref()
+            .map(|identity| identity.product.as_str());
+        let announced = self
+            .discovered
+            .as_ref()
+            .and_then(|found| found.product.as_deref());
+        [reported, announced]
+            .into_iter()
+            .flatten()
+            .find(|model| !model.is_empty())
+    }
+
     pub fn apply(&mut self, event: Event, now: f64) {
         match event {
             Event::Connected(identity) => {
@@ -286,6 +321,11 @@ impl FaderState {
         None
     }
 
+    /// When the pending wheel value becomes due, so the UI can wake up for it.
+    pub fn deadline(&self) -> Option {
+        self.pending.map(|_| self.last_step + WHEEL_IDLE_SECONDS)
+    }
+
     pub fn shown(&self, device: f64) -> f64 {
         self.pending.unwrap_or(device)
     }
@@ -561,6 +601,60 @@ mod tests {
         assert_eq!(fader.shown(80.0), 80.0);
     }
 
+    #[test]
+    fn wheel_deadline_follows_the_last_step_and_ends_when_due() {
+        let range = output_level_range();
+        let mut fader = FaderState::default();
+        assert_eq!(fader.deadline(), None);
+        fader.on_wheel(1, 80.0, range.clone(), 1.0);
+        fader.on_wheel(1, 80.0, range, 1.5);
+        assert_eq!(fader.deadline(), Some(1.5 + WHEEL_IDLE_SECONDS));
+        fader.due(2.0);
+        assert_eq!(fader.deadline(), None);
+    }
+
+    fn identity(name: &str, product: &str) -> Identity {
+        Identity {
+            product: product.to_owned(),
+            version: "1".to_owned(),
+            serial: SERIAL.to_owned(),
+            name: name.to_owned(),
+        }
+    }
+
+    #[test]
+    fn display_name_falls_back_from_own_to_announced_to_model_to_masked_address() {
+        let mut speaker = state();
+        assert_eq!(speaker.display_name(), mask_host(&address(ADDRESS_TEXT)));
+        speaker.discovered = Some(Found {
+            name: String::new(),
+            product: Some("KH 750".to_owned()),
+            ..found(ADDRESS_TEXT, None)
+        });
+        assert_eq!(speaker.display_name(), "KH 750");
+        speaker.discovered.as_mut().unwrap().name = "announced".to_owned();
+        assert_eq!(speaker.display_name(), "announced");
+        speaker.identity = Some(identity("", "KH 120 II"));
+        assert_eq!(speaker.display_name(), "announced");
+        speaker.identity = Some(identity("own", "KH 120 II"));
+        assert_eq!(speaker.display_name(), "own");
+    }
+
+    #[test]
+    fn model_prefers_the_reported_product() {
+        let mut speaker = state();
+        assert_eq!(speaker.model(), None);
+        speaker.discovered = Some(Found {
+            product: Some("KH 750".to_owned()),
+            ..found(ADDRESS_TEXT, None)
+        });
+        assert_eq!(speaker.model(), Some("KH 750"));
+        speaker.identity = Some(identity("", ""));
+        assert_eq!(speaker.model(), Some("KH 750"));
+        speaker.identity = Some(identity("", "KH 120 II"));
+        assert_eq!(speaker.model(), Some("KH 120 II"));
+    }
+
     #[test]
     fn wheel_clamps_at_120() {
         let range = output_level_range();
diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs
index 794a69d..c33dcc2 100644
--- a/crates/app/src/texts.rs
+++ b/crates/app/src/texts.rs
@@ -1,7 +1,31 @@
 //! German UI strings; the only place user-visible text lives.
 
+use neumann_control_core::address::AddressError;
 use neumann_control_core::catalog::ValueError;
 
+pub const WINDOW_TITLE: &str = "Neumann Control";
+pub const SCAN: &str = "Suchen";
+pub const ADD_ADDRESS: &str = "+ Adresse";
+pub const SCAN_FAILED: &str = "Suche fehlgeschlagen";
+pub const SETTINGS_NOT_SAVED: &str = "Einstellungen konnten nicht gespeichert werden";
+pub const NO_SPEAKERS: &str =
+    "Keine Lautsprecher. „Suchen“ findet sie im Netz, „+ Adresse“ fügt einen von Hand hinzu.";
+
+pub const MUTE: &str = "Stumm";
+pub const IDENTIFY: &str = "Ident.";
+pub const MORE: &str = "Mehr";
+pub const LESS: &str = "Weniger";
+pub const CONNECTING: &str = "verbinde …";
+pub const CONNECTED: &str = "verbunden";
+pub const LEVEL: &str = "Pegel";
+pub const CLIP: &str = "Übersteuert";
+
+pub const ADD_ADDRESS_TITLE: &str = "Lautsprecher per Adresse hinzufügen";
+pub const ADDRESS_FIELD: &str = "IPv6-Adresse";
+pub const ADDRESS_HINT: &str = "z. B. fe80::1%7";
+pub const ADD: &str = "Hinzufügen";
+pub const CANCEL: &str = "Abbrechen";
+
 pub const UNREACHABLE: &str = "nicht erreichbar (aus oder Standby)";
 pub const WRITE_FAILED: &str = "Schreiben fehlgeschlagen";
 const SERIAL_MISMATCH_PREFIX: &str = "Anderes Gerät unter dieser Adresse (Seriennummer ";
@@ -10,6 +34,36 @@ const SERIAL_MISMATCH_SUFFIX: &str = ")";
 const REJECTED_READ_ONLY_CODE: i64 = 405;
 const REJECTED_WRONG_VALUE_CODE: i64 = 406;
 
+pub fn speaker_count(count: usize) -> String {
+    format!("{count} Lautsprecher")
+}
+
+pub fn port(port: u16) -> String {
+    format!("Port {port}")
+}
+
+pub fn readout(value: f64, unit: &str) -> String {
+    format!("{value:.1} {unit}")
+}
+
+pub fn status_line(firmware: &str, masked_serial: &str) -> String {
+    format!("Firmware {firmware} · SN {masked_serial}")
+}
+
+pub fn describe_address_error(error: &AddressError) -> String {
+    let text = match error {
+        AddressError::Ipv4NotSupported => {
+            "IPv4 geht nicht: die Lautsprecher sind nur über IPv6 erreichbar, z. B. fe80::1%7."
+        }
+        AddressError::MissingScope => {
+            "Bei fe80-Adressen fehlt der Schnittstellenindex nach dem %, z. B. fe80::1%7."
+        }
+        AddressError::ScopeNotNumeric => "Nach dem % muss der Schnittstellenindex als Zahl stehen.",
+        AddressError::Invalid => "Keine gültige IPv6-Adresse.",
+    };
+    text.to_owned()
+}
+
 pub fn serial_mismatch(masked_serial: &str) -> String {
     format!("{SERIAL_MISMATCH_PREFIX}{masked_serial}{SERIAL_MISMATCH_SUFFIX}")
 }
diff --git a/crates/app/src/ui_kit/fonts.rs b/crates/app/src/ui_kit/fonts.rs
new file mode 100644
index 0000000..839e86c
--- /dev/null
+++ b/crates/app/src/ui_kit/fonts.rs
@@ -0,0 +1,90 @@
+//! Inter for all text, egui's default fonts behind it for symbols and emoji.
+
+use std::sync::Arc;
+
+use eframe::egui::{Context, FontData, FontDefinitions, FontFamily, FontId};
+
+const FONT_INTER: &str = "Inter";
+const FONT_INTER_SEMIBOLD: &str = "Inter-SemiBold";
+
+fn font_data(bytes: &'static [u8]) -> Arc {
+    Arc::new(FontData::from_static(bytes))
+}
+
+pub fn definitions() -> FontDefinitions {
+    let mut definitions = FontDefinitions::default();
+    definitions.font_data.insert(
+        FONT_INTER.to_owned(),
+        font_data(include_bytes!("../../assets/fonts/Inter-Regular.ttf")),
+    );
+    definitions.font_data.insert(
+        FONT_INTER_SEMIBOLD.to_owned(),
+        font_data(include_bytes!("../../assets/fonts/Inter-SemiBold.ttf")),
+    );
+    let default_fallbacks = definitions.families[&FontFamily::Proportional].clone();
+    let semibold_family = std::iter::once(FONT_INTER_SEMIBOLD.to_owned())
+        .chain(default_fallbacks)
+        .collect();
+    definitions.families.insert(
+        FontFamily::Name(FONT_INTER_SEMIBOLD.into()),
+        semibold_family,
+    );
+    if let Some(names) = definitions.families.get_mut(&FontFamily::Proportional) {
+        names.insert(0, FONT_INTER.to_owned());
+    }
+    definitions
+}
+
+/// Takes effect from the next frame on.
+pub fn install(context: &Context) {
+    context.set_fonts(definitions());
+}
+
+pub fn semibold(size: f32) -> FontId {
+    FontId::new(size, FontFamily::Name(FONT_INTER_SEMIBOLD.into()))
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn inter_comes_first_and_egui_defaults_stay_as_fallbacks() {
+        let definitions = definitions();
+        let proportional = &definitions.families[&FontFamily::Proportional];
+        assert_eq!(proportional[0], FONT_INTER);
+        assert!(proportional.len() > 1);
+        let semibold = &definitions.families[&semibold(12.0).family];
+        assert_eq!(semibold[0], FONT_INTER_SEMIBOLD);
+        assert!(semibold.len() > 1);
+    }
+
+    /// cargo about only knows crates, so the release's THIRD-PARTY-LICENSES.html carries the
+    /// embedded font's licence solely through its copy in the root `about.hbs`.
+    #[test]
+    fn the_licence_template_carries_every_embedded_font_licence_verbatim() {
+        let manifest_directory = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
+        let template = std::fs::read_to_string(manifest_directory.join("../../about.hbs"))
+            .expect("the licence template at the repo root");
+        let fonts_directory = manifest_directory.join("assets/fonts");
+        let mut licence_files = 0;
+        for entry in std::fs::read_dir(&fonts_directory).expect("the fonts folder") {
+            let name = entry.expect("a fonts folder entry").file_name();
+            let name = name.to_string_lossy();
+            if !(name.starts_with("OFL-") && name.ends_with(".txt")) {
+                continue;
+            }
+            licence_files += 1;
+            let text = std::fs::read_to_string(fonts_directory.join(&*name)).expect("the licence");
+            let opening = format!("data-file=\"{name}\">");
+            let start = template
+                .find(&opening)
+                .unwrap_or_else(|| panic!("{name} is missing from about.hbs"))
+                + opening.len();
+            let end = start + template[start..].find("
").expect("the closing tag"); + assert_eq!(&template[start..end], text, "{name} differs in about.hbs"); + } + assert!(licence_files > 0); + assert_eq!(template.matches("data-file=\"OFL-").count(), licence_files); + } +} diff --git a/crates/app/src/ui_kit/mod.rs b/crates/app/src/ui_kit/mod.rs new file mode 100644 index 0000000..c8362e5 --- /dev/null +++ b/crates/app/src/ui_kit/mod.rs @@ -0,0 +1,2 @@ +pub mod fonts; +pub mod theme; diff --git a/crates/app/src/ui_kit/theme.rs b/crates/app/src/ui_kit/theme.rs new file mode 100644 index 0000000..d251f67 --- /dev/null +++ b/crates/app/src/ui_kit/theme.rs @@ -0,0 +1,172 @@ +//! The look of the app in Windows' light and dark mode. egui follows the system theme on its +//! own, so each of its two themes gets its own style here. + +use std::sync::Arc; + +use eframe::egui::style::{Selection, WidgetVisuals}; +use eframe::egui::{ + vec2, Color32, Context, CornerRadius, FontId, Stroke, Style, TextStyle, Theme, Visuals, +}; + +use super::fonts::semibold; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Tokens { + pub chrome: Color32, + pub card: Color32, + pub border: Color32, + pub field: Color32, + pub hover: Color32, + pub text: Color32, + pub text_dim: Color32, + pub accent: Color32, + pub accent_text: Color32, + pub danger: Color32, + pub connected: Color32, + pub connecting: Color32, + pub unreachable: Color32, + pub meter_track: Color32, + pub meter_fill: Color32, + pub clip_off: Color32, +} + +const RADIUS_SMALL: u8 = 3; +const RADIUS_LARGE: u8 = 6; +const SIZE_SMALL: f32 = 10.5; +const SIZE_BODY: f32 = 12.0; +const SIZE_HEADING: f32 = 13.5; + +impl Tokens { + pub fn dark() -> Tokens { + Tokens { + chrome: Color32::from_gray(32), + card: Color32::from_gray(44), + border: Color32::from_gray(64), + field: Color32::from_gray(56), + hover: Color32::from_gray(72), + text: Color32::from_gray(222), + text_dim: Color32::from_gray(160), + accent: Color32::from_rgb(34, 110, 220), + accent_text: Color32::WHITE, + danger: Color32::from_rgb(248, 113, 120), + connected: Color32::from_rgb(74, 200, 110), + connecting: Color32::from_rgb(230, 180, 60), + unreachable: Color32::from_gray(110), + meter_track: Color32::from_gray(24), + meter_fill: Color32::from_rgb(74, 200, 110), + clip_off: Color32::from_gray(70), + } + } + + pub fn light() -> Tokens { + Tokens { + chrome: Color32::from_rgb(243, 243, 246), + card: Color32::WHITE, + border: Color32::from_rgb(210, 210, 218), + field: Color32::from_rgb(236, 236, 240), + hover: Color32::from_rgb(224, 224, 230), + text: Color32::from_rgb(24, 24, 28), + text_dim: Color32::from_rgb(92, 92, 102), + accent: Color32::from_rgb(29, 98, 216), + accent_text: Color32::WHITE, + danger: Color32::from_rgb(200, 40, 50), + connected: Color32::from_rgb(30, 150, 70), + connecting: Color32::from_rgb(200, 140, 20), + unreachable: Color32::from_gray(150), + meter_track: Color32::from_rgb(222, 222, 228), + meter_fill: Color32::from_rgb(30, 160, 80), + clip_off: Color32::from_rgb(200, 200, 206), + } + } + + /// The tokens of the theme egui is showing right now. + pub fn current(context: &Context) -> Tokens { + match context.theme() { + Theme::Dark => Tokens::dark(), + Theme::Light => Tokens::light(), + } + } +} + +fn widget_visuals(fill: Color32, border: Color32, foreground: Color32) -> WidgetVisuals { + WidgetVisuals { + bg_fill: fill, + weak_bg_fill: fill, + bg_stroke: Stroke::new(1.0, border), + corner_radius: CornerRadius::same(RADIUS_SMALL), + fg_stroke: Stroke::new(1.0, foreground), + expansion: 0.0, + } +} + +fn visuals(tokens: &Tokens, theme: Theme) -> Visuals { + let mut visuals = theme.default_visuals(); + visuals.panel_fill = tokens.chrome; + visuals.window_fill = tokens.card; + visuals.window_stroke = Stroke::new(1.0, tokens.border); + visuals.window_corner_radius = CornerRadius::same(RADIUS_LARGE); + visuals.extreme_bg_color = tokens.field; + visuals.override_text_color = Some(tokens.text); + visuals.weak_text_color = Some(tokens.text_dim); + visuals.error_fg_color = tokens.danger; + visuals.selection = Selection { + bg_fill: tokens.accent, + stroke: Stroke::new(1.0, tokens.accent_text), + }; + visuals.widgets.noninteractive = widget_visuals(tokens.card, tokens.border, tokens.text_dim); + visuals.widgets.inactive = widget_visuals(tokens.field, tokens.border, tokens.text); + visuals.widgets.hovered = widget_visuals(tokens.hover, tokens.border, tokens.text); + visuals.widgets.active = widget_visuals(tokens.hover, tokens.accent, tokens.text); + visuals.widgets.open = widget_visuals(tokens.field, tokens.border, tokens.text); + visuals.slider_trailing_fill = true; + visuals +} + +fn style(theme: Theme) -> Style { + let tokens = match theme { + Theme::Dark => Tokens::dark(), + Theme::Light => Tokens::light(), + }; + let mut style = Style { + visuals: visuals(&tokens, theme), + ..Style::default() + }; + style.text_styles = [ + (TextStyle::Small, FontId::proportional(SIZE_SMALL)), + (TextStyle::Body, FontId::proportional(SIZE_BODY)), + (TextStyle::Button, FontId::proportional(SIZE_BODY)), + (TextStyle::Heading, semibold(SIZE_HEADING)), + (TextStyle::Monospace, FontId::monospace(SIZE_BODY)), + ] + .into(); + style.spacing.item_spacing = vec2(8.0, 6.0); + style.spacing.button_padding = vec2(10.0, 4.0); + style.spacing.interact_size = vec2(24.0, 24.0); + style +} + +pub fn apply(context: &Context) { + for theme in [Theme::Light, Theme::Dark] { + context.set_style_of(theme, Arc::new(style(theme))); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn each_egui_theme_gets_its_own_tokens() { + let context = Context::default(); + apply(&context); + assert_eq!( + context.style_of(Theme::Light).visuals.panel_fill, + Tokens::light().chrome + ); + assert_eq!( + context.style_of(Theme::Dark).visuals.panel_fill, + Tokens::dark().chrome + ); + assert!(!context.style_of(Theme::Light).visuals.dark_mode); + } +} diff --git a/crates/app/src/views/add_address.rs b/crates/app/src/views/add_address.rs new file mode 100644 index 0000000..286e34b --- /dev/null +++ b/crates/app/src/views/add_address.rs @@ -0,0 +1,126 @@ +//! The "+ Adresse" dialog: one IPv6 address (link-local with `%index`), port fixed to 45. + +use eframe::egui::{Id, Key, Modal, TextEdit, Ui}; +use neumann_control_core::address::{parse_address, AddressError, SSC_PORT}; +use neumann_control_core::settings::ManualSpeaker; + +use crate::texts; + +const MODAL_ID: &str = "add_address"; +const FIELD_WIDTH: f32 = 260.0; + +#[derive(Debug, Default)] +pub struct AddAddressForm { + text: String, + error: Option, + focused_once: bool, +} + +#[derive(Debug, PartialEq)] +pub enum AddAddressAction { + None, + Cancel, + Add(ManualSpeaker), +} + +pub fn show(ui: &mut Ui, form: &mut AddAddressForm) -> AddAddressAction { + let modal = Modal::new(Id::new(MODAL_ID)).show(ui.ctx(), |ui| contents(ui, form)); + if modal.should_close() { + return AddAddressAction::Cancel; + } + modal.inner +} + +fn contents(ui: &mut Ui, form: &mut AddAddressForm) -> AddAddressAction { + ui.heading(texts::ADD_ADDRESS_TITLE); + let label = ui.label(texts::ADDRESS_FIELD); + let field = ui + .horizontal(|ui| { + let field = ui + .add( + TextEdit::singleline(&mut form.text) + .hint_text(texts::ADDRESS_HINT) + .desired_width(FIELD_WIDTH), + ) + .labelled_by(label.id); + ui.label(texts::port(SSC_PORT)); + field + }) + .inner; + if !form.focused_once { + form.focused_once = true; + field.request_focus(); + } + if field.changed() { + form.error = None; + } + if let Some(error) = &form.error { + ui.colored_label( + ui.visuals().error_fg_color, + texts::describe_address_error(error), + ); + } + let submitted = field.lost_focus() && ui.input(|input| input.key_pressed(Key::Enter)); + let (added, cancelled) = ui + .horizontal(|ui| { + ( + ui.button(texts::ADD).clicked(), + ui.button(texts::CANCEL).clicked(), + ) + }) + .inner; + if cancelled { + return AddAddressAction::Cancel; + } + if !(added || submitted) { + return AddAddressAction::None; + } + submit(form) +} + +fn submit(form: &mut AddAddressForm) -> AddAddressAction { + let text = form.text.trim(); + match parse_address(text, SSC_PORT) { + Ok(_) => AddAddressAction::Add(ManualSpeaker { + address: text.to_owned(), + port: SSC_PORT, + serial: None, + }), + Err(error) => { + form.error = Some(error); + AddAddressAction::None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn form(text: &str) -> AddAddressForm { + AddAddressForm { + text: text.to_owned(), + ..AddAddressForm::default() + } + } + + #[test] + fn a_link_local_address_with_index_is_added_on_port_45_trimmed() { + let mut form = form(" fe80::2a36:38ff:fe12:3456%7 "); + assert_eq!( + submit(&mut form), + AddAddressAction::Add(ManualSpeaker { + address: "fe80::2a36:38ff:fe12:3456%7".to_owned(), + port: SSC_PORT, + serial: None, + }) + ); + } + + #[test] + fn a_link_local_address_without_index_keeps_the_dialog_with_its_reason() { + let mut form = form("fe80::2a36:38ff:fe12:3456"); + assert_eq!(submit(&mut form), AddAddressAction::None); + assert_eq!(form.error, Some(AddressError::MissingScope)); + } +} diff --git a/crates/app/src/views/card.rs b/crates/app/src/views/card.rs new file mode 100644 index 0000000..4c16618 --- /dev/null +++ b/crates/app/src/views/card.rs @@ -0,0 +1,292 @@ +//! One speaker as a channel strip: name, status, level fader with input meters, mute and +//! identify, the "Mehr" toggle and a status line. + +use std::ops::RangeInclusive; + +use eframe::egui::{ + self, pos2, vec2, Button, CornerRadius, Event, Label, Rect, RichText, Sense, Slider, Stroke, + Ui, Vec2, +}; +use neumann_control_core::catalog::{Catalog, Entry}; +use neumann_control_core::mask::mask_serial; +use neumann_control_core::speaker::Command; +use neumann_control_core::ssc::wire::SscPath; +use serde_json::Value; + +use crate::state::{ + fraction, output_level_range, SpeakerState, Status, OUTPUT_LEVEL_PATH, WHEEL_STEP_DB, +}; +use crate::texts; +use crate::ui_kit::theme::Tokens; + +const CARD_WIDTH: f32 = 170.0; +const CARD_MARGIN: i8 = 8; +const CARD_RADIUS: u8 = 6; +const BORDER_WIDTH: f32 = 1.0; +const FADER_HEIGHT: f32 = 150.0; +const METER_WIDTH: f32 = 8.0; +const CLIP_LAMP_RADIUS: f32 = 3.5; +const CLIP_LAMP_SPACE: f32 = 10.0; +const PEAK_LINE_WIDTH: f32 = 2.0; +const STATUS_DOT_RADIUS: f32 = 4.0; +const UNREACHABLE_OPACITY: f32 = 0.55; + +const MUTE_PATH: &str = "audio/out/mute"; +const IDENTIFY_PATH: &str = "device/identification/visual"; +const INPUT_CLIP_PATH: &str = "m/in/clip"; + +/// Draws the card and returns the writes the user asked for, already checked against the +/// catalog for this model. +pub fn show(ui: &mut Ui, speaker: &mut SpeakerState, catalog: &Catalog) -> Vec { + let tokens = Tokens::current(ui.ctx()); + let mut writer = Writer { + catalog, + product: speaker.model().unwrap_or_default().to_owned(), + connected: speaker.status == Status::Connected, + commands: Vec::new(), + }; + let inner_width = CARD_WIDTH - 2.0 * (f32::from(CARD_MARGIN) + BORDER_WIDTH); + egui::Frame::new() + .fill(tokens.card) + .stroke(Stroke::new(BORDER_WIDTH, tokens.border)) + .corner_radius(CornerRadius::same(CARD_RADIUS)) + .inner_margin(CARD_MARGIN) + .show(ui, |ui| { + ui.set_width(inner_width); + if speaker.status == Status::Unreachable { + ui.multiply_opacity(UNREACHABLE_OPACITY); + } + header(ui, speaker, &tokens); + strip(ui, speaker, &mut writer, &tokens); + buttons(ui, speaker, &mut writer); + more_toggle(ui, speaker); + status_line(ui, speaker, &tokens); + }); + writer.commands +} + +/// Collects the card's writes; a write goes out only to a connected speaker of a model the +/// catalog lists as writable for the path, with a value the catalog accepts. +struct Writer<'a> { + catalog: &'a Catalog, + product: String, + connected: bool, + commands: Vec, +} + +impl Writer<'_> { + fn entry(&self, path: &SscPath) -> Option<&Entry> { + self.catalog + .entries + .iter() + .find(|entry| entry.path == *path) + } + + fn can_write(&self, path: &SscPath) -> bool { + let writable = self + .entry(path) + .is_some_and(|entry| entry.is_writable(&self.product)); + self.connected && writable + } + + fn set(&mut self, path: SscPath, value: Value) { + if !self.can_write(&path) { + return; + } + let Some(entry) = self.entry(&path) else { + return; + }; + if let Err(error) = entry.validate(&value, &self.product, &self.catalog.classes) { + log::warn!("not sending {path}: {error}"); + return; + } + self.commands.push(Command::Set { path, value }); + } +} + +fn constant_path(text: &str) -> SscPath { + SscPath::parse(text).expect("constant path is valid") +} + +fn header(ui: &mut Ui, speaker: &SpeakerState, tokens: &Tokens) { + let name = speaker.display_name(); + ui.horizontal(|ui| { + status_dot(ui, speaker.status, tokens); + ui.add(Label::new(RichText::new(&name).strong()).truncate()); + }); + let model = speaker.model().filter(|model| *model != name); + if let Some(model) = model { + ui.add(Label::new(RichText::new(model).weak()).truncate()); + } +} + +fn status_dot(ui: &mut Ui, status: Status, tokens: &Tokens) { + let (color, text) = match status { + Status::Connected => (tokens.connected, texts::CONNECTED), + Status::Connecting => (tokens.connecting, texts::CONNECTING), + Status::Unreachable => (tokens.unreachable, texts::UNREACHABLE), + }; + let size = Vec2::splat(2.0 * STATUS_DOT_RADIUS); + let (rect, response) = ui.allocate_exact_size(size, Sense::hover()); + ui.painter() + .circle_filled(rect.center(), STATUS_DOT_RADIUS, color); + response.on_hover_text(text); +} + +fn strip(ui: &mut Ui, speaker: &mut SpeakerState, writer: &mut Writer, tokens: &Tokens) { + let path = constant_path(OUTPUT_LEVEL_PATH); + let device = speaker.values.get(&path).and_then(Value::as_f64); + let range = output_level_range(); + let shown = ui + .horizontal(|ui| { + let shown = fader(ui, speaker, writer, device, range); + meters(ui, speaker, tokens); + shown + }) + .inner; + let unit = writer + .entry(&path) + .and_then(|entry| entry.unit.clone()) + .unwrap_or_default(); + let readout = device.map(|_| texts::readout(shown, &unit)); + ui.label(readout.unwrap_or_default()); +} + +/// The vertical level fader; returns the value it shows. +fn fader( + ui: &mut Ui, + speaker: &mut SpeakerState, + writer: &mut Writer, + device: Option, + range: RangeInclusive, +) -> f64 { + let path = constant_path(OUTPUT_LEVEL_PATH); + let enabled = device.is_some() && writer.can_write(&path); + let device = device.unwrap_or(*range.start()); + let now = ui.input(|input| input.time); + let mut value = speaker.fader.shown(device); + ui.spacing_mut().slider_width = FADER_HEIGHT; + let slider = Slider::new(&mut value, range.clone()) + .vertical() + .show_value(false) + .step_by(WHEEL_STEP_DB); + let response = ui.add_enabled(enabled, slider); + // While dragging, the slider follows the pointer and the speaker hears nothing. The release + // frame still moves the value but no longer counts as dragged, so this one condition sends + // the final value of a drag, a click on the track and a key press alike. + let settled = response.changed() && !response.dragged(); + if settled { + writer.set(path.clone(), Value::from(value)); + } + if enabled && response.hovered() { + let steps = wheel_steps(ui); + // The cards sit in a scroll area, which would scroll along with the fader. + ui.input_mut(|input| input.smooth_scroll_delta = Vec2::ZERO); + if steps != 0 { + speaker.fader.on_wheel(steps, device, range, now); + value = speaker.fader.shown(device); + } + } + if let Some(due) = speaker.fader.due(now) { + writer.set(path, Value::from(due)); + } + if let Some(deadline) = speaker.fader.deadline() { + let wait = (deadline - now).max(0.0); + ui.ctx().request_repaint_after_secs(wait as f32); + } + value +} + +/// One step per wheel event, upwards raising the level, whatever unit the device reports. +fn wheel_steps(ui: &Ui) -> i32 { + ui.input(|input| { + input + .events + .iter() + .filter_map(|event| match event { + Event::MouseWheel { delta, .. } if delta.y != 0.0 => Some(delta.y.signum() as i32), + _ => None, + }) + .sum() + }) +} + +fn meters(ui: &mut Ui, speaker: &SpeakerState, tokens: &Tokens) { + let now = ui.input(|input| input.time); + let clips = speaker + .values + .get(&constant_path(INPUT_CLIP_PATH)) + .and_then(Value::as_array); + for (channel, meter) in speaker.input_meters.iter().enumerate() { + let clipped = clips + .and_then(|clips| clips.get(channel)) + .and_then(Value::as_bool) + .unwrap_or(false); + let size = vec2(METER_WIDTH, FADER_HEIGHT); + let (rect, response) = ui.allocate_exact_size(size, Sense::hover()); + let lamp_color = if clipped { + tokens.danger + } else { + tokens.clip_off + }; + let lamp_center = pos2(rect.center().x, rect.top() + CLIP_LAMP_RADIUS); + let track = Rect::from_min_max(pos2(rect.left(), rect.top() + CLIP_LAMP_SPACE), rect.max); + let painter = ui.painter(); + painter.circle_filled(lamp_center, CLIP_LAMP_RADIUS, lamp_color); + painter.rect_filled(track, CornerRadius::ZERO, tokens.meter_track); + let level_top = track.bottom() - track.height() * fraction(meter.level()); + let level = Rect::from_min_max(pos2(track.left(), level_top), track.max); + painter.rect_filled(level, CornerRadius::ZERO, tokens.meter_fill); + let peak_y = track.bottom() - track.height() * fraction(meter.peak(now)); + painter.hline( + track.x_range(), + peak_y, + Stroke::new(PEAK_LINE_WIDTH, tokens.text_dim), + ); + if clipped { + response.on_hover_text(texts::CLIP); + } + } +} + +fn buttons(ui: &mut Ui, speaker: &SpeakerState, writer: &mut Writer) { + ui.horizontal(|ui| { + toggle(ui, speaker, writer, MUTE_PATH, texts::MUTE); + toggle(ui, speaker, writer, IDENTIFY_PATH, texts::IDENTIFY); + }); +} + +/// A button showing a switch value of the speaker; a click writes the opposite. +fn toggle(ui: &mut Ui, speaker: &SpeakerState, writer: &mut Writer, path: &str, text: &str) { + let path = constant_path(path); + let current = speaker.values.get(&path).and_then(Value::as_bool); + let enabled = current.is_some() && writer.can_write(&path); + let on = current.unwrap_or(false); + let clicked = ui + .add_enabled(enabled, Button::new(text).selected(on)) + .clicked(); + if clicked { + writer.set(path, Value::Bool(!on)); + } +} + +fn more_toggle(ui: &mut Ui, speaker: &mut SpeakerState) { + let text = if speaker.expanded { + texts::LESS + } else { + texts::MORE + }; + if ui.button(text).clicked() { + speaker.expanded = !speaker.expanded; + } +} + +fn status_line(ui: &mut Ui, speaker: &SpeakerState, tokens: &Tokens) { + if let Some(identity) = &speaker.identity { + let line = texts::status_line(&identity.version, &mask_serial(&identity.serial)); + ui.add(Label::new(RichText::new(line).small().weak()).wrap()); + } + if let Some(message) = &speaker.message { + ui.add(Label::new(RichText::new(message).small().color(tokens.danger)).wrap()); + } +} diff --git a/crates/app/src/views/mod.rs b/crates/app/src/views/mod.rs new file mode 100644 index 0000000..8412aa1 --- /dev/null +++ b/crates/app/src/views/mod.rs @@ -0,0 +1,3 @@ +pub mod add_address; +pub mod card; +pub mod toolbar; diff --git a/crates/app/src/views/toolbar.rs b/crates/app/src/views/toolbar.rs new file mode 100644 index 0000000..b15f85f --- /dev/null +++ b/crates/app/src/views/toolbar.rs @@ -0,0 +1,49 @@ +//! The top bar: "Suchen", "+ Adresse", the speaker count and a notice when something failed. + +use eframe::egui::{self, Button, Ui}; + +use crate::texts; + +const PANEL_ID: &str = "toolbar"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ToolbarAction { + None, + Scan, + AddAddress, +} + +pub struct ToolbarFacts<'a> { + pub scanning: bool, + pub speaker_count: usize, + pub notice: Option<&'a str>, +} + +pub fn show(ui: &mut Ui, facts: &ToolbarFacts) -> ToolbarAction { + egui::Panel::top(PANEL_ID) + .show(ui, |ui| ui.horizontal(|ui| controls(ui, facts)).inner) + .inner +} + +fn controls(ui: &mut Ui, facts: &ToolbarFacts) -> ToolbarAction { + let mut action = ToolbarAction::None; + if ui + .add_enabled(!facts.scanning, Button::new(texts::SCAN)) + .clicked() + { + action = ToolbarAction::Scan; + } + if facts.scanning { + ui.spinner(); + } + if ui.button(texts::ADD_ADDRESS).clicked() { + action = ToolbarAction::AddAddress; + } + ui.separator(); + ui.label(texts::speaker_count(facts.speaker_count)); + if let Some(notice) = facts.notice { + ui.separator(); + ui.colored_label(ui.visuals().error_fg_color, notice); + } + action +} diff --git a/crates/app/tests/views.rs b/crates/app/tests/views.rs new file mode 100644 index 0000000..54bc33d --- /dev/null +++ b/crates/app/tests/views.rs @@ -0,0 +1,399 @@ +//! The app window driven through egui_kittest, with real speaker workers talking to the +//! simulator. Threads deliver the events, so the tests step frames until a condition holds +//! (bounded by a deadline) instead of `run()`. + +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{self, Receiver}; +use std::sync::Arc; +use std::thread; +use std::time::{Duration, Instant}; + +use eframe::egui::{self, accesskit::Role, FontFamily, FontId, MouseWheelUnit, TouchPhase}; +use egui_kittest::kittest::{NodeT, Queryable}; +use egui_kittest::Harness; +use neumann_control_app::app::NeumannApp; +use neumann_control_app::spawner::{ScanResult, SpeakerSpawner}; +use neumann_control_app::texts; +use neumann_control_app::ui_kit::fonts; +use neumann_control_core::address::AddressError; +use neumann_control_core::catalog::{Catalog, ValueError}; +use neumann_control_core::discovery::Found; +use neumann_control_core::mask::mask_serial; +use neumann_control_core::settings::{self, ManualSpeaker, Settings, SETTINGS_FILE}; +use neumann_control_core::simulator::{SimModel, Simulator}; +use neumann_control_core::speaker::{self, Event, SpeakerHandle, Target, Timing}; +use serde_json::Value; + +const SIMULATED_SERIAL: &str = "SIM0001234"; +const WAIT_LIMIT: Duration = Duration::from_secs(15); +// Paces the polling loop only; every outcome is decided by the condition. +const POLL_PAUSE: Duration = Duration::from_millis(10); +const WINDOW_SIZE: [f32; 2] = [900.0, 700.0]; +const LOOPBACK: &str = "::1"; + +struct SimulatorSpawner { + found: Vec, +} + +impl SpeakerSpawner for SimulatorSpawner { + fn spawn( + &self, + target: Target, + wake: Arc, + ) -> (SpeakerHandle, Receiver) { + speaker::spawn(target, Timing::default(), wake) + } + + fn scan(&self) -> Receiver { + let (sender, receiver) = mpsc::channel(); + sender.send(Ok(self.found.clone())).unwrap(); + receiver + } +} + +fn manual_for(simulator: &Simulator) -> ManualSpeaker { + ManualSpeaker { + address: LOOPBACK.to_owned(), + port: simulator.address().port(), + serial: None, + } +} + +fn settings_file(directory: &Path, manual: Vec) -> PathBuf { + let path = directory.join(SETTINGS_FILE); + let settings = Settings { + manual, + window: None, + }; + settings::save(&settings, &path).unwrap(); + path +} + +fn harness(settings_path: PathBuf, found: Vec) -> Harness<'static, NeumannApp> { + Harness::builder() + .with_size(WINDOW_SIZE) + .build_eframe(move |creation_context| { + NeumannApp::new( + &creation_context.egui_ctx, + settings_path, + Box::new(SimulatorSpawner { found }), + ) + }) +} + +fn wait_until( + harness: &mut Harness<'static, NeumannApp>, + what: &str, + condition: impl Fn(&Harness<'static, NeumannApp>) -> bool, +) { + let deadline = Instant::now() + WAIT_LIMIT; + loop { + harness.step(); + if condition(harness) { + return; + } + assert!(Instant::now() < deadline, "timed out waiting for {what}"); + thread::sleep(POLL_PAUSE); + } +} + +fn readout(value: f64) -> String { + texts::readout(value, "dB SPL") +} + +fn wait_for_level(harness: &mut Harness<'static, NeumannApp>, value: f64) { + let label = readout(value); + wait_until(harness, &label, |harness| { + harness.query_by_label(&label).is_some() + }); +} + +fn level_sets(simulator: &Simulator) -> Vec { + simulator + .requests() + .iter() + .filter_map(|request| request.pointer("/audio/out/level").and_then(Value::as_f64)) + .collect() +} + +fn connected_card(model: SimModel) -> (Simulator, tempfile::TempDir, Harness<'static, NeumannApp>) { + let simulator = Simulator::start(model); + let directory = tempfile::tempdir().unwrap(); + let path = settings_file(directory.path(), vec![manual_for(&simulator)]); + let mut harness = harness(path, Vec::new()); + wait_for_level(&mut harness, 100.0); + (simulator, directory, harness) +} + +#[test] +fn card_shows_level_and_name() { + let (_simulator, _directory, harness) = connected_card(SimModel::Kh120Ii); + harness.get_by_label("KH 120 II"); + harness.get_by_label("Simulated KH 120 II"); + harness.get_by_label(&readout(100.0)); + harness.get_by_label_contains(&mask_serial(SIMULATED_SERIAL)); + assert!(harness.query_by_label_contains(SIMULATED_SERIAL).is_none()); + harness.get_by_label(&texts::speaker_count(1)); +} + +#[test] +fn fader_drag_sends_once_on_release() { + let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + let rect = harness.get_by_role(Role::Slider).rect(); + let start = rect.center(); + harness.drag_at(start); + for frame in 1..=10 { + harness.hover_at(start + egui::vec2(0.0, 4.0 * frame as f32)); + harness.step(); + } + assert!( + level_sets(&simulator).is_empty(), + "a Set went out while dragging" + ); + let shown = harness + .get_by_role(Role::Slider) + .accesskit_node() + .numeric_value() + .unwrap(); + harness.drop_at(start + egui::vec2(0.0, 40.0)); + wait_until(&mut harness, "the Set after release", |_| { + !level_sets(&simulator).is_empty() + }); + harness.run_steps(10); + let sets = level_sets(&simulator); + assert_eq!(sets.len(), 1, "{sets:?}"); + assert!(sets[0] < 100.0, "dragging down lowers the level: {sets:?}"); + assert_eq!(shown, sets[0]); +} + +#[test] +fn wheel_over_fader_sends_one_set_after_the_wheel_went_quiet() { + let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + let center = harness.get_by_role(Role::Slider).rect().center(); + harness.hover_at(center); + for _ in 0..2 { + harness.event(egui::Event::MouseWheel { + unit: MouseWheelUnit::Line, + delta: egui::vec2(0.0, 1.0), + phase: TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + } + harness.step(); + assert!(level_sets(&simulator).is_empty(), "the wheel wrote at once"); + harness.get_by_label(&readout(101.0)); + wait_until(&mut harness, "the wheel Set", |_| { + !level_sets(&simulator).is_empty() + }); + harness.run_steps(10); + assert_eq!(level_sets(&simulator), vec![101.0]); +} + +#[test] +fn mute_button_sends_set() { + let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + harness.get_by_label(texts::MUTE).click(); + wait_until(&mut harness, "mute on the device", |_| { + simulator.value("audio/out/mute") == Some(Value::Bool(true)) + }); +} + +#[test] +fn identify_button_sends_set() { + let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + harness.get_by_label(texts::IDENTIFY).click(); + wait_until(&mut harness, "identification on the device", |_| { + simulator.value("device/identification/visual") == Some(Value::Bool(true)) + }); +} + +#[test] +fn more_toggles_to_less_and_back() { + let (_simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + harness.get_by_label(texts::MORE).click(); + harness.run_steps(2); + harness.get_by_label(texts::LESS).click(); + harness.run_steps(2); + harness.get_by_label(texts::MORE); +} + +#[test] +fn unreachable_card_is_disabled() { + let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + simulator.stop(); + wait_until(&mut harness, "the unreachable text", |harness| { + harness.query_by_label(texts::UNREACHABLE).is_some() + }); + assert!(harness + .get_by_label(texts::MUTE) + .accesskit_node() + .is_disabled()); + assert!(harness + .get_by_label(texts::IDENTIFY) + .accesskit_node() + .is_disabled()); + assert!(harness + .get_by_role(Role::Slider) + .accesskit_node() + .is_disabled()); +} + +#[test] +fn add_address_refuses_ipv4_with_hint() { + let directory = tempfile::tempdir().unwrap(); + let path = settings_file(directory.path(), Vec::new()); + let mut harness = harness(path.clone(), Vec::new()); + harness.get_by_label(texts::ADD_ADDRESS).click(); + harness.run_steps(2); + harness.get_by_label(texts::ADDRESS_FIELD).click(); + harness.run_steps(1); + harness + .get_by_label(texts::ADDRESS_FIELD) + .type_text("192.168.1.20"); + harness.run_steps(1); + harness.get_by_label(texts::ADD).click(); + harness.run_steps(2); + harness.get_by_label(&texts::describe_address_error( + &AddressError::Ipv4NotSupported, + )); + assert!(settings::load(&path).manual.is_empty()); + harness.get_by_label(&texts::speaker_count(0)); +} + +#[test] +fn add_address_saves_the_speaker_and_shows_its_card() { + let directory = tempfile::tempdir().unwrap(); + let path = settings_file(directory.path(), Vec::new()); + let mut harness = harness(path.clone(), Vec::new()); + harness.get_by_label(texts::ADD_ADDRESS).click(); + harness.run_steps(2); + harness.get_by_label(texts::ADDRESS_FIELD).click(); + harness.run_steps(1); + harness + .get_by_label(texts::ADDRESS_FIELD) + .type_text(LOOPBACK); + harness.run_steps(1); + harness.get_by_label(texts::ADD).click(); + harness.run_steps(2); + assert!(harness.query_by_label(texts::ADD_ADDRESS_TITLE).is_none()); + let saved = settings::load(&path).manual; + assert_eq!(saved.len(), 1); + assert_eq!(saved[0].address, LOOPBACK); + assert_eq!(saved[0].port, neumann_control_core::address::SSC_PORT); + harness.get_by_label(&texts::speaker_count(1)); +} + +#[test] +fn scan_adds_found_speakers_once() { + let simulator = Simulator::start(SimModel::Kh750); + let directory = tempfile::tempdir().unwrap(); + let path = settings_file(directory.path(), Vec::new()); + let found = Found { + name: String::new(), + product: Some("KH 750".to_owned()), + serial: Some("SIM0007500".to_owned()), + address: simulator.address(), + }; + let mut harness = harness(path, vec![found]); + harness.get_by_label(texts::SCAN).click(); + wait_for_level(&mut harness, 100.0); + harness.get_by_label("KH 750"); + harness.get_by_label(texts::MORE).click(); + harness.run_steps(2); + harness.get_by_label(texts::SCAN).click(); + harness.run_steps(5); + harness.get_by_label(&texts::speaker_count(1)); + // The card kept its state, so it was not recreated. + harness.get_by_label(texts::LESS); +} + +/// Every string literal in texts.rs, so a new text is covered without being listed here. +fn literals_of_texts_module() -> Vec { + let source = include_str!("../src/texts.rs"); + let mut literals = Vec::new(); + let mut rest = source; + while let Some(start) = rest.find('"') { + let after = &rest[start + 1..]; + let end = after.find('"').expect("closed literal"); + literals.push(after[..end].to_owned()); + rest = &after[end + 1..]; + } + literals +} + +#[test] +fn all_ui_texts_have_glyphs() { + let context = egui::Context::default(); + fonts::install(&context); + context + .run_ui(egui::RawInput::default(), |_| {}) + .textures_delta + .clear(); + let catalog = Catalog::embedded(); + let mut texts_to_check = literals_of_texts_module(); + texts_to_check.extend([ + texts::speaker_count(12), + texts::port(45), + texts::readout(-12.5, "dB SPL"), + texts::status_line("1_7_3", &mask_serial(SIMULATED_SERIAL)), + texts::serial_mismatch(&mask_serial(SIMULATED_SERIAL)), + texts::describe_rejection(405), + texts::describe_rejection(406), + texts::describe_rejection(500), + ]); + for error in [ + AddressError::Ipv4NotSupported, + AddressError::MissingScope, + AddressError::ScopeNotNumeric, + AddressError::Invalid, + ] { + texts_to_check.push(texts::describe_address_error(&error)); + } + for error in [ + ValueError::OutOfRange { min: 0.0, max: 1.0 }, + ValueError::NotAnOption, + ValueError::NotAStep, + ValueError::TooLong(52), + ValueError::NotPrintableAscii, + ValueError::WrongType, + ] { + texts_to_check.push(texts::describe_value_error(&error)); + } + for entry in &catalog.entries { + texts_to_check.push(entry.group.title().to_owned()); + texts_to_check.push(entry.label.clone()); + texts_to_check.extend(entry.unit.clone()); + } + let families = [FontFamily::Proportional, fonts::semibold(12.0).family]; + for family in families { + let font = FontId::new(12.0, family.clone()); + for text in &texts_to_check { + let covered = context.fonts_mut(|fonts| fonts.has_glyphs(&font, text)); + assert!(covered, "{text:?} in {family:?}"); + } + } + assert!(texts_to_check.iter().any(|text| text == texts::NO_SPEAKERS)); +} + +#[test] +fn scan_moves_a_known_speaker_to_its_new_address() { + let simulator = Simulator::start(SimModel::Kh120Ii); + let directory = tempfile::tempdir().unwrap(); + let stale = ManualSpeaker { + address: LOOPBACK.to_owned(), + port: neumann_control_core::address::SSC_PORT, + serial: Some(SIMULATED_SERIAL.to_owned()), + }; + let path = settings_file(directory.path(), vec![stale]); + let found = Found { + name: "announced".to_owned(), + product: None, + serial: Some(SIMULATED_SERIAL.to_owned()), + address: simulator.address(), + }; + let mut harness = harness(path, vec![found]); + harness.get_by_label(&texts::speaker_count(1)); + harness.get_by_label(texts::SCAN).click(); + wait_for_level(&mut harness, 100.0); + harness.get_by_label(&texts::speaker_count(1)); +} From 23e8a0cfa2c726e5197e3e1b84c91bd8cd549476 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:12:57 +0200 Subject: [PATCH 018/460] Step the fader per whole wheel notch --- crates/app/src/state.rs | 48 ++++++++++++++++++++++++++++-------- crates/app/src/views/card.rs | 47 +++++++++++++++++++++++++++-------- 2 files changed, 75 insertions(+), 20 deletions(-) diff --git a/crates/app/src/state.rs b/crates/app/src/state.rs index f26bb20..b0b26c4 100644 --- a/crates/app/src/state.rs +++ b/crates/app/src/state.rs @@ -302,15 +302,29 @@ impl MeterState { pub struct FaderState { pending: Option, last_step: f64, + /// Wheel movement short of a whole notch, in 1/NOTCH_RESOLUTION notches. + partial_notch: i64, } +/// Fractions of a notch are counted in whole units so ten tenths make exactly one notch. +const NOTCH_RESOLUTION: f64 = 1000.0; + impl FaderState { /// `range` is the allowed value range of the faded entry. - pub fn on_wheel(&mut self, steps: i32, current: f64, range: RangeInclusive, now: f64) { + /// `notches` may be fractional (touchpads, high-resolution wheels); one step of + /// WHEEL_STEP_DB per whole notch, the rest is kept for the next event. + pub fn on_wheel(&mut self, notches: f64, current: f64, range: RangeInclusive, now: f64) { + self.partial_notch += (notches * NOTCH_RESOLUTION).round() as i64; + let resolution = NOTCH_RESOLUTION as i64; + let whole = self.partial_notch / resolution; + self.partial_notch %= resolution; + self.last_step = now; + if whole == 0 { + return; + } let base = self.pending.unwrap_or(current); - let target = base + f64::from(steps) * WHEEL_STEP_DB; + let target = base + whole as f64 * WHEEL_STEP_DB; self.pending = Some(target.clamp(*range.start(), *range.end())); - self.last_step = now; } pub fn due(&mut self, now: f64) -> Option { @@ -591,9 +605,9 @@ mod tests { fn wheel_steps_debounce_to_one_value() { let range = output_level_range(); let mut fader = FaderState::default(); - fader.on_wheel(1, 80.0, range.clone(), 1.0); - fader.on_wheel(1, 80.0, range.clone(), 1.125); - fader.on_wheel(1, 80.0, range, 1.25); + fader.on_wheel(1.0, 80.0, range.clone(), 1.0); + fader.on_wheel(1.0, 80.0, range.clone(), 1.125); + fader.on_wheel(1.0, 80.0, range, 1.25); assert_eq!(fader.shown(80.0), 81.5); assert_eq!(fader.due(1.5), None); assert_eq!(fader.due(1.625), Some(81.5)); @@ -606,8 +620,8 @@ mod tests { let range = output_level_range(); let mut fader = FaderState::default(); assert_eq!(fader.deadline(), None); - fader.on_wheel(1, 80.0, range.clone(), 1.0); - fader.on_wheel(1, 80.0, range, 1.5); + fader.on_wheel(1.0, 80.0, range.clone(), 1.0); + fader.on_wheel(1.0, 80.0, range, 1.5); assert_eq!(fader.deadline(), Some(1.5 + WHEEL_IDLE_SECONDS)); fader.due(2.0); assert_eq!(fader.deadline(), None); @@ -655,14 +669,28 @@ mod tests { assert_eq!(speaker.model(), Some("KH 120 II")); } + #[test] + fn fractional_wheel_events_add_up_to_whole_notches() { + let range = output_level_range(); + let mut fader = FaderState::default(); + for _ in 0..10 { + fader.on_wheel(0.1, 80.0, range.clone(), 1.0); + } + assert_eq!(fader.shown(80.0), 80.5); + fader.on_wheel(0.5, 80.0, range.clone(), 1.0); + assert_eq!(fader.shown(80.0), 80.5); + fader.on_wheel(0.5, 80.0, range, 1.0); + assert_eq!(fader.shown(80.0), 81.0); + } + #[test] fn wheel_clamps_at_120() { let range = output_level_range(); assert_eq!(range, 0.0..=120.0); let mut fader = FaderState::default(); - fader.on_wheel(10, 119.0, range.clone(), 0.0); + fader.on_wheel(10.0, 119.0, range.clone(), 0.0); assert_eq!(fader.due(1.0), Some(120.0)); - fader.on_wheel(-1000, 1.0, range, 2.0); + fader.on_wheel(-1000.0, 1.0, range, 2.0); assert_eq!(fader.due(3.0), Some(0.0)); } } diff --git a/crates/app/src/views/card.rs b/crates/app/src/views/card.rs index 4c16618..4ce1079 100644 --- a/crates/app/src/views/card.rs +++ b/crates/app/src/views/card.rs @@ -4,8 +4,8 @@ use std::ops::RangeInclusive; use eframe::egui::{ - self, pos2, vec2, Button, CornerRadius, Event, Label, Rect, RichText, Sense, Slider, Stroke, - Ui, Vec2, + self, pos2, vec2, Button, CornerRadius, Event, Label, MouseWheelUnit, Rect, RichText, Sense, + Slider, Stroke, Ui, Vec2, }; use neumann_control_core::catalog::{Catalog, Entry}; use neumann_control_core::mask::mask_serial; @@ -179,13 +179,12 @@ fn fader( writer.set(path.clone(), Value::from(value)); } if enabled && response.hovered() { - let steps = wheel_steps(ui); // The cards sit in a scroll area, which would scroll along with the fader. ui.input_mut(|input| input.smooth_scroll_delta = Vec2::ZERO); - if steps != 0 { - speaker.fader.on_wheel(steps, device, range, now); - value = speaker.fader.shown(device); + for notches in wheel_notches(ui) { + speaker.fader.on_wheel(notches, device, range.clone(), now); } + value = speaker.fader.shown(device); } if let Some(due) = speaker.fader.due(now) { writer.set(path, Value::from(due)); @@ -197,20 +196,36 @@ fn fader( value } -/// One step per wheel event, upwards raising the level, whatever unit the device reports. -fn wheel_steps(ui: &Ui) -> i32 { +/// Lines and pixels of a precision touchpad come as many small events, pages as rare large +/// ones; egui scrolls this many points per line. +const POINTS_PER_NOTCH: f32 = 40.0; +const NOTCHES_PER_PAGE: f32 = 10.0; + +/// Each wheel event of this frame in notches (lines), upwards positive. +fn wheel_notches(ui: &Ui) -> Vec { ui.input(|input| { input .events .iter() .filter_map(|event| match event { - Event::MouseWheel { delta, .. } if delta.y != 0.0 => Some(delta.y.signum() as i32), + Event::MouseWheel { unit, delta, .. } if delta.y != 0.0 => { + Some(notches(*unit, delta.y)) + } _ => None, }) - .sum() + .collect() }) } +fn notches(unit: MouseWheelUnit, delta: f32) -> f64 { + let lines = match unit { + MouseWheelUnit::Line => delta, + MouseWheelUnit::Point => delta / POINTS_PER_NOTCH, + MouseWheelUnit::Page => delta * NOTCHES_PER_PAGE, + }; + f64::from(lines) +} + fn meters(ui: &mut Ui, speaker: &SpeakerState, tokens: &Tokens) { let now = ui.input(|input| input.time); let clips = speaker @@ -290,3 +305,15 @@ fn status_line(ui: &mut Ui, speaker: &SpeakerState, tokens: &Tokens) { ui.add(Label::new(RichText::new(message).small().color(tokens.danger)).wrap()); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wheel_units_become_notches() { + assert_eq!(notches(MouseWheelUnit::Line, 0.25), 0.25); + assert_eq!(notches(MouseWheelUnit::Point, -80.0), -2.0); + assert_eq!(notches(MouseWheelUnit::Page, 1.0), 10.0); + } +} From 08b0df8f246b6fdbbf629ce0a1d4a4ba536a6e44 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:17:28 +0200 Subject: [PATCH 019/460] Move write policy out of the views Views report intents; writes.rs owns catalog checks and refusals, paths.rs the named SSC paths. state.rs split into meter, fader and targets. --- crates/app/src/app.rs | 3 +- crates/app/src/fader.rs | 123 +++++++++++++ crates/app/src/lib.rs | 5 + crates/app/src/meter.rs | 79 +++++++++ crates/app/src/paths.rs | 39 +++++ crates/app/src/state.rs | 319 +--------------------------------- crates/app/src/targets.rs | 143 +++++++++++++++ crates/app/src/texts.rs | 11 ++ crates/app/src/views/card.rs | 230 ++++++------------------ crates/app/src/views/fader.rs | 103 +++++++++++ crates/app/src/views/mod.rs | 1 + crates/app/src/writes.rs | 212 ++++++++++++++++++++++ 12 files changed, 781 insertions(+), 487 deletions(-) create mode 100644 crates/app/src/fader.rs create mode 100644 crates/app/src/meter.rs create mode 100644 crates/app/src/paths.rs create mode 100644 crates/app/src/targets.rs create mode 100644 crates/app/src/views/fader.rs create mode 100644 crates/app/src/writes.rs diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index ee3997a..3bbcbb9 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -16,7 +16,8 @@ use neumann_control_core::settings::{self, ManualSpeaker, Settings, WindowGeomet use neumann_control_core::speaker::{Command, Event, SpeakerHandle, Target}; use crate::spawner::{ScanResult, SpeakerSpawner}; -use crate::state::{merge_targets, SpeakerKey, SpeakerState}; +use crate::state::{SpeakerKey, SpeakerState}; +use crate::targets::merge_targets; use crate::texts; use crate::ui_kit::{fonts, theme}; use crate::views::add_address::{self, AddAddressAction, AddAddressForm}; diff --git a/crates/app/src/fader.rs b/crates/app/src/fader.rs new file mode 100644 index 0000000..26c196a --- /dev/null +++ b/crates/app/src/fader.rs @@ -0,0 +1,123 @@ +//! The level fader's wheel: steps collect until the wheel goes quiet, then one write goes out. +//! Times are seconds of the UI clock. + +use std::ops::RangeInclusive; + +use neumann_control_core::catalog::Catalog; + +use crate::paths; + +pub const WHEEL_STEP_DB: f64 = 0.5; +pub const WHEEL_IDLE_SECONDS: f64 = 0.3; +/// Fractions of a notch are counted in whole units so ten tenths make exactly one notch. +const NOTCH_RESOLUTION: f64 = 1000.0; + +/// The catalog range of the output level, which the fader drives. +pub fn output_level_range() -> RangeInclusive { + let path = paths::path(paths::OUTPUT_LEVEL); + let entry = + paths::catalog_entry(Catalog::embedded(), &path).expect("catalog has the output level"); + let min = entry.min.expect("output level has a minimum"); + let max = entry.max.expect("output level has a maximum"); + min..=max +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct FaderState { + pending: Option, + last_step: f64, + /// Wheel movement short of a whole notch, in 1/NOTCH_RESOLUTION notches. + partial_notch: i64, +} + +impl FaderState { + /// `notches` may be fractional (touchpads, high-resolution wheels); one step of + /// WHEEL_STEP_DB per whole notch, the rest is kept for the next event. `range` is the + /// allowed value range of the faded entry. + pub fn on_wheel(&mut self, notches: f64, current: f64, range: RangeInclusive, now: f64) { + self.partial_notch += (notches * NOTCH_RESOLUTION).round() as i64; + let resolution = NOTCH_RESOLUTION as i64; + let whole = self.partial_notch / resolution; + self.partial_notch %= resolution; + self.last_step = now; + if whole == 0 { + return; + } + let base = self.pending.unwrap_or(current); + let target = base + whole as f64 * WHEEL_STEP_DB; + self.pending = Some(target.clamp(*range.start(), *range.end())); + } + + pub fn due(&mut self, now: f64) -> Option { + let idle = now - self.last_step >= WHEEL_IDLE_SECONDS; + if self.pending.is_some() && idle { + return self.pending.take(); + } + None + } + + /// When the pending wheel value becomes due, so the UI can wake up for it. + pub fn deadline(&self) -> Option { + self.pending.map(|_| self.last_step + WHEEL_IDLE_SECONDS) + } + + pub fn shown(&self, device: f64) -> f64 { + self.pending.unwrap_or(device) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wheel_steps_debounce_to_one_value() { + let range = output_level_range(); + let mut fader = FaderState::default(); + fader.on_wheel(1.0, 80.0, range.clone(), 1.0); + fader.on_wheel(1.0, 80.0, range.clone(), 1.125); + fader.on_wheel(1.0, 80.0, range, 1.25); + assert_eq!(fader.shown(80.0), 81.5); + assert_eq!(fader.due(1.5), None); + assert_eq!(fader.due(1.625), Some(81.5)); + assert_eq!(fader.due(2.0), None); + assert_eq!(fader.shown(80.0), 80.0); + } + + #[test] + fn wheel_deadline_follows_the_last_step_and_ends_when_due() { + let range = output_level_range(); + let mut fader = FaderState::default(); + assert_eq!(fader.deadline(), None); + fader.on_wheel(1.0, 80.0, range.clone(), 1.0); + fader.on_wheel(1.0, 80.0, range, 1.5); + assert_eq!(fader.deadline(), Some(1.5 + WHEEL_IDLE_SECONDS)); + fader.due(2.0); + assert_eq!(fader.deadline(), None); + } + + #[test] + fn fractional_wheel_events_add_up_to_whole_notches() { + let range = output_level_range(); + let mut fader = FaderState::default(); + for _ in 0..10 { + fader.on_wheel(0.1, 80.0, range.clone(), 1.0); + } + assert_eq!(fader.shown(80.0), 80.5); + fader.on_wheel(0.5, 80.0, range.clone(), 1.0); + assert_eq!(fader.shown(80.0), 80.5); + fader.on_wheel(0.5, 80.0, range, 1.0); + assert_eq!(fader.shown(80.0), 81.0); + } + + #[test] + fn wheel_clamps_at_120() { + let range = output_level_range(); + assert_eq!(range, 0.0..=120.0); + let mut fader = FaderState::default(); + fader.on_wheel(10.0, 119.0, range.clone(), 0.0); + assert_eq!(fader.due(1.0), Some(120.0)); + fader.on_wheel(-1000.0, 1.0, range, 2.0); + assert_eq!(fader.due(3.0), Some(0.0)); + } +} diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index 45202f3..59a9491 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -1,6 +1,11 @@ pub mod app; +pub mod fader; +pub mod meter; +pub mod paths; pub mod spawner; pub mod state; +pub mod targets; pub mod texts; pub mod ui_kit; pub mod views; +pub mod writes; diff --git a/crates/app/src/meter.rs b/crates/app/src/meter.rs new file mode 100644 index 0000000..2104852 --- /dev/null +++ b/crates/app/src/meter.rs @@ -0,0 +1,79 @@ +//! Level meters: the shown level and a peak held for a while. Times are seconds of the UI clock. + +pub const PEAK_HOLD_SECONDS: f64 = 1.5; +pub const METER_FLOOR_DB: f64 = -60.0; +pub const METER_CEILING_DB: f64 = 6.0; + +/// Maps a dB value linearly from the floor..ceiling range to 0..1; the device reports silence +/// as -160, which lands below the floor and gives 0. +pub fn fraction(db: f64) -> f32 { + let position = (db - METER_FLOOR_DB) / (METER_CEILING_DB - METER_FLOOR_DB); + position.clamp(0.0, 1.0) as f32 +} + +#[derive(Clone, Copy, Debug)] +pub struct MeterState { + level: f64, + peak: f64, + peak_since: f64, +} + +impl Default for MeterState { + fn default() -> MeterState { + MeterState { + level: METER_FLOOR_DB, + peak: METER_FLOOR_DB, + peak_since: 0.0, + } + } +} + +impl MeterState { + pub fn update(&mut self, level: f64, now: f64) { + self.level = level; + let hold_expired = now - self.peak_since >= PEAK_HOLD_SECONDS; + if level >= self.peak || hold_expired { + self.peak = level; + self.peak_since = now; + } + } + + pub fn level(&self) -> f64 { + self.level + } + + /// The held peak, or the current level once the hold ran out. + pub fn peak(&self, now: f64) -> f64 { + if now - self.peak_since >= PEAK_HOLD_SECONDS { + return self.level; + } + self.peak + } + + pub fn reset(&mut self) { + *self = MeterState::default(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn peak_holds_then_falls_after_1_5_s() { + let mut meter = MeterState::default(); + meter.update(-6.0, 0.0); + meter.update(-30.0, 1.0); + assert_eq!(meter.peak(1.0), -6.0); + assert_eq!(meter.level(), -30.0); + assert_eq!(meter.peak(1.25), -6.0); + assert_eq!(meter.peak(1.5), -30.0); + } + + #[test] + fn meter_fraction_clamps_below_floor_and_above_ceiling() { + assert_eq!(fraction(-160.0), 0.0); + assert_eq!(fraction(10.0), 1.0); + assert_eq!(fraction(-27.0), 0.5); + } +} diff --git a/crates/app/src/paths.rs b/crates/app/src/paths.rs new file mode 100644 index 0000000..a717835 --- /dev/null +++ b/crates/app/src/paths.rs @@ -0,0 +1,39 @@ +//! The SSC paths the app handles by name; everything else comes from the catalog. + +use neumann_control_core::catalog::{Catalog, Entry}; +use neumann_control_core::ssc::wire::SscPath; + +pub const OUTPUT_LEVEL: &str = "audio/out/level"; +pub const MUTE: &str = "audio/out/mute"; +pub const IDENTIFY: &str = "device/identification/visual"; +pub const INPUT_METER: &str = "m/in/level"; +pub const OUTPUT_METER: &str = "m/out/level"; +pub const INPUT_CLIP: &str = "m/in/clip"; + +pub fn path(text: &str) -> SscPath { + SscPath::parse(text).expect("constant path is valid") +} + +pub fn catalog_entry<'a>(catalog: &'a Catalog, path: &SscPath) -> Option<&'a Entry> { + catalog.entries.iter().find(|entry| entry.path == *path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_named_path_is_in_the_catalog() { + let catalog = Catalog::embedded(); + for text in [ + OUTPUT_LEVEL, + MUTE, + IDENTIFY, + INPUT_METER, + OUTPUT_METER, + INPUT_CLIP, + ] { + assert!(catalog_entry(catalog, &path(text)).is_some(), "{text}"); + } + } +} diff --git a/crates/app/src/state.rs b/crates/app/src/state.rs index b0b26c4..4c58b2e 100644 --- a/crates/app/src/state.rs +++ b/crates/app/src/state.rs @@ -1,32 +1,20 @@ -//! UI-independent state of the speaker cards: what the worker events add up to, which targets -//! to talk to, and the time-based behaviour of meters and the fader wheel. +//! UI-independent state of one speaker card: what the worker events add up to. //! All times are seconds of the UI clock passed in by the caller. use std::collections::{BTreeMap, BTreeSet}; use std::net::SocketAddrV6; -use std::ops::RangeInclusive; -use neumann_control_core::address::parse_address; -use neumann_control_core::catalog::Catalog; use neumann_control_core::discovery::Found; use neumann_control_core::mask::{mask_host, mask_serial}; -use neumann_control_core::settings::ManualSpeaker; use neumann_control_core::speaker::{Event, Identity}; use neumann_control_core::ssc::wire::SscPath; use serde_json::Value; +use crate::fader::FaderState; +use crate::meter::{MeterState, METER_FLOOR_DB}; +use crate::paths; use crate::texts; -pub const PEAK_HOLD_SECONDS: f64 = 1.5; -pub const METER_FLOOR_DB: f64 = -60.0; -pub const METER_CEILING_DB: f64 = 6.0; -pub const WHEEL_STEP_DB: f64 = 0.5; -pub const WHEEL_IDLE_SECONDS: f64 = 0.3; - -const INPUT_METER_PATH: &str = "m/in/level"; -const OUTPUT_METER_PATH: &str = "m/out/level"; -pub const OUTPUT_LEVEL_PATH: &str = "audio/out/level"; - #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub enum SpeakerKey { Serial(String), @@ -155,7 +143,7 @@ impl SpeakerState { } } - fn set_message(&mut self, message: Option, path: Option) { + pub(crate) fn set_message(&mut self, message: Option, path: Option) { self.message = message; self.message_path = path; } @@ -169,8 +157,8 @@ impl SpeakerState { fn feed_meters(&mut self, path: &SscPath, value: &Value, now: f64) { let meters = match path.to_string().as_str() { - INPUT_METER_PATH => &mut self.input_meters, - OUTPUT_METER_PATH => &mut self.output_meters, + paths::INPUT_METER => &mut self.input_meters, + paths::OUTPUT_METER => &mut self.output_meters, _ => return, }; let Some(levels) = value.as_array() else { @@ -185,190 +173,22 @@ impl SpeakerState { } } -/// Manual entries first (settings order), then discovered speakers not yet listed. A speaker -/// present on both sides (same serial, or same address) is one target at the discovered -/// address, keyed by the serial once either side knows it. Manual entries whose address does not parse are skipped here and stay -/// untouched in the settings. -pub fn merge_targets(manual: &[ManualSpeaker], found: &[Found]) -> Vec<(SpeakerKey, SocketAddrV6)> { - let mut targets: Vec<(SpeakerKey, SocketAddrV6)> = Vec::new(); - for speaker in manual { - let Ok(address) = parse_address(&speaker.address, speaker.port) else { - continue; - }; - let key = match &speaker.serial { - Some(serial) => SpeakerKey::Serial(serial.clone()), - None => SpeakerKey::Address(address), - }; - push_or_replace(&mut targets, key, address, false); - } - for entry in found { - let key = match &entry.serial { - Some(serial) => SpeakerKey::Serial(serial.clone()), - None => SpeakerKey::Address(entry.address), - }; - push_or_replace(&mut targets, key, entry.address, true); - } - targets -} - -fn push_or_replace( - targets: &mut Vec<(SpeakerKey, SocketAddrV6)>, - key: SpeakerKey, - address: SocketAddrV6, - from_found: bool, -) { - let position = targets - .iter() - .position(|(existing, existing_address)| *existing == key || *existing_address == address); - let Some(position) = position else { - targets.push((key, address)); - return; - }; - if !from_found { - return; - } - if matches!(key, SpeakerKey::Serial(_)) { - targets[position].0 = key; - } - targets[position].1 = address; -} - -/// The catalog range of the output level, which the wheel fader drives. -pub fn output_level_range() -> RangeInclusive { - let path = SscPath::parse(OUTPUT_LEVEL_PATH).expect("constant path"); - let entry = Catalog::embedded() - .entries - .iter() - .find(|entry| entry.path == path) - .expect("catalog has the output level"); - let min = entry.min.expect("output level has a minimum"); - let max = entry.max.expect("output level has a maximum"); - min..=max -} - -/// Maps a dB value linearly from the floor..ceiling range to 0..1; the device reports silence -/// as -160, which lands below the floor and gives 0. -pub fn fraction(db: f64) -> f32 { - let position = (db - METER_FLOOR_DB) / (METER_CEILING_DB - METER_FLOOR_DB); - position.clamp(0.0, 1.0) as f32 -} - -#[derive(Clone, Copy, Debug)] -pub struct MeterState { - level: f64, - peak: f64, - peak_since: f64, -} - -impl Default for MeterState { - fn default() -> MeterState { - MeterState { - level: METER_FLOOR_DB, - peak: METER_FLOOR_DB, - peak_since: 0.0, - } - } -} - -impl MeterState { - pub fn update(&mut self, level: f64, now: f64) { - self.level = level; - let hold_expired = now - self.peak_since >= PEAK_HOLD_SECONDS; - if level >= self.peak || hold_expired { - self.peak = level; - self.peak_since = now; - } - } - - pub fn level(&self) -> f64 { - self.level - } - - /// The held peak, or the current level once the hold ran out. - pub fn peak(&self, now: f64) -> f64 { - if now - self.peak_since >= PEAK_HOLD_SECONDS { - return self.level; - } - self.peak - } - - pub fn reset(&mut self) { - *self = MeterState::default(); - } -} - -/// Collects mouse-wheel steps so one write goes out after the wheel went quiet. -#[derive(Clone, Copy, Debug, Default)] -pub struct FaderState { - pending: Option, - last_step: f64, - /// Wheel movement short of a whole notch, in 1/NOTCH_RESOLUTION notches. - partial_notch: i64, -} - -/// Fractions of a notch are counted in whole units so ten tenths make exactly one notch. -const NOTCH_RESOLUTION: f64 = 1000.0; - -impl FaderState { - /// `range` is the allowed value range of the faded entry. - /// `notches` may be fractional (touchpads, high-resolution wheels); one step of - /// WHEEL_STEP_DB per whole notch, the rest is kept for the next event. - pub fn on_wheel(&mut self, notches: f64, current: f64, range: RangeInclusive, now: f64) { - self.partial_notch += (notches * NOTCH_RESOLUTION).round() as i64; - let resolution = NOTCH_RESOLUTION as i64; - let whole = self.partial_notch / resolution; - self.partial_notch %= resolution; - self.last_step = now; - if whole == 0 { - return; - } - let base = self.pending.unwrap_or(current); - let target = base + whole as f64 * WHEEL_STEP_DB; - self.pending = Some(target.clamp(*range.start(), *range.end())); - } - - pub fn due(&mut self, now: f64) -> Option { - let idle = now - self.last_step >= WHEEL_IDLE_SECONDS; - if self.pending.is_some() && idle { - return self.pending.take(); - } - None - } - - /// When the pending wheel value becomes due, so the UI can wake up for it. - pub fn deadline(&self) -> Option { - self.pending.map(|_| self.last_step + WHEEL_IDLE_SECONDS) - } - - pub fn shown(&self, device: f64) -> f64 { - self.pending.unwrap_or(device) - } -} - #[cfg(test)] mod tests { use super::*; + use neumann_control_core::address::parse_address; use neumann_control_core::ssc::wire::DeviceError; use serde_json::json; const SERIAL: &str = "SIM0001234"; const OTHER_SERIAL: &str = "SIM0007500"; const ADDRESS_TEXT: &str = "fe80::2a36:38ff:fe12:3456%7"; - const OTHER_ADDRESS_TEXT: &str = "fe80::2a36:38ff:fe12:3457%7"; const PORT: u16 = 45; fn address(text: &str) -> SocketAddrV6 { parse_address(text, PORT).unwrap() } - fn manual(text: &str, serial: Option<&str>) -> ManualSpeaker { - ManualSpeaker { - address: text.to_owned(), - port: PORT, - serial: serial.map(str::to_owned), - } - } - fn found(text: &str, serial: Option<&str>) -> Found { Found { name: "KH 120".to_owned(), @@ -396,48 +216,6 @@ mod tests { SscPath::parse(text).unwrap() } - #[test] - fn same_serial_merges_into_one_target() { - let targets = merge_targets( - &[manual(ADDRESS_TEXT, Some(SERIAL))], - &[found(OTHER_ADDRESS_TEXT, Some(SERIAL))], - ); - assert_eq!( - targets, - vec![( - SpeakerKey::Serial(SERIAL.to_owned()), - address(OTHER_ADDRESS_TEXT) - )] - ); - } - - #[test] - fn manual_without_serial_stays_by_address() { - let targets = merge_targets( - &[manual(ADDRESS_TEXT, None), manual("not an address", None)], - &[found(OTHER_ADDRESS_TEXT, Some(OTHER_SERIAL))], - ); - assert_eq!( - targets, - vec![ - ( - SpeakerKey::Address(address(ADDRESS_TEXT)), - address(ADDRESS_TEXT) - ), - ( - SpeakerKey::Serial(OTHER_SERIAL.to_owned()), - address(OTHER_ADDRESS_TEXT) - ), - ] - ); - } - - #[test] - fn found_without_serial_dedupes_by_address() { - let targets = merge_targets(&[manual(ADDRESS_TEXT, None)], &[found(ADDRESS_TEXT, None)]); - assert_eq!(targets.len(), 1); - } - #[test] fn connected_upgrades_key_to_serial_and_keeps_address() { let mut speaker = state(); @@ -535,17 +313,6 @@ mod tests { assert_eq!(speaker.output_meters[1].level(), -20.5); } - #[test] - fn peak_holds_then_falls_after_1_5_s() { - let mut meter = MeterState::default(); - meter.update(-6.0, 0.0); - meter.update(-30.0, 1.0); - assert_eq!(meter.peak(1.0), -6.0); - assert_eq!(meter.level(), -30.0); - assert_eq!(meter.peak(1.25), -6.0); - assert_eq!(meter.peak(1.5), -30.0); - } - #[test] fn input_and_output_meters_are_kept_apart() { let mut speaker = state(); @@ -582,51 +349,6 @@ mod tests { assert_eq!(speaker.message, None); } - #[test] - fn manual_address_and_found_serial_merge_into_one_target() { - let targets = merge_targets( - &[manual(ADDRESS_TEXT, None)], - &[found(ADDRESS_TEXT, Some(SERIAL))], - ); - assert_eq!( - targets, - vec![(SpeakerKey::Serial(SERIAL.to_owned()), address(ADDRESS_TEXT))] - ); - } - - #[test] - fn meter_fraction_clamps_below_floor_and_above_ceiling() { - assert_eq!(fraction(-160.0), 0.0); - assert_eq!(fraction(10.0), 1.0); - assert_eq!(fraction(-27.0), 0.5); - } - - #[test] - fn wheel_steps_debounce_to_one_value() { - let range = output_level_range(); - let mut fader = FaderState::default(); - fader.on_wheel(1.0, 80.0, range.clone(), 1.0); - fader.on_wheel(1.0, 80.0, range.clone(), 1.125); - fader.on_wheel(1.0, 80.0, range, 1.25); - assert_eq!(fader.shown(80.0), 81.5); - assert_eq!(fader.due(1.5), None); - assert_eq!(fader.due(1.625), Some(81.5)); - assert_eq!(fader.due(2.0), None); - assert_eq!(fader.shown(80.0), 80.0); - } - - #[test] - fn wheel_deadline_follows_the_last_step_and_ends_when_due() { - let range = output_level_range(); - let mut fader = FaderState::default(); - assert_eq!(fader.deadline(), None); - fader.on_wheel(1.0, 80.0, range.clone(), 1.0); - fader.on_wheel(1.0, 80.0, range, 1.5); - assert_eq!(fader.deadline(), Some(1.5 + WHEEL_IDLE_SECONDS)); - fader.due(2.0); - assert_eq!(fader.deadline(), None); - } - fn identity(name: &str, product: &str) -> Identity { Identity { product: product.to_owned(), @@ -668,29 +390,4 @@ mod tests { speaker.identity = Some(identity("", "KH 120 II")); assert_eq!(speaker.model(), Some("KH 120 II")); } - - #[test] - fn fractional_wheel_events_add_up_to_whole_notches() { - let range = output_level_range(); - let mut fader = FaderState::default(); - for _ in 0..10 { - fader.on_wheel(0.1, 80.0, range.clone(), 1.0); - } - assert_eq!(fader.shown(80.0), 80.5); - fader.on_wheel(0.5, 80.0, range.clone(), 1.0); - assert_eq!(fader.shown(80.0), 80.5); - fader.on_wheel(0.5, 80.0, range, 1.0); - assert_eq!(fader.shown(80.0), 81.0); - } - - #[test] - fn wheel_clamps_at_120() { - let range = output_level_range(); - assert_eq!(range, 0.0..=120.0); - let mut fader = FaderState::default(); - fader.on_wheel(10.0, 119.0, range.clone(), 0.0); - assert_eq!(fader.due(1.0), Some(120.0)); - fader.on_wheel(-1000.0, 1.0, range, 2.0); - assert_eq!(fader.due(3.0), Some(0.0)); - } } diff --git a/crates/app/src/targets.rs b/crates/app/src/targets.rs new file mode 100644 index 0000000..7efcc21 --- /dev/null +++ b/crates/app/src/targets.rs @@ -0,0 +1,143 @@ +//! Which speakers to talk to: manual entries and scan results merged into one target each. + +use std::net::SocketAddrV6; + +use neumann_control_core::address::parse_address; +use neumann_control_core::discovery::Found; +use neumann_control_core::settings::ManualSpeaker; + +use crate::state::SpeakerKey; + +/// Manual entries first (settings order), then discovered speakers not yet listed. A speaker +/// present on both sides (same serial, or same address) is one target at the discovered +/// address, keyed by the serial once either side knows it. Manual entries whose address does +/// not parse are skipped here and stay untouched in the settings. +pub fn merge_targets(manual: &[ManualSpeaker], found: &[Found]) -> Vec<(SpeakerKey, SocketAddrV6)> { + let mut targets: Vec<(SpeakerKey, SocketAddrV6)> = Vec::new(); + for speaker in manual { + let Ok(address) = parse_address(&speaker.address, speaker.port) else { + continue; + }; + let key = match &speaker.serial { + Some(serial) => SpeakerKey::Serial(serial.clone()), + None => SpeakerKey::Address(address), + }; + push_or_replace(&mut targets, key, address, false); + } + for entry in found { + let key = match &entry.serial { + Some(serial) => SpeakerKey::Serial(serial.clone()), + None => SpeakerKey::Address(entry.address), + }; + push_or_replace(&mut targets, key, entry.address, true); + } + targets +} + +fn push_or_replace( + targets: &mut Vec<(SpeakerKey, SocketAddrV6)>, + key: SpeakerKey, + address: SocketAddrV6, + from_found: bool, +) { + let position = targets + .iter() + .position(|(existing, existing_address)| *existing == key || *existing_address == address); + let Some(position) = position else { + targets.push((key, address)); + return; + }; + if !from_found { + return; + } + if matches!(key, SpeakerKey::Serial(_)) { + targets[position].0 = key; + } + targets[position].1 = address; +} + +#[cfg(test)] +mod tests { + use super::*; + + const SERIAL: &str = "SIM0001234"; + const OTHER_SERIAL: &str = "SIM0007500"; + const ADDRESS_TEXT: &str = "fe80::2a36:38ff:fe12:3456%7"; + const OTHER_ADDRESS_TEXT: &str = "fe80::2a36:38ff:fe12:3457%7"; + const PORT: u16 = 45; + + fn address(text: &str) -> SocketAddrV6 { + parse_address(text, PORT).unwrap() + } + + fn manual(text: &str, serial: Option<&str>) -> ManualSpeaker { + ManualSpeaker { + address: text.to_owned(), + port: PORT, + serial: serial.map(str::to_owned), + } + } + + fn found(text: &str, serial: Option<&str>) -> Found { + Found { + name: "KH 120".to_owned(), + product: None, + serial: serial.map(str::to_owned), + address: address(text), + } + } + + #[test] + fn same_serial_merges_into_one_target() { + let targets = merge_targets( + &[manual(ADDRESS_TEXT, Some(SERIAL))], + &[found(OTHER_ADDRESS_TEXT, Some(SERIAL))], + ); + assert_eq!( + targets, + vec![( + SpeakerKey::Serial(SERIAL.to_owned()), + address(OTHER_ADDRESS_TEXT) + )] + ); + } + + #[test] + fn manual_without_serial_stays_by_address() { + let targets = merge_targets( + &[manual(ADDRESS_TEXT, None), manual("not an address", None)], + &[found(OTHER_ADDRESS_TEXT, Some(OTHER_SERIAL))], + ); + assert_eq!( + targets, + vec![ + ( + SpeakerKey::Address(address(ADDRESS_TEXT)), + address(ADDRESS_TEXT) + ), + ( + SpeakerKey::Serial(OTHER_SERIAL.to_owned()), + address(OTHER_ADDRESS_TEXT) + ), + ] + ); + } + + #[test] + fn found_without_serial_dedupes_by_address() { + let targets = merge_targets(&[manual(ADDRESS_TEXT, None)], &[found(ADDRESS_TEXT, None)]); + assert_eq!(targets.len(), 1); + } + + #[test] + fn manual_address_and_found_serial_merge_into_one_target() { + let targets = merge_targets( + &[manual(ADDRESS_TEXT, None)], + &[found(ADDRESS_TEXT, Some(SERIAL))], + ); + assert_eq!( + targets, + vec![(SpeakerKey::Serial(SERIAL.to_owned()), address(ADDRESS_TEXT))] + ); + } +} diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index c33dcc2..440cb8d 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -3,6 +3,8 @@ use neumann_control_core::address::AddressError; use neumann_control_core::catalog::ValueError; +use crate::writes::Refusal; + pub const WINDOW_TITLE: &str = "Neumann Control"; pub const SCAN: &str = "Suchen"; pub const ADD_ADDRESS: &str = "+ Adresse"; @@ -81,6 +83,15 @@ pub fn describe_value_error(error: &ValueError) -> String { } } +pub fn describe_refusal(refusal: &Refusal) -> String { + match refusal { + Refusal::NotConnected => "Nicht verbunden".to_owned(), + Refusal::NotWritable => "Für dieses Modell nicht freigegeben".to_owned(), + Refusal::ValueUnknown => "Wert noch unbekannt".to_owned(), + Refusal::Invalid(error) => describe_value_error(error), + } +} + pub fn describe_rejection(code: i64) -> String { match code { REJECTED_READ_ONLY_CODE => "Gerät lehnt ab: nur lesbar".to_owned(), diff --git a/crates/app/src/views/card.rs b/crates/app/src/views/card.rs index 4ce1079..b27fc32 100644 --- a/crates/app/src/views/card.rs +++ b/crates/app/src/views/card.rs @@ -1,29 +1,28 @@ //! One speaker as a channel strip: name, status, level fader with input meters, mute and -//! identify, the "Mehr" toggle and a status line. - -use std::ops::RangeInclusive; +//! identify, the "Mehr" toggle and a status line. The card only reports what was clicked; +//! `writes` decides whether it becomes a command. use eframe::egui::{ - self, pos2, vec2, Button, CornerRadius, Event, Label, MouseWheelUnit, Rect, RichText, Sense, - Slider, Stroke, Ui, Vec2, + self, pos2, vec2, Button, CornerRadius, Label, Rect, RichText, Sense, Stroke, Ui, Vec2, }; -use neumann_control_core::catalog::{Catalog, Entry}; +use neumann_control_core::catalog::Catalog; use neumann_control_core::mask::mask_serial; use neumann_control_core::speaker::Command; -use neumann_control_core::ssc::wire::SscPath; use serde_json::Value; -use crate::state::{ - fraction, output_level_range, SpeakerState, Status, OUTPUT_LEVEL_PATH, WHEEL_STEP_DB, -}; +use super::fader::{self, FADER_HEIGHT}; +use crate::fader::output_level_range; +use crate::meter::fraction; +use crate::paths; +use crate::state::{SpeakerState, Status}; use crate::texts; use crate::ui_kit::theme::Tokens; +use crate::writes::{self, Intent, Switch}; const CARD_WIDTH: f32 = 170.0; const CARD_MARGIN: i8 = 8; const CARD_RADIUS: u8 = 6; const BORDER_WIDTH: f32 = 1.0; -const FADER_HEIGHT: f32 = 150.0; const METER_WIDTH: f32 = 8.0; const CLIP_LAMP_RADIUS: f32 = 3.5; const CLIP_LAMP_SPACE: f32 = 10.0; @@ -31,20 +30,10 @@ const PEAK_LINE_WIDTH: f32 = 2.0; const STATUS_DOT_RADIUS: f32 = 4.0; const UNREACHABLE_OPACITY: f32 = 0.55; -const MUTE_PATH: &str = "audio/out/mute"; -const IDENTIFY_PATH: &str = "device/identification/visual"; -const INPUT_CLIP_PATH: &str = "m/in/clip"; - -/// Draws the card and returns the writes the user asked for, already checked against the -/// catalog for this model. +/// Draws the card and returns the writes the user asked for that the write policy allowed. pub fn show(ui: &mut Ui, speaker: &mut SpeakerState, catalog: &Catalog) -> Vec { let tokens = Tokens::current(ui.ctx()); - let mut writer = Writer { - catalog, - product: speaker.model().unwrap_or_default().to_owned(), - connected: speaker.status == Status::Connected, - commands: Vec::new(), - }; + let mut commands = Vec::new(); let inner_width = CARD_WIDTH - 2.0 * (f32::from(CARD_MARGIN) + BORDER_WIDTH); egui::Frame::new() .fill(tokens.card) @@ -57,55 +46,12 @@ pub fn show(ui: &mut Ui, speaker: &mut SpeakerState, catalog: &Catalog) -> Vec { - catalog: &'a Catalog, - product: String, - connected: bool, - commands: Vec, -} - -impl Writer<'_> { - fn entry(&self, path: &SscPath) -> Option<&Entry> { - self.catalog - .entries - .iter() - .find(|entry| entry.path == *path) - } - - fn can_write(&self, path: &SscPath) -> bool { - let writable = self - .entry(path) - .is_some_and(|entry| entry.is_writable(&self.product)); - self.connected && writable - } - - fn set(&mut self, path: SscPath, value: Value) { - if !self.can_write(&path) { - return; - } - let Some(entry) = self.entry(&path) else { - return; - }; - if let Err(error) = entry.validate(&value, &self.product, &self.catalog.classes) { - log::warn!("not sending {path}: {error}"); - return; - } - self.commands.push(Command::Set { path, value }); - } -} - -fn constant_path(text: &str) -> SscPath { - SscPath::parse(text).expect("constant path is valid") + commands } fn header(ui: &mut Ui, speaker: &SpeakerState, tokens: &Tokens) { @@ -133,104 +79,34 @@ fn status_dot(ui: &mut Ui, status: Status, tokens: &Tokens) { response.on_hover_text(text); } -fn strip(ui: &mut Ui, speaker: &mut SpeakerState, writer: &mut Writer, tokens: &Tokens) { - let path = constant_path(OUTPUT_LEVEL_PATH); +fn strip( + ui: &mut Ui, + speaker: &mut SpeakerState, + catalog: &Catalog, + commands: &mut Vec, + tokens: &Tokens, +) { + let path = paths::path(paths::OUTPUT_LEVEL); let device = speaker.values.get(&path).and_then(Value::as_f64); - let range = output_level_range(); let shown = ui .horizontal(|ui| { - let shown = fader(ui, speaker, writer, device, range); + let shown = fader::show(ui, speaker, catalog, commands, device, output_level_range()); meters(ui, speaker, tokens); shown }) .inner; - let unit = writer - .entry(&path) + let unit = paths::catalog_entry(catalog, &path) .and_then(|entry| entry.unit.clone()) .unwrap_or_default(); let readout = device.map(|_| texts::readout(shown, &unit)); ui.label(readout.unwrap_or_default()); } -/// The vertical level fader; returns the value it shows. -fn fader( - ui: &mut Ui, - speaker: &mut SpeakerState, - writer: &mut Writer, - device: Option, - range: RangeInclusive, -) -> f64 { - let path = constant_path(OUTPUT_LEVEL_PATH); - let enabled = device.is_some() && writer.can_write(&path); - let device = device.unwrap_or(*range.start()); - let now = ui.input(|input| input.time); - let mut value = speaker.fader.shown(device); - ui.spacing_mut().slider_width = FADER_HEIGHT; - let slider = Slider::new(&mut value, range.clone()) - .vertical() - .show_value(false) - .step_by(WHEEL_STEP_DB); - let response = ui.add_enabled(enabled, slider); - // While dragging, the slider follows the pointer and the speaker hears nothing. The release - // frame still moves the value but no longer counts as dragged, so this one condition sends - // the final value of a drag, a click on the track and a key press alike. - let settled = response.changed() && !response.dragged(); - if settled { - writer.set(path.clone(), Value::from(value)); - } - if enabled && response.hovered() { - // The cards sit in a scroll area, which would scroll along with the fader. - ui.input_mut(|input| input.smooth_scroll_delta = Vec2::ZERO); - for notches in wheel_notches(ui) { - speaker.fader.on_wheel(notches, device, range.clone(), now); - } - value = speaker.fader.shown(device); - } - if let Some(due) = speaker.fader.due(now) { - writer.set(path, Value::from(due)); - } - if let Some(deadline) = speaker.fader.deadline() { - let wait = (deadline - now).max(0.0); - ui.ctx().request_repaint_after_secs(wait as f32); - } - value -} - -/// Lines and pixels of a precision touchpad come as many small events, pages as rare large -/// ones; egui scrolls this many points per line. -const POINTS_PER_NOTCH: f32 = 40.0; -const NOTCHES_PER_PAGE: f32 = 10.0; - -/// Each wheel event of this frame in notches (lines), upwards positive. -fn wheel_notches(ui: &Ui) -> Vec { - ui.input(|input| { - input - .events - .iter() - .filter_map(|event| match event { - Event::MouseWheel { unit, delta, .. } if delta.y != 0.0 => { - Some(notches(*unit, delta.y)) - } - _ => None, - }) - .collect() - }) -} - -fn notches(unit: MouseWheelUnit, delta: f32) -> f64 { - let lines = match unit { - MouseWheelUnit::Line => delta, - MouseWheelUnit::Point => delta / POINTS_PER_NOTCH, - MouseWheelUnit::Page => delta * NOTCHES_PER_PAGE, - }; - f64::from(lines) -} - fn meters(ui: &mut Ui, speaker: &SpeakerState, tokens: &Tokens) { let now = ui.input(|input| input.time); let clips = speaker .values - .get(&constant_path(INPUT_CLIP_PATH)) + .get(&paths::path(paths::INPUT_CLIP)) .and_then(Value::as_array); for (channel, meter) in speaker.input_meters.iter().enumerate() { let clipped = clips @@ -264,24 +140,40 @@ fn meters(ui: &mut Ui, speaker: &SpeakerState, tokens: &Tokens) { } } -fn buttons(ui: &mut Ui, speaker: &SpeakerState, writer: &mut Writer) { +fn buttons( + ui: &mut Ui, + speaker: &mut SpeakerState, + catalog: &Catalog, + commands: &mut Vec, +) { ui.horizontal(|ui| { - toggle(ui, speaker, writer, MUTE_PATH, texts::MUTE); - toggle(ui, speaker, writer, IDENTIFY_PATH, texts::IDENTIFY); + toggle(ui, speaker, catalog, commands, Switch::Mute, texts::MUTE); + toggle( + ui, + speaker, + catalog, + commands, + Switch::Identify, + texts::IDENTIFY, + ); }); } -/// A button showing a switch value of the speaker; a click writes the opposite. -fn toggle(ui: &mut Ui, speaker: &SpeakerState, writer: &mut Writer, path: &str, text: &str) { - let path = constant_path(path); +/// A button showing a switch value of the speaker; a click asks for the opposite. +fn toggle( + ui: &mut Ui, + speaker: &mut SpeakerState, + catalog: &Catalog, + commands: &mut Vec, + switch: Switch, + text: &str, +) { + let path = switch.path(); let current = speaker.values.get(&path).and_then(Value::as_bool); - let enabled = current.is_some() && writer.can_write(&path); - let on = current.unwrap_or(false); - let clicked = ui - .add_enabled(enabled, Button::new(text).selected(on)) - .clicked(); - if clicked { - writer.set(path, Value::Bool(!on)); + let enabled = current.is_some() && writes::can_write(speaker, catalog, &path); + let button = Button::new(text).selected(current.unwrap_or(false)); + if ui.add_enabled(enabled, button).clicked() { + commands.extend(speaker.request(Intent::Toggle(switch), catalog)); } } @@ -305,15 +197,3 @@ fn status_line(ui: &mut Ui, speaker: &SpeakerState, tokens: &Tokens) { ui.add(Label::new(RichText::new(message).small().color(tokens.danger)).wrap()); } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn wheel_units_become_notches() { - assert_eq!(notches(MouseWheelUnit::Line, 0.25), 0.25); - assert_eq!(notches(MouseWheelUnit::Point, -80.0), -2.0); - assert_eq!(notches(MouseWheelUnit::Page, 1.0), 10.0); - } -} diff --git a/crates/app/src/views/fader.rs b/crates/app/src/views/fader.rs new file mode 100644 index 0000000..51c71e0 --- /dev/null +++ b/crates/app/src/views/fader.rs @@ -0,0 +1,103 @@ +//! The card's level fader: a vertical slider that writes once it settles, and the mouse wheel +//! in 0.5 dB steps per notch. + +use std::ops::RangeInclusive; + +use eframe::egui::{Event, MouseWheelUnit, Slider, Ui, Vec2}; +use neumann_control_core::catalog::Catalog; +use neumann_control_core::speaker::Command; + +use crate::fader::WHEEL_STEP_DB; +use crate::paths; +use crate::state::SpeakerState; +use crate::writes::{self, Intent}; + +/// The slider's length. +pub const FADER_HEIGHT: f32 = 150.0; + +/// The vertical level fader; returns the value it shows. +pub fn show( + ui: &mut Ui, + speaker: &mut SpeakerState, + catalog: &Catalog, + commands: &mut Vec, + device: Option, + range: RangeInclusive, +) -> f64 { + let path = paths::path(paths::OUTPUT_LEVEL); + let enabled = device.is_some() && writes::can_write(speaker, catalog, &path); + let device = device.unwrap_or(*range.start()); + let now = ui.input(|input| input.time); + let mut value = speaker.fader.shown(device); + ui.spacing_mut().slider_width = FADER_HEIGHT; + let slider = Slider::new(&mut value, range.clone()) + .vertical() + .show_value(false) + .step_by(WHEEL_STEP_DB); + let response = ui.add_enabled(enabled, slider); + // While dragging, the slider follows the pointer and the speaker hears nothing. The release + // frame still moves the value but no longer counts as dragged, so this one condition sends + // the final value of a drag, a click on the track and a key press alike. + let settled = response.changed() && !response.dragged(); + if settled { + commands.extend(speaker.request(Intent::Level(value), catalog)); + } + if enabled && response.hovered() { + // The cards sit in a scroll area, which would scroll along with the fader. + ui.input_mut(|input| input.smooth_scroll_delta = Vec2::ZERO); + for notches in wheel_notches(ui) { + speaker.fader.on_wheel(notches, device, range.clone(), now); + } + value = speaker.fader.shown(device); + } + if let Some(due) = speaker.fader.due(now) { + commands.extend(speaker.request(Intent::Level(due), catalog)); + } + if let Some(deadline) = speaker.fader.deadline() { + let wait = (deadline - now).max(0.0); + ui.ctx().request_repaint_after_secs(wait as f32); + } + value +} + +/// Lines and pixels of a precision touchpad come as many small events, pages as rare large +/// ones; egui scrolls this many points per line. +const POINTS_PER_NOTCH: f32 = 40.0; +const NOTCHES_PER_PAGE: f32 = 10.0; + +/// Each wheel event of this frame in notches (lines), upwards positive. +fn wheel_notches(ui: &Ui) -> Vec { + ui.input(|input| { + input + .events + .iter() + .filter_map(|event| match event { + Event::MouseWheel { unit, delta, .. } if delta.y != 0.0 => { + Some(notches(*unit, delta.y)) + } + _ => None, + }) + .collect() + }) +} + +fn notches(unit: MouseWheelUnit, delta: f32) -> f64 { + let lines = match unit { + MouseWheelUnit::Line => delta, + MouseWheelUnit::Point => delta / POINTS_PER_NOTCH, + MouseWheelUnit::Page => delta * NOTCHES_PER_PAGE, + }; + f64::from(lines) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wheel_units_become_notches() { + assert_eq!(notches(MouseWheelUnit::Line, 0.25), 0.25); + assert_eq!(notches(MouseWheelUnit::Point, -80.0), -2.0); + assert_eq!(notches(MouseWheelUnit::Page, 1.0), 10.0); + } +} diff --git a/crates/app/src/views/mod.rs b/crates/app/src/views/mod.rs index 8412aa1..1c32930 100644 --- a/crates/app/src/views/mod.rs +++ b/crates/app/src/views/mod.rs @@ -1,3 +1,4 @@ pub mod add_address; pub mod card; +pub mod fader; pub mod toolbar; diff --git a/crates/app/src/writes.rs b/crates/app/src/writes.rs new file mode 100644 index 0000000..1ca5ff1 --- /dev/null +++ b/crates/app/src/writes.rs @@ -0,0 +1,212 @@ +//! The write policy: what the user asked for becomes a `Command` only for a connected speaker +//! whose model the catalog lists as writable for the path, with a value the catalog accepts. +//! Anything else is refused with a message on the card. + +use neumann_control_core::catalog::{Catalog, ValueError}; +use neumann_control_core::speaker::Command; +use neumann_control_core::ssc::wire::SscPath; +use serde_json::Value; + +use crate::paths; +use crate::state::{SpeakerState, Status}; +use crate::texts; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Switch { + Mute, + Identify, +} + +impl Switch { + pub fn path(self) -> SscPath { + match self { + Switch::Mute => paths::path(paths::MUTE), + Switch::Identify => paths::path(paths::IDENTIFY), + } + } +} + +/// What a control on the card asks for. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Intent { + /// Flip a switch to the opposite of its current value. + Toggle(Switch), + /// Set the output level. + Level(f64), +} + +impl Intent { + pub fn path(self) -> SscPath { + match self { + Intent::Toggle(switch) => switch.path(), + Intent::Level(_) => paths::path(paths::OUTPUT_LEVEL), + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Refusal { + NotConnected, + NotWritable, + /// A switch whose current value the speaker has not reported yet. + ValueUnknown, + Invalid(ValueError), +} + +/// Whether a control for `path` may be offered at all. +pub fn can_write(speaker: &SpeakerState, catalog: &Catalog, path: &SscPath) -> bool { + let product = speaker.model().unwrap_or_default(); + let writable = + paths::catalog_entry(catalog, path).is_some_and(|entry| entry.is_writable(product)); + speaker.status == Status::Connected && writable +} + +/// The checks of the policy, in the order the user would fix them. +pub fn check( + speaker: &SpeakerState, + catalog: &Catalog, + path: &SscPath, + value: &Value, +) -> Result<(), Refusal> { + if speaker.status != Status::Connected { + return Err(Refusal::NotConnected); + } + let product = speaker.model().unwrap_or_default(); + let entry = paths::catalog_entry(catalog, path) + .filter(|entry| entry.is_writable(product)) + .ok_or(Refusal::NotWritable)?; + entry + .validate(value, product, &catalog.classes) + .map_err(Refusal::Invalid) +} + +impl SpeakerState { + /// The command for `intent`, or `None` after putting the refusal on the card. + pub fn request(&mut self, intent: Intent, catalog: &Catalog) -> Option { + let path = intent.path(); + let checked = self + .intended_value(intent, &path) + .and_then(|value| check(self, catalog, &path, &value).map(|()| value)); + match checked { + Ok(value) => Some(Command::Set { path, value }), + Err(refusal) => { + log::warn!("not sending {path}: {refusal:?}"); + self.set_message(Some(texts::describe_refusal(&refusal)), Some(path)); + None + } + } + } + + fn intended_value(&self, intent: Intent, path: &SscPath) -> Result { + match intent { + Intent::Level(level) => Ok(Value::from(level)), + Intent::Toggle(_) => { + let current = self.values.get(path).and_then(Value::as_bool); + let current = current.ok_or(Refusal::ValueUnknown)?; + Ok(Value::Bool(!current)) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use neumann_control_core::address::parse_address; + use neumann_control_core::speaker::{Event, Identity}; + + use crate::state::SpeakerKey; + + const ADDRESS_TEXT: &str = "fe80::2a36:38ff:fe12:3456%7"; + + fn speaker(product: &str) -> SpeakerState { + let address = parse_address(ADDRESS_TEXT, 45).unwrap(); + let mut speaker = SpeakerState::new(SpeakerKey::Address(address), address); + let identity = Identity { + product: product.to_owned(), + version: "1".to_owned(), + serial: "SIM0001234".to_owned(), + name: "left".to_owned(), + }; + speaker.apply(Event::Connected(identity), 0.0); + speaker.apply( + Event::Value { + path: paths::path(paths::MUTE), + value: Value::Bool(false), + }, + 0.0, + ); + speaker + } + + fn set(path: &str, value: Value) -> Option { + Some(Command::Set { + path: paths::path(path), + value, + }) + } + + #[test] + fn toggle_writes_the_opposite_switch_value() { + let mut speaker = speaker("KH 120 II"); + let command = speaker.request(Intent::Toggle(Switch::Mute), Catalog::embedded()); + assert_eq!(command, set(paths::MUTE, Value::Bool(true))); + assert_eq!(speaker.message, None); + } + + #[test] + fn a_model_without_write_permission_is_refused_with_a_message() { + let mut speaker = speaker("KH 80"); + let command = speaker.request(Intent::Level(80.0), Catalog::embedded()); + assert_eq!(command, None); + let expected = texts::describe_refusal(&Refusal::NotWritable); + assert_eq!(speaker.message.as_deref(), Some(expected.as_str())); + assert!(!can_write( + &speaker, + Catalog::embedded(), + &paths::path(paths::OUTPUT_LEVEL) + )); + } + + #[test] + fn an_out_of_range_value_is_refused_with_the_catalog_reason() { + let mut speaker = speaker("KH 120 II"); + let command = speaker.request(Intent::Level(130.0), Catalog::embedded()); + assert_eq!(command, None); + let reason = ValueError::OutOfRange { + min: 0.0, + max: 120.0, + }; + let expected = texts::describe_refusal(&Refusal::Invalid(reason)); + assert_eq!(speaker.message.as_deref(), Some(expected.as_str())); + } + + #[test] + fn an_unreachable_speaker_is_refused() { + let mut speaker = speaker("KH 120 II"); + speaker.apply(Event::Unreachable("masked".to_owned()), 1.0); + assert_eq!( + check( + &speaker, + Catalog::embedded(), + &paths::path(paths::MUTE), + &Value::Bool(true) + ), + Err(Refusal::NotConnected) + ); + assert!(!can_write( + &speaker, + Catalog::embedded(), + &paths::path(paths::MUTE) + )); + } + + #[test] + fn a_switch_without_a_reported_value_is_refused() { + let mut speaker = speaker("KH 120 II"); + let command = speaker.request(Intent::Toggle(Switch::Identify), Catalog::embedded()); + assert_eq!(command, None); + let expected = texts::describe_refusal(&Refusal::ValueUnknown); + assert_eq!(speaker.message.as_deref(), Some(expected.as_str())); + } +} From 864c78d336c4b8e9c77f5320f4444c9c712a60d7 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:20:14 +0200 Subject: [PATCH 020/460] Keep one card per speaker A connected speaker's serial goes into its manual entry, and a second card with a known serial is dropped. --- crates/app/src/app.rs | 43 +++++++++++++++++++++++--- crates/app/src/targets.rs | 64 +++++++++++++++++++++++++++++++++++++++ crates/app/tests/views.rs | 57 ++++++++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+), 4 deletions(-) diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index 3bbcbb9..4c17326 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -17,7 +17,7 @@ use neumann_control_core::speaker::{Command, Event, SpeakerHandle, Target}; use crate::spawner::{ScanResult, SpeakerSpawner}; use crate::state::{SpeakerKey, SpeakerState}; -use crate::targets::merge_targets; +use crate::targets::{duplicate_cards, merge_targets, remember_serial}; use crate::texts; use crate::ui_kit::{fonts, theme}; use crate::views::add_address::{self, AddAddressAction, AddAddressForm}; @@ -118,17 +118,47 @@ impl NeumannApp { let speaker = &mut self.speakers[position]; speaker.state.address = address; speaker.events = events; - let old = std::mem::replace(&mut speaker.handle, handle); - // Dropping a handle joins its worker, which can sit in a 3 s connect; not on this thread. - thread::spawn(move || drop(old)); + retire(std::mem::replace(&mut speaker.handle, handle)); } fn drain_events(&mut self, now: f64) { + let mut connected = Vec::new(); for speaker in &mut self.speakers { while let Ok(event) = speaker.events.try_recv() { + if let Event::Connected(identity) = &event { + connected.push((identity.serial.clone(), speaker.state.address)); + } speaker.state.apply(event, now); } } + if connected.is_empty() { + return; + } + let mut remembered = false; + for (serial, address) in &connected { + remembered |= remember_serial(&mut self.settings.manual, serial, *address); + } + if remembered { + self.save_settings(); + } + self.drop_duplicate_cards(); + } + + /// One speaker reached at two addresses shows up as two cards until both connected. + fn drop_duplicate_cards(&mut self) { + let keys: Vec = self + .speakers + .iter() + .map(|speaker| speaker.state.key.clone()) + .collect(); + for position in duplicate_cards(&keys).into_iter().rev() { + let speaker = self.speakers.remove(position); + log::info!( + "{} is a speaker already shown", + mask_host(&speaker.state.address) + ); + retire(speaker.handle); + } } fn poll_scan(&mut self) { @@ -238,6 +268,11 @@ impl NeumannApp { } } +/// Dropping a handle joins its worker, which can sit in a 3 s connect; not on the UI thread. +fn retire(handle: SpeakerHandle) { + thread::spawn(move || drop(handle)); +} + /// Outer position and inner size in points, as the viewport builder takes them back. `None` /// while minimized or maximized, so the file keeps the last normal window. pub fn window_geometry(viewport: &ViewportInfo) -> Option { diff --git a/crates/app/src/targets.rs b/crates/app/src/targets.rs index 7efcc21..dab7b4a 100644 --- a/crates/app/src/targets.rs +++ b/crates/app/src/targets.rs @@ -34,6 +34,35 @@ pub fn merge_targets(manual: &[ManualSpeaker], found: &[Found]) -> Vec<(SpeakerK targets } +/// Writes the serial a speaker reported into the manual entries for its address that have none +/// yet, so the next merge joins them with scan results by serial. True when an entry changed. +pub fn remember_serial(manual: &mut [ManualSpeaker], serial: &str, address: SocketAddrV6) -> bool { + if serial.is_empty() { + return false; + } + let mut changed = false; + for entry in manual.iter_mut() { + let same_address = parse_address(&entry.address, entry.port) == Ok(address); + if same_address && entry.serial.is_none() { + entry.serial = Some(serial.to_owned()); + changed = true; + } + } + changed +} + +/// Positions of cards whose serial an earlier card already has: one speaker reached at two +/// addresses. The first card stays. +pub fn duplicate_cards(keys: &[SpeakerKey]) -> Vec { + keys.iter() + .enumerate() + .filter(|(position, key)| { + matches!(key, SpeakerKey::Serial(_)) && keys[..*position].contains(key) + }) + .map(|(position, _)| position) + .collect() +} + fn push_or_replace( targets: &mut Vec<(SpeakerKey, SocketAddrV6)>, key: SpeakerKey, @@ -129,6 +158,41 @@ mod tests { assert_eq!(targets.len(), 1); } + #[test] + fn a_reported_serial_lands_in_manual_entries_without_one() { + let mut entries = vec![ + manual(ADDRESS_TEXT, None), + manual(OTHER_ADDRESS_TEXT, None), + manual(ADDRESS_TEXT, Some(OTHER_SERIAL)), + ]; + assert!(remember_serial(&mut entries, SERIAL, address(ADDRESS_TEXT))); + assert_eq!(entries[0].serial.as_deref(), Some(SERIAL)); + assert_eq!(entries[1].serial, None); + assert_eq!(entries[2].serial.as_deref(), Some(OTHER_SERIAL)); + assert!(!remember_serial( + &mut entries, + SERIAL, + address(ADDRESS_TEXT) + )); + assert!(!remember_serial( + &mut entries, + "", + address(OTHER_ADDRESS_TEXT) + )); + } + + #[test] + fn later_cards_with_a_known_serial_are_duplicates() { + let keys = [ + SpeakerKey::Address(address(ADDRESS_TEXT)), + SpeakerKey::Serial(SERIAL.to_owned()), + SpeakerKey::Address(address(ADDRESS_TEXT)), + SpeakerKey::Serial(OTHER_SERIAL.to_owned()), + SpeakerKey::Serial(SERIAL.to_owned()), + ]; + assert_eq!(duplicate_cards(&keys), vec![4]); + } + #[test] fn manual_address_and_found_serial_merge_into_one_target() { let targets = merge_targets( diff --git a/crates/app/tests/views.rs b/crates/app/tests/views.rs index 54bc33d..d3aee8a 100644 --- a/crates/app/tests/views.rs +++ b/crates/app/tests/views.rs @@ -397,3 +397,60 @@ fn scan_moves_a_known_speaker_to_its_new_address() { wait_for_level(&mut harness, 100.0); harness.get_by_label(&texts::speaker_count(1)); } + +fn readouts(harness: &Harness<'static, NeumannApp>) -> usize { + harness.query_all_by_label(&readout(100.0)).count() +} + +fn has_one_card(harness: &Harness<'static, NeumannApp>) -> bool { + readouts(harness) == 1 && harness.query_by_label(&texts::speaker_count(1)).is_some() +} + +fn serials_remembered(path: &Path, count: usize) -> bool { + let manual = settings::load(path).manual; + manual.len() == count + && manual + .iter() + .all(|entry| entry.serial.as_deref() == Some(SIMULATED_SERIAL)) +} + +#[test] +fn a_manual_speaker_found_at_a_second_address_stays_one_card() { + let manual_simulator = Simulator::start(SimModel::Kh120Ii); + // The manual card connects only after the scan created a card for the second address. + manual_simulator.set_reply_delay(Duration::from_millis(300)); + let second_simulator = Simulator::start(SimModel::Kh120Ii); + let directory = tempfile::tempdir().unwrap(); + let path = settings_file(directory.path(), vec![manual_for(&manual_simulator)]); + let found = Found { + name: String::new(), + product: Some("KH 120 II".to_owned()), + serial: Some(SIMULATED_SERIAL.to_owned()), + address: second_simulator.address(), + }; + let mut harness = harness(path.clone(), vec![found]); + harness.get_by_label(texts::SCAN).click(); + wait_until(&mut harness, "the serial in the manual entry", |_| { + serials_remembered(&path, 1) + }); + wait_until(&mut harness, "one card after the first scan", has_one_card); + harness.get_by_label(texts::SCAN).click(); + harness.run_steps(5); + wait_until(&mut harness, "one card after the second scan", has_one_card); +} + +#[test] +fn two_manual_entries_for_one_speaker_stay_one_card() { + let first = Simulator::start(SimModel::Kh120Ii); + let second = Simulator::start(SimModel::Kh120Ii); + let directory = tempfile::tempdir().unwrap(); + let path = settings_file( + directory.path(), + vec![manual_for(&first), manual_for(&second)], + ); + let mut harness = harness(path.clone(), Vec::new()); + wait_until(&mut harness, "both serials remembered", |_| { + serials_remembered(&path, 2) + }); + wait_until(&mut harness, "one card", has_one_card); +} From 3a335451f8c3b35183a16cf3f12836d1d242b27f Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:24:57 +0200 Subject: [PATCH 021/460] Keep the window on a connected monitor --- Cargo.lock | 1 + crates/app/Cargo.toml | 2 + crates/app/src/app.rs | 36 ++++---- crates/app/src/lib.rs | 1 + crates/app/src/main.rs | 53 ++++++++---- crates/app/src/window_placement.rs | 130 +++++++++++++++++++++++++++++ crates/core/src/settings.rs | 1 + 7 files changed, 192 insertions(+), 32 deletions(-) create mode 100644 crates/app/src/window_placement.rs diff --git a/Cargo.lock b/Cargo.lock index e0d48fb..be8dadd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1927,6 +1927,7 @@ dependencies = [ "serde_json", "simplelog", "tempfile", + "winit", ] [[package]] diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 1845c45..5ba6489 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -20,6 +20,8 @@ serde_json = "1" eframe = "0.36" log = "0.4" simplelog = "0.12" +# Already in the tree through eframe; lists the monitors to place the window at the start. +winit = "0.30" [dev-dependencies] egui_kittest = { version = "0.36", features = ["eframe"] } diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index 4c17326..c64cd0c 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -273,20 +273,21 @@ fn retire(handle: SpeakerHandle) { thread::spawn(move || drop(handle)); } -/// Outer position and inner size in points, as the viewport builder takes them back. `None` -/// while minimized or maximized, so the file keeps the last normal window. -pub fn window_geometry(viewport: &ViewportInfo) -> Option { +/// Outer position and inner size in physical pixels, as `window_placement` takes them back. +/// `None` while minimized or maximized, so the file keeps the last normal window. +pub fn window_geometry(viewport: &ViewportInfo, pixels_per_point: f32) -> Option { let outer = viewport.outer_rect?; let inner = viewport.inner_rect?; let normal = viewport.minimized != Some(true) && viewport.maximized != Some(true); if !normal { return None; } + let physical = |points: f32| (points * pixels_per_point).round(); Some(WindowGeometry { - x: outer.min.x, - y: outer.min.y, - width: inner.width(), - height: inner.height(), + x: physical(outer.min.x), + y: physical(outer.min.y), + width: physical(inner.width()), + height: physical(inner.height()), }) } @@ -295,7 +296,8 @@ impl eframe::App for NeumannApp { let now = context.input(|input| input.time); self.drain_events(now); self.poll_scan(); - let geometry = context.input(|input| window_geometry(input.viewport())); + let pixels_per_point = context.pixels_per_point(); + let geometry = context.input(|input| window_geometry(input.viewport(), pixels_per_point)); self.window = geometry.or(self.window); } @@ -330,14 +332,14 @@ mod tests { } #[test] - fn geometry_is_outer_position_and_inner_size() { + fn geometry_is_outer_position_and_inner_size_in_physical_pixels() { assert_eq!( - window_geometry(&viewport()), + window_geometry(&viewport(), 1.5), Some(WindowGeometry { - x: -10.0, - y: 20.0, - width: 800.0, - height: 500.0, + x: -15.0, + y: 30.0, + width: 1200.0, + height: 750.0, }) ); } @@ -352,8 +354,8 @@ mod tests { maximized: Some(true), ..viewport() }; - assert_eq!(window_geometry(&minimized), None); - assert_eq!(window_geometry(&maximized), None); - assert_eq!(window_geometry(&ViewportInfo::default()), None); + assert_eq!(window_geometry(&minimized, 1.0), None); + assert_eq!(window_geometry(&maximized, 1.0), None); + assert_eq!(window_geometry(&ViewportInfo::default(), 1.0), None); } } diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index 59a9491..37bcc9a 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -8,4 +8,5 @@ pub mod targets; pub mod texts; pub mod ui_kit; pub mod views; +pub mod window_placement; pub mod writes; diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 2a48c4e..7dcbcec 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -5,14 +5,17 @@ use std::error::Error; use std::fs::File; use std::path::Path; -use eframe::egui::{pos2, vec2, ViewportBuilder}; +use eframe::egui::ViewportBuilder; use eframe::egui_wgpu::{SurfaceConfig, WgpuConfiguration, WgpuSetup}; use eframe::wgpu; use neumann_control_app::app::NeumannApp; use neumann_control_app::spawner::CoreSpawner; use neumann_control_app::texts; +use neumann_control_app::window_placement::{fit_into, MonitorArea}; use neumann_control_core::settings::{self, WindowGeometry, LOG_FILE, SETTINGS_FILE}; use simplelog::{ConfigBuilder, LevelFilter, WriteLogger}; +use winit::dpi::{PhysicalPosition, PhysicalSize}; +use winit::window::Window; const WINDOW_SIZE: [f32; 2] = [900.0, 600.0]; const WINDOW_MIN_SIZE: [f32; 2] = [480.0, 360.0]; @@ -36,19 +39,31 @@ fn wgpu_options() -> WgpuConfiguration { options } -/// The saved window, never smaller than the minimum; the default size when none was saved. -fn viewport(saved: Option) -> ViewportBuilder { - let builder = ViewportBuilder::default() - .with_title(texts::WINDOW_TITLE) - .with_min_inner_size(WINDOW_MIN_SIZE); - let Some(saved) = saved else { - return builder.with_inner_size(WINDOW_SIZE); - }; - let width = saved.width.max(WINDOW_MIN_SIZE[0]); - let height = saved.height.max(WINDOW_MIN_SIZE[1]); - builder - .with_position(pos2(saved.x, saved.y)) - .with_inner_size(vec2(width, height)) +fn monitor_areas(window: &Window) -> Vec { + window + .available_monitors() + .map(|monitor| { + let position = monitor.position(); + let size = monitor.size(); + MonitorArea { + x: position.x as f32, + y: position.y as f32, + width: size.width as f32, + height: size.height as f32, + } + }) + .collect() +} + +/// Set in physical pixels once the window exists: the viewport builder takes points, whose +/// scale is not known before the window is on a monitor. +fn place_window(window: &Window, saved: WindowGeometry) { + let scale = window.scale_factor() as f32; + let min_size = (WINDOW_MIN_SIZE[0] * scale, WINDOW_MIN_SIZE[1] * scale); + let geometry = fit_into(saved, &monitor_areas(window), min_size); + window.set_outer_position(PhysicalPosition::new(geometry.x, geometry.y)); + // The size granted arrives as a resize event like any other. + let _ = window.request_inner_size(PhysicalSize::new(geometry.width, geometry.height)); } /// The app still runs without a log, e.g. from a read-only folder. @@ -71,7 +86,11 @@ fn main() -> Result<(), Box> { let settings_path = directory.join(SETTINGS_FILE); let saved_window = settings::load(&settings_path).window; let options = eframe::NativeOptions { - viewport: viewport(saved_window), + viewport: ViewportBuilder::default() + .with_title(texts::WINDOW_TITLE) + .with_inner_size(WINDOW_SIZE) + .with_min_inner_size(WINDOW_MIN_SIZE), + centered: true, wgpu_options: wgpu_options(), ..eframe::NativeOptions::default() }; @@ -79,6 +98,10 @@ fn main() -> Result<(), Box> { texts::WINDOW_TITLE, options, Box::new(move |creation_context| { + let window = creation_context.winit_window(); + if let (Some(window), Some(saved)) = (window, saved_window) { + place_window(window, saved); + } let context = creation_context.egui_ctx.clone(); let spawner = Box::new(CoreSpawner::new(context.clone())); Ok(Box::new(NeumannApp::new(&context, settings_path, spawner))) diff --git a/crates/app/src/window_placement.rs b/crates/app/src/window_placement.rs new file mode 100644 index 0000000..f252340 --- /dev/null +++ b/crates/app/src/window_placement.rs @@ -0,0 +1,130 @@ +//! Where the window reopens: the saved geometry, moved onto a monitor that is still there +//! (an undocked laptop would otherwise open it off screen). All values are physical pixels. + +use neumann_control_core::settings::WindowGeometry; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MonitorArea { + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, +} + +/// The saved geometry on the monitor holding its centre, else on the first monitor; shrunk to +/// that monitor but never below `min_size`. Without monitors (none could be listed) the saved +/// geometry is used as it is. +pub fn fit_into( + saved: WindowGeometry, + monitors: &[MonitorArea], + min_size: (f32, f32), +) -> WindowGeometry { + let Some(first) = monitors.first() else { + return saved; + }; + let centre_x = saved.x + saved.width / 2.0; + let centre_y = saved.y + saved.height / 2.0; + let monitor = monitors + .iter() + .find(|monitor| monitor.contains(centre_x, centre_y)) + .unwrap_or(first); + let width = saved.width.min(monitor.width).max(min_size.0); + let height = saved.height.min(monitor.height).max(min_size.1); + WindowGeometry { + x: clamp_start(saved.x, monitor.x, monitor.width, width), + y: clamp_start(saved.y, monitor.y, monitor.height, height), + width, + height, + } +} + +impl MonitorArea { + fn contains(&self, x: f32, y: f32) -> bool { + let inside_x = x >= self.x && x < self.x + self.width; + let inside_y = y >= self.y && y < self.y + self.height; + inside_x && inside_y + } +} + +/// Keeps the window inside the monitor along one axis; a window longer than the monitor +/// (the minimum size wins) starts at the monitor's edge. +fn clamp_start(start: f32, monitor_start: f32, monitor_length: f32, window_length: f32) -> f32 { + let last_start = monitor_start + monitor_length - window_length; + if last_start < monitor_start { + return monitor_start; + } + start.clamp(monitor_start, last_start) +} + +#[cfg(test)] +mod tests { + use super::*; + + const MIN_SIZE: (f32, f32) = (480.0, 360.0); + const LAPTOP: MonitorArea = MonitorArea { + x: 0.0, + y: 0.0, + width: 1920.0, + height: 1080.0, + }; + const DOCKED: MonitorArea = MonitorArea { + x: 1920.0, + y: 0.0, + width: 2560.0, + height: 1440.0, + }; + + fn geometry(x: f32, y: f32, width: f32, height: f32) -> WindowGeometry { + WindowGeometry { + x, + y, + width, + height, + } + } + + #[test] + fn a_window_on_a_present_monitor_stays_where_it_was() { + let saved = geometry(2100.0, 100.0, 900.0, 600.0); + assert_eq!(fit_into(saved, &[LAPTOP, DOCKED], MIN_SIZE), saved); + } + + #[test] + fn a_window_on_a_monitor_that_is_gone_moves_onto_the_first_one() { + let saved = geometry(2100.0, 100.0, 900.0, 600.0); + assert_eq!( + fit_into(saved, &[LAPTOP], MIN_SIZE), + geometry(1020.0, 100.0, 900.0, 600.0) + ); + } + + #[test] + fn a_window_hanging_over_the_edge_is_pulled_in() { + let saved = geometry(-300.0, 900.0, 900.0, 600.0); + assert_eq!( + fit_into(saved, &[LAPTOP], MIN_SIZE), + geometry(0.0, 480.0, 900.0, 600.0) + ); + } + + #[test] + fn a_window_larger_than_its_monitor_shrinks_but_not_below_the_minimum() { + let small = MonitorArea { + x: 0.0, + y: 0.0, + width: 400.0, + height: 800.0, + }; + let saved = geometry(0.0, 0.0, 3000.0, 2000.0); + assert_eq!( + fit_into(saved, &[small], MIN_SIZE), + geometry(0.0, 0.0, 480.0, 800.0) + ); + } + + #[test] + fn without_monitors_the_saved_geometry_is_kept() { + let saved = geometry(-5000.0, -5000.0, 900.0, 600.0); + assert_eq!(fit_into(saved, &[], MIN_SIZE), saved); + } +} diff --git a/crates/core/src/settings.rs b/crates/core/src/settings.rs index 70a0bb5..43e0657 100644 --- a/crates/core/src/settings.rs +++ b/crates/core/src/settings.rs @@ -28,6 +28,7 @@ pub struct ManualSpeaker { pub serial: Option, } +/// Outer position and inner size of the main window in physical pixels. #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub struct WindowGeometry { pub x: f32, From bf9a3db335ad7231e1ded2d74691361313887dc6 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:30:42 +0200 Subject: [PATCH 022/460] Tighten fader behaviour and tests Escape cancels a drag, a drag drops pending wheel steps, a sent level stays shown until confirmed, the wheel is due within 50 ms. Tests wait for later polls before asserting that nothing was sent. --- crates/app/src/fader.rs | 87 ++++++++++++++++++++++++++++++----- crates/app/src/state.rs | 13 ++++++ crates/app/src/views/card.rs | 3 +- crates/app/src/views/fader.rs | 34 +++++++++++--- crates/app/src/writes.rs | 26 ++++++++--- crates/app/tests/views.rs | 83 +++++++++++++++++++++++++++++++++ 6 files changed, 222 insertions(+), 24 deletions(-) diff --git a/crates/app/src/fader.rs b/crates/app/src/fader.rs index 26c196a..2af4b8f 100644 --- a/crates/app/src/fader.rs +++ b/crates/app/src/fader.rs @@ -9,6 +9,11 @@ use crate::paths; pub const WHEEL_STEP_DB: f64 = 0.5; pub const WHEEL_IDLE_SECONDS: f64 = 0.3; +/// egui may wake a frame about 16 ms before the requested time; within this much of the +/// deadline the wheel value counts as due instead of booking another tiny wait. +pub const DUE_SLACK_SECONDS: f64 = 0.05; +/// How long the fader keeps showing a sent value while the speaker has not confirmed it. +pub const SENT_HOLD_SECONDS: f64 = 2.0; /// Fractions of a notch are counted in whole units so ten tenths make exactly one notch. const NOTCH_RESOLUTION: f64 = 1000.0; @@ -28,6 +33,8 @@ pub struct FaderState { last_step: f64, /// Wheel movement short of a whole notch, in 1/NOTCH_RESOLUTION notches. partial_notch: i64, + /// The last value sent and when, shown until the speaker reports the level again. + sent: Option<(f64, f64)>, } impl FaderState { @@ -49,20 +56,43 @@ impl FaderState { } pub fn due(&mut self, now: f64) -> Option { - let idle = now - self.last_step >= WHEEL_IDLE_SECONDS; + let idle = now - self.last_step >= WHEEL_IDLE_SECONDS - DUE_SLACK_SECONDS; if self.pending.is_some() && idle { return self.pending.take(); } None } - /// When the pending wheel value becomes due, so the UI can wake up for it. + /// A drag takes over from the wheel: steps not yet sent would overwrite its value. + pub fn drag_started(&mut self) { + self.pending = None; + self.partial_notch = 0; + } + + pub fn sent(&mut self, value: f64, now: f64) { + self.drag_started(); + self.sent = Some((value, now)); + } + + /// The speaker reported the level; from now on its value is shown. + pub fn confirmed(&mut self) { + self.sent = None; + } + + /// When the shown value changes on its own (the wheel becomes due, a sent value stops + /// being held), so the UI can wake up for it. pub fn deadline(&self) -> Option { - self.pending.map(|_| self.last_step + WHEEL_IDLE_SECONDS) + let wheel = self.pending.map(|_| self.last_step + WHEEL_IDLE_SECONDS); + let hold = self.sent.map(|(_, at)| at + SENT_HOLD_SECONDS); + wheel.into_iter().chain(hold).reduce(f64::min) } - pub fn shown(&self, device: f64) -> f64 { - self.pending.unwrap_or(device) + pub fn shown(&self, device: f64, now: f64) -> f64 { + let held = self + .sent + .filter(|(_, at)| now - at < SENT_HOLD_SECONDS) + .map(|(value, _)| value); + self.pending.or(held).unwrap_or(device) } } @@ -77,11 +107,11 @@ mod tests { fader.on_wheel(1.0, 80.0, range.clone(), 1.0); fader.on_wheel(1.0, 80.0, range.clone(), 1.125); fader.on_wheel(1.0, 80.0, range, 1.25); - assert_eq!(fader.shown(80.0), 81.5); - assert_eq!(fader.due(1.5), None); + assert_eq!(fader.shown(80.0, 0.0), 81.5); + assert_eq!(fader.due(1.375), None); assert_eq!(fader.due(1.625), Some(81.5)); assert_eq!(fader.due(2.0), None); - assert_eq!(fader.shown(80.0), 80.0); + assert_eq!(fader.shown(80.0, 0.0), 80.0); } #[test] @@ -103,11 +133,46 @@ mod tests { for _ in 0..10 { fader.on_wheel(0.1, 80.0, range.clone(), 1.0); } - assert_eq!(fader.shown(80.0), 80.5); + assert_eq!(fader.shown(80.0, 0.0), 80.5); fader.on_wheel(0.5, 80.0, range.clone(), 1.0); - assert_eq!(fader.shown(80.0), 80.5); + assert_eq!(fader.shown(80.0, 0.0), 80.5); fader.on_wheel(0.5, 80.0, range, 1.0); - assert_eq!(fader.shown(80.0), 81.0); + assert_eq!(fader.shown(80.0, 0.0), 81.0); + } + + #[test] + fn the_wheel_is_due_within_the_slack_before_its_deadline() { + let range = output_level_range(); + let mut fader = FaderState::default(); + fader.on_wheel(1.0, 80.0, range, 1.0); + assert_eq!(fader.due(1.125), None); + assert_eq!(fader.due(1.25), Some(80.5)); + } + + #[test] + fn a_drag_drops_wheel_steps_not_yet_sent() { + let range = output_level_range(); + let mut fader = FaderState::default(); + fader.on_wheel(2.0, 80.0, range, 1.0); + fader.drag_started(); + assert_eq!(fader.due(5.0), None); + assert_eq!(fader.deadline(), None); + } + + #[test] + fn a_sent_value_is_shown_until_confirmed_or_two_seconds_passed() { + let range = output_level_range(); + let mut fader = FaderState::default(); + fader.on_wheel(1.0, 80.0, range, 1.0); + fader.sent(90.0, 1.0); + assert_eq!(fader.due(5.0), None); + assert_eq!(fader.shown(80.0, 2.5), 90.0); + assert_eq!(fader.deadline(), Some(1.0 + SENT_HOLD_SECONDS)); + assert_eq!(fader.shown(80.0, 3.0), 80.0); + fader.sent(95.0, 4.0); + fader.confirmed(); + assert_eq!(fader.shown(80.0, 4.0), 80.0); + assert_eq!(fader.deadline(), None); } #[test] diff --git a/crates/app/src/state.rs b/crates/app/src/state.rs index 4c58b2e..6c03e6d 100644 --- a/crates/app/src/state.rs +++ b/crates/app/src/state.rs @@ -118,6 +118,9 @@ impl SpeakerState { if self.message_path.as_ref() == Some(&path) { self.set_message(None, None); } + if path == paths::path(paths::OUTPUT_LEVEL) { + self.fader.confirmed(); + } self.absent.remove(&path); self.feed_meters(&path, &value, now); self.values.insert(path, value); @@ -349,6 +352,16 @@ mod tests { assert_eq!(speaker.message, None); } + #[test] + fn a_reported_level_ends_the_held_fader_value() { + let mut speaker = state(); + speaker.fader.sent(90.0, 0.0); + speaker.apply(value_event("device/name", json!("left")), 0.5); + assert_eq!(speaker.fader.shown(80.0, 0.5), 90.0); + speaker.apply(value_event(paths::OUTPUT_LEVEL, json!(80.0)), 0.5); + assert_eq!(speaker.fader.shown(80.0, 0.5), 80.0); + } + fn identity(name: &str, product: &str) -> Identity { Identity { product: product.to_owned(), diff --git a/crates/app/src/views/card.rs b/crates/app/src/views/card.rs index b27fc32..e772ba4 100644 --- a/crates/app/src/views/card.rs +++ b/crates/app/src/views/card.rs @@ -173,7 +173,8 @@ fn toggle( let enabled = current.is_some() && writes::can_write(speaker, catalog, &path); let button = Button::new(text).selected(current.unwrap_or(false)); if ui.add_enabled(enabled, button).clicked() { - commands.extend(speaker.request(Intent::Toggle(switch), catalog)); + let now = ui.input(|input| input.time); + commands.extend(speaker.request(Intent::Toggle(switch), catalog, now)); } } diff --git a/crates/app/src/views/fader.rs b/crates/app/src/views/fader.rs index 51c71e0..46462a7 100644 --- a/crates/app/src/views/fader.rs +++ b/crates/app/src/views/fader.rs @@ -3,7 +3,7 @@ use std::ops::RangeInclusive; -use eframe::egui::{Event, MouseWheelUnit, Slider, Ui, Vec2}; +use eframe::egui::{Event, Key, MouseWheelUnit, Response, Slider, Ui, Vec2}; use neumann_control_core::catalog::Catalog; use neumann_control_core::speaker::Command; @@ -12,6 +12,8 @@ use crate::paths; use crate::state::SpeakerState; use crate::writes::{self, Intent}; +const DRAG_ABORTED_ID: &str = "fader_drag_aborted"; + /// The slider's length. pub const FADER_HEIGHT: f32 = 150.0; @@ -28,19 +30,26 @@ pub fn show( let enabled = device.is_some() && writes::can_write(speaker, catalog, &path); let device = device.unwrap_or(*range.start()); let now = ui.input(|input| input.time); - let mut value = speaker.fader.shown(device); + let mut value = speaker.fader.shown(device, now); ui.spacing_mut().slider_width = FADER_HEIGHT; let slider = Slider::new(&mut value, range.clone()) .vertical() .show_value(false) .step_by(WHEEL_STEP_DB); let response = ui.add_enabled(enabled, slider); + if response.drag_started() { + speaker.fader.drag_started(); + } + let aborted = drag_aborted(ui, &response); + if aborted { + value = speaker.fader.shown(device, now); + } // While dragging, the slider follows the pointer and the speaker hears nothing. The release // frame still moves the value but no longer counts as dragged, so this one condition sends // the final value of a drag, a click on the track and a key press alike. - let settled = response.changed() && !response.dragged(); + let settled = response.changed() && !response.dragged() && !aborted; if settled { - commands.extend(speaker.request(Intent::Level(value), catalog)); + commands.extend(speaker.request(Intent::Level(value), catalog, now)); } if enabled && response.hovered() { // The cards sit in a scroll area, which would scroll along with the fader. @@ -48,10 +57,10 @@ pub fn show( for notches in wheel_notches(ui) { speaker.fader.on_wheel(notches, device, range.clone(), now); } - value = speaker.fader.shown(device); + value = speaker.fader.shown(device, now); } if let Some(due) = speaker.fader.due(now) { - commands.extend(speaker.request(Intent::Level(due), catalog)); + commands.extend(speaker.request(Intent::Level(due), catalog, now)); } if let Some(deadline) = speaker.fader.deadline() { let wait = (deadline - now).max(0.0); @@ -60,6 +69,19 @@ pub fn show( value } +/// Escape ends egui's drag but leaves the button down, and the slider keeps following the +/// pointer until it is released; none of that may reach the speaker. True from the Escape +/// frame through the release frame. +fn drag_aborted(ui: &Ui, response: &Response) -> bool { + let id = response.id.with(DRAG_ABORTED_ID); + let was_aborted = ui.data(|data| data.get_temp::(id)).unwrap_or(false); + let escape = ui.input(|input| input.key_pressed(Key::Escape)); + let aborted = was_aborted || (escape && response.drag_stopped()); + let button_down = ui.input(|input| input.pointer.primary_down()); + ui.data_mut(|data| data.insert_temp(id, aborted && button_down)); + aborted +} + /// Lines and pixels of a precision touchpad come as many small events, pages as rare large /// ones; egui scrolls this many points per line. const POINTS_PER_NOTCH: f32 = 40.0; diff --git a/crates/app/src/writes.rs b/crates/app/src/writes.rs index 1ca5ff1..5607ad7 100644 --- a/crates/app/src/writes.rs +++ b/crates/app/src/writes.rs @@ -82,13 +82,19 @@ pub fn check( impl SpeakerState { /// The command for `intent`, or `None` after putting the refusal on the card. - pub fn request(&mut self, intent: Intent, catalog: &Catalog) -> Option { + /// A sent level is held on the fader until the speaker reports it (`now` is the UI clock). + pub fn request(&mut self, intent: Intent, catalog: &Catalog, now: f64) -> Option { let path = intent.path(); let checked = self .intended_value(intent, &path) .and_then(|value| check(self, catalog, &path, &value).map(|()| value)); match checked { - Ok(value) => Some(Command::Set { path, value }), + Ok(value) => { + if let Intent::Level(level) = intent { + self.fader.sent(level, now); + } + Some(Command::Set { path, value }) + } Err(refusal) => { log::warn!("not sending {path}: {refusal:?}"); self.set_message(Some(texts::describe_refusal(&refusal)), Some(path)); @@ -149,15 +155,23 @@ mod tests { #[test] fn toggle_writes_the_opposite_switch_value() { let mut speaker = speaker("KH 120 II"); - let command = speaker.request(Intent::Toggle(Switch::Mute), Catalog::embedded()); + let command = speaker.request(Intent::Toggle(Switch::Mute), Catalog::embedded(), 0.0); assert_eq!(command, set(paths::MUTE, Value::Bool(true))); assert_eq!(speaker.message, None); } + #[test] + fn a_sent_level_is_held_on_the_fader() { + let mut speaker = speaker("KH 120 II"); + let command = speaker.request(Intent::Level(90.0), Catalog::embedded(), 0.0); + assert_eq!(command, set(paths::OUTPUT_LEVEL, Value::from(90.0))); + assert_eq!(speaker.fader.shown(80.0, 0.5), 90.0); + } + #[test] fn a_model_without_write_permission_is_refused_with_a_message() { let mut speaker = speaker("KH 80"); - let command = speaker.request(Intent::Level(80.0), Catalog::embedded()); + let command = speaker.request(Intent::Level(80.0), Catalog::embedded(), 0.0); assert_eq!(command, None); let expected = texts::describe_refusal(&Refusal::NotWritable); assert_eq!(speaker.message.as_deref(), Some(expected.as_str())); @@ -171,7 +185,7 @@ mod tests { #[test] fn an_out_of_range_value_is_refused_with_the_catalog_reason() { let mut speaker = speaker("KH 120 II"); - let command = speaker.request(Intent::Level(130.0), Catalog::embedded()); + let command = speaker.request(Intent::Level(130.0), Catalog::embedded(), 0.0); assert_eq!(command, None); let reason = ValueError::OutOfRange { min: 0.0, @@ -204,7 +218,7 @@ mod tests { #[test] fn a_switch_without_a_reported_value_is_refused() { let mut speaker = speaker("KH 120 II"); - let command = speaker.request(Intent::Toggle(Switch::Identify), Catalog::embedded()); + let command = speaker.request(Intent::Toggle(Switch::Identify), Catalog::embedded(), 0.0); assert_eq!(command, None); let expected = texts::describe_refusal(&Refusal::ValueUnknown); assert_eq!(speaker.message.as_deref(), Some(expected.as_str())); diff --git a/crates/app/tests/views.rs b/crates/app/tests/views.rs index d3aee8a..ec7ddb2 100644 --- a/crates/app/tests/views.rs +++ b/crates/app/tests/views.rs @@ -108,6 +108,24 @@ fn wait_for_level(harness: &mut Harness<'static, NeumannApp>, value: f64) { }); } +/// Requests the simulator must receive after a frame before "nothing was sent" can be +/// asserted: the worker runs commands before polls, so one request still in flight plus one +/// more mean anything the frame sent has arrived. +const BARRIER_REQUESTS: usize = 2; + +/// Waits without drawing frames, so the window sends nothing new meanwhile. +fn barrier(simulator: &Simulator) { + let start = simulator.requests().len(); + let deadline = Instant::now() + WAIT_LIMIT; + while simulator.requests().len() < start + BARRIER_REQUESTS { + assert!( + Instant::now() < deadline, + "timed out waiting for later polls" + ); + thread::sleep(POLL_PAUSE); + } +} + fn level_sets(simulator: &Simulator) -> Vec { simulator .requests() @@ -146,6 +164,7 @@ fn fader_drag_sends_once_on_release() { harness.hover_at(start + egui::vec2(0.0, 4.0 * frame as f32)); harness.step(); } + barrier(&simulator); assert!( level_sets(&simulator).is_empty(), "a Set went out while dragging" @@ -160,6 +179,7 @@ fn fader_drag_sends_once_on_release() { !level_sets(&simulator).is_empty() }); harness.run_steps(10); + barrier(&simulator); let sets = level_sets(&simulator); assert_eq!(sets.len(), 1, "{sets:?}"); assert!(sets[0] < 100.0, "dragging down lowers the level: {sets:?}"); @@ -180,15 +200,78 @@ fn wheel_over_fader_sends_one_set_after_the_wheel_went_quiet() { }); } harness.step(); + barrier(&simulator); assert!(level_sets(&simulator).is_empty(), "the wheel wrote at once"); harness.get_by_label(&readout(101.0)); wait_until(&mut harness, "the wheel Set", |_| { !level_sets(&simulator).is_empty() }); harness.run_steps(10); + barrier(&simulator); assert_eq!(level_sets(&simulator), vec![101.0]); } +#[test] +fn escape_during_a_drag_sends_nothing() { + let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + let start = harness.get_by_role(Role::Slider).rect().center(); + harness.drag_at(start); + for frame in 1..=5 { + harness.hover_at(start + egui::vec2(0.0, 4.0 * frame as f32)); + harness.step(); + } + harness.key_press(egui::Key::Escape); + harness.step(); + for frame in 6..=10 { + harness.hover_at(start + egui::vec2(0.0, 4.0 * frame as f32)); + harness.step(); + } + harness.drop_at(start + egui::vec2(0.0, 40.0)); + harness.run_steps(10); + barrier(&simulator); + assert!( + level_sets(&simulator).is_empty(), + "{:?}", + level_sets(&simulator) + ); + harness.get_by_label(&readout(100.0)); + // The next drag is a normal one again. + harness.drag_at(start); + harness.hover_at(start + egui::vec2(0.0, 20.0)); + harness.step(); + harness.drop_at(start + egui::vec2(0.0, 20.0)); + wait_until(&mut harness, "the next drag's Set", |_| { + !level_sets(&simulator).is_empty() + }); +} + +#[test] +fn a_drag_replaces_wheel_steps_not_yet_sent() { + let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + let start = harness.get_by_role(Role::Slider).rect().center(); + harness.hover_at(start); + harness.event(egui::Event::MouseWheel { + unit: MouseWheelUnit::Line, + delta: egui::vec2(0.0, 1.0), + phase: TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + harness.drag_at(start); + for frame in 1..=4 { + harness.hover_at(start + egui::vec2(0.0, 5.0 * frame as f32)); + harness.step(); + } + harness.drop_at(start + egui::vec2(0.0, 20.0)); + wait_until(&mut harness, "the drag's Set", |_| { + !level_sets(&simulator).is_empty() + }); + harness.run_steps(10); + barrier(&simulator); + let sets = level_sets(&simulator); + assert_eq!(sets.len(), 1, "{sets:?}"); + assert!(sets[0] < 100.0, "{sets:?}"); +} + #[test] fn mute_button_sends_set() { let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); From 93fd35cbab2fe2f773bd6bdb22813ac24c6ad2f5 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:32:16 +0200 Subject: [PATCH 023/460] Name an already listed address in the dialog --- crates/app/src/app.rs | 11 +---- crates/app/src/targets.rs | 7 +++ crates/app/src/texts.rs | 1 + crates/app/src/views/add_address.rs | 71 ++++++++++++++++++++--------- crates/app/tests/views.rs | 25 ++++++++++ 5 files changed, 85 insertions(+), 30 deletions(-) diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index c64cd0c..e4611cc 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -186,18 +186,11 @@ impl NeumannApp { } } + /// The dialog already refused unparsable and listed addresses. fn add_manual(&mut self, manual: ManualSpeaker) { let Ok(address) = parse_address(&manual.address, manual.port) else { return; }; - let known = self - .settings - .manual - .iter() - .any(|existing| parse_address(&existing.address, existing.port) == Ok(address)); - if known { - return; - } log::info!("manual speaker added at {}", mask_host(&address)); self.settings.manual.push(manual); self.save_settings(); @@ -257,7 +250,7 @@ impl NeumannApp { let Some(form) = &mut self.add_address else { return; }; - match add_address::show(ui, form) { + match add_address::show(ui, form, &self.settings.manual) { AddAddressAction::None => {} AddAddressAction::Cancel => self.add_address = None, AddAddressAction::Add(manual) => { diff --git a/crates/app/src/targets.rs b/crates/app/src/targets.rs index dab7b4a..8c78281 100644 --- a/crates/app/src/targets.rs +++ b/crates/app/src/targets.rs @@ -34,6 +34,13 @@ pub fn merge_targets(manual: &[ManualSpeaker], found: &[Found]) -> Vec<(SpeakerK targets } +/// Whether a manual entry already points at `address`, however it was written. +pub fn is_listed(manual: &[ManualSpeaker], address: SocketAddrV6) -> bool { + manual + .iter() + .any(|entry| parse_address(&entry.address, entry.port) == Ok(address)) +} + /// Writes the serial a speaker reported into the manual entries for its address that have none /// yet, so the next merge joins them with scan results by serial. True when an entry changed. pub fn remember_serial(manual: &mut [ManualSpeaker], serial: &str, address: SocketAddrV6) -> bool { diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 440cb8d..825e529 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -27,6 +27,7 @@ pub const ADDRESS_FIELD: &str = "IPv6-Adresse"; pub const ADDRESS_HINT: &str = "z. B. fe80::1%7"; pub const ADD: &str = "Hinzufügen"; pub const CANCEL: &str = "Abbrechen"; +pub const ADDRESS_ALREADY_LISTED: &str = "Diese Adresse ist schon eingetragen."; pub const UNREACHABLE: &str = "nicht erreichbar (aus oder Standby)"; pub const WRITE_FAILED: &str = "Schreiben fehlgeschlagen"; diff --git a/crates/app/src/views/add_address.rs b/crates/app/src/views/add_address.rs index 286e34b..851c697 100644 --- a/crates/app/src/views/add_address.rs +++ b/crates/app/src/views/add_address.rs @@ -4,6 +4,7 @@ use eframe::egui::{Id, Key, Modal, TextEdit, Ui}; use neumann_control_core::address::{parse_address, AddressError, SSC_PORT}; use neumann_control_core::settings::ManualSpeaker; +use crate::targets::is_listed; use crate::texts; const MODAL_ID: &str = "add_address"; @@ -12,10 +13,16 @@ const FIELD_WIDTH: f32 = 260.0; #[derive(Debug, Default)] pub struct AddAddressForm { text: String, - error: Option, + error: Option, focused_once: bool, } +#[derive(Debug, PartialEq)] +enum FormError { + Address(AddressError), + AlreadyListed, +} + #[derive(Debug, PartialEq)] pub enum AddAddressAction { None, @@ -23,15 +30,16 @@ pub enum AddAddressAction { Add(ManualSpeaker), } -pub fn show(ui: &mut Ui, form: &mut AddAddressForm) -> AddAddressAction { - let modal = Modal::new(Id::new(MODAL_ID)).show(ui.ctx(), |ui| contents(ui, form)); +/// `listed` are the manual speakers already in the settings. +pub fn show(ui: &mut Ui, form: &mut AddAddressForm, listed: &[ManualSpeaker]) -> AddAddressAction { + let modal = Modal::new(Id::new(MODAL_ID)).show(ui.ctx(), |ui| contents(ui, form, listed)); if modal.should_close() { return AddAddressAction::Cancel; } modal.inner } -fn contents(ui: &mut Ui, form: &mut AddAddressForm) -> AddAddressAction { +fn contents(ui: &mut Ui, form: &mut AddAddressForm, listed: &[ManualSpeaker]) -> AddAddressAction { ui.heading(texts::ADD_ADDRESS_TITLE); let label = ui.label(texts::ADDRESS_FIELD); let field = ui @@ -55,10 +63,11 @@ fn contents(ui: &mut Ui, form: &mut AddAddressForm) -> AddAddressAction { form.error = None; } if let Some(error) = &form.error { - ui.colored_label( - ui.visuals().error_fg_color, - texts::describe_address_error(error), - ); + let text = match error { + FormError::Address(error) => texts::describe_address_error(error), + FormError::AlreadyListed => texts::ADDRESS_ALREADY_LISTED.to_owned(), + }; + ui.colored_label(ui.visuals().error_fg_color, text); } let submitted = field.lost_focus() && ui.input(|input| input.key_pressed(Key::Enter)); let (added, cancelled) = ui @@ -75,22 +84,27 @@ fn contents(ui: &mut Ui, form: &mut AddAddressForm) -> AddAddressAction { if !(added || submitted) { return AddAddressAction::None; } - submit(form) + submit(form, listed) } -fn submit(form: &mut AddAddressForm) -> AddAddressAction { +fn submit(form: &mut AddAddressForm, listed: &[ManualSpeaker]) -> AddAddressAction { let text = form.text.trim(); - match parse_address(text, SSC_PORT) { - Ok(_) => AddAddressAction::Add(ManualSpeaker { - address: text.to_owned(), - port: SSC_PORT, - serial: None, - }), + let address = match parse_address(text, SSC_PORT) { + Ok(address) => address, Err(error) => { - form.error = Some(error); - AddAddressAction::None + form.error = Some(FormError::Address(error)); + return AddAddressAction::None; } + }; + if is_listed(listed, address) { + form.error = Some(FormError::AlreadyListed); + return AddAddressAction::None; } + AddAddressAction::Add(ManualSpeaker { + address: text.to_owned(), + port: SSC_PORT, + serial: None, + }) } #[cfg(test)] @@ -108,7 +122,7 @@ mod tests { fn a_link_local_address_with_index_is_added_on_port_45_trimmed() { let mut form = form(" fe80::2a36:38ff:fe12:3456%7 "); assert_eq!( - submit(&mut form), + submit(&mut form, &[]), AddAddressAction::Add(ManualSpeaker { address: "fe80::2a36:38ff:fe12:3456%7".to_owned(), port: SSC_PORT, @@ -120,7 +134,22 @@ mod tests { #[test] fn a_link_local_address_without_index_keeps_the_dialog_with_its_reason() { let mut form = form("fe80::2a36:38ff:fe12:3456"); - assert_eq!(submit(&mut form), AddAddressAction::None); - assert_eq!(form.error, Some(AddressError::MissingScope)); + assert_eq!(submit(&mut form, &[]), AddAddressAction::None); + assert_eq!( + form.error, + Some(FormError::Address(AddressError::MissingScope)) + ); + } + + #[test] + fn an_address_already_listed_in_another_spelling_is_refused() { + let listed = [ManualSpeaker { + address: "fe80::2a36:38ff:fe12:3456%7".to_owned(), + port: SSC_PORT, + serial: None, + }]; + let mut form = form("[FE80::2A36:38FF:FE12:3456%7]"); + assert_eq!(submit(&mut form, &listed), AddAddressAction::None); + assert_eq!(form.error, Some(FormError::AlreadyListed)); } } diff --git a/crates/app/tests/views.rs b/crates/app/tests/views.rs index ec7ddb2..425f986 100644 --- a/crates/app/tests/views.rs +++ b/crates/app/tests/views.rs @@ -537,3 +537,28 @@ fn two_manual_entries_for_one_speaker_stay_one_card() { }); wait_until(&mut harness, "one card", has_one_card); } + +#[test] +fn add_address_names_an_address_already_listed() { + let directory = tempfile::tempdir().unwrap(); + let listed = ManualSpeaker { + address: LOOPBACK.to_owned(), + port: neumann_control_core::address::SSC_PORT, + serial: None, + }; + let path = settings_file(directory.path(), vec![listed]); + let mut harness = harness(path.clone(), Vec::new()); + harness.get_by_label(texts::ADD_ADDRESS).click(); + harness.run_steps(2); + harness.get_by_label(texts::ADDRESS_FIELD).click(); + harness.run_steps(1); + harness + .get_by_label(texts::ADDRESS_FIELD) + .type_text("[::1]"); + harness.run_steps(1); + harness.get_by_label(texts::ADD).click(); + harness.run_steps(2); + harness.get_by_label(texts::ADDRESS_ALREADY_LISTED); + harness.get_by_label(texts::ADD_ADDRESS_TITLE); + assert_eq!(settings::load(&path).manual.len(), 1); +} From 9b88d5e080a7acdff272e677fcda036298c39f2e Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:36:00 +0200 Subject: [PATCH 024/460] Split the window tests by responsibility --- crates/app/tests/glyphs.rs | 78 +++++++ crates/app/tests/speakers.rs | 170 ++++++++++++++ crates/app/tests/support/mod.rs | 159 +++++++++++++ crates/app/tests/views.rs | 385 +------------------------------- 4 files changed, 412 insertions(+), 380 deletions(-) create mode 100644 crates/app/tests/glyphs.rs create mode 100644 crates/app/tests/speakers.rs create mode 100644 crates/app/tests/support/mod.rs diff --git a/crates/app/tests/glyphs.rs b/crates/app/tests/glyphs.rs new file mode 100644 index 0000000..d05ffd4 --- /dev/null +++ b/crates/app/tests/glyphs.rs @@ -0,0 +1,78 @@ +//! Every UI text can be drawn with the bundled fonts. + +use eframe::egui::{self, FontFamily, FontId}; +use neumann_control_app::texts; +use neumann_control_app::ui_kit::fonts; +use neumann_control_core::address::AddressError; +use neumann_control_core::catalog::{Catalog, ValueError}; +use neumann_control_core::mask::mask_serial; + +const SIMULATED_SERIAL: &str = "SIM0001234"; + +/// Every string literal in texts.rs, so a new text is covered without being listed here. +fn literals_of_texts_module() -> Vec { + let source = include_str!("../src/texts.rs"); + let mut literals = Vec::new(); + let mut rest = source; + while let Some(start) = rest.find('"') { + let after = &rest[start + 1..]; + let end = after.find('"').expect("closed literal"); + literals.push(after[..end].to_owned()); + rest = &after[end + 1..]; + } + literals +} + +#[test] +fn all_ui_texts_have_glyphs() { + let context = egui::Context::default(); + fonts::install(&context); + context + .run_ui(egui::RawInput::default(), |_| {}) + .textures_delta + .clear(); + let catalog = Catalog::embedded(); + let mut texts_to_check = literals_of_texts_module(); + texts_to_check.extend([ + texts::speaker_count(12), + texts::port(45), + texts::readout(-12.5, "dB SPL"), + texts::status_line("1_7_3", &mask_serial(SIMULATED_SERIAL)), + texts::serial_mismatch(&mask_serial(SIMULATED_SERIAL)), + texts::describe_rejection(405), + texts::describe_rejection(406), + texts::describe_rejection(500), + ]); + for error in [ + AddressError::Ipv4NotSupported, + AddressError::MissingScope, + AddressError::ScopeNotNumeric, + AddressError::Invalid, + ] { + texts_to_check.push(texts::describe_address_error(&error)); + } + for error in [ + ValueError::OutOfRange { min: 0.0, max: 1.0 }, + ValueError::NotAnOption, + ValueError::NotAStep, + ValueError::TooLong(52), + ValueError::NotPrintableAscii, + ValueError::WrongType, + ] { + texts_to_check.push(texts::describe_value_error(&error)); + } + for entry in &catalog.entries { + texts_to_check.push(entry.group.title().to_owned()); + texts_to_check.push(entry.label.clone()); + texts_to_check.extend(entry.unit.clone()); + } + let families = [FontFamily::Proportional, fonts::semibold(12.0).family]; + for family in families { + let font = FontId::new(12.0, family.clone()); + for text in &texts_to_check { + let covered = context.fonts_mut(|fonts| fonts.has_glyphs(&font, text)); + assert!(covered, "{text:?} in {family:?}"); + } + } + assert!(texts_to_check.iter().any(|text| text == texts::NO_SPEAKERS)); +} diff --git a/crates/app/tests/speakers.rs b/crates/app/tests/speakers.rs new file mode 100644 index 0000000..e0cff2a --- /dev/null +++ b/crates/app/tests/speakers.rs @@ -0,0 +1,170 @@ +//! Which cards the window shows: manual addresses, scans, one card per speaker. + +mod support; + +use egui_kittest::kittest::Queryable; +use neumann_control_app::texts; +use neumann_control_core::address::{AddressError, SSC_PORT}; +use neumann_control_core::discovery::Found; +use neumann_control_core::settings::{self, ManualSpeaker}; +use neumann_control_core::simulator::{SimModel, Simulator}; +use std::time::Duration; +use support::*; + +#[test] +fn add_address_refuses_ipv4_with_hint() { + let directory = tempfile::tempdir().unwrap(); + let path = settings_file(directory.path(), Vec::new()); + let mut harness = harness(path.clone(), Vec::new()); + harness.get_by_label(texts::ADD_ADDRESS).click(); + harness.run_steps(2); + harness.get_by_label(texts::ADDRESS_FIELD).click(); + harness.run_steps(1); + harness + .get_by_label(texts::ADDRESS_FIELD) + .type_text("192.168.1.20"); + harness.run_steps(1); + harness.get_by_label(texts::ADD).click(); + harness.run_steps(2); + harness.get_by_label(&texts::describe_address_error( + &AddressError::Ipv4NotSupported, + )); + assert!(settings::load(&path).manual.is_empty()); + harness.get_by_label(&texts::speaker_count(0)); +} + +#[test] +fn add_address_saves_the_speaker_and_shows_its_card() { + let directory = tempfile::tempdir().unwrap(); + let path = settings_file(directory.path(), Vec::new()); + let mut harness = harness(path.clone(), Vec::new()); + harness.get_by_label(texts::ADD_ADDRESS).click(); + harness.run_steps(2); + harness.get_by_label(texts::ADDRESS_FIELD).click(); + harness.run_steps(1); + harness + .get_by_label(texts::ADDRESS_FIELD) + .type_text(LOOPBACK); + harness.run_steps(1); + harness.get_by_label(texts::ADD).click(); + harness.run_steps(2); + assert!(harness.query_by_label(texts::ADD_ADDRESS_TITLE).is_none()); + let saved = settings::load(&path).manual; + assert_eq!(saved.len(), 1); + assert_eq!(saved[0].address, LOOPBACK); + assert_eq!(saved[0].port, SSC_PORT); + harness.get_by_label(&texts::speaker_count(1)); +} + +#[test] +fn scan_adds_found_speakers_once() { + let simulator = Simulator::start(SimModel::Kh750); + let directory = tempfile::tempdir().unwrap(); + let path = settings_file(directory.path(), Vec::new()); + let found = Found { + name: String::new(), + product: Some("KH 750".to_owned()), + serial: Some("SIM0007500".to_owned()), + address: simulator.address(), + }; + let mut harness = harness(path, vec![found]); + harness.get_by_label(texts::SCAN).click(); + wait_for_level(&mut harness, 100.0); + harness.get_by_label("KH 750"); + harness.get_by_label(texts::MORE).click(); + harness.run_steps(2); + harness.get_by_label(texts::SCAN).click(); + harness.run_steps(5); + harness.get_by_label(&texts::speaker_count(1)); + // The card kept its state, so it was not recreated. + harness.get_by_label(texts::LESS); +} + +#[test] +fn scan_moves_a_known_speaker_to_its_new_address() { + let simulator = Simulator::start(SimModel::Kh120Ii); + let directory = tempfile::tempdir().unwrap(); + let stale = ManualSpeaker { + address: LOOPBACK.to_owned(), + port: SSC_PORT, + serial: Some(SIMULATED_SERIAL.to_owned()), + }; + let path = settings_file(directory.path(), vec![stale]); + let found = Found { + name: "announced".to_owned(), + product: None, + serial: Some(SIMULATED_SERIAL.to_owned()), + address: simulator.address(), + }; + let mut harness = harness(path, vec![found]); + harness.get_by_label(&texts::speaker_count(1)); + harness.get_by_label(texts::SCAN).click(); + wait_for_level(&mut harness, 100.0); + harness.get_by_label(&texts::speaker_count(1)); +} + +#[test] +fn a_manual_speaker_found_at_a_second_address_stays_one_card() { + let manual_simulator = Simulator::start(SimModel::Kh120Ii); + // The manual card connects only after the scan created a card for the second address. + manual_simulator.set_reply_delay(Duration::from_millis(300)); + let second_simulator = Simulator::start(SimModel::Kh120Ii); + let directory = tempfile::tempdir().unwrap(); + let path = settings_file(directory.path(), vec![manual_for(&manual_simulator)]); + let found = Found { + name: String::new(), + product: Some("KH 120 II".to_owned()), + serial: Some(SIMULATED_SERIAL.to_owned()), + address: second_simulator.address(), + }; + let mut harness = harness(path.clone(), vec![found]); + harness.get_by_label(texts::SCAN).click(); + wait_until(&mut harness, "the serial in the manual entry", |_| { + serials_remembered(&path, 1) + }); + wait_until(&mut harness, "one card after the first scan", has_one_card); + harness.get_by_label(texts::SCAN).click(); + harness.run_steps(5); + wait_until(&mut harness, "one card after the second scan", has_one_card); +} + +#[test] +fn two_manual_entries_for_one_speaker_stay_one_card() { + let first = Simulator::start(SimModel::Kh120Ii); + let second = Simulator::start(SimModel::Kh120Ii); + let directory = tempfile::tempdir().unwrap(); + let path = settings_file( + directory.path(), + vec![manual_for(&first), manual_for(&second)], + ); + let mut harness = harness(path.clone(), Vec::new()); + wait_until(&mut harness, "both serials remembered", |_| { + serials_remembered(&path, 2) + }); + wait_until(&mut harness, "one card", has_one_card); +} + +#[test] +fn add_address_names_an_address_already_listed() { + let directory = tempfile::tempdir().unwrap(); + let listed = ManualSpeaker { + address: LOOPBACK.to_owned(), + port: SSC_PORT, + serial: None, + }; + let path = settings_file(directory.path(), vec![listed]); + let mut harness = harness(path.clone(), Vec::new()); + harness.get_by_label(texts::ADD_ADDRESS).click(); + harness.run_steps(2); + harness.get_by_label(texts::ADDRESS_FIELD).click(); + harness.run_steps(1); + harness + .get_by_label(texts::ADDRESS_FIELD) + .type_text("[::1]"); + harness.run_steps(1); + harness.get_by_label(texts::ADD).click(); + harness.run_steps(2); + harness.get_by_label(texts::ADDRESS_ALREADY_LISTED); + harness.get_by_label(texts::ADD_ADDRESS_TITLE); + assert_eq!(settings::load(&path).manual.len(), 1); +} diff --git a/crates/app/tests/support/mod.rs b/crates/app/tests/support/mod.rs new file mode 100644 index 0000000..3f99a77 --- /dev/null +++ b/crates/app/tests/support/mod.rs @@ -0,0 +1,159 @@ +//! Shared by the window tests: the app with real speaker workers talking to the simulator. +//! Threads deliver the events, so tests step frames until a condition holds (bounded by a +//! deadline) instead of `run()`. +// Each test binary uses its own subset of these helpers. +#![allow(dead_code)] + +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{self, Receiver}; +use std::sync::Arc; +use std::thread; +use std::time::{Duration, Instant}; + +use egui_kittest::kittest::Queryable; +use egui_kittest::Harness; +use neumann_control_app::app::NeumannApp; +use neumann_control_app::spawner::{ScanResult, SpeakerSpawner}; +use neumann_control_app::texts; +use neumann_control_core::discovery::Found; +use neumann_control_core::settings::{self, ManualSpeaker, Settings, SETTINGS_FILE}; +use neumann_control_core::simulator::{SimModel, Simulator}; +use neumann_control_core::speaker::{self, Event, SpeakerHandle, Target, Timing}; +use serde_json::Value; + +pub const SIMULATED_SERIAL: &str = "SIM0001234"; +pub const WAIT_LIMIT: Duration = Duration::from_secs(15); +// Paces the polling loop only; every outcome is decided by the condition. +pub const POLL_PAUSE: Duration = Duration::from_millis(10); +pub const WINDOW_SIZE: [f32; 2] = [900.0, 700.0]; +pub const LOOPBACK: &str = "::1"; + +pub struct SimulatorSpawner { + pub found: Vec, +} + +impl SpeakerSpawner for SimulatorSpawner { + fn spawn( + &self, + target: Target, + wake: Arc, + ) -> (SpeakerHandle, Receiver) { + speaker::spawn(target, Timing::default(), wake) + } + + fn scan(&self) -> Receiver { + let (sender, receiver) = mpsc::channel(); + sender.send(Ok(self.found.clone())).unwrap(); + receiver + } +} + +pub fn manual_for(simulator: &Simulator) -> ManualSpeaker { + ManualSpeaker { + address: LOOPBACK.to_owned(), + port: simulator.address().port(), + serial: None, + } +} + +pub fn settings_file(directory: &Path, manual: Vec) -> PathBuf { + let path = directory.join(SETTINGS_FILE); + let settings = Settings { + manual, + window: None, + }; + settings::save(&settings, &path).unwrap(); + path +} + +pub fn harness(settings_path: PathBuf, found: Vec) -> Harness<'static, NeumannApp> { + Harness::builder() + .with_size(WINDOW_SIZE) + .build_eframe(move |creation_context| { + NeumannApp::new( + &creation_context.egui_ctx, + settings_path, + Box::new(SimulatorSpawner { found }), + ) + }) +} + +pub fn wait_until( + harness: &mut Harness<'static, NeumannApp>, + what: &str, + condition: impl Fn(&Harness<'static, NeumannApp>) -> bool, +) { + let deadline = Instant::now() + WAIT_LIMIT; + loop { + harness.step(); + if condition(harness) { + return; + } + assert!(Instant::now() < deadline, "timed out waiting for {what}"); + thread::sleep(POLL_PAUSE); + } +} + +pub fn readout(value: f64) -> String { + texts::readout(value, "dB SPL") +} + +pub fn wait_for_level(harness: &mut Harness<'static, NeumannApp>, value: f64) { + let label = readout(value); + wait_until(harness, &label, |harness| { + harness.query_by_label(&label).is_some() + }); +} + +/// Requests the simulator must receive after a frame before "nothing was sent" can be +/// asserted: the worker runs commands before polls, so one request still in flight plus one +/// more mean anything the frame sent has arrived. +pub const BARRIER_REQUESTS: usize = 2; + +/// Waits without drawing frames, so the window sends nothing new meanwhile. +pub fn barrier(simulator: &Simulator) { + let start = simulator.requests().len(); + let deadline = Instant::now() + WAIT_LIMIT; + while simulator.requests().len() < start + BARRIER_REQUESTS { + assert!( + Instant::now() < deadline, + "timed out waiting for later polls" + ); + thread::sleep(POLL_PAUSE); + } +} + +pub fn level_sets(simulator: &Simulator) -> Vec { + simulator + .requests() + .iter() + .filter_map(|request| request.pointer("/audio/out/level").and_then(Value::as_f64)) + .collect() +} + +pub fn connected_card( + model: SimModel, +) -> (Simulator, tempfile::TempDir, Harness<'static, NeumannApp>) { + let simulator = Simulator::start(model); + let directory = tempfile::tempdir().unwrap(); + let path = settings_file(directory.path(), vec![manual_for(&simulator)]); + let mut harness = harness(path, Vec::new()); + wait_for_level(&mut harness, 100.0); + (simulator, directory, harness) +} + +pub fn readouts(harness: &Harness<'static, NeumannApp>) -> usize { + harness.query_all_by_label(&readout(100.0)).count() +} + +pub fn has_one_card(harness: &Harness<'static, NeumannApp>) -> bool { + readouts(harness) == 1 && harness.query_by_label(&texts::speaker_count(1)).is_some() +} + +pub fn serials_remembered(path: &Path, count: usize) -> bool { + let manual = settings::load(path).manual; + manual.len() == count + && manual + .iter() + .all(|entry| entry.serial.as_deref() == Some(SIMULATED_SERIAL)) +} diff --git a/crates/app/tests/views.rs b/crates/app/tests/views.rs index 425f986..2a12683 100644 --- a/crates/app/tests/views.rs +++ b/crates/app/tests/views.rs @@ -1,147 +1,14 @@ -//! The app window driven through egui_kittest, with real speaker workers talking to the -//! simulator. Threads deliver the events, so the tests step frames until a condition holds -//! (bounded by a deadline) instead of `run()`. +//! The speaker card in the real window: values, fader, buttons, unreachable state. -use std::path::{Path, PathBuf}; -use std::sync::mpsc::{self, Receiver}; -use std::sync::Arc; -use std::thread; -use std::time::{Duration, Instant}; +mod support; -use eframe::egui::{self, accesskit::Role, FontFamily, FontId, MouseWheelUnit, TouchPhase}; +use eframe::egui::{self, accesskit::Role, MouseWheelUnit, TouchPhase}; use egui_kittest::kittest::{NodeT, Queryable}; -use egui_kittest::Harness; -use neumann_control_app::app::NeumannApp; -use neumann_control_app::spawner::{ScanResult, SpeakerSpawner}; use neumann_control_app::texts; -use neumann_control_app::ui_kit::fonts; -use neumann_control_core::address::AddressError; -use neumann_control_core::catalog::{Catalog, ValueError}; -use neumann_control_core::discovery::Found; use neumann_control_core::mask::mask_serial; -use neumann_control_core::settings::{self, ManualSpeaker, Settings, SETTINGS_FILE}; -use neumann_control_core::simulator::{SimModel, Simulator}; -use neumann_control_core::speaker::{self, Event, SpeakerHandle, Target, Timing}; +use neumann_control_core::simulator::SimModel; use serde_json::Value; - -const SIMULATED_SERIAL: &str = "SIM0001234"; -const WAIT_LIMIT: Duration = Duration::from_secs(15); -// Paces the polling loop only; every outcome is decided by the condition. -const POLL_PAUSE: Duration = Duration::from_millis(10); -const WINDOW_SIZE: [f32; 2] = [900.0, 700.0]; -const LOOPBACK: &str = "::1"; - -struct SimulatorSpawner { - found: Vec, -} - -impl SpeakerSpawner for SimulatorSpawner { - fn spawn( - &self, - target: Target, - wake: Arc, - ) -> (SpeakerHandle, Receiver) { - speaker::spawn(target, Timing::default(), wake) - } - - fn scan(&self) -> Receiver { - let (sender, receiver) = mpsc::channel(); - sender.send(Ok(self.found.clone())).unwrap(); - receiver - } -} - -fn manual_for(simulator: &Simulator) -> ManualSpeaker { - ManualSpeaker { - address: LOOPBACK.to_owned(), - port: simulator.address().port(), - serial: None, - } -} - -fn settings_file(directory: &Path, manual: Vec) -> PathBuf { - let path = directory.join(SETTINGS_FILE); - let settings = Settings { - manual, - window: None, - }; - settings::save(&settings, &path).unwrap(); - path -} - -fn harness(settings_path: PathBuf, found: Vec) -> Harness<'static, NeumannApp> { - Harness::builder() - .with_size(WINDOW_SIZE) - .build_eframe(move |creation_context| { - NeumannApp::new( - &creation_context.egui_ctx, - settings_path, - Box::new(SimulatorSpawner { found }), - ) - }) -} - -fn wait_until( - harness: &mut Harness<'static, NeumannApp>, - what: &str, - condition: impl Fn(&Harness<'static, NeumannApp>) -> bool, -) { - let deadline = Instant::now() + WAIT_LIMIT; - loop { - harness.step(); - if condition(harness) { - return; - } - assert!(Instant::now() < deadline, "timed out waiting for {what}"); - thread::sleep(POLL_PAUSE); - } -} - -fn readout(value: f64) -> String { - texts::readout(value, "dB SPL") -} - -fn wait_for_level(harness: &mut Harness<'static, NeumannApp>, value: f64) { - let label = readout(value); - wait_until(harness, &label, |harness| { - harness.query_by_label(&label).is_some() - }); -} - -/// Requests the simulator must receive after a frame before "nothing was sent" can be -/// asserted: the worker runs commands before polls, so one request still in flight plus one -/// more mean anything the frame sent has arrived. -const BARRIER_REQUESTS: usize = 2; - -/// Waits without drawing frames, so the window sends nothing new meanwhile. -fn barrier(simulator: &Simulator) { - let start = simulator.requests().len(); - let deadline = Instant::now() + WAIT_LIMIT; - while simulator.requests().len() < start + BARRIER_REQUESTS { - assert!( - Instant::now() < deadline, - "timed out waiting for later polls" - ); - thread::sleep(POLL_PAUSE); - } -} - -fn level_sets(simulator: &Simulator) -> Vec { - simulator - .requests() - .iter() - .filter_map(|request| request.pointer("/audio/out/level").and_then(Value::as_f64)) - .collect() -} - -fn connected_card(model: SimModel) -> (Simulator, tempfile::TempDir, Harness<'static, NeumannApp>) { - let simulator = Simulator::start(model); - let directory = tempfile::tempdir().unwrap(); - let path = settings_file(directory.path(), vec![manual_for(&simulator)]); - let mut harness = harness(path, Vec::new()); - wait_for_level(&mut harness, 100.0); - (simulator, directory, harness) -} +use support::*; #[test] fn card_shows_level_and_name() { @@ -320,245 +187,3 @@ fn unreachable_card_is_disabled() { .accesskit_node() .is_disabled()); } - -#[test] -fn add_address_refuses_ipv4_with_hint() { - let directory = tempfile::tempdir().unwrap(); - let path = settings_file(directory.path(), Vec::new()); - let mut harness = harness(path.clone(), Vec::new()); - harness.get_by_label(texts::ADD_ADDRESS).click(); - harness.run_steps(2); - harness.get_by_label(texts::ADDRESS_FIELD).click(); - harness.run_steps(1); - harness - .get_by_label(texts::ADDRESS_FIELD) - .type_text("192.168.1.20"); - harness.run_steps(1); - harness.get_by_label(texts::ADD).click(); - harness.run_steps(2); - harness.get_by_label(&texts::describe_address_error( - &AddressError::Ipv4NotSupported, - )); - assert!(settings::load(&path).manual.is_empty()); - harness.get_by_label(&texts::speaker_count(0)); -} - -#[test] -fn add_address_saves_the_speaker_and_shows_its_card() { - let directory = tempfile::tempdir().unwrap(); - let path = settings_file(directory.path(), Vec::new()); - let mut harness = harness(path.clone(), Vec::new()); - harness.get_by_label(texts::ADD_ADDRESS).click(); - harness.run_steps(2); - harness.get_by_label(texts::ADDRESS_FIELD).click(); - harness.run_steps(1); - harness - .get_by_label(texts::ADDRESS_FIELD) - .type_text(LOOPBACK); - harness.run_steps(1); - harness.get_by_label(texts::ADD).click(); - harness.run_steps(2); - assert!(harness.query_by_label(texts::ADD_ADDRESS_TITLE).is_none()); - let saved = settings::load(&path).manual; - assert_eq!(saved.len(), 1); - assert_eq!(saved[0].address, LOOPBACK); - assert_eq!(saved[0].port, neumann_control_core::address::SSC_PORT); - harness.get_by_label(&texts::speaker_count(1)); -} - -#[test] -fn scan_adds_found_speakers_once() { - let simulator = Simulator::start(SimModel::Kh750); - let directory = tempfile::tempdir().unwrap(); - let path = settings_file(directory.path(), Vec::new()); - let found = Found { - name: String::new(), - product: Some("KH 750".to_owned()), - serial: Some("SIM0007500".to_owned()), - address: simulator.address(), - }; - let mut harness = harness(path, vec![found]); - harness.get_by_label(texts::SCAN).click(); - wait_for_level(&mut harness, 100.0); - harness.get_by_label("KH 750"); - harness.get_by_label(texts::MORE).click(); - harness.run_steps(2); - harness.get_by_label(texts::SCAN).click(); - harness.run_steps(5); - harness.get_by_label(&texts::speaker_count(1)); - // The card kept its state, so it was not recreated. - harness.get_by_label(texts::LESS); -} - -/// Every string literal in texts.rs, so a new text is covered without being listed here. -fn literals_of_texts_module() -> Vec { - let source = include_str!("../src/texts.rs"); - let mut literals = Vec::new(); - let mut rest = source; - while let Some(start) = rest.find('"') { - let after = &rest[start + 1..]; - let end = after.find('"').expect("closed literal"); - literals.push(after[..end].to_owned()); - rest = &after[end + 1..]; - } - literals -} - -#[test] -fn all_ui_texts_have_glyphs() { - let context = egui::Context::default(); - fonts::install(&context); - context - .run_ui(egui::RawInput::default(), |_| {}) - .textures_delta - .clear(); - let catalog = Catalog::embedded(); - let mut texts_to_check = literals_of_texts_module(); - texts_to_check.extend([ - texts::speaker_count(12), - texts::port(45), - texts::readout(-12.5, "dB SPL"), - texts::status_line("1_7_3", &mask_serial(SIMULATED_SERIAL)), - texts::serial_mismatch(&mask_serial(SIMULATED_SERIAL)), - texts::describe_rejection(405), - texts::describe_rejection(406), - texts::describe_rejection(500), - ]); - for error in [ - AddressError::Ipv4NotSupported, - AddressError::MissingScope, - AddressError::ScopeNotNumeric, - AddressError::Invalid, - ] { - texts_to_check.push(texts::describe_address_error(&error)); - } - for error in [ - ValueError::OutOfRange { min: 0.0, max: 1.0 }, - ValueError::NotAnOption, - ValueError::NotAStep, - ValueError::TooLong(52), - ValueError::NotPrintableAscii, - ValueError::WrongType, - ] { - texts_to_check.push(texts::describe_value_error(&error)); - } - for entry in &catalog.entries { - texts_to_check.push(entry.group.title().to_owned()); - texts_to_check.push(entry.label.clone()); - texts_to_check.extend(entry.unit.clone()); - } - let families = [FontFamily::Proportional, fonts::semibold(12.0).family]; - for family in families { - let font = FontId::new(12.0, family.clone()); - for text in &texts_to_check { - let covered = context.fonts_mut(|fonts| fonts.has_glyphs(&font, text)); - assert!(covered, "{text:?} in {family:?}"); - } - } - assert!(texts_to_check.iter().any(|text| text == texts::NO_SPEAKERS)); -} - -#[test] -fn scan_moves_a_known_speaker_to_its_new_address() { - let simulator = Simulator::start(SimModel::Kh120Ii); - let directory = tempfile::tempdir().unwrap(); - let stale = ManualSpeaker { - address: LOOPBACK.to_owned(), - port: neumann_control_core::address::SSC_PORT, - serial: Some(SIMULATED_SERIAL.to_owned()), - }; - let path = settings_file(directory.path(), vec![stale]); - let found = Found { - name: "announced".to_owned(), - product: None, - serial: Some(SIMULATED_SERIAL.to_owned()), - address: simulator.address(), - }; - let mut harness = harness(path, vec![found]); - harness.get_by_label(&texts::speaker_count(1)); - harness.get_by_label(texts::SCAN).click(); - wait_for_level(&mut harness, 100.0); - harness.get_by_label(&texts::speaker_count(1)); -} - -fn readouts(harness: &Harness<'static, NeumannApp>) -> usize { - harness.query_all_by_label(&readout(100.0)).count() -} - -fn has_one_card(harness: &Harness<'static, NeumannApp>) -> bool { - readouts(harness) == 1 && harness.query_by_label(&texts::speaker_count(1)).is_some() -} - -fn serials_remembered(path: &Path, count: usize) -> bool { - let manual = settings::load(path).manual; - manual.len() == count - && manual - .iter() - .all(|entry| entry.serial.as_deref() == Some(SIMULATED_SERIAL)) -} - -#[test] -fn a_manual_speaker_found_at_a_second_address_stays_one_card() { - let manual_simulator = Simulator::start(SimModel::Kh120Ii); - // The manual card connects only after the scan created a card for the second address. - manual_simulator.set_reply_delay(Duration::from_millis(300)); - let second_simulator = Simulator::start(SimModel::Kh120Ii); - let directory = tempfile::tempdir().unwrap(); - let path = settings_file(directory.path(), vec![manual_for(&manual_simulator)]); - let found = Found { - name: String::new(), - product: Some("KH 120 II".to_owned()), - serial: Some(SIMULATED_SERIAL.to_owned()), - address: second_simulator.address(), - }; - let mut harness = harness(path.clone(), vec![found]); - harness.get_by_label(texts::SCAN).click(); - wait_until(&mut harness, "the serial in the manual entry", |_| { - serials_remembered(&path, 1) - }); - wait_until(&mut harness, "one card after the first scan", has_one_card); - harness.get_by_label(texts::SCAN).click(); - harness.run_steps(5); - wait_until(&mut harness, "one card after the second scan", has_one_card); -} - -#[test] -fn two_manual_entries_for_one_speaker_stay_one_card() { - let first = Simulator::start(SimModel::Kh120Ii); - let second = Simulator::start(SimModel::Kh120Ii); - let directory = tempfile::tempdir().unwrap(); - let path = settings_file( - directory.path(), - vec![manual_for(&first), manual_for(&second)], - ); - let mut harness = harness(path.clone(), Vec::new()); - wait_until(&mut harness, "both serials remembered", |_| { - serials_remembered(&path, 2) - }); - wait_until(&mut harness, "one card", has_one_card); -} - -#[test] -fn add_address_names_an_address_already_listed() { - let directory = tempfile::tempdir().unwrap(); - let listed = ManualSpeaker { - address: LOOPBACK.to_owned(), - port: neumann_control_core::address::SSC_PORT, - serial: None, - }; - let path = settings_file(directory.path(), vec![listed]); - let mut harness = harness(path.clone(), Vec::new()); - harness.get_by_label(texts::ADD_ADDRESS).click(); - harness.run_steps(2); - harness.get_by_label(texts::ADDRESS_FIELD).click(); - harness.run_steps(1); - harness - .get_by_label(texts::ADDRESS_FIELD) - .type_text("[::1]"); - harness.run_steps(1); - harness.get_by_label(texts::ADD).click(); - harness.run_steps(2); - harness.get_by_label(texts::ADDRESS_ALREADY_LISTED); - harness.get_by_label(texts::ADD_ADDRESS_TITLE); - assert_eq!(settings::load(&path).manual.len(), 1); -} From 9bef36fcdff05fa86bb50287d249de1acb53812b Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:57:55 +0200 Subject: [PATCH 025/460] Expand cards with settings per model --- crates/app/src/display.rs | 100 +++++++++++ crates/app/src/lib.rs | 1 + crates/app/src/paths.rs | 10 ++ crates/app/src/state.rs | 45 ++++- crates/app/src/texts.rs | 8 + crates/app/src/views/card.rs | 61 ++----- crates/app/src/views/details.rs | 142 ++++++++++++++++ crates/app/src/views/details_fields.rs | 225 +++++++++++++++++++++++++ crates/app/src/views/meters.rs | 54 ++++++ crates/app/src/views/mod.rs | 3 + crates/app/src/writes.rs | 111 +++++++++++- crates/app/tests/details.rs | 156 +++++++++++++++++ crates/app/tests/support/mod.rs | 2 + 13 files changed, 864 insertions(+), 54 deletions(-) create mode 100644 crates/app/src/display.rs create mode 100644 crates/app/src/views/details.rs create mode 100644 crates/app/src/views/details_fields.rs create mode 100644 crates/app/src/views/meters.rs create mode 100644 crates/app/tests/details.rs diff --git a/crates/app/src/display.rs b/crates/app/src/display.rs new file mode 100644 index 0000000..41cb179 --- /dev/null +++ b/crates/app/src/display.rs @@ -0,0 +1,100 @@ +//! How a value the speaker reports reads on screen: the raw value with its unit, switches as +//! on/off, lists joined, and temperatures in whole degrees Celsius. + +use neumann_control_core::catalog::{Conversion, Entry}; +use serde_json::Value; + +use crate::texts; + +const LIST_SEPARATOR: &str = ", "; + +pub fn shown_value(entry: &Entry, value: &Value) -> String { + if let (Some(conversion), Some(number)) = (entry.conversion, value.as_f64()) { + return converted(conversion, number); + } + let text = raw_text(value); + match entry.unit.as_deref() { + Some(unit) if !text.is_empty() => format!("{text} {unit}"), + _ => text, + } +} + +/// The device reports whole kelvin, so tenths of a degree would only show rounding noise. +fn converted(conversion: Conversion, number: f64) -> String { + let unit = match conversion { + Conversion::KelvinToCelsius => texts::CELSIUS, + }; + // Adding zero turns the -0 of a slightly negative reading into 0. + let whole = conversion.apply(number).round() + 0.0; + format!("{whole} {unit}") +} + +fn raw_text(value: &Value) -> String { + match value { + Value::Null => String::new(), + Value::Bool(true) => texts::ON.to_owned(), + Value::Bool(false) => texts::OFF.to_owned(), + Value::String(text) => text.clone(), + Value::Array(items) => items + .iter() + .map(raw_text) + .collect::>() + .join(LIST_SEPARATOR), + Value::Number(_) | Value::Object(_) => value.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use neumann_control_core::catalog::Catalog; + use serde_json::json; + + use crate::paths; + + fn entry(path: &str) -> &'static Entry { + paths::catalog_entry(Catalog::embedded(), &paths::path(path)).unwrap() + } + + #[test] + fn kelvin_is_shown_as_whole_degrees_celsius() { + assert_eq!( + shown_value(entry("device/temperature"), &json!(313)), + "40 °C" + ); + assert_eq!( + shown_value(entry("device/temperature"), &json!(273)), + "0 °C" + ); + } + + #[test] + fn a_rear_panel_string_keeps_its_unit() { + assert_eq!(shown_value(entry("ui/bass_gain"), &json!("-2")), "-2 dB"); + assert_eq!( + shown_value(entry("ui/input_select"), &json!("ANALOG")), + "ANALOG" + ); + } + + #[test] + fn lists_are_joined_and_switches_read_as_on_or_off() { + let warnings = json!(["NO_WARNING", "HOT"]); + assert_eq!(shown_value(entry("warnings"), &warnings), "NO_WARNING, HOT"); + let enabled = entry("device/standby/enabled"); + assert_eq!(shown_value(enabled, &json!(true)), texts::ON); + assert_eq!(shown_value(enabled, &json!(false)), texts::OFF); + } + + #[test] + fn a_missing_value_shows_no_unit() { + assert_eq!( + shown_value(entry("device/standby/countdown"), &Value::Null), + "" + ); + assert_eq!( + shown_value(entry("device/standby/countdown"), &json!(5)), + "5 min" + ); + } +} diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index 37bcc9a..e6edb7a 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -1,4 +1,5 @@ pub mod app; +pub mod display; pub mod fader; pub mod meter; pub mod paths; diff --git a/crates/app/src/paths.rs b/crates/app/src/paths.rs index a717835..f755773 100644 --- a/crates/app/src/paths.rs +++ b/crates/app/src/paths.rs @@ -9,6 +9,11 @@ pub const IDENTIFY: &str = "device/identification/visual"; pub const INPUT_METER: &str = "m/in/level"; pub const OUTPUT_METER: &str = "m/out/level"; pub const INPUT_CLIP: &str = "m/in/clip"; +pub const OUTPUT_CLIP: &str = "m/out/clip"; +pub const PRODUCT: &str = "device/identity/product"; +pub const FIRMWARE_VERSION: &str = "device/identity/version"; +pub const SERIAL: &str = "device/identity/serial"; +pub const CONTROL_MODE: &str = "ui/control_mode"; pub fn path(text: &str) -> SscPath { SscPath::parse(text).expect("constant path is valid") @@ -32,6 +37,11 @@ mod tests { INPUT_METER, OUTPUT_METER, INPUT_CLIP, + OUTPUT_CLIP, + PRODUCT, + FIRMWARE_VERSION, + SERIAL, + CONTROL_MODE, ] { assert!(catalog_entry(catalog, &path(text)).is_some(), "{text}"); } diff --git a/crates/app/src/state.rs b/crates/app/src/state.rs index 6c03e6d..073ef33 100644 --- a/crates/app/src/state.rs +++ b/crates/app/src/state.rs @@ -6,7 +6,8 @@ use std::net::SocketAddrV6; use neumann_control_core::discovery::Found; use neumann_control_core::mask::{mask_host, mask_serial}; -use neumann_control_core::speaker::{Event, Identity}; +use neumann_control_core::schema_walk::LeafReport; +use neumann_control_core::speaker::{Command, Event, Identity}; use neumann_control_core::ssc::wire::SscPath; use serde_json::Value; @@ -28,6 +29,16 @@ pub enum Status { Unreachable, } +/// The answer to "Alle Werte…": every value the speaker has, read on request. +#[derive(Clone, Debug, Default, PartialEq)] +pub enum AllValuesState { + #[default] + Idle, + Loading, + Loaded(Vec), + Failed(String), +} + #[derive(Clone, Debug)] pub struct SpeakerState { pub key: SpeakerKey, @@ -45,6 +56,7 @@ pub struct SpeakerState { pub input_meters: Vec, pub output_meters: Vec, pub fader: FaderState, + pub all_values: AllValuesState, } impl SpeakerState { @@ -63,6 +75,7 @@ impl SpeakerState { input_meters: Vec::new(), output_meters: Vec::new(), fader: FaderState::default(), + all_values: AllValuesState::Idle, } } @@ -141,11 +154,17 @@ impl SpeakerState { let text = texts::serial_mismatch(&mask_serial(&found)); self.set_message(Some(text), None); } - // The full-value dump has no state to land in until the report view exists. - Event::AllValues(_) | Event::AllValuesFailed(_) => {} + Event::AllValues(reports) => self.all_values = AllValuesState::Loaded(reports), + Event::AllValuesFailed(reason) => self.all_values = AllValuesState::Failed(reason), } } + /// Starts reading every value; the worker answers with `AllValues` or `AllValuesFailed`. + pub fn read_all_values(&mut self) -> Command { + self.all_values = AllValuesState::Loading; + Command::ReadAll + } + pub(crate) fn set_message(&mut self, message: Option, path: Option) { self.message = message; self.message_path = path; @@ -362,6 +381,26 @@ mod tests { assert_eq!(speaker.fader.shown(80.0, 0.5), 80.0); } + #[test] + fn all_values_go_from_loading_to_loaded_or_failed() { + let mut speaker = state(); + assert_eq!(speaker.read_all_values(), Command::ReadAll); + assert_eq!(speaker.all_values, AllValuesState::Loading); + let reports = vec![LeafReport { + path: path("device/name"), + value: Ok(json!("left")), + limits: None, + }]; + speaker.apply(Event::AllValues(reports.clone()), 0.0); + assert_eq!(speaker.all_values, AllValuesState::Loaded(reports)); + speaker.read_all_values(); + speaker.apply(Event::AllValuesFailed("refused".to_owned()), 1.0); + assert_eq!( + speaker.all_values, + AllValuesState::Failed("refused".to_owned()) + ); + } + fn identity(name: &str, product: &str) -> Identity { Identity { product: product.to_owned(), diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 825e529..89b27cb 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -22,6 +22,14 @@ pub const CONNECTED: &str = "verbunden"; pub const LEVEL: &str = "Pegel"; pub const CLIP: &str = "Übersteuert"; +pub const ALL_VALUES: &str = "Alle Werte…"; +pub const APPLY: &str = "Übernehmen"; +pub const ON: &str = "an"; +pub const OFF: &str = "aus"; +pub const CELSIUS: &str = "°C"; +pub const SWITCH_MODE: &str = "Umschalten"; +pub const LOCAL_MODE_WARNING: &str = "Im Modus LOCAL kann die Netzwerksteuerung gesperrt sein, bis am Gerät zurückgestellt wird. Trotzdem umschalten?"; + pub const ADD_ADDRESS_TITLE: &str = "Lautsprecher per Adresse hinzufügen"; pub const ADDRESS_FIELD: &str = "IPv6-Adresse"; pub const ADDRESS_HINT: &str = "z. B. fe80::1%7"; diff --git a/crates/app/src/views/card.rs b/crates/app/src/views/card.rs index e772ba4..67c97d3 100644 --- a/crates/app/src/views/card.rs +++ b/crates/app/src/views/card.rs @@ -1,18 +1,16 @@ //! One speaker as a channel strip: name, status, level fader with input meters, mute and -//! identify, the "Mehr" toggle and a status line. The card only reports what was clicked; -//! `writes` decides whether it becomes a command. +//! identify, the "Mehr" toggle with the settings it opens, and a status line. The card only +//! reports what was clicked; `writes` decides whether it becomes a command. -use eframe::egui::{ - self, pos2, vec2, Button, CornerRadius, Label, Rect, RichText, Sense, Stroke, Ui, Vec2, -}; +use eframe::egui::{self, Button, CornerRadius, Label, RichText, Sense, Stroke, Ui, Vec2}; use neumann_control_core::catalog::Catalog; use neumann_control_core::mask::mask_serial; use neumann_control_core::speaker::Command; use serde_json::Value; use super::fader::{self, FADER_HEIGHT}; +use super::{details, meters}; use crate::fader::output_level_range; -use crate::meter::fraction; use crate::paths; use crate::state::{SpeakerState, Status}; use crate::texts; @@ -23,12 +21,9 @@ const CARD_WIDTH: f32 = 170.0; const CARD_MARGIN: i8 = 8; const CARD_RADIUS: u8 = 6; const BORDER_WIDTH: f32 = 1.0; -const METER_WIDTH: f32 = 8.0; -const CLIP_LAMP_RADIUS: f32 = 3.5; -const CLIP_LAMP_SPACE: f32 = 10.0; -const PEAK_LINE_WIDTH: f32 = 2.0; const STATUS_DOT_RADIUS: f32 = 4.0; const UNREACHABLE_OPACITY: f32 = 0.55; +const DETAILS_ID: &str = "details"; /// Draws the card and returns the writes the user asked for that the write policy allowed. pub fn show(ui: &mut Ui, speaker: &mut SpeakerState, catalog: &Catalog) -> Vec { @@ -49,6 +44,15 @@ pub fn show(ui: &mut Ui, speaker: &mut SpeakerState, catalog: &Catalog) -> Vec Vec { + // The card's own layout wraps sideways; the rows of this area stack. + ui.vertical(|ui| rows(ui, speaker, catalog)).inner +} + +fn rows(ui: &mut Ui, speaker: &mut SpeakerState, catalog: &Catalog) -> Vec { + let tokens = Tokens::current(ui.ctx()); + let product = speaker.model().unwrap_or_default().to_owned(); + let groups = groups(catalog.entries_for(&product), speaker); + let mut commands = Vec::new(); + let mut fields = Fields { + speaker, + catalog, + commands: &mut commands, + confirmation_id: ui.id().with(CONFIRMATION_ID), + }; + for (group, entries) in groups { + ui.push_id(group.title(), |ui| { + ui.separator(); + ui.label(RichText::new(group.title()).strong()); + for entry in entries { + row(ui, &mut fields, entry, &tokens); + } + }); + } + ui.separator(); + all_values_button(ui, fields.speaker, fields.commands); + fields.confirmation(ui); + commands +} + +/// The entries to show, grouped in the order the catalog first names each group. +fn groups<'a>(entries: Vec<&'a Entry>, speaker: &SpeakerState) -> Vec<(Group, Vec<&'a Entry>)> { + let mut groups: Vec<(Group, Vec<&Entry>)> = Vec::new(); + for entry in entries.into_iter().filter(|entry| is_shown(entry, speaker)) { + match groups.iter_mut().find(|(group, _)| *group == entry.group) { + Some((_, members)) => members.push(entry), + None => groups.push((entry.group, vec![entry])), + } + } + groups +} + +/// Clip lists light the lamps of their meters, so they get no row of their own. +fn is_shown(entry: &Entry, speaker: &SpeakerState) -> bool { + let path = entry.path.to_string(); + let on_card = SHOWN_ON_CARD.contains(&path.as_str()); + let absent = speaker.absent.contains(&entry.path); + let drawable = match entry.kind { + Kind::Clip => false, + Kind::Meter => path == paths::OUTPUT_METER, + _ => true, + }; + drawable && !on_card && !absent +} + +fn row(ui: &mut Ui, fields: &mut Fields, entry: &Entry, tokens: &Tokens) { + let product = fields.speaker.model().unwrap_or_default(); + if !entry.is_writable(product) { + display(ui, fields.speaker, entry, tokens); + return; + } + match entry.kind { + Kind::Number => fields.number(ui, entry), + Kind::Switch => fields.switch(ui, entry), + Kind::Select => fields.select(ui, entry), + Kind::Text => fields.text(ui, entry), + Kind::Display | Kind::Meter | Kind::Clip => display(ui, fields.speaker, entry, tokens), + } +} + +fn display(ui: &mut Ui, speaker: &SpeakerState, entry: &Entry, tokens: &Tokens) { + if entry.kind == Kind::Meter { + output_meters(ui, speaker, entry, tokens); + return; + } + let shown = speaker + .values + .get(&entry.path) + .map(|value| shown_value(entry, value)) + .unwrap_or_default(); + ui.horizontal_wrapped(|ui| { + ui.weak(&entry.label); + ui.weak(shown); + }); +} + +fn output_meters(ui: &mut Ui, speaker: &SpeakerState, entry: &Entry, tokens: &Tokens) { + ui.weak(&entry.label); + let clips = speaker.values.get(&paths::path(paths::OUTPUT_CLIP)); + ui.horizontal(|ui| { + meters::show( + ui, + &speaker.output_meters, + clips, + OUTPUT_METER_HEIGHT, + tokens, + ); + }); +} + +fn all_values_button(ui: &mut Ui, speaker: &mut SpeakerState, commands: &mut Vec) { + let loading = speaker.all_values == AllValuesState::Loading; + let enabled = speaker.status == Status::Connected && !loading; + if ui + .add_enabled(enabled, Button::new(texts::ALL_VALUES)) + .clicked() + { + commands.push(speaker.read_all_values()); + } +} diff --git a/crates/app/src/views/details_fields.rs b/crates/app/src/views/details_fields.rs new file mode 100644 index 0000000..9362a38 --- /dev/null +++ b/crates/app/src/views/details_fields.rs @@ -0,0 +1,225 @@ +//! The controls of the "Mehr" area, one per writable kind: number, switch, selection and text, +//! plus the confirmation a risky selection waits for. Every write goes through `writes`. + +use eframe::egui::{Button, Checkbox, ComboBox, DragValue, Id, Key, Modal, TextEdit, Ui}; +use neumann_control_core::catalog::{Catalog, Entry}; +use neumann_control_core::speaker::Command; +use serde_json::Value; + +use crate::state::SpeakerState; +use crate::texts; +use crate::writes::{self, Intent}; + +/// Drag speed for numbers without a step in the catalog, in units per point. +const UNSTEPPED_DRAG_SPEED: f64 = 0.1; +const CONFIRMATION_WIDTH: f32 = 320.0; +const PENDING_NUMBER_ID: &str = "pending_number"; +const TEXT_DRAFT_ID: &str = "text_draft"; +const TEXT_FIELD_ID: &str = "text_field"; + +/// What the user typed into a text field and has not yet written. +#[derive(Clone, Debug, Default)] +struct TextDraft { + text: String, + edited: bool, + refusal: Option, +} + +pub(super) struct Fields<'a> { + pub speaker: &'a mut SpeakerState, + pub catalog: &'a Catalog, + pub commands: &'a mut Vec, + /// Where a selection waiting for confirmation is kept between frames. + pub confirmation_id: Id, +} + +impl Fields<'_> { + fn send(&mut self, ui: &Ui, intent: Intent) { + let now = ui.input(|input| input.time); + let command = self.speaker.request(intent, self.catalog, now); + self.commands.extend(command); + } + + fn enabled(&self, entry: &Entry, known: bool) -> bool { + known && writes::can_write(self.speaker, self.catalog, &entry.path) + } + + /// A drag or a typed value is one gesture; its final value is written once it ends + /// (release, Enter or focus loss), never while it is going on. Escape drops it. + pub fn number(&mut self, ui: &mut Ui, entry: &Entry) { + let reported = self.speaker.values.get(&entry.path).cloned(); + let device = reported.as_ref().and_then(Value::as_f64); + let enabled = self.enabled(entry, device.is_some()); + let product = self.speaker.model().unwrap_or_default(); + let min = entry.min.unwrap_or(f64::NEG_INFINITY); + let max = entry + .max_for(product, &self.catalog.classes) + .unwrap_or(f64::INFINITY); + let pending_id = ui.id().with(entry.path.to_string()).with(PENDING_NUMBER_ID); + let pending = ui.data(|data| data.get_temp::(pending_id)); + let mut value = pending.or(device).unwrap_or(entry.min.unwrap_or_default()); + let mut drag = DragValue::new(&mut value) + .range(min..=max) + .speed(entry.step.unwrap_or(UNSTEPPED_DRAG_SPEED)); + if let Some(unit) = &entry.unit { + drag = drag.suffix(format!(" {unit}")); + } + let response = ui + .horizontal_wrapped(|ui| { + let label = ui.label(&entry.label); + ui.add_enabled(enabled, drag).labelled_by(label.id) + }) + .inner; + if response.changed() { + let snapped = writes::number_value(entry, reported.as_ref(), value); + let shown = snapped.as_f64().unwrap_or(value); + ui.data_mut(|data| data.insert_temp(pending_id, shown)); + } + let gesture_over = !response.dragged() && !response.has_focus(); + let pending = ui.data(|data| data.get_temp::(pending_id)); + let Some(number) = pending.filter(|_| gesture_over) else { + return; + }; + ui.data_mut(|data| data.remove::(pending_id)); + if ui.input(|input| input.key_pressed(Key::Escape)) { + return; + } + let path = entry.path.clone(); + let value = writes::number_value(entry, reported.as_ref(), number); + self.send(ui, Intent::Write { path, value }); + } + + pub fn switch(&mut self, ui: &mut Ui, entry: &Entry) { + let current = self + .speaker + .values + .get(&entry.path) + .and_then(Value::as_bool); + let enabled = self.enabled(entry, current.is_some()); + let mut checked = current.unwrap_or(false); + let checkbox = Checkbox::new(&mut checked, &entry.label); + if ui.add_enabled(enabled, checkbox).clicked() { + let path = entry.path.clone(); + let value = Value::Bool(checked); + self.send(ui, Intent::Write { path, value }); + } + } + + pub fn select(&mut self, ui: &mut Ui, entry: &Entry) { + let current = self + .speaker + .values + .get(&entry.path) + .and_then(Value::as_str) + .map(str::to_owned); + let enabled = self.enabled(entry, current.is_some()); + let current = current.unwrap_or_default(); + let mut chosen = None; + let label = ui.label(&entry.label); + ui.add_enabled_ui(enabled, |ui| { + ComboBox::from_id_salt(entry.path.to_string()) + .selected_text(¤t) + .width(ui.available_width()) + .show_ui(ui, |ui| { + for option in &entry.options { + let selected = *option == current; + if ui.selectable_label(selected, option).clicked() && !selected { + chosen = Some(option.clone()); + } + } + }) + .response + .labelled_by(label.id); + }); + let Some(option) = chosen else { + return; + }; + let intent = Intent::Write { + path: entry.path.clone(), + value: Value::String(option), + }; + if intent.needs_confirmation() { + ui.data_mut(|data| data.insert_temp(self.confirmation_id, intent)); + return; + } + self.send(ui, intent); + } + + /// Written with "Übernehmen" or Enter after the catalog check; a refused text stays in + /// the field with the reason below it. Polling never replaces what is being edited. + pub fn text(&mut self, ui: &mut Ui, entry: &Entry) { + let reported = self + .speaker + .values + .get(&entry.path) + .and_then(Value::as_str) + .map(str::to_owned); + let enabled = self.enabled(entry, reported.is_some()); + let draft_id = ui.id().with(entry.path.to_string()).with(TEXT_DRAFT_ID); + let field_id = draft_id.with(TEXT_FIELD_ID); + let mut draft = ui + .data(|data| data.get_temp::(draft_id)) + .unwrap_or_default(); + let focused = ui.memory(|memory| memory.has_focus(field_id)); + if !draft.edited && !focused { + draft.text = reported.unwrap_or_default(); + } + let label = ui.label(&entry.label); + let field = TextEdit::singleline(&mut draft.text) + .id(field_id) + .desired_width(f32::INFINITY); + let field = ui.add_enabled(enabled, field).labelled_by(label.id); + if field.changed() { + draft.edited = true; + draft.refusal = None; + } + // Keys typed while a dialog is open belong to the dialog. + let dialog_open = ui.memory(|memory| memory.top_modal_layer().is_some()); + let entered = field.lost_focus() && ui.input(|input| input.key_pressed(Key::Enter)); + let applied = ui.add_enabled(enabled, Button::new(texts::APPLY)).clicked(); + if applied || (entered && !dialog_open) { + self.submit_text(ui, entry, &mut draft); + } + if let Some(refusal) = &draft.refusal { + ui.colored_label(ui.visuals().error_fg_color, refusal); + } + ui.data_mut(|data| data.insert_temp(draft_id, draft)); + } + + fn submit_text(&mut self, ui: &Ui, entry: &Entry, draft: &mut TextDraft) { + let value = Value::String(draft.text.clone()); + if let Err(refusal) = writes::check(self.speaker, self.catalog, &entry.path, &value) { + draft.refusal = Some(texts::describe_refusal(&refusal)); + return; + } + draft.edited = false; + draft.refusal = None; + let path = entry.path.clone(); + self.send(ui, Intent::Write { path, value }); + } + + /// The dialog for a selection that needs confirmation; the write waits until "Umschalten". + pub fn confirmation(&mut self, ui: &Ui) { + let id = self.confirmation_id; + let Some(intent) = ui.data(|data| data.get_temp::(id)) else { + return; + }; + let modal = Modal::new(id).show(ui.ctx(), |ui| { + ui.set_max_width(CONFIRMATION_WIDTH); + ui.label(texts::LOCAL_MODE_WARNING); + ui.horizontal(|ui| { + let confirmed = ui.button(texts::SWITCH_MODE).clicked(); + let cancelled = ui.button(texts::CANCEL).clicked(); + (confirmed, cancelled) + }) + .inner + }); + let (confirmed, cancelled) = modal.inner; + if confirmed || cancelled || modal.should_close() { + ui.data_mut(|data| data.remove::(id)); + } + if confirmed { + self.send(ui, intent); + } + } +} diff --git a/crates/app/src/views/meters.rs b/crates/app/src/views/meters.rs new file mode 100644 index 0000000..aa6f60b --- /dev/null +++ b/crates/app/src/views/meters.rs @@ -0,0 +1,54 @@ +//! Level meters drawn side by side, one per channel, each with its clip lamp and held peak. + +use eframe::egui::{pos2, vec2, CornerRadius, Rect, Sense, Stroke, Ui}; +use serde_json::Value; + +use crate::meter::{fraction, MeterState}; +use crate::texts; +use crate::ui_kit::theme::Tokens; + +const METER_WIDTH: f32 = 8.0; +const CLIP_LAMP_RADIUS: f32 = 3.5; +const CLIP_LAMP_SPACE: f32 = 10.0; +const PEAK_LINE_WIDTH: f32 = 2.0; + +/// `clips` is the speaker's clip list for the same channels, if it reported one. +pub fn show( + ui: &mut Ui, + meters: &[MeterState], + clips: Option<&Value>, + height: f32, + tokens: &Tokens, +) { + let now = ui.input(|input| input.time); + let clips = clips.and_then(Value::as_array); + for (channel, meter) in meters.iter().enumerate() { + let clipped = clips + .and_then(|clips| clips.get(channel)) + .and_then(Value::as_bool) + .unwrap_or(false); + let (rect, response) = ui.allocate_exact_size(vec2(METER_WIDTH, height), Sense::hover()); + let lamp_color = if clipped { + tokens.danger + } else { + tokens.clip_off + }; + let lamp_center = pos2(rect.center().x, rect.top() + CLIP_LAMP_RADIUS); + let track = Rect::from_min_max(pos2(rect.left(), rect.top() + CLIP_LAMP_SPACE), rect.max); + let painter = ui.painter(); + painter.circle_filled(lamp_center, CLIP_LAMP_RADIUS, lamp_color); + painter.rect_filled(track, CornerRadius::ZERO, tokens.meter_track); + let level_top = track.bottom() - track.height() * fraction(meter.level()); + let level = Rect::from_min_max(pos2(track.left(), level_top), track.max); + painter.rect_filled(level, CornerRadius::ZERO, tokens.meter_fill); + let peak_y = track.bottom() - track.height() * fraction(meter.peak(now)); + painter.hline( + track.x_range(), + peak_y, + Stroke::new(PEAK_LINE_WIDTH, tokens.text_dim), + ); + if clipped { + response.on_hover_text(texts::CLIP); + } + } +} diff --git a/crates/app/src/views/mod.rs b/crates/app/src/views/mod.rs index 1c32930..aa79cd2 100644 --- a/crates/app/src/views/mod.rs +++ b/crates/app/src/views/mod.rs @@ -1,4 +1,7 @@ pub mod add_address; pub mod card; +pub mod details; +mod details_fields; pub mod fader; +mod meters; pub mod toolbar; diff --git a/crates/app/src/writes.rs b/crates/app/src/writes.rs index 5607ad7..c95464d 100644 --- a/crates/app/src/writes.rs +++ b/crates/app/src/writes.rs @@ -2,7 +2,7 @@ //! whose model the catalog lists as writable for the path, with a value the catalog accepts. //! Anything else is refused with a message on the card. -use neumann_control_core::catalog::{Catalog, ValueError}; +use neumann_control_core::catalog::{Catalog, Entry, ValueError}; use neumann_control_core::speaker::Command; use neumann_control_core::ssc::wire::SscPath; use serde_json::Value; @@ -26,22 +26,54 @@ impl Switch { } } +/// The control mode in which the speaker may stop listening to the network. +const LOCAL_CONTROL_MODE: &str = "LOCAL"; + /// What a control on the card asks for. -#[derive(Clone, Copy, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq)] pub enum Intent { /// Flip a switch to the opposite of its current value. Toggle(Switch), /// Set the output level. Level(f64), + /// Set any catalog path to the given value. + Write { path: SscPath, value: Value }, } impl Intent { - pub fn path(self) -> SscPath { + pub fn path(&self) -> SscPath { match self { Intent::Toggle(switch) => switch.path(), Intent::Level(_) => paths::path(paths::OUTPUT_LEVEL), + Intent::Write { path, .. } => path.clone(), + } + } + + /// Whether the user has to confirm before the write goes out: in LOCAL control mode the + /// speaker may ignore the network until someone resets it at the device. + pub fn needs_confirmation(&self) -> bool { + let Intent::Write { path, value } = self else { + return false; + }; + *path == paths::path(paths::CONTROL_MODE) && value.as_str() == Some(LOCAL_CONTROL_MODE) + } +} + +/// The value to write for a number the user entered: on the entry's step grid, and whole +/// when the speaker reports the path as a whole number, so the write has the device's own type. +pub fn number_value(entry: &Entry, reported: Option<&Value>, number: f64) -> Value { + let snapped = match entry.step.filter(|step| *step > 0.0) { + Some(step) => { + let origin = entry.min.unwrap_or(0.0); + origin + ((number - origin) / step).round() * step } + None => number, + }; + let whole = reported.is_some_and(|value| value.is_i64() || value.is_u64()); + if whole && snapped.fract() == 0.0 { + return Value::from(snapped as i64); } + Value::from(snapped) } #[derive(Clone, Debug, PartialEq)] @@ -86,7 +118,7 @@ impl SpeakerState { pub fn request(&mut self, intent: Intent, catalog: &Catalog, now: f64) -> Option { let path = intent.path(); let checked = self - .intended_value(intent, &path) + .intended_value(&intent, &path) .and_then(|value| check(self, catalog, &path, &value).map(|()| value)); match checked { Ok(value) => { @@ -103,9 +135,10 @@ impl SpeakerState { } } - fn intended_value(&self, intent: Intent, path: &SscPath) -> Result { + fn intended_value(&self, intent: &Intent, path: &SscPath) -> Result { match intent { - Intent::Level(level) => Ok(Value::from(level)), + Intent::Level(level) => Ok(Value::from(*level)), + Intent::Write { value, .. } => Ok(value.clone()), Intent::Toggle(_) => { let current = self.values.get(path).and_then(Value::as_bool); let current = current.ok_or(Refusal::ValueUnknown)?; @@ -215,6 +248,72 @@ mod tests { )); } + fn entry(path: &str) -> &'static Entry { + paths::catalog_entry(Catalog::embedded(), &paths::path(path)).unwrap() + } + + #[test] + fn a_write_intent_sends_the_given_value() { + let mut speaker = speaker("KH 120 II"); + let intent = Intent::Write { + path: paths::path("device/standby/enabled"), + value: Value::Bool(false), + }; + let command = speaker.request(intent, Catalog::embedded(), 0.0); + assert_eq!(command, set("device/standby/enabled", Value::Bool(false))); + } + + #[test] + fn a_write_intent_outside_the_catalog_rules_is_refused() { + let mut speaker = speaker("KH 120 II"); + let intent = Intent::Write { + path: paths::path("device/name"), + value: Value::from("Küche"), + }; + assert_eq!(speaker.request(intent, Catalog::embedded(), 0.0), None); + let expected = texts::describe_refusal(&Refusal::Invalid(ValueError::NotPrintableAscii)); + assert_eq!(speaker.message.as_deref(), Some(expected.as_str())); + } + + #[test] + fn only_switching_to_local_control_needs_confirmation() { + let write = |value: &str| Intent::Write { + path: paths::path(paths::CONTROL_MODE), + value: Value::from(value), + }; + assert!(write("LOCAL").needs_confirmation()); + assert!(!write("NETWORK").needs_confirmation()); + assert!(!Intent::Level(80.0).needs_confirmation()); + let other = Intent::Write { + path: paths::path("audio/in/interface"), + value: Value::from("LOCAL"), + }; + assert!(!other.needs_confirmation()); + } + + #[test] + fn a_number_snaps_to_the_step_and_keeps_the_reported_type() { + let delay = entry("audio/out/delay"); + assert_eq!( + number_value(delay, Some(&Value::from(0)), 12.4), + Value::from(12) + ); + assert_eq!( + number_value(delay, Some(&Value::from(0.0)), 12.6), + Value::from(13.0) + ); + let standby_level = entry("device/standby/level"); + assert_eq!( + number_value(standby_level, Some(&Value::from(-65)), -70.2), + Value::from(-70) + ); + let level = entry(paths::OUTPUT_LEVEL); + assert_eq!( + number_value(level, Some(&Value::from(100.0)), 90.25), + Value::from(90.25) + ); + } + #[test] fn a_switch_without_a_reported_value_is_refused() { let mut speaker = speaker("KH 120 II"); diff --git a/crates/app/tests/details.rs b/crates/app/tests/details.rs new file mode 100644 index 0000000..e83be50 --- /dev/null +++ b/crates/app/tests/details.rs @@ -0,0 +1,156 @@ +//! The card's expanded area: settings per model, displays, confirmation and number gestures. + +mod support; + +use eframe::egui::{self, accesskit::Role}; +use egui_kittest::kittest::{NodeT, Queryable}; +use egui_kittest::Harness; +use neumann_control_app::app::NeumannApp; +use neumann_control_app::texts; +use neumann_control_core::catalog::ValueError; +use neumann_control_core::simulator::{SimModel, Simulator}; +use serde_json::{json, Value}; +use support::*; + +const STANDBY_LABEL: &str = "Auto-Standby"; +const DELAY_LABEL: &str = "Verzögerung"; +const CONTROL_MODE_LABEL: &str = "Steuermodus"; +const NAME_PATH: &str = "/device/name"; +const CONTROL_MODE_PATH: &str = "/ui/control_mode"; +const DELAY_PATH: &str = "/audio/out/delay"; + +/// A connected card with "Mehr" open, in a window tall enough for the whole area. +fn expanded_card(model: SimModel) -> (Simulator, tempfile::TempDir, Harness<'static, NeumannApp>) { + let (simulator, directory, mut harness) = connected_card(model); + harness.set_size(EXPANDED_WINDOW_SIZE.into()); + harness.get_by_label(texts::MORE).click(); + wait_until(&mut harness, "the expanded area", |harness| { + harness.query_by_label(texts::ALL_VALUES).is_some() + }); + (simulator, directory, harness) +} + +/// The values written to `pointer`; polls ask with `null` and are left out. +fn sets_to(simulator: &Simulator, pointer: &str) -> Vec { + simulator + .requests() + .iter() + .filter_map(|request| request.pointer(pointer).filter(|value| !value.is_null())) + .cloned() + .collect() +} + +fn role_of(harness: &Harness<'static, NeumannApp>, label: &str) -> Role { + harness.get_by_label(label).accesskit_node().role() +} + +#[test] +fn kh750_details_show_out1_and_no_phase() { + let (_simulator, _directory, harness) = expanded_card(SimModel::Kh750); + harness.get_by_label("Ausgang 1"); + harness.get_by_label("Ausgang 2"); + assert!(harness.query_by_label("Phase invertiert").is_none()); +} + +#[test] +fn kh750_standby_enabled_is_display_only() { + let (_simulator, _directory, harness) = expanded_card(SimModel::Kh750); + assert_eq!(role_of(&harness, STANDBY_LABEL), Role::Label); + assert!(harness.query_all_by_role(Role::CheckBox).all(|node| node + .accesskit_node() + .label() + .as_deref() + != Some(STANDBY_LABEL))); +} + +#[test] +fn kh120ii_standby_enabled_is_checkbox() { + let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); + assert_eq!(role_of(&harness, STANDBY_LABEL), Role::CheckBox); + harness.get_by_label(STANDBY_LABEL).click(); + wait_until(&mut harness, "standby off on the device", |_| { + simulator.value("device/standby/enabled") == Some(Value::Bool(false)) + }); +} + +#[test] +fn temperature_is_shown_in_celsius() { + let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh750); + simulator.set_value("device/temperature", json!(313)); + wait_until(&mut harness, "40 °C", |harness| { + harness.query_by_label("40 °C").is_some() + }); +} + +#[test] +fn name_field_shows_refusal() { + let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); + harness.get_by_role(Role::TextInput).click(); + harness.run_steps(1); + harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + harness.get_by_role(Role::TextInput).type_text("Küche"); + harness.run_steps(1); + harness.get_by_label(texts::APPLY).click(); + harness.run_steps(2); + barrier(&simulator); + let sets = sets_to(&simulator, NAME_PATH); + assert!(sets.is_empty(), "{sets:?}"); + let refusal = texts::describe_value_error(&ValueError::NotPrintableAscii); + let refusal = harness.get_by_label(&refusal).rect(); + let field = harness.get_by_role(Role::TextInput); + assert_eq!(field.value().as_deref(), Some("Küche")); + assert!( + refusal.min.y > field.rect().max.y, + "the reason is below the field" + ); + let next_row = harness.get_by_label(texts::ALL_VALUES).rect(); + assert!( + refusal.max.y < next_row.min.y, + "the reason is not the card's message" + ); +} + +#[test] +fn local_control_mode_needs_confirmation() { + let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); + harness.get_by_label(CONTROL_MODE_LABEL).click(); + harness.run_steps(2); + harness.get_by_label("LOCAL").click(); + harness.run_steps(2); + barrier(&simulator); + assert!(sets_to(&simulator, CONTROL_MODE_PATH).is_empty()); + harness.get_by_label(texts::LOCAL_MODE_WARNING); + harness.get_by_label(texts::SWITCH_MODE).click(); + wait_until(&mut harness, "LOCAL on the device", |_| { + simulator.value("ui/control_mode") == Some(json!("LOCAL")) + }); + assert_eq!(sets_to(&simulator, CONTROL_MODE_PATH), vec![json!("LOCAL")]); +} + +#[test] +fn number_field_sends_once_per_gesture() { + let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); + let start = harness.get_by_label(DELAY_LABEL).rect().center(); + harness.drag_at(start); + for frame in 1..=10 { + harness.hover_at(start + egui::vec2(4.0 * frame as f32, 0.0)); + harness.step(); + } + barrier(&simulator); + assert!( + sets_to(&simulator, DELAY_PATH).is_empty(), + "a Set went out while dragging" + ); + harness.drop_at(start + egui::vec2(40.0, 0.0)); + wait_until(&mut harness, "the Set after release", |_| { + !sets_to(&simulator, DELAY_PATH).is_empty() + }); + harness.run_steps(10); + barrier(&simulator); + let sets = sets_to(&simulator, DELAY_PATH); + assert_eq!(sets.len(), 1, "{sets:?}"); + let delay = sets[0] + .as_i64() + .expect("the delay is sent as a whole number"); + assert!(delay > 0, "dragging right raises the delay: {sets:?}"); +} diff --git a/crates/app/tests/support/mod.rs b/crates/app/tests/support/mod.rs index 3f99a77..3cad1e8 100644 --- a/crates/app/tests/support/mod.rs +++ b/crates/app/tests/support/mod.rs @@ -26,6 +26,8 @@ pub const WAIT_LIMIT: Duration = Duration::from_secs(15); // Paces the polling loop only; every outcome is decided by the condition. pub const POLL_PAUSE: Duration = Duration::from_millis(10); pub const WINDOW_SIZE: [f32; 2] = [900.0, 700.0]; +/// Tall enough for a card with "Mehr" open, so every control in it can be clicked. +pub const EXPANDED_WINDOW_SIZE: [f32; 2] = [900.0, 1600.0]; pub const LOOPBACK: &str = "::1"; pub struct SimulatorSpawner { From 19cb3a7bc707eaabb5aa94a79d87959559ef86f3 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 01:12:30 +0200 Subject: [PATCH 026/460] Keep the reported level out of the fader clamp --- crates/app/src/views/fader.rs | 7 +++++-- crates/app/tests/views.rs | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/app/src/views/fader.rs b/crates/app/src/views/fader.rs index 46462a7..a31956e 100644 --- a/crates/app/src/views/fader.rs +++ b/crates/app/src/views/fader.rs @@ -3,7 +3,7 @@ use std::ops::RangeInclusive; -use eframe::egui::{Event, Key, MouseWheelUnit, Response, Slider, Ui, Vec2}; +use eframe::egui::{Event, Key, MouseWheelUnit, Response, Slider, SliderClamping, Ui, Vec2}; use neumann_control_core::catalog::Catalog; use neumann_control_core::speaker::Command; @@ -35,7 +35,10 @@ pub fn show( let slider = Slider::new(&mut value, range.clone()) .vertical() .show_value(false) - .step_by(WHEEL_STEP_DB); + .step_by(WHEEL_STEP_DB) + // The default also clamps and snaps the reported level, so the readout would show a + // value the speaker does not have. + .clamping(SliderClamping::Edits); let response = ui.add_enabled(enabled, slider); if response.drag_started() { speaker.fader.drag_started(); diff --git a/crates/app/tests/views.rs b/crates/app/tests/views.rs index 2a12683..ee1a56c 100644 --- a/crates/app/tests/views.rs +++ b/crates/app/tests/views.rs @@ -187,3 +187,17 @@ fn unreachable_card_is_disabled() { .accesskit_node() .is_disabled()); } + +#[test] +fn out_of_range_level_is_not_written_back() { + let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + simulator.set_value("audio/out/level", serde_json::json!(130.0)); + wait_for_level(&mut harness, 130.0); + harness.run_steps(10); + barrier(&simulator); + assert!( + level_sets(&simulator).is_empty(), + "{:?}", + level_sets(&simulator) + ); +} From 2f855d43831a4876aba003d35742464996dae762 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 01:12:30 +0200 Subject: [PATCH 027/460] Write settings only at the end of a user gesture --- crates/app/src/views/details_fields.rs | 58 ++++++---- crates/app/src/writes.rs | 34 ++++-- crates/app/tests/details.rs | 151 +++++++++++++++++++++++++ 3 files changed, 210 insertions(+), 33 deletions(-) diff --git a/crates/app/src/views/details_fields.rs b/crates/app/src/views/details_fields.rs index 9362a38..6bc4ef0 100644 --- a/crates/app/src/views/details_fields.rs +++ b/crates/app/src/views/details_fields.rs @@ -44,8 +44,9 @@ impl Fields<'_> { known && writes::can_write(self.speaker, self.catalog, &entry.path) } - /// A drag or a typed value is one gesture; its final value is written once it ends - /// (release, Enter or focus loss), never while it is going on. Escape drops it. + /// A drag or a typed value is one gesture. Its value is held while the gesture is active + /// and written once, on the frame it ends (release, Enter or focus loss); Escape drops it. + /// The device value itself is never clamped or written back. pub fn number(&mut self, ui: &mut Ui, entry: &Entry) { let reported = self.speaker.values.get(&entry.path).cloned(); let device = reported.as_ref().and_then(Value::as_f64); @@ -56,10 +57,11 @@ impl Fields<'_> { .max_for(product, &self.catalog.classes) .unwrap_or(f64::INFINITY); let pending_id = ui.id().with(entry.path.to_string()).with(PENDING_NUMBER_ID); - let pending = ui.data(|data| data.get_temp::(pending_id)); - let mut value = pending.or(device).unwrap_or(entry.min.unwrap_or_default()); + let held = ui.data(|data| data.get_temp::(pending_id)); + let mut value = held.or(device).unwrap_or(entry.min.unwrap_or_default()); let mut drag = DragValue::new(&mut value) .range(min..=max) + .clamp_existing_to_range(false) .speed(entry.step.unwrap_or(UNSTEPPED_DRAG_SPEED)); if let Some(unit) = &entry.unit { drag = drag.suffix(format!(" {unit}")); @@ -70,16 +72,20 @@ impl Fields<'_> { ui.add_enabled(enabled, drag).labelled_by(label.id) }) .inner; - if response.changed() { - let snapped = writes::number_value(entry, reported.as_ref(), value); - let shown = snapped.as_f64().unwrap_or(value); - ui.data_mut(|data| data.insert_temp(pending_id, shown)); + let active = response.dragged() || response.has_focus(); + let ended = response.drag_stopped() || response.lost_focus(); + if !active && !ended { + ui.data_mut(|data| data.remove::(pending_id)); + return; } - let gesture_over = !response.dragged() && !response.has_focus(); - let pending = ui.data(|data| data.get_temp::(pending_id)); - let Some(number) = pending.filter(|_| gesture_over) else { + let edited = response.changed().then_some(value); + let Some(number) = edited.or(held) else { return; }; + if active { + ui.data_mut(|data| data.insert_temp(pending_id, number)); + return; + } ui.data_mut(|data| data.remove::(pending_id)); if ui.input(|input| input.key_pressed(Key::Escape)) { return; @@ -176,10 +182,16 @@ impl Fields<'_> { // Keys typed while a dialog is open belong to the dialog. let dialog_open = ui.memory(|memory| memory.top_modal_layer().is_some()); let entered = field.lost_focus() && ui.input(|input| input.key_pressed(Key::Enter)); - let applied = ui.add_enabled(enabled, Button::new(texts::APPLY)).clicked(); - if applied || (entered && !dialog_open) { + let apply = ui.add_enabled(enabled, Button::new(texts::APPLY)); + if apply.clicked() || (entered && !dialog_open) { self.submit_text(ui, entry, &mut draft); } + // Leaving the field without writing gives it back to the device, unless the press + // that took the focus is on "Übernehmen" or a refusal still explains the text. + let abandoned = field.lost_focus() && !entered && !apply.is_pointer_button_down_on(); + if abandoned && draft.refusal.is_none() { + draft.edited = false; + } if let Some(refusal) = &draft.refusal { ui.colored_label(ui.visuals().error_fg_color, refusal); } @@ -187,15 +199,19 @@ impl Fields<'_> { } fn submit_text(&mut self, ui: &Ui, entry: &Entry, draft: &mut TextDraft) { - let value = Value::String(draft.text.clone()); - if let Err(refusal) = writes::check(self.speaker, self.catalog, &entry.path, &value) { - draft.refusal = Some(texts::describe_refusal(&refusal)); - return; + let intent = Intent::Write { + path: entry.path.clone(), + value: Value::String(draft.text.clone()), + }; + let now = ui.input(|input| input.time); + match self.speaker.try_request(intent, self.catalog, now) { + Ok(command) => { + draft.edited = false; + draft.refusal = None; + self.commands.push(command); + } + Err(refusal) => draft.refusal = Some(texts::describe_refusal(&refusal)), } - draft.edited = false; - draft.refusal = None; - let path = entry.path.clone(); - self.send(ui, Intent::Write { path, value }); } /// The dialog for a selection that needs confirmation; the write waits until "Umschalten". diff --git a/crates/app/src/writes.rs b/crates/app/src/writes.rs index c95464d..6ea01f1 100644 --- a/crates/app/src/writes.rs +++ b/crates/app/src/writes.rs @@ -1,6 +1,7 @@ //! The write policy: what the user asked for becomes a `Command` only for a connected speaker //! whose model the catalog lists as writable for the path, with a value the catalog accepts. -//! Anything else is refused with a message on the card. +//! Anything else is refused: `request` puts the reason on the card's message line, and +//! `try_request` hands it to the caller (a text field shows it under itself). use neumann_control_core::catalog::{Catalog, Entry, ValueError}; use neumann_control_core::speaker::Command; @@ -117,24 +118,33 @@ impl SpeakerState { /// A sent level is held on the fader until the speaker reports it (`now` is the UI clock). pub fn request(&mut self, intent: Intent, catalog: &Catalog, now: f64) -> Option { let path = intent.path(); - let checked = self - .intended_value(&intent, &path) - .and_then(|value| check(self, catalog, &path, &value).map(|()| value)); - match checked { - Ok(value) => { - if let Intent::Level(level) = intent { - self.fader.sent(level, now); - } - Some(Command::Set { path, value }) - } + match self.try_request(intent, catalog, now) { + Ok(command) => Some(command), Err(refusal) => { - log::warn!("not sending {path}: {refusal:?}"); self.set_message(Some(texts::describe_refusal(&refusal)), Some(path)); None } } } + /// Like `request`, but the refusal goes back to the caller instead of onto the card. + pub fn try_request( + &mut self, + intent: Intent, + catalog: &Catalog, + now: f64, + ) -> Result { + let path = intent.path(); + let checked = self + .intended_value(&intent, &path) + .and_then(|value| check(self, catalog, &path, &value).map(|()| value)); + let value = checked.inspect_err(|refusal| log::warn!("not sending {path}: {refusal:?}"))?; + if let Intent::Level(level) = intent { + self.fader.sent(level, now); + } + Ok(Command::Set { path, value }) + } + fn intended_value(&self, intent: &Intent, path: &SscPath) -> Result { match intent { Intent::Level(level) => Ok(Value::from(*level)), diff --git a/crates/app/tests/details.rs b/crates/app/tests/details.rs index e83be50..f402582 100644 --- a/crates/app/tests/details.rs +++ b/crates/app/tests/details.rs @@ -18,6 +18,10 @@ const CONTROL_MODE_LABEL: &str = "Steuermodus"; const NAME_PATH: &str = "/device/name"; const CONTROL_MODE_PATH: &str = "/ui/control_mode"; const DELAY_PATH: &str = "/audio/out/delay"; +const STANDBY_TIME_LABEL: &str = "Standby-Zeit"; +const STANDBY_TIME_PATH: &str = "/device/standby/auto_standby_time"; +/// Frames a widget would need to write a value nobody chose, if it did. +const IDLE_FRAMES: usize = 10; /// A connected card with "Mehr" open, in a window tall enough for the whole area. fn expanded_card(model: SimModel) -> (Simulator, tempfile::TempDir, Harness<'static, NeumannApp>) { @@ -27,6 +31,7 @@ fn expanded_card(model: SimModel) -> (Simulator, tempfile::TempDir, Harness<'sta wait_until(&mut harness, "the expanded area", |harness| { harness.query_by_label(texts::ALL_VALUES).is_some() }); + harness.run_steps(1); (simulator, directory, harness) } @@ -154,3 +159,149 @@ fn number_field_sends_once_per_gesture() { .expect("the delay is sent as a whole number"); assert!(delay > 0, "dragging right raises the delay: {sets:?}"); } + +/// Clicks a number field into text editing and types `text` over its value. +fn type_number(harness: &mut Harness<'static, NeumannApp>, label: &str, text: &str) { + harness.get_by_label(label).click(); + harness.run_steps(1); + harness.get_by_label(label).type_text(text); + harness.run_steps(1); +} + +fn numeric_value(harness: &Harness<'static, NeumannApp>, label: &str) -> Option { + harness.get_by_label(label).accesskit_node().numeric_value() +} + +#[test] +fn out_of_range_device_value_is_not_written_back() { + let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); + simulator.set_value("audio/out/delay", json!(6000)); + wait_until(&mut harness, "the reported delay", |harness| { + numeric_value(harness, DELAY_LABEL) == Some(6000.0) + }); + harness.run_steps(IDLE_FRAMES); + barrier(&simulator); + let sets = sets_to(&simulator, DELAY_PATH); + assert!(sets.is_empty(), "{sets:?}"); +} + +#[test] +fn abandoned_number_edit_is_not_sent_on_reopen() { + let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); + type_number(&mut harness, STANDBY_TIME_LABEL, "50"); + harness.get_by_label(texts::LESS).click(); + harness.run_steps(2); + harness.get_by_label(texts::MORE).click(); + harness.run_steps(IDLE_FRAMES); + barrier(&simulator); + let sets = sets_to(&simulator, STANDBY_TIME_PATH); + assert!(sets.is_empty(), "{sets:?}"); +} + +#[test] +fn a_typed_number_is_snapped_only_when_sent() { + let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); + type_number(&mut harness, STANDBY_TIME_LABEL, "1.5"); + harness.run_steps(2); + let shown = harness.get_by_label(STANDBY_TIME_LABEL).value(); + assert_eq!(shown.as_deref(), Some("1.5")); + harness.key_press(egui::Key::Enter); + wait_until(&mut harness, "the Set after Enter", |_| { + !sets_to(&simulator, STANDBY_TIME_PATH).is_empty() + }); + assert_eq!(sets_to(&simulator, STANDBY_TIME_PATH), vec![json!(2)]); +} + +#[test] +fn escape_during_number_edit_sends_nothing() { + let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); + type_number(&mut harness, STANDBY_TIME_LABEL, "50"); + harness.key_press(egui::Key::Escape); + harness.run_steps(IDLE_FRAMES); + barrier(&simulator); + let sets = sets_to(&simulator, STANDBY_TIME_PATH); + assert!(sets.is_empty(), "{sets:?}"); + assert_eq!(numeric_value(&harness, STANDBY_TIME_LABEL), Some(60.0)); +} + +#[test] +fn an_abandoned_name_draft_follows_the_device_again() { + let (_simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); + harness.get_by_role(Role::TextInput).click(); + harness.run_steps(1); + harness.get_by_role(Role::TextInput).type_text("draft"); + harness.run_steps(1); + let typed = harness + .get_by_role(Role::TextInput) + .value() + .unwrap_or_default(); + assert!(typed.contains("draft"), "{typed}"); + harness.get_by_label("Gerät").click(); + harness.run_steps(2); + let shown = harness.get_by_role(Role::TextInput).value(); + assert_eq!(shown.as_deref(), Some("Simulated KH 120 II")); +} + +#[test] +fn card_controls_are_not_repeated_in_the_area() { + let (_simulator, _directory, harness) = expanded_card(SimModel::Kh120Ii); + assert_eq!(harness.query_all_by_label(texts::MUTE).count(), 1); + assert_eq!(harness.query_all_by_label(texts::IDENTIFY).count(), 1); + assert!(harness.query_by_label("Identifizieren").is_none()); + assert!(harness.query_by_label("Eingangspegel").is_none()); + assert_eq!(harness.query_all_by_role(Role::Slider).count(), 1); + assert!(harness.query_all_by_role(Role::SpinButton).all(|node| node + .accesskit_node() + .label() + .as_deref() + != Some(texts::LEVEL))); +} + +#[test] +fn kh750_output_meters_are_drawn() { + let (_simulator, _directory, harness) = expanded_card(SimModel::Kh750); + // One for the meters, one for the rear-panel output level. + assert_eq!(harness.query_all_by_label("Ausgangspegel").count(), 2); +} + +#[test] +fn cancelling_local_control_mode_sends_nothing() { + let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); + harness.get_by_label(CONTROL_MODE_LABEL).click(); + harness.run_steps(2); + harness.get_by_label("LOCAL").click(); + harness.run_steps(2); + harness.get_by_label(texts::CANCEL).click(); + harness.run_steps(IDLE_FRAMES); + barrier(&simulator); + assert!(sets_to(&simulator, CONTROL_MODE_PATH).is_empty()); + assert!(harness.query_by_label(texts::LOCAL_MODE_WARNING).is_none()); +} + +#[test] +fn network_control_mode_is_sent_without_confirmation() { + let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); + simulator.set_value("ui/control_mode", json!("LOCAL")); + wait_until(&mut harness, "LOCAL on the card", |harness| { + harness.get_by_label(CONTROL_MODE_LABEL).value().as_deref() == Some("LOCAL") + }); + harness.get_by_label(CONTROL_MODE_LABEL).click(); + harness.run_steps(2); + harness.get_by_label("NETWORK").click(); + wait_until(&mut harness, "NETWORK on the device", |_| { + simulator.value("ui/control_mode") == Some(json!("NETWORK")) + }); + assert!(harness.query_by_label(texts::LOCAL_MODE_WARNING).is_none()); +} + +#[test] +fn all_values_button_reads_the_whole_tree() { + let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); + harness.get_by_label(texts::ALL_VALUES).click(); + wait_until(&mut harness, "the schema walk", |_| { + simulator + .requests() + .iter() + .any(|request| request.pointer("/osc/schema").is_some()) + }); +} From 070107bf278f3de9ef66ccb2fc361e135768de17 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:13:32 +0200 Subject: [PATCH 028/460] =?UTF-8?q?Keep=20a=20name=20draft=20while=20focus?= =?UTF-8?q?=20is=20on=20=C3=9Cbernehmen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/app/src/views/details_fields.rs | 9 +++---- crates/app/tests/details.rs | 33 ++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/crates/app/src/views/details_fields.rs b/crates/app/src/views/details_fields.rs index 6bc4ef0..852d7c5 100644 --- a/crates/app/src/views/details_fields.rs +++ b/crates/app/src/views/details_fields.rs @@ -186,10 +186,11 @@ impl Fields<'_> { if apply.clicked() || (entered && !dialog_open) { self.submit_text(ui, entry, &mut draft); } - // Leaving the field without writing gives it back to the device, unless the press - // that took the focus is on "Übernehmen" or a refusal still explains the text. - let abandoned = field.lost_focus() && !entered && !apply.is_pointer_button_down_on(); - if abandoned && draft.refusal.is_none() { + // The draft belongs to the field and "Übernehmen" together: only when focus has left + // both (and no press is on the button) is it given back to the device, unless a + // refusal still explains the text. + let in_use = field.has_focus() || apply.has_focus() || apply.is_pointer_button_down_on(); + if !in_use && draft.refusal.is_none() { draft.edited = false; } if let Some(refusal) = &draft.refusal { diff --git a/crates/app/tests/details.rs b/crates/app/tests/details.rs index f402582..d9a11d6 100644 --- a/crates/app/tests/details.rs +++ b/crates/app/tests/details.rs @@ -242,6 +242,39 @@ fn an_abandoned_name_draft_follows_the_device_again() { assert_eq!(shown.as_deref(), Some("Simulated KH 120 II")); } +#[test] +fn a_name_typed_then_tabbed_to_apply_is_sent() { + let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); + harness.get_by_role(Role::TextInput).click(); + harness.run_steps(1); + harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + harness.get_by_role(Role::TextInput).type_text("Studio"); + harness.run_steps(1); + harness.key_press(egui::Key::Tab); + harness.run_steps(2); + harness.key_press(egui::Key::Enter); + wait_until(&mut harness, "the Set after Enter on Übernehmen", |_| { + !sets_to(&simulator, NAME_PATH).is_empty() + }); + barrier(&simulator); + assert_eq!(sets_to(&simulator, NAME_PATH), vec![json!("Studio")]); +} + +#[test] +fn a_name_draft_left_by_tabbing_past_apply_follows_the_device_again() { + let (_simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); + harness.get_by_role(Role::TextInput).click(); + harness.run_steps(1); + harness.get_by_role(Role::TextInput).type_text("draft"); + harness.run_steps(1); + harness.key_press(egui::Key::Tab); + harness.run_steps(2); + harness.key_press(egui::Key::Tab); + harness.run_steps(3); + let shown = harness.get_by_role(Role::TextInput).value(); + assert_eq!(shown.as_deref(), Some("Simulated KH 120 II")); +} + #[test] fn card_controls_are_not_repeated_in_the_area() { let (_simulator, _directory, harness) = expanded_card(SimModel::Kh120Ii); From 9cc63ef0747f2206fb04f99f110a15fc3052375f Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:15:46 +0200 Subject: [PATCH 029/460] Show number fields unrounded so a visit is no edit --- crates/app/src/views/details_fields.rs | 5 ++++- crates/app/tests/details.rs | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/app/src/views/details_fields.rs b/crates/app/src/views/details_fields.rs index 852d7c5..29a7afc 100644 --- a/crates/app/src/views/details_fields.rs +++ b/crates/app/src/views/details_fields.rs @@ -46,7 +46,9 @@ impl Fields<'_> { /// A drag or a typed value is one gesture. Its value is held while the gesture is active /// and written once, on the frame it ends (release, Enter or focus loss); Escape drops it. - /// The device value itself is never clamped or written back. + /// The device value itself is never clamped or written back. The field shows the value + /// unrounded: egui parses the shown text when focus leaves and would otherwise report the + /// rounded text as an edit. pub fn number(&mut self, ui: &mut Ui, entry: &Entry) { let reported = self.speaker.values.get(&entry.path).cloned(); let device = reported.as_ref().and_then(Value::as_f64); @@ -62,6 +64,7 @@ impl Fields<'_> { let mut drag = DragValue::new(&mut value) .range(min..=max) .clamp_existing_to_range(false) + .custom_formatter(|number, _| number.to_string()) .speed(entry.step.unwrap_or(UNSTEPPED_DRAG_SPEED)); if let Some(unit) = &entry.unit { drag = drag.suffix(format!(" {unit}")); diff --git a/crates/app/tests/details.rs b/crates/app/tests/details.rs index d9a11d6..d1420c9 100644 --- a/crates/app/tests/details.rs +++ b/crates/app/tests/details.rs @@ -185,6 +185,22 @@ fn out_of_range_device_value_is_not_written_back() { assert!(sets.is_empty(), "{sets:?}"); } +#[test] +fn visiting_a_number_field_does_not_write_its_rounded_text() { + let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); + simulator.set_value("audio/out/delay", json!(100.123456)); + wait_until(&mut harness, "the reported delay", |harness| { + numeric_value(harness, DELAY_LABEL) == Some(100.123456) + }); + harness.get_by_label(DELAY_LABEL).click(); + harness.run_steps(2); + harness.key_press(egui::Key::Tab); + harness.run_steps(IDLE_FRAMES); + barrier(&simulator); + let sets = sets_to(&simulator, DELAY_PATH); + assert!(sets.is_empty(), "{sets:?}"); +} + #[test] fn abandoned_number_edit_is_not_sent_on_reopen() { let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); From 751e25e2da9827ef21b4b6f60e341ced51275dbc Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:20:03 +0200 Subject: [PATCH 030/460] Send a number only if the gesture produced it --- crates/app/src/views/details_fields.rs | 13 ++++++++----- crates/app/tests/details.rs | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/crates/app/src/views/details_fields.rs b/crates/app/src/views/details_fields.rs index 29a7afc..4172d73 100644 --- a/crates/app/src/views/details_fields.rs +++ b/crates/app/src/views/details_fields.rs @@ -81,14 +81,17 @@ impl Fields<'_> { ui.data_mut(|data| data.remove::(pending_id)); return; } - let edited = response.changed().then_some(value); - let Some(number) = edited.or(held) else { - return; - }; + // egui also reports a change on the frame focus leaves, when it reparses and clamps + // the shown text; only changes during the gesture are the user's. if active { - ui.data_mut(|data| data.insert_temp(pending_id, number)); + if response.changed() { + ui.data_mut(|data| data.insert_temp(pending_id, value)); + } return; } + let Some(number) = held else { + return; + }; ui.data_mut(|data| data.remove::(pending_id)); if ui.input(|input| input.key_pressed(Key::Escape)) { return; diff --git a/crates/app/tests/details.rs b/crates/app/tests/details.rs index d1420c9..81015ea 100644 --- a/crates/app/tests/details.rs +++ b/crates/app/tests/details.rs @@ -201,6 +201,23 @@ fn visiting_a_number_field_does_not_write_its_rounded_text() { assert!(sets.is_empty(), "{sets:?}"); } +#[test] +fn visiting_an_out_of_range_number_field_writes_nothing() { + let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); + simulator.set_value("audio/out/delay", json!(6000)); + wait_until(&mut harness, "the reported delay", |harness| { + numeric_value(harness, DELAY_LABEL) == Some(6000.0) + }); + harness.get_by_label(DELAY_LABEL).click(); + harness.run_steps(2); + harness.key_press(egui::Key::Tab); + harness.run_steps(IDLE_FRAMES); + barrier(&simulator); + let sets = sets_to(&simulator, DELAY_PATH); + assert!(sets.is_empty(), "{sets:?}"); + assert_eq!(numeric_value(&harness, DELAY_LABEL), Some(6000.0)); +} + #[test] fn abandoned_number_edit_is_not_sent_on_reopen() { let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); From a133ea088b22b9db68f9475050fa96850b691455 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:24:10 +0200 Subject: [PATCH 031/460] Send a number typed and entered in one frame --- crates/app/src/views/details_fields.rs | 24 ++++++++++++++++++------ crates/app/tests/details.rs | 20 ++++++++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/crates/app/src/views/details_fields.rs b/crates/app/src/views/details_fields.rs index 4172d73..0762e10 100644 --- a/crates/app/src/views/details_fields.rs +++ b/crates/app/src/views/details_fields.rs @@ -1,7 +1,7 @@ //! The controls of the "Mehr" area, one per writable kind: number, switch, selection and text, //! plus the confirmation a risky selection waits for. Every write goes through `writes`. -use eframe::egui::{Button, Checkbox, ComboBox, DragValue, Id, Key, Modal, TextEdit, Ui}; +use eframe::egui::{Button, Checkbox, ComboBox, DragValue, Event, Id, Key, Modal, TextEdit, Ui}; use neumann_control_core::catalog::{Catalog, Entry}; use neumann_control_core::speaker::Command; use serde_json::Value; @@ -17,6 +17,16 @@ const PENDING_NUMBER_ID: &str = "pending_number"; const TEXT_DRAFT_ID: &str = "text_draft"; const TEXT_FIELD_ID: &str = "text_field"; +fn is_text_edit(event: &Event) -> bool { + match event { + Event::Text(_) | Event::Paste(_) | Event::Cut => true, + Event::Key { + key, pressed: true, .. + } => matches!(key, Key::Backspace | Key::Delete), + _ => false, + } +} + /// What the user typed into a text field and has not yet written. #[derive(Clone, Debug, Default)] struct TextDraft { @@ -82,14 +92,16 @@ impl Fields<'_> { return; } // egui also reports a change on the frame focus leaves, when it reparses and clamps - // the shown text; only changes during the gesture are the user's. + // the shown text; that one is the user's only if the same frame carried their typing. + let typed_this_frame = ui.input(|input| input.events.iter().any(is_text_edit)); + let changed_by_user = response.changed() && (active || typed_this_frame); + if changed_by_user { + ui.data_mut(|data| data.insert_temp(pending_id, value)); + } if active { - if response.changed() { - ui.data_mut(|data| data.insert_temp(pending_id, value)); - } return; } - let Some(number) = held else { + let Some(number) = changed_by_user.then_some(value).or(held) else { return; }; ui.data_mut(|data| data.remove::(pending_id)); diff --git a/crates/app/tests/details.rs b/crates/app/tests/details.rs index 81015ea..158fe3e 100644 --- a/crates/app/tests/details.rs +++ b/crates/app/tests/details.rs @@ -218,6 +218,26 @@ fn visiting_an_out_of_range_number_field_writes_nothing() { assert_eq!(numeric_value(&harness, DELAY_LABEL), Some(6000.0)); } +#[test] +fn a_number_typed_and_entered_in_one_frame_is_sent() { + let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); + harness.get_by_label(STANDBY_TIME_LABEL).click(); + harness.run_steps(2); + let events = &mut harness.input_mut().events; + events.push(egui::Event::Text("50".to_owned())); + events.push(egui::Event::Key { + key: egui::Key::Enter, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }); + harness.run_steps(1); + harness.run_steps(IDLE_FRAMES); + barrier(&simulator); + assert_eq!(sets_to(&simulator, STANDBY_TIME_PATH), vec![json!(50)]); +} + #[test] fn abandoned_number_edit_is_not_sent_on_reopen() { let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); From 03edc91d2ca4f1a1b04e973c45cdd6178c08b295 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:36:09 +0200 Subject: [PATCH 032/460] List every value from the device schema Read-only window with filter, masked identifiers and capped cells; a late answer no longer reopens a closed window. --- crates/app/src/all_values.rs | 205 +++++++++++++++++++++++++++++ crates/app/src/app.rs | 17 ++- crates/app/src/lib.rs | 1 + crates/app/src/paths.rs | 1 + crates/app/src/state.rs | 33 ++++- crates/app/src/texts.rs | 20 +++ crates/app/src/views/all_values.rs | 120 +++++++++++++++++ crates/app/src/views/mod.rs | 1 + crates/app/tests/views.rs | 103 ++++++++++++++- 9 files changed, 496 insertions(+), 5 deletions(-) create mode 100644 crates/app/src/all_values.rs create mode 100644 crates/app/src/views/all_values.rs diff --git a/crates/app/src/all_values.rs b/crates/app/src/all_values.rs new file mode 100644 index 0000000..7185705 --- /dev/null +++ b/crates/app/src/all_values.rs @@ -0,0 +1,205 @@ +//! What the "Alle Werte" table shows: one row per leaf of the device schema, filtered, with +//! identifiers masked and device text capped. No drawing here. + +use neumann_control_core::catalog::Catalog; +use neumann_control_core::mask::mask_serial; +use neumann_control_core::schema_walk::LeafReport; +use serde_json::Value; + +use crate::paths; + +/// A device can answer with any amount of text; one table cell must not grow with it. +pub const VALUE_CHARACTER_LIMIT: usize = 200; +const ELLIPSIS: char = '…'; +const COPY_SEPARATOR: &str = " = "; +const RANGE_SEPARATOR: &str = " "; +const OPTION_SEPARATOR: &str = " | "; +const BOUNDS_SEPARATOR: &str = " … "; +const UNITS_KEY: &str = "units"; +const MINIMUM_KEY: &str = "min"; +const MAXIMUM_KEY: &str = "max"; +const OPTIONS_KEY: &str = "option"; +/// Leaves that name the device itself; shown masked like the serial on the card. +const IDENTIFIER_PATHS: &[&str] = &[paths::SERIAL, paths::MAC_ADDRESSES]; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Row { + pub path: String, + /// Capped for the cell. + pub value: String, + /// Units, range and options from the leaf's limits. + pub range: String, + pub known: bool, + /// `path = value`, whole. + pub copy_text: String, +} + +/// The reports whose path contains `filter`, ignoring case and surrounding blanks. +pub fn rows(reports: &[LeafReport], filter: &str, catalog: &Catalog) -> Vec { + let wanted = filter.trim().to_lowercase(); + reports + .iter() + .filter(|report| report.path.to_string().to_lowercase().contains(&wanted)) + .map(|report| row(report, catalog)) + .collect() +} + +fn row(report: &LeafReport, catalog: &Catalog) -> Row { + let path = report.path.to_string(); + let full = value_text(report, &path); + Row { + value: capped(&full), + range: capped(&range_text(report.limits.as_ref())), + known: paths::catalog_entry(catalog, &report.path).is_some(), + copy_text: format!("{path}{COPY_SEPARATOR}{full}"), + path, + } +} + +fn value_text(report: &LeafReport, path: &str) -> String { + let text = match &report.value { + Ok(value) => plain_text(value), + Err(refusal) => refusal.clone(), + }; + if IDENTIFIER_PATHS.contains(&path) { + return mask_serial(&text); + } + text +} + +/// Strings without their quotes, everything else as JSON. +fn plain_text(value: &Value) -> String { + match value { + Value::String(text) => text.clone(), + other => other.to_string(), + } +} + +fn range_text(limits: Option<&Value>) -> String { + let Some(limits) = limits else { + return String::new(); + }; + let mut parts = Vec::new(); + if let Some(units) = limits.get(UNITS_KEY).map(plain_text) { + parts.push(units); + } + let bounds = (limits.get(MINIMUM_KEY), limits.get(MAXIMUM_KEY)); + if bounds != (None, None) { + let [minimum, maximum] = [bounds.0, bounds.1].map(|bound| bound.map(plain_text)); + parts.push(format!( + "{}{BOUNDS_SEPARATOR}{}", + minimum.unwrap_or_default(), + maximum.unwrap_or_default() + )); + } + if let Some(options) = limits.get(OPTIONS_KEY) { + parts.push(options_text(options)); + } + parts.join(RANGE_SEPARATOR) +} + +fn options_text(options: &Value) -> String { + match options { + Value::Array(items) => items + .iter() + .map(plain_text) + .collect::>() + .join(OPTION_SEPARATOR), + other => plain_text(other), + } +} + +/// At most `VALUE_CHARACTER_LIMIT` characters, cut on a character boundary. +fn capped(text: &str) -> String { + let mut characters = text.chars(); + let kept: String = characters.by_ref().take(VALUE_CHARACTER_LIMIT).collect(); + if characters.next().is_none() { + return text.to_owned(); + } + format!("{kept}{ELLIPSIS}") +} + +#[cfg(test)] +mod tests { + use super::*; + use neumann_control_core::ssc::wire::SscPath; + use serde_json::json; + + fn report(path: &str, value: Result, limits: Option) -> LeafReport { + LeafReport { + path: SscPath::parse(path).unwrap(), + value, + limits, + } + } + + fn one(report: LeafReport) -> Row { + rows(&[report], "", Catalog::embedded()).remove(0) + } + + #[test] + fn filter_ignores_case_and_blanks() { + let reports = [ + report("device/standby/level", Ok(json!(-65)), None), + report("audio/out/level", Ok(json!(100.0)), None), + ]; + let found = rows(&reports, " STANDBY ", Catalog::embedded()); + assert_eq!(found.len(), 1); + assert_eq!(found[0].path, "device/standby/level"); + assert_eq!(rows(&reports, "", Catalog::embedded()).len(), 2); + } + + #[test] + fn long_text_is_capped_in_the_cell_but_copied_whole() { + let long = "ä".repeat(VALUE_CHARACTER_LIMIT + 50); + let row = one(report("device/name", Ok(json!(long)), None)); + assert_eq!(row.value.chars().count(), VALUE_CHARACTER_LIMIT + 1); + assert!(row.value.ends_with(ELLIPSIS)); + assert_eq!(row.copy_text, format!("device/name = {long}")); + } + + #[test] + fn text_at_the_limit_is_not_cut() { + let exact = "x".repeat(VALUE_CHARACTER_LIMIT); + let row = one(report("device/name", Ok(json!(exact)), None)); + assert_eq!(row.value, exact); + } + + #[test] + fn identifiers_are_masked_in_cell_and_copy() { + for path in IDENTIFIER_PATHS { + let row = one(report(path, Ok(json!("SIM0001234")), None)); + assert_eq!(row.value, "…234", "{path}"); + assert_eq!(row.copy_text, format!("{path} = …234")); + } + } + + #[test] + fn a_refusal_is_the_value_text() { + let row = one(report("warnings", Err("refused (404)".to_owned()), None)); + assert_eq!(row.value, "refused (404)"); + } + + #[test] + fn known_means_in_the_catalog() { + assert!(one(report(paths::OUTPUT_LEVEL, Ok(json!(1)), None)).known); + assert!(!one(report("device/restore", Ok(json!("")), None)).known); + } + + #[test] + fn range_joins_units_bounds_and_options() { + let limits = json!({ "type": "number", "units": "dB", "min": 0, "max": 120 }); + let row = one(report("a/b", Ok(json!(1)), Some(limits))); + assert_eq!(row.range, "dB 0 … 120"); + let options = json!({ "option": ["ON", "OFF"] }); + let row = one(report("a/b", Ok(json!("ON")), Some(options))); + assert_eq!(row.range, "ON | OFF"); + let row = one(report( + "a/b", + Ok(json!(1)), + Some(json!({ "type": "number" })), + )); + assert_eq!(row.range, ""); + assert_eq!(one(report("a/b", Ok(json!(1)), None)).range, ""); + } +} diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index e4611cc..9e4513d 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -16,13 +16,13 @@ use neumann_control_core::settings::{self, ManualSpeaker, Settings, WindowGeomet use neumann_control_core::speaker::{Command, Event, SpeakerHandle, Target}; use crate::spawner::{ScanResult, SpeakerSpawner}; -use crate::state::{SpeakerKey, SpeakerState}; +use crate::state::{AllValuesState, SpeakerKey, SpeakerState}; use crate::targets::{duplicate_cards, merge_targets, remember_serial}; use crate::texts; use crate::ui_kit::{fonts, theme}; use crate::views::add_address::{self, AddAddressAction, AddAddressForm}; -use crate::views::card; use crate::views::toolbar::{self, ToolbarAction, ToolbarFacts}; +use crate::views::{all_values, card}; const CARDS_SCROLL_ID: &str = "cards"; @@ -220,6 +220,19 @@ impl NeumannApp { } egui::CentralPanel::default().show(ui, |ui| self.show_cards(ui)); self.show_dialog(ui); + self.show_all_values(ui.ctx()); + } + + fn show_all_values(&mut self, context: &Context) { + let catalog = Catalog::embedded(); + for speaker in &mut self.speakers { + if speaker.state.all_values == AllValuesState::Idle { + continue; + } + for command in all_values::show(context, &mut speaker.state, catalog) { + speaker.handle.send(command); + } + } } fn show_cards(&mut self, ui: &mut Ui) { diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index e6edb7a..ce85077 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -1,3 +1,4 @@ +pub mod all_values; pub mod app; pub mod display; pub mod fader; diff --git a/crates/app/src/paths.rs b/crates/app/src/paths.rs index f755773..fb25eaf 100644 --- a/crates/app/src/paths.rs +++ b/crates/app/src/paths.rs @@ -13,6 +13,7 @@ pub const OUTPUT_CLIP: &str = "m/out/clip"; pub const PRODUCT: &str = "device/identity/product"; pub const FIRMWARE_VERSION: &str = "device/identity/version"; pub const SERIAL: &str = "device/identity/serial"; +pub const MAC_ADDRESSES: &str = "device/network/ether/macs"; pub const CONTROL_MODE: &str = "ui/control_mode"; pub fn path(text: &str) -> SscPath { diff --git a/crates/app/src/state.rs b/crates/app/src/state.rs index 073ef33..54fdb83 100644 --- a/crates/app/src/state.rs +++ b/crates/app/src/state.rs @@ -57,6 +57,8 @@ pub struct SpeakerState { pub output_meters: Vec, pub fader: FaderState, pub all_values: AllValuesState, + /// Case-insensitive part of a path the "Alle Werte" table is narrowed to. + pub all_values_filter: String, } impl SpeakerState { @@ -76,6 +78,7 @@ impl SpeakerState { output_meters: Vec::new(), fader: FaderState::default(), all_values: AllValuesState::Idle, + all_values_filter: String::new(), } } @@ -154,8 +157,10 @@ impl SpeakerState { let text = texts::serial_mismatch(&mask_serial(&found)); self.set_message(Some(text), None); } - Event::AllValues(reports) => self.all_values = AllValuesState::Loaded(reports), - Event::AllValuesFailed(reason) => self.all_values = AllValuesState::Failed(reason), + Event::AllValues(reports) => self.finish_all_values(AllValuesState::Loaded(reports)), + Event::AllValuesFailed(reason) => { + self.finish_all_values(AllValuesState::Failed(reason)); + } } } @@ -165,6 +170,19 @@ impl SpeakerState { Command::ReadAll } + /// Closes the "Alle Werte" window and forgets its filter. + pub fn close_all_values(&mut self) { + self.all_values = AllValuesState::Idle; + self.all_values_filter.clear(); + } + + /// An answer that comes after the window was closed would reopen it. + fn finish_all_values(&mut self, outcome: AllValuesState) { + if self.all_values == AllValuesState::Loading { + self.all_values = outcome; + } + } + pub(crate) fn set_message(&mut self, message: Option, path: Option) { self.message = message; self.message_path = path; @@ -401,6 +419,17 @@ mod tests { ); } + #[test] + fn an_answer_after_closing_does_not_reopen_the_window() { + let mut speaker = state(); + speaker.read_all_values(); + speaker.all_values_filter = "standby".to_owned(); + speaker.close_all_values(); + speaker.apply(Event::AllValues(Vec::new()), 0.0); + assert_eq!(speaker.all_values, AllValuesState::Idle); + assert!(speaker.all_values_filter.is_empty()); + } + fn identity(name: &str, product: &str) -> Identity { Identity { product: product.to_owned(), diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 89b27cb..0006c8e 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -30,6 +30,18 @@ pub const CELSIUS: &str = "°C"; pub const SWITCH_MODE: &str = "Umschalten"; pub const LOCAL_MODE_WARNING: &str = "Im Modus LOCAL kann die Netzwerksteuerung gesperrt sein, bis am Gerät zurückgestellt wird. Trotzdem umschalten?"; +pub const ALL_VALUES_FILTER_LABEL: &str = "Pfad enthält"; +pub const ALL_VALUES_RELOAD: &str = "Neu einlesen"; +pub const ALL_VALUES_LOADING: &str = "Lese alle Werte …"; +pub const ALL_VALUES_FAILED: &str = + "Die Werte konnten nicht gelesen werden. Das Gerät ist nicht erreichbar oder hat die Abfrage abgelehnt."; +pub const ALL_VALUES_PATH_COLUMN: &str = "Pfad"; +pub const ALL_VALUES_VALUE_COLUMN: &str = "Wert"; +pub const ALL_VALUES_RANGE_COLUMN: &str = "Einheit / Bereich"; +pub const ALL_VALUES_KNOWN_COLUMN: &str = "Katalog"; +pub const ALL_VALUES_KNOWN: &str = "bekannt"; +pub const ALL_VALUES_COPY: &str = "Kopieren"; + pub const ADD_ADDRESS_TITLE: &str = "Lautsprecher per Adresse hinzufügen"; pub const ADDRESS_FIELD: &str = "IPv6-Adresse"; pub const ADDRESS_HINT: &str = "z. B. fe80::1%7"; @@ -49,6 +61,14 @@ pub fn speaker_count(count: usize) -> String { format!("{count} Lautsprecher") } +pub fn all_values_title(name: &str) -> String { + format!("Alle Werte – {name}") +} + +pub fn all_values_count(shown: usize, total: usize) -> String { + format!("{shown} von {total} Werten") +} + pub fn port(port: u16) -> String { format!("Port {port}") } diff --git a/crates/app/src/views/all_values.rs b/crates/app/src/views/all_values.rs new file mode 100644 index 0000000..9e8aabe --- /dev/null +++ b/crates/app/src/views/all_values.rs @@ -0,0 +1,120 @@ +//! The "Alle Werte" window: every leaf of the device schema with its value, read-only. The +//! only thing it can ask the worker for is a fresh read. + +use eframe::egui::{Context, Id, Label, ScrollArea, TextEdit, Ui, Window}; +use neumann_control_core::catalog::Catalog; +use neumann_control_core::speaker::Command; + +use crate::all_values::{rows, Row}; +use crate::state::{AllValuesState, SpeakerState}; +use crate::texts; + +const WINDOW_ID: &str = "all_values"; +const DEFAULT_WINDOW_SIZE: [f32; 2] = [920.0, 480.0]; +const PATH_WIDTH: f32 = 250.0; +const VALUE_WIDTH: f32 = 280.0; +const RANGE_WIDTH: f32 = 180.0; +const KNOWN_WIDTH: f32 = 70.0; +const FILTER_WIDTH: f32 = 220.0; + +/// Draws the window while the speaker's state is not idle and closes it (back to idle) when +/// the user does. Returns the commands the user asked for. +pub fn show(context: &Context, speaker: &mut SpeakerState, catalog: &Catalog) -> Vec { + let mut open = true; + let mut commands = Vec::new(); + Window::new(texts::all_values_title(&speaker.display_name())) + .id(Id::new((WINDOW_ID, speaker.key.clone()))) + .default_size(DEFAULT_WINDOW_SIZE) + .open(&mut open) + .show(context, |ui| contents(ui, speaker, catalog, &mut commands)); + if !open { + speaker.close_all_values(); + } + commands +} + +fn contents( + ui: &mut Ui, + speaker: &mut SpeakerState, + catalog: &Catalog, + commands: &mut Vec, +) { + let loading = speaker.all_values == AllValuesState::Loading; + ui.horizontal(|ui| { + let label = ui.label(texts::ALL_VALUES_FILTER_LABEL); + ui.add(TextEdit::singleline(&mut speaker.all_values_filter).desired_width(FILTER_WIDTH)) + .labelled_by(label.id); + if ui + .add_enabled( + !loading, + eframe::egui::Button::new(texts::ALL_VALUES_RELOAD), + ) + .clicked() + { + commands.push(speaker.read_all_values()); + } + }); + ui.separator(); + match &speaker.all_values { + AllValuesState::Idle => {} + AllValuesState::Loading => { + ui.horizontal(|ui| { + ui.spinner(); + ui.label(texts::ALL_VALUES_LOADING); + }); + } + AllValuesState::Failed(_) => { + ui.colored_label(ui.visuals().error_fg_color, texts::ALL_VALUES_FAILED); + } + AllValuesState::Loaded(reports) => { + let shown = rows(reports, &speaker.all_values_filter, catalog); + ui.weak(texts::all_values_count(shown.len(), reports.len())); + table(ui, &shown); + } + } +} + +fn table(ui: &mut Ui, shown: &[Row]) { + header(ui); + let row_height = ui.spacing().interact_size.y; + ScrollArea::vertical() + .auto_shrink([false, false]) + .show_rows(ui, row_height, shown.len(), |ui, range| { + for row in &shown[range] { + row_cells(ui, row); + } + }); +} + +fn header(ui: &mut Ui) { + ui.horizontal(|ui| { + cell(ui, PATH_WIDTH, texts::ALL_VALUES_PATH_COLUMN); + cell(ui, VALUE_WIDTH, texts::ALL_VALUES_VALUE_COLUMN); + cell(ui, RANGE_WIDTH, texts::ALL_VALUES_RANGE_COLUMN); + cell(ui, KNOWN_WIDTH, texts::ALL_VALUES_KNOWN_COLUMN); + }); +} + +fn row_cells(ui: &mut Ui, row: &Row) { + ui.horizontal(|ui| { + cell(ui, PATH_WIDTH, &row.path); + cell(ui, VALUE_WIDTH, &row.value); + cell(ui, RANGE_WIDTH, &row.range); + let known = if row.known { + texts::ALL_VALUES_KNOWN + } else { + "" + }; + cell(ui, KNOWN_WIDTH, known); + if ui.button(texts::ALL_VALUES_COPY).clicked() { + ui.ctx().copy_text(row.copy_text.clone()); + } + }); +} + +/// A label in a fixed-width column; what does not fit is cut, the full text is on hover. +fn cell(ui: &mut Ui, width: f32, text: &str) { + let height = ui.spacing().interact_size.y; + ui.add_sized([width, height], Label::new(text).truncate()) + .on_hover_text(text); +} diff --git a/crates/app/src/views/mod.rs b/crates/app/src/views/mod.rs index aa79cd2..1c3abb5 100644 --- a/crates/app/src/views/mod.rs +++ b/crates/app/src/views/mod.rs @@ -1,4 +1,5 @@ pub mod add_address; +pub mod all_values; pub mod card; pub mod details; mod details_fields; diff --git a/crates/app/tests/views.rs b/crates/app/tests/views.rs index ee1a56c..f327180 100644 --- a/crates/app/tests/views.rs +++ b/crates/app/tests/views.rs @@ -4,9 +4,17 @@ mod support; use eframe::egui::{self, accesskit::Role, MouseWheelUnit, TouchPhase}; use egui_kittest::kittest::{NodeT, Queryable}; +use egui_kittest::Harness; +use neumann_control_app::app::NeumannApp; +use neumann_control_app::paths; +use neumann_control_app::state::{SpeakerKey, SpeakerState}; use neumann_control_app::texts; +use neumann_control_app::views::all_values; +use neumann_control_core::address::parse_address; +use neumann_control_core::catalog::Catalog; use neumann_control_core::mask::mask_serial; -use neumann_control_core::simulator::SimModel; +use neumann_control_core::simulator::{SimModel, Simulator}; +use neumann_control_core::speaker::Event; use serde_json::Value; use support::*; @@ -201,3 +209,96 @@ fn out_of_range_level_is_not_written_back() { level_sets(&simulator) ); } + +const KH120II_LEAF_COUNT: usize = 50; +const STANDBY_LEAF_COUNT: usize = 4; +const SPEAKER_NAME: &str = "Simulated KH 120 II"; +const STANDBY_ENABLED_PATH: &str = "device/standby/enabled"; +/// A refusal text as the worker produces it from a lost connection, address included. +const RAW_FAILURE: &str = "connection error: could not read from fe80::2a36:38ff:fe12:3456%7"; + +fn all_values_window( + model: SimModel, +) -> (Simulator, tempfile::TempDir, Harness<'static, NeumannApp>) { + let (simulator, directory, mut harness) = connected_card(model); + harness.set_size(EXPANDED_WINDOW_SIZE.into()); + harness.get_by_label(texts::MORE).click(); + wait_until(&mut harness, "the expanded area", |harness| { + harness.query_by_label(texts::ALL_VALUES).is_some() + }); + harness.run_steps(1); + harness.get_by_label(texts::ALL_VALUES).click(); + wait_until(&mut harness, "the loaded table", |harness| { + harness + .query_by_label(texts::ALL_VALUES_PATH_COLUMN) + .is_some() + }); + (simulator, directory, harness) +} + +fn filter_all_values(harness: &mut Harness<'static, NeumannApp>, text: &str) { + harness.get_by_label(texts::ALL_VALUES_FILTER_LABEL).click(); + harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + harness + .get_by_label(texts::ALL_VALUES_FILTER_LABEL) + .type_text(text); + harness.run_steps(3); +} + +#[test] +fn all_values_lists_50_leaves_for_kh120ii() { + let (_simulator, _directory, harness) = all_values_window(SimModel::Kh120Ii); + harness.get_by_label(&texts::all_values_title(SPEAKER_NAME)); + harness.get_by_label(&texts::all_values_count( + KH120II_LEAF_COUNT, + KH120II_LEAF_COUNT, + )); +} + +#[test] +fn filter_narrows_rows() { + let (_simulator, _directory, mut harness) = all_values_window(SimModel::Kh120Ii); + filter_all_values(&mut harness, "standby"); + harness.get_by_label(&texts::all_values_count( + STANDBY_LEAF_COUNT, + KH120II_LEAF_COUNT, + )); + harness.get_by_label(STANDBY_ENABLED_PATH); + assert!(harness.query_by_label("audio/out/level").is_none()); +} + +#[test] +fn known_column_marks_catalog_paths() { + let (_simulator, _directory, mut harness) = all_values_window(SimModel::Kh120Ii); + filter_all_values(&mut harness, STANDBY_ENABLED_PATH); + harness.get_by_label(texts::ALL_VALUES_KNOWN); + filter_all_values(&mut harness, "restore"); + harness.get_by_label("device/restore"); + assert!(harness.query_by_label(texts::ALL_VALUES_KNOWN).is_none()); +} + +#[test] +fn serial_is_masked_in_the_table() { + let (_simulator, _directory, mut harness) = all_values_window(SimModel::Kh120Ii); + filter_all_values(&mut harness, paths::SERIAL); + harness.get_by_label(paths::SERIAL); + harness.get_by_label(&mask_serial(SIMULATED_SERIAL)); + assert!(harness.query_by_label_contains(SIMULATED_SERIAL).is_none()); +} + +#[test] +fn failure_is_shown() { + let address = parse_address("fe80::2a36:38ff:fe12:3456%7", 45).unwrap(); + let mut speaker = SpeakerState::new(SpeakerKey::Address(address), address); + speaker.read_all_values(); + speaker.apply(Event::AllValuesFailed(RAW_FAILURE.to_owned()), 0.0); + let catalog = Catalog::embedded(); + let harness = Harness::new_ui(|ui| { + all_values::show(ui.ctx(), &mut speaker, catalog); + }); + harness.get_by_label(texts::ALL_VALUES_FAILED); + assert!(harness.query_by_label_contains("fe12:3456").is_none()); + assert!(harness + .query_by_label_contains("connection error") + .is_none()); +} From 7dc7e8b556a93ed87656ee22522b1d23fe211e53 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:38:31 +0200 Subject: [PATCH 033/460] Treat null limits as absent in the range column --- crates/app/src/all_values.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/crates/app/src/all_values.rs b/crates/app/src/all_values.rs index 7185705..14479e7 100644 --- a/crates/app/src/all_values.rs +++ b/crates/app/src/all_values.rs @@ -79,11 +79,12 @@ fn range_text(limits: Option<&Value>) -> String { let Some(limits) = limits else { return String::new(); }; + let given = |key: &str| limits.get(key).filter(|value| !value.is_null()); let mut parts = Vec::new(); - if let Some(units) = limits.get(UNITS_KEY).map(plain_text) { + if let Some(units) = given(UNITS_KEY).map(plain_text) { parts.push(units); } - let bounds = (limits.get(MINIMUM_KEY), limits.get(MAXIMUM_KEY)); + let bounds = (given(MINIMUM_KEY), given(MAXIMUM_KEY)); if bounds != (None, None) { let [minimum, maximum] = [bounds.0, bounds.1].map(|bound| bound.map(plain_text)); parts.push(format!( @@ -92,7 +93,7 @@ fn range_text(limits: Option<&Value>) -> String { maximum.unwrap_or_default() )); } - if let Some(options) = limits.get(OPTIONS_KEY) { + if let Some(options) = given(OPTIONS_KEY) { parts.push(options_text(options)); } parts.join(RANGE_SEPARATOR) @@ -186,6 +187,15 @@ mod tests { assert!(!one(report("device/restore", Ok(json!("")), None)).known); } + #[test] + fn null_limits_are_absent() { + let limits = json!({ "units": null, "min": null, "max": 120, "option": null }); + let row = one(report("a/b", Ok(json!(1)), Some(limits))); + assert_eq!(row.range, " … 120"); + let all_null = json!({ "units": null, "min": null, "max": null, "option": null }); + assert_eq!(one(report("a/b", Ok(json!(1)), Some(all_null))).range, ""); + } + #[test] fn range_joins_units_bounds_and_options() { let limits = json!({ "type": "number", "units": "dB", "min": 0, "max": 120 }); From 5cae9e961cb1d99ae114247db2e021171ce2bc7c Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:40:10 +0200 Subject: [PATCH 034/460] Build copy text on click, not per frame --- crates/app/src/all_values.rs | 34 ++++++++++++++++++------------ crates/app/src/views/all_values.rs | 8 +++---- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/crates/app/src/all_values.rs b/crates/app/src/all_values.rs index 14479e7..c51f264 100644 --- a/crates/app/src/all_values.rs +++ b/crates/app/src/all_values.rs @@ -22,20 +22,27 @@ const OPTIONS_KEY: &str = "option"; /// Leaves that name the device itself; shown masked like the serial on the card. const IDENTIFIER_PATHS: &[&str] = &[paths::SERIAL, paths::MAC_ADDRESSES]; -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Row { +#[derive(Clone, Debug, PartialEq)] +pub struct Row<'a> { + report: &'a LeafReport, pub path: String, /// Capped for the cell. pub value: String, /// Units, range and options from the leaf's limits. pub range: String, pub known: bool, - /// `path = value`, whole. - pub copy_text: String, +} + +impl Row<'_> { + /// `path = value`, whole; built on demand because only a click needs it. + pub fn copy_text(&self) -> String { + let value = value_text(self.report, &self.path); + format!("{}{COPY_SEPARATOR}{value}", self.path) + } } /// The reports whose path contains `filter`, ignoring case and surrounding blanks. -pub fn rows(reports: &[LeafReport], filter: &str, catalog: &Catalog) -> Vec { +pub fn rows<'a>(reports: &'a [LeafReport], filter: &str, catalog: &Catalog) -> Vec> { let wanted = filter.trim().to_lowercase(); reports .iter() @@ -44,14 +51,13 @@ pub fn rows(reports: &[LeafReport], filter: &str, catalog: &Catalog) -> Vec .collect() } -fn row(report: &LeafReport, catalog: &Catalog) -> Row { +fn row<'a>(report: &'a LeafReport, catalog: &Catalog) -> Row<'a> { let path = report.path.to_string(); - let full = value_text(report, &path); Row { - value: capped(&full), + report, + value: capped(&value_text(report, &path)), range: capped(&range_text(report.limits.as_ref())), known: paths::catalog_entry(catalog, &report.path).is_some(), - copy_text: format!("{path}{COPY_SEPARATOR}{full}"), path, } } @@ -134,8 +140,10 @@ mod tests { } } - fn one(report: LeafReport) -> Row { - rows(&[report], "", Catalog::embedded()).remove(0) + /// The report is leaked so the row may outlive the call; tests only. + fn one(report: LeafReport) -> Row<'static> { + let leaked: &'static [LeafReport] = Box::leak(Box::new([report])); + rows(leaked, "", Catalog::embedded()).remove(0) } #[test] @@ -156,7 +164,7 @@ mod tests { let row = one(report("device/name", Ok(json!(long)), None)); assert_eq!(row.value.chars().count(), VALUE_CHARACTER_LIMIT + 1); assert!(row.value.ends_with(ELLIPSIS)); - assert_eq!(row.copy_text, format!("device/name = {long}")); + assert_eq!(row.copy_text(), format!("device/name = {long}")); } #[test] @@ -171,7 +179,7 @@ mod tests { for path in IDENTIFIER_PATHS { let row = one(report(path, Ok(json!("SIM0001234")), None)); assert_eq!(row.value, "…234", "{path}"); - assert_eq!(row.copy_text, format!("{path} = …234")); + assert_eq!(row.copy_text(), format!("{path} = …234")); } } diff --git a/crates/app/src/views/all_values.rs b/crates/app/src/views/all_values.rs index 9e8aabe..eb4ce5c 100644 --- a/crates/app/src/views/all_values.rs +++ b/crates/app/src/views/all_values.rs @@ -74,7 +74,7 @@ fn contents( } } -fn table(ui: &mut Ui, shown: &[Row]) { +fn table(ui: &mut Ui, shown: &[Row<'_>]) { header(ui); let row_height = ui.spacing().interact_size.y; ScrollArea::vertical() @@ -95,7 +95,7 @@ fn header(ui: &mut Ui) { }); } -fn row_cells(ui: &mut Ui, row: &Row) { +fn row_cells(ui: &mut Ui, row: &Row<'_>) { ui.horizontal(|ui| { cell(ui, PATH_WIDTH, &row.path); cell(ui, VALUE_WIDTH, &row.value); @@ -107,12 +107,12 @@ fn row_cells(ui: &mut Ui, row: &Row) { }; cell(ui, KNOWN_WIDTH, known); if ui.button(texts::ALL_VALUES_COPY).clicked() { - ui.ctx().copy_text(row.copy_text.clone()); + ui.ctx().copy_text(row.copy_text()); } }); } -/// A label in a fixed-width column; what does not fit is cut, the full text is on hover. +/// A label in a fixed-width column; what does not fit is cut, the cell's text is on hover. fn cell(ui: &mut Ui, width: f32, text: &str) { let height = ui.spacing().interact_size.y; ui.add_sized([width, height], Label::new(text).truncate()) From 57d8d7015b4bae59378b3ad37dc54f09a8fec4f2 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:44:35 +0200 Subject: [PATCH 035/460] Add release build script --- .gitignore | 1 + build-release.ps1 | 106 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 build-release.ps1 diff --git a/.gitignore b/.gitignore index ea8c4bf..ecb817e 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +/release diff --git a/build-release.ps1 b/build-release.ps1 new file mode 100644 index 0000000..7696473 --- /dev/null +++ b/build-release.ps1 @@ -0,0 +1,106 @@ +<# +.SYNOPSIS + Builds a release binary and drops it into release\\. The version is the one in + the workspace Cargo.toml. A release folder that already holds files is refused unless + -Force is given. +#> +[CmdletBinding()] +param([switch] $Force) + +$ErrorActionPreference = 'Stop' +Set-Location $PSScriptRoot + +# The one list of what a release folder holds; anything else in it fails the build. +$releaseFiles = @('neumann-control.exe', 'LICENSE', 'THIRD-PARTY-LICENSES.html', 'SHA256SUMS') +# Only the version line of [workspace.package]: a plain match would also hit a dependency +# pinned with `version = "..."`. +$workspaceVersionLine = [regex]'(\[workspace\.package\][^\[]*?\nversion = ")([^"]+)(")' + +function Invoke-Step { + param([string] $Name, [scriptblock] $Command) + Write-Host "" + Write-Host "=== $Name" -ForegroundColor Cyan + $global:LASTEXITCODE = 0 + & $Command + if ($LASTEXITCODE -ne 0) { + Write-Host "" + Write-Host "FAILED: $Name" -ForegroundColor Red + exit 1 + } +} + +function Assert-Tool { + param([string] $Name, [string] $InstallHint) + if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { + Write-Host "FAILED: $Name is not installed. $InstallHint" -ForegroundColor Red + exit 1 + } +} + +function Assert-CleanTree { + $status = git status --porcelain + # A git that cannot run prints nothing, which would otherwise read as a clean tree. + if ($LASTEXITCODE -ne 0) { + Write-Host "FAILED: git status did not run; cannot prove the release maps to a commit" -ForegroundColor Red + exit 1 + } + if ($status) { + Write-Host "FAILED: working tree is dirty; a release must map to a commit" -ForegroundColor Red + exit 1 + } +} + +function Get-WorkspaceVersion { + $match = $workspaceVersionLine.Match([System.IO.File]::ReadAllText((Resolve-Path 'Cargo.toml'))) + if (-not $match.Success) { throw 'could not find the [workspace.package] version in Cargo.toml' } + $match.Groups[2].Value +} + +Assert-Tool 'cargo-about' 'Install with: cargo install cargo-about --locked' +Assert-CleanTree + +$version = Get-WorkspaceVersion + +# Checked before the gate: a rerun must not silently replace an exe already built. +$releaseDirectory = Join-Path 'release' $version +$holdsEarlierBuild = (Test-Path -LiteralPath $releaseDirectory) -and @(Get-ChildItem -LiteralPath $releaseDirectory -Force).Count -gt 0 +if ($holdsEarlierBuild -and -not $Force) { + Write-Host "FAILED: $releaseDirectory already holds files; pass -Force to overwrite them" -ForegroundColor Red + exit 1 +} + +Invoke-Step 'Quality gates' { & (Join-Path $PSScriptRoot 'check.ps1') } + +Invoke-Step 'Build release binary' { cargo build --release --locked -p neumann-control-app } + +New-Item -ItemType Directory -Force -Path $releaseDirectory | Out-Null +Copy-Item 'target\release\neumann-control.exe' $releaseDirectory +Copy-Item 'LICENSE' $releaseDirectory + +$licensesPath = Join-Path $releaseDirectory 'THIRD-PARTY-LICENSES.html' +Invoke-Step 'Generate third-party licenses' { + cargo about generate --workspace --locked --fail -c about.toml about.hbs -o $licensesPath +} + +$sumsPath = Join-Path $releaseDirectory 'SHA256SUMS' +$sumLines = $releaseFiles | + Where-Object { $_ -ne 'SHA256SUMS' } | + ForEach-Object { "$((Get-FileHash (Join-Path $releaseDirectory $_) -Algorithm SHA256).Hash.ToLower()) $_" } +# Plain LF and no byte order mark: `sha256sum -c` reads the list on any platform, +# while Set-Content would write CRLF and -Encoding utf8 a BOM on PowerShell 5.1. +[System.IO.File]::WriteAllText($sumsPath, (($sumLines -join "`n") + "`n"), (New-Object System.Text.UTF8Encoding $false)) + +# With -Force the folder is reused, so a stray file from earlier would ship unnoticed. +$present = @(Get-ChildItem -LiteralPath $releaseDirectory -Force | ForEach-Object { $_.Name }) +$unexpected = @($present | Where-Object { $releaseFiles -notcontains $_ }) +$missing = @($releaseFiles | Where-Object { $present -notcontains $_ }) +if ($unexpected -or $missing) { + $unexpected | ForEach-Object { Write-Host " unexpected: $_" } + $missing | ForEach-Object { Write-Host " missing: $_" } + Write-Host "FAILED: $releaseDirectory does not hold exactly $($releaseFiles -join ', ')" -ForegroundColor Red + exit 1 +} +Assert-CleanTree + +Write-Host "" +Write-Host "Release written to $releaseDirectory" -ForegroundColor Green From 718a701f835a3bd84794330cd200ada5c3618939 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:48:12 +0200 Subject: [PATCH 036/460] Resolve release folder from script root --- build-release.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/build-release.ps1 b/build-release.ps1 index 7696473..ca04a9d 100644 --- a/build-release.ps1 +++ b/build-release.ps1 @@ -62,7 +62,8 @@ Assert-CleanTree $version = Get-WorkspaceVersion # Checked before the gate: a rerun must not silently replace an exe already built. -$releaseDirectory = Join-Path 'release' $version +# Absolute: .NET file calls resolve relative paths against the process directory, not Set-Location. +$releaseDirectory = Join-Path $PSScriptRoot (Join-Path 'release' $version) $holdsEarlierBuild = (Test-Path -LiteralPath $releaseDirectory) -and @(Get-ChildItem -LiteralPath $releaseDirectory -Force).Count -gt 0 if ($holdsEarlierBuild -and -not $Force) { Write-Host "FAILED: $releaseDirectory already holds files; pass -Force to overwrite them" -ForegroundColor Red From 8d611616c4ad0a39e88ea1024e281b5abcc0a379 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:51:53 +0200 Subject: [PATCH 037/460] Document usage and limits, add 0.1.0 changelog --- CHANGELOG.md | 17 +++++++++ README.md | 104 ++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 119 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 701cd4c..9dc9e09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1,18 @@ +# Changelog + ## [Unreleased] + +## [0.1.0] - 2026-10-18 + +First version. + +### Added + +- Speaker discovery over mDNS and manual entry by IPv6 address, with the interface index for + link-local addresses. +- Speaker cards with an output level fader, input meters with clip lamps, mute and identify. +- A per-model settings area and a list of all values the speaker reports. +- Support for the KH 120 II and the KH 750 with writes; other models read-only. +- Portable settings and log next to the executable. +- A speaker simulator for both models used by the automated tests. +- `check.ps1` quality gates and `build-release.ps1` for the release folder. diff --git a/README.md b/README.md index ffec228..ad0063e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,105 @@ # neumann-control -A portable Windows app to find, read and control Neumann KH DSP speakers over Sennheiser SSC. +A portable Windows app to find, read and control Neumann KH DSP speakers over the +Sennheiser Sound Control protocol (SSC). The user interface is in German. -Status: in development. +This is an independent project. It is not affiliated with or endorsed by Neumann or Sennheiser. + +## Status + +Version 0.1.0. The protocol layer, the polling and the interface are covered by automated tests +against a built-in speaker simulator for the KH 120 II and the KH 750. The app has not yet been +verified against real speakers; the model support described below follows the protocol +knowledge the path catalog is built from and the simulator, not a hardware test. + +## Features + +- Finds speakers on the local network with mDNS (`_ssc._tcp`) when you press "Suchen" (a scan + takes 4 seconds), or adds a speaker by its IPv6 address with "+ Adresse". +- One card per speaker: connection state, output level fader (0-120 dB SPL, mouse wheel in + 0.5 dB steps), input level meters with clip lamps, "Stumm" (mute) and "Ident." (identify), + firmware version and masked serial number. +- "Mehr" opens every setting the catalog knows for that model (output, input, standby, device, + rear panel, metering), grouped; settings a model may write get a control, the rest are shown + greyed. +- "Alle Werte..." lists every value the speaker reports in its own schema, with a path filter and + copy buttons, including paths the catalog does not know yet. +- Switching the control mode to LOCAL asks for confirmation first, because a speaker in LOCAL + mode can refuse network control until it is reset at the device. +- A speaker that stops answering is shown as unreachable and reconnected every 10 seconds. + +## Supported models + +| Model | Read | Write | +|-------|------|-------| +| KH 120 II | yes | yes | +| KH 750 | yes | yes, except standby (see limits) | +| Other models in the catalog (for example KH 80, KH 150) | yes | no | + +The catalog is `crates/core/src/paths.toml`. A product that is not listed there for a path is +read-only, so models whose behaviour is unknown never receive a write. Settings that only exist +on some models (subwoofer outputs, logo brightness) appear only for those models. + +## Requirements + +- Windows 10 or 11 with a DirectX 12 capable graphics adapter. +- The computer and the speakers on the same layer 2 network segment (same switch or VLAN). + The speakers are reached over IPv6 link-local addresses, which are not routed. +- IPv6 enabled on the network adapter in use. +- To scan, the firewall must allow mDNS (UDP 5353) for the app. Adding a speaker by address + needs only the speaker's TCP port 45. + +## Usage + +1. Start `neumann-control.exe` and press "Suchen". Found speakers get a card. +2. If the scan finds nothing (for example mDNS is blocked), press "+ Adresse" and enter the + speaker's IPv6 address. A link-local address (`fe80::...`) needs the index of the network + adapter after a `%`, for example `fe80::1%7`. +3. Find the index in PowerShell with `Get-NetAdapter | Select-Object Name, ifIndex`, or with + `netsh interface ipv6 show interfaces` (column `Idx`). + +Manually added speakers are remembered and reconnected at every start. Speakers found by a scan +are listed for the current session only; they are not saved unless added by address. + +IPv4 addresses are not accepted: the speakers are reachable only over IPv6. + +## Files + +The app is portable and writes only next to the executable: + +| File | Content | +|------|---------| +| `settings.json` | Manually added speakers (address, port, serial) and the window position and size | +| `neumann-control.log` | Log of the current run, replaced at each start | + +Speaker addresses and serial numbers are masked in the log and in the interface. An unreadable +or malformed `settings.json` is renamed to `settings.json.bad` and replaced by defaults. To +remove a manually added speaker, delete its entry from `settings.json` while the app is closed. + +## Known limits + +- SSC has no authentication. Anyone on the network segment can read and change the settings of + every speaker; use the app only on a network you trust. +- A speaker in standby does not answer on the network, so it looks the same as a speaker that is + switched off ("nicht erreichbar (aus oder Standby)"). +- On the KH 750, auto-standby, standby time and standby level are shown but not written; writing + them is untested on that model. +- Models other than the KH 120 II and the KH 750 are read-only. +- Only IPv6 link-local addressing is supported; IPv4 is not. +- Windows only. + +## Building + +Needs Rust 1.95 or newer. + + cargo build --release --locked -p neumann-control-app + +`check.ps1` runs the quality gates (format, licence check, `cargo audit`, clippy, tests); it +needs `cargo-audit` and `cargo-about`. `build-release.ps1` runs them on a clean working tree and +writes `release\\` with the executable, `LICENSE`, `THIRD-PARTY-LICENSES.html` and +`SHA256SUMS`. + +## Licence + +MIT, see `LICENSE`. The licences of the bundled dependencies are in +`THIRD-PARTY-LICENSES.html`; the Inter font is under the SIL Open Font Licence. From 5f00d0842e04ef98b450a6c6781be4cb3be804ac Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:18:09 +0200 Subject: [PATCH 038/460] Show control mode read-only The device answers 405 to every control mode write on both models; the rear CONTROL switch sets it. The LOCAL confirmation had no remaining caller. --- README.md | 4 +- crates/app/src/paths.rs | 2 - crates/app/src/texts.rs | 2 - crates/app/src/views/details.rs | 3 -- crates/app/src/views/details_fields.rs | 38 ++------------- crates/app/src/writes.rs | 28 ----------- crates/app/tests/details.rs | 65 ++++++++------------------ crates/core/src/paths.toml | 8 ++-- crates/core/src/simulator/device.rs | 11 +++-- crates/core/tests/catalog.rs | 27 +++++++---- crates/core/tests/simulator.rs | 17 +++++++ 11 files changed, 71 insertions(+), 134 deletions(-) diff --git a/README.md b/README.md index ad0063e..03854d0 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,8 @@ knowledge the path catalog is built from and the simulator, not a hardware test. greyed. - "Alle Werte..." lists every value the speaker reports in its own schema, with a path filter and copy buttons, including paths the catalog does not know yet. -- Switching the control mode to LOCAL asks for confirmation first, because a speaker in LOCAL - mode can refuse network control until it is reset at the device. +- Shows the control mode (NETWORK or LOCAL), which the CONTROL switch on the back of the speaker + sets; it cannot be changed over the network. In LOCAL the speaker refuses every write. - A speaker that stops answering is shown as unreachable and reconnected every 10 seconds. ## Supported models diff --git a/crates/app/src/paths.rs b/crates/app/src/paths.rs index fb25eaf..8877781 100644 --- a/crates/app/src/paths.rs +++ b/crates/app/src/paths.rs @@ -14,7 +14,6 @@ pub const PRODUCT: &str = "device/identity/product"; pub const FIRMWARE_VERSION: &str = "device/identity/version"; pub const SERIAL: &str = "device/identity/serial"; pub const MAC_ADDRESSES: &str = "device/network/ether/macs"; -pub const CONTROL_MODE: &str = "ui/control_mode"; pub fn path(text: &str) -> SscPath { SscPath::parse(text).expect("constant path is valid") @@ -42,7 +41,6 @@ mod tests { PRODUCT, FIRMWARE_VERSION, SERIAL, - CONTROL_MODE, ] { assert!(catalog_entry(catalog, &path(text)).is_some(), "{text}"); } diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 0006c8e..a2960ce 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -27,8 +27,6 @@ pub const APPLY: &str = "Übernehmen"; pub const ON: &str = "an"; pub const OFF: &str = "aus"; pub const CELSIUS: &str = "°C"; -pub const SWITCH_MODE: &str = "Umschalten"; -pub const LOCAL_MODE_WARNING: &str = "Im Modus LOCAL kann die Netzwerksteuerung gesperrt sein, bis am Gerät zurückgestellt wird. Trotzdem umschalten?"; pub const ALL_VALUES_FILTER_LABEL: &str = "Pfad enthält"; pub const ALL_VALUES_RELOAD: &str = "Neu einlesen"; diff --git a/crates/app/src/views/details.rs b/crates/app/src/views/details.rs index e4483d5..0287b4a 100644 --- a/crates/app/src/views/details.rs +++ b/crates/app/src/views/details.rs @@ -26,7 +26,6 @@ const SHOWN_ON_CARD: &[&str] = &[ paths::SERIAL, ]; const OUTPUT_METER_HEIGHT: f32 = 60.0; -const CONFIRMATION_ID: &str = "confirmation"; /// Draws the area and returns the writes the user asked for that the write policy allowed. pub fn show(ui: &mut Ui, speaker: &mut SpeakerState, catalog: &Catalog) -> Vec { @@ -43,7 +42,6 @@ fn rows(ui: &mut Ui, speaker: &mut SpeakerState, catalog: &Catalog) -> Vec Vec { pub speaker: &'a mut SpeakerState, pub catalog: &'a Catalog, pub commands: &'a mut Vec, - /// Where a selection waiting for confirmation is kept between frames. - pub confirmation_id: Id, } impl Fields<'_> { @@ -162,10 +159,6 @@ impl Fields<'_> { path: entry.path.clone(), value: Value::String(option), }; - if intent.needs_confirmation() { - ui.data_mut(|data| data.insert_temp(self.confirmation_id, intent)); - return; - } self.send(ui, intent); } @@ -232,29 +225,4 @@ impl Fields<'_> { Err(refusal) => draft.refusal = Some(texts::describe_refusal(&refusal)), } } - - /// The dialog for a selection that needs confirmation; the write waits until "Umschalten". - pub fn confirmation(&mut self, ui: &Ui) { - let id = self.confirmation_id; - let Some(intent) = ui.data(|data| data.get_temp::(id)) else { - return; - }; - let modal = Modal::new(id).show(ui.ctx(), |ui| { - ui.set_max_width(CONFIRMATION_WIDTH); - ui.label(texts::LOCAL_MODE_WARNING); - ui.horizontal(|ui| { - let confirmed = ui.button(texts::SWITCH_MODE).clicked(); - let cancelled = ui.button(texts::CANCEL).clicked(); - (confirmed, cancelled) - }) - .inner - }); - let (confirmed, cancelled) = modal.inner; - if confirmed || cancelled || modal.should_close() { - ui.data_mut(|data| data.remove::(id)); - } - if confirmed { - self.send(ui, intent); - } - } } diff --git a/crates/app/src/writes.rs b/crates/app/src/writes.rs index 6ea01f1..77373af 100644 --- a/crates/app/src/writes.rs +++ b/crates/app/src/writes.rs @@ -27,9 +27,6 @@ impl Switch { } } -/// The control mode in which the speaker may stop listening to the network. -const LOCAL_CONTROL_MODE: &str = "LOCAL"; - /// What a control on the card asks for. #[derive(Clone, Debug, PartialEq)] pub enum Intent { @@ -49,15 +46,6 @@ impl Intent { Intent::Write { path, .. } => path.clone(), } } - - /// Whether the user has to confirm before the write goes out: in LOCAL control mode the - /// speaker may ignore the network until someone resets it at the device. - pub fn needs_confirmation(&self) -> bool { - let Intent::Write { path, value } = self else { - return false; - }; - *path == paths::path(paths::CONTROL_MODE) && value.as_str() == Some(LOCAL_CONTROL_MODE) - } } /// The value to write for a number the user entered: on the entry's step grid, and whole @@ -285,22 +273,6 @@ mod tests { assert_eq!(speaker.message.as_deref(), Some(expected.as_str())); } - #[test] - fn only_switching_to_local_control_needs_confirmation() { - let write = |value: &str| Intent::Write { - path: paths::path(paths::CONTROL_MODE), - value: Value::from(value), - }; - assert!(write("LOCAL").needs_confirmation()); - assert!(!write("NETWORK").needs_confirmation()); - assert!(!Intent::Level(80.0).needs_confirmation()); - let other = Intent::Write { - path: paths::path("audio/in/interface"), - value: Value::from("LOCAL"), - }; - assert!(!other.needs_confirmation()); - } - #[test] fn a_number_snaps_to_the_step_and_keeps_the_reported_type() { let delay = entry("audio/out/delay"); diff --git a/crates/app/tests/details.rs b/crates/app/tests/details.rs index 158fe3e..29cb0fb 100644 --- a/crates/app/tests/details.rs +++ b/crates/app/tests/details.rs @@ -1,4 +1,4 @@ -//! The card's expanded area: settings per model, displays, confirmation and number gestures. +//! The card's expanded area: settings per model, displays and number gestures. mod support; @@ -16,7 +16,6 @@ const STANDBY_LABEL: &str = "Auto-Standby"; const DELAY_LABEL: &str = "Verzögerung"; const CONTROL_MODE_LABEL: &str = "Steuermodus"; const NAME_PATH: &str = "/device/name"; -const CONTROL_MODE_PATH: &str = "/ui/control_mode"; const DELAY_PATH: &str = "/audio/out/delay"; const STANDBY_TIME_LABEL: &str = "Standby-Zeit"; const STANDBY_TIME_PATH: &str = "/device/standby/auto_standby_time"; @@ -116,20 +115,24 @@ fn name_field_shows_refusal() { } #[test] -fn local_control_mode_needs_confirmation() { - let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); - harness.get_by_label(CONTROL_MODE_LABEL).click(); - harness.run_steps(2); - harness.get_by_label("LOCAL").click(); - harness.run_steps(2); - barrier(&simulator); - assert!(sets_to(&simulator, CONTROL_MODE_PATH).is_empty()); - harness.get_by_label(texts::LOCAL_MODE_WARNING); - harness.get_by_label(texts::SWITCH_MODE).click(); - wait_until(&mut harness, "LOCAL on the device", |_| { - simulator.value("ui/control_mode") == Some(json!("LOCAL")) - }); - assert_eq!(sets_to(&simulator, CONTROL_MODE_PATH), vec![json!("LOCAL")]); +fn control_mode_is_shown_read_only_on_both_models() { + for model in [SimModel::Kh120Ii, SimModel::Kh750] { + let (_simulator, _directory, harness) = expanded_card(model); + let combo_boxes = harness.query_all_by_role(Role::ComboBox); + let labels: Vec<_> = combo_boxes + .map(|node| node.accesskit_node().label()) + .collect(); + let offered = labels + .iter() + .any(|label| label.as_deref() == Some(CONTROL_MODE_LABEL)); + assert!(!offered, "{model:?} offers a control mode selection"); + assert_eq!( + role_of(&harness, CONTROL_MODE_LABEL), + Role::Label, + "{model:?}" + ); + harness.get_by_label("NETWORK"); + } } #[test] @@ -350,36 +353,6 @@ fn kh750_output_meters_are_drawn() { assert_eq!(harness.query_all_by_label("Ausgangspegel").count(), 2); } -#[test] -fn cancelling_local_control_mode_sends_nothing() { - let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); - harness.get_by_label(CONTROL_MODE_LABEL).click(); - harness.run_steps(2); - harness.get_by_label("LOCAL").click(); - harness.run_steps(2); - harness.get_by_label(texts::CANCEL).click(); - harness.run_steps(IDLE_FRAMES); - barrier(&simulator); - assert!(sets_to(&simulator, CONTROL_MODE_PATH).is_empty()); - assert!(harness.query_by_label(texts::LOCAL_MODE_WARNING).is_none()); -} - -#[test] -fn network_control_mode_is_sent_without_confirmation() { - let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); - simulator.set_value("ui/control_mode", json!("LOCAL")); - wait_until(&mut harness, "LOCAL on the card", |harness| { - harness.get_by_label(CONTROL_MODE_LABEL).value().as_deref() == Some("LOCAL") - }); - harness.get_by_label(CONTROL_MODE_LABEL).click(); - harness.run_steps(2); - harness.get_by_label("NETWORK").click(); - wait_until(&mut harness, "NETWORK on the device", |_| { - simulator.value("ui/control_mode") == Some(json!("NETWORK")) - }); - assert!(harness.query_by_label(texts::LOCAL_MODE_WARNING).is_none()); -} - #[test] fn all_values_button_reads_the_whole_tree() { let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); diff --git a/crates/core/src/paths.toml b/crates/core/src/paths.toml index 7c77523..f991bcc 100644 --- a/crates/core/src/paths.toml +++ b/crates/core/src/paths.toml @@ -1,7 +1,9 @@ # Path catalog of neumann-control. # # This file mirrors custom_components/neumann_kh/const.py of neumann-connect (Home Assistant -# integration), synced from version 1.19.1. Model knowledge lives here and nowhere in code: +# integration), synced from version 1.19.1 plus its later change of `ui/control_mode` to a +# read-only sensor (the rear CONTROL switch sets it; a write answers 405 on both models). +# Model knowledge lives here and nowhere in code: # which model has a path (`class`), where it may be written (`writable_on`, exact product # strings as the device reports them) and how often it is polled (`poll`). A product that is # not listed in `writable_on` is read-only, so unmeasured models never get a write. @@ -283,11 +285,9 @@ writable_on = ["KH 120 II", "KH 750"] path = "ui/control_mode" label = "Steuermodus" group = "device" -kind = "select" +kind = "display" poll = "fast" class = "all" -options = ["NETWORK", "LOCAL"] -writable_on = ["KH 120 II", "KH 750"] [[entry]] path = "ui/logo/brightness" diff --git a/crates/core/src/simulator/device.rs b/crates/core/src/simulator/device.rs index e62067f..9db1780 100644 --- a/crates/core/src/simulator/device.rs +++ b/crates/core/src/simulator/device.rs @@ -72,8 +72,10 @@ const READ_ONLY_COMMON: &[&str] = &[ "warnings", ]; -/// Refused with 405 on the KH 120 II in the write tests. +/// Refused with 405 on the KH 120 II in the hardware write tests; `ui/control_mode` follows +/// the rear CONTROL switch. const READ_ONLY_KH_120_II: &[&str] = &[ + "ui/control_mode", "ui/input_gain", "ui/input_select", "ui/bass_gain", @@ -82,9 +84,12 @@ const READ_ONLY_KH_120_II: &[&str] = &[ "ui/output_level", ]; -/// Refused with 405 on the KH 750 in the write tests, plus the sensors the integration -/// models as read-only. `device/standby/enabled` is writable on the KH 120 II only. +/// Refused with 405 on the KH 750 in the hardware write tests (`ui/control_mode` and +/// `ui/auto_standby` follow rear switches), plus the sensors the integration models as +/// read-only. `device/standby/enabled` is writable on the KH 120 II only. const READ_ONLY_KH_750: &[&str] = &[ + "ui/control_mode", + "ui/auto_standby", "ui/bass_management", "ui/channel_b_input_mode", "ui/subwoofer_input_gain", diff --git a/crates/core/tests/catalog.rs b/crates/core/tests/catalog.rs index 91a437b..eb516b5 100644 --- a/crates/core/tests/catalog.rs +++ b/crates/core/tests/catalog.rs @@ -1,4 +1,4 @@ -use neumann_control_core::catalog::{Catalog, CatalogError, Conversion, Entry, ValueError}; +use neumann_control_core::catalog::{Catalog, CatalogError, Conversion, Entry, Kind, ValueError}; use neumann_control_core::ssc::wire::SscPath; use serde_json::json; @@ -145,17 +145,26 @@ fn name_longer_than_52_is_refused() { #[test] fn unknown_option_is_refused() { let catalog = Catalog::embedded(); - let mode = entry("ui/control_mode"); + let interface = entry("audio/in/interface"); assert_eq!( - mode.validate(&json!("REMOTE"), MONITOR, &catalog.classes), + interface.validate(&json!("REMOTE"), MONITOR, &catalog.classes), Err(ValueError::NotAnOption) ); assert_eq!( - mode.validate(&json!("LOCAL"), MONITOR, &catalog.classes), + interface.validate(&json!("DIGITAL ONLY"), MONITOR, &catalog.classes), Ok(()) ); } +#[test] +fn control_mode_is_read_only_on_every_model() { + let mode = entry("ui/control_mode"); + assert_eq!(mode.kind, Kind::Display); + for product in [MONITOR, SUBWOOFER] { + assert!(!mode.is_writable(product), "{product}"); + } +} + #[test] fn wrong_json_type_is_refused() { let catalog = Catalog::embedded(); @@ -221,16 +230,16 @@ fn select_without_options_is_an_error() { logo = [] [[entry]] - path = "ui/control_mode" - label = "Steuermodus" - group = "device" + path = "audio/in/interface" + label = "Eingangs-Interface" + group = "input" kind = "select" - poll = "fast" + poll = "slow" class = "all" "#; match Catalog::parse(text) { Err(CatalogError::Invalid { path: invalid, .. }) => { - assert_eq!(invalid, path("ui/control_mode")) + assert_eq!(invalid, path("audio/in/interface")) } other => panic!("expected Invalid, got {other:?}"), } diff --git a/crates/core/tests/simulator.rs b/crates/core/tests/simulator.rs index 6650d25..f9c7557 100644 --- a/crates/core/tests/simulator.rs +++ b/crates/core/tests/simulator.rs @@ -166,6 +166,23 @@ fn simulator_refuses_standby_enabled_set_on_kh750_only() { assert_eq!(kh120.value("device/standby/enabled"), Some(json!(false))); } +#[test] +fn control_mode_set_answers_405_on_both_models() { + for model in [SimModel::Kh120Ii, SimModel::Kh750] { + let simulator = Simulator::start(model); + let reply = Client::connect(&simulator).ask(&json!({"ui":{"control_mode":"NETWORK"}})); + assert_eq!(device_error(&reply).map(|error| error.code), Some(405)); + } +} + +#[test] +fn auto_standby_set_answers_405_on_kh750() { + let simulator = Simulator::start(SimModel::Kh750); + let reply = Client::connect(&simulator).ask(&json!({"ui":{"auto_standby":"OFF"}})); + assert_eq!(device_error(&reply).map(|error| error.code), Some(405)); + assert_eq!(simulator.value("ui/auto_standby"), Some(json!("ON"))); +} + #[test] fn valid_set_is_stored_and_echoed() { let simulator = start_kh120(); From 9efcc1d2ec3084f3c62f32c13fa718f83765af2f Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:22:57 +0200 Subject: [PATCH 039/460] Keep write messages until the next request The worker re-reads a path right after a rejection, which cleared the message before it was drawn. A 401 now says the rear switch is on LOCAL. --- crates/app/src/state.rs | 95 ++++++++++++++++++----- crates/app/src/texts.rs | 4 + crates/app/src/writes.rs | 36 ++++++++- crates/app/tests/write_messages.rs | 115 ++++++++++++++++++++++++++++ crates/core/src/simulator/device.rs | 18 +++++ crates/core/tests/simulator.rs | 15 ++++ 6 files changed, 260 insertions(+), 23 deletions(-) create mode 100644 crates/app/tests/write_messages.rs diff --git a/crates/app/src/state.rs b/crates/app/src/state.rs index 54fdb83..946e85c 100644 --- a/crates/app/src/state.rs +++ b/crates/app/src/state.rs @@ -22,6 +22,18 @@ pub enum SpeakerKey { Address(SocketAddrV6), } +/// What the card's message is about, which decides when it ends. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum MessageSource { + /// Connecting, unreachable, another device: replaced by the next connection event. + Connection, + /// A refused, rejected or failed write. It stays until the user's next write request on + /// this card (or a connection event): the worker re-reads a path right after a rejection + /// and polls it every few seconds, so ending it on a fresh value hid it at once; and the + /// texts do not name the path, so they are stale after any later request. + Write, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Status { Connecting, @@ -50,8 +62,7 @@ pub struct SpeakerState { pub absent: BTreeSet, pub status: Status, pub message: Option, - /// The path a write message is about; a fresh value for it clears the message. - message_path: Option, + message_source: MessageSource, pub expanded: bool, pub input_meters: Vec, pub output_meters: Vec, @@ -72,7 +83,7 @@ impl SpeakerState { absent: BTreeSet::new(), status: Status::Connecting, message: None, - message_path: None, + message_source: MessageSource::Connection, expanded: false, input_meters: Vec::new(), output_meters: Vec::new(), @@ -122,18 +133,18 @@ impl SpeakerState { } self.identity = Some(identity); self.status = Status::Connected; - self.set_message(None, None); + self.set_message(None, MessageSource::Connection); } // The masked reason is for the log; the card only shows the generic text. Event::Unreachable(_) => { self.status = Status::Unreachable; - self.set_message(Some(texts::UNREACHABLE.to_owned()), None); + self.set_message( + Some(texts::UNREACHABLE.to_owned()), + MessageSource::Connection, + ); self.reset_meters(); } Event::Value { path, value } => { - if self.message_path.as_ref() == Some(&path) { - self.set_message(None, None); - } if path == paths::path(paths::OUTPUT_LEVEL) { self.fader.confirmed(); } @@ -145,17 +156,17 @@ impl SpeakerState { self.values.remove(&path); self.absent.insert(path); } - Event::WriteRejected { path, error } => { + Event::WriteRejected { error, .. } => { let text = texts::describe_rejection(error.code); - self.set_message(Some(text), Some(path)); + self.set_message(Some(text), MessageSource::Write); } - Event::WriteFailed { path, .. } => { - self.set_message(Some(texts::WRITE_FAILED.to_owned()), Some(path)); + Event::WriteFailed { .. } => { + self.set_message(Some(texts::WRITE_FAILED.to_owned()), MessageSource::Write); } Event::SerialMismatch { found, .. } => { self.status = Status::Unreachable; let text = texts::serial_mismatch(&mask_serial(&found)); - self.set_message(Some(text), None); + self.set_message(Some(text), MessageSource::Connection); } Event::AllValues(reports) => self.finish_all_values(AllValuesState::Loaded(reports)), Event::AllValuesFailed(reason) => { @@ -183,9 +194,16 @@ impl SpeakerState { } } - pub(crate) fn set_message(&mut self, message: Option, path: Option) { + pub(crate) fn set_message(&mut self, message: Option, source: MessageSource) { self.message = message; - self.message_path = path; + self.message_source = source; + } + + /// A new write request makes the message about the previous one stale. + pub(crate) fn clear_write_message(&mut self) { + if self.message_source == MessageSource::Write { + self.set_message(None, MessageSource::Write); + } } fn reset_meters(&mut self) { @@ -373,7 +391,28 @@ mod tests { } #[test] - fn write_message_clears_when_the_path_reports_a_value() { + fn a_rejection_message_survives_the_reread_of_its_path() { + let mut speaker = state(); + speaker.apply( + Event::WriteRejected { + path: path("ui/bass_gain"), + error: DeviceError { + code: 405, + description: String::new(), + }, + }, + 0.0, + ); + speaker.apply(value_event("ui/bass_gain", json!(0.0)), 0.1); + speaker.apply(value_event("ui/bass_gain", json!(0.0)), 30.0); + assert_eq!( + speaker.message.as_deref(), + Some("Gerät lehnt ab: nur lesbar") + ); + } + + #[test] + fn a_failed_write_message_survives_values_of_its_path() { let mut speaker = state(); speaker.apply( Event::WriteFailed { @@ -382,11 +421,27 @@ mod tests { }, 0.0, ); - assert_eq!(speaker.message.as_deref(), Some(texts::WRITE_FAILED)); - speaker.apply(value_event("device/name", json!("left")), 1.0); - assert!(speaker.message.is_some()); speaker.apply(value_event("audio/out/level", json!(80.0)), 2.0); - assert_eq!(speaker.message, None); + assert_eq!(speaker.message.as_deref(), Some(texts::WRITE_FAILED)); + } + + #[test] + fn a_local_mode_rejection_names_the_rear_switch() { + let mut speaker = state(); + speaker.apply( + Event::WriteRejected { + path: path("audio/out/level"), + error: DeviceError { + code: 401, + description: "authorisation needed".to_owned(), + }, + }, + 0.0, + ); + assert_eq!( + speaker.message.as_deref(), + Some("Gerät steht auf LOCAL (Schalter hinten), Schreiben gesperrt") + ); } #[test] diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index a2960ce..0a07e29 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -52,6 +52,7 @@ pub const WRITE_FAILED: &str = "Schreiben fehlgeschlagen"; const SERIAL_MISMATCH_PREFIX: &str = "Anderes Gerät unter dieser Adresse (Seriennummer "; const SERIAL_MISMATCH_SUFFIX: &str = ")"; +const REJECTED_LOCAL_MODE_CODE: i64 = 401; const REJECTED_READ_ONLY_CODE: i64 = 405; const REJECTED_WRONG_VALUE_CODE: i64 = 406; @@ -121,6 +122,9 @@ pub fn describe_refusal(refusal: &Refusal) -> String { pub fn describe_rejection(code: i64) -> String { match code { + REJECTED_LOCAL_MODE_CODE => { + "Gerät steht auf LOCAL (Schalter hinten), Schreiben gesperrt".to_owned() + } REJECTED_READ_ONLY_CODE => "Gerät lehnt ab: nur lesbar".to_owned(), REJECTED_WRONG_VALUE_CODE => "Gerät lehnt ab: falscher Wert".to_owned(), other => format!("Gerät lehnt ab (Code {other})"), diff --git a/crates/app/src/writes.rs b/crates/app/src/writes.rs index 77373af..c34d678 100644 --- a/crates/app/src/writes.rs +++ b/crates/app/src/writes.rs @@ -9,7 +9,7 @@ use neumann_control_core::ssc::wire::SscPath; use serde_json::Value; use crate::paths; -use crate::state::{SpeakerState, Status}; +use crate::state::{MessageSource, SpeakerState, Status}; use crate::texts; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -105,11 +105,11 @@ impl SpeakerState { /// The command for `intent`, or `None` after putting the refusal on the card. /// A sent level is held on the fader until the speaker reports it (`now` is the UI clock). pub fn request(&mut self, intent: Intent, catalog: &Catalog, now: f64) -> Option { - let path = intent.path(); match self.try_request(intent, catalog, now) { Ok(command) => Some(command), Err(refusal) => { - self.set_message(Some(texts::describe_refusal(&refusal)), Some(path)); + let text = texts::describe_refusal(&refusal); + self.set_message(Some(text), MessageSource::Write); None } } @@ -122,6 +122,7 @@ impl SpeakerState { catalog: &Catalog, now: f64, ) -> Result { + self.clear_write_message(); let path = intent.path(); let checked = self .intended_value(&intent, &path) @@ -273,6 +274,35 @@ mod tests { assert_eq!(speaker.message.as_deref(), Some(expected.as_str())); } + #[test] + fn a_refusal_stays_through_polls_until_the_next_request() { + let mut speaker = speaker("KH 120 II"); + speaker.request(Intent::Level(130.0), Catalog::embedded(), 0.0); + let level = Event::Value { + path: paths::path(paths::OUTPUT_LEVEL), + value: Value::from(80.0), + }; + speaker.apply(level, 2.0); + assert!(speaker.message.is_some(), "a poll ended the refusal"); + let command = speaker.request(Intent::Toggle(Switch::Mute), Catalog::embedded(), 3.0); + assert_eq!(command, set(paths::MUTE, Value::Bool(true))); + assert_eq!(speaker.message, None); + } + + #[test] + fn a_request_keeps_the_unreachable_message() { + let mut speaker = speaker("KH 120 II"); + speaker.apply(Event::Unreachable("masked".to_owned()), 1.0); + let intent = Intent::Write { + path: paths::path("device/name"), + value: Value::from("left"), + }; + assert!(speaker + .try_request(intent, Catalog::embedded(), 2.0) + .is_err()); + assert_eq!(speaker.message.as_deref(), Some(texts::UNREACHABLE)); + } + #[test] fn a_number_snaps_to_the_step_and_keeps_the_reported_type() { let delay = entry("audio/out/delay"); diff --git a/crates/app/tests/write_messages.rs b/crates/app/tests/write_messages.rs new file mode 100644 index 0000000..1b78de9 --- /dev/null +++ b/crates/app/tests/write_messages.rs @@ -0,0 +1,115 @@ +//! The card's write messages across the seam worker -> events -> speaker state: a rejection +//! must still be on the card after everything the worker sends in reply has been applied. + +use std::sync::mpsc::{Receiver, RecvTimeoutError}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use neumann_control_app::state::{SpeakerKey, SpeakerState, Status}; +use neumann_control_app::writes::Intent; +use neumann_control_core::catalog::Catalog; +use neumann_control_core::simulator::{SimModel, Simulator}; +use neumann_control_core::speaker::{self, Command, Event, SpeakerHandle, Target, Timing}; +use neumann_control_core::ssc::wire::SscPath; +use serde_json::json; + +const EVENT_DEADLINE: Duration = Duration::from_secs(5); +/// No event for this long means the worker has answered everything it was asked. +const QUIET_PERIOD: Duration = Duration::from_millis(300); +/// Polls far apart, so only the reply to the write and its re-read follow it. +const LONG_INTERVAL: Duration = Duration::from_secs(60); +const READ_ONLY_TEXT: &str = "Gerät lehnt ab: nur lesbar"; +const LOCAL_MODE_TEXT: &str = "Gerät steht auf LOCAL (Schalter hinten), Schreiben gesperrt"; + +fn quiet_timing() -> Timing { + Timing { + meter: LONG_INTERVAL, + fast: LONG_INTERVAL, + slow: LONG_INTERVAL, + reconnect: LONG_INTERVAL, + } +} + +/// A worker for the simulator and the card state it feeds, once the worker is connected and +/// has read the values it reads on connecting. +fn connected(simulator: &Simulator) -> (SpeakerHandle, Receiver, SpeakerState) { + let address = simulator.address(); + let target = Target { + address, + expected_serial: None, + }; + let (handle, events) = speaker::spawn(target, quiet_timing(), Arc::new(|| {})); + let mut state = SpeakerState::new(SpeakerKey::Address(address), address); + apply_until(&events, &mut state, |event| { + matches!(event, Event::Connected(_)) + }); + apply_until_quiet(&events, &mut state); + assert_eq!(state.status, Status::Connected); + (handle, events, state) +} + +fn apply_until(events: &Receiver, state: &mut SpeakerState, done: impl Fn(&Event) -> bool) { + let end = Instant::now() + EVENT_DEADLINE; + loop { + let remaining = end.saturating_duration_since(Instant::now()); + let event = events.recv_timeout(remaining).expect("the awaited event"); + let finished = done(&event); + state.apply(event, 0.0); + if finished { + return; + } + } +} + +fn apply_until_quiet(events: &Receiver, state: &mut SpeakerState) { + loop { + match events.recv_timeout(QUIET_PERIOD) { + Ok(event) => state.apply(event, 0.0), + Err(RecvTimeoutError::Timeout) => return, + Err(RecvTimeoutError::Disconnected) => panic!("the worker ended"), + } + } +} + +/// Applies the rejection of `path`, the worker's re-read of it and anything after that. +fn apply_reply_to_write(events: &Receiver, state: &mut SpeakerState, path: &SscPath) { + apply_until( + events, + state, + |event| matches!(event, Event::WriteRejected { path: rejected, .. } if rejected == path), + ); + apply_until( + events, + state, + |event| matches!(event, Event::Value { path: read, .. } if read == path), + ); + apply_until_quiet(events, state); +} + +#[test] +fn a_rejected_write_keeps_its_message_after_the_reread() { + let simulator = Simulator::start(SimModel::Kh120Ii); + let (handle, events, mut state) = connected(&simulator); + let path = SscPath::parse("ui/bass_gain").unwrap(); + handle.send(Command::Set { + path: path.clone(), + value: json!(2.0), + }); + apply_reply_to_write(&events, &mut state, &path); + assert_eq!(state.message.as_deref(), Some(READ_ONLY_TEXT)); +} + +#[test] +fn a_write_in_local_mode_shows_the_local_mode_message() { + let simulator = Simulator::start(SimModel::Kh120Ii); + let (handle, events, mut state) = connected(&simulator); + simulator.set_value("ui/control_mode", json!("LOCAL")); + let command = state + .request(Intent::Level(90.0), Catalog::embedded(), 0.0) + .expect("the write policy allows the level"); + handle.send(command); + let path = SscPath::parse("audio/out/level").unwrap(); + apply_reply_to_write(&events, &mut state, &path); + assert_eq!(state.message.as_deref(), Some(LOCAL_MODE_TEXT)); + assert_eq!(simulator.value("audio/out/level"), Some(json!(100.0))); +} diff --git a/crates/core/src/simulator/device.rs b/crates/core/src/simulator/device.rs index 9db1780..ba0c9ae 100644 --- a/crates/core/src/simulator/device.rs +++ b/crates/core/src/simulator/device.rs @@ -39,6 +39,10 @@ const NOT_FOUND: ErrorKind = ErrorKind { code: 404, description: "address not found", }; +const NOT_AUTHORISED: ErrorKind = ErrorKind { + code: 401, + description: "authorisation needed", +}; const NOT_ALLOWED: ErrorKind = ErrorKind { code: 405, description: "method not allowed", @@ -52,6 +56,10 @@ const PARAMETER_NOT_FOUND: ErrorKind = ErrorKind { description: "parameter address not found", }; +/// While the rear CONTROL switch is on LOCAL, every set answers 401 and gets still work. +const CONTROL_MODE_PATH: &str = "ui/control_mode"; +const LOCAL_CONTROL_MODE: &str = "LOCAL"; + const KH_120_II_TREE: &str = include_str!("kh120ii.json"); const KH_750_TREE: &str = include_str!("kh750.json"); @@ -251,6 +259,9 @@ impl SimulatedDevice { if value.is_null() { return Ok(nest(path, current)); } + if self.is_in_local_mode() { + return Err(error_entry(path, &NOT_AUTHORISED)); + } if self.is_read_only(path) { return Err(error_entry(path, &NOT_ALLOWED)); } @@ -273,6 +284,13 @@ impl SimulatedDevice { path.len() } + fn is_in_local_mode(&self) -> bool { + self.value(CONTROL_MODE_PATH) + .as_ref() + .and_then(Value::as_str) + == Some(LOCAL_CONTROL_MODE) + } + fn is_read_only(&self, path: &[String]) -> bool { let joined = path.join(PATH_SEPARATOR); READ_ONLY_COMMON.contains(&joined.as_str()) diff --git a/crates/core/tests/simulator.rs b/crates/core/tests/simulator.rs index f9c7557..77ac050 100644 --- a/crates/core/tests/simulator.rs +++ b/crates/core/tests/simulator.rs @@ -183,6 +183,21 @@ fn auto_standby_set_answers_405_on_kh750() { assert_eq!(simulator.value("ui/auto_standby"), Some(json!("ON"))); } +#[test] +fn local_control_mode_refuses_every_set_with_401() { + let simulator = start_kh120(); + simulator.set_value("ui/control_mode", json!("LOCAL")); + let mut client = Client::connect(&simulator); + let refused = client.ask(&json!({"audio":{"out":{"level":100.0}}})); + let error = device_error(&refused).expect("an error reply"); + assert_eq!( + (error.code, error.description.as_str()), + (401, "authorisation needed") + ); + let read = client.ask(&json!({"audio":{"out":{"level":null}}})); + assert_eq!(read, json!({"audio":{"out":{"level":100.0}}})); +} + #[test] fn valid_set_is_stored_and_echoed() { let simulator = start_kh120(); From 951b4683e9bb55263596f3e33330cb3f5f032f67 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:25:07 +0200 Subject: [PATCH 040/460] Mask the serial in announced speaker names --- crates/core/examples/scan.rs | 3 ++- crates/core/src/discovery.rs | 31 +++++++++++++++++++++++++++++-- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/crates/core/examples/scan.rs b/crates/core/examples/scan.rs index 23ff96b..36b2ef3 100644 --- a/crates/core/examples/scan.rs +++ b/crates/core/examples/scan.rs @@ -1,4 +1,5 @@ -//! Prints the speakers found on the LAN, with serial and address masked. +//! Prints the speakers found on the LAN, with serial and address masked (discovery already +//! masks the serial inside the announced name). use neumann_control_core::discovery::{scan, SCAN_DURATION}; use neumann_control_core::mask::{mask_host, mask_serial}; diff --git a/crates/core/src/discovery.rs b/crates/core/src/discovery.rs index 70d0fcd..fe2fe02 100644 --- a/crates/core/src/discovery.rs +++ b/crates/core/src/discovery.rs @@ -7,6 +7,7 @@ use std::time::{Duration, Instant}; use mdns_sd::{IfKind, ScopedIp, ServiceDaemon, ServiceEvent}; use crate::address::is_link_local; +use crate::mask::mask_serial; pub const SCAN_DURATION: Duration = Duration::from_secs(4); pub const MAX_SERVICES: usize = 32; @@ -18,6 +19,7 @@ const NO_SCOPE: u32 = 0; #[derive(Clone, Debug, PartialEq, Eq)] pub struct Found { + /// The announced instance name with the serial masked; speakers put their serial in it. pub name: String, pub product: Option, pub serial: Option, @@ -100,10 +102,15 @@ fn found_from_parts( product: Option<&str>, serial: Option<&str>, ) -> Option { + let serial = txt_value(serial); + let mut name = instance_name(fullname, service_type).to_owned(); + if let Some(serial) = &serial { + name = name.replace(serial.as_str(), &mask_serial(serial)); + } Some(Found { - name: capped(instance_name(fullname, service_type)), + name: capped(&name), product: txt_value(product), - serial: txt_value(serial), + serial, address: pick_address(addresses, port)?, }) } @@ -224,6 +231,26 @@ mod tests { assert_eq!(found.product, None); } + #[test] + fn the_serial_in_the_instance_name_is_masked() { + let address: Ipv6Addr = "2001:db8::1".parse().unwrap(); + let found = found_from_parts( + "KH 120 II SIM0001234._ssc._tcp.local.", + SERVICE_TYPE, + 45, + &[(address, 0)], + Some("KH 120 II"), + Some("SIM0001234"), + ) + .unwrap(); + assert!(!found.name.contains("SIM0001234"), "{}", found.name); + assert_eq!( + found.name, + format!("KH 120 II {}", mask_serial("SIM0001234")) + ); + assert_eq!(found.serial.as_deref(), Some("SIM0001234")); + } + #[test] fn control_characters_are_removed_from_txt() { assert_eq!(capped("KH\u{0}1\n20\u{1b}"), "KH120"); From dcc41c9408daaa7bacdc67c74407e23b5c0207bb Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:29:20 +0200 Subject: [PATCH 041/460] Write only by the reported product The announced mDNS model is unchecked text; the worker already polls by the reported product, so the write policy and the settings rows follow it. --- crates/app/src/state.rs | 10 +++++++++- crates/app/src/views/details.rs | 4 ++-- crates/app/src/views/details_fields.rs | 2 +- crates/app/src/writes.rs | 22 +++++++++++++++++++-- crates/app/tests/details.rs | 27 ++++++++++++++++++++++++++ 5 files changed, 59 insertions(+), 6 deletions(-) diff --git a/crates/app/src/state.rs b/crates/app/src/state.rs index 946e85c..c8fb26a 100644 --- a/crates/app/src/state.rs +++ b/crates/app/src/state.rs @@ -109,7 +109,15 @@ impl SpeakerState { .unwrap_or_else(|| mask_host(&self.address)) } - /// The reported product, else the announced one. + /// The product the speaker itself reported. Writes and the catalog rows use only this, + /// like the worker's polling: the announced model is unchecked mDNS text. + pub fn product(&self) -> &str { + self.identity + .as_ref() + .map_or("", |identity| identity.product.as_str()) + } + + /// The reported product, else the announced one; for showing only. pub fn model(&self) -> Option<&str> { let reported = self .identity diff --git a/crates/app/src/views/details.rs b/crates/app/src/views/details.rs index 0287b4a..5d36b5f 100644 --- a/crates/app/src/views/details.rs +++ b/crates/app/src/views/details.rs @@ -35,7 +35,7 @@ pub fn show(ui: &mut Ui, speaker: &mut SpeakerState, catalog: &Catalog) -> Vec Vec { let tokens = Tokens::current(ui.ctx()); - let product = speaker.model().unwrap_or_default().to_owned(); + let product = speaker.product().to_owned(); let groups = groups(catalog.entries_for(&product), speaker); let mut commands = Vec::new(); let mut fields = Fields { @@ -83,7 +83,7 @@ fn is_shown(entry: &Entry, speaker: &SpeakerState) -> bool { } fn row(ui: &mut Ui, fields: &mut Fields, entry: &Entry, tokens: &Tokens) { - let product = fields.speaker.model().unwrap_or_default(); + let product = fields.speaker.product(); if !entry.is_writable(product) { display(ui, fields.speaker, entry, tokens); return; diff --git a/crates/app/src/views/details_fields.rs b/crates/app/src/views/details_fields.rs index 89e4329..5bf648b 100644 --- a/crates/app/src/views/details_fields.rs +++ b/crates/app/src/views/details_fields.rs @@ -60,7 +60,7 @@ impl Fields<'_> { let reported = self.speaker.values.get(&entry.path).cloned(); let device = reported.as_ref().and_then(Value::as_f64); let enabled = self.enabled(entry, device.is_some()); - let product = self.speaker.model().unwrap_or_default(); + let product = self.speaker.product(); let min = entry.min.unwrap_or(f64::NEG_INFINITY); let max = entry .max_for(product, &self.catalog.classes) diff --git a/crates/app/src/writes.rs b/crates/app/src/writes.rs index c34d678..b563980 100644 --- a/crates/app/src/writes.rs +++ b/crates/app/src/writes.rs @@ -76,7 +76,7 @@ pub enum Refusal { /// Whether a control for `path` may be offered at all. pub fn can_write(speaker: &SpeakerState, catalog: &Catalog, path: &SscPath) -> bool { - let product = speaker.model().unwrap_or_default(); + let product = speaker.product(); let writable = paths::catalog_entry(catalog, path).is_some_and(|entry| entry.is_writable(product)); speaker.status == Status::Connected && writable @@ -92,7 +92,7 @@ pub fn check( if speaker.status != Status::Connected { return Err(Refusal::NotConnected); } - let product = speaker.model().unwrap_or_default(); + let product = speaker.product(); let entry = paths::catalog_entry(catalog, path) .filter(|entry| entry.is_writable(product)) .ok_or(Refusal::NotWritable)?; @@ -151,6 +151,7 @@ impl SpeakerState { mod tests { use super::*; use neumann_control_core::address::parse_address; + use neumann_control_core::discovery::Found; use neumann_control_core::speaker::{Event, Identity}; use crate::state::SpeakerKey; @@ -303,6 +304,23 @@ mod tests { assert_eq!(speaker.message.as_deref(), Some(texts::UNREACHABLE)); } + #[test] + fn the_announced_model_does_not_allow_writes() { + let mut speaker = speaker(""); + speaker.discovered = Some(Found { + name: String::new(), + product: Some("KH 120 II".to_owned()), + serial: None, + address: parse_address(ADDRESS_TEXT, 45).unwrap(), + }); + let level = paths::path(paths::OUTPUT_LEVEL); + assert!(!can_write(&speaker, Catalog::embedded(), &level)); + assert_eq!( + check(&speaker, Catalog::embedded(), &level, &Value::from(80.0)), + Err(Refusal::NotWritable) + ); + } + #[test] fn a_number_snaps_to_the_step_and_keeps_the_reported_type() { let delay = entry("audio/out/delay"); diff --git a/crates/app/tests/details.rs b/crates/app/tests/details.rs index 29cb0fb..b4f4311 100644 --- a/crates/app/tests/details.rs +++ b/crates/app/tests/details.rs @@ -8,6 +8,7 @@ use egui_kittest::Harness; use neumann_control_app::app::NeumannApp; use neumann_control_app::texts; use neumann_control_core::catalog::ValueError; +use neumann_control_core::discovery::Found; use neumann_control_core::simulator::{SimModel, Simulator}; use serde_json::{json, Value}; use support::*; @@ -15,6 +16,7 @@ use support::*; const STANDBY_LABEL: &str = "Auto-Standby"; const DELAY_LABEL: &str = "Verzögerung"; const CONTROL_MODE_LABEL: &str = "Steuermodus"; +const LOGO_LABEL: &str = "Logo-Helligkeit"; const NAME_PATH: &str = "/device/name"; const DELAY_PATH: &str = "/audio/out/delay"; const STANDBY_TIME_LABEL: &str = "Standby-Zeit"; @@ -135,6 +137,31 @@ fn control_mode_is_shown_read_only_on_both_models() { } } +#[test] +fn an_announced_model_gives_no_write_controls_or_model_rows() { + let simulator = Simulator::start(SimModel::Kh120Ii); + simulator.set_value("device/identity/product", json!("")); + let directory = tempfile::tempdir().unwrap(); + let path = settings_file(directory.path(), Vec::new()); + let found = Found { + name: String::new(), + product: Some("KH 120 II".to_owned()), + serial: Some(SIMULATED_SERIAL.to_owned()), + address: simulator.address(), + }; + let mut harness = harness(path, vec![found]); + harness.set_size(EXPANDED_WINDOW_SIZE.into()); + harness.get_by_label(texts::SCAN).click(); + wait_for_level(&mut harness, 100.0); + harness.get_by_label(texts::MORE).click(); + wait_until(&mut harness, "the expanded area", |harness| { + harness.query_by_label(texts::ALL_VALUES).is_some() + }); + harness.run_steps(1); + assert_eq!(role_of(&harness, STANDBY_LABEL), Role::Label); + assert!(harness.query_by_label(LOGO_LABEL).is_none()); +} + #[test] fn number_field_sends_once_per_gesture() { let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); From 9959d7f544a013f1dc5db5103e868d92b6d8978f Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:29:32 +0200 Subject: [PATCH 042/460] Note the speaker's connection limit --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 03854d0..5c41da1 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,8 @@ remove a manually added speaker, delete its entry from `settings.json` while the every speaker; use the app only on a network you trust. - A speaker in standby does not answer on the network, so it looks the same as a speaker that is switched off ("nicht erreichbar (aus oder Standby)"). +- A speaker accepts only a few TCP connections at a time (about four). With more clients + connected (vendor app, home automation), it can look unreachable as well. - On the KH 750, auto-standby, standby time and standby level are shown but not written; writing them is untested on that model. - Models other than the KH 120 II and the KH 750 are read-only. From 302947039e2f0472a65c9ca299f2f2406e7ade17 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:30:41 +0200 Subject: [PATCH 043/460] Correct the texts module doc --- crates/app/src/texts.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 0a07e29..b01ea24 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -1,4 +1,6 @@ -//! German UI strings; the only place user-visible text lives. +//! The app's German UI strings. Not here: group titles (`catalog::Group::title`), entry labels +//! and units (`paths.toml`), and the separators that format device values (`all_values`, +//! `display`). use neumann_control_core::address::AddressError; use neumann_control_core::catalog::ValueError; From a2ca558b421e2df566930478f7677d2e7007e868 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:36:47 +0200 Subject: [PATCH 044/460] Check catalog writes against classes and ranges --- crates/core/src/catalog.rs | 18 +++++++++++---- crates/core/tests/catalog.rs | 44 ++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/crates/core/src/catalog.rs b/crates/core/src/catalog.rs index 183b25a..9146ee6 100644 --- a/crates/core/src/catalog.rs +++ b/crates/core/src/catalog.rs @@ -298,7 +298,7 @@ impl Catalog { if !seen.insert(entry.path.clone()) { return Err(CatalogError::DuplicatePath(entry.path.clone())); } - check_entry(entry)?; + check_entry(entry, &file.models)?; } Ok(Catalog { entries: file.entry, @@ -314,7 +314,7 @@ impl Catalog { } } -fn check_entry(entry: &Entry) -> Result<(), CatalogError> { +fn check_entry(entry: &Entry, classes: &ModelClasses) -> Result<(), CatalogError> { let invalid = |reason| CatalogError::Invalid { path: entry.path.clone(), reason, @@ -322,15 +322,23 @@ fn check_entry(entry: &Entry) -> Result<(), CatalogError> { if !entry.kind.can_be_written() && !entry.writable_on.is_empty() { return Err(invalid("read-only kind lists writable models")); } + let writable_without_path = entry + .writable_on + .iter() + .any(|product| !classes.contains(entry.class, product)); + if writable_without_path { + return Err(invalid("writable on a model outside its class")); + } if entry.kind == Kind::Select && entry.options.is_empty() { return Err(invalid("select without options")); } if entry.kind == Kind::Text && entry.max_length.is_none() { return Err(invalid("text without max_length")); } - let reversed = matches!((entry.min, entry.max), (Some(min), Some(max)) if min > max); - if reversed { - return Err(invalid("min above max")); + let empty_range = + |max: Option| matches!((entry.min, max), (Some(min), Some(max)) if min >= max); + if empty_range(entry.max) || empty_range(entry.max_subwoofer) { + return Err(invalid("min not below max")); } Ok(()) } diff --git a/crates/core/tests/catalog.rs b/crates/core/tests/catalog.rs index eb516b5..2760216 100644 --- a/crates/core/tests/catalog.rs +++ b/crates/core/tests/catalog.rs @@ -258,3 +258,47 @@ fn delay_must_be_whole_steps() { Ok(()) ); } + +/// The reason `Catalog::parse` gives for the one entry in `entry`, a TOML table body. +fn invalid_reason(entry: &str) -> Option<&'static str> { + let text = format!("[models]\nsubwoofer = [\"KH 750\"]\nlogo = []\n\n[[entry]]\n{entry}"); + match Catalog::parse(&text) { + Err(CatalogError::Invalid { reason, .. }) => Some(reason), + Err(other) => panic!("expected Invalid, got {other:?}"), + Ok(_) => None, + } +} + +const SUBWOOFER_OUTPUT: &str = r#" + path = "audio/out1/mute" + label = "Stumm" + group = "output1" + kind = "switch" + poll = "fast" + class = "subwoofer" +"#; + +const LEVEL: &str = r#" + path = "audio/out/level" + label = "Pegel" + group = "output" + kind = "number" + poll = "fast" + class = "all" +"#; + +#[test] +fn writable_on_a_product_outside_the_class_is_an_error() { + assert!(invalid_reason(&format!("{SUBWOOFER_OUTPUT}writable_on = [\"KH 120 II\"]")).is_some()); + assert_eq!( + invalid_reason(&format!("{SUBWOOFER_OUTPUT}writable_on = [\"KH 750\"]")), + None + ); +} + +#[test] +fn an_empty_number_range_is_an_error() { + assert!(invalid_reason(&format!("{LEVEL}min = 5\nmax = 5")).is_some()); + assert!(invalid_reason(&format!("{LEVEL}min = 5\nmax = 10\nmax_subwoofer = 5")).is_some()); + assert_eq!(invalid_reason(&format!("{LEVEL}min = 0\nmax = 120")), None); +} From 7d3cb3a8696141dad60ab5309796617d2a7298bc Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:51:59 +0200 Subject: [PATCH 045/460] Wait for the connect-time read in window tests Tests acted on paths read after the level; a 5 ms reply delay made them fail. The simulator now keeps that delay so the race stays visible. --- crates/app/tests/details.rs | 1 + crates/app/tests/support/mod.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/crates/app/tests/details.rs b/crates/app/tests/details.rs index b4f4311..bb9109b 100644 --- a/crates/app/tests/details.rs +++ b/crates/app/tests/details.rs @@ -153,6 +153,7 @@ fn an_announced_model_gives_no_write_controls_or_model_rows() { harness.set_size(EXPANDED_WINDOW_SIZE.into()); harness.get_by_label(texts::SCAN).click(); wait_for_level(&mut harness, 100.0); + wait_for_initial_read(&mut harness, &simulator); harness.get_by_label(texts::MORE).click(); wait_until(&mut harness, "the expanded area", |harness| { harness.query_by_label(texts::ALL_VALUES).is_some() diff --git a/crates/app/tests/support/mod.rs b/crates/app/tests/support/mod.rs index 3cad1e8..7ae31df 100644 --- a/crates/app/tests/support/mod.rs +++ b/crates/app/tests/support/mod.rs @@ -15,6 +15,7 @@ use egui_kittest::Harness; use neumann_control_app::app::NeumannApp; use neumann_control_app::spawner::{ScanResult, SpeakerSpawner}; use neumann_control_app::texts; +use neumann_control_core::catalog::Catalog; use neumann_control_core::discovery::Found; use neumann_control_core::settings::{self, ManualSpeaker, Settings, SETTINGS_FILE}; use neumann_control_core::simulator::{SimModel, Simulator}; @@ -29,6 +30,10 @@ pub const WINDOW_SIZE: [f32; 2] = [900.0, 700.0]; /// Tall enough for a card with "Mehr" open, so every control in it can be clicked. pub const EXPANDED_WINDOW_SIZE: [f32; 2] = [900.0, 1600.0]; pub const LOOPBACK: &str = "::1"; +const PRODUCT_PATH: &str = "device/identity/product"; +/// Real speakers take tens of milliseconds per reply; an instant simulator hid tests that +/// acted before the connect-time read had arrived. +const REPLY_DELAY: Duration = Duration::from_millis(5); pub struct SimulatorSpawner { pub found: Vec, @@ -137,13 +142,37 @@ pub fn connected_card( model: SimModel, ) -> (Simulator, tempfile::TempDir, Harness<'static, NeumannApp>) { let simulator = Simulator::start(model); + simulator.set_reply_delay(REPLY_DELAY); let directory = tempfile::tempdir().unwrap(); let path = settings_file(directory.path(), vec![manual_for(&simulator)]); let mut harness = harness(path, Vec::new()); wait_for_level(&mut harness, 100.0); + wait_for_initial_read(&mut harness, &simulator); (simulator, directory, harness) } +/// Steps frames until the window holds every value the worker reads on connecting, which is +/// one read per catalog path of the model, in catalog order. The worker sends its next request +/// only after handing the previous reply to the window, so a request after the read of the +/// last path means all those values are in the channel; one more frame applies them. +pub fn wait_for_initial_read(harness: &mut Harness<'static, NeumannApp>, simulator: &Simulator) { + let product = simulator + .value(PRODUCT_PATH) + .and_then(|product| product.as_str().map(str::to_owned)) + .expect("the simulator reports a product"); + let entries = Catalog::embedded().entries_for(&product); + let last = entries.last().expect("the catalog has paths for the model"); + let pointer = format!("/{}", last.path); + wait_until(harness, "the initial read", |_| { + let requests = simulator.requests(); + let last_read = requests + .iter() + .position(|request| request.pointer(&pointer).is_some_and(Value::is_null)); + last_read.is_some_and(|index| requests.len() > index + 1) + }); + harness.step(); +} + pub fn readouts(harness: &Harness<'static, NeumannApp>) -> usize { harness.query_all_by_label(&readout(100.0)).count() } From c022915629d6fb535b83f6a6c09276d27ef62a7d Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:53:27 +0200 Subject: [PATCH 046/460] Say the LOCAL refusal is measured on the KH 750 --- README.md | 3 ++- crates/core/src/simulator/device.rs | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5c41da1..2664879 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,8 @@ knowledge the path catalog is built from and the simulator, not a hardware test. - "Alle Werte..." lists every value the speaker reports in its own schema, with a path filter and copy buttons, including paths the catalog does not know yet. - Shows the control mode (NETWORK or LOCAL), which the CONTROL switch on the back of the speaker - sets; it cannot be changed over the network. In LOCAL the speaker refuses every write. + sets; it cannot be changed over the network. In LOCAL a KH 750 refuses every write (measured; + the KH 120 II is assumed to do the same, not measured). - A speaker that stops answering is shown as unreachable and reconnected every 10 seconds. ## Supported models diff --git a/crates/core/src/simulator/device.rs b/crates/core/src/simulator/device.rs index ba0c9ae..aad7d08 100644 --- a/crates/core/src/simulator/device.rs +++ b/crates/core/src/simulator/device.rs @@ -57,6 +57,7 @@ const PARAMETER_NOT_FOUND: ErrorKind = ErrorKind { }; /// While the rear CONTROL switch is on LOCAL, every set answers 401 and gets still work. +/// Measured on the KH 750 only; the simulated KH 120 II is assumed to do the same. const CONTROL_MODE_PATH: &str = "ui/control_mode"; const LOCAL_CONTROL_MODE: &str = "LOCAL"; From 3d66b364d48530364260a0431ed59d9eb5b73598 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:55:55 +0200 Subject: [PATCH 047/460] Rename the class check in catalog validation --- crates/core/src/catalog.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/core/src/catalog.rs b/crates/core/src/catalog.rs index 9146ee6..062d99b 100644 --- a/crates/core/src/catalog.rs +++ b/crates/core/src/catalog.rs @@ -322,11 +322,11 @@ fn check_entry(entry: &Entry, classes: &ModelClasses) -> Result<(), CatalogError if !entry.kind.can_be_written() && !entry.writable_on.is_empty() { return Err(invalid("read-only kind lists writable models")); } - let writable_without_path = entry + let writable_outside_class = entry .writable_on .iter() .any(|product| !classes.contains(entry.class, product)); - if writable_without_path { + if writable_outside_class { return Err(invalid("writable on a model outside its class")); } if entry.kind == Kind::Select && entry.options.is_empty() { From 5e41f6965507bc5cd0ce323c8b75be5a7352cf25 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:40:21 +0200 Subject: [PATCH 048/460] Show firmware version with dots --- crates/app/src/display.rs | 21 +++++++++++++++++++++ crates/app/src/views/card.rs | 6 +++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/app/src/display.rs b/crates/app/src/display.rs index 41cb179..41130a1 100644 --- a/crates/app/src/display.rs +++ b/crates/app/src/display.rs @@ -7,6 +7,16 @@ use serde_json::Value; use crate::texts; const LIST_SEPARATOR: &str = ", "; +const DEVICE_VERSION_SEPARATOR: char = '_'; +const SHOWN_VERSION_SEPARATOR: char = '.'; + +/// The device reports its firmware as `major_minor_patch`; people write it with dots. +pub fn firmware_version(reported: &str) -> String { + reported.replace( + DEVICE_VERSION_SEPARATOR, + &SHOWN_VERSION_SEPARATOR.to_string(), + ) +} pub fn shown_value(entry: &Entry, value: &Value) -> String { if let (Some(conversion), Some(number)) = (entry.conversion, value.as_f64()) { @@ -56,6 +66,17 @@ mod tests { paths::catalog_entry(Catalog::embedded(), &paths::path(path)).unwrap() } + #[test] + fn firmware_underscores_become_dots() { + assert_eq!(firmware_version("2_1_2"), "2.1.2"); + } + + #[test] + fn firmware_without_underscores_is_unchanged() { + assert_eq!(firmware_version("2.1.2"), "2.1.2"); + assert_eq!(firmware_version("1"), "1"); + } + #[test] fn kelvin_is_shown_as_whole_degrees_celsius() { assert_eq!( diff --git a/crates/app/src/views/card.rs b/crates/app/src/views/card.rs index 67c97d3..84bb4c6 100644 --- a/crates/app/src/views/card.rs +++ b/crates/app/src/views/card.rs @@ -10,6 +10,7 @@ use serde_json::Value; use super::fader::{self, FADER_HEIGHT}; use super::{details, meters}; +use crate::display::firmware_version; use crate::fader::output_level_range; use crate::paths; use crate::state::{SpeakerState, Status}; @@ -162,7 +163,10 @@ fn more_toggle(ui: &mut Ui, speaker: &mut SpeakerState) { fn status_line(ui: &mut Ui, speaker: &SpeakerState, tokens: &Tokens) { if let Some(identity) = &speaker.identity { - let line = texts::status_line(&identity.version, &mask_serial(&identity.serial)); + let line = texts::status_line( + &firmware_version(&identity.version), + &mask_serial(&identity.serial), + ); ui.add(Label::new(RichText::new(line).small().weak()).wrap()); } if let Some(message) = &speaker.message { From 96557faf611b8c6062d41497339aba3df43ff8ae Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:40:36 +0200 Subject: [PATCH 049/460] Stack card content top-down in the card row --- crates/app/src/app.rs | 25 ++++++++++--------- crates/app/src/views/card.rs | 21 ++++++++++++---- crates/app/tests/views.rs | 48 ++++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 16 deletions(-) diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index 9e4513d..76d96cc 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -7,7 +7,7 @@ use std::sync::mpsc::{Receiver, TryRecvError}; use std::sync::Arc; use std::thread; -use eframe::egui::{self, Context, ScrollArea, Ui, ViewportInfo}; +use eframe::egui::{self, Align, Context, Layout, ScrollArea, Ui, ViewportInfo}; use neumann_control_core::address::parse_address; use neumann_control_core::catalog::Catalog; use neumann_control_core::discovery::{merge_found, Found}; @@ -245,17 +245,20 @@ impl NeumannApp { .id_salt(CARDS_SCROLL_ID) .auto_shrink([false, false]) .show(ui, |ui| { - ui.horizontal_wrapped(|ui| { - for speaker in &mut self.speakers { - let key = speaker.state.key.clone(); - let commands = ui - .push_id(key, |ui| card::show(ui, &mut speaker.state, catalog)) - .inner; - for command in commands { - speaker.handle.send(command); + ui.with_layout( + Layout::left_to_right(Align::Min).with_main_wrap(true), + |ui| { + for speaker in &mut self.speakers { + let key = speaker.state.key.clone(); + let commands = ui + .push_id(key, |ui| card::show(ui, &mut speaker.state, catalog)) + .inner; + for command in commands { + speaker.handle.send(command); + } } - } - }); + }, + ); }); } diff --git a/crates/app/src/views/card.rs b/crates/app/src/views/card.rs index 84bb4c6..69bdf4f 100644 --- a/crates/app/src/views/card.rs +++ b/crates/app/src/views/card.rs @@ -30,6 +30,18 @@ const DETAILS_ID: &str = "details"; pub fn show(ui: &mut Ui, speaker: &mut SpeakerState, catalog: &Catalog) -> Vec { let tokens = Tokens::current(ui.ctx()); let mut commands = Vec::new(); + // The parent lays cards out in a wrapping row; the content must not inherit that flow. + ui.vertical(|ui| card_frame(ui, speaker, catalog, &mut commands, &tokens)); + commands +} + +fn card_frame( + ui: &mut Ui, + speaker: &mut SpeakerState, + catalog: &Catalog, + commands: &mut Vec, + tokens: &Tokens, +) { let inner_width = CARD_WIDTH - 2.0 * (f32::from(CARD_MARGIN) + BORDER_WIDTH); egui::Frame::new() .fill(tokens.card) @@ -41,9 +53,9 @@ pub fn show(ui: &mut Ui, speaker: &mut SpeakerState, catalog: &Catalog) -> Vec Vec = harness + .query_all_by_label(texts::MUTE) + .map(|node| node.rect()) + .collect(); + let mores: Vec<_> = harness + .query_all_by_label(texts::MORE) + .map(|node| node.rect()) + .collect(); + assert_eq!(mutes.len(), 2); + assert_eq!(mores.len(), 2); + for (index, name) in names.iter().enumerate() { + let card = if mutes[0].center().x < mutes[1].center().x { + index + } else { + 1 - index + }; + assert!(name.bottom() <= mutes[card].top(), "name above Stumm"); + assert!( + mutes[card].bottom() <= mores[card].top(), + "Mehr below Stumm" + ); + } + assert!( + (names[0].top() - names[1].top()).abs() < 1.0, + "cards start at the same height: {names:?}" + ); +} From 749b82fb0615d667b35c48a8b926f91d293ac327 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:50:55 +0200 Subject: [PATCH 050/460] Strip the serial suffix from announced names --- crates/core/src/discovery.rs | 62 ++++++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/crates/core/src/discovery.rs b/crates/core/src/discovery.rs index fe2fe02..deb0838 100644 --- a/crates/core/src/discovery.rs +++ b/crates/core/src/discovery.rs @@ -15,11 +15,13 @@ const SERVICE_TYPE: &str = "_ssc._tcp.local."; const MAX_TXT_CHARACTERS: usize = 64; const TXT_PRODUCT_KEY: &str = "model"; const TXT_SERIAL_KEY: &str = "id"; +const SERIAL_SEPARATOR: char = '-'; const NO_SCOPE: u32 = 0; #[derive(Clone, Debug, PartialEq, Eq)] pub struct Found { - /// The announced instance name with the serial masked; speakers put their serial in it. + /// The announced instance name without its "-" suffix; any other occurrence of the + /// serial is masked. pub name: String, pub product: Option, pub serial: Option, @@ -94,6 +96,15 @@ fn add_found(mut found: Vec, candidate: Found) -> Vec { merge_found(found) } +/// Speakers announce "-"; the suffix only repeats the TXT serial. A name +/// that is nothing but the suffix is kept, so the speaker never loses its announced name. +fn without_serial_suffix<'a>(name: &'a str, serial: &str) -> &'a str { + let suffix = format!("{SERIAL_SEPARATOR}{serial}"); + name.strip_suffix(&suffix) + .filter(|rest| !rest.is_empty()) + .unwrap_or(name) +} + fn found_from_parts( fullname: &str, service_type: &str, @@ -105,7 +116,7 @@ fn found_from_parts( let serial = txt_value(serial); let mut name = instance_name(fullname, service_type).to_owned(); if let Some(serial) = &serial { - name = name.replace(serial.as_str(), &mask_serial(serial)); + name = without_serial_suffix(&name, serial).replace(serial.as_str(), &mask_serial(serial)); } Some(Found { name: capped(&name), @@ -251,6 +262,53 @@ mod tests { assert_eq!(found.serial.as_deref(), Some("SIM0001234")); } + fn announced_name(fullname: &str, serial: Option<&str>) -> String { + let address: Ipv6Addr = "2001:db8::1".parse().unwrap(); + found_from_parts( + fullname, + SERVICE_TYPE, + 45, + &[(address, 0)], + Some("KH 120 II"), + serial, + ) + .unwrap() + .name + } + + #[test] + fn a_trailing_dash_serial_is_stripped_from_the_instance_name() { + assert_eq!( + announced_name("Desk left-SIM0001234._ssc._tcp.local.", Some("SIM0001234")), + "Desk left" + ); + } + + #[test] + fn a_serial_elsewhere_in_the_instance_name_is_masked() { + assert_eq!( + announced_name( + "SIM0001234 desk-SIM0001234._ssc._tcp.local.", + Some("SIM0001234") + ), + format!("{} desk", mask_serial("SIM0001234")) + ); + } + + #[test] + fn without_a_txt_serial_the_instance_name_stays_unchanged() { + assert_eq!( + announced_name("Desk left-SIM0001234._ssc._tcp.local.", None), + "Desk left-SIM0001234" + ); + } + + #[test] + fn an_instance_name_that_is_only_the_suffix_keeps_its_masked_serial() { + let name = announced_name("-SIM0001234._ssc._tcp.local.", Some("SIM0001234")); + assert_eq!(name, format!("-{}", mask_serial("SIM0001234"))); + } + #[test] fn control_characters_are_removed_from_txt() { assert_eq!(capped("KH\u{0}1\n20\u{1b}"), "KH120"); From 57029a08e363c2f5469159fd123af2a514bf2343 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:59:51 +0200 Subject: [PATCH 051/460] Format a speaker address for the settings --- crates/core/src/address.rs | 9 +++++++++ crates/core/tests/address.rs | 15 ++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/core/src/address.rs b/crates/core/src/address.rs index 439cf3c..3fa1d3c 100644 --- a/crates/core/src/address.rs +++ b/crates/core/src/address.rs @@ -64,6 +64,15 @@ pub fn parse_address(input: &str, port: u16) -> Result String { + match address.scope_id() { + NO_SCOPE => address.ip().to_string(), + scope => format!("{}{SCOPE_SEPARATOR}{scope}", address.ip()), + } +} + // `u32::from_str` accepts a leading `+`, which Windows would not. fn parse_scope(text: &str) -> Result { if text.is_empty() || !text.bytes().all(|byte| byte.is_ascii_digit()) { diff --git a/crates/core/tests/address.rs b/crates/core/tests/address.rs index 74ee922..c035864 100644 --- a/crates/core/tests/address.rs +++ b/crates/core/tests/address.rs @@ -1,6 +1,8 @@ use std::net::{Ipv6Addr, SocketAddrV6}; -use neumann_control_core::address::{is_link_local, parse_address, AddressError, SSC_PORT}; +use neumann_control_core::address::{ + format_host, is_link_local, parse_address, AddressError, SSC_PORT, +}; use neumann_control_core::mask::{mask_host, mask_serial}; const SIMULATED_ADDRESS: &str = "fe80::2a36:38ff:fe12:3456"; @@ -106,3 +108,14 @@ fn serial_mask_keeps_last_three() { fn short_serial_is_unchanged() { assert_eq!(mask_serial("123"), "123"); } + +#[test] +fn a_formatted_host_parses_back_to_the_same_address() { + let link_local = SocketAddrV6::new(SIMULATED_ADDRESS.parse().unwrap(), SSC_PORT, 0, 7); + let global = SocketAddrV6::new("2001:db8::1".parse().unwrap(), SSC_PORT, 0, 0); + assert_eq!(format_host(&link_local), format!("{SIMULATED_ADDRESS}%7")); + assert_eq!(format_host(&global), "2001:db8::1"); + for address in [link_local, global] { + assert_eq!(parse_address(&format_host(&address), SSC_PORT), Ok(address)); + } +} From 29f9ddf6f0b445a10e0f05f54f57f01746555925 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:59:51 +0200 Subject: [PATCH 052/460] Choose scanned speakers in a dialog and keep them --- crates/app/src/app.rs | 89 +++++++--- crates/app/src/lib.rs | 1 + crates/app/src/scan_choice.rs | 202 +++++++++++++++++++++ crates/app/src/targets.rs | 263 +++++++++++++++++++--------- crates/app/src/texts.rs | 4 + crates/app/src/views/mod.rs | 1 + crates/app/src/views/scan_choice.rs | 77 ++++++++ crates/app/tests/details.rs | 3 +- crates/app/tests/speakers.rs | 254 ++++++++++++++++++++------- crates/app/tests/support/mod.rs | 35 +++- crates/core/src/settings.rs | 2 + 11 files changed, 761 insertions(+), 170 deletions(-) create mode 100644 crates/app/src/scan_choice.rs create mode 100644 crates/app/src/views/scan_choice.rs diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index 76d96cc..1fd21f0 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -15,12 +15,14 @@ use neumann_control_core::mask::mask_host; use neumann_control_core::settings::{self, ManualSpeaker, Settings, WindowGeometry}; use neumann_control_core::speaker::{Command, Event, SpeakerHandle, Target}; +use crate::scan_choice::ScanChoice; use crate::spawner::{ScanResult, SpeakerSpawner}; use crate::state::{AllValuesState, SpeakerKey, SpeakerState}; -use crate::targets::{duplicate_cards, merge_targets, remember_serial}; +use crate::targets::{duplicate_cards, follow_moves, remember_serial, targets}; use crate::texts; use crate::ui_kit::{fonts, theme}; use crate::views::add_address::{self, AddAddressAction, AddAddressForm}; +use crate::views::scan_choice::{self as scan_choice_view, ScanChoiceAction}; use crate::views::toolbar::{self, ToolbarAction, ToolbarFacts}; use crate::views::{all_values, card}; @@ -38,15 +40,17 @@ pub struct NeumannApp { spawner: Box, wake: Arc, speakers: Vec, + /// Everything scans reported this session; names and models for the cards. found: Vec, scan: Option>, + scan_choice: Option, notice: Option<&'static str>, add_address: Option, window: Option, } impl NeumannApp { - /// Loads the settings and starts a worker for every manual speaker. + /// Loads the settings and starts a worker for every remembered speaker. pub fn new( context: &Context, settings_path: PathBuf, @@ -65,6 +69,7 @@ impl NeumannApp { speakers: Vec::new(), found: Vec::new(), scan: None, + scan_choice: None, notice: None, add_address: None, }; @@ -72,9 +77,10 @@ impl NeumannApp { app } - /// Starts workers for targets without a card and moves cards whose speaker changed address. + /// Starts workers for remembered speakers without a card and moves cards whose entry now + /// points at another address. fn sync_targets(&mut self) { - for (key, address) in merge_targets(&self.settings.manual, &self.found) { + for (key, address) in targets(&self.settings.manual) { let discovered = self.found.iter().find(|found| found.address == address); let existing = self .speakers @@ -171,28 +177,53 @@ impl NeumannApp { Err(TryRecvError::Disconnected) => Err("the scan ended without a result".to_owned()), }; self.scan = None; - match result { - Ok(found) => { - log::info!("scan found {} speakers", found.len()); - let mut all = std::mem::take(&mut self.found); - all.extend(found); - self.found = merge_found(all); - self.sync_targets(); - } + let found = match result { + Ok(found) => merge_found(found), Err(reason) => { log::warn!("scan failed: {reason}"); - self.notice = Some(texts::SCAN_FAILED); + self.show_scan_failure(); + return; } + }; + log::info!("scan found {} speakers", found.len()); + let mut all = std::mem::take(&mut self.found); + all.extend(found.iter().cloned()); + self.found = merge_found(all); + if follow_moves(&mut self.settings.manual, &found) { + self.save_settings(); } + self.sync_targets(); + if self.scan_choice.is_some() { + self.scan_choice = Some(ScanChoice::offer(&found, &self.cards())); + } + } + + /// The dialog shows the failure; after it was cancelled, the toolbar does. + fn show_scan_failure(&mut self) { + match &mut self.scan_choice { + Some(choice) => *choice = ScanChoice::Failed, + None => self.notice = Some(texts::SCAN_FAILED), + } + } + + fn cards(&self) -> Vec<(SpeakerKey, SocketAddrV6)> { + self.speakers + .iter() + .map(|speaker| (speaker.state.key.clone(), speaker.state.address)) + .collect() } - /// The dialog already refused unparsable and listed addresses. - fn add_manual(&mut self, manual: ManualSpeaker) { - let Ok(address) = parse_address(&manual.address, manual.port) else { + /// The dialogs already refused unparsable and listed addresses. + fn add_speakers(&mut self, entries: Vec) { + if entries.is_empty() { return; - }; - log::info!("manual speaker added at {}", mask_host(&address)); - self.settings.manual.push(manual); + } + for entry in &entries { + if let Ok(address) = parse_address(&entry.address, entry.port) { + log::info!("speaker added at {}", mask_host(&address)); + } + } + self.settings.manual.extend(entries); self.save_settings(); self.sync_targets(); } @@ -214,12 +245,14 @@ impl NeumannApp { ToolbarAction::Scan => { self.notice = None; self.scan = Some(self.spawner.scan()); + self.scan_choice = Some(ScanChoice::Scanning); } ToolbarAction::AddAddress => self.add_address = Some(AddAddressForm::default()), ToolbarAction::None => {} } egui::CentralPanel::default().show(ui, |ui| self.show_cards(ui)); self.show_dialog(ui); + self.show_scan_choice(ui); self.show_all_values(ui.ctx()); } @@ -271,7 +304,23 @@ impl NeumannApp { AddAddressAction::Cancel => self.add_address = None, AddAddressAction::Add(manual) => { self.add_address = None; - self.add_manual(manual); + self.add_speakers(vec![manual]); + } + } + } + + fn show_scan_choice(&mut self, ui: &mut Ui) { + let Some(choice) = &mut self.scan_choice else { + return; + }; + match scan_choice_view::show(ui, choice) { + ScanChoiceAction::None => {} + ScanChoiceAction::Toggle(index) => choice.toggle(index), + ScanChoiceAction::Cancel => self.scan_choice = None, + ScanChoiceAction::Add => { + let chosen = choice.chosen(); + self.scan_choice = None; + self.add_speakers(chosen); } } } diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index ce85077..09a85d3 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -4,6 +4,7 @@ pub mod display; pub mod fader; pub mod meter; pub mod paths; +pub mod scan_choice; pub mod spawner; pub mod state; pub mod targets; diff --git a/crates/app/src/scan_choice.rs b/crates/app/src/scan_choice.rs new file mode 100644 index 0000000..a9c83a8 --- /dev/null +++ b/crates/app/src/scan_choice.rs @@ -0,0 +1,202 @@ +//! What the "Gefundene Lautsprecher" dialog offers after "Suchen": the speakers a scan found, +//! which of them already have a card, and which the user checked. + +use std::net::SocketAddrV6; + +use neumann_control_core::discovery::Found; +use neumann_control_core::mask::mask_host; +use neumann_control_core::settings::ManualSpeaker; + +use crate::state::SpeakerKey; +use crate::targets::{entry_for, has_card}; + +#[derive(Clone, Debug, PartialEq)] +pub enum ScanChoice { + Scanning, + Failed, + Offered(Vec), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Offer { + pub found: Found, + /// Already has a card, so it cannot be chosen again. + pub known: bool, + pub checked: bool, +} + +impl ScanChoice { + /// `cards` are the key and address of every card in the window. + pub fn offer(found: &[Found], cards: &[(SpeakerKey, SocketAddrV6)]) -> ScanChoice { + let offers = found + .iter() + .map(|found| Offer { + found: found.clone(), + known: has_card(found, cards), + checked: false, + }) + .collect(); + ScanChoice::Offered(offers) + } + + /// Flips the check of one row; rows of speakers with a card stay unchecked. + pub fn toggle(&mut self, index: usize) { + let ScanChoice::Offered(offers) = self else { + return; + }; + let Some(offer) = offers.get_mut(index) else { + return; + }; + if !offer.known { + offer.checked = !offer.checked; + } + } + + pub fn can_add(&self) -> bool { + self.offers().iter().any(|offer| offer.checked) + } + + /// The settings entries for the checked speakers. + pub fn chosen(&self) -> Vec { + self.offers() + .iter() + .filter(|offer| offer.checked) + .map(|offer| entry_for(&offer.found)) + .collect() + } + + fn offers(&self) -> &[Offer] { + match self { + ScanChoice::Offered(offers) => offers, + ScanChoice::Scanning | ScanChoice::Failed => &[], + } + } +} + +impl Offer { + /// The announced name, else the model, else the masked address; announced names can be + /// empty. + pub fn name(&self) -> String { + [ + Some(self.found.name.as_str()), + self.found.product.as_deref(), + ] + .into_iter() + .flatten() + .find(|name| !name.is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| self.masked_address()) + } + + /// The announced model, unless the name already is the model. + pub fn model(&self) -> Option<&str> { + let name = self.name(); + self.found.product.as_deref().filter(|model| *model != name) + } + + pub fn masked_address(&self) -> String { + mask_host(&self.found.address) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use neumann_control_core::address::parse_address; + + const KNOWN_SERIAL: &str = "SIM0001234"; + const NEW_SERIAL: &str = "SIM0007500"; + const KNOWN_ADDRESS_TEXT: &str = "fe80::2a36:38ff:fe12:3456%7"; + const NEW_ADDRESS_TEXT: &str = "fe80::2a36:38ff:fe12:3457%7"; + const PORT: u16 = 45; + + fn address(text: &str) -> SocketAddrV6 { + parse_address(text, PORT).unwrap() + } + + fn found(name: &str, serial: &str, text: &str) -> Found { + Found { + name: name.to_owned(), + product: Some("KH 750".to_owned()), + serial: Some(serial.to_owned()), + address: address(text), + } + } + + fn choice() -> ScanChoice { + let cards = [( + SpeakerKey::Serial(KNOWN_SERIAL.to_owned()), + address(KNOWN_ADDRESS_TEXT), + )]; + ScanChoice::offer( + &[ + found("Desk", KNOWN_SERIAL, KNOWN_ADDRESS_TEXT), + found("Sub", NEW_SERIAL, NEW_ADDRESS_TEXT), + ], + &cards, + ) + } + + #[test] + fn speakers_with_a_card_are_offered_as_known_and_nothing_is_checked() { + let ScanChoice::Offered(offers) = choice() else { + panic!("a finished scan offers its speakers"); + }; + let known: Vec = offers.iter().map(|offer| offer.known).collect(); + assert_eq!(known, vec![true, false]); + assert!(offers.iter().all(|offer| !offer.checked)); + assert!(!choice().can_add()); + } + + #[test] + fn only_new_speakers_can_be_checked() { + let mut choice = choice(); + choice.toggle(0); + assert!(!choice.can_add()); + choice.toggle(1); + assert!(choice.can_add()); + choice.toggle(1); + assert!(!choice.can_add()); + choice.toggle(2); + assert!(!choice.can_add()); + } + + #[test] + fn the_checked_speakers_become_settings_entries() { + let mut choice = choice(); + choice.toggle(1); + assert_eq!( + choice.chosen(), + vec![ManualSpeaker { + address: "fe80::2a36:38ff:fe12:3457%7".to_owned(), + port: PORT, + serial: Some(NEW_SERIAL.to_owned()), + }] + ); + } + + #[test] + fn a_running_or_failed_scan_offers_nothing() { + for mut choice in [ScanChoice::Scanning, ScanChoice::Failed] { + choice.toggle(0); + assert!(!choice.can_add()); + assert!(choice.chosen().is_empty()); + } + } + + #[test] + fn an_unnamed_speaker_is_named_by_model_then_masked_address() { + let mut offer = Offer { + found: found("", NEW_SERIAL, NEW_ADDRESS_TEXT), + known: false, + checked: false, + }; + assert_eq!(offer.name(), "KH 750"); + assert_eq!(offer.model(), None); + offer.found.product = None; + assert_eq!(offer.name(), mask_host(&address(NEW_ADDRESS_TEXT))); + offer.found.name = "Sub".to_owned(); + offer.found.product = Some("KH 750".to_owned()); + assert_eq!(offer.model(), Some("KH 750")); + } +} diff --git a/crates/app/src/targets.rs b/crates/app/src/targets.rs index 8c78281..a8354d1 100644 --- a/crates/app/src/targets.rs +++ b/crates/app/src/targets.rs @@ -1,18 +1,18 @@ -//! Which speakers to talk to: manual entries and scan results merged into one target each. +//! Which speakers to talk to: the remembered entries in the settings, one target each, and how +//! scan results relate to them. use std::net::SocketAddrV6; -use neumann_control_core::address::parse_address; +use neumann_control_core::address::{format_host, parse_address}; use neumann_control_core::discovery::Found; use neumann_control_core::settings::ManualSpeaker; use crate::state::SpeakerKey; -/// Manual entries first (settings order), then discovered speakers not yet listed. A speaker -/// present on both sides (same serial, or same address) is one target at the discovered -/// address, keyed by the serial once either side knows it. Manual entries whose address does -/// not parse are skipped here and stay untouched in the settings. -pub fn merge_targets(manual: &[ManualSpeaker], found: &[Found]) -> Vec<(SpeakerKey, SocketAddrV6)> { +/// One target per remembered entry in settings order, keyed by the serial once it is known. +/// A later entry with a key or address already listed is the same speaker and is skipped, as +/// are entries whose address does not parse; both stay untouched in the settings. +pub fn targets(manual: &[ManualSpeaker]) -> Vec<(SpeakerKey, SocketAddrV6)> { let mut targets: Vec<(SpeakerKey, SocketAddrV6)> = Vec::new(); for speaker in manual { let Ok(address) = parse_address(&speaker.address, speaker.port) else { @@ -22,16 +22,77 @@ pub fn merge_targets(manual: &[ManualSpeaker], found: &[Found]) -> Vec<(SpeakerK Some(serial) => SpeakerKey::Serial(serial.clone()), None => SpeakerKey::Address(address), }; - push_or_replace(&mut targets, key, address, false); + let listed = targets + .iter() + .any(|(existing, existing_address)| *existing == key || *existing_address == address); + if !listed { + targets.push((key, address)); + } } - for entry in found { - let key = match &entry.serial { - Some(serial) => SpeakerKey::Serial(serial.clone()), - None => SpeakerKey::Address(entry.address), + targets +} + +/// Whether a scanned speaker already has a card: by serial when the scan reported one, by +/// address otherwise. +pub fn has_card(found: &Found, cards: &[(SpeakerKey, SocketAddrV6)]) -> bool { + cards.iter().any(|(key, address)| match &found.serial { + Some(serial) => *key == SpeakerKey::Serial(serial.clone()) || *address == found.address, + None => *address == found.address, + }) +} + +/// The settings entry that brings a scanned speaker back at the next start without a scan. +pub fn entry_for(found: &Found) -> ManualSpeaker { + ManualSpeaker { + address: format_host(&found.address), + port: found.address.port(), + serial: found.serial.clone(), + } +} + +/// Points remembered entries at the address a scan found their serial at: link-local +/// addresses and interface indexes can change between starts. An entry that then equals an +/// earlier one is dropped. True when an entry changed. +pub fn follow_moves(manual: &mut Vec, found: &[Found]) -> bool { + let mut changed = false; + for entry in manual.iter_mut() { + let Some(serial) = &entry.serial else { + continue; }; - push_or_replace(&mut targets, key, entry.address, true); + let moved = found.iter().find(|candidate| { + candidate.serial.as_ref() == Some(serial) + && parse_address(&entry.address, entry.port) != Ok(candidate.address) + }); + if let Some(moved) = moved { + *entry = entry_for(moved); + changed = true; + } } - targets + if changed { + let mut kept: Vec = Vec::new(); + for entry in manual.drain(..) { + if !kept.contains(&entry) { + kept.push(entry); + } + } + *manual = kept; + } + changed +} + +/// Drops every remembered entry of a removed card: those with its serial and those at its +/// address. True when an entry was removed. +pub fn forget(manual: &mut Vec, key: &SpeakerKey, address: SocketAddrV6) -> bool { + let before = manual.len(); + manual.retain(|entry| { + let same_serial = match key { + SpeakerKey::Serial(serial) => entry.serial.as_ref() == Some(serial), + SpeakerKey::Address(_) => false, + }; + let same_address = parse_address(&entry.address, entry.port) == Ok(address); + !(same_serial || same_address) + }); + manual.len() != before } /// Whether a manual entry already points at `address`, however it was written. @@ -70,28 +131,6 @@ pub fn duplicate_cards(keys: &[SpeakerKey]) -> Vec { .collect() } -fn push_or_replace( - targets: &mut Vec<(SpeakerKey, SocketAddrV6)>, - key: SpeakerKey, - address: SocketAddrV6, - from_found: bool, -) { - let position = targets - .iter() - .position(|(existing, existing_address)| *existing == key || *existing_address == address); - let Some(position) = position else { - targets.push((key, address)); - return; - }; - if !from_found { - return; - } - if matches!(key, SpeakerKey::Serial(_)) { - targets[position].0 = key; - } - targets[position].1 = address; -} - #[cfg(test)] mod tests { use super::*; @@ -100,6 +139,7 @@ mod tests { const OTHER_SERIAL: &str = "SIM0007500"; const ADDRESS_TEXT: &str = "fe80::2a36:38ff:fe12:3456%7"; const OTHER_ADDRESS_TEXT: &str = "fe80::2a36:38ff:fe12:3457%7"; + const UNUSED_ADDRESS_TEXT: &str = "fe80::2a36:38ff:fe12:3458%7"; const PORT: u16 = 45; fn address(text: &str) -> SocketAddrV6 { @@ -123,48 +163,6 @@ mod tests { } } - #[test] - fn same_serial_merges_into_one_target() { - let targets = merge_targets( - &[manual(ADDRESS_TEXT, Some(SERIAL))], - &[found(OTHER_ADDRESS_TEXT, Some(SERIAL))], - ); - assert_eq!( - targets, - vec![( - SpeakerKey::Serial(SERIAL.to_owned()), - address(OTHER_ADDRESS_TEXT) - )] - ); - } - - #[test] - fn manual_without_serial_stays_by_address() { - let targets = merge_targets( - &[manual(ADDRESS_TEXT, None), manual("not an address", None)], - &[found(OTHER_ADDRESS_TEXT, Some(OTHER_SERIAL))], - ); - assert_eq!( - targets, - vec![ - ( - SpeakerKey::Address(address(ADDRESS_TEXT)), - address(ADDRESS_TEXT) - ), - ( - SpeakerKey::Serial(OTHER_SERIAL.to_owned()), - address(OTHER_ADDRESS_TEXT) - ), - ] - ); - } - - #[test] - fn found_without_serial_dedupes_by_address() { - let targets = merge_targets(&[manual(ADDRESS_TEXT, None)], &[found(ADDRESS_TEXT, None)]); - assert_eq!(targets.len(), 1); - } - #[test] fn a_reported_serial_lands_in_manual_entries_without_one() { let mut entries = vec![ @@ -201,14 +199,111 @@ mod tests { } #[test] - fn manual_address_and_found_serial_merge_into_one_target() { - let targets = merge_targets( - &[manual(ADDRESS_TEXT, None)], - &[found(ADDRESS_TEXT, Some(SERIAL))], - ); + fn each_remembered_speaker_is_one_target_in_settings_order() { + let targets = targets(&[ + manual(ADDRESS_TEXT, None), + manual("not an address", None), + manual(OTHER_ADDRESS_TEXT, Some(SERIAL)), + manual(ADDRESS_TEXT, Some(OTHER_SERIAL)), + manual(ADDRESS_TEXT, None), + ]); assert_eq!( targets, - vec![(SpeakerKey::Serial(SERIAL.to_owned()), address(ADDRESS_TEXT))] + vec![ + ( + SpeakerKey::Address(address(ADDRESS_TEXT)), + address(ADDRESS_TEXT) + ), + ( + SpeakerKey::Serial(SERIAL.to_owned()), + address(OTHER_ADDRESS_TEXT) + ), + ] + ); + } + + #[test] + fn a_scanned_speaker_has_a_card_by_serial_or_else_by_address() { + let cards = [ + (SpeakerKey::Serial(SERIAL.to_owned()), address(ADDRESS_TEXT)), + ( + SpeakerKey::Address(address(OTHER_ADDRESS_TEXT)), + address(OTHER_ADDRESS_TEXT), + ), + ]; + assert!(has_card(&found(UNUSED_ADDRESS_TEXT, Some(SERIAL)), &cards)); + assert!(has_card(&found(OTHER_ADDRESS_TEXT, None), &cards)); + assert!(has_card( + &found(OTHER_ADDRESS_TEXT, Some(OTHER_SERIAL)), + &cards + )); + assert!(!has_card( + &found(UNUSED_ADDRESS_TEXT, Some(OTHER_SERIAL)), + &cards + )); + assert!(!has_card(&found(UNUSED_ADDRESS_TEXT, None), &cards)); + } + + #[test] + fn the_entry_for_a_scanned_speaker_parses_back_to_its_address() { + let entry = entry_for(&found(ADDRESS_TEXT, Some(SERIAL))); + assert_eq!(entry, manual(ADDRESS_TEXT, Some(SERIAL))); + assert_eq!( + parse_address(&entry.address, entry.port), + Ok(address(ADDRESS_TEXT)) + ); + } + + #[test] + fn a_saved_serial_found_elsewhere_moves_its_entry() { + let mut entries = vec![ + manual(ADDRESS_TEXT, Some(SERIAL)), + manual(OTHER_ADDRESS_TEXT, None), + manual(UNUSED_ADDRESS_TEXT, Some(OTHER_SERIAL)), + ]; + let scanned = [ + found(OTHER_ADDRESS_TEXT, Some(SERIAL)), + found(UNUSED_ADDRESS_TEXT, Some(OTHER_SERIAL)), + ]; + assert!(follow_moves(&mut entries, &scanned)); + assert_eq!( + entries, + vec![ + manual(OTHER_ADDRESS_TEXT, Some(SERIAL)), + manual(OTHER_ADDRESS_TEXT, None), + manual(UNUSED_ADDRESS_TEXT, Some(OTHER_SERIAL)), + ] + ); + assert!(!follow_moves(&mut entries, &scanned)); + } + + #[test] + fn an_entry_moved_onto_an_equal_one_is_dropped() { + let mut entries = vec![ + manual(ADDRESS_TEXT, Some(SERIAL)), + manual(OTHER_ADDRESS_TEXT, Some(SERIAL)), + ]; + assert!(follow_moves( + &mut entries, + &[found(OTHER_ADDRESS_TEXT, Some(SERIAL))] + )); + assert_eq!(entries, vec![manual(OTHER_ADDRESS_TEXT, Some(SERIAL))]); + } + + #[test] + fn forgetting_a_card_drops_its_serial_and_its_address() { + let mut entries = vec![ + manual(ADDRESS_TEXT, None), + manual(OTHER_ADDRESS_TEXT, Some(SERIAL)), + manual(UNUSED_ADDRESS_TEXT, Some(OTHER_SERIAL)), + ]; + let key = SpeakerKey::Serial(SERIAL.to_owned()); + assert!(forget(&mut entries, &key, address(ADDRESS_TEXT))); + assert_eq!( + entries, + vec![manual(UNUSED_ADDRESS_TEXT, Some(OTHER_SERIAL))] ); + let unknown = SpeakerKey::Address(address(ADDRESS_TEXT)); + assert!(!forget(&mut entries, &unknown, address(ADDRESS_TEXT))); } } diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index b01ea24..cb19f1f 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -49,6 +49,10 @@ pub const ADD: &str = "Hinzufügen"; pub const CANCEL: &str = "Abbrechen"; pub const ADDRESS_ALREADY_LISTED: &str = "Diese Adresse ist schon eingetragen."; +pub const SCAN_CHOICE_TITLE: &str = "Gefundene Lautsprecher"; +pub const NOTHING_FOUND: &str = "Keine Lautsprecher gefunden"; +pub const ALREADY_ADDED: &str = "bereits hinzugefügt"; + pub const UNREACHABLE: &str = "nicht erreichbar (aus oder Standby)"; pub const WRITE_FAILED: &str = "Schreiben fehlgeschlagen"; const SERIAL_MISMATCH_PREFIX: &str = "Anderes Gerät unter dieser Adresse (Seriennummer "; diff --git a/crates/app/src/views/mod.rs b/crates/app/src/views/mod.rs index 1c3abb5..2c5312e 100644 --- a/crates/app/src/views/mod.rs +++ b/crates/app/src/views/mod.rs @@ -5,4 +5,5 @@ pub mod details; mod details_fields; pub mod fader; mod meters; +pub mod scan_choice; pub mod toolbar; diff --git a/crates/app/src/views/scan_choice.rs b/crates/app/src/views/scan_choice.rs new file mode 100644 index 0000000..21474c2 --- /dev/null +++ b/crates/app/src/views/scan_choice.rs @@ -0,0 +1,77 @@ +//! The "Gefundene Lautsprecher" dialog after "Suchen": a spinner while the scan runs, then one +//! row per found speaker to check. Speakers that already have a card are shown but not offered. + +use eframe::egui::{Button, Checkbox, Grid, Id, Modal, Ui}; + +use crate::scan_choice::{Offer, ScanChoice}; +use crate::texts; + +const MODAL_ID: &str = "scan_choice"; +const GRID_ID: &str = "scan_choice_rows"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ScanChoiceAction { + None, + Toggle(usize), + Add, + Cancel, +} + +pub fn show(ui: &mut Ui, choice: &ScanChoice) -> ScanChoiceAction { + let modal = Modal::new(Id::new(MODAL_ID)).show(ui.ctx(), |ui| contents(ui, choice)); + if modal.should_close() { + return ScanChoiceAction::Cancel; + } + modal.inner +} + +fn contents(ui: &mut Ui, choice: &ScanChoice) -> ScanChoiceAction { + ui.heading(texts::SCAN_CHOICE_TITLE); + let mut action = match choice { + ScanChoice::Scanning => { + ui.spinner(); + ScanChoiceAction::None + } + ScanChoice::Failed => { + ui.colored_label(ui.visuals().error_fg_color, texts::SCAN_FAILED); + ScanChoiceAction::None + } + ScanChoice::Offered(offers) if offers.is_empty() => { + ui.label(texts::NOTHING_FOUND); + ScanChoiceAction::None + } + ScanChoice::Offered(offers) => rows(ui, offers), + }; + ui.horizontal(|ui| { + if ui + .add_enabled(choice.can_add(), Button::new(texts::ADD)) + .clicked() + { + action = ScanChoiceAction::Add; + } + if ui.button(texts::CANCEL).clicked() { + action = ScanChoiceAction::Cancel; + } + }); + action +} + +fn rows(ui: &mut Ui, offers: &[Offer]) -> ScanChoiceAction { + let mut action = ScanChoiceAction::None; + Grid::new(GRID_ID).striped(true).show(ui, |ui| { + for (index, offer) in offers.iter().enumerate() { + let mut checked = offer.checked; + let checkbox = Checkbox::new(&mut checked, offer.name()); + if ui.add_enabled(!offer.known, checkbox).clicked() { + action = ScanChoiceAction::Toggle(index); + } + ui.label(offer.model().unwrap_or_default()); + ui.label(offer.masked_address()); + if offer.known { + ui.weak(texts::ALREADY_ADDED); + } + ui.end_row(); + } + }); + action +} diff --git a/crates/app/tests/details.rs b/crates/app/tests/details.rs index bb9109b..2410bc1 100644 --- a/crates/app/tests/details.rs +++ b/crates/app/tests/details.rs @@ -151,7 +151,8 @@ fn an_announced_model_gives_no_write_controls_or_model_rows() { }; let mut harness = harness(path, vec![found]); harness.set_size(EXPANDED_WINDOW_SIZE.into()); - harness.get_by_label(texts::SCAN).click(); + // Without an announced name the dialog names the speaker by its announced model. + scan_and_add(&mut harness, &["KH 120 II"]); wait_for_level(&mut harness, 100.0); wait_for_initial_read(&mut harness, &simulator); harness.get_by_label(texts::MORE).click(); diff --git a/crates/app/tests/speakers.rs b/crates/app/tests/speakers.rs index e0cff2a..602078e 100644 --- a/crates/app/tests/speakers.rs +++ b/crates/app/tests/speakers.rs @@ -1,16 +1,196 @@ -//! Which cards the window shows: manual addresses, scans, one card per speaker. +//! Which cards the window shows: addresses, speakers chosen from a scan, one card per speaker. mod support; -use egui_kittest::kittest::Queryable; +use std::path::Path; +use std::time::Duration; + +use egui_kittest::kittest::{NodeT, Queryable}; use neumann_control_app::texts; -use neumann_control_core::address::{AddressError, SSC_PORT}; +use neumann_control_core::address::{parse_address, AddressError, SSC_PORT}; use neumann_control_core::discovery::Found; +use neumann_control_core::mask::mask_host; use neumann_control_core::settings::{self, ManualSpeaker}; use neumann_control_core::simulator::{SimModel, Simulator}; -use std::time::Duration; use support::*; +const KH750_NAME: &str = "Sub"; +const KH750_SERIAL: &str = "SIM0007500"; +const KH120_NAME: &str = "Desk"; + +fn announced(simulator: &Simulator, name: &str, product: &str, serial: &str) -> Found { + Found { + name: name.to_owned(), + product: Some(product.to_owned()), + serial: Some(serial.to_owned()), + address: simulator.address(), + } +} + +fn kh750_found(simulator: &Simulator) -> Found { + announced(simulator, KH750_NAME, "KH 750", KH750_SERIAL) +} + +fn kh120_found(simulator: &Simulator) -> Found { + announced(simulator, KH120_NAME, "KH 120 II", SIMULATED_SERIAL) +} + +fn saved_at(path: &Path, simulator: &Simulator) -> bool { + settings::load(path) + .manual + .iter() + .any(|entry| parse_address(&entry.address, entry.port) == Ok(simulator.address())) +} + +#[test] +fn scan_shows_choices_without_adding_cards() { + let simulator = Simulator::start(SimModel::Kh750); + let directory = tempfile::tempdir().unwrap(); + let path = settings_file(directory.path(), Vec::new()); + let mut harness = harness(path.clone(), vec![kh750_found(&simulator)]); + harness.get_by_label(texts::SCAN).click(); + wait_for_label(&mut harness, KH750_NAME); + harness.get_by_label(texts::SCAN_CHOICE_TITLE); + harness.get_by_label("KH 750"); + harness.get_by_label(&mask_host(&simulator.address())); + harness.run_steps(5); + harness.get_by_label(&texts::speaker_count(0)); + assert!(settings::load(&path).manual.is_empty()); + assert!(simulator.requests().is_empty()); +} + +#[test] +fn scan_says_when_nothing_was_found() { + let directory = tempfile::tempdir().unwrap(); + let path = settings_file(directory.path(), Vec::new()); + let mut harness = harness(path, Vec::new()); + harness.get_by_label(texts::SCAN).click(); + wait_for_label(&mut harness, texts::NOTHING_FOUND); + harness.get_by_label(texts::CANCEL).click(); + harness.run_steps(2); + assert!(harness.query_by_label(texts::SCAN_CHOICE_TITLE).is_none()); +} + +#[test] +fn a_failed_scan_says_so_in_the_dialog() { + let directory = tempfile::tempdir().unwrap(); + let path = settings_file(directory.path(), Vec::new()); + let mut harness = harness_scanning(path, Err("no network".to_owned())); + harness.get_by_label(texts::SCAN).click(); + wait_for_label(&mut harness, texts::SCAN_FAILED); + harness.get_by_label(texts::SCAN_CHOICE_TITLE); + harness.get_by_label(texts::CANCEL).click(); + harness.run_steps(2); + assert!(harness.query_by_label(texts::SCAN_CHOICE_TITLE).is_none()); + assert!(harness.query_by_label(texts::SCAN_FAILED).is_none()); +} + +#[test] +fn only_checked_speakers_get_cards() { + let unchosen = Simulator::start(SimModel::Kh120Ii); + let chosen = Simulator::start(SimModel::Kh750); + let directory = tempfile::tempdir().unwrap(); + let path = settings_file(directory.path(), Vec::new()); + let found = vec![kh120_found(&unchosen), kh750_found(&chosen)]; + let mut harness = harness(path.clone(), found); + scan_and_add(&mut harness, &[KH750_NAME]); + assert!(harness.query_by_label(texts::SCAN_CHOICE_TITLE).is_none()); + wait_for_level(&mut harness, 100.0); + wait_for_initial_read(&mut harness, &chosen); + harness.get_by_label(&texts::speaker_count(1)); + let saved = settings::load(&path).manual; + assert_eq!(saved.len(), 1); + assert_eq!(saved[0].serial.as_deref(), Some(KH750_SERIAL)); + assert!(saved_at(&path, &chosen)); + assert!(unchosen.requests().is_empty()); +} + +#[test] +fn cancel_adds_nothing() { + let simulator = Simulator::start(SimModel::Kh750); + let directory = tempfile::tempdir().unwrap(); + let path = settings_file(directory.path(), Vec::new()); + let mut harness = harness(path.clone(), vec![kh750_found(&simulator)]); + harness.get_by_label(texts::SCAN).click(); + wait_for_label(&mut harness, KH750_NAME); + harness.get_by_label(KH750_NAME).click(); + harness.run_steps(2); + harness.get_by_label(texts::CANCEL).click(); + harness.run_steps(5); + assert!(harness.query_by_label(texts::SCAN_CHOICE_TITLE).is_none()); + harness.get_by_label(&texts::speaker_count(0)); + assert!(settings::load(&path).manual.is_empty()); + assert!(simulator.requests().is_empty()); +} + +#[test] +fn known_speaker_is_marked_and_not_selectable() { + let simulator = Simulator::start(SimModel::Kh120Ii); + let directory = tempfile::tempdir().unwrap(); + let path = settings_file(directory.path(), vec![manual_for(&simulator)]); + let mut harness = harness(path.clone(), vec![kh120_found(&simulator)]); + wait_for_level(&mut harness, 100.0); + wait_for_initial_read(&mut harness, &simulator); + harness.get_by_label(texts::MORE).click(); + harness.run_steps(2); + harness.get_by_label(texts::SCAN).click(); + wait_for_label(&mut harness, KH120_NAME); + harness.get_by_label(texts::ALREADY_ADDED); + assert!(harness + .get_by_label(KH120_NAME) + .accesskit_node() + .is_disabled()); + harness.get_by_label(KH120_NAME).click(); + harness.run_steps(2); + harness.get_by_label(texts::ADD).click(); + harness.run_steps(2); + harness.get_by_label(texts::SCAN_CHOICE_TITLE); + harness.get_by_label(texts::CANCEL).click(); + harness.run_steps(2); + harness.get_by_label(&texts::speaker_count(1)); + assert_eq!(settings::load(&path).manual.len(), 1); + // The card kept its state, so it was not recreated. + harness.get_by_label(texts::LESS); +} + +#[test] +fn chosen_speaker_survives_restart() { + let simulator = Simulator::start(SimModel::Kh750); + let directory = tempfile::tempdir().unwrap(); + let path = settings_file(directory.path(), Vec::new()); + let mut first_run = harness(path.clone(), vec![kh750_found(&simulator)]); + scan_and_add(&mut first_run, &[KH750_NAME]); + wait_for_level(&mut first_run, 100.0); + drop(first_run); + let mut harness = harness(path.clone(), Vec::new()); + wait_for_level(&mut harness, 100.0); + wait_for_initial_read(&mut harness, &simulator); + harness.get_by_label(&texts::speaker_count(1)); + harness.get_by_label("KH 750"); + assert!(saved_at(&path, &simulator)); +} + +#[test] +fn rescan_updates_saved_address() { + let simulator = Simulator::start(SimModel::Kh120Ii); + let directory = tempfile::tempdir().unwrap(); + let stale = ManualSpeaker { + address: LOOPBACK.to_owned(), + port: SSC_PORT, + serial: Some(SIMULATED_SERIAL.to_owned()), + }; + let path = settings_file(directory.path(), vec![stale]); + let mut harness = harness(path.clone(), vec![kh120_found(&simulator)]); + harness.get_by_label(&texts::speaker_count(1)); + harness.get_by_label(texts::SCAN).click(); + wait_for_label(&mut harness, KH120_NAME); + harness.get_by_label(texts::ALREADY_ADDED); + assert!(saved_at(&path, &simulator)); + assert_eq!(settings::load(&path).manual.len(), 1); + wait_for_level(&mut harness, 100.0); + harness.get_by_label(&texts::speaker_count(1)); +} + #[test] fn add_address_refuses_ipv4_with_hint() { let directory = tempfile::tempdir().unwrap(); @@ -56,76 +236,26 @@ fn add_address_saves_the_speaker_and_shows_its_card() { harness.get_by_label(&texts::speaker_count(1)); } -#[test] -fn scan_adds_found_speakers_once() { - let simulator = Simulator::start(SimModel::Kh750); - let directory = tempfile::tempdir().unwrap(); - let path = settings_file(directory.path(), Vec::new()); - let found = Found { - name: String::new(), - product: Some("KH 750".to_owned()), - serial: Some("SIM0007500".to_owned()), - address: simulator.address(), - }; - let mut harness = harness(path, vec![found]); - harness.get_by_label(texts::SCAN).click(); - wait_for_level(&mut harness, 100.0); - harness.get_by_label("KH 750"); - harness.get_by_label(texts::MORE).click(); - harness.run_steps(2); - harness.get_by_label(texts::SCAN).click(); - harness.run_steps(5); - harness.get_by_label(&texts::speaker_count(1)); - // The card kept its state, so it was not recreated. - harness.get_by_label(texts::LESS); -} - -#[test] -fn scan_moves_a_known_speaker_to_its_new_address() { - let simulator = Simulator::start(SimModel::Kh120Ii); - let directory = tempfile::tempdir().unwrap(); - let stale = ManualSpeaker { - address: LOOPBACK.to_owned(), - port: SSC_PORT, - serial: Some(SIMULATED_SERIAL.to_owned()), - }; - let path = settings_file(directory.path(), vec![stale]); - let found = Found { - name: "announced".to_owned(), - product: None, - serial: Some(SIMULATED_SERIAL.to_owned()), - address: simulator.address(), - }; - let mut harness = harness(path, vec![found]); - harness.get_by_label(&texts::speaker_count(1)); - harness.get_by_label(texts::SCAN).click(); - wait_for_level(&mut harness, 100.0); - harness.get_by_label(&texts::speaker_count(1)); -} - #[test] fn a_manual_speaker_found_at_a_second_address_stays_one_card() { let manual_simulator = Simulator::start(SimModel::Kh120Ii); - // The manual card connects only after the scan created a card for the second address. + // The manual card connects only after the chosen card for the second address started. manual_simulator.set_reply_delay(Duration::from_millis(300)); let second_simulator = Simulator::start(SimModel::Kh120Ii); let directory = tempfile::tempdir().unwrap(); let path = settings_file(directory.path(), vec![manual_for(&manual_simulator)]); - let found = Found { - name: String::new(), - product: Some("KH 120 II".to_owned()), - serial: Some(SIMULATED_SERIAL.to_owned()), - address: second_simulator.address(), - }; - let mut harness = harness(path.clone(), vec![found]); - harness.get_by_label(texts::SCAN).click(); - wait_until(&mut harness, "the serial in the manual entry", |_| { - serials_remembered(&path, 1) + let mut harness = harness(path.clone(), vec![kh120_found(&second_simulator)]); + scan_and_add(&mut harness, &[KH120_NAME]); + wait_until(&mut harness, "the serial in both entries", |_| { + serials_remembered(&path, 2) }); wait_until(&mut harness, "one card after the first scan", has_one_card); harness.get_by_label(texts::SCAN).click(); + wait_for_label(&mut harness, texts::ALREADY_ADDED); + harness.get_by_label(texts::CANCEL).click(); harness.run_steps(5); wait_until(&mut harness, "one card after the second scan", has_one_card); + assert!(serials_remembered(&path, 1)); } #[test] diff --git a/crates/app/tests/support/mod.rs b/crates/app/tests/support/mod.rs index 7ae31df..e8aacbb 100644 --- a/crates/app/tests/support/mod.rs +++ b/crates/app/tests/support/mod.rs @@ -30,13 +30,14 @@ pub const WINDOW_SIZE: [f32; 2] = [900.0, 700.0]; /// Tall enough for a card with "Mehr" open, so every control in it can be clicked. pub const EXPANDED_WINDOW_SIZE: [f32; 2] = [900.0, 1600.0]; pub const LOOPBACK: &str = "::1"; +const DIALOG_SETTLE_FRAMES: usize = 2; const PRODUCT_PATH: &str = "device/identity/product"; /// Real speakers take tens of milliseconds per reply; an instant simulator hid tests that /// acted before the connect-time read had arrived. const REPLY_DELAY: Duration = Duration::from_millis(5); pub struct SimulatorSpawner { - pub found: Vec, + pub scan_result: ScanResult, } impl SpeakerSpawner for SimulatorSpawner { @@ -50,7 +51,7 @@ impl SpeakerSpawner for SimulatorSpawner { fn scan(&self) -> Receiver { let (sender, receiver) = mpsc::channel(); - sender.send(Ok(self.found.clone())).unwrap(); + sender.send(self.scan_result.clone()).unwrap(); receiver } } @@ -74,13 +75,20 @@ pub fn settings_file(directory: &Path, manual: Vec) -> PathBuf { } pub fn harness(settings_path: PathBuf, found: Vec) -> Harness<'static, NeumannApp> { + harness_scanning(settings_path, Ok(found)) +} + +pub fn harness_scanning( + settings_path: PathBuf, + scan_result: ScanResult, +) -> Harness<'static, NeumannApp> { Harness::builder() .with_size(WINDOW_SIZE) .build_eframe(move |creation_context| { NeumannApp::new( &creation_context.egui_ctx, settings_path, - Box::new(SimulatorSpawner { found }), + Box::new(SimulatorSpawner { scan_result }), ) }) } @@ -101,6 +109,27 @@ pub fn wait_until( } } +/// Also steps the frames a newly opened dialog needs to settle: egui sizes a new modal in its +/// first frame and centres it in a later one, so a click right away lands beside the widget. +pub fn wait_for_label(harness: &mut Harness<'static, NeumannApp>, label: &str) { + wait_until(harness, label, |harness| { + harness.query_by_label(label).is_some() + }); + harness.run_steps(DIALOG_SETTLE_FRAMES); +} + +/// Scans, checks the named speakers in the dialog and adds them. +pub fn scan_and_add(harness: &mut Harness<'static, NeumannApp>, names: &[&str]) { + harness.get_by_label(texts::SCAN).click(); + for name in names { + wait_for_label(harness, name); + harness.get_by_label(name).click(); + harness.run_steps(2); + } + harness.get_by_label(texts::ADD).click(); + harness.run_steps(2); +} + pub fn readout(value: f64) -> String { texts::readout(value, "dB SPL") } diff --git a/crates/core/src/settings.rs b/crates/core/src/settings.rs index 43e0657..b4bd768 100644 --- a/crates/core/src/settings.rs +++ b/crates/core/src/settings.rs @@ -16,6 +16,8 @@ const QUARANTINE_SUFFIX: &str = ".bad"; #[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] #[serde(default)] pub struct Settings { + /// Every remembered speaker, added by address or chosen from a scan. The key keeps its + /// first name so that files written before scans were remembered load unchanged. pub manual: Vec, pub window: Option, } From 6d057ac08c84466b51b08a810dcf1241bee28e6c Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:11:26 +0200 Subject: [PATCH 053/460] Remove a speaker after confirmation --- crates/app/src/app.rs | 63 ++++++++++++++++++++++++-- crates/app/src/texts.rs | 3 ++ crates/app/src/views/card.rs | 40 ++++++++++------ crates/app/src/views/mod.rs | 1 + crates/app/src/views/remove_speaker.rs | 38 ++++++++++++++++ crates/app/tests/speakers.rs | 49 +++++++++++++++++++- 6 files changed, 177 insertions(+), 17 deletions(-) create mode 100644 crates/app/src/views/remove_speaker.rs diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index 1fd21f0..82f54b7 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -18,10 +18,11 @@ use neumann_control_core::speaker::{Command, Event, SpeakerHandle, Target}; use crate::scan_choice::ScanChoice; use crate::spawner::{ScanResult, SpeakerSpawner}; use crate::state::{AllValuesState, SpeakerKey, SpeakerState}; -use crate::targets::{duplicate_cards, follow_moves, remember_serial, targets}; +use crate::targets::{duplicate_cards, follow_moves, forget, remember_serial, targets}; use crate::texts; use crate::ui_kit::{fonts, theme}; use crate::views::add_address::{self, AddAddressAction, AddAddressForm}; +use crate::views::remove_speaker::{self, RemoveAction}; use crate::views::scan_choice::{self as scan_choice_view, ScanChoiceAction}; use crate::views::toolbar::{self, ToolbarAction, ToolbarFacts}; use crate::views::{all_values, card}; @@ -34,6 +35,14 @@ struct Speaker { events: Receiver, } +/// The card "Entfernen" was clicked on, while the confirmation is open. Key and address both, +/// because connecting can change the key meanwhile. +struct Removal { + key: SpeakerKey, + address: SocketAddrV6, + name: String, +} + pub struct NeumannApp { settings_path: PathBuf, settings: Settings, @@ -46,6 +55,7 @@ pub struct NeumannApp { scan_choice: Option, notice: Option<&'static str>, add_address: Option, + removal: Option, window: Option, } @@ -72,6 +82,7 @@ impl NeumannApp { scan_choice: None, notice: None, add_address: None, + removal: None, }; app.sync_targets(); app @@ -228,6 +239,25 @@ impl NeumannApp { self.sync_targets(); } + /// Stops the card's worker and forgets every settings entry of its speaker. + fn remove_speaker(&mut self, removal: Removal) { + let position = self.speakers.iter().position(|speaker| { + speaker.state.key == removal.key || speaker.state.address == removal.address + }); + let (key, address) = match position { + Some(position) => { + let speaker = self.speakers.remove(position); + retire(speaker.handle); + (speaker.state.key, speaker.state.address) + } + None => (removal.key, removal.address), + }; + log::info!("speaker at {} removed", mask_host(&address)); + if forget(&mut self.settings.manual, &key, address) { + self.save_settings(); + } + } + fn save_settings(&mut self) { if let Err(error) = settings::save(&self.settings, &self.settings_path) { log::warn!("settings could not be saved ({})", error.kind()); @@ -253,6 +283,7 @@ impl NeumannApp { egui::CentralPanel::default().show(ui, |ui| self.show_cards(ui)); self.show_dialog(ui); self.show_scan_choice(ui); + self.show_removal(ui); self.show_all_values(ui.ctx()); } @@ -274,6 +305,7 @@ impl NeumannApp { return; } let catalog = Catalog::embedded(); + let mut removal = None; ScrollArea::vertical() .id_salt(CARDS_SCROLL_ID) .auto_shrink([false, false]) @@ -283,16 +315,41 @@ impl NeumannApp { |ui| { for speaker in &mut self.speakers { let key = speaker.state.key.clone(); - let commands = ui + let response = ui .push_id(key, |ui| card::show(ui, &mut speaker.state, catalog)) .inner; - for command in commands { + for command in response.commands { speaker.handle.send(command); } + if response.remove_clicked { + removal = Some(Removal { + key: speaker.state.key.clone(), + address: speaker.state.address, + name: speaker.state.display_name(), + }); + } } }, ); }); + if removal.is_some() { + self.removal = removal; + } + } + + fn show_removal(&mut self, ui: &mut Ui) { + let Some(removal) = &self.removal else { + return; + }; + match remove_speaker::show(ui, &removal.name) { + RemoveAction::None => {} + RemoveAction::Cancel => self.removal = None, + RemoveAction::Confirm => { + if let Some(removal) = self.removal.take() { + self.remove_speaker(removal); + } + } + } } fn show_dialog(&mut self, ui: &mut Ui) { diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index cb19f1f..3dd8094 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -53,6 +53,9 @@ pub const SCAN_CHOICE_TITLE: &str = "Gefundene Lautsprecher"; pub const NOTHING_FOUND: &str = "Keine Lautsprecher gefunden"; pub const ALREADY_ADDED: &str = "bereits hinzugefügt"; +pub const REMOVE: &str = "Entfernen"; +pub const REMOVE_TITLE: &str = "Lautsprecher entfernen?"; + pub const UNREACHABLE: &str = "nicht erreichbar (aus oder Standby)"; pub const WRITE_FAILED: &str = "Schreiben fehlgeschlagen"; const SERIAL_MISMATCH_PREFIX: &str = "Anderes Gerät unter dieser Adresse (Seriennummer "; diff --git a/crates/app/src/views/card.rs b/crates/app/src/views/card.rs index 69bdf4f..848db37 100644 --- a/crates/app/src/views/card.rs +++ b/crates/app/src/views/card.rs @@ -1,6 +1,6 @@ //! One speaker as a channel strip: name, status, level fader with input meters, mute and -//! identify, the "Mehr" toggle with the settings it opens, and a status line. The card only -//! reports what was clicked; `writes` decides whether it becomes a command. +//! identify, the "Mehr" toggle with the settings it opens and "Entfernen", and a status line. +//! The card only reports what was clicked; `writes` decides whether it becomes a command. use eframe::egui::{self, Button, CornerRadius, Label, RichText, Sense, Stroke, Ui, Vec2}; use neumann_control_core::catalog::Catalog; @@ -26,22 +26,33 @@ const STATUS_DOT_RADIUS: f32 = 4.0; const UNREACHABLE_OPACITY: f32 = 0.55; const DETAILS_ID: &str = "details"; -/// Draws the card and returns the writes the user asked for that the write policy allowed. -pub fn show(ui: &mut Ui, speaker: &mut SpeakerState, catalog: &Catalog) -> Vec { +pub struct CardResponse { + /// The writes the user asked for that the write policy allowed. + pub commands: Vec, + pub remove_clicked: bool, +} + +pub fn show(ui: &mut Ui, speaker: &mut SpeakerState, catalog: &Catalog) -> CardResponse { let tokens = Tokens::current(ui.ctx()); let mut commands = Vec::new(); // The parent lays cards out in a wrapping row; the content must not inherit that flow. - ui.vertical(|ui| card_frame(ui, speaker, catalog, &mut commands, &tokens)); - commands + let remove_clicked = ui + .vertical(|ui| card_frame(ui, speaker, catalog, &mut commands, &tokens)) + .inner; + CardResponse { + commands, + remove_clicked, + } } +/// True when "Entfernen" was clicked. fn card_frame( ui: &mut Ui, speaker: &mut SpeakerState, catalog: &Catalog, commands: &mut Vec, tokens: &Tokens, -) { +) -> bool { let inner_width = CARD_WIDTH - 2.0 * (f32::from(CARD_MARGIN) + BORDER_WIDTH); egui::Frame::new() .fill(tokens.card) @@ -59,15 +70,18 @@ fn card_frame( more_toggle(ui, speaker); // The scope exists collapsed too, so opening it never renumbers the widgets after it. let expanded = ui.push_id(DETAILS_ID, |ui| { - if speaker.expanded { - details::show(ui, speaker, catalog) - } else { - Vec::new() + if !speaker.expanded { + return (Vec::new(), false); } + let commands = details::show(ui, speaker, catalog); + (commands, ui.button(texts::REMOVE).clicked()) }); - commands.extend(expanded.inner); + let (expanded_commands, remove_clicked) = expanded.inner; + commands.extend(expanded_commands); status_line(ui, speaker, tokens); - }); + remove_clicked + }) + .inner } fn header(ui: &mut Ui, speaker: &SpeakerState, tokens: &Tokens) { diff --git a/crates/app/src/views/mod.rs b/crates/app/src/views/mod.rs index 2c5312e..30f329a 100644 --- a/crates/app/src/views/mod.rs +++ b/crates/app/src/views/mod.rs @@ -5,5 +5,6 @@ pub mod details; mod details_fields; pub mod fader; mod meters; +pub mod remove_speaker; pub mod scan_choice; pub mod toolbar; diff --git a/crates/app/src/views/remove_speaker.rs b/crates/app/src/views/remove_speaker.rs new file mode 100644 index 0000000..f3c3945 --- /dev/null +++ b/crates/app/src/views/remove_speaker.rs @@ -0,0 +1,38 @@ +//! The confirmation before a card's speaker is removed and forgotten. + +use eframe::egui::{Id, Modal, Ui}; + +use crate::texts; + +const MODAL_ID: &str = "remove_speaker"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RemoveAction { + None, + Confirm, + Cancel, +} + +/// `name` is the card's display name, so the user sees which speaker goes. +pub fn show(ui: &mut Ui, name: &str) -> RemoveAction { + let modal = Modal::new(Id::new(MODAL_ID)).show(ui.ctx(), |ui| contents(ui, name)); + if modal.should_close() { + return RemoveAction::Cancel; + } + modal.inner +} + +fn contents(ui: &mut Ui, name: &str) -> RemoveAction { + ui.heading(texts::REMOVE_TITLE); + ui.label(name); + ui.horizontal(|ui| { + if ui.button(texts::REMOVE).clicked() { + return RemoveAction::Confirm; + } + if ui.button(texts::CANCEL).clicked() { + return RemoveAction::Cancel; + } + RemoveAction::None + }) + .inner +} diff --git a/crates/app/tests/speakers.rs b/crates/app/tests/speakers.rs index 602078e..63dc7f8 100644 --- a/crates/app/tests/speakers.rs +++ b/crates/app/tests/speakers.rs @@ -6,11 +6,13 @@ use std::path::Path; use std::time::Duration; use egui_kittest::kittest::{NodeT, Queryable}; +use egui_kittest::Harness; +use neumann_control_app::app::NeumannApp; use neumann_control_app::texts; use neumann_control_core::address::{parse_address, AddressError, SSC_PORT}; use neumann_control_core::discovery::Found; use neumann_control_core::mask::mask_host; -use neumann_control_core::settings::{self, ManualSpeaker}; +use neumann_control_core::settings::{self, ManualSpeaker, SETTINGS_FILE}; use neumann_control_core::simulator::{SimModel, Simulator}; use support::*; @@ -298,3 +300,48 @@ fn add_address_names_an_address_already_listed() { harness.get_by_label(texts::ADD_ADDRESS_TITLE); assert_eq!(settings::load(&path).manual.len(), 1); } + +/// Opens "Mehr" on the only card and asks to remove it. +fn ask_to_remove(harness: &mut Harness<'static, NeumannApp>) { + harness.set_size(EXPANDED_WINDOW_SIZE.into()); + harness.get_by_label(texts::MORE).click(); + wait_for_label(harness, texts::REMOVE); + harness.get_by_label(texts::REMOVE).click(); + wait_for_label(harness, texts::REMOVE_TITLE); +} + +#[test] +fn remove_needs_confirmation_and_forgets_speaker() { + let (_simulator, directory, mut harness) = connected_card(SimModel::Kh120Ii); + let path = directory.path().join(SETTINGS_FILE); + ask_to_remove(&mut harness); + harness.get_by_label(&texts::speaker_count(1)); + assert_eq!(settings::load(&path).manual.len(), 1); + let confirm = harness + .query_all_by_label(texts::REMOVE) + .last() + .expect("the dialog's button"); + confirm.click(); + harness.run_steps(2); + assert!(harness.query_by_label(texts::REMOVE_TITLE).is_none()); + harness.get_by_label(&texts::speaker_count(0)); + assert!(settings::load(&path).manual.is_empty()); + drop(harness); + let mut restarted = support::harness(path, Vec::new()); + restarted.run_steps(2); + restarted.get_by_label(&texts::speaker_count(0)); +} + +#[test] +fn cancel_remove_keeps_speaker() { + let (_simulator, directory, mut harness) = connected_card(SimModel::Kh120Ii); + let path = directory.path().join(SETTINGS_FILE); + ask_to_remove(&mut harness); + harness.get_by_label(texts::CANCEL).click(); + harness.run_steps(2); + assert!(harness.query_by_label(texts::REMOVE_TITLE).is_none()); + harness.get_by_label(&texts::speaker_count(1)); + harness.get_by_label(&readout(100.0)); + harness.get_by_label(texts::LESS); + assert_eq!(settings::load(&path).manual.len(), 1); +} From 4f049d44d94bf3fa41201dc0082d0b8bbb14ac57 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:12:09 +0200 Subject: [PATCH 054/460] Describe choosing and removing speakers --- CHANGELOG.md | 7 +++++-- README.md | 21 ++++++++++++++------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dc9e09..b9ad1b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,11 @@ First version. ### Added -- Speaker discovery over mDNS and manual entry by IPv6 address, with the interface index for - link-local addresses. +- Speaker discovery over mDNS with a dialog to choose which found speakers to add, and manual + entry by IPv6 address, with the interface index for link-local addresses. +- Added speakers are remembered and reconnected at every start; a scan updates the stored + address of a remembered speaker that moved. +- Removing a speaker from its card after a confirmation. - Speaker cards with an output level fader, input meters with clip lamps, mute and identify. - A per-model settings area and a list of all values the speaker reports. - Support for the KH 120 II and the KH 750 with writes; other models read-only. diff --git a/README.md b/README.md index 2664879..993e0a2 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,8 @@ knowledge the path catalog is built from and the simulator, not a hardware test. ## Features - Finds speakers on the local network with mDNS (`_ssc._tcp`) when you press "Suchen" (a scan - takes 4 seconds), or adds a speaker by its IPv6 address with "+ Adresse". + takes 4 seconds) and lets you choose which of them to add, or adds a speaker by its IPv6 + address with "+ Adresse". Added speakers are remembered. - One card per speaker: connection state, output level fader (0-120 dB SPL, mouse wheel in 0.5 dB steps), input level meters with clip lamps, "Stumm" (mute) and "Ident." (identify), firmware version and masked serial number. @@ -52,15 +53,22 @@ on some models (subwoofer outputs, logo brightness) appear only for those models ## Usage -1. Start `neumann-control.exe` and press "Suchen". Found speakers get a card. +1. Start `neumann-control.exe` and press "Suchen". After the scan the dialog "Gefundene + Lautsprecher" lists every speaker found with its name, model and masked address. Check the + ones you want and press "Hinzufügen"; each gets a card. Speakers that already have a card are + marked "bereits hinzugefügt" and cannot be checked again. "Abbrechen" adds nothing. 2. If the scan finds nothing (for example mDNS is blocked), press "+ Adresse" and enter the speaker's IPv6 address. A link-local address (`fe80::...`) needs the index of the network adapter after a `%`, for example `fe80::1%7`. 3. Find the index in PowerShell with `Get-NetAdapter | Select-Object Name, ifIndex`, or with `netsh interface ipv6 show interfaces` (column `Idx`). -Manually added speakers are remembered and reconnected at every start. Speakers found by a scan -are listed for the current session only; they are not saved unless added by address. +Added speakers, whether chosen from a scan or entered by address, are remembered and reconnected +at every start without a scan. When a later scan finds a remembered speaker at a different +address (link-local addresses and interface indexes can change), the stored address is updated. + +To remove a speaker, open "Mehr" on its card, press "Entfernen" and confirm with "Entfernen". +The card disappears and the speaker is no longer remembered. IPv4 addresses are not accepted: the speakers are reachable only over IPv6. @@ -70,12 +78,11 @@ The app is portable and writes only next to the executable: | File | Content | |------|---------| -| `settings.json` | Manually added speakers (address, port, serial) and the window position and size | +| `settings.json` | Remembered speakers (address, port, serial) and the window position and size | | `neumann-control.log` | Log of the current run, replaced at each start | Speaker addresses and serial numbers are masked in the log and in the interface. An unreadable -or malformed `settings.json` is renamed to `settings.json.bad` and replaced by defaults. To -remove a manually added speaker, delete its entry from `settings.json` while the app is closed. +or malformed `settings.json` is renamed to `settings.json.bad` and replaced by defaults. ## Known limits From af11f1dace3cabdccc6dcf612e4ddce6f8de73d3 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:19:42 +0200 Subject: [PATCH 055/460] Wait for the dialog mark, not the card name --- crates/app/tests/speakers.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/app/tests/speakers.rs b/crates/app/tests/speakers.rs index 63dc7f8..8598fb2 100644 --- a/crates/app/tests/speakers.rs +++ b/crates/app/tests/speakers.rs @@ -185,8 +185,8 @@ fn rescan_updates_saved_address() { let mut harness = harness(path.clone(), vec![kh120_found(&simulator)]); harness.get_by_label(&texts::speaker_count(1)); harness.get_by_label(texts::SCAN).click(); - wait_for_label(&mut harness, KH120_NAME); - harness.get_by_label(texts::ALREADY_ADDED); + // Not the name: the unconnected card shows the announced name as well. + wait_for_label(&mut harness, texts::ALREADY_ADDED); assert!(saved_at(&path, &simulator)); assert_eq!(settings::load(&path).manual.len(), 1); wait_for_level(&mut harness, 100.0); From e0b650106ffc4b696e9fef99e9b078e2e2402718 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:32:41 +0200 Subject: [PATCH 056/460] Show quiet input levels on the meters --- crates/app/src/meter.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/app/src/meter.rs b/crates/app/src/meter.rs index 2104852..3143ee0 100644 --- a/crates/app/src/meter.rs +++ b/crates/app/src/meter.rs @@ -1,7 +1,8 @@ //! Level meters: the shown level and a peak held for a while. Times are seconds of the UI clock. pub const PEAK_HOLD_SECONDS: f64 = 1.5; -pub const METER_FLOOR_DB: f64 = -60.0; +/// Low enough that quiet room volume (inputs around -90 dB on the KH 120 II) still moves. +pub const METER_FLOOR_DB: f64 = -120.0; pub const METER_CEILING_DB: f64 = 6.0; /// Maps a dB value linearly from the floor..ceiling range to 0..1; the device reports silence @@ -74,6 +75,12 @@ mod tests { fn meter_fraction_clamps_below_floor_and_above_ceiling() { assert_eq!(fraction(-160.0), 0.0); assert_eq!(fraction(10.0), 1.0); - assert_eq!(fraction(-27.0), 0.5); + assert_eq!(fraction(-57.0), 0.5); + } + + #[test] + fn quiet_room_level_is_visible() { + // Measured on a KH 120 II at quiet room volume: the input meter reads about -90 dB. + assert!(fraction(-90.0) > 0.2); } } From 0c85d07d7fe981c35b6e301366735d0b80f21083 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:41:15 +0200 Subject: [PATCH 057/460] Let the meters glide between readings --- crates/app/src/meter.rs | 59 ++++++++++++++++++++++++++++++++-- crates/app/src/views/meters.rs | 5 ++- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/crates/app/src/meter.rs b/crates/app/src/meter.rs index 3143ee0..66d193d 100644 --- a/crates/app/src/meter.rs +++ b/crates/app/src/meter.rs @@ -12,9 +12,17 @@ pub fn fraction(db: f64) -> f32 { position.clamp(0.0, 1.0) as f32 } +/// The device is polled about ten times a second; the bar glides between the readings like a +/// peak meter instead of jumping: quick rise, steady fall. +pub const ATTACK_SECONDS: f64 = 0.08; +pub const RELEASE_DB_PER_SECOND: f64 = 24.0; + #[derive(Clone, Copy, Debug)] pub struct MeterState { level: f64, + /// Where the bar stood when `level` arrived, and when that was. + start: f64, + start_time: f64, peak: f64, peak_since: f64, } @@ -23,6 +31,8 @@ impl Default for MeterState { fn default() -> MeterState { MeterState { level: METER_FLOOR_DB, + start: METER_FLOOR_DB, + start_time: 0.0, peak: METER_FLOOR_DB, peak_since: 0.0, } @@ -31,6 +41,8 @@ impl Default for MeterState { impl MeterState { pub fn update(&mut self, level: f64, now: f64) { + self.start = self.shown(now); + self.start_time = now; self.level = level; let hold_expired = now - self.peak_since >= PEAK_HOLD_SECONDS; if level >= self.peak || hold_expired { @@ -39,14 +51,33 @@ impl MeterState { } } + /// The last reading of the device. pub fn level(&self) -> f64 { self.level } - /// The held peak, or the current level once the hold ran out. + /// Where the bar stands at `now` on its way from the previous position to `level`. + pub fn shown(&self, now: f64) -> f64 { + let elapsed = (now - self.start_time).max(0.0); + if self.level >= self.start { + // Land exactly on the reading; rounding would otherwise keep `is_moving` true. + if elapsed >= ATTACK_SECONDS { + return self.level; + } + return self.start + (self.level - self.start) * elapsed / ATTACK_SECONDS; + } + (self.start - RELEASE_DB_PER_SECOND * elapsed).max(self.level) + } + + /// Whether the bar still moves, so the window has to keep drawing. + pub fn is_moving(&self, now: f64) -> bool { + self.shown(now) != self.level + } + + /// The held peak, or the bar once the hold ran out. pub fn peak(&self, now: f64) -> f64 { if now - self.peak_since >= PEAK_HOLD_SECONDS { - return self.level; + return self.shown(now); } self.peak } @@ -68,7 +99,7 @@ mod tests { assert_eq!(meter.peak(1.0), -6.0); assert_eq!(meter.level(), -30.0); assert_eq!(meter.peak(1.25), -6.0); - assert_eq!(meter.peak(1.5), -30.0); + assert_eq!(meter.peak(1.5), meter.shown(1.5)); } #[test] @@ -78,6 +109,28 @@ mod tests { assert_eq!(fraction(-57.0), 0.5); } + #[test] + fn meter_rises_within_the_attack_time() { + let mut meter = MeterState::default(); + meter.update(-20.0, 10.0); + assert_eq!(meter.shown(10.0), METER_FLOOR_DB); + let halfway = meter.shown(10.0 + ATTACK_SECONDS / 2.0); + assert!(halfway > METER_FLOOR_DB && halfway < -20.0); + assert_eq!(meter.shown(10.0 + ATTACK_SECONDS), -20.0); + } + + #[test] + fn meter_falls_at_the_release_rate_instead_of_jumping() { + let mut meter = MeterState::default(); + meter.update(-20.0, 0.0); + meter.update(-80.0, 1.0); + assert_eq!(meter.shown(1.0), -20.0); + assert_eq!(meter.shown(1.5), -20.0 - RELEASE_DB_PER_SECOND * 0.5); + assert_eq!(meter.shown(10.0), -80.0); + assert!(meter.is_moving(1.5)); + assert!(!meter.is_moving(10.0)); + } + #[test] fn quiet_room_level_is_visible() { // Measured on a KH 120 II at quiet room volume: the input meter reads about -90 dB. diff --git a/crates/app/src/views/meters.rs b/crates/app/src/views/meters.rs index aa6f60b..a18c694 100644 --- a/crates/app/src/views/meters.rs +++ b/crates/app/src/views/meters.rs @@ -38,7 +38,7 @@ pub fn show( let painter = ui.painter(); painter.circle_filled(lamp_center, CLIP_LAMP_RADIUS, lamp_color); painter.rect_filled(track, CornerRadius::ZERO, tokens.meter_track); - let level_top = track.bottom() - track.height() * fraction(meter.level()); + let level_top = track.bottom() - track.height() * fraction(meter.shown(now)); let level = Rect::from_min_max(pos2(track.left(), level_top), track.max); painter.rect_filled(level, CornerRadius::ZERO, tokens.meter_fill); let peak_y = track.bottom() - track.height() * fraction(meter.peak(now)); @@ -51,4 +51,7 @@ pub fn show( response.on_hover_text(texts::CLIP); } } + if meters.iter().any(|meter| meter.is_moving(now)) { + ui.ctx().request_repaint(); + } } From 31ab9bb0ac77525c0868861ffc9172860d4b0ada Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:54:45 +0200 Subject: [PATCH 058/460] Show output meters next to the input meters --- crates/app/src/texts.rs | 7 ++++++ crates/app/src/views/card.rs | 28 +++++++++++++++++++++-- crates/app/src/views/details.rs | 40 ++++++--------------------------- crates/app/src/views/meters.rs | 8 +++++-- crates/app/tests/details.rs | 36 +++++++++++++++++++++++++---- 5 files changed, 78 insertions(+), 41 deletions(-) diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 3dd8094..67f447e 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -23,6 +23,8 @@ pub const CONNECTING: &str = "verbinde …"; pub const CONNECTED: &str = "verbunden"; pub const LEVEL: &str = "Pegel"; pub const CLIP: &str = "Übersteuert"; +pub const INPUT_METERS: &str = "Eingangspegel"; +pub const OUTPUT_METERS: &str = "Ausgangspegel"; pub const ALL_VALUES: &str = "Alle Werte…"; pub const APPLY: &str = "Übernehmen"; @@ -81,6 +83,11 @@ pub fn port(port: u16) -> String { format!("Port {port}") } +/// Names one meter for screen readers, counting channels from 1. +pub fn meter_label(group: &str, channel: usize) -> String { + format!("{group} {channel}") +} + pub fn readout(value: f64, unit: &str) -> String { format!("{value:.1} {unit}") } diff --git a/crates/app/src/views/card.rs b/crates/app/src/views/card.rs index 848db37..8d11da4 100644 --- a/crates/app/src/views/card.rs +++ b/crates/app/src/views/card.rs @@ -25,6 +25,7 @@ const BORDER_WIDTH: f32 = 1.0; const STATUS_DOT_RADIUS: f32 = 4.0; const UNREACHABLE_OPACITY: f32 = 0.55; const DETAILS_ID: &str = "details"; +const METER_GROUP_GAP: f32 = 6.0; pub struct CardResponse { /// The writes the user asked for that the write policy allowed. @@ -132,9 +133,32 @@ fn strip( ui.label(readout.unwrap_or_default()); } +/// Input meters, then the output meters of models that report them (KH 750), set apart. fn meters(ui: &mut Ui, speaker: &SpeakerState, tokens: &Tokens) { - let clips = speaker.values.get(&paths::path(paths::INPUT_CLIP)); - meters::show(ui, &speaker.input_meters, clips, FADER_HEIGHT, tokens); + let input_clips = speaker.values.get(&paths::path(paths::INPUT_CLIP)); + let input = &speaker.input_meters; + meters::show( + ui, + texts::INPUT_METERS, + input, + input_clips, + FADER_HEIGHT, + tokens, + ); + if speaker.output_meters.is_empty() { + return; + } + ui.add_space(METER_GROUP_GAP); + let output_clips = speaker.values.get(&paths::path(paths::OUTPUT_CLIP)); + let output = &speaker.output_meters; + meters::show( + ui, + texts::OUTPUT_METERS, + output, + output_clips, + FADER_HEIGHT, + tokens, + ); } fn buttons( diff --git a/crates/app/src/views/details.rs b/crates/app/src/views/details.rs index 5d36b5f..f7714f5 100644 --- a/crates/app/src/views/details.rs +++ b/crates/app/src/views/details.rs @@ -7,12 +7,10 @@ use neumann_control_core::catalog::{Catalog, Entry, Group, Kind}; use neumann_control_core::speaker::Command; use super::details_fields::Fields; -use super::meters; use crate::display::shown_value; use crate::paths; use crate::state::{AllValuesState, SpeakerState, Status}; use crate::texts; -use crate::ui_kit::theme::Tokens; /// Drawn by the card itself: level, mute, identify, input meters, model, firmware and serial. const SHOWN_ON_CARD: &[&str] = &[ @@ -25,7 +23,6 @@ const SHOWN_ON_CARD: &[&str] = &[ paths::FIRMWARE_VERSION, paths::SERIAL, ]; -const OUTPUT_METER_HEIGHT: f32 = 60.0; /// Draws the area and returns the writes the user asked for that the write policy allowed. pub fn show(ui: &mut Ui, speaker: &mut SpeakerState, catalog: &Catalog) -> Vec { @@ -34,7 +31,6 @@ pub fn show(ui: &mut Ui, speaker: &mut SpeakerState, catalog: &Catalog) -> Vec Vec { - let tokens = Tokens::current(ui.ctx()); let product = speaker.product().to_owned(); let groups = groups(catalog.entries_for(&product), speaker); let mut commands = Vec::new(); @@ -48,7 +44,7 @@ fn rows(ui: &mut Ui, speaker: &mut SpeakerState, catalog: &Catalog) -> Vec(entries: Vec<&'a Entry>, speaker: &SpeakerState) -> Vec<(Group, Ve groups } -/// Clip lists light the lamps of their meters, so they get no row of their own. +/// Meters sit on the card and clip lists light their lamps, so neither gets a row here. fn is_shown(entry: &Entry, speaker: &SpeakerState) -> bool { let path = entry.path.to_string(); let on_card = SHOWN_ON_CARD.contains(&path.as_str()); let absent = speaker.absent.contains(&entry.path); - let drawable = match entry.kind { - Kind::Clip => false, - Kind::Meter => path == paths::OUTPUT_METER, - _ => true, - }; + let drawable = !matches!(entry.kind, Kind::Clip | Kind::Meter); drawable && !on_card && !absent } -fn row(ui: &mut Ui, fields: &mut Fields, entry: &Entry, tokens: &Tokens) { +fn row(ui: &mut Ui, fields: &mut Fields, entry: &Entry) { let product = fields.speaker.product(); if !entry.is_writable(product) { - display(ui, fields.speaker, entry, tokens); + display(ui, fields.speaker, entry); return; } match entry.kind { @@ -93,15 +85,11 @@ fn row(ui: &mut Ui, fields: &mut Fields, entry: &Entry, tokens: &Tokens) { Kind::Switch => fields.switch(ui, entry), Kind::Select => fields.select(ui, entry), Kind::Text => fields.text(ui, entry), - Kind::Display | Kind::Meter | Kind::Clip => display(ui, fields.speaker, entry, tokens), + Kind::Display | Kind::Meter | Kind::Clip => display(ui, fields.speaker, entry), } } -fn display(ui: &mut Ui, speaker: &SpeakerState, entry: &Entry, tokens: &Tokens) { - if entry.kind == Kind::Meter { - output_meters(ui, speaker, entry, tokens); - return; - } +fn display(ui: &mut Ui, speaker: &SpeakerState, entry: &Entry) { let shown = speaker .values .get(&entry.path) @@ -113,20 +101,6 @@ fn display(ui: &mut Ui, speaker: &SpeakerState, entry: &Entry, tokens: &Tokens) }); } -fn output_meters(ui: &mut Ui, speaker: &SpeakerState, entry: &Entry, tokens: &Tokens) { - ui.weak(&entry.label); - let clips = speaker.values.get(&paths::path(paths::OUTPUT_CLIP)); - ui.horizontal(|ui| { - meters::show( - ui, - &speaker.output_meters, - clips, - OUTPUT_METER_HEIGHT, - tokens, - ); - }); -} - fn all_values_button(ui: &mut Ui, speaker: &mut SpeakerState, commands: &mut Vec) { let loading = speaker.all_values == AllValuesState::Loading; let enabled = speaker.status == Status::Connected && !loading; diff --git a/crates/app/src/views/meters.rs b/crates/app/src/views/meters.rs index a18c694..3611a7c 100644 --- a/crates/app/src/views/meters.rs +++ b/crates/app/src/views/meters.rs @@ -1,6 +1,6 @@ //! Level meters drawn side by side, one per channel, each with its clip lamp and held peak. -use eframe::egui::{pos2, vec2, CornerRadius, Rect, Sense, Stroke, Ui}; +use eframe::egui::{pos2, vec2, CornerRadius, Rect, Sense, Stroke, Ui, WidgetInfo, WidgetType}; use serde_json::Value; use crate::meter::{fraction, MeterState}; @@ -12,9 +12,11 @@ const CLIP_LAMP_RADIUS: f32 = 3.5; const CLIP_LAMP_SPACE: f32 = 10.0; const PEAK_LINE_WIDTH: f32 = 2.0; -/// `clips` is the speaker's clip list for the same channels, if it reported one. +/// `clips` is the speaker's clip list for the same channels, if it reported one; `group` +/// names the meters for screen readers. pub fn show( ui: &mut Ui, + group: &str, meters: &[MeterState], clips: Option<&Value>, height: f32, @@ -28,6 +30,8 @@ pub fn show( .and_then(Value::as_bool) .unwrap_or(false); let (rect, response) = ui.allocate_exact_size(vec2(METER_WIDTH, height), Sense::hover()); + let label = texts::meter_label(group, channel + 1); + response.widget_info(|| WidgetInfo::labeled(WidgetType::ProgressIndicator, true, &label)); let lamp_color = if clipped { tokens.danger } else { diff --git a/crates/app/tests/details.rs b/crates/app/tests/details.rs index 2410bc1..cadca11 100644 --- a/crates/app/tests/details.rs +++ b/crates/app/tests/details.rs @@ -376,10 +376,38 @@ fn card_controls_are_not_repeated_in_the_area() { } #[test] -fn kh750_output_meters_are_drawn() { - let (_simulator, _directory, harness) = expanded_card(SimModel::Kh750); - // One for the meters, one for the rear-panel output level. - assert_eq!(harness.query_all_by_label("Ausgangspegel").count(), 2); +fn kh750_output_meters_sit_next_to_the_input_meters() { + let (_simulator, _directory, mut harness) = connected_card(SimModel::Kh750); + let output_meter = texts::meter_label(texts::OUTPUT_METERS, 1); + wait_for_label(&mut harness, &output_meter); + assert_eq!( + harness + .query_all_by_label_contains(texts::OUTPUT_METERS) + .count(), + 3 + ); + assert_eq!( + harness + .query_all_by_label_contains(texts::INPUT_METERS) + .count(), + 2 + ); + harness.set_size(EXPANDED_WINDOW_SIZE.into()); + harness.get_by_label(texts::MORE).click(); + wait_for_label(&mut harness, texts::ALL_VALUES); + assert_eq!(harness.query_all_by_label(&output_meter).count(), 1); +} + +#[test] +fn kh120ii_card_has_no_output_meters() { + let (_simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + wait_for_label(&mut harness, &texts::meter_label(texts::INPUT_METERS, 2)); + assert_eq!( + harness + .query_all_by_label_contains(texts::OUTPUT_METERS) + .count(), + 0 + ); } #[test] From 4d93d0b6633d4aee895a41cd103c3008bbfdd8cc Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:56:24 +0200 Subject: [PATCH 059/460] Describe the output meters on the card --- CHANGELOG.md | 2 +- README.md | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9ad1b3..919b884 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ First version. - Added speakers are remembered and reconnected at every start; a scan updates the stored address of a remembered speaker that moved. - Removing a speaker from its card after a confirmation. -- Speaker cards with an output level fader, input meters with clip lamps, mute and identify. +- Speaker cards with an output level fader, input meters (KH 750: also output meters) with clip lamps, mute and identify. - A per-model settings area and a list of all values the speaker reports. - Support for the KH 120 II and the KH 750 with writes; other models read-only. - Portable settings and log next to the executable. diff --git a/README.md b/README.md index 993e0a2..15e0e0c 100644 --- a/README.md +++ b/README.md @@ -18,10 +18,11 @@ knowledge the path catalog is built from and the simulator, not a hardware test. takes 4 seconds) and lets you choose which of them to add, or adds a speaker by its IPv6 address with "+ Adresse". Added speakers are remembered. - One card per speaker: connection state, output level fader (0-120 dB SPL, mouse wheel in - 0.5 dB steps), input level meters with clip lamps, "Stumm" (mute) and "Ident." (identify), + 0.5 dB steps), input level meters with clip lamps (on the KH 750 also its output meters), + "Stumm" (mute) and "Ident." (identify), firmware version and masked serial number. - "Mehr" opens every setting the catalog knows for that model (output, input, standby, device, - rear panel, metering), grouped; settings a model may write get a control, the rest are shown + rear panel), grouped; settings a model may write get a control, the rest are shown greyed. - "Alle Werte..." lists every value the speaker reports in its own schema, with a path filter and copy buttons, including paths the catalog does not know yet. From 2051521b1824c055bf0d095853ea05a62a799cce Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:13:07 +0200 Subject: [PATCH 060/460] Note the LOCAL refusal on both models --- README.md | 4 ++-- crates/core/src/simulator/device.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 15e0e0c..3d6a279 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ knowledge the path catalog is built from and the simulator, not a hardware test. - "Alle Werte..." lists every value the speaker reports in its own schema, with a path filter and copy buttons, including paths the catalog does not know yet. - Shows the control mode (NETWORK or LOCAL), which the CONTROL switch on the back of the speaker - sets; it cannot be changed over the network. In LOCAL a KH 750 refuses every write (measured; - the KH 120 II is assumed to do the same, not measured). + sets; it cannot be changed over the network. In LOCAL the speaker refuses every write, and the + card says so. - A speaker that stops answering is shown as unreachable and reconnected every 10 seconds. ## Supported models diff --git a/crates/core/src/simulator/device.rs b/crates/core/src/simulator/device.rs index aad7d08..30266c6 100644 --- a/crates/core/src/simulator/device.rs +++ b/crates/core/src/simulator/device.rs @@ -57,7 +57,7 @@ const PARAMETER_NOT_FOUND: ErrorKind = ErrorKind { }; /// While the rear CONTROL switch is on LOCAL, every set answers 401 and gets still work. -/// Measured on the KH 750 only; the simulated KH 120 II is assumed to do the same. +/// Measured on the KH 750 and the KH 120 II. const CONTROL_MODE_PATH: &str = "ui/control_mode"; const LOCAL_CONTROL_MODE: &str = "LOCAL"; From a2eb6252f025eab7d5dad28d76b64404f4f9c2a6 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:17:41 +0200 Subject: [PATCH 061/460] Keep the connection when an answer has no value --- crates/core/src/schema_walk.rs | 14 +++++++------- crates/core/src/speaker.rs | 19 +++++++++++-------- crates/core/src/ssc/client.rs | 20 ++++++++++++++------ crates/core/tests/client.rs | 14 ++++++++++++++ crates/core/tests/speaker.rs | 30 ++++++++++++++++++++++++++++++ 5 files changed, 76 insertions(+), 21 deletions(-) diff --git a/crates/core/src/schema_walk.rs b/crates/core/src/schema_walk.rs index 1597989..c79cb75 100644 --- a/crates/core/src/schema_walk.rs +++ b/crates/core/src/schema_walk.rs @@ -36,10 +36,10 @@ pub enum WalkError { impl From for WalkError { fn from(error: SscError) -> WalkError { - match error { - SscError::Device(_) => WalkError::Refused(error.to_string()), - lost => WalkError::Connection(lost), + if error.is_connection_lost() { + return WalkError::Connection(error); } + WalkError::Refused(error.to_string()) } } @@ -105,16 +105,16 @@ fn report( } let value = match client.get(&path) { Ok(value) => Ok(value), - Err(refusal @ SscError::Device(_)) => Err(refusal.to_string()), - Err(lost) => return Err(WalkError::Connection(lost)), + Err(lost) if lost.is_connection_lost() => return Err(WalkError::Connection(lost)), + Err(refusal) => Err(refusal.to_string()), }; if should_stop() { return Err(WalkError::Stopped); } let limits = match client.limits(&path) { Ok(limits) => Some(limits), - Err(SscError::Device(_)) => None, - Err(lost) => return Err(WalkError::Connection(lost)), + Err(lost) if lost.is_connection_lost() => return Err(WalkError::Connection(lost)), + Err(_) => None, }; Ok(LeafReport { path, diff --git a/crates/core/src/speaker.rs b/crates/core/src/speaker.rs index 3395ccc..9e53a9d 100644 --- a/crates/core/src/speaker.rs +++ b/crates/core/src/speaker.rs @@ -304,14 +304,9 @@ impl Worker { self.outbox.emit(Event::Absent(path.clone())); } } + Err(lost) if lost.is_connection_lost() => return Err(Interruption::Lost(lost)), // Nothing the user could act on; the next poll asks again. - Err(SscError::Device(error)) => log::warn!( - "{} refused to read {path} ({}): {}", - self.host, - error.code, - error.description - ), - Err(lost) => return Err(Interruption::Lost(lost)), + Err(error) => log::warn!("{} could not read {path}: {error}", self.host), } Ok(()) } @@ -347,13 +342,21 @@ impl Worker { }); self.read(session, &path) } - lost => { + lost if lost.is_connection_lost() => { self.outbox.emit(Event::WriteFailed { path, message: lost.to_string(), }); Err(Interruption::Lost(lost)) } + // Whether the device took the value is unknown; show what it holds now. + unusable => { + self.outbox.emit(Event::WriteFailed { + path: path.clone(), + message: unusable.to_string(), + }); + self.read(session, &path) + } } } diff --git a/crates/core/src/ssc/client.rs b/crates/core/src/ssc/client.rs index edc33fd..539f76d 100644 --- a/crates/core/src/ssc/client.rs +++ b/crates/core/src/ssc/client.rs @@ -40,13 +40,16 @@ pub enum SscError { Connection(String), Timeout, Device(DeviceError), + /// The speaker answered this request, but without a usable value (missing, `null`, wrong + /// shape). The stream is still in step, so the connection stays. + Unusable(String), Protocol(String), } impl SscError { /// Whether the connection is unusable afterwards and the caller has to reconnect. pub fn is_connection_lost(&self) -> bool { - !matches!(self, SscError::Device(_)) + !matches!(self, SscError::Device(_) | SscError::Unusable(_)) } } @@ -60,6 +63,7 @@ impl fmt::Display for SscError { "the speaker refused the request ({}): {}", error.code, error.description ), + SscError::Unusable(message) => write!(formatter, "unusable answer: {message}"), SscError::Protocol(message) => write!(formatter, "protocol error: {message}"), } } @@ -238,7 +242,7 @@ impl Client { .map(|(name, child)| (name.clone(), node_kind(child))) .collect()), Value::Null => Ok(Vec::new()), - _ => Err(protocol("schema level is not an object")), + _ => Err(unusable("schema level is not an object")), } } @@ -255,7 +259,7 @@ impl Client { }; match description { Some(found @ Value::Object(_)) => Ok(found.clone()), - _ => Err(protocol("limits reply holds no description")), + _ => Err(unusable("limits reply holds no description")), } } @@ -326,13 +330,17 @@ fn judge(tree: &Value, xid: u64, path: &SscPath) -> Verdict { fn confirmed_value(reply: &Value, path: &SscPath) -> Result { extract(reply, path) .cloned() - .ok_or_else(|| protocol("reply without the requested path")) + .ok_or_else(|| unusable("reply without the requested path")) } fn protocol(message: &str) -> SscError { SscError::Protocol(message.to_owned()) } +fn unusable(message: &str) -> SscError { + SscError::Unusable(message.to_owned()) +} + fn introspection_path(text: &str) -> SscPath { SscPath::parse(text).expect("constant introspection path is valid") } @@ -359,12 +367,12 @@ fn introspected_node<'a>( let listed = extract(reply, &introspection_path(method)) .and_then(Value::as_array) .and_then(|items| items.first()) - .ok_or_else(|| protocol("reply without the requested path"))?; + .ok_or_else(|| unusable("reply without the requested path"))?; let segments = path.map_or(&[][..], SscPath::segments); segments .iter() .try_fold(listed, |node, segment| node.get(segment)) - .ok_or_else(|| protocol("reply does not describe the requested path")) + .ok_or_else(|| unusable("reply does not describe the requested path")) } fn node_kind(node: &Value) -> NodeKind { diff --git a/crates/core/tests/client.rs b/crates/core/tests/client.rs index 0c916d8..2f29f70 100644 --- a/crates/core/tests/client.rs +++ b/crates/core/tests/client.rs @@ -337,3 +337,17 @@ fn device_that_keeps_talking_is_cut_after_ten_seconds() { "cut after {elapsed:?}" ); } + +#[test] +fn reply_with_null_value_keeps_the_connection() { + let mut client = scripted_speaker(|stream, xid| { + let reply = + format!("{{\"audio\":{{\"out\":{{\"level\":null}}}},\"osc\":{{\"xid\":{xid}}}}}\r\n"); + let _ = stream.write_all(reply.as_bytes()); + thread::sleep(Duration::from_millis(200)); + }); + let error = client.get(&path("audio/out/level")).unwrap_err(); + assert!(matches!(error, SscError::Unusable(_)), "got {error:?}"); + assert!(!error.is_connection_lost()); + assert!(!client.is_closed()); +} diff --git a/crates/core/tests/speaker.rs b/crates/core/tests/speaker.rs index 00b7bf6..afe10a9 100644 --- a/crates/core/tests/speaker.rs +++ b/crates/core/tests/speaker.rs @@ -326,6 +326,36 @@ fn absent_path_is_reported_once_and_not_polled_again() { assert_eq!(count_requests(&simulator, &mute), 1); } +#[test] +fn null_value_from_the_device_keeps_the_connection() { + let simulator = Simulator::start(SimModel::Kh120Ii); + simulator.set_value("audio/out/mute", Value::Null); + // Only fast paths repeat, so the product is read only when a connection starts. + let timing = Timing { + meter: LONG_INTERVAL, + slow: LONG_INTERVAL, + ..short_timing() + }; + let (_handle, events) = start_worker(simulator.address(), None, timing); + + let mute = path("audio/out/mute"); + let level = path("audio/out/level"); + let mut seen = collect_until(&events, EVENT_DEADLINE, is_connected); + wait_for_requests(&simulator, &mute, 3, EVENT_DEADLINE); + seen.extend(collect_until(&events, EVENT_DEADLINE, |event| { + is_value_of(event, &level) + })); + + assert!(!seen.iter().any(is_unreachable), "seen: {seen:?}"); + assert!(!seen.iter().any(|event| is_value_of(event, &mute))); + // A connection reads the product twice (identity check, then the catalog's first round); + // a reconnect would add two more. + assert_eq!( + count_requests(&simulator, &path("device/identity/product")), + 2 + ); +} + #[test] fn set_on_absent_path_reports_absent_again() { let simulator = Simulator::start(SimModel::Kh120Ii); From 63c1906e46783aec8e1bae2dc422d3df916eb3b3 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:22:50 +0200 Subject: [PATCH 062/460] Read everything in steps between polls and writes --- crates/core/src/schema_walk.rs | 80 ++++++++++++++++++++++++++-------- crates/core/src/speaker.rs | 45 ++++++++++++++----- crates/core/tests/speaker.rs | 77 ++++++++++++++++++++++++++++++++ 3 files changed, 174 insertions(+), 28 deletions(-) diff --git a/crates/core/src/schema_walk.rs b/crates/core/src/schema_walk.rs index c79cb75..f81bd37 100644 --- a/crates/core/src/schema_walk.rs +++ b/crates/core/src/schema_walk.rs @@ -50,22 +50,66 @@ pub fn read_all( client: &mut Client, should_stop: &dyn Fn() -> bool, ) -> Result, WalkError> { - let leaves = leaf_paths(client, should_stop)?; - let mut reports = Vec::with_capacity(leaves.len()); - for path in leaves { - reports.push(report(client, path, should_stop)?); + let mut walk = Walk::new(); + loop { + if let Progress::Done(reports) = walk.advance(client, should_stop)? { + return Ok(reports); + } } - Ok(reports) } -fn leaf_paths( - client: &mut Client, - should_stop: &dyn Fn() -> bool, -) -> Result, WalkError> { - let mut leaves = Vec::new(); - let mut containers: VecDeque> = VecDeque::from([None]); - let mut visited = 0usize; - while let Some(container) = containers.pop_front() { +pub enum Progress { + Running, + Done(Vec), +} + +/// The same walk taken one step at a time (one container, or one leaf with its limits), so +/// the caller can answer commands and poll meters in between. +pub struct Walk { + containers: VecDeque>, + leaves: VecDeque, + reports: Vec, + visited: usize, +} + +impl Default for Walk { + fn default() -> Walk { + Walk::new() + } +} + +impl Walk { + pub fn new() -> Walk { + Walk { + containers: VecDeque::from([None]), + leaves: VecDeque::new(), + reports: Vec::new(), + visited: 0, + } + } + + pub fn advance( + &mut self, + client: &mut Client, + should_stop: &dyn Fn() -> bool, + ) -> Result { + if let Some(container) = self.containers.pop_front() { + self.expand(client, container, should_stop)?; + return Ok(Progress::Running); + } + if let Some(path) = self.leaves.pop_front() { + self.reports.push(report(client, path, should_stop)?); + return Ok(Progress::Running); + } + Ok(Progress::Done(std::mem::take(&mut self.reports))) + } + + fn expand( + &mut self, + client: &mut Client, + container: Option, + should_stop: &dyn Fn() -> bool, + ) -> Result<(), WalkError> { if should_stop() { return Err(WalkError::Stopped); } @@ -73,8 +117,8 @@ fn leaf_paths( if container.is_none() && name == PROTOCOL_CONTAINER { continue; } - visited += 1; - if visited > MAX_SCHEMA_NODES { + self.visited += 1; + if self.visited > MAX_SCHEMA_NODES { return Err(WalkError::Refused(format!( "schema has more than {MAX_SCHEMA_NODES} nodes" ))); @@ -82,17 +126,17 @@ fn leaf_paths( let path = child_path(container.as_ref(), &name)?; let depth = path.segments().len(); match kind { - NodeKind::Leaf => leaves.push(path), + NodeKind::Leaf => self.leaves.push_back(path), NodeKind::Container if depth >= MAX_SCHEMA_DEPTH => { return Err(WalkError::Refused(format!( "schema is deeper than {MAX_SCHEMA_DEPTH} levels" ))); } - NodeKind::Container => containers.push_back(Some(path)), + NodeKind::Container => self.containers.push_back(Some(path)), } } + Ok(()) } - Ok(leaves) } fn report( diff --git a/crates/core/src/speaker.rs b/crates/core/src/speaker.rs index 9e53a9d..801dc79 100644 --- a/crates/core/src/speaker.rs +++ b/crates/core/src/speaker.rs @@ -15,7 +15,7 @@ use serde_json::Value; use crate::catalog::{Catalog, Entry}; use crate::mask::{mask_host, mask_serial}; use crate::poll_schedule::{Due, Schedule}; -use crate::schema_walk::{self, LeafReport, WalkError}; +use crate::schema_walk::{LeafReport, Progress, Walk, WalkError}; use crate::ssc::client::{Client, SscError}; use crate::ssc::wire::{DeviceError, SscPath}; use inbox::Inbox; @@ -192,6 +192,8 @@ struct Session { client: Client, schedule: Schedule, absent: HashSet, + /// A running "read everything", advanced whenever no command or poll is due. + walk: Option, } struct Worker { @@ -244,8 +246,15 @@ impl Worker { client, schedule: Schedule::new(&entries, &self.timing, Instant::now()), absent: HashSet::new(), + walk: None, }; - match self.serve(&mut session, &entries) { + let interruption = self.serve(&mut session, &entries); + // The app waits for an answer to every "read everything". + if session.walk.is_some() { + self.outbox + .emit(Event::AllValuesFailed(NOT_CONNECTED_MESSAGE.to_owned())); + } + match interruption { Interruption::Stopped => Err(Stopped), Interruption::Lost(error) => { self.report_unreachable(&error); @@ -269,23 +278,31 @@ impl Worker { /// A pending command, or else what the schedule has due; waits for whichever comes first. fn step(&self, session: &mut Session) -> Result<(), Interruption> { - let wait = session.schedule.time_until_due(Instant::now()); + let wait = match session.walk { + Some(_) => Duration::ZERO, + None => session.schedule.time_until_due(Instant::now()), + }; match self.inbox.next(wait) { Ok(command) => return self.execute(session, command), Err(RecvTimeoutError::Timeout) => {} Err(RecvTimeoutError::Disconnected) => return Err(Interruption::Stopped), } match session.schedule.take_due(Instant::now()) { - Due::Meters(paths) => paths.iter().try_for_each(|path| self.read(session, path)), - Due::Path(path) => self.read(session, &path), - Due::Nothing => Ok(()), + Due::Meters(paths) => paths.iter().try_for_each(|path| self.read(session, path))?, + Due::Path(path) => self.read(session, &path)?, + Due::Nothing => {} } + // One step per round, so neither the walk nor the meters can starve the other. + self.advance_walk(session) } fn execute(&self, session: &mut Session, command: Command) -> Result<(), Interruption> { match command { Command::Set { path, value } => self.write(session, path, value), - Command::ReadAll => self.read_all(session), + Command::ReadAll => { + session.walk.get_or_insert_with(Walk::new); + Ok(()) + } Command::Stop => Err(Interruption::Stopped), } } @@ -360,10 +377,18 @@ impl Worker { } } - fn read_all(&self, session: &mut Session) -> Result<(), Interruption> { + fn advance_walk(&self, session: &mut Session) -> Result<(), Interruption> { + let Some(walk) = session.walk.as_mut() else { + return Ok(()); + }; let should_stop = || self.inbox.stop_requested(); - match schema_walk::read_all(&mut session.client, &should_stop) { - Ok(reports) => { + let outcome = walk.advance(&mut session.client, &should_stop); + if !matches!(outcome, Ok(Progress::Running)) { + session.walk = None; + } + match outcome { + Ok(Progress::Running) => Ok(()), + Ok(Progress::Done(reports)) => { self.outbox.emit(Event::AllValues(reports)); Ok(()) } diff --git a/crates/core/tests/speaker.rs b/crates/core/tests/speaker.rs index afe10a9..0896f3b 100644 --- a/crates/core/tests/speaker.rs +++ b/crates/core/tests/speaker.rs @@ -582,6 +582,83 @@ fn read_all_command_reports_all_values() { assert_eq!(reports.len(), KH_120_II_LEAVES); } +/// Long enough per request that the KH 750 walk (about 260 requests) takes over a second. +const WALK_REPLY_DELAY: Duration = Duration::from_millis(5); + +#[test] +fn set_during_read_all_is_written_before_the_walk_ends() { + let simulator = Simulator::start(SimModel::Kh750); + let (handle, events) = start_worker(simulator.address(), None, quiet_timing()); + collect_until(&events, EVENT_DEADLINE, is_connected); + simulator.set_reply_delay(WALK_REPLY_DELAY); + + handle.send(Command::ReadAll); + wait_for_requests(&simulator, &path("audio/out/level"), 2, EVENT_DEADLINE); + let level = path("audio/out/level"); + handle.send(Command::Set { + path: level.clone(), + value: json!(90.0), + }); + let seen = collect_until(&events, RECOVERY_DEADLINE, |event| { + matches!(event, Event::AllValues(_) | Event::AllValuesFailed(_)) + }); + let written = seen.iter().position(|event| { + *event + == Event::Value { + path: level.clone(), + value: json!(90.0), + } + }); + assert!(written.is_some(), "the set waited for the walk"); + assert!(matches!(seen.last(), Some(Event::AllValues(_)))); +} + +#[test] +fn meters_keep_running_during_read_all() { + let simulator = Simulator::start(SimModel::Kh750); + let timing = Timing { + meter: Duration::from_millis(20), + ..quiet_timing() + }; + let (handle, events) = start_worker(simulator.address(), None, timing); + collect_until(&events, EVENT_DEADLINE, is_connected); + simulator.set_reply_delay(WALK_REPLY_DELAY); + + handle.send(Command::ReadAll); + let meter = path("m/in/level"); + let seen = collect_until(&events, RECOVERY_DEADLINE, |event| { + matches!(event, Event::AllValues(_) | Event::AllValuesFailed(_)) + }); + let meter_values = seen + .iter() + .filter(|event| is_value_of(event, &meter)) + .count(); + assert!( + meter_values >= 5, + "only {meter_values} meter values during the walk" + ); +} + +#[test] +fn connection_loss_during_read_all_is_answered() { + let simulator = Simulator::start(SimModel::Kh750); + let timing = Timing { + meter: Duration::from_millis(20), + ..quiet_timing() + }; + let (handle, events) = start_worker(simulator.address(), None, timing); + collect_until(&events, EVENT_DEADLINE, is_connected); + simulator.set_reply_delay(WALK_REPLY_DELAY); + + handle.send(Command::ReadAll); + wait_for_requests(&simulator, &path("audio/out/level"), 2, EVENT_DEADLINE); + simulator.inject(Fault::CloseConnection); + let seen = collect_until(&events, EVENT_DEADLINE, |event| { + matches!(event, Event::AllValues(_) | Event::AllValuesFailed(_)) + }); + assert!(matches!(seen.last(), Some(Event::AllValuesFailed(_)))); +} + #[test] fn drop_stops_the_thread() { let simulator = Simulator::start(SimModel::Kh120Ii); From c2c5a0e28cac2b42917c2657bda2f196b190f889 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:30:32 +0200 Subject: [PATCH 063/460] Pause polling while events wait unread --- crates/app/src/app.rs | 6 +-- crates/app/src/spawner.rs | 14 ++---- crates/app/tests/support/mod.rs | 8 +-- crates/app/tests/write_messages.rs | 13 ++--- crates/core/src/speaker.rs | 78 ++++++++++++++++++++++++++---- crates/core/tests/speaker.rs | 38 +++++++++++++-- 6 files changed, 118 insertions(+), 39 deletions(-) diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index 82f54b7..2e13b69 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -13,7 +13,7 @@ use neumann_control_core::catalog::Catalog; use neumann_control_core::discovery::{merge_found, Found}; use neumann_control_core::mask::mask_host; use neumann_control_core::settings::{self, ManualSpeaker, Settings, WindowGeometry}; -use neumann_control_core::speaker::{Command, Event, SpeakerHandle, Target}; +use neumann_control_core::speaker::{Command, Event, Events, SpeakerHandle, Target}; use crate::scan_choice::ScanChoice; use crate::spawner::{ScanResult, SpeakerSpawner}; @@ -32,7 +32,7 @@ const CARDS_SCROLL_ID: &str = "cards"; struct Speaker { state: SpeakerState, handle: SpeakerHandle, - events: Receiver, + events: Events, } /// The card "Entfernen" was clicked on, while the confirmation is open. Key and address both, @@ -117,7 +117,7 @@ impl NeumannApp { } } - fn spawn(&self, key: &SpeakerKey, address: SocketAddrV6) -> (SpeakerHandle, Receiver) { + fn spawn(&self, key: &SpeakerKey, address: SocketAddrV6) -> (SpeakerHandle, Events) { let expected_serial = match key { SpeakerKey::Serial(serial) => Some(serial.clone()), SpeakerKey::Address(_) => None, diff --git a/crates/app/src/spawner.rs b/crates/app/src/spawner.rs index 7ab6dd9..9d6123d 100644 --- a/crates/app/src/spawner.rs +++ b/crates/app/src/spawner.rs @@ -6,7 +6,7 @@ use std::thread; use eframe::egui::Context; use neumann_control_core::discovery::{self, Found, SCAN_DURATION}; -use neumann_control_core::speaker::{self, Event, SpeakerHandle, Target, Timing}; +use neumann_control_core::speaker::{self, Events, SpeakerHandle, Target, Timing}; const SCAN_THREAD_NAME: &str = "scan"; @@ -14,11 +14,7 @@ pub type ScanResult = Result, String>; pub trait SpeakerSpawner { /// Starts a worker for `target`; `wake` runs after each of its events. - fn spawn( - &self, - target: Target, - wake: Arc, - ) -> (SpeakerHandle, Receiver); + fn spawn(&self, target: Target, wake: Arc) -> (SpeakerHandle, Events); /// Starts a scan in the background; the receiver gets exactly one result. fn scan(&self) -> Receiver; @@ -37,11 +33,7 @@ impl CoreSpawner { } impl SpeakerSpawner for CoreSpawner { - fn spawn( - &self, - target: Target, - wake: Arc, - ) -> (SpeakerHandle, Receiver) { + fn spawn(&self, target: Target, wake: Arc) -> (SpeakerHandle, Events) { speaker::spawn(target, Timing::default(), wake) } diff --git a/crates/app/tests/support/mod.rs b/crates/app/tests/support/mod.rs index e8aacbb..2f57ad5 100644 --- a/crates/app/tests/support/mod.rs +++ b/crates/app/tests/support/mod.rs @@ -19,7 +19,7 @@ use neumann_control_core::catalog::Catalog; use neumann_control_core::discovery::Found; use neumann_control_core::settings::{self, ManualSpeaker, Settings, SETTINGS_FILE}; use neumann_control_core::simulator::{SimModel, Simulator}; -use neumann_control_core::speaker::{self, Event, SpeakerHandle, Target, Timing}; +use neumann_control_core::speaker::{self, Events, SpeakerHandle, Target, Timing}; use serde_json::Value; pub const SIMULATED_SERIAL: &str = "SIM0001234"; @@ -41,11 +41,7 @@ pub struct SimulatorSpawner { } impl SpeakerSpawner for SimulatorSpawner { - fn spawn( - &self, - target: Target, - wake: Arc, - ) -> (SpeakerHandle, Receiver) { + fn spawn(&self, target: Target, wake: Arc) -> (SpeakerHandle, Events) { speaker::spawn(target, Timing::default(), wake) } diff --git a/crates/app/tests/write_messages.rs b/crates/app/tests/write_messages.rs index 1b78de9..1449845 100644 --- a/crates/app/tests/write_messages.rs +++ b/crates/app/tests/write_messages.rs @@ -1,7 +1,7 @@ //! The card's write messages across the seam worker -> events -> speaker state: a rejection //! must still be on the card after everything the worker sends in reply has been applied. -use std::sync::mpsc::{Receiver, RecvTimeoutError}; +use std::sync::mpsc::RecvTimeoutError; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -9,7 +9,7 @@ use neumann_control_app::state::{SpeakerKey, SpeakerState, Status}; use neumann_control_app::writes::Intent; use neumann_control_core::catalog::Catalog; use neumann_control_core::simulator::{SimModel, Simulator}; -use neumann_control_core::speaker::{self, Command, Event, SpeakerHandle, Target, Timing}; +use neumann_control_core::speaker::{self, Command, Event, Events, SpeakerHandle, Target, Timing}; use neumann_control_core::ssc::wire::SscPath; use serde_json::json; @@ -27,12 +27,13 @@ fn quiet_timing() -> Timing { fast: LONG_INTERVAL, slow: LONG_INTERVAL, reconnect: LONG_INTERVAL, + ..Timing::default() } } /// A worker for the simulator and the card state it feeds, once the worker is connected and /// has read the values it reads on connecting. -fn connected(simulator: &Simulator) -> (SpeakerHandle, Receiver, SpeakerState) { +fn connected(simulator: &Simulator) -> (SpeakerHandle, Events, SpeakerState) { let address = simulator.address(); let target = Target { address, @@ -48,7 +49,7 @@ fn connected(simulator: &Simulator) -> (SpeakerHandle, Receiver, SpeakerS (handle, events, state) } -fn apply_until(events: &Receiver, state: &mut SpeakerState, done: impl Fn(&Event) -> bool) { +fn apply_until(events: &Events, state: &mut SpeakerState, done: impl Fn(&Event) -> bool) { let end = Instant::now() + EVENT_DEADLINE; loop { let remaining = end.saturating_duration_since(Instant::now()); @@ -61,7 +62,7 @@ fn apply_until(events: &Receiver, state: &mut SpeakerState, done: impl Fn } } -fn apply_until_quiet(events: &Receiver, state: &mut SpeakerState) { +fn apply_until_quiet(events: &Events, state: &mut SpeakerState) { loop { match events.recv_timeout(QUIET_PERIOD) { Ok(event) => state.apply(event, 0.0), @@ -72,7 +73,7 @@ fn apply_until_quiet(events: &Receiver, state: &mut SpeakerState) { } /// Applies the rejection of `path`, the worker's re-read of it and anything after that. -fn apply_reply_to_write(events: &Receiver, state: &mut SpeakerState, path: &SscPath) { +fn apply_reply_to_write(events: &Events, state: &mut SpeakerState, path: &SscPath) { apply_until( events, state, diff --git a/crates/core/src/speaker.rs b/crates/core/src/speaker.rs index 801dc79..f9466c1 100644 --- a/crates/core/src/speaker.rs +++ b/crates/core/src/speaker.rs @@ -5,7 +5,8 @@ mod inbox; use std::collections::HashSet; use std::net::SocketAddrV6; -use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender, TryRecvError}; use std::sync::Arc; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; @@ -23,6 +24,8 @@ use inbox::Inbox; const DEFAULT_METER_INTERVAL: Duration = Duration::from_millis(100); const DEFAULT_FAST_INTERVAL: Duration = Duration::from_secs(2); const DEFAULT_SLOW_INTERVAL: Duration = Duration::from_secs(30); +/// About half a minute of KH 750 meter events. +const DEFAULT_UNREAD_EVENTS_LIMIT: usize = 1000; const DEFAULT_RECONNECT_INTERVAL: Duration = Duration::from_secs(10); const PRODUCT_PATH: &str = "device/identity/product"; @@ -47,6 +50,9 @@ pub struct Timing { pub fast: Duration, pub slow: Duration, pub reconnect: Duration, + /// Polling pauses while this many events wait unread, e.g. while the window is not drawn, + /// so they cannot pile up; commands still run. + pub unread_events_limit: usize, } impl Default for Timing { @@ -56,6 +62,7 @@ impl Default for Timing { fast: DEFAULT_FAST_INTERVAL, slow: DEFAULT_SLOW_INTERVAL, reconnect: DEFAULT_RECONNECT_INTERVAL, + unread_events_limit: DEFAULT_UNREAD_EVENTS_LIMIT, } } } @@ -136,15 +143,20 @@ pub fn spawn( target: Target, timing: Timing, wake: Arc, -) -> (SpeakerHandle, Receiver) { +) -> (SpeakerHandle, Events) { let (command_sender, commands) = mpsc::channel(); let (events, event_receiver) = mpsc::channel(); + let unread = Arc::new(AtomicUsize::new(0)); let worker = Worker { host: mask_host(&target.address), target, timing, inbox: Inbox::new(commands), - outbox: Outbox { events, wake }, + outbox: Outbox { + events, + unread: Arc::clone(&unread), + wake, + }, reported: None, }; let thread = thread::Builder::new() @@ -155,20 +167,58 @@ pub fn spawn( commands: command_sender, thread: Some(thread), }; - (handle, event_receiver) + let events = Events { + receiver: event_receiver, + unread, + }; + (handle, events) +} + +/// The worker's events, counting what is still unread so the worker can pause polling. +pub struct Events { + receiver: Receiver, + unread: Arc, +} + +impl Events { + pub fn try_recv(&self) -> Result { + let event = self.receiver.try_recv()?; + self.unread.fetch_sub(1, Ordering::Relaxed); + Ok(event) + } + + /// Every event that is already there, without waiting. + pub fn try_iter(&self) -> impl Iterator + '_ { + std::iter::from_fn(|| self.try_recv().ok()) + } + + pub fn recv_timeout(&self, timeout: Duration) -> Result { + let event = self.receiver.recv_timeout(timeout)?; + self.unread.fetch_sub(1, Ordering::Relaxed); + Ok(event) + } } struct Outbox { events: Sender, + unread: Arc, wake: Arc, } impl Outbox { fn emit(&self, event: Event) { + // Counted first, so the reader never takes it below zero. + self.unread.fetch_add(1, Ordering::Relaxed); // A dropped receiver means the app is closing; the handle's Drop stops this thread. - let _ = self.events.send(event); + if self.events.send(event).is_err() { + self.unread.fetch_sub(1, Ordering::Relaxed); + } (self.wake)(); } + + fn unread(&self) -> usize { + self.unread.load(Ordering::Relaxed) + } } /// Why the worker is disconnected, so each reason is reported once and not on every retry. @@ -278,16 +328,22 @@ impl Worker { /// A pending command, or else what the schedule has due; waits for whichever comes first. fn step(&self, session: &mut Session) -> Result<(), Interruption> { - let wait = match session.walk { - Some(_) => Duration::ZERO, - None => session.schedule.time_until_due(Instant::now()), + let walking = session.walk.is_some() && !self.paused(); + let wait = if walking { + Duration::ZERO + } else { + session.schedule.time_until_due(Instant::now()) }; match self.inbox.next(wait) { Ok(command) => return self.execute(session, command), Err(RecvTimeoutError::Timeout) => {} Err(RecvTimeoutError::Disconnected) => return Err(Interruption::Stopped), } - match session.schedule.take_due(Instant::now()) { + let due = session.schedule.take_due(Instant::now()); + if self.paused() { + return Ok(()); + } + match due { Due::Meters(paths) => paths.iter().try_for_each(|path| self.read(session, path))?, Due::Path(path) => self.read(session, &path)?, Due::Nothing => {} @@ -296,6 +352,10 @@ impl Worker { self.advance_walk(session) } + fn paused(&self) -> bool { + self.outbox.unread() >= self.timing.unread_events_limit + } + fn execute(&self, session: &mut Session, command: Command) -> Result<(), Interruption> { match command { Command::Set { path, value } => self.write(session, path, value), diff --git a/crates/core/tests/speaker.rs b/crates/core/tests/speaker.rs index 0896f3b..e53348d 100644 --- a/crates/core/tests/speaker.rs +++ b/crates/core/tests/speaker.rs @@ -1,6 +1,6 @@ use std::net::{Ipv6Addr, SocketAddrV6, TcpListener}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use std::sync::mpsc::{Receiver, RecvTimeoutError}; +use std::sync::mpsc::RecvTimeoutError; use std::sync::Arc; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; @@ -9,7 +9,7 @@ use neumann_control_core::catalog::Catalog; use neumann_control_core::schema_walk::{read_all, WalkError, MAX_SCHEMA_DEPTH, MAX_SCHEMA_NODES}; use neumann_control_core::simulator::{Fault, SimModel, Simulator}; use neumann_control_core::speaker::{ - spawn, Command, Event, Identity, SpeakerHandle, Target, Timing, + spawn, Command, Event, Events, Identity, SpeakerHandle, Target, Timing, }; use neumann_control_core::ssc::client::Client; use neumann_control_core::ssc::wire::SscPath; @@ -39,6 +39,7 @@ fn short_timing() -> Timing { fast: Duration::from_millis(50), slow: Duration::from_millis(200), reconnect: Duration::from_millis(100), + ..Timing::default() } } @@ -48,6 +49,7 @@ fn quiet_timing() -> Timing { fast: LONG_INTERVAL, slow: LONG_INTERVAL, reconnect: Duration::from_millis(100), + ..Timing::default() } } @@ -59,7 +61,7 @@ fn start_worker( address: SocketAddrV6, expected_serial: Option<&str>, timing: Timing, -) -> (SpeakerHandle, Receiver) { +) -> (SpeakerHandle, Events) { let target = Target { address, expected_serial: expected_serial.map(str::to_owned), @@ -69,7 +71,7 @@ fn start_worker( /// Every event up to and including the first that matches; panics when none comes in time. fn collect_until( - events: &Receiver, + events: &Events, deadline: Duration, matches: impl Fn(&Event) -> bool, ) -> Vec { @@ -383,6 +385,7 @@ fn commands_run_before_polling() { fast: Duration::from_millis(1), slow: Duration::from_millis(1), reconnect: Duration::from_millis(100), + ..Timing::default() }; let (handle, events) = start_worker(simulator.address(), None, busy); // A fast path read twice means the connect-time read is over and polling runs nonstop. @@ -423,6 +426,7 @@ fn meters_are_polled_between_slow_paths() { fast: Duration::from_millis(1), slow: Duration::from_millis(1), reconnect: Duration::from_millis(100), + ..Timing::default() }; let (_handle, events) = start_worker(simulator.address(), None, busy); let last = last_initial_path(KH_120_II_PRODUCT); @@ -434,6 +438,8 @@ fn meters_are_polled_between_slow_paths() { let slow_before = count_requests(&simulator, &slow); let end = Instant::now() + Duration::from_secs(1); while Instant::now() < end && count_requests(&simulator, &meter) < meters_before + 10 { + // Read like the app does; unread events would pause polling. + events.try_iter().for_each(drop); thread::sleep(REQUEST_POLL_INTERVAL); } @@ -659,6 +665,30 @@ fn connection_loss_during_read_all_is_answered() { assert!(matches!(seen.last(), Some(Event::AllValuesFailed(_)))); } +#[test] +fn polling_pauses_while_nobody_reads_the_events() { + let simulator = Simulator::start(SimModel::Kh120Ii); + let timing = Timing { + unread_events_limit: 30, + ..short_timing() + }; + let (_handle, events) = start_worker(simulator.address(), None, timing); + let meter = path("m/in/level"); + wait_for_requests(&simulator, &meter, 1, EVENT_DEADLINE); + + thread::sleep(QUIET_PERIOD); + let paused_at = count_requests(&simulator, &meter); + thread::sleep(QUIET_PERIOD); + assert_eq!( + count_requests(&simulator, &meter), + paused_at, + "polling went on" + ); + + while events.try_recv().is_ok() {} + wait_for_requests(&simulator, &meter, paused_at + 3, EVENT_DEADLINE); +} + #[test] fn drop_stops_the_thread() { let simulator = Simulator::start(SimModel::Kh120Ii); From 8ef1d821825f9ee53803a3c0d813c9009bdc1516 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:33:41 +0200 Subject: [PATCH 064/460] Bound device-sized text and meter lists on screen --- crates/app/src/all_values.rs | 14 ++---------- crates/app/src/display.rs | 25 ++++++++++++++++++++- crates/app/src/state.rs | 31 +++++++++++++++++++++++++- crates/app/src/views/details_fields.rs | 3 ++- 4 files changed, 58 insertions(+), 15 deletions(-) diff --git a/crates/app/src/all_values.rs b/crates/app/src/all_values.rs index c51f264..cb31c99 100644 --- a/crates/app/src/all_values.rs +++ b/crates/app/src/all_values.rs @@ -6,11 +6,10 @@ use neumann_control_core::mask::mask_serial; use neumann_control_core::schema_walk::LeafReport; use serde_json::Value; +use crate::display::capped; use crate::paths; /// A device can answer with any amount of text; one table cell must not grow with it. -pub const VALUE_CHARACTER_LIMIT: usize = 200; -const ELLIPSIS: char = '…'; const COPY_SEPARATOR: &str = " = "; const RANGE_SEPARATOR: &str = " "; const OPTION_SEPARATOR: &str = " | "; @@ -116,19 +115,10 @@ fn options_text(options: &Value) -> String { } } -/// At most `VALUE_CHARACTER_LIMIT` characters, cut on a character boundary. -fn capped(text: &str) -> String { - let mut characters = text.chars(); - let kept: String = characters.by_ref().take(VALUE_CHARACTER_LIMIT).collect(); - if characters.next().is_none() { - return text.to_owned(); - } - format!("{kept}{ELLIPSIS}") -} - #[cfg(test)] mod tests { use super::*; + use crate::display::{ELLIPSIS, VALUE_CHARACTER_LIMIT}; use neumann_control_core::ssc::wire::SscPath; use serde_json::json; diff --git a/crates/app/src/display.rs b/crates/app/src/display.rs index 41130a1..3697c32 100644 --- a/crates/app/src/display.rs +++ b/crates/app/src/display.rs @@ -6,6 +6,9 @@ use serde_json::Value; use crate::texts; +/// Device text can be up to a line's 1 MiB; laid out whole it would stall every frame. +pub const VALUE_CHARACTER_LIMIT: usize = 200; +pub const ELLIPSIS: char = '…'; const LIST_SEPARATOR: &str = ", "; const DEVICE_VERSION_SEPARATOR: char = '_'; const SHOWN_VERSION_SEPARATOR: char = '.'; @@ -22,13 +25,23 @@ pub fn shown_value(entry: &Entry, value: &Value) -> String { if let (Some(conversion), Some(number)) = (entry.conversion, value.as_f64()) { return converted(conversion, number); } - let text = raw_text(value); + let text = capped(&raw_text(value)); match entry.unit.as_deref() { Some(unit) if !text.is_empty() => format!("{text} {unit}"), _ => text, } } +/// At most `VALUE_CHARACTER_LIMIT` characters, cut on a character boundary. +pub fn capped(text: &str) -> String { + let mut characters = text.chars(); + let kept: String = characters.by_ref().take(VALUE_CHARACTER_LIMIT).collect(); + if characters.next().is_none() { + return text.to_owned(); + } + format!("{kept}{ELLIPSIS}") +} + /// The device reports whole kelvin, so tenths of a degree would only show rounding noise. fn converted(conversion: Conversion, number: f64) -> String { let unit = match conversion { @@ -66,6 +79,16 @@ mod tests { paths::catalog_entry(Catalog::embedded(), &paths::path(path)).unwrap() } + #[test] + fn a_long_device_text_is_shortened_on_a_character_boundary() { + let long = "ä".repeat(VALUE_CHARACTER_LIMIT + 50); + let shown = shown_value(entry("device/name"), &json!(long)); + assert_eq!(shown.chars().count(), VALUE_CHARACTER_LIMIT + 1); + assert!(shown.ends_with(ELLIPSIS)); + let exact = "x".repeat(VALUE_CHARACTER_LIMIT); + assert_eq!(capped(&exact), exact); + } + #[test] fn firmware_underscores_become_dots() { assert_eq!(firmware_version("2_1_2"), "2.1.2"); diff --git a/crates/app/src/state.rs b/crates/app/src/state.rs index c8fb26a..7d1c270 100644 --- a/crates/app/src/state.rs +++ b/crates/app/src/state.rs @@ -11,11 +11,16 @@ use neumann_control_core::speaker::{Command, Event, Identity}; use neumann_control_core::ssc::wire::SscPath; use serde_json::Value; +use crate::display::capped; use crate::fader::FaderState; use crate::meter::{MeterState, METER_FLOOR_DB}; use crate::paths; use crate::texts; +/// The KH 750 reports three output channels; a longer list from a faulty device would draw a +/// meter per element. +pub const MAX_METER_CHANNELS: usize = 8; + #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub enum SpeakerKey { Serial(String), @@ -105,7 +110,7 @@ impl SpeakerState { .into_iter() .flatten() .find(|name| !name.is_empty()) - .map(str::to_owned) + .map(capped) .unwrap_or_else(|| mask_host(&self.address)) } @@ -230,6 +235,7 @@ impl SpeakerState { let Some(levels) = value.as_array() else { return; }; + let levels = &levels[..levels.len().min(MAX_METER_CHANNELS)]; if meters.len() < levels.len() { meters.resize_with(levels.len(), MeterState::default); } @@ -242,6 +248,7 @@ impl SpeakerState { #[cfg(test)] mod tests { use super::*; + use crate::display::VALUE_CHARACTER_LIMIT; use neumann_control_core::address::parse_address; use neumann_control_core::ssc::wire::DeviceError; use serde_json::json; @@ -365,6 +372,28 @@ mod tests { assert!(!message.contains(OTHER_SERIAL)); } + #[test] + fn a_long_meter_list_draws_a_bounded_number_of_meters() { + let mut speaker = state(); + let levels: Vec = vec![-20.0; 500]; + speaker.apply( + Event::Value { + path: path("m/in/level"), + value: json!(levels), + }, + 0.0, + ); + assert_eq!(speaker.input_meters.len(), MAX_METER_CHANNELS); + } + + #[test] + fn a_huge_device_name_is_shortened_for_the_card() { + let mut speaker = state(); + let huge = "n".repeat(1_000_000); + speaker.apply(Event::Connected(identity(&huge, "KH 120 II")), 0.0); + assert!(speaker.display_name().chars().count() <= VALUE_CHARACTER_LIMIT + 1); + } + #[test] fn meter_value_feeds_one_meter_per_channel() { let mut speaker = state(); diff --git a/crates/app/src/views/details_fields.rs b/crates/app/src/views/details_fields.rs index 5bf648b..9bb73f6 100644 --- a/crates/app/src/views/details_fields.rs +++ b/crates/app/src/views/details_fields.rs @@ -6,6 +6,7 @@ use neumann_control_core::catalog::{Catalog, Entry}; use neumann_control_core::speaker::Command; use serde_json::Value; +use crate::display::capped; use crate::state::SpeakerState; use crate::texts; use crate::writes::{self, Intent}; @@ -179,7 +180,7 @@ impl Fields<'_> { .unwrap_or_default(); let focused = ui.memory(|memory| memory.has_focus(field_id)); if !draft.edited && !focused { - draft.text = reported.unwrap_or_default(); + draft.text = reported.as_deref().map(capped).unwrap_or_default(); } let label = ui.label(&entry.label); let field = TextEdit::singleline(&mut draft.text) From 1ecce43af883a9ca4660a389af3d7569a978b222 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:23:44 +0200 Subject: [PATCH 065/460] Report the speaker's response time --- crates/app/src/state.rs | 16 +++++++++++ crates/core/src/speaker.rs | 52 ++++++++++++++++++++++++++++++++++- crates/core/src/ssc/client.rs | 10 +++++++ crates/core/tests/speaker.rs | 21 ++++++++++++++ 4 files changed, 98 insertions(+), 1 deletion(-) diff --git a/crates/app/src/state.rs b/crates/app/src/state.rs index 7d1c270..6784940 100644 --- a/crates/app/src/state.rs +++ b/crates/app/src/state.rs @@ -3,6 +3,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::net::SocketAddrV6; +use std::time::Duration; use neumann_control_core::discovery::Found; use neumann_control_core::mask::{mask_host, mask_serial}; @@ -75,6 +76,8 @@ pub struct SpeakerState { pub all_values: AllValuesState, /// Case-insensitive part of a path the "Alle Werte" table is narrowed to. pub all_values_filter: String, + /// The worker's running mean; none until the current connection measured one. + pub response_time: Option, } impl SpeakerState { @@ -95,6 +98,7 @@ impl SpeakerState { fader: FaderState::default(), all_values: AllValuesState::Idle, all_values_filter: String::new(), + response_time: None, } } @@ -156,6 +160,7 @@ impl SpeakerState { MessageSource::Connection, ); self.reset_meters(); + self.response_time = None; } Event::Value { path, value } => { if path == paths::path(paths::OUTPUT_LEVEL) { @@ -178,6 +183,7 @@ impl SpeakerState { } Event::SerialMismatch { found, .. } => { self.status = Status::Unreachable; + self.response_time = None; let text = texts::serial_mismatch(&mask_serial(&found)); self.set_message(Some(text), MessageSource::Connection); } @@ -185,6 +191,7 @@ impl SpeakerState { Event::AllValuesFailed(reason) => { self.finish_all_values(AllValuesState::Failed(reason)); } + Event::ResponseTime(time) => self.response_time = Some(time), } } @@ -372,6 +379,15 @@ mod tests { assert!(!message.contains(OTHER_SERIAL)); } + #[test] + fn response_time_is_cleared_when_unreachable() { + let mut speaker = state(); + speaker.apply(Event::ResponseTime(Duration::from_millis(14)), 0.0); + assert_eq!(speaker.response_time, Some(Duration::from_millis(14))); + speaker.apply(Event::Unreachable("gone".to_owned()), 1.0); + assert_eq!(speaker.response_time, None); + } + #[test] fn a_long_meter_list_draws_a_bounded_number_of_meters() { let mut speaker = state(); diff --git a/crates/core/src/speaker.rs b/crates/core/src/speaker.rs index f9466c1..6f37d75 100644 --- a/crates/core/src/speaker.rs +++ b/crates/core/src/speaker.rs @@ -110,6 +110,8 @@ pub enum Event { found: String, }, AllValues(Vec), + /// A running mean of the request round trips, sent at most once per second while connected. + ResponseTime(Duration), AllValuesFailed(String), } @@ -244,6 +246,42 @@ struct Session { absent: HashSet, /// A running "read everything", advanced whenever no command or poll is due. walk: Option, + response_time: ResponseTime, +} + +/// Weight of the newest round trip in the running mean. +const RESPONSE_TIME_WEIGHT: f64 = 0.2; +const RESPONSE_TIME_INTERVAL: Duration = Duration::from_secs(1); + +#[derive(Default)] +struct ResponseTime { + mean: Option, + reported_at: Option, +} + +impl ResponseTime { + fn add(&mut self, sample: Duration) { + let mean = match self.mean { + Some(mean) => { + mean.mul_f64(1.0 - RESPONSE_TIME_WEIGHT) + sample.mul_f64(RESPONSE_TIME_WEIGHT) + } + None => sample, + }; + self.mean = Some(mean); + } + + /// The mean when the last report is a second old or there was none. + fn due(&mut self, now: Instant) -> Option { + let mean = self.mean?; + let waited = self + .reported_at + .is_none_or(|at| now.duration_since(at) >= RESPONSE_TIME_INTERVAL); + if !waited { + return None; + } + self.reported_at = Some(now); + Some(mean) + } } struct Worker { @@ -297,6 +335,7 @@ impl Worker { schedule: Schedule::new(&entries, &self.timing, Instant::now()), absent: HashSet::new(), walk: None, + response_time: ResponseTime::default(), }; let interruption = self.serve(&mut session, &entries); // The app waits for an answer to every "read everything". @@ -349,7 +388,18 @@ impl Worker { Due::Nothing => {} } // One step per round, so neither the walk nor the meters can starve the other. - self.advance_walk(session) + let walked = self.advance_walk(session); + self.report_response_time(session); + walked + } + + fn report_response_time(&self, session: &mut Session) { + if let Some(sample) = session.client.take_round_trip() { + session.response_time.add(sample); + } + if let Some(mean) = session.response_time.due(Instant::now()) { + self.outbox.emit(Event::ResponseTime(mean)); + } } fn paused(&self) -> bool { diff --git a/crates/core/src/ssc/client.rs b/crates/core/src/ssc/client.rs index 539f76d..c2c03ae 100644 --- a/crates/core/src/ssc/client.rs +++ b/crates/core/src/ssc/client.rs @@ -187,6 +187,8 @@ pub struct Client { connection: Connection, next_xid: u64, closed: bool, + /// Send to matching reply of the last answered request, until taken. + round_trip: Option, } impl Client { @@ -208,6 +210,7 @@ impl Client { }, next_xid: FIRST_XID, closed: false, + round_trip: None, }) } @@ -263,6 +266,11 @@ impl Client { } } + /// The round trip of the last answered request, once. + pub fn take_round_trip(&mut self) -> Option { + self.round_trip.take() + } + fn close(&mut self) { self.closed = true; let _ = self.connection.stream.shutdown(Shutdown::Both); @@ -286,8 +294,10 @@ impl Client { fn transact(&mut self, path: &SscPath, value: Value) -> Result { let xid = self.next_xid; self.next_xid += 1; + let sent = Instant::now(); self.connection.send_line(&request_line(path, value, xid))?; let reply = self.read_reply(xid, path)?; + self.round_trip = Some(sent.elapsed()); match device_error(&reply) { Some(error) => Err(SscError::Device(error)), None => Ok(reply), diff --git a/crates/core/tests/speaker.rs b/crates/core/tests/speaker.rs index e53348d..7542b96 100644 --- a/crates/core/tests/speaker.rs +++ b/crates/core/tests/speaker.rs @@ -689,6 +689,27 @@ fn polling_pauses_while_nobody_reads_the_events() { wait_for_requests(&simulator, &meter, paused_at + 3, EVENT_DEADLINE); } +#[test] +fn response_time_is_reported_about_once_a_second() { + let simulator = Simulator::start(SimModel::Kh120Ii); + simulator.set_reply_delay(Duration::from_millis(30)); + let (_handle, events) = start_worker(simulator.address(), None, short_timing()); + collect_until(&events, EVENT_DEADLINE, is_connected); + + let end = Instant::now() + Duration::from_millis(2500); + let mut times = Vec::new(); + while let Some(remaining) = end.checked_duration_since(Instant::now()) { + if let Ok(Event::ResponseTime(time)) = events.recv_timeout(remaining) { + times.push(time); + } + } + assert!((2..=3).contains(×.len()), "{times:?}"); + assert!( + times.iter().all(|time| *time >= Duration::from_millis(25)), + "{times:?}" + ); +} + #[test] fn drop_stops_the_thread() { let simulator = Simulator::start(SimModel::Kh120Ii); From 6b9708f8aba4d1c877d4fa3e6979b80eb5ff22b5 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:25:16 +0200 Subject: [PATCH 066/460] Keep desk preferences in the settings --- crates/app/tests/support/mod.rs | 2 +- crates/core/src/settings.rs | 53 +++++++++++++++++++++++++++++++++ crates/core/tests/settings.rs | 32 +++++++++++++++++++- 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/crates/app/tests/support/mod.rs b/crates/app/tests/support/mod.rs index 2f57ad5..8d25ab2 100644 --- a/crates/app/tests/support/mod.rs +++ b/crates/app/tests/support/mod.rs @@ -64,7 +64,7 @@ pub fn settings_file(directory: &Path, manual: Vec) -> PathBuf { let path = directory.join(SETTINGS_FILE); let settings = Settings { manual, - window: None, + ..Settings::default() }; settings::save(&settings, &path).unwrap(); path diff --git a/crates/core/src/settings.rs b/crates/core/src/settings.rs index b4bd768..127162b 100644 --- a/crates/core/src/settings.rs +++ b/crates/core/src/settings.rs @@ -20,6 +20,59 @@ pub struct Settings { /// first name so that files written before scans were remembered load unchanged. pub manual: Vec, pub window: Option, + pub desk: DeskSettings, +} + +/// What the settings dialog changes. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct DeskSettings { + pub theme: ThemeChoice, + pub meter_floor: MeterFloor, + pub show_response_times: bool, +} + +impl Default for DeskSettings { + fn default() -> DeskSettings { + DeskSettings { + theme: ThemeChoice::System, + meter_floor: MeterFloor::Minus120, + show_response_times: true, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum ThemeChoice { + #[default] + System, + Light, + Dark, +} + +/// The lowest level a meter shows; quiet room volume reads about -90 dB on the inputs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum MeterFloor { + #[default] + Minus120, + Minus90, + Minus60, +} + +impl MeterFloor { + pub const ALL: [MeterFloor; 3] = [ + MeterFloor::Minus120, + MeterFloor::Minus90, + MeterFloor::Minus60, + ]; + + pub fn db(self) -> f64 { + match self { + MeterFloor::Minus120 => -120.0, + MeterFloor::Minus90 => -90.0, + MeterFloor::Minus60 => -60.0, + } + } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/crates/core/tests/settings.rs b/crates/core/tests/settings.rs index a231c33..8c40c5f 100644 --- a/crates/core/tests/settings.rs +++ b/crates/core/tests/settings.rs @@ -1,5 +1,6 @@ use neumann_control_core::settings::{ - load, save, ManualSpeaker, Settings, WindowGeometry, SETTINGS_FILE, + load, save, DeskSettings, ManualSpeaker, MeterFloor, Settings, ThemeChoice, WindowGeometry, + SETTINGS_FILE, }; use std::fs; @@ -23,6 +24,7 @@ fn sample() -> Settings { width: 800.0, height: 600.0, }), + desk: DeskSettings::default(), } } @@ -105,3 +107,31 @@ fn unknown_and_missing_fields_are_tolerated() { assert_eq!(loaded.window, None); assert!(path.exists()); } + +#[test] +fn old_settings_file_loads_with_desk_defaults() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(SETTINGS_FILE); + fs::write(&path, r#"{"manual":[],"window":null}"#).unwrap(); + let loaded = load(&path); + assert_eq!(loaded.desk, DeskSettings::default()); + assert_eq!(loaded.desk.theme, ThemeChoice::System); + assert_eq!(loaded.desk.meter_floor.db(), -120.0); + assert!(loaded.desk.show_response_times); +} + +#[test] +fn desk_settings_round_trip() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(SETTINGS_FILE); + let settings = Settings { + desk: DeskSettings { + theme: ThemeChoice::Dark, + meter_floor: MeterFloor::Minus60, + show_response_times: false, + }, + ..Settings::default() + }; + save(&settings, &path).unwrap(); + assert_eq!(load(&path), settings); +} From 3f354d925a43e57f08e93dbf44ffa8be579ffb23 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:27:38 +0200 Subject: [PATCH 067/460] Share the gesture state between fader and knob --- crates/app/src/fader.rs | 171 +---------------------------- crates/app/src/gesture.rs | 198 ++++++++++++++++++++++++++++++++++ crates/app/src/lib.rs | 1 + crates/app/src/state.rs | 6 +- crates/app/src/views/fader.rs | 4 +- 5 files changed, 206 insertions(+), 174 deletions(-) create mode 100644 crates/app/src/gesture.rs diff --git a/crates/app/src/fader.rs b/crates/app/src/fader.rs index 2af4b8f..b4645ba 100644 --- a/crates/app/src/fader.rs +++ b/crates/app/src/fader.rs @@ -1,5 +1,4 @@ -//! The level fader's wheel: steps collect until the wheel goes quiet, then one write goes out. -//! Times are seconds of the UI clock. +//! The level fader: its catalog range and wheel step. use std::ops::RangeInclusive; @@ -8,14 +7,6 @@ use neumann_control_core::catalog::Catalog; use crate::paths; pub const WHEEL_STEP_DB: f64 = 0.5; -pub const WHEEL_IDLE_SECONDS: f64 = 0.3; -/// egui may wake a frame about 16 ms before the requested time; within this much of the -/// deadline the wheel value counts as due instead of booking another tiny wait. -pub const DUE_SLACK_SECONDS: f64 = 0.05; -/// How long the fader keeps showing a sent value while the speaker has not confirmed it. -pub const SENT_HOLD_SECONDS: f64 = 2.0; -/// Fractions of a notch are counted in whole units so ten tenths make exactly one notch. -const NOTCH_RESOLUTION: f64 = 1000.0; /// The catalog range of the output level, which the fader drives. pub fn output_level_range() -> RangeInclusive { @@ -26,163 +17,3 @@ pub fn output_level_range() -> RangeInclusive { let max = entry.max.expect("output level has a maximum"); min..=max } - -#[derive(Clone, Copy, Debug, Default)] -pub struct FaderState { - pending: Option, - last_step: f64, - /// Wheel movement short of a whole notch, in 1/NOTCH_RESOLUTION notches. - partial_notch: i64, - /// The last value sent and when, shown until the speaker reports the level again. - sent: Option<(f64, f64)>, -} - -impl FaderState { - /// `notches` may be fractional (touchpads, high-resolution wheels); one step of - /// WHEEL_STEP_DB per whole notch, the rest is kept for the next event. `range` is the - /// allowed value range of the faded entry. - pub fn on_wheel(&mut self, notches: f64, current: f64, range: RangeInclusive, now: f64) { - self.partial_notch += (notches * NOTCH_RESOLUTION).round() as i64; - let resolution = NOTCH_RESOLUTION as i64; - let whole = self.partial_notch / resolution; - self.partial_notch %= resolution; - self.last_step = now; - if whole == 0 { - return; - } - let base = self.pending.unwrap_or(current); - let target = base + whole as f64 * WHEEL_STEP_DB; - self.pending = Some(target.clamp(*range.start(), *range.end())); - } - - pub fn due(&mut self, now: f64) -> Option { - let idle = now - self.last_step >= WHEEL_IDLE_SECONDS - DUE_SLACK_SECONDS; - if self.pending.is_some() && idle { - return self.pending.take(); - } - None - } - - /// A drag takes over from the wheel: steps not yet sent would overwrite its value. - pub fn drag_started(&mut self) { - self.pending = None; - self.partial_notch = 0; - } - - pub fn sent(&mut self, value: f64, now: f64) { - self.drag_started(); - self.sent = Some((value, now)); - } - - /// The speaker reported the level; from now on its value is shown. - pub fn confirmed(&mut self) { - self.sent = None; - } - - /// When the shown value changes on its own (the wheel becomes due, a sent value stops - /// being held), so the UI can wake up for it. - pub fn deadline(&self) -> Option { - let wheel = self.pending.map(|_| self.last_step + WHEEL_IDLE_SECONDS); - let hold = self.sent.map(|(_, at)| at + SENT_HOLD_SECONDS); - wheel.into_iter().chain(hold).reduce(f64::min) - } - - pub fn shown(&self, device: f64, now: f64) -> f64 { - let held = self - .sent - .filter(|(_, at)| now - at < SENT_HOLD_SECONDS) - .map(|(value, _)| value); - self.pending.or(held).unwrap_or(device) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn wheel_steps_debounce_to_one_value() { - let range = output_level_range(); - let mut fader = FaderState::default(); - fader.on_wheel(1.0, 80.0, range.clone(), 1.0); - fader.on_wheel(1.0, 80.0, range.clone(), 1.125); - fader.on_wheel(1.0, 80.0, range, 1.25); - assert_eq!(fader.shown(80.0, 0.0), 81.5); - assert_eq!(fader.due(1.375), None); - assert_eq!(fader.due(1.625), Some(81.5)); - assert_eq!(fader.due(2.0), None); - assert_eq!(fader.shown(80.0, 0.0), 80.0); - } - - #[test] - fn wheel_deadline_follows_the_last_step_and_ends_when_due() { - let range = output_level_range(); - let mut fader = FaderState::default(); - assert_eq!(fader.deadline(), None); - fader.on_wheel(1.0, 80.0, range.clone(), 1.0); - fader.on_wheel(1.0, 80.0, range, 1.5); - assert_eq!(fader.deadline(), Some(1.5 + WHEEL_IDLE_SECONDS)); - fader.due(2.0); - assert_eq!(fader.deadline(), None); - } - - #[test] - fn fractional_wheel_events_add_up_to_whole_notches() { - let range = output_level_range(); - let mut fader = FaderState::default(); - for _ in 0..10 { - fader.on_wheel(0.1, 80.0, range.clone(), 1.0); - } - assert_eq!(fader.shown(80.0, 0.0), 80.5); - fader.on_wheel(0.5, 80.0, range.clone(), 1.0); - assert_eq!(fader.shown(80.0, 0.0), 80.5); - fader.on_wheel(0.5, 80.0, range, 1.0); - assert_eq!(fader.shown(80.0, 0.0), 81.0); - } - - #[test] - fn the_wheel_is_due_within_the_slack_before_its_deadline() { - let range = output_level_range(); - let mut fader = FaderState::default(); - fader.on_wheel(1.0, 80.0, range, 1.0); - assert_eq!(fader.due(1.125), None); - assert_eq!(fader.due(1.25), Some(80.5)); - } - - #[test] - fn a_drag_drops_wheel_steps_not_yet_sent() { - let range = output_level_range(); - let mut fader = FaderState::default(); - fader.on_wheel(2.0, 80.0, range, 1.0); - fader.drag_started(); - assert_eq!(fader.due(5.0), None); - assert_eq!(fader.deadline(), None); - } - - #[test] - fn a_sent_value_is_shown_until_confirmed_or_two_seconds_passed() { - let range = output_level_range(); - let mut fader = FaderState::default(); - fader.on_wheel(1.0, 80.0, range, 1.0); - fader.sent(90.0, 1.0); - assert_eq!(fader.due(5.0), None); - assert_eq!(fader.shown(80.0, 2.5), 90.0); - assert_eq!(fader.deadline(), Some(1.0 + SENT_HOLD_SECONDS)); - assert_eq!(fader.shown(80.0, 3.0), 80.0); - fader.sent(95.0, 4.0); - fader.confirmed(); - assert_eq!(fader.shown(80.0, 4.0), 80.0); - assert_eq!(fader.deadline(), None); - } - - #[test] - fn wheel_clamps_at_120() { - let range = output_level_range(); - assert_eq!(range, 0.0..=120.0); - let mut fader = FaderState::default(); - fader.on_wheel(10.0, 119.0, range.clone(), 0.0); - assert_eq!(fader.due(1.0), Some(120.0)); - fader.on_wheel(-1000.0, 1.0, range, 2.0); - assert_eq!(fader.due(3.0), Some(0.0)); - } -} diff --git a/crates/app/src/gesture.rs b/crates/app/src/gesture.rs new file mode 100644 index 0000000..d545626 --- /dev/null +++ b/crates/app/src/gesture.rs @@ -0,0 +1,198 @@ +//! What a fader or knob shows and sends between gestures: wheel steps collect until the wheel +//! goes quiet, then one write goes out; a sent value is shown until the speaker confirms it. +//! Times are seconds of the UI clock. + +use std::ops::RangeInclusive; + +pub const WHEEL_IDLE_SECONDS: f64 = 0.3; +/// egui may wake a frame about 16 ms before the requested time; within this much of the +/// deadline the wheel value counts as due instead of booking another tiny wait. +pub const DUE_SLACK_SECONDS: f64 = 0.05; +/// How long the fader keeps showing a sent value while the speaker has not confirmed it. +pub const SENT_HOLD_SECONDS: f64 = 2.0; +/// Fractions of a notch are counted in whole units so ten tenths make exactly one notch. +const NOTCH_RESOLUTION: f64 = 1000.0; + +#[derive(Clone, Copy, Debug, Default)] +pub struct GestureState { + pending: Option, + last_step: f64, + /// Wheel movement short of a whole notch, in 1/NOTCH_RESOLUTION notches. + partial_notch: i64, + /// The last value sent and when, shown until the speaker reports the level again. + sent: Option<(f64, f64)>, +} + +impl GestureState { + /// `notches` may be fractional (touchpads, high-resolution wheels); one `step` per whole + /// notch, the rest is kept for the next event. `range` is the allowed value range of the + /// controlled entry. + pub fn on_wheel( + &mut self, + notches: f64, + step: f64, + current: f64, + range: RangeInclusive, + now: f64, + ) { + self.partial_notch += (notches * NOTCH_RESOLUTION).round() as i64; + let resolution = NOTCH_RESOLUTION as i64; + let whole = self.partial_notch / resolution; + self.partial_notch %= resolution; + self.last_step = now; + if whole == 0 { + return; + } + let base = self.pending.unwrap_or(current); + let target = base + whole as f64 * step; + self.pending = Some(target.clamp(*range.start(), *range.end())); + } + + pub fn due(&mut self, now: f64) -> Option { + let idle = now - self.last_step >= WHEEL_IDLE_SECONDS - DUE_SLACK_SECONDS; + if self.pending.is_some() && idle { + return self.pending.take(); + } + None + } + + /// A drag takes over from the wheel: steps not yet sent would overwrite its value. + pub fn drag_started(&mut self) { + self.pending = None; + self.partial_notch = 0; + } + + pub fn sent(&mut self, value: f64, now: f64) { + self.drag_started(); + self.sent = Some((value, now)); + } + + /// The speaker reported the level; from now on its value is shown. + pub fn confirmed(&mut self) { + self.sent = None; + } + + /// When the shown value changes on its own (the wheel becomes due, a sent value stops + /// being held), so the UI can wake up for it. + pub fn deadline(&self) -> Option { + let wheel = self.pending.map(|_| self.last_step + WHEEL_IDLE_SECONDS); + let hold = self.sent.map(|(_, at)| at + SENT_HOLD_SECONDS); + wheel.into_iter().chain(hold).reduce(f64::min) + } + + pub fn shown(&self, device: f64, now: f64) -> f64 { + let held = self + .sent + .filter(|(_, at)| now - at < SENT_HOLD_SECONDS) + .map(|(value, _)| value); + self.pending.or(held).unwrap_or(device) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const LEVEL_STEP: f64 = 0.5; + const LEVEL_RANGE: RangeInclusive = 0.0..=120.0; + + #[test] + fn wheel_steps_debounce_to_one_value() { + let range = LEVEL_RANGE; + let mut fader = GestureState::default(); + fader.on_wheel(1.0, LEVEL_STEP, 80.0, range.clone(), 1.0); + fader.on_wheel(1.0, LEVEL_STEP, 80.0, range.clone(), 1.125); + fader.on_wheel(1.0, LEVEL_STEP, 80.0, range, 1.25); + assert_eq!(fader.shown(80.0, 0.0), 81.5); + assert_eq!(fader.due(1.375), None); + assert_eq!(fader.due(1.625), Some(81.5)); + assert_eq!(fader.due(2.0), None); + assert_eq!(fader.shown(80.0, 0.0), 80.0); + } + + #[test] + fn wheel_deadline_follows_the_last_step_and_ends_when_due() { + let range = LEVEL_RANGE; + let mut fader = GestureState::default(); + assert_eq!(fader.deadline(), None); + fader.on_wheel(1.0, LEVEL_STEP, 80.0, range.clone(), 1.0); + fader.on_wheel(1.0, LEVEL_STEP, 80.0, range, 1.5); + assert_eq!(fader.deadline(), Some(1.5 + WHEEL_IDLE_SECONDS)); + fader.due(2.0); + assert_eq!(fader.deadline(), None); + } + + #[test] + fn fractional_wheel_events_add_up_to_whole_notches() { + let range = LEVEL_RANGE; + let mut fader = GestureState::default(); + for _ in 0..10 { + fader.on_wheel(0.1, LEVEL_STEP, 80.0, range.clone(), 1.0); + } + assert_eq!(fader.shown(80.0, 0.0), 80.5); + fader.on_wheel(0.5, LEVEL_STEP, 80.0, range.clone(), 1.0); + assert_eq!(fader.shown(80.0, 0.0), 80.5); + fader.on_wheel(0.5, LEVEL_STEP, 80.0, range, 1.0); + assert_eq!(fader.shown(80.0, 0.0), 81.0); + } + + #[test] + fn the_wheel_is_due_within_the_slack_before_its_deadline() { + let range = LEVEL_RANGE; + let mut fader = GestureState::default(); + fader.on_wheel(1.0, LEVEL_STEP, 80.0, range, 1.0); + assert_eq!(fader.due(1.125), None); + assert_eq!(fader.due(1.25), Some(80.5)); + } + + #[test] + fn a_drag_drops_wheel_steps_not_yet_sent() { + let range = LEVEL_RANGE; + let mut fader = GestureState::default(); + fader.on_wheel(2.0, LEVEL_STEP, 80.0, range, 1.0); + fader.drag_started(); + assert_eq!(fader.due(5.0), None); + assert_eq!(fader.deadline(), None); + } + + #[test] + fn a_sent_value_is_shown_until_confirmed_or_two_seconds_passed() { + let range = LEVEL_RANGE; + let mut fader = GestureState::default(); + fader.on_wheel(1.0, LEVEL_STEP, 80.0, range, 1.0); + fader.sent(90.0, 1.0); + assert_eq!(fader.due(5.0), None); + assert_eq!(fader.shown(80.0, 2.5), 90.0); + assert_eq!(fader.deadline(), Some(1.0 + SENT_HOLD_SECONDS)); + assert_eq!(fader.shown(80.0, 3.0), 80.0); + fader.sent(95.0, 4.0); + fader.confirmed(); + assert_eq!(fader.shown(80.0, 4.0), 80.0); + assert_eq!(fader.deadline(), None); + } + + #[test] + fn wheel_clamps_at_the_range_ends() { + let range = LEVEL_RANGE; + let mut fader = GestureState::default(); + fader.on_wheel(10.0, LEVEL_STEP, 119.0, range.clone(), 0.0); + assert_eq!(fader.due(1.0), Some(120.0)); + fader.on_wheel(-1000.0, LEVEL_STEP, 1.0, range, 2.0); + assert_eq!(fader.due(3.0), Some(0.0)); + } + + #[test] + fn wheel_moves_one_step_per_notch() { + let mut knob = GestureState::default(); + knob.on_wheel(2.0, 1.0, 10.0, 0.0..=100.0, 0.0); + assert_eq!(knob.due(1.0), Some(12.0)); + knob.on_wheel(-1.0, 5.0, 12.0, 0.0..=100.0, 2.0); + assert_eq!(knob.due(3.0), Some(7.0)); + } + + #[test] + fn knob_shows_out_of_range_value_without_writing() { + let knob = GestureState::default(); + assert_eq!(knob.shown(6000.0, 0.0), 6000.0); + } +} diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index 09a85d3..fc36cb3 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -2,6 +2,7 @@ pub mod all_values; pub mod app; pub mod display; pub mod fader; +pub mod gesture; pub mod meter; pub mod paths; pub mod scan_choice; diff --git a/crates/app/src/state.rs b/crates/app/src/state.rs index 6784940..30a9b36 100644 --- a/crates/app/src/state.rs +++ b/crates/app/src/state.rs @@ -13,7 +13,7 @@ use neumann_control_core::ssc::wire::SscPath; use serde_json::Value; use crate::display::capped; -use crate::fader::FaderState; +use crate::gesture::GestureState; use crate::meter::{MeterState, METER_FLOOR_DB}; use crate::paths; use crate::texts; @@ -72,7 +72,7 @@ pub struct SpeakerState { pub expanded: bool, pub input_meters: Vec, pub output_meters: Vec, - pub fader: FaderState, + pub fader: GestureState, pub all_values: AllValuesState, /// Case-insensitive part of a path the "Alle Werte" table is narrowed to. pub all_values_filter: String, @@ -95,7 +95,7 @@ impl SpeakerState { expanded: false, input_meters: Vec::new(), output_meters: Vec::new(), - fader: FaderState::default(), + fader: GestureState::default(), all_values: AllValuesState::Idle, all_values_filter: String::new(), response_time: None, diff --git a/crates/app/src/views/fader.rs b/crates/app/src/views/fader.rs index a31956e..1d32d8e 100644 --- a/crates/app/src/views/fader.rs +++ b/crates/app/src/views/fader.rs @@ -58,7 +58,9 @@ pub fn show( // The cards sit in a scroll area, which would scroll along with the fader. ui.input_mut(|input| input.smooth_scroll_delta = Vec2::ZERO); for notches in wheel_notches(ui) { - speaker.fader.on_wheel(notches, device, range.clone(), now); + speaker + .fader + .on_wheel(notches, WHEEL_STEP_DB, device, range.clone(), now); } value = speaker.fader.shown(device, now); } From 6ab85d0b06d32b2770277b56fa5e37b4686f0c05 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:32:34 +0200 Subject: [PATCH 068/460] Derive status LEDs and the average response time --- crates/app/src/lib.rs | 1 + crates/app/src/paths.rs | 4 + crates/app/src/status_leds.rs | 154 ++++++++++++++++++++++++++++++++++ 3 files changed, 159 insertions(+) create mode 100644 crates/app/src/status_leds.rs diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index fc36cb3..5eed75a 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -8,6 +8,7 @@ pub mod paths; pub mod scan_choice; pub mod spawner; pub mod state; +pub mod status_leds; pub mod targets; pub mod texts; pub mod ui_kit; diff --git a/crates/app/src/paths.rs b/crates/app/src/paths.rs index 8877781..678d3c5 100644 --- a/crates/app/src/paths.rs +++ b/crates/app/src/paths.rs @@ -14,6 +14,8 @@ pub const PRODUCT: &str = "device/identity/product"; pub const FIRMWARE_VERSION: &str = "device/identity/version"; pub const SERIAL: &str = "device/identity/serial"; pub const MAC_ADDRESSES: &str = "device/network/ether/macs"; +pub const WARNINGS: &str = "warnings"; +pub const CONTROL_MODE: &str = "ui/control_mode"; pub fn path(text: &str) -> SscPath { SscPath::parse(text).expect("constant path is valid") @@ -41,6 +43,8 @@ mod tests { PRODUCT, FIRMWARE_VERSION, SERIAL, + WARNINGS, + CONTROL_MODE, ] { assert!(catalog_entry(catalog, &path(text)).is_some(), "{text}"); } diff --git a/crates/app/src/status_leds.rs b/crates/app/src/status_leds.rs new file mode 100644 index 0000000..7ca216d --- /dev/null +++ b/crates/app/src/status_leds.rs @@ -0,0 +1,154 @@ +//! The four LEDs of a channel strip and the response time over all speakers, derived from +//! what the speakers reported. Standby has no LED: a speaker in standby does not answer and +//! shows as unreachable. + +use std::time::Duration; + +use serde_json::Value; + +use crate::paths; +use crate::state::{SpeakerState, Status}; + +/// What `warnings` holds while the speaker has nothing to report. +const NO_WARNING: &str = "NO_WARNING"; +const LOCAL_CONTROL_MODE: &str = "LOCAL"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ConnectionLed { + Connected, + Connecting, + Off, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct StatusLeds { + pub connection: ConnectionLed, + pub clip: bool, + pub warning: bool, + /// The rear switch is on LOCAL; the speaker refuses every write. + pub local: bool, +} + +pub fn status_leds(speaker: &SpeakerState) -> StatusLeds { + let connection = match speaker.status { + Status::Connected => ConnectionLed::Connected, + Status::Connecting => ConnectionLed::Connecting, + Status::Unreachable => ConnectionLed::Off, + }; + let reported = |path: &str| speaker.values.get(&paths::path(path)); + let clip = [paths::INPUT_CLIP, paths::OUTPUT_CLIP] + .into_iter() + .filter_map(reported) + .any(any_true); + let warning = reported(paths::WARNINGS).is_some_and(holds_a_warning); + let local = reported(paths::CONTROL_MODE).and_then(Value::as_str) == Some(LOCAL_CONTROL_MODE); + StatusLeds { + connection, + clip, + warning, + local, + } +} + +/// The mean of the connected speakers' response times; none while no speaker has one. +pub fn average_response_time<'a>( + speakers: impl Iterator, +) -> Option { + let times: Vec = speakers + .filter(|speaker| speaker.status == Status::Connected) + .filter_map(|speaker| speaker.response_time) + .collect(); + let count = u32::try_from(times.len()).ok().filter(|count| *count > 0)?; + Some(times.iter().sum::() / count) +} + +fn any_true(value: &Value) -> bool { + match value { + Value::Bool(flag) => *flag, + Value::Array(items) => items.iter().any(any_true), + _ => false, + } +} + +fn holds_a_warning(value: &Value) -> bool { + match value { + Value::String(text) => text != NO_WARNING, + Value::Array(items) => items.iter().any(holds_a_warning), + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use neumann_control_core::address::parse_address; + use neumann_control_core::speaker::{Event, Identity}; + use neumann_control_core::ssc::wire::SscPath; + use serde_json::json; + + use crate::state::SpeakerKey; + + const ADDRESS_TEXT: &str = "fe80::2a36:38ff:fe12:3456%7"; + const PORT: u16 = 45; + + fn connected() -> SpeakerState { + let address = parse_address(ADDRESS_TEXT, PORT).unwrap(); + let mut speaker = SpeakerState::new(SpeakerKey::Address(address), address); + speaker.apply( + Event::Connected(Identity { + product: "KH 120 II".to_owned(), + version: "1_7_3".to_owned(), + serial: "SIM0001234".to_owned(), + name: "Front L".to_owned(), + }), + 0.0, + ); + speaker + } + + fn report(speaker: &mut SpeakerState, path: &str, value: Value) { + let path = SscPath::parse(path).unwrap(); + speaker.apply(Event::Value { path, value }, 0.0); + } + + #[test] + fn leds_follow_connection_clip_warning_and_local() { + let mut speaker = connected(); + report(&mut speaker, paths::INPUT_CLIP, json!([false, false])); + report(&mut speaker, paths::WARNINGS, json!(["NO_WARNING"])); + report(&mut speaker, paths::CONTROL_MODE, json!("NETWORK")); + let quiet = status_leds(&speaker); + assert_eq!(quiet.connection, ConnectionLed::Connected); + assert!(!quiet.clip && !quiet.warning && !quiet.local); + + report( + &mut speaker, + paths::OUTPUT_CLIP, + json!([false, true, false]), + ); + report(&mut speaker, paths::WARNINGS, json!(["OVERTEMPERATURE"])); + report(&mut speaker, paths::CONTROL_MODE, json!("LOCAL")); + let lit = status_leds(&speaker); + assert!(lit.clip && lit.warning && lit.local); + + speaker.apply(Event::Unreachable("gone".to_owned()), 1.0); + assert_eq!(status_leds(&speaker).connection, ConnectionLed::Off); + } + + #[test] + fn average_ignores_unconnected_speakers() { + let mut first = connected(); + first.apply(Event::ResponseTime(Duration::from_millis(10)), 0.0); + let mut second = connected(); + second.apply(Event::ResponseTime(Duration::from_millis(30)), 0.0); + let mut gone = connected(); + gone.apply(Event::ResponseTime(Duration::from_millis(500)), 0.0); + gone.status = Status::Unreachable; + let speakers = [first, second, gone]; + assert_eq!( + average_response_time(speakers.iter()), + Some(Duration::from_millis(20)) + ); + assert_eq!(average_response_time([].iter()), None); + } +} From e4879258fbb4c074843898d9d3800ce0ebdfde0f Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:35:24 +0200 Subject: [PATCH 069/460] Show the dragged level live --- crates/app/src/views/card.rs | 9 ++++---- crates/app/src/views/fader.rs | 40 ++++++++++++++++++++++++++++++----- crates/app/tests/views.rs | 23 ++++++++++++++++++++ 3 files changed, 63 insertions(+), 9 deletions(-) diff --git a/crates/app/src/views/card.rs b/crates/app/src/views/card.rs index 8d11da4..b18f7b4 100644 --- a/crates/app/src/views/card.rs +++ b/crates/app/src/views/card.rs @@ -119,16 +119,17 @@ fn strip( ) { let path = paths::path(paths::OUTPUT_LEVEL); let device = speaker.values.get(&path).and_then(Value::as_f64); + let unit = paths::catalog_entry(catalog, &path) + .and_then(|entry| entry.unit.clone()) + .unwrap_or_default(); let shown = ui .horizontal(|ui| { - let shown = fader::show(ui, speaker, catalog, commands, device, output_level_range()); + let range = output_level_range(); + let shown = fader::show(ui, speaker, catalog, commands, device, range, &unit); meters(ui, speaker, tokens); shown }) .inner; - let unit = paths::catalog_entry(catalog, &path) - .and_then(|entry| entry.unit.clone()) - .unwrap_or_default(); let readout = device.map(|_| texts::readout(shown, &unit)); ui.label(readout.unwrap_or_default()); } diff --git a/crates/app/src/views/fader.rs b/crates/app/src/views/fader.rs index 1d32d8e..a6f0e6f 100644 --- a/crates/app/src/views/fader.rs +++ b/crates/app/src/views/fader.rs @@ -3,21 +3,29 @@ use std::ops::RangeInclusive; -use eframe::egui::{Event, Key, MouseWheelUnit, Response, Slider, SliderClamping, Ui, Vec2}; +use eframe::egui::{ + self, Align2, Area, Event, Frame, Key, MouseWheelUnit, Order, Response, Slider, SliderClamping, + Ui, Vec2, +}; use neumann_control_core::catalog::Catalog; use neumann_control_core::speaker::Command; use crate::fader::WHEEL_STEP_DB; use crate::paths; use crate::state::SpeakerState; +use crate::texts; use crate::writes::{self, Intent}; const DRAG_ABORTED_ID: &str = "fader_drag_aborted"; +const HANDLE_LABEL_ID: &str = "fader_handle_label"; +/// Space between the fader and the value shown beside its handle while dragging. +const HANDLE_LABEL_GAP: f32 = 6.0; /// The slider's length. pub const FADER_HEIGHT: f32 = 150.0; -/// The vertical level fader; returns the value it shows. +/// The vertical level fader; returns the value it shows. While dragging, the value also stands +/// beside the handle, where the eye is. pub fn show( ui: &mut Ui, speaker: &mut SpeakerState, @@ -25,6 +33,7 @@ pub fn show( commands: &mut Vec, device: Option, range: RangeInclusive, + unit: &str, ) -> f64 { let path = paths::path(paths::OUTPUT_LEVEL); let enabled = device.is_some() && writes::can_write(speaker, catalog, &path); @@ -57,12 +66,19 @@ pub fn show( if enabled && response.hovered() { // The cards sit in a scroll area, which would scroll along with the fader. ui.input_mut(|input| input.smooth_scroll_delta = Vec2::ZERO); - for notches in wheel_notches(ui) { + let wheel = wheel_notches(ui); + for notches in &wheel { speaker .fader - .on_wheel(notches, WHEEL_STEP_DB, device, range.clone(), now); + .on_wheel(*notches, WHEEL_STEP_DB, device, range.clone(), now); } - value = speaker.fader.shown(device, now); + // Only the wheel changes what is shown here; a drag keeps its own value. + if !wheel.is_empty() { + value = speaker.fader.shown(device, now); + } + } + if response.dragged() && !aborted { + handle_label(ui, &response, &texts::readout(value, unit)); } if let Some(due) = speaker.fader.due(now) { commands.extend(speaker.request(Intent::Level(due), catalog, now)); @@ -74,6 +90,20 @@ pub fn show( value } +fn handle_label(ui: &Ui, response: &Response, text: &str) { + let pointer = ui.input(|input| input.pointer.interact_pos()); + let y = pointer.map_or(response.rect.center().y, |position| position.y); + let anchor = egui::pos2(response.rect.right() + HANDLE_LABEL_GAP, y); + Area::new(response.id.with(HANDLE_LABEL_ID)) + .order(Order::Tooltip) + .fixed_pos(anchor) + .pivot(Align2::LEFT_CENTER) + .interactable(false) + .show(ui.ctx(), |ui| { + Frame::popup(ui.style()).show(ui, |ui| ui.label(text)); + }); +} + /// Escape ends egui's drag but leaves the button down, and the slider keeps following the /// pointer until it is released; none of that may reach the speaker. True from the Escape /// frame through the release frame. diff --git a/crates/app/tests/views.rs b/crates/app/tests/views.rs index 9311cda..c99f507 100644 --- a/crates/app/tests/views.rs +++ b/crates/app/tests/views.rs @@ -61,6 +61,29 @@ fn fader_drag_sends_once_on_release() { assert_eq!(shown, sets[0]); } +#[test] +fn fader_value_is_shown_at_the_handle_while_dragging() { + let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + let start = harness.get_by_role(Role::Slider).rect().center(); + harness.drag_at(start); + for frame in 1..=5 { + harness.hover_at(start + egui::vec2(0.0, 4.0 * frame as f32)); + harness.step(); + } + let dragged = harness + .get_by_role(Role::Slider) + .accesskit_node() + .numeric_value() + .unwrap(); + assert!(dragged < 100.0); + // Once under the fader, once at its handle. + assert_eq!(harness.query_all_by_label(&readout(dragged)).count(), 2); + harness.drop_at(start + egui::vec2(0.0, 20.0)); + harness.run_steps(2); + barrier(&simulator); + assert_eq!(harness.query_all_by_label(&readout(dragged)).count(), 1); +} + #[test] fn wheel_over_fader_sends_one_set_after_the_wheel_went_quiet() { let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); From 6473db8309726ddf3c666268657891d8a605acc3 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:59:03 +0200 Subject: [PATCH 070/460] Lay out speakers as channel strips with a device panel --- crates/app/src/app.rs | 113 +++++++--- crates/app/src/meter.rs | 21 +- crates/app/src/state.rs | 8 +- crates/app/src/texts.rs | 19 +- crates/app/src/views/card.rs | 225 ------------------- crates/app/src/views/details.rs | 79 +++++-- crates/app/src/views/details_fields.rs | 2 +- crates/app/src/views/fader.rs | 7 +- crates/app/src/views/knob.rs | 296 +++++++++++++++++++++++++ crates/app/src/views/meters.rs | 5 +- crates/app/src/views/mod.rs | 6 +- crates/app/src/views/panel.rs | 101 +++++++++ crates/app/src/views/strip.rs | 235 ++++++++++++++++++++ crates/app/tests/details.rs | 30 +-- crates/app/tests/speakers.rs | 15 +- crates/app/tests/support/mod.rs | 23 +- crates/app/tests/views.rs | 241 ++++++++++++++++---- 17 files changed, 1064 insertions(+), 362 deletions(-) delete mode 100644 crates/app/src/views/card.rs create mode 100644 crates/app/src/views/knob.rs create mode 100644 crates/app/src/views/panel.rs create mode 100644 crates/app/src/views/strip.rs diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index 2e13b69..463e99c 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -7,7 +7,7 @@ use std::sync::mpsc::{Receiver, TryRecvError}; use std::sync::Arc; use std::thread; -use eframe::egui::{self, Align, Context, Layout, ScrollArea, Ui, ViewportInfo}; +use eframe::egui::{self, Align, Context, Layout, Pos2, ScrollArea, Ui, ViewportInfo}; use neumann_control_core::address::parse_address; use neumann_control_core::catalog::Catalog; use neumann_control_core::discovery::{merge_found, Found}; @@ -17,7 +17,7 @@ use neumann_control_core::speaker::{Command, Event, Events, SpeakerHandle, Targe use crate::scan_choice::ScanChoice; use crate::spawner::{ScanResult, SpeakerSpawner}; -use crate::state::{AllValuesState, SpeakerKey, SpeakerState}; +use crate::state::{AllValuesState, SpeakerKey, SpeakerState, Status}; use crate::targets::{duplicate_cards, follow_moves, forget, remember_serial, targets}; use crate::texts; use crate::ui_kit::{fonts, theme}; @@ -25,9 +25,9 @@ use crate::views::add_address::{self, AddAddressAction, AddAddressForm}; use crate::views::remove_speaker::{self, RemoveAction}; use crate::views::scan_choice::{self as scan_choice_view, ScanChoiceAction}; use crate::views::toolbar::{self, ToolbarAction, ToolbarFacts}; -use crate::views::{all_values, card}; +use crate::views::{all_values, panel, strip}; -const CARDS_SCROLL_ID: &str = "cards"; +const DESK_SCROLL_ID: &str = "desk"; struct Speaker { state: SpeakerState, @@ -35,7 +35,15 @@ struct Speaker { events: Events, } -/// The card "Entfernen" was clicked on, while the confirmation is open. Key and address both, +/// The speaker whose device panel is open and where it opens. +struct OpenPanel { + key: SpeakerKey, + anchor: Pos2, + /// The opening click is still in this frame's input and must not close the panel. + opened_now: bool, +} + +/// The speaker "Entfernen" was clicked for, while the confirmation is open. Key and address both, /// because connecting can change the key meanwhile. struct Removal { key: SpeakerKey, @@ -56,6 +64,7 @@ pub struct NeumannApp { notice: Option<&'static str>, add_address: Option, removal: Option, + panel: Option, window: Option, } @@ -83,6 +92,7 @@ impl NeumannApp { notice: None, add_address: None, removal: None, + panel: None, }; app.sync_targets(); app @@ -280,7 +290,8 @@ impl NeumannApp { ToolbarAction::AddAddress => self.add_address = Some(AddAddressForm::default()), ToolbarAction::None => {} } - egui::CentralPanel::default().show(ui, |ui| self.show_cards(ui)); + egui::CentralPanel::default().show(ui, |ui| self.show_desk(ui)); + self.show_panel(ui.ctx()); self.show_dialog(ui); self.show_scan_choice(ui); self.show_removal(ui); @@ -299,41 +310,79 @@ impl NeumannApp { } } - fn show_cards(&mut self, ui: &mut Ui) { + fn show_desk(&mut self, ui: &mut Ui) { if self.speakers.is_empty() { ui.weak(texts::NO_SPEAKERS); return; } let catalog = Catalog::embedded(); - let mut removal = None; - ScrollArea::vertical() - .id_salt(CARDS_SCROLL_ID) + let desk = self.settings.desk; + let mut opened = None; + ScrollArea::both() + .id_salt(DESK_SCROLL_ID) .auto_shrink([false, false]) .show(ui, |ui| { - ui.with_layout( - Layout::left_to_right(Align::Min).with_main_wrap(true), - |ui| { - for speaker in &mut self.speakers { - let key = speaker.state.key.clone(); - let response = ui - .push_id(key, |ui| card::show(ui, &mut speaker.state, catalog)) - .inner; - for command in response.commands { - speaker.handle.send(command); - } - if response.remove_clicked { - removal = Some(Removal { - key: speaker.state.key.clone(), - address: speaker.state.address, - name: speaker.state.display_name(), - }); - } + ui.with_layout(Layout::left_to_right(Align::Min), |ui| { + for speaker in &mut self.speakers { + let key = speaker.state.key.clone(); + let response = ui + .push_id(&key, |ui| { + strip::show(ui, &mut speaker.state, catalog, &desk) + }) + .inner; + for command in response.commands { + speaker.handle.send(command); } - }, - ); + if let Some(anchor) = response.open_panel { + opened = Some(OpenPanel { + key, + anchor, + opened_now: true, + }); + } + } + }); + }); + if opened.is_some() { + self.panel = opened; + } + } + + /// The open device panel; it closes when its speaker is gone or unreachable. + fn show_panel(&mut self, context: &Context) { + let Some(open) = &mut self.panel else { + return; + }; + let speaker = self + .speakers + .iter_mut() + .find(|speaker| speaker.state.key == open.key) + .filter(|speaker| speaker.state.status != Status::Unreachable); + let Some(speaker) = speaker else { + self.panel = None; + return; + }; + let catalog = Catalog::embedded(); + let response = panel::show( + context, + &mut speaker.state, + catalog, + open.anchor, + open.opened_now, + ); + open.opened_now = false; + for command in response.commands { + speaker.handle.send(command); + } + if response.remove { + self.removal = Some(Removal { + key: speaker.state.key.clone(), + address: speaker.state.address, + name: speaker.state.display_name(), }); - if removal.is_some() { - self.removal = removal; + } + if response.close || response.remove { + self.panel = None; } } diff --git a/crates/app/src/meter.rs b/crates/app/src/meter.rs index 66d193d..056c18f 100644 --- a/crates/app/src/meter.rs +++ b/crates/app/src/meter.rs @@ -6,9 +6,9 @@ pub const METER_FLOOR_DB: f64 = -120.0; pub const METER_CEILING_DB: f64 = 6.0; /// Maps a dB value linearly from the floor..ceiling range to 0..1; the device reports silence -/// as -160, which lands below the floor and gives 0. -pub fn fraction(db: f64) -> f32 { - let position = (db - METER_FLOOR_DB) / (METER_CEILING_DB - METER_FLOOR_DB); +/// as -160, which lands below any floor and gives 0. `floor` comes from the desk settings. +pub fn fraction(db: f64, floor: f64) -> f32 { + let position = (db - floor) / (METER_CEILING_DB - floor); position.clamp(0.0, 1.0) as f32 } @@ -104,9 +104,9 @@ mod tests { #[test] fn meter_fraction_clamps_below_floor_and_above_ceiling() { - assert_eq!(fraction(-160.0), 0.0); - assert_eq!(fraction(10.0), 1.0); - assert_eq!(fraction(-57.0), 0.5); + assert_eq!(fraction(-160.0, METER_FLOOR_DB), 0.0); + assert_eq!(fraction(10.0, METER_FLOOR_DB), 1.0); + assert_eq!(fraction(-57.0, METER_FLOOR_DB), 0.5); } #[test] @@ -134,6 +134,13 @@ mod tests { #[test] fn quiet_room_level_is_visible() { // Measured on a KH 120 II at quiet room volume: the input meter reads about -90 dB. - assert!(fraction(-90.0) > 0.2); + assert!(fraction(-90.0, METER_FLOOR_DB) > 0.2); + } + + #[test] + fn meter_floor_setting_changes_the_scale() { + assert_eq!(fraction(-60.0, -60.0), 0.0); + assert_eq!(fraction(-27.0, -60.0), 0.5); + assert!(fraction(-60.0, -120.0) > 0.4); } } diff --git a/crates/app/src/state.rs b/crates/app/src/state.rs index 30a9b36..fbb30a9 100644 --- a/crates/app/src/state.rs +++ b/crates/app/src/state.rs @@ -69,10 +69,11 @@ pub struct SpeakerState { pub status: Status, pub message: Option, message_source: MessageSource, - pub expanded: bool, pub input_meters: Vec, pub output_meters: Vec, pub fader: GestureState, + /// One per knob the user turned, so a sent value stays shown until the speaker reports it. + pub knobs: BTreeMap, pub all_values: AllValuesState, /// Case-insensitive part of a path the "Alle Werte" table is narrowed to. pub all_values_filter: String, @@ -92,10 +93,10 @@ impl SpeakerState { status: Status::Connecting, message: None, message_source: MessageSource::Connection, - expanded: false, input_meters: Vec::new(), output_meters: Vec::new(), fader: GestureState::default(), + knobs: BTreeMap::new(), all_values: AllValuesState::Idle, all_values_filter: String::new(), response_time: None, @@ -166,6 +167,9 @@ impl SpeakerState { if path == paths::path(paths::OUTPUT_LEVEL) { self.fader.confirmed(); } + if let Some(knob) = self.knobs.get_mut(&path) { + knob.confirmed(); + } self.absent.remove(&path); self.feed_meters(&path, &value, now); self.values.insert(path, value); diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 67f447e..3089300 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -17,12 +17,18 @@ pub const NO_SPEAKERS: &str = pub const MUTE: &str = "Stumm"; pub const IDENTIFY: &str = "Ident."; -pub const MORE: &str = "Mehr"; -pub const LESS: &str = "Weniger"; pub const CONNECTING: &str = "verbinde …"; pub const CONNECTED: &str = "verbunden"; pub const LEVEL: &str = "Pegel"; pub const CLIP: &str = "Übersteuert"; +pub const LED_CONNECTION: &str = "Verbindung"; +pub const LED_CLIP: &str = "Übersteuerung"; +pub const LED_WARNING: &str = "Warnung des Geräts"; +pub const LED_LOCAL: &str = "LOCAL: Schalter hinten, Schreiben gesperrt"; +pub const KNOB_DELAY: &str = "Delay"; +pub const KNOB_STANDBY: &str = "Standby"; +pub const CLOSE: &str = "Schließen"; +pub const PANEL_TITLE: &str = "Einstellungen"; pub const INPUT_METERS: &str = "Eingangspegel"; pub const OUTPUT_METERS: &str = "Ausgangspegel"; @@ -88,6 +94,15 @@ pub fn meter_label(group: &str, channel: usize) -> String { format!("{group} {channel}") } +/// The panel's knob beside a number field, named apart from the field. +pub fn knob_label(label: &str) -> String { + format!("{label} (Regler)") +} + +pub fn response_time(milliseconds: u128) -> String { + format!("{milliseconds} ms") +} + pub fn readout(value: f64, unit: &str) -> String { format!("{value:.1} {unit}") } diff --git a/crates/app/src/views/card.rs b/crates/app/src/views/card.rs deleted file mode 100644 index b18f7b4..0000000 --- a/crates/app/src/views/card.rs +++ /dev/null @@ -1,225 +0,0 @@ -//! One speaker as a channel strip: name, status, level fader with input meters, mute and -//! identify, the "Mehr" toggle with the settings it opens and "Entfernen", and a status line. -//! The card only reports what was clicked; `writes` decides whether it becomes a command. - -use eframe::egui::{self, Button, CornerRadius, Label, RichText, Sense, Stroke, Ui, Vec2}; -use neumann_control_core::catalog::Catalog; -use neumann_control_core::mask::mask_serial; -use neumann_control_core::speaker::Command; -use serde_json::Value; - -use super::fader::{self, FADER_HEIGHT}; -use super::{details, meters}; -use crate::display::firmware_version; -use crate::fader::output_level_range; -use crate::paths; -use crate::state::{SpeakerState, Status}; -use crate::texts; -use crate::ui_kit::theme::Tokens; -use crate::writes::{self, Intent, Switch}; - -const CARD_WIDTH: f32 = 170.0; -const CARD_MARGIN: i8 = 8; -const CARD_RADIUS: u8 = 6; -const BORDER_WIDTH: f32 = 1.0; -const STATUS_DOT_RADIUS: f32 = 4.0; -const UNREACHABLE_OPACITY: f32 = 0.55; -const DETAILS_ID: &str = "details"; -const METER_GROUP_GAP: f32 = 6.0; - -pub struct CardResponse { - /// The writes the user asked for that the write policy allowed. - pub commands: Vec, - pub remove_clicked: bool, -} - -pub fn show(ui: &mut Ui, speaker: &mut SpeakerState, catalog: &Catalog) -> CardResponse { - let tokens = Tokens::current(ui.ctx()); - let mut commands = Vec::new(); - // The parent lays cards out in a wrapping row; the content must not inherit that flow. - let remove_clicked = ui - .vertical(|ui| card_frame(ui, speaker, catalog, &mut commands, &tokens)) - .inner; - CardResponse { - commands, - remove_clicked, - } -} - -/// True when "Entfernen" was clicked. -fn card_frame( - ui: &mut Ui, - speaker: &mut SpeakerState, - catalog: &Catalog, - commands: &mut Vec, - tokens: &Tokens, -) -> bool { - let inner_width = CARD_WIDTH - 2.0 * (f32::from(CARD_MARGIN) + BORDER_WIDTH); - egui::Frame::new() - .fill(tokens.card) - .stroke(Stroke::new(BORDER_WIDTH, tokens.border)) - .corner_radius(CornerRadius::same(CARD_RADIUS)) - .inner_margin(CARD_MARGIN) - .show(ui, |ui| { - ui.set_width(inner_width); - if speaker.status == Status::Unreachable { - ui.multiply_opacity(UNREACHABLE_OPACITY); - } - header(ui, speaker, tokens); - strip(ui, speaker, catalog, commands, tokens); - buttons(ui, speaker, catalog, commands); - more_toggle(ui, speaker); - // The scope exists collapsed too, so opening it never renumbers the widgets after it. - let expanded = ui.push_id(DETAILS_ID, |ui| { - if !speaker.expanded { - return (Vec::new(), false); - } - let commands = details::show(ui, speaker, catalog); - (commands, ui.button(texts::REMOVE).clicked()) - }); - let (expanded_commands, remove_clicked) = expanded.inner; - commands.extend(expanded_commands); - status_line(ui, speaker, tokens); - remove_clicked - }) - .inner -} - -fn header(ui: &mut Ui, speaker: &SpeakerState, tokens: &Tokens) { - let name = speaker.display_name(); - ui.horizontal(|ui| { - status_dot(ui, speaker.status, tokens); - ui.add(Label::new(RichText::new(&name).strong()).truncate()); - }); - let model = speaker.model().filter(|model| *model != name); - if let Some(model) = model { - ui.add(Label::new(RichText::new(model).weak()).truncate()); - } -} - -fn status_dot(ui: &mut Ui, status: Status, tokens: &Tokens) { - let (color, text) = match status { - Status::Connected => (tokens.connected, texts::CONNECTED), - Status::Connecting => (tokens.connecting, texts::CONNECTING), - Status::Unreachable => (tokens.unreachable, texts::UNREACHABLE), - }; - let size = Vec2::splat(2.0 * STATUS_DOT_RADIUS); - let (rect, response) = ui.allocate_exact_size(size, Sense::hover()); - ui.painter() - .circle_filled(rect.center(), STATUS_DOT_RADIUS, color); - response.on_hover_text(text); -} - -fn strip( - ui: &mut Ui, - speaker: &mut SpeakerState, - catalog: &Catalog, - commands: &mut Vec, - tokens: &Tokens, -) { - let path = paths::path(paths::OUTPUT_LEVEL); - let device = speaker.values.get(&path).and_then(Value::as_f64); - let unit = paths::catalog_entry(catalog, &path) - .and_then(|entry| entry.unit.clone()) - .unwrap_or_default(); - let shown = ui - .horizontal(|ui| { - let range = output_level_range(); - let shown = fader::show(ui, speaker, catalog, commands, device, range, &unit); - meters(ui, speaker, tokens); - shown - }) - .inner; - let readout = device.map(|_| texts::readout(shown, &unit)); - ui.label(readout.unwrap_or_default()); -} - -/// Input meters, then the output meters of models that report them (KH 750), set apart. -fn meters(ui: &mut Ui, speaker: &SpeakerState, tokens: &Tokens) { - let input_clips = speaker.values.get(&paths::path(paths::INPUT_CLIP)); - let input = &speaker.input_meters; - meters::show( - ui, - texts::INPUT_METERS, - input, - input_clips, - FADER_HEIGHT, - tokens, - ); - if speaker.output_meters.is_empty() { - return; - } - ui.add_space(METER_GROUP_GAP); - let output_clips = speaker.values.get(&paths::path(paths::OUTPUT_CLIP)); - let output = &speaker.output_meters; - meters::show( - ui, - texts::OUTPUT_METERS, - output, - output_clips, - FADER_HEIGHT, - tokens, - ); -} - -fn buttons( - ui: &mut Ui, - speaker: &mut SpeakerState, - catalog: &Catalog, - commands: &mut Vec, -) { - ui.horizontal(|ui| { - toggle(ui, speaker, catalog, commands, Switch::Mute, texts::MUTE); - toggle( - ui, - speaker, - catalog, - commands, - Switch::Identify, - texts::IDENTIFY, - ); - }); -} - -/// A button showing a switch value of the speaker; a click asks for the opposite. -fn toggle( - ui: &mut Ui, - speaker: &mut SpeakerState, - catalog: &Catalog, - commands: &mut Vec, - switch: Switch, - text: &str, -) { - let path = switch.path(); - let current = speaker.values.get(&path).and_then(Value::as_bool); - let enabled = current.is_some() && writes::can_write(speaker, catalog, &path); - let button = Button::new(text).selected(current.unwrap_or(false)); - if ui.add_enabled(enabled, button).clicked() { - let now = ui.input(|input| input.time); - commands.extend(speaker.request(Intent::Toggle(switch), catalog, now)); - } -} - -fn more_toggle(ui: &mut Ui, speaker: &mut SpeakerState) { - let text = if speaker.expanded { - texts::LESS - } else { - texts::MORE - }; - if ui.button(text).clicked() { - speaker.expanded = !speaker.expanded; - } -} - -fn status_line(ui: &mut Ui, speaker: &SpeakerState, tokens: &Tokens) { - if let Some(identity) = &speaker.identity { - let line = texts::status_line( - &firmware_version(&identity.version), - &mask_serial(&identity.serial), - ); - ui.add(Label::new(RichText::new(line).small().weak()).wrap()); - } - if let Some(message) = &speaker.message { - ui.add(Label::new(RichText::new(message).small().color(tokens.danger)).wrap()); - } -} diff --git a/crates/app/src/views/details.rs b/crates/app/src/views/details.rs index f7714f5..eb529d5 100644 --- a/crates/app/src/views/details.rs +++ b/crates/app/src/views/details.rs @@ -1,19 +1,26 @@ -//! The card's "Mehr" area: every catalog entry for the speaker's model, in the catalog's group -//! order. Entries the model may write get a control, the rest a greyed value; what the card -//! itself shows and what the speaker does not have are left out. +//! The device panel's settings: every catalog entry for the speaker's model, in the catalog's +//! group order. Entries the model may write get a control (numbers also a knob), the rest a +//! greyed value; what the strip or the panel header shows and what the speaker does not have +//! are left out. use eframe::egui::{Button, RichText, Ui}; use neumann_control_core::catalog::{Catalog, Entry, Group, Kind}; use neumann_control_core::speaker::Command; use super::details_fields::Fields; +use super::knob; use crate::display::shown_value; use crate::paths; use crate::state::{AllValuesState, SpeakerState, Status}; use crate::texts; -/// Drawn by the card itself: level, mute, identify, input meters, model, firmware and serial. -const SHOWN_ON_CARD: &[&str] = &[ +const KNOB_DIAMETER: f32 = 30.0; +const COLUMNS: usize = 2; +const GROUP_GAP: f32 = 8.0; + +/// Drawn by the strip (level, mute, identify, meters) or the panel header (model, firmware, +/// serial). +const SHOWN_ELSEWHERE: &[&str] = &[ paths::OUTPUT_LEVEL, paths::MUTE, paths::IDENTIFY, @@ -26,7 +33,6 @@ const SHOWN_ON_CARD: &[&str] = &[ /// Draws the area and returns the writes the user asked for that the write policy allowed. pub fn show(ui: &mut Ui, speaker: &mut SpeakerState, catalog: &Catalog) -> Vec { - // The card's own layout wraps sideways; the rows of this area stack. ui.vertical(|ui| rows(ui, speaker, catalog)).inner } @@ -39,23 +45,48 @@ fn rows(ui: &mut Ui, speaker: &mut SpeakerState, catalog: &Catalog) -> Vec = Vec<(Group, Vec<&'a Entry>)>; + +/// Groups in catalog order, the first ones left until about half the rows are placed. +fn split_in_columns(groups: Groups<'_>) -> (Groups<'_>, Groups<'_>) { + let total: usize = groups.iter().map(|(_, entries)| entries.len()).sum(); + let mut placed = 0; + let mut left = Vec::new(); + let mut right = Vec::new(); + for (group, entries) in groups { + let fits_left = right.is_empty() && 2 * placed < total; + placed += entries.len(); + if fits_left { + left.push((group, entries)); + } else { + right.push((group, entries)); + } + } + (left, right) +} + /// The entries to show, grouped in the order the catalog first names each group. -fn groups<'a>(entries: Vec<&'a Entry>, speaker: &SpeakerState) -> Vec<(Group, Vec<&'a Entry>)> { - let mut groups: Vec<(Group, Vec<&Entry>)> = Vec::new(); +fn groups<'a>(entries: Vec<&'a Entry>, speaker: &SpeakerState) -> Groups<'a> { + let mut groups: Groups = Vec::new(); for entry in entries.into_iter().filter(|entry| is_shown(entry, speaker)) { match groups.iter_mut().find(|(group, _)| *group == entry.group) { Some((_, members)) => members.push(entry), @@ -65,10 +96,10 @@ fn groups<'a>(entries: Vec<&'a Entry>, speaker: &SpeakerState) -> Vec<(Group, Ve groups } -/// Meters sit on the card and clip lists light their lamps, so neither gets a row here. +/// Meters sit on the strip and clip lists light their lamps, so neither gets a row here. fn is_shown(entry: &Entry, speaker: &SpeakerState) -> bool { let path = entry.path.to_string(); - let on_card = SHOWN_ON_CARD.contains(&path.as_str()); + let on_card = SHOWN_ELSEWHERE.contains(&path.as_str()); let absent = speaker.absent.contains(&entry.path); let drawable = !matches!(entry.kind, Kind::Clip | Kind::Meter); drawable && !on_card && !absent @@ -81,7 +112,17 @@ fn row(ui: &mut Ui, fields: &mut Fields, entry: &Entry) { return; } match entry.kind { - Kind::Number => fields.number(ui, entry), + Kind::Number => { + ui.horizontal(|ui| { + let catalog = fields.catalog; + let label = texts::knob_label(&entry.label); + let speaker = &mut *fields.speaker; + let sent = + knob::for_entry(ui, speaker, catalog, entry, &label, false, KNOB_DIAMETER); + fields.commands.extend(sent); + fields.number(ui, entry); + }); + } Kind::Switch => fields.switch(ui, entry), Kind::Select => fields.select(ui, entry), Kind::Text => fields.text(ui, entry), diff --git a/crates/app/src/views/details_fields.rs b/crates/app/src/views/details_fields.rs index 9bb73f6..829b288 100644 --- a/crates/app/src/views/details_fields.rs +++ b/crates/app/src/views/details_fields.rs @@ -185,7 +185,7 @@ impl Fields<'_> { let label = ui.label(&entry.label); let field = TextEdit::singleline(&mut draft.text) .id(field_id) - .desired_width(f32::INFINITY); + .desired_width(ui.available_width()); let field = ui.add_enabled(enabled, field).labelled_by(label.id); if field.changed() { draft.edited = true; diff --git a/crates/app/src/views/fader.rs b/crates/app/src/views/fader.rs index a6f0e6f..d3a5fb3 100644 --- a/crates/app/src/views/fader.rs +++ b/crates/app/src/views/fader.rs @@ -5,7 +5,7 @@ use std::ops::RangeInclusive; use eframe::egui::{ self, Align2, Area, Event, Frame, Key, MouseWheelUnit, Order, Response, Slider, SliderClamping, - Ui, Vec2, + Ui, Vec2, WidgetInfo, }; use neumann_control_core::catalog::Catalog; use neumann_control_core::speaker::Command; @@ -49,6 +49,7 @@ pub fn show( // value the speaker does not have. .clamping(SliderClamping::Edits); let response = ui.add_enabled(enabled, slider); + response.widget_info(|| WidgetInfo::slider(enabled, value, texts::LEVEL)); if response.drag_started() { speaker.fader.drag_started(); } @@ -107,7 +108,7 @@ fn handle_label(ui: &Ui, response: &Response, text: &str) { /// Escape ends egui's drag but leaves the button down, and the slider keeps following the /// pointer until it is released; none of that may reach the speaker. True from the Escape /// frame through the release frame. -fn drag_aborted(ui: &Ui, response: &Response) -> bool { +pub(super) fn drag_aborted(ui: &Ui, response: &Response) -> bool { let id = response.id.with(DRAG_ABORTED_ID); let was_aborted = ui.data(|data| data.get_temp::(id)).unwrap_or(false); let escape = ui.input(|input| input.key_pressed(Key::Escape)); @@ -123,7 +124,7 @@ const POINTS_PER_NOTCH: f32 = 40.0; const NOTCHES_PER_PAGE: f32 = 10.0; /// Each wheel event of this frame in notches (lines), upwards positive. -fn wheel_notches(ui: &Ui) -> Vec { +pub(super) fn wheel_notches(ui: &Ui) -> Vec { ui.input(|input| { input .events diff --git a/crates/app/src/views/knob.rs b/crates/app/src/views/knob.rs new file mode 100644 index 0000000..fc45095 --- /dev/null +++ b/crates/app/src/views/knob.rs @@ -0,0 +1,296 @@ +//! A rotary knob for a catalog number: vertical drag, mouse wheel, double-click for exact entry. +//! It writes once at the end of a gesture and shows a device value outside its range as is. + +use std::f32::consts::PI; +use std::ops::RangeInclusive; + +use eframe::egui::{ + pos2, vec2, Id, Key, Label, RichText, Sense, Shape, Stroke, TextEdit, Ui, WidgetInfo, +}; + +use neumann_control_core::catalog::{Catalog, Entry}; +use neumann_control_core::speaker::Command; +use serde_json::Value; + +use super::fader::{drag_aborted, wheel_notches}; +use crate::display::shown_value; +use crate::gesture::GestureState; +use crate::state::SpeakerState; +use crate::ui_kit::theme::Tokens; +use crate::writes::{self, Intent}; + +/// Points of vertical drag for the whole range; Shift makes it this many times finer. +const FULL_RANGE_DRAG_POINTS: f64 = 200.0; +const FINE_DIVISOR: f64 = 10.0; +/// Wheel notches for the whole range, so wide ranges (delay in samples) stay usable. +const FULL_RANGE_NOTCHES: f64 = 100.0; +/// The ring leaves a gap at the bottom, from -135° to +135° around the top. +const SWEEP_RADIANS: f32 = 1.5 * PI; +const RING_WIDTH: f32 = 2.0; +const INDICATOR_WIDTH: f32 = 2.0; +const ARC_SEGMENTS: usize = 32; +const DRAG_VALUE_ID: &str = "knob_drag_value"; +const EDIT_TEXT_ID: &str = "knob_edit_text"; +const EDIT_WIDTH: f32 = 56.0; +/// Grid for entries without a catalog step. +const UNSTEPPED_DIVISIONS: f64 = 1000.0; + +/// A knob for a catalog number of the speaker; returns the write the policy allowed. +pub fn for_entry( + ui: &mut Ui, + speaker: &mut SpeakerState, + catalog: &Catalog, + entry: &Entry, + label: &str, + caption: bool, + diameter: f32, +) -> Option { + let product = speaker.product().to_owned(); + let minimum = entry.min?; + let maximum = entry.max_for(&product, &catalog.classes)?; + let reported = speaker.values.get(&entry.path).cloned(); + let step = entry + .step + .unwrap_or((maximum - minimum) / UNSTEPPED_DIVISIONS); + let format = |value: f64| shown_value(entry, &Value::from(value)); + let knob = Knob { + label, + caption, + range: minimum..=maximum, + step, + device: reported.as_ref().and_then(Value::as_f64), + enabled: writes::can_write(speaker, catalog, &entry.path), + diameter, + format: &format, + }; + let mut state = speaker.knobs.remove(&entry.path).unwrap_or_default(); + let id = ui.id().with(entry.path.to_string()); + let sent = show(ui, id, &knob, &mut state); + speaker.knobs.insert(entry.path.clone(), state); + let value = writes::number_value(entry, reported.as_ref(), sent?); + let now = ui.input(|input| input.time); + let path = entry.path.clone(); + speaker.request(Intent::Write { path, value }, catalog, now) +} + +pub struct Knob<'a> { + /// Names the knob for screen readers and its tooltip. + pub label: &'a str, + /// Whether the label also stands under the knob. + pub caption: bool, + pub range: RangeInclusive, + /// The catalog step; values sent are on this grid. + pub step: f64, + pub device: Option, + pub enabled: bool, + pub diameter: f32, + /// Formats a value for the line under the knob. + pub format: &'a dyn Fn(f64) -> String, +} + +/// Draws the knob; returns the value to send when a gesture ended. +pub fn show(ui: &mut Ui, id: Id, knob: &Knob, state: &mut GestureState) -> Option { + let tokens = Tokens::current(ui.ctx()); + let now = ui.input(|input| input.time); + let device = knob.device.unwrap_or(*knob.range.start()); + let enabled = knob.enabled && knob.device.is_some(); + let mut sent = None; + let response = ui + .vertical_centered(|ui| { + let size = vec2(knob.diameter, knob.diameter); + let sense = if enabled { + Sense::click_and_drag() + } else { + Sense::hover() + }; + let (rect, response) = ui.allocate_exact_size(size, sense); + let drag_id = id.with(DRAG_VALUE_ID); + let mut value = state.shown(device, now); + if response.drag_started() { + state.drag_started(); + ui.data_mut(|data| data.insert_temp(drag_id, value)); + } + let aborted = drag_aborted(ui, &response); + if response.dragged() && !aborted { + let held = ui + .data(|data| data.get_temp::(drag_id)) + .unwrap_or(value); + let fine = ui.input(|input| input.modifiers.shift); + let moved = drag_change(&knob.range, -f64::from(response.drag_delta().y), fine); + value = (held + moved).clamp(*knob.range.start(), *knob.range.end()); + ui.data_mut(|data| data.insert_temp(drag_id, value)); + } + if response.drag_stopped() && !aborted { + let dragged = ui.data(|data| data.get_temp::(drag_id)); + sent = dragged.map(|value| on_grid(value, knob.step)); + } + if response.drag_stopped() { + ui.data_mut(|data| data.remove::(drag_id)); + } + if enabled && response.hovered() { + let wheel = wheel_notches(ui); + let step = wheel_step(&knob.range, knob.step); + for notches in &wheel { + state.on_wheel(*notches, step, device, knob.range.clone(), now); + } + if !wheel.is_empty() { + value = state.shown(device, now); + } + } + if let Some(due) = state.due(now) { + sent = Some(due); + } + if let Some(deadline) = state.deadline() { + ui.ctx() + .request_repaint_after_secs((deadline - now).max(0.0) as f32); + } + paint(ui, rect, knob, value, enabled, &tokens); + let label = knob.label.to_owned(); + response.widget_info(|| WidgetInfo::slider(enabled, value, &label)); + if knob.caption { + ui.add(Label::new(RichText::new(knob.label).small().weak())); + } + let edited = exact_entry(ui, id, &response, knob, value); + sent = sent.or(edited); + if exact_entry_open(ui, id) { + return response; + } + ui.add(Label::new( + RichText::new((knob.format)(value)).small().monospace(), + )); + response + }) + .inner; + response.on_hover_text(knob.label); + if let Some(value) = sent { + state.sent(value, now); + } + sent +} + +/// The value change for a vertical drag of `points` (upwards positive). +pub fn drag_change(range: &RangeInclusive, points: f64, fine: bool) -> f64 { + let span = range.end() - range.start(); + let per_point = span / FULL_RANGE_DRAG_POINTS; + let divisor = if fine { FINE_DIVISOR } else { 1.0 }; + points * per_point / divisor +} + +/// One wheel notch: a whole number of catalog steps, at least one, about a hundredth of the +/// range. +pub fn wheel_step(range: &RangeInclusive, step: f64) -> f64 { + let span = range.end() - range.start(); + let steps = (span / FULL_RANGE_NOTCHES / step).round().max(1.0); + steps * step +} + +pub fn on_grid(value: f64, step: f64) -> f64 { + (value / step).round() * step +} + +/// Double-click opens a text field under the knob; Enter sends, Escape or leaving cancels. +fn exact_entry( + ui: &mut Ui, + id: Id, + response: &eframe::egui::Response, + knob: &Knob, + value: f64, +) -> Option { + let text_id = id.with(EDIT_TEXT_ID); + if response.double_clicked() { + ui.data_mut(|data| data.insert_temp(text_id, value.to_string())); + ui.memory_mut(|memory| memory.request_focus(text_id)); + } + let mut text = ui.data(|data| data.get_temp::(text_id))?; + let field = ui.add( + TextEdit::singleline(&mut text) + .id(text_id) + .desired_width(EDIT_WIDTH), + ); + let entered = field.lost_focus() && ui.input(|input| input.key_pressed(Key::Enter)); + if field.lost_focus() { + ui.data_mut(|data| data.remove::(text_id)); + } else { + ui.data_mut(|data| data.insert_temp(text_id, text.clone())); + } + if !entered { + return None; + } + let typed: f64 = text.trim().replace(',', ".").parse().ok()?; + let clamped = typed.clamp(*knob.range.start(), *knob.range.end()); + Some(on_grid(clamped, knob.step)) +} + +fn exact_entry_open(ui: &Ui, id: Id) -> bool { + ui.data(|data| data.get_temp::(id.with(EDIT_TEXT_ID))) + .is_some() +} + +fn paint( + ui: &Ui, + rect: eframe::egui::Rect, + knob: &Knob, + value: f64, + enabled: bool, + tokens: &Tokens, +) { + let painter = ui.painter(); + let center = rect.center(); + let radius = knob.diameter / 2.0 - RING_WIDTH; + let span = knob.range.end() - knob.range.start(); + let position = ((value - knob.range.start()) / span).clamp(0.0, 1.0) as f32; + let start = -SWEEP_RADIANS / 2.0; + let angle = start + position * SWEEP_RADIANS; + let point = |angle: f32| center + radius * vec2(angle.sin(), -angle.cos()); + let track: Vec<_> = (0..=ARC_SEGMENTS) + .map(|segment| point(start + SWEEP_RADIANS * segment as f32 / ARC_SEGMENTS as f32)) + .collect(); + painter.add(Shape::line( + track, + Stroke::new(RING_WIDTH, tokens.meter_track), + )); + let color = if enabled { + tokens.accent + } else { + tokens.text_dim + }; + let filled: Vec<_> = (0..=ARC_SEGMENTS) + .map(|segment| point(start + (angle - start) * segment as f32 / ARC_SEGMENTS as f32)) + .collect(); + painter.add(Shape::line(filled, Stroke::new(RING_WIDTH, color))); + let tip = point(angle); + let inner = center + (tip - center) * 0.35; + painter.line_segment( + [pos2(inner.x, inner.y), tip], + Stroke::new(INDICATOR_WIDTH, tokens.text), + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + const DELAY_RANGE: RangeInclusive = 0.0..=5760.0; + + #[test] + fn a_full_drag_covers_the_range_and_shift_is_finer() { + assert_eq!( + drag_change(&DELAY_RANGE, FULL_RANGE_DRAG_POINTS, false), + 5760.0 + ); + assert_eq!(drag_change(&DELAY_RANGE, 10.0, true), 28.8); + } + + #[test] + fn a_wheel_notch_is_a_whole_number_of_steps() { + assert_eq!(wheel_step(&DELAY_RANGE, 1.0), 58.0); + assert_eq!(wheel_step(&(1.0..=240.0), 1.0), 2.0); + assert_eq!(wheel_step(&(0.0..=10.0), 1.0), 1.0); + } + + #[test] + fn sent_values_sit_on_the_step_grid() { + assert_eq!(on_grid(12.4, 1.0), 12.0); + assert_eq!(on_grid(1.26, 0.5), 1.5); + } +} diff --git a/crates/app/src/views/meters.rs b/crates/app/src/views/meters.rs index 3611a7c..71def9a 100644 --- a/crates/app/src/views/meters.rs +++ b/crates/app/src/views/meters.rs @@ -20,6 +20,7 @@ pub fn show( meters: &[MeterState], clips: Option<&Value>, height: f32, + floor: f64, tokens: &Tokens, ) { let now = ui.input(|input| input.time); @@ -42,10 +43,10 @@ pub fn show( let painter = ui.painter(); painter.circle_filled(lamp_center, CLIP_LAMP_RADIUS, lamp_color); painter.rect_filled(track, CornerRadius::ZERO, tokens.meter_track); - let level_top = track.bottom() - track.height() * fraction(meter.shown(now)); + let level_top = track.bottom() - track.height() * fraction(meter.shown(now), floor); let level = Rect::from_min_max(pos2(track.left(), level_top), track.max); painter.rect_filled(level, CornerRadius::ZERO, tokens.meter_fill); - let peak_y = track.bottom() - track.height() * fraction(meter.peak(now)); + let peak_y = track.bottom() - track.height() * fraction(meter.peak(now), floor); painter.hline( track.x_range(), peak_y, diff --git a/crates/app/src/views/mod.rs b/crates/app/src/views/mod.rs index 30f329a..6db66e1 100644 --- a/crates/app/src/views/mod.rs +++ b/crates/app/src/views/mod.rs @@ -1,10 +1,12 @@ pub mod add_address; pub mod all_values; -pub mod card; -pub mod details; +mod details; mod details_fields; pub mod fader; +mod knob; mod meters; +pub mod panel; pub mod remove_speaker; pub mod scan_choice; +pub mod strip; pub mod toolbar; diff --git a/crates/app/src/views/panel.rs b/crates/app/src/views/panel.rs new file mode 100644 index 0000000..769b3a4 --- /dev/null +++ b/crates/app/src/views/panel.rs @@ -0,0 +1,101 @@ +//! The device panel: every setting of one speaker, opened from its strip and floating beside +//! it. It closes on Escape, its close button, or a click outside it. + +use eframe::egui::{Button, Context, Id, Key, Label, Pos2, RichText, Ui, Window}; +use neumann_control_core::catalog::Catalog; +use neumann_control_core::mask::mask_serial; +use neumann_control_core::speaker::Command; + +use super::details; +use crate::display::firmware_version; +use crate::state::SpeakerState; +use crate::texts; + +const PANEL_ID: &str = "device_panel"; +const EDITING_ID: &str = "editing"; +const PANEL_WIDTH: f32 = 600.0; +/// Room kept free below the panel and for its header and footer. +const PANEL_CHROME_HEIGHT: f32 = 120.0; +const PANEL_MIN_HEIGHT: f32 = 160.0; + +pub struct PanelResponse { + /// The writes the user asked for that the write policy allowed. + pub commands: Vec, + pub close: bool, + pub remove: bool, +} + +/// `opened_now` is true in the frame of the click that opened the panel, which must not count +/// as a click outside. +pub fn show( + context: &Context, + speaker: &mut SpeakerState, + catalog: &Catalog, + anchor: Pos2, + opened_now: bool, +) -> PanelResponse { + let mut close = false; + let mut remove = false; + // Escape in a field cancels the edit; only Escape with nothing being edited closes. egui + // drops the focus at the start of the Escape frame, so the last frame's state decides. + let editing_id = Id::new(PANEL_ID).with(EDITING_ID); + let editing = context.data(|data| data.get_temp::(editing_id).unwrap_or(false)); + // As tall as the window allows below the anchor, so rows are not hidden needlessly. + let room = context.content_rect().bottom() - anchor.y - PANEL_CHROME_HEIGHT; + let max_height = room.max(PANEL_MIN_HEIGHT); + let window = Window::new(texts::PANEL_TITLE) + .id(Id::new(PANEL_ID)) + .title_bar(false) + .collapsible(false) + .resizable(false) + .fixed_pos(anchor) + .min_width(PANEL_WIDTH) + .max_width(PANEL_WIDTH) + .max_height(max_height) + .default_height(max_height) + .vscroll(true) + .show(context, |ui| { + close = header(ui, speaker); + let commands = details::show(ui, speaker, catalog); + ui.separator(); + remove = ui.button(texts::REMOVE).clicked(); + commands + }) + .expect("the panel window is open"); + let escape = !editing && context.input(|input| input.key_pressed(Key::Escape)); + let editing_now = context.memory(|memory| memory.focused().is_some()); + context.data_mut(|data| data.insert_temp(editing_id, editing_now)); + let pressed = context.input(|input| input.pointer.any_pressed()); + let position = context.input(|input| input.pointer.interact_pos()); + let outside = position + .is_some_and(|position| context.layer_id_at(position) != Some(window.response.layer_id)); + let clicked_outside = pressed && outside; + PanelResponse { + commands: window.inner.unwrap_or_default(), + close: close || escape || (clicked_outside && !opened_now), + remove, + } +} + +/// Name, model, masked serial and firmware; true when the close button was clicked. +fn header(ui: &mut Ui, speaker: &SpeakerState) -> bool { + let closed = ui + .horizontal(|ui| { + ui.add(Label::new(RichText::new(speaker.display_name()).strong()).truncate()); + ui.with_layout( + eframe::egui::Layout::right_to_left(eframe::egui::Align::Center), + |ui| ui.add(Button::new(texts::CLOSE).small()).clicked(), + ) + .inner + }) + .inner; + let model = speaker.model().unwrap_or_default().to_owned(); + if let Some(identity) = &speaker.identity { + let line = texts::status_line( + &firmware_version(&identity.version), + &mask_serial(&identity.serial), + ); + ui.add(Label::new(RichText::new(format!("{model} · {line}")).small().weak()).wrap()); + } + closed +} diff --git a/crates/app/src/views/strip.rs b/crates/app/src/views/strip.rs new file mode 100644 index 0000000..f0b7ca1 --- /dev/null +++ b/crates/app/src/views/strip.rs @@ -0,0 +1,235 @@ +//! One speaker as a channel strip: name (opens the device panel), status LEDs, delay and +//! standby knobs, level fader with meters, readout, response time, mute and identify, and the +//! last message. The strip only reports gestures; `writes` decides what becomes a command. + +use eframe::egui::{ + self, Button, Color32, CornerRadius, Label, Pos2, RichText, Sense, Stroke, Ui, Vec2, + WidgetInfo, WidgetType, +}; +use neumann_control_core::catalog::Catalog; +use neumann_control_core::settings::DeskSettings; +use neumann_control_core::speaker::Command; +use serde_json::Value; + +use super::fader::{self, FADER_HEIGHT}; +use super::{knob, meters}; +use crate::fader::output_level_range; +use crate::paths; +use crate::state::{SpeakerState, Status}; +use crate::status_leds::{status_leds, ConnectionLed}; +use crate::texts; +use crate::ui_kit::theme::Tokens; +use crate::writes::{self, Intent, Switch}; + +pub const STRIP_WIDTH: f32 = 112.0; +const STRIP_MARGIN: i8 = 6; +const STRIP_RADIUS: u8 = 6; +const BORDER_WIDTH: f32 = 1.0; +const LED_RADIUS: f32 = 3.5; +const LED_SPACING: f32 = 6.0; +const KNOB_DIAMETER: f32 = 26.0; +const UNREACHABLE_OPACITY: f32 = 0.55; +const METER_GROUP_GAP: f32 = 4.0; +const DELAY_PATH: &str = "audio/out/delay"; +const STANDBY_TIME_PATH: &str = "device/standby/auto_standby_time"; + +pub struct StripResponse { + /// The writes the user asked for that the write policy allowed. + pub commands: Vec, + /// Where the device panel opens when the name was clicked. + pub open_panel: Option, +} + +pub fn show( + ui: &mut Ui, + speaker: &mut SpeakerState, + catalog: &Catalog, + desk: &DeskSettings, +) -> StripResponse { + let tokens = Tokens::current(ui.ctx()); + let mut commands = Vec::new(); + let inner_width = STRIP_WIDTH - 2.0 * (f32::from(STRIP_MARGIN) + BORDER_WIDTH); + let open_panel = egui::Frame::new() + .fill(tokens.card) + .stroke(Stroke::new(BORDER_WIDTH, tokens.border)) + .corner_radius(CornerRadius::same(STRIP_RADIUS)) + .inner_margin(STRIP_MARGIN) + .show(ui, |ui| { + // The desk lays strips out in a row; the content of one strip stacks. + ui.vertical(|ui| { + ui.set_width(inner_width); + if speaker.status == Status::Unreachable { + ui.multiply_opacity(UNREACHABLE_OPACITY); + } + let open_panel = name(ui, speaker); + leds(ui, speaker, &tokens); + knobs(ui, speaker, catalog, &mut commands); + level(ui, speaker, catalog, desk, &mut commands, &tokens); + if desk.show_response_times { + response_time(ui, speaker); + } + buttons(ui, speaker, catalog, &mut commands); + message(ui, speaker, &tokens); + open_panel + }) + .inner + }) + .inner; + StripResponse { + commands, + open_panel, + } +} + +fn name(ui: &mut Ui, speaker: &SpeakerState) -> Option { + let text = RichText::new(speaker.display_name()).strong(); + let response = ui.add(Button::new(text).frame(false).truncate()); + response.clicked().then(|| response.rect.left_bottom()) +} + +fn leds(ui: &mut Ui, speaker: &SpeakerState, tokens: &Tokens) { + let state = status_leds(speaker); + let connection = match state.connection { + ConnectionLed::Connected => tokens.connected, + ConnectionLed::Connecting => tokens.connecting, + ConnectionLed::Off => tokens.unreachable, + }; + let lit = |on: bool, color: Color32| if on { color } else { tokens.clip_off }; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = LED_SPACING; + led(ui, connection, texts::LED_CONNECTION); + led(ui, lit(state.clip, tokens.danger), texts::LED_CLIP); + led( + ui, + lit(state.warning, tokens.connecting), + texts::LED_WARNING, + ); + led(ui, lit(state.local, tokens.accent), texts::LED_LOCAL); + }); +} + +fn led(ui: &mut Ui, color: Color32, text: &str) { + let size = Vec2::splat(2.0 * LED_RADIUS); + let (rect, response) = ui.allocate_exact_size(size, Sense::hover()); + ui.painter().circle_filled(rect.center(), LED_RADIUS, color); + response.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, text)); + response.on_hover_text(text); +} + +fn knobs(ui: &mut Ui, speaker: &mut SpeakerState, catalog: &Catalog, commands: &mut Vec) { + let product = speaker.product().to_owned(); + let entries = catalog.entries_for(&product); + let entry = |path: &str| { + let path = paths::path(path); + entries.iter().copied().find(|entry| entry.path == path) + }; + ui.columns(2, |columns| { + let knobs = [ + (DELAY_PATH, texts::KNOB_DELAY), + (STANDBY_TIME_PATH, texts::KNOB_STANDBY), + ]; + for (column, (path, label)) in columns.iter_mut().zip(knobs) { + let Some(entry) = entry(path) else { + continue; + }; + let sent = knob::for_entry(column, speaker, catalog, entry, label, true, KNOB_DIAMETER); + commands.extend(sent); + } + }); +} + +fn level( + ui: &mut Ui, + speaker: &mut SpeakerState, + catalog: &Catalog, + desk: &DeskSettings, + commands: &mut Vec, + tokens: &Tokens, +) { + let path = paths::path(paths::OUTPUT_LEVEL); + let device = speaker.values.get(&path).and_then(Value::as_f64); + let unit = paths::catalog_entry(catalog, &path) + .and_then(|entry| entry.unit.clone()) + .unwrap_or_default(); + let shown = ui + .horizontal(|ui| { + let range = output_level_range(); + let shown = fader::show(ui, speaker, catalog, commands, device, range, &unit); + meters(ui, speaker, desk, tokens); + shown + }) + .inner; + let readout = device.map(|_| texts::readout(shown, &unit)); + ui.add(Label::new( + RichText::new(readout.unwrap_or_default()).monospace(), + )); +} + +/// Input meters, then the output meters of models that report them (KH 750), set apart. +fn meters(ui: &mut Ui, speaker: &SpeakerState, desk: &DeskSettings, tokens: &Tokens) { + let floor = desk.meter_floor.db(); + let input_clips = speaker.values.get(&paths::path(paths::INPUT_CLIP)); + let input = &speaker.input_meters; + let group = texts::INPUT_METERS; + meters::show(ui, group, input, input_clips, FADER_HEIGHT, floor, tokens); + if speaker.output_meters.is_empty() { + return; + } + ui.add_space(METER_GROUP_GAP); + let output_clips = speaker.values.get(&paths::path(paths::OUTPUT_CLIP)); + let output = &speaker.output_meters; + let group = texts::OUTPUT_METERS; + meters::show(ui, group, output, output_clips, FADER_HEIGHT, floor, tokens); +} + +fn response_time(ui: &mut Ui, speaker: &SpeakerState) { + let text = speaker + .response_time + .map(|time| texts::response_time(time.as_millis())) + .unwrap_or_default(); + ui.add(Label::new(RichText::new(text).small().weak().monospace())); +} + +fn buttons( + ui: &mut Ui, + speaker: &mut SpeakerState, + catalog: &Catalog, + commands: &mut Vec, +) { + ui.horizontal(|ui| { + toggle(ui, speaker, catalog, commands, Switch::Mute, texts::MUTE); + toggle( + ui, + speaker, + catalog, + commands, + Switch::Identify, + texts::IDENTIFY, + ); + }); +} + +/// A button showing a switch value of the speaker; a click asks for the opposite. +fn toggle( + ui: &mut Ui, + speaker: &mut SpeakerState, + catalog: &Catalog, + commands: &mut Vec, + switch: Switch, + text: &str, +) { + let path = switch.path(); + let current = speaker.values.get(&path).and_then(Value::as_bool); + let enabled = current.is_some() && writes::can_write(speaker, catalog, &path); + let button = Button::new(text).selected(current.unwrap_or(false)); + if ui.add_enabled(enabled, button).clicked() { + let now = ui.input(|input| input.time); + commands.extend(speaker.request(Intent::Toggle(switch), catalog, now)); + } +} + +fn message(ui: &mut Ui, speaker: &SpeakerState, tokens: &Tokens) { + if let Some(message) = &speaker.message { + ui.add(Label::new(RichText::new(message).small().color(tokens.danger)).wrap()); + } +} diff --git a/crates/app/tests/details.rs b/crates/app/tests/details.rs index cadca11..bc61ef5 100644 --- a/crates/app/tests/details.rs +++ b/crates/app/tests/details.rs @@ -27,12 +27,7 @@ const IDLE_FRAMES: usize = 10; /// A connected card with "Mehr" open, in a window tall enough for the whole area. fn expanded_card(model: SimModel) -> (Simulator, tempfile::TempDir, Harness<'static, NeumannApp>) { let (simulator, directory, mut harness) = connected_card(model); - harness.set_size(EXPANDED_WINDOW_SIZE.into()); - harness.get_by_label(texts::MORE).click(); - wait_until(&mut harness, "the expanded area", |harness| { - harness.query_by_label(texts::ALL_VALUES).is_some() - }); - harness.run_steps(1); + open_panel(&mut harness, device_name(model)); (simulator, directory, harness) } @@ -155,11 +150,7 @@ fn an_announced_model_gives_no_write_controls_or_model_rows() { scan_and_add(&mut harness, &["KH 120 II"]); wait_for_level(&mut harness, 100.0); wait_for_initial_read(&mut harness, &simulator); - harness.get_by_label(texts::MORE).click(); - wait_until(&mut harness, "the expanded area", |harness| { - harness.query_by_label(texts::ALL_VALUES).is_some() - }); - harness.run_steps(1); + open_panel(&mut harness, KH120_DEVICE_NAME); assert_eq!(role_of(&harness, STANDBY_LABEL), Role::Label); assert!(harness.query_by_label(LOGO_LABEL).is_none()); } @@ -274,9 +265,13 @@ fn a_number_typed_and_entered_in_one_frame_is_sent() { fn abandoned_number_edit_is_not_sent_on_reopen() { let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); type_number(&mut harness, STANDBY_TIME_LABEL, "50"); - harness.get_by_label(texts::LESS).click(); + // The first Escape cancels the edit, the second closes the panel. + harness.key_press(egui::Key::Escape); + harness.run_steps(2); + harness.key_press(egui::Key::Escape); harness.run_steps(2); - harness.get_by_label(texts::MORE).click(); + assert!(harness.query_by_label(texts::CLOSE).is_none()); + open_panel(&mut harness, KH120_DEVICE_NAME); harness.run_steps(IDLE_FRAMES); barrier(&simulator); let sets = sets_to(&simulator, STANDBY_TIME_PATH); @@ -361,13 +356,14 @@ fn a_name_draft_left_by_tabbing_past_apply_follows_the_device_again() { } #[test] -fn card_controls_are_not_repeated_in_the_area() { +fn strip_controls_are_not_repeated_in_the_panel() { let (_simulator, _directory, harness) = expanded_card(SimModel::Kh120Ii); assert_eq!(harness.query_all_by_label(texts::MUTE).count(), 1); assert_eq!(harness.query_all_by_label(texts::IDENTIFY).count(), 1); assert!(harness.query_by_label("Identifizieren").is_none()); assert!(harness.query_by_label("Eingangspegel").is_none()); - assert_eq!(harness.query_all_by_role(Role::Slider).count(), 1); + let faders = harness.query_all_by_role_and_label(Role::Slider, texts::LEVEL); + assert_eq!(faders.count(), 1); assert!(harness.query_all_by_role(Role::SpinButton).all(|node| node .accesskit_node() .label() @@ -392,9 +388,7 @@ fn kh750_output_meters_sit_next_to_the_input_meters() { .count(), 2 ); - harness.set_size(EXPANDED_WINDOW_SIZE.into()); - harness.get_by_label(texts::MORE).click(); - wait_for_label(&mut harness, texts::ALL_VALUES); + open_panel(&mut harness, KH750_DEVICE_NAME); assert_eq!(harness.query_all_by_label(&output_meter).count(), 1); } diff --git a/crates/app/tests/speakers.rs b/crates/app/tests/speakers.rs index 8598fb2..5b43ca6 100644 --- a/crates/app/tests/speakers.rs +++ b/crates/app/tests/speakers.rs @@ -133,8 +133,6 @@ fn known_speaker_is_marked_and_not_selectable() { let mut harness = harness(path.clone(), vec![kh120_found(&simulator)]); wait_for_level(&mut harness, 100.0); wait_for_initial_read(&mut harness, &simulator); - harness.get_by_label(texts::MORE).click(); - harness.run_steps(2); harness.get_by_label(texts::SCAN).click(); wait_for_label(&mut harness, KH120_NAME); harness.get_by_label(texts::ALREADY_ADDED); @@ -151,8 +149,8 @@ fn known_speaker_is_marked_and_not_selectable() { harness.run_steps(2); harness.get_by_label(&texts::speaker_count(1)); assert_eq!(settings::load(&path).manual.len(), 1); - // The card kept its state, so it was not recreated. - harness.get_by_label(texts::LESS); + // The strip kept its values, so it was not recreated. + harness.get_by_label(&readout(100.0)); } #[test] @@ -168,7 +166,7 @@ fn chosen_speaker_survives_restart() { wait_for_level(&mut harness, 100.0); wait_for_initial_read(&mut harness, &simulator); harness.get_by_label(&texts::speaker_count(1)); - harness.get_by_label("KH 750"); + harness.get_by_label(KH750_DEVICE_NAME); assert!(saved_at(&path, &simulator)); } @@ -301,11 +299,9 @@ fn add_address_names_an_address_already_listed() { assert_eq!(settings::load(&path).manual.len(), 1); } -/// Opens "Mehr" on the only card and asks to remove it. +/// Opens the device panel of the only speaker and asks to remove it. fn ask_to_remove(harness: &mut Harness<'static, NeumannApp>) { - harness.set_size(EXPANDED_WINDOW_SIZE.into()); - harness.get_by_label(texts::MORE).click(); - wait_for_label(harness, texts::REMOVE); + open_panel(harness, KH120_DEVICE_NAME); harness.get_by_label(texts::REMOVE).click(); wait_for_label(harness, texts::REMOVE_TITLE); } @@ -342,6 +338,5 @@ fn cancel_remove_keeps_speaker() { assert!(harness.query_by_label(texts::REMOVE_TITLE).is_none()); harness.get_by_label(&texts::speaker_count(1)); harness.get_by_label(&readout(100.0)); - harness.get_by_label(texts::LESS); assert_eq!(settings::load(&path).manual.len(), 1); } diff --git a/crates/app/tests/support/mod.rs b/crates/app/tests/support/mod.rs index 8d25ab2..2510289 100644 --- a/crates/app/tests/support/mod.rs +++ b/crates/app/tests/support/mod.rs @@ -23,12 +23,14 @@ use neumann_control_core::speaker::{self, Events, SpeakerHandle, Target, Timing} use serde_json::Value; pub const SIMULATED_SERIAL: &str = "SIM0001234"; +pub const KH120_DEVICE_NAME: &str = "Simulated KH 120 II"; +pub const KH750_DEVICE_NAME: &str = "Simulated KH 750"; pub const WAIT_LIMIT: Duration = Duration::from_secs(15); // Paces the polling loop only; every outcome is decided by the condition. pub const POLL_PAUSE: Duration = Duration::from_millis(10); pub const WINDOW_SIZE: [f32; 2] = [900.0, 700.0]; /// Tall enough for a card with "Mehr" open, so every control in it can be clicked. -pub const EXPANDED_WINDOW_SIZE: [f32; 2] = [900.0, 1600.0]; +pub const EXPANDED_WINDOW_SIZE: [f32; 2] = [1200.0, 1600.0]; pub const LOOPBACK: &str = "::1"; const DIALOG_SETTLE_FRAMES: usize = 2; const PRODUCT_PATH: &str = "device/identity/product"; @@ -213,3 +215,22 @@ pub fn serials_remembered(path: &Path, count: usize) -> bool { .iter() .all(|entry| entry.serial.as_deref() == Some(SIMULATED_SERIAL)) } + +pub fn device_name(model: SimModel) -> &'static str { + match model { + SimModel::Kh120Ii => KH120_DEVICE_NAME, + SimModel::Kh750 => KH750_DEVICE_NAME, + } +} + +/// Clicks the strip's name and waits until the device panel shows its settings. +pub fn open_panel(harness: &mut Harness<'static, NeumannApp>, name: &str) { + // The panel takes its size from the window it opens in. + harness.set_size(EXPANDED_WINDOW_SIZE.into()); + harness.run_steps(2); + harness.get_by_label(name).click(); + wait_until(harness, "the device panel", |harness| { + harness.query_by_label(texts::ALL_VALUES).is_some() + }); + harness.run_steps(1); +} diff --git a/crates/app/tests/views.rs b/crates/app/tests/views.rs index c99f507..7930fa8 100644 --- a/crates/app/tests/views.rs +++ b/crates/app/tests/views.rs @@ -19,20 +19,23 @@ use serde_json::Value; use support::*; #[test] -fn card_shows_level_and_name() { - let (_simulator, _directory, harness) = connected_card(SimModel::Kh120Ii); - harness.get_by_label("KH 120 II"); - harness.get_by_label("Simulated KH 120 II"); +fn strip_shows_name_and_level_and_panel_the_masked_serial() { + let (_simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + harness.get_by_label(KH120_DEVICE_NAME); harness.get_by_label(&readout(100.0)); + harness.get_by_label(&texts::speaker_count(1)); + open_panel(&mut harness, KH120_DEVICE_NAME); + harness.get_by_label_contains("KH 120 II ·"); harness.get_by_label_contains(&mask_serial(SIMULATED_SERIAL)); assert!(harness.query_by_label_contains(SIMULATED_SERIAL).is_none()); - harness.get_by_label(&texts::speaker_count(1)); } #[test] fn fader_drag_sends_once_on_release() { let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); - let rect = harness.get_by_role(Role::Slider).rect(); + let rect = harness + .get_by_role_and_label(Role::Slider, texts::LEVEL) + .rect(); let start = rect.center(); harness.drag_at(start); for frame in 1..=10 { @@ -45,7 +48,7 @@ fn fader_drag_sends_once_on_release() { "a Set went out while dragging" ); let shown = harness - .get_by_role(Role::Slider) + .get_by_role_and_label(Role::Slider, texts::LEVEL) .accesskit_node() .numeric_value() .unwrap(); @@ -64,14 +67,17 @@ fn fader_drag_sends_once_on_release() { #[test] fn fader_value_is_shown_at_the_handle_while_dragging() { let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); - let start = harness.get_by_role(Role::Slider).rect().center(); + let start = harness + .get_by_role_and_label(Role::Slider, texts::LEVEL) + .rect() + .center(); harness.drag_at(start); for frame in 1..=5 { harness.hover_at(start + egui::vec2(0.0, 4.0 * frame as f32)); harness.step(); } let dragged = harness - .get_by_role(Role::Slider) + .get_by_role_and_label(Role::Slider, texts::LEVEL) .accesskit_node() .numeric_value() .unwrap(); @@ -87,7 +93,10 @@ fn fader_value_is_shown_at_the_handle_while_dragging() { #[test] fn wheel_over_fader_sends_one_set_after_the_wheel_went_quiet() { let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); - let center = harness.get_by_role(Role::Slider).rect().center(); + let center = harness + .get_by_role_and_label(Role::Slider, texts::LEVEL) + .rect() + .center(); harness.hover_at(center); for _ in 0..2 { harness.event(egui::Event::MouseWheel { @@ -112,7 +121,10 @@ fn wheel_over_fader_sends_one_set_after_the_wheel_went_quiet() { #[test] fn escape_during_a_drag_sends_nothing() { let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); - let start = harness.get_by_role(Role::Slider).rect().center(); + let start = harness + .get_by_role_and_label(Role::Slider, texts::LEVEL) + .rect() + .center(); harness.drag_at(start); for frame in 1..=5 { harness.hover_at(start + egui::vec2(0.0, 4.0 * frame as f32)); @@ -146,7 +158,10 @@ fn escape_during_a_drag_sends_nothing() { #[test] fn a_drag_replaces_wheel_steps_not_yet_sent() { let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); - let start = harness.get_by_role(Role::Slider).rect().center(); + let start = harness + .get_by_role_and_label(Role::Slider, texts::LEVEL) + .rect() + .center(); harness.hover_at(start); harness.event(egui::Event::MouseWheel { unit: MouseWheelUnit::Line, @@ -189,13 +204,27 @@ fn identify_button_sends_set() { } #[test] -fn more_toggles_to_less_and_back() { +fn panel_opens_from_strip_name_and_closes_on_escape() { let (_simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); - harness.get_by_label(texts::MORE).click(); + open_panel(&mut harness, KH120_DEVICE_NAME); + harness.get_by_label(texts::CLOSE); + harness.key_press(egui::Key::Escape); harness.run_steps(2); - harness.get_by_label(texts::LESS).click(); + assert!(harness.query_by_label(texts::ALL_VALUES).is_none()); + open_panel(&mut harness, KH120_DEVICE_NAME); + harness.get_by_label(texts::CLOSE).click(); harness.run_steps(2); - harness.get_by_label(texts::MORE); + assert!(harness.query_by_label(texts::ALL_VALUES).is_none()); +} + +#[test] +fn panel_closes_when_its_speaker_goes_away() { + let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + open_panel(&mut harness, KH120_DEVICE_NAME); + simulator.stop(); + wait_until(&mut harness, "the panel to close", |harness| { + harness.query_by_label(texts::ALL_VALUES).is_none() + }); } #[test] @@ -214,7 +243,7 @@ fn unreachable_card_is_disabled() { .accesskit_node() .is_disabled()); assert!(harness - .get_by_role(Role::Slider) + .get_by_role_and_label(Role::Slider, texts::LEVEL) .accesskit_node() .is_disabled()); } @@ -244,12 +273,7 @@ fn all_values_window( model: SimModel, ) -> (Simulator, tempfile::TempDir, Harness<'static, NeumannApp>) { let (simulator, directory, mut harness) = connected_card(model); - harness.set_size(EXPANDED_WINDOW_SIZE.into()); - harness.get_by_label(texts::MORE).click(); - wait_until(&mut harness, "the expanded area", |harness| { - harness.query_by_label(texts::ALL_VALUES).is_some() - }); - harness.run_steps(1); + open_panel(&mut harness, device_name(model)); harness.get_by_label(texts::ALL_VALUES).click(); wait_until(&mut harness, "the loaded table", |harness| { harness @@ -327,7 +351,7 @@ fn failure_is_shown() { } #[test] -fn card_content_stacks_top_down() { +fn strip_content_stacks_top_down() { let first = Simulator::start(SimModel::Kh120Ii); let second = Simulator::start(SimModel::Kh750); let directory = tempfile::tempdir().unwrap(); @@ -336,40 +360,181 @@ fn card_content_stacks_top_down() { vec![manual_for(&first), manual_for(&second)], ); let mut harness = harness(path, Vec::new()); - wait_until(&mut harness, "both cards connected", |harness| { - harness.query_all_by_role(Role::Slider).count() == 2 - && harness.query_by_label("KH 120 II").is_some() - && harness.query_by_label("KH 750").is_some() + wait_until(&mut harness, "both strips connected", |harness| { + harness + .query_all_by_role_and_label(Role::Slider, texts::LEVEL) + .count() + == 2 + && harness.query_by_label(KH120_DEVICE_NAME).is_some() + && harness.query_by_label(KH750_DEVICE_NAME).is_some() }); harness.run_steps(3); let names = [ - harness.get_by_label("KH 120 II").rect(), - harness.get_by_label("KH 750").rect(), + harness.get_by_label(KH120_DEVICE_NAME).rect(), + harness.get_by_label(KH750_DEVICE_NAME).rect(), ]; let mutes: Vec<_> = harness .query_all_by_label(texts::MUTE) .map(|node| node.rect()) .collect(); - let mores: Vec<_> = harness - .query_all_by_label(texts::MORE) + let delays: Vec<_> = harness + .query_all_by_role_and_label(Role::Slider, texts::KNOB_DELAY) .map(|node| node.rect()) .collect(); assert_eq!(mutes.len(), 2); - assert_eq!(mores.len(), 2); + assert_eq!(delays.len(), 2); for (index, name) in names.iter().enumerate() { - let card = if mutes[0].center().x < mutes[1].center().x { + let strip = if mutes[0].center().x < mutes[1].center().x { index } else { 1 - index }; - assert!(name.bottom() <= mutes[card].top(), "name above Stumm"); + assert!(name.bottom() <= delays[strip].top(), "name above the knobs"); assert!( - mutes[card].bottom() <= mores[card].top(), - "Mehr below Stumm" + delays[strip].bottom() <= mutes[strip].top(), + "knobs above Stumm" ); } assert!( (names[0].top() - names[1].top()).abs() < 1.0, - "cards start at the same height: {names:?}" + "strips start at the same height: {names:?}" + ); +} + +fn delay_sets(simulator: &Simulator) -> Vec { + simulator + .requests() + .iter() + .filter_map(|request| { + request + .pointer("/audio/out/delay") + .filter(|value| !value.is_null()) + }) + .cloned() + .collect() +} + +#[test] +fn strip_shows_leds_knobs_fader_and_buttons() { + let (_simulator, _directory, harness) = connected_card(SimModel::Kh120Ii); + for label in [ + texts::LED_CONNECTION, + texts::LED_CLIP, + texts::LED_WARNING, + texts::LED_LOCAL, + ] { + harness.get_by_label(label); + } + harness.get_by_role_and_label(Role::Slider, texts::KNOB_DELAY); + harness.get_by_role_and_label(Role::Slider, texts::KNOB_STANDBY); + harness.get_by_label(texts::MUTE); + harness.get_by_label(texts::IDENTIFY); + harness.get_by_label(&readout(100.0)); +} + +#[test] +fn kh750_standby_knob_is_read_only() { + let (_simulator, _directory, harness) = connected_card(SimModel::Kh750); + let standby = harness.get_by_role_and_label(Role::Slider, texts::KNOB_STANDBY); + assert!(standby.accesskit_node().is_disabled()); + let delay = harness.get_by_role_and_label(Role::Slider, texts::KNOB_DELAY); + assert!(!delay.accesskit_node().is_disabled()); +} + +#[test] +fn knob_drag_writes_once_on_release() { + let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + let start = harness + .get_by_role_and_label(Role::Slider, texts::KNOB_DELAY) + .rect() + .center(); + harness.drag_at(start); + for frame in 1..=5 { + harness.hover_at(start - egui::vec2(0.0, 4.0 * frame as f32)); + harness.step(); + } + barrier(&simulator); + assert!( + delay_sets(&simulator).is_empty(), + "a Set went out while dragging" + ); + harness.drop_at(start - egui::vec2(0.0, 20.0)); + wait_until(&mut harness, "the delay Set", |_| { + !delay_sets(&simulator).is_empty() + }); + harness.run_steps(10); + barrier(&simulator); + let sets = delay_sets(&simulator); + assert_eq!(sets.len(), 1, "{sets:?}"); + assert!(sets[0].as_i64().is_some_and(|delay| delay > 0), "{sets:?}"); +} + +#[test] +fn escape_during_knob_drag_sends_nothing() { + let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + let start = harness + .get_by_role_and_label(Role::Slider, texts::KNOB_DELAY) + .rect() + .center(); + harness.drag_at(start); + for frame in 1..=3 { + harness.hover_at(start - egui::vec2(0.0, 4.0 * frame as f32)); + harness.step(); + } + harness.key_press(egui::Key::Escape); + harness.step(); + harness.drop_at(start - egui::vec2(0.0, 20.0)); + harness.run_steps(10); + barrier(&simulator); + assert!(delay_sets(&simulator).is_empty()); +} + +#[test] +fn knob_wheel_writes_after_idle() { + let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + let center = harness + .get_by_role_and_label(Role::Slider, texts::KNOB_DELAY) + .rect() + .center(); + harness.hover_at(center); + harness.event(egui::Event::MouseWheel { + unit: MouseWheelUnit::Line, + delta: egui::vec2(0.0, 1.0), + phase: TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + harness.step(); + barrier(&simulator); + assert!(delay_sets(&simulator).is_empty(), "the wheel wrote at once"); + wait_until(&mut harness, "the wheel Set", |_| { + !delay_sets(&simulator).is_empty() + }); + harness.run_steps(10); + barrier(&simulator); + assert_eq!(delay_sets(&simulator), vec![serde_json::json!(58)]); +} + +#[test] +fn desk_scrolls_when_narrow() { + let first = Simulator::start(SimModel::Kh120Ii); + let second = Simulator::start(SimModel::Kh750); + let directory = tempfile::tempdir().unwrap(); + let path = settings_file( + directory.path(), + vec![manual_for(&first), manual_for(&second)], + ); + let mut harness = harness(path, Vec::new()); + harness.set_size(egui::vec2(160.0, 700.0)); + wait_until(&mut harness, "both strips", |harness| { + harness.query_by_label(KH120_DEVICE_NAME).is_some() + && harness.query_by_label(KH750_DEVICE_NAME).is_some() + }); + harness.run_steps(3); + let first_strip = harness.get_by_label(KH120_DEVICE_NAME).rect(); + let second_strip = harness.get_by_label(KH750_DEVICE_NAME).rect(); + assert!( + first_strip.right() <= second_strip.left() || second_strip.right() <= first_strip.left(), + "strips overlap: {first_strip:?} {second_strip:?}" ); + harness.get_by_role(Role::ScrollBar); } From d5478ba0a15fd8a36705bc0d9321ee45c13ddcc5 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:03:30 +0200 Subject: [PATCH 071/460] Add a settings dialog and response times to the toolbar --- crates/app/src/app.rs | 30 +++++++++++++ crates/app/src/texts.rs | 22 +++++++++- crates/app/src/ui_kit/theme.rs | 14 ++++++- crates/app/src/views/mod.rs | 1 + crates/app/src/views/settings_dialog.rs | 56 +++++++++++++++++++++++++ crates/app/src/views/toolbar.rs | 19 ++++++++- crates/app/tests/views.rs | 39 +++++++++++++++++ 7 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 crates/app/src/views/settings_dialog.rs diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index 463e99c..52e814b 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -18,12 +18,14 @@ use neumann_control_core::speaker::{Command, Event, Events, SpeakerHandle, Targe use crate::scan_choice::ScanChoice; use crate::spawner::{ScanResult, SpeakerSpawner}; use crate::state::{AllValuesState, SpeakerKey, SpeakerState, Status}; +use crate::status_leds::average_response_time; use crate::targets::{duplicate_cards, follow_moves, forget, remember_serial, targets}; use crate::texts; use crate::ui_kit::{fonts, theme}; use crate::views::add_address::{self, AddAddressAction, AddAddressForm}; use crate::views::remove_speaker::{self, RemoveAction}; use crate::views::scan_choice::{self as scan_choice_view, ScanChoiceAction}; +use crate::views::settings_dialog::{self, SettingsAction}; use crate::views::toolbar::{self, ToolbarAction, ToolbarFacts}; use crate::views::{all_values, panel, strip}; @@ -65,6 +67,7 @@ pub struct NeumannApp { add_address: Option, removal: Option, panel: Option, + settings_open: bool, window: Option, } @@ -78,6 +81,7 @@ impl NeumannApp { fonts::install(context); theme::apply(context); let settings = settings::load(&settings_path); + context.set_theme(theme::preference(settings.desk.theme)); let repaint_context = context.clone(); let mut app = NeumannApp { window: settings.window, @@ -93,6 +97,7 @@ impl NeumannApp { add_address: None, removal: None, panel: None, + settings_open: false, }; app.sync_targets(); app @@ -280,6 +285,7 @@ impl NeumannApp { scanning: self.scan.is_some(), speaker_count: self.speakers.len(), notice: self.notice, + average_response_time: self.average_response_time(), }; match toolbar::show(ui, &facts) { ToolbarAction::Scan => { @@ -288,6 +294,7 @@ impl NeumannApp { self.scan_choice = Some(ScanChoice::Scanning); } ToolbarAction::AddAddress => self.add_address = Some(AddAddressForm::default()), + ToolbarAction::Settings => self.settings_open = true, ToolbarAction::None => {} } egui::CentralPanel::default().show(ui, |ui| self.show_desk(ui)); @@ -295,6 +302,7 @@ impl NeumannApp { self.show_dialog(ui); self.show_scan_choice(ui); self.show_removal(ui); + self.show_settings(ui); self.show_all_values(ui.ctx()); } @@ -386,6 +394,28 @@ impl NeumannApp { } } + fn average_response_time(&self) -> Option { + if !self.settings.desk.show_response_times { + return None; + } + average_response_time(self.speakers.iter().map(|speaker| &speaker.state)) + } + + fn show_settings(&mut self, ui: &mut Ui) { + if !self.settings_open { + return; + } + match settings_dialog::show(ui, &mut self.settings.desk) { + SettingsAction::Changed => { + ui.ctx() + .set_theme(theme::preference(self.settings.desk.theme)); + self.save_settings(); + } + SettingsAction::Close => self.settings_open = false, + SettingsAction::None => {} + } + } + fn show_removal(&mut self, ui: &mut Ui) { let Some(removal) = &self.removal else { return; diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 3089300..1933659 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -28,7 +28,18 @@ pub const LED_LOCAL: &str = "LOCAL: Schalter hinten, Schreiben gesperrt"; pub const KNOB_DELAY: &str = "Delay"; pub const KNOB_STANDBY: &str = "Standby"; pub const CLOSE: &str = "Schließen"; -pub const PANEL_TITLE: &str = "Einstellungen"; +pub const PANEL_TITLE: &str = "Lautsprecher"; +pub const SETTINGS: &str = "Einstellungen"; +pub const SETTINGS_TITLE: &str = "Einstellungen"; +pub const THEME: &str = "Darstellung"; +pub const THEME_SYSTEM: &str = "wie Windows"; +pub const THEME_LIGHT: &str = "Hell"; +pub const THEME_DARK: &str = "Dunkel"; +pub const LANGUAGE: &str = "Sprache"; +pub const LANGUAGE_GERMAN: &str = "Deutsch"; +pub const LANGUAGE_ENGLISH: &str = "English (folgt)"; +pub const METER_RANGE: &str = "Pegelanzeige ab"; +pub const SHOW_RESPONSE_TIMES: &str = "Antwortzeiten anzeigen"; pub const INPUT_METERS: &str = "Eingangspegel"; pub const OUTPUT_METERS: &str = "Ausgangspegel"; @@ -99,6 +110,15 @@ pub fn knob_label(label: &str) -> String { format!("{label} (Regler)") } +pub fn meter_floor(db: f64) -> String { + format!("{db} dB") +} + +/// The toolbar's average over all connected speakers. +pub fn average_response_time(milliseconds: u128) -> String { + format!("Ø {milliseconds} ms") +} + pub fn response_time(milliseconds: u128) -> String { format!("{milliseconds} ms") } diff --git a/crates/app/src/ui_kit/theme.rs b/crates/app/src/ui_kit/theme.rs index d251f67..6de2977 100644 --- a/crates/app/src/ui_kit/theme.rs +++ b/crates/app/src/ui_kit/theme.rs @@ -5,9 +5,12 @@ use std::sync::Arc; use eframe::egui::style::{Selection, WidgetVisuals}; use eframe::egui::{ - vec2, Color32, Context, CornerRadius, FontId, Stroke, Style, TextStyle, Theme, Visuals, + vec2, Color32, Context, CornerRadius, FontId, Stroke, Style, TextStyle, Theme, ThemePreference, + Visuals, }; +use neumann_control_core::settings::ThemeChoice; + use super::fonts::semibold; #[derive(Clone, Copy, Debug, PartialEq)] @@ -151,6 +154,15 @@ pub fn apply(context: &Context) { } } +/// What the settings dialog's theme choice means for egui. +pub fn preference(choice: ThemeChoice) -> ThemePreference { + match choice { + ThemeChoice::System => ThemePreference::System, + ThemeChoice::Light => ThemePreference::Light, + ThemeChoice::Dark => ThemePreference::Dark, + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/app/src/views/mod.rs b/crates/app/src/views/mod.rs index 6db66e1..0fd306f 100644 --- a/crates/app/src/views/mod.rs +++ b/crates/app/src/views/mod.rs @@ -8,5 +8,6 @@ mod meters; pub mod panel; pub mod remove_speaker; pub mod scan_choice; +pub mod settings_dialog; pub mod strip; pub mod toolbar; diff --git a/crates/app/src/views/settings_dialog.rs b/crates/app/src/views/settings_dialog.rs new file mode 100644 index 0000000..251cc39 --- /dev/null +++ b/crates/app/src/views/settings_dialog.rs @@ -0,0 +1,56 @@ +//! The settings dialog behind the toolbar's "Einstellungen": theme, language, meter range and +//! response times. Every change applies at once; the app saves it. + +use eframe::egui::{Id, Modal, RadioButton, Ui}; +use neumann_control_core::settings::{DeskSettings, MeterFloor, ThemeChoice}; + +use crate::texts; + +const MODAL_ID: &str = "settings_dialog"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SettingsAction { + None, + Changed, + Close, +} + +pub fn show(ui: &mut Ui, desk: &mut DeskSettings) -> SettingsAction { + let modal = Modal::new(Id::new(MODAL_ID)).show(ui.ctx(), |ui| contents(ui, desk)); + if modal.should_close() { + return SettingsAction::Close; + } + modal.inner +} + +fn contents(ui: &mut Ui, desk: &mut DeskSettings) -> SettingsAction { + let before = *desk; + ui.heading(texts::SETTINGS_TITLE); + ui.label(texts::THEME); + ui.horizontal(|ui| { + ui.radio_value(&mut desk.theme, ThemeChoice::System, texts::THEME_SYSTEM); + ui.radio_value(&mut desk.theme, ThemeChoice::Light, texts::THEME_LIGHT); + ui.radio_value(&mut desk.theme, ThemeChoice::Dark, texts::THEME_DARK); + }); + ui.label(texts::LANGUAGE); + ui.horizontal(|ui| { + // English arrives with stage C; German is the only choice until then. + ui.add(RadioButton::new(true, texts::LANGUAGE_GERMAN)); + ui.add_enabled(false, RadioButton::new(false, texts::LANGUAGE_ENGLISH)); + }); + ui.label(texts::METER_RANGE); + ui.horizontal(|ui| { + for floor in MeterFloor::ALL { + ui.radio_value(&mut desk.meter_floor, floor, texts::meter_floor(floor.db())); + } + }); + ui.checkbox(&mut desk.show_response_times, texts::SHOW_RESPONSE_TIMES); + ui.separator(); + if ui.button(texts::CLOSE).clicked() { + return SettingsAction::Close; + } + if *desk != before { + return SettingsAction::Changed; + } + SettingsAction::None +} diff --git a/crates/app/src/views/toolbar.rs b/crates/app/src/views/toolbar.rs index b15f85f..445b6fd 100644 --- a/crates/app/src/views/toolbar.rs +++ b/crates/app/src/views/toolbar.rs @@ -1,6 +1,9 @@ -//! The top bar: "Suchen", "+ Adresse", the speaker count and a notice when something failed. +//! The top bar: "Suchen", "+ Adresse", the speaker count, a notice when something failed, and +//! at the right the average response time and "Einstellungen". -use eframe::egui::{self, Button, Ui}; +use std::time::Duration; + +use eframe::egui::{self, Align, Button, Layout, RichText, Ui}; use crate::texts; @@ -11,12 +14,15 @@ pub enum ToolbarAction { None, Scan, AddAddress, + Settings, } pub struct ToolbarFacts<'a> { pub scanning: bool, pub speaker_count: usize, pub notice: Option<&'a str>, + /// None while no speaker has measured one or the settings hide it. + pub average_response_time: Option, } pub fn show(ui: &mut Ui, facts: &ToolbarFacts) -> ToolbarAction { @@ -45,5 +51,14 @@ fn controls(ui: &mut Ui, facts: &ToolbarFacts) -> ToolbarAction { ui.separator(); ui.colored_label(ui.visuals().error_fg_color, notice); } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.button(texts::SETTINGS).clicked() { + action = ToolbarAction::Settings; + } + if let Some(time) = facts.average_response_time { + let text = texts::average_response_time(time.as_millis()); + ui.label(RichText::new(text).monospace().weak()); + } + }); action } diff --git a/crates/app/tests/views.rs b/crates/app/tests/views.rs index 7930fa8..599a3f8 100644 --- a/crates/app/tests/views.rs +++ b/crates/app/tests/views.rs @@ -538,3 +538,42 @@ fn desk_scrolls_when_narrow() { ); harness.get_by_role(Role::ScrollBar); } + +fn open_settings(harness: &mut Harness<'static, NeumannApp>) { + harness.get_by_label(texts::SETTINGS).click(); + wait_for_label(harness, texts::THEME); +} + +#[test] +fn settings_dialog_switches_theme_and_saves_on_change() { + let (_simulator, directory, mut harness) = connected_card(SimModel::Kh120Ii); + open_settings(&mut harness); + // The harness starts dark, so light is the choice that shows an effect. + harness.get_by_label(texts::THEME_LIGHT).click(); + harness.run_steps(2); + let preference = harness.ctx.options(|options| options.theme_preference); + assert_eq!(preference, egui::ThemePreference::Light); + let path = directory + .path() + .join(neumann_control_core::settings::SETTINGS_FILE); + let saved = neumann_control_core::settings::load(&path); + assert_eq!( + saved.desk.theme, + neumann_control_core::settings::ThemeChoice::Light + ); + harness.get_by_label(texts::CLOSE).click(); + harness.run_steps(2); + assert!(harness.query_by_label(texts::THEME).is_none()); +} + +#[test] +fn toolbar_shows_average_response_time_until_turned_off() { + let (_simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + wait_until(&mut harness, "the average response time", |harness| { + harness.query_by_label_contains("Ø ").is_some() + }); + open_settings(&mut harness); + harness.get_by_label(texts::SHOW_RESPONSE_TIMES).click(); + harness.run_steps(2); + assert!(harness.query_by_label_contains("Ø ").is_none()); +} From 590a64bf1bcb2ee4847a1fafe63627b9b9a0a06a Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:03:57 +0200 Subject: [PATCH 072/460] Describe the desk layout --- CHANGELOG.md | 10 +++++++--- README.md | 32 ++++++++++++++++++++------------ 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 919b884..b4ab480 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,13 @@ First version. entry by IPv6 address, with the interface index for link-local addresses. - Added speakers are remembered and reconnected at every start; a scan updates the stored address of a remembered speaker that moved. -- Removing a speaker from its card after a confirmation. -- Speaker cards with an output level fader, input meters (KH 750: also output meters) with clip lamps, mute and identify. -- A per-model settings area and a list of all values the speaker reports. +- Removing a speaker from its device panel after a confirmation. +- One channel strip per speaker with status LEDs, delay and standby knobs, an output level fader + that shows its value while dragging, input meters (KH 750: also output meters) with clip lamps, + response time, mute and identify. +- A device panel per speaker with every setting of its model and a list of all values the + speaker reports. +- A settings dialog for theme, meter range and response times. - Support for the KH 120 II and the KH 750 with writes; other models read-only. - Portable settings and log next to the executable. - A speaker simulator for both models used by the automated tests. diff --git a/README.md b/README.md index 3d6a279..f0b05f5 100644 --- a/README.md +++ b/README.md @@ -17,18 +17,26 @@ knowledge the path catalog is built from and the simulator, not a hardware test. - Finds speakers on the local network with mDNS (`_ssc._tcp`) when you press "Suchen" (a scan takes 4 seconds) and lets you choose which of them to add, or adds a speaker by its IPv6 address with "+ Adresse". Added speakers are remembered. -- One card per speaker: connection state, output level fader (0-120 dB SPL, mouse wheel in - 0.5 dB steps), input level meters with clip lamps (on the KH 750 also its output meters), - "Stumm" (mute) and "Ident." (identify), - firmware version and masked serial number. -- "Mehr" opens every setting the catalog knows for that model (output, input, standby, device, - rear panel), grouped; settings a model may write get a control, the rest are shown - greyed. +- One channel strip per speaker, side by side: name, four LEDs (connection, clip, device + warning, LOCAL), knobs for delay and standby time, output level fader (0-120 dB SPL, mouse + wheel in 0.5 dB steps, the value shown beside the handle while dragging), input level meters + with clip lamps (on the KH 750 also its output meters), response time, "Stumm" (mute) and + "Ident." (identify). +- Knobs: drag up or down (Shift for fine steps), turn the mouse wheel, or double-click to type + a value; the value is written once when you let go, stop the wheel or press Enter, and + Escape cancels. +- Clicking a speaker's name opens its device panel with every setting the catalog knows for + that model (output, input, standby, device, rear panel), grouped in two columns; settings a + model may write get a control, the rest are shown greyed. The panel also shows model, + firmware and masked serial number. Escape, "Schließen" or a click outside closes it. +- "Einstellungen" sets the theme (like Windows, light, dark), the lowest level the meters show + (-120, -90 or -60 dB) and whether response times are shown; the toolbar shows the average + response time of the connected speakers. - "Alle Werte..." lists every value the speaker reports in its own schema, with a path filter and copy buttons, including paths the catalog does not know yet. - Shows the control mode (NETWORK or LOCAL), which the CONTROL switch on the back of the speaker sets; it cannot be changed over the network. In LOCAL the speaker refuses every write, and the - card says so. + strip says so. - A speaker that stops answering is shown as unreachable and reconnected every 10 seconds. ## Supported models @@ -56,7 +64,7 @@ on some models (subwoofer outputs, logo brightness) appear only for those models 1. Start `neumann-control.exe` and press "Suchen". After the scan the dialog "Gefundene Lautsprecher" lists every speaker found with its name, model and masked address. Check the - ones you want and press "Hinzufügen"; each gets a card. Speakers that already have a card are + ones you want and press "Hinzufügen"; each gets a strip. Speakers that already have a strip are marked "bereits hinzugefügt" and cannot be checked again. "Abbrechen" adds nothing. 2. If the scan finds nothing (for example mDNS is blocked), press "+ Adresse" and enter the speaker's IPv6 address. A link-local address (`fe80::...`) needs the index of the network @@ -68,8 +76,8 @@ Added speakers, whether chosen from a scan or entered by address, are remembered at every start without a scan. When a later scan finds a remembered speaker at a different address (link-local addresses and interface indexes can change), the stored address is updated. -To remove a speaker, open "Mehr" on its card, press "Entfernen" and confirm with "Entfernen". -The card disappears and the speaker is no longer remembered. +To remove a speaker, open its device panel, press "Entfernen" and confirm with "Entfernen". +The strip disappears and the speaker is no longer remembered. IPv4 addresses are not accepted: the speakers are reachable only over IPv6. @@ -79,7 +87,7 @@ The app is portable and writes only next to the executable: | File | Content | |------|---------| -| `settings.json` | Remembered speakers (address, port, serial) and the window position and size | +| `settings.json` | Remembered speakers (address, port, serial), the window position and size, and the choices from "Einstellungen" | | `neumann-control.log` | Log of the current run, replaced at each start | Speaker addresses and serial numbers are masked in the log and in the interface. An unreadable From 1f75c173af8ea56da959903d9e6c1d1ec252290e Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:17:17 +0200 Subject: [PATCH 073/460] Keep knobs to their own cell in the panel --- crates/app/src/views/knob.rs | 25 ++++++++++++++++--------- crates/app/tests/details.rs | 12 ++++++++++++ 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/crates/app/src/views/knob.rs b/crates/app/src/views/knob.rs index fc45095..3dc8149 100644 --- a/crates/app/src/views/knob.rs +++ b/crates/app/src/views/knob.rs @@ -5,7 +5,8 @@ use std::f32::consts::PI; use std::ops::RangeInclusive; use eframe::egui::{ - pos2, vec2, Id, Key, Label, RichText, Sense, Shape, Stroke, TextEdit, Ui, WidgetInfo, + pos2, vec2, Align, Id, Key, Label, Layout, RichText, Sense, Shape, Stroke, TextEdit, Ui, + WidgetInfo, }; use neumann_control_core::catalog::{Catalog, Entry}; @@ -32,6 +33,8 @@ const ARC_SEGMENTS: usize = 32; const DRAG_VALUE_ID: &str = "knob_drag_value"; const EDIT_TEXT_ID: &str = "knob_edit_text"; const EDIT_WIDTH: f32 = 56.0; +/// Room for the value line under the knob. +const CELL_MIN_WIDTH: f32 = 56.0; /// Grid for entries without a catalog step. const UNSTEPPED_DIVISIONS: f64 = 1000.0; @@ -52,7 +55,11 @@ pub fn for_entry( let step = entry .step .unwrap_or((maximum - minimum) / UNSTEPPED_DIVISIONS); - let format = |value: f64| shown_value(entry, &Value::from(value)); + // Through the write's own type, so a whole-number entry reads "60 min", not "60.0 min". + let format = |value: f64| { + let typed = writes::number_value(entry, reported.as_ref(), value); + shown_value(entry, &typed) + }; let knob = Knob { label, caption, @@ -76,7 +83,7 @@ pub fn for_entry( pub struct Knob<'a> { /// Names the knob for screen readers and its tooltip. pub label: &'a str, - /// Whether the label also stands under the knob. + /// Whether label and value stand under the knob; beside a number field they would repeat it. pub caption: bool, pub range: RangeInclusive, /// The catalog step; values sent are on this grid. @@ -95,8 +102,10 @@ pub fn show(ui: &mut Ui, id: Id, knob: &Knob, state: &mut GestureState) -> Optio let device = knob.device.unwrap_or(*knob.range.start()); let enabled = knob.enabled && knob.device.is_some(); let mut sent = None; + // A cell of its own width: a centred layout would take the whole row from its neighbours. + let cell = vec2(knob.diameter.max(CELL_MIN_WIDTH), 0.0); let response = ui - .vertical_centered(|ui| { + .allocate_ui_with_layout(cell, Layout::top_down(Align::Center), |ui| { let size = vec2(knob.diameter, knob.diameter); let sense = if enabled { Sense::click_and_drag() @@ -152,12 +161,10 @@ pub fn show(ui: &mut Ui, id: Id, knob: &Knob, state: &mut GestureState) -> Optio } let edited = exact_entry(ui, id, &response, knob, value); sent = sent.or(edited); - if exact_entry_open(ui, id) { - return response; + if knob.caption && !exact_entry_open(ui, id) { + let text = RichText::new((knob.format)(value)).small().monospace(); + ui.add(Label::new(text)); } - ui.add(Label::new( - RichText::new((knob.format)(value)).small().monospace(), - )); response }) .inner; diff --git a/crates/app/tests/details.rs b/crates/app/tests/details.rs index bc61ef5..ac83e46 100644 --- a/crates/app/tests/details.rs +++ b/crates/app/tests/details.rs @@ -415,3 +415,15 @@ fn all_values_button_reads_the_whole_tree() { .any(|request| request.pointer("/osc/schema").is_some()) }); } + +#[test] +fn panel_number_rows_keep_their_labels_on_one_line() { + let (_simulator, _directory, harness) = expanded_card(SimModel::Kh120Ii); + for label in [STANDBY_TIME_LABEL, LOGO_LABEL, DELAY_LABEL] { + let rect = harness.get_by_role_and_label(Role::Label, label).rect(); + assert!(rect.width() > rect.height(), "{label} wraps: {rect:?}"); + } + // The number field shows the value; the knob beside it does not repeat it, and the strip + // shows a whole number as a whole number. + assert!(harness.query_by_label_contains("60.0 min").is_none()); +} From 4f2192a7b889fabe8b4c18dc24c6f250606ed3e3 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:44:32 +0200 Subject: [PATCH 074/460] Show the knob value while turning and redraw the knob --- crates/app/src/gesture.rs | 5 +++ crates/app/src/views/details.rs | 12 +++-- crates/app/src/views/fader.rs | 24 ++++++---- crates/app/src/views/knob.rs | 79 +++++++++++++++++++++++---------- crates/app/src/views/strip.rs | 13 ++++-- crates/app/tests/views.rs | 22 +++++++++ 6 files changed, 117 insertions(+), 38 deletions(-) diff --git a/crates/app/src/gesture.rs b/crates/app/src/gesture.rs index d545626..8cdd6d0 100644 --- a/crates/app/src/gesture.rs +++ b/crates/app/src/gesture.rs @@ -48,6 +48,11 @@ impl GestureState { self.pending = Some(target.clamp(*range.start(), *range.end())); } + /// Wheel steps wait to be sent. + pub fn wheel_pending(&self) -> bool { + self.pending.is_some() + } + pub fn due(&mut self, now: f64) -> Option { let idle = now - self.last_step >= WHEEL_IDLE_SECONDS - DUE_SLACK_SECONDS; if self.pending.is_some() && idle { diff --git a/crates/app/src/views/details.rs b/crates/app/src/views/details.rs index eb529d5..8ed9c5d 100644 --- a/crates/app/src/views/details.rs +++ b/crates/app/src/views/details.rs @@ -14,7 +14,6 @@ use crate::paths; use crate::state::{AllValuesState, SpeakerState, Status}; use crate::texts; -const KNOB_DIAMETER: f32 = 30.0; const COLUMNS: usize = 2; const GROUP_GAP: f32 = 8.0; @@ -117,8 +116,15 @@ fn row(ui: &mut Ui, fields: &mut Fields, entry: &Entry) { let catalog = fields.catalog; let label = texts::knob_label(&entry.label); let speaker = &mut *fields.speaker; - let sent = - knob::for_entry(ui, speaker, catalog, entry, &label, false, KNOB_DIAMETER); + let sent = knob::for_entry( + ui, + speaker, + catalog, + entry, + &label, + false, + knob::KNOB_DIAMETER, + ); fields.commands.extend(sent); fields.number(ui, entry); }); diff --git a/crates/app/src/views/fader.rs b/crates/app/src/views/fader.rs index d3a5fb3..c990ad7 100644 --- a/crates/app/src/views/fader.rs +++ b/crates/app/src/views/fader.rs @@ -4,8 +4,8 @@ use std::ops::RangeInclusive; use eframe::egui::{ - self, Align2, Area, Event, Frame, Key, MouseWheelUnit, Order, Response, Slider, SliderClamping, - Ui, Vec2, WidgetInfo, + self, Align2, Area, Event, Frame, Id, Key, MouseWheelUnit, Order, Rect, Response, Slider, + SliderClamping, Ui, Vec2, WidgetInfo, }; use neumann_control_core::catalog::Catalog; use neumann_control_core::speaker::Command; @@ -79,7 +79,15 @@ pub fn show( } } if response.dragged() && !aborted { - handle_label(ui, &response, &texts::readout(value, unit)); + let pointer = ui.input(|input| input.pointer.interact_pos()); + let y = pointer.map_or(response.rect.center().y, |position| position.y); + value_beside( + ui, + response.id, + response.rect, + y, + &texts::readout(value, unit), + ); } if let Some(due) = speaker.fader.due(now) { commands.extend(speaker.request(Intent::Level(due), catalog, now)); @@ -91,11 +99,11 @@ pub fn show( value } -fn handle_label(ui: &Ui, response: &Response, text: &str) { - let pointer = ui.input(|input| input.pointer.interact_pos()); - let y = pointer.map_or(response.rect.center().y, |position| position.y); - let anchor = egui::pos2(response.rect.right() + HANDLE_LABEL_GAP, y); - Area::new(response.id.with(HANDLE_LABEL_ID)) +/// A value floating right of `rect` at height `y` while a control is being moved, where the +/// eye is; fader and knob both use it. +pub(super) fn value_beside(ui: &Ui, id: Id, rect: Rect, y: f32, text: &str) { + let anchor = egui::pos2(rect.right() + HANDLE_LABEL_GAP, y); + Area::new(id.with(HANDLE_LABEL_ID)) .order(Order::Tooltip) .fixed_pos(anchor) .pivot(Align2::LEFT_CENTER) diff --git a/crates/app/src/views/knob.rs b/crates/app/src/views/knob.rs index 3dc8149..b10cca2 100644 --- a/crates/app/src/views/knob.rs +++ b/crates/app/src/views/knob.rs @@ -1,40 +1,47 @@ -//! A rotary knob for a catalog number: vertical drag, mouse wheel, double-click for exact entry. -//! It writes once at the end of a gesture and shows a device value outside its range as is. +//! A rotary knob for a catalog number: drag (up or right is more), mouse wheel, double-click +//! for exact entry. While it moves, the value floats beside it. It writes once at the end of a +//! gesture and shows a device value outside its range as is. use std::f32::consts::PI; use std::ops::RangeInclusive; use eframe::egui::{ - pos2, vec2, Align, Id, Key, Label, Layout, RichText, Sense, Shape, Stroke, TextEdit, Ui, - WidgetInfo, + pos2, vec2, Align, CursorIcon, Id, Key, Label, Layout, RichText, Sense, Shape, Stroke, + TextEdit, Ui, WidgetInfo, }; use neumann_control_core::catalog::{Catalog, Entry}; use neumann_control_core::speaker::Command; use serde_json::Value; -use super::fader::{drag_aborted, wheel_notches}; +use super::fader::{drag_aborted, value_beside, wheel_notches}; use crate::display::shown_value; use crate::gesture::GestureState; use crate::state::SpeakerState; use crate::ui_kit::theme::Tokens; use crate::writes::{self, Intent}; -/// Points of vertical drag for the whole range; Shift makes it this many times finer. -const FULL_RANGE_DRAG_POINTS: f64 = 200.0; +/// Points of drag for the whole range; Shift makes it this many times finer. +const FULL_RANGE_DRAG_POINTS: f64 = 300.0; const FINE_DIVISOR: f64 = 10.0; /// Wheel notches for the whole range, so wide ranges (delay in samples) stay usable. const FULL_RANGE_NOTCHES: f64 = 100.0; /// The ring leaves a gap at the bottom, from -135° to +135° around the top. const SWEEP_RADIANS: f32 = 1.5 * PI; -const RING_WIDTH: f32 = 2.0; +const ARC_WIDTH: f32 = 3.0; +/// Space between the value arc and the knob's body. +const ARC_GAP: f32 = 2.0; +const BODY_BORDER_WIDTH: f32 = 1.0; const INDICATOR_WIDTH: f32 = 2.0; +/// The pointer line runs from this fraction of the body radius outwards. +const INDICATOR_START: f32 = 0.3; const ARC_SEGMENTS: usize = 32; const DRAG_VALUE_ID: &str = "knob_drag_value"; const EDIT_TEXT_ID: &str = "knob_edit_text"; const EDIT_WIDTH: f32 = 56.0; /// Room for the value line under the knob. -const CELL_MIN_WIDTH: f32 = 56.0; +const CELL_MIN_WIDTH: f32 = 52.0; +pub const KNOB_DIAMETER: f32 = 34.0; /// Grid for entries without a catalog step. const UNSTEPPED_DIVISIONS: f64 = 1000.0; @@ -125,7 +132,9 @@ pub fn show(ui: &mut Ui, id: Id, knob: &Knob, state: &mut GestureState) -> Optio .data(|data| data.get_temp::(drag_id)) .unwrap_or(value); let fine = ui.input(|input| input.modifiers.shift); - let moved = drag_change(&knob.range, -f64::from(response.drag_delta().y), fine); + let delta = response.drag_delta(); + let points = f64::from(delta.x - delta.y); + let moved = drag_change(&knob.range, points, fine); value = (held + moved).clamp(*knob.range.start(), *knob.range.end()); ui.data_mut(|data| data.insert_temp(drag_id, value)); } @@ -153,6 +162,13 @@ pub fn show(ui: &mut Ui, id: Id, knob: &Knob, state: &mut GestureState) -> Optio ui.ctx() .request_repaint_after_secs((deadline - now).max(0.0) as f32); } + if enabled && response.hovered() { + ui.ctx().set_cursor_icon(CursorIcon::ResizeVertical); + } + let moving = (response.dragged() && !aborted) || state.wheel_pending(); + if moving { + value_beside(ui, id, rect, rect.center().y, &(knob.format)(value)); + } paint(ui, rect, knob, value, enabled, &tokens); let label = knob.label.to_owned(); response.widget_info(|| WidgetInfo::slider(enabled, value, &label)); @@ -243,30 +259,45 @@ fn paint( ) { let painter = ui.painter(); let center = rect.center(); - let radius = knob.diameter / 2.0 - RING_WIDTH; + let arc_radius = knob.diameter / 2.0 - ARC_WIDTH / 2.0; + let body_radius = arc_radius - ARC_WIDTH / 2.0 - ARC_GAP; let span = knob.range.end() - knob.range.start(); let position = ((value - knob.range.start()) / span).clamp(0.0, 1.0) as f32; let start = -SWEEP_RADIANS / 2.0; let angle = start + position * SWEEP_RADIANS; - let point = |angle: f32| center + radius * vec2(angle.sin(), -angle.cos()); - let track: Vec<_> = (0..=ARC_SEGMENTS) - .map(|segment| point(start + SWEEP_RADIANS * segment as f32 / ARC_SEGMENTS as f32)) - .collect(); + let on_circle = |radius: f32, angle: f32| center + radius * vec2(angle.sin(), -angle.cos()); + let arc = |from: f32, to: f32| -> Vec<_> { + (0..=ARC_SEGMENTS) + .map(|segment| { + on_circle( + arc_radius, + from + (to - from) * segment as f32 / ARC_SEGMENTS as f32, + ) + }) + .collect() + }; + let end = start + SWEEP_RADIANS; painter.add(Shape::line( - track, - Stroke::new(RING_WIDTH, tokens.meter_track), + arc(start, end), + Stroke::new(ARC_WIDTH, tokens.meter_track), )); let color = if enabled { tokens.accent } else { tokens.text_dim }; - let filled: Vec<_> = (0..=ARC_SEGMENTS) - .map(|segment| point(start + (angle - start) * segment as f32 / ARC_SEGMENTS as f32)) - .collect(); - painter.add(Shape::line(filled, Stroke::new(RING_WIDTH, color))); - let tip = point(angle); - let inner = center + (tip - center) * 0.35; + painter.add(Shape::line( + arc(start, angle), + Stroke::new(ARC_WIDTH, color), + )); + painter.circle( + center, + body_radius, + tokens.field, + Stroke::new(BODY_BORDER_WIDTH, tokens.border), + ); + let tip = on_circle(body_radius, angle); + let inner = on_circle(body_radius * INDICATOR_START, angle); painter.line_segment( [pos2(inner.x, inner.y), tip], Stroke::new(INDICATOR_WIDTH, tokens.text), @@ -285,7 +316,7 @@ mod tests { drag_change(&DELAY_RANGE, FULL_RANGE_DRAG_POINTS, false), 5760.0 ); - assert_eq!(drag_change(&DELAY_RANGE, 10.0, true), 28.8); + assert_eq!(drag_change(&DELAY_RANGE, 10.0, true), 19.2); } #[test] diff --git a/crates/app/src/views/strip.rs b/crates/app/src/views/strip.rs index f0b7ca1..82fe868 100644 --- a/crates/app/src/views/strip.rs +++ b/crates/app/src/views/strip.rs @@ -21,13 +21,12 @@ use crate::texts; use crate::ui_kit::theme::Tokens; use crate::writes::{self, Intent, Switch}; -pub const STRIP_WIDTH: f32 = 112.0; +pub const STRIP_WIDTH: f32 = 124.0; const STRIP_MARGIN: i8 = 6; const STRIP_RADIUS: u8 = 6; const BORDER_WIDTH: f32 = 1.0; const LED_RADIUS: f32 = 3.5; const LED_SPACING: f32 = 6.0; -const KNOB_DIAMETER: f32 = 26.0; const UNREACHABLE_OPACITY: f32 = 0.55; const METER_GROUP_GAP: f32 = 4.0; const DELAY_PATH: &str = "audio/out/delay"; @@ -132,7 +131,15 @@ fn knobs(ui: &mut Ui, speaker: &mut SpeakerState, catalog: &Catalog, commands: & let Some(entry) = entry(path) else { continue; }; - let sent = knob::for_entry(column, speaker, catalog, entry, label, true, KNOB_DIAMETER); + let sent = knob::for_entry( + column, + speaker, + catalog, + entry, + label, + true, + knob::KNOB_DIAMETER, + ); commands.extend(sent); } }); diff --git a/crates/app/tests/views.rs b/crates/app/tests/views.rs index 599a3f8..5ff4a82 100644 --- a/crates/app/tests/views.rs +++ b/crates/app/tests/views.rs @@ -577,3 +577,25 @@ fn toolbar_shows_average_response_time_until_turned_off() { harness.run_steps(2); assert!(harness.query_by_label_contains("Ø ").is_none()); } + +#[test] +fn knob_value_is_shown_beside_it_while_dragging() { + let (_simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + let start = harness + .get_by_role_and_label(Role::Slider, texts::KNOB_DELAY) + .rect() + .center(); + let unit = "1/48 kHz"; + assert_eq!(harness.query_all_by_label_contains(unit).count(), 1); + harness.drag_at(start); + for frame in 1..=4 { + harness.hover_at(start - egui::vec2(0.0, 4.0 * frame as f32)); + harness.step(); + } + // Under the knob and beside it. + assert_eq!(harness.query_all_by_label_contains(unit).count(), 2); + harness.key_press(egui::Key::Escape); + harness.drop_at(start); + harness.run_steps(2); + assert_eq!(harness.query_all_by_label_contains(unit).count(), 1); +} From 968d5ddfb43d0ebd4dd931b05f05761eddf10c03 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:56:24 +0200 Subject: [PATCH 075/460] Show the delay in ms and turn knobs around their centre --- crates/app/src/display.rs | 31 +++++++-- crates/app/src/texts.rs | 1 + crates/app/src/views/details_fields.rs | 16 ++++- crates/app/src/views/fader.rs | 7 +- crates/app/src/views/knob.rs | 95 ++++++++++++++++++++------ crates/app/tests/details.rs | 27 ++++++++ crates/app/tests/views.rs | 95 ++++++++++++++++---------- crates/core/src/catalog.rs | 13 ++++ crates/core/src/paths.toml | 3 + 9 files changed, 221 insertions(+), 67 deletions(-) diff --git a/crates/app/src/display.rs b/crates/app/src/display.rs index 3697c32..99a68d2 100644 --- a/crates/app/src/display.rs +++ b/crates/app/src/display.rs @@ -42,14 +42,24 @@ pub fn capped(text: &str) -> String { format!("{kept}{ELLIPSIS}") } -/// The device reports whole kelvin, so tenths of a degree would only show rounding noise. -fn converted(conversion: Conversion, number: f64) -> String { - let unit = match conversion { +/// In the unit shown. The device reports whole kelvin, so tenths of a degree would only show +/// rounding noise; a delay sample is about 0.02 ms, so it needs two decimals. +pub fn converted(conversion: Conversion, number: f64) -> String { + let shown = conversion.apply(number); + let unit = converted_unit(conversion); + match conversion { + // Adding zero turns the -0 of a slightly negative reading into 0. + Conversion::KelvinToCelsius => format!("{} {unit}", shown.round() + 0.0), + Conversion::SamplesToMilliseconds => format!("{shown:.2} {unit}"), + } +} + +/// The unit a converted value is shown in. +pub fn converted_unit(conversion: Conversion) -> &'static str { + match conversion { Conversion::KelvinToCelsius => texts::CELSIUS, - }; - // Adding zero turns the -0 of a slightly negative reading into 0. - let whole = conversion.apply(number).round() + 0.0; - format!("{whole} {unit}") + Conversion::SamplesToMilliseconds => texts::MILLISECONDS, + } } fn raw_text(value: &Value) -> String { @@ -89,6 +99,13 @@ mod tests { assert_eq!(capped(&exact), exact); } + #[test] + fn delay_is_shown_in_milliseconds() { + let delay = entry("audio/out/delay"); + assert_eq!(shown_value(delay, &json!(3732)), "77.75 ms"); + assert_eq!(shown_value(delay, &json!(0)), "0.00 ms"); + } + #[test] fn firmware_underscores_become_dots() { assert_eq!(firmware_version("2_1_2"), "2.1.2"); diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 1933659..4faedac 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -48,6 +48,7 @@ pub const APPLY: &str = "Übernehmen"; pub const ON: &str = "an"; pub const OFF: &str = "aus"; pub const CELSIUS: &str = "°C"; +pub const MILLISECONDS: &str = "ms"; pub const ALL_VALUES_FILTER_LABEL: &str = "Pfad enthält"; pub const ALL_VALUES_RELOAD: &str = "Neu einlesen"; diff --git a/crates/app/src/views/details_fields.rs b/crates/app/src/views/details_fields.rs index 829b288..65fd4bb 100644 --- a/crates/app/src/views/details_fields.rs +++ b/crates/app/src/views/details_fields.rs @@ -6,7 +6,7 @@ use neumann_control_core::catalog::{Catalog, Entry}; use neumann_control_core::speaker::Command; use serde_json::Value; -use crate::display::capped; +use crate::display::{capped, converted_unit}; use crate::state::SpeakerState; use crate::texts; use crate::writes::{self, Intent}; @@ -69,14 +69,26 @@ impl Fields<'_> { let pending_id = ui.id().with(entry.path.to_string()).with(PENDING_NUMBER_ID); let held = ui.data(|data| data.get_temp::(pending_id)); let mut value = held.or(device).unwrap_or(entry.min.unwrap_or_default()); + let step = entry.step.unwrap_or(UNSTEPPED_DRAG_SPEED); let mut drag = DragValue::new(&mut value) .range(min..=max) .clamp_existing_to_range(false) .custom_formatter(|number, _| number.to_string()) - .speed(entry.step.unwrap_or(UNSTEPPED_DRAG_SPEED)); + .speed(step); if let Some(unit) = &entry.unit { drag = drag.suffix(format!(" {unit}")); } + if let Some(conversion) = entry.conversion { + // Typed and shown in the converted unit; back on the device's step grid, so the + // shown text parses to exactly the device value and a visit writes nothing. + drag = drag + .custom_formatter(move |number, _| conversion.apply(number).to_string()) + .custom_parser(move |text| { + let typed: f64 = text.trim().replace(',', ".").parse().ok()?; + Some((conversion.invert(typed) / step).round() * step) + }) + .suffix(format!(" {}", converted_unit(conversion))); + } let response = ui .horizontal_wrapped(|ui| { let label = ui.label(&entry.label); diff --git a/crates/app/src/views/fader.rs b/crates/app/src/views/fader.rs index c990ad7..bd7164a 100644 --- a/crates/app/src/views/fader.rs +++ b/crates/app/src/views/fader.rs @@ -4,8 +4,8 @@ use std::ops::RangeInclusive; use eframe::egui::{ - self, Align2, Area, Event, Frame, Id, Key, MouseWheelUnit, Order, Rect, Response, Slider, - SliderClamping, Ui, Vec2, WidgetInfo, + self, Align2, Area, Event, Frame, Id, Key, Label, MouseWheelUnit, Order, Rect, Response, + Slider, SliderClamping, Ui, Vec2, WidgetInfo, }; use neumann_control_core::catalog::Catalog; use neumann_control_core::speaker::Command; @@ -109,7 +109,8 @@ pub(super) fn value_beside(ui: &Ui, id: Id, rect: Rect, y: f32, text: &str) { .pivot(Align2::LEFT_CENTER) .interactable(false) .show(ui.ctx(), |ui| { - Frame::popup(ui.style()).show(ui, |ui| ui.label(text)); + // One line: the area is narrow and would wrap the value letter by letter. + Frame::popup(ui.style()).show(ui, |ui| ui.add(Label::new(text).extend())); }); } diff --git a/crates/app/src/views/knob.rs b/crates/app/src/views/knob.rs index b10cca2..a855958 100644 --- a/crates/app/src/views/knob.rs +++ b/crates/app/src/views/knob.rs @@ -1,12 +1,12 @@ -//! A rotary knob for a catalog number: drag (up or right is more), mouse wheel, double-click -//! for exact entry. While it moves, the value floats beside it. It writes once at the end of a +//! A rotary knob for a catalog number: turn it by moving the pointer around it (clockwise is +//! more), mouse wheel, double-click for exact entry. While it moves, the value floats beside it. It writes once at the end of a //! gesture and shows a device value outside its range as is. use std::f32::consts::PI; use std::ops::RangeInclusive; use eframe::egui::{ - pos2, vec2, Align, CursorIcon, Id, Key, Label, Layout, RichText, Sense, Shape, Stroke, + pos2, vec2, Align, CursorIcon, Id, Key, Label, Layout, Pos2, RichText, Sense, Shape, Stroke, TextEdit, Ui, WidgetInfo, }; @@ -21,9 +21,10 @@ use crate::state::SpeakerState; use crate::ui_kit::theme::Tokens; use crate::writes::{self, Intent}; -/// Points of drag for the whole range; Shift makes it this many times finer. -const FULL_RANGE_DRAG_POINTS: f64 = 300.0; +/// Shift makes turning this many times finer. const FINE_DIVISOR: f64 = 10.0; +/// Closer to the centre than this the pointer's angle jumps around, so it is ignored. +const DEAD_ZONE_RADIUS: f32 = 4.0; /// Wheel notches for the whole range, so wide ranges (delay in samples) stay usable. const FULL_RANGE_NOTCHES: f64 = 100.0; /// The ring leaves a gap at the bottom, from -135° to +135° around the top. @@ -37,6 +38,7 @@ const INDICATOR_WIDTH: f32 = 2.0; const INDICATOR_START: f32 = 0.3; const ARC_SEGMENTS: usize = 32; const DRAG_VALUE_ID: &str = "knob_drag_value"; +const DRAG_ANGLE_ID: &str = "knob_drag_angle"; const EDIT_TEXT_ID: &str = "knob_edit_text"; const EDIT_WIDTH: f32 = 56.0; /// Room for the value line under the knob. @@ -121,22 +123,36 @@ pub fn show(ui: &mut Ui, id: Id, knob: &Knob, state: &mut GestureState) -> Optio }; let (rect, response) = ui.allocate_exact_size(size, sense); let drag_id = id.with(DRAG_VALUE_ID); + let angle_id = id.with(DRAG_ANGLE_ID); + let pointer = ui.input(|input| input.pointer.interact_pos()); + let angle = pointer.and_then(|pointer| pointer_angle(rect.center(), pointer)); let mut value = state.shown(device, now); if response.drag_started() { state.drag_started(); - ui.data_mut(|data| data.insert_temp(drag_id, value)); + ui.data_mut(|data| { + data.insert_temp(drag_id, value); + data.remove::(angle_id); + }); } let aborted = drag_aborted(ui, &response); if response.dragged() && !aborted { let held = ui .data(|data| data.get_temp::(drag_id)) .unwrap_or(value); + let last = ui.data(|data| data.get_temp::(angle_id)); let fine = ui.input(|input| input.modifiers.shift); - let delta = response.drag_delta(); - let points = f64::from(delta.x - delta.y); - let moved = drag_change(&knob.range, points, fine); + let turned = last + .zip(angle) + .map_or(0.0, |(last, angle)| angle_change(last, angle)); + let moved = turn_change(&knob.range, turned, fine); value = (held + moved).clamp(*knob.range.start(), *knob.range.end()); - ui.data_mut(|data| data.insert_temp(drag_id, value)); + ui.data_mut(|data| { + data.insert_temp(drag_id, value); + // Inside the dead zone the last good angle stays the reference. + if let Some(angle) = angle { + data.insert_temp(angle_id, angle); + } + }); } if response.drag_stopped() && !aborted { let dragged = ui.data(|data| data.get_temp::(drag_id)); @@ -163,7 +179,12 @@ pub fn show(ui: &mut Ui, id: Id, knob: &Knob, state: &mut GestureState) -> Optio .request_repaint_after_secs((deadline - now).max(0.0) as f32); } if enabled && response.hovered() { - ui.ctx().set_cursor_icon(CursorIcon::ResizeVertical); + let icon = if response.dragged() { + CursorIcon::Grabbing + } else { + CursorIcon::Grab + }; + ui.ctx().set_cursor_icon(icon); } let moving = (response.dragged() && !aborted) || state.wheel_pending(); if moving { @@ -191,12 +212,32 @@ pub fn show(ui: &mut Ui, id: Id, knob: &Knob, state: &mut GestureState) -> Optio sent } -/// The value change for a vertical drag of `points` (upwards positive). -pub fn drag_change(range: &RangeInclusive, points: f64, fine: bool) -> f64 { +/// The pointer's angle around `center`, clockwise from the top; none too close to the centre. +pub fn pointer_angle(center: Pos2, pointer: Pos2) -> Option { + let offset = pointer - center; + if offset.length() < DEAD_ZONE_RADIUS { + return None; + } + Some(offset.x.atan2(-offset.y)) +} + +/// The turn from `previous` to `current`, the short way round (across the bottom too). +pub fn angle_change(previous: f32, current: f32) -> f32 { + let change = current - previous; + if change > PI { + return change - 2.0 * PI; + } + if change < -PI { + return change + 2.0 * PI; + } + change +} + +/// The value change for a turn of `radians`: the knob's whole sweep is the whole range. +pub fn turn_change(range: &RangeInclusive, radians: f32, fine: bool) -> f64 { let span = range.end() - range.start(); - let per_point = span / FULL_RANGE_DRAG_POINTS; let divisor = if fine { FINE_DIVISOR } else { 1.0 }; - points * per_point / divisor + f64::from(radians) / f64::from(SWEEP_RADIANS) * span / divisor } /// One wheel notch: a whole number of catalog steps, at least one, about a hundredth of the @@ -311,12 +352,28 @@ mod tests { const DELAY_RANGE: RangeInclusive = 0.0..=5760.0; #[test] - fn a_full_drag_covers_the_range_and_shift_is_finer() { + fn pointer_angle_is_measured_clockwise_from_the_top() { + let center = pos2(50.0, 50.0); + assert_eq!(pointer_angle(center, pos2(50.0, 20.0)), Some(0.0)); + assert_eq!(pointer_angle(center, pos2(80.0, 50.0)), Some(PI / 2.0)); assert_eq!( - drag_change(&DELAY_RANGE, FULL_RANGE_DRAG_POINTS, false), - 5760.0 + pointer_angle(center, pos2(52.0, 51.0)), + None, + "too close to the centre" ); - assert_eq!(drag_change(&DELAY_RANGE, 10.0, true), 19.2); + } + + #[test] + fn turning_takes_the_short_way_across_the_bottom() { + let small = 0.1; + assert!((angle_change(PI - small, -PI + small) - 2.0 * small).abs() < 1e-5); + assert!((angle_change(-PI + small, PI - small) + 2.0 * small).abs() < 1e-5); + } + + #[test] + fn a_full_sweep_covers_the_range_and_shift_is_finer() { + assert_eq!(turn_change(&DELAY_RANGE, SWEEP_RADIANS, false), 5760.0); + assert!((turn_change(&DELAY_RANGE, SWEEP_RADIANS, true) - 576.0).abs() < 1e-9); } #[test] diff --git a/crates/app/tests/details.rs b/crates/app/tests/details.rs index ac83e46..0d4b980 100644 --- a/crates/app/tests/details.rs +++ b/crates/app/tests/details.rs @@ -427,3 +427,30 @@ fn panel_number_rows_keep_their_labels_on_one_line() { // shows a whole number as a whole number. assert!(harness.query_by_label_contains("60.0 min").is_none()); } + +#[test] +fn delay_is_typed_in_milliseconds_and_sent_in_samples() { + let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); + type_number(&mut harness, DELAY_LABEL, "10"); + harness.key_press(egui::Key::Enter); + wait_until(&mut harness, "the delay Set", |_| { + !sets_to(&simulator, DELAY_PATH).is_empty() + }); + assert_eq!(sets_to(&simulator, DELAY_PATH), vec![json!(480)]); +} + +#[test] +fn visiting_a_delay_in_milliseconds_writes_nothing() { + let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); + // One sample is 0.0208333… ms, which no decimal text holds exactly. + simulator.set_value("audio/out/delay", json!(1)); + wait_until(&mut harness, "the reported delay", |harness| { + harness.query_by_label_contains("0.02").is_some() + }); + harness.get_by_label(DELAY_LABEL).click(); + harness.run_steps(1); + harness.key_press(egui::Key::Tab); + harness.run_steps(IDLE_FRAMES); + barrier(&simulator); + assert!(sets_to(&simulator, DELAY_PATH).is_empty()); +} diff --git a/crates/app/tests/views.rs b/crates/app/tests/views.rs index 5ff4a82..69204c9 100644 --- a/crates/app/tests/views.rs +++ b/crates/app/tests/views.rs @@ -401,6 +401,34 @@ fn strip_content_stacks_top_down() { ); } +/// Point on a circle of `radius` around `center` at `degrees` clockwise from the top. +fn around(center: egui::Pos2, radius: f32, degrees: f32) -> egui::Pos2 { + let radians = degrees.to_radians(); + center + radius * egui::vec2(radians.sin(), -radians.cos()) +} + +/// Presses at `from` degrees on the knob's rim and moves clockwise to `to` without releasing. +fn turn_knob( + harness: &mut Harness<'static, NeumannApp>, + label: &str, + from: f32, + to: f32, +) -> egui::Pos2 { + let center = harness + .get_by_role_and_label(Role::Slider, label) + .rect() + .center(); + let radius = 14.0; + harness.drag_at(around(center, radius, from)); + let steps = 6; + for step in 1..=steps { + let degrees = from + (to - from) * step as f32 / steps as f32; + harness.hover_at(around(center, radius, degrees)); + harness.step(); + } + around(center, radius, to) +} + fn delay_sets(simulator: &Simulator) -> Vec { simulator .requests() @@ -444,21 +472,13 @@ fn kh750_standby_knob_is_read_only() { #[test] fn knob_drag_writes_once_on_release() { let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); - let start = harness - .get_by_role_and_label(Role::Slider, texts::KNOB_DELAY) - .rect() - .center(); - harness.drag_at(start); - for frame in 1..=5 { - harness.hover_at(start - egui::vec2(0.0, 4.0 * frame as f32)); - harness.step(); - } + let end = turn_knob(&mut harness, texts::KNOB_DELAY, 90.0, 150.0); barrier(&simulator); assert!( delay_sets(&simulator).is_empty(), - "a Set went out while dragging" + "a Set went out while turning" ); - harness.drop_at(start - egui::vec2(0.0, 20.0)); + harness.drop_at(end); wait_until(&mut harness, "the delay Set", |_| { !delay_sets(&simulator).is_empty() }); @@ -472,18 +492,10 @@ fn knob_drag_writes_once_on_release() { #[test] fn escape_during_knob_drag_sends_nothing() { let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); - let start = harness - .get_by_role_and_label(Role::Slider, texts::KNOB_DELAY) - .rect() - .center(); - harness.drag_at(start); - for frame in 1..=3 { - harness.hover_at(start - egui::vec2(0.0, 4.0 * frame as f32)); - harness.step(); - } + let end = turn_knob(&mut harness, texts::KNOB_DELAY, 90.0, 150.0); harness.key_press(egui::Key::Escape); harness.step(); - harness.drop_at(start - egui::vec2(0.0, 20.0)); + harness.drop_at(end); harness.run_steps(10); barrier(&simulator); assert!(delay_sets(&simulator).is_empty()); @@ -581,21 +593,32 @@ fn toolbar_shows_average_response_time_until_turned_off() { #[test] fn knob_value_is_shown_beside_it_while_dragging() { let (_simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); - let start = harness - .get_by_role_and_label(Role::Slider, texts::KNOB_DELAY) - .rect() - .center(); - let unit = "1/48 kHz"; - assert_eq!(harness.query_all_by_label_contains(unit).count(), 1); - harness.drag_at(start); - for frame in 1..=4 { - harness.hover_at(start - egui::vec2(0.0, 4.0 * frame as f32)); - harness.step(); - } - // Under the knob and beside it. - assert_eq!(harness.query_all_by_label_contains(unit).count(), 2); + let unit = " ms"; + let shown = |harness: &Harness<'static, NeumannApp>| { + harness + .query_all_by_label_contains(unit) + .filter(|node| node.accesskit_node().role() == Role::Label) + // The delay has decimals; the response time ("14 ms") has none. + .filter(|node| { + // egui puts a label's text into the node's value. + node.accesskit_node() + .value() + .is_some_and(|text| text.contains('.')) + }) + .map(|node| node.rect()) + .collect::>() + }; + assert_eq!(shown(&harness).len(), 1); + let end = turn_knob(&mut harness, texts::KNOB_DELAY, 90.0, 150.0); + // Under the knob and beside it, each on one line. + let labels = shown(&harness); + assert_eq!(labels.len(), 2); + assert!( + labels.iter().all(|rect| rect.width() > rect.height()), + "{labels:?}" + ); harness.key_press(egui::Key::Escape); - harness.drop_at(start); + harness.drop_at(end); harness.run_steps(2); - assert_eq!(harness.query_all_by_label_contains(unit).count(), 1); + assert_eq!(shown(&harness).len(), 1); } diff --git a/crates/core/src/catalog.rs b/crates/core/src/catalog.rs index 062d99b..fdf8e58 100644 --- a/crates/core/src/catalog.rs +++ b/crates/core/src/catalog.rs @@ -15,6 +15,8 @@ const PRINTABLE_ASCII: std::ops::RangeInclusive = ' '..='~'; /// Relative to the step size; absorbs binary rounding of decimal steps. const STEP_TOLERANCE: f64 = 1e-9; const KELVIN_OFFSET: f64 = 273.15; +/// The speakers count delay in samples at 48 kHz. +const SAMPLES_PER_MILLISECOND: f64 = 48.0; #[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] #[serde(rename_all = "snake_case")] @@ -88,12 +90,23 @@ pub enum ModelClass { #[serde(rename_all = "snake_case")] pub enum Conversion { KelvinToCelsius, + SamplesToMilliseconds, } impl Conversion { + /// From the device's unit to the one shown. pub fn apply(&self, value: f64) -> f64 { match self { Conversion::KelvinToCelsius => value - KELVIN_OFFSET, + Conversion::SamplesToMilliseconds => value / SAMPLES_PER_MILLISECOND, + } + } + + /// From the unit shown back to the device's, for values the user types. + pub fn invert(&self, value: f64) -> f64 { + match self { + Conversion::KelvinToCelsius => value + KELVIN_OFFSET, + Conversion::SamplesToMilliseconds => value * SAMPLES_PER_MILLISECOND, } } } diff --git a/crates/core/src/paths.toml b/crates/core/src/paths.toml index f991bcc..2770cc3 100644 --- a/crates/core/src/paths.toml +++ b/crates/core/src/paths.toml @@ -78,6 +78,7 @@ kind = "number" poll = "fast" class = "all" unit = "1/48 kHz" +conversion = "samples_to_milliseconds" min = 0 max = 5760 max_subwoofer = 1000 @@ -130,6 +131,7 @@ kind = "number" poll = "fast" class = "subwoofer" unit = "1/48 kHz" +conversion = "samples_to_milliseconds" min = 0 max = 1000 step = 1 @@ -180,6 +182,7 @@ kind = "number" poll = "fast" class = "subwoofer" unit = "1/48 kHz" +conversion = "samples_to_milliseconds" min = 0 max = 1000 step = 1 From 21d8c83de6547f533195a29ee3c74dd5bc55dac7 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:40:54 +0200 Subject: [PATCH 076/460] Make fader and knobs finer and typeable --- crates/app/src/texts.rs | 1 + crates/app/src/views/fader.rs | 149 +++++++++++++++++++++++++--------- crates/app/src/views/knob.rs | 64 ++++++++++----- crates/app/src/views/strip.rs | 21 +++-- crates/app/tests/views.rs | 98 ++++++++++++++++++++++ 5 files changed, 267 insertions(+), 66 deletions(-) diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 4faedac..17dd94e 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -28,6 +28,7 @@ pub const LED_LOCAL: &str = "LOCAL: Schalter hinten, Schreiben gesperrt"; pub const KNOB_DELAY: &str = "Delay"; pub const KNOB_STANDBY: &str = "Standby"; pub const CLOSE: &str = "Schließen"; +pub const CONTROL_HINT: &str = "Shift: fein · Doppelklick: Wert eingeben"; pub const PANEL_TITLE: &str = "Lautsprecher"; pub const SETTINGS: &str = "Einstellungen"; pub const SETTINGS_TITLE: &str = "Einstellungen"; diff --git a/crates/app/src/views/fader.rs b/crates/app/src/views/fader.rs index bd7164a..2b39f76 100644 --- a/crates/app/src/views/fader.rs +++ b/crates/app/src/views/fader.rs @@ -1,11 +1,11 @@ -//! The card's level fader: a vertical slider that writes once it settles, and the mouse wheel -//! in 0.5 dB steps per notch. +//! The strip's level fader: dragged relative to the pointer (Shift for fine moves), written once +//! when let go, and the mouse wheel in 0.5 dB steps per notch. use std::ops::RangeInclusive; use eframe::egui::{ - self, Align2, Area, Event, Frame, Id, Key, Label, MouseWheelUnit, Order, Rect, Response, - Slider, SliderClamping, Ui, Vec2, WidgetInfo, + self, vec2, Align2, Area, CornerRadius, CursorIcon, Event, Frame, Id, Key, Label, + MouseWheelUnit, Order, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, WidgetInfo, }; use neumann_control_core::catalog::Catalog; use neumann_control_core::speaker::Command; @@ -14,18 +14,31 @@ use crate::fader::WHEEL_STEP_DB; use crate::paths; use crate::state::SpeakerState; use crate::texts; +use crate::ui_kit::theme::Tokens; use crate::writes::{self, Intent}; const DRAG_ABORTED_ID: &str = "fader_drag_aborted"; const HANDLE_LABEL_ID: &str = "fader_handle_label"; +const DRAG_VALUE_ID: &str = "fader_drag_value"; +const FADER_WIDTH: f32 = 24.0; +const TRACK_WIDTH: f32 = 4.0; +const TRACK_RADIUS: u8 = 2; +const CAP_WIDTH: f32 = 22.0; +const CAP_HEIGHT: f32 = 12.0; +const CAP_RADIUS: u8 = 3; +const CAP_BORDER_WIDTH: f32 = 1.0; +const CAP_LINE_WIDTH: f32 = 1.5; +/// Shift makes a drag this many times finer, on the fader and on knobs. +pub(super) const FINE_DIVISOR: f64 = 10.0; /// Space between the fader and the value shown beside its handle while dragging. const HANDLE_LABEL_GAP: f32 = 6.0; -/// The slider's length. -pub const FADER_HEIGHT: f32 = 150.0; +/// The fader's length; the whole range spans it. +pub const FADER_HEIGHT: f32 = 200.0; -/// The vertical level fader; returns the value it shows. While dragging, the value also stands -/// beside the handle, where the eye is. +/// The vertical level fader; returns the value it shows. It moves relative to the pointer from +/// wherever it is grabbed, never jumping to the pointer; Shift makes it `FINE_DIVISOR` times +/// finer. While dragging, the value also stands beside the fader, where the eye is. pub fn show( ui: &mut Ui, speaker: &mut SpeakerState, @@ -35,37 +48,51 @@ pub fn show( range: RangeInclusive, unit: &str, ) -> f64 { + let tokens = Tokens::current(ui.ctx()); let path = paths::path(paths::OUTPUT_LEVEL); let enabled = device.is_some() && writes::can_write(speaker, catalog, &path); let device = device.unwrap_or(*range.start()); let now = ui.input(|input| input.time); let mut value = speaker.fader.shown(device, now); - ui.spacing_mut().slider_width = FADER_HEIGHT; - let slider = Slider::new(&mut value, range.clone()) - .vertical() - .show_value(false) - .step_by(WHEEL_STEP_DB) - // The default also clamps and snaps the reported level, so the readout would show a - // value the speaker does not have. - .clamping(SliderClamping::Edits); - let response = ui.add_enabled(enabled, slider); - response.widget_info(|| WidgetInfo::slider(enabled, value, texts::LEVEL)); + // Drag only: a drag then starts on the press itself, so a wheel step pending from just + // before cannot fall due in between and reach the speaker. + let sense = if enabled { + Sense::drag() + } else { + Sense::hover() + }; + let (rect, response) = ui.allocate_exact_size(vec2(FADER_WIDTH, FADER_HEIGHT), sense); + let drag_id = response.id.with(DRAG_VALUE_ID); if response.drag_started() { speaker.fader.drag_started(); + ui.data_mut(|data| data.insert_temp(drag_id, value)); } let aborted = drag_aborted(ui, &response); - if aborted { - value = speaker.fader.shown(device, now); + let dragging = response.dragged() && !aborted; + if dragging { + let held = ui + .data(|data| data.get_temp::(drag_id)) + .unwrap_or(value); + let fine = ui.input(|input| input.modifiers.shift); + let moved = drag_change(&range, -f64::from(response.drag_delta().y), fine); + let unsnapped = (held + moved).clamp(*range.start(), *range.end()); + ui.data_mut(|data| data.insert_temp(drag_id, unsnapped)); + value = on_level_grid(unsnapped); } - // While dragging, the slider follows the pointer and the speaker hears nothing. The release - // frame still moves the value but no longer counts as dragged, so this one condition sends - // the final value of a drag, a click on the track and a key press alike. - let settled = response.changed() && !response.dragged() && !aborted; - if settled { - commands.extend(speaker.request(Intent::Level(value), catalog, now)); + // The speaker hears nothing while dragging; the release sends the value once. + if response.drag_stopped() { + let dragged = ui.data(|data| data.get_temp::(drag_id)); + ui.data_mut(|data| data.remove::(drag_id)); + if let Some(dragged) = dragged.filter(|_| !aborted) { + value = on_level_grid(dragged); + commands.extend(speaker.request(Intent::Level(value), catalog, now)); + } } - if enabled && response.hovered() { - // The cards sit in a scroll area, which would scroll along with the fader. + // A held fader ignores the wheel: steps added now would overwrite the drag's value. + let held = response.is_pointer_button_down_on() || response.dragged(); + if enabled && response.hovered() && !held { + ui.ctx().set_cursor_icon(CursorIcon::ResizeVertical); + // The strips sit in a scroll area, which would scroll along with the fader. ui.input_mut(|input| input.smooth_scroll_delta = Vec2::ZERO); let wheel = wheel_notches(ui); for notches in &wheel { @@ -73,21 +100,17 @@ pub fn show( .fader .on_wheel(*notches, WHEEL_STEP_DB, device, range.clone(), now); } - // Only the wheel changes what is shown here; a drag keeps its own value. if !wheel.is_empty() { value = speaker.fader.shown(device, now); } } - if response.dragged() && !aborted { - let pointer = ui.input(|input| input.pointer.interact_pos()); - let y = pointer.map_or(response.rect.center().y, |position| position.y); - value_beside( - ui, - response.id, - response.rect, - y, - &texts::readout(value, unit), - ); + paint(ui, rect, &range, value, enabled, &tokens); + response.widget_info(|| WidgetInfo::slider(enabled, value, texts::LEVEL)); + let id = response.id; + response.on_hover_text(texts::CONTROL_HINT); + if dragging { + let y = value_y(rect, &range, value); + value_beside(ui, id, rect, y, &texts::readout(value, unit)); } if let Some(due) = speaker.fader.due(now) { commands.extend(speaker.request(Intent::Level(due), catalog, now)); @@ -99,6 +122,54 @@ pub fn show( value } +/// The value change for a drag of `points` (upwards positive): the fader's length is the +/// whole range. +pub fn drag_change(range: &RangeInclusive, points: f64, fine: bool) -> f64 { + let span = range.end() - range.start(); + let divisor = if fine { FINE_DIVISOR } else { 1.0 }; + points / f64::from(FADER_HEIGHT) * span / divisor +} + +fn on_level_grid(value: f64) -> f64 { + (value / WHEEL_STEP_DB).round() * WHEEL_STEP_DB +} + +fn value_y(rect: Rect, range: &RangeInclusive, value: f64) -> f32 { + let span = range.end() - range.start(); + let position = ((value - range.start()) / span).clamp(0.0, 1.0) as f32; + rect.bottom() - position * rect.height() +} + +fn paint( + ui: &Ui, + rect: Rect, + range: &RangeInclusive, + value: f64, + enabled: bool, + tokens: &Tokens, +) { + let painter = ui.painter(); + let track = Rect::from_center_size(rect.center(), vec2(TRACK_WIDTH, rect.height())); + painter.rect_filled(track, CornerRadius::same(TRACK_RADIUS), tokens.meter_track); + let y = value_y(rect, range, value); + let fill = Rect::from_min_max(egui::pos2(track.left(), y), track.right_bottom()); + let color = if enabled { + tokens.accent + } else { + tokens.text_dim + }; + painter.rect_filled(fill, CornerRadius::same(TRACK_RADIUS), color); + let cap = Rect::from_center_size(egui::pos2(rect.center().x, y), vec2(CAP_WIDTH, CAP_HEIGHT)); + painter.rect( + cap, + CornerRadius::same(CAP_RADIUS), + tokens.field, + Stroke::new(CAP_BORDER_WIDTH, tokens.border), + StrokeKind::Inside, + ); + painter.hline(cap.x_range(), y, Stroke::new(CAP_LINE_WIDTH, tokens.text)); +} + /// A value floating right of `rect` at height `y` while a control is being moved, where the /// eye is; fader and knob both use it. pub(super) fn value_beside(ui: &Ui, id: Id, rect: Rect, y: f32, text: &str) { diff --git a/crates/app/src/views/knob.rs b/crates/app/src/views/knob.rs index a855958..fa982bc 100644 --- a/crates/app/src/views/knob.rs +++ b/crates/app/src/views/knob.rs @@ -10,25 +10,25 @@ use eframe::egui::{ TextEdit, Ui, WidgetInfo, }; -use neumann_control_core::catalog::{Catalog, Entry}; +use neumann_control_core::catalog::{Catalog, Conversion, Entry}; use neumann_control_core::speaker::Command; use serde_json::Value; -use super::fader::{drag_aborted, value_beside, wheel_notches}; +use super::fader::{drag_aborted, value_beside, wheel_notches, FINE_DIVISOR}; use crate::display::shown_value; use crate::gesture::GestureState; use crate::state::SpeakerState; +use crate::texts; use crate::ui_kit::theme::Tokens; use crate::writes::{self, Intent}; -/// Shift makes turning this many times finer. -const FINE_DIVISOR: f64 = 10.0; /// Closer to the centre than this the pointer's angle jumps around, so it is ignored. const DEAD_ZONE_RADIUS: f32 = 4.0; /// Wheel notches for the whole range, so wide ranges (delay in samples) stay usable. const FULL_RANGE_NOTCHES: f64 = 100.0; /// The ring leaves a gap at the bottom, from -135° to +135° around the top. const SWEEP_RADIANS: f32 = 1.5 * PI; +const FULL_RANGE_TURN_RADIANS: f32 = 3.0 * PI; const ARC_WIDTH: f32 = 3.0; /// Space between the value arc and the knob's body. const ARC_GAP: f32 = 2.0; @@ -78,6 +78,7 @@ pub fn for_entry( enabled: writes::can_write(speaker, catalog, &entry.path), diameter, format: &format, + conversion: entry.conversion, }; let mut state = speaker.knobs.remove(&entry.path).unwrap_or_default(); let id = ui.id().with(entry.path.to_string()); @@ -102,6 +103,8 @@ pub struct Knob<'a> { pub diameter: f32, /// Formats a value for the line under the knob. pub format: &'a dyn Fn(f64) -> String, + /// The unit typed values are in, when it is not the device's. + pub conversion: Option, } /// Draws the knob; returns the value to send when a gesture ended. @@ -196,16 +199,23 @@ pub fn show(ui: &mut Ui, id: Id, knob: &Knob, state: &mut GestureState) -> Optio if knob.caption { ui.add(Label::new(RichText::new(knob.label).small().weak())); } - let edited = exact_entry(ui, id, &response, knob, value); + let open_now = response.double_clicked(); + let range = &knob.range; + let edited = value_entry(ui, id, open_now, value, range, knob.step, knob.conversion); sent = sent.or(edited); - if knob.caption && !exact_entry_open(ui, id) { + if knob.caption && !value_entry_open(ui, id) { let text = RichText::new((knob.format)(value)).small().monospace(); ui.add(Label::new(text)); } response }) .inner; - response.on_hover_text(knob.label); + response.on_hover_text(format!( + "{} +{}", + knob.label, + texts::CONTROL_HINT + )); if let Some(value) = sent { state.sent(value, now); } @@ -233,11 +243,12 @@ pub fn angle_change(previous: f32, current: f32) -> f32 { change } -/// The value change for a turn of `radians`: the knob's whole sweep is the whole range. +/// The value change for a turn of `radians`: one and a half turns cover the whole range, finer +/// than the drawn sweep so a value can be met. pub fn turn_change(range: &RangeInclusive, radians: f32, fine: bool) -> f64 { let span = range.end() - range.start(); let divisor = if fine { FINE_DIVISOR } else { 1.0 }; - f64::from(radians) / f64::from(SWEEP_RADIANS) * span / divisor + f64::from(radians) / f64::from(FULL_RANGE_TURN_RADIANS) * span / divisor } /// One wheel notch: a whole number of catalog steps, at least one, about a hundredth of the @@ -252,18 +263,22 @@ pub fn on_grid(value: f64, step: f64) -> f64 { (value / step).round() * step } -/// Double-click opens a text field under the knob; Enter sends, Escape or leaving cancels. -fn exact_entry( +/// A text field for an exact value, open from `open_now` until Enter (returns the value, clamped +/// and on the step grid) or until Escape or leaving it (returns nothing). Knob and fader use it. +/// With a `conversion`, the text is in the converted unit (the delay in ms). +pub(super) fn value_entry( ui: &mut Ui, id: Id, - response: &eframe::egui::Response, - knob: &Knob, + open_now: bool, value: f64, + range: &RangeInclusive, + step: f64, + conversion: Option, ) -> Option { let text_id = id.with(EDIT_TEXT_ID); - if response.double_clicked() { - ui.data_mut(|data| data.insert_temp(text_id, value.to_string())); - ui.memory_mut(|memory| memory.request_focus(text_id)); + if open_now { + let shown = conversion.map_or(value, |conversion| conversion.apply(value)); + ui.data_mut(|data| data.insert_temp(text_id, shown.to_string())); } let mut text = ui.data(|data| data.get_temp::(text_id))?; let field = ui.add( @@ -271,6 +286,10 @@ fn exact_entry( .id(text_id) .desired_width(EDIT_WIDTH), ); + // Asked of the drawn field: a request before it exists is lost to the opening click. + if open_now { + field.request_focus(); + } let entered = field.lost_focus() && ui.input(|input| input.key_pressed(Key::Enter)); if field.lost_focus() { ui.data_mut(|data| data.remove::(text_id)); @@ -281,11 +300,12 @@ fn exact_entry( return None; } let typed: f64 = text.trim().replace(',', ".").parse().ok()?; - let clamped = typed.clamp(*knob.range.start(), *knob.range.end()); - Some(on_grid(clamped, knob.step)) + let typed = conversion.map_or(typed, |conversion| conversion.invert(typed)); + let clamped = typed.clamp(*range.start(), *range.end()); + Some(on_grid(clamped, step)) } -fn exact_entry_open(ui: &Ui, id: Id) -> bool { +pub(super) fn value_entry_open(ui: &Ui, id: Id) -> bool { ui.data(|data| data.get_temp::(id.with(EDIT_TEXT_ID))) .is_some() } @@ -371,9 +391,9 @@ mod tests { } #[test] - fn a_full_sweep_covers_the_range_and_shift_is_finer() { - assert_eq!(turn_change(&DELAY_RANGE, SWEEP_RADIANS, false), 5760.0); - assert!((turn_change(&DELAY_RANGE, SWEEP_RADIANS, true) - 576.0).abs() < 1e-9); + fn one_and_a_half_turns_cover_the_range_and_shift_is_finer() { + assert_eq!(turn_change(&DELAY_RANGE, 3.0 * PI, false), 5760.0); + assert!((turn_change(&DELAY_RANGE, 3.0 * PI, true) - 576.0).abs() < 1e-9); } #[test] diff --git a/crates/app/src/views/strip.rs b/crates/app/src/views/strip.rs index 82fe868..08ec39f 100644 --- a/crates/app/src/views/strip.rs +++ b/crates/app/src/views/strip.rs @@ -13,7 +13,7 @@ use serde_json::Value; use super::fader::{self, FADER_HEIGHT}; use super::{knob, meters}; -use crate::fader::output_level_range; +use crate::fader::{output_level_range, WHEEL_STEP_DB}; use crate::paths; use crate::state::{SpeakerState, Status}; use crate::status_leds::{status_leds, ConnectionLed}; @@ -30,6 +30,7 @@ const LED_SPACING: f32 = 6.0; const UNREACHABLE_OPACITY: f32 = 0.55; const METER_GROUP_GAP: f32 = 4.0; const DELAY_PATH: &str = "audio/out/delay"; +const LEVEL_ENTRY_ID: &str = "level_entry"; const STANDBY_TIME_PATH: &str = "device/standby/auto_standby_time"; pub struct StripResponse { @@ -166,10 +167,20 @@ fn level( shown }) .inner; - let readout = device.map(|_| texts::readout(shown, &unit)); - ui.add(Label::new( - RichText::new(readout.unwrap_or_default()).monospace(), - )); + let readout = device + .map(|_| texts::readout(shown, &unit)) + .unwrap_or_default(); + let label = Label::new(RichText::new(readout).monospace()).sense(Sense::click()); + let response = ui.add(label).on_hover_text(texts::CONTROL_HINT); + let entry_id = response.id.with(LEVEL_ENTRY_ID); + let open_now = response.double_clicked() && device.is_some(); + let range = output_level_range(); + if let Some(level) = + knob::value_entry(ui, entry_id, open_now, shown, &range, WHEEL_STEP_DB, None) + { + let now = ui.input(|input| input.time); + commands.extend(speaker.request(Intent::Level(level), catalog, now)); + } } /// Input meters, then the output meters of models that report them (KH 750), set apart. diff --git a/crates/app/tests/views.rs b/crates/app/tests/views.rs index 69204c9..a792eaa 100644 --- a/crates/app/tests/views.rs +++ b/crates/app/tests/views.rs @@ -622,3 +622,101 @@ fn knob_value_is_shown_beside_it_while_dragging() { harness.run_steps(2); assert_eq!(shown(&harness).len(), 1); } + +/// How far the level moves while dragging the fader `points` down, without releasing. +fn level_change_while_dragging( + harness: &mut Harness<'static, NeumannApp>, + points: f32, + modifiers: egui::Modifiers, +) -> f64 { + let start = harness + .get_by_role_and_label(Role::Slider, texts::LEVEL) + .rect() + .center(); + let before = harness + .get_by_role_and_label(Role::Slider, texts::LEVEL) + .accesskit_node() + .numeric_value() + .unwrap(); + harness.drag_at(start); + // After the press, which carries no modifiers of its own. + harness.event(egui::Event::ModifiersChanged(modifiers)); + for frame in 1..=4 { + harness.hover_at(start + egui::vec2(0.0, points * frame as f32 / 4.0)); + harness.step(); + } + let during = harness + .get_by_role_and_label(Role::Slider, texts::LEVEL) + .accesskit_node() + .numeric_value() + .unwrap(); + harness.key_press(egui::Key::Escape); + harness.drop_at(start); + harness.run_steps(2); + before - during +} + +#[test] +fn fader_moves_ten_times_finer_with_shift() { + let (_simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + let coarse = level_change_while_dragging(&mut harness, 40.0, egui::Modifiers::NONE); + let fine = level_change_while_dragging(&mut harness, 40.0, egui::Modifiers::SHIFT); + assert!(coarse > 10.0, "coarse {coarse}"); + assert!( + fine > 0.0 && fine <= coarse / 5.0, + "coarse {coarse}, fine {fine}" + ); +} + +#[test] +fn double_click_on_the_level_readout_enters_a_value() { + let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + let position = harness.get_by_label(&readout(100.0)).rect().center(); + double_click_at(&mut harness, position); + harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + harness.get_by_role(Role::TextInput).type_text("87.5"); + harness.key_press(egui::Key::Enter); + wait_until(&mut harness, "the typed level", |_| { + !level_sets(&simulator).is_empty() + }); + harness.run_steps(5); + barrier(&simulator); + assert_eq!(level_sets(&simulator), vec![87.5]); +} + +/// The harness clock moves about half a second per frame, more than egui allows between the +/// two clicks of a double click, so the allowance is widened for the test. +fn double_click_at(harness: &mut Harness<'static, NeumannApp>, position: egui::Pos2) { + harness + .ctx + .options_mut(|options| options.input_options.max_double_click_delay = 2.0); + harness.hover_at(position); + for pressed in [true, false, true, false] { + harness.event(egui::Event::PointerButton { + pos: position, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }); + } + harness.run_steps(2); +} + +#[test] +fn double_click_on_the_delay_knob_takes_milliseconds() { + let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + let position = harness + .get_by_role_and_label(Role::Slider, texts::KNOB_DELAY) + .rect() + .center(); + double_click_at(&mut harness, position); + harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + harness.get_by_role(Role::TextInput).type_text("10"); + harness.key_press(egui::Key::Enter); + wait_until(&mut harness, "the typed delay", |_| { + !delay_sets(&simulator).is_empty() + }); + harness.run_steps(5); + barrier(&simulator); + assert_eq!(delay_sets(&simulator), vec![serde_json::json!(480)]); +} From 473e4c47ff2f681b76e817ec312837388a0ac9b3 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:42:33 +0200 Subject: [PATCH 077/460] Label the meters and describe the fine controls --- CHANGELOG.md | 6 ++--- README.md | 18 ++++++++----- crates/app/src/texts.rs | 2 ++ crates/app/src/views/strip.rs | 49 +++++++++++++++++++++++++++++------ crates/app/tests/views.rs | 10 +++++++ 5 files changed, 67 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4ab480..9570429 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,9 @@ First version. - Added speakers are remembered and reconnected at every start; a scan updates the stored address of a remembered speaker that moved. - Removing a speaker from its device panel after a confirmation. -- One channel strip per speaker with status LEDs, delay and standby knobs, an output level fader - that shows its value while dragging, input meters (KH 750: also output meters) with clip lamps, - response time, mute and identify. +- One channel strip per speaker with status LEDs, delay (in ms) and standby knobs, an output + level fader that shows its value while dragging, has a fine mode and takes typed values, input + meters (KH 750: also output meters) with clip lamps, response time, mute and identify. - A device panel per speaker with every setting of its model and a list of all values the speaker reports. - A settings dialog for theme, meter range and response times. diff --git a/README.md b/README.md index f0b05f5..41d5b3b 100644 --- a/README.md +++ b/README.md @@ -18,13 +18,17 @@ knowledge the path catalog is built from and the simulator, not a hardware test. takes 4 seconds) and lets you choose which of them to add, or adds a speaker by its IPv6 address with "+ Adresse". Added speakers are remembered. - One channel strip per speaker, side by side: name, four LEDs (connection, clip, device - warning, LOCAL), knobs for delay and standby time, output level fader (0-120 dB SPL, mouse - wheel in 0.5 dB steps, the value shown beside the handle while dragging), input level meters - with clip lamps (on the KH 750 also its output meters), response time, "Stumm" (mute) and - "Ident." (identify). -- Knobs: drag up or down (Shift for fine steps), turn the mouse wheel, or double-click to type - a value; the value is written once when you let go, stop the wheel or press Enter, and - Escape cancels. + warning, LOCAL), knobs for delay (shown in ms) and standby time, output level fader + (0-120 dB SPL in 0.5 dB steps), input level meters marked "Ein" with clip lamps (on the + KH 750 also its output meters, marked "Aus"), response time, "Stumm" (mute) and "Ident." + (identify). +- Fader: grab it anywhere and drag; it moves with the pointer and never jumps to it. Shift makes + it ten times finer, the mouse wheel moves 0.5 dB per notch, and a double-click on the value + under it lets you type one. The value stands beside the fader while you drag. +- Knobs: turn them by moving the pointer around the knob (clockwise is more; one and a half + turns cover the whole range), Shift for fine steps, the mouse wheel, or a double-click to type + a value (the delay in ms). The value stands beside the knob while it moves. +- Fader and knobs write once when you let go, stop the wheel or press Enter; Escape cancels. - Clicking a speaker's name opens its device panel with every setting the catalog knows for that model (output, input, standby, device, rear panel), grouped in two columns; settings a model may write get a control, the rest are shown greyed. The panel also shows model, diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 17dd94e..9dcd69f 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -43,6 +43,8 @@ pub const METER_RANGE: &str = "Pegelanzeige ab"; pub const SHOW_RESPONSE_TIMES: &str = "Antwortzeiten anzeigen"; pub const INPUT_METERS: &str = "Eingangspegel"; pub const OUTPUT_METERS: &str = "Ausgangspegel"; +pub const METER_INPUT_SHORT: &str = "Ein"; +pub const METER_OUTPUT_SHORT: &str = "Aus"; pub const ALL_VALUES: &str = "Alle Werte…"; pub const APPLY: &str = "Übernehmen"; diff --git a/crates/app/src/views/strip.rs b/crates/app/src/views/strip.rs index 08ec39f..cec5a97 100644 --- a/crates/app/src/views/strip.rs +++ b/crates/app/src/views/strip.rs @@ -14,6 +14,7 @@ use serde_json::Value; use super::fader::{self, FADER_HEIGHT}; use super::{knob, meters}; use crate::fader::{output_level_range, WHEEL_STEP_DB}; +use crate::meter::MeterState; use crate::paths; use crate::state::{SpeakerState, Status}; use crate::status_leds::{status_leds, ConnectionLed}; @@ -186,18 +187,50 @@ fn level( /// Input meters, then the output meters of models that report them (KH 750), set apart. fn meters(ui: &mut Ui, speaker: &SpeakerState, desk: &DeskSettings, tokens: &Tokens) { let floor = desk.meter_floor.db(); - let input_clips = speaker.values.get(&paths::path(paths::INPUT_CLIP)); - let input = &speaker.input_meters; - let group = texts::INPUT_METERS; - meters::show(ui, group, input, input_clips, FADER_HEIGHT, floor, tokens); + let input = MeterGroup { + caption: texts::METER_INPUT_SHORT, + name: texts::INPUT_METERS, + meters: &speaker.input_meters, + clips: speaker.values.get(&paths::path(paths::INPUT_CLIP)), + }; + meter_group(ui, &input, floor, tokens); if speaker.output_meters.is_empty() { return; } ui.add_space(METER_GROUP_GAP); - let output_clips = speaker.values.get(&paths::path(paths::OUTPUT_CLIP)); - let output = &speaker.output_meters; - let group = texts::OUTPUT_METERS; - meters::show(ui, group, output, output_clips, FADER_HEIGHT, floor, tokens); + let output = MeterGroup { + caption: texts::METER_OUTPUT_SHORT, + name: texts::OUTPUT_METERS, + meters: &speaker.output_meters, + clips: speaker.values.get(&paths::path(paths::OUTPUT_CLIP)), + }; + meter_group(ui, &output, floor, tokens); +} + +struct MeterGroup<'a> { + /// Under the meters, so input and output can be told apart at a glance. + caption: &'a str, + /// For screen readers. + name: &'a str, + meters: &'a [MeterState], + clips: Option<&'a Value>, +} + +fn meter_group(ui: &mut Ui, group: &MeterGroup, floor: f64, tokens: &Tokens) { + ui.vertical(|ui| { + ui.horizontal(|ui| { + meters::show( + ui, + group.name, + group.meters, + group.clips, + FADER_HEIGHT, + floor, + tokens, + ); + }); + ui.add(Label::new(RichText::new(group.caption).small().weak())); + }); } fn response_time(ui: &mut Ui, speaker: &SpeakerState) { diff --git a/crates/app/tests/views.rs b/crates/app/tests/views.rs index a792eaa..0d36650 100644 --- a/crates/app/tests/views.rs +++ b/crates/app/tests/views.rs @@ -720,3 +720,13 @@ fn double_click_on_the_delay_knob_takes_milliseconds() { barrier(&simulator); assert_eq!(delay_sets(&simulator), vec![serde_json::json!(480)]); } + +#[test] +fn meter_groups_are_labelled_input_and_output() { + let (_simulator, _directory, harness) = connected_card(SimModel::Kh750); + harness.get_by_label(texts::METER_INPUT_SHORT); + harness.get_by_label(texts::METER_OUTPUT_SHORT); + let (_other, _other_directory, kh120) = connected_card(SimModel::Kh120Ii); + kh120.get_by_label(texts::METER_INPUT_SHORT); + assert!(kh120.query_by_label(texts::METER_OUTPUT_SHORT).is_none()); +} From 32bffc6c191f37565e5d97cf35faaa0572b7bab9 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:54:15 +0200 Subject: [PATCH 078/460] Draw meters as zone segments on a spread scale --- crates/app/src/meter.rs | 116 +++++++++++++++++++++++++++++---- crates/app/src/texts.rs | 5 ++ crates/app/src/ui_kit/theme.rs | 13 +++- crates/app/src/views/meters.rs | 92 +++++++++++++++++++++----- crates/app/src/views/strip.rs | 11 +++- 5 files changed, 204 insertions(+), 33 deletions(-) diff --git a/crates/app/src/meter.rs b/crates/app/src/meter.rs index 056c18f..9ba3f7a 100644 --- a/crates/app/src/meter.rs +++ b/crates/app/src/meter.rs @@ -5,11 +5,73 @@ pub const PEAK_HOLD_SECONDS: f64 = 1.5; pub const METER_FLOOR_DB: f64 = -120.0; pub const METER_CEILING_DB: f64 = 6.0; -/// Maps a dB value linearly from the floor..ceiling range to 0..1; the device reports silence -/// as -160, which lands below any floor and gives 0. `floor` comes from the desk settings. -pub fn fraction(db: f64, floor: f64) -> f32 { - let position = (db - floor) / (METER_CEILING_DB - floor); - position.clamp(0.0, 1.0) as f32 +/// Below this a level is safe, up to `OVER_FROM_DB` hot, above over. +pub const HOT_FROM_DB: f64 = -18.0; +pub const OVER_FROM_DB: f64 = -6.0; + +/// The meter's scale: dB against height from -120 to +6 dB. The top is spread out, where a +/// level matters, and the bottom still leaves quiet room volume (about -90 dB) visible. +const SCALE: [(f64, f64); 10] = [ + (METER_FLOOR_DB, 0.0), + (-90.0, 0.18), + (-60.0, 0.32), + (-40.0, 0.42), + (-20.0, 0.56), + (-10.0, 0.68), + (-6.0, 0.76), + (-3.0, 0.85), + (0.0, 0.93), + (METER_CEILING_DB, 1.0), +]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Zone { + Safe, + Hot, + Over, +} + +pub fn zone(db: f64) -> Zone { + if db > OVER_FROM_DB { + return Zone::Over; + } + if db > HOT_FROM_DB { + return Zone::Hot; + } + Zone::Safe +} + +/// Where `db` sits on the meter, 0 at `floor` (from the desk settings) and 1 at the top. The +/// device reports silence as -160, which lands below any floor and gives 0. +pub fn position(db: f64, floor: f64) -> f32 { + let bottom = scale_height(floor); + let height = (scale_height(db) - bottom) / (1.0 - bottom); + height.clamp(0.0, 1.0) as f32 +} + +/// The level at a height of the meter; the inverse of `position`. +pub fn level_at(position: f32, floor: f64) -> f64 { + let bottom = scale_height(floor); + let height = bottom + f64::from(position) * (1.0 - bottom); + let pairs = SCALE.windows(2); + for pair in pairs { + let [(low_db, low), (high_db, high)] = [pair[0], pair[1]]; + if height <= high { + return low_db + (height - low) / (high - low) * (high_db - low_db); + } + } + METER_CEILING_DB +} + +fn scale_height(db: f64) -> f64 { + let db = db.clamp(METER_FLOOR_DB, METER_CEILING_DB); + for pair in SCALE.windows(2) { + let [(low_db, low), (high_db, high)] = [pair[0], pair[1]]; + if db <= high_db { + return low + (db - low_db) / (high_db - low_db) * (high - low); + } + } + 1.0 } /// The device is polled about ten times a second; the bar glides between the readings like a @@ -103,10 +165,35 @@ mod tests { } #[test] - fn meter_fraction_clamps_below_floor_and_above_ceiling() { - assert_eq!(fraction(-160.0, METER_FLOOR_DB), 0.0); - assert_eq!(fraction(10.0, METER_FLOOR_DB), 1.0); - assert_eq!(fraction(-57.0, METER_FLOOR_DB), 0.5); + fn position_clamps_below_floor_and_above_ceiling() { + assert_eq!(position(-160.0, METER_FLOOR_DB), 0.0); + assert_eq!(position(10.0, METER_FLOOR_DB), 1.0); + assert_eq!(position(METER_CEILING_DB, METER_FLOOR_DB), 1.0); + } + + #[test] + fn the_top_of_the_scale_is_spread_out() { + // 0 to -10 dB takes more room than -60 to -70 dB. + let top = position(0.0, METER_FLOOR_DB) - position(-10.0, METER_FLOOR_DB); + let low = position(-60.0, METER_FLOOR_DB) - position(-70.0, METER_FLOOR_DB); + assert!(top > 2.0 * low, "top {top}, low {low}"); + } + + #[test] + fn level_at_is_the_inverse_of_position() { + for db in [-110.0, -90.0, -40.0, -18.0, -6.0, -1.0, 3.0] { + let back = level_at(position(db, METER_FLOOR_DB), METER_FLOOR_DB); + assert!((back - db).abs() < 0.01, "{db} -> {back}"); + } + } + + #[test] + fn zones_split_at_minus_18_and_minus_6() { + assert_eq!(zone(-30.0), Zone::Safe); + assert_eq!(zone(-18.0), Zone::Safe); + assert_eq!(zone(-10.0), Zone::Hot); + assert_eq!(zone(-6.0), Zone::Hot); + assert_eq!(zone(-1.0), Zone::Over); } #[test] @@ -134,13 +221,16 @@ mod tests { #[test] fn quiet_room_level_is_visible() { // Measured on a KH 120 II at quiet room volume: the input meter reads about -90 dB. - assert!(fraction(-90.0, METER_FLOOR_DB) > 0.2); + assert!(position(-90.0, METER_FLOOR_DB) > 0.15); } #[test] fn meter_floor_setting_changes_the_scale() { - assert_eq!(fraction(-60.0, -60.0), 0.0); - assert_eq!(fraction(-27.0, -60.0), 0.5); - assert!(fraction(-60.0, -120.0) > 0.4); + assert_eq!(position(-60.0, -60.0), 0.0); + assert_eq!(position(-90.0, -60.0), 0.0); + assert!(position(-60.0, -120.0) > 0.25); + // A higher floor gives the bottom of the remaining range more room. + let near_floor = |floor: f64| position(-55.0, floor) - position(-60.0, floor); + assert!(near_floor(-60.0) > near_floor(-120.0)); } } diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 9dcd69f..7a4f21d 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -114,6 +114,11 @@ pub fn knob_label(label: &str) -> String { format!("{label} (Regler)") } +/// A mark on the meter scale, without unit to fit beside the meters. +pub fn scale_mark(db: f64) -> String { + format!("{db}") +} + pub fn meter_floor(db: f64) -> String { format!("{db} dB") } diff --git a/crates/app/src/ui_kit/theme.rs b/crates/app/src/ui_kit/theme.rs index 6de2977..cd97844 100644 --- a/crates/app/src/ui_kit/theme.rs +++ b/crates/app/src/ui_kit/theme.rs @@ -29,7 +29,10 @@ pub struct Tokens { pub connecting: Color32, pub unreachable: Color32, pub meter_track: Color32, - pub meter_fill: Color32, + /// Meter zones: up to -18 dB, up to -6 dB, above. + pub meter_safe: Color32, + pub meter_hot: Color32, + pub meter_over: Color32, pub clip_off: Color32, } @@ -56,7 +59,9 @@ impl Tokens { connecting: Color32::from_rgb(230, 180, 60), unreachable: Color32::from_gray(110), meter_track: Color32::from_gray(24), - meter_fill: Color32::from_rgb(74, 200, 110), + meter_safe: Color32::from_rgb(74, 200, 110), + meter_hot: Color32::from_rgb(232, 176, 52), + meter_over: Color32::from_rgb(240, 84, 84), clip_off: Color32::from_gray(70), } } @@ -77,7 +82,9 @@ impl Tokens { connecting: Color32::from_rgb(200, 140, 20), unreachable: Color32::from_gray(150), meter_track: Color32::from_rgb(222, 222, 228), - meter_fill: Color32::from_rgb(30, 160, 80), + meter_safe: Color32::from_rgb(30, 160, 80), + meter_hot: Color32::from_rgb(214, 140, 16), + meter_over: Color32::from_rgb(214, 48, 48), clip_off: Color32::from_rgb(200, 200, 206), } } diff --git a/crates/app/src/views/meters.rs b/crates/app/src/views/meters.rs index 71def9a..107a246 100644 --- a/crates/app/src/views/meters.rs +++ b/crates/app/src/views/meters.rs @@ -1,16 +1,29 @@ -//! Level meters drawn side by side, one per channel, each with its clip lamp and held peak. +//! Level meters drawn side by side, one per channel: segments in the colour of their zone +//! (lit up to the level, dimmed above it, so the eye always sees where red begins), the held +//! peak as one lit segment, and a clip lamp on top. The scale is the spread one from `meter`. -use eframe::egui::{pos2, vec2, CornerRadius, Rect, Sense, Stroke, Ui, WidgetInfo, WidgetType}; +use eframe::egui::{ + pos2, vec2, Align2, Color32, CornerRadius, FontId, Rect, Sense, Stroke, Ui, WidgetInfo, + WidgetType, +}; use serde_json::Value; -use crate::meter::{fraction, MeterState}; +use crate::meter::{level_at, position, zone, MeterState, Zone}; use crate::texts; use crate::ui_kit::theme::Tokens; const METER_WIDTH: f32 = 8.0; const CLIP_LAMP_RADIUS: f32 = 3.5; const CLIP_LAMP_SPACE: f32 = 10.0; -const PEAK_LINE_WIDTH: f32 = 2.0; +/// One segment and the gap below it. +const SEGMENT_PITCH: f32 = 5.0; +const SEGMENT_GAP: f32 = 1.0; +/// How strongly a segment above the level still shows its zone colour. +const UNLIT_OPACITY: f32 = 0.22; +const SCALE_WIDTH: f32 = 18.0; +const SCALE_TICK_LENGTH: f32 = 3.0; +const SCALE_FONT_SIZE: f32 = 8.5; +const SCALE_MARKS_DB: [f64; 5] = [0.0, -6.0, -20.0, -60.0, -90.0]; /// `clips` is the speaker's clip list for the same channels, if it reported one; `group` /// names the meters for screen readers. @@ -38,20 +51,13 @@ pub fn show( } else { tokens.clip_off }; - let lamp_center = pos2(rect.center().x, rect.top() + CLIP_LAMP_RADIUS); - let track = Rect::from_min_max(pos2(rect.left(), rect.top() + CLIP_LAMP_SPACE), rect.max); let painter = ui.painter(); + let lamp_center = pos2(rect.center().x, rect.top() + CLIP_LAMP_RADIUS); painter.circle_filled(lamp_center, CLIP_LAMP_RADIUS, lamp_color); - painter.rect_filled(track, CornerRadius::ZERO, tokens.meter_track); - let level_top = track.bottom() - track.height() * fraction(meter.shown(now), floor); - let level = Rect::from_min_max(pos2(track.left(), level_top), track.max); - painter.rect_filled(level, CornerRadius::ZERO, tokens.meter_fill); - let peak_y = track.bottom() - track.height() * fraction(meter.peak(now), floor); - painter.hline( - track.x_range(), - peak_y, - Stroke::new(PEAK_LINE_WIDTH, tokens.text_dim), - ); + let track = Rect::from_min_max(pos2(rect.left(), rect.top() + CLIP_LAMP_SPACE), rect.max); + let level = position(meter.shown(now), floor); + let peak = position(meter.peak(now), floor); + paint_segments(ui, track, level, peak, floor, tokens); if clipped { response.on_hover_text(texts::CLIP); } @@ -60,3 +66,57 @@ pub fn show( ui.ctx().request_repaint(); } } + +/// dB marks beside the meters, at the same heights; marks below `floor` are left out. +pub fn scale(ui: &mut Ui, height: f32, floor: f64, tokens: &Tokens) { + let (rect, _) = ui.allocate_exact_size(vec2(SCALE_WIDTH, height), Sense::hover()); + let track = Rect::from_min_max(pos2(rect.left(), rect.top() + CLIP_LAMP_SPACE), rect.max); + let painter = ui.painter(); + for mark in SCALE_MARKS_DB.into_iter().filter(|mark| *mark > floor) { + let y = track.bottom() - track.height() * position(mark, floor); + let tick = [ + pos2(track.right() - SCALE_TICK_LENGTH, y), + pos2(track.right(), y), + ]; + painter.line_segment(tick, Stroke::new(1.0, tokens.text_dim)); + painter.text( + pos2(track.right() - SCALE_TICK_LENGTH - 1.0, y), + Align2::RIGHT_CENTER, + texts::scale_mark(mark), + FontId::monospace(SCALE_FONT_SIZE), + tokens.text_dim, + ); + } +} + +fn paint_segments(ui: &Ui, track: Rect, level: f32, peak: f32, floor: f64, tokens: &Tokens) { + let painter = ui.painter(); + let count = (track.height() / SEGMENT_PITCH).floor().max(1.0) as usize; + let pitch = track.height() / count as f32; + let lit = (level * count as f32).round() as usize; + let peak_segment = (peak * count as f32).ceil() as usize; + for segment in 0..count { + let middle = (segment as f32 + 0.5) / count as f32; + let color = zone_color(zone(level_at(middle, floor)), tokens); + let shown = segment < lit || segment + 1 == peak_segment; + let color = if shown { + color + } else { + color.gamma_multiply(UNLIT_OPACITY) + }; + let bottom = track.bottom() - segment as f32 * pitch; + let rect = Rect::from_min_max( + pos2(track.left(), bottom - pitch + SEGMENT_GAP), + pos2(track.right(), bottom), + ); + painter.rect_filled(rect, CornerRadius::ZERO, color); + } +} + +fn zone_color(zone: Zone, tokens: &Tokens) -> Color32 { + match zone { + Zone::Safe => tokens.meter_safe, + Zone::Hot => tokens.meter_hot, + Zone::Over => tokens.meter_over, + } +} diff --git a/crates/app/src/views/strip.rs b/crates/app/src/views/strip.rs index cec5a97..11b726d 100644 --- a/crates/app/src/views/strip.rs +++ b/crates/app/src/views/strip.rs @@ -22,7 +22,7 @@ use crate::texts; use crate::ui_kit::theme::Tokens; use crate::writes::{self, Intent, Switch}; -pub const STRIP_WIDTH: f32 = 124.0; +pub const STRIP_WIDTH: f32 = 140.0; const STRIP_MARGIN: i8 = 6; const STRIP_RADIUS: u8 = 6; const BORDER_WIDTH: f32 = 1.0; @@ -30,6 +30,7 @@ const LED_RADIUS: f32 = 3.5; const LED_SPACING: f32 = 6.0; const UNREACHABLE_OPACITY: f32 = 0.55; const METER_GROUP_GAP: f32 = 4.0; +const METER_SPACING: f32 = 3.0; const DELAY_PATH: &str = "audio/out/delay"; const LEVEL_ENTRY_ID: &str = "level_entry"; const STANDBY_TIME_PATH: &str = "device/standby/auto_standby_time"; @@ -188,6 +189,7 @@ fn level( fn meters(ui: &mut Ui, speaker: &SpeakerState, desk: &DeskSettings, tokens: &Tokens) { let floor = desk.meter_floor.db(); let input = MeterGroup { + with_scale: true, caption: texts::METER_INPUT_SHORT, name: texts::INPUT_METERS, meters: &speaker.input_meters, @@ -199,6 +201,7 @@ fn meters(ui: &mut Ui, speaker: &SpeakerState, desk: &DeskSettings, tokens: &Tok } ui.add_space(METER_GROUP_GAP); let output = MeterGroup { + with_scale: false, caption: texts::METER_OUTPUT_SHORT, name: texts::OUTPUT_METERS, meters: &speaker.output_meters, @@ -208,6 +211,8 @@ fn meters(ui: &mut Ui, speaker: &SpeakerState, desk: &DeskSettings, tokens: &Tok } struct MeterGroup<'a> { + /// The first group carries the dB marks; the others share its heights. + with_scale: bool, /// Under the meters, so input and output can be told apart at a glance. caption: &'a str, /// For screen readers. @@ -219,6 +224,10 @@ struct MeterGroup<'a> { fn meter_group(ui: &mut Ui, group: &MeterGroup, floor: f64, tokens: &Tokens) { ui.vertical(|ui| { ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = METER_SPACING; + if group.with_scale { + meters::scale(ui, FADER_HEIGHT, floor, tokens); + } meters::show( ui, group.name, From 7c7c34a05e6c5f23d3278b393012ae9efd8209e0 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:02:20 +0200 Subject: [PATCH 079/460] Keep meter segments on whole pixels with rounded corners --- crates/app/src/views/meters.rs | 74 +++++++++++++++++++++++++++++----- 1 file changed, 64 insertions(+), 10 deletions(-) diff --git a/crates/app/src/views/meters.rs b/crates/app/src/views/meters.rs index 107a246..e46609e 100644 --- a/crates/app/src/views/meters.rs +++ b/crates/app/src/views/meters.rs @@ -18,9 +18,10 @@ const CLIP_LAMP_SPACE: f32 = 10.0; /// One segment and the gap below it. const SEGMENT_PITCH: f32 = 5.0; const SEGMENT_GAP: f32 = 1.0; +const SEGMENT_RADIUS: u8 = 1; /// How strongly a segment above the level still shows its zone colour. const UNLIT_OPACITY: f32 = 0.22; -const SCALE_WIDTH: f32 = 18.0; +const SCALE_WIDTH: f32 = 24.0; const SCALE_TICK_LENGTH: f32 = 3.0; const SCALE_FONT_SIZE: f32 = 8.5; const SCALE_MARKS_DB: [f64; 5] = [0.0, -6.0, -20.0, -60.0, -90.0]; @@ -91,11 +92,11 @@ pub fn scale(ui: &mut Ui, height: f32, floor: f64, tokens: &Tokens) { fn paint_segments(ui: &Ui, track: Rect, level: f32, peak: f32, floor: f64, tokens: &Tokens) { let painter = ui.painter(); - let count = (track.height() / SEGMENT_PITCH).floor().max(1.0) as usize; - let pitch = track.height() / count as f32; + let rects = segment_rects(track, ui.ctx().pixels_per_point()); + let count = rects.len().max(1); let lit = (level * count as f32).round() as usize; let peak_segment = (peak * count as f32).ceil() as usize; - for segment in 0..count { + for (segment, rect) in rects.into_iter().enumerate() { let middle = (segment as f32 + 0.5) / count as f32; let color = zone_color(zone(level_at(middle, floor)), tokens); let shown = segment < lit || segment + 1 == peak_segment; @@ -104,15 +105,33 @@ fn paint_segments(ui: &Ui, track: Rect, level: f32, peak: f32, floor: f64, token } else { color.gamma_multiply(UNLIT_OPACITY) }; - let bottom = track.bottom() - segment as f32 * pitch; - let rect = Rect::from_min_max( - pos2(track.left(), bottom - pitch + SEGMENT_GAP), - pos2(track.right(), bottom), - ); - painter.rect_filled(rect, CornerRadius::ZERO, color); + painter.rect_filled(rect, CornerRadius::same(SEGMENT_RADIUS), color); } } +/// The segments from the bottom up, each the same whole number of physical pixels high with the +/// same gap: sizes derived from the track's height would round to uneven pixels. +fn segment_rects(track: Rect, pixels_per_point: f32) -> Vec { + let to_points = |pixels: f32| pixels / pixels_per_point; + let pitch = (SEGMENT_PITCH * pixels_per_point).round().max(2.0); + let gap = (SEGMENT_GAP * pixels_per_point).round().max(1.0); + let top = (track.top() * pixels_per_point).ceil(); + let bottom = (track.bottom() * pixels_per_point).floor(); + let left = to_points((track.left() * pixels_per_point).round()); + let right = to_points((track.right() * pixels_per_point).round()); + let count = ((bottom - top + gap) / pitch).floor().max(0.0) as usize; + (0..count) + .map(|segment| { + let segment_bottom = bottom - segment as f32 * pitch; + let segment_top = segment_bottom - (pitch - gap); + Rect::from_min_max( + pos2(left, to_points(segment_top)), + pos2(right, to_points(segment_bottom)), + ) + }) + .collect() +} + fn zone_color(zone: Zone, tokens: &Tokens) -> Color32 { match zone { Zone::Safe => tokens.meter_safe, @@ -120,3 +139,38 @@ fn zone_color(zone: Zone, tokens: &Tokens) -> Color32 { Zone::Over => tokens.meter_over, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn segments_are_equal_on_whole_pixels() { + // A track whose edges fall between pixels, as in the strip. + let track = Rect::from_min_max(pos2(10.3, 20.6), pos2(18.3, 207.4)); + let rects = segment_rects(track, 1.0); + assert!(rects.len() > 30); + let heights: Vec = rects.iter().map(Rect::height).collect(); + assert!( + heights.iter().all(|height| *height == heights[0]), + "{heights:?}" + ); + for rect in &rects { + assert_eq!(rect.top(), rect.top().round()); + assert_eq!(rect.bottom(), rect.bottom().round()); + } + for pair in rects.windows(2) { + assert_eq!(pair[0].top() - pair[1].bottom(), SEGMENT_GAP); + } + } + + #[test] + fn segments_follow_the_pixel_grid_at_other_scales() { + let track = Rect::from_min_max(pos2(10.0, 20.0), pos2(18.0, 200.0)); + let pixels_per_point = 1.25; + for rect in segment_rects(track, pixels_per_point) { + let top = rect.top() * pixels_per_point; + assert!((top - top.round()).abs() < 1e-3, "{rect:?}"); + } + } +} From 9c64999d9c4847b353fee23cfc23146c556bee24 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:12:36 +0200 Subject: [PATCH 080/460] Centre the meter captions under their meters --- crates/app/src/views/strip.rs | 37 ++++++++++++++++++----------------- crates/app/tests/views.rs | 20 +++++++++++++++++++ 2 files changed, 39 insertions(+), 18 deletions(-) diff --git a/crates/app/src/views/strip.rs b/crates/app/src/views/strip.rs index 11b726d..48bf14f 100644 --- a/crates/app/src/views/strip.rs +++ b/crates/app/src/views/strip.rs @@ -3,8 +3,8 @@ //! last message. The strip only reports gestures; `writes` decides what becomes a command. use eframe::egui::{ - self, Button, Color32, CornerRadius, Label, Pos2, RichText, Sense, Stroke, Ui, Vec2, - WidgetInfo, WidgetType, + self, vec2, Align, Button, Color32, CornerRadius, Label, Layout, Pos2, RichText, Sense, Stroke, + Ui, Vec2, WidgetInfo, WidgetType, }; use neumann_control_core::catalog::Catalog; use neumann_control_core::settings::DeskSettings; @@ -222,23 +222,24 @@ struct MeterGroup<'a> { } fn meter_group(ui: &mut Ui, group: &MeterGroup, floor: f64, tokens: &Tokens) { - ui.vertical(|ui| { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = METER_SPACING; - if group.with_scale { - meters::scale(ui, FADER_HEIGHT, floor, tokens); - } - meters::show( - ui, - group.name, - group.meters, - group.clips, - FADER_HEIGHT, - floor, - tokens, - ); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = METER_SPACING; + if group.with_scale { + meters::scale(ui, FADER_HEIGHT, floor, tokens); + } + // The caption belongs to the meters, not to the scale beside them. + ui.vertical(|ui| { + let meters = ui + .horizontal(|ui| { + let (name, levels, clips) = (group.name, group.meters, group.clips); + meters::show(ui, name, levels, clips, FADER_HEIGHT, floor, tokens); + }) + .response + .rect; + let caption = Label::new(RichText::new(group.caption).small().weak()); + let cell = vec2(meters.width(), 0.0); + ui.allocate_ui_with_layout(cell, Layout::top_down(Align::Center), |ui| ui.add(caption)); }); - ui.add(Label::new(RichText::new(group.caption).small().weak())); }); } diff --git a/crates/app/tests/views.rs b/crates/app/tests/views.rs index 0d36650..64ac517 100644 --- a/crates/app/tests/views.rs +++ b/crates/app/tests/views.rs @@ -730,3 +730,23 @@ fn meter_groups_are_labelled_input_and_output() { kh120.get_by_label(texts::METER_INPUT_SHORT); assert!(kh120.query_by_label(texts::METER_OUTPUT_SHORT).is_none()); } + +#[test] +fn meter_captions_are_centred_under_their_meters() { + let (_simulator, _directory, harness) = connected_card(SimModel::Kh750); + for (caption, group, channels) in [ + (texts::METER_INPUT_SHORT, texts::INPUT_METERS, 2), + (texts::METER_OUTPUT_SHORT, texts::OUTPUT_METERS, 3), + ] { + let first = harness.get_by_label(&texts::meter_label(group, 1)).rect(); + let last = harness + .get_by_label(&texts::meter_label(group, channels)) + .rect(); + let middle = (first.left() + last.right()) / 2.0; + let centre = harness.get_by_label(caption).rect().center().x; + assert!( + (centre - middle).abs() < 2.0, + "{caption}: {centre} vs {middle}" + ); + } +} From fb713a029717efa2264db143862a546193a871b5 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:22:07 +0200 Subject: [PATCH 081/460] Name meter channels as the speakers describe them --- crates/app/src/texts.rs | 16 +++++++++-- crates/app/src/views/meters.rs | 51 +++++++++++++++++++++++----------- crates/app/src/views/strip.rs | 35 ++++++++++++----------- crates/app/tests/details.rs | 4 +-- crates/app/tests/views.rs | 26 +++++++++++++---- 5 files changed, 89 insertions(+), 43 deletions(-) diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 7a4f21d..da59076 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -104,8 +104,20 @@ pub fn port(port: u16) -> String { format!("Port {port}") } -/// Names one meter for screen readers, counting channels from 1. -pub fn meter_label(group: &str, channel: usize) -> String { +/// The channels as the speakers describe their meters: inputs "[A,B]", the KH 750's outputs +/// "[A, B, subwoofer]". +pub const INPUT_CHANNELS: &[&str] = &["A", "B"]; +pub const OUTPUT_CHANNELS: &[&str] = &["A", "B", "Sub"]; + +/// The name under a meter; a channel the speakers never described is counted from 1. +pub fn channel_name(names: &[&str], index: usize) -> String { + names + .get(index) + .map_or_else(|| (index + 1).to_string(), |name| (*name).to_owned()) +} + +/// Names one meter for screen readers. +pub fn meter_label(group: &str, channel: &str) -> String { format!("{group} {channel}") } diff --git a/crates/app/src/views/meters.rs b/crates/app/src/views/meters.rs index e46609e..9a0dbc3 100644 --- a/crates/app/src/views/meters.rs +++ b/crates/app/src/views/meters.rs @@ -3,8 +3,8 @@ //! peak as one lit segment, and a clip lamp on top. The scale is the spread one from `meter`. use eframe::egui::{ - pos2, vec2, Align2, Color32, CornerRadius, FontId, Rect, Sense, Stroke, Ui, WidgetInfo, - WidgetType, + pos2, vec2, Align2, Color32, CornerRadius, FontId, Label, Rect, RichText, Sense, Stroke, Ui, + Vec2, WidgetInfo, WidgetType, }; use serde_json::Value; @@ -25,27 +25,41 @@ const SCALE_WIDTH: f32 = 24.0; const SCALE_TICK_LENGTH: f32 = 3.0; const SCALE_FONT_SIZE: f32 = 8.5; const SCALE_MARKS_DB: [f64; 5] = [0.0, -6.0, -20.0, -60.0, -90.0]; +const CHANNEL_NAME_HEIGHT: f32 = 12.0; +const CHANNEL_NAME_SIZE: f32 = 9.0; -/// `clips` is the speaker's clip list for the same channels, if it reported one; `group` -/// names the meters for screen readers. -pub fn show( - ui: &mut Ui, - group: &str, - meters: &[MeterState], - clips: Option<&Value>, - height: f32, - floor: f64, - tokens: &Tokens, -) { +/// One group of meters, side by side. +pub struct Meters<'a> { + /// Names the meters for screen readers. + pub name: &'a str, + /// What the speaker calls each channel. + pub channels: &'a [&'a str], + pub levels: &'a [MeterState], + /// The speaker's clip list for the same channels, if it reported one. + pub clips: Option<&'a Value>, +} + +/// Returns the area the meters themselves cover, without names that stick out beside them. +pub fn show(ui: &mut Ui, group: &Meters, height: f32, floor: f64, tokens: &Tokens) -> Rect { + let mut covered = Rect::NOTHING; + let (name_of_group, channels, meters) = (group.name, group.channels, group.levels); let now = ui.input(|input| input.time); - let clips = clips.and_then(Value::as_array); + let clips = group.clips.and_then(Value::as_array); for (channel, meter) in meters.iter().enumerate() { let clipped = clips .and_then(|clips| clips.get(channel)) .and_then(Value::as_bool) .unwrap_or(false); - let (rect, response) = ui.allocate_exact_size(vec2(METER_WIDTH, height), Sense::hover()); - let label = texts::meter_label(group, channel + 1); + // The channel's name goes below `height`, so the scale beside stays level with it. + let size = vec2(METER_WIDTH, height + CHANNEL_NAME_HEIGHT); + let (cell, response) = ui.allocate_exact_size(size, Sense::hover()); + covered = covered.union(cell); + let rect = Rect::from_min_size(cell.min, vec2(METER_WIDTH, height)); + let name = texts::channel_name(channels, channel); + let name_rect = Rect::from_min_max(pos2(cell.left(), rect.bottom()), cell.max); + let caption = Label::new(RichText::new(&name).size(CHANNEL_NAME_SIZE).weak()).extend(); + ui.put(name_rect, caption); + let label = texts::meter_label(name_of_group, &name); response.widget_info(|| WidgetInfo::labeled(WidgetType::ProgressIndicator, true, &label)); let lamp_color = if clipped { tokens.danger @@ -66,6 +80,11 @@ pub fn show( if meters.iter().any(|meter| meter.is_moving(now)) { ui.ctx().request_repaint(); } + // Before the first reading there are no meters; an empty area would be infinitely negative. + if meters.is_empty() { + return Rect::from_min_size(ui.cursor().min, Vec2::ZERO); + } + covered } /// dB marks beside the meters, at the same heights; marks below `floor` are left out. diff --git a/crates/app/src/views/strip.rs b/crates/app/src/views/strip.rs index 48bf14f..0bb7910 100644 --- a/crates/app/src/views/strip.rs +++ b/crates/app/src/views/strip.rs @@ -12,9 +12,9 @@ use neumann_control_core::speaker::Command; use serde_json::Value; use super::fader::{self, FADER_HEIGHT}; -use super::{knob, meters}; +use super::knob; +use super::meters::{self, Meters}; use crate::fader::{output_level_range, WHEEL_STEP_DB}; -use crate::meter::MeterState; use crate::paths; use crate::state::{SpeakerState, Status}; use crate::status_leds::{status_leds, ConnectionLed}; @@ -191,9 +191,12 @@ fn meters(ui: &mut Ui, speaker: &SpeakerState, desk: &DeskSettings, tokens: &Tok let input = MeterGroup { with_scale: true, caption: texts::METER_INPUT_SHORT, - name: texts::INPUT_METERS, - meters: &speaker.input_meters, - clips: speaker.values.get(&paths::path(paths::INPUT_CLIP)), + meters: Meters { + name: texts::INPUT_METERS, + channels: texts::INPUT_CHANNELS, + levels: &speaker.input_meters, + clips: speaker.values.get(&paths::path(paths::INPUT_CLIP)), + }, }; meter_group(ui, &input, floor, tokens); if speaker.output_meters.is_empty() { @@ -203,9 +206,12 @@ fn meters(ui: &mut Ui, speaker: &SpeakerState, desk: &DeskSettings, tokens: &Tok let output = MeterGroup { with_scale: false, caption: texts::METER_OUTPUT_SHORT, - name: texts::OUTPUT_METERS, - meters: &speaker.output_meters, - clips: speaker.values.get(&paths::path(paths::OUTPUT_CLIP)), + meters: Meters { + name: texts::OUTPUT_METERS, + channels: texts::OUTPUT_CHANNELS, + levels: &speaker.output_meters, + clips: speaker.values.get(&paths::path(paths::OUTPUT_CLIP)), + }, }; meter_group(ui, &output, floor, tokens); } @@ -215,10 +221,7 @@ struct MeterGroup<'a> { with_scale: bool, /// Under the meters, so input and output can be told apart at a glance. caption: &'a str, - /// For screen readers. - name: &'a str, - meters: &'a [MeterState], - clips: Option<&'a Value>, + meters: Meters<'a>, } fn meter_group(ui: &mut Ui, group: &MeterGroup, floor: f64, tokens: &Tokens) { @@ -230,12 +233,8 @@ fn meter_group(ui: &mut Ui, group: &MeterGroup, floor: f64, tokens: &Tokens) { // The caption belongs to the meters, not to the scale beside them. ui.vertical(|ui| { let meters = ui - .horizontal(|ui| { - let (name, levels, clips) = (group.name, group.meters, group.clips); - meters::show(ui, name, levels, clips, FADER_HEIGHT, floor, tokens); - }) - .response - .rect; + .horizontal(|ui| meters::show(ui, &group.meters, FADER_HEIGHT, floor, tokens)) + .inner; let caption = Label::new(RichText::new(group.caption).small().weak()); let cell = vec2(meters.width(), 0.0); ui.allocate_ui_with_layout(cell, Layout::top_down(Align::Center), |ui| ui.add(caption)); diff --git a/crates/app/tests/details.rs b/crates/app/tests/details.rs index 0d4b980..c873cfd 100644 --- a/crates/app/tests/details.rs +++ b/crates/app/tests/details.rs @@ -374,7 +374,7 @@ fn strip_controls_are_not_repeated_in_the_panel() { #[test] fn kh750_output_meters_sit_next_to_the_input_meters() { let (_simulator, _directory, mut harness) = connected_card(SimModel::Kh750); - let output_meter = texts::meter_label(texts::OUTPUT_METERS, 1); + let output_meter = texts::meter_label(texts::OUTPUT_METERS, "A"); wait_for_label(&mut harness, &output_meter); assert_eq!( harness @@ -395,7 +395,7 @@ fn kh750_output_meters_sit_next_to_the_input_meters() { #[test] fn kh120ii_card_has_no_output_meters() { let (_simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); - wait_for_label(&mut harness, &texts::meter_label(texts::INPUT_METERS, 2)); + wait_for_label(&mut harness, &texts::meter_label(texts::INPUT_METERS, "B")); assert_eq!( harness .query_all_by_label_contains(texts::OUTPUT_METERS) diff --git a/crates/app/tests/views.rs b/crates/app/tests/views.rs index 64ac517..a1d597e 100644 --- a/crates/app/tests/views.rs +++ b/crates/app/tests/views.rs @@ -734,13 +734,13 @@ fn meter_groups_are_labelled_input_and_output() { #[test] fn meter_captions_are_centred_under_their_meters() { let (_simulator, _directory, harness) = connected_card(SimModel::Kh750); - for (caption, group, channels) in [ - (texts::METER_INPUT_SHORT, texts::INPUT_METERS, 2), - (texts::METER_OUTPUT_SHORT, texts::OUTPUT_METERS, 3), + for (caption, group, last) in [ + (texts::METER_INPUT_SHORT, texts::INPUT_METERS, "B"), + (texts::METER_OUTPUT_SHORT, texts::OUTPUT_METERS, "Sub"), ] { - let first = harness.get_by_label(&texts::meter_label(group, 1)).rect(); + let first = harness.get_by_label(&texts::meter_label(group, "A")).rect(); let last = harness - .get_by_label(&texts::meter_label(group, channels)) + .get_by_label(&texts::meter_label(group, last)) .rect(); let middle = (first.left() + last.right()) / 2.0; let centre = harness.get_by_label(caption).rect().center().x; @@ -750,3 +750,19 @@ fn meter_captions_are_centred_under_their_meters() { ); } } + +#[test] +fn meters_name_their_channels_as_the_speaker_does() { + // The KH 750 describes its outputs as "[A, B, subwoofer]", its inputs as "[A,B]". + let (_simulator, _directory, harness) = connected_card(SimModel::Kh750); + for (group, channel) in [ + (texts::INPUT_METERS, "A"), + (texts::INPUT_METERS, "B"), + (texts::OUTPUT_METERS, "A"), + (texts::OUTPUT_METERS, "B"), + (texts::OUTPUT_METERS, "Sub"), + ] { + harness.get_by_label(&texts::meter_label(group, channel)); + } + assert_eq!(harness.query_all_by_label("Sub").count(), 1); +} From fd9df8cdd9f3e05180357f688ab7b83ef7ad9451 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:31:45 +0200 Subject: [PATCH 082/460] Top-align fader and meters and give each channel name room --- crates/app/src/views/meters.rs | 20 +++++++++++++++----- crates/app/src/views/strip.rs | 6 ++++-- crates/app/tests/views.rs | 30 ++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/crates/app/src/views/meters.rs b/crates/app/src/views/meters.rs index 9a0dbc3..043eb41 100644 --- a/crates/app/src/views/meters.rs +++ b/crates/app/src/views/meters.rs @@ -50,14 +50,24 @@ pub fn show(ui: &mut Ui, group: &Meters, height: f32, floor: f64, tokens: &Token .and_then(|clips| clips.get(channel)) .and_then(Value::as_bool) .unwrap_or(false); - // The channel's name goes below `height`, so the scale beside stays level with it. - let size = vec2(METER_WIDTH, height + CHANNEL_NAME_HEIGHT); + // The channel's name goes below `height`, so the scale beside stays level with it; the + // column is as wide as the name ("Sub"), with the meter in its middle. + let name = texts::channel_name(channels, channel); + let font = FontId::proportional(CHANNEL_NAME_SIZE); + let name_width = ui + .painter() + .layout_no_wrap(name.clone(), font, tokens.text_dim) + .size() + .x; + let size = vec2(METER_WIDTH.max(name_width), height + CHANNEL_NAME_HEIGHT); let (cell, response) = ui.allocate_exact_size(size, Sense::hover()); covered = covered.union(cell); - let rect = Rect::from_min_size(cell.min, vec2(METER_WIDTH, height)); - let name = texts::channel_name(channels, channel); + let rect = Rect::from_center_size( + pos2(cell.center().x, cell.top() + height / 2.0), + vec2(METER_WIDTH, height), + ); let name_rect = Rect::from_min_max(pos2(cell.left(), rect.bottom()), cell.max); - let caption = Label::new(RichText::new(&name).size(CHANNEL_NAME_SIZE).weak()).extend(); + let caption = Label::new(RichText::new(&name).size(CHANNEL_NAME_SIZE).weak()); ui.put(name_rect, caption); let label = texts::meter_label(name_of_group, &name); response.widget_info(|| WidgetInfo::labeled(WidgetType::ProgressIndicator, true, &label)); diff --git a/crates/app/src/views/strip.rs b/crates/app/src/views/strip.rs index 0bb7910..1eadaed 100644 --- a/crates/app/src/views/strip.rs +++ b/crates/app/src/views/strip.rs @@ -161,8 +161,10 @@ fn level( let unit = paths::catalog_entry(catalog, &path) .and_then(|entry| entry.unit.clone()) .unwrap_or_default(); + // Top-aligned: the meter groups are taller than the fader (names, caption) and a centred + // row would shift them against it and against each other. let shown = ui - .horizontal(|ui| { + .with_layout(Layout::left_to_right(Align::Min), |ui| { let range = output_level_range(); let shown = fader::show(ui, speaker, catalog, commands, device, range, &unit); meters(ui, speaker, desk, tokens); @@ -225,7 +227,7 @@ struct MeterGroup<'a> { } fn meter_group(ui: &mut Ui, group: &MeterGroup, floor: f64, tokens: &Tokens) { - ui.horizontal(|ui| { + ui.with_layout(Layout::left_to_right(Align::Min), |ui| { ui.spacing_mut().item_spacing.x = METER_SPACING; if group.with_scale { meters::scale(ui, FADER_HEIGHT, floor, tokens); diff --git a/crates/app/tests/views.rs b/crates/app/tests/views.rs index a1d597e..d55c547 100644 --- a/crates/app/tests/views.rs +++ b/crates/app/tests/views.rs @@ -766,3 +766,33 @@ fn meters_name_their_channels_as_the_speaker_does() { } assert_eq!(harness.query_all_by_label("Sub").count(), 1); } + +#[test] +fn fader_and_meter_groups_line_up_at_the_top() { + let (_simulator, _directory, harness) = connected_card(SimModel::Kh750); + let top = |label: String| harness.get_by_label(&label).rect().top(); + let input = top(texts::meter_label(texts::INPUT_METERS, "A")); + let output = top(texts::meter_label(texts::OUTPUT_METERS, "A")); + let fader = harness + .get_by_role_and_label(Role::Slider, texts::LEVEL) + .rect() + .top(); + assert!( + (input - output).abs() < 1.0, + "input {input}, output {output}" + ); + assert!((input - fader).abs() < 1.0, "input {input}, fader {fader}"); +} + +#[test] +fn channel_names_do_not_touch() { + let (_simulator, _directory, harness) = connected_card(SimModel::Kh750); + // The output B is the right one of the two. + let b = harness + .query_all_by_label("B") + .map(|node| node.rect()) + .max_by(|first, second| first.left().total_cmp(&second.left())) + .unwrap(); + let sub = harness.get_by_label("Sub").rect(); + assert!(sub.left() > b.right(), "B {b:?}, Sub {sub:?}"); +} From 7692a19b30422e3c75d71f6b6a413876fff1e8ac Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:45:49 +0200 Subject: [PATCH 083/460] Mark the input channels the speaker plays --- README.md | 8 ++-- crates/app/src/input_source.rs | 77 ++++++++++++++++++++++++++++++++++ crates/app/src/lib.rs | 1 + crates/app/src/paths.rs | 2 + crates/app/src/texts.rs | 5 +++ crates/app/src/views/meters.rs | 38 +++++++++++++++-- crates/app/src/views/strip.rs | 9 +++- crates/app/tests/views.rs | 24 ++++++++++- 8 files changed, 155 insertions(+), 9 deletions(-) create mode 100644 crates/app/src/input_source.rs diff --git a/README.md b/README.md index 41d5b3b..485911f 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,11 @@ knowledge the path catalog is built from and the simulator, not a hardware test. address with "+ Adresse". Added speakers are remembered. - One channel strip per speaker, side by side: name, four LEDs (connection, clip, device warning, LOCAL), knobs for delay (shown in ms) and standby time, output level fader - (0-120 dB SPL in 0.5 dB steps), input level meters marked "Ein" with clip lamps (on the - KH 750 also its output meters, marked "Aus"), response time, "Stumm" (mute) and "Ident." - (identify). + (0-120 dB SPL in 0.5 dB steps), input level meters for channels A and B with clip lamps (on + the KH 750 also its output meters A, B and Sub, marked "Aus"), response time, "Stumm" (mute) + and "Ident." (identify). On the KH 120 II the input meters are captioned with the rear input + switch (Analog, AES3 L, AES3 R, Mono) and a dot marks the channels the speaker plays; that A + is the left AES3 channel is assumed, not measured. - Fader: grab it anywhere and drag; it moves with the pointer and never jumps to it. Shift makes it ten times finer, the mouse wheel moves 0.5 dB per notch, and a double-click on the value under it lets you type one. The value stands beside the fader while you drag. diff --git a/crates/app/src/input_source.rs b/crates/app/src/input_source.rs new file mode 100644 index 0000000..d42d036 --- /dev/null +++ b/crates/app/src/input_source.rs @@ -0,0 +1,77 @@ +//! Which input channels a speaker plays, from its rear input switch (`ui/input_select`). The +//! input meters show channels A and B of the input stage; the switch decides what the speaker +//! plays of them. That A is the left AES3 channel is assumed, not yet measured. + +use crate::paths; +use crate::state::SpeakerState; +use crate::texts; + +const ANALOG: &str = "ANALOG"; +const AES3_LEFT: &str = "AES3 L"; +const AES3_RIGHT: &str = "AES3 R"; +const MONO: &str = "MONO"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct InputSource { + /// Shown under the input meters instead of the generic caption. + pub caption: &'static str, + /// Whether channel A and channel B reach the speaker. + pub played: [bool; 2], +} + +/// None for models without the switch (KH 750) and for values the speakers never reported. +pub fn input_source(speaker: &SpeakerState) -> Option { + let value = speaker.values.get(&paths::path(paths::INPUT_SELECT))?; + let (caption, played) = match value.as_str()? { + ANALOG => (texts::SOURCE_ANALOG, [true, true]), + AES3_LEFT => (texts::SOURCE_AES3_LEFT, [true, false]), + AES3_RIGHT => (texts::SOURCE_AES3_RIGHT, [false, true]), + MONO => (texts::SOURCE_MONO, [true, true]), + _ => return None, + }; + Some(InputSource { caption, played }) +} + +#[cfg(test)] +mod tests { + use super::*; + use neumann_control_core::address::parse_address; + use neumann_control_core::speaker::Event; + use serde_json::{json, Value}; + + use crate::state::SpeakerKey; + + fn reporting(select: Value) -> SpeakerState { + let address = parse_address("fe80::2a36:38ff:fe12:3456%7", 45).unwrap(); + let mut speaker = SpeakerState::new(SpeakerKey::Address(address), address); + let path = paths::path(paths::INPUT_SELECT); + speaker.apply( + Event::Value { + path, + value: select, + }, + 0.0, + ); + speaker + } + + #[test] + fn the_switch_decides_which_channels_are_played() { + let cases = [ + ("ANALOG", texts::SOURCE_ANALOG, [true, true]), + ("AES3 L", texts::SOURCE_AES3_LEFT, [true, false]), + ("AES3 R", texts::SOURCE_AES3_RIGHT, [false, true]), + ("MONO", texts::SOURCE_MONO, [true, true]), + ]; + for (reported, caption, played) in cases { + let source = input_source(&reporting(json!(reported))); + assert_eq!(source, Some(InputSource { caption, played }), "{reported}"); + } + } + + #[test] + fn unknown_or_missing_switch_gives_no_source() { + assert_eq!(input_source(&reporting(json!("SOMETHING NEW"))), None); + assert_eq!(input_source(&reporting(json!(3))), None); + } +} diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index 5eed75a..0aa45f5 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -3,6 +3,7 @@ pub mod app; pub mod display; pub mod fader; pub mod gesture; +pub mod input_source; pub mod meter; pub mod paths; pub mod scan_choice; diff --git a/crates/app/src/paths.rs b/crates/app/src/paths.rs index 678d3c5..38879ad 100644 --- a/crates/app/src/paths.rs +++ b/crates/app/src/paths.rs @@ -16,6 +16,7 @@ pub const SERIAL: &str = "device/identity/serial"; pub const MAC_ADDRESSES: &str = "device/network/ether/macs"; pub const WARNINGS: &str = "warnings"; pub const CONTROL_MODE: &str = "ui/control_mode"; +pub const INPUT_SELECT: &str = "ui/input_select"; pub fn path(text: &str) -> SscPath { SscPath::parse(text).expect("constant path is valid") @@ -45,6 +46,7 @@ mod tests { SERIAL, WARNINGS, CONTROL_MODE, + INPUT_SELECT, ] { assert!(catalog_entry(catalog, &path(text)).is_some(), "{text}"); } diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index da59076..d6421c9 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -45,6 +45,11 @@ pub const INPUT_METERS: &str = "Eingangspegel"; pub const OUTPUT_METERS: &str = "Ausgangspegel"; pub const METER_INPUT_SHORT: &str = "Ein"; pub const METER_OUTPUT_SHORT: &str = "Aus"; +pub const SOURCE_ANALOG: &str = "Analog"; +pub const SOURCE_AES3_LEFT: &str = "AES3 L"; +pub const SOURCE_AES3_RIGHT: &str = "AES3 R"; +pub const SOURCE_MONO: &str = "Mono (A+B)"; +pub const PLAYED_CHANNEL: &str = "Wird wiedergegeben (Annahme: A = links, B = rechts)"; pub const ALL_VALUES: &str = "Alle Werte…"; pub const APPLY: &str = "Übernehmen"; diff --git a/crates/app/src/views/meters.rs b/crates/app/src/views/meters.rs index 043eb41..50aa7d8 100644 --- a/crates/app/src/views/meters.rs +++ b/crates/app/src/views/meters.rs @@ -3,8 +3,8 @@ //! peak as one lit segment, and a clip lamp on top. The scale is the spread one from `meter`. use eframe::egui::{ - pos2, vec2, Align2, Color32, CornerRadius, FontId, Label, Rect, RichText, Sense, Stroke, Ui, - Vec2, WidgetInfo, WidgetType, + pos2, vec2, Align2, Color32, CornerRadius, FontId, Label, Rect, Response, RichText, Sense, + Stroke, Ui, Vec2, WidgetInfo, WidgetType, }; use serde_json::Value; @@ -27,6 +27,8 @@ const SCALE_FONT_SIZE: f32 = 8.5; const SCALE_MARKS_DB: [f64; 5] = [0.0, -6.0, -20.0, -60.0, -90.0]; const CHANNEL_NAME_HEIGHT: f32 = 12.0; const CHANNEL_NAME_SIZE: f32 = 9.0; +const PLAYED_MARK_SPACE: f32 = 8.0; +const PLAYED_MARK_RADIUS: f32 = 2.0; /// One group of meters, side by side. pub struct Meters<'a> { @@ -37,6 +39,8 @@ pub struct Meters<'a> { pub levels: &'a [MeterState], /// The speaker's clip list for the same channels, if it reported one. pub clips: Option<&'a Value>, + /// Which channels the speaker plays; empty when it does not say. + pub played: &'a [bool], } /// Returns the area the meters themselves cover, without names that stick out beside them. @@ -59,16 +63,32 @@ pub fn show(ui: &mut Ui, group: &Meters, height: f32, floor: f64, tokens: &Token .layout_no_wrap(name.clone(), font, tokens.text_dim) .size() .x; - let size = vec2(METER_WIDTH.max(name_width), height + CHANNEL_NAME_HEIGHT); + let mark_space = if group.played.is_empty() { + 0.0 + } else { + PLAYED_MARK_SPACE + }; + let size = vec2( + METER_WIDTH.max(name_width), + height + CHANNEL_NAME_HEIGHT + mark_space, + ); let (cell, response) = ui.allocate_exact_size(size, Sense::hover()); covered = covered.union(cell); let rect = Rect::from_center_size( pos2(cell.center().x, cell.top() + height / 2.0), vec2(METER_WIDTH, height), ); - let name_rect = Rect::from_min_max(pos2(cell.left(), rect.bottom()), cell.max); + let name_bottom = rect.bottom() + CHANNEL_NAME_HEIGHT; + let name_rect = Rect::from_min_max( + pos2(cell.left(), rect.bottom()), + pos2(cell.right(), name_bottom), + ); let caption = Label::new(RichText::new(&name).size(CHANNEL_NAME_SIZE).weak()); ui.put(name_rect, caption); + if group.played.get(channel).copied().unwrap_or(false) { + let mark = Rect::from_min_max(pos2(cell.left(), name_bottom), cell.max); + ui.put(mark, |ui: &mut Ui| played_mark(ui, tokens)); + } let label = texts::meter_label(name_of_group, &name); response.widget_info(|| WidgetInfo::labeled(WidgetType::ProgressIndicator, true, &label)); let lamp_color = if clipped { @@ -97,6 +117,16 @@ pub fn show(ui: &mut Ui, group: &Meters, height: f32, floor: f64, tokens: &Token covered } +/// The dot under a channel the speaker plays. +fn played_mark(ui: &mut Ui, tokens: &Tokens) -> Response { + let size = Vec2::splat(2.0 * PLAYED_MARK_RADIUS); + let (rect, response) = ui.allocate_exact_size(size, Sense::hover()); + ui.painter() + .circle_filled(rect.center(), PLAYED_MARK_RADIUS, tokens.text); + response.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, texts::PLAYED_CHANNEL)); + response.on_hover_text(texts::PLAYED_CHANNEL) +} + /// dB marks beside the meters, at the same heights; marks below `floor` are left out. pub fn scale(ui: &mut Ui, height: f32, floor: f64, tokens: &Tokens) { let (rect, _) = ui.allocate_exact_size(vec2(SCALE_WIDTH, height), Sense::hover()); diff --git a/crates/app/src/views/strip.rs b/crates/app/src/views/strip.rs index 1eadaed..b595c51 100644 --- a/crates/app/src/views/strip.rs +++ b/crates/app/src/views/strip.rs @@ -15,6 +15,7 @@ use super::fader::{self, FADER_HEIGHT}; use super::knob; use super::meters::{self, Meters}; use crate::fader::{output_level_range, WHEEL_STEP_DB}; +use crate::input_source::input_source; use crate::paths; use crate::state::{SpeakerState, Status}; use crate::status_leds::{status_leds, ConnectionLed}; @@ -189,15 +190,20 @@ fn level( /// Input meters, then the output meters of models that report them (KH 750), set apart. fn meters(ui: &mut Ui, speaker: &SpeakerState, desk: &DeskSettings, tokens: &Tokens) { + let source = input_source(speaker); + let caption = source.map_or(texts::METER_INPUT_SHORT, |source| source.caption); + let played = source.map(|source| source.played).unwrap_or_default(); + let played: &[bool] = if source.is_some() { &played } else { &[] }; let floor = desk.meter_floor.db(); let input = MeterGroup { with_scale: true, - caption: texts::METER_INPUT_SHORT, + caption, meters: Meters { name: texts::INPUT_METERS, channels: texts::INPUT_CHANNELS, levels: &speaker.input_meters, clips: speaker.values.get(&paths::path(paths::INPUT_CLIP)), + played, }, }; meter_group(ui, &input, floor, tokens); @@ -213,6 +219,7 @@ fn meters(ui: &mut Ui, speaker: &SpeakerState, desk: &DeskSettings, tokens: &Tok channels: texts::OUTPUT_CHANNELS, levels: &speaker.output_meters, clips: speaker.values.get(&paths::path(paths::OUTPUT_CLIP)), + played: &[], }, }; meter_group(ui, &output, floor, tokens); diff --git a/crates/app/tests/views.rs b/crates/app/tests/views.rs index d55c547..c7650a7 100644 --- a/crates/app/tests/views.rs +++ b/crates/app/tests/views.rs @@ -726,8 +726,9 @@ fn meter_groups_are_labelled_input_and_output() { let (_simulator, _directory, harness) = connected_card(SimModel::Kh750); harness.get_by_label(texts::METER_INPUT_SHORT); harness.get_by_label(texts::METER_OUTPUT_SHORT); + // The KH 120 II names its input by the rear switch instead. let (_other, _other_directory, kh120) = connected_card(SimModel::Kh120Ii); - kh120.get_by_label(texts::METER_INPUT_SHORT); + kh120.get_by_label(texts::SOURCE_ANALOG); assert!(kh120.query_by_label(texts::METER_OUTPUT_SHORT).is_none()); } @@ -796,3 +797,24 @@ fn channel_names_do_not_touch() { let sub = harness.get_by_label("Sub").rect(); assert!(sub.left() > b.right(), "B {b:?}, Sub {sub:?}"); } + +#[test] +fn played_input_channels_are_marked_with_the_source() { + let (simulator, _directory, analog) = connected_card(SimModel::Kh120Ii); + analog.get_by_label(texts::SOURCE_ANALOG); + assert_eq!(analog.query_all_by_label(texts::PLAYED_CHANNEL).count(), 2); + drop((simulator, analog)); + // The switch is polled every 30 s, so the speaker reports AES3 L from the start. + let left = Simulator::start(SimModel::Kh120Ii); + left.set_value("ui/input_select", serde_json::json!("AES3 L")); + let directory = tempfile::tempdir().unwrap(); + let path = settings_file(directory.path(), vec![manual_for(&left)]); + let mut aes3 = harness(path, Vec::new()); + wait_for_level(&mut aes3, 100.0); + wait_for_initial_read(&mut aes3, &left); + aes3.get_by_label(texts::SOURCE_AES3_LEFT); + assert_eq!(aes3.query_all_by_label(texts::PLAYED_CHANNEL).count(), 1); + let (_other, _other_directory, kh750) = connected_card(SimModel::Kh750); + kh750.get_by_label(texts::METER_INPUT_SHORT); + assert_eq!(kh750.query_all_by_label(texts::PLAYED_CHANNEL).count(), 0); +} From d2fb8fe5a81e6e7b6ce6841f807865827be09f54 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:53:13 +0200 Subject: [PATCH 084/460] Keep the meter caption on one line --- crates/app/src/views/strip.rs | 26 +++++++++++++++++++++----- crates/app/tests/views.rs | 12 ++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/crates/app/src/views/strip.rs b/crates/app/src/views/strip.rs index b595c51..050b043 100644 --- a/crates/app/src/views/strip.rs +++ b/crates/app/src/views/strip.rs @@ -3,8 +3,8 @@ //! last message. The strip only reports gestures; `writes` decides what becomes a command. use eframe::egui::{ - self, vec2, Align, Button, Color32, CornerRadius, Label, Layout, Pos2, RichText, Sense, Stroke, - Ui, Vec2, WidgetInfo, WidgetType, + self, vec2, Align, Button, Color32, CornerRadius, Label, Layout, Pos2, Rect, RichText, Sense, + Stroke, Ui, Vec2, WidgetInfo, WidgetType, }; use neumann_control_core::catalog::Catalog; use neumann_control_core::settings::DeskSettings; @@ -32,6 +32,7 @@ const LED_SPACING: f32 = 6.0; const UNREACHABLE_OPACITY: f32 = 0.55; const METER_GROUP_GAP: f32 = 4.0; const METER_SPACING: f32 = 3.0; +const CAPTION_HEIGHT: f32 = 16.0; const DELAY_PATH: &str = "audio/out/delay"; const LEVEL_ENTRY_ID: &str = "level_entry"; const STANDBY_TIME_PATH: &str = "device/standby/auto_standby_time"; @@ -244,9 +245,20 @@ fn meter_group(ui: &mut Ui, group: &MeterGroup, floor: f64, tokens: &Tokens) { let meters = ui .horizontal(|ui| meters::show(ui, &group.meters, FADER_HEIGHT, floor, tokens)) .inner; - let caption = Label::new(RichText::new(group.caption).small().weak()); - let cell = vec2(meters.width(), 0.0); - ui.allocate_ui_with_layout(cell, Layout::top_down(Align::Center), |ui| ui.add(caption)); + // Centred on the meters but never wrapped: "Analog" is wider than two meters. + let caption = RichText::new(group.caption).small().weak(); + let width = ui + .painter() + .layout_no_wrap(caption.text().to_owned(), small_font(ui), tokens.text_dim) + .size() + .x; + let size = vec2(meters.width(), CAPTION_HEIGHT); + let (reserved, _) = ui.allocate_exact_size(size, Sense::hover()); + let row = Rect::from_center_size( + egui::pos2(meters.center().x, reserved.center().y), + vec2(width.max(meters.width()), CAPTION_HEIGHT), + ); + ui.put(row, Label::new(caption).extend()); }); }); } @@ -302,3 +314,7 @@ fn message(ui: &mut Ui, speaker: &SpeakerState, tokens: &Tokens) { ui.add(Label::new(RichText::new(message).small().color(tokens.danger)).wrap()); } } + +fn small_font(ui: &Ui) -> egui::FontId { + egui::TextStyle::Small.resolve(ui.style()) +} diff --git a/crates/app/tests/views.rs b/crates/app/tests/views.rs index c7650a7..c03a214 100644 --- a/crates/app/tests/views.rs +++ b/crates/app/tests/views.rs @@ -818,3 +818,15 @@ fn played_input_channels_are_marked_with_the_source() { kh750.get_by_label(texts::METER_INPUT_SHORT); assert_eq!(kh750.query_all_by_label(texts::PLAYED_CHANNEL).count(), 0); } + +#[test] +fn input_source_caption_stays_on_one_line() { + let (_simulator, _directory, harness) = connected_card(SimModel::Kh120Ii); + let caption = harness.get_by_label(texts::SOURCE_ANALOG).rect(); + assert!(caption.width() > caption.height(), "{caption:?}"); + let level = harness.get_by_label(&readout(100.0)).rect(); + assert!( + caption.bottom() <= level.top(), + "caption {caption:?}, level {level:?}" + ); +} From 5f637a692f798944ffb16ea692fedd523a1afee9 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:03:01 +0200 Subject: [PATCH 085/460] Keep speaker groups and the desk order --- crates/app/src/groups.rs | 223 ++++++++++++++++++++++++++++++++++ crates/app/src/lib.rs | 1 + crates/app/src/paths.rs | 4 + crates/core/src/settings.rs | 24 ++++ crates/core/tests/settings.rs | 36 +++++- 5 files changed, 286 insertions(+), 2 deletions(-) create mode 100644 crates/app/src/groups.rs diff --git a/crates/app/src/groups.rs b/crates/app/src/groups.rs new file mode 100644 index 0000000..c60cf7e --- /dev/null +++ b/crates/app/src/groups.rs @@ -0,0 +1,223 @@ +//! Groups on the desk: who belongs where, the order of the strips, and the relative moves of a +//! group or master fader. No drawing here. + +use std::collections::HashMap; +use std::ops::RangeInclusive; + +use neumann_control_core::settings::{GroupSettings, Linkable}; +use neumann_control_core::ssc::wire::SscPath; + +use crate::paths; +use crate::state::SpeakerKey; + +const SERIAL_PREFIX: &str = "serial:"; +const ADDRESS_PREFIX: &str = "address:"; + +/// How a speaker is named in `settings.json`: by serial once known, else by address. +pub fn member_id(key: &SpeakerKey) -> String { + match key { + SpeakerKey::Serial(serial) => format!("{SERIAL_PREFIX}{serial}"), + SpeakerKey::Address(address) => format!("{ADDRESS_PREFIX}{address}"), + } +} + +/// The SSC path a linked setting writes. +pub fn linked_path(linkable: Linkable) -> SscPath { + let text = match linkable { + Linkable::Level => paths::OUTPUT_LEVEL, + Linkable::Mute => paths::MUTE, + Linkable::Standby => paths::STANDBY_ENABLED, + Linkable::Input => paths::INPUT_INTERFACE, + }; + paths::path(text) +} + +pub const LINKABLE: [Linkable; 4] = [ + Linkable::Level, + Linkable::Mute, + Linkable::Standby, + Linkable::Input, +]; + +/// The linked setting a path belongs to, if any. +pub fn linkable_of(path: &SscPath) -> Option { + LINKABLE + .into_iter() + .find(|linkable| linked_path(*linkable) == *path) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DeskItem { + /// Index into the speakers as the app holds them. + Speaker(usize), + /// Index into the groups; drawn after its members. + GroupStrip(usize), +} + +/// The strips from left to right: ungrouped speakers first, then each group's members followed +/// by its group strip. Within each part the saved order decides; speakers it does not know yet +/// follow in the app's order. +pub fn layout(order: &[String], groups: &[GroupSettings], speakers: &[String]) -> Vec { + let rank: HashMap<&str, usize> = order + .iter() + .enumerate() + .map(|(position, id)| (id.as_str(), position)) + .collect(); + let mut sorted: Vec = (0..speakers.len()).collect(); + sorted.sort_by_key(|index| { + ( + rank.get(speakers[*index].as_str()) + .copied() + .unwrap_or(usize::MAX), + *index, + ) + }); + let group_of = |index: usize| { + groups + .iter() + .position(|group| group.members.contains(&speakers[index])) + }; + let mut items: Vec = sorted + .iter() + .copied() + .filter(|index| group_of(*index).is_none()) + .map(DeskItem::Speaker) + .collect(); + for (group, _) in groups.iter().enumerate() { + let members = sorted + .iter() + .copied() + .filter(|index| group_of(*index) == Some(group)); + items.extend(members.map(DeskItem::Speaker)); + items.push(DeskItem::GroupStrip(group)); + } + items +} + +/// Puts `member` into `group` (or into none), out of any other group. +pub fn set_membership(groups: &mut [GroupSettings], member: &str, group: Option) { + for existing in groups.iter_mut() { + existing.members.retain(|id| id != member); + } + if let Some(target) = group.and_then(|index| groups.get_mut(index)) { + target.members.push(member.to_owned()); + } +} + +/// Moves `member` in front of `before` in the saved order; speakers the order does not know +/// yet are added first in `speakers` order, so the result covers everyone. +pub fn move_in_order(order: &mut Vec, speakers: &[String], member: &str, before: &str) { + for id in speakers { + if !order.contains(id) { + order.push(id.clone()); + } + } + order.retain(|id| id != member); + let at = order + .iter() + .position(|id| id == before) + .unwrap_or(order.len()); + order.insert(at, member.to_owned()); +} + +/// The level a group or master fader shows: its loudest member. +pub fn reference_level(levels: &[f64]) -> Option { + levels.iter().copied().reduce(f64::max) +} + +/// Every member moved by `delta` dB within its own range; members that would not change are +/// left out, so nothing is written for them. +pub fn shifted_levels( + members: &[(K, f64, RangeInclusive)], + delta: f64, +) -> Vec<(K, f64)> { + members + .iter() + .filter_map(|(key, level, range)| { + let shifted = (level + delta).clamp(*range.start(), *range.end()); + (shifted != *level).then(|| (key.clone(), shifted)) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn group(name: &str, members: &[&str]) -> GroupSettings { + GroupSettings { + name: name.to_owned(), + color: 0, + members: members.iter().map(|id| (*id).to_owned()).collect(), + linked: Vec::new(), + } + } + + fn ids(names: &[&str]) -> Vec { + names.iter().map(|name| (*name).to_owned()).collect() + } + + #[test] + fn layout_puts_ungrouped_first_then_each_group_with_its_strip() { + let speakers = ids(&["a", "b", "c", "d"]); + let groups = [group("Front", &["c", "a"]), group("Sub", &["d"])]; + let order = ids(&["c", "b", "a"]); + let items = layout(&order, &groups, &speakers); + use DeskItem::*; + assert_eq!( + items, + vec![ + Speaker(1), + Speaker(2), + Speaker(0), + GroupStrip(0), + Speaker(3), + GroupStrip(1) + ] + ); + } + + #[test] + fn a_speaker_is_in_one_group_at_most() { + let mut groups = [group("Front", &["a"]), group("Sub", &[])]; + set_membership(&mut groups, "a", Some(1)); + assert!(groups[0].members.is_empty()); + assert_eq!(groups[1].members, ids(&["a"])); + set_membership(&mut groups, "a", None); + assert!(groups.iter().all(|group| group.members.is_empty())); + } + + #[test] + fn moving_puts_the_speaker_before_the_target() { + let mut order = ids(&["a", "b"]); + move_in_order(&mut order, &ids(&["a", "b", "c"]), "c", "a"); + assert_eq!(order, ids(&["c", "a", "b"])); + move_in_order(&mut order, &ids(&["a", "b", "c"]), "c", "b"); + assert_eq!(order, ids(&["a", "c", "b"])); + } + + #[test] + fn group_fader_follows_the_loudest_and_keeps_differences() { + assert_eq!(reference_level(&[90.0, 96.0]), Some(96.0)); + assert_eq!(reference_level(&[]), None); + let members = [("a", 90.0, 0.0..=120.0), ("b", 96.0, 0.0..=120.0)]; + assert_eq!( + shifted_levels(&members, -6.0), + vec![("a", 84.0), ("b", 90.0)] + ); + } + + #[test] + fn a_member_at_its_limit_stays_there() { + let members = [("a", 118.0, 0.0..=120.0), ("b", 120.0, 0.0..=120.0)]; + assert_eq!(shifted_levels(&members, 5.0), vec![("a", 120.0)]); + } + + #[test] + fn linked_settings_map_to_their_paths_and_back() { + for linkable in LINKABLE { + assert_eq!(linkable_of(&linked_path(linkable)), Some(linkable)); + } + assert_eq!(linkable_of(&paths::path(paths::IDENTIFY)), None); + } +} diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index 0aa45f5..d090323 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -3,6 +3,7 @@ pub mod app; pub mod display; pub mod fader; pub mod gesture; +pub mod groups; pub mod input_source; pub mod meter; pub mod paths; diff --git a/crates/app/src/paths.rs b/crates/app/src/paths.rs index 38879ad..9584fe5 100644 --- a/crates/app/src/paths.rs +++ b/crates/app/src/paths.rs @@ -17,6 +17,8 @@ pub const MAC_ADDRESSES: &str = "device/network/ether/macs"; pub const WARNINGS: &str = "warnings"; pub const CONTROL_MODE: &str = "ui/control_mode"; pub const INPUT_SELECT: &str = "ui/input_select"; +pub const STANDBY_ENABLED: &str = "device/standby/enabled"; +pub const INPUT_INTERFACE: &str = "audio/in/interface"; pub fn path(text: &str) -> SscPath { SscPath::parse(text).expect("constant path is valid") @@ -47,6 +49,8 @@ mod tests { WARNINGS, CONTROL_MODE, INPUT_SELECT, + STANDBY_ENABLED, + INPUT_INTERFACE, ] { assert!(catalog_entry(catalog, &path(text)).is_some(), "{text}"); } diff --git a/crates/core/src/settings.rs b/crates/core/src/settings.rs index 127162b..bd1cdb6 100644 --- a/crates/core/src/settings.rs +++ b/crates/core/src/settings.rs @@ -21,6 +21,30 @@ pub struct Settings { pub manual: Vec, pub window: Option, pub desk: DeskSettings, + pub groups: Vec, + /// Speaker identities (`serial:` or `address:
`) in the order of the desk. + pub order: Vec, +} + +/// A group of speakers with a group strip; a speaker is in at most one group. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GroupSettings { + pub name: String, + /// Index into the app's group colour palette. + pub color: u8, + /// Speaker identities, as in `Settings::order`. + pub members: Vec, + /// Settings a change on one member sends to all members. + #[serde(default)] + pub linked: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum Linkable { + Level, + Mute, + Standby, + Input, } /// What the settings dialog changes. diff --git a/crates/core/tests/settings.rs b/crates/core/tests/settings.rs index 8c40c5f..6fc30aa 100644 --- a/crates/core/tests/settings.rs +++ b/crates/core/tests/settings.rs @@ -1,6 +1,6 @@ use neumann_control_core::settings::{ - load, save, DeskSettings, ManualSpeaker, MeterFloor, Settings, ThemeChoice, WindowGeometry, - SETTINGS_FILE, + load, save, DeskSettings, GroupSettings, Linkable, ManualSpeaker, MeterFloor, Settings, + ThemeChoice, WindowGeometry, SETTINGS_FILE, }; use std::fs; @@ -25,6 +25,7 @@ fn sample() -> Settings { height: 600.0, }), desk: DeskSettings::default(), + ..Settings::default() } } @@ -135,3 +136,34 @@ fn desk_settings_round_trip() { save(&settings, &path).unwrap(); assert_eq!(load(&path), settings); } + +#[test] +fn old_settings_file_loads_without_groups() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(SETTINGS_FILE); + fs::write(&path, r#"{"manual":[],"window":null}"#).unwrap(); + let loaded = load(&path); + assert!(loaded.groups.is_empty()); + assert!(loaded.order.is_empty()); +} + +#[test] +fn groups_and_order_round_trip() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(SETTINGS_FILE); + let settings = Settings { + groups: vec![GroupSettings { + name: "Front".to_owned(), + color: 1, + members: vec!["serial:SIM0001234".to_owned()], + linked: vec![Linkable::Level, Linkable::Mute], + }], + order: vec![ + "serial:SIM0001234".to_owned(), + "serial:SIM0007500".to_owned(), + ], + ..Settings::default() + }; + save(&settings, &path).unwrap(); + assert_eq!(load(&path), settings); +} From 923a66bba567205fa58ed179962f1c784b3d5fc1 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:09:29 +0200 Subject: [PATCH 086/460] Add group strips, a master strip and linked settings --- crates/app/src/app.rs | 135 ++++++----- crates/app/src/app/desk.rs | 345 +++++++++++++++++++++++++++ crates/app/src/texts.rs | 48 ++++ crates/app/src/ui_kit/theme.rs | 18 ++ crates/app/src/views/fader.rs | 58 +++++ crates/app/src/views/group_dialog.rs | 39 +++ crates/app/src/views/group_strip.rs | 114 +++++++++ crates/app/src/views/mod.rs | 2 + crates/app/src/views/panel.rs | 77 +++++- crates/app/src/views/strip.rs | 9 +- crates/app/src/views/toolbar.rs | 4 + crates/app/tests/groups.rs | 191 +++++++++++++++ 12 files changed, 979 insertions(+), 61 deletions(-) create mode 100644 crates/app/src/app/desk.rs create mode 100644 crates/app/src/views/group_dialog.rs create mode 100644 crates/app/src/views/group_strip.rs create mode 100644 crates/app/tests/groups.rs diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index 52e814b..93081a7 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -7,7 +7,7 @@ use std::sync::mpsc::{Receiver, TryRecvError}; use std::sync::Arc; use std::thread; -use eframe::egui::{self, Align, Context, Layout, Pos2, ScrollArea, Ui, ViewportInfo}; +use eframe::egui::{self, Context, Pos2, Ui, ViewportInfo}; use neumann_control_core::address::parse_address; use neumann_control_core::catalog::Catalog; use neumann_control_core::discovery::{merge_found, Found}; @@ -15,6 +15,7 @@ use neumann_control_core::mask::mask_host; use neumann_control_core::settings::{self, ManualSpeaker, Settings, WindowGeometry}; use neumann_control_core::speaker::{Command, Event, Events, SpeakerHandle, Target}; +use crate::groups::set_membership; use crate::scan_choice::ScanChoice; use crate::spawner::{ScanResult, SpeakerSpawner}; use crate::state::{AllValuesState, SpeakerKey, SpeakerState, Status}; @@ -23,11 +24,14 @@ use crate::targets::{duplicate_cards, follow_moves, forget, remember_serial, tar use crate::texts; use crate::ui_kit::{fonts, theme}; use crate::views::add_address::{self, AddAddressAction, AddAddressForm}; +use crate::views::group_dialog::{self, GroupDialogAction}; use crate::views::remove_speaker::{self, RemoveAction}; use crate::views::scan_choice::{self as scan_choice_view, ScanChoiceAction}; use crate::views::settings_dialog::{self, SettingsAction}; use crate::views::toolbar::{self, ToolbarAction, ToolbarFacts}; -use crate::views::{all_values, panel, strip}; +use crate::views::{all_values, panel}; + +mod desk; const DESK_SCROLL_ID: &str = "desk"; @@ -67,6 +71,7 @@ pub struct NeumannApp { add_address: Option, removal: Option, panel: Option, + group_draft: Option, settings_open: bool, window: Option, } @@ -97,6 +102,7 @@ impl NeumannApp { add_address: None, removal: None, panel: None, + group_draft: None, settings_open: false, }; app.sync_targets(); @@ -294,6 +300,7 @@ impl NeumannApp { self.scan_choice = Some(ScanChoice::Scanning); } ToolbarAction::AddAddress => self.add_address = Some(AddAddressForm::default()), + ToolbarAction::AddGroup => self.add_group(), ToolbarAction::Settings => self.settings_open = true, ToolbarAction::None => {} } @@ -303,6 +310,7 @@ impl NeumannApp { self.show_scan_choice(ui); self.show_removal(ui); self.show_settings(ui); + self.show_group_dialog(ui); self.show_all_values(ui.ctx()); } @@ -318,75 +326,58 @@ impl NeumannApp { } } - fn show_desk(&mut self, ui: &mut Ui) { - if self.speakers.is_empty() { - ui.weak(texts::NO_SPEAKERS); - return; - } - let catalog = Catalog::embedded(); - let desk = self.settings.desk; - let mut opened = None; - ScrollArea::both() - .id_salt(DESK_SCROLL_ID) - .auto_shrink([false, false]) - .show(ui, |ui| { - ui.with_layout(Layout::left_to_right(Align::Min), |ui| { - for speaker in &mut self.speakers { - let key = speaker.state.key.clone(); - let response = ui - .push_id(&key, |ui| { - strip::show(ui, &mut speaker.state, catalog, &desk) - }) - .inner; - for command in response.commands { - speaker.handle.send(command); - } - if let Some(anchor) = response.open_panel { - opened = Some(OpenPanel { - key, - anchor, - opened_now: true, - }); - } - } - }); - }); - if opened.is_some() { - self.panel = opened; - } - } - /// The open device panel; it closes when its speaker is gone or unreachable. fn show_panel(&mut self, context: &Context) { let Some(open) = &mut self.panel else { return; }; - let speaker = self + let index = self .speakers - .iter_mut() - .find(|speaker| speaker.state.key == open.key) - .filter(|speaker| speaker.state.status != Status::Unreachable); - let Some(speaker) = speaker else { + .iter() + .position(|speaker| speaker.state.key == open.key) + .filter(|index| self.speakers[*index].state.status != Status::Unreachable); + let Some(index) = index else { self.panel = None; return; }; - let catalog = Catalog::embedded(); - let response = panel::show( - context, - &mut speaker.state, - catalog, - open.anchor, - open.opened_now, - ); + let (anchor, opened_now) = (open.anchor, open.opened_now); open.opened_now = false; + let catalog = Catalog::embedded(); + let ids = self.member_ids(); + let names: Vec = self + .settings + .groups + .iter() + .map(|group| group.name.clone()) + .collect(); + let current = desk::group_of(&self.settings.groups, &ids[index]); + let linked = current.map_or(Vec::new(), |group| { + self.settings.groups[group].linked.clone() + }); + let choice = panel::GroupChoice { + names: &names, + current, + linked: &linked, + }; + let state = &mut self.speakers[index].state; + let response = panel::show(context, state, catalog, anchor, opened_now, &choice); + let now = context.input(|input| input.time); for command in response.commands { - speaker.handle.send(command); + self.send_with_links(index, command, catalog, &ids, now); + } + if let Some(group) = response.group { + set_membership(&mut self.settings.groups, &ids[index], group); + self.save_settings(); } + if let Some(linkable) = response.copy_to_group { + self.copy_to_group(index, linkable, catalog, &ids, now); + } + let speaker = &self.speakers[index].state; if response.remove { self.removal = Some(Removal { - key: speaker.state.key.clone(), - address: speaker.state.address, - name: speaker.state.display_name(), + key: speaker.key.clone(), + address: speaker.address, + name: speaker.display_name(), }); } if response.close || response.remove { @@ -394,6 +385,36 @@ impl NeumannApp { } } + fn show_group_dialog(&mut self, ui: &mut Ui) { + let Some(draft) = &mut self.group_draft else { + return; + }; + match group_dialog::show(ui, &mut draft.name) { + GroupDialogAction::None => {} + GroupDialogAction::Close => { + let (index, name) = (draft.index, draft.name.trim().to_owned()); + if let Some(group) = self + .settings + .groups + .get_mut(index) + .filter(|_| !name.is_empty()) + { + group.name = name; + } + self.group_draft = None; + self.save_settings(); + } + GroupDialogAction::Delete => { + let index = draft.index; + if index < self.settings.groups.len() { + self.settings.groups.remove(index); + } + self.group_draft = None; + self.save_settings(); + } + } + } + fn average_response_time(&self) -> Option { if !self.settings.desk.show_response_times { return None; diff --git a/crates/app/src/app/desk.rs b/crates/app/src/app/desk.rs new file mode 100644 index 0000000..af936ce --- /dev/null +++ b/crates/app/src/app/desk.rs @@ -0,0 +1,345 @@ +//! The desk: strips in the saved order with their groups, group strips and the master strip, +//! and what their gestures mean for each speaker: relative moves of a group or the master, and +//! a change on a group member sent on to the other members when the group links that setting. + +use eframe::egui::{Align, Layout, ScrollArea, Ui}; +use neumann_control_core::catalog::Catalog; +use neumann_control_core::settings::{GroupSettings, Linkable}; +use neumann_control_core::speaker::Command; +use serde_json::Value; + +use super::{NeumannApp, OpenPanel, DESK_SCROLL_ID}; +use crate::fader::output_level_range; +use crate::groups::{ + layout, linkable_of, linked_path, member_id, reference_level, shifted_levels, DeskItem, +}; +use crate::paths; +use crate::state::{SpeakerState, Status}; +use crate::texts; +use crate::ui_kit::theme::{group_color, group_color_count}; +use crate::views::group_strip::{self, GroupStripResponse, MasterResponse}; +use crate::views::strip; +use crate::writes::Intent; + +/// What a frame's gestures asked for; applied once the desk is drawn. +enum DeskAction { + Commands(usize, Vec), + Group(usize, GroupStripResponse), + Master(MasterResponse), + OpenPanel(OpenPanel), +} + +/// The group being renamed or deleted, and the name typed so far. +pub(super) struct GroupDraft { + pub index: usize, + pub name: String, +} + +impl NeumannApp { + pub(super) fn show_desk(&mut self, ui: &mut Ui) { + if self.speakers.is_empty() { + ui.weak(texts::NO_SPEAKERS); + return; + } + let catalog = Catalog::embedded(); + let desk = self.settings.desk; + let ids = self.member_ids(); + let items = layout(&self.settings.order, &self.settings.groups, &ids); + let range = output_level_range(); + let unit = level_unit(catalog); + let mut actions = Vec::new(); + ScrollArea::both() + .id_salt(DESK_SCROLL_ID) + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.with_layout(Layout::left_to_right(Align::Min), |ui| { + for item in &items { + match *item { + DeskItem::Speaker(index) => { + let color = group_of(&self.settings.groups, &ids[index]) + .map(|group| group_color(self.settings.groups[group].color)); + let speaker = &mut self.speakers[index]; + let key = speaker.state.key.clone(); + let response = ui + .push_id(&key, |ui| { + strip::show(ui, &mut speaker.state, catalog, &desk, color) + }) + .inner; + actions.push(DeskAction::Commands(index, response.commands)); + if let Some(anchor) = response.open_panel { + let opened_now = true; + let open = OpenPanel { + key, + anchor, + opened_now, + }; + actions.push(DeskAction::OpenPanel(open)); + } + } + DeskItem::GroupStrip(group) => { + let settings = &self.settings.groups[group]; + let members = members_of(settings, &ids); + let states = self.states(&members); + let reference = reference_level(&levels(&states)); + let muted = all_muted(&states); + let color = group_color(settings.color); + let response = ui + .push_id((texts::GROUP, group), |ui| { + let range = range.clone(); + group_strip::group( + ui, settings, color, reference, muted, range, &unit, + ) + }) + .inner; + actions.push(DeskAction::Group(group, response)); + } + } + } + let everyone: Vec = (0..self.speakers.len()).collect(); + let states = self.states(&everyone); + let reference = reference_level(&levels(&states)); + let muted = all_muted(&states); + let response = group_strip::master(ui, reference, muted, range.clone(), &unit); + actions.push(DeskAction::Master(response)); + }); + }); + let now = ui.input(|input| input.time); + for action in actions { + self.apply_desk_action(action, catalog, &ids, now); + } + } + + fn apply_desk_action( + &mut self, + action: DeskAction, + catalog: &Catalog, + ids: &[String], + now: f64, + ) { + match action { + DeskAction::Commands(index, commands) => { + for command in commands { + self.send_with_links(index, command, catalog, ids, now); + } + } + DeskAction::Group(group, response) => { + let members = members_of(&self.settings.groups[group], ids); + self.apply_shared(&members, response.level, response.mute, catalog, now); + if let Some(linkable) = response.toggled_link { + toggle_link(&mut self.settings.groups[group], linkable); + self.save_settings(); + } + if response.open_settings { + let name = self.settings.groups[group].name.clone(); + self.group_draft = Some(GroupDraft { index: group, name }); + } + } + DeskAction::Master(response) => { + let everyone: Vec = (0..self.speakers.len()).collect(); + self.apply_shared(&everyone, response.level, response.mute, catalog, now); + } + DeskAction::OpenPanel(open) => self.panel = Some(open), + } + } + + /// A group or master fader let go at `level`: every connected member moves by the same + /// amount within its own range; a mute switch sets mute on all of them. + fn apply_shared( + &mut self, + members: &[usize], + level: Option, + mute: Option, + catalog: &Catalog, + now: f64, + ) { + if let Some(level) = level { + let states = self.states(members); + if let Some(reference) = reference_level(&levels(&states)) { + let range = output_level_range(); + let current: Vec<(usize, f64, _)> = members + .iter() + .filter_map(|index| { + level_of(&self.speakers[*index].state) + .map(|level| (*index, level, range.clone())) + }) + .collect(); + for (index, shifted) in shifted_levels(¤t, level - reference) { + self.request_and_send(index, Intent::Level(shifted), catalog, now); + } + } + } + if let Some(mute) = mute { + for index in members { + let path = paths::path(paths::MUTE); + let intent = Intent::Write { + path, + value: Value::Bool(mute), + }; + self.request_and_send(*index, intent, catalog, now); + } + } + } + + /// Sends a speaker's command, and when its group links the setting, the same value to the + /// other members whose product may write it. + pub(super) fn send_with_links( + &mut self, + index: usize, + command: Command, + catalog: &Catalog, + ids: &[String], + now: f64, + ) { + let partners = self.link_partners(index, &command, ids); + self.speakers[index].handle.send(command.clone()); + let Command::Set { path, value } = command else { + return; + }; + for partner in partners { + let intent = Intent::Write { + path: path.clone(), + value: value.clone(), + }; + self.request_and_send(partner, intent, catalog, now); + } + } + + /// "Für Gruppe übernehmen": the member's current value of `linkable` for all others. + pub(super) fn copy_to_group( + &mut self, + index: usize, + linkable: Linkable, + catalog: &Catalog, + ids: &[String], + now: f64, + ) { + let path = linked_path(linkable); + let Some(value) = self.speakers[index].state.values.get(&path).cloned() else { + return; + }; + let Some(group) = group_of(&self.settings.groups, &ids[index]) else { + return; + }; + for partner in members_of(&self.settings.groups[group], ids) { + if partner != index { + let intent = Intent::Write { + path: path.clone(), + value: value.clone(), + }; + self.request_and_send(partner, intent, catalog, now); + } + } + } + + fn link_partners(&self, index: usize, command: &Command, ids: &[String]) -> Vec { + let Command::Set { path, .. } = command else { + return Vec::new(); + }; + let Some(linkable) = linkable_of(path) else { + return Vec::new(); + }; + let Some(group) = group_of(&self.settings.groups, &ids[index]) else { + return Vec::new(); + }; + let settings = &self.settings.groups[group]; + if !settings.linked.contains(&linkable) { + return Vec::new(); + } + members_of(settings, ids) + .into_iter() + .filter(|member| *member != index) + .collect() + } + + fn request_and_send(&mut self, index: usize, intent: Intent, catalog: &Catalog, now: f64) { + let speaker = &mut self.speakers[index]; + if let Some(command) = speaker.state.request(intent, catalog, now) { + speaker.handle.send(command); + } + } + + pub(super) fn member_ids(&self) -> Vec { + self.speakers + .iter() + .map(|speaker| member_id(&speaker.state.key)) + .collect() + } + + pub(super) fn add_group(&mut self) { + let count = self.settings.groups.len(); + let name = format!("{} {}", texts::NEW_GROUP_NAME, count + 1); + let color = (count % usize::from(group_color_count())) as u8; + self.settings.groups.push(GroupSettings { + name: name.clone(), + color, + members: Vec::new(), + linked: Vec::new(), + }); + self.save_settings(); + self.group_draft = Some(GroupDraft { index: count, name }); + } + + fn states(&self, indexes: &[usize]) -> Vec<&SpeakerState> { + indexes + .iter() + .map(|index| &self.speakers[*index].state) + .collect() + } +} + +pub(super) fn group_of(groups: &[GroupSettings], id: &str) -> Option { + groups + .iter() + .position(|group| group.members.iter().any(|member| member == id)) +} + +fn members_of(group: &GroupSettings, ids: &[String]) -> Vec { + ids.iter() + .enumerate() + .filter(|(_, id)| group.members.contains(id)) + .map(|(index, _)| index) + .collect() +} + +fn toggle_link(group: &mut GroupSettings, linkable: Linkable) { + if group.linked.contains(&linkable) { + group.linked.retain(|linked| *linked != linkable); + } else { + group.linked.push(linkable); + } +} + +fn level_of(state: &SpeakerState) -> Option { + if state.status != Status::Connected { + return None; + } + state + .values + .get(&paths::path(paths::OUTPUT_LEVEL)) + .and_then(Value::as_f64) +} + +fn levels(states: &[&SpeakerState]) -> Vec { + states.iter().filter_map(|state| level_of(state)).collect() +} + +/// Whether every connected member is muted; none when no member is connected. +fn all_muted(states: &[&SpeakerState]) -> Option { + let mutes: Vec = states + .iter() + .filter(|state| state.status == Status::Connected) + .filter_map(|state| { + state + .values + .get(&paths::path(paths::MUTE)) + .and_then(Value::as_bool) + }) + .collect(); + (!mutes.is_empty()).then(|| mutes.iter().all(|muted| *muted)) +} + +fn level_unit(catalog: &Catalog) -> String { + paths::catalog_entry(catalog, &paths::path(paths::OUTPUT_LEVEL)) + .and_then(|entry| entry.unit.clone()) + .unwrap_or_default() +} diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index d6421c9..ed4fbc5 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -4,6 +4,7 @@ use neumann_control_core::address::AddressError; use neumann_control_core::catalog::ValueError; +use neumann_control_core::settings::Linkable; use crate::writes::Refusal; @@ -28,6 +29,22 @@ pub const LED_LOCAL: &str = "LOCAL: Schalter hinten, Schreiben gesperrt"; pub const KNOB_DELAY: &str = "Delay"; pub const KNOB_STANDBY: &str = "Standby"; pub const CLOSE: &str = "Schließen"; +pub const ADD_GROUP: &str = "+ Gruppe"; +pub const GROUP_DIALOG_TITLE: &str = "Gruppe"; +pub const GROUP_NAME: &str = "Name"; +pub const DELETE_GROUP: &str = "Gruppe löschen"; +pub const NEW_GROUP_NAME: &str = "Gruppe"; +pub const GROUP: &str = "Gruppe"; +pub const NO_GROUP: &str = "keine"; +pub const LINKED: &str = "Gekoppelt"; +pub const LINK_LEVEL: &str = "Pegel"; +pub const LINK_MUTE: &str = "Stumm"; +pub const LINK_STANDBY: &str = "Standby"; +pub const LINK_INPUT: &str = "Eingang"; +pub const COPY_TO_GROUP: &str = "Für Gruppe übernehmen"; +pub const MASTER: &str = "Master"; +pub const MASTER_LEVEL: &str = "Master-Pegel"; +pub const MUTE_ALL: &str = "Alle stumm"; pub const CONTROL_HINT: &str = "Shift: fein · Doppelklick: Wert eingeben"; pub const PANEL_TITLE: &str = "Lautsprecher"; pub const SETTINGS: &str = "Einstellungen"; @@ -145,10 +162,41 @@ pub fn average_response_time(milliseconds: u128) -> String { format!("Ø {milliseconds} ms") } +/// Names a group's link button for screen readers, apart from the strips' own buttons. +pub fn link_label(name: &str) -> String { + format!("{name} koppeln") +} + +pub fn linkable_name(linkable: Linkable) -> &'static str { + match linkable { + Linkable::Level => LINK_LEVEL, + Linkable::Mute => LINK_MUTE, + Linkable::Standby => LINK_STANDBY, + Linkable::Input => LINK_INPUT, + } +} + +pub fn copy_to_group(setting: &str) -> String { + format!("{COPY_TO_GROUP}: {setting}") +} + +pub fn group_level(group: &str) -> String { + format!("Pegel {group}") +} + +pub fn group_mute(group: &str) -> String { + format!("{group} stumm") +} + pub fn response_time(milliseconds: u128) -> String { format!("{milliseconds} ms") } +/// A group's or the master's level, without unit: it moves several speakers by the same amount. +pub fn shared_readout(value: f64) -> String { + format!("{value:.1}") +} + pub fn readout(value: f64, unit: &str) -> String { format!("{value:.1} {unit}") } diff --git a/crates/app/src/ui_kit/theme.rs b/crates/app/src/ui_kit/theme.rs index cd97844..e10bfe8 100644 --- a/crates/app/src/ui_kit/theme.rs +++ b/crates/app/src/ui_kit/theme.rs @@ -161,6 +161,24 @@ pub fn apply(context: &Context) { } } +/// Group colours, picked in turn for new groups; readable in both themes. +const GROUP_COLORS: [Color32; 6] = [ + Color32::from_rgb(55, 138, 221), + Color32::from_rgb(216, 90, 48), + Color32::from_rgb(29, 158, 117), + Color32::from_rgb(127, 119, 221), + Color32::from_rgb(186, 117, 23), + Color32::from_rgb(212, 83, 126), +]; + +pub fn group_color(index: u8) -> Color32 { + GROUP_COLORS[usize::from(index) % GROUP_COLORS.len()] +} + +pub fn group_color_count() -> u8 { + GROUP_COLORS.len() as u8 +} + /// What the settings dialog's theme choice means for egui. pub fn preference(choice: ThemeChoice) -> ThemePreference { match choice { diff --git a/crates/app/src/views/fader.rs b/crates/app/src/views/fader.rs index 2b39f76..fa5bdf5 100644 --- a/crates/app/src/views/fader.rs +++ b/crates/app/src/views/fader.rs @@ -122,6 +122,64 @@ pub fn show( value } +/// A fader for several speakers at once (group, master): it shows `reference` and returns the +/// value it was let go at; the caller turns the move into one write per speaker. No wheel, so a +/// group move is always one deliberate gesture. +pub fn shared( + ui: &mut Ui, + label: &str, + reference: Option, + range: RangeInclusive, + unit: &str, +) -> Option { + let tokens = Tokens::current(ui.ctx()); + let enabled = reference.is_some(); + let mut value = reference.unwrap_or(*range.start()); + let sense = if enabled { + Sense::drag() + } else { + Sense::hover() + }; + let (rect, response) = ui.allocate_exact_size(vec2(FADER_WIDTH, FADER_HEIGHT), sense); + let drag_id = response.id.with(DRAG_VALUE_ID); + if response.drag_started() { + ui.data_mut(|data| data.insert_temp(drag_id, value)); + } + let aborted = drag_aborted(ui, &response); + let dragging = response.dragged() && !aborted; + if dragging { + let held = ui + .data(|data| data.get_temp::(drag_id)) + .unwrap_or(value); + let fine = ui.input(|input| input.modifiers.shift); + let moved = drag_change(&range, -f64::from(response.drag_delta().y), fine); + let unsnapped = (held + moved).clamp(*range.start(), *range.end()); + ui.data_mut(|data| data.insert_temp(drag_id, unsnapped)); + value = on_level_grid(unsnapped); + } + let mut released = None; + if response.drag_stopped() { + let dragged = ui.data(|data| data.get_temp::(drag_id)); + ui.data_mut(|data| data.remove::(drag_id)); + released = dragged + .filter(|_| !aborted) + .map(on_level_grid) + .filter(|released| Some(*released) != reference); + } + if enabled && response.hovered() { + ui.ctx().set_cursor_icon(CursorIcon::ResizeVertical); + } + paint(ui, rect, &range, value, enabled, &tokens); + response.widget_info(|| WidgetInfo::slider(enabled, value, label)); + let id = response.id; + response.on_hover_text(texts::CONTROL_HINT); + if dragging { + let y = value_y(rect, &range, value); + value_beside(ui, id, rect, y, &texts::readout(value, unit)); + } + released +} + /// The value change for a drag of `points` (upwards positive): the fader's length is the /// whole range. pub fn drag_change(range: &RangeInclusive, points: f64, fine: bool) -> f64 { diff --git a/crates/app/src/views/group_dialog.rs b/crates/app/src/views/group_dialog.rs new file mode 100644 index 0000000..710ae7c --- /dev/null +++ b/crates/app/src/views/group_dialog.rs @@ -0,0 +1,39 @@ +//! Renaming or deleting a group, opened from its strip's name. + +use eframe::egui::{Id, Modal, TextEdit, Ui}; + +use crate::texts; + +const MODAL_ID: &str = "group_dialog"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GroupDialogAction { + None, + /// Closed; the name in the draft is kept. + Close, + Delete, +} + +pub fn show(ui: &mut Ui, name: &mut String) -> GroupDialogAction { + let modal = Modal::new(Id::new(MODAL_ID)).show(ui.ctx(), |ui| contents(ui, name)); + if modal.should_close() { + return GroupDialogAction::Close; + } + modal.inner +} + +fn contents(ui: &mut Ui, name: &mut String) -> GroupDialogAction { + ui.heading(texts::GROUP_DIALOG_TITLE); + let label = ui.label(texts::GROUP_NAME); + ui.add(TextEdit::singleline(name)).labelled_by(label.id); + ui.horizontal(|ui| { + if ui.button(texts::CLOSE).clicked() { + return GroupDialogAction::Close; + } + if ui.button(texts::DELETE_GROUP).clicked() { + return GroupDialogAction::Delete; + } + GroupDialogAction::None + }) + .inner +} diff --git a/crates/app/src/views/group_strip.rs b/crates/app/src/views/group_strip.rs new file mode 100644 index 0000000..d5ce920 --- /dev/null +++ b/crates/app/src/views/group_strip.rs @@ -0,0 +1,114 @@ +//! The strip of a group (name, link buttons, group fader, mute) and the master strip at the end +//! of the desk. Both only report gestures; the app turns them into one write per speaker. + +use std::ops::RangeInclusive; + +use eframe::egui::{ + self, Button, Color32, CornerRadius, RichText, Stroke, Ui, WidgetInfo, WidgetType, +}; +use neumann_control_core::settings::{GroupSettings, Linkable}; + +use super::fader; +use crate::groups::LINKABLE; +use crate::texts; +use crate::ui_kit::theme::Tokens; + +const STRIP_WIDTH: f32 = 96.0; +const STRIP_MARGIN: i8 = 6; +const STRIP_RADIUS: u8 = 6; +const BORDER_WIDTH: f32 = 2.0; + +#[derive(Default)] +pub struct GroupStripResponse { + /// The level the group fader was let go at. + pub level: Option, + /// The mute every member should get. + pub mute: Option, + pub toggled_link: Option, + pub open_settings: bool, +} + +/// `reference` is the loudest connected member, `muted` whether all of them are muted. +pub fn group( + ui: &mut Ui, + group: &GroupSettings, + color: Color32, + reference: Option, + muted: Option, + range: RangeInclusive, + unit: &str, +) -> GroupStripResponse { + let mut response = GroupStripResponse::default(); + frame(ui, color, |ui| { + let name = RichText::new(&group.name).strong().color(color); + response.open_settings = ui.add(Button::new(name).frame(false).truncate()).clicked(); + ui.add(egui::Label::new( + RichText::new(texts::LINKED).small().weak(), + )); + for linkable in LINKABLE { + let text = texts::linkable_name(linkable); + let on = group.linked.contains(&linkable); + let button = ui.add(Button::new(RichText::new(text).small()).selected(on)); + let label = texts::link_label(text); + button.widget_info(|| WidgetInfo::selected(WidgetType::Checkbox, true, on, &label)); + if button.clicked() { + response.toggled_link = Some(linkable); + } + } + let label = texts::group_level(&group.name); + response.level = fader::shared(ui, &label, reference, range, unit); + let readout = reference.map(texts::shared_readout).unwrap_or_default(); + ui.add(egui::Label::new(RichText::new(readout).monospace())); + response.mute = mute_button(ui, &texts::group_mute(&group.name), muted); + }); + response +} + +#[derive(Default)] +pub struct MasterResponse { + pub level: Option, + pub mute: Option, +} + +pub fn master( + ui: &mut Ui, + reference: Option, + muted: Option, + range: RangeInclusive, + unit: &str, +) -> MasterResponse { + let tokens = Tokens::current(ui.ctx()); + let mut response = MasterResponse::default(); + frame(ui, tokens.accent, |ui| { + ui.add(egui::Label::new(RichText::new(texts::MASTER).strong())); + response.level = fader::shared(ui, texts::MASTER_LEVEL, reference, range, unit); + let readout = reference.map(texts::shared_readout).unwrap_or_default(); + ui.add(egui::Label::new(RichText::new(readout).monospace())); + response.mute = mute_button(ui, texts::MUTE_ALL, muted); + }); + response +} + +fn frame(ui: &mut Ui, color: Color32, contents: impl FnOnce(&mut Ui)) { + let tokens = Tokens::current(ui.ctx()); + let inner_width = STRIP_WIDTH - 2.0 * (f32::from(STRIP_MARGIN) + BORDER_WIDTH); + egui::Frame::new() + .fill(tokens.card) + .stroke(Stroke::new(BORDER_WIDTH, color)) + .corner_radius(CornerRadius::same(STRIP_RADIUS)) + .inner_margin(STRIP_MARGIN) + .show(ui, |ui| { + ui.vertical(|ui| { + ui.set_width(inner_width); + contents(ui); + }); + }); +} + +/// Shows whether all are muted; a click asks for the opposite for all. +fn mute_button(ui: &mut Ui, label: &str, muted: Option) -> Option { + let all = muted.unwrap_or(false); + let button = ui.add_enabled(muted.is_some(), Button::new(texts::MUTE).selected(all)); + button.widget_info(|| WidgetInfo::selected(WidgetType::Button, muted.is_some(), all, label)); + button.clicked().then_some(!all) +} diff --git a/crates/app/src/views/mod.rs b/crates/app/src/views/mod.rs index 0fd306f..bf0d287 100644 --- a/crates/app/src/views/mod.rs +++ b/crates/app/src/views/mod.rs @@ -3,6 +3,8 @@ pub mod all_values; mod details; mod details_fields; pub mod fader; +pub mod group_dialog; +pub mod group_strip; mod knob; mod meters; pub mod panel; diff --git a/crates/app/src/views/panel.rs b/crates/app/src/views/panel.rs index 769b3a4..ee2a85f 100644 --- a/crates/app/src/views/panel.rs +++ b/crates/app/src/views/panel.rs @@ -1,9 +1,10 @@ //! The device panel: every setting of one speaker, opened from its strip and floating beside //! it. It closes on Escape, its close button, or a click outside it. -use eframe::egui::{Button, Context, Id, Key, Label, Pos2, RichText, Ui, Window}; +use eframe::egui::{Button, ComboBox, Context, Id, Key, Label, Order, Pos2, RichText, Ui, Window}; use neumann_control_core::catalog::Catalog; use neumann_control_core::mask::mask_serial; +use neumann_control_core::settings::Linkable; use neumann_control_core::speaker::Command; use super::details; @@ -12,6 +13,7 @@ use crate::state::SpeakerState; use crate::texts; const PANEL_ID: &str = "device_panel"; +const GROUP_CHOICE_ID: &str = "group_choice"; const EDITING_ID: &str = "editing"; const PANEL_WIDTH: f32 = 600.0; /// Room kept free below the panel and for its header and footer. @@ -23,6 +25,17 @@ pub struct PanelResponse { pub commands: Vec, pub close: bool, pub remove: bool, + /// A new group for the speaker (`Some(None)`: no group). + pub group: Option>, + /// A linked setting whose value goes to the other members. + pub copy_to_group: Option, +} + +/// The groups the speaker could be in, which one it is in, and what that group links. +pub struct GroupChoice<'a> { + pub names: &'a [String], + pub current: Option, + pub linked: &'a [Linkable], } /// `opened_now` is true in the frame of the click that opened the panel, which must not count @@ -33,9 +46,12 @@ pub fn show( catalog: &Catalog, anchor: Pos2, opened_now: bool, + groups: &GroupChoice, ) -> PanelResponse { let mut close = false; let mut remove = false; + let mut group = None; + let mut copy_to_group = None; // Escape in a field cancels the edit; only Escape with nothing being edited closes. egui // drops the focus at the start of the Escape frame, so the last frame's state decides. let editing_id = Id::new(PANEL_ID).with(EDITING_ID); @@ -56,6 +72,8 @@ pub fn show( .vscroll(true) .show(context, |ui| { close = header(ui, speaker); + group = group_choice(ui, groups); + copy_to_group = copy_buttons(ui, groups); let commands = details::show(ui, speaker, catalog); ui.separator(); remove = ui.button(texts::REMOVE).clicked(); @@ -67,13 +85,20 @@ pub fn show( context.data_mut(|data| data.insert_temp(editing_id, editing_now)); let pressed = context.input(|input| input.pointer.any_pressed()); let position = context.input(|input| input.pointer.interact_pos()); - let outside = position - .is_some_and(|position| context.layer_id_at(position) != Some(window.response.layer_id)); + // Popups the panel opens (a combo box's list) lie on layers above it; a click there + // belongs to the panel. + let outside = position.is_some_and(|position| { + let layer = context.layer_id_at(position); + let on_popup = layer.is_some_and(|layer| layer.order > Order::Middle); + layer != Some(window.response.layer_id) && !on_popup + }); let clicked_outside = pressed && outside; PanelResponse { commands: window.inner.unwrap_or_default(), close: close || escape || (clicked_outside && !opened_now), remove, + group, + copy_to_group, } } @@ -99,3 +124,49 @@ fn header(ui: &mut Ui, speaker: &SpeakerState) -> bool { } closed } + +/// "Gruppe: keine / "; returns a different choice. +fn group_choice(ui: &mut Ui, groups: &GroupChoice) -> Option> { + let shown = groups + .current + .and_then(|index| groups.names.get(index)) + .map_or(texts::NO_GROUP, String::as_str); + let mut chosen = None; + ui.horizontal(|ui| { + let label = ui.label(texts::GROUP); + ComboBox::from_id_salt(GROUP_CHOICE_ID) + .selected_text(shown) + .show_ui(ui, |ui| { + if ui + .selectable_label(groups.current.is_none(), texts::NO_GROUP) + .clicked() + { + chosen = Some(None); + } + for (index, name) in groups.names.iter().enumerate() { + let selected = groups.current == Some(index); + if ui.selectable_label(selected, name).clicked() { + chosen = Some(Some(index)); + } + } + }) + .response + .labelled_by(label.id); + }); + chosen.filter(|choice| *choice != groups.current) +} + +/// One button per setting the speaker's group links. +fn copy_buttons(ui: &mut Ui, groups: &GroupChoice) -> Option { + groups.current?; + let mut copy = None; + ui.horizontal_wrapped(|ui| { + for linkable in groups.linked { + let text = texts::copy_to_group(texts::linkable_name(*linkable)); + if ui.button(text).clicked() { + copy = Some(*linkable); + } + } + }); + copy +} diff --git a/crates/app/src/views/strip.rs b/crates/app/src/views/strip.rs index 050b043..74d7471 100644 --- a/crates/app/src/views/strip.rs +++ b/crates/app/src/views/strip.rs @@ -26,6 +26,7 @@ use crate::writes::{self, Intent, Switch}; pub const STRIP_WIDTH: f32 = 140.0; const STRIP_MARGIN: i8 = 6; const STRIP_RADIUS: u8 = 6; +const GROUP_BORDER_WIDTH: f32 = 2.0; const BORDER_WIDTH: f32 = 1.0; const LED_RADIUS: f32 = 3.5; const LED_SPACING: f32 = 6.0; @@ -49,13 +50,19 @@ pub fn show( speaker: &mut SpeakerState, catalog: &Catalog, desk: &DeskSettings, + group_color: Option, ) -> StripResponse { let tokens = Tokens::current(ui.ctx()); let mut commands = Vec::new(); let inner_width = STRIP_WIDTH - 2.0 * (f32::from(STRIP_MARGIN) + BORDER_WIDTH); let open_panel = egui::Frame::new() .fill(tokens.card) - .stroke(Stroke::new(BORDER_WIDTH, tokens.border)) + // A member of a group is framed in the group's colour. + .stroke( + group_color.map_or(Stroke::new(BORDER_WIDTH, tokens.border), |color| { + Stroke::new(GROUP_BORDER_WIDTH, color) + }), + ) .corner_radius(CornerRadius::same(STRIP_RADIUS)) .inner_margin(STRIP_MARGIN) .show(ui, |ui| { diff --git a/crates/app/src/views/toolbar.rs b/crates/app/src/views/toolbar.rs index 445b6fd..baef897 100644 --- a/crates/app/src/views/toolbar.rs +++ b/crates/app/src/views/toolbar.rs @@ -14,6 +14,7 @@ pub enum ToolbarAction { None, Scan, AddAddress, + AddGroup, Settings, } @@ -45,6 +46,9 @@ fn controls(ui: &mut Ui, facts: &ToolbarFacts) -> ToolbarAction { if ui.button(texts::ADD_ADDRESS).clicked() { action = ToolbarAction::AddAddress; } + if ui.button(texts::ADD_GROUP).clicked() { + action = ToolbarAction::AddGroup; + } ui.separator(); ui.label(texts::speaker_count(facts.speaker_count)); if let Some(notice) = facts.notice { diff --git a/crates/app/tests/groups.rs b/crates/app/tests/groups.rs new file mode 100644 index 0000000..0977294 --- /dev/null +++ b/crates/app/tests/groups.rs @@ -0,0 +1,191 @@ +//! Groups on the desk: group fader, group and master mute, linked settings, the group dialog +//! and choosing a group in the device panel. + +mod support; + +use eframe::egui::{self, accesskit::Role}; +use egui_kittest::kittest::Queryable; +use egui_kittest::Harness; +use neumann_control_app::app::NeumannApp; +use neumann_control_app::texts; +use neumann_control_core::settings::{self, GroupSettings, Linkable, Settings, SETTINGS_FILE}; +use neumann_control_core::simulator::{SimModel, Simulator}; +use serde_json::Value; +use support::*; + +const KH120_ID: &str = "serial:SIM0001234"; +const KH750_ID: &str = "serial:SIM0007500"; +const GROUP_NAME: &str = "Front"; + +/// A KH 120 II and a KH 750 in one group "Front", linking `linked`, both connected. +fn grouped( + linked: Vec, +) -> ( + Simulator, + Simulator, + tempfile::TempDir, + Harness<'static, NeumannApp>, +) { + let kh120 = Simulator::start(SimModel::Kh120Ii); + let kh750 = Simulator::start(SimModel::Kh750); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(SETTINGS_FILE); + let mut first = manual_for(&kh120); + first.serial = Some("SIM0001234".to_owned()); + let mut second = manual_for(&kh750); + second.serial = Some("SIM0007500".to_owned()); + let settings = Settings { + manual: vec![first, second], + groups: vec![GroupSettings { + name: GROUP_NAME.to_owned(), + color: 0, + members: vec![KH120_ID.to_owned(), KH750_ID.to_owned()], + linked, + }], + ..Settings::default() + }; + settings::save(&settings, &path).unwrap(); + let mut harness = harness(path, Vec::new()); + harness.set_size(egui::vec2(1200.0, 900.0)); + wait_until(&mut harness, "both strips connected", |harness| { + harness.query_by_label(KH120_DEVICE_NAME).is_some() + && harness.query_by_label(KH750_DEVICE_NAME).is_some() + && harness.query_all_by_label(&readout(100.0)).count() == 2 + }); + wait_for_initial_read(&mut harness, &kh120); + wait_for_initial_read(&mut harness, &kh750); + (kh120, kh750, directory, harness) +} + +fn sets(simulator: &Simulator, pointer: &str) -> Vec { + simulator + .requests() + .iter() + .filter_map(|request| request.pointer(pointer).filter(|value| !value.is_null())) + .cloned() + .collect() +} + +#[test] +fn group_fader_moves_every_member_by_the_same_amount() { + let (kh120, kh750, _directory, mut harness) = grouped(Vec::new()); + let label = texts::group_level(GROUP_NAME); + let start = harness + .get_by_role_and_label(Role::Slider, &label) + .rect() + .center(); + harness.drag_at(start); + for frame in 1..=4 { + harness.hover_at(start + egui::vec2(0.0, 5.0 * frame as f32)); + harness.step(); + } + harness.drop_at(start + egui::vec2(0.0, 20.0)); + wait_until(&mut harness, "both level sets", |_| { + !sets(&kh120, "/audio/out/level").is_empty() && !sets(&kh750, "/audio/out/level").is_empty() + }); + let first = sets(&kh120, "/audio/out/level"); + let second = sets(&kh750, "/audio/out/level"); + assert_eq!(first.len(), 1, "{first:?}"); + assert_eq!(first, second); + assert!(first[0].as_f64().unwrap() < 100.0); +} + +#[test] +fn linked_mute_reaches_the_other_member() { + let (kh120, kh750, _directory, mut harness) = grouped(vec![Linkable::Mute]); + harness + .get_all_by_label(texts::MUTE) + .next() + .unwrap() + .click(); + wait_until(&mut harness, "both mute sets", |_| { + !sets(&kh120, "/audio/out/mute").is_empty() && !sets(&kh750, "/audio/out/mute").is_empty() + }); +} + +#[test] +fn unlinked_mute_stays_with_its_speaker() { + let (kh120, kh750, _directory, mut harness) = grouped(Vec::new()); + harness + .get_all_by_label(texts::MUTE) + .next() + .unwrap() + .click(); + wait_until(&mut harness, "one mute set", |_| { + !sets(&kh120, "/audio/out/mute").is_empty() || !sets(&kh750, "/audio/out/mute").is_empty() + }); + harness.run_steps(5); + barrier(&kh120); + barrier(&kh750); + let total = sets(&kh120, "/audio/out/mute").len() + sets(&kh750, "/audio/out/mute").len(); + assert_eq!(total, 1); +} + +#[test] +fn group_and_master_mute_switch_all() { + let (kh120, kh750, _directory, mut harness) = grouped(Vec::new()); + harness.get_by_label(&texts::group_mute(GROUP_NAME)).click(); + wait_until(&mut harness, "group mute", |_| { + sets(&kh120, "/audio/out/mute") == [Value::Bool(true)] + && sets(&kh750, "/audio/out/mute") == [Value::Bool(true)] + }); + // All muted now, so the master switches all back on. + harness.get_by_label(texts::MUTE_ALL).click(); + let unmuted = [Value::Bool(true), Value::Bool(false)]; + wait_until(&mut harness, "master unmute", |_| { + sets(&kh120, "/audio/out/mute") == unmuted && sets(&kh750, "/audio/out/mute") == unmuted + }); +} + +#[test] +fn link_buttons_toggle_and_are_saved() { + let (_kh120, _kh750, directory, mut harness) = grouped(Vec::new()); + harness + .get_by_label(&texts::link_label(texts::LINK_LEVEL)) + .click(); + harness.run_steps(2); + let saved = settings::load(&directory.path().join(SETTINGS_FILE)); + assert_eq!(saved.groups[0].linked, vec![Linkable::Level]); +} + +#[test] +fn group_dialog_renames_and_deletes() { + let (_kh120, _kh750, directory, mut harness) = grouped(Vec::new()); + harness.get_by_label(GROUP_NAME).click(); + wait_for_label(&mut harness, texts::GROUP_DIALOG_TITLE); + harness.get_by_role(Role::TextInput).click(); + harness.run_steps(1); + harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + harness.get_by_role(Role::TextInput).type_text("Hinten"); + harness.get_by_label(texts::CLOSE).click(); + harness.run_steps(2); + let path = directory.path().join(SETTINGS_FILE); + assert_eq!(settings::load(&path).groups[0].name, "Hinten"); + harness.get_by_label("Hinten").click(); + wait_for_label(&mut harness, texts::DELETE_GROUP); + harness.get_by_label(texts::DELETE_GROUP).click(); + harness.run_steps(2); + assert!(settings::load(&path).groups.is_empty()); +} + +#[test] +fn add_group_creates_one_and_the_panel_assigns_a_speaker() { + let (simulator, directory, mut harness) = connected_card(SimModel::Kh120Ii); + harness.get_by_label(texts::ADD_GROUP).click(); + wait_for_label(&mut harness, texts::GROUP_DIALOG_TITLE); + harness.get_by_label(texts::CLOSE).click(); + harness.run_steps(2); + let path = directory.path().join(SETTINGS_FILE); + assert_eq!(settings::load(&path).groups.len(), 1); + open_panel(&mut harness, KH120_DEVICE_NAME); + harness + .get_by_role_and_label(Role::ComboBox, texts::GROUP) + .click(); + harness.run_steps(2); + let name = settings::load(&path).groups[0].name.clone(); + harness.get_all_by_label(&name).last().unwrap().click(); + harness.run_steps(2); + let saved = settings::load(&path); + assert_eq!(saved.groups[0].members.len(), 1, "{:?}", saved.groups); + drop(simulator); +} From f5b7fac500cc38738a24b38c1faa34c037027a31 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:12:07 +0200 Subject: [PATCH 087/460] Reorder strips by dragging their grip --- crates/app/src/app/desk.rs | 52 ++++++++++++++++++++++++++++++++--- crates/app/src/texts.rs | 1 + crates/app/src/views/strip.rs | 51 ++++++++++++++++++++++++++++++---- crates/app/tests/groups.rs | 21 ++++++++++++++ 4 files changed, 116 insertions(+), 9 deletions(-) diff --git a/crates/app/src/app/desk.rs b/crates/app/src/app/desk.rs index af936ce..01ecb91 100644 --- a/crates/app/src/app/desk.rs +++ b/crates/app/src/app/desk.rs @@ -2,7 +2,7 @@ //! and what their gestures mean for each speaker: relative moves of a group or the master, and //! a change on a group member sent on to the other members when the group links that setting. -use eframe::egui::{Align, Layout, ScrollArea, Ui}; +use eframe::egui::{Align, Layout, Rect, ScrollArea, Sense, Stroke, Ui}; use neumann_control_core::catalog::Catalog; use neumann_control_core::settings::{GroupSettings, Linkable}; use neumann_control_core::speaker::Command; @@ -11,22 +11,33 @@ use serde_json::Value; use super::{NeumannApp, OpenPanel, DESK_SCROLL_ID}; use crate::fader::output_level_range; use crate::groups::{ - layout, linkable_of, linked_path, member_id, reference_level, shifted_levels, DeskItem, + layout, linkable_of, linked_path, member_id, move_in_order, reference_level, shifted_levels, + DeskItem, }; use crate::paths; use crate::state::{SpeakerState, Status}; use crate::texts; -use crate::ui_kit::theme::{group_color, group_color_count}; +use crate::ui_kit::theme::{group_color, group_color_count, Tokens}; use crate::views::group_strip::{self, GroupStripResponse, MasterResponse}; use crate::views::strip; use crate::writes::Intent; +const DROP_ZONE_ID: &str = "drop_zone"; +const DROP_MARK_WIDTH: f32 = 2.0; +/// The drop mark sits in the gap left of the strip. +const DROP_MARK_OFFSET: f32 = 4.0; + /// What a frame's gestures asked for; applied once the desk is drawn. enum DeskAction { Commands(usize, Vec), Group(usize, GroupStripResponse), Master(MasterResponse), OpenPanel(OpenPanel), + /// A strip dropped on another: `member` goes in front of `before`. + Move { + member: String, + before: String, + }, } /// The group being renamed or deleted, and the name typed so far. @@ -62,9 +73,21 @@ impl NeumannApp { let key = speaker.state.key.clone(); let response = ui .push_id(&key, |ui| { - strip::show(ui, &mut speaker.state, catalog, &desk, color) + let member = &ids[index]; + strip::show( + ui, + &mut speaker.state, + catalog, + &desk, + color, + member, + ) }) .inner; + if let Some(member) = dropped_on(ui, response.rect, index) { + let before = ids[index].clone(); + actions.push(DeskAction::Move { member, before }); + } actions.push(DeskAction::Commands(index, response.commands)); if let Some(anchor) = response.open_panel { let opened_now = true; @@ -139,6 +162,12 @@ impl NeumannApp { self.apply_shared(&everyone, response.level, response.mute, catalog, now); } DeskAction::OpenPanel(open) => self.panel = Some(open), + DeskAction::Move { member, before } => { + if member != before { + move_in_order(&mut self.settings.order, ids, &member, &before); + self.save_settings(); + } + } } } @@ -287,6 +316,21 @@ impl NeumannApp { } } +/// The speaker identity dropped on the strip at `rect`, if one was let go there; while one +/// hovers, a line marks where it would go. +fn dropped_on(ui: &mut Ui, rect: Rect, index: usize) -> Option { + let id = ui.id().with((DROP_ZONE_ID, index)); + let zone = ui.interact(rect, id, Sense::hover()); + if zone.dnd_hover_payload::().is_some() { + let tokens = Tokens::current(ui.ctx()); + let x = rect.left() - DROP_MARK_OFFSET; + let stroke = Stroke::new(DROP_MARK_WIDTH, tokens.accent); + ui.painter().vline(x, rect.y_range(), stroke); + } + zone.dnd_release_payload::() + .map(|member| (*member).clone()) +} + pub(super) fn group_of(groups: &[GroupSettings], id: &str) -> Option { groups .iter() diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index ed4fbc5..109474d 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -30,6 +30,7 @@ pub const KNOB_DELAY: &str = "Delay"; pub const KNOB_STANDBY: &str = "Standby"; pub const CLOSE: &str = "Schließen"; pub const ADD_GROUP: &str = "+ Gruppe"; +pub const MOVE_STRIP: &str = "Verschieben"; pub const GROUP_DIALOG_TITLE: &str = "Gruppe"; pub const GROUP_NAME: &str = "Name"; pub const DELETE_GROUP: &str = "Gruppe löschen"; diff --git a/crates/app/src/views/strip.rs b/crates/app/src/views/strip.rs index 74d7471..be677f2 100644 --- a/crates/app/src/views/strip.rs +++ b/crates/app/src/views/strip.rs @@ -27,6 +27,12 @@ pub const STRIP_WIDTH: f32 = 140.0; const STRIP_MARGIN: i8 = 6; const STRIP_RADIUS: u8 = 6; const GROUP_BORDER_WIDTH: f32 = 2.0; +const GRIP_ID: &str = "grip"; +const GRIP_ROWS: usize = 3; +const GRIP_DOT_SPACING: f32 = 4.0; +const GRIP_DOT_RADIUS: f32 = 1.2; +const GRIP_WIDTH: f32 = 2.0 * GRIP_DOT_SPACING; +const GRIP_HEIGHT: f32 = GRIP_ROWS as f32 * GRIP_DOT_SPACING; const BORDER_WIDTH: f32 = 1.0; const LED_RADIUS: f32 = 3.5; const LED_SPACING: f32 = 6.0; @@ -43,6 +49,8 @@ pub struct StripResponse { pub commands: Vec, /// Where the device panel opens when the name was clicked. pub open_panel: Option, + /// The strip's area, where another strip can be dropped. + pub rect: Rect, } pub fn show( @@ -51,11 +59,12 @@ pub fn show( catalog: &Catalog, desk: &DeskSettings, group_color: Option, + member: &str, ) -> StripResponse { let tokens = Tokens::current(ui.ctx()); let mut commands = Vec::new(); let inner_width = STRIP_WIDTH - 2.0 * (f32::from(STRIP_MARGIN) + BORDER_WIDTH); - let open_panel = egui::Frame::new() + let frame = egui::Frame::new() .fill(tokens.card) // A member of a group is framed in the group's colour. .stroke( @@ -72,7 +81,12 @@ pub fn show( if speaker.status == Status::Unreachable { ui.multiply_opacity(UNREACHABLE_OPACITY); } - let open_panel = name(ui, speaker); + let open_panel = ui + .horizontal(|ui| { + grip(ui, member, &tokens); + name(ui, speaker) + }) + .inner; leds(ui, speaker, &tokens); knobs(ui, speaker, catalog, &mut commands); level(ui, speaker, catalog, desk, &mut commands, &tokens); @@ -84,11 +98,38 @@ pub fn show( open_panel }) .inner - }) - .inner; + }); StripResponse { commands, - open_panel, + open_panel: frame.inner, + rect: frame.response.rect, + } +} + +/// The handle a strip is dragged by to another place on the desk; the payload is the speaker's +/// identity in the saved order. +fn grip(ui: &mut Ui, member: &str, tokens: &Tokens) { + let id = ui.id().with(GRIP_ID); + let response = ui + .dnd_drag_source(id, member.to_owned(), |ui| { + let size = vec2(GRIP_WIDTH, GRIP_HEIGHT); + let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); + for row in 0..GRIP_ROWS { + for column in 0..2 { + let center = rect.left_top() + + vec2( + GRIP_DOT_SPACING * (column as f32 + 0.5), + GRIP_DOT_SPACING * (row as f32 + 0.5), + ); + ui.painter() + .circle_filled(center, GRIP_DOT_RADIUS, tokens.text_dim); + } + } + }) + .response; + response.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, texts::MOVE_STRIP)); + if response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); } } diff --git a/crates/app/tests/groups.rs b/crates/app/tests/groups.rs index 0977294..19edf71 100644 --- a/crates/app/tests/groups.rs +++ b/crates/app/tests/groups.rs @@ -189,3 +189,24 @@ fn add_group_creates_one_and_the_panel_assigns_a_speaker() { assert_eq!(saved.groups[0].members.len(), 1, "{:?}", saved.groups); drop(simulator); } + +#[test] +fn dragging_a_strip_onto_another_puts_it_in_front_and_saves_the_order() { + let (_kh120, _kh750, directory, mut harness) = grouped(Vec::new()); + let handles: Vec = harness + .query_all_by_label(texts::MOVE_STRIP) + .map(|node| node.rect()) + .collect(); + assert_eq!(handles.len(), 2); + let (first, second) = (handles[0], handles[1]); + harness.drag_at(second.center()); + for frame in 1..=6 { + let progress = frame as f32 / 6.0; + harness.hover_at(second.center() + (first.center() - second.center()) * progress); + harness.step(); + } + harness.drop_at(first.center()); + harness.run_steps(2); + let saved = settings::load(&directory.path().join(SETTINGS_FILE)); + assert_eq!(saved.order, vec![KH750_ID.to_owned(), KH120_ID.to_owned()]); +} From acfbf3b8b8546163c66799c24a494beb6c977770 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:13:01 +0200 Subject: [PATCH 088/460] Describe groups, master and reordering --- CHANGELOG.md | 2 ++ README.md | 11 ++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9570429..3aa78fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ First version. - A device panel per speaker with every setting of its model and a list of all values the speaker reports. - A settings dialog for theme, meter range and response times. +- Groups with a group strip (relative fader, mute, linked settings), a master strip, and strips + reordered by dragging. - Support for the KH 120 II and the KH 750 with writes; other models read-only. - Portable settings and log next to the executable. - A speaker simulator for both models used by the automated tests. diff --git a/README.md b/README.md index 485911f..c012603 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,15 @@ knowledge the path catalog is built from and the simulator, not a hardware test. that model (output, input, standby, device, rear panel), grouped in two columns; settings a model may write get a control, the rest are shown greyed. The panel also shows model, firmware and masked serial number. Escape, "Schließen" or a click outside closes it. +- Groups: "+ Gruppe" creates a group; a speaker joins one in its device panel ("Gruppe"). Group + members are framed in the group's colour and followed by a group strip: its fader moves every + member by the same amount (each stays within its own range), "Stumm" mutes them all, and the + buttons under "Gekoppelt" link level, mute, standby or input, so a change on one member goes + to the others whose model may write it. "Für Gruppe übernehmen" in the panel copies a linked + value to the other members. Clicking the group's name renames or deletes it. +- A master strip at the end moves and mutes all connected speakers the same way. +- Drag a strip by the dots before its name onto another strip to put it in front; the order is + remembered. - "Einstellungen" sets the theme (like Windows, light, dark), the lowest level the meters show (-120, -90 or -60 dB) and whether response times are shown; the toolbar shows the average response time of the connected speakers. @@ -93,7 +102,7 @@ The app is portable and writes only next to the executable: | File | Content | |------|---------| -| `settings.json` | Remembered speakers (address, port, serial), the window position and size, and the choices from "Einstellungen" | +| `settings.json` | Remembered speakers (address, port, serial), groups and the strip order, the window position and size, and the choices from "Einstellungen" | | `neumann-control.log` | Log of the current run, replaced at each start | Speaker addresses and serial numbers are masked in the log and in the interface. An unreadable From 9744d19602d35dd2324dda263164f4600d9ac47a Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:25:35 +0200 Subject: [PATCH 089/460] Preview strip reordering while dragging The other strips make room where the dragged one would land, and a slot behind the last strip lets it reach the right end. --- README.md | 5 +- crates/app/src/app/desk.rs | 97 +++++++++++++++++++++------------- crates/app/src/groups.rs | 103 ++++++++++++++++++++++++++++++------- crates/app/tests/groups.rs | 53 +++++++++++++++++++ 4 files changed, 201 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index c012603..ad95e19 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,9 @@ knowledge the path catalog is built from and the simulator, not a hardware test. to the others whose model may write it. "Für Gruppe übernehmen" in the panel copies a linked value to the other members. Clicking the group's name renames or deletes it. - A master strip at the end moves and mutes all connected speakers the same way. -- Drag a strip by the dots before its name onto another strip to put it in front; the order is - remembered. +- Drag a strip by the dots before its name; the other strips of its group (or the ungrouped + ones) make room where it would land, and the order is remembered when you let go. Escape + cancels. - "Einstellungen" sets the theme (like Windows, light, dark), the lowest level the meters show (-120, -90 or -60 dB) and whether response times are shown; the toolbar shows the average response time of the connected speakers. diff --git a/crates/app/src/app/desk.rs b/crates/app/src/app/desk.rs index 01ecb91..1a386c7 100644 --- a/crates/app/src/app/desk.rs +++ b/crates/app/src/app/desk.rs @@ -2,7 +2,9 @@ //! and what their gestures mean for each speaker: relative moves of a group or the master, and //! a change on a group member sent on to the other members when the group links that setting. -use eframe::egui::{Align, Layout, Rect, ScrollArea, Sense, Stroke, Ui}; +use std::collections::HashMap; + +use eframe::egui::{Align, Context, DragAndDrop, Id, Layout, ScrollArea, Ui}; use neumann_control_core::catalog::Catalog; use neumann_control_core::settings::{GroupSettings, Linkable}; use neumann_control_core::speaker::Command; @@ -11,21 +13,21 @@ use serde_json::Value; use super::{NeumannApp, OpenPanel, DESK_SCROLL_ID}; use crate::fader::output_level_range; use crate::groups::{ - layout, linkable_of, linked_path, member_id, move_in_order, reference_level, shifted_levels, - DeskItem, + insertion_slot, layout, linkable_of, linked_path, member_id, order_of, reference_level, + shifted_levels, with_moved, DeskItem, }; use crate::paths; use crate::state::{SpeakerState, Status}; use crate::texts; -use crate::ui_kit::theme::{group_color, group_color_count, Tokens}; +use crate::ui_kit::theme::{group_color, group_color_count}; use crate::views::group_strip::{self, GroupStripResponse, MasterResponse}; use crate::views::strip; use crate::writes::Intent; -const DROP_ZONE_ID: &str = "drop_zone"; -const DROP_MARK_WIDTH: f32 = 2.0; -/// The drop mark sits in the gap left of the strip. -const DROP_MARK_OFFSET: f32 = 4.0; +const STRIP_CENTERS_ID: &str = "strip_centers"; +/// The dragged strip stays where it would land, faded so the strips around it read as the +/// result of the drop. +const DRAGGED_OPACITY: f32 = 0.4; /// What a frame's gestures asked for; applied once the desk is drawn. enum DeskAction { @@ -33,11 +35,6 @@ enum DeskAction { Group(usize, GroupStripResponse), Master(MasterResponse), OpenPanel(OpenPanel), - /// A strip dropped on another: `member` goes in front of `before`. - Move { - member: String, - before: String, - }, } /// The group being renamed or deleted, and the name typed so far. @@ -56,6 +53,13 @@ impl NeumannApp { let desk = self.settings.desk; let ids = self.member_ids(); let items = layout(&self.settings.order, &self.settings.groups, &ids); + let dragged = DragAndDrop::payload::(ui.ctx()) + .and_then(|member| ids.iter().position(|id| *id == *member)); + let items = match dragged { + Some(index) => previewed(ui.ctx(), items, index, &ids, &self.settings.groups), + None => items, + }; + let mut centers = HashMap::new(); let range = output_level_range(); let unit = level_unit(catalog); let mut actions = Vec::new(); @@ -73,6 +77,9 @@ impl NeumannApp { let key = speaker.state.key.clone(); let response = ui .push_id(&key, |ui| { + if dragged == Some(index) { + ui.multiply_opacity(DRAGGED_OPACITY); + } let member = &ids[index]; strip::show( ui, @@ -84,10 +91,7 @@ impl NeumannApp { ) }) .inner; - if let Some(member) = dropped_on(ui, response.rect, index) { - let before = ids[index].clone(); - actions.push(DeskAction::Move { member, before }); - } + centers.insert(ids[index].clone(), response.rect.center().x); actions.push(DeskAction::Commands(index, response.commands)); if let Some(anchor) = response.open_panel { let opened_now = true; @@ -126,6 +130,16 @@ impl NeumannApp { actions.push(DeskAction::Master(response)); }); }); + ui.ctx() + .data_mut(|data| data.insert_temp(Id::new(STRIP_CENTERS_ID), centers)); + let dropped = dragged.is_some() && ui.input(|input| input.pointer.any_released()); + if dropped { + let order = order_of(&items, &ids, &self.settings.order); + if order != self.settings.order { + self.settings.order = order; + self.save_settings(); + } + } let now = ui.input(|input| input.time); for action in actions { self.apply_desk_action(action, catalog, &ids, now); @@ -162,12 +176,6 @@ impl NeumannApp { self.apply_shared(&everyone, response.level, response.mute, catalog, now); } DeskAction::OpenPanel(open) => self.panel = Some(open), - DeskAction::Move { member, before } => { - if member != before { - move_in_order(&mut self.settings.order, ids, &member, &before); - self.save_settings(); - } - } } } @@ -316,19 +324,36 @@ impl NeumannApp { } } -/// The speaker identity dropped on the strip at `rect`, if one was let go there; while one -/// hovers, a line marks where it would go. -fn dropped_on(ui: &mut Ui, rect: Rect, index: usize) -> Option { - let id = ui.id().with((DROP_ZONE_ID, index)); - let zone = ui.interact(rect, id, Sense::hover()); - if zone.dnd_hover_payload::().is_some() { - let tokens = Tokens::current(ui.ctx()); - let x = rect.left() - DROP_MARK_OFFSET; - let stroke = Stroke::new(DROP_MARK_WIDTH, tokens.accent); - ui.painter().vline(x, rect.y_range(), stroke); - } - zone.dnd_release_payload::() - .map(|member| (*member).clone()) +/// The desk as it would look with `dragged` dropped at the pointer: moved among the other +/// speakers of its section (its group, or the ungrouped ones), placed by where their strips were +/// last frame. Before every one of them was drawn once, the desk stays as it is. +fn previewed( + ctx: &Context, + items: Vec, + dragged: usize, + ids: &[String], + groups: &[GroupSettings], +) -> Vec { + let section = group_of(groups, &ids[dragged]); + let others: Vec = items + .iter() + .filter_map(|item| match item { + DeskItem::Speaker(index) if *index != dragged => Some(*index), + _ => None, + }) + .filter(|index| group_of(groups, &ids[*index]) == section) + .collect(); + let centers: HashMap = ctx + .data(|data| data.get_temp(Id::new(STRIP_CENTERS_ID))) + .unwrap_or_default(); + let known: Option> = others + .iter() + .map(|index| centers.get(&ids[*index]).copied()) + .collect(); + let (Some(known), Some(pointer)) = (known, ctx.pointer_interact_pos()) else { + return items; + }; + with_moved(&items, dragged, &others, insertion_slot(&known, pointer.x)) } pub(super) fn group_of(groups: &[GroupSettings], id: &str) -> Option { diff --git a/crates/app/src/groups.rs b/crates/app/src/groups.rs index c60cf7e..3b7706e 100644 --- a/crates/app/src/groups.rs +++ b/crates/app/src/groups.rs @@ -104,20 +104,58 @@ pub fn set_membership(groups: &mut [GroupSettings], member: &str, group: Option< } } -/// Moves `member` in front of `before` in the saved order; speakers the order does not know -/// yet are added first in `speakers` order, so the result covers everyone. -pub fn move_in_order(order: &mut Vec, speakers: &[String], member: &str, before: &str) { - for id in speakers { - if !order.contains(id) { - order.push(id.clone()); - } - } - order.retain(|id| id != member); - let at = order +/// Where a dragged strip would land: the number of other strips of its section whose centre +/// is left of the pointer, so past the last one is a slot of its own. +pub fn insertion_slot(centers: &[f32], pointer_x: f32) -> usize { + centers.iter().filter(|center| **center < pointer_x).count() +} + +/// The desk with `dragged` moved to `slot` among `others`, the other speakers of its section in +/// their current order; strips of other sections keep their places. +pub fn with_moved( + items: &[DeskItem], + dragged: usize, + others: &[usize], + slot: usize, +) -> Vec { + let Some(anchor) = others.get(slot).or(others.last()) else { + return items.to_vec(); + }; + let mut moved: Vec = items + .iter() + .copied() + .filter(|item| *item != DeskItem::Speaker(dragged)) + .collect(); + let anchor_at = moved + .iter() + .position(|item| *item == DeskItem::Speaker(*anchor)) + .unwrap_or(moved.len()); + let at = if slot < others.len() { + anchor_at + } else { + anchor_at + 1 + }; + moved.insert(at, DeskItem::Speaker(dragged)); + moved +} + +/// The saved order after a drop: the speakers as `items` shows them, then the saved ids of +/// speakers not on the desk right now, so they keep their places for when they return. +pub fn order_of(items: &[DeskItem], speakers: &[String], saved: &[String]) -> Vec { + let mut order: Vec = items + .iter() + .filter_map(|item| match item { + DeskItem::Speaker(index) => Some(speakers[*index].clone()), + DeskItem::GroupStrip(_) => None, + }) + .collect(); + let absent: Vec = saved .iter() - .position(|id| id == before) - .unwrap_or(order.len()); - order.insert(at, member.to_owned()); + .filter(|id| !order.contains(id)) + .cloned() + .collect(); + order.extend(absent); + order } /// The level a group or master fader shows: its loudest member. @@ -188,12 +226,39 @@ mod tests { } #[test] - fn moving_puts_the_speaker_before_the_target() { - let mut order = ids(&["a", "b"]); - move_in_order(&mut order, &ids(&["a", "b", "c"]), "c", "a"); - assert_eq!(order, ids(&["c", "a", "b"])); - move_in_order(&mut order, &ids(&["a", "b", "c"]), "c", "b"); - assert_eq!(order, ids(&["a", "c", "b"])); + fn the_slot_is_the_number_of_strips_left_of_the_pointer() { + let centers = [50.0, 150.0, 250.0]; + assert_eq!(insertion_slot(¢ers, 10.0), 0); + assert_eq!(insertion_slot(¢ers, 120.0), 1); + assert_eq!(insertion_slot(¢ers, 400.0), 3, "behind the last strip"); + } + + #[test] + fn a_dragged_strip_moves_within_its_section() { + use DeskItem::*; + let items = [Speaker(0), Speaker(1), Speaker(2), GroupStrip(0)]; + // Speaker 0 dragged among the others of its section (1 and 2). + assert_eq!( + with_moved(&items, 0, &[1, 2], 2), + vec![Speaker(1), Speaker(2), Speaker(0), GroupStrip(0)] + ); + assert_eq!( + with_moved(&items, 2, &[0, 1], 0), + vec![Speaker(2), Speaker(0), Speaker(1), GroupStrip(0)] + ); + assert_eq!(with_moved(&items, 1, &[0, 2], 1), items.to_vec()); + } + + #[test] + fn the_saved_order_follows_the_desk_and_keeps_absent_speakers() { + use DeskItem::*; + let speakers = ids(&["a", "b"]); + let items = [Speaker(1), GroupStrip(0), Speaker(0)]; + let saved = ids(&["gone", "a", "b"]); + assert_eq!( + order_of(&items, &speakers, &saved), + ids(&["b", "a", "gone"]) + ); } #[test] diff --git a/crates/app/tests/groups.rs b/crates/app/tests/groups.rs index 19edf71..730f1b9 100644 --- a/crates/app/tests/groups.rs +++ b/crates/app/tests/groups.rs @@ -210,3 +210,56 @@ fn dragging_a_strip_onto_another_puts_it_in_front_and_saves_the_order() { let saved = settings::load(&directory.path().join(SETTINGS_FILE)); assert_eq!(saved.order, vec![KH750_ID.to_owned(), KH120_ID.to_owned()]); } + +#[test] +fn a_strip_can_be_dragged_behind_the_last_one_and_the_others_make_room_meanwhile() { + let (_kh120, _kh750, directory, mut harness) = grouped(Vec::new()); + let handle = harness + .get_all_by_label(texts::MOVE_STRIP) + .next() + .unwrap() + .rect(); + let group_strip = harness + .get_by_role_and_label(Role::Slider, &texts::group_level(GROUP_NAME)) + .rect(); + // Past the last member, over the group strip: no strip to drop "in front of" there. + let target = egui::pos2(group_strip.center().x, handle.center().y); + harness.drag_at(handle.center()); + for frame in 1..=6 { + let progress = frame as f32 / 6.0; + harness.hover_at(handle.center() + (target - handle.center()) * progress); + harness.step(); + } + let x_of = |harness: &Harness<'static, NeumannApp>, name: &str| { + harness.get_by_label(name).rect().center().x + }; + assert!( + x_of(&harness, KH750_DEVICE_NAME) < x_of(&harness, KH120_DEVICE_NAME), + "the other strip moves aside while dragging" + ); + harness.drop_at(target); + harness.run_steps(2); + let saved = settings::load(&directory.path().join(SETTINGS_FILE)); + assert_eq!(saved.order, vec![KH750_ID.to_owned(), KH120_ID.to_owned()]); +} + +#[test] +fn escape_while_dragging_keeps_the_order() { + let (_kh120, _kh750, directory, mut harness) = grouped(Vec::new()); + let handles: Vec = harness + .query_all_by_label(texts::MOVE_STRIP) + .map(|node| node.rect()) + .collect(); + let (first, second) = (handles[0], handles[1]); + harness.drag_at(second.center()); + for frame in 1..=6 { + let progress = frame as f32 / 6.0; + harness.hover_at(second.center() + (first.center() - second.center()) * progress); + harness.step(); + } + harness.key_press(egui::Key::Escape); + harness.drop_at(first.center()); + harness.run_steps(2); + let saved = settings::load(&directory.path().join(SETTINGS_FILE)); + assert!(saved.order.is_empty(), "{:?}", saved.order); +} From 41aaf57cc5b458b820a989c29d62acec5d97d3b5 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:34:31 +0200 Subject: [PATCH 090/460] Thin the group and master strip borders --- crates/app/src/views/group_strip.rs | 2 +- crates/app/src/views/strip.rs | 10 ++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/crates/app/src/views/group_strip.rs b/crates/app/src/views/group_strip.rs index d5ce920..5edb8f2 100644 --- a/crates/app/src/views/group_strip.rs +++ b/crates/app/src/views/group_strip.rs @@ -16,7 +16,7 @@ use crate::ui_kit::theme::Tokens; const STRIP_WIDTH: f32 = 96.0; const STRIP_MARGIN: i8 = 6; const STRIP_RADIUS: u8 = 6; -const BORDER_WIDTH: f32 = 2.0; +const BORDER_WIDTH: f32 = 1.0; #[derive(Default)] pub struct GroupStripResponse { diff --git a/crates/app/src/views/strip.rs b/crates/app/src/views/strip.rs index be677f2..cd6b5af 100644 --- a/crates/app/src/views/strip.rs +++ b/crates/app/src/views/strip.rs @@ -26,7 +26,6 @@ use crate::writes::{self, Intent, Switch}; pub const STRIP_WIDTH: f32 = 140.0; const STRIP_MARGIN: i8 = 6; const STRIP_RADIUS: u8 = 6; -const GROUP_BORDER_WIDTH: f32 = 2.0; const GRIP_ID: &str = "grip"; const GRIP_ROWS: usize = 3; const GRIP_DOT_SPACING: f32 = 4.0; @@ -67,11 +66,10 @@ pub fn show( let frame = egui::Frame::new() .fill(tokens.card) // A member of a group is framed in the group's colour. - .stroke( - group_color.map_or(Stroke::new(BORDER_WIDTH, tokens.border), |color| { - Stroke::new(GROUP_BORDER_WIDTH, color) - }), - ) + .stroke(Stroke::new( + BORDER_WIDTH, + group_color.unwrap_or(tokens.border), + )) .corner_radius(CornerRadius::same(STRIP_RADIUS)) .inner_margin(STRIP_MARGIN) .show(ui, |ui| { From 7072df5b2368eb1408cfe88cd1e89a56bb9514c3 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:45:59 +0200 Subject: [PATCH 091/460] Add an English UI with an immediate language switch Texts carry both languages side by side, so none can lack a translation; speaker messages are worded when drawn, so they follow a switch too. --- crates/app/src/app.rs | 3 +- crates/app/src/display.rs | 8 +- crates/app/src/input_source.rs | 2 +- crates/app/src/state.rs | 60 ++-- crates/app/src/texts.rs | 432 +++++++++++++++++------- crates/app/src/views/add_address.rs | 2 +- crates/app/src/views/all_values.rs | 10 +- crates/app/src/views/details.rs | 8 +- crates/app/src/views/details_fields.rs | 8 +- crates/app/src/views/group_strip.rs | 6 +- crates/app/src/views/panel.rs | 4 +- crates/app/src/views/settings_dialog.rs | 15 +- crates/app/src/views/strip.rs | 32 +- crates/app/src/views/toolbar.rs | 4 +- crates/app/src/writes.rs | 24 +- crates/app/tests/details.rs | 29 +- crates/app/tests/glyphs.rs | 44 ++- crates/app/tests/groups.rs | 30 +- crates/app/tests/speakers.rs | 104 +++--- crates/app/tests/support/mod.rs | 6 +- crates/app/tests/views.rs | 179 ++++++---- crates/app/tests/write_messages.rs | 11 +- crates/core/src/catalog.rs | 35 +- crates/core/src/paths.toml | 50 +++ crates/core/src/settings.rs | 17 + crates/core/tests/catalog.rs | 15 + crates/core/tests/settings.rs | 15 +- 27 files changed, 758 insertions(+), 395 deletions(-) diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index 93081a7..1557bdd 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -67,7 +67,7 @@ pub struct NeumannApp { found: Vec, scan: Option>, scan_choice: Option, - notice: Option<&'static str>, + notice: Option, add_address: Option, removal: Option, panel: Option, @@ -517,6 +517,7 @@ impl eframe::App for NeumannApp { } fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) { + texts::set_language(self.settings.desk.language); self.show(ui); } diff --git a/crates/app/src/display.rs b/crates/app/src/display.rs index 99a68d2..ce761a8 100644 --- a/crates/app/src/display.rs +++ b/crates/app/src/display.rs @@ -65,8 +65,8 @@ pub fn converted_unit(conversion: Conversion) -> &'static str { fn raw_text(value: &Value) -> String { match value { Value::Null => String::new(), - Value::Bool(true) => texts::ON.to_owned(), - Value::Bool(false) => texts::OFF.to_owned(), + Value::Bool(true) => texts::ON.to_string(), + Value::Bool(false) => texts::OFF.to_string(), Value::String(text) => text.clone(), Value::Array(items) => items .iter() @@ -143,8 +143,8 @@ mod tests { let warnings = json!(["NO_WARNING", "HOT"]); assert_eq!(shown_value(entry("warnings"), &warnings), "NO_WARNING, HOT"); let enabled = entry("device/standby/enabled"); - assert_eq!(shown_value(enabled, &json!(true)), texts::ON); - assert_eq!(shown_value(enabled, &json!(false)), texts::OFF); + assert_eq!(shown_value(enabled, &json!(true)), texts::ON.get()); + assert_eq!(shown_value(enabled, &json!(false)), texts::OFF.get()); } #[test] diff --git a/crates/app/src/input_source.rs b/crates/app/src/input_source.rs index d42d036..06b0e1e 100644 --- a/crates/app/src/input_source.rs +++ b/crates/app/src/input_source.rs @@ -14,7 +14,7 @@ const MONO: &str = "MONO"; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct InputSource { /// Shown under the input meters instead of the generic caption. - pub caption: &'static str, + pub caption: texts::Text, /// Whether channel A and channel B reach the speaker. pub played: [bool; 2], } diff --git a/crates/app/src/state.rs b/crates/app/src/state.rs index fbb30a9..0c622db 100644 --- a/crates/app/src/state.rs +++ b/crates/app/src/state.rs @@ -16,7 +16,7 @@ use crate::display::capped; use crate::gesture::GestureState; use crate::meter::{MeterState, METER_FLOOR_DB}; use crate::paths; -use crate::texts; +use crate::writes::Refusal; /// The KH 750 reports three output channels; a longer list from a faulty device would draw a /// meter per element. @@ -57,6 +57,18 @@ pub enum AllValuesState { Failed(String), } +/// What the strip says about the speaker; worded only when drawn, so it follows the language. +#[derive(Clone, Debug, PartialEq)] +pub enum Message { + Unreachable, + WriteFailed, + /// The device refused a write with this error code. + Rejected(i64), + /// A different device answered at the address; its serial, masked. + SerialMismatch(String), + Refused(Refusal), +} + #[derive(Clone, Debug)] pub struct SpeakerState { pub key: SpeakerKey, @@ -67,7 +79,7 @@ pub struct SpeakerState { pub values: BTreeMap, pub absent: BTreeSet, pub status: Status, - pub message: Option, + pub message: Option, message_source: MessageSource, pub input_meters: Vec, pub output_meters: Vec, @@ -156,10 +168,7 @@ impl SpeakerState { // The masked reason is for the log; the card only shows the generic text. Event::Unreachable(_) => { self.status = Status::Unreachable; - self.set_message( - Some(texts::UNREACHABLE.to_owned()), - MessageSource::Connection, - ); + self.set_message(Some(Message::Unreachable), MessageSource::Connection); self.reset_meters(); self.response_time = None; } @@ -179,17 +188,17 @@ impl SpeakerState { self.absent.insert(path); } Event::WriteRejected { error, .. } => { - let text = texts::describe_rejection(error.code); - self.set_message(Some(text), MessageSource::Write); + let message = Message::Rejected(error.code); + self.set_message(Some(message), MessageSource::Write); } Event::WriteFailed { .. } => { - self.set_message(Some(texts::WRITE_FAILED.to_owned()), MessageSource::Write); + self.set_message(Some(Message::WriteFailed), MessageSource::Write); } Event::SerialMismatch { found, .. } => { self.status = Status::Unreachable; self.response_time = None; - let text = texts::serial_mismatch(&mask_serial(&found)); - self.set_message(Some(text), MessageSource::Connection); + let message = Message::SerialMismatch(mask_serial(&found)); + self.set_message(Some(message), MessageSource::Connection); } Event::AllValues(reports) => self.finish_all_values(AllValuesState::Loaded(reports)), Event::AllValuesFailed(reason) => { @@ -218,7 +227,7 @@ impl SpeakerState { } } - pub(crate) fn set_message(&mut self, message: Option, source: MessageSource) { + pub(crate) fn set_message(&mut self, message: Option, source: MessageSource) { self.message = message; self.message_source = source; } @@ -329,7 +338,7 @@ mod tests { ); speaker.apply(Event::Unreachable("masked".to_owned()), 1.0); assert_eq!(speaker.status, Status::Unreachable); - assert_eq!(speaker.message.as_deref(), Some(texts::UNREACHABLE)); + assert_eq!(speaker.message, Some(Message::Unreachable)); assert_eq!(speaker.values.len(), 1); } @@ -361,10 +370,7 @@ mod tests { }, 0.0, ); - assert_eq!( - speaker.message.as_deref(), - Some("Gerät lehnt ab: nur lesbar") - ); + assert_eq!(speaker.message, Some(Message::Rejected(405))); } #[test] @@ -378,9 +384,11 @@ mod tests { 0.0, ); assert_eq!(speaker.status, Status::Unreachable); - let message = speaker.message.unwrap(); - assert!(message.contains(&mask_serial(OTHER_SERIAL))); - assert!(!message.contains(OTHER_SERIAL)); + let Some(Message::SerialMismatch(masked)) = speaker.message else { + panic!("{:?}", speaker.message); + }; + assert_eq!(masked, mask_serial(OTHER_SERIAL)); + assert_ne!(masked, OTHER_SERIAL); } #[test] @@ -462,10 +470,7 @@ mod tests { ); speaker.apply(value_event("ui/bass_gain", json!(0.0)), 0.1); speaker.apply(value_event("ui/bass_gain", json!(0.0)), 30.0); - assert_eq!( - speaker.message.as_deref(), - Some("Gerät lehnt ab: nur lesbar") - ); + assert_eq!(speaker.message, Some(Message::Rejected(405))); } #[test] @@ -479,7 +484,7 @@ mod tests { 0.0, ); speaker.apply(value_event("audio/out/level", json!(80.0)), 2.0); - assert_eq!(speaker.message.as_deref(), Some(texts::WRITE_FAILED)); + assert_eq!(speaker.message, Some(Message::WriteFailed)); } #[test] @@ -495,10 +500,7 @@ mod tests { }, 0.0, ); - assert_eq!( - speaker.message.as_deref(), - Some("Gerät steht auf LOCAL (Schalter hinten), Schreiben gesperrt") - ); + assert_eq!(speaker.message, Some(Message::Rejected(401))); } #[test] diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 109474d..6d7e750 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -1,126 +1,232 @@ -//! The app's German UI strings. Not here: group titles (`catalog::Group::title`), entry labels -//! and units (`paths.toml`), and the separators that format device values (`all_values`, -//! `display`). +//! The app's UI strings in German and English, side by side so that no text lacks a translation. +//! Not here: group titles (`catalog::Group::title`), entry labels and units (`paths.toml`), and +//! the separators that format device values (`all_values`, `display`). Log texts stay English +//! and never come from here. +use std::cell::Cell; +use std::fmt; + +use eframe::egui::{RichText, WidgetText}; use neumann_control_core::address::AddressError; use neumann_control_core::catalog::ValueError; -use neumann_control_core::settings::Linkable; +use neumann_control_core::settings::{Language, Linkable}; +use crate::state::Message; use crate::writes::Refusal; +thread_local! { + /// UI texts are only read on the UI thread; per thread rather than per process so that UI + /// tests running in parallel each keep their own language. + static LANGUAGE: Cell = const { Cell::new(Language::German) }; +} + +/// The language every text of this thread is read in from now on. +pub fn set_language(language: Language) { + LANGUAGE.with(|current| current.set(language)); +} + +pub fn language() -> Language { + LANGUAGE.with(Cell::get) +} + +fn pick(german: T, english: T) -> T { + match language() { + Language::German => german, + Language::English => english, + } +} + +/// One UI text in both languages. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct Text { + german: &'static str, + english: &'static str, +} + +impl Text { + pub const fn new(german: &'static str, english: &'static str) -> Text { + Text { german, english } + } + + /// The text in the current language. + pub fn get(self) -> &'static str { + pick(self.german, self.english) + } +} + +impl fmt::Display for Text { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.get()) + } +} + +impl From for WidgetText { + fn from(text: Text) -> WidgetText { + text.get().into() + } +} + +impl From for RichText { + fn from(text: Text) -> RichText { + RichText::new(text.get()) + } +} + +impl From for String { + fn from(text: Text) -> String { + text.get().to_owned() + } +} + pub const WINDOW_TITLE: &str = "Neumann Control"; -pub const SCAN: &str = "Suchen"; -pub const ADD_ADDRESS: &str = "+ Adresse"; -pub const SCAN_FAILED: &str = "Suche fehlgeschlagen"; -pub const SETTINGS_NOT_SAVED: &str = "Einstellungen konnten nicht gespeichert werden"; -pub const NO_SPEAKERS: &str = - "Keine Lautsprecher. „Suchen“ findet sie im Netz, „+ Adresse“ fügt einen von Hand hinzu."; - -pub const MUTE: &str = "Stumm"; -pub const IDENTIFY: &str = "Ident."; -pub const CONNECTING: &str = "verbinde …"; -pub const CONNECTED: &str = "verbunden"; -pub const LEVEL: &str = "Pegel"; -pub const CLIP: &str = "Übersteuert"; -pub const LED_CONNECTION: &str = "Verbindung"; -pub const LED_CLIP: &str = "Übersteuerung"; -pub const LED_WARNING: &str = "Warnung des Geräts"; -pub const LED_LOCAL: &str = "LOCAL: Schalter hinten, Schreiben gesperrt"; -pub const KNOB_DELAY: &str = "Delay"; -pub const KNOB_STANDBY: &str = "Standby"; -pub const CLOSE: &str = "Schließen"; -pub const ADD_GROUP: &str = "+ Gruppe"; -pub const MOVE_STRIP: &str = "Verschieben"; -pub const GROUP_DIALOG_TITLE: &str = "Gruppe"; -pub const GROUP_NAME: &str = "Name"; -pub const DELETE_GROUP: &str = "Gruppe löschen"; -pub const NEW_GROUP_NAME: &str = "Gruppe"; -pub const GROUP: &str = "Gruppe"; -pub const NO_GROUP: &str = "keine"; -pub const LINKED: &str = "Gekoppelt"; -pub const LINK_LEVEL: &str = "Pegel"; -pub const LINK_MUTE: &str = "Stumm"; -pub const LINK_STANDBY: &str = "Standby"; -pub const LINK_INPUT: &str = "Eingang"; -pub const COPY_TO_GROUP: &str = "Für Gruppe übernehmen"; -pub const MASTER: &str = "Master"; -pub const MASTER_LEVEL: &str = "Master-Pegel"; -pub const MUTE_ALL: &str = "Alle stumm"; -pub const CONTROL_HINT: &str = "Shift: fein · Doppelklick: Wert eingeben"; -pub const PANEL_TITLE: &str = "Lautsprecher"; -pub const SETTINGS: &str = "Einstellungen"; -pub const SETTINGS_TITLE: &str = "Einstellungen"; -pub const THEME: &str = "Darstellung"; -pub const THEME_SYSTEM: &str = "wie Windows"; -pub const THEME_LIGHT: &str = "Hell"; -pub const THEME_DARK: &str = "Dunkel"; -pub const LANGUAGE: &str = "Sprache"; +pub const SCAN: Text = Text::new("Suchen", "Scan"); +pub const ADD_ADDRESS: Text = Text::new("+ Adresse", "+ Address"); +pub const SCAN_FAILED: Text = Text::new("Suche fehlgeschlagen", "Scan failed"); +pub const SETTINGS_NOT_SAVED: Text = Text::new( + "Einstellungen konnten nicht gespeichert werden", + "Settings could not be saved", +); +pub const NO_SPEAKERS: Text = Text::new( + "Keine Lautsprecher. „Suchen“ findet sie im Netz, „+ Adresse“ fügt einen von Hand hinzu.", + "No speakers. “Scan” finds them on the network, “+ Address” adds one by hand.", +); + +pub const MUTE: Text = Text::new("Stumm", "Mute"); +pub const IDENTIFY: Text = Text::new("Ident.", "Ident."); +pub const CONNECTING: Text = Text::new("verbinde …", "connecting …"); +pub const CONNECTED: Text = Text::new("verbunden", "connected"); +pub const LEVEL: Text = Text::new("Pegel", "Level"); +pub const CLIP: Text = Text::new("Übersteuert", "Clipping"); +pub const LED_CONNECTION: Text = Text::new("Verbindung", "Connection"); +pub const LED_CLIP: Text = Text::new("Übersteuerung", "Clipping"); +pub const LED_WARNING: Text = Text::new("Warnung des Geräts", "Device warning"); +pub const LED_LOCAL: Text = Text::new( + "LOCAL: Schalter hinten, Schreiben gesperrt", + "LOCAL: rear switch, writing locked", +); +pub const KNOB_DELAY: Text = Text::new("Delay", "Delay"); +pub const KNOB_STANDBY: Text = Text::new("Standby", "Standby"); +pub const CLOSE: Text = Text::new("Schließen", "Close"); +pub const ADD_GROUP: Text = Text::new("+ Gruppe", "+ Group"); +pub const MOVE_STRIP: Text = Text::new("Verschieben", "Move"); +pub const GROUP_DIALOG_TITLE: Text = Text::new("Gruppe", "Group"); +pub const GROUP_NAME: Text = Text::new("Name", "Name"); +pub const DELETE_GROUP: Text = Text::new("Gruppe löschen", "Delete group"); +pub const NEW_GROUP_NAME: Text = Text::new("Gruppe", "Group"); +pub const GROUP: Text = Text::new("Gruppe", "Group"); +pub const NO_GROUP: Text = Text::new("keine", "none"); +pub const LINKED: Text = Text::new("Gekoppelt", "Linked"); +pub const LINK_LEVEL: Text = Text::new("Pegel", "Level"); +pub const LINK_MUTE: Text = Text::new("Stumm", "Mute"); +pub const LINK_STANDBY: Text = Text::new("Standby", "Standby"); +pub const LINK_INPUT: Text = Text::new("Eingang", "Input"); +pub const COPY_TO_GROUP: Text = Text::new("Für Gruppe übernehmen", "Apply to group"); +pub const MASTER: Text = Text::new("Master", "Master"); +pub const MASTER_LEVEL: Text = Text::new("Master-Pegel", "Master level"); +pub const MUTE_ALL: Text = Text::new("Alle stumm", "Mute all"); +pub const CONTROL_HINT: Text = Text::new( + "Shift: fein · Doppelklick: Wert eingeben", + "Shift: fine · Double-click: type a value", +); +pub const PANEL_TITLE: Text = Text::new("Lautsprecher", "Speaker"); +pub const SETTINGS: Text = Text::new("Einstellungen", "Settings"); +pub const SETTINGS_TITLE: Text = Text::new("Einstellungen", "Settings"); +pub const THEME: Text = Text::new("Darstellung", "Appearance"); +pub const THEME_SYSTEM: Text = Text::new("wie Windows", "like Windows"); +pub const THEME_LIGHT: Text = Text::new("Hell", "Light"); +pub const THEME_DARK: Text = Text::new("Dunkel", "Dark"); +pub const LANGUAGE_LABEL: Text = Text::new("Sprache", "Language"); +/// Each language is named in itself, so it can be found from either. pub const LANGUAGE_GERMAN: &str = "Deutsch"; -pub const LANGUAGE_ENGLISH: &str = "English (folgt)"; -pub const METER_RANGE: &str = "Pegelanzeige ab"; -pub const SHOW_RESPONSE_TIMES: &str = "Antwortzeiten anzeigen"; -pub const INPUT_METERS: &str = "Eingangspegel"; -pub const OUTPUT_METERS: &str = "Ausgangspegel"; -pub const METER_INPUT_SHORT: &str = "Ein"; -pub const METER_OUTPUT_SHORT: &str = "Aus"; -pub const SOURCE_ANALOG: &str = "Analog"; -pub const SOURCE_AES3_LEFT: &str = "AES3 L"; -pub const SOURCE_AES3_RIGHT: &str = "AES3 R"; -pub const SOURCE_MONO: &str = "Mono (A+B)"; -pub const PLAYED_CHANNEL: &str = "Wird wiedergegeben (Annahme: A = links, B = rechts)"; - -pub const ALL_VALUES: &str = "Alle Werte…"; -pub const APPLY: &str = "Übernehmen"; -pub const ON: &str = "an"; -pub const OFF: &str = "aus"; +pub const LANGUAGE_ENGLISH: &str = "English"; +pub const METER_RANGE: Text = Text::new("Pegelanzeige ab", "Meters from"); +pub const SHOW_RESPONSE_TIMES: Text = Text::new("Antwortzeiten anzeigen", "Show response times"); +pub const INPUT_METERS: Text = Text::new("Eingangspegel", "Input level"); +pub const OUTPUT_METERS: Text = Text::new("Ausgangspegel", "Output level"); +pub const METER_INPUT_SHORT: Text = Text::new("Ein", "In"); +pub const METER_OUTPUT_SHORT: Text = Text::new("Aus", "Out"); +pub const SOURCE_ANALOG: Text = Text::new("Analog", "Analog"); +pub const SOURCE_AES3_LEFT: Text = Text::new("AES3 L", "AES3 L"); +pub const SOURCE_AES3_RIGHT: Text = Text::new("AES3 R", "AES3 R"); +pub const SOURCE_MONO: Text = Text::new("Mono (A+B)", "Mono (A+B)"); +pub const PLAYED_CHANNEL: Text = Text::new( + "Wird wiedergegeben (Annahme: A = links, B = rechts)", + "Played (assumed: A = left, B = right)", +); + +pub const ALL_VALUES: Text = Text::new("Alle Werte…", "All values…"); +pub const APPLY: Text = Text::new("Übernehmen", "Apply"); +pub const ON: Text = Text::new("an", "on"); +pub const OFF: Text = Text::new("aus", "off"); pub const CELSIUS: &str = "°C"; pub const MILLISECONDS: &str = "ms"; -pub const ALL_VALUES_FILTER_LABEL: &str = "Pfad enthält"; -pub const ALL_VALUES_RELOAD: &str = "Neu einlesen"; -pub const ALL_VALUES_LOADING: &str = "Lese alle Werte …"; -pub const ALL_VALUES_FAILED: &str = - "Die Werte konnten nicht gelesen werden. Das Gerät ist nicht erreichbar oder hat die Abfrage abgelehnt."; -pub const ALL_VALUES_PATH_COLUMN: &str = "Pfad"; -pub const ALL_VALUES_VALUE_COLUMN: &str = "Wert"; -pub const ALL_VALUES_RANGE_COLUMN: &str = "Einheit / Bereich"; -pub const ALL_VALUES_KNOWN_COLUMN: &str = "Katalog"; -pub const ALL_VALUES_KNOWN: &str = "bekannt"; -pub const ALL_VALUES_COPY: &str = "Kopieren"; - -pub const ADD_ADDRESS_TITLE: &str = "Lautsprecher per Adresse hinzufügen"; -pub const ADDRESS_FIELD: &str = "IPv6-Adresse"; -pub const ADDRESS_HINT: &str = "z. B. fe80::1%7"; -pub const ADD: &str = "Hinzufügen"; -pub const CANCEL: &str = "Abbrechen"; -pub const ADDRESS_ALREADY_LISTED: &str = "Diese Adresse ist schon eingetragen."; - -pub const SCAN_CHOICE_TITLE: &str = "Gefundene Lautsprecher"; -pub const NOTHING_FOUND: &str = "Keine Lautsprecher gefunden"; -pub const ALREADY_ADDED: &str = "bereits hinzugefügt"; - -pub const REMOVE: &str = "Entfernen"; -pub const REMOVE_TITLE: &str = "Lautsprecher entfernen?"; - -pub const UNREACHABLE: &str = "nicht erreichbar (aus oder Standby)"; -pub const WRITE_FAILED: &str = "Schreiben fehlgeschlagen"; -const SERIAL_MISMATCH_PREFIX: &str = "Anderes Gerät unter dieser Adresse (Seriennummer "; -const SERIAL_MISMATCH_SUFFIX: &str = ")"; +pub const ALL_VALUES_FILTER_LABEL: Text = Text::new("Pfad enthält", "Path contains"); +pub const ALL_VALUES_RELOAD: Text = Text::new("Neu einlesen", "Read again"); +pub const ALL_VALUES_LOADING: Text = Text::new("Lese alle Werte …", "Reading all values …"); +pub const ALL_VALUES_FAILED: Text = Text::new( + "Die Werte konnten nicht gelesen werden. Das Gerät ist nicht erreichbar oder hat die Abfrage abgelehnt.", + "The values could not be read. The device is unreachable or refused the request.", +); +pub const ALL_VALUES_PATH_COLUMN: Text = Text::new("Pfad", "Path"); +pub const ALL_VALUES_VALUE_COLUMN: Text = Text::new("Wert", "Value"); +pub const ALL_VALUES_RANGE_COLUMN: Text = Text::new("Einheit / Bereich", "Unit / range"); +pub const ALL_VALUES_KNOWN_COLUMN: Text = Text::new("Katalog", "Catalog"); +pub const ALL_VALUES_KNOWN: Text = Text::new("bekannt", "known"); +pub const ALL_VALUES_COPY: Text = Text::new("Kopieren", "Copy"); + +pub const ADD_ADDRESS_TITLE: Text = Text::new( + "Lautsprecher per Adresse hinzufügen", + "Add a speaker by address", +); +pub const ADDRESS_FIELD: Text = Text::new("IPv6-Adresse", "IPv6 address"); +pub const ADDRESS_HINT: Text = Text::new("z. B. fe80::1%7", "e.g. fe80::1%7"); +pub const ADD: Text = Text::new("Hinzufügen", "Add"); +pub const CANCEL: Text = Text::new("Abbrechen", "Cancel"); +pub const ADDRESS_ALREADY_LISTED: Text = Text::new( + "Diese Adresse ist schon eingetragen.", + "This address is already listed.", +); + +pub const SCAN_CHOICE_TITLE: Text = Text::new("Gefundene Lautsprecher", "Speakers found"); +pub const NOTHING_FOUND: Text = Text::new("Keine Lautsprecher gefunden", "No speakers found"); +pub const ALREADY_ADDED: Text = Text::new("bereits hinzugefügt", "already added"); + +pub const REMOVE: Text = Text::new("Entfernen", "Remove"); +pub const REMOVE_TITLE: Text = Text::new("Lautsprecher entfernen?", "Remove speaker?"); + +pub const UNREACHABLE: Text = Text::new( + "nicht erreichbar (aus oder Standby)", + "unreachable (off or standby)", +); +pub const WRITE_FAILED: Text = Text::new("Schreiben fehlgeschlagen", "Write failed"); const REJECTED_LOCAL_MODE_CODE: i64 = 401; const REJECTED_READ_ONLY_CODE: i64 = 405; const REJECTED_WRONG_VALUE_CODE: i64 = 406; pub fn speaker_count(count: usize) -> String { - format!("{count} Lautsprecher") + pick( + format!("{count} Lautsprecher"), + match count { + 1 => format!("{count} speaker"), + _ => format!("{count} speakers"), + }, + ) } pub fn all_values_title(name: &str) -> String { - format!("Alle Werte – {name}") + pick( + format!("Alle Werte – {name}"), + format!("All values – {name}"), + ) } pub fn all_values_count(shown: usize, total: usize) -> String { - format!("{shown} von {total} Werten") + pick( + format!("{shown} von {total} Werten"), + format!("{shown} of {total} values"), + ) } pub fn port(port: u16) -> String { @@ -146,7 +252,7 @@ pub fn meter_label(group: &str, channel: &str) -> String { /// The panel's knob beside a number field, named apart from the field. pub fn knob_label(label: &str) -> String { - format!("{label} (Regler)") + pick(format!("{label} (Regler)"), format!("{label} (knob)")) } /// A mark on the meter scale, without unit to fit beside the meters. @@ -165,10 +271,10 @@ pub fn average_response_time(milliseconds: u128) -> String { /// Names a group's link button for screen readers, apart from the strips' own buttons. pub fn link_label(name: &str) -> String { - format!("{name} koppeln") + pick(format!("{name} koppeln"), format!("Link {name}")) } -pub fn linkable_name(linkable: Linkable) -> &'static str { +pub fn linkable_name(linkable: Linkable) -> Text { match linkable { Linkable::Level => LINK_LEVEL, Linkable::Mute => LINK_MUTE, @@ -182,11 +288,11 @@ pub fn copy_to_group(setting: &str) -> String { } pub fn group_level(group: &str) -> String { - format!("Pegel {group}") + pick(format!("Pegel {group}"), format!("Level {group}")) } pub fn group_mute(group: &str) -> String { - format!("{group} stumm") + pick(format!("{group} stumm"), format!("Mute {group}")) } pub fn response_time(milliseconds: u128) -> String { @@ -208,51 +314,119 @@ pub fn status_line(firmware: &str, masked_serial: &str) -> String { pub fn describe_address_error(error: &AddressError) -> String { let text = match error { - AddressError::Ipv4NotSupported => { - "IPv4 geht nicht: die Lautsprecher sind nur über IPv6 erreichbar, z. B. fe80::1%7." - } - AddressError::MissingScope => { - "Bei fe80-Adressen fehlt der Schnittstellenindex nach dem %, z. B. fe80::1%7." + AddressError::Ipv4NotSupported => Text::new( + "IPv4 geht nicht: die Lautsprecher sind nur über IPv6 erreichbar, z. B. fe80::1%7.", + "IPv4 does not work: the speakers are only reachable over IPv6, e.g. fe80::1%7.", + ), + AddressError::MissingScope => Text::new( + "Bei fe80-Adressen fehlt der Schnittstellenindex nach dem %, z. B. fe80::1%7.", + "An fe80 address needs the interface index after the %, e.g. fe80::1%7.", + ), + AddressError::ScopeNotNumeric => Text::new( + "Nach dem % muss der Schnittstellenindex als Zahl stehen.", + "The interface index after the % must be a number.", + ), + AddressError::Invalid => { + Text::new("Keine gültige IPv6-Adresse.", "Not a valid IPv6 address.") } - AddressError::ScopeNotNumeric => "Nach dem % muss der Schnittstellenindex als Zahl stehen.", - AddressError::Invalid => "Keine gültige IPv6-Adresse.", }; - text.to_owned() + text.get().to_owned() +} + +pub fn describe_message(message: &Message) -> String { + match message { + Message::Unreachable => UNREACHABLE.into(), + Message::WriteFailed => WRITE_FAILED.into(), + Message::Rejected(code) => describe_rejection(*code), + Message::SerialMismatch(masked_serial) => serial_mismatch(masked_serial), + Message::Refused(refusal) => describe_refusal(refusal), + } } pub fn serial_mismatch(masked_serial: &str) -> String { - format!("{SERIAL_MISMATCH_PREFIX}{masked_serial}{SERIAL_MISMATCH_SUFFIX}") + pick( + format!("Anderes Gerät unter dieser Adresse (Seriennummer {masked_serial})"), + format!("Another device at this address (serial number {masked_serial})"), + ) } pub fn describe_value_error(error: &ValueError) -> String { match error { - ValueError::OutOfRange { min, max } => format!("Wert außerhalb von {min} bis {max}."), - ValueError::NotAnOption => "Wert ist keine der Auswahlmöglichkeiten.".to_owned(), - ValueError::NotAStep => "Wert passt nicht zur Schrittweite.".to_owned(), - ValueError::TooLong(limit) => format!("Höchstens {limit} Zeichen."), - ValueError::NotPrintableAscii => { - "Nur Buchstaben A–Z, Ziffern und Satzzeichen ohne Umlaute.".to_owned() + ValueError::OutOfRange { min, max } => pick( + format!("Wert außerhalb von {min} bis {max}."), + format!("Value outside {min} to {max}."), + ), + ValueError::NotAnOption => Text::new( + "Wert ist keine der Auswahlmöglichkeiten.", + "Value is not one of the choices.", + ) + .into(), + ValueError::NotAStep => Text::new( + "Wert passt nicht zur Schrittweite.", + "Value does not fit the step size.", + ) + .into(), + ValueError::TooLong(limit) => pick( + format!("Höchstens {limit} Zeichen."), + format!("At most {limit} characters."), + ), + ValueError::NotPrintableAscii => Text::new( + "Nur Buchstaben A–Z, Ziffern und Satzzeichen ohne Umlaute.", + "Only letters A–Z, digits and punctuation, no accented letters.", + ) + .into(), + ValueError::WrongType => { + Text::new("Wert hat den falschen Typ.", "Value has the wrong type.").into() } - ValueError::WrongType => "Wert hat den falschen Typ.".to_owned(), } } pub fn describe_refusal(refusal: &Refusal) -> String { match refusal { - Refusal::NotConnected => "Nicht verbunden".to_owned(), - Refusal::NotWritable => "Für dieses Modell nicht freigegeben".to_owned(), - Refusal::ValueUnknown => "Wert noch unbekannt".to_owned(), + Refusal::NotConnected => Text::new("Nicht verbunden", "Not connected").into(), + Refusal::NotWritable => Text::new( + "Für dieses Modell nicht freigegeben", + "Not enabled for this model", + ) + .into(), + Refusal::ValueUnknown => Text::new("Wert noch unbekannt", "Value not known yet").into(), Refusal::Invalid(error) => describe_value_error(error), } } pub fn describe_rejection(code: i64) -> String { match code { - REJECTED_LOCAL_MODE_CODE => { - "Gerät steht auf LOCAL (Schalter hinten), Schreiben gesperrt".to_owned() + REJECTED_LOCAL_MODE_CODE => Text::new( + "Gerät steht auf LOCAL (Schalter hinten), Schreiben gesperrt", + "Device is set to LOCAL (rear switch), writing locked", + ) + .into(), + REJECTED_READ_ONLY_CODE => { + Text::new("Gerät lehnt ab: nur lesbar", "Device refuses: read only").into() } - REJECTED_READ_ONLY_CODE => "Gerät lehnt ab: nur lesbar".to_owned(), - REJECTED_WRONG_VALUE_CODE => "Gerät lehnt ab: falscher Wert".to_owned(), - other => format!("Gerät lehnt ab (Code {other})"), + REJECTED_WRONG_VALUE_CODE => Text::new( + "Gerät lehnt ab: falscher Wert", + "Device refuses: wrong value", + ) + .into(), + other => pick( + format!("Gerät lehnt ab (Code {other})"), + format!("Device refuses (code {other})"), + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_text_follows_the_language_of_its_thread() { + assert_eq!(MUTE.get(), "Stumm"); + set_language(Language::English); + assert_eq!(MUTE.get(), "Mute"); + assert_eq!(speaker_count(1), "1 speaker"); + set_language(Language::German); + assert_eq!(MUTE.to_string(), "Stumm"); } } diff --git a/crates/app/src/views/add_address.rs b/crates/app/src/views/add_address.rs index 851c697..f099f97 100644 --- a/crates/app/src/views/add_address.rs +++ b/crates/app/src/views/add_address.rs @@ -65,7 +65,7 @@ fn contents(ui: &mut Ui, form: &mut AddAddressForm, listed: &[ManualSpeaker]) -> if let Some(error) = &form.error { let text = match error { FormError::Address(error) => texts::describe_address_error(error), - FormError::AlreadyListed => texts::ADDRESS_ALREADY_LISTED.to_owned(), + FormError::AlreadyListed => texts::ADDRESS_ALREADY_LISTED.to_string(), }; ui.colored_label(ui.visuals().error_fg_color, text); } diff --git a/crates/app/src/views/all_values.rs b/crates/app/src/views/all_values.rs index eb4ce5c..e1ced40 100644 --- a/crates/app/src/views/all_values.rs +++ b/crates/app/src/views/all_values.rs @@ -88,10 +88,10 @@ fn table(ui: &mut Ui, shown: &[Row<'_>]) { fn header(ui: &mut Ui) { ui.horizontal(|ui| { - cell(ui, PATH_WIDTH, texts::ALL_VALUES_PATH_COLUMN); - cell(ui, VALUE_WIDTH, texts::ALL_VALUES_VALUE_COLUMN); - cell(ui, RANGE_WIDTH, texts::ALL_VALUES_RANGE_COLUMN); - cell(ui, KNOWN_WIDTH, texts::ALL_VALUES_KNOWN_COLUMN); + cell(ui, PATH_WIDTH, texts::ALL_VALUES_PATH_COLUMN.get()); + cell(ui, VALUE_WIDTH, texts::ALL_VALUES_VALUE_COLUMN.get()); + cell(ui, RANGE_WIDTH, texts::ALL_VALUES_RANGE_COLUMN.get()); + cell(ui, KNOWN_WIDTH, texts::ALL_VALUES_KNOWN_COLUMN.get()); }); } @@ -101,7 +101,7 @@ fn row_cells(ui: &mut Ui, row: &Row<'_>) { cell(ui, VALUE_WIDTH, &row.value); cell(ui, RANGE_WIDTH, &row.range); let known = if row.known { - texts::ALL_VALUES_KNOWN + texts::ALL_VALUES_KNOWN.get() } else { "" }; diff --git a/crates/app/src/views/details.rs b/crates/app/src/views/details.rs index 8ed9c5d..886e627 100644 --- a/crates/app/src/views/details.rs +++ b/crates/app/src/views/details.rs @@ -48,8 +48,8 @@ fn rows(ui: &mut Ui, speaker: &mut SpeakerState, catalog: &Catalog) -> Vec { ui.horizontal(|ui| { let catalog = fields.catalog; - let label = texts::knob_label(&entry.label); + let label = texts::knob_label(entry.label(texts::language())); let speaker = &mut *fields.speaker; let sent = knob::for_entry( ui, @@ -143,7 +143,7 @@ fn display(ui: &mut Ui, speaker: &SpeakerState, entry: &Entry) { .map(|value| shown_value(entry, value)) .unwrap_or_default(); ui.horizontal_wrapped(|ui| { - ui.weak(&entry.label); + ui.weak(entry.label(texts::language())); ui.weak(shown); }); } diff --git a/crates/app/src/views/details_fields.rs b/crates/app/src/views/details_fields.rs index 65fd4bb..a51b18f 100644 --- a/crates/app/src/views/details_fields.rs +++ b/crates/app/src/views/details_fields.rs @@ -91,7 +91,7 @@ impl Fields<'_> { } let response = ui .horizontal_wrapped(|ui| { - let label = ui.label(&entry.label); + let label = ui.label(entry.label(texts::language())); ui.add_enabled(enabled, drag).labelled_by(label.id) }) .inner; @@ -131,7 +131,7 @@ impl Fields<'_> { .and_then(Value::as_bool); let enabled = self.enabled(entry, current.is_some()); let mut checked = current.unwrap_or(false); - let checkbox = Checkbox::new(&mut checked, &entry.label); + let checkbox = Checkbox::new(&mut checked, entry.label(texts::language())); if ui.add_enabled(enabled, checkbox).clicked() { let path = entry.path.clone(); let value = Value::Bool(checked); @@ -149,7 +149,7 @@ impl Fields<'_> { let enabled = self.enabled(entry, current.is_some()); let current = current.unwrap_or_default(); let mut chosen = None; - let label = ui.label(&entry.label); + let label = ui.label(entry.label(texts::language())); ui.add_enabled_ui(enabled, |ui| { ComboBox::from_id_salt(entry.path.to_string()) .selected_text(¤t) @@ -194,7 +194,7 @@ impl Fields<'_> { if !draft.edited && !focused { draft.text = reported.as_deref().map(capped).unwrap_or_default(); } - let label = ui.label(&entry.label); + let label = ui.label(entry.label(texts::language())); let field = TextEdit::singleline(&mut draft.text) .id(field_id) .desired_width(ui.available_width()); diff --git a/crates/app/src/views/group_strip.rs b/crates/app/src/views/group_strip.rs index 5edb8f2..408424e 100644 --- a/crates/app/src/views/group_strip.rs +++ b/crates/app/src/views/group_strip.rs @@ -49,7 +49,7 @@ pub fn group( let text = texts::linkable_name(linkable); let on = group.linked.contains(&linkable); let button = ui.add(Button::new(RichText::new(text).small()).selected(on)); - let label = texts::link_label(text); + let label = texts::link_label(text.get()); button.widget_info(|| WidgetInfo::selected(WidgetType::Checkbox, true, on, &label)); if button.clicked() { response.toggled_link = Some(linkable); @@ -81,10 +81,10 @@ pub fn master( let mut response = MasterResponse::default(); frame(ui, tokens.accent, |ui| { ui.add(egui::Label::new(RichText::new(texts::MASTER).strong())); - response.level = fader::shared(ui, texts::MASTER_LEVEL, reference, range, unit); + response.level = fader::shared(ui, texts::MASTER_LEVEL.get(), reference, range, unit); let readout = reference.map(texts::shared_readout).unwrap_or_default(); ui.add(egui::Label::new(RichText::new(readout).monospace())); - response.mute = mute_button(ui, texts::MUTE_ALL, muted); + response.mute = mute_button(ui, texts::MUTE_ALL.get(), muted); }); response } diff --git a/crates/app/src/views/panel.rs b/crates/app/src/views/panel.rs index ee2a85f..4705201 100644 --- a/crates/app/src/views/panel.rs +++ b/crates/app/src/views/panel.rs @@ -130,7 +130,7 @@ fn group_choice(ui: &mut Ui, groups: &GroupChoice) -> Option> { let shown = groups .current .and_then(|index| groups.names.get(index)) - .map_or(texts::NO_GROUP, String::as_str); + .map_or(texts::NO_GROUP.get(), String::as_str); let mut chosen = None; ui.horizontal(|ui| { let label = ui.label(texts::GROUP); @@ -162,7 +162,7 @@ fn copy_buttons(ui: &mut Ui, groups: &GroupChoice) -> Option { let mut copy = None; ui.horizontal_wrapped(|ui| { for linkable in groups.linked { - let text = texts::copy_to_group(texts::linkable_name(*linkable)); + let text = texts::copy_to_group(texts::linkable_name(*linkable).get()); if ui.button(text).clicked() { copy = Some(*linkable); } diff --git a/crates/app/src/views/settings_dialog.rs b/crates/app/src/views/settings_dialog.rs index 251cc39..9f626f8 100644 --- a/crates/app/src/views/settings_dialog.rs +++ b/crates/app/src/views/settings_dialog.rs @@ -1,8 +1,8 @@ //! The settings dialog behind the toolbar's "Einstellungen": theme, language, meter range and //! response times. Every change applies at once; the app saves it. -use eframe::egui::{Id, Modal, RadioButton, Ui}; -use neumann_control_core::settings::{DeskSettings, MeterFloor, ThemeChoice}; +use eframe::egui::{Id, Modal, Ui}; +use neumann_control_core::settings::{DeskSettings, Language, MeterFloor, ThemeChoice}; use crate::texts; @@ -32,11 +32,14 @@ fn contents(ui: &mut Ui, desk: &mut DeskSettings) -> SettingsAction { ui.radio_value(&mut desk.theme, ThemeChoice::Light, texts::THEME_LIGHT); ui.radio_value(&mut desk.theme, ThemeChoice::Dark, texts::THEME_DARK); }); - ui.label(texts::LANGUAGE); + ui.label(texts::LANGUAGE_LABEL); ui.horizontal(|ui| { - // English arrives with stage C; German is the only choice until then. - ui.add(RadioButton::new(true, texts::LANGUAGE_GERMAN)); - ui.add_enabled(false, RadioButton::new(false, texts::LANGUAGE_ENGLISH)); + ui.radio_value(&mut desk.language, Language::German, texts::LANGUAGE_GERMAN); + ui.radio_value( + &mut desk.language, + Language::English, + texts::LANGUAGE_ENGLISH, + ); }); ui.label(texts::METER_RANGE); ui.horizontal(|ui| { diff --git a/crates/app/src/views/strip.rs b/crates/app/src/views/strip.rs index cd6b5af..0d70de7 100644 --- a/crates/app/src/views/strip.rs +++ b/crates/app/src/views/strip.rs @@ -147,14 +147,14 @@ fn leds(ui: &mut Ui, speaker: &SpeakerState, tokens: &Tokens) { let lit = |on: bool, color: Color32| if on { color } else { tokens.clip_off }; ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = LED_SPACING; - led(ui, connection, texts::LED_CONNECTION); - led(ui, lit(state.clip, tokens.danger), texts::LED_CLIP); + led(ui, connection, texts::LED_CONNECTION.get()); + led(ui, lit(state.clip, tokens.danger), texts::LED_CLIP.get()); led( ui, lit(state.warning, tokens.connecting), - texts::LED_WARNING, + texts::LED_WARNING.get(), ); - led(ui, lit(state.local, tokens.accent), texts::LED_LOCAL); + led(ui, lit(state.local, tokens.accent), texts::LED_LOCAL.get()); }); } @@ -187,7 +187,7 @@ fn knobs(ui: &mut Ui, speaker: &mut SpeakerState, catalog: &Catalog, commands: & speaker, catalog, entry, - label, + label.get(), true, knob::KNOB_DIAMETER, ); @@ -244,9 +244,9 @@ fn meters(ui: &mut Ui, speaker: &SpeakerState, desk: &DeskSettings, tokens: &Tok let floor = desk.meter_floor.db(); let input = MeterGroup { with_scale: true, - caption, + caption: caption.get(), meters: Meters { - name: texts::INPUT_METERS, + name: texts::INPUT_METERS.get(), channels: texts::INPUT_CHANNELS, levels: &speaker.input_meters, clips: speaker.values.get(&paths::path(paths::INPUT_CLIP)), @@ -260,9 +260,9 @@ fn meters(ui: &mut Ui, speaker: &SpeakerState, desk: &DeskSettings, tokens: &Tok ui.add_space(METER_GROUP_GAP); let output = MeterGroup { with_scale: false, - caption: texts::METER_OUTPUT_SHORT, + caption: texts::METER_OUTPUT_SHORT.get(), meters: Meters { - name: texts::OUTPUT_METERS, + name: texts::OUTPUT_METERS.get(), channels: texts::OUTPUT_CHANNELS, levels: &speaker.output_meters, clips: speaker.values.get(&paths::path(paths::OUTPUT_CLIP)), @@ -324,14 +324,21 @@ fn buttons( commands: &mut Vec, ) { ui.horizontal(|ui| { - toggle(ui, speaker, catalog, commands, Switch::Mute, texts::MUTE); + toggle( + ui, + speaker, + catalog, + commands, + Switch::Mute, + texts::MUTE.get(), + ); toggle( ui, speaker, catalog, commands, Switch::Identify, - texts::IDENTIFY, + texts::IDENTIFY.get(), ); }); } @@ -357,7 +364,8 @@ fn toggle( fn message(ui: &mut Ui, speaker: &SpeakerState, tokens: &Tokens) { if let Some(message) = &speaker.message { - ui.add(Label::new(RichText::new(message).small().color(tokens.danger)).wrap()); + let text = texts::describe_message(message); + ui.add(Label::new(RichText::new(text).small().color(tokens.danger)).wrap()); } } diff --git a/crates/app/src/views/toolbar.rs b/crates/app/src/views/toolbar.rs index baef897..04fa516 100644 --- a/crates/app/src/views/toolbar.rs +++ b/crates/app/src/views/toolbar.rs @@ -18,10 +18,10 @@ pub enum ToolbarAction { Settings, } -pub struct ToolbarFacts<'a> { +pub struct ToolbarFacts { pub scanning: bool, pub speaker_count: usize, - pub notice: Option<&'a str>, + pub notice: Option, /// None while no speaker has measured one or the settings hide it. pub average_response_time: Option, } diff --git a/crates/app/src/writes.rs b/crates/app/src/writes.rs index b563980..b543a10 100644 --- a/crates/app/src/writes.rs +++ b/crates/app/src/writes.rs @@ -9,8 +9,7 @@ use neumann_control_core::ssc::wire::SscPath; use serde_json::Value; use crate::paths; -use crate::state::{MessageSource, SpeakerState, Status}; -use crate::texts; +use crate::state::{Message, MessageSource, SpeakerState, Status}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Switch { @@ -108,8 +107,7 @@ impl SpeakerState { match self.try_request(intent, catalog, now) { Ok(command) => Some(command), Err(refusal) => { - let text = texts::describe_refusal(&refusal); - self.set_message(Some(text), MessageSource::Write); + self.set_message(Some(Message::Refused(refusal)), MessageSource::Write); None } } @@ -206,8 +204,8 @@ mod tests { let mut speaker = speaker("KH 80"); let command = speaker.request(Intent::Level(80.0), Catalog::embedded(), 0.0); assert_eq!(command, None); - let expected = texts::describe_refusal(&Refusal::NotWritable); - assert_eq!(speaker.message.as_deref(), Some(expected.as_str())); + let expected = Message::Refused(Refusal::NotWritable); + assert_eq!(speaker.message, Some(expected)); assert!(!can_write( &speaker, Catalog::embedded(), @@ -224,8 +222,8 @@ mod tests { min: 0.0, max: 120.0, }; - let expected = texts::describe_refusal(&Refusal::Invalid(reason)); - assert_eq!(speaker.message.as_deref(), Some(expected.as_str())); + let expected = Message::Refused(Refusal::Invalid(reason)); + assert_eq!(speaker.message, Some(expected)); } #[test] @@ -271,8 +269,8 @@ mod tests { value: Value::from("Küche"), }; assert_eq!(speaker.request(intent, Catalog::embedded(), 0.0), None); - let expected = texts::describe_refusal(&Refusal::Invalid(ValueError::NotPrintableAscii)); - assert_eq!(speaker.message.as_deref(), Some(expected.as_str())); + let expected = Message::Refused(Refusal::Invalid(ValueError::NotPrintableAscii)); + assert_eq!(speaker.message, Some(expected)); } #[test] @@ -301,7 +299,7 @@ mod tests { assert!(speaker .try_request(intent, Catalog::embedded(), 2.0) .is_err()); - assert_eq!(speaker.message.as_deref(), Some(texts::UNREACHABLE)); + assert_eq!(speaker.message, Some(Message::Unreachable)); } #[test] @@ -349,7 +347,7 @@ mod tests { let mut speaker = speaker("KH 120 II"); let command = speaker.request(Intent::Toggle(Switch::Identify), Catalog::embedded(), 0.0); assert_eq!(command, None); - let expected = texts::describe_refusal(&Refusal::ValueUnknown); - assert_eq!(speaker.message.as_deref(), Some(expected.as_str())); + let expected = Message::Refused(Refusal::ValueUnknown); + assert_eq!(speaker.message, Some(expected)); } } diff --git a/crates/app/tests/details.rs b/crates/app/tests/details.rs index c873cfd..82b1b5f 100644 --- a/crates/app/tests/details.rs +++ b/crates/app/tests/details.rs @@ -91,7 +91,7 @@ fn name_field_shows_refusal() { harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); harness.get_by_role(Role::TextInput).type_text("Küche"); harness.run_steps(1); - harness.get_by_label(texts::APPLY).click(); + harness.get_by_label(texts::APPLY.get()).click(); harness.run_steps(2); barrier(&simulator); let sets = sets_to(&simulator, NAME_PATH); @@ -104,7 +104,7 @@ fn name_field_shows_refusal() { refusal.min.y > field.rect().max.y, "the reason is below the field" ); - let next_row = harness.get_by_label(texts::ALL_VALUES).rect(); + let next_row = harness.get_by_label(texts::ALL_VALUES.get()).rect(); assert!( refusal.max.y < next_row.min.y, "the reason is not the card's message" @@ -270,7 +270,7 @@ fn abandoned_number_edit_is_not_sent_on_reopen() { harness.run_steps(2); harness.key_press(egui::Key::Escape); harness.run_steps(2); - assert!(harness.query_by_label(texts::CLOSE).is_none()); + assert!(harness.query_by_label(texts::CLOSE.get()).is_none()); open_panel(&mut harness, KH120_DEVICE_NAME); harness.run_steps(IDLE_FRAMES); barrier(&simulator); @@ -358,33 +358,33 @@ fn a_name_draft_left_by_tabbing_past_apply_follows_the_device_again() { #[test] fn strip_controls_are_not_repeated_in_the_panel() { let (_simulator, _directory, harness) = expanded_card(SimModel::Kh120Ii); - assert_eq!(harness.query_all_by_label(texts::MUTE).count(), 1); - assert_eq!(harness.query_all_by_label(texts::IDENTIFY).count(), 1); + assert_eq!(harness.query_all_by_label(texts::MUTE.get()).count(), 1); + assert_eq!(harness.query_all_by_label(texts::IDENTIFY.get()).count(), 1); assert!(harness.query_by_label("Identifizieren").is_none()); assert!(harness.query_by_label("Eingangspegel").is_none()); - let faders = harness.query_all_by_role_and_label(Role::Slider, texts::LEVEL); + let faders = harness.query_all_by_role_and_label(Role::Slider, texts::LEVEL.get()); assert_eq!(faders.count(), 1); assert!(harness.query_all_by_role(Role::SpinButton).all(|node| node .accesskit_node() .label() .as_deref() - != Some(texts::LEVEL))); + != Some(texts::LEVEL.get()))); } #[test] fn kh750_output_meters_sit_next_to_the_input_meters() { let (_simulator, _directory, mut harness) = connected_card(SimModel::Kh750); - let output_meter = texts::meter_label(texts::OUTPUT_METERS, "A"); + let output_meter = texts::meter_label(texts::OUTPUT_METERS.get(), "A"); wait_for_label(&mut harness, &output_meter); assert_eq!( harness - .query_all_by_label_contains(texts::OUTPUT_METERS) + .query_all_by_label_contains(texts::OUTPUT_METERS.get()) .count(), 3 ); assert_eq!( harness - .query_all_by_label_contains(texts::INPUT_METERS) + .query_all_by_label_contains(texts::INPUT_METERS.get()) .count(), 2 ); @@ -395,10 +395,13 @@ fn kh750_output_meters_sit_next_to_the_input_meters() { #[test] fn kh120ii_card_has_no_output_meters() { let (_simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); - wait_for_label(&mut harness, &texts::meter_label(texts::INPUT_METERS, "B")); + wait_for_label( + &mut harness, + &texts::meter_label(texts::INPUT_METERS.get(), "B"), + ); assert_eq!( harness - .query_all_by_label_contains(texts::OUTPUT_METERS) + .query_all_by_label_contains(texts::OUTPUT_METERS.get()) .count(), 0 ); @@ -407,7 +410,7 @@ fn kh120ii_card_has_no_output_meters() { #[test] fn all_values_button_reads_the_whole_tree() { let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); - harness.get_by_label(texts::ALL_VALUES).click(); + harness.get_by_label(texts::ALL_VALUES.get()).click(); wait_until(&mut harness, "the schema walk", |_| { simulator .requests() diff --git a/crates/app/tests/glyphs.rs b/crates/app/tests/glyphs.rs index d05ffd4..2e276cf 100644 --- a/crates/app/tests/glyphs.rs +++ b/crates/app/tests/glyphs.rs @@ -6,6 +6,7 @@ use neumann_control_app::ui_kit::fonts; use neumann_control_core::address::AddressError; use neumann_control_core::catalog::{Catalog, ValueError}; use neumann_control_core::mask::mask_serial; +use neumann_control_core::settings::Language; const SIMULATED_SERIAL: &str = "SIM0001234"; @@ -33,7 +34,31 @@ fn all_ui_texts_have_glyphs() { .clear(); let catalog = Catalog::embedded(); let mut texts_to_check = literals_of_texts_module(); - texts_to_check.extend([ + for language in Language::ALL { + texts::set_language(language); + texts_to_check.extend(formatted_texts()); + for entry in &catalog.entries { + texts_to_check.push(entry.group.title(language).to_owned()); + texts_to_check.push(entry.label(language).to_owned()); + texts_to_check.extend(entry.unit.clone()); + } + } + let families = [FontFamily::Proportional, fonts::semibold(12.0).family]; + for family in families { + let font = FontId::new(12.0, family.clone()); + for text in &texts_to_check { + let covered = context.fonts_mut(|fonts| fonts.has_glyphs(&font, text)); + assert!(covered, "{text:?} in {family:?}"); + } + } + assert!(texts_to_check + .iter() + .any(|text| text == texts::NO_SPEAKERS.get())); +} + +/// The texts made by formatting, in the current language. +fn formatted_texts() -> Vec { + let mut texts_to_check = vec![ texts::speaker_count(12), texts::port(45), texts::readout(-12.5, "dB SPL"), @@ -42,7 +67,7 @@ fn all_ui_texts_have_glyphs() { texts::describe_rejection(405), texts::describe_rejection(406), texts::describe_rejection(500), - ]); + ]; for error in [ AddressError::Ipv4NotSupported, AddressError::MissingScope, @@ -61,18 +86,5 @@ fn all_ui_texts_have_glyphs() { ] { texts_to_check.push(texts::describe_value_error(&error)); } - for entry in &catalog.entries { - texts_to_check.push(entry.group.title().to_owned()); - texts_to_check.push(entry.label.clone()); - texts_to_check.extend(entry.unit.clone()); - } - let families = [FontFamily::Proportional, fonts::semibold(12.0).family]; - for family in families { - let font = FontId::new(12.0, family.clone()); - for text in &texts_to_check { - let covered = context.fonts_mut(|fonts| fonts.has_glyphs(&font, text)); - assert!(covered, "{text:?} in {family:?}"); - } - } - assert!(texts_to_check.iter().any(|text| text == texts::NO_SPEAKERS)); + texts_to_check } diff --git a/crates/app/tests/groups.rs b/crates/app/tests/groups.rs index 730f1b9..ac4f4c1 100644 --- a/crates/app/tests/groups.rs +++ b/crates/app/tests/groups.rs @@ -94,7 +94,7 @@ fn group_fader_moves_every_member_by_the_same_amount() { fn linked_mute_reaches_the_other_member() { let (kh120, kh750, _directory, mut harness) = grouped(vec![Linkable::Mute]); harness - .get_all_by_label(texts::MUTE) + .get_all_by_label(texts::MUTE.get()) .next() .unwrap() .click(); @@ -107,7 +107,7 @@ fn linked_mute_reaches_the_other_member() { fn unlinked_mute_stays_with_its_speaker() { let (kh120, kh750, _directory, mut harness) = grouped(Vec::new()); harness - .get_all_by_label(texts::MUTE) + .get_all_by_label(texts::MUTE.get()) .next() .unwrap() .click(); @@ -130,7 +130,7 @@ fn group_and_master_mute_switch_all() { && sets(&kh750, "/audio/out/mute") == [Value::Bool(true)] }); // All muted now, so the master switches all back on. - harness.get_by_label(texts::MUTE_ALL).click(); + harness.get_by_label(texts::MUTE_ALL.get()).click(); let unmuted = [Value::Bool(true), Value::Bool(false)]; wait_until(&mut harness, "master unmute", |_| { sets(&kh120, "/audio/out/mute") == unmuted && sets(&kh750, "/audio/out/mute") == unmuted @@ -141,7 +141,7 @@ fn group_and_master_mute_switch_all() { fn link_buttons_toggle_and_are_saved() { let (_kh120, _kh750, directory, mut harness) = grouped(Vec::new()); harness - .get_by_label(&texts::link_label(texts::LINK_LEVEL)) + .get_by_label(&texts::link_label(texts::LINK_LEVEL.get())) .click(); harness.run_steps(2); let saved = settings::load(&directory.path().join(SETTINGS_FILE)); @@ -152,18 +152,18 @@ fn link_buttons_toggle_and_are_saved() { fn group_dialog_renames_and_deletes() { let (_kh120, _kh750, directory, mut harness) = grouped(Vec::new()); harness.get_by_label(GROUP_NAME).click(); - wait_for_label(&mut harness, texts::GROUP_DIALOG_TITLE); + wait_for_label(&mut harness, texts::GROUP_DIALOG_TITLE.get()); harness.get_by_role(Role::TextInput).click(); harness.run_steps(1); harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); harness.get_by_role(Role::TextInput).type_text("Hinten"); - harness.get_by_label(texts::CLOSE).click(); + harness.get_by_label(texts::CLOSE.get()).click(); harness.run_steps(2); let path = directory.path().join(SETTINGS_FILE); assert_eq!(settings::load(&path).groups[0].name, "Hinten"); harness.get_by_label("Hinten").click(); - wait_for_label(&mut harness, texts::DELETE_GROUP); - harness.get_by_label(texts::DELETE_GROUP).click(); + wait_for_label(&mut harness, texts::DELETE_GROUP.get()); + harness.get_by_label(texts::DELETE_GROUP.get()).click(); harness.run_steps(2); assert!(settings::load(&path).groups.is_empty()); } @@ -171,15 +171,15 @@ fn group_dialog_renames_and_deletes() { #[test] fn add_group_creates_one_and_the_panel_assigns_a_speaker() { let (simulator, directory, mut harness) = connected_card(SimModel::Kh120Ii); - harness.get_by_label(texts::ADD_GROUP).click(); - wait_for_label(&mut harness, texts::GROUP_DIALOG_TITLE); - harness.get_by_label(texts::CLOSE).click(); + harness.get_by_label(texts::ADD_GROUP.get()).click(); + wait_for_label(&mut harness, texts::GROUP_DIALOG_TITLE.get()); + harness.get_by_label(texts::CLOSE.get()).click(); harness.run_steps(2); let path = directory.path().join(SETTINGS_FILE); assert_eq!(settings::load(&path).groups.len(), 1); open_panel(&mut harness, KH120_DEVICE_NAME); harness - .get_by_role_and_label(Role::ComboBox, texts::GROUP) + .get_by_role_and_label(Role::ComboBox, texts::GROUP.get()) .click(); harness.run_steps(2); let name = settings::load(&path).groups[0].name.clone(); @@ -194,7 +194,7 @@ fn add_group_creates_one_and_the_panel_assigns_a_speaker() { fn dragging_a_strip_onto_another_puts_it_in_front_and_saves_the_order() { let (_kh120, _kh750, directory, mut harness) = grouped(Vec::new()); let handles: Vec = harness - .query_all_by_label(texts::MOVE_STRIP) + .query_all_by_label(texts::MOVE_STRIP.get()) .map(|node| node.rect()) .collect(); assert_eq!(handles.len(), 2); @@ -215,7 +215,7 @@ fn dragging_a_strip_onto_another_puts_it_in_front_and_saves_the_order() { fn a_strip_can_be_dragged_behind_the_last_one_and_the_others_make_room_meanwhile() { let (_kh120, _kh750, directory, mut harness) = grouped(Vec::new()); let handle = harness - .get_all_by_label(texts::MOVE_STRIP) + .get_all_by_label(texts::MOVE_STRIP.get()) .next() .unwrap() .rect(); @@ -247,7 +247,7 @@ fn a_strip_can_be_dragged_behind_the_last_one_and_the_others_make_room_meanwhile fn escape_while_dragging_keeps_the_order() { let (_kh120, _kh750, directory, mut harness) = grouped(Vec::new()); let handles: Vec = harness - .query_all_by_label(texts::MOVE_STRIP) + .query_all_by_label(texts::MOVE_STRIP.get()) .map(|node| node.rect()) .collect(); let (first, second) = (handles[0], handles[1]); diff --git a/crates/app/tests/speakers.rs b/crates/app/tests/speakers.rs index 5b43ca6..4fb8919 100644 --- a/crates/app/tests/speakers.rs +++ b/crates/app/tests/speakers.rs @@ -50,9 +50,9 @@ fn scan_shows_choices_without_adding_cards() { let directory = tempfile::tempdir().unwrap(); let path = settings_file(directory.path(), Vec::new()); let mut harness = harness(path.clone(), vec![kh750_found(&simulator)]); - harness.get_by_label(texts::SCAN).click(); + harness.get_by_label(texts::SCAN.get()).click(); wait_for_label(&mut harness, KH750_NAME); - harness.get_by_label(texts::SCAN_CHOICE_TITLE); + harness.get_by_label(texts::SCAN_CHOICE_TITLE.get()); harness.get_by_label("KH 750"); harness.get_by_label(&mask_host(&simulator.address())); harness.run_steps(5); @@ -66,11 +66,13 @@ fn scan_says_when_nothing_was_found() { let directory = tempfile::tempdir().unwrap(); let path = settings_file(directory.path(), Vec::new()); let mut harness = harness(path, Vec::new()); - harness.get_by_label(texts::SCAN).click(); - wait_for_label(&mut harness, texts::NOTHING_FOUND); - harness.get_by_label(texts::CANCEL).click(); + harness.get_by_label(texts::SCAN.get()).click(); + wait_for_label(&mut harness, texts::NOTHING_FOUND.get()); + harness.get_by_label(texts::CANCEL.get()).click(); harness.run_steps(2); - assert!(harness.query_by_label(texts::SCAN_CHOICE_TITLE).is_none()); + assert!(harness + .query_by_label(texts::SCAN_CHOICE_TITLE.get()) + .is_none()); } #[test] @@ -78,13 +80,15 @@ fn a_failed_scan_says_so_in_the_dialog() { let directory = tempfile::tempdir().unwrap(); let path = settings_file(directory.path(), Vec::new()); let mut harness = harness_scanning(path, Err("no network".to_owned())); - harness.get_by_label(texts::SCAN).click(); - wait_for_label(&mut harness, texts::SCAN_FAILED); - harness.get_by_label(texts::SCAN_CHOICE_TITLE); - harness.get_by_label(texts::CANCEL).click(); + harness.get_by_label(texts::SCAN.get()).click(); + wait_for_label(&mut harness, texts::SCAN_FAILED.get()); + harness.get_by_label(texts::SCAN_CHOICE_TITLE.get()); + harness.get_by_label(texts::CANCEL.get()).click(); harness.run_steps(2); - assert!(harness.query_by_label(texts::SCAN_CHOICE_TITLE).is_none()); - assert!(harness.query_by_label(texts::SCAN_FAILED).is_none()); + assert!(harness + .query_by_label(texts::SCAN_CHOICE_TITLE.get()) + .is_none()); + assert!(harness.query_by_label(texts::SCAN_FAILED.get()).is_none()); } #[test] @@ -96,7 +100,9 @@ fn only_checked_speakers_get_cards() { let found = vec![kh120_found(&unchosen), kh750_found(&chosen)]; let mut harness = harness(path.clone(), found); scan_and_add(&mut harness, &[KH750_NAME]); - assert!(harness.query_by_label(texts::SCAN_CHOICE_TITLE).is_none()); + assert!(harness + .query_by_label(texts::SCAN_CHOICE_TITLE.get()) + .is_none()); wait_for_level(&mut harness, 100.0); wait_for_initial_read(&mut harness, &chosen); harness.get_by_label(&texts::speaker_count(1)); @@ -113,13 +119,15 @@ fn cancel_adds_nothing() { let directory = tempfile::tempdir().unwrap(); let path = settings_file(directory.path(), Vec::new()); let mut harness = harness(path.clone(), vec![kh750_found(&simulator)]); - harness.get_by_label(texts::SCAN).click(); + harness.get_by_label(texts::SCAN.get()).click(); wait_for_label(&mut harness, KH750_NAME); harness.get_by_label(KH750_NAME).click(); harness.run_steps(2); - harness.get_by_label(texts::CANCEL).click(); + harness.get_by_label(texts::CANCEL.get()).click(); harness.run_steps(5); - assert!(harness.query_by_label(texts::SCAN_CHOICE_TITLE).is_none()); + assert!(harness + .query_by_label(texts::SCAN_CHOICE_TITLE.get()) + .is_none()); harness.get_by_label(&texts::speaker_count(0)); assert!(settings::load(&path).manual.is_empty()); assert!(simulator.requests().is_empty()); @@ -133,19 +141,19 @@ fn known_speaker_is_marked_and_not_selectable() { let mut harness = harness(path.clone(), vec![kh120_found(&simulator)]); wait_for_level(&mut harness, 100.0); wait_for_initial_read(&mut harness, &simulator); - harness.get_by_label(texts::SCAN).click(); + harness.get_by_label(texts::SCAN.get()).click(); wait_for_label(&mut harness, KH120_NAME); - harness.get_by_label(texts::ALREADY_ADDED); + harness.get_by_label(texts::ALREADY_ADDED.get()); assert!(harness .get_by_label(KH120_NAME) .accesskit_node() .is_disabled()); harness.get_by_label(KH120_NAME).click(); harness.run_steps(2); - harness.get_by_label(texts::ADD).click(); + harness.get_by_label(texts::ADD.get()).click(); harness.run_steps(2); - harness.get_by_label(texts::SCAN_CHOICE_TITLE); - harness.get_by_label(texts::CANCEL).click(); + harness.get_by_label(texts::SCAN_CHOICE_TITLE.get()); + harness.get_by_label(texts::CANCEL.get()).click(); harness.run_steps(2); harness.get_by_label(&texts::speaker_count(1)); assert_eq!(settings::load(&path).manual.len(), 1); @@ -182,9 +190,9 @@ fn rescan_updates_saved_address() { let path = settings_file(directory.path(), vec![stale]); let mut harness = harness(path.clone(), vec![kh120_found(&simulator)]); harness.get_by_label(&texts::speaker_count(1)); - harness.get_by_label(texts::SCAN).click(); + harness.get_by_label(texts::SCAN.get()).click(); // Not the name: the unconnected card shows the announced name as well. - wait_for_label(&mut harness, texts::ALREADY_ADDED); + wait_for_label(&mut harness, texts::ALREADY_ADDED.get()); assert!(saved_at(&path, &simulator)); assert_eq!(settings::load(&path).manual.len(), 1); wait_for_level(&mut harness, 100.0); @@ -196,15 +204,15 @@ fn add_address_refuses_ipv4_with_hint() { let directory = tempfile::tempdir().unwrap(); let path = settings_file(directory.path(), Vec::new()); let mut harness = harness(path.clone(), Vec::new()); - harness.get_by_label(texts::ADD_ADDRESS).click(); + harness.get_by_label(texts::ADD_ADDRESS.get()).click(); harness.run_steps(2); - harness.get_by_label(texts::ADDRESS_FIELD).click(); + harness.get_by_label(texts::ADDRESS_FIELD.get()).click(); harness.run_steps(1); harness - .get_by_label(texts::ADDRESS_FIELD) + .get_by_label(texts::ADDRESS_FIELD.get()) .type_text("192.168.1.20"); harness.run_steps(1); - harness.get_by_label(texts::ADD).click(); + harness.get_by_label(texts::ADD.get()).click(); harness.run_steps(2); harness.get_by_label(&texts::describe_address_error( &AddressError::Ipv4NotSupported, @@ -218,17 +226,19 @@ fn add_address_saves_the_speaker_and_shows_its_card() { let directory = tempfile::tempdir().unwrap(); let path = settings_file(directory.path(), Vec::new()); let mut harness = harness(path.clone(), Vec::new()); - harness.get_by_label(texts::ADD_ADDRESS).click(); + harness.get_by_label(texts::ADD_ADDRESS.get()).click(); harness.run_steps(2); - harness.get_by_label(texts::ADDRESS_FIELD).click(); + harness.get_by_label(texts::ADDRESS_FIELD.get()).click(); harness.run_steps(1); harness - .get_by_label(texts::ADDRESS_FIELD) + .get_by_label(texts::ADDRESS_FIELD.get()) .type_text(LOOPBACK); harness.run_steps(1); - harness.get_by_label(texts::ADD).click(); + harness.get_by_label(texts::ADD.get()).click(); harness.run_steps(2); - assert!(harness.query_by_label(texts::ADD_ADDRESS_TITLE).is_none()); + assert!(harness + .query_by_label(texts::ADD_ADDRESS_TITLE.get()) + .is_none()); let saved = settings::load(&path).manual; assert_eq!(saved.len(), 1); assert_eq!(saved[0].address, LOOPBACK); @@ -250,9 +260,9 @@ fn a_manual_speaker_found_at_a_second_address_stays_one_card() { serials_remembered(&path, 2) }); wait_until(&mut harness, "one card after the first scan", has_one_card); - harness.get_by_label(texts::SCAN).click(); - wait_for_label(&mut harness, texts::ALREADY_ADDED); - harness.get_by_label(texts::CANCEL).click(); + harness.get_by_label(texts::SCAN.get()).click(); + wait_for_label(&mut harness, texts::ALREADY_ADDED.get()); + harness.get_by_label(texts::CANCEL.get()).click(); harness.run_steps(5); wait_until(&mut harness, "one card after the second scan", has_one_card); assert!(serials_remembered(&path, 1)); @@ -284,26 +294,26 @@ fn add_address_names_an_address_already_listed() { }; let path = settings_file(directory.path(), vec![listed]); let mut harness = harness(path.clone(), Vec::new()); - harness.get_by_label(texts::ADD_ADDRESS).click(); + harness.get_by_label(texts::ADD_ADDRESS.get()).click(); harness.run_steps(2); - harness.get_by_label(texts::ADDRESS_FIELD).click(); + harness.get_by_label(texts::ADDRESS_FIELD.get()).click(); harness.run_steps(1); harness - .get_by_label(texts::ADDRESS_FIELD) + .get_by_label(texts::ADDRESS_FIELD.get()) .type_text("[::1]"); harness.run_steps(1); - harness.get_by_label(texts::ADD).click(); + harness.get_by_label(texts::ADD.get()).click(); harness.run_steps(2); - harness.get_by_label(texts::ADDRESS_ALREADY_LISTED); - harness.get_by_label(texts::ADD_ADDRESS_TITLE); + harness.get_by_label(texts::ADDRESS_ALREADY_LISTED.get()); + harness.get_by_label(texts::ADD_ADDRESS_TITLE.get()); assert_eq!(settings::load(&path).manual.len(), 1); } /// Opens the device panel of the only speaker and asks to remove it. fn ask_to_remove(harness: &mut Harness<'static, NeumannApp>) { open_panel(harness, KH120_DEVICE_NAME); - harness.get_by_label(texts::REMOVE).click(); - wait_for_label(harness, texts::REMOVE_TITLE); + harness.get_by_label(texts::REMOVE.get()).click(); + wait_for_label(harness, texts::REMOVE_TITLE.get()); } #[test] @@ -314,12 +324,12 @@ fn remove_needs_confirmation_and_forgets_speaker() { harness.get_by_label(&texts::speaker_count(1)); assert_eq!(settings::load(&path).manual.len(), 1); let confirm = harness - .query_all_by_label(texts::REMOVE) + .query_all_by_label(texts::REMOVE.get()) .last() .expect("the dialog's button"); confirm.click(); harness.run_steps(2); - assert!(harness.query_by_label(texts::REMOVE_TITLE).is_none()); + assert!(harness.query_by_label(texts::REMOVE_TITLE.get()).is_none()); harness.get_by_label(&texts::speaker_count(0)); assert!(settings::load(&path).manual.is_empty()); drop(harness); @@ -333,9 +343,9 @@ fn cancel_remove_keeps_speaker() { let (_simulator, directory, mut harness) = connected_card(SimModel::Kh120Ii); let path = directory.path().join(SETTINGS_FILE); ask_to_remove(&mut harness); - harness.get_by_label(texts::CANCEL).click(); + harness.get_by_label(texts::CANCEL.get()).click(); harness.run_steps(2); - assert!(harness.query_by_label(texts::REMOVE_TITLE).is_none()); + assert!(harness.query_by_label(texts::REMOVE_TITLE.get()).is_none()); harness.get_by_label(&texts::speaker_count(1)); harness.get_by_label(&readout(100.0)); assert_eq!(settings::load(&path).manual.len(), 1); diff --git a/crates/app/tests/support/mod.rs b/crates/app/tests/support/mod.rs index 2510289..d9b50a7 100644 --- a/crates/app/tests/support/mod.rs +++ b/crates/app/tests/support/mod.rs @@ -118,13 +118,13 @@ pub fn wait_for_label(harness: &mut Harness<'static, NeumannApp>, label: &str) { /// Scans, checks the named speakers in the dialog and adds them. pub fn scan_and_add(harness: &mut Harness<'static, NeumannApp>, names: &[&str]) { - harness.get_by_label(texts::SCAN).click(); + harness.get_by_label(texts::SCAN.get()).click(); for name in names { wait_for_label(harness, name); harness.get_by_label(name).click(); harness.run_steps(2); } - harness.get_by_label(texts::ADD).click(); + harness.get_by_label(texts::ADD.get()).click(); harness.run_steps(2); } @@ -230,7 +230,7 @@ pub fn open_panel(harness: &mut Harness<'static, NeumannApp>, name: &str) { harness.run_steps(2); harness.get_by_label(name).click(); wait_until(harness, "the device panel", |harness| { - harness.query_by_label(texts::ALL_VALUES).is_some() + harness.query_by_label(texts::ALL_VALUES.get()).is_some() }); harness.run_steps(1); } diff --git a/crates/app/tests/views.rs b/crates/app/tests/views.rs index c03a214..7d0104b 100644 --- a/crates/app/tests/views.rs +++ b/crates/app/tests/views.rs @@ -34,7 +34,7 @@ fn strip_shows_name_and_level_and_panel_the_masked_serial() { fn fader_drag_sends_once_on_release() { let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); let rect = harness - .get_by_role_and_label(Role::Slider, texts::LEVEL) + .get_by_role_and_label(Role::Slider, texts::LEVEL.get()) .rect(); let start = rect.center(); harness.drag_at(start); @@ -48,7 +48,7 @@ fn fader_drag_sends_once_on_release() { "a Set went out while dragging" ); let shown = harness - .get_by_role_and_label(Role::Slider, texts::LEVEL) + .get_by_role_and_label(Role::Slider, texts::LEVEL.get()) .accesskit_node() .numeric_value() .unwrap(); @@ -68,7 +68,7 @@ fn fader_drag_sends_once_on_release() { fn fader_value_is_shown_at_the_handle_while_dragging() { let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); let start = harness - .get_by_role_and_label(Role::Slider, texts::LEVEL) + .get_by_role_and_label(Role::Slider, texts::LEVEL.get()) .rect() .center(); harness.drag_at(start); @@ -77,7 +77,7 @@ fn fader_value_is_shown_at_the_handle_while_dragging() { harness.step(); } let dragged = harness - .get_by_role_and_label(Role::Slider, texts::LEVEL) + .get_by_role_and_label(Role::Slider, texts::LEVEL.get()) .accesskit_node() .numeric_value() .unwrap(); @@ -94,7 +94,7 @@ fn fader_value_is_shown_at_the_handle_while_dragging() { fn wheel_over_fader_sends_one_set_after_the_wheel_went_quiet() { let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); let center = harness - .get_by_role_and_label(Role::Slider, texts::LEVEL) + .get_by_role_and_label(Role::Slider, texts::LEVEL.get()) .rect() .center(); harness.hover_at(center); @@ -122,7 +122,7 @@ fn wheel_over_fader_sends_one_set_after_the_wheel_went_quiet() { fn escape_during_a_drag_sends_nothing() { let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); let start = harness - .get_by_role_and_label(Role::Slider, texts::LEVEL) + .get_by_role_and_label(Role::Slider, texts::LEVEL.get()) .rect() .center(); harness.drag_at(start); @@ -159,7 +159,7 @@ fn escape_during_a_drag_sends_nothing() { fn a_drag_replaces_wheel_steps_not_yet_sent() { let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); let start = harness - .get_by_role_and_label(Role::Slider, texts::LEVEL) + .get_by_role_and_label(Role::Slider, texts::LEVEL.get()) .rect() .center(); harness.hover_at(start); @@ -188,7 +188,7 @@ fn a_drag_replaces_wheel_steps_not_yet_sent() { #[test] fn mute_button_sends_set() { let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); - harness.get_by_label(texts::MUTE).click(); + harness.get_by_label(texts::MUTE.get()).click(); wait_until(&mut harness, "mute on the device", |_| { simulator.value("audio/out/mute") == Some(Value::Bool(true)) }); @@ -197,7 +197,7 @@ fn mute_button_sends_set() { #[test] fn identify_button_sends_set() { let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); - harness.get_by_label(texts::IDENTIFY).click(); + harness.get_by_label(texts::IDENTIFY.get()).click(); wait_until(&mut harness, "identification on the device", |_| { simulator.value("device/identification/visual") == Some(Value::Bool(true)) }); @@ -207,14 +207,14 @@ fn identify_button_sends_set() { fn panel_opens_from_strip_name_and_closes_on_escape() { let (_simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); open_panel(&mut harness, KH120_DEVICE_NAME); - harness.get_by_label(texts::CLOSE); + harness.get_by_label(texts::CLOSE.get()); harness.key_press(egui::Key::Escape); harness.run_steps(2); - assert!(harness.query_by_label(texts::ALL_VALUES).is_none()); + assert!(harness.query_by_label(texts::ALL_VALUES.get()).is_none()); open_panel(&mut harness, KH120_DEVICE_NAME); - harness.get_by_label(texts::CLOSE).click(); + harness.get_by_label(texts::CLOSE.get()).click(); harness.run_steps(2); - assert!(harness.query_by_label(texts::ALL_VALUES).is_none()); + assert!(harness.query_by_label(texts::ALL_VALUES.get()).is_none()); } #[test] @@ -223,7 +223,7 @@ fn panel_closes_when_its_speaker_goes_away() { open_panel(&mut harness, KH120_DEVICE_NAME); simulator.stop(); wait_until(&mut harness, "the panel to close", |harness| { - harness.query_by_label(texts::ALL_VALUES).is_none() + harness.query_by_label(texts::ALL_VALUES.get()).is_none() }); } @@ -232,18 +232,18 @@ fn unreachable_card_is_disabled() { let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); simulator.stop(); wait_until(&mut harness, "the unreachable text", |harness| { - harness.query_by_label(texts::UNREACHABLE).is_some() + harness.query_by_label(texts::UNREACHABLE.get()).is_some() }); assert!(harness - .get_by_label(texts::MUTE) + .get_by_label(texts::MUTE.get()) .accesskit_node() .is_disabled()); assert!(harness - .get_by_label(texts::IDENTIFY) + .get_by_label(texts::IDENTIFY.get()) .accesskit_node() .is_disabled()); assert!(harness - .get_by_role_and_label(Role::Slider, texts::LEVEL) + .get_by_role_and_label(Role::Slider, texts::LEVEL.get()) .accesskit_node() .is_disabled()); } @@ -274,20 +274,22 @@ fn all_values_window( ) -> (Simulator, tempfile::TempDir, Harness<'static, NeumannApp>) { let (simulator, directory, mut harness) = connected_card(model); open_panel(&mut harness, device_name(model)); - harness.get_by_label(texts::ALL_VALUES).click(); + harness.get_by_label(texts::ALL_VALUES.get()).click(); wait_until(&mut harness, "the loaded table", |harness| { harness - .query_by_label(texts::ALL_VALUES_PATH_COLUMN) + .query_by_label(texts::ALL_VALUES_PATH_COLUMN.get()) .is_some() }); (simulator, directory, harness) } fn filter_all_values(harness: &mut Harness<'static, NeumannApp>, text: &str) { - harness.get_by_label(texts::ALL_VALUES_FILTER_LABEL).click(); + harness + .get_by_label(texts::ALL_VALUES_FILTER_LABEL.get()) + .click(); harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); harness - .get_by_label(texts::ALL_VALUES_FILTER_LABEL) + .get_by_label(texts::ALL_VALUES_FILTER_LABEL.get()) .type_text(text); harness.run_steps(3); } @@ -318,10 +320,12 @@ fn filter_narrows_rows() { fn known_column_marks_catalog_paths() { let (_simulator, _directory, mut harness) = all_values_window(SimModel::Kh120Ii); filter_all_values(&mut harness, STANDBY_ENABLED_PATH); - harness.get_by_label(texts::ALL_VALUES_KNOWN); + harness.get_by_label(texts::ALL_VALUES_KNOWN.get()); filter_all_values(&mut harness, "restore"); harness.get_by_label("device/restore"); - assert!(harness.query_by_label(texts::ALL_VALUES_KNOWN).is_none()); + assert!(harness + .query_by_label(texts::ALL_VALUES_KNOWN.get()) + .is_none()); } #[test] @@ -343,7 +347,7 @@ fn failure_is_shown() { let harness = Harness::new_ui(|ui| { all_values::show(ui.ctx(), &mut speaker, catalog); }); - harness.get_by_label(texts::ALL_VALUES_FAILED); + harness.get_by_label(texts::ALL_VALUES_FAILED.get()); assert!(harness.query_by_label_contains("fe12:3456").is_none()); assert!(harness .query_by_label_contains("connection error") @@ -362,7 +366,7 @@ fn strip_content_stacks_top_down() { let mut harness = harness(path, Vec::new()); wait_until(&mut harness, "both strips connected", |harness| { harness - .query_all_by_role_and_label(Role::Slider, texts::LEVEL) + .query_all_by_role_and_label(Role::Slider, texts::LEVEL.get()) .count() == 2 && harness.query_by_label(KH120_DEVICE_NAME).is_some() @@ -374,11 +378,11 @@ fn strip_content_stacks_top_down() { harness.get_by_label(KH750_DEVICE_NAME).rect(), ]; let mutes: Vec<_> = harness - .query_all_by_label(texts::MUTE) + .query_all_by_label(texts::MUTE.get()) .map(|node| node.rect()) .collect(); let delays: Vec<_> = harness - .query_all_by_role_and_label(Role::Slider, texts::KNOB_DELAY) + .query_all_by_role_and_label(Role::Slider, texts::KNOB_DELAY.get()) .map(|node| node.rect()) .collect(); assert_eq!(mutes.len(), 2); @@ -451,28 +455,28 @@ fn strip_shows_leds_knobs_fader_and_buttons() { texts::LED_WARNING, texts::LED_LOCAL, ] { - harness.get_by_label(label); + harness.get_by_label(label.get()); } - harness.get_by_role_and_label(Role::Slider, texts::KNOB_DELAY); - harness.get_by_role_and_label(Role::Slider, texts::KNOB_STANDBY); - harness.get_by_label(texts::MUTE); - harness.get_by_label(texts::IDENTIFY); + harness.get_by_role_and_label(Role::Slider, texts::KNOB_DELAY.get()); + harness.get_by_role_and_label(Role::Slider, texts::KNOB_STANDBY.get()); + harness.get_by_label(texts::MUTE.get()); + harness.get_by_label(texts::IDENTIFY.get()); harness.get_by_label(&readout(100.0)); } #[test] fn kh750_standby_knob_is_read_only() { let (_simulator, _directory, harness) = connected_card(SimModel::Kh750); - let standby = harness.get_by_role_and_label(Role::Slider, texts::KNOB_STANDBY); + let standby = harness.get_by_role_and_label(Role::Slider, texts::KNOB_STANDBY.get()); assert!(standby.accesskit_node().is_disabled()); - let delay = harness.get_by_role_and_label(Role::Slider, texts::KNOB_DELAY); + let delay = harness.get_by_role_and_label(Role::Slider, texts::KNOB_DELAY.get()); assert!(!delay.accesskit_node().is_disabled()); } #[test] fn knob_drag_writes_once_on_release() { let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); - let end = turn_knob(&mut harness, texts::KNOB_DELAY, 90.0, 150.0); + let end = turn_knob(&mut harness, texts::KNOB_DELAY.get(), 90.0, 150.0); barrier(&simulator); assert!( delay_sets(&simulator).is_empty(), @@ -492,7 +496,7 @@ fn knob_drag_writes_once_on_release() { #[test] fn escape_during_knob_drag_sends_nothing() { let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); - let end = turn_knob(&mut harness, texts::KNOB_DELAY, 90.0, 150.0); + let end = turn_knob(&mut harness, texts::KNOB_DELAY.get(), 90.0, 150.0); harness.key_press(egui::Key::Escape); harness.step(); harness.drop_at(end); @@ -505,7 +509,7 @@ fn escape_during_knob_drag_sends_nothing() { fn knob_wheel_writes_after_idle() { let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); let center = harness - .get_by_role_and_label(Role::Slider, texts::KNOB_DELAY) + .get_by_role_and_label(Role::Slider, texts::KNOB_DELAY.get()) .rect() .center(); harness.hover_at(center); @@ -552,8 +556,8 @@ fn desk_scrolls_when_narrow() { } fn open_settings(harness: &mut Harness<'static, NeumannApp>) { - harness.get_by_label(texts::SETTINGS).click(); - wait_for_label(harness, texts::THEME); + harness.get_by_label(texts::SETTINGS.get()).click(); + wait_for_label(harness, texts::THEME.get()); } #[test] @@ -561,7 +565,7 @@ fn settings_dialog_switches_theme_and_saves_on_change() { let (_simulator, directory, mut harness) = connected_card(SimModel::Kh120Ii); open_settings(&mut harness); // The harness starts dark, so light is the choice that shows an effect. - harness.get_by_label(texts::THEME_LIGHT).click(); + harness.get_by_label(texts::THEME_LIGHT.get()).click(); harness.run_steps(2); let preference = harness.ctx.options(|options| options.theme_preference); assert_eq!(preference, egui::ThemePreference::Light); @@ -573,9 +577,31 @@ fn settings_dialog_switches_theme_and_saves_on_change() { saved.desk.theme, neumann_control_core::settings::ThemeChoice::Light ); - harness.get_by_label(texts::CLOSE).click(); + harness.get_by_label(texts::CLOSE.get()).click(); harness.run_steps(2); - assert!(harness.query_by_label(texts::THEME).is_none()); + assert!(harness.query_by_label(texts::THEME.get()).is_none()); +} + +#[test] +fn english_applies_at_once_and_is_saved() { + let (_simulator, directory, mut harness) = connected_card(SimModel::Kh120Ii); + open_settings(&mut harness); + harness.get_by_label(texts::LANGUAGE_ENGLISH).click(); + harness.run_steps(2); + // Literal English on purpose: `texts` follows the language the app just set. + harness.get_by_label("Appearance"); + harness.get_by_label("Scan"); + harness.get_by_label("Mute"); + harness.get_by_label("Close").click(); + harness.run_steps(2); + let path = directory + .path() + .join(neumann_control_core::settings::SETTINGS_FILE); + let saved = neumann_control_core::settings::load(&path); + assert_eq!( + saved.desk.language, + neumann_control_core::settings::Language::English + ); } #[test] @@ -585,7 +611,9 @@ fn toolbar_shows_average_response_time_until_turned_off() { harness.query_by_label_contains("Ø ").is_some() }); open_settings(&mut harness); - harness.get_by_label(texts::SHOW_RESPONSE_TIMES).click(); + harness + .get_by_label(texts::SHOW_RESPONSE_TIMES.get()) + .click(); harness.run_steps(2); assert!(harness.query_by_label_contains("Ø ").is_none()); } @@ -609,7 +637,7 @@ fn knob_value_is_shown_beside_it_while_dragging() { .collect::>() }; assert_eq!(shown(&harness).len(), 1); - let end = turn_knob(&mut harness, texts::KNOB_DELAY, 90.0, 150.0); + let end = turn_knob(&mut harness, texts::KNOB_DELAY.get(), 90.0, 150.0); // Under the knob and beside it, each on one line. let labels = shown(&harness); assert_eq!(labels.len(), 2); @@ -630,11 +658,11 @@ fn level_change_while_dragging( modifiers: egui::Modifiers, ) -> f64 { let start = harness - .get_by_role_and_label(Role::Slider, texts::LEVEL) + .get_by_role_and_label(Role::Slider, texts::LEVEL.get()) .rect() .center(); let before = harness - .get_by_role_and_label(Role::Slider, texts::LEVEL) + .get_by_role_and_label(Role::Slider, texts::LEVEL.get()) .accesskit_node() .numeric_value() .unwrap(); @@ -646,7 +674,7 @@ fn level_change_while_dragging( harness.step(); } let during = harness - .get_by_role_and_label(Role::Slider, texts::LEVEL) + .get_by_role_and_label(Role::Slider, texts::LEVEL.get()) .accesskit_node() .numeric_value() .unwrap(); @@ -706,7 +734,7 @@ fn double_click_at(harness: &mut Harness<'static, NeumannApp>, position: egui::P fn double_click_on_the_delay_knob_takes_milliseconds() { let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); let position = harness - .get_by_role_and_label(Role::Slider, texts::KNOB_DELAY) + .get_by_role_and_label(Role::Slider, texts::KNOB_DELAY.get()) .rect() .center(); double_click_at(&mut harness, position); @@ -724,12 +752,14 @@ fn double_click_on_the_delay_knob_takes_milliseconds() { #[test] fn meter_groups_are_labelled_input_and_output() { let (_simulator, _directory, harness) = connected_card(SimModel::Kh750); - harness.get_by_label(texts::METER_INPUT_SHORT); - harness.get_by_label(texts::METER_OUTPUT_SHORT); + harness.get_by_label(texts::METER_INPUT_SHORT.get()); + harness.get_by_label(texts::METER_OUTPUT_SHORT.get()); // The KH 120 II names its input by the rear switch instead. let (_other, _other_directory, kh120) = connected_card(SimModel::Kh120Ii); - kh120.get_by_label(texts::SOURCE_ANALOG); - assert!(kh120.query_by_label(texts::METER_OUTPUT_SHORT).is_none()); + kh120.get_by_label(texts::SOURCE_ANALOG.get()); + assert!(kh120 + .query_by_label(texts::METER_OUTPUT_SHORT.get()) + .is_none()); } #[test] @@ -739,12 +769,14 @@ fn meter_captions_are_centred_under_their_meters() { (texts::METER_INPUT_SHORT, texts::INPUT_METERS, "B"), (texts::METER_OUTPUT_SHORT, texts::OUTPUT_METERS, "Sub"), ] { - let first = harness.get_by_label(&texts::meter_label(group, "A")).rect(); + let first = harness + .get_by_label(&texts::meter_label(group.get(), "A")) + .rect(); let last = harness - .get_by_label(&texts::meter_label(group, last)) + .get_by_label(&texts::meter_label(group.get(), last)) .rect(); let middle = (first.left() + last.right()) / 2.0; - let centre = harness.get_by_label(caption).rect().center().x; + let centre = harness.get_by_label(caption.get()).rect().center().x; assert!( (centre - middle).abs() < 2.0, "{caption}: {centre} vs {middle}" @@ -763,7 +795,7 @@ fn meters_name_their_channels_as_the_speaker_does() { (texts::OUTPUT_METERS, "B"), (texts::OUTPUT_METERS, "Sub"), ] { - harness.get_by_label(&texts::meter_label(group, channel)); + harness.get_by_label(&texts::meter_label(group.get(), channel)); } assert_eq!(harness.query_all_by_label("Sub").count(), 1); } @@ -772,10 +804,10 @@ fn meters_name_their_channels_as_the_speaker_does() { fn fader_and_meter_groups_line_up_at_the_top() { let (_simulator, _directory, harness) = connected_card(SimModel::Kh750); let top = |label: String| harness.get_by_label(&label).rect().top(); - let input = top(texts::meter_label(texts::INPUT_METERS, "A")); - let output = top(texts::meter_label(texts::OUTPUT_METERS, "A")); + let input = top(texts::meter_label(texts::INPUT_METERS.get(), "A")); + let output = top(texts::meter_label(texts::OUTPUT_METERS.get(), "A")); let fader = harness - .get_by_role_and_label(Role::Slider, texts::LEVEL) + .get_by_role_and_label(Role::Slider, texts::LEVEL.get()) .rect() .top(); assert!( @@ -801,8 +833,13 @@ fn channel_names_do_not_touch() { #[test] fn played_input_channels_are_marked_with_the_source() { let (simulator, _directory, analog) = connected_card(SimModel::Kh120Ii); - analog.get_by_label(texts::SOURCE_ANALOG); - assert_eq!(analog.query_all_by_label(texts::PLAYED_CHANNEL).count(), 2); + analog.get_by_label(texts::SOURCE_ANALOG.get()); + assert_eq!( + analog + .query_all_by_label(texts::PLAYED_CHANNEL.get()) + .count(), + 2 + ); drop((simulator, analog)); // The switch is polled every 30 s, so the speaker reports AES3 L from the start. let left = Simulator::start(SimModel::Kh120Ii); @@ -812,17 +849,25 @@ fn played_input_channels_are_marked_with_the_source() { let mut aes3 = harness(path, Vec::new()); wait_for_level(&mut aes3, 100.0); wait_for_initial_read(&mut aes3, &left); - aes3.get_by_label(texts::SOURCE_AES3_LEFT); - assert_eq!(aes3.query_all_by_label(texts::PLAYED_CHANNEL).count(), 1); + aes3.get_by_label(texts::SOURCE_AES3_LEFT.get()); + assert_eq!( + aes3.query_all_by_label(texts::PLAYED_CHANNEL.get()).count(), + 1 + ); let (_other, _other_directory, kh750) = connected_card(SimModel::Kh750); - kh750.get_by_label(texts::METER_INPUT_SHORT); - assert_eq!(kh750.query_all_by_label(texts::PLAYED_CHANNEL).count(), 0); + kh750.get_by_label(texts::METER_INPUT_SHORT.get()); + assert_eq!( + kh750 + .query_all_by_label(texts::PLAYED_CHANNEL.get()) + .count(), + 0 + ); } #[test] fn input_source_caption_stays_on_one_line() { let (_simulator, _directory, harness) = connected_card(SimModel::Kh120Ii); - let caption = harness.get_by_label(texts::SOURCE_ANALOG).rect(); + let caption = harness.get_by_label(texts::SOURCE_ANALOG.get()).rect(); assert!(caption.width() > caption.height(), "{caption:?}"); let level = harness.get_by_label(&readout(100.0)).rect(); assert!( diff --git a/crates/app/tests/write_messages.rs b/crates/app/tests/write_messages.rs index 1449845..1098aaf 100644 --- a/crates/app/tests/write_messages.rs +++ b/crates/app/tests/write_messages.rs @@ -5,7 +5,7 @@ use std::sync::mpsc::RecvTimeoutError; use std::sync::Arc; use std::time::{Duration, Instant}; -use neumann_control_app::state::{SpeakerKey, SpeakerState, Status}; +use neumann_control_app::state::{Message, SpeakerKey, SpeakerState, Status}; use neumann_control_app::writes::Intent; use neumann_control_core::catalog::Catalog; use neumann_control_core::simulator::{SimModel, Simulator}; @@ -18,8 +18,9 @@ const EVENT_DEADLINE: Duration = Duration::from_secs(5); const QUIET_PERIOD: Duration = Duration::from_millis(300); /// Polls far apart, so only the reply to the write and its re-read follow it. const LONG_INTERVAL: Duration = Duration::from_secs(60); -const READ_ONLY_TEXT: &str = "Gerät lehnt ab: nur lesbar"; -const LOCAL_MODE_TEXT: &str = "Gerät steht auf LOCAL (Schalter hinten), Schreiben gesperrt"; +/// The error codes the speakers answer a refused write with. +const READ_ONLY_CODE: i64 = 405; +const LOCAL_MODE_CODE: i64 = 401; fn quiet_timing() -> Timing { Timing { @@ -97,7 +98,7 @@ fn a_rejected_write_keeps_its_message_after_the_reread() { value: json!(2.0), }); apply_reply_to_write(&events, &mut state, &path); - assert_eq!(state.message.as_deref(), Some(READ_ONLY_TEXT)); + assert_eq!(state.message, Some(Message::Rejected(READ_ONLY_CODE))); } #[test] @@ -111,6 +112,6 @@ fn a_write_in_local_mode_shows_the_local_mode_message() { handle.send(command); let path = SscPath::parse("audio/out/level").unwrap(); apply_reply_to_write(&events, &mut state, &path); - assert_eq!(state.message.as_deref(), Some(LOCAL_MODE_TEXT)); + assert_eq!(state.message, Some(Message::Rejected(LOCAL_MODE_CODE))); assert_eq!(simulator.value("audio/out/level"), Some(json!(100.0))); } diff --git a/crates/core/src/catalog.rs b/crates/core/src/catalog.rs index fdf8e58..9141217 100644 --- a/crates/core/src/catalog.rs +++ b/crates/core/src/catalog.rs @@ -8,6 +8,7 @@ use std::sync::OnceLock; use serde::Deserialize; use serde_json::Value; +use crate::settings::Language; use crate::ssc::wire::SscPath; const EMBEDDED_CATALOG: &str = include_str!("paths.toml"); @@ -32,16 +33,20 @@ pub enum Group { } impl Group { - pub fn title(&self) -> &'static str { - match self { - Group::Output => "Ausgang", - Group::Output1 => "Ausgang 1", - Group::Output2 => "Ausgang 2", - Group::Input => "Eingang", - Group::Standby => "Standby", - Group::Device => "Gerät", - Group::RearPanel => "Rückseite", - Group::Metering => "Messung", + pub fn title(&self, language: Language) -> &'static str { + let (german, english) = match self { + Group::Output => ("Ausgang", "Output"), + Group::Output1 => ("Ausgang 1", "Output 1"), + Group::Output2 => ("Ausgang 2", "Output 2"), + Group::Input => ("Eingang", "Input"), + Group::Standby => ("Standby", "Standby"), + Group::Device => ("Gerät", "Device"), + Group::RearPanel => ("Rückseite", "Rear panel"), + Group::Metering => ("Messung", "Metering"), + }; + match language { + Language::German => german, + Language::English => english, } } } @@ -162,7 +167,8 @@ impl std::error::Error for ValueError {} #[serde(deny_unknown_fields)] pub struct Entry { pub path: SscPath, - pub label: String, + label: String, + label_en: String, pub group: Group, pub kind: Kind, pub poll: Poll, @@ -189,6 +195,13 @@ pub struct Entry { } impl Entry { + pub fn label(&self, language: Language) -> &str { + match language { + Language::German => &self.label, + Language::English => &self.label_en, + } + } + pub fn is_writable(&self, product: &str) -> bool { self.writable_on.iter().any(|known| known == product) } diff --git a/crates/core/src/paths.toml b/crates/core/src/paths.toml index 2770cc3..dbd7564 100644 --- a/crates/core/src/paths.toml +++ b/crates/core/src/paths.toml @@ -21,6 +21,7 @@ logo = ["KH 80", "KH 80 DSP", "KH 150", "KH 150 AES67", "KH 120", "KH 120 II", " [[entry]] path = "device/identity/product" label = "Produkt" +label_en = "Product" group = "device" kind = "display" poll = "once" @@ -29,6 +30,7 @@ class = "all" [[entry]] path = "device/identity/version" label = "Firmware-Version" +label_en = "Firmware version" group = "device" kind = "display" poll = "once" @@ -37,6 +39,7 @@ class = "all" [[entry]] path = "device/identity/serial" label = "Seriennummer" +label_en = "Serial number" group = "device" kind = "display" poll = "once" @@ -45,6 +48,7 @@ class = "all" [[entry]] path = "device/identity/vendor" label = "Hersteller" +label_en = "Vendor" group = "device" kind = "display" poll = "once" @@ -53,6 +57,7 @@ class = "all" [[entry]] path = "device/identity/hw_version" label = "Hardware-Version" +label_en = "Hardware version" group = "device" kind = "display" poll = "slow" @@ -61,6 +66,7 @@ class = "all" [[entry]] path = "audio/out/level" label = "Pegel" +label_en = "Level" group = "output" kind = "number" poll = "fast" @@ -73,6 +79,7 @@ writable_on = ["KH 120 II", "KH 750"] [[entry]] path = "audio/out/delay" label = "Verzögerung" +label_en = "Delay" group = "output" kind = "number" poll = "fast" @@ -88,6 +95,7 @@ writable_on = ["KH 120 II", "KH 750"] [[entry]] path = "audio/out/mute" label = "Stumm" +label_en = "Mute" group = "output" kind = "switch" poll = "fast" @@ -97,6 +105,7 @@ writable_on = ["KH 120 II", "KH 750"] [[entry]] path = "audio/out/phaseinversion" label = "Phase invertiert" +label_en = "Phase inverted" group = "output" kind = "switch" poll = "fast" @@ -106,6 +115,7 @@ writable_on = ["KH 120 II"] [[entry]] path = "audio/out/label" label = "Bezeichnung" +label_en = "Label" group = "output" kind = "display" poll = "slow" @@ -114,6 +124,7 @@ class = "subwoofer" [[entry]] path = "audio/out1/level" label = "Pegel" +label_en = "Level" group = "output1" kind = "number" poll = "fast" @@ -126,6 +137,7 @@ writable_on = ["KH 750"] [[entry]] path = "audio/out1/delay" label = "Verzögerung" +label_en = "Delay" group = "output1" kind = "number" poll = "fast" @@ -140,6 +152,7 @@ writable_on = ["KH 750"] [[entry]] path = "audio/out1/mute" label = "Stumm" +label_en = "Mute" group = "output1" kind = "switch" poll = "fast" @@ -149,6 +162,7 @@ writable_on = ["KH 750"] [[entry]] path = "audio/out1/label" label = "Bezeichnung" +label_en = "Label" group = "output1" kind = "display" poll = "slow" @@ -157,6 +171,7 @@ class = "subwoofer" [[entry]] path = "audio/out1/loudspeaker" label = "Lautsprecher" +label_en = "Loudspeaker" group = "output1" kind = "display" poll = "slow" @@ -165,6 +180,7 @@ class = "subwoofer" [[entry]] path = "audio/out2/level" label = "Pegel" +label_en = "Level" group = "output2" kind = "number" poll = "fast" @@ -177,6 +193,7 @@ writable_on = ["KH 750"] [[entry]] path = "audio/out2/delay" label = "Verzögerung" +label_en = "Delay" group = "output2" kind = "number" poll = "fast" @@ -191,6 +208,7 @@ writable_on = ["KH 750"] [[entry]] path = "audio/out2/mute" label = "Stumm" +label_en = "Mute" group = "output2" kind = "switch" poll = "fast" @@ -200,6 +218,7 @@ writable_on = ["KH 750"] [[entry]] path = "audio/out2/label" label = "Bezeichnung" +label_en = "Label" group = "output2" kind = "display" poll = "slow" @@ -208,6 +227,7 @@ class = "subwoofer" [[entry]] path = "audio/out2/loudspeaker" label = "Lautsprecher" +label_en = "Loudspeaker" group = "output2" kind = "display" poll = "slow" @@ -216,6 +236,7 @@ class = "subwoofer" [[entry]] path = "audio/in/interface" label = "Eingangs-Interface" +label_en = "Input interface" group = "input" kind = "select" poll = "slow" @@ -226,6 +247,7 @@ writable_on = ["KH 120 II", "KH 750"] [[entry]] path = "audio/in/current_input" label = "Aktueller Eingang" +label_en = "Current input" group = "input" kind = "display" poll = "slow" @@ -234,6 +256,7 @@ class = "all" [[entry]] path = "device/standby/enabled" label = "Auto-Standby" +label_en = "Auto standby" group = "standby" kind = "switch" poll = "fast" @@ -243,6 +266,7 @@ writable_on = ["KH 120 II"] [[entry]] path = "device/standby/auto_standby_time" label = "Standby-Zeit" +label_en = "Standby time" group = "standby" kind = "number" poll = "fast" @@ -256,6 +280,7 @@ writable_on = ["KH 120 II"] [[entry]] path = "device/standby/level" label = "Standby-Pegel" +label_en = "Standby level" group = "standby" kind = "number" poll = "fast" @@ -269,6 +294,7 @@ writable_on = ["KH 120 II"] [[entry]] path = "device/standby/countdown" label = "Countdown" +label_en = "Countdown" group = "standby" kind = "display" poll = "fast" @@ -278,6 +304,7 @@ unit = "min" [[entry]] path = "device/identification/visual" label = "Identifizieren" +label_en = "Identify" group = "device" kind = "switch" poll = "fast" @@ -287,6 +314,7 @@ writable_on = ["KH 120 II", "KH 750"] [[entry]] path = "ui/control_mode" label = "Steuermodus" +label_en = "Control mode" group = "device" kind = "display" poll = "fast" @@ -295,6 +323,7 @@ class = "all" [[entry]] path = "ui/logo/brightness" label = "Logo-Helligkeit" +label_en = "Logo brightness" group = "device" kind = "number" poll = "fast" @@ -308,6 +337,7 @@ writable_on = ["KH 120 II"] [[entry]] path = "device/name" label = "Name" +label_en = "Name" group = "device" kind = "text" poll = "slow" @@ -318,6 +348,7 @@ writable_on = ["KH 120 II", "KH 750"] [[entry]] path = "warnings" label = "Warnungen" +label_en = "Warnings" group = "device" kind = "display" poll = "fast" @@ -326,6 +357,7 @@ class = "all" [[entry]] path = "device/temperature" label = "Temperatur" +label_en = "Temperature" group = "device" kind = "display" poll = "fast" @@ -336,6 +368,7 @@ conversion = "kelvin_to_celsius" [[entry]] path = "ui/input_gain" label = "Eingangsverstärkung" +label_en = "Input gain" group = "rear_panel" kind = "display" poll = "slow" @@ -345,6 +378,7 @@ unit = "dB" [[entry]] path = "ui/output_level" label = "Ausgangspegel" +label_en = "Output level" group = "rear_panel" kind = "display" poll = "slow" @@ -354,6 +388,7 @@ unit = "dB SPL" [[entry]] path = "ui/bass_gain" label = "Bass" +label_en = "Bass" group = "rear_panel" kind = "display" poll = "slow" @@ -363,6 +398,7 @@ unit = "dB" [[entry]] path = "ui/mid_gain" label = "Mitten" +label_en = "Mid" group = "rear_panel" kind = "display" poll = "slow" @@ -372,6 +408,7 @@ unit = "dB" [[entry]] path = "ui/treble_gain" label = "Höhen" +label_en = "Treble" group = "rear_panel" kind = "display" poll = "slow" @@ -381,6 +418,7 @@ unit = "dB" [[entry]] path = "ui/input_select" label = "Eingangswahl" +label_en = "Input select" group = "rear_panel" kind = "display" poll = "slow" @@ -389,6 +427,7 @@ class = "monitor" [[entry]] path = "audio/digital_bypass" label = "Digital-Bypass" +label_en = "Digital bypass" group = "rear_panel" kind = "display" poll = "slow" @@ -397,6 +436,7 @@ class = "subwoofer" [[entry]] path = "ui/bass_management" label = "Bassmanagement" +label_en = "Bass management" group = "rear_panel" kind = "display" poll = "slow" @@ -405,6 +445,7 @@ class = "subwoofer" [[entry]] path = "ui/channel_b_input_mode" label = "Kanal-B-Eingang" +label_en = "Channel B input" group = "rear_panel" kind = "display" poll = "slow" @@ -413,6 +454,7 @@ class = "subwoofer" [[entry]] path = "ui/subwoofer_input_gain" label = "Eingangsverstärkung" +label_en = "Input gain" group = "rear_panel" kind = "display" poll = "slow" @@ -422,6 +464,7 @@ unit = "dB" [[entry]] path = "ui/subwoofer_low_cut" label = "Low-Cut" +label_en = "Low cut" group = "rear_panel" kind = "display" poll = "slow" @@ -431,6 +474,7 @@ unit = "dB" [[entry]] path = "ui/subwoofer_output_level" label = "Ausgangspegel" +label_en = "Output level" group = "rear_panel" kind = "display" poll = "slow" @@ -440,6 +484,7 @@ unit = "dB SPL" [[entry]] path = "ui/subwoofer_phase" label = "Phase" +label_en = "Phase" group = "rear_panel" kind = "display" poll = "slow" @@ -449,6 +494,7 @@ unit = "°" [[entry]] path = "ui/subwoofer_phase_inversion" label = "Phasenumkehr" +label_en = "Phase inversion" group = "rear_panel" kind = "display" poll = "slow" @@ -458,6 +504,7 @@ unit = "°" [[entry]] path = "m/in/level" label = "Eingangspegel" +label_en = "Input level" group = "metering" kind = "meter" poll = "meter" @@ -467,6 +514,7 @@ unit = "dB" [[entry]] path = "m/in/clip" label = "Eingang übersteuert" +label_en = "Input clipping" group = "metering" kind = "clip" poll = "meter" @@ -475,6 +523,7 @@ class = "all" [[entry]] path = "m/out/level" label = "Ausgangspegel" +label_en = "Output level" group = "metering" kind = "meter" poll = "meter" @@ -484,6 +533,7 @@ unit = "dB" [[entry]] path = "m/out/clip" label = "Ausgang übersteuert" +label_en = "Output clipping" group = "metering" kind = "clip" poll = "meter" diff --git a/crates/core/src/settings.rs b/crates/core/src/settings.rs index bd1cdb6..e1c9514 100644 --- a/crates/core/src/settings.rs +++ b/crates/core/src/settings.rs @@ -19,7 +19,9 @@ pub struct Settings { /// Every remembered speaker, added by address or chosen from a scan. The key keeps its /// first name so that files written before scans were remembered load unchanged. pub manual: Vec, + /// The window as the desk had it, and as the compact mode had it. pub window: Option, + pub compact_window: Option, pub desk: DeskSettings, pub groups: Vec, /// Speaker identities (`serial:` or `address:
`) in the order of the desk. @@ -54,6 +56,8 @@ pub struct DeskSettings { pub theme: ThemeChoice, pub meter_floor: MeterFloor, pub show_response_times: bool, + pub language: Language, + pub compact: bool, } impl Default for DeskSettings { @@ -62,6 +66,8 @@ impl Default for DeskSettings { theme: ThemeChoice::System, meter_floor: MeterFloor::Minus120, show_response_times: true, + language: Language::German, + compact: false, } } } @@ -74,6 +80,17 @@ pub enum ThemeChoice { Dark, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum Language { + #[default] + German, + English, +} + +impl Language { + pub const ALL: [Language; 2] = [Language::German, Language::English]; +} + /// The lowest level a meter shows; quiet room volume reads about -90 dB on the inputs. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] pub enum MeterFloor { diff --git a/crates/core/tests/catalog.rs b/crates/core/tests/catalog.rs index 2760216..a2b2aad 100644 --- a/crates/core/tests/catalog.rs +++ b/crates/core/tests/catalog.rs @@ -1,4 +1,5 @@ use neumann_control_core::catalog::{Catalog, CatalogError, Conversion, Entry, Kind, ValueError}; +use neumann_control_core::settings::Language; use neumann_control_core::ssc::wire::SscPath; use serde_json::json; @@ -201,6 +202,7 @@ fn duplicate_path_in_text_is_an_error() { [[entry]] path = "audio/out/mute" label = "Stumm" + label_en = "Mute" group = "output" kind = "switch" poll = "fast" @@ -209,6 +211,7 @@ fn duplicate_path_in_text_is_an_error() { [[entry]] path = "audio/out/mute" label = "Stumm" + label_en = "Mute" group = "output" kind = "switch" poll = "fast" @@ -232,6 +235,7 @@ fn select_without_options_is_an_error() { [[entry]] path = "audio/in/interface" label = "Eingangs-Interface" + label_en = "Input interface" group = "input" kind = "select" poll = "slow" @@ -272,6 +276,7 @@ fn invalid_reason(entry: &str) -> Option<&'static str> { const SUBWOOFER_OUTPUT: &str = r#" path = "audio/out1/mute" label = "Stumm" + label_en = "Mute" group = "output1" kind = "switch" poll = "fast" @@ -281,6 +286,7 @@ const SUBWOOFER_OUTPUT: &str = r#" const LEVEL: &str = r#" path = "audio/out/level" label = "Pegel" + label_en = "Level" group = "output" kind = "number" poll = "fast" @@ -302,3 +308,12 @@ fn an_empty_number_range_is_an_error() { assert!(invalid_reason(&format!("{LEVEL}min = 5\nmax = 10\nmax_subwoofer = 5")).is_some()); assert_eq!(invalid_reason(&format!("{LEVEL}min = 0\nmax = 120")), None); } + +#[test] +fn every_entry_has_a_label_in_each_language() { + for entry in &Catalog::embedded().entries { + for language in Language::ALL { + assert!(!entry.label(language).is_empty(), "{}", entry.path); + } + } +} diff --git a/crates/core/tests/settings.rs b/crates/core/tests/settings.rs index 6fc30aa..f61e16a 100644 --- a/crates/core/tests/settings.rs +++ b/crates/core/tests/settings.rs @@ -1,6 +1,6 @@ use neumann_control_core::settings::{ - load, save, DeskSettings, GroupSettings, Linkable, ManualSpeaker, MeterFloor, Settings, - ThemeChoice, WindowGeometry, SETTINGS_FILE, + load, save, DeskSettings, GroupSettings, Language, Linkable, ManualSpeaker, MeterFloor, + Settings, ThemeChoice, WindowGeometry, SETTINGS_FILE, }; use std::fs; @@ -119,6 +119,9 @@ fn old_settings_file_loads_with_desk_defaults() { assert_eq!(loaded.desk.theme, ThemeChoice::System); assert_eq!(loaded.desk.meter_floor.db(), -120.0); assert!(loaded.desk.show_response_times); + assert_eq!(loaded.desk.language, Language::German); + assert!(!loaded.desk.compact); + assert_eq!(loaded.compact_window, None); } #[test] @@ -130,7 +133,15 @@ fn desk_settings_round_trip() { theme: ThemeChoice::Dark, meter_floor: MeterFloor::Minus60, show_response_times: false, + language: Language::English, + compact: true, }, + compact_window: Some(WindowGeometry { + x: 10.0, + y: 20.0, + width: 400.0, + height: 300.0, + }), ..Settings::default() }; save(&settings, &path).unwrap(); From 100919f0d5ae27251d4a32bdee8fc45a50fe7a86 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:58:59 +0200 Subject: [PATCH 092/460] Add a compact window mode One row per ungrouped speaker and per group with a lying level fader, a small meter and mute, and a master row; Ctrl+K switches, and each mode keeps its own window. --- CHANGELOG.md | 5 +- README.md | 15 ++- crates/app/src/app.rs | 26 +++- crates/app/src/app/compact.rs | 125 +++++++++++++++++ crates/app/src/app/desk.rs | 14 +- crates/app/src/groups.rs | 41 ++++++ crates/app/src/main.rs | 7 +- crates/app/src/state.rs | 18 +++ crates/app/src/texts.rs | 2 + crates/app/src/views/compact.rs | 201 ++++++++++++++++++++++++++++ crates/app/src/views/fader.rs | 189 ++++++++++++++++++++------ crates/app/src/views/group_strip.rs | 17 ++- crates/app/src/views/meters.rs | 34 ++++- crates/app/src/views/mod.rs | 1 + crates/app/src/views/strip.rs | 17 ++- crates/app/src/views/toolbar.rs | 51 ++++--- crates/app/tests/compact.rs | 88 ++++++++++++ crates/app/tests/groups.rs | 57 +------- crates/app/tests/support/mod.rs | 58 +++++++- crates/core/src/settings.rs | 30 +++++ crates/core/tests/settings.rs | 26 ++++ 21 files changed, 879 insertions(+), 143 deletions(-) create mode 100644 crates/app/src/app/compact.rs create mode 100644 crates/app/src/views/compact.rs create mode 100644 crates/app/tests/compact.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3aa78fc..27b9c84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,10 @@ First version. meters (KH 750: also output meters) with clip lamps, response time, mute and identify. - A device panel per speaker with every setting of its model and a list of all values the speaker reports. -- A settings dialog for theme, meter range and response times. +- A settings dialog for theme, language, meter range and response times. +- German and English user interface, switched at once. +- A compact window mode (Ctrl+K) with one row per ungrouped speaker and per group and a master + row, with its own window size and position. - Groups with a group strip (relative fader, mute, linked settings), a master strip, and strips reordered by dragging. - Support for the KH 120 II and the KH 750 with writes; other models read-only. diff --git a/README.md b/README.md index ad95e19..85d6541 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # neumann-control A portable Windows app to find, read and control Neumann KH DSP speakers over the -Sennheiser Sound Control protocol (SSC). The user interface is in German. +Sennheiser Sound Control protocol (SSC). The user interface is in German or English; the +names below are the German ones. This is an independent project. It is not affiliated with or endorsed by Neumann or Sennheiser. @@ -45,9 +46,15 @@ knowledge the path catalog is built from and the simulator, not a hardware test. - Drag a strip by the dots before its name; the other strips of its group (or the ungrouped ones) make room where it would land, and the order is remembered when you let go. Escape cancels. -- "Einstellungen" sets the theme (like Windows, light, dark), the lowest level the meters show - (-120, -90 or -60 dB) and whether response times are shown; the toolbar shows the average - response time of the connected speakers. +- "Einstellungen" sets the theme (like Windows, light, dark), the language (Deutsch, English; + it changes at once), the lowest level the meters show (-120, -90 or -60 dB) and whether + response times are shown; the toolbar shows the average response time of the connected + speakers. +- "Kompakt" (or Ctrl+K) switches to a compact window and back: one row per ungrouped speaker + and per group, each with LEDs (a group: its colour), name, a lying level fader with its value, + the loudest input as a small meter and "Stumm", and the master row at the bottom. A group row + moves its members like the group strip. The compact window keeps its own size and position; + scanning and adding speakers or groups stay on the desk. - "Alle Werte..." lists every value the speaker reports in its own schema, with a path filter and copy buttons, including paths the catalog does not know yet. - Shows the control mode (NETWORK or LOCAL), which the CONTROL switch on the back of the speaker diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index 1557bdd..d5c33d9 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -7,7 +7,7 @@ use std::sync::mpsc::{Receiver, TryRecvError}; use std::sync::Arc; use std::thread; -use eframe::egui::{self, Context, Pos2, Ui, ViewportInfo}; +use eframe::egui::{self, Context, Key, KeyboardShortcut, Modifiers, Pos2, Ui, ViewportInfo}; use neumann_control_core::address::parse_address; use neumann_control_core::catalog::Catalog; use neumann_control_core::discovery::{merge_found, Found}; @@ -31,9 +31,14 @@ use crate::views::settings_dialog::{self, SettingsAction}; use crate::views::toolbar::{self, ToolbarAction, ToolbarFacts}; use crate::views::{all_values, panel}; +mod compact; mod desk; const DESK_SCROLL_ID: &str = "desk"; +const COMPACT_SCROLL_ID: &str = "compact"; +const COMPACT_SHORTCUT: KeyboardShortcut = KeyboardShortcut::new(Modifiers::COMMAND, Key::K); +/// The desk's window size in points before it was ever resized. +pub const DESK_WINDOW_SIZE: [f32; 2] = [900.0, 600.0]; struct Speaker { state: SpeakerState, @@ -89,7 +94,7 @@ impl NeumannApp { context.set_theme(theme::preference(settings.desk.theme)); let repaint_context = context.clone(); let mut app = NeumannApp { - window: settings.window, + window: settings.window_of_mode(), settings_path, settings, spawner, @@ -288,6 +293,7 @@ impl NeumannApp { fn show(&mut self, ui: &mut Ui) { let facts = ToolbarFacts { + compact: self.settings.desk.compact, scanning: self.scan.is_some(), speaker_count: self.speakers.len(), notice: self.notice, @@ -302,9 +308,19 @@ impl NeumannApp { ToolbarAction::AddAddress => self.add_address = Some(AddAddressForm::default()), ToolbarAction::AddGroup => self.add_group(), ToolbarAction::Settings => self.settings_open = true, + ToolbarAction::ToggleCompact => self.toggle_compact(ui.ctx()), ToolbarAction::None => {} } - egui::CentralPanel::default().show(ui, |ui| self.show_desk(ui)); + if ui.input_mut(|input| input.consume_shortcut(&COMPACT_SHORTCUT)) { + self.toggle_compact(ui.ctx()); + } + egui::CentralPanel::default().show(ui, |ui| { + if self.settings.desk.compact { + self.show_compact(ui); + } else { + self.show_desk(ui); + } + }); self.show_panel(ui.ctx()); self.show_dialog(ui); self.show_scan_choice(ui); @@ -527,8 +543,8 @@ impl eframe::App for NeumannApp { speaker.handle.send(Command::Stop); } self.speakers.clear(); - if self.window.is_some() { - self.settings.window = self.window; + if let Some(window) = self.window { + self.settings.remember_window(window); self.save_settings(); } } diff --git a/crates/app/src/app/compact.rs b/crates/app/src/app/compact.rs new file mode 100644 index 0000000..b4cd291 --- /dev/null +++ b/crates/app/src/app/compact.rs @@ -0,0 +1,125 @@ +//! The compact mode: one row per ungrouped speaker and per group, the master at the bottom. The +//! rows' gestures mean the same as on the desk and go the same way to the speakers. + +use eframe::egui::{Context, ScrollArea, Ui, ViewportCommand}; +use neumann_control_core::catalog::Catalog; + +use super::desk::{all_muted, level_unit, levels, members_of, DeskAction}; +use super::{NeumannApp, OpenPanel, COMPACT_SCROLL_ID, DESK_WINDOW_SIZE}; +use crate::fader::output_level_range; +use crate::groups::{compact_rows, layout, reference_level, CompactRow}; +use crate::texts; +use crate::ui_kit::theme::group_color; +use crate::views::compact::{self, SharedRow}; + +/// The window size the compact mode starts with before it was ever resized, in points. +const COMPACT_WINDOW_SIZE: [f32; 2] = [640.0, 360.0]; + +impl NeumannApp { + pub(super) fn show_compact(&mut self, ui: &mut Ui) { + if self.speakers.is_empty() { + ui.weak(texts::NO_SPEAKERS); + return; + } + let catalog = Catalog::embedded(); + let desk = self.settings.desk; + let ids = self.member_ids(); + let items = layout(&self.settings.order, &self.settings.groups, &ids); + let rows = compact_rows(&items, &self.settings.groups, &ids); + let unit = level_unit(catalog); + let floor = desk.meter_floor.db(); + let now = ui.input(|input| input.time); + let mut actions = Vec::new(); + ScrollArea::vertical() + .id_salt(COMPACT_SCROLL_ID) + .auto_shrink([false, false]) + .show(ui, |ui| { + for row in &rows { + match *row { + CompactRow::Speaker(index) => { + let speaker = &mut self.speakers[index]; + let key = speaker.state.key.clone(); + let response = ui + .push_id(&key, |ui| { + compact::speaker(ui, &mut speaker.state, catalog, &desk) + }) + .inner; + actions.push(DeskAction::Commands(index, response.commands)); + if let Some(anchor) = response.open_panel { + let opened_now = true; + let open = OpenPanel { + key, + anchor, + opened_now, + }; + actions.push(DeskAction::OpenPanel(open)); + } + } + CompactRow::Group(group) => { + let settings = &self.settings.groups[group]; + let members = members_of(settings, &ids); + let shown = self.shared_row(&members, &unit, floor, now); + let color = group_color(settings.color); + let response = ui + .push_id((texts::GROUP, group), |ui| { + compact::group(ui, settings, color, shown) + }) + .inner; + actions.push(DeskAction::Group(group, response)); + } + } + } + let everyone: Vec = (0..self.speakers.len()).collect(); + let shown = self.shared_row(&everyone, &unit, floor, now); + actions.push(DeskAction::Master(compact::master(ui, shown))); + }); + for action in actions { + self.apply_desk_action(action, catalog, &ids, now); + } + } + + fn shared_row<'a>( + &self, + members: &[usize], + unit: &'a str, + floor: f64, + now: f64, + ) -> SharedRow<'a> { + let states = self.states(members); + let inputs: Vec = states + .iter() + .filter_map(|state| state.loudest_input(now)) + .collect(); + SharedRow { + reference: reference_level(&levels(&states)), + muted: all_muted(&states), + loudest: reference_level(&inputs), + range: output_level_range(), + unit, + floor, + } + } + + /// Desk to compact or back: the window the new mode last had comes back, else its first size. + pub(super) fn toggle_compact(&mut self, context: &Context) { + let target = self.settings.switch_mode(self.window); + self.save_settings(); + let Some(target) = target else { + let [width, height] = if self.settings.desk.compact { + COMPACT_WINDOW_SIZE + } else { + DESK_WINDOW_SIZE + }; + context.send_viewport_cmd(ViewportCommand::InnerSize(eframe::egui::vec2( + width, height, + ))); + return; + }; + let points = |physical: f32| physical / context.pixels_per_point(); + let position = eframe::egui::pos2(points(target.x), points(target.y)); + let size = eframe::egui::vec2(points(target.width), points(target.height)); + context.send_viewport_cmd(ViewportCommand::OuterPosition(position)); + context.send_viewport_cmd(ViewportCommand::InnerSize(size)); + self.window = Some(target); + } +} diff --git a/crates/app/src/app/desk.rs b/crates/app/src/app/desk.rs index 1a386c7..7051087 100644 --- a/crates/app/src/app/desk.rs +++ b/crates/app/src/app/desk.rs @@ -30,7 +30,7 @@ const STRIP_CENTERS_ID: &str = "strip_centers"; const DRAGGED_OPACITY: f32 = 0.4; /// What a frame's gestures asked for; applied once the desk is drawn. -enum DeskAction { +pub(super) enum DeskAction { Commands(usize, Vec), Group(usize, GroupStripResponse), Master(MasterResponse), @@ -146,7 +146,7 @@ impl NeumannApp { } } - fn apply_desk_action( + pub(super) fn apply_desk_action( &mut self, action: DeskAction, catalog: &Catalog, @@ -316,7 +316,7 @@ impl NeumannApp { self.group_draft = Some(GroupDraft { index: count, name }); } - fn states(&self, indexes: &[usize]) -> Vec<&SpeakerState> { + pub(super) fn states(&self, indexes: &[usize]) -> Vec<&SpeakerState> { indexes .iter() .map(|index| &self.speakers[*index].state) @@ -362,7 +362,7 @@ pub(super) fn group_of(groups: &[GroupSettings], id: &str) -> Option { .position(|group| group.members.iter().any(|member| member == id)) } -fn members_of(group: &GroupSettings, ids: &[String]) -> Vec { +pub(super) fn members_of(group: &GroupSettings, ids: &[String]) -> Vec { ids.iter() .enumerate() .filter(|(_, id)| group.members.contains(id)) @@ -388,12 +388,12 @@ fn level_of(state: &SpeakerState) -> Option { .and_then(Value::as_f64) } -fn levels(states: &[&SpeakerState]) -> Vec { +pub(super) fn levels(states: &[&SpeakerState]) -> Vec { states.iter().filter_map(|state| level_of(state)).collect() } /// Whether every connected member is muted; none when no member is connected. -fn all_muted(states: &[&SpeakerState]) -> Option { +pub(super) fn all_muted(states: &[&SpeakerState]) -> Option { let mutes: Vec = states .iter() .filter(|state| state.status == Status::Connected) @@ -407,7 +407,7 @@ fn all_muted(states: &[&SpeakerState]) -> Option { (!mutes.is_empty()).then(|| mutes.iter().all(|muted| *muted)) } -fn level_unit(catalog: &Catalog) -> String { +pub(super) fn level_unit(catalog: &Catalog) -> String { paths::catalog_entry(catalog, &paths::path(paths::OUTPUT_LEVEL)) .and_then(|entry| entry.unit.clone()) .unwrap_or_default() diff --git a/crates/app/src/groups.rs b/crates/app/src/groups.rs index 3b7706e..4df5f38 100644 --- a/crates/app/src/groups.rs +++ b/crates/app/src/groups.rs @@ -94,6 +94,34 @@ pub fn layout(order: &[String], groups: &[GroupSettings], speakers: &[String]) - items } +/// One row of the compact mode: an ungrouped speaker, or a group standing for its members. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CompactRow { + Speaker(usize), + Group(usize), +} + +/// The compact rows in the order of the desk: grouped speakers have no row of their own. +pub fn compact_rows( + items: &[DeskItem], + groups: &[GroupSettings], + speakers: &[String], +) -> Vec { + let grouped = |index: usize| { + groups + .iter() + .any(|group| group.members.contains(&speakers[index])) + }; + items + .iter() + .filter_map(|item| match *item { + DeskItem::Speaker(index) if grouped(index) => None, + DeskItem::Speaker(index) => Some(CompactRow::Speaker(index)), + DeskItem::GroupStrip(group) => Some(CompactRow::Group(group)), + }) + .collect() +} + /// Puts `member` into `group` (or into none), out of any other group. pub fn set_membership(groups: &mut [GroupSettings], member: &str, group: Option) { for existing in groups.iter_mut() { @@ -215,6 +243,19 @@ mod tests { ); } + #[test] + fn compact_rows_show_groups_instead_of_their_members() { + use DeskItem::*; + let speakers = ids(&["a", "b", "c"]); + let groups = [group("Front", &["a", "c"])]; + let items = layout(&[], &groups, &speakers); + assert_eq!( + compact_rows(&items, &groups, &speakers), + vec![CompactRow::Speaker(1), CompactRow::Group(0)] + ); + assert_eq!(items[0], Speaker(1)); + } + #[test] fn a_speaker_is_in_one_group_at_most() { let mut groups = [group("Front", &["a"]), group("Sub", &[])]; diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 7dcbcec..fceeaab 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -8,7 +8,7 @@ use std::path::Path; use eframe::egui::ViewportBuilder; use eframe::egui_wgpu::{SurfaceConfig, WgpuConfiguration, WgpuSetup}; use eframe::wgpu; -use neumann_control_app::app::NeumannApp; +use neumann_control_app::app::{NeumannApp, DESK_WINDOW_SIZE}; use neumann_control_app::spawner::CoreSpawner; use neumann_control_app::texts; use neumann_control_app::window_placement::{fit_into, MonitorArea}; @@ -17,7 +17,6 @@ use simplelog::{ConfigBuilder, LevelFilter, WriteLogger}; use winit::dpi::{PhysicalPosition, PhysicalSize}; use winit::window::Window; -const WINDOW_SIZE: [f32; 2] = [900.0, 600.0]; const WINDOW_MIN_SIZE: [f32; 2] = [480.0, 360.0]; // Our crates only: wgpu logs the graphics adapters with their bus ids at info level. const LOG_TARGET_PREFIX: &str = "neumann_control"; @@ -84,11 +83,11 @@ fn main() -> Result<(), Box> { .ok_or("the executable has no parent folder")?; start_log(directory); let settings_path = directory.join(SETTINGS_FILE); - let saved_window = settings::load(&settings_path).window; + let saved_window = settings::load(&settings_path).window_of_mode(); let options = eframe::NativeOptions { viewport: ViewportBuilder::default() .with_title(texts::WINDOW_TITLE) - .with_inner_size(WINDOW_SIZE) + .with_inner_size(DESK_WINDOW_SIZE) .with_min_inner_size(WINDOW_MIN_SIZE), centered: true, wgpu_options: wgpu_options(), diff --git a/crates/app/src/state.rs b/crates/app/src/state.rs index 0c622db..0e395ab 100644 --- a/crates/app/src/state.rs +++ b/crates/app/src/state.rs @@ -227,6 +227,14 @@ impl SpeakerState { } } + /// The loudest input channel as its meter shows it now; none before any meter reading. + pub fn loudest_input(&self, now: f64) -> Option { + self.input_meters + .iter() + .map(|meter| meter.shown(now)) + .reduce(f64::max) + } + pub(crate) fn set_message(&mut self, message: Option, source: MessageSource) { self.message = message; self.message_source = source; @@ -446,6 +454,16 @@ mod tests { assert_eq!(speaker.output_meters.len(), 2); } + #[test] + fn the_loudest_input_is_the_highest_channel_as_shown() { + let mut speaker = state(); + assert_eq!(speaker.loudest_input(0.0), None); + speaker.apply(value_event("m/in/level", json!([-30.0, -12.0])), 0.0); + speaker.apply(value_event("m/out/level", json!([-3.0])), 0.0); + // Long after the rise, the meters show the levels themselves. + assert_eq!(speaker.loudest_input(10.0), Some(-12.0)); + } + #[test] fn unreachable_resets_meters() { let mut speaker = state(); diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 6d7e750..f76eefc 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -130,6 +130,8 @@ pub const CONTROL_HINT: Text = Text::new( ); pub const PANEL_TITLE: Text = Text::new("Lautsprecher", "Speaker"); pub const SETTINGS: Text = Text::new("Einstellungen", "Settings"); +pub const COMPACT: Text = Text::new("Kompakt", "Compact"); +pub const COMPACT_HINT: Text = Text::new("Strg+K", "Ctrl+K"); pub const SETTINGS_TITLE: Text = Text::new("Einstellungen", "Settings"); pub const THEME: Text = Text::new("Darstellung", "Appearance"); pub const THEME_SYSTEM: Text = Text::new("wie Windows", "like Windows"); diff --git a/crates/app/src/views/compact.rs b/crates/app/src/views/compact.rs new file mode 100644 index 0000000..1fd213a --- /dev/null +++ b/crates/app/src/views/compact.rs @@ -0,0 +1,201 @@ +//! The compact mode's rows: LEDs (or the group's colour), name, a lying level fader with its +//! value, the loudest input as a small meter, and mute. One row per ungrouped speaker and per +//! group, the master at the bottom. The rows only report gestures, as the strips do. + +use std::ops::RangeInclusive; + +use eframe::egui::{ + self, vec2, Align, Button, Color32, CornerRadius, Layout, Pos2, RichText, Sense, Stroke, Ui, +}; +use neumann_control_core::catalog::Catalog; +use neumann_control_core::settings::{DeskSettings, GroupSettings}; +use neumann_control_core::speaker::Command; +use serde_json::Value; + +use super::fader::{self, Orientation, Scale}; +use super::group_strip::{mute_button, GroupStripResponse, MasterResponse}; +use super::meters; +use super::strip::{self, UNREACHABLE_OPACITY}; +use crate::fader::output_level_range; +use crate::paths; +use crate::state::{SpeakerState, Status}; +use crate::texts; +use crate::ui_kit::theme::Tokens; +use crate::writes::Switch; + +const ROW_MARGIN: i8 = 4; +const ROW_RADIUS: u8 = 4; +const BORDER_WIDTH: f32 = 1.0; +/// The columns line up from row to row; the LED column is as wide as four LEDs. +const MARK_WIDTH: f32 = 50.0; +const NAME_WIDTH: f32 = 120.0; +const READOUT_WIDTH: f32 = 72.0; +const ROW_HEIGHT: f32 = 22.0; +const SWATCH_SIZE: f32 = 10.0; +const SWATCH_RADIUS: u8 = 2; + +pub struct SpeakerRowResponse { + /// The writes the user asked for that the write policy allowed. + pub commands: Vec, + /// Where the device panel opens when the name was clicked. + pub open_panel: Option, +} + +pub fn speaker( + ui: &mut Ui, + speaker: &mut SpeakerState, + catalog: &Catalog, + desk: &DeskSettings, +) -> SpeakerRowResponse { + let tokens = Tokens::current(ui.ctx()); + let mut commands = Vec::new(); + let path = paths::path(paths::OUTPUT_LEVEL); + let device = speaker.values.get(&path).and_then(Value::as_f64); + let unit = paths::catalog_entry(catalog, &path) + .and_then(|entry| entry.unit.clone()) + .unwrap_or_default(); + let now = ui.input(|input| input.time); + let loudest = speaker.loudest_input(now); + let open_panel = row(ui, tokens.border, |ui| { + if speaker.status == Status::Unreachable { + ui.multiply_opacity(UNREACHABLE_OPACITY); + } + cell(ui, MARK_WIDTH, |ui| strip::leds(ui, speaker, &tokens)); + let open_panel = cell(ui, NAME_WIDTH, |ui| strip::name(ui, speaker)); + let range = output_level_range(); + let scale = Scale { + range, + unit: &unit, + orientation: Orientation::Horizontal, + }; + let shown = fader::show(ui, speaker, catalog, &mut commands, device, scale); + let readout = device + .map(|_| texts::readout(shown, &unit)) + .unwrap_or_default(); + cell(ui, READOUT_WIDTH, |ui| ui.monospace(readout)); + let floor = desk.meter_floor.db(); + meters::bar(ui, loudest, floor, texts::INPUT_METERS.get(), &tokens); + let mute = texts::MUTE.get(); + strip::toggle(ui, speaker, catalog, &mut commands, Switch::Mute, mute); + open_panel + }); + SpeakerRowResponse { + commands, + open_panel, + } +} + +/// What a group or master row shows: the loudest connected member's level as reference, whether +/// all are muted, and the loudest input among them. +pub struct SharedRow<'a> { + pub reference: Option, + pub muted: Option, + pub loudest: Option, + pub range: RangeInclusive, + pub unit: &'a str, + pub floor: f64, +} + +pub fn group( + ui: &mut Ui, + group: &GroupSettings, + color: Color32, + shown: SharedRow, +) -> GroupStripResponse { + let tokens = Tokens::current(ui.ctx()); + let mut response = GroupStripResponse::default(); + row(ui, color, |ui| { + cell(ui, MARK_WIDTH, |ui| swatch(ui, color)); + let name = RichText::new(&group.name).strong().color(color); + response.open_settings = cell(ui, NAME_WIDTH, |ui| { + ui.add(Button::new(name).frame(false).truncate()).clicked() + }); + let label = texts::group_level(&group.name); + let (level, mute) = + shared_controls(ui, &label, &texts::group_mute(&group.name), shown, &tokens); + response.level = level; + response.mute = mute; + }); + response +} + +pub fn master(ui: &mut Ui, shown: SharedRow) -> MasterResponse { + let tokens = Tokens::current(ui.ctx()); + let mut response = MasterResponse::default(); + row(ui, tokens.accent, |ui| { + cell(ui, MARK_WIDTH, |_| {}); + cell(ui, NAME_WIDTH, |ui| { + ui.add(egui::Label::new(RichText::new(texts::MASTER).strong())) + }); + let (level, mute) = shared_controls( + ui, + texts::MASTER_LEVEL.get(), + texts::MUTE_ALL.get(), + shown, + &tokens, + ); + response.level = level; + response.mute = mute; + }); + response +} + +/// Fader, value, meter and mute of a row that moves several speakers at once. +fn shared_controls( + ui: &mut Ui, + level_label: &str, + mute_label: &str, + shown: SharedRow, + tokens: &Tokens, +) -> (Option, Option) { + let scale = Scale { + range: shown.range, + unit: shown.unit, + orientation: Orientation::Horizontal, + }; + let level = fader::shared(ui, level_label, shown.reference, scale); + let readout = shown + .reference + .map(texts::shared_readout) + .unwrap_or_default(); + cell(ui, READOUT_WIDTH, |ui| ui.monospace(readout)); + meters::bar( + ui, + shown.loudest, + shown.floor, + texts::INPUT_METERS.get(), + tokens, + ); + let mute = mute_button(ui, mute_label, shown.muted); + (level, mute) +} + +fn row(ui: &mut Ui, border: Color32, contents: impl FnOnce(&mut Ui) -> R) -> R { + let tokens = Tokens::current(ui.ctx()); + egui::Frame::new() + .fill(tokens.card) + .stroke(Stroke::new(BORDER_WIDTH, border)) + .corner_radius(CornerRadius::same(ROW_RADIUS)) + .inner_margin(ROW_MARGIN) + .show(ui, |ui| { + ui.with_layout(Layout::left_to_right(Align::Center), contents) + .inner + }) + .inner +} + +/// A column of fixed width, so that the rows line up. +fn cell(ui: &mut Ui, width: f32, contents: impl FnOnce(&mut Ui) -> R) -> R { + let size = vec2(width, ROW_HEIGHT); + ui.allocate_ui_with_layout(size, Layout::left_to_right(Align::Center), |ui| { + ui.set_min_size(size); + contents(ui) + }) + .inner +} + +fn swatch(ui: &mut Ui, color: Color32) { + let (rect, _) = ui.allocate_exact_size(vec2(SWATCH_SIZE, SWATCH_SIZE), Sense::hover()); + ui.painter() + .rect_filled(rect, CornerRadius::same(SWATCH_RADIUS), color); +} diff --git a/crates/app/src/views/fader.rs b/crates/app/src/views/fader.rs index fa5bdf5..dc200f3 100644 --- a/crates/app/src/views/fader.rs +++ b/crates/app/src/views/fader.rs @@ -1,5 +1,6 @@ -//! The strip's level fader: dragged relative to the pointer (Shift for fine moves), written once -//! when let go, and the mouse wheel in 0.5 dB steps per notch. +//! The level fader, upright on a strip and lying in a compact row: dragged relative to the +//! pointer (Shift for fine moves), written once when let go, and the mouse wheel in 0.5 dB steps +//! per notch. use std::ops::RangeInclusive; @@ -33,10 +34,59 @@ pub(super) const FINE_DIVISOR: f64 = 10.0; /// Space between the fader and the value shown beside its handle while dragging. const HANDLE_LABEL_GAP: f32 = 6.0; -/// The fader's length; the whole range spans it. +/// The upright fader's length; the whole range spans it. pub const FADER_HEIGHT: f32 = 200.0; +/// The lying fader's length in a compact row. +const BAR_LENGTH: f32 = 160.0; +/// Space between a lying fader and the value shown above its handle while dragging. +const VALUE_ABOVE_GAP: f32 = 4.0; -/// The vertical level fader; returns the value it shows. It moves relative to the pointer from +/// Which way a fader runs: upright on a strip, lying in a compact row. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Orientation { + Vertical, + Horizontal, +} + +impl Orientation { + fn size(self) -> Vec2 { + match self { + Orientation::Vertical => vec2(FADER_WIDTH, FADER_HEIGHT), + Orientation::Horizontal => vec2(BAR_LENGTH, FADER_WIDTH), + } + } + + fn length(self) -> f32 { + match self { + Orientation::Vertical => FADER_HEIGHT, + Orientation::Horizontal => BAR_LENGTH, + } + } + + /// The part of a drag along the fader, towards higher values positive. + fn along(self, delta: Vec2) -> f64 { + match self { + Orientation::Vertical => -f64::from(delta.y), + Orientation::Horizontal => f64::from(delta.x), + } + } + + fn cursor(self) -> CursorIcon { + match self { + Orientation::Vertical => CursorIcon::ResizeVertical, + Orientation::Horizontal => CursorIcon::ResizeHorizontal, + } + } +} + +/// What a fader spans and how it lies. +pub struct Scale<'a> { + pub range: RangeInclusive, + pub unit: &'a str, + pub orientation: Orientation, +} + +/// A speaker's level fader; returns the value it shows. It moves relative to the pointer from /// wherever it is grabbed, never jumping to the pointer; Shift makes it `FINE_DIVISOR` times /// finer. While dragging, the value also stands beside the fader, where the eye is. pub fn show( @@ -45,9 +95,13 @@ pub fn show( catalog: &Catalog, commands: &mut Vec, device: Option, - range: RangeInclusive, - unit: &str, + scale: Scale, ) -> f64 { + let Scale { + range, + unit, + orientation, + } = scale; let tokens = Tokens::current(ui.ctx()); let path = paths::path(paths::OUTPUT_LEVEL); let enabled = device.is_some() && writes::can_write(speaker, catalog, &path); @@ -61,7 +115,7 @@ pub fn show( } else { Sense::hover() }; - let (rect, response) = ui.allocate_exact_size(vec2(FADER_WIDTH, FADER_HEIGHT), sense); + let (rect, response) = ui.allocate_exact_size(orientation.size(), sense); let drag_id = response.id.with(DRAG_VALUE_ID); if response.drag_started() { speaker.fader.drag_started(); @@ -74,7 +128,8 @@ pub fn show( .data(|data| data.get_temp::(drag_id)) .unwrap_or(value); let fine = ui.input(|input| input.modifiers.shift); - let moved = drag_change(&range, -f64::from(response.drag_delta().y), fine); + let points = orientation.along(response.drag_delta()); + let moved = drag_change(&range, points, fine, orientation.length()); let unsnapped = (held + moved).clamp(*range.start(), *range.end()); ui.data_mut(|data| data.insert_temp(drag_id, unsnapped)); value = on_level_grid(unsnapped); @@ -91,7 +146,7 @@ pub fn show( // A held fader ignores the wheel: steps added now would overwrite the drag's value. let held = response.is_pointer_button_down_on() || response.dragged(); if enabled && response.hovered() && !held { - ui.ctx().set_cursor_icon(CursorIcon::ResizeVertical); + ui.ctx().set_cursor_icon(orientation.cursor()); // The strips sit in a scroll area, which would scroll along with the fader. ui.input_mut(|input| input.smooth_scroll_delta = Vec2::ZERO); let wheel = wheel_notches(ui); @@ -104,13 +159,13 @@ pub fn show( value = speaker.fader.shown(device, now); } } - paint(ui, rect, &range, value, enabled, &tokens); + paint(ui, rect, &range, value, enabled, orientation, &tokens); response.widget_info(|| WidgetInfo::slider(enabled, value, texts::LEVEL)); let id = response.id; response.on_hover_text(texts::CONTROL_HINT); if dragging { - let y = value_y(rect, &range, value); - value_beside(ui, id, rect, y, &texts::readout(value, unit)); + let text = texts::readout(value, unit); + dragged_value(ui, id, rect, &range, value, orientation, &text); } if let Some(due) = speaker.fader.due(now) { commands.extend(speaker.request(Intent::Level(due), catalog, now)); @@ -125,13 +180,12 @@ pub fn show( /// A fader for several speakers at once (group, master): it shows `reference` and returns the /// value it was let go at; the caller turns the move into one write per speaker. No wheel, so a /// group move is always one deliberate gesture. -pub fn shared( - ui: &mut Ui, - label: &str, - reference: Option, - range: RangeInclusive, - unit: &str, -) -> Option { +pub fn shared(ui: &mut Ui, label: &str, reference: Option, scale: Scale) -> Option { + let Scale { + range, + unit, + orientation, + } = scale; let tokens = Tokens::current(ui.ctx()); let enabled = reference.is_some(); let mut value = reference.unwrap_or(*range.start()); @@ -140,7 +194,7 @@ pub fn shared( } else { Sense::hover() }; - let (rect, response) = ui.allocate_exact_size(vec2(FADER_WIDTH, FADER_HEIGHT), sense); + let (rect, response) = ui.allocate_exact_size(orientation.size(), sense); let drag_id = response.id.with(DRAG_VALUE_ID); if response.drag_started() { ui.data_mut(|data| data.insert_temp(drag_id, value)); @@ -152,7 +206,8 @@ pub fn shared( .data(|data| data.get_temp::(drag_id)) .unwrap_or(value); let fine = ui.input(|input| input.modifiers.shift); - let moved = drag_change(&range, -f64::from(response.drag_delta().y), fine); + let points = orientation.along(response.drag_delta()); + let moved = drag_change(&range, points, fine, orientation.length()); let unsnapped = (held + moved).clamp(*range.start(), *range.end()); ui.data_mut(|data| data.insert_temp(drag_id, unsnapped)); value = on_level_grid(unsnapped); @@ -167,35 +222,44 @@ pub fn shared( .filter(|released| Some(*released) != reference); } if enabled && response.hovered() { - ui.ctx().set_cursor_icon(CursorIcon::ResizeVertical); + ui.ctx().set_cursor_icon(orientation.cursor()); } - paint(ui, rect, &range, value, enabled, &tokens); + paint(ui, rect, &range, value, enabled, orientation, &tokens); response.widget_info(|| WidgetInfo::slider(enabled, value, label)); let id = response.id; response.on_hover_text(texts::CONTROL_HINT); if dragging { - let y = value_y(rect, &range, value); - value_beside(ui, id, rect, y, &texts::readout(value, unit)); + let text = texts::readout(value, unit); + dragged_value(ui, id, rect, &range, value, orientation, &text); } released } -/// The value change for a drag of `points` (upwards positive): the fader's length is the -/// whole range. -pub fn drag_change(range: &RangeInclusive, points: f64, fine: bool) -> f64 { +/// The value change for a drag of `points` along a fader of `length`, which spans the whole +/// range. +fn drag_change(range: &RangeInclusive, points: f64, fine: bool, length: f32) -> f64 { let span = range.end() - range.start(); let divisor = if fine { FINE_DIVISOR } else { 1.0 }; - points / f64::from(FADER_HEIGHT) * span / divisor + points / f64::from(length) * span / divisor } fn on_level_grid(value: f64) -> f64 { (value / WHEEL_STEP_DB).round() * WHEEL_STEP_DB } -fn value_y(rect: Rect, range: &RangeInclusive, value: f64) -> f32 { +/// Where `value` sits along the fader: a y on an upright one, an x on a lying one. +fn value_position( + rect: Rect, + range: &RangeInclusive, + value: f64, + orientation: Orientation, +) -> f32 { let span = range.end() - range.start(); let position = ((value - range.start()) / span).clamp(0.0, 1.0) as f32; - rect.bottom() - position * rect.height() + match orientation { + Orientation::Vertical => rect.bottom() - position * rect.height(), + Orientation::Horizontal => rect.left() + position * rect.width(), + } } fn paint( @@ -204,20 +268,40 @@ fn paint( range: &RangeInclusive, value: f64, enabled: bool, + orientation: Orientation, tokens: &Tokens, ) { let painter = ui.painter(); - let track = Rect::from_center_size(rect.center(), vec2(TRACK_WIDTH, rect.height())); - painter.rect_filled(track, CornerRadius::same(TRACK_RADIUS), tokens.meter_track); - let y = value_y(rect, range, value); - let fill = Rect::from_min_max(egui::pos2(track.left(), y), track.right_bottom()); + let at = value_position(rect, range, value, orientation); let color = if enabled { tokens.accent } else { tokens.text_dim }; + let (track, fill, cap) = match orientation { + Orientation::Vertical => { + let track = Rect::from_center_size(rect.center(), vec2(TRACK_WIDTH, rect.height())); + let fill = Rect::from_min_max(egui::pos2(track.left(), at), track.right_bottom()); + let center = egui::pos2(rect.center().x, at); + ( + track, + fill, + Rect::from_center_size(center, vec2(CAP_WIDTH, CAP_HEIGHT)), + ) + } + Orientation::Horizontal => { + let track = Rect::from_center_size(rect.center(), vec2(rect.width(), TRACK_WIDTH)); + let fill = Rect::from_min_max(track.left_top(), egui::pos2(at, track.bottom())); + let center = egui::pos2(at, rect.center().y); + ( + track, + fill, + Rect::from_center_size(center, vec2(CAP_HEIGHT, CAP_WIDTH)), + ) + } + }; + painter.rect_filled(track, CornerRadius::same(TRACK_RADIUS), tokens.meter_track); painter.rect_filled(fill, CornerRadius::same(TRACK_RADIUS), color); - let cap = Rect::from_center_size(egui::pos2(rect.center().x, y), vec2(CAP_WIDTH, CAP_HEIGHT)); painter.rect( cap, CornerRadius::same(CAP_RADIUS), @@ -225,17 +309,46 @@ fn paint( Stroke::new(CAP_BORDER_WIDTH, tokens.border), StrokeKind::Inside, ); - painter.hline(cap.x_range(), y, Stroke::new(CAP_LINE_WIDTH, tokens.text)); + let line = Stroke::new(CAP_LINE_WIDTH, tokens.text); + match orientation { + Orientation::Vertical => painter.hline(cap.x_range(), at, line), + Orientation::Horizontal => painter.vline(at, cap.y_range(), line), + }; +} + +/// The value while dragging, by the handle: beside an upright fader, above a lying one, so the +/// pointer never covers it. +fn dragged_value( + ui: &Ui, + id: Id, + rect: Rect, + range: &RangeInclusive, + value: f64, + orientation: Orientation, + text: &str, +) { + let at = value_position(rect, range, value, orientation); + match orientation { + Orientation::Vertical => value_beside(ui, id, rect, at, text), + Orientation::Horizontal => { + let anchor = egui::pos2(at, rect.top() - VALUE_ABOVE_GAP); + floating_value(ui, id, anchor, Align2::CENTER_BOTTOM, text); + } + } } /// A value floating right of `rect` at height `y` while a control is being moved, where the /// eye is; fader and knob both use it. pub(super) fn value_beside(ui: &Ui, id: Id, rect: Rect, y: f32, text: &str) { let anchor = egui::pos2(rect.right() + HANDLE_LABEL_GAP, y); + floating_value(ui, id, anchor, Align2::LEFT_CENTER, text); +} + +fn floating_value(ui: &Ui, id: Id, anchor: egui::Pos2, pivot: Align2, text: &str) { Area::new(id.with(HANDLE_LABEL_ID)) .order(Order::Tooltip) .fixed_pos(anchor) - .pivot(Align2::LEFT_CENTER) + .pivot(pivot) .interactable(false) .show(ui.ctx(), |ui| { // One line: the area is narrow and would wrap the value letter by letter. diff --git a/crates/app/src/views/group_strip.rs b/crates/app/src/views/group_strip.rs index 408424e..54e3556 100644 --- a/crates/app/src/views/group_strip.rs +++ b/crates/app/src/views/group_strip.rs @@ -8,7 +8,7 @@ use eframe::egui::{ }; use neumann_control_core::settings::{GroupSettings, Linkable}; -use super::fader; +use super::fader::{self, Orientation, Scale}; use crate::groups::LINKABLE; use crate::texts; use crate::ui_kit::theme::Tokens; @@ -56,7 +56,7 @@ pub fn group( } } let label = texts::group_level(&group.name); - response.level = fader::shared(ui, &label, reference, range, unit); + response.level = fader::shared(ui, &label, reference, upright(range, unit)); let readout = reference.map(texts::shared_readout).unwrap_or_default(); ui.add(egui::Label::new(RichText::new(readout).monospace())); response.mute = mute_button(ui, &texts::group_mute(&group.name), muted); @@ -81,7 +81,8 @@ pub fn master( let mut response = MasterResponse::default(); frame(ui, tokens.accent, |ui| { ui.add(egui::Label::new(RichText::new(texts::MASTER).strong())); - response.level = fader::shared(ui, texts::MASTER_LEVEL.get(), reference, range, unit); + let label = texts::MASTER_LEVEL.get(); + response.level = fader::shared(ui, label, reference, upright(range, unit)); let readout = reference.map(texts::shared_readout).unwrap_or_default(); ui.add(egui::Label::new(RichText::new(readout).monospace())); response.mute = mute_button(ui, texts::MUTE_ALL.get(), muted); @@ -89,6 +90,14 @@ pub fn master( response } +fn upright(range: RangeInclusive, unit: &str) -> Scale<'_> { + Scale { + range, + unit, + orientation: Orientation::Vertical, + } +} + fn frame(ui: &mut Ui, color: Color32, contents: impl FnOnce(&mut Ui)) { let tokens = Tokens::current(ui.ctx()); let inner_width = STRIP_WIDTH - 2.0 * (f32::from(STRIP_MARGIN) + BORDER_WIDTH); @@ -106,7 +115,7 @@ fn frame(ui: &mut Ui, color: Color32, contents: impl FnOnce(&mut Ui)) { } /// Shows whether all are muted; a click asks for the opposite for all. -fn mute_button(ui: &mut Ui, label: &str, muted: Option) -> Option { +pub(super) fn mute_button(ui: &mut Ui, label: &str, muted: Option) -> Option { let all = muted.unwrap_or(false); let button = ui.add_enabled(muted.is_some(), Button::new(texts::MUTE).selected(all)); button.widget_info(|| WidgetInfo::selected(WidgetType::Button, muted.is_some(), all, label)); diff --git a/crates/app/src/views/meters.rs b/crates/app/src/views/meters.rs index 50aa7d8..7844a76 100644 --- a/crates/app/src/views/meters.rs +++ b/crates/app/src/views/meters.rs @@ -8,7 +8,9 @@ use eframe::egui::{ }; use serde_json::Value; -use crate::meter::{level_at, position, zone, MeterState, Zone}; +use crate::meter::{ + level_at, position, zone, MeterState, Zone, HOT_FROM_DB, METER_CEILING_DB, OVER_FROM_DB, +}; use crate::texts; use crate::ui_kit::theme::Tokens; @@ -29,6 +31,9 @@ const CHANNEL_NAME_HEIGHT: f32 = 12.0; const CHANNEL_NAME_SIZE: f32 = 9.0; const PLAYED_MARK_SPACE: f32 = 8.0; const PLAYED_MARK_RADIUS: f32 = 2.0; +const BAR_WIDTH: f32 = 72.0; +const BAR_HEIGHT: f32 = 6.0; +const BAR_RADIUS: u8 = 2; /// One group of meters, side by side. pub struct Meters<'a> { @@ -191,6 +196,33 @@ fn segment_rects(track: Rect, pixels_per_point: f32) -> Vec { .collect() } +/// One level lying in a compact row: the zones side by side, lit up to `db` and dimmed above, +/// like the strip's meters. +pub fn bar(ui: &mut Ui, db: Option, floor: f64, label: &str, tokens: &Tokens) { + let (rect, response) = ui.allocate_exact_size(vec2(BAR_WIDTH, BAR_HEIGHT), Sense::hover()); + let lit = db.map_or(0.0, |db| position(db, floor)); + let zones = [ + (Zone::Safe, floor, HOT_FROM_DB), + (Zone::Hot, HOT_FROM_DB, OVER_FROM_DB), + (Zone::Over, OVER_FROM_DB, METER_CEILING_DB), + ]; + let painter = ui.painter(); + let x = |at: f32| rect.left() + at * rect.width(); + for (zone, from, to) in zones { + let (start, end) = (position(from, floor), position(to, floor)); + let color = zone_color(zone, tokens); + let whole = Rect::from_x_y_ranges(x(start)..=x(end), rect.y_range()); + painter.rect_filled( + whole, + CornerRadius::same(BAR_RADIUS), + color.gamma_multiply(UNLIT_OPACITY), + ); + let shown = Rect::from_x_y_ranges(x(start)..=x(lit.clamp(start, end)), rect.y_range()); + painter.rect_filled(shown, CornerRadius::same(BAR_RADIUS), color); + } + response.widget_info(|| WidgetInfo::labeled(WidgetType::ProgressIndicator, true, label)); +} + fn zone_color(zone: Zone, tokens: &Tokens) -> Color32 { match zone { Zone::Safe => tokens.meter_safe, diff --git a/crates/app/src/views/mod.rs b/crates/app/src/views/mod.rs index bf0d287..939b148 100644 --- a/crates/app/src/views/mod.rs +++ b/crates/app/src/views/mod.rs @@ -1,5 +1,6 @@ pub mod add_address; pub mod all_values; +pub mod compact; mod details; mod details_fields; pub mod fader; diff --git a/crates/app/src/views/strip.rs b/crates/app/src/views/strip.rs index 0d70de7..0e744f0 100644 --- a/crates/app/src/views/strip.rs +++ b/crates/app/src/views/strip.rs @@ -11,7 +11,7 @@ use neumann_control_core::settings::DeskSettings; use neumann_control_core::speaker::Command; use serde_json::Value; -use super::fader::{self, FADER_HEIGHT}; +use super::fader::{self, Orientation, Scale, FADER_HEIGHT}; use super::knob; use super::meters::{self, Meters}; use crate::fader::{output_level_range, WHEEL_STEP_DB}; @@ -35,7 +35,7 @@ const GRIP_HEIGHT: f32 = GRIP_ROWS as f32 * GRIP_DOT_SPACING; const BORDER_WIDTH: f32 = 1.0; const LED_RADIUS: f32 = 3.5; const LED_SPACING: f32 = 6.0; -const UNREACHABLE_OPACITY: f32 = 0.55; +pub(super) const UNREACHABLE_OPACITY: f32 = 0.55; const METER_GROUP_GAP: f32 = 4.0; const METER_SPACING: f32 = 3.0; const CAPTION_HEIGHT: f32 = 16.0; @@ -131,13 +131,13 @@ fn grip(ui: &mut Ui, member: &str, tokens: &Tokens) { } } -fn name(ui: &mut Ui, speaker: &SpeakerState) -> Option { +pub(super) fn name(ui: &mut Ui, speaker: &SpeakerState) -> Option { let text = RichText::new(speaker.display_name()).strong(); let response = ui.add(Button::new(text).frame(false).truncate()); response.clicked().then(|| response.rect.left_bottom()) } -fn leds(ui: &mut Ui, speaker: &SpeakerState, tokens: &Tokens) { +pub(super) fn leds(ui: &mut Ui, speaker: &SpeakerState, tokens: &Tokens) { let state = status_leds(speaker); let connection = match state.connection { ConnectionLed::Connected => tokens.connected, @@ -214,7 +214,12 @@ fn level( let shown = ui .with_layout(Layout::left_to_right(Align::Min), |ui| { let range = output_level_range(); - let shown = fader::show(ui, speaker, catalog, commands, device, range, &unit); + let scale = Scale { + range, + unit: &unit, + orientation: Orientation::Vertical, + }; + let shown = fader::show(ui, speaker, catalog, commands, device, scale); meters(ui, speaker, desk, tokens); shown }) @@ -344,7 +349,7 @@ fn buttons( } /// A button showing a switch value of the speaker; a click asks for the opposite. -fn toggle( +pub(super) fn toggle( ui: &mut Ui, speaker: &mut SpeakerState, catalog: &Catalog, diff --git a/crates/app/src/views/toolbar.rs b/crates/app/src/views/toolbar.rs index 04fa516..1129917 100644 --- a/crates/app/src/views/toolbar.rs +++ b/crates/app/src/views/toolbar.rs @@ -1,5 +1,7 @@ -//! The top bar: "Suchen", "+ Adresse", the speaker count, a notice when something failed, and -//! at the right the average response time and "Einstellungen". +//! The top bar: "Suchen", "+ Adresse", "+ Gruppe", the speaker count, a notice when something +//! failed, and at the right the average response time, "Kompakt" and "Einstellungen". The +//! compact window is narrow, so there only the count, notice and the right side remain; adding +//! speakers and groups is the desk's job. use std::time::Duration; @@ -16,9 +18,11 @@ pub enum ToolbarAction { AddAddress, AddGroup, Settings, + ToggleCompact, } pub struct ToolbarFacts { + pub compact: bool, pub scanning: bool, pub speaker_count: usize, pub notice: Option, @@ -34,22 +38,10 @@ pub fn show(ui: &mut Ui, facts: &ToolbarFacts) -> ToolbarAction { fn controls(ui: &mut Ui, facts: &ToolbarFacts) -> ToolbarAction { let mut action = ToolbarAction::None; - if ui - .add_enabled(!facts.scanning, Button::new(texts::SCAN)) - .clicked() - { - action = ToolbarAction::Scan; - } - if facts.scanning { - ui.spinner(); - } - if ui.button(texts::ADD_ADDRESS).clicked() { - action = ToolbarAction::AddAddress; - } - if ui.button(texts::ADD_GROUP).clicked() { - action = ToolbarAction::AddGroup; + if !facts.compact { + action = adding(ui, facts); + ui.separator(); } - ui.separator(); ui.label(texts::speaker_count(facts.speaker_count)); if let Some(notice) = facts.notice { ui.separator(); @@ -59,6 +51,10 @@ fn controls(ui: &mut Ui, facts: &ToolbarFacts) -> ToolbarAction { if ui.button(texts::SETTINGS).clicked() { action = ToolbarAction::Settings; } + let compact = Button::new(texts::COMPACT).selected(facts.compact); + if ui.add(compact).on_hover_text(texts::COMPACT_HINT).clicked() { + action = ToolbarAction::ToggleCompact; + } if let Some(time) = facts.average_response_time { let text = texts::average_response_time(time.as_millis()); ui.label(RichText::new(text).monospace().weak()); @@ -66,3 +62,24 @@ fn controls(ui: &mut Ui, facts: &ToolbarFacts) -> ToolbarAction { }); action } + +/// "Suchen", "+ Adresse" and "+ Gruppe". +fn adding(ui: &mut Ui, facts: &ToolbarFacts) -> ToolbarAction { + let mut action = ToolbarAction::None; + if ui + .add_enabled(!facts.scanning, Button::new(texts::SCAN)) + .clicked() + { + action = ToolbarAction::Scan; + } + if facts.scanning { + ui.spinner(); + } + if ui.button(texts::ADD_ADDRESS).clicked() { + action = ToolbarAction::AddAddress; + } + if ui.button(texts::ADD_GROUP).clicked() { + action = ToolbarAction::AddGroup; + } + action +} diff --git a/crates/app/tests/compact.rs b/crates/app/tests/compact.rs new file mode 100644 index 0000000..ae77b31 --- /dev/null +++ b/crates/app/tests/compact.rs @@ -0,0 +1,88 @@ +//! The compact mode: switching there and back, one row per ungrouped speaker and per group, +//! and rows writing like the strips do. + +mod support; + +use eframe::egui::{self, accesskit::Role, Key, Modifiers}; +use egui_kittest::kittest::Queryable; +use egui_kittest::Harness; +use neumann_control_app::app::NeumannApp; +use neumann_control_app::texts; +use neumann_control_core::settings::{self, SETTINGS_FILE}; +use neumann_control_core::simulator::SimModel; +use serde_json::Value; +use support::*; + +fn press_compact_shortcut(harness: &mut Harness<'static, NeumannApp>) { + harness.key_press_modifiers(Modifiers::COMMAND, Key::K); + harness.run_steps(2); +} + +fn drag_level(harness: &mut Harness<'static, NeumannApp>, label: &str, by: f32) { + let start = harness + .get_by_role_and_label(Role::Slider, label) + .rect() + .center(); + harness.drag_at(start); + for frame in 1..=4 { + harness.hover_at(start + egui::vec2(by * frame as f32 / 4.0, 0.0)); + harness.step(); + } + harness.drop_at(start + egui::vec2(by, 0.0)); +} + +#[test] +fn the_shortcut_switches_to_compact_and_back_and_is_saved() { + let (_simulator, directory, mut harness) = connected_card(SimModel::Kh120Ii); + let path = directory.path().join(SETTINGS_FILE); + harness.get_by_label(texts::ADD_GROUP.get()); + press_compact_shortcut(&mut harness); + assert!(settings::load(&path).desk.compact); + assert!( + harness.query_by_label(texts::ADD_GROUP.get()).is_none(), + "the compact toolbar leaves adding to the desk" + ); + harness.get_by_label(KH120_DEVICE_NAME); + harness.get_by_label(texts::MASTER.get()); + harness.get_by_label(texts::COMPACT.get()).click(); + harness.run_steps(2); + assert!(!settings::load(&path).desk.compact); + harness.get_by_label(texts::ADD_GROUP.get()); +} + +#[test] +fn a_compact_row_writes_its_level_once_when_let_go() { + let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + press_compact_shortcut(&mut harness); + drag_level(&mut harness, texts::LEVEL.get(), -40.0); + wait_until(&mut harness, "a level set", |_| { + !level_sets(&simulator).is_empty() + }); + harness.run_steps(5); + barrier(&simulator); + let sets = level_sets(&simulator); + assert_eq!(sets.len(), 1, "{sets:?}"); + assert!(sets[0] < 100.0, "{sets:?}"); +} + +#[test] +fn a_group_has_one_row_instead_of_its_members() { + let (kh120, kh750, _directory, mut harness) = grouped(Vec::new()); + press_compact_shortcut(&mut harness); + assert!(harness.query_by_label(KH120_DEVICE_NAME).is_none()); + assert!(harness.query_by_label(KH750_DEVICE_NAME).is_none()); + harness.get_by_label(GROUP_NAME); + harness.get_by_label(&texts::group_mute(GROUP_NAME)).click(); + wait_until(&mut harness, "group mute", |_| { + sets(&kh120, "/audio/out/mute") == [Value::Bool(true)] + && sets(&kh750, "/audio/out/mute") == [Value::Bool(true)] + }); + drag_level(&mut harness, &texts::group_level(GROUP_NAME), -20.0); + wait_until(&mut harness, "both level sets", |_| { + !sets(&kh120, "/audio/out/level").is_empty() && !sets(&kh750, "/audio/out/level").is_empty() + }); + assert_eq!( + sets(&kh120, "/audio/out/level"), + sets(&kh750, "/audio/out/level") + ); +} diff --git a/crates/app/tests/groups.rs b/crates/app/tests/groups.rs index ac4f4c1..b35f964 100644 --- a/crates/app/tests/groups.rs +++ b/crates/app/tests/groups.rs @@ -8,64 +8,11 @@ use egui_kittest::kittest::Queryable; use egui_kittest::Harness; use neumann_control_app::app::NeumannApp; use neumann_control_app::texts; -use neumann_control_core::settings::{self, GroupSettings, Linkable, Settings, SETTINGS_FILE}; -use neumann_control_core::simulator::{SimModel, Simulator}; +use neumann_control_core::settings::{self, Linkable, SETTINGS_FILE}; +use neumann_control_core::simulator::SimModel; use serde_json::Value; use support::*; -const KH120_ID: &str = "serial:SIM0001234"; -const KH750_ID: &str = "serial:SIM0007500"; -const GROUP_NAME: &str = "Front"; - -/// A KH 120 II and a KH 750 in one group "Front", linking `linked`, both connected. -fn grouped( - linked: Vec, -) -> ( - Simulator, - Simulator, - tempfile::TempDir, - Harness<'static, NeumannApp>, -) { - let kh120 = Simulator::start(SimModel::Kh120Ii); - let kh750 = Simulator::start(SimModel::Kh750); - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join(SETTINGS_FILE); - let mut first = manual_for(&kh120); - first.serial = Some("SIM0001234".to_owned()); - let mut second = manual_for(&kh750); - second.serial = Some("SIM0007500".to_owned()); - let settings = Settings { - manual: vec![first, second], - groups: vec![GroupSettings { - name: GROUP_NAME.to_owned(), - color: 0, - members: vec![KH120_ID.to_owned(), KH750_ID.to_owned()], - linked, - }], - ..Settings::default() - }; - settings::save(&settings, &path).unwrap(); - let mut harness = harness(path, Vec::new()); - harness.set_size(egui::vec2(1200.0, 900.0)); - wait_until(&mut harness, "both strips connected", |harness| { - harness.query_by_label(KH120_DEVICE_NAME).is_some() - && harness.query_by_label(KH750_DEVICE_NAME).is_some() - && harness.query_all_by_label(&readout(100.0)).count() == 2 - }); - wait_for_initial_read(&mut harness, &kh120); - wait_for_initial_read(&mut harness, &kh750); - (kh120, kh750, directory, harness) -} - -fn sets(simulator: &Simulator, pointer: &str) -> Vec { - simulator - .requests() - .iter() - .filter_map(|request| request.pointer(pointer).filter(|value| !value.is_null())) - .cloned() - .collect() -} - #[test] fn group_fader_moves_every_member_by_the_same_amount() { let (kh120, kh750, _directory, mut harness) = grouped(Vec::new()); diff --git a/crates/app/tests/support/mod.rs b/crates/app/tests/support/mod.rs index d9b50a7..e34a017 100644 --- a/crates/app/tests/support/mod.rs +++ b/crates/app/tests/support/mod.rs @@ -10,6 +10,7 @@ use std::sync::Arc; use std::thread; use std::time::{Duration, Instant}; +use eframe::egui; use egui_kittest::kittest::Queryable; use egui_kittest::Harness; use neumann_control_app::app::NeumannApp; @@ -17,7 +18,9 @@ use neumann_control_app::spawner::{ScanResult, SpeakerSpawner}; use neumann_control_app::texts; use neumann_control_core::catalog::Catalog; use neumann_control_core::discovery::Found; -use neumann_control_core::settings::{self, ManualSpeaker, Settings, SETTINGS_FILE}; +use neumann_control_core::settings::{ + self, GroupSettings, Linkable, ManualSpeaker, Settings, SETTINGS_FILE, +}; use neumann_control_core::simulator::{SimModel, Simulator}; use neumann_control_core::speaker::{self, Events, SpeakerHandle, Target, Timing}; use serde_json::Value; @@ -234,3 +237,56 @@ pub fn open_panel(harness: &mut Harness<'static, NeumannApp>, name: &str) { }); harness.run_steps(1); } + +pub const KH120_ID: &str = "serial:SIM0001234"; +pub const KH750_ID: &str = "serial:SIM0007500"; +pub const GROUP_NAME: &str = "Front"; + +/// A KH 120 II and a KH 750 in one group "Front", linking `linked`, both connected. +pub fn grouped( + linked: Vec, +) -> ( + Simulator, + Simulator, + tempfile::TempDir, + Harness<'static, NeumannApp>, +) { + let kh120 = Simulator::start(SimModel::Kh120Ii); + let kh750 = Simulator::start(SimModel::Kh750); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(SETTINGS_FILE); + let mut first = manual_for(&kh120); + first.serial = Some("SIM0001234".to_owned()); + let mut second = manual_for(&kh750); + second.serial = Some("SIM0007500".to_owned()); + let settings = Settings { + manual: vec![first, second], + groups: vec![GroupSettings { + name: GROUP_NAME.to_owned(), + color: 0, + members: vec![KH120_ID.to_owned(), KH750_ID.to_owned()], + linked, + }], + ..Settings::default() + }; + settings::save(&settings, &path).unwrap(); + let mut harness = harness(path, Vec::new()); + harness.set_size(egui::vec2(1200.0, 900.0)); + wait_until(&mut harness, "both strips connected", |harness| { + harness.query_by_label(KH120_DEVICE_NAME).is_some() + && harness.query_by_label(KH750_DEVICE_NAME).is_some() + && harness.query_all_by_label(&readout(100.0)).count() == 2 + }); + wait_for_initial_read(&mut harness, &kh120); + wait_for_initial_read(&mut harness, &kh750); + (kh120, kh750, directory, harness) +} + +pub fn sets(simulator: &Simulator, pointer: &str) -> Vec { + simulator + .requests() + .iter() + .filter_map(|request| request.pointer(pointer).filter(|value| !value.is_null())) + .cloned() + .collect() +} diff --git a/crates/core/src/settings.rs b/crates/core/src/settings.rs index e1c9514..938ca36 100644 --- a/crates/core/src/settings.rs +++ b/crates/core/src/settings.rs @@ -28,6 +28,36 @@ pub struct Settings { pub order: Vec, } +impl Settings { + /// The window as the current mode, desk or compact, last had it. + pub fn window_of_mode(&self) -> Option { + if self.desk.compact { + self.compact_window + } else { + self.window + } + } + + /// Keeps `geometry` for the current mode. + pub fn remember_window(&mut self, geometry: WindowGeometry) { + if self.desk.compact { + self.compact_window = Some(geometry); + } else { + self.window = Some(geometry); + } + } + + /// Leaves the current mode, keeping its window `current`, and returns the window the other + /// mode last had. + pub fn switch_mode(&mut self, current: Option) -> Option { + if let Some(current) = current { + self.remember_window(current); + } + self.desk.compact = !self.desk.compact; + self.window_of_mode() + } +} + /// A group of speakers with a group strip; a speaker is in at most one group. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct GroupSettings { diff --git a/crates/core/tests/settings.rs b/crates/core/tests/settings.rs index f61e16a..ce4c9e9 100644 --- a/crates/core/tests/settings.rs +++ b/crates/core/tests/settings.rs @@ -178,3 +178,29 @@ fn groups_and_order_round_trip() { save(&settings, &path).unwrap(); assert_eq!(load(&path), settings); } + +#[test] +fn each_mode_keeps_its_own_window() { + let desk = WindowGeometry { + x: 0.0, + y: 0.0, + width: 900.0, + height: 600.0, + }; + let compact = WindowGeometry { + width: 480.0, + height: 360.0, + ..desk + }; + let mut settings = Settings::default(); + assert_eq!( + settings.switch_mode(Some(desk)), + None, + "compact never had a window" + ); + assert!(settings.desk.compact); + assert_eq!(settings.switch_mode(Some(compact)), Some(desk)); + assert!(!settings.desk.compact); + assert_eq!(settings.switch_mode(None), Some(compact)); + assert_eq!(settings.window_of_mode(), Some(compact)); +} From 616bc232e939035534ae2111929ac6552527d925 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:06:11 +0200 Subject: [PATCH 093/460] Keep compact rows one line high --- crates/app/src/views/compact.rs | 6 ++---- crates/app/tests/compact.rs | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/crates/app/src/views/compact.rs b/crates/app/src/views/compact.rs index 1fd213a..4a56cd3 100644 --- a/crates/app/src/views/compact.rs +++ b/crates/app/src/views/compact.rs @@ -177,10 +177,8 @@ fn row(ui: &mut Ui, border: Color32, contents: impl FnOnce(&mut Ui) -> R) -> .stroke(Stroke::new(BORDER_WIDTH, border)) .corner_radius(CornerRadius::same(ROW_RADIUS)) .inner_margin(ROW_MARGIN) - .show(ui, |ui| { - ui.with_layout(Layout::left_to_right(Align::Center), contents) - .inner - }) + // `horizontal` starts one line high; a centred layout would fill all the height left. + .show(ui, |ui| ui.horizontal(contents).inner) .inner } diff --git a/crates/app/tests/compact.rs b/crates/app/tests/compact.rs index ae77b31..9446c74 100644 --- a/crates/app/tests/compact.rs +++ b/crates/app/tests/compact.rs @@ -86,3 +86,20 @@ fn a_group_has_one_row_instead_of_its_members() { sets(&kh750, "/audio/out/level") ); } + +#[test] +fn compact_rows_are_one_line_high_and_follow_each_other() { + let (_kh120, _kh750, _directory, mut harness) = grouped(Vec::new()); + press_compact_shortcut(&mut harness); + let center = |harness: &Harness<'static, NeumannApp>, label: &str| { + harness + .get_by_role_and_label(Role::Slider, label) + .rect() + .center() + .y + }; + let group = center(&harness, &texts::group_level(GROUP_NAME)); + let master = center(&harness, texts::MASTER_LEVEL.get()); + // Neighbouring rows: their faders are about one row apart, not a window height. + assert!(master - group < 60.0, "group {group}, master {master}"); +} From 46d8f7aecfc131f730f5385e86c35cc8849c69c9 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:39:08 +0200 Subject: [PATCH 094/460] Write only values a gesture chose A click, a typed value outside the range, a wheel step of a hidden control or a scrolled panel no longer reach the speaker; group moves count from the drag start and can pass the loudest member's limit. --- crates/app/src/app/compact.rs | 23 ++-- crates/app/src/app/desk.rs | 52 +++----- crates/app/src/gesture.rs | 105 ++++++++++++++- crates/app/src/groups.rs | 30 +++++ crates/app/src/texts.rs | 7 + crates/app/src/views/compact.rs | 28 ++-- crates/app/src/views/details_fields.rs | 13 +- crates/app/src/views/fader.rs | 164 +++++++++++++---------- crates/app/src/views/group_strip.rs | 32 ++--- crates/app/src/views/knob.rs | 59 ++++----- crates/app/src/views/strip.rs | 7 +- crates/app/src/writes.rs | 4 + crates/app/tests/details.rs | 18 +++ crates/app/tests/support/mod.rs | 31 +++++ crates/app/tests/views.rs | 31 ----- crates/app/tests/write_gestures.rs | 176 +++++++++++++++++++++++++ 16 files changed, 550 insertions(+), 230 deletions(-) create mode 100644 crates/app/tests/write_gestures.rs diff --git a/crates/app/src/app/compact.rs b/crates/app/src/app/compact.rs index b4cd291..852d36c 100644 --- a/crates/app/src/app/compact.rs +++ b/crates/app/src/app/compact.rs @@ -4,10 +4,10 @@ use eframe::egui::{Context, ScrollArea, Ui, ViewportCommand}; use neumann_control_core::catalog::Catalog; -use super::desk::{all_muted, level_unit, levels, members_of, DeskAction}; +use super::desk::{all_muted, levels, members_of, DeskAction}; use super::{NeumannApp, OpenPanel, COMPACT_SCROLL_ID, DESK_WINDOW_SIZE}; use crate::fader::output_level_range; -use crate::groups::{compact_rows, layout, reference_level, CompactRow}; +use crate::groups::{compact_rows, layout, reference_level, shared_level, CompactRow}; use crate::texts; use crate::ui_kit::theme::group_color; use crate::views::compact::{self, SharedRow}; @@ -26,7 +26,6 @@ impl NeumannApp { let ids = self.member_ids(); let items = layout(&self.settings.order, &self.settings.groups, &ids); let rows = compact_rows(&items, &self.settings.groups, &ids); - let unit = level_unit(catalog); let floor = desk.meter_floor.db(); let now = ui.input(|input| input.time); let mut actions = Vec::new(); @@ -58,7 +57,7 @@ impl NeumannApp { CompactRow::Group(group) => { let settings = &self.settings.groups[group]; let members = members_of(settings, &ids); - let shown = self.shared_row(&members, &unit, floor, now); + let shown = self.shared_row(&members, floor, now); let color = group_color(settings.color); let response = ui .push_id((texts::GROUP, group), |ui| { @@ -70,7 +69,7 @@ impl NeumannApp { } } let everyone: Vec = (0..self.speakers.len()).collect(); - let shown = self.shared_row(&everyone, &unit, floor, now); + let shown = self.shared_row(&everyone, floor, now); actions.push(DeskAction::Master(compact::master(ui, shown))); }); for action in actions { @@ -78,24 +77,18 @@ impl NeumannApp { } } - fn shared_row<'a>( - &self, - members: &[usize], - unit: &'a str, - floor: f64, - now: f64, - ) -> SharedRow<'a> { + fn shared_row(&self, members: &[usize], floor: f64, now: f64) -> SharedRow { let states = self.states(members); let inputs: Vec = states .iter() .filter_map(|state| state.loudest_input(now)) .collect(); + let range = output_level_range(); SharedRow { - reference: reference_level(&levels(&states)), + level: shared_level(&levels(&states), &range), muted: all_muted(&states), loudest: reference_level(&inputs), - range: output_level_range(), - unit, + range, floor, } } diff --git a/crates/app/src/app/desk.rs b/crates/app/src/app/desk.rs index 7051087..0c3ce0f 100644 --- a/crates/app/src/app/desk.rs +++ b/crates/app/src/app/desk.rs @@ -13,7 +13,7 @@ use serde_json::Value; use super::{NeumannApp, OpenPanel, DESK_SCROLL_ID}; use crate::fader::output_level_range; use crate::groups::{ - insertion_slot, layout, linkable_of, linked_path, member_id, order_of, reference_level, + insertion_slot, layout, linkable_of, linked_path, member_id, order_of, shared_level, shifted_levels, with_moved, DeskItem, }; use crate::paths; @@ -61,7 +61,6 @@ impl NeumannApp { }; let mut centers = HashMap::new(); let range = output_level_range(); - let unit = level_unit(catalog); let mut actions = Vec::new(); ScrollArea::both() .id_salt(DESK_SCROLL_ID) @@ -107,15 +106,14 @@ impl NeumannApp { let settings = &self.settings.groups[group]; let members = members_of(settings, &ids); let states = self.states(&members); - let reference = reference_level(&levels(&states)); + let level = shared_level(&levels(&states), &range); let muted = all_muted(&states); let color = group_color(settings.color); let response = ui .push_id((texts::GROUP, group), |ui| { let range = range.clone(); - group_strip::group( - ui, settings, color, reference, muted, range, &unit, - ) + let level = level.as_ref(); + group_strip::group(ui, settings, color, level, muted, range) }) .inner; actions.push(DeskAction::Group(group, response)); @@ -124,9 +122,9 @@ impl NeumannApp { } let everyone: Vec = (0..self.speakers.len()).collect(); let states = self.states(&everyone); - let reference = reference_level(&levels(&states)); + let level = shared_level(&levels(&states), &range); let muted = all_muted(&states); - let response = group_strip::master(ui, reference, muted, range.clone(), &unit); + let response = group_strip::master(ui, level.as_ref(), muted, range.clone()); actions.push(DeskAction::Master(response)); }); }); @@ -179,30 +177,28 @@ impl NeumannApp { } } - /// A group or master fader let go at `level`: every connected member moves by the same - /// amount within its own range; a mute switch sets mute on all of them. + /// A group or master fader let go after moving by `moved` dB: every connected member moves by + /// that much from its own current level, within its own range; a mute switch sets mute on all + /// of them. fn apply_shared( &mut self, members: &[usize], - level: Option, + moved: Option, mute: Option, catalog: &Catalog, now: f64, ) { - if let Some(level) = level { - let states = self.states(members); - if let Some(reference) = reference_level(&levels(&states)) { - let range = output_level_range(); - let current: Vec<(usize, f64, _)> = members - .iter() - .filter_map(|index| { - level_of(&self.speakers[*index].state) - .map(|level| (*index, level, range.clone())) - }) - .collect(); - for (index, shifted) in shifted_levels(¤t, level - reference) { - self.request_and_send(index, Intent::Level(shifted), catalog, now); - } + if let Some(moved) = moved { + let range = output_level_range(); + let current: Vec<(usize, f64, _)> = members + .iter() + .filter_map(|index| { + level_of(&self.speakers[*index].state) + .map(|level| (*index, level, range.clone())) + }) + .collect(); + for (index, shifted) in shifted_levels(¤t, moved) { + self.request_and_send(index, Intent::Level(shifted), catalog, now); } } if let Some(mute) = mute { @@ -406,9 +402,3 @@ pub(super) fn all_muted(states: &[&SpeakerState]) -> Option { .collect(); (!mutes.is_empty()).then(|| mutes.iter().all(|muted| *muted)) } - -pub(super) fn level_unit(catalog: &Catalog) -> String { - paths::catalog_entry(catalog, &paths::path(paths::OUTPUT_LEVEL)) - .and_then(|entry| entry.unit.clone()) - .unwrap_or_default() -} diff --git a/crates/app/src/gesture.rs b/crates/app/src/gesture.rs index 8cdd6d0..b7f05d3 100644 --- a/crates/app/src/gesture.rs +++ b/crates/app/src/gesture.rs @@ -1,9 +1,13 @@ //! What a fader or knob shows and sends between gestures: wheel steps collect until the wheel //! goes quiet, then one write goes out; a sent value is shown until the speaker confirms it. -//! Times are seconds of the UI clock. +//! Also the rules every control shares for what a gesture may write: a drag writes only when it +//! changed the value, a move never leads further out of the range, and a typed value is taken +//! as typed (the write policy refuses one outside the range). Times are seconds of the UI clock. use std::ops::RangeInclusive; +use neumann_control_core::catalog::Conversion; + pub const WHEEL_IDLE_SECONDS: f64 = 0.3; /// egui may wake a frame about 16 ms before the requested time; within this much of the /// deadline the wheel value counts as due instead of booking another tiny wait. @@ -12,6 +16,10 @@ pub const DUE_SLACK_SECONDS: f64 = 0.05; pub const SENT_HOLD_SECONDS: f64 = 2.0; /// Fractions of a notch are counted in whole units so ten tenths make exactly one notch. const NOTCH_RESOLUTION: f64 = 1000.0; +/// A drawn control asks for its wheel step about `WHEEL_IDLE_SECONDS` after the last notch. Asked +/// this much later, the control was hidden meanwhile (panel closed, mode switched), and the step +/// is dropped instead of being written without a gesture. +const STALE_WHEEL_SECONDS: f64 = 1.0; #[derive(Clone, Copy, Debug, Default)] pub struct GestureState { @@ -45,7 +53,9 @@ impl GestureState { } let base = self.pending.unwrap_or(current); let target = base + whole as f64 * step; - self.pending = Some(target.clamp(*range.start(), *range.end())); + if let Some(moved) = bounded_move(base, target, &range) { + self.pending = Some(moved); + } } /// Wheel steps wait to be sent. @@ -54,6 +64,9 @@ impl GestureState { } pub fn due(&mut self, now: f64) -> Option { + if now - self.last_step > STALE_WHEEL_SECONDS { + self.pending = None; + } let idle = now - self.last_step >= WHEEL_IDLE_SECONDS - DUE_SLACK_SECONDS; if self.pending.is_some() && idle { return self.pending.take(); @@ -94,6 +107,38 @@ impl GestureState { } } +/// Where a move from `start` to `target` may land in `range`. From inside, a move past an end +/// stops there. From a device value outside the range, only a move back toward the range counts +/// and lands at its near end; a move further out goes nowhere. +pub fn bounded_move(start: f64, target: f64, range: &RangeInclusive) -> Option { + let inside = range.contains(&start); + let inward = distance(target, range) < distance(start, range); + (inside || inward).then(|| target.clamp(*range.start(), *range.end())) +} + +fn distance(value: f64, range: &RangeInclusive) -> f64 { + (range.start() - value).max(value - range.end()).max(0.0) +} + +/// What a drag from `start` to `end` writes: the end on the control's grid, and only when that +/// differs from the start on the grid, so a click or a drag back to the start writes nothing. +pub fn released(start: f64, end: f64, step: f64) -> Option { + let end = on_grid(end, step); + (end != on_grid(start, step)).then_some(end) +} + +pub fn on_grid(value: f64, step: f64) -> f64 { + (value / step).round() * step +} + +/// A value typed into a control: a point or a comma as decimal separator, converted from the unit +/// it was typed in. Not fitted to the range: the write policy refuses a value outside it and says +/// why, so a typo never becomes the range's end. +pub fn typed_value(text: &str, conversion: Option) -> Option { + let typed: f64 = text.trim().replace(',', ".").parse().ok()?; + Some(conversion.map_or(typed, |conversion| conversion.invert(typed))) +} + #[cfg(test)] mod tests { use super::*; @@ -195,9 +240,59 @@ mod tests { assert_eq!(knob.due(3.0), Some(7.0)); } + const DELAY_RANGE: RangeInclusive = 0.0..=5760.0; + + #[test] + fn a_wheel_step_whose_control_was_not_drawn_in_time_is_dropped() { + let mut fader = GestureState::default(); + fader.on_wheel(1.0, LEVEL_STEP, 80.0, LEVEL_RANGE, 1.0); + // A drawn control asks within the idle time; much later it was hidden meanwhile. + assert_eq!(fader.due(4.0), None); + assert_eq!(fader.deadline(), None); + } + + #[test] + fn a_drag_outside_the_range_only_moves_back_into_it() { + assert_eq!(bounded_move(6000.0, 6100.0, &DELAY_RANGE), None); + assert_eq!(bounded_move(6000.0, 5900.0, &DELAY_RANGE), Some(5760.0)); + assert_eq!(bounded_move(5000.0, 6100.0, &DELAY_RANGE), Some(5760.0)); + assert_eq!(bounded_move(5000.0, 4000.0, &DELAY_RANGE), Some(4000.0)); + } + + #[test] + fn a_drag_writes_only_what_it_changed_on_the_grid() { + assert_eq!(released(90.3, 90.3, LEVEL_STEP), None, "a click"); + assert_eq!( + released(90.3, 90.4, LEVEL_STEP), + None, + "still 90.5 on the grid" + ); + assert_eq!(released(90.3, 91.1, LEVEL_STEP), Some(91.0)); + } + + #[test] + fn a_typed_value_is_taken_as_typed_and_converted() { + assert_eq!(typed_value(" 87,25 ", None), Some(87.25)); + assert_eq!(typed_value("1000", None), Some(1000.0)); + assert_eq!( + typed_value("10", Some(Conversion::SamplesToMilliseconds)), + Some(480.0) + ); + assert_eq!(typed_value("laut", None), None); + } + #[test] - fn knob_shows_out_of_range_value_without_writing() { - let knob = GestureState::default(); - assert_eq!(knob.shown(6000.0, 0.0), 6000.0); + fn sent_values_sit_on_the_step_grid() { + assert_eq!(on_grid(12.4, 1.0), 12.0); + assert_eq!(on_grid(1.26, 0.5), 1.5); + } + + #[test] + fn a_wheel_outside_the_range_only_moves_back_into_it() { + let mut knob = GestureState::default(); + knob.on_wheel(1.0, 58.0, 6000.0, DELAY_RANGE, 0.0); + assert_eq!(knob.due(0.5), None, "a step further out writes nothing"); + knob.on_wheel(-1.0, 58.0, 6000.0, DELAY_RANGE, 1.0); + assert_eq!(knob.due(1.5), Some(5760.0)); } } diff --git a/crates/app/src/groups.rs b/crates/app/src/groups.rs index 4df5f38..6186bda 100644 --- a/crates/app/src/groups.rs +++ b/crates/app/src/groups.rs @@ -191,6 +191,27 @@ pub fn reference_level(levels: &[f64]) -> Option { levels.iter().copied().reduce(f64::max) } +/// What a group or master fader shows and how far it can be pushed. +#[derive(Clone, Debug, PartialEq)] +pub struct SharedLevel { + /// The loudest member's level. + pub reference: f64, + /// Down until the loudest member reaches the bottom of `range`; up until the quietest + /// reaches its top, so the others still follow while the loudest stays at its limit. + pub reach: RangeInclusive, +} + +/// None while no member reports a level. +pub fn shared_level(levels: &[f64], range: &RangeInclusive) -> Option { + let reference = reference_level(levels)?; + let quietest = levels.iter().copied().reduce(f64::min)?; + let top = reference + (range.end() - quietest); + Some(SharedLevel { + reference, + reach: *range.start()..=top, + }) +} + /// Every member moved by `delta` dB within its own range; members that would not change are /// left out, so nothing is written for them. pub fn shifted_levels( @@ -313,6 +334,15 @@ mod tests { ); } + #[test] + fn a_shared_fader_reaches_as_far_as_the_quietest_member_can_follow() { + let range = 0.0..=120.0; + let level = shared_level(&[120.0, 90.0], &range).unwrap(); + assert_eq!(level.reference, 120.0); + assert_eq!(level.reach, 0.0..=150.0); + assert_eq!(shared_level(&[], &range), None); + } + #[test] fn a_member_at_its_limit_stays_there() { let members = [("a", 118.0, 0.0..=120.0), ("b", 120.0, 0.0..=120.0)]; diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index f76eefc..74430a9 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -128,6 +128,8 @@ pub const CONTROL_HINT: Text = Text::new( "Shift: fein · Doppelklick: Wert eingeben", "Shift: fine · Double-click: type a value", ); +/// Faders take no typed value themselves (the strip's readout below does). +pub const FADER_HINT: Text = Text::new("Shift: fein", "Shift: fine"); pub const PANEL_TITLE: Text = Text::new("Lautsprecher", "Speaker"); pub const SETTINGS: Text = Text::new("Einstellungen", "Settings"); pub const COMPACT: Text = Text::new("Kompakt", "Compact"); @@ -301,6 +303,11 @@ pub fn response_time(milliseconds: u128) -> String { format!("{milliseconds} ms") } +/// How far a group or master fader has moved while it is dragged. +pub fn level_move(moved: f64) -> String { + format!("{moved:+.1} dB") +} + /// A group's or the master's level, without unit: it moves several speakers by the same amount. pub fn shared_readout(value: f64) -> String { format!("{value:.1}") diff --git a/crates/app/src/views/compact.rs b/crates/app/src/views/compact.rs index 4a56cd3..ba00c76 100644 --- a/crates/app/src/views/compact.rs +++ b/crates/app/src/views/compact.rs @@ -13,10 +13,11 @@ use neumann_control_core::speaker::Command; use serde_json::Value; use super::fader::{self, Orientation, Scale}; -use super::group_strip::{mute_button, GroupStripResponse, MasterResponse}; +use super::group_strip::{mute_button, shared_readout, GroupStripResponse, MasterResponse}; use super::meters; use super::strip::{self, UNREACHABLE_OPACITY}; use crate::fader::output_level_range; +use crate::groups::SharedLevel; use crate::paths; use crate::state::{SpeakerState, Status}; use crate::texts; @@ -85,14 +86,13 @@ pub fn speaker( } } -/// What a group or master row shows: the loudest connected member's level as reference, whether -/// all are muted, and the loudest input among them. -pub struct SharedRow<'a> { - pub reference: Option, +/// What a group or master row shows: the level of the loudest connected member, whether all are +/// muted, and the loudest input among them. +pub struct SharedRow { + pub level: Option, pub muted: Option, pub loudest: Option, pub range: RangeInclusive, - pub unit: &'a str, pub floor: f64, } @@ -148,16 +148,10 @@ fn shared_controls( shown: SharedRow, tokens: &Tokens, ) -> (Option, Option) { - let scale = Scale { - range: shown.range, - unit: shown.unit, - orientation: Orientation::Horizontal, - }; - let level = fader::shared(ui, level_label, shown.reference, scale); - let readout = shown - .reference - .map(texts::shared_readout) - .unwrap_or_default(); + let level = shown.level.as_ref(); + let range = shown.range; + let moved = fader::shared(ui, level_label, level, range, Orientation::Horizontal); + let readout = shared_readout(level); cell(ui, READOUT_WIDTH, |ui| ui.monospace(readout)); meters::bar( ui, @@ -167,7 +161,7 @@ fn shared_controls( tokens, ); let mute = mute_button(ui, mute_label, shown.muted); - (level, mute) + (moved, mute) } fn row(ui: &mut Ui, border: Color32, contents: impl FnOnce(&mut Ui) -> R) -> R { diff --git a/crates/app/src/views/details_fields.rs b/crates/app/src/views/details_fields.rs index a51b18f..b98e0db 100644 --- a/crates/app/src/views/details_fields.rs +++ b/crates/app/src/views/details_fields.rs @@ -7,6 +7,7 @@ use neumann_control_core::speaker::Command; use serde_json::Value; use crate::display::{capped, converted_unit}; +use crate::gesture::{bounded_move, on_grid, typed_value}; use crate::state::SpeakerState; use crate::texts; use crate::writes::{self, Intent}; @@ -70,9 +71,10 @@ impl Fields<'_> { let held = ui.data(|data| data.get_temp::(pending_id)); let mut value = held.or(device).unwrap_or(entry.min.unwrap_or_default()); let step = entry.step.unwrap_or(UNSTEPPED_DRAG_SPEED); + // No range on the field: egui would clamp typed text to it, and a typo would reach the + // speaker as the range's end. A drag is kept inside below; a typed value goes to the write + // policy as typed and is refused with the reason when outside. let mut drag = DragValue::new(&mut value) - .range(min..=max) - .clamp_existing_to_range(false) .custom_formatter(|number, _| number.to_string()) .speed(step); if let Some(unit) = &entry.unit { @@ -84,8 +86,7 @@ impl Fields<'_> { drag = drag .custom_formatter(move |number, _| conversion.apply(number).to_string()) .custom_parser(move |text| { - let typed: f64 = text.trim().replace(',', ".").parse().ok()?; - Some((conversion.invert(typed) / step).round() * step) + typed_value(text, Some(conversion)).map(|typed| on_grid(typed, step)) }) .suffix(format!(" {}", converted_unit(conversion))); } @@ -95,6 +96,10 @@ impl Fields<'_> { ui.add_enabled(enabled, drag).labelled_by(label.id) }) .inner; + if response.dragged() { + let start = device.unwrap_or(value); + value = bounded_move(start, value, &(min..=max)).unwrap_or(start); + } let active = response.dragged() || response.has_focus(); let ended = response.drag_stopped() || response.lost_focus(); if !active && !ended { diff --git a/crates/app/src/views/fader.rs b/crates/app/src/views/fader.rs index dc200f3..b43f18f 100644 --- a/crates/app/src/views/fader.rs +++ b/crates/app/src/views/fader.rs @@ -12,6 +12,8 @@ use neumann_control_core::catalog::Catalog; use neumann_control_core::speaker::Command; use crate::fader::WHEEL_STEP_DB; +use crate::gesture::{self, bounded_move, on_grid}; +use crate::groups::SharedLevel; use crate::paths; use crate::state::SpeakerState; use crate::texts; @@ -21,6 +23,7 @@ use crate::writes::{self, Intent}; const DRAG_ABORTED_ID: &str = "fader_drag_aborted"; const HANDLE_LABEL_ID: &str = "fader_handle_label"; const DRAG_VALUE_ID: &str = "fader_drag_value"; +const DRAG_START_ID: &str = "fader_drag_start"; const FADER_WIDTH: f32 = 24.0; const TRACK_WIDTH: f32 = 4.0; const TRACK_RADIUS: u8 = 2; @@ -79,7 +82,7 @@ impl Orientation { } } -/// What a fader spans and how it lies. +/// What a speaker's fader spans and how it lies. pub struct Scale<'a> { pub range: RangeInclusive, pub unit: &'a str, @@ -116,39 +119,23 @@ pub fn show( Sense::hover() }; let (rect, response) = ui.allocate_exact_size(orientation.size(), sense); - let drag_id = response.id.with(DRAG_VALUE_ID); if response.drag_started() { speaker.fader.drag_started(); - ui.data_mut(|data| data.insert_temp(drag_id, value)); } - let aborted = drag_aborted(ui, &response); - let dragging = response.dragged() && !aborted; - if dragging { - let held = ui - .data(|data| data.get_temp::(drag_id)) - .unwrap_or(value); - let fine = ui.input(|input| input.modifiers.shift); - let points = orientation.along(response.drag_delta()); - let moved = drag_change(&range, points, fine, orientation.length()); - let unsnapped = (held + moved).clamp(*range.start(), *range.end()); - ui.data_mut(|data| data.insert_temp(drag_id, unsnapped)); - value = on_level_grid(unsnapped); + let drag = drag_value(ui, &response, value, &range, &range, orientation); + let dragging = drag.current.is_some(); + if let Some(current) = drag.current { + value = current; } - // The speaker hears nothing while dragging; the release sends the value once. - if response.drag_stopped() { - let dragged = ui.data(|data| data.get_temp::(drag_id)); - ui.data_mut(|data| data.remove::(drag_id)); - if let Some(dragged) = dragged.filter(|_| !aborted) { - value = on_level_grid(dragged); - commands.extend(speaker.request(Intent::Level(value), catalog, now)); - } + // The speaker hears nothing while dragging; the release sends the value once, if it moved. + if let Some(level) = drag.released { + value = level; + commands.extend(speaker.request(Intent::Level(level), catalog, now)); } // A held fader ignores the wheel: steps added now would overwrite the drag's value. let held = response.is_pointer_button_down_on() || response.dragged(); if enabled && response.hovered() && !held { ui.ctx().set_cursor_icon(orientation.cursor()); - // The strips sit in a scroll area, which would scroll along with the fader. - ui.input_mut(|input| input.smooth_scroll_delta = Vec2::ZERO); let wheel = wheel_notches(ui); for notches in &wheel { speaker @@ -162,7 +149,7 @@ pub fn show( paint(ui, rect, &range, value, enabled, orientation, &tokens); response.widget_info(|| WidgetInfo::slider(enabled, value, texts::LEVEL)); let id = response.id; - response.on_hover_text(texts::CONTROL_HINT); + response.on_hover_text(texts::FADER_HINT); if dragging { let text = texts::readout(value, unit); dragged_value(ui, id, rect, &range, value, orientation, &text); @@ -177,62 +164,104 @@ pub fn show( value } -/// A fader for several speakers at once (group, master): it shows `reference` and returns the -/// value it was let go at; the caller turns the move into one write per speaker. No wheel, so a -/// group move is always one deliberate gesture. -pub fn shared(ui: &mut Ui, label: &str, reference: Option, scale: Scale) -> Option { - let Scale { - range, - unit, - orientation, - } = scale; +/// A fader for several speakers at once (group, master): it shows the loudest member and +/// returns the move it was let go at, in dB from where the drag started; the caller moves every +/// member by it from its own current level. It reaches past the loudest member's limit as far as +/// the quietest can follow (`SharedLevel::reach`). No wheel, so a group move is always one +/// deliberate gesture. +pub fn shared( + ui: &mut Ui, + label: &str, + level: Option<&SharedLevel>, + range: RangeInclusive, + orientation: Orientation, +) -> Option { let tokens = Tokens::current(ui.ctx()); - let enabled = reference.is_some(); - let mut value = reference.unwrap_or(*range.start()); + let enabled = level.is_some(); + let reference = level.map_or(*range.start(), |level| level.reference); + let reach = level.map_or(range.clone(), |level| level.reach.clone()); let sense = if enabled { Sense::drag() } else { Sense::hover() }; let (rect, response) = ui.allocate_exact_size(orientation.size(), sense); - let drag_id = response.id.with(DRAG_VALUE_ID); + let drag = drag_value(ui, &response, reference, &range, &reach, orientation); + let value = drag.current.unwrap_or(reference); + if enabled && response.hovered() { + ui.ctx().set_cursor_icon(orientation.cursor()); + } + paint(ui, rect, &range, value, enabled, orientation, &tokens); + response.widget_info(|| WidgetInfo::slider(enabled, value, label)); + let id = response.id; + response.on_hover_text(texts::FADER_HINT); + if drag.current.is_some() { + let text = texts::level_move(value - reference); + dragged_value(ui, id, rect, &range, value, orientation, &text); + } + drag.released.map(|released| released - drag.started) +} + +/// A drag on a fader this frame: the value it shows while held, and the value it writes when let +/// go (only when it moved on the level grid; never after Escape). +struct Drag { + /// The value when the drag began; a group move is measured from it, not from the reference + /// of the release frame, which may have changed meanwhile. + started: f64, + current: Option, + released: Option, +} + +/// `start` is the value when the drag began, `range` what the fader's length spans, `reach` how +/// far the drag may go (beyond `range` for a group fader). +fn drag_value( + ui: &Ui, + response: &Response, + start: f64, + range: &RangeInclusive, + reach: &RangeInclusive, + orientation: Orientation, +) -> Drag { + let held_id = response.id.with(DRAG_VALUE_ID); + let start_id = response.id.with(DRAG_START_ID); if response.drag_started() { - ui.data_mut(|data| data.insert_temp(drag_id, value)); + ui.data_mut(|data| { + data.insert_temp(held_id, start); + data.insert_temp(start_id, start); + }); } - let aborted = drag_aborted(ui, &response); - let dragging = response.dragged() && !aborted; - if dragging { + let aborted = drag_aborted(ui, response); + let started = ui + .data(|data| data.get_temp::(start_id)) + .unwrap_or(start); + let mut current = None; + if response.dragged() && !aborted { let held = ui - .data(|data| data.get_temp::(drag_id)) - .unwrap_or(value); + .data(|data| data.get_temp::(held_id)) + .unwrap_or(start); let fine = ui.input(|input| input.modifiers.shift); let points = orientation.along(response.drag_delta()); - let moved = drag_change(&range, points, fine, orientation.length()); - let unsnapped = (held + moved).clamp(*range.start(), *range.end()); - ui.data_mut(|data| data.insert_temp(drag_id, unsnapped)); - value = on_level_grid(unsnapped); + let target = held + drag_change(range, points, fine, orientation.length()); + let moved = bounded_move(started, target, reach).unwrap_or(held); + ui.data_mut(|data| data.insert_temp(held_id, moved)); + current = Some(on_grid(moved, WHEEL_STEP_DB)); } let mut released = None; if response.drag_stopped() { - let dragged = ui.data(|data| data.get_temp::(drag_id)); - ui.data_mut(|data| data.remove::(drag_id)); + let dragged = ui.data(|data| data.get_temp::(held_id)); + ui.data_mut(|data| { + data.remove::(held_id); + data.remove::(start_id); + }); released = dragged .filter(|_| !aborted) - .map(on_level_grid) - .filter(|released| Some(*released) != reference); + .and_then(|dragged| gesture::released(started, dragged, WHEEL_STEP_DB)); } - if enabled && response.hovered() { - ui.ctx().set_cursor_icon(orientation.cursor()); - } - paint(ui, rect, &range, value, enabled, orientation, &tokens); - response.widget_info(|| WidgetInfo::slider(enabled, value, label)); - let id = response.id; - response.on_hover_text(texts::CONTROL_HINT); - if dragging { - let text = texts::readout(value, unit); - dragged_value(ui, id, rect, &range, value, orientation, &text); + Drag { + started, + current, + released, } - released } /// The value change for a drag of `points` along a fader of `length`, which spans the whole @@ -243,10 +272,6 @@ fn drag_change(range: &RangeInclusive, points: f64, fine: bool, length: f32 points / f64::from(length) * span / divisor } -fn on_level_grid(value: f64) -> f64 { - (value / WHEEL_STEP_DB).round() * WHEEL_STEP_DB -} - /// Where `value` sits along the fader: a y on an upright one, an x on a lying one. fn value_position( rect: Rect, @@ -374,8 +399,11 @@ pub(super) fn drag_aborted(ui: &Ui, response: &Response) -> bool { const POINTS_PER_NOTCH: f32 = 40.0; const NOTCHES_PER_PAGE: f32 = 10.0; -/// Each wheel event of this frame in notches (lines), upwards positive. +/// Each wheel event of this frame in notches (lines), upwards positive. Taken by the hovered +/// control: the scroll area around it would otherwise scroll as well and bring another control +/// under the pointer for the next notches. pub(super) fn wheel_notches(ui: &Ui) -> Vec { + ui.input_mut(|input| input.smooth_scroll_delta = Vec2::ZERO); ui.input(|input| { input .events diff --git a/crates/app/src/views/group_strip.rs b/crates/app/src/views/group_strip.rs index 54e3556..edb1e42 100644 --- a/crates/app/src/views/group_strip.rs +++ b/crates/app/src/views/group_strip.rs @@ -8,8 +8,8 @@ use eframe::egui::{ }; use neumann_control_core::settings::{GroupSettings, Linkable}; -use super::fader::{self, Orientation, Scale}; -use crate::groups::LINKABLE; +use super::fader::{self, Orientation}; +use crate::groups::{SharedLevel, LINKABLE}; use crate::texts; use crate::ui_kit::theme::Tokens; @@ -20,7 +20,7 @@ const BORDER_WIDTH: f32 = 1.0; #[derive(Default)] pub struct GroupStripResponse { - /// The level the group fader was let go at. + /// The move the group fader was let go at, in dB. pub level: Option, /// The mute every member should get. pub mute: Option, @@ -28,15 +28,14 @@ pub struct GroupStripResponse { pub open_settings: bool, } -/// `reference` is the loudest connected member, `muted` whether all of them are muted. +/// `level` follows the loudest connected member, `muted` says whether all of them are muted. pub fn group( ui: &mut Ui, group: &GroupSettings, color: Color32, - reference: Option, + level: Option<&SharedLevel>, muted: Option, range: RangeInclusive, - unit: &str, ) -> GroupStripResponse { let mut response = GroupStripResponse::default(); frame(ui, color, |ui| { @@ -56,8 +55,8 @@ pub fn group( } } let label = texts::group_level(&group.name); - response.level = fader::shared(ui, &label, reference, upright(range, unit)); - let readout = reference.map(texts::shared_readout).unwrap_or_default(); + response.level = fader::shared(ui, &label, level, range, Orientation::Vertical); + let readout = shared_readout(level); ui.add(egui::Label::new(RichText::new(readout).monospace())); response.mute = mute_button(ui, &texts::group_mute(&group.name), muted); }); @@ -72,30 +71,27 @@ pub struct MasterResponse { pub fn master( ui: &mut Ui, - reference: Option, + level: Option<&SharedLevel>, muted: Option, range: RangeInclusive, - unit: &str, ) -> MasterResponse { let tokens = Tokens::current(ui.ctx()); let mut response = MasterResponse::default(); frame(ui, tokens.accent, |ui| { ui.add(egui::Label::new(RichText::new(texts::MASTER).strong())); let label = texts::MASTER_LEVEL.get(); - response.level = fader::shared(ui, label, reference, upright(range, unit)); - let readout = reference.map(texts::shared_readout).unwrap_or_default(); + response.level = fader::shared(ui, label, level, range, Orientation::Vertical); + let readout = shared_readout(level); ui.add(egui::Label::new(RichText::new(readout).monospace())); response.mute = mute_button(ui, texts::MUTE_ALL.get(), muted); }); response } -fn upright(range: RangeInclusive, unit: &str) -> Scale<'_> { - Scale { - range, - unit, - orientation: Orientation::Vertical, - } +pub(super) fn shared_readout(level: Option<&SharedLevel>) -> String { + level + .map(|level| texts::shared_readout(level.reference)) + .unwrap_or_default() } fn frame(ui: &mut Ui, color: Color32, contents: impl FnOnce(&mut Ui)) { diff --git a/crates/app/src/views/knob.rs b/crates/app/src/views/knob.rs index fa982bc..a59722e 100644 --- a/crates/app/src/views/knob.rs +++ b/crates/app/src/views/knob.rs @@ -1,6 +1,6 @@ //! A rotary knob for a catalog number: turn it by moving the pointer around it (clockwise is -//! more), mouse wheel, double-click for exact entry. While it moves, the value floats beside it. It writes once at the end of a -//! gesture and shows a device value outside its range as is. +//! more), mouse wheel, double-click for exact entry. While it moves, the value floats beside it. +//! It writes once at the end of a gesture and shows a device value outside its range as is. use std::f32::consts::PI; use std::ops::RangeInclusive; @@ -16,7 +16,7 @@ use serde_json::Value; use super::fader::{drag_aborted, value_beside, wheel_notches, FINE_DIVISOR}; use crate::display::shown_value; -use crate::gesture::GestureState; +use crate::gesture::{bounded_move, released, typed_value, GestureState}; use crate::state::SpeakerState; use crate::texts; use crate::ui_kit::theme::Tokens; @@ -39,6 +39,7 @@ const INDICATOR_START: f32 = 0.3; const ARC_SEGMENTS: usize = 32; const DRAG_VALUE_ID: &str = "knob_drag_value"; const DRAG_ANGLE_ID: &str = "knob_drag_angle"; +const DRAG_START_ID: &str = "knob_drag_start"; const EDIT_TEXT_ID: &str = "knob_edit_text"; const EDIT_WIDTH: f32 = 56.0; /// Room for the value line under the knob. @@ -126,6 +127,7 @@ pub fn show(ui: &mut Ui, id: Id, knob: &Knob, state: &mut GestureState) -> Optio }; let (rect, response) = ui.allocate_exact_size(size, sense); let drag_id = id.with(DRAG_VALUE_ID); + let start_id = id.with(DRAG_START_ID); let angle_id = id.with(DRAG_ANGLE_ID); let pointer = ui.input(|input| input.pointer.interact_pos()); let angle = pointer.and_then(|pointer| pointer_angle(rect.center(), pointer)); @@ -134,10 +136,14 @@ pub fn show(ui: &mut Ui, id: Id, knob: &Knob, state: &mut GestureState) -> Optio state.drag_started(); ui.data_mut(|data| { data.insert_temp(drag_id, value); + data.insert_temp(start_id, value); data.remove::(angle_id); }); } let aborted = drag_aborted(ui, &response); + let started = ui + .data(|data| data.get_temp::(start_id)) + .unwrap_or(value); if response.dragged() && !aborted { let held = ui .data(|data| data.get_temp::(drag_id)) @@ -147,8 +153,8 @@ pub fn show(ui: &mut Ui, id: Id, knob: &Knob, state: &mut GestureState) -> Optio let turned = last .zip(angle) .map_or(0.0, |(last, angle)| angle_change(last, angle)); - let moved = turn_change(&knob.range, turned, fine); - value = (held + moved).clamp(*knob.range.start(), *knob.range.end()); + let target = held + turn_change(&knob.range, turned, fine); + value = bounded_move(started, target, &knob.range).unwrap_or(held); ui.data_mut(|data| { data.insert_temp(drag_id, value); // Inside the dead zone the last good angle stays the reference. @@ -159,10 +165,13 @@ pub fn show(ui: &mut Ui, id: Id, knob: &Knob, state: &mut GestureState) -> Optio } if response.drag_stopped() && !aborted { let dragged = ui.data(|data| data.get_temp::(drag_id)); - sent = dragged.map(|value| on_grid(value, knob.step)); + sent = dragged.and_then(|dragged| released(started, dragged, knob.step)); } if response.drag_stopped() { - ui.data_mut(|data| data.remove::(drag_id)); + ui.data_mut(|data| { + data.remove::(drag_id); + data.remove::(start_id); + }); } if enabled && response.hovered() { let wheel = wheel_notches(ui); @@ -200,8 +209,7 @@ pub fn show(ui: &mut Ui, id: Id, knob: &Knob, state: &mut GestureState) -> Optio ui.add(Label::new(RichText::new(knob.label).small().weak())); } let open_now = response.double_clicked(); - let range = &knob.range; - let edited = value_entry(ui, id, open_now, value, range, knob.step, knob.conversion); + let edited = value_entry(ui, id, open_now, value, knob.conversion); sent = sent.or(edited); if knob.caption && !value_entry_open(ui, id) { let text = RichText::new((knob.format)(value)).small().monospace(); @@ -210,15 +218,7 @@ pub fn show(ui: &mut Ui, id: Id, knob: &Knob, state: &mut GestureState) -> Optio response }) .inner; - response.on_hover_text(format!( - "{} -{}", - knob.label, - texts::CONTROL_HINT - )); - if let Some(value) = sent { - state.sent(value, now); - } + response.on_hover_text(format!("{}\n{}", knob.label, texts::CONTROL_HINT)); sent } @@ -259,20 +259,14 @@ pub fn wheel_step(range: &RangeInclusive, step: f64) -> f64 { steps * step } -pub fn on_grid(value: f64, step: f64) -> f64 { - (value / step).round() * step -} - -/// A text field for an exact value, open from `open_now` until Enter (returns the value, clamped -/// and on the step grid) or until Escape or leaving it (returns nothing). Knob and fader use it. -/// With a `conversion`, the text is in the converted unit (the delay in ms). +/// A text field for an exact value, open from `open_now` until Enter (returns the value as typed, +/// see `gesture::typed_value`) or until Escape or leaving it (returns nothing). Knob and fader +/// readout use it. With a `conversion`, the text is in the converted unit (the delay in ms). pub(super) fn value_entry( ui: &mut Ui, id: Id, open_now: bool, value: f64, - range: &RangeInclusive, - step: f64, conversion: Option, ) -> Option { let text_id = id.with(EDIT_TEXT_ID); @@ -299,10 +293,7 @@ pub(super) fn value_entry( if !entered { return None; } - let typed: f64 = text.trim().replace(',', ".").parse().ok()?; - let typed = conversion.map_or(typed, |conversion| conversion.invert(typed)); - let clamped = typed.clamp(*range.start(), *range.end()); - Some(on_grid(clamped, step)) + typed_value(&text, conversion) } pub(super) fn value_entry_open(ui: &Ui, id: Id) -> bool { @@ -402,10 +393,4 @@ mod tests { assert_eq!(wheel_step(&(1.0..=240.0), 1.0), 2.0); assert_eq!(wheel_step(&(0.0..=10.0), 1.0), 1.0); } - - #[test] - fn sent_values_sit_on_the_step_grid() { - assert_eq!(on_grid(12.4, 1.0), 12.0); - assert_eq!(on_grid(1.26, 0.5), 1.5); - } } diff --git a/crates/app/src/views/strip.rs b/crates/app/src/views/strip.rs index 0e744f0..d61ec5e 100644 --- a/crates/app/src/views/strip.rs +++ b/crates/app/src/views/strip.rs @@ -15,6 +15,7 @@ use super::fader::{self, Orientation, Scale, FADER_HEIGHT}; use super::knob; use super::meters::{self, Meters}; use crate::fader::{output_level_range, WHEEL_STEP_DB}; +use crate::gesture::on_grid; use crate::input_source::input_source; use crate::paths; use crate::state::{SpeakerState, Status}; @@ -231,10 +232,8 @@ fn level( let response = ui.add(label).on_hover_text(texts::CONTROL_HINT); let entry_id = response.id.with(LEVEL_ENTRY_ID); let open_now = response.double_clicked() && device.is_some(); - let range = output_level_range(); - if let Some(level) = - knob::value_entry(ui, entry_id, open_now, shown, &range, WHEEL_STEP_DB, None) - { + if let Some(level) = knob::value_entry(ui, entry_id, open_now, shown, None) { + let level = on_grid(level, WHEEL_STEP_DB); let now = ui.input(|input| input.time); commands.extend(speaker.request(Intent::Level(level), catalog, now)); } diff --git a/crates/app/src/writes.rs b/crates/app/src/writes.rs index b543a10..0677968 100644 --- a/crates/app/src/writes.rs +++ b/crates/app/src/writes.rs @@ -126,9 +126,13 @@ impl SpeakerState { .intended_value(&intent, &path) .and_then(|value| check(self, catalog, &path, &value).map(|()| value)); let value = checked.inspect_err(|refusal| log::warn!("not sending {path}: {refusal:?}"))?; + // A sent value stays on its control until the speaker reports it; a refused one does not. if let Intent::Level(level) = intent { self.fader.sent(level, now); } + if let (Some(knob), Some(number)) = (self.knobs.get_mut(&path), value.as_f64()) { + knob.sent(number, now); + } Ok(Command::Set { path, value }) } diff --git a/crates/app/tests/details.rs b/crates/app/tests/details.rs index 82b1b5f..b950b8d 100644 --- a/crates/app/tests/details.rs +++ b/crates/app/tests/details.rs @@ -7,6 +7,7 @@ use egui_kittest::kittest::{NodeT, Queryable}; use egui_kittest::Harness; use neumann_control_app::app::NeumannApp; use neumann_control_app::texts; +use neumann_control_app::writes::Refusal; use neumann_control_core::catalog::ValueError; use neumann_control_core::discovery::Found; use neumann_control_core::simulator::{SimModel, Simulator}; @@ -442,6 +443,23 @@ fn delay_is_typed_in_milliseconds_and_sent_in_samples() { assert_eq!(sets_to(&simulator, DELAY_PATH), vec![json!(480)]); } +#[test] +fn a_typed_number_outside_the_range_is_refused_not_clamped() { + let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); + // 500 ms are 24000 samples, far above the KH 120 II's 5760. + type_number(&mut harness, DELAY_LABEL, "500"); + harness.key_press(egui::Key::Enter); + harness.run_steps(IDLE_FRAMES); + barrier(&simulator); + let sets = sets_to(&simulator, DELAY_PATH); + assert!(sets.is_empty(), "{sets:?}"); + let range = ValueError::OutOfRange { + min: 0.0, + max: 5760.0, + }; + harness.get_by_label(&texts::describe_refusal(&Refusal::Invalid(range))); +} + #[test] fn visiting_a_delay_in_milliseconds_writes_nothing() { let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); diff --git a/crates/app/tests/support/mod.rs b/crates/app/tests/support/mod.rs index e34a017..f563083 100644 --- a/crates/app/tests/support/mod.rs +++ b/crates/app/tests/support/mod.rs @@ -290,3 +290,34 @@ pub fn sets(simulator: &Simulator, pointer: &str) -> Vec { .cloned() .collect() } + +pub fn delay_sets(simulator: &Simulator) -> Vec { + simulator + .requests() + .iter() + .filter_map(|request| { + request + .pointer("/audio/out/delay") + .filter(|value| !value.is_null()) + }) + .cloned() + .collect() +} + +/// The harness clock moves about half a second per frame, more than egui allows between the +/// two clicks of a double click, so the allowance is widened for the test. +pub fn double_click_at(harness: &mut Harness<'static, NeumannApp>, position: egui::Pos2) { + harness + .ctx + .options_mut(|options| options.input_options.max_double_click_delay = 2.0); + harness.hover_at(position); + for pressed in [true, false, true, false] { + harness.event(egui::Event::PointerButton { + pos: position, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }); + } + harness.run_steps(2); +} diff --git a/crates/app/tests/views.rs b/crates/app/tests/views.rs index 7d0104b..b715175 100644 --- a/crates/app/tests/views.rs +++ b/crates/app/tests/views.rs @@ -433,19 +433,6 @@ fn turn_knob( around(center, radius, to) } -fn delay_sets(simulator: &Simulator) -> Vec { - simulator - .requests() - .iter() - .filter_map(|request| { - request - .pointer("/audio/out/delay") - .filter(|value| !value.is_null()) - }) - .cloned() - .collect() -} - #[test] fn strip_shows_leds_knobs_fader_and_buttons() { let (_simulator, _directory, harness) = connected_card(SimModel::Kh120Ii); @@ -712,24 +699,6 @@ fn double_click_on_the_level_readout_enters_a_value() { assert_eq!(level_sets(&simulator), vec![87.5]); } -/// The harness clock moves about half a second per frame, more than egui allows between the -/// two clicks of a double click, so the allowance is widened for the test. -fn double_click_at(harness: &mut Harness<'static, NeumannApp>, position: egui::Pos2) { - harness - .ctx - .options_mut(|options| options.input_options.max_double_click_delay = 2.0); - harness.hover_at(position); - for pressed in [true, false, true, false] { - harness.event(egui::Event::PointerButton { - pos: position, - button: egui::PointerButton::Primary, - pressed, - modifiers: egui::Modifiers::NONE, - }); - } - harness.run_steps(2); -} - #[test] fn double_click_on_the_delay_knob_takes_milliseconds() { let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); diff --git a/crates/app/tests/write_gestures.rs b/crates/app/tests/write_gestures.rs new file mode 100644 index 0000000..415c3be --- /dev/null +++ b/crates/app/tests/write_gestures.rs @@ -0,0 +1,176 @@ +//! A write is the end of a deliberate gesture on that control and carries the value the gesture +//! chose, never a value rounded or clamped behind the user's back. These tests hold that line +//! for each kind of control: strip fader, group fader, typed entry and panel knob. + +mod support; + +use eframe::egui::{self, accesskit::Role, MouseWheelUnit, TouchPhase}; +use egui_kittest::kittest::Queryable; +use egui_kittest::Harness; +use neumann_control_app::app::NeumannApp; +use neumann_control_app::texts; +use neumann_control_app::writes::Refusal; +use neumann_control_core::catalog::ValueError; +use neumann_control_core::simulator::SimModel; +use serde_json::{json, Value}; +use support::*; + +const LEVEL_PATH: &str = "audio/out/level"; + +fn wheel(harness: &mut Harness<'static, NeumannApp>, notches: f32) { + harness.event(egui::Event::MouseWheel { + unit: MouseWheelUnit::Line, + delta: egui::vec2(0.0, notches), + phase: TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); +} + +fn slider_center(harness: &Harness<'static, NeumannApp>, label: &str) -> egui::Pos2 { + harness + .get_by_role_and_label(Role::Slider, label) + .rect() + .center() +} + +fn click_at(harness: &mut Harness<'static, NeumannApp>, position: egui::Pos2) { + harness.drag_at(position); + harness.step(); + harness.drop_at(position); + harness.run_steps(5); +} + +fn levels(simulator: &neumann_control_core::simulator::Simulator) -> Vec { + sets(simulator, "/audio/out/level") +} + +#[test] +fn a_click_on_a_fader_without_moving_writes_nothing() { + let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + simulator.set_value(LEVEL_PATH, json!(90.3)); + wait_for_level(&mut harness, 90.3); + let center = slider_center(&harness, texts::LEVEL.get()); + click_at(&mut harness, center); + barrier(&simulator); + assert!( + level_sets(&simulator).is_empty(), + "{:?}", + level_sets(&simulator) + ); +} + +#[test] +fn a_click_on_a_group_fader_without_moving_writes_nothing() { + let (kh120, kh750, _directory, mut harness) = grouped(Vec::new()); + // The KH 120 II is the loudest member, so its off-grid level is the group's reference. + kh750.set_value(LEVEL_PATH, json!(80.0)); + kh120.set_value(LEVEL_PATH, json!(90.3)); + wait_for_level(&mut harness, 80.0); + wait_until(&mut harness, "the off-grid level", |harness| { + harness.query_by_label(&readout(90.3)).is_some() + }); + let center = slider_center(&harness, &texts::group_level(GROUP_NAME)); + click_at(&mut harness, center); + barrier(&kh120); + barrier(&kh750); + assert!(levels(&kh120).is_empty() && levels(&kh750).is_empty()); +} + +#[test] +fn a_typed_level_outside_the_range_is_refused_not_clamped() { + let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + let position = harness.get_by_label(&readout(100.0)).rect().center(); + double_click_at(&mut harness, position); + harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + harness.get_by_role(Role::TextInput).type_text("1000"); + harness.key_press(egui::Key::Enter); + harness.run_steps(5); + barrier(&simulator); + assert!( + level_sets(&simulator).is_empty(), + "{:?}", + level_sets(&simulator) + ); + let range = ValueError::OutOfRange { + min: 0.0, + max: 120.0, + }; + harness.get_by_label(&texts::describe_refusal(&Refusal::Invalid(range))); +} + +#[test] +fn the_wheel_on_a_panel_knob_does_not_scroll_another_knob_under_the_pointer() { + let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + open_panel(&mut harness, KH120_DEVICE_NAME); + // Short enough that the panel scrolls; `open_panel` makes the window tall. + harness.set_size(egui::vec2(1200.0, 420.0)); + harness.run_steps(2); + let label = texts::knob_label("Logo-Helligkeit"); + let center = slider_center(&harness, &label); + harness.hover_at(center); + for _ in 0..8 { + wheel(&mut harness, -1.0); + harness.step(); + } + wait_until(&mut harness, "the logo write", |_| { + !sets(&simulator, "/ui/logo/brightness").is_empty() + }); + harness.run_steps(5); + barrier(&simulator); + assert!( + delay_sets(&simulator).is_empty(), + "{:?}", + delay_sets(&simulator) + ); +} + +#[test] +fn a_group_move_is_relative_to_where_the_drag_started() { + let (kh120, kh750, _directory, mut harness) = grouped(Vec::new()); + kh750.set_value(LEVEL_PATH, json!(90.0)); + wait_for_level(&mut harness, 90.0); + let start = slider_center(&harness, &texts::group_level(GROUP_NAME)); + // A short move down (about 3 dB), smaller than the change of the loudest member below. + harness.drag_at(start); + for frame in 1..=4 { + harness.hover_at(start + egui::vec2(0.0, 1.25 * frame as f32)); + harness.step(); + } + // The loudest member changes while the group fader is held. + kh120.set_value(LEVEL_PATH, json!(80.0)); + wait_for_level(&mut harness, 80.0); + harness.drop_at(start + egui::vec2(0.0, 5.0)); + wait_until(&mut harness, "both level sets", |_| { + !levels(&kh120).is_empty() && !levels(&kh750).is_empty() + }); + let first = levels(&kh120)[0].as_f64().unwrap(); + let second = levels(&kh750)[0].as_f64().unwrap(); + assert!(second < 90.0, "the KH 750 went up: {second}"); + assert!( + ((first - 80.0) - (second - 90.0)).abs() < 1e-9, + "{first} {second}" + ); +} + +#[test] +fn a_group_raises_its_quieter_members_when_the_loudest_is_at_its_limit() { + let (kh120, kh750, _directory, mut harness) = grouped(Vec::new()); + kh120.set_value(LEVEL_PATH, json!(120.0)); + kh750.set_value(LEVEL_PATH, json!(90.0)); + wait_for_level(&mut harness, 120.0); + wait_for_level(&mut harness, 90.0); + let start = slider_center(&harness, &texts::group_level(GROUP_NAME)); + harness.drag_at(start); + for frame in 1..=4 { + harness.hover_at(start - egui::vec2(0.0, 5.0 * frame as f32)); + harness.step(); + } + harness.drop_at(start - egui::vec2(0.0, 20.0)); + wait_until(&mut harness, "the KH 750 set", |_| { + !levels(&kh750).is_empty() + }); + harness.run_steps(5); + barrier(&kh120); + assert!(levels(&kh750)[0].as_f64().unwrap() > 90.0); + assert!(levels(&kh120).is_empty(), "{:?}", levels(&kh120)); +} From ea8abd6c1e2e0e287e47ba7d2f5713d4c3f81c66 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:40:59 +0200 Subject: [PATCH 095/460] Keep group and order when a speaker's id changes A speaker added by address is known by serial once it connects; its group, place and open panel now follow it, and removing a speaker forgets its group and place too. --- crates/app/src/app.rs | 35 +++++++++++--- crates/app/src/groups.rs | 93 ++++++++++++++++++++++++++++++++++++++ crates/app/tests/groups.rs | 52 ++++++++++++++++++++- 3 files changed, 172 insertions(+), 8 deletions(-) diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index d5c33d9..839a74b 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -15,7 +15,7 @@ use neumann_control_core::mask::mask_host; use neumann_control_core::settings::{self, ManualSpeaker, Settings, WindowGeometry}; use neumann_control_core::speaker::{Command, Event, Events, SpeakerHandle, Target}; -use crate::groups::set_membership; +use crate::groups::{forget_member, member_id, rename_member, set_membership}; use crate::scan_choice::ScanChoice; use crate::spawner::{ScanResult, SpeakerSpawner}; use crate::state::{AllValuesState, SpeakerKey, SpeakerState, Status}; @@ -166,27 +166,46 @@ impl NeumannApp { fn drain_events(&mut self, now: f64) { let mut connected = Vec::new(); + let mut renamed = Vec::new(); for speaker in &mut self.speakers { while let Ok(event) = speaker.events.try_recv() { if let Event::Connected(identity) = &event { connected.push((identity.serial.clone(), speaker.state.address)); } + let before = speaker.state.key.clone(); speaker.state.apply(event, now); + if speaker.state.key != before { + renamed.push((before, speaker.state.key.clone())); + } } } if connected.is_empty() { return; } - let mut remembered = false; + let mut changed = false; for (serial, address) in &connected { - remembered |= remember_serial(&mut self.settings.manual, serial, *address); + changed |= remember_serial(&mut self.settings.manual, serial, *address); + } + for (before, after) in &renamed { + changed |= self.follow_key(before, after); } - if remembered { + if changed { self.save_settings(); } self.drop_duplicate_cards(); } + /// A speaker's key changed (its serial became known): its group, its place in the order and + /// its open panel go with it. + fn follow_key(&mut self, before: &SpeakerKey, after: &SpeakerKey) -> bool { + if let Some(panel) = self.panel.as_mut().filter(|panel| panel.key == *before) { + panel.key = after.clone(); + } + let (old, new) = (member_id(before), member_id(after)); + let settings = &mut self.settings; + rename_member(&mut settings.groups, &mut settings.order, &old, &new) + } + /// One speaker reached at two addresses shows up as two cards until both connected. fn drop_duplicate_cards(&mut self) { let keys: Vec = self @@ -265,7 +284,8 @@ impl NeumannApp { self.sync_targets(); } - /// Stops the card's worker and forgets every settings entry of its speaker. + /// Stops the speaker's worker and forgets it in the settings: its manual entry, its group and + /// its place in the order. fn remove_speaker(&mut self, removal: Removal) { let position = self.speakers.iter().position(|speaker| { speaker.state.key == removal.key || speaker.state.address == removal.address @@ -279,7 +299,10 @@ impl NeumannApp { None => (removal.key, removal.address), }; log::info!("speaker at {} removed", mask_host(&address)); - if forget(&mut self.settings.manual, &key, address) { + let ids = [member_id(&key), member_id(&SpeakerKey::Address(address))]; + let settings = &mut self.settings; + let forgotten = forget_member(&mut settings.groups, &mut settings.order, &ids); + if forget(&mut settings.manual, &key, address) | forgotten { self.save_settings(); } } diff --git a/crates/app/src/groups.rs b/crates/app/src/groups.rs index 6186bda..6e5989b 100644 --- a/crates/app/src/groups.rs +++ b/crates/app/src/groups.rs @@ -122,6 +122,59 @@ pub fn compact_rows( .collect() } +/// A speaker known by address until it first connects is known by serial from then on; its +/// group and its place in the order follow it. Returns whether anything changed. +pub fn rename_member( + groups: &mut [GroupSettings], + order: &mut Vec, + old: &str, + new: &str, +) -> bool { + let mut changed = false; + for group in groups.iter_mut() { + changed |= rename_in(&mut group.members, old, new); + } + changed | rename_in(order, old, new) +} + +/// `old` becomes `new` where it stands; where `new` is already listed, `old` just goes. +fn rename_in(ids: &mut Vec, old: &str, new: &str) -> bool { + let Some(position) = ids.iter().position(|id| id == old) else { + return false; + }; + if ids.iter().any(|id| id == new) { + ids.remove(position); + } else { + ids[position] = new.to_owned(); + } + true +} + +/// A removed speaker leaves its group and its place in the order, under every id it had. +/// Returns whether anything changed. +pub fn forget_member( + groups: &mut [GroupSettings], + order: &mut Vec, + ids: &[String], +) -> bool { + let known = |id: &String| ids.contains(id); + let before = order.len() + + groups + .iter() + .map(|group| group.members.len()) + .sum::(); + order.retain(|id| !known(id)); + for group in groups.iter_mut() { + group.members.retain(|id| !known(id)); + } + let after = order.len() + + groups + .iter() + .map(|group| group.members.len()) + .sum::(); + after != before +} + /// Puts `member` into `group` (or into none), out of any other group. pub fn set_membership(groups: &mut [GroupSettings], member: &str, group: Option) { for existing in groups.iter_mut() { @@ -277,6 +330,46 @@ mod tests { assert_eq!(items[0], Speaker(1)); } + #[test] + fn a_renamed_member_keeps_its_group_and_place() { + let mut groups = [group("Front", &["address:a", "b"])]; + let mut order = ids(&["c", "address:a", "b"]); + assert!(rename_member( + &mut groups, + &mut order, + "address:a", + "serial:a" + )); + assert_eq!(groups[0].members, ids(&["serial:a", "b"])); + assert_eq!(order, ids(&["c", "serial:a", "b"])); + assert!(!rename_member( + &mut groups, + &mut order, + "address:x", + "serial:x" + )); + } + + #[test] + fn a_rename_onto_a_listed_id_drops_the_old_one() { + let mut groups = [group("Front", &["serial:a", "address:a"])]; + let mut order = ids(&["serial:a", "address:a"]); + rename_member(&mut groups, &mut order, "address:a", "serial:a"); + assert_eq!(groups[0].members, ids(&["serial:a"])); + assert_eq!(order, ids(&["serial:a"])); + } + + #[test] + fn a_forgotten_member_leaves_group_and_order_under_every_id() { + let mut groups = [group("Front", &["serial:a", "b"])]; + let mut order = ids(&["address:a", "b"]); + let forgotten = ids(&["serial:a", "address:a"]); + assert!(forget_member(&mut groups, &mut order, &forgotten)); + assert_eq!(groups[0].members, ids(&["b"])); + assert_eq!(order, ids(&["b"])); + assert!(!forget_member(&mut groups, &mut order, &forgotten)); + } + #[test] fn a_speaker_is_in_one_group_at_most() { let mut groups = [group("Front", &["a"]), group("Sub", &[])]; diff --git a/crates/app/tests/groups.rs b/crates/app/tests/groups.rs index b35f964..40b303d 100644 --- a/crates/app/tests/groups.rs +++ b/crates/app/tests/groups.rs @@ -8,8 +8,8 @@ use egui_kittest::kittest::Queryable; use egui_kittest::Harness; use neumann_control_app::app::NeumannApp; use neumann_control_app::texts; -use neumann_control_core::settings::{self, Linkable, SETTINGS_FILE}; -use neumann_control_core::simulator::SimModel; +use neumann_control_core::settings::{self, GroupSettings, Linkable, Settings, SETTINGS_FILE}; +use neumann_control_core::simulator::{SimModel, Simulator}; use serde_json::Value; use support::*; @@ -210,3 +210,51 @@ fn escape_while_dragging_keeps_the_order() { let saved = settings::load(&directory.path().join(SETTINGS_FILE)); assert!(saved.order.is_empty(), "{:?}", saved.order); } + +#[test] +fn order_and_group_saved_by_address_follow_the_speaker_once_it_connects() { + let kh120 = Simulator::start(SimModel::Kh120Ii); + let kh750 = Simulator::start(SimModel::Kh750); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(SETTINGS_FILE); + // Added by address and never connected yet: no serial is known. + let address_id = + |simulator: &Simulator| format!("address:[::1]:{}", simulator.address().port()); + let settings = Settings { + manual: vec![manual_for(&kh120), manual_for(&kh750)], + groups: vec![GroupSettings { + name: GROUP_NAME.to_owned(), + color: 0, + members: vec![address_id(&kh120)], + linked: Vec::new(), + }], + order: vec![address_id(&kh750), address_id(&kh120)], + ..Settings::default() + }; + settings::save(&settings, &path).unwrap(); + let mut harness = harness(path.clone(), Vec::new()); + harness.set_size(egui::vec2(1200.0, 900.0)); + wait_until(&mut harness, "both strips connected", |harness| { + harness.query_all_by_label(&readout(100.0)).count() == 2 + }); + harness.run_steps(2); + let saved = settings::load(&path); + assert_eq!(saved.order, vec![KH750_ID.to_owned(), KH120_ID.to_owned()]); + assert_eq!(saved.groups[0].members, vec![KH120_ID.to_owned()]); +} + +#[test] +fn removing_a_speaker_also_forgets_its_group() { + let (_kh120, _kh750, directory, mut harness) = grouped(Vec::new()); + open_panel(&mut harness, KH120_DEVICE_NAME); + harness.get_by_label(texts::REMOVE.get()).click(); + wait_for_label(&mut harness, texts::REMOVE_TITLE.get()); + harness + .query_all_by_label(texts::REMOVE.get()) + .last() + .expect("the dialog's button") + .click(); + harness.run_steps(2); + let saved = settings::load(&directory.path().join(SETTINGS_FILE)); + assert_eq!(saved.groups[0].members, vec![KH750_ID.to_owned()]); +} From 2ec9ad5bf7c51f9047b6caadb2bb7985db277ae9 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:46:39 +0200 Subject: [PATCH 096/460] Fit the window onto the monitors on a mode switch Switching between desk and compact placed the other mode's window unchecked, so it could open off screen; a mode that never had a window starts at its own size. --- crates/app/src/app.rs | 12 +++---- crates/app/src/app/compact.rs | 37 +++++++++------------- crates/app/src/main.rs | 41 ++++-------------------- crates/app/src/window_placement.rs | 51 +++++++++++++++++++++++++++++- 4 files changed, 76 insertions(+), 65 deletions(-) diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index 839a74b..d999c56 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -37,8 +37,6 @@ mod desk; const DESK_SCROLL_ID: &str = "desk"; const COMPACT_SCROLL_ID: &str = "compact"; const COMPACT_SHORTCUT: KeyboardShortcut = KeyboardShortcut::new(Modifiers::COMMAND, Key::K); -/// The desk's window size in points before it was ever resized. -pub const DESK_WINDOW_SIZE: [f32; 2] = [900.0, 600.0]; struct Speaker { state: SpeakerState, @@ -314,7 +312,7 @@ impl NeumannApp { } } - fn show(&mut self, ui: &mut Ui) { + fn show(&mut self, ui: &mut Ui, frame: &eframe::Frame) { let facts = ToolbarFacts { compact: self.settings.desk.compact, scanning: self.scan.is_some(), @@ -331,11 +329,11 @@ impl NeumannApp { ToolbarAction::AddAddress => self.add_address = Some(AddAddressForm::default()), ToolbarAction::AddGroup => self.add_group(), ToolbarAction::Settings => self.settings_open = true, - ToolbarAction::ToggleCompact => self.toggle_compact(ui.ctx()), + ToolbarAction::ToggleCompact => self.toggle_compact(frame), ToolbarAction::None => {} } if ui.input_mut(|input| input.consume_shortcut(&COMPACT_SHORTCUT)) { - self.toggle_compact(ui.ctx()); + self.toggle_compact(frame); } egui::CentralPanel::default().show(ui, |ui| { if self.settings.desk.compact { @@ -555,9 +553,9 @@ impl eframe::App for NeumannApp { self.window = geometry.or(self.window); } - fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) { + fn ui(&mut self, ui: &mut Ui, frame: &mut eframe::Frame) { texts::set_language(self.settings.desk.language); - self.show(ui); + self.show(ui, frame); } fn on_exit(&mut self) { diff --git a/crates/app/src/app/compact.rs b/crates/app/src/app/compact.rs index 852d36c..1d4f72f 100644 --- a/crates/app/src/app/compact.rs +++ b/crates/app/src/app/compact.rs @@ -1,19 +1,17 @@ //! The compact mode: one row per ungrouped speaker and per group, the master at the bottom. The //! rows' gestures mean the same as on the desk and go the same way to the speakers. -use eframe::egui::{Context, ScrollArea, Ui, ViewportCommand}; +use eframe::egui::{ScrollArea, Ui}; use neumann_control_core::catalog::Catalog; use super::desk::{all_muted, levels, members_of, DeskAction}; -use super::{NeumannApp, OpenPanel, COMPACT_SCROLL_ID, DESK_WINDOW_SIZE}; +use super::{NeumannApp, OpenPanel, COMPACT_SCROLL_ID}; use crate::fader::output_level_range; use crate::groups::{compact_rows, layout, reference_level, shared_level, CompactRow}; use crate::texts; use crate::ui_kit::theme::group_color; use crate::views::compact::{self, SharedRow}; - -/// The window size the compact mode starts with before it was ever resized, in points. -const COMPACT_WINDOW_SIZE: [f32; 2] = [640.0, 360.0]; +use crate::window_placement::{first_window_size, place_window, resize_window}; impl NeumannApp { pub(super) fn show_compact(&mut self, ui: &mut Ui) { @@ -93,26 +91,21 @@ impl NeumannApp { } } - /// Desk to compact or back: the window the new mode last had comes back, else its first size. - pub(super) fn toggle_compact(&mut self, context: &Context) { + /// Desk to compact or back: the window the new mode last had comes back, fitted onto the + /// monitors there are now, else the mode's first size. + pub(super) fn toggle_compact(&mut self, frame: &eframe::Frame) { let target = self.settings.switch_mode(self.window); self.save_settings(); - let Some(target) = target else { - let [width, height] = if self.settings.desk.compact { - COMPACT_WINDOW_SIZE - } else { - DESK_WINDOW_SIZE - }; - context.send_viewport_cmd(ViewportCommand::InnerSize(eframe::egui::vec2( - width, height, - ))); + // Only a geometry of the new mode may be kept for it; a maximised window reports none + // and would otherwise leave the old mode's geometry to be saved for the new one. + self.window = target; + // No real window under test. + let Some(window) = frame.winit_window() else { return; }; - let points = |physical: f32| physical / context.pixels_per_point(); - let position = eframe::egui::pos2(points(target.x), points(target.y)); - let size = eframe::egui::vec2(points(target.width), points(target.height)); - context.send_viewport_cmd(ViewportCommand::OuterPosition(position)); - context.send_viewport_cmd(ViewportCommand::InnerSize(size)); - self.window = Some(target); + match target { + Some(geometry) => place_window(window, geometry), + None => resize_window(window, first_window_size(self.settings.desk.compact)), + } } } diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index fceeaab..e03e9ba 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -8,16 +8,13 @@ use std::path::Path; use eframe::egui::ViewportBuilder; use eframe::egui_wgpu::{SurfaceConfig, WgpuConfiguration, WgpuSetup}; use eframe::wgpu; -use neumann_control_app::app::{NeumannApp, DESK_WINDOW_SIZE}; +use neumann_control_app::app::NeumannApp; use neumann_control_app::spawner::CoreSpawner; use neumann_control_app::texts; -use neumann_control_app::window_placement::{fit_into, MonitorArea}; -use neumann_control_core::settings::{self, WindowGeometry, LOG_FILE, SETTINGS_FILE}; +use neumann_control_app::window_placement::{first_window_size, place_window, WINDOW_MIN_SIZE}; +use neumann_control_core::settings::{self, LOG_FILE, SETTINGS_FILE}; use simplelog::{ConfigBuilder, LevelFilter, WriteLogger}; -use winit::dpi::{PhysicalPosition, PhysicalSize}; -use winit::window::Window; -const WINDOW_MIN_SIZE: [f32; 2] = [480.0, 360.0]; // Our crates only: wgpu logs the graphics adapters with their bus ids at info level. const LOG_TARGET_PREFIX: &str = "neumann_control"; @@ -38,33 +35,6 @@ fn wgpu_options() -> WgpuConfiguration { options } -fn monitor_areas(window: &Window) -> Vec { - window - .available_monitors() - .map(|monitor| { - let position = monitor.position(); - let size = monitor.size(); - MonitorArea { - x: position.x as f32, - y: position.y as f32, - width: size.width as f32, - height: size.height as f32, - } - }) - .collect() -} - -/// Set in physical pixels once the window exists: the viewport builder takes points, whose -/// scale is not known before the window is on a monitor. -fn place_window(window: &Window, saved: WindowGeometry) { - let scale = window.scale_factor() as f32; - let min_size = (WINDOW_MIN_SIZE[0] * scale, WINDOW_MIN_SIZE[1] * scale); - let geometry = fit_into(saved, &monitor_areas(window), min_size); - window.set_outer_position(PhysicalPosition::new(geometry.x, geometry.y)); - // The size granted arrives as a resize event like any other. - let _ = window.request_inner_size(PhysicalSize::new(geometry.width, geometry.height)); -} - /// The app still runs without a log, e.g. from a read-only folder. fn start_log(directory: &Path) { let Ok(file) = File::create(directory.join(LOG_FILE)) else { @@ -83,11 +53,12 @@ fn main() -> Result<(), Box> { .ok_or("the executable has no parent folder")?; start_log(directory); let settings_path = directory.join(SETTINGS_FILE); - let saved_window = settings::load(&settings_path).window_of_mode(); + let saved = settings::load(&settings_path); + let saved_window = saved.window_of_mode(); let options = eframe::NativeOptions { viewport: ViewportBuilder::default() .with_title(texts::WINDOW_TITLE) - .with_inner_size(DESK_WINDOW_SIZE) + .with_inner_size(first_window_size(saved.desk.compact)) .with_min_inner_size(WINDOW_MIN_SIZE), centered: true, wgpu_options: wgpu_options(), diff --git a/crates/app/src/window_placement.rs b/crates/app/src/window_placement.rs index f252340..18cb48a 100644 --- a/crates/app/src/window_placement.rs +++ b/crates/app/src/window_placement.rs @@ -1,7 +1,56 @@ //! Where the window reopens: the saved geometry, moved onto a monitor that is still there -//! (an undocked laptop would otherwise open it off screen). All values are physical pixels. +//! (an undocked laptop would otherwise open it off screen). At the start and when the window +//! switches between desk and compact mode. All geometry values are physical pixels. use neumann_control_core::settings::WindowGeometry; +use winit::dpi::{LogicalSize, PhysicalPosition, PhysicalSize}; +use winit::window::Window; + +/// The smallest window in points, in both modes. +pub const WINDOW_MIN_SIZE: [f32; 2] = [480.0, 360.0]; +/// The window size in points of a mode that never had a window of its own. +pub const DESK_WINDOW_SIZE: [f32; 2] = [900.0, 600.0]; +pub const COMPACT_WINDOW_SIZE: [f32; 2] = [640.0, 360.0]; + +pub fn first_window_size(compact: bool) -> [f32; 2] { + if compact { + COMPACT_WINDOW_SIZE + } else { + DESK_WINDOW_SIZE + } +} + +fn monitor_areas(window: &Window) -> Vec { + window + .available_monitors() + .map(|monitor| { + let position = monitor.position(); + let size = monitor.size(); + MonitorArea { + x: position.x as f32, + y: position.y as f32, + width: size.width as f32, + height: size.height as f32, + } + }) + .collect() +} + +/// Puts the window where `saved` says, fitted onto the monitors there are now. Set in physical +/// pixels once the window exists: points need the scale, which is known only on a monitor. +pub fn place_window(window: &Window, saved: WindowGeometry) { + let scale = window.scale_factor() as f32; + let min_size = (WINDOW_MIN_SIZE[0] * scale, WINDOW_MIN_SIZE[1] * scale); + let geometry = fit_into(saved, &monitor_areas(window), min_size); + window.set_outer_position(PhysicalPosition::new(geometry.x, geometry.y)); + // The size granted arrives as a resize event like any other. + let _ = window.request_inner_size(PhysicalSize::new(geometry.width, geometry.height)); +} + +/// Gives the window a mode's first size, where it is. +pub fn resize_window(window: &Window, size: [f32; 2]) { + let _ = window.request_inner_size(LogicalSize::new(size[0], size[1])); +} #[derive(Clone, Copy, Debug, PartialEq)] pub struct MonitorArea { From 5864a10c2d6d0793d6bfbb5430c09ad4057e02ed Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:46:39 +0200 Subject: [PATCH 097/460] Keep the readable settings when a value is not understood An unknown value or entry took the whole file, and so every speaker; now only that value falls back, and a copy of the file is kept for the dropped parts. --- crates/core/src/settings.rs | 78 +++++++++++++++++++++++++++++++++-- crates/core/tests/settings.rs | 30 ++++++++++++++ 2 files changed, 105 insertions(+), 3 deletions(-) diff --git a/crates/core/src/settings.rs b/crates/core/src/settings.rs index 938ca36..4906450 100644 --- a/crates/core/src/settings.rs +++ b/crates/core/src/settings.rs @@ -1,11 +1,13 @@ //! Persistent settings stored next to the executable. +use std::cell::Cell; use std::ffi::OsString; use std::fs::{self, File}; use std::io::{self, ErrorKind, Write}; use std::path::{Path, PathBuf}; -use serde::{Deserialize, Serialize}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Deserializer, Serialize}; pub const SETTINGS_FILE: &str = "settings.json"; pub const LOG_FILE: &str = "neumann-control.log"; @@ -13,18 +15,29 @@ pub const LOG_FILE: &str = "neumann-control.log"; const TEMPORARY_SUFFIX: &str = ".tmp"; const QUARANTINE_SUFFIX: &str = ".bad"; +thread_local! { + /// How many values the current `load` replaced or left out; then the file is copied aside + /// before the next save drops them for good. + static NOT_UNDERSTOOD: Cell = const { Cell::new(0) }; +} + #[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] #[serde(default)] pub struct Settings { /// Every remembered speaker, added by address or chosen from a scan. The key keeps its /// first name so that files written before scans were remembered load unchanged. + #[serde(deserialize_with = "readable_items")] pub manual: Vec, /// The window as the desk had it, and as the compact mode had it. + #[serde(deserialize_with = "or_default")] pub window: Option, + #[serde(deserialize_with = "or_default")] pub compact_window: Option, pub desk: DeskSettings, + #[serde(deserialize_with = "readable_items")] pub groups: Vec, /// Speaker identities (`serial:` or `address:
`) in the order of the desk. + #[serde(deserialize_with = "or_default")] pub order: Vec, } @@ -83,10 +96,15 @@ pub enum Linkable { #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct DeskSettings { + #[serde(deserialize_with = "or_default")] pub theme: ThemeChoice, + #[serde(deserialize_with = "or_default")] pub meter_floor: MeterFloor, + #[serde(deserialize_with = "or_default")] pub show_response_times: bool, + #[serde(deserialize_with = "or_default")] pub language: Language, + #[serde(deserialize_with = "or_default")] pub compact: bool, } @@ -163,9 +181,49 @@ pub struct WindowGeometry { pub height: f32, } +/// A value this version cannot read (one a later version wrote, a hand edit) takes its default +/// instead of costing the whole file, and so the remembered speakers. +fn or_default<'de, D, T>(deserializer: D) -> Result +where + D: Deserializer<'de>, + T: DeserializeOwned + Default, +{ + let value = serde_json::Value::deserialize(deserializer)?; + Ok(T::deserialize(value).unwrap_or_else(|error| { + log::warn!( + "a settings value is not understood ({:?}); using its default", + error.classify() + ); + NOT_UNDERSTOOD.with(|count| count.set(count.get() + 1)); + T::default() + })) +} + +/// A list keeps the entries this version can read; one it cannot is left out, not the list. +/// Something that is not a list at all means a damaged file, which `load` sets aside whole. +fn readable_items<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: DeserializeOwned, +{ + let items = Vec::::deserialize(deserializer)?; + let total = items.len(); + let readable: Vec = items + .into_iter() + .filter_map(|item| T::deserialize(item).ok()) + .collect(); + let left_out = total - readable.len(); + if left_out > 0 { + log::warn!("{left_out} settings entries are not understood; left out"); + NOT_UNDERSTOOD.with(|count| count.set(count.get() + left_out)); + } + Ok(readable) +} + /// Loads the settings; a missing file yields the defaults, an unreadable or /// malformed one is moved aside (never overwriting an earlier one) and also -/// yields the defaults. +/// yields the defaults. Within a readable file, values this version does not understand take +/// their defaults (see `or_default`). pub fn load(path: &Path) -> Settings { let bytes = match fs::read(path) { Ok(bytes) => bytes, @@ -179,8 +237,14 @@ pub fn load(path: &Path) -> Settings { return Settings::default(); } }; + NOT_UNDERSTOOD.with(|count| count.set(0)); match serde_json::from_slice(&bytes) { - Ok(settings) => settings, + Ok(settings) => { + if NOT_UNDERSTOOD.with(Cell::get) > 0 { + keep_a_copy(path); + } + settings + } Err(error) => { // Only the error category is logged: the message can quote file contents, // which include speaker addresses. @@ -206,6 +270,14 @@ pub fn save(settings: &Settings, path: &Path) -> io::Result<()> { fs::rename(&temporary, path) } +/// Copies the file aside, as `quarantine` would move it, keeping the original in place. +fn keep_a_copy(path: &Path) { + let target = free_quarantine_path(path); + if let Err(error) = fs::copy(path, &target) { + log::warn!("settings file could not be copied aside ({})", error.kind()); + } +} + fn quarantine(path: &Path) { let target = free_quarantine_path(path); if let Err(error) = fs::rename(path, &target) { diff --git a/crates/core/tests/settings.rs b/crates/core/tests/settings.rs index ce4c9e9..573f537 100644 --- a/crates/core/tests/settings.rs +++ b/crates/core/tests/settings.rs @@ -204,3 +204,33 @@ fn each_mode_keeps_its_own_window() { assert_eq!(settings.switch_mode(None), Some(compact)); assert_eq!(settings.window_of_mode(), Some(compact)); } + +#[test] +fn a_value_this_version_does_not_know_costs_only_that_value() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(SETTINGS_FILE); + // As a later version or a hand edit might leave it: an unknown language and meter floor, + // a group without a colour, a speaker entry without a port. + let text = r#"{ + "manual": [{"address": "fe80::1%7", "port": 45, "serial": "SIM1"}, {"address": "fe80::2%7"}], + "desk": {"language": "French", "meter_floor": "Minus200", "show_response_times": false}, + "groups": [{"name": "Front", "members": ["serial:SIM1"]}, {"name": "Sub", "color": 1, "members": []}], + "order": ["serial:SIM1"] + }"#; + fs::write(&path, text).unwrap(); + let loaded = load(&path); + assert_eq!(loaded.manual.len(), 1, "the readable speaker stays"); + assert_eq!(loaded.desk.language, Language::German); + assert_eq!(loaded.desk.meter_floor, MeterFloor::Minus120); + assert!( + !loaded.desk.show_response_times, + "a readable neighbour stays" + ); + assert_eq!(loaded.groups.len(), 1); + assert_eq!(loaded.groups[0].name, "Sub"); + assert_eq!(loaded.order, vec!["serial:SIM1".to_owned()]); + // The original stays readable for whoever wants the dropped values back. + let copy = directory.path().join(format!("{SETTINGS_FILE}.bad")); + assert_eq!(fs::read_to_string(copy).unwrap(), text); + assert!(path.exists()); +} From bbb7334308b344136df527b9f88236a52a543080 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:49:04 +0200 Subject: [PATCH 098/460] Keep toolbar and compact rows usable in a narrow window At the minimum width the toolbar drew over itself and compact rows hid their mute button; the toolbar now cuts its left side, compact mode scrolls sideways, and its columns line up. --- crates/app/src/app/compact.rs | 3 +- crates/app/src/views/compact.rs | 12 +++++-- crates/app/src/views/toolbar.rs | 47 ++++++++++++++++++--------- crates/app/tests/compact.rs | 57 +++++++++++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 19 deletions(-) diff --git a/crates/app/src/app/compact.rs b/crates/app/src/app/compact.rs index 1d4f72f..a679d49 100644 --- a/crates/app/src/app/compact.rs +++ b/crates/app/src/app/compact.rs @@ -27,7 +27,8 @@ impl NeumannApp { let floor = desk.meter_floor.db(); let now = ui.input(|input| input.time); let mut actions = Vec::new(); - ScrollArea::vertical() + // Both ways: below the rows' width the window scrolls instead of hiding mute. + ScrollArea::both() .id_salt(COMPACT_SCROLL_ID) .auto_shrink([false, false]) .show(ui, |ui| { diff --git a/crates/app/src/views/compact.rs b/crates/app/src/views/compact.rs index ba00c76..1d45756 100644 --- a/crates/app/src/views/compact.rs +++ b/crates/app/src/views/compact.rs @@ -30,7 +30,8 @@ const BORDER_WIDTH: f32 = 1.0; /// The columns line up from row to row; the LED column is as wide as four LEDs. const MARK_WIDTH: f32 = 50.0; const NAME_WIDTH: f32 = 120.0; -const READOUT_WIDTH: f32 = 72.0; +/// Wide enough for the longest readout, "120.0 dB SPL". +const READOUT_WIDTH: f32 = 96.0; const ROW_HEIGHT: f32 = 22.0; const SWATCH_SIZE: f32 = 10.0; const SWATCH_RADIUS: u8 = 2; @@ -73,7 +74,7 @@ pub fn speaker( let readout = device .map(|_| texts::readout(shown, &unit)) .unwrap_or_default(); - cell(ui, READOUT_WIDTH, |ui| ui.monospace(readout)); + cell(ui, READOUT_WIDTH, |ui| readout_label(ui, readout)); let floor = desk.meter_floor.db(); meters::bar(ui, loudest, floor, texts::INPUT_METERS.get(), &tokens); let mute = texts::MUTE.get(); @@ -152,7 +153,7 @@ fn shared_controls( let range = shown.range; let moved = fader::shared(ui, level_label, level, range, Orientation::Horizontal); let readout = shared_readout(level); - cell(ui, READOUT_WIDTH, |ui| ui.monospace(readout)); + cell(ui, READOUT_WIDTH, |ui| readout_label(ui, readout)); meters::bar( ui, shown.loudest, @@ -186,6 +187,11 @@ fn cell(ui: &mut Ui, width: f32, contents: impl FnOnce(&mut Ui) -> R) -> R { .inner } +/// Cut rather than pushing the columns behind it out of line. +fn readout_label(ui: &mut Ui, readout: String) { + ui.add(egui::Label::new(RichText::new(readout).monospace()).truncate()); +} + fn swatch(ui: &mut Ui, color: Color32) { let (rect, _) = ui.allocate_exact_size(vec2(SWATCH_SIZE, SWATCH_SIZE), Sense::hover()); ui.painter() diff --git a/crates/app/src/views/toolbar.rs b/crates/app/src/views/toolbar.rs index 1129917..1899f06 100644 --- a/crates/app/src/views/toolbar.rs +++ b/crates/app/src/views/toolbar.rs @@ -5,7 +5,7 @@ use std::time::Duration; -use eframe::egui::{self, Align, Button, Layout, RichText, Ui}; +use eframe::egui::{self, containers::Sides, Button, RichText, TextWrapMode, Ui}; use crate::texts; @@ -32,11 +32,25 @@ pub struct ToolbarFacts { pub fn show(ui: &mut Ui, facts: &ToolbarFacts) -> ToolbarAction { egui::Panel::top(PANEL_ID) - .show(ui, |ui| ui.horizontal(|ui| controls(ui, facts)).inner) + .show(ui, |ui| controls(ui, facts)) .inner } +/// The right side is placed first, so in a narrow window the count and a notice on the left are +/// cut (whole on hover) instead of being drawn under the buttons. fn controls(ui: &mut Ui, facts: &ToolbarFacts) -> ToolbarAction { + let (left, right) = Sides::new() + .shrink_left() + .wrap_mode(TextWrapMode::Truncate) + .show(ui, |ui| left_side(ui, facts), |ui| right_side(ui, facts)); + if right == ToolbarAction::None { + left + } else { + right + } +} + +fn left_side(ui: &mut Ui, facts: &ToolbarFacts) -> ToolbarAction { let mut action = ToolbarAction::None; if !facts.compact { action = adding(ui, facts); @@ -47,19 +61,22 @@ fn controls(ui: &mut Ui, facts: &ToolbarFacts) -> ToolbarAction { ui.separator(); ui.colored_label(ui.visuals().error_fg_color, notice); } - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if ui.button(texts::SETTINGS).clicked() { - action = ToolbarAction::Settings; - } - let compact = Button::new(texts::COMPACT).selected(facts.compact); - if ui.add(compact).on_hover_text(texts::COMPACT_HINT).clicked() { - action = ToolbarAction::ToggleCompact; - } - if let Some(time) = facts.average_response_time { - let text = texts::average_response_time(time.as_millis()); - ui.label(RichText::new(text).monospace().weak()); - } - }); + action +} + +fn right_side(ui: &mut Ui, facts: &ToolbarFacts) -> ToolbarAction { + let mut action = ToolbarAction::None; + if ui.button(texts::SETTINGS).clicked() { + action = ToolbarAction::Settings; + } + let compact = Button::new(texts::COMPACT).selected(facts.compact); + if ui.add(compact).on_hover_text(texts::COMPACT_HINT).clicked() { + action = ToolbarAction::ToggleCompact; + } + if let Some(time) = facts.average_response_time { + let text = texts::average_response_time(time.as_millis()); + ui.label(RichText::new(text).monospace().weak()); + } action } diff --git a/crates/app/tests/compact.rs b/crates/app/tests/compact.rs index 9446c74..0caec1b 100644 --- a/crates/app/tests/compact.rs +++ b/crates/app/tests/compact.rs @@ -103,3 +103,60 @@ fn compact_rows_are_one_line_high_and_follow_each_other() { // Neighbouring rows: their faders are about one row apart, not a window height. assert!(master - group < 60.0, "group {group}, master {master}"); } + +#[test] +fn compact_columns_line_up_between_speaker_and_master_rows() { + let (_simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + press_compact_shortcut(&mut harness); + let bars: Vec = harness + .query_all_by_label(texts::INPUT_METERS.get()) + .map(|node| node.rect().left()) + .collect(); + assert_eq!(bars.len(), 2, "speaker and master row"); + assert!((bars[0] - bars[1]).abs() < 0.5, "{bars:?}"); +} + +#[test] +fn mute_stays_reachable_in_the_narrowest_compact_window() { + let (_simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + press_compact_shortcut(&mut harness); + harness.set_size(egui::vec2(480.0, 360.0)); + harness.run_steps(2); + let mute = harness.get_by_label(texts::MUTE.get()).rect(); + let fits = mute.right() <= 480.0; + let scrolls = harness + .query_all_by_role(Role::ScrollBar) + .any(|bar| bar.rect().width() > bar.rect().height()); + assert!(fits || scrolls, "mute at {mute:?} without a way there"); +} + +#[test] +fn the_toolbar_does_not_overlap_itself_in_the_narrowest_window() { + let (_simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + harness.set_size(egui::vec2(480.0, 360.0)); + harness.run_steps(2); + let toolbar = [ + texts::SCAN.get(), + texts::ADD_ADDRESS.get(), + texts::ADD_GROUP.get(), + texts::COMPACT.get(), + texts::SETTINGS.get(), + ]; + let rects: Vec = toolbar + .iter() + .map(|label| harness.get_by_label(label).rect()) + .collect(); + for (index, rect) in rects.iter().enumerate() { + assert!(rect.right() <= 480.0, "{} at {rect:?}", toolbar[index]); + for other in &rects[index + 1..] { + assert!(!rect.intersects(*other), "{rect:?} and {other:?}"); + } + } + if let Some(count) = harness.query_by_label(&texts::speaker_count(1)) { + let count = count.rect(); + assert!( + rects.iter().all(|rect| !rect.intersects(count)), + "{count:?}" + ); + } +} From 885df6522887e1622ccd4ce0faef331dd4f35ab3 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:50:57 +0200 Subject: [PATCH 099/460] Leave unreachable members alone and name new groups apart Group, master and linked writes skipped no member, so an unreachable one lost its message; compact rows now show a refused write, and a new group never repeats a name or colour. --- crates/app/src/app/desk.rs | 26 ++++++++++++---------- crates/app/src/groups.rs | 29 +++++++++++++++++++++++++ crates/app/src/views/compact.rs | 13 +++++++++++ crates/app/tests/compact.rs | 12 +++++++++++ crates/app/tests/groups.rs | 38 +++++++++++++++++++++++++++++++++ 5 files changed, 107 insertions(+), 11 deletions(-) diff --git a/crates/app/src/app/desk.rs b/crates/app/src/app/desk.rs index 0c3ce0f..f12c2eb 100644 --- a/crates/app/src/app/desk.rs +++ b/crates/app/src/app/desk.rs @@ -13,7 +13,7 @@ use serde_json::Value; use super::{NeumannApp, OpenPanel, DESK_SCROLL_ID}; use crate::fader::output_level_range; use crate::groups::{ - insertion_slot, layout, linkable_of, linked_path, member_id, order_of, shared_level, + insertion_slot, layout, linkable_of, linked_path, member_id, new_group, order_of, shared_level, shifted_levels, with_moved, DeskItem, }; use crate::paths; @@ -284,8 +284,13 @@ impl NeumannApp { .collect() } + /// A write on behalf of a gesture elsewhere (group, master, link, copy): a member that is + /// not connected is left out, so its own message ("nicht erreichbar") stays. fn request_and_send(&mut self, index: usize, intent: Intent, catalog: &Catalog, now: f64) { let speaker = &mut self.speakers[index]; + if speaker.state.status != Status::Connected { + return; + } if let Some(command) = speaker.state.request(intent, catalog, now) { speaker.handle.send(command); } @@ -299,17 +304,16 @@ impl NeumannApp { } pub(super) fn add_group(&mut self) { - let count = self.settings.groups.len(); - let name = format!("{} {}", texts::NEW_GROUP_NAME, count + 1); - let color = (count % usize::from(group_color_count())) as u8; - self.settings.groups.push(GroupSettings { - name: name.clone(), - color, - members: Vec::new(), - linked: Vec::new(), - }); + let group = new_group( + &self.settings.groups, + texts::NEW_GROUP_NAME.get(), + group_color_count(), + ); + let name = group.name.clone(); + self.settings.groups.push(group); self.save_settings(); - self.group_draft = Some(GroupDraft { index: count, name }); + let index = self.settings.groups.len() - 1; + self.group_draft = Some(GroupDraft { index, name }); } pub(super) fn states(&self, indexes: &[usize]) -> Vec<&SpeakerState> { diff --git a/crates/app/src/groups.rs b/crates/app/src/groups.rs index 6e5989b..6686a0b 100644 --- a/crates/app/src/groups.rs +++ b/crates/app/src/groups.rs @@ -175,6 +175,24 @@ pub fn forget_member( after != before } +/// A new group: named `base` with the lowest number no group has, in the palette colour fewest +/// groups have, so no two groups look alike until there are more groups than colours. +pub fn new_group(groups: &[GroupSettings], base: &str, colors: u8) -> GroupSettings { + let taken = |name: &str| groups.iter().any(|group| group.name == name); + let name = (1..) + .map(|number| format!("{base} {number}")) + .find(|name| !taken(name)) + .expect("an unbounded range always yields a free name"); + let uses = |color: u8| groups.iter().filter(|group| group.color == color).count(); + let color = (0..colors).min_by_key(|color| uses(*color)).unwrap_or(0); + GroupSettings { + name, + color, + members: Vec::new(), + linked: Vec::new(), + } +} + /// Puts `member` into `group` (or into none), out of any other group. pub fn set_membership(groups: &mut [GroupSettings], member: &str, group: Option) { for existing in groups.iter_mut() { @@ -370,6 +388,17 @@ mod tests { assert!(!forget_member(&mut groups, &mut order, &forgotten)); } + #[test] + fn a_new_group_takes_a_free_name_and_the_least_used_colour() { + let mut groups = vec![group("Gruppe 2", &[])]; + groups[0].color = 1; + let next = new_group(&groups, "Gruppe", 3); + assert_eq!((next.name.as_str(), next.color), ("Gruppe 1", 0)); + groups.push(next); + let after = new_group(&groups, "Gruppe", 3); + assert_eq!((after.name.as_str(), after.color), ("Gruppe 3", 2)); + } + #[test] fn a_speaker_is_in_one_group_at_most() { let mut groups = [group("Front", &["a"]), group("Sub", &[])]; diff --git a/crates/app/src/views/compact.rs b/crates/app/src/views/compact.rs index 1d45756..bead7ed 100644 --- a/crates/app/src/views/compact.rs +++ b/crates/app/src/views/compact.rs @@ -6,6 +6,7 @@ use std::ops::RangeInclusive; use eframe::egui::{ self, vec2, Align, Button, Color32, CornerRadius, Layout, Pos2, RichText, Sense, Stroke, Ui, + WidgetInfo, WidgetType, }; use neumann_control_core::catalog::Catalog; use neumann_control_core::settings::{DeskSettings, GroupSettings}; @@ -35,6 +36,7 @@ const READOUT_WIDTH: f32 = 96.0; const ROW_HEIGHT: f32 = 22.0; const SWATCH_SIZE: f32 = 10.0; const SWATCH_RADIUS: u8 = 2; +const MESSAGE_MARK: &str = "!"; pub struct SpeakerRowResponse { /// The writes the user asked for that the write policy allowed. @@ -79,6 +81,9 @@ pub fn speaker( meters::bar(ui, loudest, floor, texts::INPUT_METERS.get(), &tokens); let mute = texts::MUTE.get(); strip::toggle(ui, speaker, catalog, &mut commands, Switch::Mute, mute); + if let Some(message) = &speaker.message { + message_mark(ui, &texts::describe_message(message), &tokens); + } open_panel }); SpeakerRowResponse { @@ -187,6 +192,14 @@ fn cell(ui: &mut Ui, width: f32, contents: impl FnOnce(&mut Ui) -> R) -> R { .inner } +/// The strip's message line has no room in a row; a mark stands for it, the text on hover. +fn message_mark(ui: &mut Ui, text: &str, tokens: &Tokens) { + let mark = RichText::new(MESSAGE_MARK).strong().color(tokens.danger); + let response = ui.add(egui::Label::new(mark).sense(Sense::hover())); + response.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, text)); + response.on_hover_text(text); +} + /// Cut rather than pushing the columns behind it out of line. fn readout_label(ui: &mut Ui, readout: String) { ui.add(egui::Label::new(RichText::new(readout).monospace()).truncate()); diff --git a/crates/app/tests/compact.rs b/crates/app/tests/compact.rs index 0caec1b..d9a9d66 100644 --- a/crates/app/tests/compact.rs +++ b/crates/app/tests/compact.rs @@ -160,3 +160,15 @@ fn the_toolbar_does_not_overlap_itself_in_the_narrowest_window() { ); } } + +#[test] +fn a_compact_row_says_why_a_write_was_refused() { + let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + simulator.set_value("ui/control_mode", serde_json::json!("LOCAL")); + press_compact_shortcut(&mut harness); + drag_level(&mut harness, texts::LEVEL.get(), -40.0); + let message = texts::describe_rejection(401); + wait_until(&mut harness, "the LOCAL message", |harness| { + harness.query_by_label(&message).is_some() + }); +} diff --git a/crates/app/tests/groups.rs b/crates/app/tests/groups.rs index 40b303d..8c5ea04 100644 --- a/crates/app/tests/groups.rs +++ b/crates/app/tests/groups.rs @@ -258,3 +258,41 @@ fn removing_a_speaker_also_forgets_its_group() { let saved = settings::load(&directory.path().join(SETTINGS_FILE)); assert_eq!(saved.groups[0].members, vec![KH750_ID.to_owned()]); } + +#[test] +fn group_mute_leaves_an_unreachable_member_alone() { + let (kh120, kh750, _directory, mut harness) = grouped(Vec::new()); + kh750.stop(); + wait_for_label(&mut harness, texts::UNREACHABLE.get()); + harness.get_by_label(&texts::group_mute(GROUP_NAME)).click(); + wait_until(&mut harness, "the reachable member's mute", |_| { + !sets(&kh120, "/audio/out/mute").is_empty() + }); + harness.run_steps(2); + harness.get_by_label(texts::UNREACHABLE.get()); +} + +#[test] +fn a_new_group_never_repeats_the_name_or_colour_of_another() { + let (_simulator, directory, mut harness) = connected_card(SimModel::Kh120Ii); + let path = directory.path().join(SETTINGS_FILE); + for _ in 0..2 { + harness.get_by_label(texts::ADD_GROUP.get()).click(); + wait_for_label(&mut harness, texts::GROUP_DIALOG_TITLE.get()); + harness.get_by_label(texts::CLOSE.get()).click(); + harness.run_steps(2); + } + let first = settings::load(&path).groups[0].name.clone(); + harness.get_by_label(&first).click(); + wait_for_label(&mut harness, texts::DELETE_GROUP.get()); + harness.get_by_label(texts::DELETE_GROUP.get()).click(); + harness.run_steps(2); + harness.get_by_label(texts::ADD_GROUP.get()).click(); + wait_for_label(&mut harness, texts::GROUP_DIALOG_TITLE.get()); + harness.get_by_label(texts::CLOSE.get()).click(); + harness.run_steps(2); + let groups = settings::load(&path).groups; + assert_eq!(groups.len(), 2); + assert_ne!(groups[0].name, groups[1].name); + assert_ne!(groups[0].color, groups[1].color); +} From 83faf7421deb5f549356d20f9472fcfd84ceefbd Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:59:34 +0200 Subject: [PATCH 100/460] Name settings as the panel does and write German decimals Strip knobs and link buttons used other names than the device panel, and "Aus" read as off; the German interface now shows a decimal comma and accepts both separators. --- crates/app/src/display.rs | 9 +++-- crates/app/src/groups.rs | 10 +++++ crates/app/src/paths.rs | 2 + crates/app/src/texts.rs | 56 ++++++++++++++------------ crates/app/src/views/details_fields.rs | 5 ++- crates/app/src/views/group_strip.rs | 10 +++-- crates/app/src/views/knob.rs | 2 +- crates/app/src/views/panel.rs | 3 +- crates/app/src/views/strip.rs | 13 +++--- crates/app/tests/details.rs | 40 ++++++++++++------ crates/app/tests/groups.rs | 3 +- crates/app/tests/views.rs | 34 +++++++++++----- 12 files changed, 117 insertions(+), 70 deletions(-) diff --git a/crates/app/src/display.rs b/crates/app/src/display.rs index ce761a8..4c5e7fc 100644 --- a/crates/app/src/display.rs +++ b/crates/app/src/display.rs @@ -50,7 +50,7 @@ pub fn converted(conversion: Conversion, number: f64) -> String { match conversion { // Adding zero turns the -0 of a slightly negative reading into 0. Conversion::KelvinToCelsius => format!("{} {unit}", shown.round() + 0.0), - Conversion::SamplesToMilliseconds => format!("{shown:.2} {unit}"), + Conversion::SamplesToMilliseconds => format!("{} {unit}", texts::decimal(shown, 2)), } } @@ -73,7 +73,8 @@ fn raw_text(value: &Value) -> String { .map(raw_text) .collect::>() .join(LIST_SEPARATOR), - Value::Number(_) | Value::Object(_) => value.to_string(), + Value::Number(_) => texts::number_text(value.to_string()), + Value::Object(_) => value.to_string(), } } @@ -102,8 +103,8 @@ mod tests { #[test] fn delay_is_shown_in_milliseconds() { let delay = entry("audio/out/delay"); - assert_eq!(shown_value(delay, &json!(3732)), "77.75 ms"); - assert_eq!(shown_value(delay, &json!(0)), "0.00 ms"); + assert_eq!(shown_value(delay, &json!(3732)), "77,75 ms"); + assert_eq!(shown_value(delay, &json!(0)), "0,00 ms"); } #[test] diff --git a/crates/app/src/groups.rs b/crates/app/src/groups.rs index 6686a0b..bf5fd1a 100644 --- a/crates/app/src/groups.rs +++ b/crates/app/src/groups.rs @@ -4,11 +4,13 @@ use std::collections::HashMap; use std::ops::RangeInclusive; +use neumann_control_core::catalog::Catalog; use neumann_control_core::settings::{GroupSettings, Linkable}; use neumann_control_core::ssc::wire::SscPath; use crate::paths; use crate::state::SpeakerKey; +use crate::texts; const SERIAL_PREFIX: &str = "serial:"; const ADDRESS_PREFIX: &str = "address:"; @@ -32,6 +34,14 @@ pub fn linked_path(linkable: Linkable) -> SscPath { paths::path(text) } +/// A linked setting is named as the device panel names it, so a link button and the panel +/// never disagree about which setting it is. +pub fn linkable_label(linkable: Linkable) -> &'static str { + let path = linked_path(linkable); + paths::catalog_entry(Catalog::embedded(), &path) + .map_or("", |entry| entry.label(texts::language())) +} + pub const LINKABLE: [Linkable; 4] = [ Linkable::Level, Linkable::Mute, diff --git a/crates/app/src/paths.rs b/crates/app/src/paths.rs index 9584fe5..381e5d1 100644 --- a/crates/app/src/paths.rs +++ b/crates/app/src/paths.rs @@ -19,6 +19,8 @@ pub const CONTROL_MODE: &str = "ui/control_mode"; pub const INPUT_SELECT: &str = "ui/input_select"; pub const STANDBY_ENABLED: &str = "device/standby/enabled"; pub const INPUT_INTERFACE: &str = "audio/in/interface"; +pub const DELAY: &str = "audio/out/delay"; +pub const STANDBY_TIME: &str = "device/standby/auto_standby_time"; pub fn path(text: &str) -> SscPath { SscPath::parse(text).expect("constant path is valid") diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 74430a9..76811c6 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -9,7 +9,7 @@ use std::fmt; use eframe::egui::{RichText, WidgetText}; use neumann_control_core::address::AddressError; use neumann_control_core::catalog::ValueError; -use neumann_control_core::settings::{Language, Linkable}; +use neumann_control_core::settings::Language; use crate::state::Message; use crate::writes::Refusal; @@ -36,6 +36,25 @@ fn pick(german: T, english: T) -> T { } } +/// A number as written in the language: a decimal comma in German, a point in English. Typed +/// values accept both (`gesture::typed_value`). +pub fn number_text(text: String) -> String { + match language() { + Language::German => text.replace('.', ","), + Language::English => text, + } +} + +/// `value` with `places` decimals. +pub fn decimal(value: f64, places: usize) -> String { + number_text(format!("{value:.places$}")) +} + +/// The shortest text that reads back as exactly `value`. +pub fn number(value: f64) -> String { + number_text(value.to_string()) +} + /// One UI text in both languages. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct Text { @@ -93,8 +112,6 @@ pub const NO_SPEAKERS: Text = Text::new( pub const MUTE: Text = Text::new("Stumm", "Mute"); pub const IDENTIFY: Text = Text::new("Ident.", "Ident."); -pub const CONNECTING: Text = Text::new("verbinde …", "connecting …"); -pub const CONNECTED: Text = Text::new("verbunden", "connected"); pub const LEVEL: Text = Text::new("Pegel", "Level"); pub const CLIP: Text = Text::new("Übersteuert", "Clipping"); pub const LED_CONNECTION: Text = Text::new("Verbindung", "Connection"); @@ -104,8 +121,6 @@ pub const LED_LOCAL: Text = Text::new( "LOCAL: Schalter hinten, Schreiben gesperrt", "LOCAL: rear switch, writing locked", ); -pub const KNOB_DELAY: Text = Text::new("Delay", "Delay"); -pub const KNOB_STANDBY: Text = Text::new("Standby", "Standby"); pub const CLOSE: Text = Text::new("Schließen", "Close"); pub const ADD_GROUP: Text = Text::new("+ Gruppe", "+ Group"); pub const MOVE_STRIP: Text = Text::new("Verschieben", "Move"); @@ -116,10 +131,6 @@ pub const NEW_GROUP_NAME: Text = Text::new("Gruppe", "Group"); pub const GROUP: Text = Text::new("Gruppe", "Group"); pub const NO_GROUP: Text = Text::new("keine", "none"); pub const LINKED: Text = Text::new("Gekoppelt", "Linked"); -pub const LINK_LEVEL: Text = Text::new("Pegel", "Level"); -pub const LINK_MUTE: Text = Text::new("Stumm", "Mute"); -pub const LINK_STANDBY: Text = Text::new("Standby", "Standby"); -pub const LINK_INPUT: Text = Text::new("Eingang", "Input"); pub const COPY_TO_GROUP: Text = Text::new("Für Gruppe übernehmen", "Apply to group"); pub const MASTER: Text = Text::new("Master", "Master"); pub const MASTER_LEVEL: Text = Text::new("Master-Pegel", "Master level"); @@ -147,8 +158,8 @@ pub const METER_RANGE: Text = Text::new("Pegelanzeige ab", "Meters from"); pub const SHOW_RESPONSE_TIMES: Text = Text::new("Antwortzeiten anzeigen", "Show response times"); pub const INPUT_METERS: Text = Text::new("Eingangspegel", "Input level"); pub const OUTPUT_METERS: Text = Text::new("Ausgangspegel", "Output level"); -pub const METER_INPUT_SHORT: Text = Text::new("Ein", "In"); -pub const METER_OUTPUT_SHORT: Text = Text::new("Aus", "Out"); +pub const METER_INPUT_SHORT: Text = Text::new("Eing.", "In"); +pub const METER_OUTPUT_SHORT: Text = Text::new("Ausg.", "Out"); pub const SOURCE_ANALOG: Text = Text::new("Analog", "Analog"); pub const SOURCE_AES3_LEFT: Text = Text::new("AES3 L", "AES3 L"); pub const SOURCE_AES3_RIGHT: Text = Text::new("AES3 R", "AES3 R"); @@ -278,15 +289,6 @@ pub fn link_label(name: &str) -> String { pick(format!("{name} koppeln"), format!("Link {name}")) } -pub fn linkable_name(linkable: Linkable) -> Text { - match linkable { - Linkable::Level => LINK_LEVEL, - Linkable::Mute => LINK_MUTE, - Linkable::Standby => LINK_STANDBY, - Linkable::Input => LINK_INPUT, - } -} - pub fn copy_to_group(setting: &str) -> String { format!("{COPY_TO_GROUP}: {setting}") } @@ -305,16 +307,16 @@ pub fn response_time(milliseconds: u128) -> String { /// How far a group or master fader has moved while it is dragged. pub fn level_move(moved: f64) -> String { - format!("{moved:+.1} dB") + number_text(format!("{moved:+.1} dB")) } /// A group's or the master's level, without unit: it moves several speakers by the same amount. pub fn shared_readout(value: f64) -> String { - format!("{value:.1}") + decimal(value, 1) } pub fn readout(value: f64, unit: &str) -> String { - format!("{value:.1} {unit}") + format!("{} {unit}", decimal(value, 1)) } pub fn status_line(firmware: &str, masked_serial: &str) -> String { @@ -362,8 +364,8 @@ pub fn serial_mismatch(masked_serial: &str) -> String { pub fn describe_value_error(error: &ValueError) -> String { match error { ValueError::OutOfRange { min, max } => pick( - format!("Wert außerhalb von {min} bis {max}."), - format!("Value outside {min} to {max}."), + format!("Wert außerhalb von {} bis {}.", number(*min), number(*max)), + format!("Value outside {} to {}.", number(*min), number(*max)), ), ValueError::NotAnOption => Text::new( "Wert ist keine der Auswahlmöglichkeiten.", @@ -437,5 +439,9 @@ mod tests { assert_eq!(speaker_count(1), "1 speaker"); set_language(Language::German); assert_eq!(MUTE.to_string(), "Stumm"); + assert_eq!(readout(87.5, "dB SPL"), "87,5 dB SPL"); + set_language(Language::English); + assert_eq!(readout(87.5, "dB SPL"), "87.5 dB SPL"); + set_language(Language::German); } } diff --git a/crates/app/src/views/details_fields.rs b/crates/app/src/views/details_fields.rs index b98e0db..7aec383 100644 --- a/crates/app/src/views/details_fields.rs +++ b/crates/app/src/views/details_fields.rs @@ -75,7 +75,8 @@ impl Fields<'_> { // speaker as the range's end. A drag is kept inside below; a typed value goes to the write // policy as typed and is refused with the reason when outside. let mut drag = DragValue::new(&mut value) - .custom_formatter(|number, _| number.to_string()) + .custom_formatter(|number, _| texts::number(number)) + .custom_parser(|text| typed_value(text, None)) .speed(step); if let Some(unit) = &entry.unit { drag = drag.suffix(format!(" {unit}")); @@ -84,7 +85,7 @@ impl Fields<'_> { // Typed and shown in the converted unit; back on the device's step grid, so the // shown text parses to exactly the device value and a visit writes nothing. drag = drag - .custom_formatter(move |number, _| conversion.apply(number).to_string()) + .custom_formatter(move |number, _| texts::number(conversion.apply(number))) .custom_parser(move |text| { typed_value(text, Some(conversion)).map(|typed| on_grid(typed, step)) }) diff --git a/crates/app/src/views/group_strip.rs b/crates/app/src/views/group_strip.rs index edb1e42..f1b57df 100644 --- a/crates/app/src/views/group_strip.rs +++ b/crates/app/src/views/group_strip.rs @@ -9,7 +9,7 @@ use eframe::egui::{ use neumann_control_core::settings::{GroupSettings, Linkable}; use super::fader::{self, Orientation}; -use crate::groups::{SharedLevel, LINKABLE}; +use crate::groups::{linkable_label, SharedLevel, LINKABLE}; use crate::texts; use crate::ui_kit::theme::Tokens; @@ -45,10 +45,12 @@ pub fn group( RichText::new(texts::LINKED).small().weak(), )); for linkable in LINKABLE { - let text = texts::linkable_name(linkable); + let text = linkable_label(linkable); let on = group.linked.contains(&linkable); - let button = ui.add(Button::new(RichText::new(text).small()).selected(on)); - let label = texts::link_label(text.get()); + // Wrapped: the panel's names ("Eingangs-Interface") are wider than the strip. + let button = Button::new(RichText::new(text).small()).wrap().selected(on); + let button = ui.add(button); + let label = texts::link_label(text); button.widget_info(|| WidgetInfo::selected(WidgetType::Checkbox, true, on, &label)); if button.clicked() { response.toggled_link = Some(linkable); diff --git a/crates/app/src/views/knob.rs b/crates/app/src/views/knob.rs index a59722e..e209078 100644 --- a/crates/app/src/views/knob.rs +++ b/crates/app/src/views/knob.rs @@ -272,7 +272,7 @@ pub(super) fn value_entry( let text_id = id.with(EDIT_TEXT_ID); if open_now { let shown = conversion.map_or(value, |conversion| conversion.apply(value)); - ui.data_mut(|data| data.insert_temp(text_id, shown.to_string())); + ui.data_mut(|data| data.insert_temp(text_id, texts::number(shown))); } let mut text = ui.data(|data| data.get_temp::(text_id))?; let field = ui.add( diff --git a/crates/app/src/views/panel.rs b/crates/app/src/views/panel.rs index 4705201..eee063f 100644 --- a/crates/app/src/views/panel.rs +++ b/crates/app/src/views/panel.rs @@ -9,6 +9,7 @@ use neumann_control_core::speaker::Command; use super::details; use crate::display::firmware_version; +use crate::groups::linkable_label; use crate::state::SpeakerState; use crate::texts; @@ -162,7 +163,7 @@ fn copy_buttons(ui: &mut Ui, groups: &GroupChoice) -> Option { let mut copy = None; ui.horizontal_wrapped(|ui| { for linkable in groups.linked { - let text = texts::copy_to_group(texts::linkable_name(*linkable).get()); + let text = texts::copy_to_group(linkable_label(*linkable)); if ui.button(text).clicked() { copy = Some(*linkable); } diff --git a/crates/app/src/views/strip.rs b/crates/app/src/views/strip.rs index d61ec5e..577ab9c 100644 --- a/crates/app/src/views/strip.rs +++ b/crates/app/src/views/strip.rs @@ -40,9 +40,7 @@ pub(super) const UNREACHABLE_OPACITY: f32 = 0.55; const METER_GROUP_GAP: f32 = 4.0; const METER_SPACING: f32 = 3.0; const CAPTION_HEIGHT: f32 = 16.0; -const DELAY_PATH: &str = "audio/out/delay"; const LEVEL_ENTRY_ID: &str = "level_entry"; -const STANDBY_TIME_PATH: &str = "device/standby/auto_standby_time"; pub struct StripResponse { /// The writes the user asked for that the write policy allowed. @@ -175,20 +173,19 @@ fn knobs(ui: &mut Ui, speaker: &mut SpeakerState, catalog: &Catalog, commands: & entries.iter().copied().find(|entry| entry.path == path) }; ui.columns(2, |columns| { - let knobs = [ - (DELAY_PATH, texts::KNOB_DELAY), - (STANDBY_TIME_PATH, texts::KNOB_STANDBY), - ]; - for (column, (path, label)) in columns.iter_mut().zip(knobs) { + let knobs = [paths::DELAY, paths::STANDBY_TIME]; + for (column, path) in columns.iter_mut().zip(knobs) { let Some(entry) = entry(path) else { continue; }; + // Named as in the device panel, so one setting has one name. + let label = entry.label(texts::language()); let sent = knob::for_entry( column, speaker, catalog, entry, - label.get(), + label, true, knob::KNOB_DIAMETER, ); diff --git a/crates/app/tests/details.rs b/crates/app/tests/details.rs index b950b8d..4d12cc6 100644 --- a/crates/app/tests/details.rs +++ b/crates/app/tests/details.rs @@ -4,7 +4,7 @@ mod support; use eframe::egui::{self, accesskit::Role}; use egui_kittest::kittest::{NodeT, Queryable}; -use egui_kittest::Harness; +use egui_kittest::{Harness, Node}; use neumann_control_app::app::NeumannApp; use neumann_control_app::texts; use neumann_control_app::writes::Refusal; @@ -159,7 +159,7 @@ fn an_announced_model_gives_no_write_controls_or_model_rows() { #[test] fn number_field_sends_once_per_gesture() { let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); - let start = harness.get_by_label(DELAY_LABEL).rect().center(); + let start = number_field(&harness, DELAY_LABEL).rect().center(); harness.drag_at(start); for frame in 1..=10 { harness.hover_at(start + egui::vec2(4.0 * frame as f32, 0.0)); @@ -184,16 +184,23 @@ fn number_field_sends_once_per_gesture() { assert!(delay > 0, "dragging right raises the delay: {sets:?}"); } +/// The panel's number field; the strip's knob for the same setting carries the same name. +fn number_field<'a>(harness: &'a Harness<'static, NeumannApp>, label: &'a str) -> Node<'a> { + harness.get_by_role_and_label(Role::SpinButton, label) +} + /// Clicks a number field into text editing and types `text` over its value. fn type_number(harness: &mut Harness<'static, NeumannApp>, label: &str, text: &str) { - harness.get_by_label(label).click(); + number_field(&harness, label).click(); harness.run_steps(1); - harness.get_by_label(label).type_text(text); + number_field(&harness, label).type_text(text); harness.run_steps(1); } fn numeric_value(harness: &Harness<'static, NeumannApp>, label: &str) -> Option { - harness.get_by_label(label).accesskit_node().numeric_value() + number_field(&harness, label) + .accesskit_node() + .numeric_value() } #[test] @@ -216,7 +223,7 @@ fn visiting_a_number_field_does_not_write_its_rounded_text() { wait_until(&mut harness, "the reported delay", |harness| { numeric_value(harness, DELAY_LABEL) == Some(100.123456) }); - harness.get_by_label(DELAY_LABEL).click(); + number_field(&harness, DELAY_LABEL).click(); harness.run_steps(2); harness.key_press(egui::Key::Tab); harness.run_steps(IDLE_FRAMES); @@ -232,7 +239,7 @@ fn visiting_an_out_of_range_number_field_writes_nothing() { wait_until(&mut harness, "the reported delay", |harness| { numeric_value(harness, DELAY_LABEL) == Some(6000.0) }); - harness.get_by_label(DELAY_LABEL).click(); + number_field(&harness, DELAY_LABEL).click(); harness.run_steps(2); harness.key_press(egui::Key::Tab); harness.run_steps(IDLE_FRAMES); @@ -245,7 +252,7 @@ fn visiting_an_out_of_range_number_field_writes_nothing() { #[test] fn a_number_typed_and_entered_in_one_frame_is_sent() { let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); - harness.get_by_label(STANDBY_TIME_LABEL).click(); + number_field(&harness, STANDBY_TIME_LABEL).click(); harness.run_steps(2); let events = &mut harness.input_mut().events; events.push(egui::Event::Text("50".to_owned())); @@ -284,7 +291,7 @@ fn a_typed_number_is_snapped_only_when_sent() { let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); type_number(&mut harness, STANDBY_TIME_LABEL, "1.5"); harness.run_steps(2); - let shown = harness.get_by_label(STANDBY_TIME_LABEL).value(); + let shown = number_field(&harness, STANDBY_TIME_LABEL).value(); assert_eq!(shown.as_deref(), Some("1.5")); harness.key_press(egui::Key::Enter); wait_until(&mut harness, "the Set after Enter", |_| { @@ -423,9 +430,16 @@ fn all_values_button_reads_the_whole_tree() { #[test] fn panel_number_rows_keep_their_labels_on_one_line() { let (_simulator, _directory, harness) = expanded_card(SimModel::Kh120Ii); + // The panel row and the strip knob's caption carry the same name; neither may wrap. for label in [STANDBY_TIME_LABEL, LOGO_LABEL, DELAY_LABEL] { - let rect = harness.get_by_role_and_label(Role::Label, label).rect(); - assert!(rect.width() > rect.height(), "{label} wraps: {rect:?}"); + let rects: Vec<_> = harness + .query_all_by_role_and_label(Role::Label, label) + .map(|node| node.rect()) + .collect(); + assert!(!rects.is_empty(), "{label}"); + for rect in rects { + assert!(rect.width() > rect.height(), "{label} wraps: {rect:?}"); + } } // The number field shows the value; the knob beside it does not repeat it, and the strip // shows a whole number as a whole number. @@ -466,9 +480,9 @@ fn visiting_a_delay_in_milliseconds_writes_nothing() { // One sample is 0.0208333… ms, which no decimal text holds exactly. simulator.set_value("audio/out/delay", json!(1)); wait_until(&mut harness, "the reported delay", |harness| { - harness.query_by_label_contains("0.02").is_some() + harness.query_by_label_contains("0,02").is_some() }); - harness.get_by_label(DELAY_LABEL).click(); + number_field(&harness, DELAY_LABEL).click(); harness.run_steps(1); harness.key_press(egui::Key::Tab); harness.run_steps(IDLE_FRAMES); diff --git a/crates/app/tests/groups.rs b/crates/app/tests/groups.rs index 8c5ea04..e3d7064 100644 --- a/crates/app/tests/groups.rs +++ b/crates/app/tests/groups.rs @@ -7,6 +7,7 @@ use eframe::egui::{self, accesskit::Role}; use egui_kittest::kittest::Queryable; use egui_kittest::Harness; use neumann_control_app::app::NeumannApp; +use neumann_control_app::groups::linkable_label; use neumann_control_app::texts; use neumann_control_core::settings::{self, GroupSettings, Linkable, Settings, SETTINGS_FILE}; use neumann_control_core::simulator::{SimModel, Simulator}; @@ -88,7 +89,7 @@ fn group_and_master_mute_switch_all() { fn link_buttons_toggle_and_are_saved() { let (_kh120, _kh750, directory, mut harness) = grouped(Vec::new()); harness - .get_by_label(&texts::link_label(texts::LINK_LEVEL.get())) + .get_by_label(&texts::link_label(linkable_label(Linkable::Level))) .click(); harness.run_steps(2); let saved = settings::load(&directory.path().join(SETTINGS_FILE)); diff --git a/crates/app/tests/views.rs b/crates/app/tests/views.rs index b715175..6ae0cfd 100644 --- a/crates/app/tests/views.rs +++ b/crates/app/tests/views.rs @@ -18,6 +18,10 @@ use neumann_control_core::speaker::Event; use serde_json::Value; use support::*; +/// The strip's knobs carry the device panel's names (paths.toml). +const DELAY_KNOB: &str = "Verzögerung"; +const STANDBY_KNOB: &str = "Standby-Zeit"; + #[test] fn strip_shows_name_and_level_and_panel_the_masked_serial() { let (_simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); @@ -382,7 +386,7 @@ fn strip_content_stacks_top_down() { .map(|node| node.rect()) .collect(); let delays: Vec<_> = harness - .query_all_by_role_and_label(Role::Slider, texts::KNOB_DELAY.get()) + .query_all_by_role_and_label(Role::Slider, DELAY_KNOB) .map(|node| node.rect()) .collect(); assert_eq!(mutes.len(), 2); @@ -444,8 +448,8 @@ fn strip_shows_leds_knobs_fader_and_buttons() { ] { harness.get_by_label(label.get()); } - harness.get_by_role_and_label(Role::Slider, texts::KNOB_DELAY.get()); - harness.get_by_role_and_label(Role::Slider, texts::KNOB_STANDBY.get()); + harness.get_by_role_and_label(Role::Slider, DELAY_KNOB); + harness.get_by_role_and_label(Role::Slider, STANDBY_KNOB); harness.get_by_label(texts::MUTE.get()); harness.get_by_label(texts::IDENTIFY.get()); harness.get_by_label(&readout(100.0)); @@ -454,16 +458,16 @@ fn strip_shows_leds_knobs_fader_and_buttons() { #[test] fn kh750_standby_knob_is_read_only() { let (_simulator, _directory, harness) = connected_card(SimModel::Kh750); - let standby = harness.get_by_role_and_label(Role::Slider, texts::KNOB_STANDBY.get()); + let standby = harness.get_by_role_and_label(Role::Slider, STANDBY_KNOB); assert!(standby.accesskit_node().is_disabled()); - let delay = harness.get_by_role_and_label(Role::Slider, texts::KNOB_DELAY.get()); + let delay = harness.get_by_role_and_label(Role::Slider, DELAY_KNOB); assert!(!delay.accesskit_node().is_disabled()); } #[test] fn knob_drag_writes_once_on_release() { let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); - let end = turn_knob(&mut harness, texts::KNOB_DELAY.get(), 90.0, 150.0); + let end = turn_knob(&mut harness, DELAY_KNOB, 90.0, 150.0); barrier(&simulator); assert!( delay_sets(&simulator).is_empty(), @@ -483,7 +487,7 @@ fn knob_drag_writes_once_on_release() { #[test] fn escape_during_knob_drag_sends_nothing() { let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); - let end = turn_knob(&mut harness, texts::KNOB_DELAY.get(), 90.0, 150.0); + let end = turn_knob(&mut harness, DELAY_KNOB, 90.0, 150.0); harness.key_press(egui::Key::Escape); harness.step(); harness.drop_at(end); @@ -496,7 +500,7 @@ fn escape_during_knob_drag_sends_nothing() { fn knob_wheel_writes_after_idle() { let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); let center = harness - .get_by_role_and_label(Role::Slider, texts::KNOB_DELAY.get()) + .get_by_role_and_label(Role::Slider, DELAY_KNOB) .rect() .center(); harness.hover_at(center); @@ -618,13 +622,13 @@ fn knob_value_is_shown_beside_it_while_dragging() { // egui puts a label's text into the node's value. node.accesskit_node() .value() - .is_some_and(|text| text.contains('.')) + .is_some_and(|text| text.contains(',')) }) .map(|node| node.rect()) .collect::>() }; assert_eq!(shown(&harness).len(), 1); - let end = turn_knob(&mut harness, texts::KNOB_DELAY.get(), 90.0, 150.0); + let end = turn_knob(&mut harness, DELAY_KNOB, 90.0, 150.0); // Under the knob and beside it, each on one line. let labels = shown(&harness); assert_eq!(labels.len(), 2); @@ -703,7 +707,7 @@ fn double_click_on_the_level_readout_enters_a_value() { fn double_click_on_the_delay_knob_takes_milliseconds() { let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); let position = harness - .get_by_role_and_label(Role::Slider, texts::KNOB_DELAY.get()) + .get_by_role_and_label(Role::Slider, DELAY_KNOB) .rect() .center(); double_click_at(&mut harness, position); @@ -844,3 +848,11 @@ fn input_source_caption_stays_on_one_line() { "caption {caption:?}, level {level:?}" ); } + +#[test] +fn the_strip_knob_captions_stay_apart() { + let (_simulator, _directory, harness) = connected_card(SimModel::Kh120Ii); + let caption = |label: &str| harness.get_by_role_and_label(Role::Label, label).rect(); + let (delay, standby) = (caption(DELAY_KNOB), caption(STANDBY_KNOB)); + assert!(!delay.intersects(standby), "{delay:?} {standby:?}"); +} From e22a817507777fdb69dbc2280ec92c3ce5c8bbce Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:02:21 +0200 Subject: [PATCH 101/460] Drop the card wording and bring the README up to date Comments, test names and helpers still spoke of cards and "Mehr" from before the desk; the README now says what was checked on real speakers and how the fixed gestures behave. --- README.md | 35 +++++++----- crates/app/src/all_values.rs | 2 +- crates/app/src/app.rs | 18 +++---- crates/app/src/app/desk.rs | 6 +-- crates/app/src/meter.rs | 4 +- crates/app/src/scan_choice.rs | 20 +++---- crates/app/src/state.rs | 12 ++--- crates/app/src/targets.rs | 38 ++++++------- crates/app/src/views/details.rs | 4 +- crates/app/src/views/details_fields.rs | 2 +- crates/app/src/views/remove_speaker.rs | 4 +- crates/app/src/views/scan_choice.rs | 2 +- crates/app/src/writes.rs | 8 +-- crates/app/tests/compact.rs | 12 ++--- crates/app/tests/details.rs | 64 +++++++++++----------- crates/app/tests/groups.rs | 4 +- crates/app/tests/speakers.rs | 34 +++++++----- crates/app/tests/support/mod.rs | 6 +-- crates/app/tests/views.rs | 74 +++++++++++++------------- crates/app/tests/write_gestures.rs | 6 +-- crates/app/tests/write_messages.rs | 6 +-- crates/core/src/paths.toml | 2 +- crates/core/src/settings.rs | 2 +- crates/core/src/ssc/client.rs | 2 +- 24 files changed, 193 insertions(+), 174 deletions(-) diff --git a/README.md b/README.md index 85d6541..bb1bc4e 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,10 @@ This is an independent project. It is not affiliated with or endorsed by Neumann ## Status Version 0.1.0. The protocol layer, the polling and the interface are covered by automated tests -against a built-in speaker simulator for the KH 120 II and the KH 750. The app has not yet been -verified against real speakers; the model support described below follows the protocol -knowledge the path catalog is built from and the simulator, not a hardware test. +against a built-in speaker simulator for the KH 120 II and the KH 750. On real KH 120 II and +KH 750 speakers the app was checked for scanning, reading, metering and writing level, mute, +identify and standby time. Groups, the compact mode and the English interface are so far +covered by the simulator tests only. ## Features @@ -21,7 +22,7 @@ knowledge the path catalog is built from and the simulator, not a hardware test. - One channel strip per speaker, side by side: name, four LEDs (connection, clip, device warning, LOCAL), knobs for delay (shown in ms) and standby time, output level fader (0-120 dB SPL in 0.5 dB steps), input level meters for channels A and B with clip lamps (on - the KH 750 also its output meters A, B and Sub, marked "Aus"), response time, "Stumm" (mute) + the KH 750 also its output meters A, B and Sub, marked "Ausg."), response time, "Stumm" (mute) and "Ident." (identify). On the KH 120 II the input meters are captioned with the rear input switch (Analog, AES3 L, AES3 R, Mono) and a dot marks the channels the speaker plays; that A is the left AES3 channel is assumed, not measured. @@ -31,30 +32,35 @@ knowledge the path catalog is built from and the simulator, not a hardware test. - Knobs: turn them by moving the pointer around the knob (clockwise is more; one and a half turns cover the whole range), Shift for fine steps, the mouse wheel, or a double-click to type a value (the delay in ms). The value stands beside the knob while it moves. -- Fader and knobs write once when you let go, stop the wheel or press Enter; Escape cancels. +- Fader and knobs write once when you let go (only if the value changed), stop the wheel or + press Enter; Escape cancels a drag or a typed value. A typed value outside the range is + refused with the reason instead of being cut to the range's end. - Clicking a speaker's name opens its device panel with every setting the catalog knows for that model (output, input, standby, device, rear panel), grouped in two columns; settings a model may write get a control, the rest are shown greyed. The panel also shows model, firmware and masked serial number. Escape, "Schließen" or a click outside closes it. - Groups: "+ Gruppe" creates a group; a speaker joins one in its device panel ("Gruppe"). Group members are framed in the group's colour and followed by a group strip: its fader moves every - member by the same amount (each stays within its own range), "Stumm" mutes them all, and the - buttons under "Gekoppelt" link level, mute, standby or input, so a change on one member goes - to the others whose model may write it. "Für Gruppe übernehmen" in the panel copies a linked - value to the other members. Clicking the group's name renames or deletes it. + member by the same amount (each stays within its own range; with the loudest member at its + maximum the others still follow), "Stumm" mutes them all, and the buttons under "Gekoppelt" + link level, mute, auto standby or input interface, so a change on one member goes to the others + whose model may write it. Members that are not connected are left out. "Für Gruppe übernehmen" + in the panel copies a linked value to the other members. Clicking the group's name renames or + deletes it. - A master strip at the end moves and mutes all connected speakers the same way. - Drag a strip by the dots before its name; the other strips of its group (or the ungrouped ones) make room where it would land, and the order is remembered when you let go. Escape cancels. - "Einstellungen" sets the theme (like Windows, light, dark), the language (Deutsch, English; - it changes at once), the lowest level the meters show (-120, -90 or -60 dB) and whether - response times are shown; the toolbar shows the average response time of the connected + it changes at once; German shows a decimal comma, and typed values take a comma or a point), + the lowest level the meters show (-120, -90 or -60 dB) and whether response times are shown; the toolbar shows the average response time of the connected speakers. - "Kompakt" (or Ctrl+K) switches to a compact window and back: one row per ungrouped speaker and per group, each with LEDs (a group: its colour), name, a lying level fader with its value, the loudest input as a small meter and "Stumm", and the master row at the bottom. A group row - moves its members like the group strip. The compact window keeps its own size and position; - scanning and adding speakers or groups stay on the desk. + moves its members like the group strip; a "!" stands for a speaker's message, which shows on + hover. The compact window keeps its own size and position and scrolls sideways when narrower + than its rows; scanning and adding speakers or groups stay on the desk. - "Alle Werte..." lists every value the speaker reports in its own schema, with a path filter and copy buttons, including paths the catalog does not know yet. - Shows the control mode (NETWORK or LOCAL), which the CONTROL switch on the back of the speaker @@ -99,7 +105,8 @@ Added speakers, whether chosen from a scan or entered by address, are remembered at every start without a scan. When a later scan finds a remembered speaker at a different address (link-local addresses and interface indexes can change), the stored address is updated. -To remove a speaker, open its device panel, press "Entfernen" and confirm with "Entfernen". +To remove a speaker, open its device panel, press "Entfernen" and confirm with "Entfernen". It +also leaves its group and its place in the order. The strip disappears and the speaker is no longer remembered. IPv4 addresses are not accepted: the speakers are reachable only over IPv6. diff --git a/crates/app/src/all_values.rs b/crates/app/src/all_values.rs index cb31c99..4602792 100644 --- a/crates/app/src/all_values.rs +++ b/crates/app/src/all_values.rs @@ -18,7 +18,7 @@ const UNITS_KEY: &str = "units"; const MINIMUM_KEY: &str = "min"; const MAXIMUM_KEY: &str = "max"; const OPTIONS_KEY: &str = "option"; -/// Leaves that name the device itself; shown masked like the serial on the card. +/// Leaves that name the device itself; shown masked like the serial in the device panel. const IDENTIFIER_PATHS: &[&str] = &[paths::SERIAL, paths::MAC_ADDRESSES]; #[derive(Clone, Debug, PartialEq)] diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index d999c56..fc2898b 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -20,7 +20,7 @@ use crate::scan_choice::ScanChoice; use crate::spawner::{ScanResult, SpeakerSpawner}; use crate::state::{AllValuesState, SpeakerKey, SpeakerState, Status}; use crate::status_leds::average_response_time; -use crate::targets::{duplicate_cards, follow_moves, forget, remember_serial, targets}; +use crate::targets::{duplicate_speakers, follow_moves, forget, remember_serial, targets}; use crate::texts; use crate::ui_kit::{fonts, theme}; use crate::views::add_address::{self, AddAddressAction, AddAddressForm}; @@ -66,7 +66,7 @@ pub struct NeumannApp { spawner: Box, wake: Arc, speakers: Vec, - /// Everything scans reported this session; names and models for the cards. + /// Everything scans reported this session; names and models for the strips. found: Vec, scan: Option>, scan_choice: Option, @@ -112,7 +112,7 @@ impl NeumannApp { app } - /// Starts workers for remembered speakers without a card and moves cards whose entry now + /// Starts workers for remembered speakers not shown yet and moves speakers whose entry now /// points at another address. fn sync_targets(&mut self) { for (key, address) in targets(&self.settings.manual) { @@ -190,7 +190,7 @@ impl NeumannApp { if changed { self.save_settings(); } - self.drop_duplicate_cards(); + self.drop_duplicate_speakers(); } /// A speaker's key changed (its serial became known): its group, its place in the order and @@ -204,14 +204,14 @@ impl NeumannApp { rename_member(&mut settings.groups, &mut settings.order, &old, &new) } - /// One speaker reached at two addresses shows up as two cards until both connected. - fn drop_duplicate_cards(&mut self) { + /// One speaker reached at two addresses shows up twice until both connected. + fn drop_duplicate_speakers(&mut self) { let keys: Vec = self .speakers .iter() .map(|speaker| speaker.state.key.clone()) .collect(); - for position in duplicate_cards(&keys).into_iter().rev() { + for position in duplicate_speakers(&keys).into_iter().rev() { let speaker = self.speakers.remove(position); log::info!( "{} is a speaker already shown", @@ -248,7 +248,7 @@ impl NeumannApp { } self.sync_targets(); if self.scan_choice.is_some() { - self.scan_choice = Some(ScanChoice::offer(&found, &self.cards())); + self.scan_choice = Some(ScanChoice::offer(&found, &self.shown_speakers())); } } @@ -260,7 +260,7 @@ impl NeumannApp { } } - fn cards(&self) -> Vec<(SpeakerKey, SocketAddrV6)> { + fn shown_speakers(&self) -> Vec<(SpeakerKey, SocketAddrV6)> { self.speakers .iter() .map(|speaker| (speaker.state.key.clone(), speaker.state.address)) diff --git a/crates/app/src/app/desk.rs b/crates/app/src/app/desk.rs index f12c2eb..ed8b3a6 100644 --- a/crates/app/src/app/desk.rs +++ b/crates/app/src/app/desk.rs @@ -328,7 +328,7 @@ impl NeumannApp { /// speakers of its section (its group, or the ungrouped ones), placed by where their strips were /// last frame. Before every one of them was drawn once, the desk stays as it is. fn previewed( - ctx: &Context, + context: &Context, items: Vec, dragged: usize, ids: &[String], @@ -343,14 +343,14 @@ fn previewed( }) .filter(|index| group_of(groups, &ids[*index]) == section) .collect(); - let centers: HashMap = ctx + let centers: HashMap = context .data(|data| data.get_temp(Id::new(STRIP_CENTERS_ID))) .unwrap_or_default(); let known: Option> = others .iter() .map(|index| centers.get(&ids[*index]).copied()) .collect(); - let (Some(known), Some(pointer)) = (known, ctx.pointer_interact_pos()) else { + let (Some(known), Some(pointer)) = (known, context.pointer_interact_pos()) else { return items; }; with_moved(&items, dragged, &others, insertion_slot(&known, pointer.x)) diff --git a/crates/app/src/meter.rs b/crates/app/src/meter.rs index 9ba3f7a..2003fe2 100644 --- a/crates/app/src/meter.rs +++ b/crates/app/src/meter.rs @@ -1,8 +1,10 @@ //! Level meters: the shown level and a peak held for a while. Times are seconds of the UI clock. +use neumann_control_core::settings::MeterFloor; + pub const PEAK_HOLD_SECONDS: f64 = 1.5; /// Low enough that quiet room volume (inputs around -90 dB on the KH 120 II) still moves. -pub const METER_FLOOR_DB: f64 = -120.0; +pub const METER_FLOOR_DB: f64 = MeterFloor::Minus120.db(); pub const METER_CEILING_DB: f64 = 6.0; /// Below this a level is safe, up to `OVER_FROM_DB` hot, above over. diff --git a/crates/app/src/scan_choice.rs b/crates/app/src/scan_choice.rs index a9c83a8..fd098eb 100644 --- a/crates/app/src/scan_choice.rs +++ b/crates/app/src/scan_choice.rs @@ -1,5 +1,5 @@ //! What the "Gefundene Lautsprecher" dialog offers after "Suchen": the speakers a scan found, -//! which of them already have a card, and which the user checked. +//! which of them are already shown, and which the user checked. use std::net::SocketAddrV6; @@ -8,7 +8,7 @@ use neumann_control_core::mask::mask_host; use neumann_control_core::settings::ManualSpeaker; use crate::state::SpeakerKey; -use crate::targets::{entry_for, has_card}; +use crate::targets::{entry_for, is_shown}; #[derive(Clone, Debug, PartialEq)] pub enum ScanChoice { @@ -20,26 +20,26 @@ pub enum ScanChoice { #[derive(Clone, Debug, PartialEq)] pub struct Offer { pub found: Found, - /// Already has a card, so it cannot be chosen again. + /// Already shown, so it cannot be chosen again. pub known: bool, pub checked: bool, } impl ScanChoice { - /// `cards` are the key and address of every card in the window. - pub fn offer(found: &[Found], cards: &[(SpeakerKey, SocketAddrV6)]) -> ScanChoice { + /// `shown` are the key and address of every speaker in the window. + pub fn offer(found: &[Found], shown: &[(SpeakerKey, SocketAddrV6)]) -> ScanChoice { let offers = found .iter() .map(|found| Offer { found: found.clone(), - known: has_card(found, cards), + known: is_shown(found, shown), checked: false, }) .collect(); ScanChoice::Offered(offers) } - /// Flips the check of one row; rows of speakers with a card stay unchecked. + /// Flips the check of one row; rows of speakers already shown stay unchecked. pub fn toggle(&mut self, index: usize) { let ScanChoice::Offered(offers) = self else { return; @@ -124,7 +124,7 @@ mod tests { } fn choice() -> ScanChoice { - let cards = [( + let shown = [( SpeakerKey::Serial(KNOWN_SERIAL.to_owned()), address(KNOWN_ADDRESS_TEXT), )]; @@ -133,12 +133,12 @@ mod tests { found("Desk", KNOWN_SERIAL, KNOWN_ADDRESS_TEXT), found("Sub", NEW_SERIAL, NEW_ADDRESS_TEXT), ], - &cards, + &shown, ) } #[test] - fn speakers_with_a_card_are_offered_as_known_and_nothing_is_checked() { + fn speakers_already_shown_are_offered_as_known_and_nothing_is_checked() { let ScanChoice::Offered(offers) = choice() else { panic!("a finished scan offers its speakers"); }; diff --git a/crates/app/src/state.rs b/crates/app/src/state.rs index 0e395ab..e81fa28 100644 --- a/crates/app/src/state.rs +++ b/crates/app/src/state.rs @@ -1,4 +1,4 @@ -//! UI-independent state of one speaker card: what the worker events add up to. +//! UI-independent state of one speaker: what the worker events add up to. //! All times are seconds of the UI clock passed in by the caller. use std::collections::{BTreeMap, BTreeSet}; @@ -28,13 +28,13 @@ pub enum SpeakerKey { Address(SocketAddrV6), } -/// What the card's message is about, which decides when it ends. +/// What the speaker's message is about, which decides when it ends. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum MessageSource { /// Connecting, unreachable, another device: replaced by the next connection event. Connection, /// A refused, rejected or failed write. It stays until the user's next write request on - /// this card (or a connection event): the worker re-reads a path right after a rejection + /// this speaker (or a connection event): the worker re-reads a path right after a rejection /// and polls it every few seconds, so ending it on a fresh value hid it at once; and the /// texts do not name the path, so they are stale after any later request. Write, @@ -165,7 +165,7 @@ impl SpeakerState { self.status = Status::Connected; self.set_message(None, MessageSource::Connection); } - // The masked reason is for the log; the card only shows the generic text. + // The masked reason is for the log; the strip only shows the generic text. Event::Unreachable(_) => { self.status = Status::Unreachable; self.set_message(Some(Message::Unreachable), MessageSource::Connection); @@ -335,7 +335,7 @@ mod tests { } #[test] - fn unreachable_event_greys_card_and_keeps_values() { + fn unreachable_event_greys_the_speaker_and_keeps_values() { let mut speaker = state(); speaker.apply( Event::Value { @@ -423,7 +423,7 @@ mod tests { } #[test] - fn a_huge_device_name_is_shortened_for_the_card() { + fn a_huge_device_name_is_shortened_for_the_strip() { let mut speaker = state(); let huge = "n".repeat(1_000_000); speaker.apply(Event::Connected(identity(&huge, "KH 120 II")), 0.0); diff --git a/crates/app/src/targets.rs b/crates/app/src/targets.rs index a8354d1..0fcf2d6 100644 --- a/crates/app/src/targets.rs +++ b/crates/app/src/targets.rs @@ -32,10 +32,10 @@ pub fn targets(manual: &[ManualSpeaker]) -> Vec<(SpeakerKey, SocketAddrV6)> { targets } -/// Whether a scanned speaker already has a card: by serial when the scan reported one, by +/// Whether a scanned speaker is already shown: by serial when the scan reported one, by /// address otherwise. -pub fn has_card(found: &Found, cards: &[(SpeakerKey, SocketAddrV6)]) -> bool { - cards.iter().any(|(key, address)| match &found.serial { +pub fn is_shown(found: &Found, shown: &[(SpeakerKey, SocketAddrV6)]) -> bool { + shown.iter().any(|(key, address)| match &found.serial { Some(serial) => *key == SpeakerKey::Serial(serial.clone()) || *address == found.address, None => *address == found.address, }) @@ -80,7 +80,7 @@ pub fn follow_moves(manual: &mut Vec, found: &[Found]) -> bool { changed } -/// Drops every remembered entry of a removed card: those with its serial and those at its +/// Drops every remembered entry of a removed speaker: those with its serial and those at its /// address. True when an entry was removed. pub fn forget(manual: &mut Vec, key: &SpeakerKey, address: SocketAddrV6) -> bool { let before = manual.len(); @@ -119,9 +119,9 @@ pub fn remember_serial(manual: &mut [ManualSpeaker], serial: &str, address: Sock changed } -/// Positions of cards whose serial an earlier card already has: one speaker reached at two -/// addresses. The first card stays. -pub fn duplicate_cards(keys: &[SpeakerKey]) -> Vec { +/// Positions of speakers whose serial an earlier one already has: one speaker reached at two +/// addresses. The first stays. +pub fn duplicate_speakers(keys: &[SpeakerKey]) -> Vec { keys.iter() .enumerate() .filter(|(position, key)| { @@ -187,7 +187,7 @@ mod tests { } #[test] - fn later_cards_with_a_known_serial_are_duplicates() { + fn later_speakers_with_a_known_serial_are_duplicates() { let keys = [ SpeakerKey::Address(address(ADDRESS_TEXT)), SpeakerKey::Serial(SERIAL.to_owned()), @@ -195,7 +195,7 @@ mod tests { SpeakerKey::Serial(OTHER_SERIAL.to_owned()), SpeakerKey::Serial(SERIAL.to_owned()), ]; - assert_eq!(duplicate_cards(&keys), vec![4]); + assert_eq!(duplicate_speakers(&keys), vec![4]); } #[test] @@ -223,25 +223,25 @@ mod tests { } #[test] - fn a_scanned_speaker_has_a_card_by_serial_or_else_by_address() { - let cards = [ + fn a_scanned_speaker_is_shown_by_serial_or_else_by_address() { + let shown = [ (SpeakerKey::Serial(SERIAL.to_owned()), address(ADDRESS_TEXT)), ( SpeakerKey::Address(address(OTHER_ADDRESS_TEXT)), address(OTHER_ADDRESS_TEXT), ), ]; - assert!(has_card(&found(UNUSED_ADDRESS_TEXT, Some(SERIAL)), &cards)); - assert!(has_card(&found(OTHER_ADDRESS_TEXT, None), &cards)); - assert!(has_card( + assert!(is_shown(&found(UNUSED_ADDRESS_TEXT, Some(SERIAL)), &shown)); + assert!(is_shown(&found(OTHER_ADDRESS_TEXT, None), &shown)); + assert!(is_shown( &found(OTHER_ADDRESS_TEXT, Some(OTHER_SERIAL)), - &cards + &shown )); - assert!(!has_card( + assert!(!is_shown( &found(UNUSED_ADDRESS_TEXT, Some(OTHER_SERIAL)), - &cards + &shown )); - assert!(!has_card(&found(UNUSED_ADDRESS_TEXT, None), &cards)); + assert!(!is_shown(&found(UNUSED_ADDRESS_TEXT, None), &shown)); } #[test] @@ -291,7 +291,7 @@ mod tests { } #[test] - fn forgetting_a_card_drops_its_serial_and_its_address() { + fn forgetting_a_speaker_drops_its_serial_and_its_address() { let mut entries = vec![ manual(ADDRESS_TEXT, None), manual(OTHER_ADDRESS_TEXT, Some(SERIAL)), diff --git a/crates/app/src/views/details.rs b/crates/app/src/views/details.rs index 886e627..4c9d3f2 100644 --- a/crates/app/src/views/details.rs +++ b/crates/app/src/views/details.rs @@ -98,10 +98,10 @@ fn groups<'a>(entries: Vec<&'a Entry>, speaker: &SpeakerState) -> Groups<'a> { /// Meters sit on the strip and clip lists light their lamps, so neither gets a row here. fn is_shown(entry: &Entry, speaker: &SpeakerState) -> bool { let path = entry.path.to_string(); - let on_card = SHOWN_ELSEWHERE.contains(&path.as_str()); + let on_strip = SHOWN_ELSEWHERE.contains(&path.as_str()); let absent = speaker.absent.contains(&entry.path); let drawable = !matches!(entry.kind, Kind::Clip | Kind::Meter); - drawable && !on_card && !absent + drawable && !on_strip && !absent } fn row(ui: &mut Ui, fields: &mut Fields, entry: &Entry) { diff --git a/crates/app/src/views/details_fields.rs b/crates/app/src/views/details_fields.rs index 7aec383..b60b947 100644 --- a/crates/app/src/views/details_fields.rs +++ b/crates/app/src/views/details_fields.rs @@ -1,4 +1,4 @@ -//! The controls of the "Mehr" area, one per writable kind: number, switch, selection and text. +//! The device panel's controls, one per writable kind: number, switch, selection and text. //! Every write goes through `writes`. use eframe::egui::{Button, Checkbox, ComboBox, DragValue, Event, Key, TextEdit, Ui}; diff --git a/crates/app/src/views/remove_speaker.rs b/crates/app/src/views/remove_speaker.rs index f3c3945..d807feb 100644 --- a/crates/app/src/views/remove_speaker.rs +++ b/crates/app/src/views/remove_speaker.rs @@ -1,4 +1,4 @@ -//! The confirmation before a card's speaker is removed and forgotten. +//! The confirmation before a speaker is removed and forgotten. use eframe::egui::{Id, Modal, Ui}; @@ -13,7 +13,7 @@ pub enum RemoveAction { Cancel, } -/// `name` is the card's display name, so the user sees which speaker goes. +/// `name` is the speaker's display name, so the user sees which speaker goes. pub fn show(ui: &mut Ui, name: &str) -> RemoveAction { let modal = Modal::new(Id::new(MODAL_ID)).show(ui.ctx(), |ui| contents(ui, name)); if modal.should_close() { diff --git a/crates/app/src/views/scan_choice.rs b/crates/app/src/views/scan_choice.rs index 21474c2..43b9ea3 100644 --- a/crates/app/src/views/scan_choice.rs +++ b/crates/app/src/views/scan_choice.rs @@ -1,5 +1,5 @@ //! The "Gefundene Lautsprecher" dialog after "Suchen": a spinner while the scan runs, then one -//! row per found speaker to check. Speakers that already have a card are shown but not offered. +//! row per found speaker to check. Speakers already shown are listed but not offered. use eframe::egui::{Button, Checkbox, Grid, Id, Modal, Ui}; diff --git a/crates/app/src/writes.rs b/crates/app/src/writes.rs index 0677968..68d9385 100644 --- a/crates/app/src/writes.rs +++ b/crates/app/src/writes.rs @@ -1,6 +1,6 @@ //! The write policy: what the user asked for becomes a `Command` only for a connected speaker //! whose model the catalog lists as writable for the path, with a value the catalog accepts. -//! Anything else is refused: `request` puts the reason on the card's message line, and +//! Anything else is refused: `request` puts the reason on the speaker's message line, and //! `try_request` hands it to the caller (a text field shows it under itself). use neumann_control_core::catalog::{Catalog, Entry, ValueError}; @@ -26,7 +26,7 @@ impl Switch { } } -/// What a control on the card asks for. +/// What a control of a speaker asks for. #[derive(Clone, Debug, PartialEq)] pub enum Intent { /// Flip a switch to the opposite of its current value. @@ -101,7 +101,7 @@ pub fn check( } impl SpeakerState { - /// The command for `intent`, or `None` after putting the refusal on the card. + /// The command for `intent`, or `None` after putting the refusal on the speaker's message. /// A sent level is held on the fader until the speaker reports it (`now` is the UI clock). pub fn request(&mut self, intent: Intent, catalog: &Catalog, now: f64) -> Option { match self.try_request(intent, catalog, now) { @@ -113,7 +113,7 @@ impl SpeakerState { } } - /// Like `request`, but the refusal goes back to the caller instead of onto the card. + /// Like `request`, but the refusal goes back to the caller instead of onto the message. pub fn try_request( &mut self, intent: Intent, diff --git a/crates/app/tests/compact.rs b/crates/app/tests/compact.rs index d9a9d66..e9242f9 100644 --- a/crates/app/tests/compact.rs +++ b/crates/app/tests/compact.rs @@ -33,7 +33,7 @@ fn drag_level(harness: &mut Harness<'static, NeumannApp>, label: &str, by: f32) #[test] fn the_shortcut_switches_to_compact_and_back_and_is_saved() { - let (_simulator, directory, mut harness) = connected_card(SimModel::Kh120Ii); + let (_simulator, directory, mut harness) = connected_speaker(SimModel::Kh120Ii); let path = directory.path().join(SETTINGS_FILE); harness.get_by_label(texts::ADD_GROUP.get()); press_compact_shortcut(&mut harness); @@ -52,7 +52,7 @@ fn the_shortcut_switches_to_compact_and_back_and_is_saved() { #[test] fn a_compact_row_writes_its_level_once_when_let_go() { - let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); press_compact_shortcut(&mut harness); drag_level(&mut harness, texts::LEVEL.get(), -40.0); wait_until(&mut harness, "a level set", |_| { @@ -106,7 +106,7 @@ fn compact_rows_are_one_line_high_and_follow_each_other() { #[test] fn compact_columns_line_up_between_speaker_and_master_rows() { - let (_simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + let (_simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); press_compact_shortcut(&mut harness); let bars: Vec = harness .query_all_by_label(texts::INPUT_METERS.get()) @@ -118,7 +118,7 @@ fn compact_columns_line_up_between_speaker_and_master_rows() { #[test] fn mute_stays_reachable_in_the_narrowest_compact_window() { - let (_simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + let (_simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); press_compact_shortcut(&mut harness); harness.set_size(egui::vec2(480.0, 360.0)); harness.run_steps(2); @@ -132,7 +132,7 @@ fn mute_stays_reachable_in_the_narrowest_compact_window() { #[test] fn the_toolbar_does_not_overlap_itself_in_the_narrowest_window() { - let (_simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + let (_simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); harness.set_size(egui::vec2(480.0, 360.0)); harness.run_steps(2); let toolbar = [ @@ -163,7 +163,7 @@ fn the_toolbar_does_not_overlap_itself_in_the_narrowest_window() { #[test] fn a_compact_row_says_why_a_write_was_refused() { - let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); simulator.set_value("ui/control_mode", serde_json::json!("LOCAL")); press_compact_shortcut(&mut harness); drag_level(&mut harness, texts::LEVEL.get(), -40.0); diff --git a/crates/app/tests/details.rs b/crates/app/tests/details.rs index 4d12cc6..aaab35b 100644 --- a/crates/app/tests/details.rs +++ b/crates/app/tests/details.rs @@ -1,4 +1,4 @@ -//! The card's expanded area: settings per model, displays and number gestures. +//! The device panel: settings per model, displays and number gestures. mod support; @@ -25,9 +25,11 @@ const STANDBY_TIME_PATH: &str = "/device/standby/auto_standby_time"; /// Frames a widget would need to write a value nobody chose, if it did. const IDLE_FRAMES: usize = 10; -/// A connected card with "Mehr" open, in a window tall enough for the whole area. -fn expanded_card(model: SimModel) -> (Simulator, tempfile::TempDir, Harness<'static, NeumannApp>) { - let (simulator, directory, mut harness) = connected_card(model); +/// A connected speaker with its device panel open, in a window tall enough for all of it. +fn speaker_with_panel( + model: SimModel, +) -> (Simulator, tempfile::TempDir, Harness<'static, NeumannApp>) { + let (simulator, directory, mut harness) = connected_speaker(model); open_panel(&mut harness, device_name(model)); (simulator, directory, harness) } @@ -48,7 +50,7 @@ fn role_of(harness: &Harness<'static, NeumannApp>, label: &str) -> Role { #[test] fn kh750_details_show_out1_and_no_phase() { - let (_simulator, _directory, harness) = expanded_card(SimModel::Kh750); + let (_simulator, _directory, harness) = speaker_with_panel(SimModel::Kh750); harness.get_by_label("Ausgang 1"); harness.get_by_label("Ausgang 2"); assert!(harness.query_by_label("Phase invertiert").is_none()); @@ -56,7 +58,7 @@ fn kh750_details_show_out1_and_no_phase() { #[test] fn kh750_standby_enabled_is_display_only() { - let (_simulator, _directory, harness) = expanded_card(SimModel::Kh750); + let (_simulator, _directory, harness) = speaker_with_panel(SimModel::Kh750); assert_eq!(role_of(&harness, STANDBY_LABEL), Role::Label); assert!(harness.query_all_by_role(Role::CheckBox).all(|node| node .accesskit_node() @@ -67,7 +69,7 @@ fn kh750_standby_enabled_is_display_only() { #[test] fn kh120ii_standby_enabled_is_checkbox() { - let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); + let (simulator, _directory, mut harness) = speaker_with_panel(SimModel::Kh120Ii); assert_eq!(role_of(&harness, STANDBY_LABEL), Role::CheckBox); harness.get_by_label(STANDBY_LABEL).click(); wait_until(&mut harness, "standby off on the device", |_| { @@ -77,7 +79,7 @@ fn kh120ii_standby_enabled_is_checkbox() { #[test] fn temperature_is_shown_in_celsius() { - let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh750); + let (simulator, _directory, mut harness) = speaker_with_panel(SimModel::Kh750); simulator.set_value("device/temperature", json!(313)); wait_until(&mut harness, "40 °C", |harness| { harness.query_by_label("40 °C").is_some() @@ -86,7 +88,7 @@ fn temperature_is_shown_in_celsius() { #[test] fn name_field_shows_refusal() { - let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); + let (simulator, _directory, mut harness) = speaker_with_panel(SimModel::Kh120Ii); harness.get_by_role(Role::TextInput).click(); harness.run_steps(1); harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); @@ -108,14 +110,14 @@ fn name_field_shows_refusal() { let next_row = harness.get_by_label(texts::ALL_VALUES.get()).rect(); assert!( refusal.max.y < next_row.min.y, - "the reason is not the card's message" + "the reason is not the speaker's message" ); } #[test] fn control_mode_is_shown_read_only_on_both_models() { for model in [SimModel::Kh120Ii, SimModel::Kh750] { - let (_simulator, _directory, harness) = expanded_card(model); + let (_simulator, _directory, harness) = speaker_with_panel(model); let combo_boxes = harness.query_all_by_role(Role::ComboBox); let labels: Vec<_> = combo_boxes .map(|node| node.accesskit_node().label()) @@ -158,7 +160,7 @@ fn an_announced_model_gives_no_write_controls_or_model_rows() { #[test] fn number_field_sends_once_per_gesture() { - let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); + let (simulator, _directory, mut harness) = speaker_with_panel(SimModel::Kh120Ii); let start = number_field(&harness, DELAY_LABEL).rect().center(); harness.drag_at(start); for frame in 1..=10 { @@ -205,7 +207,7 @@ fn numeric_value(harness: &Harness<'static, NeumannApp>, label: &str) -> Option< #[test] fn out_of_range_device_value_is_not_written_back() { - let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); + let (simulator, _directory, mut harness) = speaker_with_panel(SimModel::Kh120Ii); simulator.set_value("audio/out/delay", json!(6000)); wait_until(&mut harness, "the reported delay", |harness| { numeric_value(harness, DELAY_LABEL) == Some(6000.0) @@ -218,7 +220,7 @@ fn out_of_range_device_value_is_not_written_back() { #[test] fn visiting_a_number_field_does_not_write_its_rounded_text() { - let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); + let (simulator, _directory, mut harness) = speaker_with_panel(SimModel::Kh120Ii); simulator.set_value("audio/out/delay", json!(100.123456)); wait_until(&mut harness, "the reported delay", |harness| { numeric_value(harness, DELAY_LABEL) == Some(100.123456) @@ -234,7 +236,7 @@ fn visiting_a_number_field_does_not_write_its_rounded_text() { #[test] fn visiting_an_out_of_range_number_field_writes_nothing() { - let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); + let (simulator, _directory, mut harness) = speaker_with_panel(SimModel::Kh120Ii); simulator.set_value("audio/out/delay", json!(6000)); wait_until(&mut harness, "the reported delay", |harness| { numeric_value(harness, DELAY_LABEL) == Some(6000.0) @@ -251,7 +253,7 @@ fn visiting_an_out_of_range_number_field_writes_nothing() { #[test] fn a_number_typed_and_entered_in_one_frame_is_sent() { - let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); + let (simulator, _directory, mut harness) = speaker_with_panel(SimModel::Kh120Ii); number_field(&harness, STANDBY_TIME_LABEL).click(); harness.run_steps(2); let events = &mut harness.input_mut().events; @@ -271,7 +273,7 @@ fn a_number_typed_and_entered_in_one_frame_is_sent() { #[test] fn abandoned_number_edit_is_not_sent_on_reopen() { - let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); + let (simulator, _directory, mut harness) = speaker_with_panel(SimModel::Kh120Ii); type_number(&mut harness, STANDBY_TIME_LABEL, "50"); // The first Escape cancels the edit, the second closes the panel. harness.key_press(egui::Key::Escape); @@ -288,7 +290,7 @@ fn abandoned_number_edit_is_not_sent_on_reopen() { #[test] fn a_typed_number_is_snapped_only_when_sent() { - let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); + let (simulator, _directory, mut harness) = speaker_with_panel(SimModel::Kh120Ii); type_number(&mut harness, STANDBY_TIME_LABEL, "1.5"); harness.run_steps(2); let shown = number_field(&harness, STANDBY_TIME_LABEL).value(); @@ -302,7 +304,7 @@ fn a_typed_number_is_snapped_only_when_sent() { #[test] fn escape_during_number_edit_sends_nothing() { - let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); + let (simulator, _directory, mut harness) = speaker_with_panel(SimModel::Kh120Ii); type_number(&mut harness, STANDBY_TIME_LABEL, "50"); harness.key_press(egui::Key::Escape); harness.run_steps(IDLE_FRAMES); @@ -314,7 +316,7 @@ fn escape_during_number_edit_sends_nothing() { #[test] fn an_abandoned_name_draft_follows_the_device_again() { - let (_simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); + let (_simulator, _directory, mut harness) = speaker_with_panel(SimModel::Kh120Ii); harness.get_by_role(Role::TextInput).click(); harness.run_steps(1); harness.get_by_role(Role::TextInput).type_text("draft"); @@ -332,7 +334,7 @@ fn an_abandoned_name_draft_follows_the_device_again() { #[test] fn a_name_typed_then_tabbed_to_apply_is_sent() { - let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); + let (simulator, _directory, mut harness) = speaker_with_panel(SimModel::Kh120Ii); harness.get_by_role(Role::TextInput).click(); harness.run_steps(1); harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); @@ -350,7 +352,7 @@ fn a_name_typed_then_tabbed_to_apply_is_sent() { #[test] fn a_name_draft_left_by_tabbing_past_apply_follows_the_device_again() { - let (_simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); + let (_simulator, _directory, mut harness) = speaker_with_panel(SimModel::Kh120Ii); harness.get_by_role(Role::TextInput).click(); harness.run_steps(1); harness.get_by_role(Role::TextInput).type_text("draft"); @@ -365,7 +367,7 @@ fn a_name_draft_left_by_tabbing_past_apply_follows_the_device_again() { #[test] fn strip_controls_are_not_repeated_in_the_panel() { - let (_simulator, _directory, harness) = expanded_card(SimModel::Kh120Ii); + let (_simulator, _directory, harness) = speaker_with_panel(SimModel::Kh120Ii); assert_eq!(harness.query_all_by_label(texts::MUTE.get()).count(), 1); assert_eq!(harness.query_all_by_label(texts::IDENTIFY.get()).count(), 1); assert!(harness.query_by_label("Identifizieren").is_none()); @@ -381,7 +383,7 @@ fn strip_controls_are_not_repeated_in_the_panel() { #[test] fn kh750_output_meters_sit_next_to_the_input_meters() { - let (_simulator, _directory, mut harness) = connected_card(SimModel::Kh750); + let (_simulator, _directory, mut harness) = connected_speaker(SimModel::Kh750); let output_meter = texts::meter_label(texts::OUTPUT_METERS.get(), "A"); wait_for_label(&mut harness, &output_meter); assert_eq!( @@ -401,8 +403,8 @@ fn kh750_output_meters_sit_next_to_the_input_meters() { } #[test] -fn kh120ii_card_has_no_output_meters() { - let (_simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); +fn kh120ii_strip_has_no_output_meters() { + let (_simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); wait_for_label( &mut harness, &texts::meter_label(texts::INPUT_METERS.get(), "B"), @@ -417,7 +419,7 @@ fn kh120ii_card_has_no_output_meters() { #[test] fn all_values_button_reads_the_whole_tree() { - let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); + let (simulator, _directory, mut harness) = speaker_with_panel(SimModel::Kh120Ii); harness.get_by_label(texts::ALL_VALUES.get()).click(); wait_until(&mut harness, "the schema walk", |_| { simulator @@ -429,7 +431,7 @@ fn all_values_button_reads_the_whole_tree() { #[test] fn panel_number_rows_keep_their_labels_on_one_line() { - let (_simulator, _directory, harness) = expanded_card(SimModel::Kh120Ii); + let (_simulator, _directory, harness) = speaker_with_panel(SimModel::Kh120Ii); // The panel row and the strip knob's caption carry the same name; neither may wrap. for label in [STANDBY_TIME_LABEL, LOGO_LABEL, DELAY_LABEL] { let rects: Vec<_> = harness @@ -448,7 +450,7 @@ fn panel_number_rows_keep_their_labels_on_one_line() { #[test] fn delay_is_typed_in_milliseconds_and_sent_in_samples() { - let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); + let (simulator, _directory, mut harness) = speaker_with_panel(SimModel::Kh120Ii); type_number(&mut harness, DELAY_LABEL, "10"); harness.key_press(egui::Key::Enter); wait_until(&mut harness, "the delay Set", |_| { @@ -459,7 +461,7 @@ fn delay_is_typed_in_milliseconds_and_sent_in_samples() { #[test] fn a_typed_number_outside_the_range_is_refused_not_clamped() { - let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); + let (simulator, _directory, mut harness) = speaker_with_panel(SimModel::Kh120Ii); // 500 ms are 24000 samples, far above the KH 120 II's 5760. type_number(&mut harness, DELAY_LABEL, "500"); harness.key_press(egui::Key::Enter); @@ -476,7 +478,7 @@ fn a_typed_number_outside_the_range_is_refused_not_clamped() { #[test] fn visiting_a_delay_in_milliseconds_writes_nothing() { - let (simulator, _directory, mut harness) = expanded_card(SimModel::Kh120Ii); + let (simulator, _directory, mut harness) = speaker_with_panel(SimModel::Kh120Ii); // One sample is 0.0208333… ms, which no decimal text holds exactly. simulator.set_value("audio/out/delay", json!(1)); wait_until(&mut harness, "the reported delay", |harness| { diff --git a/crates/app/tests/groups.rs b/crates/app/tests/groups.rs index e3d7064..be3e8da 100644 --- a/crates/app/tests/groups.rs +++ b/crates/app/tests/groups.rs @@ -118,7 +118,7 @@ fn group_dialog_renames_and_deletes() { #[test] fn add_group_creates_one_and_the_panel_assigns_a_speaker() { - let (simulator, directory, mut harness) = connected_card(SimModel::Kh120Ii); + let (simulator, directory, mut harness) = connected_speaker(SimModel::Kh120Ii); harness.get_by_label(texts::ADD_GROUP.get()).click(); wait_for_label(&mut harness, texts::GROUP_DIALOG_TITLE.get()); harness.get_by_label(texts::CLOSE.get()).click(); @@ -275,7 +275,7 @@ fn group_mute_leaves_an_unreachable_member_alone() { #[test] fn a_new_group_never_repeats_the_name_or_colour_of_another() { - let (_simulator, directory, mut harness) = connected_card(SimModel::Kh120Ii); + let (_simulator, directory, mut harness) = connected_speaker(SimModel::Kh120Ii); let path = directory.path().join(SETTINGS_FILE); for _ in 0..2 { harness.get_by_label(texts::ADD_GROUP.get()).click(); diff --git a/crates/app/tests/speakers.rs b/crates/app/tests/speakers.rs index 4fb8919..31c97c3 100644 --- a/crates/app/tests/speakers.rs +++ b/crates/app/tests/speakers.rs @@ -1,4 +1,4 @@ -//! Which cards the window shows: addresses, speakers chosen from a scan, one card per speaker. +//! Which speakers the window shows: addresses, speakers chosen from a scan, each once. mod support; @@ -45,7 +45,7 @@ fn saved_at(path: &Path, simulator: &Simulator) -> bool { } #[test] -fn scan_shows_choices_without_adding_cards() { +fn scan_shows_choices_without_adding_speakers() { let simulator = Simulator::start(SimModel::Kh750); let directory = tempfile::tempdir().unwrap(); let path = settings_file(directory.path(), Vec::new()); @@ -92,7 +92,7 @@ fn a_failed_scan_says_so_in_the_dialog() { } #[test] -fn only_checked_speakers_get_cards() { +fn only_checked_speakers_are_added() { let unchosen = Simulator::start(SimModel::Kh120Ii); let chosen = Simulator::start(SimModel::Kh750); let directory = tempfile::tempdir().unwrap(); @@ -191,7 +191,7 @@ fn rescan_updates_saved_address() { let mut harness = harness(path.clone(), vec![kh120_found(&simulator)]); harness.get_by_label(&texts::speaker_count(1)); harness.get_by_label(texts::SCAN.get()).click(); - // Not the name: the unconnected card shows the announced name as well. + // Not the name: the unconnected strip shows the announced name as well. wait_for_label(&mut harness, texts::ALREADY_ADDED.get()); assert!(saved_at(&path, &simulator)); assert_eq!(settings::load(&path).manual.len(), 1); @@ -222,7 +222,7 @@ fn add_address_refuses_ipv4_with_hint() { } #[test] -fn add_address_saves_the_speaker_and_shows_its_card() { +fn add_address_saves_the_speaker_and_shows_it() { let directory = tempfile::tempdir().unwrap(); let path = settings_file(directory.path(), Vec::new()); let mut harness = harness(path.clone(), Vec::new()); @@ -247,9 +247,9 @@ fn add_address_saves_the_speaker_and_shows_its_card() { } #[test] -fn a_manual_speaker_found_at_a_second_address_stays_one_card() { +fn a_manual_speaker_found_at_a_second_address_is_shown_once() { let manual_simulator = Simulator::start(SimModel::Kh120Ii); - // The manual card connects only after the chosen card for the second address started. + // The manual entry connects only after the chosen one for the second address started. manual_simulator.set_reply_delay(Duration::from_millis(300)); let second_simulator = Simulator::start(SimModel::Kh120Ii); let directory = tempfile::tempdir().unwrap(); @@ -259,17 +259,25 @@ fn a_manual_speaker_found_at_a_second_address_stays_one_card() { wait_until(&mut harness, "the serial in both entries", |_| { serials_remembered(&path, 2) }); - wait_until(&mut harness, "one card after the first scan", has_one_card); + wait_until( + &mut harness, + "one speaker after the first scan", + shows_one_speaker, + ); harness.get_by_label(texts::SCAN.get()).click(); wait_for_label(&mut harness, texts::ALREADY_ADDED.get()); harness.get_by_label(texts::CANCEL.get()).click(); harness.run_steps(5); - wait_until(&mut harness, "one card after the second scan", has_one_card); + wait_until( + &mut harness, + "one speaker after the second scan", + shows_one_speaker, + ); assert!(serials_remembered(&path, 1)); } #[test] -fn two_manual_entries_for_one_speaker_stay_one_card() { +fn two_manual_entries_for_one_speaker_are_shown_once() { let first = Simulator::start(SimModel::Kh120Ii); let second = Simulator::start(SimModel::Kh120Ii); let directory = tempfile::tempdir().unwrap(); @@ -281,7 +289,7 @@ fn two_manual_entries_for_one_speaker_stay_one_card() { wait_until(&mut harness, "both serials remembered", |_| { serials_remembered(&path, 2) }); - wait_until(&mut harness, "one card", has_one_card); + wait_until(&mut harness, "one speaker", shows_one_speaker); } #[test] @@ -318,7 +326,7 @@ fn ask_to_remove(harness: &mut Harness<'static, NeumannApp>) { #[test] fn remove_needs_confirmation_and_forgets_speaker() { - let (_simulator, directory, mut harness) = connected_card(SimModel::Kh120Ii); + let (_simulator, directory, mut harness) = connected_speaker(SimModel::Kh120Ii); let path = directory.path().join(SETTINGS_FILE); ask_to_remove(&mut harness); harness.get_by_label(&texts::speaker_count(1)); @@ -340,7 +348,7 @@ fn remove_needs_confirmation_and_forgets_speaker() { #[test] fn cancel_remove_keeps_speaker() { - let (_simulator, directory, mut harness) = connected_card(SimModel::Kh120Ii); + let (_simulator, directory, mut harness) = connected_speaker(SimModel::Kh120Ii); let path = directory.path().join(SETTINGS_FILE); ask_to_remove(&mut harness); harness.get_by_label(texts::CANCEL.get()).click(); diff --git a/crates/app/tests/support/mod.rs b/crates/app/tests/support/mod.rs index f563083..1704bde 100644 --- a/crates/app/tests/support/mod.rs +++ b/crates/app/tests/support/mod.rs @@ -32,7 +32,7 @@ pub const WAIT_LIMIT: Duration = Duration::from_secs(15); // Paces the polling loop only; every outcome is decided by the condition. pub const POLL_PAUSE: Duration = Duration::from_millis(10); pub const WINDOW_SIZE: [f32; 2] = [900.0, 700.0]; -/// Tall enough for a card with "Mehr" open, so every control in it can be clicked. +/// Tall enough for an open device panel, so every control in it can be clicked. pub const EXPANDED_WINDOW_SIZE: [f32; 2] = [1200.0, 1600.0]; pub const LOOPBACK: &str = "::1"; const DIALOG_SETTLE_FRAMES: usize = 2; @@ -168,7 +168,7 @@ pub fn level_sets(simulator: &Simulator) -> Vec { .collect() } -pub fn connected_card( +pub fn connected_speaker( model: SimModel, ) -> (Simulator, tempfile::TempDir, Harness<'static, NeumannApp>) { let simulator = Simulator::start(model); @@ -207,7 +207,7 @@ pub fn readouts(harness: &Harness<'static, NeumannApp>) -> usize { harness.query_all_by_label(&readout(100.0)).count() } -pub fn has_one_card(harness: &Harness<'static, NeumannApp>) -> bool { +pub fn shows_one_speaker(harness: &Harness<'static, NeumannApp>) -> bool { readouts(harness) == 1 && harness.query_by_label(&texts::speaker_count(1)).is_some() } diff --git a/crates/app/tests/views.rs b/crates/app/tests/views.rs index 6ae0cfd..5c90a8f 100644 --- a/crates/app/tests/views.rs +++ b/crates/app/tests/views.rs @@ -1,4 +1,4 @@ -//! The speaker card in the real window: values, fader, buttons, unreachable state. +//! The speaker strip in the real window: values, fader, buttons, unreachable state. mod support; @@ -24,7 +24,7 @@ const STANDBY_KNOB: &str = "Standby-Zeit"; #[test] fn strip_shows_name_and_level_and_panel_the_masked_serial() { - let (_simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + let (_simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); harness.get_by_label(KH120_DEVICE_NAME); harness.get_by_label(&readout(100.0)); harness.get_by_label(&texts::speaker_count(1)); @@ -36,7 +36,7 @@ fn strip_shows_name_and_level_and_panel_the_masked_serial() { #[test] fn fader_drag_sends_once_on_release() { - let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); let rect = harness .get_by_role_and_label(Role::Slider, texts::LEVEL.get()) .rect(); @@ -70,7 +70,7 @@ fn fader_drag_sends_once_on_release() { #[test] fn fader_value_is_shown_at_the_handle_while_dragging() { - let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); let start = harness .get_by_role_and_label(Role::Slider, texts::LEVEL.get()) .rect() @@ -96,7 +96,7 @@ fn fader_value_is_shown_at_the_handle_while_dragging() { #[test] fn wheel_over_fader_sends_one_set_after_the_wheel_went_quiet() { - let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); let center = harness .get_by_role_and_label(Role::Slider, texts::LEVEL.get()) .rect() @@ -124,7 +124,7 @@ fn wheel_over_fader_sends_one_set_after_the_wheel_went_quiet() { #[test] fn escape_during_a_drag_sends_nothing() { - let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); let start = harness .get_by_role_and_label(Role::Slider, texts::LEVEL.get()) .rect() @@ -161,7 +161,7 @@ fn escape_during_a_drag_sends_nothing() { #[test] fn a_drag_replaces_wheel_steps_not_yet_sent() { - let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); let start = harness .get_by_role_and_label(Role::Slider, texts::LEVEL.get()) .rect() @@ -191,7 +191,7 @@ fn a_drag_replaces_wheel_steps_not_yet_sent() { #[test] fn mute_button_sends_set() { - let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); harness.get_by_label(texts::MUTE.get()).click(); wait_until(&mut harness, "mute on the device", |_| { simulator.value("audio/out/mute") == Some(Value::Bool(true)) @@ -200,7 +200,7 @@ fn mute_button_sends_set() { #[test] fn identify_button_sends_set() { - let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); harness.get_by_label(texts::IDENTIFY.get()).click(); wait_until(&mut harness, "identification on the device", |_| { simulator.value("device/identification/visual") == Some(Value::Bool(true)) @@ -209,7 +209,7 @@ fn identify_button_sends_set() { #[test] fn panel_opens_from_strip_name_and_closes_on_escape() { - let (_simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + let (_simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); open_panel(&mut harness, KH120_DEVICE_NAME); harness.get_by_label(texts::CLOSE.get()); harness.key_press(egui::Key::Escape); @@ -223,7 +223,7 @@ fn panel_opens_from_strip_name_and_closes_on_escape() { #[test] fn panel_closes_when_its_speaker_goes_away() { - let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); open_panel(&mut harness, KH120_DEVICE_NAME); simulator.stop(); wait_until(&mut harness, "the panel to close", |harness| { @@ -232,8 +232,8 @@ fn panel_closes_when_its_speaker_goes_away() { } #[test] -fn unreachable_card_is_disabled() { - let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); +fn unreachable_strip_is_disabled() { + let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); simulator.stop(); wait_until(&mut harness, "the unreachable text", |harness| { harness.query_by_label(texts::UNREACHABLE.get()).is_some() @@ -254,7 +254,7 @@ fn unreachable_card_is_disabled() { #[test] fn out_of_range_level_is_not_written_back() { - let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); simulator.set_value("audio/out/level", serde_json::json!(130.0)); wait_for_level(&mut harness, 130.0); harness.run_steps(10); @@ -276,7 +276,7 @@ const RAW_FAILURE: &str = "connection error: could not read from fe80::2a36:38ff fn all_values_window( model: SimModel, ) -> (Simulator, tempfile::TempDir, Harness<'static, NeumannApp>) { - let (simulator, directory, mut harness) = connected_card(model); + let (simulator, directory, mut harness) = connected_speaker(model); open_panel(&mut harness, device_name(model)); harness.get_by_label(texts::ALL_VALUES.get()).click(); wait_until(&mut harness, "the loaded table", |harness| { @@ -439,7 +439,7 @@ fn turn_knob( #[test] fn strip_shows_leds_knobs_fader_and_buttons() { - let (_simulator, _directory, harness) = connected_card(SimModel::Kh120Ii); + let (_simulator, _directory, harness) = connected_speaker(SimModel::Kh120Ii); for label in [ texts::LED_CONNECTION, texts::LED_CLIP, @@ -457,7 +457,7 @@ fn strip_shows_leds_knobs_fader_and_buttons() { #[test] fn kh750_standby_knob_is_read_only() { - let (_simulator, _directory, harness) = connected_card(SimModel::Kh750); + let (_simulator, _directory, harness) = connected_speaker(SimModel::Kh750); let standby = harness.get_by_role_and_label(Role::Slider, STANDBY_KNOB); assert!(standby.accesskit_node().is_disabled()); let delay = harness.get_by_role_and_label(Role::Slider, DELAY_KNOB); @@ -466,7 +466,7 @@ fn kh750_standby_knob_is_read_only() { #[test] fn knob_drag_writes_once_on_release() { - let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); let end = turn_knob(&mut harness, DELAY_KNOB, 90.0, 150.0); barrier(&simulator); assert!( @@ -486,7 +486,7 @@ fn knob_drag_writes_once_on_release() { #[test] fn escape_during_knob_drag_sends_nothing() { - let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); let end = turn_knob(&mut harness, DELAY_KNOB, 90.0, 150.0); harness.key_press(egui::Key::Escape); harness.step(); @@ -498,7 +498,7 @@ fn escape_during_knob_drag_sends_nothing() { #[test] fn knob_wheel_writes_after_idle() { - let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); let center = harness .get_by_role_and_label(Role::Slider, DELAY_KNOB) .rect() @@ -553,7 +553,7 @@ fn open_settings(harness: &mut Harness<'static, NeumannApp>) { #[test] fn settings_dialog_switches_theme_and_saves_on_change() { - let (_simulator, directory, mut harness) = connected_card(SimModel::Kh120Ii); + let (_simulator, directory, mut harness) = connected_speaker(SimModel::Kh120Ii); open_settings(&mut harness); // The harness starts dark, so light is the choice that shows an effect. harness.get_by_label(texts::THEME_LIGHT.get()).click(); @@ -575,7 +575,7 @@ fn settings_dialog_switches_theme_and_saves_on_change() { #[test] fn english_applies_at_once_and_is_saved() { - let (_simulator, directory, mut harness) = connected_card(SimModel::Kh120Ii); + let (_simulator, directory, mut harness) = connected_speaker(SimModel::Kh120Ii); open_settings(&mut harness); harness.get_by_label(texts::LANGUAGE_ENGLISH).click(); harness.run_steps(2); @@ -597,7 +597,7 @@ fn english_applies_at_once_and_is_saved() { #[test] fn toolbar_shows_average_response_time_until_turned_off() { - let (_simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + let (_simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); wait_until(&mut harness, "the average response time", |harness| { harness.query_by_label_contains("Ø ").is_some() }); @@ -611,7 +611,7 @@ fn toolbar_shows_average_response_time_until_turned_off() { #[test] fn knob_value_is_shown_beside_it_while_dragging() { - let (_simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + let (_simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); let unit = " ms"; let shown = |harness: &Harness<'static, NeumannApp>| { harness @@ -677,7 +677,7 @@ fn level_change_while_dragging( #[test] fn fader_moves_ten_times_finer_with_shift() { - let (_simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + let (_simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); let coarse = level_change_while_dragging(&mut harness, 40.0, egui::Modifiers::NONE); let fine = level_change_while_dragging(&mut harness, 40.0, egui::Modifiers::SHIFT); assert!(coarse > 10.0, "coarse {coarse}"); @@ -689,7 +689,7 @@ fn fader_moves_ten_times_finer_with_shift() { #[test] fn double_click_on_the_level_readout_enters_a_value() { - let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); let position = harness.get_by_label(&readout(100.0)).rect().center(); double_click_at(&mut harness, position); harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); @@ -705,7 +705,7 @@ fn double_click_on_the_level_readout_enters_a_value() { #[test] fn double_click_on_the_delay_knob_takes_milliseconds() { - let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); let position = harness .get_by_role_and_label(Role::Slider, DELAY_KNOB) .rect() @@ -724,11 +724,11 @@ fn double_click_on_the_delay_knob_takes_milliseconds() { #[test] fn meter_groups_are_labelled_input_and_output() { - let (_simulator, _directory, harness) = connected_card(SimModel::Kh750); + let (_simulator, _directory, harness) = connected_speaker(SimModel::Kh750); harness.get_by_label(texts::METER_INPUT_SHORT.get()); harness.get_by_label(texts::METER_OUTPUT_SHORT.get()); // The KH 120 II names its input by the rear switch instead. - let (_other, _other_directory, kh120) = connected_card(SimModel::Kh120Ii); + let (_other, _other_directory, kh120) = connected_speaker(SimModel::Kh120Ii); kh120.get_by_label(texts::SOURCE_ANALOG.get()); assert!(kh120 .query_by_label(texts::METER_OUTPUT_SHORT.get()) @@ -737,7 +737,7 @@ fn meter_groups_are_labelled_input_and_output() { #[test] fn meter_captions_are_centred_under_their_meters() { - let (_simulator, _directory, harness) = connected_card(SimModel::Kh750); + let (_simulator, _directory, harness) = connected_speaker(SimModel::Kh750); for (caption, group, last) in [ (texts::METER_INPUT_SHORT, texts::INPUT_METERS, "B"), (texts::METER_OUTPUT_SHORT, texts::OUTPUT_METERS, "Sub"), @@ -760,7 +760,7 @@ fn meter_captions_are_centred_under_their_meters() { #[test] fn meters_name_their_channels_as_the_speaker_does() { // The KH 750 describes its outputs as "[A, B, subwoofer]", its inputs as "[A,B]". - let (_simulator, _directory, harness) = connected_card(SimModel::Kh750); + let (_simulator, _directory, harness) = connected_speaker(SimModel::Kh750); for (group, channel) in [ (texts::INPUT_METERS, "A"), (texts::INPUT_METERS, "B"), @@ -775,7 +775,7 @@ fn meters_name_their_channels_as_the_speaker_does() { #[test] fn fader_and_meter_groups_line_up_at_the_top() { - let (_simulator, _directory, harness) = connected_card(SimModel::Kh750); + let (_simulator, _directory, harness) = connected_speaker(SimModel::Kh750); let top = |label: String| harness.get_by_label(&label).rect().top(); let input = top(texts::meter_label(texts::INPUT_METERS.get(), "A")); let output = top(texts::meter_label(texts::OUTPUT_METERS.get(), "A")); @@ -792,7 +792,7 @@ fn fader_and_meter_groups_line_up_at_the_top() { #[test] fn channel_names_do_not_touch() { - let (_simulator, _directory, harness) = connected_card(SimModel::Kh750); + let (_simulator, _directory, harness) = connected_speaker(SimModel::Kh750); // The output B is the right one of the two. let b = harness .query_all_by_label("B") @@ -805,7 +805,7 @@ fn channel_names_do_not_touch() { #[test] fn played_input_channels_are_marked_with_the_source() { - let (simulator, _directory, analog) = connected_card(SimModel::Kh120Ii); + let (simulator, _directory, analog) = connected_speaker(SimModel::Kh120Ii); analog.get_by_label(texts::SOURCE_ANALOG.get()); assert_eq!( analog @@ -827,7 +827,7 @@ fn played_input_channels_are_marked_with_the_source() { aes3.query_all_by_label(texts::PLAYED_CHANNEL.get()).count(), 1 ); - let (_other, _other_directory, kh750) = connected_card(SimModel::Kh750); + let (_other, _other_directory, kh750) = connected_speaker(SimModel::Kh750); kh750.get_by_label(texts::METER_INPUT_SHORT.get()); assert_eq!( kh750 @@ -839,7 +839,7 @@ fn played_input_channels_are_marked_with_the_source() { #[test] fn input_source_caption_stays_on_one_line() { - let (_simulator, _directory, harness) = connected_card(SimModel::Kh120Ii); + let (_simulator, _directory, harness) = connected_speaker(SimModel::Kh120Ii); let caption = harness.get_by_label(texts::SOURCE_ANALOG.get()).rect(); assert!(caption.width() > caption.height(), "{caption:?}"); let level = harness.get_by_label(&readout(100.0)).rect(); @@ -851,7 +851,7 @@ fn input_source_caption_stays_on_one_line() { #[test] fn the_strip_knob_captions_stay_apart() { - let (_simulator, _directory, harness) = connected_card(SimModel::Kh120Ii); + let (_simulator, _directory, harness) = connected_speaker(SimModel::Kh120Ii); let caption = |label: &str| harness.get_by_role_and_label(Role::Label, label).rect(); let (delay, standby) = (caption(DELAY_KNOB), caption(STANDBY_KNOB)); assert!(!delay.intersects(standby), "{delay:?} {standby:?}"); diff --git a/crates/app/tests/write_gestures.rs b/crates/app/tests/write_gestures.rs index 415c3be..97153ea 100644 --- a/crates/app/tests/write_gestures.rs +++ b/crates/app/tests/write_gestures.rs @@ -46,7 +46,7 @@ fn levels(simulator: &neumann_control_core::simulator::Simulator) -> Vec #[test] fn a_click_on_a_fader_without_moving_writes_nothing() { - let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); simulator.set_value(LEVEL_PATH, json!(90.3)); wait_for_level(&mut harness, 90.3); let center = slider_center(&harness, texts::LEVEL.get()); @@ -78,7 +78,7 @@ fn a_click_on_a_group_fader_without_moving_writes_nothing() { #[test] fn a_typed_level_outside_the_range_is_refused_not_clamped() { - let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); let position = harness.get_by_label(&readout(100.0)).rect().center(); double_click_at(&mut harness, position); harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); @@ -100,7 +100,7 @@ fn a_typed_level_outside_the_range_is_refused_not_clamped() { #[test] fn the_wheel_on_a_panel_knob_does_not_scroll_another_knob_under_the_pointer() { - let (simulator, _directory, mut harness) = connected_card(SimModel::Kh120Ii); + let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); open_panel(&mut harness, KH120_DEVICE_NAME); // Short enough that the panel scrolls; `open_panel` makes the window tall. harness.set_size(egui::vec2(1200.0, 420.0)); diff --git a/crates/app/tests/write_messages.rs b/crates/app/tests/write_messages.rs index 1098aaf..9eb2e37 100644 --- a/crates/app/tests/write_messages.rs +++ b/crates/app/tests/write_messages.rs @@ -1,5 +1,5 @@ -//! The card's write messages across the seam worker -> events -> speaker state: a rejection -//! must still be on the card after everything the worker sends in reply has been applied. +//! A speaker's write messages across the seam worker -> events -> speaker state: a rejection +//! must still be shown after everything the worker sends in reply has been applied. use std::sync::mpsc::RecvTimeoutError; use std::sync::Arc; @@ -32,7 +32,7 @@ fn quiet_timing() -> Timing { } } -/// A worker for the simulator and the card state it feeds, once the worker is connected and +/// A worker for the simulator and the speaker state it feeds, once the worker is connected and /// has read the values it reads on connecting. fn connected(simulator: &Simulator) -> (SpeakerHandle, Events, SpeakerState) { let address = simulator.address(); diff --git a/crates/core/src/paths.toml b/crates/core/src/paths.toml index dbd7564..2d51b53 100644 --- a/crates/core/src/paths.toml +++ b/crates/core/src/paths.toml @@ -8,7 +8,7 @@ # strings as the device reports them) and how often it is polled (`poll`). A product that is # not listed in `writable_on` is read-only, so unmeasured models never get a write. # -# Labels are German UI copy. Out of scope (not listed): EQ, restore, save_settings, out5, +# `label` is the German UI copy, `label_en` the English one. Out of scope (not listed): EQ, restore, save_settings, out5, # mixer, mute_persist, update, auth, system, time, macs. [models] diff --git a/crates/core/src/settings.rs b/crates/core/src/settings.rs index 4906450..4df63f6 100644 --- a/crates/core/src/settings.rs +++ b/crates/core/src/settings.rs @@ -155,7 +155,7 @@ impl MeterFloor { MeterFloor::Minus60, ]; - pub fn db(self) -> f64 { + pub const fn db(self) -> f64 { match self { MeterFloor::Minus120 => -120.0, MeterFloor::Minus90 => -90.0, diff --git a/crates/core/src/ssc/client.rs b/crates/core/src/ssc/client.rs index c2c03ae..caeae7d 100644 --- a/crates/core/src/ssc/client.rs +++ b/crates/core/src/ssc/client.rs @@ -187,7 +187,7 @@ pub struct Client { connection: Connection, next_xid: u64, closed: bool, - /// Send to matching reply of the last answered request, until taken. + /// Time from sending the last answered request to its matching reply, until taken. round_trip: Option, } From 3c3ed8e6397db2f5643c817bfb02300f1325ce77 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:04:31 +0200 Subject: [PATCH 102/460] Test linked writes, link toggling and ungrouped reordering Covers a link skipping a model that may not write, a linked level, copying to the group, switching a link off, reordering without groups and an older settings file; the response time test now counts from its first report. --- crates/app/tests/groups.rs | 112 +++++++++++++++++++++++++++++++++- crates/core/tests/settings.rs | 25 ++++++++ crates/core/tests/speaker.rs | 11 +++- 3 files changed, 144 insertions(+), 4 deletions(-) diff --git a/crates/app/tests/groups.rs b/crates/app/tests/groups.rs index be3e8da..9b07527 100644 --- a/crates/app/tests/groups.rs +++ b/crates/app/tests/groups.rs @@ -9,6 +9,7 @@ use egui_kittest::Harness; use neumann_control_app::app::NeumannApp; use neumann_control_app::groups::linkable_label; use neumann_control_app::texts; +use neumann_control_app::writes::Refusal; use neumann_control_core::settings::{self, GroupSettings, Linkable, Settings, SETTINGS_FILE}; use neumann_control_core::simulator::{SimModel, Simulator}; use serde_json::Value; @@ -92,8 +93,16 @@ fn link_buttons_toggle_and_are_saved() { .get_by_label(&texts::link_label(linkable_label(Linkable::Level))) .click(); harness.run_steps(2); - let saved = settings::load(&directory.path().join(SETTINGS_FILE)); - assert_eq!(saved.groups[0].linked, vec![Linkable::Level]); + let path = directory.path().join(SETTINGS_FILE); + assert_eq!( + settings::load(&path).groups[0].linked, + vec![Linkable::Level] + ); + harness + .get_by_label(&texts::link_label(linkable_label(Linkable::Level))) + .click(); + harness.run_steps(2); + assert!(settings::load(&path).groups[0].linked.is_empty()); } #[test] @@ -297,3 +306,102 @@ fn a_new_group_never_repeats_the_name_or_colour_of_another() { assert_ne!(groups[0].name, groups[1].name); assert_ne!(groups[0].color, groups[1].color); } + +#[test] +fn a_linked_setting_skips_a_model_that_may_not_write_it() { + // The KH 750's auto standby is read-only (decisions: its write is unmeasured). + let (kh120, kh750, _directory, mut harness) = grouped(vec![Linkable::Standby]); + open_panel(&mut harness, KH120_DEVICE_NAME); + harness + .get_by_role_and_label(Role::CheckBox, "Auto-Standby") + .click(); + wait_until(&mut harness, "the KH 120 II standby set", |_| { + !sets(&kh120, "/device/standby/enabled").is_empty() + }); + harness.run_steps(5); + barrier(&kh750); + assert!(sets(&kh750, "/device/standby/enabled").is_empty()); + harness.get_by_label(&texts::describe_refusal(&Refusal::NotWritable)); +} + +#[test] +fn a_linked_level_reaches_the_other_member_as_it_is() { + let (kh120, kh750, _directory, mut harness) = grouped(vec![Linkable::Level]); + let start = harness + .query_all_by_role_and_label(Role::Slider, texts::LEVEL.get()) + .next() + .unwrap() + .rect() + .center(); + harness.drag_at(start); + for frame in 1..=4 { + harness.hover_at(start + egui::vec2(0.0, 5.0 * frame as f32)); + harness.step(); + } + harness.drop_at(start + egui::vec2(0.0, 20.0)); + wait_until(&mut harness, "both level sets", |_| { + !sets(&kh120, "/audio/out/level").is_empty() && !sets(&kh750, "/audio/out/level").is_empty() + }); + assert_eq!( + sets(&kh120, "/audio/out/level"), + sets(&kh750, "/audio/out/level") + ); +} + +#[test] +fn copy_to_group_sends_the_members_value_to_the_others() { + let (kh120, kh750, _directory, mut harness) = grouped(vec![Linkable::Level]); + kh120.set_value("audio/out/level", serde_json::json!(90.0)); + wait_for_level(&mut harness, 90.0); + open_panel(&mut harness, KH120_DEVICE_NAME); + let copy = texts::copy_to_group(linkable_label(Linkable::Level)); + harness.get_by_label(©).click(); + wait_until(&mut harness, "the copied level", |_| { + !sets(&kh750, "/audio/out/level").is_empty() + }); + assert_eq!( + sets(&kh750, "/audio/out/level"), + vec![serde_json::json!(90.0)] + ); + assert!(sets(&kh120, "/audio/out/level").is_empty()); +} + +#[test] +fn ungrouped_strips_can_be_reordered_too() { + let kh120 = Simulator::start(SimModel::Kh120Ii); + let kh750 = Simulator::start(SimModel::Kh750); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(SETTINGS_FILE); + let mut first = manual_for(&kh120); + first.serial = Some("SIM0001234".to_owned()); + let mut second = manual_for(&kh750); + second.serial = Some("SIM0007500".to_owned()); + let settings = Settings { + manual: vec![first, second], + ..Settings::default() + }; + settings::save(&settings, &path).unwrap(); + let mut harness = harness(path.clone(), Vec::new()); + harness.set_size(egui::vec2(1200.0, 900.0)); + wait_until(&mut harness, "both strips connected", |harness| { + harness.query_all_by_label(&readout(100.0)).count() == 2 + }); + let handles: Vec = harness + .query_all_by_label(texts::MOVE_STRIP.get()) + .map(|node| node.rect()) + .collect(); + let (left, right) = (handles[0], handles[1]); + let target = egui::pos2(right.right() + 200.0, left.center().y); + harness.drag_at(left.center()); + for frame in 1..=6 { + let progress = frame as f32 / 6.0; + harness.hover_at(left.center() + (target - left.center()) * progress); + harness.step(); + } + harness.drop_at(target); + harness.run_steps(2); + assert_eq!( + settings::load(&path).order, + vec![KH750_ID.to_owned(), KH120_ID.to_owned()] + ); +} diff --git a/crates/core/tests/settings.rs b/crates/core/tests/settings.rs index 573f537..4c18747 100644 --- a/crates/core/tests/settings.rs +++ b/crates/core/tests/settings.rs @@ -234,3 +234,28 @@ fn a_value_this_version_does_not_know_costs_only_that_value() { assert_eq!(fs::read_to_string(copy).unwrap(), text); assert!(path.exists()); } + +#[test] +fn a_file_of_the_previous_version_keeps_its_speakers_and_window() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(SETTINGS_FILE); + // Before groups, order, language and the compact mode existed. + let text = r#"{ + "manual": [{"address": "fe80::1%7", "port": 45, "serial": "SIM1"}], + "window": {"x": 10.0, "y": 20.0, "width": 900.0, "height": 600.0}, + "desk": {"theme": "Dark", "meter_floor": "Minus90", "show_response_times": false} + }"#; + fs::write(&path, text).unwrap(); + let loaded = load(&path); + assert_eq!(loaded.manual[0].serial.as_deref(), Some("SIM1")); + assert_eq!( + loaded.window_of_mode().map(|window| window.width), + Some(900.0) + ); + assert_eq!(loaded.desk.theme, ThemeChoice::Dark); + assert_eq!(loaded.desk.meter_floor, MeterFloor::Minus90); + assert!(!directory + .path() + .join(format!("{SETTINGS_FILE}.bad")) + .exists()); +} diff --git a/crates/core/tests/speaker.rs b/crates/core/tests/speaker.rs index 7542b96..597d5c7 100644 --- a/crates/core/tests/speaker.rs +++ b/crates/core/tests/speaker.rs @@ -696,14 +696,21 @@ fn response_time_is_reported_about_once_a_second() { let (_handle, events) = start_worker(simulator.address(), None, short_timing()); collect_until(&events, EVENT_DEADLINE, is_connected); + // Counted from the first report: the read at connect takes a varying while before it. + let first = collect_until(&events, EVENT_DEADLINE, |event| { + matches!(event, Event::ResponseTime(_)) + }); + let Some(Event::ResponseTime(first)) = first.last().cloned() else { + panic!("no response time: {first:?}"); + }; let end = Instant::now() + Duration::from_millis(2500); - let mut times = Vec::new(); + let mut times = vec![first]; while let Some(remaining) = end.checked_duration_since(Instant::now()) { if let Ok(Event::ResponseTime(time)) = events.recv_timeout(remaining) { times.push(time); } } - assert!((2..=3).contains(×.len()), "{times:?}"); + assert!((3..=4).contains(×.len()), "{times:?}"); assert!( times.iter().all(|time| *time >= Duration::from_millis(25)), "{times:?}" From 956dbfb17c4c65ac26c62a421455b41e2d6d6ef1 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:04:41 +0200 Subject: [PATCH 103/460] Note decimal comma and tolerant settings in the changelog --- CHANGELOG.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27b9c84..0c24313 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,12 +19,13 @@ First version. - A device panel per speaker with every setting of its model and a list of all values the speaker reports. - A settings dialog for theme, language, meter range and response times. -- German and English user interface, switched at once. +- German and English user interface, switched at once; German shows a decimal comma. - A compact window mode (Ctrl+K) with one row per ungrouped speaker and per group and a master row, with its own window size and position. - Groups with a group strip (relative fader, mute, linked settings), a master strip, and strips reordered by dragging. - Support for the KH 120 II and the KH 750 with writes; other models read-only. -- Portable settings and log next to the executable. +- Portable settings and log next to the executable; a settings value this version does not + understand falls back to its default without losing the rest, and the file is copied aside. - A speaker simulator for both models used by the automated tests. - `check.ps1` quality gates and `build-release.ps1` for the release folder. From 939df2ba926d6827e93ab51351d0da0929fc3b38 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:19:42 +0200 Subject: [PATCH 104/460] Format decimals without a runtime precision The gate forbids precisions taken from a variable; the two precisions in use get a helper each. --- crates/app/src/display.rs | 2 +- crates/app/src/texts.rs | 13 ++++++++----- crates/app/tests/details.rs | 6 +++--- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/crates/app/src/display.rs b/crates/app/src/display.rs index 4c5e7fc..ac65b9e 100644 --- a/crates/app/src/display.rs +++ b/crates/app/src/display.rs @@ -50,7 +50,7 @@ pub fn converted(conversion: Conversion, number: f64) -> String { match conversion { // Adding zero turns the -0 of a slightly negative reading into 0. Conversion::KelvinToCelsius => format!("{} {unit}", shown.round() + 0.0), - Conversion::SamplesToMilliseconds => format!("{} {unit}", texts::decimal(shown, 2)), + Conversion::SamplesToMilliseconds => format!("{} {unit}", texts::two_decimals(shown)), } } diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 76811c6..7c4d5e1 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -45,9 +45,12 @@ pub fn number_text(text: String) -> String { } } -/// `value` with `places` decimals. -pub fn decimal(value: f64, places: usize) -> String { - number_text(format!("{value:.places$}")) +pub fn one_decimal(value: f64) -> String { + number_text(format!("{value:.1}")) +} + +pub fn two_decimals(value: f64) -> String { + number_text(format!("{value:.2}")) } /// The shortest text that reads back as exactly `value`. @@ -312,11 +315,11 @@ pub fn level_move(moved: f64) -> String { /// A group's or the master's level, without unit: it moves several speakers by the same amount. pub fn shared_readout(value: f64) -> String { - decimal(value, 1) + one_decimal(value) } pub fn readout(value: f64, unit: &str) -> String { - format!("{} {unit}", decimal(value, 1)) + format!("{} {unit}", one_decimal(value)) } pub fn status_line(firmware: &str, masked_serial: &str) -> String { diff --git a/crates/app/tests/details.rs b/crates/app/tests/details.rs index aaab35b..806c7f4 100644 --- a/crates/app/tests/details.rs +++ b/crates/app/tests/details.rs @@ -193,14 +193,14 @@ fn number_field<'a>(harness: &'a Harness<'static, NeumannApp>, label: &'a str) - /// Clicks a number field into text editing and types `text` over its value. fn type_number(harness: &mut Harness<'static, NeumannApp>, label: &str, text: &str) { - number_field(&harness, label).click(); + number_field(harness, label).click(); harness.run_steps(1); - number_field(&harness, label).type_text(text); + number_field(harness, label).type_text(text); harness.run_steps(1); } fn numeric_value(harness: &Harness<'static, NeumannApp>, label: &str) -> Option { - number_field(&harness, label) + number_field(harness, label) .accesskit_node() .numeric_value() } From c1e23468a5794a7e9a954557af8e5a870230e51e Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:54:59 +0200 Subject: [PATCH 105/460] Bind a wheel burst to where it began and close review gaps A wheel begun on a gap no longer turns the knobs the scroll brings under the pointer. Wheel steps at a range end, typed values just outside it and drags back to the start write nothing; groups load what they can, a member stays in one group, and compact group rows show messages. --- README.md | 4 +- crates/app/src/app.rs | 16 ++++- crates/app/src/app/compact.rs | 5 ++ crates/app/src/gesture.rs | 10 ++- crates/app/src/groups.rs | 18 +++++ crates/app/src/main.rs | 3 +- crates/app/src/texts.rs | 5 ++ crates/app/src/views/compact.rs | 5 ++ crates/app/src/views/details_fields.rs | 17 ++++- crates/app/src/views/fader.rs | 56 ++------------- crates/app/src/views/knob.rs | 13 +++- crates/app/src/views/mod.rs | 1 + crates/app/src/views/strip.rs | 8 ++- crates/app/src/views/wheel.rs | 94 ++++++++++++++++++++++++ crates/app/tests/compact.rs | 13 ++++ crates/app/tests/details.rs | 40 +++++++++++ crates/app/tests/support/mod.rs | 27 +++++++ crates/app/tests/views.rs | 27 ------- crates/app/tests/write_gestures.rs | 99 ++++++++++++++++++++++++++ crates/core/src/settings.rs | 4 +- crates/core/tests/settings.rs | 12 +++- 21 files changed, 385 insertions(+), 92 deletions(-) create mode 100644 crates/app/src/views/wheel.rs diff --git a/README.md b/README.md index bb1bc4e..5278686 100644 --- a/README.md +++ b/README.md @@ -58,8 +58,8 @@ covered by the simulator tests only. - "Kompakt" (or Ctrl+K) switches to a compact window and back: one row per ungrouped speaker and per group, each with LEDs (a group: its colour), name, a lying level fader with its value, the loudest input as a small meter and "Stumm", and the master row at the bottom. A group row - moves its members like the group strip; a "!" stands for a speaker's message, which shows on - hover. The compact window keeps its own size and position and scrolls sideways when narrower + moves its members like the group strip; a "!" stands for a speaker's message (on a group or + the master row, a member's), which shows on hover. The compact window keeps its own size and position and scrolls sideways when narrower than its rows; scanning and adding speakers or groups stay on the desk. - "Alle Werte..." lists every value the speaker reports in its own schema, with a path filter and copy buttons, including paths the catalog does not know yet. diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index fc2898b..6a99d9e 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -29,7 +29,7 @@ use crate::views::remove_speaker::{self, RemoveAction}; use crate::views::scan_choice::{self as scan_choice_view, ScanChoiceAction}; use crate::views::settings_dialog::{self, SettingsAction}; use crate::views::toolbar::{self, ToolbarAction, ToolbarFacts}; -use crate::views::{all_values, panel}; +use crate::views::{all_values, panel, wheel}; mod compact; mod desk; @@ -85,10 +85,21 @@ impl NeumannApp { context: &Context, settings_path: PathBuf, spawner: Box, + ) -> NeumannApp { + let settings = settings::load(&settings_path); + NeumannApp::with_settings(context, settings_path, settings, spawner) + } + + /// With settings already loaded from `settings_path`, as the start does to place the window: + /// a second load would set a file with values it does not understand aside a second time. + pub fn with_settings( + context: &Context, + settings_path: PathBuf, + settings: Settings, + spawner: Box, ) -> NeumannApp { fonts::install(context); theme::apply(context); - let settings = settings::load(&settings_path); context.set_theme(theme::preference(settings.desk.theme)); let repaint_context = context.clone(); let mut app = NeumannApp { @@ -555,6 +566,7 @@ impl eframe::App for NeumannApp { fn ui(&mut self, ui: &mut Ui, frame: &mut eframe::Frame) { texts::set_language(self.settings.desk.language); + wheel::track_burst(ui.ctx()); self.show(ui, frame); } diff --git a/crates/app/src/app/compact.rs b/crates/app/src/app/compact.rs index a679d49..f5d98ab 100644 --- a/crates/app/src/app/compact.rs +++ b/crates/app/src/app/compact.rs @@ -83,12 +83,17 @@ impl NeumannApp { .filter_map(|state| state.loudest_input(now)) .collect(); let range = output_level_range(); + let message = states.iter().find_map(|state| { + let message = texts::describe_message(state.message.as_ref()?); + Some(texts::member_message(&state.display_name(), &message)) + }); SharedRow { level: shared_level(&levels(&states), &range), muted: all_muted(&states), loudest: reference_level(&inputs), range, floor, + message, } } diff --git a/crates/app/src/gesture.rs b/crates/app/src/gesture.rs index b7f05d3..c8ae1da 100644 --- a/crates/app/src/gesture.rs +++ b/crates/app/src/gesture.rs @@ -53,7 +53,8 @@ impl GestureState { } let base = self.pending.unwrap_or(current); let target = base + whole as f64 * step; - if let Some(moved) = bounded_move(base, target, &range) { + let moved = bounded_move(base, target, &range).filter(|moved| *moved != base); + if let Some(moved) = moved { self.pending = Some(moved); } } @@ -287,6 +288,13 @@ mod tests { assert_eq!(on_grid(1.26, 0.5), 1.5); } + #[test] + fn a_wheel_against_a_range_end_writes_nothing() { + let mut fader = GestureState::default(); + fader.on_wheel(1.0, LEVEL_STEP, 120.0, LEVEL_RANGE, 0.0); + assert_eq!(fader.due(0.5), None); + } + #[test] fn a_wheel_outside_the_range_only_moves_back_into_it() { let mut knob = GestureState::default(); diff --git a/crates/app/src/groups.rs b/crates/app/src/groups.rs index bf5fd1a..ef584c6 100644 --- a/crates/app/src/groups.rs +++ b/crates/app/src/groups.rs @@ -144,6 +144,15 @@ pub fn rename_member( for group in groups.iter_mut() { changed |= rename_in(&mut group.members, old, new); } + // Known under both ids, it may have been put into two groups; the first keeps it. + let first = groups + .iter() + .position(|group| group.members.iter().any(|id| id == new)); + for (index, group) in groups.iter_mut().enumerate() { + if Some(index) != first { + group.members.retain(|id| id != new); + } + } changed | rename_in(order, old, new) } @@ -387,6 +396,15 @@ mod tests { assert_eq!(order, ids(&["serial:a"])); } + #[test] + fn a_rename_never_leaves_a_speaker_in_two_groups() { + let mut groups = [group("Front", &["address:a"]), group("Sub", &["serial:a"])]; + let mut order = Vec::new(); + rename_member(&mut groups, &mut order, "address:a", "serial:a"); + assert_eq!(groups[0].members, ids(&["serial:a"])); + assert!(groups[1].members.is_empty()); + } + #[test] fn a_forgotten_member_leaves_group_and_order_under_every_id() { let mut groups = [group("Front", &["serial:a", "b"])]; diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index e03e9ba..46291b9 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -74,7 +74,8 @@ fn main() -> Result<(), Box> { } let context = creation_context.egui_ctx.clone(); let spawner = Box::new(CoreSpawner::new(context.clone())); - Ok(Box::new(NeumannApp::new(&context, settings_path, spawner))) + let app = NeumannApp::with_settings(&context, settings_path, saved, spawner); + Ok(Box::new(app)) }), )?; Ok(()) diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 7c4d5e1..ab55b95 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -357,6 +357,11 @@ pub fn describe_message(message: &Message) -> String { } } +/// A group row's note about one of its members. +pub fn member_message(name: &str, message: &str) -> String { + format!("{name}: {message}") +} + pub fn serial_mismatch(masked_serial: &str) -> String { pick( format!("Anderes Gerät unter dieser Adresse (Seriennummer {masked_serial})"), diff --git a/crates/app/src/views/compact.rs b/crates/app/src/views/compact.rs index bead7ed..320971e 100644 --- a/crates/app/src/views/compact.rs +++ b/crates/app/src/views/compact.rs @@ -100,6 +100,8 @@ pub struct SharedRow { pub loudest: Option, pub range: RangeInclusive, pub floor: f64, + /// A member's message, worded with its name: grouped members have no row of their own. + pub message: Option, } pub fn group( @@ -167,6 +169,9 @@ fn shared_controls( tokens, ); let mute = mute_button(ui, mute_label, shown.muted); + if let Some(message) = &shown.message { + message_mark(ui, message, tokens); + } (moved, mute) } diff --git a/crates/app/src/views/details_fields.rs b/crates/app/src/views/details_fields.rs index b60b947..08361d1 100644 --- a/crates/app/src/views/details_fields.rs +++ b/crates/app/src/views/details_fields.rs @@ -7,7 +7,7 @@ use neumann_control_core::speaker::Command; use serde_json::Value; use crate::display::{capped, converted_unit}; -use crate::gesture::{bounded_move, on_grid, typed_value}; +use crate::gesture::{bounded_move, on_grid, released, typed_value}; use crate::state::SpeakerState; use crate::texts; use crate::writes::{self, Intent}; @@ -124,8 +124,21 @@ impl Fields<'_> { if ui.input(|input| input.key_pressed(Key::Escape)) { return; } + // A drag or an edit that ends where it began writes nothing. + let unchanged = match (entry.step, device) { + (Some(step), Some(device)) => released(device, number, step).is_none(), + _ => Some(number) == device, + }; + if unchanged { + return; + } let path = entry.path.clone(); - let value = writes::number_value(entry, reported.as_ref(), number); + // A typed value outside the range is sent as typed, for the write policy to refuse. + let value = if (min..=max).contains(&number) { + writes::number_value(entry, reported.as_ref(), number) + } else { + Value::from(number) + }; self.send(ui, Intent::Write { path, value }); } diff --git a/crates/app/src/views/fader.rs b/crates/app/src/views/fader.rs index b43f18f..65accab 100644 --- a/crates/app/src/views/fader.rs +++ b/crates/app/src/views/fader.rs @@ -5,12 +5,13 @@ use std::ops::RangeInclusive; use eframe::egui::{ - self, vec2, Align2, Area, CornerRadius, CursorIcon, Event, Frame, Id, Key, Label, - MouseWheelUnit, Order, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, WidgetInfo, + self, vec2, Align2, Area, CornerRadius, CursorIcon, Frame, Id, Key, Label, Order, Rect, + Response, Sense, Stroke, StrokeKind, Ui, Vec2, WidgetInfo, }; use neumann_control_core::catalog::Catalog; use neumann_control_core::speaker::Command; +use super::wheel::notches_for; use crate::fader::WHEEL_STEP_DB; use crate::gesture::{self, bounded_move, on_grid}; use crate::groups::SharedLevel; @@ -136,7 +137,7 @@ pub fn show( let held = response.is_pointer_button_down_on() || response.dragged(); if enabled && response.hovered() && !held { ui.ctx().set_cursor_icon(orientation.cursor()); - let wheel = wheel_notches(ui); + let wheel = notches_for(ui, rect); for notches in &wheel { speaker .fader @@ -196,7 +197,9 @@ pub fn shared( let id = response.id; response.on_hover_text(texts::FADER_HINT); if drag.current.is_some() { - let text = texts::level_move(value - reference); + let moved = gesture::released(drag.started, value, WHEEL_STEP_DB) + .map_or(0.0, |end| end - drag.started); + let text = texts::level_move(moved); dragged_value(ui, id, rect, &range, value, orientation, &text); } drag.released.map(|released| released - drag.started) @@ -393,48 +396,3 @@ pub(super) fn drag_aborted(ui: &Ui, response: &Response) -> bool { ui.data_mut(|data| data.insert_temp(id, aborted && button_down)); aborted } - -/// Lines and pixels of a precision touchpad come as many small events, pages as rare large -/// ones; egui scrolls this many points per line. -const POINTS_PER_NOTCH: f32 = 40.0; -const NOTCHES_PER_PAGE: f32 = 10.0; - -/// Each wheel event of this frame in notches (lines), upwards positive. Taken by the hovered -/// control: the scroll area around it would otherwise scroll as well and bring another control -/// under the pointer for the next notches. -pub(super) fn wheel_notches(ui: &Ui) -> Vec { - ui.input_mut(|input| input.smooth_scroll_delta = Vec2::ZERO); - ui.input(|input| { - input - .events - .iter() - .filter_map(|event| match event { - Event::MouseWheel { unit, delta, .. } if delta.y != 0.0 => { - Some(notches(*unit, delta.y)) - } - _ => None, - }) - .collect() - }) -} - -fn notches(unit: MouseWheelUnit, delta: f32) -> f64 { - let lines = match unit { - MouseWheelUnit::Line => delta, - MouseWheelUnit::Point => delta / POINTS_PER_NOTCH, - MouseWheelUnit::Page => delta * NOTCHES_PER_PAGE, - }; - f64::from(lines) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn wheel_units_become_notches() { - assert_eq!(notches(MouseWheelUnit::Line, 0.25), 0.25); - assert_eq!(notches(MouseWheelUnit::Point, -80.0), -2.0); - assert_eq!(notches(MouseWheelUnit::Page, 1.0), 10.0); - } -} diff --git a/crates/app/src/views/knob.rs b/crates/app/src/views/knob.rs index e209078..506ea4d 100644 --- a/crates/app/src/views/knob.rs +++ b/crates/app/src/views/knob.rs @@ -14,7 +14,8 @@ use neumann_control_core::catalog::{Catalog, Conversion, Entry}; use neumann_control_core::speaker::Command; use serde_json::Value; -use super::fader::{drag_aborted, value_beside, wheel_notches, FINE_DIVISOR}; +use super::fader::{drag_aborted, value_beside, FINE_DIVISOR}; +use super::wheel::notches_for; use crate::display::shown_value; use crate::gesture::{bounded_move, released, typed_value, GestureState}; use crate::state::SpeakerState; @@ -85,7 +86,13 @@ pub fn for_entry( let id = ui.id().with(entry.path.to_string()); let sent = show(ui, id, &knob, &mut state); speaker.knobs.insert(entry.path.clone(), state); - let value = writes::number_value(entry, reported.as_ref(), sent?); + let sent = sent?; + // A typed value outside the range is sent as typed, for the write policy to refuse. + let value = if (minimum..=maximum).contains(&sent) { + writes::number_value(entry, reported.as_ref(), sent) + } else { + Value::from(sent) + }; let now = ui.input(|input| input.time); let path = entry.path.clone(); speaker.request(Intent::Write { path, value }, catalog, now) @@ -174,7 +181,7 @@ pub fn show(ui: &mut Ui, id: Id, knob: &Knob, state: &mut GestureState) -> Optio }); } if enabled && response.hovered() { - let wheel = wheel_notches(ui); + let wheel = notches_for(ui, rect); let step = wheel_step(&knob.range, knob.step); for notches in &wheel { state.on_wheel(*notches, step, device, knob.range.clone(), now); diff --git a/crates/app/src/views/mod.rs b/crates/app/src/views/mod.rs index 939b148..2542ba8 100644 --- a/crates/app/src/views/mod.rs +++ b/crates/app/src/views/mod.rs @@ -14,3 +14,4 @@ pub mod scan_choice; pub mod settings_dialog; pub mod strip; pub mod toolbar; +pub mod wheel; diff --git a/crates/app/src/views/strip.rs b/crates/app/src/views/strip.rs index 577ab9c..7f40390 100644 --- a/crates/app/src/views/strip.rs +++ b/crates/app/src/views/strip.rs @@ -230,7 +230,13 @@ fn level( let entry_id = response.id.with(LEVEL_ENTRY_ID); let open_now = response.double_clicked() && device.is_some(); if let Some(level) = knob::value_entry(ui, entry_id, open_now, shown, None) { - let level = on_grid(level, WHEEL_STEP_DB); + // Outside the range the typed value stays as typed, so the write policy refuses it + // instead of the grid pulling it in ("120,2" is not 120). + let level = if output_level_range().contains(&level) { + on_grid(level, WHEEL_STEP_DB) + } else { + level + }; let now = ui.input(|input| input.time); commands.extend(speaker.request(Intent::Level(level), catalog, now)); } diff --git a/crates/app/src/views/wheel.rs b/crates/app/src/views/wheel.rs new file mode 100644 index 0000000..374fbe7 --- /dev/null +++ b/crates/app/src/views/wheel.rs @@ -0,0 +1,94 @@ +//! The mouse wheel over faders and knobs. A burst of wheel events belongs to the control under +//! the pointer where it began: a burst begun on a gap scrolls the area around it, and a knob or +//! fader that the scroll brings under the pointer takes none of its notches. A control holding +//! a burst stops the area around it from scrolling, so it stays under the pointer. + +use eframe::egui::{Context, Event, Id, MouseWheelUnit, Pos2, Rect, Ui, Vec2}; + +use crate::gesture::WHEEL_IDLE_SECONDS; + +const BURST_ID: &str = "wheel_burst"; +/// Lines and pixels of a precision touchpad come as many small events, pages as rare large +/// ones; egui scrolls this many points per line. +const POINTS_PER_NOTCH: f32 = 40.0; +const NOTCHES_PER_PAGE: f32 = 10.0; + +#[derive(Clone, Copy, Debug)] +struct Burst { + /// Where the pointer was at the first event of the burst. + start: Pos2, + last_event: f64, +} + +/// Once per frame before anything is drawn: notes where a new burst begins. Events closer +/// together than the wheel's idle time continue the burst. +pub fn track_burst(context: &Context) { + let (wheeling, now, pointer) = context.input(|input| { + let wheeling = input + .events + .iter() + .any(|event| matches!(event, Event::MouseWheel { .. })); + (wheeling, input.time, input.pointer.hover_pos()) + }); + let Some(pointer) = pointer.filter(|_| wheeling) else { + return; + }; + let id = Id::new(BURST_ID); + let previous = context.data(|data| data.get_temp::(id)); + let start = previous + .filter(|burst| now - burst.last_event < WHEEL_IDLE_SECONDS) + .map_or(pointer, |burst| burst.start); + let burst = Burst { + start, + last_event: now, + }; + context.data_mut(|data| data.insert_temp(id, burst)); +} + +/// This frame's wheel events in notches (lines), upwards positive, for the control at `rect` when +/// the burst began over it; none otherwise. +pub(super) fn notches_for(ui: &Ui, rect: Rect) -> Vec { + let burst = ui.data(|data| data.get_temp::(Id::new(BURST_ID))); + let now = ui.input(|input| input.time); + let held_here = burst.is_some_and(|burst| { + rect.contains(burst.start) && now - burst.last_event < WHEEL_IDLE_SECONDS + }); + if !held_here { + return Vec::new(); + } + // egui spreads a wheel event's scroll over several frames; all of them stay with the control. + ui.input_mut(|input| input.smooth_scroll_delta = Vec2::ZERO); + ui.input(|input| { + input + .events + .iter() + .filter_map(|event| match event { + Event::MouseWheel { unit, delta, .. } if delta.y != 0.0 => { + Some(notches(*unit, delta.y)) + } + _ => None, + }) + .collect() + }) +} + +fn notches(unit: MouseWheelUnit, delta: f32) -> f64 { + let lines = match unit { + MouseWheelUnit::Line => delta, + MouseWheelUnit::Point => delta / POINTS_PER_NOTCH, + MouseWheelUnit::Page => delta * NOTCHES_PER_PAGE, + }; + f64::from(lines) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wheel_units_become_notches() { + assert_eq!(notches(MouseWheelUnit::Line, 0.25), 0.25); + assert_eq!(notches(MouseWheelUnit::Point, -80.0), -2.0); + assert_eq!(notches(MouseWheelUnit::Page, 1.0), 10.0); + } +} diff --git a/crates/app/tests/compact.rs b/crates/app/tests/compact.rs index e9242f9..53e5895 100644 --- a/crates/app/tests/compact.rs +++ b/crates/app/tests/compact.rs @@ -172,3 +172,16 @@ fn a_compact_row_says_why_a_write_was_refused() { harness.query_by_label(&message).is_some() }); } + +#[test] +fn a_group_row_says_why_a_members_write_was_refused() { + let (kh120, _kh750, _directory, mut harness) = grouped(Vec::new()); + kh120.set_value("ui/control_mode", serde_json::json!("LOCAL")); + press_compact_shortcut(&mut harness); + drag_level(&mut harness, &texts::group_level(GROUP_NAME), -40.0); + let message = texts::describe_rejection(401); + // On the group row and on the master row, which both hold the member. + wait_until(&mut harness, "the LOCAL message", |harness| { + harness.query_all_by_label_contains(&message).count() == 2 + }); +} diff --git a/crates/app/tests/details.rs b/crates/app/tests/details.rs index 806c7f4..0536138 100644 --- a/crates/app/tests/details.rs +++ b/crates/app/tests/details.rs @@ -491,3 +491,43 @@ fn visiting_a_delay_in_milliseconds_writes_nothing() { barrier(&simulator); assert!(sets_to(&simulator, DELAY_PATH).is_empty()); } + +#[test] +fn a_panel_drag_outside_the_range_writes_nothing() { + let (simulator, _directory, mut harness) = speaker_with_panel(SimModel::Kh120Ii); + simulator.set_value("audio/out/delay", json!(6000)); + wait_until(&mut harness, "the reported delay", |harness| { + numeric_value(harness, DELAY_LABEL) == Some(6000.0) + }); + let start = number_field(&harness, DELAY_LABEL).rect().center(); + harness.drag_at(start); + for frame in 1..=10 { + harness.hover_at(start + egui::vec2(4.0 * frame as f32, 0.0)); + harness.step(); + } + harness.drop_at(start + egui::vec2(40.0, 0.0)); + harness.run_steps(IDLE_FRAMES); + barrier(&simulator); + let sets = sets_to(&simulator, DELAY_PATH); + assert!(sets.is_empty(), "{sets:?}"); +} + +#[test] +fn a_panel_drag_back_to_its_start_writes_nothing() { + let (simulator, _directory, mut harness) = speaker_with_panel(SimModel::Kh120Ii); + simulator.set_value("audio/out/delay", json!(100.123456)); + wait_until(&mut harness, "the reported delay", |harness| { + numeric_value(harness, DELAY_LABEL) == Some(100.123456) + }); + let start = number_field(&harness, DELAY_LABEL).rect().center(); + harness.drag_at(start); + for offset in [8.0, 16.0, 8.0, 0.0] { + harness.hover_at(start + egui::vec2(offset, 0.0)); + harness.step(); + } + harness.drop_at(start); + harness.run_steps(IDLE_FRAMES); + barrier(&simulator); + let sets = sets_to(&simulator, DELAY_PATH); + assert!(sets.is_empty(), "{sets:?}"); +} diff --git a/crates/app/tests/support/mod.rs b/crates/app/tests/support/mod.rs index 1704bde..dde7837 100644 --- a/crates/app/tests/support/mod.rs +++ b/crates/app/tests/support/mod.rs @@ -321,3 +321,30 @@ pub fn double_click_at(harness: &mut Harness<'static, NeumannApp>, position: egu } harness.run_steps(2); } + +pub fn around(center: egui::Pos2, radius: f32, degrees: f32) -> egui::Pos2 { + let radians = degrees.to_radians(); + center + radius * egui::vec2(radians.sin(), -radians.cos()) +} + +/// Presses at `from` degrees on the knob's rim and moves clockwise to `to` without releasing. +pub fn turn_knob( + harness: &mut Harness<'static, NeumannApp>, + label: &str, + from: f32, + to: f32, +) -> egui::Pos2 { + let center = harness + .get_by_role_and_label(eframe::egui::accesskit::Role::Slider, label) + .rect() + .center(); + let radius = 14.0; + harness.drag_at(around(center, radius, from)); + let steps = 6; + for step in 1..=steps { + let degrees = from + (to - from) * step as f32 / steps as f32; + harness.hover_at(around(center, radius, degrees)); + harness.step(); + } + around(center, radius, to) +} diff --git a/crates/app/tests/views.rs b/crates/app/tests/views.rs index 5c90a8f..c49f530 100644 --- a/crates/app/tests/views.rs +++ b/crates/app/tests/views.rs @@ -410,33 +410,6 @@ fn strip_content_stacks_top_down() { } /// Point on a circle of `radius` around `center` at `degrees` clockwise from the top. -fn around(center: egui::Pos2, radius: f32, degrees: f32) -> egui::Pos2 { - let radians = degrees.to_radians(); - center + radius * egui::vec2(radians.sin(), -radians.cos()) -} - -/// Presses at `from` degrees on the knob's rim and moves clockwise to `to` without releasing. -fn turn_knob( - harness: &mut Harness<'static, NeumannApp>, - label: &str, - from: f32, - to: f32, -) -> egui::Pos2 { - let center = harness - .get_by_role_and_label(Role::Slider, label) - .rect() - .center(); - let radius = 14.0; - harness.drag_at(around(center, radius, from)); - let steps = 6; - for step in 1..=steps { - let degrees = from + (to - from) * step as f32 / steps as f32; - harness.hover_at(around(center, radius, degrees)); - harness.step(); - } - around(center, radius, to) -} - #[test] fn strip_shows_leds_knobs_fader_and_buttons() { let (_simulator, _directory, harness) = connected_speaker(SimModel::Kh120Ii); diff --git a/crates/app/tests/write_gestures.rs b/crates/app/tests/write_gestures.rs index 97153ea..28eb9e0 100644 --- a/crates/app/tests/write_gestures.rs +++ b/crates/app/tests/write_gestures.rs @@ -15,6 +15,9 @@ use neumann_control_core::simulator::SimModel; use serde_json::{json, Value}; use support::*; +/// The strip's delay knob carries the device panel's name. +const DELAY_KNOB: &str = "Verzögerung"; + const LEVEL_PATH: &str = "audio/out/level"; fn wheel(harness: &mut Harness<'static, NeumannApp>, notches: f32) { @@ -174,3 +177,99 @@ fn a_group_raises_its_quieter_members_when_the_loudest_is_at_its_limit() { assert!(levels(&kh750)[0].as_f64().unwrap() > 90.0); assert!(levels(&kh120).is_empty(), "{:?}", levels(&kh120)); } + +#[test] +fn scrolling_the_panel_from_a_gap_never_turns_a_knob() { + let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); + open_panel(&mut harness, KH120_DEVICE_NAME); + harness.set_size(egui::vec2(1200.0, 420.0)); + harness.run_steps(2); + let knob = slider_center(&harness, &texts::knob_label("Logo-Helligkeit")); + // Above the knob, on no control: the wheel scrolls the panel, and knobs pass under it. + harness.hover_at(knob - egui::vec2(0.0, 40.0)); + for _ in 0..12 { + wheel(&mut harness, -1.0); + harness.step(); + } + harness.run_steps(5); + barrier(&simulator); + assert!( + delay_sets(&simulator).is_empty(), + "{:?}", + delay_sets(&simulator) + ); + assert!(sets(&simulator, "/ui/logo/brightness").is_empty()); +} + +#[test] +fn a_knob_on_an_out_of_range_value_turned_further_out_writes_nothing() { + let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); + simulator.set_value("audio/out/delay", json!(6000)); + wait_until(&mut harness, "the reported delay", |harness| { + harness.query_by_label_contains("125,00 ms").is_some() + }); + let end = turn_knob(&mut harness, DELAY_KNOB, 90.0, 150.0); + harness.drop_at(end); + harness.run_steps(5); + barrier(&simulator); + assert!( + delay_sets(&simulator).is_empty(), + "{:?}", + delay_sets(&simulator) + ); +} + +#[test] +fn a_fader_on_an_out_of_range_level_pushed_further_out_writes_nothing() { + let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); + simulator.set_value(LEVEL_PATH, json!(125.0)); + wait_for_level(&mut harness, 125.0); + let start = slider_center(&harness, texts::LEVEL.get()); + harness.drag_at(start); + for frame in 1..=4 { + harness.hover_at(start - egui::vec2(0.0, 5.0 * frame as f32)); + harness.step(); + } + harness.drop_at(start - egui::vec2(0.0, 20.0)); + harness.run_steps(5); + barrier(&simulator); + assert!( + level_sets(&simulator).is_empty(), + "{:?}", + level_sets(&simulator) + ); +} + +#[test] +fn a_typed_delay_outside_the_range_is_refused_on_the_strip_knob() { + let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); + let position = slider_center(&harness, DELAY_KNOB); + double_click_at(&mut harness, position); + harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + harness.get_by_role(Role::TextInput).type_text("5000"); + harness.key_press(egui::Key::Enter); + harness.run_steps(5); + barrier(&simulator); + assert!( + delay_sets(&simulator).is_empty(), + "{:?}", + delay_sets(&simulator) + ); +} + +#[test] +fn a_typed_level_just_outside_the_range_is_refused_too() { + let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); + let position = harness.get_by_label(&readout(100.0)).rect().center(); + double_click_at(&mut harness, position); + harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + harness.get_by_role(Role::TextInput).type_text("120,2"); + harness.key_press(egui::Key::Enter); + harness.run_steps(5); + barrier(&simulator); + assert!( + level_sets(&simulator).is_empty(), + "{:?}", + level_sets(&simulator) + ); +} diff --git a/crates/core/src/settings.rs b/crates/core/src/settings.rs index 4df63f6..aa1096a 100644 --- a/crates/core/src/settings.rs +++ b/crates/core/src/settings.rs @@ -76,11 +76,13 @@ impl Settings { pub struct GroupSettings { pub name: String, /// Index into the app's group colour palette. + #[serde(default, deserialize_with = "or_default")] pub color: u8, /// Speaker identities, as in `Settings::order`. + #[serde(default, deserialize_with = "readable_items")] pub members: Vec, /// Settings a change on one member sends to all members. - #[serde(default)] + #[serde(default, deserialize_with = "readable_items")] pub linked: Vec, } diff --git a/crates/core/tests/settings.rs b/crates/core/tests/settings.rs index 4c18747..98367f2 100644 --- a/crates/core/tests/settings.rs +++ b/crates/core/tests/settings.rs @@ -214,7 +214,10 @@ fn a_value_this_version_does_not_know_costs_only_that_value() { let text = r#"{ "manual": [{"address": "fe80::1%7", "port": 45, "serial": "SIM1"}, {"address": "fe80::2%7"}], "desk": {"language": "French", "meter_floor": "Minus200", "show_response_times": false}, - "groups": [{"name": "Front", "members": ["serial:SIM1"]}, {"name": "Sub", "color": 1, "members": []}], + "groups": [ + {"name": "Front", "members": ["serial:SIM1"], "linked": ["Level", "Delay"]}, + {"name": "Sub", "color": 1, "members": []} + ], "order": ["serial:SIM1"] }"#; fs::write(&path, text).unwrap(); @@ -226,8 +229,11 @@ fn a_value_this_version_does_not_know_costs_only_that_value() { !loaded.desk.show_response_times, "a readable neighbour stays" ); - assert_eq!(loaded.groups.len(), 1); - assert_eq!(loaded.groups[0].name, "Sub"); + // A group keeps its members when its colour is missing or a link is unknown. + assert_eq!(loaded.groups.len(), 2); + assert_eq!(loaded.groups[0].members, vec!["serial:SIM1".to_owned()]); + assert_eq!(loaded.groups[0].linked, vec![Linkable::Level]); + assert_eq!(loaded.groups[1].color, 1); assert_eq!(loaded.order, vec!["serial:SIM1".to_owned()]); // The original stays readable for whoever wants the dropped values back. let copy = directory.path().join(format!("{SETTINGS_FILE}.bad")); From 4eebf773abb5ce52d4cd8eed866c6d1b14704d2c Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:03:49 +0200 Subject: [PATCH 106/460] Add the app logo and icon Derived from the Home Assistant integration's brand: the same rings and wordmark, with CONTROL in place of CONNECT; the icon goes into the window and the executable. --- CHANGELOG.md | 1 + Cargo.lock | 11 +++++++++++ README.md | 2 ++ crates/app/Cargo.toml | 4 ++++ crates/app/assets/brand/icon.ico | Bin 0 -> 34122 bytes crates/app/assets/brand/icon.png | Bin 0 -> 6532 bytes crates/app/assets/brand/logo.png | Bin 0 -> 25745 bytes crates/app/assets/brand/logo@2x.png | Bin 0 -> 17308 bytes crates/app/build.rs | 14 ++++++++++++++ crates/app/src/main.rs | 9 +++++++++ 10 files changed, 41 insertions(+) create mode 100644 crates/app/assets/brand/icon.ico create mode 100644 crates/app/assets/brand/icon.png create mode 100644 crates/app/assets/brand/logo.png create mode 100644 crates/app/assets/brand/logo@2x.png create mode 100644 crates/app/build.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c24313..a85862f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ First version. - Groups with a group strip (relative fader, mute, linked settings), a master strip, and strips reordered by dragging. - Support for the KH 120 II and the KH 750 with writes; other models read-only. +- An application icon for the window, the taskbar and the executable. - Portable settings and log next to the executable; a settings value this version does not understand falls back to its default without losing the rest, and the file is copied aside. - A speaker simulator for both models used by the automated tests. diff --git a/Cargo.lock b/Cargo.lock index be8dadd..8441fdd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1928,6 +1928,7 @@ dependencies = [ "simplelog", "tempfile", "winit", + "winresource", ] [[package]] @@ -4170,6 +4171,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winresource" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b" +dependencies = [ + "toml", + "version_check", +] + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/README.md b/README.md index 5278686..29069ba 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # neumann-control +![Neumann Control](crates/app/assets/brand/logo.png) + A portable Windows app to find, read and control Neumann KH DSP speakers over the Sennheiser Sound Control protocol (SSC). The user interface is in German or English; the names below are the German ones. diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 5ba6489..2d6eb12 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -23,6 +23,10 @@ simplelog = "0.12" # Already in the tree through eframe; lists the monitors to place the window at the start. winit = "0.30" +[build-dependencies] +# Puts the icon into the executable, for Explorer and the taskbar. +winresource = "0.1" + [dev-dependencies] egui_kittest = { version = "0.36", features = ["eframe"] } neumann-control-core = { path = "../core", features = ["test-support"] } diff --git a/crates/app/assets/brand/icon.ico b/crates/app/assets/brand/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..c4caefe130bf02604bd135e64081f18fc957a7a7 GIT binary patch literal 34122 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 diff --git a/crates/app/assets/brand/icon.png b/crates/app/assets/brand/icon.png index 714b1172c2c0e2d68b72cf7d338bd608a6c29831..d95b0ee8f5a3e82cc24959ad11f8d0138ee5869d 100644 GIT binary patch literal 8933 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 KmN{E!VgCa)B_f{y From d47531b7e1bbec32c5a25f81f761c0d0207628b8 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:10:03 +0200 Subject: [PATCH 108/460] Rewrite the README for GitHub Sections for installation, getting started, using the desk, groups, compact mode and how writes stay safe, with a table of contents; content unchanged where it was already true. --- README.md | 328 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 220 insertions(+), 108 deletions(-) diff --git a/README.md b/README.md index 29069ba..25f5017 100644 --- a/README.md +++ b/README.md @@ -1,129 +1,234 @@ -# neumann-control +# Neumann Control ![Neumann Control](crates/app/assets/brand/logo.png) -A portable Windows app to find, read and control Neumann KH DSP speakers over the -Sennheiser Sound Control protocol (SSC). The user interface is in German or English; the -names below are the German ones. +A portable Windows app to find, read and control Neumann KH DSP speakers over the network, using +the Sennheiser Sound Control protocol (SSC). It shows your speakers side by side like channel +strips on a desk, with level faders, meters, groups and a master, and it is careful never to +write a value you did not choose. -This is an independent project. It is not affiliated with or endorsed by Neumann or Sennheiser. +> This is an independent project. It is not affiliated with or endorsed by Neumann or +> Sennheiser. + +## Contents + +- [Highlights](#highlights) +- [Status](#status) +- [Requirements](#requirements) +- [Installation](#installation) +- [Getting started](#getting-started) +- [Using the app](#using-the-app) +- [How writes stay safe](#how-writes-stay-safe) +- [Supported models](#supported-models) +- [Files and privacy](#files-and-privacy) +- [Known limits](#known-limits) +- [Building from source](#building-from-source) +- [Licence](#licence) + +## Highlights + +- **Find speakers** on the local network (mDNS) and choose which to add, or add one by its IPv6 + address. Added speakers are remembered. +- **A desk of channel strips**: status LEDs, delay and standby knobs, a level fader, input + meters (on the KH 750 also its output meters), mute and identify. +- **A device panel** per speaker with every setting the catalog knows for its model. +- **Groups and a master**: move or mute several speakers at once, and link settings within a + group. +- **Compact mode** (Ctrl+K): one row per speaker or group in a small window. +- **German and English** interface, light and dark theme. +- **Portable**: one executable, settings and log next to it, no installer. + +The interface is in German or English. This README uses the German names of buttons and dialogs, +with their meaning in brackets where it is not obvious. ## Status Version 0.1.0. The protocol layer, the polling and the interface are covered by automated tests against a built-in speaker simulator for the KH 120 II and the KH 750. On real KH 120 II and KH 750 speakers the app was checked for scanning, reading, metering and writing level, mute, -identify and standby time. Groups, the compact mode and the English interface are so far -covered by the simulator tests only. - -## Features - -- Finds speakers on the local network with mDNS (`_ssc._tcp`) when you press "Suchen" (a scan - takes 4 seconds) and lets you choose which of them to add, or adds a speaker by its IPv6 - address with "+ Adresse". Added speakers are remembered. -- One channel strip per speaker, side by side: name, four LEDs (connection, clip, device - warning, LOCAL), knobs for delay (shown in ms) and standby time, output level fader - (0-120 dB SPL in 0.5 dB steps), input level meters for channels A and B with clip lamps (on - the KH 750 also its output meters A, B and Sub, marked "Ausg."), response time, "Stumm" (mute) - and "Ident." (identify). On the KH 120 II the input meters are captioned with the rear input - switch (Analog, AES3 L, AES3 R, Mono) and a dot marks the channels the speaker plays; that A - is the left AES3 channel is assumed, not measured. -- Fader: grab it anywhere and drag; it moves with the pointer and never jumps to it. Shift makes - it ten times finer, the mouse wheel moves 0.5 dB per notch, and a double-click on the value - under it lets you type one. The value stands beside the fader while you drag. -- Knobs: turn them by moving the pointer around the knob (clockwise is more; one and a half - turns cover the whole range), Shift for fine steps, the mouse wheel, or a double-click to type - a value (the delay in ms). The value stands beside the knob while it moves. -- Fader and knobs write once when you let go (only if the value changed), stop the wheel or - press Enter; Escape cancels a drag or a typed value. A typed value outside the range is - refused with the reason instead of being cut to the range's end. -- Clicking a speaker's name opens its device panel with every setting the catalog knows for - that model (output, input, standby, device, rear panel), grouped in two columns; settings a - model may write get a control, the rest are shown greyed. The panel also shows model, - firmware and masked serial number. Escape, "Schließen" or a click outside closes it. -- Groups: "+ Gruppe" creates a group; a speaker joins one in its device panel ("Gruppe"). Group - members are framed in the group's colour and followed by a group strip: its fader moves every - member by the same amount (each stays within its own range; with the loudest member at its - maximum the others still follow), "Stumm" mutes them all, and the buttons under "Gekoppelt" - link level, mute, auto standby or input interface, so a change on one member goes to the others - whose model may write it. Members that are not connected are left out. "Für Gruppe übernehmen" - in the panel copies a linked value to the other members. Clicking the group's name renames or - deletes it. -- A master strip at the end moves and mutes all connected speakers the same way. -- Drag a strip by the dots before its name; the other strips of its group (or the ungrouped - ones) make room where it would land, and the order is remembered when you let go. Escape - cancels. -- "Einstellungen" sets the theme (like Windows, light, dark), the language (Deutsch, English; - it changes at once; German shows a decimal comma, and typed values take a comma or a point), - the lowest level the meters show (-120, -90 or -60 dB) and whether response times are shown; the toolbar shows the average response time of the connected - speakers. -- "Kompakt" (or Ctrl+K) switches to a compact window and back: one row per ungrouped speaker - and per group, each with LEDs (a group: its colour), name, a lying level fader with its value, - the loudest input as a small meter and "Stumm", and the master row at the bottom. A group row - moves its members like the group strip; a "!" stands for a speaker's message (on a group or - the master row, a member's), which shows on hover. The compact window keeps its own size and position and scrolls sideways when narrower - than its rows; scanning and adding speakers or groups stay on the desk. -- "Alle Werte..." lists every value the speaker reports in its own schema, with a path filter and - copy buttons, including paths the catalog does not know yet. -- Shows the control mode (NETWORK or LOCAL), which the CONTROL switch on the back of the speaker - sets; it cannot be changed over the network. In LOCAL the speaker refuses every write, and the - strip says so. -- A speaker that stops answering is shown as unreachable and reconnected every 10 seconds. - -## Supported models - -| Model | Read | Write | -|-------|------|-------| -| KH 120 II | yes | yes | -| KH 750 | yes | yes, except standby (see limits) | -| Other models in the catalog (for example KH 80, KH 150) | yes | no | - -The catalog is `crates/core/src/paths.toml`. A product that is not listed there for a path is -read-only, so models whose behaviour is unknown never receive a write. Settings that only exist -on some models (subwoofer outputs, logo brightness) appear only for those models. +identify and standby time. Groups, the compact mode and the English interface are so far covered +by the simulator tests only. ## Requirements - Windows 10 or 11 with a DirectX 12 capable graphics adapter. -- The computer and the speakers on the same layer 2 network segment (same switch or VLAN). - The speakers are reached over IPv6 link-local addresses, which are not routed. +- The computer and the speakers on the same layer 2 network segment (same switch or VLAN). The + speakers are reached over IPv6 link-local addresses, which are not routed. - IPv6 enabled on the network adapter in use. -- To scan, the firewall must allow mDNS (UDP 5353) for the app. Adding a speaker by address - needs only the speaker's TCP port 45. +- To scan, the firewall must allow mDNS (UDP 5353) for the app. Adding a speaker by address needs + only the speaker's TCP port 45. + +## Installation + +The app needs no installer. A release folder holds: + +| File | Content | +|------|---------| +| `neumann-control.exe` | The app | +| `LICENSE` | The app's licence (MIT) | +| `THIRD-PARTY-LICENSES.html` | The licences of the libraries and fonts built into it | +| `SHA256SUMS` | Checksums of the files above | + +Put the folder anywhere you can write to, for example in your documents, and start +`neumann-control.exe`. The app keeps its settings and log in that folder (see +[Files and privacy](#files-and-privacy)). To update, replace the executable; to remove the app, +delete the folder. + +## Getting started + +1. Start `neumann-control.exe` and press **Suchen** (scan). The scan takes 4 seconds. The dialog + "Gefundene Lautsprecher" (speakers found) lists every speaker found with its name, model and + masked address. Check the ones you want and press **Hinzufügen** (add); each gets a strip. + Speakers that already have one are marked "bereits hinzugefügt" (already added). + **Abbrechen** (cancel) adds nothing. +2. If the scan finds nothing (for example because mDNS is blocked), press **+ Adresse** and enter + the speaker's IPv6 address. A link-local address (`fe80::...`) needs the index of the network + adapter after a `%`, for example `fe80::1%7`. Find the index in PowerShell: + + ```powershell + Get-NetAdapter | Select-Object Name, ifIndex + ``` + + or with `netsh interface ipv6 show interfaces` (column `Idx`). + +Added speakers are remembered and reconnected at every start without a scan. When a later scan +finds a remembered speaker at a different address (link-local addresses and interface indexes can +change), the stored address is updated. IPv4 addresses are not accepted: the speakers are +reachable only over IPv6. + +## Using the app + +### The desk + +Each speaker has a channel strip: + +- its name (click it to open the device panel) and a grip to drag the strip elsewhere, +- four LEDs: connection, clip, device warning, and LOCAL (the rear CONTROL switch locks writing), +- knobs for **Verzögerung** (delay, shown in ms) and **Standby-Zeit** (standby time), +- the output level fader (0–120 dB SPL in 0.5 dB steps) with its value below, +- input meters for channels A and B with clip lamps; the KH 750 also shows its output meters A, B + and Sub, captioned "Ausg." (output), +- the response time, **Stumm** (mute) and **Ident.** (identify). -## Usage +On the KH 120 II the input meters are captioned with the rear input switch (Analog, AES3 L, +AES3 R, Mono), and a dot marks the channels the speaker plays. That channel A is the left AES3 +channel is assumed, not measured. -1. Start `neumann-control.exe` and press "Suchen". After the scan the dialog "Gefundene - Lautsprecher" lists every speaker found with its name, model and masked address. Check the - ones you want and press "Hinzufügen"; each gets a strip. Speakers that already have a strip are - marked "bereits hinzugefügt" and cannot be checked again. "Abbrechen" adds nothing. -2. If the scan finds nothing (for example mDNS is blocked), press "+ Adresse" and enter the - speaker's IPv6 address. A link-local address (`fe80::...`) needs the index of the network - adapter after a `%`, for example `fe80::1%7`. -3. Find the index in PowerShell with `Get-NetAdapter | Select-Object Name, ifIndex`, or with - `netsh interface ipv6 show interfaces` (column `Idx`). +The toolbar shows the number of speakers and the average response time of the connected ones. -Added speakers, whether chosen from a scan or entered by address, are remembered and reconnected -at every start without a scan. When a later scan finds a remembered speaker at a different -address (link-local addresses and interface indexes can change), the stored address is updated. +### Faders and knobs -To remove a speaker, open its device panel, press "Entfernen" and confirm with "Entfernen". It -also leaves its group and its place in the order. -The strip disappears and the speaker is no longer remembered. +- **Fader**: grab it anywhere and drag; it moves with the pointer and never jumps to it. Shift + makes it ten times finer, and the mouse wheel moves 0.5 dB per notch. A double-click on the value + below it lets you type one. While you drag, the value stands beside the fader. +- **Knobs**: turn them by moving the pointer around the knob (clockwise is more; one and a half + turns cover the whole range), with Shift for fine steps, or with the mouse wheel. A double-click + lets you type a value (the delay in ms). While a knob moves, its value stands beside it. +- Values are written once: when you let go (only if the value changed), when the wheel stops, or + when you press Enter. Escape cancels a drag or a typed value. -IPv4 addresses are not accepted: the speakers are reachable only over IPv6. +### Device panel -## Files +Click a speaker's name. The panel lists every setting the catalog knows for that model (output, +input, standby, device, rear panel) in two columns. Settings the model may write get a control; +the rest are shown greyed. The panel also shows model, firmware and the masked serial number. +Escape, **Schließen** (close) or a click outside closes it. + +**Alle Werte...** (all values) lists every value the speaker reports in its own schema, with a path +filter and copy buttons, including paths the catalog does not know yet. + +To remove a speaker, press **Entfernen** (remove) in its panel and confirm. The speaker is no +longer remembered, and it also leaves its group and its place in the order. + +### Groups and master + +- **+ Gruppe** creates a group; a speaker joins one in its device panel (**Gruppe**). Members are + framed in the group's colour and followed by a group strip. +- The group fader moves every member by the same amount, each within its own range. With the + loudest member at its maximum, the others still follow. **Stumm** on the group strip mutes them + all. +- The buttons under **Gekoppelt** (linked) link level, mute, auto standby or input interface: a + change on one member goes to the others whose model may write it. Members that are not + connected are left out. **Für Gruppe übernehmen** (apply to group) in the panel copies a linked + value to the other members. +- Click the group's name to rename or delete it. +- The master strip at the end moves and mutes all connected speakers the same way. + +Drag a strip by the dots before its name to reorder: the other strips of its group (or the +ungrouped ones) make room where it would land, and the order is remembered when you let go. Escape +cancels. + +### Compact mode + +**Kompakt** in the toolbar (or Ctrl+K) switches to a compact window and back. It has one row per +ungrouped speaker and per group, each with LEDs (a group: its colour), the name, a lying level +fader with its value, the loudest input as a small meter and **Stumm**, and a master row at the +bottom. A group row moves its members like the group strip. A **!** stands for a speaker's message +(on a group or the master row, a member's), which shows on hover. + +The compact window keeps its own size and position and scrolls sideways when it is narrower than +its rows. Scanning and adding speakers or groups stay on the desk. + +### Settings + +**Einstellungen** (settings) sets: + +- the theme: like Windows, light or dark, +- the language: Deutsch or English. It changes at once. German shows a decimal comma; typed values + take a comma or a point. +- the lowest level the meters show: −120, −90 or −60 dB, +- whether response times are shown. + +### Control mode + +The app shows the control mode (NETWORK or LOCAL), which the CONTROL switch on the back of the +speaker sets; it cannot be changed over the network. In LOCAL the speaker refuses every write, and +the strip says so. + +A speaker that stops answering is shown as unreachable and reconnected every 10 seconds. + +## How writes stay safe + +A speaker setting changes only when you mean it to: + +- A value is written at the end of a gesture, never while you drag. +- A click on a fader without moving it writes nothing. +- A typed value outside the allowed range is refused with the reason; it is never cut to the + range's end. +- The mouse wheel belongs to the control under the pointer where you started turning it. Turning + it over a gap scrolls the panel; a knob that scrolls under the pointer is not changed. +- A wheel step on a control that has meanwhile disappeared (panel closed, mode switched) is + dropped. +- A value the speaker reports outside its range is shown as it is and never written back. +- Models whose behaviour is unknown are read-only (see [Supported models](#supported-models)). + +## Supported models + +| Model | Read | Write | +|-------|------|-------| +| KH 120 II | yes | yes | +| KH 750 | yes | yes, except standby (see [Known limits](#known-limits)) | +| Other models in the catalog (for example KH 80, KH 150) | yes | no | + +The catalog is [`crates/core/src/paths.toml`](crates/core/src/paths.toml). A product that is not +listed there for a path is read-only, so models whose behaviour is unknown never receive a write. +Settings that only exist on some models (subwoofer outputs, logo brightness) appear only for those +models. + +## Files and privacy The app is portable and writes only next to the executable: | File | Content | |------|---------| -| `settings.json` | Remembered speakers (address, port, serial), groups and the strip order, the window position and size, and the choices from "Einstellungen" | +| `settings.json` | Remembered speakers (address, port, serial), groups and the strip order, the window position and size for the desk and the compact mode, and the choices from "Einstellungen" | | `neumann-control.log` | Log of the current run, replaced at each start | -Speaker addresses and serial numbers are masked in the log and in the interface. An unreadable -or malformed `settings.json` is renamed to `settings.json.bad` and replaced by defaults. +Speaker addresses and serial numbers are masked in the log and in the interface. An unreadable or +malformed `settings.json` is renamed to `settings.json.bad` and replaced by defaults. If only some +values in it are not understood (for example after going back to an older version), those values +fall back to their defaults, the rest is kept, and a copy of the file is kept as +`settings.json.bad`. ## Known limits @@ -131,26 +236,33 @@ or malformed `settings.json` is renamed to `settings.json.bad` and replaced by d every speaker; use the app only on a network you trust. - A speaker in standby does not answer on the network, so it looks the same as a speaker that is switched off ("nicht erreichbar (aus oder Standby)"). -- A speaker accepts only a few TCP connections at a time (about four). With more clients - connected (vendor app, home automation), it can look unreachable as well. -- On the KH 750, auto-standby, standby time and standby level are shown but not written; writing +- A speaker accepts only a few TCP connections at a time (about four). With more clients connected + (vendor app, home automation), it can look unreachable as well. +- On the KH 750, auto standby, standby time and standby level are shown but not written; writing them is untested on that model. - Models other than the KH 120 II and the KH 750 are read-only. - Only IPv6 link-local addressing is supported; IPv4 is not. - Windows only. -## Building +## Building from source + +Needs Rust 1.95 or newer and the Windows SDK (its resource compiler embeds the app icon). -Needs Rust 1.95 or newer. +```powershell +cargo build --release --locked -p neumann-control-app +``` - cargo build --release --locked -p neumann-control-app +The executable is `target\release\neumann-control.exe`. -`check.ps1` runs the quality gates (format, licence check, `cargo audit`, clippy, tests); it -needs `cargo-audit` and `cargo-about`. `build-release.ps1` runs them on a clean working tree and -writes `release\\` with the executable, `LICENSE`, `THIRD-PARTY-LICENSES.html` and +`check.ps1` runs the quality gates: format, licence check, `cargo audit`, clippy and the tests. +It needs `cargo-audit` and `cargo-about`. `build-release.ps1` runs them on a clean working tree +and writes `release\\` with the executable, `LICENSE`, `THIRD-PARTY-LICENSES.html` and `SHA256SUMS`. +The workspace has two crates: `crates/core` (the SSC client, discovery, the path catalog, +settings and a speaker simulator for the tests) and `crates/app` (the window). + ## Licence -MIT, see `LICENSE`. The licences of the bundled dependencies are in +MIT, see [`LICENSE`](LICENSE). The licences of the bundled dependencies are in `THIRD-PARTY-LICENSES.html`; the Inter font is under the SIL Open Font Licence. From 6e2c39a8b7caefc4b44f8a3d694262634bde4537 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:13:20 +0200 Subject: [PATCH 109/460] Drop promotional wording from the README --- README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 25f5017..c6b43a2 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,7 @@ A portable Windows app to find, read and control Neumann KH DSP speakers over the network, using the Sennheiser Sound Control protocol (SSC). It shows your speakers side by side like channel -strips on a desk, with level faders, meters, groups and a master, and it is careful never to -write a value you did not choose. +strips on a desk, with level faders, meters, groups and a master. > This is an independent project. It is not affiliated with or endorsed by Neumann or > Sennheiser. @@ -189,7 +188,7 @@ A speaker that stops answering is shown as unreachable and reconnected every 10 ## How writes stay safe -A speaker setting changes only when you mean it to: +When the app writes to a speaker: - A value is written at the end of a gesture, never while you drag. - A click on a fader without moving it writes nothing. From c7fa0130567eb3ab3428f5b1ef736af67fdfdc78 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:13:57 +0200 Subject: [PATCH 110/460] Use the English interface names in the README --- README.md | 47 +++++++++++++++++++++++------------------------ 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index c6b43a2..f9e814b 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,8 @@ strips on a desk, with level faders, meters, groups and a master. - **German and English** interface, light and dark theme. - **Portable**: one executable, settings and log next to it, no installer. -The interface is in German or English. This README uses the German names of buttons and dialogs, -with their meaning in brackets where it is not obvious. +The interface starts in German; switch it to English in **Settings** → **Language** (in German: +**Einstellungen** → **Sprache**). This README uses the English names. ## Status @@ -75,12 +75,11 @@ delete the folder. ## Getting started -1. Start `neumann-control.exe` and press **Suchen** (scan). The scan takes 4 seconds. The dialog - "Gefundene Lautsprecher" (speakers found) lists every speaker found with its name, model and - masked address. Check the ones you want and press **Hinzufügen** (add); each gets a strip. - Speakers that already have one are marked "bereits hinzugefügt" (already added). - **Abbrechen** (cancel) adds nothing. -2. If the scan finds nothing (for example because mDNS is blocked), press **+ Adresse** and enter +1. Start `neumann-control.exe` and press **Scan**. The scan takes 4 seconds. The dialog + "Speakers found" lists every speaker found with its name, model and masked address. Check the + ones you want and press **Add**; each gets a strip. Speakers that already have one are marked + "already added". **Cancel** adds nothing. +2. If the scan finds nothing (for example because mDNS is blocked), press **+ Address** and enter the speaker's IPv6 address. A link-local address (`fe80::...`) needs the index of the network adapter after a `%`, for example `fe80::1%7`. Find the index in PowerShell: @@ -103,11 +102,11 @@ Each speaker has a channel strip: - its name (click it to open the device panel) and a grip to drag the strip elsewhere, - four LEDs: connection, clip, device warning, and LOCAL (the rear CONTROL switch locks writing), -- knobs for **Verzögerung** (delay, shown in ms) and **Standby-Zeit** (standby time), +- knobs for **Delay** (shown in ms) and **Standby time**, - the output level fader (0–120 dB SPL in 0.5 dB steps) with its value below, - input meters for channels A and B with clip lamps; the KH 750 also shows its output meters A, B - and Sub, captioned "Ausg." (output), -- the response time, **Stumm** (mute) and **Ident.** (identify). + and Sub, captioned "Out", +- the response time, **Mute** and **Ident.** (identify). On the KH 120 II the input meters are captioned with the rear input switch (Analog, AES3 L, AES3 R, Mono), and a dot marks the channels the speaker plays. That channel A is the left AES3 @@ -131,24 +130,24 @@ The toolbar shows the number of speakers and the average response time of the co Click a speaker's name. The panel lists every setting the catalog knows for that model (output, input, standby, device, rear panel) in two columns. Settings the model may write get a control; the rest are shown greyed. The panel also shows model, firmware and the masked serial number. -Escape, **Schließen** (close) or a click outside closes it. +Escape, **Close** or a click outside closes it. -**Alle Werte...** (all values) lists every value the speaker reports in its own schema, with a path +**All values…** lists every value the speaker reports in its own schema, with a path filter and copy buttons, including paths the catalog does not know yet. -To remove a speaker, press **Entfernen** (remove) in its panel and confirm. The speaker is no +To remove a speaker, press **Remove** in its panel and confirm. The speaker is no longer remembered, and it also leaves its group and its place in the order. ### Groups and master -- **+ Gruppe** creates a group; a speaker joins one in its device panel (**Gruppe**). Members are +- **+ Group** creates a group; a speaker joins one in its device panel (**Group**). Members are framed in the group's colour and followed by a group strip. - The group fader moves every member by the same amount, each within its own range. With the - loudest member at its maximum, the others still follow. **Stumm** on the group strip mutes them + loudest member at its maximum, the others still follow. **Mute** on the group strip mutes them all. -- The buttons under **Gekoppelt** (linked) link level, mute, auto standby or input interface: a +- The buttons under **Linked** link level, mute, auto standby or input interface: a change on one member goes to the others whose model may write it. Members that are not - connected are left out. **Für Gruppe übernehmen** (apply to group) in the panel copies a linked + connected are left out. **Apply to group** in the panel copies a linked value to the other members. - Click the group's name to rename or delete it. - The master strip at the end moves and mutes all connected speakers the same way. @@ -159,9 +158,9 @@ cancels. ### Compact mode -**Kompakt** in the toolbar (or Ctrl+K) switches to a compact window and back. It has one row per +**Compact** in the toolbar (or Ctrl+K) switches to a compact window and back. It has one row per ungrouped speaker and per group, each with LEDs (a group: its colour), the name, a lying level -fader with its value, the loudest input as a small meter and **Stumm**, and a master row at the +fader with its value, the loudest input as a small meter and **Mute**, and a master row at the bottom. A group row moves its members like the group strip. A **!** stands for a speaker's message (on a group or the master row, a member's), which shows on hover. @@ -170,10 +169,10 @@ its rows. Scanning and adding speakers or groups stay on the desk. ### Settings -**Einstellungen** (settings) sets: +**Settings** sets: - the theme: like Windows, light or dark, -- the language: Deutsch or English. It changes at once. German shows a decimal comma; typed values +- the language: German or English. It changes at once. German shows a decimal comma; typed values take a comma or a point. - the lowest level the meters show: −120, −90 or −60 dB, - whether response times are shown. @@ -220,7 +219,7 @@ The app is portable and writes only next to the executable: | File | Content | |------|---------| -| `settings.json` | Remembered speakers (address, port, serial), groups and the strip order, the window position and size for the desk and the compact mode, and the choices from "Einstellungen" | +| `settings.json` | Remembered speakers (address, port, serial), groups and the strip order, the window position and size for the desk and the compact mode, and the choices from **Settings** | | `neumann-control.log` | Log of the current run, replaced at each start | Speaker addresses and serial numbers are masked in the log and in the interface. An unreadable or @@ -234,7 +233,7 @@ fall back to their defaults, the rest is kept, and a copy of the file is kept as - SSC has no authentication. Anyone on the network segment can read and change the settings of every speaker; use the app only on a network you trust. - A speaker in standby does not answer on the network, so it looks the same as a speaker that is - switched off ("nicht erreichbar (aus oder Standby)"). + switched off ("unreachable (off or standby)"). - A speaker accepts only a few TCP connections at a time (about four). With more clients connected (vendor app, home automation), it can look unreachable as well. - On the KH 750, auto standby, standby time and standby level are shown but not written; writing From b79656560ff79dd7bbdcd6353eb7ebde51efe74e Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:14:54 +0200 Subject: [PATCH 111/460] Mark the app as alpha in the README --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f9e814b..867feab 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,9 @@ A portable Windows app to find, read and control Neumann KH DSP speakers over th the Sennheiser Sound Control protocol (SSC). It shows your speakers side by side like channel strips on a desk, with level faders, meters, groups and a master. +> **Alpha.** This is an early version: expect rough edges and changes between versions, and keep +> an eye on your speakers' settings while you use it. +> > This is an independent project. It is not affiliated with or endorsed by Neumann or > Sennheiser. @@ -42,7 +45,7 @@ The interface starts in German; switch it to English in **Settings** → **Langu ## Status -Version 0.1.0. The protocol layer, the polling and the interface are covered by automated tests +Version 0.1.0, alpha. The protocol layer, the polling and the interface are covered by automated tests against a built-in speaker simulator for the KH 120 II and the KH 750. On real KH 120 II and KH 750 speakers the app was checked for scanning, reading, metering and writing level, mute, identify and standby time. Groups, the compact mode and the English interface are so far covered From 10a385b1382b956bf282ef0385c1684243f6b994 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:30:14 +0200 Subject: [PATCH 112/460] Show the desk in the README A screenshot of the desk with a group strip and an open device panel; the serial digits in it are masked. --- README.md | 5 +++++ docs/images/desk.png | Bin 0 -> 484655 bytes 2 files changed, 5 insertions(+) create mode 100644 docs/images/desk.png diff --git a/README.md b/README.md index 867feab..dccd901 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,11 @@ strips on a desk, with level faders, meters, groups and a master. > This is an independent project. It is not affiliated with or endorsed by Neumann or > Sennheiser. +![The desk: four speakers, a group strip, and the device panel of a KH 750](docs/images/desk.png) + +*The desk with four speakers (two KH 120 II, two KH 750), the group strip "Front", and the device +panel of one KH 750 open. The interface is shown in German.* + ## Contents - [Highlights](#highlights) diff --git a/docs/images/desk.png b/docs/images/desk.png new file mode 100644 index 0000000000000000000000000000000000000000..d3e47dd92d12862ff7d0494c4f05e462874c6af5 GIT binary patch literal 484655 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�AWZ!7jrP;)4&8Fs@g->~ zDf`9JnV08u5+F;nH5C&QxT5`d?^zU93$y+;>i^%7pbtp2#a2Yqz#O)?i_=?G|m$QXiFGHvmu(3h+h{n^)H(!na@O_jCXf_~;zy~NO5cKXoIT=VJk|}rm=i|~+JPC{X05C-_2j*ey@RwsC z2*@*9N>SSJ@bYeLZ5e($Bq~~5Uhd)QN^5I-uRHnn?Hy0XPoF+P8n)yFbavp~JNu_5 zcy=0wgY%y956%ltciBKveYgW!}CmAuIbFMoUX7 zkh4OfqN}TmYeMM9qM{8aB6%oy9BsK^As`e1<&4b$&>FeWSMTbP#X1QEVuDS`WFSSq z{L$C>^yxc%I!NIg{5%!+g%-XQ_-nepn&fnmvJ*|6V8@VIg@KGBO~j;cNE+;%%tn(F58{q4-9K z^F}h|5%viIgF@-Q~qkk(-x)GjkKGZxz6 zj5iB(t`}LuCQQPn6l!CwSN$#o>iLFphdfF57t`ysd|jL*Zt9SD#-y<7x8dacPXYSe z`mYty8W5~Rb%Nt)NjZGds;pN>CG8AaWW>eA4;*NjaD~SCOD(8${Y)j( zQ>T755Yr&wN>JXxCg$K6efLgw=+NV{3!vm^i#y)DdH6vT96BB?s-GPx6^rKCPF}Q^reTfccUVL0C=*aF3i_$DJ>@hw0J%iO(V;Ld0>Omp7mTg6`=j8ZfD;sGzSx zng*Ei%jzb?39nY$%P6~7pbh|Yj#Y?!7yI)HNP1sid47IUPcQeY(Ym1KSLXU4-BwD7RH#VbS9LJZvxD#m|(3D&NHh4PY;KcKfiXjvnV=OQKs z!8)mQ9wPw7G_%GE7`&(F3g}o z;|rC*&d(ANxf$L8pdbFS{SR~t$alB8LB<7eLN{4dNLV<5ui)|HMSN*6PsnprNr^I= zRgKdq{`X7iCx5V%ZES47A49cvN-cp8T0nV@*c~6*2gG#QP)cgZ4-zRi%n=h2-98wf zl(frXFB!&cJ6yh;Y*$rS*a1BaXet&Kb-^{5<~$nQO+N<$6(lti+KpLQ5A5@Ks> z+ts0IAQx`tG;{pYrsUdJ?=6HqWK%nh5oTszysPscKHTwXI)6S+>o(txrC`z7$7&f3 z&`iOB@LvbVDFcIX>gwX<`i zkFRyTm)ER&x@*+&l>U_~r4aa=Jx0Ff%Dc3eqjle5@2<-)#LX)uXv%gtl~O3mQsPI_HCpe1EUI)mmnuN=86H{Rr{VFB&OW#4SO} zurV=B|L}#e1YAlII{$tQ3nTSB=Hw*&+zHhOPP*QoYSA_I^<(zs2ciG^M^vOMdXeig zJ^ll43xiXqCV%)MW4WyKY@eT0jsEPfDD|eMs$m6i7$EUS_PQ%id4M^?xxmK8hQlW0 zHgw5&2!}bSH`7j90W*==ICc1?!^2^(+B}|9Q7l=k{xsy|FCXc}>_nT(8Vwozw$5Hd z$Za;I_$3u05(w=~SLW47oIkxhac=XT@YaVy$^_k$BxmFIpoxjT@r|Zgx)*8o-tekV z^}*z4zl;U5AIg(9^pWJ_Bk5XrF&1!-S-Ym$OU!RJ>uZR|5&I3p#_z3ayTv*T>`j>s zX$6x5=5^0ULdWx^AiwPvg@xs-dnv57aTMX*sVTS5|M_;VbRl2yM1R)-66B+$%YP08fg+zy_&S{(@_Ny*Ne4s7uVXy9fhCk7p?M~CZTLb zh6(2Yn3nh;RIG@6QCFt@YwDx>{ck@_PTfQm|N5DPqAio|YYNJ!lC8Q-_CjhMThen>V~Ub24j;|t576BS=tbS_gL@Ql4+&sffQxBk!b?&~L# zh%Z(6xrJRM$uZZ^O?+$W85EzOkQtZSN_rq7UB|{m>wfw8h4h7);QPZ1hY8J?w=G{E zF4TJ=QL;b4-em2>so{m9YX%o**wfQbzN_9C|Gld-XX=#YwI4mN`dPnc*_Z~D#8kgM z?B#RW#m{|mA?C-{d!u1!UhB6CTdz+r z7s<@r&v-h+IV=0X`1v2cl+7InbjYcx>tJYYl6)QJiptg3tZdPX@uXOdl}F%bBk`_w z>e()70kc*rRgVWlZP6~tHP(}#ufHf^sA;S#d`YL=(84}*_I%=*J#s7s_Nw{q(-J2E zieJry2q%S$vsXAVyPauCSn; z2j89gVWnHVYXnW+PFDAodg_06h!|+p`)|L1^$X+X?6W}5Djv#TaU?0FuiL##oXbQJ zU5(Am{9vsT6hzoNP0zppg%?1Z6(=zlDZfLDw10%+yG_4ecs3Dy`x~M~6}RpUf2p}O zhGT97i|u8QKU6@Ab4TDLQpPQRWpuZsrnX5?Y8|eK)8nt~5?Y#9H^@3M_{F`>{gBwT z5)zr#7HoCkAr%$(DW_J;*cyq^)44<%Pa$>d;?w*R*S8XF|B4whq&k-q zRn{w9+VoW}!fpjamRMPRSLO^K7qeB-N6Ti;;yF8JFrmHj>uY%_E^Ej?vfJL-iRp2) z-p{7fmetT)?PWhJ-Cf7EMSiEfd8ap`TK_E(%mN$|q=rg^&apEyKZh(8W!9}GWS4ZT ztbn7?-6f_JaNg*&X1T~|Ps4`s=LiVJkw@*{=9nBl)To(pYu0&i8b)jwzR32W*C~Nku6JB^qEfc z@cmlI`1D4wm}@=8uQqFsRVu-eD>f#LPc-OA8XX)zel8cX+=fsYniVuF;FBxi4uuhf zjc%e7S1pOTl*{W>bQU30L$L*#6%u7<=iVemt9yF3&Mdhp>2N{S3Pw*N_?{xj&Si8kw$cVfIpAyl1Ivy9Zip9ye zp|X^Bn+$o!Wz5@qFw*AQv1`|kRm&Qb#;Y57c~#u~AQU<2rSVwvs(Ss{l}WGkc40R{ zj=fy;$+T&jXNW_S*c$!yx_?A9E=0T6b~-Fc#WTh-m?U2`iKW&5!JuWt+SHmb+86Cl zp8jhc{9qjC-<30*GSxhXN&r+Gd@R(K6dS1a&2&fX@m1^Ykmo@7a7==6iqOO^2TKZJ z=ZC{AY$Smp1v4Z&3qEj*Dr>1|_?YaffCyFL@U&d%JND0;i9hS@Pw*EvE_H0E4HxUB zikM9>+;~|c>5~?3%G^%DVQ6v|%~sOn)&|Cg(wF$1F70@X{A(-!&mTy=;qDO5{J9s= zjZD{8i;j)QgPfaQKqy4wc2Yc3SjMvMXSMN6Kf*Lxsi@6vsVqe?iPrB4MRCs=a+X3Q z=$k}1ctt{bX`qWv*aZPTPl1>i-w$hn>W@zOu!1+Q~KJ zZm~WGmLs>EJ-xc?92itR_!~Oxw3KP&&VMc~Zp@sz&JfQ#sUVX+Vb>_)p|bGv>XUTK zG}^{Y5yg&<*?3Rph@r0C^!T%Q*Ja@bgmI?`6%l7p9~>N7 z3y!jqD4oygu+{PTHah>q*Vgp8i71pbELjxA zf4_38sV$Ao?&`SC+`gsMQ!WY?L_dGPiRk>`*;31ltgP?9wg7!U3!C@-RDSU!AgZ3_ zFAp!UhE2&EGR}*>f9X{;_I*c0m)%}6Fb~f0&kbHehSaJM+6X~>(gqLUB-UiP& zGy!gyHb=5gkU z7MPGbia1_Y{m*K1Zz6!-4gSAr@-Ec|>r)AQ<2Fh?Q|}ABqz%th*t4PnSNSOV-WbqY zx#-~|M^22j-e*52mM%xz)X7_!hH#6HC#9@r>Nc>&{tNr^um zfxHPO@i3120x(2QRQCQ88OsL}A^vxDd#Mp{>xUyI?d61g6^u0Nb>O# z#qkG%xEiLWsloSD-gt71W-|uzX%Qu-ysH&(yS_LfX zy(uOcCnqLCN!>cTeE%KiOLb>|cZ;WDV5}OA=j2y%Tc=)W1=p3bZMf1>92UFN&PPVH z*8lI7^a!w0lA0#d^@B)G96?Nxz=w`$98lNojEp02Eh--tyx8g>Lmr$h7-?CWUo{Kpp>-Kv zkr6=Qc_fZSwb;nsBV6w&i{8HVZv3+o(Mg#@g!#&kO*S;UzyEsiZ08f0&#RMQS&E_% zHhj>r?PX;}BfHIlgg>1QaROg=SJ!X2**8_OOXdUBDw9=bL`BroOu3^Q%g9BZYS`N*T7;5xYx>8yw*;;#lmN$EacVs>dB`5bq^yy`saFAf7Z6&&w z^b-xEk2fscGcNWUWLe3uLnxS_8ZV00o8;G?eOl-};Ucv1Ao3|3S|~ltQh368C%2HM za{WtIZX$}p4!kIwEf&{MlL0RVG-aG<1ssd2QT+WVn21}6uN?*{Z)If7cb@l%yBoKpiXa!kWe!zhB5 zo{kPF5Lx3x7+EYVFlf@n!zEq`QkucR#I*Rd)Wo(j7wF9RbO5`6;5c9GUg)7KjaS4d zer!~zJ^9l&YTnkQnFlUoLsJ3)L3Rgt+?qRuiHe?ROlaB|u{H_@B)2K@F-tV(>Fp{H zwoH?x;3bvR7^fuXuHjj3?&%=PZ&NfBm-Lg%Wnrr=HrI_1B`@kVC+IGD_EJXwCh5&r zwo(W9_)@#=!>nngK~Y|EbcDX8k2>aaaEL5H@Rtf@5RbgP7ocbaO7Z>Ih5G^_NEN~A zK|ch%>(i%iuJTKo$#(FVL=_FFbou8`BuuyWqYoLfcJzo1>a5(2UkEAaE-P-g-CQlb z(d{<+cjM^a)`itSBlX+o(9v-PonLT+=p0g4NuN1Q9oXaYAt3K}XDDa(uP}k@aCL{H z_g~Cagnge6h(l=Vde9lO(+d_iLOoPT+bu8`zqjkxC+*{^DFcU`vQ)@6`r-mU|N1w* zuDPXUd#nEMwe#o4D&BbxsqN-2+ihgf|?b7TwwF{zfN;zB|Nj~)^97$;AoZ4*Cc%thA6+Z$F9&UZpI zmFrWL4pgvFl9H8ThOQJJ-)EplviDBp4{%>-u#y0K!7VBp9eE7^cO6vYzDM9)ajzRj zpvegdz?4+T0LbG5?;Rj2E00QtnMPTILdpPPfu`&A4eN8*QTYQA_3?Qj`$qib?CHO! z8CzgW{kpYvXR?cZIrl-*rw>3KfNfS!?f?KuLjy(AzKNHR%_9v*=SAXRB5YxF63|Zv zee*zSQ4aa_^qU-1$4U;B`5RUgKd3 zvp|VuqWwg;Xu@{uVecWD!#cN5zGIkCcW3745sJ)dV|jPGm#AcDyDYTg4}{@hP#T{d z6cH&ZDY1O+k~XrqbN2D4DRvGOq*(nwy1qM{>wf>=s?4%ic0`dC*)xjBPDn-}BeD}& zWo3ng>`_^XjFOU&D0?MEc2+`CO87lK_xb+&>$=nqD_WtrL0AJJ%n9bDwCewYg0;18HQIog5C3Iw>FkF`Ml8)! z09W9&vD+i{;$g~5f=i#plMCV9v!qU(`!CjlNc0{GV5^iFXh#9OX&M5*{JXgc_6}ez zZ1u>O4bs$3z(o2@i^|F~j*~46uR}4E5Yj2~6yZECCWRVaHk zC7=adh{4hqwhYsDofnDSArh zdu^gE3=@F>m>C%$Cpjn?eWaN8u#|MGM^6~0lTNMj9r$~K(hA!`l6r`k(w>8E^Pbny z2tPu~_&0{RPjz2@XP*7-69x>Nj!mU?+P!qOj6tcsWNsPJ5s9~xI{q%4ZLlyhBGF^A zfLTIKrt6lmWL62MTvA&m_|ge~^F{s95=KCq}g z>CB{QC?@4CdeKdi+Sts@5A@`7Wu__iAUI%~{d?*2?5dwoS$6huCpKRB<&|>_9y)?l zCT3*JCMQ`b<_@X1!AtoFdDGQbYn+(ij%C1J>>x>}Tj7kbw|rLn%#F*O>^V>A zAF{f>Ht)~LUs;d?vcz|;_FDD6?7er7ih5m*+&m;Ke6GfsY0el7JID#Z^V#iVK4)sl z+{OKhy)1IJ&On3scEr~fn*O7*Wr6vbGCmcd4t9OE5$BoHISa@S7=Nw@v3GZ^^@~R` z>RP8xokFccE{2y^RsVeKty|{Rj)}g%US-kRbbnNCt${a zECdh~ScnaGMAit}5wK+m8hK!`aCobPx|J8Jsw+rqaukMNeJ6nd%UUEwY={z$IUEAefQzi3dyZClx27cMb%w)t4` zSftEAIBF)6g0PS8kKNQ5an4rPY+q#3v4&+g)=;NUcS0)03t9$hW=xBbq2YwLd}(QE zYilb?K8vWOnW^cA(NP)_1)D?^UlJ}@R?*&V_?d7mG&w9G=QG5 zdkxE_lo{}P2$B|%)8ToW!!v(^D$a=Ld>O?xUY>YY{t%yk6k6QMw>jy=MTmMBJ6e*U zp{Xm8`&C-?om)n~UCdZ2r78I5!POmt5iVGT028^I#R>f18)-K>fV9f3SLEKs^0s_+1b@-%qPu@9`9WQi&kGhrtnj=_+SlB`wK!t zbbf0(vx0^ED(DNKKzcYzx2f-j3aI+Vl<&v}7ji=ai(+BNMMv`X#)YjGl zBNZ1G)?w5Hi9;M>r;gNDKOX=-LZH?*+-r`qGc@0dxFqBlL*dEmMfISq?V6m(n_V79 ziz-82Q!L4{6RC#W)ALHK^74Rgb!uEa zIJFEeq>q0YFdp~3PVy-wMK90rgdt-uNv*cLvREviK~BhrhZobH5?L_OgzM$qzD?D4 z!&Zutuf4L-CL~P6)yR&1Dri8|`-8k>X`Kisg(EkIQ}1SK`4@PD`9ag>vfjk z@B+;5@9P5#Wc$~%hV@^+Cim_vjcp&1mR=|1`eIucv4^Jkf>BXmZrx*9rK4T`LZ1#+ z)6wpE@cwOWFuSmkg0_gR#i}d1bfVubCOOIUR)Cs$+kc?L0wO3IkMJTwXLI{@;eP+l zHjc}CGs)au{c*tF@W-_Fqhvo7@C5L#L+*kBEUNi-Z0z-|MQeQ>9hhCKe{KS&>*vpE zk#-0r@JDvkiCy{Z#g4`Gzd)1e)xThob@?T#H0b!42Ifr7T#9tK`=CF6h$;>}*d=&< zm|7zW4bZES_ng7`^PGFB2_5&=!dV=4s%!TPPXapkmiI{F=H?#ilP&UyZ4%xdaEuw$ zD#^HY?&+~#x<}magz=xRXjxwWL($C8)JC0Xo?n5b#yuc&x0()98v6Bz>5sf>c%tYP zI(9&@;f2!)3Yq;PE%#PsI`_>lt-Y!&^#3#?i-M{JBY6 zC={!K@m;RT>CLbG5l^VnU?JoMN;Du)2ng(l;rkNF<*!vb?R$?b96lK6;9iVcoj-q? zocJ#fxw@@wUdx3)2NW_vwivfaj?gs}U5{dufHv(?7ns{F1b zRgP?*iwEyejH*#oDR#p)$9z|qL$Bgsg>>9Z$Emxy&jRo1a{eslA&0P%kR%pjNUzR? zNdu36D_6j`fU(W&&r8|6Wcw0>4Lh=o0#}KpMP!9Ti71%e^BBFGFFkHl;?;a&o}vUD zFfzJgoNj!i2+^}yjn4n}`Pn{F-IN(D(cd@bpQMhxwD9NBX`veZjkhwd=olDqVDG@8 z8VHGSG@Of&28DvK3UmEMmF+1NIBPg*AGSQnc_6QC`%&I5ceZD0E_DfOOc{>IiW`); z9`a+(qfJxF!!*=!d^DL?Pe-Ph`%Y7VV<=34s%vU&wWw+K)x-2 z#xJs{wqAY`>^b>bVZna#z;8K(H(1m--))}QK)}F)=X0}^8Z!|@8<;^8#GtRZDz@`O ztX5K;s@7KiZO`&RdEfrGviJWGODXE+gp2YGEcG)Uf1}a+qsr55j#BolI)RGukof!1 z8{(v|Q;>UtGCeEkA}lth1xxVfx!3$nXl;ooDv}uf;kL+w?!Em z5Hs=Dag-DkdAD*FD&*|Piu5&druMGUpXwjFl4kP@#Zqwfob0e@rdQX<5z`VY zY}vJ+{{lDL&uapF?-+c8eWR5R#_pg0-L5Fc;OWNQ9z~zXc<+w+J@LwSy8kHYWbf$w z@Y8%NnBdefTuw!ukfkk=C&C*UpTnN^HIV-!Kh9g8)Ala9smqStKFG4Gyv}<+wGp2(9nBpQ3D_JIAxj!;;!bU})U1ZtZGx zlyZRCb40`sMGQ=k|6yHzNmIk%=s_VN=rJ*YkB*jYcYw5pciR7_Bd{kRZ{4${#l;f$ zbn%ryPXOcuBGaG#Ac@uGFE8apMIRRwOc7#0(W|L$z)eJ7WvhB<@qC^4{MPTW9Sl?c^;Yl!va*5) zB9e~InV6vCcc>bMN**{R>O44<;HK%6=lrK;8-O~kx3{tKGR7CM*Pxp1bO5`Jnwon0 z+AFXuF*7W#PnuEony9xy{EiI@+JO*qLi7MYR_0sEpdnwf%F>fjQi{uyELD=Yjk%-c z!XZ)7Uz=MXRLPPT^b#yhj$O}G_Ed+fUJq}J3WKGA8Tua zVRYXNimxA5${C*yx$4+8#ZsSmtD{Oo|4q|?Ruw;W^YJq)vl=>>H)&If#ZdCb7R}ii(y#k}nLW5Wg@L~zk%1w9T{*>@dRXA# z2?y`WF7ab>6P|x=ia5B0gs$|dxOO~aCodEXqs`_ey)%7PY%p40m~J+Gb4X_CyzSQo z63>G7s-ceeK$iPM<-Y2khDp5HNOL&xQ=R^AEh#B2a`*|HK6R=Dq`Yqn_eH5}KLJL7 z;uLT8wA#g6f?-5GZz7$7WR#B|s%JSx=}6U6W3afZgQ8)4&z?;B-NckAAnECUXT*a4 z03rs&nEXEV&Ydc78n7Sr_4d|E0w-@B)Km@yUlBz`4)IO|qDd?MQ0t^0H^mErt7%pK zd+@Aviz$Z+WmGT#dmM2fN8xyYz7a1HQfijl+O&|pU~Yb|*YOcn=$9|+p4JK6n25a1 zrSxfFK%?B+$jI}v>odgE<5jss3okeLHvC>1Yfu3t757_>M2!}6|2EdSpJ&%5B$i(+ zE8lrH?fIO`e~uT%+yVj6%fndhVnBex2RFrU1Yz>rXP%7#xU21KkL^GXUX@npIEW24 zv%}Nx5|2A^5t|i|o8Q@esxb@+)MEO;e_Fp0`4s#oOc&h zllWhZZX?w8IpkQEBBfnEB&VccDXfAlDx48*bs&c~>})NgC?=-Tva|n1qS+O1yPplg zzsF2YThY8BO)WeSci5>yBmk~11ZQ3Wfd?feluCZMHwdupfQns4D0&ie9-IJOu|h00 zFZ)0UmNqSAI;RXCAnyDT>sKgUIASq+%v~8l&RBwU!J}F1^BaEl>9DR1T{e=gbzE6k z;61(WB{aT0SAb{2zZz*Y!f4V_t*+u{;0KBR7&b6)LWEB>=#!{BYA%}?z4eH%#s+H| z0(n4g+Tm#A=)DwIR))QmxCw3U=&Qg0NNBaRv_M$KdjT)R9%n?YS$ea0JqEGUk!kX<)Yg6nt5S{!!RM!yBks-%J+n@ zy5OBHykKU|6AamiHMUz3iYC~`TvO!IPWp}~1#>TsWHb{fj2In(*)ZTFvMqK#+{saNb=|7(o?=$8Yxzaq$Ve)=@J;I2!-wbL4lQC z(tVgN?W3mdNj{U_Ow49V&TKrP;2+8m0;I`vj@X?PeiaX zB_-0+6)mn@JMXX5-lkz1B}6WA$(EImPCGeJ#AUj6U#++ZG6^OaPtadKabWfDt**<- zmXxjY|B`sbqeZlLXIascsr~%*TmSTGkm*TA&4ZPaUniL~ZS6Ye$9Q&=)cj{fir3b( z)aUZ$*c&gIW5U1IITc@?Zf^9E!ifk6hG(Iqp$^xz9%ra}u%Tqb>|B`Sy2+*Hjk_!Y#NAt~(eH zOk6XQj^Skr`c;)f{LRMPGzC`j*f8Ty5IEx#6SlA4q~05RFWvzbr(ZS#VB4^k2AT<< zar`ge@gT{3@w@pLEMM5R=GN4}Up;E77WD#Z6vAQv=lv2^VZ8i!;DxRKMC%no%8mwq z*K>Xag-z?7by%S-S{rkuSo$jYrdvn34{jqbd z7(Q1s@MXpSixn{jV%X@Fo-GdooZ{3w0Q)4E>;p)9Uzv$cHZ$|z8wP!T0f8)8j|+Bo z{15?ES9^mN05%ynCnpmbucG3=Jt`Y&n0t;NZ-(Ch4>?p;XbY_WygwVXmeat-#+i4D zZlCf-Zu5lf^^FRCZG0g7xvjFjakXLdyutmUa_if5KkJCHuX^@uA68bvX!sNE5m4=hgitaLj%5Br*sICN{6^Z?2F}Hshs$$Bs$(Ud!mtPJ18`lmSNCHT z5!Qx>^a|Z$Rd0v~VfKQ_U0qcSKllVs@Zb4D0qrKbbA|m%_!q%DoLgLk@*9|fS`8#C z@QOQg_AGXp6$--u>T;<4Fc$GWFvV2#qg~X)0SspO z76H!;*S+#$=_wYZxYl@xVN{gUz31+cPhEJ)ySIYg~#~AB~B$GqbQ*O9`4Bt@&k%Hx`CF&`@D5AHE(R5g7?)1!QuR-$G>J?Se$v z6T=)P?>LKV7Je9~pO1T$k(rrnF9&az%f}AfJY$%Q(XP(tgT;J)h(eJtjugUXWPRP) zM~n5>^|s0TApt=_LFdk8SX?s*Jgr6x>#~R3fy(vA2i_UWl$!~$x1HOwaPWb9B2^Nb zshIrN7B6_PKiDhd{iT#&x^kVfk&b?;H?f?3&u_*6T-ca?mS(H)X}y!3QT!{u!x ziA(+bN?~)1V%_wKZ6b8d%_O=GcP_qNVu>=A_;*qNMYbSyyH%+cs(tiGq;vUvodMT>{BCqSu-}5xEx)gH=30=qx0H&? z*wiZaNH?8NlHZDp+ckV@NicDX)HCx#(@@Y1>E(A7HnvxM|9BkI6>b~d{(iNg>hqH7 zby2brxOc~%D1a>slK{2cbrvn5q<3Ae%I3SP(@^T!C%7%rmnEMx;iW*6eH-Uoq!+=f zgm8L8%jEX?>FFg(ym!cfR{?!1_re6Wpov*o=S@t)s<#L>iqI+j{ys_o#?%Q`pd0wr z8Q>`(8y5`3j=pc(wRXAK8(hi>WUdF^s;)6TYmJ)U>xeX!I^Fiv;s-j3ZbG(yBv! zi?;=kQ4Veocwv@n_^hK9wxlKNx#t>wJ`h7sN;o7j=(>jhZX)1sgxm?_4LmwERCzp? zBAvV;ac$7$lD1B1owO*ct=At52rJ%|s1#fyeaBVDiJ{u=jV zuUy1ggH~g#q01{0rm^huKA!Y}dwV4knz8$8)HF0oKYIv5vQPB^&)9{Qzkd(HVLa*v z=TP{>5pWgE#xTf(!PfBQFV|<`dtZU;xi!VT1NS1c$n3er4EC2k}ED2MYRt!GSp^!A5m4R>>LtPKp@Vc16| ziO{Fg@>g|`Km$*_l}z*#y-nQ0aT4!mJD=k^Rx48bwKQiOo4;_R3!K*xbI_5z^e zpS`pO*H&9tpvk;32Dk~jbR7&jAiWrrh0?-$DB0s{ zKlZ2VDnT&^DHzHIUUir>;Gpm8`+?PEJoBfmze5dp$X|iS1L@htWA7?sYq0Fw{br32 z8H?>2)b6NTsx67|RLJN&CMT!8Xwq%L1C3xxM#i~Uxrz#cpB<{Q;4FpJVe*9sLhn%r zpb+JiFhOV$j6K>W?L}4FhOR-SW59|LW*ooxJM7eg(7GXoxxj;9t3+-Fzis+6C%S-h z(VV(~Q&YI3fJK$FGrcBUXsi#$oT|DVlLucP%&SOK zs(tdLcg7RV9Ja(IIzdjy+Hu4++Mr-zg>EB2RA!tbk#Gdc6d*WYj9tsd_Hj?nBF05h znUgdxV6Xchau{Eh_n}R3H8TDBbGEOCSmZwS94(Ml$~Uw%qngTMJ44@>&ropO*)CFl zGD=-np5EDky5Cpn68X_vhK>G8$NeH^%u|g0!tKdh+0CBi%mtFnf8*@dA{P_Z^7;^4 zt66^QZr?@YIJNtMT0a=-&q~sE@p&rpYS>)YNb&L4qZf!sBrpAwvtNUoRM6d!5hV}} zAr%!B(gncE0ig>HT58@YUAG)mVaa<6^a==|mzFSvQBqTzfhf9~Pz~ci4 zu-miG=)F=>n$}Uod&lKX*+BbVyRNHCoz|3ozMZhWvb5AKO@cQMb`=B|>-FicpYl52 z_rp`A^7GQ`5Qllre!r(sA^O+<7hOWsbPmzR*RIKF4wIGq_ z0UJWXW>CW8j9aVjNlUG(+QHg@^(S?1%gWyV*f zqw5_SD*Q!}aEXqr5ecLypWqs!mi42I3buPryi`|j!`@1jypb_P+#g3CjL{2YeIZ0B z@;e|aB`IGCe@S$`D=FvPoGxFUzNU=iuI1m$pK7Q|jPr%|?xleVB4KAG^^Ek>f#rrB ziHSk>?CG!q8Ztc}D_$`}_BROEe8d#KdN0 zzk~bC$V_BpZDo~>1@gt#y%qSEKU0M@UkRe+j{fM;m6I{G;{aKpd5A*eo`eaiO*>K1++K;>0bS~qNo z7eCl8vcV?X&|4 z|6ARtZz(g`tM@2aZt09S7l>>%cMLy$m;WX~;q2Wv(!ZbY%Dc~_qG(ch=hiqAYvLs{ zw~o9wnq;FU>|-D0q-C9)ocCK;(Z9-Pq`g^wf)0q%G}t0()yX^tNj_W;09b>DZ*MO^ ze(O`6muos`_4D(@v+S3zMmA6U-aHgR6+f+cm0c>B&PZH7E1u;2f<%PQyth12D`+G` zsGQw!NKlXbqypeai+pW!izsIPrD^l;>E=WKdX5|H`9etIY9$UL`FGaHC@CkWeD?_U zJH)jBx-|{)VbJsqItzqBvpUsj{cn=RnO_+DPy1nv-X%|^VwPgeL*CwR`zDf8AOs^B z&N)8V{V7G^&L+Ad;+f zwJr8BZ0$NuEy>3>i57_j?2~9wU3CT`ysv zL!0fsODiDMP>-D8;e?6ZbmZ8NT@ZMsb8-rjt6N)hM^K6?$jMRAD-Gl`X@+*}`kuoz zGjNIRUof6D*j6`Q;pM%Ea|a98rV0dy^ewK#;E6yF8l3vzh^@(6Y=yAG1{EQv`zi=> zmN^Ga3@^vqrZqLG7YS}*E6Y<7n*+zv>euU&q@XwwxIh`Sl4|C9e*Kf!K3}no$;dZ zeMJRoX{P|hh9!VvqI>DzIu0nbv-;>dS^w6y-46PBK%*(-3w$AjqDkhxYB?7ai(}qq zf4oUN5li-IsiHP8P z*l84D8?tQdW3CR@-ILFqyp@=)svn1$`wzw7P4J{(4dwkhcOF%7yr-ukso>L#mF8$d5dSo%e@Lt8!+bZ0Hg(`_aJ)ZElfOx>pPBJw3c%sEr%_Z$qpp= zUsNaej_sDS(|)sY|G0%Aqo!#{bdk~^?1IC~dZhiFS2TM{$U~eqnF++atDc>wPC(~U z*wCN?PqTliI5&Epsg3kd7L}--cob!InvF@j$a@Ma~o07cv?wWS-)T3hH*lL zHayf`v|{x0d@(vMdOnRS>W*Nuczu06;UU%5rts|pOjvZ_w(!s2zi&2$EPuy`yno-$ zv@A-j@*Va5jLd@-_g&r`@t8=mOJU9C&DV(Ct2W0053x5K9P@1H#m;kWv7PTv?X`N6 z|N2NmUg&)Bm(@4-vA#%Q6ia+3yv$YqyX%3lDf8FVJ3D{9rh39={|xQDY*ED{CZ;T- zU{#(#y#LyEeS`!{>zqjJeQ}M0lmV~Og@lhvmOkJZ$vp3@JYR+4*!H7_v~B1 zp}3vU{J8{_xQwA5Y!<3)QPdvQTQ`Z~Ug->FU$UneR$c7#hj%&u|}l1D~bPpT!LduztxQ$|Y0Ou#7(Z zg~)~UWw=i;Tv?do{7s)|gecdNj0Z_`o%WD{@9~LdCDNyFHFL%HCdaAk)E>XxdhvE-hB!v&Bfw$GG<3Um)J;1HBeq!KuZ{;!+EUwj*FP3nkr8RI zci5PJaHz`5XtQrM7juPJY-sfc5B84$;U|(?#dTqmj|mYA`q!_(_EF`(l$eYw)zP5~ z;s>7*ax-AE&z!voveFAfGn_Xi*@E zgrS*d2CZSm|4?n$^qJg6UWXfZAWzC~J46!TEa3KI;`06xE>UtJ?u)zCPRon+ycF`; z{S4fM5jv?Df+g;AEcA`f-V{vF&OT8@JyhsBk=5~@wux$k3^4Se+i?u_5(Bj$N)nRW zlqdS?|9PC01u7K{rQqOHf-P%C=Rxu&rjd%g_LUGUCb0m40IVEoZK#HDAtacDp+m!; z=l>{PyTu02g$Mn8w5mT&tNb|XKM^DG_vxzXMkuU8;+@4=(S9>(vazv+y1xj^je)yU;Q zWdzNxhrkG=|2@VWnQ1 z?M~&VxKL?PYglh)z(a87xtUsU-R{SP`~puw4LnSIeffkr(nMwgUTyzfboTW^qG@gD z2KC37A(JWL=#C2NX7T7ph+3ZSBAyffy4PyaZ}bs3J@*F8j^0zqM({S25>PgPo2Re8 zulAjR1I!OEJOQW#P~2O7mc4X=uau=eSf1V9{HaBmO*rYhB>qFZyrHz}Z0{>DpkO-5 zl`G1iB(_em2dk`tZF{gh=t0}8-OM4Q<8lJY&80!tVYg95%JYI&Bp$2zuk>HfW0VO( zJ~a?6^{CHF1Vxa5VbWbM&CyUUzGDtj?1Pfh3;rfmvES_=`4zuf5r{~b9qi*$-+wpu zd@$-(_<8@2Lt4}X%}DC06nX!I?*jM)rs#HqPC$nJ#4KS;TuSQA+CTKtJKK|E zD%Jd}nPbaWWjRvW1UER&l8tZ)brW454B`qn?0)d(F2K!tP4&FqN%C9HOQn#Esd3zswnPOboEqs>lV*;XufogL!VR6bFg85h2}i}!kvo@sl=09@7{GGLBzCFnvaiUmqfxf zd8&gSKE6txyZ~cxqiYO&ZT8HD-JyN`oQCR_bh)!(O8|lqSml2EB9Dv%Z7WDvGBq z{$}k}5j-rP59b=QaN6)Qe(hn#Y+QG~^=GR4-IMN0$nEmzlb>_tUVV|a;!%a8JsuPUTbOuSH;Fv?If}96XO4j%vmVG!?Wmh{< zOk{sU|K#bohMUJDi}+2i``4LPO?im!x_!GZuEwBMx@bt3Jd}rM{EA5MM}C8zR>!{v zTiIw|UMJ_47$j)YTr(B2#CH-)y&ko>g7wz^QKQ8J?gL#_xAOrCs@uRp2gu!SYUob zL$IHppSyd{^6w1Yhvqyyf80Ni-`7fzZ6|1b`C2{+{W;Irf)2VQqH#^JtatYJCe(-} z3`jJTKPC@9JVeeZDt1VUJf^N8?iewB$E6l(&Ws|m-={FGZ{ve!>SWHoj-)^3<^`y-0F_ z=Ru&X3rQ$BMa7(W`g-L-`$>50SUAl}zHop-*xP&H!jnf<_3Z4DAN8i*V^0hCA8P5E zn)L&b!;i*jD#(cRb~#fw`RVG${#As5Bo@bzT)-r9a146S0zNq#cz{)^Vzh`hEO6yZ0R(VJVF4 zw%~C6N3Tt%#TA2r<|EV6{&@~AHXy0rmX<)aWzp=t@Fav?UvsVK-0Q??daE1R#^|29 zXQ`;Ey=6JjnxZhn+Y?GdKdJMN&~x5&uX^ z-WVriUpf`H1oV{9r!;JiE@J~G&NZF@>)%1=2YNs9BhIN$%I-egUg=HO3$GJGQw|SH z#K5?H_Wn#j6A2Mj1im$NDuI8cNQOS~{>vxhSbw;X&*og@cl@Z;ymp2aaci1+_1-#) z+Q!cY&)b5h7jy*QRgMe~V^2fCY2g|?2KXPk=5v^qE?zv~n_?lGWf{w)(>|;-OGBec zwNrANgNSb6rg_#b?eb#1BZAf<>#Tf7zdhQCYO0D3we`;DW2omtfUAvF1!X&(sTesm z9oq?B#d0B$1Cr5NvgQ;2@|%9hhqE%CXmx+7Zl+>$HUGfwJNkTFIs6Ru{EIL74$#Jn z%NrW}e3^Y9bDW9XmMy}TE!9wzgj(T&n-RhtxtqgFCCGUYJt$mJaqb2SFlTnN}+DX8iLQ7ERQjFP4(y{ zHP0$LZtSrLY%vLmCqU+aFPCCdH`{Z)zylL8fxI9a9{~T8Og%v+rL)WYt2P5%K>AgH*FCv^Jx{`77(HT81P&sAHbsGychm6Xyq<^ z!6E8~`A~{IWIaF$gvd!s4&)bM!M_3G5;GIqE{-EmoWB^`{Qm6KdKs2_>|X`L{1mxt za;qF07?X1Zrn=Vtq2>W^hiV4|1YUy!?ViAGK$@IFhUa3k5@|T4G}RuN+t!5v+f>D^ z-jk&l#gki&v5xw^dG?W6;+;D;-_FXqfxfl#8=Oj{ycGx+CvA!(ryOMC@fkHJ+0BY4p3pKacsq=eZ^BOW<_kNi+{MyGb27 z0n-yWpqhqOh%!v3RMkCu{>I&}>1~ynpgzOEA=L=>^#*1k*Y_T-Qzu7h#`ab$_YAht zpvxS$bYxz2ftNL~$=77~y>J~R=f#eDF==WQS6)GeGYUM_>=BIhWB^eK2_i9qT)~zp8_4i?uprd_>CF%07#_C7dj(Y+H(l2 z-BVRt0eF%3Ouk>8LkCaQa^KF&L-rOTb z+!u(Nce+;rL1e8SAfyB25YonGEf@}trO!=&z7=vW7yYWs^iNa!{u8Tzcez-L4S9L$ zbo+Ut-Hc;5k16M6`k$%z0C!pB4`39G*8D>o-g8Vs=R#Z(xrfa0OWvR z;Zy*t9!w227O3KYKSIU%f~n*My;^=ajrc?B6udnf3_%UxO*m=ry%-fGSH~qR`Ll$T zNL^jJq+Ei!gv*tFTuX5E0mJv_YTnwj)ljKcAuim)uY(~+}t@|q;dhL>achdL8nBPKCM}a0;^5Eb zf_U;*NB}1Q?_g6X=ZO86mw?qTzMtXbk%231k)@nQ?&xkteLuT`p>~6Z1A22mKUSHC z5x-UnQV*J_CGXS>S%D(HiFdFX{3_sN_&}fzBhidYanAR}wQR_i2+ji_t?j@~1i~6{ zwP6N~*kN1g%Vk%;eWXLQG%#$8=CD-0^A5rmM07$9gzha*S*@|rrIVLup6qwTDZx2= z`NV74SHNNT_N@b)SP1MOa(9uuhu#6x1nwKLYkx;gYYA<+re+JS z@pO~n{7tA@^V))s3Ff(euF9 zkruq_hb4{=!GHji1ev$HX+FWB_}{lnD$s45tT|_%qMs!{hy`b%A?VSVaLvp%ARk%o zalIWMkIX$h7Kp{Tw0~$AqI#f_XDhqKbv_ z9%Wll7zm_$#4><)8WmMxCITLqRzOq1`7{aYxBR;c`L*$ySit}xUkJBc^!>4pQz3|H z`+`*!wgmwDZh-Iu@5X%R@OO5evK$2O1^yOtUJyrsN8lX(2x<+85MYxgf)WTc7Sy@G z#dBjf`b@yNbOS}94j=Fr)P^sUVbhFz17Ca0qodbv#Y7>Zx6uDkg^ z4{CM|-n|O^Pwc++tBam4>Z!WfJzEM} zrroC&M0msO?e4WvPB8MdhY8G1TrvIgQ{@Ollcb~$`2>?XBVT3MUGs>QlEwDf9$!sT z%JM7w+1Rcjfzsn(UQ>ZZ9CN4gGy`L}tyzR^Lr81@$F{BPvx_&1GDt**@X$H*KRk_% zG`=77Ncb$NFs#(m+sz*>CjhSEDegm4U6tPmGe5)`0kDWbl+wO^`{0$vb-OSx&lYL1 zAfUZA8>GGIzfX*i7_~(>w;(|WYe802)D15nY<4R}FxD7*-%1%n3j7eE9R`#DyCkqf zKqp7SWeGQy5Gr^@2m~c4+u&gC{1`y`(1iPn;_b2luWqHGk&%}G#bI78RL`O4w}N|! z2RcWmX&a)mt1m{8#QBEw!_ZIz*j$7zz4v~IC9(Dn7)b%>2q1%pw+d=1UjKmUHahVb zwg^1EJD}mz;#h(0G@KZ2Xgz^J16bDw&d^!VI*TV~rn+i8%hx{y^J>4`C#0<(KYjWO zs-FWGvbbn!kvMQ+!c+g`3EgWYXQuoXA^R_j>qhF|A5~VH1TCls>W$FXqe0#V_XTPj z0!I{RW$g}l;)E6ZvuCN%T?Hhqj}{FsC(r%?N`^|PzHvkOk$+5#JX!MBV2aa81g2+P z9E$H_gnI}GXfn1SP!>f~t&?quKqT7U-UhG+o9W{MgdS+?%kNi(LyL4Dfwn`fwzD<1 z18VI8uzdV@{1o~UoM9d?ZhkNFs={Da+M{junoW(th3AgDOcK50C6lt#a&a(Mu}NQ348D$JX)sdbY2Nr)>R^} zoray|9S}A@GmBb61_~?jm%xm%ar&6nXai-6yD<)NfatH_W)EgDDhouPptK=*^@NwK z_%gw)U=;lc=r!1?i>59TPD1awg#P(A*RgXVNHS1@5QdI1?m%=t5P9$vK>T0w-L&k+ z-)mJd#Cy>wxNdu0#tJA`aC~|HDMPnso55I?aR+VK8hpdQ344cYFe#9)!v1+1byL*5 z(hk!B>v>dtJhVv0Mt~JrDZ6V-M81+x)U4fP)L~8fb^D@TlsVDvY4CE zT8B6ar(_T)^BB+1J=;rh2FqXgn*W*2fd~;v>d~>W)x(z|b?HL!hYEfB=3Bj@zI6F} z+GQHi#3E57KE0}+FC~7^G`#o2PPZzgzQnAJNfRo_h3zt>xAqrsY@bb#5hR#PmF=7503N zxo6m6bFPA^~+&KI3>=~J+J+)@dBMUK-TV-Bf=8Z24qSgG*OuCBIeY#nb3jfvP zQ$yw(lGhB$0egj3nn+)QQd*)@HA>t+Ui0^vkjHXgNzVq88m*As@ka9Xa+?Bm;jg{Z z{@*4Jk)EX``{uPGK_m=HO5k|t9{AU;ZjS9_GuuC4m)!I2KtTN$DqDL>=^M)wEE+9m+&>es<++k19lC3G|A?;Ue${zRs-7bp z;nj0i@n4QTc9OhTG1DB|5=sj>&54Dwv=ffXlLEz0uB`{zSmPH8)} zX#LDeZ!dOucswC2o||%QVxDIrPJfcq`+d0f(K(HZhjSZv;!XN&KkN;4AhP7!(6ILs znq?u~{VdMV`B=t$#mS?R)Np@UQ{iac8sDRW6Y10>q~d*&a?}3{-u^HzpIv`(|OQT_(8`Rg|J@-1(q(1804R>DqlI`Q(jz8wy{w?zhddgcAYLO-0UFVE65hhCno0qMt!7c z^{95Ei92qvTxcaU-zx4W>Yr>7lbC;Zz@xRD&2zxVokK=h`SB7F8X0FBxBuCYd*5y7 zSXPDr0X@Nr;sR4j12L=8&)R&`fj-@;{pGCeO0m6Jl&5Px^!Rov54C6)wndMxt;#o5 z&UX2TM0&CC0#k}Nl@O?G5`Ux~linra-Q7?w&s?HXTrn$P-*r!ik(@VHWPe^!`)ZIO zpNN6%4`Md!=j3zc!#)$FhlcNPrPa(>9qk-YzLQNk;CSG9?zSKk2hZW~Fnv|syj|jl z@~q-&I&XXt5-r$1;=V9Xv1jwz%bT}}TMIqAoh3Zhf9}zvRAl*GenPzXhh+GnOJ|ld z1xx~a`%Wh32KN3UcFD*$asFmR<87oAcxokbo9N7_AkN8ci4ZPwQikS?NMl` zXuLEd(M`Efs$iicHn+KPq;$}K==@c4kraYwR1KN8{3sexxp=g7YINs)vUJxwjb)SK zOLfS)mB$u_y+U^_FdMU6HfVpkwR*lvDIV7h zTVY5}jZ@eYp%XAcy8BjRt|+Pdt$=Kw9hM#q(HT-Fk!WU(M&Ix+iZp&jPl6~@NyBsQ zX6Xw|N%T3;ct&gVPvzC!71}60vE(FXoab9&cwEP&Cp-U+2QweF6t{KZPft2tLe6WNhm)qB45lwGk4JH!Urjnm}O&`A}*hv>Ab!7KK2e{(bg_3e;Qw)ESOxSht zzuRp{E1T6MPI=&H7_a1UR@M2lZ!3;C$Z1x)-x&9z{lNF9o_M#Wt=Y2<9gXt!B*Nb| zEO>KCxpR-`)@b_#WzTq{faA7F|FslLisyM#!YqM-=VJxd7xjbJQg6j|{XIWkVOm-P zWCwtNLzM(Im_8J3KOh8A{a5pRhb=hOl505rV4{I+zz0?J`xrgh;@;|ve3n+eDth@{ z>D6ZTPn#Z`%RW*GQky$fSFlz10?@ z@Is4!`Q7|JMkl^g%UUezH2euC{JZV`X8zqWt5}u|Wo4{QuHxd(PV305Z2uI{uy}si zNLS)M(f8;>57RD0kLDjFm%OQYLh_F@$EZThj}N>j^4U3zOx4aiWoak9WJ_=S*w`19 z=cv~7^3A{V#0(-^XZP*VA=j?v-*gElzLyA_^Z>nq3K5^Po2JS^uTHNx}6?;c!RB_2W=G?Tafyi?pN{xu_q}7~)tK0E?)}G7nJVt1 z{p=~Sv=@$FPoRaurLNV#9bQL*uK23hsXRuDv-o!Sio_#K zZzFLLAAT$|X=Hk*rTPD;dhfWJ|NoD>-DwYLN0ataNrOszX-NYWl}Z|ZBwj zZS5&78nlklR45fvp@EEKT#w`Py{_Bu@`o?qPwJfWevRjNJns+9?QHvYx~`F)F|A9m z$dU}>H#`dCE(&lSt9_ekH_GpLAYAf@l5(9|tFhPv#R{jHFvl!!a}#1mT}8&cR}xW* z+f2DjG%H>FsA~CZ!C?Ext97!*oD#N)#qP2Cj>R5)A`eJ)DdPJ5XN1fGjpdS6Jk!M8 zUiFyzw8z(-qIz7q%QB5NHm$G5=kQyHEbg=iOxZ^yV1%U1yF@qWTmAh{vqZ;SW^NRP`(rO%_Mv(1d!*^G>HCpO zhrjKVJOASNhVOyXMwis?+f%H4T`tJtF%%7LZ~dbzHGQUj@f-Wnissvnvqj@HitT)k z(%%(R&OS>%+dptzrt0K=?UU`TD`!Z#zpAVLhTPi-d6xHg?8PlCK@r-3|9zZFChvmy zcTFT-{2lSDBVCzCK-##9X@6XC%K(R%MuTNi_3iSno?$m4c^Uckvoci|)jnYG9P(pb z8>)eg07G42Z{jA$^iQ>#H=I7&h~#!L%MBcs*3F{1YDnezNS?(Y%!Ic;dc;}6ifHTU z{lF?kwp6?%N6*K}-HYEg@nIXcSf$m67}_YFcCRcqJNc+PpRXG}_DZ{YM6$kOlVY-> zA?LBd&zB#ZB1$UxT#+;$_*qKcKgGN@yvv`LSa_w(gNvLulb1c0EwfZ)EBU`=AK7`; zggN=w?>63)wy!=p`3H%zBhr?6mRU2Wm`)0{jc(Hj{@fu<9p}Zbt$JD~Ld>b{b8P1E zkCSSp$DBfsH0Y+KrC0}EJx98?qonM^X^qq?RuDv;yR~ zs&{^3j+68k8W*#^lFKW^?X5%DzdvB!QmD9)?!s%D2X1^!%KMVPEi?SQIMwo=`LcLH z-RmBgkS%-spB*vz*(-MSu+!SlWB12TDd+P!sTln<&geN^=BJZRRz0va|-X!_zQtzO=i9)q=XA1=!%X!omy-Q-=JPsc6;bCMme~&OXE!mmgSw7 zWl5sO9cFc*2W}o#t3UnSpT1nWd{aUD);4(H&?(g|bu}N9iyOAhSDm=s70uZEOO;h2 zC5cH^>!~eQ(U8~W-2MOU?SC8mf4`-51swQz-b2OWi`LV+3dtmWSvJN81)4g?Gk=9s zzqKC{s_@<@Sqc~1=+bWgdGYa~B6snV`DV}LUZm}8@c%WX9ep;KQC_Z6TDEeJt(Wu( zFD`@BhY~krj#mVF?cbx+9nM4V!bKspUQScnhNiB)kb6~r$=k#+Sw$u+- zxQjPmKkZ<6IfY$MuxMRwyur^X%R;AR`V#X;-sesGUjCWVI5APT{xR;w+qDOU6CKm_ zCw+}QYrZV!@=3?mSoMoa^?$<|uI{(|Vb8Pv8Q~vsL)fC%&v1db{_M^Fz-YWc< z0}fE1{GF12BR~5{Nb~{mh?smypg~oV|2qmkUR2JD(_55IsTC$@J)NG7JIB@fiTv3{ zUr%5DIKpbspXioalJv-f#1`arLQq3kLVlZi-Tr`^yS?62eLiGlDxO~KRr$#Es;+YR zyoBmQF9CiZj2k^X!ZE_UT~zKN!7`y~D{0pu=4btN=0l~0Hhuxajculn%j&;K)0;_( zM;7gpi4hWsDjkTuc%!`Gjq8<)x(ivSozMGg->Xj=vsQ-8f?clvV>6L1dEu#&ldj4! zo@c?!(cx{9awM)R*FNxDN3t<~dMJ_ljQ#u}hrD~G6}NhPGfS<+MP#M6J3QoSO$ih% zvAL~pElznlr(RfWYuPV^7R=gH7x$K3fjGsC3BS9BZP9xH`wl&4sg1xxilk!J>R4FI@#TNqFw{P5N3;HAUZH*~T{8`>v z(xmvsIfWmTSss5c-v+TjB;2Y&jvl0eRR#8+`ha&!;FZY;0*o0`wh|DdCGV3j~}Rhig)M9uh_p3ukRw2 zglf)p)#F*>Ch#qTs-ktTJ=OQ|wyTa2O+OEMnP^V;-AlZ1fjs=iuD2C~ZWuWf;_t~Y zaGa66I#Md0H8vr!H7frJ*R`@T%WLHqQw1aT+4%NdD0ogV6CR3A7bYp26)ad)D|Kg# zsYpvzk+cuk91imHYI?iy_D-GT1!5xagA2xSg#)v(kZ`mr+SpMvTLZi|8&c;>7LsNZsIYw~doioKn7n_z^nqHG0;qAD&g& z`dnL0N-A^pKXFb>y84a2$#yYw5*Ja5L5$Dk_OAhtx+bw}Le3zpQA2G-(IJ-c`BhT4~Gqj%}jC0P(behDWKL&OvF*Owq zdC&$x!@lS5uN^w=4_FQbZ!H+|0+Y{TBwDzG4}<;9nB^wXM(L+L(I-4#3dVL0(ui`W zahC^|sOhCh=X{UC=6p%u6sJ)v|NZmpmrtK4wX4ymdb)d~qM{<@jk?Afl6t181R;9kfa{jqcG5pI7V89>yP-&a`SR>cM-)0wY^o^ zIv*DArr3ja7|_X2`ub1{2QmY!8_cd%{_FL~T`vJ)rC_K-J;BnAm+lue^XbrYx$`i< zYS;(I64+M0XN&s75Vj?+zRr|Vd(`pd+Co}I{?M`HkSnUI(WT0wH~PGI8Qu6r z7~E1kQfN$0UXbT27rIVj8sUo3k3C3CbZ8{SIjxI{&9O_A2#Zi3-IvJttc*SD5RXi; zsg}j9Qo;)7sZ=Hb^(R|~Oh1$ul`zp}9GYRb(7c<&f3{Uk>3MHmH+7(ic3JIkenn5V z9*=={)!o%3Sw+zpHN~LqwwG)?=^uoj)mM^`-Dw-}XU~oXIY}O<9NsqbP ztMP?fcl_7oDNSKv9j=FP=APJ*=FRjnK3DrJ_;^|XCW_eE+SVhv13Tiy(l>5D>yEhS zJyDs2KRlMQ%-xY$@{fWXK6OfTHOz$_F0z@b$&9P0C)|Sa&$_^{sIV~f?AcR-+ex1> zbq9U!anZT{z=OyXw}#F{p#zv|2yEa6Br=flJ@aY&h*8Y z!ivmC6?vgsQuBP2jR|2;ozXH!W#EcS1oW*}uiqWaEJ)^0rR$JFN&U%nSP&n=oaT)i zOY`$PF(#yRw1<5mt=K!Jb9LBEq0xW(FnjEq-ite!cewYZ0Yf(6rJ}a)MurEUtMX?h z&}M@*uQyQIB(UcT6PzluioH_}_YMYk{G8?UV|HM^{a{$a_O_iu``Yl`z&Ge7qZNXo z%qFb=#6++T3ESN-z6n2fjy$fl``k}FOl(Wx*cXld-=RQ@jf=a*eH1OZayw~JuI@T0 z=xD=7#9H}W2YyBWfSsq(5I6XFHwf8rfmpKp$O8f_H^3Myfp=b!$N0_Bd9dshQb->Uv5hG?D?|MLY1r%O(ZA1so)GvLTDZ9#5|CLUWAEppji_ z#7kwhj=5Whf}v)6zEuUdP12dBMN~93`T4Ig6Lkg??-+QTx*=lD7s)ld&Y=f~_AC!| zu|qN(hlA<|<=wA6e5S#fD)6kEeq3NqTEVWM0G29CyewrEwWQ0>29h~bCHdC9JXlVY z)E>%G-yG8*t@_$PHLgHR3~n4ADZGuQX<_CgTycn`Fli&1W0-l(jW4Ol$q%?T)xg;+`H|DokyO)ixU0YcBRig68Q?uKhR#) zj(9C-P%Bkgz~a>K*Eq^z&^Xb1eZaW?%Fl!O%MB-m@Xm1-okb?-AaaI= z@uayk5IjI0VKB0mv2XV-*N#ZO9iCdK=UW|U=gZ0vCImcjcl9w6c{)o)<^ zajbltOwA6M=>UZfGy7D#|A4B5Ndfm`$H;;=2o<66!*;d7sLp?5{|sk;o6d+`r-%wj zRoW(fb(P}nWn+1&hQKV3sE#>Z`g=+jvQ{VF^rVvE3;q4;AWMUEN7#=Mlh3}m+o5HB z^yEndn*6iWfI$S~$!nSkHgK)?by@Y8#|6!VsAo;2$@_K z+^LaMp{L&DE=l$3*2v-veWMdglmw@4)V+Y4x$M+p+60P_FZH~K=$E1~Hh~|m;82rS zSV$9aex``(@V2BQ;O&4j1Qnr6*xY8y zVHa15ZZGwjfog#lty8y^Ke^g}do^9}-1{wb?bRdVYa4xUv98~h zsrwuD)g*@u^QXp2I_fzPYt0WG31%XQ(I+UD*9ZPgeOWKsHqzR7$uf~pTIQXY;v?~L z7mwWCP*xE}5mP-bi2@yJ)i<3{s&f*xf$1sybDO_xu--U*H;A)KMUU3Qvp!>a^0sXZ zpT|5`K#*b=vz{@qQDU*Bq>O>NJ+8|R_v+PE70)0P7UJRpX1^a)%Y^x`MyKC@V9g9& z6I$VjBfs1{Y*ao*bqBU&FF~J}*l}I^Y*v0LjyoU3O}G-S?>e>3@dcis{>U0a0DR16 zV7-OjG+7sdFTv@oASY*VzL-S$f#Z=;c3I7X?k$n}qkTtcmeW`7b+SzwD|1w|@xH;8 zj>z&Cs5Kyssa<GyVkO=8owLrDQa8Y~tuZ3>vp6275+Da|3u2eEJbAeh4~3ZVeoo?nWgs~GGY1OY2$ z>z>`BqDSxp5%2FM(9+NZEez_i$3nb1jgjwq&&OvVAj5YI!~d`$*oQ{uF?IARGm5;1 zhP!c?OvCaz?=0Q=`UVtMUWk*S!AVX{m6MeG0d=MI*+NMVt!{ZwX#Pwt0VJxfQL0$UQS!9|P1W#9STkAUh=7f($D;UmXB| z^)K@)lg&Gjen?2fu_s)kY}FN(gqC=*ZT6{m^k(Pi6{zd(L_l1UAw`MWle*kX?f-1jwHwa?06V+f}-DLBrV;~44qk*Ua|H#0|=qZ$1$dVxc z!WBcdtuLj<808nOzfiPzH@?7`2#OhuYECGyLc%jse|k1i4}&*tP?LK z9MZ-F6Vy|mU0~l{pI?|I!7mcO*NI0jc*}E%N}aR&oqrVVSy>#pecH*UbYJ9n|BmAB z`&)CA!}4q^Uk7fMJ4(-5GdTMB^vn25yu-yO3S|pE>b-pGeCg_;-Nqg|ZQ`au`zy!X zGpO}Gt!z9=Ov&pFe!93K4QP=S#dMTltfP_@gRcsHz0ScZ8%&S}I4Ox~pLN2P#A z#9t6`*U2`4pnz*t#iH8f{ykhySUgaFBcbsJbq`DQih|3lwh~%byGr|gIV!i2tKWw+ zTb^zVB>La3`FqFbYK19xE0X_DGDRpK6b9t9K(9=}F*VhYTK2Q{*>B`CZGnz1h(iww ztYsVy@I68Uh){_QKQLh0*Oe|V1zgAYIFFzmT1QBSf5L0-?fg8ZoKpmM;mo_QFPEtL zS!=rf*i%RdBfgFuK!|bk=1uY-FX# z$A$GMUf$Zbd%Z9(&lNgo{A(99nmF`o>RWEOv(H#0F!LQz;RlTJ+sgD`#2wc)L;PTn zNZxnM%$V7pM~cOz*Ybbk9`sTElITWH>#}3Wg+^CBU?vg+lXwUg+iWqQhf@slC(M{p zzNK}t|5DU}uaZpCII||%qYmDel$1B7aB#3?D|?t$A8zMXIGm#7H&UDm{Bv0u;xo+c zKt}apviS>c{+^3Mkm>CkffC7Y8_V^isvKBpZVvV{q#%HZsSHtZE%%GDjm)GsnIFns#{y}GheBK(VIp_ZNW ze#90?)%x9SZrcGMj&SNubv2YHYo9({RdnkDW0y@_H?19G%TS_$BgjYlrRU3lkA)}^kT(Jo6E^y#heviH%tnX=UJa~P zu-2!h=S-K(M}nN3a_q~e?w{Y&t%&9$B+V|kMDUi#OuEDEXMO`6to0PrR07O&yh~)D z1J(@~i$A}9Acp{OG;%DODlWFUJ?eA%;K9ic)%RJ?%z?v$uf&_Z1zNQ9`>r@9EE?+1 zKY(H@F~6E`a~m^JV?JQ4Rys8@5(q!=Gpg5Y#;F|>Q&KRiuOKU1QC@9kY)tVf=-Oid zxR|8ZJD#%<2s#9adpo?Q8@x9~zQLP8%KN^#fV;*NVqg3cVofkD>f*)y4sIO?n*~Ga zZe6j!cLHY0`PDqFmTr#bdvT=qW+5Rt(r0W&OlXV`4(1Jc{rt8h073JyCxX`=2WzHJ z8i~!7r3F94?9j)$v>onom!KrMOovxqBGUOi~8rz$a*e+rE*eoa_#;!qP ztAGC$4hfOXdVduD%;zQY@~90H&NnWr%bg;uKl$yqr;C#)#dkaOW4Bvsthi&ALssyG zx-p6DUEjh@Dus``SLh|~b1C+1xu*2&|&t6FTjt@4LjQ2%UnIwXGp;W;&%mX)*f+0_|c&eJ;M&B1Y3t{i|{ zpHdG}n~dsef9xQqO4v(eW@dt`G7|@%L_NJ|`*Q8zkFXX+YO;u3j`@_kCBBGKa9gr0ftNwPw}y&}%DgY@<$W|P9*9*288XE|ePug(Jv%$} z$Eurf^!moeU+-P2pT&vd5b8XyKlXibJi8TG-w34^Y2ATDAqgF?7s=^ce7u z?k~S^cUb0X;hbQh+lxrSzI;yoqK6~$SlWQW(^Vo!X6P2J;?$j{#o7or7os z>URCp{33R+)HwOp*n|kD4ov^P4^U4r6dUdEWjMWFAq)|Ip#BTzn{T*$oMp2x zKBKvNH=M6^GTRUvwnL=a+1Uxl4i)jX0up5o;TBG!zc7!GmXbo$j%#WevmzJ^2o6>g z7HVP?GH0h@-wakHG%9)o%3(Vf7dD0Au8=6IwrF8I^$xECHC=>cC6I#l|+o?zEt|0{u~9-94&XOBme(hnzoA44-y550wgD=f z{LEW-PtKkEx-j$xGVeEGuSg^&eMP5Xp0tPf+Qa9*j$!y190@fH{385<3kx+4w;OT#9W#7mq$AQt+;CZvd|<0Nkt4GI0c9o$x`}$HP z>gHmx`my4YPD!;XyeVmfotczN8F^{9Bhw;(vu7z1CB;n=iAr(oVj`wJ1h*(oUs>q8 z7rMKj=byB5OXny!Eg;TwYcHRRAi^N%JFOylF} z0=SS|Y`_jz_-{<2a$`Uxnz1O4q3V*SNPU*hxFD)gR$bkJJ+`h+Ii`~$T3VmStQy+> zGY%h|IafU$gHdX@4|)Os#?P>IQ3xW>s3FQC3)s=5$ET-rzY5opXZ?ER&S_=mM_dMB zXU~Rwc*ZhNXhx(-{`1w(9R#PPB^J*2WFib?{oRVJ3El3>=P)OR(~iz<=b%4k{-cC= zEJhG|AmxhAd8J=}FYcuLwv0tkueU0?cA(J!d#K#=f5IvaHNVF+$6%%fLz(Q`AUVPi zmXp&9)TNAv{PSLJiVJki!sHOD8SP)Q9(#Vl83+%{ZRXiE2snWlzS(9#Y6@jO>cEAN zn`g@w%aI@)bBC*|D=K0;iw9r+PnPljYBM&bwFIe%tYw3cW#c|A@CGZIVjzRcls0J8 zvqM2$@JNa(KZ{;zSy@uC_YF&P6<))oT&l)YMoN**MB_`}_NM%!G%BM}9biO6u^iP3u(E$Rg-j z#w=q30e8E2lk-dM%N-Hg-$5rsBm74IJ~fn@$l~{qm5AlZW&}=qBbrQ$5R^cTjyfRk z8%Y0?A5skw5Y--frk8U9V@P&kK_jtggm^)>@N;exYFGKTCJGa1r79~~7#Y#Q!laiK1$*oTFmjzkH7~xAmgln= zK~D&|sAWk#{FZROc{pY(3-*G&k|x^l*!&l|1zUHA^NQG6Ti3!c1*hcqkI%mVOx|?i zPPM=m3o|YWTh5uG?3OhFako`vE~11^8D+o~rC&t7I&l$w5tA;$Ewe{~d{x2_6J>KM z#d*Xgx+k0e=6QBM@s1g3ATrSTLi=8MOQrzqwx6OuNHUW|8S1O9?gm-o#= zsTUqLDZ{WJP+hQ)sJnyDGmQ(E8y-3y6;LY?5yIniqKd1taCwNe0`Ky&7y?xa?Y*P6 zmG+p$QMwu4|K7*94Bv^YIRJ|hM;}c=!Gt&dc6D_^ zmGwow{~H?wALBpGvTr6Joa(cxUfSL!_->*TLcz7Q3NBAj*pgF zlX>6muY@Y^DbaTJ?c$E+#sC%BDDYX;AzWN-h~?{KHQoEzIa?^qX+}NS^@6VniO|H& z7`rsEbKG%|ja<^xHI953HO_X0YAH0Qcs6!Les^RJ*TX}t!E!1$Op69Iavmz0<6&^k z(=cka7^qCTMdDrjG9B!WIvE~&V1oFegND-K!D5i!FS-|w>@=13W*SfMH#NGR3BRTc z;w|<7pMG_tE4<6RM`0^{i0%?2<&L{jy@A4Fr(gwxiyK3nImH(kXNT=BN;;_1G!k%u#+}*}U)AqURg=qwHu|e0wK@lB z&d|T!KWHz0?QPv1xRb=Dbmxmrp$5>OB({VzgU`zs@!v%aU3Y z^|G|)(G3u9c6)ZfDe$e?8v=>vyn>r)k8#fxk<9LPk8qA-C2!3Le+)F*1b+NU@D~=h z29%jW73O;Egc zT@OXZ=zi@bjl0CVp@R40mVSfz+RDEB+_9VM$)23W=%Wgh3FYi#}iUqwDc9kd;sPI3IVcTuYcug!;IB_{Z zU06`mg!5xghb_nJ^_%=_#bZ7YndtH7OKRM(G~P_~;Cr1svt@1%zq*@Zmd8@?S)lY} z(lv3$(Q))qa{$PT7(8}bl@0pM+Zc+n`+8;Chev@SHx zev=jSiZTsuXPb@H0~ml_SvFu|1D8KJN#f+BQk}*LL&MzQv=l{6k48th#)b;TrpWii zjNFZ|YCi3dP|byHRP1|8x?TED8}A_I#humcrtSy{%WZOy>tLa0|H7T471<9IaQk7D zUK9vaDZyDDBKn16J`5c!dwDcS)W_LrbZ2Ox9yuZfqq?V0t3g*)v6=B9h+KQzL++7+ zH|?$*D0slxnaL|zmW1O*ZYxHJDEnAG#ft8 z!}NZjOgZ;a$ojAX!@ggll9#587yJg~uIx6n{%l~dmOLJN|8Uz*3Af0_bQ-NM$>)op z_vP~8$GJuO`%*A#aXzp`hZpzc#`hIAnt-&OQA7s)RUABBrj)TE zx$`2yaq`wr_~M5agYa3*3?#l3KTC(y5I6k%{5XtB$jJ8{k=FYU3(0noIQHO(0@FwHY(+QZTiY_cQP@Bo1n2E0n%SPvJ%leYOas7y zHaKw^0A_&U(EES?Y#>Ui`JopQIf1m*1Qj1$-6+eFn2E@d#Wv|r+#Sicw$2V%Twt1! zaowYHvN?yUH`bobZK9|QH+L(-jsx~LT2J>zZB3+P%AXnXlfRP8 zzRg&;@aj1pW?wI*I=9wN6VHEF%!2=@*gC28V8EH!v=WtKash6lG1}gb&D$u*h;mu5 zx6_;5_#TP^NS$QmjeZ`gd~^A=P1I z8)MiM^4+(1ydDZmW8@nk?2wgL7Jk+a4g{Lo)r|qSvr(K8|EE85>x~tX$5Rwk@2ZH>_LPOT!aW$ze>KDY%MRkpy z*d4}87w`(qNKE@fVSt6L%nWlL2A0927t^}ydqpNan?7m5e8so^d0l-yQeBKyYXmJ& z7;hS#57M}icTvyU4k*Zl@;tMXDky`TuavnK!E{j3Vgxlrb+lseI8e-mGi2-O4_WEy zwJ0Z$nR#?E{5k?O5i+aVa@g-a>(Am=<#sHBPHUE*+-)D9isb0OnN@6n)A0_WqTSa0DVbJ zCy2f%OAnAp-&dwrR#w=Oxn#A)&7lW&-5f7LEL769yEZBf9xZ--n%L1nb->Y)4@O0T zf+_48$Z9bV4V?`zbP)k;j|!aaof$q;pjrq^9XxSzr~Dr|9yy7te5n5Z3w_Mb z8B|>6DT3SFH@DjO?j>Im%u^4BoyEuB`#g0#ka?@^rIB};VtBv3 zJ*HYg^o7UU8XAA}@6LvXssm(dOenfhm=WW}e0{s_X}cmD$k*cCV40-UgDGkbgLIGQ zD0BAL9dvYLF#3dk7Q37(e=3Unn8DBPo=v=X(U7co6Yix9TXNdmuTR*F3IR9w@@ki?aay~h_*57WfnZE4ZaW!EA7K;<26y;B-DPy|uz z0@AA`_@S%rjjP3r2uh*Z!O%9~?y#eR$bb?T;s#U&IwgPN8+W7xM5|N0q8Tt2maiW2 z0$KtBKRDc>W{6UrN30cY%zrM;io(_F5kjs;c{@347iEOh{Qitk1rqY7Dvpq>j_zLC z)G_R2-NTvBZm679!%SQV>C1Y{7}=WQWj>Uj+?eTlieavf)Fw&ln8=jhUKr!IqeF=5 zZH;6?TpY}xUZX+CO(UYMEfiZuc)?2lE?K5a;`Sy*T#z~2f#ar&5&6&41Z9+%K*Cu% zl(2TdWT6Rl9h9Dl;^eY&6}V9g;s}|ljpbvM%EBzM$Y~z+4Q+A*iQfBX1l%%1A`v5 z7i2if{cr73AbpN!5rAXHCxC!cYGE8$K8DN@kM*_sGQ32kGn2%%jfq^FHtm2S40vnY zvwJV>31XOeg;+LCWG^a+s7-P?k1Ph^G)VIZL(iy#5fT+WB%*LqMlAstblRWHkn|$` z#16h+Hu@xMp^ZBIdZa3A!cp?uYdIY=xh}wP7g!}!_h{(oP}j2Gzn?N~9QF+HZrJU@ zO~tovi6|&kqY8!EYyZGN^sA^oBPf52)m$#oa3Q{WuBLHgIm>=g;s@#wRQ}@(o(E3I z@7=h~+$LVGpY&Rau|JVmr0HdmRjq&W3NMHClQZse?2qG&YZ9X)#rcMQJb7M<5(xZn zn^2vEh*t6fU=~MH0Hm=T3|`&t(|BeuPZyUm%H(@~tx`+g(H&q7MeFLE3P$ z*Bw(wh^zmaOp1mk5ehumJ_9B~+54-ib(zyZNk)cgRIN&cz-mV!T55CXP&=p+K+cy| zGA0YP{E1@8od=Kx25-~wkq(m5kTJ0_G^Bg_1>?G=qg%8>1K{8Zumn;raKyQWp`-k8 zvKi3D(=0>53uidhswyf9ysT}h$u_$<%w@BVQ zn)H*B#A2ZhG|JAkd*rO%+3cjI%6AO5&=B<6X}C764CHz;grz z!*LV~!kM$khHP&yTmjQ))+q+j8mCI2HSYm#&?IhxSQ}3_ZU^u6>+lPC3QZEGt0BBx ze{*S%Apr@fc@ga8_fv+au%86>=8D2i@O@Ab#PYzUh`3@__GdTSo=8^j9WpAD1U1S+ zesNu1lOZ$WI+_w#oeKc#Im>buXBIo3qh_QjtX1MEs^XTQ0awsRv>Plo1fP zB4_*o#JE=uR5NN8BS@Nxii)-nr>W_f7f_h%4aD*!>&Bye6?+h0;o`*2R^9e_0Htnp zwT)8w=RSM@bw39_!`&1ASdKq&+vKVq$6nfx?Fhy#n%7~uE{#5$(*&KVUf(4_HZGmX z*pTTqpz{=+%~a^%Pz>IdabZ3z)o=u+Qi&12RZZ6J`yw9%vI=kd=B`ukpeO>qvHH?V zsda(4$xpm!f;_5nwa}Ja;#GQ$>I`?XL6Hr<8=VN&zrovFYf>}|bNAhY@)HZ)>=I*y zNay#tA6?+Q0th>@L=>SYuJXKRA*N;aR$lnl^;j*tzrd8LSMyPW@U6N-`2zyw?cx>r zN~!F$XO=^rl}*OZTlRBu-^lM4e{PZJpLW+UHY`O-yq~M5>Gy@SH$Ygi2TGu@S;Q$d+y3MNJD zG_#L)PP_BI!*P#C5fBG)G2&vhvA;sjPj>GDTt>|o95+B3-{2f|r5_Gg6S#V(gIZXI zBaD+670b@AbR-sRLC}A!`c7*c^6@DXCZLgZn#aBMl|`k^ZIpZ&nV4_^LZ{V?Yr~og zf5xMfAbqa$AUR}V#tQc^)&k1X8|XEWV}soJjkU#1{QN$(4wg4}pKH`p66{U?waX@F zOa9_=^ijdy%qv%Rs;K=}QG0VaXk(Mv zT3NBfpc{k~Twu0bUtcFHoCyoV9b9(z?!?jMAoL({dHFPI;P8cRWj#*7-Y4;0OU9gk z%JuaBB|8L%tT?Wnty8avImc#x3r~hRW?KGNbr^KX?2gmEf4G|d<1)YWDe3sCtuvNS zEK#w>gv2%IZZ1pNLTxHWs_S$mU*xE}Uh{Fm2DpQx03iad280?yYbsF@_n_c_-Utc; z_KzxAK92P(7-m4|sHL^Sy+6k<$D7JQA zWfxaX%+t~%JlG4(>hj5E6f@vj01whJA4nVb-7zy2)!e)Hs<$`6<2m{1K+c)R_(ID^r|xfL?ZZ0RfK9Ff zG+~l?LTt=NoCU&Y z-<45#BkTB3E(dP{>Sm|PZ&2|4eR}@SUhB)GxswO@u^Y|th|p(56mb5}`mwj%KW4-J zK&2Fl{v-g2uq=>6k*cA)B1*JduotE99ox2%Wq)LEWSk4=)mV%`?paz<;VbfUug|M! zZpNV4?%9BwnH2Xv1htR{rB_}F75e6>RXq0GfKj8x?(^NgxVC*7TkveqY^h6cESh8(D4$g?>J&G2SoVnG z5r&`Py%fvCo7ZM&U%6$5B|JD)Cu_=?z+uqs;?6j=e4=3dgRYPt9tc(vB!R#1Bw+#4 z>1uV`jy%KzJxuR`N1mr%t+!pgp%MM@5u|IF0_%8|FZt}*GjgF040JwomEL$@~zBdq^*x=UR2$E zfpsLPkv)dBzg*Wno_hTu(#Tm$IX4DYnV+ZNbLX@PnWC{|p9Yw!L=h{Xfr7~`nwo%C zIapgq$=}K!7dTZS$;g=m2dZ~ZZ@Qo#mYxm`Gr|M%wHEs6NQgrXFlZ1cV8=j;xRXB> zwJnH#CK`_)U}74lkd-8TMm+>2VTwjvl~@f{b&bdsT3Yyb)3r(tu1d+uhC`)6F8e|R zk!tuRKwamh(9wg|)|8QAZ@c!gOn}{mrBCpk)N3B5IJ{LOs1(+-|EVPra-su=BpT>a zE(2Xw_4QwWes92Iot&YZO))3HUf%8zPR)VKQixEw3~+q=K$M9~EAKhuknqJfyP*@t zyFvCUz#Na}KT?|0I5t+txyuA6mcF4e%=?Qdj-P`^((xR{eGqfk-XX+)_vmT{+*=?n zW@m@E95op-Q4d2g0@I^-qhx&-ej(@>@*)TQklR9o6s16Ifw48{mz-^Fcer*?;I4&Q z6eo#};d$HX;YXrmFF#1tJ2|~1vuPomFD!g;!;?TQya%db%%;Fo3RManyQrBBatsv0 zMKg#1?r8XMEQEiWduBx zEoi=CkZ#BeHta9Rj@DDHx44rH3ue`;pvi^E5Q8zs=H`$#zrbCIQxk4zPP|?;!x~S& zAUMo31un=pDUu4jU$$iq(bqIG%5DzPNdQC8Yjp)$YG@o`K9>i(4e z__;cP?Xm~M``$icIJMU`_DGbp!mw5$AmfCld~h%T@;4{~Uiqp~d-t7_2ZR}o+QzuJ z-Y9OyQxDGltsqy4;{BxfLeyDmWj9KgBxU7^;*>`j^6w{2M*TZ1RM+|=Nwu;Vf~~gy zXjWTpXfIk&zz)sC%)Wrydu{s$@;D02>b`Hjsae5&Li&MiT~K;!g5=^;<#)3+EFGID z;yU6t(R7~6q&<06G6{QMp>4J6^~2=zzrP+}YumgkJJUzaZ#Lswx7H;>GpMBQO#JD4 zp0TQvH8LPA>YLdiWvbpyrL4JE-S_Tbrq+iDVhy6^RZkUOU^XAwf zH^cOS)W{H>Znf%{to<{n%$pQ?^Roy4NX76}J8Ni&Fmo3N*Ts=RT@?#zIeyDa`m-Px zc667I`KZqySIN0Adr9UQhaxn3*do0Tb;KC2Uvb zwv6=IJH2x^PajC*%wo@?2q|!96cHC05Yr==%qhN~DRTXlmd4gO>78S_Z{J^7NRi>z zX!vg$r?a4+P-4g;MC-iXIm6f=JM7aK>D4hoO%#%P|B~31otCas6A{wLH?xCEn2J&Q zZH7JvK{!`noBc(lZLeY$^mLAQyHekgZDaby5Gm3j?SiF(pFJZqLPH!WpnF9146$;TB+vmw=o9h zB;zc_ebm!8i{)>(yZFbQ<#X}AI4E!8ajflS@S+gsmim*6dj7RJv*uh64>I(93C!u) zmhjNXs7ho10gn2qNk3#Rold(P9lADTgt+(%NRv0!1F3#+DSqyE zr&Lq17(t2fx!%GNkk`0}IpN=rA8dq}sTM3L&Hp3Ew5Q=I~zJ8KAzV zK7aQ<;kwx@_3{z*ap#Vy-%B(1wyap{k1-g}4r$7TJMo@arrm6^(GjBCm)RiRqJKbK z)$@X++ODuJ1(R~l&pZ^*mg&FVUow!)%U9cHZ*boERYIFklv>_}oQo4{Yik%>DMn=s z7ud2b4}Vs8TmuDzLb^KpuHn{8VUY$^K4)6a-&-VvoL9Xh(>?B19WFLbEn59?s7rCe zm7REj?Yw9uz4!y}SEQrT9P!&ed0#nUGc3F%HSBprbH(u5dy>kLdnyD5Zp{@3?pIB2svEp} zfGDhM^t#AIMmM>0yW5Eq&7-_+bsP5w``i<7Y#+?iW?GsctqGV~5qsTzpB z-Z{0+@xMj0jnX=gLHe}qP^GBUsna_>c~d^^c&deP9LlX}*fHNd+L$EuErhkpPkqC} z{i)U$b9{H`8zJ@7R%W=o`?$z(i3(MVb$07KF=cb$&hO8xUQje@C90wTiZ(9B_!p3j z1SnWn-Q69O85wt^sR837LCeiQ7n;T_eBFf87TB2I0EV_~mXg#Qfdd zTyy=XzreSQBS~QsWRxe8d7CmlZ+0Y@AMfl0F&<&!i5Dsm!{i@}6{}Xccw~H1+_5LS zE4zh(2b#-IsB!1+KF6QbOBH9EZff22-o8tDwjj%4b1M=F<(pmQjxM;Vo z>s;Zavo$x_q8DC{%nA$-TdP-zldjh$S( zEPvcohWzH6ZhoNtOGlipS(b^|SF8D@VIL!Hy58Kv3y+Kr+FAM|6rc8b)8s$d7jXUu z#pT)Gnnt$SGlhm!Eq2}%E)|_}({$SdY-s5}2WmSpAO4aR;3b{;-qgd}&%ohrHH%{Q znZZm&KNCl(pwzdcG`f*zMwjUiUy4z@yy6#j`_}yt`&Hw@*XbJ;v(-CiX4oQD&MbH} zuSn+Tm5Zlt`J=Cr9L1=koC<{xMJ8iVVCSZjg7lda{?{Io+VwPVx6*xJ^$w6!IDfQf z*d>A^^oR4Ac?KqpU&76Nw>9Yejfw?Yz6AAG4|MCC0`FyYV%*&~@@&*ODlj1}n%a5_ zj~IiS^?yVPDALcYn!yBB{~ z&%H~|UpUz;97ju`3VE@S1)+Ir__5hY5M=T-ZrxPW)ZRI)1Ol&$mzA$?1%0;hh|1G1 z=XV7msZK&;_|A;-&*{!%%c0Zz&v6J#QzgRbrRin09KRl?BVY{TCV)=7*(G5DH(`p_ zf)6g57FizAE8UaZKPh_kJGbP<%97--m~k1b9$nTww;ILVowj-&^AZ@{Vw?%ZEEtX{ zk^U`5e~s5IIcCM>ZDSV6^6=MTpEJC30{tF{;}PmgCVl??{XRw=VO%4X6g?gr#Ul28 zoBn2#AAIllA5ast&dL1LmbBfcH|4q-?TSG9<9PMW`}h9}m5)t!8APQxTp<~dD*~bi zWAE?K>X^UVyQgDTMo_HK>wmzt|95XSr3F*&@~@X=$|Gt9ZzE83oQSMm|M_$G=RPqN z3)J7?gJXVk_n}JrYmeWNiyw{j)B!iyMP>`jMiwd7slF}9oqKXwd=YshysVf3qKEt# z@{MscZ;)J&2Lgmn&Ui(R%C8)*2BA0vMgdD) z3I9RALTXG1CZ;ly#S_!o1FuOQy8WhyN5_`eI)zLez8TBfPF}{z_b7<(kmg&CqgtpZ zp_>#+VMwtDh5_?PM5tJ=KwI^?QZfm3Kv)$Z>C*r4+NU<S&I6?}v0ojOuUuhExinS``BsP4^K}_z%@njH>UUj zz?G4ap$w!h{8%O!iZVU41sGt$AE112s)Th2bjPpoxLvY^b{@N11v3T>4HPliM35_w zV!0BDoYAXCQvPDh7?TO!BgMKqUd8U-8g1@!{&(YZUkt^UM2SaK{y#i@2RPOJ|NgOO z93scaJhnx=CMbT%oLJ#c9fD)BFZk68KOv1R*B#JdA`^G|6I@Y z9Sx4p=RIDp`*n}3KUU|zTOIs;Jvg)vZRs@vq}ko5)}RdM<&RaTqzr8^w*X@jz4Or6 zboq%r7^x6LLtv$Q4_&B`t^?r)lU@9(VAM7!gHS>ZD&6C7k@b1pz8ggx24uU{pZ#|L za`xMczdxfpFxIkR|GrD3wo~dVJb<(lOmEnFQm#Zfzo3k?&`)=s8ECQDyY0F6C|Ih` zifIOxqX*C4b66+Me21DqPH|;-arpE1EsI0}RZgLh@`TCc*n5VXv%>?7+zNQxP~dl`>Ipe{LdC(GlWbVDoSuQ(FAftDOo z6S~#qU~m7$M+0+hTyrfN`LjH*X@a zgNOkOJxH$){{Z7A@!zg0jZKTxHI{us9e=|wK29f{+MY9XP$I$LDa)dH#_k!Ds_^J< z9FK3X9KF6Q`lTC3P5OrY;Md8;eZ=O)j|^U4^HJM_DpgWX1vELSfw|ggTb(J)TFg%2 zPuJVjM4hs1d~`k{*;3;9c%bO-tlItcsxWz)Q5|CGlV!E&QjTciZ?dJITz_9++KFpf zG}GT8MR=OE<9JFJY3;fdMzOPpx|=F>POcfqQR9)1?DTtj$&fKvZsW#{n>P8M+^{TwUMkWpXn zUQd$n)+DzNK+e?F)O5ry?b*j?cx}AGaf8zSnZ2bmb;eRZm?i@-F~ZELyLQgeS9cR7O0Kj++q=h2KTGiBi$)CL}w*Bzg zV#9aj@L~72S8>;E1)tfwe>?OZzK_ctPhheMao<+>+u#a9UrJYlKLZ|&vN>BS=lC(( zmJ-ipk~o{jk+tFpxeRc*xoSsuTEauhMjiTq_3qNIKZf3|{aWdHMVlN$VikT{TN@Yo z7pFk@#!H7YX7+Ml?SB@+c8k~YZ|GFjJrYlAOc3XcTaG2mB2TDwXqeteaxjZr*ib0r zZOKK}urF*P%;}PQ&JIsAW=qAe)lHjd*N@Yk6R+d+4{HB6%3UetFUzEGX8ZbArx?>D zd3)a|#x~S4bbG>Ffi(}KB;Swao&fyYaXC#pQDNj$+tkDtDjU{FE3qihHZFRsD0M!! zA=F{(?n~T4rVs47DatxU?=iu?04Z*01o%M``cZutXJbsb0^Ejf6Gs_I$ER~P^mpA1 z7|_tfT+pKbVkml%_DYAg-kLJ83*5F^Fb6S!&%?o5kOxH1c9PQ*Han;vW zvoQ@eD?>?v3$jhKu&g@mS;KH>uR~a2niK??(vxrw>8h! zEMs||O=FX=H>GJg7wYt}iiR@YbUxNFQs)V=FkVZGsU$Os zY_$Bsvn0tW5nw!@!gKS?zICzJqi==picFS`6-iE}JRNfL=V(tjkNoLUxA^K?(j$yD zTty74dewDtI$5j(9Q5kbHev}S7-Ep=14yOHb;+mq`p_$78#uWX=w^pTNPpW%-Vhoj z#9E?%KYi$7qxm>7w4*1hB5b|hgrC-zI_Hm zdtpmUKZ9KMvH#~^JN0Gj>y}r8FPT4H*e&>+BI=o3knhUg5>x+s^{P$MLym?VVyCYB zcJh9n?&{FJ+x_^`W~FtzZ`&7deVsmim-XCLk49N%XXA5Gp zC#@&^*uZ5FEnpB+>#DNlW(%&eIDD#n zC<1Y-)GU7HfaybV2M$>+wA7f?FrE!-(hUk)#yEd` za#E31&&6dJsQKjN;I zAE7Of``F~LSq`u8kz+K3nvH{ zWZeW2?ihxxkmXR%{lt$YOQi;Z$HN@qf|rbbAccBj;HhAF8QYL$O!Qu%-q0{1&L|Ku z_CI(B$`ypLsCprDEr(s+=*D)ne_$+SzWG8tG;z)-7gTbER@XP0bb`m!4Z01M3`VtO zN`E2BvEjqd@&vtRZ8T(ggu54F;aN?IVI`If9b&Xgzm~PejPMh0nXvvnln0>l1iJPyKyM@;3*Gf*_M-+ ziFOEt56FSC3bFoB-*1e68NOK*)`V!mgM(KfGUXsso|>UE$MD?=zH=Bc4%h|rlQ#$K z;(o2Jdj@@?*!igvu%kfQOi;dr2IQe`1oL_jukZp`^DvLJgar-e?5Np=Rb1hy)Px~T z%(~?2i1pA`2#4NxrdJdJ68Yp?7}ZK3yA8p00M`L2dylPqroJ_zAjw`j*c8YER_D8ta(8=V9%sC&tHDBbD&qzkf`+sJ5IQd1W@chHKEZ{la_~lnVUkcQD$}Dq6^NVXOy> zu+~gf5Ri(U5vsHv3jl^a_$~jz;Jr>ZT!|!sqYjV2HbEJ&=Z+l)+&D}?F}OmFW!y1* z^Y^)bKopUu=9#roe&fopas0aRXyg zXKreWp?{@QQRS!aU^gi#D*oz~fJ@=6kku8X7BJ0LK{kPQ57Z8jd%)?~S*2Csm;-JZ z$m66wBw&Kr=cbbBEXdBrmbvY;JN)plYH-ZHf3P2@CnnVhQ^UtMbM`l8B^)^MKm)E7 z-M#zg)Y8Q}o_7G=fYdE41Dks54kyU(l_(k8B`@XeD@ zDuF!^?t`i#c|JpAE3k+KA%vXo2)vGnE*vlAQf|ECgF??ISR0sSMhIvET*jG0*3&7o zo_fpFcXBSyi4^rns=)?Nr1bLdIHE zmC?=bN@?^^L#qUr5{B-HA3u<4JODyEt^o$RzL6R3zK1}IkTwYYJDfqn#Tj&NlD>W! zAIGut5qwgRGkHG&x^PJQhw`xF?jFT_(>kz4tdpNVfBtulju(PjdIR%&JyB6n_!207 zRH;-NLB_)vJrtu4!hQ(BaaFfJ?1b&?3b94EJ4W)@WeBPltNr9+I#<2vSG1Z4$=I*&ehIX+Mq7au}Zx(YuhQWr?1xWK?x0Dn7~+U%vy!pI1vPafVc!6?A- z@>?j`3Nz=G?rys%7d0UZoVqv(cEUHL_8){5i0_exOTG3A&$@N6j@!4dSFz}+3f#DW z^6fG(0MQV1HGHZE_J6@7f~sI*0mru%&WYR%2@_iolYM<>0oVaFdVT7uIIHqJ_@h5S z^vwMO=mLYBrdO|yf+IoNhPrPj4g?%8dpidrb?EhOgGUp(>zC&BoSc48brEQbdYfsx zP^lPzW_+~%Ju)5y+#IS%>e|g`Xs!H;(oiik)vNr#o`d26*cXiTGMvF^kJZc4 zc)lo+9l`r+dHp(+#ufAnq^{Zw|M$_ScFo{yB`zgA24|Pud_5op5mu_mz5^R3c?=XZ zye%24YgaE_q8oBV5H0rL96@ur4~7-igw@VDJ1;M##wuu^VS9#KWP^eNeOB3D)ym_8 z-q7eNwZOgwnsrd|ATL*PEQdN4Tv>RXxuHI@K-2Pf%CoW86Q)%{#nbB)3$;cD_@qT`0>5n-~?Gbcn?o0T6De~5W% zE&mN|cdeZ>wG6mgnUp9pe+d>fDva4o;(Lf`i8vcLq+v-CD-_)^Y&4zeu%c@<;2D|T zFbtNnPc`C4(_7}S`X70AH6AW0RzfQJpN~`TwZJ+AN=Z+?j~8i?1>jSb;NkzzmPikP zx;AcR_)$A3wjFGDYj}lq#lW!>y%&SF>oC!Z*Q{#Vy`T+C9+L_5+tW`~{fa5A#f4ar@w`oIu%p7)k)~>RnyG z;hqZ2nIKo8f@t_A#>)VB+-F}zIRqH0uDu$z&VIhuU#%f$ zbgGt%LFLx8$g{kAdLqV23e6`@rHj)-vSn%&&h9?(H)i|(-Q;g)nXVZpUl-AIZrAg8 zvSjYU9cl4$WiQw3Pg`5>aP3}_Iqjz*J8?;AcPaeaT>M^MyVNl^S@v9I&%^1!f_rJH zb)9ZMBIT!6&VPOGqqWX;=dIXZo;UqR-`&e(j;3;i?|!3F={CG;VRbG!^y%6l@jy3v z5A~6*m}{iAU(>H2k4nC8mUe0$d@^}eok8Iechx<^L*Gw7nKU?`AUMleIqj=6Pg=cP z9igD;m;A7)`Et1j zwo~ZAUpkHXc$k3X4M`BiRG@0c81v~fMApN+h_iuDKp;LJ z!F}8>tbCM0nN!q){5MP1s&T`k1L73CYjn&tzuxX(%>!A|Bn4%wKN_NzQcX~$e~rIY zRl1y#qT=>C6*Xir%w=^>tWVgL;a7atj36IF`hF>Dl3?KX>jK(vzV-K--Hj zg`e1v`tfvGs({M~hF(t(!KdJ=)+a}zhwDdcMOXzA6xv3fMh&1MknSVG7{v@`ZswMj z*We?EKAKUk4Tb(%US6=q(N-O)b-I3G-eKqr?}}0{&ZrO=zVIvB?^8-EE-ogREND~K z#g7pn$-R4u^71%cVXCZk<{MwkO%&0nhw+o@Y&)qCQH8!ZpL(vFCVqxv<3k6&vkTi^ zoYF04p5^WA>Ov{Phkh(JOd+n9tR`V#|IvQaeL`LeO#fd z11QOd9p+IDRKR*-F=8=RE|%&{I;C4BE;8@(7TxHTw6q$4G|DrgFsDO@1z>}C78vLF z@RJzfN4mq<*wolKL(Q`jIcca{05mAH_&Pfe;RfB@SbM>U6%v)5m?{sEz#V0avgA_` z|Hihc@nJam%nOF<3FtV{5F*Fg+{O;I9hw)yPW3wSC>-rymwFvL_6?=x5%p|fB!~7m zNXO^_F9$UlU+^M{ZYM9>YLZ87RG6Zb_4ieaI zB;$hBTSyeyR*LH`u#9xPIPyN*?aIq;{j&+$$>D#o7-Sc^p~|J!D#(pEFP{5RMxn&rv908$d5W70iJwUCCPVToRG!KuQBXkHQ|grxi@M@ zlXuFK_!ocs*%Y-H1=ylX0?XGgUkhjs4H?fHnacYfa3a2C%UtDXLVu9GWG!9D>8+oW z=GEPLwlKMz+~9Dwo{@U;ym zB_#4t9Uy-i%2e5zcPN+x&~&f9J~k4c#YV_^;E%^y06%Da09nu}Rz~4viW)pQ2VQxh z%!dGmIXXI`OotCGR~{cN+3|tX6F*NUr)x?r=o!J;&xHOC8Y}9phHmB)EbaT?I!X|I za9aY%j>mxnw-EqxprsQF>uV=d=xk~#9}IMXf_?9=7D#?E8#mk3)P&!SP-0Ww?&k3D z;lmZQ(k`uDAIs%&65=0pLe(D0_9uK)FF8bOm}$ zB|X13F-|tp))gDtsyqY9f@YLl9Qixx)eg|;Xzdt-m7-6J;l~|7SQSik92t$dbp67L zV9-HKM^9|1-T%T9X1rR;%F4I{p@hRRXy@%cjSmzXT2NE&{*v#ZD2)*=1HlKA%0b92YC-{8u+Z4$}0Na53s zw1Hj8dJ1KtrWRua__bveAe{U`<-pzh_o*-WHLrzv3HK%Pu@X+~ILL<60pR@u%@N;v z6sQWL3!`q+>(}%wF=UXGECB`LtOp1QBxPgE?2(H*3%kA|4rLPP@S3cA6iqTRU!V|v z0!UidX8Z@ECJ2p;9BVAP(3n_3+X@TLo@xSFmNpSmbw@Ka^n%3fQ0aXTEj#pUXxU(p zXr||e^*YvkS=Pjbt#n=Mb!{=poux`4Wi#0!Z~pCtbyTZXNJvQ2#y0qgm@+l> zJ7CAcn)ko~kplnZn3s@%vP8zaIjjJVB@EjO)J;OUfFQki`LgHz`%MR*@TFbEl}tcU zn1;#5eNUl6WUpW1U&Yn5IWj6rFV^$onK1Fh>6d4k?OB40{|jPIYIaR=`{1hb08vPQ z7)Vu69p~7ys5ZH()E!MQIs&;&%a~)8k3>O?NeXfmUIRXalQi%SP;JOTpi{QW!Oxdft`?s!^i zs?wnvP3Y3?Met63|Mmm&-S}884sED~nb~CL%@&ADhh~7*@&{(_u(M^3^U~R#4gZ9= z+6hb39~gHg>l$(O!tb$km#P0D6PwiQ*TuTGg_$`#u#axYD;U3#o$-;`#5TEsj``=( zCsChhX>!V}_#O79Y>`*GC-N2P)JS%lIibJn-w7~?L840jYy`9Ny+RNEL^qSqu1XTr zW{PCtdu#Q73XFZLdV_;a6~D0Nw=FQq1v*R@w$`sZFmnqJnb+kKDH@itD9}PSyYoo zNHKJX)q3n=#V;nNjai_^pZW~^;ay20o-WTxwhm~By6IAm^{4!R473;v&%jDhq8z+@ zfY`N^v)gmd%$=zXi9UVCOlWP*ewgB2z6=h}&}hi~l*4~=coAV?j>D|ZU@b~FN5boO z;ORCgsT;TpuzfbPltK&+X9Tbck{L;-$#0j@2ZpSj>N-{d)Jyv7E%&lO(Cbi8PNG@F zt=Fn-4wwU)Hqh*hE7Yp{op1eT@PNbRu)z6oEqNoHhTT@QvrSx>13_GkP z5MNMIa;hWWaA=$En}#FFwWFpmex01deOY@Gnd0zxLd`dc(RLia;?U<}{^*yiGW2@J zdcY$`{}O9r$B{

VF-}FX$149Hf-0j0Yq_A2BG1<(uGl#Lg2>C98wC-lR+Ce+JQSfL?8SihwmWezdjVz-&pm@K6ApNs%!5~OVY>RjLl6K`)6$$g583?Z@?{d_hi!rl z9x(%Fdy%$Frp3i!z!>2Cfp!hsP3IX4cNvZjAV;8Cu$6zQyNnK&(ca2-D*gU?nueT7 zCuSYSOnakTFkOSkGbQTh{Cw0Q@D6wLRiMH}{>cTLUYnX;1MAEo>E@dyXh+nMKKjsvm`}GIt8iL(TK3L;kPt?X z#_YLmrx5@0aLK+@o&<0Ev%=y^v{R(dSNx5OG5V|XkX@7CbBxY7T-WS#=d>`>tu9Yx z3h2U0ZKDeSXVE{`^_?7IEtHGn=V?qa@7lLm^mKZo)h%}3e4HOE#=667=M`3jZmJ5R7zt^F))Y%WQg7HQ47KWCs%N@>?E`CXc^ zW60X4SL&!2IR_0m?hlY+JAYT4d#kv#zg8ry%U_y^UDUYfv4bSe@lv?xn-AA)A7la- z0!Z3fg`Q0=&kb+(7})$yHe;qvH)`sG5RNX9&WcthT`Sh!ZLsuUDaCABf&Gp6NO28A zsY#a~7Cp>t4rt>#m)!13YOv*!?Of-staBd1v$1}XZs;95M@M$smg&&*!NFQQG3nH= z04!_QN9A!SG@`+!>T`&b$n`6`pWXj*{qlIAj#|^^MNq9TYR<;gIg<>*4bsu6#Wb(~ zli;7*K!S>QNM=N`)V?ZJqOy~s1(p@$bQ{h{N=XSunV6ej-Fi|~ZBw2u2uHvLajTJ% z7=CIm3Wgrc)#u5PTikf5f;zSqQ9D5#SpxL#KK`|nht7ZB5^!O{yGn>}*O{YLYv&~5 z@MEr&06v|1cO$ng^s;m7I+wBzipRzi; z=M8t#h7$ zBOdr3=n64#RVIAL!W7qfaj9g~@uR?Ub$6$a;LS=mj;|`aru!+&fJ=ugHlV1WV460D zf=hBgw*|^g26+xDpR7qIjFWQ9#7jHd+hL`T^D$H!K#M&_jQ|W$pd)7KU0d6x?b|;d ztGK2aI4hH?B);?lcNp5XU&&c?w@&d)@=5#O-H|tz5?5VpT2B$z(fo5`SxCpR*ypm~ ziLwu_$&N9o- zQ@n0;4^(GvyY_@$qu**tzlwN0N8TzeaeeYXF<4tVq!BNkV%)yiUOYo9_{j2tGe(m~ zK146{r0tVOne`o%k+GKalfA`t{eda5Q#CzQPXEws=h0ezXTvBS%G$L!m(emd)i4df z)cQ2Q&llGt()Qw-gmD~9?{bUSa=C590)iLA%td%J3|Mgh;%0MjFrkV$e?g(k$ii|t z@(4NtnBjniPo$dx`~frtRFUJ`e&xqTNg}Z;a-Uh~z9_D-B$u8(G{mX^;}yI6aldzv z-YM8KsaO-v4Zj7ZVuqaHE%gDU{>Q{V>VI)`SMQ+y8^4RGYQUlqf{7a4ZYu-Vu(eV+?Qhd_9Ehb#XnvpT(Cwaj^6n&U zc#W>z!auootCjLujVCWZv^jM|XBn+BEW7pDhQpnOrs4ihC zJSl=SIXLkesp#>hcr5X5zbH*L!~ZKR_B*AoaG0iCUuw|I%0S+7opb1KacB2~_o^cCb|&X6 zisVgF?C*j8e(*-aK{BWFxzFEC4joAVKMev#mz&4g9vi`vdL*{#M{)yI$15}?cfV>^ z%drajezwPM+_jm@%vZy~8qI(ZlST+3u`5|iZd|!9~r;aFFoIx7;L3X$jO>GyW8mUYwkc2V7?MR(^l?d>xxnS~_boL$aW*A`Td(}SEsC)TDX!fN7lTyRV-jeA2P;rweG3yt( z*+bK{L9PyIE`nKwA&bM&aPA6dqSjC9)bbCh+wiUDdIj?e>`s_+{e!~@(J27$1;bft zcn#^+kZ0a!^ra8(-^ZEiimd>r2w?0z$F``kKGEAi9!|OBEm^{tQb=Oxm6~Gd?4&v~ zGf>z`I>Mm7%!;ipzCwgp_Vw%9sMVreh{z#Za=V3$JVQhE{ciMEF zN~)E1JP8l}=ytPBrzC7d1Dp{IrhplGECc-2PqcpKM3{Jow+S%n@)=xgQVbiem9O)l z3$END8*AW}+s7fOs;n4zEn0*9DXU=RbiGirFr7b*WUi}Ach$D;c%4OjeEiOVOQbqw ztxaEI4){8xA8awEU2OLx{FS?hl~R-wYl zV7xU|^;CLb*j|S2E8XJ}<}w*S_NFjO96g%vGNHA?*4FS$tnPi7nXEVC{CQVRebsvY z+Gw3Z8Nq;jxg;^|8ekS=2h3syK?6f>3;_zNbc)~RK2rN{^U!4bq4tYI6@9xnGM_6) z;ZMu}OhQD{?{6OzB{t`wKRx)wkIv;nTt15Z0YLN@)#+Yq#;!YWP;5-eF4xv%D`$Ik zdU`WB=TwJqWmUT!q>H;mDeq1#WT*um+Sk%IIabSYOf3sOWT4G9+yHaxx^{?`v@{cY z=w*3k*;AA!wf(fL64z1aLqKB445Yd(zlj( z%bC>+zxE4~M{W7C_^R(1X?@tPCM8b#8+=h*Ggs3h{>P=Y^!K|=r3NWY6Q4s=5|b0g z;=jqI8-9|fcI-3Acy+a_$(hPwEp34 z@fwTL6Z>SuXKy~+ltxXEi%n5ZF{$e`i+P+)Y-^f3sYES%%3KG9u{_Ap9^#3CI3^0N(zJfll0%UYKm zc|kS#ZI*oKPXW%P)1o$X;YUl9j#qxlR_Py_=t)>b3mq+Zqh^FP8*bB=uU=)pareC%rMMXI7jfNB4m{28uBR2JW?XlIga|cOJ5ouIV&%Hs@V4*y8)!Ba1n38sCci zOZxf3{5%?SOLKF^uwXr@Geu`PZa~DFG+ukMI{i&D^$q~+7a^oSvfmjpccQ9;!+Q!c za(%74&W0E-A}xFk2ghZqJPNWb`iHyW+5)#?#MOYYk&^@I*KlESyHs30CMG!Pc=WK# zwS9(2xrx&Xbknb8Eh4|xQBA@xk>SwEyWZJ{{#IvBW{a}<{-rcvqn~usDV?jl1p}JCT2vEkP~TVY$>7AhP)6`39v>M*357~p zs*7c!8C{_G-uw;^FZE9-%j)9U!%06Z;Q-*mo>5=CIn-z^Bb_KSz3PiiCMkTPq6j3} zW}91$?GNkCF)_v@9pV0eVJLhfuk(LN((hIFGc$bp0Xyn;ED-{S*1`Vu9IJ`OMV_5DC8B1F|#+1BMpKs3Oh7j5~0>a!1!=XUx_ z!mjLPH+6>CiOYK!>{-yfRycy&C{^7*U2|OW($JS56QT<#Zrdaz=;Fdqkyl&i9R%Ms zDylw(MutNd>BRO7Cg70B!aT5%JRJ5!2CUj*OJM4&;dy$?npOS_eLRt)!M}19;tsxw zSYy-n)b@&kPl&{ujP29em+Q(VLA4Ei#6e*b;CI~kbC73UF`(Mm)E6Db1#)bg z_qlt-=1J4sV)b~9csH2rOG`F4iQ(PMyE#Sv>vPr`VJ)uvXQ43LU2f39+Yl3zChfRR z?uCS3Y)VnaVeeP&3;MHeIs102tT%X9G}`CPFf=x1NXdHt&6DlLkCJ$CxuC8SY|9my zx%4!VKF=Q$ZJnzU7pnx-=D%542;AA3VVfkF4oTHt;c_P99lLhzYFJ$fJGN_BG3Tm_ z+DETPVBa*CYJ#qau7ui9XHUdNm={cZE@S{xqM-KDo!J5BH;vE>fEy$e9Tb! z+8*TKVf3)T%)+#`v|z``Y<_I|l62Ua80N~JV>nq*-tjVqH3ewlCbU`@AWd(Y&{|3v~FPQZx;k^@Jb@Pzdh(=Di0 zVDkmWLWh>08=Ds3$BII>N0T<6)=>sgOoHAV;f>}Gh50-<3SJ8S_Pg6ecS>w$9XbON zSim*#gs2*|IIiK7rgh;@Xnd&75zN;bFN$%0?y$Jtez*jgcsZ8iaD-pMFa$&ZI?asg zUGQN7<~T&k`{IfpXeN-C6C7cSv2|eoy>tlq1&svK^nXI5C?hBKSgkcrCXs za~K6jiPdt4zN+d>yEwslIYO0UGcM&(vn!w?bEhlNq=N@@V07o}tCApcn}hz39phU> z4#Ia6Ab;^$DVk%Yw?!wP=I=`n{fa;iNg9Fi1me_54pG(?V35+qBjW)t6-ss!^r#s! zutN%Z@t;pBrq}C-z8wOhh?JF=umo&+-101jmKryvq}%FKlbw9@1_#TpzkRn4-#k=YH?+22+)( ziuJ+M|GZT)r_P?opcgnay0t{|u}_aS@B~Zzp)ziJ!t2AG#SEuZBF`FOqoCu0kV624 zO^f&y!25me?K9vCs&8UH093u~15D|0@|azNt9?{$QE?=*fdv2IKp zsR9hMgouO?Yy=p5$jj3e5Cmp8Zvt3C(1UOx77C<tF}X z6o|f1bA*p>b@{n<i}CU2!FdEUHvk<=OE5E^CLaIW4* zd(*Qo-|O*&+6?E)v9b?3<>}p=UgruKrX^g1H9__Us|bHZ&5(tW89y7Q(3L1x^7HAV zwk8WAtrtin$w+?VMoVMkiS9dHcP`(eq+zy!UG?~`hhh^uF?zryLm2R*cLn_gQ62Xk z%5trXU)}6JHi!S2w&@VcbPSTWfa~OthNp6}@)O(2d2B|g833i)@*#tCbG>N||G$EM zZxB%tZs6a426Xfiw_IFW284WHkK=`0s_sh+GqfI>idGqcM zMfmv%+huspzRgq}H)Z8EH^=3wE448A$?MUVSNm1ZE-(H>bVj0>_5jl0Th~RoK~2Ig zu93hP-O$nAPNq;mI3Gtxr<3vl>=L6Wu}c?^i~8W;Zo??m#3U1pSg>TA%RYcz4t+4` zp0H~Qi1={qVPRr|ta57b`JprwZ6+y-n~^HH?+IGyk3{REyofq==eCe!tN@W zxUc%X(a6+hY(63yG@}dS!wN@-Gz&{hQOyA5SEmSy^KM;*(r6aOAV9Jh9AH6dp8I{f zVc41S1-xtEA*aX1gW)X(st=HVJU<{UMJ;1-F)@^_-dB^-%?(I&B$A1NuAS*k!dYCL zM2MTQ&o#mQL_})nzdeAW2$ceeA%VZ#lXI!QC*t}$LU(9r_-C$uYHK>XuDr)MeAKNi zP1HNwD>wTT+49zI-B0}0A2_?&cKXGc{3+XUDbCgXaYz(ta#W6Wa_b#l7^Qr@E5w@M zb%fm6?lD-SC)>%fPs-V}Rw4cN-3ko@)BJr74~!J}H^k=0_1u;-8a)`Jt71e^jmhWH zll;NUlP|3#Wpty{hf7w^xKQ~Cm9b5kazyDhCD!<6&x~bs)=hGLfK#d=)jCBfR>5(D znPysGn!9u>bV?OjyoPi~9E~Va%+0)Mn++u#(%`+?@=f`Tv(jr7X?96{I`0@alf(Ny ze)ZYRsd(f?ak*R*#f9d)Z+H6M7@jM`KGCI7Z0pWSzhF;x^S{8b@IdcZ0K5EKW?RyG z`^AS}3$H4N^gr9!q2}?$_?LjjqJjQW>EEl_7UGL{xqPX+MF0ILI6_kSMKU0wZV8>oQ0;*e@mA9K68LaqF8_-?qJ)c;d@ zx0WjLsiN82U~O&k(^e%pW{TT*I$822ZJ3o)_jddu7{(y$V>tdCw5VJ*lGijgvFJXU z4kt}7(BLY|iooavV>-5sx6{uhZt*Ve>|my?Y*12K1~w)dyf{pVghVvRFG_O>CF+jm zu-l%YiLE@L#e~ICj%E7QtDJWhrt?>wF_1+FG$_wS_wW90&xVf<3zrB*#kC1&+$zlQ zBx%SK{0nuejI8W)sMpD5t%$zm=9c|TLOca5C(EL)P)Pg+~0Cp_=JqEztVlCB6CdihM=xjN40Tl#T5dSbm zga`nsy@hyUUVT-YwX}j^nIRs8;2MaEKR~2Sp&;n0N;01QR6%Gl`OY77-iQN6K}Tr) zK?2ptL*a=Mc_VUFeCxW00)u<*?tymr2Wl1xdIi>cV8USF@A6g0WD{ELY-N}54i6YP zL6dP-KwNLDgZq-9UxeF3K52zbn+Cyigs7h&0k08MVY~`{hZDh;JiTd%uPb%is4h%_ ziSGIwl=2u1{>CHIJ8=wRBpSTD3I1EIp3IBZzIdbLNj@YD0pS39cdd*$Nk)lL6I@iC zf}76wfj!={yNi2`!&Evxe;+uSh%y|iOtHNzjFU<;P^IcHCZHyrkP+$z>62+FX~cpT zT~HW7qE50Sk}^P6C2q^glP86A!gn2f?CS=l1I{kOiiVbKbmIf=6GS-$z4xwMYcY?6 zQ$6!|19Jj2MBMy!4+N|w52 zkBvhKSWBpD5ZiDVlJk5y2>ziu>osV3+iOZAeb3>9n#*sd6SrsKz-?Hl4Q60Gt-?w7!biq2)pz?sP1s8 zf+FnO+#leRKxh2Ef8>AdtRa$w#XykfQS?O4)2^JuSEdGi^0P9db{miJFPs$@(k-xv zxkj0I4~2{*#?Ft7a8+WuFpA9xb26mZIDzQf%c~Rh0)!(FV%bc1vjV$L(`6b^Mo=+> zNe@S=v)~0MZ>d*q#Z?PlMc4(7&r>KfvhaoeTZsMsNdHB1{h;LJpcbW4aqmOg)H6uk zat^RaFmhbzYP2zTsk-rLN1ez4Zj1LoXMpAR0*}~nL#1Yn{2YJqN}Hs3#2$5%l@i|&Pqsg0 z{PJgN0)^&z`|({)wwIdkj%%E|RQBdd++&Et3x@#dDpNQ`bxnW2;tTM-aeS_IS~??Z zoqK_2R$pqLklN;~pA9}0U%s}AE@?MtWgOraA1_5lD}`)*A>dYEVWy9ICW7 z@=%q#iAn4SFCz)g6^v-`JRuKuso9NitBmpu;e`c%I8RAVUbe#2y4-OTZnJodYB zStt9Ku9hu0ON0r))Xmx>_nMmRpAC`RDHsg5m z{HISu3w7@Iw|hmn?V=+j^u;^wjyA;2WnaCDqxER9`ooR)7J`9e;S=WZ?d5xkmO*Q& z*=hiisyeR7m}>!_W7{rFpFVQrh%P5A=nxGO!D6U!4oUN)FX3Q7-yrNn*b@x56sdhB zv?0R-AVY>npXq%X+qB-luLQORTc0nz*|q+R9r%A$r$#nZ_RU6!Ejt5HPyYHpWo`~n ztMayOOE1=1*$I*kExq1re}5m(@|yal%*|$3&waZC@$Q-j%wxUwN;yMWww``@qesDsD-gU2h1E_{d3Q#icH|HsFFL;Bos?noS78(DL6$8^=m zrj^f|g6EHhAp8gVM4=!|3(RWSA8waMmqW3zZ=!k`-oJW13qv>|z>+Oj1T$4z0}M{U2KW^Zu5nAoR>y;(D}wCU;BHz(Qi;&I2;?yZ@<;?3;!hHm-py@%lYLQxhRbCK8pzv0};lqTMk zSM%@tNo}81hLeGl`;)Z(OenSBW;+WDn?c`}4SO6Lr`#?e3PkKrd-TJq&v8eRwt<@8 zhh;hV=Ut_x*3u|CUxjPuP+Pb;1kaaUUk^Pqc=tCtD59b(D7M5n`xFmgD-xB6@Ze%2 zxGu6LrKnxu-1!I6h}*Y2@q^zFrf}{p==!P&BLRtxrR)QwVysvwpX0vez#MQ?Ryz;~ zZO!zVA`Th49glu2-m?0)yXPU#HEp}I`ZwP>e)6uw1+=bdBG+#K9Man<_W3ka*n4#+ zT*}6u!GeM-f@;S^$cDLth8Rv>nA~rrG0b>`Xyv^LwVZdAMDlY(n}no9q8dgXB|EeK z+r*0(30qXWt-No6Z??P_@$WkY)xK)){4whJAHdi3)6Ly3s7osE- zi0XyC^0W=#yN_TlX)j&YX5;lb(n@RxvDKeqz@i=$oE-QI(Q7a?d|QeF-(dg1+HTF( zJ|B38tPcG6=X6EmVA>cy8;Dx&u4@UMn=dEFnHWUN{5u))_mh?8PXqU0XkCf7ec;bW zvj%A{8%07i4oTpzhyKrw&wcB8`!x8 zrWc@$+tbpW5Z(u|iD0OYl@B@BO&w5tO`<~a_eC~tr2t5~1elDTcDM?ovy+Mt&JKg2pZBa}z z2gqMDW???OS-N%z2nbB-jYJSpoiyM>48e4F()_eQT2m!RUZ72LhStkr|+6 z@I)LDGyJkaYU7Ac=5-UZoO9jaWO$cq{%5p;m~H5GQi`+C&;OVx{cTc@Tj=WLBgS@S z2a>{X`$TRmi;2(2%)LKr1k0u-hESueA%+=r_Z-6ySaAlu*8sYyg)rc5)-HVFHym16 zb_sd#HGJ;A=ABW~#gWg2kM#?>_K_ZoVASSNoX5sf5kYRd_oH#*Y4^7?PwRPE$MiJW zu9*nM)93xaOQb>z)@!-NIIUZdD;rkt)t^NcS^>A3>CE?zrWX9(v#WGfhI3`|gZsl6 zq@KPmmD}oBY1Qd@ZfjY^5jAfCMnaILeXVNkUpQNJ4$tv2BW132`y7>=^yfI=js$Lh z(Zu{sjvBiGt~W+ao>$-J4SlCRo_Z+#l3a7T)yix4o*EasW1ro1+WdRfMs`-PoeW1J zaS$Ui8+L+?nKaxG#-pFBRFnUHwL&-@mJ6+JjK*PA%Bt(n9#uU5>qP$3AIjc&{NhRX zo{+u0=_9-{GOxKWc0FIW_rEEp(Ujyp1R{2?pS$N)q8(0(4hk7LjXK*lEZyJTg~{39 zqms#AniyoLel!2K6f+wi)B&x;&ccF$Br?FN_WnIG-Q3^#a16CH3t99k2p-(=DqtGV zxxd(T0>0%ZrLFYVtkPqkOD0FUWH*q&+Y?eqNx$i79%i8%SK*Q4rO$Nf`}~A)J{^8Q z_||N z?PZ%Md&&bi{D}Sea4`i$L_8FTj%$?4?M{p}RNc(wQaEQ`YYu8?677Y3Bk4K(yR1jl z7`}|0ecM5#;W-l&f|5?tG+k*jC)Rw(4?q_Cnuxa36Q=7U$T{)pCqjT&ao8JB4Kcg4^vPMzyXR7xnzcpNg{F}5_I;CP(aGF9vW0)HS@v~49&9K8xrDzo zRpw?5W;um{yZH&FffQG1X8?DoDTd1t-P71Cx%hw-N40y8v~4RD2)`y&UM*4X=_jDE z`C;)zk8EW(p9SzddUT%}v~GNz`1d?cU_fvKb&3!Ox2v@@2UtHTv8mkixGNbm4hLhvuDY|lWA-YVA-a>mjK zCKdwfkL+PK?OEF=5;f6sr~hq1-LP`Cv`2KZX9#p6 zokTrK;xY-GP6y7(dKNu;pEm!Un*MDrz~0}1{9~%2YAr=`+vE=Wb~A9X8^0$MZ$j&A zrJbtl&{76Jsh!i+gfx@uEa|V>zzkG?#3#PG7X4@yebk zwiYPz0C1mQN2fel&G#`z&!#J=mz{gJTK>Paft> zGjF{t?SESt1DyPx!f%m%g|o9nE#$XwNuhCTXQ~Dt(5;@ZHL{0c>91lvqfcdBa9$eu zJTw^u&WFTFd{-@bi_xJXhoekrOQm4_Sap{nL;LvaYbOE(x!r9X0}5%qH%I&M>QWrT z?N-b$t(9bL=hyhzmcbQ`%EEim`GT|{&tV4FApq(?VigCi?~_Z$KpU@Je0R&qc+Q?Ls=s-1|JsO> z`QCfQQHG9IG#{*V-9TV;v*ITrY*XERA>(D)wYbFEbq%s2MG=9!{sHfNL&Ws@SG|=$ z__i)p$yag+)d=@+xE%7f zlm)q>=yfn|L8`8GxMPj&44RPwP&xBlcXMZstf<@$72yAg>;}X6^K(!JqE@5)IiS-N zGIL4{>+lgyB0?j>GS@{HKH$3N1sS7#LoF^ZQXMEOmzFJO)7bFKZACWUu)lDdo>jvz ztD5IroLNL-!ED%-2hDon)#`Q+ergU+RNs``MH-yKd;Q*FF<@LCjT5vC{D@t~i2hH- zQ0lcvaPmMG0kzysQhL{~NUHcYKn%@iS${pt?_t{W>?!`zhNf4o=~k)vifvM*>0z2$ z)fw1p*EVh<8zE8~Ep37sjxF0Z0aNjwl)D%Tld~PfS4J8JGUx|0d_+A42ZI(5Guk&D zwT_t=&oLcaja^B@HR;VXK8yu@#()g_E~s^G)?6(8q)-)-d*B{u1B;6dMpj?+@b?ph ze{r)5jlnbQ0KbdaGp#E@u2q{#10@#h$(;~KF{*HG=(zDT)}4ToO}LDqG{CpcK?7vr zqoGKJS)r#=owjflmixAnAe22+o{W>kMbE#jo}5 zXki@7ggeA&>YpkwY-f0zRSS4@fmjuXx-75R7(-W}e93nxe&qxb{%1Da2ftTdoDWy! z_rW17EpQfl`ulea(gUg1X*_7me>nrRR02wgf0yDs_ca^fz6@xqMqJE;Khe4cb`L-0 zoM?V@xp0F0rjHiLKS){?dJ^lEdspLMBl%}>q|@+6#YyR6V9vGwz%u(fI!{+6_+ynn z-at0$kjL*eEj%c2CDAKaSN92bRXQ1^Q@k}p2!W#6B`RGUPylLRM);3ZGr*7QcmrfA z0;L+rEqJ@KP{b7wM&*)h{l{(v+hT_`>JdqjNG|||;N0B@P`VerNv6k4;wmoUiC}qW zZh^Z9;PBesHhL=)oB`0M&;*{+#v)W|hZ`~LMIoHSMfTz->M9C|dG_*CYO#zw4z@_) z^7Z3j1}fr{{f*a&UXeY6!^!jY;ir{IyveRw~g5 z#BB$^bu3_CKZ-4V^=>wnK2a=@shp^RwbCt9 z9Z)i;e{;jP92rvURlFkuzo%4)b}dSYO)$^E-x;>ri?y_K2C8@ zRxOmeeV9X~tPp0>9Qu+fA<)TWI?cwtudW3%<6Huc79VC=tTpqT1*3in*~o7^gQUbt z=UU}|MknhzbpBGw?(o8trTVLUTSLO16=jWNX zXDI@M6pK|r$w<{QY$p@@FmrR<>A%Sq0P&)kt`>IV(yUx}FzO0nY`=(A4|vIh)e$`G zF9WVbTcQTz{8V4+`9-H{CPC=0se7HuVG(i#!XKvE&|rl+pw0r{;I{XCH=CVA+)&|pq@W}kQ~3b zy52$RHDar6M0Sc*a2NJ<*L3DApxO!dG{BI~!D>=<(TZ(k15lm-z2tS~d2*)f^{y79 zQli}IpUwYY_#)#rDU^wzN28&)NB`MU5fO(AsDu!}t9*NV_-6E&YV>$#^mb?5kWy-6 z9ncP3a$);Hy_AbHpaNNg9O1*)mK*A-fD>a1 zgi3sS6m%da`MH^@=g24DqKJ27+hklX>v!HjZj5+RSol94TaB?8O#i%9N4t3gHo+~;Ari4fT9?1MIkr(iGqNBx7C&-D1ccP zs6#XaB>_T2#xy6j;2}=dh0We+ee3m5hNdBZ_u2C+LeEvIPwARhnNTW}A~_)?NHz)9JL=<~R+K4d2hR-iI8sZ~Cz_26sEK#M06euIoRntsq>zuhTq{ zeeAJEN<5oA*U&uNUES)OPgPsKgJxy<_b#Jk%|{5ODj$_>liq0=-sL{!+uL$~YB5>Y z(Yp)XFK0~@!YO9b`pEZs^yl@dRsU@eVZAG3cD3PmedCh;#?d8&Qc9IsuV(;nImMVGrrbp)sSk3e#z-E6bVOiMod~cE&7NKVTh@OF+p^KZ}8|X}?y-e1~SD z_`j*}Kq4|6+d{1Id8FTQbxtxN|H9TH3_h9*BSeBCASgb9yb@5JNZ-HL^_<+l(zp`* z&AJbNeATgsVy;EPAF%`&Z~iFF zO2rUw24a-IgW(*`S9N)t%S2dT5HHfiQM4-;!OIB*hG&&zfQqW39xycfN@7`ZQ9R>m zSOnVJdTMR5kdh-36PjJYj0@t@7pL3#<%p46!UgPhqdDo7hZCOxe5lB5)vX(!%oXpaA9GOWW0`_FHDs#vh*&J ztzt~Q|Ah5AYE-fC#d?q3-Vs%VMzrO?>C%TobzevX;lk6=5t~ppmyrK3bzYk50#mn`al*WB9M6H{= zp0KLKbM*8FXIRxGh1W{&gC( z$}!{Yuek_3H?R~BePFP{vHfUvB*Bh@+dnRv&v|oUbHMa9PSY0I^A18WHVx%oAo)ZC zKx+B7WPk_;P+1EitsG#U`!W2Tf$k61DpAPRTl(9LQE;Nx^Bm=U$SOu$DsU39>U|hy zg=h0=hPCH>|3nAw6GZ+46q0S5Uk~4&^Y7rRz*0`oof(mck^@3}4By%se+zPSI36wp zfc(@rI^?Oah`!K&yAM-ZI&dw`7HTOjI7nEzJ{2FK$rb!e>>T>mc5zT|nkhjxC|14y zI^flCmRl~qOCc>a_BGQk>F)4@G9T8;TnrA`QOj_Mj-6>zTgApBF!5-J?+4w9Zt z!xVIqdhg%s{s+Mpuyx2&i3KX*ynSx=bAO^+LU`iTGSrd}O&#VxE#RFvpVi06V|?ED zqDzfLB(bp3?DDI;INn%!u8L2P>!$h5Pb?hs9TV9n5uO|Lc67-=6)b6>wx+Hq+78H5d6B|fgEmSs*# zlieqSA}bo-FIj59FR#WXJ%mXV&h_2U+`hq@8(~e#P@ZA>I}*{?82mvf&0KuFfD0=Y zYx9STsc%kO=a93l3Myj%%kJI+Q7W5`Eg#LjoglL+H;b{9Pmu{i?KUT&7B6XnY+Z_FNn8$vR-+%Lcpt>LTzO&L&dRadXe61R8_QoNbCM$!2D>gOu-`a(uH-geeRN<| zt@U^H`36!?DzYx8_ATG)Mto+Gd^>&FPkCf6=ioIW-Dxw12Q<}l-} zY0JHt_F_L@BTh5ol`!Ly58}5OFy3q6NyRpp`@Blx6LBNxUT5TCbWrX$=F{i$URcA( zN&Vc&^>(DE)c9Fe37ste#M0we2f zY~Fw{auF{O_OT_gC6(JQo3oV}(@ESlp#UNRUb|bkQ4z^yq^2yQ_4B;7HbazQabj*L zhUHvam@Q5#jd>!6V~=4@EcCbE=KKmd7umRa*cWg|BdhmrMv@}J#@p13WnFU_RyB3r zzjz|0=vSCASpHcrMoPSw7#?3EaOcVGd?Mv%Bwv=}%)IWp9={6&i$4i$KR^_JeK$*E z&yiR=7*_B48Xccel=`u1(ybz&+xeR&JQi>4_#4XEG6DMc=)OiN>6ys(7+yuRPO81% zF`~7%Ze+xUW)bR=IS@)pbaySsBfU6cf5|YQF?MJdI%}BE)*Lwxg86YmSej}SDTXb{ zLmrBn)Mj11(wxp14=R2Ii#Z7IJ2_sk8NGyde>Alndh2nvh<5KmXsT1dVEcCOW5nl+ zEPeJB&zCT(toT-QT`v^(g4F#`s`Q+I@o_Q{zJ|RAi(0<7CU`NzJUB_AmTux}M(VsB zBvKojzaFto?r5FqxqfF4KNhHndAPEU`K)RyxM{y#z+_qfG1^oZr6Mv?LbB~j@t{V+b}PpB6vYcrcwn91QSSOj4xvte z3XK9Lgr{N1y!}sT8yqLU^_hwwD*Ard55ro$JuiuIdZENLgDxWK=<;7JM>E$rneWkC zehu>_&r2q|j;84QOe8ybZK|dl+|Lz>%F~a?&`Q&t?G4M$#dFql+Z^f{b>Dz$AEQvv z-VtuLR94d?!Lie@Akm1GGYp~P%gNi~hNAyPnL<4}IlXuo=IQxUl%DTDJMkF;g|zhc z&|>yE0Li6j@1hgZPmp_c<7TH-C2+Tx)(`Kq_Are>dUFIBlFlqmJ!7AOXN1M(gG3&l zj)b2)w_)Q?BH;6rPo)PFbd^l1-%|q3(JewS5#I*7^M-i0JwJ`SLwE$vlS5vtlzYZZ z8-rEE+0o7q*cZ1-^c+Rlc{~wnqfo{cSuVBoo_Pvmi?{p<9cz{M#^}Gxj;G{bnko^l ze2KBXB<4??` zCklZm|EI-^x80ES2s(Z`(pF^d4$u3<%lpK_#d_>g-g#ZGxl_cdLu8M1M0C7(!r zOy`~ok$8SF;EgX`<~%>KZU&rW;Z3A5zt6OGdc8hR@BS588kDlv^!UI6xt`g+OCtCn z!kOS{*K)aWyCXqN=dxcRhDqU^u(}k7U{O086Y}0b03tGE;wg)}rqg-q=>cE4uD(>W zdG`2GFl3tRI?+8MM`fAP<}Xs7<=ao+%b>&JmQ-lZw4Z}wjPevBnL3`zANcRMSvFUD zT9Pw`ybn8!#LuJcF4fWJL;i4VZy$IGv#|j;^Lz`PgfQW^c!`M4GH$~w@SxQzItIjh zZuj9e|2KprX%GZ-F+^Aoh5bI?^08!z2E1qHI{4=nl$5)#zPX)6c{{6*q$e|Ew7b-D zkw5r&`1o|vEGCDfHQLDBF@tBz981$)QYGesWgx&6!4aXK1A$Mek+BLBc{K@TQvfSs zl`A5bc@lEDf+&0Z6=bxCj;F~GVso)F|9Yol2|j7##2|Hb(Qc7-iWGjYX!)_Q&8>FBChZ}heSsuA*{=j4qz z4)%lTd2G3Zs=O!827{Bl&g#!GL>fSe6-gHLv_v01bh>ylE%>a^mg2MOaHX2ryCQdi z$pv)Vh%Yyo{pS+Fa=DjQ^D0VUTg(}1em?LQDlnf)2Oi-nlcKRV?XOQb)5hzCf=Vhg z`OWiob-W4ZxOX}o!g1=gRYqi6^5nbiQb*%|n;x#Md0WVnsIRwL*BvhXif74-IF*W9 z75^=lVFkH485z#gLLUunOihbc?P-BdemRYIGQg3F5(Yth(J)FJ$mw!n!R0c$hD~%9 zVHQPZYMid==6JA|P|aPR)JWN3pdT;~KS+n<71&X-$7^z9%@lUH1koS#p{mv`UZAaQ zdDe&w`u;*$cC7!wwS%eACg9Ma?=Y~V0b+WujL0b64@Oq{$g@CQ64@+au2|-3t}VGr z{gJI8G?ds`Lh$je`_#gRJ5Jbn#$gaAcNjLrD&KkBIN0uDU9~i(OV@H=@Uvplryq5Z zn5cb|sLrb1+1jQTzu<`BwLwu;V0Nm-F^}Hg8&qjKB0YB@^<(v%zd;l6hTWFGCBC)F z*T=`P$xYbpCRv(=wY9jIwlR>(@4ijZnSEQ>Me#a5T#IH_fnO}`)>)L)v<&-GDx66w zDtJhF5z=P>3FvY|I5)KCT`qM@>y}6u%(r%0viKy1%S^4py;Gwfc#v`C^Otp+7U z%j-f8&Ic%Q#)%@)m{R(a=2(W*imT%GM+9XOa0>${nVyD`Tmu(}H&Ur$05I8ohp7#;3RGe2^(Yq%`Kc$d$ zt`%A6J|Zo0#{CxQ$ujypASuWc>u$yoc0k&+z=4yoZVu&};f=EM=^kc4SiUW_&jt)G z(LfU494)Ol$5jSJMKve-I1x%l!AgpbU}F)pbZfQH%1O$@mZu|Jd}u!!R!|mxor{;q zDx!F}#(6yV5aG_WqJ`GsZYd151TKr~BMs%?1ROZgku*azYB^%BGS2XM&Z*lRBFd8T z4WDf@I~Jj9tAP98>K{aZ+8dve`aV5j@J!VBR5P zXx81Y1Z=bpsyY`iO=Jr8J`rqW=3tXA7#^}FcT63qszTnM)U8N9aqb+oN<2v_CxuXnUjcPJT zW&Kw%=n*1R?Fjfod`>)OYUo{*J=HRVC=piUWG9Q8C(^G(ql>Es=`l*XjAL!ScmHb7rEl!Yn)%=Cezj0dk9_8 zmQ%^!MS>aY3yp`B-@!=ArF64Lx}kHmC44IUBWz>7CD13>T@~{lL%B#&5*frruE5{e zM2k$m04pfbW;L!XE*V${wHHT!N)mr29%x%Z`A%D%0p9#lNfnc^qv%M1y>a>WsA)-B z12HeG^D^izi~yvP|9JqKAaeh-N&^+x&?j~N-+>~n^@*uwVF z2rQXKx7Us!_FfS!+ngI~RbU6uMhjIfpT&s!|GeHfkvv)qK^3J&PP- zadlc*`Q>2Y5fjH$<4l{Vsi=x)mH>X~@^ERM6m;1L)t9W-ojf}1Ti$vP#yUSQw(^Y~ z76Btj-GL1&3O*FF$@yR1gx%-?Y70v2acP(Kr~2I|GXKfvM_DN-fdP}~{LOzU!(Hl*LABQbo%=l+B?rOov?Qr*VV10nBw^k=QA+C!*B`VY zBjLq$F!B9jzrSyaD96sK(M{y6%5q}G_cbcApL2c3NrX;D``(Ldt7V3)0JNkDU zt~jpk>{CCF*N~Ef;qoYq;Vaopl}6K*kpl6}75CsKzX!cT73B5I^3|B}QQh^)RCV4F zGD(S(%WC(C$Cr=-iJ{V{HGd+%AxFYr#Dy>XgakLs!i)mD(R>mgc{CL|Vpcv&(j*68 z_g)lALUA`7Q@p<@6n)m(r@`F)dl3vdG#d4oX*LZEh6uq#pP$Ygx8U4yo8j&_;xZN6 zJ(jtwyw%Ddnz6vCSb5NXRo=+^{XWtZLWjeWQzTRnC!6KZ?;*4t-y4Uok=^a(!`<5L z2T@lLtnhB$`?2T9M7! zyC~0#*>rOv#F7vfT%gdoTh#!~P-vq*^Ft_>A6ig!sG?rl1Wq$p-xYnppid{wZSDL) zNW?D5?JOiIjY8VMGZK?YEl4UIxz_cF3r|okRhB~KHzQwSB|{S?FKd*Xn5N`B^|34e zNK#}r_XurzfkRUrqqx5H0jKH&Y8?9aRU_7=8uBDbADZf%P-=Wq=PpTlN7Resy zj>4UE&nTMO+X#9szWi({Cgp&2B(&4O#whloQLO}f)?(6r4^RBuo4^)DGo7LiVu)Go zW`1Y6D9v$avotQy?oEN8wF>m-@4<=MVpAcChZ>qE7h@7&d^Rrr6~|2+SG0K##Wn>3 zl288s0F)CW-uRk1hi!ZZLbIknO|9Eb%j3D-E(N$tbr!QN^!*`t^g=_#*`7-YQqq{k zy8Nflbz+OTGs3aT{Kt@bjKJ32p*HuUynzXKkmKy7yNx#($%MdO31k+p@mR+xF) zu?ETsc1HpV+HVv^P#JbqnUR7n`AVa==w3l4pj?w8rTXnZd>N-JeO%4m2jvzz^NuTB z&FX)A5(*T!VUCilQwR|erBj>CM3dVab$Wxn3)}k3MKqXFQDYbvVD;0sv|$;8)d?~L z`#Z*lU(BpXTK_!He?CeX1M%2z@NU#E7{MC~$9}Fx7KVvzfF-mAEe>+%p*M2jL<&|! zm{|mh)trW7Q9p7TK&uH_SU!RB!2F#xb+>fFKCa;e~l%ve~B;7-!b#W*(1i*ky4yIqMcbMRk6r8kfX< zZv^B#d6r2Cca8E61H9k=4sz@Z)*}Fp5n)msHZ$y8ZQ#wC!3j+))8Fg&cV_kdC#*y{ zY%BuK!p)yOHHAR<;+G*wmUJfW)eSJl>t+H)L-hdHnPfc=B|aJo=nOm{O!e#8@RcJSREWO<26e76+OHV_lG)!dQU5424%TF2}Iik>- zdLPN5Ny)Z_@3>!8B$_`xw767bT4#nqv71*xYV>GSJs0N$ifJhd^wu)RG8)grYv0=q z$GBhqEOW~ar_%z}qMwk^$J(0&b|m@Ii$w|I78&|Tc71(_ts0jc(Wc03?8u~!GNYuR z-KyMmy0oR|h>=yrb?JGlf!3)NDRnN1z^0mh1YE%X9g(3=DEOP|G+NcbK)_ZVtJue9``(f%v%+n;41lRhb>>xYdgy#sZWHM*+ivDbtv zT1l9gIo@tKH;p}}Bt$|$9Wzvd+I5_KR1QR*frED%GHBUN53AwkP1f3Mj zoHggAdAun?CFm(;%0%OTSpWW2^JV(~tZKlTb{&k@?ogo2gN;WHPRgpYG52!S0hlWJ1JSEf7|GGvLo#_SWa|dzL}PL}u;}a#BhEdnmkzJA zo?oYHf#FK6xU35nta7FWsTOl>&F|oW)6A%{fnew*%^Iy7 z(x+oSH7;AH6a4p`!NLlXZ4<$upqy?85G0-H(-6;@fuX@_23D8+IF4jq);#i|H4a0l zKJJOdOGl5PbU8~XHW=iJfFbM+>`)ER$3~?ImN)uqb}7Mmx&E~VW~q0ggn$kM$_+VN z(X*is#s8k%`jH4mdv`ZDa2Anq*CmM?xeh{Vj~dG;xY{DCb5MNFjf3PN!4(qc^aBB% zb(hyO(BqBATWg;ja$4v9p`74nJ4fhGu2N$t-K?*BGp{>E0#5$M4XM3*11_3>Odz~H zo}0Pf95-{#UwZ$f;$Vm`Zk#Rp5epHXx`^=dm%5~nFU8i3 zjZoQ1Dr!lZ@ejMY6jg8C3OrPDR==e0%H~b#v4?zEL6WCmN>e>>64gywRNiTMb4lyS zj4#R7gnul3bM&T;0hfa@B|2u#^Z>CeibB83#T6kJ0Cunf6su`+_G4h87%ey=ECVUHM3ocx2a+|-_jNx(h zrb$f+ly$l)k=P4N6$vJ^^g}V$=-k&ZQY4t3mr?Yw_kuGa+cj*K+k%#YkwacsV@WCH zOdt%{bttILuB*LNw6@A5P8H2hA^m}vSQ>+9lqXPxmX;Ed;47|IAsB=*v55Rq+s=`S zs7O2K_MyGW{lDjX^DXvk{EnMh`%7IfXotQBG0P65r0p;9WMy<~jmko=JqX-RYg1HI zKFsZ)m<`sR$5^}&&xgW~%KW4G&U3ZBFVD5UE-x4Xf@~Rby=OXEdmlyIu9sBXUw~!= zs^V;oZ+e>nOC3@r9wJ?SgYmx3_O8oVOq$OdGdaCgLAxJ(q{BUOy6%T~4BT}UJhr^I zH7{+*PrHtrS%pp)dd8wE(DZmdxDCw`7`e{@JzIU0W^3ZDX))Yav?W9tRqu0aWY~opE#y@( z#KwP_K+M6M*AFGZ{Ds5edtZ_T)9W>Oo!dUQq2{(j>$}L8ek@ku`ZS)SwdOUMEV3j)0tynsLtpHKK<5CzREe@Luc7|@R z4G~M9pHF%GHLTNCr+{BQhK0}R*sH8&!|%|#Ov6k{yS(z@8{G8aqps0Z(9;7Q0|m#1 z(^Qdkuj)b)YKMA`)}`cHO$Pl}t`VG&P+W^VNLgH=G?iJyxL}MNfpaCZ0Hyl0n+CX0 z=5ynjwQT=TkD>|uoH@D8z3YS8ogQMJgnk?Q%&q&e3!V&fV4deIyjkCOw7pm6YKDD^ zg>(0tSodSv=pynv-P*yaYyXvrJ!*FH8ZRd>zn0^}m(mEryL=*FcL5auOodM^$T93_PL(g@^?O!bm+ zkoJ!j3GLhF(phy|JLs<#YmEd-n7$e;uuL%Zy1YgAA6%U4X>Z!lkuvaIbiX`RjW`$S zZh4Jj7`-J;XtExE{$$!loW5r99J%NFBsgMImg_#rHVU-@WJUv6nm-Gzf!+#)^4Sv& z@Li3RP$*uiHS7k+muNUEu8|wAdrj2gf7Rxxxs>PH|C}R_HH>^u$8Ha~Z88cKKoZZC zHId5>=ST>1M|o^U)TGJ*D``7`rF1kLyhhY)?TwPqbq9+y2r#W7Z}PSFA2UX7yDIN% z&lOKNxM8K)GPtp|zyu&AZ>AU(Z5yaMDy7t}$V+_62RQ8~lY=tmXXc;oME~a;b4au6 z-xd@5pUy71BxZw-KY(;>700%}zYmzp{w2x*&N|ye{TGfoW+r%ew9+_@v0%l|nY7I= ziCm@3(XqfwVpO(?ETdXnH-gIfw~7FsD3hDDq^6!BC33t$tR;V_g4OoJ!S%*sBSF5+ zcj9_h`UxO{B~=9CBBBL2PI2jSUH^Iddx54ngv6(Tfc5k{8u^oi0r>(GSya@>LR5Sh z<|doudU0&aaP3cpf~MEe3o%&xI#3b|9M5nWipz~bu3z4E@{~Kz6@J%1t1q`vG~|1t zf3(g>)gec0Z_{V|I#N@Smc@w>mDS>b%!yzRs8UrRGm@qsnHu_s4_tQaK&OJpOK`f; zk$O24y5Gp54R@Bt^?y{llTGS|Mqpe(0 z0ivRDR{grnw;Du2ZW37@H*qV^j^}AW)?i zmViiaDU3^xC@m%;h=#uVXF7c7d0_RN%tlh4*wqWLod1V2#J|M(0qj!Os1S?GQ5rk>;O*w zx7mQJ(R+2m&>e_O1MH9WfCZ8FUh2F`5)+!O$?9s#4PQ=bH%c%?RusLb5Wc8t5)%ZE zgfuhE-2hg{Gd(Fmk=aS8a8ioV4qJiFTS>;#mZ8rKV+66rctT0;A}A*>_?)eDxy}D5 z4bFxM_B3@E+lx|;YH;1+Q>D9#kM*vlzADi=+yP>;bG?~z(|gWhm-9q^nxC!B_Pq5g z?GJwLx=B({gEhjJOwo<~20bpdD_H;@A#^ib#Z=WPqIaGDwtPuKm5m;VjD^tLsav6o zVx-Oi;r&sD6lKvXLA3kRF4k+Y((qpv+P^q&xujOW`^*~fI00}qcHveO)d*?vPc%0e zJgugfdlQN}2Ot+PY6-~Dn#OLzGcF0ixI&}pw&~8!St_9+CmZOW7JR^b`Ej_4^T_abTjgIEB~iNq`5y;7IY!EDlc=HM zdG{hwSi799tz|)@BP0J@>Hyo*31I3uZ+qpH*>r5PdWo#BsrWcz*R6>(cFT8lW^zPKk(fB+rg&c&Lcq!GSl$-_*OYTV-vNMpU#m#W17wc0& zo+0}tjD>&et|*A^V&M=SF9e7hYVjzDc;t0!mJxZaJtS zei(%*NP}U-@cHrw_V2!y0SR~BO<1{8}s-_ew9_G#t_m%2X zYTF;_35!I_mDvk~`29yVEjqSXyHTN+UMlJ$MDT(K5QBm(x^4`el5iJdHh|PBwVJ8= zjR~35_BB>Q^73P0HMYai*(^H3nWSok+U}@Sp~62WVtc9B;eU_mat^mBOhKT{?T(c~ zjuLi+7NO_El61qrfa$q%)|&V5Mv#nz#C2K-aB-78^da!)Dy!m7S-J#;7M2YG%ksb1 zWOvu(aO3Fc2sjzF0A{{i0T1{vfxu#2TQWf5>* zDzLPhJ3H6SbuHsv$_AS$~{67`IE-hXr;Kmq!4#ITXc$2kW? zT>+M649CI3Bcsax5^hL4!ItL8V_p2w@9y-V2MmX!S~ z$6{WHQQevXW7L3F{IhiQk2aZ|KD^8%>dcG`-qWf^z-E|s$QKB}i_a&v-Md=llGrLD zoIin*97}5(=n(rW_!DrhDcDyM5b*XC?0oL1p;u;1732>F9#w`+OX&$l`YnG#UT5HX zFCEu7WIOJLqeD)y%)c9>{h{o7Z)v_EXD;qJdr&P{v=LJN{DFM6;P)=*IIP5sxpIxvY&7HoVTHqSI6!( z7Nfl8@{~5zTBX?i%2f*ll>Z`L7VP>mKYcE+e&6)EudMI$v=6~L6*YRh9woiobBS-? z@A=^I>ZuhK9F;;o)Xo0wEaZDPyWizp%wjrKJ=rS8)WtbRK!a=vDq1P{S{`RmBxsfBlEr_;Bet3IIa@80-tc`i_we2>2J^ z5&%R52mAr1*UG5yJur7n^Zif{)b(Fy$zD{`EWGk?W{qAJjY@17DQ&XC9h@I#Y?CqZ zfK~@!7ItqVdb``51DKJ2bP)hh9~pAz?OtEX`#KU&ZB#WS2`%f-ji43mVF3-9w(4_XD$w zyJ%sz_8J^NQWtL-RG~UP4ad9i{vyp>ys~#V=9fiPMdIoo?`T+j9>>sZUyf#{SF8_- zmxXRdmUEvGF8$99JT|gD#+%FXyUp<~{4$8s&>&2!b0HZ~vJQJ)3WTJFJD^2T9OZY% zKOA$qU;-ZAd|nJNoOJ6=S69nE$iaM}=OVg}V-0wE8m;wRtYqOmj#dBG1KH}F7QD+@ zl%e~4Hu6imGUYWp;Gu8$Fz0QKfXwaWT4u}Z$w7wM=<7*`*&305S0WxC{gdQD}d^Afg`*PPXG**v8aQVVw%)CQc#@x)7IB7RO&cD!vWI4 zX_Pg<>~B|-5|eIUKfV96P8Eqt_`$7{ZUnebnTP(obIwQiaN8ov#@&ClcDQ*kVLAGR&YUk=el;T_WH zRRhN%R6M*zf&!f!$SXjO^qYzZEO6O+F5&)aG zbI2-{OX7SVS42PF5S#q3&0&aR6d+*y!>}<=K&WcD0a7P*f$X^fLmrJ_i36n- zu2c&mMuc4?z%;4NE{>|5a6TGe4iK_|nzCsSc{A8rkdu>>ojvK$OS>ufAu@97A00)V zIjZ|^NiV?pHf}SVE!e8bKs7!N88P%80CzBpX+SRVr={2<~1^nU#`kCq&Rp z^#lx=3d!XbkMWq5 zV4rZmboY`X-5?Fp5(|QKhteG?3eqTDf^>IxOM|3zcb7`1#3~5fcfbF==kD=%Jfgb` z`^L;OGry-`o}dE>g?rKohb^>NrZC8fpLx(a+>8|^?`SGTM`haL?W0WDr_rt_K5Wk% zjjzk}vxVlIHoR5>pLy98ryY^*R(hVd@Vww!W(Hj~gP><{k$Yy=O1o$a>$MO*)PhIC zu5OkrRT!_#o{U*~DY@#~oh^^3_t=4awa@od?Z>Ns@-Y^w#5=5h?(sC6&VBlo-ehw9 zduBq!dM{4jcoSRV?8n;EwT2HK?Pn5#nwzxG;JwPGAD2^$@jHGe?iQ1YEBx&De!A|p zpiuY=I@*ZD32EkTs3aHPoxO1_{C4rj5Jd6&N2M&%j<3t-fW1R4Pf*Lp_t}KOB})y`^Gig0Hd2f>+rF@M?T z^5l2(Z|~+2Q~h99cK~pVr-eA>JHQ^n(Ms0x4 z{poM=rv%A{W)s?<}S%k>Pot-scG+ukT zJihzNh{z5h_xvktZNv#I?%HUigWxEP|L-T2IdH3`If0Iq7Tyo~6E5XT_p%)sw z^j@v6k^xZll9KF-FGKGV? zxB7`oS8xqXs2LevGzsBL;u<~<2n^am|Jp0Eh&YHXt&!H@Y}U^5uj!|QTv$WS9D^a! zzorLIcTG2J0k#_KmE7;N|N1h^KFX%g*0dG=OFmtHsNP^#jr-uEu#_k1dA;WCw(+M+ zSk8AePgNCU$C!w;Pj)`n1uFWS(1!x zR-jyNVAaj>#ECwz|60bn_oQ^f=YsE+Rx8PKdLd=_^E9)tp;l7JwCl{DzD}QAO_xgY zCtUewMhxd)+o7D&gs8eS3qo&VuJNf-?Aa~7_G8g`kpp6SN-p!N8J+h8zaB5uhlWM+ z0o3K&;x)sscClec%MF~Ep@)tgE>&F0yRYJy&@0YHRXDE7K0E*Nf~suEetHRN16Qr~ z3nvnzLBlRJCxsVIBm-H{7k#Z8%+%;~ghhn)-H{v<9r~z~Rpf2Y2OFvq>{XaXh0ROg zDTlswal}NF3|f^I^}8;OGG{fXYfl>NjUC8*I(YCPrR|f#pcOj{q>hlPmYuXbwn)Cj z)=7$b0P;eBIqcK)RK~}Mr-Vm(kPRc#t6l`AUJK??F zI|?@D9snLp@_susK5aeg!Rbx=27odM9uzul0U2WwG|^g6?3yk#%%GoVL-YtIGnvjOY% z;OWO7Pieqv223Z&9ynkS$H@qH+o+Mam{_ZMZwTU*;h!Gh09gjjl1R`5*va33*#w$o zAb`yT{M!Elo%Ufb`hE`uq)-4M4J4xaCBgoGed_=Ez;{{?R7P(guK>oA)ZOLG!x_QD zS@OsIWaO=^qWw@H_zH}RFiuWhquy zKnX-R9YD2AZ>E#gM1MhvC~0X4UK;|!RiDX#B2THR7psn1car^iUN?uXvqGUhrnaYd z`7|Kce+*?naUh7Al#>@xcm58r?(RQ2euExoAHw(0Lms zn}LL)U`02AURTucvz`3Z)9Ba2YyYE?9eMG%;|Y^r!7#cUDH@36KvTLD{7|Lt}QP6A3NMJ8+b`1##JCh^GHsZ^<*W_?$54`l@)!E~$m2 z=nE~!NI)gMf9@N&278p!8-qFvpJKMeir<^g`{_{%S-WLrfHxj{UWQ((zcKBvro?2F z#GGD4LjPOGp(kW;CrfNSFfr`aJ`#luGFE}j;DLoHhQZx2u^=H7-y^<%4xA!EJLt64_|Yz) zq!sq)_$!O18r-&IViDgUzD}t!^;Fm{`AyyBlie<2hDSXw3r+lUUnD+_C{o@1nm}|K z&-89Rx@mbmQRa0HPrKw7_(woDsQ37;esGC=1P&ab@7_duo^@k9yFZTxo+RS!!{G7+& z>BKt`1T?<8z-NB~Nz_ElL1h9Eb#HB--7fv716(S1UV?M`Uo}{|aiWsJh&{-V#WF33 z*k-^mbdWGC&DyYS4NUg};$S}9M7Qmc(VW?0-0oih=y^cQegod#@P!YMi8?RDJ6%B9 zXX>?In0-^H$9a7ZwMh(jrz}sbpVN54U|%G!7vX>-2Z}`aJ2r{x((RKVy9^8RM%|f6p`xV)TGKR~d zcMwMk;6s8_17T9RWo2SpBgg$#L zJ!FhtrryRK;$D?Ctzy)7-o9PO*6h75M#C!FQNsmporbr6MFvFfvqj^Wz|R51K>#Si zk*H-Jq84++r>dCs;<$~$RX!ctw6G$H<&56E4$Y#%-gc0?pnXv7Ki7o?o6 zx!P$l1r?%$6yLtrSQ~RW)c*(r|TrVFp!%WISAa3Ska5N$C#m zF5W?`LS?9ZeXM<*?fW{2XIEGIBqsdF7jZR&^gCRK8_+GNst;l_5XfP?-7w3&1@uo) z+cfj0wqrs`>-`;eG?mrUOGFx3)~C zA)w=~KitDnfa|rqCThgP)N@)%aN9%!`4{}k&r^djEn*B%BI@zmsg`zjdzq&`wkcbI z{>5b`U{n3K)9eM5;xt~O#aJ6^p#SRxuOK@!4R92Qz_NZ^%n@La>6dR3wAJ=cPZ5?H zRh{=Yghx{#0{@%+@b^2<$9Fl)D988V>OR}F_gl2f$BiykOHN*=Pn{O&7!dpC!e_Du zB<1HIwe>F(+YJc~?Yx<*{s!)G@rzM`>XscU2#@EGa@ZaM>j@%sDzZ#gppjeB-Y#a} zEoFb_d<3>05U@}4L%mCQH(X~_`%}P89U1mp0(|S8ogh}u;V4V%G${6j>h2FWqlU;3 zA1&B=B@)L*7*N!TCBfCwha;ykal=br>9zIqYh4uhBQZl)u|%j)pKn6`_M3dVyXsFt z9BKV2ZXonNZi)t^P`yBb0ER;+wV5=X#P5&tvec0Rlmaw~0;bG@o{-4OGR8Lpd5+bwh%PSk+YGB(^m+XrYJLD4+K3Pdn?^gn1zgknxT9J$=)*x!{ zfJZ4X8X$!!X6=GXfCu1z&}!|=2JIJv^ZS40h&nEP;E@4xEbc$(B?q7|n(7JA?Es(& zX2&!I@qq=5jL}kab9efwbIzgE;S2-i z><8_GN5AOjFxiq)J{xd6bWd>)W&t2ABnP;v@ZTYpW|B1s<0$+HZ*$B3X8=M^(neF% z0hpfPb$~ZC52yZ%+)8M!aZc5tEuGk2IE+*zq< z*kjgz8IzM7{Q_Ih2Jhs=Wpn}p>CJU1v|$w3#e`F@_pfGMCF&;wbiU{ag^|q&`hq+*3sG@3ObV7P zE8{l$I!GaqFVEWsSRWq7@+HC48oYGmtagW`(>>Tb7i0k3JCZiwwSG5TqA(T?gHdOT`)IC-mxcn z>Nx?hl)#vb_hmKwP#INdn`$PpN;N3J91JQPO6=W(U)Inc$ZQeAk7$zN$)c)TE zF@TfqS>ZKQ*|2r=IIc9{x8!V6`3F8W+CvL&dXEs+Gm|=v<9B-u#*%g-g(2EraM*D; z+F73rlzOqBjs$<#oZyL)u6g7;sn#TLFzpnfGaHNKh5ozo-NKZZ6^IQe83}cLjjAy* z7pSGbg=2lffA$mB<|H0|w%V6;J*@9PVH@T1OQz)>7ChC%0W-Y(+v_a(97ieVMW&|h zSIBTyMT+HeUvB;ULI*zp`n9d$KbhOQq%askvw4Q(9imTxrjPCVD?;NMb&6){l`dJE zV3TGH>r~5%)M7j~tX1uBxo{1>@In#k`BAvdtar~h*`oXx@`^n_jT2F+`Q*@oS}@&< ze5od7As;%i^DpLpQJ_9QTEgj&K8@#P0U%&~;H+yimIk~ZM0RB-egP&p_y8G#!KHZA z;&OsG&jRMm7u(SBZh@~2a9l%aW{_!z`^TEr+l(RPiaIWYUh+9fSjJSr#0Oijfj z@>0^$fY2?UWbN-jK+7bNrwQ z#$`TVP0T63P)Z~)++W-IL0gVeU&dixH>1Yy@LskadSbj~XYrnuQTxZ0En8y$?~FPt zx}iQaeafQEFqHySbq(2pmZ53V17>b}jQG#{WIF9Bg8o~5EJ_xtVuD20e2IF z8kan&(H{0?QS!QXvP{c9@Tq#1Z~cLTYcV5M%8H3#`5PW79! zm?ku9%NvhjSWrT^(ZMo8$Fg`5qOgKSq{>l327Jtjai-lLY0<2A zuZ78PvBl~3_6nQD70D4yX~Tyl5iv$gSR%kw9Gn% z1<%%KiKgbvy{oL7kvY4c?=AY(BK8Kjc2Qc|q?ha1_Ao0a&3@3jZJuvhInL?zQlpBo zg}+&#oG9iJ#IDt+eEWf|A;Exg@}wqS!vdELkHvQ?Nox_^!Vr^Y`P@3GK+SB|A%%pv zIK!LGz)K+8c_Pd16YOM_1IKY@lX7DDbq&sG{pFV^qMT#w>M||}PS@}g(=Q)*t>XI# z$0+s9N?fbv*qze;{FR5Oz(QDxSZ>j8@S3Zx4&UD;=?;E|c6qe@P?Y6KjXQ`q9Tm)Rz zpsvCFDIkJ->@vy+63(BX+}gVlqEGO015wV!9T65@<=tv#mHj%+TnvU5OCHW+?NYxJHc$Mys*$LG$p*OM)>j-s>%F&Uy64tgg z2ag!Xm*)2n?KHhH_h$k~sOX^8QovD;4OQz}2|CI6fB*B+**FT^I6kbfPfYfnh&EwtsX#EV7o1XqGHE@g;-AMt-b_dLLyIM^ z#xb$H&EL|h;HG#2PP+NaZ#=gdh@~4AOrGrUIkCuI<4dUFQ;(uh3>|cSf9~r0`h)uF z*2{=I50Gg@V{6ghR_@o>`gHfeI{yB(}IzVuzsh{$%0}f(Ft0^Rd28TH(0{Bvt z-`p~EIp^t{RS#giX99jhh!x0h{>rke4ang&*mXSS~ zh5sc0uF%vbR!|@o+=DD3=R@@lV!kMH+G96SH!7z)d#W0?FtK(KHBD*Waq>br7%%_0_#U;vNBF&o`haOVTcO|0a1PmFF z$|uY}o>i)FV*PvK1NTk(eV@q6ya!(RFDM~pS&dU6^>07XYxJOrPN-|*$DBos=6jCEl%+d(Mer)n9rVd%@|js!Px}u0Ymi!$^#x+s zytwQM2{((Rd?jd?(m^-jA~2yP=v}#?+*3`(p`tEY)x!uB*@sef-vfkViy%MWBZ7L+ z|M6P_q11w814uuuK;}d+-MX*gpcd%)ozvtUfScYSgdkuP0kU&MdmXU$qLIclE$qXtRwY5oJ1`bF!kpN})CrE@ z+&KKtq-7S9QvMXg`45u_-)5jZ4?Av{L*>AWtNi4re*B~idz9Vv*qkpFUa(jbYSOI+ zcR6tAZIlzs1a*`sVE@i}8!e*51v&rbs@9%8p=I+-m`qn7rwr6~xJK|l|DJX#16~^D z#3PjK2`EF*FdFg{1N7lkCL7SaWK9VlR=%3ZT7>n9pLWENFz--~KZY7x<2(1*E>Uf) zH$nN&_XcP6)RMdkCci!%8tgG_*kK=iYGZ9UDyMV&xyRR3L}+0Zp0w&h)z58)+hq**JU*n z=3&J9GeZX=9>S`+;-9j1P0Ug{E{|CVCp1k!O0k`&75HL?F zi}CaS?xnpyiw7N5dwX`)AO4(!G9#ijptLy43&^G@c1R zDl5F^afkDfK2T~%%T12c5G5Yz=F=YxT82U3Bt|s9{`brvkp_K!*R3L-aPDxMGO36o1YjRkh=5x4>szARR z7<%lc?#@^%Y*G#rwtFy8QB(8w@5m!{5%>OXN1vE5yCp@%&n^EUs=~AA9^%Ur2eSgB z^ui;HMivYQMx-%1VyZ~VF>60avcjn5#gmy7KJ#0etnUe2%y>dFw$_f-25@BBu}S8B zO%q{!aVAgnaqz%V$9;N3F7p(JZ^46aTVz2Z6B!cj6`QxlQH-f(ye~vp``eZE*mI~a z&_`xkP^e!g+yJ8>=Zj~?c4$ZlNK!A3Xs5G=OyuWC?3D6jiHXbDS3aakv-UDGh0*%4Is{EOKpI@A&e-VP8$GHU&5ziyxHwNHr2VPSMUHq&G80&1|m^PoZ9)eJL zBrmew_rIf9^jxy#Y6v)2bz)XrI`#Mh@%%r_8HB?P>5?x9j_(opNHAM6E%>^@XpNd{ zj^N{2AkYj9F3=jb5&Y1{AAV{orG+V$&n*MZA2711ELCC@BL)#BO&j+spI#!Wu_b#| zYi$sp)0gFcGP*kpeI~)jr^&n$EWZmX5V+STp#PhO zmiA%J^iJ3Dcy=~{&xUr4fI$d!>;k!S!V|L^J9px}&#&CE7I%9hy_ki2CV}xsT3Mf+ z)e|s}21S0b{xi|*rA&v#aog zS=+vAT`Vh&{f5^%PeT2s<{BIQNOx3wm8J9-j4AbktqNj*sj~M2=ChSGTomF=cp(uM z77qVDxswBI5G??bCyd1QXO-n?$sD++2SC#<=f%I+=QK)jeWLmsi$QH~fnESUfy$F^ zP-}HsYZdaq3|d@QqKMd@KgYAVLPVJwQZU1@_M7{baH6YGU?a`or1~fwYc)0JcPkMcgs~ z!K9=|GM7&|Px@c|S^HNr5C|Rvk4mMuRkxMb{zae@^l%;|S1M|1UZ*2b4YV>U$+KTz={CHBKfe6>d%A>BPqCAGl?oBL8M&EUrYVz8L)~! zoGiEG?IdOx%|MJiYZ`X`+|dSX5A0qwzUz;P7Zf0)(@m_Jh@BPORT9Mq9H{d5I`_ z;U(8Gcr$B0c4zI{d1VJniAk(;YT zTpv3@?F6YR^(a+(ufL0_=!U0+5#)s4=K6KDqmFs=>Q+;K;QKU!=_g&iQ2jAXB6BT{ z_@sGLSsG?-IBqrQ%T_ICu( zxH|v{sOsX1-fV0+vM*4{i*?0bAoYX;(-BYk-#z2X7dZtiM@qVCDz`ut-BxM+*s6+c z9n?)hRTup-FBHotc`RuWjbm!Ibg|-I|F`@F8d^mn7ZSJbGKuNXPQp?32y^b9)_cF+ zcY8F8uuC-9iO8#4Z>~UtM92wgXQ54F6|vrXIh8%4)vbpLAubc?lxjIyf=EX~A|x@! zgLqrCpb*b=(e}S5osS$#$quA8NUa!5H1Nh@9P|`(($-twkmysG_^h==;M8vXkFmY| zA~rE$N@kJ=Xk|;$1k6#C@Ba`^a5F?H$z}PeuJ4E;BIh?=*T)t@dyL>G|-0*e`IY4cBSYps9l$EJUjKp9LFEH zZ*AV0l*o4XbYWr6bZQ8x!)?&fE_?hjP9aY^m#xp8eEwwPeKA`-7yEnLMID#~=A0&Y z&$`X+&UE>j>mPaVtWpegQ|(RhQ`IT+nt3x^z;u9&wOh%Ix%$I(}FQ zpCBqyka_)%N1LiWaXXO#1#YrJE zR6MErVayxM7+l6O|Ml}KReCZvX%=Us&XC6bi@KxV$Q^gl5@V>J%^b%g>e$TdPo(7M z^mlf#nF(u=P1-{l(V9nElCAB-g{&>3c-AsPg!@7GiNiGO=4q&Kr0IzJDnv=L` z)@ll&av)gWvvTBs7qXU(-ei$L<)@BD0y4e8G)y^Ly6wTN;L2}n|M0vwr;prNaGVrn zI|q+$?5;-g*bb^+wjj;g{-KbZ@E>%(wp~l-j-ZnP)S5pq8B|ND0 zl@Fkp`0!Kx-U{qJU>)d>x&b;sLBq|`zH8Z2jj_LT^W|P~H_Mt>XSOBWJ)a^5Eg#X` z)<3(g2QvZS#6?uAKn^4htOFB{sc5v(ydN*;z~(mXY-vMUvi%oRdD7?$h+A{iSLi3)kxldQSY|C?BAx z433fGb6C%({S(#?zb%!GK6fLL$;DrGW42}IBy{6y;1**b4hR$D|E3Vk*?h@wn4~6X zEs8b^j$L;;~?T}QW zW$hcaYH8%F7lWjgdA-8s!G1D|H))x5i)-hU@u;;K?@5!XW>3&r$j1`D8(sZ!$=bX6 z*AI$5WxCIyKWE)JaZ_G{KVdWR#pw_)y983d zNFw`rmC&28gv9WYffmE?k~6FG#iVZ`i}6TpbMt=;vwy;Z9*(7 zJu3hG+Oz*Sgx1G$1dab@*WdzqAv;_#I&2(~f#pYX9yu*tZben}iuYtK!DIe?W2m@{ zj&iXUe=_=BOVxt!nE?_eW*=L}isZiPXNv~In6>*StHx=(-tJ{rFr!(k}p@(d&?&l@l{xlFD+4BrERe}HEz$E zQJd`q&FNk4CuDM2W-Ue*&7aov@`j#T@Vdb+j_nsr3dC|DhpOA-qAUs4N+D=falY*I zf*$2;xtA}-naZTN9Wg%qU|;~1S5Wr3eu0tbOrWBznD>mjvs_TMy+kCLf<#O>sAo}^ zo}KgsTSjP|d2J1LH8nNX7A4`;b_&J%seE%eaV%t;9yh92lEH%3X$&7aUu_y4$VFRa z(Orh$*sFZac_44aaZ1QpUSbO|(hl3z4z_f|rKs}m=B*E{afcQC7-`|M&S8?}a)Qa+ z9`3A5FE%$eIj7sDkl3A)x?pUAjtrlbbvrK7ijN=j&5muPL7KR!0r#0_uz!kNZ zpI;!zV2~nzU7Ex|l_}(WX7Rd@;v#k8pMZdS#P6INy-?h*f~u^ntOV(D9?@*{Xzw?& zh0=V-f}*_kIrx+m(fn*ZWZzijt?_NvajEijMrYs(rYT5gP!SUAz|?i8FCx$L&gzka z8Sq=hA0#C4gidXA=O)8y;770V2QO%^qn|me>ZA;0wcX_YHWc%F`-_qGZ`h@?#xpYw z#m5DfTQY((3`m|f8($k^jTkKo6q?}*@>prpb-FuSaC*=0f9sx&5nrJg_Df}IZ>`7G zor(R*P^vI{=uGXSFijY>ODTvI?$OxF`q54dX|&gh;2EjQ|5iAzt)H)seKlTZUu56Q zq_E?WZSnPZwZMH`xq~*APG)CA2=j1yciMJ3Xpv=p(TsN1SBn4*hMDD2F>^iRzN(Gu zP+3?>vlyiNpAG6gaBNvEbr}!$3;RRKxhg06K!{}>MP`oph$b-J4=ihp;pHyPjUQ9m zM2RtkiAkdX4~$!}^fuRvB$RW49Gs6C=IKs&7D1YGhN=&Y5mK{K)Ri%-CVjX%4Srd_kNnOx?FS`$zH!=ceA3PKm1D$l_p$ z1;XOdh1o4Y6|y-+#(c2cOF>s}pRGjPJ3Dq3pEmH0@!w|3%W?6DkNlx~k`@*8d4!YD zbEj%4^%*m!Z9iGu{cRgl(=-ZF#-16p_xQu_MqaTE%FVg!lwGyP763?MkMI&jPX;zT zVWv@W$zD9sIa4m+W(JKjfer1cD5at{W66LSrhj-)3@^@#&g-@xc3$2;9wm_r-QFw~ zh&5eBF;c{rxHwl`Y84iYvyI2H`BN#0B?xLahRD4Nt=8rB@#u+bSz{MFNc5nUv-qLF z_TPs1?>7W|a9#Mf_JVo{SZS|m4H;+f7O#?=2l{y_E$C@DBMx*j*scnd0*8>jNg~(e zxD<1oBH5$f{>r14YhpNC6>F(~VJdJe&|gLs$NQ$OwGSYb{ImmD?3adIJOYq>UbxXI z-KY+4lPb)~$;=qx(hlp0GAhNpX_nAi!?y1T9Z@xRjVsjT+oj(ku?G4Y($?w^pq@}U zVA09q5~DEKV4P44DJr0J&0eNzlTD)S)McW7I#a2ongw*wdvN@vO_c zF^U?S{IJb*5>vw}q$y>4uuFuU_`4d1P^Vh;0A!u&(dAqE6GBv9ptlXABm0=vRruI?GH;#e9i+t zpAn{teK?N)>d(1D;e{?Lgk?$ISlZC8LKg?h3Y*i2gfjj?$Uz7FDE>2BXTu~b0c90Q zYtMxQ>rZcV`A5TRf1*y8@5Ucl8}z}j9dXC(9eWbG|?srEnN*+mvv%)QU}s6MpT{WR6MaD)6czLfJJIO(U{=%pp1*@ zj~l;L$EJL0mf!OjV}eso7qnmtQqg0uywWpqhhs~h$HlydW)jfC&BepY$IvwuZX z3s2|m%gaK`-2J@52w&_*LW0)sWR3AVpI$y$k(lr#J0+Gx%^F)VAKz#a&vYv{ZjB2E zT@kt;z}Rd3#aBjrZPZ7T&N7GVt0K;iiM!81TOQ&3dynqYrf_rNGqqG{nDGqhc2!75 z9n)2itg^09zsMZ#LaE}u@eB)Lb)I0kTa|`o#nDQ{MFQEbtn)f~mCEAYI<_lB3Mzso zd&aNoA1a~6lc$pML-P6?#_1ry*hYHpV4qT>lx=5*)*7ww%&}7Gc}VN7Sm(NysQd;J z*z2PHpwWop+V+WYW(Lv-(xCrBga5(<5CCwisHhk?0yx9^vy1%A41bz_3dhO@H{jP? zgVQeD|5F~MXM&ZbnHq<-71PNXHrQ{-J--ifHfs#YKT??KOIRbQN_jI9g3RKWkfSy0 zGTTJ!^P-R|Q%Gt4a$S5EU(3rN$-3+y!*U1ZgSw<6VUG^$Q(>hg1K}-`lYZjMGI`k) z*S`fLlK#@zXq&n(<+BP?_1shkbXT?T##_{mMtw+H`yyQsRzT z3Oj-Q%-|gPU9(BsJ0L_gs?k9B42zHZR5|V-#~>E}#WrSH)v@(gA!fpJhh^^c?lVYQ{M8DAx+^Ry0f@?8r*V7;WRGK=Y$kc^RQ>ijJcVP>-Vg|{Chac`Ok zihpa7I+r=;Afxljvam_Cxuc@`v3m5wVxw={>9j0th!v4i&pdkzu#hz7BXT!B@0$^D3VY;a1S8=C`$T*dLxwetmACDCiM3 zH!pF(e;>{#o8he=LsfMhq2*onMlXt0C67zdI=e|krGTUCJ#Q<0FQIB&Uz(Zg#H~!T zV+I+bNb`1mauOEL{1S$Xi(6pd%DZ(EyR*6rx*9~S^SNaPN>Hfff>0rIU*fvk z^0`Jp1%~3WW?7)!iL&Kl`4{rap~kxL!fn=bjkhEPWDSIhe6wz7*s5WQuQJ_^_0e9zN?`lt{Mx(7R8AG7abtn7c%Yj|w%0o6RwK8{Ktyi%_`ao2Ow zo5o}#n%GF5o#rUGBcw;JIX4PZF+hR;>8d7?FwD$`@jw^n&sHr_zFA^cd#0_X{E=!? z!kkM`bpG!cU;%E9g^6OK3yyaRiZjja`*y#u$mr;Dhy1COx0M)lX2Bm4yac}ztCHYG zs2C|^_Dno)4rLevgU#kGf+ESxZh21Xjm8WXI&9Ea-4Gn4;WJr#BctolA{KRcP&-%u z@G9|GOC)Fa=Mw8;6)T8&@Z`OBn)jU6+F2ikcZ1f`9gFH2T`lwc(GL5h)ImonU00Tf z)woRC&fUi)H42fXua;V{4UX9_b#>`wB_xDp8#*&Jdod4#>lM7@KNmM3iDNRjhYkMh z>zka6QRTNh_Bp37IJ3f`I#Vom_hnMRDPoXQ;y8FL5F9K8DjJB#?X)9lXXu|Xpt^1b z%QYukQ(~US#>N6=P*m4RDy)|N$#9(z&zl*G5EQf5o#*jyj$%tCqhAhV$uD@xwutqj z>U!r05hSOMU#g|ZDmrOFgTp2k3k1h%<`Z9qbUFF(f{s8|fq44zD!;Nrsc+sPp>^o{ z)Y@AG8hlYYzLcWyKs=u94v*6)KKrzjIVLq^Ka`>E5e7qhy=-Hq5mKobwD87~hIbab zmPf^rt+V4}k5M1CbKnxxQw?-qy9{fxrXWBs|y2hk_1==>x+A$n`oreCKG$~P0 zc82c$yBbE?UO)G7z67~!45GkXTnn~GkUm-LMm3);X64euR|IIeenCl(GmAe@)O(%t zD)43!sj7b>jBsOosY|M+*rxmO?x7yOqv-~k2mXleu9tb8UNMyl83qa&)LV_jGck_T zcL#L~3ojC4BK7j0$q@hf&Ain_XF6{IXtwjoLDF=ABs<->{VG|kLyZ#HFO!5u(wLPU zlqB0r%T6SB;RBw=ce87Czp#|wAI~Yjao6wEpK1GWDs_8QsnZ!o7lw&K7>mEdCXHNH z!D`v=E*mms)z&?>{2C?TIci~CSB{9@8IxZZMKQaf95NRJ!=e>v%STs=X~zub!wW{r z?ZRE2gpKIOe#G${}(B<*RlkoAJ z@j1EU%GVH3kJ*GTb)?4MioX9j_%1_Ebu@r(vQ$&y50#|}4$cNO&v>$7`I9#Xg-(@~ z3x|J2M`IZW9Mzh83G-@}a_jhT_bo_rqjP*OR~IAE%%HrDeplQJ?j^5QEOKlLt6mro zQw_6curV&@2xVc$=LY05pgk_six+zHv?UnRgGUR_g*+4+CwnQyu%gLEF=HTa(?5Un z;%Zhm^YG#%$fYTiq@iz&VXtEWOXI?xeEJ@Dy6VE~C+nB==UYrXQCU#|t_zLs`jY7H zsNzUI#o@D|$~)%q*1`I<6rvXI8AupilR3G#!oHxv!U$t4gcy4Q&;(Jr?8f{egmD7} zKfI$}$Z+{vnZ-D87_npPYq?4Sp_e^;?XUVXkm&Ufwdd4GdlX2Rx!*XuS@B&@IZ%Ze zSJTO(P=p;>=^mA+y$I5vdt_wCNrp)gBrWO=Q9yxgG9e`i&S8XdbYMvvywJ@3z8p}i z7m^*P8~1|Ym7AXx#XruowCCnBetCE+E7+;7?Wz3=j?;pb{a39QG$Jc~h4yw#*gYlE z!8wX|uAkh7C^}q}7&NZAMZI$D+$!_wFr%<4v3jNLgZ(9y3}Mvuwi=@#P zYVe^`T*bh0d#Z>HdT7%Ww4=G#^?BdI*3lj zJ{RtU`qz*%nZGxC+{mABW^=^8s^zEOw(h!Xtv!I5`9n5k{W2j@(2YPG`mY}!i4}XoGL9%=^)0S<4I8Ob8 zUxVjfiz9-P+x)@eydvnk-J#HagEC8ROKF(QN;1Wxn0MQ?4-@GN9HV9LlSo`~p+uh- zznD9k-Qr*u$XV#o5%ltsvLMHN9gjp0_HU=?CL?hR(q!uyC$Ns9q>qRYit%bm62|kr zoE}>pqIhQ?w&;)_7G4nBW#0V2z+g5^uffjMM|4uDQx}^q&y}V}(rDlqERg!QON6US zrW?s3%~ii#WryunI>w8M1HHSXgu;)G!E1~0$rXf<^0Ag=QmcpbduJ7yCB<2u)PaQz z!dP=ldl{7&4Zfm)P_iK6Fl$tTY2rlC=EB1^8UWHLhmGFQ z*C(?Fq4Y=Pdf5k=#LVaG&yIZYnwZV#5nX_Cuzc^wImc$hZv8xIhO%1`QUw^1ZA^rW z9cm;AE51zLq%g+Z{t{W=R z=!a-nKK1aiaEdQpv&DhCF+F*;Ph_CQwNzirU4|i?hjF~T%$63)SF>0o*u)mnjdo5* zim#~rHl6v3$KOFi{A&XSAtrwY`Tr>gB5|a2@oIMt=yydT2bduN8`l~L32_aE1uCqO z?1^R=G{P!0iTxXrU#y5)eu)NeQadJ|+;c`hr1#q~Y}m3;^}EsL*W-=*pHvyR$(&wQ z`|m&15j!ak1EVwpxe#=-&!y3*TsVZN?*tgI}Vi>+0t6!@Ia zj7UzNm(Rq-)J;TgruvdW9C{M447w;zlF{S+p2g~xN1_PQG1BEIYd=ctsvmSd_aEGo zqz4c*UO!w#zaRtk1KIBh+K&F+BVAgQ0uOpT`0O!3OP-i6J&#Qs`2Yol#C-N&&FsAw zszb^aGM_8NCy^N*$xi&(bAnXGFjQdtz;H(>jZPhE9GB9Cj6)a3`-i< zBXe`nf3aElN#J1hDXEa5At9M4sHbB-a%6ZEOV20^Q6P*VlFxokLxGBlL>NPrOss+0 zRZR2ROj_j1Ol9Uws)jb0PDO&;DTlS2wwQ2`C415RO+RR zy6nuBYQq5t>CKv06!Svn(0Dyf@_qR7FGdTB5c{-)cl(Fr*uh>MZz@4=#1WV-eDJ=x z-0x5E>i7sC6MKIr+*oksb~Duc92<--a_t3M#gIKuKYzEV)O#d|G4ES5uU9_Di+jO# zMJ-Cly1MnN%oM{@qh!C~n$PhMJxVtg_th5Toxq^RTH9a@8jYJOX_{XwYWVk=SR4;@ z^?71}`uvYkyiW^bG|kY6Fi{+5{+USOdr_4r{g@9UK${a4zHOUUGEL>}C-(Q)DS~ty zJ<^UOCd$UfuEAu$mffB~7Pk2uh8e~%>BN_t7%>};$*G;a@j>)GbuyVY*I0ih}yMAjsKkHIF)#XY6~4C_6-STdcY^tv$VOEuM`ed=PxpWNuDI#AI zhxK{<5SGqOn{TQ=F*REm%TdXIDd=l6TYec#XhJc{P4-HwcX!ZRz1+u4iDjN2&?m(MaRq9OFCLGH6= z$=jm?4#E7auY%SUO!WT31+6KyUjJRaMvHCdMY``?*}j}6gHO2mB>N}3>!oq$7?f5i>A7d=SY zDQ|xpPgi4mS_DNrZbU@EMqofuYINm6*Y(jaWB$|nb_}6a1fxlf>XF!uf$QHIwb69z-IfBBHhK4HT_(eI<4790uF|4LY##K9(VQGEnuck3r&UkegT zy*Av@TgzdAU9{T?>_>k;zv1L1;4IdBym}=)O`*#x;j`;(Q7E z)`11BuIYuWta|kNY0k^zJl5J{X8c=ybvbitrg9o@sj4?>MnH4O!k;;nde1zII$HB2 zZAW-teZWC>05PN?R7^YiA* z-ts~&6ahwX7S*BMg#WXrPc2ulX#JePIlz?M%8s+(Ys$`mu?fex_y|oR(YYb^(Bl+_ zWGimZzYf`lf%`_|{~Z_;&3N{W8dYrfbEcpV7`SVv69%K%0`4JValABqW-7j2q=>+;*+XD;iqho72PH~e8Lrc{*a(a{`9f#4) z8?9vKF-$~z=NN6+R}zGNzYTIu7k?7<)mzScN(>S}?qZoK{}&857b|6N33{!=xu7fN z4-PoYqcjt>{VJ92Ifp!;L&GEp`rM?bc`Pr=8I1EBPo?7Y?W<g|?R>bNvHwlvx2mqL2a>u`fedo|ECWEgLjcz1VI z#m&l@JYephkc=7J_~S*VM=419dn>c*O37yhRUEg_O zNHUzbU`&0udwH?T+TB07@4VQBm@*JEt12g1z`<*n^Vw{fKi>gQF@KP0u5WB;a`R({ zlvTF-z*yRb_a7Lin+rJGE+vl7TdM%EI!tK%{cdvM0*ly@2@;SoD@p_Z`x)$)%3_@K zw9=!9nFAKiX>IHs_vgewi~+_syUe1$qx$>%IRbu1i-?Gn?q>1Y4!6u5zEeoPbNkoN z&gX$gwb|Q~)l%$6ad&>voPtRIX9T5H@9W1#8U#FN?;Gv`W2Ox@o7J%r{A@oZa~DukQn^&!xv^@z#ZcbZl_@d zX~fUhT;`(Z>uO@)cHs$=1fWFa@i|xnvxY)-7UgVF|CvT-l+%}+dThq6USO{=|F?_Z zu0H+vqHaXBWhLESHujcfg^1hQU?2Z{D-5wqwD^OPyJL}h*T&ZN_naeyTo?d*r_DO& zTY<6m3S4HeU`~MyL&DSZ7C}qna312AW-S}C%D?w;e#D4<@$j4L@tA(3zl{S`aGa%B$t{I}?qyL+fNAs0;_P?+k}gl}xsg4k*fs zxO2e|!+*2H&oe{Oql5Gbe?O7_t6mX+T`GzF!R~52uYfGR)Rj+cLdLxDq=MnU{|LK8 z)JiYpvs$=P#ZPSh4KbA*wz!T-M?yaoc&@;|6-*bIKvpb3c6>*upF*67PC6+dyhWWp zgZvjAu8dph2W*=X{&!;pto=eJltM4LYOwIbh%gPxeLCp=75LLUP9isdDDP-g#;8Li zZmX<^5)aZQxlMmXjPU7$Uv$@kA9J*{yY$L;T2|-5PP-C5V~jx*>ifcDX=y%;eT=i7 z6bJclRveO47!fiF)K?@3ZVk}MNB%M6k)|+PKRTTu631O2BsUl&B*50cjT(M~CY^5+ zj1f`2J%H4MRF33go+*abb9W#JN?372bMN4;ffWRYwnVFlW=rTV_=~w8tPLId_>UHA zxUTeqBwq`$ccvEg)4%is?-j6x4&<`xB_}Kyvn$FDIQ{#35gdtN3ZTGnm*oYR|GY%J zlh(Jklt=1}E6Jk2AdVnVvCUr`H6$}CjJWl_=Hh`1`_TIjTo)Ysr!XZ&8V#~28kmey z;h)tm(bf)ZbNcxSF$_Mffj0sdKKYJHx{&(@{H`%MCfrUq@9@KH3PG2*r5zRZ;5^-b zc?K3}Fb6Z?Bemj{tiv$cZ8W>|W?-z&b_D+WTCZ=v%HB0Arlv^{^rrKb)zJ{nOil>K1mvcEaCSrFc2HNqpiy~Vg-=i(CqFOC8 zIo|*WK%F2H))di$Tzr^_u?&}#$h27L&uoEG1{q@mG4TEzmISBWXffjMCI(9YO2_P| zx?z%HqiHp(#qZ=#;8{ap8DMNhfV6zY@${O~olq&qi@M-IBf#-WNPcqoadL()qDz03 zTIte99kYC6kr7ie{IO9ICyA&a>c5(P7U76vlv zKZI*6>se=2$D?I%Ga5q`qN5Mg4<{hY*46g zIeeeka5-CDW(+s4>N6gee6gx+Po=2}mJf-vA&}3O!k#Q%BStaW=H0y|J^GMLX=F5q z9D|;xJU{iI)0d;`!NaMm(zo!xVp876BQ@6ln!d#{iQk&`;0+e`I5{)=haj)IWFEHF zfYw9rK}x!IrvFmQ2d@3C<}R)-8&#_n{QS2kLrFYs$JYmpy#DU}vAnLGc!|7 z?EHYeVP<$_gc$Q@ENzQ3xM0IL%S>h9`KGdG;Bt!I#)Dh3r_6zWn8D5gSr`w(Z|jOo zxD1HHK=fDSFW&sFC$QTuv{{ZA{7mB8KP8cYfvLcmxw(M-K7KLqB$+##s3#I+{ z0ZVjm#&v*N$W8w0VPEM9+k5TVVV-Wv!*4j9doXKr+UFQh>v!UUY6QzNSSYYzU)gp9 zBDZ2~R+GP<$Y7=>WV5p4B8oW+nb@J~b_R1zIN;Wz`)L$BR!EK=f8+?QBJmyNziV=0 zKH%7gOqu0Ar_t=r5{Q$Fr#Xkg2)}K66Qz;5q9Um9Szf$r-Y7*J8j)KApIxg&d*kr* z-^o7w9v{@-9nX#yurG6I@&kXId8bZ1#GxKc%nttT4?v>_12_jsO7~4y0#Azq!NDxG z{-_4bRKOlh^tfpN>{iYV^z`(q_5b}_6%(`UdHI~iNB-Cjs!asLNM$pq@j2Xpc{OHy z|8G!$eiG@fGUK1${duD|_(^c_RTbYe7OIG#ps*>1QLXt$Wt@b^90S7waWNExHy`Cy zWfwCMak8Xw+3=~F4RE1CyNwv#{etJ{?h84EmES~<80WOJy?aZ%-jC1%#i=NG`z@wV zS$+CGju4^56Lj&PL8cD&l!}Xd48)e%WT`$(Gt@mlea0BB(B&-IZ9-i`h0hs8S`{`) zN{Swe7J8Er?b%Yd;bEp}sQ=ut_V=I&LS7&3q&oG;>Ab2Zxb!zD(+y)c-B)T>UsJ?% znG>{^pJTkjRlr}k^qb*hLa$~}V&JID|(l}fK2cWsb|5B&>0p0c(_ZxA^I})}C5)d> z*ILIDog>X&EdQK%q zXry8CLGME;AE^ToPl{Y@giM^vJIf1|-P6JFy293%A?O+)Df{QmAC(>mv8 z$JW2A#;51zjF;!3yRLBbfq;Wpyc`@L0~%v?_3A7$$kPRbP)J!q!otTpvr5uPO;uhG zb*txY$7KN10iymNA0Hnc<_`GxHxvsWe7BI?I1Q6a6R^hvUf#bF$qk^}6-B0_cQ4L% z9p94!7KF7FCfrJImvnm7x$i-$ljOY_oq1~lsT_2(g?|K|8XV%04h|&23L|xQRE6sm za`Yn=r3(h%>o01hoLy6t`niLHfJ|S5JWI$@?**!1Sg`-2ue9zwv-ezVhzO4>CZKoO zKsTo_7>m`#Kv4xBE_~yxCbJ^Mco6y70fSze0TsfC+ETxj{7HQKIswtktba7{p1Pe2 z*Cr&`xFzff$W22DIMHgz$kiK*MC%fnONhUuld_AW2Uk}-U`>=sCAu9&E0jr2ag#Mp zOTLY3BY7$>y5Yx9bec!Y~@2GiRO`L2>a5MS7D*=Ff~ z&)n_1%>1z9zPABOHzi)zYcO}cd-L5^RV0)Kt2-^bG#+OxFOcWYt1`mNL~q^;M)XH= zfd_I$87?nsmiAW%$P&RAYiGJ%sl*QD_4ns<#2jRWpdY^FqTQZr`{8>D)&d#r!OKq{w;IxdFUkT z9m+2XjN=7>3zGHoJM{MB+zj4A`DohNlzz!TWJcJtJEHUL|T(6X$d=!56cRd&_#^Fh8gAJOO z&3Xpvt5PKP-0jy(95R0)CSHE6 zY$d&fxC8;N3P)`Y%0|V^rzvFOpP>V|_)UB1+-7_U-u5NG1P))}h0v*JJwDpxAGDCm zQSw)r)Y=h0K`#)C;X1v4x*uRKeT&cTW#kro&F126=Kde8EX^Vyj3dH%+jOKMZ>jfy z`Wh*9A&0wdRy0`sz>rWc8+&P|ftSA%<-+8nK6e)L+^3|-NG5u*EA~;N)6awTkw=Dx z8Lp2XKYsJz>2#H;RK1}Ng*nejfvr0N?9#{#sheYol%1+n=+=&JL=97qkEFDvV`3KC zrfWQ&^KIjeT+7h1U)fT_{0V+rmy^}zZO3A#L-9vDvt_Bum5u}$@bP`SVkteLA$Iuj zUS-};rlcpeJ7^}otn}!v2w=<<7XnxTe^X01HBG`F;HSMtCi)&i7ak5@g;sH1KN$3a@f|3mgb9 ztiLV{4Rw76JX54sAAk`4hhwK@oVd07g5YBWHdmPMxmGMf-`u#~Yg9SmUj8-gC|okP zw$>FIF9>rdP+ho_jCHV*Mn?*G|FMwvgBrB<~UHvVYL{TybN8fvQ{ZnEnYKna8GM%Kx~=Jz3aJN&L3G!Q&|j)m$IDP{A?Yk-3Fr{ zVuEL%(WKat+h3&S_x2s2jRtOo9wmpJMY)@C(0!(3)+Nh6UEJhL`8Y=56W;B@>s~ud z+>?8+yJ*IFzl*{$f$Obe>BjtxU&}lXpm*K(2ly*j>iM(wlNC_g?-pq1y9gfZNc0Tfm=@5gh%haFn0k$wP$OPX!O1^7f8{=Te#R&? zE%qXmcB7nIM~XMsF;7*xdMud@j@3IfB2?q`98ql;y;KpoI~@V@t;>V1d2af;0z(tu z=0q01eBx9d85Jxd)1InZ+0V9QwFx8?|()GRJ$G>$? zyZ0S_d9S8yx)U3|T*X~G6>VuCP~ji=LAKlMWpC95`G9MQb~;p!h>N6c#0Jdc!cBux z?mr7Tts+(TeoLu>dRYe|`IO}!a)>U&oF0|OgHu}>)Is2s8Qv6C9* z_sJm~MTp1c6!?&ne`=iD3sYG-1BD{Ga@&U#(>!j$1e17&@q8#c!Ttvz`py}uSGa}h z#gre>&W2$y;u*`K$k8N56e&%Ab7#0v1qs;SM5i0e-L${f@wPgh@HDiT;sY=K0^QE9`cXoi-Sw##~UofKmiW%1C53dmY<{}`Vj|q#M%xLmA zn$m6ca79UL%eZzK;Od0mi+$J8GwHpoFT-d~{wDBUy3Mi;FPfTxxz5NvrMwK)!W5dG zekymuVU7dc_CkZhtqd(8;bdl7Vdf-LXz3wdyY!17@Ttf_0x3}*%PHa-ygpLYu`05A z0uCvjYlFEf=ikCM(r)<=GHIPR?!4Z4Upk$_*022h4L;{4&!B_&`P1>}y-%xh-_ij` zR!QTw1gNI@O4=Rjh)FG8*JT3|)BULt&)yp<__`%~67+7{RyOx(EVN-RY~9hFXI9pc z#=qN&h+V7vUj17+kx1>HBj&{{?%DIIRXNAMbl8aJg+{(CAjFIBe>{s`6i^wF%&oHc zmIQcLM&QNCf9pf|$x!JyAIm3mT8jAJ5Lxt{RdyFJ5ww}t-cQKo_fvhf?B#An;#Phy zv)tAkKwZR|_j44nJ8FQ<007;TF*Cr7NjZ!&3fJfV_05-?bQph|2X75=S~L!IbO+e6+vC5KS~ z*$GGcZvl(qXBOTkJBj2#440*#45#Na$}7cx?u_NT{u0Dtw7BK5W<|n9?rWlwyi&Um zriB*DLPv_jlSqR8!Xq$0hYX4ecu4hwZ=tj60- ztN8Seye2-pRkrx01YGaF(m^URa^<&4WCWuh)iHZFjsIMwlW@kkn|PIAJ^P`}p3uSZ zET55ckVdB3txq19f8Mrxbh6A++-6eLpi1v4BH$LyY5(JmUxK8zn1t zcj>NnUH^cy^y@;%-G<9mN$EQj4Ubq;>Ig5sF=uNN<9-4ANhq?A2Q`#0H;~3tWOQE) zJXWAt7@vki)(0*YfbjNaoIk|v1mF0){QS_!JeKBzVWmX@<95F$qV}%Wz;UQ%i#{ga zyFn#T<#TA~6?O6DIw>1Dk5x?ld!ze~;5rVrrmTIslruQQcK{D9G_C|lc)dV#9qbJR zz4jKWJ1&?~jWk(PXkcYm$~`?Rzh_W=!elh!l!YL>0Q`=dNQkA00wfHtv1SD}sCsj6 z4LEbF!=SU(+)svhYR{ePvXjiLhU`NH!>nT@8GuPYR4wqgO*#VFG#?`XGI%Fc>w|rq zFCa_Isu==Lr|Y1HxSM>V)1CdZ^S?>+_)NfH!MElSwB)qVuX`1LSK;|MIzEo<8HBR- zj)G>{81(x(_fy@ErI*0i|31kz=)@*Sq*Ic?inXqwU>R{R_S$bZp$7pgvxY<)ux8l*4B=WyHLqK=bQQ3jCa57U>LCa zot>Q@&stP=J!XRe|0=Gm+|7%=pAMi4U?PWw&3}JIqb)$^4*NlXP@TwJor`w%j+%BOC1}?xS9d1qJ}4|9}Ql!f8xLZyO>G45t`WZ_D6#itH0J zD<8~>{oCtoYil#qq2{sbT^q`;odsu&$5h>m-SHi8xBEvZPRbFCc%&zNtVm-^r2eg;Ze{IZjc1_ z=PPN~xQZ0)^~tTmUwqBddReN}qrmn`UY`k?kmrj&Xa!8y*zpdWiM{wDtqHo8$F_#o=*f7=|@`r9DDncWIN3ue`SoBm0lq`zv3bALDi9{M$c5DnUYy@8=$ya_JNBtP}` z7~!GS5e5_+8{38hERA#!8|?3BTHtsg_Tgdvmn8iT4-;0KywE>5@ADB z=Ie4We3U~j&?y`46(2Ox1V5+NHiHCu;kcnMB$*=ShjO! zGeD<9qWb1R1(fJzW%gjZqL{PxuC%SUxqj2|(Ia3YY-375Dl5`@ak>eADk+F&dC?Cq zf|y!<#G>uppa8PYZ=z+6&tpr!{wgX6jLtU!bT@2rDX39`4s>Ux3EDa9Y<~}5U*9%k z>lKI~`u6j`<2(^_>NHUw_zRGYvj8vz`9-{JPKEA%4oEZ?N zfM5oBps?q=_6dkH?SRIrzUKUiP!2BorsVzEVwV7h2B@jUv&vfczdpfoJPYmBqJNP$ z0z3(l?}0AXxhV=zDg=skQ5GD177|rpP?o=^ih;4B&(D{CFoV7&Gnb{-MY0(+Izb${ zMKa}ZnfKWQ65dRcYuhpKW0%Q8Ag0kh7UP_61>DvWZXnEEx9iivp+xn4@?QeudzzW<_iS4r z=K$V|*!Uvw?X#xkSBwhKq@LyC`(qRGaf}L1%+79S2tzGu-IrePI4`<;4#~O_Sd*2N zm|jf>0<4PTruGS-2|1u3gg9vi=L#|RZZkFk^aQczpX{$f{dfj>WI+CMg-5V>U$z0KlQV9yeses_zkdo2 z-}~{`v0Mlo*&RcXKOiCU4c5lsBmWCrVxR?$#dL!sEg?d20<5i3Z`ih8_y+X^JktUA zf!lhrWi93l7(Mkx%G85oBxz@TS*SbxAf=f@C^5@ zeTR2qaTr42KtMYweHCWc7^Cd#^V710Os$8)g6-Mv8YEx@2Y)$XR7vpfxcZQo6IG@@ zJ^d98{byC#0YB$jW6eJ+uPYP@zF{xaekq#0t(G40t^f8I?KN(CkW84!x@4j>s>fMG zMN9mspR%s+N}8&PnTMH5ZBdm+KrtydysvJpYT`L{ZeDyNrN{I}CA#vMzx3sA9h9LM zKim!0cewL$D5$FPa}>95XaBv{C%qvOsL9=(5W2rf+3b;B*NN00>-VIHvp*$x6T2e9 zW3Y*%IbgPH{{)G;*`HB0K0hMNX!T#D{*#bYsqlnl#fE|VB|ig1DwS1 z*nQnp0{GUBr0_-D7Cr>?s+5gw0PTIh{jZr-0Qi}Xnpi^EfX=1`)_Nf;a$9zq?V(6S zyc@qS{{i%u7$FF02{VCV1B>cyD76-Xu{I8Upw>Ah2l8zRvUu2?KvzrS(Y(iZ$2Jy0hTkeg3KZ|= zs+FVz?C#>coonT<;g^Y z=mA+Ax1L#!1VelKP1uRiynEswZe|JvJwW}9P-k@jm}wg<5#_~Bgex@W6MysCRd?&2 z-^jIVwnthIhZFe1tNsPFiE4R-t&n_=Ipc-9Qe-(b)dL3N|Q&!_`_Bk8Z4?{>| zbFhiS7om4A7w|6@K3(U4+1R!=|Ji2uTPBIi_Ym3)sp(<2$Atug8hml^PN5zTQL(wd zv(Zjr&YJR~-8NSp@!j zIa0r0A|9FF)B;=p&{@s$eMZ%-0n6Fz;M5`%zt0^&)o$D}82pmstRMI;&JJNR=RhHo z4OIyA3ZVCZZ;+yJ$J6XabF22G~_%WRpY&+$e0u!jcfi!M`R~ z)-VV#MQxJq%^*n9!-EE#*QS3o-WZ+|pxXzDn|~*lNYFrG#8eXs{a)WwE=yn%&m;K~ zJ%A}L>s1o&Aw~BIgWS*_W3XVZcAW#YCjJyB5{2_H$T8*}9+tOsU8E_&A15Ezkx6yb zU?}fd#q}GVqWM8GHLNLQ9|AH%P+ZvLTnNpHRc&^tjL>P+Rk3;6theW}79;N}`zS{_mV zgmV)BN=9Vg9Nc>G0?ovq2`5?5t6TTHybC!JM2lraOsrO|TQ%QF?;A}jHv-?b3q)`I z@IfL zY!3FB-iJyGIv!BE{QmVNUoL)Ud%F!xUEOG3s17 zfb=I1^CC7>pc*59y?(F*&qohpc~#eOp9-!9$Z1(^a7)q}7?>>S*-_%CW zu<*^ZQr$AeUyzXP>Y=Ag{_?`2 z5}p9U>OEFb?19*Mu9bZ{ntiz|mhHD;VKoP^Mk;XHl#Z>={TYJvN#TwN48M5={~_p# z_-#(S0iV5uEl?$D%59$kD!N2mdpF;;?0~xCF3B~32|&*E7q&#vQgiOh{-hE#PW>R0 z$z^w)kSdf;wuqO5Gxa;pv0lqOE!JnHPa)D^3t{sk&q<(!j5raiq|n(>L>8@bhf9)0 zly}rFG(>}x+x(`tc+?&BW4}gYr8Fjfr?1XbGncp1&B#+iIIl^jGNz4b*4C=^on#rt zCVqPzLCb}@R!8G-Xu1-wfh{!85xGT`l62xVSiIQNZd{6(<^29`k91Z=P9Q zkLSdN;0WL)6tF|Hvzf~ZaJ-P53DpFkHuFucMLm-#EB%umvs~uA(13sIg(RIlj{nkQ zh>R^ou@Aiys+4edCiNLE zWqevHhY3x0brzHKOpB)@P%4iey=fML8Z0eRm(3*ih;HM0wdHrx`rK^LokCs7TiXCP zM&Xj>#cOYnIgt*&gyt}ahN!m+8?e_Tvq6`7p?Q0?fU}jsO1M(U)!$--+ zQBidN20QGQUJ*fsB;><^qJy=l&!(MBpMT>=fvsVQ_N{FWX`eSD(2UvfFSS7pVRsFn zL7-h{K-mqScoS#B7c?1M8#p}B@d0l|cI~RUGI@m0Zn!B6{j$r+V6GHAFIql3Z7U!1 z3Mh!3+KeZN_3x(wp=*V|v58-kG}uC4`)uM0g0#U_!7&Hk-#_LrJfQxWHkXiwl^Z>u zYZ1W}{1{k8?ngM5^O!g5e0+R(m(4%>L z=N9Bgc(FqOiTH=e^tuG_ADwRO>|B^!ZU(M968=V9;p^>EUOjc=DJX?4B$(90I==62 z79yK{(-KofVT5=P{q0;QtZy86)^@1NUQ^nB0bADe2`~%?&W6CO7=Q^QmFk13+}=OP zhSC^XJwrQq@5q)o9{Z~)E}JYxv|)@2y6=omMzz$|ZDt${vMBN`y9L&a{7=4{jC0B1 zFTrikb`P_iTLEF?IV=+`0lmVLiVAJkOW1&#BNZ#^TsP-|Fy9a`hvh5?FodQzHGsQb zM9jc+S1@&49h;U({plV&GAyt?jW;>%W^IHwY~T^ zy~v=k@p;cnM<*^zw<698hlZnSum-o{DkUc+&9l4gX4n)ecIC&aw2jzLP_f4q8U)wq zl#*p}&(uh&<)4I33YZHV^`6Kvc9)^SE9b0d+<8OLYNP)UhXnWiEiJ|?CD((cr9&~W z{#gX&4c54d^a~SMabavVl3+YF{m3cBN=m+rt!0|hprv1Yy;vLv_4!0uH0p%)j$GVg zqNhcQXlK-*BzZ)xrjjC>oW`ibf^glJ#`WBR&jNYsFHrJra%T;Ux6f(t1TiROU~*oX z_c8+`MqG(NhK}P2FI@yd^~MP1k4U*U(rq@;I(7sxPacuW#6%dJPvLOY2LA59l6z4% z5Y4QiIY*C4V@R(>awRKXXzA9Kt}g#5hFc3WXDYh{;`%W?F5ljVhX+x-%(dSEZ#VQr zae^@CjniS0D~oDWix0u($0t~ULm@C}PvgGjgSOtcArM~mAk%c6EU0Jx6SF@9w(l36(<;Y z55ojgjKx52V14(4GrcMOOvANj7T&_%DWH#Rp%8U|f$Vuo-!;&6plt$!+K7q{NzlfaiKAuebgdl#Hm zHdbmJy~TZ3#6KqUdw&yDDMMDvWkpmxs#BqRJ%sKWLtDEK_xI(R{)dNw z?#lg!SaEgPM9ZXO`x2GH$mNcw;F0B5w^Lic*a{r06EWY`@xT0Z6~}#S@aX~l#!s6p zrz7>j_}q*4Cyw><9$Sw$1Xaz}_Fi-Ka~eI+BsCUdX=-Rv$P?_6>c*gsm4gn`=fH*< z@8u*r$u%KPyW1)i-ww>@jI>AMWxI$iov%8*ebaPNrj`d!7s~3+=lJ(Th`OEa?aRh9 zP~!G&hBvjQ87ZYO`^w{FTdaj&aV<`Haev)!YjPLAj&HDKXLU{ND_#4P$Q|E&Q><^M zauK;qb-ij<`V4=qT(hh&aR_TYinl6z7rfp;Uj3Kl#hJ;v`J_NrN64=&h{{;n4ISsa z+~4(}&Ofv5y&5l%=VL~jLJzBa@6m-jl0=c8IML19x=rt0`vj||WXE6X{A9vFVdmOA z#kfOY^#o?@ZAx?>Q*m*NTPSC&u)h#o&|zbMO)^@Wo)C-12jfaa(XtsfCzd@B?o22e`q1icphc7(|@aK8@FN$ z&;7pPo;KmBJx?RDeX18q<>Gcxsk^XIG^0FY0V7uMjk~*3J3k@-iSPy`^mdba15XZ3 z%QXDcJKttADAa0J?L;2IARD7z_-MBPRKy2|hqmn2A49IfWAVv-b6u=Y=$94lslri5 zy&-$tVvMymtJckWpGU0O7qCoxCh+n+l+CjBc)}#`JPe08?$sEmXSt@Msqs=2nXu9Q zulWJ$AO>B{D-M%(Aj)6^V6{2feIs(2p+C#t&Z<8z4vZ5S(nsvB+;sTRi)=%x*Dtyk=)2bkjr#Uxs(6Sqlj=*@!k(v*c4bH);KTY z7^Ml5=&Fn=B`!F>|K8}^;7-*^xv`GEBEzg4Nlu=`D?`__^kUNDZjiVnHdT0pIpLo8 zS{qN8#uIrImMFtRwq#XI?8NZ%#M{3A>8?JUQ@zWe)2UW|`qofJhuQL=@4Bz-^%^XU zgDcO|n_~GV?Fb%M5+&aLZ45W_v|tdUoORFC@Dc-;|IaX=mWVx;UrDCO=+|Q1=N=oh zRuqIh8V`trsw8+ZDbGfiGU7h4U^>12O>tFzGKgHfbD@tAb7yKM;;Yv#E-=5NUxR6X z?anzy;~J?Byw7slIo7wd)(}-&a`fW=>JR9dA8e@4RT6w_CL_VMV7$p z^|(WoIp;eo8=iow^4_-+(bs9TUyPn6#aAggKOg7=F1-O@4?y}&yq8m;-{a?Tf3K_F zdOT+54zR_P_xFu(JLmjsx%u_-)~=qd+> zU0nc*+C2weXw+1}u?RjVXqxa)kn~pj-V`1Gu(udW5UjP&$2{PV_jxV_>(F$y2`x`Q8Poovn0+6rXB}?))R&M4y1H!O3+vr#RlwV>1X2#XyST$WQ4IaB0dRn6287I?V?n* z3I9N>L;>X-K&P-`tF$F|wWFay?+rPuVY$B@3i6J$iPoKnTWe*R&F#B%`1A!awf9oI zZ}FP+Y%w!qIPtido z)pHYOBAgO@PW$bfdPZE372q#8o}V6~IGuk3xN z&u4#gI_y5AsvN(DjJCjoLL`#xkKV7%X5C%kO)<`VRQv2>j8|!aXe2%9K}oI6>6gr0Pr*2a`S8ffo8&zORNMlkwtQ~Yp5#-P>n|SDpc3Vi&GmR|6I5d8M%gS-- zZ(DOcZeY_Nt$n+4y%bb4e7|sy0%6Sdc&~MstwDqgSeJHwEdOY3fxL+&FXC-#ZBknZ8gfC??q3F zteNYPn9B7z%5Vi?N*cs<&{4U7+-|+HydE@Iz`F0isf4@e@pO7{I!UwhbQP!}AdI6y zj^5_7tawu%R5rg`_xr#51s|HsV!4mOG?G(+CcKuRukRTgUY?_))6Gf%8ekEjZ>^xh zofi{RA%#l7s&ya?ATloK7_k4wMv4HOxWtlDAihf*52E>K3K1KrQ`^i ztOD6m;1584A@DIOB*knzb#xR1NyG^6Y@Ka#;Z@PU3!y;x8Z^7D!vW@&D*+$~nGghw z8vr=@BnwDt2<^BKavkAr3@Yc&p!I@I=YHHG{J)jiB^Zj-Ph`&5*=i**f&>XjrpX4! z?5qsUl7l=NhUvltgXf#)dmpagyLou@r}HV@W1^UN55WYOM0SH3X@gz6`fDg;=_1VA zz|HuN<(EzOM$pMtCos7M0<(+JitDXGM~E?ciYK3*Fn7bj3N0+R=i6U|c@z*o#4ZXD zw{75+IKGsLvGf0esoNAFQs5JPMB2^3A+h%OSI7|la3r^}0T z!@C;7Ilu~(7Zf0h(dYQI{eGM^0e8;zyj|-xbUPSk>4e*+DG2_X)d8LBi|TQ10gizM*&trRuE9cXqjZ3hhCk&dHB+o6hVyfn$B( z;DzS7D3Lui|3lEb!el?iq%yTfZW-LHj!~I1pn`)nZB7tc;0->8$VE5(nAu7inZ3M3 zw({`|JmitLa0+|JBv#ao9y{-i2x9_HZMVC8;NP^1@@qQ_53{n2Za94S8lr#foSl!J zL@~ZOF8icJGrnb$F9%JJqFdcnfmZwxIX4fAZ>uz1U0yFqVdGx^n8u%zA2>Xz=<0c4 zapvEwtMxO$9RU&v%g&1(#l;U%jv^wL}yy{HiF z3SE2An~b|U_Z31k-S2bWkp`RVNUg%#A>tfFXWB30b2H3)6R)V2+ezHP1n4P9?73iNvw=VH9@!6FOcRLEo~4c(1ee8p00Pbd0kk9aSIx1u0GJn#O? zkkK>-I?BoX3>xB=w>|1=)KZriD1$wwiDXKQv?sH|_SZ$t!&H)=x9~+Rer{>TX0Qx6 zK>sCf*Nf^O=5%|&Fge(jbx%|w3@ujG>ZL2~-YYaZNz;-d1!@LN4Yg%7SAE`oUliSM zT+PG&q3DU1djccxt!0Jwdg``WGc@`&GNrgX4|*qL0t<#$U^F)XILXLH^X?nFe%+CA zpba;$hc)#Tch=L<(@U~a=#g!?oqK?16D9&UU|?UQ@%2^E~}mB+}xZfgb(PX zcV}CG5cGr)4-noCoX=yJ+dXhQCQ^HC1Vv7vPuZNLr@@9;htk^_xOEev!6^91RKKm!gxXbtldqfiSO2$eSn)vt;3 z7hqD#i^uGM_q|SbU%S0gkVFs1KJyPBSpX)V(PrH3U+*5bbj8yGO$$P}8=ZRxBWj=^ zxHVqN)=}C4q%J1vvvUR?7fVt0)su~fJhl77BVV%)wQ-*=BXtvuFRTn?2ZD4Fk)i{$ zTyOXkW*i_kQt&@B($kwjIIqfVZ;JJfbefF2zYc2)m&|wwwZTt{CRH``YSDnT?1Wt zT$lLy{cY#FH@IwWqVV&Sn;AZJIi?Z5u3lkMD1Ds5O@V$p33Ijo%1`sp;xuE^Vja4I z#?*D-8CX~M5RL)z@_?!px)&;wjkhjP7(JDIQ{`$qtW{~tsngLD&mH;%<+SyMfQRln z-R-KJ5}$1CdzeE6MD#_Sjf^;w*Vf)ldbn4ByCfEs^*iHOMM_>GfuGWAR^}@R8?kxF zsL(HV`41io5QC8VG;ruFqjREi^pc_nLncQhQa<2NZEaIddyKqxHCViLgJ*cRQHG{~ z**)cL9C~Euqj7>-9kBKS0sD4bE(tz%jOZ?{k|Rp6zx7 zLi=s-FTb=yXSRIc08X_7{0yirNV(tVY@l&OmRgRbb_q7vjg^AJ1=(-uOmzud9RTkE zur4gq6H{7g4Kd~%qUqOZX%65E z1FG6-u_CP3CruqOTdyFDym4$lL52!p8VkUfiSqXWaAy#HyjPe81%SICILpBOMZ{id zHSHkmMN9@JHd*&O;5Syb{^>z3Hzf3A2eG3`bI_*rG+#sIOzl5DnE^$m6`+$^g~|ps z7E$-9fc%5_Wev7C=#1qrDE~*(dB;=v|Ns9w<}u1Kvd3`{iENR=;h5QF%iffgy^cLX zNcI*P*`utCWM#`PiI5R8lkj_;_vicbC*9EHx?b0FJRbLl@P>EODv+4m0rx!cBy5gs zd42(EZ?NY*!2xr?1p)W-WJF%$G1#?!Y4fc=k&Z0{3n4Gi`+~0+w>&N5Z3PSA@ySUM z;2&$Z9>m;4XWQg$J{Eab^tOYdQ}P7Nc|hF&rcs_?@jj@P_GuM}&IJr=WWD9R-|JOu zY;6$^_dl^IJZi8e^^Tx*m=NWXSB<8aC%#t&R?)ZBCvle^1Uef@j)_c8O&tMnP@T{h zG0zG6*S5A}ucWkiXd1f9iKpnlpSjGe=LJhAtbm>y_XC1wCnr6COVbgpAP_&e9J%wl zQT&tMupRt=4>EB!@{tngTX8b?di#4@?VsA}pI$ABO)Z$aCi(rUK)K&!?@bh_sq_#8 zpl1gQeOwI)GP8B5Yk`%>*-&BH`CH55lcq|Q+s`uL-dSOL;{oqW+mY;;Xs}=cg7{qECj<d=`VOx+NnsHM{_QAMeVg7W!Ka8^Wth zBwhnz=#CCSlveAM*g2QD<4&s;8_n|Q(#lHy|9l9`K=cwJ_Z??00Rl)i|81ewz|>oh zNYw`?LHigBKcSFWtQFyu#l|V!`GCaISsZVcMHEVlZ2n5<*=8WmC%-vC8yz^Ptkwxv zB!}R?kyeLRAvN@(rr|knm3i{71vskaqcP@rehG%9lvVxl=jL9?ezf?u2Ad1{f+EO) zBo8WPBQ`E;S_Hyk{VwXSEvqUI^+$g^v@o~NV_nnKkw?1&V1AeK$wsV4i{fpZC`}TW z=y0hNYOW*Uz`nJAR2>`$nnrD!8^$f5d;~+18|}&%Q1)&8{HayjCX7`L0|K2KFrER! znoO!5Py+*TL4{t$pf=SwPNQ%ujaw}}pXL?g^Sc*+R?bd#^X~e(13V*eumKAiP;vUDJ*ap%!6^_XVV8S6hO+eWT04}R3rMbiy}x6ypq6qKua3Ise&_p zg4cr2u5La*3j98}ue$<^#g>6~^~_svn*qt}J?-gvu=EF);^^>DG%BUn&E!^EFgRPl zs=^A`uNKeToB!c7b^swIAG2-y^XQK2lB^!fK(=Yn*}}J6w_b*5gG#+4eK%$opd|sd z2$(JaE|6nN2Y`iui@0HOzk-N8hEDw8UjQ)#7_3d}FM@*sIMcx@ zVO+)f$ko=MBPejq?0a@AsCa+_9xRwckw3S#jKL!Uv>$CJnKQg7&_y*&gEC#KPt!^b zq^mYPHa0W+2GkcvW4JdhaE5?^){)P6AkZ4vC3Av5R!cO?!@Pci4KQEs2X+f!TQTH) zS;zwvTR{B)8fonlruL}}U!YDa*11uyuR3|S_x;2F%u&_CM}Av4Pu2Z2@LMkefDBl& zPJ@C1G&^9UdW{ljxo6%OPJ$iy5rOl!_xEB80pkI%gx2K79^M6M$?`x-%^Cz;3I9De z5>bpdwrCnM6lW|u%4vo9mBq`Z4OOdLgV{MM(fbU#vi9@|LThv#e-tuHa77;|AWGD; znu5;#tcHM}SJ*I*xN6F=)!W(bc<2YUUHC%L^3PN+kuMVxZqn$oJNhbC4y8lN7(CLk z=2SuYaat+_?DpTuDPu!8R%f?7X4j+a#93{pGJ9pUVaVFSsHJV(Cg#=#cu%Zxs(?qf z1>lT;zs||83#gmxJ;@+IPork|ezlV};MtY?*U2G#t40i{tgRxIHmme*E(#@6)1qmc zdJzYuf_z9K+L$14elGy^t@xmZOLq z`Iq$-qfLXGk^PW44FA=!TrE9)m~e_b-7ClTT=<~`8*O)_prNS-K)@%K)LQTmqMc|I z;gs6fy`Feol=K>zb_1O;B!$x8z zhH)y(1B=v8t7~afH!JA$1<0oF=l!B)b=#G@En8LF(%Tyw0~$xjR>!Zh-<1-%cr>Q2DD#c%-TX_ac zIo#T+zI-b|ik%}7 zL<`+=G2}k1P0^NNh={uxi=qzG_4i-Ys}_wtqh~M{euxlMh9lL5Zex1lk_ew6xO3aA z3aMN1xX#V`xH9(3>shNmlCjDpxhYAt6#Xjj>o##8;KFGW0ZtZRhUaHyW~Qgbfs_XH z4d6xI*nb&#%Xdk4cvUL3gVAro^iQ>EZ*JSqJaXN>yp$8W|9{^8L2JCD;M;rC2=+05 zfQ51V-+^jz;XK>5cPcWOZ|899!F;k%A)*lMy+jPnQvz)=qDWu+?hNs8Vx-)UxQ(MF zN(_9K+VuwcGtBiOf~RHIhp)^tQ(lWJyY8mi3{X|~Kj3yZ)4CrMkHeh4c{BH^wo(iR zPK+#vJZ=T8!1^F?T)2wc;K=0LEiW!EQn;IXN_%hKgBk6{~;` zhs}MKy8gs! z>)4gZ{M)^E!Q=p}Vu21bm$A}XNL~>j%?UZx zyDjZwCODhy>%@FK2pI4TI15d5-MVG}t`Izr8u|17J&1j=Js0M^1V8DestqV}v}trJ zra*NZt=w*^AF2{a|Jn0lj!M>J26CiWzn30Sv70}sn8^&Ggd0&85`{#tQ$b8lA@|@l z(JhU3A;i&e`}sB;0^C%sCEB^d-IpCU+?wjK$Lmlum`<7=%K1AD#$2D z0mcD9KKtBkr)Kz4AMU)T#K5Qsdg~D661BG{dxq4YA51bf1e30*F#P^ogn6NfY)|rX z1ZO*`7zB8J)_ybD;p#RB8=)+;6Hs=XMfNi+#7|rzp&EM?^UnUO^qu*CI5qe)i?SDJ z2tL-s<@M4&qNR8Hk(>wu*k77|=6ML10E)NbHh~4bW&8EzspA!b&2{^x%D#U|M?^dM zx~Cq+I;UVy{|-!Cj*q?Kr+`FG$87hmGH_r0@6QF`Z0D}DZxH5a!3Burf>I=C(=T6Vg-5K=9X^doaA=;=4A~qcl@}DM*gl z;bw)}T9FX_=*~~rZD0~I7V-)S`pDDx`{?^2x6XNHnAU-<&QNiuy4eT<^JEM^cyBgx{&<4LIuVZCz7A zhUMr4=F`lZKoa(YTB*;LZK5qhwBS_a%>ThVkNVOxACbCnI%k;~7|NHi;a6j=EoZGI1G^c@d#JB|IHk+xq%MPXqOl`Kqk2tT&-+wdEsq_=&*+vC>p6K`O-Pzej zT-$sKI7jqt9E;;4dpBZQp)dZhoEtD~Q zp(scUfoF~0LzxOvMtApdDm6TDSn4cWWomZOY<{Le4 zhdU}g{NDrQ=01EcWo}$WIY{n*-9VBm`NT`PCpQi z2rs%ax2mzqiHhqu(g0f*JU8DmP^0?h0{ zCOZpQ@~U0WfMiF@xbpiYEkWW{E(gr4wkRjG1a)Ew1y$eaFMk$#g(aycAXq-~-AO7> z5K@=Bf1TdTQb{FyTBj;hGbZ|>`&9uB>@*pYkyyt|h)@APkz6~SNghST?Ix*lmkMGR zgu|^=wLIg9J1i;`=NGDfIXYpsK%|2ylNfBXGzhLg&+P z9y;|jEEkM^GL71A9fkW-J3U`c zBmF_o88>Fo&yV!ztN^xM{+yDspy0T#38BpO*tAUiyk2Fw^Ikzb$J)-&AA}zX2zpTB zH6z@pWrY^M7Ke9D=|SC(+M;h?Pm^5hck3pjTc$ml273?+$D};bzpJ9)SU2beYpF%3AT)Hn`NybtXR{ z8IvK)G{&2j0rFzc)uN#x<(5!xYKh;<&7W`gRSv&S9$owS-OWTy1tkeJP* zHt!?_3nik+l^jBjtvN|@f{({9M*h^TiQp~z6VobCg@q25)DJ(!r^zQK#*<5pfWaf6 zSx8t))@_OWs>a0=`j0+SO~!Y0xbknyTJ@c^AEoD9>1loX^TBnMWI=Mu@p^C5{&30h z-2X1?lJ!RE!Q$J729V-0r7e7rZ=Ydh5@2I}E_Cs$qAn13esrp^oQU!4)&#G1gE?Y# zaCiiQx%m|*Y%l0X|8x=y{|Dag^e&;a3OAR=+`o}7%%d8Ij0*Eb{1RdhptM`n?*5 z!vtl-yim^LB*IB#8<8~MG$W?`wMsM9>vm6p(~Oa%m3JCm3cmQO@5C)fA5BbHE-b|I zYr}sMFhs-=aGsNTcXA%xn|fyw6GATPJYd*4hh3OgeO&qLgE1lL%L*0#Ga6kG^5l%8BbdJlN8P^a!y&X=E%;6X#E(p+9+kyN z!D>8iaqjS3Q@TdhBPM(Mspnv41QKqHkLR6lLRSNqMQI}+7t0wB7_DTIc-zkGdCbO1J%EgaLpN$!~XXD;hWkp5V(llLDj4e>fJ_s3?xJv{*Cjeny7-!>{z{XZ)4ht)| z9P9X^5wAhmjf|&SV|=LA&xBBsQo}>XGR=LD9B`|wY<4~G55kmrVs4mMIcR6gsuiur z!(_8DNsl>Ra3;S(VX<-D)1B!_kNF9yswgQwX-)R0%CU$0QO!_a7lL}?DfhYV9+S)> zL*5C3r3mePc9hR!UQ2qxEAqRc$T&4~wX&f6UPJF3*pDA6LM*BD1=9cIv9%95xl@jN z@lfXIkFephm^k~p$_4cC42F9S*8=X5%X3ICbhgeKS&tRKjYCy;I!aDdeaa&T`*Y6V7zihx6B(jGZI7H(VW9EEaIh5iW zuA>jJ^79kpVepVyq_fiv=Ishwn5A@;mqxaA0A88=GgUOH@NL@S+@6+Kq?UJ#j2)xS z_MdnCzW=c;>8h8t@qiZZB9o=XUguyVoC<>DM=Pu~>C76JkujuQ*G5FU8vBpSUg@$> zY_*S2!9Z`&kgLNNBVI0t${V-E{yXRF=$+zYjP4Wu_LRrtf#dx=%h_Up-oV(e;*)Tw z>Oe>f;-&bPf+$WG?67t&ZNgSa40(uxqY+t0oFp4QL`g%+E+^LsHwA;Fwg(cMdpBlclpr_K_oQRb2c!2%&jMq}sSD5@m z(Nu_M{O`h@S;@$BU$)hvw7kq2PemJg&ZCiH>&(l<^sll;d{i(dg8SI$P6B1r@*J7P zW}wqU@Xzc%0}mMk`Z7WUoai|gbJjpk%+xX$}` zGn7Cgv9o|6q|rXPhar58q)+w_IRf8Tk%>kp${zls|BsWotTMHdHbMVK9qTI`Wc6$` z!||!v5|D5K7s@M!i^{f_TE4cxPVMsXyRT$F?nVDsO5j5iqK}=NA;<0u zK{)0vRAH(X!@u)-cP?F&Qb6*1P-uW2OgU!bVoD<0Q0?$>csnb^V>{9gU(Eq+d(-tdq{>$rC`5Ph`4!J?-GEi`6-MS7odHOHHEfT%y< zm1$w~6ifUS>ZoY+WPokyYP)=jU%QdB7?cu0dBn!zPhrikjWj!5TMY5oq-0rZitFW`ETrVJ62i|9vdJxONwwI9|}yrT)iVh_>hY zRlmWP^vtrIG{^#of3jyqdiq;jbun zt^C8Y8(*?WylsZ#ge>S;<26stfop30i%0fPO8zt{01pTlz_)GXkfYtk?IcFXqv6$8rVc@qyaiz~k46@Mln`^9^|$z48t)NM)uZHSk_x z16Lp3jbj`i{K?qlZjOL@U|5n^Vu};J%}#R$UnHYZ>Fd_eTh2FV`I2M`v%*2r-e9|% zi_2G%j&3!C+*a3& zhw1g9LWf2WLONf4%~byKPAR@IrLL{m_oB3*u*MfoZu!iZMR{A&^!)w?$oK?vn`1sd zu86sVTAz2fNBzvq0Uh!`i<6hPH`_27NC2d^F1CZ$q`LE0d7pfg8|vpY|4437DsP6u zqSFPLXgE0$n5QGK2QF`)vhhkW*KE)wDfZq#ZM^EF#nRg>>F?>>9ab2VyiP5seD9bz z;Fq~~<@ff*&w@X4TnmX*{-K_LRvqXN{6O@gP-5^W*5F zoYkc0HMHQGmiPSqGNF*G`*ZghgvkRF0ynwf#%OH#qhx{ zQ%Ld{@pl0kdDg_mSy$$xwxj%r;)Lds#5+oa6gAD?SK-1^K}N0qK~JEp zjAF%LwFnYPm@LK<{S@UmDf7fjO#9*V24s9FHEZ$^m*pI$=}kNotLy{~Lagqg=^Ag& z8#oyUgD_S$lNmoU^WkQ$LN@%>4JK-pjx;F|e_A;V47usu#2{K=vD>Z`X8$TUVCRII zDV@&dmw+}5l7096dKlvu(h)NJnnt24CAVrzu-5z7)&HIKc6?KgVmUHfMTU~g1+_tU z%8nPeE_>aa<48hfPoKs=aC{>5>H0bfq!&)4ELT;Rk=N;*{w*1Hxd*qPkA+h@=wdUn zPEWp_)j%g2JfMcIl6;U(p25urZR3EGh^?N2% z^B3}vIxn^BH?=#&{#y<|6LJssQ;!$oRUR1iO`z6)qKHkcL8urD@z54stcw{|GNNuX zV(GzB_m078(8k+n;*`{BSBw)o>0=M7h8pWvO+Hn{Z71e)uPu4-*k!pPp2Or`J2@|D zlK#fjP;CxXtg}|1F@o73Csw)AR6u{lUCg2eNegKOAo_nMRYoAX$3Vig|LD;yTUCoY z7a^Q+-|NTPYpfLoT-lQRx#Kgro6|(okyYPNaaeh3!LwPn?H@~B5?-EG@`8P$Bk=kO zyyiE2|Ba422WTgDLz$C4=4_oxK^Erd)d<2y^r9!T_D_9?w?JONOUc$Ns(bx@+ z+;iiX15PUxTMwMM`CgL2#55Ac-C)H6;3#Q#U!z3QXhuOx_uw#m5cT?FXk9C*Ip z`jJgjSSSWdn)5CtlEXsX@b|^D6Z4k%pHz%k_WTJE))-rCry@UBmhjQx$543kGx_J< z$}){^b$ZH*UJXr+MB+=FDokH5d7BWP2wTJ^^VD*|W;#RmLFaZiWPHCs>}tWcGXqzw zKM0(n&KE)#Al!3u?1WQZ6Lfe9PA3*{s+hKB6uy6>G3V(X5JwI2MiOr4L#{e-HRCLp zKEAo$ssxYFFoeiJ!sU=m5#ky|tT5F3St=UUl-@vXg!u5)Oy^9LoE1NeNbjR{^2e(8 zG(B!tLW&SZ3wZK3#HrLJ;cCaWBmPdM#o^Pmyi=;;_sKs9BK3K-%-dzJghw4jr!`H} zmgMVf5slCMSR;<9lQQ?f^fhDc(Pa)m&YAuDXclhq2Paqq zTn)aoPpbJ97k)rKhLif6Rq9)ID7~o;0u6qQ1|8Rbv~&afy-UEoPdevKSOMa_ zIQrksqk&bGmA`;6_%B$9r$j^*84R|a4!gVuAqv1!8z%&~JAVOZ?JabEqx<3F0}&}=mhL1MqfSkfcXMvf7Gi$Q-6OC zBr?wdR1$Lt?B>2;C;vCJg&f@jXfi7zd8b1_YKcP!0Yo(q&HLZ+E>I^9PHkJ;k-U%7 z^F@L03YHQ$*B!?XddYyGyc2 z7sW{Gp(alQ(YG2Nn|Q*YgHLRzoo7BIL|hB~{4rW5F7A#A6A=e;+1U8LHBW+5hBYfz zR;s0wUJybkpv%m)&1Cw16F+h$3pHQymzaAwPV|x6p-c-2qe8MLU$pAOp|o^^ZhB7FUyosEIV6ES;tHyOUKYO>tCEB7Rv;E32kSa~9z_#r zL8E|@j>V(Y><)VmNvKoPvdzl&;J1`0wxk^k%E@03&$sWk?C<|;PQ`hQoJ+$u7d6Kt z^#px00iX00c_*^z7%5AQiAx2DkUC%Uev;5K++?MITRkgAL6nhje8NcXv$=sH6=k&< zYdD%YI_hEnLbQ&)NU8~Ao~+-q?QcWc+;kXRApzC{&pZI6Rkc71S%?c+2{TboNumhN zH{in&5JBc2u)l&4g&p4}UiE@?`EguX9{8M-(@luasG+o|NS@^(kzvb5p559uLtVxc zjuta|`EQi%{HGEi_5uwPSh54@loHG9kU#lO&V0}^zsra`IO#~6d zt`NXNA~a0LMYI3Bzc`Gvz*p&ngzxJ-k70ax-=jE$vJDAwsmL{Pl4qVUMY3a^cnS7v z^7v?EF%}6cvui=$s=ul)2>39(nwOYA*sw7e{Mj)lddH<@$jy>Y?x}>OjF0kmFiq^=!~Uw_{wTt@H=UT^0`#9*EN`oUTs3U#HbO~}G( z^oECO=Z8_d@Voxd);+uH;&Gya<@Z0NnM#O77V?CC@75P%7xyO!FKDD~Kfs9R{rKOF zbNkOf6MijR=$26>&Yc4E2iah)3-o_ZdQ?BR?3`e ziKwTS*CGJnOup8<#5pT*fqKJ>0yt%d0QeOo5<0+X0|do}r#m2735P_+;Ya~L4Ul>! zE$A4Bz=0$U$Y;2a8z6kU+!wg41C(V-!2SlJn^r&>#zD?zjr>6_jJ@N--u>r*AcxBw z1K}d4!qY5{pFs52`@aA`byQ&nJb@l*U=wKhkyQ76l*J!_&Ic01jF+mbxQsiq&J|pq zcr`Ov?zbGzJAshk?_>#bgB*n(8u+Y1fTn@-GU;-D`TkDpT}KE$XQSuMDu0Z;+=oFb z1%ffS75{M@a#pLIov~8EO8)wnKbvN~BG9v%=y&Oq?u{-9D|jl*_|MRo)NSd}9b-kj zS~$8fw~nAY{*uteWLw#}gVt0=R`e$s#K;!=CGVuMn%>yStikeMIWo3Tk<*GbHhrZh zoNiBT#5i)V-$B=E8^s<5kIPog{2N63NFH;q{$x8n=ZWM`vJ9dYd}5=BpT#5Jl(C2; z1?ICJ>aFZ>?RUNjl!-#v&Tdosfg+olI0EZ7YK?o2c*ltXkSjU)51nuQ!7`Q<#M9Zz z{oN|d5ADo5wI)OC9N7N8oNiCro7I1V3eD8`bSm<{gw*VC@f{$4#BnNseTFUYP#e<* z%u^4W28K503XL*=I~OQEf%*<058oJmEhP6p4ov2pHd;3UxIUmR2T&>C!;^*#-nm-P zN4B8)Efu1UUyI9tu!m){^C`0k3!lsLlgml)_64qmt1Neb`368kPrZXrym2H_;2*pE z+jFTC?7MpoRMw+1mjPBofV|Xk0o)>6TU#Iu&a@hb>AL;(>4Qo=oUuzd=`8;R1mPrQ zxS%1N<7h?Z-wH5#0DdHM-~Uneg1~VE90TpE{4NeoP7iY4d)!i=1XwRX1j0e6<3vV$ z7@Sm4q#(9t5(x8gp_0))z-i^><;4hga)1a9f`v>oOKyJ)sP}lHwdai6GCkS`98>d<#{jW6Tcs(yIZ7%!UDw_z)Ui>#uJHv|@%M(&-a@EY zV{j@KYlD=7kd%{OAH*m&O>KHI89-x8!UzIuJUrMjM*PjgPt)OLcAfuH)_roctEP=e zIh?hSj^U5-#vpypPV)iGl%2W20WtIz*OF*Z%@ zompskHvf)u*o3L~=8`d4(1_!4D$j-8Wx^xGh7iaR5hHtEg>8NcLEzUCuP0WrBMFGu z6^HuWFA|q5NmvMRHkFl8LdsGHZ3h-K61)B_XY;+D zf{C$zv!VhvXHLi_3O=;6^5t6)^{c=BzvjBuV?JgPplPYbL0T-xC|hgKlk_OLUgo%N zZ+lGD_ZtZK{Xjfh8;<1&P@^f&W_lhvRZjfe+^l+Vj)RqH(}1KlD}aFjF=~52l-wK7 z^wx7r&!$3`Ycu;`ajPTD2PoZvH3bw}fM)3fq7c(Q*#vT|p1_uHmOB6-){t2_1pff= zyoZhafyXAM|NP`z2hcnUk^{ykQ9Gj*D{vVdfV&kW+-3nGBv6>YEON>920ec8#V?oO z)-7`oEeI@CpDgupN=(4sT~2wRkpaFb(C0=(Z+7BtoTIH7@K|ljv;7?dguFS41T%9_ z5gJ2Fe$*oHRROIafQC*2UY`%J->@&^{I$Rv0q*-CP=^X^*Z}C8{6}(qap+C)ZNB(GZt)%H-Loa$s%g0g97fORhG$s? zs4t##taSy|a-(Q=lhh*GhT;@S=La^?_n{4q9GX7*+l%J)2AW- z(Nmf1a!kQE7Ou*P-V4&kh_+VL)d61I;#tmaz&G$H0{nDL6Y-yo18%(USJ|kH18zOJ zJ9O**>C*S4+}urv-5hhKcv}uaBFE~Xq<%ePNj8?MlZTc=%|8XW!ysiUm~5uqgG!b4 z$7|U_M0onw_PS1|cqJzWfB#hGP~}P35Y=4yJ@7qloGdwpU=APIdz30AJs-y*OC57k za-Z==cl3|hxBGgzXFS4uS0POI-L>{^pdALJu0>m_Z}|5!o~$cT0&ccZ0lCK{mV$#> zNBA>Gr#QFlZ0h01G3d#oy=lt&pb2%3UcI=A96qZY2X6bf`i>U5(CQmyf--1 zK07%)34d&S!5`36fQ-YYVwU61e_}FMgoPuLEg z+|O`$;f2z9b_@WgRsjJf>a0(nRBxUE{~z0(r}jYn3yiwofN?h3BEek!j0ir`57zx>J1>V%Nhe@>7f9I$1_owK{VOFT6iA&?Z2+ROGYv!mwqWu43{qDU5HN*A(c&+EhWMv+C4Go zo~Ais|C$sE?vd?*4=B%EASTK5@0gK#b^U{S0YozozBjG?4^4=Z@R za_>V=PtVv`VpEdWw-<|K-8mUfjhXXGMEV&A7WA@zN~gNBNBa?BqKv*|j8G>c1fg28 z!HWm=o2uZ1STca5Z+q-)0Y+qxkIe< zuu0~%0f9&j&c_XpUM0%%J!PJaFu1&LENSfASU365bdJO)I-=&&dJdWInJ80sOqt(J zN4EIo*6KGcVnorxqj7qnrxvof4@fq59XLzgPVdY&gDyfr8;48_0F}bqFYcd!L1IEw zm*VhAeHt!L_TRIPKhFRiZ$I{Cl@Ca!1<@VfLqax>qda8QWI_eH) zz2@bbeWGms2l*1kG;Zh_?0+TD!diBYU)n8&RoY;{EG6`p1b-? ztVvL(|9YEUTtJdvtlE(F0ES5Opm*CaQMqlDnJMm0j_=c!?LE$J9kra7H5+>GMC3-U zrc2#8WcT7hf{&%5tLtAB=uCgK2YZ}ksUl>+^=3Pp?Zd(UTmn{@D)lP%qHef?r0_nr z;D0zOC*bLC0N4w_7pa@_r_3Ae0sn$8z^;P&l>7R1XbCtJ&VB)K3obXs?0ot?szj*!Z8Xu@#yu9>hOt#h5gyPRswU)-9B z|F*bxwawMe&HXRXNlIaes!1g#8m}Ko377m0$u7}%30X_B$}H|vSr@q8I*|&XK|*k5 zrgSl_s+2d;yQLCSgX!8GXO@%M&*Rnph(2HGuXscv9FryEqCo|t|F9i$PV$+FF=}1t*OQh%CkKu7ee?vbZkYNr zzvO62gQxCOVhQXdVU=R%{q|oQf9XMqbIm%M;H{_0T<~;YzIjXPW9W-3x5YP1c3|{- zkJdtrtS0%MqZretCXHhe**m#WF9Nh9=6t`|?~fU5^~@Nyacx)h1h%hc94IFZxPCet z$jnPuMbq~*-HwCgB23@+2bl>5dr?tugmyTEsLEMa5|6O#$lN72r&M12luLf~gC_3$ z>Xeik|EZ;{l5FC>!G+of*$ptP#||)15mT_50CyekuMb{+8zN+-k>|t!%MF0u4Z!+} zn~nkW?Zvm98{WVFfqGu#0NAPuc_s!fLA-%4xD5h8l5sGo85KA;WzQ#d{TClgm9hRF zV($Ch@Qd!Odfw}0!_1bal^Tr#xV6tdR2jdm`eRtR&~cEntHv?jc5>vG6V3Z_ut7of zw;De$&quaCw-bwjx@}yb`$synHUOHyWgFuXjY)T7Hzrp)fL9+EL=A>?U|<7oce0zA zSjeYCxMBaU{<0fzyJ(n{3)W?6e}8g?Lp531#Y{&k*P)6NkNVxBYVlj<(>FGS$HRo!H>O-a z;px$8DTl`x>KFWY`{)C*^-?0{;&TZBg*V)`tau)_z+lhux|cU;{$fz%4s;!<$g8`2 z(MIsV_vSEGP`F1rN35c6K2;F^MK6ukTOU&hLQzu;ZL%>`Mr>^cO1$kg&$Pv0xgWiI z&HiPzICn5;**gD;?)hw)dNWhxNVhx#yr8sP>VgJ!}QC`g4g^#5q zdaU^1EKMU+;YQ4T60xjKxlnRwTGgeI#_;)a%ZvQh_+iQbGf7ABce%b88lc1bT%RH! z(TBlsuJ#~pn^y2OXIJ3@S7qVvOk(2%;XV@rjhdF*)$`Ftp+4nVS70J%u|hhx#GFQi zJj?!+eQ2&1)040nSs&WCPlfEi(Ys0C-Jl`vzN4Q?ZWb`RV?0}3 zLm_{=;!?HmArE{J5BIUO{XmB-vR~30^p%c(*7Ghl^89u|)a{DFOCemb25zr1qMfg* zdv`}CMbFtTSAucy(>Y!JM@N$Y0qX4h=1j6T9n4oofTK9J5F{$#_`?(5^m(sQ8iIKa zumLG4Dn9+Rc!m=7%uH$3DI=$#!0+trgeZM?0kWd*jo=4hG1!zW54LooK$Pz|@$Yyoa60_$-!K5`>K19eZr7(#0FGgNEI4Qxte$>*SXf2w-)v&q>QyOZ z>*FJ#lKdpKa?ZItYxVLh_;MEz^(g>K8fWmNNvgM7D8Grq6?z~EM8!4;ARF)&SP^>{ zwbxki(Ec{K_9=pl4mVWFgy-1IftK7K)R5lEVlwni_YT+JzrEv)T5+fA>&b@QfpU+L z7I{*(Q9o8@b0s+XQ}cCH?zVCY&!4DwLRS-EETQrictlTAIpcn@Wjat}8SxUc^QK7& zLOY2FKT->NR!uYoQ{BPiUi?U6>-&rxS$Mzz=~oDad^iqMp&wKCM+gqK9tx6R>zxgA zY+rdiIz>2ebsMcLkmN5j!K$SC3tsAx=k_N z{9)L(nWb51Ry?_edhAXcQv7Z$r1bpd>Bq$fX!Kq#%G)=Yvz$k=`G7{_!?N~zfTs>^ zQUOQSJr7@%g-6eH(Gq`LSx?I?qVAo;11xtg(%+bo-u?b4p&JjAlT`UEgo}uZhNv*( zjS+nphhGirJ^9x67q>hxnu2G)_$SGygzs+n2f0VZD%H8p!>Sdh7vj%M{q}MtwW-eRYL1$l~ z$9m7};rXDa>7%{RpcciM=5XYDTz`*ynoa^*AM)}WH7H{){*7LmaBYrhCtY6b0>i>^ zRy8;lfT_Ig#d!e0qdaGkY678;PF2loU}!Z|rxsB>(hLxyV0?0TaG=aj zZD)Cx2f-Idpuh$Ef!{xCi~breOL&dopdTQ~C0=o5<8>Yp)ZUil>lu5-21$gcbtS__2>Am>@M%+YgO< zrO$^SVh|{3@QU*jXM)u+-*ROm@}W}$LD@MKr{bn?fujsP&rgDHY(DD?wWD^$kCl20 z$zZw*XV0=EAAV>3@e9Vb+1if8M_8?2ES`I;-)0%*;{Ed^kmkcm*^BUzhiGnSIxM?p z&Htp86v_E$<$!#pGSPg-=NVW;e_5oyGhAg9If7Yzc%6kI@i7ghp~aBLnz)qAuS$Mp zx0QUqN_JEvc|F;TVQ17c4yo6A1>niwD>%AgyT%iX%kTsfp}-?u&-?n`bl1XUz~FoF z3}gg+FnShU{zos0*7K2aGHxG$Ll(l8a3j^$x52+Z(gCw9xYn(v)n~UvvxY%KEI4`q zN|_FM|0wYH0Dx%&r~eupkTU>q)q@2m$Hy1bF6YxA<2!5p^zt<7vglEzZ!Ruv2b917 z12S;As|g-uySUovCZkG#i8~)>JJ(L;1T+aYCMN16+xoe4Q#a;yU$9?&4(QXafGXE< zdDc;F5yQLOe7csUyLbU8K|i%jm~Yu!Qr1p6#`FW14K6~DvKe&qBVpsx;Nj@rC^MS% z2es9Iw=-_1it8$G?j~FzJ@JU}*iL=w%T2vt#B*V1f9B=HzoSgKRkjF{LNj)yFlQpP@i>H2KV0)>BQyjbk^y{(&7 zo^n$q`Kfnq?(ucID-b>@R@2y!hAa9Ny7~2k+fMUTBDWc@ho*2c-u}C7)_IMy%X%5X^cih5Nz5stG>5Tjw5@f z2DLW?N}}$2t56`@o}YjIsuHm-$_GD=n`xhXbAx|5nK9AR!^3k)J~=J{zOu~c*qPi%w9?{A=5+Re=~aH{7!1A`MX zmPf53Mho?Hg-CW%_#fGSUmD-TA#hn*M*cpH3zdEM;q+58wZgRGcD>aHtJR*TN)%q< zRzq;p#CtYZA%2KG44F~7ouSkR(x~nv3y8kLW4&XdnCf3LTl?D*p=SOCU)sRm4{}^R ztt}u4TQuP0#9kE&*N9i5FMYYX$U)gG{$gQTj`hx6nm#>W0z46QHcN6XYH)q3a_~t$C-0N{his8wl{o*7*V`%o=);%MJI{McuIPaB26;i zhWI#-7|P#i&Erf?m``BPCQrYHDDU|CIP)eT#YKx$ zOjGZm2WM45GrJJsnRB>PEy;5CZb}Nui5x=ebnQAF8m{f@rPil}|1QcPd(*~GS?nnQB>Z@Aai@jbA`Jpb-rAg4RgVQ^GdvLE=~ zHr695THy-v3l}0J-NW;$%8V!c;)3_`)%i0^Eg1B&0?I9)rWyPo|x5V2`pJprcl>osQ({R z-yKe6{P%w!^N4f~lHGCay|+p_gpADWy-8MCsbh8s+1ZJ#q>PY^kX5#j6p744B`KAD z@ALgWzw3F`)gBMX> z9ovbAzl{<1xaSz>C=d;_adx;1hO4xe^!Ss}nhp|cd7e0fNyTER@RB*vGb$sITLrix z+`G%44{6xrr#Fvd?xmjDI~lO0_oR85Q#NKw|B%4@kkdCsi`bB{gyr(f{SCqLE7@2j zm!sBdMwD-Pkky#|q3FrHua!`|RHwVDHrR$9u09@n4yPv-pEizfc|W^BRW7(0PVtCR?%H3)FE?~R zet@>|cXBh+(aih2k=Om+>{qpp7EU;ApZ~yv!T3w&n;t%V_;1(oKPY7F_S4qCX66Mj zC|s`?l0qQDZF}$9_TGcW*}c-|X%o}Hod5d!{@>qdAHfnb{%(*?kgzD9GinegXM#X5 zh)NBPNA>C&S!FWSsQv3xNl#9BmERpwz4%*LFJK!Wv-BH|Ht(L*i&vY8jhNq&Id z0`tnDtzJf?uWzn34|Lq9jR(sK zL-P>}GaE}()0)WTabnRuX%&@vV9xozS85^P5x8kz< zTx^;3j5_*HO(-iey2 ztCAn&#>!9}Lh$IG09y%Tk$PWuu6LEWD>H!-7w#3vE=ZBG;&bV2Js2=)yp?7VhvHiA zGV4~%H^^j`ubZ{gbmD}b1o9`KBkl(fvU0Cv>K4!YDww$9(loDYRJ1ynozvyA!83_m zmb!PC0_;o zYNUmn+W#W%Raj7=Illtl+6SUYp28Ck@cE9feVn3|r|fL}S_?n8Dt=@bJ+*Vt*81Id zITEl7NtYp5`qflb!#5ZqpnnzQ9xv-R`%fKwWk;vUMT}G$KVP4=?#nqiIN;ZXn)yEv zAM@-|$cFO`xVjsEzzPcoT3b%}1r`Puq@^43ER(HA~R#uRqRESV-! zFH`3u+GEx-ZszOMMK0&HaDQ4_NcgTSiGEwDw=p_b<`(N({pLkFH~5vd3?+m*ow{=&HL`?*-?nB# z`!70mt3pD9z3*$;>vM75saR;3FLFI7;EBExERAn+*$<|9u7XHs-bx<(>6#c%h}AUv z67hT2>r-%R|HhWjlbl`IEDm@3Iq``MJD1l!&P{*O0;zqupE{#1UOy&2KU7i8hhp`; zE}(L+1tRacUjKH#GKF-Uvt0e5j=mPI+9s|Cts)RM0~Ja%*9u1S57LeoueDNW0Ukt zs4t`Y>w9dd4#KgNA;H{>#vNyxDE!#-OxQzG?T_<)a*wgtg@@`_f7S+4Q9nZv2;zXL zO)Jy3Za|9RdV@HOjBzm+S+tV1OKaUOB;R9_jH58V&YA{E5)Q%RT3&fuGj(UI+{?T& z{mu_NI>|gbRVix>J~ENX>0Hhgvr>n7H-_{b(RVHuYEqU&<8QUF#Z2U@&}r zeSzzb8?p0Z@%ao(JjAlLhiu!s89mKS>vk$O6NPEFEaFf?S&-qaoToI$tc_3Ls0;7e zj)I~FD%qiR>h!*_gQ@UX>uB1_0k3mjxiO2C51=*2nd#KyrJS~nfm4qn7hh*W zzTM|Y*b!si$>J8CkFM_GaTqt)=u&D~sxw_+lB;6^+m_f7LKz%AoW|YsS$X#vp0hb-bJj=jtw& zZRS$0&QB>At(px!KMSz#?Q??l^Ei6au`N|3;D#nD(V)!wVPoEA&=JEPo0})HVmmLV zN!hpb{?M!Eo0qtqZv8y!?iGEfP8H;cV!z&@$cuRlw$Y5ZmCilzn=YcZeRv6N0!`Yr zCj!0pg54YcL^)y)llby`a$0^|jG{v4qH}`Iw4~yY!p*p73SllR8^w17O%bcyU^JL( z<%lxs)X^V6>vtoBi?1K0nCKVqq6SH<%lAGMqR~MtM!1UUYgAo0`d`W>@$71Q&P9s6 ziZpni{fI#-_UOI{jb;Nix~d!pBs@Ya*+LL0qQ==yKP%2M|3x7nk~wL?VOJI9-e6T_ zW?hqA|C#zGFr|s=s)sX8Idh}0udg_h>le%qqrNXwE7jOYqC<{~vW9p1MW0z;*c!gl z|D4tK>gjhZnJ?EL7-#NYgS8vj`Zn0m0$UzD9mM}0QE3kS>I}ZUHy$?^)c&@4=bI|? z3SY`{%k4Oyz&n?$oYI<%g>2olUhB}=KMS;{ax8d+(SIRe+eT_Alm41e%95W~1<##^ zWae8-KLM?N``C+_Jab%vyX`{+7W3a1()IkW`l8Ba0+lX5LHxEJB92y<;~fz$Li7SO zCp@LPqsB`N#9h1@5_C@674mPmVN+)~KByPGM3j}Tn$cksT%VTc<#IdXScHYr%2-!@ z84_ww-_O*;wQvZcWl2XDqP4=1UQgo9=8Mhd&Mr|S$7c_-QQA~53IeD2_C;IM}ufpqr0fqsZvEacMX-2iH zcp`$QIsddBou;MEE>EMbrXjcF+gFU!VX{f(Qv1vH%50q^jLe^_GIRDd4v6UVqBx9q z(iRrLqR?u{w{4AwQG|B_SJb2g)IH9JRSWp>S|3Y;isFw7_-ws0??#UPx^L@x^+x*o zl{fv$OpMFz<2Gik9vPW_eG>H0{!w*&8VSUA*t)UTYOC-mfGT(I+SRLEs2v`-PRRu8B&2d_`S$PZ8Sqdg(n|I@3@&BMdP z!7;&CnizP(V&lbIPx;3~2$I)!mXMK=0kV-nx77j*mV%(afB$YM*t&i?8olq`xX})| z#dBAbdh4c`jVCo9G)~W&RDm{yhX)F_0B9OoZ1}0}+wRay{VVPh6HR_n!8el} zQ;BHSD>w5BT2#36)ApJqnQ zXJm|kFF8|<`^$9F7@wp;B04@j@vrqaZ<}=C+MpofOoejaPQ~-O-k|aDHxoip#W>i2wNdds>saG8oGUl1Ld2etl{kBewT$2y?&g1j|$ zb)BBdxs{aE27=W~ZC~S6KQJ;f&O>VkA0OGzM^5I1EJ--+p{?W~IhXuT=%w2H$4j(C zlc9R&AFOrhy~FN7khM$h4*l}w%ignpe-jeud@e$G5*gHm;hbE_N-jn9?j$$=!lD}H z>A4m~%LD2PLNLT5*+aCMp0L~w4Dt5R;Q}yC#C8Th;J?Gg@58MA00@Duw&o(qP1o~I;st8PQ z|9PGCk5)$J)p*mT4GrbR1zZOfztiFmnPkgR8cdd}lW0d5bE&%~1wYXwjTU;(UmhMo zCoPcJbFo}!B1JE6s4`=|bToL)9YH+Z!$cS8tcy;hpPdlPei61rWtB?p8CWlvB)Ky$ zli+6Lvb2OObgiz*1t9u?i&34pqBja0$qtrU3~03SS(+Q&i(Dfi#xdknVLH$!haOQm zS5*2@n6WpeSRko%sMVqfm2e*W-7U|{P|-xw{?B(>IeLyGYe1>75{-sqqC&DCJNdp* z3)WAe>xmBT^Pr-cpiS4}jH#38r|99XX2W%rn)4~gLY_m88JRJ^ zqjbr>dMf<)=Vs7w(9qIeYTuoP4KGzW8UmK`+J6nf{Ya zp5ypPr_ymt;5W-|+gr0OKqrg*Alxur-PMsmOn{0qrNk4$+c(O)hu~67S_)kqDHQ$Q z0dU}>mK7)!-PqlSR#)TdDTtO1gPL2>Uk@Ly>BT|26}egy_|5#B984O7p^-!9DJd!8 zvU&>ve0k+C5B`~~IGM$tkk~PGuZ%PA;P&b-xF?Tkjf0}pJkg$ig=TGGGinkbzeE>W!zgre}eQ6AnIzM*e-f0>Hioa2G4 zyfz}k-|R%m5pUaZB8btUZ>TlWnO9we|{5#$nas&j$*_4 zlCo%5i8nKs!?@lGQ>+$*NC;VN#8T6xJ^lU#s1AR>IMTIy@0!|md`sms!sU$a(;=zS zd`I<_@E=Eh7koPGx6M|gC{{oMUBrqnxL-^(U-#db`S<@YoT zaSj?=^#P~8N=mMZyWnN}(#!r3ZnNO690$16R^&sXsfLnza9>c%cdjq6adEYMOg}dn zONA5!ZMIZ2SL9!Ahc0dFnrSja0TOf{n;Ez&wxRqOE+xhzM}P%za|ihvIaw8ey3=+G zp!tw!eg}kZ1B4KhTLvtuMUt=ebBC;HPsk@Pg?f5=0{I6ygrUIPy^{aDy)9IdmIi8B z@(Zv%??zRK2o@iN11|@fU*W#uIuTB;59j3Jp&>$jNu1PGC+K*9kx$oYxg&pg1$s_P z;rYShR6`bAO~{iWn7z$68LKMTxzMHL3$_8hz$dgHzg7at8mr94bH60e;l8#FI*b7S zybK9??kmbEpx&DWf$BcIn}07abeR0?19ur58K5nAY%PI2S%j+|hy!}bCmKekAodBP zSuMzmj7u5@5h#L=jp%&yWpn1oH7xIAM10=RaK$BqPfn6$oC&|rRfw?D>u6i1=9E~k zd>Ynv&9i6l%;6}~xeKX>UvT<;1eSq*;*o{Gey_i>nXBVJWAh8Vk$Rombm;_Pp*9M_ zfQ&Rn5DzM1DWM&mToptnSOwhwp?UacQNBPoM%p?)YM?bA>pegA(tNqrPpIM za(q9U?k;t9$VuGwb7A-4L;O`@>I$ohSCgj93g7^QYkta)TqdK zt+(BrUBooW@^GBX4H^{}M~JZqE~%Ro8?QmqaKqxFv z<%!#iD&WPKfpA~D*qcn_(46B3*~=jNNFX(`VzUE))t7<51Ofd6>&6W@;br@J0vHhROsc{vzVak|dn($Utb-5PGW(#2ZHIY@ zER_LD2XJ@nu&RV~6Y0hr3`PL2H-c~qdJTk2+u`}|UA7OcyzAtKlS2L%K*BVEK6=g& zoO9L{F&dkYLhX%E~Lr8lg(UG&OK0LHodpmErJ=lA=69s6G@8*lPxL>_>6 z92CkUg=%CzcCI*R3+QnBmcV6*k|a-KcmDlZeF_jfm_lxheBb{dk(oF!@n+GiylHWlQebk_Li9^eVIuW52Lht!kPlQDZDu- z5&S@OT3y_Y#>dBc81iyxG+eFJk>{7PWKb6I3lT>J zH;K415S70!Vv>)Zyv}r<`t7lyH-g)3P%?1l%$fP$;)9FIP1Ep^hf+P&1dij^w`I{M zl39Ay7B#YcnS;yt3(N?#WS5Sg$3mhl=+9q!o^NzDHoQaX>+jFt$EL{}pd39t$(<|q zZI%}=lAoL^DsOxhwOpa-LhmN?_~fJ+B&yE0n2D~RwDcKV{ss~7V6eSQ9iut>V#vGU zVcPuwuF+h%erq*ly-aQ_$_AaLH-|NaSD(G!2ZkVVZ%EzsnH zQqOE}fwI2L#0%c_uJ+%o3--q2`<zs%qeEUy|pruG4|L5hmr0{(G2mK*5nz(cB!$ay`e_`^#ioKG(w)udG`^QDNIxWKM32NHf*;L}uNaW#&AiBX`YN^`yG}Ic^R<21jRS z4JKy?iMLTvfmK|qEobECl9=%2UaIEj`YQs7!nSkIG=HoQ;+g>6+{{LM(QaFs_ZKyJ8SD6i2v1tU|^q*8cY!g+h_niniUuoanGg&X&>v#~va zl_P7laavIK8$6ZoR#XV3xCoGJYAm{}#G89NmSrYyfDebz+E?<0(9FTr4>oQYIrvhosRe)$?t_Zm_z0Jaezx1w zzrZac#hIooS{obolDD%yjqdz`GEL*J9UIe^Ifx`{^|3_FW&_YsKaxGb2ZGJLs;Uam zOQ;T60AUUgiy1QRz`MWPJm$_wUh%-s_jX~Rgp}1%uZ@yt`u(9g}+mg$Q zjbDo!tbf%P9%=WuH~fO6b0~(op!D!}6>S^TiI)Afwti($=JgaN;p&G^MRl2K@K7{y z$|vlM4}veD)hqitS8*-R>W}=$L!BGvJ5Hv;p|r{&Pyh6dZ}y;DP0{_vOh$$O9GAyG z{%K<-yq}Be;nt|lRPm^^cSKlZEC3{nJRzg0T_Jg%I+eeQKU?~8n4HJPgT%vn4lVK_ z#jyoU&bR5kPd~}g8Wr0}0x;jB}L4L(wa3HmJM)4lR>5in>%f^9??~ z85~1I5?4mn%U-PfH1pQiRi~HcJ@VarKSEj;b9Z^1moP_#;mu-xT)x47(QU1#!2|6x zKqNk_6S#W(WM|BA_RZ~@?>~AF_F_s`grY8dR2#u3h5K5ka13WZe&ZqNNTK9oajB6i zJ}`G&L96nS^OCY^V4GLXSMlwqxR(sE=Oud^_6KQXqZ!8TB1Ewj9=v9JNxiP`Xxexi z+mjQky1o$+i-FPnxhlQG@mPiz8b*Zalq98_x4Lz-4bgA4sIUZO-4d&IV*%~Vq^?|N zTJ*&p`w9N;upkes3!XJ_Z& z$SWxL19GirkDrGn=W36L{yQlz@0w)%`||R%xd@a$H+zs1H=$}mOTX7yB^}JM%DfI1SCrqRTP|^-us@NK_%c)(Dg0uuujpmgx1aqH1Mgv z;66F0aw=j3`Kj%kyu)Z{|L#6|1fkFXSUTAdMHLs(NqcL)$mE4=n#&ccmrpR&uMvcT zUc;o%KBccOe+W|Zab6CW8qJ)k`tg(Qj4dui1XqNqVlltpl%>!ezzAqSh-0T^Y>_w1 z*>p7;ueY`5-mM$V z(s%_xUszW`fq&}Hha*|}tZA_RHwqgk$cNnhqHzj3YD>qViqbEge_vi9*;pd1*&^K4 zl?s8r6?hmcD<}Z&9OMUQA6fp&#wL7w5Qc^gp%@a>Qh|E@7uX`fh_Smo{~IaK7ixkv^o6@39$A~%k} zlO9&5FJEf6EYtbHGm^5TT}m!_oXWm0*J}Oz)2msY5If(KNag4HAj4n#pp^AYa0adj zR-gm0qQ8=m_HaMpyiu9qmb)c~2kl z44Ni+TxZIX5+ZE5GC;!_@4htW5#?hfAv|Gp$ZFm@#I<^&VeqNas3}2qnpn*1yh^xQ zkn^q)T~N#`&rw5*h%>zrHZaBZ3D*n$m=9aQ?UJX4Agvm0Vv9^_#bs%#jpB*9H!+=vJB+kzGsFNGdgzvLyf{$8DchGVVZ zw*|XtSvJ%v4c`@ZY#`fDBb61sDy4jbWL|R%rO_SuK%uUO+ba9$%~&t0kh*Iy;sNl} z0q6*<*CM-$R&~z3VD)O>d+8@80;b;&?hPt1OaZ)Is1`Eqt$@tHX+Un$1envCp~}zU zuq#aZEw&ce5Ed=CesZA&V6UULalCjk+QLtsqc4YSE{}%w5-4m~4`B4W$0`PPxM%C- z`QQWp2Gy76e|_~Tc(On7*t$kI5%`zA0{~!Px^s9M>{Vq3okX28GnP(9d*H&h4Vi<} zr$#M!EmgZAd>dLr1K`+F%pWEPD0t-=+E{sw<8{-?;%NZZ_ugOs2UbDR=`2Aq5VaSs zfB1bxdHJL{c`$%0v|QE)pdoA5`&+=tKSH}^G57)Ld2yH=SO!($r&x>|P&e?nsizXSnM&48yW;HKv>>{iyg(4 z=v-w)Tua4QO;}KDh&Qu2x9)|J+yq^!!VoV}LOdq+Oo9lYPX$DUKDUvGraq>U^gb#a zVa)h;#1+tJUg_Ce5$@XeYjTc0h-{#dAUxo@4N| zc>k;LJvjfBy6dG;n0% z6~eL3?ajj^=g>Y2ZgHk%tL)@kx1<0ow5SFxR+e6wdqV;VaQ;3(JIL-aGJ_0AKXA^D zj>Oz4X47MGqTDa*_4lL`@mMfugnmpryG6!Vww{7{?5eYK2|T!lr^y$Ew!8J6M?7M4 z6j%Y>?je{!+d-v6UfzWh$;PxrKFUsTo<4^F`UIn^i%@IV2GB`gUmu7$93*cqDW44d zND}O_b}^UlETnj5@&V-LWazc~SVyr)J!~#8%jV?eg@1@U2GY2PZT>^2zy%4@q2H4a z@wcx3S$geo{qMlQ04(G+jb3?Bfsheb(Wd%gEnw^p{f)m33VCDU@l8@J$<$6HMzr$l zz8`-3_MFO%mSg=3)a^JvI(8gi zbZpw_uo)jEZkm?q&Dzg3%gjvf7=&)d^thKKLzBfQ*O}Bv>H>PU`~rNwvLMadznI!0 zom8qS3!_iN1!?L;N>>-Q&7ZDfx6%6PF)z3eS>j5M)Ca662k|5is}B0!^Y5jlqAFna zT+Vgzly7)PY1-o&{BFm&jjjJ2Y4N2sc^ciU?rb(Wmm2>_Ztwgr1Hm zlkasG+q0VwiSD%K)u3@jc`(%74zEcAE#@*YSA%gaEx5_0#AK^?l_Xg7pt%jl7y9LR zW0+x@_-rAgHCwSQMiCF(W*<9EeMG5m8tekN0+$kt%`*-mB=9n>ZUi(S$Iz2Br zG8z^|Sy@>V(JT-sDgn#_3+6V!=3iu_HVvR`^)A;I@I8~CPYl+BoN-F!wWqwGpx|VV z79k$&jDQ`7x7$~lfxjH8ZknNctj4BU^m@&_f;ZMQb(T8jAAl?;ppf%^xdXs`P!|W_ zK6Iw7148|$d)LS*2OuLc9G#TOU#Xl}#{a->iV;&|m07GhAkSP6|??UZsA55wy#dF40Rol(YeNFDR8 zMLRX)7^aS>^$FD{nD1&}q`=EbuiH2GR_L*Ab~&?4&EsWM(nFmy@s@yQwjf7}7z`(a z;_vw~;5Z>2yAyF#o$Au=V{TM8IQ?8~_Bm(%i>l1QzO#xVS?(Jdr7x_f_3vht()vc^ z4!&{w*wk`~=~m{lto^wt(KOz36vXUw$#d*4*llX23GJ6PGkEzKtVDN3?g(@|68d23 zJZn5aUsm%#F^itlYflu_8286kUm44QH0S-AAvD+~EGlc?9En(?`q$@JdM|Uu&-uQ} z$t(&j&lwrYKZ`OBIa$xukLYMCm|ussFNl4hbE~AcVasY)${!|M04|u!v%T_^hjgp$ zEIol`OL!BLlam*o?VAfk@2G=_`e60?!K%E$D|v-JmW@z%aseMey=6&YGIMq9vHlG# zoG9hPd$lAk2r3{;u+7)(bnZh9XB`C7sE_X6-XF54ZUnK~; zi<1voXvTxb^P$hXx9{&>1N5&MdckdOmIx2lTU0AZ2<$S{lo2L<6`z4Om$#(D-+CNx z@CppSpI1-)3v0ACH_0af=+u=D+wGps`~B?w$?8kD4x|+nc1w@{^#rAE?-h`aRR-br z`;&Gjn3O`DQC?*s$L!N=SCaDi9v7{;Q_eqeN z07p8xX3X8Cjg?l&lUU6wWnKzzpz*XR_SBU%^p)Gi*Co81XgV)+ar%X<(!6){$9Q*7 z3qIcM2gJ+H{bOPzAnnZ&^3HF0Ju4rA?a&xxH#?db!9q zIcdnzB#Hv7wJFliCQQMfy2bYE$NZt@>+2{XpIvLp+!G;|T1*&hAsw$(kMd|wA89(# zXuZwH7at|}GqSR3@aNAb_3afCF9BA$Ty6SNyVSVK%xrqjQ-0C`80j{Qt=tX__JM0q z8SSocf3T>az)UUluR*q(-hsEK7w@?{IXS|v+xsylzwXz+?lcEA%HT)~9AEspiMmpF zLr`Lb)mcBRKHHYqm9;zFPC*2YFY^5&WyhICO9x3jJUqQCoogii*dO9;*^1{FfC6W? zT<;z8p{@4g?Cc_Xzjq;cy2jfnCKs4Fd5y*??@7*+Z>aPUX&P7lri74RSMjkkvTEa%X=98KQH1=qW{^gk5>bEUrnn^ zD4&|@2*!&hrx6)Sl9_3%#vQJ3y$$x;ETp_dJcOVBmA(yDX+!a-OOIoSGW0 z8f*M_k9j1cbFf5Rm7b=Hq>nZ3jfAv>33aty7J78l4&In0V%#&SX?b6**>ZjU!KfV< zrzQ@safUdLdR!qXG^CG7h(Zt0Bhu`ePA-UE8c^FVE*lK@%g)F?u!c9pRe1$(0KNO zLCBs@>+)!?+}rwFrpjwxdj5@sJMJDT7d4CzC4t zIYoT&_kKk9Z}(AQZRR5<`(K1tTa2>h%$|+6D`NT@-DNcuNFWHIL+(1~el~Hau5dYg z$Gu9%_(|Reu3;?oK03fiJpzEIW~$DMajh6sacw$1!<9hM!paImw%jFUMfAKH=lG^o z5Yy6&#MGR7X0@l{fiktI&Qf8&GoTPOV;Q9Fj?MVQQ1ZmxS^Q z-`;$1aPpjfb{uwGXH{`I_b|8!m`#VnUw$UC@w%Lktv(7+Y5G0&omi9FY0qSX#=Mpt zN3U?(^EWP^b;=fQYS{l>N+L0^m#wJHA=4ivM+5IfaB*IWru`ZnyL#G``B1UO@B9D% z27%v+8c%2o?3_i9(hmkwN8EQUrj~2&(BnFrW`K4Jo(SVFTNk6VI9J}Rsr#}1fqxbX zOM#nexUGlP$ZQB{p_N|^V&_9fypEoFta6lRO=M3{koQij#clrN^OHzC`l`d7uR5hm z?9o~*RA)2IMYFuklce$C$TsI#z!AsOnj*KQSDs8Q2-3?%{P+-fguH1J?k8OHbBQJZlMh*{DJkOY)`OA2zM(3NQ?e1x@;vea7-|qi;K}eM)$FG-Gv`{pc1$ zYR~zq*_BYrD8ku;j5{v|;*&hqMFjp%k)(G{dW;&oX5C`d{z1SkTQb$w(C7><^N^CR zt;l`a*P=>;Y~PyyC|~~P7a$Z?afvA-Aq8VIPH*FooHxUHQ}k<38w8B}ULU18cGlS8 zQdn@HW|=>3XynNE;ikS{>J71R-F9k2xoikSAXC1{n{3(J%R?Bp0iwg9*|LW;4=Q+N zN4_7`SU8xk?<%nKOQ`$uHtUK9%^~`9e|w33W(6r8yX=y#f%9cdTH3Mg(udFq#VlLr zDmxAV?QN0oWk4XJqRaH*0(}&WTs_tyz>i_~8MrV=BhV|^j6q4-zLBWIraTbQnl9_q zK=dg#ZPN*9Ul&iXs~TXO#S+tn@%=&^5$9F>)NlUfN1>#%MawLoPiqczH)yC}GsbwU?;(pvr&n?En69oH2H4FhR?aMQeH%@1$!dl2EzuF4rm} zZT^>{0@Gn^8rD4&%`QvR9yIU4ZBdA_QjftzEZFwMC}WN+cEO4|BNySPkdN4kW(D59 zA;;$5BEGhX5AEEq9`IMB}yJ{`fxuM+#&ius}b^ONih;5|mOB8+2;vxypNX$Il7AKN_~Z z2JP}zbN8&&Bxp2xZvGs|87Mx9rk;rjSq#h5L*ix{t4^)wHjJ39~lPA>_b_RHFfAf%+A%u&s`!-6r8%ddWSsBx8Z(Toh*5VzT4z07 zZn}(d)wQc8L^e9jf5Dw^I#q=5)7HybnO8jA@f+k=EnB(%M8+dO_?pYP; zpG^|B%sC#c=o+}4^X4}zRtA^qrO;56DBy&b^=z#w6A$Y4Ou|Zg<>7hHkXONBL=qYC_&nXsEbeFlmjwZv(O` zJc&fh+N@<_JZkJb+t2^|W5#TKxtren$+@-owHCiFlN&$QDBa>OJ}v^ws7*pAWh_~} z>2uC;KV-10Pc&}mZ48%0zq`h0b%u}6h2Gtqx?puA{pcIMs5RMq{Nr71d!OYleZhq} zyB~dQ+mI)TUu?f{jvedOXgzIRws3R*l^T}K&~O?p)<+P3S2yL0YIF)+sDeAq@@!lIjR~3Vi|%-OzDQ2f)vP%N!5h6|CUx#EgFeR zMW#8}%?A(;X*omDoQpLd+Jp$1P3FgzT5AK8VujgXnTd%B(VPyp#z#|ski<{3jlR<< z6!J%+RsYu3Jh1W!a2}XQVh#IvqSmo=E7m~m=@QGxoMkC(oBgBR?C?clW1cygkiml& zOnhY``ja+{37N*X!jQZt<-{{2U#LpF*LUz6v&mHbmG3VKt zKf=K9F8GxJBs`gXiv&fVRL4)SH6&~Er7VH=k1SsG_ph~o^l#JS)LW2$NX?XBuq$CF zjR&bKZ3Qo$r9@#hz1~wS&M+n`_6cni@%2eI*=~L$P~J5YvHHF(nMhB^{vY+cjs z!}IBXcO})GCRqPx$pfWOl>a}0>|>Kw1IDX&pQ$%z>OK8JmIT9ws;I8cr8N$sfXmzM zd)IdI-*a(tQWD7kAb>p(qb$=0al7mYSzRU%Y5YRf@d+5q-nPXtG`pO|qgzw?+)mcF zO)N>V1Z!NZrD8O?M}wbOP&YsrHjvj=J?%YISeDmeq0UKS(s%x1e--nmDCWF773BI< z)L#1=-ad<3$b-}f5EDOxWM2?F@Gg>_S&39?H zPh5`PWFz^ur0w){rST_l{cdWcc-&58N5yh66YCraV~sP~SVVqDzIt`cpXP*a{-urb zpK)5TzFaa9{F?)-lnVtxSh|F!?d5>@+?_jYy=2!zZ1fUJTq zH%{r3L*@X^Y_NYsP81_!^C0;o=OXf`S8DBPet8&qNmZ5Ig{^Z&?|DmKs2oTOOA+Mg z^J8>9#tDUcCs}dwtj1@x&JbJn`un|wcGl!2HYZiEUy?KkNq;1jL$IfD2+Nt>$%-=> z=+Av>UZXr~AAjlIv$)LtRgKR%yIW_LKmU1>tEwD*BDy5ad9N=2qSD0HdHm?O>#Ns! z2E>)eHFOEZc`KOIpZ=-qT|?8efa87gb}w9Q**i#qbO*Qq z93WjA)JYO{Prm~)ja<$b@QoI=l}hmqA_?srVS$%l19&ij$*?Nb>_L#2|aw9jjx8FGZMu?Lag^P0#_0J|mSh1C%rnMIPe8r}IxugLfJ2BcWB@{pz}otH7|0d@ zBLNoeS@`b8O*jh1?t)wh>?fD@yp{T)L~2$UE9=RSs_eR;7`7F+>;4&4aLk2IzQjh_`2lAOSY!#ciykuQP{!GD`q>!1H! z-zXHBpDRCFh*z->d@rpQY^;(lZ^C2rLV~hyaFMp7k zkFM_2ksH~?8UMR=z|wK&y-qq6Vpdn=#*PhJ&AO`8XI4E06gjX`^MQjB$e6Dph>##} zR{QMP)?*(goM>wrNy3I~SGnF+HjC>Hg{IRhGDTtTqOybUTiww)q2@S&Yt+5%tix$3f!PIE=fl3(WG~ya3X0y<$`DyCSbJagwi|z7@2a5 zYQSZE9UUEmgC`@7YmL)2hkWsxl`pJS)Aq*-QZ7Rll}UQ5ZL0a^n{2g1SdH0%`tVl@ zK|UB=HnxP4dV_sYeM4PgpOS|IR1}CiZ}!U907n{(23Gp?TTU<nMwVIjx16BfEd^3B> zT}$+AS)P@dqR%2u{88>=J_ge+DI{I7HI?7sZ<)v-dND6lbbAz;Zgb3g%vZ(@w$`sS z#HO5Jd%S$lRze0UjOzO?Yg{O53Q}1~O)0}N^U+7s%A|D#x;k%2oR!Pod%;I1Uy(PQ z5I(8fuC3y=vDkR}5F%l=@*_}4xJb1>8;dzimq2pjPziq*!^VWRGx$FPJE-=Ka0a*A ziDpg63*cRbqT}endd^YV`o@5kTs5&R*JEjAdTqi_!)svldHu7@lrr^MY(ZZQZK)oE zc=EW2=1nIB4*&XnlYfO@={6{r+RqYvK7&pewNTSMKT*8k-AL}NfQnw&Ji$UiFm@gA zwCPJcDuzyo1CeC50)&E{)jnJ7B7FY7S*hL-zVQK8KnC${BUb7#Q^lqiDI4WS52a(% zq|rKDXLE3=C<1b2%srDpxJVUC-Nw?9df84M%I7BBBei*P_R9^>QOamC zA-6PaA704k;*D!QZVOeTq9iWgnCK8omh-k-|-ql$1mw1%C3r zU?+Lor;!8k%&wAI0crl${7=ofhQs)fM9F4SSBTY$QO@1Y)LyqKnB zDNDhYg~vPlm)o6Vk?~xbp*-(OU^8lrXLhD?k)gdbj*yl|z8+Pt-a=_>rz@wFm@Lky zIbYP21s#JSy_?3vjKhBYB~0m!WqJZ1mWu*zSgwPtE7gpW7kjs922a zCopy}pXVHs`;WdABnfkng9OieYWI;H;C9s$OS)QOYAnvxWIS&ASio08j!1nu*&iT| z2M3;iUkqa*_vNUMlAQ}sM04u<*cn&{ zz=+yqTJ)q0m>CUki^P!bxpWL7z7c8^IH4b70ePXEbIBWI|=3!(UM z(1jlj!oKNZ3dAFvc7}R=W`al*CNf zAHFpLB`|3eSPqv6mf<5#O;EBTke>h_aNi))vn5MvfE z^v1ic7Vt7yS5H~DUXj4MS(OJ+gVfHnrSe+->|#G-y?fCNll5RXj(~r6w;=J;ajdnM zXj1nY7E|MKyD5-aU;xn(jXjkxwqASAEAJ+b!+8}ksFVYEW(d9g_rvoWjh2<`EZ%&j z1Qje?N|!-$_W}l_CzsxzBxnh8w=k)_0WBbxSHluDI#ZNXh>ejUWq5|CKiI!iyTc1ZmQ$3fz()tr7VdL znth!2ZUm({{4J@qDR{>)sZPe84l<>`9S@t?XFVYux%-)XE`h8Xrv)j6UhwL6;-=(o zB_UKvo`>spyt2)h)xQq;{kU-`&Nq8mn98#)RJ4QTLpNEGwI2H$$V>1-WV+=}zs^#U zsuv~)Y{F!IVrNKhqqXd>_r^!RnmM2vRoi^J>UDaR1AZ%N9}}t?JaL91zVMa`6VkEiU~{tMD=v-@9jpxMy>ZJ5ctFCV#5HJ9vh5)`kTvX6~vI&b9o zpQTw3rfWVCG-QU8yntBi`W?Ycv#l#(K#(nurSCDPq7 zq|zWFC0&ZrASHs7AYGDD112I8(j5}gor2(Z?&tmZ;c_W)Gjqi``|OASRcJl%I|Vh= z^7~=A$Nw*=59%Das(*u|T%f(otkE3`t-QRvl1E$T<7+4eJZ!6SmhFt+dP+)pqFH0B zlo~zkZ`tjsL;EV6u!XJ6m?}@%IZW>AALnN+u+BFQby*8b?xZw6}K-scr^NNXfEaf56G85^}xxz$)g#r7%3{c#?*( z8!|83RqiwGPBUp9W!m@7VMOon_wXffHynOwycFPRW}>+l{^&u$-R5CW)Jp3J&Icg1 zf^17mm?jp&f9`z-?UTntDDoZCTOmmYaG8&nMJBfqyLQk;f-@R$(cvuW!$=V4jTT_x zM$ucdI_9OMiO%sHoZvHYV;wT4J?&zmBEhzyP>r}1evA1{5e37K;;?2#MSDjleg=6g zTq}ogmF%fVdtyF?*(zy4+M{H_x|H!Rey2Cf`Fi|QmmF(Sva>4%ybib3`v_Gpef!_InI0t(Ck8vuBbG5D?Qb1H9j_TGxadrC zy2I&v+kCUm|B}`@p2EdmD2*>&ya)zv<&JR$OMFWestw?0FcO*0NU8O1*y&Q?MbZ`Z zx>Mz>w2|Q-THiSX8_DiZ86{hIb<(Qm(M0{JNAr}L*I1PLv5PP1XBR%Kg}ldv^PiXt zr0hJWbFoopw~xK2s?UoZjD}n_?r3=at9w`cvn~X^a`GgHzW+VwiNW@{1H;+scVR_F zqlfAnpTOEc1e?YHFzJ7;{Ob4Mo3A2(1FkJ~Hi_Lwe92H+9>wcAEy>t_wIS#(z-ev^ z9i&T>aEn+oHUU{{S27q37@#`*I#8Z7NPZFZN{Sjcz9(NGs)kAyH*Jhx$>#0UWck!u z%=4BbNA9hYo~*Yql#lV-X;AiwqGsJow|o@SX2e230S*bHeG1oL({iNz=WPxI+H%7+ zxGGU0;EPW|ijdJD<;#AYbXU{vBF~Rod-&xw4EH4_h$AK}_^dL%v@@b_|G`h8!(QUS z>3B_qs#t5am^>yer}r`yc!T@j1hd9;614059(8C8s9^&xf%89nK<$Fi8j(0U*fM-S zs)Nr)g28Nn$8T2iy#`Ku^d;8DBa@61U zy(u_fa&IXVUx&L1Z`vT0BEizF4S_mrRSQ+ zi~u-wjT^lW(P@&q9ZYnY(QnOo*4TMU=r2&48w*ic3sx($cuqZNZu+{)BFMd_%#x6SRm;|0%Y42l{hQv)zDgV`!0a#_L3vMd}x^oVNU|&gHp3t2t(MlM%wPgxO0Ssl3F=aIwPh`s}Ced0grkRkglb&4rp!*N*!}eNr2UH8ZFY9JOF>MF$6n>ee$VC(YGJbn|7Iveak^bOMkvv8<40#H*eY$7G{}1H3#H7M1-t~2~^VA2bz>L zU@AS0(3FFAS6yBGgHj$nke}zh7|VmOb(_J9y+5S5Q!ZqznS91;o4$ z6DHxA=%PCi3k7v&8mr%k+!l;3wGtQT&VSJXK;>0&9dY}Er>O?IJfMX252sGR1z28a zR$$XUIQSo6AtOLCwC}l*p9L3nH7I}Jw~md8Ne0^xvpYg0eMr6xw6B%MRdV(tb!fzM z6O@t?AoZ+I(p<~lp8n=0DJpu5NTvYW2cf`I+R)p9D{7#4a}uNhJJ=Dxfi>kh86d=K z<~Vky$gmX2?E=Wxp&SF$Y~cr^yc({+Ec62oICRu*K0d~T#E2yZym-J=^|Jk&Q<_At zk|=XNFY~zP4BRxzk6PiCQL{XOcw(B1#Rga``v>&U{v&=iRNzhh?5_$6_tQ;jn)XdJ z&w!2v6JvCC^x+Ne07nB2lMWyT@f12T-n#Q{ShznXvB>*zc5EK?wd|heYWC(Y>am&; z&LY>qHuPeag9 z%jT})Eeq|Sk3B?PG}~VWB!BK}k;=}cNovwcY2rEg(*2L}wv;+cj6%eiyQAPQtWhe< zIab&*P2Ks=$immQSRZk@%32zD`QYS}_hyb%H$mQnmXV$)1N22SojL2fN3CMGRi@m95F?13OnTS=7u zf2&yE&e;Aq|NQ5ajK$=1Q*iOjhs`aSjQf9{V5j}))7Emm{Z;46!}bdt3dw&anFj?* z-wW$9J$#=P{U{$DRkB#%N}cg0I77$$9!p8GleON(%NIMPX4hSko1&}ij}=?UiTQ7- z`;mpy;!FAq;?kSbXrWN?BwrKQSrQ{fwGz59N1rerC!_RgJ!KjuQ=fb<=1JUzaG=<# zbOi!OIgtOg$_QCa+{Njsk{o(yeYDv#>6qjn`1`g6FS+QUREqq4&kr%OpF&&`n(0v_ z=tpx5im#~-X`fxdBK0`bVJD4_kz`UR$6BesVS8|khGTplw>SMMpM1u5;j`u-&LfhXm2no+O9=X+qD0IOm%)L-fq9CPhD?{%apEHwYAY{G+!b>y}i33 z@L1i41h49STcxn@J^V2>+Y~bTm2a`X zz>vH9mPRg*k`g{=MlEaUz;6m&T5)ZPKrRpsbVHkKm(Mu(mo2C$1u=%$jMuLoj(y!C zyl81II>;+e{oOIFl7J@pk5q!sNL}*4j1nZnk5<8Lug5v3!6B~njlu=z=KVta4 zVhtL)2mTs!uWY*Rc4G#dAmB=ZH{aU0FTx%U1lr_-%iJSxsP1mPzYlE6ZzG`H=qq&6lV`E79#`*cuX!tpq!g?`PRi3cZ6y8_#!RE{U1zN0r6|R@aDY)V z(`F!5V@y#RM8m5`EGocDZFQ$g@%2w?ED}i{@`|7=>HZ|BS0PjcZfVoiX*#%R2^abX((2=|+X8s}nm3E>Zo630NPE2+ zig2Cs(A(Y~T~TB-5bq#a2~`* zEGoNDEo=aoH{Tu$&Nh=Uv|s5MV~YZ3%SN+S@Nz;h3@BfdRrBG~4LGhkZ-57DlI_@C zVkuk{;va(e(Iabc_Ty7kda?xm*D$m5Jy|IvoZ5sDkIVZh!(#nczBV=skh_%*MB2Bl z+tFNZg2e0K)$ucE8B9-*^zCQCM{xWi%M4C7a6tk2d!a7uzeO%xx`g=pfa_QWVo5t4u%{svV)Byx$Pekm-M(H0 zd5vKy4;~Uie`LTzMY0#l2t>p4{kzIiMo>E_Ybpy1mw??p z3>n@$&_y$=Ky^eL{CDTgn>UV8u^?bx1u=D>zySbHV5IK~7X}20LCg+jpGUk8unAaU zPIcp;w}3z4_d7i~fr=@5^FuD1`|{5>Fz-Sjg43g|$|0vh2}`uK>=%GHKB7+t^|Z_Z zcm{Wr75Fk3)ewj`<54Z2cMCoMt1B43B96R=@7sXGJSTcMz2qy+&%X-~5qMaWZ4R?o z7#UOto<3$NLr_NRE)aRZma&~`c*%yye_+%IkLbBrlHxx-uBUv~H05O{DShA<2!k4s zV}e`79uV<|1T~_of7VWA)n7Op_z7{my7%J|HFEZLjGk;&*LNB=IpvbM;1yKsV2$@f&&m z=^y)PqwKSKDRAmM8@samG#q|MUdAHdX3ysBGWw63G%HEy-Ue?&+MgwfAZfVrK&Q|Mxi?OZj2IQ`6MTM%xT$zvn|zOAjz zrw29{tWKGoNGq^3AOd4InR~T11o&@b>@DIye7zpd&O*PHCUIfVF`XFt8IUl^Wi^Kk^P* z@*b|VguHv9V~U=vM{UqhL^uG8NR`Lb8T=s2l%&^~dpQw1HjobW!l@4j$hg6yi`m)m zjPT)y2?B2*>u+%kbFg{#4W^j=L-l%%ZhV8}yt?7m-A>N*{Zc~?KX(vogR#-Ps73vQ zv|<1KPdFhkhp7MxJTwX1Zyis-G6wnWz*0j%d^F<8r-6xAqM$N2PW z#uL^xD$8XrxfPFla^Ed9Mf+0_IRDfheB#0W>ubmbh6VEHm)O);cJTgZheRx*rD~X5doc&Ws7hhuIku&qc}_ zQfwD1DKa{Lf3(^2H0@38DGJb67;((zi5qN+mh`3zsriBdKmr&*ff*EHYd78E3u3`3 zBF7z%8{d#E47@Ca4F;a=VoS)b2Xr)G(<2+gll`|pUSj?4V@N(O4h<81l&Ip+exE=)jG!wz2}&M{v{w%`p&|O#3Gcxy&m1Zd;ga7*z>SVrr*Vyq{%V zvkL+t)%jH0sW(#pAY8Y<$8MNeFRdcN!XT_XHlU+og@6x2$#5%gF8hs)pf^9I>1S`y zPv+A{Hw&7`u^MJ9rQVk0=dUlvpOJ1oONb|9x$J`F1Th8dYQ+srj6c*!!JLNY&7ThQT~~ z>|B)JEqNm~7cH`{q`g$>1TWqLX?za<1Aj^AX>;1XzzaO9xb7K=Y5>x`G#ftW?T&I9kQ$~ukD8wuj3Y;!Tr9MYKAmi4nv5)h+Ybs4s6ZgkqN@d7P^hNh;Hj3-BWY6JhN&z>Zo9yaMG{!NTlX?I z^U9Yrk6|D9X^Z@ zh2skg$Ej{+U|>FDKuyjCE>bXegl;UKSis(-fb}GE(0U(3vICU?#MB(Z)rE8!h#=pu zHu^(|Uf`&KnQKfM{9;fkXLvxt`m&yo;#_h{M{A%zt6;-4p-fxw60TSP3YYlksBa|2 z+InnfaJfOevDb6NR_)~t)hiKU{hKNx5x$(2#IdhB=ovi6dz(*jGJ1)Ux=~LJS3g@H zsNFP}zwO%Lzu6bMAYgetlT3|Pl4SL3%qLb3b)tm?nCeV)coLBD3yEWqvQKDUBz%}) zQYM5#rHz`YTe(9C)Kevg=ijmBm}Pro;#2Gtul9t0rZP&h+GF-(?ga*HyD&U7Vfgo#)Wpr8+z>R%`j!DDoyYLr zuU@REK7u0u&)(8EFrIq~e)3YtAnot&kEw6JAb{Si#xB-x-DeBNy5KTqFk5!c3zkQQ z_yOR^9AtReDUMdEPp=~m|vp1S>=X?qh;K7Twv5Arj5 zi3?vW+zyVRKmz~f768nxU%!6A4|g4X_X~^wc(Bn|bto9-fUVFL@MjnHG{lEK1d~z) zflyiz7g2CM0ZKGrp{l`vtieIE4`aT+AU7@ET)kj9xuG{0xUeU=1y%%WzpBe1!9e(i z=3(@Rn!d;@=Eyo>5QapNB_LZ4U8h>9p7K3-k<+qoDPt(q53U>}MLgh-yA(f^UV{|4 zOOxV7j`{VmZQK01zU?>g!;@sdzRbDpI3t0;;C3e`tFCcJ<3Eq19d#E zuC6riFw|&WMlDs{TZ#)kB@Neh61^u! z6I3(>@fi~FqU|YHcoqo>8GKN6@00ZkZm^|O5Gyj`lGvO)yHnXXVora1lqLOW_VQC4 z;yCWFpENa%lMzh}E^a`C&ZvXDPAxD||FhymV8WNTp)xUrWOjHX{y-!!2X%>=Jq*C0 zM8UEG!!`g3+gidXNddmQjA}bDZOa#bXse%+i)@G$^cjJL7hoccV85J-G!7~T+@q{2 zjErzxJ-aa~CZh2$fForkBRbas)8}d2L-=_>ZCzNf0mCuB5-MwSxgkV2gGAx+*7W7AoS>%> zXc4HeKoGku#+H>=e*h{CO_*6>Dv(w~jtu%-FfH)B1d{#=BLL9Qw|$px7D1sJFLQp3 zkfNanQzuk5d1+8O^eNU{jUO;rSoZ_bAN^+@2!o&F9<OI7H>HW9c(;LwIRSJUi z0(iq_FY~)!dhp!hT*cn2;Ib7b5*3iM3?{agC~xwYQTRgj+TNsmc$xOOLReWQYGs7XwX<$FY|C(*<3QGHvI4eS$nr#V5Yy^j3 zsJR*Mmtz3((LXG$rANWVU8!3-hLBuTV;~1b&uh~ z?hN7>s;H-O(hIuCNz9TH%Lk+j=meUwJL^j=EDSz(?v-Tx%XHW_keu#z=MX8mRQ~#i zD^c=t+9!+W5uJ=XWM`HjATO>FJ zPI3&i;Xf9u$KO(H75!!%C&+1jJ6JRRuBb8K&AK6@veFKH&gguQ&g9-esIbcSCDJKHaKYZ5w(zl{zS9L2;ORLvjdXL(+ z6f&NPtHER9$s-w@uVw9AFvf>!#mXuNcFLt1pPsT%lWD}7f*TtK+Hey!%|0O{Bm*7MK?ot9DyIEE;k_g6 zKVMDpne^VGJ{$7js34<>uXs;v@9Kt9D8^kT%dwbd&XkCz zVzhHD)p0RQEv)yJcopYNkK!fyYF0`d(_lwRjcb>{JNloKNXxeoqIP+mWajU{yDqB( z0wr7bz2vG)C`4kY1;D}V6uMurC7Rgh9A$0i2bGp!HIBG11MqVIk%jTFk2uB*d-RL% zVU=YiPOiJs}Q*gcm*shOVxp=p^xtTSgCx1-&rR^&C)LEAgjlTh)O8@B57UtYQ z%uT2$R78i(AohM)9UxDU^FFXm#vVMrs0ILl>_!I=<=RynYO}0r9F;YQcr-If_VB>w!!t&@6#=_Oq9lk2?E>{v1Nl|YV|^; z8x2NJ-mJmlp11cAGK!d;hO!(Sy+ojY=Z2^Zx5CUEngOs3^ZJ|Jd5{|4ydSaE6&o?% z(B~j|wm$&Xi#c3^0EP;ds|Nv*3r@3f)*p9Mv?l$7;evzi!XYn(=MRmj37}IJzXqMx z1gF`FL97E-y|{RN45Om%%LYG$9eGG`8MePT{M3KY=YZd>?`=m$m~b%bSyO?1?|HpH z5jI&KOVw;-QZJVB1%E)(p}f=5Wk0=+tF?= zq}1xd)@7TW-K1&|ebGsRTj$+JFn0*?7WlawDuwq=o>D4;<7cE+NpjDJ=R}FGIznDk z&9(4eryvQJ6~stn;9$uKewnUgiupV2=|Y4ejbhPSM-zD8N~3a(jKRaJ>wT)ex~$OE z5mQb!IuPGW8c8p9=WZ9T3+D2cq%kiDQVil}-O#_}#;)8Lx^GFbE-B%vPSIrgUpepp zI-qJ;3|`~EU{F#yg2c57u^El|>riaL3=%;~foZTljhR4~maOKofz>|JNmf);zyx;+ z7+6Ryz5ciI>W3_+$?9Eu0S*ok5pbcmLb%ZuCaTV;!ffpO`W9p}fNMVD>pVM~BOXN3 zSDg4(^lX|dVW&FmgePM{g+8;kb(TNU+49DVV9@J)X()pP&>XHAfVD`DBYXZBeV zNn-@7x0x|Nn1HtJa<(DBHI)7TK-=V)&hJ5?kk6uKFRQS!1y*#(5DxYkv_muJXQy?q z9f>WQY*wIWg_Z=2>U=Lt($DE>NqK>n*bEruA+zk-wiVd5PCy8bta}XnNZ+jN5~sCZ zv$4zT^ti3688L z)nx`oe_`y^EB`2G6*5HMg+fS=niTZ7928LC3o=le;&AFD&9r>Cb{Uu{#> zk0Qxg5tB-`s8$Gjgo87V?6^FFYhQ49b&lbB+5cd?Pr~qkYvyhj5elb++3wFemI=B4 zHe-xnpCfgsc>iq@{1B$=Dx0xY)Jy9(1jt+1P47o>b*gM)$B+a}0=zTL?PEu7dUxT~ z8=>L81?m|l5_&Xm{ecmKEF~$Ag$ z3hRZeqnjQN8m;0}h$l%xg8$(xlWM(b~A@VtdjyelyI1hD19^sG6%8l6#_ZwXZgkHLt$aq4*k# zbE#i&V{@|={vBDR)pJEn;o$W<1qO-0T4P1tJup)T?>NccY>+D>V|SSU>*-K{77_Ft zr!dPwj1&Qy1XMpCFpG{8BrqDr5XqVQ({0d16H6b@f*b!iP|^$n)#gNn+u_bRg~_n% zgZc{bGZ+)IsU*UQ9t1fvw=ez5WfOilDay;6W3h^E&@EWbFNpgUAqsO}kWZKHne{Y6 zHYGxp<5;>)EOfOq`{lpVxf6gt_m7ScnyvSwTX<*fP<;?6t)2LBP+H(>2Jo&;s9eRm;y_~mG?3Y7XX3R(A~e*ZzwI8rmAsY z>;%|&bE@<}Swd_4fiUuOVB~>BBtVktKgpq-wg3WzEP+f9J)6e%4-OpJk)fJbYvVdC zcuZP?9e26mz%WnLeZ=h#=%)Hd2govoBSgUlFjJG4<}hA^7dA=!nu#;8kkbp0*SE?# zT-Zm4LM4&t$`wR54O7AEu-y-fwE|V8h7Zh?;%NhCLoe-@6cpTPN&91tv4u+tX#DBzW@g2IwPA zB1jU5`j3xa@$wSp{;Jk>e&MPSJ#Y~vK=+QMjW785yOe)|D;I!CVel_-|B~7uep{#|2kyhFN zTD37hWdz3cPbVHecH&z{^e-Ol&+CQN_Sp1t^!gO+_0^j`pL&AitY$Bh;>5(o4VMJQ zaKABYjAywgkFt|vyxr}WWI`mrS@vUL;5%vXuzESSUjL=c_B@l9#qQeXc!|vHNjtql zL3%?@PWFGJTfBu>w9AW5P;6fX%1N}mPgyGU$LCA-z)F7F|13Z0BPnUVl{lSqVX&K{ zVa~tO^~W!5(F0minQH#FEcDB+V* zl+#pf&a4-A;X;m^-`w2HWdrxyYU^)T98PPK86T4R0&y6o$8B)t!dXA;9}Iw|V%{6e z!<`+stut7q&>G8m?V&=T9gpaMiY=lD#@Og`JCj2oFSPHwfxEGHrm`%&Mx;o0LA7bO0*OZR>w(LH9%Joo$o58F zA-M8%KQp3?|Hq$!p9!QBiSYYpR(XA*U`h*s8+2mu95rJ%Xlb)nn{_oshQ~p_1pF$< zVVj3BXNF90tMCYXB@ABgVQf^2-h6i;DSq&h{vIA`kuw%S3~}tW*iWMTHqQuA{ImHhqm(2jQQy;* z8`#9sI~1RBMzwT!Z0*TCML#LLMTam=f<9g~r3#R8RmF@Oq?KJ#LZP-AI`(Q4n(RoQ z_Mw;sj7dhge(JJB-g&f2b`wjqo4GsiSI5r@lg~_y`0vP72+3K0R?Ix);}QCU%VrTJ z*t^Egmr^RUgp1ns8*ryk*kPahwp`8VTEK`W8W__!xkRDxv6zXool1W0ae*-Yt*V2$ z=*gOE+2R^65^Hj8I0&pJ47TlWsg|;-i}*Cv82Fh-5(lzG<$@gTUy(5ONuh^JO<}UP zkx}YqdvHRh-$q6gzL8?_Tj97L75mnph5QjG#tEz+ghMSIDQpvKDGhq$AHyd;XuB zJ9Itla1lL)WWp|JVGIl-DH1A!4oZ_WMFa)kKHm0w#4_yl4$4;`aSy%XA!YY9zXGut zrFMVe{_i|3U3>o={~#LDg^pug18~Hhva<-F@|y-u#M_+WxU076ZopTe5gH_20XqG9 zjFQvpha@fOM%y2hH?JRPyWMDmD3& z7#ABGxZ!NcNjD6Dy=l+2MW0e1xNJKkZj(xZQ!-3;xW8@Sm*XS^{q>(Y&9b}dvxUr< zfzOpWc#E5(mfB0#`ZpYBZu5#8y}ntjD!Uh-2GooJ&u9r+w0PGZly;>D5bhwCy)P@r z{@?UbYhk@i5_W%y?o6$to*19+7Gi{Z)+)MmE9F8KjlBJDD188weDBf88Fk-@Hz%gj z6>0}4Cv2H-70u#2$?xj*7BP58U}}*cH+EX(X_MGXfx@e*`tpn^hKL-`ZQ}YB43AiQQ+)DAN}F9Gv@{ zsECW9DluAtzgKDHo9p!%1=3 ze(+l}MN>p#Rb2@UE8|T(&@ifukIKw?qlg7;`0WTT`L{%^83i4gxFj7BvR9r+4pXJk z?@I{NcE&~GNw}ISsN!EJ8TQL=-Vv-68MF;tWzV?Hm4H>NEW^}C(8nuu>6?6j1Wdk| ztZ7Mgpgaz|hlOSQ>H-RX#<=Ru#hnP|tw4o)a5&0-#l;2LDL~HXq{JZOQCbK(U9AWWb>Tl2VBOZ4;{+ zBy+XHFa;_1P#=&%H{4fClSn}a&!5}QF0i9~sVuSTJL?J(mU+^-b?S)h&~GL2tc9NoEEUWZT(mb%jR^61VTX3hgu zQ(0B|CnlQjeSQwG^cr<|csL!<9D&;9{7&hQoAU;ua7B2)i9?^EH#>xB))gWmHaaEw z?32MlyWXs`DVUMNoU5;0hhPN+n9Tqu>`DIXiw~-W&W_g&_k-92c0JwvLcgcGDT_uH z-6F~Fx#u6Dv?&z6b3xGht;vXppx^@q#(s~KHg(ljom?|wi&o?6QI-pq#rlJciYmru zPg0e%#Z7&`yN~vL$*b01*JpGmixgXFPcz9}_T(63&=LBY|D_>m!r?C28FrLp*tig%b7I&Y{(|&d7Ara2eg5_Ii+EQ<}?d+1`dZqC5U@iKlUa zZ1J%WfwcGLBux1oE>DP5J{k!xx~FrwN~^rN)I%w4;qB3cm~^4z_|QX_l!!_j!sXH@bC;TI#y`mLFh@AHpr@VbyT<=WHtQ+meG{U z8%3wx%>KFjN$PZdW7&0dSsZ<;ZLd!bRsx2{X>487250ssQkhk1#`IGHsTQ$8+)g+* zd6{}=+Sk&r`u(Mkhg{h062!{%`aY2ohehj|d=%f@HVJT0qvPmkdxxZ5t_L4b^kk~M zRaZpGg)zes5tp={&`RQ=_<7mC$uTm^I)p@oNcza#KUBxV?xK%imJ6F>UDzp)j4h~f zM1T%a95R@X>)GKj;;jxp$^Voo8pmWs+KU?P+`D@b>mDVEb1cbrt&Ruo&L>7(yqBsp zf4JkGJD|S!?>HT@ay5yqbWqDNX6VL@U%f!)V@JR5PO#@Ke^~a?@_EoL?l|e&s8@Ii z;>T_b&i8w#1)la=^V`0mVU`@xf01q!1U$%c3(PoLLR`GF3(ygmIsXQ+4xGR~%Rd=N zSJu`}hb+&4)fIRE$K1~Jh*MoQd%@`+vB)+~!}X?MC`yL~|75S2?rO4NE%%tMP%Ykq z{sl3bQDI-rCuvQ}rlF?Rc`lcB6Juw)P{SnwK%OpW(SRjfT*&LmhD*8wM6(9yUcuJh zuH4}CnzIYM6TZzy=Llj4j_PXBuYgH8J#8T`>9=0AItF7NO?-K!j*xE%@4){!L(U|nl9}P zpWQ81$#?S#F~D!(m=S6$Y5AnfS1v7(nbDk8Dp-`Z+F&qU)>>%O#EN<2`H`bo3)8~y zW!JxM@|+rB2qN5h@L7UR(lOnT<7#;Z0$2f)EtNR8hHvP4VbaO%Z;wR9*ecTOr~O&t zkZ$#B^tDQ;8lfr#9|6cZ&}-a>*K67)Strc_)6KtGar@KvPu=}eip76a^ZD67mJ%r} z*RTyF?z^e$rh*NTCfdaMy?Bh4GkYGRTV`WzIoK3EN&Ofi@|dLm2WOsAX4<`^rtrti z`Nuz;F45@qrB&=Hif0~q-qJm^l+dCs_noiI`t9P`>awcGmaUM;43uvrOFQoiJyI|% zYom7%rMX|?Im8iIS3D=A_~;pq;t1ESU#Dgn)kR8DYd)`TRikqI9(4w+5W~7F3}jaN zYL{uR-|(_Fxshf4Aq`6_!_z3Qf{HOO>@wEV7l)>w<*}`q4(u>0PhLiJP(Gn2x>$NP zrNdp{?#J`Orr%EJM|DereO}tdG$PWl?_3C48CXAc_gC2W`$n!>Mn=y_1H!L1H$_pu z>+W;QK5fZTpvT4Shs$A1WGPV$d11}8X{dfXw4l2q1@qgr%UZKvzeyRG#wI!~QWWO=?(pH8=C!IKL2VC4 zy?iVl;%i5);P7#{*AL`8r&?HSW!7Ott$k3%g#^B z)Ccq@L5ojS$)ECnZ0qv1xAPJl&TJ%8l-JkQ?5tV-XfSihHFDqnH$;B=LD!f0C7f2{ z!qfcdD!$)HLg0-EZ^CIXQJbD^owZe@NAGmG} z2s^^}f`8T%j*zZn%?{Abywd59KT2AWIr*a0pln-PLv_ z85$Nzj(#a}uBg7cG+yw54(=XuVZ)E?)o6Ni0=Oy}pb)3k`I zp+eM@Hj1=QdvfBcmr-Uywd|P6ys{jM;td0X$T+SQ+4cvk0B=jJNxdCrIk|`{&*Zf~ zXk|QU_G_kc5uYYf(8HPb;&rcURvp6Q93~gtM_IihH5Q{#=jb{W0isOy2waIHWC8aa!E?GB>ikm23Bv?ZPsWXLF1KjmZ_8{udu>2 z6LEY|);~SJ3S(LQ>cYZ73Gf@RZN{m8bytt`bsBghJE=Dx6a56U_uECkgB#LisqX2< zN>wopHx7x-Y6@Ci}5X#)1Guut`N1>@%JYSNg$1AoS@C!_dvKY$Xn;+{pGIK}|- zL9;XH|9-LvbYdHIBg1Q6DUPMn@7}*hU{#eweU21pv0=Ohn%dGQ{bKJuSNh=sXaTPX zzb@7MYJj+*#{h&9E&<3uuaSRia0)mAltuU`5Zt120Qdr_9WWG4F*oZjKvG6E-=v{5}0y= zkNpn$NffW@d2VFb@hawBfssrz{CuPahORQXYaE8{s|S8BZR^nGK;3xsNSkgGitNZ` zSpTbDKZl0Afbvdl9kiR2?GP(ZsxkmFy#S0LHR=*XR4;dJL|0ZI_pVa1NKC4YeD^WN)ZD+M6hIncTV zLv)E?LD55o`~~W1$GSzNNN;&W?aDKy+_N3-ZYMpUVkC61MlQPfAA_!=K9zR!!W~^T z=E%{dBxf520TlYFY%u3QM4?yD;kSQ}MO)+5XEE2gvhPVli<8P$pAO6%~rLPGF(8~XoYq*DCkW9HxL z7Qa=X85g5R_3Uq(s*MNmY;Q#TYQ%B>_Et;t1`R(h6}f##NBISud-;Z( zM9z0L49dtum`TZAHyu2a`2OX$=Ljn^5$$*RlrKz#4hoOyv7YZbU5H>yZ+I+~%E}uV z%TZ;FYdM>olthxqz+5sn+TjzXypa%0wx=1vmB@_GQt$y=o61D96JM5CLEi0wi*EF{ zbPc&XIUd^B#S_X~7@>N-y6^}%r{mA1p*DnN>$8QFUm#e4aWtThTt9$ufS955WJ#Gi z`c3_F9j|qS_b^uRGg<=TWkAaYBrurp7D-+vO=1SA{S-{oK)GJh@~-Ys0LDX**Pb#C z8S+3nMug(P5h`*uZt>Z%O*L1P@2tP30N_|0Y#=bl0@mazNO{3b@h-4GR~e#m?IGG4 zxJt8BBNCcx}M$W8E?>5P-`YXzM_Cn0$RyG!9R zR6GS~-0^pG%u0b{x>i}&q1oG zE%tXsx*Cxw!H5b_z$O6LvOy0Ao_zh>M>pS&ijRdOBt3ImctGmK6uYihq~CS`n)&eI z12FTTA%yCp?lCaW+h6_qb+7JXD>#k7D+!yv5m>7zZ91IA$xIJK+GoLMW8`hZT33oyg z@ES~xzi8hu^;~~Z*ZVD{$mFeD1mzeX6^ChsEmy~VorsVjOJepxZfJV;9K8oVwq<7y zNQJO)goHACT%dVHahoez#+00zIVDN0*@TMu;2B9yWJq+ez(uBoiinRzO!+~HZ)~xG z(M9rZooR+`PEw?fViW3RoOoFDN18VTmx!q7`x*HMaCLA;{jGmK9nr}Q(`0eK z_DfVa?dZO-O|>{ax4XpxW5oS3w^rHqQAQt0iCr10&>1t-NB=g!e_+sQ?tSLP)dCH5 zzqbMVu4o)Weg==Wmc1UJH-pz>m1`Ql8O(d2vH_sC1L`6JuHdg%jV9q}I`BJ#YD8}) zp8sj9euHyDS0X)_sQy8mv!KvHg6&`p_cVe;1x9Ab^&;Um7goJ+a*R-PpuU1P_^JY* zq+}V#?_4&(6UGr}9U8EWm6fq~_dWyf26Vq35JRG|WCtED_}{Yv*WgD{W_?O?*E3s9tbyKQK^R60Sx{WTx)7?YOlSn z=6cPe6T=61H@<`O1|n1YCI-(}_ZakR$g=~+p&uRC#0->BB<&0c72r8>1ebuO>Pw@8 zz&~3Pr0pf&k<{k^MWN$&!>zNcMrI$oz^l4QpFP&L62T3Z(y_DwrFYa6p=O{Q-sZ)ZgDxH_FfV*WYyDwHN?DV5DdkG z_Vegcgz$%ciFCz-Bz&8&i)xK|Oz6$(W%c%%AY=V*m8jgGyf!z!B}ongiVTg)*3ZLS z=ANRnyxUm^Hq?J|-|@Zia3hmA%+DvPuV%-`BFQ+i2w`*%BcY

kA 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

aA#8gQI!ni=m29rYu?<} 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 tv9H<| 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 Date: Mon, 19 Oct 2026 00:34:54 +0200 Subject: [PATCH 113/460] Keep the strip knob captions on one line The panel names broke inside the word in the narrow knob column; captions are now slightly smaller and cut rather than wrapped if a name is still too long. --- crates/app/src/views/knob.rs | 6 +++++- crates/app/tests/views.rs | 6 ++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/app/src/views/knob.rs b/crates/app/src/views/knob.rs index 506ea4d..813b0dd 100644 --- a/crates/app/src/views/knob.rs +++ b/crates/app/src/views/knob.rs @@ -43,6 +43,9 @@ const DRAG_ANGLE_ID: &str = "knob_drag_angle"; const DRAG_START_ID: &str = "knob_drag_start"; const EDIT_TEXT_ID: &str = "knob_edit_text"; const EDIT_WIDTH: f32 = 56.0; +/// Below the small text style, so the panel's names ("Verzögerung", "Standby-Zeit") fit their +/// column on one line; a longer one is cut, never broken inside a word, and shows whole on hover. +const CAPTION_SIZE: f32 = 9.0; /// Room for the value line under the knob. const CELL_MIN_WIDTH: f32 = 52.0; pub const KNOB_DIAMETER: f32 = 34.0; @@ -213,7 +216,8 @@ pub fn show(ui: &mut Ui, id: Id, knob: &Knob, state: &mut GestureState) -> Optio let label = knob.label.to_owned(); response.widget_info(|| WidgetInfo::slider(enabled, value, &label)); if knob.caption { - ui.add(Label::new(RichText::new(knob.label).small().weak())); + let caption = RichText::new(knob.label).size(CAPTION_SIZE).weak(); + ui.add(Label::new(caption).truncate()); } let open_now = response.double_clicked(); let edited = value_entry(ui, id, open_now, value, knob.conversion); diff --git a/crates/app/tests/views.rs b/crates/app/tests/views.rs index c49f530..595e984 100644 --- a/crates/app/tests/views.rs +++ b/crates/app/tests/views.rs @@ -828,4 +828,10 @@ fn the_strip_knob_captions_stay_apart() { let caption = |label: &str| harness.get_by_role_and_label(Role::Label, label).rect(); let (delay, standby) = (caption(DELAY_KNOB), caption(STANDBY_KNOB)); assert!(!delay.intersects(standby), "{delay:?} {standby:?}"); + // One line each: a name too long for its column is cut, never broken inside a word. + let small = egui::TextStyle::Small.resolve(&harness.ctx.global_style()); + let line = harness.ctx.fonts_mut(|fonts| fonts.row_height(&small)); + for rect in [delay, standby] { + assert!(rect.height() < 1.5 * line, "{rect:?} for a line of {line}"); + } } From e5a98b10960386506b2824655e31a25cea77fd14 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:22:14 +0200 Subject: [PATCH 114/460] Show a peak readout under each meter Meters are 14 px wide; the held peak is a thin line instead of a lit segment. --- crates/app/src/meter.rs | 51 +++++++++++++++++++++++ crates/app/src/texts.rs | 9 ++++ crates/app/src/views/meters.rs | 75 ++++++++++++++++++++++++++++------ crates/app/tests/views.rs | 10 +++++ 4 files changed, 132 insertions(+), 13 deletions(-) diff --git a/crates/app/src/meter.rs b/crates/app/src/meter.rs index 2003fe2..1e2eab5 100644 --- a/crates/app/src/meter.rs +++ b/crates/app/src/meter.rs @@ -3,6 +3,8 @@ use neumann_control_core::settings::MeterFloor; pub const PEAK_HOLD_SECONDS: f64 = 1.5; +/// A falling peak would make its readout count down every frame; it steps at this pace instead. +pub const READOUT_REFRESH_SECONDS: f64 = 0.25; /// Low enough that quiet room volume (inputs around -90 dB on the KH 120 II) still moves. pub const METER_FLOOR_DB: f64 = MeterFloor::Minus120.db(); pub const METER_CEILING_DB: f64 = 6.0; @@ -146,6 +148,23 @@ impl MeterState { self.peak } + /// The held peak in whole dB for the number under the meter; `None` at or below `floor`. + /// While the hold lasts it is the held peak itself; afterwards it follows the falling bar + /// in steps of `READOUT_REFRESH_SECONDS`, so a new peak still shows at once. + pub fn readout(&self, now: f64, floor: f64) -> Option { + let hold_end = self.peak_since + PEAK_HOLD_SECONDS; + let db = if now < hold_end { + self.peak + } else { + let step = (now / READOUT_REFRESH_SECONDS).floor() * READOUT_REFRESH_SECONDS; + self.shown(step.max(hold_end)) + }; + if db <= floor { + return None; + } + Some(db.round() as i32) + } + pub fn reset(&mut self) { *self = MeterState::default(); } @@ -166,6 +185,38 @@ mod tests { assert_eq!(meter.peak(1.5), meter.shown(1.5)); } + #[test] + fn readout_rounds_the_held_peak() { + let mut meter = MeterState::default(); + meter.update(-18.4, 0.0); + meter.update(-40.0, 0.5); + assert_eq!(meter.readout(0.6, -120.0), Some(-18)); + } + + #[test] + fn readout_rises_at_once_and_steps_while_falling() { + let mut meter = MeterState::default(); + meter.update(-6.0, 0.0); + meter.update(-30.0, 1.0); + assert_eq!(meter.readout(1.4, -120.0), Some(-6)); + // The hold ran out at 1.5 s; the bar falls 24 dB/s from -6 dB at 1.0 s. + assert_eq!(meter.readout(1.6, -120.0), Some(-18)); + assert_eq!(meter.readout(1.7, -120.0), Some(-18)); + assert_eq!(meter.readout(1.75, -120.0), Some(-24)); + meter.update(-3.0, 1.8); + assert_eq!(meter.readout(1.8, -120.0), Some(-3)); + } + + #[test] + fn readout_is_none_below_the_floor() { + let mut meter = MeterState::default(); + meter.update(-160.0, 0.0); + assert_eq!(meter.readout(0.1, -90.0), None); + meter.update(-95.0, 1.0); + assert_eq!(meter.readout(1.1, -90.0), None); + assert_eq!(meter.readout(1.1, -120.0), Some(-95)); + } + #[test] fn position_clamps_below_floor_and_above_ceiling() { assert_eq!(position(-160.0, METER_FLOOR_DB), 0.0); diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index ab55b95..b10fb04 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -268,6 +268,15 @@ pub fn meter_label(group: &str, channel: &str) -> String { format!("{group} {channel}") } +/// The number under a meter: whole dB with a real minus sign, a dash when below the floor. +pub fn meter_readout(db: Option) -> String { + match db { + Some(db) if db < 0 => format!("\u{2212}{}", db.unsigned_abs()), + Some(db) => db.to_string(), + None => "\u{2013}".to_owned(), + } +} + /// The panel's knob beside a number field, named apart from the field. pub fn knob_label(label: &str) -> String { pick(format!("{label} (Regler)"), format!("{label} (knob)")) diff --git a/crates/app/src/views/meters.rs b/crates/app/src/views/meters.rs index 7844a76..e113a45 100644 --- a/crates/app/src/views/meters.rs +++ b/crates/app/src/views/meters.rs @@ -1,6 +1,6 @@ //! Level meters drawn side by side, one per channel: segments in the colour of their zone //! (lit up to the level, dimmed above it, so the eye always sees where red begins), the held -//! peak as one lit segment, and a clip lamp on top. The scale is the spread one from `meter`. +//! peak as a thin line, the held peak as a number below, and a clip lamp on top. The scale is the spread one from `meter`. use eframe::egui::{ pos2, vec2, Align2, Color32, CornerRadius, FontId, Label, Rect, Response, RichText, Sense, @@ -14,7 +14,7 @@ use crate::meter::{ use crate::texts; use crate::ui_kit::theme::Tokens; -const METER_WIDTH: f32 = 8.0; +const METER_WIDTH: f32 = 14.0; const CLIP_LAMP_RADIUS: f32 = 3.5; const CLIP_LAMP_SPACE: f32 = 10.0; /// One segment and the gap below it. @@ -29,6 +29,10 @@ const SCALE_FONT_SIZE: f32 = 8.5; const SCALE_MARKS_DB: [f64; 5] = [0.0, -6.0, -20.0, -60.0, -90.0]; const CHANNEL_NAME_HEIGHT: f32 = 12.0; const CHANNEL_NAME_SIZE: f32 = 9.0; +const READOUT_HEIGHT: f32 = 12.0; +/// The widest readout, so a column keeps its width whatever the level. +const WIDEST_READOUT: Option = Some(-120); +const PEAK_LINE_HEIGHT: f32 = 2.0; const PLAYED_MARK_SPACE: f32 = 8.0; const PLAYED_MARK_RADIUS: f32 = 2.0; const BAR_WIDTH: f32 = 72.0; @@ -63,11 +67,15 @@ pub fn show(ui: &mut Ui, group: &Meters, height: f32, floor: f64, tokens: &Token // column is as wide as the name ("Sub"), with the meter in its middle. let name = texts::channel_name(channels, channel); let font = FontId::proportional(CHANNEL_NAME_SIZE); - let name_width = ui - .painter() - .layout_no_wrap(name.clone(), font, tokens.text_dim) - .size() - .x; + let text_width = |text: String| { + let painter = ui.painter(); + painter + .layout_no_wrap(text, font.clone(), tokens.text_dim) + .size() + .x + }; + let name_width = + text_width(name.clone()).max(text_width(texts::meter_readout(WIDEST_READOUT))); let mark_space = if group.played.is_empty() { 0.0 } else { @@ -75,7 +83,7 @@ pub fn show(ui: &mut Ui, group: &Meters, height: f32, floor: f64, tokens: &Token }; let size = vec2( METER_WIDTH.max(name_width), - height + CHANNEL_NAME_HEIGHT + mark_space, + height + CHANNEL_NAME_HEIGHT + READOUT_HEIGHT + mark_space, ); let (cell, response) = ui.allocate_exact_size(size, Sense::hover()); covered = covered.union(cell); @@ -90,12 +98,23 @@ pub fn show(ui: &mut Ui, group: &Meters, height: f32, floor: f64, tokens: &Token ); let caption = Label::new(RichText::new(&name).size(CHANNEL_NAME_SIZE).weak()); ui.put(name_rect, caption); + let readout = meter.readout(now, floor); + let readout_bottom = name_bottom + READOUT_HEIGHT; + let readout_rect = Rect::from_min_max( + pos2(cell.left(), name_bottom), + pos2(cell.right(), readout_bottom), + ); + let readout_text = RichText::new(texts::meter_readout(readout)).size(CHANNEL_NAME_SIZE); + ui.put(readout_rect, Label::new(readout_text)); if group.played.get(channel).copied().unwrap_or(false) { - let mark = Rect::from_min_max(pos2(cell.left(), name_bottom), cell.max); + let mark = Rect::from_min_max(pos2(cell.left(), readout_bottom), cell.max); ui.put(mark, |ui: &mut Ui| played_mark(ui, tokens)); } let label = texts::meter_label(name_of_group, &name); - response.widget_info(|| WidgetInfo::labeled(WidgetType::ProgressIndicator, true, &label)); + response.widget_info(|| WidgetInfo { + value: readout.map(f64::from), + ..WidgetInfo::labeled(WidgetType::ProgressIndicator, true, &label) + }); let lamp_color = if clipped { tokens.danger } else { @@ -159,18 +178,35 @@ fn paint_segments(ui: &Ui, track: Rect, level: f32, peak: f32, floor: f64, token let rects = segment_rects(track, ui.ctx().pixels_per_point()); let count = rects.len().max(1); let lit = (level * count as f32).round() as usize; - let peak_segment = (peak * count as f32).ceil() as usize; for (segment, rect) in rects.into_iter().enumerate() { let middle = (segment as f32 + 0.5) / count as f32; let color = zone_color(zone(level_at(middle, floor)), tokens); - let shown = segment < lit || segment + 1 == peak_segment; - let color = if shown { + let color = if segment < lit { color } else { color.gamma_multiply(UNLIT_OPACITY) }; painter.rect_filled(rect, CornerRadius::same(SEGMENT_RADIUS), color); } + if let Some(line) = peak_line(track, level, peak) { + let color = zone_color(zone(level_at(peak, floor)), tokens); + painter.rect_filled(line, CornerRadius::ZERO, color); + } +} + +/// The held peak as a thin line at its exact height, kept inside the track; none when the bar +/// reaches it. +fn peak_line(track: Rect, level: f32, peak: f32) -> Option { + if peak <= level { + return None; + } + let center = track.bottom() - track.height() * peak; + let top = (center - PEAK_LINE_HEIGHT / 2.0).max(track.top()); + let line = Rect::from_min_max( + pos2(track.left(), top), + pos2(track.right(), top + PEAK_LINE_HEIGHT), + ); + Some(line) } /// The segments from the bottom up, each the same whole number of physical pixels high with the @@ -255,6 +291,19 @@ mod tests { } } + #[test] + fn peak_line_sits_at_the_peak_and_vanishes_at_the_bar() { + let track = Rect::from_min_max(pos2(10.0, 0.0), pos2(24.0, 100.0)); + let line = peak_line(track, 0.2, 0.5).expect("a peak above the bar"); + assert_eq!(line.center().y, 50.0); + assert_eq!(line.height(), PEAK_LINE_HEIGHT); + assert_eq!(line.x_range(), track.x_range()); + assert_eq!(peak_line(track, 0.5, 0.5), None); + assert_eq!(peak_line(track, 0.6, 0.5), None); + let top = peak_line(track, 0.2, 1.0).expect("a peak at the top"); + assert_eq!(top.top(), track.top()); + } + #[test] fn segments_follow_the_pixel_grid_at_other_scales() { let track = Rect::from_min_max(pos2(10.0, 20.0), pos2(18.0, 200.0)); diff --git a/crates/app/tests/views.rs b/crates/app/tests/views.rs index 595e984..6ece558 100644 --- a/crates/app/tests/views.rs +++ b/crates/app/tests/views.rs @@ -835,3 +835,13 @@ fn the_strip_knob_captions_stay_apart() { assert!(rect.height() < 1.5 * line, "{rect:?} for a line of {line}"); } } + +#[test] +fn each_meter_channel_shows_its_peak_readout() { + let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); + simulator.set_value("m/in/level", serde_json::json!([-18.2, -40.4])); + wait_for_label(&mut harness, &texts::meter_readout(Some(-18))); + harness.get_by_label(&texts::meter_readout(Some(-40))); + assert_eq!(texts::meter_readout(Some(-18)), "\u{2212}18"); + assert_eq!(texts::meter_readout(None), "\u{2013}"); +} From ed80ea0f2c2b9734d5486899ac69229c1cb16ba0 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:25:34 +0200 Subject: [PATCH 115/460] Add the write history model --- crates/app/src/history.rs | 186 ++++++++++++++++++++++++++++++++++++++ crates/app/src/lib.rs | 1 + 2 files changed, 187 insertions(+) create mode 100644 crates/app/src/history.rs diff --git a/crates/app/src/history.rs b/crates/app/src/history.rs new file mode 100644 index 0000000..80170a0 --- /dev/null +++ b/crates/app/src/history.rs @@ -0,0 +1,186 @@ +//! The writes of this session, newest last: one entry per user action, each change of it +//! revertible on its own. Lives only as long as the app runs. + +use std::collections::VecDeque; + +use neumann_control_core::ssc::wire::SscPath; +use serde_json::Value; + +use crate::state::SpeakerKey; + +/// A session that runs for days still keeps a bounded list. +pub const HISTORY_LIMIT: usize = 500; + +/// The wall clock time of an entry, as the user reads it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ClockTime { + pub hour: u8, + pub minute: u8, + pub second: u8, +} + +/// One write to one speaker. +#[derive(Clone, Debug, PartialEq)] +pub struct Change { + pub speaker: SpeakerKey, + pub path: SscPath, + /// `None` when the speaker had not reported the value: such a change cannot be taken back. + pub before: Option, + pub after: Value, + pub taken_back: bool, +} + +/// Everything one user action wrote: a gesture, a group move, a linked write. +#[derive(Clone, Debug, PartialEq)] +pub struct Entry { + pub id: u64, + pub clock: ClockTime, + pub changes: Vec, + /// The entry this one took back. + pub takes_back: Option, +} + +#[derive(Debug, Default)] +pub struct History { + entries: VecDeque, + next_id: u64, +} + +impl History { + /// Adds the changes of one frame as an entry; a frame without writes adds nothing. + pub fn push( + &mut self, + clock: ClockTime, + changes: Vec, + takes_back: Option, + ) -> Option { + if changes.is_empty() { + return None; + } + let id = self.next_id; + self.next_id += 1; + self.entries.push_back(Entry { + id, + clock, + changes, + takes_back, + }); + if self.entries.len() > HISTORY_LIMIT { + self.entries.pop_front(); + } + Some(id) + } + + /// Newest first, as the history window lists them. + pub fn entries(&self) -> impl Iterator { + self.entries.iter().rev() + } + + pub fn entry(&self, id: u64) -> Option<&Entry> { + self.entries.iter().find(|entry| entry.id == id) + } + + /// The newest entry with a change that can still be taken back: what Ctrl+Z takes back. + pub fn newest_open(&self) -> Option { + self.entries() + .find(|entry| entry.changes.iter().any(is_open)) + .map(|entry| entry.id) + } + + pub fn mark_taken_back(&mut self, id: u64, change: usize) { + let entry = self.entries.iter_mut().find(|entry| entry.id == id); + if let Some(change) = entry.and_then(|entry| entry.changes.get_mut(change)) { + change.taken_back = true; + } + } +} + +pub fn is_open(change: &Change) -> bool { + !change.taken_back && change.before.is_some() +} + +/// Whether a reported value still is the written one: numbers within half a catalog step (the +/// speaker may report 84 for a written 84.0, or round to its own grid), anything else exactly. +pub fn same_value(reported: &Value, written: &Value, step: Option) -> bool { + let (Some(reported_number), Some(written_number)) = (reported.as_f64(), written.as_f64()) + else { + return reported == written; + }; + let tolerance = step.map_or(0.0, |step| step / 2.0); + (reported_number - written_number).abs() < tolerance || reported_number == written_number +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + use crate::paths; + + const NOON: ClockTime = ClockTime { + hour: 12, + minute: 0, + second: 0, + }; + + fn change(before: Option, after: Value) -> Change { + Change { + speaker: SpeakerKey::Serial("SIM0001234".to_owned()), + path: paths::path(paths::OUTPUT_LEVEL), + before, + after, + taken_back: false, + } + } + + #[test] + fn empty_frames_make_no_entry() { + let mut history = History::default(); + assert_eq!(history.push(NOON, Vec::new(), None), None); + assert_eq!(history.entries().count(), 0); + } + + #[test] + fn entries_come_newest_first() { + let mut history = History::default(); + let first = history.push(NOON, vec![change(Some(json!(80)), json!(84))], None); + let second = history.push(NOON, vec![change(Some(json!(84)), json!(90))], first); + let ids: Vec = history.entries().map(|entry| entry.id).collect(); + assert_eq!(ids, vec![second.unwrap(), first.unwrap()]); + assert_eq!(history.entry(second.unwrap()).unwrap().takes_back, first); + } + + #[test] + fn the_oldest_entry_drops_out_at_the_limit() { + let mut history = History::default(); + let first = history.push(NOON, vec![change(Some(json!(1)), json!(2))], None); + for _ in 0..HISTORY_LIMIT { + history.push(NOON, vec![change(Some(json!(1)), json!(2))], None); + } + assert_eq!(history.entries().count(), HISTORY_LIMIT); + assert!(history.entry(first.unwrap()).is_none()); + } + + #[test] + fn newest_open_skips_taken_back_and_unknown_before() { + let mut history = History::default(); + let known = history.push(NOON, vec![change(Some(json!(80)), json!(84))], None); + let unknown = history.push(NOON, vec![change(None, json!(84))], None); + assert_eq!(history.newest_open(), known); + assert!(!is_open( + &history.entry(unknown.unwrap()).unwrap().changes[0] + )); + history.mark_taken_back(known.unwrap(), 0); + assert_eq!(history.newest_open(), None); + } + + #[test] + fn numbers_within_half_a_step_are_the_same() { + assert!(same_value(&json!(84), &json!(84.0), Some(0.5))); + assert!(same_value(&json!(84.0), &json!(84.2), Some(0.5))); + assert!(!same_value(&json!(84.0), &json!(84.5), Some(0.5))); + assert!(!same_value(&json!(84.0), &json!(84.2), None)); + assert!(same_value(&json!(true), &json!(true), None)); + assert!(!same_value(&json!("a"), &json!("b"), Some(1.0))); + } +} diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index d090323..a00fcf0 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -4,6 +4,7 @@ pub mod display; pub mod fader; pub mod gesture; pub mod groups; +pub mod history; pub mod input_source; pub mod meter; pub mod paths; From ef576582a1e3e6ee93f259054fa2c21e3b07ef17 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:28:51 +0200 Subject: [PATCH 116/460] Record every write and take one back Writes of one frame form one history entry; taking one back writes the values before through the write policy and asks first when they changed. --- Cargo.lock | 1 + crates/app/Cargo.toml | 2 + crates/app/src/app.rs | 7 ++ crates/app/src/app/history.rs | 176 +++++++++++++++++++++++++++++++++ crates/app/src/history.rs | 14 +++ crates/app/src/state.rs | 20 ++++ crates/app/src/texts.rs | 2 + crates/app/src/writes.rs | 43 ++++++++ crates/app/tests/history.rs | 180 ++++++++++++++++++++++++++++++++++ 9 files changed, 445 insertions(+) create mode 100644 crates/app/src/app/history.rs create mode 100644 crates/app/tests/history.rs diff --git a/Cargo.lock b/Cargo.lock index 8441fdd..7f59977 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1927,6 +1927,7 @@ dependencies = [ "serde_json", "simplelog", "tempfile", + "time", "winit", "winresource", ] diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 2d6eb12..f5333f8 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -20,6 +20,8 @@ serde_json = "1" eframe = "0.36" log = "0.4" simplelog = "0.12" +# Already in the tree through simplelog; the local clock time of the history's entries. +time = { version = "0.3", features = ["local-offset"] } # Already in the tree through eframe; lists the monitors to place the window at the start. winit = "0.30" diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index 6a99d9e..805b4f4 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -33,6 +33,9 @@ use crate::views::{all_values, panel, wheel}; mod compact; mod desk; +mod history; + +pub use history::TakeBackNotice; const DESK_SCROLL_ID: &str = "desk"; const COMPACT_SCROLL_ID: &str = "compact"; @@ -77,6 +80,7 @@ pub struct NeumannApp { group_draft: Option, settings_open: bool, window: Option, + history: history::HistoryState, } impl NeumannApp { @@ -118,6 +122,7 @@ impl NeumannApp { panel: None, group_draft: None, settings_open: false, + history: history::HistoryState::default(), }; app.sync_targets(); app @@ -360,6 +365,8 @@ impl NeumannApp { self.show_settings(ui); self.show_group_dialog(ui); self.show_all_values(ui.ctx()); + let now = ui.input(|input| input.time); + self.finish_history_frame(now); } fn show_all_values(&mut self, context: &Context) { diff --git a/crates/app/src/app/history.rs b/crates/app/src/app/history.rs new file mode 100644 index 0000000..b11ecfa --- /dev/null +++ b/crates/app/src/app/history.rs @@ -0,0 +1,176 @@ +//! The session's history on the app side: the writes of each frame become one entry, and an +//! entry is taken back by writing its values before through the normal write policy. + +use super::NeumannApp; +use crate::history::{changed_since, is_open, Change, ClockTime, Entry, History}; +use crate::paths; +use crate::state::{SpeakerState, Status}; +use crate::texts; +use crate::writes::Intent; +use neumann_control_core::catalog::Catalog; +use neumann_control_core::speaker::Command; + +/// What the last take-back could not write, for the history window. +#[derive(Clone, Debug, PartialEq)] +pub struct TakeBackNotice { + pub entry: u64, + pub written: usize, + /// The names of the speakers left out (gone, unreachable, or refused). + pub skipped: Vec, +} + +/// The history and the take-back waiting for its turn or for the user's answer. +#[derive(Debug, Default)] +pub(super) struct HistoryState { + pub history: History, + /// The entry to take back at the end of this frame, and whether the user confirmed it. + pub request: Option<(u64, bool)>, + /// An entry whose values changed since; the user is asked before it is taken back. + pub question: Option, + pub notice: Option, +} + +impl NeumannApp { + pub fn history(&self) -> &History { + &self.history.history + } + + pub fn request_take_back(&mut self, id: u64) { + self.history.request = Some((id, false)); + } + + pub fn take_back_question(&self) -> Option { + self.history.question + } + + /// The answer to "changed since": `true` takes the entry back anyway. + pub fn answer_take_back(&mut self, confirmed: bool) { + let question = self.history.question.take(); + if confirmed { + self.history.request = question.map(|id| (id, true)); + } + } + + pub fn take_back_notice(&self) -> Option<&TakeBackNotice> { + self.history.notice.as_ref() + } + + /// Last in the frame: runs a requested take-back, then files everything this frame wrote, + /// the take-back's own writes included, as one entry. + pub(super) fn finish_history_frame(&mut self, now: f64) { + let mut takes_back = None; + if let Some((id, confirmed)) = self.history.request.take() { + takes_back = self.take_back(id, confirmed, now); + } + let changes: Vec = self + .speakers + .iter_mut() + .flat_map(|speaker| { + let key = speaker.state.key.clone(); + speaker + .state + .written + .drain(..) + .map(move |(path, before, after)| Change { + speaker: key.clone(), + path, + before, + after, + taken_back: false, + }) + .collect::>() + }) + .collect(); + self.history.history.push(clock_now(), changes, takes_back); + } + + /// Writes the open changes' values before; returns the entry when it was taken back (in + /// part or whole), `None` when it waits for the user's answer or is gone. + fn take_back(&mut self, id: u64, confirmed: bool, now: f64) -> Option { + let catalog = Catalog::embedded(); + let entry = self.history.history.entry(id)?.clone(); + if !confirmed && self.changed_since(&entry, catalog) { + self.history.question = Some(id); + return None; + } + let mut written = 0; + let mut skipped = Vec::new(); + for (index, change) in entry.changes.iter().enumerate() { + if !is_open(change) { + continue; + } + let speaker = self + .speakers + .iter_mut() + .find(|speaker| speaker.state.key == change.speaker); + let Some(speaker) = speaker else { + skipped.push(texts::REMOVED_SPEAKER.get().to_owned()); + continue; + }; + let sent = send_before(&mut speaker.state, change, catalog, now); + match sent { + Some(command) => { + speaker.handle.send(command); + self.history.history.mark_taken_back(id, index); + written += 1; + } + None => skipped.push(speaker.state.display_name()), + } + } + self.history.notice = (!skipped.is_empty()).then_some(TakeBackNotice { + entry: id, + written, + skipped, + }); + Some(id) + } + + fn changed_since(&self, entry: &Entry, catalog: &Catalog) -> bool { + entry + .changes + .iter() + .filter(|change| is_open(change)) + .any(|change| { + let speaker = self + .speakers + .iter() + .find(|speaker| speaker.state.key == change.speaker); + let reported = speaker.and_then(|speaker| speaker.state.values.get(&change.path)); + let step = paths::catalog_entry(catalog, &change.path).and_then(|entry| entry.step); + changed_since(change, reported, step) + }) + } +} + +/// The value before through the write policy; a level as a level, so the fader holds it. +fn send_before( + speaker: &mut SpeakerState, + change: &Change, + catalog: &Catalog, + now: f64, +) -> Option { + if speaker.status != Status::Connected { + return None; + } + let before = change.before.clone()?; + let level = before + .as_f64() + .filter(|_| change.path == paths::path(paths::OUTPUT_LEVEL)); + let intent = match level { + Some(level) => Intent::Level(level), + None => Intent::Write { + path: change.path.clone(), + value: before, + }, + }; + speaker.request(intent, catalog, now) +} + +fn clock_now() -> ClockTime { + let now = time::OffsetDateTime::now_local().unwrap_or_else(|_| time::OffsetDateTime::now_utc()); + ClockTime { + hour: now.hour(), + minute: now.minute(), + second: now.second(), + } +} diff --git a/crates/app/src/history.rs b/crates/app/src/history.rs index 80170a0..b460957 100644 --- a/crates/app/src/history.rs +++ b/crates/app/src/history.rs @@ -95,6 +95,12 @@ impl History { } } +/// Whether the speaker now reports something else than the change wrote. A value it has not +/// reported is no reason to ask: the write policy decides about that speaker anyway. +pub fn changed_since(change: &Change, reported: Option<&Value>, step: Option) -> bool { + reported.is_some_and(|reported| !same_value(reported, &change.after, step)) +} + pub fn is_open(change: &Change) -> bool { !change.taken_back && change.before.is_some() } @@ -174,6 +180,14 @@ mod tests { assert_eq!(history.newest_open(), None); } + #[test] + fn a_change_counts_as_changed_since_only_when_the_speaker_reports_another_value() { + let written = change(Some(json!(80.0)), json!(84.0)); + assert!(!changed_since(&written, Some(&json!(84)), Some(0.5))); + assert!(changed_since(&written, Some(&json!(78.0)), Some(0.5))); + assert!(!changed_since(&written, None, Some(0.5))); + } + #[test] fn numbers_within_half_a_step_are_the_same() { assert!(same_value(&json!(84), &json!(84.0), Some(0.5))); diff --git a/crates/app/src/state.rs b/crates/app/src/state.rs index e81fa28..fd1629a 100644 --- a/crates/app/src/state.rs +++ b/crates/app/src/state.rs @@ -91,6 +91,11 @@ pub struct SpeakerState { pub all_values_filter: String, /// The worker's running mean; none until the current connection measured one. pub response_time: Option, + /// What this frame sent: path, value before, value written. The app takes it into the + /// history at the end of the frame. + pub written: Vec<(SscPath, Option, Value)>, + /// Sent values the speaker has not reported back yet; the value before of the next write. + pending: BTreeMap, } impl SpeakerState { @@ -112,6 +117,8 @@ impl SpeakerState { all_values: AllValuesState::Idle, all_values_filter: String::new(), response_time: None, + written: Vec::new(), + pending: BTreeMap::new(), } } @@ -155,6 +162,17 @@ impl SpeakerState { .find(|model| !model.is_empty()) } + /// Notes a sent write for the history; Identify is a momentary signal, not a setting. + pub(crate) fn record_written(&mut self, path: &SscPath, value: &Value) { + if *path == paths::path(paths::IDENTIFY) { + return; + } + let before = self.pending.get(path).or_else(|| self.values.get(path)); + let change = (path.clone(), before.cloned(), value.clone()); + self.pending.insert(path.clone(), value.clone()); + self.written.push(change); + } + pub fn apply(&mut self, event: Event, now: f64) { match event { Event::Connected(identity) => { @@ -171,6 +189,7 @@ impl SpeakerState { self.set_message(Some(Message::Unreachable), MessageSource::Connection); self.reset_meters(); self.response_time = None; + self.pending.clear(); } Event::Value { path, value } => { if path == paths::path(paths::OUTPUT_LEVEL) { @@ -180,6 +199,7 @@ impl SpeakerState { knob.confirmed(); } self.absent.remove(&path); + self.pending.remove(&path); self.feed_meters(&path, &value, now); self.values.insert(path, value); } diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index b10fb04..30a71f2 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -138,6 +138,8 @@ pub const COPY_TO_GROUP: Text = Text::new("Für Gruppe übernehmen", "Apply to g pub const MASTER: Text = Text::new("Master", "Master"); pub const MASTER_LEVEL: Text = Text::new("Master-Pegel", "Master level"); pub const MUTE_ALL: Text = Text::new("Alle stumm", "Mute all"); +/// A speaker of a history entry that has been removed since. +pub const REMOVED_SPEAKER: Text = Text::new("entfernter Lautsprecher", "removed speaker"); pub const CONTROL_HINT: Text = Text::new( "Shift: fein · Doppelklick: Wert eingeben", "Shift: fine · Double-click: type a value", diff --git a/crates/app/src/writes.rs b/crates/app/src/writes.rs index 68d9385..a22f1b2 100644 --- a/crates/app/src/writes.rs +++ b/crates/app/src/writes.rs @@ -133,6 +133,7 @@ impl SpeakerState { if let (Some(knob), Some(number)) = (self.knobs.get_mut(&path), value.as_f64()) { knob.sent(number, now); } + self.record_written(&path, &value); Ok(Command::Set { path, value }) } @@ -346,6 +347,48 @@ mod tests { ); } + #[test] + fn a_sent_write_is_recorded_with_the_value_before() { + let mut speaker = speaker("KH 120 II"); + let level = Event::Value { + path: paths::path(paths::OUTPUT_LEVEL), + value: Value::from(80.0), + }; + speaker.apply(level, 0.0); + speaker.request(Intent::Level(90.0), Catalog::embedded(), 0.0); + // The speaker has not reported 90 yet: the next write started from 90, not 80. + speaker.request(Intent::Level(95.0), Catalog::embedded(), 0.1); + let path = paths::path(paths::OUTPUT_LEVEL); + let written = std::mem::take(&mut speaker.written); + assert_eq!( + written, + vec![ + (path.clone(), Some(Value::from(80.0)), Value::from(90.0)), + (path.clone(), Some(Value::from(90.0)), Value::from(95.0)), + ] + ); + let reported = Event::Value { + path: path.clone(), + value: Value::from(93.0), + }; + speaker.apply(reported, 0.5); + speaker.request(Intent::Level(85.0), Catalog::embedded(), 1.0); + assert_eq!(speaker.written[0].1, Some(Value::from(93.0))); + } + + #[test] + fn identify_and_refused_writes_are_not_recorded() { + let mut speaker = speaker("KH 120 II"); + let identify = Event::Value { + path: paths::path(paths::IDENTIFY), + value: Value::Bool(false), + }; + speaker.apply(identify, 0.0); + speaker.request(Intent::Toggle(Switch::Identify), Catalog::embedded(), 0.0); + speaker.request(Intent::Level(130.0), Catalog::embedded(), 0.0); + assert!(speaker.written.is_empty(), "{:?}", speaker.written); + } + #[test] fn a_switch_without_a_reported_value_is_refused() { let mut speaker = speaker("KH 120 II"); diff --git a/crates/app/tests/history.rs b/crates/app/tests/history.rs new file mode 100644 index 0000000..904bcde --- /dev/null +++ b/crates/app/tests/history.rs @@ -0,0 +1,180 @@ +//! Every write lands in the session's history as one entry per user action, and each entry can +//! be taken back: the values before are written again, through the same rules as any write. + +mod support; + +use eframe::egui::{self, accesskit::Role}; +use egui_kittest::kittest::Queryable; +use egui_kittest::Harness; +use neumann_control_app::app::NeumannApp; +use neumann_control_app::history::{is_open, Entry}; +use neumann_control_app::texts; +use neumann_control_core::simulator::SimModel; +use serde_json::{json, Value}; +use support::*; + +const LEVEL_PATH: &str = "audio/out/level"; + +fn entries(harness: &Harness<'static, NeumannApp>) -> Vec { + harness.state().history().entries().cloned().collect() +} + +fn drag_fader_down(harness: &mut Harness<'static, NeumannApp>) { + let start = harness + .get_by_role_and_label(Role::Slider, texts::LEVEL.get()) + .rect() + .center(); + harness.drag_at(start); + for frame in 1..=4 { + harness.hover_at(start + egui::vec2(0.0, 5.0 * frame as f32)); + harness.step(); + } + harness.drop_at(start + egui::vec2(0.0, 20.0)); + harness.run_steps(3); +} + +fn take_back(harness: &mut Harness<'static, NeumannApp>, id: u64) { + harness.state_mut().request_take_back(id); + harness.run_steps(3); +} + +#[test] +fn a_fader_drag_is_one_history_entry() { + let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); + simulator.set_value(LEVEL_PATH, json!(90.0)); + wait_for_level(&mut harness, 90.0); + drag_fader_down(&mut harness); + wait_until(&mut harness, "the level write", |_| { + !level_sets(&simulator).is_empty() + }); + let entries = entries(&harness); + assert_eq!(entries.len(), 1, "{entries:?}"); + let change = &entries[0].changes[0]; + assert_eq!(entries[0].changes.len(), 1); + assert_eq!(change.path.to_string(), LEVEL_PATH); + assert_eq!(change.before.as_ref().and_then(Value::as_f64), Some(90.0)); + assert_eq!(change.after.as_f64(), Some(level_sets(&simulator)[0])); +} + +#[test] +fn a_group_mute_is_one_entry_with_every_sent_member() { + let (kh120, kh750, _directory, mut harness) = grouped(Vec::new()); + harness.get_by_label(&texts::group_mute(GROUP_NAME)).click(); + wait_until(&mut harness, "group mute", |_| { + !sets(&kh120, "/audio/out/mute").is_empty() && !sets(&kh750, "/audio/out/mute").is_empty() + }); + let entries = entries(&harness); + assert_eq!(entries.len(), 1, "{entries:?}"); + assert_eq!(entries[0].changes.len(), 2); +} + +#[test] +fn a_group_mute_leaves_an_unreachable_member_out_of_the_entry() { + let (kh120, kh750, _directory, mut harness) = grouped(Vec::new()); + kh750.stop(); + wait_for_label(&mut harness, texts::UNREACHABLE.get()); + harness.get_by_label(&texts::group_mute(GROUP_NAME)).click(); + wait_until(&mut harness, "the reachable member's mute", |_| { + !sets(&kh120, "/audio/out/mute").is_empty() + }); + let entries = entries(&harness); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].changes.len(), 1); +} + +#[test] +fn identify_is_not_recorded() { + let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); + harness.get_by_label(texts::IDENTIFY.get()).click(); + wait_until(&mut harness, "the identify write", |_| { + !sets(&simulator, "/device/identification/visual").is_empty() + }); + harness.run_steps(2); + assert!(entries(&harness).is_empty()); +} + +#[test] +fn taking_back_writes_the_value_before() { + let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); + harness.get_by_label(texts::MUTE.get()).click(); + wait_until(&mut harness, "mute", |_| { + sets(&simulator, "/audio/out/mute") == [json!(true)] + }); + let id = entries(&harness)[0].id; + take_back(&mut harness, id); + wait_until(&mut harness, "unmute", |_| { + sets(&simulator, "/audio/out/mute") == [json!(true), json!(false)] + }); + let entries = entries(&harness); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].takes_back, Some(id)); + assert!(entries[1].changes[0].taken_back); +} + +#[test] +fn a_take_back_is_itself_an_entry_that_can_be_taken_back() { + let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); + harness.get_by_label(texts::MUTE.get()).click(); + wait_until(&mut harness, "mute", |_| { + !sets(&simulator, "/audio/out/mute").is_empty() + }); + let first = entries(&harness)[0].id; + take_back(&mut harness, first); + wait_until(&mut harness, "unmute", |_| { + sets(&simulator, "/audio/out/mute").len() == 2 + }); + let newest = harness.state().history().newest_open(); + assert_eq!(newest, Some(entries(&harness)[0].id)); + take_back(&mut harness, newest.unwrap()); + wait_until(&mut harness, "mute again", |_| { + sets(&simulator, "/audio/out/mute") == [json!(true), json!(false), json!(true)] + }); +} + +#[test] +fn a_changed_value_asks_before_taking_back() { + let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); + simulator.set_value(LEVEL_PATH, json!(90.0)); + wait_for_level(&mut harness, 90.0); + drag_fader_down(&mut harness); + wait_until(&mut harness, "the level write", |_| { + !level_sets(&simulator).is_empty() + }); + // Someone else turns the speaker meanwhile. + simulator.set_value(LEVEL_PATH, json!(70.0)); + wait_for_level(&mut harness, 70.0); + let id = entries(&harness)[0].id; + take_back(&mut harness, id); + assert_eq!(harness.state().take_back_question(), Some(id)); + harness.state_mut().answer_take_back(false); + harness.run_steps(3); + barrier(&simulator); + assert_eq!(level_sets(&simulator).len(), 1); + assert_eq!(harness.state().take_back_question(), None); + take_back(&mut harness, id); + harness.state_mut().answer_take_back(true); + wait_until(&mut harness, "the level written back", |_| { + level_sets(&simulator).len() == 2 + }); + assert_eq!(level_sets(&simulator)[1], 90.0); +} + +#[test] +fn an_unreachable_speaker_stays_open() { + let (kh120, kh750, _directory, mut harness) = grouped(Vec::new()); + harness.get_by_label(&texts::group_mute(GROUP_NAME)).click(); + wait_until(&mut harness, "group mute", |_| { + !sets(&kh120, "/audio/out/mute").is_empty() && !sets(&kh750, "/audio/out/mute").is_empty() + }); + kh750.stop(); + wait_for_label(&mut harness, texts::UNREACHABLE.get()); + let id = entries(&harness)[0].id; + take_back(&mut harness, id); + wait_until(&mut harness, "the reachable member unmuted", |_| { + sets(&kh120, "/audio/out/mute").len() == 2 + }); + let original = harness.state().history().entry(id).unwrap().clone(); + let open: Vec = original.changes.iter().map(is_open).collect(); + assert_eq!(open.iter().filter(|open| **open).count(), 1, "{open:?}"); + assert!(harness.state().take_back_notice().is_some()); +} From f850c4614179802f872d66d5af2142e57801a00d Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:35:13 +0200 Subject: [PATCH 117/460] Add the history window and Ctrl+Z --- CHANGELOG.md | 11 ++ README.md | 16 ++- crates/app/src/app.rs | 4 + crates/app/src/app/history.rs | 185 ++++++++++++++++++++++++++++++++ crates/app/src/texts.rs | 84 ++++++++++++++- crates/app/src/views/history.rs | 119 ++++++++++++++++++++ crates/app/src/views/mod.rs | 1 + crates/app/src/views/toolbar.rs | 6 +- crates/app/tests/history.rs | 114 ++++++++++++++++++++ crates/app/tests/views.rs | 4 +- 10 files changed, 538 insertions(+), 6 deletions(-) create mode 100644 crates/app/src/views/history.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index a85862f..dbb9f92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## [Unreleased] +### Added + +- A history of every setting the app wrote in this session (**History** in the toolbar), with + **Undo** on each entry and Ctrl+Z for the newest. A group or master move is one entry. When a + value changed since, the app asks before writing the old one back. +- The held peak of each meter as a number under its channel. + +### Changed + +- Wider meters; the held peak is a thin line instead of a lit segment. + ## [0.1.0] - 2026-10-18 First version. diff --git a/README.md b/README.md index dccd901..43114cb 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ panel of one KH 750 open. The interface is shown in German.* - **A device panel** per speaker with every setting the catalog knows for its model. - **Groups and a master**: move or mute several speakers at once, and link settings within a group. +- **History and undo**: every write of the session, each one revertible, Ctrl+Z for the newest. - **Compact mode** (Ctrl+K): one row per speaker or group in a small window. - **German and English** interface, light and dark theme. - **Portable**: one executable, settings and log next to it, no installer. @@ -113,7 +114,8 @@ Each speaker has a channel strip: - knobs for **Delay** (shown in ms) and **Standby time**, - the output level fader (0–120 dB SPL in 0.5 dB steps) with its value below, - input meters for channels A and B with clip lamps; the KH 750 also shows its output meters A, B - and Sub, captioned "Out", + and Sub, captioned "Out". A thin line marks each meter's peak of the last 1.5 seconds, and the + number under the channel shows it in whole dB ("–" below the meter range), - the response time, **Mute** and **Ident.** (identify). On the KH 120 II the input meters are captioned with the rear input switch (Analog, AES3 L, @@ -175,6 +177,18 @@ bottom. A group row moves its members like the group strip. A **!** stands for a The compact window keeps its own size and position and scrolls sideways when it is narrower than its rows. Scanning and adding speakers or groups stay on the desk. +### History and undo + +**History** in the toolbar lists every setting the app wrote in this session, newest first, with +the time, the speaker, the setting and the value before and after. A group or master move is one +entry. **Undo** writes the values before again; Ctrl+Z does the same for the newest entry (not +while you type in a value field). An undo is itself an entry, so it can be undone too. + +When a value changed since it was written (in another app or on the speaker), the app lists the +changes and asks before writing the old value back. A speaker that is not connected is left out, +and its change can be undone later. The history starts empty at every start; identify is not +recorded. + ### Settings **Settings** sets: diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index 805b4f4..6d7c538 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -345,12 +345,14 @@ impl NeumannApp { ToolbarAction::AddAddress => self.add_address = Some(AddAddressForm::default()), ToolbarAction::AddGroup => self.add_group(), ToolbarAction::Settings => self.settings_open = true, + ToolbarAction::History => self.open_history(), ToolbarAction::ToggleCompact => self.toggle_compact(frame), ToolbarAction::None => {} } if ui.input_mut(|input| input.consume_shortcut(&COMPACT_SHORTCUT)) { self.toggle_compact(frame); } + self.undo_shortcut(ui); egui::CentralPanel::default().show(ui, |ui| { if self.settings.desk.compact { self.show_compact(ui); @@ -365,6 +367,8 @@ impl NeumannApp { self.show_settings(ui); self.show_group_dialog(ui); self.show_all_values(ui.ctx()); + self.show_history(ui); + self.show_take_back_question(ui); let now = ui.input(|input| input.time); self.finish_history_frame(now); } diff --git a/crates/app/src/app/history.rs b/crates/app/src/app/history.rs index b11ecfa..029fecc 100644 --- a/crates/app/src/app/history.rs +++ b/crates/app/src/app/history.rs @@ -1,15 +1,20 @@ //! The session's history on the app side: the writes of each frame become one entry, and an //! entry is taken back by writing its values before through the normal write policy. +use eframe::egui::{Key, KeyboardShortcut, Modifiers, Ui}; + use super::NeumannApp; use crate::history::{changed_since, is_open, Change, ClockTime, Entry, History}; use crate::paths; use crate::state::{SpeakerState, Status}; use crate::texts; +use crate::views::history::{self as history_view, HistoryAction, HistoryRow, QuestionAction}; use crate::writes::Intent; use neumann_control_core::catalog::Catalog; use neumann_control_core::speaker::Command; +const UNDO_SHORTCUT: KeyboardShortcut = KeyboardShortcut::new(Modifiers::COMMAND, Key::Z); + /// What the last take-back could not write, for the history window. #[derive(Clone, Debug, PartialEq)] pub struct TakeBackNotice { @@ -28,9 +33,185 @@ pub(super) struct HistoryState { /// An entry whose values changed since; the user is asked before it is taken back. pub question: Option, pub notice: Option, + pub open: bool, +} + +/// How a change reads: whose, which setting, and its unit. +struct Described { + speaker: String, + setting: String, + unit: Option, } impl NeumannApp { + pub(super) fn open_history(&mut self) { + self.history.open = true; + self.history.notice = None; + } + + /// Ctrl+Z: takes back the newest open entry. Not while a text field has the keyboard (it + /// has its own undo) or a dialog waits for an answer. + pub(super) fn undo_shortcut(&mut self, ui: &mut Ui) { + let typing = ui.ctx().memory(|memory| memory.focused().is_some()); + if typing || self.dialog_open() { + return; + } + if !ui.input_mut(|input| input.consume_shortcut(&UNDO_SHORTCUT)) { + return; + } + if let Some(id) = self.history.history.newest_open() { + self.request_take_back(id); + } + } + + fn dialog_open(&self) -> bool { + self.settings_open + || self.removal.is_some() + || self.add_address.is_some() + || self.scan_choice.is_some() + || self.group_draft.is_some() + || self.history.question.is_some() + } + + pub(super) fn show_history(&mut self, ui: &mut Ui) { + if !self.history.open { + return; + } + let rows: Vec = self + .history + .history + .entries() + .map(|entry| self.row(entry)) + .collect(); + let notice = self + .history + .notice + .as_ref() + .map(|notice| texts::take_back_skipped(¬ice.skipped)); + match history_view::show(ui, &rows, notice.as_deref()) { + HistoryAction::None => {} + HistoryAction::Close => self.history.open = false, + HistoryAction::TakeBack(id) => self.request_take_back(id), + } + } + + pub(super) fn show_take_back_question(&mut self, ui: &mut Ui) { + let Some(id) = self.history.question else { + return; + }; + let Some(entry) = self.history.history.entry(id) else { + self.history.question = None; + return; + }; + let lines = self.changed_lines(entry); + match history_view::question(ui, &lines) { + QuestionAction::None => {} + QuestionAction::Confirm => self.answer_take_back(true), + QuestionAction::Cancel => self.answer_take_back(false), + } + } + + fn row(&self, entry: &Entry) -> HistoryRow { + let lines: Vec = entry + .changes + .iter() + .map(|change| self.change_line(change)) + .collect(); + let text = match lines.as_slice() { + [line] => line.clone(), + _ => { + let first = &entry.changes[0].path; + let shared = entry.changes.iter().all(|change| change.path == *first); + let setting = shared.then(|| self.describe(&entry.changes[0]).setting); + texts::history_changes(setting.as_deref(), entry.changes.len()) + } + }; + let text = match entry + .takes_back + .and_then(|id| self.history.history.entry(id)) + { + Some(original) => texts::history_take_back(&clock_text(original.clock), &text), + None => text, + }; + let open = entry.changes.iter().any(is_open); + let all_taken_back = entry.changes.iter().all(|change| change.taken_back); + let reason = if all_taken_back { + texts::TAKEN_BACK.get() + } else { + texts::BEFORE_UNKNOWN.get() + }; + HistoryRow { + id: entry.id, + time: clock_text(entry.clock), + text, + details: if lines.len() > 1 { lines } else { Vec::new() }, + open, + reason: (!open).then(|| reason.to_owned()), + } + } + + fn change_line(&self, change: &Change) -> String { + let described = self.describe(change); + let unit = described.unit.as_deref(); + let before = change.before.as_ref().map_or_else( + || texts::BEFORE_UNKNOWN.get().to_owned(), + |before| texts::history_value(before, unit), + ); + let after = texts::history_value(&change.after, unit); + texts::history_change(&described.speaker, &described.setting, &before, &after) + } + + /// The open changes whose speakers now report another value than the written one. + fn changed_lines(&self, entry: &Entry) -> Vec { + let catalog = Catalog::embedded(); + let mut lines = Vec::new(); + for change in entry.changes.iter().filter(|change| is_open(change)) { + let reported = self.reported(change); + let step = paths::catalog_entry(catalog, &change.path).and_then(|entry| entry.step); + let Some(now) = reported.filter(|_| changed_since(change, reported, step)) else { + continue; + }; + let described = self.describe(change); + let unit = described.unit.as_deref(); + lines.push(texts::changed_now( + &described.speaker, + &described.setting, + &texts::history_value(now, unit), + &texts::history_value(&change.after, unit), + )); + } + lines + } + + fn reported(&self, change: &Change) -> Option<&serde_json::Value> { + let speaker = self + .speakers + .iter() + .find(|speaker| speaker.state.key == change.speaker); + speaker.and_then(|speaker| speaker.state.values.get(&change.path)) + } + + fn describe(&self, change: &Change) -> Described { + let speaker = self + .speakers + .iter() + .find(|speaker| speaker.state.key == change.speaker) + .map_or_else( + || texts::REMOVED_SPEAKER.get().to_owned(), + |speaker| speaker.state.display_name(), + ); + let entry = paths::catalog_entry(Catalog::embedded(), &change.path); + let language = self.settings.desk.language; + Described { + speaker, + setting: entry.map_or_else( + || change.path.to_string(), + |entry| entry.label(language).to_owned(), + ), + unit: entry.and_then(|entry| entry.unit.clone()), + } + } + pub fn history(&self) -> &History { &self.history.history } @@ -174,3 +355,7 @@ fn clock_now() -> ClockTime { second: now.second(), } } + +fn clock_text(clock: ClockTime) -> String { + texts::clock_time(clock.hour, clock.minute, clock.second) +} diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 30a71f2..278f0ba 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -10,6 +10,7 @@ use eframe::egui::{RichText, WidgetText}; use neumann_control_core::address::AddressError; use neumann_control_core::catalog::ValueError; use neumann_control_core::settings::Language; +use serde_json::Value; use crate::state::Message; use crate::writes::Refusal; @@ -138,6 +139,23 @@ pub const COPY_TO_GROUP: Text = Text::new("Für Gruppe übernehmen", "Apply to g pub const MASTER: Text = Text::new("Master", "Master"); pub const MASTER_LEVEL: Text = Text::new("Master-Pegel", "Master level"); pub const MUTE_ALL: Text = Text::new("Alle stumm", "Mute all"); +pub const HISTORY: Text = Text::new("Verlauf", "History"); +pub const HISTORY_TITLE: Text = Text::new("Verlauf dieser Sitzung", "History of this session"); +pub const NO_HISTORY: Text = Text::new( + "Noch keine Änderungen in dieser Sitzung.", + "No changes in this session yet.", +); +pub const TAKE_BACK: Text = Text::new("Zurück", "Undo"); +pub const TAKEN_BACK: Text = Text::new("Zurückgenommen", "Undone"); +pub const BEFORE_UNKNOWN: Text = Text::new( + "Der Wert vorher ist unbekannt", + "The value before is unknown", +); +pub const CHANGED_SINCE: Text = Text::new("Seitdem geändert", "Changed since"); +pub const REVERT_ANYWAY: Text = Text::new("Trotzdem zurücksetzen?", "Revert anyway?"); +pub const REVERT: Text = Text::new("Zurücksetzen", "Revert"); +pub const SWITCH_ON: Text = Text::new("an", "on"); +pub const SWITCH_OFF: Text = Text::new("aus", "off"); /// A speaker of a history entry that has been removed since. pub const REMOVED_SPEAKER: Text = Text::new("entfernter Lautsprecher", "removed speaker"); pub const CONTROL_HINT: Text = Text::new( @@ -273,9 +291,9 @@ pub fn meter_label(group: &str, channel: &str) -> String { /// The number under a meter: whole dB with a real minus sign, a dash when below the floor. pub fn meter_readout(db: Option) -> String { match db { - Some(db) if db < 0 => format!("\u{2212}{}", db.unsigned_abs()), + Some(db) if db < 0 => format!("−{}", db.unsigned_abs()), Some(db) => db.to_string(), - None => "\u{2013}".to_owned(), + None => "–".to_owned(), } } @@ -446,6 +464,68 @@ pub fn describe_rejection(code: i64) -> String { } } +/// A value in the history, with its unit. +pub fn history_value(value: &Value, unit: Option<&str>) -> String { + let text = match value { + Value::Bool(true) => SWITCH_ON.get().to_owned(), + Value::Bool(false) => SWITCH_OFF.get().to_owned(), + Value::Number(number) => number + .as_f64() + .map_or_else(|| number.to_string(), self::number), + Value::String(text) => text.clone(), + other => other.to_string(), + }; + match unit { + Some(unit) => format!("{text} {unit}"), + None => text, + } +} + +/// One change of a history entry. +pub fn history_change(speaker: &str, setting: &str, before: &str, after: &str) -> String { + format!("{speaker} · {setting}: {before} → {after}") +} + +/// An entry with several changes: the setting when all share it. +pub fn history_changes(setting: Option<&str>, count: usize) -> String { + match setting { + Some(setting) => pick( + format!("{setting}: {count} Lautsprecher"), + format!("{setting}: {count} speakers"), + ), + None => pick(format!("{count} Änderungen"), format!("{count} changes")), + } +} + +/// An entry that took back an earlier one. +pub fn history_take_back(time: &str, text: &str) -> String { + pick( + format!("Rücknahme von {time}: {text}"), + format!("Undo of {time}: {text}"), + ) +} + +pub fn clock_time(hour: u8, minute: u8, second: u8) -> String { + format!("{hour:02}:{minute:02}:{second:02}") +} + +/// A value that changed since it was written, in the question before taking it back. +pub fn changed_now(speaker: &str, setting: &str, now: &str, written: &str) -> String { + pick( + format!("{speaker} · {setting}: jetzt {now} statt {written}"), + format!("{speaker} · {setting}: now {now} instead of {written}"), + ) +} + +/// What the last take-back left out. +pub fn take_back_skipped(names: &[String]) -> String { + let names = names.join(", "); + pick( + format!("Nicht zurückgenommen: {names}"), + format!("Not undone: {names}"), + ) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/app/src/views/history.rs b/crates/app/src/views/history.rs new file mode 100644 index 0000000..b8326f6 --- /dev/null +++ b/crates/app/src/views/history.rs @@ -0,0 +1,119 @@ +//! The history window behind the toolbar's "Verlauf": the writes of this session, newest on top, +//! each with "Zurück"; and the question before taking back values that changed since. + +use eframe::egui::{Button, Id, Modal, RichText, ScrollArea, Ui}; + +use crate::texts; + +const MODAL_ID: &str = "history"; +const QUESTION_ID: &str = "history_question"; +const LIST_HEIGHT: f32 = 420.0; +const LIST_WIDTH: f32 = 520.0; + +/// One entry as the window shows it, worded by the app. +pub struct HistoryRow { + pub id: u64, + pub time: String, + pub text: String, + /// Every change of an entry with several, shown on hover. + pub details: Vec, + /// Whether "Zurück" can take something back; otherwise `reason` says why not. + pub open: bool, + pub reason: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HistoryAction { + None, + Close, + TakeBack(u64), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum QuestionAction { + None, + Confirm, + Cancel, +} + +pub fn show(ui: &mut Ui, rows: &[HistoryRow], notice: Option<&str>) -> HistoryAction { + let modal = Modal::new(Id::new(MODAL_ID)).show(ui.ctx(), |ui| contents(ui, rows, notice)); + if modal.should_close() { + return HistoryAction::Close; + } + modal.inner +} + +fn contents(ui: &mut Ui, rows: &[HistoryRow], notice: Option<&str>) -> HistoryAction { + let mut action = HistoryAction::None; + ui.heading(texts::HISTORY_TITLE); + if let Some(notice) = notice { + ui.colored_label(ui.visuals().warn_fg_color, notice); + } + if rows.is_empty() { + ui.weak(texts::NO_HISTORY); + } + ScrollArea::vertical() + .max_height(LIST_HEIGHT) + .min_scrolled_width(LIST_WIDTH) + .show(ui, |ui| { + for row in rows { + if let Some(id) = row_line(ui, row) { + action = HistoryAction::TakeBack(id); + } + } + }); + ui.separator(); + if ui.button(texts::CLOSE).clicked() { + action = HistoryAction::Close; + } + action +} + +/// Returns the entry when its "Zurück" was clicked. +fn row_line(ui: &mut Ui, row: &HistoryRow) -> Option { + ui.horizontal(|ui| { + let button = ui.add_enabled(row.open, Button::new(texts::TAKE_BACK)); + let button = match &row.reason { + Some(reason) => button.on_disabled_hover_text(reason), + None => button, + }; + ui.label(RichText::new(&row.time).monospace().weak()); + let text = if row.open { + RichText::new(&row.text) + } else { + RichText::new(&row.text).weak() + }; + let label = ui.label(text); + if !row.details.is_empty() { + label.on_hover_text(row.details.join("\n")); + } + button.clicked().then_some(row.id) + }) + .inner +} + +/// "Seitdem geändert": the changes whose values are no longer the written ones. +pub fn question(ui: &mut Ui, lines: &[String]) -> QuestionAction { + let modal = Modal::new(Id::new(QUESTION_ID)).show(ui.ctx(), |ui| { + ui.heading(texts::CHANGED_SINCE); + for line in lines { + ui.label(line); + } + ui.label(texts::REVERT_ANYWAY); + ui.horizontal(|ui| { + if ui.button(texts::REVERT).clicked() { + return QuestionAction::Confirm; + } + if ui.button(texts::CANCEL).clicked() { + return QuestionAction::Cancel; + } + QuestionAction::None + }) + .inner + }); + if modal.should_close() { + return QuestionAction::Cancel; + } + modal.inner +} diff --git a/crates/app/src/views/mod.rs b/crates/app/src/views/mod.rs index 2542ba8..d095181 100644 --- a/crates/app/src/views/mod.rs +++ b/crates/app/src/views/mod.rs @@ -6,6 +6,7 @@ mod details_fields; pub mod fader; pub mod group_dialog; pub mod group_strip; +pub mod history; mod knob; mod meters; pub mod panel; diff --git a/crates/app/src/views/toolbar.rs b/crates/app/src/views/toolbar.rs index 1899f06..a34fdb3 100644 --- a/crates/app/src/views/toolbar.rs +++ b/crates/app/src/views/toolbar.rs @@ -1,5 +1,5 @@ //! The top bar: "Suchen", "+ Adresse", "+ Gruppe", the speaker count, a notice when something -//! failed, and at the right the average response time, "Kompakt" and "Einstellungen". The +//! failed, and at the right the average response time, "Kompakt", "Verlauf" and "Einstellungen". The //! compact window is narrow, so there only the count, notice and the right side remain; adding //! speakers and groups is the desk's job. @@ -18,6 +18,7 @@ pub enum ToolbarAction { AddAddress, AddGroup, Settings, + History, ToggleCompact, } @@ -69,6 +70,9 @@ fn right_side(ui: &mut Ui, facts: &ToolbarFacts) -> ToolbarAction { if ui.button(texts::SETTINGS).clicked() { action = ToolbarAction::Settings; } + if ui.button(texts::HISTORY).clicked() { + action = ToolbarAction::History; + } let compact = Button::new(texts::COMPACT).selected(facts.compact); if ui.add(compact).on_hover_text(texts::COMPACT_HINT).clicked() { action = ToolbarAction::ToggleCompact; diff --git a/crates/app/tests/history.rs b/crates/app/tests/history.rs index 904bcde..0cee6f6 100644 --- a/crates/app/tests/history.rs +++ b/crates/app/tests/history.rs @@ -178,3 +178,117 @@ fn an_unreachable_speaker_stays_open() { assert_eq!(open.iter().filter(|open| **open).count(), 1, "{open:?}"); assert!(harness.state().take_back_notice().is_some()); } + +fn mute_label() -> String { + let path = neumann_control_app::paths::path("audio/out/mute"); + let catalog = neumann_control_core::catalog::Catalog::embedded(); + let entry = neumann_control_app::paths::catalog_entry(catalog, &path).unwrap(); + entry + .label(neumann_control_core::settings::Language::German) + .to_owned() +} + +fn mute_change(before: bool, after: bool) -> String { + texts::history_change( + device_name(SimModel::Kh120Ii), + &mute_label(), + &texts::history_value(&json!(before), None), + &texts::history_value(&json!(after), None), + ) +} + +fn click_mute( + harness: &mut Harness<'static, NeumannApp>, + simulator: &neumann_control_core::simulator::Simulator, + count: usize, +) { + harness.get_by_label(texts::MUTE.get()).click(); + wait_until(harness, "mute", |_| { + sets(simulator, "/audio/out/mute").len() == count + }); + harness.run_steps(2); +} + +#[test] +fn the_history_lists_the_newest_write_first() { + let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); + click_mute(&mut harness, &simulator, 1); + wait_until(&mut harness, "the muted state", |_| { + harness_is_muted(&simulator) + }); + click_mute(&mut harness, &simulator, 2); + harness.get_by_label(texts::HISTORY.get()).click(); + wait_for_label(&mut harness, &mute_change(false, true)); + let older = harness.get_by_label(&mute_change(false, true)).rect().top(); + let newer = harness.get_by_label(&mute_change(true, false)).rect().top(); + assert!(newer < older, "newer {newer}, older {older}"); + assert_eq!( + harness.query_all_by_label(texts::TAKE_BACK.get()).count(), + 2 + ); + harness + .query_all_by_label(texts::TAKE_BACK.get()) + .next() + .unwrap() + .click(); + wait_until(&mut harness, "the newest taken back", |_| { + sets(&simulator, "/audio/out/mute").len() == 3 + }); +} + +fn harness_is_muted(simulator: &neumann_control_core::simulator::Simulator) -> bool { + simulator.value("audio/out/mute") == Some(json!(true)) +} + +#[test] +fn the_empty_history_says_so() { + let (_simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); + harness.get_by_label(texts::HISTORY.get()).click(); + wait_for_label(&mut harness, texts::NO_HISTORY.get()); +} + +#[test] +fn ctrl_z_takes_back_the_newest_write() { + let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); + click_mute(&mut harness, &simulator, 1); + harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Z); + wait_until(&mut harness, "unmute", |_| { + sets(&simulator, "/audio/out/mute") == [json!(true), json!(false)] + }); +} + +#[test] +fn ctrl_z_in_a_text_field_does_not_take_back() { + let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); + click_mute(&mut harness, &simulator, 1); + // Past the widened double-click allowance (2 s at 0.25 s a frame), so the mute click does not + // count as the first click of the double click. + harness.run_steps(12); + let position = harness.get_by_label(&readout(100.0)).rect().center(); + double_click_at(&mut harness, position); + harness.get_by_role(Role::TextInput); + harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Z); + harness.run_steps(5); + barrier(&simulator); + assert_eq!(sets(&simulator, "/audio/out/mute").len(), 1); +} + +#[test] +fn ctrl_z_on_a_changed_value_asks_and_reverts_on_confirm() { + let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); + simulator.set_value(LEVEL_PATH, json!(90.0)); + wait_for_level(&mut harness, 90.0); + drag_fader_down(&mut harness); + wait_until(&mut harness, "the level write", |_| { + !level_sets(&simulator).is_empty() + }); + simulator.set_value(LEVEL_PATH, json!(70.0)); + wait_for_level(&mut harness, 70.0); + harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Z); + wait_for_label(&mut harness, texts::REVERT.get()); + harness.get_by_label(texts::REVERT.get()).click(); + wait_until(&mut harness, "the level written back", |_| { + level_sets(&simulator).len() == 2 + }); + assert_eq!(level_sets(&simulator)[1], 90.0); +} diff --git a/crates/app/tests/views.rs b/crates/app/tests/views.rs index 6ece558..cbf5ea3 100644 --- a/crates/app/tests/views.rs +++ b/crates/app/tests/views.rs @@ -842,6 +842,6 @@ fn each_meter_channel_shows_its_peak_readout() { simulator.set_value("m/in/level", serde_json::json!([-18.2, -40.4])); wait_for_label(&mut harness, &texts::meter_readout(Some(-18))); harness.get_by_label(&texts::meter_readout(Some(-40))); - assert_eq!(texts::meter_readout(Some(-18)), "\u{2212}18"); - assert_eq!(texts::meter_readout(None), "\u{2013}"); + assert_eq!(texts::meter_readout(Some(-18)), "−18"); + assert_eq!(texts::meter_readout(None), "–"); } From 0f7fe98d61cc0329a1e0325e3ad13da0a1214e6d Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:03:02 +0200 Subject: [PATCH 118/460] Round the ends of the peak line --- crates/app/src/views/meters.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/app/src/views/meters.rs b/crates/app/src/views/meters.rs index e113a45..da3126e 100644 --- a/crates/app/src/views/meters.rs +++ b/crates/app/src/views/meters.rs @@ -190,7 +190,9 @@ fn paint_segments(ui: &Ui, track: Rect, level: f32, peak: f32, floor: f64, token } if let Some(line) = peak_line(track, level, peak) { let color = zone_color(zone(level_at(peak, floor)), tokens); - painter.rect_filled(line, CornerRadius::ZERO, color); + // Round ends, so the line reads as a mark and not as a thin segment. + let round = CornerRadius::same((PEAK_LINE_HEIGHT / 2.0) as u8); + painter.rect_filled(line, round, color); } } From 872a70a8d79d0edd8f10b37ef43e3a1c46852bae Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:25:27 +0200 Subject: [PATCH 119/460] Allow standby time and level writes on the KH 750 A write probe on a KH 750 wrote, read back and restored both; auto standby follows the rear switch and stays read-only. --- CHANGELOG.md | 1 + README.md | 6 +++--- crates/app/src/writes.rs | 23 +++++++++++++++++++++++ crates/app/tests/groups.rs | 2 +- crates/app/tests/views.rs | 4 ++-- crates/core/src/paths.toml | 4 ++-- 6 files changed, 32 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbb9f92..0b26eb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Changed +- Standby time and standby level can be set on the KH 750 too. - Wider meters; the held peak is a thin line instead of a lit segment. ## [0.1.0] - 2026-10-18 diff --git a/README.md b/README.md index 43114cb..85174d7 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,7 @@ When the app writes to a speaker: | Model | Read | Write | |-------|------|-------| | KH 120 II | yes | yes | -| KH 750 | yes | yes, except standby (see [Known limits](#known-limits)) | +| KH 750 | yes | yes, except auto standby (see [Known limits](#known-limits)) | | Other models in the catalog (for example KH 80, KH 150) | yes | no | The catalog is [`crates/core/src/paths.toml`](crates/core/src/paths.toml). A product that is not @@ -258,8 +258,8 @@ fall back to their defaults, the rest is kept, and a copy of the file is kept as switched off ("unreachable (off or standby)"). - A speaker accepts only a few TCP connections at a time (about four). With more clients connected (vendor app, home automation), it can look unreachable as well. -- On the KH 750, auto standby, standby time and standby level are shown but not written; writing - them is untested on that model. +- On the KH 750, auto standby is shown but not written: it follows the AUTO STANDBY switch on the + back, and the speaker refuses writes to it. - Models other than the KH 120 II and the KH 750 are read-only. - Only IPv6 link-local addressing is supported; IPv4 is not. - Windows only. diff --git a/crates/app/src/writes.rs b/crates/app/src/writes.rs index a22f1b2..c09fd3b 100644 --- a/crates/app/src/writes.rs +++ b/crates/app/src/writes.rs @@ -389,6 +389,29 @@ mod tests { assert!(speaker.written.is_empty(), "{:?}", speaker.written); } + #[test] + fn the_kh_750_writes_standby_time_and_level_but_not_auto_standby() { + // Measured on a KH 750 (firmware 2_1_2): time and level take a write and read back; + // auto standby follows the rear AUTO STANDBY switch. + let speaker = speaker("KH 750"); + let catalog = Catalog::embedded(); + assert!(can_write( + &speaker, + catalog, + &paths::path(paths::STANDBY_TIME) + )); + assert!(can_write( + &speaker, + catalog, + &paths::path("device/standby/level") + )); + assert!(!can_write( + &speaker, + catalog, + &paths::path("device/standby/enabled") + )); + } + #[test] fn a_switch_without_a_reported_value_is_refused() { let mut speaker = speaker("KH 120 II"); diff --git a/crates/app/tests/groups.rs b/crates/app/tests/groups.rs index 9b07527..3b39f67 100644 --- a/crates/app/tests/groups.rs +++ b/crates/app/tests/groups.rs @@ -309,7 +309,7 @@ fn a_new_group_never_repeats_the_name_or_colour_of_another() { #[test] fn a_linked_setting_skips_a_model_that_may_not_write_it() { - // The KH 750's auto standby is read-only (decisions: its write is unmeasured). + // The KH 750's auto standby follows its rear switch and refuses writes. let (kh120, kh750, _directory, mut harness) = grouped(vec![Linkable::Standby]); open_panel(&mut harness, KH120_DEVICE_NAME); harness diff --git a/crates/app/tests/views.rs b/crates/app/tests/views.rs index cbf5ea3..b2cd6b3 100644 --- a/crates/app/tests/views.rs +++ b/crates/app/tests/views.rs @@ -429,10 +429,10 @@ fn strip_shows_leds_knobs_fader_and_buttons() { } #[test] -fn kh750_standby_knob_is_read_only() { +fn kh750_standby_knob_can_be_turned() { let (_simulator, _directory, harness) = connected_speaker(SimModel::Kh750); let standby = harness.get_by_role_and_label(Role::Slider, STANDBY_KNOB); - assert!(standby.accesskit_node().is_disabled()); + assert!(!standby.accesskit_node().is_disabled()); let delay = harness.get_by_role_and_label(Role::Slider, DELAY_KNOB); assert!(!delay.accesskit_node().is_disabled()); } diff --git a/crates/core/src/paths.toml b/crates/core/src/paths.toml index 2d51b53..100dee9 100644 --- a/crates/core/src/paths.toml +++ b/crates/core/src/paths.toml @@ -275,7 +275,7 @@ unit = "min" min = 1 max = 240 step = 1 -writable_on = ["KH 120 II"] +writable_on = ["KH 120 II", "KH 750"] [[entry]] path = "device/standby/level" @@ -289,7 +289,7 @@ unit = "dBu" min = -80 max = -55 step = 1 -writable_on = ["KH 120 II"] +writable_on = ["KH 120 II", "KH 750"] [[entry]] path = "device/standby/countdown" From 499d4582590fcf657e6878d1173b6037b184c464 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:08:48 +0200 Subject: [PATCH 120/460] Keep reading events while waiting for polling to resume One drain only allows another unread_events_limit events, and the paths that fell due during the pause take most of them; under load fewer than three were meter reads, so the test failed although the worker behaved. --- crates/core/tests/speaker.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/core/tests/speaker.rs b/crates/core/tests/speaker.rs index 597d5c7..16cd6bd 100644 --- a/crates/core/tests/speaker.rs +++ b/crates/core/tests/speaker.rs @@ -685,8 +685,14 @@ fn polling_pauses_while_nobody_reads_the_events() { "polling went on" ); - while events.try_recv().is_ok() {} - wait_for_requests(&simulator, &meter, paused_at + 3, EVENT_DEADLINE); + // Read on like the app does every frame: one drain only buys `unread_events_limit` more + // events, and the paths that fell due during the pause take most of them. + let end = Instant::now() + EVENT_DEADLINE; + while count_requests(&simulator, &meter) < paused_at + 3 { + assert!(Instant::now() < end, "polling did not resume"); + while events.try_recv().is_ok() {} + thread::sleep(REQUEST_POLL_INTERVAL); + } } #[test] From f73e4d5d91db47bd09f7c09a538e5d7ee7ea665c Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:12:16 +0200 Subject: [PATCH 121/460] Tidy the history window An unknown value before reads as "?"; only the rows in sight are worded. --- crates/app/src/app/history.rs | 20 +++++++++----------- crates/app/src/history.rs | 25 +++++++++++++++++++++++++ crates/app/src/texts.rs | 11 +++++++++++ crates/app/src/views/history.rs | 27 ++++++++++++++++++++------- 4 files changed, 65 insertions(+), 18 deletions(-) diff --git a/crates/app/src/app/history.rs b/crates/app/src/app/history.rs index 029fecc..19c5bbf 100644 --- a/crates/app/src/app/history.rs +++ b/crates/app/src/app/history.rs @@ -77,18 +77,19 @@ impl NeumannApp { if !self.history.open { return; } - let rows: Vec = self - .history - .history - .entries() - .map(|entry| self.row(entry)) - .collect(); let notice = self .history .notice .as_ref() .map(|notice| texts::take_back_skipped(¬ice.skipped)); - match history_view::show(ui, &rows, notice.as_deref()) { + let count = self.history.history.len(); + let row_at = |place| { + self.history + .history + .newest(place) + .map(|entry| self.row(entry)) + }; + match history_view::show(ui, count, &row_at, notice.as_deref()) { HistoryAction::None => {} HistoryAction::Close => self.history.open = false, HistoryAction::TakeBack(id) => self.request_take_back(id), @@ -153,10 +154,7 @@ impl NeumannApp { fn change_line(&self, change: &Change) -> String { let described = self.describe(change); let unit = described.unit.as_deref(); - let before = change.before.as_ref().map_or_else( - || texts::BEFORE_UNKNOWN.get().to_owned(), - |before| texts::history_value(before, unit), - ); + let before = texts::history_before(change.before.as_ref(), unit); let after = texts::history_value(&change.after, unit); texts::history_change(&described.speaker, &described.setting, &before, &after) } diff --git a/crates/app/src/history.rs b/crates/app/src/history.rs index b460957..f4eb4b3 100644 --- a/crates/app/src/history.rs +++ b/crates/app/src/history.rs @@ -76,6 +76,20 @@ impl History { self.entries.iter().rev() } + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// The entry at `place` in the newest-first order, without walking the others. + pub fn newest(&self, place: usize) -> Option<&Entry> { + let index = self.entries.len().checked_sub(place + 1)?; + self.entries.get(index) + } + pub fn entry(&self, id: u64) -> Option<&Entry> { self.entries.iter().find(|entry| entry.id == id) } @@ -156,6 +170,17 @@ mod tests { assert_eq!(history.entry(second.unwrap()).unwrap().takes_back, first); } + #[test] + fn entries_are_found_by_their_place_newest_first() { + let mut history = History::default(); + let first = history.push(NOON, vec![change(Some(json!(1)), json!(2))], None); + let second = history.push(NOON, vec![change(Some(json!(2)), json!(3))], None); + assert_eq!(history.len(), 2); + assert_eq!(history.newest(0).map(|entry| entry.id), second); + assert_eq!(history.newest(1).map(|entry| entry.id), first); + assert!(history.newest(2).is_none()); + } + #[test] fn the_oldest_entry_drops_out_at_the_limit() { let mut history = History::default(); diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 278f0ba..974ae55 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -481,6 +481,11 @@ pub fn history_value(value: &Value, unit: Option<&str>) -> String { } } +/// The value before in a history line; "?" when the speaker had not reported it. +pub fn history_before(before: Option<&Value>, unit: Option<&str>) -> String { + before.map_or_else(|| "?".to_owned(), |before| history_value(before, unit)) +} + /// One change of a history entry. pub fn history_change(speaker: &str, setting: &str, before: &str, after: &str) -> String { format!("{speaker} · {setting}: {before} → {after}") @@ -530,6 +535,12 @@ pub fn take_back_skipped(names: &[String]) -> String { mod tests { use super::*; + #[test] + fn an_unknown_value_before_reads_as_a_question_mark() { + assert_eq!(history_before(None, Some("dB")), "?"); + assert_eq!(history_before(Some(&Value::from(84)), Some("dB")), "84 dB"); + } + #[test] fn a_text_follows_the_language_of_its_thread() { assert_eq!(MUTE.get(), "Stumm"); diff --git a/crates/app/src/views/history.rs b/crates/app/src/views/history.rs index b8326f6..6e59036 100644 --- a/crates/app/src/views/history.rs +++ b/crates/app/src/views/history.rs @@ -36,29 +36,42 @@ pub enum QuestionAction { Cancel, } -pub fn show(ui: &mut Ui, rows: &[HistoryRow], notice: Option<&str>) -> HistoryAction { - let modal = Modal::new(Id::new(MODAL_ID)).show(ui.ctx(), |ui| contents(ui, rows, notice)); +/// `row_at` words the row at a place, newest first; only the rows in sight are asked for. +pub fn show( + ui: &mut Ui, + count: usize, + row_at: &dyn Fn(usize) -> Option, + notice: Option<&str>, +) -> HistoryAction { + let modal = + Modal::new(Id::new(MODAL_ID)).show(ui.ctx(), |ui| contents(ui, count, row_at, notice)); if modal.should_close() { return HistoryAction::Close; } modal.inner } -fn contents(ui: &mut Ui, rows: &[HistoryRow], notice: Option<&str>) -> HistoryAction { +fn contents( + ui: &mut Ui, + count: usize, + row_at: &dyn Fn(usize) -> Option, + notice: Option<&str>, +) -> HistoryAction { let mut action = HistoryAction::None; ui.heading(texts::HISTORY_TITLE); if let Some(notice) = notice { ui.colored_label(ui.visuals().warn_fg_color, notice); } - if rows.is_empty() { + if count == 0 { ui.weak(texts::NO_HISTORY); } + let row_height = ui.spacing().interact_size.y; ScrollArea::vertical() .max_height(LIST_HEIGHT) .min_scrolled_width(LIST_WIDTH) - .show(ui, |ui| { - for row in rows { - if let Some(id) = row_line(ui, row) { + .show_rows(ui, row_height, count, |ui, places| { + for row in places.filter_map(row_at) { + if let Some(id) = row_line(ui, &row) { action = HistoryAction::TakeBack(id); } } From fb1838f053dc9a8bec03e583aa5d667e2f23267b Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:16:52 +0200 Subject: [PATCH 122/460] Follow the Windows display language by default "like Windows" joins German and English; a stored choice keeps its value. --- CHANGELOG.md | 1 + Cargo.lock | 1 + README.md | 7 +++-- crates/app/Cargo.toml | 4 +++ crates/app/src/app.rs | 11 +++++-- crates/app/src/app/history.rs | 2 +- crates/app/src/lib.rs | 1 + crates/app/src/main.rs | 4 ++- crates/app/src/system_language.rs | 41 +++++++++++++++++++++++++ crates/app/src/texts.rs | 3 +- crates/app/src/views/settings_dialog.rs | 14 ++++----- crates/app/tests/support/mod.rs | 13 +++++++- crates/app/tests/views.rs | 22 ++++++++++--- crates/core/src/settings.rs | 24 +++++++++++++-- crates/core/tests/settings.rs | 25 ++++++++++++--- 15 files changed, 145 insertions(+), 28 deletions(-) create mode 100644 crates/app/src/system_language.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b26eb3..7ce9654 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Changed +- The language follows the Windows display language unless one is chosen in the settings. - Standby time and standby level can be set on the KH 750 too. - Wider meters; the held peak is a thin line instead of a lit segment. diff --git a/Cargo.lock b/Cargo.lock index 7f59977..c9d6ea8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1928,6 +1928,7 @@ dependencies = [ "simplelog", "tempfile", "time", + "windows-sys 0.61.2", "winit", "winresource", ] diff --git a/README.md b/README.md index 85174d7..15da26f 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,9 @@ panel of one KH 750 open. The interface is shown in German.* - **German and English** interface, light and dark theme. - **Portable**: one executable, settings and log next to it, no installer. -The interface starts in German; switch it to English in **Settings** → **Language** (in German: -**Einstellungen** → **Sprache**). This README uses the English names. +The interface follows the Windows display language: German on a German Windows, English on any +other. **Settings** → **Language** (in German: **Einstellungen** → **Sprache**) can fix one of +them. This README uses the English names. ## Status @@ -194,7 +195,7 @@ recorded. **Settings** sets: - the theme: like Windows, light or dark, -- the language: German or English. It changes at once. German shows a decimal comma; typed values +- the language: like Windows, German or English. It changes at once. German shows a decimal comma; typed values take a comma or a point. - the lowest level the meters show: −120, −90 or −60 dB, - whether response times are shown. diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index f5333f8..49a16af 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -25,6 +25,10 @@ time = { version = "0.3", features = ["local-offset"] } # Already in the tree through eframe; lists the monitors to place the window at the start. winit = "0.30" +[target.'cfg(windows)'.dependencies] +# Already in the tree through winit; asks Windows for its display language. +windows-sys = { version = "0.61", features = ["Win32_Globalization"] } + [build-dependencies] # Puts the icon into the executable, for Explorer and the taskbar. winresource = "0.1" diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index 6d7c538..c740459 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -12,7 +12,7 @@ use neumann_control_core::address::parse_address; use neumann_control_core::catalog::Catalog; use neumann_control_core::discovery::{merge_found, Found}; use neumann_control_core::mask::mask_host; -use neumann_control_core::settings::{self, ManualSpeaker, Settings, WindowGeometry}; +use neumann_control_core::settings::{self, Language, ManualSpeaker, Settings, WindowGeometry}; use neumann_control_core::speaker::{Command, Event, Events, SpeakerHandle, Target}; use crate::groups::{forget_member, member_id, rename_member, set_membership}; @@ -81,6 +81,8 @@ pub struct NeumannApp { settings_open: bool, window: Option, history: history::HistoryState, + /// What "like Windows" means, asked of Windows at the start. + system_language: Language, } impl NeumannApp { @@ -89,9 +91,10 @@ impl NeumannApp { context: &Context, settings_path: PathBuf, spawner: Box, + system_language: Language, ) -> NeumannApp { let settings = settings::load(&settings_path); - NeumannApp::with_settings(context, settings_path, settings, spawner) + NeumannApp::with_settings(context, settings_path, settings, spawner, system_language) } /// With settings already loaded from `settings_path`, as the start does to place the window: @@ -101,6 +104,7 @@ impl NeumannApp { settings_path: PathBuf, settings: Settings, spawner: Box, + system_language: Language, ) -> NeumannApp { fonts::install(context); theme::apply(context); @@ -123,6 +127,7 @@ impl NeumannApp { group_draft: None, settings_open: false, history: history::HistoryState::default(), + system_language, }; app.sync_targets(); app @@ -576,7 +581,7 @@ impl eframe::App for NeumannApp { } fn ui(&mut self, ui: &mut Ui, frame: &mut eframe::Frame) { - texts::set_language(self.settings.desk.language); + texts::set_language(self.settings.desk.language.resolve(self.system_language)); wheel::track_burst(ui.ctx()); self.show(ui, frame); } diff --git a/crates/app/src/app/history.rs b/crates/app/src/app/history.rs index 19c5bbf..c107dd1 100644 --- a/crates/app/src/app/history.rs +++ b/crates/app/src/app/history.rs @@ -199,7 +199,7 @@ impl NeumannApp { |speaker| speaker.state.display_name(), ); let entry = paths::catalog_entry(Catalog::embedded(), &change.path); - let language = self.settings.desk.language; + let language = texts::language(); Described { speaker, setting: entry.map_or_else( diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index a00fcf0..50b7116 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -12,6 +12,7 @@ pub mod scan_choice; pub mod spawner; pub mod state; pub mod status_leds; +pub mod system_language; pub mod targets; pub mod texts; pub mod ui_kit; diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 45c4296..eddfdad 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -11,6 +11,7 @@ use eframe::egui_wgpu::{SurfaceConfig, WgpuConfiguration, WgpuSetup}; use eframe::wgpu; use neumann_control_app::app::NeumannApp; use neumann_control_app::spawner::CoreSpawner; +use neumann_control_app::system_language; use neumann_control_app::texts; use neumann_control_app::window_placement::{first_window_size, place_window, WINDOW_MIN_SIZE}; use neumann_control_core::settings::{self, LOG_FILE, SETTINGS_FILE}; @@ -83,7 +84,8 @@ fn main() -> Result<(), Box> { } let context = creation_context.egui_ctx.clone(); let spawner = Box::new(CoreSpawner::new(context.clone())); - let app = NeumannApp::with_settings(&context, settings_path, saved, spawner); + let language = system_language::detect(); + let app = NeumannApp::with_settings(&context, settings_path, saved, spawner, language); Ok(Box::new(app)) }), )?; diff --git a/crates/app/src/system_language.rs b/crates/app/src/system_language.rs new file mode 100644 index 0000000..ea00663 --- /dev/null +++ b/crates/app/src/system_language.rs @@ -0,0 +1,41 @@ +//! The display language Windows shows its own interface in, for the language "like Windows". + +use neumann_control_core::settings::Language; + +/// The primary language part of a Windows language id (`LANG_GERMAN`), whatever the country. +const PRIMARY_LANGUAGE_MASK: u16 = 0x03ff; +const PRIMARY_GERMAN: u16 = 0x07; + +/// German for German in any country, English for every other language. +pub fn language_of(language_id: u16) -> Language { + if language_id & PRIMARY_LANGUAGE_MASK == PRIMARY_GERMAN { + return Language::German; + } + Language::English +} + +#[cfg(windows)] +pub fn detect() -> Language { + // SAFETY: takes no arguments and only reads the user's setting. + let language_id = unsafe { windows_sys::Win32::Globalization::GetUserDefaultUILanguage() }; + language_of(language_id) +} + +#[cfg(not(windows))] +pub fn detect() -> Language { + Language::English +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn german_in_any_country_is_german_and_the_rest_english() { + assert_eq!(language_of(0x0407), Language::German); + assert_eq!(language_of(0x0807), Language::German); + assert_eq!(language_of(0x0c07), Language::German); + assert_eq!(language_of(0x0409), Language::English); + assert_eq!(language_of(0x040c), Language::English); + } +} diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 974ae55..69eaaea 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -170,7 +170,8 @@ pub const COMPACT: Text = Text::new("Kompakt", "Compact"); pub const COMPACT_HINT: Text = Text::new("Strg+K", "Ctrl+K"); pub const SETTINGS_TITLE: Text = Text::new("Einstellungen", "Settings"); pub const THEME: Text = Text::new("Darstellung", "Appearance"); -pub const THEME_SYSTEM: Text = Text::new("wie Windows", "like Windows"); +/// Theme and language that follow Windows. +pub const LIKE_WINDOWS: Text = Text::new("wie Windows", "like Windows"); pub const THEME_LIGHT: Text = Text::new("Hell", "Light"); pub const THEME_DARK: Text = Text::new("Dunkel", "Dark"); pub const LANGUAGE_LABEL: Text = Text::new("Sprache", "Language"); diff --git a/crates/app/src/views/settings_dialog.rs b/crates/app/src/views/settings_dialog.rs index 9f626f8..c8bdea9 100644 --- a/crates/app/src/views/settings_dialog.rs +++ b/crates/app/src/views/settings_dialog.rs @@ -2,7 +2,7 @@ //! response times. Every change applies at once; the app saves it. use eframe::egui::{Id, Modal, Ui}; -use neumann_control_core::settings::{DeskSettings, Language, MeterFloor, ThemeChoice}; +use neumann_control_core::settings::{DeskSettings, LanguageChoice, MeterFloor, ThemeChoice}; use crate::texts; @@ -28,18 +28,16 @@ fn contents(ui: &mut Ui, desk: &mut DeskSettings) -> SettingsAction { ui.heading(texts::SETTINGS_TITLE); ui.label(texts::THEME); ui.horizontal(|ui| { - ui.radio_value(&mut desk.theme, ThemeChoice::System, texts::THEME_SYSTEM); + ui.radio_value(&mut desk.theme, ThemeChoice::System, texts::LIKE_WINDOWS); ui.radio_value(&mut desk.theme, ThemeChoice::Light, texts::THEME_LIGHT); ui.radio_value(&mut desk.theme, ThemeChoice::Dark, texts::THEME_DARK); }); ui.label(texts::LANGUAGE_LABEL); ui.horizontal(|ui| { - ui.radio_value(&mut desk.language, Language::German, texts::LANGUAGE_GERMAN); - ui.radio_value( - &mut desk.language, - Language::English, - texts::LANGUAGE_ENGLISH, - ); + let language = &mut desk.language; + ui.radio_value(language, LanguageChoice::System, texts::LIKE_WINDOWS); + ui.radio_value(language, LanguageChoice::German, texts::LANGUAGE_GERMAN); + ui.radio_value(language, LanguageChoice::English, texts::LANGUAGE_ENGLISH); }); ui.label(texts::METER_RANGE); ui.horizontal(|ui| { diff --git a/crates/app/tests/support/mod.rs b/crates/app/tests/support/mod.rs index dde7837..c769a8c 100644 --- a/crates/app/tests/support/mod.rs +++ b/crates/app/tests/support/mod.rs @@ -19,7 +19,7 @@ use neumann_control_app::texts; use neumann_control_core::catalog::Catalog; use neumann_control_core::discovery::Found; use neumann_control_core::settings::{ - self, GroupSettings, Linkable, ManualSpeaker, Settings, SETTINGS_FILE, + self, GroupSettings, Language, Linkable, ManualSpeaker, Settings, SETTINGS_FILE, }; use neumann_control_core::simulator::{SimModel, Simulator}; use neumann_control_core::speaker::{self, Events, SpeakerHandle, Target, Timing}; @@ -82,6 +82,16 @@ pub fn harness(settings_path: PathBuf, found: Vec) -> Harness<'static, Ne pub fn harness_scanning( settings_path: PathBuf, scan_result: ScanResult, +) -> Harness<'static, NeumannApp> { + // The tests read the German texts; "like Windows" must not depend on the machine. + harness_in(settings_path, scan_result, Language::German) +} + +/// The app on a Windows whose display language is `system`. +pub fn harness_in( + settings_path: PathBuf, + scan_result: ScanResult, + system: Language, ) -> Harness<'static, NeumannApp> { Harness::builder() .with_size(WINDOW_SIZE) @@ -90,6 +100,7 @@ pub fn harness_scanning( &creation_context.egui_ctx, settings_path, Box::new(SimulatorSpawner { scan_result }), + system, ) }) } diff --git a/crates/app/tests/views.rs b/crates/app/tests/views.rs index b2cd6b3..6b53f0e 100644 --- a/crates/app/tests/views.rs +++ b/crates/app/tests/views.rs @@ -13,6 +13,7 @@ use neumann_control_app::views::all_values; use neumann_control_core::address::parse_address; use neumann_control_core::catalog::Catalog; use neumann_control_core::mask::mask_serial; +use neumann_control_core::settings::{self, Language, LanguageChoice}; use neumann_control_core::simulator::{SimModel, Simulator}; use neumann_control_core::speaker::Event; use serde_json::Value; @@ -562,10 +563,7 @@ fn english_applies_at_once_and_is_saved() { .path() .join(neumann_control_core::settings::SETTINGS_FILE); let saved = neumann_control_core::settings::load(&path); - assert_eq!( - saved.desk.language, - neumann_control_core::settings::Language::English - ); + assert_eq!(saved.desk.language, LanguageChoice::English); } #[test] @@ -845,3 +843,19 @@ fn each_meter_channel_shows_its_peak_readout() { assert_eq!(texts::meter_readout(Some(-18)), "−18"); assert_eq!(texts::meter_readout(None), "–"); } + +#[test] +fn the_language_follows_windows_unless_one_is_chosen() { + let directory = tempfile::tempdir().unwrap(); + let path = settings_file(directory.path(), Vec::new()); + let mut harness = harness_in(path.clone(), Ok(Vec::new()), Language::English); + harness.run_steps(2); + harness.get_by_label("Settings"); + drop(harness); + let mut settings = settings::load(&path); + settings.desk.language = LanguageChoice::German; + settings::save(&settings, &path).unwrap(); + let mut harness = harness_in(path, Ok(Vec::new()), Language::English); + harness.run_steps(2); + harness.get_by_label("Einstellungen"); +} diff --git a/crates/core/src/settings.rs b/crates/core/src/settings.rs index aa1096a..f1d5a73 100644 --- a/crates/core/src/settings.rs +++ b/crates/core/src/settings.rs @@ -105,7 +105,7 @@ pub struct DeskSettings { #[serde(deserialize_with = "or_default")] pub show_response_times: bool, #[serde(deserialize_with = "or_default")] - pub language: Language, + pub language: LanguageChoice, #[serde(deserialize_with = "or_default")] pub compact: bool, } @@ -116,7 +116,7 @@ impl Default for DeskSettings { theme: ThemeChoice::System, meter_floor: MeterFloor::Minus120, show_response_times: true, - language: Language::German, + language: LanguageChoice::System, compact: false, } } @@ -141,6 +141,26 @@ impl Language { pub const ALL: [Language; 2] = [Language::German, Language::English]; } +/// The language the settings ask for; "like Windows" takes the system's display language. +/// The names of the fixed choices are those `Language` was stored under before. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum LanguageChoice { + #[default] + System, + German, + English, +} + +impl LanguageChoice { + pub fn resolve(self, system: Language) -> Language { + match self { + LanguageChoice::System => system, + LanguageChoice::German => Language::German, + LanguageChoice::English => Language::English, + } + } +} + /// The lowest level a meter shows; quiet room volume reads about -90 dB on the inputs. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] pub enum MeterFloor { diff --git a/crates/core/tests/settings.rs b/crates/core/tests/settings.rs index 98367f2..98da4df 100644 --- a/crates/core/tests/settings.rs +++ b/crates/core/tests/settings.rs @@ -1,5 +1,6 @@ use neumann_control_core::settings::{ - load, save, DeskSettings, GroupSettings, Language, Linkable, ManualSpeaker, MeterFloor, + load, save, DeskSettings, GroupSettings, Language, LanguageChoice, Linkable, ManualSpeaker, + MeterFloor, Settings, ThemeChoice, WindowGeometry, SETTINGS_FILE, }; use std::fs; @@ -119,7 +120,7 @@ fn old_settings_file_loads_with_desk_defaults() { assert_eq!(loaded.desk.theme, ThemeChoice::System); assert_eq!(loaded.desk.meter_floor.db(), -120.0); assert!(loaded.desk.show_response_times); - assert_eq!(loaded.desk.language, Language::German); + assert_eq!(loaded.desk.language, LanguageChoice::System); assert!(!loaded.desk.compact); assert_eq!(loaded.compact_window, None); } @@ -133,7 +134,7 @@ fn desk_settings_round_trip() { theme: ThemeChoice::Dark, meter_floor: MeterFloor::Minus60, show_response_times: false, - language: Language::English, + language: LanguageChoice::English, compact: true, }, compact_window: Some(WindowGeometry { @@ -223,7 +224,7 @@ fn a_value_this_version_does_not_know_costs_only_that_value() { fs::write(&path, text).unwrap(); let loaded = load(&path); assert_eq!(loaded.manual.len(), 1, "the readable speaker stays"); - assert_eq!(loaded.desk.language, Language::German); + assert_eq!(loaded.desk.language, LanguageChoice::System); assert_eq!(loaded.desk.meter_floor, MeterFloor::Minus120); assert!( !loaded.desk.show_response_times, @@ -265,3 +266,19 @@ fn a_file_of_the_previous_version_keeps_its_speakers_and_window() { .join(format!("{SETTINGS_FILE}.bad")) .exists()); } + +#[test] +fn a_stored_language_keeps_its_choice() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(SETTINGS_FILE); + fs::write(&path, r#"{"manual":[],"desk":{"language":"German"}}"#).unwrap(); + assert_eq!(load(&path).desk.language, LanguageChoice::German); +} + +#[test] +fn like_windows_follows_the_system_language() { + assert_eq!(LanguageChoice::System.resolve(Language::English), Language::English); + assert_eq!(LanguageChoice::System.resolve(Language::German), Language::German); + assert_eq!(LanguageChoice::German.resolve(Language::English), Language::German); + assert_eq!(LanguageChoice::English.resolve(Language::German), Language::English); +} From 822afd6d49fe5a1bb6b3fad7aac7164593a9e574 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:20:38 +0200 Subject: [PATCH 123/460] Format the settings tests --- crates/core/tests/settings.rs | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/crates/core/tests/settings.rs b/crates/core/tests/settings.rs index 98da4df..265b577 100644 --- a/crates/core/tests/settings.rs +++ b/crates/core/tests/settings.rs @@ -1,7 +1,6 @@ use neumann_control_core::settings::{ load, save, DeskSettings, GroupSettings, Language, LanguageChoice, Linkable, ManualSpeaker, - MeterFloor, - Settings, ThemeChoice, WindowGeometry, SETTINGS_FILE, + MeterFloor, Settings, ThemeChoice, WindowGeometry, SETTINGS_FILE, }; use std::fs; @@ -277,8 +276,20 @@ fn a_stored_language_keeps_its_choice() { #[test] fn like_windows_follows_the_system_language() { - assert_eq!(LanguageChoice::System.resolve(Language::English), Language::English); - assert_eq!(LanguageChoice::System.resolve(Language::German), Language::German); - assert_eq!(LanguageChoice::German.resolve(Language::English), Language::German); - assert_eq!(LanguageChoice::English.resolve(Language::German), Language::English); + assert_eq!( + LanguageChoice::System.resolve(Language::English), + Language::English + ); + assert_eq!( + LanguageChoice::System.resolve(Language::German), + Language::German + ); + assert_eq!( + LanguageChoice::German.resolve(Language::English), + Language::German + ); + assert_eq!( + LanguageChoice::English.resolve(Language::German), + Language::English + ); } From 8754122a5e63be1fad5329f43580b28b8fb89c6f Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:49:55 +0200 Subject: [PATCH 124/460] Add the EQ containers to the catalog Each container in [[eq]] becomes six band entries read by the slow poll; a band write is an array with null everywhere but the band. The simulator keeps null entries. --- crates/app/src/views/details.rs | 5 +- crates/app/tests/details.rs | 7 + crates/core/src/catalog.rs | 195 +++++++++++++++++++++++++++- crates/core/src/paths.toml | 80 +++++++++++- crates/core/src/simulator/device.rs | 6 +- crates/core/src/ssc/wire.rs | 7 + crates/core/tests/catalog.rs | 40 ++++++ crates/core/tests/simulator.rs | 23 ++++ 8 files changed, 357 insertions(+), 6 deletions(-) diff --git a/crates/app/src/views/details.rs b/crates/app/src/views/details.rs index 4c9d3f2..669f495 100644 --- a/crates/app/src/views/details.rs +++ b/crates/app/src/views/details.rs @@ -95,12 +95,13 @@ fn groups<'a>(entries: Vec<&'a Entry>, speaker: &SpeakerState) -> Groups<'a> { groups } -/// Meters sit on the strip and clip lists light their lamps, so neither gets a row here. +/// Meters sit on the strip, clip lists light their lamps and EQ bands have their own window, +/// so none of them gets a row here. fn is_shown(entry: &Entry, speaker: &SpeakerState) -> bool { let path = entry.path.to_string(); let on_strip = SHOWN_ELSEWHERE.contains(&path.as_str()); let absent = speaker.absent.contains(&entry.path); - let drawable = !matches!(entry.kind, Kind::Clip | Kind::Meter); + let drawable = !matches!(entry.kind, Kind::Clip | Kind::Meter) && !entry.bands; drawable && !on_strip && !absent } diff --git a/crates/app/tests/details.rs b/crates/app/tests/details.rs index 0536138..568b5fd 100644 --- a/crates/app/tests/details.rs +++ b/crates/app/tests/details.rs @@ -531,3 +531,10 @@ fn a_panel_drag_back_to_its_start_writes_nothing() { let sets = sets_to(&simulator, DELAY_PATH); assert!(sets.is_empty(), "{sets:?}"); } + +#[test] +fn eq_rows_are_not_in_the_device_panel() { + let (_simulator, _directory, harness) = speaker_with_panel(SimModel::Kh120Ii); + assert!(harness.query_by_label("Verstärkung").is_none()); + assert!(harness.query_by_label("Güte (Q)").is_none()); +} diff --git a/crates/core/src/catalog.rs b/crates/core/src/catalog.rs index 9141217..2db48fa 100644 --- a/crates/core/src/catalog.rs +++ b/crates/core/src/catalog.rs @@ -30,6 +30,7 @@ pub enum Group { Device, RearPanel, Metering, + Eq, } impl Group { @@ -43,6 +44,7 @@ impl Group { Group::Device => ("Gerät", "Device"), Group::RearPanel => ("Rückseite", "Rear panel"), Group::Metering => ("Messung", "Metering"), + Group::Eq => ("EQ", "EQ"), }; match language { Language::German => german, @@ -192,6 +194,10 @@ pub struct Entry { pub writable_on: Vec, #[serde(default)] pub conversion: Option, + /// An EQ leaf: an array with one entry per band, written one band at a time (`null` + /// elsewhere). Filled in from the `[[eq]]` table, never written in `[[entry]]`. + #[serde(default)] + pub bands: bool, } impl Entry { @@ -218,6 +224,35 @@ impl Entry { value: &Value, product: &str, classes: &ModelClasses, + ) -> Result<(), ValueError> { + if self.bands { + return self.validate_bands(value, product, classes); + } + self.validate_one(value, product, classes) + } + + /// At least one band given and every given band valid; `null` leaves a band as it is. + fn validate_bands( + &self, + value: &Value, + product: &str, + classes: &ModelClasses, + ) -> Result<(), ValueError> { + let bands = value.as_array().ok_or(ValueError::WrongType)?; + let given: Vec<&Value> = bands.iter().filter(|band| !band.is_null()).collect(); + if given.is_empty() { + return Err(ValueError::WrongType); + } + given + .into_iter() + .try_for_each(|band| self.validate_one(band, product, classes)) + } + + fn validate_one( + &self, + value: &Value, + product: &str, + classes: &ModelClasses, ) -> Result<(), ValueError> { match self.kind { Kind::Number => self.validate_number(value, self.max_for(product, classes)), @@ -301,12 +336,160 @@ impl std::error::Error for CatalogError {} struct CatalogFile { models: ModelClasses, entry: Vec, + #[serde(default)] + eq: Vec, } +/// An EQ container (`audio/out/eq2`, the crossover `audio/out/eq1/in1`, ...). Its six leaves +/// become catalog entries; which models have it and may write it is data like any entry. +#[derive(Debug, Clone, Deserialize)] +pub struct EqContainer { + #[serde(rename = "container")] + pub path: SscPath, + label: String, + label_en: String, + pub class: ModelClass, + #[serde(default)] + pub writable_on: Vec, +} + +impl EqContainer { + pub fn label(&self, language: Language) -> &str { + match language { + Language::German => &self.label, + Language::English => &self.label_en, + } + } + + fn leaves(&self) -> Vec { + EQ_LEAVES + .iter() + .map(|leaf| Entry { + path: self.path.child(leaf.name), + label: leaf.label.to_owned(), + label_en: leaf.label_en.to_owned(), + group: Group::Eq, + kind: leaf.kind, + poll: Poll::Slow, + class: self.class, + unit: leaf.unit.map(str::to_owned), + min: leaf.min, + max: leaf.max, + max_subwoofer: None, + step: None, + max_length: None, + options: leaf + .options + .iter() + .map(|option| (*option).to_owned()) + .collect(), + writable_on: self.writable_on.clone(), + conversion: None, + bands: true, + }) + .collect() + } +} + +/// One of the six leaves every EQ container has. +struct EqLeaf { + name: &'static str, + label: &'static str, + label_en: &'static str, + kind: Kind, + min: Option, + max: Option, + unit: Option<&'static str>, + options: &'static [&'static str], +} + +const EQ_GAIN_MIN_DB: f64 = -99.0; +const EQ_GAIN_MAX_DB: f64 = 24.0; + +/// The leaves with their limits (osc/limits, the same on both models, 2026-10-19). +const EQ_LEAVES: [EqLeaf; 6] = [ + EqLeaf { + name: "enabled", + label: "Ein", + label_en: "On", + kind: Kind::Switch, + min: None, + max: None, + unit: None, + options: &[], + }, + EqLeaf { + name: "gain", + label: "Verstärkung", + label_en: "Gain", + kind: Kind::Number, + min: Some(EQ_GAIN_MIN_DB), + max: Some(EQ_GAIN_MAX_DB), + unit: Some("dB"), + options: &[], + }, + EqLeaf { + name: "boost", + label: "Boost", + label_en: "Boost", + kind: Kind::Number, + min: Some(EQ_GAIN_MIN_DB), + max: Some(EQ_GAIN_MAX_DB), + unit: Some("dB"), + options: &[], + }, + EqLeaf { + name: "frequency", + label: "Frequenz", + label_en: "Frequency", + kind: Kind::Number, + min: Some(10.0), + max: Some(24000.0), + unit: Some("Hz"), + options: &[], + }, + EqLeaf { + name: "q", + label: "Güte (Q)", + label_en: "Q", + kind: Kind::Number, + min: Some(0.1), + max: Some(16.0), + unit: None, + options: &[], + }, + EqLeaf { + name: "type", + label: "Typ", + label_en: "Type", + kind: Kind::Select, + min: None, + max: None, + unit: None, + options: &EQ_TYPES, + }, +]; + +/// The filter types the devices list in osc/limits for `type`. +pub const EQ_TYPES: [&str; 11] = [ + "PARAMETRIC", + "LOSHELF", + "HISHELF", + "LOWPASS", + "HIGHPASS", + "BANDPASS", + "NOTCH", + "ALLPASS", + "HI6DB", + "LO6DB", + "INVERSION", +]; + #[derive(Debug)] pub struct Catalog { pub entries: Vec, pub classes: ModelClasses, + pub eq: Vec, } impl Catalog { @@ -317,8 +500,10 @@ impl Catalog { } pub fn parse(text: &str) -> Result { - let file: CatalogFile = + let mut file: CatalogFile = toml::from_str(text).map_err(|error| CatalogError::Syntax(error.to_string()))?; + let eq_leaves: Vec = file.eq.iter().flat_map(EqContainer::leaves).collect(); + file.entry.extend(eq_leaves); let mut seen = HashSet::new(); for entry in &file.entry { if !seen.insert(entry.path.clone()) { @@ -329,9 +514,17 @@ impl Catalog { Ok(Catalog { entries: file.entry, classes: file.models, + eq: file.eq, }) } + pub fn eq_containers(&self, product: &str) -> Vec<&EqContainer> { + self.eq + .iter() + .filter(|container| self.classes.contains(container.class, product)) + .collect() + } + pub fn entries_for(&self, product: &str) -> Vec<&Entry> { self.entries .iter() diff --git a/crates/core/src/paths.toml b/crates/core/src/paths.toml index 100dee9..e24f018 100644 --- a/crates/core/src/paths.toml +++ b/crates/core/src/paths.toml @@ -8,7 +8,8 @@ # strings as the device reports them) and how often it is polled (`poll`). A product that is # not listed in `writable_on` is read-only, so unmeasured models never get a write. # -# `label` is the German UI copy, `label_en` the English one. Out of scope (not listed): EQ, restore, save_settings, out5, +# `label` is the German UI copy, `label_en` the English one. EQ containers are in `[[eq]]` at the end; +# each becomes six band entries. Out of scope (not listed): restore, save_settings, out5, # mixer, mute_persist, update, auth, system, time, macs. [models] @@ -538,3 +539,80 @@ group = "metering" kind = "clip" poll = "meter" class = "subwoofer" + + +# EQ containers (measured 2026-10-19: KH 120 II eq2 10 bands, eq3 20; KH 750 eq2/eq3 10 on +# out, out1, out2, eq1/in1|in2 2 = crossover). Writes measured on `audio/out/eq2` only so far. + +[[eq]] +container = "audio/out/eq2" +label = "EQ 2" +label_en = "EQ 2" +class = "all" +writable_on = ["KH 120 II", "KH 750"] + +[[eq]] +container = "audio/out/eq3" +label = "EQ 3" +label_en = "EQ 3" +class = "all" + +[[eq]] +container = "audio/out/eq1/in1" +label = "Weiche 1" +label_en = "Crossover 1" +class = "subwoofer" + +[[eq]] +container = "audio/out/eq1/in2" +label = "Weiche 2" +label_en = "Crossover 2" +class = "subwoofer" + +[[eq]] +container = "audio/out1/eq2" +label = "EQ 2" +label_en = "EQ 2" +class = "subwoofer" + +[[eq]] +container = "audio/out1/eq3" +label = "EQ 3" +label_en = "EQ 3" +class = "subwoofer" + +[[eq]] +container = "audio/out1/eq1/in1" +label = "Weiche 1" +label_en = "Crossover 1" +class = "subwoofer" + +[[eq]] +container = "audio/out1/eq1/in2" +label = "Weiche 2" +label_en = "Crossover 2" +class = "subwoofer" + +[[eq]] +container = "audio/out2/eq2" +label = "EQ 2" +label_en = "EQ 2" +class = "subwoofer" + +[[eq]] +container = "audio/out2/eq3" +label = "EQ 3" +label_en = "EQ 3" +class = "subwoofer" + +[[eq]] +container = "audio/out2/eq1/in1" +label = "Weiche 1" +label_en = "Crossover 1" +class = "subwoofer" + +[[eq]] +container = "audio/out2/eq1/in2" +label = "Weiche 2" +label_en = "Crossover 2" +class = "subwoofer" diff --git a/crates/core/src/simulator/device.rs b/crates/core/src/simulator/device.rs index 30266c6..eda2aac 100644 --- a/crates/core/src/simulator/device.rs +++ b/crates/core/src/simulator/device.rs @@ -530,15 +530,17 @@ fn same_kind(current: &Value, value: &Value) -> bool { && held .iter() .zip(given) - .all(|(left, right)| same_kind(left, right)) + .all(|(left, right)| right.is_null() || same_kind(left, right)) } _ => false, } } -/// A float leaf stays a float: `90` is stored and echoed as `90.0`, as the devices do. +/// A float leaf stays a float: `90` is stored and echoed as `90.0`, as the devices do. A `null` +/// in an array keeps that entry (measured on EQ arrays: only the given band changes). fn resulting_value(current: &Value, value: Value) -> Value { match (current, &value) { + (_, Value::Null) => current.clone(), (Value::Number(held), Value::Number(given)) if held.is_f64() => given .as_f64() .and_then(Number::from_f64) diff --git a/crates/core/src/ssc/wire.rs b/crates/core/src/ssc/wire.rs index e0b09ac..53fece0 100644 --- a/crates/core/src/ssc/wire.rs +++ b/crates/core/src/ssc/wire.rs @@ -50,6 +50,13 @@ impl SscPath { pub fn segments(&self) -> &[String] { &self.0 } + + /// The path one level deeper; `segment` is a leaf name of the catalog, never empty. + pub fn child(&self, segment: &str) -> SscPath { + let mut segments = self.0.clone(); + segments.push(segment.to_owned()); + SscPath(segments) + } } impl fmt::Display for SscPath { diff --git a/crates/core/tests/catalog.rs b/crates/core/tests/catalog.rs index a2b2aad..e6dfa15 100644 --- a/crates/core/tests/catalog.rs +++ b/crates/core/tests/catalog.rs @@ -317,3 +317,43 @@ fn every_entry_has_a_label_in_each_language() { } } } + +#[test] +fn eq_containers_per_model() { + let catalog = Catalog::embedded(); + assert_eq!(catalog.eq_containers(MONITOR).len(), 2); + assert_eq!(catalog.eq_containers(SUBWOOFER).len(), 12); + let first = &catalog.eq_containers(MONITOR)[0]; + assert_eq!(first.path, path("audio/out/eq2")); + assert_eq!(first.label(Language::German), "EQ 2"); + assert!(has(MONITOR, "audio/out/eq3/frequency")); + assert!(!has(MONITOR, "audio/out1/eq2/gain")); + assert!(entry("audio/out/eq2/gain").is_writable(SUBWOOFER)); + assert!(!entry("audio/out/eq3/gain").is_writable(MONITOR)); + assert!(entry("audio/out/eq2/gain").bands); +} + +#[test] +fn a_band_write_needs_one_valid_entry() { + let catalog = Catalog::embedded(); + let check = |text: &str, value| entry(text).validate(&value, MONITOR, &catalog.classes); + assert_eq!(check("audio/out/eq2/gain", json!([null, 3.0])), Ok(())); + assert_eq!( + check("audio/out/eq2/gain", json!([null, null])), + Err(ValueError::WrongType) + ); + assert_eq!( + check("audio/out/eq2/gain", json!([null, 30.0])), + Err(ValueError::OutOfRange { + min: -99.0, + max: 24.0 + }) + ); + assert!(check("audio/out/eq2/type", json!([null, "FOO"])).is_err()); + assert_eq!(check("audio/out/eq2/type", json!(["NOTCH", null])), Ok(())); + assert_eq!(check("audio/out/eq2/enabled", json!([true])), Ok(())); + assert_eq!( + check("audio/out/eq2/gain", json!(3.0)), + Err(ValueError::WrongType) + ); +} diff --git a/crates/core/tests/simulator.rs b/crates/core/tests/simulator.rs index 77ac050..c8394a9 100644 --- a/crates/core/tests/simulator.rs +++ b/crates/core/tests/simulator.rs @@ -456,3 +456,26 @@ fn request_split_across_reads_is_reassembled() { .unwrap(); assert_eq!(client.read_tree()["audio"]["out"]["mute"], json!(false)); } + +#[test] +fn a_null_entry_keeps_the_band() { + let simulator = start_kh120(); + let mut client = Client::connect(&simulator); + let mut gain = vec![Value::Null; 10]; + gain[9] = json!(3.0); + let reply = client.ask(&json!({"audio":{"out":{"eq2":{"gain": gain}}}})); + assert!(device_error(&reply).is_none(), "{reply}"); + let mut expected = vec![json!(0.0); 10]; + expected[9] = json!(3.0); + assert_eq!( + simulator.value("audio/out/eq2/gain"), + Some(Value::Array(expected)) + ); +} + +#[test] +fn a_short_array_is_not_acceptable() { + let simulator = start_kh120(); + let reply = Client::connect(&simulator).ask(&json!({"audio":{"out":{"eq2":{"gain":[2.0]}}}})); + assert_eq!(device_error(&reply).unwrap().code, 406); +} From 7e5cc407a3915809db9d49f84cbdf0be086570cc Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:51:14 +0200 Subject: [PATCH 125/460] Model EQ bands and their history A band write keeps the other bands: its value before and the pending value are the reported array with only the written places. --- crates/app/src/eq.rs | 185 ++++++++++++++++++++++++++++++++++++++ crates/app/src/history.rs | 79 ++++++++++++++++ crates/app/src/lib.rs | 1 + crates/app/src/state.rs | 10 ++- crates/app/src/writes.rs | 40 +++++++++ 5 files changed, 311 insertions(+), 4 deletions(-) create mode 100644 crates/app/src/eq.rs diff --git a/crates/app/src/eq.rs b/crates/app/src/eq.rs new file mode 100644 index 0000000..ecc3e15 --- /dev/null +++ b/crates/app/src/eq.rs @@ -0,0 +1,185 @@ +//! EQ bands as the speaker reports them: six arrays per container, one entry per band. A write +//! changes one leaf of one band and leaves the others alone (`null` elsewhere). + +use std::collections::BTreeMap; + +use neumann_control_core::ssc::wire::SscPath; +use serde_json::Value; + +/// The six leaves of an EQ container, as the catalog names them. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Leaf { + Enabled, + Gain, + Boost, + Frequency, + Q, + Type, +} + +impl Leaf { + pub const ALL: [Leaf; 6] = [ + Leaf::Enabled, + Leaf::Gain, + Leaf::Boost, + Leaf::Frequency, + Leaf::Q, + Leaf::Type, + ]; + + pub fn name(self) -> &'static str { + match self { + Leaf::Enabled => "enabled", + Leaf::Gain => "gain", + Leaf::Boost => "boost", + Leaf::Frequency => "frequency", + Leaf::Q => "q", + Leaf::Type => "type", + } + } + + pub fn path(self, container: &SscPath) -> SscPath { + container.child(self.name()) + } +} + +/// One band of a container. +#[derive(Clone, Debug, PartialEq)] +pub struct Band { + pub enabled: bool, + pub gain: f64, + pub boost: f64, + pub frequency: f64, + pub q: f64, + pub kind: String, +} + +impl Band { + /// Measured on the KH 750: boost acts like gain and adds to it. + pub fn effective_gain(&self) -> f64 { + self.gain + self.boost + } +} + +/// The bands of `container`; `None` unless all six leaves are reported as arrays of one length +/// with the right element types, so a strange report is shown read-only and never written. +pub fn bands_of(values: &BTreeMap, container: &SscPath) -> Option> { + let array = |leaf: Leaf| values.get(&leaf.path(container))?.as_array(); + let numbers = + |leaf: Leaf| -> Option> { array(leaf)?.iter().map(Value::as_f64).collect() }; + let enabled: Vec = array(Leaf::Enabled)? + .iter() + .map(Value::as_bool) + .collect::>()?; + let kinds: Vec = array(Leaf::Type)? + .iter() + .map(|kind| kind.as_str().map(str::to_owned)) + .collect::>()?; + let (gain, boost) = (numbers(Leaf::Gain)?, numbers(Leaf::Boost)?); + let (frequency, q) = (numbers(Leaf::Frequency)?, numbers(Leaf::Q)?); + let count = enabled.len(); + let lengths = [ + kinds.len(), + gain.len(), + boost.len(), + frequency.len(), + q.len(), + ]; + if lengths.iter().any(|length| *length != count) { + return None; + } + let bands = (0..count) + .map(|band| Band { + enabled: enabled[band], + gain: gain[band], + boost: boost[band], + frequency: frequency[band], + q: q[band], + kind: kinds[band].clone(), + }) + .collect(); + Some(bands) +} + +/// The value that writes `value` to `band` only (measured: `null` leaves a band as it is). +pub fn one_band(length: usize, band: usize, value: Value) -> Value { + let mut array = vec![Value::Null; length]; + array[band] = value; + Value::Array(array) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + use crate::paths; + + fn container() -> SscPath { + paths::path("audio/out/eq2") + } + + fn values(entries: &[(&str, Value)]) -> BTreeMap { + entries + .iter() + .map(|(leaf, value)| (container().child(leaf), value.clone())) + .collect() + } + + fn two_bands() -> BTreeMap { + values(&[ + ("enabled", json!([true, false])), + ("gain", json!([3.0, 0.0])), + ("boost", json!([1.0, 0.0])), + ("frequency", json!([100.0, 1000.0])), + ("q", json!([0.7, 1.0])), + ("type", json!(["PARAMETRIC", "NOTCH"])), + ]) + } + + #[test] + fn bands_of_reads_six_arrays() { + let bands = bands_of(&two_bands(), &container()).unwrap(); + assert_eq!(bands.len(), 2); + assert_eq!( + bands[0], + Band { + enabled: true, + gain: 3.0, + boost: 1.0, + frequency: 100.0, + q: 0.7, + kind: "PARAMETRIC".to_owned(), + } + ); + assert_eq!(bands[1].kind, "NOTCH"); + assert_eq!(bands[0].effective_gain(), 4.0); + } + + #[test] + fn bands_of_refuses_mixed_lengths_and_wrong_types() { + let mut values = two_bands(); + values.insert(container().child("q"), json!([0.7])); + assert_eq!(bands_of(&values, &container()), None); + let mut values = two_bands(); + values.insert(container().child("gain"), json!([3.0, "x"])); + assert_eq!(bands_of(&values, &container()), None); + let mut values = two_bands(); + values.remove(&container().child("type")); + assert_eq!(bands_of(&values, &container()), None); + } + + #[test] + fn one_band_is_null_elsewhere() { + assert_eq!(one_band(3, 1, json!(2.5)), json!([null, 2.5, null])); + } + + #[test] + fn a_leaf_names_its_path() { + assert_eq!( + Leaf::Gain.path(&container()), + paths::path("audio/out/eq2/gain") + ); + assert_eq!(Leaf::ALL.len(), 6); + } +} diff --git a/crates/app/src/history.rs b/crates/app/src/history.rs index f4eb4b3..f588d32 100644 --- a/crates/app/src/history.rs +++ b/crates/app/src/history.rs @@ -121,7 +121,14 @@ pub fn is_open(change: &Change) -> bool { /// Whether a reported value still is the written one: numbers within half a catalog step (the /// speaker may report 84 for a written 84.0, or round to its own grid), anything else exactly. +/// A written array counts only where it gave a value: its `null` places were not written. pub fn same_value(reported: &Value, written: &Value, step: Option) -> bool { + if let (Some(reported), Some(written)) = (reported.as_array(), written.as_array()) { + return reported.len() == written.len() + && reported.iter().zip(written).all(|(reported, written)| { + written.is_null() || same_value(reported, written, step) + }); + } let (Some(reported_number), Some(written_number)) = (reported.as_f64(), written.as_f64()) else { return reported == written; @@ -130,6 +137,50 @@ pub fn same_value(reported: &Value, written: &Value, step: Option) -> bool (reported_number - written_number).abs() < tolerance || reported_number == written_number } +/// What the speaker holds after `written` arrived: an array write with `null` places keeps the +/// entries of `base` there. +pub fn merged(base: Option<&Value>, written: &Value) -> Value { + let (Some(base), Some(given)) = (base.and_then(Value::as_array), written.as_array()) else { + return written.clone(); + }; + if base.len() != given.len() { + return written.clone(); + } + let entries = base.iter().zip(given).map(|(held, given)| { + if given.is_null() { + held.clone() + } else { + given.clone() + } + }); + Value::Array(entries.collect()) +} + +/// The part of `before` that `after` overwrites, in the same `null` form, so taking a band +/// write back touches that band only. +pub fn matching(before: &Value, after: &Value) -> Value { + let (Some(held), Some(given)) = (before.as_array(), after.as_array()) else { + return before.clone(); + }; + let entries = held.iter().zip(given).map(|(held, given)| { + if given.is_null() { + Value::Null + } else { + held.clone() + } + }); + Value::Array(entries.collect()) +} + +/// Whether a value says anything: not `null`, and an array with at least one entry given. +pub fn is_given(value: &Value) -> bool { + match value { + Value::Null => false, + Value::Array(entries) => entries.iter().any(|entry| !entry.is_null()), + _ => true, + } +} + #[cfg(test)] mod tests { use super::*; @@ -213,6 +264,34 @@ mod tests { assert!(!changed_since(&written, None, Some(0.5))); } + #[test] + fn merged_keeps_other_bands() { + let base = json!([1.0, 2.0, 3.0]); + assert_eq!( + merged(Some(&base), &json!([null, 5.0, null])), + json!([1.0, 5.0, 3.0]) + ); + assert_eq!(merged(None, &json!([null, 5.0])), json!([null, 5.0])); + assert_eq!(merged(Some(&json!(1.0)), &json!(2.0)), json!(2.0)); + } + + #[test] + fn matching_takes_the_written_places() { + let before = json!([1.0, 2.0, 3.0]); + assert_eq!( + matching(&before, &json!([null, 5.0, null])), + json!([null, 2.0, null]) + ); + assert_eq!(matching(&json!(80.0), &json!(84.0)), json!(80.0)); + } + + #[test] + fn same_value_ignores_null_places() { + let reported = json!([9.0, 5.0, 7.0]); + assert!(same_value(&reported, &json!([null, 5.0, null]), None)); + assert!(!same_value(&reported, &json!([null, 6.0, null]), None)); + } + #[test] fn numbers_within_half_a_step_are_the_same() { assert!(same_value(&json!(84), &json!(84.0), Some(0.5))); diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index 50b7116..9004b1c 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -1,6 +1,7 @@ pub mod all_values; pub mod app; pub mod display; +pub mod eq; pub mod fader; pub mod gesture; pub mod groups; diff --git a/crates/app/src/state.rs b/crates/app/src/state.rs index fd1629a..f583e40 100644 --- a/crates/app/src/state.rs +++ b/crates/app/src/state.rs @@ -14,6 +14,7 @@ use serde_json::Value; use crate::display::capped; use crate::gesture::GestureState; +use crate::history::{is_given, matching, merged}; use crate::meter::{MeterState, METER_FLOOR_DB}; use crate::paths; use crate::writes::Refusal; @@ -167,10 +168,11 @@ impl SpeakerState { if *path == paths::path(paths::IDENTIFY) { return; } - let before = self.pending.get(path).or_else(|| self.values.get(path)); - let change = (path.clone(), before.cloned(), value.clone()); - self.pending.insert(path.clone(), value.clone()); - self.written.push(change); + let base = self.pending.get(path).or_else(|| self.values.get(path)); + let before = base.map(|base| matching(base, value)).filter(is_given); + let pending = merged(base, value); + self.written.push((path.clone(), before, value.clone())); + self.pending.insert(path.clone(), pending); } pub fn apply(&mut self, event: Event, now: f64) { diff --git a/crates/app/src/writes.rs b/crates/app/src/writes.rs index c09fd3b..b9dab69 100644 --- a/crates/app/src/writes.rs +++ b/crates/app/src/writes.rs @@ -412,6 +412,46 @@ mod tests { )); } + #[test] + fn a_second_band_write_keeps_the_first_pending() { + let mut speaker = speaker("KH 120 II"); + let path = paths::path("audio/out/eq2/gain"); + speaker.apply( + Event::Value { + path: path.clone(), + value: serde_json::json!([0.0, 0.0, 0.0]), + }, + 0.0, + ); + let write = |value| Intent::Write { + path: path.clone(), + value, + }; + let catalog = Catalog::embedded(); + assert!(speaker + .request(write(serde_json::json!([3.0, null, null])), catalog, 0.0) + .is_some()); + assert!(speaker + .request(write(serde_json::json!([null, 4.0, null])), catalog, 0.1) + .is_some()); + assert!(speaker + .request(write(serde_json::json!([5.0, null, null])), catalog, 0.2) + .is_some()); + let befores: Vec> = speaker + .written + .iter() + .map(|change| change.1.clone()) + .collect(); + assert_eq!( + befores, + vec![ + Some(serde_json::json!([0.0, null, null])), + Some(serde_json::json!([null, 0.0, null])), + Some(serde_json::json!([3.0, null, null])), + ] + ); + } + #[test] fn a_switch_without_a_reported_value_is_refused() { let mut speaker = speaker("KH 120 II"); From 8196bb17fa9d53da543497d4d222b65b510e5ef9 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:53:37 +0200 Subject: [PATCH 126/460] Compute EQ response curves --- crates/app/src/eq_response.rs | 194 ++++++++++++++++++++++++++++++++++ crates/app/src/lib.rs | 1 + 2 files changed, 195 insertions(+) create mode 100644 crates/app/src/eq_response.rs diff --git a/crates/app/src/eq_response.rs b/crates/app/src/eq_response.rs new file mode 100644 index 0000000..ad5c35b --- /dev/null +++ b/crates/app/src/eq_response.rs @@ -0,0 +1,194 @@ +//! The magnitude response of EQ bands, computed from their settings (not measured): analog +//! prototypes of the usual filter shapes, so no sample rate has to be assumed. + +use crate::eq::Band; + +/// The graph's frequency axis: the range the devices accept for `frequency`. +pub const FREQUENCY_MIN: f64 = 10.0; +pub const FREQUENCY_MAX: f64 = 24000.0; +pub const GRAPH_POINTS: usize = 400; + +/// `a2·s² + a1·s + a0` at `s = j·x`, where `x` is the frequency over the band's frequency. +#[derive(Clone, Copy)] +struct Quadratic { + a2: f64, + a1: f64, + a0: f64, +} + +impl Quadratic { + const fn new(a2: f64, a1: f64, a0: f64) -> Quadratic { + Quadratic { a2, a1, a0 } + } + + fn magnitude(self, x: f64) -> f64 { + let real = self.a0 - self.a2 * x * x; + let imaginary = self.a1 * x; + real.hypot(imaginary) + } +} + +/// The band's level change at `frequency`, in dB. +pub fn band_db(band: &Band, frequency: f64) -> f64 { + if !band.enabled { + return 0.0; + } + let x = frequency / band.frequency; + let Some((scale, numerator, denominator)) = transfer(band) else { + return 0.0; + }; + let magnitude = scale * numerator.magnitude(x) / denominator.magnitude(x); + 20.0 * magnitude.log10() +} + +/// Numerator and denominator of the shape (RBJ audio EQ cookbook, analog form), with a factor. +fn transfer(band: &Band) -> Option<(f64, Quadratic, Quadratic)> { + let q = band.q; + let a = 10f64.powf(band.effective_gain() / 40.0); + let resonant = Quadratic::new(1.0, 1.0 / q, 1.0); + let first_order = Quadratic::new(0.0, 1.0, 1.0); + let shelf_slope = a.sqrt() / q; + let shape = match band.kind.as_str() { + "PARAMETRIC" => ( + 1.0, + Quadratic::new(1.0, a / q, 1.0), + Quadratic::new(1.0, 1.0 / (a * q), 1.0), + ), + "LOSHELF" => ( + a, + Quadratic::new(1.0, shelf_slope, a), + Quadratic::new(a, shelf_slope, 1.0), + ), + "HISHELF" => ( + a, + Quadratic::new(a, shelf_slope, 1.0), + Quadratic::new(1.0, shelf_slope, a), + ), + "LOWPASS" => (1.0, Quadratic::new(0.0, 0.0, 1.0), resonant), + "HIGHPASS" => (1.0, Quadratic::new(1.0, 0.0, 0.0), resonant), + "BANDPASS" => (1.0, Quadratic::new(0.0, 1.0 / q, 0.0), resonant), + "NOTCH" => (1.0, Quadratic::new(1.0, 0.0, 1.0), resonant), + "LO6DB" => (1.0, Quadratic::new(0.0, 0.0, 1.0), first_order), + "HI6DB" => (1.0, Quadratic::new(0.0, 1.0, 0.0), first_order), + // ALLPASS and INVERSION change the phase only; an unknown type is drawn flat. + _ => return None, + }; + Some(shape) +} + +/// The sum of the bands at each frequency, in dB. +pub fn curve_db(bands: &[Band], frequencies: &[f64]) -> Vec { + frequencies + .iter() + .map(|frequency| bands.iter().map(|band| band_db(band, *frequency)).sum()) + .collect() +} + +/// `GRAPH_POINTS` frequencies spaced evenly on a log scale from the lowest to the highest. +pub fn graph_frequencies() -> Vec { + let (low, high) = (FREQUENCY_MIN.ln(), FREQUENCY_MAX.ln()); + let last = (GRAPH_POINTS - 1) as f64; + (0..GRAPH_POINTS) + .map(|point| (low + (high - low) * point as f64 / last).exp()) + .collect() +} + +#[cfg(test)] +mod tests { + use std::f64::consts::FRAC_1_SQRT_2; + + use super::*; + + fn band(kind: &str, gain: f64, frequency: f64, q: f64) -> Band { + Band { + enabled: true, + gain, + boost: 0.0, + frequency, + q, + kind: kind.to_owned(), + } + } + + fn close(actual: f64, expected: f64) { + assert!((actual - expected).abs() < 0.05, "{actual} vs {expected}"); + } + + #[test] + fn peaking_reaches_its_gain_at_its_frequency() { + close(band_db(&band("PARAMETRIC", 6.0, 1000.0, 1.0), 1000.0), 6.0); + close( + band_db(&band("PARAMETRIC", -9.0, 1000.0, 4.0), 1000.0), + -9.0, + ); + } + + #[test] + fn peaking_is_flat_far_away() { + assert!(band_db(&band("PARAMETRIC", 6.0, 1000.0, 1.0), 10.0).abs() < 0.1); + } + + #[test] + fn shelves_reach_their_gain_at_their_end() { + close(band_db(&band("LOSHELF", 6.0, 200.0, 0.7), 10.0), 6.0); + close(band_db(&band("HISHELF", -4.0, 2000.0, 0.7), 24000.0), -4.0); + } + + #[test] + fn low_pass_is_minus_3_db_at_its_frequency_with_q_0_707() { + close( + band_db(&band("LOWPASS", 0.0, 80.0, FRAC_1_SQRT_2), 80.0), + -3.01, + ); + close( + band_db(&band("HIGHPASS", 0.0, 80.0, FRAC_1_SQRT_2), 80.0), + -3.01, + ); + } + + #[test] + fn first_order_filters_are_minus_3_db_at_their_frequency() { + close(band_db(&band("LO6DB", 0.0, 500.0, 1.0), 500.0), -3.01); + close(band_db(&band("HI6DB", 0.0, 500.0, 1.0), 500.0), -3.01); + } + + #[test] + fn notch_and_band_pass() { + assert!(band_db(&band("NOTCH", 0.0, 1000.0, 2.0), 1000.0) < -60.0); + close(band_db(&band("BANDPASS", 0.0, 1000.0, 2.0), 1000.0), 0.0); + } + + #[test] + fn phase_only_and_unknown_types_are_flat() { + for kind in ["ALLPASS", "INVERSION", "SOMETHING"] { + close(band_db(&band(kind, 6.0, 1000.0, 1.0), 1000.0), 0.0); + } + } + + #[test] + fn boost_adds_to_gain() { + let mut boosted = band("PARAMETRIC", 3.0, 1000.0, 1.0); + boosted.boost = 3.0; + close(band_db(&boosted, 1000.0), 6.0); + } + + #[test] + fn a_disabled_band_is_flat() { + let mut off = band("PARAMETRIC", 6.0, 1000.0, 1.0); + off.enabled = false; + assert_eq!(band_db(&off, 1000.0), 0.0); + } + + #[test] + fn a_curve_is_the_sum_of_its_bands() { + let bands = [ + band("PARAMETRIC", 6.0, 1000.0, 1.0), + band("PARAMETRIC", 3.0, 1000.0, 1.0), + ]; + close(curve_db(&bands, &[1000.0])[0], 9.0); + let frequencies = graph_frequencies(); + assert_eq!(frequencies.len(), GRAPH_POINTS); + close(frequencies[0], FREQUENCY_MIN); + close(*frequencies.last().unwrap(), FREQUENCY_MAX); + } +} diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index 9004b1c..4f19a90 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -2,6 +2,7 @@ pub mod all_values; pub mod app; pub mod display; pub mod eq; +pub mod eq_response; pub mod fader; pub mod gesture; pub mod groups; From 2495900985510681a556298e2108aff7ceb6b2fa Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:56:10 +0200 Subject: [PATCH 127/460] Move the number field gesture into its own module The EQ table is its second user. --- crates/app/src/views/details_fields.rs | 101 +++++----------------- crates/app/src/views/mod.rs | 1 + crates/app/src/views/number_field.rs | 111 +++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 81 deletions(-) create mode 100644 crates/app/src/views/number_field.rs diff --git a/crates/app/src/views/details_fields.rs b/crates/app/src/views/details_fields.rs index 08361d1..b34deaa 100644 --- a/crates/app/src/views/details_fields.rs +++ b/crates/app/src/views/details_fields.rs @@ -1,33 +1,22 @@ //! The device panel's controls, one per writable kind: number, switch, selection and text. //! Every write goes through `writes`. -use eframe::egui::{Button, Checkbox, ComboBox, DragValue, Event, Key, TextEdit, Ui}; +use eframe::egui::{Button, Checkbox, ComboBox, Key, TextEdit, Ui}; use neumann_control_core::catalog::{Catalog, Entry}; use neumann_control_core::speaker::Command; use serde_json::Value; -use crate::display::{capped, converted_unit}; -use crate::gesture::{bounded_move, on_grid, released, typed_value}; +use super::number_field::{self, NumberField}; +use crate::display::capped; use crate::state::SpeakerState; use crate::texts; use crate::writes::{self, Intent}; /// Drag speed for numbers without a step in the catalog, in units per point. const UNSTEPPED_DRAG_SPEED: f64 = 0.1; -const PENDING_NUMBER_ID: &str = "pending_number"; const TEXT_DRAFT_ID: &str = "text_draft"; const TEXT_FIELD_ID: &str = "text_field"; -fn is_text_edit(event: &Event) -> bool { - match event { - Event::Text(_) | Event::Paste(_) | Event::Cut => true, - Event::Key { - key, pressed: true, .. - } => matches!(key, Key::Backspace | Key::Delete), - _ => false, - } -} - /// What the user typed into a text field and has not yet written. #[derive(Clone, Debug, Default)] struct TextDraft { @@ -53,92 +42,42 @@ impl Fields<'_> { known && writes::can_write(self.speaker, self.catalog, &entry.path) } - /// A drag or a typed value is one gesture. Its value is held while the gesture is active - /// and written once, on the frame it ends (release, Enter or focus loss); Escape drops it. - /// The device value itself is never clamped or written back. The field shows the value - /// unrounded: egui parses the shown text when focus leaves and would otherwise report the - /// rounded text as an edit. + /// One gesture per write, see `number_field`; a typed value outside the range is sent as + /// typed, for the write policy to refuse with the reason. pub fn number(&mut self, ui: &mut Ui, entry: &Entry) { let reported = self.speaker.values.get(&entry.path).cloned(); let device = reported.as_ref().and_then(Value::as_f64); - let enabled = self.enabled(entry, device.is_some()); let product = self.speaker.product(); let min = entry.min.unwrap_or(f64::NEG_INFINITY); let max = entry .max_for(product, &self.catalog.classes) .unwrap_or(f64::INFINITY); - let pending_id = ui.id().with(entry.path.to_string()).with(PENDING_NUMBER_ID); - let held = ui.data(|data| data.get_temp::(pending_id)); - let mut value = held.or(device).unwrap_or(entry.min.unwrap_or_default()); - let step = entry.step.unwrap_or(UNSTEPPED_DRAG_SPEED); - // No range on the field: egui would clamp typed text to it, and a typo would reach the - // speaker as the range's end. A drag is kept inside below; a typed value goes to the write - // policy as typed and is refused with the reason when outside. - let mut drag = DragValue::new(&mut value) - .custom_formatter(|number, _| texts::number(number)) - .custom_parser(|text| typed_value(text, None)) - .speed(step); - if let Some(unit) = &entry.unit { - drag = drag.suffix(format!(" {unit}")); - } - if let Some(conversion) = entry.conversion { - // Typed and shown in the converted unit; back on the device's step grid, so the - // shown text parses to exactly the device value and a visit writes nothing. - drag = drag - .custom_formatter(move |number, _| texts::number(conversion.apply(number))) - .custom_parser(move |text| { - typed_value(text, Some(conversion)).map(|typed| on_grid(typed, step)) - }) - .suffix(format!(" {}", converted_unit(conversion))); - } - let response = ui + let written = ui .horizontal_wrapped(|ui| { let label = ui.label(entry.label(texts::language())); - ui.add_enabled(enabled, drag).labelled_by(label.id) + let field = NumberField { + id: ui.id().with(entry.path.to_string()), + device, + range: min..=max, + step: entry.step, + drag_speed: entry.step.unwrap_or(UNSTEPPED_DRAG_SPEED), + unit: entry.unit.as_deref(), + conversion: entry.conversion, + enabled: self.enabled(entry, device.is_some()), + labelled_by: Some(label.id), + }; + number_field::show(ui, &field) }) .inner; - if response.dragged() { - let start = device.unwrap_or(value); - value = bounded_move(start, value, &(min..=max)).unwrap_or(start); - } - let active = response.dragged() || response.has_focus(); - let ended = response.drag_stopped() || response.lost_focus(); - if !active && !ended { - ui.data_mut(|data| data.remove::(pending_id)); - return; - } - // egui also reports a change on the frame focus leaves, when it reparses and clamps - // the shown text; that one is the user's only if the same frame carried their typing. - let typed_this_frame = ui.input(|input| input.events.iter().any(is_text_edit)); - let changed_by_user = response.changed() && (active || typed_this_frame); - if changed_by_user { - ui.data_mut(|data| data.insert_temp(pending_id, value)); - } - if active { + let Some(number) = written else { return; - } - let Some(number) = changed_by_user.then_some(value).or(held) else { - return; - }; - ui.data_mut(|data| data.remove::(pending_id)); - if ui.input(|input| input.key_pressed(Key::Escape)) { - return; - } - // A drag or an edit that ends where it began writes nothing. - let unchanged = match (entry.step, device) { - (Some(step), Some(device)) => released(device, number, step).is_none(), - _ => Some(number) == device, }; - if unchanged { - return; - } - let path = entry.path.clone(); - // A typed value outside the range is sent as typed, for the write policy to refuse. let value = if (min..=max).contains(&number) { writes::number_value(entry, reported.as_ref(), number) } else { Value::from(number) }; + let path = entry.path.clone(); self.send(ui, Intent::Write { path, value }); } diff --git a/crates/app/src/views/mod.rs b/crates/app/src/views/mod.rs index d095181..1e3c8e8 100644 --- a/crates/app/src/views/mod.rs +++ b/crates/app/src/views/mod.rs @@ -9,6 +9,7 @@ pub mod group_strip; pub mod history; mod knob; mod meters; +mod number_field; pub mod panel; pub mod remove_speaker; pub mod scan_choice; diff --git a/crates/app/src/views/number_field.rs b/crates/app/src/views/number_field.rs new file mode 100644 index 0000000..23a778b --- /dev/null +++ b/crates/app/src/views/number_field.rs @@ -0,0 +1,111 @@ +//! A number field as one gesture: a drag or a typed value is held while it lasts and handed out +//! once, on the frame it ends (release, Enter or focus loss); Escape drops it. The device value +//! is never clamped or written back. The device panel and the EQ table use it. + +use std::ops::RangeInclusive; + +use eframe::egui::{DragValue, Event, Id, Key, Response, Ui}; +use neumann_control_core::catalog::Conversion; + +use crate::display::converted_unit; +use crate::gesture::{bounded_move, on_grid, released, typed_value}; +use crate::texts; + +const PENDING_NUMBER_ID: &str = "pending_number"; + +fn is_text_edit(event: &Event) -> bool { + match event { + Event::Text(_) | Event::Paste(_) | Event::Cut => true, + Event::Key { + key, pressed: true, .. + } => matches!(key, Key::Backspace | Key::Delete), + _ => false, + } +} + +pub struct NumberField<'a> { + pub id: Id, + /// What the speaker reports; `None` before the first report. + pub device: Option, + /// Where a drag may go; a typed value is handed out as typed, for the write policy. + pub range: RangeInclusive, + /// The device's grid, if it has one: a gesture ending on the start's grid point is no change. + pub step: Option, + pub drag_speed: f64, + pub unit: Option<&'a str>, + pub conversion: Option, + pub enabled: bool, + /// The label the field is named by for screen readers. + pub labelled_by: Option, +} + +/// Draws the field; returns the number to write when a gesture ended on a changed value. +pub fn show(ui: &mut Ui, field: &NumberField) -> Option { + let pending_id = field.id.with(PENDING_NUMBER_ID); + let held = ui.data(|data| data.get_temp::(pending_id)); + // Before the first report the field starts at the range's low end (or 0 without one). + let low = Some(*field.range.start()).filter(|low| low.is_finite()); + let mut value = held.or(field.device).or(low).unwrap_or_default(); + let response = drag_value(ui, field, &mut value); + let device = field.device; + if response.dragged() { + let start = device.unwrap_or(value); + value = bounded_move(start, value, &field.range).unwrap_or(start); + } + let active = response.dragged() || response.has_focus(); + let ended = response.drag_stopped() || response.lost_focus(); + if !active && !ended { + ui.data_mut(|data| data.remove::(pending_id)); + return None; + } + // egui also reports a change on the frame focus leaves, when it reparses and clamps the + // shown text; that one is the user's only if the same frame carried their typing. + let typed_this_frame = ui.input(|input| input.events.iter().any(is_text_edit)); + let changed_by_user = response.changed() && (active || typed_this_frame); + if changed_by_user { + ui.data_mut(|data| data.insert_temp(pending_id, value)); + } + if active { + return None; + } + let number = changed_by_user.then_some(value).or(held)?; + ui.data_mut(|data| data.remove::(pending_id)); + if ui.input(|input| input.key_pressed(Key::Escape)) { + return None; + } + // A drag or an edit that ends where it began writes nothing. + let unchanged = match (field.step, device) { + (Some(step), Some(device)) => released(device, number, step).is_none(), + _ => Some(number) == device, + }; + (!unchanged).then_some(number) +} + +/// No range on the egui field: it would clamp typed text to it, and a typo would reach the +/// speaker as the range's end. The field shows the value unrounded: egui parses the shown text +/// when focus leaves and would otherwise report the rounded text as an edit. +fn drag_value(ui: &mut Ui, field: &NumberField, value: &mut f64) -> Response { + let mut drag = DragValue::new(value) + .custom_formatter(|number, _| texts::number(number)) + .custom_parser(|text| typed_value(text, None)) + .speed(field.drag_speed); + if let Some(unit) = field.unit { + drag = drag.suffix(format!(" {unit}")); + } + if let Some(conversion) = field.conversion { + // Typed and shown in the converted unit; back on the device's step grid, so the shown + // text parses to exactly the device value and a visit writes nothing. + let step = field.drag_speed; + drag = drag + .custom_formatter(move |number, _| texts::number(conversion.apply(number))) + .custom_parser(move |text| { + typed_value(text, Some(conversion)).map(|typed| on_grid(typed, step)) + }) + .suffix(format!(" {}", converted_unit(conversion))); + } + let response = ui.add_enabled(field.enabled, drag); + match field.labelled_by { + Some(label) => response.labelled_by(label), + None => response, + } +} From ab6c2870b5c9a6f1c66213c42462df80e25fdd66 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:04:49 +0200 Subject: [PATCH 128/460] Measure the narrow toolbar once the response time is shown The toolbar's split lags one frame when the response time first appears; the test measured that frame by chance and failed now and then. --- crates/app/tests/compact.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/app/tests/compact.rs b/crates/app/tests/compact.rs index 53e5895..b31f37f 100644 --- a/crates/app/tests/compact.rs +++ b/crates/app/tests/compact.rs @@ -134,6 +134,10 @@ fn mute_stays_reachable_in_the_narrowest_compact_window() { fn the_toolbar_does_not_overlap_itself_in_the_narrowest_window() { let (_simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); harness.set_size(egui::vec2(480.0, 360.0)); + // The widest toolbar: with the average response time, which comes after a measurement. + wait_until(&mut harness, "the average response time", |harness| { + harness.query_by_label_contains("Ø ").is_some() + }); harness.run_steps(2); let toolbar = [ texts::SCAN.get(), From 914b813e3088cf4f0baf4c24f057b22e0482bbb8 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:05:25 +0200 Subject: [PATCH 129/460] Add the EQ window One window per speaker with a curve per container, the sum, draggable band points and a table; each edit writes one band and lands in the history as that band. --- crates/app/src/app.rs | 7 + crates/app/src/app/eq.rs | 345 ++++++++++++++++++++++++++++++ crates/app/src/app/history.rs | 52 ++++- crates/app/src/eq.rs | 42 ++++ crates/app/src/texts.rs | 41 ++++ crates/app/src/views/eq_graph.rs | 313 +++++++++++++++++++++++++++ crates/app/src/views/eq_table.rs | 184 ++++++++++++++++ crates/app/src/views/eq_window.rs | 112 ++++++++++ crates/app/src/views/mod.rs | 3 + crates/app/src/views/panel.rs | 5 + crates/app/tests/eq.rs | 180 ++++++++++++++++ crates/app/tests/support/mod.rs | 9 + 12 files changed, 1285 insertions(+), 8 deletions(-) create mode 100644 crates/app/src/app/eq.rs create mode 100644 crates/app/src/views/eq_graph.rs create mode 100644 crates/app/src/views/eq_table.rs create mode 100644 crates/app/src/views/eq_window.rs create mode 100644 crates/app/tests/eq.rs diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index c740459..6ba1188 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -33,6 +33,7 @@ use crate::views::{all_values, panel, wheel}; mod compact; mod desk; +mod eq; mod history; pub use history::TakeBackNotice; @@ -81,6 +82,7 @@ pub struct NeumannApp { settings_open: bool, window: Option, history: history::HistoryState, + eq_windows: Vec, /// What "like Windows" means, asked of Windows at the start. system_language: Language, } @@ -127,6 +129,7 @@ impl NeumannApp { group_draft: None, settings_open: false, history: history::HistoryState::default(), + eq_windows: Vec::new(), system_language, }; app.sync_targets(); @@ -372,6 +375,7 @@ impl NeumannApp { self.show_settings(ui); self.show_group_dialog(ui); self.show_all_values(ui.ctx()); + self.show_eq_windows(ui.ctx()); self.show_history(ui); self.show_take_back_question(ui); let now = ui.input(|input| input.time); @@ -444,6 +448,9 @@ impl NeumannApp { name: speaker.display_name(), }); } + if response.open_eq { + self.open_eq(speaker.key.clone()); + } if response.close || response.remove { self.panel = None; } diff --git a/crates/app/src/app/eq.rs b/crates/app/src/app/eq.rs new file mode 100644 index 0000000..fe2584c --- /dev/null +++ b/crates/app/src/app/eq.rs @@ -0,0 +1,345 @@ +//! The EQ windows on the app side: which are open, what they show, and the band writes they +//! ask for, which go through the write policy like every other write. + +use eframe::egui::{Color32, Context, Id, ViewportId}; +use neumann_control_core::catalog::{Catalog, EqContainer, EQ_TYPES}; +use neumann_control_core::ssc::wire::SscPath; +use serde_json::Value; + +use super::NeumannApp; +use crate::eq::{bands_of, one_band, Band, Leaf}; +use crate::eq_response::{curve_db, graph_frequencies}; +use crate::paths; +use crate::state::{SpeakerKey, SpeakerState, Status}; +use crate::texts; +use crate::ui_kit::theme::group_color; +use crate::views::eq_graph::{Graph, GraphAction, GraphCurve, GraphPoint}; +use crate::views::eq_table::{Limits, Table, TableAction, TableRow}; +use crate::views::eq_window::{self, EqAction, EqView}; +use crate::writes::{self, Intent}; + +const EQ_VIEWPORT_ID: &str = "eq_window"; +const EQ_GRAPH_ID: &str = "eq_graph"; +const EQ_TABLE_ID: &str = "eq_table"; + +/// An open EQ window and what is chosen in it. +pub(super) struct EqWindow { + key: SpeakerKey, + output: usize, + container: usize, + band: usize, + refusal: Option, +} + +/// One container as the window shows it. +struct Shown<'a> { + container: &'a EqContainer, + bands: Option>, + curve: Vec, + color: Color32, +} + +impl NeumannApp { + pub(super) fn open_eq(&mut self, key: SpeakerKey) { + if self.eq_windows.iter().any(|window| window.key == key) { + return; + } + self.eq_windows.push(EqWindow { + key, + output: 0, + container: 0, + band: 0, + refusal: None, + }); + } + + /// Every open window, each drawn in its own viewport; a window whose speaker is gone closes. + pub(super) fn show_eq_windows(&mut self, context: &Context) { + let speakers = &self.speakers; + self.eq_windows.retain(|window| { + speakers + .iter() + .any(|speaker| speaker.state.key == window.key) + }); + let now = context.input(|input| input.time); + let mut closed = Vec::new(); + for window in 0..self.eq_windows.len() { + let actions = self.show_eq_window(context, window); + for action in actions { + if action == EqAction::Close { + closed.push(window); + continue; + } + self.apply_eq_action(window, action, now); + } + } + for window in closed.into_iter().rev() { + self.eq_windows.remove(window); + } + } + + fn show_eq_window(&mut self, context: &Context, index: usize) -> Vec { + let catalog = Catalog::embedded(); + let window = &self.eq_windows[index]; + let Some(speaker) = self.speaker_of(&window.key) else { + return Vec::new(); + }; + let outputs = outputs_of(catalog, speaker.product()); + let output = window.output.min(outputs.len().saturating_sub(1)); + let frequencies = graph_frequencies(); + let shown: Vec = outputs + .get(output) + .map(|(_, containers)| containers.as_slice()) + .unwrap_or_default() + .iter() + .enumerate() + .map(|(place, container)| { + let bands = bands_of(&speaker.values, &container.path); + let curve = curve_db(bands.as_deref().unwrap_or_default(), &frequencies); + let color = group_color(place as u8); + Shown { + container, + bands, + curve, + color, + } + }) + .collect(); + let sum: Vec = (0..frequencies.len()) + .map(|point| shown.iter().map(|container| container.curve[point]).sum()) + .collect(); + let chosen = window.container.min(shown.len().saturating_sub(1)); + let selected = shown.get(chosen); + let bands = selected.and_then(|shown| shown.bands.clone()); + let editable = selected.is_some_and(|shown| { + let gain = Leaf::Gain.path(&shown.container.path); + bands.is_some() && writes::can_write(speaker, catalog, &gain) + }); + let curves: Vec = shown + .iter() + .map(|shown| GraphCurve { + color: shown.color, + db: &shown.curve, + }) + .collect(); + let points: Vec = bands + .iter() + .flatten() + .map(|band| GraphPoint { + frequency: band.frequency, + gain: band.gain, + boost: band.boost, + q: band.q, + enabled: band.enabled, + }) + .collect(); + let limits = limits_of(catalog, selected.map(|shown| &shown.container.path)); + let headers = headers_of(catalog, selected.map(|shown| &shown.container.path)); + let rows: Option> = bands.as_ref().map(|bands| { + bands + .iter() + .map(|band| TableRow { + enabled: band.enabled, + kind: band.kind.clone(), + frequency: band.frequency, + q: band.q, + gain: band.gain, + boost: band.boost, + }) + .collect() + }); + let id = Id::new(EQ_VIEWPORT_ID).with(&window.key); + let color = selected.map_or(Color32::GRAY, |shown| shown.color); + let graph = Graph { + id: id.with(EQ_GRAPH_ID).with(chosen), + frequencies: &frequencies, + curves: &curves, + sum: &sum, + points: &points, + color, + selected: window.band, + editable, + gain_range: limits.gain.clone(), + q_range: limits.q.clone(), + }; + let table = rows.as_ref().map(|rows| Table { + id: id.with(EQ_TABLE_ID).with(chosen), + rows, + limits: &limits, + headers: &headers, + editable, + selected: window.band, + }); + let output_names: Vec = outputs.iter().map(|(name, _)| name.clone()).collect(); + let tabs: Vec<(String, Color32)> = shown + .iter() + .map(|shown| { + ( + shown.container.label(texts::language()).to_owned(), + shown.color, + ) + }) + .collect(); + let unreachable = speaker.status != Status::Connected; + let notice = unreachable + .then(|| texts::UNREACHABLE.get().to_owned()) + .or_else(|| window.refusal.clone()); + let view = EqView { + viewport: ViewportId::from_hash_of(id), + title: texts::eq_window_title(&speaker.display_name()), + outputs: &output_names, + output, + tabs: &tabs, + container: chosen, + graph, + table, + notice, + }; + eq_window::show(context, &view) + } + + fn speaker_of(&self, key: &SpeakerKey) -> Option<&SpeakerState> { + self.speakers + .iter() + .map(|speaker| &speaker.state) + .find(|state| state.key == *key) + } + + fn apply_eq_action(&mut self, index: usize, action: EqAction, now: f64) { + let window = &mut self.eq_windows[index]; + match action { + EqAction::Close => {} + EqAction::Output(output) => { + (window.output, window.container, window.band) = (output, 0, 0); + } + EqAction::Container(container) => (window.container, window.band) = (container, 0), + EqAction::Graph(GraphAction::Select(band)) + | EqAction::Table(TableAction::Select(band)) => window.band = band, + EqAction::Graph(GraphAction::Move { + band, + frequency, + gain, + }) => { + let writes = [(Leaf::Frequency, frequency), (Leaf::Gain, gain)]; + for (leaf, value) in writes { + if let Some(value) = value { + self.write_band(index, band, leaf, Value::from(value), now); + } + } + } + EqAction::Graph(GraphAction::Q { band, q }) => { + self.write_band(index, band, Leaf::Q, Value::from(q), now); + } + EqAction::Table(TableAction::Write { band, leaf, value }) => { + self.write_band(index, band, leaf, value, now); + } + } + } + + /// One leaf of one band, `null` elsewhere; a refusal is shown in the window. + fn write_band(&mut self, index: usize, band: usize, leaf: Leaf, value: Value, now: f64) { + let catalog = Catalog::embedded(); + let window = &self.eq_windows[index]; + let Some(speaker) = self + .speakers + .iter_mut() + .find(|speaker| speaker.state.key == window.key) + else { + return; + }; + let outputs = outputs_of(catalog, speaker.state.product()); + let container = outputs + .get(window.output) + .and_then(|(_, containers)| containers.get(window.container)) + .map(|container| container.path.clone()); + let Some(container) = container else { + return; + }; + let path = leaf.path(&container); + let Some(length) = speaker + .state + .values + .get(&path) + .and_then(Value::as_array) + .map(Vec::len) + else { + return; + }; + let intent = Intent::Write { + path: path.clone(), + value: one_band(length, band, value), + }; + let refusal = match speaker.state.try_request(intent, catalog, now) { + Ok(command) => { + speaker.handle.send(command); + None + } + Err(refusal) => { + let label = paths::catalog_entry(catalog, &path).map_or_else( + || leaf.name().to_owned(), + |entry| entry.label(texts::language()).to_owned(), + ); + let reason = texts::describe_refusal(&refusal); + Some(texts::eq_refusal(band + 1, &label, &reason)) + } + }; + self.eq_windows[index].refusal = refusal; + } +} + +/// The speaker's EQ containers by output (`out`, `out1`, `out2`), in catalog order. +fn outputs_of<'a>(catalog: &'a Catalog, product: &str) -> Vec<(String, Vec<&'a EqContainer>)> { + let mut outputs: Vec<(String, Vec<&EqContainer>)> = Vec::new(); + for container in catalog.eq_containers(product) { + let output = container + .path + .segments() + .get(1) + .cloned() + .unwrap_or_default(); + let name = output_name(&output); + match outputs.iter_mut().find(|(known, _)| *known == name) { + Some((_, containers)) => containers.push(container), + None => outputs.push((name, vec![container])), + } + } + outputs +} + +fn output_name(output: &str) -> String { + let text = match output { + "out1" => texts::EQ_OUTPUT_1, + "out2" => texts::EQ_OUTPUT_2, + _ => texts::EQ_OUTPUT_ALL, + }; + text.get().to_owned() +} + +/// The catalog's limits for the container's leaves; without a container, nothing is allowed. +fn limits_of(catalog: &Catalog, container: Option<&SscPath>) -> Limits { + let range = |leaf: Leaf| { + let entry = + container.and_then(|container| paths::catalog_entry(catalog, &leaf.path(container))); + let min = entry.and_then(|entry| entry.min).unwrap_or(0.0); + let max = entry.and_then(|entry| entry.max).unwrap_or(0.0); + min..=max + }; + Limits { + frequency: range(Leaf::Frequency), + q: range(Leaf::Q), + gain: range(Leaf::Gain), + types: EQ_TYPES.iter().map(|kind| (*kind).to_owned()).collect(), + } +} + +/// Column titles in `Leaf::ALL` order, from the catalog in the current language. +fn headers_of(catalog: &Catalog, container: Option<&SscPath>) -> [String; 6] { + Leaf::ALL.map(|leaf| { + container + .and_then(|container| paths::catalog_entry(catalog, &leaf.path(container))) + .map_or_else( + || leaf.name().to_owned(), + |entry| entry.label(texts::language()).to_owned(), + ) + }) +} diff --git a/crates/app/src/app/history.rs b/crates/app/src/app/history.rs index c107dd1..81e2aaf 100644 --- a/crates/app/src/app/history.rs +++ b/crates/app/src/app/history.rs @@ -4,6 +4,9 @@ use eframe::egui::{Key, KeyboardShortcut, Modifiers, Ui}; use super::NeumannApp; +use serde_json::Value; + +use crate::eq::container_of; use crate::history::{changed_since, is_open, Change, ClockTime, Entry, History}; use crate::paths; use crate::state::{SpeakerState, Status}; @@ -41,6 +44,18 @@ struct Described { speaker: String, setting: String, unit: Option, + /// For an EQ band write: the band it touched; its values are that band's entries. + band: Option, +} + +impl Described { + /// The part of `value` the line shows: the band's entry of an EQ array, else all of it. + fn shown<'a>(&self, value: &'a Value) -> Option<&'a Value> { + match self.band { + Some(band) => value.as_array().and_then(|bands| bands.get(band)), + None => Some(value), + } + } } impl NeumannApp { @@ -154,8 +169,13 @@ impl NeumannApp { fn change_line(&self, change: &Change) -> String { let described = self.describe(change); let unit = described.unit.as_deref(); - let before = texts::history_before(change.before.as_ref(), unit); - let after = texts::history_value(&change.after, unit); + let before = change + .before + .as_ref() + .and_then(|before| described.shown(before)); + let before = texts::history_before(before, unit); + let after = described.shown(&change.after).unwrap_or(&change.after); + let after = texts::history_value(after, unit); texts::history_change(&described.speaker, &described.setting, &before, &after) } @@ -171,11 +191,13 @@ impl NeumannApp { }; let described = self.describe(change); let unit = described.unit.as_deref(); + let now = described.shown(now).unwrap_or(now); + let written = described.shown(&change.after).unwrap_or(&change.after); lines.push(texts::changed_now( &described.speaker, &described.setting, &texts::history_value(now, unit), - &texts::history_value(&change.after, unit), + &texts::history_value(written, unit), )); } lines @@ -198,15 +220,29 @@ impl NeumannApp { || texts::REMOVED_SPEAKER.get().to_owned(), |speaker| speaker.state.display_name(), ); - let entry = paths::catalog_entry(Catalog::embedded(), &change.path); + let catalog = Catalog::embedded(); + let entry = paths::catalog_entry(catalog, &change.path); let language = texts::language(); + let label = entry.map_or_else( + || change.path.to_string(), + |entry| entry.label(language).to_owned(), + ); + let band = change + .after + .as_array() + .and_then(|bands| bands.iter().position(|band| !band.is_null())); + let container = container_of(catalog, &change.path); + let setting = match (container, band) { + (Some(container), Some(band)) => { + texts::eq_change_setting(container.label(language), band + 1, &label) + } + _ => label, + }; Described { speaker, - setting: entry.map_or_else( - || change.path.to_string(), - |entry| entry.label(language).to_owned(), - ), + setting, unit: entry.and_then(|entry| entry.unit.clone()), + band: container.and(band), } } diff --git a/crates/app/src/eq.rs b/crates/app/src/eq.rs index ecc3e15..098cedb 100644 --- a/crates/app/src/eq.rs +++ b/crates/app/src/eq.rs @@ -3,6 +3,7 @@ use std::collections::BTreeMap; +use neumann_control_core::catalog::{Catalog, EqContainer}; use neumann_control_core::ssc::wire::SscPath; use serde_json::Value; @@ -101,6 +102,31 @@ pub fn bands_of(values: &BTreeMap, container: &SscPath) -> Optio Some(bands) } +/// Gain and boost move in half decibels, Q in tenths, frequencies keep three digits. +pub const GAIN_GRID_DB: f64 = 0.5; +pub const Q_STEP: f64 = 0.1; +const FREQUENCY_DIGITS: i32 = 3; + +/// `frequency` rounded to `FREQUENCY_DIGITS` significant digits (1230 Hz, 47.3 Hz). +pub fn snapped_frequency(frequency: f64) -> f64 { + let magnitude = frequency.abs().log10().floor() as i32; + let unit = 10f64.powi(magnitude + 1 - FREQUENCY_DIGITS); + let snapped = (frequency / unit).round() * unit; + // Rounding by a power of ten leaves binary noise (47.300000000000004). + let decimals = (FREQUENCY_DIGITS - 1 - magnitude).max(0); + let scale = 10f64.powi(decimals); + (snapped * scale).round() / scale +} + +/// The EQ container a band leaf belongs to. +pub fn container_of<'a>(catalog: &'a Catalog, path: &SscPath) -> Option<&'a EqContainer> { + catalog.eq.iter().find(|container| { + Leaf::ALL + .iter() + .any(|leaf| leaf.path(&container.path) == *path) + }) +} + /// The value that writes `value` to `band` only (measured: `null` leaves a band as it is). pub fn one_band(length: usize, band: usize, value: Value) -> Value { let mut array = vec![Value::Null; length]; @@ -174,6 +200,22 @@ mod tests { assert_eq!(one_band(3, 1, json!(2.5)), json!([null, 2.5, null])); } + #[test] + fn a_frequency_keeps_three_digits() { + assert_eq!(snapped_frequency(1234.5), 1230.0); + assert_eq!(snapped_frequency(99.95), 100.0); + assert_eq!(snapped_frequency(47.26), 47.3); + assert_eq!(snapped_frequency(23999.0), 24000.0); + } + + #[test] + fn a_band_path_finds_its_container() { + let catalog = neumann_control_core::catalog::Catalog::embedded(); + let found = container_of(catalog, &paths::path("audio/out/eq3/q")).unwrap(); + assert_eq!(found.path, paths::path("audio/out/eq3")); + assert!(container_of(catalog, &paths::path("audio/out/level")).is_none()); + } + #[test] fn a_leaf_names_its_path() { assert_eq!( diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 69eaaea..613e8db 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -156,6 +156,20 @@ pub const REVERT_ANYWAY: Text = Text::new("Trotzdem zurücksetzen?", "Revert any pub const REVERT: Text = Text::new("Zurücksetzen", "Revert"); pub const SWITCH_ON: Text = Text::new("an", "on"); pub const SWITCH_OFF: Text = Text::new("aus", "off"); +pub const EQ: Text = Text::new("EQ", "EQ"); +pub const EQ_SUM: Text = Text::new("Summe", "Sum"); +pub const EQ_COMPUTED: Text = Text::new( + "Berechnet aus den Einstellungen, nicht gemessen", + "Computed from the settings, not measured", +); +pub const EQ_NOT_REPORTED: Text = Text::new( + "Der Lautsprecher hat diesen EQ noch nicht gemeldet.", + "The speaker has not reported this EQ yet.", +); +pub const EQ_OUTPUT_ALL: Text = Text::new("Gesamt", "Overall"); +pub const EQ_OUTPUT_1: Text = Text::new("Ausgang 1", "Output 1"); +pub const EQ_OUTPUT_2: Text = Text::new("Ausgang 2", "Output 2"); +pub const BAND: Text = Text::new("Band", "Band"); /// A speaker of a history entry that has been removed since. pub const REMOVED_SPEAKER: Text = Text::new("entfernter Lautsprecher", "removed speaker"); pub const CONTROL_HINT: Text = Text::new( @@ -532,6 +546,33 @@ pub fn take_back_skipped(names: &[String]) -> String { ) } +/// Names a band's point in the EQ graph. +pub fn eq_band(number: usize) -> String { + format!("{} {number}", BAND.get()) +} + +pub fn eq_window_title(speaker: &str) -> String { + format!("EQ · {speaker}") +} + +/// A frequency mark of the EQ graph: "50", "1k", "20k". +pub fn frequency_mark(hertz: f64) -> String { + if hertz >= 1000.0 { + return format!("{}k", hertz / 1000.0); + } + format!("{hertz}") +} + +/// What a band change in the history touched: "EQ 2 Band 3 · Verstärkung". +pub fn eq_change_setting(container: &str, band: usize, leaf: &str) -> String { + format!("{container} {} {band} · {leaf}", BAND.get()) +} + +/// A refused band write, under the EQ graph. +pub fn eq_refusal(band: usize, leaf: &str, reason: &str) -> String { + format!("{} {band} · {leaf}: {reason}", BAND.get()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/app/src/views/eq_graph.rs b/crates/app/src/views/eq_graph.rs new file mode 100644 index 0000000..d716e9e --- /dev/null +++ b/crates/app/src/views/eq_graph.rs @@ -0,0 +1,313 @@ +//! The EQ graph: a log frequency axis, one curve per container, the sum, and a point per band +//! of the chosen container. A point is dragged for frequency and gain and turned with the +//! mouse wheel for Q; each gesture is handed out once, when it ends. + +use std::ops::RangeInclusive; + +use eframe::egui::{ + pos2, vec2, Align2, Color32, CursorIcon, FontId, Id, Pos2, Rect, Sense, Shape, Stroke, Ui, + WidgetInfo, WidgetType, +}; + +use super::wheel::notches_for; +use crate::eq::{snapped_frequency, GAIN_GRID_DB, Q_STEP}; +use crate::eq_response::{FREQUENCY_MAX, FREQUENCY_MIN}; +use crate::gesture::{on_grid, GestureState}; +use crate::texts; +use crate::ui_kit::theme::Tokens; + +const GRAPH_HEIGHT: f32 = 300.0; +/// The dB axis shows at least ±24 dB and grows in 6 dB steps to fit the curves. +const DB_SPAN_MIN: f64 = 24.0; +const DB_GRID: f64 = 6.0; +const FREQUENCY_MARKS: [f64; 10] = [ + 20.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 2000.0, 5000.0, 10000.0, 20000.0, +]; +const LABEL_SIZE: f32 = 9.0; +const CURVE_WIDTH: f32 = 1.5; +const SUM_WIDTH: f32 = 2.5; +const POINT_RADIUS: f32 = 5.0; +const SELECTED_RING: f32 = 3.0; +const POINT_GRAB: f32 = 14.0; +const DRAG_ID: &str = "eq_point_drag"; +const WHEEL_ID: &str = "eq_point_wheel"; + +pub struct GraphCurve<'a> { + pub color: Color32, + pub db: &'a [f64], +} + +#[derive(Clone, Copy, Debug)] +pub struct GraphPoint { + pub frequency: f64, + pub gain: f64, + pub boost: f64, + pub q: f64, + pub enabled: bool, +} + +pub struct Graph<'a> { + pub id: Id, + pub frequencies: &'a [f64], + pub curves: &'a [GraphCurve<'a>], + pub sum: &'a [f64], + pub points: &'a [GraphPoint], + pub color: Color32, + pub selected: usize, + pub editable: bool, + pub gain_range: RangeInclusive, + pub q_range: RangeInclusive, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum GraphAction { + Select(usize), + /// A drag ended; only the values it changed are given. + Move { + band: usize, + frequency: Option, + gain: Option, + }, + Q { + band: usize, + q: f64, + }, +} + +/// Maps frequency and level to the drawing area and back. +struct Axes { + rect: Rect, + db_span: f64, +} + +impl Axes { + fn x(&self, frequency: f64) -> f32 { + let (low, high) = (FREQUENCY_MIN.ln(), FREQUENCY_MAX.ln()); + let share = (frequency.clamp(FREQUENCY_MIN, FREQUENCY_MAX).ln() - low) / (high - low); + self.rect.left() + share as f32 * self.rect.width() + } + + fn y(&self, db: f64) -> f32 { + let share = (db.clamp(-self.db_span, self.db_span) / self.db_span) as f32; + self.rect.center().y - share * self.rect.height() / 2.0 + } + + fn frequency(&self, x: f32) -> f64 { + let (low, high) = (FREQUENCY_MIN.ln(), FREQUENCY_MAX.ln()); + let share = f64::from(((x - self.rect.left()) / self.rect.width()).clamp(0.0, 1.0)); + (low + share * (high - low)).exp() + } + + fn db(&self, y: f32) -> f64 { + let share = f64::from((self.rect.center().y - y) / (self.rect.height() / 2.0)); + share.clamp(-1.0, 1.0) * self.db_span + } +} + +pub fn show(ui: &mut Ui, graph: &Graph) -> Vec { + let tokens = Tokens::current(ui.ctx()); + let size = vec2(ui.available_width(), GRAPH_HEIGHT); + let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); + let axes = Axes { + rect: rect.shrink2(vec2(28.0, 14.0)), + db_span: db_span(graph), + }; + paint_grid(ui, &axes, &tokens); + for curve in graph.curves { + paint_curve( + ui, + &axes, + graph.frequencies, + curve.db, + Stroke::new(CURVE_WIDTH, curve.color), + ); + } + paint_curve( + ui, + &axes, + graph.frequencies, + graph.sum, + Stroke::new(SUM_WIDTH, tokens.text), + ); + let mut actions = Vec::new(); + for (band, point) in graph.points.iter().enumerate() { + actions.extend(band_point(ui, graph, &axes, band, point)); + } + actions +} + +/// Wide enough for every point and every boost of a curve. Cuts are not counted: a low or high +/// pass falls without end and would squeeze everything else flat; such a curve leaves the +/// graph at its bottom edge. +fn db_span(graph: &Graph) -> f64 { + let curves = graph.curves.iter().flat_map(|curve| curve.db.iter()); + let boosts = curves.chain(graph.sum).copied().filter(|db| *db > 0.0); + let points = graph + .points + .iter() + .map(|point| (point.gain + point.boost).abs()); + let largest = boosts.chain(points).fold(0.0, f64::max); + ((largest / DB_GRID).ceil() * DB_GRID).max(DB_SPAN_MIN) +} + +/// A grid line every 6 dB up to ±24 dB, coarser on a wider axis so the labels stay apart. +fn db_grid(span: f64) -> f64 { + DB_GRID * (span / DB_SPAN_MIN).ceil() +} + +fn paint_grid(ui: &Ui, axes: &Axes, tokens: &Tokens) { + let painter = ui.painter(); + let rect = axes.rect; + let line = Stroke::new(1.0, tokens.border); + let font = FontId::proportional(LABEL_SIZE); + for mark in FREQUENCY_MARKS { + let x = axes.x(mark); + painter.line_segment([pos2(x, rect.top()), pos2(x, rect.bottom())], line); + let text = texts::frequency_mark(mark); + let at = pos2(x, rect.bottom() + 2.0); + painter.text(at, Align2::CENTER_TOP, text, font.clone(), tokens.text_dim); + } + let grid = db_grid(axes.db_span); + let steps = (axes.db_span / grid) as i32; + for step in -steps..=steps { + let db = f64::from(step) * grid; + let y = axes.y(db); + let stroke = if step == 0 { + Stroke::new(1.0, tokens.text_dim) + } else { + line + }; + painter.line_segment([pos2(rect.left(), y), pos2(rect.right(), y)], stroke); + let at = pos2(rect.left() - 3.0, y); + painter.text( + at, + Align2::RIGHT_CENTER, + texts::scale_mark(db), + font.clone(), + tokens.text_dim, + ); + } +} + +fn paint_curve(ui: &Ui, axes: &Axes, frequencies: &[f64], db: &[f64], stroke: Stroke) { + let points: Vec = frequencies + .iter() + .zip(db) + .map(|(frequency, db)| pos2(axes.x(*frequency), axes.y(*db))) + .collect(); + ui.painter().add(Shape::line(points, stroke)); +} + +/// Draws one band's point and hands out what its gesture chose. +fn band_point( + ui: &mut Ui, + graph: &Graph, + axes: &Axes, + band: usize, + point: &GraphPoint, +) -> Vec { + let id = graph.id.with(band); + let drag_id = id.with(DRAG_ID); + let dragged_to = ui.data(|data| data.get_temp::(drag_id)); + let home = pos2(axes.x(point.frequency), axes.y(point.gain + point.boost)); + let center = dragged_to.unwrap_or(home); + let rect = Rect::from_center_size(center, vec2(POINT_GRAB, POINT_GRAB)); + let sense = if graph.editable { + Sense::click_and_drag() + } else { + Sense::click() + }; + let response = ui.interact(rect, id, sense); + let label = texts::eq_band(band + 1); + response.widget_info(|| WidgetInfo::labeled(WidgetType::Button, graph.editable, &label)); + let mut actions = Vec::new(); + if response.clicked() || response.drag_started() { + actions.push(GraphAction::Select(band)); + } + if response.dragged() { + let pointer = response.interact_pointer_pos().unwrap_or(center); + let inside = pointer.clamp(axes.rect.min, axes.rect.max); + ui.data_mut(|data| data.insert_temp(drag_id, inside)); + } + if response.drag_stopped() { + ui.data_mut(|data| data.remove::(drag_id)); + if let Some(end) = dragged_to { + actions.extend(moved(axes, graph, band, point, end)); + } + } + // Asked every frame: a wheel step falls due after the pointer may have left the point. + if graph.editable { + actions.extend(wheel_q(ui, graph, id, band, point, rect)); + } + if graph.editable && response.hovered() { + ui.ctx().set_cursor_icon(CursorIcon::Grab); + } + paint_point(ui, graph, band, point, center); + actions +} + +fn moved( + axes: &Axes, + graph: &Graph, + band: usize, + point: &GraphPoint, + end: Pos2, +) -> Option { + let frequency = snapped_frequency(axes.frequency(end.x)); + let range = &graph.gain_range; + let gain = + on_grid(axes.db(end.y) - point.boost, GAIN_GRID_DB).clamp(*range.start(), *range.end()); + let frequency = (frequency != snapped_frequency(point.frequency)).then_some(frequency); + let gain = (gain != on_grid(point.gain, GAIN_GRID_DB)).then_some(gain); + let changed = frequency.is_some() || gain.is_some(); + changed.then_some(GraphAction::Move { + band, + frequency, + gain, + }) +} + +/// Wheel notches over a point step Q; the step goes out when the wheel goes quiet. +fn wheel_q( + ui: &Ui, + graph: &Graph, + id: Id, + band: usize, + point: &GraphPoint, + rect: Rect, +) -> Option { + let wheel_id = id.with(WHEEL_ID); + let now = ui.input(|input| input.time); + let mut state = ui + .data(|data| data.get_temp::(wheel_id)) + .unwrap_or_default(); + for notches in notches_for(ui, rect) { + state.on_wheel(notches, Q_STEP, point.q, graph.q_range.clone(), now); + } + let due = state.due(now); + if let Some(deadline) = state.deadline() { + ui.ctx() + .request_repaint_after_secs((deadline - now).max(0.0) as f32); + } + ui.data_mut(|data| data.insert_temp(wheel_id, state)); + due.map(|q| GraphAction::Q { + band, + q: on_grid(q, Q_STEP), + }) +} + +fn paint_point(ui: &Ui, graph: &Graph, band: usize, point: &GraphPoint, center: Pos2) { + let painter = ui.painter(); + if band == graph.selected { + painter.circle_stroke( + center, + POINT_RADIUS + SELECTED_RING, + Stroke::new(1.0, graph.color), + ); + } + if point.enabled { + painter.circle_filled(center, POINT_RADIUS, graph.color); + } else { + painter.circle_stroke(center, POINT_RADIUS, Stroke::new(1.5, graph.color)); + } +} diff --git a/crates/app/src/views/eq_table.rs b/crates/app/src/views/eq_table.rs new file mode 100644 index 0000000..d285c1f --- /dev/null +++ b/crates/app/src/views/eq_table.rs @@ -0,0 +1,184 @@ +//! The bands of one EQ container as a table: on/off, type, frequency, Q, gain and boost. Number +//! cells follow the device panel's gesture rules (`number_field`). + +use std::ops::RangeInclusive; + +use eframe::egui::{Checkbox, ComboBox, Grid, Id, ScrollArea, Ui}; +use serde_json::Value; + +use super::number_field::{self, NumberField}; +use crate::eq::{Leaf, GAIN_GRID_DB, Q_STEP}; +use crate::texts; + +const TABLE_HEIGHT: f32 = 260.0; +/// The order of the columns after the band number. +const COLUMNS: [Leaf; 6] = [ + Leaf::Enabled, + Leaf::Type, + Leaf::Frequency, + Leaf::Q, + Leaf::Gain, + Leaf::Boost, +]; +/// Frequencies are dragged in whole hertz; typing takes any value. +const FREQUENCY_DRAG_SPEED: f64 = 1.0; + +pub struct TableRow { + pub enabled: bool, + pub kind: String, + pub frequency: f64, + pub q: f64, + pub gain: f64, + pub boost: f64, +} + +/// What the catalog allows per leaf. +pub struct Limits { + pub frequency: RangeInclusive, + pub q: RangeInclusive, + pub gain: RangeInclusive, + pub types: Vec, +} + +pub struct Table<'a> { + pub id: Id, + pub rows: &'a [TableRow], + pub limits: &'a Limits, + /// Column titles in `Leaf::ALL` order, from the catalog in the current language. + pub headers: &'a [String; 6], + pub editable: bool, + pub selected: usize, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum TableAction { + Select(usize), + Write { + band: usize, + leaf: Leaf, + value: Value, + }, +} + +pub fn show(ui: &mut Ui, table: &Table) -> Vec { + let mut actions = Vec::new(); + ScrollArea::vertical() + .id_salt(table.id) + .max_height(TABLE_HEIGHT) + .show(ui, |ui| { + Grid::new(table.id).striped(true).show(ui, |ui| { + ui.label(texts::BAND); + let header_ids: Vec<(Leaf, Id)> = COLUMNS + .iter() + .map(|leaf| (*leaf, ui.label(header(table, *leaf)).id)) + .collect(); + ui.end_row(); + for (band, row) in table.rows.iter().enumerate() { + actions.extend(row_cells(ui, table, &header_ids, band, row)); + ui.end_row(); + } + }); + }); + actions +} + +fn header<'a>(table: &'a Table, leaf: Leaf) -> &'a str { + let place = Leaf::ALL + .iter() + .position(|known| *known == leaf) + .unwrap_or_default(); + &table.headers[place] +} + +/// The column title a cell is named by for screen readers. +fn header_id(headers: &[(Leaf, Id)], leaf: Leaf) -> Id { + headers + .iter() + .find(|(known, _)| *known == leaf) + .map(|(_, id)| *id) + .expect("every column has a title") +} + +fn row_cells( + ui: &mut Ui, + table: &Table, + headers: &[(Leaf, Id)], + band: usize, + row: &TableRow, +) -> Vec { + let mut actions = Vec::new(); + let number = (band + 1).to_string(); + if ui + .selectable_label(band == table.selected, number) + .clicked() + { + actions.push(TableAction::Select(band)); + } + let mut enabled = row.enabled; + let checkbox = ui + .add_enabled(table.editable, Checkbox::without_text(&mut enabled)) + .labelled_by(header_id(headers, Leaf::Enabled)); + if checkbox.clicked() { + actions.push(write(band, Leaf::Enabled, Value::Bool(enabled))); + } + actions.extend(type_cell(ui, table, band, row)); + let limits = table.limits; + let cells = [ + ( + Leaf::Frequency, + row.frequency, + &limits.frequency, + FREQUENCY_DRAG_SPEED, + Some("Hz"), + ), + (Leaf::Q, row.q, &limits.q, Q_STEP, None), + (Leaf::Gain, row.gain, &limits.gain, GAIN_GRID_DB, Some("dB")), + ( + Leaf::Boost, + row.boost, + &limits.gain, + GAIN_GRID_DB, + Some("dB"), + ), + ]; + for (leaf, value, range, speed, unit) in cells { + let field = NumberField { + id: table.id.with((band, leaf.name())), + device: Some(value), + range: range.clone(), + step: None, + drag_speed: speed, + unit, + conversion: None, + enabled: table.editable, + labelled_by: Some(header_id(headers, leaf)), + }; + if let Some(number) = number_field::show(ui, &field) { + actions.push(write(band, leaf, Value::from(number))); + } + } + actions +} + +fn type_cell(ui: &mut Ui, table: &Table, band: usize, row: &TableRow) -> Option { + let known = table.limits.types.contains(&row.kind); + let mut chosen = None; + // A type the catalog does not know is shown as it is and left alone. + ui.add_enabled_ui(table.editable && known, |ui| { + ComboBox::from_id_salt(table.id.with((band, Leaf::Type.name()))) + .selected_text(&row.kind) + .show_ui(ui, |ui| { + for option in &table.limits.types { + let selected = *option == row.kind; + if ui.selectable_label(selected, option).clicked() && !selected { + chosen = Some(option.clone()); + } + } + }); + }); + chosen.map(|kind| write(band, Leaf::Type, Value::String(kind))) +} + +fn write(band: usize, leaf: Leaf, value: Value) -> TableAction { + TableAction::Write { band, leaf, value } +} diff --git a/crates/app/src/views/eq_window.rs b/crates/app/src/views/eq_window.rs new file mode 100644 index 0000000..a56a047 --- /dev/null +++ b/crates/app/src/views/eq_window.rs @@ -0,0 +1,112 @@ +//! The EQ window of one speaker, an OS window of its own (embedded as an inner window where the +//! backend has only one): output and container choice, the graph and the band table. + +use eframe::egui::{ + self, vec2, Color32, Context, Key, RichText, Ui, ViewportBuilder, ViewportClass, ViewportId, +}; + +use super::eq_graph::{self, Graph, GraphAction}; +use super::eq_table::{self, Table, TableAction}; +use crate::texts; + +const EQ_WINDOW_SIZE: [f32; 2] = [900.0, 640.0]; +const EQ_WINDOW_MIN_SIZE: [f32; 2] = [640.0, 480.0]; +const LEGEND_SWATCH: f32 = 10.0; + +pub struct EqView<'a> { + pub viewport: ViewportId, + pub title: String, + /// The outputs to choose from; one output needs no choice. + pub outputs: &'a [String], + pub output: usize, + /// The containers of the chosen output, with their curve colours. + pub tabs: &'a [(String, Color32)], + pub container: usize, + pub graph: Graph<'a>, + /// `None` while the container's bands are not (or not sensibly) reported. + pub table: Option>, + /// Why nothing can be written right now, or why the last write was refused. + pub notice: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum EqAction { + Close, + Output(usize), + Container(usize), + Graph(GraphAction), + Table(TableAction), +} + +pub fn show(context: &Context, view: &EqView) -> Vec { + let builder = ViewportBuilder::default() + .with_title(&view.title) + .with_inner_size(EQ_WINDOW_SIZE) + .with_min_inner_size(EQ_WINDOW_MIN_SIZE); + context.show_viewport_immediate(view.viewport, builder, |ui, class| { + let mut actions = match class { + ViewportClass::EmbeddedWindow => contents(ui, view), + _ => { + egui::CentralPanel::default() + .show(ui, |ui| contents(ui, view)) + .inner + } + }; + let close_requested = ui.input(|input| input.viewport().close_requested()); + // Escape in a field cancels the edit; only Escape with nothing being edited closes. + let editing = ui.memory(|memory| memory.focused().is_some()); + let escape = !editing && ui.input(|input| input.key_pressed(Key::Escape)); + if close_requested || escape { + actions.push(EqAction::Close); + } + actions + }) +} + +fn contents(ui: &mut Ui, view: &EqView) -> Vec { + let mut actions = Vec::new(); + if view.outputs.len() > 1 { + ui.horizontal(|ui| { + for (index, output) in view.outputs.iter().enumerate() { + if ui.selectable_label(index == view.output, output).clicked() { + actions.push(EqAction::Output(index)); + } + } + }); + } + ui.horizontal(|ui| { + for (index, (label, color)) in view.tabs.iter().enumerate() { + let (swatch, _) = + ui.allocate_exact_size(vec2(LEGEND_SWATCH, LEGEND_SWATCH), egui::Sense::hover()); + ui.painter().rect_filled(swatch, 2.0, *color); + if ui + .selectable_label(index == view.container, label) + .clicked() + { + actions.push(EqAction::Container(index)); + } + } + ui.label(RichText::new(texts::EQ_SUM).strong()); + }); + actions.extend( + eq_graph::show(ui, &view.graph) + .into_iter() + .map(EqAction::Graph), + ); + ui.weak(texts::EQ_COMPUTED); + if let Some(notice) = &view.notice { + ui.colored_label(ui.visuals().warn_fg_color, notice); + } + ui.separator(); + match &view.table { + Some(table) => actions.extend(eq_table::show(ui, table).into_iter().map(EqAction::Table)), + None => { + ui.weak(texts::EQ_NOT_REPORTED); + } + } + ui.separator(); + if ui.button(texts::CLOSE).clicked() { + actions.push(EqAction::Close); + } + actions +} diff --git a/crates/app/src/views/mod.rs b/crates/app/src/views/mod.rs index 1e3c8e8..b430611 100644 --- a/crates/app/src/views/mod.rs +++ b/crates/app/src/views/mod.rs @@ -3,6 +3,9 @@ pub mod all_values; pub mod compact; mod details; mod details_fields; +pub mod eq_graph; +pub mod eq_table; +pub mod eq_window; pub mod fader; pub mod group_dialog; pub mod group_strip; diff --git a/crates/app/src/views/panel.rs b/crates/app/src/views/panel.rs index eee063f..e070103 100644 --- a/crates/app/src/views/panel.rs +++ b/crates/app/src/views/panel.rs @@ -30,6 +30,7 @@ pub struct PanelResponse { pub group: Option>, /// A linked setting whose value goes to the other members. pub copy_to_group: Option, + pub open_eq: bool, } /// The groups the speaker could be in, which one it is in, and what that group links. @@ -53,6 +54,8 @@ pub fn show( let mut remove = false; let mut group = None; let mut copy_to_group = None; + let mut open_eq = false; + let has_eq = !catalog.eq_containers(speaker.product()).is_empty(); // Escape in a field cancels the edit; only Escape with nothing being edited closes. egui // drops the focus at the start of the Escape frame, so the last frame's state decides. let editing_id = Id::new(PANEL_ID).with(EDITING_ID); @@ -77,6 +80,7 @@ pub fn show( copy_to_group = copy_buttons(ui, groups); let commands = details::show(ui, speaker, catalog); ui.separator(); + open_eq = has_eq && ui.button(texts::EQ).clicked(); remove = ui.button(texts::REMOVE).clicked(); commands }) @@ -100,6 +104,7 @@ pub fn show( remove, group, copy_to_group, + open_eq, } } diff --git a/crates/app/tests/eq.rs b/crates/app/tests/eq.rs new file mode 100644 index 0000000..c6eca29 --- /dev/null +++ b/crates/app/tests/eq.rs @@ -0,0 +1,180 @@ +//! The EQ window: one per speaker, a graph of every container and a table of the chosen one. +//! A band edit writes that band only, at the end of the gesture, and lands in the history. + +mod support; + +use eframe::egui::{self, accesskit::Role}; +use egui_kittest::kittest::{NodeT, Queryable}; +use egui_kittest::Harness; +use neumann_control_app::app::NeumannApp; +use neumann_control_app::texts; +use neumann_control_app::writes::Refusal; +use neumann_control_core::catalog::ValueError; +use neumann_control_core::simulator::{SimModel, Simulator}; +use serde_json::{json, Value}; +use support::*; + +const GAIN_LABEL: &str = "Verstärkung"; +const GAIN_POINTER: &str = "/audio/out/eq2/gain"; +const FREQUENCY_POINTER: &str = "/audio/out/eq2/frequency"; + +/// Ten bands spread over the range, so every point has its own place. +fn spread(simulator: &Simulator) { + let frequencies: Vec = (0..10).map(|band| 50.0 * 2f64.powi(band)).collect(); + simulator.set_value("audio/out/eq2/frequency", json!(frequencies)); +} + +fn open_eq(model: SimModel) -> (Simulator, tempfile::TempDir, Harness<'static, NeumannApp>) { + let (simulator, directory, mut harness) = connected_speaker_with(model, spread); + open_panel(&mut harness, device_name(model)); + harness.get_by_label(texts::EQ.get()).click(); + wait_for_label(&mut harness, &texts::eq_band(1)); + (simulator, directory, harness) +} + +fn drag(harness: &mut Harness<'static, NeumannApp>, from: egui::Pos2, by: egui::Vec2) { + harness.drag_at(from); + for frame in 1..=5 { + harness.hover_at(from + by * (frame as f32 / 5.0)); + harness.step(); + } + harness.drop_at(from + by); + harness.run_steps(3); +} + +fn gain_fields(harness: &Harness<'static, NeumannApp>) -> Vec { + harness + .query_all_by_role_and_label(Role::SpinButton, GAIN_LABEL) + .map(|field| field.accesskit_node().is_disabled()) + .collect() +} + +#[test] +fn the_eq_button_opens_the_window_with_both_containers() { + let (_simulator, _directory, harness) = open_eq(SimModel::Kh120Ii); + harness.get_by_label("EQ 2"); + harness.get_by_label("EQ 3"); + assert_eq!(gain_fields(&harness), vec![false; 10]); +} + +#[test] +fn dragging_a_point_writes_frequency_and_gain_of_that_band_only() { + let (simulator, _directory, mut harness) = open_eq(SimModel::Kh120Ii); + let point = harness.get_by_label(&texts::eq_band(3)).rect().center(); + drag(&mut harness, point, egui::vec2(30.0, -40.0)); + wait_until(&mut harness, "the band writes", |_| { + !sets(&simulator, GAIN_POINTER).is_empty() + && !sets(&simulator, FREQUENCY_POINTER).is_empty() + }); + let gain = sets(&simulator, GAIN_POINTER)[0].clone(); + let entries = gain.as_array().unwrap(); + assert_eq!(entries.len(), 10); + let only_band_3 = entries + .iter() + .enumerate() + .all(|(band, entry)| (band == 2) != entry.is_null()); + assert!(only_band_3, "{gain}"); + assert!(entries[2].as_f64().unwrap() > 0.0); + let frequency = sets(&simulator, FREQUENCY_POINTER)[0][2].as_f64().unwrap(); + assert!(frequency > 200.0, "{frequency}"); + let history: Vec<_> = harness.state().history().entries().cloned().collect(); + assert_eq!(history.len(), 1); + assert_eq!(history[0].changes.len(), 2); +} + +#[test] +fn a_drag_beyond_the_edge_stays_in_the_limits() { + let (simulator, _directory, mut harness) = open_eq(SimModel::Kh120Ii); + let point = harness.get_by_label(&texts::eq_band(10)).rect().center(); + drag(&mut harness, point, egui::vec2(2000.0, -2000.0)); + wait_until(&mut harness, "the band writes", |_| { + !sets(&simulator, FREQUENCY_POINTER).is_empty() + && !sets(&simulator, GAIN_POINTER).is_empty() + }); + let frequency = sets(&simulator, FREQUENCY_POINTER)[0][9].as_f64().unwrap(); + assert!(frequency <= 24000.0, "{frequency}"); + let gain = sets(&simulator, GAIN_POINTER)[0][9].as_f64().unwrap(); + assert!(gain <= 24.0, "{gain}"); +} + +#[test] +fn a_typed_gain_out_of_range_is_refused() { + let (simulator, _directory, mut harness) = open_eq(SimModel::Kh120Ii); + harness + .query_all_by_role_and_label(Role::SpinButton, GAIN_LABEL) + .next() + .unwrap() + .click(); + harness.run_steps(1); + harness + .query_all_by_role_and_label(Role::SpinButton, GAIN_LABEL) + .next() + .unwrap() + .type_text("30"); + harness.key_press(egui::Key::Enter); + harness.run_steps(3); + barrier(&simulator); + assert!(sets(&simulator, GAIN_POINTER).is_empty()); + let range = ValueError::OutOfRange { + min: -99.0, + max: 24.0, + }; + harness.get_by_label_contains(&texts::describe_refusal(&Refusal::Invalid(range))); +} + +#[test] +fn undo_of_a_band_edit_writes_that_band_back() { + let (simulator, _directory, mut harness) = open_eq(SimModel::Kh120Ii); + let point = harness.get_by_label(&texts::eq_band(2)).rect().center(); + drag(&mut harness, point, egui::vec2(0.0, -40.0)); + wait_until(&mut harness, "the gain write", |_| { + !sets(&simulator, GAIN_POINTER).is_empty() + }); + let id = harness.state().history().newest_open().unwrap(); + harness.state_mut().request_take_back(id); + wait_until(&mut harness, "the gain written back", |_| { + sets(&simulator, GAIN_POINTER).len() == 2 + }); + let mut expected = vec![Value::Null; 10]; + expected[1] = json!(0.0); + assert_eq!(sets(&simulator, GAIN_POINTER)[1], Value::Array(expected)); +} + +#[test] +fn a_read_only_container_has_no_editable_fields() { + let (_simulator, _directory, mut harness) = open_eq(SimModel::Kh120Ii); + harness.get_by_label("EQ 3").click(); + harness.run_steps(2); + assert_eq!(gain_fields(&harness), vec![true; 20]); +} + +#[test] +fn the_crossover_is_shown_on_the_kh750() { + let (_simulator, _directory, harness) = open_eq(SimModel::Kh750); + harness.get_by_label("Weiche 1"); + harness.get_by_label(texts::EQ_OUTPUT_ALL.get()); +} + +#[test] +fn an_unreachable_speaker_greys_the_window() { + let (simulator, _directory, mut harness) = open_eq(SimModel::Kh120Ii); + simulator.stop(); + wait_until(&mut harness, "the fields greyed", |harness| { + gain_fields(harness).iter().all(|disabled| *disabled) + }); +} + +#[test] +fn a_band_change_reads_as_one_band_in_the_history() { + let (simulator, _directory, mut harness) = open_eq(SimModel::Kh120Ii); + let point = harness.get_by_label(&texts::eq_band(2)).rect().center(); + drag(&mut harness, point, egui::vec2(0.0, -40.0)); + wait_until(&mut harness, "the gain write", |_| { + !sets(&simulator, GAIN_POINTER).is_empty() + }); + harness.get_by_label(texts::CLOSE.get()).click(); + harness.run_steps(2); + harness.get_by_label(texts::HISTORY.get()).click(); + harness.run_steps(2); + harness.get_by_label_contains(&texts::eq_change_setting("EQ 2", 2, GAIN_LABEL)); +} diff --git a/crates/app/tests/support/mod.rs b/crates/app/tests/support/mod.rs index c769a8c..78b070d 100644 --- a/crates/app/tests/support/mod.rs +++ b/crates/app/tests/support/mod.rs @@ -181,9 +181,18 @@ pub fn level_sets(simulator: &Simulator) -> Vec { pub fn connected_speaker( model: SimModel, +) -> (Simulator, tempfile::TempDir, Harness<'static, NeumannApp>) { + connected_speaker_with(model, |_| {}) +} + +/// Like `connected_speaker`, with values set on the simulator before the app first reads them. +pub fn connected_speaker_with( + model: SimModel, + prepare: impl FnOnce(&Simulator), ) -> (Simulator, tempfile::TempDir, Harness<'static, NeumannApp>) { let simulator = Simulator::start(model); simulator.set_reply_delay(REPLY_DELAY); + prepare(&simulator); let directory = tempfile::tempdir().unwrap(); let path = settings_file(directory.path(), vec![manual_for(&simulator)]); let mut harness = harness(path, Vec::new()); From 20ae50bcb017ff51d69dbb0dab7eddd3b3269e5c Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:06:22 +0200 Subject: [PATCH 130/460] Document the EQ window --- CHANGELOG.md | 2 ++ README.md | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ce9654..032f8fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- An EQ window per speaker: a computed response graph of every EQ and their sum, band points to + drag, and a table of the bands. Each change writes one band only and can be undone. - A history of every setting the app wrote in this session (**History** in the toolbar), with **Undo** on each entry and Ctrl+Z for the newest. A group or master move is one entry. When a value changed since, the app asks before writing the old one back. diff --git a/README.md b/README.md index 15da26f..d07d45f 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ panel of one KH 750 open. The interface is shown in German.* - **A device panel** per speaker with every setting the catalog knows for its model. - **Groups and a master**: move or mute several speakers at once, and link settings within a group. +- **EQ** in a window of its own: a computed response graph and a table of the bands. - **History and undo**: every write of the session, each one revertible, Ctrl+Z for the newest. - **Compact mode** (Ctrl+K): one row per speaker or group in a small window. - **German and English** interface, light and dark theme. @@ -178,6 +179,23 @@ bottom. A group row moves its members like the group strip. A **!** stands for a The compact window keeps its own size and position and scrolls sideways when it is narrower than its rows. Scanning and adding speakers or groups stay on the desk. +### EQ + +**EQ** in a speaker's device panel opens its EQ in a window of its own (one per speaker; it can +be moved to another screen). On the KH 750 choose the output first (Overall, Output 1, +Output 2); then each EQ of that output has a tab: "EQ 2", "EQ 3", and on the KH 750 the +crossover ("Crossover 1", "Crossover 2"). + +- The graph shows every EQ of the output as its own curve and their sum as a thicker line, from + 10 Hz to 24 kHz. The curves are computed from the settings, not measured. A band's level is + its gain plus its boost. +- Each band of the chosen EQ is a point: filled when on, hollow when off. Drag it to change + frequency and gain, turn the mouse wheel over it to change Q. +- The table below lists every band with on/off, type, frequency, Q, gain and boost; numbers take + a comma or a point, and a value outside the allowed range is refused with the reason. +- A change is written when the gesture ends, and only for that one band, so the other bands + stay as they are. Every change appears in the history and can be undone. + ### History and undo **History** in the toolbar lists every setting the app wrote in this session, newest first, with @@ -262,6 +280,8 @@ fall back to their defaults, the rest is kept, and a copy of the file is kept as - On the KH 750, auto standby is shown but not written: it follows the AUTO STANDBY switch on the back, and the speaker refuses writes to it. - Models other than the KH 120 II and the KH 750 are read-only. +- EQ writes are verified on "EQ 2" of the output (`audio/out/eq2`) so far; the other EQs and the + crossover are shown but not written yet. - Only IPv6 link-local addressing is supported; IPv4 is not. - Windows only. From c873eae640937600900f2e21bee75a21ce765edc Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:26:53 +0200 Subject: [PATCH 131/460] Treat EQ gain as flat and boost as the filter Measured on the KH 750: gain lifts every frequency, boost is the peak or shelf height, and the band pass peaks at 20 log Q. A drag now moves boost; the crossovers are named after their inputs A and B. --- README.md | 12 ++++---- crates/app/src/app/eq.rs | 4 +-- crates/app/src/eq.rs | 8 ------ crates/app/src/eq_response.rs | 49 +++++++++++++++++++++----------- crates/app/src/views/eq_graph.rs | 15 +++++----- crates/app/tests/eq.rs | 29 +++++++++++-------- crates/core/src/catalog.rs | 9 +++--- crates/core/src/paths.toml | 26 ++++++++--------- 8 files changed, 85 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index d07d45f..e2c2f2c 100644 --- a/README.md +++ b/README.md @@ -184,14 +184,16 @@ its rows. Scanning and adding speakers or groups stay on the desk. **EQ** in a speaker's device panel opens its EQ in a window of its own (one per speaker; it can be moved to another screen). On the KH 750 choose the output first (Overall, Output 1, Output 2); then each EQ of that output has a tab: "EQ 2", "EQ 3", and on the KH 750 the -crossover ("Crossover 1", "Crossover 2"). +crossovers of input A and input B ("Crossover A", "Crossover B"). - The graph shows every EQ of the output as its own curve and their sum as a thicker line, from - 10 Hz to 24 kHz. The curves are computed from the settings, not measured. A band's level is - its gain plus its boost. + 10 Hz to 24 kHz. The curves are computed from the settings, not measured. Each band has two + levels: **Level** lifts or lowers every frequency while the band is on, **Boost** is the + height of its filter (the peak or shelf); at the band's frequency both add up. - Each band of the chosen EQ is a point: filled when on, hollow when off. Drag it to change - frequency and gain, turn the mouse wheel over it to change Q. -- The table below lists every band with on/off, type, frequency, Q, gain and boost; numbers take + frequency and boost, turn the mouse wheel over it to change Q. Level is changed in the table + only, so a drag never moves the whole spectrum. +- The table below lists every band with on/off, type, frequency, Q, level and boost; numbers take a comma or a point, and a value outside the allowed range is refused with the reason. - A change is written when the gesture ends, and only for that one band, so the other bands stay as they are. Every change appears in the history and can be undone. diff --git a/crates/app/src/app/eq.rs b/crates/app/src/app/eq.rs index fe2584c..47ef5de 100644 --- a/crates/app/src/app/eq.rs +++ b/crates/app/src/app/eq.rs @@ -218,9 +218,9 @@ impl NeumannApp { EqAction::Graph(GraphAction::Move { band, frequency, - gain, + boost, }) => { - let writes = [(Leaf::Frequency, frequency), (Leaf::Gain, gain)]; + let writes = [(Leaf::Frequency, frequency), (Leaf::Boost, boost)]; for (leaf, value) in writes { if let Some(value) = value { self.write_band(index, band, leaf, Value::from(value), now); diff --git a/crates/app/src/eq.rs b/crates/app/src/eq.rs index 098cedb..eee972d 100644 --- a/crates/app/src/eq.rs +++ b/crates/app/src/eq.rs @@ -55,13 +55,6 @@ pub struct Band { pub kind: String, } -impl Band { - /// Measured on the KH 750: boost acts like gain and adds to it. - pub fn effective_gain(&self) -> f64 { - self.gain + self.boost - } -} - /// The bands of `container`; `None` unless all six leaves are reported as arrays of one length /// with the right element types, so a strange report is shown read-only and never written. pub fn bands_of(values: &BTreeMap, container: &SscPath) -> Option> { @@ -179,7 +172,6 @@ mod tests { } ); assert_eq!(bands[1].kind, "NOTCH"); - assert_eq!(bands[0].effective_gain(), 4.0); } #[test] diff --git a/crates/app/src/eq_response.rs b/crates/app/src/eq_response.rs index ad5c35b..999ff40 100644 --- a/crates/app/src/eq_response.rs +++ b/crates/app/src/eq_response.rs @@ -1,5 +1,6 @@ //! The magnitude response of EQ bands, computed from their settings (not measured): analog -//! prototypes of the usual filter shapes, so no sample rate has to be assumed. +//! prototypes of the usual filter shapes, so no sample rate has to be assumed. Measured on the +//! KH 750: a band's `gain` lifts every frequency, its `boost` is the height of the filter. use crate::eq::Band; @@ -34,17 +35,17 @@ pub fn band_db(band: &Band, frequency: f64) -> f64 { return 0.0; } let x = frequency / band.frequency; - let Some((scale, numerator, denominator)) = transfer(band) else { - return 0.0; - }; - let magnitude = scale * numerator.magnitude(x) / denominator.magnitude(x); - 20.0 * magnitude.log10() + let shape = transfer(band).map_or(0.0, |(scale, numerator, denominator)| { + let magnitude = scale * numerator.magnitude(x) / denominator.magnitude(x); + 20.0 * magnitude.log10() + }); + band.gain + shape } /// Numerator and denominator of the shape (RBJ audio EQ cookbook, analog form), with a factor. fn transfer(band: &Band) -> Option<(f64, Quadratic, Quadratic)> { let q = band.q; - let a = 10f64.powf(band.effective_gain() / 40.0); + let a = 10f64.powf(band.boost / 40.0); let resonant = Quadratic::new(1.0, 1.0 / q, 1.0); let first_order = Quadratic::new(0.0, 1.0, 1.0); let shelf_slope = a.sqrt() / q; @@ -66,7 +67,8 @@ fn transfer(band: &Band) -> Option<(f64, Quadratic, Quadratic)> { ), "LOWPASS" => (1.0, Quadratic::new(0.0, 0.0, 1.0), resonant), "HIGHPASS" => (1.0, Quadratic::new(1.0, 0.0, 0.0), resonant), - "BANDPASS" => (1.0, Quadratic::new(0.0, 1.0 / q, 0.0), resonant), + // Measured: the centre is 20·log10(Q) dB, the constant-skirt form. + "BANDPASS" => (1.0, Quadratic::new(0.0, 1.0, 0.0), resonant), "NOTCH" => (1.0, Quadratic::new(1.0, 0.0, 1.0), resonant), "LO6DB" => (1.0, Quadratic::new(0.0, 0.0, 1.0), first_order), "HI6DB" => (1.0, Quadratic::new(0.0, 1.0, 0.0), first_order), @@ -99,11 +101,12 @@ mod tests { use super::*; - fn band(kind: &str, gain: f64, frequency: f64, q: f64) -> Band { + /// A band with `boost`, the filter's height; its flat `gain` is 0. + fn band(kind: &str, boost: f64, frequency: f64, q: f64) -> Band { Band { enabled: true, - gain, - boost: 0.0, + gain: 0.0, + boost, frequency, q, kind: kind.to_owned(), @@ -155,7 +158,9 @@ mod tests { #[test] fn notch_and_band_pass() { assert!(band_db(&band("NOTCH", 0.0, 1000.0, 2.0), 1000.0) < -60.0); - close(band_db(&band("BANDPASS", 0.0, 1000.0, 2.0), 1000.0), 0.0); + // Measured on the KH 750: the band pass peaks at 20·log10(Q), +6 dB for Q 2. + close(band_db(&band("BANDPASS", 0.0, 50.0, 2.0), 50.0), 6.02); + close(band_db(&band("BANDPASS", 0.0, 50.0, 2.0), 200.0), -11.56); } #[test] @@ -166,10 +171,22 @@ mod tests { } #[test] - fn boost_adds_to_gain() { - let mut boosted = band("PARAMETRIC", 3.0, 1000.0, 1.0); - boosted.boost = 3.0; - close(band_db(&boosted, 1000.0), 6.0); + fn gain_lifts_every_frequency() { + let mut lifted = band("PARAMETRIC", 0.0, 1000.0, 1.0); + lifted.gain = 6.0; + close(band_db(&lifted, 10.0), 6.0); + close(band_db(&lifted, 20000.0), 6.0); + let mut low_pass = band("LOWPASS", 0.0, 200.0, FRAC_1_SQRT_2); + low_pass.gain = 6.0; + close(band_db(&low_pass, 20.0), 6.0); + } + + #[test] + fn gain_and_boost_add_at_the_band_frequency() { + let mut both = band("PARAMETRIC", 3.0, 1000.0, 1.0); + both.gain = 3.0; + close(band_db(&both, 1000.0), 6.0); + close(band_db(&both, 10.0), 3.0); } #[test] diff --git a/crates/app/src/views/eq_graph.rs b/crates/app/src/views/eq_graph.rs index d716e9e..1908ce9 100644 --- a/crates/app/src/views/eq_graph.rs +++ b/crates/app/src/views/eq_graph.rs @@ -62,11 +62,12 @@ pub struct Graph<'a> { #[derive(Clone, Copy, Debug, PartialEq)] pub enum GraphAction { Select(usize), - /// A drag ended; only the values it changed are given. + /// A drag ended; only the values it changed are given. A drag moves the filter (`boost`); + /// the band's flat `gain` stays. Move { band: usize, frequency: Option, - gain: Option, + boost: Option, }, Q { band: usize, @@ -255,15 +256,15 @@ fn moved( ) -> Option { let frequency = snapped_frequency(axes.frequency(end.x)); let range = &graph.gain_range; - let gain = - on_grid(axes.db(end.y) - point.boost, GAIN_GRID_DB).clamp(*range.start(), *range.end()); + let boost = on_grid(axes.db(end.y) - point.gain, GAIN_GRID_DB); + let boost = boost.clamp(*range.start(), *range.end()); let frequency = (frequency != snapped_frequency(point.frequency)).then_some(frequency); - let gain = (gain != on_grid(point.gain, GAIN_GRID_DB)).then_some(gain); - let changed = frequency.is_some() || gain.is_some(); + let boost = (boost != on_grid(point.boost, GAIN_GRID_DB)).then_some(boost); + let changed = frequency.is_some() || boost.is_some(); changed.then_some(GraphAction::Move { band, frequency, - gain, + boost, }) } diff --git a/crates/app/tests/eq.rs b/crates/app/tests/eq.rs index c6eca29..4c37f28 100644 --- a/crates/app/tests/eq.rs +++ b/crates/app/tests/eq.rs @@ -14,8 +14,10 @@ use neumann_control_core::simulator::{SimModel, Simulator}; use serde_json::{json, Value}; use support::*; -const GAIN_LABEL: &str = "Verstärkung"; +const GAIN_LABEL: &str = "Pegel"; +const BOOST_LABEL: &str = "Anhebung"; const GAIN_POINTER: &str = "/audio/out/eq2/gain"; +const BOOST_POINTER: &str = "/audio/out/eq2/boost"; const FREQUENCY_POINTER: &str = "/audio/out/eq2/frequency"; /// Ten bands spread over the range, so every point has its own place. @@ -58,15 +60,17 @@ fn the_eq_button_opens_the_window_with_both_containers() { } #[test] -fn dragging_a_point_writes_frequency_and_gain_of_that_band_only() { +fn dragging_a_point_writes_frequency_and_boost_of_that_band_only() { let (simulator, _directory, mut harness) = open_eq(SimModel::Kh120Ii); let point = harness.get_by_label(&texts::eq_band(3)).rect().center(); drag(&mut harness, point, egui::vec2(30.0, -40.0)); wait_until(&mut harness, "the band writes", |_| { - !sets(&simulator, GAIN_POINTER).is_empty() + !sets(&simulator, BOOST_POINTER).is_empty() && !sets(&simulator, FREQUENCY_POINTER).is_empty() }); - let gain = sets(&simulator, GAIN_POINTER)[0].clone(); + // The flat gain stays: dragging a point shapes the filter, not the level of everything. + assert!(sets(&simulator, GAIN_POINTER).is_empty()); + let gain = sets(&simulator, BOOST_POINTER)[0].clone(); let entries = gain.as_array().unwrap(); assert_eq!(entries.len(), 10); let only_band_3 = entries @@ -89,11 +93,11 @@ fn a_drag_beyond_the_edge_stays_in_the_limits() { drag(&mut harness, point, egui::vec2(2000.0, -2000.0)); wait_until(&mut harness, "the band writes", |_| { !sets(&simulator, FREQUENCY_POINTER).is_empty() - && !sets(&simulator, GAIN_POINTER).is_empty() + && !sets(&simulator, BOOST_POINTER).is_empty() }); let frequency = sets(&simulator, FREQUENCY_POINTER)[0][9].as_f64().unwrap(); assert!(frequency <= 24000.0, "{frequency}"); - let gain = sets(&simulator, GAIN_POINTER)[0][9].as_f64().unwrap(); + let gain = sets(&simulator, BOOST_POINTER)[0][9].as_f64().unwrap(); assert!(gain <= 24.0, "{gain}"); } @@ -128,16 +132,16 @@ fn undo_of_a_band_edit_writes_that_band_back() { let point = harness.get_by_label(&texts::eq_band(2)).rect().center(); drag(&mut harness, point, egui::vec2(0.0, -40.0)); wait_until(&mut harness, "the gain write", |_| { - !sets(&simulator, GAIN_POINTER).is_empty() + !sets(&simulator, BOOST_POINTER).is_empty() }); let id = harness.state().history().newest_open().unwrap(); harness.state_mut().request_take_back(id); wait_until(&mut harness, "the gain written back", |_| { - sets(&simulator, GAIN_POINTER).len() == 2 + sets(&simulator, BOOST_POINTER).len() == 2 }); let mut expected = vec![Value::Null; 10]; expected[1] = json!(0.0); - assert_eq!(sets(&simulator, GAIN_POINTER)[1], Value::Array(expected)); + assert_eq!(sets(&simulator, BOOST_POINTER)[1], Value::Array(expected)); } #[test] @@ -151,7 +155,8 @@ fn a_read_only_container_has_no_editable_fields() { #[test] fn the_crossover_is_shown_on_the_kh750() { let (_simulator, _directory, harness) = open_eq(SimModel::Kh750); - harness.get_by_label("Weiche 1"); + harness.get_by_label("Weiche A"); + harness.get_by_label("Weiche B"); harness.get_by_label(texts::EQ_OUTPUT_ALL.get()); } @@ -170,11 +175,11 @@ fn a_band_change_reads_as_one_band_in_the_history() { let point = harness.get_by_label(&texts::eq_band(2)).rect().center(); drag(&mut harness, point, egui::vec2(0.0, -40.0)); wait_until(&mut harness, "the gain write", |_| { - !sets(&simulator, GAIN_POINTER).is_empty() + !sets(&simulator, BOOST_POINTER).is_empty() }); harness.get_by_label(texts::CLOSE.get()).click(); harness.run_steps(2); harness.get_by_label(texts::HISTORY.get()).click(); harness.run_steps(2); - harness.get_by_label_contains(&texts::eq_change_setting("EQ 2", 2, GAIN_LABEL)); + harness.get_by_label_contains(&texts::eq_change_setting("EQ 2", 2, BOOST_LABEL)); } diff --git a/crates/core/src/catalog.rs b/crates/core/src/catalog.rs index 2db48fa..a7bf93d 100644 --- a/crates/core/src/catalog.rs +++ b/crates/core/src/catalog.rs @@ -391,7 +391,8 @@ impl EqContainer { } } -/// One of the six leaves every EQ container has. +/// One of the six leaves every EQ container has. Measured on the KH 750: `gain` lifts every +/// frequency ("Pegel"), `boost` is the filter's height ("Anhebung"). struct EqLeaf { name: &'static str, label: &'static str, @@ -420,8 +421,8 @@ const EQ_LEAVES: [EqLeaf; 6] = [ }, EqLeaf { name: "gain", - label: "Verstärkung", - label_en: "Gain", + label: "Pegel", + label_en: "Level", kind: Kind::Number, min: Some(EQ_GAIN_MIN_DB), max: Some(EQ_GAIN_MAX_DB), @@ -430,7 +431,7 @@ const EQ_LEAVES: [EqLeaf; 6] = [ }, EqLeaf { name: "boost", - label: "Boost", + label: "Anhebung", label_en: "Boost", kind: Kind::Number, min: Some(EQ_GAIN_MIN_DB), diff --git a/crates/core/src/paths.toml b/crates/core/src/paths.toml index e24f018..5d57089 100644 --- a/crates/core/src/paths.toml +++ b/crates/core/src/paths.toml @@ -542,7 +542,7 @@ class = "subwoofer" # EQ containers (measured 2026-10-19: KH 120 II eq2 10 bands, eq3 20; KH 750 eq2/eq3 10 on -# out, out1, out2, eq1/in1|in2 2 = crossover). Writes measured on `audio/out/eq2` only so far. +# out, out1, out2, eq1/in1|in2 2 = crossover of input A|B). Writes measured on `audio/out/eq2` only so far. [[eq]] container = "audio/out/eq2" @@ -559,14 +559,14 @@ class = "all" [[eq]] container = "audio/out/eq1/in1" -label = "Weiche 1" -label_en = "Crossover 1" +label = "Weiche A" +label_en = "Crossover A" class = "subwoofer" [[eq]] container = "audio/out/eq1/in2" -label = "Weiche 2" -label_en = "Crossover 2" +label = "Weiche B" +label_en = "Crossover B" class = "subwoofer" [[eq]] @@ -583,14 +583,14 @@ class = "subwoofer" [[eq]] container = "audio/out1/eq1/in1" -label = "Weiche 1" -label_en = "Crossover 1" +label = "Weiche A" +label_en = "Crossover A" class = "subwoofer" [[eq]] container = "audio/out1/eq1/in2" -label = "Weiche 2" -label_en = "Crossover 2" +label = "Weiche B" +label_en = "Crossover B" class = "subwoofer" [[eq]] @@ -607,12 +607,12 @@ class = "subwoofer" [[eq]] container = "audio/out2/eq1/in1" -label = "Weiche 1" -label_en = "Crossover 1" +label = "Weiche A" +label_en = "Crossover A" class = "subwoofer" [[eq]] container = "audio/out2/eq1/in2" -label = "Weiche 2" -label_en = "Crossover 2" +label = "Weiche B" +label_en = "Crossover B" class = "subwoofer" From 75eb5d27f6ddfa1995a3cb553e278bc43e865e8b Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:30:39 +0200 Subject: [PATCH 132/460] Open the measured EQ containers for writing A write probe wrote one band, left the others alone and restored it in every EQ container except the crossovers of outputs 1 and 2. --- README.md | 4 ++-- crates/app/tests/eq.rs | 16 +++++++++++++++- crates/core/src/paths.toml | 10 +++++++++- crates/core/tests/catalog.rs | 6 +++++- 4 files changed, 31 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e2c2f2c..bcdc4a6 100644 --- a/README.md +++ b/README.md @@ -282,8 +282,8 @@ fall back to their defaults, the rest is kept, and a copy of the file is kept as - On the KH 750, auto standby is shown but not written: it follows the AUTO STANDBY switch on the back, and the speaker refuses writes to it. - Models other than the KH 120 II and the KH 750 are read-only. -- EQ writes are verified on "EQ 2" of the output (`audio/out/eq2`) so far; the other EQs and the - crossover are shown but not written yet. +- On the KH 750 the crossovers of Output 1 and Output 2 are shown but not written yet; their + writes are untested. Every other EQ can be edited. - Only IPv6 link-local addressing is supported; IPv4 is not. - Windows only. diff --git a/crates/app/tests/eq.rs b/crates/app/tests/eq.rs index 4c37f28..8ef7f26 100644 --- a/crates/app/tests/eq.rs +++ b/crates/app/tests/eq.rs @@ -146,10 +146,24 @@ fn undo_of_a_band_edit_writes_that_band_back() { #[test] fn a_read_only_container_has_no_editable_fields() { + // The crossovers of outputs 1 and 2 are not measured yet and stay read-only. + let (_simulator, _directory, mut harness) = open_eq(SimModel::Kh750); + // "Ausgang 1" is also a heading in the device panel; the choice is a button. + harness + .get_by_role_and_label(Role::Button, texts::EQ_OUTPUT_1.get()) + .click(); + harness.run_steps(2); + harness.get_by_label("Weiche A").click(); + harness.run_steps(2); + assert_eq!(gain_fields(&harness), vec![true; 2]); +} + +#[test] +fn the_kh120_eq3_can_be_edited() { let (_simulator, _directory, mut harness) = open_eq(SimModel::Kh120Ii); harness.get_by_label("EQ 3").click(); harness.run_steps(2); - assert_eq!(gain_fields(&harness), vec![true; 20]); + assert_eq!(gain_fields(&harness), vec![false; 20]); } #[test] diff --git a/crates/core/src/paths.toml b/crates/core/src/paths.toml index 5d57089..3b71e7f 100644 --- a/crates/core/src/paths.toml +++ b/crates/core/src/paths.toml @@ -542,7 +542,8 @@ class = "subwoofer" # EQ containers (measured 2026-10-19: KH 120 II eq2 10 bands, eq3 20; KH 750 eq2/eq3 10 on -# out, out1, out2, eq1/in1|in2 2 = crossover of input A|B). Writes measured on `audio/out/eq2` only so far. +# out, out1, out2, eq1/in1|in2 2 = crossover of input A|B). Writes measured (write, read back, +# restore) on every container except the crossovers of out1 and out2. [[eq]] container = "audio/out/eq2" @@ -556,30 +557,35 @@ container = "audio/out/eq3" label = "EQ 3" label_en = "EQ 3" class = "all" +writable_on = ["KH 120 II", "KH 750"] [[eq]] container = "audio/out/eq1/in1" label = "Weiche A" label_en = "Crossover A" class = "subwoofer" +writable_on = ["KH 750"] [[eq]] container = "audio/out/eq1/in2" label = "Weiche B" label_en = "Crossover B" class = "subwoofer" +writable_on = ["KH 750"] [[eq]] container = "audio/out1/eq2" label = "EQ 2" label_en = "EQ 2" class = "subwoofer" +writable_on = ["KH 750"] [[eq]] container = "audio/out1/eq3" label = "EQ 3" label_en = "EQ 3" class = "subwoofer" +writable_on = ["KH 750"] [[eq]] container = "audio/out1/eq1/in1" @@ -598,12 +604,14 @@ container = "audio/out2/eq2" label = "EQ 2" label_en = "EQ 2" class = "subwoofer" +writable_on = ["KH 750"] [[eq]] container = "audio/out2/eq3" label = "EQ 3" label_en = "EQ 3" class = "subwoofer" +writable_on = ["KH 750"] [[eq]] container = "audio/out2/eq1/in1" diff --git a/crates/core/tests/catalog.rs b/crates/core/tests/catalog.rs index e6dfa15..4e0f26e 100644 --- a/crates/core/tests/catalog.rs +++ b/crates/core/tests/catalog.rs @@ -329,7 +329,11 @@ fn eq_containers_per_model() { assert!(has(MONITOR, "audio/out/eq3/frequency")); assert!(!has(MONITOR, "audio/out1/eq2/gain")); assert!(entry("audio/out/eq2/gain").is_writable(SUBWOOFER)); - assert!(!entry("audio/out/eq3/gain").is_writable(MONITOR)); + // Writes measured 2026-10-19 on every container except the crossovers of outputs 1 and 2. + assert!(entry("audio/out/eq3/gain").is_writable(MONITOR)); + assert!(entry("audio/out2/eq3/gain").is_writable(SUBWOOFER)); + assert!(entry("audio/out/eq1/in2/gain").is_writable(SUBWOOFER)); + assert!(!entry("audio/out1/eq1/in1/gain").is_writable(SUBWOOFER)); assert!(entry("audio/out/eq2/gain").bands); } From 11ad35b1767ee38fcbf8a5ca8abffe6fa52fe34e Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:37:42 +0200 Subject: [PATCH 133/460] Open the crossovers of outputs 1 and 2 for writing The write probe wrote one band, left the others alone and restored it on all four. --- README.md | 2 -- crates/app/tests/eq.rs | 5 ++--- crates/core/src/paths.toml | 6 +++++- crates/core/tests/catalog.rs | 6 ++++-- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index bcdc4a6..20fcf3e 100644 --- a/README.md +++ b/README.md @@ -282,8 +282,6 @@ fall back to their defaults, the rest is kept, and a copy of the file is kept as - On the KH 750, auto standby is shown but not written: it follows the AUTO STANDBY switch on the back, and the speaker refuses writes to it. - Models other than the KH 120 II and the KH 750 are read-only. -- On the KH 750 the crossovers of Output 1 and Output 2 are shown but not written yet; their - writes are untested. Every other EQ can be edited. - Only IPv6 link-local addressing is supported; IPv4 is not. - Windows only. diff --git a/crates/app/tests/eq.rs b/crates/app/tests/eq.rs index 8ef7f26..a7bcb65 100644 --- a/crates/app/tests/eq.rs +++ b/crates/app/tests/eq.rs @@ -145,8 +145,7 @@ fn undo_of_a_band_edit_writes_that_band_back() { } #[test] -fn a_read_only_container_has_no_editable_fields() { - // The crossovers of outputs 1 and 2 are not measured yet and stay read-only. +fn the_crossovers_of_the_outputs_can_be_edited() { let (_simulator, _directory, mut harness) = open_eq(SimModel::Kh750); // "Ausgang 1" is also a heading in the device panel; the choice is a button. harness @@ -155,7 +154,7 @@ fn a_read_only_container_has_no_editable_fields() { harness.run_steps(2); harness.get_by_label("Weiche A").click(); harness.run_steps(2); - assert_eq!(gain_fields(&harness), vec![true; 2]); + assert_eq!(gain_fields(&harness), vec![false; 2]); } #[test] diff --git a/crates/core/src/paths.toml b/crates/core/src/paths.toml index 3b71e7f..aac29a6 100644 --- a/crates/core/src/paths.toml +++ b/crates/core/src/paths.toml @@ -543,7 +543,7 @@ class = "subwoofer" # EQ containers (measured 2026-10-19: KH 120 II eq2 10 bands, eq3 20; KH 750 eq2/eq3 10 on # out, out1, out2, eq1/in1|in2 2 = crossover of input A|B). Writes measured (write, read back, -# restore) on every container except the crossovers of out1 and out2. +# restore) on every container. [[eq]] container = "audio/out/eq2" @@ -592,12 +592,14 @@ container = "audio/out1/eq1/in1" label = "Weiche A" label_en = "Crossover A" class = "subwoofer" +writable_on = ["KH 750"] [[eq]] container = "audio/out1/eq1/in2" label = "Weiche B" label_en = "Crossover B" class = "subwoofer" +writable_on = ["KH 750"] [[eq]] container = "audio/out2/eq2" @@ -618,9 +620,11 @@ container = "audio/out2/eq1/in1" label = "Weiche A" label_en = "Crossover A" class = "subwoofer" +writable_on = ["KH 750"] [[eq]] container = "audio/out2/eq1/in2" label = "Weiche B" label_en = "Crossover B" class = "subwoofer" +writable_on = ["KH 750"] diff --git a/crates/core/tests/catalog.rs b/crates/core/tests/catalog.rs index 4e0f26e..625563e 100644 --- a/crates/core/tests/catalog.rs +++ b/crates/core/tests/catalog.rs @@ -329,11 +329,13 @@ fn eq_containers_per_model() { assert!(has(MONITOR, "audio/out/eq3/frequency")); assert!(!has(MONITOR, "audio/out1/eq2/gain")); assert!(entry("audio/out/eq2/gain").is_writable(SUBWOOFER)); - // Writes measured 2026-10-19 on every container except the crossovers of outputs 1 and 2. + // Writes measured 2026-10-19 on every container of both models. assert!(entry("audio/out/eq3/gain").is_writable(MONITOR)); assert!(entry("audio/out2/eq3/gain").is_writable(SUBWOOFER)); assert!(entry("audio/out/eq1/in2/gain").is_writable(SUBWOOFER)); - assert!(!entry("audio/out1/eq1/in1/gain").is_writable(SUBWOOFER)); + assert!(entry("audio/out1/eq1/in1/gain").is_writable(SUBWOOFER)); + assert!(entry("audio/out2/eq1/in2/gain").is_writable(SUBWOOFER)); + assert!(!entry("audio/out/eq2/gain").is_writable(UNKNOWN_PRODUCT)); assert!(entry("audio/out/eq2/gain").bands); } From 1216b14ef5804f62ee4c71f0cbfa5f4ace891e66 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:01:35 +0200 Subject: [PATCH 134/460] Let build-release.ps1 write to a given folder The release folder is named neumann-control-; -OutputRoot lets a local build script put it outside the repository. --- README.md | 3 ++- build-release.ps1 | 21 ++++++++++++++++----- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 20fcf3e..f94f541 100644 --- a/README.md +++ b/README.md @@ -297,7 +297,8 @@ The executable is `target\release\neumann-control.exe`. `check.ps1` runs the quality gates: format, licence check, `cargo audit`, clippy and the tests. It needs `cargo-audit` and `cargo-about`. `build-release.ps1` runs them on a clean working tree -and writes `release\\` with the executable, `LICENSE`, `THIRD-PARTY-LICENSES.html` and +and writes `release\neumann-control-\` (or `-OutputRoot `) with the +executable, `LICENSE`, `THIRD-PARTY-LICENSES.html` and `SHA256SUMS`. The workspace has two crates: `crates/core` (the SSC client, discovery, the path catalog, diff --git a/build-release.ps1 b/build-release.ps1 index ca04a9d..374a6f7 100644 --- a/build-release.ps1 +++ b/build-release.ps1 @@ -1,11 +1,14 @@ <# .SYNOPSIS - Builds a release binary and drops it into release\\. The version is the one in - the workspace Cargo.toml. A release folder that already holds files is refused unless - -Force is given. + Builds a release binary and drops it into \neumann-control-\. The + version is the one in the workspace Cargo.toml; OutputRoot defaults to release\ in the + repository. A release folder that already holds files is refused unless -Force is given. #> [CmdletBinding()] -param([switch] $Force) +param( + [string] $OutputRoot = 'release', + [switch] $Force +) $ErrorActionPreference = 'Stop' Set-Location $PSScriptRoot @@ -63,7 +66,15 @@ $version = Get-WorkspaceVersion # Checked before the gate: a rerun must not silently replace an exe already built. # Absolute: .NET file calls resolve relative paths against the process directory, not Set-Location. -$releaseDirectory = Join-Path $PSScriptRoot (Join-Path 'release' $version) +$root = if ([System.IO.Path]::IsPathRooted($OutputRoot)) { $OutputRoot } else { Join-Path $PSScriptRoot $OutputRoot } +# Only the default root (ignored by git) is created; a given root must exist, so a typo does +# not invent a folder somewhere. +if ($OutputRoot -eq 'release') { New-Item -ItemType Directory -Force -Path $root | Out-Null } +if (-not (Test-Path -LiteralPath $root -PathType Container)) { + Write-Host "FAILED: output root $root does not exist" -ForegroundColor Red + exit 1 +} +$releaseDirectory = Join-Path (Resolve-Path -LiteralPath $root).Path "neumann-control-$version" $holdsEarlierBuild = (Test-Path -LiteralPath $releaseDirectory) -and @(Get-ChildItem -LiteralPath $releaseDirectory -Force).Count -gt 0 if ($holdsEarlierBuild -and -not $Force) { Write-Host "FAILED: $releaseDirectory already holds files; pass -Force to overwrite them" -ForegroundColor Red From 61f19321ddb915685a7e19f3c554838533ada440 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:49:06 +0200 Subject: [PATCH 135/460] Name the KH 750 outputs Subwoofer, Output A and Output B Measured: an EQ on out acts on the woofer only, out1 and out2 move the meters of sockets A and B. --- CHANGELOG.md | 2 ++ README.md | 4 ++-- crates/app/src/app/eq.rs | 6 +++--- crates/app/src/texts.rs | 8 +++++--- crates/app/tests/details.rs | 4 ++-- crates/app/tests/eq.rs | 7 ++++--- crates/core/src/catalog.rs | 4 ++-- 7 files changed, 20 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 032f8fe..3f93841 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ ### Changed +- On the KH 750 the outputs are named after what they drive: Subwoofer (its own woofer), Output A + and Output B (its two output sockets), formerly Output 1 and Output 2. - The language follows the Windows display language unless one is chosen in the settings. - Standby time and standby level can be set on the KH 750 too. - Wider meters; the held peak is a thin line instead of a lit segment. diff --git a/README.md b/README.md index f94f541..520a9af 100644 --- a/README.md +++ b/README.md @@ -182,8 +182,8 @@ its rows. Scanning and adding speakers or groups stay on the desk. ### EQ **EQ** in a speaker's device panel opens its EQ in a window of its own (one per speaker; it can -be moved to another screen). On the KH 750 choose the output first (Overall, Output 1, -Output 2); then each EQ of that output has a tab: "EQ 2", "EQ 3", and on the KH 750 the +be moved to another screen). On the KH 750 choose the output first (Subwoofer for its own +woofer, Output A and Output B for its two output sockets); then each EQ of that output has a tab: "EQ 2", "EQ 3", and on the KH 750 the crossovers of input A and input B ("Crossover A", "Crossover B"). - The graph shows every EQ of the output as its own curve and their sum as a thicker line, from diff --git a/crates/app/src/app/eq.rs b/crates/app/src/app/eq.rs index 47ef5de..95760f1 100644 --- a/crates/app/src/app/eq.rs +++ b/crates/app/src/app/eq.rs @@ -308,9 +308,9 @@ fn outputs_of<'a>(catalog: &'a Catalog, product: &str) -> Vec<(String, Vec<&'a E fn output_name(output: &str) -> String { let text = match output { - "out1" => texts::EQ_OUTPUT_1, - "out2" => texts::EQ_OUTPUT_2, - _ => texts::EQ_OUTPUT_ALL, + "out1" => texts::EQ_OUTPUT_A, + "out2" => texts::EQ_OUTPUT_B, + _ => texts::EQ_OUTPUT_SUBWOOFER, }; text.get().to_owned() } diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 613e8db..0b46bac 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -166,9 +166,11 @@ pub const EQ_NOT_REPORTED: Text = Text::new( "Der Lautsprecher hat diesen EQ noch nicht gemeldet.", "The speaker has not reported this EQ yet.", ); -pub const EQ_OUTPUT_ALL: Text = Text::new("Gesamt", "Overall"); -pub const EQ_OUTPUT_1: Text = Text::new("Ausgang 1", "Output 1"); -pub const EQ_OUTPUT_2: Text = Text::new("Ausgang 2", "Output 2"); +/// The KH 750's outputs, measured 2026-10-19: `out` drives its own woofer, `out1`/`out2` are the +/// sockets A and B. +pub const EQ_OUTPUT_SUBWOOFER: Text = Text::new("Subwoofer", "Subwoofer"); +pub const EQ_OUTPUT_A: Text = Text::new("Ausgang A", "Output A"); +pub const EQ_OUTPUT_B: Text = Text::new("Ausgang B", "Output B"); pub const BAND: Text = Text::new("Band", "Band"); /// A speaker of a history entry that has been removed since. pub const REMOVED_SPEAKER: Text = Text::new("entfernter Lautsprecher", "removed speaker"); diff --git a/crates/app/tests/details.rs b/crates/app/tests/details.rs index 568b5fd..33b4e8d 100644 --- a/crates/app/tests/details.rs +++ b/crates/app/tests/details.rs @@ -51,8 +51,8 @@ fn role_of(harness: &Harness<'static, NeumannApp>, label: &str) -> Role { #[test] fn kh750_details_show_out1_and_no_phase() { let (_simulator, _directory, harness) = speaker_with_panel(SimModel::Kh750); - harness.get_by_label("Ausgang 1"); - harness.get_by_label("Ausgang 2"); + harness.get_by_label("Ausgang A"); + harness.get_by_label("Ausgang B"); assert!(harness.query_by_label("Phase invertiert").is_none()); } diff --git a/crates/app/tests/eq.rs b/crates/app/tests/eq.rs index a7bcb65..6548a20 100644 --- a/crates/app/tests/eq.rs +++ b/crates/app/tests/eq.rs @@ -147,9 +147,9 @@ fn undo_of_a_band_edit_writes_that_band_back() { #[test] fn the_crossovers_of_the_outputs_can_be_edited() { let (_simulator, _directory, mut harness) = open_eq(SimModel::Kh750); - // "Ausgang 1" is also a heading in the device panel; the choice is a button. + // "Ausgang A" is also a heading in the device panel; the choice is a button. harness - .get_by_role_and_label(Role::Button, texts::EQ_OUTPUT_1.get()) + .get_by_role_and_label(Role::Button, "Ausgang A") .click(); harness.run_steps(2); harness.get_by_label("Weiche A").click(); @@ -170,7 +170,8 @@ fn the_crossover_is_shown_on_the_kh750() { let (_simulator, _directory, harness) = open_eq(SimModel::Kh750); harness.get_by_label("Weiche A"); harness.get_by_label("Weiche B"); - harness.get_by_label(texts::EQ_OUTPUT_ALL.get()); + // Measured: `out` is the KH 750's own driver, `out1`/`out2` the sockets A and B. + harness.get_by_label("Subwoofer"); } #[test] diff --git a/crates/core/src/catalog.rs b/crates/core/src/catalog.rs index a7bf93d..08b3dc6 100644 --- a/crates/core/src/catalog.rs +++ b/crates/core/src/catalog.rs @@ -37,8 +37,8 @@ impl Group { pub fn title(&self, language: Language) -> &'static str { let (german, english) = match self { Group::Output => ("Ausgang", "Output"), - Group::Output1 => ("Ausgang 1", "Output 1"), - Group::Output2 => ("Ausgang 2", "Output 2"), + Group::Output1 => ("Ausgang A", "Output A"), + Group::Output2 => ("Ausgang B", "Output B"), Group::Input => ("Eingang", "Input"), Group::Standby => ("Standby", "Standby"), Group::Device => ("Gerät", "Device"), From eeb644a15048c4b12dc651ed8161c3ee44959de5 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:49:14 +0200 Subject: [PATCH 136/460] Rewrap the EQ paragraph of the README --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 520a9af..27b2add 100644 --- a/README.md +++ b/README.md @@ -183,8 +183,9 @@ its rows. Scanning and adding speakers or groups stay on the desk. **EQ** in a speaker's device panel opens its EQ in a window of its own (one per speaker; it can be moved to another screen). On the KH 750 choose the output first (Subwoofer for its own -woofer, Output A and Output B for its two output sockets); then each EQ of that output has a tab: "EQ 2", "EQ 3", and on the KH 750 the -crossovers of input A and input B ("Crossover A", "Crossover B"). +woofer, Output A and Output B for its two output sockets); then each EQ of that output has a +tab: "EQ 2", "EQ 3", and on the KH 750 the crossovers of input A and input B ("Crossover A", +"Crossover B"). - The graph shows every EQ of the output as its own curve and their sum as a thicker line, from 10 Hz to 24 kHz. The curves are computed from the settings, not measured. Each band has two From cb510f2d8fa466e6c5e40d72c5b26f836f98d798 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:50:13 +0200 Subject: [PATCH 137/460] Open the EQ window from the strip --- crates/app/src/app/desk.rs | 7 ++++++- crates/app/src/eq.rs | 8 ++++++++ crates/app/src/views/panel.rs | 3 ++- crates/app/src/views/strip.rs | 14 ++++++++++--- crates/app/tests/eq.rs | 37 ++++++++++++++++++++++++++++++++++- 5 files changed, 63 insertions(+), 6 deletions(-) diff --git a/crates/app/src/app/desk.rs b/crates/app/src/app/desk.rs index ed8b3a6..e49a249 100644 --- a/crates/app/src/app/desk.rs +++ b/crates/app/src/app/desk.rs @@ -17,7 +17,7 @@ use crate::groups::{ shifted_levels, with_moved, DeskItem, }; use crate::paths; -use crate::state::{SpeakerState, Status}; +use crate::state::{SpeakerKey, SpeakerState, Status}; use crate::texts; use crate::ui_kit::theme::{group_color, group_color_count}; use crate::views::group_strip::{self, GroupStripResponse, MasterResponse}; @@ -35,6 +35,7 @@ pub(super) enum DeskAction { Group(usize, GroupStripResponse), Master(MasterResponse), OpenPanel(OpenPanel), + OpenEq(SpeakerKey), } /// The group being renamed or deleted, and the name typed so far. @@ -92,6 +93,9 @@ impl NeumannApp { .inner; centers.insert(ids[index].clone(), response.rect.center().x); actions.push(DeskAction::Commands(index, response.commands)); + if response.open_eq { + actions.push(DeskAction::OpenEq(key.clone())); + } if let Some(anchor) = response.open_panel { let opened_now = true; let open = OpenPanel { @@ -174,6 +178,7 @@ impl NeumannApp { self.apply_shared(&everyone, response.level, response.mute, catalog, now); } DeskAction::OpenPanel(open) => self.panel = Some(open), + DeskAction::OpenEq(key) => self.open_eq(key), } } diff --git a/crates/app/src/eq.rs b/crates/app/src/eq.rs index eee972d..5d52a2e 100644 --- a/crates/app/src/eq.rs +++ b/crates/app/src/eq.rs @@ -7,6 +7,8 @@ use neumann_control_core::catalog::{Catalog, EqContainer}; use neumann_control_core::ssc::wire::SscPath; use serde_json::Value; +use crate::state::SpeakerState; + /// The six leaves of an EQ container, as the catalog names them. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Leaf { @@ -120,6 +122,12 @@ pub fn container_of<'a>(catalog: &'a Catalog, path: &SscPath) -> Option<&'a EqCo }) } +/// Whether the speaker has an EQ to open: only once it reported its model, because containers +/// of the class "all" match any product, an unknown one too. +pub fn has_eq(catalog: &Catalog, speaker: &SpeakerState) -> bool { + speaker.identity.is_some() && !catalog.eq_containers(speaker.product()).is_empty() +} + /// The value that writes `value` to `band` only (measured: `null` leaves a band as it is). pub fn one_band(length: usize, band: usize, value: Value) -> Value { let mut array = vec![Value::Null; length]; diff --git a/crates/app/src/views/panel.rs b/crates/app/src/views/panel.rs index e070103..38efbd8 100644 --- a/crates/app/src/views/panel.rs +++ b/crates/app/src/views/panel.rs @@ -9,6 +9,7 @@ use neumann_control_core::speaker::Command; use super::details; use crate::display::firmware_version; +use crate::eq::has_eq; use crate::groups::linkable_label; use crate::state::SpeakerState; use crate::texts; @@ -55,7 +56,7 @@ pub fn show( let mut group = None; let mut copy_to_group = None; let mut open_eq = false; - let has_eq = !catalog.eq_containers(speaker.product()).is_empty(); + let has_eq = has_eq(catalog, speaker); // Escape in a field cancels the edit; only Escape with nothing being edited closes. egui // drops the focus at the start of the Escape frame, so the last frame's state decides. let editing_id = Id::new(PANEL_ID).with(EDITING_ID); diff --git a/crates/app/src/views/strip.rs b/crates/app/src/views/strip.rs index 7f40390..70d0b0a 100644 --- a/crates/app/src/views/strip.rs +++ b/crates/app/src/views/strip.rs @@ -14,6 +14,7 @@ use serde_json::Value; use super::fader::{self, Orientation, Scale, FADER_HEIGHT}; use super::knob; use super::meters::{self, Meters}; +use crate::eq::has_eq; use crate::fader::{output_level_range, WHEEL_STEP_DB}; use crate::gesture::on_grid; use crate::input_source::input_source; @@ -49,6 +50,7 @@ pub struct StripResponse { pub open_panel: Option, /// The strip's area, where another strip can be dropped. pub rect: Rect, + pub open_eq: bool, } pub fn show( @@ -61,6 +63,7 @@ pub fn show( ) -> StripResponse { let tokens = Tokens::current(ui.ctx()); let mut commands = Vec::new(); + let mut open_eq = false; let inner_width = STRIP_WIDTH - 2.0 * (f32::from(STRIP_MARGIN) + BORDER_WIDTH); let frame = egui::Frame::new() .fill(tokens.card) @@ -90,7 +93,7 @@ pub fn show( if desk.show_response_times { response_time(ui, speaker); } - buttons(ui, speaker, catalog, &mut commands); + open_eq = buttons(ui, speaker, catalog, &mut commands); message(ui, speaker, &tokens); open_panel }) @@ -100,6 +103,7 @@ pub fn show( commands, open_panel: frame.inner, rect: frame.response.rect, + open_eq, } } @@ -324,12 +328,14 @@ fn response_time(ui: &mut Ui, speaker: &SpeakerState) { ui.add(Label::new(RichText::new(text).small().weak().monospace())); } +/// Mute and identify, and EQ for models with EQ containers; true when EQ was clicked. fn buttons( ui: &mut Ui, speaker: &mut SpeakerState, catalog: &Catalog, commands: &mut Vec, -) { +) -> bool { + let has_eq = has_eq(catalog, speaker); ui.horizontal(|ui| { toggle( ui, @@ -347,7 +353,9 @@ fn buttons( Switch::Identify, texts::IDENTIFY.get(), ); - }); + has_eq && ui.button(texts::EQ).clicked() + }) + .inner } /// A button showing a switch value of the speaker; a click asks for the opposite. diff --git a/crates/app/tests/eq.rs b/crates/app/tests/eq.rs index 6548a20..561e4e1 100644 --- a/crates/app/tests/eq.rs +++ b/crates/app/tests/eq.rs @@ -29,11 +29,20 @@ fn spread(simulator: &Simulator) { fn open_eq(model: SimModel) -> (Simulator, tempfile::TempDir, Harness<'static, NeumannApp>) { let (simulator, directory, mut harness) = connected_speaker_with(model, spread); open_panel(&mut harness, device_name(model)); - harness.get_by_label(texts::EQ.get()).click(); + click_panel_eq(&mut harness); wait_for_label(&mut harness, &texts::eq_band(1)); (simulator, directory, harness) } +/// The strip has an "EQ" button too; the panel's lies below it, at the panel's foot. +fn click_panel_eq(harness: &mut Harness<'static, NeumannApp>) { + let lowest = harness + .query_all_by_role_and_label(Role::Button, texts::EQ.get()) + .max_by(|a, b| a.rect().center().y.total_cmp(&b.rect().center().y)) + .expect("an EQ button"); + lowest.click(); +} + fn drag(harness: &mut Harness<'static, NeumannApp>, from: egui::Pos2, by: egui::Vec2) { harness.drag_at(from); for frame in 1..=5 { @@ -197,3 +206,29 @@ fn a_band_change_reads_as_one_band_in_the_history() { harness.run_steps(2); harness.get_by_label_contains(&texts::eq_change_setting("EQ 2", 2, BOOST_LABEL)); } + +#[test] +fn the_strip_opens_the_eq_window() { + let (_simulator, _directory, mut harness) = connected_speaker(SimModel::Kh750); + // With the panel closed, the strip's button is the only "EQ". + harness + .get_by_role_and_label(Role::Button, texts::EQ.get()) + .click(); + wait_for_label(&mut harness, &texts::eq_band(1)); + harness.get_by_label("Subwoofer"); +} + +#[test] +fn the_eq_button_waits_for_the_speakers_model() { + // Never connected, the model is unknown: which containers it has is not known either. + let simulator = Simulator::start(SimModel::Kh750); + let directory = tempfile::tempdir().unwrap(); + let path = settings_file(directory.path(), vec![manual_for(&simulator)]); + simulator.stop(); + let mut harness = harness(path, Vec::new()); + harness.run_steps(5); + harness.get_by_label(texts::MUTE.get()); + assert!(harness + .query_by_role_and_label(Role::Button, texts::EQ.get()) + .is_none()); +} From fca491e19f52f041fcd20ba92b3a3e31d4b84587 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:55:12 +0200 Subject: [PATCH 138/460] Flatten an EQ container from its window --- crates/app/src/app/eq.rs | 114 +++++++++++++++++++++--------- crates/app/src/eq.rs | 14 ++++ crates/app/src/texts.rs | 14 ++++ crates/app/src/views/eq_window.rs | 33 +++++++++ crates/app/tests/eq.rs | 85 ++++++++++++++++++++++ crates/core/src/catalog.rs | 4 ++ crates/core/src/paths.toml | 6 ++ crates/core/tests/catalog.rs | 26 +++++++ 8 files changed, 264 insertions(+), 32 deletions(-) diff --git a/crates/app/src/app/eq.rs b/crates/app/src/app/eq.rs index 95760f1..ae653bf 100644 --- a/crates/app/src/app/eq.rs +++ b/crates/app/src/app/eq.rs @@ -7,7 +7,7 @@ use neumann_control_core::ssc::wire::SscPath; use serde_json::Value; use super::NeumannApp; -use crate::eq::{bands_of, one_band, Band, Leaf}; +use crate::eq::{bands_of, flat, one_band, Band, Leaf, FLATTENING_LEAVES}; use crate::eq_response::{curve_db, graph_frequencies}; use crate::paths; use crate::state::{SpeakerKey, SpeakerState, Status}; @@ -15,7 +15,7 @@ use crate::texts; use crate::ui_kit::theme::group_color; use crate::views::eq_graph::{Graph, GraphAction, GraphCurve, GraphPoint}; use crate::views::eq_table::{Limits, Table, TableAction, TableRow}; -use crate::views::eq_window::{self, EqAction, EqView}; +use crate::views::eq_window::{self, EqAction, EqView, ResetOffer}; use crate::writes::{self, Intent}; const EQ_VIEWPORT_ID: &str = "eq_window"; @@ -29,6 +29,7 @@ pub(super) struct EqWindow { container: usize, band: usize, refusal: Option, + asking_reset: bool, } /// One container as the window shows it. @@ -50,6 +51,7 @@ impl NeumannApp { container: 0, band: 0, refusal: None, + asking_reset: false, }); } @@ -184,6 +186,12 @@ impl NeumannApp { let notice = unreachable .then(|| texts::UNREACHABLE.get().to_owned()) .or_else(|| window.refusal.clone()); + let reset = selected + .filter(|shown| editable && !shown.container.crossover) + .map(|shown| ResetOffer { + container: shown.container.label(texts::language()).to_owned(), + asking: window.asking_reset, + }); let view = EqView { viewport: ViewportId::from_hash_of(id), title: texts::eq_window_title(&speaker.display_name()), @@ -194,6 +202,7 @@ impl NeumannApp { graph, table, notice, + reset, }; eq_window::show(context, &view) } @@ -211,8 +220,18 @@ impl NeumannApp { EqAction::Close => {} EqAction::Output(output) => { (window.output, window.container, window.band) = (output, 0, 0); + window.asking_reset = false; + } + EqAction::Container(container) => { + (window.container, window.band) = (container, 0); + window.asking_reset = false; + } + EqAction::ResetAsk => window.asking_reset = true, + EqAction::ResetCancel => window.asking_reset = false, + EqAction::ResetConfirm => { + window.asking_reset = false; + self.flatten(index, now); } - EqAction::Container(container) => (window.container, window.band) = (container, 0), EqAction::Graph(GraphAction::Select(band)) | EqAction::Table(TableAction::Select(band)) => window.band = band, EqAction::Graph(GraphAction::Move { @@ -236,54 +255,85 @@ impl NeumannApp { } } - /// One leaf of one band, `null` elsewhere; a refusal is shown in the window. - fn write_band(&mut self, index: usize, band: usize, leaf: Leaf, value: Value, now: f64) { + /// `gain` and `boost` of every band of the chosen container to 0, both in this frame, so + /// the history files them as one entry. + fn flatten(&mut self, index: usize, now: f64) { + for leaf in FLATTENING_LEAVES { + let Some((path, length)) = self.chosen_leaf(index, leaf) else { + return; + }; + if let Err((label, reason)) = self.send_eq_write(index, &path, flat(length), now) { + self.eq_windows[index].refusal = Some(texts::eq_leaf_refusal(&label, &reason)); + return; + } + } + } + + /// The chosen container's `leaf` and its band count, once the speaker reported it. + fn chosen_leaf(&self, index: usize, leaf: Leaf) -> Option<(SscPath, usize)> { let catalog = Catalog::embedded(); let window = &self.eq_windows[index]; - let Some(speaker) = self - .speakers - .iter_mut() - .find(|speaker| speaker.state.key == window.key) - else { - return; - }; - let outputs = outputs_of(catalog, speaker.state.product()); + let speaker = self.speaker_of(&window.key)?; + let outputs = outputs_of(catalog, speaker.product()); let container = outputs .get(window.output) - .and_then(|(_, containers)| containers.get(window.container)) - .map(|container| container.path.clone()); - let Some(container) = container else { - return; - }; - let path = leaf.path(&container); - let Some(length) = speaker - .state + .and_then(|(_, containers)| containers.get(window.container))?; + let path = leaf.path(&container.path); + let length = speaker .values .get(&path) .and_then(Value::as_array) - .map(Vec::len) - else { + .map(Vec::len)?; + Some((path, length)) + } + + /// One leaf of one band, `null` elsewhere; a refusal is shown in the window. + fn write_band(&mut self, index: usize, band: usize, leaf: Leaf, value: Value, now: f64) { + let Some((path, length)) = self.chosen_leaf(index, leaf) else { return; }; + let refusal = self + .send_eq_write(index, &path, one_band(length, band, value), now) + .err() + .map(|(label, reason)| texts::eq_refusal(band + 1, &label, &reason)); + self.eq_windows[index].refusal = refusal; + } + + /// One EQ write through the write policy; a refusal comes back as the leaf's name and the + /// reason, for the window to word. + fn send_eq_write( + &mut self, + index: usize, + path: &SscPath, + value: Value, + now: f64, + ) -> Result<(), (String, String)> { + let catalog = Catalog::embedded(); + let key = &self.eq_windows[index].key; + let Some(speaker) = self + .speakers + .iter_mut() + .find(|speaker| speaker.state.key == *key) + else { + return Ok(()); + }; let intent = Intent::Write { path: path.clone(), - value: one_band(length, band, value), + value, }; - let refusal = match speaker.state.try_request(intent, catalog, now) { + match speaker.state.try_request(intent, catalog, now) { Ok(command) => { speaker.handle.send(command); - None + Ok(()) } Err(refusal) => { - let label = paths::catalog_entry(catalog, &path).map_or_else( - || leaf.name().to_owned(), + let label = paths::catalog_entry(catalog, path).map_or_else( + || path.to_string(), |entry| entry.label(texts::language()).to_owned(), ); - let reason = texts::describe_refusal(&refusal); - Some(texts::eq_refusal(band + 1, &label, &reason)) + Err((label, texts::describe_refusal(&refusal))) } - }; - self.eq_windows[index].refusal = refusal; + } } } diff --git a/crates/app/src/eq.rs b/crates/app/src/eq.rs index 5d52a2e..7b3dd0a 100644 --- a/crates/app/src/eq.rs +++ b/crates/app/src/eq.rs @@ -128,6 +128,15 @@ pub fn has_eq(catalog: &Catalog, speaker: &SpeakerState) -> bool { speaker.identity.is_some() && !catalog.eq_containers(speaker.product()).is_empty() } +/// The leaves a reset to flat sets to 0 dB: `gain` is a flat level and `boost` the filter's +/// height (measured), so with both at 0 a band changes nothing whatever its type. +pub const FLATTENING_LEAVES: [Leaf; 2] = [Leaf::Gain, Leaf::Boost]; + +/// The value that sets every band of a leaf to 0. +pub fn flat(length: usize) -> Value { + Value::from(vec![0.0; length]) +} + /// The value that writes `value` to `band` only (measured: `null` leaves a band as it is). pub fn one_band(length: usize, band: usize, value: Value) -> Value { let mut array = vec![Value::Null; length]; @@ -153,6 +162,11 @@ mod tests { .collect() } + #[test] + fn flat_sets_every_band_to_zero() { + assert_eq!(flat(3), json!([0.0, 0.0, 0.0])); + } + fn two_bands() -> BTreeMap { values(&[ ("enabled", json!([true, false])), diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 0b46bac..dff37d3 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -172,6 +172,7 @@ pub const EQ_OUTPUT_SUBWOOFER: Text = Text::new("Subwoofer", "Subwoofer"); pub const EQ_OUTPUT_A: Text = Text::new("Ausgang A", "Output A"); pub const EQ_OUTPUT_B: Text = Text::new("Ausgang B", "Output B"); pub const BAND: Text = Text::new("Band", "Band"); +pub const EQ_RESET: Text = Text::new("Flach stellen", "Make flat"); /// A speaker of a history entry that has been removed since. pub const REMOVED_SPEAKER: Text = Text::new("entfernter Lautsprecher", "removed speaker"); pub const CONTROL_HINT: Text = Text::new( @@ -570,6 +571,19 @@ pub fn eq_change_setting(container: &str, band: usize, leaf: &str) -> String { format!("{container} {} {band} · {leaf}", BAND.get()) } +/// Asked before a reset to flat, in the EQ window. +pub fn eq_reset_question(container: &str) -> String { + pick( + format!("Alle Bänder von {container} flach stellen? Pegel und Anhebung auf 0 dB."), + format!("Make every band of {container} flat? Level and boost to 0 dB."), + ) +} + +/// A refused write of a whole leaf (a reset), under the EQ graph. +pub fn eq_leaf_refusal(leaf: &str, reason: &str) -> String { + format!("{leaf}: {reason}") +} + /// A refused band write, under the EQ graph. pub fn eq_refusal(band: usize, leaf: &str, reason: &str) -> String { format!("{} {band} · {leaf}: {reason}", BAND.get()) diff --git a/crates/app/src/views/eq_window.rs b/crates/app/src/views/eq_window.rs index a56a047..dc5791a 100644 --- a/crates/app/src/views/eq_window.rs +++ b/crates/app/src/views/eq_window.rs @@ -27,6 +27,14 @@ pub struct EqView<'a> { pub table: Option>, /// Why nothing can be written right now, or why the last write was refused. pub notice: Option, + /// The reset of the chosen container; `None` on a crossover or while it cannot be written. + pub reset: Option, +} + +pub struct ResetOffer { + pub container: String, + /// The question is shown instead of the button. + pub asking: bool, } #[derive(Clone, Debug, PartialEq)] @@ -36,6 +44,9 @@ pub enum EqAction { Container(usize), Graph(GraphAction), Table(TableAction), + ResetAsk, + ResetConfirm, + ResetCancel, } pub fn show(context: &Context, view: &EqView) -> Vec { @@ -97,6 +108,9 @@ fn contents(ui: &mut Ui, view: &EqView) -> Vec { if let Some(notice) = &view.notice { ui.colored_label(ui.visuals().warn_fg_color, notice); } + if let Some(reset) = &view.reset { + actions.extend(reset_row(ui, reset)); + } ui.separator(); match &view.table { Some(table) => actions.extend(eq_table::show(ui, table).into_iter().map(EqAction::Table)), @@ -110,3 +124,22 @@ fn contents(ui: &mut Ui, view: &EqView) -> Vec { } actions } + +fn reset_row(ui: &mut Ui, reset: &ResetOffer) -> Option { + ui.horizontal(|ui| { + if !reset.asking { + return ui + .button(texts::EQ_RESET) + .clicked() + .then_some(EqAction::ResetAsk); + } + ui.label(texts::eq_reset_question(&reset.container)); + if ui.button(texts::EQ_RESET).clicked() { + return Some(EqAction::ResetConfirm); + } + ui.button(texts::CANCEL) + .clicked() + .then_some(EqAction::ResetCancel) + }) + .inner +} diff --git a/crates/app/tests/eq.rs b/crates/app/tests/eq.rs index 561e4e1..cd2ce48 100644 --- a/crates/app/tests/eq.rs +++ b/crates/app/tests/eq.rs @@ -232,3 +232,88 @@ fn the_eq_button_waits_for_the_speakers_model() { .query_by_role_and_label(Role::Button, texts::EQ.get()) .is_none()); } + +fn shaped(simulator: &Simulator) { + spread(simulator); + simulator.set_value("audio/out/eq2/gain", json!(vec![1.5; 10])); + simulator.set_value("audio/out/eq2/boost", json!(vec![-3.0; 10])); +} + +fn reset_buttons(harness: &Harness<'static, NeumannApp>) -> usize { + harness + .query_all_by_role_and_label(Role::Button, texts::EQ_RESET.get()) + .count() +} + +#[test] +fn reset_asks_then_flattens_gain_and_boost() { + let (simulator, _directory, mut harness) = connected_speaker_with(SimModel::Kh120Ii, shaped); + harness + .get_by_role_and_label(Role::Button, texts::EQ.get()) + .click(); + wait_for_label(&mut harness, &texts::eq_band(1)); + harness + .get_by_role_and_label(Role::Button, texts::EQ_RESET.get()) + .click(); + harness.run_steps(2); + harness.get_by_label_contains(&texts::eq_reset_question("EQ 2")); + barrier(&simulator); + assert!(sets(&simulator, GAIN_POINTER).is_empty()); + // Asking replaces the button by the confirming one. + assert_eq!(reset_buttons(&harness), 1); + harness + .get_by_role_and_label(Role::Button, texts::EQ_RESET.get()) + .click(); + wait_until(&mut harness, "both arrays written", |_| { + !sets(&simulator, GAIN_POINTER).is_empty() && !sets(&simulator, BOOST_POINTER).is_empty() + }); + assert_eq!(sets(&simulator, GAIN_POINTER), vec![json!(vec![0.0; 10])]); + assert_eq!(sets(&simulator, BOOST_POINTER), vec![json!(vec![0.0; 10])]); + assert!(sets(&simulator, FREQUENCY_POINTER).is_empty()); + let history: Vec<_> = harness.state().history().entries().cloned().collect(); + assert_eq!(history.len(), 1); + assert_eq!(history[0].changes.len(), 2); + assert_eq!(history[0].changes[0].before, Some(json!(vec![1.5; 10]))); +} + +#[test] +fn a_reset_can_be_cancelled() { + let (simulator, _directory, mut harness) = connected_speaker_with(SimModel::Kh120Ii, shaped); + harness + .get_by_role_and_label(Role::Button, texts::EQ.get()) + .click(); + wait_for_label(&mut harness, &texts::eq_band(1)); + harness + .get_by_role_and_label(Role::Button, texts::EQ_RESET.get()) + .click(); + harness.run_steps(2); + harness + .get_by_role_and_label(Role::Button, texts::CANCEL.get()) + .click(); + harness.run_steps(3); + barrier(&simulator); + assert!(sets(&simulator, GAIN_POINTER).is_empty()); + assert!(harness + .query_by_label_contains(&texts::eq_reset_question("EQ 2")) + .is_none()); + assert_eq!(reset_buttons(&harness), 1); +} + +#[test] +fn reset_is_not_offered_on_a_crossover() { + let (_simulator, _directory, mut harness) = open_eq(SimModel::Kh750); + assert_eq!(reset_buttons(&harness), 1); + harness.get_by_label("Weiche A").click(); + harness.run_steps(2); + assert_eq!(reset_buttons(&harness), 0); +} + +#[test] +fn reset_is_not_offered_while_the_speaker_is_unreachable() { + let (simulator, _directory, mut harness) = open_eq(SimModel::Kh120Ii); + assert_eq!(reset_buttons(&harness), 1); + simulator.stop(); + wait_until(&mut harness, "the reset withdrawn", |harness| { + reset_buttons(harness) == 0 + }); +} diff --git a/crates/core/src/catalog.rs b/crates/core/src/catalog.rs index 08b3dc6..b8dc351 100644 --- a/crates/core/src/catalog.rs +++ b/crates/core/src/catalog.rs @@ -351,6 +351,10 @@ pub struct EqContainer { pub class: ModelClass, #[serde(default)] pub writable_on: Vec, + /// A crossover between the speaker's outputs: flattening it would remove the split, so it + /// is never reset to flat. + #[serde(default)] + pub crossover: bool, } impl EqContainer { diff --git a/crates/core/src/paths.toml b/crates/core/src/paths.toml index aac29a6..dee09ff 100644 --- a/crates/core/src/paths.toml +++ b/crates/core/src/paths.toml @@ -565,6 +565,7 @@ label = "Weiche A" label_en = "Crossover A" class = "subwoofer" writable_on = ["KH 750"] +crossover = true [[eq]] container = "audio/out/eq1/in2" @@ -572,6 +573,7 @@ label = "Weiche B" label_en = "Crossover B" class = "subwoofer" writable_on = ["KH 750"] +crossover = true [[eq]] container = "audio/out1/eq2" @@ -593,6 +595,7 @@ label = "Weiche A" label_en = "Crossover A" class = "subwoofer" writable_on = ["KH 750"] +crossover = true [[eq]] container = "audio/out1/eq1/in2" @@ -600,6 +603,7 @@ label = "Weiche B" label_en = "Crossover B" class = "subwoofer" writable_on = ["KH 750"] +crossover = true [[eq]] container = "audio/out2/eq2" @@ -621,6 +625,7 @@ label = "Weiche A" label_en = "Crossover A" class = "subwoofer" writable_on = ["KH 750"] +crossover = true [[eq]] container = "audio/out2/eq1/in2" @@ -628,3 +633,4 @@ label = "Weiche B" label_en = "Crossover B" class = "subwoofer" writable_on = ["KH 750"] +crossover = true diff --git a/crates/core/tests/catalog.rs b/crates/core/tests/catalog.rs index 625563e..384e874 100644 --- a/crates/core/tests/catalog.rs +++ b/crates/core/tests/catalog.rs @@ -363,3 +363,29 @@ fn a_band_write_needs_one_valid_entry() { Err(ValueError::WrongType) ); } + +#[test] +fn only_the_kh750_eq1_containers_are_crossovers() { + let catalog = Catalog::embedded(); + let crossovers: Vec = catalog + .eq + .iter() + .filter(|container| container.crossover) + .map(|container| container.path.to_string()) + .collect(); + let expected: Vec = ["out", "out1", "out2"] + .iter() + .flat_map(|output| ["in1", "in2"].map(|input| format!("audio/{output}/eq1/{input}"))) + .collect(); + assert_eq!(crossovers, expected); +} + +#[test] +fn a_flat_full_array_is_a_valid_band_write() { + let catalog = Catalog::embedded(); + let flat = json!([0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]); + assert_eq!( + entry("audio/out/eq2/boost").validate(&flat, MONITOR, &catalog.classes), + Ok(()) + ); +} From 9ef53ab01be0f96c72feb5e7bac0156d82daec95 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:00:50 +0200 Subject: [PATCH 139/460] Undo EQ changes from the EQ window --- crates/app/src/app.rs | 3 +- crates/app/src/app/eq.rs | 107 +++++++++++++++++++++++++++++- crates/app/src/app/history.rs | 16 +++-- crates/app/src/history.rs | 19 +++++- crates/app/src/texts.rs | 1 + crates/app/src/views/eq_window.rs | 52 ++++++++++++++- crates/app/src/views/history.rs | 5 +- crates/app/tests/eq.rs | 79 ++++++++++++++++++++++ 8 files changed, 271 insertions(+), 11 deletions(-) diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index 6ba1188..3ccecf1 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -360,7 +360,6 @@ impl NeumannApp { if ui.input_mut(|input| input.consume_shortcut(&COMPACT_SHORTCUT)) { self.toggle_compact(frame); } - self.undo_shortcut(ui); egui::CentralPanel::default().show(ui, |ui| { if self.settings.desk.compact { self.show_compact(ui); @@ -376,6 +375,8 @@ impl NeumannApp { self.show_group_dialog(ui); self.show_all_values(ui.ctx()); self.show_eq_windows(ui.ctx()); + // After the EQ windows: where one is embedded in this window, its Ctrl+Z comes first. + self.undo_shortcut(ui); self.show_history(ui); self.show_take_back_question(ui); let now = ui.input(|input| input.time); diff --git a/crates/app/src/app/eq.rs b/crates/app/src/app/eq.rs index ae653bf..fb4a7d1 100644 --- a/crates/app/src/app/eq.rs +++ b/crates/app/src/app/eq.rs @@ -7,8 +7,9 @@ use neumann_control_core::ssc::wire::SscPath; use serde_json::Value; use super::NeumannApp; -use crate::eq::{bands_of, flat, one_band, Band, Leaf, FLATTENING_LEAVES}; +use crate::eq::{bands_of, container_of, flat, one_band, Band, Leaf, FLATTENING_LEAVES}; use crate::eq_response::{curve_db, graph_frequencies}; +use crate::history::Entry; use crate::paths; use crate::state::{SpeakerKey, SpeakerState, Status}; use crate::texts; @@ -186,6 +187,16 @@ impl NeumannApp { let notice = unreachable .then(|| texts::UNREACHABLE.get().to_owned()) .or_else(|| window.refusal.clone()); + let undo = self + .eq_undo(&window.key) + .and_then(|id| self.history.history.entry(id)) + .map(|entry| self.row(entry).text); + let question = self + .history + .question + .filter(|_| self.eq_question_window() == Some(index)) + .and_then(|id| self.history.history.entry(id)) + .map(|entry| self.changed_lines(entry)); let reset = selected .filter(|shown| editable && !shown.container.crossover) .map(|shown| ResetOffer { @@ -203,10 +214,33 @@ impl NeumannApp { table, notice, reset, + undo, + question, }; eq_window::show(context, &view) } + /// The newest entry the EQ window of `key` can undo: open, and only EQ writes to it. + fn eq_undo(&self, key: &SpeakerKey) -> Option { + let catalog = Catalog::embedded(); + self.history + .history + .newest_open_where(|entry| is_eq_entry_of(entry, key, catalog)) + } + + /// The EQ window that asks the pending "changed since" question, if it is about an EQ + /// entry of a speaker whose window is open. + pub(super) fn eq_question_window(&self) -> Option { + let catalog = Catalog::embedded(); + let entry = self + .history + .question + .and_then(|id| self.history.history.entry(id))?; + self.eq_windows + .iter() + .position(|window| is_eq_entry_of(entry, &window.key, catalog)) + } + fn speaker_of(&self, key: &SpeakerKey) -> Option<&SpeakerState> { self.speakers .iter() @@ -232,6 +266,14 @@ impl NeumannApp { window.asking_reset = false; self.flatten(index, now); } + EqAction::Undo => { + let key = window.key.clone(); + if let Some(id) = self.eq_undo(&key) { + self.request_take_back(id); + } + } + EqAction::QuestionConfirm => self.answer_take_back(true), + EqAction::QuestionCancel => self.answer_take_back(false), EqAction::Graph(GraphAction::Select(band)) | EqAction::Table(TableAction::Select(band)) => window.band = band, EqAction::Graph(GraphAction::Move { @@ -337,6 +379,14 @@ impl NeumannApp { } } +/// Whether every change of `entry` is an EQ write to the speaker `key`. +fn is_eq_entry_of(entry: &Entry, key: &SpeakerKey, catalog: &Catalog) -> bool { + entry + .changes + .iter() + .all(|change| change.speaker == *key && container_of(catalog, &change.path).is_some()) +} + /// The speaker's EQ containers by output (`out`, `out1`, `out2`), in catalog order. fn outputs_of<'a>(catalog: &'a Catalog, product: &str) -> Vec<(String, Vec<&'a EqContainer>)> { let mut outputs: Vec<(String, Vec<&EqContainer>)> = Vec::new(); @@ -393,3 +443,58 @@ fn headers_of(catalog: &Catalog, container: Option<&SscPath>) -> [String; 6] { ) }) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::history::{Change, ClockTime}; + use serde_json::json; + + fn key(serial: &str) -> SpeakerKey { + SpeakerKey::Serial(serial.to_owned()) + } + + fn entry(changes: &[(&SpeakerKey, &str)]) -> Entry { + let changes = changes + .iter() + .map(|(speaker, path)| Change { + speaker: (*speaker).clone(), + path: crate::paths::path(path), + before: Some(json!(0.0)), + after: json!(1.0), + taken_back: false, + }) + .collect(); + Entry { + id: 0, + clock: ClockTime { + hour: 12, + minute: 0, + second: 0, + }, + changes, + takes_back: None, + } + } + + #[test] + fn an_eq_entry_belongs_to_its_speaker_only() { + let catalog = Catalog::embedded(); + let (left, right) = (key("A"), key("B")); + let flattened = entry(&[ + (&left, "audio/out/eq2/gain"), + (&left, "audio/out/eq2/boost"), + ]); + assert!(is_eq_entry_of(&flattened, &left, catalog)); + assert!(!is_eq_entry_of(&flattened, &right, catalog)); + let muted = entry(&[(&left, "audio/out/mute")]); + assert!(!is_eq_entry_of(&muted, &left, catalog)); + let mixed = entry(&[(&left, "audio/out/eq2/gain"), (&left, "audio/out/level")]); + assert!(!is_eq_entry_of(&mixed, &left, catalog)); + let both = entry(&[ + (&left, "audio/out/eq2/gain"), + (&right, "audio/out/eq2/gain"), + ]); + assert!(!is_eq_entry_of(&both, &left, catalog)); + } +} diff --git a/crates/app/src/app/history.rs b/crates/app/src/app/history.rs index 81e2aaf..df87023 100644 --- a/crates/app/src/app/history.rs +++ b/crates/app/src/app/history.rs @@ -1,7 +1,7 @@ //! The session's history on the app side: the writes of each frame become one entry, and an //! entry is taken back by writing its values before through the normal write policy. -use eframe::egui::{Key, KeyboardShortcut, Modifiers, Ui}; +use eframe::egui::Ui; use super::NeumannApp; use serde_json::Value; @@ -11,13 +11,13 @@ use crate::history::{changed_since, is_open, Change, ClockTime, Entry, History}; use crate::paths; use crate::state::{SpeakerState, Status}; use crate::texts; -use crate::views::history::{self as history_view, HistoryAction, HistoryRow, QuestionAction}; +use crate::views::history::{ + self as history_view, HistoryAction, HistoryRow, QuestionAction, UNDO_SHORTCUT, +}; use crate::writes::Intent; use neumann_control_core::catalog::Catalog; use neumann_control_core::speaker::Command; -const UNDO_SHORTCUT: KeyboardShortcut = KeyboardShortcut::new(Modifiers::COMMAND, Key::Z); - /// What the last take-back could not write, for the history window. #[derive(Clone, Debug, PartialEq)] pub struct TakeBackNotice { @@ -115,6 +115,10 @@ impl NeumannApp { let Some(id) = self.history.question else { return; }; + // An EQ entry is asked about in its speaker's EQ window, where the user undid it. + if self.eq_question_window().is_some() { + return; + } let Some(entry) = self.history.history.entry(id) else { self.history.question = None; return; @@ -127,7 +131,7 @@ impl NeumannApp { } } - fn row(&self, entry: &Entry) -> HistoryRow { + pub(super) fn row(&self, entry: &Entry) -> HistoryRow { let lines: Vec = entry .changes .iter() @@ -180,7 +184,7 @@ impl NeumannApp { } /// The open changes whose speakers now report another value than the written one. - fn changed_lines(&self, entry: &Entry) -> Vec { + pub(super) fn changed_lines(&self, entry: &Entry) -> Vec { let catalog = Catalog::embedded(); let mut lines = Vec::new(); for change in entry.changes.iter().filter(|change| is_open(change)) { diff --git a/crates/app/src/history.rs b/crates/app/src/history.rs index f588d32..8985776 100644 --- a/crates/app/src/history.rs +++ b/crates/app/src/history.rs @@ -96,8 +96,13 @@ impl History { /// The newest entry with a change that can still be taken back: what Ctrl+Z takes back. pub fn newest_open(&self) -> Option { + self.newest_open_where(|_| true) + } + + /// Like `newest_open`, among the entries `keep` accepts (the EQ window's undo). + pub fn newest_open_where(&self, keep: impl Fn(&Entry) -> bool) -> Option { self.entries() - .find(|entry| entry.changes.iter().any(is_open)) + .find(|entry| entry.changes.iter().any(is_open) && keep(entry)) .map(|entry| entry.id) } @@ -256,6 +261,18 @@ mod tests { assert_eq!(history.newest_open(), None); } + #[test] + fn newest_open_where_skips_entries_it_does_not_keep() { + let mut history = History::default(); + let older = history.push(NOON, vec![change(Some(json!(1)), json!(2))], None); + let newer = history.push(NOON, vec![change(Some(json!(80)), json!(84))], None); + let kept = |entry: &Entry| entry.changes[0].after == json!(2); + assert_eq!(history.newest_open_where(kept), older); + history.mark_taken_back(older.unwrap(), 0); + assert_eq!(history.newest_open_where(kept), None); + assert_eq!(history.newest_open_where(|_| true), newer); + } + #[test] fn a_change_counts_as_changed_since_only_when_the_speaker_reports_another_value() { let written = change(Some(json!(80.0)), json!(84.0)); diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index dff37d3..7293aca 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -173,6 +173,7 @@ pub const EQ_OUTPUT_A: Text = Text::new("Ausgang A", "Output A"); pub const EQ_OUTPUT_B: Text = Text::new("Ausgang B", "Output B"); pub const BAND: Text = Text::new("Band", "Band"); pub const EQ_RESET: Text = Text::new("Flach stellen", "Make flat"); +pub const EQ_UNDO: Text = Text::new("Rückgängig", "Undo"); /// A speaker of a history entry that has been removed since. pub const REMOVED_SPEAKER: Text = Text::new("entfernter Lautsprecher", "removed speaker"); pub const CONTROL_HINT: Text = Text::new( diff --git a/crates/app/src/views/eq_window.rs b/crates/app/src/views/eq_window.rs index dc5791a..c592079 100644 --- a/crates/app/src/views/eq_window.rs +++ b/crates/app/src/views/eq_window.rs @@ -2,11 +2,13 @@ //! backend has only one): output and container choice, the graph and the band table. use eframe::egui::{ - self, vec2, Color32, Context, Key, RichText, Ui, ViewportBuilder, ViewportClass, ViewportId, + self, vec2, Button, Color32, Context, Key, RichText, Ui, ViewportBuilder, ViewportClass, + ViewportId, }; use super::eq_graph::{self, Graph, GraphAction}; use super::eq_table::{self, Table, TableAction}; +use super::history::UNDO_SHORTCUT; use crate::texts; const EQ_WINDOW_SIZE: [f32; 2] = [900.0, 640.0]; @@ -29,6 +31,10 @@ pub struct EqView<'a> { pub notice: Option, /// The reset of the chosen container; `None` on a crossover or while it cannot be written. pub reset: Option, + /// The history line Undo would take back; `None` disables it. + pub undo: Option, + /// "Changed since" for the entry being undone: the changes another client made meanwhile. + pub question: Option>, } pub struct ResetOffer { @@ -47,6 +53,9 @@ pub enum EqAction { ResetAsk, ResetConfirm, ResetCancel, + Undo, + QuestionConfirm, + QuestionCancel, } pub fn show(context: &Context, view: &EqView) -> Vec { @@ -70,6 +79,10 @@ pub fn show(context: &Context, view: &EqView) -> Vec { if close_requested || escape { actions.push(EqAction::Close); } + let typing = ui.memory(|memory| memory.focused().is_some()); + if !typing && ui.input_mut(|input| input.consume_shortcut(&UNDO_SHORTCUT)) { + actions.push(EqAction::Undo); + } actions }) } @@ -111,6 +124,10 @@ fn contents(ui: &mut Ui, view: &EqView) -> Vec { if let Some(reset) = &view.reset { actions.extend(reset_row(ui, reset)); } + actions.extend(undo_row(ui, view.undo.as_deref())); + if let Some(lines) = &view.question { + actions.extend(question(ui, lines)); + } ui.separator(); match &view.table { Some(table) => actions.extend(eq_table::show(ui, table).into_iter().map(EqAction::Table)), @@ -125,6 +142,39 @@ fn contents(ui: &mut Ui, view: &EqView) -> Vec { actions } +fn undo_row(ui: &mut Ui, line: Option<&str>) -> Option { + ui.horizontal(|ui| { + let clicked = ui + .add_enabled(line.is_some(), Button::new(texts::EQ_UNDO)) + .clicked(); + if let Some(line) = line { + ui.weak(line); + } + clicked.then_some(EqAction::Undo) + }) + .inner +} + +fn question(ui: &mut Ui, lines: &[String]) -> Option { + ui.group(|ui| { + ui.strong(texts::CHANGED_SINCE); + for line in lines { + ui.label(line); + } + ui.label(texts::REVERT_ANYWAY); + ui.horizontal(|ui| { + if ui.button(texts::REVERT).clicked() { + return Some(EqAction::QuestionConfirm); + } + ui.button(texts::CANCEL) + .clicked() + .then_some(EqAction::QuestionCancel) + }) + .inner + }) + .inner +} + fn reset_row(ui: &mut Ui, reset: &ResetOffer) -> Option { ui.horizontal(|ui| { if !reset.asking { diff --git a/crates/app/src/views/history.rs b/crates/app/src/views/history.rs index 6e59036..abfb5f9 100644 --- a/crates/app/src/views/history.rs +++ b/crates/app/src/views/history.rs @@ -1,7 +1,7 @@ //! The history window behind the toolbar's "Verlauf": the writes of this session, newest on top, //! each with "Zurück"; and the question before taking back values that changed since. -use eframe::egui::{Button, Id, Modal, RichText, ScrollArea, Ui}; +use eframe::egui::{Button, Id, Key, KeyboardShortcut, Modal, Modifiers, RichText, ScrollArea, Ui}; use crate::texts; @@ -107,6 +107,9 @@ fn row_line(ui: &mut Ui, row: &HistoryRow) -> Option { } /// "Seitdem geändert": the changes whose values are no longer the written ones. +/// Ctrl+Z, in the main window and in the EQ windows. +pub const UNDO_SHORTCUT: KeyboardShortcut = KeyboardShortcut::new(Modifiers::COMMAND, Key::Z); + pub fn question(ui: &mut Ui, lines: &[String]) -> QuestionAction { let modal = Modal::new(Id::new(QUESTION_ID)).show(ui.ctx(), |ui| { ui.heading(texts::CHANGED_SINCE); diff --git a/crates/app/tests/eq.rs b/crates/app/tests/eq.rs index cd2ce48..ab2950d 100644 --- a/crates/app/tests/eq.rs +++ b/crates/app/tests/eq.rs @@ -317,3 +317,82 @@ fn reset_is_not_offered_while_the_speaker_is_unreachable() { reset_buttons(harness) == 0 }); } + +/// A band edit, then a newer mute from the strip (with the window closed, as it covers the +/// strip), then the window open again. +fn eq_change_then_mute(harness: &mut Harness<'static, NeumannApp>, simulator: &Simulator) { + let point = harness.get_by_label(&texts::eq_band(2)).rect().center(); + drag(harness, point, egui::vec2(0.0, -40.0)); + wait_until(harness, "the band write", |_| { + !sets(simulator, BOOST_POINTER).is_empty() + }); + harness.get_by_label(texts::CLOSE.get()).click(); + harness.run_steps(2); + harness + .get_by_role_and_label(Role::Button, texts::MUTE.get()) + .click(); + wait_until(harness, "the mute write", |_| { + !sets(simulator, "/audio/out/mute").is_empty() + }); + harness + .get_by_role_and_label(Role::Button, texts::EQ.get()) + .click(); + wait_for_label(harness, &texts::eq_band(1)); +} + +fn open_from_strip( + model: SimModel, +) -> (Simulator, tempfile::TempDir, Harness<'static, NeumannApp>) { + let (simulator, directory, mut harness) = connected_speaker_with(model, spread); + // The embedded window is cut off in the small window, its close button with it. + harness.set_size(EXPANDED_WINDOW_SIZE.into()); + harness.run_steps(2); + harness + .get_by_role_and_label(Role::Button, texts::EQ.get()) + .click(); + wait_for_label(&mut harness, &texts::eq_band(1)); + (simulator, directory, harness) +} + +fn assert_band_2_written_back(simulator: &Simulator) { + let boost = sets(simulator, BOOST_POINTER); + assert_eq!(boost.len(), 2, "{boost:?}"); + let mut expected = vec![Value::Null; 10]; + expected[1] = json!(0.0); + assert_eq!(boost[1], Value::Array(expected)); + assert_eq!(sets(simulator, "/audio/out/mute").len(), 1); +} + +#[test] +fn eq_undo_takes_back_the_speakers_last_eq_change() { + let (simulator, _directory, mut harness) = open_from_strip(SimModel::Kh120Ii); + eq_change_then_mute(&mut harness, &simulator); + harness.get_by_label_contains(&texts::eq_change_setting("EQ 2", 2, BOOST_LABEL)); + harness + .get_by_role_and_label(Role::Button, texts::EQ_UNDO.get()) + .click(); + wait_until(&mut harness, "the band written back", |_| { + sets(&simulator, BOOST_POINTER).len() == 2 + }); + assert_band_2_written_back(&simulator); +} + +#[test] +fn ctrl_z_in_the_eq_window_undoes_eq_only() { + let (simulator, _directory, mut harness) = open_from_strip(SimModel::Kh120Ii); + eq_change_then_mute(&mut harness, &simulator); + harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Z); + wait_until(&mut harness, "the band written back", |_| { + sets(&simulator, BOOST_POINTER).len() == 2 + }); + harness.run_steps(3); + barrier(&simulator); + assert_band_2_written_back(&simulator); +} + +#[test] +fn eq_undo_is_disabled_without_eq_changes() { + let (_simulator, _directory, harness) = open_from_strip(SimModel::Kh120Ii); + let undo = harness.get_by_role_and_label(Role::Button, texts::EQ_UNDO.get()); + assert!(undo.accesskit_node().is_disabled()); +} From 70f49155c76956ac63a8970483c09c4623c14ccc Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:06:15 +0200 Subject: [PATCH 140/460] Show temperature and warning trends in the panel --- crates/app/src/lib.rs | 1 + crates/app/src/paths.rs | 1 + crates/app/src/state.rs | 46 ++++++++ crates/app/src/status_leds.rs | 11 ++ crates/app/src/texts.rs | 42 +++++++ crates/app/src/trend.rs | 150 +++++++++++++++++++++++++ crates/app/src/views/mod.rs | 1 + crates/app/src/views/panel.rs | 25 +++++ crates/app/src/views/trends.rs | 196 +++++++++++++++++++++++++++++++++ crates/app/tests/details.rs | 22 ++++ 10 files changed, 495 insertions(+) create mode 100644 crates/app/src/trend.rs create mode 100644 crates/app/src/views/trends.rs diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index 4f19a90..cb8f16e 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -17,6 +17,7 @@ pub mod status_leds; pub mod system_language; pub mod targets; pub mod texts; +pub mod trend; pub mod ui_kit; pub mod views; pub mod window_placement; diff --git a/crates/app/src/paths.rs b/crates/app/src/paths.rs index 381e5d1..69aea94 100644 --- a/crates/app/src/paths.rs +++ b/crates/app/src/paths.rs @@ -15,6 +15,7 @@ pub const FIRMWARE_VERSION: &str = "device/identity/version"; pub const SERIAL: &str = "device/identity/serial"; pub const MAC_ADDRESSES: &str = "device/network/ether/macs"; pub const WARNINGS: &str = "warnings"; +pub const TEMPERATURE: &str = "device/temperature"; pub const CONTROL_MODE: &str = "ui/control_mode"; pub const INPUT_SELECT: &str = "ui/input_select"; pub const STANDBY_ENABLED: &str = "device/standby/enabled"; diff --git a/crates/app/src/state.rs b/crates/app/src/state.rs index f583e40..072382b 100644 --- a/crates/app/src/state.rs +++ b/crates/app/src/state.rs @@ -17,6 +17,8 @@ use crate::gesture::GestureState; use crate::history::{is_given, matching, merged}; use crate::meter::{MeterState, METER_FLOOR_DB}; use crate::paths; +use crate::status_leds::active_warnings; +use crate::trend::Trend; use crate::writes::Refusal; /// The KH 750 reports three output channels; a longer list from a faulty device would draw a @@ -97,6 +99,10 @@ pub struct SpeakerState { pub written: Vec<(SscPath, Option, Value)>, /// Sent values the speaker has not reported back yet; the value before of the next write. pending: BTreeMap, + /// `device/temperature` as reported (kelvin), for the device panel's trend. + pub temperature: Trend, + /// The active warnings, empty while there are none. + pub warnings: Trend>, } impl SpeakerState { @@ -120,6 +126,8 @@ impl SpeakerState { response_time: None, written: Vec::new(), pending: BTreeMap::new(), + temperature: Trend::default(), + warnings: Trend::default(), } } @@ -192,6 +200,8 @@ impl SpeakerState { self.reset_meters(); self.response_time = None; self.pending.clear(); + self.temperature.record(now, None); + self.warnings.record(now, None); } Event::Value { path, value } => { if path == paths::path(paths::OUTPUT_LEVEL) { @@ -203,6 +213,7 @@ impl SpeakerState { self.absent.remove(&path); self.pending.remove(&path); self.feed_meters(&path, &value, now); + self.record_trends(&path, &value, now); self.values.insert(path, value); } Event::Absent(path) => { @@ -221,6 +232,8 @@ impl SpeakerState { self.response_time = None; let message = Message::SerialMismatch(mask_serial(&found)); self.set_message(Some(message), MessageSource::Connection); + self.temperature.record(now, None); + self.warnings.record(now, None); } Event::AllValues(reports) => self.finish_all_values(AllValuesState::Loaded(reports)), Event::AllValuesFailed(reason) => { @@ -230,6 +243,14 @@ impl SpeakerState { } } + fn record_trends(&mut self, path: &SscPath, value: &Value, now: f64) { + if *path == paths::path(paths::TEMPERATURE) { + self.temperature.record(now, value.as_f64()); + } else if *path == paths::path(paths::WARNINGS) { + self.warnings.record(now, active_warnings(value)); + } + } + /// Starts reading every value; the worker answers with `AllValues` or `AllValuesFailed`. pub fn read_all_values(&mut self) -> Command { self.all_values = AllValuesState::Loading; @@ -339,6 +360,31 @@ mod tests { SscPath::parse(text).unwrap() } + #[test] + fn temperature_and_warnings_are_recorded_with_gaps() { + let mut speaker = state(); + speaker.apply(value_event("device/temperature", json!(310)), 1.0); + speaker.apply(value_event("warnings", json!(["NO_WARNING"])), 1.0); + speaker.apply(value_event("warnings", json!(["OVERTEMPERATURE"])), 2.0); + speaker.apply(Event::Unreachable("gone".to_owned()), 3.0); + let temperature: Vec<_> = speaker + .temperature + .points() + .map(|point| (point.time, point.value)) + .collect(); + assert_eq!(temperature, vec![(1.0, Some(310.0)), (3.0, None)]); + let warnings: Vec<_> = speaker + .warnings + .points() + .map(|point| (point.time, point.value.clone())) + .collect(); + let over = vec!["OVERTEMPERATURE".to_owned()]; + assert_eq!( + warnings, + vec![(1.0, Some(Vec::new())), (2.0, Some(over)), (3.0, None)] + ); + } + #[test] fn connected_upgrades_key_to_serial_and_keeps_address() { let mut speaker = state(); diff --git a/crates/app/src/status_leds.rs b/crates/app/src/status_leds.rs index 7ca216d..c48ca21 100644 --- a/crates/app/src/status_leds.rs +++ b/crates/app/src/status_leds.rs @@ -70,6 +70,17 @@ fn any_true(value: &Value) -> bool { } } +/// The warnings `warnings` reports, without `NO_WARNING`; `None` for a value of another form. +pub fn active_warnings(value: &Value) -> Option> { + let texts: Vec<&str> = match value { + Value::String(text) => vec![text.as_str()], + Value::Array(items) => items.iter().map(Value::as_str).collect::>()?, + _ => return None, + }; + let active = texts.into_iter().filter(|text| *text != NO_WARNING); + Some(active.map(str::to_owned).collect()) +} + fn holds_a_warning(value: &Value) -> bool { match value { Value::String(text) => text != NO_WARNING, diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 7293aca..89fe4ce 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -174,6 +174,11 @@ pub const EQ_OUTPUT_B: Text = Text::new("Ausgang B", "Output B"); pub const BAND: Text = Text::new("Band", "Band"); pub const EQ_RESET: Text = Text::new("Flach stellen", "Make flat"); pub const EQ_UNDO: Text = Text::new("Rückgängig", "Undo"); +pub const NO_TREND_VALUES: Text = Text::new("Noch keine Werte", "No values yet"); +pub const TREND_NOW: Text = Text::new("jetzt", "now"); +pub const WARNING_TREND: Text = Text::new("Warnungsverlauf", "Warning trend"); +pub const NO_WARNING: Text = Text::new("keine Warnung", "no warning"); +pub const NOT_READ: Text = Text::new("nicht erreichbar", "not reachable"); /// A speaker of a history entry that has been removed since. pub const REMOVED_SPEAKER: Text = Text::new("entfernter Lautsprecher", "removed speaker"); pub const CONTROL_HINT: Text = Text::new( @@ -572,6 +577,43 @@ pub fn eq_change_setting(container: &str, band: usize, leaf: &str) -> String { format!("{container} {} {band} · {leaf}", BAND.get()) } +/// The device panel's trend heading: "Verlauf (2 h)". +pub fn trends_title(window_seconds: f64) -> String { + let hours = hours(window_seconds); + pick(format!("Verlauf ({hours} h)"), format!("Trend ({hours} h)")) +} + +/// The left end of a trend's time axis: "−2 h". +pub fn trend_start(window_seconds: f64) -> String { + format!("−{} h", hours(window_seconds)) +} + +const SECONDS_PER_HOUR: f64 = 3600.0; + +fn hours(seconds: f64) -> f64 { + seconds / SECONDS_PER_HOUR +} + +/// Names the temperature line for screen readers, with the current value. +pub fn temperature_trend(current: &str) -> String { + pick( + format!("Temperaturverlauf, jetzt {current}"), + format!("Temperature trend, now {current}"), + ) +} + +pub fn trend_range(low: &str, high: &str) -> String { + format!("{low} … {high}") +} + +/// What a trend held at the hovered time. +pub fn trend_hover(minutes_ago: u64, value: &str) -> String { + pick( + format!("vor {minutes_ago} min: {value}"), + format!("{minutes_ago} min ago: {value}"), + ) +} + /// Asked before a reset to flat, in the EQ window. pub fn eq_reset_question(container: &str) -> String { pick( diff --git a/crates/app/src/trend.rs b/crates/app/src/trend.rs new file mode 100644 index 0000000..ece6620 --- /dev/null +++ b/crates/app/src/trend.rs @@ -0,0 +1,150 @@ +//! A value's course over the last two hours of this session: only its change points, so a value +//! that rarely changes costs one point, and gaps where the speaker could not be read. + +use std::collections::VecDeque; + +/// How far back the device panel shows a trend. +pub const TREND_WINDOW_SECONDS: f64 = 7200.0; +/// A value that changes at every poll for hours still keeps a bounded list. +pub const TREND_POINT_LIMIT: usize = 4096; + +#[derive(Clone, Debug, PartialEq)] +pub struct TrendPoint { + /// Seconds of the UI clock. + pub time: f64, + /// `None` from here on the speaker could not be read. + pub value: Option, +} + +#[derive(Clone, Debug)] +pub struct Trend { + points: VecDeque>, +} + +impl Default for Trend { + fn default() -> Self { + Trend { + points: VecDeque::new(), + } + } +} + +impl Trend { + /// Notes what was read at `now`; only a change adds a point. + pub fn record(&mut self, now: f64, value: Option) { + let unchanged = self.points.back().is_some_and(|last| last.value == value); + let nothing_yet = self.points.is_empty() && value.is_none(); + if !unchanged && !nothing_yet { + self.points.push_back(TrendPoint { time: now, value }); + } + self.prune(now); + } + + /// Drops what lies before the window, except the last point before it: that one holds the + /// value at the window's start. + fn prune(&mut self, now: f64) { + let start = now - TREND_WINDOW_SECONDS; + while self.points.len() > 1 && self.points[1].time <= start { + self.points.pop_front(); + } + while self.points.len() > TREND_POINT_LIMIT { + self.points.pop_front(); + } + } + + pub fn points(&self) -> impl Iterator> { + self.points.iter() + } + + pub fn is_empty(&self) -> bool { + self.points.is_empty() + } + + /// The value at `time`: that of the last point at or before it. + pub fn value_at(&self, time: f64) -> Option<&T> { + self.points + .iter() + .rev() + .find(|point| point.time <= time) + .and_then(|point| point.value.as_ref()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn times(trend: &Trend) -> Vec { + trend.points().map(|point| point.time).collect() + } + + #[test] + fn records_only_changes() { + let mut trend = Trend::default(); + trend.record(0.0, Some(310.0)); + trend.record(1.0, Some(310.0)); + trend.record(2.0, Some(311.0)); + assert_eq!(times(&trend), vec![0.0, 2.0]); + } + + #[test] + fn a_gap_is_a_none_point() { + let mut trend = Trend::default(); + trend.record(0.0, Some(310.0)); + trend.record(5.0, None); + trend.record(9.0, Some(310.0)); + assert_eq!(times(&trend), vec![0.0, 5.0, 9.0]); + assert_eq!(trend.value_at(6.0), None); + assert_eq!(trend.value_at(9.5), Some(&310.0)); + } + + #[test] + fn nothing_read_yet_is_no_gap() { + let mut trend: Trend = Trend::default(); + trend.record(0.0, None); + assert!(trend.is_empty()); + } + + #[test] + fn drops_points_older_than_the_window_but_keeps_the_start() { + let mut trend = Trend::default(); + trend.record(0.0, Some(1.0)); + trend.record(100.0, Some(2.0)); + trend.record(200.0, Some(3.0)); + let now = 150.0 + TREND_WINDOW_SECONDS; + trend.record(now, Some(4.0)); + // The point at 100 holds the value at the window's start (150), so it stays. + assert_eq!(times(&trend), vec![100.0, 200.0, now]); + assert_eq!(trend.value_at(150.0), Some(&2.0)); + } + + #[test] + fn an_unchanged_value_still_moves_the_window() { + let mut trend = Trend::default(); + trend.record(0.0, Some(1.0)); + trend.record(10.0, Some(2.0)); + trend.record(20.0 + TREND_WINDOW_SECONDS, Some(2.0)); + assert_eq!(times(&trend), vec![10.0]); + } + + #[test] + fn is_capped() { + let mut trend = Trend::default(); + for step in 0..TREND_POINT_LIMIT + 10 { + trend.record(step as f64 * 0.1, Some(step)); + } + assert_eq!(trend.points().count(), TREND_POINT_LIMIT); + assert_eq!( + trend.points().next().map(|point| point.value), + Some(Some(10)) + ); + } + + #[test] + fn value_at_before_the_first_point_is_unknown() { + let mut trend = Trend::default(); + trend.record(5.0, Some(1.0)); + assert_eq!(trend.value_at(4.0), None); + assert_eq!(trend.value_at(5.0), Some(&1.0)); + } +} diff --git a/crates/app/src/views/mod.rs b/crates/app/src/views/mod.rs index b430611..706afa6 100644 --- a/crates/app/src/views/mod.rs +++ b/crates/app/src/views/mod.rs @@ -19,4 +19,5 @@ pub mod scan_choice; pub mod settings_dialog; pub mod strip; pub mod toolbar; +pub mod trends; pub mod wheel; diff --git a/crates/app/src/views/panel.rs b/crates/app/src/views/panel.rs index 38efbd8..2a83920 100644 --- a/crates/app/src/views/panel.rs +++ b/crates/app/src/views/panel.rs @@ -8,9 +8,11 @@ use neumann_control_core::settings::Linkable; use neumann_control_core::speaker::Command; use super::details; +use super::trends::{self, TemperatureTrend, Trends}; use crate::display::firmware_version; use crate::eq::has_eq; use crate::groups::linkable_label; +use crate::paths; use crate::state::SpeakerState; use crate::texts; @@ -81,6 +83,8 @@ pub fn show( copy_to_group = copy_buttons(ui, groups); let commands = details::show(ui, speaker, catalog); ui.separator(); + trends::show(ui, &trends_of(ui, speaker, catalog)); + ui.separator(); open_eq = has_eq && ui.button(texts::EQ).clicked(); remove = ui.button(texts::REMOVE).clicked(); commands @@ -109,6 +113,27 @@ pub fn show( } } +/// The speaker's trends; the temperature only where the catalog lists it for the model. +fn trends_of<'a>(ui: &Ui, speaker: &'a SpeakerState, catalog: &'a Catalog) -> Trends<'a> { + let path = paths::path(paths::TEMPERATURE); + let temperature = catalog + .entries_for(speaker.product()) + .into_iter() + .find(|entry| entry.path == path) + .and_then(|entry| { + Some(TemperatureTrend { + trend: &speaker.temperature, + conversion: entry.conversion?, + label: entry.label(texts::language()), + }) + }); + Trends { + now: ui.input(|input| input.time), + temperature, + warnings: &speaker.warnings, + } +} + /// Name, model, masked serial and firmware; true when the close button was clicked. fn header(ui: &mut Ui, speaker: &SpeakerState) -> bool { let closed = ui diff --git a/crates/app/src/views/trends.rs b/crates/app/src/views/trends.rs new file mode 100644 index 0000000..0ba55ac --- /dev/null +++ b/crates/app/src/views/trends.rs @@ -0,0 +1,196 @@ +//! The device panel's trends of this session: the temperature as a step line and the warnings +//! as a bar, over the last two hours; hovering shows how long ago and what was read. + +use eframe::egui::{ + self, pos2, vec2, Color32, Rect, RichText, Sense, Stroke, Ui, WidgetInfo, WidgetType, +}; +use neumann_control_core::catalog::Conversion; + +use crate::display::converted; +use crate::texts; +use crate::trend::{Trend, TrendPoint, TREND_WINDOW_SECONDS}; +use crate::ui_kit::theme::Tokens; + +const LINE_HEIGHT: f32 = 48.0; +const BAR_HEIGHT: f32 = 8.0; +const LINE_WIDTH: f32 = 1.5; +/// A temperature that stayed the same still gets a band of this many degrees to sit in. +const MIN_SPAN_DEGREES: f64 = 2.0; +const SECONDS_PER_MINUTE: f64 = 60.0; + +pub struct Trends<'a> { + /// Seconds of the UI clock, the right edge. + pub now: f64, + /// `None` for a model without a temperature. + pub temperature: Option>, + pub warnings: &'a Trend>, +} + +pub struct TemperatureTrend<'a> { + /// As reported (kelvin). + pub trend: &'a Trend, + pub conversion: Conversion, + /// The catalog's name of the setting, as in the panel's rows. + pub label: &'a str, +} + +pub fn show(ui: &mut Ui, trends: &Trends) { + let tokens = Tokens::current(ui.ctx()); + ui.strong(texts::trends_title(TREND_WINDOW_SECONDS)); + let temperature = trends + .temperature + .as_ref() + .filter(|temperature| !temperature.trend.is_empty()); + if temperature.is_none() && trends.warnings.is_empty() { + ui.weak(texts::NO_TREND_VALUES); + return; + } + let start = trends.now - TREND_WINDOW_SECONDS; + if let Some(temperature) = temperature { + temperature_line(ui, temperature, start, trends.now, &tokens); + } + warning_bar(ui, trends.warnings, start, trends.now, &tokens); + ui.horizontal(|ui| { + ui.small(RichText::new(texts::trend_start(TREND_WINDOW_SECONDS)).weak()); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.small(RichText::new(texts::TREND_NOW).weak()); + }); + }); +} + +/// Where a time lies on the trend's x axis. +struct Axis { + rect: Rect, + start: f64, + now: f64, +} + +impl Axis { + fn x(&self, time: f64) -> f32 { + let fraction = ((time - self.start) / (self.now - self.start)).clamp(0.0, 1.0); + self.rect.left() + fraction as f32 * self.rect.width() + } + + fn time(&self, x: f32) -> f64 { + let fraction = f64::from((x - self.rect.left()) / self.rect.width()); + self.start + fraction.clamp(0.0, 1.0) * (self.now - self.start) + } +} + +/// Each point with its end: the next point's time, or now for the last. +fn spans(points: &[&TrendPoint], now: f64) -> Vec<(f64, f64)> { + points + .iter() + .enumerate() + .map(|(index, point)| { + let end = points.get(index + 1).map_or(now, |next| next.time); + (point.time, end) + }) + .collect() +} + +fn temperature_line( + ui: &mut Ui, + temperature: &TemperatureTrend, + start: f64, + now: f64, + tokens: &Tokens, +) { + let (trend, conversion) = (temperature.trend, temperature.conversion); + let points: Vec<&TrendPoint> = trend.points().collect(); + let shown = |kelvin: f64| conversion.apply(kelvin); + let reported: Vec = points.iter().filter_map(|point| point.value).collect(); + let low = reported.iter().copied().fold(f64::INFINITY, f64::min); + let high = reported.iter().copied().fold(f64::NEG_INFINITY, f64::max); + let middle = shown((low + high) / 2.0); + // Kelvin to Celsius only shifts the scale, so the span is the same in either unit. + let half_span = ((high - low) / 2.0).max(MIN_SPAN_DEGREES / 2.0); + let current = trend + .value_at(now) + .map(|kelvin| converted(conversion, *kelvin)); + ui.horizontal(|ui| { + ui.label(temperature.label); + if high > low { + let range = + texts::trend_range(&converted(conversion, low), &converted(conversion, high)); + ui.weak(range); + } + }); + let width = ui.available_width(); + let (rect, response) = ui.allocate_exact_size(vec2(width, LINE_HEIGHT), Sense::hover()); + let painter = ui.painter_at(rect); + painter.rect_stroke( + rect, + 2.0, + Stroke::new(1.0, tokens.border), + egui::StrokeKind::Inside, + ); + let axis = Axis { rect, start, now }; + let y = |value: f64| { + let fraction = ((value - middle) / (2.0 * half_span) + 0.5) as f32; + rect.bottom() - fraction * rect.height() + }; + let stroke = Stroke::new(LINE_WIDTH, tokens.accent); + let mut previous: Option = None; + for (point, (from, to)) in points.iter().zip(spans(&points, now)) { + let Some(value) = point.value.map(shown) else { + previous = None; + continue; + }; + let left = pos2(axis.x(from), y(value)); + let right = pos2(axis.x(to), y(value)); + if let Some(previous) = previous { + painter.line_segment([previous, left], stroke); + } + painter.line_segment([left, right], stroke); + previous = Some(right); + } + let label = texts::temperature_trend(current.as_deref().unwrap_or_default()); + response.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, &label)); + if let Some(pointer) = response.hover_pos() { + let time = axis.time(pointer.x); + let text = trend.value_at(time).map_or_else( + || texts::NOT_READ.get().to_owned(), + |kelvin| converted(conversion, *kelvin), + ); + response.on_hover_text(texts::trend_hover(minutes_ago(now, time), &text)); + } +} + +fn warning_bar(ui: &mut Ui, trend: &Trend>, start: f64, now: f64, tokens: &Tokens) { + let width = ui.available_width(); + let (rect, response) = ui.allocate_exact_size(vec2(width, BAR_HEIGHT), Sense::hover()); + let painter = ui.painter_at(rect); + let axis = Axis { rect, start, now }; + let points: Vec<&TrendPoint>> = trend.points().collect(); + for (point, (from, to)) in points.iter().zip(spans(&points, now)) { + let color: Option = match &point.value { + Some(active) if !active.is_empty() => Some(tokens.danger), + Some(_) => Some(tokens.clip_off), + None => None, + }; + let Some(color) = color else { + continue; + }; + let span = Rect::from_min_max( + pos2(axis.x(from), rect.top()), + pos2(axis.x(to), rect.bottom()), + ); + painter.rect_filled(span, 0.0, color); + } + response + .widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, texts::WARNING_TREND.get())); + if let Some(pointer) = response.hover_pos() { + let time = axis.time(pointer.x); + let text = match trend.value_at(time) { + Some(active) if active.is_empty() => texts::NO_WARNING.get().to_owned(), + Some(active) => active.join(", "), + None => texts::NOT_READ.get().to_owned(), + }; + response.on_hover_text(texts::trend_hover(minutes_ago(now, time), &text)); + } +} + +fn minutes_ago(now: f64, time: f64) -> u64 { + ((now - time) / SECONDS_PER_MINUTE).round().max(0.0) as u64 +} diff --git a/crates/app/tests/details.rs b/crates/app/tests/details.rs index 33b4e8d..8f5d3f0 100644 --- a/crates/app/tests/details.rs +++ b/crates/app/tests/details.rs @@ -538,3 +538,25 @@ fn eq_rows_are_not_in_the_device_panel() { assert!(harness.query_by_label("Verstärkung").is_none()); assert!(harness.query_by_label("Güte (Q)").is_none()); } + +#[test] +fn the_panel_shows_the_temperature_trend_of_a_kh750() { + let (simulator, _directory, mut harness) = speaker_with_panel(SimModel::Kh750); + simulator.set_value("device/temperature", json!(313)); + wait_until(&mut harness, "the trend at 40 °C", |harness| { + harness + .query_by_label(&texts::temperature_trend("40 °C")) + .is_some() + }); + harness.get_by_label(texts::WARNING_TREND.get()); +} + +#[test] +fn a_kh120_has_only_the_warning_trend() { + let (_simulator, _directory, harness) = speaker_with_panel(SimModel::Kh120Ii); + harness.get_by_label(texts::WARNING_TREND.get()); + let temperature = texts::temperature_trend(""); + assert!(harness + .query_by_label_contains(temperature.trim_end()) + .is_none()); +} From 24b0345f0c0c20edfa6171fd98a6061810a286c9 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:10:53 +0200 Subject: [PATCH 141/460] Show one unit in converted number fields --- crates/app/src/views/number_field.rs | 4 +++- crates/app/tests/details.rs | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/app/src/views/number_field.rs b/crates/app/src/views/number_field.rs index 23a778b..2a848c4 100644 --- a/crates/app/src/views/number_field.rs +++ b/crates/app/src/views/number_field.rs @@ -89,7 +89,9 @@ fn drag_value(ui: &mut Ui, field: &NumberField, value: &mut f64) -> Response { .custom_formatter(|number, _| texts::number(number)) .custom_parser(|text| typed_value(text, None)) .speed(field.drag_speed); - if let Some(unit) = field.unit { + // A converted field shows the converted unit only; both would read "0 1/48 kHz ms". + let device_unit = field.unit.filter(|_| field.conversion.is_none()); + if let Some(unit) = device_unit { drag = drag.suffix(format!(" {unit}")); } if let Some(conversion) = field.conversion { diff --git a/crates/app/tests/details.rs b/crates/app/tests/details.rs index 8f5d3f0..6fe9b15 100644 --- a/crates/app/tests/details.rs +++ b/crates/app/tests/details.rs @@ -560,3 +560,15 @@ fn a_kh120_has_only_the_warning_trend() { .query_by_label_contains(temperature.trim_end()) .is_none()); } + +#[test] +fn a_converted_delay_shows_only_the_converted_unit() { + let (_simulator, _directory, harness) = speaker_with_panel(SimModel::Kh750); + let shown: Vec = harness + .query_all_by_role(Role::SpinButton) + .filter_map(|field| field.accesskit_node().value().map(|text| text.to_string())) + .collect(); + let delays: Vec<&String> = shown.iter().filter(|text| text.ends_with(" ms")).collect(); + assert!(!delays.is_empty(), "{shown:?}"); + assert!(shown.iter().all(|text| !text.contains("1/48 kHz")), "{shown:?}"); +} From cfb4aef7dda700f0ef4082125376fb53b7a88521 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:10:53 +0200 Subject: [PATCH 142/460] Fit the trends to the session and join the EQ edit row --- crates/app/src/texts.rs | 11 +++++-- crates/app/src/trend.rs | 36 +++++++++++++++++++++++ crates/app/src/views/eq_window.rs | 48 ++++++++++++++----------------- crates/app/src/views/trends.rs | 23 ++++++++++----- 4 files changed, 82 insertions(+), 36 deletions(-) diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 89fe4ce..d8dcf01 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -13,6 +13,7 @@ use neumann_control_core::settings::Language; use serde_json::Value; use crate::state::Message; +use crate::trend::whole_minutes; use crate::writes::Refusal; thread_local! { @@ -583,9 +584,13 @@ pub fn trends_title(window_seconds: f64) -> String { pick(format!("Verlauf ({hours} h)"), format!("Trend ({hours} h)")) } -/// The left end of a trend's time axis: "−2 h". -pub fn trend_start(window_seconds: f64) -> String { - format!("−{} h", hours(window_seconds)) +/// The left end of a trend's time axis: "−2 h" for whole hours, else "−25 min". +pub fn trend_start(span_seconds: f64) -> String { + let hours = hours(span_seconds); + if hours.fract() == 0.0 { + return format!("−{hours} h"); + } + format!("−{} min", whole_minutes(span_seconds)) } const SECONDS_PER_HOUR: f64 = 3600.0; diff --git a/crates/app/src/trend.rs b/crates/app/src/trend.rs index ece6620..4b400fc 100644 --- a/crates/app/src/trend.rs +++ b/crates/app/src/trend.rs @@ -7,6 +7,22 @@ use std::collections::VecDeque; pub const TREND_WINDOW_SECONDS: f64 = 7200.0; /// A value that changes at every poll for hours still keeps a bounded list. pub const TREND_POINT_LIMIT: usize = 4096; +/// The shortest span a trend is drawn over, so the first readings are not a line at the edge. +pub const TREND_MIN_SPAN_SECONDS: f64 = 60.0; + +const SECONDS_PER_MINUTE: f64 = 60.0; + +/// Seconds as whole minutes, as a trend reads them out; never negative. +pub fn whole_minutes(seconds: f64) -> u64 { + (seconds / SECONDS_PER_MINUTE).round().max(0.0) as u64 +} + +/// The seconds a trend is drawn over: from the first reading, at least +/// `TREND_MIN_SPAN_SECONDS`, at most the window. +pub fn shown_span(first: Option, now: f64) -> f64 { + let since_first = first.map_or(0.0, |first| now - first); + since_first.clamp(TREND_MIN_SPAN_SECONDS, TREND_WINDOW_SECONDS) +} #[derive(Clone, Debug, PartialEq)] pub struct TrendPoint { @@ -60,6 +76,10 @@ impl Trend { self.points.is_empty() } + pub fn first_time(&self) -> Option { + self.points.front().map(|point| point.time) + } + /// The value at `time`: that of the last point at or before it. pub fn value_at(&self, time: f64) -> Option<&T> { self.points @@ -140,6 +160,22 @@ mod tests { ); } + #[test] + fn the_span_runs_from_the_first_reading_within_its_bounds() { + assert_eq!(shown_span(Some(100.0), 400.0), 300.0); + assert_eq!(shown_span(Some(100.0), 110.0), TREND_MIN_SPAN_SECONDS); + assert_eq!(shown_span(None, 110.0), TREND_MIN_SPAN_SECONDS); + let late = 100.0 + 3.0 * TREND_WINDOW_SECONDS; + assert_eq!(shown_span(Some(100.0), late), TREND_WINDOW_SECONDS); + } + + #[test] + fn whole_minutes_round_and_stay_positive() { + assert_eq!(whole_minutes(89.0), 1); + assert_eq!(whole_minutes(91.0), 2); + assert_eq!(whole_minutes(-5.0), 0); + } + #[test] fn value_at_before_the_first_point_is_unknown() { let mut trend = Trend::default(); diff --git a/crates/app/src/views/eq_window.rs b/crates/app/src/views/eq_window.rs index c592079..00c64c3 100644 --- a/crates/app/src/views/eq_window.rs +++ b/crates/app/src/views/eq_window.rs @@ -121,10 +121,7 @@ fn contents(ui: &mut Ui, view: &EqView) -> Vec { if let Some(notice) = &view.notice { ui.colored_label(ui.visuals().warn_fg_color, notice); } - if let Some(reset) = &view.reset { - actions.extend(reset_row(ui, reset)); - } - actions.extend(undo_row(ui, view.undo.as_deref())); + actions.extend(edit_row(ui, view.reset.as_ref(), view.undo.as_deref())); if let Some(lines) = &view.question { actions.extend(question(ui, lines)); } @@ -142,15 +139,23 @@ fn contents(ui: &mut Ui, view: &EqView) -> Vec { actions } -fn undo_row(ui: &mut Ui, line: Option<&str>) -> Option { +/// Reset and undo in one row; while the reset is asked about, its question takes the row. +fn edit_row(ui: &mut Ui, reset: Option<&ResetOffer>, undo: Option<&str>) -> Option { ui.horizontal(|ui| { - let clicked = ui - .add_enabled(line.is_some(), Button::new(texts::EQ_UNDO)) + if let Some(asked) = reset.filter(|reset| reset.asking) { + return reset_question(ui, asked); + } + let flatten = reset.is_some() && ui.button(texts::EQ_RESET).clicked(); + let undone = ui + .add_enabled(undo.is_some(), Button::new(texts::EQ_UNDO)) .clicked(); - if let Some(line) = line { + if let Some(line) = undo { ui.weak(line); } - clicked.then_some(EqAction::Undo) + if flatten { + return Some(EqAction::ResetAsk); + } + undone.then_some(EqAction::Undo) }) .inner } @@ -175,21 +180,12 @@ fn question(ui: &mut Ui, lines: &[String]) -> Option { .inner } -fn reset_row(ui: &mut Ui, reset: &ResetOffer) -> Option { - ui.horizontal(|ui| { - if !reset.asking { - return ui - .button(texts::EQ_RESET) - .clicked() - .then_some(EqAction::ResetAsk); - } - ui.label(texts::eq_reset_question(&reset.container)); - if ui.button(texts::EQ_RESET).clicked() { - return Some(EqAction::ResetConfirm); - } - ui.button(texts::CANCEL) - .clicked() - .then_some(EqAction::ResetCancel) - }) - .inner +fn reset_question(ui: &mut Ui, reset: &ResetOffer) -> Option { + ui.label(texts::eq_reset_question(&reset.container)); + if ui.button(texts::EQ_RESET).clicked() { + return Some(EqAction::ResetConfirm); + } + ui.button(texts::CANCEL) + .clicked() + .then_some(EqAction::ResetCancel) } diff --git a/crates/app/src/views/trends.rs b/crates/app/src/views/trends.rs index 0ba55ac..14418bb 100644 --- a/crates/app/src/views/trends.rs +++ b/crates/app/src/views/trends.rs @@ -8,16 +8,16 @@ use neumann_control_core::catalog::Conversion; use crate::display::converted; use crate::texts; -use crate::trend::{Trend, TrendPoint, TREND_WINDOW_SECONDS}; +use crate::trend::{shown_span, whole_minutes, Trend, TrendPoint, TREND_WINDOW_SECONDS}; use crate::ui_kit::theme::Tokens; const LINE_HEIGHT: f32 = 48.0; const BAR_HEIGHT: f32 = 8.0; const LINE_WIDTH: f32 = 1.5; +/// Keeps the lowest and highest reading off the frame. +const LINE_MARGIN: f32 = 6.0; /// A temperature that stayed the same still gets a band of this many degrees to sit in. const MIN_SPAN_DEGREES: f64 = 2.0; -const SECONDS_PER_MINUTE: f64 = 60.0; - pub struct Trends<'a> { /// Seconds of the UI clock, the right edge. pub now: f64, @@ -45,13 +45,21 @@ pub fn show(ui: &mut Ui, trends: &Trends) { ui.weak(texts::NO_TREND_VALUES); return; } - let start = trends.now - TREND_WINDOW_SECONDS; + let first = [ + temperature.and_then(|temperature| temperature.trend.first_time()), + trends.warnings.first_time(), + ] + .into_iter() + .flatten() + .reduce(f64::min); + let span = shown_span(first, trends.now); + let start = trends.now - span; if let Some(temperature) = temperature { temperature_line(ui, temperature, start, trends.now, &tokens); } warning_bar(ui, trends.warnings, start, trends.now, &tokens); ui.horizontal(|ui| { - ui.small(RichText::new(texts::trend_start(TREND_WINDOW_SECONDS)).weak()); + ui.small(RichText::new(texts::trend_start(span)).weak()); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.small(RichText::new(texts::TREND_NOW).weak()); }); @@ -128,7 +136,8 @@ fn temperature_line( let axis = Axis { rect, start, now }; let y = |value: f64| { let fraction = ((value - middle) / (2.0 * half_span) + 0.5) as f32; - rect.bottom() - fraction * rect.height() + let inner = rect.shrink2(vec2(0.0, LINE_MARGIN)); + inner.bottom() - fraction * inner.height() }; let stroke = Stroke::new(LINE_WIDTH, tokens.accent); let mut previous: Option = None; @@ -192,5 +201,5 @@ fn warning_bar(ui: &mut Ui, trend: &Trend>, start: f64, now: f64, to } fn minutes_ago(now: f64, time: f64) -> u64 { - ((now - time) / SECONDS_PER_MINUTE).round().max(0.0) as u64 + whole_minutes(now - time) } From 013914bca9f63046d3b944212a03f60b8204ff92 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:18:09 +0200 Subject: [PATCH 143/460] Describe the EQ button, flat, EQ undo and trends --- CHANGELOG.md | 10 ++++++++++ README.md | 18 +++++++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f93841..600060a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ **Undo** on each entry and Ctrl+Z for the newest. A group or master move is one entry. When a value changed since, the app asks before writing the old one back. - The held peak of each meter as a number under its channel. +- An **EQ** button on each strip, opening the speaker's EQ window. +- **Make flat** in the EQ window: level and boost of every band of an EQ to 0 dB, after a + question; not for the KH 750 crossovers. +- **Undo** in the EQ window and Ctrl+Z there: takes back the speaker's newest EQ change. +- Trends of the temperature and the warnings over the last two hours of the session in the + device panel. ### Changed @@ -19,6 +25,10 @@ - Standby time and standby level can be set on the KH 750 too. - Wider meters; the held peak is a thin line instead of a lit segment. +### Fixed + +- Number fields with a converted unit showed the device's unit as well ("0 1/48 kHz ms"). + ## [0.1.0] - 2026-10-18 First version. diff --git a/README.md b/README.md index 27b2add..3122373 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,8 @@ Each speaker has a channel strip: - input meters for channels A and B with clip lamps; the KH 750 also shows its output meters A, B and Sub, captioned "Out". A thin line marks each meter's peak of the last 1.5 seconds, and the number under the channel shows it in whole dB ("–" below the meter range), -- the response time, **Mute** and **Ident.** (identify). +- the response time, **Mute**, **Ident.** (identify) and **EQ**, which opens the speaker's EQ + window once the speaker has reported its model. On the KH 120 II the input meters are captioned with the rear input switch (Analog, AES3 L, AES3 R, Mono), and a dot marks the channels the speaker plays. That channel A is the left AES3 @@ -147,6 +148,11 @@ Escape, **Close** or a click outside closes it. **All values…** lists every value the speaker reports in its own schema, with a path filter and copy buttons, including paths the catalog does not know yet. +**Trend (2 h)** below the settings shows what the app read in this session, at most the last two +hours: the temperature as a line (KH 750) with its lowest and highest value, and the warnings as a +bar, red while a warning was active. Hover over them for the value and how long ago it was read. +Gaps mark the times the speaker could not be read. + To remove a speaker, press **Remove** in its panel and confirm. The speaker is no longer remembered, and it also leaves its group and its place in the order. @@ -181,7 +187,7 @@ its rows. Scanning and adding speakers or groups stay on the desk. ### EQ -**EQ** in a speaker's device panel opens its EQ in a window of its own (one per speaker; it can +**EQ** on a speaker's strip or in its device panel opens its EQ in a window of its own (one per speaker; it can be moved to another screen). On the KH 750 choose the output first (Subwoofer for its own woofer, Output A and Output B for its two output sockets); then each EQ of that output has a tab: "EQ 2", "EQ 3", and on the KH 750 the crossovers of input A and input B ("Crossover A", @@ -198,13 +204,19 @@ tab: "EQ 2", "EQ 3", and on the KH 750 the crossovers of input A and input B ("C a comma or a point, and a value outside the allowed range is refused with the reason. - A change is written when the gesture ends, and only for that one band, so the other bands stay as they are. Every change appears in the history and can be undone. +- **Make flat** sets level and boost of every band of the chosen EQ to 0 dB, after a question; + frequency, Q, type and on/off stay. It is one history entry. The crossovers have no **Make + flat**: flattening them would remove the subwoofer's low pass. +- **Undo** in the window takes back the speaker's newest EQ change, also with Ctrl+Z while the + window is in front; the line beside it says which change that is. When the values changed + since, the window asks first. ### History and undo **History** in the toolbar lists every setting the app wrote in this session, newest first, with the time, the speaker, the setting and the value before and after. A group or master move is one entry. **Undo** writes the values before again; Ctrl+Z does the same for the newest entry (not -while you type in a value field). An undo is itself an entry, so it can be undone too. +while you type in a value field; in an EQ window, for that speaker's newest EQ change). An undo is itself an entry, so it can be undone too. When a value changed since it was written (in another app or on the speaker), the app lists the changes and asks before writing the old value back. A speaker that is not connected is left out, From 859f1633f41c91886aeff283c747d77de7f37601 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:25:36 +0200 Subject: [PATCH 144/460] Add mute persist, the input mix and the auto standby switch --- crates/app/src/texts.rs | 11 +++ crates/app/src/views/details.rs | 2 + crates/app/src/views/details_fields.rs | 50 ++++++++++++++ crates/app/tests/details.rs | 60 +++++++++++++++- crates/core/src/catalog.rs | 38 +++++++++++ crates/core/src/paths.toml | 94 +++++++++++++++++++++++++- crates/core/tests/catalog.rs | 52 ++++++++++++++ 7 files changed, 304 insertions(+), 3 deletions(-) diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index d8dcf01..50faba0 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -498,6 +498,11 @@ pub fn history_value(value: &Value, unit: Option<&str>) -> String { .as_f64() .map_or_else(|| number.to_string(), self::number), Value::String(text) => text.clone(), + Value::Array(entries) => entries + .iter() + .map(|entry| history_value(entry, None)) + .collect::>() + .join(" / "), other => other.to_string(), }; match unit { @@ -641,6 +646,12 @@ pub fn eq_refusal(band: usize, leaf: &str, reason: &str) -> String { mod tests { use super::*; + #[test] + fn a_list_value_reads_entry_by_entry() { + let value = serde_json::json!([0.0, -20.5]); + assert_eq!(history_value(&value, Some("dB")).replace(',', "."), "0 / -20.5 dB"); + } + #[test] fn an_unknown_value_before_reads_as_a_question_mark() { assert_eq!(history_before(None, Some("dB")), "?"); diff --git a/crates/app/src/views/details.rs b/crates/app/src/views/details.rs index 669f495..ef6d10e 100644 --- a/crates/app/src/views/details.rs +++ b/crates/app/src/views/details.rs @@ -111,7 +111,9 @@ fn row(ui: &mut Ui, fields: &mut Fields, entry: &Entry) { display(ui, fields.speaker, entry); return; } + let is_list = !entry.element_labels(texts::language()).is_empty(); match entry.kind { + Kind::Number if is_list => fields.number_list(ui, entry), Kind::Number => { ui.horizontal(|ui| { let catalog = fields.catalog; diff --git a/crates/app/src/views/details_fields.rs b/crates/app/src/views/details_fields.rs index b34deaa..e94ee42 100644 --- a/crates/app/src/views/details_fields.rs +++ b/crates/app/src/views/details_fields.rs @@ -81,6 +81,56 @@ impl Fields<'_> { self.send(ui, Intent::Write { path, value }); } + /// A list value (`elements` in the catalog): one field per entry. A change writes the whole + /// list, the other entries as the speaker reported them. + pub fn number_list(&mut self, ui: &mut Ui, entry: &Entry) { + let names = entry.element_labels(texts::language()); + let reported: Option> = self + .speaker + .values + .get(&entry.path) + .and_then(Value::as_array) + .filter(|entries| entries.len() == names.len()) + .cloned(); + let product = self.speaker.product(); + let min = entry.min.unwrap_or(f64::NEG_INFINITY); + let max = entry + .max_for(product, &self.catalog.classes) + .unwrap_or(f64::INFINITY); + let mut written = None; + ui.horizontal_wrapped(|ui| { + ui.label(entry.label(texts::language())); + for (index, name) in names.iter().enumerate() { + let device = reported + .as_ref() + .and_then(|entries| entries[index].as_f64()); + let label = ui.label(name); + let field = NumberField { + id: ui.id().with(entry.path.to_string()).with(index), + device, + range: min..=max, + step: entry.step, + drag_speed: entry.step.unwrap_or(UNSTEPPED_DRAG_SPEED), + unit: entry.unit.as_deref(), + conversion: entry.conversion, + enabled: self.enabled(entry, device.is_some()), + labelled_by: Some(label.id), + }; + if let Some(number) = number_field::show(ui, &field) { + written = Some((index, number)); + } + } + }); + let (Some((index, number)), Some(mut entries)) = (written, reported) else { + return; + }; + // Outside the range it goes as typed, for the write policy to refuse with the reason. + entries[index] = Value::from(number); + let path = entry.path.clone(); + let value = Value::Array(entries); + self.send(ui, Intent::Write { path, value }); + } + pub fn switch(&mut self, ui: &mut Ui, entry: &Entry) { let current = self .speaker diff --git a/crates/app/tests/details.rs b/crates/app/tests/details.rs index 6fe9b15..c3e98c7 100644 --- a/crates/app/tests/details.rs +++ b/crates/app/tests/details.rs @@ -570,5 +570,63 @@ fn a_converted_delay_shows_only_the_converted_unit() { .collect(); let delays: Vec<&String> = shown.iter().filter(|text| text.ends_with(" ms")).collect(); assert!(!delays.is_empty(), "{shown:?}"); - assert!(shown.iter().all(|text| !text.contains("1/48 kHz")), "{shown:?}"); + assert!( + shown.iter().all(|text| !text.contains("1/48 kHz")), + "{shown:?}" + ); +} + +const INPUT_MIX_POINTER: &str = "/audio/out/mixer/levels"; + +#[test] +fn the_input_mix_writes_the_whole_list() { + let (simulator, _directory, mut harness) = speaker_with_panel(SimModel::Kh120Ii); + let before = simulator.value("audio/out/mixer/levels").unwrap(); + harness + .query_all_by_role_and_label(Role::SpinButton, "Eingang B") + .next() + .expect("a field for input B") + .click(); + harness.run_steps(1); + harness + .query_all_by_role_and_label(Role::SpinButton, "Eingang B") + .next() + .unwrap() + .type_text("-20"); + harness.key_press(egui::Key::Enter); + wait_until(&mut harness, "the mix written", |_| { + !sets_to(&simulator, INPUT_MIX_POINTER).is_empty() + }); + let mut expected = before.as_array().unwrap().clone(); + expected[1] = json!(-20.0); + assert_eq!( + sets_to(&simulator, INPUT_MIX_POINTER), + vec![Value::Array(expected)] + ); +} + +#[test] +fn mute_persist_is_a_checkbox_that_writes() { + let (simulator, _directory, mut harness) = speaker_with_panel(SimModel::Kh120Ii); + assert_eq!(role_of(&harness, "Stumm-Persistenz"), Role::CheckBox); + harness.get_by_label("Stumm-Persistenz").click(); + wait_until(&mut harness, "mute persist written", |_| { + !sets_to(&simulator, "/audio/out/mute_persist").is_empty() + }); + assert_eq!( + sets_to(&simulator, "/audio/out/mute_persist"), + vec![json!(true)] + ); +} + +#[test] +fn kh750_shows_its_auto_standby_switch() { + let (_simulator, _directory, harness) = speaker_with_panel(SimModel::Kh750); + harness.get_by_label("Auto-Standby-Schalter"); + assert_eq!( + harness + .query_all_by_role_and_label(Role::SpinButton, "Eingang A") + .count(), + 3 + ); } diff --git a/crates/core/src/catalog.rs b/crates/core/src/catalog.rs index b8dc351..f32583e 100644 --- a/crates/core/src/catalog.rs +++ b/crates/core/src/catalog.rs @@ -198,6 +198,12 @@ pub struct Entry { /// elsewhere). Filled in from the `[[eq]]` table, never written in `[[entry]]`. #[serde(default)] pub bands: bool, + /// A list value with one entry per name (`audio/out/mixer/levels`: input A, input B). It is + /// written whole: whether `null` leaves an entry as it is was not measured for these paths. + #[serde(default)] + elements: Vec, + #[serde(default)] + elements_en: Vec, } impl Entry { @@ -208,6 +214,14 @@ impl Entry { } } + /// The names of a list value's entries; empty for a single value. + pub fn element_labels(&self, language: Language) -> &[String] { + match language { + Language::German => &self.elements, + Language::English => &self.elements_en, + } + } + pub fn is_writable(&self, product: &str) -> bool { self.writable_on.iter().any(|known| known == product) } @@ -228,9 +242,28 @@ impl Entry { if self.bands { return self.validate_bands(value, product, classes); } + if !self.elements.is_empty() { + return self.validate_elements(value, product, classes); + } self.validate_one(value, product, classes) } + /// Every entry given and valid, in a list of the catalog's length. + fn validate_elements( + &self, + value: &Value, + product: &str, + classes: &ModelClasses, + ) -> Result<(), ValueError> { + let entries = value.as_array().ok_or(ValueError::WrongType)?; + if entries.len() != self.elements.len() { + return Err(ValueError::WrongType); + } + entries + .iter() + .try_for_each(|entry| self.validate_one(entry, product, classes)) + } + /// At least one band given and every given band valid; `null` leaves a band as it is. fn validate_bands( &self, @@ -390,6 +423,8 @@ impl EqContainer { writable_on: self.writable_on.clone(), conversion: None, bands: true, + elements: Vec::new(), + elements_en: Vec::new(), }) .collect() } @@ -559,6 +594,9 @@ fn check_entry(entry: &Entry, classes: &ModelClasses) -> Result<(), CatalogError if entry.kind == Kind::Text && entry.max_length.is_none() { return Err(invalid("text without max_length")); } + if entry.elements.len() != entry.elements_en.len() { + return Err(invalid("element names differ between the languages")); + } let empty_range = |max: Option| matches!((entry.min, max), (Some(min), Some(max)) if min >= max); if empty_range(entry.max) || empty_range(entry.max_subwoofer) { diff --git a/crates/core/src/paths.toml b/crates/core/src/paths.toml index dee09ff..2ba14cb 100644 --- a/crates/core/src/paths.toml +++ b/crates/core/src/paths.toml @@ -9,8 +9,14 @@ # not listed in `writable_on` is read-only, so unmeasured models never get a write. # # `label` is the German UI copy, `label_en` the English one. EQ containers are in `[[eq]]` at the end; -# each becomes six band entries. Out of scope (not listed): restore, save_settings, out5, -# mixer, mute_persist, update, auth, system, time, macs. +# each becomes six band entries. `elements` name the entries of a list value, which is written +# whole. Out of scope (not listed): restore, save_settings, out5, update, auth, system, time +# (reads 0 on both models), macs. +# +# mute_persist and mixer/levels: write-tested 2026-10-20 on KH 120 II (out) and KH 750 (out, out1, +# out2), restored. What mute_persist does is not measured. mixer/levels entry 0 is input A, entry 1 +# input B: measured on the KH 750 with a tone at input B, -6 dB on an entry moved its output's meter +# by -6.0 dB (out entry 1 -> Sub, out1 entry 0 -> A, out2 entry 1 -> B). [models] # Models with subwoofer features; every other product counts as a monitor. @@ -122,6 +128,31 @@ kind = "display" poll = "slow" class = "subwoofer" +[[entry]] +path = "audio/out/mute_persist" +label = "Stumm-Persistenz" +label_en = "Mute persist" +group = "output" +kind = "switch" +poll = "slow" +class = "all" +writable_on = ["KH 120 II", "KH 750"] + +[[entry]] +path = "audio/out/mixer/levels" +label = "Eingangsmischung" +label_en = "Input mix" +group = "output" +kind = "number" +poll = "slow" +class = "all" +unit = "dB" +min = -100 +max = 0 +elements = ["Eingang A", "Eingang B"] +elements_en = ["Input A", "Input B"] +writable_on = ["KH 120 II", "KH 750"] + [[entry]] path = "audio/out1/level" label = "Pegel" @@ -178,6 +209,31 @@ kind = "display" poll = "slow" class = "subwoofer" +[[entry]] +path = "audio/out1/mute_persist" +label = "Stumm-Persistenz" +label_en = "Mute persist" +group = "output1" +kind = "switch" +poll = "slow" +class = "subwoofer" +writable_on = ["KH 750"] + +[[entry]] +path = "audio/out1/mixer/levels" +label = "Eingangsmischung" +label_en = "Input mix" +group = "output1" +kind = "number" +poll = "slow" +class = "subwoofer" +unit = "dB" +min = -100 +max = 0 +elements = ["Eingang A", "Eingang B"] +elements_en = ["Input A", "Input B"] +writable_on = ["KH 750"] + [[entry]] path = "audio/out2/level" label = "Pegel" @@ -234,6 +290,31 @@ kind = "display" poll = "slow" class = "subwoofer" +[[entry]] +path = "audio/out2/mute_persist" +label = "Stumm-Persistenz" +label_en = "Mute persist" +group = "output2" +kind = "switch" +poll = "slow" +class = "subwoofer" +writable_on = ["KH 750"] + +[[entry]] +path = "audio/out2/mixer/levels" +label = "Eingangsmischung" +label_en = "Input mix" +group = "output2" +kind = "number" +poll = "slow" +class = "subwoofer" +unit = "dB" +min = -100 +max = 0 +elements = ["Eingang A", "Eingang B"] +elements_en = ["Input A", "Input B"] +writable_on = ["KH 750"] + [[entry]] path = "audio/in/interface" label = "Eingangs-Interface" @@ -502,6 +583,15 @@ poll = "slow" class = "subwoofer" unit = "°" +[[entry]] +path = "ui/auto_standby" +label = "Auto-Standby-Schalter" +label_en = "Auto standby switch" +group = "rear_panel" +kind = "display" +poll = "slow" +class = "subwoofer" + [[entry]] path = "m/in/level" label = "Eingangspegel" diff --git a/crates/core/tests/catalog.rs b/crates/core/tests/catalog.rs index 384e874..5700f4a 100644 --- a/crates/core/tests/catalog.rs +++ b/crates/core/tests/catalog.rs @@ -389,3 +389,55 @@ fn a_flat_full_array_is_a_valid_band_write() { Ok(()) ); } + +#[test] +fn the_d2_settings_are_in_the_catalog() { + for output in ["out", "out1", "out2"] { + let product = if output == "out" { MONITOR } else { SUBWOOFER }; + let persist = entry(&format!("audio/{output}/mute_persist")); + assert_eq!(persist.kind, Kind::Switch); + assert!(persist.is_writable(SUBWOOFER)); + assert_eq!(persist.is_writable(MONITOR), output == "out"); + let mixer = entry(&format!("audio/{output}/mixer/levels")); + assert_eq!(mixer.kind, Kind::Number); + assert!(mixer.is_writable(product)); + assert!(!mixer.is_writable(UNKNOWN_PRODUCT)); + assert_eq!( + mixer.element_labels(Language::German), + ["Eingang A", "Eingang B"] + ); + assert_eq!( + mixer.element_labels(Language::English), + ["Input A", "Input B"] + ); + } + assert!(has(SUBWOOFER, "ui/auto_standby")); + assert!(!has(MONITOR, "ui/auto_standby")); + assert_eq!(entry("ui/auto_standby").kind, Kind::Display); +} + +#[test] +fn a_list_value_is_written_whole() { + let catalog = Catalog::embedded(); + let mixer = entry("audio/out/mixer/levels"); + let check = |value| mixer.validate(&value, MONITOR, &catalog.classes); + assert_eq!(check(json!([0.0, -6.0])), Ok(())); + assert_eq!(check(json!([null, -6.0])), Err(ValueError::WrongType)); + assert_eq!(check(json!([-6.0])), Err(ValueError::WrongType)); + assert_eq!(check(json!(-6.0)), Err(ValueError::WrongType)); + assert_eq!( + check(json!([0.0, 3.0])), + Err(ValueError::OutOfRange { + min: -100.0, + max: 0.0 + }) + ); +} + +#[test] +fn element_labels_must_match_in_both_languages() { + let list = format!( + "{LEVEL}min = -100\nmax = 0\nelements = [\"Eingang A\", \"Eingang B\"]\nelements_en = [\"Input A\"]" + ); + assert!(invalid_reason(&list).is_some()); +} From 38a2fc556dbfec9b3e5ccf3e9da60af4a9614106 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:26:20 +0200 Subject: [PATCH 145/460] Put each input of the mix on its own line --- crates/app/src/texts.rs | 5 ++++- crates/app/src/views/details_fields.rs | 10 +++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 50faba0..e4dd0ea 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -649,7 +649,10 @@ mod tests { #[test] fn a_list_value_reads_entry_by_entry() { let value = serde_json::json!([0.0, -20.5]); - assert_eq!(history_value(&value, Some("dB")).replace(',', "."), "0 / -20.5 dB"); + assert_eq!( + history_value(&value, Some("dB")).replace(',', "."), + "0 / -20.5 dB" + ); } #[test] diff --git a/crates/app/src/views/details_fields.rs b/crates/app/src/views/details_fields.rs index e94ee42..ccf9e16 100644 --- a/crates/app/src/views/details_fields.rs +++ b/crates/app/src/views/details_fields.rs @@ -98,9 +98,9 @@ impl Fields<'_> { .max_for(product, &self.catalog.classes) .unwrap_or(f64::INFINITY); let mut written = None; - ui.horizontal_wrapped(|ui| { - ui.label(entry.label(texts::language())); - for (index, name) in names.iter().enumerate() { + ui.label(entry.label(texts::language())); + for (index, name) in names.iter().enumerate() { + ui.horizontal(|ui| { let device = reported .as_ref() .and_then(|entries| entries[index].as_f64()); @@ -119,8 +119,8 @@ impl Fields<'_> { if let Some(number) = number_field::show(ui, &field) { written = Some((index, number)); } - } - }); + }); + } let (Some((index, number)), Some(mut entries)) = (written, reported) else { return; }; From 1eb3390017271b05efa1eb97842e6dc722ca3620 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:38:00 +0200 Subject: [PATCH 146/460] Describe the input mix and mute persist --- CHANGELOG.md | 2 ++ README.md | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 600060a..86447cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ - **Undo** in the EQ window and Ctrl+Z there: takes back the speaker's newest EQ change. - Trends of the temperature and the warnings over the last two hours of the session in the device panel. +- **Input mix** per output (input A and B, −100 to 0 dB) and **Mute persist** in the device + panel; the KH 750 also shows the position of its AUTO STANDBY switch. ### Changed diff --git a/README.md b/README.md index 3122373..7f36f24 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,11 @@ input, standby, device, rear panel) in two columns. Settings the model may write the rest are shown greyed. The panel also shows model, firmware and the masked serial number. Escape, **Close** or a click outside closes it. +Each output also has **Input mix**, one field per input (Input A, Input B, −100 to 0 dB): how +much of each input that output plays. By default the KH 750 plays input A on output A, input B +on output B and both on its own woofer. **Mute persist** is the speaker's own setting of that +name; what it does has not been measured. + **All values…** lists every value the speaker reports in its own schema, with a path filter and copy buttons, including paths the catalog does not know yet. @@ -294,6 +299,8 @@ fall back to their defaults, the rest is kept, and a copy of the file is kept as (vendor app, home automation), it can look unreachable as well. - On the KH 750, auto standby is shown but not written: it follows the AUTO STANDBY switch on the back, and the speaker refuses writes to it. +- What **Mute persist** does is not known yet; the app only shows and writes the speaker's own + setting. - Models other than the KH 120 II and the KH 750 are read-only. - Only IPv6 link-local addressing is supported; IPv4 is not. - Windows only. From 09c58ee4f3fb942dd57dddd0e40d4971a77bd1ee Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:41:00 +0200 Subject: [PATCH 147/460] Name a write of every band as all bands in the history --- crates/app/src/app/history.rs | 41 +++++++++++++++++++++++++---------- crates/app/src/texts.rs | 8 +++++++ 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/crates/app/src/app/history.rs b/crates/app/src/app/history.rs index df87023..dcef9dd 100644 --- a/crates/app/src/app/history.rs +++ b/crates/app/src/app/history.rs @@ -46,15 +46,24 @@ struct Described { unit: Option, /// For an EQ band write: the band it touched; its values are that band's entries. band: Option, + /// An EQ write of every band (a reset to flat). + all_bands: bool, } impl Described { - /// The part of `value` the line shows: the band's entry of an EQ array, else all of it. + /// The part of `value` the line shows: the band's entry of an EQ array, one entry for + /// all bands holding the same, else all of it. fn shown<'a>(&self, value: &'a Value) -> Option<&'a Value> { - match self.band { - Some(band) => value.as_array().and_then(|bands| bands.get(band)), - None => Some(value), + if let Some(band) = self.band { + return value.as_array().and_then(|bands| bands.get(band)); } + let bands = value.as_array().filter(|_| self.all_bands); + let first = bands.and_then(|bands| bands.first()); + let alike = bands.is_some_and(|bands| bands.iter().all(|band| Some(band) == first)); + if alike { + return first; + } + Some(value) } } @@ -231,22 +240,32 @@ impl NeumannApp { || change.path.to_string(), |entry| entry.label(language).to_owned(), ); - let band = change + let given: Vec = change .after .as_array() - .and_then(|bands| bands.iter().position(|band| !band.is_null())); + .map(|bands| { + let written = bands.iter().enumerate().filter(|(_, band)| !band.is_null()); + written.map(|(band, _)| band).collect() + }) + .unwrap_or_default(); let container = container_of(catalog, &change.path); - let setting = match (container, band) { - (Some(container), Some(band)) => { - texts::eq_change_setting(container.label(language), band + 1, &label) + let (setting, band, all_bands) = match (container, given.as_slice()) { + (Some(container), [band]) => { + let setting = texts::eq_change_setting(container.label(language), band + 1, &label); + (setting, Some(*band), false) + } + (Some(container), [_, _, ..]) => { + let setting = texts::eq_all_bands_setting(container.label(language), &label); + (setting, None, true) } - _ => label, + _ => (label, None, false), }; Described { speaker, setting, unit: entry.and_then(|entry| entry.unit.clone()), - band: container.and(band), + band, + all_bands, } } diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index e4dd0ea..8316add 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -637,6 +637,14 @@ pub fn eq_leaf_refusal(leaf: &str, reason: &str) -> String { format!("{leaf}: {reason}") } +/// What a write of every band touched: "EQ 2 · Pegel, alle Bänder". +pub fn eq_all_bands_setting(container: &str, leaf: &str) -> String { + pick( + format!("{container} · {leaf}, alle Bänder"), + format!("{container} · {leaf}, all bands"), + ) +} + /// A refused band write, under the EQ graph. pub fn eq_refusal(band: usize, leaf: &str, reason: &str) -> String { format!("{} {band} · {leaf}: {reason}", BAND.get()) From 53b089cb1a4b3e28138ac003a3bc615221ec4cfb Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:41:00 +0200 Subject: [PATCH 148/460] Open the EQ window from the strip in the tests --- crates/app/tests/eq.rs | 69 +++++++++++++++++++++++++++++------------- 1 file changed, 48 insertions(+), 21 deletions(-) diff --git a/crates/app/tests/eq.rs b/crates/app/tests/eq.rs index ab2950d..aa03b6f 100644 --- a/crates/app/tests/eq.rs +++ b/crates/app/tests/eq.rs @@ -26,10 +26,16 @@ fn spread(simulator: &Simulator) { simulator.set_value("audio/out/eq2/frequency", json!(frequencies)); } +/// Opened from the strip: the KH 750's panel is taller than the test window, so its EQ button +/// lies outside it. fn open_eq(model: SimModel) -> (Simulator, tempfile::TempDir, Harness<'static, NeumannApp>) { let (simulator, directory, mut harness) = connected_speaker_with(model, spread); - open_panel(&mut harness, device_name(model)); - click_panel_eq(&mut harness); + // The embedded window is cut off in the small window, its close button with it. + harness.set_size(EXPANDED_WINDOW_SIZE.into()); + harness.run_steps(2); + harness + .get_by_role_and_label(Role::Button, texts::EQ.get()) + .click(); wait_for_label(&mut harness, &texts::eq_band(1)); (simulator, directory, harness) } @@ -61,8 +67,11 @@ fn gain_fields(harness: &Harness<'static, NeumannApp>) -> Vec { } #[test] -fn the_eq_button_opens_the_window_with_both_containers() { - let (_simulator, _directory, harness) = open_eq(SimModel::Kh120Ii); +fn the_panels_eq_button_opens_the_window_with_both_containers() { + let (_simulator, _directory, mut harness) = connected_speaker_with(SimModel::Kh120Ii, spread); + open_panel(&mut harness, device_name(SimModel::Kh120Ii)); + click_panel_eq(&mut harness); + wait_for_label(&mut harness, &texts::eq_band(1)); harness.get_by_label("EQ 2"); harness.get_by_label("EQ 3"); assert_eq!(gain_fields(&harness), vec![false; 10]); @@ -340,20 +349,6 @@ fn eq_change_then_mute(harness: &mut Harness<'static, NeumannApp>, simulator: &S wait_for_label(harness, &texts::eq_band(1)); } -fn open_from_strip( - model: SimModel, -) -> (Simulator, tempfile::TempDir, Harness<'static, NeumannApp>) { - let (simulator, directory, mut harness) = connected_speaker_with(model, spread); - // The embedded window is cut off in the small window, its close button with it. - harness.set_size(EXPANDED_WINDOW_SIZE.into()); - harness.run_steps(2); - harness - .get_by_role_and_label(Role::Button, texts::EQ.get()) - .click(); - wait_for_label(&mut harness, &texts::eq_band(1)); - (simulator, directory, harness) -} - fn assert_band_2_written_back(simulator: &Simulator) { let boost = sets(simulator, BOOST_POINTER); assert_eq!(boost.len(), 2, "{boost:?}"); @@ -365,7 +360,7 @@ fn assert_band_2_written_back(simulator: &Simulator) { #[test] fn eq_undo_takes_back_the_speakers_last_eq_change() { - let (simulator, _directory, mut harness) = open_from_strip(SimModel::Kh120Ii); + let (simulator, _directory, mut harness) = open_eq(SimModel::Kh120Ii); eq_change_then_mute(&mut harness, &simulator); harness.get_by_label_contains(&texts::eq_change_setting("EQ 2", 2, BOOST_LABEL)); harness @@ -379,7 +374,7 @@ fn eq_undo_takes_back_the_speakers_last_eq_change() { #[test] fn ctrl_z_in_the_eq_window_undoes_eq_only() { - let (simulator, _directory, mut harness) = open_from_strip(SimModel::Kh120Ii); + let (simulator, _directory, mut harness) = open_eq(SimModel::Kh120Ii); eq_change_then_mute(&mut harness, &simulator); harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Z); wait_until(&mut harness, "the band written back", |_| { @@ -392,7 +387,39 @@ fn ctrl_z_in_the_eq_window_undoes_eq_only() { #[test] fn eq_undo_is_disabled_without_eq_changes() { - let (_simulator, _directory, harness) = open_from_strip(SimModel::Kh120Ii); + let (_simulator, _directory, harness) = open_eq(SimModel::Kh120Ii); let undo = harness.get_by_role_and_label(Role::Button, texts::EQ_UNDO.get()); assert!(undo.accesskit_node().is_disabled()); } + +#[test] +fn a_reset_reads_as_all_bands_in_the_history() { + let (simulator, _directory, mut harness) = connected_speaker_with(SimModel::Kh120Ii, shaped); + harness.set_size(EXPANDED_WINDOW_SIZE.into()); + harness.run_steps(2); + harness + .get_by_role_and_label(Role::Button, texts::EQ.get()) + .click(); + wait_for_label(&mut harness, &texts::eq_band(1)); + for _ in 0..2 { + harness + .get_by_role_and_label(Role::Button, texts::EQ_RESET.get()) + .click(); + harness.run_steps(2); + } + wait_until(&mut harness, "both arrays written", |_| { + !sets(&simulator, GAIN_POINTER).is_empty() && !sets(&simulator, BOOST_POINTER).is_empty() + }); + harness.get_by_label(texts::CLOSE.get()).click(); + harness.run_steps(2); + harness.get_by_label(texts::HISTORY.get()).click(); + harness.run_steps(2); + // The changes of an entry stand in the tooltip of its line. + harness + .get_by_label_contains(&texts::history_changes(None, 2)) + .hover(); + harness.run_steps(3); + let one_band = texts::eq_change_setting("EQ 2", 1, GAIN_LABEL); + assert!(harness.query_by_label_contains(&one_band).is_none()); + harness.get_by_label_contains(&texts::eq_all_bands_setting("EQ 2", GAIN_LABEL)); +} From 5799bb4f1d16f19a67ca8a9dffbe3a17769e12ea Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:01:59 +0200 Subject: [PATCH 149/460] Make crossover protection a setting --- crates/app/src/app/eq.rs | 5 ++- crates/app/src/texts.rs | 8 +++++ crates/app/src/views/eq_window.rs | 5 +++ crates/app/src/views/settings_dialog.rs | 15 +++++++- crates/app/tests/eq.rs | 48 +++++++++++++++++++++++++ crates/app/tests/support/mod.rs | 2 +- crates/core/src/settings.rs | 12 +++++++ crates/core/tests/settings.rs | 17 +++++++-- 8 files changed, 107 insertions(+), 5 deletions(-) diff --git a/crates/app/src/app/eq.rs b/crates/app/src/app/eq.rs index fb4a7d1..8394b5b 100644 --- a/crates/app/src/app/eq.rs +++ b/crates/app/src/app/eq.rs @@ -3,6 +3,7 @@ use eframe::egui::{Color32, Context, Id, ViewportId}; use neumann_control_core::catalog::{Catalog, EqContainer, EQ_TYPES}; +use neumann_control_core::settings::CrossoverGuard; use neumann_control_core::ssc::wire::SscPath; use serde_json::Value; @@ -197,11 +198,13 @@ impl NeumannApp { .filter(|_| self.eq_question_window() == Some(index)) .and_then(|id| self.history.history.entry(id)) .map(|entry| self.changed_lines(entry)); + let crossovers_open = self.settings.desk.crossovers == CrossoverGuard::Open; let reset = selected - .filter(|shown| editable && !shown.container.crossover) + .filter(|shown| editable && (crossovers_open || !shown.container.crossover)) .map(|shown| ResetOffer { container: shown.container.label(texts::language()).to_owned(), asking: window.asking_reset, + crossover: shown.container.crossover, }); let view = EqView { viewport: ViewportId::from_hash_of(id), diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 8316add..663573d 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -175,6 +175,14 @@ pub const EQ_OUTPUT_B: Text = Text::new("Ausgang B", "Output B"); pub const BAND: Text = Text::new("Band", "Band"); pub const EQ_RESET: Text = Text::new("Flach stellen", "Make flat"); pub const EQ_UNDO: Text = Text::new("Rückgängig", "Undo"); +pub const CROSSOVER_WARNING: Text = Text::new( + "Das ist eine Weiche: ihr Tief- bzw. Hochpass entfällt, der Ausgang spielt ungefiltert.", + "This is a crossover: its low or high pass goes, the output plays unfiltered.", +); +pub const PROTECT_CROSSOVERS: Text = Text::new( + "Weichen schützen (kein Flachstellen, keine EQ-Presets)", + "Protect crossovers (no flattening, no EQ presets)", +); pub const NO_TREND_VALUES: Text = Text::new("Noch keine Werte", "No values yet"); pub const TREND_NOW: Text = Text::new("jetzt", "now"); pub const WARNING_TREND: Text = Text::new("Warnungsverlauf", "Warning trend"); diff --git a/crates/app/src/views/eq_window.rs b/crates/app/src/views/eq_window.rs index 00c64c3..1df2c7f 100644 --- a/crates/app/src/views/eq_window.rs +++ b/crates/app/src/views/eq_window.rs @@ -41,6 +41,8 @@ pub struct ResetOffer { pub container: String, /// The question is shown instead of the button. pub asking: bool, + /// The container is a crossover (offered only with the protection off): the question warns. + pub crossover: bool, } #[derive(Clone, Debug, PartialEq)] @@ -182,6 +184,9 @@ fn question(ui: &mut Ui, lines: &[String]) -> Option { fn reset_question(ui: &mut Ui, reset: &ResetOffer) -> Option { ui.label(texts::eq_reset_question(&reset.container)); + if reset.crossover { + ui.colored_label(ui.visuals().warn_fg_color, texts::CROSSOVER_WARNING); + } if ui.button(texts::EQ_RESET).clicked() { return Some(EqAction::ResetConfirm); } diff --git a/crates/app/src/views/settings_dialog.rs b/crates/app/src/views/settings_dialog.rs index c8bdea9..8d54f97 100644 --- a/crates/app/src/views/settings_dialog.rs +++ b/crates/app/src/views/settings_dialog.rs @@ -2,7 +2,9 @@ //! response times. Every change applies at once; the app saves it. use eframe::egui::{Id, Modal, Ui}; -use neumann_control_core::settings::{DeskSettings, LanguageChoice, MeterFloor, ThemeChoice}; +use neumann_control_core::settings::{ + CrossoverGuard, DeskSettings, LanguageChoice, MeterFloor, ThemeChoice, +}; use crate::texts; @@ -46,6 +48,17 @@ fn contents(ui: &mut Ui, desk: &mut DeskSettings) -> SettingsAction { } }); ui.checkbox(&mut desk.show_response_times, texts::SHOW_RESPONSE_TIMES); + let mut protected = desk.crossovers == CrossoverGuard::Protected; + if ui + .checkbox(&mut protected, texts::PROTECT_CROSSOVERS) + .changed() + { + desk.crossovers = if protected { + CrossoverGuard::Protected + } else { + CrossoverGuard::Open + }; + } ui.separator(); if ui.button(texts::CLOSE).clicked() { return SettingsAction::Close; diff --git a/crates/app/tests/eq.rs b/crates/app/tests/eq.rs index aa03b6f..d757b0c 100644 --- a/crates/app/tests/eq.rs +++ b/crates/app/tests/eq.rs @@ -10,6 +10,7 @@ use neumann_control_app::app::NeumannApp; use neumann_control_app::texts; use neumann_control_app::writes::Refusal; use neumann_control_core::catalog::ValueError; +use neumann_control_core::settings::CrossoverGuard; use neumann_control_core::simulator::{SimModel, Simulator}; use serde_json::{json, Value}; use support::*; @@ -423,3 +424,50 @@ fn a_reset_reads_as_all_bands_in_the_history() { assert!(harness.query_by_label_contains(&one_band).is_none()); harness.get_by_label_contains(&texts::eq_all_bands_setting("EQ 2", GAIN_LABEL)); } + +/// A KH 750 with its EQ window open, crossover protection switched off in the settings. +fn open_eq_with_open_crossovers() -> (Simulator, tempfile::TempDir, Harness<'static, NeumannApp>) { + let simulator = Simulator::start(SimModel::Kh750); + simulator.set_reply_delay(REPLY_DELAY); + spread(&simulator); + let directory = tempfile::tempdir().unwrap(); + let path = settings_file(directory.path(), vec![manual_for(&simulator)]); + let mut settings = neumann_control_core::settings::load(&path); + settings.desk.crossovers = CrossoverGuard::Open; + neumann_control_core::settings::save(&settings, &path).unwrap(); + let mut harness = harness(path, Vec::new()); + wait_for_level(&mut harness, 100.0); + wait_for_initial_read(&mut harness, &simulator); + harness.set_size(EXPANDED_WINDOW_SIZE.into()); + harness.run_steps(2); + harness + .get_by_role_and_label(Role::Button, texts::EQ.get()) + .click(); + wait_for_label(&mut harness, &texts::eq_band(1)); + (simulator, directory, harness) +} + +#[test] +fn with_open_crossovers_flat_is_offered_with_a_warning() { + let (_simulator, _directory, mut harness) = open_eq_with_open_crossovers(); + harness.get_by_label("Weiche A").click(); + harness.run_steps(2); + assert_eq!(reset_buttons(&harness), 1); + harness + .get_by_role_and_label(Role::Button, texts::EQ_RESET.get()) + .click(); + harness.run_steps(2); + harness.get_by_label_contains(texts::CROSSOVER_WARNING.get()); +} + +#[test] +fn eq_2_has_no_crossover_warning() { + let (_simulator, _directory, mut harness) = open_eq_with_open_crossovers(); + harness + .get_by_role_and_label(Role::Button, texts::EQ_RESET.get()) + .click(); + harness.run_steps(2); + assert!(harness + .query_by_label_contains(texts::CROSSOVER_WARNING.get()) + .is_none()); +} diff --git a/crates/app/tests/support/mod.rs b/crates/app/tests/support/mod.rs index 78b070d..5141b08 100644 --- a/crates/app/tests/support/mod.rs +++ b/crates/app/tests/support/mod.rs @@ -39,7 +39,7 @@ const DIALOG_SETTLE_FRAMES: usize = 2; const PRODUCT_PATH: &str = "device/identity/product"; /// Real speakers take tens of milliseconds per reply; an instant simulator hid tests that /// acted before the connect-time read had arrived. -const REPLY_DELAY: Duration = Duration::from_millis(5); +pub const REPLY_DELAY: Duration = Duration::from_millis(5); pub struct SimulatorSpawner { pub scan_result: ScanResult, diff --git a/crates/core/src/settings.rs b/crates/core/src/settings.rs index f1d5a73..03f682f 100644 --- a/crates/core/src/settings.rs +++ b/crates/core/src/settings.rs @@ -108,6 +108,8 @@ pub struct DeskSettings { pub language: LanguageChoice, #[serde(deserialize_with = "or_default")] pub compact: bool, + #[serde(deserialize_with = "or_default")] + pub crossovers: CrossoverGuard, } impl Default for DeskSettings { @@ -118,10 +120,20 @@ impl Default for DeskSettings { show_response_times: true, language: LanguageChoice::System, compact: false, + crossovers: CrossoverGuard::Protected, } } } +/// Whether a crossover may be flattened or overwritten by an EQ preset: that removes the split +/// between the outputs. A value that is not understood falls back to protected. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum CrossoverGuard { + #[default] + Protected, + Open, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] pub enum ThemeChoice { #[default] diff --git a/crates/core/tests/settings.rs b/crates/core/tests/settings.rs index 265b577..8184cd5 100644 --- a/crates/core/tests/settings.rs +++ b/crates/core/tests/settings.rs @@ -1,6 +1,6 @@ use neumann_control_core::settings::{ - load, save, DeskSettings, GroupSettings, Language, LanguageChoice, Linkable, ManualSpeaker, - MeterFloor, Settings, ThemeChoice, WindowGeometry, SETTINGS_FILE, + load, save, CrossoverGuard, DeskSettings, GroupSettings, Language, LanguageChoice, Linkable, + ManualSpeaker, MeterFloor, Settings, ThemeChoice, WindowGeometry, SETTINGS_FILE, }; use std::fs; @@ -135,6 +135,7 @@ fn desk_settings_round_trip() { show_response_times: false, language: LanguageChoice::English, compact: true, + crossovers: CrossoverGuard::Open, }, compact_window: Some(WindowGeometry { x: 10.0, @@ -293,3 +294,15 @@ fn like_windows_follows_the_system_language() { Language::English ); } + +#[test] +fn crossovers_stay_protected_unless_opened_on_purpose() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(SETTINGS_FILE); + fs::write(&path, r#"{"manual":[],"desk":{}}"#).unwrap(); + assert_eq!(load(&path).desk.crossovers, CrossoverGuard::Protected); + fs::write(&path, r#"{"manual":[],"desk":{"crossovers":"maybe"}}"#).unwrap(); + assert_eq!(load(&path).desk.crossovers, CrossoverGuard::Protected); + fs::write(&path, r#"{"manual":[],"desk":{"crossovers":"Open"}}"#).unwrap(); + assert_eq!(load(&path).desk.crossovers, CrossoverGuard::Open); +} From ae07135dfbe493a0ec11f8b2c7617955e6521b9e Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:04:05 +0200 Subject: [PATCH 150/460] Compare EQ curves of other speakers --- crates/app/src/app/eq.rs | 109 +++++++++++++++++++++++++++--- crates/app/src/texts.rs | 6 ++ crates/app/src/views/eq_graph.rs | 33 +++++++-- crates/app/src/views/eq_window.rs | 31 +++++++++ crates/app/tests/eq.rs | 38 +++++++++++ 5 files changed, 203 insertions(+), 14 deletions(-) diff --git a/crates/app/src/app/eq.rs b/crates/app/src/app/eq.rs index 8394b5b..e827755 100644 --- a/crates/app/src/app/eq.rs +++ b/crates/app/src/app/eq.rs @@ -8,14 +8,14 @@ use neumann_control_core::ssc::wire::SscPath; use serde_json::Value; use super::NeumannApp; -use crate::eq::{bands_of, container_of, flat, one_band, Band, Leaf, FLATTENING_LEAVES}; +use crate::eq::{bands_of, container_of, flat, has_eq, one_band, Band, Leaf, FLATTENING_LEAVES}; use crate::eq_response::{curve_db, graph_frequencies}; use crate::history::Entry; use crate::paths; use crate::state::{SpeakerKey, SpeakerState, Status}; use crate::texts; use crate::ui_kit::theme::group_color; -use crate::views::eq_graph::{Graph, GraphAction, GraphCurve, GraphPoint}; +use crate::views::eq_graph::{ComparedCurve, Graph, GraphAction, GraphCurve, GraphPoint}; use crate::views::eq_table::{Limits, Table, TableAction, TableRow}; use crate::views::eq_window::{self, EqAction, EqView, ResetOffer}; use crate::writes::{self, Intent}; @@ -32,6 +32,10 @@ pub(super) struct EqWindow { band: usize, refusal: Option, asking_reset: bool, + /// Other speakers whose sum is drawn for comparison. + compared: Vec, + /// The speakers offered for comparison in the last frame, in the order shown. + candidates: Vec, } /// One container as the window shows it. @@ -54,6 +58,8 @@ impl NeumannApp { band: 0, refusal: None, asking_reset: false, + compared: Vec::new(), + candidates: Vec::new(), }); } @@ -112,6 +118,41 @@ impl NeumannApp { let sum: Vec = (0..frequencies.len()) .map(|point| shown.iter().map(|container| container.curve[point]).sum()) .collect(); + let key = shown.first().map(|shown| output_key(shown.container)); + let others: Vec<&SpeakerState> = self + .speakers + .iter() + .map(|speaker| &speaker.state) + .filter(|other| other.key != window.key && has_eq(catalog, other)) + .filter(|other| { + key.as_deref() + .is_some_and(|key| has_output(catalog, other.product(), key)) + }) + .collect(); + let candidate_keys: Vec = + others.iter().map(|other| other.key.clone()).collect(); + let candidates: Vec<(String, bool)> = others + .iter() + .map(|other| (other.display_name(), window.compared.contains(&other.key))) + .collect(); + let compared_sums: Vec<(String, Color32, Vec)> = others + .iter() + .filter(|other| window.compared.contains(&other.key)) + .filter_map(|other| { + let sum = output_sum(catalog, other, key.as_deref()?, &frequencies)?; + Some((other.display_name(), sum)) + }) + .enumerate() + .map(|(place, (name, sum))| (name, group_color((shown.len() + place) as u8), sum)) + .collect(); + let compared_curves: Vec = compared_sums + .iter() + .map(|(_, color, db)| ComparedCurve { color: *color, db }) + .collect(); + let legend: Vec<(String, Color32)> = compared_sums + .iter() + .map(|(name, color, _)| (name.clone(), *color)) + .collect(); let chosen = window.container.min(shown.len().saturating_sub(1)); let selected = shown.get(chosen); let bands = selected.and_then(|shown| shown.bands.clone()); @@ -159,6 +200,7 @@ impl NeumannApp { frequencies: &frequencies, curves: &curves, sum: &sum, + compared: &compared_curves, points: &points, color, selected: window.band, @@ -219,8 +261,12 @@ impl NeumannApp { reset, undo, question, + candidates: &candidates, + compared: &legend, }; - eq_window::show(context, &view) + let actions = eq_window::show(context, &view); + self.eq_windows[index].candidates = candidate_keys; + actions } /// The newest entry the EQ window of `key` can undo: open, and only EQ writes to it. @@ -275,6 +321,15 @@ impl NeumannApp { self.request_take_back(id); } } + EqAction::Compare(place, on) => { + let Some(key) = window.candidates.get(place).cloned() else { + return; + }; + window.compared.retain(|compared| *compared != key); + if on { + window.compared.push(key); + } + } EqAction::QuestionConfirm => self.answer_take_back(true), EqAction::QuestionCancel => self.answer_take_back(false), EqAction::Graph(GraphAction::Select(band)) @@ -390,17 +445,51 @@ fn is_eq_entry_of(entry: &Entry, key: &SpeakerKey, catalog: &Catalog) -> bool { .all(|change| change.speaker == *key && container_of(catalog, &change.path).is_some()) } +/// The output a container belongs to: `out`, `out1` or `out2`. +fn output_key(container: &EqContainer) -> String { + container + .path + .segments() + .get(1) + .cloned() + .unwrap_or_default() +} + +fn has_output(catalog: &Catalog, product: &str, key: &str) -> bool { + catalog + .eq_containers(product) + .iter() + .any(|container| output_key(container) == key) +} + +/// The sum of the speaker's EQs on output `key`; `None` while it reported none of them. +fn output_sum( + catalog: &Catalog, + speaker: &SpeakerState, + key: &str, + frequencies: &[f64], +) -> Option> { + let curves: Vec> = catalog + .eq_containers(speaker.product()) + .into_iter() + .filter(|container| output_key(container) == key) + .filter_map(|container| bands_of(&speaker.values, &container.path)) + .map(|bands| curve_db(&bands, frequencies)) + .collect(); + if curves.is_empty() { + return None; + } + let sum = (0..frequencies.len()) + .map(|point| curves.iter().map(|curve| curve[point]).sum()) + .collect(); + Some(sum) +} + /// The speaker's EQ containers by output (`out`, `out1`, `out2`), in catalog order. fn outputs_of<'a>(catalog: &'a Catalog, product: &str) -> Vec<(String, Vec<&'a EqContainer>)> { let mut outputs: Vec<(String, Vec<&EqContainer>)> = Vec::new(); for container in catalog.eq_containers(product) { - let output = container - .path - .segments() - .get(1) - .cloned() - .unwrap_or_default(); - let name = output_name(&output); + let name = output_name(&output_key(container)); match outputs.iter_mut().find(|(known, _)| *known == name) { Some((_, containers)) => containers.push(container), None => outputs.push((name, vec![container])), diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 663573d..b47fc6a 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -175,6 +175,7 @@ pub const EQ_OUTPUT_B: Text = Text::new("Ausgang B", "Output B"); pub const BAND: Text = Text::new("Band", "Band"); pub const EQ_RESET: Text = Text::new("Flach stellen", "Make flat"); pub const EQ_UNDO: Text = Text::new("Rückgängig", "Undo"); +pub const COMPARE_WITH: Text = Text::new("Vergleichen mit:", "Compare with:"); pub const CROSSOVER_WARNING: Text = Text::new( "Das ist eine Weiche: ihr Tief- bzw. Hochpass entfällt, der Ausgang spielt ungefiltert.", "This is a crossover: its low or high pass goes, the output plays unfiltered.", @@ -632,6 +633,11 @@ pub fn trend_hover(minutes_ago: u64, value: &str) -> String { ) } +/// The legend entry of another speaker's curve in the EQ graph. +pub fn compared_curve(speaker: &str) -> String { + pick(format!("{speaker} (Summe)"), format!("{speaker} (sum)")) +} + /// Asked before a reset to flat, in the EQ window. pub fn eq_reset_question(container: &str) -> String { pick( diff --git a/crates/app/src/views/eq_graph.rs b/crates/app/src/views/eq_graph.rs index 1908ce9..fbddfa2 100644 --- a/crates/app/src/views/eq_graph.rs +++ b/crates/app/src/views/eq_graph.rs @@ -26,6 +26,8 @@ const FREQUENCY_MARKS: [f64; 10] = [ const LABEL_SIZE: f32 = 9.0; const CURVE_WIDTH: f32 = 1.5; const SUM_WIDTH: f32 = 2.5; +const DASH_LENGTH: f32 = 6.0; +const DASH_GAP: f32 = 4.0; const POINT_RADIUS: f32 = 5.0; const SELECTED_RING: f32 = 3.0; const POINT_GRAB: f32 = 14.0; @@ -37,6 +39,12 @@ pub struct GraphCurve<'a> { pub db: &'a [f64], } +/// Another speaker's sum on the same output, drawn dashed for comparison. +pub struct ComparedCurve<'a> { + pub color: Color32, + pub db: &'a [f64], +} + #[derive(Clone, Copy, Debug)] pub struct GraphPoint { pub frequency: f64, @@ -51,6 +59,7 @@ pub struct Graph<'a> { pub frequencies: &'a [f64], pub curves: &'a [GraphCurve<'a>], pub sum: &'a [f64], + pub compared: &'a [ComparedCurve<'a>], pub points: &'a [GraphPoint], pub color: Color32, pub selected: usize, @@ -123,6 +132,10 @@ pub fn show(ui: &mut Ui, graph: &Graph) -> Vec { Stroke::new(CURVE_WIDTH, curve.color), ); } + for curve in graph.compared { + let stroke = Stroke::new(CURVE_WIDTH, curve.color); + paint_dashed(ui, &axes, graph.frequencies, curve.db, stroke); + } paint_curve( ui, &axes, @@ -141,7 +154,9 @@ pub fn show(ui: &mut Ui, graph: &Graph) -> Vec { /// pass falls without end and would squeeze everything else flat; such a curve leaves the /// graph at its bottom edge. fn db_span(graph: &Graph) -> f64 { - let curves = graph.curves.iter().flat_map(|curve| curve.db.iter()); + let own = graph.curves.iter().map(|curve| curve.db); + let compared = graph.compared.iter().map(|curve| curve.db); + let curves = own.chain(compared).flatten(); let boosts = curves.chain(graph.sum).copied().filter(|db| *db > 0.0); let points = graph .points @@ -191,12 +206,22 @@ fn paint_grid(ui: &Ui, axes: &Axes, tokens: &Tokens) { } fn paint_curve(ui: &Ui, axes: &Axes, frequencies: &[f64], db: &[f64], stroke: Stroke) { - let points: Vec = frequencies + ui.painter() + .add(Shape::line(curve_points(axes, frequencies, db), stroke)); +} + +fn paint_dashed(ui: &Ui, axes: &Axes, frequencies: &[f64], db: &[f64], stroke: Stroke) { + let points = curve_points(axes, frequencies, db); + ui.painter() + .extend(Shape::dashed_line(&points, stroke, DASH_LENGTH, DASH_GAP)); +} + +fn curve_points(axes: &Axes, frequencies: &[f64], db: &[f64]) -> Vec { + frequencies .iter() .zip(db) .map(|(frequency, db)| pos2(axes.x(*frequency), axes.y(*db))) - .collect(); - ui.painter().add(Shape::line(points, stroke)); + .collect() } /// Draws one band's point and hands out what its gesture chose. diff --git a/crates/app/src/views/eq_window.rs b/crates/app/src/views/eq_window.rs index 1df2c7f..8acf2dd 100644 --- a/crates/app/src/views/eq_window.rs +++ b/crates/app/src/views/eq_window.rs @@ -35,6 +35,10 @@ pub struct EqView<'a> { pub undo: Option, /// "Changed since" for the entry being undone: the changes another client made meanwhile. pub question: Option>, + /// Other speakers with the same output: name and whether its sum is compared. + pub candidates: &'a [(String, bool)], + /// The compared curves that are drawn, for the legend: name and colour. + pub compared: &'a [(String, Color32)], } pub struct ResetOffer { @@ -58,6 +62,8 @@ pub enum EqAction { Undo, QuestionConfirm, QuestionCancel, + /// The candidate at this place is compared or no longer. + Compare(usize, bool), } pub fn show(context: &Context, view: &EqView) -> Vec { @@ -113,7 +119,14 @@ fn contents(ui: &mut Ui, view: &EqView) -> Vec { } } ui.label(RichText::new(texts::EQ_SUM).strong()); + for (name, color) in view.compared { + let (swatch, _) = + ui.allocate_exact_size(vec2(LEGEND_SWATCH, LEGEND_SWATCH), egui::Sense::hover()); + ui.painter().rect_filled(swatch, 2.0, *color); + ui.label(texts::compared_curve(name)); + } }); + actions.extend(compare_row(ui, view.candidates)); actions.extend( eq_graph::show(ui, &view.graph) .into_iter() @@ -141,6 +154,24 @@ fn contents(ui: &mut Ui, view: &EqView) -> Vec { actions } +fn compare_row(ui: &mut Ui, candidates: &[(String, bool)]) -> Option { + if candidates.is_empty() { + return None; + } + ui.horizontal_wrapped(|ui| { + ui.label(texts::COMPARE_WITH); + let mut action = None; + for (place, (name, checked)) in candidates.iter().enumerate() { + let mut now = *checked; + if ui.checkbox(&mut now, name).changed() { + action = Some(EqAction::Compare(place, now)); + } + } + action + }) + .inner +} + /// Reset and undo in one row; while the reset is asked about, its question takes the row. fn edit_row(ui: &mut Ui, reset: Option<&ResetOffer>, undo: Option<&str>) -> Option { ui.horizontal(|ui| { diff --git a/crates/app/tests/eq.rs b/crates/app/tests/eq.rs index d757b0c..565fc57 100644 --- a/crates/app/tests/eq.rs +++ b/crates/app/tests/eq.rs @@ -471,3 +471,41 @@ fn eq_2_has_no_crossover_warning() { .query_by_label_contains(texts::CROSSOVER_WARNING.get()) .is_none()); } + +/// The EQ window of the strip whose name is `name`, with two speakers on the desk. +fn open_eq_of(harness: &mut Harness<'static, NeumannApp>, name: &str) { + harness.set_size(EXPANDED_WINDOW_SIZE.into()); + harness.run_steps(2); + let column = harness.get_by_label(name).rect().center().x; + let button = harness + .query_all_by_role_and_label(Role::Button, texts::EQ.get()) + .min_by(|a, b| { + let distance = |node: &egui_kittest::Node| (node.rect().center().x - column).abs(); + distance(a).total_cmp(&distance(b)) + }) + .expect("an EQ button"); + button.click(); + wait_for_label(harness, &texts::eq_band(1)); +} + +#[test] +fn another_speakers_curve_can_be_compared() { + let (_kh120, _kh750, _directory, mut harness) = grouped(Vec::new()); + open_eq_of(&mut harness, KH750_DEVICE_NAME); + let compared = texts::compared_curve(KH120_DEVICE_NAME); + assert!(harness.query_by_label(&compared).is_none()); + harness + .get_by_role_and_label(Role::CheckBox, KH120_DEVICE_NAME) + .click(); + wait_until(&mut harness, "the compared curve", |harness| { + harness.query_by_label(&compared).is_some() + }); + // Output A has no counterpart on the KH 120 II, so nothing to compare there. + harness + .get_by_role_and_label(Role::Button, "Ausgang A") + .click(); + harness.run_steps(2); + assert!(harness + .query_by_role_and_label(Role::CheckBox, KH120_DEVICE_NAME) + .is_none()); +} From 33c976dc166fada3d66da71c28fc0a33b8fa7fdd Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:18:41 +0200 Subject: [PATCH 151/460] Write a redacted scan report of a speaker --- crates/app/src/app.rs | 2 + crates/app/src/app/full_scan.rs | 94 ++++++++++ crates/app/src/lib.rs | 1 + crates/app/src/paths.rs | 1 + crates/app/src/scan_report.rs | 310 ++++++++++++++++++++++++++++++++ crates/app/src/state.rs | 31 +++- crates/app/src/texts.rs | 17 ++ crates/app/src/views/details.rs | 36 +++- crates/app/tests/details.rs | 33 ++++ 9 files changed, 521 insertions(+), 4 deletions(-) create mode 100644 crates/app/src/app/full_scan.rs create mode 100644 crates/app/src/scan_report.rs diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index 3ccecf1..76f917f 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -34,6 +34,7 @@ use crate::views::{all_values, panel, wheel}; mod compact; mod desk; mod eq; +mod full_scan; mod history; pub use history::TakeBackNotice; @@ -582,6 +583,7 @@ impl eframe::App for NeumannApp { fn logic(&mut self, context: &Context, _frame: &mut eframe::Frame) { let now = context.input(|input| input.time); self.drain_events(now); + self.write_scan_reports(); self.poll_scan(); let pixels_per_point = context.pixels_per_point(); let geometry = context.input(|input| window_geometry(input.viewport(), pixels_per_point)); diff --git a/crates/app/src/app/full_scan.rs b/crates/app/src/app/full_scan.rs new file mode 100644 index 0000000..4fc779d --- /dev/null +++ b/crates/app/src/app/full_scan.rs @@ -0,0 +1,94 @@ +//! "Komplett scannen" on the app side: a finished walk becomes two report files next to the +//! settings (next to the exe), and the speaker notes where they went. + +use std::fs; +use std::path::{Path, PathBuf}; + +use neumann_control_core::catalog::Catalog; +use neumann_control_core::schema_walk::LeafReport; +use serde_json::Value; + +use super::NeumannApp; +use crate::paths; +use crate::scan_report::{file_stem, json_report, redacted, text_report, ScanHeader}; +use crate::state::{FullScanState, SpeakerState}; + +const JSON_EXTENSION: &str = "json"; +const TEXT_EXTENSION: &str = "txt"; + +impl NeumannApp { + /// Writes the report of every speaker whose walk just finished. + pub(super) fn write_scan_reports(&mut self) { + let directory = self + .settings_path + .parent() + .map(Path::to_path_buf) + .unwrap_or_default(); + for speaker in &mut self.speakers { + let state = &mut speaker.state; + // Taken out and put back unless read: a running scan must stay running. + let reports = match std::mem::take(&mut state.full_scan) { + FullScanState::Read(reports) => reports, + other => { + state.full_scan = other; + continue; + } + }; + state.full_scan = match write_report(&directory, state, &reports) { + Ok(path) => FullScanState::Written(path.display().to_string()), + Err(error) => FullScanState::Failed(error.to_string()), + }; + } + } +} + +/// Both files; returns the JSON file's path. +fn write_report( + directory: &Path, + speaker: &SpeakerState, + reports: &[LeafReport], +) -> std::io::Result { + let catalog = Catalog::embedded(); + let model = speaker.product().to_owned(); + let firmware = speaker + .identity + .as_ref() + .map(|identity| identity.version.clone()) + .unwrap_or_default(); + let serial = speaker + .identity + .as_ref() + .map(|identity| identity.serial.clone()) + .unwrap_or_default(); + let name = speaker + .values + .get(&paths::path(paths::DEVICE_NAME)) + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(); + let now = time::OffsetDateTime::now_local().unwrap_or_else(|_| time::OffsetDateTime::now_utc()); + let time = format!( + "{:04}-{:02}-{:02} {:02}:{:02}:{:02}", + now.year(), + u8::from(now.month()), + now.day(), + now.hour(), + now.minute(), + now.second() + ); + let compact_time = time.replace(['-', ':'], "").replace(' ', "-"); + let header = ScanHeader { + model: &model, + firmware: &firmware, + time: &time, + }; + let reports = redacted(reports, &[serial.as_str(), name.as_str()]); + let stem = file_stem(&model, &firmware, &compact_time); + let json_path = directory.join(format!("{stem}.{JSON_EXTENSION}")); + let json = serde_json::to_string_pretty(&json_report(&header, &reports, catalog)) + .map_err(std::io::Error::other)?; + fs::write(&json_path, json)?; + let text = text_report(&header, &reports, catalog); + fs::write(directory.join(format!("{stem}.{TEXT_EXTENSION}")), text)?; + Ok(json_path) +} diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index cb8f16e..0ecb7b6 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -11,6 +11,7 @@ pub mod input_source; pub mod meter; pub mod paths; pub mod scan_choice; +pub mod scan_report; pub mod spawner; pub mod state; pub mod status_leds; diff --git a/crates/app/src/paths.rs b/crates/app/src/paths.rs index 69aea94..f8b1af1 100644 --- a/crates/app/src/paths.rs +++ b/crates/app/src/paths.rs @@ -15,6 +15,7 @@ pub const FIRMWARE_VERSION: &str = "device/identity/version"; pub const SERIAL: &str = "device/identity/serial"; pub const MAC_ADDRESSES: &str = "device/network/ether/macs"; pub const WARNINGS: &str = "warnings"; +pub const DEVICE_NAME: &str = "device/name"; pub const TEMPERATURE: &str = "device/temperature"; pub const CONTROL_MODE: &str = "ui/control_mode"; pub const INPUT_SELECT: &str = "ui/input_select"; diff --git a/crates/app/src/scan_report.rs b/crates/app/src/scan_report.rs new file mode 100644 index 0000000..a9f8432 --- /dev/null +++ b/crates/app/src/scan_report.rs @@ -0,0 +1,310 @@ +//! The report of "Komplett scannen": everything a speaker answered, with what the catalog knows +//! about each path, and nothing that identifies the speaker or its owner. No file access here. + +use neumann_control_core::catalog::Catalog; +use neumann_control_core::schema_walk::LeafReport; +use serde_json::{json, Map, Value}; + +use crate::paths; + +/// Stands for every value the report leaves out. +pub const REDACTED: &str = ""; +/// Leaves that name the speaker or its owner. +const IDENTIFYING_PATHS: &[&str] = &[paths::SERIAL, paths::MAC_ADDRESSES, paths::DEVICE_NAME]; +/// Every leaf below this holds network details of the owner's setup. +const NETWORK_PREFIX: &str = "device/network/"; +const MAC_GROUPS: usize = 6; +const MAC_GROUP_LENGTH: usize = 2; +/// An IPv6 address has at least two colons; a time like "12:30" has one. +const IPV6_MIN_COLONS: usize = 2; + +/// What the report says about the speaker, apart from its leaves. +pub struct ScanHeader<'a> { + pub model: &'a str, + pub firmware: &'a str, + /// Local time of the scan, `yyyy-mm-dd hh:mm:ss`. + pub time: &'a str, +} + +/// `scan---`, without the extension; never a serial or name. +pub fn file_stem(model: &str, firmware: &str, compact_time: &str) -> String { + let safe = |text: &str| -> String { + text.chars() + .map(|character| { + if character.is_ascii_alphanumeric() || character == '_' { + character + } else { + '-' + } + }) + .collect() + }; + format!( + "scan-{}-{}-{}", + safe(model), + safe(firmware), + safe(compact_time) + ) +} + +/// The leaves with every identifying value replaced: the listed paths, the network subtree, any +/// text that looks like a MAC or IPv6 address, and every occurrence of `known` (the speaker's +/// serial and name), wherever another leaf repeats them. +pub fn redacted(reports: &[LeafReport], known: &[&str]) -> Vec { + let known: Vec<&str> = known + .iter() + .copied() + .filter(|text| !text.is_empty()) + .collect(); + reports + .iter() + .map(|report| { + let path = report.path.to_string(); + let identifying = + IDENTIFYING_PATHS.contains(&path.as_str()) || path.starts_with(NETWORK_PREFIX); + let value = match &report.value { + _ if identifying => Ok(Value::String(REDACTED.to_owned())), + Ok(value) => Ok(redacted_value(value, &known)), + Err(text) => Err(redacted_text(text, &known)), + }; + LeafReport { + path: report.path.clone(), + value, + limits: report + .limits + .as_ref() + .map(|limits| redacted_value(limits, &known)), + } + }) + .collect() +} + +fn redacted_value(value: &Value, known: &[&str]) -> Value { + match value { + Value::String(text) => Value::String(redacted_text(text, known)), + Value::Array(items) => Value::Array( + items + .iter() + .map(|item| redacted_value(item, known)) + .collect(), + ), + Value::Object(fields) => Value::Object( + fields + .iter() + .map(|(key, value)| (key.clone(), redacted_value(value, known))) + .collect(), + ), + other => other.clone(), + } +} + +/// Replaces the known identifiers and each word that looks like an address; the rest stays. +fn redacted_text(text: &str, known: &[&str]) -> String { + let text = known.iter().fold(text.to_owned(), |text, identifier| { + text.replace(identifier, REDACTED) + }); + text.split(' ') + .map(|word| { + let bare = word.trim_matches(|character: char| ",;()[]\"'".contains(character)); + if looks_like_address(bare) { + word.replace(bare, REDACTED) + } else { + word.to_owned() + } + }) + .collect::>() + .join(" ") +} + +fn looks_like_address(word: &str) -> bool { + looks_like_mac(word) || looks_like_ipv6(word) +} + +fn looks_like_mac(word: &str) -> bool { + let groups: Vec<&str> = word.split([':', '-']).collect(); + groups.len() == MAC_GROUPS + && groups.iter().all(|group| { + group.len() == MAC_GROUP_LENGTH && group.chars().all(|digit| digit.is_ascii_hexdigit()) + }) +} + +fn looks_like_ipv6(word: &str) -> bool { + let address = word.split('%').next().unwrap_or_default(); + let colons = address.matches(':').count(); + let allowed = address + .chars() + .all(|character| character.is_ascii_hexdigit() || character == ':' || character == '.'); + let has_digit = address + .chars() + .any(|character| character.is_ascii_hexdigit()); + colons >= IPV6_MIN_COLONS && allowed && has_digit +} + +/// The report as JSON: header, then per leaf the value or the refusal, the device's limits, and +/// what the catalog says for this model. `reports` must already be redacted. +pub fn json_report(header: &ScanHeader, reports: &[LeafReport], catalog: &Catalog) -> Value { + let leaves: Vec = reports + .iter() + .map(|report| { + let mut leaf = Map::new(); + leaf.insert("path".to_owned(), json!(report.path.to_string())); + match &report.value { + Ok(value) => leaf.insert("value".to_owned(), value.clone()), + Err(text) => leaf.insert("error".to_owned(), json!(text)), + }; + leaf.insert( + "limits".to_owned(), + report.limits.clone().unwrap_or(Value::Null), + ); + let (known, writable_on) = catalog_view(catalog, header.model, report); + leaf.insert("in_catalog".to_owned(), json!(known)); + leaf.insert("writable_on".to_owned(), json!(writable_on)); + Value::Object(leaf) + }) + .collect(); + json!({ + "model": header.model, + "firmware": header.firmware, + "time": header.time, + "leaves": leaves, + }) +} + +/// The same as plain text, one line per leaf. +pub fn text_report(header: &ScanHeader, reports: &[LeafReport], catalog: &Catalog) -> String { + let mut lines = vec![ + format!("model: {}", header.model), + format!("firmware: {}", header.firmware), + format!("time: {}", header.time), + String::new(), + "path | value or error | catalog | writable on | limits".to_owned(), + ]; + for report in reports { + let shown = match &report.value { + Ok(value) => value.to_string(), + Err(text) => format!("error: {text}"), + }; + let (known, writable_on) = catalog_view(catalog, header.model, report); + let catalog_column = if known { "known" } else { "not in catalog" }; + let limits = report + .limits + .as_ref() + .map(Value::to_string) + .unwrap_or_default(); + lines.push(format!( + "{} | {shown} | {catalog_column} | {} | {limits}", + report.path, + writable_on.join(", ") + )); + } + lines.join("\n") + "\n" +} + +/// Whether the catalog lists the path for the model, and where it may be written. +fn catalog_view(catalog: &Catalog, model: &str, report: &LeafReport) -> (bool, Vec) { + let entry = catalog + .entries_for(model) + .into_iter() + .find(|entry| entry.path == report.path); + match entry { + Some(entry) => (true, entry.writable_on.clone()), + None => (false, Vec::new()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use neumann_control_core::ssc::wire::SscPath; + + fn leaf(path: &str, value: Value) -> LeafReport { + LeafReport { + path: SscPath::parse(path).unwrap(), + value: Ok(value), + limits: None, + } + } + + fn values(reports: &[LeafReport]) -> Vec { + reports + .iter() + .map(|report| report.value.clone().unwrap_or_else(Value::String)) + .collect() + } + + #[test] + fn identifying_leaves_are_redacted() { + let reports = [ + leaf("device/identity/serial", json!("3712345678")), + leaf("device/network/ether/macs", json!("aa:bb:cc:dd:ee:ff")), + leaf("device/name", json!("Living room left")), + leaf("device/network/ipv6/address", json!(["x"])), + leaf("device/identity/product", json!("KH 750")), + ]; + let shown = values(&redacted(&reports, &[])); + assert_eq!(shown[..4].to_vec(), vec![json!(REDACTED); 4]); + assert_eq!(shown[4], json!("KH 750")); + } + + #[test] + fn address_like_text_is_redacted_anywhere() { + let reports = [ + leaf("osc/some/text", json!("seen at fe80::1a2b:3c4d%7 now")), + leaf("osc/list", json!(["aa-bb-cc-dd-ee-ff", "12:30"])), + LeafReport { + path: SscPath::parse("osc/refused").unwrap(), + value: Err("from 2001:db8::1, refused".to_owned()), + limits: Some(json!({"desc": "mac 00:11:22:33:44:55"})), + }, + ]; + let shown = redacted(&reports, &[]); + assert_eq!(shown[0].value, Ok(json!(format!("seen at {REDACTED} now")))); + assert_eq!(shown[1].value, Ok(json!([REDACTED, "12:30"]))); + assert_eq!(shown[2].value, Err(format!("from {REDACTED}, refused"))); + assert_eq!( + shown[2].limits, + Some(json!({"desc": format!("mac {REDACTED}")})) + ); + } + + #[test] + fn the_speakers_own_identifiers_are_redacted_wherever_they_appear() { + let reports = [ + leaf("device/system", json!("unit 3712345678 ok")), + leaf("osc/some/text", json!(["Living room left speaker"])), + ]; + let shown = values(&redacted(&reports, &["3712345678", "Living room left"])); + assert_eq!(shown[0], json!(format!("unit {REDACTED} ok"))); + assert_eq!(shown[1], json!([format!("{REDACTED} speaker")])); + } + + #[test] + fn the_file_name_holds_model_firmware_and_time_only() { + assert_eq!( + file_stem("KH 750", "2_1_2", "20261020-190405"), + "scan-KH-750-2_1_2-20261020-190405" + ); + } + + #[test] + fn the_report_says_what_the_catalog_knows() { + let catalog = Catalog::embedded(); + let header = ScanHeader { + model: "KH 120 II", + firmware: "1_7_3", + time: "2026-10-20 19:04:05", + }; + let reports = [ + leaf("audio/out/level", json!(100.0)), + leaf("device/update/enable", json!(false)), + ]; + let report = json_report(&header, &reports, catalog); + let leaves = report["leaves"].as_array().unwrap(); + assert_eq!(leaves[0]["in_catalog"], json!(true)); + assert_eq!(leaves[0]["writable_on"], json!(["KH 120 II", "KH 750"])); + assert_eq!(leaves[1]["in_catalog"], json!(false)); + let text = text_report(&header, &reports, catalog); + assert!(text.contains("audio/out/level | 100.0 | known | KH 120 II, KH 750 |")); + assert!(text.contains("device/update/enable | false | not in catalog |")); + } +} diff --git a/crates/app/src/state.rs b/crates/app/src/state.rs index 072382b..5a5e2f4 100644 --- a/crates/app/src/state.rs +++ b/crates/app/src/state.rs @@ -31,6 +31,19 @@ pub enum SpeakerKey { Address(SocketAddrV6), } +/// "Komplett scannen": the walk, then its report file. +#[derive(Clone, Debug, Default, PartialEq)] +pub enum FullScanState { + #[default] + Idle, + Running, + /// Read; the app writes the report and replaces this with the outcome. + Read(Vec), + /// The JSON file's path. + Written(String), + Failed(String), +} + /// What the speaker's message is about, which decides when it ends. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum MessageSource { @@ -103,6 +116,7 @@ pub struct SpeakerState { pub temperature: Trend, /// The active warnings, empty while there are none. pub warnings: Trend>, + pub full_scan: FullScanState, } impl SpeakerState { @@ -128,6 +142,7 @@ impl SpeakerState { pending: BTreeMap::new(), temperature: Trend::default(), warnings: Trend::default(), + full_scan: FullScanState::Idle, } } @@ -235,8 +250,16 @@ impl SpeakerState { self.temperature.record(now, None); self.warnings.record(now, None); } - Event::AllValues(reports) => self.finish_all_values(AllValuesState::Loaded(reports)), + Event::AllValues(reports) => { + if self.full_scan == FullScanState::Running { + self.full_scan = FullScanState::Read(reports.clone()); + } + self.finish_all_values(AllValuesState::Loaded(reports)); + } Event::AllValuesFailed(reason) => { + if self.full_scan == FullScanState::Running { + self.full_scan = FullScanState::Failed(reason.clone()); + } self.finish_all_values(AllValuesState::Failed(reason)); } Event::ResponseTime(time) => self.response_time = Some(time), @@ -257,6 +280,12 @@ impl SpeakerState { Command::ReadAll } + /// Starts "Komplett scannen"; the walk is the same as for "Alle Werte". + pub fn start_full_scan(&mut self) -> Command { + self.full_scan = FullScanState::Running; + Command::ReadAll + } + /// Closes the "Alle Werte" window and forgets its filter. pub fn close_all_values(&mut self) { self.all_values = AllValuesState::Idle; diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index b47fc6a..a0deb47 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -227,6 +227,8 @@ pub const PLAYED_CHANNEL: Text = Text::new( ); pub const ALL_VALUES: Text = Text::new("Alle Werte…", "All values…"); +pub const FULL_SCAN: Text = Text::new("Komplett scannen", "Full scan"); +pub const FULL_SCAN_RUNNING: Text = Text::new("Scan läuft …", "Scanning …"); pub const APPLY: Text = Text::new("Übernehmen", "Apply"); pub const ON: Text = Text::new("an", "on"); pub const OFF: Text = Text::new("aus", "off"); @@ -592,6 +594,21 @@ pub fn eq_change_setting(container: &str, band: usize, leaf: &str) -> String { format!("{container} {} {band} · {leaf}", BAND.get()) } +/// Where the full scan's report went. +pub fn scan_written(path: &str) -> String { + pick( + format!("Bericht geschrieben: {path}"), + format!("Report written: {path}"), + ) +} + +pub fn scan_failed(reason: &str) -> String { + pick( + format!("Scan fehlgeschlagen: {reason}"), + format!("Scan failed: {reason}"), + ) +} + /// The device panel's trend heading: "Verlauf (2 h)". pub fn trends_title(window_seconds: f64) -> String { let hours = hours(window_seconds); diff --git a/crates/app/src/views/details.rs b/crates/app/src/views/details.rs index ef6d10e..e5a3a3b 100644 --- a/crates/app/src/views/details.rs +++ b/crates/app/src/views/details.rs @@ -3,7 +3,7 @@ //! greyed value; what the strip or the panel header shows and what the speaker does not have //! are left out. -use eframe::egui::{Button, RichText, Ui}; +use eframe::egui::{self, Button, RichText, Ui}; use neumann_control_core::catalog::{Catalog, Entry, Group, Kind}; use neumann_control_core::speaker::Command; @@ -11,7 +11,7 @@ use super::details_fields::Fields; use super::knob; use crate::display::shown_value; use crate::paths; -use crate::state::{AllValuesState, SpeakerState, Status}; +use crate::state::{AllValuesState, FullScanState, SpeakerState, Status}; use crate::texts; const COLUMNS: usize = 2; @@ -59,7 +59,11 @@ fn rows(ui: &mut Ui, speaker: &mut SpeakerState, catalog: &Catalog) -> Vec) { + let running = speaker.full_scan == FullScanState::Running; + let enabled = speaker.status == Status::Connected && !running; + if ui + .add_enabled(enabled, Button::new(texts::FULL_SCAN)) + .clicked() + { + commands.push(speaker.start_full_scan()); + } +} + +fn full_scan_outcome(ui: &mut Ui, speaker: &SpeakerState) { + match &speaker.full_scan { + FullScanState::Idle | FullScanState::Read(_) => {} + FullScanState::Running => { + ui.weak(texts::FULL_SCAN_RUNNING); + } + FullScanState::Written(path) => { + ui.add(egui::Label::new(texts::scan_written(path)).wrap()); + } + FullScanState::Failed(reason) => { + ui.colored_label(ui.visuals().error_fg_color, texts::scan_failed(reason)); + } + } +} + fn all_values_button(ui: &mut Ui, speaker: &mut SpeakerState, commands: &mut Vec) { let loading = speaker.all_values == AllValuesState::Loading; let enabled = speaker.status == Status::Connected && !loading; diff --git a/crates/app/tests/details.rs b/crates/app/tests/details.rs index c3e98c7..d93fa38 100644 --- a/crates/app/tests/details.rs +++ b/crates/app/tests/details.rs @@ -630,3 +630,36 @@ fn kh750_shows_its_auto_standby_switch() { 3 ); } + +#[test] +fn a_full_scan_writes_a_redacted_report_next_to_the_settings() { + let (_simulator, directory, mut harness) = speaker_with_panel(SimModel::Kh120Ii); + harness.get_by_label(texts::FULL_SCAN.get()).click(); + let written = |directory: &std::path::Path| -> Vec { + std::fs::read_dir(directory) + .unwrap() + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("scan-")) + }) + .collect() + }; + wait_until(&mut harness, "both report files", |_| { + written(directory.path()).len() == 2 + }); + for path in written(directory.path()) { + let name = path.file_name().unwrap().to_str().unwrap().to_owned(); + assert!(name.starts_with("scan-KH-120-II-1_7_3-"), "{name}"); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains("audio/out/level"), "{name}"); + assert!(!text.contains("SIM0001234"), "{name} holds the serial"); + assert!(!text.contains(KH120_DEVICE_NAME), "{name} holds the name"); + } + wait_until(&mut harness, "the report noted in the panel", |harness| { + harness + .query_by_label_contains(&texts::scan_written("")) + .is_some() + }); +} From 163d0a153b70f540c082a0639a0683fd57f12582 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:27:21 +0200 Subject: [PATCH 152/460] Redact the announced name in scan reports too --- crates/app/src/app/full_scan.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/app/src/app/full_scan.rs b/crates/app/src/app/full_scan.rs index 4fc779d..8a20160 100644 --- a/crates/app/src/app/full_scan.rs +++ b/crates/app/src/app/full_scan.rs @@ -55,10 +55,10 @@ fn write_report( .as_ref() .map(|identity| identity.version.clone()) .unwrap_or_default(); - let serial = speaker + let (serial, announced) = speaker .identity .as_ref() - .map(|identity| identity.serial.clone()) + .map(|identity| (identity.serial.clone(), identity.name.clone())) .unwrap_or_default(); let name = speaker .values @@ -82,7 +82,8 @@ fn write_report( firmware: &firmware, time: &time, }; - let reports = redacted(reports, &[serial.as_str(), name.as_str()]); + let known = [serial.as_str(), name.as_str(), announced.as_str()]; + let reports = redacted(reports, &known); let stem = file_stem(&model, &firmware, &compact_time); let json_path = directory.join(format!("{stem}.{JSON_EXTENSION}")); let json = serde_json::to_string_pretty(&json_report(&header, &reports, catalog)) From 2ab7144c0e1b8ab4c385a251567d226d071f382a Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:27:22 +0200 Subject: [PATCH 153/460] Save and load settings presets --- crates/app/src/app.rs | 13 +- crates/app/src/app/history.rs | 13 +- crates/app/src/app/preset_dialogs.rs | 196 ++++++++++++++++++++++++++ crates/app/src/lib.rs | 1 + crates/app/src/presets.rs | 192 +++++++++++++++++++++++++ crates/app/src/texts.rs | 35 +++++ crates/app/src/views/mod.rs | 1 + crates/app/src/views/panel.rs | 10 ++ crates/app/src/views/preset_dialog.rs | 138 ++++++++++++++++++ crates/app/src/writes.rs | 15 ++ crates/app/tests/details.rs | 57 ++++++++ crates/core/src/settings.rs | 31 +++- crates/core/tests/settings.rs | 34 ++++- 13 files changed, 721 insertions(+), 15 deletions(-) create mode 100644 crates/app/src/app/preset_dialogs.rs create mode 100644 crates/app/src/presets.rs create mode 100644 crates/app/src/views/preset_dialog.rs diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index 76f917f..0eff471 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -36,6 +36,7 @@ mod desk; mod eq; mod full_scan; mod history; +mod preset_dialogs; pub use history::TakeBackNotice; @@ -84,6 +85,7 @@ pub struct NeumannApp { window: Option, history: history::HistoryState, eq_windows: Vec, + preset_dialog: Option, /// What "like Windows" means, asked of Windows at the start. system_language: Language, } @@ -131,6 +133,7 @@ impl NeumannApp { settings_open: false, history: history::HistoryState::default(), eq_windows: Vec::new(), + preset_dialog: None, system_language, }; app.sync_targets(); @@ -372,6 +375,7 @@ impl NeumannApp { self.show_dialog(ui); self.show_scan_choice(ui); self.show_removal(ui); + self.show_preset_dialog(ui); self.show_settings(ui); self.show_group_dialog(ui); self.show_all_values(ui.ctx()); @@ -450,8 +454,15 @@ impl NeumannApp { name: speaker.display_name(), }); } + let key = speaker.key.clone(); if response.open_eq { - self.open_eq(speaker.key.clone()); + self.open_eq(key.clone()); + } + if response.save_preset { + self.open_preset_save(key.clone()); + } + if response.load_preset { + self.open_preset_load(key); } if response.close || response.remove { self.panel = None; diff --git a/crates/app/src/app/history.rs b/crates/app/src/app/history.rs index dcef9dd..0e56118 100644 --- a/crates/app/src/app/history.rs +++ b/crates/app/src/app/history.rs @@ -95,6 +95,7 @@ impl NeumannApp { || self.scan_choice.is_some() || self.group_draft.is_some() || self.history.question.is_some() + || self.preset_dialog.is_some() } pub(super) fn show_history(&mut self, ui: &mut Ui) { @@ -391,17 +392,7 @@ fn send_before( return None; } let before = change.before.clone()?; - let level = before - .as_f64() - .filter(|_| change.path == paths::path(paths::OUTPUT_LEVEL)); - let intent = match level { - Some(level) => Intent::Level(level), - None => Intent::Write { - path: change.path.clone(), - value: before, - }, - }; - speaker.request(intent, catalog, now) + speaker.request(Intent::restoring(&change.path, before), catalog, now) } fn clock_now() -> ClockTime { diff --git a/crates/app/src/app/preset_dialogs.rs b/crates/app/src/app/preset_dialogs.rs new file mode 100644 index 0000000..99ca131 --- /dev/null +++ b/crates/app/src/app/preset_dialogs.rs @@ -0,0 +1,196 @@ +//! The settings presets on the app side: the open dialog, saving into the settings file, and +//! writing a loaded preset in one frame, so the history files it as one entry. + +use eframe::egui::Ui; +use neumann_control_core::catalog::Catalog; +use neumann_control_core::ssc::wire::SscPath; +use serde_json::Value; + +use super::NeumannApp; +use crate::display::shown_value; +use crate::paths; +use crate::presets::{capture, chosen_by_default, plan, presettable}; +use crate::state::SpeakerKey; +use crate::texts; +use crate::views::preset_dialog::{self, LoadAction, LoadView, SaveAction, SaveRow}; +use crate::writes::Intent; + +/// Shown for a value the speaker has not reported. +const UNKNOWN_VALUE: &str = "?"; + +pub(super) enum PresetDialog { + Save { + key: SpeakerKey, + name: String, + chosen: Vec, + }, + Load { + key: SpeakerKey, + selected: Option, + }, +} + +impl NeumannApp { + pub(super) fn open_preset_save(&mut self, key: SpeakerKey) { + let catalog = Catalog::embedded(); + let Some(speaker) = self + .speakers + .iter() + .find(|speaker| speaker.state.key == key) + else { + return; + }; + let chosen = presettable(catalog, &speaker.state) + .into_iter() + .filter(|entry| chosen_by_default(entry)) + .map(|entry| entry.path.clone()) + .collect(); + self.preset_dialog = Some(PresetDialog::Save { + key, + name: String::new(), + chosen, + }); + } + + pub(super) fn open_preset_load(&mut self, key: SpeakerKey) { + self.preset_dialog = Some(PresetDialog::Load { + key, + selected: None, + }); + } + + pub(super) fn show_preset_dialog(&mut self, ui: &mut Ui) { + let now = ui.input(|input| input.time); + match self.preset_dialog.take() { + None => {} + Some(PresetDialog::Save { key, name, chosen }) => { + self.preset_dialog = self.show_save(ui, key, name, chosen); + } + Some(PresetDialog::Load { key, selected }) => { + self.preset_dialog = self.show_load(ui, key, selected, now); + } + } + } + + /// Returns the dialog as it stays open, or `None` when it closed. + fn show_save( + &mut self, + ui: &mut Ui, + key: SpeakerKey, + mut name: String, + mut chosen: Vec, + ) -> Option { + let catalog = Catalog::embedded(); + let speaker = &self + .speakers + .iter() + .find(|speaker| speaker.state.key == key)? + .state; + let entries = presettable(catalog, speaker); + let rows: Vec = entries + .iter() + .map(|entry| SaveRow { + label: entry.label(texts::language()).to_owned(), + value: speaker.values.get(&entry.path).map_or_else( + || UNKNOWN_VALUE.to_owned(), + |value| shown_value(entry, value), + ), + chosen: chosen.contains(&entry.path), + }) + .collect(); + match preset_dialog::save(ui, texts::SAVE_PRESET_TITLE.get(), &rows, &mut name) { + SaveAction::None => {} + SaveAction::Toggle(place, on) => { + let path = entries[place].path.clone(); + chosen.retain(|known| *known != path); + if on { + chosen.push(path); + } + } + SaveAction::Save => { + let preset = capture(name.trim(), speaker, &chosen); + let presets = &mut self.settings.presets; + presets.retain(|known| known.name != preset.name); + presets.push(preset); + self.save_settings(); + return None; + } + SaveAction::Cancel => return None, + } + Some(PresetDialog::Save { key, name, chosen }) + } + + fn show_load( + &mut self, + ui: &mut Ui, + key: SpeakerKey, + mut selected: Option, + now: f64, + ) -> Option { + let catalog = Catalog::embedded(); + let index = self + .speakers + .iter() + .position(|speaker| speaker.state.key == key)?; + let names: Vec = self + .settings + .presets + .iter() + .map(|preset| texts::preset_entry(&preset.name, &preset.product)) + .collect(); + let speaker = &self.speakers[index].state; + let loading = selected + .and_then(|place| self.settings.presets.get(place)) + .map(|preset| plan(preset, speaker, catalog)) + .unwrap_or_default(); + let shown = |path: &SscPath, value: Option<&Value>| { + let entry = paths::catalog_entry(catalog, path); + match (entry, value) { + (Some(entry), Some(value)) => shown_value(entry, value), + _ => UNKNOWN_VALUE.to_owned(), + } + }; + let changes: Vec = loading + .changes + .iter() + .map(|change| { + let label = paths::catalog_entry(catalog, &change.path).map_or_else( + || change.path.to_string(), + |entry| entry.label(texts::language()).to_owned(), + ); + let current = shown(&change.path, change.current.as_ref()); + let preset = shown(&change.path, Some(&change.preset)); + texts::preset_change(&label, ¤t, &preset) + }) + .collect(); + let view = LoadView { + title: texts::LOAD_PRESET_TITLE.get(), + presets: &names, + selected, + changes: &changes, + skipped: &loading.skipped, + }; + match preset_dialog::load(ui, &view) { + LoadAction::None => {} + LoadAction::Select(place) => selected = Some(place), + LoadAction::Write => { + let speaker = &mut self.speakers[index]; + for change in loading.changes { + let intent = Intent::restoring(&change.path, change.preset); + if let Some(command) = speaker.state.request(intent, catalog, now) { + speaker.handle.send(command); + } + } + return None; + } + LoadAction::Delete => { + if let Some(place) = selected.take() { + self.settings.presets.remove(place); + self.save_settings(); + } + } + LoadAction::Close => return None, + } + Some(PresetDialog::Load { key, selected }) + } +} diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index 0ecb7b6..8b9bf7c 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -10,6 +10,7 @@ pub mod history; pub mod input_source; pub mod meter; pub mod paths; +pub mod presets; pub mod scan_choice; pub mod scan_report; pub mod spawner; diff --git a/crates/app/src/presets.rs b/crates/app/src/presets.rs new file mode 100644 index 0000000..037748f --- /dev/null +++ b/crates/app/src/presets.rs @@ -0,0 +1,192 @@ +//! Settings presets: which settings a speaker's preset can hold, taking one, and what loading +//! one onto a speaker would write. The writes themselves go through the write policy. + +use neumann_control_core::catalog::{Catalog, Entry}; +use neumann_control_core::settings::SettingsPreset; +use neumann_control_core::ssc::wire::SscPath; +use serde_json::Value; + +use crate::history::same_value; +use crate::paths; +use crate::state::SpeakerState; + +/// Settings that are not a state to restore: identify only flashes the logo. +const NOT_PRESETTABLE: &[&str] = &[paths::IDENTIFY]; + +/// What one write of a loaded preset changes. +#[derive(Clone, Debug, PartialEq)] +pub struct PresetChange { + pub path: SscPath, + /// `None` while the speaker has not reported the value. + pub current: Option, + pub preset: Value, +} + +/// Loading a preset onto a speaker: the writes, and the paths it may not write (another model, +/// or a path the catalog does not know). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct PresetPlan { + pub changes: Vec, + pub skipped: Vec, +} + +/// The settings a preset of this speaker can hold: writable on its model, not an EQ band. +pub fn presettable<'a>(catalog: &'a Catalog, speaker: &SpeakerState) -> Vec<&'a Entry> { + let product = speaker.product(); + catalog + .entries_for(product) + .into_iter() + .filter(|entry| entry.is_writable(product) && !entry.bands) + .filter(|entry| !NOT_PRESETTABLE.contains(&entry.path.to_string().as_str())) + .collect() +} + +/// Checked when the save dialog opens: everything but the speaker's own name. +pub fn chosen_by_default(entry: &Entry) -> bool { + entry.path != paths::path(paths::DEVICE_NAME) +} + +/// The chosen settings as the speaker reports them now; one it has not reported is left out. +pub fn capture(name: &str, speaker: &SpeakerState, chosen: &[SscPath]) -> SettingsPreset { + let values = chosen + .iter() + .filter_map(|path| { + let value = speaker.values.get(path)?; + Some((path.to_string(), value.clone())) + }) + .collect(); + SettingsPreset { + name: name.to_owned(), + product: speaker.product().to_owned(), + values, + } +} + +/// The writes loading `preset` needs: only paths this speaker may write and whose value differs. +pub fn plan(preset: &SettingsPreset, speaker: &SpeakerState, catalog: &Catalog) -> PresetPlan { + let product = speaker.product(); + let mut plan = PresetPlan::default(); + for (text, value) in &preset.values { + let entry = SscPath::parse(text).ok().and_then(|path| { + catalog + .entries_for(product) + .into_iter() + .find(|entry| entry.path == path) + }); + let Some(entry) = entry.filter(|entry| entry.is_writable(product) && !entry.bands) else { + plan.skipped.push(text.clone()); + continue; + }; + let current = speaker.values.get(&entry.path).cloned(); + let unchanged = current + .as_ref() + .is_some_and(|current| same_value(current, value, entry.step)); + if !unchanged { + plan.changes.push(PresetChange { + path: entry.path.clone(), + current, + preset: value.clone(), + }); + } + } + plan +} + +#[cfg(test)] +mod tests { + use super::*; + use neumann_control_core::speaker::{Event, Identity}; + use serde_json::json; + use std::net::SocketAddrV6; + + use crate::state::SpeakerKey; + + fn speaker(product: &str, values: &[(&str, Value)]) -> SpeakerState { + let address: SocketAddrV6 = "[fe80::1]:45".parse().unwrap(); + let mut speaker = SpeakerState::new(SpeakerKey::Address(address), address); + let identity = Identity { + product: product.to_owned(), + version: "1".to_owned(), + serial: "SIM".to_owned(), + name: String::new(), + }; + speaker.apply(Event::Connected(identity), 0.0); + for (path, value) in values { + let path = paths::path(path); + speaker.apply( + Event::Value { + path, + value: value.clone(), + }, + 0.0, + ); + } + speaker + } + + #[test] + fn a_preset_holds_writable_settings_but_not_identify_or_eq_bands() { + let catalog = Catalog::embedded(); + let monitor = speaker("KH 120 II", &[]); + let paths: Vec = presettable(catalog, &monitor) + .iter() + .map(|entry| entry.path.to_string()) + .collect(); + assert!(paths.contains(&"audio/out/level".to_owned())); + assert!(paths.contains(&"device/name".to_owned())); + assert!(!paths.contains(&paths::IDENTIFY.to_owned())); + assert!(!paths.iter().any(|path| path.contains("/eq"))); + assert!(!paths.contains(&"ui/control_mode".to_owned())); + } + + #[test] + fn capture_takes_the_chosen_reported_values() { + let monitor = speaker( + "KH 120 II", + &[ + ("audio/out/level", json!(85.0)), + ("audio/out/delay", json!(10)), + ], + ); + let chosen = [paths::path("audio/out/level"), paths::path("device/name")]; + let preset = capture("Night", &monitor, &chosen); + assert_eq!(preset.product, "KH 120 II"); + assert_eq!( + preset.values, + [("audio/out/level".to_owned(), json!(85.0))].into() + ); + } + + #[test] + fn loading_writes_only_differing_writable_paths_and_names_the_rest() { + let catalog = Catalog::embedded(); + let preset = SettingsPreset { + name: "Sub".to_owned(), + product: "KH 750".to_owned(), + values: [ + ("audio/out/level".to_owned(), json!(90.0)), + ("audio/out/delay".to_owned(), json!(10)), + ("audio/out1/level".to_owned(), json!(95.0)), + ("no/such/path".to_owned(), json!(1)), + ] + .into(), + }; + let monitor = speaker( + "KH 120 II", + &[ + ("audio/out/level", json!(85.0)), + ("audio/out/delay", json!(10)), + ], + ); + let plan = plan(&preset, &monitor, catalog); + assert_eq!( + plan.changes, + vec![PresetChange { + path: paths::path("audio/out/level"), + current: Some(json!(85.0)), + preset: json!(90.0), + }] + ); + assert_eq!(plan.skipped, vec!["audio/out1/level", "no/such/path"]); + } +} diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index a0deb47..db26fda 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -227,6 +227,19 @@ pub const PLAYED_CHANNEL: Text = Text::new( ); pub const ALL_VALUES: Text = Text::new("Alle Werte…", "All values…"); +pub const SAVE_PRESET: Text = Text::new("Als Preset speichern…", "Save as preset…"); +pub const LOAD_PRESET: Text = Text::new("Preset laden…", "Load preset…"); +pub const SAVE_PRESET_TITLE: Text = Text::new("Einstellungen als Preset", "Settings as a preset"); +pub const LOAD_PRESET_TITLE: Text = Text::new("Preset laden", "Load a preset"); +pub const PRESET_NAME: Text = Text::new("Name des Presets", "Preset name"); +pub const SAVE: Text = Text::new("Speichern", "Save"); +pub const WRITE_PRESET: Text = Text::new("Schreiben", "Write"); +pub const DELETE_PRESET: Text = Text::new("Löschen", "Delete"); +pub const NO_PRESETS: Text = Text::new("Noch keine Presets.", "No presets yet."); +pub const PRESET_UNCHANGED: Text = Text::new( + "Der Lautsprecher hat schon die Werte des Presets.", + "The speaker already has the preset's values.", +); pub const FULL_SCAN: Text = Text::new("Komplett scannen", "Full scan"); pub const FULL_SCAN_RUNNING: Text = Text::new("Scan läuft …", "Scanning …"); pub const APPLY: Text = Text::new("Übernehmen", "Apply"); @@ -594,6 +607,28 @@ pub fn eq_change_setting(container: &str, band: usize, leaf: &str) -> String { format!("{container} {} {band} · {leaf}", BAND.get()) } +/// A setting in the save dialog: "Pegel: 85 dB SPL". +pub fn preset_row(setting: &str, value: &str) -> String { + format!("{setting}: {value}") +} + +/// A preset in the load dialog. +pub fn preset_entry(name: &str, product: &str) -> String { + format!("{name} ({product})") +} + +/// One write of a loaded preset: "Pegel: 85 dB SPL → 90 dB SPL". +pub fn preset_change(setting: &str, current: &str, preset: &str) -> String { + format!("{setting}: {current} → {preset}") +} + +pub fn preset_skipped(paths: &[String]) -> String { + pick( + format!("Übersprungen, hier nicht schreibbar: {}", paths.join(", ")), + format!("Skipped, not writable here: {}", paths.join(", ")), + ) +} + /// Where the full scan's report went. pub fn scan_written(path: &str) -> String { pick( diff --git a/crates/app/src/views/mod.rs b/crates/app/src/views/mod.rs index 706afa6..d7fb8cf 100644 --- a/crates/app/src/views/mod.rs +++ b/crates/app/src/views/mod.rs @@ -14,6 +14,7 @@ mod knob; mod meters; mod number_field; pub mod panel; +pub mod preset_dialog; pub mod remove_speaker; pub mod scan_choice; pub mod settings_dialog; diff --git a/crates/app/src/views/panel.rs b/crates/app/src/views/panel.rs index 2a83920..7e74a21 100644 --- a/crates/app/src/views/panel.rs +++ b/crates/app/src/views/panel.rs @@ -34,6 +34,8 @@ pub struct PanelResponse { /// A linked setting whose value goes to the other members. pub copy_to_group: Option, pub open_eq: bool, + pub save_preset: bool, + pub load_preset: bool, } /// The groups the speaker could be in, which one it is in, and what that group links. @@ -58,6 +60,8 @@ pub fn show( let mut group = None; let mut copy_to_group = None; let mut open_eq = false; + let mut save_preset = false; + let mut load_preset = false; let has_eq = has_eq(catalog, speaker); // Escape in a field cancels the edit; only Escape with nothing being edited closes. egui // drops the focus at the start of the Escape frame, so the last frame's state decides. @@ -85,6 +89,10 @@ pub fn show( ui.separator(); trends::show(ui, &trends_of(ui, speaker, catalog)); ui.separator(); + ui.horizontal(|ui| { + save_preset = ui.button(texts::SAVE_PRESET).clicked(); + load_preset = ui.button(texts::LOAD_PRESET).clicked(); + }); open_eq = has_eq && ui.button(texts::EQ).clicked(); remove = ui.button(texts::REMOVE).clicked(); commands @@ -110,6 +118,8 @@ pub fn show( group, copy_to_group, open_eq, + save_preset, + load_preset, } } diff --git a/crates/app/src/views/preset_dialog.rs b/crates/app/src/views/preset_dialog.rs new file mode 100644 index 0000000..d1c8217 --- /dev/null +++ b/crates/app/src/views/preset_dialog.rs @@ -0,0 +1,138 @@ +//! The two preset dialogs of the device panel: saving chosen settings under a name, and loading +//! a preset with a preview of what it would change. They only draw and report. + +use eframe::egui::{Button, Id, Modal, ScrollArea, TextEdit, Ui}; + +use crate::texts; + +const SAVE_ID: &str = "preset_save"; +const LOAD_ID: &str = "preset_load"; +const LIST_HEIGHT: f32 = 320.0; + +/// A setting offered for the preset: its name, current value and whether it is chosen. +pub struct SaveRow { + pub label: String, + pub value: String, + pub chosen: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SaveAction { + None, + Toggle(usize, bool), + Save, + Cancel, +} + +/// `rows` empty: the preset holds a fixed set (an EQ), only its name is asked. +pub fn save(ui: &mut Ui, title: &str, rows: &[SaveRow], name: &mut String) -> SaveAction { + let modal = Modal::new(Id::new(SAVE_ID)).show(ui.ctx(), |ui| { + ui.heading(title); + let label = ui.label(texts::PRESET_NAME); + ui.add(TextEdit::singleline(name)).labelled_by(label.id); + let mut action = SaveAction::None; + ScrollArea::vertical() + .max_height(LIST_HEIGHT) + .show(ui, |ui| { + for (place, row) in rows.iter().enumerate() { + let mut chosen = row.chosen; + let text = texts::preset_row(&row.label, &row.value); + if ui.checkbox(&mut chosen, text).changed() { + action = SaveAction::Toggle(place, chosen); + } + } + }); + let something = rows.is_empty() || rows.iter().any(|row| row.chosen); + let savable = !name.trim().is_empty() && something; + ui.horizontal(|ui| { + if ui.add_enabled(savable, Button::new(texts::SAVE)).clicked() { + action = SaveAction::Save; + } + if ui.button(texts::CANCEL).clicked() { + action = SaveAction::Cancel; + } + }); + action + }); + if modal.should_close() { + return SaveAction::Cancel; + } + modal.inner +} + +pub struct LoadView<'a> { + pub title: &'a str, + /// "Name (product)" per preset. + pub presets: &'a [String], + pub selected: Option, + /// "Setting: current → preset" per write. + pub changes: &'a [String], + pub skipped: &'a [String], +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LoadAction { + None, + Select(usize), + Write, + Delete, + Close, +} + +pub fn load(ui: &mut Ui, view: &LoadView) -> LoadAction { + let modal = Modal::new(Id::new(LOAD_ID)).show(ui.ctx(), |ui| { + ui.heading(view.title); + let mut action = LoadAction::None; + if view.presets.is_empty() { + ui.weak(texts::NO_PRESETS); + } + for (place, preset) in view.presets.iter().enumerate() { + if ui + .selectable_label(view.selected == Some(place), preset) + .clicked() + { + action = LoadAction::Select(place); + } + } + if view.selected.is_some() { + ui.separator(); + if view.changes.is_empty() { + ui.weak(texts::PRESET_UNCHANGED); + } + ScrollArea::vertical() + .max_height(LIST_HEIGHT) + .show(ui, |ui| { + for change in view.changes { + ui.label(change); + } + }); + if !view.skipped.is_empty() { + ui.weak(texts::preset_skipped(view.skipped)); + } + } + ui.horizontal(|ui| { + let writable = view.selected.is_some() && !view.changes.is_empty(); + if ui + .add_enabled(writable, Button::new(texts::WRITE_PRESET)) + .clicked() + { + action = LoadAction::Write; + } + let deletable = view.selected.is_some(); + if ui + .add_enabled(deletable, Button::new(texts::DELETE_PRESET)) + .clicked() + { + action = LoadAction::Delete; + } + if ui.button(texts::CLOSE).clicked() { + action = LoadAction::Close; + } + }); + action + }); + if modal.should_close() { + return LoadAction::Close; + } + modal.inner +} diff --git a/crates/app/src/writes.rs b/crates/app/src/writes.rs index b9dab69..dd4c041 100644 --- a/crates/app/src/writes.rs +++ b/crates/app/src/writes.rs @@ -38,6 +38,21 @@ pub enum Intent { } impl Intent { + /// Writing a stored value back (undo, a preset): the output level as a level, so the + /// fader holds it like after a gesture; anything else as it is. + pub fn restoring(path: &SscPath, value: Value) -> Intent { + let level = value + .as_f64() + .filter(|_| *path == paths::path(paths::OUTPUT_LEVEL)); + match level { + Some(level) => Intent::Level(level), + None => Intent::Write { + path: path.clone(), + value, + }, + } + } + pub fn path(&self) -> SscPath { match self { Intent::Toggle(switch) => switch.path(), diff --git a/crates/app/tests/details.rs b/crates/app/tests/details.rs index d93fa38..06723e1 100644 --- a/crates/app/tests/details.rs +++ b/crates/app/tests/details.rs @@ -663,3 +663,60 @@ fn a_full_scan_writes_a_redacted_report_next_to_the_settings() { .is_some() }); } + +const LEVEL_POINTER: &str = "/audio/out/level"; + +#[test] +fn a_settings_preset_is_saved_and_written_back_as_one_entry() { + let (simulator, directory, mut harness) = speaker_with_panel(SimModel::Kh120Ii); + harness.get_by_label(texts::SAVE_PRESET.get()).click(); + harness.run_steps(3); + harness + .get_by_role_and_label(Role::TextInput, texts::PRESET_NAME.get()) + .click(); + harness.run_steps(1); + harness + .get_by_role_and_label(Role::TextInput, texts::PRESET_NAME.get()) + .type_text("Evening"); + harness.run_steps(1); + let delay = harness + .query_all_by_role(Role::CheckBox) + .find(|node| { + node.accesskit_node() + .label() + .is_some_and(|label| label.starts_with("Verzögerung")) + }) + .expect("the delay row"); + delay.click(); + harness.run_steps(2); + harness.get_by_label(texts::SAVE.get()).click(); + harness.run_steps(3); + let saved = neumann_control_core::settings::load(&directory.path().join("settings.json")); + assert_eq!(saved.presets.len(), 1); + let preset = &saved.presets[0]; + assert_eq!(preset.name, "Evening"); + assert!(preset.values.contains_key("audio/out/level")); + assert!(!preset.values.contains_key("audio/out/delay")); + assert!(!preset.values.contains_key("device/name")); + + simulator.set_value("audio/out/level", json!(70.0)); + wait_for_level(&mut harness, 70.0); + let entries_before = harness.state().history().len(); + if harness.query_by_label(texts::LOAD_PRESET.get()).is_none() { + open_panel(&mut harness, device_name(SimModel::Kh120Ii)); + } + harness.get_by_label(texts::LOAD_PRESET.get()).click(); + harness.run_steps(3); + harness + .get_by_label(&texts::preset_entry("Evening", "KH 120 II")) + .click(); + // The preview grows the dialog, which centres itself again over a few frames. + harness.run_steps(6); + harness.get_by_label_contains("Pegel: "); + harness.get_by_label(texts::WRITE_PRESET.get()).click(); + wait_until(&mut harness, "the level written back", |_| { + sets_to(&simulator, LEVEL_POINTER).contains(&json!(100.0)) + }); + harness.run_steps(2); + assert_eq!(harness.state().history().len(), entries_before + 1); +} diff --git a/crates/core/src/settings.rs b/crates/core/src/settings.rs index 03f682f..01d2f5c 100644 --- a/crates/core/src/settings.rs +++ b/crates/core/src/settings.rs @@ -1,6 +1,7 @@ //! Persistent settings stored next to the executable. use std::cell::Cell; +use std::collections::BTreeMap; use std::ffi::OsString; use std::fs::{self, File}; use std::io::{self, ErrorKind, Write}; @@ -39,6 +40,29 @@ pub struct Settings { /// Speaker identities (`serial:` or `address:
`) in the order of the desk. #[serde(deserialize_with = "or_default")] pub order: Vec, + #[serde(deserialize_with = "readable_items")] + pub presets: Vec, + #[serde(deserialize_with = "readable_items")] + pub eq_presets: Vec, +} + +/// Chosen settings of a speaker under a name, to write to it or another speaker later. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SettingsPreset { + pub name: String, + /// The product it was saved from, shown with the name. + pub product: String, + /// SSC path → value. + pub values: BTreeMap, +} + +/// One EQ container's bands under a name: its six leaves, each an array of `bands` entries. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EqPreset { + pub name: String, + pub bands: usize, + /// Leaf name (`gain`, `boost`, ...) → array. + pub leaves: BTreeMap, } impl Settings { @@ -240,7 +264,12 @@ where D: Deserializer<'de>, T: DeserializeOwned, { - let items = Vec::::deserialize(deserializer)?; + // A value that is not a list at all loses only this list, not the whole file. + let serde_json::Value::Array(items) = serde_json::Value::deserialize(deserializer)? else { + log::warn!("a settings list is not a list; left out"); + NOT_UNDERSTOOD.with(|count| count.set(count.get() + 1)); + return Ok(Vec::new()); + }; let total = items.len(); let readable: Vec = items .into_iter() diff --git a/crates/core/tests/settings.rs b/crates/core/tests/settings.rs index 8184cd5..71ad525 100644 --- a/crates/core/tests/settings.rs +++ b/crates/core/tests/settings.rs @@ -1,6 +1,7 @@ use neumann_control_core::settings::{ - load, save, CrossoverGuard, DeskSettings, GroupSettings, Language, LanguageChoice, Linkable, - ManualSpeaker, MeterFloor, Settings, ThemeChoice, WindowGeometry, SETTINGS_FILE, + load, save, CrossoverGuard, DeskSettings, EqPreset, GroupSettings, Language, LanguageChoice, + Linkable, ManualSpeaker, MeterFloor, Settings, SettingsPreset, ThemeChoice, WindowGeometry, + SETTINGS_FILE, }; use std::fs; @@ -306,3 +307,32 @@ fn crossovers_stay_protected_unless_opened_on_purpose() { fs::write(&path, r#"{"manual":[],"desk":{"crossovers":"Open"}}"#).unwrap(); assert_eq!(load(&path).desk.crossovers, CrossoverGuard::Open); } + +#[test] +fn presets_round_trip_and_a_broken_one_is_left_out() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(SETTINGS_FILE); + let settings = Settings { + presets: vec![SettingsPreset { + name: "Night".to_owned(), + product: "KH 120 II".to_owned(), + values: [("audio/out/level".to_owned(), serde_json::json!(80.0))].into(), + }], + eq_presets: vec![EqPreset { + name: "Flat-ish".to_owned(), + bands: 2, + leaves: [("gain".to_owned(), serde_json::json!([0.0, 1.0]))].into(), + }], + ..Settings::default() + }; + save(&settings, &path).unwrap(); + assert_eq!(load(&path), settings); + fs::write( + &path, + r#"{"manual":[],"presets":[{"name":"ok","product":"KH 750","values":{}},{"name":3}],"eq_presets":"no"}"#, + ) + .unwrap(); + let loaded = load(&path); + assert_eq!(loaded.presets.len(), 1); + assert!(loaded.eq_presets.is_empty()); +} From 2058c887b857b20025f7a1ed8bb7832a12251737 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:32:18 +0200 Subject: [PATCH 154/460] Save and load EQ presets --- crates/app/src/app/eq.rs | 178 +++++++++++++++++++++++--- crates/app/src/app/preset_dialogs.rs | 1 + crates/app/src/presets.rs | 132 ++++++++++++++++++- crates/app/src/texts.rs | 20 +++ crates/app/src/views/eq_window.rs | 81 ++++++++++++ crates/app/src/views/preset_dialog.rs | 5 + crates/app/tests/eq.rs | 91 +++++++++++++ 7 files changed, 491 insertions(+), 17 deletions(-) diff --git a/crates/app/src/app/eq.rs b/crates/app/src/app/eq.rs index e827755..f9bde8b 100644 --- a/crates/app/src/app/eq.rs +++ b/crates/app/src/app/eq.rs @@ -12,12 +12,13 @@ use crate::eq::{bands_of, container_of, flat, has_eq, one_band, Band, Leaf, FLAT use crate::eq_response::{curve_db, graph_frequencies}; use crate::history::Entry; use crate::paths; +use crate::presets::{capture_eq, eq_plan, fitting}; use crate::state::{SpeakerKey, SpeakerState, Status}; use crate::texts; use crate::ui_kit::theme::group_color; use crate::views::eq_graph::{ComparedCurve, Graph, GraphAction, GraphCurve, GraphPoint}; use crate::views::eq_table::{Limits, Table, TableAction, TableRow}; -use crate::views::eq_window::{self, EqAction, EqView, ResetOffer}; +use crate::views::eq_window::{self, EqAction, EqView, PresetDialogView, ResetOffer}; use crate::writes::{self, Intent}; const EQ_VIEWPORT_ID: &str = "eq_window"; @@ -36,6 +37,17 @@ pub(super) struct EqWindow { compared: Vec, /// The speakers offered for comparison in the last frame, in the order shown. candidates: Vec, + preset: Option, +} + +enum EqPresetDialog { + Save { + name: String, + }, + /// `selected` is a place among the presets that fit the container. + Load { + selected: Option, + }, } /// One container as the window shows it. @@ -60,6 +72,7 @@ impl NeumannApp { asking_reset: false, compared: Vec::new(), candidates: Vec::new(), + preset: None, }); } @@ -241,13 +254,51 @@ impl NeumannApp { .and_then(|id| self.history.history.entry(id)) .map(|entry| self.changed_lines(entry)); let crossovers_open = self.settings.desk.crossovers == CrossoverGuard::Open; - let reset = selected - .filter(|shown| editable && (crossovers_open || !shown.container.crossover)) - .map(|shown| ResetOffer { - container: shown.container.label(texts::language()).to_owned(), - asking: window.asking_reset, - crossover: shown.container.crossover, - }); + let reshapable = + selected.filter(|shown| editable && (crossovers_open || !shown.container.crossover)); + let band_count = bands.as_ref().map_or(0, Vec::len); + let fitting_presets = fitting(&self.settings.eq_presets, band_count); + let preset_names: Vec = fitting_presets + .iter() + .map(|place| { + let preset = &self.settings.eq_presets[*place]; + texts::eq_preset_entry(&preset.name, preset.bands) + }) + .collect(); + let preset_changes: Vec = match (&window.preset, reshapable) { + ( + Some(EqPresetDialog::Load { + selected: Some(place), + }), + Some(shown), + ) => fitting_presets + .get(*place) + .map(|place| { + let preset = &self.settings.eq_presets[*place]; + let plan = eq_plan(preset, &speaker.values, &shown.container.path); + plan.bands + .iter() + .map(|band| texts::eq_preset_band(band + 1)) + .collect() + }) + .unwrap_or_default(), + _ => Vec::new(), + }; + let preset_dialog = match &window.preset { + Some(EqPresetDialog::Save { name }) => Some(PresetDialogView::Save { name }), + Some(EqPresetDialog::Load { selected }) => Some(PresetDialogView::Load { + names: &preset_names, + selected: *selected, + changes: &preset_changes, + crossover: reshapable.is_some_and(|shown| shown.container.crossover), + }), + None => None, + }; + let reset = reshapable.map(|shown| ResetOffer { + container: shown.container.label(texts::language()).to_owned(), + asking: window.asking_reset, + crossover: shown.container.crossover, + }); let view = EqView { viewport: ViewportId::from_hash_of(id), title: texts::eq_window_title(&speaker.display_name()), @@ -263,6 +314,8 @@ impl NeumannApp { question, candidates: &candidates, compared: &legend, + presets: reshapable.is_some(), + preset_dialog, }; let actions = eq_window::show(context, &view); self.eq_windows[index].candidates = candidate_keys; @@ -321,6 +374,26 @@ impl NeumannApp { self.request_take_back(id); } } + EqAction::PresetSave => { + window.preset = Some(EqPresetDialog::Save { + name: String::new(), + }); + } + EqAction::PresetLoad => window.preset = Some(EqPresetDialog::Load { selected: None }), + EqAction::PresetName(typed) => { + if let Some(EqPresetDialog::Save { name }) = &mut window.preset { + *name = typed; + } + } + EqAction::PresetSelect(place) => { + window.preset = Some(EqPresetDialog::Load { + selected: Some(place), + }); + } + EqAction::PresetClose => window.preset = None, + EqAction::PresetConfirmSave => self.save_eq_preset(index), + EqAction::PresetWrite => self.write_eq_preset(index, now), + EqAction::PresetDelete => self.delete_eq_preset(index), EqAction::Compare(place, on) => { let Some(key) = window.candidates.get(place).cloned() else { return; @@ -355,6 +428,84 @@ impl NeumannApp { } } + /// The chosen container's path and its band count, once the speaker reported it. + fn chosen_container(&self, index: usize) -> Option<(SscPath, usize)> { + let catalog = Catalog::embedded(); + let window = &self.eq_windows[index]; + let speaker = self.speaker_of(&window.key)?; + let outputs = outputs_of(catalog, speaker.product()); + let container = outputs + .get(window.output) + .and_then(|(_, containers)| containers.get(window.container))?; + let length = speaker + .values + .get(&Leaf::Gain.path(&container.path)) + .and_then(Value::as_array) + .map(Vec::len)?; + Some((container.path.clone(), length)) + } + + fn save_eq_preset(&mut self, index: usize) { + let window = &self.eq_windows[index]; + let Some(EqPresetDialog::Save { name }) = &window.preset else { + return; + }; + let name = name.trim().to_owned(); + let preset = self.chosen_container(index).and_then(|(container, _)| { + let speaker = self.speaker_of(&self.eq_windows[index].key)?; + capture_eq(&name, &speaker.values, &container) + }); + if let Some(preset) = preset { + let presets = &mut self.settings.eq_presets; + presets.retain(|known| known.name != preset.name); + presets.push(preset); + self.save_settings(); + } + self.eq_windows[index].preset = None; + } + + /// The preset place in the settings that the load dialog's selection stands for. + fn selected_eq_preset(&self, index: usize) -> Option { + let Some(EqPresetDialog::Load { + selected: Some(place), + }) = &self.eq_windows[index].preset + else { + return None; + }; + let (_, bands) = self.chosen_container(index)?; + fitting(&self.settings.eq_presets, bands) + .get(*place) + .copied() + } + + /// Every differing leaf whole, in this frame: one history entry. + fn write_eq_preset(&mut self, index: usize, now: f64) { + let plan = self.selected_eq_preset(index).and_then(|place| { + let (container, _) = self.chosen_container(index)?; + let speaker = self.speaker_of(&self.eq_windows[index].key)?; + Some(eq_plan( + &self.settings.eq_presets[place], + &speaker.values, + &container, + )) + }); + self.eq_windows[index].preset = None; + for (path, value) in plan.map(|plan| plan.writes).unwrap_or_default() { + if let Err((label, reason)) = self.send_eq_write(index, &path, value, now) { + self.eq_windows[index].refusal = Some(texts::eq_leaf_refusal(&label, &reason)); + return; + } + } + } + + fn delete_eq_preset(&mut self, index: usize) { + if let Some(place) = self.selected_eq_preset(index) { + self.settings.eq_presets.remove(place); + self.save_settings(); + } + self.eq_windows[index].preset = Some(EqPresetDialog::Load { selected: None }); + } + /// `gain` and `boost` of every band of the chosen container to 0, both in this frame, so /// the history files them as one entry. fn flatten(&mut self, index: usize, now: f64) { @@ -371,14 +522,9 @@ impl NeumannApp { /// The chosen container's `leaf` and its band count, once the speaker reported it. fn chosen_leaf(&self, index: usize, leaf: Leaf) -> Option<(SscPath, usize)> { - let catalog = Catalog::embedded(); - let window = &self.eq_windows[index]; - let speaker = self.speaker_of(&window.key)?; - let outputs = outputs_of(catalog, speaker.product()); - let container = outputs - .get(window.output) - .and_then(|(_, containers)| containers.get(window.container))?; - let path = leaf.path(&container.path); + let (container, _) = self.chosen_container(index)?; + let speaker = self.speaker_of(&self.eq_windows[index].key)?; + let path = leaf.path(&container); let length = speaker .values .get(&path) diff --git a/crates/app/src/app/preset_dialogs.rs b/crates/app/src/app/preset_dialogs.rs index 99ca131..dd5763e 100644 --- a/crates/app/src/app/preset_dialogs.rs +++ b/crates/app/src/app/preset_dialogs.rs @@ -169,6 +169,7 @@ impl NeumannApp { selected, changes: &changes, skipped: &loading.skipped, + warning: None, }; match preset_dialog::load(ui, &view) { LoadAction::None => {} diff --git a/crates/app/src/presets.rs b/crates/app/src/presets.rs index 037748f..2813096 100644 --- a/crates/app/src/presets.rs +++ b/crates/app/src/presets.rs @@ -2,10 +2,13 @@ //! one onto a speaker would write. The writes themselves go through the write policy. use neumann_control_core::catalog::{Catalog, Entry}; -use neumann_control_core::settings::SettingsPreset; +use std::collections::BTreeMap; + +use neumann_control_core::settings::{EqPreset, SettingsPreset}; use neumann_control_core::ssc::wire::SscPath; use serde_json::Value; +use crate::eq::Leaf; use crate::history::same_value; use crate::paths; use crate::state::SpeakerState; @@ -92,6 +95,77 @@ pub fn plan(preset: &SettingsPreset, speaker: &SpeakerState, catalog: &Catalog) plan } +/// Loading an EQ preset: the leaves to write whole, and the bands that change (for the preview). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct EqPlan { + pub writes: Vec<(SscPath, Value)>, + /// Zero-based. + pub bands: Vec, +} + +/// The container's six leaves as reported; `None` until the speaker reported all of them with +/// the same band count. +pub fn capture_eq( + name: &str, + values: &BTreeMap, + container: &SscPath, +) -> Option { + let mut leaves = BTreeMap::new(); + let mut bands = None; + for leaf in Leaf::ALL { + let array = values.get(&leaf.path(container))?.as_array()?; + if *bands.get_or_insert(array.len()) != array.len() { + return None; + } + leaves.insert(leaf.name().to_owned(), Value::Array(array.clone())); + } + Some(EqPreset { + name: name.to_owned(), + bands: bands?, + leaves, + }) +} + +/// The places of the presets that fit a container of `bands` bands. +pub fn fitting(presets: &[EqPreset], bands: usize) -> Vec { + presets + .iter() + .enumerate() + .filter(|(_, preset)| preset.bands == bands) + .map(|(place, _)| place) + .collect() +} + +/// The leaves whose arrays differ from what the speaker reports, written whole. +pub fn eq_plan( + preset: &EqPreset, + values: &BTreeMap, + container: &SscPath, +) -> EqPlan { + let mut plan = EqPlan::default(); + for leaf in Leaf::ALL { + let Some(wanted) = preset.leaves.get(leaf.name()).and_then(Value::as_array) else { + continue; + }; + let path = leaf.path(container); + let held = values.get(&path).and_then(Value::as_array); + let differing: Vec = (0..wanted.len()) + .filter(|band| held.and_then(|held| held.get(*band)) != wanted.get(*band)) + .collect(); + if differing.is_empty() { + continue; + } + for band in differing { + if !plan.bands.contains(&band) { + plan.bands.push(band); + } + } + plan.writes.push((path, Value::Array(wanted.clone()))); + } + plan.bands.sort_unstable(); + plan +} + #[cfg(test)] mod tests { use super::*; @@ -99,6 +173,7 @@ mod tests { use serde_json::json; use std::net::SocketAddrV6; + use crate::eq::Leaf; use crate::state::SpeakerKey; fn speaker(product: &str, values: &[(&str, Value)]) -> SpeakerState { @@ -124,6 +199,61 @@ mod tests { speaker } + fn eq_values(gain: Value) -> BTreeMap { + let container = paths::path("audio/out/eq2"); + Leaf::ALL + .iter() + .map(|leaf| { + let value = match leaf { + Leaf::Gain => gain.clone(), + Leaf::Enabled => json!([true, false]), + Leaf::Type => json!(["PARAMETRIC", "NOTCH"]), + _ => json!([1.0, 2.0]), + }; + (leaf.path(&container), value) + }) + .collect() + } + + #[test] + fn an_eq_preset_holds_all_six_leaves_of_a_container() { + let container = paths::path("audio/out/eq2"); + let preset = capture_eq("Warm", &eq_values(json!([0.0, 3.0])), &container).unwrap(); + assert_eq!(preset.bands, 2); + assert_eq!(preset.leaves.len(), 6); + assert_eq!(preset.leaves["gain"], json!([0.0, 3.0])); + let mut missing = eq_values(json!([0.0, 3.0])); + missing.remove(&Leaf::Q.path(&container)); + assert!(capture_eq("Warm", &missing, &container).is_none()); + } + + #[test] + fn loading_an_eq_preset_writes_only_differing_leaves() { + let container = paths::path("audio/out/eq2"); + let preset = capture_eq("Warm", &eq_values(json!([0.0, 3.0])), &container).unwrap(); + let now = eq_values(json!([0.0, -2.0])); + let plan = eq_plan(&preset, &now, &container); + assert_eq!(plan.bands, vec![1]); + assert_eq!( + plan.writes, + vec![(Leaf::Gain.path(&container), json!([0.0, 3.0]))] + ); + assert!(eq_plan(&preset, &eq_values(json!([0.0, 3.0])), &container) + .writes + .is_empty()); + } + + #[test] + fn only_presets_with_the_same_band_count_fit() { + let preset = |bands| EqPreset { + name: format!("{bands}"), + bands, + leaves: BTreeMap::new(), + }; + let presets = [preset(10), preset(20), preset(10)]; + assert_eq!(fitting(&presets, 10), vec![0, 2]); + } + #[test] fn a_preset_holds_writable_settings_but_not_identify_or_eq_bands() { let catalog = Catalog::embedded(); diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index db26fda..0d86030 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -175,6 +175,10 @@ pub const EQ_OUTPUT_B: Text = Text::new("Ausgang B", "Output B"); pub const BAND: Text = Text::new("Band", "Band"); pub const EQ_RESET: Text = Text::new("Flach stellen", "Make flat"); pub const EQ_UNDO: Text = Text::new("Rückgängig", "Undo"); +pub const EQ_PRESET_SAVE: Text = Text::new("Als EQ-Preset speichern…", "Save as EQ preset…"); +pub const EQ_PRESET_LOAD: Text = Text::new("EQ-Preset laden…", "Load EQ preset…"); +pub const EQ_PRESET_SAVE_TITLE: Text = Text::new("EQ als Preset", "EQ as a preset"); +pub const EQ_PRESET_LOAD_TITLE: Text = Text::new("EQ-Preset laden", "Load an EQ preset"); pub const COMPARE_WITH: Text = Text::new("Vergleichen mit:", "Compare with:"); pub const CROSSOVER_WARNING: Text = Text::new( "Das ist eine Weiche: ihr Tief- bzw. Hochpass entfällt, der Ausgang spielt ungefiltert.", @@ -685,6 +689,22 @@ pub fn trend_hover(minutes_ago: u64, value: &str) -> String { ) } +/// An EQ preset in the load dialog: its name and band count. +pub fn eq_preset_entry(name: &str, bands: usize) -> String { + pick( + format!("{name} ({bands} Bänder)"), + format!("{name} ({bands} bands)"), + ) +} + +/// A band an EQ preset would change, in the load dialog's preview. +pub fn eq_preset_band(number: usize) -> String { + pick( + format!("Band {number} ändert sich"), + format!("Band {number} changes"), + ) +} + /// The legend entry of another speaker's curve in the EQ graph. pub fn compared_curve(speaker: &str) -> String { pick(format!("{speaker} (Summe)"), format!("{speaker} (sum)")) diff --git a/crates/app/src/views/eq_window.rs b/crates/app/src/views/eq_window.rs index 8acf2dd..8ef1b46 100644 --- a/crates/app/src/views/eq_window.rs +++ b/crates/app/src/views/eq_window.rs @@ -9,6 +9,7 @@ use eframe::egui::{ use super::eq_graph::{self, Graph, GraphAction}; use super::eq_table::{self, Table, TableAction}; use super::history::UNDO_SHORTCUT; +use super::preset_dialog::{self, LoadAction, LoadView, SaveAction}; use crate::texts; const EQ_WINDOW_SIZE: [f32; 2] = [900.0, 640.0]; @@ -39,6 +40,24 @@ pub struct EqView<'a> { pub candidates: &'a [(String, bool)], /// The compared curves that are drawn, for the legend: name and colour. pub compared: &'a [(String, Color32)], + /// Whether the chosen container can be saved as or loaded from a preset. + pub presets: bool, + pub preset_dialog: Option>, +} + +pub enum PresetDialogView<'a> { + Save { + name: &'a str, + }, + Load { + /// The presets that fit the container. + names: &'a [String], + selected: Option, + /// The bands that would change. + changes: &'a [String], + /// The container is a crossover (protection off): loading warns. + crossover: bool, + }, } pub struct ResetOffer { @@ -64,6 +83,15 @@ pub enum EqAction { QuestionCancel, /// The candidate at this place is compared or no longer. Compare(usize, bool), + PresetSave, + PresetLoad, + PresetName(String), + PresetConfirmSave, + /// The place among the fitting presets. + PresetSelect(usize), + PresetWrite, + PresetDelete, + PresetClose, } pub fn show(context: &Context, view: &EqView) -> Vec { @@ -137,6 +165,19 @@ fn contents(ui: &mut Ui, view: &EqView) -> Vec { ui.colored_label(ui.visuals().warn_fg_color, notice); } actions.extend(edit_row(ui, view.reset.as_ref(), view.undo.as_deref())); + if view.presets { + ui.horizontal(|ui| { + if ui.button(texts::EQ_PRESET_SAVE).clicked() { + actions.push(EqAction::PresetSave); + } + if ui.button(texts::EQ_PRESET_LOAD).clicked() { + actions.push(EqAction::PresetLoad); + } + }); + } + if let Some(dialog) = &view.preset_dialog { + actions.extend(preset_dialog(ui, dialog)); + } if let Some(lines) = &view.question { actions.extend(question(ui, lines)); } @@ -154,6 +195,46 @@ fn contents(ui: &mut Ui, view: &EqView) -> Vec { actions } +fn preset_dialog(ui: &mut Ui, dialog: &PresetDialogView) -> Option { + match dialog { + PresetDialogView::Save { name } => { + let mut typed = (*name).to_owned(); + let title = texts::EQ_PRESET_SAVE_TITLE.get(); + let action = preset_dialog::save(ui, title, &[], &mut typed); + if typed != *name { + return Some(EqAction::PresetName(typed)); + } + match action { + SaveAction::Save => Some(EqAction::PresetConfirmSave), + SaveAction::Cancel => Some(EqAction::PresetClose), + SaveAction::None | SaveAction::Toggle(..) => None, + } + } + PresetDialogView::Load { + names, + selected, + changes, + crossover, + } => { + let view = LoadView { + title: texts::EQ_PRESET_LOAD_TITLE.get(), + presets: names, + selected: *selected, + changes, + skipped: &[], + warning: crossover.then(|| texts::CROSSOVER_WARNING.get()), + }; + match preset_dialog::load(ui, &view) { + LoadAction::None => None, + LoadAction::Select(place) => Some(EqAction::PresetSelect(place)), + LoadAction::Write => Some(EqAction::PresetWrite), + LoadAction::Delete => Some(EqAction::PresetDelete), + LoadAction::Close => Some(EqAction::PresetClose), + } + } + } +} + fn compare_row(ui: &mut Ui, candidates: &[(String, bool)]) -> Option { if candidates.is_empty() { return None; diff --git a/crates/app/src/views/preset_dialog.rs b/crates/app/src/views/preset_dialog.rs index d1c8217..977f055 100644 --- a/crates/app/src/views/preset_dialog.rs +++ b/crates/app/src/views/preset_dialog.rs @@ -68,6 +68,8 @@ pub struct LoadView<'a> { /// "Setting: current → preset" per write. pub changes: &'a [String], pub skipped: &'a [String], + /// A caution above the buttons (a crossover would lose its split). + pub warning: Option<&'a str>, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -110,6 +112,9 @@ pub fn load(ui: &mut Ui, view: &LoadView) -> LoadAction { ui.weak(texts::preset_skipped(view.skipped)); } } + if let Some(warning) = view.warning { + ui.colored_label(ui.visuals().warn_fg_color, warning); + } ui.horizontal(|ui| { let writable = view.selected.is_some() && !view.changes.is_empty(); if ui diff --git a/crates/app/tests/eq.rs b/crates/app/tests/eq.rs index 565fc57..69706a8 100644 --- a/crates/app/tests/eq.rs +++ b/crates/app/tests/eq.rs @@ -509,3 +509,94 @@ fn another_speakers_curve_can_be_compared() { .query_by_role_and_label(Role::CheckBox, KH120_DEVICE_NAME) .is_none()); } + +fn save_eq_preset(harness: &mut Harness<'static, NeumannApp>, name: &str) { + harness + .get_by_role_and_label(Role::Button, texts::EQ_PRESET_SAVE.get()) + .click(); + harness.run_steps(3); + let field = || texts::PRESET_NAME.get(); + harness + .get_by_role_and_label(Role::TextInput, field()) + .click(); + harness.run_steps(1); + harness + .get_by_role_and_label(Role::TextInput, field()) + .type_text(name); + harness.run_steps(1); + harness.get_by_label(texts::SAVE.get()).click(); + harness.run_steps(3); +} + +#[test] +fn an_eq_preset_is_saved_and_written_back_as_one_entry() { + let (simulator, directory, mut harness) = open_eq(SimModel::Kh120Ii); + save_eq_preset(&mut harness, "Warm"); + let saved = neumann_control_core::settings::load(&directory.path().join("settings.json")); + assert_eq!(saved.eq_presets.len(), 1); + assert_eq!(saved.eq_presets[0].bands, 10); + assert_eq!(saved.eq_presets[0].leaves.len(), 6); + + let point = harness.get_by_label(&texts::eq_band(2)).rect().center(); + drag(&mut harness, point, egui::vec2(0.0, -40.0)); + wait_until(&mut harness, "the band write", |_| { + !sets(&simulator, BOOST_POINTER).is_empty() + }); + harness.run_steps(3); + let entries_before = harness.state().history().len(); + harness + .get_by_role_and_label(Role::Button, texts::EQ_PRESET_LOAD.get()) + .click(); + harness.run_steps(3); + harness + .get_by_label(&texts::eq_preset_entry("Warm", 10)) + .click(); + harness.run_steps(6); + harness.get_by_label(&texts::eq_preset_band(2)); + harness.get_by_label(texts::WRITE_PRESET.get()).click(); + wait_until(&mut harness, "the preset's boost written", |_| { + sets(&simulator, BOOST_POINTER).len() == 2 + }); + assert_eq!(sets(&simulator, BOOST_POINTER)[1], json!(vec![0.0; 10])); + harness.run_steps(2); + assert_eq!(harness.state().history().len(), entries_before + 1); +} + +#[test] +fn eq_presets_of_another_band_count_are_not_offered() { + let (_simulator, _directory, mut harness) = open_eq(SimModel::Kh120Ii); + save_eq_preset(&mut harness, "Warm"); + harness.get_by_label("EQ 3").click(); + harness.run_steps(2); + harness + .get_by_role_and_label(Role::Button, texts::EQ_PRESET_LOAD.get()) + .click(); + harness.run_steps(3); + harness.get_by_label(texts::NO_PRESETS.get()); + assert!(harness + .query_by_label(&texts::eq_preset_entry("Warm", 10)) + .is_none()); +} + +#[test] +fn a_protected_crossover_has_no_eq_presets() { + let (_simulator, _directory, mut harness) = open_eq(SimModel::Kh750); + harness.get_by_role_and_label(Role::Button, texts::EQ_PRESET_SAVE.get()); + harness.get_by_label("Weiche A").click(); + harness.run_steps(2); + assert!(harness + .query_by_role_and_label(Role::Button, texts::EQ_PRESET_SAVE.get()) + .is_none()); +} + +#[test] +fn with_open_crossovers_loading_an_eq_preset_warns() { + let (_simulator, _directory, mut harness) = open_eq_with_open_crossovers(); + harness.get_by_label("Weiche A").click(); + harness.run_steps(2); + harness + .get_by_role_and_label(Role::Button, texts::EQ_PRESET_LOAD.get()) + .click(); + harness.run_steps(3); + harness.get_by_label_contains(texts::CROSSOVER_WARNING.get()); +} From f15ff3479ee96e62ed0138f724b778df2185f5ed Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:39:44 +0200 Subject: [PATCH 155/460] Show the Windows volume as a strip --- Cargo.lock | 1 + crates/app/Cargo.toml | 12 ++ crates/app/src/app.rs | 31 ++- crates/app/src/app/desk.rs | 15 ++ crates/app/src/app/system_volume.rs | 67 +++++++ crates/app/src/lib.rs | 1 + crates/app/src/main.rs | 5 +- crates/app/src/system_audio.rs | 248 ++++++++++++++++++++++++ crates/app/src/texts.rs | 16 ++ crates/app/src/views/group_strip.rs | 2 +- crates/app/src/views/mod.rs | 1 + crates/app/src/views/settings_dialog.rs | 56 +++++- crates/app/src/views/system_strip.rs | 77 ++++++++ crates/app/tests/support/mod.rs | 64 +++++- crates/app/tests/system_audio.rs | 142 ++++++++++++++ crates/core/src/settings.rs | 11 ++ crates/core/tests/settings.rs | 23 ++- 17 files changed, 759 insertions(+), 13 deletions(-) create mode 100644 crates/app/src/app/system_volume.rs create mode 100644 crates/app/src/system_audio.rs create mode 100644 crates/app/src/views/system_strip.rs create mode 100644 crates/app/tests/system_audio.rs diff --git a/Cargo.lock b/Cargo.lock index c9d6ea8..d1ce18a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1928,6 +1928,7 @@ dependencies = [ "simplelog", "tempfile", "time", + "windows", "windows-sys 0.61.2", "winit", "winresource", diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 49a16af..0499fbb 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -28,6 +28,18 @@ winit = "0.30" [target.'cfg(windows)'.dependencies] # Already in the tree through winit; asks Windows for its display language. windows-sys = { version = "0.61", features = ["Win32_Globalization"] } +# Already in the tree through accesskit_windows; Core Audio (COM) for the Windows volume strip, +# which windows-sys cannot call. +windows = { version = "0.62", features = [ + "Win32_Devices_FunctionDiscovery", + "Win32_Foundation", + "Win32_Media_Audio", + "Win32_Media_Audio_Endpoints", + "Win32_System_Com", + "Win32_System_Com_StructuredStorage", + "Win32_System_Variant", + "Win32_UI_Shell_PropertiesSystem", +] } [build-dependencies] # Puts the icon into the executable, for Explorer and the taskbar. diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index 0eff471..a63f21a 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -16,10 +16,12 @@ use neumann_control_core::settings::{self, Language, ManualSpeaker, Settings, Wi use neumann_control_core::speaker::{Command, Event, Events, SpeakerHandle, Target}; use crate::groups::{forget_member, member_id, rename_member, set_membership}; +use crate::meter::MeterState; use crate::scan_choice::ScanChoice; use crate::spawner::{ScanResult, SpeakerSpawner}; use crate::state::{AllValuesState, SpeakerKey, SpeakerState, Status}; use crate::status_leds::average_response_time; +use crate::system_audio::{AudioDevice, SystemAudio}; use crate::targets::{duplicate_speakers, follow_moves, forget, remember_serial, targets}; use crate::texts; use crate::ui_kit::{fonts, theme}; @@ -37,6 +39,7 @@ mod eq; mod full_scan; mod history; mod preset_dialogs; +mod system_volume; pub use history::TakeBackNotice; @@ -86,6 +89,11 @@ pub struct NeumannApp { history: history::HistoryState, eq_windows: Vec, preset_dialog: Option, + system_audio: Box, + /// The Windows strip's meters, one per channel of the device. + system_meters: Vec, + /// The playback devices for the settings dialog, listed when it opens. + audio_devices: Vec, /// What "like Windows" means, asked of Windows at the start. system_language: Language, } @@ -96,10 +104,18 @@ impl NeumannApp { context: &Context, settings_path: PathBuf, spawner: Box, + system_audio: Box, system_language: Language, ) -> NeumannApp { let settings = settings::load(&settings_path); - NeumannApp::with_settings(context, settings_path, settings, spawner, system_language) + NeumannApp::with_settings( + context, + settings_path, + settings, + spawner, + system_audio, + system_language, + ) } /// With settings already loaded from `settings_path`, as the start does to place the window: @@ -109,6 +125,7 @@ impl NeumannApp { settings_path: PathBuf, settings: Settings, spawner: Box, + system_audio: Box, system_language: Language, ) -> NeumannApp { fonts::install(context); @@ -134,6 +151,9 @@ impl NeumannApp { history: history::HistoryState::default(), eq_windows: Vec::new(), preset_dialog: None, + system_audio, + system_meters: Vec::new(), + audio_devices: Vec::new(), system_language, }; app.sync_targets(); @@ -356,7 +376,10 @@ impl NeumannApp { } ToolbarAction::AddAddress => self.add_address = Some(AddAddressForm::default()), ToolbarAction::AddGroup => self.add_group(), - ToolbarAction::Settings => self.settings_open = true, + ToolbarAction::Settings => { + self.settings_open = true; + self.audio_devices = self.system_audio.devices(); + } ToolbarAction::History => self.open_history(), ToolbarAction::ToggleCompact => self.toggle_compact(frame), ToolbarAction::None => {} @@ -510,7 +533,8 @@ impl NeumannApp { if !self.settings_open { return; } - match settings_dialog::show(ui, &mut self.settings.desk) { + let audio = &mut self.settings.system_audio; + match settings_dialog::show(ui, &mut self.settings.desk, audio, &self.audio_devices) { SettingsAction::Changed => { ui.ctx() .set_theme(theme::preference(self.settings.desk.theme)); @@ -604,6 +628,7 @@ impl eframe::App for NeumannApp { fn ui(&mut self, ui: &mut Ui, frame: &mut eframe::Frame) { texts::set_language(self.settings.desk.language.resolve(self.system_language)); wheel::track_burst(ui.ctx()); + self.keep_system_meters_moving(ui.ctx()); self.show(ui, frame); } diff --git a/crates/app/src/app/desk.rs b/crates/app/src/app/desk.rs index e49a249..448f0a7 100644 --- a/crates/app/src/app/desk.rs +++ b/crates/app/src/app/desk.rs @@ -22,6 +22,7 @@ use crate::texts; use crate::ui_kit::theme::{group_color, group_color_count}; use crate::views::group_strip::{self, GroupStripResponse, MasterResponse}; use crate::views::strip; +use crate::views::system_strip::{self, SystemStrip, SystemStripResponse}; use crate::writes::Intent; const STRIP_CENTERS_ID: &str = "strip_centers"; @@ -36,6 +37,7 @@ pub(super) enum DeskAction { Master(MasterResponse), OpenPanel(OpenPanel), OpenEq(SpeakerKey), + System(SystemStripResponse), } /// The group being renamed or deleted, and the name typed so far. @@ -62,6 +64,8 @@ impl NeumannApp { }; let mut centers = HashMap::new(); let range = output_level_range(); + let now = ui.input(|input| input.time); + let system = self.read_system_volume(now); let mut actions = Vec::new(); ScrollArea::both() .id_salt(DESK_SCROLL_ID) @@ -130,6 +134,16 @@ impl NeumannApp { let muted = all_muted(&states); let response = group_strip::master(ui, level.as_ref(), muted, range.clone()); actions.push(DeskAction::Master(response)); + if let Some(view) = &system { + let strip = SystemStrip { + name: &view.name, + volume: view.volume, + mute: view.mute, + meters: &self.system_meters, + floor: desk.meter_floor.db(), + }; + actions.push(DeskAction::System(system_strip::show(ui, &strip))); + } }); }); ui.ctx() @@ -179,6 +193,7 @@ impl NeumannApp { } DeskAction::OpenPanel(open) => self.panel = Some(open), DeskAction::OpenEq(key) => self.open_eq(key), + DeskAction::System(response) => self.apply_system_volume(response), } } diff --git a/crates/app/src/app/system_volume.rs b/crates/app/src/app/system_volume.rs new file mode 100644 index 0000000..97b1891 --- /dev/null +++ b/crates/app/src/app/system_volume.rs @@ -0,0 +1,67 @@ +//! The Windows volume strip on the app side: read once a frame while shown, the meters fed from +//! the peaks, and its gestures handed to Windows. Not part of the speakers' history. + +use std::time::Duration; + +use super::NeumannApp; +use crate::system_audio::peak_db; +use crate::texts; +use crate::views::system_strip::SystemStripResponse; + +/// Windows' peak value is read every frame; this keeps frames coming for the meters. +const METER_REFRESH: Duration = Duration::from_millis(50); +const PERCENT: f64 = 100.0; + +/// What the strip shows this frame; the meters stay in the app. +pub(super) struct SystemView { + pub name: String, + pub volume: Option, + pub mute: Option, +} + +impl NeumannApp { + /// `None` while the strip is switched off. + pub(super) fn read_system_volume(&mut self, now: f64) -> Option { + let settings = &self.settings.system_audio; + if !settings.shown { + return None; + } + let device = settings.device.clone(); + let Some(state) = self.system_audio.state(device.as_deref()) else { + self.system_meters.clear(); + return Some(SystemView { + name: texts::SYSTEM_AUDIO_NAME.get().to_owned(), + volume: None, + mute: None, + }); + }; + let floor = self.settings.desk.meter_floor.db(); + self.system_meters + .resize(state.peaks.len(), Default::default()); + for (meter, peak) in self.system_meters.iter_mut().zip(&state.peaks) { + meter.update(peak_db(*peak, floor), now); + } + Some(SystemView { + name: state.name, + volume: Some(f64::from(state.volume) * PERCENT), + mute: Some(state.mute), + }) + } + + pub(super) fn apply_system_volume(&mut self, response: SystemStripResponse) { + let device = self.settings.system_audio.device.clone(); + if let Some(volume) = response.volume { + let scalar = (volume / PERCENT) as f32; + self.system_audio.set_volume(device.as_deref(), scalar); + } + if let Some(mute) = response.mute { + self.system_audio.set_mute(device.as_deref(), mute); + } + } + + pub(super) fn keep_system_meters_moving(&self, context: &eframe::egui::Context) { + if self.settings.system_audio.shown { + context.request_repaint_after(METER_REFRESH); + } + } +} diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index 8b9bf7c..a0767b8 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -16,6 +16,7 @@ pub mod scan_report; pub mod spawner; pub mod state; pub mod status_leds; +pub mod system_audio; pub mod system_language; pub mod targets; pub mod texts; diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index eddfdad..8afa02f 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -11,6 +11,7 @@ use eframe::egui_wgpu::{SurfaceConfig, WgpuConfiguration, WgpuSetup}; use eframe::wgpu; use neumann_control_app::app::NeumannApp; use neumann_control_app::spawner::CoreSpawner; +use neumann_control_app::system_audio::CoreAudio; use neumann_control_app::system_language; use neumann_control_app::texts; use neumann_control_app::window_placement::{first_window_size, place_window, WINDOW_MIN_SIZE}; @@ -85,7 +86,9 @@ fn main() -> Result<(), Box> { let context = creation_context.egui_ctx.clone(); let spawner = Box::new(CoreSpawner::new(context.clone())); let language = system_language::detect(); - let app = NeumannApp::with_settings(&context, settings_path, saved, spawner, language); + let audio = Box::new(CoreAudio::new()); + let app = + NeumannApp::with_settings(&context, settings_path, saved, spawner, audio, language); Ok(Box::new(app)) }), )?; diff --git a/crates/app/src/system_audio.rs b/crates/app/src/system_audio.rs new file mode 100644 index 0000000..f0e6499 --- /dev/null +++ b/crates/app/src/system_audio.rs @@ -0,0 +1,248 @@ +//! The Windows volume of a playback device, for the system strip: its name, volume, mute and +//! peak level per channel. Behind a trait, so the app and its tests do not depend on a sound card. + +/// A playback device Windows lists as active. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AudioDevice { + /// The endpoint id Windows gives the device; stored in the settings. + pub id: String, + pub name: String, +} + +/// What the strip shows of a device. +#[derive(Clone, Debug, PartialEq)] +pub struct AudioState { + pub name: String, + /// 0 to 1, as Windows' own volume slider. + pub volume: f32, + pub mute: bool, + /// Peak per channel, 0 to 1, since the previous reading. + pub peaks: Vec, +} + +pub trait SystemAudio { + fn devices(&mut self) -> Vec; + /// `device` `None` is Windows' default playback device; `None` back when it is gone. + fn state(&mut self, device: Option<&str>) -> Option; + fn set_volume(&mut self, device: Option<&str>, volume: f32); + fn set_mute(&mut self, device: Option<&str>, mute: bool); +} + +/// A peak value (0 to 1) as dB for the meters; silence is the meters' floor. +pub fn peak_db(peak: f32, floor: f64) -> f64 { + if peak <= 0.0 { + return floor; + } + (20.0 * f64::from(peak).log10()).max(floor) +} + +#[cfg(windows)] +pub use core_audio::CoreAudio; + +#[cfg(windows)] +mod core_audio { + use windows::core::{HSTRING, PWSTR}; + use windows::Win32::Devices::FunctionDiscovery::PKEY_Device_FriendlyName; + use windows::Win32::Media::Audio::Endpoints::{IAudioEndpointVolume, IAudioMeterInformation}; + use windows::Win32::Media::Audio::{ + eConsole, eRender, IMMDevice, IMMDeviceEnumerator, MMDeviceEnumerator, DEVICE_STATE_ACTIVE, + }; + use windows::Win32::System::Com::{ + CoCreateInstance, CoInitializeEx, CoTaskMemFree, CLSCTX_ALL, COINIT_APARTMENTTHREADED, + STGM_READ, + }; + + use super::{AudioDevice, AudioState, SystemAudio}; + + /// The device last read, so a frame does not activate its interfaces again. + struct Endpoint { + /// What the caller asked for (`None`: the default device). + wanted: Option, + id: String, + name: String, + volume: IAudioEndpointVolume, + meter: IAudioMeterInformation, + } + + pub struct CoreAudio { + enumerator: Option, + endpoint: Option, + } + + impl CoreAudio { + /// COM on the UI thread; when eframe initialised it already, that apartment is used. + pub fn new() -> CoreAudio { + // SAFETY: called on the thread that uses the interfaces; a second initialisation + // only reports that COM is already up. + let _ = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) }; + // SAFETY: plain COM object creation; a failure leaves the strip without a device. + let enumerator = + unsafe { CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL) }.ok(); + CoreAudio { + enumerator, + endpoint: None, + } + } + + fn device(&self, wanted: Option<&str>) -> Option { + let enumerator = self.enumerator.as_ref()?; + // SAFETY: COM calls on a live enumerator. + let device = unsafe { + match wanted { + None => enumerator.GetDefaultAudioEndpoint(eRender, eConsole), + Some(id) => enumerator.GetDevice(&HSTRING::from(id)), + } + } + .ok()?; + // SAFETY: as above. + let state = unsafe { device.GetState() }.ok()?; + (state == DEVICE_STATE_ACTIVE).then_some(device) + } + + /// The cached endpoint when it is still the one asked for, else a fresh one. + fn endpoint(&mut self, wanted: Option<&str>) -> Option<&Endpoint> { + let device = self.device(wanted)?; + let id = device_id(&device)?; + let current = self + .endpoint + .as_ref() + .is_some_and(|endpoint| endpoint.wanted.as_deref() == wanted && endpoint.id == id); + if !current { + // SAFETY: activating interfaces of a live device. + let volume = + unsafe { device.Activate::(CLSCTX_ALL, None) }.ok()?; + // SAFETY: as above. + let meter = + unsafe { device.Activate::(CLSCTX_ALL, None) }.ok()?; + self.endpoint = Some(Endpoint { + wanted: wanted.map(str::to_owned), + name: device_name(&device).unwrap_or_else(|| id.clone()), + id, + volume, + meter, + }); + } + self.endpoint.as_ref() + } + } + + impl Default for CoreAudio { + fn default() -> Self { + CoreAudio::new() + } + } + + fn device_id(device: &IMMDevice) -> Option { + // SAFETY: GetId returns a string the caller frees with CoTaskMemFree. + unsafe { + let id: PWSTR = device.GetId().ok()?; + let text = id.to_string().ok(); + CoTaskMemFree(Some(id.0 as *const _)); + text + } + } + + fn device_name(device: &IMMDevice) -> Option { + // SAFETY: reading one property of a live device. + unsafe { + let store = device.OpenPropertyStore(STGM_READ).ok()?; + let value = store.GetValue(&PKEY_Device_FriendlyName).ok()?; + Some(value.to_string()) + } + } + + impl SystemAudio for CoreAudio { + fn devices(&mut self) -> Vec { + let Some(enumerator) = self.enumerator.as_ref() else { + return Vec::new(); + }; + // SAFETY: COM calls on a live enumerator and the collection it returns. + unsafe { + let Ok(collection) = enumerator.EnumAudioEndpoints(eRender, DEVICE_STATE_ACTIVE) + else { + return Vec::new(); + }; + let count = collection.GetCount().unwrap_or(0); + (0..count) + .filter_map(|place| collection.Item(place).ok()) + .filter_map(|device| { + let id = device_id(&device)?; + let name = device_name(&device).unwrap_or_else(|| id.clone()); + Some(AudioDevice { id, name }) + }) + .collect() + } + } + + fn state(&mut self, device: Option<&str>) -> Option { + let endpoint = self.endpoint(device)?; + // SAFETY: COM calls on activated interfaces; a vanished device answers an error. + let read = unsafe { + let volume = endpoint.volume.GetMasterVolumeLevelScalar().ok()?; + let mute = endpoint.volume.GetMute().ok()?.as_bool(); + let channels = endpoint.meter.GetMeteringChannelCount().ok()?; + let mut peaks = vec![0.0; channels as usize]; + endpoint.meter.GetChannelsPeakValues(&mut peaks).ok()?; + Some((volume, mute, peaks)) + }; + let Some((volume, mute, peaks)) = read else { + self.endpoint = None; + return None; + }; + Some(AudioState { + name: endpoint.name.clone(), + volume, + mute, + peaks, + }) + } + + fn set_volume(&mut self, device: Option<&str>, volume: f32) { + if let Some(endpoint) = self.endpoint(device) { + // SAFETY: COM call on an activated interface. + let _ = unsafe { + endpoint + .volume + .SetMasterVolumeLevelScalar(volume.clamp(0.0, 1.0), std::ptr::null()) + }; + } + } + + fn set_mute(&mut self, device: Option<&str>, mute: bool) { + if let Some(endpoint) = self.endpoint(device) { + // SAFETY: COM call on an activated interface. + let _ = unsafe { endpoint.volume.SetMute(mute, std::ptr::null()) }; + } + } + } +} + +/// Where Core Audio does not exist: no devices. +#[cfg(not(windows))] +#[derive(Default)] +pub struct CoreAudio; + +#[cfg(not(windows))] +impl SystemAudio for CoreAudio { + fn devices(&mut self) -> Vec { + Vec::new() + } + fn state(&mut self, _device: Option<&str>) -> Option { + None + } + fn set_volume(&mut self, _device: Option<&str>, _volume: f32) {} + fn set_mute(&mut self, _device: Option<&str>, _mute: bool) {} +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_peak_reads_as_db_down_to_the_floor() { + assert_eq!(peak_db(1.0, -120.0), 0.0); + assert!((peak_db(0.5, -120.0) + 6.02).abs() < 0.01); + assert_eq!(peak_db(0.0, -120.0), -120.0); + assert_eq!(peak_db(1e-9, -60.0), -60.0); + } +} diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 0d86030..5631cfb 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -179,6 +179,17 @@ pub const EQ_PRESET_SAVE: Text = Text::new("Als EQ-Preset speichern…", "Save a pub const EQ_PRESET_LOAD: Text = Text::new("EQ-Preset laden…", "Load EQ preset…"); pub const EQ_PRESET_SAVE_TITLE: Text = Text::new("EQ als Preset", "EQ as a preset"); pub const EQ_PRESET_LOAD_TITLE: Text = Text::new("EQ-Preset laden", "Load an EQ preset"); +pub const SYSTEM_AUDIO_SHOW: Text = Text::new( + "Windows-Lautstärke im Pult zeigen", + "Show the Windows volume on the desk", +); +pub const SYSTEM_AUDIO_DEVICE: Text = Text::new("Gerät", "Device"); +pub const SYSTEM_AUDIO_DEFAULT: Text = Text::new("Windows-Standardgerät", "Windows default device"); +pub const SYSTEM_AUDIO_UNAVAILABLE: Text = Text::new("nicht verfügbar", "not available"); +pub const SYSTEM_AUDIO_NAME: Text = Text::new("Windows", "Windows"); +pub const SYSTEM_VOLUME: Text = Text::new("Windows-Lautstärke", "Windows volume"); +pub const SYSTEM_METERS: Text = Text::new("Windows-Pegel", "Windows level"); +pub const SYSTEM_MUTE: Text = Text::new("Windows stumm", "Windows mute"); pub const COMPARE_WITH: Text = Text::new("Vergleichen mit:", "Compare with:"); pub const CROSSOVER_WARNING: Text = Text::new( "Das ist eine Weiche: ihr Tief- bzw. Hochpass entfällt, der Ausgang spielt ungefiltert.", @@ -689,6 +700,11 @@ pub fn trend_hover(minutes_ago: u64, value: &str) -> String { ) } +/// The Windows volume under its fader: "52 %". +pub fn percent(value: f64) -> String { + format!("{} %", value.round()) +} + /// An EQ preset in the load dialog: its name and band count. pub fn eq_preset_entry(name: &str, bands: usize) -> String { pick( diff --git a/crates/app/src/views/group_strip.rs b/crates/app/src/views/group_strip.rs index f1b57df..8104a9c 100644 --- a/crates/app/src/views/group_strip.rs +++ b/crates/app/src/views/group_strip.rs @@ -96,7 +96,7 @@ pub(super) fn shared_readout(level: Option<&SharedLevel>) -> String { .unwrap_or_default() } -fn frame(ui: &mut Ui, color: Color32, contents: impl FnOnce(&mut Ui)) { +pub(super) fn frame(ui: &mut Ui, color: Color32, contents: impl FnOnce(&mut Ui)) { let tokens = Tokens::current(ui.ctx()); let inner_width = STRIP_WIDTH - 2.0 * (f32::from(STRIP_MARGIN) + BORDER_WIDTH); egui::Frame::new() diff --git a/crates/app/src/views/mod.rs b/crates/app/src/views/mod.rs index d7fb8cf..54db566 100644 --- a/crates/app/src/views/mod.rs +++ b/crates/app/src/views/mod.rs @@ -19,6 +19,7 @@ pub mod remove_speaker; pub mod scan_choice; pub mod settings_dialog; pub mod strip; +pub mod system_strip; pub mod toolbar; pub mod trends; pub mod wheel; diff --git a/crates/app/src/views/settings_dialog.rs b/crates/app/src/views/settings_dialog.rs index 8d54f97..2b47990 100644 --- a/crates/app/src/views/settings_dialog.rs +++ b/crates/app/src/views/settings_dialog.rs @@ -1,14 +1,18 @@ //! The settings dialog behind the toolbar's "Einstellungen": theme, language, meter range and //! response times. Every change applies at once; the app saves it. +use eframe::egui::ComboBox; use eframe::egui::{Id, Modal, Ui}; use neumann_control_core::settings::{ - CrossoverGuard, DeskSettings, LanguageChoice, MeterFloor, ThemeChoice, + CrossoverGuard, DeskSettings, LanguageChoice, MeterFloor, SystemAudioSettings, ThemeChoice, }; +use crate::system_audio::AudioDevice; + use crate::texts; const MODAL_ID: &str = "settings_dialog"; +const SYSTEM_DEVICE_ID: &str = "system_audio_device"; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SettingsAction { @@ -17,16 +21,28 @@ pub enum SettingsAction { Close, } -pub fn show(ui: &mut Ui, desk: &mut DeskSettings) -> SettingsAction { - let modal = Modal::new(Id::new(MODAL_ID)).show(ui.ctx(), |ui| contents(ui, desk)); +pub fn show( + ui: &mut Ui, + desk: &mut DeskSettings, + audio: &mut SystemAudioSettings, + devices: &[AudioDevice], +) -> SettingsAction { + let modal = + Modal::new(Id::new(MODAL_ID)).show(ui.ctx(), |ui| contents(ui, desk, audio, devices)); if modal.should_close() { return SettingsAction::Close; } modal.inner } -fn contents(ui: &mut Ui, desk: &mut DeskSettings) -> SettingsAction { +fn contents( + ui: &mut Ui, + desk: &mut DeskSettings, + audio: &mut SystemAudioSettings, + devices: &[AudioDevice], +) -> SettingsAction { let before = *desk; + let audio_before = audio.clone(); ui.heading(texts::SETTINGS_TITLE); ui.label(texts::THEME); ui.horizontal(|ui| { @@ -59,12 +75,42 @@ fn contents(ui: &mut Ui, desk: &mut DeskSettings) -> SettingsAction { CrossoverGuard::Open }; } + system_audio(ui, audio, devices); ui.separator(); if ui.button(texts::CLOSE).clicked() { return SettingsAction::Close; } - if *desk != before { + if *desk != before || *audio != audio_before { return SettingsAction::Changed; } SettingsAction::None } + +/// Whether the desk shows the Windows volume, and of which playback device. +fn system_audio(ui: &mut Ui, audio: &mut SystemAudioSettings, devices: &[AudioDevice]) { + ui.checkbox(&mut audio.shown, texts::SYSTEM_AUDIO_SHOW); + if !audio.shown { + return; + } + let chosen = match &audio.device { + None => texts::SYSTEM_AUDIO_DEFAULT.get().to_owned(), + Some(id) => devices.iter().find(|device| device.id == *id).map_or_else( + || texts::SYSTEM_AUDIO_UNAVAILABLE.get().to_owned(), + |device| device.name.clone(), + ), + }; + ui.horizontal(|ui| { + let label = ui.label(texts::SYSTEM_AUDIO_DEVICE); + ComboBox::from_id_salt(SYSTEM_DEVICE_ID) + .selected_text(chosen) + .show_ui(ui, |ui| { + ui.selectable_value(&mut audio.device, None, texts::SYSTEM_AUDIO_DEFAULT); + for device in devices { + let id = Some(device.id.clone()); + ui.selectable_value(&mut audio.device, id, &device.name); + } + }) + .response + .labelled_by(label.id); + }); +} diff --git a/crates/app/src/views/system_strip.rs b/crates/app/src/views/system_strip.rs new file mode 100644 index 0000000..bafec43 --- /dev/null +++ b/crates/app/src/views/system_strip.rs @@ -0,0 +1,77 @@ +//! The strip with Windows' own volume of a playback device: its name, a fader in percent, mute +//! and a peak meter per channel. It only reports gestures; the app talks to Windows. + +use std::ops::RangeInclusive; + +use eframe::egui::{self, Align, Layout, RichText, Ui}; + +use super::fader::{self, Orientation, FADER_HEIGHT}; +use super::group_strip::{frame, mute_button}; +use super::meters::{self, Meters}; +use crate::groups::SharedLevel; +use crate::meter::MeterState; +use crate::texts; +use crate::ui_kit::theme::Tokens; + +const PERCENT_RANGE: RangeInclusive = 0.0..=100.0; +/// Windows' order of speaker positions; a device with more channels shows the rest unnamed. +const CHANNEL_NAMES: [&str; 8] = ["L", "R", "C", "LFE", "SL", "SR", "BL", "BR"]; + +pub struct SystemStrip<'a> { + pub name: &'a str, + /// Percent; `None` while the device cannot be read. + pub volume: Option, + pub mute: Option, + pub meters: &'a [MeterState], + pub floor: f64, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct SystemStripResponse { + /// The volume to set, in percent. + pub volume: Option, + pub mute: Option, +} + +pub fn show(ui: &mut Ui, strip: &SystemStrip) -> SystemStripResponse { + let tokens = Tokens::current(ui.ctx()); + let mut response = SystemStripResponse::default(); + frame(ui, tokens.border, |ui| { + ui.add(egui::Label::new(RichText::new(strip.name).strong()).truncate()); + let Some(volume) = strip.volume else { + ui.weak(texts::SYSTEM_AUDIO_UNAVAILABLE); + return; + }; + ui.with_layout(Layout::left_to_right(Align::Min), |ui| { + let level = SharedLevel { + reference: volume, + reach: PERCENT_RANGE, + }; + let label = texts::SYSTEM_VOLUME.get(); + let moved = fader::shared( + ui, + label, + Some(&level), + PERCENT_RANGE, + Orientation::Vertical, + ); + response.volume = moved.map(|moved| (volume + moved).clamp(0.0, 100.0)); + let channels: Vec<&str> = (0..strip.meters.len()) + .map(|channel| CHANNEL_NAMES.get(channel).copied().unwrap_or_default()) + .collect(); + let group = Meters { + name: texts::SYSTEM_METERS.get(), + channels: &channels, + levels: strip.meters, + clips: None, + played: &[], + }; + meters::show(ui, &group, FADER_HEIGHT, strip.floor, &tokens); + }); + ui.add(egui::Label::new( + RichText::new(texts::percent(volume)).monospace(), + )); + response.mute = mute_button(ui, texts::SYSTEM_MUTE.get(), strip.mute); + }); + response +} diff --git a/crates/app/tests/support/mod.rs b/crates/app/tests/support/mod.rs index 5141b08..5b4c99c 100644 --- a/crates/app/tests/support/mod.rs +++ b/crates/app/tests/support/mod.rs @@ -6,7 +6,7 @@ use std::path::{Path, PathBuf}; use std::sync::mpsc::{self, Receiver}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; @@ -15,6 +15,7 @@ use egui_kittest::kittest::Queryable; use egui_kittest::Harness; use neumann_control_app::app::NeumannApp; use neumann_control_app::spawner::{ScanResult, SpeakerSpawner}; +use neumann_control_app::system_audio::{AudioDevice, AudioState, SystemAudio}; use neumann_control_app::texts; use neumann_control_core::catalog::Catalog; use neumann_control_core::discovery::Found; @@ -92,6 +93,23 @@ pub fn harness_in( settings_path: PathBuf, scan_result: ScanResult, system: Language, +) -> Harness<'static, NeumannApp> { + build(settings_path, scan_result, system, FakeAudio::default()) +} + +/// The app with `audio` standing in for Windows' playback devices. +pub fn harness_with_audio( + settings_path: PathBuf, + audio: FakeAudio, +) -> Harness<'static, NeumannApp> { + build(settings_path, Ok(Vec::new()), Language::German, audio) +} + +fn build( + settings_path: PathBuf, + scan_result: ScanResult, + system: Language, + audio: FakeAudio, ) -> Harness<'static, NeumannApp> { Harness::builder() .with_size(WINDOW_SIZE) @@ -100,11 +118,55 @@ pub fn harness_in( &creation_context.egui_ctx, settings_path, Box::new(SimulatorSpawner { scan_result }), + Box::new(audio), system, ) }) } +/// What the fake playback devices hold and what the app asked of them. +#[derive(Default)] +pub struct FakeAudioState { + pub devices: Vec, + /// The device's state; `None`: gone. + pub state: Option, + pub volume_sets: Vec, + pub mute_sets: Vec, + /// The device each read asked for. + pub asked: Vec>, +} + +#[derive(Clone, Default)] +pub struct FakeAudio(pub Arc>); + +impl SystemAudio for FakeAudio { + fn devices(&mut self) -> Vec { + self.0.lock().unwrap().devices.clone() + } + + fn state(&mut self, device: Option<&str>) -> Option { + let mut held = self.0.lock().unwrap(); + held.asked.push(device.map(str::to_owned)); + held.state.clone() + } + + fn set_volume(&mut self, _device: Option<&str>, volume: f32) { + let mut held = self.0.lock().unwrap(); + held.volume_sets.push(volume); + if let Some(state) = &mut held.state { + state.volume = volume; + } + } + + fn set_mute(&mut self, _device: Option<&str>, mute: bool) { + let mut held = self.0.lock().unwrap(); + held.mute_sets.push(mute); + if let Some(state) = &mut held.state { + state.mute = mute; + } + } +} + pub fn wait_until( harness: &mut Harness<'static, NeumannApp>, what: &str, diff --git a/crates/app/tests/system_audio.rs b/crates/app/tests/system_audio.rs new file mode 100644 index 0000000..feac9dc --- /dev/null +++ b/crates/app/tests/system_audio.rs @@ -0,0 +1,142 @@ +//! The Windows volume strip, with a fake standing in for Windows' playback devices: shown only +//! when chosen, it reads name, volume, mute and peaks and hands mute and fader moves to Windows. + +mod support; + +use eframe::egui::{self, accesskit::Role}; +use egui_kittest::kittest::{NodeT, Queryable}; +use egui_kittest::Harness; +use neumann_control_app::app::NeumannApp; +use neumann_control_app::system_audio::{AudioDevice, AudioState}; +use neumann_control_app::texts; +use neumann_control_core::settings::{self, SystemAudioSettings}; +use neumann_control_core::simulator::{SimModel, Simulator}; +use support::*; + +const DEVICE_NAME: &str = "Studio monitors"; +const DEVICE_ID: &str = "{0.0.0.00000000}.{fake}"; + +fn fake(state: Option) -> FakeAudio { + let audio = FakeAudio::default(); + { + let mut held = audio.0.lock().unwrap(); + held.devices = vec![AudioDevice { + id: DEVICE_ID.to_owned(), + name: DEVICE_NAME.to_owned(), + }]; + held.state = state; + } + audio +} + +fn playing() -> AudioState { + AudioState { + name: DEVICE_NAME.to_owned(), + volume: 0.5, + mute: false, + peaks: vec![0.5, 0.25], + } +} + +/// A connected KH 120 II, the strip shown or not, and the fake behind it. +fn desk_with( + shown: bool, + audio: FakeAudio, +) -> (Simulator, tempfile::TempDir, Harness<'static, NeumannApp>) { + let simulator = Simulator::start(SimModel::Kh120Ii); + simulator.set_reply_delay(REPLY_DELAY); + let directory = tempfile::tempdir().unwrap(); + let path = settings_file(directory.path(), vec![manual_for(&simulator)]); + let mut stored = settings::load(&path); + stored.system_audio = SystemAudioSettings { + shown, + device: None, + }; + settings::save(&stored, &path).unwrap(); + let mut harness = harness_with_audio(path, audio); + harness.set_size(egui::vec2(1200.0, 900.0)); + wait_for_level(&mut harness, 100.0); + (simulator, directory, harness) +} + +#[test] +fn the_strip_is_off_until_chosen() { + let audio = fake(Some(playing())); + let (_simulator, _directory, harness) = desk_with(false, audio.clone()); + assert!(harness.query_by_label(DEVICE_NAME).is_none()); + assert!(audio.0.lock().unwrap().asked.is_empty()); +} + +#[test] +fn the_strip_shows_the_default_device() { + let audio = fake(Some(playing())); + let (_simulator, _directory, mut harness) = desk_with(true, audio.clone()); + harness.run_steps(2); + harness.get_by_label(DEVICE_NAME); + harness.get_by_label(&texts::percent(50.0)); + assert_eq!(audio.0.lock().unwrap().asked.last(), Some(&None)); +} + +#[test] +fn mute_goes_to_windows() { + let audio = fake(Some(playing())); + let (_simulator, _directory, mut harness) = desk_with(true, audio.clone()); + harness.run_steps(2); + harness + .get_by_role_and_label(Role::Button, texts::SYSTEM_MUTE.get()) + .click(); + harness.run_steps(2); + assert_eq!(audio.0.lock().unwrap().mute_sets, vec![true]); +} + +#[test] +fn a_fader_move_sets_the_windows_volume() { + let audio = fake(Some(playing())); + let (_simulator, _directory, mut harness) = desk_with(true, audio.clone()); + harness.run_steps(2); + let fader = harness + .get_by_role_and_label(Role::Slider, texts::SYSTEM_VOLUME.get()) + .rect(); + let from = fader.center(); + harness.drag_at(from); + for frame in 1..=5 { + harness.hover_at(from + egui::vec2(0.0, -8.0 * frame as f32)); + harness.step(); + } + harness.drop_at(from + egui::vec2(0.0, -40.0)); + harness.run_steps(3); + let sets = audio.0.lock().unwrap().volume_sets.clone(); + assert_eq!(sets.len(), 1, "{sets:?}"); + assert!(sets[0] > 0.5, "{sets:?}"); +} + +#[test] +fn a_vanished_device_reads_unavailable() { + let audio = fake(None); + let (_simulator, _directory, mut harness) = desk_with(true, audio); + harness.run_steps(2); + harness.get_by_label(texts::SYSTEM_AUDIO_UNAVAILABLE.get()); + assert!(harness + .query_by_role_and_label(Role::Slider, texts::SYSTEM_VOLUME.get()) + .is_none()); +} + +#[test] +fn the_device_is_chosen_in_the_settings() { + let audio = fake(Some(playing())); + let (_simulator, directory, mut harness) = desk_with(true, audio); + harness.get_by_label(texts::SETTINGS.get()).click(); + harness.run_steps(3); + harness + .get_by_role_and_label(Role::ComboBox, texts::SYSTEM_AUDIO_DEVICE.get()) + .click(); + harness.run_steps(2); + harness + .query_all_by_label(DEVICE_NAME) + .find(|node| node.accesskit_node().role() != Role::Label) + .expect("the device in the list") + .click(); + harness.run_steps(2); + let stored = settings::load(&directory.path().join("settings.json")); + assert_eq!(stored.system_audio.device.as_deref(), Some(DEVICE_ID)); +} diff --git a/crates/core/src/settings.rs b/crates/core/src/settings.rs index 01d2f5c..c030176 100644 --- a/crates/core/src/settings.rs +++ b/crates/core/src/settings.rs @@ -44,6 +44,17 @@ pub struct Settings { pub presets: Vec, #[serde(deserialize_with = "readable_items")] pub eq_presets: Vec, + #[serde(deserialize_with = "or_default")] + pub system_audio: SystemAudioSettings, +} + +/// The strip with Windows' own volume of a playback device. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct SystemAudioSettings { + pub shown: bool, + /// The endpoint id Windows gives the device; `None`: Windows' default playback device. + pub device: Option, } /// Chosen settings of a speaker under a name, to write to it or another speaker later. diff --git a/crates/core/tests/settings.rs b/crates/core/tests/settings.rs index 71ad525..c9e7982 100644 --- a/crates/core/tests/settings.rs +++ b/crates/core/tests/settings.rs @@ -1,7 +1,7 @@ use neumann_control_core::settings::{ load, save, CrossoverGuard, DeskSettings, EqPreset, GroupSettings, Language, LanguageChoice, - Linkable, ManualSpeaker, MeterFloor, Settings, SettingsPreset, ThemeChoice, WindowGeometry, - SETTINGS_FILE, + Linkable, ManualSpeaker, MeterFloor, Settings, SettingsPreset, SystemAudioSettings, + ThemeChoice, WindowGeometry, SETTINGS_FILE, }; use std::fs; @@ -336,3 +336,22 @@ fn presets_round_trip_and_a_broken_one_is_left_out() { assert_eq!(loaded.presets.len(), 1); assert!(loaded.eq_presets.is_empty()); } + +#[test] +fn the_windows_volume_strip_is_off_unless_chosen() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(SETTINGS_FILE); + fs::write(&path, r#"{"manual":[]}"#).unwrap(); + assert_eq!(load(&path).system_audio, SystemAudioSettings::default()); + assert!(!load(&path).system_audio.shown); + fs::write( + &path, + r#"{"manual":[],"system_audio":{"shown":true,"device":"{0.0.0.00000000}.{id}"}}"#, + ) + .unwrap(); + let loaded = load(&path).system_audio; + assert!(loaded.shown); + assert_eq!(loaded.device.as_deref(), Some("{0.0.0.00000000}.{id}")); + fs::write(&path, r#"{"manual":[],"system_audio":7}"#).unwrap(); + assert_eq!(load(&path).system_audio, SystemAudioSettings::default()); +} From 3493b174a206bd05b1fca4bd1a9af073a7ac4ea6 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:42:41 +0200 Subject: [PATCH 156/460] Describe compare, presets, full scan and the Windows strip --- CHANGELOG.md | 9 +++++++++ README.md | 29 ++++++++++++++++++++++++++--- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86447cd..3b4483f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,15 @@ device panel. - **Input mix** per output (input A and B, −100 to 0 dB) and **Mute persist** in the device panel; the KH 750 also shows the position of its AUTO STANDBY switch. +- **Compare with** in the EQ window: the EQ sum of other speakers on the same output as a dashed + line. +- Settings presets (chosen settings under a name) and EQ presets (all bands of an EQ), each loaded + with a preview as one history entry. +- **Full scan** in the device panel: a report of everything a speaker answers, without its + identifiers, next to the executable. +- A strip with the Windows volume of a chosen playback device, with mute and peak meters. +- **Protect crossovers** in the settings (on by default); off, "Make flat" and EQ presets work on + the KH 750 crossovers too, with a warning. ### Changed diff --git a/README.md b/README.md index 7f36f24..63358c1 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,8 @@ panel of one KH 750 open. The interface is shown in German.* group. - **EQ** in a window of its own: a computed response graph and a table of the bands. - **History and undo**: every write of the session, each one revertible, Ctrl+Z for the newest. +- **Presets** of chosen settings and of EQs, and a comparison of EQ curves between speakers. +- **Windows volume** of a playback device as a strip on the desk. - **Compact mode** (Ctrl+K): one row per speaker or group in a small window. - **German and English** interface, light and dark theme. - **Portable**: one executable, settings and log next to it, no installer. @@ -151,7 +153,14 @@ on output B and both on its own woofer. **Mute persist** is the speaker's own se name; what it does has not been measured. **All values…** lists every value the speaker reports in its own schema, with a path -filter and copy buttons, including paths the catalog does not know yet. +filter and copy buttons, including paths the catalog does not know yet. **Full scan** reads the +same and writes it next to the executable as `scan---.json` and `.txt`, +with what the app knows about each path; serial number, MAC address, the speaker's name and any +network address are left out. It is meant for collecting what other models report. + +**Save as preset…** keeps chosen settings of the speaker under a name (all writable settings are +offered, the name unchecked). **Load preset…** shows what a preset would change on this speaker and +writes it as one history entry; settings this model may not write are listed as skipped. **Trend (2 h)** below the settings shows what the app read in this session, at most the last two hours: the temperature as a line (KH 750) with its lowest and highest value, and the warnings as a @@ -215,6 +224,15 @@ tab: "EQ 2", "EQ 3", and on the KH 750 the crossovers of input A and input B ("C - **Undo** in the window takes back the speaker's newest EQ change, also with Ctrl+Z while the window is in front; the line beside it says which change that is. When the values changed since, the window asks first. +- **Compare with** lists the other speakers with the same output; a checked one shows the sum of + its EQs there as a dashed line. +- **Save as EQ preset…** keeps all bands of the chosen EQ under a name; **Load EQ preset…** offers + the presets with the same number of bands, shows which bands change and writes them as one + history entry. Writing several settings of a band one after another can pass a short level + jump. +- The crossovers are protected by default: no **Make flat** and no EQ presets there. Switching off + **Protect crossovers** in **Settings** allows both, with a warning that the output then plays + unfiltered. ### History and undo @@ -236,7 +254,11 @@ recorded. - the language: like Windows, German or English. It changes at once. German shows a decimal comma; typed values take a comma or a point. - the lowest level the meters show: −120, −90 or −60 dB, -- whether response times are shown. +- whether response times are shown, +- whether the EQ crossovers are protected (on by default), +- whether the desk shows a strip with the Windows volume, and of which playback device (Windows' + default device or a chosen one): a fader in percent, mute and a peak meter per channel. It + sets Windows' own volume, like the volume slider in the taskbar. ### Control mode @@ -280,7 +302,8 @@ The app is portable and writes only next to the executable: | File | Content | |------|---------| -| `settings.json` | Remembered speakers (address, port, serial), groups and the strip order, the window position and size for the desk and the compact mode, and the choices from **Settings** | +| `settings.json` | Remembered speakers (address, port, serial), groups and the strip order, the window position and size for the desk and the compact mode, the choices from **Settings**, and the presets | +| `scan-*.json`, `scan-*.txt` | Written by **Full scan**, without serial number, MAC address, name or network addresses | | `neumann-control.log` | Log of the current run, replaced at each start | Speaker addresses and serial numbers are masked in the log and in the interface. An unreadable or From eb25dec14e76cf5bbec6adb55a897727463c4b5f Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:57:51 +0200 Subject: [PATCH 157/460] Show a value that rounds to zero without a minus --- crates/app/src/texts.rs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 5631cfb..1de8c10 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -41,6 +41,16 @@ fn pick(german: T, english: T) -> T { /// A number as written in the language: a decimal comma in German, a point in English. Typed /// values accept both (`gesture::typed_value`). pub fn number_text(text: String) -> String { + // A value that shows as zero has no sign: the speakers report -0.0, and -0.04 rounds to "-0.0". + let shows_zero = text.strip_prefix('-').is_some_and(|rest| { + rest.chars() + .all(|character| character == '0' || character == '.') + }); + let text = if shows_zero { + text[1..].to_owned() + } else { + text + }; match language() { Language::German => text.replace('.', ","), Language::English => text, @@ -702,7 +712,7 @@ pub fn trend_hover(minutes_ago: u64, value: &str) -> String { /// The Windows volume under its fader: "52 %". pub fn percent(value: f64) -> String { - format!("{} %", value.round()) + format!("{} %", number_text(value.round().to_string())) } /// An EQ preset in the load dialog: its name and band count. @@ -765,6 +775,17 @@ mod tests { ); } + #[test] + fn a_value_that_shows_as_zero_has_no_minus() { + let plain = |text: String| text.replace(',', "."); + assert_eq!(number(-0.0), "0"); + assert_eq!(plain(one_decimal(-0.04)), "0.0"); + assert_eq!(plain(two_decimals(-0.001)), "0.00"); + assert_eq!(percent(-0.2), "0 %"); + assert_eq!(plain(one_decimal(-0.06)), "-0.1"); + assert_eq!(number(-2.5).replace(',', "."), "-2.5"); + } + #[test] fn an_unknown_value_before_reads_as_a_question_mark() { assert_eq!(history_before(None, Some("dB")), "?"); From a7eb9575c721beef51b908f10ba7bfc7529eb251 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:00:57 +0200 Subject: [PATCH 158/460] Hide bands that are off and draw the chosen band alone --- crates/app/src/app/eq.rs | 6 ++++- crates/app/src/eq_response.rs | 23 +++++++++++++++++ crates/app/src/views/eq_graph.rs | 20 ++++++++++++++- crates/app/tests/eq.rs | 44 ++++++++++++++++++++++++-------- 4 files changed, 81 insertions(+), 12 deletions(-) diff --git a/crates/app/src/app/eq.rs b/crates/app/src/app/eq.rs index f9bde8b..871101c 100644 --- a/crates/app/src/app/eq.rs +++ b/crates/app/src/app/eq.rs @@ -9,7 +9,7 @@ use serde_json::Value; use super::NeumannApp; use crate::eq::{bands_of, container_of, flat, has_eq, one_band, Band, Leaf, FLATTENING_LEAVES}; -use crate::eq_response::{curve_db, graph_frequencies}; +use crate::eq_response::{band_curve, curve_db, graph_frequencies}; use crate::history::Entry; use crate::paths; use crate::presets::{capture_eq, eq_plan, fitting}; @@ -208,12 +208,16 @@ impl NeumannApp { }); let id = Id::new(EQ_VIEWPORT_ID).with(&window.key); let color = selected.map_or(Color32::GRAY, |shown| shown.color); + let band_alone = bands + .as_deref() + .and_then(|bands| band_curve(bands, window.band, &frequencies)); let graph = Graph { id: id.with(EQ_GRAPH_ID).with(chosen), frequencies: &frequencies, curves: &curves, sum: &sum, compared: &compared_curves, + band_curve: band_alone.as_deref(), points: &points, color, selected: window.band, diff --git a/crates/app/src/eq_response.rs b/crates/app/src/eq_response.rs index 999ff40..dc6338c 100644 --- a/crates/app/src/eq_response.rs +++ b/crates/app/src/eq_response.rs @@ -86,6 +86,13 @@ pub fn curve_db(bands: &[Band], frequencies: &[f64]) -> Vec { .collect() } +/// The chosen band on its own, to show what it does apart from the others; `None` when it is +/// off or not there. +pub fn band_curve(bands: &[Band], chosen: usize, frequencies: &[f64]) -> Option> { + let band = bands.get(chosen).filter(|band| band.enabled)?; + Some(curve_db(std::slice::from_ref(band), frequencies)) +} + /// `GRAPH_POINTS` frequencies spaced evenly on a log scale from the lowest to the highest. pub fn graph_frequencies() -> Vec { let (low, high) = (FREQUENCY_MIN.ln(), FREQUENCY_MAX.ln()); @@ -117,6 +124,22 @@ mod tests { assert!((actual - expected).abs() < 0.05, "{actual} vs {expected}"); } + #[test] + fn the_chosen_band_alone_reaches_its_own_height() { + let bands = [ + band("PARAMETRIC", -15.0, 21.0, 0.7), + band("PARAMETRIC", 10.5, 51.9, 1.4), + ]; + let frequencies = [51.9]; + let own = band_curve(&bands, 1, &frequencies).unwrap(); + close(own[0], 10.5); + assert!(curve_db(&bands, &frequencies)[0] < 10.0); + let mut off = bands.clone(); + off[1].enabled = false; + assert!(band_curve(&off, 1, &frequencies).is_none()); + assert!(band_curve(&bands, 7, &frequencies).is_none()); + } + #[test] fn peaking_reaches_its_gain_at_its_frequency() { close(band_db(&band("PARAMETRIC", 6.0, 1000.0, 1.0), 1000.0), 6.0); diff --git a/crates/app/src/views/eq_graph.rs b/crates/app/src/views/eq_graph.rs index fbddfa2..d3dca72 100644 --- a/crates/app/src/views/eq_graph.rs +++ b/crates/app/src/views/eq_graph.rs @@ -27,6 +27,8 @@ const LABEL_SIZE: f32 = 9.0; const CURVE_WIDTH: f32 = 1.5; const SUM_WIDTH: f32 = 2.5; const DASH_LENGTH: f32 = 6.0; +const BAND_CURVE_WIDTH: f32 = 1.0; +const BAND_CURVE_OPACITY: f32 = 0.6; const DASH_GAP: f32 = 4.0; const POINT_RADIUS: f32 = 5.0; const SELECTED_RING: f32 = 3.0; @@ -60,6 +62,8 @@ pub struct Graph<'a> { pub curves: &'a [GraphCurve<'a>], pub sum: &'a [f64], pub compared: &'a [ComparedCurve<'a>], + /// The chosen band on its own, drawn thin in the container's colour. + pub band_curve: Option<&'a [f64]>, pub points: &'a [GraphPoint], pub color: Color32, pub selected: usize, @@ -136,6 +140,13 @@ pub fn show(ui: &mut Ui, graph: &Graph) -> Vec { let stroke = Stroke::new(CURVE_WIDTH, curve.color); paint_dashed(ui, &axes, graph.frequencies, curve.db, stroke); } + if let Some(db) = graph.band_curve { + let stroke = Stroke::new( + BAND_CURVE_WIDTH, + graph.color.gamma_multiply(BAND_CURVE_OPACITY), + ); + paint_curve(ui, &axes, graph.frequencies, db, stroke); + } paint_curve( ui, &axes, @@ -144,7 +155,13 @@ pub fn show(ui: &mut Ui, graph: &Graph) -> Vec { Stroke::new(SUM_WIDTH, tokens.text), ); let mut actions = Vec::new(); - for (band, point) in graph.points.iter().enumerate() { + // A band that is off changes nothing; it is switched on in the table. + for (band, point) in graph + .points + .iter() + .enumerate() + .filter(|(_, point)| point.enabled) + { actions.extend(band_point(ui, graph, &axes, band, point)); } actions @@ -161,6 +178,7 @@ fn db_span(graph: &Graph) -> f64 { let points = graph .points .iter() + .filter(|point| point.enabled) .map(|point| (point.gain + point.boost).abs()); let largest = boosts.chain(points).fold(0.0, f64::max); ((largest / DB_GRID).ceil() * DB_GRID).max(DB_SPAN_MIN) diff --git a/crates/app/tests/eq.rs b/crates/app/tests/eq.rs index 69706a8..24b124a 100644 --- a/crates/app/tests/eq.rs +++ b/crates/app/tests/eq.rs @@ -25,19 +25,32 @@ const FREQUENCY_POINTER: &str = "/audio/out/eq2/frequency"; fn spread(simulator: &Simulator) { let frequencies: Vec = (0..10).map(|band| 50.0 * 2f64.powi(band)).collect(); simulator.set_value("audio/out/eq2/frequency", json!(frequencies)); + // Only bands that are on have a point to drag. + simulator.set_value("audio/out/eq2/enabled", json!(vec![true; 10])); } /// Opened from the strip: the KH 750's panel is taller than the test window, so its EQ button /// lies outside it. fn open_eq(model: SimModel) -> (Simulator, tempfile::TempDir, Harness<'static, NeumannApp>) { - let (simulator, directory, mut harness) = connected_speaker_with(model, spread); + open_eq_with(model, |_| {}) +} + +/// Like `open_eq`, with values set on the simulator (after the spread) before the first read. +fn open_eq_with( + model: SimModel, + prepare: impl FnOnce(&Simulator), +) -> (Simulator, tempfile::TempDir, Harness<'static, NeumannApp>) { + let (simulator, directory, mut harness) = connected_speaker_with(model, |simulator| { + spread(simulator); + prepare(simulator); + }); // The embedded window is cut off in the small window, its close button with it. harness.set_size(EXPANDED_WINDOW_SIZE.into()); harness.run_steps(2); harness .get_by_role_and_label(Role::Button, texts::EQ.get()) .click(); - wait_for_label(&mut harness, &texts::eq_band(1)); + wait_for_label(&mut harness, texts::EQ_COMPUTED.get()); (simulator, directory, harness) } @@ -72,7 +85,7 @@ fn the_panels_eq_button_opens_the_window_with_both_containers() { let (_simulator, _directory, mut harness) = connected_speaker_with(SimModel::Kh120Ii, spread); open_panel(&mut harness, device_name(SimModel::Kh120Ii)); click_panel_eq(&mut harness); - wait_for_label(&mut harness, &texts::eq_band(1)); + wait_for_label(&mut harness, texts::EQ_COMPUTED.get()); harness.get_by_label("EQ 2"); harness.get_by_label("EQ 3"); assert_eq!(gain_fields(&harness), vec![false; 10]); @@ -224,7 +237,7 @@ fn the_strip_opens_the_eq_window() { harness .get_by_role_and_label(Role::Button, texts::EQ.get()) .click(); - wait_for_label(&mut harness, &texts::eq_band(1)); + wait_for_label(&mut harness, texts::EQ_COMPUTED.get()); harness.get_by_label("Subwoofer"); } @@ -261,7 +274,7 @@ fn reset_asks_then_flattens_gain_and_boost() { harness .get_by_role_and_label(Role::Button, texts::EQ.get()) .click(); - wait_for_label(&mut harness, &texts::eq_band(1)); + wait_for_label(&mut harness, texts::EQ_COMPUTED.get()); harness .get_by_role_and_label(Role::Button, texts::EQ_RESET.get()) .click(); @@ -292,7 +305,7 @@ fn a_reset_can_be_cancelled() { harness .get_by_role_and_label(Role::Button, texts::EQ.get()) .click(); - wait_for_label(&mut harness, &texts::eq_band(1)); + wait_for_label(&mut harness, texts::EQ_COMPUTED.get()); harness .get_by_role_and_label(Role::Button, texts::EQ_RESET.get()) .click(); @@ -347,7 +360,7 @@ fn eq_change_then_mute(harness: &mut Harness<'static, NeumannApp>, simulator: &S harness .get_by_role_and_label(Role::Button, texts::EQ.get()) .click(); - wait_for_label(harness, &texts::eq_band(1)); + wait_for_label(harness, texts::EQ_COMPUTED.get()); } fn assert_band_2_written_back(simulator: &Simulator) { @@ -401,7 +414,7 @@ fn a_reset_reads_as_all_bands_in_the_history() { harness .get_by_role_and_label(Role::Button, texts::EQ.get()) .click(); - wait_for_label(&mut harness, &texts::eq_band(1)); + wait_for_label(&mut harness, texts::EQ_COMPUTED.get()); for _ in 0..2 { harness .get_by_role_and_label(Role::Button, texts::EQ_RESET.get()) @@ -443,7 +456,7 @@ fn open_eq_with_open_crossovers() -> (Simulator, tempfile::TempDir, Harness<'sta harness .get_by_role_and_label(Role::Button, texts::EQ.get()) .click(); - wait_for_label(&mut harness, &texts::eq_band(1)); + wait_for_label(&mut harness, texts::EQ_COMPUTED.get()); (simulator, directory, harness) } @@ -485,7 +498,7 @@ fn open_eq_of(harness: &mut Harness<'static, NeumannApp>, name: &str) { }) .expect("an EQ button"); button.click(); - wait_for_label(harness, &texts::eq_band(1)); + wait_for_label(harness, texts::EQ_COMPUTED.get()); } #[test] @@ -600,3 +613,14 @@ fn with_open_crossovers_loading_an_eq_preset_warns() { harness.run_steps(3); harness.get_by_label_contains(texts::CROSSOVER_WARNING.get()); } + +#[test] +fn a_switched_off_band_has_no_point_in_the_graph() { + let (_simulator, _directory, harness) = open_eq_with(SimModel::Kh120Ii, |simulator| { + let mut enabled = vec![true; 10]; + enabled[2] = false; + simulator.set_value("audio/out/eq2/enabled", serde_json::json!(enabled)); + }); + harness.get_by_label(&texts::eq_band(2)); + assert!(harness.query_by_label(&texts::eq_band(3)).is_none()); +} From e5e1d1c281c477600d4dd9e15fee3f85fe4348c3 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:00:57 +0200 Subject: [PATCH 159/460] Describe the band line and hidden bands --- CHANGELOG.md | 1 + README.md | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b4483f..0044d09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ ### Fixed +- A value that rounds to zero showed a minus ("-0 dB"). - Number fields with a converted unit showed the device's unit as well ("0 1/48 kHz ms"). ## [0.1.0] - 2026-10-18 diff --git a/README.md b/README.md index 63358c1..0c2de47 100644 --- a/README.md +++ b/README.md @@ -211,9 +211,11 @@ tab: "EQ 2", "EQ 3", and on the KH 750 the crossovers of input A and input B ("C 10 Hz to 24 kHz. The curves are computed from the settings, not measured. Each band has two levels: **Level** lifts or lowers every frequency while the band is on, **Boost** is the height of its filter (the peak or shelf); at the band's frequency both add up. -- Each band of the chosen EQ is a point: filled when on, hollow when off. Drag it to change - frequency and boost, turn the mouse wheel over it to change Q. Level is changed in the table - only, so a drag never moves the whole spectrum. +- Each band of the chosen EQ that is on is a point; bands that are off have none and are switched + on in the table. Drag a point to change frequency and boost, turn the mouse wheel over it to + change Q. Level is changed in the table only, so a drag never moves the whole spectrum. +- A point stands at its band's own height; the curves add every band up, so a point can lie + above or below them. The chosen band alone is drawn as a thin line in its EQ's colour. - The table below lists every band with on/off, type, frequency, Q, level and boost; numbers take a comma or a point, and a value outside the allowed range is refused with the reason. - A change is written when the gesture ends, and only for that one band, so the other bands From 46d4abd5bdd5fbe97441d7723f32a78b1e0d021e Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:32:55 +0200 Subject: [PATCH 160/460] Colour each EQ band and let the colours be chosen --- crates/app/src/app.rs | 23 +++- crates/app/src/app/eq.rs | 26 ++-- crates/app/src/eq_response.rs | 23 ++-- crates/app/src/texts.rs | 9 ++ crates/app/src/ui_kit/eq_palette.rs | 161 ++++++++++++++++++++++++ crates/app/src/ui_kit/mod.rs | 1 + crates/app/src/views/eq_graph.rs | 120 +++++++++++++++--- crates/app/src/views/eq_table.rs | 9 +- crates/app/src/views/settings_dialog.rs | 101 ++++++++++++--- crates/app/tests/eq.rs | 36 ++++++ crates/core/src/settings.rs | 25 ++++ crates/core/tests/settings.rs | 37 +++++- 12 files changed, 510 insertions(+), 61 deletions(-) create mode 100644 crates/app/src/ui_kit/eq_palette.rs diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index a63f21a..3f46fc1 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -24,6 +24,7 @@ use crate::status_leds::average_response_time; use crate::system_audio::{AudioDevice, SystemAudio}; use crate::targets::{duplicate_speakers, follow_moves, forget, remember_serial, targets}; use crate::texts; +use crate::ui_kit::eq_palette::{curve_key, CURVE_KEYS}; use crate::ui_kit::{fonts, theme}; use crate::views::add_address::{self, AddAddressAction, AddAddressForm}; use crate::views::group_dialog::{self, GroupDialogAction}; @@ -533,8 +534,8 @@ impl NeumannApp { if !self.settings_open { return; } - let audio = &mut self.settings.system_audio; - match settings_dialog::show(ui, &mut self.settings.desk, audio, &self.audio_devices) { + let labels = curve_labels(); + match settings_dialog::show(ui, &mut self.settings, &self.audio_devices, &labels) { SettingsAction::Changed => { ui.ctx() .set_theme(theme::preference(self.settings.desk.theme)); @@ -645,6 +646,24 @@ impl eframe::App for NeumannApp { } } +/// The EQ curves the settings offer colours for, named as the EQ window's tabs name them. +fn curve_labels() -> Vec<(String, String)> { + let catalog = Catalog::embedded(); + CURVE_KEYS + .iter() + .filter_map(|key| { + let container = catalog + .eq + .iter() + .find(|container| curve_key(&container.path) == *key)?; + Some(( + key.to_string(), + container.label(texts::language()).to_owned(), + )) + }) + .collect() +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/app/src/app/eq.rs b/crates/app/src/app/eq.rs index 871101c..da00a9a 100644 --- a/crates/app/src/app/eq.rs +++ b/crates/app/src/app/eq.rs @@ -9,12 +9,13 @@ use serde_json::Value; use super::NeumannApp; use crate::eq::{bands_of, container_of, flat, has_eq, one_band, Band, Leaf, FLATTENING_LEAVES}; -use crate::eq_response::{band_curve, curve_db, graph_frequencies}; +use crate::eq_response::{band_curves, curve_db, graph_frequencies}; use crate::history::Entry; use crate::paths; use crate::presets::{capture_eq, eq_plan, fitting}; use crate::state::{SpeakerKey, SpeakerState, Status}; use crate::texts; +use crate::ui_kit::eq_palette::{band_color, curve_color, curve_key}; use crate::ui_kit::theme::group_color; use crate::views::eq_graph::{ComparedCurve, Graph, GraphAction, GraphCurve, GraphPoint}; use crate::views::eq_table::{Limits, Table, TableAction, TableRow}; @@ -115,11 +116,10 @@ impl NeumannApp { .map(|(_, containers)| containers.as_slice()) .unwrap_or_default() .iter() - .enumerate() - .map(|(place, container)| { + .map(|container| { let bands = bands_of(&speaker.values, &container.path); let curve = curve_db(bands.as_deref().unwrap_or_default(), &frequencies); - let color = group_color(place as u8); + let color = curve_color(&self.settings.eq_display, &curve_key(&container.path)); Shown { container, bands, @@ -180,10 +180,14 @@ impl NeumannApp { db: &shown.curve, }) .collect(); + let dark = context.theme() == eframe::egui::Theme::Dark; + let display = &self.settings.eq_display; let points: Vec = bands .iter() .flatten() - .map(|band| GraphPoint { + .enumerate() + .map(|(place, band)| GraphPoint { + color: band_color(display, place, dark), frequency: band.frequency, gain: band.gain, boost: band.boost, @@ -207,19 +211,20 @@ impl NeumannApp { .collect() }); let id = Id::new(EQ_VIEWPORT_ID).with(&window.key); - let color = selected.map_or(Color32::GRAY, |shown| shown.color); - let band_alone = bands + let own_curves = bands .as_deref() - .and_then(|bands| band_curve(bands, window.band, &frequencies)); + .map(|bands| band_curves(bands, &frequencies)) + .unwrap_or_default(); + let band_colors: Vec = points.iter().map(|point| point.color).collect(); let graph = Graph { id: id.with(EQ_GRAPH_ID).with(chosen), frequencies: &frequencies, curves: &curves, sum: &sum, compared: &compared_curves, - band_curve: band_alone.as_deref(), + band_curves: &own_curves, + band_curve_mode: display.band_curves, points: &points, - color, selected: window.band, editable, gain_range: limits.gain.clone(), @@ -232,6 +237,7 @@ impl NeumannApp { headers: &headers, editable, selected: window.band, + colors: &band_colors, }); let output_names: Vec = outputs.iter().map(|(name, _)| name.clone()).collect(); let tabs: Vec<(String, Color32)> = shown diff --git a/crates/app/src/eq_response.rs b/crates/app/src/eq_response.rs index dc6338c..671b171 100644 --- a/crates/app/src/eq_response.rs +++ b/crates/app/src/eq_response.rs @@ -86,11 +86,16 @@ pub fn curve_db(bands: &[Band], frequencies: &[f64]) -> Vec { .collect() } -/// The chosen band on its own, to show what it does apart from the others; `None` when it is -/// off or not there. -pub fn band_curve(bands: &[Band], chosen: usize, frequencies: &[f64]) -> Option> { - let band = bands.get(chosen).filter(|band| band.enabled)?; - Some(curve_db(std::slice::from_ref(band), frequencies)) +/// Each band on its own, to show what it does apart from the others; `None` for a band that is +/// off. +pub fn band_curves(bands: &[Band], frequencies: &[f64]) -> Vec>> { + bands + .iter() + .map(|band| { + band.enabled + .then(|| curve_db(std::slice::from_ref(band), frequencies)) + }) + .collect() } /// `GRAPH_POINTS` frequencies spaced evenly on a log scale from the lowest to the highest. @@ -131,13 +136,13 @@ mod tests { band("PARAMETRIC", 10.5, 51.9, 1.4), ]; let frequencies = [51.9]; - let own = band_curve(&bands, 1, &frequencies).unwrap(); - close(own[0], 10.5); + let own = band_curves(&bands, &frequencies); + close(own[1].as_ref().unwrap()[0], 10.5); assert!(curve_db(&bands, &frequencies)[0] < 10.0); let mut off = bands.clone(); off[1].enabled = false; - assert!(band_curve(&off, 1, &frequencies).is_none()); - assert!(band_curve(&bands, 7, &frequencies).is_none()); + assert!(band_curves(&off, &frequencies)[1].is_none()); + assert_eq!(band_curves(&bands, &frequencies).len(), 2); } #[test] diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 1de8c10..cf5770e 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -200,6 +200,15 @@ pub const SYSTEM_AUDIO_NAME: Text = Text::new("Windows", "Windows"); pub const SYSTEM_VOLUME: Text = Text::new("Windows-Lautstärke", "Windows volume"); pub const SYSTEM_METERS: Text = Text::new("Windows-Pegel", "Windows level"); pub const SYSTEM_MUTE: Text = Text::new("Windows stumm", "Windows mute"); +pub const EQ_DISPLAY: Text = Text::new("EQ-Darstellung", "EQ display"); +pub const BAND_CURVES_ALL: Text = Text::new("Einzelkurven aller Bänder", "Curves of all bands"); +pub const BAND_CURVES_CHOSEN: Text = Text::new( + "Einzelkurve nur für das gewählte Band und das unter der Maus", + "Curve only for the chosen band and the one under the pointer", +); +pub const BAND_COLORS: Text = Text::new("Bandfarben", "Band colours"); +pub const CURVE_COLORS: Text = Text::new("Kurvenfarben", "Curve colours"); +pub const DEFAULT_COLORS: Text = Text::new("Standardfarben", "Default colours"); pub const COMPARE_WITH: Text = Text::new("Vergleichen mit:", "Compare with:"); pub const CROSSOVER_WARNING: Text = Text::new( "Das ist eine Weiche: ihr Tief- bzw. Hochpass entfällt, der Ausgang spielt ungefiltert.", diff --git a/crates/app/src/ui_kit/eq_palette.rs b/crates/app/src/ui_kit/eq_palette.rs new file mode 100644 index 0000000..23c8b31 --- /dev/null +++ b/crates/app/src/ui_kit/eq_palette.rs @@ -0,0 +1,161 @@ +//! The EQ graph's colours: one per band number and one per kind of container, each the +//! default unless the user chose another in the settings. + +use eframe::egui::Color32; +use neumann_control_core::settings::EqDisplaySettings; +use neumann_control_core::ssc::wire::SscPath; + +use super::theme::group_color; + +/// The categorical order of the reference palette, validated for neighbouring pairs in both +/// themes. A band beyond the eighth repeats it: the number in its point names the band, the +/// colour only helps. +const BANDS_LIGHT: [Color32; 8] = [ + Color32::from_rgb(0x2a, 0x78, 0xd6), + Color32::from_rgb(0xeb, 0x68, 0x34), + Color32::from_rgb(0x1b, 0xaf, 0x7a), + Color32::from_rgb(0xed, 0xa1, 0x00), + Color32::from_rgb(0xe8, 0x7b, 0xa4), + Color32::from_rgb(0x00, 0x83, 0x00), + Color32::from_rgb(0x62, 0x50, 0xd6), + Color32::from_rgb(0xe3, 0x49, 0x48), +]; +const BANDS_DARK: [Color32; 8] = [ + Color32::from_rgb(0x39, 0x87, 0xe5), + Color32::from_rgb(0xd9, 0x59, 0x26), + Color32::from_rgb(0x19, 0x9e, 0x70), + Color32::from_rgb(0xc9, 0x85, 0x00), + Color32::from_rgb(0xd5, 0x51, 0x81), + Color32::from_rgb(0x00, 0x83, 0x00), + Color32::from_rgb(0x90, 0x85, 0xe9), + Color32::from_rgb(0xe6, 0x67, 0x67), +]; + +/// The containers below an output, in the order their curves had their colours so far. +pub const CURVE_KEYS: [&str; 4] = ["eq2", "eq3", "eq1/in1", "eq1/in2"]; +/// The most bands a container has (KH 120 II `eq3`); the settings offer this many colours. +pub const BAND_COLOR_COUNT: usize = 20; + +/// Text on a point of `color`: dark ink on a light fill, light ink on a dark one. +const DARK_INK: Color32 = Color32::from_rgb(0x1a, 0x1a, 0x19); +const LIGHT_INK: Color32 = Color32::from_rgb(0xf0, 0xef, 0xec); +/// Relative luminance above which dark ink reads better than light ink. +const LIGHT_FILL_LUMINANCE: f32 = 0.18; + +pub fn default_band_color(band: usize, dark: bool) -> Color32 { + let palette = if dark { &BANDS_DARK } else { &BANDS_LIGHT }; + palette[band % palette.len()] +} + +/// `band` counts from 0; the settings count from 1, as the user does. +pub fn band_color(display: &EqDisplaySettings, band: usize, dark: bool) -> Color32 { + display + .band_colors + .get(&(band + 1).to_string()) + .and_then(|text| parse_hex(text)) + .unwrap_or_else(|| default_band_color(band, dark)) +} + +/// `eq2`, `eq3`, `eq1/in1` from `audio/out1/eq1/in1`: the container below its output. +pub fn curve_key(container: &SscPath) -> String { + container.segments().get(2..).unwrap_or_default().join("/") +} + +pub fn default_curve_color(key: &str) -> Color32 { + let place = CURVE_KEYS + .iter() + .position(|known| *known == key) + .unwrap_or_default(); + group_color(place as u8) +} + +pub fn curve_color(display: &EqDisplaySettings, key: &str) -> Color32 { + display + .curve_colors + .get(key) + .and_then(|text| parse_hex(text)) + .unwrap_or_else(|| default_curve_color(key)) +} + +/// `#rrggbb`; anything else is not a colour. +pub fn parse_hex(text: &str) -> Option { + let digits = text.strip_prefix('#')?; + if digits.len() != 6 || !digits.chars().all(|digit| digit.is_ascii_hexdigit()) { + return None; + } + let channel = |at: usize| u8::from_str_radix(&digits[at..at + 2], 16).ok(); + Some(Color32::from_rgb(channel(0)?, channel(2)?, channel(4)?)) +} + +pub fn hex(color: Color32) -> String { + format!("#{:02x}{:02x}{:02x}", color.r(), color.g(), color.b()) +} + +pub fn ink_on(color: Color32) -> Color32 { + let linear = |channel: u8| { + let value = f32::from(channel) / 255.0; + if value <= 0.04045 { + value / 12.92 + } else { + ((value + 0.055) / 1.055).powf(2.4) + } + }; + let luminance = + 0.2126 * linear(color.r()) + 0.7152 * linear(color.g()) + 0.0722 * linear(color.b()); + if luminance > LIGHT_FILL_LUMINANCE { + DARK_INK + } else { + LIGHT_INK + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bands_take_the_palette_in_order_and_repeat_after_eight() { + assert_eq!(default_band_color(0, true), BANDS_DARK[0]); + assert_eq!(default_band_color(7, false), BANDS_LIGHT[7]); + assert_eq!(default_band_color(8, true), default_band_color(0, true)); + } + + #[test] + fn a_chosen_colour_wins_and_nonsense_falls_back() { + let mut display = EqDisplaySettings::default(); + display + .band_colors + .insert("3".to_owned(), "#ff0000".to_owned()); + display.band_colors.insert("4".to_owned(), "red".to_owned()); + assert_eq!(band_color(&display, 2, true), Color32::from_rgb(255, 0, 0)); + assert_eq!(band_color(&display, 3, true), default_band_color(3, true)); + display + .curve_colors + .insert("eq3".to_owned(), "#00ff00".to_owned()); + assert_eq!(curve_color(&display, "eq3"), Color32::from_rgb(0, 255, 0)); + assert_eq!(curve_color(&display, "eq2"), group_color(0)); + } + + #[test] + fn hex_reads_back() { + let color = Color32::from_rgb(0x2a, 0x78, 0xd6); + assert_eq!(parse_hex(&hex(color)), Some(color)); + assert_eq!(parse_hex("#12345"), None); + assert_eq!(parse_hex("#gg0000"), None); + } + + #[test] + fn the_curve_key_is_the_container_below_its_output() { + let key = |text: &str| curve_key(&SscPath::parse(text).unwrap()); + assert_eq!(key("audio/out/eq2"), "eq2"); + assert_eq!(key("audio/out1/eq1/in2"), "eq1/in2"); + assert_eq!(default_curve_color("eq1/in1"), group_color(2)); + } + + #[test] + fn the_number_in_a_point_has_contrast() { + assert_eq!(ink_on(Color32::from_rgb(0xed, 0xa1, 0x00)), DARK_INK); + assert_eq!(ink_on(Color32::from_rgb(0x00, 0x83, 0x00)), LIGHT_INK); + assert_eq!(ink_on(Color32::from_rgb(0x62, 0x50, 0xd6)), LIGHT_INK); + } +} diff --git a/crates/app/src/ui_kit/mod.rs b/crates/app/src/ui_kit/mod.rs index c8362e5..dc39f36 100644 --- a/crates/app/src/ui_kit/mod.rs +++ b/crates/app/src/ui_kit/mod.rs @@ -1,2 +1,3 @@ +pub mod eq_palette; pub mod fonts; pub mod theme; diff --git a/crates/app/src/views/eq_graph.rs b/crates/app/src/views/eq_graph.rs index d3dca72..ae67a21 100644 --- a/crates/app/src/views/eq_graph.rs +++ b/crates/app/src/views/eq_graph.rs @@ -5,8 +5,8 @@ use std::ops::RangeInclusive; use eframe::egui::{ - pos2, vec2, Align2, Color32, CursorIcon, FontId, Id, Pos2, Rect, Sense, Shape, Stroke, Ui, - WidgetInfo, WidgetType, + pos2, vec2, Align2, Color32, CursorIcon, FontId, Id, Mesh, Pos2, Rect, Sense, Shape, Stroke, + Ui, WidgetInfo, WidgetType, }; use super::wheel::notches_for; @@ -14,7 +14,9 @@ use crate::eq::{snapped_frequency, GAIN_GRID_DB, Q_STEP}; use crate::eq_response::{FREQUENCY_MAX, FREQUENCY_MIN}; use crate::gesture::{on_grid, GestureState}; use crate::texts; +use crate::ui_kit::eq_palette::ink_on; use crate::ui_kit::theme::Tokens; +use neumann_control_core::settings::BandCurves; const GRAPH_HEIGHT: f32 = 300.0; /// The dB axis shows at least ±24 dB and grows in 6 dB steps to fit the curves. @@ -28,9 +30,14 @@ const CURVE_WIDTH: f32 = 1.5; const SUM_WIDTH: f32 = 2.5; const DASH_LENGTH: f32 = 6.0; const BAND_CURVE_WIDTH: f32 = 1.0; -const BAND_CURVE_OPACITY: f32 = 0.6; +const CHOSEN_CURVE_WIDTH: f32 = 1.5; +const BAND_CURVE_OPACITY: f32 = 0.65; +/// The chosen band's area between its curve and 0 dB. +const CHOSEN_AREA_OPACITY: f32 = 0.22; +const POINT_NUMBER_SIZE: f32 = 10.0; const DASH_GAP: f32 = 4.0; -const POINT_RADIUS: f32 = 5.0; +/// Big enough for a two-digit number inside. +const POINT_RADIUS: f32 = 7.0; const SELECTED_RING: f32 = 3.0; const POINT_GRAB: f32 = 14.0; const DRAG_ID: &str = "eq_point_drag"; @@ -49,6 +56,8 @@ pub struct ComparedCurve<'a> { #[derive(Clone, Copy, Debug)] pub struct GraphPoint { + /// The band's colour, for its point and its own curve. + pub color: Color32, pub frequency: f64, pub gain: f64, pub boost: f64, @@ -62,10 +71,10 @@ pub struct Graph<'a> { pub curves: &'a [GraphCurve<'a>], pub sum: &'a [f64], pub compared: &'a [ComparedCurve<'a>], - /// The chosen band on its own, drawn thin in the container's colour. - pub band_curve: Option<&'a [f64]>, + /// Each band on its own, `None` for a band that is off; drawn thin in the band's colour. + pub band_curves: &'a [Option>], + pub band_curve_mode: BandCurves, pub points: &'a [GraphPoint], - pub color: Color32, pub selected: usize, pub editable: bool, pub gain_range: RangeInclusive, @@ -140,13 +149,7 @@ pub fn show(ui: &mut Ui, graph: &Graph) -> Vec { let stroke = Stroke::new(CURVE_WIDTH, curve.color); paint_dashed(ui, &axes, graph.frequencies, curve.db, stroke); } - if let Some(db) = graph.band_curve { - let stroke = Stroke::new( - BAND_CURVE_WIDTH, - graph.color.gamma_multiply(BAND_CURVE_OPACITY), - ); - paint_curve(ui, &axes, graph.frequencies, db, stroke); - } + paint_band_curves(ui, graph, &axes); paint_curve( ui, &axes, @@ -346,12 +349,91 @@ fn paint_point(ui: &Ui, graph: &Graph, band: usize, point: &GraphPoint, center: painter.circle_stroke( center, POINT_RADIUS + SELECTED_RING, - Stroke::new(1.0, graph.color), + Stroke::new(1.0, point.color), ); } - if point.enabled { - painter.circle_filled(center, POINT_RADIUS, graph.color); - } else { - painter.circle_stroke(center, POINT_RADIUS, Stroke::new(1.5, graph.color)); + painter.circle_filled(center, POINT_RADIUS, point.color); + painter.text( + center, + Align2::CENTER_CENTER, + (band + 1).to_string(), + FontId::proportional(POINT_NUMBER_SIZE), + ink_on(point.color), + ); +} + +/// Every band that is on (or only the chosen one and the one under the pointer); the chosen +/// band stronger, with its area to 0 dB. +fn paint_band_curves(ui: &Ui, graph: &Graph, axes: &Axes) { + let hovered = hovered_band(ui, graph, axes); + for (band, db) in graph.band_curves.iter().enumerate() { + let Some(db) = db else { + continue; + }; + let Some(color) = graph.points.get(band).map(|point| point.color) else { + continue; + }; + let chosen = band == graph.selected; + let shown = graph.band_curve_mode == BandCurves::All || chosen || hovered == Some(band); + if !shown { + continue; + } + if chosen { + paint_area( + ui, + axes, + graph.frequencies, + db, + color.gamma_multiply(CHOSEN_AREA_OPACITY), + ); + paint_curve( + ui, + axes, + graph.frequencies, + db, + Stroke::new(CHOSEN_CURVE_WIDTH, color), + ); + } else { + let faint = color.gamma_multiply(BAND_CURVE_OPACITY); + paint_curve( + ui, + axes, + graph.frequencies, + db, + Stroke::new(BAND_CURVE_WIDTH, faint), + ); + } + } +} + +/// The band whose point is under the pointer, if any. +fn hovered_band(ui: &Ui, graph: &Graph, axes: &Axes) -> Option { + let pointer = ui.input(|input| input.pointer.hover_pos())?; + graph + .points + .iter() + .enumerate() + .filter(|(_, point)| point.enabled) + .find(|(_, point)| { + let home = pos2(axes.x(point.frequency), axes.y(point.gain + point.boost)); + home.distance(pointer) <= POINT_GRAB + }) + .map(|(band, _)| band) +} + +/// The area between a curve and 0 dB, as one strip of triangles. +fn paint_area(ui: &Ui, axes: &Axes, frequencies: &[f64], db: &[f64], color: Color32) { + let zero = axes.y(0.0); + let mut mesh = Mesh::default(); + for (place, (frequency, value)) in frequencies.iter().zip(db).enumerate() { + let x = axes.x(*frequency); + mesh.colored_vertex(pos2(x, zero), color); + mesh.colored_vertex(pos2(x, axes.y(*value)), color); + if place > 0 { + let at = (2 * place) as u32; + mesh.add_triangle(at - 2, at - 1, at); + mesh.add_triangle(at - 1, at + 1, at); + } } + ui.painter().add(Shape::mesh(mesh)); } diff --git a/crates/app/src/views/eq_table.rs b/crates/app/src/views/eq_table.rs index d285c1f..106e26e 100644 --- a/crates/app/src/views/eq_table.rs +++ b/crates/app/src/views/eq_table.rs @@ -3,7 +3,7 @@ use std::ops::RangeInclusive; -use eframe::egui::{Checkbox, ComboBox, Grid, Id, ScrollArea, Ui}; +use eframe::egui::{Checkbox, Color32, ComboBox, Grid, Id, RichText, ScrollArea, Ui}; use serde_json::Value; use super::number_field::{self, NumberField}; @@ -48,6 +48,8 @@ pub struct Table<'a> { pub headers: &'a [String; 6], pub editable: bool, pub selected: usize, + /// Each band's colour, as its point in the graph has it. + pub colors: &'a [Color32], } #[derive(Clone, Debug, PartialEq)] @@ -107,7 +109,10 @@ fn row_cells( row: &TableRow, ) -> Vec { let mut actions = Vec::new(); - let number = (band + 1).to_string(); + let mut number = RichText::new((band + 1).to_string()).strong(); + if let Some(color) = table.colors.get(band) { + number = number.color(*color); + } if ui .selectable_label(band == table.selected, number) .clicked() diff --git a/crates/app/src/views/settings_dialog.rs b/crates/app/src/views/settings_dialog.rs index 2b47990..06defc3 100644 --- a/crates/app/src/views/settings_dialog.rs +++ b/crates/app/src/views/settings_dialog.rs @@ -1,18 +1,24 @@ -//! The settings dialog behind the toolbar's "Einstellungen": theme, language, meter range and -//! response times. Every change applies at once; the app saves it. +//! The settings dialog behind the toolbar's "Einstellungen": theme, language, meter range, +//! response times, crossovers, the Windows strip and the EQ graph's display. Every change applies +//! at once; the app saves it. -use eframe::egui::ComboBox; -use eframe::egui::{Id, Modal, Ui}; +use eframe::egui::color_picker::{color_edit_button_srgba, Alpha}; +use eframe::egui::{ComboBox, Grid, Id, Modal, ScrollArea, Theme, Ui}; use neumann_control_core::settings::{ - CrossoverGuard, DeskSettings, LanguageChoice, MeterFloor, SystemAudioSettings, ThemeChoice, + BandCurves, CrossoverGuard, EqDisplaySettings, LanguageChoice, MeterFloor, Settings, + SystemAudioSettings, ThemeChoice, }; use crate::system_audio::AudioDevice; - use crate::texts; +use crate::ui_kit::eq_palette::{band_color, curve_color, hex, BAND_COLOR_COUNT}; const MODAL_ID: &str = "settings_dialog"; const SYSTEM_DEVICE_ID: &str = "system_audio_device"; +const BAND_GRID_ID: &str = "band_colors"; +const BAND_COLUMNS: usize = 10; +/// Taller than this, the dialog scrolls. +const DIALOG_MAX_HEIGHT: f32 = 600.0; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SettingsAction { @@ -21,14 +27,19 @@ pub enum SettingsAction { Close, } +/// `curve_labels`: each curve's settings key with its name, as the EQ window's tabs say it. pub fn show( ui: &mut Ui, - desk: &mut DeskSettings, - audio: &mut SystemAudioSettings, + settings: &mut Settings, devices: &[AudioDevice], + curve_labels: &[(String, String)], ) -> SettingsAction { - let modal = - Modal::new(Id::new(MODAL_ID)).show(ui.ctx(), |ui| contents(ui, desk, audio, devices)); + let modal = Modal::new(Id::new(MODAL_ID)).show(ui.ctx(), |ui| { + ScrollArea::vertical() + .max_height(DIALOG_MAX_HEIGHT) + .show(ui, |ui| contents(ui, settings, devices, curve_labels)) + .inner + }); if modal.should_close() { return SettingsAction::Close; } @@ -37,12 +48,16 @@ pub fn show( fn contents( ui: &mut Ui, - desk: &mut DeskSettings, - audio: &mut SystemAudioSettings, + settings: &mut Settings, devices: &[AudioDevice], + curve_labels: &[(String, String)], ) -> SettingsAction { - let before = *desk; - let audio_before = audio.clone(); + let before = ( + settings.desk, + settings.system_audio.clone(), + settings.eq_display.clone(), + ); + let desk = &mut settings.desk; ui.heading(texts::SETTINGS_TITLE); ui.label(texts::THEME); ui.horizontal(|ui| { @@ -75,17 +90,71 @@ fn contents( CrossoverGuard::Open }; } - system_audio(ui, audio, devices); + system_audio(ui, &mut settings.system_audio, devices); + ui.separator(); + eq_display(ui, &mut settings.eq_display, curve_labels); ui.separator(); if ui.button(texts::CLOSE).clicked() { return SettingsAction::Close; } - if *desk != before || *audio != audio_before { + let after = ( + settings.desk, + settings.system_audio.clone(), + settings.eq_display.clone(), + ); + if after != before { return SettingsAction::Changed; } SettingsAction::None } +/// Which bands show their own curve, and the colours of bands and curves. +fn eq_display(ui: &mut Ui, display: &mut EqDisplaySettings, curve_labels: &[(String, String)]) { + let dark = ui.ctx().theme() == Theme::Dark; + ui.strong(texts::EQ_DISPLAY); + ui.radio_value( + &mut display.band_curves, + BandCurves::All, + texts::BAND_CURVES_ALL, + ); + ui.radio_value( + &mut display.band_curves, + BandCurves::ChosenAndHovered, + texts::BAND_CURVES_CHOSEN, + ); + ui.label(texts::BAND_COLORS); + Grid::new(BAND_GRID_ID).show(ui, |ui| { + for band in 0..BAND_COLOR_COUNT { + let mut color = band_color(display, band, dark); + ui.horizontal(|ui| { + ui.label((band + 1).to_string()); + if color_edit_button_srgba(ui, &mut color, Alpha::Opaque).changed() { + display + .band_colors + .insert((band + 1).to_string(), hex(color)); + } + }); + if (band + 1) % BAND_COLUMNS == 0 { + ui.end_row(); + } + } + }); + ui.label(texts::CURVE_COLORS); + ui.horizontal_wrapped(|ui| { + for (key, label) in curve_labels { + let mut color = curve_color(display, key); + ui.label(label); + if color_edit_button_srgba(ui, &mut color, Alpha::Opaque).changed() { + display.curve_colors.insert(key.clone(), hex(color)); + } + } + }); + if ui.button(texts::DEFAULT_COLORS).clicked() { + display.band_colors.clear(); + display.curve_colors.clear(); + } +} + /// Whether the desk shows the Windows volume, and of which playback device. fn system_audio(ui: &mut Ui, audio: &mut SystemAudioSettings, devices: &[AudioDevice]) { ui.checkbox(&mut audio.shown, texts::SYSTEM_AUDIO_SHOW); diff --git a/crates/app/tests/eq.rs b/crates/app/tests/eq.rs index 24b124a..e3dbdf4 100644 --- a/crates/app/tests/eq.rs +++ b/crates/app/tests/eq.rs @@ -624,3 +624,39 @@ fn a_switched_off_band_has_no_point_in_the_graph() { harness.get_by_label(&texts::eq_band(2)); assert!(harness.query_by_label(&texts::eq_band(3)).is_none()); } + +#[test] +fn the_eq_display_is_chosen_and_reset_in_the_settings() { + let simulator = Simulator::start(SimModel::Kh120Ii); + simulator.set_reply_delay(REPLY_DELAY); + let directory = tempfile::tempdir().unwrap(); + let path = settings_file(directory.path(), vec![manual_for(&simulator)]); + let mut stored = neumann_control_core::settings::load(&path); + stored + .eq_display + .band_colors + .insert("3".to_owned(), "#ff0000".to_owned()); + neumann_control_core::settings::save(&stored, &path).unwrap(); + let mut harness = harness(path.clone(), Vec::new()); + harness.set_size(EXPANDED_WINDOW_SIZE.into()); + wait_for_level(&mut harness, 100.0); + harness.get_by_label(texts::SETTINGS.get()).click(); + harness.run_steps(4); + harness + .get_by_label(texts::BAND_CURVES_CHOSEN.get()) + .click(); + harness.run_steps(2); + // The dialog scrolls; the button lies below what it shows at first. + harness + .get_by_label(texts::DEFAULT_COLORS.get()) + .scroll_to_me(); + harness.run_steps(3); + harness.get_by_label(texts::DEFAULT_COLORS.get()).click(); + harness.run_steps(2); + let stored = neumann_control_core::settings::load(&path).eq_display; + assert_eq!( + stored.band_curves, + neumann_control_core::settings::BandCurves::ChosenAndHovered + ); + assert!(stored.band_colors.is_empty(), "{:?}", stored.band_colors); +} diff --git a/crates/core/src/settings.rs b/crates/core/src/settings.rs index c030176..6b8b9d1 100644 --- a/crates/core/src/settings.rs +++ b/crates/core/src/settings.rs @@ -46,6 +46,31 @@ pub struct Settings { pub eq_presets: Vec, #[serde(deserialize_with = "or_default")] pub system_audio: SystemAudioSettings, + #[serde(deserialize_with = "or_default")] + pub eq_display: EqDisplaySettings, +} + +/// How the EQ graph draws its bands, and the colours the user chose instead of the defaults. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct EqDisplaySettings { + #[serde(deserialize_with = "or_default")] + pub band_curves: BandCurves, + /// Band number (from 1) → `#rrggbb`; a band not listed has its default colour. + #[serde(deserialize_with = "or_default")] + pub band_colors: BTreeMap, + /// Container below its output (`eq2`, `eq1/in1`, ...) → `#rrggbb`. + #[serde(deserialize_with = "or_default")] + pub curve_colors: BTreeMap, +} + +/// Which bands show their own curve in the EQ graph. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum BandCurves { + #[default] + All, + /// The chosen band and the one under the pointer. + ChosenAndHovered, } /// The strip with Windows' own volume of a playback device. diff --git a/crates/core/tests/settings.rs b/crates/core/tests/settings.rs index c9e7982..2ea0d0b 100644 --- a/crates/core/tests/settings.rs +++ b/crates/core/tests/settings.rs @@ -1,7 +1,7 @@ use neumann_control_core::settings::{ - load, save, CrossoverGuard, DeskSettings, EqPreset, GroupSettings, Language, LanguageChoice, - Linkable, ManualSpeaker, MeterFloor, Settings, SettingsPreset, SystemAudioSettings, - ThemeChoice, WindowGeometry, SETTINGS_FILE, + load, save, BandCurves, CrossoverGuard, DeskSettings, EqPreset, GroupSettings, Language, + LanguageChoice, Linkable, ManualSpeaker, MeterFloor, Settings, SettingsPreset, + SystemAudioSettings, ThemeChoice, WindowGeometry, SETTINGS_FILE, }; use std::fs; @@ -355,3 +355,34 @@ fn the_windows_volume_strip_is_off_unless_chosen() { fs::write(&path, r#"{"manual":[],"system_audio":7}"#).unwrap(); assert_eq!(load(&path).system_audio, SystemAudioSettings::default()); } + +#[test] +fn the_eq_display_keeps_its_choices_and_falls_back_on_nonsense() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(SETTINGS_FILE); + fs::write(&path, r#"{"manual":[]}"#).unwrap(); + let display = load(&path).eq_display; + assert_eq!(display.band_curves, BandCurves::All); + assert!(display.band_colors.is_empty() && display.curve_colors.is_empty()); + fs::write( + &path, + r##"{"manual":[],"eq_display":{"band_curves":"ChosenAndHovered","band_colors":{"3":"#ff0000"},"curve_colors":{"eq2":"#00ff00"}}}"##, + ) + .unwrap(); + let display = load(&path).eq_display; + assert_eq!(display.band_curves, BandCurves::ChosenAndHovered); + assert_eq!( + display.band_colors.get("3").map(String::as_str), + Some("#ff0000") + ); + assert_eq!( + display.curve_colors.get("eq2").map(String::as_str), + Some("#00ff00") + ); + fs::write( + &path, + r#"{"manual":[],"eq_display":{"band_curves":"sometimes"}}"#, + ) + .unwrap(); + assert_eq!(load(&path).eq_display.band_curves, BandCurves::All); +} From 2637e82d35a6a17f550955ada10b755c0740b538 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:32:55 +0200 Subject: [PATCH 161/460] Describe band colours and band curves --- CHANGELOG.md | 3 +++ README.md | 12 ++++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0044d09..e3c37a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,9 @@ - **Full scan** in the device panel: a report of everything a speaker answers, without its identifiers, next to the executable. - A strip with the Windows volume of a chosen playback device, with mute and peak meters. +- Each EQ band has its own colour and its number in its point; every band's own curve is drawn + thin, the chosen band's with its area. Colours and which band curves show are set in the + settings. - **Protect crossovers** in the settings (on by default); off, "Make flat" and EQ presets work on the KH 750 crossovers too, with a warning. diff --git a/README.md b/README.md index 0c2de47..26c23e5 100644 --- a/README.md +++ b/README.md @@ -211,11 +211,13 @@ tab: "EQ 2", "EQ 3", and on the KH 750 the crossovers of input A and input B ("C 10 Hz to 24 kHz. The curves are computed from the settings, not measured. Each band has two levels: **Level** lifts or lowers every frequency while the band is on, **Boost** is the height of its filter (the peak or shelf); at the band's frequency both add up. -- Each band of the chosen EQ that is on is a point; bands that are off have none and are switched - on in the table. Drag a point to change frequency and boost, turn the mouse wheel over it to - change Q. Level is changed in the table only, so a drag never moves the whole spectrum. +- Each band of the chosen EQ that is on is a point in its band's colour with its number inside; + bands that are off have none and are switched on in the table. Drag a point to change + frequency and boost, turn the mouse wheel over it to change Q. Level is changed in the table + only, so a drag never moves the whole spectrum. - A point stands at its band's own height; the curves add every band up, so a point can lie - above or below them. The chosen band alone is drawn as a thin line in its EQ's colour. + above or below them. Each band's own curve is drawn thin in its colour, the chosen band's + stronger with its area to 0 dB. - The table below lists every band with on/off, type, frequency, Q, level and boost; numbers take a comma or a point, and a value outside the allowed range is refused with the reason. - A change is written when the gesture ends, and only for that one band, so the other bands @@ -258,6 +260,8 @@ recorded. - the lowest level the meters show: −120, −90 or −60 dB, - whether response times are shown, - whether the EQ crossovers are protected (on by default), +- the EQ display: own curves of all bands, or only of the chosen band and the one under the + pointer; the colour of each band number and of each EQ curve (**Default colours** resets them), - whether the desk shows a strip with the Windows volume, and of which playback device (Windows' default device or a chosen one): a fader in percent, mute and a peak meter per channel. It sets Windows' own volume, like the volume slider in the taskbar. From ede218ff8652160cfa00025745d7472ccd8ca544 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:46:27 +0200 Subject: [PATCH 162/460] Reset every band of an EQ --- CHANGELOG.md | 4 +- README.md | 5 ++- crates/app/src/app/eq.rs | 32 ++++++++-------- crates/app/src/eq.rs | 61 +++++++++++++++++++++++++++---- crates/app/src/texts.rs | 13 +++++++ crates/app/src/views/eq_window.rs | 30 ++++++++++----- crates/app/tests/eq.rs | 51 ++++++++++++++++++++++++++ 7 files changed, 162 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3c37a1..a565431 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ - An **EQ** button on each strip, opening the speaker's EQ window. - **Make flat** in the EQ window: level and boost of every band of an EQ to 0 dB, after a question; not for the KH 750 crossovers. +- **Reset** in the EQ window: every band of an EQ off and back to PARAMETRIC, 100 Hz, Q 0.7, + level and boost 0 dB, after a question; protected like "Make flat". - **Undo** in the EQ window and Ctrl+Z there: takes back the speaker's newest EQ change. - Trends of the temperature and the warnings over the last two hours of the session in the device panel. @@ -28,7 +30,7 @@ - Each EQ band has its own colour and its number in its point; every band's own curve is drawn thin, the chosen band's with its area. Colours and which band curves show are set in the settings. -- **Protect crossovers** in the settings (on by default); off, "Make flat" and EQ presets work on +- **Protect crossovers** in the settings (on by default); off, "Make flat", "Reset" and EQ presets work on the KH 750 crossovers too, with a warning. ### Changed diff --git a/README.md b/README.md index 26c23e5..a74b5ea 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,9 @@ tab: "EQ 2", "EQ 3", and on the KH 750 the crossovers of input A and input B ("C - **Make flat** sets level and boost of every band of the chosen EQ to 0 dB, after a question; frequency, Q, type and on/off stay. It is one history entry. The crossovers have no **Make flat**: flattening them would remove the subwoofer's low pass. +- **Reset** switches every band of the chosen EQ off and sets it back to PARAMETRIC, 100 Hz, + Q 0.7, level and boost 0 dB, after a question. The bands are switched off first; it is one + history entry. - **Undo** in the window takes back the speaker's newest EQ change, also with Ctrl+Z while the window is in front; the line beside it says which change that is. When the values changed since, the window asks first. @@ -234,7 +237,7 @@ tab: "EQ 2", "EQ 3", and on the KH 750 the crossovers of input A and input B ("C the presets with the same number of bands, shows which bands change and writes them as one history entry. Writing several settings of a band one after another can pass a short level jump. -- The crossovers are protected by default: no **Make flat** and no EQ presets there. Switching off +- The crossovers are protected by default: no **Make flat**, no **Reset** and no EQ presets there. Switching off **Protect crossovers** in **Settings** allows both, with a warning that the output then plays unfiltered. diff --git a/crates/app/src/app/eq.rs b/crates/app/src/app/eq.rs index da00a9a..fb00748 100644 --- a/crates/app/src/app/eq.rs +++ b/crates/app/src/app/eq.rs @@ -8,7 +8,7 @@ use neumann_control_core::ssc::wire::SscPath; use serde_json::Value; use super::NeumannApp; -use crate::eq::{bands_of, container_of, flat, has_eq, one_band, Band, Leaf, FLATTENING_LEAVES}; +use crate::eq::{bands_of, container_of, has_eq, one_band, Band, Leaf, ResetKind}; use crate::eq_response::{band_curves, curve_db, graph_frequencies}; use crate::history::Entry; use crate::paths; @@ -33,7 +33,7 @@ pub(super) struct EqWindow { container: usize, band: usize, refusal: Option, - asking_reset: bool, + asking_reset: Option, /// Other speakers whose sum is drawn for comparison. compared: Vec, /// The speakers offered for comparison in the last frame, in the order shown. @@ -70,7 +70,7 @@ impl NeumannApp { container: 0, band: 0, refusal: None, - asking_reset: false, + asking_reset: None, compared: Vec::new(), candidates: Vec::new(), preset: None, @@ -366,17 +366,18 @@ impl NeumannApp { EqAction::Close => {} EqAction::Output(output) => { (window.output, window.container, window.band) = (output, 0, 0); - window.asking_reset = false; + window.asking_reset = None; } EqAction::Container(container) => { (window.container, window.band) = (container, 0); - window.asking_reset = false; + window.asking_reset = None; } - EqAction::ResetAsk => window.asking_reset = true, - EqAction::ResetCancel => window.asking_reset = false, + EqAction::ResetAsk(kind) => window.asking_reset = Some(kind), + EqAction::ResetCancel => window.asking_reset = None, EqAction::ResetConfirm => { - window.asking_reset = false; - self.flatten(index, now); + if let Some(kind) = window.asking_reset.take() { + self.reset_container(index, kind, now); + } } EqAction::Undo => { let key = window.key.clone(); @@ -516,14 +517,15 @@ impl NeumannApp { self.eq_windows[index].preset = Some(EqPresetDialog::Load { selected: None }); } - /// `gain` and `boost` of every band of the chosen container to 0, both in this frame, so - /// the history files them as one entry. - fn flatten(&mut self, index: usize, now: f64) { - for leaf in FLATTENING_LEAVES { - let Some((path, length)) = self.chosen_leaf(index, leaf) else { + /// Every band of the chosen container at once, in this frame, so the history files it as + /// one entry. + fn reset_container(&mut self, index: usize, kind: ResetKind, now: f64) { + for leaf in kind.leaves() { + let Some((path, length)) = self.chosen_leaf(index, *leaf) else { return; }; - if let Err((label, reason)) = self.send_eq_write(index, &path, flat(length), now) { + let value = kind.value(*leaf, length); + if let Err((label, reason)) = self.send_eq_write(index, &path, value, now) { self.eq_windows[index].refusal = Some(texts::eq_leaf_refusal(&label, &reason)); return; } diff --git a/crates/app/src/eq.rs b/crates/app/src/eq.rs index 7b3dd0a..03c6d70 100644 --- a/crates/app/src/eq.rs +++ b/crates/app/src/eq.rs @@ -128,13 +128,43 @@ pub fn has_eq(catalog: &Catalog, speaker: &SpeakerState) -> bool { speaker.identity.is_some() && !catalog.eq_containers(speaker.product()).is_empty() } -/// The leaves a reset to flat sets to 0 dB: `gain` is a flat level and `boost` the filter's -/// height (measured), so with both at 0 a band changes nothing whatever its type. -pub const FLATTENING_LEAVES: [Leaf; 2] = [Leaf::Gain, Leaf::Boost]; +/// What a full reset sets every band to: what the tested speakers report for bands nobody +/// touched, not a measured factory state. +const RESET_TYPE: &str = "PARAMETRIC"; +const RESET_FREQUENCY_HZ: f64 = 100.0; +const RESET_Q: f64 = 0.7; -/// The value that sets every band of a leaf to 0. -pub fn flat(length: usize) -> Value { - Value::from(vec![0.0; length]) +/// Setting every band of a container back at once. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ResetKind { + /// `gain` and `boost` to 0 dB: `gain` is a flat level and `boost` the filter's height + /// (measured), so with both at 0 a band changes nothing whatever its type. Bands stay on. + Flat, + /// Every band off and every leaf back to the start values. + Full, +} + +impl ResetKind { + /// In the order they are written: a full reset switches the bands off first, so the other + /// leaves change while nothing sounds. + pub fn leaves(self) -> &'static [Leaf] { + match self { + ResetKind::Flat => &[Leaf::Gain, Leaf::Boost], + ResetKind::Full => &Leaf::ALL, + } + } + + /// The whole array a leaf of `length` bands gets. + pub fn value(self, leaf: Leaf, length: usize) -> Value { + let entry = match leaf { + Leaf::Enabled => Value::Bool(false), + Leaf::Type => Value::from(RESET_TYPE), + Leaf::Frequency => Value::from(RESET_FREQUENCY_HZ), + Leaf::Q => Value::from(RESET_Q), + Leaf::Gain | Leaf::Boost => Value::from(0.0), + }; + Value::Array(vec![entry; length]) + } } /// The value that writes `value` to `band` only (measured: `null` leaves a band as it is). @@ -163,8 +193,23 @@ mod tests { } #[test] - fn flat_sets_every_band_to_zero() { - assert_eq!(flat(3), json!([0.0, 0.0, 0.0])); + fn flat_sets_level_and_boost_of_every_band_to_zero() { + assert_eq!(ResetKind::Flat.leaves(), &[Leaf::Gain, Leaf::Boost]); + assert_eq!(ResetKind::Flat.value(Leaf::Gain, 3), json!([0.0, 0.0, 0.0])); + } + + #[test] + fn a_full_reset_switches_off_first_and_sets_every_leaf() { + let leaves = ResetKind::Full.leaves(); + assert_eq!(leaves.len(), 6); + assert_eq!(leaves[0], Leaf::Enabled); + let value = |leaf| ResetKind::Full.value(leaf, 2); + assert_eq!(value(Leaf::Enabled), json!([false, false])); + assert_eq!(value(Leaf::Type), json!(["PARAMETRIC", "PARAMETRIC"])); + assert_eq!(value(Leaf::Frequency), json!([100.0, 100.0])); + assert_eq!(value(Leaf::Q), json!([0.7, 0.7])); + assert_eq!(value(Leaf::Gain), json!([0.0, 0.0])); + assert_eq!(value(Leaf::Boost), json!([0.0, 0.0])); } fn two_bands() -> BTreeMap { diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index cf5770e..de2dbfd 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -184,6 +184,7 @@ pub const EQ_OUTPUT_A: Text = Text::new("Ausgang A", "Output A"); pub const EQ_OUTPUT_B: Text = Text::new("Ausgang B", "Output B"); pub const BAND: Text = Text::new("Band", "Band"); pub const EQ_RESET: Text = Text::new("Flach stellen", "Make flat"); +pub const EQ_FULL_RESET: Text = Text::new("Zurücksetzen", "Reset"); pub const EQ_UNDO: Text = Text::new("Rückgängig", "Undo"); pub const EQ_PRESET_SAVE: Text = Text::new("Als EQ-Preset speichern…", "Save as EQ preset…"); pub const EQ_PRESET_LOAD: Text = Text::new("EQ-Preset laden…", "Load EQ preset…"); @@ -745,6 +746,18 @@ pub fn compared_curve(speaker: &str) -> String { pick(format!("{speaker} (Summe)"), format!("{speaker} (sum)")) } +/// Asked before a full reset, in the EQ window. +pub fn eq_full_reset_question(container: &str) -> String { + pick( + format!( + "Alle Bänder von {container} zurücksetzen? Aus, PARAMETRIC, 100 Hz, Q 0,7, Pegel und Anhebung 0 dB." + ), + format!( + "Reset every band of {container}? Off, PARAMETRIC, 100 Hz, Q 0.7, level and boost 0 dB." + ), + ) +} + /// Asked before a reset to flat, in the EQ window. pub fn eq_reset_question(container: &str) -> String { pick( diff --git a/crates/app/src/views/eq_window.rs b/crates/app/src/views/eq_window.rs index 8ef1b46..9967798 100644 --- a/crates/app/src/views/eq_window.rs +++ b/crates/app/src/views/eq_window.rs @@ -10,6 +10,7 @@ use super::eq_graph::{self, Graph, GraphAction}; use super::eq_table::{self, Table, TableAction}; use super::history::UNDO_SHORTCUT; use super::preset_dialog::{self, LoadAction, LoadView, SaveAction}; +use crate::eq::ResetKind; use crate::texts; const EQ_WINDOW_SIZE: [f32; 2] = [900.0, 640.0]; @@ -62,8 +63,8 @@ pub enum PresetDialogView<'a> { pub struct ResetOffer { pub container: String, - /// The question is shown instead of the button. - pub asking: bool, + /// The reset being asked about; its question is shown instead of the buttons. + pub asking: Option, /// The container is a crossover (offered only with the protection off): the question warns. pub crossover: bool, } @@ -75,7 +76,7 @@ pub enum EqAction { Container(usize), Graph(GraphAction), Table(TableAction), - ResetAsk, + ResetAsk(ResetKind), ResetConfirm, ResetCancel, Undo, @@ -256,10 +257,11 @@ fn compare_row(ui: &mut Ui, candidates: &[(String, bool)]) -> Option { /// Reset and undo in one row; while the reset is asked about, its question takes the row. fn edit_row(ui: &mut Ui, reset: Option<&ResetOffer>, undo: Option<&str>) -> Option { ui.horizontal(|ui| { - if let Some(asked) = reset.filter(|reset| reset.asking) { - return reset_question(ui, asked); + if let Some((asked, kind)) = reset.and_then(|reset| Some((reset, reset.asking?))) { + return reset_question(ui, asked, kind); } let flatten = reset.is_some() && ui.button(texts::EQ_RESET).clicked(); + let full = reset.is_some() && ui.button(texts::EQ_FULL_RESET).clicked(); let undone = ui .add_enabled(undo.is_some(), Button::new(texts::EQ_UNDO)) .clicked(); @@ -267,7 +269,10 @@ fn edit_row(ui: &mut Ui, reset: Option<&ResetOffer>, undo: Option<&str>) -> Opti ui.weak(line); } if flatten { - return Some(EqAction::ResetAsk); + return Some(EqAction::ResetAsk(ResetKind::Flat)); + } + if full { + return Some(EqAction::ResetAsk(ResetKind::Full)); } undone.then_some(EqAction::Undo) }) @@ -294,12 +299,19 @@ fn question(ui: &mut Ui, lines: &[String]) -> Option { .inner } -fn reset_question(ui: &mut Ui, reset: &ResetOffer) -> Option { - ui.label(texts::eq_reset_question(&reset.container)); +fn reset_question(ui: &mut Ui, reset: &ResetOffer, kind: ResetKind) -> Option { + let (question, confirm) = match kind { + ResetKind::Flat => (texts::eq_reset_question(&reset.container), texts::EQ_RESET), + ResetKind::Full => ( + texts::eq_full_reset_question(&reset.container), + texts::EQ_FULL_RESET, + ), + }; + ui.label(question); if reset.crossover { ui.colored_label(ui.visuals().warn_fg_color, texts::CROSSOVER_WARNING); } - if ui.button(texts::EQ_RESET).clicked() { + if ui.button(confirm).clicked() { return Some(EqAction::ResetConfirm); } ui.button(texts::CANCEL) diff --git a/crates/app/tests/eq.rs b/crates/app/tests/eq.rs index e3dbdf4..9732946 100644 --- a/crates/app/tests/eq.rs +++ b/crates/app/tests/eq.rs @@ -660,3 +660,54 @@ fn the_eq_display_is_chosen_and_reset_in_the_settings() { ); assert!(stored.band_colors.is_empty(), "{:?}", stored.band_colors); } + +#[test] +fn a_full_reset_switches_every_band_off_and_back_to_the_start_values() { + let (simulator, _directory, mut harness) = open_eq_with(SimModel::Kh120Ii, |simulator| { + simulator.set_value("audio/out/eq2/gain", json!(vec![1.5; 10])); + simulator.set_value("audio/out/eq2/q", json!(vec![2.0; 10])); + }); + harness + .get_by_role_and_label(Role::Button, texts::EQ_FULL_RESET.get()) + .click(); + harness.run_steps(2); + harness.get_by_label_contains(&texts::eq_full_reset_question("EQ 2")); + barrier(&simulator); + assert!(sets(&simulator, "/audio/out/eq2/enabled").is_empty()); + harness + .get_by_role_and_label(Role::Button, texts::EQ_FULL_RESET.get()) + .click(); + wait_until(&mut harness, "all six leaves written", |_| { + ["enabled", "type", "frequency", "q", "gain", "boost"] + .iter() + .all(|leaf| !sets(&simulator, &format!("/audio/out/eq2/{leaf}")).is_empty()) + }); + assert_eq!( + sets(&simulator, "/audio/out/eq2/enabled"), + vec![json!(vec![false; 10])] + ); + assert_eq!( + sets(&simulator, FREQUENCY_POINTER), + vec![json!(vec![100.0; 10])] + ); + assert_eq!( + sets(&simulator, "/audio/out/eq2/q"), + vec![json!(vec![0.7; 10])] + ); + assert_eq!(sets(&simulator, GAIN_POINTER), vec![json!(vec![0.0; 10])]); + let history: Vec<_> = harness.state().history().entries().cloned().collect(); + assert_eq!(history.len(), 1); + assert_eq!(history[0].changes.len(), 6); + assert!(history[0].changes[0].path.to_string().ends_with("enabled")); +} + +#[test] +fn a_full_reset_is_not_offered_on_a_protected_crossover() { + let (_simulator, _directory, mut harness) = open_eq(SimModel::Kh750); + harness.get_by_role_and_label(Role::Button, texts::EQ_FULL_RESET.get()); + harness.get_by_label("Weiche A").click(); + harness.run_steps(2); + assert!(harness + .query_by_role_and_label(Role::Button, texts::EQ_FULL_RESET.get()) + .is_none()); +} From 6d9a6b981002ad18f3a16330fe9ff48955ec68f9 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:21:02 +0200 Subject: [PATCH 163/460] Import and export EQs as APO filter files Reads Equalizer APO and REW Generic filter lines into the chosen EQ with a preview, checked whole before the first write; exports APO lines. Shelves wait for a measurement of the speakers' shelf Q. --- CHANGELOG.md | 7 +- Cargo.lock | 27 ++ README.md | 19 +- crates/app/Cargo.toml | 3 + crates/app/src/app.rs | 9 + crates/app/src/app/eq.rs | 108 ++++- crates/app/src/app/eq_files.rs | 168 ++++++++ crates/app/src/eq_file.rs | 646 ++++++++++++++++++++++++++++++ crates/app/src/file_dialog.rs | 57 +++ crates/app/src/lib.rs | 2 + crates/app/src/texts.rs | 126 ++++++ crates/app/src/views/eq_window.rs | 102 ++++- crates/app/tests/eq.rs | 149 +++++++ crates/app/tests/support/mod.rs | 28 +- 14 files changed, 1417 insertions(+), 34 deletions(-) create mode 100644 crates/app/src/app/eq_files.rs create mode 100644 crates/app/src/eq_file.rs create mode 100644 crates/app/src/file_dialog.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index a565431..5d74e4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,8 +30,11 @@ - Each EQ band has its own colour and its number in its point; every band's own curve is drawn thin, the chosen band's with its area. Colours and which band curves show are set in the settings. -- **Protect crossovers** in the settings (on by default); off, "Make flat", "Reset" and EQ presets work on - the KH 750 crossovers too, with a warning. +- **Import…** and **Export…** in the EQ window: filter lines of Equalizer APO and of Room EQ + Wizard's "Generic" export, with a preview and one history entry; export as APO lines. Shelving + filters are not taken over yet. +- **Protect crossovers** in the settings (on by default); off, "Make flat", "Reset", EQ presets + and the import work on the KH 750 crossovers too, with a warning. ### Changed diff --git a/Cargo.lock b/Cargo.lock index d1ce18a..6e5b68d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -730,6 +730,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.13.2", + "block2 0.6.2", + "libc", "objc2 0.6.5", ] @@ -1924,6 +1926,7 @@ dependencies = [ "egui_kittest", "log", "neumann-control-core", + "rfd", "serde_json", "simplelog", "tempfile", @@ -2047,6 +2050,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.2", + "block2 0.6.2", "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", @@ -2705,6 +2709,29 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "rfd" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "js-sys", + "libc", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "percent-encoding", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustc-hash" version = "1.1.0" diff --git a/README.md b/README.md index a74b5ea..59b8afd 100644 --- a/README.md +++ b/README.md @@ -237,9 +237,22 @@ tab: "EQ 2", "EQ 3", and on the KH 750 the crossovers of input A and input B ("C the presets with the same number of bands, shows which bands change and writes them as one history entry. Writing several settings of a band one after another can pass a short level jump. -- The crossovers are protected by default: no **Make flat**, no **Reset** and no EQ presets there. Switching off - **Protect crossovers** in **Settings** allows both, with a warning that the output then plays - unfiltered. +- **Import…** reads the filter lines of an Equalizer APO `config.txt` or of a filter file Room EQ + Wizard exported for its "Generic" equaliser (`Filter 1: ON PK Fc 100 Hz Gain -3.0 dB Q 1.41`). + A filter with a number goes to that band, one without to the next band; bands the file does not + name are switched off. The file's `Gain` becomes the band's boost. Peaking (`PK`, `PEQ`, + `Modal`), low and high pass with Q (`LP`/`LPQ`, `HP`/`HPQ`), band pass (`BP`), notch (`NO`) and + all pass (`AP`) are taken over; `BW Oct` instead of Q is converted. Shelving filters are refused + for now, as are other types, a missing Q, more bands than the EQ has and values the speaker would + refuse: then nothing is written. `Preamp` and other APO commands are not taken over; the preview + names them. The preview shows which bands change before **Write** sends them as one history + entry. +- **Export…** saves the chosen EQ as Equalizer APO filter lines, numbered by band. Bands that are + off, a band's flat level, shelving filters and inversion have no APO line; they are written as + comments and listed after saving. Room EQ Wizard cannot load text filter files. +- The crossovers are protected by default: no **Make flat**, no **Reset**, no EQ presets and no + **Import…** there. Switching off **Protect crossovers** in **Settings** allows them, with a + warning that the output then plays unfiltered. ### History and undo diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 0499fbb..3a94e1e 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -41,6 +41,9 @@ windows = { version = "0.62", features = [ "Win32_UI_Shell_PropertiesSystem", ] } +# The Windows file dialog for the EQ import and export. +rfd = { version = "0.17", default-features = false } + [build-dependencies] # Puts the icon into the executable, for Explorer and the taskbar. winresource = "0.1" diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index 3f46fc1..73a7a8f 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -15,6 +15,7 @@ use neumann_control_core::mask::mask_host; use neumann_control_core::settings::{self, Language, ManualSpeaker, Settings, WindowGeometry}; use neumann_control_core::speaker::{Command, Event, Events, SpeakerHandle, Target}; +use crate::file_dialog::{FileDialog, NativeFileDialog}; use crate::groups::{forget_member, member_id, rename_member, set_membership}; use crate::meter::MeterState; use crate::scan_choice::ScanChoice; @@ -37,6 +38,7 @@ use crate::views::{all_values, panel, wheel}; mod compact; mod desk; mod eq; +mod eq_files; mod full_scan; mod history; mod preset_dialogs; @@ -91,6 +93,7 @@ pub struct NeumannApp { eq_windows: Vec, preset_dialog: Option, system_audio: Box, + file_dialog: Box, /// The Windows strip's meters, one per channel of the device. system_meters: Vec, /// The playback devices for the settings dialog, listed when it opens. @@ -153,6 +156,7 @@ impl NeumannApp { eq_windows: Vec::new(), preset_dialog: None, system_audio, + file_dialog: Box::new(NativeFileDialog), system_meters: Vec::new(), audio_devices: Vec::new(), system_language, @@ -161,6 +165,11 @@ impl NeumannApp { app } + /// The dialog the EQ import and export choose their files with; the tests' stand-in. + pub fn set_file_dialog(&mut self, file_dialog: Box) { + self.file_dialog = file_dialog; + } + /// Starts workers for remembered speakers not shown yet and moves speakers whose entry now /// points at another address. fn sync_targets(&mut self) { diff --git a/crates/app/src/app/eq.rs b/crates/app/src/app/eq.rs index fb00748..3f1ccab 100644 --- a/crates/app/src/app/eq.rs +++ b/crates/app/src/app/eq.rs @@ -3,7 +3,7 @@ use eframe::egui::{Color32, Context, Id, ViewportId}; use neumann_control_core::catalog::{Catalog, EqContainer, EQ_TYPES}; -use neumann_control_core::settings::CrossoverGuard; +use neumann_control_core::settings::{CrossoverGuard, EqPreset}; use neumann_control_core::ssc::wire::SscPath; use serde_json::Value; @@ -19,7 +19,7 @@ use crate::ui_kit::eq_palette::{band_color, curve_color, curve_key}; use crate::ui_kit::theme::group_color; use crate::views::eq_graph::{ComparedCurve, Graph, GraphAction, GraphCurve, GraphPoint}; use crate::views::eq_table::{Limits, Table, TableAction, TableRow}; -use crate::views::eq_window::{self, EqAction, EqView, PresetDialogView, ResetOffer}; +use crate::views::eq_window::{self, EqAction, EqView, ImportView, PresetDialogView, ResetOffer}; use crate::writes::{self, Intent}; const EQ_VIEWPORT_ID: &str = "eq_window"; @@ -39,6 +39,18 @@ pub(super) struct EqWindow { /// The speakers offered for comparison in the last frame, in the order shown. candidates: Vec, preset: Option, + pub(super) import: Option, + /// What the last export did, line by line. + pub(super) file_report: Vec, +} + +/// A filter file read for import, shown before it is written. +pub(super) struct PendingImport { + pub(super) file: String, + /// `None` when the file cannot be imported; `refusal` says why. + pub(super) preset: Option, + pub(super) notes: Vec, + pub(super) refusal: Option, } enum EqPresetDialog { @@ -74,6 +86,8 @@ impl NeumannApp { compared: Vec::new(), candidates: Vec::new(), preset: None, + import: None, + file_report: Vec::new(), }); } @@ -304,6 +318,27 @@ impl NeumannApp { }), None => None, }; + let import_changes: Vec = match (&window.import, reshapable) { + (Some(pending), Some(shown)) => pending + .preset + .as_ref() + .map(|preset| { + eq_plan(preset, &speaker.values, &shown.container.path) + .bands + .iter() + .map(|band| texts::eq_preset_band(band + 1)) + .collect() + }) + .unwrap_or_default(), + _ => Vec::new(), + }; + let import = window.import.as_ref().map(|pending| ImportView { + file: &pending.file, + changes: &import_changes, + notes: &pending.notes, + refusal: pending.refusal.as_deref(), + crossover: reshapable.is_some_and(|shown| shown.container.crossover), + }); let reset = reshapable.map(|shown| ResetOffer { container: shown.container.label(texts::language()).to_owned(), asking: window.asking_reset, @@ -326,6 +361,9 @@ impl NeumannApp { compared: &legend, presets: reshapable.is_some(), preset_dialog, + exportable: selected.is_some_and(|shown| shown.bands.is_some()), + import, + file_report: &window.file_report, }; let actions = eq_window::show(context, &view); self.eq_windows[index].candidates = candidate_keys; @@ -353,7 +391,12 @@ impl NeumannApp { .position(|window| is_eq_entry_of(entry, &window.key, catalog)) } - fn speaker_of(&self, key: &SpeakerKey) -> Option<&SpeakerState> { + /// The speaker of the EQ window at `index`. + pub(super) fn eq_speaker(&self, index: usize) -> Option<&SpeakerState> { + self.speaker_of(&self.eq_windows[index].key) + } + + pub(super) fn speaker_of(&self, key: &SpeakerKey) -> Option<&SpeakerState> { self.speakers .iter() .map(|speaker| &speaker.state) @@ -405,6 +448,10 @@ impl NeumannApp { EqAction::PresetConfirmSave => self.save_eq_preset(index), EqAction::PresetWrite => self.write_eq_preset(index, now), EqAction::PresetDelete => self.delete_eq_preset(index), + EqAction::Import => self.import_eq_file(index), + EqAction::ImportWrite => self.write_eq_import(index, now), + EqAction::ImportCancel => window.import = None, + EqAction::Export => self.export_eq_file(index), EqAction::Compare(place, on) => { let Some(key) = window.candidates.get(place).cloned() else { return; @@ -440,7 +487,7 @@ impl NeumannApp { } /// The chosen container's path and its band count, once the speaker reported it. - fn chosen_container(&self, index: usize) -> Option<(SscPath, usize)> { + pub(super) fn chosen_container(&self, index: usize) -> Option<(SscPath, usize)> { let catalog = Catalog::embedded(); let window = &self.eq_windows[index]; let speaker = self.speaker_of(&window.key)?; @@ -463,7 +510,7 @@ impl NeumannApp { }; let name = name.trim().to_owned(); let preset = self.chosen_container(index).and_then(|(container, _)| { - let speaker = self.speaker_of(&self.eq_windows[index].key)?; + let speaker = self.eq_speaker(index)?; capture_eq(&name, &speaker.values, &container) }); if let Some(preset) = preset { @@ -493,7 +540,7 @@ impl NeumannApp { fn write_eq_preset(&mut self, index: usize, now: f64) { let plan = self.selected_eq_preset(index).and_then(|place| { let (container, _) = self.chosen_container(index)?; - let speaker = self.speaker_of(&self.eq_windows[index].key)?; + let speaker = self.eq_speaker(index)?; Some(eq_plan( &self.settings.eq_presets[place], &speaker.values, @@ -501,7 +548,22 @@ impl NeumannApp { )) }); self.eq_windows[index].preset = None; - for (path, value) in plan.map(|plan| plan.writes).unwrap_or_default() { + self.write_eq_leaves(index, plan.map(|plan| plan.writes).unwrap_or_default(), now); + } + + /// Whole leaves in this frame (one history entry), all or none: every write is checked + /// before the first is sent, so a refused leaf never leaves the others half written. + pub(super) fn write_eq_leaves( + &mut self, + index: usize, + writes: Vec<(SscPath, Value)>, + now: f64, + ) { + if let Some(refusal) = self.eq_refusal(index, &writes) { + self.eq_windows[index].refusal = Some(refusal); + return; + } + for (path, value) in writes { if let Err((label, reason)) = self.send_eq_write(index, &path, value, now) { self.eq_windows[index].refusal = Some(texts::eq_leaf_refusal(&label, &reason)); return; @@ -509,6 +571,20 @@ impl NeumannApp { } } + /// Why the write policy would refuse one of `writes`, as the window says it. + pub(super) fn eq_refusal(&self, index: usize, writes: &[(SscPath, Value)]) -> Option { + let catalog = Catalog::embedded(); + let speaker = self.eq_speaker(index)?; + writes.iter().find_map(|(path, value)| { + let refusal = writes::check(speaker, catalog, path, value).err()?; + let label = leaf_label(catalog, path); + Some(texts::eq_leaf_refusal( + &label, + &texts::describe_refusal(&refusal), + )) + }) + } + fn delete_eq_preset(&mut self, index: usize) { if let Some(place) = self.selected_eq_preset(index) { self.settings.eq_presets.remove(place); @@ -535,7 +611,7 @@ impl NeumannApp { /// The chosen container's `leaf` and its band count, once the speaker reported it. fn chosen_leaf(&self, index: usize, leaf: Leaf) -> Option<(SscPath, usize)> { let (container, _) = self.chosen_container(index)?; - let speaker = self.speaker_of(&self.eq_windows[index].key)?; + let speaker = self.eq_speaker(index)?; let path = leaf.path(&container); let length = speaker .values @@ -584,17 +660,19 @@ impl NeumannApp { speaker.handle.send(command); Ok(()) } - Err(refusal) => { - let label = paths::catalog_entry(catalog, path).map_or_else( - || path.to_string(), - |entry| entry.label(texts::language()).to_owned(), - ); - Err((label, texts::describe_refusal(&refusal))) - } + Err(refusal) => Err((leaf_label(catalog, path), texts::describe_refusal(&refusal))), } } } +/// A leaf's name as the window shows it. +fn leaf_label(catalog: &Catalog, path: &SscPath) -> String { + paths::catalog_entry(catalog, path).map_or_else( + || path.to_string(), + |entry| entry.label(texts::language()).to_owned(), + ) +} + /// Whether every change of `entry` is an EQ write to the speaker `key`. fn is_eq_entry_of(entry: &Entry, key: &SpeakerKey, catalog: &Catalog) -> bool { entry diff --git a/crates/app/src/app/eq_files.rs b/crates/app/src/app/eq_files.rs new file mode 100644 index 0000000..cfaea8a --- /dev/null +++ b/crates/app/src/app/eq_files.rs @@ -0,0 +1,168 @@ +//! The EQ window's import and export of filter files (Equalizer APO, REW): reading and writing +//! the file here, the format in `eq_file`, the preview and the write as for an EQ preset. + +use std::fs; +use std::path::Path; + +use neumann_control_core::catalog::Catalog; +use neumann_control_core::settings::EqPreset; +use neumann_control_core::ssc::wire::SscPath; +use serde_json::Value; + +use super::eq::PendingImport; +use super::NeumannApp; +use crate::eq::bands_of; +use crate::eq_file::{self, MAX_FILE_BYTES}; +use crate::presets::eq_plan; +use crate::texts; + +/// Characters Windows does not allow in a file name; a container label like "A/B" has some. +const FORBIDDEN_IN_FILE_NAMES: [char; 9] = ['\\', '/', ':', '*', '?', '"', '<', '>', '|']; +const FILE_NAME_REPLACEMENT: char = '-'; +const FILE_EXTENSION: &str = "txt"; + +impl NeumannApp { + /// Asks for a file and shows what it would change; nothing is written yet. + pub(super) fn import_eq_file(&mut self, index: usize) { + let Some(path) = self.file_dialog.open() else { + return; + }; + let file = file_name(&path); + let pending = match self.imported_preset(index, &path, &file) { + Ok((preset, notes)) => { + let refusal = self.import_refusal(index, &preset); + PendingImport { + file, + preset: Some(preset), + notes, + refusal, + } + } + Err(refusal) => PendingImport { + file, + preset: None, + notes: Vec::new(), + refusal: Some(refusal), + }, + }; + let window = &mut self.eq_windows[index]; + window.file_report.clear(); + window.import = Some(pending); + } + + /// The container after the import, with the notes on what the file holds beyond it. + fn imported_preset( + &self, + index: usize, + path: &Path, + file: &str, + ) -> Result<(EqPreset, Vec), String> { + let text = read_text(path, file)?; + let imported = eq_file::parse(&text).map_err(|error| texts::eq_import_error(&error))?; + let current = self + .chosen_container(index) + .and_then(|(container, _)| { + let speaker = self.eq_speaker(index)?; + bands_of(&speaker.values, &container) + }) + .ok_or_else(|| texts::EQ_NOT_REPORTED.get().to_owned())?; + let preset = eq_file::imported_preset(file, &imported, ¤t) + .map_err(|error| texts::eq_import_error(&error))?; + let mut notes = Vec::new(); + if !imported.ignored.is_empty() { + notes.push(texts::eq_import_ignored(&imported.ignored)); + } + Ok((preset, notes)) + } + + /// Why the speaker would refuse the import, checked before anything is written. + fn import_refusal(&self, index: usize, preset: &EqPreset) -> Option { + let writes = self.import_writes(index, preset); + self.eq_refusal(index, &writes) + } + + fn import_writes(&self, index: usize, preset: &EqPreset) -> Vec<(SscPath, Value)> { + let Some((container, _)) = self.chosen_container(index) else { + return Vec::new(); + }; + let Some(speaker) = self.eq_speaker(index) else { + return Vec::new(); + }; + eq_plan(preset, &speaker.values, &container).writes + } + + /// The previewed import as one history entry; the speaker's values are compared again, so + /// only what still differs is written. + pub(super) fn write_eq_import(&mut self, index: usize, now: f64) { + let Some(pending) = self.eq_windows[index].import.take() else { + return; + }; + let Some(preset) = pending.preset else { + return; + }; + let writes = self.import_writes(index, &preset); + self.write_eq_leaves(index, writes, now); + } + + /// Asks where to save the chosen container as APO filter lines and says what was left out. + pub(super) fn export_eq_file(&mut self, index: usize) { + let Some((suggested, export)) = self.eq_export(index) else { + return; + }; + let Some(path) = self.file_dialog.save(&suggested) else { + return; + }; + let file = file_name(&path); + let report = match fs::write(&path, export.text) { + Ok(()) => texts::eq_export_report(&file, &export.notes), + Err(error) => texts::eq_file_unwritable(&file, &error.to_string()), + }; + self.eq_windows[index].file_report = report; + } + + /// The file name to suggest and the export of the chosen container. + fn eq_export(&self, index: usize) -> Option<(String, eq_file::Export)> { + let catalog = Catalog::embedded(); + let (container, _) = self.chosen_container(index)?; + let speaker = self.eq_speaker(index)?; + let bands = bands_of(&speaker.values, &container)?; + let label = catalog + .eq_containers(speaker.product()) + .into_iter() + .find(|known| known.path == container) + .map_or_else( + || container.to_string(), + |known| known.label(texts::language()).to_owned(), + ); + let suggested = safe_file_name(&format!("{} {label}.{FILE_EXTENSION}", speaker.product())); + Some((suggested, eq_file::export(&bands, &label))) + } +} + +fn read_text(path: &Path, file: &str) -> Result { + let unreadable = |error: std::io::Error| texts::eq_file_unreadable(file, &error.to_string()); + let size = fs::metadata(path).map_err(unreadable)?.len(); + if size > MAX_FILE_BYTES { + return Err(texts::EQ_FILE_TOO_LARGE.get().to_owned()); + } + let bytes = fs::read(path).map_err(unreadable)?; + // Filter files are ASCII; an editor's other code page must not refuse the whole file. + Ok(String::from_utf8_lossy(&bytes).into_owned()) +} + +fn file_name(path: &Path) -> String { + path.file_name() + .map_or_else(|| path.to_string_lossy(), |name| name.to_string_lossy()) + .into_owned() +} + +fn safe_file_name(name: &str) -> String { + name.chars() + .map(|character| { + if FORBIDDEN_IN_FILE_NAMES.contains(&character) { + return FILE_NAME_REPLACEMENT; + } + character + }) + .collect() +} diff --git a/crates/app/src/eq_file.rs b/crates/app/src/eq_file.rs new file mode 100644 index 0000000..65b7391 --- /dev/null +++ b/crates/app/src/eq_file.rs @@ -0,0 +1,646 @@ +//! EQ filters as text files: the filter lines of Equalizer APO's `config.txt`, which are also +//! what Room EQ Wizard exports for its "Generic" equaliser (`Filter 1: ON PK Fc 100 Hz Gain -3.0 +//! dB Q 1.41`). Import maps them onto a container's bands; export writes a container as APO lines. +//! Shelves are left out both ways until it is measured how the speakers define their Q. + +use std::collections::BTreeMap; + +use neumann_control_core::settings::EqPreset; +use serde_json::Value; + +use crate::eq::{Band, Leaf}; + +/// Bigger than any filter file; a larger file is not one. +pub const MAX_FILE_BYTES: u64 = 1024 * 1024; + +const COMMENT: char = '#'; +const FILTER_COMMAND: &str = "filter"; +const BYTE_ORDER_MARK: char = '\u{feff}'; +/// APO commands that change the sound but have no place in one EQ container; anything else +/// before a colon (REW's header lines `Dated:`, `Notes:` ...) is passed over silently. +const SOUND_COMMANDS: [&str; 14] = [ + "preamp", + "channel", + "include", + "graphiceq", + "device", + "delay", + "copy", + "convolution", + "stage", + "eval", + "if", + "elseif", + "else", + "loudnesscorrection", +]; + +/// A filter as the speaker takes it: `boost` is the filter's height, `gain` stays 0. +#[derive(Clone, Debug, PartialEq)] +pub struct Filter { + pub kind: &'static str, + pub frequency: f64, + pub boost: f64, + pub q: f64, +} + +/// What a file holds: per band (zero-based) a filter, or `None` for a band the file switches +/// off; and the sound commands that were not taken over. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Imported { + pub bands: BTreeMap>, + pub ignored: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum ImportError { + /// `line` is one-based. + Line { + line: usize, + problem: LineProblem, + }, + NoFilters, + /// The file names a band the container does not have (one-based). + TooManyBands { + band: usize, + bands: usize, + }, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum LineProblem { + UnknownType(String), + ShelfNotSupported(String), + Missing(Parameter), + BadNumber(String), + UnknownState(String), + /// A second line for the same band (one-based). + SameBand(usize), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Parameter { + Frequency, + Gain, + Q, +} + +/// Which parameters a file type needs, and the speaker's type for it. +struct FileType { + kind: &'static str, + gain: bool, +} + +fn file_type(token: &str) -> Result, LineProblem> { + let upper = token.to_ascii_uppercase(); + let shaped = |kind, gain| Ok(Some(FileType { kind, gain })); + match upper.as_str() { + "PK" | "PEQ" | "MODAL" => shaped("PARAMETRIC", true), + "LP" | "LPQ" => shaped("LOWPASS", false), + "HP" | "HPQ" => shaped("HIGHPASS", false), + "BP" => shaped("BANDPASS", false), + "NO" => shaped("NOTCH", false), + "AP" => shaped("ALLPASS", false), + "NONE" => Ok(None), + "LS" | "HS" | "LSC" | "HSC" => Err(LineProblem::ShelfNotSupported(token.to_owned())), + _ => Err(LineProblem::UnknownType(token.to_owned())), + } +} + +/// The filters of an APO or REW text. Filter lines with a number go to that band, lines +/// without one to the band after the previous line. +pub fn parse(text: &str) -> Result { + let mut imported = Imported::default(); + let mut next_band = 0; + let text = text.trim_start_matches(BYTE_ORDER_MARK); + for (place, line) in text.lines().enumerate() { + let line = line.trim(); + if line.starts_with(COMMENT) { + continue; + } + let Some((command, rest)) = line.split_once(':') else { + continue; + }; + let command = command.trim(); + let lower = command.to_ascii_lowercase(); + let failed = |problem| ImportError::Line { + line: place + 1, + problem, + }; + let Some(number) = lower.strip_prefix(FILTER_COMMAND) else { + note_ignored(&mut imported, command, &lower); + continue; + }; + let band = match number.trim() { + "" => next_band, + given => band_number(given).map_err(failed)?, + }; + if imported.bands.contains_key(&band) { + return Err(failed(LineProblem::SameBand(band + 1))); + } + imported + .bands + .insert(band, filter_of(rest).map_err(failed)?); + next_band = band + 1; + } + if imported.bands.is_empty() { + return Err(ImportError::NoFilters); + } + Ok(imported) +} + +fn note_ignored(imported: &mut Imported, command: &str, lower: &str) { + let sounds = SOUND_COMMANDS.contains(&lower); + let known = imported + .ignored + .iter() + .any(|seen| seen.eq_ignore_ascii_case(command)); + if sounds && !known { + imported.ignored.push(command.to_owned()); + } +} + +/// Zero-based band from the one-based number after `Filter`. +fn band_number(given: &str) -> Result { + let number: usize = given + .parse() + .map_err(|_| LineProblem::BadNumber(given.to_owned()))?; + number + .checked_sub(1) + .ok_or_else(|| LineProblem::BadNumber(given.to_owned())) +} + +/// `ON PK Fc 100 Hz Gain -3 dB Q 1.4`; `None` for a switched-off or empty filter. +fn filter_of(rest: &str) -> Result, LineProblem> { + let tokens: Vec<&str> = rest.split_whitespace().collect(); + let Some((state, tokens)) = tokens.split_first() else { + return Err(LineProblem::UnknownState(String::new())); + }; + match state.to_ascii_uppercase().as_str() { + "ON" => {} + "OFF" => return Ok(None), + _ => return Err(LineProblem::UnknownState((*state).to_owned())), + } + let Some((kind, parameters)) = tokens.split_first() else { + return Err(LineProblem::UnknownType(String::new())); + }; + let Some(file_type) = file_type(kind)? else { + return Ok(None); + }; + let values = Parameters::read(parameters)?; + let frequency = values + .frequency + .ok_or(LineProblem::Missing(Parameter::Frequency))?; + let mut boost = 0.0; + if file_type.gain { + boost = values.gain.ok_or(LineProblem::Missing(Parameter::Gain))?; + } + let q = values.q.ok_or(LineProblem::Missing(Parameter::Q))?; + Ok(Some(Filter { + kind: file_type.kind, + frequency, + boost, + q, + })) +} + +#[derive(Default)] +struct Parameters { + frequency: Option, + gain: Option, + q: Option, +} + +impl Parameters { + /// `Fc Hz`, `Gain dB`, `Q ` or `BW Oct `, in any order; other words (units, + /// Modal's `T60 target 100 ms`) are passed over. + fn read(tokens: &[&str]) -> Result { + let mut read = Parameters::default(); + let mut place = 0; + while place < tokens.len() { + let word = tokens[place].to_ascii_uppercase(); + let value_at = |offset: usize| tokens.get(place + offset).map(|token| number(token)); + match word.as_str() { + "FC" => read.frequency = value_at(1).transpose()?, + "GAIN" => read.gain = value_at(1).transpose()?, + "Q" => read.q = value_at(1).transpose()?, + "BW" if tokens + .get(place + 1) + .is_some_and(|unit| unit.eq_ignore_ascii_case("oct")) => + { + read.q = value_at(2).transpose()?.map(q_of_octaves); + } + _ => {} + } + place += 1; + } + Ok(read) + } +} + +/// A number with a point or a comma, a unit glued on or not (`129.1Hz`, `-3,5`). +fn number(token: &str) -> Result { + let bare = token + .trim_end_matches(|letter: char| letter.is_ascii_alphabetic()) + .replace(',', "."); + bare.parse::() + .ok() + .filter(|value| value.is_finite()) + .ok_or_else(|| LineProblem::BadNumber(token.to_owned())) +} + +/// The Q of a bandwidth in octaves (RBJ cookbook). +fn q_of_octaves(octaves: f64) -> f64 { + let ratio = 2f64.powf(octaves); + ratio.sqrt() / (ratio - 1.0) +} + +/// The container after the import: the file's bands as given, every other band switched off +/// with its other settings kept. `current` is the container as the speaker reports it. +pub fn imported_preset( + name: &str, + imported: &Imported, + current: &[Band], +) -> Result { + let bands = current.len(); + if let Some(last) = imported + .bands + .keys() + .next_back() + .filter(|last| **last >= bands) + { + return Err(ImportError::TooManyBands { + band: last + 1, + bands, + }); + } + let result: Vec = current + .iter() + .enumerate() + .map(|(place, band)| match imported.bands.get(&place) { + Some(Some(filter)) => Band { + enabled: true, + gain: 0.0, + boost: filter.boost, + frequency: filter.frequency, + q: filter.q, + kind: filter.kind.to_owned(), + }, + _ => Band { + enabled: false, + ..band.clone() + }, + }) + .collect(); + let leaves = Leaf::ALL + .iter() + .map(|leaf| (leaf.name().to_owned(), leaf_array(*leaf, &result))) + .collect(); + Ok(EqPreset { + name: name.to_owned(), + bands, + leaves, + }) +} + +fn leaf_array(leaf: Leaf, bands: &[Band]) -> Value { + let entry = |band: &Band| match leaf { + Leaf::Enabled => Value::Bool(band.enabled), + Leaf::Gain => Value::from(band.gain), + Leaf::Boost => Value::from(band.boost), + Leaf::Frequency => Value::from(band.frequency), + Leaf::Q => Value::from(band.q), + Leaf::Type => Value::from(band.kind.clone()), + }; + Value::Array(bands.iter().map(entry).collect()) +} + +/// A band the export could not write as it is (one-based numbers). +#[derive(Clone, Debug, PartialEq)] +pub enum ExportNote { + Off(usize), + /// The band's flat level, which APO filter lines cannot carry. + FlatLevel { + band: usize, + gain: f64, + }, + NotExportable { + band: usize, + kind: String, + }, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Export { + pub text: String, + pub notes: Vec, +} + +/// APO's token for a speaker type, and whether its line carries a gain. +fn apo_type(kind: &str) -> Option<(&'static str, bool)> { + match kind { + "PARAMETRIC" => Some(("PK", true)), + "LOWPASS" => Some(("LPQ", false)), + "HIGHPASS" => Some(("HPQ", false)), + "BANDPASS" => Some(("BP", false)), + "NOTCH" => Some(("NO", false)), + "ALLPASS" => Some(("AP", false)), + _ => None, + } +} + +/// The bands as APO filter lines, each with its band number so a re-import puts it back in its +/// place; what has no APO line becomes a comment and a note. +pub fn export(bands: &[Band], container: &str) -> Export { + let mut lines = vec![format!( + "{COMMENT} Neumann Control EQ export: {container}, {} bands", + bands.len() + )]; + let mut notes = Vec::new(); + for (place, band) in bands.iter().enumerate() { + let number = place + 1; + if !band.enabled { + lines.push(format!("{COMMENT} Filter {number}: off")); + notes.push(ExportNote::Off(number)); + continue; + } + let Some((token, gain)) = apo_type(&band.kind) else { + lines.push(format!( + "{COMMENT} Filter {number}: {} not exported", + band.kind + )); + notes.push(ExportNote::NotExportable { + band: number, + kind: band.kind.clone(), + }); + continue; + }; + lines.push(filter_line(number, token, gain, band)); + if band.gain != 0.0 { + lines.push(format!( + "{COMMENT} Filter {number}: flat level {} dB not exported", + trimmed(band.gain) + )); + notes.push(ExportNote::FlatLevel { + band: number, + gain: band.gain, + }); + } + } + lines.push(String::new()); + Export { + text: lines.join("\r\n"), + notes, + } +} + +fn filter_line(number: usize, token: &str, gain: bool, band: &Band) -> String { + let mut line = format!( + "Filter {number}: ON {token} Fc {} Hz", + trimmed(band.frequency) + ); + if gain { + line.push_str(&format!(" Gain {} dB", trimmed(band.boost))); + } + line.push_str(&format!(" Q {}", trimmed(band.q))); + line +} + +/// Up to three decimals with a point, no trailing zeros and no `-0`. +fn trimmed(value: f64) -> String { + let text = format!("{value:.3}"); + let text = text.trim_end_matches('0').trim_end_matches('.'); + match text { + "-0" => "0".to_owned(), + other => other.to_owned(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn band(kind: &str, boost: f64, frequency: f64, q: f64) -> Band { + Band { + enabled: true, + gain: 0.0, + boost, + frequency, + q, + kind: kind.to_owned(), + } + } + + fn filter(kind: &'static str, frequency: f64, boost: f64, q: f64) -> Option { + Some(Filter { + kind, + frequency, + boost, + q, + }) + } + + fn problem_of(text: &str) -> LineProblem { + match parse(text) { + Err(ImportError::Line { problem, .. }) => problem, + other => panic!("expected a line problem, got {other:?}"), + } + } + + #[test] + fn an_apo_peak_line_becomes_a_parametric_band_with_its_gain_as_boost() { + let imported = parse("Filter 1: ON PK Fc 100 Hz Gain -3.0 dB Q 1.41").unwrap(); + assert_eq!(imported.bands[&0], filter("PARAMETRIC", 100.0, -3.0, 1.41)); + } + + #[test] + fn aligned_columns_glued_units_and_a_decimal_comma_are_read() { + let text = "Filter 2: ON PK Fc 129,1Hz Gain -18,5dB Q 4,00"; + let imported = parse(text).unwrap(); + assert_eq!(imported.bands[&1], filter("PARAMETRIC", 129.1, -18.5, 4.0)); + } + + #[test] + fn filters_without_number_follow_the_previous_band() { + let text = "Filter 3: ON NO Fc 50 Hz Q 2\nFilter: ON AP Fc 900 Hz Q 0.707\n"; + let imported = parse(text).unwrap(); + assert_eq!(imported.bands[&2], filter("NOTCH", 50.0, 0.0, 2.0)); + assert_eq!(imported.bands[&3], filter("ALLPASS", 900.0, 0.0, 0.707)); + } + + #[test] + fn a_bandwidth_in_octaves_becomes_its_q() { + let imported = parse("Filter: ON PEQ Fc 100 Hz Gain 1.0 dB BW Oct 1").unwrap(); + let q = imported.bands[&0].as_ref().unwrap().q; + assert!((q - std::f64::consts::SQRT_2).abs() < 1e-9, "{q}"); + } + + #[test] + fn none_and_off_switch_their_band_off() { + let imported = parse("Filter 1: ON None\nFilter 2: OFF PK Fc 1 Hz").unwrap(); + assert_eq!(imported.bands[&0], None); + assert_eq!(imported.bands[&1], None); + } + + #[test] + fn preamp_and_other_sound_commands_are_named_and_rew_headers_are_not() { + let text = "\u{feff}Filter Settings file\r\nDated: 1-Jan-2026 10:00:00\r\nNotes:x\r\n\ + Equaliser: Generic\r\nPreamp: -6 dB\r\nChannel: L\r\n# Filter 9: ON PK\r\n\ + Filter 1: ON HPQ Fc 30 Hz Q 0.5\r\n"; + let imported = parse(text).unwrap(); + assert_eq!(imported.ignored, ["Preamp", "Channel"]); + assert_eq!(imported.bands.len(), 1); + assert_eq!(imported.bands[&0], filter("HIGHPASS", 30.0, 0.0, 0.5)); + } + + #[test] + fn shelves_are_refused_until_measured() { + for line in [ + "Filter: ON LSC Fc 80 Hz Gain 4 dB Q 0.7", + "Filter: ON HS 6dB Fc 1 Hz", + ] { + assert!( + matches!(problem_of(line), LineProblem::ShelfNotSupported(_)), + "{line}" + ); + } + } + + #[test] + fn an_unknown_type_refuses_the_whole_file_with_its_line() { + let text = "Filter 1: ON PK Fc 100 Hz Gain 1 dB Q 1\nFilter 2: ON IIR Order 2"; + assert_eq!( + parse(text), + Err(ImportError::Line { + line: 2, + problem: LineProblem::UnknownType("IIR".to_owned()), + }) + ); + } + + #[test] + fn a_missing_q_or_gain_is_refused_rather_than_guessed() { + assert_eq!( + problem_of("Filter: ON LP Fc 8000 Hz"), + LineProblem::Missing(Parameter::Q) + ); + assert_eq!( + problem_of("Filter: ON PK Fc 100 Hz Q 1"), + LineProblem::Missing(Parameter::Gain) + ); + } + + #[test] + fn a_bad_number_and_a_second_line_for_a_band_are_refused() { + assert_eq!( + problem_of("Filter: ON PK Fc abc Hz Gain 1 dB Q 1"), + LineProblem::BadNumber("abc".to_owned()) + ); + let twice = "Filter 1: ON None\nFilter 1: ON None"; + assert_eq!(problem_of(twice), LineProblem::SameBand(1)); + } + + #[test] + fn a_file_without_filters_is_refused() { + assert_eq!(parse("Preamp: -3 dB\n"), Err(ImportError::NoFilters)); + } + + #[test] + fn the_import_switches_the_other_bands_off_and_keeps_their_settings() { + let current = vec![ + band("NOTCH", 2.0, 50.0, 3.0), + band("LOSHELF", 4.0, 80.0, 0.7), + ]; + let imported = parse("Filter 1: ON PK Fc 100 Hz Gain -3 dB Q 1.4").unwrap(); + let preset = imported_preset("room.txt", &imported, ¤t).unwrap(); + assert_eq!(preset.bands, 2); + assert_eq!(preset.leaves["enabled"], serde_json::json!([true, false])); + assert_eq!( + preset.leaves["type"], + serde_json::json!(["PARAMETRIC", "LOSHELF"]) + ); + assert_eq!(preset.leaves["boost"], serde_json::json!([-3.0, 4.0])); + assert_eq!(preset.leaves["gain"], serde_json::json!([0.0, 0.0])); + assert_eq!(preset.leaves["q"], serde_json::json!([1.4, 0.7])); + } + + #[test] + fn more_bands_than_the_container_has_are_refused() { + let current = vec![band("PARAMETRIC", 0.0, 100.0, 0.7); 2]; + let imported = parse("Filter 3: ON NO Fc 50 Hz Q 2").unwrap(); + assert_eq!( + imported_preset("x", &imported, ¤t), + Err(ImportError::TooManyBands { band: 3, bands: 2 }) + ); + } + + #[test] + fn the_export_writes_numbered_apo_lines_and_comments_what_it_cannot() { + let mut flat = band("PARAMETRIC", -3.0, 1230.0, 0.7); + flat.gain = 1.5; + let mut off = band("NOTCH", 0.0, 50.0, 2.0); + off.enabled = false; + let bands = vec![ + flat, + off, + band("LOSHELF", 4.0, 80.0, 0.7), + band("HIGHPASS", 0.0, 30.5, 0.5), + ]; + let export = export(&bands, "EQ 3"); + let lines: Vec<&str> = export.text.lines().collect(); + assert_eq!( + lines, + [ + "# Neumann Control EQ export: EQ 3, 4 bands", + "Filter 1: ON PK Fc 1230 Hz Gain -3 dB Q 0.7", + "# Filter 1: flat level 1.5 dB not exported", + "# Filter 2: off", + "# Filter 3: LOSHELF not exported", + "Filter 4: ON HPQ Fc 30.5 Hz Q 0.5", + ] + ); + assert_eq!( + export.notes, + [ + ExportNote::FlatLevel { band: 1, gain: 1.5 }, + ExportNote::Off(2), + ExportNote::NotExportable { + band: 3, + kind: "LOSHELF".to_owned() + }, + ] + ); + } + + #[test] + fn an_exported_container_reads_back_as_the_same_bands() { + let mut off = band("PARAMETRIC", 0.0, 100.0, 0.7); + off.enabled = false; + let bands = vec![ + band("PARAMETRIC", -4.5, 63.0, 2.5), + off.clone(), + band("LOWPASS", 0.0, 12000.0, 0.707), + band("BANDPASS", 0.0, 500.0, 1.0), + band("NOTCH", 0.0, 50.0, 8.0), + band("ALLPASS", 0.0, 900.0, 0.7), + band("HIGHPASS", 0.0, 25.0, 0.5), + ]; + let text = export(&bands, "EQ 2").text; + let imported = parse(&text).unwrap(); + let preset = imported_preset("back", &imported, &bands).unwrap(); + for leaf in Leaf::ALL { + assert_eq!( + preset.leaves[leaf.name()], + leaf_array(leaf, &bands), + "{leaf:?}" + ); + } + } + + #[test] + fn exported_numbers_never_read_minus_zero() { + assert_eq!(trimmed(-0.0001), "0"); + assert_eq!(trimmed(0.707), "0.707"); + assert_eq!(trimmed(24000.0), "24000"); + } +} diff --git a/crates/app/src/file_dialog.rs b/crates/app/src/file_dialog.rs new file mode 100644 index 0000000..e854ac9 --- /dev/null +++ b/crates/app/src/file_dialog.rs @@ -0,0 +1,57 @@ +//! Choosing a file for the EQ import and export: Windows' own dialog, a stand-in in the tests. + +use std::path::PathBuf; + +pub trait FileDialog { + /// The text file to read; `None` when the user cancels. + fn open(&mut self) -> Option; + /// Where to write `suggested` (a file name); `None` when the user cancels. + fn save(&mut self, suggested: &str) -> Option; +} + +#[cfg(windows)] +pub use native::NativeFileDialog; + +#[cfg(windows)] +mod native { + use std::path::PathBuf; + + use super::FileDialog; + + const TEXT_FILTER_NAME: &str = "Text"; + const TEXT_EXTENSIONS: [&str; 1] = ["txt"]; + + #[derive(Default)] + pub struct NativeFileDialog; + + impl FileDialog for NativeFileDialog { + fn open(&mut self) -> Option { + rfd::FileDialog::new() + .add_filter(TEXT_FILTER_NAME, &TEXT_EXTENSIONS) + .pick_file() + } + + fn save(&mut self, suggested: &str) -> Option { + rfd::FileDialog::new() + .add_filter(TEXT_FILTER_NAME, &TEXT_EXTENSIONS) + .set_file_name(suggested) + .save_file() + } + } +} + +/// Elsewhere there is no dialog: import and export find no file. +#[cfg(not(windows))] +#[derive(Default)] +pub struct NativeFileDialog; + +#[cfg(not(windows))] +impl FileDialog for NativeFileDialog { + fn open(&mut self) -> Option { + None + } + + fn save(&mut self, _suggested: &str) -> Option { + None + } +} diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index a0767b8..745fb88 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -2,8 +2,10 @@ pub mod all_values; pub mod app; pub mod display; pub mod eq; +pub mod eq_file; pub mod eq_response; pub mod fader; +pub mod file_dialog; pub mod gesture; pub mod groups; pub mod history; diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index de2dbfd..5dc639c 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -12,6 +12,7 @@ use neumann_control_core::catalog::ValueError; use neumann_control_core::settings::Language; use serde_json::Value; +use crate::eq_file::{ExportNote, ImportError, LineProblem, Parameter}; use crate::state::Message; use crate::trend::whole_minutes; use crate::writes::Refusal; @@ -190,6 +191,13 @@ pub const EQ_PRESET_SAVE: Text = Text::new("Als EQ-Preset speichern…", "Save a pub const EQ_PRESET_LOAD: Text = Text::new("EQ-Preset laden…", "Load EQ preset…"); pub const EQ_PRESET_SAVE_TITLE: Text = Text::new("EQ als Preset", "EQ as a preset"); pub const EQ_PRESET_LOAD_TITLE: Text = Text::new("EQ-Preset laden", "Load an EQ preset"); +pub const EQ_IMPORT: Text = Text::new("Importieren…", "Import…"); +pub const EQ_EXPORT: Text = Text::new("Exportieren…", "Export…"); +pub const EQ_IMPORT_TITLE: Text = Text::new("EQ importieren", "Import an EQ"); +pub const EQ_FILE_TOO_LARGE: Text = Text::new( + "Die Datei ist zu groß für eine Filterdatei.", + "The file is too large for a filter file.", +); pub const SYSTEM_AUDIO_SHOW: Text = Text::new( "Windows-Lautstärke im Pult zeigen", "Show the Windows volume on the desk", @@ -784,6 +792,124 @@ pub fn eq_refusal(band: usize, leaf: &str, reason: &str) -> String { format!("{} {band} · {leaf}: {reason}", BAND.get()) } +/// The import preview's line naming the file. +pub fn eq_import_from(file: &str) -> String { + pick(format!("Aus {file}"), format!("From {file}")) +} + +/// The sound commands of the file that the import does not take over. +pub fn eq_import_ignored(commands: &[String]) -> String { + let list = commands.join(", "); + pick( + format!("Nicht übernommen: {list}"), + format!("Not taken over: {list}"), + ) +} + +pub fn eq_file_unreadable(file: &str, reason: &str) -> String { + pick( + format!("{file} lässt sich nicht lesen: {reason}"), + format!("{file} cannot be read: {reason}"), + ) +} + +pub fn eq_file_unwritable(file: &str, reason: &str) -> Vec { + vec![pick( + format!("{file} lässt sich nicht schreiben: {reason}"), + format!("{file} cannot be written: {reason}"), + )] +} + +/// Why a filter file cannot be imported. +pub fn eq_import_error(error: &ImportError) -> String { + match error { + ImportError::Line { line, problem } => pick( + format!("Zeile {line}: {}", line_problem(problem)), + format!("Line {line}: {}", line_problem(problem)), + ), + ImportError::NoFilters => Text::new( + "Die Datei enthält keine Filterzeilen.", + "The file holds no filter lines.", + ) + .get() + .to_owned(), + ImportError::TooManyBands { band, bands } => pick( + format!("Die Datei nennt Band {band}, dieses EQ hat {bands} Bänder."), + format!("The file names band {band}, this EQ has {bands} bands."), + ), + } +} + +fn line_problem(problem: &LineProblem) -> String { + match problem { + LineProblem::UnknownType(kind) => pick( + format!("Filtertyp „{kind}“ gibt es am Lautsprecher nicht."), + format!("The speaker has no filter type \"{kind}\"."), + ), + LineProblem::ShelfNotSupported(kind) => pick( + format!("Kuhschwanzfilter („{kind}“) werden noch nicht übernommen."), + format!("Shelving filters (\"{kind}\") are not taken over yet."), + ), + LineProblem::Missing(parameter) => pick( + format!("{} fehlt.", parameter_name(*parameter)), + format!("{} is missing.", parameter_name(*parameter)), + ), + LineProblem::BadNumber(token) => pick( + format!("„{token}“ ist keine Zahl."), + format!("\"{token}\" is not a number."), + ), + LineProblem::UnknownState(state) => pick( + format!("„{state}“ statt ON oder OFF."), + format!("\"{state}\" instead of ON or OFF."), + ), + LineProblem::SameBand(band) => pick( + format!("Band {band} steht schon in einer früheren Zeile."), + format!("Band {band} is already in an earlier line."), + ), + } +} + +fn parameter_name(parameter: Parameter) -> &'static str { + match parameter { + Parameter::Frequency => "Fc", + Parameter::Gain => "Gain", + Parameter::Q => "Q", + } +} + +/// After an export, one line each: where it went, then what it could not carry. +pub fn eq_export_report(file: &str, notes: &[ExportNote]) -> Vec { + let mut lines = vec![pick( + format!("Gespeichert: {file}"), + format!("Saved: {file}"), + )]; + lines.extend(notes.iter().map(export_note)); + lines +} + +fn export_note(note: &ExportNote) -> String { + match note { + ExportNote::Off(band) => pick( + format!("Band {band} ist aus und steht nur als Kommentar in der Datei."), + format!("Band {band} is off and only a comment in the file."), + ), + ExportNote::FlatLevel { band, gain } => pick( + format!( + "Band {band}: Pegel {} dB nicht exportiert (APO kennt ihn nicht).", + one_decimal(*gain) + ), + format!( + "Band {band}: level {} dB not exported (APO has none).", + one_decimal(*gain) + ), + ), + ExportNote::NotExportable { band, kind } => pick( + format!("Band {band}: {kind} nicht exportiert."), + format!("Band {band}: {kind} not exported."), + ), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/app/src/views/eq_window.rs b/crates/app/src/views/eq_window.rs index 9967798..2353e26 100644 --- a/crates/app/src/views/eq_window.rs +++ b/crates/app/src/views/eq_window.rs @@ -2,8 +2,8 @@ //! backend has only one): output and container choice, the graph and the band table. use eframe::egui::{ - self, vec2, Button, Color32, Context, Key, RichText, Ui, ViewportBuilder, ViewportClass, - ViewportId, + self, vec2, Button, Color32, Context, Id, Key, Modal, RichText, ScrollArea, Ui, + ViewportBuilder, ViewportClass, ViewportId, }; use super::eq_graph::{self, Graph, GraphAction}; @@ -16,6 +16,8 @@ use crate::texts; const EQ_WINDOW_SIZE: [f32; 2] = [900.0, 640.0]; const EQ_WINDOW_MIN_SIZE: [f32; 2] = [640.0, 480.0]; const LEGEND_SWATCH: f32 = 10.0; +const IMPORT_ID: &str = "eq_import"; +const IMPORT_LIST_HEIGHT: f32 = 240.0; pub struct EqView<'a> { pub viewport: ViewportId, @@ -41,9 +43,27 @@ pub struct EqView<'a> { pub candidates: &'a [(String, bool)], /// The compared curves that are drawn, for the legend: name and colour. pub compared: &'a [(String, Color32)], - /// Whether the chosen container can be saved as or loaded from a preset. + /// Whether the chosen container can be saved as or loaded from a preset, or imported into. pub presets: bool, pub preset_dialog: Option>, + /// Whether the chosen container can be exported (its bands are reported). + pub exportable: bool, + pub import: Option>, + /// What the last export did, line by line. + pub file_report: &'a [String], +} + +/// The preview of a filter file before it is written. +pub struct ImportView<'a> { + pub file: &'a str, + /// The bands that would change; empty while `refusal` is set. + pub changes: &'a [String], + /// What the file holds that is not taken over. + pub notes: &'a [String], + /// Why the file cannot be written; Write is then disabled. + pub refusal: Option<&'a str>, + /// The container is a crossover (protection off): writing warns. + pub crossover: bool, } pub enum PresetDialogView<'a> { @@ -93,6 +113,10 @@ pub enum EqAction { PresetWrite, PresetDelete, PresetClose, + Import, + ImportWrite, + ImportCancel, + Export, } pub fn show(context: &Context, view: &EqView) -> Vec { @@ -166,19 +190,29 @@ fn contents(ui: &mut Ui, view: &EqView) -> Vec { ui.colored_label(ui.visuals().warn_fg_color, notice); } actions.extend(edit_row(ui, view.reset.as_ref(), view.undo.as_deref())); - if view.presets { - ui.horizontal(|ui| { - if ui.button(texts::EQ_PRESET_SAVE).clicked() { - actions.push(EqAction::PresetSave); - } - if ui.button(texts::EQ_PRESET_LOAD).clicked() { - actions.push(EqAction::PresetLoad); - } - }); + ui.horizontal(|ui| { + if view.presets && ui.button(texts::EQ_PRESET_SAVE).clicked() { + actions.push(EqAction::PresetSave); + } + if view.presets && ui.button(texts::EQ_PRESET_LOAD).clicked() { + actions.push(EqAction::PresetLoad); + } + if view.presets && ui.button(texts::EQ_IMPORT).clicked() { + actions.push(EqAction::Import); + } + if view.exportable && ui.button(texts::EQ_EXPORT).clicked() { + actions.push(EqAction::Export); + } + }); + for line in view.file_report { + ui.label(line); } if let Some(dialog) = &view.preset_dialog { actions.extend(preset_dialog(ui, dialog)); } + if let Some(import) = &view.import { + actions.extend(import_preview(ui, import)); + } if let Some(lines) = &view.question { actions.extend(question(ui, lines)); } @@ -236,6 +270,50 @@ fn preset_dialog(ui: &mut Ui, dialog: &PresetDialogView) -> Option { } } +fn import_preview(ui: &mut Ui, import: &ImportView) -> Option { + let modal = Modal::new(Id::new(IMPORT_ID)).show(ui.ctx(), |ui| { + ui.heading(texts::EQ_IMPORT_TITLE); + ui.label(texts::eq_import_from(import.file)); + if let Some(refusal) = import.refusal { + ui.colored_label(ui.visuals().warn_fg_color, refusal); + } + let unchanged = import.refusal.is_none() && import.changes.is_empty(); + if unchanged { + ui.weak(texts::PRESET_UNCHANGED); + } + ScrollArea::vertical() + .max_height(IMPORT_LIST_HEIGHT) + .show(ui, |ui| { + for change in import.changes { + ui.label(change); + } + }); + for note in import.notes { + ui.weak(note); + } + if import.crossover { + ui.colored_label(ui.visuals().warn_fg_color, texts::CROSSOVER_WARNING); + } + ui.horizontal(|ui| { + let writable = import.refusal.is_none() && !import.changes.is_empty(); + if ui + .add_enabled(writable, Button::new(texts::WRITE_PRESET)) + .clicked() + { + return Some(EqAction::ImportWrite); + } + ui.button(texts::CANCEL) + .clicked() + .then_some(EqAction::ImportCancel) + }) + .inner + }); + if modal.should_close() { + return Some(EqAction::ImportCancel); + } + modal.inner +} + fn compare_row(ui: &mut Ui, candidates: &[(String, bool)]) -> Option { if candidates.is_empty() { return None; diff --git a/crates/app/tests/eq.rs b/crates/app/tests/eq.rs index 9732946..d890f8b 100644 --- a/crates/app/tests/eq.rs +++ b/crates/app/tests/eq.rs @@ -7,6 +7,7 @@ use eframe::egui::{self, accesskit::Role}; use egui_kittest::kittest::{NodeT, Queryable}; use egui_kittest::Harness; use neumann_control_app::app::NeumannApp; +use neumann_control_app::eq_file; use neumann_control_app::texts; use neumann_control_app::writes::Refusal; use neumann_control_core::catalog::ValueError; @@ -711,3 +712,151 @@ fn a_full_reset_is_not_offered_on_a_protected_crossover() { .query_by_role_and_label(Role::Button, texts::EQ_FULL_RESET.get()) .is_none()); } + +/// The EQ window's file dialog hands out `files` from now on. +fn use_files(harness: &mut Harness<'static, NeumannApp>, files: &FakeFiles) { + harness.state_mut().set_file_dialog(Box::new(files.clone())); +} + +/// Writes `text` as `name` into `directory` and imports it. +fn import_file( + harness: &mut Harness<'static, NeumannApp>, + directory: &tempfile::TempDir, + name: &str, + text: &str, +) { + let path = directory.path().join(name); + std::fs::write(&path, text).unwrap(); + use_files( + harness, + &FakeFiles { + open: Some(path), + ..FakeFiles::default() + }, + ); + harness + .get_by_role_and_label(Role::Button, texts::EQ_IMPORT.get()) + .click(); + // The preview is a modal; it centres itself over a few frames. + harness.run_steps(6); +} + +#[test] +fn an_apo_file_is_previewed_then_written_as_one_history_entry() { + // The whole frequency list is written, so every band must be in the speakers' range (the + // spread's tenth band is above it). + let (simulator, directory, mut harness) = open_eq_with(SimModel::Kh120Ii, |simulator| { + let frequencies: Vec = (0..10).map(|band| 40.0 * 2f64.powi(band)).collect(); + simulator.set_value("audio/out/eq2/frequency", json!(frequencies)); + }); + let text = "Preamp: -3 dB\r\nFilter 1: ON PK Fc 120 Hz Gain -4 dB Q 2\r\n"; + import_file(&mut harness, &directory, "room.txt", text); + harness.get_by_label(&texts::eq_import_from("room.txt")); + harness.get_by_label(&texts::eq_import_ignored(&["Preamp".to_owned()])); + harness.get_by_label(&texts::eq_preset_band(1)); + harness.get_by_label(&texts::eq_preset_band(10)); + barrier(&simulator); + assert!(sets(&simulator, "/audio/out/eq2/enabled").is_empty()); + harness + .get_by_role_and_label(Role::Button, texts::WRITE_PRESET.get()) + .click(); + wait_until(&mut harness, "the import written", |_| { + !sets(&simulator, BOOST_POINTER).is_empty() + }); + let mut enabled = vec![false; 10]; + enabled[0] = true; + assert_eq!( + sets(&simulator, "/audio/out/eq2/enabled"), + vec![json!(enabled)] + ); + assert_eq!(sets(&simulator, BOOST_POINTER)[0][0], json!(-4.0)); + assert_eq!(sets(&simulator, FREQUENCY_POINTER)[0][0], json!(120.0)); + let history: Vec<_> = harness.state().history().entries().cloned().collect(); + assert_eq!(history.len(), 1); + assert!(history[0].changes[0].path.to_string().ends_with("enabled")); +} + +#[test] +fn a_file_the_speaker_would_refuse_writes_nothing() { + let (simulator, directory, mut harness) = open_eq(SimModel::Kh120Ii); + // 5 Hz is below the speakers' 10 Hz. + let text = "Filter 1: ON PK Fc 5 Hz Gain -4 dB Q 2\n"; + import_file(&mut harness, &directory, "low.txt", text); + let write = harness.get_by_role_and_label(Role::Button, texts::WRITE_PRESET.get()); + assert!(write.accesskit_node().is_disabled()); + harness + .get_by_role_and_label(Role::Button, texts::CANCEL.get()) + .click(); + harness.run_steps(2); + barrier(&simulator); + assert!(sets(&simulator, "/audio/out/eq2/enabled").is_empty()); + assert!(sets(&simulator, FREQUENCY_POINTER).is_empty()); +} + +#[test] +fn a_shelf_in_the_file_is_named_and_refused() { + let (_simulator, directory, mut harness) = open_eq(SimModel::Kh120Ii); + let text = + "Filter 1: ON PK Fc 120 Hz Gain -4 dB Q 2\nFilter 2: ON LSC Fc 80 Hz Gain 3 dB Q 0.7\n"; + import_file(&mut harness, &directory, "shelf.txt", text); + let error = eq_file::ImportError::Line { + line: 2, + problem: eq_file::LineProblem::ShelfNotSupported("LSC".to_owned()), + }; + harness.get_by_label(&texts::eq_import_error(&error)); + let write = harness.get_by_role_and_label(Role::Button, texts::WRITE_PRESET.get()); + assert!(write.accesskit_node().is_disabled()); +} + +#[test] +fn import_is_not_offered_on_a_protected_crossover_but_export_is() { + let (_simulator, _directory, mut harness) = open_eq(SimModel::Kh750); + harness.get_by_role_and_label(Role::Button, texts::EQ_IMPORT.get()); + harness.get_by_label("Weiche A").click(); + harness.run_steps(2); + assert!(harness + .query_by_role_and_label(Role::Button, texts::EQ_IMPORT.get()) + .is_none()); + harness.get_by_role_and_label(Role::Button, texts::EQ_EXPORT.get()); +} + +#[test] +fn the_export_saves_the_chosen_eq_as_apo_lines() { + let (_simulator, directory, mut harness) = open_eq(SimModel::Kh120Ii); + let path = directory.path().join("export.txt"); + let files = FakeFiles { + save: Some(path.clone()), + ..FakeFiles::default() + }; + use_files(&mut harness, &files); + harness + .get_by_role_and_label(Role::Button, texts::EQ_EXPORT.get()) + .click(); + harness.run_steps(2); + assert_eq!(*files.suggested.lock().unwrap(), ["KH 120 II EQ 2.txt"]); + let text = std::fs::read_to_string(&path).unwrap(); + assert!( + text.starts_with("# Neumann Control EQ export: EQ 2, 10 bands"), + "{text}" + ); + assert!(text.contains("Filter 1: ON PK Fc 50 Hz"), "{text}"); + harness.get_by_label(&texts::eq_export_report("export.txt", &[])[0]); +} + +#[test] +fn a_cancelled_file_dialog_changes_nothing() { + let (_simulator, _directory, mut harness) = open_eq(SimModel::Kh120Ii); + harness + .get_by_role_and_label(Role::Button, texts::EQ_IMPORT.get()) + .click(); + harness + .get_by_role_and_label(Role::Button, texts::EQ_EXPORT.get()) + .click(); + harness.run_steps(6); + assert!(harness + .query_by_label(texts::EQ_IMPORT_TITLE.get()) + .is_none()); + assert!(harness + .query_by_label_contains(&texts::eq_export_report("", &[])[0]) + .is_none()); +} diff --git a/crates/app/tests/support/mod.rs b/crates/app/tests/support/mod.rs index 5b4c99c..83c1503 100644 --- a/crates/app/tests/support/mod.rs +++ b/crates/app/tests/support/mod.rs @@ -14,6 +14,7 @@ use eframe::egui; use egui_kittest::kittest::Queryable; use egui_kittest::Harness; use neumann_control_app::app::NeumannApp; +use neumann_control_app::file_dialog::FileDialog; use neumann_control_app::spawner::{ScanResult, SpeakerSpawner}; use neumann_control_app::system_audio::{AudioDevice, AudioState, SystemAudio}; use neumann_control_app::texts; @@ -114,16 +115,39 @@ fn build( Harness::builder() .with_size(WINDOW_SIZE) .build_eframe(move |creation_context| { - NeumannApp::new( + let mut app = NeumannApp::new( &creation_context.egui_ctx, settings_path, Box::new(SimulatorSpawner { scan_result }), Box::new(audio), system, - ) + ); + // Never Windows' dialog in a test: it would wait for a click nobody makes. + app.set_file_dialog(Box::new(FakeFiles::default())); + app }) } +/// Stands in for the file dialog: hands out the paths the test chose (`None` is a cancel) and +/// keeps the names the app suggested for saving. +#[derive(Clone, Default)] +pub struct FakeFiles { + pub open: Option, + pub save: Option, + pub suggested: Arc>>, +} + +impl FileDialog for FakeFiles { + fn open(&mut self) -> Option { + self.open.clone() + } + + fn save(&mut self, suggested: &str) -> Option { + self.suggested.lock().unwrap().push(suggested.to_owned()); + self.save.clone() + } +} + /// What the fake playback devices hold and what the app asked of them. #[derive(Default)] pub struct FakeAudioState { From 3e9772fbaa997b6663322a08bc2248361d7b2ebe Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:31:21 +0200 Subject: [PATCH 164/460] Test shelf curves against a measurement --- crates/app/src/eq_response.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crates/app/src/eq_response.rs b/crates/app/src/eq_response.rs index 671b171..ab772d6 100644 --- a/crates/app/src/eq_response.rs +++ b/crates/app/src/eq_response.rs @@ -165,6 +165,29 @@ mod tests { close(band_db(&band("HISHELF", -4.0, 2000.0, 0.7), 24000.0), -4.0); } + /// Measured on a KH 120 II (REW, UMIK-1, 1/12 octave, against the band switched off): the + /// speakers' `q` of a shelf is the cookbook's slope S, so q 2.0 overshoots only slightly. + #[test] + fn a_shelf_q_of_2_overshoots_as_little_as_measured() { + let near = |actual: f64, expected: f64| { + assert!((actual - expected).abs() < 0.3, "{actual} vs {expected}"); + }; + let low = band("LOSHELF", 6.0, 200.0, 2.0); + near(band_db(&low, 100.0), 6.54); + near(band_db(&low, 400.0), -0.50); + let high = band("HISHELF", 6.0, 2000.0, 2.0); + near(band_db(&high, 1000.0), -0.53); + near(band_db(&high, 5657.0), 6.37); + let steepest_without_overshoot = band("LOSHELF", 6.0, 200.0, 1.0); + near(band_db(&steepest_without_overshoot, 100.0), 5.70); + near(band_db(&steepest_without_overshoot, 400.0), 0.41); + } + + #[test] + fn a_shelf_steeper_than_the_cookbook_form_allows_is_drawn_flat() { + close(band_db(&band("HISHELF", 12.0, 2000.0, 16.0), 20000.0), 0.0); + } + #[test] fn low_pass_is_minus_3_db_at_its_frequency_with_q_0_707() { close( From ecc073e78459fc755a9ee097ae1833a346b022cd Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:31:33 +0200 Subject: [PATCH 165/460] Draw shelves with q as the cookbook slope Measured on a KH 120 II with REW: the speakers' shelf q is the RBJ slope S; reading it as Q drew an overshoot the speaker does not have. --- crates/app/src/eq_response.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/app/src/eq_response.rs b/crates/app/src/eq_response.rs index ab772d6..647e6c8 100644 --- a/crates/app/src/eq_response.rs +++ b/crates/app/src/eq_response.rs @@ -48,7 +48,14 @@ fn transfer(band: &Band) -> Option<(f64, Quadratic, Quadratic)> { let a = 10f64.powf(band.boost / 40.0); let resonant = Quadratic::new(1.0, 1.0 / q, 1.0); let first_order = Quadratic::new(0.0, 1.0, 1.0); - let shelf_slope = a.sqrt() / q; + // Measured on the KH 120 II: a shelf's `q` is the cookbook's slope S, not its Q. + let shelf_radicand = (a + 1.0 / a) * (1.0 / q - 1.0) + 2.0; + let is_shelf = matches!(band.kind.as_str(), "LOSHELF" | "HISHELF"); + if is_shelf && shelf_radicand <= 0.0 { + // A slope this steep for this height is outside the cookbook form; not measured. + return None; + } + let shelf_slope = a.sqrt() * shelf_radicand.max(0.0).sqrt(); let shape = match band.kind.as_str() { "PARAMETRIC" => ( 1.0, From 526bf0e41982d54542e429c1851216484c7b4e64 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:32:52 +0200 Subject: [PATCH 166/460] Document the shelf slope --- CHANGELOG.md | 2 ++ README.md | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d74e4f..064bab3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,8 @@ ### Fixed +- The EQ graph drew shelving filters too steep, with an overshoot the speaker does not have: their + Q is the shelf's slope. - A value that rounds to zero showed a minus ("-0 dB"). - Number fields with a converted unit showed the device's unit as well ("0 1/48 kHz ms"). diff --git a/README.md b/README.md index 59b8afd..4864ce0 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,8 @@ tab: "EQ 2", "EQ 3", and on the KH 750 the crossovers of input A and input B ("C - The graph shows every EQ of the output as its own curve and their sum as a thicker line, from 10 Hz to 24 kHz. The curves are computed from the settings, not measured. Each band has two levels: **Level** lifts or lowers every frequency while the band is on, **Boost** is the - height of its filter (the peak or shelf); at the band's frequency both add up. + height of its filter (the peak or shelf); at the band's frequency both add up. For a shelving + filter the speakers' **Q** is its slope (1 is the steepest without overshoot), as measured. - Each band of the chosen EQ that is on is a point in its band's colour with its number inside; bands that are off have none and are switched on in the table. Drag a point to change frequency and boost, turn the mouse wheel over it to change Q. Level is changed in the table From 11c2aac23de2d127016dc984af9b27a1b78b7b78 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:56:20 +0200 Subject: [PATCH 167/460] Simulate the speakers' request token limit Measured on a KH 120 II: requests over 29 JSON tokens are refused with 413. The tests show EQ 3 writes with the transaction id failing as on the speaker. --- crates/app/tests/eq.rs | 33 +++++++++++++++++++++++++ crates/core/src/simulator/device.rs | 13 +++++++++- crates/core/src/ssc/wire.rs | 20 ++++++++++++++++ crates/core/tests/simulator.rs | 37 +++++++++++++++++++++++++++++ crates/core/tests/wire.rs | 19 +++++++++++++++ 5 files changed, 121 insertions(+), 1 deletion(-) diff --git a/crates/app/tests/eq.rs b/crates/app/tests/eq.rs index d890f8b..50112ec 100644 --- a/crates/app/tests/eq.rs +++ b/crates/app/tests/eq.rs @@ -860,3 +860,36 @@ fn a_cancelled_file_dialog_changes_nothing() { .query_by_label_contains(&texts::eq_export_report("", &[])[0]) .is_none()); } + +/// The KH 120 II refuses a request of more than 29 JSON tokens (413), and every write of its +/// 20-band EQ 3 with the transaction id has more: "Reset" there was refused on the speaker. +#[test] +fn a_full_reset_of_the_twenty_band_eq_3_is_taken() { + let (simulator, _directory, mut harness) = open_eq_with(SimModel::Kh120Ii, |simulator| { + simulator.set_value("audio/out/eq3/enabled", json!(vec![true; 20])); + simulator.set_value("audio/out/eq3/q", json!(vec![2.0; 20])); + }); + harness.get_by_label("EQ 3").click(); + harness.run_steps(2); + harness + .get_by_role_and_label(Role::Button, texts::EQ_FULL_RESET.get()) + .click(); + harness.run_steps(2); + harness + .get_by_role_and_label(Role::Button, texts::EQ_FULL_RESET.get()) + .click(); + wait_until(&mut harness, "all six leaves of EQ 3 written", |_| { + ["enabled", "type", "frequency", "q", "gain", "boost"] + .iter() + .all(|leaf| !sets(&simulator, &format!("/audio/out/eq3/{leaf}")).is_empty()) + }); + barrier(&simulator); + assert_eq!( + simulator.value("audio/out/eq3/enabled"), + Some(json!(vec![false; 20])) + ); + assert_eq!( + simulator.value("audio/out/eq3/q"), + Some(json!(vec![0.7; 20])) + ); +} diff --git a/crates/core/src/simulator/device.rs b/crates/core/src/simulator/device.rs index eda2aac..3310a97 100644 --- a/crates/core/src/simulator/device.rs +++ b/crates/core/src/simulator/device.rs @@ -4,7 +4,7 @@ use serde_json::{json, Map, Number, Value}; use super::SimModel; -use crate::ssc::wire::{merge, SscPath}; +use crate::ssc::wire::{merge, token_count, SscPath, MAX_REQUEST_TOKENS}; const OSC_KEY: &str = "osc"; const XID_KEY: &str = "xid"; @@ -51,6 +51,10 @@ const NOT_ACCEPTABLE: ErrorKind = ErrorKind { code: 406, description: "not acceptable", }; +const REQUEST_TOO_LONG: ErrorKind = ErrorKind { + code: 413, + description: "request too long", +}; const PARAMETER_NOT_FOUND: ErrorKind = ErrorKind { code: 454, description: "parameter address not found", @@ -173,6 +177,13 @@ impl SimulatedDevice { } pub fn answer(&mut self, request: &Map) -> Answer { + // Measured: the speaker gives up before it reads the xid, so the refusal carries none. + if token_count(&Value::Object(request.clone())) > MAX_REQUEST_TOKENS { + return Answer { + body: error_reply(&REQUEST_TOO_LONG), + xid: None, + }; + } let xid = request .get(OSC_KEY) .and_then(|osc| osc.get(XID_KEY)) diff --git a/crates/core/src/ssc/wire.rs b/crates/core/src/ssc/wire.rs index 53fece0..2a48487 100644 --- a/crates/core/src/ssc/wire.rs +++ b/crates/core/src/ssc/wire.rs @@ -12,6 +12,11 @@ const XID_KEY: &str = "xid"; const ERROR_KEY: &str = "error"; const DESCRIPTION_KEY: &str = "desc"; +/// The largest request the speakers take, in JSON tokens: every object, array, key and scalar. +/// Measured on a KH 120 II (2026-10-20): 29 tokens taken, 31 and more refused with 413 +/// "request too long", whatever the byte length (855 bytes in 29 tokens were taken). +pub const MAX_REQUEST_TOKENS: usize = 29; + #[derive(Clone, Debug, PartialEq, Eq)] pub enum WireError { InvalidPath, @@ -188,6 +193,21 @@ pub fn request_line(path: &SscPath, value: Value, xid: u64) -> String { request.to_string() } +/// The JSON tokens of a value, as a token-counting parser sees it: the value itself, and for an +/// object each key and member, for an array each entry. +pub fn token_count(value: &Value) -> usize { + match value { + Value::Object(members) => { + 1 + members + .values() + .map(|member| 1 + token_count(member)) + .sum::() + } + Value::Array(entries) => 1 + entries.iter().map(token_count).sum::(), + _ => 1, + } +} + /// A JSON number for a set request; NaN and infinities have no JSON form. pub fn number(value: f64) -> Result { Number::from_f64(value) diff --git a/crates/core/tests/simulator.rs b/crates/core/tests/simulator.rs index c8394a9..c0147db 100644 --- a/crates/core/tests/simulator.rs +++ b/crates/core/tests/simulator.rs @@ -479,3 +479,40 @@ fn a_short_array_is_not_acceptable() { let reply = Client::connect(&simulator).ask(&json!({"audio":{"out":{"eq2":{"gain":[2.0]}}}})); assert_eq!(device_error(&reply).unwrap().code, 406); } + +/// Measured on a KH 120 II (2026-10-20): a request of more than 29 JSON tokens (every object, +/// array, key and scalar) is refused with 413 "request too long", and the reply has no xid. +#[test] +fn a_request_over_the_speakers_token_budget_is_refused_with_413() { + let simulator = start_kh120(); + let mut client = Client::connect(&simulator); + let twenty = json!([ + true, false, false, false, false, false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false + ]); + let taken = client.ask(&json!({"audio":{"out":{"eq3":{"enabled": twenty}}}})); + assert!( + device_error(&taken).is_none(), + "29 tokens are taken: {taken}" + ); + let refused = client.ask(&json!({"osc":{"xid":1},"audio":{"out":{"eq3":{"enabled": twenty}}}})); + let error = device_error(&refused).expect("33 tokens are refused"); + assert_eq!( + (error.code, error.description.as_str()), + (413, "request too long") + ); + assert_eq!(reply_xid(&refused), None); + let ten = json!([true, false, false, false, false, false, false, false, false, false]); + let two_lists = client.ask(&json!({"audio":{"out":{"eq2":{"enabled": ten, "boost": ten}}}})); + assert_eq!( + device_error(&two_lists).map(|error| error.code), + Some(413), + "31 tokens" + ); + let with_mute = client + .ask(&json!({"audio":{"out":{"eq2":{"enabled": ten}, "mute": false}},"osc":{"xid":7}})); + assert!( + device_error(&with_mute).is_none(), + "25 tokens are taken: {with_mute}" + ); +} diff --git a/crates/core/tests/wire.rs b/crates/core/tests/wire.rs index 09bbcce..1f8bda7 100644 --- a/crates/core/tests/wire.rs +++ b/crates/core/tests/wire.rs @@ -164,3 +164,22 @@ fn path_displays_and_serializes_as_string() { assert_eq!(back, parsed); assert!(serde_json::from_str::(r#""a//b""#).is_err()); } + +/// Measured on a KH 120 II: the speaker refuses a request of more than 29 JSON tokens with 413, +/// and a write of a 20-band EQ list with the xid has 33. Without it the line fits; the reply is +/// then matched by its path. +#[test] +fn a_twenty_band_eq_write_leaves_out_the_xid() { + let twenty = Value::Array(vec![Value::Null; 20]); + let line = request_line(&path("audio/out/eq3/enabled"), twenty.clone(), 5); + assert_eq!( + parse_line(line.as_bytes()).unwrap(), + json!({"audio": {"out": {"eq3": {"enabled": twenty}}}}) + ); + let ten = Value::Array(vec![Value::Null; 10]); + let line = request_line(&path("audio/out/eq2/enabled"), ten.clone(), 6); + assert_eq!( + parse_line(line.as_bytes()).unwrap(), + json!({"osc": {"xid": 6}, "audio": {"out": {"eq2": {"enabled": ten}}}}) + ); +} From e34939da7f1dbe41b021d736b96883c10d9a635f Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:58:11 +0200 Subject: [PATCH 168/460] Leave out the xid when it would make a request too long A write of a 20-band EQ list with the transaction id has 33 JSON tokens and the speakers refuse more than 29 (413); without it the reply is matched by its path. --- crates/core/src/ssc/wire.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/core/src/ssc/wire.rs b/crates/core/src/ssc/wire.rs index 2a48487..3d2806c 100644 --- a/crates/core/src/ssc/wire.rs +++ b/crates/core/src/ssc/wire.rs @@ -183,13 +183,17 @@ pub fn parse_line(line: &[u8]) -> Result { } /// Builds a request line without terminator. The transaction id lives under `osc`; a path -/// that itself starts with `osc` shares that object. +/// that itself starts with `osc` shares that object. A request the id would push over +/// `MAX_REQUEST_TOKENS` (a 20-band EQ list) goes without it; its reply is matched by its path. pub fn request_line(path: &SscPath, value: Value, xid: u64) -> String { let nested = path.segments().iter().rev().fold(value, |inner, segment| { Value::Object(Map::from_iter([(segment.clone(), inner)])) }); let mut request = serde_json::json!({ OSC_KEY: { XID_KEY: xid } }); - merge(&mut request, nested); + merge(&mut request, nested.clone()); + if token_count(&request) > MAX_REQUEST_TOKENS { + return nested.to_string(); + } request.to_string() } From 80182f9b53868e07e08bc1404a5cd5d74c354531 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:59:17 +0200 Subject: [PATCH 169/460] Document the EQ 3 write fix --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 064bab3..dbb774e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,8 @@ ### Fixed +- Every write to the KH 120 II's 20-band EQ 3 was refused by the speaker ("code 413"): the + request was one value too long for it. - The EQ graph drew shelving filters too steep, with an overshoot the speaker does not have: their Q is the shelf's slope. - A value that rounds to zero showed a minus ("-0 dB"). From 6691c3ae2afb872d26592635a251afde9efb8fe8 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:22:43 +0200 Subject: [PATCH 170/460] Test every EQ filter type against measurements --- crates/app/src/eq_response.rs | 84 +++++++++++++++++++++++++++++++++-- 1 file changed, 81 insertions(+), 3 deletions(-) diff --git a/crates/app/src/eq_response.rs b/crates/app/src/eq_response.rs index 647e6c8..eb0f238 100644 --- a/crates/app/src/eq_response.rs +++ b/crates/app/src/eq_response.rs @@ -172,12 +172,32 @@ mod tests { close(band_db(&band("HISHELF", -4.0, 2000.0, 0.7), 24000.0), -4.0); } - /// Measured on a KH 120 II (REW, UMIK-1, 1/12 octave, against the band switched off): the - /// speakers' `q` of a shelf is the cookbook's slope S, so q 2.0 overshoots only slightly. + /// How far a computed value may lie from a measured one: the measurements (REW, UMIK-1, + /// 1/12 octave, each against the band switched off) scatter by about 0.1 dB. + const MEASURED_TOLERANCE_DB: f64 = 0.3; + + /// `band`'s value at each frequency against what a KH 120 II measured there. + fn as_measured(band: &Band, measured: &[(f64, f64)]) { + for (frequency, expected) in measured { + let actual = band_db(band, *frequency); + let off = (actual - expected).abs(); + assert!( + off < MEASURED_TOLERANCE_DB, + "{} at {frequency} Hz: {actual:.2} vs measured {expected}", + band.kind + ); + } + } + + /// Measured on a KH 120 II: the speakers' `q` of a shelf is the cookbook's slope S, so + /// q 2.0 overshoots only slightly. #[test] fn a_shelf_q_of_2_overshoots_as_little_as_measured() { let near = |actual: f64, expected: f64| { - assert!((actual - expected).abs() < 0.3, "{actual} vs {expected}"); + assert!( + (actual - expected).abs() < MEASURED_TOLERANCE_DB, + "{actual} vs {expected}" + ); }; let low = band("LOSHELF", 6.0, 200.0, 2.0); near(band_db(&low, 100.0), 6.54); @@ -190,6 +210,64 @@ mod tests { near(band_db(&steepest_without_overshoot, 400.0), 0.41); } + /// Measured on a KH 120 II: the EQ runs at 48 kHz, so near the top a band is narrower than + /// its analog form (a 14 kHz peak is 2.7 dB at 10 kHz, not 4). + #[test] + fn filters_near_the_top_are_as_narrow_as_measured() { + as_measured( + &band("PARAMETRIC", 6.0, 14000.0, 1.0), + &[ + (7000.0, 1.02), + (10000.0, 2.72), + (14000.0, 5.93), + (16000.0, 4.67), + ], + ); + as_measured( + &band("HISHELF", 6.0, 12000.0, 0.7), + &[ + (6000.0, 0.56), + (10000.0, 2.0), + (14000.0, 3.99), + (16000.0, 4.82), + ], + ); + } + + /// Measured on a KH 120 II: `q` is the cookbook Q for these types, the 6 dB filters are first + /// order, the band pass peaks at 20·log10(Q). + #[test] + fn every_filter_type_is_drawn_as_measured() { + as_measured( + &band("PARAMETRIC", 6.0, 1000.0, 4.0), + &[(1000.0, 5.83), (1260.0, 1.37), (2000.0, 0.2)], + ); + as_measured( + &band("LOWPASS", 0.0, 1000.0, FRAC_1_SQRT_2), + &[(500.0, -0.28), (1000.0, -3.01), (2000.0, -12.34)], + ); + as_measured( + &band("HIGHPASS", 0.0, 1000.0, 1.0), + &[(500.0, -11.03), (1000.0, -0.04), (2000.0, 0.92)], + ); + as_measured( + &band("LO6DB", 0.0, 1000.0, 0.7), + &[(500.0, -0.98), (1000.0, -3.02), (2000.0, -7.01)], + ); + as_measured( + &band("HI6DB", 0.0, 1000.0, 0.7), + &[(500.0, -6.91), (1000.0, -3.01), (2000.0, -0.95)], + ); + as_measured( + &band("NOTCH", 0.0, 1000.0, 2.0), + &[(700.0, -1.83), (1400.0, -1.85)], + ); + as_measured( + &band("BANDPASS", 0.0, 1000.0, 2.0), + &[(250.0, -11.56), (1000.0, 5.98), (4000.0, -11.73)], + ); + } + #[test] fn a_shelf_steeper_than_the_cookbook_form_allows_is_drawn_flat() { close(band_db(&band("HISHELF", 12.0, 2000.0, 16.0), 20000.0), 0.0); From 50267e6fc9faf5fd00e8746a8912096b4c04793a Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:35:23 +0200 Subject: [PATCH 171/460] Draw EQ bands as the speakers' 48 kHz filters Measured on a KH 120 II: near the top the bands are as narrow as cookbook biquads at 48 kHz; the analog form drew a 14 kHz peak up to 1.3 dB too wide. --- crates/app/src/eq_response.rs | 154 +++++++++++++++++++++------------- 1 file changed, 97 insertions(+), 57 deletions(-) diff --git a/crates/app/src/eq_response.rs b/crates/app/src/eq_response.rs index eb0f238..0596d90 100644 --- a/crates/app/src/eq_response.rs +++ b/crates/app/src/eq_response.rs @@ -1,6 +1,10 @@ -//! The magnitude response of EQ bands, computed from their settings (not measured): analog -//! prototypes of the usual filter shapes, so no sample rate has to be assumed. Measured on the -//! KH 750: a band's `gain` lifts every frequency, its `boost` is the height of the filter. +//! The magnitude response of EQ bands, computed from their settings (not measured): the biquads +//! of the RBJ audio EQ cookbook at the speakers' rate. Measured on a KH 120 II: every type +//! follows its cookbook form, a shelf's `q` is the slope S, and near the top the bands are as +//! narrow as at 48 kHz. Measured on the KH 750: a band's `gain` lifts every frequency, its +//! `boost` is the height of the filter. + +use std::f64::consts::TAU; use crate::eq::Band; @@ -9,23 +13,25 @@ pub const FREQUENCY_MIN: f64 = 10.0; pub const FREQUENCY_MAX: f64 = 24000.0; pub const GRAPH_POINTS: usize = 400; -/// `a2·s² + a1·s + a0` at `s = j·x`, where `x` is the frequency over the band's frequency. -#[derive(Clone, Copy)] -struct Quadratic { - a2: f64, - a1: f64, - a0: f64, -} +/// The rate the speakers run their EQ at (measured on the KH 120 II; assumed for the KH 750, +/// whose bands sit low, where the rate makes no difference). +const SAMPLE_RATE_HZ: f64 = 48000.0; -impl Quadratic { - const fn new(a2: f64, a1: f64, a0: f64) -> Quadratic { - Quadratic { a2, a1, a0 } - } +/// `(b0 + b1·z⁻¹ + b2·z⁻²) / (a0 + a1·z⁻¹ + a2·z⁻²)`. +struct Biquad { + b: [f64; 3], + a: [f64; 3], +} - fn magnitude(self, x: f64) -> f64 { - let real = self.a0 - self.a2 * x * x; - let imaginary = self.a1 * x; - real.hypot(imaginary) +impl Biquad { + fn magnitude(&self, frequency: f64) -> f64 { + let w = TAU * frequency / SAMPLE_RATE_HZ; + let at = |c: &[f64; 3]| { + let real = c[0] + c[1] * w.cos() + c[2] * (2.0 * w).cos(); + let imaginary = c[1] * w.sin() + c[2] * (2.0 * w).sin(); + real.hypot(imaginary) + }; + at(&self.b) / at(&self.a) } } @@ -34,55 +40,82 @@ pub fn band_db(band: &Band, frequency: f64) -> f64 { if !band.enabled { return 0.0; } - let x = frequency / band.frequency; - let shape = transfer(band).map_or(0.0, |(scale, numerator, denominator)| { - let magnitude = scale * numerator.magnitude(x) / denominator.magnitude(x); - 20.0 * magnitude.log10() - }); + let shape = biquad(band).map_or(0.0, |filter| 20.0 * filter.magnitude(frequency).log10()); band.gain + shape } -/// Numerator and denominator of the shape (RBJ audio EQ cookbook, analog form), with a factor. -fn transfer(band: &Band) -> Option<(f64, Quadratic, Quadratic)> { - let q = band.q; - let a = 10f64.powf(band.boost / 40.0); - let resonant = Quadratic::new(1.0, 1.0 / q, 1.0); - let first_order = Quadratic::new(0.0, 1.0, 1.0); - // Measured on the KH 120 II: a shelf's `q` is the cookbook's slope S, not its Q. - let shelf_radicand = (a + 1.0 / a) * (1.0 / q - 1.0) + 2.0; - let is_shelf = matches!(band.kind.as_str(), "LOSHELF" | "HISHELF"); - if is_shelf && shelf_radicand <= 0.0 { - // A slope this steep for this height is outside the cookbook form; not measured. +/// The band's filter as the cookbook defines it; `None` for a type that changes the phase only, +/// an unknown type, or a setting the cookbook form does not cover. +fn biquad(band: &Band) -> Option { + if band.frequency >= SAMPLE_RATE_HZ / 2.0 { + // At half the rate the form is undefined; what the speakers do there is not measured. return None; } - let shelf_slope = a.sqrt() * shelf_radicand.max(0.0).sqrt(); - let shape = match band.kind.as_str() { - "PARAMETRIC" => ( - 1.0, - Quadratic::new(1.0, a / q, 1.0), - Quadratic::new(1.0, 1.0 / (a * q), 1.0), - ), - "LOSHELF" => ( - a, - Quadratic::new(1.0, shelf_slope, a), - Quadratic::new(a, shelf_slope, 1.0), + let w0 = TAU * band.frequency / SAMPLE_RATE_HZ; + let (cos, sin) = (w0.cos(), w0.sin()); + let a = 10f64.powf(band.boost / 40.0); + let alpha = sin / (2.0 * band.q); + let resonant = [1.0 + alpha, -2.0 * cos, 1.0 - alpha]; + let first_order_k = (w0 / 2.0).tan(); + let first_order = [1.0 + first_order_k, first_order_k - 1.0, 0.0]; + let filter = |b: [f64; 3], a: [f64; 3]| Some(Biquad { b, a }); + match band.kind.as_str() { + "PARAMETRIC" => filter( + [1.0 + alpha * a, -2.0 * cos, 1.0 - alpha * a], + [1.0 + alpha / a, -2.0 * cos, 1.0 - alpha / a], ), - "HISHELF" => ( - a, - Quadratic::new(a, shelf_slope, 1.0), - Quadratic::new(1.0, shelf_slope, a), + "LOSHELF" | "HISHELF" => shelf(band, a, cos, sin), + "LOWPASS" => filter([(1.0 - cos) / 2.0, 1.0 - cos, (1.0 - cos) / 2.0], resonant), + "HIGHPASS" => filter( + [(1.0 + cos) / 2.0, -(1.0 + cos), (1.0 + cos) / 2.0], + resonant, ), - "LOWPASS" => (1.0, Quadratic::new(0.0, 0.0, 1.0), resonant), - "HIGHPASS" => (1.0, Quadratic::new(1.0, 0.0, 0.0), resonant), // Measured: the centre is 20·log10(Q) dB, the constant-skirt form. - "BANDPASS" => (1.0, Quadratic::new(0.0, 1.0, 0.0), resonant), - "NOTCH" => (1.0, Quadratic::new(1.0, 0.0, 1.0), resonant), - "LO6DB" => (1.0, Quadratic::new(0.0, 0.0, 1.0), first_order), - "HI6DB" => (1.0, Quadratic::new(0.0, 1.0, 0.0), first_order), + "BANDPASS" => filter([sin / 2.0, 0.0, -sin / 2.0], resonant), + "NOTCH" => filter([1.0, -2.0 * cos, 1.0], resonant), + "LO6DB" => filter([first_order_k, first_order_k, 0.0], first_order), + "HI6DB" => filter([1.0, -1.0, 0.0], first_order), // ALLPASS and INVERSION change the phase only; an unknown type is drawn flat. - _ => return None, + _ => None, + } +} + +/// Measured on the KH 120 II: a shelf's `q` is the cookbook's slope S, not its Q. +fn shelf(band: &Band, a: f64, cos: f64, sin: f64) -> Option { + let radicand = (a + 1.0 / a) * (1.0 / band.q - 1.0) + 2.0; + if radicand <= 0.0 { + // A slope this steep for this height is outside the cookbook form; not measured. + return None; + } + let lift = 2.0 * a.sqrt() * (sin / 2.0) * radicand.sqrt(); + let (plus, minus) = (a + 1.0, a - 1.0); + let filter = match band.kind.as_str() { + "LOSHELF" => Biquad { + b: [ + a * (plus - minus * cos + lift), + 2.0 * a * (minus - plus * cos), + a * (plus - minus * cos - lift), + ], + a: [ + plus + minus * cos + lift, + -2.0 * (minus + plus * cos), + plus + minus * cos - lift, + ], + }, + _ => Biquad { + b: [ + a * (plus + minus * cos + lift), + -2.0 * a * (minus + plus * cos), + a * (plus + minus * cos - lift), + ], + a: [ + plus - minus * cos + lift, + 2.0 * (minus - plus * cos), + plus - minus * cos - lift, + ], + }, }; - Some(shape) + Some(filter) } /// The sum of the bands at each frequency, in dB. @@ -268,6 +301,13 @@ mod tests { ); } + #[test] + fn a_band_at_half_the_rate_is_drawn_flat() { + let top = band("PARAMETRIC", 6.0, FREQUENCY_MAX, 1.0); + close(band_db(&top, 20000.0), 0.0); + assert!(band_db(&top, FREQUENCY_MAX).is_finite()); + } + #[test] fn a_shelf_steeper_than_the_cookbook_form_allows_is_drawn_flat() { close(band_db(&band("HISHELF", 12.0, 2000.0, 16.0), 20000.0), 0.0); From 71eacb7c4c22c5e8a4a17404e54093cc6406c5cf Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:35:23 +0200 Subject: [PATCH 172/460] Document the 48 kHz EQ graph --- CHANGELOG.md | 2 ++ README.md | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbb774e..9ae9c15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,8 @@ - Every write to the KH 120 II's 20-band EQ 3 was refused by the speaker ("code 413"): the request was one value too long for it. +- The EQ graph drew bands near the top too wide (a 14 kHz peak by up to 1.3 dB): it now draws + the speakers' digital filters at 48 kHz. - The EQ graph drew shelving filters too steep, with an overshoot the speaker does not have: their Q is the shelf's slope. - A value that rounds to zero showed a minus ("-0 dB"). diff --git a/README.md b/README.md index 4864ce0..e0644f1 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,8 @@ tab: "EQ 2", "EQ 3", and on the KH 750 the crossovers of input A and input B ("C "Crossover B"). - The graph shows every EQ of the output as its own curve and their sum as a thicker line, from - 10 Hz to 24 kHz. The curves are computed from the settings, not measured. Each band has two + 10 Hz to 24 kHz. The curves are computed from the settings, not measured, as the speakers' + digital filters at 48 kHz (checked against measurements of every filter type). Each band has two levels: **Level** lifts or lowers every frequency while the band is on, **Boost** is the height of its filter (the peak or shelf); at the band's frequency both add up. For a shelving filter the speakers' **Q** is its slope (1 is the steepest without overshoot), as measured. From 5a2b852fe80f0a72058a770475d79091f39055e7 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:42:53 +0200 Subject: [PATCH 173/460] Test the import against a real REW Generic export --- crates/app/src/eq_file.rs | 106 +++++++++++++++++++++++++++++++++----- crates/app/src/texts.rs | 2 +- crates/app/tests/eq.rs | 2 +- 3 files changed, 95 insertions(+), 15 deletions(-) diff --git a/crates/app/src/eq_file.rs b/crates/app/src/eq_file.rs index 65b7391..1751ebf 100644 --- a/crates/app/src/eq_file.rs +++ b/crates/app/src/eq_file.rs @@ -70,7 +70,7 @@ pub enum ImportError { #[derive(Clone, Debug, PartialEq)] pub enum LineProblem { UnknownType(String), - ShelfNotSupported(String), + UnsupportedForm(String), Missing(Parameter), BadNumber(String), UnknownState(String), @@ -102,7 +102,7 @@ fn file_type(token: &str) -> Result, LineProblem> { "NO" => shaped("NOTCH", false), "AP" => shaped("ALLPASS", false), "NONE" => Ok(None), - "LS" | "HS" | "LSC" | "HSC" => Err(LineProblem::ShelfNotSupported(token.to_owned())), + "LS" | "HS" | "LSC" | "HSC" => Err(LineProblem::UnsupportedForm(token.to_owned())), _ => Err(LineProblem::UnknownType(token.to_owned())), } } @@ -493,14 +493,79 @@ mod tests { assert_eq!(imported.bands[&0], filter("HIGHPASS", 30.0, 0.0, 0.5)); } + /// Exported by Room EQ Wizard 5.31.3, equaliser "Generic" (2026-10-21): two-word types, + /// all 20 slots, and a second block of two empty filters at the end. + const REW_GENERIC_EXPORT: &str = "Filter Settings file\r\n\r\nRoom EQ V5.31.3\r\n\ + Dated: 21.10.2026 00:37:16\r\n\r\nNotes:testfilter\r\n\r\nEqualiser: Generic\r\n\ + No measurement\r\n\ + Filter 1: ON PK Fc 100.0 Hz Gain -3.00 dB Q 1.410\r\n\ + Filter 2: ON LS Q Fc 200.0 Hz Gain 6.00 dB Q 2.00\r\n\ + Filter 3: ON HS Q Fc 2000 Hz Gain -4.00 dB Q 0.700\r\n\ + Filter 4: ON LS Fc 100.0 Hz Gain 3.00 dB\r\n\ + Filter 5: ON HP Q Fc 30.00 Hz Q 0.700\r\n\ + Filter 6: ON LP1 Fc 15.00 Hz \r\n\ + Filter 7: ON None \r\nFilter 8: ON None \r\nFilter 9: ON None \r\n\ + Filter 10: ON None \r\nFilter 11: ON None \r\nFilter 12: ON None \r\n\ + Filter 13: ON None \r\nFilter 14: ON None \r\nFilter 15: ON None \r\n\ + Filter 16: ON None \r\nFilter 17: ON None \r\nFilter 18: ON None \r\n\ + Filter 19: ON None \r\nFilter 20: ON None \r\n\ + Filter 1: ON None \r\nFilter 2: ON None \r\n\r\n"; + + fn near(actual: f64, expected: f64) { + assert!((actual - expected).abs() < 1e-5, "{actual} vs {expected}"); + } + + #[test] + fn a_real_rew_generic_export_reads_as_its_filters() { + let imported = parse(REW_GENERIC_EXPORT).unwrap(); + assert!(imported.ignored.is_empty()); + assert_eq!(imported.bands.len(), 20); + assert_eq!(imported.bands[&0], filter("PARAMETRIC", 100.0, -3.0, 1.41)); + assert_eq!(imported.bands[&4], filter("HIGHPASS", 30.0, 0.0, 0.7)); + let first_order = imported.bands[&5].as_ref().unwrap(); + assert_eq!((first_order.kind, first_order.frequency), ("LO6DB", 15.0)); + assert!((6..20).all(|band| imported.bands[&band].is_none())); + } + + /// REW's shelf Q is the cookbook Q (read from the biquad it stores in a .req), the speakers' + /// shelf q is the cookbook slope S (measured): Q 2 at +6 dB is q 5.72, REW's plain shelf + /// (Q 0.7071) is q 1. + #[test] + fn a_rew_shelf_q_becomes_the_speakers_slope() { + let imported = parse(REW_GENERIC_EXPORT).unwrap(); + let low = imported.bands[&1].as_ref().unwrap(); + assert_eq!( + (low.kind, low.frequency, low.boost), + ("LOSHELF", 200.0, 6.0) + ); + near(low.q, 5.723559); + let high = imported.bands[&2].as_ref().unwrap(); + assert_eq!( + (high.kind, high.frequency, high.boost), + ("HISHELF", 2000.0, -4.0) + ); + near(high.q, 0.980509); + let plain = imported.bands[&3].as_ref().unwrap(); + assert_eq!((plain.kind, plain.boost), ("LOSHELF", 3.0)); + near(plain.q, 1.0); + } + + #[test] + fn empty_slots_beyond_the_eq_are_ignored() { + let current = vec![band("PARAMETRIC", 0.0, 100.0, 0.7); 10]; + let imported = parse(REW_GENERIC_EXPORT).unwrap(); + let preset = imported_preset("rew.txt", &imported, ¤t).unwrap(); + assert_eq!(preset.bands, 10); + } + #[test] - fn shelves_are_refused_until_measured() { + fn shelves_by_slope_or_corner_frequency_are_refused() { for line in [ - "Filter: ON LSC Fc 80 Hz Gain 4 dB Q 0.7", - "Filter: ON HS 6dB Fc 1 Hz", + "Filter: ON LSC 10.8 dB Fc 300 Hz Gain 5 dB", + "Filter: ON HS 6dB Fc 1000 Hz Gain 3 dB", ] { assert!( - matches!(problem_of(line), LineProblem::ShelfNotSupported(_)), + matches!(problem_of(line), LineProblem::UnsupportedForm(_)), "{line}" ); } @@ -536,7 +601,7 @@ mod tests { problem_of("Filter: ON PK Fc abc Hz Gain 1 dB Q 1"), LineProblem::BadNumber("abc".to_owned()) ); - let twice = "Filter 1: ON None\nFilter 1: ON None"; + let twice = "Filter 1: ON None\nFilter 1: ON PK Fc 100 Hz Gain 1 dB Q 1"; assert_eq!(problem_of(twice), LineProblem::SameBand(1)); } @@ -595,7 +660,7 @@ mod tests { "Filter 1: ON PK Fc 1230 Hz Gain -3 dB Q 0.7", "# Filter 1: flat level 1.5 dB not exported", "# Filter 2: off", - "# Filter 3: LOSHELF not exported", + "Filter 3: ON LSC Fc 80 Hz Gain 4 dB Q 0.589", "Filter 4: ON HPQ Fc 30.5 Hz Q 0.5", ] ); @@ -603,11 +668,7 @@ mod tests { export.notes, [ ExportNote::FlatLevel { band: 1, gain: 1.5 }, - ExportNote::Off(2), - ExportNote::NotExportable { - band: 3, - kind: "LOSHELF".to_owned() - }, + ExportNote::Off(2) ] ); } @@ -637,6 +698,25 @@ mod tests { } } + #[test] + fn an_exported_shelf_reads_back_as_the_same_slope() { + let bands = vec![ + band("LOSHELF", 6.0, 200.0, 2.0), + band("HISHELF", -4.0, 2000.0, 0.7), + ]; + let imported = parse(&export(&bands, "EQ 2").text).unwrap(); + for (place, expected) in bands.iter().enumerate() { + let back = imported.bands[&place].as_ref().unwrap(); + assert_eq!(back.kind, expected.kind); + assert!( + (back.q - expected.q).abs() < 0.01, + "{} vs {}", + back.q, + expected.q + ); + } + } + #[test] fn exported_numbers_never_read_minus_zero() { assert_eq!(trimmed(-0.0001), "0"); diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 5dc639c..9deb5a5 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -846,7 +846,7 @@ fn line_problem(problem: &LineProblem) -> String { format!("Filtertyp „{kind}“ gibt es am Lautsprecher nicht."), format!("The speaker has no filter type \"{kind}\"."), ), - LineProblem::ShelfNotSupported(kind) => pick( + LineProblem::UnsupportedForm(kind) => pick( format!("Kuhschwanzfilter („{kind}“) werden noch nicht übernommen."), format!("Shelving filters (\"{kind}\") are not taken over yet."), ), diff --git a/crates/app/tests/eq.rs b/crates/app/tests/eq.rs index 50112ec..9898fa0 100644 --- a/crates/app/tests/eq.rs +++ b/crates/app/tests/eq.rs @@ -801,7 +801,7 @@ fn a_shelf_in_the_file_is_named_and_refused() { import_file(&mut harness, &directory, "shelf.txt", text); let error = eq_file::ImportError::Line { line: 2, - problem: eq_file::LineProblem::ShelfNotSupported("LSC".to_owned()), + problem: eq_file::LineProblem::UnsupportedForm("LSC".to_owned()), }; harness.get_by_label(&texts::eq_import_error(&error)); let write = harness.get_by_role_and_label(Role::Button, texts::WRITE_PRESET.get()); From c89e5dfbd71b1aec0b2125728c0b5b621852692e Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:47:05 +0200 Subject: [PATCH 174/460] Import and export shelves via the slope REW's shelf Q is the cookbook Q (read from the biquad in its .req), the speakers' q the slope S. Reads REW's two-word types, LP1/HP1, empty slots and its second block of None. --- crates/app/src/eq_file.rs | 146 ++++++++++++++++++++++++---------- crates/app/src/eq_response.rs | 25 ++++-- crates/app/src/texts.rs | 8 +- crates/app/tests/eq.rs | 6 +- 4 files changed, 129 insertions(+), 56 deletions(-) diff --git a/crates/app/src/eq_file.rs b/crates/app/src/eq_file.rs index 1751ebf..369e63a 100644 --- a/crates/app/src/eq_file.rs +++ b/crates/app/src/eq_file.rs @@ -1,14 +1,18 @@ //! EQ filters as text files: the filter lines of Equalizer APO's `config.txt`, which are also //! what Room EQ Wizard exports for its "Generic" equaliser (`Filter 1: ON PK Fc 100 Hz Gain -3.0 //! dB Q 1.41`). Import maps them onto a container's bands; export writes a container as APO lines. -//! Shelves are left out both ways until it is measured how the speakers define their Q. +//! A shelf's Q in these files is the cookbook Q (read from the biquad REW stores), the speakers' +//! shelf `q` is the cookbook slope S (measured), so shelves are converted both ways. +use std::collections::btree_map::Entry; use std::collections::BTreeMap; +use std::f64::consts::FRAC_1_SQRT_2; use neumann_control_core::settings::EqPreset; use serde_json::Value; use crate::eq::{Band, Leaf}; +use crate::eq_response::{shelf_q_of_slope, shelf_slope_of_q}; /// Bigger than any filter file; a larger file is not one. pub const MAX_FILE_BYTES: u64 = 1024 * 1024; @@ -16,6 +20,13 @@ pub const MAX_FILE_BYTES: u64 = 1024 * 1024; const COMMENT: char = '#'; const FILTER_COMMAND: &str = "filter"; const BYTE_ORDER_MARK: char = '\u{feff}'; +/// The second word of REW's two-word types (`LS Q`, `HP Q`): the variant with a Q. +const Q_SUFFIX: &str = "Q"; +const FREQUENCY_KEY: &str = "FC"; +/// REW's plain shelf (`LS`, `HS`), read from the biquad it stores: Q 1/√2, slope 1. +const PLAIN_SHELF_Q: f64 = FRAC_1_SQRT_2; +/// First-order filters have no Q; the speakers still want one in their range. +const FIRST_ORDER_Q: f64 = 0.7; /// APO commands that change the sound but have no place in one EQ container; anything else /// before a colon (REW's header lines `Dated:`, `Notes:` ...) is passed over silently. const SOUND_COMMANDS: [&str; 14] = [ @@ -85,26 +96,65 @@ pub enum Parameter { Q, } -/// Which parameters a file type needs, and the speaker's type for it. +/// What a file type's Q means for the speaker. +#[derive(Clone, Copy)] +enum QRule { + /// The cookbook Q, taken as it is; a line without one is refused. + Required, + /// The cookbook Q of a shelf, converted to the speakers' slope; REW's plain shelf has none. + Shelf, + /// A first-order filter: no Q. + Unused, +} + +/// The speaker's type for a file type, whether its line carries a gain, and what its Q means. struct FileType { kind: &'static str, gain: bool, + q: QRule, } -fn file_type(token: &str) -> Result, LineProblem> { - let upper = token.to_ascii_uppercase(); - let shaped = |kind, gain| Ok(Some(FileType { kind, gain })); +fn file_type(name: &str) -> Result, LineProblem> { + let upper = name.to_ascii_uppercase(); + let shaped = |kind, gain, q| Ok(Some(FileType { kind, gain, q })); match upper.as_str() { - "PK" | "PEQ" | "MODAL" => shaped("PARAMETRIC", true), - "LP" | "LPQ" => shaped("LOWPASS", false), - "HP" | "HPQ" => shaped("HIGHPASS", false), - "BP" => shaped("BANDPASS", false), - "NO" => shaped("NOTCH", false), - "AP" => shaped("ALLPASS", false), + "PK" | "PEQ" | "MODAL" | "PK Q" => shaped("PARAMETRIC", true, QRule::Required), + "LP" | "LPQ" | "LP Q" => shaped("LOWPASS", false, QRule::Required), + "HP" | "HPQ" | "HP Q" => shaped("HIGHPASS", false, QRule::Required), + "LP1" => shaped("LO6DB", false, QRule::Unused), + "HP1" => shaped("HI6DB", false, QRule::Unused), + "BP" => shaped("BANDPASS", false, QRule::Required), + "NO" => shaped("NOTCH", false, QRule::Required), + "AP" => shaped("ALLPASS", false, QRule::Required), + "LS" | "LSC" | "LS Q" => shaped("LOSHELF", true, QRule::Shelf), + "HS" | "HSC" | "HS Q" => shaped("HISHELF", true, QRule::Shelf), "NONE" => Ok(None), - "LS" | "HS" | "LSC" | "HSC" => Err(LineProblem::UnsupportedForm(token.to_owned())), - _ => Err(LineProblem::UnknownType(token.to_owned())), + _ => Err(LineProblem::UnknownType(name.to_owned())), + } +} + +/// The type's name and the words after it: REW writes `LS Q` / `HP Q` as two words. A shelf +/// followed by anything but `Fc` is APO's slope (`LSC 10.8 dB`) or corner (`LS 6dB`) form, +/// whose meaning is not verified. +fn type_and_parameters<'a>(tokens: &[&'a str]) -> Result<(String, Vec<&'a str>), LineProblem> { + let Some((first, rest)) = tokens.split_first() else { + return Err(LineProblem::UnknownType(String::new())); + }; + let second = rest.first().copied().unwrap_or_default(); + let is_key = |word: &str| word.eq_ignore_ascii_case(FREQUENCY_KEY); + let with_q = + second.eq_ignore_ascii_case(Q_SUFFIX) && rest.get(1).is_some_and(|word| is_key(word)); + if with_q { + return Ok((format!("{first} {Q_SUFFIX}"), rest[1..].to_vec())); + } + let shelf = matches!( + first.to_ascii_uppercase().as_str(), + "LS" | "HS" | "LSC" | "HSC" + ); + if shelf && !second.is_empty() && !is_key(second) { + return Err(LineProblem::UnsupportedForm(format!("{first} {second}"))); } + Ok(((*first).to_owned(), rest.to_vec())) } /// The filters of an APO or REW text. Filter lines with a number go to that band, lines @@ -135,13 +185,16 @@ pub fn parse(text: &str) -> Result { "" => next_band, given => band_number(given).map_err(failed)?, }; - if imported.bands.contains_key(&band) { - return Err(failed(LineProblem::SameBand(band + 1))); - } - imported - .bands - .insert(band, filter_of(rest).map_err(failed)?); + let filter = filter_of(rest).map_err(failed)?; next_band = band + 1; + match imported.bands.entry(band) { + Entry::Vacant(slot) => { + slot.insert(filter); + } + // REW appends a second block of empty filters (`Filter 1: ON None` again). + Entry::Occupied(_) if filter.is_none() => {} + Entry::Occupied(_) => return Err(failed(LineProblem::SameBand(band + 1))), + } } if imported.bands.is_empty() { return Err(ImportError::NoFilters); @@ -181,13 +234,11 @@ fn filter_of(rest: &str) -> Result, LineProblem> { "OFF" => return Ok(None), _ => return Err(LineProblem::UnknownState((*state).to_owned())), } - let Some((kind, parameters)) = tokens.split_first() else { - return Err(LineProblem::UnknownType(String::new())); - }; - let Some(file_type) = file_type(kind)? else { + let (name, parameters) = type_and_parameters(tokens)?; + let Some(file_type) = file_type(&name)? else { return Ok(None); }; - let values = Parameters::read(parameters)?; + let values = Parameters::read(¶meters)?; let frequency = values .frequency .ok_or(LineProblem::Missing(Parameter::Frequency))?; @@ -195,7 +246,11 @@ fn filter_of(rest: &str) -> Result, LineProblem> { if file_type.gain { boost = values.gain.ok_or(LineProblem::Missing(Parameter::Gain))?; } - let q = values.q.ok_or(LineProblem::Missing(Parameter::Q))?; + let q = match file_type.q { + QRule::Required => values.q.ok_or(LineProblem::Missing(Parameter::Q))?, + QRule::Shelf => shelf_slope_of_q(values.q.unwrap_or(PLAIN_SHELF_Q), boost), + QRule::Unused => FIRST_ORDER_Q, + }; Ok(Some(Filter { kind: file_type.kind, frequency, @@ -263,12 +318,14 @@ pub fn imported_preset( current: &[Band], ) -> Result { let bands = current.len(); - if let Some(last) = imported + // Empty slots past the container (REW writes all 20) are no filters. + let beyond = imported .bands - .keys() - .next_back() - .filter(|last| **last >= bands) - { + .iter() + .filter(|(band, filter)| **band >= bands && filter.is_some()) + .map(|(band, _)| *band) + .next_back(); + if let Some(last) = beyond { return Err(ImportError::TooManyBands { band: last + 1, bands, @@ -336,15 +393,18 @@ pub struct Export { pub notes: Vec, } -/// APO's token for a speaker type, and whether its line carries a gain. -fn apo_type(kind: &str) -> Option<(&'static str, bool)> { - match kind { - "PARAMETRIC" => Some(("PK", true)), - "LOWPASS" => Some(("LPQ", false)), - "HIGHPASS" => Some(("HPQ", false)), - "BANDPASS" => Some(("BP", false)), - "NOTCH" => Some(("NO", false)), - "ALLPASS" => Some(("AP", false)), +/// APO's token for a band, whether its line carries a gain, and its cookbook Q (a shelf's slope +/// converted); `None` for a type APO has no line for, or a shelf beyond the cookbook form. +fn apo_line_of(band: &Band) -> Option<(&'static str, bool, f64)> { + match band.kind.as_str() { + "PARAMETRIC" => Some(("PK", true, band.q)), + "LOWPASS" => Some(("LPQ", false, band.q)), + "HIGHPASS" => Some(("HPQ", false, band.q)), + "BANDPASS" => Some(("BP", false, band.q)), + "NOTCH" => Some(("NO", false, band.q)), + "ALLPASS" => Some(("AP", false, band.q)), + "LOSHELF" => Some(("LSC", true, shelf_q_of_slope(band.q, band.boost)?)), + "HISHELF" => Some(("HSC", true, shelf_q_of_slope(band.q, band.boost)?)), _ => None, } } @@ -364,7 +424,7 @@ pub fn export(bands: &[Band], container: &str) -> Export { notes.push(ExportNote::Off(number)); continue; } - let Some((token, gain)) = apo_type(&band.kind) else { + let Some((token, gain, q)) = apo_line_of(band) else { lines.push(format!( "{COMMENT} Filter {number}: {} not exported", band.kind @@ -375,7 +435,7 @@ pub fn export(bands: &[Band], container: &str) -> Export { }); continue; }; - lines.push(filter_line(number, token, gain, band)); + lines.push(filter_line(number, token, gain, q, band)); if band.gain != 0.0 { lines.push(format!( "{COMMENT} Filter {number}: flat level {} dB not exported", @@ -394,7 +454,7 @@ pub fn export(bands: &[Band], container: &str) -> Export { } } -fn filter_line(number: usize, token: &str, gain: bool, band: &Band) -> String { +fn filter_line(number: usize, token: &str, gain: bool, q: f64, band: &Band) -> String { let mut line = format!( "Filter {number}: ON {token} Fc {} Hz", trimmed(band.frequency) @@ -402,7 +462,7 @@ fn filter_line(number: usize, token: &str, gain: bool, band: &Band) -> String { if gain { line.push_str(&format!(" Gain {} dB", trimmed(band.boost))); } - line.push_str(&format!(" Q {}", trimmed(band.q))); + line.push_str(&format!(" Q {}", trimmed(q))); line } diff --git a/crates/app/src/eq_response.rs b/crates/app/src/eq_response.rs index 0596d90..775f0b5 100644 --- a/crates/app/src/eq_response.rs +++ b/crates/app/src/eq_response.rs @@ -80,14 +80,25 @@ fn biquad(band: &Band) -> Option { } } -/// Measured on the KH 120 II: a shelf's `q` is the cookbook's slope S, not its Q. +/// The cookbook Q of a shelf whose `q` (the speakers' slope S, measured on the KH 120 II) and +/// height are given; `None` for a slope too steep for its height, where the cookbook form ends +/// (what the speakers do there is not measured). +pub fn shelf_q_of_slope(slope: f64, boost: f64) -> Option { + let a = 10f64.powf(boost / 40.0); + let radicand = (a + 1.0 / a) * (1.0 / slope - 1.0) + 2.0; + (radicand > 0.0).then(|| 1.0 / radicand.sqrt()) +} + +/// The speakers' `q` (slope S) of a shelf given by its cookbook Q, as Room EQ Wizard and +/// Equalizer APO write it; every Q has one. +pub fn shelf_slope_of_q(q: f64, boost: f64) -> f64 { + let a = 10f64.powf(boost / 40.0); + 1.0 / ((1.0 / (q * q) - 2.0) / (a + 1.0 / a) + 1.0) +} + fn shelf(band: &Band, a: f64, cos: f64, sin: f64) -> Option { - let radicand = (a + 1.0 / a) * (1.0 / band.q - 1.0) + 2.0; - if radicand <= 0.0 { - // A slope this steep for this height is outside the cookbook form; not measured. - return None; - } - let lift = 2.0 * a.sqrt() * (sin / 2.0) * radicand.sqrt(); + let q = shelf_q_of_slope(band.q, band.boost)?; + let lift = a.sqrt() * sin / q; let (plus, minus) = (a + 1.0, a - 1.0); let filter = match band.kind.as_str() { "LOSHELF" => Biquad { diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 9deb5a5..222f5ed 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -846,9 +846,11 @@ fn line_problem(problem: &LineProblem) -> String { format!("Filtertyp „{kind}“ gibt es am Lautsprecher nicht."), format!("The speaker has no filter type \"{kind}\"."), ), - LineProblem::UnsupportedForm(kind) => pick( - format!("Kuhschwanzfilter („{kind}“) werden noch nicht übernommen."), - format!("Shelving filters (\"{kind}\") are not taken over yet."), + LineProblem::UnsupportedForm(form) => pick( + format!( + "Kuhschwanzfilter mit Steilheit in dB oder Eckfrequenz („{form}“) werden nicht übernommen." + ), + format!("Shelving filters by slope in dB or corner frequency (\"{form}\") are not taken over."), ), LineProblem::Missing(parameter) => pick( format!("{} fehlt.", parameter_name(*parameter)), diff --git a/crates/app/tests/eq.rs b/crates/app/tests/eq.rs index 9898fa0..10a3eb8 100644 --- a/crates/app/tests/eq.rs +++ b/crates/app/tests/eq.rs @@ -794,14 +794,14 @@ fn a_file_the_speaker_would_refuse_writes_nothing() { } #[test] -fn a_shelf_in_the_file_is_named_and_refused() { +fn a_shelf_by_slope_in_the_file_is_named_and_refused() { let (_simulator, directory, mut harness) = open_eq(SimModel::Kh120Ii); let text = - "Filter 1: ON PK Fc 120 Hz Gain -4 dB Q 2\nFilter 2: ON LSC Fc 80 Hz Gain 3 dB Q 0.7\n"; + "Filter 1: ON PK Fc 120 Hz Gain -4 dB Q 2\nFilter 2: ON LSC 10.8 dB Fc 80 Hz Gain 3 dB\n"; import_file(&mut harness, &directory, "shelf.txt", text); let error = eq_file::ImportError::Line { line: 2, - problem: eq_file::LineProblem::UnsupportedForm("LSC".to_owned()), + problem: eq_file::LineProblem::UnsupportedForm("LSC 10.8".to_owned()), }; harness.get_by_label(&texts::eq_import_error(&error)); let write = harness.get_by_role_and_label(Role::Button, texts::WRITE_PRESET.get()); From 412661775ee14e477f316476142e1cc34493caa6 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:47:05 +0200 Subject: [PATCH 175/460] Document shelf import and export --- CHANGELOG.md | 2 +- README.md | 20 ++++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ae9c15..6c89f2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,7 @@ settings. - **Import…** and **Export…** in the EQ window: filter lines of Equalizer APO and of Room EQ Wizard's "Generic" export, with a preview and one history entry; export as APO lines. Shelving - filters are not taken over yet. + filters are converted between the files' Q and the speakers' slope. - **Protect crossovers** in the settings (on by default); off, "Make flat", "Reset", EQ presets and the import work on the KH 750 crossovers too, with a warning. diff --git a/README.md b/README.md index e0644f1..f643bc7 100644 --- a/README.md +++ b/README.md @@ -242,16 +242,20 @@ tab: "EQ 2", "EQ 3", and on the KH 750 the crossovers of input A and input B ("C - **Import…** reads the filter lines of an Equalizer APO `config.txt` or of a filter file Room EQ Wizard exported for its "Generic" equaliser (`Filter 1: ON PK Fc 100 Hz Gain -3.0 dB Q 1.41`). A filter with a number goes to that band, one without to the next band; bands the file does not - name are switched off. The file's `Gain` becomes the band's boost. Peaking (`PK`, `PEQ`, - `Modal`), low and high pass with Q (`LP`/`LPQ`, `HP`/`HPQ`), band pass (`BP`), notch (`NO`) and - all pass (`AP`) are taken over; `BW Oct` instead of Q is converted. Shelving filters are refused - for now, as are other types, a missing Q, more bands than the EQ has and values the speaker would - refuse: then nothing is written. `Preamp` and other APO commands are not taken over; the preview + name are switched off; empty slots (`None`) are passed over. The file's `Gain` becomes the band's + boost. Taken over: peaking (`PK`, `PEQ`, `Modal`), low and high pass with Q (`LP`/`LPQ`/`LP Q`, + `HP`/`HPQ`/`HP Q`), first-order low and high pass (`LP1`, `HP1`), band pass (`BP`), notch (`NO`), + all pass (`AP`) and shelving filters (`LS`, `LSC`, `LS Q`, and the same with `HS`); `BW Oct` + instead of Q is converted. A shelf's Q in these files is the usual (cookbook) Q, the speakers' + shelf Q is its slope, so it is converted; a plain `LS`/`HS` is taken as REW defines it (slope 1). + Refused: shelves by slope in dB or by corner frequency (`LSC 10.8 dB`, `LS 6dB`), other types, a + missing Q, more filters than the EQ has bands and values the speaker would refuse: then nothing + is written. `Preamp` and other APO commands are not taken over; the preview names them. The preview shows which bands change before **Write** sends them as one history entry. -- **Export…** saves the chosen EQ as Equalizer APO filter lines, numbered by band. Bands that are - off, a band's flat level, shelving filters and inversion have no APO line; they are written as - comments and listed after saving. Room EQ Wizard cannot load text filter files. +- **Export…** saves the chosen EQ as Equalizer APO filter lines, numbered by band; shelves as + `LSC`/`HSC` with their Q converted back. Bands that are off, a band's flat level, the 6 dB + filters and inversion have no APO line; they are written as comments and listed after saving. Room EQ Wizard cannot load text filter files. - The crossovers are protected by default: no **Make flat**, no **Reset**, no EQ presets and no **Import…** there. Switching off **Protect crossovers** in **Settings** allows them, with a warning that the output then plays unfiltered. From 62d339d8d32720ed52d0ee6a6ad81af9a82e5e67 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:30:47 +0200 Subject: [PATCH 176/460] Test that a MAC list is masked address by address --- crates/app/src/all_values.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/app/src/all_values.rs b/crates/app/src/all_values.rs index 4602792..647ec78 100644 --- a/crates/app/src/all_values.rs +++ b/crates/app/src/all_values.rs @@ -173,6 +173,13 @@ mod tests { } } + #[test] + fn a_list_of_mac_addresses_is_masked_address_by_address() { + let macs = json!(["00:1b:66:00:00:aa", "00:1b:66:00:00:bb"]); + let row = one(report(paths::MAC_ADDRESSES, Ok(macs), None)); + assert_eq!(row.value, "…:aa, …:bb"); + } + #[test] fn a_refusal_is_the_value_text() { let row = one(report("warnings", Err("refused (404)".to_owned()), None)); From 90aa0736135844e2ca3ec6fb567f2a47c0199f39 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:30:48 +0200 Subject: [PATCH 177/460] Test that a delayed reply blocks no other caller --- crates/core/tests/simulator.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/core/tests/simulator.rs b/crates/core/tests/simulator.rs index c0147db..44af0b9 100644 --- a/crates/core/tests/simulator.rs +++ b/crates/core/tests/simulator.rs @@ -13,6 +13,7 @@ const SILENCE_TIMEOUT: Duration = Duration::from_millis(400); const PARTIAL_LINE_PAUSE: Duration = Duration::from_millis(200); const LINE_LIMIT_BYTES: usize = 1_048_576; const FOREIGN_XID_OFFSET: u64 = 1000; +const SLOW_REPLY_DELAY: Duration = Duration::from_secs(2); const KH_120_II: &str = "KH 120 II"; const KH_750: &str = "KH 750"; const KH_120_II_LEAVES: usize = 50; @@ -516,3 +517,21 @@ fn a_request_over_the_speakers_token_budget_is_refused_with_413() { "25 tokens are taken: {with_mute}" ); } + +#[test] +fn a_delayed_reply_does_not_hold_up_other_callers() { + let simulator = Simulator::start(SimModel::Kh120Ii); + simulator.set_reply_delay(SLOW_REPLY_DELAY); + let mut client = Client::connect(&simulator); + client.send(&json!({"audio": {"out": {"mute": null}}})); + let sent = std::time::Instant::now(); + while simulator.requests().is_empty() { + std::thread::sleep(Duration::from_millis(5)); + } + simulator.value("audio/out/mute"); + assert!( + sent.elapsed() < SLOW_REPLY_DELAY / 2, + "waited {:?} for a lock held through the reply delay", + sent.elapsed() + ); +} From 509e81e210d93a99b41be6d8f50869fdecf1b392 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:31:14 +0200 Subject: [PATCH 178/460] Mask a MAC list address by address --- crates/app/src/all_values.rs | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/crates/app/src/all_values.rs b/crates/app/src/all_values.rs index 647ec78..73aff5b 100644 --- a/crates/app/src/all_values.rs +++ b/crates/app/src/all_values.rs @@ -20,6 +20,7 @@ const MAXIMUM_KEY: &str = "max"; const OPTIONS_KEY: &str = "option"; /// Leaves that name the device itself; shown masked like the serial in the device panel. const IDENTIFIER_PATHS: &[&str] = &[paths::SERIAL, paths::MAC_ADDRESSES]; +const IDENTIFIER_SEPARATOR: &str = ", "; #[derive(Clone, Debug, PartialEq)] pub struct Row<'a> { @@ -62,14 +63,23 @@ fn row<'a>(report: &'a LeafReport, catalog: &Catalog) -> Row<'a> { } fn value_text(report: &LeafReport, path: &str) -> String { - let text = match &report.value { + match &report.value { + Ok(value) if IDENTIFIER_PATHS.contains(&path) => masked_identifier(value), Ok(value) => plain_text(value), Err(refusal) => refusal.clone(), - }; - if IDENTIFIER_PATHS.contains(&path) { - return mask_serial(&text); } - text +} + +/// A list (the MAC addresses) is masked entry by entry; masked as one JSON text only its +/// closing bracket would be left. +fn masked_identifier(value: &Value) -> String { + let Some(list) = value.as_array() else { + return mask_serial(&plain_text(value)); + }; + list.iter() + .map(|entry| mask_serial(&plain_text(entry))) + .collect::>() + .join(IDENTIFIER_SEPARATOR) } /// Strings without their quotes, everything else as JSON. From ffe6656898d15c82a0e88e859312fdb1d33b9d12 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:31:15 +0200 Subject: [PATCH 179/460] Release the simulator lock before the reply delay --- crates/core/src/simulator/mod.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/core/src/simulator/mod.rs b/crates/core/src/simulator/mod.rs index d72cf47..3926315 100644 --- a/crates/core/src/simulator/mod.rs +++ b/crates/core/src/simulator/mod.rs @@ -139,7 +139,10 @@ impl Shared { continue; }; let delivery = self.respond(request); - thread::sleep(self.state().reply_delay); + // Read first: the guard of `self.state()` would otherwise be held through the sleep + // and stall every other connection and test query for the whole delay. + let delay = self.state().reply_delay; + thread::sleep(delay); let lines = match delivery { Delivery::Lines(lines) => lines, Delivery::Silent => continue, From 3e389e2a56b2a2ce3fe101adab5ae444f31b2516 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:39:38 +0200 Subject: [PATCH 180/460] Test a take-back before the write is confirmed --- crates/app/tests/history.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/app/tests/history.rs b/crates/app/tests/history.rs index 0cee6f6..54ebbc4 100644 --- a/crates/app/tests/history.rs +++ b/crates/app/tests/history.rs @@ -14,6 +14,8 @@ use serde_json::{json, Value}; use support::*; const LEVEL_PATH: &str = "audio/out/level"; +/// Long enough that the speaker's confirmation of a write is still out when the test acts. +const UNCONFIRMED_WRITE_DELAY: std::time::Duration = std::time::Duration::from_secs(2); fn entries(harness: &Harness<'static, NeumannApp>) -> Vec { harness.state().history().entries().cloned().collect() @@ -257,6 +259,22 @@ fn ctrl_z_takes_back_the_newest_write() { }); } +#[test] +fn a_take_back_before_the_speaker_confirmed_the_write_does_not_ask() { + let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); + simulator.set_reply_delay(UNCONFIRMED_WRITE_DELAY); + harness.get_by_label(texts::MUTE.get()).click(); + wait_until(&mut harness, "mute", |_| { + sets(&simulator, "/audio/out/mute") == [json!(true)] + }); + harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Z); + harness.run_steps(3); + assert_eq!(harness.state().take_back_question(), None); + let entries = entries(&harness); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].takes_back, Some(entries[1].id)); +} + #[test] fn ctrl_z_in_a_text_field_does_not_take_back() { let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); From afcd468836884e4cc1e61b6a399d94a03b6054d2 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:41:15 +0200 Subject: [PATCH 181/460] Take back against the latest value, sent writes included A take-back right after a write compared with the value before it was confirmed and asked whether someone else had changed it. --- crates/app/src/app/history.rs | 17 ++++++----------- crates/app/src/history.rs | 9 +++++---- crates/app/src/state.rs | 7 +++++++ 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/crates/app/src/app/history.rs b/crates/app/src/app/history.rs index 0e56118..926eaae 100644 --- a/crates/app/src/app/history.rs +++ b/crates/app/src/app/history.rs @@ -193,14 +193,14 @@ impl NeumannApp { texts::history_change(&described.speaker, &described.setting, &before, &after) } - /// The open changes whose speakers now report another value than the written one. + /// The open changes whose speakers now hold another value than the written one. pub(super) fn changed_lines(&self, entry: &Entry) -> Vec { let catalog = Catalog::embedded(); let mut lines = Vec::new(); for change in entry.changes.iter().filter(|change| is_open(change)) { - let reported = self.reported(change); + let latest = self.latest(change); let step = paths::catalog_entry(catalog, &change.path).and_then(|entry| entry.step); - let Some(now) = reported.filter(|_| changed_since(change, reported, step)) else { + let Some(now) = latest.filter(|_| changed_since(change, latest, step)) else { continue; }; let described = self.describe(change); @@ -217,12 +217,12 @@ impl NeumannApp { lines } - fn reported(&self, change: &Change) -> Option<&serde_json::Value> { + fn latest(&self, change: &Change) -> Option<&serde_json::Value> { let speaker = self .speakers .iter() .find(|speaker| speaker.state.key == change.speaker); - speaker.and_then(|speaker| speaker.state.values.get(&change.path)) + speaker.and_then(|speaker| speaker.state.latest(&change.path)) } fn describe(&self, change: &Change) -> Described { @@ -370,13 +370,8 @@ impl NeumannApp { .iter() .filter(|change| is_open(change)) .any(|change| { - let speaker = self - .speakers - .iter() - .find(|speaker| speaker.state.key == change.speaker); - let reported = speaker.and_then(|speaker| speaker.state.values.get(&change.path)); let step = paths::catalog_entry(catalog, &change.path).and_then(|entry| entry.step); - changed_since(change, reported, step) + changed_since(change, self.latest(change), step) }) } } diff --git a/crates/app/src/history.rs b/crates/app/src/history.rs index 8985776..db91c94 100644 --- a/crates/app/src/history.rs +++ b/crates/app/src/history.rs @@ -114,10 +114,11 @@ impl History { } } -/// Whether the speaker now reports something else than the change wrote. A value it has not -/// reported is no reason to ask: the write policy decides about that speaker anyway. -pub fn changed_since(change: &Change, reported: Option<&Value>, step: Option) -> bool { - reported.is_some_and(|reported| !same_value(reported, &change.after, step)) +/// Whether the speaker's latest value (`SpeakerState::latest`) is another than the change +/// wrote. A value it has not reported is no reason to ask: the write policy decides about that +/// speaker anyway. +pub fn changed_since(change: &Change, latest: Option<&Value>, step: Option) -> bool { + latest.is_some_and(|latest| !same_value(latest, &change.after, step)) } pub fn is_open(change: &Change) -> bool { diff --git a/crates/app/src/state.rs b/crates/app/src/state.rs index 5a5e2f4..b2713f8 100644 --- a/crates/app/src/state.rs +++ b/crates/app/src/state.rs @@ -198,6 +198,13 @@ impl SpeakerState { self.pending.insert(path.clone(), pending); } + /// The newest sent value not yet reported back, else the reported one: what the speaker + /// holds once its writes are through. A take-back compares with this, so its own write + /// still on the way does not count as someone else's change. + pub fn latest(&self, path: &SscPath) -> Option<&Value> { + self.pending.get(path).or_else(|| self.values.get(path)) + } + pub fn apply(&mut self, event: Event, now: f64) { match event { Event::Connected(identity) => { From 22b04c3877443d30477bdb0dc17436dab53c0c94 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:48:28 +0200 Subject: [PATCH 182/460] Test a serial in another case in the mDNS name --- crates/core/src/discovery.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/core/src/discovery.rs b/crates/core/src/discovery.rs index deb0838..a96c771 100644 --- a/crates/core/src/discovery.rs +++ b/crates/core/src/discovery.rs @@ -295,6 +295,14 @@ mod tests { ); } + #[test] + fn a_serial_in_another_letter_case_is_stripped_and_masked() { + assert_eq!( + announced_name("sim0001234 desk-sim0001234._ssc._tcp.local.", Some("SIM0001234")), + format!("{} desk", mask_serial("SIM0001234")) + ); + } + #[test] fn without_a_txt_serial_the_instance_name_stays_unchanged() { assert_eq!( From b2413ececcc3faa68fa69eb05c3866d02d6f473e Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:48:56 +0200 Subject: [PATCH 183/460] Mask the serial in the mDNS name in any letter case --- crates/core/src/discovery.rs | 37 +++++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/crates/core/src/discovery.rs b/crates/core/src/discovery.rs index a96c771..6125600 100644 --- a/crates/core/src/discovery.rs +++ b/crates/core/src/discovery.rs @@ -98,11 +98,35 @@ fn add_found(mut found: Vec, candidate: Found) -> Vec { /// Speakers announce "-"; the suffix only repeats the TXT serial. A name /// that is nothing but the suffix is kept, so the speaker never loses its announced name. +// Serials are ASCII; comparing without case keeps a differently cased copy from showing whole. fn without_serial_suffix<'a>(name: &'a str, serial: &str) -> &'a str { let suffix = format!("{SERIAL_SEPARATOR}{serial}"); - name.strip_suffix(&suffix) - .filter(|rest| !rest.is_empty()) - .unwrap_or(name) + let Some(start) = name.len().checked_sub(suffix.len()) else { + return name; + }; + let ends_with_serial = name + .get(start..) + .is_some_and(|end| end.eq_ignore_ascii_case(&suffix)); + // A name that is only the serial keeps it, masked below, rather than becoming empty. + if ends_with_serial && start > 0 { + return &name[..start]; + } + name +} + +fn with_serial_masked(name: &str, serial: &str) -> String { + let lower_name = name.to_ascii_lowercase(); + let lower_serial = serial.to_ascii_lowercase(); + let mask = mask_serial(serial); + let mut masked = String::new(); + let mut rest = 0; + while let Some(offset) = lower_name[rest..].find(&lower_serial) { + masked.push_str(&name[rest..rest + offset]); + masked.push_str(&mask); + rest += offset + serial.len(); + } + masked.push_str(&name[rest..]); + masked } fn found_from_parts( @@ -116,7 +140,7 @@ fn found_from_parts( let serial = txt_value(serial); let mut name = instance_name(fullname, service_type).to_owned(); if let Some(serial) = &serial { - name = without_serial_suffix(&name, serial).replace(serial.as_str(), &mask_serial(serial)); + name = with_serial_masked(without_serial_suffix(&name, serial), serial); } Some(Found { name: capped(&name), @@ -298,7 +322,10 @@ mod tests { #[test] fn a_serial_in_another_letter_case_is_stripped_and_masked() { assert_eq!( - announced_name("sim0001234 desk-sim0001234._ssc._tcp.local.", Some("SIM0001234")), + announced_name( + "sim0001234 desk-sim0001234._ssc._tcp.local.", + Some("SIM0001234") + ), format!("{} desk", mask_serial("SIM0001234")) ); } From dc3a0379c4daccd1ebf64df34bf98383fdb87c8c Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:20:36 +0200 Subject: [PATCH 184/460] Test writes against poll replies older than them --- crates/app/src/writes.rs | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/crates/app/src/writes.rs b/crates/app/src/writes.rs index dd4c041..71d3e09 100644 --- a/crates/app/src/writes.rs +++ b/crates/app/src/writes.rs @@ -467,6 +467,41 @@ mod tests { ); } + fn polled(speaker: &mut SpeakerState, path: &str, value: Value) { + let path = paths::path(path); + speaker.apply(Event::Value { path, value }, 0.0); + } + + #[test] + fn a_second_toggle_before_the_confirmation_toggles_back() { + let mut speaker = speaker("KH 120 II"); + let catalog = Catalog::embedded(); + let first = speaker.request(Intent::Toggle(Switch::Mute), catalog, 0.0); + assert_eq!(first, set(paths::MUTE, Value::Bool(true))); + let second = speaker.request(Intent::Toggle(Switch::Mute), catalog, 0.1); + assert_eq!(second, set(paths::MUTE, Value::Bool(false))); + } + + #[test] + fn a_poll_reply_older_than_the_write_does_not_undo_it() { + let mut speaker = speaker("KH 120 II"); + speaker.request(Intent::Toggle(Switch::Mute), Catalog::embedded(), 0.0); + polled(&mut speaker, paths::MUTE, Value::Bool(false)); + assert_eq!( + speaker.latest(&paths::path(paths::MUTE)), + Some(&Value::Bool(true)) + ); + } + + #[test] + fn a_level_poll_older_than_the_write_keeps_the_fader_holding() { + let mut speaker = speaker("KH 120 II"); + polled(&mut speaker, paths::OUTPUT_LEVEL, Value::from(80.0)); + speaker.request(Intent::Level(90.0), Catalog::embedded(), 0.0); + polled(&mut speaker, paths::OUTPUT_LEVEL, Value::from(80.0)); + assert_eq!(speaker.fader.shown(80.0, 0.5), 90.0); + } + #[test] fn a_switch_without_a_reported_value_is_refused() { let mut speaker = speaker("KH 120 II"); From 6d39029d24899a3d45f9d9e426b8e716234e3501 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:23:34 +0200 Subject: [PATCH 185/460] Document address parsing and pin its edge cases --- crates/core/src/address.rs | 4 ++++ crates/core/src/mask.rs | 1 + crates/core/tests/address.rs | 22 ++++++++++++++++++++++ 3 files changed, 27 insertions(+) diff --git a/crates/core/src/address.rs b/crates/core/src/address.rs index 3fa1d3c..778f24a 100644 --- a/crates/core/src/address.rs +++ b/crates/core/src/address.rs @@ -3,6 +3,7 @@ use std::fmt; use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV6}; +/// The TCP port Sennheiser Sound Control listens on. pub const SSC_PORT: u16 = 45; const SCOPE_SEPARATOR: char = '%'; @@ -32,10 +33,13 @@ impl fmt::Display for AddressError { impl std::error::Error for AddressError {} +/// `fe80::/10`: reachable only through one interface, so it needs a scope. pub fn is_link_local(address: &Ipv6Addr) -> bool { address.segments()[0] & LINK_LOCAL_PREFIX_MASK == LINK_LOCAL_PREFIX } +/// Reads an address as typed or stored: optional brackets and blanks, a numeric `%index` +/// scope (required for link-local), no IPv4 or IPv4-mapped form. pub fn parse_address(input: &str, port: u16) -> Result { let trimmed = input.trim(); let unbracketed = trimmed diff --git a/crates/core/src/mask.rs b/crates/core/src/mask.rs index 4c2b2f4..76019e0 100644 --- a/crates/core/src/mask.rs +++ b/crates/core/src/mask.rs @@ -24,6 +24,7 @@ pub fn mask_host(address: &SocketAddrV6) -> String { format!("{prefix}{MASK_PLACEHOLDER}{last_byte:02x}{scope}") } +/// Keeps the last three characters, enough to tell two speakers apart in a log. pub fn mask_serial(serial: &str) -> String { let characters: Vec = serial.chars().collect(); if characters.len() <= KEPT_SERIAL_CHARACTERS { diff --git a/crates/core/tests/address.rs b/crates/core/tests/address.rs index c035864..b97c94b 100644 --- a/crates/core/tests/address.rs +++ b/crates/core/tests/address.rs @@ -91,6 +91,28 @@ fn scope_with_plus_sign_is_not_numeric() { ); } +#[test] +fn empty_scope_is_not_numeric() { + assert_eq!( + parse_address("fe80::1%", SSC_PORT), + Err(AddressError::ScopeNotNumeric) + ); +} + +#[test] +fn loopback_needs_no_scope() { + let address = parse_address("::1", SSC_PORT).unwrap(); + assert_eq!(*address.ip(), Ipv6Addr::LOCALHOST); + assert_eq!(address.scope_id(), 0); +} + +#[test] +fn bracketed_address_keeps_its_scope() { + let address = parse_address(" [fe80::1%12] ", SSC_PORT).unwrap(); + assert_eq!(address.scope_id(), 12); + assert_eq!(format_host(&address), "fe80::1%12"); +} + #[test] fn ipv4_mapped_address_is_refused() { assert_eq!( From 70916f5c6fab9cc65de020a5b40314312f41b651 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:23:35 +0200 Subject: [PATCH 186/460] Pin the read budgets and guard the read-only test --- crates/core/tests/catalog.rs | 7 ++++++- crates/core/tests/client.rs | 11 ++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/core/tests/catalog.rs b/crates/core/tests/catalog.rs index 5700f4a..9c5c1ec 100644 --- a/crates/core/tests/catalog.rs +++ b/crates/core/tests/catalog.rs @@ -71,7 +71,12 @@ fn kh120ii_has_logo_but_kh750_not() { #[test] fn unknown_model_is_read_only() { - for candidate in &Catalog::embedded().entries { + let entries = &Catalog::embedded().entries; + // Without a writable entry for a known model the loop below would prove nothing. + assert!(entries + .iter() + .any(|candidate| candidate.is_writable(MONITOR))); + for candidate in entries { assert!( !candidate.is_writable(UNKNOWN_PRODUCT), "{}", diff --git a/crates/core/tests/client.rs b/crates/core/tests/client.rs index 2f29f70..e20d8ac 100644 --- a/crates/core/tests/client.rs +++ b/crates/core/tests/client.rs @@ -5,7 +5,8 @@ use std::time::{Duration, Instant}; use neumann_control_core::simulator::{Fault, SimModel, Simulator}; use neumann_control_core::ssc::client::{ - Client, NodeKind, SscError, MAX_LINE_BYTES, MAX_RESPONSE_BYTES, MAX_RESPONSE_LINES, + Client, NodeKind, SscError, MAX_LINE_BYTES, MAX_READ_DURATION, MAX_RESPONSE_BYTES, + MAX_RESPONSE_LINES, }; use neumann_control_core::ssc::wire::SscPath; use serde_json::{json, Value}; @@ -351,3 +352,11 @@ fn reply_with_null_value_keeps_the_connection() { assert!(!error.is_connection_lost()); assert!(!client.is_closed()); } + +#[test] +fn the_read_budgets_are_the_specified_ones() { + assert_eq!(MAX_LINE_BYTES, 1024 * 1024); + assert_eq!(MAX_RESPONSE_LINES, 256); + assert_eq!(MAX_RESPONSE_BYTES, 4 * 1024 * 1024); + assert_eq!(MAX_READ_DURATION, Duration::from_secs(10)); +} From dbbac77c08eb468b0411447d4931e11557c7adf7 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:23:35 +0200 Subject: [PATCH 187/460] Keep wire::merge inside the core crate --- crates/core/src/ssc/wire.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/core/src/ssc/wire.rs b/crates/core/src/ssc/wire.rs index 3d2806c..d09d704 100644 --- a/crates/core/src/ssc/wire.rs +++ b/crates/core/src/ssc/wire.rs @@ -91,7 +91,7 @@ pub struct DeviceError { } /// Merges `incoming` into `target`: objects recursively, anything else is replaced. -pub fn merge(target: &mut Value, incoming: Value) { +pub(crate) fn merge(target: &mut Value, incoming: Value) { match (target, incoming) { (Value::Object(existing), Value::Object(additions)) => { for (key, value) in additions { From 74dac302eee1775dfa25c71c4330887083aa6ead Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:30:50 +0200 Subject: [PATCH 188/460] Hold a sent value until the speaker answers its write The worker answers one request at a time, so a read answered before a write could arrive after it and undo the sent value: a second toggle repeated the first, and a take-back asked whether someone else had changed the value. --- crates/app/src/state.rs | 94 ++++++++++++++++++++++++++++-------- crates/app/src/writes.rs | 72 ++++++++++++++++++++++++--- crates/core/src/speaker.rs | 10 +++- crates/core/tests/speaker.rs | 10 ++-- 4 files changed, 154 insertions(+), 32 deletions(-) diff --git a/crates/app/src/state.rs b/crates/app/src/state.rs index b2713f8..6be8222 100644 --- a/crates/app/src/state.rs +++ b/crates/app/src/state.rs @@ -110,8 +110,8 @@ pub struct SpeakerState { /// What this frame sent: path, value before, value written. The app takes it into the /// history at the end of the frame. pub written: Vec<(SscPath, Option, Value)>, - /// Sent values the speaker has not reported back yet; the value before of the next write. - pending: BTreeMap, + /// Sent values the speaker has not answered yet; the value before of the next write. + pending: BTreeMap, /// `device/temperature` as reported (kelvin), for the device panel's trend. pub temperature: Trend, /// The active warnings, empty while there are none. @@ -119,6 +119,13 @@ pub struct SpeakerState { pub full_scan: FullScanState, } +/// The newest value sent to one path and how many of its `Set`s are still unanswered. +#[derive(Clone, Debug, PartialEq)] +struct Pending { + value: Value, + unanswered: usize, +} + impl SpeakerState { pub fn new(key: SpeakerKey, address: SocketAddrV6) -> SpeakerState { SpeakerState { @@ -191,18 +198,52 @@ impl SpeakerState { if *path == paths::path(paths::IDENTIFY) { return; } - let base = self.pending.get(path).or_else(|| self.values.get(path)); + let base = self.latest(path); let before = base.map(|base| matching(base, value)).filter(is_given); - let pending = merged(base, value); + let merged = merged(base, value); + let unanswered = self + .pending + .get(path) + .map_or(0, |pending| pending.unanswered); self.written.push((path.clone(), before, value.clone())); + let pending = Pending { + value: merged, + unanswered: unanswered + 1, + }; self.pending.insert(path.clone(), pending); } - /// The newest sent value not yet reported back, else the reported one: what the speaker - /// holds once its writes are through. A take-back compares with this, so its own write + /// The newest sent value not yet answered, else the reported one: what the speaker holds + /// once its writes are through. The next toggle and a take-back start from this, so a write /// still on the way does not count as someone else's change. pub fn latest(&self, path: &SscPath) -> Option<&Value> { - self.pending.get(path).or_else(|| self.values.get(path)) + let sent = self.pending.get(path).map(|pending| &pending.value); + sent.or_else(|| self.values.get(path)) + } + + /// Counts one `Set` to `path` as answered; when none is left, the sent value is no longer + /// held. Whether the speaker took it, its next report says. + fn settle(&mut self, path: &SscPath) { + if let Some(pending) = self.pending.get_mut(path) { + pending.unanswered = pending.unanswered.saturating_sub(1); + if pending.unanswered > 0 { + return; + } + self.pending.remove(path); + } + if *path == paths::path(paths::OUTPUT_LEVEL) { + self.fader.confirmed(); + } + if let Some(knob) = self.knobs.get_mut(path) { + knob.confirmed(); + } + } + + fn take_reported(&mut self, path: SscPath, value: Value, now: f64) { + self.absent.remove(&path); + self.feed_meters(&path, &value, now); + self.record_trends(&path, &value, now); + self.values.insert(path, value); } pub fn apply(&mut self, event: Event, now: f64) { @@ -225,33 +266,40 @@ impl SpeakerState { self.temperature.record(now, None); self.warnings.record(now, None); } + // The worker answers one request at a time, so a read answered before a write was + // sent can arrive after it; the write's own answer follows and wins. Event::Value { path, value } => { - if path == paths::path(paths::OUTPUT_LEVEL) { - self.fader.confirmed(); + if !self.pending.contains_key(&path) { + self.take_reported(path, value, now); } - if let Some(knob) = self.knobs.get_mut(&path) { - knob.confirmed(); + } + Event::Written { path, value } => { + self.settle(&path); + if !self.pending.contains_key(&path) { + self.take_reported(path, value, now); } - self.absent.remove(&path); - self.pending.remove(&path); - self.feed_meters(&path, &value, now); - self.record_trends(&path, &value, now); - self.values.insert(path, value); } Event::Absent(path) => { + // Every later write to the path is answered the same way, so none is awaited. + self.pending.remove(&path); + self.settle(&path); self.values.remove(&path); self.absent.insert(path); } - Event::WriteRejected { error, .. } => { + // A re-read or, for a lost connection, the reconnect follows; it shows the real value. + Event::WriteRejected { path, error } => { + self.settle(&path); let message = Message::Rejected(error.code); self.set_message(Some(message), MessageSource::Write); } - Event::WriteFailed { .. } => { + Event::WriteFailed { path, .. } => { + self.settle(&path); self.set_message(Some(Message::WriteFailed), MessageSource::Write); } Event::SerialMismatch { found, .. } => { self.status = Status::Unreachable; self.response_time = None; + self.pending.clear(); let message = Message::SerialMismatch(mask_serial(&found)); self.set_message(Some(message), MessageSource::Connection); self.temperature.record(now, None); @@ -626,12 +674,18 @@ mod tests { } #[test] - fn a_reported_level_ends_the_held_fader_value() { + fn the_answer_to_the_level_write_ends_the_held_fader_value() { let mut speaker = state(); + let level = path(paths::OUTPUT_LEVEL); speaker.fader.sent(90.0, 0.0); + speaker.record_written(&level, &json!(90.0)); speaker.apply(value_event("device/name", json!("left")), 0.5); assert_eq!(speaker.fader.shown(80.0, 0.5), 90.0); - speaker.apply(value_event(paths::OUTPUT_LEVEL, json!(80.0)), 0.5); + let answer = Event::Written { + path: level, + value: json!(80.0), + }; + speaker.apply(answer, 0.5); assert_eq!(speaker.fader.shown(80.0, 0.5), 80.0); } diff --git a/crates/app/src/writes.rs b/crates/app/src/writes.rs index 71d3e09..0b17aab 100644 --- a/crates/app/src/writes.rs +++ b/crates/app/src/writes.rs @@ -157,7 +157,7 @@ impl SpeakerState { Intent::Level(level) => Ok(Value::from(*level)), Intent::Write { value, .. } => Ok(value.clone()), Intent::Toggle(_) => { - let current = self.values.get(path).and_then(Value::as_bool); + let current = self.latest(path).and_then(Value::as_bool); let current = current.ok_or(Refusal::ValueUnknown)?; Ok(Value::Bool(!current)) } @@ -171,9 +171,12 @@ mod tests { use neumann_control_core::address::parse_address; use neumann_control_core::discovery::Found; use neumann_control_core::speaker::{Event, Identity}; + use neumann_control_core::ssc::wire::DeviceError; use crate::state::SpeakerKey; + const READ_ONLY_CODE: i64 = 405; + const ADDRESS_TEXT: &str = "fe80::2a36:38ff:fe12:3456%7"; fn speaker(product: &str) -> SpeakerState { @@ -382,11 +385,14 @@ mod tests { (path.clone(), Some(Value::from(90.0)), Value::from(95.0)), ] ); - let reported = Event::Value { - path: path.clone(), - value: Value::from(93.0), - }; - speaker.apply(reported, 0.5); + // Both writes answered; the speaker kept 93 of the 95. + for answered in [90.0, 93.0] { + let answer = Event::Written { + path: path.clone(), + value: Value::from(answered), + }; + speaker.apply(answer, 0.5); + } speaker.request(Intent::Level(85.0), Catalog::embedded(), 1.0); assert_eq!(speaker.written[0].1, Some(Value::from(93.0))); } @@ -502,6 +508,60 @@ mod tests { assert_eq!(speaker.fader.shown(80.0, 0.5), 90.0); } + fn answered(speaker: &mut SpeakerState, path: &str, value: Value) { + let path = paths::path(path); + speaker.apply(Event::Written { path, value }, 0.0); + } + + #[test] + fn two_toggles_show_the_second_only_when_both_are_answered() { + let mut speaker = speaker("KH 120 II"); + let catalog = Catalog::embedded(); + speaker.request(Intent::Toggle(Switch::Mute), catalog, 0.0); + speaker.request(Intent::Toggle(Switch::Mute), catalog, 0.1); + let mute = paths::path(paths::MUTE); + answered(&mut speaker, paths::MUTE, Value::Bool(true)); + assert_eq!(speaker.values.get(&mute), Some(&Value::Bool(false))); + assert_eq!(speaker.latest(&mute), Some(&Value::Bool(false))); + answered(&mut speaker, paths::MUTE, Value::Bool(false)); + polled(&mut speaker, paths::MUTE, Value::Bool(true)); + assert_eq!(speaker.values.get(&mute), Some(&Value::Bool(true))); + } + + #[test] + fn a_rejected_write_lets_the_re_read_show_the_kept_value() { + let mut speaker = speaker("KH 120 II"); + speaker.request(Intent::Toggle(Switch::Mute), Catalog::embedded(), 0.0); + let mute = paths::path(paths::MUTE); + let error = DeviceError { + code: READ_ONLY_CODE, + description: String::new(), + }; + speaker.apply( + Event::WriteRejected { + path: mute.clone(), + error, + }, + 0.0, + ); + polled(&mut speaker, paths::MUTE, Value::Bool(false)); + assert_eq!(speaker.latest(&mute), Some(&Value::Bool(false))); + } + + #[test] + fn a_failed_write_lets_the_reconnect_read_show_the_value() { + let mut speaker = speaker("KH 120 II"); + speaker.request(Intent::Toggle(Switch::Mute), Catalog::embedded(), 0.0); + let mute = paths::path(paths::MUTE); + let failed = Event::WriteFailed { + path: mute.clone(), + message: String::new(), + }; + speaker.apply(failed, 0.0); + polled(&mut speaker, paths::MUTE, Value::Bool(false)); + assert_eq!(speaker.latest(&mute), Some(&Value::Bool(false))); + } + #[test] fn a_switch_without_a_reported_value_is_refused() { let mut speaker = speaker("KH 120 II"); diff --git a/crates/core/src/speaker.rs b/crates/core/src/speaker.rs index 6f37d75..7f4ba69 100644 --- a/crates/core/src/speaker.rs +++ b/crates/core/src/speaker.rs @@ -88,10 +88,18 @@ pub enum Event { Connected(Identity), /// Sent once per outage; the text is the masked error. Unreachable(String), + /// A read: the value as the speaker held it when the request was answered. Value { path: SscPath, value: Value, }, + /// The speaker's answer to one `Set`: the value it now holds. Each `Set` gets exactly one of + /// `Written`, `Absent`, `WriteRejected` or `WriteFailed`, so the app can tell its own write + /// confirmed from a read that was answered before the write arrived. + Written { + path: SscPath, + value: Value, + }, /// The speaker does not have the path; it is not polled again on this connection, and /// every `Set` to it is answered with this again. Absent(SscPath), @@ -446,7 +454,7 @@ impl Worker { ) -> Result<(), Interruption> { let error = match session.client.set(&path, value) { Ok(confirmed) => { - self.outbox.emit(Event::Value { + self.outbox.emit(Event::Written { path, value: confirmed, }); diff --git a/crates/core/tests/speaker.rs b/crates/core/tests/speaker.rs index 16cd6bd..417c095 100644 --- a/crates/core/tests/speaker.rs +++ b/crates/core/tests/speaker.rs @@ -400,7 +400,7 @@ fn commands_run_before_polling() { let after_send = simulator.requests().len(); collect_until(&events, EVENT_DEADLINE, |event| { *event - == Event::Value { + == Event::Written { path: level.clone(), value: json!(90.0), } @@ -610,7 +610,7 @@ fn set_during_read_all_is_written_before_the_walk_ends() { }); let written = seen.iter().position(|event| { *event - == Event::Value { + == Event::Written { path: level.clone(), value: json!(90.0), } @@ -807,7 +807,7 @@ fn oversized_schema_fails_read_all_but_keeps_connection() { }); let seen = collect_until(&events, EVENT_DEADLINE, |event| { *event - == Event::Value { + == Event::Written { path: mute.clone(), value: json!(true), } @@ -855,12 +855,12 @@ fn commands_queued_during_long_work_keep_their_order() { value: json!(value), }); } - let last_set = Event::Value { + let last_set = Event::Written { path: level.clone(), value: json!(90.0), }; let seen = collect_until(&events, RECOVERY_DEADLINE, |event| *event == last_set); - let first_set = Event::Value { + let first_set = Event::Written { path: level, value: json!(80.0), }; From d4d298245d6b61c19230aec74ccffa881ef81dba Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:32:00 +0200 Subject: [PATCH 189/460] Test that the wheel steps from the held value --- crates/app/src/gesture.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/app/src/gesture.rs b/crates/app/src/gesture.rs index c8ae1da..d9a168c 100644 --- a/crates/app/src/gesture.rs +++ b/crates/app/src/gesture.rs @@ -161,6 +161,14 @@ mod tests { assert_eq!(fader.shown(80.0, 0.0), 80.0); } + #[test] + fn the_wheel_steps_from_the_held_sent_value() { + let mut fader = GestureState::default(); + fader.sent(90.0, 1.0); + fader.on_wheel(1.0, LEVEL_STEP, 80.0, LEVEL_RANGE, 1.1); + assert_eq!(fader.shown(80.0, 1.1), 90.5); + } + #[test] fn wheel_deadline_follows_the_last_step_and_ends_when_due() { let range = LEVEL_RANGE; From 338c0dbd7af57f94ab1b1bccf19a529c93c95b03 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:32:20 +0200 Subject: [PATCH 190/460] Step the wheel from the shown value --- crates/app/src/gesture.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/app/src/gesture.rs b/crates/app/src/gesture.rs index d9a168c..ef57936 100644 --- a/crates/app/src/gesture.rs +++ b/crates/app/src/gesture.rs @@ -51,7 +51,8 @@ impl GestureState { if whole == 0 { return; } - let base = self.pending.unwrap_or(current); + // The shown value: a sent level still held counts, not the one the speaker reported last. + let base = self.shown(current, now); let target = base + whole as f64 * step; let moved = bounded_move(base, target, &range).filter(|moved| *moved != base); if let Some(moved) = moved { From 6d74b90abf5eee28747e1d8a32b324233d02711c Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:34:08 +0200 Subject: [PATCH 191/460] Test the knob's held value against stale reads --- crates/app/src/writes.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/app/src/writes.rs b/crates/app/src/writes.rs index 0b17aab..2722807 100644 --- a/crates/app/src/writes.rs +++ b/crates/app/src/writes.rs @@ -562,6 +562,25 @@ mod tests { assert_eq!(speaker.latest(&mute), Some(&Value::Bool(false))); } + #[test] + fn a_knob_holds_its_sent_value_until_the_write_is_answered() { + let mut speaker = speaker("KH 120 II"); + let delay = paths::path(paths::DELAY); + polled(&mut speaker, paths::DELAY, Value::from(0)); + speaker + .knobs + .insert(delay.clone(), crate::gesture::GestureState::default()); + let intent = Intent::Write { + path: delay.clone(), + value: Value::from(48), + }; + speaker.request(intent, Catalog::embedded(), 0.0); + polled(&mut speaker, paths::DELAY, Value::from(0)); + assert_eq!(speaker.knobs[&delay].shown(0.0, 0.5), 48.0); + answered(&mut speaker, paths::DELAY, Value::from(48)); + assert_eq!(speaker.knobs[&delay].shown(0.0, 0.5), 0.0); + } + #[test] fn a_switch_without_a_reported_value_is_refused() { let mut speaker = speaker("KH 120 II"); From 7ab0b78ad0e2c6c1634d7fa44156d85e5c3a9838 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:36:17 +0200 Subject: [PATCH 192/460] Set up mutation testing with cargo-mutants --- .cargo/mutants.toml | 13 +++++++++++++ .gitignore | 1 + README.md | 5 +++++ 3 files changed, 19 insertions(+) create mode 100644 .cargo/mutants.toml diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml new file mode 100644 index 0000000..f3e33a6 --- /dev/null +++ b/.cargo/mutants.toml @@ -0,0 +1,13 @@ +# cargo-mutants: `cargo mutants --workspace` (see README, "Mutation testing"). +exclude_globs = [ + # Test infrastructure; its own faults show up as failing tests elsewhere. + "crates/core/src/simulator/**", + # Drawing code: a moved pixel survives every test by design, which would bury real gaps. + "crates/app/src/views/**", + "crates/app/src/ui_kit/**", + "crates/app/src/main.rs", +] +# The core's integration tests need the simulator. +features = ["neumann-control-core/test-support"] +# Kittests wait on real sockets; a slow mutant is not a caught one. +timeout_multiplier = 3.0 diff --git a/.gitignore b/.gitignore index ecb817e..5551739 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target /release +/mutants.out*/ diff --git a/README.md b/README.md index f643bc7..3615c25 100644 --- a/README.md +++ b/README.md @@ -372,6 +372,11 @@ and writes `release\neumann-control-\` (or `-OutputRoot `) with executable, `LICENSE`, `THIRD-PARTY-LICENSES.html` and `SHA256SUMS`. +Mutation testing uses `cargo-mutants` with the settings in `.cargo/mutants.toml` (simulator +and drawing code left out): `cargo mutants -p neumann-control-core` for the core, and +`cargo mutants --workspace --in-diff ` for the lines a change touched. A full run over +the app takes hours. + The workspace has two crates: `crates/core` (the SSC client, discovery, the path catalog, settings and a speaker simulator for the tests) and `crates/app` (the window). From 3e8efc4a5567c9c292227433e77189604a87dd8f Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:36:17 +0200 Subject: [PATCH 193/460] Note the write and masking fixes in the changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c89f2e..4d70ba1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,14 @@ Q is the shelf's slope. - A value that rounds to zero showed a minus ("-0 dB"). - Number fields with a converted unit showed the device's unit as well ("0 1/48 kHz ms"). +- A second click on a switch before the speaker had answered the first repeated it (two quick + clicks on mute muted twice), and a fader or knob could jump back to its old value for a moment + after a write. +- **Undo** right after a write asked whether the value had changed since, and wrote nothing. +- The mouse wheel stepped a fader or knob from the level before the last drag, not from the shown + one. +- The MAC addresses in **All values** showed only `…"]`; each address is now masked on its own. +- A speaker name announced with the serial in other letter case showed the serial unmasked. ## [0.1.0] - 2026-10-18 From 0e4d2a9f62098c40d6e2c12de26ba224bdad2b68 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:43:33 +0200 Subject: [PATCH 194/460] Update eframe and egui_kittest to 0.37 Follows the renames (WidgetType to Role, Id::new to Id::unique, Ui::id to scope_id); the history tooltip test now waits for the tooltip, which 0.37 shows a few frames later. --- Cargo.lock | 343 +++++++++++++++++++----- crates/app/Cargo.toml | 4 +- crates/app/src/app/desk.rs | 4 +- crates/app/src/app/eq.rs | 2 +- crates/app/src/views/add_address.rs | 2 +- crates/app/src/views/all_values.rs | 2 +- crates/app/src/views/compact.rs | 6 +- crates/app/src/views/details_fields.rs | 9 +- crates/app/src/views/eq_graph.rs | 6 +- crates/app/src/views/eq_window.rs | 2 +- crates/app/src/views/group_dialog.rs | 2 +- crates/app/src/views/group_strip.rs | 8 +- crates/app/src/views/history.rs | 4 +- crates/app/src/views/knob.rs | 2 +- crates/app/src/views/meters.rs | 10 +- crates/app/src/views/panel.rs | 4 +- crates/app/src/views/preset_dialog.rs | 4 +- crates/app/src/views/remove_speaker.rs | 2 +- crates/app/src/views/scan_choice.rs | 2 +- crates/app/src/views/settings_dialog.rs | 2 +- crates/app/src/views/strip.rs | 10 +- crates/app/src/views/trends.rs | 7 +- crates/app/src/views/wheel.rs | 4 +- crates/app/tests/eq.rs | 6 +- crates/app/tests/views.rs | 3 + crates/app/tests/write_gestures.rs | 1 + 26 files changed, 326 insertions(+), 125 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6e5b68d..870cf29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,21 +20,21 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "accesskit" -version = "0.24.1" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932" dependencies = [ "uuid", ] [[package]] name = "accesskit_atspi_common" -version = "0.18.1" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4" dependencies = [ "accesskit", - "accesskit_consumer 0.36.0", + "accesskit_consumer", "atspi-common", "phf", "serde", @@ -43,43 +43,37 @@ dependencies = [ [[package]] name = "accesskit_consumer" -version = "0.35.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344" dependencies = [ "accesskit", - "hashbrown 0.16.1", -] - -[[package]] -name = "accesskit_consumer" -version = "0.36.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" -dependencies = [ - "accesskit", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] -name = "accesskit_consumer" -version = "0.38.0" +name = "accesskit_ios" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" +checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937" dependencies = [ "accesskit", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", ] [[package]] name = "accesskit_macos" -version = "0.26.3" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4" dependencies = [ "accesskit", - "accesskit_consumer 0.38.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "objc2 0.5.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -87,9 +81,9 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.21.1" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -105,13 +99,13 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.32.1" +version = "0.35.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "static_assertions", "windows", "windows-core", @@ -119,11 +113,12 @@ dependencies = [ [[package]] name = "accesskit_winit" -version = "0.32.2" +version = "0.34.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77" dependencies = [ "accesskit", + "accesskit_ios", "accesskit_macos", "accesskit_unix", "accesskit_windows", @@ -207,6 +202,7 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.61.2", + "wl-clipboard-rs", "x11rb", ] @@ -778,9 +774,9 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "ecolor" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +checksum = "31bcbef0d4263a63eb32c4834e5d711613035ce1333468e107d928923e21e6e2" dependencies = [ "bytemuck", "emath", @@ -788,9 +784,9 @@ dependencies = [ [[package]] name = "eframe" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +checksum = "9c5642501a65ca657d05f00c643a5c14ff2346cb3818032bfe23d58e1f8e5f84" dependencies = [ "ahash", "bytemuck", @@ -799,6 +795,7 @@ dependencies = [ "egui-wgpu", "egui-winit", "egui_glow", + "egui_system_font_provider", "glutin", "glutin-winit", "image", @@ -812,6 +809,7 @@ dependencies = [ "pollster", "profiling", "raw-window-handle", + "send_wrapper", "static_assertions", "wasm-bindgen", "web-sys", @@ -823,9 +821,9 @@ dependencies = [ [[package]] name = "egui" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +checksum = "bec9144f49b7a28b5f395d3147e325b4fe3e77ba686eac11d1bdf0a7da3f375f" dependencies = [ "accesskit", "ahash", @@ -843,9 +841,9 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +checksum = "0309cbe1febed48af5b974e4bcc424b35e9664863a4d402697147b57a488c864" dependencies = [ "ahash", "bytemuck", @@ -863,16 +861,18 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +checksum = "e4e449f4c4a2a60c7ccb4ba7fa4078417bfe1f9ddc03b5e05ce75748e33a70de" dependencies = [ "accesskit_winit", "arboard", + "block2 0.6.2", "bytemuck", "egui", "log", "objc2 0.6.5", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", "profiling", @@ -880,14 +880,15 @@ dependencies = [ "smithay-clipboard", "web-time", "webbrowser", + "windows-sys 0.61.2", "winit", ] [[package]] name = "egui_glow" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +checksum = "9275ca5cc35a6b7c94e0e8cedf22542f66cb0cf45ed8ab2657c78ff49fec7268" dependencies = [ "bytemuck", "egui", @@ -899,9 +900,9 @@ dependencies = [ [[package]] name = "egui_kittest" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +checksum = "0979c2e37860fcf13a2bea77508607f067a9a6a84ba9bd47b5e298582fb59b86" dependencies = [ "eframe", "egui", @@ -911,6 +912,21 @@ dependencies = [ "toml", ] +[[package]] +name = "egui_system_font_provider" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f2a2fdfe3c05dd9f691d2b1388b2312d6dce1af502f217c22136d22d9bd76f6" +dependencies = [ + "epaint", + "fontique", + "log", + "poll-promise", + "skrifa", + "sys-locale", + "unicode-script", +] + [[package]] name = "either" version = "1.19.0" @@ -919,9 +935,9 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[package]] name = "emath" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +checksum = "e73ce90f54750be8672c02be0743fe47530e6f251e066f0ee2869b59eb297af5" dependencies = [ "bytemuck", ] @@ -955,9 +971,9 @@ dependencies = [ [[package]] name = "epaint" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +checksum = "97bac86ae3dde93fc3951e790951caea739ff39a6c89d909cdaf1d9278dbb1bd" dependencies = [ "ahash", "bytemuck", @@ -974,15 +990,16 @@ dependencies = [ "skrifa", "smallvec", "unicode-general-category", + "unicode-properties", "unicode-segmentation", "vello_cpu", ] [[package]] name = "epaint_default_fonts" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" +checksum = "cfc1cc505827f4c23eedc297975a0fd038268d41293f389dd8ff3b574aefab19" [[package]] name = "equivalent" @@ -1068,6 +1085,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.10" @@ -1090,6 +1113,12 @@ dependencies = [ "spin", ] +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -1105,6 +1134,28 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "fontique" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6688bc1294fe7117d788937b6c53480169b29c566954af490830d4c09da9516a" +dependencies = [ + "hashbrown 0.17.1", + "linebender_resource_handle", + "memmap2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-text", + "objc2-foundation 0.3.2", + "parlance", + "read-fonts", + "roxmltree", + "smallvec", + "windows", + "windows-core", + "yeslogic-fontconfig-sys", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1248,15 +1299,16 @@ dependencies = [ [[package]] name = "glifo" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", + "png", "skrifa", "smallvec", "vello_common", @@ -1387,6 +1439,15 @@ dependencies = [ "smallvec", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1395,7 +1456,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -1404,7 +1465,7 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -1670,12 +1731,12 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kittest" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +checksum = "0bb81a721ba76aa095b7f45deb180504f786904e56ca4b8e141f66eb89a78b75" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", + "accesskit_consumer", ] [[package]] @@ -1955,6 +2016,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2141,6 +2211,16 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.2", + "objc2-core-foundation", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -2348,6 +2428,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "owned_ttf_parser" version = "0.25.1" @@ -2386,6 +2476,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "parlance" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73072a9470554c8e794778acece2312661218aaabb635600957d952cf01c716e" + [[package]] name = "peniko" version = "0.6.1" @@ -2405,11 +2501,22 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "phf" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ "phf_macros", "phf_shared", @@ -2418,9 +2525,9 @@ dependencies = [ [[package]] name = "phf_generator" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ "fastrand", "phf_shared", @@ -2428,9 +2535,9 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" dependencies = [ "phf_generator", "phf_shared", @@ -2441,9 +2548,9 @@ dependencies = [ [[package]] name = "phf_shared" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" dependencies = [ "siphasher", ] @@ -2510,6 +2617,16 @@ dependencies = [ "miniz_oxide 0.8.9", ] +[[package]] +name = "poll-promise" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6a58fecbf9da8965bcdb20ce4fd29788d1acee68ddbb64f0ba1b81bccdb7df" +dependencies = [ + "document-features", + "static_assertions", +] + [[package]] name = "polling" version = "3.11.0" @@ -2732,6 +2849,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -2831,6 +2957,12 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "serde" version = "1.0.229" @@ -3142,6 +3274,15 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -3377,6 +3518,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + [[package]] name = "ttf-parser" version = "0.25.1" @@ -3415,6 +3567,18 @@ version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -3458,28 +3622,30 @@ dependencies = [ [[package]] name = "vello_common" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" dependencies = [ "bytemuck", "fearless_simd", "guillotiere", "log", "peniko", + "png", "smallvec", "thiserror 2.0.21", ] [[package]] name = "vello_cpu" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" dependencies = [ "bytemuck", "glifo", "hashbrown 0.17.1", + "png", "vello_common", ] @@ -4217,6 +4383,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.5", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "writeable" version = "0.6.4" @@ -4286,6 +4470,17 @@ version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 3a94e1e..8485b19 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -17,7 +17,7 @@ path = "src/lib.rs" [dependencies] neumann-control-core = { path = "../core" } serde_json = "1" -eframe = "0.36" +eframe = "0.37" log = "0.4" simplelog = "0.12" # Already in the tree through simplelog; the local clock time of the history's entries. @@ -49,6 +49,6 @@ rfd = { version = "0.17", default-features = false } winresource = "0.1" [dev-dependencies] -egui_kittest = { version = "0.36", features = ["eframe"] } +egui_kittest = { version = "0.37", features = ["eframe"] } neumann-control-core = { path = "../core", features = ["test-support"] } tempfile = "3" diff --git a/crates/app/src/app/desk.rs b/crates/app/src/app/desk.rs index 448f0a7..aa74241 100644 --- a/crates/app/src/app/desk.rs +++ b/crates/app/src/app/desk.rs @@ -147,7 +147,7 @@ impl NeumannApp { }); }); ui.ctx() - .data_mut(|data| data.insert_temp(Id::new(STRIP_CENTERS_ID), centers)); + .data_mut(|data| data.insert_temp(Id::unique(STRIP_CENTERS_ID), centers)); let dropped = dragged.is_some() && ui.input(|input| input.pointer.any_released()); if dropped { let order = order_of(&items, &ids, &self.settings.order); @@ -364,7 +364,7 @@ fn previewed( .filter(|index| group_of(groups, &ids[*index]) == section) .collect(); let centers: HashMap = context - .data(|data| data.get_temp(Id::new(STRIP_CENTERS_ID))) + .data(|data| data.get_temp(Id::unique(STRIP_CENTERS_ID))) .unwrap_or_default(); let known: Option> = others .iter() diff --git a/crates/app/src/app/eq.rs b/crates/app/src/app/eq.rs index 3f1ccab..1f33453 100644 --- a/crates/app/src/app/eq.rs +++ b/crates/app/src/app/eq.rs @@ -224,7 +224,7 @@ impl NeumannApp { }) .collect() }); - let id = Id::new(EQ_VIEWPORT_ID).with(&window.key); + let id = Id::unique(EQ_VIEWPORT_ID).with(&window.key); let own_curves = bands .as_deref() .map(|bands| band_curves(bands, &frequencies)) diff --git a/crates/app/src/views/add_address.rs b/crates/app/src/views/add_address.rs index f099f97..fe6c755 100644 --- a/crates/app/src/views/add_address.rs +++ b/crates/app/src/views/add_address.rs @@ -32,7 +32,7 @@ pub enum AddAddressAction { /// `listed` are the manual speakers already in the settings. pub fn show(ui: &mut Ui, form: &mut AddAddressForm, listed: &[ManualSpeaker]) -> AddAddressAction { - let modal = Modal::new(Id::new(MODAL_ID)).show(ui.ctx(), |ui| contents(ui, form, listed)); + let modal = Modal::new(Id::unique(MODAL_ID)).show(ui.ctx(), |ui| contents(ui, form, listed)); if modal.should_close() { return AddAddressAction::Cancel; } diff --git a/crates/app/src/views/all_values.rs b/crates/app/src/views/all_values.rs index e1ced40..547d8c1 100644 --- a/crates/app/src/views/all_values.rs +++ b/crates/app/src/views/all_values.rs @@ -23,7 +23,7 @@ pub fn show(context: &Context, speaker: &mut SpeakerState, catalog: &Catalog) -> let mut open = true; let mut commands = Vec::new(); Window::new(texts::all_values_title(&speaker.display_name())) - .id(Id::new((WINDOW_ID, speaker.key.clone()))) + .id(Id::unique((WINDOW_ID, speaker.key.clone()))) .default_size(DEFAULT_WINDOW_SIZE) .open(&mut open) .show(context, |ui| contents(ui, speaker, catalog, &mut commands)); diff --git a/crates/app/src/views/compact.rs b/crates/app/src/views/compact.rs index 320971e..469973a 100644 --- a/crates/app/src/views/compact.rs +++ b/crates/app/src/views/compact.rs @@ -5,8 +5,8 @@ use std::ops::RangeInclusive; use eframe::egui::{ - self, vec2, Align, Button, Color32, CornerRadius, Layout, Pos2, RichText, Sense, Stroke, Ui, - WidgetInfo, WidgetType, + self, vec2, Align, Button, Color32, CornerRadius, Layout, Pos2, RichText, Role, Sense, Stroke, + Ui, WidgetInfo, }; use neumann_control_core::catalog::Catalog; use neumann_control_core::settings::{DeskSettings, GroupSettings}; @@ -201,7 +201,7 @@ fn cell(ui: &mut Ui, width: f32, contents: impl FnOnce(&mut Ui) -> R) -> R { fn message_mark(ui: &mut Ui, text: &str, tokens: &Tokens) { let mark = RichText::new(MESSAGE_MARK).strong().color(tokens.danger); let response = ui.add(egui::Label::new(mark).sense(Sense::hover())); - response.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, text)); + response.widget_info(|| WidgetInfo::labeled(Role::Label, true, text)); response.on_hover_text(text); } diff --git a/crates/app/src/views/details_fields.rs b/crates/app/src/views/details_fields.rs index ccf9e16..dbc7027 100644 --- a/crates/app/src/views/details_fields.rs +++ b/crates/app/src/views/details_fields.rs @@ -56,7 +56,7 @@ impl Fields<'_> { .horizontal_wrapped(|ui| { let label = ui.label(entry.label(texts::language())); let field = NumberField { - id: ui.id().with(entry.path.to_string()), + id: ui.scope_id().with(entry.path.to_string()), device, range: min..=max, step: entry.step, @@ -106,7 +106,7 @@ impl Fields<'_> { .and_then(|entries| entries[index].as_f64()); let label = ui.label(name); let field = NumberField { - id: ui.id().with(entry.path.to_string()).with(index), + id: ui.scope_id().with(entry.path.to_string()).with(index), device, range: min..=max, step: entry.step, @@ -193,7 +193,10 @@ impl Fields<'_> { .and_then(Value::as_str) .map(str::to_owned); let enabled = self.enabled(entry, reported.is_some()); - let draft_id = ui.id().with(entry.path.to_string()).with(TEXT_DRAFT_ID); + let draft_id = ui + .scope_id() + .with(entry.path.to_string()) + .with(TEXT_DRAFT_ID); let field_id = draft_id.with(TEXT_FIELD_ID); let mut draft = ui .data(|data| data.get_temp::(draft_id)) diff --git a/crates/app/src/views/eq_graph.rs b/crates/app/src/views/eq_graph.rs index ae67a21..85dc066 100644 --- a/crates/app/src/views/eq_graph.rs +++ b/crates/app/src/views/eq_graph.rs @@ -5,8 +5,8 @@ use std::ops::RangeInclusive; use eframe::egui::{ - pos2, vec2, Align2, Color32, CursorIcon, FontId, Id, Mesh, Pos2, Rect, Sense, Shape, Stroke, - Ui, WidgetInfo, WidgetType, + pos2, vec2, Align2, Color32, CursorIcon, FontId, Id, Mesh, Pos2, Rect, Role, Sense, Shape, + Stroke, Ui, WidgetInfo, }; use super::wheel::notches_for; @@ -266,7 +266,7 @@ fn band_point( }; let response = ui.interact(rect, id, sense); let label = texts::eq_band(band + 1); - response.widget_info(|| WidgetInfo::labeled(WidgetType::Button, graph.editable, &label)); + response.widget_info(|| WidgetInfo::labeled(Role::Button, graph.editable, &label)); let mut actions = Vec::new(); if response.clicked() || response.drag_started() { actions.push(GraphAction::Select(band)); diff --git a/crates/app/src/views/eq_window.rs b/crates/app/src/views/eq_window.rs index 2353e26..77637bb 100644 --- a/crates/app/src/views/eq_window.rs +++ b/crates/app/src/views/eq_window.rs @@ -271,7 +271,7 @@ fn preset_dialog(ui: &mut Ui, dialog: &PresetDialogView) -> Option { } fn import_preview(ui: &mut Ui, import: &ImportView) -> Option { - let modal = Modal::new(Id::new(IMPORT_ID)).show(ui.ctx(), |ui| { + let modal = Modal::new(Id::unique(IMPORT_ID)).show(ui.ctx(), |ui| { ui.heading(texts::EQ_IMPORT_TITLE); ui.label(texts::eq_import_from(import.file)); if let Some(refusal) = import.refusal { diff --git a/crates/app/src/views/group_dialog.rs b/crates/app/src/views/group_dialog.rs index 710ae7c..8fa1cc6 100644 --- a/crates/app/src/views/group_dialog.rs +++ b/crates/app/src/views/group_dialog.rs @@ -15,7 +15,7 @@ pub enum GroupDialogAction { } pub fn show(ui: &mut Ui, name: &mut String) -> GroupDialogAction { - let modal = Modal::new(Id::new(MODAL_ID)).show(ui.ctx(), |ui| contents(ui, name)); + let modal = Modal::new(Id::unique(MODAL_ID)).show(ui.ctx(), |ui| contents(ui, name)); if modal.should_close() { return GroupDialogAction::Close; } diff --git a/crates/app/src/views/group_strip.rs b/crates/app/src/views/group_strip.rs index 8104a9c..8e44725 100644 --- a/crates/app/src/views/group_strip.rs +++ b/crates/app/src/views/group_strip.rs @@ -3,9 +3,7 @@ use std::ops::RangeInclusive; -use eframe::egui::{ - self, Button, Color32, CornerRadius, RichText, Stroke, Ui, WidgetInfo, WidgetType, -}; +use eframe::egui::{self, Button, Color32, CornerRadius, RichText, Role, Stroke, Ui, WidgetInfo}; use neumann_control_core::settings::{GroupSettings, Linkable}; use super::fader::{self, Orientation}; @@ -51,7 +49,7 @@ pub fn group( let button = Button::new(RichText::new(text).small()).wrap().selected(on); let button = ui.add(button); let label = texts::link_label(text); - button.widget_info(|| WidgetInfo::selected(WidgetType::Checkbox, true, on, &label)); + button.widget_info(|| WidgetInfo::selected(Role::CheckBox, true, on, &label)); if button.clicked() { response.toggled_link = Some(linkable); } @@ -116,6 +114,6 @@ pub(super) fn frame(ui: &mut Ui, color: Color32, contents: impl FnOnce(&mut Ui)) pub(super) fn mute_button(ui: &mut Ui, label: &str, muted: Option) -> Option { let all = muted.unwrap_or(false); let button = ui.add_enabled(muted.is_some(), Button::new(texts::MUTE).selected(all)); - button.widget_info(|| WidgetInfo::selected(WidgetType::Button, muted.is_some(), all, label)); + button.widget_info(|| WidgetInfo::selected(Role::Button, muted.is_some(), all, label)); button.clicked().then_some(!all) } diff --git a/crates/app/src/views/history.rs b/crates/app/src/views/history.rs index abfb5f9..c88f954 100644 --- a/crates/app/src/views/history.rs +++ b/crates/app/src/views/history.rs @@ -44,7 +44,7 @@ pub fn show( notice: Option<&str>, ) -> HistoryAction { let modal = - Modal::new(Id::new(MODAL_ID)).show(ui.ctx(), |ui| contents(ui, count, row_at, notice)); + Modal::new(Id::unique(MODAL_ID)).show(ui.ctx(), |ui| contents(ui, count, row_at, notice)); if modal.should_close() { return HistoryAction::Close; } @@ -111,7 +111,7 @@ fn row_line(ui: &mut Ui, row: &HistoryRow) -> Option { pub const UNDO_SHORTCUT: KeyboardShortcut = KeyboardShortcut::new(Modifiers::COMMAND, Key::Z); pub fn question(ui: &mut Ui, lines: &[String]) -> QuestionAction { - let modal = Modal::new(Id::new(QUESTION_ID)).show(ui.ctx(), |ui| { + let modal = Modal::new(Id::unique(QUESTION_ID)).show(ui.ctx(), |ui| { ui.heading(texts::CHANGED_SINCE); for line in lines { ui.label(line); diff --git a/crates/app/src/views/knob.rs b/crates/app/src/views/knob.rs index 813b0dd..74c1537 100644 --- a/crates/app/src/views/knob.rs +++ b/crates/app/src/views/knob.rs @@ -86,7 +86,7 @@ pub fn for_entry( conversion: entry.conversion, }; let mut state = speaker.knobs.remove(&entry.path).unwrap_or_default(); - let id = ui.id().with(entry.path.to_string()); + let id = ui.scope_id().with(entry.path.to_string()); let sent = show(ui, id, &knob, &mut state); speaker.knobs.insert(entry.path.clone(), state); let sent = sent?; diff --git a/crates/app/src/views/meters.rs b/crates/app/src/views/meters.rs index da3126e..3d9cef6 100644 --- a/crates/app/src/views/meters.rs +++ b/crates/app/src/views/meters.rs @@ -3,8 +3,8 @@ //! peak as a thin line, the held peak as a number below, and a clip lamp on top. The scale is the spread one from `meter`. use eframe::egui::{ - pos2, vec2, Align2, Color32, CornerRadius, FontId, Label, Rect, Response, RichText, Sense, - Stroke, Ui, Vec2, WidgetInfo, WidgetType, + pos2, vec2, Align2, Color32, CornerRadius, FontId, Label, Rect, Response, RichText, Role, + Sense, Stroke, Ui, Vec2, WidgetInfo, }; use serde_json::Value; @@ -113,7 +113,7 @@ pub fn show(ui: &mut Ui, group: &Meters, height: f32, floor: f64, tokens: &Token let label = texts::meter_label(name_of_group, &name); response.widget_info(|| WidgetInfo { value: readout.map(f64::from), - ..WidgetInfo::labeled(WidgetType::ProgressIndicator, true, &label) + ..WidgetInfo::labeled(Role::ProgressIndicator, true, &label) }); let lamp_color = if clipped { tokens.danger @@ -147,7 +147,7 @@ fn played_mark(ui: &mut Ui, tokens: &Tokens) -> Response { let (rect, response) = ui.allocate_exact_size(size, Sense::hover()); ui.painter() .circle_filled(rect.center(), PLAYED_MARK_RADIUS, tokens.text); - response.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, texts::PLAYED_CHANNEL)); + response.widget_info(|| WidgetInfo::labeled(Role::Label, true, texts::PLAYED_CHANNEL)); response.on_hover_text(texts::PLAYED_CHANNEL) } @@ -258,7 +258,7 @@ pub fn bar(ui: &mut Ui, db: Option, floor: f64, label: &str, tokens: &Token let shown = Rect::from_x_y_ranges(x(start)..=x(lit.clamp(start, end)), rect.y_range()); painter.rect_filled(shown, CornerRadius::same(BAR_RADIUS), color); } - response.widget_info(|| WidgetInfo::labeled(WidgetType::ProgressIndicator, true, label)); + response.widget_info(|| WidgetInfo::labeled(Role::ProgressIndicator, true, label)); } fn zone_color(zone: Zone, tokens: &Tokens) -> Color32 { diff --git a/crates/app/src/views/panel.rs b/crates/app/src/views/panel.rs index 7e74a21..4a4caeb 100644 --- a/crates/app/src/views/panel.rs +++ b/crates/app/src/views/panel.rs @@ -65,13 +65,13 @@ pub fn show( let has_eq = has_eq(catalog, speaker); // Escape in a field cancels the edit; only Escape with nothing being edited closes. egui // drops the focus at the start of the Escape frame, so the last frame's state decides. - let editing_id = Id::new(PANEL_ID).with(EDITING_ID); + let editing_id = Id::unique(PANEL_ID).with(EDITING_ID); let editing = context.data(|data| data.get_temp::(editing_id).unwrap_or(false)); // As tall as the window allows below the anchor, so rows are not hidden needlessly. let room = context.content_rect().bottom() - anchor.y - PANEL_CHROME_HEIGHT; let max_height = room.max(PANEL_MIN_HEIGHT); let window = Window::new(texts::PANEL_TITLE) - .id(Id::new(PANEL_ID)) + .id(Id::unique(PANEL_ID)) .title_bar(false) .collapsible(false) .resizable(false) diff --git a/crates/app/src/views/preset_dialog.rs b/crates/app/src/views/preset_dialog.rs index 977f055..5a70861 100644 --- a/crates/app/src/views/preset_dialog.rs +++ b/crates/app/src/views/preset_dialog.rs @@ -26,7 +26,7 @@ pub enum SaveAction { /// `rows` empty: the preset holds a fixed set (an EQ), only its name is asked. pub fn save(ui: &mut Ui, title: &str, rows: &[SaveRow], name: &mut String) -> SaveAction { - let modal = Modal::new(Id::new(SAVE_ID)).show(ui.ctx(), |ui| { + let modal = Modal::new(Id::unique(SAVE_ID)).show(ui.ctx(), |ui| { ui.heading(title); let label = ui.label(texts::PRESET_NAME); ui.add(TextEdit::singleline(name)).labelled_by(label.id); @@ -82,7 +82,7 @@ pub enum LoadAction { } pub fn load(ui: &mut Ui, view: &LoadView) -> LoadAction { - let modal = Modal::new(Id::new(LOAD_ID)).show(ui.ctx(), |ui| { + let modal = Modal::new(Id::unique(LOAD_ID)).show(ui.ctx(), |ui| { ui.heading(view.title); let mut action = LoadAction::None; if view.presets.is_empty() { diff --git a/crates/app/src/views/remove_speaker.rs b/crates/app/src/views/remove_speaker.rs index d807feb..3a95fda 100644 --- a/crates/app/src/views/remove_speaker.rs +++ b/crates/app/src/views/remove_speaker.rs @@ -15,7 +15,7 @@ pub enum RemoveAction { /// `name` is the speaker's display name, so the user sees which speaker goes. pub fn show(ui: &mut Ui, name: &str) -> RemoveAction { - let modal = Modal::new(Id::new(MODAL_ID)).show(ui.ctx(), |ui| contents(ui, name)); + let modal = Modal::new(Id::unique(MODAL_ID)).show(ui.ctx(), |ui| contents(ui, name)); if modal.should_close() { return RemoveAction::Cancel; } diff --git a/crates/app/src/views/scan_choice.rs b/crates/app/src/views/scan_choice.rs index 43b9ea3..fbf13c4 100644 --- a/crates/app/src/views/scan_choice.rs +++ b/crates/app/src/views/scan_choice.rs @@ -18,7 +18,7 @@ pub enum ScanChoiceAction { } pub fn show(ui: &mut Ui, choice: &ScanChoice) -> ScanChoiceAction { - let modal = Modal::new(Id::new(MODAL_ID)).show(ui.ctx(), |ui| contents(ui, choice)); + let modal = Modal::new(Id::unique(MODAL_ID)).show(ui.ctx(), |ui| contents(ui, choice)); if modal.should_close() { return ScanChoiceAction::Cancel; } diff --git a/crates/app/src/views/settings_dialog.rs b/crates/app/src/views/settings_dialog.rs index 06defc3..61bcd4b 100644 --- a/crates/app/src/views/settings_dialog.rs +++ b/crates/app/src/views/settings_dialog.rs @@ -34,7 +34,7 @@ pub fn show( devices: &[AudioDevice], curve_labels: &[(String, String)], ) -> SettingsAction { - let modal = Modal::new(Id::new(MODAL_ID)).show(ui.ctx(), |ui| { + let modal = Modal::new(Id::unique(MODAL_ID)).show(ui.ctx(), |ui| { ScrollArea::vertical() .max_height(DIALOG_MAX_HEIGHT) .show(ui, |ui| contents(ui, settings, devices, curve_labels)) diff --git a/crates/app/src/views/strip.rs b/crates/app/src/views/strip.rs index 70d0b0a..e5d4391 100644 --- a/crates/app/src/views/strip.rs +++ b/crates/app/src/views/strip.rs @@ -3,8 +3,8 @@ //! last message. The strip only reports gestures; `writes` decides what becomes a command. use eframe::egui::{ - self, vec2, Align, Button, Color32, CornerRadius, Label, Layout, Pos2, Rect, RichText, Sense, - Stroke, Ui, Vec2, WidgetInfo, WidgetType, + self, vec2, Align, Button, Color32, CornerRadius, Label, Layout, Pos2, Rect, RichText, Role, + Sense, Stroke, Ui, Vec2, WidgetInfo, }; use neumann_control_core::catalog::Catalog; use neumann_control_core::settings::DeskSettings; @@ -110,7 +110,7 @@ pub fn show( /// The handle a strip is dragged by to another place on the desk; the payload is the speaker's /// identity in the saved order. fn grip(ui: &mut Ui, member: &str, tokens: &Tokens) { - let id = ui.id().with(GRIP_ID); + let id = ui.scope_id().with(GRIP_ID); let response = ui .dnd_drag_source(id, member.to_owned(), |ui| { let size = vec2(GRIP_WIDTH, GRIP_HEIGHT); @@ -128,7 +128,7 @@ fn grip(ui: &mut Ui, member: &str, tokens: &Tokens) { } }) .response; - response.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, texts::MOVE_STRIP)); + response.widget_info(|| WidgetInfo::labeled(Role::Button, true, texts::MOVE_STRIP)); if response.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); } @@ -165,7 +165,7 @@ fn led(ui: &mut Ui, color: Color32, text: &str) { let size = Vec2::splat(2.0 * LED_RADIUS); let (rect, response) = ui.allocate_exact_size(size, Sense::hover()); ui.painter().circle_filled(rect.center(), LED_RADIUS, color); - response.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, text)); + response.widget_info(|| WidgetInfo::labeled(Role::Label, true, text)); response.on_hover_text(text); } diff --git a/crates/app/src/views/trends.rs b/crates/app/src/views/trends.rs index 14418bb..a877d5e 100644 --- a/crates/app/src/views/trends.rs +++ b/crates/app/src/views/trends.rs @@ -2,7 +2,7 @@ //! as a bar, over the last two hours; hovering shows how long ago and what was read. use eframe::egui::{ - self, pos2, vec2, Color32, Rect, RichText, Sense, Stroke, Ui, WidgetInfo, WidgetType, + self, pos2, vec2, Color32, Rect, RichText, Role, Sense, Stroke, Ui, WidgetInfo, }; use neumann_control_core::catalog::Conversion; @@ -155,7 +155,7 @@ fn temperature_line( previous = Some(right); } let label = texts::temperature_trend(current.as_deref().unwrap_or_default()); - response.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, &label)); + response.widget_info(|| WidgetInfo::labeled(Role::Label, true, &label)); if let Some(pointer) = response.hover_pos() { let time = axis.time(pointer.x); let text = trend.value_at(time).map_or_else( @@ -187,8 +187,7 @@ fn warning_bar(ui: &mut Ui, trend: &Trend>, start: f64, now: f64, to ); painter.rect_filled(span, 0.0, color); } - response - .widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, texts::WARNING_TREND.get())); + response.widget_info(|| WidgetInfo::labeled(Role::Label, true, texts::WARNING_TREND.get())); if let Some(pointer) = response.hover_pos() { let time = axis.time(pointer.x); let text = match trend.value_at(time) { diff --git a/crates/app/src/views/wheel.rs b/crates/app/src/views/wheel.rs index 374fbe7..99b3e21 100644 --- a/crates/app/src/views/wheel.rs +++ b/crates/app/src/views/wheel.rs @@ -33,7 +33,7 @@ pub fn track_burst(context: &Context) { let Some(pointer) = pointer.filter(|_| wheeling) else { return; }; - let id = Id::new(BURST_ID); + let id = Id::unique(BURST_ID); let previous = context.data(|data| data.get_temp::(id)); let start = previous .filter(|burst| now - burst.last_event < WHEEL_IDLE_SECONDS) @@ -48,7 +48,7 @@ pub fn track_burst(context: &Context) { /// This frame's wheel events in notches (lines), upwards positive, for the control at `rect` when /// the burst began over it; none otherwise. pub(super) fn notches_for(ui: &Ui, rect: Rect) -> Vec { - let burst = ui.data(|data| data.get_temp::(Id::new(BURST_ID))); + let burst = ui.data(|data| data.get_temp::(Id::unique(BURST_ID))); let now = ui.input(|input| input.time); let held_here = burst.is_some_and(|burst| { rect.contains(burst.start) && now - burst.last_event < WHEEL_IDLE_SECONDS diff --git a/crates/app/tests/eq.rs b/crates/app/tests/eq.rs index 10a3eb8..a9fa578 100644 --- a/crates/app/tests/eq.rs +++ b/crates/app/tests/eq.rs @@ -433,10 +433,12 @@ fn a_reset_reads_as_all_bands_in_the_history() { harness .get_by_label_contains(&texts::history_changes(None, 2)) .hover(); - harness.run_steps(3); + let all_bands = texts::eq_all_bands_setting("EQ 2", GAIN_LABEL); + wait_until(&mut harness, "the tooltip", |harness| { + harness.query_by_label_contains(&all_bands).is_some() + }); let one_band = texts::eq_change_setting("EQ 2", 1, GAIN_LABEL); assert!(harness.query_by_label_contains(&one_band).is_none()); - harness.get_by_label_contains(&texts::eq_all_bands_setting("EQ 2", GAIN_LABEL)); } /// A KH 750 with its EQ window open, crossover protection switched off in the settings. diff --git a/crates/app/tests/views.rs b/crates/app/tests/views.rs index 6b53f0e..24a44d6 100644 --- a/crates/app/tests/views.rs +++ b/crates/app/tests/views.rs @@ -108,6 +108,7 @@ fn wheel_over_fader_sends_one_set_after_the_wheel_went_quiet() { unit: MouseWheelUnit::Line, delta: egui::vec2(0.0, 1.0), phase: TouchPhase::Move, + source: egui::MouseWheelSource::Wheel, modifiers: egui::Modifiers::NONE, }); } @@ -172,6 +173,7 @@ fn a_drag_replaces_wheel_steps_not_yet_sent() { unit: MouseWheelUnit::Line, delta: egui::vec2(0.0, 1.0), phase: TouchPhase::Move, + source: egui::MouseWheelSource::Wheel, modifiers: egui::Modifiers::NONE, }); harness.drag_at(start); @@ -482,6 +484,7 @@ fn knob_wheel_writes_after_idle() { unit: MouseWheelUnit::Line, delta: egui::vec2(0.0, 1.0), phase: TouchPhase::Move, + source: egui::MouseWheelSource::Wheel, modifiers: egui::Modifiers::NONE, }); harness.step(); diff --git a/crates/app/tests/write_gestures.rs b/crates/app/tests/write_gestures.rs index 28eb9e0..0332f59 100644 --- a/crates/app/tests/write_gestures.rs +++ b/crates/app/tests/write_gestures.rs @@ -25,6 +25,7 @@ fn wheel(harness: &mut Harness<'static, NeumannApp>, notches: f32) { unit: MouseWheelUnit::Line, delta: egui::vec2(0.0, notches), phase: TouchPhase::Move, + source: egui::MouseWheelSource::Wheel, modifiers: egui::Modifiers::NONE, }); } From 0d400095750a1ec817454436d2829a2b33e66cbb Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:57:57 +0200 Subject: [PATCH 195/460] Test that an empty serial leaves the name alone --- crates/core/src/discovery.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/core/src/discovery.rs b/crates/core/src/discovery.rs index 6125600..0142e7e 100644 --- a/crates/core/src/discovery.rs +++ b/crates/core/src/discovery.rs @@ -330,6 +330,11 @@ mod tests { ); } + #[test] + fn an_empty_serial_leaves_the_name_as_it_is() { + assert_eq!(with_serial_masked("Desk left", ""), "Desk left"); + } + #[test] fn without_a_txt_serial_the_instance_name_stays_unchanged() { assert_eq!( From 432aa6ee54944dd781cd6b25f2d2d5d0a9f04aaf Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:57:58 +0200 Subject: [PATCH 196/460] Leave the name alone for an empty serial --- crates/core/src/discovery.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/core/src/discovery.rs b/crates/core/src/discovery.rs index 0142e7e..960fb03 100644 --- a/crates/core/src/discovery.rs +++ b/crates/core/src/discovery.rs @@ -115,6 +115,10 @@ fn without_serial_suffix<'a>(name: &'a str, serial: &str) -> &'a str { } fn with_serial_masked(name: &str, serial: &str) -> String { + // An empty serial would be found at every position without moving on. + if serial.is_empty() { + return name.to_owned(); + } let lower_name = name.to_ascii_lowercase(); let lower_serial = serial.to_ascii_lowercase(); let mask = mask_serial(serial); From 74a90049ac833505b15d95b858b65bb556d0afc3 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:10:47 +0200 Subject: [PATCH 197/460] Pin the xid at exactly the token limit --- crates/core/tests/wire.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/core/tests/wire.rs b/crates/core/tests/wire.rs index 1f8bda7..df1018b 100644 --- a/crates/core/tests/wire.rs +++ b/crates/core/tests/wire.rs @@ -1,5 +1,6 @@ use neumann_control_core::ssc::wire::{ - device_error, extract, number, parse_line, reply_xid, request_line, SscPath, WireError, + device_error, extract, number, parse_line, reply_xid, request_line, token_count, SscPath, + WireError, MAX_REQUEST_TOKENS, }; use serde_json::{json, Value}; @@ -183,3 +184,17 @@ fn a_twenty_band_eq_write_leaves_out_the_xid() { json!({"osc": {"xid": 6}, "audio": {"out": {"eq2": {"enabled": ten}}}}) ); } + +/// `audio/out/eq2/enabled` with n values and the xid is 13 + n tokens: 16 values are exactly +/// the 29 the speaker takes, 17 are one too many. +#[test] +fn the_xid_stays_up_to_exactly_the_token_limit() { + let at_limit = Value::Array(vec![Value::Null; 16]); + let line = request_line(&path("audio/out/eq2/enabled"), at_limit.clone(), 7); + let request = parse_line(line.as_bytes()).unwrap(); + assert_eq!(token_count(&request), MAX_REQUEST_TOKENS); + assert_eq!(reply_xid(&request), Some(7)); + let over = Value::Array(vec![Value::Null; 17]); + let line = request_line(&path("audio/out/eq2/enabled"), over, 8); + assert_eq!(reply_xid(&parse_line(line.as_bytes()).unwrap()), None); +} From a0c131de415259397e260a92d3c53d5d221c31f6 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:29:40 +0200 Subject: [PATCH 198/460] Test the catalog's kind, conversion and step checks --- crates/core/tests/catalog.rs | 62 ++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/crates/core/tests/catalog.rs b/crates/core/tests/catalog.rs index 9c5c1ec..52da530 100644 --- a/crates/core/tests/catalog.rs +++ b/crates/core/tests/catalog.rs @@ -446,3 +446,65 @@ fn element_labels_must_match_in_both_languages() { ); assert!(invalid_reason(&list).is_some()); } + +#[test] +fn a_read_only_kind_listing_writable_models_is_an_error() { + let display = SUBWOOFER_OUTPUT.replace("kind = \"switch\"", "kind = \"display\""); + assert!(invalid_reason(&format!("{display}writable_on = [\"KH 750\"]")).is_some()); + assert_eq!(invalid_reason(&display), None); +} + +#[test] +fn invert_takes_a_shown_value_back_to_the_devices_unit() { + for (conversion, device) in [ + (Conversion::KelvinToCelsius, 313.0), + (Conversion::SamplesToMilliseconds, 96.0), + ] { + let shown = conversion.apply(device); + assert!( + (conversion.invert(shown) - device).abs() < 1e-9, + "{conversion:?}" + ); + assert_ne!(shown, device, "{conversion:?}"); + } +} + +/// A level entry writable on the KH 750 with this `min` and `step`. +fn stepped(min: f64, step: f64) -> Catalog { + let text = format!( + "[models]\nsubwoofer = [\"KH 750\"]\nlogo = []\n\n[[entry]]\n{LEVEL}\ + min = {min:?}\nmax = 100.0\nstep = {step:?}\nwritable_on = [\"KH 750\"]" + ); + Catalog::parse(&text).unwrap() +} + +fn step_check(catalog: &Catalog, value: f64) -> Result<(), ValueError> { + catalog.entries[0].validate(&json!(value), SUBWOOFER, &catalog.classes) +} + +#[test] +fn the_step_grid_starts_at_the_minimum() { + let catalog = stepped(0.5, 2.0); + assert_eq!(step_check(&catalog, 2.5), Ok(())); + assert_eq!(step_check(&catalog, 3.0), Err(ValueError::NotAStep)); + let catalog = stepped(0.0, 2.0); + assert_eq!(step_check(&catalog, 3.0), Err(ValueError::NotAStep)); + let catalog = stepped(0.0, 0.5); + assert_eq!(step_check(&catalog, 0.5), Ok(())); +} + +#[test] +fn every_error_has_a_text() { + let errors = [ + ValueError::NotAStep.to_string(), + CatalogError::Invalid { + path: path("audio/out/level"), + reason: "select without options", + } + .to_string(), + neumann_control_core::address::AddressError::Invalid.to_string(), + ]; + for text in errors { + assert!(!text.is_empty()); + } +} From 1011c76d7b46e98e696974ca06dcdcba755158e6 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:29:40 +0200 Subject: [PATCH 199/460] Test which found address survives the merge --- crates/core/tests/discovery.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/core/tests/discovery.rs b/crates/core/tests/discovery.rs index 474500e..8b11748 100644 --- a/crates/core/tests/discovery.rs +++ b/crates/core/tests/discovery.rs @@ -77,5 +77,19 @@ fn entries_without_serial_are_kept_by_address() { found(None, SIMULATED_LINK_LOCAL, 7), found(None, SIMULATED_GLOBAL, 0), ]); - assert_eq!(merged.len(), 2); + let addresses: Vec = merged.iter().map(|entry| *entry.address.ip()).collect(); + assert_eq!( + addresses, + [address(SIMULATED_LINK_LOCAL), address(SIMULATED_GLOBAL)] + ); +} + +#[test] +fn a_later_link_local_address_replaces_an_earlier_one() { + let merged = merge_found(vec![ + found(Some("SIM0001234"), SIMULATED_LINK_LOCAL, 7), + found(Some("SIM0001234"), SIMULATED_LINK_LOCAL, 15), + ]); + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].address.scope_id(), 15); } From c659fe821c9140e36790d6874cede13e38875f3d Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:29:41 +0200 Subject: [PATCH 200/460] Test the poll rotation on its own --- crates/core/src/poll_schedule.rs | 71 ++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/crates/core/src/poll_schedule.rs b/crates/core/src/poll_schedule.rs index a913f2c..9e238a7 100644 --- a/crates/core/src/poll_schedule.rs +++ b/crates/core/src/poll_schedule.rs @@ -105,3 +105,74 @@ impl Schedule { .find(|&index| self.slots[index].due <= now) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::catalog::Catalog; + + const MONITOR: &str = "KH 120 II"; + + fn timing() -> Timing { + Timing { + meter: Duration::from_secs(1), + fast: Duration::from_secs(2), + slow: Duration::from_secs(10), + ..Timing::default() + } + } + + fn schedule(now: Instant) -> Schedule { + Schedule::new(&Catalog::embedded().entries_for(MONITOR), &timing(), now) + } + + /// Takes every due path at `now`, meters left out. + fn due_paths(schedule: &mut Schedule, now: Instant) -> Vec { + let mut paths = Vec::new(); + loop { + match schedule.take_due(now) { + Due::Meters(_) => {} + Due::Path(path) => paths.push(path), + Due::Nothing => return paths, + } + } + } + + #[test] + fn every_due_path_is_read_once_before_any_is_read_again() { + let catalog = Catalog::embedded(); + let entries = catalog.entries_for(MONITOR); + let first_with = |poll: Poll| *entries.iter().find(|entry| entry.poll == poll).unwrap(); + let (fast, slow) = (first_with(Poll::Fast), first_with(Poll::Slow)); + let start = Instant::now(); + let mut schedule = Schedule::new(&[fast, slow], &timing(), start); + let both_due = start + timing().slow; + assert_eq!(schedule.take_due(both_due), Due::Path(fast.path.clone())); + // The fast path is due again, the slow one still waits for its first read: it goes first. + let fast_again = both_due + timing().fast; + assert_eq!(schedule.take_due(fast_again), Due::Path(slow.path.clone())); + assert_eq!(schedule.take_due(fast_again), Due::Path(fast.path.clone())); + } + + #[test] + fn a_read_path_is_not_due_again_at_once() { + let start = Instant::now(); + let mut schedule = schedule(start); + let all_due = start + Duration::from_secs(10); + let paths = due_paths(&mut schedule, all_due); + assert_eq!(paths.len(), schedule.slots.len()); + assert!(due_paths(&mut schedule, all_due).is_empty()); + } + + #[test] + fn forget_removes_only_that_path() { + let start = Instant::now(); + let mut schedule = schedule(start); + let forgotten = schedule.slots[0].path.clone(); + let count = schedule.slots.len(); + schedule.forget(&forgotten); + let paths = due_paths(&mut schedule, start + Duration::from_secs(10)); + assert_eq!(paths.len(), count - 1); + assert!(!paths.contains(&forgotten)); + } +} From 82e3c4a1f454b184a476568f90d38275414b27a5 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:36:41 +0200 Subject: [PATCH 201/460] Test that one unknown value keeps a copy of the file --- crates/core/tests/settings.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/core/tests/settings.rs b/crates/core/tests/settings.rs index 2ea0d0b..3aa2398 100644 --- a/crates/core/tests/settings.rs +++ b/crates/core/tests/settings.rs @@ -243,6 +243,17 @@ fn a_value_this_version_does_not_know_costs_only_that_value() { assert!(path.exists()); } +#[test] +fn a_single_unknown_value_keeps_a_copy_of_the_file() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(SETTINGS_FILE); + let text = r#"{"desk": {"language": "French"}}"#; + fs::write(&path, text).unwrap(); + assert_eq!(load(&path).desk.language, LanguageChoice::System); + let copy = directory.path().join(format!("{SETTINGS_FILE}.bad")); + assert_eq!(fs::read_to_string(copy).unwrap(), text); +} + #[test] fn a_file_of_the_previous_version_keeps_its_speakers_and_window() { let directory = tempfile::tempdir().unwrap(); From 92941bb2ca5795b6ccc738be5c7418e7547a5ee4 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:36:41 +0200 Subject: [PATCH 202/460] Drop a doc line that contradicted readable_items --- crates/core/src/settings.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/core/src/settings.rs b/crates/core/src/settings.rs index 6b8b9d1..902f09a 100644 --- a/crates/core/src/settings.rs +++ b/crates/core/src/settings.rs @@ -294,7 +294,6 @@ where } /// A list keeps the entries this version can read; one it cannot is left out, not the list. -/// Something that is not a list at all means a damaged file, which `load` sets aside whole. fn readable_items<'de, D, T>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, From 30f28287be3df8b0823ada255fa2dd671008e88a Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:47:07 +0200 Subject: [PATCH 203/460] Test a connection lost while the walk reads leaves --- crates/core/tests/speaker.rs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/core/tests/speaker.rs b/crates/core/tests/speaker.rs index 417c095..0499d87 100644 --- a/crates/core/tests/speaker.rs +++ b/crates/core/tests/speaker.rs @@ -867,3 +867,35 @@ fn commands_queued_during_long_work_keep_their_order() { assert!(seen.contains(&first_set), "the first set did not run first"); assert_eq!(simulator.value("audio/out/level"), Some(json!(90.0))); } + +/// Long enough to inject a fault between seeing a request and the walk's next one. +const SLOW_WALK_REPLY: Duration = Duration::from_millis(50); + +fn is_leaf_read(request: &Value) -> bool { + request.pointer("/osc/schema").is_none() && request.pointer("/osc/limits").is_none() +} + +fn is_limits_read(request: &Value) -> bool { + request.pointer("/osc/limits").is_some() +} + +/// The connection drops right after the walk asked for a leaf's value (its limits come next) +/// and right after it asked for a leaf's limits (the next value comes next): either way the +/// walk fails as lost instead of listing every later leaf as refused. +#[test] +fn a_connection_lost_while_reading_leaves_fails_the_walk() { + for seen in [is_leaf_read as fn(&Value) -> bool, is_limits_read] { + let simulator = Simulator::start(SimModel::Kh120Ii); + simulator.set_reply_delay(SLOW_WALK_REPLY); + let mut client = Client::connect(simulator.address()).expect("simulator accepts"); + let walk = thread::spawn(move || read_all(&mut client, &|| false)); + let end = Instant::now() + EVENT_DEADLINE; + while !simulator.requests().last().is_some_and(seen) { + assert!(Instant::now() < end, "the walk never read a leaf"); + thread::sleep(Duration::from_millis(1)); + } + simulator.inject(Fault::CloseConnection); + let outcome = walk.join().unwrap(); + assert!(matches!(outcome, Err(WalkError::Connection(_))), "{outcome:?}"); + } +} From 05e0f64dbbdcf1e5dc487974e55adcfe53b07f2d Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:47:18 +0200 Subject: [PATCH 204/460] Test that the walk reads exactly its node limit --- crates/core/tests/speaker.rs | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/core/tests/speaker.rs b/crates/core/tests/speaker.rs index 0499d87..9827c57 100644 --- a/crates/core/tests/speaker.rs +++ b/crates/core/tests/speaker.rs @@ -896,6 +896,37 @@ fn a_connection_lost_while_reading_leaves_fails_the_walk() { } simulator.inject(Fault::CloseConnection); let outcome = walk.join().unwrap(); - assert!(matches!(outcome, Err(WalkError::Connection(_))), "{outcome:?}"); + assert!( + matches!(outcome, Err(WalkError::Connection(_))), + "{outcome:?}" + ); } } + +/// Every node the walk visits below the root: the leaves and the containers above them. +fn visited_nodes(reports: &[neumann_control_core::schema_walk::LeafReport]) -> usize { + let mut containers = std::collections::BTreeSet::new(); + for report in reports { + let segments = report.path.segments(); + for depth in 1..segments.len() { + containers.insert(segments[..depth].to_vec()); + } + } + reports.len() + containers.len() +} + +#[test] +fn exactly_the_node_limit_is_still_read() { + let plain = Simulator::start(SimModel::Kh120Ii); + let mut client = Client::connect(plain.address()).expect("simulator accepts"); + let base = visited_nodes(&read_all(&mut client, &|| false).unwrap()); + // One container `wide` plus its leaves fill the budget exactly. + let leaves = MAX_SCHEMA_NODES - base - 1; + let simulator = Simulator::start(SimModel::Kh120Ii); + for index in 0..leaves { + simulator.set_value(&format!("wide/node{index}"), json!(index)); + } + let mut client = Client::connect(simulator.address()).expect("simulator accepts"); + let reports = read_all(&mut client, &|| false).expect("the limit itself is allowed"); + assert_eq!(visited_nodes(&reports), MAX_SCHEMA_NODES); +} From c4f805721c8bdde2dacf07113316c289e3a96ee7 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:47:28 +0200 Subject: [PATCH 205/460] Pin the meter floors --- crates/core/tests/settings.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/core/tests/settings.rs b/crates/core/tests/settings.rs index 3aa2398..8378fc1 100644 --- a/crates/core/tests/settings.rs +++ b/crates/core/tests/settings.rs @@ -397,3 +397,9 @@ fn the_eq_display_keeps_its_choices_and_falls_back_on_nonsense() { .unwrap(); assert_eq!(load(&path).eq_display.band_curves, BandCurves::All); } + +#[test] +fn the_meter_floors_are_below_zero_db() { + let floors = MeterFloor::ALL.map(MeterFloor::db); + assert_eq!(floors, [-120.0, -90.0, -60.0]); +} From 1c888d300bb88386e6f6c5a994172611b14aa538 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:03:50 +0200 Subject: [PATCH 206/460] Test that the take-back question names the change --- crates/app/tests/history.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/app/tests/history.rs b/crates/app/tests/history.rs index 54ebbc4..fc93326 100644 --- a/crates/app/tests/history.rs +++ b/crates/app/tests/history.rs @@ -304,6 +304,9 @@ fn ctrl_z_on_a_changed_value_asks_and_reverts_on_confirm() { wait_for_level(&mut harness, 70.0); harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Z); wait_for_label(&mut harness, texts::REVERT.get()); + // The question names the setting and the value the speaker holds now. + let now = texts::changed_now("", texts::LEVEL.get(), "70 dB SPL", ""); + assert!(harness.query_by_label_contains(&now).is_some(), "{now}"); harness.get_by_label(texts::REVERT.get()).click(); wait_until(&mut harness, "the level written back", |_| { level_sets(&simulator).len() == 2 From 906a2a22d3f2860770a32715311e71731752a4d0 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:28:46 +0200 Subject: [PATCH 207/460] Test unreadable settings and a dropped list entry --- crates/core/tests/settings.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/core/tests/settings.rs b/crates/core/tests/settings.rs index 8378fc1..b00a725 100644 --- a/crates/core/tests/settings.rs +++ b/crates/core/tests/settings.rs @@ -243,6 +243,28 @@ fn a_value_this_version_does_not_know_costs_only_that_value() { assert!(path.exists()); } +#[test] +fn an_unreadable_file_is_moved_aside_not_taken_for_missing() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(SETTINGS_FILE); + // A folder in its place cannot be read, and is not "not found" either. + fs::create_dir(&path).unwrap(); + assert_eq!(load(&path), Settings::default()); + assert!(!path.exists()); + assert!(directory.path().join(format!("{SETTINGS_FILE}.bad")).is_dir()); +} + +#[test] +fn an_unknown_list_entry_alone_keeps_a_copy_of_the_file() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(SETTINGS_FILE); + let text = r#"{"manual": [{"address": "fe80::2%7"}]}"#; + fs::write(&path, text).unwrap(); + assert!(load(&path).manual.is_empty()); + let copy = directory.path().join(format!("{SETTINGS_FILE}.bad")); + assert_eq!(fs::read_to_string(copy).unwrap(), text); +} + #[test] fn a_single_unknown_value_keeps_a_copy_of_the_file() { let directory = tempfile::tempdir().unwrap(); From b65a98cacb664444b2c04f08794bf550330c0d6e Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:29:30 +0200 Subject: [PATCH 208/460] Test that forgetting a path keeps the meters --- crates/core/src/poll_schedule.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/core/src/poll_schedule.rs b/crates/core/src/poll_schedule.rs index 9e238a7..d9e9b3c 100644 --- a/crates/core/src/poll_schedule.rs +++ b/crates/core/src/poll_schedule.rs @@ -174,5 +174,9 @@ mod tests { let paths = due_paths(&mut schedule, start + Duration::from_secs(10)); assert_eq!(paths.len(), count - 1); assert!(!paths.contains(&forgotten)); + let meters = schedule.meters.clone(); + assert!(!meters.is_empty()); + let meter_time = start + Duration::from_secs(20); + assert_eq!(schedule.take_due(meter_time), Due::Meters(meters)); } } From ac7c9bc5c8b474265105e6a7a0dc76a5282d7928 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:29:30 +0200 Subject: [PATCH 209/460] Test that every request carries its own xid --- crates/core/tests/client.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/core/tests/client.rs b/crates/core/tests/client.rs index e20d8ac..b857f03 100644 --- a/crates/core/tests/client.rs +++ b/crates/core/tests/client.rs @@ -360,3 +360,19 @@ fn the_read_budgets_are_the_specified_ones() { assert_eq!(MAX_RESPONSE_BYTES, 4 * 1024 * 1024); assert_eq!(MAX_READ_DURATION, Duration::from_secs(10)); } + +#[test] +fn every_request_carries_its_own_xid() { + let simulator = Simulator::start(SimModel::Kh120Ii); + let mut client = Client::connect(simulator.address()).expect("simulator accepts"); + for _ in 0..3 { + client.get(&path("audio/out/level")).unwrap(); + } + let xids: Vec = simulator + .requests() + .iter() + .filter_map(|request| request.pointer("/osc/xid").and_then(Value::as_u64)) + .collect(); + assert_eq!(xids.len(), 3); + assert!(xids.windows(2).all(|pair| pair[0] != pair[1]), "{xids:?}"); +} From cba816b17ece403633c3c6fa3fd7533fc58ebeb4 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:29:41 +0200 Subject: [PATCH 210/460] Check the protocol errors' texts too --- crates/core/tests/catalog.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/core/tests/catalog.rs b/crates/core/tests/catalog.rs index 52da530..08595ab 100644 --- a/crates/core/tests/catalog.rs +++ b/crates/core/tests/catalog.rs @@ -503,6 +503,8 @@ fn every_error_has_a_text() { } .to_string(), neumann_control_core::address::AddressError::Invalid.to_string(), + neumann_control_core::ssc::client::SscError::Timeout.to_string(), + neumann_control_core::ssc::wire::WireError::Malformed.to_string(), ]; for text in errors { assert!(!text.is_empty()); From 578ec2f55791780bc4f8f6c3449bf834b6256d7d Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:36:51 +0200 Subject: [PATCH 211/460] Name the set-aside path in the settings test --- crates/core/tests/settings.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/core/tests/settings.rs b/crates/core/tests/settings.rs index b00a725..35fef7a 100644 --- a/crates/core/tests/settings.rs +++ b/crates/core/tests/settings.rs @@ -251,7 +251,8 @@ fn an_unreadable_file_is_moved_aside_not_taken_for_missing() { fs::create_dir(&path).unwrap(); assert_eq!(load(&path), Settings::default()); assert!(!path.exists()); - assert!(directory.path().join(format!("{SETTINGS_FILE}.bad")).is_dir()); + let set_aside = directory.path().join(format!("{SETTINGS_FILE}.bad")); + assert!(set_aside.is_dir()); } #[test] From 18d5f750b5c69f801e3989f66870c0b1b9b78751 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:15:31 +0200 Subject: [PATCH 212/460] Test stored strip layouts --- crates/core/tests/settings.rs | 44 +++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/crates/core/tests/settings.rs b/crates/core/tests/settings.rs index 35fef7a..47b82a4 100644 --- a/crates/core/tests/settings.rs +++ b/crates/core/tests/settings.rs @@ -1,8 +1,9 @@ use neumann_control_core::settings::{ load, save, BandCurves, CrossoverGuard, DeskSettings, EqPreset, GroupSettings, Language, - LanguageChoice, Linkable, ManualSpeaker, MeterFloor, Settings, SettingsPreset, - SystemAudioSettings, ThemeChoice, WindowGeometry, SETTINGS_FILE, + LanguageChoice, Linkable, ManualSpeaker, MeterFloor, Settings, SettingsPreset, StoredStripLayout, + StripElement, SystemAudioSettings, ThemeChoice, WindowGeometry, SETTINGS_FILE, }; +use neumann_control_core::ssc::wire::SscPath; use std::fs; fn sample() -> Settings { @@ -426,3 +427,42 @@ fn the_meter_floors_are_below_zero_db() { let floors = MeterFloor::ALL.map(MeterFloor::db); assert_eq!(floors, [-120.0, -90.0, -60.0]); } + +fn element(text: &str) -> StripElement { + StripElement::Path(SscPath::parse(text).unwrap()) +} + +#[test] +fn strip_layouts_round_trip() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(SETTINGS_FILE); + let mut settings = Settings::default(); + let layout = StoredStripLayout { + above: vec![element("audio/out/phaseinversion")], + below: vec![element("audio/out/mute"), StripElement::Eq], + }; + settings.desk.strip_layouts.insert("KH 120 II".to_owned(), layout); + save(&settings, &path).unwrap(); + assert_eq!(load(&path), settings); +} + +#[test] +fn a_file_without_strip_layouts_loads_none() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(SETTINGS_FILE); + fs::write(&path, r#"{"desk": {"compact": false}}"#).unwrap(); + assert!(load(&path).desk.strip_layouts.is_empty()); + assert!(!directory.path().join(format!("{SETTINGS_FILE}.bad")).exists()); +} + +#[test] +fn an_unreadable_strip_element_is_left_out_and_keeps_a_copy() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(SETTINGS_FILE); + let text = r#"{"desk": {"strip_layouts": {"KH 120 II": {"above": ["audio//out", "audio/out/delay"], "below": []}}}}"#; + fs::write(&path, text).unwrap(); + let layouts = load(&path).desk.strip_layouts; + assert_eq!(layouts["KH 120 II"].above, vec![element("audio/out/delay")]); + let copy = directory.path().join(format!("{SETTINGS_FILE}.bad")); + assert_eq!(fs::read_to_string(copy).unwrap(), text); +} From fced09868447ac0b03764695123e536bcb45ab6d Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:20:49 +0200 Subject: [PATCH 213/460] Store strip layouts per model in the settings --- crates/app/src/app/compact.rs | 2 +- crates/app/src/app/desk.rs | 2 +- crates/app/src/views/settings_dialog.rs | 4 +- crates/core/src/settings.rs | 78 ++++++++++++++++++++++++- crates/core/tests/settings.rs | 16 +++-- 5 files changed, 92 insertions(+), 10 deletions(-) diff --git a/crates/app/src/app/compact.rs b/crates/app/src/app/compact.rs index f5d98ab..6dc9bba 100644 --- a/crates/app/src/app/compact.rs +++ b/crates/app/src/app/compact.rs @@ -20,7 +20,7 @@ impl NeumannApp { return; } let catalog = Catalog::embedded(); - let desk = self.settings.desk; + let desk = self.settings.desk.clone(); let ids = self.member_ids(); let items = layout(&self.settings.order, &self.settings.groups, &ids); let rows = compact_rows(&items, &self.settings.groups, &ids); diff --git a/crates/app/src/app/desk.rs b/crates/app/src/app/desk.rs index aa74241..5d46f3f 100644 --- a/crates/app/src/app/desk.rs +++ b/crates/app/src/app/desk.rs @@ -53,7 +53,7 @@ impl NeumannApp { return; } let catalog = Catalog::embedded(); - let desk = self.settings.desk; + let desk = self.settings.desk.clone(); let ids = self.member_ids(); let items = layout(&self.settings.order, &self.settings.groups, &ids); let dragged = DragAndDrop::payload::(ui.ctx()) diff --git a/crates/app/src/views/settings_dialog.rs b/crates/app/src/views/settings_dialog.rs index 61bcd4b..6c1ba3f 100644 --- a/crates/app/src/views/settings_dialog.rs +++ b/crates/app/src/views/settings_dialog.rs @@ -53,7 +53,7 @@ fn contents( curve_labels: &[(String, String)], ) -> SettingsAction { let before = ( - settings.desk, + settings.desk.clone(), settings.system_audio.clone(), settings.eq_display.clone(), ); @@ -98,7 +98,7 @@ fn contents( return SettingsAction::Close; } let after = ( - settings.desk, + settings.desk.clone(), settings.system_audio.clone(), settings.eq_display.clone(), ); diff --git a/crates/core/src/settings.rs b/crates/core/src/settings.rs index 902f09a..d664cbe 100644 --- a/crates/core/src/settings.rs +++ b/crates/core/src/settings.rs @@ -10,6 +10,8 @@ use std::path::{Path, PathBuf}; use serde::de::DeserializeOwned; use serde::{Deserialize, Deserializer, Serialize}; +use crate::ssc::wire::SscPath; + pub const SETTINGS_FILE: &str = "settings.json"; pub const LOG_FILE: &str = "neumann-control.log"; @@ -154,8 +156,9 @@ pub enum Linkable { Input, } -/// What the settings dialog changes. -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +/// How the desk looks and behaves: the settings dialog changes most of it, the strip editor the +/// strip layouts. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct DeskSettings { #[serde(deserialize_with = "or_default")] @@ -170,6 +173,51 @@ pub struct DeskSettings { pub compact: bool, #[serde(deserialize_with = "or_default")] pub crossovers: CrossoverGuard, + /// Per reported product name; a model without an entry shows the default strip. + #[serde( + deserialize_with = "readable_layouts", + skip_serializing_if = "BTreeMap::is_empty" + )] + pub strip_layouts: BTreeMap, +} + +/// One element a channel strip shows besides its fixed part: a catalog path, or the EQ button. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StripElement { + Path(SscPath), + Eq, +} + +const EQ_ELEMENT: &str = "eq"; + +impl Serialize for StripElement { + fn serialize(&self, serializer: S) -> Result { + match self { + StripElement::Path(path) => path.serialize(serializer), + StripElement::Eq => serializer.serialize_str(EQ_ELEMENT), + } + } +} + +impl<'de> Deserialize<'de> for StripElement { + fn deserialize>(deserializer: D) -> Result { + let text = String::deserialize(deserializer)?; + if text == EQ_ELEMENT { + return Ok(StripElement::Eq); + } + SscPath::parse(&text) + .map(StripElement::Path) + .map_err(serde::de::Error::custom) + } +} + +/// A model's strip elements above and below the fader, in order, as stored. +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct StoredStripLayout { + #[serde(default, deserialize_with = "readable_items")] + pub above: Vec, + #[serde(default, deserialize_with = "readable_items")] + pub below: Vec, } impl Default for DeskSettings { @@ -181,6 +229,7 @@ impl Default for DeskSettings { language: LanguageChoice::System, compact: false, crossovers: CrossoverGuard::Protected, + strip_layouts: BTreeMap::new(), } } } @@ -318,6 +367,31 @@ where Ok(readable) } +/// Like `readable_items` for the layout map: an entry this version cannot read is left out. +fn readable_layouts<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let serde_json::Value::Object(entries) = serde_json::Value::deserialize(deserializer)? else { + log::warn!("the strip layouts are not a map; left out"); + NOT_UNDERSTOOD.with(|count| count.set(count.get() + 1)); + return Ok(BTreeMap::new()); + }; + let total = entries.len(); + let readable: BTreeMap = entries + .into_iter() + .filter_map(|(model, layout)| Some((model, StoredStripLayout::deserialize(layout).ok()?))) + .collect(); + let left_out = total - readable.len(); + if left_out > 0 { + log::warn!("{left_out} strip layouts are not understood; left out"); + NOT_UNDERSTOOD.with(|count| count.set(count.get() + left_out)); + } + Ok(readable) +} + /// Loads the settings; a missing file yields the defaults, an unreadable or /// malformed one is moved aside (never overwriting an earlier one) and also /// yields the defaults. Within a readable file, values this version does not understand take diff --git a/crates/core/tests/settings.rs b/crates/core/tests/settings.rs index 47b82a4..60ea42c 100644 --- a/crates/core/tests/settings.rs +++ b/crates/core/tests/settings.rs @@ -1,7 +1,8 @@ use neumann_control_core::settings::{ load, save, BandCurves, CrossoverGuard, DeskSettings, EqPreset, GroupSettings, Language, - LanguageChoice, Linkable, ManualSpeaker, MeterFloor, Settings, SettingsPreset, StoredStripLayout, - StripElement, SystemAudioSettings, ThemeChoice, WindowGeometry, SETTINGS_FILE, + LanguageChoice, Linkable, ManualSpeaker, MeterFloor, Settings, SettingsPreset, + StoredStripLayout, StripElement, SystemAudioSettings, ThemeChoice, WindowGeometry, + SETTINGS_FILE, }; use neumann_control_core::ssc::wire::SscPath; use std::fs; @@ -138,6 +139,7 @@ fn desk_settings_round_trip() { language: LanguageChoice::English, compact: true, crossovers: CrossoverGuard::Open, + ..DeskSettings::default() }, compact_window: Some(WindowGeometry { x: 10.0, @@ -441,7 +443,10 @@ fn strip_layouts_round_trip() { above: vec![element("audio/out/phaseinversion")], below: vec![element("audio/out/mute"), StripElement::Eq], }; - settings.desk.strip_layouts.insert("KH 120 II".to_owned(), layout); + settings + .desk + .strip_layouts + .insert("KH 120 II".to_owned(), layout); save(&settings, &path).unwrap(); assert_eq!(load(&path), settings); } @@ -452,7 +457,10 @@ fn a_file_without_strip_layouts_loads_none() { let path = directory.path().join(SETTINGS_FILE); fs::write(&path, r#"{"desk": {"compact": false}}"#).unwrap(); assert!(load(&path).desk.strip_layouts.is_empty()); - assert!(!directory.path().join(format!("{SETTINGS_FILE}.bad")).exists()); + assert!(!directory + .path() + .join(format!("{SETTINGS_FILE}.bad")) + .exists()); } #[test] From b9e2abf31b118f6f8a363e9ba10e2d9fa780cf03 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:23:00 +0200 Subject: [PATCH 214/460] Test the strip layout rules --- crates/app/src/lib.rs | 1 + crates/app/src/strip_layout.rs | 212 +++++++++++++++++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 crates/app/src/strip_layout.rs diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index 745fb88..7879659 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -17,6 +17,7 @@ pub mod scan_choice; pub mod scan_report; pub mod spawner; pub mod state; +pub mod strip_layout; pub mod status_leds; pub mod system_audio; pub mod system_language; diff --git a/crates/app/src/strip_layout.rs b/crates/app/src/strip_layout.rs new file mode 100644 index 0000000..7e22e55 --- /dev/null +++ b/crates/app/src/strip_layout.rs @@ -0,0 +1,212 @@ +//! Which elements a model's channel strips show besides their fixed part, and how a change to +//! that list is applied. The stored list is resolved against the catalog every time, so a +//! hand-edited or older file never puts something on a strip that the model cannot show. + +use neumann_control_core::catalog::{Catalog, Entry, Kind}; +use neumann_control_core::settings::{StoredStripLayout, StripElement}; + +use crate::paths; + +/// Above or below the fader. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Area { + Above, + Below, +} + +/// The elements a strip draws, resolved for one model. +#[derive(Clone, Debug, PartialEq, Default)] +pub struct StripLayout { + pub above: Vec, + pub below: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum LayoutChange { + Add(StripElement, Area), + Remove(StripElement), + /// To `index` in the stored list of `area`, counted after taking the element out. + MoveTo(StripElement, Area, usize), + MoveUp(StripElement), + MoveDown(StripElement), + Swap { old: StripElement, new: StripElement }, + Reset, +} + +const DEFAULT_ABOVE: [&str; 2] = [paths::DELAY, paths::STANDBY_TIME]; +const DEFAULT_BELOW: [&str; 2] = [paths::MUTE, paths::IDENTIFY]; + +/// Today's strip: delay and standby time above the fader, mute, identify and EQ below. +pub fn default_layout(product: &str, catalog: &Catalog) -> StoredStripLayout { + todo!() +} + +pub fn resolve( + stored: Option<&StoredStripLayout>, + product: &str, + catalog: &Catalog, +) -> StripLayout { + todo!() +} + +/// What could still be put on the strip, in catalog order, the EQ button last. +pub fn addable(layout: &StripLayout, product: &str, catalog: &Catalog) -> Vec { + todo!() +} + +/// The layout to store after `change`; `None` drops the model's entry, so it shows the default. +pub fn apply(layout: &StripLayout, change: &LayoutChange) -> Option { + todo!() +} + +pub fn on_strip(layout: &StripLayout, element: &StripElement) -> bool { + todo!() +} + +#[cfg(test)] +mod tests { + use super::*; + use neumann_control_core::ssc::wire::SscPath; + + const MONITOR: &str = "KH 120 II"; + const SUBWOOFER: &str = "KH 750"; + const PHASE_INVERSION: &str = "audio/out/phaseinversion"; + const TEMPERATURE: &str = "device/temperature"; + + fn element(text: &str) -> StripElement { + StripElement::Path(SscPath::parse(text).unwrap()) + } + + fn elements(texts: &[&str]) -> Vec { + texts.iter().map(|text| element(text)).collect() + } + + fn monitor_default() -> StripLayout { + resolve(None, MONITOR, Catalog::embedded()) + } + + fn stored(layout: StoredStripLayout) -> StripLayout { + StripLayout { + above: layout.above, + below: layout.below, + } + } + + #[test] + fn the_default_is_todays_strip() { + let layout = monitor_default(); + assert_eq!(layout.above, elements(&[paths::DELAY, paths::STANDBY_TIME])); + let mut below = elements(&[paths::MUTE, paths::IDENTIFY]); + below.push(StripElement::Eq); + assert_eq!(layout.below, below); + let subwoofer = resolve(None, SUBWOOFER, Catalog::embedded()); + assert!(subwoofer.below.contains(&StripElement::Eq)); + } + + #[test] + fn resolve_drops_what_the_model_cannot_show() { + let stored = StoredStripLayout { + above: elements(&[paths::DEVICE_NAME, PHASE_INVERSION, paths::DELAY, paths::DELAY]), + below: Vec::new(), + }; + let layout = resolve(Some(&stored), SUBWOOFER, Catalog::embedded()); + assert_eq!(layout.above, elements(&[paths::DELAY])); + } + + #[test] + fn addable_lists_what_is_missing_only() { + let addable = addable(&monitor_default(), MONITOR, Catalog::embedded()); + assert!(addable.contains(&element(PHASE_INVERSION))); + for missing in [TEMPERATURE, paths::DEVICE_NAME, paths::MUTE] { + assert!(!addable.contains(&element(missing)), "{missing}"); + } + assert!(!addable.contains(&StripElement::Eq)); + } + + #[test] + fn add_remove_and_swap() { + let layout = monitor_default(); + let phase = element(PHASE_INVERSION); + let added = stored(apply(&layout, &LayoutChange::Add(phase.clone(), Area::Below)).unwrap()); + assert_eq!(added.below.last(), Some(&phase)); + let swap = LayoutChange::Swap { + old: element(paths::MUTE), + new: phase.clone(), + }; + let swapped = stored(apply(&layout, &swap).unwrap()); + assert_eq!(swapped.below[0], phase); + assert!(!swapped.below.contains(&element(paths::MUTE))); + let removed = stored(apply(&swapped, &LayoutChange::Remove(phase.clone())).unwrap()); + assert!(!removed.below.contains(&phase)); + } + + #[test] + fn moving_crosses_the_fader_at_the_ends() { + let layout = monitor_default(); + let standby = element(paths::STANDBY_TIME); + let down = stored(apply(&layout, &LayoutChange::MoveDown(standby.clone())).unwrap()); + assert_eq!(down.above, elements(&[paths::DELAY])); + assert_eq!(down.below[0], standby); + let up = stored(apply(&down, &LayoutChange::MoveUp(standby.clone())).unwrap()); + assert_eq!(up, layout); + let delay = element(paths::DELAY); + let top = stored(apply(&layout, &LayoutChange::MoveUp(delay)).unwrap()); + assert_eq!(top, layout); + } + + #[test] + fn move_to_places_at_the_stored_index() { + let change = LayoutChange::MoveTo(StripElement::Eq, Area::Above, 0); + let moved = stored(apply(&monitor_default(), &change).unwrap()); + assert_eq!(moved.above[0], StripElement::Eq); + assert!(!moved.below.contains(&StripElement::Eq)); + } + + #[test] + fn operations_use_the_stored_order_when_an_element_is_absent() { + let layout = StripLayout { + above: elements(&[paths::DELAY, PHASE_INVERSION, paths::STANDBY_TIME]), + below: Vec::new(), + }; + let change = LayoutChange::MoveUp(element(paths::STANDBY_TIME)); + let moved = stored(apply(&layout, &change).unwrap()); + assert_eq!( + moved.above, + elements(&[paths::DELAY, paths::STANDBY_TIME, PHASE_INVERSION]) + ); + } + + #[test] + fn reset_removes_the_entry() { + assert_eq!(apply(&monitor_default(), &LayoutChange::Reset), None); + } + + #[test] + fn no_element_twice_after_any_change() { + let layout = monitor_default(); + let phase = element(PHASE_INVERSION); + let mute = element(paths::MUTE); + let changes = [ + LayoutChange::Add(mute.clone(), Area::Above), + LayoutChange::Add(phase.clone(), Area::Above), + LayoutChange::MoveTo(mute.clone(), Area::Above, 1), + LayoutChange::MoveDown(element(paths::STANDBY_TIME)), + LayoutChange::Swap { + old: mute.clone(), + new: element(paths::IDENTIFY), + }, + LayoutChange::Swap { + old: mute, + new: phase, + }, + ]; + for change in &changes { + let result = stored(apply(&layout, change).unwrap()); + let all: Vec<&StripElement> = result.above.iter().chain(&result.below).collect(); + for element in &all { + let count = all.iter().filter(|other| **other == *element).count(); + assert_eq!(count, 1, "{change:?}: {element:?}"); + } + } + } +} From 46bff68c267dbf459738f78ad9dd9071e516135a Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:28:24 +0200 Subject: [PATCH 215/460] Resolve and change strip layouts per model --- crates/app/src/strip_layout.rs | 167 +++++++++++++++++++++++++++++++-- 1 file changed, 159 insertions(+), 8 deletions(-) diff --git a/crates/app/src/strip_layout.rs b/crates/app/src/strip_layout.rs index 7e22e55..392fca7 100644 --- a/crates/app/src/strip_layout.rs +++ b/crates/app/src/strip_layout.rs @@ -3,7 +3,7 @@ //! hand-edited or older file never puts something on a strip that the model cannot show. use neumann_control_core::catalog::{Catalog, Entry, Kind}; -use neumann_control_core::settings::{StoredStripLayout, StripElement}; +use neumann_control_core::settings::{Language, StoredStripLayout, StripElement}; use crate::paths; @@ -29,7 +29,10 @@ pub enum LayoutChange { MoveTo(StripElement, Area, usize), MoveUp(StripElement), MoveDown(StripElement), - Swap { old: StripElement, new: StripElement }, + Swap { + old: StripElement, + new: StripElement, + }, Reset, } @@ -38,29 +41,172 @@ const DEFAULT_BELOW: [&str; 2] = [paths::MUTE, paths::IDENTIFY]; /// Today's strip: delay and standby time above the fader, mute, identify and EQ below. pub fn default_layout(product: &str, catalog: &Catalog) -> StoredStripLayout { - todo!() + let paths_of = |texts: &[&str]| -> Vec { + texts + .iter() + .map(|text| StripElement::Path(paths::path(text))) + .filter(|element| shows(element, product, catalog)) + .collect() + }; + let mut below = paths_of(&DEFAULT_BELOW); + if shows(&StripElement::Eq, product, catalog) { + below.push(StripElement::Eq); + } + StoredStripLayout { + above: paths_of(&DEFAULT_ABOVE), + below, + } } +/// The stored layout, or the default one, without what the model cannot show and without +/// repeats (the first one stays). pub fn resolve( stored: Option<&StoredStripLayout>, product: &str, catalog: &Catalog, ) -> StripLayout { - todo!() + let layout = stored + .cloned() + .unwrap_or_else(|| default_layout(product, catalog)); + let mut seen: Vec = Vec::new(); + let mut keep = |elements: Vec| -> Vec { + elements + .into_iter() + .filter(|element| { + let new = shows(element, product, catalog) && !seen.contains(element); + if new { + seen.push(element.clone()); + } + new + }) + .collect() + }; + let above = keep(layout.above); + let below = keep(layout.below); + StripLayout { above, below } } /// What could still be put on the strip, in catalog order, the EQ button last. pub fn addable(layout: &StripLayout, product: &str, catalog: &Catalog) -> Vec { - todo!() + let mut addable: Vec = catalog + .entries_for(product) + .into_iter() + .filter(|entry| fits_a_strip(entry)) + .map(|entry| StripElement::Path(entry.path.clone())) + .filter(|element| !on_strip(layout, element)) + .collect(); + let eq = StripElement::Eq; + if shows(&eq, product, catalog) && !on_strip(layout, &eq) { + addable.push(eq); + } + addable } /// The layout to store after `change`; `None` drops the model's entry, so it shows the default. pub fn apply(layout: &StripLayout, change: &LayoutChange) -> Option { - todo!() + let mut changed = layout.clone(); + match change { + LayoutChange::Reset => return None, + LayoutChange::Add(element, area) if !on_strip(layout, element) => { + changed.area_mut(*area).push(element.clone()); + } + LayoutChange::Add(..) => {} + LayoutChange::Remove(element) => { + changed.take(element); + } + LayoutChange::MoveTo(element, area, index) => { + if changed.take(element).is_some() { + let list = changed.area_mut(*area); + list.insert((*index).min(list.len()), element.clone()); + } + } + LayoutChange::MoveUp(element) => changed.move_up(element), + LayoutChange::MoveDown(element) => changed.move_down(element), + LayoutChange::Swap { old, new } => { + changed.take(new); + if let Some((area, index)) = changed.place(old) { + changed.area_mut(area)[index] = new.clone(); + } + } + } + Some(StoredStripLayout { + above: changed.above, + below: changed.below, + }) } pub fn on_strip(layout: &StripLayout, element: &StripElement) -> bool { - todo!() + layout.place(element).is_some() +} + +impl StripLayout { + fn area_mut(&mut self, area: Area) -> &mut Vec { + match area { + Area::Above => &mut self.above, + Area::Below => &mut self.below, + } + } + + fn place(&self, element: &StripElement) -> Option<(Area, usize)> { + let in_area = |list: &[StripElement]| list.iter().position(|other| other == element); + let above = in_area(&self.above).map(|index| (Area::Above, index)); + above.or_else(|| in_area(&self.below).map(|index| (Area::Below, index))) + } + + fn take(&mut self, element: &StripElement) -> Option<(Area, usize)> { + let (area, index) = self.place(element)?; + self.area_mut(area).remove(index); + Some((area, index)) + } + + /// One place up; the first below the fader goes to the end above it. + fn move_up(&mut self, element: &StripElement) { + match self.place(element) { + Some((Area::Below, 0)) => { + self.below.remove(0); + self.above.push(element.clone()); + } + Some((area, index)) if index > 0 => self.area_mut(area).swap(index, index - 1), + _ => {} + } + } + + /// One place down; the last above the fader goes to the start below it. + fn move_down(&mut self, element: &StripElement) { + let Some((area, index)) = self.place(element) else { + return; + }; + let last = self.area_mut(area).len() - 1; + match area { + Area::Above if index == last => { + self.above.remove(index); + self.below.insert(0, element.clone()); + } + _ if index < last => self.area_mut(area).swap(index, index + 1), + _ => {} + } + } +} + +/// Whether `element` can be drawn on a strip of `product`. +fn shows(element: &StripElement, product: &str, catalog: &Catalog) -> bool { + match element { + StripElement::Eq => !catalog.eq_containers(product).is_empty(), + StripElement::Path(path) => catalog + .entries_for(product) + .into_iter() + .any(|entry| entry.path == *path && fits_a_strip(entry)), + } +} + +/// One value a strip can show: no text field, no band list, no list of numbers, no meter. +fn fits_a_strip(entry: &Entry) -> bool { + let one_value = !entry.bands && entry.element_labels(Language::German).is_empty(); + let kind = matches!( + entry.kind, + Kind::Number | Kind::Switch | Kind::Select | Kind::Display + ); + kind && one_value } #[cfg(test)] @@ -106,7 +252,12 @@ mod tests { #[test] fn resolve_drops_what_the_model_cannot_show() { let stored = StoredStripLayout { - above: elements(&[paths::DEVICE_NAME, PHASE_INVERSION, paths::DELAY, paths::DELAY]), + above: elements(&[ + paths::DEVICE_NAME, + PHASE_INVERSION, + paths::DELAY, + paths::DELAY, + ]), below: Vec::new(), }; let layout = resolve(Some(&stored), SUBWOOFER, Catalog::embedded()); From e07f14b1c26cfc10bed7b0e527227d1f4fbcd676 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:29:17 +0200 Subject: [PATCH 216/460] Test toggling any switch path --- crates/app/src/paths.rs | 1 + crates/app/src/writes.rs | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/crates/app/src/paths.rs b/crates/app/src/paths.rs index f8b1af1..388096d 100644 --- a/crates/app/src/paths.rs +++ b/crates/app/src/paths.rs @@ -5,6 +5,7 @@ use neumann_control_core::ssc::wire::SscPath; pub const OUTPUT_LEVEL: &str = "audio/out/level"; pub const MUTE: &str = "audio/out/mute"; +pub const PHASE_INVERSION: &str = "audio/out/phaseinversion"; pub const IDENTIFY: &str = "device/identification/visual"; pub const INPUT_METER: &str = "m/in/level"; pub const OUTPUT_METER: &str = "m/out/level"; diff --git a/crates/app/src/writes.rs b/crates/app/src/writes.rs index 2722807..5bcd6e4 100644 --- a/crates/app/src/writes.rs +++ b/crates/app/src/writes.rs @@ -581,6 +581,15 @@ mod tests { assert_eq!(speaker.knobs[&delay].shown(0.0, 0.5), 0.0); } + #[test] + fn toggle_works_for_any_switch_path() { + let mut speaker = speaker("KH 120 II"); + polled(&mut speaker, paths::PHASE_INVERSION, Value::Bool(false)); + let phase = paths::path(paths::PHASE_INVERSION); + let command = speaker.request(Intent::Toggle(phase), Catalog::embedded(), 0.0); + assert_eq!(command, set(paths::PHASE_INVERSION, Value::Bool(true))); + } + #[test] fn a_switch_without_a_reported_value_is_refused() { let mut speaker = speaker("KH 120 II"); From b2c2c4db2a1259c42ce09ea251fc0550cca5325f Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:33:41 +0200 Subject: [PATCH 217/460] Toggle any switch path --- crates/app/src/lib.rs | 2 +- crates/app/src/views/strip.rs | 2 +- crates/app/src/writes.rs | 56 ++++++++++++++++++++++++++--------- 3 files changed, 44 insertions(+), 16 deletions(-) diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index 7879659..7bf626d 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -17,8 +17,8 @@ pub mod scan_choice; pub mod scan_report; pub mod spawner; pub mod state; -pub mod strip_layout; pub mod status_leds; +pub mod strip_layout; pub mod system_audio; pub mod system_language; pub mod targets; diff --git a/crates/app/src/views/strip.rs b/crates/app/src/views/strip.rs index e5d4391..4cb27f2 100644 --- a/crates/app/src/views/strip.rs +++ b/crates/app/src/views/strip.rs @@ -373,7 +373,7 @@ pub(super) fn toggle( let button = Button::new(text).selected(current.unwrap_or(false)); if ui.add_enabled(enabled, button).clicked() { let now = ui.input(|input| input.time); - commands.extend(speaker.request(Intent::Toggle(switch), catalog, now)); + commands.extend(speaker.request(Intent::Toggle(path), catalog, now)); } } diff --git a/crates/app/src/writes.rs b/crates/app/src/writes.rs index 5bcd6e4..0c1bf55 100644 --- a/crates/app/src/writes.rs +++ b/crates/app/src/writes.rs @@ -29,8 +29,8 @@ impl Switch { /// What a control of a speaker asks for. #[derive(Clone, Debug, PartialEq)] pub enum Intent { - /// Flip a switch to the opposite of its current value. - Toggle(Switch), + /// Flip the switch at the path to the opposite of its current value. + Toggle(SscPath), /// Set the output level. Level(f64), /// Set any catalog path to the given value. @@ -55,7 +55,7 @@ impl Intent { pub fn path(&self) -> SscPath { match self { - Intent::Toggle(switch) => switch.path(), + Intent::Toggle(path) => path.clone(), Intent::Level(_) => paths::path(paths::OUTPUT_LEVEL), Intent::Write { path, .. } => path.clone(), } @@ -209,7 +209,11 @@ mod tests { #[test] fn toggle_writes_the_opposite_switch_value() { let mut speaker = speaker("KH 120 II"); - let command = speaker.request(Intent::Toggle(Switch::Mute), Catalog::embedded(), 0.0); + let command = speaker.request( + Intent::Toggle(Switch::Mute.path()), + Catalog::embedded(), + 0.0, + ); assert_eq!(command, set(paths::MUTE, Value::Bool(true))); assert_eq!(speaker.message, None); } @@ -306,7 +310,11 @@ mod tests { }; speaker.apply(level, 2.0); assert!(speaker.message.is_some(), "a poll ended the refusal"); - let command = speaker.request(Intent::Toggle(Switch::Mute), Catalog::embedded(), 3.0); + let command = speaker.request( + Intent::Toggle(Switch::Mute.path()), + Catalog::embedded(), + 3.0, + ); assert_eq!(command, set(paths::MUTE, Value::Bool(true))); assert_eq!(speaker.message, None); } @@ -405,7 +413,11 @@ mod tests { value: Value::Bool(false), }; speaker.apply(identify, 0.0); - speaker.request(Intent::Toggle(Switch::Identify), Catalog::embedded(), 0.0); + speaker.request( + Intent::Toggle(Switch::Identify.path()), + Catalog::embedded(), + 0.0, + ); speaker.request(Intent::Level(130.0), Catalog::embedded(), 0.0); assert!(speaker.written.is_empty(), "{:?}", speaker.written); } @@ -482,16 +494,20 @@ mod tests { fn a_second_toggle_before_the_confirmation_toggles_back() { let mut speaker = speaker("KH 120 II"); let catalog = Catalog::embedded(); - let first = speaker.request(Intent::Toggle(Switch::Mute), catalog, 0.0); + let first = speaker.request(Intent::Toggle(Switch::Mute.path()), catalog, 0.0); assert_eq!(first, set(paths::MUTE, Value::Bool(true))); - let second = speaker.request(Intent::Toggle(Switch::Mute), catalog, 0.1); + let second = speaker.request(Intent::Toggle(Switch::Mute.path()), catalog, 0.1); assert_eq!(second, set(paths::MUTE, Value::Bool(false))); } #[test] fn a_poll_reply_older_than_the_write_does_not_undo_it() { let mut speaker = speaker("KH 120 II"); - speaker.request(Intent::Toggle(Switch::Mute), Catalog::embedded(), 0.0); + speaker.request( + Intent::Toggle(Switch::Mute.path()), + Catalog::embedded(), + 0.0, + ); polled(&mut speaker, paths::MUTE, Value::Bool(false)); assert_eq!( speaker.latest(&paths::path(paths::MUTE)), @@ -517,8 +533,8 @@ mod tests { fn two_toggles_show_the_second_only_when_both_are_answered() { let mut speaker = speaker("KH 120 II"); let catalog = Catalog::embedded(); - speaker.request(Intent::Toggle(Switch::Mute), catalog, 0.0); - speaker.request(Intent::Toggle(Switch::Mute), catalog, 0.1); + speaker.request(Intent::Toggle(Switch::Mute.path()), catalog, 0.0); + speaker.request(Intent::Toggle(Switch::Mute.path()), catalog, 0.1); let mute = paths::path(paths::MUTE); answered(&mut speaker, paths::MUTE, Value::Bool(true)); assert_eq!(speaker.values.get(&mute), Some(&Value::Bool(false))); @@ -531,7 +547,11 @@ mod tests { #[test] fn a_rejected_write_lets_the_re_read_show_the_kept_value() { let mut speaker = speaker("KH 120 II"); - speaker.request(Intent::Toggle(Switch::Mute), Catalog::embedded(), 0.0); + speaker.request( + Intent::Toggle(Switch::Mute.path()), + Catalog::embedded(), + 0.0, + ); let mute = paths::path(paths::MUTE); let error = DeviceError { code: READ_ONLY_CODE, @@ -551,7 +571,11 @@ mod tests { #[test] fn a_failed_write_lets_the_reconnect_read_show_the_value() { let mut speaker = speaker("KH 120 II"); - speaker.request(Intent::Toggle(Switch::Mute), Catalog::embedded(), 0.0); + speaker.request( + Intent::Toggle(Switch::Mute.path()), + Catalog::embedded(), + 0.0, + ); let mute = paths::path(paths::MUTE); let failed = Event::WriteFailed { path: mute.clone(), @@ -593,7 +617,11 @@ mod tests { #[test] fn a_switch_without_a_reported_value_is_refused() { let mut speaker = speaker("KH 120 II"); - let command = speaker.request(Intent::Toggle(Switch::Identify), Catalog::embedded(), 0.0); + let command = speaker.request( + Intent::Toggle(Switch::Identify.path()), + Catalog::embedded(), + 0.0, + ); assert_eq!(command, None); let expected = Message::Refused(Refusal::ValueUnknown); assert_eq!(speaker.message, Some(expected)); From 894f21060111938f6616e4c448937149d1018f46 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:36:06 +0200 Subject: [PATCH 218/460] Test strips drawn from their layout --- crates/app/tests/strip_layout.rs | 104 +++++++++++++++++++++++++++++++ crates/app/tests/support/mod.rs | 43 ++++++++++++- 2 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 crates/app/tests/strip_layout.rs diff --git a/crates/app/tests/strip_layout.rs b/crates/app/tests/strip_layout.rs new file mode 100644 index 0000000..e083864 --- /dev/null +++ b/crates/app/tests/strip_layout.rs @@ -0,0 +1,104 @@ +//! A model's strip shows what its stored layout lists; the layout is changed from the strip's and +//! the panel's context menu and in the desk's edit mode. + +mod support; + +use std::collections::BTreeMap; + +use eframe::egui::accesskit::Role; +use egui_kittest::kittest::{NodeT, Queryable}; +use neumann_control_app::display::shown_value; +use neumann_control_app::paths; +use neumann_control_app::texts; +use neumann_control_core::catalog::{Catalog, Entry}; +use neumann_control_core::settings::{StoredStripLayout, StripElement}; +use neumann_control_core::simulator::SimModel; +use neumann_control_core::ssc::wire::SscPath; +use serde_json::json; +use support::*; + +const MONITOR: &str = "KH 120 II"; +const SUBWOOFER: &str = "KH 750"; +const INTERFACE: &str = "audio/in/interface"; +const STANDBY_SWITCH: &str = "device/standby/enabled"; +const TEMPERATURE: &str = "device/temperature"; + +fn element(text: &str) -> StripElement { + StripElement::Path(SscPath::parse(text).unwrap()) +} + +fn layouts(model: &str, above: &[&str], below: &[&str]) -> BTreeMap { + let layout = StoredStripLayout { + above: above.iter().map(|text| element(text)).collect(), + below: below.iter().map(|text| element(text)).collect(), + }; + BTreeMap::from([(model.to_owned(), layout)]) +} + +fn entry(text: &str) -> &'static Entry { + let path = SscPath::parse(text).unwrap(); + Catalog::embedded() + .entries + .iter() + .find(|entry| entry.path == path) + .expect("a catalog path") +} + +fn label(text: &str) -> &'static str { + entry(text).label(texts::language()) +} + +#[test] +fn without_a_layout_the_strip_is_as_before() { + let (_simulators, _directory, harness) = desk_of(&[SimModel::Kh120Ii], BTreeMap::new()); + for knob in [paths::DELAY, paths::STANDBY_TIME] { + assert!(harness.query_all_by_label(label(knob)).next().is_some(), "{knob}"); + } + for button in [texts::MUTE.get(), texts::IDENTIFY.get(), texts::EQ.get()] { + harness.get_by_role_and_label(Role::Button, button); + } +} + +#[test] +fn a_stored_layout_shows_on_every_strip_of_its_model() { + let models = [SimModel::Kh120Ii, SimModel::Kh120Ii, SimModel::Kh750]; + let stored = layouts(MONITOR, &[], &[paths::PHASE_INVERSION]); + let (_simulators, _directory, harness) = desk_of(&models, stored); + let phase = label(paths::PHASE_INVERSION); + assert_eq!(harness.query_all_by_label(phase).count(), 2); + let mutes = harness.query_all_by_role_and_label(Role::Button, texts::MUTE.get()); + assert_eq!(mutes.count(), 1, "only the KH 750 keeps its mute button"); +} + +#[test] +fn an_added_select_writes_through_the_policy() { + let stored = layouts(MONITOR, &[], &[INTERFACE]); + let (simulators, _directory, mut harness) = desk_of(&[SimModel::Kh120Ii], stored); + harness.get_by_role(Role::ComboBox).click(); + harness.run_steps(2); + harness.get_by_label("DIGITAL ONLY").click(); + wait_until(&mut harness, "the interface write", |_| { + sets(&simulators[0], "/audio/in/interface") == [json!("DIGITAL ONLY")] + }); +} + +#[test] +fn a_switch_the_model_may_not_write_is_shown_disabled() { + let stored = layouts(SUBWOOFER, &[], &[STANDBY_SWITCH]); + let (simulators, _directory, mut harness) = desk_of(&[SimModel::Kh750], stored); + let button = harness.get_by_role_and_label(Role::Button, label(STANDBY_SWITCH)); + assert!(button.accesskit_node().is_disabled()); + button.click(); + harness.run_steps(3); + barrier(&simulators[0]); + assert!(sets(&simulators[0], "/device/standby/enabled").is_empty()); +} + +#[test] +fn a_display_shows_its_value() { + let stored = layouts(SUBWOOFER, &[TEMPERATURE], &[]); + let (simulators, _directory, harness) = desk_of(&[SimModel::Kh750], stored); + let reported = simulators[0].value(TEMPERATURE).expect("a temperature"); + let shown = shown_value(entry(TEMPERATURE), &reported); + assert!(harness.query_by_label_contains(&shown).is_some(), "{shown}"); +} diff --git a/crates/app/tests/support/mod.rs b/crates/app/tests/support/mod.rs index 83c1503..0e8e6b1 100644 --- a/crates/app/tests/support/mod.rs +++ b/crates/app/tests/support/mod.rs @@ -4,6 +4,7 @@ // Each test binary uses its own subset of these helpers. #![allow(dead_code)] +use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::sync::mpsc::{self, Receiver}; use std::sync::{Arc, Mutex}; @@ -21,7 +22,8 @@ use neumann_control_app::texts; use neumann_control_core::catalog::Catalog; use neumann_control_core::discovery::Found; use neumann_control_core::settings::{ - self, GroupSettings, Language, Linkable, ManualSpeaker, Settings, SETTINGS_FILE, + self, DeskSettings, GroupSettings, Language, Linkable, ManualSpeaker, Settings, + StoredStripLayout, SETTINGS_FILE, }; use neumann_control_core::simulator::{SimModel, Simulator}; use neumann_control_core::speaker::{self, Events, SpeakerHandle, Target, Timing}; @@ -39,6 +41,7 @@ pub const EXPANDED_WINDOW_SIZE: [f32; 2] = [1200.0, 1600.0]; pub const LOOPBACK: &str = "::1"; const DIALOG_SETTLE_FRAMES: usize = 2; const PRODUCT_PATH: &str = "device/identity/product"; +const SERIAL_PATH: &str = "device/identity/serial"; /// Real speakers take tens of milliseconds per reply; an instant simulator hid tests that /// acted before the connect-time read had arrived. pub const REPLY_DELAY: Duration = Duration::from_millis(5); @@ -454,3 +457,41 @@ pub fn turn_knob( } around(center, radius, to) } + +/// One simulator per model, each with its own serial (the app keeps one strip per serial), all +/// on the desk with `strip_layouts` stored, connected and read once. +pub fn desk_of( + models: &[SimModel], + strip_layouts: BTreeMap, +) -> (Vec, tempfile::TempDir, Harness<'static, NeumannApp>) { + let simulators: Vec = models + .iter() + .enumerate() + .map(|(index, model)| { + let simulator = Simulator::start(*model); + simulator.set_reply_delay(REPLY_DELAY); + simulator.set_value(SERIAL_PATH, Value::from(format!("SIM000000{index}"))); + simulator + }) + .collect(); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(SETTINGS_FILE); + let settings = Settings { + manual: simulators.iter().map(manual_for).collect(), + desk: DeskSettings { + strip_layouts, + ..DeskSettings::default() + }, + ..Settings::default() + }; + settings::save(&settings, &path).unwrap(); + let mut harness = harness(path, Vec::new()); + harness.set_size(egui::vec2(1400.0, 900.0)); + wait_until(&mut harness, "every strip connected", |harness| { + harness.query_all_by_label(&readout(100.0)).count() == models.len() + }); + for simulator in &simulators { + wait_for_initial_read(&mut harness, simulator); + } + (simulators, directory, harness) +} From 8298aeab97a97524da9dd2a5e1bd8ed01ad8457b Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:44:13 +0200 Subject: [PATCH 219/460] Draw each strip from its model's layout Mute and identify are listed in the device panel now, so a layout can take them off the strip and put them back. --- crates/app/src/app/desk.rs | 6 + crates/app/src/views/compact.rs | 9 +- crates/app/src/views/details.rs | 6 +- crates/app/src/views/strip.rs | 266 ++++++++++++++++++++++------- crates/app/tests/details.rs | 9 +- crates/app/tests/strip_layout.rs | 5 +- crates/app/tests/support/mod.rs | 6 +- crates/app/tests/write_gestures.rs | 5 +- 8 files changed, 233 insertions(+), 79 deletions(-) diff --git a/crates/app/src/app/desk.rs b/crates/app/src/app/desk.rs index 5d46f3f..9940aa2 100644 --- a/crates/app/src/app/desk.rs +++ b/crates/app/src/app/desk.rs @@ -18,6 +18,7 @@ use crate::groups::{ }; use crate::paths; use crate::state::{SpeakerKey, SpeakerState, Status}; +use crate::strip_layout; use crate::texts; use crate::ui_kit::theme::{group_color, group_color_count}; use crate::views::group_strip::{self, GroupStripResponse, MasterResponse}; @@ -85,11 +86,16 @@ impl NeumannApp { ui.multiply_opacity(DRAGGED_OPACITY); } let member = &ids[index]; + let product = speaker.state.product(); + let stored = desk.strip_layouts.get(product); + let layout = + strip_layout::resolve(stored, product, catalog); strip::show( ui, &mut speaker.state, catalog, &desk, + &layout, color, member, ) diff --git a/crates/app/src/views/compact.rs b/crates/app/src/views/compact.rs index 469973a..075e576 100644 --- a/crates/app/src/views/compact.rs +++ b/crates/app/src/views/compact.rs @@ -80,7 +80,14 @@ pub fn speaker( let floor = desk.meter_floor.db(); meters::bar(ui, loudest, floor, texts::INPUT_METERS.get(), &tokens); let mute = texts::MUTE.get(); - strip::toggle(ui, speaker, catalog, &mut commands, Switch::Mute, mute); + strip::toggle( + ui, + speaker, + catalog, + &mut commands, + Switch::Mute.path(), + mute, + ); if let Some(message) = &speaker.message { message_mark(ui, &texts::describe_message(message), &tokens); } diff --git a/crates/app/src/views/details.rs b/crates/app/src/views/details.rs index e5a3a3b..4909b52 100644 --- a/crates/app/src/views/details.rs +++ b/crates/app/src/views/details.rs @@ -17,12 +17,10 @@ use crate::texts; const COLUMNS: usize = 2; const GROUP_GAP: f32 = 8.0; -/// Drawn by the strip (level, mute, identify, meters) or the panel header (model, firmware, -/// serial). +/// Drawn by the strip's fixed part (level, meters) or the panel header (model, firmware, +/// serial). Mute and identify stay listed: a layout can take them off the strip. const SHOWN_ELSEWHERE: &[&str] = &[ paths::OUTPUT_LEVEL, - paths::MUTE, - paths::IDENTIFY, paths::INPUT_METER, paths::INPUT_CLIP, paths::PRODUCT, diff --git a/crates/app/src/views/strip.rs b/crates/app/src/views/strip.rs index 4cb27f2..adced4a 100644 --- a/crates/app/src/views/strip.rs +++ b/crates/app/src/views/strip.rs @@ -1,19 +1,21 @@ -//! One speaker as a channel strip: name (opens the device panel), status LEDs, delay and -//! standby knobs, level fader with meters, readout, response time, mute and identify, and the -//! last message. The strip only reports gestures; `writes` decides what becomes a command. +//! One speaker as a channel strip: name (opens the device panel), status LEDs, the elements of +//! its model's layout above and below the level fader with meters, readout and response time, +//! and the last message. The strip only reports gestures; `writes` decides what becomes a command. use eframe::egui::{ - self, vec2, Align, Button, Color32, CornerRadius, Label, Layout, Pos2, Rect, RichText, Role, - Sense, Stroke, Ui, Vec2, WidgetInfo, + self, vec2, Align, Button, Color32, ComboBox, CornerRadius, Label, Layout, Pos2, Rect, + RichText, Role, Sense, Stroke, Ui, Vec2, WidgetInfo, }; -use neumann_control_core::catalog::Catalog; -use neumann_control_core::settings::DeskSettings; +use neumann_control_core::catalog::{Catalog, Entry, Kind}; +use neumann_control_core::settings::{DeskSettings, StripElement}; use neumann_control_core::speaker::Command; +use neumann_control_core::ssc::wire::SscPath; use serde_json::Value; use super::fader::{self, Orientation, Scale, FADER_HEIGHT}; use super::knob; use super::meters::{self, Meters}; +use crate::display::shown_value; use crate::eq::has_eq; use crate::fader::{output_level_range, WHEEL_STEP_DB}; use crate::gesture::on_grid; @@ -21,9 +23,10 @@ use crate::input_source::input_source; use crate::paths; use crate::state::{SpeakerState, Status}; use crate::status_leds::{status_leds, ConnectionLed}; +use crate::strip_layout::StripLayout; use crate::texts; use crate::ui_kit::theme::Tokens; -use crate::writes::{self, Intent, Switch}; +use crate::writes::{self, Intent}; pub const STRIP_WIDTH: f32 = 140.0; const STRIP_MARGIN: i8 = 6; @@ -42,6 +45,7 @@ const METER_GROUP_GAP: f32 = 4.0; const METER_SPACING: f32 = 3.0; const CAPTION_HEIGHT: f32 = 16.0; const LEVEL_ENTRY_ID: &str = "level_entry"; +const KNOBS_PER_ROW: usize = 2; pub struct StripResponse { /// The writes the user asked for that the write policy allowed. @@ -58,6 +62,7 @@ pub fn show( speaker: &mut SpeakerState, catalog: &Catalog, desk: &DeskSettings, + layout: &StripLayout, group_color: Option, member: &str, ) -> StripResponse { @@ -88,12 +93,12 @@ pub fn show( }) .inner; leds(ui, speaker, &tokens); - knobs(ui, speaker, catalog, &mut commands); + open_eq |= elements(ui, speaker, catalog, &layout.above, &mut commands); level(ui, speaker, catalog, desk, &mut commands, &tokens); if desk.show_response_times { response_time(ui, speaker); } - open_eq = buttons(ui, speaker, catalog, &mut commands); + open_eq |= elements(ui, speaker, catalog, &layout.below, &mut commands); message(ui, speaker, &tokens); open_panel }) @@ -169,32 +174,191 @@ fn led(ui: &mut Ui, color: Color32, text: &str) { response.on_hover_text(text); } -fn knobs(ui: &mut Ui, speaker: &mut SpeakerState, catalog: &Catalog, commands: &mut Vec) { - let product = speaker.product().to_owned(); - let entries = catalog.entries_for(&product); - let entry = |path: &str| { - let path = paths::path(path); - entries.iter().copied().find(|entry| entry.path == path) - }; - ui.columns(2, |columns| { - let knobs = [paths::DELAY, paths::STANDBY_TIME]; - for (column, path) in columns.iter_mut().zip(knobs) { - let Some(entry) = entry(path) else { - continue; - }; - // Named as in the device panel, so one setting has one name. - let label = entry.label(texts::language()); - let sent = knob::for_entry( - column, - speaker, - catalog, - entry, - label, - true, - knob::KNOB_DIAMETER, - ); - commands.extend(sent); +/// One area of the layout. Neighbours of one kind share a row: knobs two by two, buttons +/// wrapped; a select or a display takes the whole width. Paths the speaker lacks are skipped. +fn elements( + ui: &mut Ui, + speaker: &mut SpeakerState, + catalog: &Catalog, + list: &[StripElement], + commands: &mut Vec, +) -> bool { + let entries = catalog.entries_for(speaker.product()); + let drawn: Vec = list + .iter() + .filter_map(|element| match element { + StripElement::Eq => has_eq(catalog, speaker).then_some(Drawn::Eq), + StripElement::Path(path) => entries + .iter() + .copied() + .find(|entry| entry.path == *path) + .filter(|_| !speaker.absent.contains(path)) + .map(Drawn::Entry), + }) + .collect(); + let mut open_eq = false; + for run in drawn.chunk_by(|left, right| left.row() == right.row()) { + match run[0].row() { + Row::Knobs => knob_rows(ui, speaker, catalog, run, commands), + Row::Buttons => open_eq |= button_row(ui, speaker, catalog, run, commands), + Row::Select => { + for entry in run.iter().filter_map(Drawn::entry) { + select(ui, speaker, catalog, entry, commands); + } + } + Row::Display => { + for entry in run.iter().filter_map(Drawn::entry) { + display(ui, speaker, entry); + } + } + } + } + open_eq +} + +#[derive(Clone, Copy)] +enum Drawn<'a> { + Entry(&'a Entry), + Eq, +} + +#[derive(Clone, Copy, PartialEq)] +enum Row { + Knobs, + Buttons, + Select, + Display, +} + +impl<'a> Drawn<'a> { + fn entry(&self) -> Option<&'a Entry> { + match self { + Drawn::Entry(entry) => Some(entry), + Drawn::Eq => None, + } + } + + fn row(&self) -> Row { + let Some(entry) = self.entry() else { + return Row::Buttons; + }; + match entry.kind { + Kind::Number => Row::Knobs, + Kind::Switch => Row::Buttons, + Kind::Select => Row::Select, + _ => Row::Display, + } + } +} + +fn knob_rows( + ui: &mut Ui, + speaker: &mut SpeakerState, + catalog: &Catalog, + run: &[Drawn], + commands: &mut Vec, +) { + for pair in run.chunks(KNOBS_PER_ROW) { + ui.columns(KNOBS_PER_ROW, |columns| { + for (column, entry) in columns.iter_mut().zip(pair.iter().filter_map(Drawn::entry)) { + // Named as in the device panel, so one setting has one name. + let label = entry.label(texts::language()); + let sent = knob::for_entry( + column, + speaker, + catalog, + entry, + label, + true, + knob::KNOB_DIAMETER, + ); + commands.extend(sent); + } + }); + } +} + +fn button_row( + ui: &mut Ui, + speaker: &mut SpeakerState, + catalog: &Catalog, + run: &[Drawn], + commands: &mut Vec, +) -> bool { + ui.horizontal_wrapped(|ui| { + let mut open_eq = false; + for drawn in run { + match drawn { + Drawn::Eq => open_eq |= ui.button(texts::EQ).clicked(), + Drawn::Entry(entry) => { + let text = button_text(entry); + toggle(ui, speaker, catalog, commands, entry.path.clone(), text); + } + } } + open_eq + }) + .inner +} + +/// The name from the panel, but identify keeps the strip's short one to fit beside mute. +fn button_text(entry: &Entry) -> &str { + if entry.path == paths::path(paths::IDENTIFY) { + return texts::IDENTIFY.get(); + } + entry.label(texts::language()) +} + +fn select( + ui: &mut Ui, + speaker: &mut SpeakerState, + catalog: &Catalog, + entry: &Entry, + commands: &mut Vec, +) { + let current = speaker + .values + .get(&entry.path) + .and_then(Value::as_str) + .map(str::to_owned); + let enabled = current.is_some() && writes::can_write(speaker, catalog, &entry.path); + let current = current.unwrap_or_default(); + let caption = ui.label(RichText::new(entry.label(texts::language())).small()); + let mut chosen = None; + ui.add_enabled_ui(enabled, |ui| { + ComboBox::from_id_salt(entry.path.to_string()) + .selected_text(¤t) + .width(ui.available_width()) + .show_ui(ui, |ui| { + for option in &entry.options { + let selected = *option == current; + if ui.selectable_label(selected, option).clicked() && !selected { + chosen = Some(option.clone()); + } + } + }) + .response + .labelled_by(caption.id); + }); + if let Some(option) = chosen { + let now = ui.input(|input| input.time); + let intent = Intent::Write { + path: entry.path.clone(), + value: Value::String(option), + }; + commands.extend(speaker.request(intent, catalog, now)); + } +} + +fn display(ui: &mut Ui, speaker: &SpeakerState, entry: &Entry) { + let shown = speaker + .values + .get(&entry.path) + .map(|value| shown_value(entry, value)) + .unwrap_or_default(); + ui.horizontal_wrapped(|ui| { + ui.weak(RichText::new(entry.label(texts::language())).small()); + ui.weak(RichText::new(shown).small()); }); } @@ -329,45 +493,15 @@ fn response_time(ui: &mut Ui, speaker: &SpeakerState) { } /// Mute and identify, and EQ for models with EQ containers; true when EQ was clicked. -fn buttons( - ui: &mut Ui, - speaker: &mut SpeakerState, - catalog: &Catalog, - commands: &mut Vec, -) -> bool { - let has_eq = has_eq(catalog, speaker); - ui.horizontal(|ui| { - toggle( - ui, - speaker, - catalog, - commands, - Switch::Mute, - texts::MUTE.get(), - ); - toggle( - ui, - speaker, - catalog, - commands, - Switch::Identify, - texts::IDENTIFY.get(), - ); - has_eq && ui.button(texts::EQ).clicked() - }) - .inner -} - /// A button showing a switch value of the speaker; a click asks for the opposite. pub(super) fn toggle( ui: &mut Ui, speaker: &mut SpeakerState, catalog: &Catalog, commands: &mut Vec, - switch: Switch, + path: SscPath, text: &str, ) { - let path = switch.path(); let current = speaker.values.get(&path).and_then(Value::as_bool); let enabled = current.is_some() && writes::can_write(speaker, catalog, &path); let button = Button::new(text).selected(current.unwrap_or(false)); diff --git a/crates/app/tests/details.rs b/crates/app/tests/details.rs index 06723e1..e09707d 100644 --- a/crates/app/tests/details.rs +++ b/crates/app/tests/details.rs @@ -366,11 +366,12 @@ fn a_name_draft_left_by_tabbing_past_apply_follows_the_device_again() { } #[test] -fn strip_controls_are_not_repeated_in_the_panel() { +fn the_fixed_part_of_the_strip_is_not_repeated_in_the_panel() { let (_simulator, _directory, harness) = speaker_with_panel(SimModel::Kh120Ii); - assert_eq!(harness.query_all_by_label(texts::MUTE.get()).count(), 1); - assert_eq!(harness.query_all_by_label(texts::IDENTIFY.get()).count(), 1); - assert!(harness.query_by_label("Identifizieren").is_none()); + // Mute and identify are listed in the panel too: a layout can take them off the strip. + let mutes = harness.query_all_by_role_and_label(Role::CheckBox, texts::MUTE.get()); + assert_eq!(mutes.count(), 1); + assert!(harness.query_by_label("Identifizieren").is_some()); assert!(harness.query_by_label("Eingangspegel").is_none()); let faders = harness.query_all_by_role_and_label(Role::Slider, texts::LEVEL.get()); assert_eq!(faders.count(), 1); diff --git a/crates/app/tests/strip_layout.rs b/crates/app/tests/strip_layout.rs index e083864..97acc8b 100644 --- a/crates/app/tests/strip_layout.rs +++ b/crates/app/tests/strip_layout.rs @@ -52,7 +52,10 @@ fn label(text: &str) -> &'static str { fn without_a_layout_the_strip_is_as_before() { let (_simulators, _directory, harness) = desk_of(&[SimModel::Kh120Ii], BTreeMap::new()); for knob in [paths::DELAY, paths::STANDBY_TIME] { - assert!(harness.query_all_by_label(label(knob)).next().is_some(), "{knob}"); + assert!( + harness.query_all_by_label(label(knob)).next().is_some(), + "{knob}" + ); } for button in [texts::MUTE.get(), texts::IDENTIFY.get(), texts::EQ.get()] { harness.get_by_role_and_label(Role::Button, button); diff --git a/crates/app/tests/support/mod.rs b/crates/app/tests/support/mod.rs index 0e8e6b1..ff5123e 100644 --- a/crates/app/tests/support/mod.rs +++ b/crates/app/tests/support/mod.rs @@ -463,7 +463,11 @@ pub fn turn_knob( pub fn desk_of( models: &[SimModel], strip_layouts: BTreeMap, -) -> (Vec, tempfile::TempDir, Harness<'static, NeumannApp>) { +) -> ( + Vec, + tempfile::TempDir, + Harness<'static, NeumannApp>, +) { let simulators: Vec = models .iter() .enumerate() diff --git a/crates/app/tests/write_gestures.rs b/crates/app/tests/write_gestures.rs index 0332f59..f363e6a 100644 --- a/crates/app/tests/write_gestures.rs +++ b/crates/app/tests/write_gestures.rs @@ -106,8 +106,9 @@ fn a_typed_level_outside_the_range_is_refused_not_clamped() { fn the_wheel_on_a_panel_knob_does_not_scroll_another_knob_under_the_pointer() { let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); open_panel(&mut harness, KH120_DEVICE_NAME); - // Short enough that the panel scrolls; `open_panel` makes the window tall. - harness.set_size(egui::vec2(1200.0, 420.0)); + // Short enough that the panel scrolls (its delay knob stays below the window), tall enough + // that the logo knob is in view; `open_panel` makes the window tall. + harness.set_size(egui::vec2(1200.0, 480.0)); harness.run_steps(2); let label = texts::knob_label("Logo-Helligkeit"); let center = slider_center(&harness, &label); From ccff8e6790cd54f9ab718b6a3cc349029691f3ae Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:46:24 +0200 Subject: [PATCH 220/460] Test the strip layout menus --- crates/app/src/texts.rs | 6 ++ crates/app/tests/strip_layout.rs | 135 ++++++++++++++++++++++++++++++- 2 files changed, 139 insertions(+), 2 deletions(-) diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 222f5ed..46dcc81 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -140,6 +140,12 @@ pub const LED_LOCAL: Text = Text::new( pub const CLOSE: Text = Text::new("Schließen", "Close"); pub const ADD_GROUP: Text = Text::new("+ Gruppe", "+ Group"); pub const MOVE_STRIP: Text = Text::new("Verschieben", "Move"); +pub const HIDE: Text = Text::new("Ausblenden", "Hide"); +pub const SWAP_FOR: Text = Text::new("Tauschen gegen …", "Swap for …"); +pub const MOVE_UP: Text = Text::new("Nach oben", "Move up"); +pub const MOVE_DOWN: Text = Text::new("Nach unten", "Move down"); +pub const ADD_TO_STRIP: Text = Text::new("Auf den Zug", "Add to strip"); +pub const REMOVE_FROM_STRIP: Text = Text::new("Vom Zug nehmen", "Remove from strip"); pub const GROUP_DIALOG_TITLE: Text = Text::new("Gruppe", "Group"); pub const GROUP_NAME: Text = Text::new("Name", "Name"); pub const DELETE_GROUP: Text = Text::new("Gruppe löschen", "Delete group"); diff --git a/crates/app/tests/strip_layout.rs b/crates/app/tests/strip_layout.rs index 97acc8b..0ac0884 100644 --- a/crates/app/tests/strip_layout.rs +++ b/crates/app/tests/strip_layout.rs @@ -5,13 +5,16 @@ mod support; use std::collections::BTreeMap; -use eframe::egui::accesskit::Role; +use eframe::egui::{self, accesskit::Role}; use egui_kittest::kittest::{NodeT, Queryable}; +use egui_kittest::Harness; +use neumann_control_app::app::NeumannApp; use neumann_control_app::display::shown_value; use neumann_control_app::paths; +use neumann_control_app::strip_layout; use neumann_control_app::texts; use neumann_control_core::catalog::{Catalog, Entry}; -use neumann_control_core::settings::{StoredStripLayout, StripElement}; +use neumann_control_core::settings::{self, StoredStripLayout, StripElement}; use neumann_control_core::simulator::SimModel; use neumann_control_core::ssc::wire::SscPath; use serde_json::json; @@ -105,3 +108,131 @@ fn a_display_shows_its_value() { let shown = shown_value(entry(TEMPERATURE), &reported); assert!(harness.query_by_label_contains(&shown).is_some(), "{shown}"); } + +fn open_first_panel(harness: &mut Harness<'static, NeumannApp>, name: &str) { + harness.set_size(EXPANDED_WINDOW_SIZE.into()); + harness.run_steps(2); + harness.query_all_by_label(name).next().unwrap().click(); + wait_until(harness, "the device panel", |harness| { + harness.query_by_label(texts::ALL_VALUES.get()).is_some() + }); + harness.run_steps(1); +} + +fn right_click_button(harness: &mut Harness<'static, NeumannApp>, label: &str) { + harness.get_by_role_and_label(Role::Button, label).click_secondary(); + harness.run_steps(2); +} + +fn choose(harness: &mut Harness<'static, NeumannApp>, item: &str) { + harness.get_by_label(item).click(); + harness.run_steps(3); +} + +fn stored_layout(directory: &tempfile::TempDir, model: &str) -> Option { + let settings = settings::load(&directory.path().join(settings::SETTINGS_FILE)); + settings.desk.strip_layouts.get(model).cloned() +} + +#[test] +fn add_to_strip_from_the_panel_reaches_both_monitors() { + let models = [SimModel::Kh120Ii, SimModel::Kh120Ii, SimModel::Kh750]; + let (_simulators, directory, mut harness) = desk_of(&models, BTreeMap::new()); + open_first_panel(&mut harness, KH120_DEVICE_NAME); + let phase = label(paths::PHASE_INVERSION); + harness + .get_by_role_and_label(Role::CheckBox, phase) + .click_secondary(); + harness.run_steps(2); + choose(&mut harness, texts::ADD_TO_STRIP.get()); + let buttons = harness.query_all_by_role_and_label(Role::Button, phase); + assert_eq!(buttons.count(), 2); + let stored = stored_layout(&directory, MONITOR).expect("a stored layout"); + assert!(stored.below.contains(&element(paths::PHASE_INVERSION))); + assert_eq!(stored_layout(&directory, SUBWOOFER), None); +} + +#[test] +fn hide_from_the_strip_menu() { + let (_simulators, _directory, mut harness) = desk_of(&[SimModel::Kh120Ii], BTreeMap::new()); + right_click_button(&mut harness, texts::IDENTIFY.get()); + choose(&mut harness, texts::HIDE.get()); + let identify = harness.query_by_role_and_label(Role::Button, texts::IDENTIFY.get()); + assert!(identify.is_none()); + open_first_panel(&mut harness, KH120_DEVICE_NAME); + harness + .get_by_role_and_label(Role::CheckBox, label(paths::IDENTIFY)) + .click_secondary(); + harness.run_steps(2); + assert!(harness.query_by_label(texts::ADD_TO_STRIP.get()).is_some()); + harness.key_press(egui::Key::Escape); + harness.run_steps(2); + harness + .get_by_role_and_label(Role::CheckBox, texts::MUTE.get()) + .click_secondary(); + harness.run_steps(2); + assert!(harness.query_by_label(texts::REMOVE_FROM_STRIP.get()).is_some()); +} + +#[test] +fn swap_keeps_the_place() { + let (_simulators, _directory, mut harness) = desk_of(&[SimModel::Kh120Ii], BTreeMap::new()); + right_click_button(&mut harness, texts::IDENTIFY.get()); + choose(&mut harness, texts::SWAP_FOR.get()); + let phase = label(paths::PHASE_INVERSION); + choose(&mut harness, phase); + let left = |label: &str| { + harness + .get_by_role_and_label(Role::Button, label) + .rect() + .left() + }; + assert!(left(texts::MUTE.get()) < left(phase)); + assert!(left(phase) < left(texts::EQ.get())); +} + +#[test] +fn swap_is_offered_only_with_something_to_add() { + let catalog = Catalog::embedded(); + let default = strip_layout::resolve(None, SUBWOOFER, catalog); + let mut everything: Vec = default.above.clone(); + everything.extend(default.below.clone()); + everything.extend(strip_layout::addable(&default, SUBWOOFER, catalog)); + let full = StoredStripLayout { + above: Vec::new(), + below: everything, + }; + let stored = BTreeMap::from([(SUBWOOFER.to_owned(), full)]); + let (_simulators, _directory, mut harness) = desk_of(&[SimModel::Kh750], stored); + right_click_button(&mut harness, texts::EQ.get()); + let swap = harness.get_by_label(texts::SWAP_FOR.get()); + assert!(swap.accesskit_node().is_disabled()); +} + +#[test] +fn the_fixed_part_has_no_layout_menu() { + let (_simulators, _directory, mut harness) = desk_of(&[SimModel::Kh120Ii], BTreeMap::new()); + harness.get_by_label(KH120_DEVICE_NAME).click_secondary(); + harness.run_steps(2); + assert!(harness.query_by_label(texts::HIDE.get()).is_none()); + harness + .get_by_role_and_label(Role::Slider, texts::LEVEL.get()) + .click_secondary(); + harness.run_steps(2); + assert!(harness.query_by_label(texts::HIDE.get()).is_none()); +} + +#[test] +fn the_menu_writes_nothing() { + let (simulators, _directory, mut harness) = desk_of(&[SimModel::Kh120Ii], BTreeMap::new()); + right_click_button(&mut harness, texts::IDENTIFY.get()); + choose(&mut harness, texts::MOVE_UP.get()); + right_click_button(&mut harness, texts::MUTE.get()); + choose(&mut harness, texts::HIDE.get()); + barrier(&simulators[0]); + let written = simulators[0] + .requests() + .iter() + .any(|request| request.to_string().contains("true") || request.to_string().contains("false")); + assert!(!written, "a set reached the speaker"); +} From bf075df78cca5c262e9d421546aa61ea4e9ecbe9 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:51:15 +0200 Subject: [PATCH 221/460] Change strip layouts from a context menu --- crates/app/src/app.rs | 20 +++- crates/app/src/app/desk.rs | 29 +++++- crates/app/src/views/details.rs | 48 ++++++++-- crates/app/src/views/layout_menu.rs | 127 +++++++++++++++++++++++++ crates/app/src/views/mod.rs | 1 + crates/app/src/views/panel.rs | 15 ++- crates/app/src/views/strip.rs | 142 ++++++++++++++++++++-------- crates/app/tests/strip_layout.rs | 19 ++-- crates/app/tests/support/mod.rs | 6 +- 9 files changed, 349 insertions(+), 58 deletions(-) create mode 100644 crates/app/src/views/layout_menu.rs diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index 73a7a8f..a408b78 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -22,6 +22,7 @@ use crate::scan_choice::ScanChoice; use crate::spawner::{ScanResult, SpeakerSpawner}; use crate::state::{AllValuesState, SpeakerKey, SpeakerState, Status}; use crate::status_leds::average_response_time; +use crate::strip_layout; use crate::system_audio::{AudioDevice, SystemAudio}; use crate::targets::{duplicate_speakers, follow_moves, forget, remember_serial, targets}; use crate::texts; @@ -467,7 +468,24 @@ impl NeumannApp { linked: &linked, }; let state = &mut self.speakers[index].state; - let response = panel::show(context, state, catalog, anchor, opened_now, &choice); + let product = state.product().to_owned(); + let stored = self.settings.desk.strip_layouts.get(&product); + let layout = state + .identity + .is_some() + .then(|| strip_layout::resolve(stored, &product, catalog)); + let response = panel::show( + context, + state, + catalog, + anchor, + opened_now, + &choice, + layout.as_ref(), + ); + if let Some(change) = &response.layout { + self.change_layout(&product, change, catalog); + } let now = context.input(|input| input.time); for command in response.commands { self.send_with_links(index, command, catalog, &ids, now); diff --git a/crates/app/src/app/desk.rs b/crates/app/src/app/desk.rs index 9940aa2..8340eab 100644 --- a/crates/app/src/app/desk.rs +++ b/crates/app/src/app/desk.rs @@ -18,7 +18,7 @@ use crate::groups::{ }; use crate::paths; use crate::state::{SpeakerKey, SpeakerState, Status}; -use crate::strip_layout; +use crate::strip_layout::{self, LayoutChange}; use crate::texts; use crate::ui_kit::theme::{group_color, group_color_count}; use crate::views::group_strip::{self, GroupStripResponse, MasterResponse}; @@ -39,6 +39,8 @@ pub(super) enum DeskAction { OpenPanel(OpenPanel), OpenEq(SpeakerKey), System(SystemStripResponse), + /// A change of one model's strip layout. + Layout(String, LayoutChange), } /// The group being renamed or deleted, and the name typed so far. @@ -106,6 +108,10 @@ impl NeumannApp { if response.open_eq { actions.push(DeskAction::OpenEq(key.clone())); } + if let Some(change) = response.layout { + let product = speaker.state.product().to_owned(); + actions.push(DeskAction::Layout(product, change)); + } if let Some(anchor) = response.open_panel { let opened_now = true; let open = OpenPanel { @@ -200,7 +206,28 @@ impl NeumannApp { DeskAction::OpenPanel(open) => self.panel = Some(open), DeskAction::OpenEq(key) => self.open_eq(key), DeskAction::System(response) => self.apply_system_volume(response), + DeskAction::Layout(product, change) => self.change_layout(&product, &change, catalog), + } + } + + /// Applies `change` to the stored layout of `product` and saves; a reset drops the entry. + pub(super) fn change_layout( + &mut self, + product: &str, + change: &LayoutChange, + catalog: &Catalog, + ) { + let layouts = &mut self.settings.desk.strip_layouts; + let layout = strip_layout::resolve(layouts.get(product), product, catalog); + match strip_layout::apply(&layout, change) { + Some(stored) => { + layouts.insert(product.to_owned(), stored); + } + None => { + layouts.remove(product); + } } + self.save_settings(); } /// A group or master fader let go after moving by `moved` dB: every connected member moves by diff --git a/crates/app/src/views/details.rs b/crates/app/src/views/details.rs index 4909b52..8f5b894 100644 --- a/crates/app/src/views/details.rs +++ b/crates/app/src/views/details.rs @@ -5,13 +5,16 @@ use eframe::egui::{self, Button, RichText, Ui}; use neumann_control_core::catalog::{Catalog, Entry, Group, Kind}; +use neumann_control_core::settings::StripElement; use neumann_control_core::speaker::Command; use super::details_fields::Fields; use super::knob; +use super::layout_menu; use crate::display::shown_value; use crate::paths; use crate::state::{AllValuesState, FullScanState, SpeakerState, Status}; +use crate::strip_layout::{self, LayoutChange, StripLayout}; use crate::texts; const COLUMNS: usize = 2; @@ -28,14 +31,33 @@ const SHOWN_ELSEWHERE: &[&str] = &[ paths::SERIAL, ]; -/// Draws the area and returns the writes the user asked for that the write policy allowed. -pub fn show(ui: &mut Ui, speaker: &mut SpeakerState, catalog: &Catalog) -> Vec { - ui.vertical(|ui| rows(ui, speaker, catalog)).inner +pub struct DetailsResponse { + /// The writes the user asked for that the write policy allowed. + pub commands: Vec, + /// A row put on the model's strips or taken off them. + pub layout: Option, } -fn rows(ui: &mut Ui, speaker: &mut SpeakerState, catalog: &Catalog) -> Vec { +/// Draws the area; `layout` is the model's strip layout once the speaker reported its model. +pub fn show( + ui: &mut Ui, + speaker: &mut SpeakerState, + catalog: &Catalog, + layout: Option<&StripLayout>, +) -> DetailsResponse { + ui.vertical(|ui| rows(ui, speaker, catalog, layout)).inner +} + +fn rows( + ui: &mut Ui, + speaker: &mut SpeakerState, + catalog: &Catalog, + layout: Option<&StripLayout>, +) -> DetailsResponse { let product = speaker.product().to_owned(); let groups = groups(catalog.entries_for(&product), speaker); + let addable = layout.map(|layout| strip_layout::addable(layout, &product, catalog)); + let mut change = None; let mut commands = Vec::new(); let mut fields = Fields { speaker, @@ -49,7 +71,18 @@ fn rows(ui: &mut Ui, speaker: &mut SpeakerState, catalog: &Catalog) -> Vec Vec = Vec<(Group, Vec<&'a Entry>)>; diff --git a/crates/app/src/views/layout_menu.rs b/crates/app/src/views/layout_menu.rs new file mode 100644 index 0000000..8c1fe5b --- /dev/null +++ b/crates/app/src/views/layout_menu.rs @@ -0,0 +1,127 @@ +//! The context menus that change a model's strip layout: on an element of a strip, and on a row +//! of the device panel. A right click anywhere on the element's area opens them, also on a +//! disabled control, which takes no click itself. They only report the change. + +use eframe::egui::{Id, Popup, PopupAnchor, PopupKind, Rect, SetOpenCommand, Ui}; +use neumann_control_core::catalog::Catalog; +use neumann_control_core::settings::StripElement; + +use crate::strip_layout::{self, Area, LayoutChange, StripLayout}; +use crate::texts; + +const ELEMENT_MENU_ID: &str = "strip_element_menu"; +const ROW_MENU_ID: &str = "panel_row_menu"; + +/// An element that could still go on the strip, with the name it is offered under. +pub struct Offer { + pub element: StripElement, + pub label: String, +} + +/// What `product`'s strips could still take, named as in the device panel. +pub fn offers(layout: &StripLayout, product: &str, catalog: &Catalog) -> Vec { + strip_layout::addable(layout, product, catalog) + .into_iter() + .map(|element| Offer { + label: element_label(&element, catalog), + element, + }) + .collect() +} + +fn element_label(element: &StripElement, catalog: &Catalog) -> String { + let StripElement::Path(path) = element else { + return texts::EQ.get().to_owned(); + }; + catalog + .entries + .iter() + .find(|entry| entry.path == *path) + .map_or_else( + || path.to_string(), + |entry| entry.label(texts::language()).to_owned(), + ) +} + +/// Hide, swap, move up or down one element of a strip. +pub fn element_menu( + ui: &Ui, + rect: Rect, + element: &StripElement, + offers: &[Offer], +) -> Option { + let id = ui.scope_id().with((ELEMENT_MENU_ID, menu_key(element))); + let mut change = None; + menu(ui, id, rect, |ui| { + if ui.button(texts::HIDE).clicked() { + change = Some(LayoutChange::Remove(element.clone())); + } + ui.add_enabled_ui(!offers.is_empty(), |ui| { + ui.menu_button(texts::SWAP_FOR, |ui| { + for offer in offers { + if ui.button(&offer.label).clicked() { + change = Some(LayoutChange::Swap { + old: element.clone(), + new: offer.element.clone(), + }); + } + } + }); + }); + if ui.button(texts::MOVE_UP).clicked() { + change = Some(LayoutChange::MoveUp(element.clone())); + } + if ui.button(texts::MOVE_DOWN).clicked() { + change = Some(LayoutChange::MoveDown(element.clone())); + } + }); + change +} + +/// Put a panel row on the strip (below the fader), or take it off. +pub fn row_menu( + ui: &Ui, + rect: Rect, + element: &StripElement, + on_strip: bool, +) -> Option { + let id = ui.scope_id().with((ROW_MENU_ID, menu_key(element))); + let mut change = None; + menu(ui, id, rect, |ui| { + let (text, wish) = match on_strip { + true => ( + texts::REMOVE_FROM_STRIP, + LayoutChange::Remove(element.clone()), + ), + false => ( + texts::ADD_TO_STRIP, + LayoutChange::Add(element.clone(), Area::Below), + ), + }; + if ui.button(text).clicked() { + change = Some(wish); + } + }); + change +} + +fn menu(ui: &Ui, id: Id, rect: Rect, contents: impl FnOnce(&mut Ui)) { + let right_clicked = + ui.input(|input| input.pointer.secondary_clicked()) && ui.rect_contains_pointer(rect); + Popup::new( + id, + ui.ctx().clone(), + PopupAnchor::PointerFixed, + ui.layer_id(), + ) + .kind(PopupKind::Menu) + .open_memory(right_clicked.then_some(SetOpenCommand::Bool(true))) + .show(contents); +} + +fn menu_key(element: &StripElement) -> String { + match element { + StripElement::Path(path) => path.to_string(), + StripElement::Eq => texts::EQ.get().to_owned(), + } +} diff --git a/crates/app/src/views/mod.rs b/crates/app/src/views/mod.rs index 54db566..8ced8e2 100644 --- a/crates/app/src/views/mod.rs +++ b/crates/app/src/views/mod.rs @@ -11,6 +11,7 @@ pub mod group_dialog; pub mod group_strip; pub mod history; mod knob; +mod layout_menu; mod meters; mod number_field; pub mod panel; diff --git a/crates/app/src/views/panel.rs b/crates/app/src/views/panel.rs index 4a4caeb..a12b3f4 100644 --- a/crates/app/src/views/panel.rs +++ b/crates/app/src/views/panel.rs @@ -14,6 +14,7 @@ use crate::eq::has_eq; use crate::groups::linkable_label; use crate::paths; use crate::state::SpeakerState; +use crate::strip_layout::{LayoutChange, StripLayout}; use crate::texts; const PANEL_ID: &str = "device_panel"; @@ -36,6 +37,8 @@ pub struct PanelResponse { pub open_eq: bool, pub save_preset: bool, pub load_preset: bool, + /// A row put on the model's strips or taken off them. + pub layout: Option, } /// The groups the speaker could be in, which one it is in, and what that group links. @@ -54,6 +57,7 @@ pub fn show( anchor: Pos2, opened_now: bool, groups: &GroupChoice, + layout: Option<&StripLayout>, ) -> PanelResponse { let mut close = false; let mut remove = false; @@ -85,7 +89,7 @@ pub fn show( close = header(ui, speaker); group = group_choice(ui, groups); copy_to_group = copy_buttons(ui, groups); - let commands = details::show(ui, speaker, catalog); + let details = details::show(ui, speaker, catalog, layout); ui.separator(); trends::show(ui, &trends_of(ui, speaker, catalog)); ui.separator(); @@ -95,7 +99,7 @@ pub fn show( }); open_eq = has_eq && ui.button(texts::EQ).clicked(); remove = ui.button(texts::REMOVE).clicked(); - commands + details }) .expect("the panel window is open"); let escape = !editing && context.input(|input| input.key_pressed(Key::Escape)); @@ -111,8 +115,13 @@ pub fn show( layer != Some(window.response.layer_id) && !on_popup }); let clicked_outside = pressed && outside; + let details = window.inner; + let (commands, layout) = details.map_or((Vec::new(), None), |details| { + (details.commands, details.layout) + }); PanelResponse { - commands: window.inner.unwrap_or_default(), + commands, + layout, close: close || escape || (clicked_outside && !opened_now), remove, group, diff --git a/crates/app/src/views/strip.rs b/crates/app/src/views/strip.rs index adced4a..54dc82a 100644 --- a/crates/app/src/views/strip.rs +++ b/crates/app/src/views/strip.rs @@ -14,6 +14,7 @@ use serde_json::Value; use super::fader::{self, Orientation, Scale, FADER_HEIGHT}; use super::knob; +use super::layout_menu::{self, Offer}; use super::meters::{self, Meters}; use crate::display::shown_value; use crate::eq::has_eq; @@ -23,7 +24,7 @@ use crate::input_source::input_source; use crate::paths; use crate::state::{SpeakerState, Status}; use crate::status_leds::{status_leds, ConnectionLed}; -use crate::strip_layout::StripLayout; +use crate::strip_layout::{LayoutChange, StripLayout}; use crate::texts; use crate::ui_kit::theme::Tokens; use crate::writes::{self, Intent}; @@ -55,6 +56,15 @@ pub struct StripResponse { /// The strip's area, where another strip can be dropped. pub rect: Rect, pub open_eq: bool, + /// A change of the model's layout from an element's context menu. + pub layout: Option, +} + +/// What the strip's areas asked for besides writes. +#[derive(Default)] +struct Wishes { + open_eq: bool, + layout: Option, } pub fn show( @@ -68,7 +78,13 @@ pub fn show( ) -> StripResponse { let tokens = Tokens::current(ui.ctx()); let mut commands = Vec::new(); - let mut open_eq = false; + let mut wishes = Wishes::default(); + // Only a speaker that reported its model can change that model's layout. + let offers = speaker + .identity + .is_some() + .then(|| layout_menu::offers(layout, speaker.product(), catalog)); + let offers = offers.as_deref(); let inner_width = STRIP_WIDTH - 2.0 * (f32::from(STRIP_MARGIN) + BORDER_WIDTH); let frame = egui::Frame::new() .fill(tokens.card) @@ -93,12 +109,30 @@ pub fn show( }) .inner; leds(ui, speaker, &tokens); - open_eq |= elements(ui, speaker, catalog, &layout.above, &mut commands); + let above = &layout.above; + elements( + ui, + speaker, + catalog, + above, + offers, + &mut commands, + &mut wishes, + ); level(ui, speaker, catalog, desk, &mut commands, &tokens); if desk.show_response_times { response_time(ui, speaker); } - open_eq |= elements(ui, speaker, catalog, &layout.below, &mut commands); + let below = &layout.below; + elements( + ui, + speaker, + catalog, + below, + offers, + &mut commands, + &mut wishes, + ); message(ui, speaker, &tokens); open_panel }) @@ -108,7 +142,8 @@ pub fn show( commands, open_panel: frame.inner, rect: frame.response.rect, - open_eq, + open_eq: wishes.open_eq, + layout: wishes.layout, } } @@ -181,8 +216,10 @@ fn elements( speaker: &mut SpeakerState, catalog: &Catalog, list: &[StripElement], + offers: Option<&[Offer]>, commands: &mut Vec, -) -> bool { + wishes: &mut Wishes, +) { let entries = catalog.entries_for(speaker.product()); let drawn: Vec = list .iter() @@ -196,24 +233,42 @@ fn elements( .map(Drawn::Entry), }) .collect(); - let mut open_eq = false; for run in drawn.chunk_by(|left, right| left.row() == right.row()) { match run[0].row() { - Row::Knobs => knob_rows(ui, speaker, catalog, run, commands), - Row::Buttons => open_eq |= button_row(ui, speaker, catalog, run, commands), - Row::Select => { - for entry in run.iter().filter_map(Drawn::entry) { - select(ui, speaker, catalog, entry, commands); - } - } - Row::Display => { - for entry in run.iter().filter_map(Drawn::entry) { - display(ui, speaker, entry); + Row::Knobs => knob_rows(ui, speaker, catalog, run, offers, commands, wishes), + Row::Buttons => button_row(ui, speaker, catalog, run, offers, commands, wishes), + Row::Select | Row::Display => { + for drawn in run { + with_menu(ui, drawn.element(), offers, wishes, |ui| { + match drawn.entry() { + Some(entry) if entry.kind == Kind::Select => { + select(ui, speaker, catalog, entry, commands); + } + Some(entry) => display(ui, speaker, entry), + None => {} + } + }); } } } } - open_eq +} + +/// Draws one element and gives it its context menu, when the model's layout can be changed. +fn with_menu( + ui: &mut Ui, + element: StripElement, + offers: Option<&[Offer]>, + wishes: &mut Wishes, + draw: impl FnOnce(&mut Ui), +) { + let rect = ui.scope(draw).response.rect; + let Some(offers) = offers else { + return; + }; + if let Some(change) = layout_menu::element_menu(ui, rect, &element, offers) { + wishes.layout = Some(change); + } } #[derive(Clone, Copy)] @@ -238,6 +293,13 @@ impl<'a> Drawn<'a> { } } + fn element(&self) -> StripElement { + match self { + Drawn::Entry(entry) => StripElement::Path(entry.path.clone()), + Drawn::Eq => StripElement::Eq, + } + } + fn row(&self) -> Row { let Some(entry) = self.entry() else { return Row::Buttons; @@ -256,23 +318,28 @@ fn knob_rows( speaker: &mut SpeakerState, catalog: &Catalog, run: &[Drawn], + offers: Option<&[Offer]>, commands: &mut Vec, + wishes: &mut Wishes, ) { for pair in run.chunks(KNOBS_PER_ROW) { ui.columns(KNOBS_PER_ROW, |columns| { for (column, entry) in columns.iter_mut().zip(pair.iter().filter_map(Drawn::entry)) { - // Named as in the device panel, so one setting has one name. - let label = entry.label(texts::language()); - let sent = knob::for_entry( - column, - speaker, - catalog, - entry, - label, - true, - knob::KNOB_DIAMETER, - ); - commands.extend(sent); + let element = StripElement::Path(entry.path.clone()); + with_menu(column, element, offers, wishes, |ui| { + // Named as in the device panel, so one setting has one name. + let label = entry.label(texts::language()); + let sent = knob::for_entry( + ui, + speaker, + catalog, + entry, + label, + true, + knob::KNOB_DIAMETER, + ); + commands.extend(sent); + }); } }); } @@ -283,22 +350,23 @@ fn button_row( speaker: &mut SpeakerState, catalog: &Catalog, run: &[Drawn], + offers: Option<&[Offer]>, commands: &mut Vec, -) -> bool { + wishes: &mut Wishes, +) { + let mut open_eq = false; ui.horizontal_wrapped(|ui| { - let mut open_eq = false; for drawn in run { - match drawn { + with_menu(ui, drawn.element(), offers, wishes, |ui| match drawn { Drawn::Eq => open_eq |= ui.button(texts::EQ).clicked(), Drawn::Entry(entry) => { let text = button_text(entry); toggle(ui, speaker, catalog, commands, entry.path.clone(), text); } - } + }); } - open_eq - }) - .inner + }); + wishes.open_eq |= open_eq; } /// The name from the panel, but identify keeps the strip's short one to fit beside mute. diff --git a/crates/app/tests/strip_layout.rs b/crates/app/tests/strip_layout.rs index 0ac0884..a226dd8 100644 --- a/crates/app/tests/strip_layout.rs +++ b/crates/app/tests/strip_layout.rs @@ -5,7 +5,7 @@ mod support; use std::collections::BTreeMap; -use eframe::egui::{self, accesskit::Role}; +use eframe::egui::accesskit::Role; use egui_kittest::kittest::{NodeT, Queryable}; use egui_kittest::Harness; use neumann_control_app::app::NeumannApp; @@ -120,7 +120,9 @@ fn open_first_panel(harness: &mut Harness<'static, NeumannApp>, name: &str) { } fn right_click_button(harness: &mut Harness<'static, NeumannApp>, label: &str) { - harness.get_by_role_and_label(Role::Button, label).click_secondary(); + harness + .get_by_role_and_label(Role::Button, label) + .click_secondary(); harness.run_steps(2); } @@ -165,13 +167,13 @@ fn hide_from_the_strip_menu() { .click_secondary(); harness.run_steps(2); assert!(harness.query_by_label(texts::ADD_TO_STRIP.get()).is_some()); - harness.key_press(egui::Key::Escape); - harness.run_steps(2); harness .get_by_role_and_label(Role::CheckBox, texts::MUTE.get()) .click_secondary(); harness.run_steps(2); - assert!(harness.query_by_label(texts::REMOVE_FROM_STRIP.get()).is_some()); + assert!(harness + .query_by_label(texts::REMOVE_FROM_STRIP.get()) + .is_some()); } #[test] @@ -230,9 +232,8 @@ fn the_menu_writes_nothing() { right_click_button(&mut harness, texts::MUTE.get()); choose(&mut harness, texts::HIDE.get()); barrier(&simulators[0]); - let written = simulators[0] - .requests() - .iter() - .any(|request| request.to_string().contains("true") || request.to_string().contains("false")); + let written = simulators[0].requests().iter().any(|request| { + request.to_string().contains("true") || request.to_string().contains("false") + }); assert!(!written, "a set reached the speaker"); } diff --git a/crates/app/tests/support/mod.rs b/crates/app/tests/support/mod.rs index ff5123e..9c9cc04 100644 --- a/crates/app/tests/support/mod.rs +++ b/crates/app/tests/support/mod.rs @@ -491,8 +491,12 @@ pub fn desk_of( settings::save(&settings, &path).unwrap(); let mut harness = harness(path, Vec::new()); harness.set_size(egui::vec2(1400.0, 900.0)); + // By name: a strip with a long layout can push its readout out of the window. wait_until(&mut harness, "every strip connected", |harness| { - harness.query_all_by_label(&readout(100.0)).count() == models.len() + models.iter().all(|model| { + let same_model = models.iter().filter(|other| *other == model).count(); + harness.query_all_by_label(device_name(*model)).count() >= same_model + }) }); for simulator in &simulators { wait_for_initial_read(&mut harness, simulator); From bda1d926e5feef91ecddeaf0d6a380cdd211d037 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:38:55 +0200 Subject: [PATCH 222/460] Test the strip edit mode --- crates/app/src/texts.rs | 17 +++ crates/app/tests/strip_layout.rs | 190 ++++++++++++++++++++++++++++++- crates/app/tests/support/mod.rs | 18 ++- 3 files changed, 218 insertions(+), 7 deletions(-) diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 46dcc81..092fab9 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -146,6 +146,10 @@ pub const MOVE_UP: Text = Text::new("Nach oben", "Move up"); pub const MOVE_DOWN: Text = Text::new("Nach unten", "Move down"); pub const ADD_TO_STRIP: Text = Text::new("Auf den Zug", "Add to strip"); pub const REMOVE_FROM_STRIP: Text = Text::new("Vom Zug nehmen", "Remove from strip"); +pub const CUSTOMIZE_STRIPS: Text = Text::new("Züge anpassen", "Customize strips"); +pub const DONE: Text = Text::new("Fertig", "Done"); +pub const ADD_ELEMENT: Text = Text::new("+ hinzufügen", "+ Add"); +pub const DEFAULT_LAYOUT: Text = Text::new("Standard", "Default"); pub const GROUP_DIALOG_TITLE: Text = Text::new("Gruppe", "Group"); pub const GROUP_NAME: Text = Text::new("Name", "Name"); pub const DELETE_GROUP: Text = Text::new("Gruppe löschen", "Delete group"); @@ -341,6 +345,19 @@ const REJECTED_LOCAL_MODE_CODE: i64 = 401; const REJECTED_READ_ONLY_CODE: i64 = 405; const REJECTED_WRONG_VALUE_CODE: i64 = 406; +/// The edit mode's note that a strip layout belongs to the model, not to the one speaker. +pub fn applies_to_every(model: &str) -> String { + pick( + format!("gilt für alle {model}"), + format!("applies to every {model}"), + ) +} + +/// The × beside an element in edit mode, named after the element. +pub fn hide_element(name: &str) -> String { + pick(format!("Ausblenden: {name}"), format!("Hide {name}")) +} + pub fn speaker_count(count: usize) -> String { pick( format!("{count} Lautsprecher"), diff --git a/crates/app/tests/strip_layout.rs b/crates/app/tests/strip_layout.rs index a226dd8..4d010a9 100644 --- a/crates/app/tests/strip_layout.rs +++ b/crates/app/tests/strip_layout.rs @@ -5,7 +5,7 @@ mod support; use std::collections::BTreeMap; -use eframe::egui::accesskit::Role; +use eframe::egui::{self, accesskit::Role}; use egui_kittest::kittest::{NodeT, Queryable}; use egui_kittest::Harness; use neumann_control_app::app::NeumannApp; @@ -237,3 +237,191 @@ fn the_menu_writes_nothing() { }); assert!(!written, "a set reached the speaker"); } + +fn start_editing(harness: &mut Harness<'static, NeumannApp>) { + harness.get_by_label(texts::CUSTOMIZE_STRIPS.get()).click(); + harness.run_steps(2); +} + +fn hide_in_edit_mode(harness: &mut Harness<'static, NeumannApp>, path: &str) { + harness.get_by_label(&texts::hide_element(label(path))).click(); + harness.run_steps(2); +} + +fn add_in_edit_mode(harness: &mut Harness<'static, NeumannApp>, offer: &str) { + harness.get_by_label(texts::ADD_ELEMENT.get()).click(); + harness.run_steps(2); + choose(harness, offer); +} + +const DRAG_FRAMES: usize = 6; + +fn drag(harness: &mut Harness<'static, NeumannApp>, from: egui::Pos2, to: egui::Pos2) { + harness.drag_at(from); + for frame in 1..=DRAG_FRAMES { + let progress = frame as f32 / DRAG_FRAMES as f32; + harness.hover_at(from + (to - from) * progress); + harness.step(); + } + harness.drop_at(to); + harness.run_steps(3); +} + +fn center(harness: &Harness<'static, NeumannApp>, role: Role, label: &str) -> egui::Pos2 { + harness.get_by_role_and_label(role, label).rect().center() +} + +fn wheel(harness: &mut Harness<'static, NeumannApp>, notches: f32) { + harness.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, notches), + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Wheel, + modifiers: egui::Modifiers::NONE, + }); +} + +/// Whether any request carried a value: reads ask with `null` only. +fn any_set(simulator: &neumann_control_core::simulator::Simulator) -> bool { + simulator.requests().iter().any(|request| { + let text = request.to_string(); + let leaves = text.matches(':').count(); + let nulls = text.matches("null").count(); + let xids = text.matches("\"xid\"").count(); + leaves > nulls + xids + }) +} + +#[test] +fn editing_hides_and_adds() { + let (_simulators, directory, mut harness) = desk_of(&[SimModel::Kh120Ii], BTreeMap::new()); + start_editing(&mut harness); + hide_in_edit_mode(&mut harness, paths::IDENTIFY); + add_in_edit_mode(&mut harness, label(paths::PHASE_INVERSION)); + harness.get_by_label(texts::DONE.get()).click(); + harness.run_steps(2); + let phase = label(paths::PHASE_INVERSION); + assert!(harness.query_by_role_and_label(Role::Button, phase).is_some()); + let identify = harness.query_by_role_and_label(Role::Button, texts::IDENTIFY.get()); + assert!(identify.is_none()); + assert!(harness.query_by_label(texts::ADD_ELEMENT.get()).is_none()); + let stored = stored_layout(&directory, MONITOR).expect("a stored layout"); + assert!(stored.below.contains(&element(paths::PHASE_INVERSION))); + assert!(!stored.below.contains(&element(paths::IDENTIFY))); +} + +#[test] +fn editing_sends_nothing() { + let (simulators, _directory, mut harness) = desk_of(&[SimModel::Kh120Ii], BTreeMap::new()); + start_editing(&mut harness); + harness + .get_by_role_and_label(Role::Button, texts::MUTE.get()) + .click(); + harness.hover_at(center(&harness, Role::Slider, label(paths::DELAY))); + for _ in 0..4 { + wheel(&mut harness, 1.0); + harness.step(); + } + let fader = center(&harness, Role::Slider, texts::LEVEL.get()); + drag(&mut harness, fader, fader + egui::vec2(0.0, 40.0)); + barrier(&simulators[0]); + assert!(!any_set(&simulators[0]), "a set reached the speaker in edit mode"); + harness.get_by_label(texts::DONE.get()).click(); + harness.run_steps(2); + harness + .get_by_role_and_label(Role::Button, texts::MUTE.get()) + .click(); + wait_until(&mut harness, "the mute after editing", |_| { + !sets(&simulators[0], "/audio/out/mute").is_empty() + }); +} + +#[test] +fn dragging_moves_across_the_fader() { + let (_simulators, directory, mut harness) = desk_of(&[SimModel::Kh120Ii], BTreeMap::new()); + start_editing(&mut harness); + let from = center(&harness, Role::Slider, label(paths::DELAY)); + let to = center(&harness, Role::Button, texts::MUTE.get()); + drag(&mut harness, from, to); + let stored = stored_layout(&directory, MONITOR).expect("a stored layout"); + assert_eq!(stored.below.first(), Some(&element(paths::DELAY))); + let knob = harness + .get_by_role_and_label(Role::Slider, label(paths::DELAY)) + .rect(); + let fader = harness + .get_by_role_and_label(Role::Slider, texts::LEVEL.get()) + .rect(); + assert!(knob.top() > fader.bottom()); +} + +#[test] +fn a_drag_next_to_an_absent_element_keeps_the_stored_order() { + let above = [paths::DELAY, paths::PHASE_INVERSION, paths::STANDBY_TIME]; + let stored = layouts(MONITOR, &above, &[]); + let (_simulators, directory, mut harness) = + desk_prepared(&[SimModel::Kh120Ii], stored, |simulators| { + simulators[0].remove_value(paths::PHASE_INVERSION); + }); + start_editing(&mut harness); + let from = center(&harness, Role::Slider, label(paths::STANDBY_TIME)); + let to = center(&harness, Role::Slider, label(paths::DELAY)); + drag(&mut harness, from, to); + let stored = stored_layout(&directory, MONITOR).expect("a stored layout"); + let expected = [paths::STANDBY_TIME, paths::DELAY, paths::PHASE_INVERSION]; + assert_eq!(stored.above, expected.map(element).to_vec()); +} + +#[test] +fn an_unreachable_strip_can_still_be_edited() { + let (mut simulators, directory, mut harness) = + desk_of(&[SimModel::Kh120Ii], BTreeMap::new()); + simulators.remove(0).stop(); + wait_for_label(&mut harness, texts::UNREACHABLE.get()); + start_editing(&mut harness); + hide_in_edit_mode(&mut harness, paths::IDENTIFY); + let stored = stored_layout(&directory, MONITOR).expect("a stored layout"); + assert!(!stored.below.contains(&element(paths::IDENTIFY))); +} + +#[test] +fn default_restores_todays_strip() { + let (_simulators, directory, mut harness) = desk_of(&[SimModel::Kh120Ii], BTreeMap::new()); + start_editing(&mut harness); + hide_in_edit_mode(&mut harness, paths::IDENTIFY); + harness.get_by_label(texts::DEFAULT_LAYOUT.get()).click(); + harness.run_steps(2); + let identify = harness.query_by_role_and_label(Role::Button, texts::IDENTIFY.get()); + assert!(identify.is_some()); + assert_eq!(stored_layout(&directory, MONITOR), None); +} + +#[test] +fn compact_mode_ends_editing() { + let (_simulators, _directory, mut harness) = desk_of(&[SimModel::Kh120Ii], BTreeMap::new()); + start_editing(&mut harness); + for _ in 0..2 { + harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::K); + harness.run_steps(3); + } + assert!(harness.query_by_label(texts::ADD_ELEMENT.get()).is_none()); + let hide = texts::hide_element(label(paths::IDENTIFY)); + assert!(harness.query_by_label(&hide).is_none()); +} + +#[test] +fn everything_hidden_keeps_the_way_back() { + let stored = layouts(MONITOR, &[], &[]); + let (_simulators, _directory, mut harness) = desk_of(&[SimModel::Kh120Ii], stored); + let mute = texts::MUTE.get(); + assert!(harness.query_by_role_and_label(Role::Button, mute).is_none()); + start_editing(&mut harness); + add_in_edit_mode(&mut harness, label(paths::MUTE)); + assert!(harness.query_by_role_and_label(Role::Button, mute).is_some()); +} + +#[test] +fn the_hint_names_the_model() { + let (_simulators, _directory, mut harness) = desk_of(&[SimModel::Kh120Ii], BTreeMap::new()); + start_editing(&mut harness); + harness.get_by_label(&texts::applies_to_every(MONITOR)); +} diff --git a/crates/app/tests/support/mod.rs b/crates/app/tests/support/mod.rs index 9c9cc04..bc9dfee 100644 --- a/crates/app/tests/support/mod.rs +++ b/crates/app/tests/support/mod.rs @@ -460,14 +460,19 @@ pub fn turn_knob( /// One simulator per model, each with its own serial (the app keeps one strip per serial), all /// on the desk with `strip_layouts` stored, connected and read once. -pub fn desk_of( +pub fn desk_of(models: &[SimModel], strip_layouts: BTreeMap) -> Desk { + desk_prepared(models, strip_layouts, |_| {}) +} + +/// The simulators, the folder of the settings file and the app. +pub type Desk = (Vec, tempfile::TempDir, Harness<'static, NeumannApp>); + +/// Like `desk_of`, with the simulators changed before the app first reads them. +pub fn desk_prepared( models: &[SimModel], strip_layouts: BTreeMap, -) -> ( - Vec, - tempfile::TempDir, - Harness<'static, NeumannApp>, -) { + prepare: impl FnOnce(&[Simulator]), +) -> Desk { let simulators: Vec = models .iter() .enumerate() @@ -478,6 +483,7 @@ pub fn desk_of( simulator }) .collect(); + prepare(&simulators); let directory = tempfile::tempdir().unwrap(); let path = directory.path().join(SETTINGS_FILE); let settings = Settings { From 926e657f2f36c9fe05d32d89d8014d50ab4ad2a9 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:55:51 +0200 Subject: [PATCH 223/460] Edit strip layouts on the desk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mark in the toolbar switches every strip into edit mode: hide with ×, add, drag across the fader, back to the default. Controls stay still meanwhile. --- crates/app/src/app.rs | 5 + crates/app/src/app/compact.rs | 2 + crates/app/src/app/desk.rs | 8 +- crates/app/src/texts.rs | 4 + crates/app/src/views/layout_menu.rs | 4 +- crates/app/src/views/strip.rs | 192 +++++++++++++++++++++++----- crates/app/src/views/toolbar.rs | 56 +++++++- crates/app/tests/compact.rs | 1 + crates/app/tests/strip_layout.rs | 49 ++++--- crates/app/tests/support/mod.rs | 6 +- 10 files changed, 271 insertions(+), 56 deletions(-) diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index a408b78..66bd4cc 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -89,6 +89,8 @@ pub struct NeumannApp { panel: Option, group_draft: Option, settings_open: bool, + /// The desk's strips are being customized; not saved, a new start shows the desk as usual. + editing: bool, window: Option, history: history::HistoryState, eq_windows: Vec, @@ -153,6 +155,7 @@ impl NeumannApp { panel: None, group_draft: None, settings_open: false, + editing: false, history: history::HistoryState::default(), eq_windows: Vec::new(), preset_dialog: None, @@ -374,6 +377,7 @@ impl NeumannApp { fn show(&mut self, ui: &mut Ui, frame: &eframe::Frame) { let facts = ToolbarFacts { compact: self.settings.desk.compact, + editing: self.editing, scanning: self.scan.is_some(), speaker_count: self.speakers.len(), notice: self.notice, @@ -393,6 +397,7 @@ impl NeumannApp { } ToolbarAction::History => self.open_history(), ToolbarAction::ToggleCompact => self.toggle_compact(frame), + ToolbarAction::ToggleEditing => self.editing = !self.editing, ToolbarAction::None => {} } if ui.input_mut(|input| input.consume_shortcut(&COMPACT_SHORTCUT)) { diff --git a/crates/app/src/app/compact.rs b/crates/app/src/app/compact.rs index 6dc9bba..4d58ab8 100644 --- a/crates/app/src/app/compact.rs +++ b/crates/app/src/app/compact.rs @@ -100,6 +100,8 @@ impl NeumannApp { /// Desk to compact or back: the window the new mode last had comes back, fitted onto the /// monitors there are now, else the mode's first size. pub(super) fn toggle_compact(&mut self, frame: &eframe::Frame) { + // Compact rows have no layout to edit; back on the desk the strips are as usual. + self.editing = false; let target = self.settings.switch_mode(self.window); self.save_settings(); // Only a geometry of the new mode may be kept for it; a maximised window reports none diff --git a/crates/app/src/app/desk.rs b/crates/app/src/app/desk.rs index 8340eab..b3ca6e9 100644 --- a/crates/app/src/app/desk.rs +++ b/crates/app/src/app/desk.rs @@ -22,7 +22,7 @@ use crate::strip_layout::{self, LayoutChange}; use crate::texts; use crate::ui_kit::theme::{group_color, group_color_count}; use crate::views::group_strip::{self, GroupStripResponse, MasterResponse}; -use crate::views::strip; +use crate::views::strip::{self, StripView}; use crate::views::system_strip::{self, SystemStrip, SystemStripResponse}; use crate::writes::Intent; @@ -57,6 +57,7 @@ impl NeumannApp { } let catalog = Catalog::embedded(); let desk = self.settings.desk.clone(); + let editing = self.editing; let ids = self.member_ids(); let items = layout(&self.settings.order, &self.settings.groups, &ids); let dragged = DragAndDrop::payload::(ui.ctx()) @@ -97,7 +98,10 @@ impl NeumannApp { &mut speaker.state, catalog, &desk, - &layout, + StripView { + layout: &layout, + editing, + }, color, member, ) diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 092fab9..7d35d97 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -150,6 +150,10 @@ pub const CUSTOMIZE_STRIPS: Text = Text::new("Züge anpassen", "Customize strips pub const DONE: Text = Text::new("Fertig", "Done"); pub const ADD_ELEMENT: Text = Text::new("+ hinzufügen", "+ Add"); pub const DEFAULT_LAYOUT: Text = Text::new("Standard", "Default"); +/// The toolbar's narrow edit button; its name for screen readers is `CUSTOMIZE_STRIPS`/`DONE`. +pub const EDIT_MARK: Text = Text::new("↕", "↕"); +/// The × beside an element in edit mode; its name is `hide_element`. +pub const HIDE_MARK: Text = Text::new("×", "×"); pub const GROUP_DIALOG_TITLE: Text = Text::new("Gruppe", "Group"); pub const GROUP_NAME: Text = Text::new("Name", "Name"); pub const DELETE_GROUP: Text = Text::new("Gruppe löschen", "Delete group"); diff --git a/crates/app/src/views/layout_menu.rs b/crates/app/src/views/layout_menu.rs index 8c1fe5b..4287da5 100644 --- a/crates/app/src/views/layout_menu.rs +++ b/crates/app/src/views/layout_menu.rs @@ -29,7 +29,7 @@ pub fn offers(layout: &StripLayout, product: &str, catalog: &Catalog) -> Vec String { +pub(super) fn element_label(element: &StripElement, catalog: &Catalog) -> String { let StripElement::Path(path) = element else { return texts::EQ.get().to_owned(); }; @@ -119,7 +119,7 @@ fn menu(ui: &Ui, id: Id, rect: Rect, contents: impl FnOnce(&mut Ui)) { .show(contents); } -fn menu_key(element: &StripElement) -> String { +pub(super) fn menu_key(element: &StripElement) -> String { match element { StripElement::Path(path) => path.to_string(), StripElement::Eq => texts::EQ.get().to_owned(), diff --git a/crates/app/src/views/strip.rs b/crates/app/src/views/strip.rs index 54dc82a..eea0b48 100644 --- a/crates/app/src/views/strip.rs +++ b/crates/app/src/views/strip.rs @@ -24,7 +24,7 @@ use crate::input_source::input_source; use crate::paths; use crate::state::{SpeakerState, Status}; use crate::status_leds::{status_leds, ConnectionLed}; -use crate::strip_layout::{LayoutChange, StripLayout}; +use crate::strip_layout::{Area, LayoutChange, StripLayout}; use crate::texts; use crate::ui_kit::theme::Tokens; use crate::writes::{self, Intent}; @@ -47,6 +47,9 @@ const METER_SPACING: f32 = 3.0; const CAPTION_HEIGHT: f32 = 16.0; const LEVEL_ENTRY_ID: &str = "level_entry"; const KNOBS_PER_ROW: usize = 2; +const DRAG_ID: &str = "strip_element_drag"; +const DROP_ID: &str = "strip_element_drop"; +const DROP_ZONE_HEIGHT: f32 = 8.0; pub struct StripResponse { /// The writes the user asked for that the write policy allowed. @@ -60,6 +63,43 @@ pub struct StripResponse { pub layout: Option, } +/// How one area is drawn: what it holds as stored, whether it is being edited, and what could +/// still be added (none before the speaker reported its model). +struct Arrange<'a> { + offers: Option<&'a [Offer]>, + editing: bool, + area: Area, + stored: &'a [StripElement], + catalog: &'a Catalog, +} + +impl Arrange<'_> { + /// Where `dropped` goes when let go on `target`: just before it, counted in the stored list + /// without `dropped`, so an element the speaker lacks keeps its place. + fn before(&self, dropped: &StripElement, target: &StripElement) -> LayoutChange { + let others: Vec<&StripElement> = self + .stored + .iter() + .filter(|element| *element != dropped) + .collect(); + let index = others + .iter() + .position(|element| *element == target) + .unwrap_or(others.len()); + LayoutChange::MoveTo(dropped.clone(), self.area, index) + } + + /// Where `dropped` goes when let go after the last element of the area. + fn at_end(&self, dropped: &StripElement) -> LayoutChange { + let others = self + .stored + .iter() + .filter(|element| *element != dropped) + .count(); + LayoutChange::MoveTo(dropped.clone(), self.area, others) + } +} + /// What the strip's areas asked for besides writes. #[derive(Default)] struct Wishes { @@ -67,15 +107,22 @@ struct Wishes { layout: Option, } +/// What the strip shows besides its fixed part, and whether it is being edited. +pub struct StripView<'a> { + pub layout: &'a StripLayout, + pub editing: bool, +} + pub fn show( ui: &mut Ui, speaker: &mut SpeakerState, catalog: &Catalog, desk: &DeskSettings, - layout: &StripLayout, + view: StripView, group_color: Option, member: &str, ) -> StripResponse { + let StripView { layout, editing } = view; let tokens = Tokens::current(ui.ctx()); let mut commands = Vec::new(); let mut wishes = Wishes::default(); @@ -85,6 +132,16 @@ pub fn show( .is_some() .then(|| layout_menu::offers(layout, speaker.product(), catalog)); let offers = offers.as_deref(); + let arrange = |area: Area| Arrange { + offers, + editing, + area, + stored: match area { + Area::Above => &layout.above, + Area::Below => &layout.below, + }, + catalog, + }; let inner_width = STRIP_WIDTH - 2.0 * (f32::from(STRIP_MARGIN) + BORDER_WIDTH); let frame = egui::Frame::new() .fill(tokens.card) @@ -109,30 +166,30 @@ pub fn show( }) .inner; leds(ui, speaker, &tokens); - let above = &layout.above; elements( ui, speaker, - catalog, - above, - offers, + &arrange(Area::Above), &mut commands, &mut wishes, ); - level(ui, speaker, catalog, desk, &mut commands, &tokens); + // While editing the fader is as still as the other controls. + ui.add_enabled_ui(!editing, |ui| { + level(ui, speaker, catalog, desk, &mut commands, &tokens); + }); if desk.show_response_times { response_time(ui, speaker); } - let below = &layout.below; elements( ui, speaker, - catalog, - below, - offers, + &arrange(Area::Below), &mut commands, &mut wishes, ); + if let (true, Some(offers)) = (editing, offers) { + edit_controls(ui, speaker.product(), offers, &mut wishes); + } message(ui, speaker, &tokens); open_panel }) @@ -214,14 +271,14 @@ fn led(ui: &mut Ui, color: Color32, text: &str) { fn elements( ui: &mut Ui, speaker: &mut SpeakerState, - catalog: &Catalog, - list: &[StripElement], - offers: Option<&[Offer]>, + arrange: &Arrange, commands: &mut Vec, wishes: &mut Wishes, ) { + let catalog = arrange.catalog; let entries = catalog.entries_for(speaker.product()); - let drawn: Vec = list + let drawn: Vec = arrange + .stored .iter() .filter_map(|element| match element { StripElement::Eq => has_eq(catalog, speaker).then_some(Drawn::Eq), @@ -235,11 +292,11 @@ fn elements( .collect(); for run in drawn.chunk_by(|left, right| left.row() == right.row()) { match run[0].row() { - Row::Knobs => knob_rows(ui, speaker, catalog, run, offers, commands, wishes), - Row::Buttons => button_row(ui, speaker, catalog, run, offers, commands, wishes), + Row::Knobs => knob_rows(ui, speaker, arrange, run, commands, wishes), + Row::Buttons => button_row(ui, speaker, arrange, run, commands, wishes), Row::Select | Row::Display => { for drawn in run { - with_menu(ui, drawn.element(), offers, wishes, |ui| { + element_cell(ui, drawn.element(), arrange, wishes, |ui| { match drawn.entry() { Some(entry) if entry.kind == Kind::Select => { select(ui, speaker, catalog, entry, commands); @@ -252,23 +309,100 @@ fn elements( } } } + if arrange.editing && arrange.offers.is_some() { + drop_zone(ui, arrange, wishes); + } } -/// Draws one element and gives it its context menu, when the model's layout can be changed. -fn with_menu( +/// Draws one element with its context menu. While editing it is a still control beside a ×, +/// dragged as a whole, and what is let go on it lands before it. +fn element_cell( ui: &mut Ui, element: StripElement, - offers: Option<&[Offer]>, + arrange: &Arrange, wishes: &mut Wishes, draw: impl FnOnce(&mut Ui), ) { - let rect = ui.scope(draw).response.rect; - let Some(offers) = offers else { + let Some(offers) = arrange.offers else { + ui.add_enabled_ui(!arrange.editing, draw); return; }; + let rect = match arrange.editing { + false => ui.scope(draw).response.rect, + true => editable_cell(ui, &element, arrange.catalog, wishes, draw), + }; if let Some(change) = layout_menu::element_menu(ui, rect, &element, offers) { wishes.layout = Some(change); } + if !arrange.editing { + return; + } + let drop_id = ui + .scope_id() + .with((DROP_ID, layout_menu::menu_key(&element))); + let target = ui.interact(rect, drop_id, Sense::hover()); + if let Some(dropped) = target.dnd_release_payload::() { + if *dropped != element { + wishes.layout = Some(arrange.before(&dropped, &element)); + } + } +} + +fn editable_cell( + ui: &mut Ui, + element: &StripElement, + catalog: &Catalog, + wishes: &mut Wishes, + draw: impl FnOnce(&mut Ui), +) -> Rect { + let drag_id = ui + .scope_id() + .with((DRAG_ID, layout_menu::menu_key(element))); + ui.horizontal(|ui| { + ui.dnd_drag_source(drag_id, element.clone(), |ui| { + ui.add_enabled_ui(false, draw); + }); + let hide_label = texts::hide_element(&layout_menu::element_label(element, catalog)); + let hide = ui.small_button(texts::HIDE_MARK); + hide.widget_info(|| WidgetInfo::labeled(Role::Button, true, &hide_label)); + if hide.on_hover_text(&hide_label).clicked() { + wishes.layout = Some(LayoutChange::Remove(element.clone())); + } + }) + .response + .rect +} + +/// A thin target after the last element of an area, so an empty area can take one too. +fn drop_zone(ui: &mut Ui, arrange: &Arrange, wishes: &mut Wishes) { + let size = vec2(ui.available_width(), DROP_ZONE_HEIGHT); + let (rect, response) = ui.allocate_exact_size(size, Sense::hover()); + if response.dnd_hover_payload::().is_some() { + let tokens = Tokens::current(ui.ctx()); + let radius = CornerRadius::same(STRIP_RADIUS); + ui.painter().rect_filled(rect, radius, tokens.border); + } + if let Some(dropped) = response.dnd_release_payload::() { + wishes.layout = Some(arrange.at_end(&dropped)); + } +} + +/// Under the strip while editing: whose layout it is, "+ Add" and "Default". +fn edit_controls(ui: &mut Ui, model: &str, offers: &[Offer], wishes: &mut Wishes) { + ui.label(RichText::new(texts::applies_to_every(model)).small().weak()); + ui.horizontal_wrapped(|ui| { + ui.menu_button(texts::ADD_ELEMENT, |ui| { + for offer in offers { + if ui.button(&offer.label).clicked() { + let added = LayoutChange::Add(offer.element.clone(), Area::Below); + wishes.layout = Some(added); + } + } + }); + if ui.button(texts::DEFAULT_LAYOUT).clicked() { + wishes.layout = Some(LayoutChange::Reset); + } + }); } #[derive(Clone, Copy)] @@ -316,17 +450,17 @@ impl<'a> Drawn<'a> { fn knob_rows( ui: &mut Ui, speaker: &mut SpeakerState, - catalog: &Catalog, + arrange: &Arrange, run: &[Drawn], - offers: Option<&[Offer]>, commands: &mut Vec, wishes: &mut Wishes, ) { + let catalog = arrange.catalog; for pair in run.chunks(KNOBS_PER_ROW) { ui.columns(KNOBS_PER_ROW, |columns| { for (column, entry) in columns.iter_mut().zip(pair.iter().filter_map(Drawn::entry)) { let element = StripElement::Path(entry.path.clone()); - with_menu(column, element, offers, wishes, |ui| { + element_cell(column, element, arrange, wishes, |ui| { // Named as in the device panel, so one setting has one name. let label = entry.label(texts::language()); let sent = knob::for_entry( @@ -348,16 +482,16 @@ fn knob_rows( fn button_row( ui: &mut Ui, speaker: &mut SpeakerState, - catalog: &Catalog, + arrange: &Arrange, run: &[Drawn], - offers: Option<&[Offer]>, commands: &mut Vec, wishes: &mut Wishes, ) { + let catalog = arrange.catalog; let mut open_eq = false; ui.horizontal_wrapped(|ui| { for drawn in run { - with_menu(ui, drawn.element(), offers, wishes, |ui| match drawn { + element_cell(ui, drawn.element(), arrange, wishes, |ui| match drawn { Drawn::Eq => open_eq |= ui.button(texts::EQ).clicked(), Drawn::Entry(entry) => { let text = button_text(entry); diff --git a/crates/app/src/views/toolbar.rs b/crates/app/src/views/toolbar.rs index a34fdb3..5477a1e 100644 --- a/crates/app/src/views/toolbar.rs +++ b/crates/app/src/views/toolbar.rs @@ -5,11 +5,18 @@ use std::time::Duration; -use eframe::egui::{self, containers::Sides, Button, RichText, TextWrapMode, Ui}; +use eframe::egui::{ + self, containers::Sides, Button, RichText, Role, TextStyle, TextWrapMode, Ui, WidgetInfo, + WidgetText, +}; use crate::texts; const PANEL_ID: &str = "toolbar"; +/// A mark needs less room around it than a word. +const EDIT_BUTTON_PADDING: f32 = 2.0; +/// A separator with the item spacing on both sides. +const SEPARATOR_ROOM: f32 = 16.0; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ToolbarAction { @@ -20,10 +27,13 @@ pub enum ToolbarAction { Settings, History, ToggleCompact, + ToggleEditing, } pub struct ToolbarFacts { pub compact: bool, + /// Whether the strips are being customized. + pub editing: bool, pub scanning: bool, pub speaker_count: usize, pub notice: Option, @@ -55,9 +65,17 @@ fn left_side(ui: &mut Ui, facts: &ToolbarFacts) -> ToolbarAction { let mut action = ToolbarAction::None; if !facts.compact { action = adding(ui, facts); - ui.separator(); } - ui.label(texts::speaker_count(facts.speaker_count)); + // Cut to "…" the count would still run under the right side's buttons in the narrowest + // window; there it is left out, the strips show how many there are. + let count = texts::speaker_count(facts.speaker_count); + let needed = text_width(ui, &count) + SEPARATOR_ROOM; + if facts.compact || needed <= ui.available_width() { + if !facts.compact { + ui.separator(); + } + ui.label(count); + } if let Some(notice) = facts.notice { ui.separator(); ui.colored_label(ui.visuals().error_fg_color, notice); @@ -65,6 +83,18 @@ fn left_side(ui: &mut Ui, facts: &ToolbarFacts) -> ToolbarAction { action } +fn text_width(ui: &Ui, text: &str) -> f32 { + WidgetText::from(text) + .into_galley( + ui, + Some(TextWrapMode::Extend), + f32::INFINITY, + TextStyle::Body, + ) + .size() + .x +} + fn right_side(ui: &mut Ui, facts: &ToolbarFacts) -> ToolbarAction { let mut action = ToolbarAction::None; if ui.button(texts::SETTINGS).clicked() { @@ -77,6 +107,9 @@ fn right_side(ui: &mut Ui, facts: &ToolbarFacts) -> ToolbarAction { if ui.add(compact).on_hover_text(texts::COMPACT_HINT).clicked() { action = ToolbarAction::ToggleCompact; } + if !facts.compact && edit_button(ui, facts.editing) { + action = ToolbarAction::ToggleEditing; + } if let Some(time) = facts.average_response_time { let text = texts::average_response_time(time.as_millis()); ui.label(RichText::new(text).monospace().weak()); @@ -104,3 +137,20 @@ fn adding(ui: &mut Ui, facts: &ToolbarFacts) -> ToolbarAction { } action } + +/// "Züge anpassen" / "Fertig" as a mark, not a word: the narrowest window has no room for one +/// more labelled button. +fn edit_button(ui: &mut Ui, editing: bool) -> bool { + let name = match editing { + true => texts::DONE.get(), + false => texts::CUSTOMIZE_STRIPS.get(), + }; + let edit = ui + .scope(|ui| { + ui.spacing_mut().button_padding.x = EDIT_BUTTON_PADDING; + ui.add(Button::new(texts::EDIT_MARK).selected(editing)) + }) + .inner; + edit.widget_info(|| WidgetInfo::selected(Role::Button, true, editing, name)); + edit.on_hover_text(name).clicked() +} diff --git a/crates/app/tests/compact.rs b/crates/app/tests/compact.rs index b31f37f..254e934 100644 --- a/crates/app/tests/compact.rs +++ b/crates/app/tests/compact.rs @@ -143,6 +143,7 @@ fn the_toolbar_does_not_overlap_itself_in_the_narrowest_window() { texts::SCAN.get(), texts::ADD_ADDRESS.get(), texts::ADD_GROUP.get(), + texts::CUSTOMIZE_STRIPS.get(), texts::COMPACT.get(), texts::SETTINGS.get(), ]; diff --git a/crates/app/tests/strip_layout.rs b/crates/app/tests/strip_layout.rs index 4d010a9..0a4983e 100644 --- a/crates/app/tests/strip_layout.rs +++ b/crates/app/tests/strip_layout.rs @@ -232,10 +232,7 @@ fn the_menu_writes_nothing() { right_click_button(&mut harness, texts::MUTE.get()); choose(&mut harness, texts::HIDE.get()); barrier(&simulators[0]); - let written = simulators[0].requests().iter().any(|request| { - request.to_string().contains("true") || request.to_string().contains("false") - }); - assert!(!written, "a set reached the speaker"); + assert!(!any_set(&simulators[0]), "a set reached the speaker"); } fn start_editing(harness: &mut Harness<'static, NeumannApp>) { @@ -244,7 +241,9 @@ fn start_editing(harness: &mut Harness<'static, NeumannApp>) { } fn hide_in_edit_mode(harness: &mut Harness<'static, NeumannApp>, path: &str) { - harness.get_by_label(&texts::hide_element(label(path))).click(); + harness + .get_by_label(&texts::hide_element(label(path))) + .click(); harness.run_steps(2); } @@ -281,15 +280,18 @@ fn wheel(harness: &mut Harness<'static, NeumannApp>, notches: f32) { }); } -/// Whether any request carried a value: reads ask with `null` only. +/// Whether any request carried a value: reads ask with `null` only, `osc` holds the id. fn any_set(simulator: &neumann_control_core::simulator::Simulator) -> bool { - simulator.requests().iter().any(|request| { - let text = request.to_string(); - let leaves = text.matches(':').count(); - let nulls = text.matches("null").count(); - let xids = text.matches("\"xid\"").count(); - leaves > nulls + xids - }) + fn carries_a_value(value: &serde_json::Value) -> bool { + match value { + serde_json::Value::Null => false, + serde_json::Value::Object(members) => members + .iter() + .any(|(key, member)| key != "osc" && carries_a_value(member)), + _ => true, + } + } + simulator.requests().iter().any(carries_a_value) } #[test] @@ -301,7 +303,9 @@ fn editing_hides_and_adds() { harness.get_by_label(texts::DONE.get()).click(); harness.run_steps(2); let phase = label(paths::PHASE_INVERSION); - assert!(harness.query_by_role_and_label(Role::Button, phase).is_some()); + assert!(harness + .query_by_role_and_label(Role::Button, phase) + .is_some()); let identify = harness.query_by_role_and_label(Role::Button, texts::IDENTIFY.get()); assert!(identify.is_none()); assert!(harness.query_by_label(texts::ADD_ELEMENT.get()).is_none()); @@ -325,7 +329,10 @@ fn editing_sends_nothing() { let fader = center(&harness, Role::Slider, texts::LEVEL.get()); drag(&mut harness, fader, fader + egui::vec2(0.0, 40.0)); barrier(&simulators[0]); - assert!(!any_set(&simulators[0]), "a set reached the speaker in edit mode"); + assert!( + !any_set(&simulators[0]), + "a set reached the speaker in edit mode" + ); harness.get_by_label(texts::DONE.get()).click(); harness.run_steps(2); harness @@ -334,6 +341,7 @@ fn editing_sends_nothing() { wait_until(&mut harness, "the mute after editing", |_| { !sets(&simulators[0], "/audio/out/mute").is_empty() }); + assert!(any_set(&simulators[0]), "the check itself sees a set"); } #[test] @@ -373,8 +381,7 @@ fn a_drag_next_to_an_absent_element_keeps_the_stored_order() { #[test] fn an_unreachable_strip_can_still_be_edited() { - let (mut simulators, directory, mut harness) = - desk_of(&[SimModel::Kh120Ii], BTreeMap::new()); + let (mut simulators, directory, mut harness) = desk_of(&[SimModel::Kh120Ii], BTreeMap::new()); simulators.remove(0).stop(); wait_for_label(&mut harness, texts::UNREACHABLE.get()); start_editing(&mut harness); @@ -413,10 +420,14 @@ fn everything_hidden_keeps_the_way_back() { let stored = layouts(MONITOR, &[], &[]); let (_simulators, _directory, mut harness) = desk_of(&[SimModel::Kh120Ii], stored); let mute = texts::MUTE.get(); - assert!(harness.query_by_role_and_label(Role::Button, mute).is_none()); + assert!(harness + .query_by_role_and_label(Role::Button, mute) + .is_none()); start_editing(&mut harness); add_in_edit_mode(&mut harness, label(paths::MUTE)); - assert!(harness.query_by_role_and_label(Role::Button, mute).is_some()); + assert!(harness + .query_by_role_and_label(Role::Button, mute) + .is_some()); } #[test] diff --git a/crates/app/tests/support/mod.rs b/crates/app/tests/support/mod.rs index bc9dfee..e5a3ae7 100644 --- a/crates/app/tests/support/mod.rs +++ b/crates/app/tests/support/mod.rs @@ -465,7 +465,11 @@ pub fn desk_of(models: &[SimModel], strip_layouts: BTreeMap, tempfile::TempDir, Harness<'static, NeumannApp>); +pub type Desk = ( + Vec, + tempfile::TempDir, + Harness<'static, NeumannApp>, +); /// Like `desk_of`, with the simulators changed before the app first reads them. pub fn desk_prepared( From c63dd67970020e75e731a9bd806dabd717c5c7a9 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:56:18 +0200 Subject: [PATCH 224/460] Document customizable strips --- CHANGELOG.md | 3 +++ README.md | 22 ++++++++++++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d70ba1..8976ac0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,9 @@ filters are converted between the files' Q and the speakers' slope. - **Protect crossovers** in the settings (on by default); off, "Make flat", "Reset", EQ presets and the import work on the KH 750 crossovers too, with a warning. +- Strips you can customize per model: hide, swap and move what a strip shows besides its fader, + put any setting of the device panel on it, from a context menu or in an edit mode (**↕** in the + toolbar). Mute and identify are listed in the device panel too. ### Changed diff --git a/README.md b/README.md index 3615c25..1c34a5c 100644 --- a/README.md +++ b/README.md @@ -115,19 +115,33 @@ Each speaker has a channel strip: - its name (click it to open the device panel) and a grip to drag the strip elsewhere, - four LEDs: connection, clip, device warning, and LOCAL (the rear CONTROL switch locks writing), -- knobs for **Delay** (shown in ms) and **Standby time**, +- by default knobs for **Delay** (shown in ms) and **Standby time**, - the output level fader (0–120 dB SPL in 0.5 dB steps) with its value below, - input meters for channels A and B with clip lamps; the KH 750 also shows its output meters A, B and Sub, captioned "Out". A thin line marks each meter's peak of the last 1.5 seconds, and the number under the channel shows it in whole dB ("–" below the meter range), -- the response time, **Mute**, **Ident.** (identify) and **EQ**, which opens the speaker's EQ - window once the speaker has reported its model. +- the response time, and by default **Mute**, **Ident.** (identify) and **EQ**, which opens the + speaker's EQ window once the speaker has reported its model. + +What a strip shows besides its name, LEDs, fader, meters and message is set per model, for every +speaker of that model at once: + +- Right-click a knob or button on a strip to **Hide** it, **Swap for …** another setting, or move it + up or down (past the fader too). Right-click a row in the device panel to put it on the strip + (**Add to strip**) or take it off. +- **↕** in the toolbar (**Customize strips**) switches every strip into edit mode: **×** hides an + element, **+ Add** adds one, dragging an element moves it (also across the fader), and + **Default** brings back the strip above. The controls write nothing while you edit; **↕** again + (**Done**) ends it. +- Any number, switch, choice or read-only value of the device panel can go on a strip; the + device name and lists such as the input mix stay in the panel. On the KH 120 II the input meters are captioned with the rear input switch (Analog, AES3 L, AES3 R, Mono), and a dot marks the channels the speaker plays. That channel A is the left AES3 channel is assumed, not measured. -The toolbar shows the number of speakers and the average response time of the connected ones. +The toolbar shows the number of speakers (not in the narrowest window) and the average response +time of the connected ones. ### Faders and knobs From 9f1d78cafab0a76ac6fc5baac2ddf3df89659938 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:58:30 +0200 Subject: [PATCH 225/460] Say that the narrow toolbar leaves the count out --- crates/app/src/views/toolbar.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/app/src/views/toolbar.rs b/crates/app/src/views/toolbar.rs index 5477a1e..4444123 100644 --- a/crates/app/src/views/toolbar.rs +++ b/crates/app/src/views/toolbar.rs @@ -47,8 +47,8 @@ pub fn show(ui: &mut Ui, facts: &ToolbarFacts) -> ToolbarAction { .inner } -/// The right side is placed first, so in a narrow window the count and a notice on the left are -/// cut (whole on hover) instead of being drawn under the buttons. +/// The right side is placed first, so in a narrow window a notice on the left is cut (whole on +/// hover) and the count left out instead of being drawn under the buttons. fn controls(ui: &mut Ui, facts: &ToolbarFacts) -> ToolbarAction { let (left, right) = Sides::new() .shrink_left() From 43949f9f47f1abd9f0d21429fd57caf6872ab940 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:41:32 +0200 Subject: [PATCH 226/460] Test that the app looks for moved speakers by itself --- crates/app/src/lib.rs | 1 + crates/app/src/rediscovery.rs | 76 +++++++++++++++++++++++++++++++++ crates/app/tests/speakers.rs | 37 ++++++++++++++++ crates/app/tests/support/mod.rs | 29 +++++++++++-- 4 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 crates/app/src/rediscovery.rs diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index 7bf626d..a960cf3 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -13,6 +13,7 @@ pub mod input_source; pub mod meter; pub mod paths; pub mod presets; +pub mod rediscovery; pub mod scan_choice; pub mod scan_report; pub mod spawner; diff --git a/crates/app/src/rediscovery.rs b/crates/app/src/rediscovery.rs new file mode 100644 index 0000000..d0f6a41 --- /dev/null +++ b/crates/app/src/rediscovery.rs @@ -0,0 +1,76 @@ +//! When the app looks for its speakers again without being asked: a link-local address keeps the +//! interface index of the computer, and Windows may give the interface another one after a +//! restart. A silent scan finds a remembered speaker by its serial at the new address. + +/// How long a remembered speaker stays unreachable before the app looks for it. +pub const UNREACHABLE_GRACE_SECONDS: f64 = 30.0; +/// The least time between two scans for speakers that stay unreachable. +pub const REPEAT_SECONDS: f64 = 300.0; + +#[derive(Debug, Default)] +pub struct Rediscovery { + started: bool, + last: Option, +} + +impl Rediscovery { + /// Whether a silent scan should start now. `followable`: some remembered speaker has a serial + /// a scan could find it by; `unreachable_for`: how long the longest-missing one has been + /// unreachable; `scanning`: a scan runs already. + pub fn due( + &mut self, + now: f64, + followable: bool, + unreachable_for: Option, + scanning: bool, + ) -> bool { + todo!() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_start_scans_once_for_remembered_speakers() { + let mut rediscovery = Rediscovery::default(); + assert!(rediscovery.due(0.0, true, None, false)); + assert!(!rediscovery.due(1.0, true, None, false)); + } + + #[test] + fn nothing_to_follow_means_no_scan() { + let mut rediscovery = Rediscovery::default(); + assert!(!rediscovery.due(0.0, false, None, false)); + assert!(!rediscovery.due(100.0, false, Some(100.0), false)); + } + + #[test] + fn an_unreachable_speaker_is_looked_for_after_the_grace() { + let mut rediscovery = Rediscovery::default(); + rediscovery.due(0.0, true, None, false); + let almost = UNREACHABLE_GRACE_SECONDS - 0.5; + assert!(!rediscovery.due(10.0, true, Some(almost), false)); + assert!(rediscovery.due(10.5, true, Some(UNREACHABLE_GRACE_SECONDS), false)); + } + + #[test] + fn a_speaker_that_stays_away_is_looked_for_at_most_every_repeat() { + let mut rediscovery = Rediscovery::default(); + rediscovery.due(0.0, true, None, false); + assert!(rediscovery.due(40.0, true, Some(40.0), false)); + let soon = 40.0 + REPEAT_SECONDS - 1.0; + assert!(!rediscovery.due(soon, true, Some(soon), false)); + let later = 40.0 + REPEAT_SECONDS; + assert!(rediscovery.due(later, true, Some(later), false)); + } + + #[test] + fn never_while_a_scan_runs() { + let mut rediscovery = Rediscovery::default(); + assert!(!rediscovery.due(0.0, true, None, true)); + assert!(rediscovery.due(1.0, true, None, false), "the start scan comes after"); + assert!(!rediscovery.due(50.0, true, Some(50.0), true)); + } +} diff --git a/crates/app/tests/speakers.rs b/crates/app/tests/speakers.rs index 31c97c3..54fd1f2 100644 --- a/crates/app/tests/speakers.rs +++ b/crates/app/tests/speakers.rs @@ -358,3 +358,40 @@ fn cancel_remove_keeps_speaker() { harness.get_by_label(&readout(100.0)); assert_eq!(settings::load(&path).manual.len(), 1); } + +#[test] +fn a_moved_speaker_is_found_at_the_start() { + let simulator = Simulator::start(SimModel::Kh120Ii); + let directory = tempfile::tempdir().unwrap(); + let stale = ManualSpeaker { + address: LOOPBACK.to_owned(), + port: SSC_PORT, + serial: Some(SIMULATED_SERIAL.to_owned()), + }; + let path = settings_file(directory.path(), vec![stale]); + let mut harness = harness(path.clone(), vec![kh120_found(&simulator)]); + wait_for_level(&mut harness, 100.0); + assert!(saved_at(&path, &simulator)); + assert!(harness.query_by_label(texts::ALREADY_ADDED.get()).is_none(), "no dialog"); +} + +#[test] +fn a_speaker_that_stays_unreachable_is_looked_for_again() { + let first = Simulator::start(SimModel::Kh120Ii); + let directory = tempfile::tempdir().unwrap(); + let mut entry = manual_for(&first); + entry.serial = Some(SIMULATED_SERIAL.to_owned()); + let path = settings_file(directory.path(), vec![entry]); + let shared: SharedScan = std::sync::Arc::new(std::sync::Mutex::new(Ok(Vec::new()))); + let mut harness = harness_with_shared_scan(path.clone(), shared.clone()); + wait_for_level(&mut harness, 100.0); + first.stop(); + wait_for_label(&mut harness, texts::UNREACHABLE.get()); + let moved = Simulator::start(SimModel::Kh120Ii); + *shared.lock().unwrap() = Ok(vec![kh120_found(&moved)]); + // The last level stays shown while unreachable; the saved address and the status tell. + wait_until(&mut harness, "the new address", |_| saved_at(&path, &moved)); + wait_until(&mut harness, "the reconnect", |harness| { + harness.query_by_label(texts::UNREACHABLE.get()).is_none() + }); +} diff --git a/crates/app/tests/support/mod.rs b/crates/app/tests/support/mod.rs index e5a3ae7..9a6a1bf 100644 --- a/crates/app/tests/support/mod.rs +++ b/crates/app/tests/support/mod.rs @@ -46,8 +46,11 @@ const SERIAL_PATH: &str = "device/identity/serial"; /// acted before the connect-time read had arrived. pub const REPLY_DELAY: Duration = Duration::from_millis(5); +/// What every scan finds; a test may change it while the app runs. +pub type SharedScan = Arc>; + pub struct SimulatorSpawner { - pub scan_result: ScanResult, + pub scan_result: SharedScan, } impl SpeakerSpawner for SimulatorSpawner { @@ -57,7 +60,8 @@ impl SpeakerSpawner for SimulatorSpawner { fn scan(&self) -> Receiver { let (sender, receiver) = mpsc::channel(); - sender.send(self.scan_result.clone()).unwrap(); + let result = self.scan_result.lock().unwrap().clone(); + sender.send(result).unwrap(); receiver } } @@ -114,6 +118,23 @@ fn build( scan_result: ScanResult, system: Language, audio: FakeAudio, +) -> Harness<'static, NeumannApp> { + build_shared(settings_path, Arc::new(Mutex::new(scan_result)), system, audio) +} + +/// The app with a scan result the test can change while it runs. +pub fn harness_with_shared_scan( + settings_path: PathBuf, + shared: SharedScan, +) -> Harness<'static, NeumannApp> { + build_shared(settings_path, shared, Language::German, FakeAudio::default()) +} + +fn build_shared( + settings_path: PathBuf, + shared: SharedScan, + system: Language, + audio: FakeAudio, ) -> Harness<'static, NeumannApp> { Harness::builder() .with_size(WINDOW_SIZE) @@ -121,7 +142,9 @@ fn build( let mut app = NeumannApp::new( &creation_context.egui_ctx, settings_path, - Box::new(SimulatorSpawner { scan_result }), + Box::new(SimulatorSpawner { + scan_result: shared, + }), Box::new(audio), system, ); From b83c99504a2a13a26179294f9804ca8b0fa011fb Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:45:04 +0200 Subject: [PATCH 227/460] Look for remembered speakers at the start and when they stay away A link-local address carries the computer's interface index, which Windows may change after a restart; a silent scan finds the speaker by its serial at the new address. --- crates/app/src/app.rs | 38 ++++++++++++++++++++++++++++++++- crates/app/src/rediscovery.rs | 33 ++++++++++++++++++++++++++-- crates/app/src/state.rs | 6 ++++++ crates/app/tests/speakers.rs | 5 ++++- crates/app/tests/support/mod.rs | 14 ++++++++++-- 5 files changed, 90 insertions(+), 6 deletions(-) diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index 66bd4cc..3590dcf 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -18,6 +18,7 @@ use neumann_control_core::speaker::{Command, Event, Events, SpeakerHandle, Targe use crate::file_dialog::{FileDialog, NativeFileDialog}; use crate::groups::{forget_member, member_id, rename_member, set_membership}; use crate::meter::MeterState; +use crate::rediscovery::Rediscovery; use crate::scan_choice::ScanChoice; use crate::spawner::{ScanResult, SpeakerSpawner}; use crate::state::{AllValuesState, SpeakerKey, SpeakerState, Status}; @@ -82,6 +83,9 @@ pub struct NeumannApp { /// Everything scans reported this session; names and models for the strips. found: Vec, scan: Option>, + /// The running scan was started by the app itself: it opens no dialog and reports no failure. + scan_silent: bool, + rediscovery: Rediscovery, scan_choice: Option, notice: Option, add_address: Option, @@ -148,6 +152,8 @@ impl NeumannApp { speakers: Vec::new(), found: Vec::new(), scan: None, + scan_silent: false, + rediscovery: Rediscovery::default(), scan_choice: None, notice: None, add_address: None, @@ -283,6 +289,31 @@ impl NeumannApp { } } + /// Starts a silent scan when the start or a speaker that stays unreachable calls for one; it + /// follows remembered speakers to their new address and adds none. + fn rediscover(&mut self, now: f64) { + let followable = self + .settings + .manual + .iter() + .any(|entry| entry.serial.is_some()); + let unreachable_for = self + .speakers + .iter() + .filter_map(|speaker| speaker.state.unreachable_since) + .map(|since| now - since) + .reduce(f64::max); + let scanning = self.scan.is_some(); + if self + .rediscovery + .due(now, followable, unreachable_for, scanning) + { + log::info!("looking for remembered speakers again"); + self.scan = Some(self.spawner.scan()); + self.scan_silent = true; + } + } + fn poll_scan(&mut self) { let Some(receiver) = &self.scan else { return; @@ -293,11 +324,14 @@ impl NeumannApp { Err(TryRecvError::Disconnected) => Err("the scan ended without a result".to_owned()), }; self.scan = None; + let silent = std::mem::take(&mut self.scan_silent); let found = match result { Ok(found) => merge_found(found), Err(reason) => { log::warn!("scan failed: {reason}"); - self.show_scan_failure(); + if !silent { + self.show_scan_failure(); + } return; } }; @@ -387,6 +421,7 @@ impl NeumannApp { ToolbarAction::Scan => { self.notice = None; self.scan = Some(self.spawner.scan()); + self.scan_silent = false; self.scan_choice = Some(ScanChoice::Scanning); } ToolbarAction::AddAddress => self.add_address = Some(AddAddressForm::default()), @@ -652,6 +687,7 @@ impl eframe::App for NeumannApp { let now = context.input(|input| input.time); self.drain_events(now); self.write_scan_reports(); + self.rediscover(now); self.poll_scan(); let pixels_per_point = context.pixels_per_point(); let geometry = context.input(|input| window_geometry(input.viewport(), pixels_per_point)); diff --git a/crates/app/src/rediscovery.rs b/crates/app/src/rediscovery.rs index d0f6a41..6419e21 100644 --- a/crates/app/src/rediscovery.rs +++ b/crates/app/src/rediscovery.rs @@ -24,7 +24,26 @@ impl Rediscovery { unreachable_for: Option, scanning: bool, ) -> bool { - todo!() + if scanning { + return false; + } + // The start counts once, also with nothing to follow yet: a serial learned later is no + // reason for a start scan. + let start = !self.started; + self.started = true; + if !followable { + return false; + } + if start { + return true; + } + let missing = unreachable_for.is_some_and(|seconds| seconds >= UNREACHABLE_GRACE_SECONDS); + let rested = self.last.is_none_or(|last| now - last >= REPEAT_SECONDS); + if !(missing && rested) { + return false; + } + self.last = Some(now); + true } } @@ -66,11 +85,21 @@ mod tests { assert!(rediscovery.due(later, true, Some(later), false)); } + #[test] + fn a_serial_learned_later_brings_no_start_scan() { + let mut rediscovery = Rediscovery::default(); + assert!(!rediscovery.due(0.0, false, None, false)); + assert!(!rediscovery.due(5.0, true, None, false)); + } + #[test] fn never_while_a_scan_runs() { let mut rediscovery = Rediscovery::default(); assert!(!rediscovery.due(0.0, true, None, true)); - assert!(rediscovery.due(1.0, true, None, false), "the start scan comes after"); + assert!( + rediscovery.due(1.0, true, None, false), + "the start scan comes after" + ); assert!(!rediscovery.due(50.0, true, Some(50.0), true)); } } diff --git a/crates/app/src/state.rs b/crates/app/src/state.rs index 6be8222..510f4ea 100644 --- a/crates/app/src/state.rs +++ b/crates/app/src/state.rs @@ -95,6 +95,8 @@ pub struct SpeakerState { pub values: BTreeMap, pub absent: BTreeSet, pub status: Status, + /// When the current outage began (the UI clock); `None` while connected or connecting. + pub unreachable_since: Option, pub message: Option, message_source: MessageSource, pub input_meters: Vec, @@ -136,6 +138,7 @@ impl SpeakerState { values: BTreeMap::new(), absent: BTreeSet::new(), status: Status::Connecting, + unreachable_since: None, message: None, message_source: MessageSource::Connection, input_meters: Vec::new(), @@ -254,11 +257,13 @@ impl SpeakerState { } self.identity = Some(identity); self.status = Status::Connected; + self.unreachable_since = None; self.set_message(None, MessageSource::Connection); } // The masked reason is for the log; the strip only shows the generic text. Event::Unreachable(_) => { self.status = Status::Unreachable; + self.unreachable_since.get_or_insert(now); self.set_message(Some(Message::Unreachable), MessageSource::Connection); self.reset_meters(); self.response_time = None; @@ -298,6 +303,7 @@ impl SpeakerState { } Event::SerialMismatch { found, .. } => { self.status = Status::Unreachable; + self.unreachable_since.get_or_insert(now); self.response_time = None; self.pending.clear(); let message = Message::SerialMismatch(mask_serial(&found)); diff --git a/crates/app/tests/speakers.rs b/crates/app/tests/speakers.rs index 54fd1f2..c01b91f 100644 --- a/crates/app/tests/speakers.rs +++ b/crates/app/tests/speakers.rs @@ -372,7 +372,10 @@ fn a_moved_speaker_is_found_at_the_start() { let mut harness = harness(path.clone(), vec![kh120_found(&simulator)]); wait_for_level(&mut harness, 100.0); assert!(saved_at(&path, &simulator)); - assert!(harness.query_by_label(texts::ALREADY_ADDED.get()).is_none(), "no dialog"); + assert!( + harness.query_by_label(texts::ALREADY_ADDED.get()).is_none(), + "no dialog" + ); } #[test] diff --git a/crates/app/tests/support/mod.rs b/crates/app/tests/support/mod.rs index 9a6a1bf..83dd21b 100644 --- a/crates/app/tests/support/mod.rs +++ b/crates/app/tests/support/mod.rs @@ -119,7 +119,12 @@ fn build( system: Language, audio: FakeAudio, ) -> Harness<'static, NeumannApp> { - build_shared(settings_path, Arc::new(Mutex::new(scan_result)), system, audio) + build_shared( + settings_path, + Arc::new(Mutex::new(scan_result)), + system, + audio, + ) } /// The app with a scan result the test can change while it runs. @@ -127,7 +132,12 @@ pub fn harness_with_shared_scan( settings_path: PathBuf, shared: SharedScan, ) -> Harness<'static, NeumannApp> { - build_shared(settings_path, shared, Language::German, FakeAudio::default()) + build_shared( + settings_path, + shared, + Language::German, + FakeAudio::default(), + ) } fn build_shared( From 211c3a67a22198264412f5cf19c1dae417a3e1bf Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:47:05 +0200 Subject: [PATCH 228/460] Document the automatic search for moved speakers --- CHANGELOG.md | 3 +++ README.md | 9 ++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8976ac0..e6813d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,9 @@ - The language follows the Windows display language unless one is chosen in the settings. - Standby time and standby level can be set on the KH 750 too. - Wider meters; the held peak is a thin line instead of a lit segment. +- Remembered speakers are looked for without asking: a silent scan at the start and when one stays + unreachable for 30 seconds (then at most every 5 minutes) follows them, by serial, to a changed + address or interface index. ### Fixed diff --git a/README.md b/README.md index 1c34a5c..8b57a34 100644 --- a/README.md +++ b/README.md @@ -102,9 +102,12 @@ delete the folder. or with `netsh interface ipv6 show interfaces` (column `Idx`). -Added speakers are remembered and reconnected at every start without a scan. When a later scan -finds a remembered speaker at a different address (link-local addresses and interface indexes can -change), the stored address is updated. IPv4 addresses are not accepted: the speakers are +Added speakers are remembered and reconnected at every start. When a scan finds a remembered +speaker at a different address (link-local addresses and interface indexes can change), the +stored address is updated. The app scans for that by itself, without a dialog: once at the start, +and when a remembered speaker stays unreachable for 30 seconds, then at most every 5 minutes +while one is missing. Speakers are matched by their serial; one added by address that never +connected has none yet and is not followed. IPv4 addresses are not accepted: the speakers are reachable only over IPv6. ## Using the app From c3c826c8391d8a43f1d542c3cfb39f07330b828b Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:03:33 +0200 Subject: [PATCH 229/460] Test that the strip's button row keeps one line --- crates/app/tests/strip_layout.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/app/tests/strip_layout.rs b/crates/app/tests/strip_layout.rs index 0a4983e..e13fd1d 100644 --- a/crates/app/tests/strip_layout.rs +++ b/crates/app/tests/strip_layout.rs @@ -436,3 +436,24 @@ fn the_hint_names_the_model() { start_editing(&mut harness); harness.get_by_label(&texts::applies_to_every(MONITOR)); } + +#[test] +fn the_button_row_keeps_its_size_and_its_words() { + let (_simulators, _directory, mut harness) = desk_of(&[SimModel::Kh120Ii], BTreeMap::new()); + let buttons = [texts::MUTE.get(), texts::IDENTIFY.get(), texts::EQ.get()]; + let rects = |harness: &Harness<'static, NeumannApp>| -> Vec { + buttons + .iter() + .map(|label| harness.get_by_role_and_label(Role::Button, label).rect()) + .collect() + }; + let first = rects(&harness); + for _ in 0..6 { + harness.step(); + assert_eq!(rects(&harness), first, "the buttons moved between frames"); + } + let one_line = first[0].height(); + for (label, rect) in buttons.iter().zip(&first) { + assert_eq!(rect.height(), one_line, "{label} wraps its text"); + } +} From 27de24691a8d4829613d80cb5c579103513c1ab6 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:05:25 +0200 Subject: [PATCH 230/460] Keep the strip's buttons in their row Each button sat in a child area of its own, which got only the rest of the line: the button wrapped its text instead of the row moving it to the next line. --- crates/app/src/views/compact.rs | 1 + crates/app/src/views/strip.rs | 90 ++++++++++++++++++++++++++------ crates/app/tests/strip_layout.rs | 13 +++-- 3 files changed, 80 insertions(+), 24 deletions(-) diff --git a/crates/app/src/views/compact.rs b/crates/app/src/views/compact.rs index 075e576..9d77d99 100644 --- a/crates/app/src/views/compact.rs +++ b/crates/app/src/views/compact.rs @@ -87,6 +87,7 @@ pub fn speaker( &mut commands, Switch::Mute.path(), mute, + true, ); if let Some(message) = &speaker.message { message_mark(ui, &texts::describe_message(message), &tokens); diff --git a/crates/app/src/views/strip.rs b/crates/app/src/views/strip.rs index eea0b48..e337e66 100644 --- a/crates/app/src/views/strip.rs +++ b/crates/app/src/views/strip.rs @@ -4,7 +4,7 @@ use eframe::egui::{ self, vec2, Align, Button, Color32, ComboBox, CornerRadius, Label, Layout, Pos2, Rect, - RichText, Role, Sense, Stroke, Ui, Vec2, WidgetInfo, + Response, RichText, Role, Sense, Stroke, Ui, Vec2, WidgetInfo, }; use neumann_control_core::catalog::{Catalog, Entry, Kind}; use neumann_control_core::settings::{DeskSettings, StripElement}; @@ -331,6 +331,43 @@ fn element_cell( false => ui.scope(draw).response.rect, true => editable_cell(ui, &element, arrange.catalog, wishes, draw), }; + arrange_at(ui, rect, element, offers, arrange, wishes); +} + +/// Like `element_cell` for a button in a wrapped row. It gets no child area of its own: one would +/// only get the rest of the line and wrap the button's text instead of the row. `draw` is told +/// whether the button may be used and returns its response. +fn inline_cell( + ui: &mut Ui, + element: StripElement, + arrange: &Arrange, + wishes: &mut Wishes, + draw: impl FnOnce(&mut Ui, bool) -> Response, +) { + let response = draw(ui, !arrange.editing); + let Some(offers) = arrange.offers else { + return; + }; + if arrange.editing { + let drag_id = ui + .scope_id() + .with((DRAG_ID, layout_menu::menu_key(&element))); + let handle = ui.interact(response.rect, drag_id, Sense::drag()); + handle.dnd_set_drag_payload(element.clone()); + hide_button(ui, &element, arrange.catalog, wishes); + } + arrange_at(ui, response.rect, element, offers, arrange, wishes); +} + +/// The context menu of an element drawn at `rect` and, while editing, the drop target on it. +fn arrange_at( + ui: &mut Ui, + rect: Rect, + element: StripElement, + offers: &[Offer], + arrange: &Arrange, + wishes: &mut Wishes, +) { if let Some(change) = layout_menu::element_menu(ui, rect, &element, offers) { wishes.layout = Some(change); } @@ -362,17 +399,22 @@ fn editable_cell( ui.dnd_drag_source(drag_id, element.clone(), |ui| { ui.add_enabled_ui(false, draw); }); - let hide_label = texts::hide_element(&layout_menu::element_label(element, catalog)); - let hide = ui.small_button(texts::HIDE_MARK); - hide.widget_info(|| WidgetInfo::labeled(Role::Button, true, &hide_label)); - if hide.on_hover_text(&hide_label).clicked() { - wishes.layout = Some(LayoutChange::Remove(element.clone())); - } + hide_button(ui, element, catalog, wishes); }) .response .rect } +/// The × beside an element while editing, named after the element. +fn hide_button(ui: &mut Ui, element: &StripElement, catalog: &Catalog, wishes: &mut Wishes) { + let hide_label = texts::hide_element(&layout_menu::element_label(element, catalog)); + let hide = ui.small_button(texts::HIDE_MARK); + hide.widget_info(|| WidgetInfo::labeled(Role::Button, true, &hide_label)); + if hide.on_hover_text(&hide_label).clicked() { + wishes.layout = Some(LayoutChange::Remove(element.clone())); + } +} + /// A thin target after the last element of an area, so an empty area can take one too. fn drop_zone(ui: &mut Ui, arrange: &Arrange, wishes: &mut Wishes) { let size = vec2(ui.available_width(), DROP_ZONE_HEIGHT); @@ -491,13 +533,24 @@ fn button_row( let mut open_eq = false; ui.horizontal_wrapped(|ui| { for drawn in run { - element_cell(ui, drawn.element(), arrange, wishes, |ui| match drawn { - Drawn::Eq => open_eq |= ui.button(texts::EQ).clicked(), - Drawn::Entry(entry) => { - let text = button_text(entry); - toggle(ui, speaker, catalog, commands, entry.path.clone(), text); - } - }); + inline_cell( + ui, + drawn.element(), + arrange, + wishes, + |ui, usable| match drawn { + Drawn::Eq => { + let eq = ui.add_enabled(usable, Button::new(texts::EQ)); + open_eq |= eq.clicked(); + eq + } + Drawn::Entry(entry) => { + let text = button_text(entry); + let path = entry.path.clone(); + toggle(ui, speaker, catalog, commands, path, text, usable) + } + }, + ); } }); wishes.open_eq |= open_eq; @@ -703,14 +756,17 @@ pub(super) fn toggle( commands: &mut Vec, path: SscPath, text: &str, -) { + usable: bool, +) -> Response { let current = speaker.values.get(&path).and_then(Value::as_bool); - let enabled = current.is_some() && writes::can_write(speaker, catalog, &path); + let enabled = usable && current.is_some() && writes::can_write(speaker, catalog, &path); let button = Button::new(text).selected(current.unwrap_or(false)); - if ui.add_enabled(enabled, button).clicked() { + let response = ui.add_enabled(enabled, button); + if response.clicked() { let now = ui.input(|input| input.time); commands.extend(speaker.request(Intent::Toggle(path), catalog, now)); } + response } fn message(ui: &mut Ui, speaker: &SpeakerState, tokens: &Tokens) { diff --git a/crates/app/tests/strip_layout.rs b/crates/app/tests/strip_layout.rs index e13fd1d..8c7ee7d 100644 --- a/crates/app/tests/strip_layout.rs +++ b/crates/app/tests/strip_layout.rs @@ -183,14 +183,13 @@ fn swap_keeps_the_place() { choose(&mut harness, texts::SWAP_FOR.get()); let phase = label(paths::PHASE_INVERSION); choose(&mut harness, phase); - let left = |label: &str| { - harness - .get_by_role_and_label(Role::Button, label) - .rect() - .left() + // In reading order: a wide button moves to the next line of the row. + let place = |label: &str| { + let rect = harness.get_by_role_and_label(Role::Button, label).rect(); + (rect.top() as i32, rect.left() as i32) }; - assert!(left(texts::MUTE.get()) < left(phase)); - assert!(left(phase) < left(texts::EQ.get())); + assert!(place(texts::MUTE.get()) < place(phase)); + assert!(place(phase) < place(texts::EQ.get())); } #[test] From c089c03548859c1fe9f465ef5a859771a902a515 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:10:52 +0200 Subject: [PATCH 231/460] =?UTF-8?q?Test=20that=20an=20edit-mode=20=C3=97?= =?UTF-8?q?=20covers=20no=20other=20control?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/app/tests/strip_layout.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/app/tests/strip_layout.rs b/crates/app/tests/strip_layout.rs index 8c7ee7d..754ea42 100644 --- a/crates/app/tests/strip_layout.rs +++ b/crates/app/tests/strip_layout.rs @@ -456,3 +456,21 @@ fn the_button_row_keeps_its_size_and_its_words() { assert_eq!(rect.height(), one_line, "{label} wraps its text"); } } + +#[test] +fn in_edit_mode_no_hide_mark_covers_another_control() { + let (_simulators, _directory, mut harness) = desk_of(&[SimModel::Kh120Ii], BTreeMap::new()); + start_editing(&mut harness); + let knobs = [paths::DELAY, paths::STANDBY_TIME]; + for own in knobs { + let mark = harness + .get_by_label(&texts::hide_element(label(own))) + .rect(); + for other in knobs.iter().filter(|other| **other != own) { + let knob = harness + .get_by_role_and_label(Role::Slider, label(other)) + .rect(); + assert!(!mark.intersects(knob), "the × of {own} lies on {other}"); + } + } +} From 78797c43b8276e6363a06326dd4ca4a50d9928c8 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:16:11 +0200 Subject: [PATCH 232/460] =?UTF-8?q?Put=20the=20edit-mode=20=C3=97=20in=20i?= =?UTF-8?q?ts=20element's=20corner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beside a knob it reached into the neighbouring column and covered the other knob. --- crates/app/src/views/strip.rs | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/crates/app/src/views/strip.rs b/crates/app/src/views/strip.rs index e337e66..8348074 100644 --- a/crates/app/src/views/strip.rs +++ b/crates/app/src/views/strip.rs @@ -50,6 +50,7 @@ const KNOBS_PER_ROW: usize = 2; const DRAG_ID: &str = "strip_element_drag"; const DROP_ID: &str = "strip_element_drop"; const DROP_ZONE_HEIGHT: f32 = 8.0; +const HIDE_MARK_SIZE: f32 = 16.0; pub struct StripResponse { /// The writes the user asked for that the write policy allowed. @@ -395,20 +396,29 @@ fn editable_cell( let drag_id = ui .scope_id() .with((DRAG_ID, layout_menu::menu_key(element))); - ui.horizontal(|ui| { - ui.dnd_drag_source(drag_id, element.clone(), |ui| { + let rect = ui + .dnd_drag_source(drag_id, element.clone(), |ui| { ui.add_enabled_ui(false, draw); - }); - hide_button(ui, element, catalog, wishes); - }) - .response - .rect + }) + .response + .rect; + // In the cell's corner: beside it, the × would reach into the neighbouring knob's column. + let size = Vec2::splat(HIDE_MARK_SIZE); + let corner = Rect::from_min_size(rect.right_top() - vec2(size.x, 0.0), size); + let hide = ui.put(corner, Button::new(texts::HIDE_MARK).small()); + on_hide(hide, element, catalog, wishes); + rect } -/// The × beside an element while editing, named after the element. +/// The × beside a button in a row while editing. fn hide_button(ui: &mut Ui, element: &StripElement, catalog: &Catalog, wishes: &mut Wishes) { - let hide_label = texts::hide_element(&layout_menu::element_label(element, catalog)); let hide = ui.small_button(texts::HIDE_MARK); + on_hide(hide, element, catalog, wishes); +} + +/// Names the × after its element and hides that element when it is clicked. +fn on_hide(hide: Response, element: &StripElement, catalog: &Catalog, wishes: &mut Wishes) { + let hide_label = texts::hide_element(&layout_menu::element_label(element, catalog)); hide.widget_info(|| WidgetInfo::labeled(Role::Button, true, &hide_label)); if hide.on_hover_text(&hide_label).clicked() { wishes.layout = Some(LayoutChange::Remove(element.clone())); From 33bea1cc64d857fe695459a738804449a555c468 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:31:21 +0200 Subject: [PATCH 233/460] =?UTF-8?q?Test=20that=20the=20edit-mode=20=C3=97?= =?UTF-8?q?=20covers=20no=20knob,=20its=20own=20included?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/app/tests/strip_layout.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/app/tests/strip_layout.rs b/crates/app/tests/strip_layout.rs index 754ea42..27b66b5 100644 --- a/crates/app/tests/strip_layout.rs +++ b/crates/app/tests/strip_layout.rs @@ -458,7 +458,7 @@ fn the_button_row_keeps_its_size_and_its_words() { } #[test] -fn in_edit_mode_no_hide_mark_covers_another_control() { +fn in_edit_mode_no_hide_mark_covers_a_knob() { let (_simulators, _directory, mut harness) = desk_of(&[SimModel::Kh120Ii], BTreeMap::new()); start_editing(&mut harness); let knobs = [paths::DELAY, paths::STANDBY_TIME]; @@ -466,11 +466,11 @@ fn in_edit_mode_no_hide_mark_covers_another_control() { let mark = harness .get_by_label(&texts::hide_element(label(own))) .rect(); - for other in knobs.iter().filter(|other| **other != own) { + for knob_path in knobs { let knob = harness - .get_by_role_and_label(Role::Slider, label(other)) + .get_by_role_and_label(Role::Slider, label(knob_path)) .rect(); - assert!(!mark.intersects(knob), "the × of {own} lies on {other}"); + assert!(!mark.intersects(knob), "the × of {own} lies on {knob_path}"); } } } From 957e1bf60bb02b28429e08e387678d0c63557b40 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:32:35 +0200 Subject: [PATCH 234/460] =?UTF-8?q?Put=20the=20edit-mode=20=C3=97=20under?= =?UTF-8?q?=20its=20element?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the corner it still covered the knob. --- crates/app/src/views/strip.rs | 21 +++++++++------------ crates/app/tests/strip_layout.rs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/crates/app/src/views/strip.rs b/crates/app/src/views/strip.rs index 8348074..8bc7a26 100644 --- a/crates/app/src/views/strip.rs +++ b/crates/app/src/views/strip.rs @@ -50,7 +50,6 @@ const KNOBS_PER_ROW: usize = 2; const DRAG_ID: &str = "strip_element_drag"; const DROP_ID: &str = "strip_element_drop"; const DROP_ZONE_HEIGHT: f32 = 8.0; -const HIDE_MARK_SIZE: f32 = 16.0; pub struct StripResponse { /// The writes the user asked for that the write policy allowed. @@ -396,18 +395,16 @@ fn editable_cell( let drag_id = ui .scope_id() .with((DRAG_ID, layout_menu::menu_key(element))); - let rect = ui - .dnd_drag_source(drag_id, element.clone(), |ui| { + // Under the element: beside it the × reached into the neighbouring knob's column, on it + // it covered the knob. + ui.vertical_centered(|ui| { + ui.dnd_drag_source(drag_id, element.clone(), |ui| { ui.add_enabled_ui(false, draw); - }) - .response - .rect; - // In the cell's corner: beside it, the × would reach into the neighbouring knob's column. - let size = Vec2::splat(HIDE_MARK_SIZE); - let corner = Rect::from_min_size(rect.right_top() - vec2(size.x, 0.0), size); - let hide = ui.put(corner, Button::new(texts::HIDE_MARK).small()); - on_hide(hide, element, catalog, wishes); - rect + }); + hide_button(ui, element, catalog, wishes); + }) + .response + .rect } /// The × beside a button in a row while editing. diff --git a/crates/app/tests/strip_layout.rs b/crates/app/tests/strip_layout.rs index 27b66b5..68d3ff6 100644 --- a/crates/app/tests/strip_layout.rs +++ b/crates/app/tests/strip_layout.rs @@ -474,3 +474,33 @@ fn in_edit_mode_no_hide_mark_covers_a_knob() { } } } + +/// The input meters' places while the levels change, in normal and in edit mode. +fn meters_while_levels_change(editing: bool) -> Vec> { + let (simulators, _directory, mut harness) = desk_of(&[SimModel::Kh120Ii], BTreeMap::new()); + if editing { + start_editing(&mut harness); + } + let mut places = Vec::new(); + for level in [-119.0, -60.0, -6.0, -100.0, -0.5, -45.0] { + simulators[0].set_value("m/in/level", json!([level, level - 3.0])); + harness.run_steps(4); + let meters: Vec = harness + .query_all_by_role(Role::ProgressIndicator) + .map(|node| node.rect()) + .collect(); + places.push(meters); + } + places +} + +#[test] +fn the_meters_stay_in_place_while_the_levels_change() { + for editing in [false, true] { + let places = meters_while_levels_change(editing); + assert!(!places[0].is_empty()); + for later in &places[1..] { + assert_eq!(*later, places[0], "editing {editing}"); + } + } +} From afd30637c504769bfaf0cef2214a54ae30e2fd26 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:43:04 +0200 Subject: [PATCH 235/460] Test that strips and meters hold still while values change --- crates/app/tests/strip_layout.rs | 63 ++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/crates/app/tests/strip_layout.rs b/crates/app/tests/strip_layout.rs index 68d3ff6..2f95128 100644 --- a/crates/app/tests/strip_layout.rs +++ b/crates/app/tests/strip_layout.rs @@ -504,3 +504,66 @@ fn the_meters_stay_in_place_while_the_levels_change() { } } } + +#[test] +fn no_strip_moves_while_values_change() { + let models = [ + SimModel::Kh120Ii, + SimModel::Kh750, + SimModel::Kh750, + SimModel::Kh120Ii, + ]; + let (simulators, _directory, mut harness) = desk_of(&models, BTreeMap::new()); + let places = |harness: &Harness<'static, NeumannApp>| -> Vec { + let names = [KH120_DEVICE_NAME, KH750_DEVICE_NAME] + .into_iter() + .flat_map(|name| harness.query_all_by_label(name).map(|node| node.rect())); + let faders = harness + .query_all_by_role_and_label(Role::Slider, texts::LEVEL.get()) + .map(|node| node.rect()); + names.chain(faders).collect() + }; + let first = places(&harness); + for (round, level) in [-119.0, -6.0, -60.0, -0.5, -100.0, -20.0] + .into_iter() + .enumerate() + { + for simulator in &simulators { + simulator.set_value("m/in/level", json!([level, level - 1.0])); + simulator.set_value("m/out/level", json!([level, level, level])); + } + harness.run_steps(4); + assert_eq!(places(&harness), first, "round {round}"); + } +} + +#[test] +fn no_strip_moves_in_a_window_narrower_than_the_desk() { + let models = [ + SimModel::Kh120Ii, + SimModel::Kh750, + SimModel::Kh750, + SimModel::Kh120Ii, + ]; + let (_simulators, _directory, mut harness) = desk_of(&models, BTreeMap::new()); + for (width, height) in [ + (700.0, 600.0), + (900.0, 700.0), + (1100.0, 500.0), + (600.0, 900.0), + ] { + harness.set_size(egui::vec2(width, height)); + harness.run_steps(4); + let places = |harness: &Harness<'static, NeumannApp>| -> Vec { + harness + .query_all_by_role_and_label(Role::Slider, texts::LEVEL.get()) + .map(|node| node.rect()) + .collect() + }; + let first = places(&harness); + for frame in 0..8 { + harness.step(); + assert_eq!(places(&harness), first, "{width}x{height}, frame {frame}"); + } + } +} From d69c7579ac59961853c902d3e634c69027045bce Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:15:12 +0200 Subject: [PATCH 236/460] Size meter columns for every peak readout Digits differ in width, so readouts such as -104 overflowed a column sized for -120 and pushed the meters and strips beside it for a moment. --- crates/app/src/views/meters.rs | 33 ++++++++++++++++++++------------ crates/app/tests/strip_layout.rs | 29 ++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 12 deletions(-) diff --git a/crates/app/src/views/meters.rs b/crates/app/src/views/meters.rs index 3d9cef6..ef28c17 100644 --- a/crates/app/src/views/meters.rs +++ b/crates/app/src/views/meters.rs @@ -30,8 +30,6 @@ const SCALE_MARKS_DB: [f64; 5] = [0.0, -6.0, -20.0, -60.0, -90.0]; const CHANNEL_NAME_HEIGHT: f32 = 12.0; const CHANNEL_NAME_SIZE: f32 = 9.0; const READOUT_HEIGHT: f32 = 12.0; -/// The widest readout, so a column keeps its width whatever the level. -const WIDEST_READOUT: Option = Some(-120); const PEAK_LINE_HEIGHT: f32 = 2.0; const PLAYED_MARK_SPACE: f32 = 8.0; const PLAYED_MARK_RADIUS: f32 = 2.0; @@ -58,6 +56,19 @@ pub fn show(ui: &mut Ui, group: &Meters, height: f32, floor: f64, tokens: &Token let (name_of_group, channels, meters) = (group.name, group.channels, group.levels); let now = ui.input(|input| input.time); let clips = group.clips.and_then(Value::as_array); + let font = FontId::proportional(CHANNEL_NAME_SIZE); + let painter = ui.painter().clone(); + let text_width = |text: String| { + painter + .layout_no_wrap(text, font.clone(), tokens.text_dim) + .size() + .x + }; + // Digits differ in width ("−104" is wider than "−120"), so a column keeps its width whatever + // the level only if it fits every readout the meter can show. + let readout_width = readouts(floor) + .map(|readout| text_width(texts::meter_readout(readout))) + .fold(0.0, f32::max); for (channel, meter) in meters.iter().enumerate() { let clipped = clips .and_then(|clips| clips.get(channel)) @@ -66,16 +77,7 @@ pub fn show(ui: &mut Ui, group: &Meters, height: f32, floor: f64, tokens: &Token // The channel's name goes below `height`, so the scale beside stays level with it; the // column is as wide as the name ("Sub"), with the meter in its middle. let name = texts::channel_name(channels, channel); - let font = FontId::proportional(CHANNEL_NAME_SIZE); - let text_width = |text: String| { - let painter = ui.painter(); - painter - .layout_no_wrap(text, font.clone(), tokens.text_dim) - .size() - .x - }; - let name_width = - text_width(name.clone()).max(text_width(texts::meter_readout(WIDEST_READOUT))); + let name_width = text_width(name.clone()).max(readout_width); let mark_space = if group.played.is_empty() { 0.0 } else { @@ -141,6 +143,13 @@ pub fn show(ui: &mut Ui, group: &Meters, height: f32, floor: f64, tokens: &Token covered } +/// Every readout a meter can show above `floor`, and none. +fn readouts(floor: f64) -> impl Iterator> { + let lowest = floor.round() as i32; + let highest = METER_CEILING_DB.round() as i32; + std::iter::once(None).chain((lowest..=highest).map(Some)) +} + /// The dot under a channel the speaker plays. fn played_mark(ui: &mut Ui, tokens: &Tokens) -> Response { let size = Vec2::splat(2.0 * PLAYED_MARK_RADIUS); diff --git a/crates/app/tests/strip_layout.rs b/crates/app/tests/strip_layout.rs index 2f95128..51db769 100644 --- a/crates/app/tests/strip_layout.rs +++ b/crates/app/tests/strip_layout.rs @@ -505,6 +505,35 @@ fn the_meters_stay_in_place_while_the_levels_change() { } } +/// Peak readouts wider than "−120" in the app's proportional font. +const WIDE_READOUTS: [i32; 6] = [-109, -108, -106, -104, -103, -100]; + +/// A wide peak readout must not widen its column and push the meters beside it. +#[test] +fn no_meter_moves_when_the_peak_readout_changes() { + let (simulators, _directory, mut harness) = desk_of(&[SimModel::Kh750], BTreeMap::new()); + let places = |harness: &Harness<'static, NeumannApp>| -> Vec { + harness + .query_all_by_role(Role::ProgressIndicator) + .map(|node| node.rect()) + .collect() + }; + simulators[0].set_value("m/in/level", json!([-119.0, -119.0])); + harness.run_steps(8); + let first = places(&harness); + assert!(!first.is_empty()); + // Rising, so the held peak and with it the readout takes each level at once. + for readout in WIDE_READOUTS { + let level = f64::from(readout); + simulators[0].set_value("m/in/level", json!([level, level])); + let shown = texts::meter_readout(Some(readout)); + wait_until(&mut harness, &shown, |harness| { + harness.query_all_by_label_contains(&shown).next().is_some() + }); + assert_eq!(places(&harness), first, "readout {shown}"); + } +} + #[test] fn no_strip_moves_while_values_change() { let models = [ From 65c4b807dbc4c2c889a7058013cb8cb00061509c Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:44:56 +0200 Subject: [PATCH 237/460] Draw falling meters 20 times a second Asking for the next frame at once redrew the window at the monitor's rate while any meter fell, which is all the time with an idle input's noise. --- CHANGELOG.md | 2 ++ crates/app/src/views/meters.rs | 7 ++++++- crates/app/tests/strip_layout.rs | 19 +++++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6813d0..df900b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,8 @@ - Remembered speakers are looked for without asking: a silent scan at the start and when one stays unreachable for 30 seconds (then at most every 5 minutes) follows them, by serial, to a changed address or interface index. +- Falling meters are drawn 20 times a second instead of at the monitor's rate, which kept the + graphics card busy while the window was idle. ### Fixed diff --git a/crates/app/src/views/meters.rs b/crates/app/src/views/meters.rs index ef28c17..4976f24 100644 --- a/crates/app/src/views/meters.rs +++ b/crates/app/src/views/meters.rs @@ -6,6 +6,8 @@ use eframe::egui::{ pos2, vec2, Align2, Color32, CornerRadius, FontId, Label, Rect, Response, RichText, Role, Sense, Stroke, Ui, Vec2, WidgetInfo, }; +use std::time::Duration; + use serde_json::Value; use crate::meter::{ @@ -36,6 +38,9 @@ const PLAYED_MARK_RADIUS: f32 = 2.0; const BAR_WIDTH: f32 = 72.0; const BAR_HEIGHT: f32 = 6.0; const BAR_RADIUS: u8 = 2; +/// How often a falling meter is drawn anew; the monitor's own rate would keep the GPU busy for +/// a movement the eye does not resolve finer. +const METER_FRAME: Duration = Duration::from_millis(50); /// One group of meters, side by side. pub struct Meters<'a> { @@ -134,7 +139,7 @@ pub fn show(ui: &mut Ui, group: &Meters, height: f32, floor: f64, tokens: &Token } } if meters.iter().any(|meter| meter.is_moving(now)) { - ui.ctx().request_repaint(); + ui.ctx().request_repaint_after(METER_FRAME); } // Before the first reading there are no meters; an empty area would be infinitely negative. if meters.is_empty() { diff --git a/crates/app/tests/strip_layout.rs b/crates/app/tests/strip_layout.rs index 51db769..3ced5e5 100644 --- a/crates/app/tests/strip_layout.rs +++ b/crates/app/tests/strip_layout.rs @@ -4,6 +4,7 @@ mod support; use std::collections::BTreeMap; +use std::time::Duration; use eframe::egui::{self, accesskit::Role}; use egui_kittest::kittest::{NodeT, Queryable}; @@ -534,6 +535,24 @@ fn no_meter_moves_when_the_peak_readout_changes() { } } +/// A falling meter asks for its next frame after a pause, not at once: at the monitor's own +/// rate the window kept the GPU busy all the time. +#[test] +fn a_falling_meter_does_not_repaint_at_once() { + let (simulators, _directory, mut harness) = desk_of(&[SimModel::Kh120Ii], BTreeMap::new()); + simulators[0].set_value("m/in/level", json!([-6.0, -6.0])); + let high = texts::meter_readout(Some(-6)); + wait_until(&mut harness, &high, |harness| { + harness.query_all_by_label_contains(&high).next().is_some() + }); + simulators[0].set_value("m/in/level", json!([-119.0, -119.0])); + // A new reading wakes the window at once; the frames of the fall after it must not. + wait_until(&mut harness, "a frame booked after a pause", |harness| { + let delay = harness.output().viewport_output[&egui::ViewportId::ROOT].repaint_delay; + !delay.is_zero() && delay < Duration::MAX + }); +} + #[test] fn no_strip_moves_while_values_change() { let models = [ From 9d437c52b795c4e4c41fb201ab61f8c51392263a Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:47:47 +0200 Subject: [PATCH 238/460] Speed up the tests and the mutation runs One binary for the window tests, shorter polling in them, nextest with a fail-fast profile, and the core's test feature switched on by the core itself. A mutation run no longer rebuilds for its test phase; a full run takes about four hours instead of twelve. --- .cargo/mutants.toml | 5 +++-- .config/nextest.toml | 4 ++++ Cargo.lock | 1 + README.md | 15 +++++++++++---- crates/app/tests/{ => app}/compact.rs | 4 +--- crates/app/tests/{ => app}/details.rs | 4 +--- crates/app/tests/{ => app}/eq.rs | 4 +--- crates/app/tests/{ => app}/glyphs.rs | 2 +- crates/app/tests/{ => app}/groups.rs | 4 +--- crates/app/tests/{ => app}/history.rs | 4 +--- crates/app/tests/app/main.rs | 17 +++++++++++++++++ crates/app/tests/{ => app}/speakers.rs | 6 ++---- crates/app/tests/{ => app}/strip_layout.rs | 4 +--- crates/app/tests/{ => app}/support/mod.rs | 15 ++++++++++++--- crates/app/tests/{ => app}/system_audio.rs | 4 +--- crates/app/tests/{ => app}/views.rs | 4 +--- crates/app/tests/{ => app}/write_gestures.rs | 4 +--- crates/app/tests/{ => app}/write_messages.rs | 0 crates/core/Cargo.toml | 3 +++ 19 files changed, 63 insertions(+), 41 deletions(-) create mode 100644 .config/nextest.toml rename crates/app/tests/{ => app}/compact.rs (99%) rename crates/app/tests/{ => app}/details.rs (99%) rename crates/app/tests/{ => app}/eq.rs (99%) rename crates/app/tests/{ => app}/glyphs.rs (98%) rename crates/app/tests/{ => app}/groups.rs (99%) rename crates/app/tests/{ => app}/history.rs (99%) create mode 100644 crates/app/tests/app/main.rs rename crates/app/tests/{ => app}/speakers.rs (99%) rename crates/app/tests/{ => app}/strip_layout.rs (99%) rename crates/app/tests/{ => app}/support/mod.rs (97%) rename crates/app/tests/{ => app}/system_audio.rs (99%) rename crates/app/tests/{ => app}/views.rs (99%) rename crates/app/tests/{ => app}/write_gestures.rs (99%) rename crates/app/tests/{ => app}/write_messages.rs (100%) diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index f3e33a6..4f0bac2 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -7,7 +7,8 @@ exclude_globs = [ "crates/app/src/ui_kit/**", "crates/app/src/main.rs", ] -# The core's integration tests need the simulator. -features = ["neumann-control-core/test-support"] +# nextest runs the tests of all test binaries at once and stops at the first failure; the +# tests themselves are short, run one binary after another they were most of a mutant's time. +test_tool = "nextest" # Kittests wait on real sockets; a slow mutant is not a caught one. timeout_multiplier = 3.0 diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 0000000..bcb3194 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,4 @@ +# A mutation run only needs to know whether some test fails: the first failure ends the run and +# stops the tests still running (`NEXTEST_PROFILE=mutants`, see README "Mutation testing"). +[profile.mutants] +fail-fast = { max-fail = 1, terminate = "immediate" } diff --git a/Cargo.lock b/Cargo.lock index 870cf29..b784096 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2004,6 +2004,7 @@ version = "0.1.0" dependencies = [ "log", "mdns-sd", + "neumann-control-core", "serde", "serde_json", "tempfile", diff --git a/README.md b/README.md index 8b57a34..2646f6f 100644 --- a/README.md +++ b/README.md @@ -389,10 +389,17 @@ and writes `release\neumann-control-\` (or `-OutputRoot `) with executable, `LICENSE`, `THIRD-PARTY-LICENSES.html` and `SHA256SUMS`. -Mutation testing uses `cargo-mutants` with the settings in `.cargo/mutants.toml` (simulator -and drawing code left out): `cargo mutants -p neumann-control-core` for the core, and -`cargo mutants --workspace --in-diff ` for the lines a change touched. A full run over -the app takes hours. +Mutation testing uses `cargo-mutants` and `cargo-nextest` with the settings in +`.cargo/mutants.toml` (simulator and drawing code left out) and the `mutants` profile in +`.config/nextest.toml`, which ends a run at the first failing test: + +```powershell +$env:NEXTEST_PROFILE = 'mutants' +cargo mutants --workspace -j 4 # everything, about four hours +cargo mutants --workspace -j 4 --in-diff # the lines a change touched +``` + +More than four jobs did not make it faster on a 32-thread machine. The workspace has two crates: `crates/core` (the SSC client, discovery, the path catalog, settings and a speaker simulator for the tests) and `crates/app` (the window). diff --git a/crates/app/tests/compact.rs b/crates/app/tests/app/compact.rs similarity index 99% rename from crates/app/tests/compact.rs rename to crates/app/tests/app/compact.rs index 254e934..cfdd3a1 100644 --- a/crates/app/tests/compact.rs +++ b/crates/app/tests/app/compact.rs @@ -1,8 +1,7 @@ //! The compact mode: switching there and back, one row per ungrouped speaker and per group, //! and rows writing like the strips do. -mod support; - +use crate::support::*; use eframe::egui::{self, accesskit::Role, Key, Modifiers}; use egui_kittest::kittest::Queryable; use egui_kittest::Harness; @@ -11,7 +10,6 @@ use neumann_control_app::texts; use neumann_control_core::settings::{self, SETTINGS_FILE}; use neumann_control_core::simulator::SimModel; use serde_json::Value; -use support::*; fn press_compact_shortcut(harness: &mut Harness<'static, NeumannApp>) { harness.key_press_modifiers(Modifiers::COMMAND, Key::K); diff --git a/crates/app/tests/details.rs b/crates/app/tests/app/details.rs similarity index 99% rename from crates/app/tests/details.rs rename to crates/app/tests/app/details.rs index e09707d..ffb7cc6 100644 --- a/crates/app/tests/details.rs +++ b/crates/app/tests/app/details.rs @@ -1,7 +1,6 @@ //! The device panel: settings per model, displays and number gestures. -mod support; - +use crate::support::*; use eframe::egui::{self, accesskit::Role}; use egui_kittest::kittest::{NodeT, Queryable}; use egui_kittest::{Harness, Node}; @@ -12,7 +11,6 @@ use neumann_control_core::catalog::ValueError; use neumann_control_core::discovery::Found; use neumann_control_core::simulator::{SimModel, Simulator}; use serde_json::{json, Value}; -use support::*; const STANDBY_LABEL: &str = "Auto-Standby"; const DELAY_LABEL: &str = "Verzögerung"; diff --git a/crates/app/tests/eq.rs b/crates/app/tests/app/eq.rs similarity index 99% rename from crates/app/tests/eq.rs rename to crates/app/tests/app/eq.rs index a9fa578..6b45d18 100644 --- a/crates/app/tests/eq.rs +++ b/crates/app/tests/app/eq.rs @@ -1,8 +1,7 @@ //! The EQ window: one per speaker, a graph of every container and a table of the chosen one. //! A band edit writes that band only, at the end of the gesture, and lands in the history. -mod support; - +use crate::support::*; use eframe::egui::{self, accesskit::Role}; use egui_kittest::kittest::{NodeT, Queryable}; use egui_kittest::Harness; @@ -14,7 +13,6 @@ use neumann_control_core::catalog::ValueError; use neumann_control_core::settings::CrossoverGuard; use neumann_control_core::simulator::{SimModel, Simulator}; use serde_json::{json, Value}; -use support::*; const GAIN_LABEL: &str = "Pegel"; const BOOST_LABEL: &str = "Anhebung"; diff --git a/crates/app/tests/glyphs.rs b/crates/app/tests/app/glyphs.rs similarity index 98% rename from crates/app/tests/glyphs.rs rename to crates/app/tests/app/glyphs.rs index 2e276cf..a986c62 100644 --- a/crates/app/tests/glyphs.rs +++ b/crates/app/tests/app/glyphs.rs @@ -12,7 +12,7 @@ const SIMULATED_SERIAL: &str = "SIM0001234"; /// Every string literal in texts.rs, so a new text is covered without being listed here. fn literals_of_texts_module() -> Vec { - let source = include_str!("../src/texts.rs"); + let source = include_str!("../../src/texts.rs"); let mut literals = Vec::new(); let mut rest = source; while let Some(start) = rest.find('"') { diff --git a/crates/app/tests/groups.rs b/crates/app/tests/app/groups.rs similarity index 99% rename from crates/app/tests/groups.rs rename to crates/app/tests/app/groups.rs index 3b39f67..5609b05 100644 --- a/crates/app/tests/groups.rs +++ b/crates/app/tests/app/groups.rs @@ -1,8 +1,7 @@ //! Groups on the desk: group fader, group and master mute, linked settings, the group dialog //! and choosing a group in the device panel. -mod support; - +use crate::support::*; use eframe::egui::{self, accesskit::Role}; use egui_kittest::kittest::Queryable; use egui_kittest::Harness; @@ -13,7 +12,6 @@ use neumann_control_app::writes::Refusal; use neumann_control_core::settings::{self, GroupSettings, Linkable, Settings, SETTINGS_FILE}; use neumann_control_core::simulator::{SimModel, Simulator}; use serde_json::Value; -use support::*; #[test] fn group_fader_moves_every_member_by_the_same_amount() { diff --git a/crates/app/tests/history.rs b/crates/app/tests/app/history.rs similarity index 99% rename from crates/app/tests/history.rs rename to crates/app/tests/app/history.rs index fc93326..0e0983a 100644 --- a/crates/app/tests/history.rs +++ b/crates/app/tests/app/history.rs @@ -1,8 +1,7 @@ //! Every write lands in the session's history as one entry per user action, and each entry can //! be taken back: the values before are written again, through the same rules as any write. -mod support; - +use crate::support::*; use eframe::egui::{self, accesskit::Role}; use egui_kittest::kittest::Queryable; use egui_kittest::Harness; @@ -11,7 +10,6 @@ use neumann_control_app::history::{is_open, Entry}; use neumann_control_app::texts; use neumann_control_core::simulator::SimModel; use serde_json::{json, Value}; -use support::*; const LEVEL_PATH: &str = "audio/out/level"; /// Long enough that the speaker's confirmation of a write is still out when the test acts. diff --git a/crates/app/tests/app/main.rs b/crates/app/tests/app/main.rs new file mode 100644 index 0000000..9da3f63 --- /dev/null +++ b/crates/app/tests/app/main.rs @@ -0,0 +1,17 @@ +//! All window tests in one binary: each test binary linked the whole egui/wgpu stack anew, +//! which was most of a mutation run's build time. + +mod support; + +mod compact; +mod details; +mod eq; +mod glyphs; +mod groups; +mod history; +mod speakers; +mod strip_layout; +mod system_audio; +mod views; +mod write_gestures; +mod write_messages; diff --git a/crates/app/tests/speakers.rs b/crates/app/tests/app/speakers.rs similarity index 99% rename from crates/app/tests/speakers.rs rename to crates/app/tests/app/speakers.rs index c01b91f..8f77c4c 100644 --- a/crates/app/tests/speakers.rs +++ b/crates/app/tests/app/speakers.rs @@ -1,10 +1,9 @@ //! Which speakers the window shows: addresses, speakers chosen from a scan, each once. -mod support; - use std::path::Path; use std::time::Duration; +use crate::support::*; use egui_kittest::kittest::{NodeT, Queryable}; use egui_kittest::Harness; use neumann_control_app::app::NeumannApp; @@ -14,7 +13,6 @@ use neumann_control_core::discovery::Found; use neumann_control_core::mask::mask_host; use neumann_control_core::settings::{self, ManualSpeaker, SETTINGS_FILE}; use neumann_control_core::simulator::{SimModel, Simulator}; -use support::*; const KH750_NAME: &str = "Sub"; const KH750_SERIAL: &str = "SIM0007500"; @@ -341,7 +339,7 @@ fn remove_needs_confirmation_and_forgets_speaker() { harness.get_by_label(&texts::speaker_count(0)); assert!(settings::load(&path).manual.is_empty()); drop(harness); - let mut restarted = support::harness(path, Vec::new()); + let mut restarted = crate::support::harness(path, Vec::new()); restarted.run_steps(2); restarted.get_by_label(&texts::speaker_count(0)); } diff --git a/crates/app/tests/strip_layout.rs b/crates/app/tests/app/strip_layout.rs similarity index 99% rename from crates/app/tests/strip_layout.rs rename to crates/app/tests/app/strip_layout.rs index 3ced5e5..14a090f 100644 --- a/crates/app/tests/strip_layout.rs +++ b/crates/app/tests/app/strip_layout.rs @@ -1,11 +1,10 @@ //! A model's strip shows what its stored layout lists; the layout is changed from the strip's and //! the panel's context menu and in the desk's edit mode. -mod support; - use std::collections::BTreeMap; use std::time::Duration; +use crate::support::*; use eframe::egui::{self, accesskit::Role}; use egui_kittest::kittest::{NodeT, Queryable}; use egui_kittest::Harness; @@ -19,7 +18,6 @@ use neumann_control_core::settings::{self, StoredStripLayout, StripElement}; use neumann_control_core::simulator::SimModel; use neumann_control_core::ssc::wire::SscPath; use serde_json::json; -use support::*; const MONITOR: &str = "KH 120 II"; const SUBWOOFER: &str = "KH 750"; diff --git a/crates/app/tests/support/mod.rs b/crates/app/tests/app/support/mod.rs similarity index 97% rename from crates/app/tests/support/mod.rs rename to crates/app/tests/app/support/mod.rs index 83dd21b..dc8a30a 100644 --- a/crates/app/tests/support/mod.rs +++ b/crates/app/tests/app/support/mod.rs @@ -1,8 +1,6 @@ //! Shared by the window tests: the app with real speaker workers talking to the simulator. //! Threads deliver the events, so tests step frames until a condition holds (bounded by a //! deadline) instead of `run()`. -// Each test binary uses its own subset of these helpers. -#![allow(dead_code)] use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -53,9 +51,20 @@ pub struct SimulatorSpawner { pub scan_result: SharedScan, } +/// The real polling pauses (values every 2 s, reconnect after 10 s) made tests wait for them; +/// shorter ones keep the order of events and save minutes over the suite. +pub fn test_timing() -> Timing { + Timing { + fast: Duration::from_millis(200), + slow: Duration::from_millis(1000), + reconnect: Duration::from_millis(500), + ..Timing::default() + } +} + impl SpeakerSpawner for SimulatorSpawner { fn spawn(&self, target: Target, wake: Arc) -> (SpeakerHandle, Events) { - speaker::spawn(target, Timing::default(), wake) + speaker::spawn(target, test_timing(), wake) } fn scan(&self) -> Receiver { diff --git a/crates/app/tests/system_audio.rs b/crates/app/tests/app/system_audio.rs similarity index 99% rename from crates/app/tests/system_audio.rs rename to crates/app/tests/app/system_audio.rs index feac9dc..ac2af46 100644 --- a/crates/app/tests/system_audio.rs +++ b/crates/app/tests/app/system_audio.rs @@ -1,8 +1,7 @@ //! The Windows volume strip, with a fake standing in for Windows' playback devices: shown only //! when chosen, it reads name, volume, mute and peaks and hands mute and fader moves to Windows. -mod support; - +use crate::support::*; use eframe::egui::{self, accesskit::Role}; use egui_kittest::kittest::{NodeT, Queryable}; use egui_kittest::Harness; @@ -11,7 +10,6 @@ use neumann_control_app::system_audio::{AudioDevice, AudioState}; use neumann_control_app::texts; use neumann_control_core::settings::{self, SystemAudioSettings}; use neumann_control_core::simulator::{SimModel, Simulator}; -use support::*; const DEVICE_NAME: &str = "Studio monitors"; const DEVICE_ID: &str = "{0.0.0.00000000}.{fake}"; diff --git a/crates/app/tests/views.rs b/crates/app/tests/app/views.rs similarity index 99% rename from crates/app/tests/views.rs rename to crates/app/tests/app/views.rs index 24a44d6..a881705 100644 --- a/crates/app/tests/views.rs +++ b/crates/app/tests/app/views.rs @@ -1,7 +1,6 @@ //! The speaker strip in the real window: values, fader, buttons, unreachable state. -mod support; - +use crate::support::*; use eframe::egui::{self, accesskit::Role, MouseWheelUnit, TouchPhase}; use egui_kittest::kittest::{NodeT, Queryable}; use egui_kittest::Harness; @@ -17,7 +16,6 @@ use neumann_control_core::settings::{self, Language, LanguageChoice}; use neumann_control_core::simulator::{SimModel, Simulator}; use neumann_control_core::speaker::Event; use serde_json::Value; -use support::*; /// The strip's knobs carry the device panel's names (paths.toml). const DELAY_KNOB: &str = "Verzögerung"; diff --git a/crates/app/tests/write_gestures.rs b/crates/app/tests/app/write_gestures.rs similarity index 99% rename from crates/app/tests/write_gestures.rs rename to crates/app/tests/app/write_gestures.rs index f363e6a..6fe8db9 100644 --- a/crates/app/tests/write_gestures.rs +++ b/crates/app/tests/app/write_gestures.rs @@ -2,8 +2,7 @@ //! chose, never a value rounded or clamped behind the user's back. These tests hold that line //! for each kind of control: strip fader, group fader, typed entry and panel knob. -mod support; - +use crate::support::*; use eframe::egui::{self, accesskit::Role, MouseWheelUnit, TouchPhase}; use egui_kittest::kittest::Queryable; use egui_kittest::Harness; @@ -13,7 +12,6 @@ use neumann_control_app::writes::Refusal; use neumann_control_core::catalog::ValueError; use neumann_control_core::simulator::SimModel; use serde_json::{json, Value}; -use support::*; /// The strip's delay knob carries the device panel's name. const DELAY_KNOB: &str = "Verzögerung"; diff --git a/crates/app/tests/write_messages.rs b/crates/app/tests/app/write_messages.rs similarity index 100% rename from crates/app/tests/write_messages.rs rename to crates/app/tests/app/write_messages.rs diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 8c9b5bd..4ba77ac 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -29,4 +29,7 @@ name = "speaker" required-features = ["test-support"] [dev-dependencies] +# The tests switch on the simulator themselves, so a test run needs no --features (and the +# mutation runs, which build without it, do not rebuild for the test phase). +neumann-control-core = { path = ".", features = ["test-support"] } tempfile = "3" From 60b3ffc94ddc760c991dad28f21137ad9adc95f3 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:48:00 +0200 Subject: [PATCH 239/460] Test when a lost speaker is looked for and panel keys Mutation testing found the grace before a silent scan and the open panel's key unchecked. --- crates/app/tests/app/speakers.rs | 50 ++++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/crates/app/tests/app/speakers.rs b/crates/app/tests/app/speakers.rs index 8f77c4c..bff7938 100644 --- a/crates/app/tests/app/speakers.rs +++ b/crates/app/tests/app/speakers.rs @@ -7,6 +7,7 @@ use crate::support::*; use egui_kittest::kittest::{NodeT, Queryable}; use egui_kittest::Harness; use neumann_control_app::app::NeumannApp; +use neumann_control_app::rediscovery::UNREACHABLE_GRACE_SECONDS; use neumann_control_app::texts; use neumann_control_core::address::{parse_address, AddressError, SSC_PORT}; use neumann_control_core::discovery::Found; @@ -31,6 +32,14 @@ fn kh750_found(simulator: &Simulator) -> Found { announced(simulator, KH750_NAME, "KH 750", KH750_SERIAL) } +/// The unreachable status shows a few frames after the outage began, and the scan answers a few +/// frames after it started. +const GRACE_MARGIN_SECONDS: f64 = 2.0; + +fn app_time(harness: &Harness<'static, NeumannApp>) -> f64 { + harness.ctx.input(|input| input.time) +} + fn kh120_found(simulator: &Simulator) -> Found { announced(simulator, KH120_NAME, "KH 120 II", SIMULATED_SERIAL) } @@ -388,11 +397,48 @@ fn a_speaker_that_stays_unreachable_is_looked_for_again() { wait_for_level(&mut harness, 100.0); first.stop(); wait_for_label(&mut harness, texts::UNREACHABLE.get()); + let since = app_time(&harness); let moved = Simulator::start(SimModel::Kh120Ii); *shared.lock().unwrap() = Ok(vec![kh120_found(&moved)]); - // The last level stays shown while unreachable; the saved address and the status tell. - wait_until(&mut harness, "the new address", |_| saved_at(&path, &moved)); + // Not looked for during the grace, and right after it. + while app_time(&harness) < since + UNREACHABLE_GRACE_SECONDS - GRACE_MARGIN_SECONDS { + harness.step(); + assert!(!saved_at(&path, &moved), "looked for during the grace"); + } + while !saved_at(&path, &moved) { + harness.step(); + std::thread::sleep(POLL_PAUSE); + assert!( + app_time(&harness) < since + UNREACHABLE_GRACE_SECONDS + GRACE_MARGIN_SECONDS, + "not looked for after the grace" + ); + } wait_until(&mut harness, "the reconnect", |harness| { harness.query_by_label(texts::UNREACHABLE.get()).is_none() }); } + +/// A speaker that connects later gets its key from its serial; an open panel of another speaker +/// stays with that one. +#[test] +fn an_open_panel_stays_when_another_speaker_learns_its_serial() { + let first = Simulator::start(SimModel::Kh120Ii); + let reserved = Simulator::start(SimModel::Kh750); + let late_address = reserved.address(); + let late_entry = manual_for(&reserved); + reserved.stop(); + let directory = tempfile::tempdir().unwrap(); + let mut entry = manual_for(&first); + entry.serial = Some(SIMULATED_SERIAL.to_owned()); + let path = settings_file(directory.path(), vec![entry, late_entry]); + let mut harness = harness(path, Vec::new()); + wait_for_label(&mut harness, KH120_DEVICE_NAME); + open_panel(&mut harness, KH120_DEVICE_NAME); + let _late = Simulator::start_on(SimModel::Kh750, late_address).unwrap(); + wait_for_label(&mut harness, KH750_DEVICE_NAME); + harness.run_steps(2); + // The strip and the panel each name the speaker the panel belongs to. + let named = |name: &str| harness.query_all_by_label(name).count(); + assert_eq!(named(KH120_DEVICE_NAME), 2, "the panel left its speaker"); + assert_eq!(named(KH750_DEVICE_NAME), 1); +} From 6e263a97778129095dbd0973baa4df5492bd542e Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:04:13 +0200 Subject: [PATCH 240/460] Test speaker removal and group deletion edges Mutation testing found removal never tried with two speakers or a key changing under the question, a stale group index unchecked, and the worker's off-thread retirement untested. --- crates/app/src/app.rs | 7 +-- crates/app/src/groups.rs | 22 ++++++++ crates/app/tests/app/speakers.rs | 87 +++++++++++++++++++++++++++++++- 3 files changed, 109 insertions(+), 7 deletions(-) diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index 3590dcf..a722f54 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -16,7 +16,7 @@ use neumann_control_core::settings::{self, Language, ManualSpeaker, Settings, Wi use neumann_control_core::speaker::{Command, Event, Events, SpeakerHandle, Target}; use crate::file_dialog::{FileDialog, NativeFileDialog}; -use crate::groups::{forget_member, member_id, rename_member, set_membership}; +use crate::groups::{delete_group, forget_member, member_id, rename_member, set_membership}; use crate::meter::MeterState; use crate::rediscovery::Rediscovery; use crate::scan_choice::ScanChoice; @@ -580,10 +580,7 @@ impl NeumannApp { self.save_settings(); } GroupDialogAction::Delete => { - let index = draft.index; - if index < self.settings.groups.len() { - self.settings.groups.remove(index); - } + delete_group(&mut self.settings.groups, draft.index); self.group_draft = None; self.save_settings(); } diff --git a/crates/app/src/groups.rs b/crates/app/src/groups.rs index ef584c6..34e082b 100644 --- a/crates/app/src/groups.rs +++ b/crates/app/src/groups.rs @@ -222,6 +222,14 @@ pub fn set_membership(groups: &mut [GroupSettings], member: &str, group: Option< } } +/// Deletes the group at `index`; an index from a dialog opened before the groups changed may +/// point past the end, and then nothing is deleted. +pub fn delete_group(groups: &mut Vec, index: usize) { + if index < groups.len() { + groups.remove(index); + } +} + /// Where a dragged strip would land: the number of other strips of its section whose centre /// is left of the pointer, so past the last one is a slot of its own. pub fn insertion_slot(centers: &[f32], pointer_x: f32) -> usize { @@ -321,6 +329,20 @@ pub fn shifted_levels( mod tests { use super::*; + #[test] + fn a_group_is_deleted_by_its_index() { + let mut groups = vec![group("A", &[]), group("B", &[])]; + delete_group(&mut groups, 0); + assert_eq!(groups, vec![group("B", &[])]); + } + + #[test] + fn an_index_past_the_end_deletes_nothing() { + let mut groups = vec![group("A", &[])]; + delete_group(&mut groups, 1); + assert_eq!(groups, vec![group("A", &[])]); + } + fn group(name: &str, members: &[&str]) -> GroupSettings { GroupSettings { name: name.to_owned(), diff --git a/crates/app/tests/app/speakers.rs b/crates/app/tests/app/speakers.rs index bff7938..12bbd44 100644 --- a/crates/app/tests/app/speakers.rs +++ b/crates/app/tests/app/speakers.rs @@ -1,7 +1,8 @@ //! Which speakers the window shows: addresses, speakers chosen from a scan, each once. +use std::collections::BTreeMap; use std::path::Path; -use std::time::Duration; +use std::time::{Duration, Instant}; use crate::support::*; use egui_kittest::kittest::{NodeT, Queryable}; @@ -13,7 +14,7 @@ use neumann_control_core::address::{parse_address, AddressError, SSC_PORT}; use neumann_control_core::discovery::Found; use neumann_control_core::mask::mask_host; use neumann_control_core::settings::{self, ManualSpeaker, SETTINGS_FILE}; -use neumann_control_core::simulator::{SimModel, Simulator}; +use neumann_control_core::simulator::{Fault, SimModel, Simulator}; const KH750_NAME: &str = "Sub"; const KH750_SERIAL: &str = "SIM0007500"; @@ -32,6 +33,14 @@ fn kh750_found(simulator: &Simulator) -> Found { announced(simulator, KH750_NAME, "KH 750", KH750_SERIAL) } +const SCROLL_FRAMES: usize = 4; +/// Longer than the test meter interval (100 ms), so a poll is waiting for its reply. +const SILENCE_SETTLE: Duration = Duration::from_millis(300); +/// Far below the 3 s a joined worker could take, far above two frames. +const STALL_LIMIT: Duration = Duration::from_secs(1); +/// Below the client's 3 s reply timeout, so the speaker stays connecting instead of failing. +const SLOW_CONNECT_REPLY: Duration = Duration::from_millis(1500); + /// The unreachable status shows a few frames after the outage began, and the scan answers a few /// frames after it started. const GRACE_MARGIN_SECONDS: f64 = 2.0; @@ -442,3 +451,77 @@ fn an_open_panel_stays_when_another_speaker_learns_its_serial() { assert_eq!(named(KH120_DEVICE_NAME), 2, "the panel left its speaker"); assert_eq!(named(KH750_DEVICE_NAME), 1); } + +/// Asks to remove the speaker named `name` and confirms. +fn remove_named(harness: &mut Harness<'static, NeumannApp>, name: &str) { + open_panel(harness, name); + ask_in_open_panel(harness); + confirm_remove(harness); +} + +/// The KH 750's panel is taller than the test window; "Remove" sits at its foot. +fn ask_in_open_panel(harness: &mut Harness<'static, NeumannApp>) { + harness.get_by_label(texts::REMOVE.get()).scroll_to_me(); + // Scrolling is animated. + harness.run_steps(SCROLL_FRAMES); + harness.get_by_label(texts::REMOVE.get()).click(); + wait_for_label(harness, texts::REMOVE_TITLE.get()); +} + +fn confirm_remove(harness: &mut Harness<'static, NeumannApp>) { + harness + .query_all_by_label(texts::REMOVE.get()) + .last() + .expect("the dialog's button") + .click(); + harness.run_steps(2); +} + +#[test] +fn removing_the_second_speaker_keeps_the_first() { + let (_simulators, _directory, mut harness) = + desk_of(&[SimModel::Kh120Ii, SimModel::Kh750], BTreeMap::new()); + remove_named(&mut harness, KH750_DEVICE_NAME); + harness.get_by_label(&texts::speaker_count(1)); + harness.get_by_label(KH120_DEVICE_NAME); + assert!(harness.query_by_label(KH750_DEVICE_NAME).is_none()); +} + +/// The confirmation stays open while the speaker connects and its key becomes its serial; the +/// speaker is removed all the same. +#[test] +fn a_speaker_that_connects_while_asked_about_is_removed() { + let simulator = Simulator::start(SimModel::Kh750); + // Slow replies keep it connecting (no model, no serial yet) while the question is asked. + simulator.set_reply_delay(SLOW_CONNECT_REPLY); + let directory = tempfile::tempdir().unwrap(); + let path = settings_file(directory.path(), vec![manual_for(&simulator)]); + let mut harness = harness(path, Vec::new()); + harness.set_size(EXPANDED_WINDOW_SIZE.into()); + harness.run_steps(2); + harness + .get_by_label(&mask_host(&simulator.address())) + .click(); + wait_for_label(&mut harness, texts::REMOVE.get()); + ask_in_open_panel(&mut harness); + simulator.set_reply_delay(REPLY_DELAY); + wait_for_label(&mut harness, KH750_DEVICE_NAME); + confirm_remove(&mut harness); + harness.get_by_label(&texts::speaker_count(0)); +} + +/// A worker can wait up to the client's 3 s reply timeout before it sees the stop; removing its +/// speaker must not make the window wait with it. +#[test] +fn removing_a_speaker_that_does_not_answer_does_not_stall_the_window() { + let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); + ask_to_remove(&mut harness); + simulator.inject(Fault::Silence); + // The next meter poll is sent and stays unanswered. + std::thread::sleep(SILENCE_SETTLE); + let started = Instant::now(); + confirm_remove(&mut harness); + let took = started.elapsed(); + harness.get_by_label(&texts::speaker_count(0)); + assert!(took < STALL_LIMIT, "the window waited {took:?}"); +} From 8f5f23f2dca9169367db2c81bb0511bfce9ebbf7 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:24:40 +0200 Subject: [PATCH 241/460] Show working values on strips by default Delay and standby time are set once; the strip now shows the input, the standby countdown and the model's own controls, with only shared values above the fader so faders stand level. --- CHANGELOG.md | 5 ++ README.md | 13 ++-- crates/app/src/paths.rs | 5 ++ crates/app/src/strip_layout.rs | 97 ++++++++++++++++++++++++---- crates/app/src/texts.rs | 3 + crates/app/src/views/strip.rs | 16 +++-- crates/app/tests/app/details.rs | 10 ++- crates/app/tests/app/strip_layout.rs | 28 ++++++-- crates/app/tests/app/support/mod.rs | 28 +++++++- 9 files changed, 176 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df900b3..e5b08d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,11 @@ address or interface index. - Falling meters are drawn 20 times a second instead of at the monitor's rate, which kept the graphics card busy while the window was idle. +- A strip now shows by default what is used while working: the input interface, the current + input and the standby countdown above the fader; mute, identify and EQ below, on the KH 120 II + also auto standby and the logo brightness, on the KH 750 the mutes of outputs A and B and the + temperature. The delay and standby time knobs moved to the device panel (they can be put back + on the strip). A strip you customized stays as it is. ### Fixed diff --git a/README.md b/README.md index 2646f6f..47a7497 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,9 @@ panel of one KH 750 open. The interface is shown in German.* - **Find speakers** on the local network (mDNS) and choose which to add, or add one by its IPv6 address. Added speakers are remembered. -- **A desk of channel strips**: status LEDs, delay and standby knobs, a level fader, input - meters (on the KH 750 also its output meters), mute and identify. +- **A desk of channel strips**: status LEDs, the input and the standby countdown, a level fader, + input meters (on the KH 750 also its output meters), mute and identify; what a strip shows can + be changed per model. - **A device panel** per speaker with every setting the catalog knows for its model. - **Groups and a master**: move or mute several speakers at once, and link settings within a group. @@ -118,13 +119,17 @@ Each speaker has a channel strip: - its name (click it to open the device panel) and a grip to drag the strip elsewhere, - four LEDs: connection, clip, device warning, and LOCAL (the rear CONTROL switch locks writing), -- by default knobs for **Delay** (shown in ms) and **Standby time**, +- by default the **Input interface** (a choice), the **Current input** and the standby + **Countdown**; set-up values such as the delay and the standby time are in the device panel, - the output level fader (0–120 dB SPL in 0.5 dB steps) with its value below, - input meters for channels A and B with clip lamps; the KH 750 also shows its output meters A, B and Sub, captioned "Out". A thin line marks each meter's peak of the last 1.5 seconds, and the number under the channel shows it in whole dB ("–" below the meter range), - the response time, and by default **Mute**, **Ident.** (identify) and **EQ**, which opens the - speaker's EQ window once the speaker has reported its model. + speaker's EQ window once the speaker has reported its model; on the KH 120 II also + **Auto standby** and a knob for the **Logo brightness**, on the KH 750 **Mute A** and **Mute B** + for its outputs A and B and its **Temperature**. Above the fader every model shows the same, so + the faders of a mixed desk stand level. What a strip shows besides its name, LEDs, fader, meters and message is set per model, for every speaker of that model at once: diff --git a/crates/app/src/paths.rs b/crates/app/src/paths.rs index 388096d..af5f965 100644 --- a/crates/app/src/paths.rs +++ b/crates/app/src/paths.rs @@ -24,6 +24,11 @@ pub const STANDBY_ENABLED: &str = "device/standby/enabled"; pub const INPUT_INTERFACE: &str = "audio/in/interface"; pub const DELAY: &str = "audio/out/delay"; pub const STANDBY_TIME: &str = "device/standby/auto_standby_time"; +pub const STANDBY_COUNTDOWN: &str = "device/standby/countdown"; +pub const CURRENT_INPUT: &str = "audio/in/current_input"; +pub const LOGO_BRIGHTNESS: &str = "ui/logo/brightness"; +pub const MUTE_OUTPUT_A: &str = "audio/out1/mute"; +pub const MUTE_OUTPUT_B: &str = "audio/out2/mute"; pub fn path(text: &str) -> SscPath { SscPath::parse(text).expect("constant path is valid") diff --git a/crates/app/src/strip_layout.rs b/crates/app/src/strip_layout.rs index 392fca7..4e01cd2 100644 --- a/crates/app/src/strip_layout.rs +++ b/crates/app/src/strip_layout.rs @@ -36,22 +36,39 @@ pub enum LayoutChange { Reset, } -const DEFAULT_ABOVE: [&str; 2] = [paths::DELAY, paths::STANDBY_TIME]; -const DEFAULT_BELOW: [&str; 2] = [paths::MUTE, paths::IDENTIFY]; - -/// Today's strip: delay and standby time above the fader, mute, identify and EQ below. +/// What is looked at or switched while working; set-up values (delay, standby time, phase) stay +/// in the panel. Above the fader only what every model has, so the faders of a mixed desk stand +/// level; each model gets the ones it has of the rest. +const DEFAULT_ABOVE: [&str; 3] = [ + paths::INPUT_INTERFACE, + paths::CURRENT_INPUT, + paths::STANDBY_COUNTDOWN, +]; +const DEFAULT_BUTTONS: [&str; 5] = [ + paths::MUTE, + paths::MUTE_OUTPUT_A, + paths::MUTE_OUTPUT_B, + paths::IDENTIFY, + paths::STANDBY_ENABLED, +]; +/// After the buttons and EQ: the model's own values. +const DEFAULT_MODEL_VALUES: [&str; 2] = [paths::TEMPERATURE, paths::LOGO_BRIGHTNESS]; + +/// The working values above the fader; the buttons, EQ and the model's own values below. pub fn default_layout(product: &str, catalog: &Catalog) -> StoredStripLayout { let paths_of = |texts: &[&str]| -> Vec { texts .iter() .map(|text| StripElement::Path(paths::path(text))) .filter(|element| shows(element, product, catalog)) + .filter(|element| usable_by_default(element, product, catalog)) .collect() }; - let mut below = paths_of(&DEFAULT_BELOW); + let mut below = paths_of(&DEFAULT_BUTTONS); if shows(&StripElement::Eq, product, catalog) { below.push(StripElement::Eq); } + below.extend(paths_of(&DEFAULT_MODEL_VALUES)); StoredStripLayout { above: paths_of(&DEFAULT_ABOVE), below, @@ -199,6 +216,24 @@ fn shows(element: &StripElement, product: &str, catalog: &Catalog) -> bool { } } +/// A control a writable model cannot write (the KH 750's auto standby follows its switch) +/// would sit on every strip greyed out. A model the app writes nothing on (not measured, or not +/// yet connected) keeps them: their state still shows, and the strip keeps its shape when the +/// model becomes known. +fn usable_by_default(element: &StripElement, product: &str, catalog: &Catalog) -> bool { + let StripElement::Path(path) = element else { + return true; + }; + let entries = catalog.entries_for(product); + let writes_anything = entries.iter().any(|entry| entry.is_writable(product)); + entries + .iter() + .find(|entry| entry.path == *path) + .is_some_and(|entry| { + entry.kind == Kind::Display || entry.is_writable(product) || !writes_anything + }) +} + /// One value a strip can show: no text field, no band list, no list of numbers, no meter. fn fits_a_strip(entry: &Entry) -> bool { let one_value = !entry.bands && entry.element_labels(Language::German).is_empty(); @@ -231,6 +266,16 @@ mod tests { resolve(None, MONITOR, Catalog::embedded()) } + /// Delay and standby time above the fader, mute, identify and EQ below. + fn two_knobs() -> StripLayout { + let mut below = elements(&[paths::MUTE, paths::IDENTIFY]); + below.push(StripElement::Eq); + StripLayout { + above: elements(&[paths::DELAY, paths::STANDBY_TIME]), + below, + } + } + fn stored(layout: StoredStripLayout) -> StripLayout { StripLayout { above: layout.above, @@ -238,15 +283,45 @@ mod tests { } } + /// The same on every model, so the faders of a mixed desk stand level. + const WORKING_ABOVE: [&str; 3] = [ + paths::INPUT_INTERFACE, + paths::CURRENT_INPUT, + paths::STANDBY_COUNTDOWN, + ]; + #[test] - fn the_default_is_todays_strip() { + fn the_monitor_default_shows_what_is_used_while_working() { let layout = monitor_default(); - assert_eq!(layout.above, elements(&[paths::DELAY, paths::STANDBY_TIME])); - let mut below = elements(&[paths::MUTE, paths::IDENTIFY]); + assert_eq!(layout.above, elements(&WORKING_ABOVE)); + let mut below = elements(&[paths::MUTE, paths::IDENTIFY, paths::STANDBY_ENABLED]); + below.push(StripElement::Eq); + below.push(element(paths::LOGO_BRIGHTNESS)); + assert_eq!(layout.below, below); + } + + /// The KH 750 cannot switch auto standby over the network (its switch does), so the default + /// leaves that button out; set-up values such as the delay stay in the panel. + #[test] + fn the_subwoofer_default_adds_its_outputs_and_its_temperature_below() { + let layout = resolve(None, SUBWOOFER, Catalog::embedded()); + assert_eq!(layout.above, elements(&WORKING_ABOVE)); + let mut below = elements(&[ + paths::MUTE, + paths::MUTE_OUTPUT_A, + paths::MUTE_OUTPUT_B, + paths::IDENTIFY, + ]); below.push(StripElement::Eq); + below.push(element(paths::TEMPERATURE)); assert_eq!(layout.below, below); - let subwoofer = resolve(None, SUBWOOFER, Catalog::embedded()); - assert!(subwoofer.below.contains(&StripElement::Eq)); + } + + #[test] + fn a_model_without_writes_keeps_its_buttons_by_default() { + let unknown = resolve(None, "", Catalog::embedded()); + assert!(unknown.below.contains(&element(paths::MUTE))); + assert!(unknown.below.contains(&element(paths::STANDBY_ENABLED))); } #[test] @@ -293,7 +368,7 @@ mod tests { #[test] fn moving_crosses_the_fader_at_the_ends() { - let layout = monitor_default(); + let layout = two_knobs(); let standby = element(paths::STANDBY_TIME); let down = stored(apply(&layout, &LayoutChange::MoveDown(standby.clone())).unwrap()); assert_eq!(down.above, elements(&[paths::DELAY])); diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 7d35d97..fede0f7 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -128,6 +128,9 @@ pub const NO_SPEAKERS: Text = Text::new( pub const MUTE: Text = Text::new("Stumm", "Mute"); pub const IDENTIFY: Text = Text::new("Ident.", "Ident."); +/// The KH 750's outputs A and B on a strip, beside its own mute. +pub const MUTE_OUTPUT_A: Text = Text::new("Stumm A", "Mute A"); +pub const MUTE_OUTPUT_B: Text = Text::new("Stumm B", "Mute B"); pub const LEVEL: Text = Text::new("Pegel", "Level"); pub const CLIP: Text = Text::new("Übersteuert", "Clipping"); pub const LED_CONNECTION: Text = Text::new("Verbindung", "Connection"); diff --git a/crates/app/src/views/strip.rs b/crates/app/src/views/strip.rs index 8bc7a26..1d7624a 100644 --- a/crates/app/src/views/strip.rs +++ b/crates/app/src/views/strip.rs @@ -563,12 +563,18 @@ fn button_row( wishes.open_eq |= open_eq; } -/// The name from the panel, but identify keeps the strip's short one to fit beside mute. +/// The name from the panel, but some keep a short one of the strip's: identify to fit beside +/// mute, and the KH 750's three mutes, which the panel tells apart by their headings only. fn button_text(entry: &Entry) -> &str { - if entry.path == paths::path(paths::IDENTIFY) { - return texts::IDENTIFY.get(); - } - entry.label(texts::language()) + let short = [ + (paths::IDENTIFY, texts::IDENTIFY), + (paths::MUTE_OUTPUT_A, texts::MUTE_OUTPUT_A), + (paths::MUTE_OUTPUT_B, texts::MUTE_OUTPUT_B), + ]; + short + .into_iter() + .find(|(path, _)| entry.path == paths::path(path)) + .map_or_else(|| entry.label(texts::language()), |(_, text)| text.get()) } fn select( diff --git a/crates/app/tests/app/details.rs b/crates/app/tests/app/details.rs index ffb7cc6..3e5a36d 100644 --- a/crates/app/tests/app/details.rs +++ b/crates/app/tests/app/details.rs @@ -152,7 +152,15 @@ fn an_announced_model_gives_no_write_controls_or_model_rows() { wait_for_level(&mut harness, 100.0); wait_for_initial_read(&mut harness, &simulator); open_panel(&mut harness, KH120_DEVICE_NAME); - assert_eq!(role_of(&harness, STANDBY_LABEL), Role::Label); + // The panel row is text; the strip's auto standby button is there but cannot be pressed. + let standby: Vec<_> = harness.query_all_by_label(STANDBY_LABEL).collect(); + assert!(standby + .iter() + .any(|node| node.accesskit_node().role() == Role::Label)); + assert!(standby + .iter() + .filter(|node| node.accesskit_node().role() == Role::Button) + .all(|node| node.accesskit_node().is_disabled())); assert!(harness.query_by_label(LOGO_LABEL).is_none()); } diff --git a/crates/app/tests/app/strip_layout.rs b/crates/app/tests/app/strip_layout.rs index 14a090f..067d996 100644 --- a/crates/app/tests/app/strip_layout.rs +++ b/crates/app/tests/app/strip_layout.rs @@ -51,12 +51,25 @@ fn label(text: &str) -> &'static str { } #[test] -fn without_a_layout_the_strip_is_as_before() { +fn without_a_layout_the_strip_shows_the_working_values() { let (_simulators, _directory, harness) = desk_of(&[SimModel::Kh120Ii], BTreeMap::new()); - for knob in [paths::DELAY, paths::STANDBY_TIME] { + let shown = [ + paths::INPUT_INTERFACE, + paths::CURRENT_INPUT, + paths::STANDBY_COUNTDOWN, + paths::LOGO_BRIGHTNESS, + paths::STANDBY_ENABLED, + ]; + for path in shown { + assert!( + harness.query_all_by_label(label(path)).next().is_some(), + "{path}" + ); + } + for set_once in [paths::DELAY, paths::STANDBY_TIME] { assert!( - harness.query_all_by_label(label(knob)).next().is_some(), - "{knob}" + harness.query_all_by_label(label(set_once)).next().is_none(), + "{set_once}" ); } for button in [texts::MUTE.get(), texts::IDENTIFY.get(), texts::EQ.get()] { @@ -314,7 +327,7 @@ fn editing_hides_and_adds() { #[test] fn editing_sends_nothing() { - let (simulators, _directory, mut harness) = desk_of(&[SimModel::Kh120Ii], BTreeMap::new()); + let (simulators, _directory, mut harness) = desk_of(&[SimModel::Kh120Ii], knob_strip_layouts()); start_editing(&mut harness); harness .get_by_role_and_label(Role::Button, texts::MUTE.get()) @@ -344,7 +357,7 @@ fn editing_sends_nothing() { #[test] fn dragging_moves_across_the_fader() { - let (_simulators, directory, mut harness) = desk_of(&[SimModel::Kh120Ii], BTreeMap::new()); + let (_simulators, directory, mut harness) = desk_of(&[SimModel::Kh120Ii], knob_strip_layouts()); start_editing(&mut harness); let from = center(&harness, Role::Slider, label(paths::DELAY)); let to = center(&harness, Role::Button, texts::MUTE.get()); @@ -458,7 +471,8 @@ fn the_button_row_keeps_its_size_and_its_words() { #[test] fn in_edit_mode_no_hide_mark_covers_a_knob() { - let (_simulators, _directory, mut harness) = desk_of(&[SimModel::Kh120Ii], BTreeMap::new()); + let (_simulators, _directory, mut harness) = + desk_of(&[SimModel::Kh120Ii], knob_strip_layouts()); start_editing(&mut harness); let knobs = [paths::DELAY, paths::STANDBY_TIME]; for own in knobs { diff --git a/crates/app/tests/app/support/mod.rs b/crates/app/tests/app/support/mod.rs index dc8a30a..28f99fc 100644 --- a/crates/app/tests/app/support/mod.rs +++ b/crates/app/tests/app/support/mod.rs @@ -21,10 +21,11 @@ use neumann_control_core::catalog::Catalog; use neumann_control_core::discovery::Found; use neumann_control_core::settings::{ self, DeskSettings, GroupSettings, Language, Linkable, ManualSpeaker, Settings, - StoredStripLayout, SETTINGS_FILE, + StoredStripLayout, StripElement, SETTINGS_FILE, }; use neumann_control_core::simulator::{SimModel, Simulator}; use neumann_control_core::speaker::{self, Events, SpeakerHandle, Target, Timing}; +use neumann_control_core::ssc::wire::SscPath; use serde_json::Value; pub const SIMULATED_SERIAL: &str = "SIM0001234"; @@ -39,6 +40,12 @@ pub const EXPANDED_WINDOW_SIZE: [f32; 2] = [1200.0, 1600.0]; pub const LOOPBACK: &str = "::1"; const DIALOG_SETTLE_FRAMES: usize = 2; const PRODUCT_PATH: &str = "device/identity/product"; +const DELAY_PATH: &str = "audio/out/delay"; +const STANDBY_TIME_PATH: &str = "device/standby/auto_standby_time"; +const MUTE_PATH: &str = "audio/out/mute"; +const IDENTIFY_PATH: &str = "device/identification/visual"; +const KH120_PRODUCT: &str = "KH 120 II"; +const KH750_PRODUCT: &str = "KH 750"; const SERIAL_PATH: &str = "device/identity/serial"; /// Real speakers take tens of milliseconds per reply; an instant simulator hid tests that /// acted before the connect-time read had arrived. @@ -83,10 +90,29 @@ pub fn manual_for(simulator: &Simulator) -> ManualSpeaker { } } +/// The strip of the window tests: the delay and standby time knobs their gestures turn, mute, +/// identify and EQ. The default strip (working values, no knobs on the KH 750) is tested on its +/// own with `desk_of`. +pub fn knob_strip_layouts() -> BTreeMap { + let path = |text: &str| StripElement::Path(SscPath::parse(text).unwrap()); + let layout = StoredStripLayout { + above: vec![path(DELAY_PATH), path(STANDBY_TIME_PATH)], + below: vec![path(MUTE_PATH), path(IDENTIFY_PATH), StripElement::Eq], + }; + [KH120_PRODUCT, KH750_PRODUCT] + .into_iter() + .map(|product| (product.to_owned(), layout.clone())) + .collect() +} + pub fn settings_file(directory: &Path, manual: Vec) -> PathBuf { let path = directory.join(SETTINGS_FILE); let settings = Settings { manual, + desk: DeskSettings { + strip_layouts: knob_strip_layouts(), + ..DeskSettings::default() + }, ..Settings::default() }; settings::save(&settings, &path).unwrap(); From 340efbc55a4f95d7091bd32900ea139297508d10 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:08:36 +0200 Subject: [PATCH 242/460] Read the input interface and current input often Side by side on the strip, the current input lagged a switch of the interface by up to 30 seconds. --- CHANGELOG.md | 2 ++ crates/core/src/paths.toml | 4 ++-- crates/core/tests/catalog.rs | 12 +++++++++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5b08d5..37c5db0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,8 @@ also auto standby and the logo brightness, on the KH 750 the mutes of outputs A and B and the temperature. The delay and standby time knobs moved to the device panel (they can be put back on the strip). A strip you customized stays as it is. +- The input interface and the current input are read every 2 seconds instead of every 30, so the + current input follows a switch of the interface right away. ### Fixed diff --git a/crates/core/src/paths.toml b/crates/core/src/paths.toml index 2ba14cb..77b169d 100644 --- a/crates/core/src/paths.toml +++ b/crates/core/src/paths.toml @@ -321,7 +321,7 @@ label = "Eingangs-Interface" label_en = "Input interface" group = "input" kind = "select" -poll = "slow" +poll = "fast" class = "all" options = ["ANALOG ONLY", "DIGITAL ONLY", "DIGITAL DISCARDS ANALOG"] writable_on = ["KH 120 II", "KH 750"] @@ -332,7 +332,7 @@ label = "Aktueller Eingang" label_en = "Current input" group = "input" kind = "display" -poll = "slow" +poll = "fast" class = "all" [[entry]] diff --git a/crates/core/tests/catalog.rs b/crates/core/tests/catalog.rs index 08595ab..7feb51f 100644 --- a/crates/core/tests/catalog.rs +++ b/crates/core/tests/catalog.rs @@ -1,4 +1,6 @@ -use neumann_control_core::catalog::{Catalog, CatalogError, Conversion, Entry, Kind, ValueError}; +use neumann_control_core::catalog::{ + Catalog, CatalogError, Conversion, Entry, Kind, Poll, ValueError, +}; use neumann_control_core::settings::Language; use neumann_control_core::ssc::wire::SscPath; use serde_json::json; @@ -55,6 +57,14 @@ fn writable_models_have_the_path() { } } +/// The current input stands on the strip beside the interface choice; read as often as the +/// choice, it follows a switch within seconds instead of half a minute. +#[test] +fn the_current_input_is_read_as_often_as_the_interface() { + assert_eq!(entry("audio/in/interface").poll, Poll::Fast); + assert_eq!(entry("audio/in/current_input").poll, Poll::Fast); +} + #[test] fn kh750_has_no_phase_inversion_and_has_out1() { assert!(!has(SUBWOOFER, "audio/out/phaseinversion")); From ff606799eb9ebe9b5e2510aba69072f839c20dcc Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:09:41 +0200 Subject: [PATCH 243/460] Keep the logo brightness off the default strip --- CHANGELOG.md | 6 +++--- README.md | 7 ++++--- crates/app/src/strip_layout.rs | 9 ++++----- crates/app/tests/app/strip_layout.rs | 3 +-- 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37c5db0..c03d387 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,9 +53,9 @@ graphics card busy while the window was idle. - A strip now shows by default what is used while working: the input interface, the current input and the standby countdown above the fader; mute, identify and EQ below, on the KH 120 II - also auto standby and the logo brightness, on the KH 750 the mutes of outputs A and B and the - temperature. The delay and standby time knobs moved to the device panel (they can be put back - on the strip). A strip you customized stays as it is. + also auto standby, on the KH 750 the mutes of outputs A and B and the temperature. The delay and + standby time knobs moved to the device panel (they can be put back on the strip). A strip you + customized stays as it is. - The input interface and the current input are read every 2 seconds instead of every 30, so the current input follows a switch of the interface right away. diff --git a/README.md b/README.md index 47a7497..d6e0959 100644 --- a/README.md +++ b/README.md @@ -120,15 +120,16 @@ Each speaker has a channel strip: - its name (click it to open the device panel) and a grip to drag the strip elsewhere, - four LEDs: connection, clip, device warning, and LOCAL (the rear CONTROL switch locks writing), - by default the **Input interface** (a choice), the **Current input** and the standby - **Countdown**; set-up values such as the delay and the standby time are in the device panel, + **Countdown**; set-up values such as the delay, the standby time and the logo brightness are in + the device panel, - the output level fader (0–120 dB SPL in 0.5 dB steps) with its value below, - input meters for channels A and B with clip lamps; the KH 750 also shows its output meters A, B and Sub, captioned "Out". A thin line marks each meter's peak of the last 1.5 seconds, and the number under the channel shows it in whole dB ("–" below the meter range), - the response time, and by default **Mute**, **Ident.** (identify) and **EQ**, which opens the speaker's EQ window once the speaker has reported its model; on the KH 120 II also - **Auto standby** and a knob for the **Logo brightness**, on the KH 750 **Mute A** and **Mute B** - for its outputs A and B and its **Temperature**. Above the fader every model shows the same, so + **Auto standby**, on the KH 750 **Mute A** and **Mute B** for its outputs A and B and its + **Temperature**. Above the fader every model shows the same, so the faders of a mixed desk stand level. What a strip shows besides its name, LEDs, fader, meters and message is set per model, for every diff --git a/crates/app/src/strip_layout.rs b/crates/app/src/strip_layout.rs index 4e01cd2..1e19427 100644 --- a/crates/app/src/strip_layout.rs +++ b/crates/app/src/strip_layout.rs @@ -36,9 +36,9 @@ pub enum LayoutChange { Reset, } -/// What is looked at or switched while working; set-up values (delay, standby time, phase) stay -/// in the panel. Above the fader only what every model has, so the faders of a mixed desk stand -/// level; each model gets the ones it has of the rest. +/// What is looked at or switched while working; set-up values (delay, standby time, phase, logo) +/// stay in the panel. Above the fader only what every model has, so the faders of a mixed desk +/// stand level; each model gets the ones it has of the rest. const DEFAULT_ABOVE: [&str; 3] = [ paths::INPUT_INTERFACE, paths::CURRENT_INPUT, @@ -52,7 +52,7 @@ const DEFAULT_BUTTONS: [&str; 5] = [ paths::STANDBY_ENABLED, ]; /// After the buttons and EQ: the model's own values. -const DEFAULT_MODEL_VALUES: [&str; 2] = [paths::TEMPERATURE, paths::LOGO_BRIGHTNESS]; +const DEFAULT_MODEL_VALUES: [&str; 1] = [paths::TEMPERATURE]; /// The working values above the fader; the buttons, EQ and the model's own values below. pub fn default_layout(product: &str, catalog: &Catalog) -> StoredStripLayout { @@ -296,7 +296,6 @@ mod tests { assert_eq!(layout.above, elements(&WORKING_ABOVE)); let mut below = elements(&[paths::MUTE, paths::IDENTIFY, paths::STANDBY_ENABLED]); below.push(StripElement::Eq); - below.push(element(paths::LOGO_BRIGHTNESS)); assert_eq!(layout.below, below); } diff --git a/crates/app/tests/app/strip_layout.rs b/crates/app/tests/app/strip_layout.rs index 067d996..3f23858 100644 --- a/crates/app/tests/app/strip_layout.rs +++ b/crates/app/tests/app/strip_layout.rs @@ -57,7 +57,6 @@ fn without_a_layout_the_strip_shows_the_working_values() { paths::INPUT_INTERFACE, paths::CURRENT_INPUT, paths::STANDBY_COUNTDOWN, - paths::LOGO_BRIGHTNESS, paths::STANDBY_ENABLED, ]; for path in shown { @@ -66,7 +65,7 @@ fn without_a_layout_the_strip_shows_the_working_values() { "{path}" ); } - for set_once in [paths::DELAY, paths::STANDBY_TIME] { + for set_once in [paths::DELAY, paths::STANDBY_TIME, paths::LOGO_BRIGHTNESS] { assert!( harness.query_all_by_label(label(set_once)).next().is_none(), "{set_once}" From fc2186f34a127df47ee3522c2e860cab073d562b Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:23:30 +0200 Subject: [PATCH 244/460] Open the device panel in a window of its own One movable window per speaker, also outside the main window, opened from the name or the new Details button under each strip. A click beside it no longer closes it. --- CHANGELOG.md | 3 + README.md | 15 +-- crates/app/src/app.rs | 90 +++++++++++------ crates/app/src/app/compact.rs | 12 +-- crates/app/src/app/desk.rs | 16 +-- crates/app/src/texts.rs | 7 ++ crates/app/src/views/compact.rs | 8 +- crates/app/src/views/panel.rs | 150 ++++++++++++++-------------- crates/app/src/views/strip.rs | 17 ++-- crates/app/tests/app/details.rs | 4 +- crates/app/tests/app/eq.rs | 15 ++- crates/app/tests/app/groups.rs | 2 +- crates/app/tests/app/speakers.rs | 9 +- crates/app/tests/app/support/mod.rs | 9 ++ crates/app/tests/app/views.rs | 76 ++++++++++++++ 15 files changed, 272 insertions(+), 161 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c03d387..8bf764c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,9 @@ customized stays as it is. - The input interface and the current input are read every 2 seconds instead of every 30, so the current input follows a switch of the interface right away. +- The device panel opens in a window of its own, one per speaker, that can be moved anywhere + (also outside the main window); a click beside it no longer closes it. **Details** under each + strip opens it, as does the speaker's name. ### Fixed diff --git a/README.md b/README.md index d6e0959..b4ccc32 100644 --- a/README.md +++ b/README.md @@ -129,8 +129,9 @@ Each speaker has a channel strip: - the response time, and by default **Mute**, **Ident.** (identify) and **EQ**, which opens the speaker's EQ window once the speaker has reported its model; on the KH 120 II also **Auto standby**, on the KH 750 **Mute A** and **Mute B** for its outputs A and B and its - **Temperature**. Above the fader every model shows the same, so - the faders of a mixed desk stand level. + **Temperature**. Above the fader every model shows the same, so the faders of a mixed desk + stand level, +- **Details** at the foot, which opens the device panel. What a strip shows besides its name, LEDs, fader, meters and message is set per model, for every speaker of that model at once: @@ -165,10 +166,12 @@ time of the connected ones. ### Device panel -Click a speaker's name. The panel lists every setting the catalog knows for that model (output, -input, standby, device, rear panel) in two columns. Settings the model may write get a control; -the rest are shown greyed. The panel also shows model, firmware and the masked serial number. -Escape, **Close** or a click outside closes it. +Click a speaker's name or **Details** under its strip. The panel opens in a window of its own, +one per speaker, which you can move anywhere, also beside the main window or onto another screen; +opening it again brings it to the front. It lists every setting the catalog knows for that model +(output, input, standby, device, rear panel) in two columns. Settings the model may write get a +control; the rest are shown greyed. The panel also shows model, firmware and the masked serial +number. Escape, **Close** or the window's close button closes it. Each output also has **Input mix**, one field per input (Input A, Input B, −100 to 0 dB): how much of each input that output plays. By default the KH 750 plays input A on output A, input B diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index a722f54..b236dcc 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -7,7 +7,10 @@ use std::sync::mpsc::{Receiver, TryRecvError}; use std::sync::Arc; use std::thread; -use eframe::egui::{self, Context, Key, KeyboardShortcut, Modifiers, Pos2, Ui, ViewportInfo}; +use eframe::egui::{ + self, Context, Id, Key, KeyboardShortcut, Modifiers, Ui, ViewportCommand, ViewportId, + ViewportInfo, +}; use neumann_control_core::address::parse_address; use neumann_control_core::catalog::Catalog; use neumann_control_core::discovery::{merge_found, Found}; @@ -50,6 +53,7 @@ pub use history::TakeBackNotice; const DESK_SCROLL_ID: &str = "desk"; const COMPACT_SCROLL_ID: &str = "compact"; +const PANEL_VIEWPORT_ID: &str = "device_panel"; const COMPACT_SHORTCUT: KeyboardShortcut = KeyboardShortcut::new(Modifiers::COMMAND, Key::K); struct Speaker { @@ -58,12 +62,13 @@ struct Speaker { events: Events, } -/// The speaker whose device panel is open and where it opens. +/// A speaker's device panel, open in a window of its own. struct OpenPanel { key: SpeakerKey, - anchor: Pos2, - /// The opening click is still in this frame's input and must not close the panel. - opened_now: bool, + /// Fixed when it opens: the key can change while the speaker connects, the window stays. + viewport: ViewportId, + /// Opened again while open: the window comes to the front. + focus: bool, } /// The speaker "Entfernen" was clicked for, while the confirmation is open. Key and address both, @@ -90,7 +95,7 @@ pub struct NeumannApp { notice: Option, add_address: Option, removal: Option, - panel: Option, + panels: Vec, group_draft: Option, settings_open: bool, /// The desk's strips are being customized; not saved, a new start shows the desk as usual. @@ -158,7 +163,7 @@ impl NeumannApp { notice: None, add_address: None, removal: None, - panel: None, + panels: Vec::new(), group_draft: None, settings_open: false, editing: false, @@ -264,7 +269,7 @@ impl NeumannApp { /// A speaker's key changed (its serial became known): its group, its place in the order and /// its open panel go with it. fn follow_key(&mut self, before: &SpeakerKey, after: &SpeakerKey) -> bool { - if let Some(panel) = self.panel.as_mut().filter(|panel| panel.key == *before) { + for panel in self.panels.iter_mut().filter(|panel| panel.key == *before) { panel.key = after.clone(); } let (old, new) = (member_id(before), member_id(after)); @@ -445,7 +450,7 @@ impl NeumannApp { self.show_desk(ui); } }); - self.show_panel(ui.ctx()); + self.show_panels(ui.ctx()); self.show_dialog(ui); self.show_scan_choice(ui); self.show_removal(ui); @@ -474,22 +479,53 @@ impl NeumannApp { } } - /// The open device panel; it closes when its speaker is gone or unreachable. - fn show_panel(&mut self, context: &Context) { - let Some(open) = &mut self.panel else { + /// Opens the speaker's panel, or brings it to the front when it is open. + fn open_panel(&mut self, key: SpeakerKey) { + if let Some(open) = self.panels.iter_mut().find(|panel| panel.key == key) { + open.focus = true; return; - }; - let index = self + } + let viewport = ViewportId::from_hash_of(Id::unique(PANEL_VIEWPORT_ID).with(&key)); + self.panels.push(OpenPanel { + key, + viewport, + focus: false, + }); + } + + /// Every open device panel; one closes when its speaker is gone or unreachable. + fn show_panels(&mut self, context: &Context) { + let speakers = &self.speakers; + self.panels.retain(|panel| { + speakers.iter().any(|speaker| { + speaker.state.key == panel.key && speaker.state.status != Status::Unreachable + }) + }); + let mut closed = Vec::new(); + for panel in 0..self.panels.len() { + if self.show_panel(context, panel) { + closed.push(panel); + } + } + for panel in closed.into_iter().rev() { + self.panels.remove(panel); + } + } + + /// One panel; true when it is to close. + fn show_panel(&mut self, context: &Context, panel: usize) -> bool { + let open = &mut self.panels[panel]; + let Some(index) = self .speakers .iter() .position(|speaker| speaker.state.key == open.key) - .filter(|index| self.speakers[*index].state.status != Status::Unreachable); - let Some(index) = index else { - self.panel = None; - return; + else { + return true; }; - let (anchor, opened_now) = (open.anchor, open.opened_now); - open.opened_now = false; + let viewport = open.viewport; + if std::mem::take(&mut open.focus) { + context.send_viewport_cmd_to(viewport, ViewportCommand::Focus); + } let catalog = Catalog::embedded(); let ids = self.member_ids(); let names: Vec = self @@ -514,15 +550,7 @@ impl NeumannApp { .identity .is_some() .then(|| strip_layout::resolve(stored, &product, catalog)); - let response = panel::show( - context, - state, - catalog, - anchor, - opened_now, - &choice, - layout.as_ref(), - ); + let response = panel::show(context, viewport, state, catalog, &choice, layout.as_ref()); if let Some(change) = &response.layout { self.change_layout(&product, change, catalog); } @@ -555,9 +583,7 @@ impl NeumannApp { if response.load_preset { self.open_preset_load(key); } - if response.close || response.remove { - self.panel = None; - } + response.close || response.remove } fn show_group_dialog(&mut self, ui: &mut Ui) { diff --git a/crates/app/src/app/compact.rs b/crates/app/src/app/compact.rs index 4d58ab8..2b14ad9 100644 --- a/crates/app/src/app/compact.rs +++ b/crates/app/src/app/compact.rs @@ -5,7 +5,7 @@ use eframe::egui::{ScrollArea, Ui}; use neumann_control_core::catalog::Catalog; use super::desk::{all_muted, levels, members_of, DeskAction}; -use super::{NeumannApp, OpenPanel, COMPACT_SCROLL_ID}; +use super::{NeumannApp, COMPACT_SCROLL_ID}; use crate::fader::output_level_range; use crate::groups::{compact_rows, layout, reference_level, shared_level, CompactRow}; use crate::texts; @@ -43,14 +43,8 @@ impl NeumannApp { }) .inner; actions.push(DeskAction::Commands(index, response.commands)); - if let Some(anchor) = response.open_panel { - let opened_now = true; - let open = OpenPanel { - key, - anchor, - opened_now, - }; - actions.push(DeskAction::OpenPanel(open)); + if response.open_panel { + actions.push(DeskAction::OpenPanel(key)); } } CompactRow::Group(group) => { diff --git a/crates/app/src/app/desk.rs b/crates/app/src/app/desk.rs index b3ca6e9..ffc4e09 100644 --- a/crates/app/src/app/desk.rs +++ b/crates/app/src/app/desk.rs @@ -10,7 +10,7 @@ use neumann_control_core::settings::{GroupSettings, Linkable}; use neumann_control_core::speaker::Command; use serde_json::Value; -use super::{NeumannApp, OpenPanel, DESK_SCROLL_ID}; +use super::{NeumannApp, DESK_SCROLL_ID}; use crate::fader::output_level_range; use crate::groups::{ insertion_slot, layout, linkable_of, linked_path, member_id, new_group, order_of, shared_level, @@ -36,7 +36,7 @@ pub(super) enum DeskAction { Commands(usize, Vec), Group(usize, GroupStripResponse), Master(MasterResponse), - OpenPanel(OpenPanel), + OpenPanel(SpeakerKey), OpenEq(SpeakerKey), System(SystemStripResponse), /// A change of one model's strip layout. @@ -116,14 +116,8 @@ impl NeumannApp { let product = speaker.state.product().to_owned(); actions.push(DeskAction::Layout(product, change)); } - if let Some(anchor) = response.open_panel { - let opened_now = true; - let open = OpenPanel { - key, - anchor, - opened_now, - }; - actions.push(DeskAction::OpenPanel(open)); + if response.open_panel { + actions.push(DeskAction::OpenPanel(key)); } } DeskItem::GroupStrip(group) => { @@ -207,7 +201,7 @@ impl NeumannApp { let everyone: Vec = (0..self.speakers.len()).collect(); self.apply_shared(&everyone, response.level, response.mute, catalog, now); } - DeskAction::OpenPanel(open) => self.panel = Some(open), + DeskAction::OpenPanel(key) => self.open_panel(key), DeskAction::OpenEq(key) => self.open_eq(key), DeskAction::System(response) => self.apply_system_volume(response), DeskAction::Layout(product, change) => self.change_layout(&product, &change, catalog), diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index fede0f7..4e04964 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -258,6 +258,8 @@ pub const CONTROL_HINT: Text = Text::new( /// Faders take no typed value themselves (the strip's readout below does). pub const FADER_HINT: Text = Text::new("Shift: fein", "Shift: fine"); pub const PANEL_TITLE: Text = Text::new("Lautsprecher", "Speaker"); +/// The button under a strip that opens the speaker's panel. +pub const DETAILS: Text = Text::new("Details", "Details"); pub const SETTINGS: Text = Text::new("Einstellungen", "Settings"); pub const COMPACT: Text = Text::new("Kompakt", "Compact"); pub const COMPACT_HINT: Text = Text::new("Strg+K", "Ctrl+K"); @@ -663,6 +665,11 @@ pub fn eq_band(number: usize) -> String { format!("{} {number}", BAND.get()) } +/// The panel window's title: "Lautsprecher · ". +pub fn panel_window_title(speaker: &str) -> String { + format!("{} · {speaker}", PANEL_TITLE.get()) +} + pub fn eq_window_title(speaker: &str) -> String { format!("EQ · {speaker}") } diff --git a/crates/app/src/views/compact.rs b/crates/app/src/views/compact.rs index 9d77d99..7400518 100644 --- a/crates/app/src/views/compact.rs +++ b/crates/app/src/views/compact.rs @@ -5,8 +5,8 @@ use std::ops::RangeInclusive; use eframe::egui::{ - self, vec2, Align, Button, Color32, CornerRadius, Layout, Pos2, RichText, Role, Sense, Stroke, - Ui, WidgetInfo, + self, vec2, Align, Button, Color32, CornerRadius, Layout, RichText, Role, Sense, Stroke, Ui, + WidgetInfo, }; use neumann_control_core::catalog::Catalog; use neumann_control_core::settings::{DeskSettings, GroupSettings}; @@ -41,8 +41,8 @@ const MESSAGE_MARK: &str = "!"; pub struct SpeakerRowResponse { /// The writes the user asked for that the write policy allowed. pub commands: Vec, - /// Where the device panel opens when the name was clicked. - pub open_panel: Option, + /// The name was clicked: the speaker's panel opens. + pub open_panel: bool, } pub fn speaker( diff --git a/crates/app/src/views/panel.rs b/crates/app/src/views/panel.rs index a12b3f4..b196197 100644 --- a/crates/app/src/views/panel.rs +++ b/crates/app/src/views/panel.rs @@ -1,7 +1,10 @@ -//! The device panel: every setting of one speaker, opened from its strip and floating beside -//! it. It closes on Escape, its close button, or a click outside it. +//! The device panel: every setting of one speaker, in a window of its own that can be moved +//! anywhere, opened from its strip. It closes on Escape, its close button or the window's. -use eframe::egui::{Button, ComboBox, Context, Id, Key, Label, Order, Pos2, RichText, Ui, Window}; +use eframe::egui::{ + self, Button, ComboBox, Context, Id, Key, Label, RichText, ScrollArea, Ui, ViewportBuilder, + ViewportClass, ViewportId, +}; use neumann_control_core::catalog::Catalog; use neumann_control_core::mask::mask_serial; use neumann_control_core::settings::Linkable; @@ -20,10 +23,8 @@ use crate::texts; const PANEL_ID: &str = "device_panel"; const GROUP_CHOICE_ID: &str = "group_choice"; const EDITING_ID: &str = "editing"; -const PANEL_WIDTH: f32 = 600.0; -/// Room kept free below the panel and for its header and footer. -const PANEL_CHROME_HEIGHT: f32 = 120.0; -const PANEL_MIN_HEIGHT: f32 = 160.0; +const PANEL_WINDOW_SIZE: [f32; 2] = [600.0, 760.0]; +const PANEL_WINDOW_MIN_SIZE: [f32; 2] = [420.0, 300.0]; pub struct PanelResponse { /// The writes the user asked for that the write policy allowed. @@ -48,88 +49,85 @@ pub struct GroupChoice<'a> { pub linked: &'a [Linkable], } -/// `opened_now` is true in the frame of the click that opened the panel, which must not count -/// as a click outside. +/// The panel in its own window (`viewport`), one per speaker. pub fn show( context: &Context, + viewport: ViewportId, + speaker: &mut SpeakerState, + catalog: &Catalog, + groups: &GroupChoice, + layout: Option<&StripLayout>, +) -> PanelResponse { + let builder = ViewportBuilder::default() + .with_title(texts::panel_window_title(&speaker.display_name())) + .with_inner_size(PANEL_WINDOW_SIZE) + .with_min_inner_size(PANEL_WINDOW_MIN_SIZE); + context.show_viewport_immediate(viewport, builder, |ui, class| { + // Several panels can be open; their widgets must not share state. + let mut response = ui + .push_id(viewport, |ui| match class { + ViewportClass::EmbeddedWindow => contents(ui, speaker, catalog, groups, layout), + _ => { + egui::CentralPanel::default() + .show(ui, |ui| contents(ui, speaker, catalog, groups, layout)) + .inner + } + }) + .inner; + // Escape in a field cancels the edit; only Escape with nothing being edited closes. egui + // drops the focus at the start of the Escape frame, so the last frame's state decides. + let editing_id = Id::unique(PANEL_ID).with(viewport).with(EDITING_ID); + let editing = ui.data(|data| data.get_temp::(editing_id).unwrap_or(false)); + let escape = !editing && ui.input(|input| input.key_pressed(Key::Escape)); + let editing_now = ui.memory(|memory| memory.focused().is_some()); + ui.data_mut(|data| data.insert_temp(editing_id, editing_now)); + let close_requested = ui.input(|input| input.viewport().close_requested()); + response.close |= escape || close_requested; + response + }) +} + +/// Everything the panel shows, scrolling when the window is shorter. +fn contents( + ui: &mut Ui, speaker: &mut SpeakerState, catalog: &Catalog, - anchor: Pos2, - opened_now: bool, groups: &GroupChoice, layout: Option<&StripLayout>, ) -> PanelResponse { - let mut close = false; - let mut remove = false; - let mut group = None; - let mut copy_to_group = None; - let mut open_eq = false; - let mut save_preset = false; - let mut load_preset = false; let has_eq = has_eq(catalog, speaker); - // Escape in a field cancels the edit; only Escape with nothing being edited closes. egui - // drops the focus at the start of the Escape frame, so the last frame's state decides. - let editing_id = Id::unique(PANEL_ID).with(EDITING_ID); - let editing = context.data(|data| data.get_temp::(editing_id).unwrap_or(false)); - // As tall as the window allows below the anchor, so rows are not hidden needlessly. - let room = context.content_rect().bottom() - anchor.y - PANEL_CHROME_HEIGHT; - let max_height = room.max(PANEL_MIN_HEIGHT); - let window = Window::new(texts::PANEL_TITLE) - .id(Id::unique(PANEL_ID)) - .title_bar(false) - .collapsible(false) - .resizable(false) - .fixed_pos(anchor) - .min_width(PANEL_WIDTH) - .max_width(PANEL_WIDTH) - .max_height(max_height) - .default_height(max_height) - .vscroll(true) - .show(context, |ui| { - close = header(ui, speaker); - group = group_choice(ui, groups); - copy_to_group = copy_buttons(ui, groups); + ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + let close = header(ui, speaker); + let group = group_choice(ui, groups); + let copy_to_group = copy_buttons(ui, groups); let details = details::show(ui, speaker, catalog, layout); ui.separator(); trends::show(ui, &trends_of(ui, speaker, catalog)); ui.separator(); - ui.horizontal(|ui| { - save_preset = ui.button(texts::SAVE_PRESET).clicked(); - load_preset = ui.button(texts::LOAD_PRESET).clicked(); - }); - open_eq = has_eq && ui.button(texts::EQ).clicked(); - remove = ui.button(texts::REMOVE).clicked(); - details + let (save_preset, load_preset) = ui + .horizontal(|ui| { + let save = ui.button(texts::SAVE_PRESET).clicked(); + let load = ui.button(texts::LOAD_PRESET).clicked(); + (save, load) + }) + .inner; + let open_eq = has_eq && ui.button(texts::EQ).clicked(); + let remove = ui.button(texts::REMOVE).clicked(); + PanelResponse { + commands: details.commands, + layout: details.layout, + close, + remove, + group, + copy_to_group, + open_eq, + save_preset, + load_preset, + } }) - .expect("the panel window is open"); - let escape = !editing && context.input(|input| input.key_pressed(Key::Escape)); - let editing_now = context.memory(|memory| memory.focused().is_some()); - context.data_mut(|data| data.insert_temp(editing_id, editing_now)); - let pressed = context.input(|input| input.pointer.any_pressed()); - let position = context.input(|input| input.pointer.interact_pos()); - // Popups the panel opens (a combo box's list) lie on layers above it; a click there - // belongs to the panel. - let outside = position.is_some_and(|position| { - let layer = context.layer_id_at(position); - let on_popup = layer.is_some_and(|layer| layer.order > Order::Middle); - layer != Some(window.response.layer_id) && !on_popup - }); - let clicked_outside = pressed && outside; - let details = window.inner; - let (commands, layout) = details.map_or((Vec::new(), None), |details| { - (details.commands, details.layout) - }); - PanelResponse { - commands, - layout, - close: close || escape || (clicked_outside && !opened_now), - remove, - group, - copy_to_group, - open_eq, - save_preset, - load_preset, - } + .inner } /// The speaker's trends; the temperature only where the catalog lists it for the model. diff --git a/crates/app/src/views/strip.rs b/crates/app/src/views/strip.rs index 1d7624a..51a98c4 100644 --- a/crates/app/src/views/strip.rs +++ b/crates/app/src/views/strip.rs @@ -3,8 +3,8 @@ //! and the last message. The strip only reports gestures; `writes` decides what becomes a command. use eframe::egui::{ - self, vec2, Align, Button, Color32, ComboBox, CornerRadius, Label, Layout, Pos2, Rect, - Response, RichText, Role, Sense, Stroke, Ui, Vec2, WidgetInfo, + self, vec2, Align, Button, Color32, ComboBox, CornerRadius, Label, Layout, Rect, Response, + RichText, Role, Sense, Stroke, Ui, Vec2, WidgetInfo, }; use neumann_control_core::catalog::{Catalog, Entry, Kind}; use neumann_control_core::settings::{DeskSettings, StripElement}; @@ -54,8 +54,8 @@ const DROP_ZONE_HEIGHT: f32 = 8.0; pub struct StripResponse { /// The writes the user asked for that the write policy allowed. pub commands: Vec, - /// Where the device panel opens when the name was clicked. - pub open_panel: Option, + /// The name or "Details" was clicked: the speaker's panel opens. + pub open_panel: bool, /// The strip's area, where another strip can be dropped. pub rect: Rect, pub open_eq: bool, @@ -191,7 +191,8 @@ pub fn show( edit_controls(ui, speaker.product(), offers, &mut wishes); } message(ui, speaker, &tokens); - open_panel + let details = ui.button(texts::DETAILS).clicked(); + open_panel || details }) .inner }); @@ -231,10 +232,10 @@ fn grip(ui: &mut Ui, member: &str, tokens: &Tokens) { } } -pub(super) fn name(ui: &mut Ui, speaker: &SpeakerState) -> Option { +/// True when clicked: the name opens the speaker's panel. +pub(super) fn name(ui: &mut Ui, speaker: &SpeakerState) -> bool { let text = RichText::new(speaker.display_name()).strong(); - let response = ui.add(Button::new(text).frame(false).truncate()); - response.clicked().then(|| response.rect.left_bottom()) + ui.add(Button::new(text).frame(false).truncate()).clicked() } pub(super) fn leds(ui: &mut Ui, speaker: &SpeakerState, tokens: &Tokens) { diff --git a/crates/app/tests/app/details.rs b/crates/app/tests/app/details.rs index 3e5a36d..a57a3b6 100644 --- a/crates/app/tests/app/details.rs +++ b/crates/app/tests/app/details.rs @@ -676,7 +676,7 @@ const LEVEL_POINTER: &str = "/audio/out/level"; #[test] fn a_settings_preset_is_saved_and_written_back_as_one_entry() { let (simulator, directory, mut harness) = speaker_with_panel(SimModel::Kh120Ii); - harness.get_by_label(texts::SAVE_PRESET.get()).click(); + click_in_panel(&mut harness, texts::SAVE_PRESET.get()); harness.run_steps(3); harness .get_by_role_and_label(Role::TextInput, texts::PRESET_NAME.get()) @@ -712,7 +712,7 @@ fn a_settings_preset_is_saved_and_written_back_as_one_entry() { if harness.query_by_label(texts::LOAD_PRESET.get()).is_none() { open_panel(&mut harness, device_name(SimModel::Kh120Ii)); } - harness.get_by_label(texts::LOAD_PRESET.get()).click(); + click_in_panel(&mut harness, texts::LOAD_PRESET.get()); harness.run_steps(3); harness .get_by_label(&texts::preset_entry("Evening", "KH 120 II")) diff --git a/crates/app/tests/app/eq.rs b/crates/app/tests/app/eq.rs index 6b45d18..0c3f3fd 100644 --- a/crates/app/tests/app/eq.rs +++ b/crates/app/tests/app/eq.rs @@ -54,12 +54,17 @@ fn open_eq_with( } /// The strip has an "EQ" button too; the panel's lies below it, at the panel's foot. +/// The panel's EQ button, the lowest one (the strip has one too), at the panel's foot. fn click_panel_eq(harness: &mut Harness<'static, NeumannApp>) { - let lowest = harness - .query_all_by_role_and_label(Role::Button, texts::EQ.get()) - .max_by(|a, b| a.rect().center().y.total_cmp(&b.rect().center().y)) - .expect("an EQ button"); - lowest.click(); + fn lowest<'h>(harness: &'h Harness<'static, NeumannApp>) -> egui_kittest::Node<'h> { + harness + .query_all_by_role_and_label(Role::Button, texts::EQ.get()) + .max_by(|a, b| a.rect().center().y.total_cmp(&b.rect().center().y)) + .expect("an EQ button") + } + lowest(harness).scroll_to_me(); + harness.run_steps(PANEL_SCROLL_FRAMES); + lowest(harness).click(); } fn drag(harness: &mut Harness<'static, NeumannApp>, from: egui::Pos2, by: egui::Vec2) { diff --git a/crates/app/tests/app/groups.rs b/crates/app/tests/app/groups.rs index 5609b05..c6b1a77 100644 --- a/crates/app/tests/app/groups.rs +++ b/crates/app/tests/app/groups.rs @@ -255,7 +255,7 @@ fn order_and_group_saved_by_address_follow_the_speaker_once_it_connects() { fn removing_a_speaker_also_forgets_its_group() { let (_kh120, _kh750, directory, mut harness) = grouped(Vec::new()); open_panel(&mut harness, KH120_DEVICE_NAME); - harness.get_by_label(texts::REMOVE.get()).click(); + click_in_panel(&mut harness, texts::REMOVE.get()); wait_for_label(&mut harness, texts::REMOVE_TITLE.get()); harness .query_all_by_label(texts::REMOVE.get()) diff --git a/crates/app/tests/app/speakers.rs b/crates/app/tests/app/speakers.rs index 12bbd44..ede6b07 100644 --- a/crates/app/tests/app/speakers.rs +++ b/crates/app/tests/app/speakers.rs @@ -33,7 +33,6 @@ fn kh750_found(simulator: &Simulator) -> Found { announced(simulator, KH750_NAME, "KH 750", KH750_SERIAL) } -const SCROLL_FRAMES: usize = 4; /// Longer than the test meter interval (100 ms), so a poll is waiting for its reply. const SILENCE_SETTLE: Duration = Duration::from_millis(300); /// Far below the 3 s a joined worker could take, far above two frames. @@ -336,7 +335,7 @@ fn add_address_names_an_address_already_listed() { /// Opens the device panel of the only speaker and asks to remove it. fn ask_to_remove(harness: &mut Harness<'static, NeumannApp>) { open_panel(harness, KH120_DEVICE_NAME); - harness.get_by_label(texts::REMOVE.get()).click(); + click_in_panel(harness, texts::REMOVE.get()); wait_for_label(harness, texts::REMOVE_TITLE.get()); } @@ -459,12 +458,8 @@ fn remove_named(harness: &mut Harness<'static, NeumannApp>, name: &str) { confirm_remove(harness); } -/// The KH 750's panel is taller than the test window; "Remove" sits at its foot. fn ask_in_open_panel(harness: &mut Harness<'static, NeumannApp>) { - harness.get_by_label(texts::REMOVE.get()).scroll_to_me(); - // Scrolling is animated. - harness.run_steps(SCROLL_FRAMES); - harness.get_by_label(texts::REMOVE.get()).click(); + click_in_panel(harness, texts::REMOVE.get()); wait_for_label(harness, texts::REMOVE_TITLE.get()); } diff --git a/crates/app/tests/app/support/mod.rs b/crates/app/tests/app/support/mod.rs index 28f99fc..ab4a556 100644 --- a/crates/app/tests/app/support/mod.rs +++ b/crates/app/tests/app/support/mod.rs @@ -404,6 +404,15 @@ pub fn device_name(model: SimModel) -> &'static str { } /// Clicks the strip's name and waits until the device panel shows its settings. +/// The panel scrolls in its window; a button at its foot is scrolled to (animated) first. +pub fn click_in_panel(harness: &mut Harness<'static, NeumannApp>, label: &str) { + harness.get_by_label(label).scroll_to_me(); + harness.run_steps(PANEL_SCROLL_FRAMES); + harness.get_by_label(label).click(); +} + +pub const PANEL_SCROLL_FRAMES: usize = 4; + pub fn open_panel(harness: &mut Harness<'static, NeumannApp>, name: &str) { // The panel takes its size from the window it opens in. harness.set_size(EXPANDED_WINDOW_SIZE.into()); diff --git a/crates/app/tests/app/views.rs b/crates/app/tests/app/views.rs index a881705..8abb104 100644 --- a/crates/app/tests/app/views.rs +++ b/crates/app/tests/app/views.rs @@ -222,6 +222,82 @@ fn panel_opens_from_strip_name_and_closes_on_escape() { assert!(harness.query_by_label(texts::ALL_VALUES.get()).is_none()); } +/// From the panel's close button up to the title bar of its embedded window. +const PANEL_TITLE_ABOVE_CLOSE: f32 = 24.0; +const PANEL_MOVE: f32 = 520.0; + +fn drag_from_to(harness: &mut Harness<'static, NeumannApp>, from: egui::Pos2, to: egui::Pos2) { + harness.hover_at(from); + harness.event(egui::Event::PointerButton { + pos: from, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }); + harness.run_steps(1); + for step in 1..=DRAG_STEPS { + harness.hover_at(from + (to - from) * (step as f32 / DRAG_STEPS as f32)); + harness.run_steps(1); + } + harness.event(egui::Event::PointerButton { + pos: to, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + harness.run_steps(2); +} + +const DRAG_STEPS: usize = 5; + +#[test] +fn the_details_button_opens_the_panel() { + let (_simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); + harness.set_size(EXPANDED_WINDOW_SIZE.into()); + harness.run_steps(2); + harness + .get_by_role_and_label(Role::Button, texts::DETAILS.get()) + .click(); + wait_for_label(&mut harness, texts::ALL_VALUES.get()); +} + +/// Each speaker's panel is a window of its own; opening a second leaves the first open. +#[test] +fn two_panels_stay_open_side_by_side() { + let (_simulators, _directory, mut harness) = desk_of( + &[SimModel::Kh120Ii, SimModel::Kh750], + std::collections::BTreeMap::new(), + ); + open_panel(&mut harness, KH120_DEVICE_NAME); + // In the test window the panel is embedded over the desk: move it off the other strip. + let title = harness.get_by_label(texts::CLOSE.get()).rect().center() + - egui::vec2(0.0, PANEL_TITLE_ABOVE_CLOSE); + drag_from_to(&mut harness, title, title + egui::vec2(PANEL_MOVE, 0.0)); + harness.get_by_label(KH750_DEVICE_NAME).click(); + wait_until(&mut harness, "two panels", |harness| { + harness.query_all_by_label(texts::ALL_VALUES.get()).count() == 2 + }); +} + +/// A window beside the desk stays open while you work on the desk. +#[test] +fn a_click_on_the_desk_keeps_the_panel_open() { + let (_simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); + open_panel(&mut harness, KH120_DEVICE_NAME); + let below_the_strip = egui::pos2(20.0, EXPANDED_WINDOW_SIZE[1] - 20.0); + harness.hover_at(below_the_strip); + for pressed in [true, false] { + harness.event(egui::Event::PointerButton { + pos: below_the_strip, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }); + } + harness.run_steps(2); + harness.get_by_label(texts::ALL_VALUES.get()); +} + #[test] fn panel_closes_when_its_speaker_goes_away() { let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); From 8f1b12521ec7f85c6b1643c5ab407f6e105e49f5 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:37:31 +0200 Subject: [PATCH 245/460] Show delays in meters when chosen A setting shows and takes delays as the distance sound travels in them (343 m/s); the speakers still get samples. One function picks the conversion for every reader. --- README.md | 5 ++- crates/app/src/app.rs | 2 + crates/app/src/display.rs | 51 +++++++++++++++++++++++-- crates/app/src/texts.rs | 8 ++++ crates/app/src/views/details_fields.rs | 6 +-- crates/app/src/views/knob.rs | 4 +- crates/app/src/views/panel.rs | 4 +- crates/app/src/views/settings_dialog.rs | 8 +++- crates/app/tests/app/details.rs | 4 +- crates/app/tests/app/eq.rs | 13 ++----- crates/app/tests/app/groups.rs | 2 +- crates/app/tests/app/speakers.rs | 4 +- crates/app/tests/app/support/mod.rs | 9 +++-- crates/app/tests/app/views.rs | 27 ++++++++++++- crates/core/src/catalog.rs | 9 +++++ crates/core/src/settings.rs | 15 ++++++++ crates/core/tests/catalog.rs | 8 ++++ crates/core/tests/settings.rs | 8 ++-- 18 files changed, 152 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index b4ccc32..c081b2b 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,8 @@ time of the connected ones. below it lets you type one. While you drag, the value stands beside the fader. - **Knobs**: turn them by moving the pointer around the knob (clockwise is more; one and a half turns cover the whole range), with Shift for fine steps, or with the mouse wheel. A double-click - lets you type a value (the delay in ms). While a knob moves, its value stands beside it. + lets you type a value (the delay in ms, or in meters, see below). While a knob moves, its value + stands beside it. - Values are written once: when you let go (only if the value changed), when the wheel stops, or when you press Enter. Escape cancels a drag or a typed value. @@ -306,6 +307,8 @@ recorded. - the language: like Windows, German or English. It changes at once. German shows a decimal comma; typed values take a comma or a point. - the lowest level the meters show: −120, −90 or −60 dB, +- whether delays are shown and typed in milliseconds or in meters, as the distance sound travels + in that time (343 m/s, at 20 °C); the speakers still get samples, - whether response times are shown, - whether the EQ crossovers are protected (on by default), - the EQ display: own curves of all bands, or only of the chosen band and the one under the diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index b236dcc..49ae476 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -18,6 +18,7 @@ use neumann_control_core::mask::mask_host; use neumann_control_core::settings::{self, Language, ManualSpeaker, Settings, WindowGeometry}; use neumann_control_core::speaker::{Command, Event, Events, SpeakerHandle, Target}; +use crate::display; use crate::file_dialog::{FileDialog, NativeFileDialog}; use crate::groups::{delete_group, forget_member, member_id, rename_member, set_membership}; use crate::meter::MeterState; @@ -719,6 +720,7 @@ impl eframe::App for NeumannApp { fn ui(&mut self, ui: &mut Ui, frame: &mut eframe::Frame) { texts::set_language(self.settings.desk.language.resolve(self.system_language)); + display::set_delay_unit(self.settings.desk.delay_unit); wheel::track_burst(ui.ctx()); self.keep_system_meters_moving(ui.ctx()); self.show(ui, frame); diff --git a/crates/app/src/display.rs b/crates/app/src/display.rs index ac65b9e..042693a 100644 --- a/crates/app/src/display.rs +++ b/crates/app/src/display.rs @@ -1,7 +1,10 @@ //! How a value the speaker reports reads on screen: the raw value with its unit, switches as //! on/off, lists joined, and temperatures in whole degrees Celsius. +use std::cell::Cell; + use neumann_control_core::catalog::{Conversion, Entry}; +use neumann_control_core::settings::DelayUnit; use serde_json::Value; use crate::texts; @@ -13,6 +16,26 @@ const LIST_SEPARATOR: &str = ", "; const DEVICE_VERSION_SEPARATOR: char = '_'; const SHOWN_VERSION_SEPARATOR: char = '.'; +thread_local! { + /// Like the language: read on the UI thread only, per thread for parallel UI tests. + static DELAY_UNIT: Cell = const { Cell::new(DelayUnit::Milliseconds) }; +} + +/// The unit every delay of this thread is shown and typed in from now on. +pub fn set_delay_unit(unit: DelayUnit) { + DELAY_UNIT.with(|current| current.set(unit)); +} + +/// The conversion `entry`'s values are shown and typed with: the catalog's, with the delay in +/// the chosen unit. Every reader of a converted value goes through here. +pub fn conversion_of(entry: &Entry) -> Option { + let meters = DELAY_UNIT.with(Cell::get) == DelayUnit::Meters; + match entry.conversion { + Some(Conversion::SamplesToMilliseconds) if meters => Some(Conversion::SamplesToMeters), + conversion => conversion, + } +} + /// The device reports its firmware as `major_minor_patch`; people write it with dots. pub fn firmware_version(reported: &str) -> String { reported.replace( @@ -22,7 +45,7 @@ pub fn firmware_version(reported: &str) -> String { } pub fn shown_value(entry: &Entry, value: &Value) -> String { - if let (Some(conversion), Some(number)) = (entry.conversion, value.as_f64()) { + if let (Some(conversion), Some(number)) = (conversion_of(entry), value.as_f64()) { return converted(conversion, number); } let text = capped(&raw_text(value)); @@ -43,14 +66,16 @@ pub fn capped(text: &str) -> String { } /// In the unit shown. The device reports whole kelvin, so tenths of a degree would only show -/// rounding noise; a delay sample is about 0.02 ms, so it needs two decimals. +/// rounding noise; a delay sample is about 0.02 ms (7 mm), so it needs two decimals. pub fn converted(conversion: Conversion, number: f64) -> String { let shown = conversion.apply(number); let unit = converted_unit(conversion); match conversion { // Adding zero turns the -0 of a slightly negative reading into 0. Conversion::KelvinToCelsius => format!("{} {unit}", shown.round() + 0.0), - Conversion::SamplesToMilliseconds => format!("{} {unit}", texts::two_decimals(shown)), + Conversion::SamplesToMilliseconds | Conversion::SamplesToMeters => { + format!("{} {unit}", texts::two_decimals(shown)) + } } } @@ -59,6 +84,7 @@ pub fn converted_unit(conversion: Conversion) -> &'static str { match conversion { Conversion::KelvinToCelsius => texts::CELSIUS, Conversion::SamplesToMilliseconds => texts::MILLISECONDS, + Conversion::SamplesToMeters => texts::METERS, } } @@ -100,6 +126,25 @@ mod tests { assert_eq!(capped(&exact), exact); } + #[test] + fn delay_is_shown_and_typed_in_meters_when_chosen() { + let delay = entry("audio/out/delay"); + set_delay_unit(DelayUnit::Meters); + let shown = shown_value(delay, &json!(480)); + let typed = crate::gesture::typed_value("3,43", conversion_of(delay)); + set_delay_unit(DelayUnit::Milliseconds); + assert_eq!(shown, "3,43 m"); + assert!((typed.unwrap() - 480.0).abs() < 1e-9); + } + + #[test] + fn only_the_delay_follows_the_delay_unit() { + set_delay_unit(DelayUnit::Meters); + let temperature = conversion_of(entry("device/temperature")); + set_delay_unit(DelayUnit::Milliseconds); + assert_eq!(temperature, Some(Conversion::KelvinToCelsius)); + } + #[test] fn delay_is_shown_in_milliseconds() { let delay = entry("audio/out/delay"); diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 4e04964..5e96d31 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -275,6 +275,13 @@ pub const LANGUAGE_GERMAN: &str = "Deutsch"; pub const LANGUAGE_ENGLISH: &str = "English"; pub const METER_RANGE: Text = Text::new("Pegelanzeige ab", "Meters from"); pub const SHOW_RESPONSE_TIMES: Text = Text::new("Antwortzeiten anzeigen", "Show response times"); +pub const DELAY_SHOWN_IN: Text = Text::new("Verzögerung anzeigen in", "Show delays in"); +pub const DELAY_IN_MILLISECONDS: Text = Text::new("Millisekunden", "milliseconds"); +/// 343 m/s, the speed of sound at 20 °C, as `catalog` converts with. +pub const DELAY_IN_METERS: Text = Text::new( + "Metern (Schallweg bei 20 °C)", + "meters (sound path at 20 °C)", +); pub const INPUT_METERS: Text = Text::new("Eingangspegel", "Input level"); pub const OUTPUT_METERS: Text = Text::new("Ausgangspegel", "Output level"); pub const METER_INPUT_SHORT: Text = Text::new("Eing.", "In"); @@ -309,6 +316,7 @@ pub const ON: Text = Text::new("an", "on"); pub const OFF: Text = Text::new("aus", "off"); pub const CELSIUS: &str = "°C"; pub const MILLISECONDS: &str = "ms"; +pub const METERS: &str = "m"; pub const ALL_VALUES_FILTER_LABEL: Text = Text::new("Pfad enthält", "Path contains"); pub const ALL_VALUES_RELOAD: Text = Text::new("Neu einlesen", "Read again"); diff --git a/crates/app/src/views/details_fields.rs b/crates/app/src/views/details_fields.rs index dbc7027..3597396 100644 --- a/crates/app/src/views/details_fields.rs +++ b/crates/app/src/views/details_fields.rs @@ -7,7 +7,7 @@ use neumann_control_core::speaker::Command; use serde_json::Value; use super::number_field::{self, NumberField}; -use crate::display::capped; +use crate::display::{capped, conversion_of}; use crate::state::SpeakerState; use crate::texts; use crate::writes::{self, Intent}; @@ -62,7 +62,7 @@ impl Fields<'_> { step: entry.step, drag_speed: entry.step.unwrap_or(UNSTEPPED_DRAG_SPEED), unit: entry.unit.as_deref(), - conversion: entry.conversion, + conversion: conversion_of(entry), enabled: self.enabled(entry, device.is_some()), labelled_by: Some(label.id), }; @@ -112,7 +112,7 @@ impl Fields<'_> { step: entry.step, drag_speed: entry.step.unwrap_or(UNSTEPPED_DRAG_SPEED), unit: entry.unit.as_deref(), - conversion: entry.conversion, + conversion: conversion_of(entry), enabled: self.enabled(entry, device.is_some()), labelled_by: Some(label.id), }; diff --git a/crates/app/src/views/knob.rs b/crates/app/src/views/knob.rs index 74c1537..000e115 100644 --- a/crates/app/src/views/knob.rs +++ b/crates/app/src/views/knob.rs @@ -16,7 +16,7 @@ use serde_json::Value; use super::fader::{drag_aborted, value_beside, FINE_DIVISOR}; use super::wheel::notches_for; -use crate::display::shown_value; +use crate::display::{conversion_of, shown_value}; use crate::gesture::{bounded_move, released, typed_value, GestureState}; use crate::state::SpeakerState; use crate::texts; @@ -83,7 +83,7 @@ pub fn for_entry( enabled: writes::can_write(speaker, catalog, &entry.path), diameter, format: &format, - conversion: entry.conversion, + conversion: conversion_of(entry), }; let mut state = speaker.knobs.remove(&entry.path).unwrap_or_default(); let id = ui.scope_id().with(entry.path.to_string()); diff --git a/crates/app/src/views/panel.rs b/crates/app/src/views/panel.rs index b196197..e53574a 100644 --- a/crates/app/src/views/panel.rs +++ b/crates/app/src/views/panel.rs @@ -12,7 +12,7 @@ use neumann_control_core::speaker::Command; use super::details; use super::trends::{self, TemperatureTrend, Trends}; -use crate::display::firmware_version; +use crate::display::{conversion_of, firmware_version}; use crate::eq::has_eq; use crate::groups::linkable_label; use crate::paths; @@ -140,7 +140,7 @@ fn trends_of<'a>(ui: &Ui, speaker: &'a SpeakerState, catalog: &'a Catalog) -> Tr .and_then(|entry| { Some(TemperatureTrend { trend: &speaker.temperature, - conversion: entry.conversion?, + conversion: conversion_of(entry)?, label: entry.label(texts::language()), }) }); diff --git a/crates/app/src/views/settings_dialog.rs b/crates/app/src/views/settings_dialog.rs index 6c1ba3f..39528a0 100644 --- a/crates/app/src/views/settings_dialog.rs +++ b/crates/app/src/views/settings_dialog.rs @@ -5,7 +5,7 @@ use eframe::egui::color_picker::{color_edit_button_srgba, Alpha}; use eframe::egui::{ComboBox, Grid, Id, Modal, ScrollArea, Theme, Ui}; use neumann_control_core::settings::{ - BandCurves, CrossoverGuard, EqDisplaySettings, LanguageChoice, MeterFloor, Settings, + BandCurves, CrossoverGuard, DelayUnit, EqDisplaySettings, LanguageChoice, MeterFloor, Settings, SystemAudioSettings, ThemeChoice, }; @@ -78,6 +78,12 @@ fn contents( ui.radio_value(&mut desk.meter_floor, floor, texts::meter_floor(floor.db())); } }); + ui.label(texts::DELAY_SHOWN_IN); + ui.horizontal(|ui| { + let unit = &mut desk.delay_unit; + ui.radio_value(unit, DelayUnit::Milliseconds, texts::DELAY_IN_MILLISECONDS); + ui.radio_value(unit, DelayUnit::Meters, texts::DELAY_IN_METERS); + }); ui.checkbox(&mut desk.show_response_times, texts::SHOW_RESPONSE_TIMES); let mut protected = desk.crossovers == CrossoverGuard::Protected; if ui diff --git a/crates/app/tests/app/details.rs b/crates/app/tests/app/details.rs index a57a3b6..2d07010 100644 --- a/crates/app/tests/app/details.rs +++ b/crates/app/tests/app/details.rs @@ -676,7 +676,7 @@ const LEVEL_POINTER: &str = "/audio/out/level"; #[test] fn a_settings_preset_is_saved_and_written_back_as_one_entry() { let (simulator, directory, mut harness) = speaker_with_panel(SimModel::Kh120Ii); - click_in_panel(&mut harness, texts::SAVE_PRESET.get()); + scroll_to_and_click(&mut harness, texts::SAVE_PRESET.get()); harness.run_steps(3); harness .get_by_role_and_label(Role::TextInput, texts::PRESET_NAME.get()) @@ -712,7 +712,7 @@ fn a_settings_preset_is_saved_and_written_back_as_one_entry() { if harness.query_by_label(texts::LOAD_PRESET.get()).is_none() { open_panel(&mut harness, device_name(SimModel::Kh120Ii)); } - click_in_panel(&mut harness, texts::LOAD_PRESET.get()); + scroll_to_and_click(&mut harness, texts::LOAD_PRESET.get()); harness.run_steps(3); harness .get_by_label(&texts::preset_entry("Evening", "KH 120 II")) diff --git a/crates/app/tests/app/eq.rs b/crates/app/tests/app/eq.rs index 0c3f3fd..b426c7b 100644 --- a/crates/app/tests/app/eq.rs +++ b/crates/app/tests/app/eq.rs @@ -63,7 +63,7 @@ fn click_panel_eq(harness: &mut Harness<'static, NeumannApp>) { .expect("an EQ button") } lowest(harness).scroll_to_me(); - harness.run_steps(PANEL_SCROLL_FRAMES); + harness.run_steps(SCROLL_FRAMES); lowest(harness).click(); } @@ -648,16 +648,9 @@ fn the_eq_display_is_chosen_and_reset_in_the_settings() { wait_for_level(&mut harness, 100.0); harness.get_by_label(texts::SETTINGS.get()).click(); harness.run_steps(4); - harness - .get_by_label(texts::BAND_CURVES_CHOSEN.get()) - .click(); + scroll_to_and_click(&mut harness, texts::BAND_CURVES_CHOSEN.get()); harness.run_steps(2); - // The dialog scrolls; the button lies below what it shows at first. - harness - .get_by_label(texts::DEFAULT_COLORS.get()) - .scroll_to_me(); - harness.run_steps(3); - harness.get_by_label(texts::DEFAULT_COLORS.get()).click(); + scroll_to_and_click(&mut harness, texts::DEFAULT_COLORS.get()); harness.run_steps(2); let stored = neumann_control_core::settings::load(&path).eq_display; assert_eq!( diff --git a/crates/app/tests/app/groups.rs b/crates/app/tests/app/groups.rs index c6b1a77..44a1a1c 100644 --- a/crates/app/tests/app/groups.rs +++ b/crates/app/tests/app/groups.rs @@ -255,7 +255,7 @@ fn order_and_group_saved_by_address_follow_the_speaker_once_it_connects() { fn removing_a_speaker_also_forgets_its_group() { let (_kh120, _kh750, directory, mut harness) = grouped(Vec::new()); open_panel(&mut harness, KH120_DEVICE_NAME); - click_in_panel(&mut harness, texts::REMOVE.get()); + scroll_to_and_click(&mut harness, texts::REMOVE.get()); wait_for_label(&mut harness, texts::REMOVE_TITLE.get()); harness .query_all_by_label(texts::REMOVE.get()) diff --git a/crates/app/tests/app/speakers.rs b/crates/app/tests/app/speakers.rs index ede6b07..29097de 100644 --- a/crates/app/tests/app/speakers.rs +++ b/crates/app/tests/app/speakers.rs @@ -335,7 +335,7 @@ fn add_address_names_an_address_already_listed() { /// Opens the device panel of the only speaker and asks to remove it. fn ask_to_remove(harness: &mut Harness<'static, NeumannApp>) { open_panel(harness, KH120_DEVICE_NAME); - click_in_panel(harness, texts::REMOVE.get()); + scroll_to_and_click(harness, texts::REMOVE.get()); wait_for_label(harness, texts::REMOVE_TITLE.get()); } @@ -459,7 +459,7 @@ fn remove_named(harness: &mut Harness<'static, NeumannApp>, name: &str) { } fn ask_in_open_panel(harness: &mut Harness<'static, NeumannApp>) { - click_in_panel(harness, texts::REMOVE.get()); + scroll_to_and_click(harness, texts::REMOVE.get()); wait_for_label(harness, texts::REMOVE_TITLE.get()); } diff --git a/crates/app/tests/app/support/mod.rs b/crates/app/tests/app/support/mod.rs index ab4a556..a80c186 100644 --- a/crates/app/tests/app/support/mod.rs +++ b/crates/app/tests/app/support/mod.rs @@ -404,14 +404,15 @@ pub fn device_name(model: SimModel) -> &'static str { } /// Clicks the strip's name and waits until the device panel shows its settings. -/// The panel scrolls in its window; a button at its foot is scrolled to (animated) first. -pub fn click_in_panel(harness: &mut Harness<'static, NeumannApp>, label: &str) { +/// Panels and dialogs scroll; a widget below what they show at first is scrolled to (the +/// scroll is animated) before the click. +pub fn scroll_to_and_click(harness: &mut Harness<'static, NeumannApp>, label: &str) { harness.get_by_label(label).scroll_to_me(); - harness.run_steps(PANEL_SCROLL_FRAMES); + harness.run_steps(SCROLL_FRAMES); harness.get_by_label(label).click(); } -pub const PANEL_SCROLL_FRAMES: usize = 4; +pub const SCROLL_FRAMES: usize = 4; pub fn open_panel(harness: &mut Harness<'static, NeumannApp>, name: &str) { // The panel takes its size from the window it opens in. diff --git a/crates/app/tests/app/views.rs b/crates/app/tests/app/views.rs index 8abb104..9f9f674 100644 --- a/crates/app/tests/app/views.rs +++ b/crates/app/tests/app/views.rs @@ -619,11 +619,36 @@ fn settings_dialog_switches_theme_and_saves_on_change() { saved.desk.theme, neumann_control_core::settings::ThemeChoice::Light ); - harness.get_by_label(texts::CLOSE.get()).click(); + scroll_to_and_click(&mut harness, texts::CLOSE.get()); harness.run_steps(2); assert!(harness.query_by_label(texts::THEME.get()).is_none()); } +/// The delay as the distance sound travels: shown at once on the strip's knob and saved. +#[test] +fn the_delay_unit_switches_to_meters_and_is_saved() { + let (_simulator, directory, mut harness) = + connected_speaker_with(SimModel::Kh120Ii, |simulator| { + simulator.set_value("audio/out/delay", serde_json::json!(480)); + }); + harness.get_by_label("10,00 ms"); + open_settings(&mut harness); + scroll_to_and_click(&mut harness, texts::DELAY_IN_METERS.get()); + harness.run_steps(2); + scroll_to_and_click(&mut harness, texts::CLOSE.get()); + harness.run_steps(2); + assert!(harness.query_by_label(texts::THEME.get()).is_none()); + harness.get_by_label("3,43 m"); + let path = directory + .path() + .join(neumann_control_core::settings::SETTINGS_FILE); + let saved = neumann_control_core::settings::load(&path); + assert_eq!( + saved.desk.delay_unit, + neumann_control_core::settings::DelayUnit::Meters + ); +} + #[test] fn english_applies_at_once_and_is_saved() { let (_simulator, directory, mut harness) = connected_speaker(SimModel::Kh120Ii); diff --git a/crates/core/src/catalog.rs b/crates/core/src/catalog.rs index f32583e..1f7fb88 100644 --- a/crates/core/src/catalog.rs +++ b/crates/core/src/catalog.rs @@ -18,6 +18,11 @@ const STEP_TOLERANCE: f64 = 1e-9; const KELVIN_OFFSET: f64 = 273.15; /// The speakers count delay in samples at 48 kHz. const SAMPLES_PER_MILLISECOND: f64 = 48.0; +/// Speed of sound in air at 20 °C. +const SPEED_OF_SOUND_METERS_PER_SECOND: f64 = 343.0; +const MILLISECONDS_PER_SECOND: f64 = 1000.0; +const SAMPLES_PER_METER: f64 = + SAMPLES_PER_MILLISECOND * MILLISECONDS_PER_SECOND / SPEED_OF_SOUND_METERS_PER_SECOND; #[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] #[serde(rename_all = "snake_case")] @@ -98,6 +103,8 @@ pub enum ModelClass { pub enum Conversion { KelvinToCelsius, SamplesToMilliseconds, + /// The distance sound travels in the delay; chosen in the settings instead of milliseconds. + SamplesToMeters, } impl Conversion { @@ -106,6 +113,7 @@ impl Conversion { match self { Conversion::KelvinToCelsius => value - KELVIN_OFFSET, Conversion::SamplesToMilliseconds => value / SAMPLES_PER_MILLISECOND, + Conversion::SamplesToMeters => value / SAMPLES_PER_METER, } } @@ -114,6 +122,7 @@ impl Conversion { match self { Conversion::KelvinToCelsius => value + KELVIN_OFFSET, Conversion::SamplesToMilliseconds => value * SAMPLES_PER_MILLISECOND, + Conversion::SamplesToMeters => value * SAMPLES_PER_METER, } } } diff --git a/crates/core/src/settings.rs b/crates/core/src/settings.rs index d664cbe..68ddd0f 100644 --- a/crates/core/src/settings.rs +++ b/crates/core/src/settings.rs @@ -173,6 +173,8 @@ pub struct DeskSettings { pub compact: bool, #[serde(deserialize_with = "or_default")] pub crossovers: CrossoverGuard, + #[serde(deserialize_with = "or_default")] + pub delay_unit: DelayUnit, /// Per reported product name; a model without an entry shows the default strip. #[serde( deserialize_with = "readable_layouts", @@ -229,6 +231,7 @@ impl Default for DeskSettings { language: LanguageChoice::System, compact: false, crossovers: CrossoverGuard::Protected, + delay_unit: DelayUnit::Milliseconds, strip_layouts: BTreeMap::new(), } } @@ -282,6 +285,18 @@ impl LanguageChoice { } } +/// How delays are shown and typed: in time, or as the distance sound travels in it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum DelayUnit { + #[default] + Milliseconds, + Meters, +} + +impl DelayUnit { + pub const ALL: [DelayUnit; 2] = [DelayUnit::Milliseconds, DelayUnit::Meters]; +} + /// The lowest level a meter shows; quiet room volume reads about -90 dB on the inputs. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] pub enum MeterFloor { diff --git a/crates/core/tests/catalog.rs b/crates/core/tests/catalog.rs index 7feb51f..e0f3337 100644 --- a/crates/core/tests/catalog.rs +++ b/crates/core/tests/catalog.rs @@ -464,11 +464,19 @@ fn a_read_only_kind_listing_writable_models_is_an_error() { assert_eq!(invalid_reason(&display), None); } +/// Sound travels 343 m/s at 20 °C: 480 samples (10 ms at 48 kHz) are 3.43 m of distance. +#[test] +fn a_delay_reads_as_the_distance_sound_travels_meanwhile() { + let metres = Conversion::SamplesToMeters.apply(480.0); + assert!((metres - 3.43).abs() < 1e-9, "{metres}"); +} + #[test] fn invert_takes_a_shown_value_back_to_the_devices_unit() { for (conversion, device) in [ (Conversion::KelvinToCelsius, 313.0), (Conversion::SamplesToMilliseconds, 96.0), + (Conversion::SamplesToMeters, 96.0), ] { let shown = conversion.apply(device); assert!( diff --git a/crates/core/tests/settings.rs b/crates/core/tests/settings.rs index 60ea42c..901e0e7 100644 --- a/crates/core/tests/settings.rs +++ b/crates/core/tests/settings.rs @@ -1,6 +1,6 @@ use neumann_control_core::settings::{ - load, save, BandCurves, CrossoverGuard, DeskSettings, EqPreset, GroupSettings, Language, - LanguageChoice, Linkable, ManualSpeaker, MeterFloor, Settings, SettingsPreset, + load, save, BandCurves, CrossoverGuard, DelayUnit, DeskSettings, EqPreset, GroupSettings, + Language, LanguageChoice, Linkable, ManualSpeaker, MeterFloor, Settings, SettingsPreset, StoredStripLayout, StripElement, SystemAudioSettings, ThemeChoice, WindowGeometry, SETTINGS_FILE, }; @@ -218,7 +218,8 @@ fn a_value_this_version_does_not_know_costs_only_that_value() { // a group without a colour, a speaker entry without a port. let text = r#"{ "manual": [{"address": "fe80::1%7", "port": 45, "serial": "SIM1"}, {"address": "fe80::2%7"}], - "desk": {"language": "French", "meter_floor": "Minus200", "show_response_times": false}, + "desk": {"language": "French", "meter_floor": "Minus200", "show_response_times": false, + "delay_unit": "Furlongs"}, "groups": [ {"name": "Front", "members": ["serial:SIM1"], "linked": ["Level", "Delay"]}, {"name": "Sub", "color": 1, "members": []} @@ -230,6 +231,7 @@ fn a_value_this_version_does_not_know_costs_only_that_value() { assert_eq!(loaded.manual.len(), 1, "the readable speaker stays"); assert_eq!(loaded.desk.language, LanguageChoice::System); assert_eq!(loaded.desk.meter_floor, MeterFloor::Minus120); + assert_eq!(loaded.desk.delay_unit, DelayUnit::Milliseconds); assert!( !loaded.desk.show_response_times, "a readable neighbour stays" From 084d60aa2ece97b4bbc2ebf6b25b015e0e764c0c Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:38:51 +0200 Subject: [PATCH 246/460] Note delays in meters in the changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bf764c..bcae672 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- Delays in meters: **Settings** shows and takes every delay as the distance sound travels in it + (343 m/s, at 20 °C) instead of in milliseconds. - An EQ window per speaker: a computed response graph of every EQ and their sum, band points to drag, and a table of the bands. Each change writes one band only and can be undone. - A history of every setting the app wrote in this session (**History** in the toolbar), with From 984c05e23b133714355788c3ee2062af2e425b5e Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:41:42 +0200 Subject: [PATCH 247/460] Let the scan dialog scan again A speaker still starting up was missed by the first scan; a new one now joins the list without losing the checks made so far. --- CHANGELOG.md | 2 + README.md | 3 +- crates/app/src/app.rs | 13 +++-- crates/app/src/scan_choice.rs | 79 +++++++++++++++++++++++++++++ crates/app/src/texts.rs | 1 + crates/app/src/views/scan_choice.rs | 22 ++++++-- crates/app/tests/app/speakers.rs | 16 ++++++ 7 files changed, 128 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bcae672..aa77206 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- **Scan again** in the dialog of found speakers: scans once more and adds what it finds, keeping + the list and the checks so far. - Delays in meters: **Settings** shows and takes every delay as the distance sound travels in it (343 m/s, at 20 °C) instead of in milliseconds. - An EQ window per speaker: a computed response graph of every EQ and their sum, band points to diff --git a/README.md b/README.md index c081b2b..aada2d5 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,8 @@ delete the folder. 1. Start `neumann-control.exe` and press **Scan**. The scan takes 4 seconds. The dialog "Speakers found" lists every speaker found with its name, model and masked address. Check the ones you want and press **Add**; each gets a strip. Speakers that already have one are marked - "already added". **Cancel** adds nothing. + "already added". **Scan again** looks once more, for a speaker that was still starting up; + the list and your checks stay, new speakers are added below. **Cancel** adds nothing. 2. If the scan finds nothing (for example because mDNS is blocked), press **+ Address** and enter the speaker's IPv6 address. A link-local address (`fe80::...`) needs the index of the network adapter after a `%`, for example `fe80::1%7`. Find the index in PowerShell: diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index 49ae476..64c7441 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -349,8 +349,8 @@ impl NeumannApp { self.save_settings(); } self.sync_targets(); - if self.scan_choice.is_some() { - self.scan_choice = Some(ScanChoice::offer(&found, &self.shown_speakers())); + if let Some(choice) = &self.scan_choice { + self.scan_choice = Some(choice.again(&found, &self.shown_speakers())); } } @@ -670,8 +670,15 @@ impl NeumannApp { let Some(choice) = &mut self.scan_choice else { return; }; - match scan_choice_view::show(ui, choice) { + match scan_choice_view::show(ui, choice, self.scan.is_some()) { ScanChoiceAction::None => {} + ScanChoiceAction::ScanAgain => { + if self.scan.is_none() { + self.scan = Some(self.spawner.scan()); + } + // A silent scan that runs already now reports to the dialog, failures too. + self.scan_silent = false; + } ScanChoiceAction::Toggle(index) => choice.toggle(index), ScanChoiceAction::Cancel => self.scan_choice = None, ScanChoiceAction::Add => { diff --git a/crates/app/src/scan_choice.rs b/crates/app/src/scan_choice.rs index fd098eb..5009dbd 100644 --- a/crates/app/src/scan_choice.rs +++ b/crates/app/src/scan_choice.rs @@ -39,6 +39,30 @@ impl ScanChoice { ScanChoice::Offered(offers) } + /// After "Erneut suchen": the rows offered so far stay, with their checks; one found again + /// takes what the new scan says (a new address), and newly found speakers come after. + pub fn again(&self, found: &[Found], shown: &[(SpeakerKey, SocketAddrV6)]) -> ScanChoice { + let mut offers = self.offers().to_vec(); + for found in found { + match offers + .iter_mut() + .find(|offer| same_speaker(&offer.found, found)) + { + Some(offer) => offer.found = found.clone(), + None => offers.push(Offer { + found: found.clone(), + known: false, + checked: false, + }), + } + } + for offer in &mut offers { + offer.known = is_shown(&offer.found, shown); + offer.checked &= !offer.known; + } + ScanChoice::Offered(offers) + } + /// Flips the check of one row; rows of speakers already shown stay unchecked. pub fn toggle(&mut self, index: usize) { let ScanChoice::Offered(offers) = self else { @@ -73,6 +97,14 @@ impl ScanChoice { } } +/// By serial when both announce one, else by address. +fn same_speaker(one: &Found, other: &Found) -> bool { + match (&one.serial, &other.serial) { + (Some(one), Some(other)) => one == other, + _ => one.address == other.address, + } +} + impl Offer { /// The announced name, else the model, else the masked address; announced names can be /// empty. @@ -175,6 +207,53 @@ mod tests { ); } + /// "Erneut suchen": what the first scan offered stays with its checks, a speaker found at a + /// new address keeps its check there, and what is new comes after. + #[test] + fn a_new_scan_keeps_the_checks_and_adds_what_it_finds() { + let mut first = choice(); + first.toggle(1); + const REAR_SERIAL: &str = "SIM0007501"; + const MOVED_TEXT: &str = "fe80::2a36:38ff:fe12:3458%7"; + let again = first.again( + &[ + found("Sub", NEW_SERIAL, MOVED_TEXT), + found("Rear", REAR_SERIAL, NEW_ADDRESS_TEXT), + ], + &[( + SpeakerKey::Serial(KNOWN_SERIAL.to_owned()), + address(KNOWN_ADDRESS_TEXT), + )], + ); + let ScanChoice::Offered(offers) = &again else { + panic!("a finished scan offers its speakers"); + }; + let rows: Vec<(&str, bool, bool)> = offers + .iter() + .map(|offer| (offer.found.name.as_str(), offer.known, offer.checked)) + .collect(); + assert_eq!( + rows, + vec![ + ("Desk", true, false), + ("Sub", false, true), + ("Rear", false, false) + ] + ); + assert_eq!(offers[1].found.address, address(MOVED_TEXT)); + } + + #[test] + fn a_new_scan_after_nothing_offers_what_it_finds() { + for first in [ScanChoice::Failed, ScanChoice::Offered(Vec::new())] { + let again = first.again(&[found("Sub", NEW_SERIAL, NEW_ADDRESS_TEXT)], &[]); + assert_eq!( + again, + ScanChoice::offer(&[found("Sub", NEW_SERIAL, NEW_ADDRESS_TEXT)], &[]) + ); + } + } + #[test] fn a_running_or_failed_scan_offers_nothing() { for mut choice in [ScanChoice::Scanning, ScanChoice::Failed] { diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 5e96d31..bf13c9c 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -347,6 +347,7 @@ pub const ADDRESS_ALREADY_LISTED: Text = Text::new( pub const SCAN_CHOICE_TITLE: Text = Text::new("Gefundene Lautsprecher", "Speakers found"); pub const NOTHING_FOUND: Text = Text::new("Keine Lautsprecher gefunden", "No speakers found"); +pub const SCAN_AGAIN: Text = Text::new("Erneut suchen", "Scan again"); pub const ALREADY_ADDED: Text = Text::new("bereits hinzugefügt", "already added"); pub const REMOVE: Text = Text::new("Entfernen", "Remove"); diff --git a/crates/app/src/views/scan_choice.rs b/crates/app/src/views/scan_choice.rs index fbf13c4..030cc1f 100644 --- a/crates/app/src/views/scan_choice.rs +++ b/crates/app/src/views/scan_choice.rs @@ -1,5 +1,6 @@ //! The "Gefundene Lautsprecher" dialog after "Suchen": a spinner while the scan runs, then one -//! row per found speaker to check. Speakers already shown are listed but not offered. +//! row per found speaker to check. Speakers already shown are listed but not offered. "Erneut +//! suchen" scans again and keeps the rows and checks so far. use eframe::egui::{Button, Checkbox, Grid, Id, Modal, Ui}; @@ -15,17 +16,20 @@ pub enum ScanChoiceAction { Toggle(usize), Add, Cancel, + ScanAgain, } -pub fn show(ui: &mut Ui, choice: &ScanChoice) -> ScanChoiceAction { - let modal = Modal::new(Id::unique(MODAL_ID)).show(ui.ctx(), |ui| contents(ui, choice)); +/// `scanning`: a scan runs, whose speakers will join the rows. +pub fn show(ui: &mut Ui, choice: &ScanChoice, scanning: bool) -> ScanChoiceAction { + let modal = + Modal::new(Id::unique(MODAL_ID)).show(ui.ctx(), |ui| contents(ui, choice, scanning)); if modal.should_close() { return ScanChoiceAction::Cancel; } modal.inner } -fn contents(ui: &mut Ui, choice: &ScanChoice) -> ScanChoiceAction { +fn contents(ui: &mut Ui, choice: &ScanChoice, scanning: bool) -> ScanChoiceAction { ui.heading(texts::SCAN_CHOICE_TITLE); let mut action = match choice { ScanChoice::Scanning => { @@ -49,6 +53,16 @@ fn contents(ui: &mut Ui, choice: &ScanChoice) -> ScanChoiceAction { { action = ScanChoiceAction::Add; } + if ui + .add_enabled(!scanning, Button::new(texts::SCAN_AGAIN)) + .clicked() + { + action = ScanChoiceAction::ScanAgain; + } + // The rows stay while a new scan runs; the spinner says that more may come. + if scanning && !matches!(choice, ScanChoice::Scanning) { + ui.spinner(); + } if ui.button(texts::CANCEL).clicked() { action = ScanChoiceAction::Cancel; } diff --git a/crates/app/tests/app/speakers.rs b/crates/app/tests/app/speakers.rs index 29097de..7a6cb49 100644 --- a/crates/app/tests/app/speakers.rs +++ b/crates/app/tests/app/speakers.rs @@ -90,6 +90,22 @@ fn scan_says_when_nothing_was_found() { .is_none()); } +/// A speaker still starting up was missed: "Scan again" finds it without closing the dialog. +#[test] +fn scan_again_finds_a_speaker_the_first_scan_missed() { + let simulator = Simulator::start(SimModel::Kh120Ii); + let directory = tempfile::tempdir().unwrap(); + let path = settings_file(directory.path(), Vec::new()); + let shared: SharedScan = std::sync::Arc::new(std::sync::Mutex::new(Ok(Vec::new()))); + let mut harness = harness_with_shared_scan(path, shared.clone()); + harness.get_by_label(texts::SCAN.get()).click(); + wait_for_label(&mut harness, texts::NOTHING_FOUND.get()); + *shared.lock().unwrap() = Ok(vec![kh120_found(&simulator)]); + harness.get_by_label(texts::SCAN_AGAIN.get()).click(); + wait_for_label(&mut harness, &mask_host(&simulator.address())); + harness.get_by_label(texts::SCAN_CHOICE_TITLE.get()); +} + #[test] fn a_failed_scan_says_so_in_the_dialog() { let directory = tempfile::tempdir().unwrap(); From cf4225e2ed6fdc9b0761340e624e4340fb9aab64 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:46:41 +0200 Subject: [PATCH 248/460] Keep empty meters on a strip before its first reading Without readings the meter group had no size and its caption lay over the scale. --- CHANGELOG.md | 2 ++ crates/app/src/views/strip.rs | 11 ++++++++++- crates/app/tests/app/views.rs | 23 +++++++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa77206..566d03d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,8 @@ ### Fixed +- A speaker not reached since the start showed no meters, and their caption lay over the scale; + it now shows two empty meters until the first reading. - Every write to the KH 120 II's 20-band EQ 3 was refused by the speaker ("code 413"): the request was one value too long for it. - The EQ graph drew bands near the top too wide (a 14 kHz peak by up to 1.3 dB): it now draws diff --git a/crates/app/src/views/strip.rs b/crates/app/src/views/strip.rs index 51a98c4..bd52568 100644 --- a/crates/app/src/views/strip.rs +++ b/crates/app/src/views/strip.rs @@ -21,6 +21,7 @@ use crate::eq::has_eq; use crate::fader::{output_level_range, WHEEL_STEP_DB}; use crate::gesture::on_grid; use crate::input_source::input_source; +use crate::meter::MeterState; use crate::paths; use crate::state::{SpeakerState, Status}; use crate::status_leds::{status_leds, ConnectionLed}; @@ -686,13 +687,21 @@ fn meters(ui: &mut Ui, speaker: &SpeakerState, desk: &DeskSettings, tokens: &Tok let played = source.map(|source| source.played).unwrap_or_default(); let played: &[bool] = if source.is_some() { &played } else { &[] }; let floor = desk.meter_floor.db(); + // Before its first reading a speaker has no meters; empty ones keep the strip's shape. + let unread; + let input_levels: &[MeterState] = if speaker.input_meters.is_empty() { + unread = vec![MeterState::default(); texts::INPUT_CHANNELS.len()]; + &unread + } else { + &speaker.input_meters + }; let input = MeterGroup { with_scale: true, caption: caption.get(), meters: Meters { name: texts::INPUT_METERS.get(), channels: texts::INPUT_CHANNELS, - levels: &speaker.input_meters, + levels: input_levels, clips: speaker.values.get(&paths::path(paths::INPUT_CLIP)), played, }, diff --git a/crates/app/tests/app/views.rs b/crates/app/tests/app/views.rs index 9f9f674..ae6d267 100644 --- a/crates/app/tests/app/views.rs +++ b/crates/app/tests/app/views.rs @@ -308,6 +308,29 @@ fn panel_closes_when_its_speaker_goes_away() { }); } +/// Never reached since the start, a speaker has no readings: its strip still shows two empty +/// input meters, so their caption stays below them instead of on the scale. +#[test] +fn a_speaker_never_reached_keeps_its_meters_and_caption_apart() { + let reserved = Simulator::start(SimModel::Kh120Ii); + let entry = manual_for(&reserved); + reserved.stop(); + let directory = tempfile::tempdir().unwrap(); + let path = settings_file(directory.path(), vec![entry]); + let mut harness = harness(path, Vec::new()); + wait_for_label(&mut harness, texts::UNREACHABLE.get()); + let meters: Vec = harness + .query_all_by_role(Role::ProgressIndicator) + .map(|node| node.rect()) + .collect(); + assert_eq!(meters.len(), 2, "input A and B"); + let caption = harness.get_by_label(texts::METER_INPUT_SHORT.get()).rect(); + assert!( + caption.top() >= meters[0].bottom(), + "{caption:?} {meters:?}" + ); +} + #[test] fn unreachable_strip_is_disabled() { let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); From 4f2b43017990fda0afdbbb5385abd8b89b688fcc Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:51:35 +0200 Subject: [PATCH 249/460] Drag strip elements with the left button only A right-drag in edit mode carried the element along; the hide marks go too, hiding is in the context menu. --- CHANGELOG.md | 2 + README.md | 5 ++- crates/app/src/texts.rs | 7 ---- crates/app/src/views/strip.rs | 63 ++++++++++------------------ crates/app/tests/app/strip_layout.rs | 59 ++++++++++++++++---------- 5 files changed, 62 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 566d03d..8b93441 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,8 @@ ### Fixed +- In edit mode a right-drag moved the element along; only the left button drags now, and the × + beside each element is gone (right-click → **Hide** hides it). - A speaker not reached since the start showed no meters, and their caption lay over the scale; it now shows two empty meters until the first reading. - Every write to the KH 120 II's 20-band EQ 3 was refused by the speaker ("code 413"): the diff --git a/README.md b/README.md index aada2d5..4444111 100644 --- a/README.md +++ b/README.md @@ -140,8 +140,9 @@ speaker of that model at once: - Right-click a knob or button on a strip to **Hide** it, **Swap for …** another setting, or move it up or down (past the fader too). Right-click a row in the device panel to put it on the strip (**Add to strip**) or take it off. -- **↕** in the toolbar (**Customize strips**) switches every strip into edit mode: **×** hides an - element, **+ Add** adds one, dragging an element moves it (also across the fader), and +- **↕** in the toolbar (**Customize strips**) switches every strip into edit mode: **+ Add** adds + an element, dragging one with the left button moves it (also across the fader), right-click + hides it, and **Default** brings back the strip above. The controls write nothing while you edit; **↕** again (**Done**) ends it. - Any number, switch, choice or read-only value of the device panel can go on a strip; the diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index bf13c9c..f6b6628 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -155,8 +155,6 @@ pub const ADD_ELEMENT: Text = Text::new("+ hinzufügen", "+ Add"); pub const DEFAULT_LAYOUT: Text = Text::new("Standard", "Default"); /// The toolbar's narrow edit button; its name for screen readers is `CUSTOMIZE_STRIPS`/`DONE`. pub const EDIT_MARK: Text = Text::new("↕", "↕"); -/// The × beside an element in edit mode; its name is `hide_element`. -pub const HIDE_MARK: Text = Text::new("×", "×"); pub const GROUP_DIALOG_TITLE: Text = Text::new("Gruppe", "Group"); pub const GROUP_NAME: Text = Text::new("Name", "Name"); pub const DELETE_GROUP: Text = Text::new("Gruppe löschen", "Delete group"); @@ -371,11 +369,6 @@ pub fn applies_to_every(model: &str) -> String { ) } -/// The × beside an element in edit mode, named after the element. -pub fn hide_element(name: &str) -> String { - pick(format!("Ausblenden: {name}"), format!("Hide {name}")) -} - pub fn speaker_count(count: usize) -> String { pick( format!("{count} Lautsprecher"), diff --git a/crates/app/src/views/strip.rs b/crates/app/src/views/strip.rs index bd52568..19be716 100644 --- a/crates/app/src/views/strip.rs +++ b/crates/app/src/views/strip.rs @@ -3,8 +3,8 @@ //! and the last message. The strip only reports gestures; `writes` decides what becomes a command. use eframe::egui::{ - self, vec2, Align, Button, Color32, ComboBox, CornerRadius, Label, Layout, Rect, Response, - RichText, Role, Sense, Stroke, Ui, Vec2, WidgetInfo, + self, vec2, Align, Button, Color32, ComboBox, CornerRadius, CursorIcon, DragAndDrop, Label, + Layout, PointerButton, Rect, Response, RichText, Role, Sense, Stroke, Ui, Vec2, WidgetInfo, }; use neumann_control_core::catalog::{Catalog, Entry, Kind}; use neumann_control_core::settings::{DeskSettings, StripElement}; @@ -316,8 +316,8 @@ fn elements( } } -/// Draws one element with its context menu. While editing it is a still control beside a ×, -/// dragged as a whole, and what is let go on it lands before it. +/// Draws one element with its context menu (which also hides it). While editing it is a still +/// control, dragged as a whole with the primary button, and what is let go on it lands before it. fn element_cell( ui: &mut Ui, element: StripElement, @@ -331,7 +331,7 @@ fn element_cell( }; let rect = match arrange.editing { false => ui.scope(draw).response.rect, - true => editable_cell(ui, &element, arrange.catalog, wishes, draw), + true => editable_cell(ui, &element, draw), }; arrange_at(ui, rect, element, offers, arrange, wishes); } @@ -351,12 +351,7 @@ fn inline_cell( return; }; if arrange.editing { - let drag_id = ui - .scope_id() - .with((DRAG_ID, layout_menu::menu_key(&element))); - let handle = ui.interact(response.rect, drag_id, Sense::drag()); - handle.dnd_set_drag_payload(element.clone()); - hide_button(ui, &element, arrange.catalog, wishes); + drag_handle(ui, response.rect, &element); } arrange_at(ui, response.rect, element, offers, arrange, wishes); } @@ -387,40 +382,24 @@ fn arrange_at( } } -fn editable_cell( - ui: &mut Ui, - element: &StripElement, - catalog: &Catalog, - wishes: &mut Wishes, - draw: impl FnOnce(&mut Ui), -) -> Rect { +fn editable_cell(ui: &mut Ui, element: &StripElement, draw: impl FnOnce(&mut Ui)) -> Rect { + let rect = ui.scope(|ui| ui.add_enabled_ui(false, draw)).response.rect; + drag_handle(ui, rect, element); + rect +} + +/// Lies over a still element while editing. Only the primary button drags: the secondary one +/// opens the element's menu, and egui would start a drag for it as well. +fn drag_handle(ui: &mut Ui, rect: Rect, element: &StripElement) { let drag_id = ui .scope_id() .with((DRAG_ID, layout_menu::menu_key(element))); - // Under the element: beside it the × reached into the neighbouring knob's column, on it - // it covered the knob. - ui.vertical_centered(|ui| { - ui.dnd_drag_source(drag_id, element.clone(), |ui| { - ui.add_enabled_ui(false, draw); - }); - hide_button(ui, element, catalog, wishes); - }) - .response - .rect -} - -/// The × beside a button in a row while editing. -fn hide_button(ui: &mut Ui, element: &StripElement, catalog: &Catalog, wishes: &mut Wishes) { - let hide = ui.small_button(texts::HIDE_MARK); - on_hide(hide, element, catalog, wishes); -} - -/// Names the × after its element and hides that element when it is clicked. -fn on_hide(hide: Response, element: &StripElement, catalog: &Catalog, wishes: &mut Wishes) { - let hide_label = texts::hide_element(&layout_menu::element_label(element, catalog)); - hide.widget_info(|| WidgetInfo::labeled(Role::Button, true, &hide_label)); - if hide.on_hover_text(&hide_label).clicked() { - wishes.layout = Some(LayoutChange::Remove(element.clone())); + let handle = ui.interact(rect, drag_id, Sense::drag()); + if handle.drag_started_by(PointerButton::Primary) { + DragAndDrop::set_payload(ui.ctx(), element.clone()); + } + if handle.hovered() { + ui.ctx().set_cursor_icon(CursorIcon::Grab); } } diff --git a/crates/app/tests/app/strip_layout.rs b/crates/app/tests/app/strip_layout.rs index 3f23858..094b45d 100644 --- a/crates/app/tests/app/strip_layout.rs +++ b/crates/app/tests/app/strip_layout.rs @@ -250,11 +250,12 @@ fn start_editing(harness: &mut Harness<'static, NeumannApp>) { harness.run_steps(2); } -fn hide_in_edit_mode(harness: &mut Harness<'static, NeumannApp>, path: &str) { - harness - .get_by_label(&texts::hide_element(label(path))) - .click(); +/// Edit mode has no × any more: an element is hidden from its context menu. `shown` is the +/// element's text on the strip ("Ident." for identify). +fn hide_in_edit_mode(harness: &mut Harness<'static, NeumannApp>, shown: &str) { + harness.get_by_label(shown).click_secondary(); harness.run_steps(2); + choose(harness, texts::HIDE.get()); } fn add_in_edit_mode(harness: &mut Harness<'static, NeumannApp>, offer: &str) { @@ -308,7 +309,7 @@ fn any_set(simulator: &neumann_control_core::simulator::Simulator) -> bool { fn editing_hides_and_adds() { let (_simulators, directory, mut harness) = desk_of(&[SimModel::Kh120Ii], BTreeMap::new()); start_editing(&mut harness); - hide_in_edit_mode(&mut harness, paths::IDENTIFY); + hide_in_edit_mode(&mut harness, texts::IDENTIFY.get()); add_in_edit_mode(&mut harness, label(paths::PHASE_INVERSION)); harness.get_by_label(texts::DONE.get()).click(); harness.run_steps(2); @@ -395,7 +396,7 @@ fn an_unreachable_strip_can_still_be_edited() { simulators.remove(0).stop(); wait_for_label(&mut harness, texts::UNREACHABLE.get()); start_editing(&mut harness); - hide_in_edit_mode(&mut harness, paths::IDENTIFY); + hide_in_edit_mode(&mut harness, texts::IDENTIFY.get()); let stored = stored_layout(&directory, MONITOR).expect("a stored layout"); assert!(!stored.below.contains(&element(paths::IDENTIFY))); } @@ -404,7 +405,7 @@ fn an_unreachable_strip_can_still_be_edited() { fn default_restores_todays_strip() { let (_simulators, directory, mut harness) = desk_of(&[SimModel::Kh120Ii], BTreeMap::new()); start_editing(&mut harness); - hide_in_edit_mode(&mut harness, paths::IDENTIFY); + hide_in_edit_mode(&mut harness, texts::IDENTIFY.get()); harness.get_by_label(texts::DEFAULT_LAYOUT.get()).click(); harness.run_steps(2); let identify = harness.query_by_role_and_label(Role::Button, texts::IDENTIFY.get()); @@ -421,8 +422,6 @@ fn compact_mode_ends_editing() { harness.run_steps(3); } assert!(harness.query_by_label(texts::ADD_ELEMENT.get()).is_none()); - let hide = texts::hide_element(label(paths::IDENTIFY)); - assert!(harness.query_by_label(&hide).is_none()); } #[test] @@ -468,23 +467,37 @@ fn the_button_row_keeps_its_size_and_its_words() { } } +/// A right click opens the element's menu; with the button held and moved it does not drag the +/// element along (it used to jump to the pointer). #[test] -fn in_edit_mode_no_hide_mark_covers_a_knob() { - let (_simulators, _directory, mut harness) = - desk_of(&[SimModel::Kh120Ii], knob_strip_layouts()); +fn a_right_drag_in_edit_mode_moves_nothing() { + let (_simulators, directory, mut harness) = desk_of(&[SimModel::Kh120Ii], knob_strip_layouts()); start_editing(&mut harness); - let knobs = [paths::DELAY, paths::STANDBY_TIME]; - for own in knobs { - let mark = harness - .get_by_label(&texts::hide_element(label(own))) - .rect(); - for knob_path in knobs { - let knob = harness - .get_by_role_and_label(Role::Slider, label(knob_path)) - .rect(); - assert!(!mark.intersects(knob), "the × of {own} lies on {knob_path}"); - } + let from = center(&harness, Role::Slider, label(paths::DELAY)); + let to = center(&harness, Role::Button, texts::MUTE.get()); + harness.hover_at(from); + let press = |pressed, pos| egui::Event::PointerButton { + pos, + button: egui::PointerButton::Secondary, + pressed, + modifiers: egui::Modifiers::NONE, + }; + harness.event(press(true, from)); + harness.step(); + for frame in 1..=DRAG_FRAMES { + let progress = frame as f32 / DRAG_FRAMES as f32; + harness.hover_at(from + (to - from) * progress); + harness.step(); + assert_eq!( + center(&harness, Role::Slider, label(paths::DELAY)), + from, + "the knob followed the right button" + ); } + harness.event(press(false, to)); + harness.run_steps(3); + let stored = stored_layout(&directory, MONITOR).expect("the test layout"); + assert_eq!(stored.above.first(), Some(&element(paths::DELAY))); } /// The input meters' places while the levels change, in normal and in edit mode. From 3800bb10d1ce344c289a28599d9f4088b072028d Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:56:12 +0200 Subject: [PATCH 250/460] Remember each speaker's name and model Kept under the serial when it connects, so a speaker that is off is still named; removing it forgets them. --- CHANGELOG.md | 2 ++ README.md | 2 +- crates/app/src/app.rs | 19 +++++++++++----- crates/app/src/state.rs | 14 ++++++++---- crates/app/src/targets.rs | 37 +++++++++++++++++++++++++++++++- crates/app/tests/app/speakers.rs | 19 ++++++++++++++++ crates/core/src/settings.rs | 13 +++++++++++ 7 files changed, 95 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b93441..2f85f21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- A speaker that is off keeps the name and model it last reported, instead of its masked + address; removing the speaker forgets them. - **Scan again** in the dialog of found speakers: scans once more and adds what it finds, keeping the list and the checks so far. - Delays in meters: **Settings** shows and takes every delay as the distance sound travels in it diff --git a/README.md b/README.md index 4444111..9544a52 100644 --- a/README.md +++ b/README.md @@ -361,7 +361,7 @@ The app is portable and writes only next to the executable: | File | Content | |------|---------| -| `settings.json` | Remembered speakers (address, port, serial), groups and the strip order, the window position and size for the desk and the compact mode, the choices from **Settings**, and the presets | +| `settings.json` | Remembered speakers (address, port, serial, and the name and model each last reported, shown while it is off), groups and the strip order, the window position and size for the desk and the compact mode, the choices from **Settings**, and the presets | | `scan-*.json`, `scan-*.txt` | Written by **Full scan**, without serial number, MAC address, name or network addresses | | `neumann-control.log` | Log of the current run, replaced at each start | diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index 64c7441..afee7f9 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -29,7 +29,9 @@ use crate::state::{AllValuesState, SpeakerKey, SpeakerState, Status}; use crate::status_leds::average_response_time; use crate::strip_layout; use crate::system_audio::{AudioDevice, SystemAudio}; -use crate::targets::{duplicate_speakers, follow_moves, forget, remember_serial, targets}; +use crate::targets::{ + duplicate_speakers, follow_moves, forget, remember_identity, remember_serial, targets, +}; use crate::texts; use crate::ui_kit::eq_palette::{curve_key, CURVE_KEYS}; use crate::ui_kit::{fonts, theme}; @@ -198,6 +200,9 @@ impl NeumannApp { let Some(position) = existing else { let mut state = SpeakerState::new(key.clone(), address); state.discovered = discovered.cloned(); + if let SpeakerKey::Serial(serial) = &key { + state.remembered = self.settings.known.get(serial).cloned(); + } let (handle, events) = self.spawn(&key, address); self.speakers.push(Speaker { state, @@ -242,7 +247,7 @@ impl NeumannApp { for speaker in &mut self.speakers { while let Ok(event) = speaker.events.try_recv() { if let Event::Connected(identity) = &event { - connected.push((identity.serial.clone(), speaker.state.address)); + connected.push((identity.clone(), speaker.state.address)); } let before = speaker.state.key.clone(); speaker.state.apply(event, now); @@ -255,8 +260,9 @@ impl NeumannApp { return; } let mut changed = false; - for (serial, address) in &connected { - changed |= remember_serial(&mut self.settings.manual, serial, *address); + for (identity, address) in &connected { + changed |= remember_serial(&mut self.settings.manual, &identity.serial, *address); + changed |= remember_identity(&mut self.settings.known, identity); } for (before, after) in &renamed { changed |= self.follow_key(before, after); @@ -401,7 +407,10 @@ impl NeumannApp { log::info!("speaker at {} removed", mask_host(&address)); let ids = [member_id(&key), member_id(&SpeakerKey::Address(address))]; let settings = &mut self.settings; - let forgotten = forget_member(&mut settings.groups, &mut settings.order, &ids); + let mut forgotten = forget_member(&mut settings.groups, &mut settings.order, &ids); + if let SpeakerKey::Serial(serial) = &key { + forgotten |= settings.known.remove(serial).is_some(); + } if forget(&mut settings.manual, &key, address) | forgotten { self.save_settings(); } diff --git a/crates/app/src/state.rs b/crates/app/src/state.rs index 510f4ea..0ff99ca 100644 --- a/crates/app/src/state.rs +++ b/crates/app/src/state.rs @@ -8,6 +8,7 @@ use std::time::Duration; use neumann_control_core::discovery::Found; use neumann_control_core::mask::{mask_host, mask_serial}; use neumann_control_core::schema_walk::LeafReport; +use neumann_control_core::settings::KnownSpeaker; use neumann_control_core::speaker::{Command, Event, Identity}; use neumann_control_core::ssc::wire::SscPath; use serde_json::Value; @@ -92,6 +93,8 @@ pub struct SpeakerState { pub identity: Option, /// What the last scan reported for this speaker; names may be empty. pub discovered: Option, + /// What it reported when it last connected, from the settings; names it while it is off. + pub remembered: Option, pub values: BTreeMap, pub absent: BTreeSet, pub status: Status, @@ -135,6 +138,7 @@ impl SpeakerState { address, identity: None, discovered: None, + remembered: None, values: BTreeMap::new(), absent: BTreeSet::new(), status: Status::Connecting, @@ -156,15 +160,16 @@ impl SpeakerState { } } - /// The speaker's own name, else the announced one, else the model, else the masked - /// address; mDNS names and device names can both be empty. + /// The speaker's own name, else the announced one, else the one it had when it last + /// connected, else the model, else the masked address; any of these names can be empty. pub fn display_name(&self) -> String { let own = self .identity .as_ref() .map(|identity| identity.name.as_str()); let announced = self.discovered.as_ref().map(|found| found.name.as_str()); - [own, announced, self.model()] + let remembered = self.remembered.as_ref().map(|known| known.name.as_str()); + [own, announced, remembered, self.model()] .into_iter() .flatten() .find(|name| !name.is_empty()) @@ -190,7 +195,8 @@ impl SpeakerState { .discovered .as_ref() .and_then(|found| found.product.as_deref()); - [reported, announced] + let remembered = self.remembered.as_ref().map(|known| known.product.as_str()); + [reported, announced, remembered] .into_iter() .flatten() .find(|model| !model.is_empty()) diff --git a/crates/app/src/targets.rs b/crates/app/src/targets.rs index 0fcf2d6..5d5a648 100644 --- a/crates/app/src/targets.rs +++ b/crates/app/src/targets.rs @@ -1,11 +1,13 @@ //! Which speakers to talk to: the remembered entries in the settings, one target each, and how //! scan results relate to them. +use std::collections::BTreeMap; use std::net::SocketAddrV6; use neumann_control_core::address::{format_host, parse_address}; use neumann_control_core::discovery::Found; -use neumann_control_core::settings::ManualSpeaker; +use neumann_control_core::settings::{KnownSpeaker, ManualSpeaker}; +use neumann_control_core::speaker::Identity; use crate::state::SpeakerKey; @@ -119,6 +121,19 @@ pub fn remember_serial(manual: &mut [ManualSpeaker], serial: &str, address: Sock changed } +/// Keeps a speaker's name and product under its serial, so it is named while it is off; true +/// when that changed what is kept. +pub fn remember_identity(known: &mut BTreeMap, identity: &Identity) -> bool { + if identity.serial.is_empty() { + return false; + } + let kept = KnownSpeaker { + name: identity.name.clone(), + product: identity.product.clone(), + }; + known.insert(identity.serial.clone(), kept.clone()) != Some(kept) +} + /// Positions of speakers whose serial an earlier one already has: one speaker reached at two /// addresses. The first stays. pub fn duplicate_speakers(keys: &[SpeakerKey]) -> Vec { @@ -133,6 +148,26 @@ pub fn duplicate_speakers(keys: &[SpeakerKey]) -> Vec { #[cfg(test)] mod tests { + + fn identity(serial: &str, name: &str) -> Identity { + Identity { + product: "KH 750".to_owned(), + version: "2_1_2".to_owned(), + serial: serial.to_owned(), + name: name.to_owned(), + } + } + + #[test] + fn an_identity_is_kept_under_its_serial_and_changes_count_once() { + let mut known = BTreeMap::new(); + assert!(remember_identity(&mut known, &identity("SIM1", "Sub"))); + assert!(!remember_identity(&mut known, &identity("SIM1", "Sub"))); + assert!(remember_identity(&mut known, &identity("SIM1", "Sub left"))); + assert_eq!(known["SIM1"].name, "Sub left"); + assert!(!remember_identity(&mut known, &identity("", "Nobody"))); + assert_eq!(known.len(), 1); + } use super::*; const SERIAL: &str = "SIM0001234"; diff --git a/crates/app/tests/app/speakers.rs b/crates/app/tests/app/speakers.rs index 7a6cb49..92e2192 100644 --- a/crates/app/tests/app/speakers.rs +++ b/crates/app/tests/app/speakers.rs @@ -371,12 +371,31 @@ fn remove_needs_confirmation_and_forgets_speaker() { assert!(harness.query_by_label(texts::REMOVE_TITLE.get()).is_none()); harness.get_by_label(&texts::speaker_count(0)); assert!(settings::load(&path).manual.is_empty()); + assert!( + settings::load(&path).known.is_empty(), + "its name is forgotten too" + ); drop(harness); let mut restarted = crate::support::harness(path, Vec::new()); restarted.run_steps(2); restarted.get_by_label(&texts::speaker_count(0)); } +/// Off at the next start, a speaker is still called by the name it last reported, not by its +/// masked address; removing it forgets the name too. +#[test] +fn a_speaker_that_is_off_keeps_its_last_name() { + let (simulator, directory, harness) = connected_speaker(SimModel::Kh120Ii); + let path = directory.path().join(SETTINGS_FILE); + let address = simulator.address(); + drop(harness); + simulator.stop(); + let mut restarted = crate::support::harness(path.clone(), Vec::new()); + wait_for_label(&mut restarted, texts::UNREACHABLE.get()); + restarted.get_by_label(KH120_DEVICE_NAME); + assert!(restarted.query_by_label(&mask_host(&address)).is_none()); +} + #[test] fn cancel_remove_keeps_speaker() { let (_simulator, directory, mut harness) = connected_speaker(SimModel::Kh120Ii); diff --git a/crates/core/src/settings.rs b/crates/core/src/settings.rs index 68ddd0f..654224f 100644 --- a/crates/core/src/settings.rs +++ b/crates/core/src/settings.rs @@ -50,6 +50,19 @@ pub struct Settings { pub system_audio: SystemAudioSettings, #[serde(deserialize_with = "or_default")] pub eq_display: EqDisplaySettings, + /// Serial → what that speaker last reported about itself, so it keeps its name while off. + #[serde( + deserialize_with = "or_default", + skip_serializing_if = "BTreeMap::is_empty" + )] + pub known: BTreeMap, +} + +/// A speaker's own name and product as it reported them when it last connected. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct KnownSpeaker { + pub name: String, + pub product: String, } /// How the EQ graph draws its bands, and the colours the user chose instead of the defaults. From 5d2f8f841a6ab79002f4583a17be10f537a23472 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:57:23 +0200 Subject: [PATCH 251/460] Add a Details button to group strips --- CHANGELOG.md | 1 + README.md | 2 +- crates/app/src/texts.rs | 8 ++++++++ crates/app/src/views/group_strip.rs | 4 ++++ crates/app/tests/app/groups.rs | 9 +++++++++ 5 files changed, 23 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f85f21..6e24b64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- **Details** on each group strip opens the group's dialog (rename, delete), as its name does. - A speaker that is off keeps the name and model it last reported, instead of its masked address; removing the speaker forgets them. - **Scan again** in the dialog of found speakers: scans once more and adds what it finds, keeping diff --git a/README.md b/README.md index 9544a52..4423bfc 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,7 @@ longer remembered, and it also leaves its group and its place in the order. change on one member goes to the others whose model may write it. Members that are not connected are left out. **Apply to group** in the panel copies a linked value to the other members. -- Click the group's name to rename or delete it. +- Click the group's name or **Details** at the foot of the group strip to rename or delete it. - The master strip at the end moves and mutes all connected speakers the same way. Drag a strip by the dots before its name to reorder: the other strips of its group (or the diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index f6b6628..d41cc92 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -459,6 +459,14 @@ pub fn group_mute(group: &str) -> String { pick(format!("{group} stumm"), format!("Mute {group}")) } +/// Names the group strip's "Details" apart from the speakers' ones. +pub fn group_details(group: &str) -> String { + pick( + format!("Details der Gruppe {group}"), + format!("Details of group {group}"), + ) +} + pub fn response_time(milliseconds: u128) -> String { format!("{milliseconds} ms") } diff --git a/crates/app/src/views/group_strip.rs b/crates/app/src/views/group_strip.rs index 8e44725..d9a2af5 100644 --- a/crates/app/src/views/group_strip.rs +++ b/crates/app/src/views/group_strip.rs @@ -59,6 +59,10 @@ pub fn group( let readout = shared_readout(level); ui.add(egui::Label::new(RichText::new(readout).monospace())); response.mute = mute_button(ui, &texts::group_mute(&group.name), muted); + let details = ui.button(texts::DETAILS); + let label = texts::group_details(&group.name); + details.widget_info(|| WidgetInfo::labeled(Role::Button, true, &label)); + response.open_settings |= details.clicked(); }); response } diff --git a/crates/app/tests/app/groups.rs b/crates/app/tests/app/groups.rs index 44a1a1c..e8f7b87 100644 --- a/crates/app/tests/app/groups.rs +++ b/crates/app/tests/app/groups.rs @@ -103,6 +103,15 @@ fn link_buttons_toggle_and_are_saved() { assert!(settings::load(&path).groups[0].linked.is_empty()); } +#[test] +fn the_group_strips_details_button_opens_its_dialog() { + let (_kh120, _kh750, _directory, mut harness) = grouped(Vec::new()); + harness + .get_by_label(&texts::group_details(GROUP_NAME)) + .click(); + wait_for_label(&mut harness, texts::GROUP_DIALOG_TITLE.get()); +} + #[test] fn group_dialog_renames_and_deletes() { let (_kh120, _kh750, directory, mut harness) = grouped(Vec::new()); From 0a3e7bf02dde666d1269ec604f14ebd39428a2e2 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:59:31 +0200 Subject: [PATCH 252/460] Draw a pen on the edit button The fonts have no pen glyph; the button keeps its name for screen readers and tooltips. --- CHANGELOG.md | 4 ++-- README.md | 9 ++++---- crates/app/src/texts.rs | 2 -- crates/app/src/views/toolbar.rs | 40 ++++++++++++++++++++++++--------- 4 files changed, 35 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e24b64..951c874 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,8 +43,8 @@ - **Protect crossovers** in the settings (on by default); off, "Make flat", "Reset", EQ presets and the import work on the KH 750 crossovers too, with a warning. - Strips you can customize per model: hide, swap and move what a strip shows besides its fader, - put any setting of the device panel on it, from a context menu or in an edit mode (**↕** in the - toolbar). Mute and identify are listed in the device panel too. + put any setting of the device panel on it, from a context menu or in an edit mode (the pen in + the toolbar). Mute and identify are listed in the device panel too. ### Changed diff --git a/README.md b/README.md index 4423bfc..c95c124 100644 --- a/README.md +++ b/README.md @@ -140,11 +140,10 @@ speaker of that model at once: - Right-click a knob or button on a strip to **Hide** it, **Swap for …** another setting, or move it up or down (past the fader too). Right-click a row in the device panel to put it on the strip (**Add to strip**) or take it off. -- **↕** in the toolbar (**Customize strips**) switches every strip into edit mode: **+ Add** adds - an element, dragging one with the left button moves it (also across the fader), right-click - hides it, and - **Default** brings back the strip above. The controls write nothing while you edit; **↕** again - (**Done**) ends it. +- The pen in the toolbar (**Customize strips**) switches every strip into edit mode: **+ Add** + adds an element, dragging one with the left button moves it (also across the fader), + right-click hides it, and **Default** brings back the strip above. The controls write nothing + while you edit; the pen again (**Done**) ends it. - Any number, switch, choice or read-only value of the device panel can go on a strip; the device name and lists such as the input mix stay in the panel. diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index d41cc92..0ecaf20 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -153,8 +153,6 @@ pub const CUSTOMIZE_STRIPS: Text = Text::new("Züge anpassen", "Customize strips pub const DONE: Text = Text::new("Fertig", "Done"); pub const ADD_ELEMENT: Text = Text::new("+ hinzufügen", "+ Add"); pub const DEFAULT_LAYOUT: Text = Text::new("Standard", "Default"); -/// The toolbar's narrow edit button; its name for screen readers is `CUSTOMIZE_STRIPS`/`DONE`. -pub const EDIT_MARK: Text = Text::new("↕", "↕"); pub const GROUP_DIALOG_TITLE: Text = Text::new("Gruppe", "Group"); pub const GROUP_NAME: Text = Text::new("Name", "Name"); pub const DELETE_GROUP: Text = Text::new("Gruppe löschen", "Delete group"); diff --git a/crates/app/src/views/toolbar.rs b/crates/app/src/views/toolbar.rs index 4444123..cb96ccd 100644 --- a/crates/app/src/views/toolbar.rs +++ b/crates/app/src/views/toolbar.rs @@ -6,15 +6,18 @@ use std::time::Duration; use eframe::egui::{ - self, containers::Sides, Button, RichText, Role, TextStyle, TextWrapMode, Ui, WidgetInfo, - WidgetText, + self, containers::Sides, vec2, Button, Color32, Painter, Pos2, RichText, Role, Shape, Stroke, + TextStyle, TextWrapMode, Ui, WidgetInfo, WidgetText, }; use crate::texts; const PANEL_ID: &str = "toolbar"; /// A mark needs less room around it than a word. -const EDIT_BUTTON_PADDING: f32 = 2.0; +/// The drawn pen on the edit button, in points. +const PEN_LENGTH: f32 = 13.0; +const PEN_WIDTH: f32 = 3.5; +const PEN_TIP_LENGTH: f32 = 3.5; /// A separator with the item spacing on both sides. const SEPARATOR_ROOM: f32 = 16.0; @@ -138,19 +141,34 @@ fn adding(ui: &mut Ui, facts: &ToolbarFacts) -> ToolbarAction { action } -/// "Züge anpassen" / "Fertig" as a mark, not a word: the narrowest window has no room for one -/// more labelled button. +/// "Züge anpassen" / "Fertig" as a drawn pen, not a word: the narrowest window has no room for +/// one more labelled button, and the fonts have no pen glyph. fn edit_button(ui: &mut Ui, editing: bool) -> bool { let name = match editing { true => texts::DONE.get(), false => texts::CUSTOMIZE_STRIPS.get(), }; - let edit = ui - .scope(|ui| { - ui.spacing_mut().button_padding.x = EDIT_BUTTON_PADDING; - ui.add(Button::new(texts::EDIT_MARK).selected(editing)) - }) - .inner; + let side = ui.spacing().interact_size.y; + let edit = ui.add(Button::new("").min_size(vec2(side, side)).selected(editing)); + let color = ui + .style() + .interact_selectable(&edit, editing) + .fg_stroke + .color; + paint_pen(ui.painter(), edit.rect.center(), color); edit.widget_info(|| WidgetInfo::selected(Role::Button, true, editing, name)); edit.on_hover_text(name).clicked() } + +/// A pen from its tip at the lower left to its end at the upper right. +fn paint_pen(painter: &Painter, center: Pos2, color: Color32) { + let along = vec2(1.0, -1.0).normalized(); + let across = vec2(1.0, 1.0).normalized() * (PEN_WIDTH / 2.0); + let tip = center - along * (PEN_LENGTH / 2.0); + let end = center + along * (PEN_LENGTH / 2.0); + let shaft = tip + along * PEN_TIP_LENGTH; + let body = vec![shaft + across, end + across, end - across, shaft - across]; + painter.add(Shape::convex_polygon(body, color, Stroke::NONE)); + let point = vec![tip, shaft + across, shaft - across]; + painter.add(Shape::convex_polygon(point, color, Stroke::NONE)); +} From 7f9c26b1c37a86d25eeeb23f61304afc187a6b89 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:05:42 +0200 Subject: [PATCH 253/460] Order the device panel by topic The catalog's order put the device first and could part the KH 750's outputs between the columns; what is only shown now follows what can be set. --- CHANGELOG.md | 2 + README.md | 4 +- crates/app/src/views/details.rs | 115 +++++++++++++++++++++++-- crates/app/tests/app/details.rs | 19 ++-- crates/app/tests/app/views.rs | 2 +- crates/app/tests/app/write_gestures.rs | 21 ++--- 6 files changed, 138 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 951c874..fec5f89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,8 @@ ### Changed +- The device panel lists its settings by topic, the outputs first and together, and in each topic + what can be set before what is only shown. - On the KH 750 the outputs are named after what they drive: Subwoofer (its own woofer), Output A and Output B (its two output sockets), formerly Output 1 and Output 2. - The language follows the Windows display language unless one is chosen in the settings. diff --git a/README.md b/README.md index c95c124..ce0bc91 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,9 @@ time of the connected ones. Click a speaker's name or **Details** under its strip. The panel opens in a window of its own, one per speaker, which you can move anywhere, also beside the main window or onto another screen; opening it again brings it to the front. It lists every setting the catalog knows for that model -(output, input, standby, device, rear panel) in two columns. Settings the model may write get a +by topic in two columns: the outputs first and together (on the KH 750 its own, +then A and B), then input, standby, device and rear panel; in each topic what can be set comes +before what is only shown. Settings the model may write get a control; the rest are shown greyed. The panel also shows model, firmware and the masked serial number. Escape, **Close** or the window's close button closes it. diff --git a/crates/app/src/views/details.rs b/crates/app/src/views/details.rs index 8f5b894..9fa2c5b 100644 --- a/crates/app/src/views/details.rs +++ b/crates/app/src/views/details.rs @@ -1,5 +1,5 @@ -//! The device panel's settings: every catalog entry for the speaker's model, in the catalog's -//! group order. Entries the model may write get a control (numbers also a knob), the rest a +//! The device panel's settings: every catalog entry for the speaker's model, by topic (the +//! outputs first and together). Entries the model may write get a control (numbers also a knob), the rest a //! greyed value; what the strip or the panel header shows and what the speaker does not have //! are left out. @@ -18,6 +18,19 @@ use crate::strip_layout::{self, LayoutChange, StripLayout}; use crate::texts; const COLUMNS: usize = 2; +/// The panel's topics in the order they are listed. +const GROUP_ORDER: [Group; 9] = [ + Group::Output, + Group::Output1, + Group::Output2, + Group::Input, + Group::Standby, + Group::Device, + Group::RearPanel, + Group::Metering, + Group::Eq, +]; +const OUTPUTS: [Group; 3] = [Group::Output, Group::Output1, Group::Output2]; const GROUP_GAP: f32 = 8.0; /// Drawn by the strip's fixed part (level, meters) or the panel header (model, firmware, @@ -103,14 +116,19 @@ fn rows( type Groups<'a> = Vec<(Group, Vec<&'a Entry>)>; -/// Groups in catalog order, the first ones left until about half the rows are placed. +/// The first groups left until about half the rows are placed; the outputs stay together in +/// the column they start in. fn split_in_columns(groups: Groups<'_>) -> (Groups<'_>, Groups<'_>) { let total: usize = groups.iter().map(|(_, entries)| entries.len()).sum(); let mut placed = 0; let mut left = Vec::new(); let mut right = Vec::new(); for (group, entries) in groups { - let fits_left = right.is_empty() && 2 * placed < total; + let continues_outputs = OUTPUTS.contains(&group) + && left + .last() + .is_some_and(|(previous, _): &(Group, _)| OUTPUTS.contains(previous)); + let fits_left = right.is_empty() && (2 * placed < total || continues_outputs); placed += entries.len(); if fits_left { left.push((group, entries)); @@ -123,8 +141,24 @@ fn split_in_columns(groups: Groups<'_>) -> (Groups<'_>, Groups<'_>) { /// The entries to show, grouped in the order the catalog first names each group. fn groups<'a>(entries: Vec<&'a Entry>, speaker: &SpeakerState) -> Groups<'a> { + let shown = entries.into_iter().filter(|entry| is_shown(entry, speaker)); + arranged(grouped(shown.collect()), speaker.product()) +} + +/// In topic order, and in each group what can be set before what is only shown; otherwise the +/// catalog's order. +fn arranged<'a>(mut groups: Groups<'a>, product: &str) -> Groups<'a> { + let place = |group: &Group| GROUP_ORDER.iter().position(|known| known == group); + groups.sort_by_key(|(group, _)| place(group)); + for (_, entries) in &mut groups { + entries.sort_by_key(|entry| !entry.is_writable(product)); + } + groups +} + +fn grouped(entries: Vec<&Entry>) -> Groups<'_> { let mut groups: Groups = Vec::new(); - for entry in entries.into_iter().filter(|entry| is_shown(entry, speaker)) { + for entry in entries { match groups.iter_mut().find(|(group, _)| *group == entry.group) { Some((_, members)) => members.push(entry), None => groups.push((entry.group, vec![entry])), @@ -225,3 +259,74 @@ fn all_values_button(ui: &mut Ui, speaker: &mut SpeakerState, commands: &mut Vec commands.push(speaker.read_all_values()); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::SpeakerKey; + use neumann_control_core::speaker::Identity; + + const SUBWOOFER: &str = "KH 750"; + const MONITOR: &str = "KH 120 II"; + + /// What the panel of a connected speaker of `product` lists, in its order. + fn arranged_for(product: &str) -> Groups<'static> { + let address = "[fe80::1%7]:45".parse().unwrap(); + let mut speaker = SpeakerState::new(SpeakerKey::Address(address), address); + speaker.identity = Some(Identity { + product: product.to_owned(), + version: "1_0_0".to_owned(), + serial: "SIM1".to_owned(), + name: String::new(), + }); + groups(Catalog::embedded().entries_for(product), &speaker) + } + + fn titles(groups: &Groups) -> Vec { + groups.iter().map(|(group, _)| *group).collect() + } + + #[test] + fn groups_follow_the_topics_with_the_outputs_first() { + assert_eq!( + titles(&arranged_for(SUBWOOFER))[..7], + [ + Group::Output, + Group::Output1, + Group::Output2, + Group::Input, + Group::Standby, + Group::Device, + Group::RearPanel, + ] + ); + } + + /// Output A and B used to land in different columns. + #[test] + fn the_outputs_stay_together_in_one_column() { + for product in [SUBWOOFER, MONITOR] { + let (left, right) = split_in_columns(arranged_for(product)); + let outputs = [Group::Output, Group::Output1, Group::Output2]; + assert!( + titles(&right).iter().all(|group| !outputs.contains(group)), + "{product}: {:?} | {:?}", + titles(&left), + titles(&right) + ); + } + } + + #[test] + fn in_a_group_the_settings_come_before_what_is_only_shown() { + for (group, entries) in arranged_for(SUBWOOFER) { + let shown_only: Vec = entries + .iter() + .map(|entry| !entry.is_writable(SUBWOOFER)) + .collect(); + let mut sorted = shown_only.clone(); + sorted.sort(); + assert_eq!(shown_only, sorted, "{group:?}"); + } + } +} diff --git a/crates/app/tests/app/details.rs b/crates/app/tests/app/details.rs index 2d07010..72fd327 100644 --- a/crates/app/tests/app/details.rs +++ b/crates/app/tests/app/details.rs @@ -42,6 +42,13 @@ fn sets_to(simulator: &Simulator, pointer: &str) -> Vec { .collect() } +/// The name field sits in "Device", below the outputs, input and standby: scrolled to first. +fn click_name_field(harness: &mut Harness<'static, NeumannApp>) { + harness.get_by_role(Role::TextInput).scroll_to_me(); + harness.run_steps(SCROLL_FRAMES); + harness.get_by_role(Role::TextInput).click(); +} + fn role_of(harness: &Harness<'static, NeumannApp>, label: &str) -> Role { harness.get_by_label(label).accesskit_node().role() } @@ -87,7 +94,7 @@ fn temperature_is_shown_in_celsius() { #[test] fn name_field_shows_refusal() { let (simulator, _directory, mut harness) = speaker_with_panel(SimModel::Kh120Ii); - harness.get_by_role(Role::TextInput).click(); + click_name_field(&mut harness); harness.run_steps(1); harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); harness.get_by_role(Role::TextInput).type_text("Küche"); @@ -323,7 +330,7 @@ fn escape_during_number_edit_sends_nothing() { #[test] fn an_abandoned_name_draft_follows_the_device_again() { let (_simulator, _directory, mut harness) = speaker_with_panel(SimModel::Kh120Ii); - harness.get_by_role(Role::TextInput).click(); + click_name_field(&mut harness); harness.run_steps(1); harness.get_by_role(Role::TextInput).type_text("draft"); harness.run_steps(1); @@ -341,7 +348,7 @@ fn an_abandoned_name_draft_follows_the_device_again() { #[test] fn a_name_typed_then_tabbed_to_apply_is_sent() { let (simulator, _directory, mut harness) = speaker_with_panel(SimModel::Kh120Ii); - harness.get_by_role(Role::TextInput).click(); + click_name_field(&mut harness); harness.run_steps(1); harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); harness.get_by_role(Role::TextInput).type_text("Studio"); @@ -359,7 +366,7 @@ fn a_name_typed_then_tabbed_to_apply_is_sent() { #[test] fn a_name_draft_left_by_tabbing_past_apply_follows_the_device_again() { let (_simulator, _directory, mut harness) = speaker_with_panel(SimModel::Kh120Ii); - harness.get_by_role(Role::TextInput).click(); + click_name_field(&mut harness); harness.run_steps(1); harness.get_by_role(Role::TextInput).type_text("draft"); harness.run_steps(1); @@ -427,7 +434,7 @@ fn kh120ii_strip_has_no_output_meters() { #[test] fn all_values_button_reads_the_whole_tree() { let (simulator, _directory, mut harness) = speaker_with_panel(SimModel::Kh120Ii); - harness.get_by_label(texts::ALL_VALUES.get()).click(); + scroll_to_and_click(&mut harness, texts::ALL_VALUES.get()); wait_until(&mut harness, "the schema walk", |_| { simulator .requests() @@ -641,7 +648,7 @@ fn kh750_shows_its_auto_standby_switch() { #[test] fn a_full_scan_writes_a_redacted_report_next_to_the_settings() { let (_simulator, directory, mut harness) = speaker_with_panel(SimModel::Kh120Ii); - harness.get_by_label(texts::FULL_SCAN.get()).click(); + scroll_to_and_click(&mut harness, texts::FULL_SCAN.get()); let written = |directory: &std::path::Path| -> Vec { std::fs::read_dir(directory) .unwrap() diff --git a/crates/app/tests/app/views.rs b/crates/app/tests/app/views.rs index ae6d267..18075b3 100644 --- a/crates/app/tests/app/views.rs +++ b/crates/app/tests/app/views.rs @@ -378,7 +378,7 @@ fn all_values_window( ) -> (Simulator, tempfile::TempDir, Harness<'static, NeumannApp>) { let (simulator, directory, mut harness) = connected_speaker(model); open_panel(&mut harness, device_name(model)); - harness.get_by_label(texts::ALL_VALUES.get()).click(); + scroll_to_and_click(&mut harness, texts::ALL_VALUES.get()); wait_until(&mut harness, "the loaded table", |harness| { harness .query_by_label(texts::ALL_VALUES_PATH_COLUMN.get()) diff --git a/crates/app/tests/app/write_gestures.rs b/crates/app/tests/app/write_gestures.rs index 6fe8db9..fa99313 100644 --- a/crates/app/tests/app/write_gestures.rs +++ b/crates/app/tests/app/write_gestures.rs @@ -104,27 +104,24 @@ fn a_typed_level_outside_the_range_is_refused_not_clamped() { fn the_wheel_on_a_panel_knob_does_not_scroll_another_knob_under_the_pointer() { let (simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); open_panel(&mut harness, KH120_DEVICE_NAME); - // Short enough that the panel scrolls (its delay knob stays below the window), tall enough - // that the logo knob is in view; `open_panel` makes the window tall. - harness.set_size(egui::vec2(1200.0, 480.0)); - harness.run_steps(2); - let label = texts::knob_label("Logo-Helligkeit"); + // The panel scrolls: a wheel the standby time knob did not take would bring the standby + // level knob below it under the pointer. + let label = texts::knob_label("Standby-Zeit"); let center = slider_center(&harness, &label); harness.hover_at(center); for _ in 0..8 { wheel(&mut harness, -1.0); harness.step(); } - wait_until(&mut harness, "the logo write", |_| { - !sets(&simulator, "/ui/logo/brightness").is_empty() + wait_until(&mut harness, "the standby time write", |_| { + !sets(&simulator, "/device/standby/auto_standby_time").is_empty() }); harness.run_steps(5); barrier(&simulator); - assert!( - delay_sets(&simulator).is_empty(), - "{:?}", - delay_sets(&simulator) - ); + for other in ["/device/standby/level", "/ui/logo/brightness"] { + assert!(sets(&simulator, other).is_empty(), "{other}"); + } + assert!(delay_sets(&simulator).is_empty()); } #[test] From 4816f465410f4945db1905a5752383557c3a02b6 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:12:39 +0200 Subject: [PATCH 254/460] Show where a dragged strip element lands The strip draws the drop as it would be, from where it drew its elements the frame before; letting go stores exactly that. Drops reach strips of the same model only. --- CHANGELOG.md | 2 + README.md | 5 +- crates/app/src/views/mod.rs | 1 + crates/app/src/views/strip.rs | 180 ++++++++++++++------------ crates/app/src/views/strip_drop.rs | 184 +++++++++++++++++++++++++++ crates/app/tests/app/strip_layout.rs | 69 ++++++++++ 6 files changed, 358 insertions(+), 83 deletions(-) create mode 100644 crates/app/src/views/strip_drop.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index fec5f89..76459b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- While an element is dragged in edit mode, its strip shows it where it would land and the + other elements make room. - **Details** on each group strip opens the group's dialog (rename, delete), as its name does. - A speaker that is off keeps the name and model it last reported, instead of its masked address; removing the speaker forgets them. diff --git a/README.md b/README.md index ce0bc91..914d0cb 100644 --- a/README.md +++ b/README.md @@ -141,8 +141,9 @@ speaker of that model at once: up or down (past the fader too). Right-click a row in the device panel to put it on the strip (**Add to strip**) or take it off. - The pen in the toolbar (**Customize strips**) switches every strip into edit mode: **+ Add** - adds an element, dragging one with the left button moves it (also across the fader), - right-click hides it, and **Default** brings back the strip above. The controls write nothing + adds an element, dragging one with the left button moves it (also across the fader; while you + drag, the strip already shows it where it would land, faded, and the others make room; it + goes to strips of the same model only), right-click hides it, and **Default** brings back the strip above. The controls write nothing while you edit; the pen again (**Done**) ends it. - Any number, switch, choice or read-only value of the device panel can go on a strip; the device name and lists such as the input mix stay in the panel. diff --git a/crates/app/src/views/mod.rs b/crates/app/src/views/mod.rs index 8ced8e2..b2ab3e8 100644 --- a/crates/app/src/views/mod.rs +++ b/crates/app/src/views/mod.rs @@ -20,6 +20,7 @@ pub mod remove_speaker; pub mod scan_choice; pub mod settings_dialog; pub mod strip; +mod strip_drop; pub mod system_strip; pub mod toolbar; pub mod trends; diff --git a/crates/app/src/views/strip.rs b/crates/app/src/views/strip.rs index 19be716..a41fb04 100644 --- a/crates/app/src/views/strip.rs +++ b/crates/app/src/views/strip.rs @@ -16,6 +16,7 @@ use super::fader::{self, Orientation, Scale, FADER_HEIGHT}; use super::knob; use super::layout_menu::{self, Offer}; use super::meters::{self, Meters}; +use super::strip_drop::{self, Dragged, Geometry, Slot}; use crate::display::shown_value; use crate::eq::has_eq; use crate::fader::{output_level_range, WHEEL_STEP_DB}; @@ -25,7 +26,7 @@ use crate::meter::MeterState; use crate::paths; use crate::state::{SpeakerState, Status}; use crate::status_leds::{status_leds, ConnectionLed}; -use crate::strip_layout::{Area, LayoutChange, StripLayout}; +use crate::strip_layout::{self, Area, LayoutChange, StripLayout}; use crate::texts; use crate::ui_kit::theme::Tokens; use crate::writes::{self, Intent}; @@ -49,8 +50,9 @@ const CAPTION_HEIGHT: f32 = 16.0; const LEVEL_ENTRY_ID: &str = "level_entry"; const KNOBS_PER_ROW: usize = 2; const DRAG_ID: &str = "strip_element_drag"; -const DROP_ID: &str = "strip_element_drop"; -const DROP_ZONE_HEIGHT: f32 = 8.0; +const GEOMETRY_ID: &str = "strip_geometry"; +/// How much of the card colour lies over the dragged element at its landing place. +const LANDING_COVER: f32 = 0.6; pub struct StripResponse { /// The writes the user asked for that the write policy allowed. @@ -72,40 +74,18 @@ struct Arrange<'a> { area: Area, stored: &'a [StripElement], catalog: &'a Catalog, + /// The model the layout belongs to; a drag carries it to strips of the same model only. + product: &'a str, + /// The dragged element at its landing place, drawn faded. + landing: Option<&'a StripElement>, } -impl Arrange<'_> { - /// Where `dropped` goes when let go on `target`: just before it, counted in the stored list - /// without `dropped`, so an element the speaker lacks keeps its place. - fn before(&self, dropped: &StripElement, target: &StripElement) -> LayoutChange { - let others: Vec<&StripElement> = self - .stored - .iter() - .filter(|element| *element != dropped) - .collect(); - let index = others - .iter() - .position(|element| *element == target) - .unwrap_or(others.len()); - LayoutChange::MoveTo(dropped.clone(), self.area, index) - } - - /// Where `dropped` goes when let go after the last element of the area. - fn at_end(&self, dropped: &StripElement) -> LayoutChange { - let others = self - .stored - .iter() - .filter(|element| *element != dropped) - .count(); - LayoutChange::MoveTo(dropped.clone(), self.area, others) - } -} - -/// What the strip's areas asked for besides writes. +/// What the strip's areas asked for besides writes, and where its elements were drawn. #[derive(Default)] struct Wishes { open_eq: bool, layout: Option, + slots: Vec, } /// What the strip shows besides its fixed part, and whether it is being edited. @@ -127,6 +107,33 @@ pub fn show( let tokens = Tokens::current(ui.ctx()); let mut commands = Vec::new(); let mut wishes = Wishes::default(); + // While an element of this model is dragged over the strip, it is drawn where it would land. + let geometry_id = ui.scope_id().with(GEOMETRY_ID); + let dragged = editing + .then(|| DragAndDrop::payload::(ui.ctx())) + .flatten() + .filter(|dragged| dragged.product == speaker.product()); + let pointer = ui.ctx().pointer_latest_pos(); + let previous = ui.data(|data| data.get_temp::(geometry_id)); + let landing = match (&dragged, pointer, &previous) { + (Some(dragged), Some(pointer), Some(previous)) if previous.strip.contains(pointer) => Some( + strip_drop::landing(previous, layout, &dragged.element, pointer), + ), + _ => None, + }; + let preview = landing + .as_ref() + .and_then(|change| strip_layout::apply(layout, change)) + .map(|stored| StripLayout { + above: stored.above, + below: stored.below, + }); + let shown = preview.as_ref().unwrap_or(layout); + let landed = preview + .as_ref() + .and(dragged.as_deref()) + .map(|dragged| &dragged.element); + let product = speaker.product().to_owned(); // Only a speaker that reported its model can change that model's layout. let offers = speaker .identity @@ -138,11 +145,14 @@ pub fn show( editing, area, stored: match area { - Area::Above => &layout.above, - Area::Below => &layout.below, + Area::Above => &shown.above, + Area::Below => &shown.below, }, catalog, + product: &product, + landing: landed, }; + let mut fader = Rect::NOTHING; let inner_width = STRIP_WIDTH - 2.0 * (f32::from(STRIP_MARGIN) + BORDER_WIDTH); let frame = egui::Frame::new() .fill(tokens.card) @@ -175,9 +185,12 @@ pub fn show( &mut wishes, ); // While editing the fader is as still as the other controls. - ui.add_enabled_ui(!editing, |ui| { - level(ui, speaker, catalog, desk, &mut commands, &tokens); - }); + fader = ui + .add_enabled_ui(!editing, |ui| { + level(ui, speaker, catalog, desk, &mut commands, &tokens); + }) + .response + .rect; if desk.show_response_times { response_time(ui, speaker); } @@ -197,6 +210,18 @@ pub fn show( }) .inner }); + if landing.is_some() && ui.input(|input| input.pointer.primary_released()) { + wishes.layout = landing; + DragAndDrop::clear_payload(ui.ctx()); + } + if editing { + let geometry = Geometry { + slots: wishes.slots, + fader, + strip: frame.response.rect, + }; + ui.data_mut(|data| data.insert_temp(geometry_id, geometry)); + } StripResponse { commands, open_panel: frame.inner, @@ -311,13 +336,10 @@ fn elements( } } } - if arrange.editing && arrange.offers.is_some() { - drop_zone(ui, arrange, wishes); - } } /// Draws one element with its context menu (which also hides it). While editing it is a still -/// control, dragged as a whole with the primary button, and what is let go on it lands before it. +/// control, dragged as a whole with the primary button. fn element_cell( ui: &mut Ui, element: StripElement, @@ -331,9 +353,13 @@ fn element_cell( }; let rect = match arrange.editing { false => ui.scope(draw).response.rect, - true => editable_cell(ui, &element, draw), + true => { + let rect = ui.scope(|ui| ui.add_enabled_ui(false, draw)).response.rect; + arrange_while_editing(ui, rect, &element, arrange, wishes); + rect + } }; - arrange_at(ui, rect, element, offers, arrange, wishes); + element_menu(ui, rect, &element, offers, wishes); } /// Like `element_cell` for a button in a wrapped row. It gets no child area of its own: one would @@ -351,72 +377,64 @@ fn inline_cell( return; }; if arrange.editing { - drag_handle(ui, response.rect, &element); + arrange_while_editing(ui, response.rect, &element, arrange, wishes); } - arrange_at(ui, response.rect, element, offers, arrange, wishes); + element_menu(ui, response.rect, &element, offers, wishes); } -/// The context menu of an element drawn at `rect` and, while editing, the drop target on it. -fn arrange_at( +fn element_menu( ui: &mut Ui, rect: Rect, - element: StripElement, + element: &StripElement, offers: &[Offer], - arrange: &Arrange, wishes: &mut Wishes, ) { - if let Some(change) = layout_menu::element_menu(ui, rect, &element, offers) { + if let Some(change) = layout_menu::element_menu(ui, rect, element, offers) { wishes.layout = Some(change); } - if !arrange.editing { - return; - } - let drop_id = ui - .scope_id() - .with((DROP_ID, layout_menu::menu_key(&element))); - let target = ui.interact(rect, drop_id, Sense::hover()); - if let Some(dropped) = target.dnd_release_payload::() { - if *dropped != element { - wishes.layout = Some(arrange.before(&dropped, &element)); - } - } } -fn editable_cell(ui: &mut Ui, element: &StripElement, draw: impl FnOnce(&mut Ui)) -> Rect { - let rect = ui.scope(|ui| ui.add_enabled_ui(false, draw)).response.rect; - drag_handle(ui, rect, element); - rect +/// While editing: notes where the element is (for the next frame's landing), fades it when it +/// is the dragged one shown at its landing place, and lets it be dragged. +fn arrange_while_editing( + ui: &mut Ui, + rect: Rect, + element: &StripElement, + arrange: &Arrange, + wishes: &mut Wishes, +) { + wishes.slots.push(Slot { + area: arrange.area, + element: element.clone(), + rect, + }); + if arrange.landing == Some(element) { + let cover = Tokens::current(ui.ctx()).card.gamma_multiply(LANDING_COVER); + ui.painter() + .rect_filled(rect, CornerRadius::same(STRIP_RADIUS), cover); + } + drag_handle(ui, rect, element, arrange.product); } /// Lies over a still element while editing. Only the primary button drags: the secondary one /// opens the element's menu, and egui would start a drag for it as well. -fn drag_handle(ui: &mut Ui, rect: Rect, element: &StripElement) { +fn drag_handle(ui: &mut Ui, rect: Rect, element: &StripElement, product: &str) { let drag_id = ui .scope_id() .with((DRAG_ID, layout_menu::menu_key(element))); let handle = ui.interact(rect, drag_id, Sense::drag()); if handle.drag_started_by(PointerButton::Primary) { - DragAndDrop::set_payload(ui.ctx(), element.clone()); + let dragged = Dragged { + product: product.to_owned(), + element: element.clone(), + }; + DragAndDrop::set_payload(ui.ctx(), dragged); } if handle.hovered() { ui.ctx().set_cursor_icon(CursorIcon::Grab); } } -/// A thin target after the last element of an area, so an empty area can take one too. -fn drop_zone(ui: &mut Ui, arrange: &Arrange, wishes: &mut Wishes) { - let size = vec2(ui.available_width(), DROP_ZONE_HEIGHT); - let (rect, response) = ui.allocate_exact_size(size, Sense::hover()); - if response.dnd_hover_payload::().is_some() { - let tokens = Tokens::current(ui.ctx()); - let radius = CornerRadius::same(STRIP_RADIUS); - ui.painter().rect_filled(rect, radius, tokens.border); - } - if let Some(dropped) = response.dnd_release_payload::() { - wishes.layout = Some(arrange.at_end(&dropped)); - } -} - /// Under the strip while editing: whose layout it is, "+ Add" and "Default". fn edit_controls(ui: &mut Ui, model: &str, offers: &[Offer], wishes: &mut Wishes) { ui.label(RichText::new(texts::applies_to_every(model)).small().weak()); diff --git a/crates/app/src/views/strip_drop.rs b/crates/app/src/views/strip_drop.rs new file mode 100644 index 0000000..1cbc09f --- /dev/null +++ b/crates/app/src/views/strip_drop.rs @@ -0,0 +1,184 @@ +//! Where an element dragged in edit mode lands: above or below the fader by the pointer's +//! height, before the first other element of that area the pointer is not past yet (reading +//! order), else at the area's end. The strip draws that landing while the drag lasts. + +use eframe::egui::{Pos2, Rect}; +use neumann_control_core::settings::StripElement; + +use crate::strip_layout::{Area, LayoutChange, StripLayout}; + +/// What a drag in edit mode carries: the element, and the model whose layout it belongs to. +#[derive(Clone, Debug, PartialEq)] +pub struct Dragged { + pub product: String, + pub element: StripElement, +} + +/// One element as the strip last drew it. +#[derive(Clone, Debug, PartialEq)] +pub struct Slot { + pub area: Area, + pub element: StripElement, + pub rect: Rect, +} + +/// Where a strip last drew its elements, its fader and itself. +#[derive(Clone, Debug, PartialEq)] +pub struct Geometry { + pub slots: Vec, + pub fader: Rect, + pub strip: Rect, +} + +/// The change that puts `dragged` where the pointer is, counted in the stored list of the area +/// without `dragged`, so an element the speaker lacks keeps its place. +pub fn landing( + geometry: &Geometry, + layout: &StripLayout, + dragged: &StripElement, + pointer: Pos2, +) -> LayoutChange { + let area = if pointer.y < geometry.fader.center().y { + Area::Above + } else { + Area::Below + }; + let next = geometry + .slots + .iter() + .filter(|slot| slot.area == area && slot.element != *dragged) + .find(|slot| comes_after(pointer, slot.rect)) + .map(|slot| &slot.element); + let stored = match area { + Area::Above => &layout.above, + Area::Below => &layout.below, + }; + let others: Vec<&StripElement> = stored + .iter() + .filter(|element| *element != dragged) + .collect(); + let index = next + .and_then(|next| others.iter().position(|element| *element == next)) + .unwrap_or(others.len()); + LayoutChange::MoveTo(dragged.clone(), area, index) +} + +/// Whether an element at `rect` comes after the pointer in reading order: on a later line, or +/// on the pointer's line with its middle not left of the pointer. +fn comes_after(pointer: Pos2, rect: Rect) -> bool { + pointer.y < rect.top() || (pointer.y <= rect.bottom() && pointer.x <= rect.center().x) +} + +#[cfg(test)] +mod tests { + use super::*; + use eframe::egui::{pos2, vec2}; + use neumann_control_core::ssc::wire::SscPath; + + fn path(text: &str) -> StripElement { + StripElement::Path(SscPath::parse(text).unwrap()) + } + + const DELAY: &str = "audio/out/delay"; + const STANDBY: &str = "device/standby/auto_standby_time"; + const MUTE: &str = "audio/out/mute"; + const IDENTIFY: &str = "device/identification/visual"; + const PHASE: &str = "audio/out/phaseinversion"; + + fn at(x: f32, y: f32) -> Rect { + Rect::from_min_size(pos2(x, y), vec2(40.0, 20.0)) + } + + /// Delay and standby knobs side by side above a fader at y 100–200; mute and identify on + /// one line below, EQ on the next. + fn strip() -> (Geometry, StripLayout) { + let slot = |area, element, rect| Slot { + area, + element, + rect, + }; + let geometry = Geometry { + slots: vec![ + slot(Area::Above, path(DELAY), at(0.0, 40.0)), + slot(Area::Above, path(STANDBY), at(60.0, 40.0)), + slot(Area::Below, path(MUTE), at(0.0, 220.0)), + slot(Area::Below, path(IDENTIFY), at(60.0, 220.0)), + slot(Area::Below, StripElement::Eq, at(0.0, 250.0)), + ], + fader: Rect::from_min_max(pos2(0.0, 100.0), pos2(120.0, 200.0)), + strip: Rect::from_min_max(pos2(0.0, 0.0), pos2(130.0, 300.0)), + }; + let layout = StripLayout { + above: vec![path(DELAY), path(STANDBY)], + below: vec![path(MUTE), path(IDENTIFY), StripElement::Eq], + }; + (geometry, layout) + } + + #[test] + fn let_go_on_an_element_lands_before_it() { + let (geometry, layout) = strip(); + let on_mute = at(0.0, 220.0).center(); + assert_eq!( + landing(&geometry, &layout, &path(DELAY), on_mute), + LayoutChange::MoveTo(path(DELAY), Area::Below, 0) + ); + } + + #[test] + fn right_of_an_elements_middle_lands_after_it() { + let (geometry, layout) = strip(); + let pointer = at(0.0, 220.0).center() + vec2(10.0, 0.0); + assert_eq!( + landing(&geometry, &layout, &path(DELAY), pointer), + LayoutChange::MoveTo(path(DELAY), Area::Below, 1) + ); + } + + #[test] + fn past_the_last_element_lands_at_the_end() { + let (geometry, layout) = strip(); + assert_eq!( + landing(&geometry, &layout, &path(DELAY), pos2(100.0, 290.0)), + LayoutChange::MoveTo(path(DELAY), Area::Below, 3) + ); + } + + #[test] + fn above_the_faders_middle_is_the_upper_area() { + let (geometry, layout) = strip(); + let pointer = pos2(10.0, 120.0); + assert_eq!( + landing(&geometry, &layout, &StripElement::Eq, pointer), + LayoutChange::MoveTo(StripElement::Eq, Area::Above, 2) + ); + let before_standby = at(60.0, 40.0).center(); + assert_eq!( + landing(&geometry, &layout, &path(MUTE), before_standby), + LayoutChange::MoveTo(path(MUTE), Area::Above, 1) + ); + } + + /// The dragged element is not a target of its own, and its own place does not count. + #[test] + fn the_dragged_element_is_left_out() { + let (geometry, layout) = strip(); + let on_itself = at(60.0, 220.0).center(); + assert_eq!( + landing(&geometry, &layout, &path(IDENTIFY), on_itself), + LayoutChange::MoveTo(path(IDENTIFY), Area::Below, 1) + ); + } + + /// An element the speaker lacks is not drawn but keeps its place in the stored list. + #[test] + fn an_absent_element_keeps_its_stored_place() { + let (geometry, mut layout) = strip(); + layout.below.insert(1, path(PHASE)); + let on_identify = at(60.0, 220.0).center(); + assert_eq!( + landing(&geometry, &layout, &path(DELAY), on_identify), + LayoutChange::MoveTo(path(DELAY), Area::Below, 2) + ); + } +} diff --git a/crates/app/tests/app/strip_layout.rs b/crates/app/tests/app/strip_layout.rs index 094b45d..5ac9195 100644 --- a/crates/app/tests/app/strip_layout.rs +++ b/crates/app/tests/app/strip_layout.rs @@ -355,6 +355,75 @@ fn editing_sends_nothing() { assert!(any_set(&simulators[0]), "the check itself sees a set"); } +/// While a knob is dragged over the buttons, the strip already shows it there: the knob row +/// above the fader closes and the buttons below make room. Nothing is stored before letting go. +#[test] +fn a_drag_shows_where_the_element_lands_before_letting_go() { + let (_simulators, directory, mut harness) = desk_of(&[SimModel::Kh120Ii], knob_strip_layouts()); + start_editing(&mut harness); + let mute_before = center(&harness, Role::Button, texts::MUTE.get()); + let from = center(&harness, Role::Slider, label(paths::DELAY)); + harness.drag_at(from); + for frame in 1..=DRAG_FRAMES { + let progress = frame as f32 / DRAG_FRAMES as f32; + harness.hover_at(from + (mute_before - from) * progress); + harness.step(); + } + harness.run_steps(2); + let delay = center(&harness, Role::Slider, label(paths::DELAY)); + // The landing is drawn from where the strip drew its elements the frame before; it must + // settle, not flicker between two places. + for _ in 0..4 { + harness.step(); + assert_eq!(center(&harness, Role::Slider, label(paths::DELAY)), delay); + } + let fader = harness + .get_by_role_and_label(Role::Slider, texts::LEVEL.get()) + .rect(); + assert!( + delay.y > fader.bottom(), + "the delay knob is shown below the fader" + ); + let mute_now = center(&harness, Role::Button, texts::MUTE.get()); + assert_ne!(mute_now, mute_before, "mute made room"); + assert_eq!( + stored_layout(&directory, MONITOR), + knob_strip_layouts().remove(MONITOR), + "nothing stored while dragging" + ); + harness.drop_at(mute_before); + harness.run_steps(3); + let stored = stored_layout(&directory, MONITOR).expect("a stored layout"); + assert_eq!(stored.below.first(), Some(&element(paths::DELAY))); +} + +/// Layouts are per model: a knob of a KH 120 II let go on a KH 750 strip lands nowhere. +#[test] +fn a_drag_onto_another_models_strip_changes_nothing() { + let (_simulators, directory, mut harness) = + desk_of(&[SimModel::Kh120Ii, SimModel::Kh750], knob_strip_layouts()); + start_editing(&mut harness); + let delays: Vec = harness + .query_all_by_role_and_label(Role::Slider, label(paths::DELAY)) + .map(|node| node.rect().center()) + .collect(); + let (from, other_strip) = (delays[0], delays[1]); + let other_mutes: Vec = harness + .query_all_by_role_and_label(Role::Button, texts::MUTE.get()) + .map(|node| node.rect().center()) + .collect(); + drag(&mut harness, from, other_mutes[1]); + assert!(other_strip.x > from.x, "the KH 750 strip is the second one"); + let layouts = knob_strip_layouts(); + for model in [MONITOR, SUBWOOFER] { + assert_eq!( + stored_layout(&directory, model), + layouts.get(model).cloned(), + "{model}" + ); + } +} + #[test] fn dragging_moves_across_the_fader() { let (_simulators, directory, mut harness) = desk_of(&[SimModel::Kh120Ii], knob_strip_layouts()); From 9f88d8fb85d8bae5819fd7698b0acec849af9de6 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:21:03 +0200 Subject: [PATCH 255/460] Choose the meter floor in 10 dB steps --- CHANGELOG.md | 2 ++ README.md | 2 +- crates/app/src/views/settings_dialog.rs | 16 +++++++++++---- crates/app/tests/app/views.rs | 18 +++++++++++++++++ crates/core/src/settings.rs | 26 +++++++++++++++++++++++-- crates/core/tests/settings.rs | 17 +++++++++++++--- 6 files changed, 71 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76459b0..023dbe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,8 @@ ### Changed +- The lowest level the meters show is chosen from a list in 10 dB steps (−120 to −30 dB) instead + of −120, −90 or −60 dB; a saved choice stays. - The device panel lists its settings by topic, the outputs first and together, and in each topic what can be set before what is only shown. - On the KH 750 the outputs are named after what they drive: Subwoofer (its own woofer), Output A diff --git a/README.md b/README.md index 914d0cb..cc08696 100644 --- a/README.md +++ b/README.md @@ -310,7 +310,7 @@ recorded. - the theme: like Windows, light or dark, - the language: like Windows, German or English. It changes at once. German shows a decimal comma; typed values take a comma or a point. -- the lowest level the meters show: −120, −90 or −60 dB, +- the lowest level the meters show: −120 to −30 dB in 10 dB steps, - whether delays are shown and typed in milliseconds or in meters, as the distance sound travels in that time (343 m/s, at 20 °C); the speakers still get samples, - whether response times are shown, diff --git a/crates/app/src/views/settings_dialog.rs b/crates/app/src/views/settings_dialog.rs index 39528a0..fa44da6 100644 --- a/crates/app/src/views/settings_dialog.rs +++ b/crates/app/src/views/settings_dialog.rs @@ -15,6 +15,7 @@ use crate::ui_kit::eq_palette::{band_color, curve_color, hex, BAND_COLOR_COUNT}; const MODAL_ID: &str = "settings_dialog"; const SYSTEM_DEVICE_ID: &str = "system_audio_device"; +const METER_FLOOR_ID: &str = "meter_floor"; const BAND_GRID_ID: &str = "band_colors"; const BAND_COLUMNS: usize = 10; /// Taller than this, the dialog scrolls. @@ -72,11 +73,18 @@ fn contents( ui.radio_value(language, LanguageChoice::German, texts::LANGUAGE_GERMAN); ui.radio_value(language, LanguageChoice::English, texts::LANGUAGE_ENGLISH); }); - ui.label(texts::METER_RANGE); ui.horizontal(|ui| { - for floor in MeterFloor::ALL { - ui.radio_value(&mut desk.meter_floor, floor, texts::meter_floor(floor.db())); - } + let label = ui.label(texts::METER_RANGE); + ComboBox::from_id_salt(METER_FLOOR_ID) + .selected_text(texts::meter_floor(desk.meter_floor.db())) + .show_ui(ui, |ui| { + for floor in MeterFloor::ALL { + let text = texts::meter_floor(floor.db()); + ui.selectable_value(&mut desk.meter_floor, floor, text); + } + }) + .response + .labelled_by(label.id); }); ui.label(texts::DELAY_SHOWN_IN); ui.horizontal(|ui| { diff --git a/crates/app/tests/app/views.rs b/crates/app/tests/app/views.rs index 18075b3..58e3d18 100644 --- a/crates/app/tests/app/views.rs +++ b/crates/app/tests/app/views.rs @@ -672,6 +672,24 @@ fn the_delay_unit_switches_to_meters_and_is_saved() { ); } +/// The meter range in 10 dB steps, from a list. +#[test] +fn a_meter_floor_between_the_old_steps_is_chosen_and_saved() { + let (_simulator, directory, mut harness) = connected_speaker(SimModel::Kh120Ii); + open_settings(&mut harness); + harness + .get_by_role_and_label(Role::ComboBox, texts::METER_RANGE.get()) + .click(); + harness.run_steps(2); + scroll_to_and_click(&mut harness, &texts::meter_floor(-70.0)); + harness.run_steps(2); + let path = directory + .path() + .join(neumann_control_core::settings::SETTINGS_FILE); + let saved = neumann_control_core::settings::load(&path); + assert_eq!(saved.desk.meter_floor.db(), -70.0); +} + #[test] fn english_applies_at_once_and_is_saved() { let (_simulator, directory, mut harness) = connected_speaker(SimModel::Kh120Ii); diff --git a/crates/core/src/settings.rs b/crates/core/src/settings.rs index 654224f..eb01fc2 100644 --- a/crates/core/src/settings.rs +++ b/crates/core/src/settings.rs @@ -310,27 +310,49 @@ impl DelayUnit { pub const ALL: [DelayUnit; 2] = [DelayUnit::Milliseconds, DelayUnit::Meters]; } -/// The lowest level a meter shows; quiet room volume reads about -90 dB on the inputs. +/// The lowest level a meter shows, in 10 dB steps; quiet room volume reads about -90 dB on the +/// inputs. The names stay as files have them. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] pub enum MeterFloor { #[default] Minus120, + Minus110, + Minus100, Minus90, + Minus80, + Minus70, Minus60, + Minus50, + Minus40, + Minus30, } impl MeterFloor { - pub const ALL: [MeterFloor; 3] = [ + pub const ALL: [MeterFloor; 10] = [ MeterFloor::Minus120, + MeterFloor::Minus110, + MeterFloor::Minus100, MeterFloor::Minus90, + MeterFloor::Minus80, + MeterFloor::Minus70, MeterFloor::Minus60, + MeterFloor::Minus50, + MeterFloor::Minus40, + MeterFloor::Minus30, ]; pub const fn db(self) -> f64 { match self { MeterFloor::Minus120 => -120.0, + MeterFloor::Minus110 => -110.0, + MeterFloor::Minus100 => -100.0, MeterFloor::Minus90 => -90.0, + MeterFloor::Minus80 => -80.0, + MeterFloor::Minus70 => -70.0, MeterFloor::Minus60 => -60.0, + MeterFloor::Minus50 => -50.0, + MeterFloor::Minus40 => -40.0, + MeterFloor::Minus30 => -30.0, } } } diff --git a/crates/core/tests/settings.rs b/crates/core/tests/settings.rs index 901e0e7..717deba 100644 --- a/crates/core/tests/settings.rs +++ b/crates/core/tests/settings.rs @@ -426,10 +426,21 @@ fn the_eq_display_keeps_its_choices_and_falls_back_on_nonsense() { assert_eq!(load(&path).eq_display.band_curves, BandCurves::All); } +/// The lowest level a meter shows, in 10 dB steps from -120 to -30 dB. #[test] -fn the_meter_floors_are_below_zero_db() { - let floors = MeterFloor::ALL.map(MeterFloor::db); - assert_eq!(floors, [-120.0, -90.0, -60.0]); +fn the_meter_floors_go_in_10_db_steps() { + let floors: Vec = MeterFloor::ALL.iter().map(|floor| floor.db()).collect(); + let steps: Vec = (3..=12).rev().map(|tens| -10.0 * f64::from(tens)).collect(); + assert_eq!(floors, steps); +} + +/// Files written with the three floors before keep them. +#[test] +fn earlier_meter_floors_still_load() { + for (name, db) in [("Minus120", -120.0), ("Minus90", -90.0), ("Minus60", -60.0)] { + let floor: MeterFloor = serde_json::from_str(&format!("\"{name}\"")).unwrap(); + assert_eq!(floor.db(), db); + } } fn element(text: &str) -> StripElement { From d6ddec2e5995bcb7465963b274ebe2c82a2b4a07 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:25:42 +0200 Subject: [PATCH 256/460] Open the device panel within the screen A fixed height let its foot reach below the screen; it now opens at the main window and only as tall as the screen leaves below. --- CHANGELOG.md | 3 +- README.md | 3 +- crates/app/src/app.rs | 20 +++++++- crates/app/src/views/panel.rs | 88 +++++++++++++++++++++++++++++++++-- 4 files changed, 106 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 023dbe1..ac3f541 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,7 +73,8 @@ current input follows a switch of the interface right away. - The device panel opens in a window of its own, one per speaker, that can be moved anywhere (also outside the main window); a click beside it no longer closes it. **Details** under each - strip opens it, as does the speaker's name. + strip opens it, as does the speaker's name. It opens at the main window and no taller than the + screen leaves, so its foot stays visible. ### Fixed diff --git a/README.md b/README.md index cc08696..5f7efce 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,8 @@ time of the connected ones. ### Device panel Click a speaker's name or **Details** under its strip. The panel opens in a window of its own, -one per speaker, which you can move anywhere, also beside the main window or onto another screen; +one per speaker, at the top left of the main window and no taller than the screen leaves below +it, which you can move anywhere, also beside the main window or onto another screen; opening it again brings it to the front. It lists every setting the catalog knows for that model by topic in two columns: the outputs first and together (on the KH 750 its own, then A and B), then input, standby, device and rear panel; in each topic what can be set comes diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index afee7f9..3f19687 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -37,6 +37,7 @@ use crate::ui_kit::eq_palette::{curve_key, CURVE_KEYS}; use crate::ui_kit::{fonts, theme}; use crate::views::add_address::{self, AddAddressAction, AddAddressForm}; use crate::views::group_dialog::{self, GroupDialogAction}; +use crate::views::panel::PanelPlacement; use crate::views::remove_speaker::{self, RemoveAction}; use crate::views::scan_choice::{self as scan_choice_view, ScanChoiceAction}; use crate::views::settings_dialog::{self, SettingsAction}; @@ -72,6 +73,8 @@ struct OpenPanel { viewport: ViewportId, /// Opened again while open: the window comes to the front. focus: bool, + /// Fixed in the first frame the panel is drawn. + placement: Option, } /// The speaker "Entfernen" was clicked for, while the confirmation is open. Key and address both, @@ -500,6 +503,7 @@ impl NeumannApp { key, viewport, focus: false, + placement: None, }); } @@ -533,6 +537,12 @@ impl NeumannApp { return true; }; let viewport = open.viewport; + let placement = *open.placement.get_or_insert_with(|| { + let (main, monitor) = context.input_for(ViewportId::ROOT, |input| { + (input.viewport().outer_rect, input.viewport().monitor_size) + }); + PanelPlacement::new(main, monitor) + }); if std::mem::take(&mut open.focus) { context.send_viewport_cmd_to(viewport, ViewportCommand::Focus); } @@ -560,7 +570,15 @@ impl NeumannApp { .identity .is_some() .then(|| strip_layout::resolve(stored, &product, catalog)); - let response = panel::show(context, viewport, state, catalog, &choice, layout.as_ref()); + let response = panel::show( + context, + viewport, + placement, + state, + catalog, + &choice, + layout.as_ref(), + ); if let Some(change) = &response.layout { self.change_layout(&product, change, catalog); } diff --git a/crates/app/src/views/panel.rs b/crates/app/src/views/panel.rs index e53574a..098751b 100644 --- a/crates/app/src/views/panel.rs +++ b/crates/app/src/views/panel.rs @@ -2,8 +2,8 @@ //! anywhere, opened from its strip. It closes on Escape, its close button or the window's. use eframe::egui::{ - self, Button, ComboBox, Context, Id, Key, Label, RichText, ScrollArea, Ui, ViewportBuilder, - ViewportClass, ViewportId, + self, Button, ComboBox, Context, Id, Key, Label, Pos2, Rect, RichText, ScrollArea, Ui, Vec2, + ViewportBuilder, ViewportClass, ViewportId, }; use neumann_control_core::catalog::Catalog; use neumann_control_core::mask::mask_serial; @@ -25,6 +25,39 @@ const GROUP_CHOICE_ID: &str = "group_choice"; const EDITING_ID: &str = "editing"; const PANEL_WINDOW_SIZE: [f32; 2] = [600.0, 760.0]; const PANEL_WINDOW_MIN_SIZE: [f32; 2] = [420.0, 300.0]; +/// From the main window's top left, so the panel is seen to belong to it. +const PANEL_OFFSET: Vec2 = Vec2::new(40.0, 40.0); +/// Kept free below the panel for the taskbar. +const SCREEN_MARGIN: f32 = 60.0; + +/// Where the panel window opens and how large: fixed when it opens, so it does not follow the +/// main window afterwards. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PanelPlacement { + pub position: Option, + pub size: Vec2, +} + +impl PanelPlacement { + /// At the main window's top left (`main`, screen points), only as tall as the screen + /// (`monitor`) leaves below that: a fixed height let the panel's foot reach below the + /// screen. Without them the system places the window. + pub fn new(main: Option, monitor: Option) -> PanelPlacement { + let full = Vec2::from(PANEL_WINDOW_SIZE); + let position = main.map(|main| main.min + PANEL_OFFSET); + let room = match (position, monitor) { + // The monitor's top is taken as 0: true for the main screen and for screens beside it. + (Some(position), Some(monitor)) => monitor.y - position.y - SCREEN_MARGIN, + (None, Some(monitor)) => monitor.y - SCREEN_MARGIN, + (_, None) => full.y, + }; + let height = room.clamp(PANEL_WINDOW_MIN_SIZE[1], full.y); + PanelPlacement { + position, + size: Vec2::new(full.x, height), + } + } +} pub struct PanelResponse { /// The writes the user asked for that the write policy allowed. @@ -49,19 +82,23 @@ pub struct GroupChoice<'a> { pub linked: &'a [Linkable], } -/// The panel in its own window (`viewport`), one per speaker. +/// The panel in its own window (`viewport`), one per speaker, opened at `placement`. pub fn show( context: &Context, viewport: ViewportId, + placement: PanelPlacement, speaker: &mut SpeakerState, catalog: &Catalog, groups: &GroupChoice, layout: Option<&StripLayout>, ) -> PanelResponse { - let builder = ViewportBuilder::default() + let mut builder = ViewportBuilder::default() .with_title(texts::panel_window_title(&speaker.display_name())) - .with_inner_size(PANEL_WINDOW_SIZE) + .with_inner_size(placement.size) .with_min_inner_size(PANEL_WINDOW_MIN_SIZE); + if let Some(position) = placement.position { + builder = builder.with_position(position); + } context.show_viewport_immediate(viewport, builder, |ui, class| { // Several panels can be open; their widgets must not share state. let mut response = ui @@ -219,3 +256,44 @@ fn copy_buttons(ui: &mut Ui, groups: &GroupChoice) -> Option { }); copy } + +#[cfg(test)] +mod tests { + use super::*; + use eframe::egui::{pos2, vec2}; + + const SCREEN: Vec2 = Vec2::new(1920.0, 1080.0); + + fn main_at(x: f32, y: f32) -> Option { + Some(Rect::from_min_size(pos2(x, y), vec2(1200.0, 700.0))) + } + + #[test] + fn the_panel_opens_at_the_main_windows_top_left_in_full_height() { + let placement = PanelPlacement::new(main_at(100.0, 50.0), Some(SCREEN)); + assert_eq!(placement.position, Some(pos2(100.0, 50.0) + PANEL_OFFSET)); + assert_eq!(placement.size, Vec2::from(PANEL_WINDOW_SIZE)); + } + + /// The panel's foot used to reach below the screen. + #[test] + fn a_low_main_window_gives_a_shorter_panel_that_ends_on_the_screen() { + let placement = PanelPlacement::new(main_at(100.0, 600.0), Some(SCREEN)); + let top = placement.position.unwrap().y; + assert!(top + placement.size.y <= SCREEN.y - SCREEN_MARGIN + 0.001); + assert_eq!(placement.size.x, PANEL_WINDOW_SIZE[0]); + } + + #[test] + fn a_tiny_screen_still_gets_the_least_panel() { + let placement = PanelPlacement::new(main_at(0.0, 0.0), Some(vec2(800.0, 200.0))); + assert_eq!(placement.size.y, PANEL_WINDOW_MIN_SIZE[1]); + } + + #[test] + fn without_window_or_screen_the_system_places_it() { + let placement = PanelPlacement::new(None, None); + assert_eq!(placement.position, None); + assert_eq!(placement.size, Vec2::from(PANEL_WINDOW_SIZE)); + } +} From 9083ac1840857c1f8451188153bddc32f91beef1 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:38:28 +0200 Subject: [PATCH 257/460] Give scroll bars room of their own egui's floating bars covered a strip's foot in a small window. --- CHANGELOG.md | 2 ++ crates/app/src/ui_kit/theme.rs | 5 ++++- crates/app/tests/app/views.rs | 31 +++++++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac3f541..8816d4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,8 @@ ### Fixed +- Scroll bars lay over the desk and covered the foot of a strip (**Details**); they now have room + of their own. - In edit mode a right-drag moved the element along; only the left button drags now, and the × beside each element is gone (right-click → **Hide** hides it). - A speaker not reached since the start showed no meters, and their caption lay over the scale; diff --git a/crates/app/src/ui_kit/theme.rs b/crates/app/src/ui_kit/theme.rs index e10bfe8..dbd3919 100644 --- a/crates/app/src/ui_kit/theme.rs +++ b/crates/app/src/ui_kit/theme.rs @@ -3,7 +3,7 @@ use std::sync::Arc; -use eframe::egui::style::{Selection, WidgetVisuals}; +use eframe::egui::style::{ScrollStyle, Selection, WidgetVisuals}; use eframe::egui::{ vec2, Color32, Context, CornerRadius, FontId, Stroke, Style, TextStyle, Theme, ThemePreference, Visuals, @@ -152,6 +152,9 @@ fn style(theme: Theme) -> Style { style.spacing.item_spacing = vec2(8.0, 6.0); style.spacing.button_padding = vec2(10.0, 4.0); style.spacing.interact_size = vec2(24.0, 24.0); + // egui's bars float over the content and covered a strip's foot ("Details"); solid bars + // take their own room. + style.spacing.scroll = ScrollStyle::solid(); style } diff --git a/crates/app/tests/app/views.rs b/crates/app/tests/app/views.rs index 58e3d18..d3b529a 100644 --- a/crates/app/tests/app/views.rs +++ b/crates/app/tests/app/views.rs @@ -620,6 +620,37 @@ fn desk_scrolls_when_narrow() { harness.get_by_role(Role::ScrollBar); } +/// Scrolled to the foot of a strip in a small window, "Details" is not under the horizontal +/// scroll bar (egui's bars float over the content by default). +#[test] +fn the_scroll_bar_does_not_cover_a_strips_foot() { + let (_simulators, _directory, mut harness) = desk_of( + &[SimModel::Kh120Ii, SimModel::Kh750], + std::collections::BTreeMap::new(), + ); + harness.set_size(egui::vec2(300.0, 500.0)); + harness.run_steps(2); + harness + .query_all_by_role_and_label(Role::Button, texts::DETAILS.get()) + .next() + .expect("a details button") + .scroll_to_me(); + harness.run_steps(SCROLL_FRAMES); + let details = harness + .query_all_by_role_and_label(Role::Button, texts::DETAILS.get()) + .next() + .unwrap() + .rect(); + let bars: Vec = harness + .query_all_by_role(Role::ScrollBar) + .map(|node| node.rect()) + .collect(); + assert!(!bars.is_empty()); + for bar in bars { + assert!(!bar.intersects(details), "{bar:?} covers {details:?}"); + } +} + fn open_settings(harness: &mut Harness<'static, NeumannApp>) { harness.get_by_label(texts::SETTINGS.get()).click(); wait_for_label(harness, texts::THEME.get()); From 75311710369c7d2579918c695021b09a59247e63 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:48:18 +0200 Subject: [PATCH 258/460] Label the temperature line and drop empty trends The highest, lowest and current value stand at the line; a model without a temperature shows no trend section, its warnings are on the LEDs. --- CHANGELOG.md | 2 ++ README.md | 5 ++-- crates/app/src/views/panel.rs | 8 ++++-- crates/app/src/views/trends.rs | 45 +++++++++++++++++++++++---------- crates/app/tests/app/details.rs | 37 +++++++++++++++++++++------ 5 files changed, 72 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8816d4d..103dc85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,8 @@ ### Changed +- The temperature trend shows its highest, lowest and current value in °C at the line; models + without a temperature (KH 120 II) show no trend section. - The lowest level the meters show is chosen from a list in 10 dB steps (−120 to −30 dB) instead of −120, −90 or −60 dB; a saved choice stays. - The device panel lists its settings by topic, the outputs first and together, and in each topic diff --git a/README.md b/README.md index 5f7efce..1698658 100644 --- a/README.md +++ b/README.md @@ -195,8 +195,9 @@ offered, the name unchecked). **Load preset…** shows what a preset would chang writes it as one history entry; settings this model may not write are listed as skipped. **Trend (2 h)** below the settings shows what the app read in this session, at most the last two -hours: the temperature as a line (KH 750) with its lowest and highest value, and the warnings as a -bar, red while a warning was active. Hover over them for the value and how long ago it was read. +hours, on models with a temperature (KH 750): the temperature as a line, with its highest and +lowest value at the left and the current one at its end in °C, and the warnings as a bar, red +while a warning was active. Hover over them for the value and how long ago it was read. Gaps mark the times the speaker could not be read. To remove a speaker, press **Remove** in its panel and confirm. The speaker is no diff --git a/crates/app/src/views/panel.rs b/crates/app/src/views/panel.rs index 098751b..e93b735 100644 --- a/crates/app/src/views/panel.rs +++ b/crates/app/src/views/panel.rs @@ -141,8 +141,12 @@ fn contents( let copy_to_group = copy_buttons(ui, groups); let details = details::show(ui, speaker, catalog, layout); ui.separator(); - trends::show(ui, &trends_of(ui, speaker, catalog)); - ui.separator(); + // Without a temperature only the warnings would be left, which the LEDs show. + let trends = trends_of(ui, speaker, catalog); + if trends.temperature.is_some() { + trends::show(ui, &trends); + ui.separator(); + } let (save_preset, load_preset) = ui .horizontal(|ui| { let save = ui.button(texts::SAVE_PRESET).clicked(); diff --git a/crates/app/src/views/trends.rs b/crates/app/src/views/trends.rs index a877d5e..128d361 100644 --- a/crates/app/src/views/trends.rs +++ b/crates/app/src/views/trends.rs @@ -2,7 +2,7 @@ //! as a bar, over the last two hours; hovering shows how long ago and what was read. use eframe::egui::{ - self, pos2, vec2, Color32, Rect, RichText, Role, Sense, Stroke, Ui, WidgetInfo, + self, pos2, vec2, Align2, Color32, FontId, Rect, RichText, Role, Sense, Stroke, Ui, WidgetInfo, }; use neumann_control_core::catalog::Conversion; @@ -18,6 +18,9 @@ const LINE_WIDTH: f32 = 1.5; const LINE_MARGIN: f32 = 6.0; /// A temperature that stayed the same still gets a band of this many degrees to sit in. const MIN_SPAN_DEGREES: f64 = 2.0; +const MARK_FONT_SIZE: f32 = 9.0; +/// Keeps the values at the line off the frame. +const MARK_INSET: f32 = 3.0; pub struct Trends<'a> { /// Seconds of the UI clock, the right edge. pub now: f64, @@ -113,17 +116,11 @@ fn temperature_line( let middle = shown((low + high) / 2.0); // Kelvin to Celsius only shifts the scale, so the span is the same in either unit. let half_span = ((high - low) / 2.0).max(MIN_SPAN_DEGREES / 2.0); - let current = trend - .value_at(now) - .map(|kelvin| converted(conversion, *kelvin)); - ui.horizontal(|ui| { - ui.label(temperature.label); - if high > low { - let range = - texts::trend_range(&converted(conversion, low), &converted(conversion, high)); - ui.weak(range); - } - }); + let current_kelvin = trend.value_at(now).copied(); + let current = current_kelvin.map(|kelvin| converted(conversion, kelvin)); + let range = (high > low) + .then(|| texts::trend_range(&converted(conversion, low), &converted(conversion, high))); + ui.label(temperature.label); let width = ui.available_width(); let (rect, response) = ui.allocate_exact_size(vec2(width, LINE_HEIGHT), Sense::hover()); let painter = ui.painter_at(rect); @@ -154,7 +151,29 @@ fn temperature_line( painter.line_segment([left, right], stroke); previous = Some(right); } - let label = texts::temperature_trend(current.as_deref().unwrap_or_default()); + // The values at the line: highest top left, lowest bottom left, the current one at its end. + let font = FontId::proportional(MARK_FONT_SIZE); + let inside = rect.shrink(MARK_INSET); + if high > low { + let marks = [ + (inside.left_top(), Align2::LEFT_TOP, high), + (inside.left_bottom(), Align2::LEFT_BOTTOM, low), + ]; + for (position, align, kelvin) in marks { + let text = converted(conversion, kelvin); + painter.text(position, align, text, font.clone(), tokens.text_dim); + } + } + if let (Some(kelvin), Some(text)) = (current_kelvin, ¤t) { + // Above the line's end, but never above the frame when the line runs along its top. + let lowest_top = inside.top() + MARK_FONT_SIZE + MARK_INSET; + let end = pos2(inside.right(), y(shown(kelvin)).max(lowest_top)); + painter.text(end, Align2::RIGHT_BOTTOM, text, font, tokens.text); + } + let mut label = texts::temperature_trend(current.as_deref().unwrap_or_default()); + if let Some(range) = &range { + label = format!("{label} ({range})"); + } response.widget_info(|| WidgetInfo::labeled(Role::Label, true, &label)); if let Some(pointer) = response.hover_pos() { let time = axis.time(pointer.x); diff --git a/crates/app/tests/app/details.rs b/crates/app/tests/app/details.rs index 72fd327..8192a22 100644 --- a/crates/app/tests/app/details.rs +++ b/crates/app/tests/app/details.rs @@ -171,6 +171,16 @@ fn an_announced_model_gives_no_write_controls_or_model_rows() { assert!(harness.query_by_label(LOGO_LABEL).is_none()); } +/// The history shows the temperature; a model without one (KH 120 II) gets no history section. +#[test] +fn the_history_is_shown_only_for_a_model_with_a_temperature() { + let title = texts::trends_title(neumann_control_app::trend::TREND_WINDOW_SECONDS); + let (_monitor, _directory, monitor_panel) = speaker_with_panel(SimModel::Kh120Ii); + assert!(monitor_panel.query_by_label_contains(&title).is_none()); + let (_subwoofer, _directory, subwoofer_panel) = speaker_with_panel(SimModel::Kh750); + assert!(subwoofer_panel.query_by_label_contains(&title).is_some()); +} + #[test] fn number_field_sends_once_per_gesture() { let (simulator, _directory, mut harness) = speaker_with_panel(SimModel::Kh120Ii); @@ -559,20 +569,31 @@ fn the_panel_shows_the_temperature_trend_of_a_kh750() { simulator.set_value("device/temperature", json!(313)); wait_until(&mut harness, "the trend at 40 °C", |harness| { harness - .query_by_label(&texts::temperature_trend("40 °C")) + .query_all_by_label_contains(&texts::temperature_trend("40 °C")) + .next() .is_some() }); harness.get_by_label(texts::WARNING_TREND.get()); } +/// The temperature line carries its lowest, highest and current value in °C. #[test] -fn a_kh120_has_only_the_warning_trend() { - let (_simulator, _directory, harness) = speaker_with_panel(SimModel::Kh120Ii); - harness.get_by_label(texts::WARNING_TREND.get()); - let temperature = texts::temperature_trend(""); - assert!(harness - .query_by_label_contains(temperature.trim_end()) - .is_none()); +fn the_temperature_line_names_its_range() { + let (simulator, _directory, mut harness) = speaker_with_panel(SimModel::Kh750); + // Below the simulator's 37 °C, so it is the lowest of the session. + simulator.set_value("device/temperature", json!(300)); + wait_until(&mut harness, "27 °C", |harness| { + harness + .query_all_by_label_contains(&texts::temperature_trend("27 °C")) + .next() + .is_some() + }); + simulator.set_value("device/temperature", json!(316)); + let range = texts::trend_range("27 °C", "43 °C"); + let line = format!("{} ({range})", texts::temperature_trend("43 °C")); + wait_until(&mut harness, "the range at the line", |harness| { + harness.query_all_by_label_contains(&line).next().is_some() + }); } #[test] From 589d8877635035feeabd7037ba07cef308452b38 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:55:51 +0200 Subject: [PATCH 259/460] Let the master strip be hidden --- CHANGELOG.md | 1 + README.md | 1 + crates/app/src/app/compact.rs | 8 +++++--- crates/app/src/app/desk.rs | 15 +++++++++------ crates/app/src/texts.rs | 1 + crates/app/src/views/settings_dialog.rs | 1 + crates/app/tests/app/views.rs | 21 +++++++++++++++++++++ crates/core/src/settings.rs | 4 ++++ crates/core/tests/settings.rs | 9 +++++++++ 9 files changed, 52 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 103dc85..c2b5ebd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- **Show master strip** in **Settings** hides the master strip and its compact row. - While an element is dragged in edit mode, its strip shows it where it would land and the other elements make room. - **Details** on each group strip opens the group's dialog (rename, delete), as its name does. diff --git a/README.md b/README.md index 1698658..073e6a5 100644 --- a/README.md +++ b/README.md @@ -316,6 +316,7 @@ recorded. - whether delays are shown and typed in milliseconds or in meters, as the distance sound travels in that time (343 m/s, at 20 °C); the speakers still get samples, - whether response times are shown, +- whether the master strip (and its row in the compact mode) is shown, - whether the EQ crossovers are protected (on by default), - the EQ display: own curves of all bands, or only of the chosen band and the one under the pointer; the colour of each band number and of each EQ curve (**Default colours** resets them), diff --git a/crates/app/src/app/compact.rs b/crates/app/src/app/compact.rs index 2b14ad9..1c7b98e 100644 --- a/crates/app/src/app/compact.rs +++ b/crates/app/src/app/compact.rs @@ -61,9 +61,11 @@ impl NeumannApp { } } } - let everyone: Vec = (0..self.speakers.len()).collect(); - let shown = self.shared_row(&everyone, floor, now); - actions.push(DeskAction::Master(compact::master(ui, shown))); + if self.settings.desk.show_master { + let everyone: Vec = (0..self.speakers.len()).collect(); + let shown = self.shared_row(&everyone, floor, now); + actions.push(DeskAction::Master(compact::master(ui, shown))); + } }); for action in actions { self.apply_desk_action(action, catalog, &ids, now); diff --git a/crates/app/src/app/desk.rs b/crates/app/src/app/desk.rs index ffc4e09..b6801fb 100644 --- a/crates/app/src/app/desk.rs +++ b/crates/app/src/app/desk.rs @@ -138,12 +138,15 @@ impl NeumannApp { } } } - let everyone: Vec = (0..self.speakers.len()).collect(); - let states = self.states(&everyone); - let level = shared_level(&levels(&states), &range); - let muted = all_muted(&states); - let response = group_strip::master(ui, level.as_ref(), muted, range.clone()); - actions.push(DeskAction::Master(response)); + if desk.show_master { + let everyone: Vec = (0..self.speakers.len()).collect(); + let states = self.states(&everyone); + let level = shared_level(&levels(&states), &range); + let muted = all_muted(&states); + let response = + group_strip::master(ui, level.as_ref(), muted, range.clone()); + actions.push(DeskAction::Master(response)); + } if let Some(view) = &system { let strip = SystemStrip { name: &view.name, diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 0ecaf20..cef20b1 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -271,6 +271,7 @@ pub const LANGUAGE_GERMAN: &str = "Deutsch"; pub const LANGUAGE_ENGLISH: &str = "English"; pub const METER_RANGE: Text = Text::new("Pegelanzeige ab", "Meters from"); pub const SHOW_RESPONSE_TIMES: Text = Text::new("Antwortzeiten anzeigen", "Show response times"); +pub const SHOW_MASTER: Text = Text::new("Master-Zug anzeigen", "Show master strip"); pub const DELAY_SHOWN_IN: Text = Text::new("Verzögerung anzeigen in", "Show delays in"); pub const DELAY_IN_MILLISECONDS: Text = Text::new("Millisekunden", "milliseconds"); /// 343 m/s, the speed of sound at 20 °C, as `catalog` converts with. diff --git a/crates/app/src/views/settings_dialog.rs b/crates/app/src/views/settings_dialog.rs index fa44da6..224afe6 100644 --- a/crates/app/src/views/settings_dialog.rs +++ b/crates/app/src/views/settings_dialog.rs @@ -93,6 +93,7 @@ fn contents( ui.radio_value(unit, DelayUnit::Meters, texts::DELAY_IN_METERS); }); ui.checkbox(&mut desk.show_response_times, texts::SHOW_RESPONSE_TIMES); + ui.checkbox(&mut desk.show_master, texts::SHOW_MASTER); let mut protected = desk.crossovers == CrossoverGuard::Protected; if ui .checkbox(&mut protected, texts::PROTECT_CROSSOVERS) diff --git a/crates/app/tests/app/views.rs b/crates/app/tests/app/views.rs index d3b529a..8b01795 100644 --- a/crates/app/tests/app/views.rs +++ b/crates/app/tests/app/views.rs @@ -721,6 +721,27 @@ fn a_meter_floor_between_the_old_steps_is_chosen_and_saved() { assert_eq!(saved.desk.meter_floor.db(), -70.0); } +/// "Show master" off hides the master strip on the desk and its row in the compact mode. +#[test] +fn the_master_can_be_hidden() { + let (_simulator, directory, mut harness) = connected_speaker(SimModel::Kh120Ii); + let master = texts::MASTER_LEVEL.get(); + assert!(harness.query_by_label(master).is_some()); + open_settings(&mut harness); + scroll_to_and_click(&mut harness, texts::SHOW_MASTER.get()); + harness.run_steps(2); + scroll_to_and_click(&mut harness, texts::CLOSE.get()); + harness.run_steps(2); + assert!(harness.query_by_label(master).is_none()); + harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::K); + harness.run_steps(3); + assert!(harness.query_by_label(master).is_none(), "compact mode"); + let path = directory + .path() + .join(neumann_control_core::settings::SETTINGS_FILE); + assert!(!neumann_control_core::settings::load(&path).desk.show_master); +} + #[test] fn english_applies_at_once_and_is_saved() { let (_simulator, directory, mut harness) = connected_speaker(SimModel::Kh120Ii); diff --git a/crates/core/src/settings.rs b/crates/core/src/settings.rs index eb01fc2..6f85834 100644 --- a/crates/core/src/settings.rs +++ b/crates/core/src/settings.rs @@ -180,6 +180,9 @@ pub struct DeskSettings { pub meter_floor: MeterFloor, #[serde(deserialize_with = "or_default")] pub show_response_times: bool, + /// The master strip at the end of the desk and the master row in the compact mode. + #[serde(deserialize_with = "or_default")] + pub show_master: bool, #[serde(deserialize_with = "or_default")] pub language: LanguageChoice, #[serde(deserialize_with = "or_default")] @@ -241,6 +244,7 @@ impl Default for DeskSettings { theme: ThemeChoice::System, meter_floor: MeterFloor::Minus120, show_response_times: true, + show_master: true, language: LanguageChoice::System, compact: false, crossovers: CrossoverGuard::Protected, diff --git a/crates/core/tests/settings.rs b/crates/core/tests/settings.rs index 717deba..43bfd01 100644 --- a/crates/core/tests/settings.rs +++ b/crates/core/tests/settings.rs @@ -426,6 +426,15 @@ fn the_eq_display_keeps_its_choices_and_falls_back_on_nonsense() { assert_eq!(load(&path).eq_display.band_curves, BandCurves::All); } +/// A file from before the setting shows the master, as the app did then. +#[test] +fn an_older_file_shows_the_master() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(SETTINGS_FILE); + fs::write(&path, r#"{"desk": {"show_response_times": false}}"#).unwrap(); + assert!(load(&path).desk.show_master); +} + /// The lowest level a meter shows, in 10 dB steps from -120 to -30 dB. #[test] fn the_meter_floors_go_in_10_db_steps() { From e3b9aa7a74f5d63fedf90b05fcd3c3d76c89aad6 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:02:07 +0200 Subject: [PATCH 260/460] Fit the first window to the desk Without a saved size the window takes what the strips need and grows with them until the user sizes it; the test fixtures save a window like every later start. --- CHANGELOG.md | 2 + README.md | 4 + crates/app/src/app.rs | 5 ++ crates/app/src/app/desk.rs | 26 +++++- crates/app/src/lib.rs | 1 + crates/app/src/window_fit.rs | 122 ++++++++++++++++++++++++++++ crates/app/tests/app/support/mod.rs | 14 +++- crates/app/tests/app/views.rs | 26 ++++++ 8 files changed, 197 insertions(+), 3 deletions(-) create mode 100644 crates/app/src/window_fit.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c2b5ebd..8e33972 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- Without a saved window size, the window is made as large as the desk needs and grows with it + until you size it yourself. - **Show master strip** in **Settings** hides the master strip and its compact row. - While an element is dragged in edit mode, its strip shows it where it would land and the other elements make room. diff --git a/README.md b/README.md index 073e6a5..5e34f33 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,10 @@ On the KH 120 II the input meters are captioned with the rear input switch (Anal AES3 R, Mono), and a dot marks the channels the speaker plays. That channel A is the left AES3 channel is assumed, not measured. +At the first start, and whenever no window size is saved, the window is made as large as the +desk needs and grows with it (a speaker added, a strip that widens once its model is known) +until you size it yourself; from then on the app keeps the size you chose. + The toolbar shows the number of speakers (not in the narrowest window) and the average response time of the connected ones. diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index 3f19687..8cb0bf6 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -43,6 +43,7 @@ use crate::views::scan_choice::{self as scan_choice_view, ScanChoiceAction}; use crate::views::settings_dialog::{self, SettingsAction}; use crate::views::toolbar::{self, ToolbarAction, ToolbarFacts}; use crate::views::{all_values, panel, wheel}; +use crate::window_fit::WindowFit; mod compact; mod desk; @@ -107,6 +108,8 @@ pub struct NeumannApp { /// The desk's strips are being customized; not saved, a new start shows the desk as usual. editing: bool, window: Option, + /// Without a saved desk window, the window is sized to the desk until the user sizes it. + window_fit: Option, history: history::HistoryState, eq_windows: Vec, preset_dialog: Option, @@ -156,6 +159,8 @@ impl NeumannApp { let repaint_context = context.clone(); let mut app = NeumannApp { window: settings.window_of_mode(), + window_fit: (settings.window.is_none() && !settings.desk.compact) + .then(WindowFit::default), settings_path, settings, spawner, diff --git a/crates/app/src/app/desk.rs b/crates/app/src/app/desk.rs index b6801fb..811beb1 100644 --- a/crates/app/src/app/desk.rs +++ b/crates/app/src/app/desk.rs @@ -4,7 +4,9 @@ use std::collections::HashMap; -use eframe::egui::{Align, Context, DragAndDrop, Id, Layout, ScrollArea, Ui}; +use eframe::egui::{ + Align, Context, DragAndDrop, Id, Layout, Rect, ScrollArea, Ui, Vec2, ViewportCommand, +}; use neumann_control_core::catalog::Catalog; use neumann_control_core::settings::{GroupSettings, Linkable}; use neumann_control_core::speaker::Command; @@ -50,6 +52,25 @@ pub(super) struct GroupDraft { } impl NeumannApp { + /// While the first window is fitted: as large as the desk's content with the margin it has + /// on the left and top also on the right and bottom. + fn fit_window(&mut self, context: &Context, desk: Rect, content: Vec2) { + let Some(fit) = &mut self.window_fit else { + return; + }; + let margin = desk.min.to_vec2(); + let wanted = margin + content + Vec2::splat(margin.x); + let (current, screen) = context.input(|input| { + let viewport = input.viewport(); + let current = viewport.inner_rect.map(|inner| inner.size()); + (current, viewport.monitor_size) + }); + let current = current.unwrap_or_else(|| context.content_rect().size()); + if let Some(size) = fit.next(current, wanted, screen) { + context.send_viewport_cmd(ViewportCommand::InnerSize(size)); + } + } + pub(super) fn show_desk(&mut self, ui: &mut Ui) { if self.speakers.is_empty() { ui.weak(texts::NO_SPEAKERS); @@ -71,7 +92,7 @@ impl NeumannApp { let now = ui.input(|input| input.time); let system = self.read_system_volume(now); let mut actions = Vec::new(); - ScrollArea::both() + let scroll = ScrollArea::both() .id_salt(DESK_SCROLL_ID) .auto_shrink([false, false]) .show(ui, |ui| { @@ -159,6 +180,7 @@ impl NeumannApp { } }); }); + self.fit_window(ui.ctx(), scroll.inner_rect, scroll.content_size); ui.ctx() .data_mut(|data| data.insert_temp(Id::unique(STRIP_CENTERS_ID), centers)); let dropped = dragged.is_some() && ui.input(|input| input.pointer.any_released()); diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index a960cf3..603f98e 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -27,5 +27,6 @@ pub mod texts; pub mod trend; pub mod ui_kit; pub mod views; +pub mod window_fit; pub mod window_placement; pub mod writes; diff --git a/crates/app/src/window_fit.rs b/crates/app/src/window_fit.rs new file mode 100644 index 0000000..13799ff --- /dev/null +++ b/crates/app/src/window_fit.rs @@ -0,0 +1,122 @@ +//! The first window without a saved size: as large as the desk needs, growing with it (a speaker +//! added, a strip that widens once its model is known) until the user sizes the window. Sizes +//! are points. + +use eframe::egui::Vec2; + +use crate::window_placement::WINDOW_MIN_SIZE; + +/// A difference below this is rounding, not a size to ask for. +const TOLERANCE: f32 = 1.0; +/// Kept free around the window on the screen. +const SCREEN_MARGIN: f32 = 60.0; + +#[derive(Debug, Default)] +pub struct WindowFit { + /// The size asked for and the size the window had then; until the window has one of them + /// the request is under way. + pending: Option<(Vec2, Vec2)>, + /// The user sized the window: the fit is over. + done: bool, +} + +impl WindowFit { + /// The size to ask the window for this frame, if any. `current` is the window's inner size, + /// `wanted` what the desk needs, `screen` the monitor's size when known. + pub fn next(&mut self, current: Vec2, wanted: Vec2, screen: Option) -> Option { + if self.done { + return None; + } + if let Some((asked, before)) = self.pending { + if close(current, before) { + return None; + } + if !close(current, asked) { + self.done = true; + return None; + } + self.pending = None; + } + let wanted = bounded(wanted, screen); + if close(current, wanted) { + return None; + } + self.pending = Some((wanted, current)); + Some(wanted) + } +} + +fn bounded(wanted: Vec2, screen: Option) -> Vec2 { + let at_least = wanted.max(Vec2::from(WINDOW_MIN_SIZE)); + match screen { + Some(screen) => at_least.min(screen - Vec2::splat(SCREEN_MARGIN)), + None => at_least, + } +} + +fn close(one: Vec2, other: Vec2) -> bool { + (one - other).abs().max_elem() < TOLERANCE +} + +#[cfg(test)] +mod tests { + use super::*; + use eframe::egui::vec2; + + const START: Vec2 = Vec2::new(900.0, 600.0); + const SCREEN: Option = Some(Vec2::new(1920.0, 1080.0)); + + #[test] + fn the_window_is_asked_for_what_the_desk_needs() { + let mut fit = WindowFit::default(); + assert_eq!( + fit.next(START, vec2(700.0, 800.0), SCREEN), + Some(vec2(700.0, 800.0)) + ); + } + + #[test] + fn nothing_is_asked_while_a_request_is_under_way_or_when_it_fits() { + let mut fit = WindowFit::default(); + let wanted = vec2(700.0, 800.0); + fit.next(START, wanted, SCREEN); + assert_eq!(fit.next(START, wanted, SCREEN), None, "under way"); + assert_eq!(fit.next(wanted, wanted, SCREEN), None, "fits"); + } + + #[test] + fn it_grows_with_the_desk() { + let mut fit = WindowFit::default(); + fit.next(START, vec2(700.0, 800.0), SCREEN); + fit.next(vec2(700.0, 800.0), vec2(700.0, 800.0), SCREEN); + assert_eq!( + fit.next(vec2(700.0, 800.0), vec2(900.0, 800.0), SCREEN), + Some(vec2(900.0, 800.0)) + ); + } + + #[test] + fn a_window_the_user_sized_is_left_alone() { + let mut fit = WindowFit::default(); + fit.next(START, vec2(700.0, 800.0), SCREEN); + fit.next(vec2(700.0, 800.0), vec2(700.0, 800.0), SCREEN); + fit.next(vec2(700.0, 800.0), vec2(900.0, 800.0), SCREEN); + assert_eq!( + fit.next(vec2(1000.0, 500.0), vec2(900.0, 800.0), SCREEN), + None + ); + assert_eq!( + fit.next(vec2(1000.0, 500.0), vec2(1200.0, 900.0), SCREEN), + None + ); + } + + #[test] + fn the_request_stays_within_the_least_size_and_the_screen() { + let mut fit = WindowFit::default(); + assert_eq!( + fit.next(START, vec2(200.0, 3000.0), SCREEN), + Some(vec2(WINDOW_MIN_SIZE[0], 1080.0 - SCREEN_MARGIN)) + ); + } +} diff --git a/crates/app/tests/app/support/mod.rs b/crates/app/tests/app/support/mod.rs index a80c186..b876257 100644 --- a/crates/app/tests/app/support/mod.rs +++ b/crates/app/tests/app/support/mod.rs @@ -21,7 +21,7 @@ use neumann_control_core::catalog::Catalog; use neumann_control_core::discovery::Found; use neumann_control_core::settings::{ self, DeskSettings, GroupSettings, Language, Linkable, ManualSpeaker, Settings, - StoredStripLayout, StripElement, SETTINGS_FILE, + StoredStripLayout, StripElement, WindowGeometry, SETTINGS_FILE, }; use neumann_control_core::simulator::{SimModel, Simulator}; use neumann_control_core::speaker::{self, Events, SpeakerHandle, Target, Timing}; @@ -105,10 +105,20 @@ pub fn knob_strip_layouts() -> BTreeMap { .collect() } +/// A window the user sized before, as every start after the first has: the window keeps the +/// test's size instead of being fitted to the desk (`a_first_window_fits_the_desk` has none). +pub const SAVED_WINDOW: WindowGeometry = WindowGeometry { + x: 0.0, + y: 0.0, + width: WINDOW_SIZE[0], + height: WINDOW_SIZE[1], +}; + pub fn settings_file(directory: &Path, manual: Vec) -> PathBuf { let path = directory.join(SETTINGS_FILE); let settings = Settings { manual, + window: Some(SAVED_WINDOW), desk: DeskSettings { strip_layouts: knob_strip_layouts(), ..DeskSettings::default() @@ -448,6 +458,7 @@ pub fn grouped( second.serial = Some("SIM0007500".to_owned()); let settings = Settings { manual: vec![first, second], + window: Some(SAVED_WINDOW), groups: vec![GroupSettings { name: GROUP_NAME.to_owned(), color: 0, @@ -570,6 +581,7 @@ pub fn desk_prepared( let path = directory.path().join(SETTINGS_FILE); let settings = Settings { manual: simulators.iter().map(manual_for).collect(), + window: Some(SAVED_WINDOW), desk: DeskSettings { strip_layouts, ..DeskSettings::default() diff --git a/crates/app/tests/app/views.rs b/crates/app/tests/app/views.rs index 8b01795..bc9aa8f 100644 --- a/crates/app/tests/app/views.rs +++ b/crates/app/tests/app/views.rs @@ -651,6 +651,32 @@ fn the_scroll_bar_does_not_cover_a_strips_foot() { } } +/// Without a saved window the window is made as large as the desk needs: here one strip and the +/// master, far less than the first 900 points, with the strip's foot in the window. (kittest +/// carries out the window's size commands itself.) +#[test] +fn a_first_window_fits_the_desk() { + let simulator = Simulator::start(SimModel::Kh120Ii); + let directory = tempfile::tempdir().unwrap(); + let path = directory + .path() + .join(neumann_control_core::settings::SETTINGS_FILE); + let first_start = neumann_control_core::settings::Settings { + manual: vec![manual_for(&simulator)], + ..Default::default() + }; + neumann_control_core::settings::save(&first_start, &path).unwrap(); + let mut harness = harness(path, Vec::new()); + wait_for_label(&mut harness, texts::DETAILS.get()); + harness.run_steps(5); + let window = harness.ctx.content_rect(); + assert!(window.width() < 900.0, "{window:?}"); + let details = harness + .get_by_role_and_label(Role::Button, texts::DETAILS.get()) + .rect(); + assert!(window.contains_rect(details), "{details:?} in {window:?}"); +} + fn open_settings(harness: &mut Harness<'static, NeumannApp>) { harness.get_by_label(texts::SETTINGS.get()).click(); wait_for_label(harness, texts::THEME.get()); From 20121b50c7002a760288ae02b2894f405c706989 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:04:58 +0200 Subject: [PATCH 261/460] Open All values in a window of its own --- CHANGELOG.md | 1 + README.md | 3 ++- crates/app/src/views/all_values.rs | 37 +++++++++++++++++++++--------- crates/app/tests/app/views.rs | 11 +++++++++ 4 files changed, 40 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e33972..a5c9cbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ ### Changed +- **All values** opens in a window of its own like the EQ and the device panel; Escape closes it. - The temperature trend shows its highest, lowest and current value in °C at the line; models without a temperature (KH 120 II) show no trend section. - The lowest level the meters show is chosen from a list in 10 dB steps (−120 to −30 dB) instead diff --git a/README.md b/README.md index 5e34f33..df702b5 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,8 @@ much of each input that output plays. By default the KH 750 plays input A on out on output B and both on its own woofer. **Mute persist** is the speaker's own setting of that name; what it does has not been measured. -**All values…** lists every value the speaker reports in its own schema, with a path +**All values…** opens a window of its own (Escape closes it) that lists every value the speaker +reports in its own schema, with a path filter and copy buttons, including paths the catalog does not know yet. **Full scan** reads the same and writes it next to the executable as `scan---.json` and `.txt`, with what the app knows about each path; serial number, MAC address, the speaker's name and any diff --git a/crates/app/src/views/all_values.rs b/crates/app/src/views/all_values.rs index 547d8c1..5dc418d 100644 --- a/crates/app/src/views/all_values.rs +++ b/crates/app/src/views/all_values.rs @@ -1,7 +1,11 @@ -//! The "Alle Werte" window: every leaf of the device schema with its value, read-only. The -//! only thing it can ask the worker for is a fresh read. +//! The "Alle Werte" window: every leaf of the device schema with its value, read-only, in a +//! window of its own like the EQ and the panel. The only thing it can ask the worker for is a +//! fresh read. -use eframe::egui::{Context, Id, Label, ScrollArea, TextEdit, Ui, Window}; +use eframe::egui::{ + self, Context, Id, Key, Label, ScrollArea, TextEdit, Ui, ViewportBuilder, ViewportClass, + ViewportId, +}; use neumann_control_core::catalog::Catalog; use neumann_control_core::speaker::Command; @@ -18,16 +22,27 @@ const KNOWN_WIDTH: f32 = 70.0; const FILTER_WIDTH: f32 = 220.0; /// Draws the window while the speaker's state is not idle and closes it (back to idle) when -/// the user does. Returns the commands the user asked for. +/// the user does: its close button, or Escape outside the filter. Returns the commands the user +/// asked for. pub fn show(context: &Context, speaker: &mut SpeakerState, catalog: &Catalog) -> Vec { - let mut open = true; + let viewport = ViewportId::from_hash_of(Id::unique((WINDOW_ID, speaker.key.clone()))); + let builder = ViewportBuilder::default() + .with_title(texts::all_values_title(&speaker.display_name())) + .with_inner_size(DEFAULT_WINDOW_SIZE); let mut commands = Vec::new(); - Window::new(texts::all_values_title(&speaker.display_name())) - .id(Id::unique((WINDOW_ID, speaker.key.clone()))) - .default_size(DEFAULT_WINDOW_SIZE) - .open(&mut open) - .show(context, |ui| contents(ui, speaker, catalog, &mut commands)); - if !open { + let close = context.show_viewport_immediate(viewport, builder, |ui, class| { + ui.push_id(viewport, |ui| match class { + ViewportClass::EmbeddedWindow => contents(ui, speaker, catalog, &mut commands), + _ => { + egui::CentralPanel::default() + .show(ui, |ui| contents(ui, speaker, catalog, &mut commands)); + } + }); + let typing = ui.memory(|memory| memory.focused().is_some()); + let escape = !typing && ui.input(|input| input.key_pressed(Key::Escape)); + escape || ui.input(|input| input.viewport().close_requested()) + }); + if close { speaker.close_all_values(); } commands diff --git a/crates/app/tests/app/views.rs b/crates/app/tests/app/views.rs index bc9aa8f..df5706f 100644 --- a/crates/app/tests/app/views.rs +++ b/crates/app/tests/app/views.rs @@ -398,6 +398,17 @@ fn filter_all_values(harness: &mut Harness<'static, NeumannApp>, text: &str) { harness.run_steps(3); } +/// A window of its own like the EQ and the panel: Escape closes it. +#[test] +fn all_values_closes_on_escape() { + let (_simulator, _directory, mut harness) = all_values_window(SimModel::Kh120Ii); + let title = texts::all_values_title(SPEAKER_NAME); + harness.get_by_label(&title); + harness.key_press(egui::Key::Escape); + harness.run_steps(2); + assert!(harness.query_by_label(&title).is_none()); +} + #[test] fn all_values_lists_50_leaves_for_kh120ii() { let (_simulator, _directory, harness) = all_values_window(SimModel::Kh120Ii); From 97d8c054f848af8663f9706b984f0247bdb37125 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:17:36 +0200 Subject: [PATCH 262/460] Let compact rows' buttons be chosen per model Rows get EQ and Details next to Mute by default; a right click hides, moves or adds a button, stored per model like the strip layouts. --- CHANGELOG.md | 2 + README.md | 14 ++- crates/app/src/app/compact.rs | 13 +- crates/app/src/app/desk.rs | 22 ++++ crates/app/src/compact_layout.rs | 180 +++++++++++++++++++++++++++ crates/app/src/lib.rs | 1 + crates/app/src/strip_layout.rs | 2 +- crates/app/src/texts.rs | 3 + crates/app/src/views/compact.rs | 74 ++++++++--- crates/app/src/views/compact_menu.rs | 102 +++++++++++++++ crates/app/src/views/layout_menu.rs | 13 +- crates/app/src/views/mod.rs | 1 + crates/app/src/views/strip.rs | 2 +- crates/app/tests/app/compact.rs | 148 +++++++++++++++++++++- crates/core/src/settings.rs | 76 ++++++++++- crates/core/tests/settings.rs | 35 +++++- 16 files changed, 653 insertions(+), 35 deletions(-) create mode 100644 crates/app/src/compact_layout.rs create mode 100644 crates/app/src/views/compact_menu.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index a5c9cbe..5f46795 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- Compact rows of speakers have **EQ** and **Details** next to **Mute**; a right click on a + button hides it, moves it, adds another switch or goes back to the default, per model. - Without a saved window size, the window is made as large as the desk needs and grows with it until you size it yourself. - **Show master strip** in **Settings** hides the master strip and its compact row. diff --git a/README.md b/README.md index df702b5..a2d6730 100644 --- a/README.md +++ b/README.md @@ -230,9 +230,15 @@ cancels. **Compact** in the toolbar (or Ctrl+K) switches to a compact window and back. It has one row per ungrouped speaker and per group, each with LEDs (a group: its colour), the name, a lying level -fader with its value, the loudest input as a small meter and **Mute**, and a master row at the -bottom. A group row moves its members like the group strip. A **!** stands for a speaker's message -(on a group or the master row, a member's), which shows on hover. +fader with its value and the loudest input as a small meter, and a master row at the bottom. A +speaker's row then has **Mute**, **EQ** and **Details** (the device panel); a group or the master +row has **Mute**. A group row moves its members like the group strip. A **!** stands for a +speaker's message (on a group or the master row, a member's), which shows on hover. + +Right-click a button of a speaker's row to hide it, move it left or right, add another (a switch +such as **Ident.**, **Mute A**/**Mute B** or auto standby, or EQ and Details again) or go back to +**Default**; right-click elsewhere in the row to add one when none is left. The buttons are chosen +per model, like the strips. The compact window keeps its own size and position and scrolls sideways when it is narrower than its rows. Scanning and adding speakers or groups stay on the desk. @@ -371,7 +377,7 @@ The app is portable and writes only next to the executable: | File | Content | |------|---------| -| `settings.json` | Remembered speakers (address, port, serial, and the name and model each last reported, shown while it is off), groups and the strip order, the window position and size for the desk and the compact mode, the choices from **Settings**, and the presets | +| `settings.json` | Remembered speakers (address, port, serial, and the name and model each last reported, shown while it is off), groups and the strip order, the window position and size for the desk and the compact mode, the choices from **Settings**, each model's strip layout and compact buttons, and the presets | | `scan-*.json`, `scan-*.txt` | Written by **Full scan**, without serial number, MAC address, name or network addresses | | `neumann-control.log` | Log of the current run, replaced at each start | diff --git a/crates/app/src/app/compact.rs b/crates/app/src/app/compact.rs index 1c7b98e..aec4ba7 100644 --- a/crates/app/src/app/compact.rs +++ b/crates/app/src/app/compact.rs @@ -6,6 +6,7 @@ use neumann_control_core::catalog::Catalog; use super::desk::{all_muted, levels, members_of, DeskAction}; use super::{NeumannApp, COMPACT_SCROLL_ID}; +use crate::compact_layout; use crate::fader::output_level_range; use crate::groups::{compact_rows, layout, reference_level, shared_level, CompactRow}; use crate::texts; @@ -37,15 +38,25 @@ impl NeumannApp { CompactRow::Speaker(index) => { let speaker = &mut self.speakers[index]; let key = speaker.state.key.clone(); + let product = speaker.state.product().to_owned(); + let stored = desk.compact_layouts.get(&product).map(Vec::as_slice); + let buttons = compact_layout::resolve(stored, &product, catalog); + let state = &mut speaker.state; let response = ui .push_id(&key, |ui| { - compact::speaker(ui, &mut speaker.state, catalog, &desk) + compact::speaker(ui, state, catalog, &desk, &buttons) }) .inner; actions.push(DeskAction::Commands(index, response.commands)); + if response.open_eq { + actions.push(DeskAction::OpenEq(key.clone())); + } if response.open_panel { actions.push(DeskAction::OpenPanel(key)); } + if let Some(change) = response.layout { + actions.push(DeskAction::CompactLayout(product, change)); + } } CompactRow::Group(group) => { let settings = &self.settings.groups[group]; diff --git a/crates/app/src/app/desk.rs b/crates/app/src/app/desk.rs index 811beb1..0c246c4 100644 --- a/crates/app/src/app/desk.rs +++ b/crates/app/src/app/desk.rs @@ -13,6 +13,7 @@ use neumann_control_core::speaker::Command; use serde_json::Value; use super::{NeumannApp, DESK_SCROLL_ID}; +use crate::compact_layout::{self, CompactChange}; use crate::fader::output_level_range; use crate::groups::{ insertion_slot, layout, linkable_of, linked_path, member_id, new_group, order_of, shared_level, @@ -43,6 +44,8 @@ pub(super) enum DeskAction { System(SystemStripResponse), /// A change of one model's strip layout. Layout(String, LayoutChange), + /// A change of one model's compact buttons. + CompactLayout(String, CompactChange), } /// The group being renamed or deleted, and the name typed so far. @@ -230,6 +233,9 @@ impl NeumannApp { DeskAction::OpenEq(key) => self.open_eq(key), DeskAction::System(response) => self.apply_system_volume(response), DeskAction::Layout(product, change) => self.change_layout(&product, &change, catalog), + DeskAction::CompactLayout(product, change) => { + self.change_compact_layout(&product, &change, catalog); + } } } @@ -253,6 +259,22 @@ impl NeumannApp { self.save_settings(); } + /// Like `change_layout` for the compact buttons of `product`. + fn change_compact_layout(&mut self, product: &str, change: &CompactChange, catalog: &Catalog) { + let layouts = &mut self.settings.desk.compact_layouts; + let stored = layouts.get(product).map(Vec::as_slice); + let buttons = compact_layout::resolve(stored, product, catalog); + match compact_layout::apply(&buttons, change) { + Some(changed) => { + layouts.insert(product.to_owned(), changed); + } + None => { + layouts.remove(product); + } + } + self.save_settings(); + } + /// A group or master fader let go after moving by `moved` dB: every connected member moves by /// that much from its own current level, within its own range; a mute switch sets mute on all /// of them. diff --git a/crates/app/src/compact_layout.rs b/crates/app/src/compact_layout.rs new file mode 100644 index 0000000..f2a9ab0 --- /dev/null +++ b/crates/app/src/compact_layout.rs @@ -0,0 +1,180 @@ +//! Which buttons a model's compact rows show after their fixed part, and how a change to that +//! list is applied. Like the strip layout, the stored list is resolved against the catalog every +//! time, so a hand-edited or older file never puts a button on a row that the model cannot have. + +use neumann_control_core::catalog::{Catalog, Kind}; +use neumann_control_core::settings::CompactButton; + +use crate::paths; +use crate::strip_layout::fits_a_strip; + +#[derive(Clone, Debug, PartialEq)] +pub enum CompactChange { + Add(CompactButton), + Remove(CompactButton), + MoveLeft(CompactButton), + MoveRight(CompactButton), + Reset, +} + +/// What a row needs while working; the other switches can be added. +pub fn default_buttons(product: &str, catalog: &Catalog) -> Vec { + let buttons = [ + CompactButton::Switch(paths::path(paths::MUTE)), + CompactButton::Eq, + CompactButton::Details, + ]; + buttons + .into_iter() + .filter(|button| shows(button, product, catalog)) + .collect() +} + +/// The stored buttons, or the default ones, without what the model cannot have and without +/// repeats (the first one stays). +pub fn resolve( + stored: Option<&[CompactButton]>, + product: &str, + catalog: &Catalog, +) -> Vec { + let buttons = stored.map_or_else(|| default_buttons(product, catalog), <[_]>::to_vec); + let mut resolved: Vec = Vec::new(); + for button in buttons { + if shows(&button, product, catalog) && !resolved.contains(&button) { + resolved.push(button); + } + } + resolved +} + +/// What could still go on the row: the model's switches in catalog order, then EQ and Details. +pub fn addable(buttons: &[CompactButton], product: &str, catalog: &Catalog) -> Vec { + let switches = catalog + .entries_for(product) + .into_iter() + .filter(|entry| entry.kind == Kind::Switch && fits_a_strip(entry)) + .map(|entry| CompactButton::Switch(entry.path.clone())); + switches + .chain([CompactButton::Eq, CompactButton::Details]) + .filter(|button| shows(button, product, catalog) && !buttons.contains(button)) + .collect() +} + +/// The buttons to store after `change`; `None` drops the model's entry, so it shows the default. +pub fn apply(buttons: &[CompactButton], change: &CompactChange) -> Option> { + let mut changed = buttons.to_vec(); + let place = |button: &CompactButton| buttons.iter().position(|other| other == button); + match change { + CompactChange::Reset => return None, + CompactChange::Add(button) if place(button).is_none() => changed.push(button.clone()), + CompactChange::Add(_) => {} + CompactChange::Remove(button) => changed.retain(|other| other != button), + CompactChange::MoveLeft(button) => { + if let Some(index) = place(button).filter(|index| *index > 0) { + changed.swap(index, index - 1); + } + } + CompactChange::MoveRight(button) => { + if let Some(index) = place(button).filter(|index| index + 1 < buttons.len()) { + changed.swap(index, index + 1); + } + } + } + Some(changed) +} + +/// Whether `button` can be on a compact row of `product`. +fn shows(button: &CompactButton, product: &str, catalog: &Catalog) -> bool { + match button { + CompactButton::Details => true, + CompactButton::Eq => !catalog.eq_containers(product).is_empty(), + CompactButton::Switch(path) => catalog + .entries_for(product) + .into_iter() + .any(|entry| entry.path == *path && entry.kind == Kind::Switch && fits_a_strip(entry)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const MONITOR: &str = "KH 120 II"; + const SUBWOOFER: &str = "KH 750"; + + fn switch(text: &str) -> CompactButton { + CompactButton::Switch(paths::path(text)) + } + + fn mute_eq_details() -> Vec { + vec![ + switch(paths::MUTE), + CompactButton::Eq, + CompactButton::Details, + ] + } + + #[test] + fn the_default_is_mute_eq_and_details() { + assert_eq!( + default_buttons(MONITOR, Catalog::embedded()), + mute_eq_details() + ); + assert_eq!( + resolve(None, SUBWOOFER, Catalog::embedded()), + mute_eq_details() + ); + } + + #[test] + fn resolve_drops_repeats_and_what_is_no_switch_of_the_model() { + let stored = [ + switch(paths::MUTE_OUTPUT_A), + switch(paths::DELAY), + CompactButton::Details, + CompactButton::Details, + ]; + let resolved = resolve(Some(&stored), MONITOR, Catalog::embedded()); + assert_eq!(resolved, vec![CompactButton::Details]); + } + + #[test] + fn addable_offers_the_other_switches_but_no_values() { + let catalog = Catalog::embedded(); + let offered = addable(&mute_eq_details(), SUBWOOFER, catalog); + for wanted in [paths::IDENTIFY, paths::MUTE_OUTPUT_A, paths::MUTE_OUTPUT_B] { + assert!(offered.contains(&switch(wanted)), "{wanted}"); + } + for unwanted in [paths::MUTE, paths::DELAY, paths::TEMPERATURE] { + assert!(!offered.contains(&switch(unwanted)), "{unwanted}"); + } + assert!(!offered.contains(&CompactButton::Eq)); + let all_hidden = addable(&[], MONITOR, catalog); + assert!(all_hidden.ends_with(&[CompactButton::Eq, CompactButton::Details])); + } + + #[test] + fn add_remove_and_reset() { + let identify = switch(paths::IDENTIFY); + let added = apply(&mute_eq_details(), &CompactChange::Add(identify.clone())).unwrap(); + assert_eq!(added.last(), Some(&identify)); + let again = apply(&added, &CompactChange::Add(identify.clone())).unwrap(); + assert_eq!(again, added); + let removed = apply(&added, &CompactChange::Remove(identify)).unwrap(); + assert_eq!(removed, mute_eq_details()); + assert_eq!(apply(&removed, &CompactChange::Reset), None); + } + + #[test] + fn moving_stops_at_the_ends() { + let buttons = mute_eq_details(); + let right = apply(&buttons, &CompactChange::MoveRight(switch(paths::MUTE))).unwrap(); + assert_eq!(right[..2], [CompactButton::Eq, switch(paths::MUTE)]); + let left = apply(&right, &CompactChange::MoveLeft(switch(paths::MUTE))).unwrap(); + assert_eq!(left, buttons); + let first = apply(&buttons, &CompactChange::MoveLeft(switch(paths::MUTE))).unwrap(); + assert_eq!(first, buttons); + let last = apply(&buttons, &CompactChange::MoveRight(CompactButton::Details)).unwrap(); + assert_eq!(last, buttons); + } +} diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index 603f98e..c2a5b29 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -1,5 +1,6 @@ pub mod all_values; pub mod app; +pub mod compact_layout; pub mod display; pub mod eq; pub mod eq_file; diff --git a/crates/app/src/strip_layout.rs b/crates/app/src/strip_layout.rs index 1e19427..5e87329 100644 --- a/crates/app/src/strip_layout.rs +++ b/crates/app/src/strip_layout.rs @@ -235,7 +235,7 @@ fn usable_by_default(element: &StripElement, product: &str, catalog: &Catalog) - } /// One value a strip can show: no text field, no band list, no list of numbers, no meter. -fn fits_a_strip(entry: &Entry) -> bool { +pub(crate) fn fits_a_strip(entry: &Entry) -> bool { let one_value = !entry.bands && entry.element_labels(Language::German).is_empty(); let kind = matches!( entry.kind, diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index cef20b1..640a170 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -147,6 +147,9 @@ pub const HIDE: Text = Text::new("Ausblenden", "Hide"); pub const SWAP_FOR: Text = Text::new("Tauschen gegen …", "Swap for …"); pub const MOVE_UP: Text = Text::new("Nach oben", "Move up"); pub const MOVE_DOWN: Text = Text::new("Nach unten", "Move down"); +pub const MOVE_LEFT: Text = Text::new("Nach links", "Move left"); +pub const MOVE_RIGHT: Text = Text::new("Nach rechts", "Move right"); +pub const ADD_BUTTON: Text = Text::new("Hinzufügen …", "Add …"); pub const ADD_TO_STRIP: Text = Text::new("Auf den Zug", "Add to strip"); pub const REMOVE_FROM_STRIP: Text = Text::new("Vom Zug nehmen", "Remove from strip"); pub const CUSTOMIZE_STRIPS: Text = Text::new("Züge anpassen", "Customize strips"); diff --git a/crates/app/src/views/compact.rs b/crates/app/src/views/compact.rs index 7400518..d5af060 100644 --- a/crates/app/src/views/compact.rs +++ b/crates/app/src/views/compact.rs @@ -1,5 +1,6 @@ //! The compact mode's rows: LEDs (or the group's colour), name, a lying level fader with its -//! value, the loudest input as a small meter, and mute. One row per ungrouped speaker and per +//! value, the loudest input as a small meter, then buttons: on a speaker's row the ones chosen +//! for its model, on a group's and the master's mute. One row per ungrouped speaker and per //! group, the master at the bottom. The rows only report gestures, as the strips do. use std::ops::RangeInclusive; @@ -9,21 +10,23 @@ use eframe::egui::{ WidgetInfo, }; use neumann_control_core::catalog::Catalog; -use neumann_control_core::settings::{DeskSettings, GroupSettings}; +use neumann_control_core::settings::{CompactButton, DeskSettings, GroupSettings}; use neumann_control_core::speaker::Command; +use neumann_control_core::ssc::wire::SscPath; use serde_json::Value; +use super::compact_menu; use super::fader::{self, Orientation, Scale}; use super::group_strip::{mute_button, shared_readout, GroupStripResponse, MasterResponse}; use super::meters; use super::strip::{self, UNREACHABLE_OPACITY}; +use crate::compact_layout::CompactChange; use crate::fader::output_level_range; use crate::groups::SharedLevel; use crate::paths; use crate::state::{SpeakerState, Status}; use crate::texts; use crate::ui_kit::theme::Tokens; -use crate::writes::Switch; const ROW_MARGIN: i8 = 4; const ROW_RADIUS: u8 = 4; @@ -41,8 +44,11 @@ const MESSAGE_MARK: &str = "!"; pub struct SpeakerRowResponse { /// The writes the user asked for that the write policy allowed. pub commands: Vec, - /// The name was clicked: the speaker's panel opens. + /// The name or Details was clicked: the speaker's panel opens. pub open_panel: bool, + pub open_eq: bool, + /// A change of the model's compact buttons. + pub layout: Option, } pub fn speaker( @@ -50,6 +56,7 @@ pub fn speaker( speaker: &mut SpeakerState, catalog: &Catalog, desk: &DeskSettings, + buttons: &[CompactButton], ) -> SpeakerRowResponse { let tokens = Tokens::current(ui.ctx()); let mut commands = Vec::new(); @@ -60,6 +67,13 @@ pub fn speaker( .unwrap_or_default(); let now = ui.input(|input| input.time); let loudest = speaker.loudest_input(now); + // Before the model is known there is nothing to offer that it surely has. + let offers = speaker + .identity + .is_some() + .then(|| compact_menu::offers(buttons, speaker.product(), catalog)); + let mut open_eq = false; + let mut layout = None; let open_panel = row(ui, tokens.border, |ui| { if speaker.status == Status::Unreachable { ui.multiply_opacity(UNREACHABLE_OPACITY); @@ -79,27 +93,57 @@ pub fn speaker( cell(ui, READOUT_WIDTH, |ui| readout_label(ui, readout)); let floor = desk.meter_floor.db(); meters::bar(ui, loudest, floor, texts::INPUT_METERS.get(), &tokens); - let mute = texts::MUTE.get(); - strip::toggle( - ui, - speaker, - catalog, - &mut commands, - Switch::Mute.path(), - mute, - true, - ); + let mut open_details = false; + let mut rects = Vec::new(); + for button in buttons { + let response = match button { + CompactButton::Switch(path) => { + let text = switch_text(path, catalog); + let path = path.clone(); + strip::toggle(ui, speaker, catalog, &mut commands, path, &text, true) + } + CompactButton::Eq => { + let eq = ui.button(texts::EQ); + open_eq |= eq.clicked(); + eq + } + CompactButton::Details => { + let details = ui.button(texts::DETAILS); + open_details |= details.clicked(); + details + } + }; + if let Some(offers) = &offers { + let change = compact_menu::button_menu(ui, response.rect, button, offers); + layout = change.or(layout.take()); + } + rects.push(response.rect); + } if let Some(message) = &speaker.message { message_mark(ui, &texts::describe_message(message), &tokens); } - open_panel + if let Some(offers) = &offers { + let change = compact_menu::row_menu(ui, ui.min_rect(), &rects, offers); + layout = change.or(layout.take()); + } + open_panel || open_details }); SpeakerRowResponse { commands, open_panel, + open_eq, + layout, } } +/// Short as on the strip, so the row stays one line. +fn switch_text(path: &SscPath, catalog: &Catalog) -> String { + paths::catalog_entry(catalog, path).map_or_else( + || path.to_string(), + |entry| strip::button_text(entry).to_owned(), + ) +} + /// What a group or master row shows: the level of the loudest connected member, whether all are /// muted, and the loudest input among them. pub struct SharedRow { diff --git a/crates/app/src/views/compact_menu.rs b/crates/app/src/views/compact_menu.rs new file mode 100644 index 0000000..a99c591 --- /dev/null +++ b/crates/app/src/views/compact_menu.rs @@ -0,0 +1,102 @@ +//! The context menus that change a model's compact buttons: on a button, and on the rest of the +//! row, which still offers adding when every button is hidden. They only report the change. + +use eframe::egui::{Rect, Ui}; +use neumann_control_core::catalog::Catalog; +use neumann_control_core::settings::CompactButton; + +use super::layout_menu::{popup, right_clicked}; +use crate::compact_layout::{self, CompactChange}; +use crate::paths; +use crate::texts; + +const BUTTON_MENU_ID: &str = "compact_button_menu"; +const ROW_MENU_ID: &str = "compact_row_menu"; + +/// A button that could still go on the row, with the name it is offered under. +pub struct CompactOffer { + pub button: CompactButton, + pub label: String, +} + +/// What `product`'s rows could still take, switches named as in the device panel. +pub fn offers(buttons: &[CompactButton], product: &str, catalog: &Catalog) -> Vec { + compact_layout::addable(buttons, product, catalog) + .into_iter() + .map(|button| CompactOffer { + label: offer_label(&button, catalog), + button, + }) + .collect() +} + +fn offer_label(button: &CompactButton, catalog: &Catalog) -> String { + match button { + CompactButton::Eq => texts::EQ.get().to_owned(), + CompactButton::Details => texts::DETAILS.get().to_owned(), + CompactButton::Switch(path) => paths::catalog_entry(catalog, path).map_or_else( + || path.to_string(), + |entry| entry.label(texts::language()).to_owned(), + ), + } +} + +/// Hide or move one button, add another, or go back to the default buttons. +pub fn button_menu( + ui: &Ui, + rect: Rect, + button: &CompactButton, + offers: &[CompactOffer], +) -> Option { + let id = ui.scope_id().with((BUTTON_MENU_ID, format!("{button:?}"))); + let mut change = None; + popup(ui, id, right_clicked(ui, rect), |ui| { + let moves = [ + (texts::HIDE, CompactChange::Remove(button.clone())), + (texts::MOVE_LEFT, CompactChange::MoveLeft(button.clone())), + (texts::MOVE_RIGHT, CompactChange::MoveRight(button.clone())), + ]; + for (text, wish) in moves { + if ui.button(text).clicked() { + change = Some(wish); + } + } + if let Some(wish) = add_and_reset(ui, offers) { + change = Some(wish); + } + }); + change +} + +/// Right-clicked in `row` but on none of its `buttons`: add a button or go back to the default. +pub fn row_menu( + ui: &Ui, + row: Rect, + buttons: &[Rect], + offers: &[CompactOffer], +) -> Option { + let on_a_button = buttons.iter().any(|rect| ui.rect_contains_pointer(*rect)); + let open = right_clicked(ui, row) && !on_a_button; + let mut change = None; + popup(ui, ui.scope_id().with(ROW_MENU_ID), open, |ui| { + change = add_and_reset(ui, offers); + }); + change +} + +fn add_and_reset(ui: &mut Ui, offers: &[CompactOffer]) -> Option { + let mut change = None; + ui.add_enabled_ui(!offers.is_empty(), |ui| { + ui.menu_button(texts::ADD_BUTTON, |ui| { + for offer in offers { + if ui.button(&offer.label).clicked() { + change = Some(CompactChange::Add(offer.button.clone())); + } + } + }); + }); + if ui.button(texts::DEFAULT_LAYOUT).clicked() { + change = Some(CompactChange::Reset); + } + change +} diff --git a/crates/app/src/views/layout_menu.rs b/crates/app/src/views/layout_menu.rs index 4287da5..0d6490f 100644 --- a/crates/app/src/views/layout_menu.rs +++ b/crates/app/src/views/layout_menu.rs @@ -106,8 +106,15 @@ pub fn row_menu( } fn menu(ui: &Ui, id: Id, rect: Rect, contents: impl FnOnce(&mut Ui)) { - let right_clicked = - ui.input(|input| input.pointer.secondary_clicked()) && ui.rect_contains_pointer(rect); + popup(ui, id, right_clicked(ui, rect), contents); +} + +pub(super) fn right_clicked(ui: &Ui, rect: Rect) -> bool { + ui.input(|input| input.pointer.secondary_clicked()) && ui.rect_contains_pointer(rect) +} + +/// A context menu at the pointer, opened when `open`. +pub(super) fn popup(ui: &Ui, id: Id, open: bool, contents: impl FnOnce(&mut Ui)) { Popup::new( id, ui.ctx().clone(), @@ -115,7 +122,7 @@ fn menu(ui: &Ui, id: Id, rect: Rect, contents: impl FnOnce(&mut Ui)) { ui.layer_id(), ) .kind(PopupKind::Menu) - .open_memory(right_clicked.then_some(SetOpenCommand::Bool(true))) + .open_memory(open.then_some(SetOpenCommand::Bool(true))) .show(contents); } diff --git a/crates/app/src/views/mod.rs b/crates/app/src/views/mod.rs index b2ab3e8..7f3b354 100644 --- a/crates/app/src/views/mod.rs +++ b/crates/app/src/views/mod.rs @@ -1,6 +1,7 @@ pub mod add_address; pub mod all_values; pub mod compact; +mod compact_menu; mod details; mod details_fields; pub mod eq_graph; diff --git a/crates/app/src/views/strip.rs b/crates/app/src/views/strip.rs index a41fb04..c2a73b3 100644 --- a/crates/app/src/views/strip.rs +++ b/crates/app/src/views/strip.rs @@ -564,7 +564,7 @@ fn button_row( /// The name from the panel, but some keep a short one of the strip's: identify to fit beside /// mute, and the KH 750's three mutes, which the panel tells apart by their headings only. -fn button_text(entry: &Entry) -> &str { +pub(super) fn button_text(entry: &Entry) -> &str { let short = [ (paths::IDENTIFY, texts::IDENTIFY), (paths::MUTE_OUTPUT_A, texts::MUTE_OUTPUT_A), diff --git a/crates/app/tests/app/compact.rs b/crates/app/tests/app/compact.rs index cfdd3a1..d6201df 100644 --- a/crates/app/tests/app/compact.rs +++ b/crates/app/tests/app/compact.rs @@ -1,21 +1,62 @@ //! The compact mode: switching there and back, one row per ungrouped speaker and per group, -//! and rows writing like the strips do. +//! rows writing like the strips do, and each model's buttons chosen by right click. use crate::support::*; use eframe::egui::{self, accesskit::Role, Key, Modifiers}; use egui_kittest::kittest::Queryable; use egui_kittest::Harness; use neumann_control_app::app::NeumannApp; +use neumann_control_app::paths; use neumann_control_app::texts; -use neumann_control_core::settings::{self, SETTINGS_FILE}; +use neumann_control_core::catalog::Catalog; +use neumann_control_core::settings::{self, CompactButton, SETTINGS_FILE}; use neumann_control_core::simulator::SimModel; use serde_json::Value; +use std::collections::BTreeMap; fn press_compact_shortcut(harness: &mut Harness<'static, NeumannApp>) { harness.key_press_modifiers(Modifiers::COMMAND, Key::K); harness.run_steps(2); } +const MONITOR: &str = "KH 120 II"; +const SUBWOOFER: &str = "KH 750"; + +fn right_click_button(harness: &mut Harness<'static, NeumannApp>, label: &str) { + harness + .get_by_role_and_label(Role::Button, label) + .click_secondary(); + harness.run_steps(2); +} + +fn choose(harness: &mut Harness<'static, NeumannApp>, item: &str) { + harness.get_by_label(item).click(); + harness.run_steps(3); +} + +fn stored_buttons(directory: &tempfile::TempDir, model: &str) -> Option> { + let settings = settings::load(&directory.path().join(SETTINGS_FILE)); + settings.desk.compact_layouts.get(model).cloned() +} + +fn switch(text: &str) -> CompactButton { + CompactButton::Switch(paths::path(text)) +} + +/// As the add menu offers a switch: under its name in the device panel. +fn panel_label(text: &str) -> &'static str { + paths::catalog_entry(Catalog::embedded(), &paths::path(text)) + .unwrap() + .label(texts::language()) +} + +fn left_of(harness: &Harness<'static, NeumannApp>, label: &str) -> f32 { + harness + .get_by_role_and_label(Role::Button, label) + .rect() + .left() +} + fn drag_level(harness: &mut Harness<'static, NeumannApp>, label: &str, by: f32) { let start = harness .get_by_role_and_label(Role::Slider, label) @@ -188,3 +229,106 @@ fn a_group_row_says_why_a_members_write_was_refused() { harness.query_all_by_label_contains(&message).count() == 2 }); } + +/// The default buttons after mute; the EQ window, drawn inside the test window, would lie over +/// the row, so each is opened by a test of its own. +#[test] +fn a_compact_row_opens_the_eq() { + let (_simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); + press_compact_shortcut(&mut harness); + harness.set_size(EXPANDED_WINDOW_SIZE.into()); + harness.run_steps(2); + harness + .get_by_role_and_label(Role::Button, texts::EQ.get()) + .click(); + wait_for_label(&mut harness, texts::EQ_COMPUTED.get()); +} + +#[test] +fn a_compact_row_opens_the_details() { + let (_simulator, _directory, mut harness) = connected_speaker(SimModel::Kh120Ii); + press_compact_shortcut(&mut harness); + harness + .get_by_role_and_label(Role::Button, texts::DETAILS.get()) + .click(); + wait_until(&mut harness, "the device panel", |harness| { + harness.query_by_label(texts::ALL_VALUES.get()).is_some() + }); +} + +#[test] +fn a_compact_button_is_hidden_and_another_added_by_right_click() { + let (_simulators, directory, mut harness) = desk_of(&[SimModel::Kh120Ii], BTreeMap::new()); + press_compact_shortcut(&mut harness); + right_click_button(&mut harness, texts::MUTE.get()); + let menus = harness.query_all_by_label(texts::ADD_BUTTON.get()).count(); + assert_eq!(menus, 1, "the button's menu only, not also the row's"); + choose(&mut harness, texts::HIDE.get()); + assert!(harness + .query_by_role_and_label(Role::Button, texts::MUTE.get()) + .is_none()); + let hidden = vec![CompactButton::Eq, CompactButton::Details]; + assert_eq!(stored_buttons(&directory, MONITOR), Some(hidden)); + right_click_button(&mut harness, texts::EQ.get()); + choose(&mut harness, texts::ADD_BUTTON.get()); + choose(&mut harness, panel_label(paths::IDENTIFY)); + harness.get_by_role_and_label(Role::Button, texts::IDENTIFY.get()); + let stored = stored_buttons(&directory, MONITOR).unwrap(); + assert_eq!(stored.last(), Some(&switch(paths::IDENTIFY))); +} + +#[test] +fn a_compact_button_moves_right_and_the_default_comes_back() { + let (_simulators, directory, mut harness) = desk_of(&[SimModel::Kh120Ii], BTreeMap::new()); + press_compact_shortcut(&mut harness); + right_click_button(&mut harness, texts::MUTE.get()); + choose(&mut harness, texts::MOVE_RIGHT.get()); + assert!(left_of(&harness, texts::EQ.get()) < left_of(&harness, texts::MUTE.get())); + right_click_button(&mut harness, texts::MUTE.get()); + choose(&mut harness, texts::DEFAULT_LAYOUT.get()); + assert!(left_of(&harness, texts::MUTE.get()) < left_of(&harness, texts::EQ.get())); + assert_eq!(stored_buttons(&directory, MONITOR), None); +} + +/// With no button left the rest of the row still offers adding one. +#[test] +fn a_row_without_buttons_still_offers_adding() { + let (_simulators, _directory, mut harness) = desk_of(&[SimModel::Kh120Ii], BTreeMap::new()); + press_compact_shortcut(&mut harness); + for button in [texts::MUTE, texts::EQ, texts::DETAILS] { + right_click_button(&mut harness, button.get()); + choose(&mut harness, texts::HIDE.get()); + } + harness.get_by_label(KH120_DEVICE_NAME).click_secondary(); + harness.run_steps(2); + choose(&mut harness, texts::ADD_BUTTON.get()); + choose(&mut harness, texts::DETAILS.get()); + harness.get_by_role_and_label(Role::Button, texts::DETAILS.get()); +} + +#[test] +fn compact_buttons_are_chosen_per_model() { + let models = [SimModel::Kh120Ii, SimModel::Kh750]; + let (_simulators, directory, mut harness) = desk_of(&models, BTreeMap::new()); + press_compact_shortcut(&mut harness); + let row_of = |harness: &Harness<'static, NeumannApp>, name: &str| { + harness.get_by_label(name).rect().center().y + }; + let monitor_row = row_of(&harness, KH120_DEVICE_NAME); + let monitor_eq = harness + .query_all_by_role_and_label(Role::Button, texts::EQ.get()) + .find(|node| (node.rect().center().y - monitor_row).abs() < 10.0) + .expect("the monitor's EQ button"); + monitor_eq.click_secondary(); + harness.run_steps(2); + choose(&mut harness, texts::HIDE.get()); + let left: Vec = harness + .query_all_by_role_and_label(Role::Button, texts::EQ.get()) + .map(|node| node.rect().center().y) + .collect(); + let subwoofer_row = row_of(&harness, KH750_DEVICE_NAME); + assert_eq!(left.len(), 1, "{left:?}"); + assert!((left[0] - subwoofer_row).abs() < 10.0, "{left:?}"); + assert!(stored_buttons(&directory, MONITOR).is_some()); + assert_eq!(stored_buttons(&directory, SUBWOOFER), None); +} diff --git a/crates/core/src/settings.rs b/crates/core/src/settings.rs index 6f85834..840d214 100644 --- a/crates/core/src/settings.rs +++ b/crates/core/src/settings.rs @@ -197,6 +197,13 @@ pub struct DeskSettings { skip_serializing_if = "BTreeMap::is_empty" )] pub strip_layouts: BTreeMap, + /// The buttons of a model's compact rows, per reported product name; without an entry the + /// default ones. + #[serde( + deserialize_with = "readable_compact_layouts", + skip_serializing_if = "BTreeMap::is_empty" + )] + pub compact_layouts: BTreeMap>, } /// One element a channel strip shows besides its fixed part: a catalog path, or the EQ button. @@ -229,6 +236,39 @@ impl<'de> Deserialize<'de> for StripElement { } } +/// One button of a compact row after its fixed part: a switch's catalog path, EQ or Details. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CompactButton { + Switch(SscPath), + Eq, + Details, +} + +const DETAILS_BUTTON: &str = "details"; + +impl Serialize for CompactButton { + fn serialize(&self, serializer: S) -> Result { + match self { + CompactButton::Switch(path) => path.serialize(serializer), + CompactButton::Eq => serializer.serialize_str(EQ_ELEMENT), + CompactButton::Details => serializer.serialize_str(DETAILS_BUTTON), + } + } +} + +impl<'de> Deserialize<'de> for CompactButton { + fn deserialize>(deserializer: D) -> Result { + let text = String::deserialize(deserializer)?; + match text.as_str() { + EQ_ELEMENT => Ok(CompactButton::Eq), + DETAILS_BUTTON => Ok(CompactButton::Details), + _ => SscPath::parse(&text) + .map(CompactButton::Switch) + .map_err(serde::de::Error::custom), + } + } +} + /// A model's strip elements above and below the fader, in order, as stored. #[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] pub struct StoredStripLayout { @@ -250,6 +290,7 @@ impl Default for DeskSettings { crossovers: CrossoverGuard::Protected, delay_unit: DelayUnit::Milliseconds, strip_layouts: BTreeMap::new(), + compact_layouts: BTreeMap::new(), } } } @@ -427,20 +468,47 @@ fn readable_layouts<'de, D>( ) -> Result, D::Error> where D: Deserializer<'de>, +{ + readable_map(deserializer, "strip layouts") +} + +/// Like `readable_layouts`; within a model's list an unreadable button is left out alone. +fn readable_compact_layouts<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + // A model whose entry is not a list is left out, so it gets the default buttons. + let lists: BTreeMap> = + readable_map(deserializer, "compact layouts")?; + lists + .into_iter() + .map(|(model, list)| { + let buttons = readable_items(serde_json::Value::Array(list)); + Ok((model, buttons.map_err(serde::de::Error::custom)?)) + }) + .collect() +} + +fn readable_map<'de, D, T>(deserializer: D, what: &str) -> Result, D::Error> +where + D: Deserializer<'de>, + T: DeserializeOwned, { let serde_json::Value::Object(entries) = serde_json::Value::deserialize(deserializer)? else { - log::warn!("the strip layouts are not a map; left out"); + log::warn!("the {what} are not a map; left out"); NOT_UNDERSTOOD.with(|count| count.set(count.get() + 1)); return Ok(BTreeMap::new()); }; let total = entries.len(); - let readable: BTreeMap = entries + let readable: BTreeMap = entries .into_iter() - .filter_map(|(model, layout)| Some((model, StoredStripLayout::deserialize(layout).ok()?))) + .filter_map(|(model, value)| Some((model, T::deserialize(value).ok()?))) .collect(); let left_out = total - readable.len(); if left_out > 0 { - log::warn!("{left_out} strip layouts are not understood; left out"); + log::warn!("{left_out} {what} are not understood; left out"); NOT_UNDERSTOOD.with(|count| count.set(count.get() + left_out)); } Ok(readable) diff --git a/crates/core/tests/settings.rs b/crates/core/tests/settings.rs index 43bfd01..6abc423 100644 --- a/crates/core/tests/settings.rs +++ b/crates/core/tests/settings.rs @@ -1,8 +1,8 @@ use neumann_control_core::settings::{ - load, save, BandCurves, CrossoverGuard, DelayUnit, DeskSettings, EqPreset, GroupSettings, - Language, LanguageChoice, Linkable, ManualSpeaker, MeterFloor, Settings, SettingsPreset, - StoredStripLayout, StripElement, SystemAudioSettings, ThemeChoice, WindowGeometry, - SETTINGS_FILE, + load, save, BandCurves, CompactButton, CrossoverGuard, DelayUnit, DeskSettings, EqPreset, + GroupSettings, Language, LanguageChoice, Linkable, ManualSpeaker, MeterFloor, Settings, + SettingsPreset, StoredStripLayout, StripElement, SystemAudioSettings, ThemeChoice, + WindowGeometry, SETTINGS_FILE, }; use neumann_control_core::ssc::wire::SscPath; use std::fs; @@ -496,3 +496,30 @@ fn an_unreadable_strip_element_is_left_out_and_keeps_a_copy() { let copy = directory.path().join(format!("{SETTINGS_FILE}.bad")); assert_eq!(fs::read_to_string(copy).unwrap(), text); } + +#[test] +fn compact_layouts_round_trip() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(SETTINGS_FILE); + let mut settings = Settings::default(); + let switch = CompactButton::Switch(SscPath::parse("device/identification/visual").unwrap()); + let buttons = vec![CompactButton::Details, switch, CompactButton::Eq]; + settings + .desk + .compact_layouts + .insert("KH 750".to_owned(), buttons); + save(&settings, &path).unwrap(); + assert_eq!(load(&path), settings); +} + +#[test] +fn an_unreadable_compact_button_is_left_out_alone() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(SETTINGS_FILE); + let text = + r#"{"desk": {"compact_layouts": {"KH 750": ["audio//out", "details"], "KH 80": 3}}}"#; + fs::write(&path, text).unwrap(); + let layouts = load(&path).desk.compact_layouts; + assert_eq!(layouts["KH 750"], vec![CompactButton::Details]); + assert!(!layouts.contains_key("KH 80")); +} From 28042805419be6d52f4c8e99eece1b729afccd36 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:46:36 +0200 Subject: [PATCH 263/460] Add the manual and a help menu with an update check The release build turns the README into an HTML manual next to the exe. "?" opens it and an About dialog that asks GitHub for the latest release over WinHTTP, only when clicked. --- .cargo/mutants.toml | 4 + CHANGELOG.md | 3 + Cargo.lock | 31 +++ Cargo.toml | 2 +- README.md | 25 +- build-release.ps1 | 11 +- crates/app/Cargo.toml | 10 +- crates/app/src/app.rs | 55 +++++ crates/app/src/help.rs | 358 ++++++++++++++++++++++++++++ crates/app/src/lib.rs | 1 + crates/app/src/texts.rs | 32 +++ crates/app/src/views/about.rs | 80 +++++++ crates/app/src/views/mod.rs | 1 + crates/app/src/views/toolbar.rs | 14 +- crates/app/tests/app/compact.rs | 1 + crates/app/tests/app/help.rs | 95 ++++++++ crates/app/tests/app/main.rs | 1 + crates/app/tests/app/support/mod.rs | 22 ++ crates/manual/Cargo.toml | 12 + crates/manual/src/lib.rs | 299 +++++++++++++++++++++++ crates/manual/src/main.rs | 29 +++ 21 files changed, 1076 insertions(+), 10 deletions(-) create mode 100644 crates/app/src/help.rs create mode 100644 crates/app/src/views/about.rs create mode 100644 crates/app/tests/app/help.rs create mode 100644 crates/manual/Cargo.toml create mode 100644 crates/manual/src/lib.rs create mode 100644 crates/manual/src/main.rs diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index 4f0bac2..1567b08 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -6,7 +6,11 @@ exclude_globs = [ "crates/app/src/views/**", "crates/app/src/ui_kit/**", "crates/app/src/main.rs", + # The manual generator only runs in the release build, which fails on a broken manual. + "crates/manual/src/main.rs", ] +# Windows' browser and WinHTTP: no test may start one or go online. +exclude_re = ["windows_help"] # nextest runs the tests of all test binaries at once and stops at the first failure; the # tests themselves are short, run one binary after another they were most of a mutant's time. test_tool = "nextest" diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f46795..a1e3a14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ### Added +- **?** in the toolbar: the **Manual** (an HTML file of this README next to the executable) and + **About Neumann Control** with version, licence and **Check for updates**, which asks GitHub + for the newest release when clicked and links to it. - Compact rows of speakers have **EQ** and **Details** next to **Mute**; a right click on a button hides it, moves it, adds another switch or goes back to the default, per model. - Without a saved window size, the window is made as large as the desk needs and grows with it diff --git a/Cargo.lock b/Cargo.lock index b784096..327ff24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2011,6 +2011,13 @@ dependencies = [ "toml", ] +[[package]] +name = "neumann-control-manual" +version = "0.1.0" +dependencies = [ + "pulldown-cmark", +] + [[package]] name = "nohash-hasher" version = "0.2.0" @@ -2717,6 +2724,24 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +[[package]] +name = "pulldown-cmark" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "278e9268c93b9e498a2da46c34d90ba93a3255eb99009646398f34bc73ab36eb" +dependencies = [ + "bitflags 2.13.2", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "pxfm" version = "0.1.30" @@ -3556,6 +3581,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + [[package]] name = "unicode-general-category" version = "1.1.0" diff --git a/Cargo.toml b/Cargo.toml index 39ce58b..7d2fc91 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/core", "crates/app"] +members = ["crates/core", "crates/app", "crates/manual"] resolver = "2" [workspace.package] diff --git a/README.md b/README.md index a2d6730..41ab749 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ The app needs no installer. A release folder holds: | File | Content | |------|---------| | `neumann-control.exe` | The app | +| `neumann-control-manual.html` | This manual, opened by **?** → **Manual** in the app | | `LICENSE` | The app's licence (MIT) | | `THIRD-PARTY-LICENSES.html` | The licences of the libraries and fonts built into it | | `SHA256SUMS` | Checksums of the files above | @@ -335,6 +336,13 @@ recorded. default device or a chosen one): a fader in percent, mute and a peak meter per channel. It sets Windows' own volume, like the volume slider in the taskbar. +### Help and updates + +**?** in the toolbar opens the **Manual** (the file next to the executable, in your browser) and +**About Neumann Control**: version, licence and a link to the project page. **Check for updates** +there asks GitHub once for the newest release and, if it is newer, links to its page; the app +does not download or install anything itself. + ### Control mode The app shows the control mode (NETWORK or LOCAL), which the CONTROL switch on the back of the @@ -373,6 +381,10 @@ models. ## Files and privacy +The app talks to the speakers on the local network. It goes to the internet only when you click +**Check for updates**: then it asks `api.github.com` for the latest release of this project, +with the app's name and version as its user agent. + The app is portable and writes only next to the executable: | File | Content | @@ -416,8 +428,12 @@ The executable is `target\release\neumann-control.exe`. `check.ps1` runs the quality gates: format, licence check, `cargo audit`, clippy and the tests. It needs `cargo-audit` and `cargo-about`. `build-release.ps1` runs them on a clean working tree and writes `release\neumann-control-\` (or `-OutputRoot `) with the -executable, `LICENSE`, `THIRD-PARTY-LICENSES.html` and -`SHA256SUMS`. +executable, the manual, `LICENSE`, `THIRD-PARTY-LICENSES.html` and `SHA256SUMS`. The manual is +this README as one HTML file with its pictures embedded, made by `crates/manual`: + +```powershell +cargo run -p neumann-control-manual -- README.md neumann-control-manual.html +``` Mutation testing uses `cargo-mutants` and `cargo-nextest` with the settings in `.cargo/mutants.toml` (simulator and drawing code left out) and the `mutants` profile in @@ -431,8 +447,9 @@ cargo mutants --workspace -j 4 --in-diff # the lines a change touched More than four jobs did not make it faster on a 32-thread machine. -The workspace has two crates: `crates/core` (the SSC client, discovery, the path catalog, -settings and a speaker simulator for the tests) and `crates/app` (the window). +The workspace has three crates: `crates/core` (the SSC client, discovery, the path catalog, +settings and a speaker simulator for the tests), `crates/app` (the window) and `crates/manual` +(the manual generator, not part of the app). ## Licence diff --git a/build-release.ps1 b/build-release.ps1 index 374a6f7..e8c0eb6 100644 --- a/build-release.ps1 +++ b/build-release.ps1 @@ -14,7 +14,7 @@ $ErrorActionPreference = 'Stop' Set-Location $PSScriptRoot # The one list of what a release folder holds; anything else in it fails the build. -$releaseFiles = @('neumann-control.exe', 'LICENSE', 'THIRD-PARTY-LICENSES.html', 'SHA256SUMS') +$releaseFiles = @('neumann-control.exe', 'neumann-control-manual.html', 'LICENSE', 'THIRD-PARTY-LICENSES.html', 'SHA256SUMS') # Only the version line of [workspace.package]: a plain match would also hit a dependency # pinned with `version = "..."`. $workspaceVersionLine = [regex]'(\[workspace\.package\][^\[]*?\nversion = ")([^"]+)(")' @@ -89,9 +89,16 @@ New-Item -ItemType Directory -Force -Path $releaseDirectory | Out-Null Copy-Item 'target\release\neumann-control.exe' $releaseDirectory Copy-Item 'LICENSE' $releaseDirectory +# The app opens this file from its own folder (help.rs names it the same). +$manualPath = Join-Path $releaseDirectory 'neumann-control-manual.html' +Invoke-Step 'Generate the manual' { + cargo run --release --locked -q -p neumann-control-manual -- README.md $manualPath +} + $licensesPath = Join-Path $releaseDirectory 'THIRD-PARTY-LICENSES.html' Invoke-Step 'Generate third-party licenses' { - cargo about generate --workspace --locked --fail -c about.toml about.hbs -o $licensesPath + # The app's graph only: the manual generator is not part of what ships. + cargo about generate -m crates\app\Cargo.toml --locked --fail -c about.toml about.hbs -o $licensesPath } $sumsPath = Join-Path $releaseDirectory 'SHA256SUMS' diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 8485b19..b2dffa9 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -26,8 +26,14 @@ time = { version = "0.3", features = ["local-offset"] } winit = "0.30" [target.'cfg(windows)'.dependencies] -# Already in the tree through winit; asks Windows for its display language. -windows-sys = { version = "0.61", features = ["Win32_Globalization"] } +# Already in the tree through winit; asks Windows for its display language, opens the manual +# and web pages, and asks GitHub for the latest release over WinHTTP (no HTTP crate needed). +windows-sys = { version = "0.61", features = [ + "Win32_Globalization", + "Win32_Networking_WinHttp", + "Win32_UI_Shell", + "Win32_UI_WindowsAndMessaging", +] } # Already in the tree through accesskit_windows; Core Audio (COM) for the Windows volume strip, # which windows-sys cannot call. windows = { version = "0.62", features = [ diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index 8cb0bf6..8d09873 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -21,6 +21,7 @@ use neumann_control_core::speaker::{Command, Event, Events, SpeakerHandle, Targe use crate::display; use crate::file_dialog::{FileDialog, NativeFileDialog}; use crate::groups::{delete_group, forget_member, member_id, rename_member, set_membership}; +use crate::help::{Help, UpdateCheck, WindowsHelp, MANUAL_FILE}; use crate::meter::MeterState; use crate::rediscovery::Rediscovery; use crate::scan_choice::ScanChoice; @@ -35,6 +36,7 @@ use crate::targets::{ use crate::texts; use crate::ui_kit::eq_palette::{curve_key, CURVE_KEYS}; use crate::ui_kit::{fonts, theme}; +use crate::views::about::{self, AboutAction, UpdateView}; use crate::views::add_address::{self, AddAddressAction, AddAddressForm}; use crate::views::group_dialog::{self, GroupDialogAction}; use crate::views::panel::PanelPlacement; @@ -115,6 +117,10 @@ pub struct NeumannApp { preset_dialog: Option, system_audio: Box, file_dialog: Box, + help: Arc, + /// The "Über" dialog while it is open, with its update check. + about: Option, + update_check: Option, /// The Windows strip's meters, one per channel of the device. system_meters: Vec, /// The playback devices for the settings dialog, listed when it opens. @@ -183,6 +189,9 @@ impl NeumannApp { preset_dialog: None, system_audio, file_dialog: Box::new(NativeFileDialog), + help: Arc::new(WindowsHelp), + about: None, + update_check: None, system_meters: Vec::new(), audio_devices: Vec::new(), system_language, @@ -196,6 +205,11 @@ impl NeumannApp { self.file_dialog = file_dialog; } + /// What opens the manual and web pages and asks GitHub; the tests' stand-in. + pub fn set_help(&mut self, help: Arc) { + self.help = help; + } + /// Starts workers for remembered speakers not shown yet and moves speakers whose entry now /// points at another address. fn sync_targets(&mut self) { @@ -456,6 +470,8 @@ impl NeumannApp { ToolbarAction::History => self.open_history(), ToolbarAction::ToggleCompact => self.toggle_compact(frame), ToolbarAction::ToggleEditing => self.editing = !self.editing, + ToolbarAction::Manual => self.open_manual(), + ToolbarAction::About => self.about = Some(UpdateView::NotAsked), ToolbarAction::None => {} } if ui.input_mut(|input| input.consume_shortcut(&COMPACT_SHORTCUT)) { @@ -472,6 +488,7 @@ impl NeumannApp { self.show_dialog(ui); self.show_scan_choice(ui); self.show_removal(ui); + self.show_about(ui); self.show_preset_dialog(ui); self.show_settings(ui); self.show_group_dialog(ui); @@ -669,6 +686,44 @@ impl NeumannApp { } } + /// From the exe's folder, where the release puts it next to the settings. + fn open_manual(&mut self) { + let manual = self.settings_path.with_file_name(MANUAL_FILE); + let opened = manual.is_file() && self.help.open(&manual.to_string_lossy()); + if !opened { + log::warn!("the manual could not be opened"); + self.notice = Some(texts::MANUAL_MISSING); + } + } + + fn show_about(&mut self, ui: &mut Ui) { + let Some(view) = &mut self.about else { + return; + }; + if let Some(result) = self.update_check.as_ref().and_then(UpdateCheck::result) { + *view = UpdateView::Done(result); + self.update_check = None; + } + match about::show(ui, view) { + AboutAction::None => {} + AboutAction::Close => { + // A check still out finishes on its own; its answer is dropped. + self.about = None; + self.update_check = None; + } + AboutAction::CheckForUpdates => { + *view = UpdateView::Checking; + let wake = Arc::clone(&self.wake); + self.update_check = Some(UpdateCheck::start(Arc::clone(&self.help), wake)); + } + AboutAction::Open(url) => { + if !self.help.open(&url) { + log::warn!("the browser could not be started"); + } + } + } + } + fn show_removal(&mut self, ui: &mut Ui) { let Some(removal) = &self.removal else { return; diff --git a/crates/app/src/help.rs b/crates/app/src/help.rs new file mode 100644 index 0000000..5006856 --- /dev/null +++ b/crates/app/src/help.rs @@ -0,0 +1,358 @@ +//! What the help menu reaches outside the app: the manual next to the exe, the web page of a +//! release, and the question whether GitHub has a newer release. Only asked when the user clicks; +//! the app has no updater. Behind a trait, so tests neither start a browser nor go online. + +use std::sync::{Arc, Mutex}; +use std::thread; + +use serde_json::Value; + +/// Written next to the exe by the release build (build-release.ps1 names it the same). +pub const MANUAL_FILE: &str = "neumann-control-manual.html"; +pub const REPOSITORY_URL: &str = "https://github.com/Varitras/Neumann-Control"; +pub const RELEASES_URL: &str = "https://github.com/Varitras/Neumann-Control/releases"; +/// GitHub's API for the newest release that is not a draft or a prerelease. +pub const API_HOST: &str = "api.github.com"; +pub const LATEST_RELEASE_PATH: &str = "/repos/Varitras/Neumann-Control/releases/latest"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Release { + /// The tag as published, e.g. "v0.2.0". + pub tag: String, + /// Its page; only ever one of this repository's. + pub url: String, +} + +pub trait Help: Send + Sync { + /// Opens a file or web address as Explorer would; false when Windows could not. + fn open(&self, target: &str) -> bool; + /// The newest release; `Ok(None)` while none is published. + fn latest_release(&self) -> Result, String>; +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum UpdateResult { + Newer(Release), + Current, + NoRelease, + /// No answer, or one that could not be read; the reason is logged. + Failed, +} + +/// What the answer means for the running version `current`. +pub fn verdict(current: &str, latest: Result, String>) -> UpdateResult { + let release = match latest { + Ok(Some(release)) => release, + Ok(None) => return UpdateResult::NoRelease, + Err(error) => { + log::warn!("update check failed: {error}"); + return UpdateResult::Failed; + } + }; + let (Some(published), Some(running)) = (version(&release.tag), version(current)) else { + log::warn!( + "update check: cannot compare {:?} with {current}", + release.tag + ); + return UpdateResult::Failed; + }; + match published > running { + true => UpdateResult::Newer(release), + false => UpdateResult::Current, + } +} + +/// "v1.2.3" or "1.2.3", a suffix such as "-beta" ignored. +fn version(text: &str) -> Option<[u64; 3]> { + let core = text.strip_prefix('v').unwrap_or(text); + let core = core.split(['-', '+']).next()?; + let mut parts = core.split('.').map(|part| part.parse::().ok()); + let version = [parts.next()??, parts.next()??, parts.next()??]; + parts.next().is_none().then_some(version) +} + +/// GitHub's answer for the latest release. A page outside this repository is replaced by the +/// list of releases: the app opens it in the browser. +pub fn parse_release(body: &str) -> Result { + let json: Value = serde_json::from_str(body).map_err(|error| error.to_string())?; + let tag = json + .get("tag_name") + .and_then(Value::as_str) + .ok_or("the answer has no tag_name")?; + let page = json.get("html_url").and_then(Value::as_str); + let own = page.filter(|url| url.starts_with(&format!("{REPOSITORY_URL}/"))); + Ok(Release { + tag: tag.to_owned(), + url: own.unwrap_or(RELEASES_URL).to_owned(), + }) +} + +/// A check running on its own thread; the window is woken when it has an answer. +pub struct UpdateCheck { + result: Arc>>, +} + +impl UpdateCheck { + pub fn start(help: Arc, wake: Arc) -> UpdateCheck { + let result = Arc::new(Mutex::new(None)); + let answer = Arc::clone(&result); + thread::spawn(move || { + let found = verdict(env!("CARGO_PKG_VERSION"), help.latest_release()); + *answer + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(found); + wake(); + }); + UpdateCheck { result } + } + + /// `None` while the answer is still out. + pub fn result(&self) -> Option { + self.result + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + } +} + +#[cfg(windows)] +pub use windows_help::WindowsHelp; + +#[cfg(windows)] +mod windows_help { + use std::ffi::c_void; + use std::ptr::{null, null_mut}; + + use windows_sys::Win32::Networking::WinHttp::{ + WinHttpCloseHandle, WinHttpConnect, WinHttpOpen, WinHttpOpenRequest, WinHttpQueryHeaders, + WinHttpReadData, WinHttpReceiveResponse, WinHttpSendRequest, WinHttpSetTimeouts, + WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, WINHTTP_FLAG_SECURE, WINHTTP_QUERY_FLAG_NUMBER, + WINHTTP_QUERY_STATUS_CODE, + }; + use windows_sys::Win32::UI::Shell::ShellExecuteW; + use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL; + + use super::{parse_release, Help, Release, API_HOST, LATEST_RELEASE_PATH}; + + /// GitHub refuses API calls without one. + const USER_AGENT: &str = concat!("neumann-control/", env!("CARGO_PKG_VERSION")); + const HTTPS_PORT: u16 = 443; + /// Per step (name lookup, connect, send, receive), in milliseconds: the dialog waits on it. + const TIMEOUT_MS: i32 = 10_000; + const STATUS_OK: u32 = 200; + /// The API's answer while a repository has no release. + const STATUS_NOT_FOUND: u32 = 404; + const READ_CHUNK: usize = 16 * 1024; + /// ShellExecute's success is any value above 32. + const SHELL_ERROR_LIMIT: isize = 32; + + pub struct WindowsHelp; + + impl Help for WindowsHelp { + fn open(&self, target: &str) -> bool { + let operation = wide("open"); + let file = wide(target); + // SAFETY: both strings are NUL-terminated and outlive the call. + let result = unsafe { + ShellExecuteW( + null_mut(), + operation.as_ptr(), + file.as_ptr(), + null(), + null(), + SW_SHOWNORMAL, + ) + }; + result as isize > SHELL_ERROR_LIMIT + } + + fn latest_release(&self) -> Result, String> { + let (status, body) = get(API_HOST, LATEST_RELEASE_PATH)?; + match status { + STATUS_OK => parse_release(&body).map(Some), + STATUS_NOT_FOUND => Ok(None), + other => Err(format!("GitHub answered {other}")), + } + } + } + + /// Closes a WinHTTP handle when dropped. + struct Handle(*mut c_void); + + impl Handle { + fn new(raw: *mut c_void, what: &str) -> Result { + match raw.is_null() { + true => Err(format!("{what}: {}", std::io::Error::last_os_error())), + false => Ok(Handle(raw)), + } + } + } + + impl Drop for Handle { + fn drop(&mut self) { + // SAFETY: the handle came from WinHTTP and is closed once. + unsafe { WinHttpCloseHandle(self.0) }; + } + } + + fn check(succeeded: i32, what: &str) -> Result<(), String> { + match succeeded { + 0 => Err(format!("{what}: {}", std::io::Error::last_os_error())), + _ => Ok(()), + } + } + + /// An HTTPS GET with Windows' proxy settings: the status and the body. + fn get(host: &str, path: &str) -> Result<(u32, String), String> { + let agent = wide(USER_AGENT); + let host = wide(host); + let verb = wide("GET"); + let path = wide(path); + // SAFETY: every string is NUL-terminated and outlives the calls; every handle is checked + // before use and closed by `Handle`, the request before the connection and the session. + unsafe { + let session = WinHttpOpen( + agent.as_ptr(), + WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, + null(), + null(), + 0, + ); + let session = Handle::new(session, "WinHttpOpen")?; + let timeouts = + WinHttpSetTimeouts(session.0, TIMEOUT_MS, TIMEOUT_MS, TIMEOUT_MS, TIMEOUT_MS); + check(timeouts, "WinHttpSetTimeouts")?; + let connection = WinHttpConnect(session.0, host.as_ptr(), HTTPS_PORT, 0); + let connection = Handle::new(connection, "WinHttpConnect")?; + let request = WinHttpOpenRequest( + connection.0, + verb.as_ptr(), + path.as_ptr(), + null(), + null(), + null(), + WINHTTP_FLAG_SECURE, + ); + let request = Handle::new(request, "WinHttpOpenRequest")?; + check( + WinHttpSendRequest(request.0, null(), 0, null(), 0, 0, 0), + "WinHttpSendRequest", + )?; + check( + WinHttpReceiveResponse(request.0, null_mut()), + "WinHttpReceiveResponse", + )?; + let mut status: u32 = 0; + let mut size = std::mem::size_of::() as u32; + check( + WinHttpQueryHeaders( + request.0, + WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, + null(), + (&mut status as *mut u32).cast(), + &mut size, + null_mut(), + ), + "WinHttpQueryHeaders", + )?; + let mut body = Vec::new(); + let mut chunk = vec![0u8; READ_CHUNK]; + loop { + let mut read: u32 = 0; + check( + WinHttpReadData( + request.0, + chunk.as_mut_ptr().cast(), + chunk.len() as u32, + &mut read, + ), + "WinHttpReadData", + )?; + if read == 0 { + break; + } + body.extend_from_slice(&chunk[..read as usize]); + } + Ok((status, String::from_utf8_lossy(&body).into_owned())) + } + } + + fn wide(text: &str) -> Vec { + text.encode_utf16().chain(std::iter::once(0)).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn release(tag: &str) -> Release { + Release { + tag: tag.to_owned(), + url: RELEASES_URL.to_owned(), + } + } + + #[test] + fn a_higher_published_version_is_newer() { + let newer = verdict("0.1.0", Ok(Some(release("v0.2.0")))); + assert_eq!(newer, UpdateResult::Newer(release("v0.2.0"))); + assert_eq!( + verdict("0.10.0", Ok(Some(release("v0.9.9")))), + UpdateResult::Current + ); + assert_eq!( + verdict("0.1.0", Ok(Some(release("0.1.0")))), + UpdateResult::Current + ); + assert_eq!( + verdict("0.1.0", Ok(Some(release("v1.0.0-beta")))), + UpdateResult::Newer(release("v1.0.0-beta")) + ); + } + + #[test] + fn no_release_and_failures_are_told_apart() { + assert_eq!(verdict("0.1.0", Ok(None)), UpdateResult::NoRelease); + assert_eq!( + verdict("0.1.0", Err("offline".to_owned())), + UpdateResult::Failed + ); + assert_eq!( + verdict("0.1.0", Ok(Some(release("latest")))), + UpdateResult::Failed + ); + assert_eq!( + verdict("0.1.0", Ok(Some(release("v1.2")))), + UpdateResult::Failed + ); + } + + #[test] + fn the_answer_gives_the_tag_and_only_a_page_of_this_repository() { + let own = format!( + r#"{{"tag_name": "v0.2.0", "html_url": "{REPOSITORY_URL}/releases/tag/v0.2.0"}}"# + ); + let parsed = parse_release(&own).unwrap(); + assert_eq!(parsed.tag, "v0.2.0"); + assert_eq!(parsed.url, format!("{REPOSITORY_URL}/releases/tag/v0.2.0")); + let foreign = r#"{"tag_name": "v0.2.0", "html_url": "https://example.com/x"}"#; + assert_eq!(parse_release(foreign).unwrap().url, RELEASES_URL); + assert!(parse_release(r#"{"name": "x"}"#).is_err()); + assert!(parse_release("not json").is_err()); + } +} + +/// Goes online, so only on request: `cargo test -p neumann-control-app --lib -- --ignored github`. +#[cfg(all(test, windows))] +mod online { + use super::*; + + #[test] + #[ignore = "asks GitHub"] + fn github_answers_the_latest_release_question() { + let answer = WindowsHelp.latest_release(); + assert!(answer.is_ok(), "{answer:?}"); + println!("{answer:?}"); + } +} diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index c2a5b29..5cdf0b4 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -9,6 +9,7 @@ pub mod fader; pub mod file_dialog; pub mod gesture; pub mod groups; +pub mod help; pub mod history; pub mod input_source; pub mod meter; diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 640a170..433958c 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -141,6 +141,27 @@ pub const LED_LOCAL: Text = Text::new( "LOCAL: rear switch, writing locked", ); pub const CLOSE: Text = Text::new("Schließen", "Close"); +pub const HELP: Text = Text::new("?", "?"); +pub const HELP_HINT: Text = Text::new("Hilfe", "Help"); +pub const MANUAL: Text = Text::new("Handbuch", "Manual"); +pub const ABOUT: Text = Text::new("Über Neumann Control", "About Neumann Control"); +pub const MANUAL_MISSING: Text = Text::new( + "Handbuch nicht gefunden (neben der Exe)", + "Manual not found (next to the exe)", +); +pub const LICENCE: Text = Text::new("MIT-Lizenz", "MIT licence"); +pub const PROJECT_PAGE: Text = Text::new("Projektseite auf GitHub", "Project page on GitHub"); +pub const CHECK_FOR_UPDATES: Text = Text::new("Nach Updates suchen", "Check for updates"); +pub const UP_TO_DATE: Text = Text::new("Diese Version ist aktuell.", "This version is up to date."); +pub const NO_RELEASE: Text = Text::new( + "Noch kein Release veröffentlicht.", + "No release published yet.", +); +pub const UPDATE_CHECK_FAILED: Text = Text::new( + "GitHub war nicht erreichbar.", + "GitHub could not be reached.", +); +pub const OPEN_RELEASE_PAGE: Text = Text::new("Release-Seite öffnen", "Open the release page"); pub const ADD_GROUP: Text = Text::new("+ Gruppe", "+ Group"); pub const MOVE_STRIP: Text = Text::new("Verschieben", "Move"); pub const HIDE: Text = Text::new("Ausblenden", "Hide"); @@ -371,6 +392,17 @@ pub fn applies_to_every(model: &str) -> String { ) } +pub fn version(version: &str) -> String { + format!("Version {version}") +} + +pub fn update_available(tag: &str) -> String { + pick( + format!("Neue Version {tag} verfügbar"), + format!("Version {tag} is available"), + ) +} + pub fn speaker_count(count: usize) -> String { pick( format!("{count} Lautsprecher"), diff --git a/crates/app/src/views/about.rs b/crates/app/src/views/about.rs new file mode 100644 index 0000000..f52e754 --- /dev/null +++ b/crates/app/src/views/about.rs @@ -0,0 +1,80 @@ +//! "Über": name, version and licence, the project page, and a check whether GitHub has a newer +//! release, which only links to it: the app does not update itself. + +use eframe::egui::{Button, Id, Modal, RichText, Ui}; + +use crate::help::{UpdateResult, REPOSITORY_URL}; +use crate::texts; + +const MODAL_ID: &str = "about"; + +/// Where the update check stands while the dialog is open. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum UpdateView { + NotAsked, + Checking, + Done(UpdateResult), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AboutAction { + None, + Close, + CheckForUpdates, + /// A web page to open in the browser. + Open(String), +} + +pub fn show(ui: &mut Ui, update: &UpdateView) -> AboutAction { + let modal = Modal::new(Id::unique(MODAL_ID)).show(ui.ctx(), |ui| contents(ui, update)); + if modal.should_close() { + return AboutAction::Close; + } + modal.inner +} + +fn contents(ui: &mut Ui, update: &UpdateView) -> AboutAction { + let mut action = AboutAction::None; + ui.heading(texts::WINDOW_TITLE); + ui.label(texts::version(env!("CARGO_PKG_VERSION"))); + ui.label(texts::LICENCE); + if ui.link(texts::PROJECT_PAGE).clicked() { + action = AboutAction::Open(REPOSITORY_URL.to_owned()); + } + ui.separator(); + let checking = *update == UpdateView::Checking; + ui.horizontal(|ui| { + let check = ui.add_enabled(!checking, Button::new(texts::CHECK_FOR_UPDATES)); + if check.clicked() { + action = AboutAction::CheckForUpdates; + } + if checking { + ui.spinner(); + } + }); + if let UpdateView::Done(result) = update { + if let Some(open) = outcome(ui, result) { + action = open; + } + } + ui.separator(); + if ui.button(texts::CLOSE).clicked() { + action = AboutAction::Close; + } + action +} + +fn outcome(ui: &mut Ui, result: &UpdateResult) -> Option { + let text = match result { + UpdateResult::Current => texts::UP_TO_DATE.get(), + UpdateResult::NoRelease => texts::NO_RELEASE.get(), + UpdateResult::Failed => texts::UPDATE_CHECK_FAILED.get(), + UpdateResult::Newer(release) => { + ui.label(RichText::new(texts::update_available(&release.tag)).strong()); + let open = ui.button(texts::OPEN_RELEASE_PAGE).clicked(); + return open.then(|| AboutAction::Open(release.url.clone())); + } + }; + ui.label(text); + None +} diff --git a/crates/app/src/views/mod.rs b/crates/app/src/views/mod.rs index 7f3b354..8f57909 100644 --- a/crates/app/src/views/mod.rs +++ b/crates/app/src/views/mod.rs @@ -1,3 +1,4 @@ +pub mod about; pub mod add_address; pub mod all_values; pub mod compact; diff --git a/crates/app/src/views/toolbar.rs b/crates/app/src/views/toolbar.rs index cb96ccd..4dc3da5 100644 --- a/crates/app/src/views/toolbar.rs +++ b/crates/app/src/views/toolbar.rs @@ -1,5 +1,6 @@ //! The top bar: "Suchen", "+ Adresse", "+ Gruppe", the speaker count, a notice when something -//! failed, and at the right the average response time, "Kompakt", "Verlauf" and "Einstellungen". The +//! failed, and at the right the average response time, "Kompakt", "Verlauf", "Einstellungen" and +//! "?" with the manual and "Über" (one small button: the narrowest window has no room for two). The //! compact window is narrow, so there only the count, notice and the right side remain; adding //! speakers and groups is the desk's job. @@ -31,6 +32,8 @@ pub enum ToolbarAction { History, ToggleCompact, ToggleEditing, + Manual, + About, } pub struct ToolbarFacts { @@ -100,6 +103,15 @@ fn text_width(ui: &Ui, text: &str) -> f32 { fn right_side(ui: &mut Ui, facts: &ToolbarFacts) -> ToolbarAction { let mut action = ToolbarAction::None; + let help = ui.menu_button(texts::HELP, |ui| { + if ui.button(texts::MANUAL).clicked() { + action = ToolbarAction::Manual; + } + if ui.button(texts::ABOUT).clicked() { + action = ToolbarAction::About; + } + }); + help.response.on_hover_text(texts::HELP_HINT); if ui.button(texts::SETTINGS).clicked() { action = ToolbarAction::Settings; } diff --git a/crates/app/tests/app/compact.rs b/crates/app/tests/app/compact.rs index d6201df..a9aa7bf 100644 --- a/crates/app/tests/app/compact.rs +++ b/crates/app/tests/app/compact.rs @@ -185,6 +185,7 @@ fn the_toolbar_does_not_overlap_itself_in_the_narrowest_window() { texts::CUSTOMIZE_STRIPS.get(), texts::COMPACT.get(), texts::SETTINGS.get(), + texts::HELP.get(), ]; let rects: Vec = toolbar .iter() diff --git a/crates/app/tests/app/help.rs b/crates/app/tests/app/help.rs new file mode 100644 index 0000000..a6ae3c3 --- /dev/null +++ b/crates/app/tests/app/help.rs @@ -0,0 +1,95 @@ +//! The "?" menu: the manual next to the exe, and "Über" with its update check, both through a +//! stand-in for the browser and GitHub. + +use std::sync::Arc; + +use crate::support::*; +use egui_kittest::kittest::Queryable; +use egui_kittest::Harness; +use neumann_control_app::app::NeumannApp; +use neumann_control_app::help::{Release, MANUAL_FILE, REPOSITORY_URL}; +use neumann_control_app::texts; + +fn app_with(help: &FakeHelp) -> (tempfile::TempDir, Harness<'static, NeumannApp>) { + let directory = tempfile::tempdir().unwrap(); + let path = settings_file(directory.path(), Vec::new()); + let mut harness = harness(path, Vec::new()); + harness.state_mut().set_help(Arc::new(help.clone())); + harness.run_steps(1); + (directory, harness) +} + +fn choose_from_help(harness: &mut Harness<'static, NeumannApp>, item: &str) { + harness.get_by_label(texts::HELP.get()).click(); + harness.run_steps(2); + harness.get_by_label(item).click(); + harness.run_steps(3); +} + +#[test] +fn the_manual_opens_from_the_exes_folder() { + let help = FakeHelp::default(); + let (directory, mut harness) = app_with(&help); + let manual = directory.path().join(MANUAL_FILE); + std::fs::write(&manual, "").unwrap(); + choose_from_help(&mut harness, texts::MANUAL.get()); + let opened = help.opened.lock().unwrap().clone(); + assert_eq!(opened, vec![manual.to_string_lossy().into_owned()]); +} + +#[test] +fn a_missing_manual_is_said_and_nothing_opened() { + let help = FakeHelp::default(); + let (_directory, mut harness) = app_with(&help); + choose_from_help(&mut harness, texts::MANUAL.get()); + harness.get_by_label(texts::MANUAL_MISSING.get()); + assert!(help.opened.lock().unwrap().is_empty()); +} + +#[test] +fn about_tells_when_no_release_is_published() { + let (_directory, mut harness) = app_with(&FakeHelp::default()); + choose_from_help(&mut harness, texts::ABOUT.get()); + harness.get_by_label(&texts::version(env!("CARGO_PKG_VERSION"))); + harness.get_by_label(texts::CHECK_FOR_UPDATES.get()).click(); + wait_for_label(&mut harness, texts::NO_RELEASE.get()); +} + +#[test] +fn a_newer_release_links_to_its_page() { + let page = format!("{REPOSITORY_URL}/releases/tag/v99.0.0"); + let help = FakeHelp { + release: Some(Release { + tag: "v99.0.0".to_owned(), + url: page.clone(), + }), + ..FakeHelp::default() + }; + let (_directory, mut harness) = app_with(&help); + choose_from_help(&mut harness, texts::ABOUT.get()); + harness.get_by_label(texts::CHECK_FOR_UPDATES.get()).click(); + wait_for_label(&mut harness, &texts::update_available("v99.0.0")); + harness.get_by_label(texts::OPEN_RELEASE_PAGE.get()).click(); + harness.run_steps(2); + assert_eq!(*help.opened.lock().unwrap(), vec![page]); + harness.get_by_label(texts::CLOSE.get()).click(); + harness.run_steps(2); + assert!(harness + .query_by_label(texts::CHECK_FOR_UPDATES.get()) + .is_none()); +} + +#[test] +fn the_running_version_is_current_against_itself() { + let help = FakeHelp { + release: Some(Release { + tag: format!("v{}", env!("CARGO_PKG_VERSION")), + url: REPOSITORY_URL.to_owned(), + }), + ..FakeHelp::default() + }; + let (_directory, mut harness) = app_with(&help); + choose_from_help(&mut harness, texts::ABOUT.get()); + harness.get_by_label(texts::CHECK_FOR_UPDATES.get()).click(); + wait_for_label(&mut harness, texts::UP_TO_DATE.get()); +} diff --git a/crates/app/tests/app/main.rs b/crates/app/tests/app/main.rs index 9da3f63..7ca3bfd 100644 --- a/crates/app/tests/app/main.rs +++ b/crates/app/tests/app/main.rs @@ -8,6 +8,7 @@ mod details; mod eq; mod glyphs; mod groups; +mod help; mod history; mod speakers; mod strip_layout; diff --git a/crates/app/tests/app/support/mod.rs b/crates/app/tests/app/support/mod.rs index b876257..906a3ba 100644 --- a/crates/app/tests/app/support/mod.rs +++ b/crates/app/tests/app/support/mod.rs @@ -14,6 +14,7 @@ use egui_kittest::kittest::Queryable; use egui_kittest::Harness; use neumann_control_app::app::NeumannApp; use neumann_control_app::file_dialog::FileDialog; +use neumann_control_app::help::{Help, Release}; use neumann_control_app::spawner::{ScanResult, SpeakerSpawner}; use neumann_control_app::system_audio::{AudioDevice, AudioState, SystemAudio}; use neumann_control_app::texts; @@ -205,6 +206,8 @@ fn build_shared( ); // Never Windows' dialog in a test: it would wait for a click nobody makes. app.set_file_dialog(Box::new(FakeFiles::default())); + // Nor a browser or a question to GitHub. + app.set_help(Arc::new(FakeHelp::default())); app }) } @@ -229,6 +232,25 @@ impl FileDialog for FakeFiles { } } +/// Stands in for the browser and GitHub: keeps what the app opened and answers the update +/// question with `release` (`None`: nothing published). +#[derive(Clone, Default)] +pub struct FakeHelp { + pub opened: Arc>>, + pub release: Option, +} + +impl Help for FakeHelp { + fn open(&self, target: &str) -> bool { + self.opened.lock().unwrap().push(target.to_owned()); + true + } + + fn latest_release(&self) -> Result, String> { + Ok(self.release.clone()) + } +} + /// What the fake playback devices hold and what the app asked of them. #[derive(Default)] pub struct FakeAudioState { diff --git a/crates/manual/Cargo.toml b/crates/manual/Cargo.toml new file mode 100644 index 0000000..91fb128 --- /dev/null +++ b/crates/manual/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "neumann-control-manual" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +rust-version.workspace = true +publish = false + +# Run by the release build only: turns the README into the manual shipped next to the exe. +[dependencies] +pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] } diff --git a/crates/manual/src/lib.rs b/crates/manual/src/lib.rs new file mode 100644 index 0000000..e0ea0a5 --- /dev/null +++ b/crates/manual/src/lib.rs @@ -0,0 +1,299 @@ +//! The manual shipped next to the exe: the README as one HTML file. Its pictures are embedded, +//! links to the repository's files point to GitHub (the release folder does not hold them), and +//! headings get the ids GitHub gives them, so the README's table of contents works. + +use std::collections::BTreeSet; + +use pulldown_cmark::{html, CowStr, Event, Options, Parser, Tag, TagEnd}; + +/// Where links to files that are not shipped lead. +pub const REPOSITORY_URL: &str = "https://github.com/Varitras/Neumann-Control"; +/// Files of the release folder: a link to them stays relative. +const SHIPPED: [&str; 1] = ["LICENSE"]; +const TITLE: &str = "Neumann Control manual"; +const STYLE: &str = "\ +:root { color-scheme: light dark; --text: #1f2328; --page: #ffffff; --line: #d0d7de; --soft: #f6f8fa; } +@media (prefers-color-scheme: dark) { + :root { --text: #e6edf3; --page: #0d1117; --line: #30363d; --soft: #161b22; } +} +body { margin: 0; background: var(--page); color: var(--text); + font: 16px/1.6 \"Segoe UI\", system-ui, sans-serif; } +main { max-width: 52rem; margin: 0 auto; padding: 2rem 1rem 4rem; } +img { max-width: 100%; height: auto; } +h1, h2 { border-bottom: 1px solid var(--line); padding-bottom: .3em; } +code { background: var(--soft); padding: .1em .3em; border-radius: 4px; font-size: 90%; } +pre { background: var(--soft); padding: 1em; overflow-x: auto; border-radius: 6px; } +pre code { padding: 0; background: none; } +table { border-collapse: collapse; display: block; overflow-x: auto; } +th, td { border: 1px solid var(--line); padding: .4em .8em; vertical-align: top; } +a { color: #0969da; } +@media (prefers-color-scheme: dark) { a { color: #4493f8; } } +"; + +/// The manual for `markdown`; `read` gives the bytes of a file named relative to the README. +/// Fails on a picture that cannot be read or a link to a heading that does not exist, so a +/// broken manual stops the release instead of shipping. +pub fn render(markdown: &str, read: impl Fn(&str) -> Option>) -> Result { + let options = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH; + let mut events: Vec = Parser::new_ext(markdown, options).collect(); + let ids = name_headings(&mut events); + for event in &mut events { + let Event::Start(tag) = event else { + continue; + }; + match tag { + Tag::Image { dest_url, .. } => *dest_url = embedded(dest_url, &read)?.into(), + Tag::Link { dest_url, .. } => *dest_url = link(dest_url, &ids)?.into(), + _ => {} + } + } + let mut body = String::new(); + html::push_html(&mut body, events.into_iter()); + Ok(format!( + "\n\n\n\n\ + \n\ + {TITLE}\n\n\n\n
\n{body}
\n\ + \n\n" + )) +} + +/// Gives every heading the id GitHub gives it and returns them all. +fn name_headings(events: &mut [Event]) -> BTreeSet { + let mut ids = BTreeSet::new(); + let mut index = 0; + while index < events.len() { + if !matches!(events[index], Event::Start(Tag::Heading { .. })) { + index += 1; + continue; + } + let mut text = String::new(); + let mut end = index + 1; + while !matches!(events[end], Event::End(TagEnd::Heading(_))) { + if let Event::Text(part) | Event::Code(part) = &events[end] { + text.push_str(part); + } + end += 1; + } + let id = unique(slug(&text), &ids); + if let Event::Start(Tag::Heading { id: heading, .. }) = &mut events[index] { + *heading = Some(CowStr::from(id.clone())); + } + ids.insert(id); + index = end + 1; + } + ids +} + +/// GitHub's heading anchor: lower case, punctuation dropped, spaces as hyphens. +fn slug(text: &str) -> String { + text.trim() + .to_lowercase() + .chars() + .filter(|character| character.is_alphanumeric() || matches!(character, ' ' | '-' | '_')) + .map(|character| if character == ' ' { '-' } else { character }) + .collect() +} + +/// A repeated heading gets "-1", "-2" … as on GitHub. +fn unique(slug: String, taken: &BTreeSet) -> String { + if !taken.contains(&slug) { + return slug; + } + (1..) + .map(|number| format!("{slug}-{number}")) + .find(|candidate| !taken.contains(candidate)) + .expect("an unused number") +} + +fn embedded(url: &str, read: impl Fn(&str) -> Option>) -> Result { + if is_absolute(url) { + return Ok(url.to_owned()); + } + let media_type = match url.rsplit('.').next().map(str::to_lowercase).as_deref() { + Some("png") => "image/png", + Some("jpg" | "jpeg") => "image/jpeg", + Some("svg") => "image/svg+xml", + Some("gif") => "image/gif", + _ => return Err(format!("{url}: not a picture the manual can embed")), + }; + let bytes = read(url).ok_or_else(|| format!("{url}: cannot be read"))?; + Ok(format!("data:{media_type};base64,{}", base64(&bytes))) +} + +fn link(url: &str, ids: &BTreeSet) -> Result { + if let Some(anchor) = url.strip_prefix('#') { + return match ids.contains(anchor) { + true => Ok(url.to_owned()), + false => Err(format!("{url}: no heading has this anchor")), + }; + } + if is_absolute(url) || SHIPPED.contains(&url) { + return Ok(url.to_owned()); + } + Ok(format!("{REPOSITORY_URL}/blob/main/{url}")) +} + +fn is_absolute(url: &str) -> bool { + url.contains("://") || url.starts_with("mailto:") || url.starts_with("data:") +} + +fn base64(bytes: &[u8]) -> String { + const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut text = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let group = [ + chunk[0], + *chunk.get(1).unwrap_or(&0), + *chunk.get(2).unwrap_or(&0), + ]; + let bits = u32::from_be_bytes([0, group[0], group[1], group[2]]); + for position in 0..4 { + let present = position <= chunk.len(); + let sextet = (bits >> (18 - 6 * position)) & 0x3f; + text.push(match present { + true => char::from(ALPHABET[sextet as usize]), + false => '=', + }); + } + } + text +} + +#[cfg(test)] +mod tests { + use super::*; + + fn no_files(_: &str) -> Option> { + None + } + + #[test] + fn headings_get_githubs_anchors_and_the_contents_links_work() { + let markdown = "## Contents\n\n- [Files and privacy](#files-and-privacy)\n\n\ + ## Files and privacy\n\n## Files and privacy\n"; + let manual = render(markdown, no_files).unwrap(); + assert!(manual.contains("

"), "{manual}"); + assert!( + manual.contains("

"), + "{manual}" + ); + assert!(manual.contains("href=\"#files-and-privacy\""), "{manual}"); + } + + #[test] + fn a_link_to_a_missing_heading_fails() { + let error = render("[Gone](#gone)\n", no_files).unwrap_err(); + assert!(error.contains("#gone"), "{error}"); + } + + #[test] + fn slugs_drop_punctuation_like_github() { + assert_eq!(slug("History and undo"), "history-and-undo"); + assert_eq!(slug("Faders & knobs (v2)"), "faders--knobs-v2"); + assert_eq!(slug("`settings.json`"), "settingsjson"); + } + + #[test] + fn pictures_are_embedded() { + let read = |name: &str| (name == "docs/desk.png").then(|| b"Man".to_vec()); + let manual = render("![Desk](docs/desk.png)\n", read).unwrap(); + assert!( + manual.contains("src=\"data:image/png;base64,TWFu\""), + "{manual}" + ); + assert!(render("![Gone](docs/gone.png)\n", read).is_err()); + } + + #[test] + fn repository_files_lead_to_github_and_shipped_ones_stay() { + let manual = render( + "[paths](crates/core/src/paths.toml) [MIT](LICENSE)\n", + no_files, + ); + let manual = manual.unwrap(); + let github = format!("href=\"{REPOSITORY_URL}/blob/main/crates/core/src/paths.toml\""); + assert!(manual.contains(&github), "{manual}"); + assert!(manual.contains("href=\"LICENSE\""), "{manual}"); + } + + #[test] + fn each_picture_gets_its_media_type() { + let read = |_: &str| Some(b"Man".to_vec()); + for (name, media_type) in [ + ("a.jpg", "image/jpeg"), + ("a.JPEG", "image/jpeg"), + ("a.svg", "image/svg+xml"), + ("a.gif", "image/gif"), + ] { + let manual = render( + &format!( + "![x]({name}) +" + ), + read, + ) + .unwrap(); + assert!( + manual.contains(&format!("data:{media_type};base64,")), + "{name}" + ); + } + assert!(render( + "![x](a.txt) +", + read + ) + .is_err()); + } + + #[test] + fn web_addresses_stay_as_they_are() { + let markdown = "[site](https://example.com/a) [mail](mailto:a@example.com) ![logo](https://example.com/logo.png) +"; + let manual = render(markdown, no_files).unwrap(); + assert!( + manual.contains("href=\"https://example.com/a\""), + "{manual}" + ); + assert!(manual.contains("href=\"mailto:a@example.com\""), "{manual}"); + assert!( + manual.contains("src=\"https://example.com/logo.png\""), + "{manual}" + ); + } + + #[test] + fn tables_are_tables() { + let manual = render( + "| File | Content | +|---|---| +| a | b | +", + no_files, + ) + .unwrap(); + assert!(manual.contains(""), "{manual}"); + } + + #[test] + fn base64_pads_as_the_standard_says() { + assert_eq!(base64(b""), ""); + assert_eq!(base64(b"M"), "TQ=="); + assert_eq!(base64(b"Ma"), "TWE="); + assert_eq!(base64(b"Man"), "TWFu"); + assert_eq!(base64(b"Many"), "TWFueQ=="); + } + + /// The real README: every anchor of its contents and every picture resolves. + #[test] + fn the_readme_renders() { + let root = concat!(env!("CARGO_MANIFEST_DIR"), "/../../"); + let markdown = std::fs::read_to_string(format!("{root}README.md")).unwrap(); + let read = |name: &str| std::fs::read(format!("{root}{name}")).ok(); + let manual = render(&markdown, read).unwrap(); + assert!( + manual.contains("

"), + "a heading of the README" + ); + } +} diff --git a/crates/manual/src/main.rs b/crates/manual/src/main.rs new file mode 100644 index 0000000..051d41a --- /dev/null +++ b/crates/manual/src/main.rs @@ -0,0 +1,29 @@ +//! `neumann-control-manual `: writes the manual for the README; pictures and +//! links are taken relative to the README's folder. + +use std::path::Path; +use std::process::ExitCode; + +fn main() -> ExitCode { + let arguments: Vec = std::env::args().skip(1).collect(); + let [readme, output] = arguments.as_slice() else { + eprintln!("usage: neumann-control-manual "); + return ExitCode::FAILURE; + }; + match write(Path::new(readme), Path::new(output)) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("manual not written: {error}"); + ExitCode::FAILURE + } + } +} + +fn write(readme: &Path, output: &Path) -> Result<(), String> { + let markdown = + std::fs::read_to_string(readme).map_err(|error| format!("{readme:?}: {error}"))?; + let folder = readme.parent().unwrap_or(Path::new(".")); + let manual = + neumann_control_manual::render(&markdown, |name| std::fs::read(folder.join(name)).ok())?; + std::fs::write(output, manual).map_err(|error| format!("{output:?}: {error}")) +} From 39e1a1482116f3ee8ef31e91a27064d5f0937dd6 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:23:20 +0200 Subject: [PATCH 264/460] Add the wiki to the help menu --- CHANGELOG.md | 4 ++-- README.md | 9 +++++---- crates/app/src/app.rs | 15 +++++++++------ crates/app/src/help.rs | 2 ++ crates/app/src/texts.rs | 1 + crates/app/src/views/toolbar.rs | 6 +++++- crates/app/tests/app/help.rs | 12 ++++++++++-- 7 files changed, 34 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1e3a14..699deeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,8 @@ ### Added -- **?** in the toolbar: the **Manual** (an HTML file of this README next to the executable) and - **About Neumann Control** with version, licence and **Check for updates**, which asks GitHub +- **?** in the toolbar: the **Manual** (an HTML file of this README next to the executable), the + **Wiki** on GitHub, and **About Neumann Control** with version, licence and **Check for updates**, which asks GitHub for the newest release when clicked and links to it. - Compact rows of speakers have **EQ** and **Details** next to **Mute**; a right click on a button hides it, moves it, adds another switch or goes back to the default, per model. diff --git a/README.md b/README.md index 41ab749..8d2c24f 100644 --- a/README.md +++ b/README.md @@ -338,10 +338,11 @@ recorded. ### Help and updates -**?** in the toolbar opens the **Manual** (the file next to the executable, in your browser) and -**About Neumann Control**: version, licence and a link to the project page. **Check for updates** -there asks GitHub once for the newest release and, if it is newer, links to its page; the app -does not download or install anything itself. +**?** in the toolbar opens the **Manual** (the file next to the executable, in your browser), the +[**Wiki**](https://github.com/Varitras/Neumann-Control/wiki) with the SSC protocol reference and +the speakers' measured behaviour, and **About Neumann Control**: version, licence and a link to +the project page. **Check for updates** there asks GitHub once for the newest release and, if it +is newer, links to its page; the app does not download or install anything itself. ### Control mode diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index 8d09873..521777f 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -21,7 +21,7 @@ use neumann_control_core::speaker::{Command, Event, Events, SpeakerHandle, Targe use crate::display; use crate::file_dialog::{FileDialog, NativeFileDialog}; use crate::groups::{delete_group, forget_member, member_id, rename_member, set_membership}; -use crate::help::{Help, UpdateCheck, WindowsHelp, MANUAL_FILE}; +use crate::help::{Help, UpdateCheck, WindowsHelp, MANUAL_FILE, WIKI_URL}; use crate::meter::MeterState; use crate::rediscovery::Rediscovery; use crate::scan_choice::ScanChoice; @@ -471,6 +471,7 @@ impl NeumannApp { ToolbarAction::ToggleCompact => self.toggle_compact(frame), ToolbarAction::ToggleEditing => self.editing = !self.editing, ToolbarAction::Manual => self.open_manual(), + ToolbarAction::Wiki => self.open_web(WIKI_URL), ToolbarAction::About => self.about = Some(UpdateView::NotAsked), ToolbarAction::None => {} } @@ -716,11 +717,13 @@ impl NeumannApp { let wake = Arc::clone(&self.wake); self.update_check = Some(UpdateCheck::start(Arc::clone(&self.help), wake)); } - AboutAction::Open(url) => { - if !self.help.open(&url) { - log::warn!("the browser could not be started"); - } - } + AboutAction::Open(url) => self.open_web(&url), + } + } + + fn open_web(&self, url: &str) { + if !self.help.open(url) { + log::warn!("the browser could not be started"); } } diff --git a/crates/app/src/help.rs b/crates/app/src/help.rs index 5006856..0525c0b 100644 --- a/crates/app/src/help.rs +++ b/crates/app/src/help.rs @@ -11,6 +11,8 @@ use serde_json::Value; pub const MANUAL_FILE: &str = "neumann-control-manual.html"; pub const REPOSITORY_URL: &str = "https://github.com/Varitras/Neumann-Control"; pub const RELEASES_URL: &str = "https://github.com/Varitras/Neumann-Control/releases"; +/// The SSC protocol reference and the speakers' measured behaviour. +pub const WIKI_URL: &str = "https://github.com/Varitras/Neumann-Control/wiki"; /// GitHub's API for the newest release that is not a draft or a prerelease. pub const API_HOST: &str = "api.github.com"; pub const LATEST_RELEASE_PATH: &str = "/repos/Varitras/Neumann-Control/releases/latest"; diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs index 433958c..5d09f36 100644 --- a/crates/app/src/texts.rs +++ b/crates/app/src/texts.rs @@ -144,6 +144,7 @@ pub const CLOSE: Text = Text::new("Schließen", "Close"); pub const HELP: Text = Text::new("?", "?"); pub const HELP_HINT: Text = Text::new("Hilfe", "Help"); pub const MANUAL: Text = Text::new("Handbuch", "Manual"); +pub const WIKI: Text = Text::new("Wiki", "Wiki"); pub const ABOUT: Text = Text::new("Über Neumann Control", "About Neumann Control"); pub const MANUAL_MISSING: Text = Text::new( "Handbuch nicht gefunden (neben der Exe)", diff --git a/crates/app/src/views/toolbar.rs b/crates/app/src/views/toolbar.rs index 4dc3da5..8cab764 100644 --- a/crates/app/src/views/toolbar.rs +++ b/crates/app/src/views/toolbar.rs @@ -1,6 +1,6 @@ //! The top bar: "Suchen", "+ Adresse", "+ Gruppe", the speaker count, a notice when something //! failed, and at the right the average response time, "Kompakt", "Verlauf", "Einstellungen" and -//! "?" with the manual and "Über" (one small button: the narrowest window has no room for two). The +//! "?" with the manual, the wiki and "Über" (one small button: the narrowest window has no room for two). The //! compact window is narrow, so there only the count, notice and the right side remain; adding //! speakers and groups is the desk's job. @@ -33,6 +33,7 @@ pub enum ToolbarAction { ToggleCompact, ToggleEditing, Manual, + Wiki, About, } @@ -107,6 +108,9 @@ fn right_side(ui: &mut Ui, facts: &ToolbarFacts) -> ToolbarAction { if ui.button(texts::MANUAL).clicked() { action = ToolbarAction::Manual; } + if ui.button(texts::WIKI).clicked() { + action = ToolbarAction::Wiki; + } if ui.button(texts::ABOUT).clicked() { action = ToolbarAction::About; } diff --git a/crates/app/tests/app/help.rs b/crates/app/tests/app/help.rs index a6ae3c3..95e4436 100644 --- a/crates/app/tests/app/help.rs +++ b/crates/app/tests/app/help.rs @@ -1,4 +1,4 @@ -//! The "?" menu: the manual next to the exe, and "Über" with its update check, both through a +//! The "?" menu: the manual next to the exe, the wiki, and "Über" with its update check, both through a //! stand-in for the browser and GitHub. use std::sync::Arc; @@ -7,7 +7,7 @@ use crate::support::*; use egui_kittest::kittest::Queryable; use egui_kittest::Harness; use neumann_control_app::app::NeumannApp; -use neumann_control_app::help::{Release, MANUAL_FILE, REPOSITORY_URL}; +use neumann_control_app::help::{Release, MANUAL_FILE, REPOSITORY_URL, WIKI_URL}; use neumann_control_app::texts; fn app_with(help: &FakeHelp) -> (tempfile::TempDir, Harness<'static, NeumannApp>) { @@ -37,6 +37,14 @@ fn the_manual_opens_from_the_exes_folder() { assert_eq!(opened, vec![manual.to_string_lossy().into_owned()]); } +#[test] +fn the_wiki_opens_in_the_browser() { + let help = FakeHelp::default(); + let (_directory, mut harness) = app_with(&help); + choose_from_help(&mut harness, texts::WIKI.get()); + assert_eq!(*help.opened.lock().unwrap(), vec![WIKI_URL.to_owned()]); +} + #[test] fn a_missing_manual_is_said_and_nothing_opened() { let help = FakeHelp::default(); From e76851f5e023fbb1770ad34cdb3b7016a8896130 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:28:20 +0200 Subject: [PATCH 265/460] Show the current desk, EQ window and panel in the README --- README.md | 9 ++++++--- docs/images/desk.png | Bin 484655 -> 267430 bytes docs/images/eq.png | Bin 0 -> 283181 bytes docs/images/panel.png | Bin 0 -> 251831 bytes 4 files changed, 6 insertions(+), 3 deletions(-) create mode 100644 docs/images/eq.png create mode 100644 docs/images/panel.png diff --git a/README.md b/README.md index 8d2c24f..6bfd28c 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,9 @@ strips on a desk, with level faders, meters, groups and a master. > This is an independent project. It is not affiliated with or endorsed by Neumann or > Sennheiser. -![The desk: four speakers, a group strip, and the device panel of a KH 750](docs/images/desk.png) +![The desk: two KH 120 II and two KH 750 with their meters, and the master strip](docs/images/desk.png) -*The desk with four speakers (two KH 120 II, two KH 750), the group strip "Front", and the device -panel of one KH 750 open. The interface is shown in German.* +*The desk with four speakers (two KH 120 II, two KH 750) and the master strip.* ## Contents @@ -184,6 +183,8 @@ before what is only shown. Settings the model may write get a control; the rest are shown greyed. The panel also shows model, firmware and the masked serial number. Escape, **Close** or the window's close button closes it. +![The device panel of a KH 750: its outputs on the left, standby, device and rear panel on the right](docs/images/panel.png) + Each output also has **Input mix**, one field per input (Input A, Input B, −100 to 0 dB): how much of each input that output plays. By default the KH 750 plays input A on output A, input B on output B and both on its own woofer. **Mute persist** is the speaker's own setting of that @@ -252,6 +253,8 @@ woofer, Output A and Output B for its two output sockets); then each EQ of that tab: "EQ 2", "EQ 3", and on the KH 750 the crossovers of input A and input B ("Crossover A", "Crossover B"). +![The EQ window of a KH 750: the curves of EQ 2, EQ 3 and the crossovers with their sum, and the band table](docs/images/eq.png) + - The graph shows every EQ of the output as its own curve and their sum as a thicker line, from 10 Hz to 24 kHz. The curves are computed from the settings, not measured, as the speakers' digital filters at 48 kHz (checked against measurements of every filter type). Each band has two diff --git a/docs/images/desk.png b/docs/images/desk.png index d3e47dd92d12862ff7d0494c4f05e462874c6af5..33261f85c1749bdafdcc5507a03707bf6f38a123 100644 GIT binary patch literal 267430 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 z%0D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 J3(gz+{}1_0N)!M9 literal 484655 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�AWZ!7jrP;)4&8Fs@g->~ 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

    kA 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

    aA#8gQI!ni=m29rYu?<} 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 tv9H<| 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 z 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�yU{l=dmW3|Ygpxjj1kp*_F7SjmbI{X53V&=B*y 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 literal 0 HcmV?d00001 diff --git a/docs/images/panel.png b/docs/images/panel.png new file mode 100644 index 0000000000000000000000000000000000000000..8d28644a3802303c76dc9d34e90d7180919ef601 GIT binary patch literal 251831 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

    N{TPe{E-uShhGs2m|J41-vu zMg>}Qh;2aN_gOTKbavo7+@NUy7)F2W3ps{N}$SYW0*!OQM+hQXdo-?cJ4nxLx;C-y)z&ioN_8TO6fu~ z;a{*bCzB&%x-5vr3^g3&PJ~68FB>NmAx}fv4+M!#D(!k%1{J7>d?c~3fr1k}@ZKo+ zz1H!8>sYtxM27efopr#ZV$r<6i^x zLYiSzc{U`l^5O^=O4;Fv;`>YPE%iJtY%(RZ*&vhwv{RYdPjuXdeQ~@6Ov)>bNBx%VU1L*-l`AWW=YG1P`AUj^B{dQf?Tm>rgA z@&;mJO6^JjQ|P_y?y%>?B)EkV_A&?x(Bl-8%ji?HI7e8I<0l#d2` z+)gSt$b#eUCy=>Sl+1)V5xY>$t-4hLFhgI3(jL7UrvWF=3l7b6>=f$Oe{{H%rL%GA zQjP;hT-&i!wk3g3k6wv9i7XZZ%!J+n9E!EM-o#C4Nh*_N#FdnluliC^5bj#KfH7I6a!-D5Hr1YtA++*_^IM&+?nUU7Ut!}#_ZvE%pM?3yunKyds$oWjDZypvbM8+gGI zr5`6E<;Od#(Xj~0+25Vrem8;mT<$j58!vC-2NMo378fYCtHxe>4A(r^10!sW1Qlif z*Q@#ORS3A?!YzU-oAXx92J{^524MoeMBZ2MLOrsdq)((S3`=w*?jEoQIB)Uo2f(;S z`}@YLT!Mm5fK5U(jdQyDH~V!wpnQ#B7QCFYiR~}(xJoz>c=#m#d4zMWnp&pTR!99z zkaqjW6jn=-!?wkbBkoLL8K!!(lBcyhp}(`cQgyPBEm0@%*%p%feJk{2cwmq#TLLTu zH^F1@T?I(Ll%%L-{I2MG172EXXK=2gv>+19E3_mib7Ty|Erc{rh`DM<-i=T&WBE{k zCe*@1g0K(Tx6=(q3p4~0$|vu6=;iezaQ)p9QEL;CF4S%#=?D~n?$t9w%uRG6EDCj@ zwa`jnq_Crsu@Mn1qN>94<_%QsO8Vve%;+ALr5> z#JHLuC@cFBAwz~Wi`K9l+vaufqhwjvS>PM6hD`uhDJ1RtRj>^x=Rm+x96E%|s4=PA zVUTj8jr`9QR{Q~=T!B=d{(v3L>S65fLp*C%hP|wDG2MSlJ z^(|_R${&4SljKk3l9w)x9QUIo3WvPJNt9mJhF<3=)3P9|C2){qmop!R%of zbtej%Cc^iqAgK)uo3NQqP6aDp&Fr+5IjQWsb!kG7Yu?-#VpkTMYx0_@e;vj)u#3==uzPr9;j zckDjzU6-vSeRqW0uhz^;rhsDx_@+RfCegDjo5Ic_^%-*8^hAHc5G%rt=Slc8E1{{d z!XO`ViLIR=f%|L+0d1s^18%Fa?;5(^V!3Pg?ib`3&A7FDVXH0TT-~*24>Ms)KpZ27VWltx7Dz`50dfg;$QFojP3vD@ zUtH$nu*<-iY+7^?2xSg6^|9Z^s}_s`za~6=eS=u)oKneAkF|1V;Jg`+4m~0WalvyT zRb>c{Xzd0dJygA2W_{@7Nmt0*(zdkX8cp-laP_0sS0jF}5@PRQ@?9|R5ye}(j zson_=3HkA7j}ZVgL#|vIut1?Vad_O(!5K;j03fc1imuY}5v!p;0VNf1o-pst%Mc_^ zZ_~Bg*@)5`r~IH5?NR{J*p4;{A%q%ocN(lz&t0w5++seLLd18#i0xSo&=ju3cFhQE ztOBfrFoPyvfN=%y5Y7l1Y>s9KItcAhJmgUkj$oo#32AIMb#F}yIQA00=R{^rmSF7C zM$X}IC;0A;JpfQfU9af1Gy4(2Ag2tZV^Pt z@$xRB(p|E)0WU6f?HZXskhj;R%c9I7OU z?GJm@z9tSFj^{@C_Yw+Ur5mQp{1!zEQDvnCk(ciiDoCj8`~D!UZu}Dwf%s5a3R+<7 zq&8HdsVQ5+5{J3#LAyM6+xYeyANlwO_DVv}c~1IKC+@67#*@J3&+kHZRwb=!rmCQz zkT^miVYoscbq{{N^4U1ZaSLFG8?c>{u4!Nz!bYH!gqlnz%izi&E!2yEtV|1kTMXub zuoBuIEi5cd+`QBk7-@o0_b-ft@#az!P{?1zc5qjG3rQh;XhlWLX1!k~m1;`>Y1;ES(c2Zc#o05Yw_mjnuhqM4Rc|KmTX zPUz|AGPMg@{=`DUPLqZvkwq&{Cga1@$Ox}d^>cKwy1+YeY@@RPmRh-siItTwpM++* zg8zeaL$;OZ@p0oJB*c~Mwa7zZhK-sKSi%=aa4w*$(L-FuK6&{vsw*tn8Y6^Rfxq*v z20;zQ$M=zuk?HBu8y_A*3yA2c)bG(XMuqp6vh(vD@s1o1-vl|evp z%R;t(8Ss4GGb8*L97_a|GSQyaiRv~f%E@~xKnKs(uT)6wygS_|V7Lt^vNGBY)DKAL zt2lZQm1hRlvY)gaGn^ymdla~#!NHD)eoL9q5bN<)>1%V39pa84;6N_miwO2ZtgM?YKjRVX_*H3y^-k! zI2ma8T;p(!@lH!1JZJ|WPn0R%0$;ZMO5|#05Q?Ln$lrcUGWT_is9hqqf2?tz`HPSec?sne9<`L!x$uC=b262AN;!)H2%HOKwTY#zt zNyW6=Rg~!-TYoe!{e2Hj@B7GhmIe+q5AMS)bx=Uh(UQ_7q zHTG{71p+{$?@3v#+P;B`^;- z1m6Hzw*ME3CVA(eaRf?oD=aYwrOxITqnbrY2v?z|26{r%({+G`Fk=FNr z($)V<^1%PM@r&+7Prlrt{GVdBmHWS#>i-K!d*O#H-G6ny2b}``k81b7|MCAPoJ;cb z?3%Lw-!JBt4d@L|{BNfCh3SF+6P`f&-#1xHzgB-8m7mWigu;~gs)#^wxPUX$VC3ns z$xX5W(*sPMVD$Bb==-1lWjx5}0cr#B1e4%@b2KVKqaq@TvD2cIfAXJiBoB$kbXeJ1_^G3wN0`keCz0|vG2x5@2Q(5xs^1OL^biqzWm`;#oJ= z>3=?*=umpqZAsdy$R9gV$Z-}RoG-q-y!7))4$r%b%r9$MKeS(5{X*?~Y{OxW{?j{d z>`s5mJVTIq?xO3GH5&5(mx)6Vs$^isF4oegE)y~DNBp6yL@3KoE-KPtCf*I_x9;PI zu3tWo9P8|{zVRTyb1+$Qo+-gGsMg7!^?R|%$Qx~;_==PM6IZ*Vab;yFa!yI#}eESZA{GDU2vs&t3Z*LU}Z!phIoy*eF7dN4en*b!N<}c=*0lS+W zEkJQK*V3YWZdN|q7DQEBcS45-KVp;U7I&eI+|Oxm16?H9f4}KYvdBBmzqB<`Al|G{ z{@5(zncI)Cl-sLt~zt(6%EsKaF=Kwm^jdyembF~EtIr*7OE-lv^wRw#3M0pum!*2_E@dLaVN*XSds1e)<}cjY z8CPM{GR*4tkB`CU#21`o`jUJ5J~%9YX>At61gE5-xNoATRJ}rLjKC=ll33~5Zpmgi ze{iY%vOPtFbht$R+w+xNXQxmp6~qd+YJ1ktArbxLgG530?%Mn;YK?fk?#49I`GXX$ z)<+ObQkcngcE|KLp|v}bwa|jJw6@MnZhg~jfi+~rX%!;HalNAZx*TT{nm=}jZPSZp|FMcsWtx$Ukojb_MI_qPK< ztCF?QnGZECGBUU5HA8?j*TF2+X}55C`j0?na9JBSYrdQ7q&)&^3){_eud}S`KOgYF zF60>N)6VhyndCYtFYto>0`%wtxIZP%~)wDzr4G|1ch zru6x`v0m3eOGM&-Ec zf}6UaPfFbBTuyhN->4^WUL%)?fo&_H?R~sF*g0@3ji*l~@9~6smW~ih=d-TY)GsEk zzK!40J)&nnxY2KXd;Ry#E$zAQshr-6u=PJqkto|+w(IuSy;*egR9l{sW|ryeB#$C? zsHRiSgDo$sjl02!vmnH;rp%{NT-;c9)3H$SE-F#;kRp!(PGkm)$p<{Sg=b_%^)qdc z)x9g$YP=n26V9y__OAaa%WOo{J-J59LuBDK<+>vmGx{z&-Hi;_Z93dhJ3@KdIZ3)+ zUzgDxtTBEg<)}a*r;t*oT|iA>ZZQIrk(iv4XTT(`oD$q_u1Z~{lB)^jE$p>c%H~|T zLkWcr^loNthaw>CSY`aHoP~7Z`|>^ZuZ=3djSI$#s{_-HCAqz@IiC9`+0B=_!33nL zAw$CY>xn|mhuZt%+QzUqZ`i$PkZf*TRc~DF96uiWIJcutB;9eF;PH=3kL$MDSU4xv zjh(tzLW+Ip<$oTBSJ7~#QDLU3R0_BOXvrbH2J|ggVXVQ$1KD6iLpa8P8X z%tp_@m1Ff88yN}i)#P;tJr?8M#pJ-(`NJV+Hkfs=dNgrR&pdCVER{Go@M=VdF_C#x zOhiyhT3Q+o5YRnnb;(55<4^d#J+)M?mf(@1`8wQo`Lz&jd4s&rI>l=$_m2XH=w!Dd zYuNjeOd`^5co@*1^)A%gpiGG8VwYt_k(utY?3;LA%AuWM~>4X~JM572<{%z~8K zuRms=3FQW!C6P9k$(6HOWeu~R27Il{s?e;Z==F84$LWSR^WIa3hbzq`E8jD=YF@)h z#roAGR}*9bN;&|v6?fX#*M$cs1yfpDRlY0IPnd*FPa(xM^-9J_?pB)B%)K~Ttxp9Y zCZy^V7sPj9rcSsmaSQ%3H8Se^b+TRMFGKAgXks_}SaM2A%2TH-+g=LHge0Ei)ju{} zt@MIKTHO#kNYN8+zW;@r!&isZNBTYj=DiyaT-Tm2CqbTu)C(Yyt1I*p?N0_0%e1L3 zGcHKScUvqj5MQ#;Eo1&qyLtNyw%UHPW2PNuJUb`OF^zKn-KOH~$#X_ktejiMos4Fl zc1vHPLt(+L1v(o}x;hmpA}|p#(+kHFo1f`ft&ywco&_7O{zy2WCf+Oz%0xuwVAdS2 z>9~OjZ#a*vP4;ej{bT<;{s%>DAZxmBLF1bUr-1G&7D=w37aQ89czN<>5f3>(Pwuh3 zN>yTHxms4dP2Q_Ad(b>$1QA4)ipVL1{h=p!ZEoefR;2nb>kOL{86C&<8zftsEQjp2 z9o^FJbdJ)D>OFl_AN94+`6KH{QW6M6YyO_XR)dl{StajXFZHm zNrF0o#{4I8_+aPFy8LbR7Gh^Ht>F$7M*zo(fWt)!1&cA-fsv8LIcau^9*-Zz4y8CO za9&0`=<4bMV^HG=-JOIbrn9j&pNLH#fAsA)ii(QD-z|ccJoV0;nt^`bdZbK?I7bRu zLph8oeJy&Yp50KM(ihN;h`RBGQ>p@~bc9;ev*xM3eCevc(o{VT5A5!3T{5k2QG8(L z!NyF{#O&*It*>vj0=OCutZnGsxpg2*Yt`}M_d=NV!Cy=i>cX4@{w(|Q?kUN4Kkvav z2?*O(2dNzw@tN+myBpMLqdrCVZSS-^c0g=7`Jkxi!X6{J+-%T^H!s7EcM8NA-$$PI zXX)-Il~CS}dnHx|ViO~-CmEdg*h!Q#;rGBgdXf0Wia@^MK{)4OCrnOGo^R2O1c>Nl z-rGyVgloDZJ=7Dj24nDpO2vCB2;T`11)wC@Q|RgG_1@jp2l5AX;J#C;0A&ju-dtbJ zo{HPH-j!bOaoCm8@0|_la1)gdmR?MuTn~xr+$^Ri7f#*sUxJSm{=+KguK72Zq|h z>q}kIwR_o#0m1+_mA1NvvrR>c9jG_K{;nLr0UbC(Hh}_!;mpwc5->Y@)TZ@Xw{D%v z^hK@uEnb?+A-^4QV*#H{cG;9$@B-1p7oU0C4}|rcf+rQ5dEzakVqFu*WA+(+v|n#u zt=SAnNXRTMHk1+rVuWhFpjDfJcHesS$U$fw4j(3Fe*!+hFoXIaG8{dlAT0(B&cwoI zlY6uIqt)3HM~JTuFpPx16@-&DJyjM6lWCZlRdU$bwcoveutm~psxsN98aH9*fn&6f zvqd!IPSqA>riI9KE&z_B2uM-vyzN;tBr$3Hi*9~)_V0cBRN14jUCLQqR}6jR2>ytm zv4J}QC2PhS9{$2t)w&J$&+wI@jAlKDDc;1^L1Pq_bLCX0HlPX}KdGMMo zzY1DQA_NL@$FM+wS>$z7acID$i!2BXMzJ(fMVEH`{0(niQ4AUDA8un5SMs|;&m2G(_slFvs1sga%=>Cj&jCYPF- zOsfQ9ijZW0v@2WS;FfOktlq<39JBlp(Ff0ta$IVs_m)I-6|i+AEMXxdx}Y1{lN0GRCFNkQhFs5af1Tv{2Q*beRXnq|fHLbmz{EQM%Q2Ex09GsGt7K zy;AZhH{;Bn@9Sy}Z`$OvfY-urLhbPoi!BPeT-d&`*ns*%uH@*j*L!!<5J`f{@zumK zE`Hi#Kb~J~R=2c?*e>9|NKpmS2k{hL5*0H~Jpq`(-KBkOK?HGkJhwVjv=Ct742nZy zVHmJBS~k`rbx`)qLA}Dovlk7Fh04CXcm;cgfQZqQ8eh>!94Hcwosmw-5~ssayfrg7 z%u)u)xRqI!$3M|JrEjzrliy8%t>CgEoU(y&L%LMBP`0za)Yie_HInIi%8B7i#Ec>6 zzzC&tWjA&h=7=q~L^Hb@=s8J4;d8fTKk(#~d^(b=R`xT8O5xbP1xr&^*Ssss)`OG^ zJoT-OnRWaNhIzf=QXRQLj*`2FXLQGBzQAupq{SL9SJ(nMg5*oVl)!Kq))`rb7)e{b zib3r9h3(4$nO7AeBaXc>2M}nk;VH)<|A_r0*;ZSfQ^5b6C}6D{Ty;loL5|=o$e3eL zM}aOQwB?W;ivtr@9gUe%kM`RBtHYqhoXd7#5f+xC#I}ZHO;1d- zZ2ZBcDJhKjs_`3LWo{dg)NtuIL5u=WQzcWoyP*LhbNk9~r4@=lUmF@AR>mMFBAAWH@Ih)tqL3yyBOKR2l~H+ME+8?R1qUx~ zYAqn(vu9T!XxiFy|KT+n59G{xcq`h z3eVEYrg95@P9mePo!bYSvr+hdIJcyH8!1!I}q)09^G62FTVr4?kf5)#m z=>H!%H>g!2+YkOB@gW@ZmL&_pRJ|b$BgENa)>;Ycs5mMLERgB0I8ky)5db#FqHyT% z@23l3I}R%dV>DkLA!1S>Gllwm2iqhqSRVKu;%}bo?0PR8*%xch0-b zd_55tO~B&r5O=ylza7dZi%^Lfw~byxVB-k6Byj49+IJ41Lc$W)@Y!&?EOGAO^kGs< zPo8i>=|1@GHx4&q$T#Ky$4-kdF^xlhP%StOhc^7-sCS7V9;*=$BalJ(M@98RFaWd( zB~Ckpd`L{*#NhFNiDb_F?+(T!Yb_;9v0r@dIk%u%MDd~1DNBb(-3?S@ zDXcWXmpW<>7=t5V}kB+xPCV z;MoSbgOxoV2Z|7f;534~nidHi60wPj&VC|CD`md=Iym(x0%Q?-tBCgElGX=Xm|&nV zg(L4h$LD${6=z4sVcTb*Ih7}(p$+E$TR#X$`K3t0+zMYNu^OJSVq;M+y43o13)0b! zamIreOnpmBL-P%@hnZnL{&MFO#us8N4kb+4rOFuKgd|62d~T*4c2@G{VMyo<1y!GG z(XF)B5;IYVX^Cf^33Hk@o^aZ|)!_j?fFlHx_wN08%tco>W~j6FUC}M=PS3&y??!@E z{bU=vS>@KoMW)AZa?7$#wM8GK*P_bp#?uhs<{*ecjqGit&O7;4m6hwb4Mbi6;`cmZ zrj!dg|KW$n!2K%drn$JeRqn|Ajkj413Ed>gDb(B6=uUvuD%nsc3cTBfY!5f0} z1zS9_eWRa02NhQl$!RQ=*n1446_&?TeIGzRX%7L9;ivO(#2e1-C&irqoWqQfL{HD? zN&aFZrj;wWx$JJ&yewe?<3+}}=v(IAjH@qA#OTsn65dQL5?lBk88(}&jTtP^w!fsT zU&TnQ*}#EYIROfc3(*dP`R_q5!&d^z?!172#y+0W`p{SQ>K*IKSofL#>M225BACcb zB^rGzA#x1OV^@s(ggxp;pW>xUAO96p0vZI?gZYlb_eDPuYD$cw!>lUQ$#`|$cXvM= z9YkQHs_a#T%_PQ6Ii=rLLV=O|;0LI`X`^k^wf#1n`U>J`ppX}Hgpx8J3d5kUDQk!_ zeT``d*m96ukHupI@sNaz9u88d*BWHQn2w>swmuCy_XC+)L+<#n*c zC@aysL`+@1h@WW-#^YQ&mWTlVlssZy=ZUf^W9OO5m}dUHXQf3xKvyU(p5Z&;G=1#Q zp}FXd)hhE`XaXnkBIbQ*WRa6PGQ6b1D|H2P?Q)1-iUJJDICYT(bkTzdXK&(0~-IrOh{T6 zP+L1hT_LuB@hU#33=KYcLP|wNNsK2=71JdAOBUU6jl|LX)pVzQGJQ4bI^dP?sX%s;4tHjB@)pM}=^&NtYwa@bP1aYRsjzzW|>#Vz@G7t<~u zJi>^&MY?Hc<`?ki=jsmBL&JgfWzRBy4D*!W3uLAJ*Aefx^F2DOwve+(lLo2+$G-pZ zGJYVx!EmMap3a(Wg1QfVdNDdi=pX`*=a3P)-5>>6;PD*b?}$Jr01zpEu>ndp8#;C9 zP~6ABfBxB4MRucqQ8&*}bFdyY6K1hFp?(;Tz|q`;T|w;{y5kyTF3P9<45?SJ zVYacSAum(IO-o<&Jd&%lAMZYa|`z2 zxwbaOSYJZ(w7Gd>JtnC_V`E+EgaPMDL)2I#9+DuSM9Ee$6$Z!7-*}IL>KE?gek$%{ z7y=wiyNL*vf5pjgj_el1xnWn(94OK|LCio5H7R$2|0DJ9mx*Su)A@~=1>9|3$&0&XhDYvkOukBKtpbUQq;LEhdN8$cw0R9XB&OH9mV z5xlxR@Jb!=@b!$N%s1+N|EZ}-UgY)Dr+XJ(Ey$+CT^UVCmrxc~r@KK_b`bC#%JL7p z+;FXlpqm9psCp1RJ;?7oh?*SUAJrrnNq#sLI^kZ*=rKUg5N}d)Gt8ve3v&{#mFC+v z@QNtv1fTG5sJxK+CgZfh{aRdI*hU;PFAi>W3dcGzjIGVq|rX zeA{gIF#%0UNy*G7lN--UR`y%Oxo(iw`;7Bsf8U8!Sd_S6RQDqG+h50MX?D@xIe2*6 zMY6jbR}@qZ1~JC%rP#Ku%yU;ZU!X>ea})Rc^zW*w{+Vjp$oS#4<;$0?9SXEFYv`Lq zn&$m0b6Xf+Mn^ZTv&EZlzoxBy!Z{fZ^TkI&NH7fNkL>IZY*ZU!Vk?#G~ zT()I28JbR}m|J35yY?5=p4;`%R4$iJ$M)C4!rE(k_0^`hc7~ZeI!K^g3`NXGiS8HD ztrWj!qf2OtagN*rCWJlG6}g2vdb8I23_Um~0mS@70asUU)IupDq?TUyPYGfo1SZEX zRAl5z28%UDt>I!QU0@_u7xHlDj4zK@ec z={;HJp39SQ$e(j;WyH_JujI^}vN9EE4QJGT_3qq>vO``dnt}(0MjT5aBSV&f)7`u0 zoo$9oM$4ehwr*Q=9jf-Ha52t4^x{u^Mc-H_O^M6?)}HMbZ@g5R+R5E=<5E)VHHYy9 zC;N-42}&+lo!E}6fXuj$?w(W{orlum%>ye*ZwzIE#JX{ilr+wIxuP8dwIgX(xTCU|?rwJ!>=k2-^QKLAY zS7)}SysP0P-IMf|Phk`(_@#7`HtQP0{CoR9yzKv6;f635FQ5_MRNC=^;zl9)%Fnm& zWpVAbl=>T_n4AenKu63coSeiMR7;emP^U35)zUS8(FLD~;~7sbH0QAK85qz~@I5wW zH-)iErC{Ep@G@0K;dBSP%VTMB%c7j)kcF2{MEsIp$-my?82<7n@C~9ovDi;YtzA*O z0bT+B1zazHEqp7im8iGq>} zEgElu8ShNuFXgYL`CDr=U)A!pApWyZWUyXBPR@^JmvEENadpWLe>p#X{Mdq1HbIHe z_d9WC1%Kn;MErn-kdpCxYUQq4<98E<^3mn~hXlMevlY|IrFInnlXCvnrZkj%VGa$O zN#_bQl4fGm4;Z|6KQ_P#Ke>nr^CwQydU&A2H{+)2Y!#Q%*+`fz4sU-s9oW~?XSm^C zF>U)tkb3xLVLVv}+QcCIi*;p|Y5;cD*z9zhj??FzJ>;Vha z5xJYex%ZNKZ|#WGX?jaPb7y3-0E5eiiB=k;(r>oJUjtxgOB&9x?T*;dn{Syku%A-p z#EU(9_Zn@XGrL3e%-ei)WF%W%-$tNLUw)?}Q$qO4jg6NBa{Ch-G|_g4BE1-8dshx~ z5Dfg)z&=>P_8}L^5{dXp!?k~r`d+P0*tq6W@0C{CZykJT>9?guCcl~-TiII=i)aDE zyyqh{56gG*9<<99UJ|(`E2{&@7~xG(uFGbNInjgf&_D=1J_4P%C@ULePrR8`?ZhE-oDNQ|WbA;ch|R&h-v0kfIR{{9}R# zs;`i{=b8pKfv^NT^-^-dp@24%subk3PIXnRm$d*XI4?1}>+s zXZYeu?U0Rp(CqHnFW%Q3HN$C0F-(+pJ>m5wmsLPLZI#`au8J zb^w^L?8%$q_8C2|R9QN!vzHogG(v=IPjk-!!#vBcjpL*z`>B`F-J*aA@qYBk!3sN@ zEQLU-2{QVW7!CtIUwD42U!`Z@2UeWaXfGPDND3pu-E5((nL%)Mjd>l3vd0&tzk~Kd zV{xpAi;F9uA&Kw}!}Q#*|C{2-TZ(NTBHdHMuY8?W*K;zlu_DDd=PkE%Up>9YN0O3) znf33q$!J@Pv$dr{I-eYb-@GoHn0eZ%>+E}l6=PHou*dL)eSsr)EJd$n6l0o9BFP{T zm@GFs%Y5(+y=($E6wsLJ6D+7($Q%$PVIC=jvAQ06gaN%xKH(0xyXNuNITs=A6KrO6 z=t`Uhefhdx;53-iP}oBHHvM#bb%Ot>@7YtdHc`TSUjxd>srNtcin6Jcxi!vl^>xpg z#4-zXv|ynKJ_bYkD1EYyZV^aPeS7M#2)x#lmDp=MrU_kfx+HZ;Ah#YX^D^}9NVk0@XWlS z0QJp0bNTc!6q6u(5Pn=Paj7v%y0+Nn-suTIqqhF5z>8hg;<~VY0q!d%Xos@A!d;Du zWI`kF`Sj^q$G2#)TOy?HbUd$p{VeFMeCXPbpRm;vW0rx;HPMN`6hk7$^_q+ovs6$Iut>Iy_y@${8h2UeMy;_pK8g(aW_>tE2JY(QZ*A{x|M`BUMRFsYRA>(0 z^e)b_2h`Xu%^B0~-m?dI?6IP8tohQGlI@vVdIA%AYKs?rRNm2)C>y0U#PESSHJO9nT!-nB?N+ z?MT$R4;TpO$=LEa%p4B!1Y6Yg+a=|-j9M6Mmgq&v{V&D-0ewBRlOBW=?(n4h1}td_ z$uAjK5?CQaMn{yOw1m1ZM_bJ7GZC51OLh@5)nX5-%%m-(zQ+$dq66AeAKqJB38)}T zl^RR9|1@4HWxYr;*sZ*j>^PbKPVNQDZKv7T{JuniuXW$D)#Aua%oWGZ(;e~#!dobz zC)vn%Ab0(~hm>Lnyhpunxp76~0xR?u$E7bGjKB1NsNr-HUJWpp;x<`zD5$QpV9N}+ z`gcij@exEh*;UybJz>c=3IrN_MSlBisam-__IW59xxD2Umd)6@Y-W4VV!AL}n z16x9f_#xPrz~4X?9qM)yNIUW)_=MJxzKV)w@0S$MKg7fhL`kf?_JsxdF{?c+!S+pP z)X(YZgpqz>Q#6fKivLOlhxZM7850vapSa-J-C06ze@&+wyQ#gCbbl{-)ctc(dR!WN zLZEfZHH~(`()5Za+ZyAQVkVw+iiJM(q^z-tNUx++hw5n>3oF zDF~E(EhH%rWN9R=zIk&VVC&+7IcMl(z%%?eNTeO3zM?AxA{r<)4Cj?$uBHu|4r^d?m`q zl_18F=Fo7&^U%;7j)8p>%3mrbRTCt6!jS#}a?LRcRd6%*PE1om92wkoc_m6$<(+16mw8RT;5P#e+qYw@fcIWe@a-^xS=_=pA*xP9AsnVF zg8NN&!olP98gwQ8g*D^0h4@sMGVcDk29K3#o{KexWHU2M_uOMd-9seN5~L+7tM6lF zq}pJ&hsUA1-yZ-7$esto-9fV}>wYe}Q&k-n7n(BdY)*iY`V2i6c)KHXmNs)mUSJ=GGFTtSPY&L(+d=Tn6hojHmL5#d1SWAF$J`Z*#eV7 zGnNcv36v(bW4F5A=r6L)1J!NNO_xhTRu4kbrs4$~&}T*^uvnd-gcJ%NADJ$f;&P^>D2Sl|Iv zl6ID#ILZa4X-VFlV4k3G0J4KgryM0_*BmB6)fxveOzPZ12#t}^*_8@-^|%4R;(>k_ z-8;Nq6?Xrchq`BT(q57n$ zg2Sw+OA@;TRA2MZ5@O&DvQBt+CV#l`4(%{91$FOz-)_p@k+|&!oO;-G5&3(b%GY8K zRvxe+O>nZ*UlV~hE6r{jN_fZVsD9M95^B#uh{X`88q_ z55~B>NKP9&?Ti2uNSl5lePJF?7xtgY2@IdYu!a*5!h!t7Of&z?(Ham@R%og5RxKzl z9z;Nfe1*;S{c!6+RRdr_Elu@s1tgm&UiTr{24214q77`gP;9*i)`Q^~*D3uW?1MmM zFGj4Jiw+yCo(B$%W{i!I(Q7KH`6dxDF3ny-NWzNl5zxHkR=Ljk)BJ-2KLqNin1A26 ziX0ZhXCiqZ(!IcR5z!O(PYaVV2d?mZN9UBrb-|E107$bA_nI>j#8Z%wH<5QNj-{D-I-P z?T6|y)5%f?h9Id$t>nWw)Hv5CE*DOJy<5tWH<+g7{Eqz8Aw>k#LM}zKOUdJ2>aYI& z3FS{7I+aAN-8<4-uOcpXbLDV>`X`q`tNkj91ottV41_yc*pYL;WWfRP8bhjL(vwf5 z1adO6@9Q5M*{0r|L45*I`J?5)0>3koit$XHN%e{5B&JbqgNBRJF2*gdOeQOdn5@2! zLLo=P$XVw4?B!Puoz2%OU3WXPr`e#_v!UjeN9q7(pU0-2*AP(o;X%q|HtafZAwZ z&h8HvH9i~ir9Nq^{34>Ch_nSN-zPj?l-r1m?vMHxcaW3ZJ+|EsR2>*Bs-@8BDbt@k zckfT%OI1xs4~DtKWFiGA%J;}0HTiyU3rXcre{+o7`J8747Z2Wx%3r#Wd8lDam-4Dx zxMuUf^evF+P>_U#Y*?n{n>0{$a)m!KUC;Y;{s1YJ**HitIN-_Eb74EWUcGtg?s`YVZS@_bOY!Mvsn^o(#LGD8SU6Fc3O#e(Fb)g}=>kA= zV&~gvSUFAqzA_fU*0VU@#;i@{@uuco+K>8qe!aD(>F9x0pHFtD14^+#3to2wxJZT}m-*?HOx7{Uy=; zDXYwWpc{ud#RF-LTbK){Dvl%!tnGZi>tMMKDaQN=qsKHN-&+sT8as$2)qmf4Y~`LoM#9sK}^gti#Xn|UcCz3J3L{3aQDc_t-r3tTD^%7-995m zqDkFFQfZ(ywoS6&eDvXh`0mFKrH<1%kEbMcO(|`wtG&+o-o9VMOYF? zpXrr-92O$Ex7>Z9e$MGCxqtIbqYah?1#hX7bcXr2APSDV5xS~%p)jy|QSX2xDt_Jl0~*>*23c?i z(hhv=k(^q!)T(&yM^2h1+gM}$e<=Izc&z*WZEa~GBT>r83`zD5Ss7<`GRn@#$cT^< z5oM>0tdMLKB9)cXijpKzW|2xESOJ##)mc!I?8U%S zYj~pTnZW7(X|H^fePN|?OrmSoQUpeyzcg~f(=Wj8^qgHLts5CIn=nw=sw(M{P4U2V z<39R+Sl)0U&1s@{E<7mF?ci!{`;hpuvxpPa02 z8gstXG?bOV|7P@FuACb=$H9zzj5jWaiT_CF_gHmkKW?wRw;njbhQm!S4AK=fv;-s# zbZ8p^7s0|9FYSjLn&Ejn;m>r3?iC+DKL;CW-u3CxUD9WNyV4$Tp?x>LM)%3}9TYhx zD&(XSLuK~*Bll+IDkhGq#O>ZUDBgBQE%6n?=Sz6^%gLoi=*FsNE$G$hMO)A1*Z+`a zX4ny-a?9L+f?}ZH>R@CeN19WpYGHTjwSH1|Tt!UX-t*5&TZHE=66dXNI0kZ1u4Y!h z@-&|%AUkt+z0|fHq3HtAcQrt)q`jrN4`-h;O1L|p{3iSEtGg}9H1GSi#58%)9wpyR zZE4Z;Je<)HI~KH?G`wr~1|U-gc|Bk5-Q#Crr3fUE#lJ7ts1Da?9sbH?yz0(8Wu`}Y1+sK&w2@ZV&(L|Mu z;<<`d6kelWJ2GZe(sac6b<2$SdE>}d!bn~MEXEV3p4avHV|pF-^RFj6vKAcVb#-YT zP1L>}t4vkfFx0b)4e~>x88^I1%C-7#YRb*`DfLS1xh8D5d)iqjZvRZpt4L5fyf1r8 zyw?=%6}sYUCWDU$H6^ey0}gVGB}r7L1KyOxJ%b7a*1U$5uygv2#rijZ078do6i}xw zs9Y$dod0+xd}DBvse<(TbLH7PHm|qebC;B>#~ZhYvh8gZg??0n)zn^BXJ-LyikDQG znx3g_ZFIt>l0_N6t_LvI$xpZ<-G4R?*LPXg{u*{7ts{@pzDE@wU-k123I zX>KN&+;)fFQ8^&M+S(em+;`OE2~^>s&)eGx6Bp2l7M6dj|6aZ%JW~q!EC4XtY@K&< z7wL9xD4d;eE8wR8D)nw~jqVKS;2n2mIRp^q+0x<>Z?Wo(V9aHKNg~~IdV6k{11l>l z8wv4kG`cXRMvj?}%IDl#?G1TdDBycEtY}-p5GZ519D7$ z>enkkn!W9*Eem6bz&C{mr^(T>rzcx#J={Z{THp`7_~dc#tbCTfV{uCfCK#nrTunO{ zdadHsHd>QL-O@(btKN2daw~J#B#o44t(PeG)@O0>;67TyU*NXimG3p;nr)+>x9p8%XN&)4HrGl*c(`!Dl3z%v<>lbus8YU2 zs{83n)boaQG#wG<-#uZnK%qacniw9hO?s|I?uw57(YHB9|7T{sbbD!~hAtg?=@)76 zYM|g{N+=2sV-Z>Y=y52gV+R`?UM>W6{&yd1OZB z$Pa#O6XE2#AP)6+;SZVd7UX0$m_XZ!nAQ0A9sV6#?8nDA+~er43A?|+!CK)`BJx43 zw23TkUp(&rkfjmN#RGYmL7uL&D>h0|X~M&3i2-7LsT|0tV@9xL#k?(^Abzi!qV+Tc0xGW^E{KSlWb~A z*V5MZAaTnN!aWp4ODH#n`Zbl!zuD;yY?CnUJ9myJ)qRzV2;OXO_EoZ6cFSRQ>nV%G zMIjA3WrlcOUS14H$Ye4sGInl?mzX$e(9v_8bfd*WNHs=GVpB}iz3QFBK`0Nn36OL) zwQ)VOd8`^4y2)uj-9C4pZgd|AOq-b*5!Xu72~9T=a5~=fMOc%^=t02p5(pJEx=;{2 zwb;W}JW?ArA}DF9t10y?**x+gOg{&9KQ(cQ@|7vM>yvqPP~4VX)U)Pj(FE{sEajY^ zj~WO__f&K%=jt^=foa-?voq9Bij^Igk&cHsyahl#aww{RRZ6`;JwNhIICeUK> z8117}%sLc`b&>GvQB1S2EP>e3Ae!gW1N{fd9~}3O*L-!o!%|g^!D&x-bU9t0fh3_h)E}YOO&LA3bBpgO+$|0cym_WbYiaXjj#$9;xgEVj zckM1hb13GUjJ-{YAl2}vz7o?{ylyO9 ztPR$-wi*XHv7bh$98=gX!9zzCp~4>g4Ff&nO#$68CM6qXbiGimQpBl=AGmfe(l^^A zX6u=FMbBOfA^107>rqQs!^gcT<-~@#n}+yLm?ASpG%W8vG#bXUe0^|EVAI%;UsW33 zL4J1RU4qBF{t*RgM=IW)pHZTW(qL^rwXirK{MtFgFZ@8yw00J$X?-Mw0mwxt?Cw2M z$1+(Iz95d@4h(z3bg+J5+|=Tu|2;m;CRN!Y>-=2 zCHh+b)Q=w^H#j{^bu|?l>Ct}2!{VRNEE^(5hd0tWuQvM`pTu`(g7i$Cz*)(vHv3{D z{6@Y3A9F~tH`62CFxTCuYxibdR68Jy4TAKBg~8d;_L|l`@&jlU@k^gSGSzN|3n0+?-D-64DginH~`!~JQ}6N{0Z(IH{Bz`KexCp z*T3R#9L}3*oLI54{{1uFD8K80rvv1_iJe2*~xV`Y>pxEHi zwOB}%$T`An*7|px7{(p;QU=Y_RHMsXSnz{N7||%7Ds$*+Pp(m55I(gWw?1I&qc*W(KoGCyVg1i*%lkERj?34 zZlqbEm)u2S_q=UFt6U5>F#O`dgU9l^N`llyV^1y*i)&G@RiJ)@gR(fb|HBxT5BaY2 zs&2EQE{FXZJ-qnMC)HQ=t)owLGLagH_c=G<`X@Aj9Pe@YBECYDiNyb{y!MiajcMhD zo{ZQmR$gc~|NiseKQ4bivu^L-Zv^@#LKX=lH$7hZ|Ni(H?eYGa8k77H@vU9NzP0i? zqdgCN3y5wD(){bc;^d8Kn7fEcjS2=SA~!pop03=J867uLQ?H2;i}A`UhpbE%*$K*4kx9P5 zpyw;l8S-C$^1$>61=ISAt6cs>!{cXl^smIOTfhGI{QNQ3@I$(E>lC>A?s%t+iboEL z-#6JsLlXrzh2-Q2uYYWqU`6|b-5P(ND#n;dN+oTT%0@q<&NekW+dV$5l0oBwZT-*d zdWhH0ZB$h09~n*!)9zzu3$JHWZiHLrcfy(L&&$U<6P^+YhrYrME~}v2LzPasv`16P z;r`q1ZpMGT6PKZR3Byu4cf6r!B3L~7AiR{m>$tRW`)=Qf1D=oIy$1uM$$OBM?c>=$ zi4YFhu03t~`)A~z^JGMe@vyV$((%~|8mH{^ji*Jl1xOk%cQFzJdAfakf^gb?3%wlN zvoY<6_(43z#OpR&tc!G*V1JVE-RG)I&HRtpBL|0;;pacJ%aBRT*;IMrK`UW(`{(6z z=2tM6cEpFc-*Fm`o;ttEGt@@zlUWv20(vah@_3K@eSRpO9~?#24|t(YMl1I5U4+SPt0(E@HcL5?Bh*0>uSYko%Vcc)x0ohB^&A&%A#8GA z7J-S;QO51t4=bTXMz<&+YBag=BxV;U!1Tdbq_E4u2X|CkZ?BZ7s3&OZfCYQgutB!ogG)byk3eESlZYsJhMEF6VcqnT4^4wS_0gu?(N#aHaW9s{18AV|Gq zhxoalcAkU{7v?XqyFbIC7$ehIb!$ND&=NvUAdz(M?*ZyPjS|MKCrvV#vlNbwQ{yE@ zjf5u{#_B1fh@q}Rpuk)-tPC-^Qi?^c(aoE8b-2l{Z`B!@6UANGd<$?eO|@1^9+>bG z2DccQvNRrrBpJMMapRTy{K+BAN+VP<4Dy=ubk_62s}j4FO2z>>Io2a<0w=#$2=cum zrtJ~96W)#NwbyFFSdYZnFb(3-VI{=afB#rNb8Ts+*991u5+j4;;%{v38?(c z(|CMemAS?KuqyMjT)$95>g)mUEIWZc#~$0Y4D-(^I#=BYxY*65<1f9HS-u`(EWB=i zOlQ!g`@-BNum5z!XHZ-#-vBuQaQU~fwFL%`MS56^`VvMun7yThLRBLL1A5P85Dkqs z4uNat^jOQ0ic5o0JjO-IAGd3KA2{K!akrgt8A8;gl;1BQtz-Cr<;Rt3e$&0Zyv;>p z)fVxrgqQW9;&G8x>~D(HXih8yc3B*iQb@F|YdPlU%o52ZFVX369~~7u2J;PAV$@In zI_A1gO&y1|? z42D}|$Vv(DZr#$B92ISWl1*Q~7)ViO%Q4ZLC|hj^n~+yhR7k&3b}f3=y&}Oia1}Z7 z=pGfNeK8wztvtlYH*!v%3&W>~P&urGOA1?|fBtSbd~39|kr&)$CxRObMw9Y7I@IH< zgWbS} z+`bcrAJnHNmM%_lW^MVb$f-)TL+VOxlI z#Mj{|cyi4DR{hq2@vTGEwaa;TJap}h8#+uZ*p+YPDrQ7^EHYNkZ8F|0{)<9yYVs>d zb$GX0w;PwHfm6CYKS@PtXT*9`3eVmp44a)-Q-TTnn!H{N?rCTc{GGVJ0zx4vXB~%( zH^_mTKY}&KDE;QqYBLw19iiMD9G4EM6Ki{Uv%~c0IVT;FfGa|ea0}(CK&vnU)j{8Yh}-4 z(ZZ;C4rV(a))IYP*dpghf2gG2vxiAfe1c=|tun?8hu=_a6On>+bl-OWZ)~=a4A_X{&L;vP1Rbqve&x<-cR58?^QgU%&2SCNlhJyWMv8LSvUj zp2a<&J9ga*-5faS5E^z@uZiQmuCb|C#*1#&$>)Eno?kSMHZ(Ka-Tb98 z1FI(tM0xk@kx$d{gJ}+ub;090`IJA4h{)1%F67(Xa z*qb+hLt{$hqOde#$gzyT4ygDwH8k>YWU8u^?aPdhO^Ym6tt<>V*Du`m;^_W(R(8UF z){v(2+9vzPimJR=uegIYQ73$Yq_1hdx=bm1h`Wf1^&BhfxzyS%CI!yLUz^#((n+~> zf`=&-n}(`qPk!&%Am5dn5itDoDF1xcW$X>a2bh?_-uYo^24t=Zgx19W+PRB(wN7el zYMMf332}%+4H>js!UP7a^~k}`(Dvh4afqd=Of>oCHSaBis=(lvZa;nB}+Vlr9JQwF>zLgN*DHccRy>+|V#Fbhv+$^RWKf2S_^5wBFR| zf}I+~eyn}em$3Ot9PjYo1X{)}4BfGTf;~F`Tl``p&}2ZOL=tFb zSkH~^MT?m->hxKJP%@wrB8$ug7uH1r7lva%YGA(jd5;cM0F+36xus%wRnKfUIYoBDoSl}=T( z89t~FLF&3X7X<-3TZ*#ssL=p1Oh&3KIdOUp}0mESKr_~TFkM3+5neic7g19idw&a;Q|u zS5;2o+chJh?yzry>Q`z zqxg~f0|4|+%B8z6Kg=RX1@}?uqQ0H%DJ7)AMpbB$UH3zCj>H>&4k#LF%BDc{LQr3kyc%)S- z+q6Dx`glb(-vGu{rHLPy#k-BcNCl zeug;p;km|lFp$S!Is*CRVPT!0JPh&x=NM_w{9tXaI>39aVwJeX?c2 zVQ{*zoPPez_z@&PB3nwAuK7(Mzp$_y81pQR@Vd~xC3>_n;Azmhd#9;;j_Q(N#{&0% z6eC!2;|Fc`pt}x#VEDYQm(qnO(Y>EoC1lV&ZCzSUoszX4S^fRFCd0lTGc#K^ZnS*L z4>VDISF;BGD!dlhYDTfLwUWtfZ1+uCf5#$d-p0lTQnr*)ecrgzT^8qCrK{GLLMOuN z_w9m7e!STGE#NtA+-MK(ATBA9Vh%K|l&D;r$>2G5Cst$J zi6SSAbxED&Bn~a`^O4PtckRR%9$02gJQ#klS(Ym@6w^JZ?lY;5l=hfDE`8o}sfnMV z8RkX}&bXsQ&&MdHA;hB@gNNqkQx}A6p9(+lo!WfpnlQ+!P`MXnSc&X}qhx1iC-DV( z`g$`%^k2l?33rU|Ak6B2UoGonHkKXMkRt8#u&-r^l-GrU=B6!MIN9>LZlV(f-4ZK-tAe3k>wZ}#62#8t8_dJV0Ox#THg{j`-h~4%b&d&c0*eoXcHYIO5csqrXb_ns z((N$vdbW?mfRpu%ggXId>GH}or%69sKV$$1kURG?r!^UVNbVyomWdAl0$3fqew&8* zi43QZ(Dg=L;-GSDF5Ou2JF`7xoAJlj->*C(9#}}+bzILS0HmDlD^lY=xHE#J$kgP( zoAurbEd;*<=7LH3##BK%G>=R?p;2a=x2(eK2aQYTou&@3-4JEgFzEx3WD=G9f(6L-UMgVO(`DdyW?>?e)5v-&WZg*oeq-}I% z;aarX-@mlTaXOpuYMYaR!PapU2NG2NFd>ao&(h(BH9h7wW_z|o*cP;Xve zOK8-f;3=9qP=T`T00c;mqO+e~atR5Y0l5=K7?`4 zOO5zZm%HxRit#ftBR=@NceoEqWA|B$S7+rF^132fC-qF8?65(VCS(Q@hC z@^42tZG{kY87fGe_tNw9u*jPn9p$2YhCkcPcYYfM zrNj!-t)uc<@}fi`_56D)C61)UphXJsQ!Ni&MKKseDs!YI1K09joHop!cf4OR(nGq# z-00eNmtW>!zUJF35SS&8j{v3K3G)fy00rhXlS}bZXFr$W@r8@L3b|s?=Y4PQJj(vJ zD5=U@||%sem@4V&Z)0*8tNJN1X!o(N2uVGe#cS)8w*KENfQ${Q0^vF(8we^ z5rHBT4KR;W6>WzF>B2YMTaZAM97fRtB$n_$AVdvE-PLx-+}Nkzm%FkW?;SGP~SX*^#8z)&G?L=?QQ>xIGmW zSJOZn@-^znZu>Vyd&h^(b#%U==2!#=b?dSl#@#fZrhns(#g@Vb2x3?jhCoD122U@3 zUH@?ePzeD7<&7lRIf!XN4TV#KIwg`>tiYo&4S2j`A_5P1eM+V2v{vicPvo(z%wYmQ zAI|AWF#N=_aEXshEto$X3c8veg}&>{${vnbNZlT(a>sxL7{3GFJgEr@jn6E%2Dp@# z316d%&o?SgN6jMLK0iP2f?@#b6M(I5$+Y*uq{rh*LlYi5JTwI7u|}K3b(H` z0y`hBgN72$6^qQdGR3r<;|~S+iep`b`1!AL(t}-)Bzyj*I`iTjL{~;PhPMk<|-Z%^PX;PlLl zU#B@92{pn^iY~Z;uF1?8VO;}*e~Z|)Xi~1<_j<;jFPAP|f*we%z7G;&!c(ww{|U`u zwaCt4BT8`SvCuJ9*HTRKpfAmSNkem2kf?U=Zj*gbQ_Jf9EbFZFM!k1jq3UKvN}LtD z`LfpOx`vcU$?lq`=iT2hbxb@GT*<8gx6hfZvcWj~)lCdAm$HQ3N!E}NuK3>bRMQA5(_c`upqb96?zQpWXk)fgTrSK|LZ0#6%OMwK2G~+VmQA=)ZtZVvKtuO z?DQL`kvaR!Yy`TVEFmV}3rIw=5Uj#Z$8TW7DUB^0*ZVUJN>48L1CKW~A=J)#((5*C zin)@0BR<{}F+6adsNeDlew6IDj+s!M_>MV!7(s)Bt zI9RxBz@-R%QkH&_ayiNru7E@!So64}%zeKcN*RSIDij7z4jNx?T)kYWfi_N^NBN(% zdGF;i-L0 z41}kRN@$%z&XB-}7vXV-jUWK7kEk;FS#)qK{jog}7Zh}XTNiGXig1e5+WsFXON#B~ zv`gNwNEwiZo;5&PGx1g0ZAg`9NOPdm0<3)dqY<-IH2xv(HWa#O0{5Pn@qNP}C!icE zT=W$I>VxfoRu_*SBfrs;Npt+UcNOjuk%TS+V-k*l4}=yn}rZ?7OLK9RAb`JX0c_CJ<;b^-#h*= zQh98Ml@hK`kj|W$$yO+#;Fh?#rAi zRZ(lZnf9|IEleSspk7U>x5{$A7D_0dJ412K;UDi2fSIKchZs`k{k61bOTE#P*J=?4 zP?Dw^RG*8Dww3zMI(rVnfGex_cu~}eA>Es0uX>_-D%`pHQdEcMhJBrvo^9T?jm!Ak zY3%b;S3CZso&{ESqBem6P+0E*LeMOTF>khZ4X){e9RxySSP)J4iQj)BP?(Jk#VEbw&1PDC^K|L$x7GbZE2tq9T#AqS(_zT4TXzl%$@j|TUnns!<{&c0y}iXx4}H}-{yivY_56`6buphA9dzGs zw)o6(^rh^0KGmHRyxzzr{xIqqIFQWEKmQ%%$}&qx0OPKaLLJZqbAC>9zBqjgib~XG zXRFV_0VQkH1hp$FT<;VYa1P6``B|h1TzvY3cFWjI{BhKQoPG8?5sR~Gs^wsNdFhQR zx>?k>xKyIqlY}8REv>`rdrh6|#T?%ZwAny!JYBb48;Rjy${{R$PLpzLvA-u;rw={? zUmL$p3_8R8lq3=n3TTZ8;KmaU1mT-B>iP}UW;Mu)oUhTbWmcNzq|jfyC&ET8 ziXZZGBQYKaG0tsOE{LTrcouXKR$@+Gg783VGXLeN&UIZLCo z7*es}^zBaCe?2M5G`%}6HMIr{3ADzlT&Qehc8M~1EXr1T)2Z1~=+T#SLU(x~i6FuR zI_%B7UnQ0}G5c}VOn^ZKQUUlTyKf#E^1K zq>vUE$yNMy>mcCs?gL6q(4TYN4<@&^R%3w%XG#$6s%S}DT3P^<@zGI*xQy+(C_Hr^ zYyjgTLJJJ2!L7Wib}Z#5|CEVViALzDud#kALE!oQb?rD4FOSDb$}=oQ!o6Y9Q6WSy zCQA~zA~?EY{~wNOo;3?KqTdU_gs7f=eOyTdB}+U#w|;YhQE?EW}HQjgq_zbw5cx*4O#u6`rHkEvTy3W6~EO!ld$r#ixacF ze93v`D!<@$5Sx%s6D1X~aALDQJr(o|@HGc1%{b%xU0R-ukDH}L*I8k{-*+VzKED@( z2{;Sq2HvbXowT%K$#MaYv2U?7qI)G*s>@1o&}#<@VRvIA0tGQB zBSJ?#m(iG!e+e6J4j!EYtQJ4Eg}`62-{p{UjXarG`e3$nuuxG)zW6enGm$@s@9%26 zFtBk;zUe|%!th~vNxCTxg_6?`wY|usGDARil``9+Oxr|{=Naq|R-~b(9uTteY6Rt* zy$D`p3_OsJR-Dgl>9LKhwG*bezCmPIc807pN@l zLCV98zF+VV_)=>{fNeEzn$6Dpd>c^kyr{fk0(#3=)2}n1hb~50YS)Enf11ga=;>{| z6KMCgjEAthNR{!8SMN2wYIodg79TlK!D0d-Tm3#1mNVIM}+4*V%`qTaO z2MUwS&pX?=#fcNbu_SX+x%tJcTk$pa zRZ&T4Ee$AMKBYx?g1<`($|VoIedxl56t2_}Pbe9B+<1iNjXrVH zz-d;i@*woy=igom4(`dkb|w86wxv>bn^I$rI~00*0SL_(j;@KsR2mbE#(BwtgS4e> zG-$01c;o1I>0wDCEH+RXm!Ot}DUg(wl=l(-51_^uQ%9GoCUSv6c~=-FXtV1#?~wQ8 z5k*aIqgw-@`PD1EdBdF;vTeV0UHBDRJeQZ(?4gw>b^qvGq)RB|3`>6%1X%17l5wMU zgOGt+A}Xa%na%=Vo8pF8=|uUNy}nJ*-|$+-9Ix@0p>KD4@?c-z6wPiL@Ox?HKo1Wqm!1FA5ba1v9ExaBZ5Jm zvl51qm{))=)m?n*2*YBo4Hm6>lTU=U??;=|R81@3sK;o2CNv*xytZ4;KdE&SEUx;9 zY}6$p^wi8}nEt_QC`nFevp!wG^X142BLtj|(XU?xW$2ih31sw`{rCecGlcCG@1*g* z0I0nPhmRtkE-awMpE`-@jQ-BwD~wUS^Rl{Id*|GJ42@cXc=HSnpo#^SfLd5@_XjTC z$ci^dL^4%0g7$bTQak9g9s#0kHM-#{vKZ z?G));QZA4b0ZK)!l^3kWP*h?P5Dy*+{unHuFtWUF;v{;@Ck56O)`hTY02v3y?EinO z20$gY$LB#gLVGNWxdmLK;FdKuHU?tVC!WVZdFN%Vl|c28h534iosm=#?x>`sBsU)^ zSX`H&xlS2nh7KPn8Ps3s@z5UPrB*>iNtAB8K+}PhkBC)3dgp^>+-aV_=nmSnys7dj z#n=nS0HhQC?4-5~}W0JC157<`Ylc8S3Z?jE7MS#i(gMm=YO{TDNXJ=)BRNrj= z$gB`)JZY_ASl6v846%Yaq4bA!E?it;=MgJ;6IB6VZm*!4pgsJ^b%pj`i4Gtiz+`;R zcU3+RoJj(r8l4MRUQ9TvWdxnhXVhU8NAv*BiSAaCFW=kuZ@vr&g=Xf2q23* zELKxB*}0Ep#9r>mmi4veu7jEV0N4u9(GorjKs5^s1$%pO0RWjcJhooKYWpS}^%hnH zXF!ptAKo0s6K(xTm!%Ov5uEEq0UG5SYzh(rz~)7Ac^8|IAm+c|jY0L&r~lh)L8DYs z(vK1NDlDCKBL@Lq_LkWL_}nW~hC{~|BkB7d*5K+7lj}3pKfwPI7>sbU1SkPeVZ-hJ zFyQt~p`O3c_Bd3Zq;vM*p&=33l_8f2)JeXp!{P$`{P$2O0{4$sR0iH`vt3+oMD4hJ z03$JY!p}Cf$)}96v#|{o%CNJ;BkFyToE%!UZXgGx?rIqwuYmBP3LxEpv*uYmFOwU7 zVkam;wfpHJyw`5ShYd3m>Gu9|*V>+WK6{Kb(Y{J~j6GSxJ=go@jS-I~A)LXKGUkM% z;oaXLDBuw}4GtngY`DWP4X|2uKKA(PfB42{W8*pcCF(6&CR)eHqXEg~d*WwP^%y zl7>qOq<^CKq2T`gAaR7HW7qM_hX@WyxvzR83eX#GE) z2|Go&IXQbUDzEVY4hzq+@CC_5~rz`M`8>4R0sdAJWmA`6v`1#>v^b0!h=o>fegcIK=9Ne)P ze2}CAry5|UYT@o?P`ZN*ND%%N!DuP*co!KWfCo4rWE!hcx>EM+Qqp-@qUqIi|`7;Drb55q=Sq_$1eluGeuobMKbCpP%&U^cAYMzKd$N z`P>E)fQm)m2rH6C8ZqaU#MmZmcm=CF6s50fYoHGRceS58A~r%(?SO zRB!Kp>=kVL$WM;mi?~^TlBe;N;x}&ftmqS5=rn#!)D<1QiF%$wzo!|FT1h#L0Ac-h_-X(S_ zWL_<$i{dAZNa%-Usopz7W0zN1xCsl}vymQE8V3fqzo7*F zP;`BRmBT>cMvA)%s>y#S|4#%>I~+dJJ*ch}55Bzkn||$uQJ?NpWZ`|TdM{iY?Uxj) z8oQd%-l8GM3;=IJR6>F&Bujl^;%Ccs#5MoJ!+*F~N3WTwvpq_cV^NRZ=a8Xv-tAgr zcA4eQ0;wqBpZo8+!Tt1#D)m~lsAwbbnFem$7Kpz7_z0!=-)BxotL72EidGi8&kfDj zRV^orbM)@s{Wadak2E60ec3!tzYT!Nm5UUY0@?QtP!6QM=wQW}xc&K{b??$UwvXqR zZs{j0-<${t)Q@xI53dgbpVMbX&z0h@TEm+%?TNxIt`5GdrYdA36TD>ung6PF0Qajvlts&u4Avd|x?v>@uizw*_eb>L+gZvRgmcxQ#_{ zy~rLmg46W;+Gw5*ZAjTe7^Q};Rd~{F80e7r@sh`Wzw%Z8tSLGvZUVgze%Q*Dt=>t1 zq*nIEDNH=>GpBXZbf}~1Pg+LFqLms#Qc&ZPuA!T1PUZTyZ!Dt(&Qi!{ou}NhB6!f5 z4$v7sQDQ&3+#m5oW#Y0~VfM#2$Ml~nQaH9ZsX^>l{+Wh{f}4jy{^^`~#2TlGv&ESv z6gijHYJzxjQ2Y*Y+v%rtaac7=3GaPzjCB{7A|4mkd#mb^ZNi!_$gVJwfNmqXFlL;+2&!s@uUpird;NB>dPL*Zp36STt2S z;+auYbbXN`uWW4?shtheU3+-G!X^KO%VQ zK=qiYC|%h0=;>Y3Jb~j2{2bUWtuV5h^hwD$RhW(R%D?X1htq8i-2D7Mzkjz>;0~|< z*i@)YrsL!0-UIQ%E*0{N%*|sdf={6J0^fNL+X`zfsf*{Zz8WXU&S85;Mn+1A^Jw)) z52UyK_0Ywz8)grDc26xtKHO!kY0}o5qYQ)y|jq(1g zG|kTjll+<>A7FjhV|+1S!DBVWo^35W*kk_k)eBw$6bCH?lMU>;_^L@h!`JP^sXy-g zrL1DG#=@xjsfVvgqI%ZUlfHx*va%x6rf+`)ze69QN|5#V{JAQ_o>SEv`?EL?wE?aL zunpFhcizg!M}F-)75wUA zYydkPwV?O8Z<0m&l)lMmIHc*#6YauoTWf0?I2)*s4R6CQ>aBApqYKI@uQ&2(m2!re zVgS|LOq!$*HYG1$I~Xp*+kLCS|M1XE3YH=)AFYZ{wSS8m@ zLM{s$OHyJY&z5yf)`AD|8x&J5s)?xZ_$p%=wc?wN)GOn2Fg!Vb{`+P9gDC638AqoJ zCnVZP9E5zuz&+*H8q6%RnO8q^yz;#q3eMoUbCUEA*XLcbNDSvWBoTS)OOujr%k$?J z63(I->OQxR=IXtl-YAL55uw5qVE$H0qv>NE({w}*M42?5Gc2PcbgO`udINLI@g@%u z(OAj%5lh99mA?ytB;;5gaS2|ZbUqdc6a%N8nV%4RJ=QSSO&>lVySO}1*kpJ5y}rPg z>m7FZqm-ZrT;L6_cQYgl`J^N?cmw^v*LxZM1Wfwsht~xwULZWE0)6AT!`fC;4j;-D zGdDYC7C$H};d_6=IEUi^W4=Kz5F4oi5^8s!v$hpVPB^(`e|UQjocnfWmmK6XxMy)b zIAQ;BgZM9wsO`Y%18A{fD-TX5d(v{)#51d_>8*Mlg|Y-S6px&$@rk(p0rOlvb;}Zf zKJZasgD%mgG%=~${$uMp=q`tPbd<9Oz75|vW*~4jVMv-ZVxyIK^ocWk(7jBp!K@wL zefeH_x#d|nHP-z6(mM;VSsd^}qy#RCPbB-xYsQ66XBTd$H{MTHlhsv<1?MFyN}=AB z&tRQ#x?m3}V3~Z%9o|@wraPW4sI`iFslNpum-0M0r=)X;JewvZYlz_lDm+uc9{9P| z8RNURDRT4bT?oy=+94s4s4vA!M}@wQHG;Q){NcTi(bge}gtL`Z z{Lv9f&4wRUrSdD{9Q_VeW6sPbwO%zXEt`vXk)cpww1mmDWkvxIMz?p{=Tu%?+SE!& zGbf*kv2T3Vv~J3G$Z%c$_ui(YMrSbhaQf2I*?6vELFD}y9;f^$)4~*wyPKQv_|b;P z8Js< z|4%7ZnQi;-x#t84aH0WKIx$(Bs|4E=adZU5iCOUT^lwzyXb6B}y12n8y^2T0H|04I%Ia0H$Y=pgV@t8_eu}K%j3EUXoM! zpz6nr8ccz+NMP)8_|lb^;MX*o;#+VmTMMfaAe0q9GgPEr_;8TQe^?L32SI)8mEP5> z`q#M&4KA{WS*}@n2D{tcjl(C3cc*6CE!wzFOvuHb zV*P&Q;q)wK0hm5vC3Sq@`dU2o{+wW86)f5~zY530#U?S)zP|#z+4+_da^h`bm6bQ% zKdNy1sRwqo1>xzF*iN*-YIQ%Ba%75ju_TB=j+>@d(0Lh zF%;*C@KU_B{R;VE5kg)8j6~lHFa>~wM~c9*{DAt;jDv0kRsb6Zhmx%HmaSX;A)l-C z`nm*LS|TjugZp>sWt_CjmoL9{c?l8^cpu>r5pjjX2n@nyfU?5}56{%##Du($uPe(>^u>Xc*Xr!G%pTZ97TY=9FRxq_cEt8O&$76wICeA8(f90m z0=Cwc&2&`3dnhq4eYMNpC*@&cQ3>jHF0Mx7v@E?iHJGU2o%ClvHO z;jcf)V0oud)G}t`p$l_)Ja-4g52aV2|E7$7DGc^R=iW#7b|Jt@SD=r`967~iQf8-X zVX=n>DUk#fWNIB~N2$J(692n;4^SF(Yhcy90|E*rN5yg#sL(ZC4p{C!gLv>p20qp> z%XEAkgyji>lKp~$+9pfz!L%HvM~PD#vMpgZ=+YQ}yL6GvG^#H+ zrM7X}@jyr|SG5_)J-55Z`)X$3rm>%4)7zC>kuU?v_)SXFfh+-Mx~ME5mo+gH0EJ|7 zWGHSS;Cji{(+SoiM?=`_aS$b=Zyz|_lGapF0)$EA2_HX=*(zuLV?RtbGliI#2elG~ zgM*)Vr?7(e(nNI-Q$Ix|l?+$(?^BfMPZNbKS3d$0Bnc|zgMb3;abQgKbcZ02C$#U) z zv#v|836_~WnTMFAJ1vUu~wFWg0 zpE;-qmSTjExP-6TN({TV$>c#USYVv6zV8#t>9aBDNzh4AILnHO>Yg()+7ZXWk&u`; zhZ*}@X#D?!3*@? zOE}CcaZo-3GvsUeQ<9P;OF?><0|1B6G2Cix238x7Ic_~Wn>q;-;jLe5&U{#%ckSEk zY{DegnG6pF~Mvsc-33H=ccX22ThSX5IkvuH>MMuO) z;H_+k=ksc`IB90~9=}%apbR5vw*KcJ)tb^d51c!M$FJ|pdEyiknsPVwrMu-95=8XV ztFG(tHdLL;$+?=Law~xvF_lXUFdY55v=_u?&j*@lFCXn)_Z%b3zIPj5PxmiAJcW%P z$VXb)L0m*kU|9iY0skXdLG~L=UCv;i!?!IVuxM4me4B9|MuvZ0FRF!Rtt(zhLpO^x zIpp$Xco`EgEVygXft+2{+}0Ku9`0^?k+W~RkJRxB>=3-pVeKPzR=Zj!mNk?xUp%wS z1TM;liMF%Xl?yzrbO_qAdV(tx?Hc18sQHPbLe(3?QY<4CJZR}vV zmvmLVPLd$q>x^B8OuyZS9AbeG=5>K&a}z${w|ZS4L=jKXTS|4ge@<1PH1Y%1tYJVI zwB}ZK6&7wKNF}-0XxDzgdS5eGiHB>biXqzRdeVkC$4ERtwsWR{LGS7Aj63$Wk4Qu~ za{;Swu{FpKnNr4*%q#4`xHAi2;D{4rxkotjSyK8*PQ2VuM15z|udaDh;N=HLimClp!?C#(xO>%<6!aioal-Pz`Iho{wJF=RKcAigJ|enj zQ_rh?9+$K4v_*40Do2?K)6LSpKMmGAo34^6CMe?&7gdjKLxN z+tc5MR5Njeoq;NibpTV0@|ra`MO!v+Mudk+k;R1te`KYDV0pFArd^{gBZl}U6%}uX ziqU)4M%SqXq7Nos^0VHl!nT=@mmGx~vDC!(o;^|n-X3^2P;_~xEW@V{7Zfjy5s-om zeGy_SZi=}$*SrRx+v@g$u)^BD-5snMEZWX*%dUiCGbALnfWF;Xhq7Id=PnXm z=B{^1PD*sjb1I=@Ab_H#;7A8$BU!@zPEpYrTw`3j26MtGiFoO7j>hV1oe~yHtydPY z1!J=F3mA*Fi`n39-05+=^O0vCuzXNN&u5VmV`GQ-4}8ISL+Y)S+HsukFC7_;m@8)< zf&ch2xLL(5!h(W@xRX5*M*wzfaMM}^Z%k+3IhG0TiU+A^7;YRajqq560F-0W^5nhZ>JI-OEin4wVcGfH9=bwWfK0-F4%l%lX zRJ>oKn3wmkxzFovbLvZ|AR89xL0*{0EkWec7ZvFc79@UHopG>`Gj#fkYBmw$=(FPn z)n@JSQEWPT(z`~1Gvn4a)X{k|Lw2|iGYk+9e_(hKW9wGo0xSY?hE8Zcdv8OJ79Q@4 z4s#_@tr8v@3U=0Iw*)>kbvJXD|~2@4>vOC zc^VtPg4#IniUi^>k()c(XE9=*L z{qa(`&0!a(tPA0^-bmen`Ai{(Q=nV5x3tvx+MFV)THbc31I}Q@7vJ;G8|cU=DJhlL zoI!Pwo2r`q5*;haXE?T)i9;gt7&o2X`-32tsn3;C^hI7I$T{W|FVP(nRSoqB70mFA_!x4aIO@8=3sf zUUwtc-(~fQjE1opGi+lCWFknBa83YzX;IykJCw!>N0thEEalc;166V!|NRw;yT71# z#=sRulW1|`DGdayZ-Cv;#|NYMfqb@0Eq_0CPM8uo6gpcx&~v|k!-@bl8l+rxU1prp z9!Jrg5x_62nP>ZkFF%iEwWRDul`P()2|2X6P%bH1EzXR#u7-~ljdb9lJ2g(y% zSsH)Wu>aQ)=#m9f8d6#Qc}=>ebF;y}{h!xB%_ZS~J0FEA=_>u#2x)BhwI~{zk$)Yo z$=?0{%xj1@pxzm#&+%V#qrmHTRsLYYEAReqx1zBXTI0%>KmU3&iXMG) zURVCV-x@IU#EOn~QE`Q3y7J}gjb7Az9f$w*2Dcqd7>(0I{%yGQ@5A|@89Dvi_letF zon^&$X`2Z0q0yfe^Z)U$+dwW4*3oAkqo7Qlm&t{yK7r6iw8sho%Mlc{7?r@0u z_Fig|cH8DObF6avQ-a_3_eVi=2@VNyXGw^O!PCBq!RHeymUYTz(xSHq`A;<0?v?*v z)V*mumTUX|-C7N+Q6V&Fk`x(AlA@GUh>{ReB11|_gQ9s_p=dIsLM4?@hE$RUB^hE# zLh~dkBtoU(`5so?_wV=O`G5YO=iPI?Su4Zkyw3AD_G90-ecQHO0Ia_rSw#%cPxVxL zdwW`rInMVMX@NuO6=LtPGwuq5^yITa7W1R@pZzEjbAuD+hWRnsP#o5vm3nk-Ehg@K zrSqWYZa@5WHt^@ZTqeb@CH`Y;5Z58qMind&9{v>yC*}Ee2yB>I(37Fz)^P-rUR`OB zKcyt3Bh&?GBA(el_k>dB-qpL|qo&#It^yw8fI-I73XC9DQ&$&c0Lo?ULS6C;EI;B( z#&Kds%qXRK&y!>px#YeZ3v@t0h_4YfDRv3sa$wqk5oCLzbsjRrg(D5m`F@k#O2Up0 z!%T;9_9{wBw;w&4+jB}>3HfP>AZ1o`VycYgE+>b$L) zXBy0}zxbo1o6(jkju)~a31-x^ea{av)&R)CeE?ixs&|M-V%;FJ@ZLnZ0*MC% z(%{&l2l3@3PSqEiM1l*n_juU3B4;0$nNb{S;|L}PEN*eIN`3IyQwg-2wHl5B@o0++ko62X8 z=JYh&77Clgw(|j0PE?b|w$oKY-jRV5IKI3Y1IF z0WUF+;HJY!4uvxs;R4B7c9)367rXow_Rl>LN=;BGd3snbXAG9Cbeh+zzso(WV=a+G z&^eP>dCq3>!IWMBdfRpjTu3PI6wWwYn-DkG`Dd^wHNeQqYGBAAcCrlE$6Y!rU^z{Cj>o~_!(|`l3dPYq~^c6X3$n%P}{zO$$^LHZ~CDpll?b}*qY`VF?3rc#L$v4 zD}c8qb6@FrqLx5Wje!LOV)!<4>QD<9ZD7ZoZpd6$NwxbsiP@4$CD~m6f8Hqp#@oIMMMYbENLn>Z6+$ zYQkX(3YkAPIDgQUOlsqhcEpwOP@kHblmtnEIPvuI>RkcT&G4STqi_P0mVwacdzuE% zNTK=RU10Qw*$#t`wyLJvcZxl7-f!)Mi}b5ae`V;k@bEO;?B*o-wy2(W6`l zY;X@|eZRUw7IVpsg?jMG-oJcp&d6nZsYY6KvQgbFynxnxu5*kXNp}h$|1!$oyH00~ z%Z-IK*_i93g53YRP`j7EFtD@hleR|kBT6r`{A2$J%OOQ|PLC?cCe zfS{ZAUQ-H_&@QrW=;b!@@plA2tbDDzpd|xFPc_hFfI$yhvjMMgrk%{a|vj8_^pT+xO{Sg<1v=NI0 z&#T7)Yc(9Mexu8C;lntgF?>0i#U-(4vt{n$QC$~~5fl<#3d8b1J{I)6yDvO--kaa{ z1oSNO(0-(cw}21+SC==u>#)E5)S4w8G0GBT(X-Xv`ow*Ft)tF7YsVj@aGtU~6a5EP zKMP`YJdAhJ>SOy+sj#7AC?z=Nu3smy@iuF|WAsz7rWh-BdHfM)+@$X0L9tFkFSh_Zn*M-vv7)Cus3*N35lhyXB`lohc4@w=4*m&ic&~(g=wnY6Dl(fkEExL zeFAU4aFSR!-=gIwM8Ex6r)-qwtY3fS<`e4cqc=w;T2AnJd8^aTmL76)*`9Z9`f$cM z-N+_Q{?hJGfF9L45}$?or`)RGvqgD!m)t*3XHU&;@Z}F(*UhugjOZ46D7&fmCOUHD zBGxf+8j`PgPMgzbm^dleO7+fBP^KL^@Hhz4xq0aiTpw5XcFx-`v6=>TnTqx(qkA<) zLu7k&VpPYg4h+QMNbQd@L4m1_v_f`cb(^0LVXYqE2$Kp$2o=ars+gl$v`AjN z^kVj(LE0ytI@bp#`Y8%F`jYA?V|Ah6=6Kz?5cXqEB=8iI6VeX) zC->Z0Fy--HjbpChO<DmLX9h+y0MUHL6&QCe~QQpE#oz#P1M(6z_ zt;XqUqZFZ&x%yNK$u)PSW=!7S{YWv8tqeLIwehR*ge&j=jRxiaWZOD)$N_A5mbTJO z!53~JvUoH%i97rIo@R$uKYYc*w@vQ`u*5;z^sZ!A&3X(O!N_7doK<{3k#wP}!nn0{ z&-tyK1j@7hzz^9q6$lIjK&M{^4>qq(CzCRTk;MKEfyI7zi4q_sM|dq z8(FKh>=ms}xqkhY{`AEx$0TNjcG@KW6}9vL%Wup?YjfdB$>Iv1rxO;;#qeqn#d65xpv?ALqpVX7NdI%e|0^4>ciP*_zj;o>|eJ6FL40*1wju5-fv5CDF3 z{hj>%Q@f8GITHO>*liz=j`ir0R=vCL&ZJ!Juu~_NtRe^FRNQl7-*%jP8yW;ANdr+7 z+90T5P6KVOIy6vsA}59{x>uW{Q0V@;BkYJP*+`Oap>(a)tOR%5O}v+q>A~mAmsPZA z!z72@7gYI0taEnZgbDL~Pm>%HGF&E~$0p^uCE5Dvd$g5Q1OH$ikGqk^Df3};q`at~ zghXY=xWVZj$|{i=Mdh0JCKtS%^!jv%2A2d*R9y$6=+a>GW7LE;iwQ5|%O@9;A z-E(iBJft~%IGpuoxC^y5Fi$aems}zKvoKd_n&UV9E^L;DL4+dP#NDIM49362_>_&{ zA$(#?PRt#z$I}LEI8m~=p`n4(Z5<~x_MA-^Q@jQ0kv|(#vY5RWXQ={Tm?LLKq@Hp` zehckdyVNtg%b(D7AxkSHCn(d0h9JOZR3!MEUHZ9yXScrAfM^tMgg3f?iMrr+m?cc0 z%R@LzDVL{7`R=}Q?4_enw_BUoT`TL}B0Yh4AKouR)Exs3>8??qy8$3a`-K6E{OFkn z9giC(M2z#Ue!JZCW&EE-WYdQGqM1*k66w78NTL43tnmpZpPW*Xli9-;LASsR;dQ19{wJ1rs;XJOC1D$Gw^iQmtlwpemTbsYCkeUb z4}#1VA6Pc($xulqS#Dh#C}T{gXG_f^k;es93Ja2TvWNfvzG~fx-Q!pDyo@!*4sIm= z;A>I4-j>RSokcdksj1xU-ed^P_2^Lj-+~ER##+{&*Suhj35X=g7gJ)5?}eNP2q)^| z=7fp1pt({3t5xluaj%{jpmdxnDZoqx>^OYSgs-PhXe5A0LTQJNtNh4=heqBl*|Vq6 zWdZy}M*e`RVBEevDbITFHl3h#)Jm_5M!L;;K_~tJ)fyH9V4D!+f5PN7)I!ko;7{%G zfpZtu-wf zTPSyo+P2X)96WT&KGh*nl|Hh!jy>vI(xA62s*M%q8L=OFjdgFC;(ef3a^}RqsiPKL zZ_{GM5UV>1%2>eFzteLQ7$enU07Sc5}$pRg)$F3sG1_c(1fJb!P_zj|}|Tw8g) zwR>wOa!=`(IbGX)zfI}MicMc;Bq1uB*ne#KV2AKRQRlVj!aXp^3U`tywY$1L?R#kr zYsqHWtQ`hL98l?+2*r`qMDpoP#sd-s#XPQ^wEy-?rWy1ZnT z*O(+u6WzXFjN}@Sa4u^2hitk$P;j^0i>LlDhe*MeDDeF7Ff}itv=`ni#Z~tgH*&PU zZf2|yC#$*Fap`ii6K`=TOli3HPZEn#)oy4N2Lna+ysk|qTCcVY3%EDTlY_M0z%s<7 zk=-%9u+}3*uTU6E)?XZq{STaz_z7AX8ikIDA2>{19|`Js`J&nHY8~c7tL=G$A%vT+ z)X#~r=*GnDA%g;re};G-a=Nthk0XB&%BGu{Sy|h`86CIro6gwqp@)y0U(sXANBmPr z>KPQ8WQA7iC(Z)FXTK)ze)irYi?i|3HE-`dHx?vrGU^kOtlqcwGx;!vUzM+3HE=j4 z`{y^**UPHfY(9X4_t7)!kSdk?Q4DNz9y?p~txpr4Tb(!X=Lhhf^>NCrPSQ$sdLLks zz*E7_n0O6$$$Em%`v+<}+tLj31nKdTA44j_T_q)DLnqfZQkuJ$oRP^hU&n`TX?!1c z=n$HB=t)}3>AO56=sxxdgesR!nDv7T>p{o@-G%=Tx`lc0z>ca0hRJDixMcW5Mxn!k zQD4|VS7E`*qjIvcUq63NRF~&id3kux;c55671HJ6#fvR1@8dLH?Qm{bfP}^%=_1DYkwNgLX%9QC*#Bi_R!lnyW3!R3`)SphG{)SNOwck+95DRS!BKuk3MUne`^tz0^#uio zM#ssYnn{_a;jt2latTZ%D_p{T%pGomKcc+^J; zd!KF#dv>0J2nev@69|wIQ}@0%<6`5Hf3smdw@bJ{nbEC})?TS+p&AYaQ$sv9<^X`vc|>--?3e5ksD!l5iswBB3^bv2*e2byTx?d?O z({~D;f+G_i?e; zwSVF`Lv|(fCiica0scC9Nnh_}4a{lOsu}Vykg{)jX&^a-sC|xH6`Aim9NA`^T6l9C z-xDqPpg5znIofwq(1hT5(5ZJoo9BK!gv3TT*!mLbPD)JyoO z!iEK~#H)|7k$A$yKy?j`Jxg;Jj6k)e%E|ujqr>r8p}QRCVn0Fxf{mB@ysIVe8|h5i zA}F)eb#Ji0r_Zg$u)H8jAJ)&nfa3!@##ejY*xLTyt@jeflV(op@p$YIlk5c}RB;yq zHKCzWJrE;ctv;__z9bNSA|8c0Z4UGRl^&SC!M5nGE-+Sv%L3z3Z4&!z(RE#6W%%6m z$Et$)Kl*8;r}=zBU#{Gk=I8I9MlMX2=RZos4-<$mzVrc2&gVj4Jd(iZ<75uSmX?cz za>QRdh9Bv7eUJW+vc{F2wkFau+uwhfdxt{MN6S?kLl1wn6-IyR{nd&N(|4KJOot*w zO`;+(V!K(ibs7c)i7u~Kcdq}mw#5qkNPfbc-b94Ib4=_uRDB%=@}AP^KKX3<5-jpB z-1N0#jkGzc^7L}hld|+R8iL&1#_HO;yyRQQ$cW$?%Bv4-JhQjie6LcUa||2)t|Dk8 zU#uNMzqSuO|8)hoT~+;pbIyM9+8+D%>?x$I+avA|L%w3!LO4B0;-6$JeUj?A&68~< zVFj5}g@X{__fqW%9%Uo6|7PAUXt#4?$5rdhO+D}I9rRjpOH{xqca5s)p-%H_D`P{> zpDTygnCJRohLMp_EfEaZj1?a~2*L#sb!@c)k%uccLLSWH-3YD!%~ZK|e`M|Et83bS zcrRW;Feu;flP)nP;L;6ZU+3MSvG0p3FT40P5Ltr@W#5GKGg=AJT*Qnpz%yA{)Bqmdqt(?Jv=Ui~DpkPNATyNhY^ad$ixS6fuHLp; z{fx%aT5L%s?2^KCW!|Cm=a5g4;VmgX8Ax_Pqg6}*IMO2wBZ}oG^Z<@r{NGc?Rd(mcH@ug;)n4?TmFC zV#fwR_8(vgI)}x>Jad`=@?s$W_fNzj2dh6PfPm@iJD6A>?;Lma%B$%stT*_e)`J_x zYQWmeL9|8=g@s*Y6fv+0E@cmwWiQg2(0G3~`!O#kN3z|G#i=JxoT%p0Q{Y(4v201a zk2_fljda-6iT@lP*im~+TJE{4I6lZDmrUp4o+fPyheG8jey(P}w~biE;&oa|MGc;y zJ@};GFsfpV+V0N+_}AxnEhyN?Xwuw+39Zjx{It~!PN0@93~&zq>W^Dl28um`^np`n z_B!qSPsg~8N}ms3!dHicJN+eoNxbN|4hkVb|Mx|s@qe@|>2PlEB=P%u&-+)Vt^Dt> z7RTD@XX?^4R*#!U1!d*mDt-O;NB_q={FaP~Z~R}(*V)|d|1@gm|NHy?zg@-%W;xc6 zQt|pHPK!Po?#xAt80M4hr896%>rj{jNF53bvx|P+f;#8gTjRj4viEjmbW_OG=uQ$6 zn6>Ei9D+g#<-?3q*a_k|3x2Gl^y_yl6QbRjNzf?UtJhof<=O7fKsWI8h+#wqFf!QjtV1XW;&F~_l>@Yd=`puAYM@M z8{1(A+;?j9to}-qXN0$8-*8GYDjHd}_VJ=dBZy zKM(%*SH)Neb1Qrq&Lpel*x#RG(%ou>R^XJ}e;q}|&#=&}vX$&h8rvV26tfM*i(i`9 zJb#|zzyE3#7+RKSru8uUy$NXPZZ?-|l}qt6yhRTarPesizutOgO=P_eoEc#vwW-y3 zl`l@Tx^Sr5K25{oIyu3N6t9W@gH#|E!uI{f|9+#jV|;pCu<3sSo_!I3@j{Aj=Q|YpV$JDBib6k zGTycq8rb#IeN4w=J!|lnVT^^EP2zKSGLZ0DbMo2LqNNFQ*ZgyI67TuO=cq9RJgb=_JHP z7Mt|GfgM?y^alY4K^f$;A#1HcNlkpwK77~mY`A?vP?Jz@CnxI!FF#pLYvK=(U1&oiwj*^!lo%}I^+Ob)0l!f)`IE}m^2TM?@gqk-!S@Uc> zA>A&a3DrsS`7rmN{b?YBIP5{|x%OY4w&V~+MNZ{PezwBrDu4+bvcxJ$>)TJ3duQsb z*R`;)5FZ>OqQYgI)+Fh`Y<`yj@+^?>E?p>&geUk(d=%)b0NdE({ncM@pb)r-e1=+j z7|Q4V`r|1csV_kZuwcIKWluZaz#tRv3S<_qoz##}u}&Zqv|30lMiUSbL2Zyoe+V2= zZa8(E-lu;iS68i#WU>?hCwFQ*T|%p{B~Qg((T<#5>(DM_)`lk6S9`OEa@=O`s~oXzo{X_%Gsc@pBAwSAZ3~v;?vJX*Iv8L!A%QA0U#-K zU`IeD&j<00x)J6MST^Qtn_U^X@vHlTzpJ_XG@`VJmV+|WAF)0+e|(5Z39LeL<5|WQ zFSB>|p3T=%?bHICX3Qucd9aSR8MO47?dE&swi}r(B!;`6vx<|QzyWvkG7UNS(yD#u z7~NmI8ks&X3MC4FpWGSiEc+iYS@!r*LCI>&-#rG0MJ;zt;JPnsbD)({2Lp+T;)YdS zQ}_7v>{A8s0}?D^jUJo~jj$wh{l;3tAoxXH6+CmW;ZxRe#0b&Cd1`L#q?3% zQ!tHY2+*lw7*Y>}6EDwAUP{VhQugT+(S@R?b?WKGWprw2^}03dL!s-x(RzH{^|sZw zxuAaiCn}G7kh)|}^O#3qofPO|qEK{E;u^JI?zC_2!dZ)X#AM|h@9t(|>zr}#Z_7Ru zPM5Mu(o<%gD}{%7D=W*z+1ZE$^pF;6g{?DAMV|@Ja=6{=+4#tb_Yu4)3%kwbKt1Gl z#>U?CyajSo(_NT2OoQVr4!t3R2M<MU(jp^@xzpwrg9 zk&%E9jfmEo7I7cJr&e8*m||j*i>HAmjF{Gm0aj^8G^-wc#he&w4<#~<>Jj0lNl8hn zQNtA#o#`Qfk|#TmH|Iy_&WW`oNhLZ{wexmRN+tK5JNH4PW`c4tD(|uUI^$hS%aei0 zPqLfwyHY$(W=DX9ZiF}U@lrGx>n@bjfRQYsVTO%by!kLT=&gqJX+71fdoV{@0b&)* z&o!5>gFe_Qg<*qo_>o8H*(U_9n8^_#AtAxRJyBE2%gf8i(7x6w9X!u%nG>dtWhz!q zks@`LniW?9zfn;^wgBOZV1QsZygpnol74)Cg(3B`XFu7AKf$D`8{Wr$o!{sZ{=w?v zNYC<*dd~>1-s`|s5l`*;Bn)x>mgom~L|sF-W-py#ha})H%Ol5rvWAT(&yK7l1+|0B z8uSXEo_=z2ZY$Tq9nTNwj!;`rDaZSQg{0K)f~bIvVoJRjEz zy>log8wsimZl2NmuhyZqNLf33@!~TO&I>m})IeZRulEy5K+-B|u*>$^@?mGYjiaAU zWx;5!Q4LGzu2?>kr(pPklABxhaZ%g@{xUaJ4zjhX%x3NxbtJg z*22cS#j6M}9pqwPAqBy?pkVr=ZHI5HiB*M`#z*^E;dE#7_#NyM*a4C_1i5p6VQdc) zx+CMpZXRuJtB*o!Tz+K4)`7TC)K;a6QM>QA3Rn02Vkxg^=0RNL+4`%ZE1k(SHVfeoi61Z zd^mJz$Rbs%+gnN@@|4Rq)jk&XiAbNtKY= z*V!UGcpP2!A7|7fnl8BjBV`B1={oku)Q1{7C8bA@2z1|z~ zr2H_BfP=M7Tz}$8tK8)PJxejJ2@UI{l?Y)^y8>*hY?vH@&oZEYRwmj==Vys}h5V^U zZ;D99gBVYRUwGDTF$5f) z?7fWTa6&Kn@KOJS+C*vfz6G}MvrHyKF=N~khX8Zrlb)sn__A^sstJ=#CA&cp>m%qq z;vgvVH@GZ+=kl%n)pm{C5zhIcd$#5n_tQqMP}OuD)+-)MP`o7OK#BWz(|p zP9~`IVArj!NiE$TjrBjiL2?o|Z{B>x@i{tUi=%$36pG* zClTJ+rz-$;6LT!zH|FHzkd&|$3|c<1Dtewf01PN5eZgt9e%~q`a181tbE-@|PGNso znA_LBr!XGFIwhNpQf4>jQtYU{XG#jxhAg?whhPE_i(Djn&d%m9_8y{<)E{xaMJ#`b zTJGb=j|RDp%a<<)=j@lZ#qFoet}NAl9On%zzRbGzu5v@!{NaiZ8T?40)Lhrr)p1^^)8c7znp29sq=T1O#lAs&b#O^{Di1#@MaE4oXs`iEyyBG z8qiREVz-RXo?G*q>*`35pkMFy_m}jn6cW1q{}7ySd#|Fup*@T6E4!~ebxBxx5Crzh z+S-C9VVX$f?VKWLNSw`=E?>5LYB{WD0LMkL{TaT+P^oEt``Z6S5~O#q(#OrM5*>M` zdD94`w-ijEPLHYyEW-r4I6t1mdZ?1pe&bxd!#!SZ`1HDPJI|R-CfPI}*9&WhXC)=0 z6%<~UuWFVaH~seQ+p<10-@kmx`nt!#u3~j&xjYlQC=1*#hwkm>HuUDKUVl#4(Uq8> z$^;6;MsFZ9RLjhnQ%Xm6ct|cC?mE4wkPt?JbZ)AgXec854f7Pcix<>qcy}nzN<(}4 zBG}*XI6n@HBz*o6o^t3I{X);`JcZ)a_@`USW3@C4eab#8D2!>20SepLZevBrobBsW zkM%!e?jA?VJ9GBDxZTclS_b!YacGcIJ;w})%2)wcaFFTkT3NEUQQ!&qi^}-?wd`h@SkM*l+%^*Lbh9i5RK_?GqR;@Dc42!b1^DZXYu`Er1Qnn zz7SkEB@Ta|pmu&wgpsXKs!Dl8ESbyPrR}gqvjpu#I9Q~lF{E}7pe~sgM7;3y^z;J< zW+&Ya)@P!UyYuXGrRT^E@f}gU@kiS>6I~K%^6<2Zt0L!p$b0#;A*rHfePAc|x|0>| zQC=UWJHGZ%=-j>gGIKb#Tg^K5#hH82mn%KX=0$(^QnRhg`xKfb@t}Rlc_qJa5|O{s z-yibdy}ReQy3X4oz}GBS0fSVhW4%xqzrU@FTX28tr6zGDq>sfbwg{_Eb3g7$UYq2V zEzX-lix%lIx7Bugpv?#U(87x6A5(@<@tgg%v?t8r!gqr@jm0!QA;lg#NI=C7FZogk zmyWBw*Aj8g_rv5SkcnbDyO*mN=$m^VzEx`kT%RIu;V^wCxGXiJ6D=19jWE#^3%~q2b zsyg?HRzm!pzH9S#&5kY#e*Usuo6RN)1n;3kr$ral6|ShxlWO-6U^n6JgSa_+?rMH7 zvHToj+{-N@^4r}DtDYHKVvNOu4cyD;?H5>gIPW~hGIPvAN|F+_UR^uLdM1ABBXefj*dHtnEd=eB70lStHPY~tAgpAa+Y zLGqpn}$Ds@#049Pt*j| z?2jH@|I^eW_B@$OMP27hkcnj8WZTYrj$@*;v~~92iyoJ8!gz4y!o91e(-sUn%k>{H zJ_H1Oy^Aoo*$_2OS2s+jFze>cZ5w_*jGZwsU+Uft`R;|b3F?dEBI6SiOH|H>wm3a8 zs2Om@#WQ_`;>CzTS9X+zx^JIyDMr#$4!%sL5=hMes5C+5r_Ge)Bi-kXnr+=Ow6p%R z(5g$1r2L)9<~*+7rLQK~NyAr!0J&B3!C118Is|WuDzt2pch$WqZ=Fq)W=+>_m!?+T z^HJyN`<_hbX8l2>=G*>v3wqf4UD{WpsPorfXF$abPVCM-**s+YyyqTd;h}T;LpRy8 zr+bT&cFn1Q_n-8rUHdl2tGH&0&;8WZoygzEADn(yaR^TjR;sxBS6`kVH-JCj$}Wpo^=5gIb?-e*z zcj3T}gXbM;(!P_M>lCtlhx*QH&!ISR#Fg}6)%r@`t0{khq+NGu%avqFbgc^?jM4&( z1o&3D@x6%#zH;!ab;M88bFwOD?5>$BWqq7~Gjo`9XzA2_8Jq7uNPgCme5WN}GjIWS zDa6IqYDlhfO5YK6w#%3Tp z@*hc{JeTwfcHImioA|1Hd$K>!qw(=M#bR3=2;#%G7%3ha~4<^=sP;`+LiB*Z1YyLbMAOAGxOCPHhE?f{Pco?9#2(` za~EkHin*55@sN$G>)ou{P22A`f@Vre#( zc!|sNvUMZXEETRe=*pc~o>Xr9KBc6vv|b@;RL6r=3v4C4O_l3seD1Bk3T!;&@U3_S z|2&_)8aS9K>_f>`N#|aWvk>DzNZFBp4s#hA1rJtzp?#}8K|&JYOd`Y~8VDgd&PbO! z?@(Yu1X@+i(yx^Lz8UisrGWm?myB0ZQW`Tx!1>-1@nlY_9msAWDmlC5V7~-d%s@Ur z!{e+)+_!4V*N=RZwm;uhxSryUMDS5Mpk|w_NN6U=GMeq~-9R-QL@prV`Kx)!h+c45 zvS|t@w>%+|0t!B+{|`;JxcETj7s_-1pZf)}qT=C9e-Ubg($!Lh^T_+Ai6Nml#jXcf z*U_z)fsgs1v2~{|!325o<4LCQkoDY2MLhuO{nhx`(+dua$UA=~%>7dKicRuDmKx24jm6aAr_;Ab;&@X zeZ^=;Tl>Fv?rhK-Ko$mB^=bTwQq}ddUnZGE-J|zzyArXJU{&0(ktu&W4{n>HRHr3> zsi?j#d>RpfNmHJNu0`WD%l@*eA3SQD1Iw>j}C0wSI4p@OE zA@(yks5)xYN<`YBE)sB0hdlo2pp`D=v1$ zX#1cgey3@GXI|`I-b=~vi9}VSudLFjj6nA7kL>=aHJ?RGL zdfVN=J$u}~*iyA2$#+glBwFh?M_tNs{=E9Z*!v4c#G{Ouan4HN&8(P!Qt8u z#-U7~Ia5w02!Z#*v3AEK4)t(KS;*nykwVKXbo8pcwyAdGH-7uf`W-)ELS&#i1AI48 zW7FlEc|@mZafww6jUL2DoMF+e+Ufb{W}2CO=eCQ-u+`?+)M%XlZxH2h?z(^z^0y~{ za(*f)D;pbyz0s+~^&!zJ3E%3@sU1x>oy|i_kdre=A>8xWrmh z_QKw|6#j!0LQJyGtmN=H7#iWSQY7kiK|^dp4ji0LSBU)D?+y!<@d$Se*J*biTG&AP zKd|rW1ql+X)O)5gWBowIIuCe@=_V*EZB~vgYq@7#FlS$iN%OvT8G?ZpIq5?lNR@3( zQW?S}-6b`ULb=mT*J}DtSg*s(L#KZEFxf(pjeNPhe5LUN&1>6VpHo;}^RVROwxY$U ztX0*@u&p;1lwDrEMA;LJZ6|{|lg7pIE}sf#c|y__JQ=>@XL!Ok19M7B zR)Y=;neK*`3ssuX^42l}0Q_XfMKSfTI88iqisitm3+cbCUk_oWGROIO6$nnshio{R z_YQ2cE+wh-o9|l{gTZvib-_l8oW*i~dS|J%cUi5*keG%myLRnzdH#yl6`6qBhmYjB zCp+1HxY7_*b#(GchmR1>=Ms=~f>y3s^WjN)Gf0lgw<~hJdI1G$sKh^7Fjnd%bK^;; zhe&lAUN)d_-{~{GJ2o~nfcmJOb<-}bHi~q~kD>XHIpU@GFe@u(?2AKov%6bw%(7q6 zZUojoxO8uc+Mwft`xo!ejHJ~dBTPtuGlRD|sUkpnXRnLbAO&Bl_!f0_tIf>+YX<@7fE1n0z9QEKSWQv|h&iV9pkI8Eumrd__= z*((md3yDK{b$^_K&JaD~HB0e@hOxZHIw!BNaOUZdjgDQ@ zpQCVMao?fmWD?IV)ZmnkIZ*U1Za?%c^!)1cdgjswN)qPQzp^y0Q@ zL_l354FTBeyol=Z*wDdiNJeC|S75AFZ@&E!9Y6jZlj*dQ(`$6!Koh?n%R zAz^vtqkHu2yJYlkBkxUB=SvRXD~PvP=KWax&o-OGfs72NOz9msHF8A9VU+F@$1*IL zSE6%{)UlUmQc_l$o11e~BK+);iUmk6TSYbdoZ@l%R2{P}tc`4LeaR{ODL~UpUeOhH zlwWdxo1uk3%vj&tEaz_s1l6=tO~q6Bv?u~?SELERo9~{PeM%y{>qy0(f6ML4A|_y- zyTCD-GR#hC$4D_?3|wb#{OcDlW~DB+D&G5&zipas)fp^(hvrW*+mq4qJ;ca!^AlEB z*3UX6_35f=*1Csu5W2KL5{+Lrh^TLNFyzHEpbz$KIrh@~$?Qb^@n%SRgdTK>Mw+oq z{v(NvtfY*>y6AT9YcF{k&uLDLMu`q)&wiAJ1zr3(O1EE|Hg#KLNjnxTTExZQTGZvJ zTi-TW8f&?2-gN9#fxS&aRA|*WD(mYNE=hgJ5~VW1tEo#?T0Aao%r@>gcaEy;O8HYI ziv_cT_{%WHWXWSt0Y0$t#kdIEpnW&k**&CGLt`PuVCadZ2b%U5sk-%HA} zcZ!)x)6%@rSi(OU)^XvoFlksVz>P66jkQnu4mIr|C1tQ7y|e8`;>FN!X0KBh>7X?^`~|BK!KXb_D4^PMx-kBvO0tN3*aE@=Bk5BxLe*S8>1 zS?RFwwzugWVmC;;NnuGV6_xMeUUBr#qD5NpvXp=PQKu(Kxuf-)95c|u`bOeGiEJ;Y zq=PDxQd2c+6Zx7d+a~rcFQ8^~_86z8)@|u;Cltf&I$f-8S-4>J?CI&L z`Vv=~ZXLE2ExOev=(UZbX0iPh#-gR#1-kyeAqFSnx0=kHDR}m=mduLnoOiFSOv+10 z%_=IbGfBiBr)4OkpQ1{RfFCY`BvYk*Mk59)jWsWdKYDaxbkR|fn6|#)VSUP!`8;j# zuYb3(dT`|6y}R-T=f?m3%25-|+yTD~;KkSeFW7wli*=XP+gQ4Fc}{x$LKzs}kJr@vpgyO)`{mvV>+AD6Mz7Rq+p(F-BC_ZG2h5z*57C9aFB?sVifz?98U8R+Z2l8fUB^+t@aJx1$ z)h?a??`1{@Xulp}wKrH@x9I%-)!`?Sb5sW_Sf~7l3c648jNe!nOUF@Zr;jw==xO)+ zN=_RD)=%>b-tv9=3*}L1DsEZ{-(Fw(wJxtWY&bIQMAsgpes7k6zdTvQpXJsJ{*;$> z_&*!XFt_78C5QYmyBvQL5c!d|M^E*C@armn8}PUOQ&0hOm;9l*Dx9kD_g8p>A{Xce ztuH|a>^kH(S+K6!Z@Sl9zG}b?&BPVdIRkrWS=ns zdvi({Jwj;zt6e*Kh^clF!NIGTz(+fjBWJGdJa5->ChA^WaO2M)nEo$ecc@;uibuev zO`Gy6@RMZoLeC;a18}DPt(*4yp;-P{nXBTdkw#I`4~YbMsc_qg^)OdDKp%ng%ZEu) zz!oZ%%N!K;soGykr1(ZlOQgUtXY5!}Ekay_5+UnvSom>wgWfqym7AHFg1*AlB-!lN zxBGP$j@C!bw)RS|AMbK&T7q>HqR9-H8=U9dxewf{iiV8gJp)K) z)6>#~X$%gZ4DmZ-5feaeB}q9WWG-ZGKA;yV?hQ1igomxi=;$J-p159;irU5@Oh7AE=NqUw`RCEdjvO1EUspT76?a zvqmB}MQ+olL%500>hYEC_U&hCzdnh1`B!+O+m9{}Y)s?p;i^NR;p;E3?MS6qy`-c-7{dbiYq z#zu-1Q0mA^inxz!{hbjI zqe*>Xtt2J0V(*lcG(u$;owTs9IXNq0O=3eZ`Vq)90&>ONho&mHdJ#3Qzr z>oG16ap~=kcfCO!*kCwEc&sNICKAG=PiIFB8guu7a$?CrDgW(v0r>yRT|IL&Lu1Fm zJ5aKQhvhMQ9AcbEZz3ZL<~P^ZAJTLQC`o<2;tR`!Lv(bXyHr~A<*2u3?wE8MH0W?( z{@Vo|O?B?Gsm1&oI&_w0c^VKCH8t<;`*u#DIjccf*Oh<5GPLL8+#T`&oyemOi)m+SAtq|YJ8XvD<()_fQ}%WfbH(vMp5l~L#zsZzBzo2U zd*I~WHM~!8E|TJYLi!U^e;djtz*WA%tfHbK#!1OXZuB=aFoefepb65X9+p?JV&~bR zNox^IdVFOofp+KLz3cK5+ecDGYnp_ypEES*8a$=s)?PztFw>!D;YZ2ePLKrU=zCql zI}P%uho7?F8LI1|Czn&fx?5$)P;ip%-;=NtYGZ~@x5i4`2^h-oJ{34Ad{5PN>KPi? zLk24|rh4H=xg;C;UcGurNoCeQVW8&(e1ul4t(`Y-Uin-gi=>o;;6iy7(<9lp7`UjCmc!y7V%b? zr;FFb3Ox(gIM1*ip%1YFwtfE$vh*0yHyaZe+{soSfSWQ%$VyR%&WkaT*=KYg3g5A{ zn{o^m%uEZlSO0)5P)U2}ynwDh>V0bQi@KqdoRU(b8hYTs%|ESpq(CE07t4=MYU}+H zTFl};O3|4G38({!;3>s#fYy?}eice=({O|WAOCwQcn$yBh4)?XMWk?{6q%U@= z>k;s&OF+V(oEHnLU{+;<`7q|$l3`~v1&Sy4gc>#*tazt5yi$n0?00hM`6 zU8P&jjkp&R16-Zt?P%2ywc*2bU3G`6R~FpZ3hGSHaj&1VW;s3nyhl4i!ozc(UZMhu zkX^7kd)2sk`=;cU=?+l89&2yYk9OkFxqVf=baDSe|F_TV;^LCdQNJVT#EP*N*6+e?4HXaMcx2gw ziIv4e%i3o=Z&dMXrBU&k>u@JP6^zj zeEvF|cYnW1pSfmghPfW24@Jbig(2!8MzVh{{(b13@ypPNI_cdj+snh*bULM@tMtjN zSMVYVmFO_Lr5z$#m?2yEF8lo4qD#? zu3B^;=2N*NJa}C6@>e|o?Xr2Cy}yS8tia(}9lX4~=cAT4R2Xy|cJGR{Uuj;GQkfV! zC9MS)KN;=Upg5lqUtkp{msGrdeFT$5 zk^w_4coIMxEJ5R#d+2(180^C`_Wk?eQvUf(s~g^!Wz5%*^1qUiadFu*!E@!^h({Ci7tZ3+Lb9Ma}hKGOJF8=Te@mX)gcKUt#x^5JELbqY6jwZ&MS#nygGH-@+}9x6_y)%0 zh-a#+s*DX8W8ksX4;mgOAf@rP3w=&^*yad$4&zQifzQNi+l@pVB>&oDuO7Ar_YaTC zr8G=cCQP`xe*PcS%qRe>NC@7)dUez52*#CnRi_&n9mJ)uuR`%8E`2^v4k5hF;B{-o zE72DI6J*-}2VpNSB1fy_K!j8Z1s9nUD?1Dv zF=St{0+#h2Hk;kwIetb7*4&d8vQ-el1cPJGb+E&n28HYgI`2`MnjSSS&>!gtg`G^8?gX zz0m=<67_{(0CRv-{^1J?6`s3Hels_+R$;b8$>P;KTLJyx<%eTtoTSyaAzIiH3McXjZl^Ds7u{soBhnB1gMGzZaV)fB24soMH(tm zxorIU&xyBmDJHNow7e|yeY)K7g%w|VKCk}wPqdl%WtJT#0cY-f?6Y>hT=?M90W;)( zealjH{g>{=M?1?{)h>c*-6mKX!=N%Ql`afU20L4eKX&?Flc`2Gxa7?yB4pIo*3!lY zf?FhN#iMdb+QGe!4KPrW`+GkL*4neT;UPp%>ps`vZfkgI#;y$p&r00SYlQ@LbC0)2 zli%I7-ncRs^BOq%EmUGJ4owUbQm`}e`k~A!2+PA(5L7ye#;M?$XKTD?LAW|QL#2Q! z`bVp)?0h39t=8>6{=fS&Ynz4&Dy4ReX8A^hLl9eGt(;cxJi@-6b$Mn>uRtRO7v1QDO+COZoYt3BT0 zxgE_dZjm~A>-*#=uT){puJTH10ow$y9Fl3s{BuK(gU~kCJdz6(j|yk~$>U@mZppP3 zKfL+g_xfa+(X{c(K-O_IF*2lkSe~?SXUPyuLdy{ZtXZ=r$tJ$I3JM@)rm%-3oplU{ zNKP+QS`e!^&|O!rLFfP{aikpI|)vyE9*?qJ|9L5t#v{HqLJui+fxOW0QZZj>Y;HE{(jKL zjapytzx+IuDwN)^!$sVWke%5XmG#Dg=^0)xl9D3zw{IhhoVv_wEFn{)4WpD|_Q zpxS5GqRbAizTVT{kXYGNU+>g6CqG}0drh5IHYOq^rGXxhm5R*V&-@p!W&r`=F0IyF z4`DWWb19*MNL@YPu#qA{hy#iJAbrco5c=~&D|I}{u3c~7U`hYn`TZi&7%}nS#Uin? zI%a6x*w0!V*0Z|VF=EEFkaKNp-RFOG8u@7O3iiR#-1M?1-86MzIOrLR zSjFQUDu{HTXEq9n^={Jv3-qNgV!6hb7=! zawKm77gdbl+C4!32gi2x>NNWa*m0u>k!n?dt@$wjMFI_xyYGrj6!()P(w3zwQyeXh z_O;qBv3qA*@1p^mk{D#8s-D2~BinY{D}5g1P5v*SdPYaT-;`<7W;;4ee=#N2^5Ge) z;R@V%ma^oZGeyWE1{@9#H#X#`=6E=s8Fa+*-~`vSFpfB>eVlMq_SFOSBrZ|-$^wO$ zCyDPG?e&yxlB|QXW_!>E96LE}F7r=(&&Qkh;mWR$nw~rfsMXlaOvZDhZTw%8jV(T= z914B-_S6g3eZsBowiz&_w%FAnRL@h3Q+2lZ&db+i`(=#+N zW04$BLqXOIi6lj}hUUB=Xm2ksnRe+C;p=e5XDp`@ z&xY~LT5>!Qoq1%QHwfPlG(fFyoBVm3$amywxvG=W#w;W*AFOMMz5)0m(UQ z_LihqOXM?KJMXmQ12)Rb+V5g;J#C?$efrQlT4UX=2HK0nS@rFgsS#Y_x^NgrgO*k& zhBMb8*`IRsMYTAge&O_%M?R|i3nyHI(mR4Dvr1;rwRixzc#RX`+$^}$ZX7wHYkD{p z-HCP)`I-2X+sX-a+ot`1{hByS=Y2_>+vpi%HvRZUE=4tS4L~fjD0$l^NcPnOmNG)< ztjGg?*^qa1ciBOrPE&rU>A~x==@h}VcRepp1VCeh`*RWzc!hNET$` zz=SXTMsgni*`1q3tqaOf`(#u<_00i&7QKa#=HUzYl6j|%p<-AbhG+D&ry7onP)GEw z^N?#wist4eRH;nq-}?Aue#%WVh!EiZ=77^A^{71tDFlhWJL3bONfE2Jx2FIY<(Szn zPdAg3v*u~WqZ092O=^$Z5*Wvt6)Vso9I{t;%X^%$ZjRsLa8ec^#--n=J|A0vO=hiZX2>($lB zLKUu*lc)fB%7qDp%3N6{`Pbc4kww7`nrRaeQ~I9k5o6TjR^o^Qof`%4G{EklSL<6} zrgE2flBmiO&ZdJh@`;nuO(SMBG}wS{ zyRoiruaRPhZG)f&sHiN)W`<2;J*PLk^(E&rw6QOKv4}ssVuc93jF0bRFFa@J>zfrU{mhMXqNZ$VMtbt#5yY;vK zcYpIgEf~=b`St$4m-_$TfBa}NHZcFwkRjXafBb8rt#sJS3vf~zp*{5|0t6LFp60lh zy+^q&mk&F9SeJ;9>=_}5kJ9R8(1($=WwVdhmZPaL8uP$WtkqGXc&gnvN;b6m+$G{j!1@gft6cCX zIgq51y&Ck2)}CDg<%i(Wy1NZFh%R4)7ZC8{bRA_;ASxcr;M~XkHf6T4Y*jR=+^J$b zt{Bg%IAKEn0W*Is2TQNPE-p`ITb6YJ`@2@?>JXD&j`#(1H>FNm=A;B(Au$%Vgf!r7 zaE{9$sPAs*Y*dif_lT?{81PyQ(8~`1gC8s~Gc#-a`0<5sNX=Z_b5Etu4ueTMBqze= z`Sxvnvg~!IY%!U#w^E!%OfFXW;Oo+JPBBMqS6{6~J+Lq7*N^(mvt`*WkHPWn4Hfq7 z+BF@T6fb<=s2w8j?l8$Rm9BsUpo79%N=g$CPd4v&((J-!^Iw~YO`!5@&?wnO!`{!2 zbvY#h>)ea3*;p3s4Gj(5w~tYa70}M5uF`bkgxw{4e})Xn(!64!vQIbV*2p}ozrW|9Gfl{l(GcGIe|US(c&z{Te^^UtA|s+9 zlr2hDl1`Epg`CI=Wfd8rM1|~q6u z$M@0W^DWNve6QE*IG*b`p9_Q0gfLhW8;K#*R0Caw86#U@O8`v*(--(&48n#hN&`cN zirvSpkz+TmUXBS!_+HE*-gcCGuS*_1$_=L13f*iPeCmEEvaW@)Yha!PN%odHgl;y{ zlhe|s-dqgL)rJil2q=%m@*9M&oqd+1m>{nRt7J9hakGSjRjfJ%`VPQC6rzXGykcQ4 z9w>u2WM{tLMJu}&xm|0g;$gE~=gG?lR8R|oV*W#A?DZ7=0g*U3vCnOR# zKZyrqz&%TSe-g2P1H?jm5dl<we$nw zjuCl%0mzD?Xrqf{EX<_+542TS*hYc}$q`9^mzs+E9E@6*ok8F51)1VN(Q+q(n)e~$p@0T9?g zRpF=#G>`aM!RV@nw1I@mqYlJA?v6f8 zZ|gp!W?&>D$*eBBaHBzP3n24q-pq-k=CvD{nRT-C_j3Il9d!fVKsT5gA!&`jtX2>Q zwIGGz#4%1=*3!{!Pl9xB4)n;F`KlnFX1(#X@V_(5GNMaO)}pA zG?lKwN|MBa+8y>bMlGCs!beDx4A|qxU!5vaQy( zA%0D(2ctfU+6qz4b=fyp-12LxDS^c=UB8gO)`6T9haT?%7LvQ50T}bDQTF8M==LOR zDwx~)+-VJ3ty70#k9KZp^;y-0BOk6vN7aB}F-xqQ3!HpxQNpX)mdapcZN#YQf){ED zUiJGLPrsyLPoeFLQpd0BV@DD$$8&B__SS+yeOq=eb0G?6XWB1ZPq1fcw*f} zCXY{xb&*A)>K7R2>$pX0eHjo4=NA|#@RJkkMzA_`c3-R{s@8Wn?NIo|vX5V1sXPdK zLxRI@xy4HwkioR|PIdEW&U3R|`Zyf)OUU(n80bJb!uTT)p#r5L*7~z&A5!Pm581Io zO->wcS{ZnraGUkH8S#dV4|h6ZtEt+Kev8;es}JIQv#_!8+2)`i2h-Z?_5_Oy6op5S zSXb5^g&TlO#;A2b!v=AuEanrTi2pXm%(M9!ujHoV0&OHQHVYp?N!HCcWoD*!8e?GJ zy)*PtfXoUTpVgBsn^{^+M3F=St>i!WT>Zvz-mu(X5tyI@x=VuHn_-+ z-fyvcSMu6P7p(@E1JGtKl4wcoap@PzNo)oC-m5|r0BD8Gb!Zdg3>hK)pa6EoHo|G* z?f|lM5u$y+eykHnlD6vHdqG#X4GTKfwo zU5(Y{!Uf#jp(-hveRw)}xcliy#_!HrbmvgLgqPQq?q<}&==it9ns(#H4GP!j$OwcU z_o_yhWYXrNtLRvMFpR26FfkL{Ru$a?gJNMB z+x(Inczc6*pQm-X3g={-^Y3n9UjJJ2U2x9xeJ#P!6HnrHpYqLOZidAI&YZ?D=;ZD8 z@-blStU+*ii~~A4?BtN5f{mk&m!n9)js5Y^YMQ&ZP_|;zr%gX*i+XKilvr`O9u%#A%3;QoDtcg|hjLb^>R5@*IiXhEJCXy;D4%jH= zYsB+hrrC9G@MMTnk;kl+1I|I{{j?de<0*uyd4gBpx0No zi5Ym;#wRB+N3Y;c@p`N~@_i*VRBG~WEw?Q*xBTE@$C>N<`V|yb;P9EJUk06|4c;DA zRyHYo>J6dv%}{l{VD;#ti^|GmmS#dFAGr8V5d9=l4{=C)AS$*f@UQM6aShq+YgYW^ z%&h%k!XH;@61~?ZazFs4Bk0j_WLoLkT%iQGz%NE#sY7fns>yWi!7pEGrbQuSX9(5J zK%JaY^*v?5zylm7*XC(`y}!>=RE?VVizZMX{O-$k>?GIxf}@HV8${})*0TT z#H;|6|67f>l zG*F{H;~^`uw%kQy=nX&w=y8SSIO9?vCLUijJUSZDympAvfh4CRKt1O_@r;Y4_8nzP z%udl1m4gQW{tNTso$&)pE&iLiWqZ9TQuw5_Wz!~H_mEzoe5i!veM~tMxml!GvIQ$W zCg9^119F3Xh9z*ntZb9JHFz6e#Rd>m4md}N%AI&B)nj)_OW)SXc)+mjU66dm5~qu#NKVa4+0lIK&24y}m6^y!uRKf0)yg&p0PpQxYJ zjp(=iY^(9LF}4vILnJO=E&*XEZ~psnzv`*(z}a^uqWw4orB2W zgjQv^%P-rXMW z5fN;A>JWF6o!2}D-md(PAD+AE`9?!LEd zpDW~ZJ_gyV6H&=xCz9K&aorCp#?YHL?MFTyG$gqQ5gU)Dbj`fcVDieesLJ<81lS9) z%EM_a=WL9z^yQUCA*X#pdZSI?F=Un#%v4JxXLTm!lBU>nL+SEImG4Xiw}jJ8Rsp* zFWXLoU@{jVsnUtaAmHFS9EWm=^X@=0}J01Kdp{uEWYyS^c27d2&uD_WcOk9Yj_ij;&~vYmKC4_0~;|FZb)g?**#HtaJQlR&lr4 zMwDuBPYq+OgkD&+e6t{K63xD#aOQp1_9RSX6cOgJKJ~=yyVS_M_+c%Co$5bU9XfOf zlw`Bf?#-!WV%O<>kDn_~%ZfI%j8wo@xYdSQ8yF|VVa zcmnpaS;8nYctrLC$kUn4Znsz7GOKR)cF8G?97wx|j<9AYx*k)>`=`t9eU0{*Hw5pU z>klwHw{)eYvp%^`H17}dT{J$=fr7MfgWS|pU1~7IgSuRU!06!#{OBepDd}1h#0JI! z;?Qmq`hV-|Z`AOttgHqza5h|UaKLD*IMCtx`uc!Yv22c+re|p1yDx-jySRP6l z%74)L0DOZe89Myw>FH9q%@o@vF;+OWI0*K>P5aK`M$1>`9t4m9VjcW8Ug!~k?-&ZR zssvHzzF=xDm{dr5MTty$tAs6P1~-F=Q&j~9Vj5i2?W1vj23n4Km!MP-AE`e=)@l5J zt7p&MEOsM=MPd9;XJ^d1SIGGSydZSQ1nVH4W3Es?o#xgvdomBC>VTDj4Gp}Tc=Im+ zQb4Y(kRGk1z&%H$z^7`j7t2rUdCtMP)%I|S2cyze zQhLKM^4|}wI5ckY@!te%mhQGLbcX~6@cBnKB=}&*KQPe*C+9vO7$f zJJa&+R4`(+mfsZU{ck#+EDruc1z;|DKkb8Y(t(5#~^|3g#A5WO5Wxl*BurX`VW^qqqf2 zQc_k1Nxg0cp_-uCNL2C`I{)Yr6tiHxPgSs5@3{?o*!=7dPa@v!tAdn5GhG|rBKXJ9 zOhJgV0m^nV&}0ugZZ}d;Lpt#$%z2Yqg#oLep1e4*T>IM zOLss$B{g*d#$JL?Deb{@SAZVoJJ{wCM}MkV8sOSchU{Ktv(_#?0=5 z{O>qBK!K`@?6Dgd?1b;Tt+w{MknZRA;tUwm4YB_u)Ry&5g<{=BjQhyZAK_IMA~?_nazBi{+DkzK6_aTul^UgtqX8WfPqoE+&YH=bl zTHfBHc(Sm1LO9LD^tq=eseQ=D=_gcuQR^aKXlEPEf^9R8=YSVz=A_v)1@e@Z#W0m!7R4~6r$~SAVn=LE!3F*&>=8tVU>hz zLy1?)0v44NJweRw2HI=75w(Klfvq*w)rdd!@xutdl@gk7&^;o-?kLNl-5iqkUbDkp z2q;`j)*DSFHO~}*V=zegU-;_)RH-1Qdqi%ylDyHSrdaxr`;dCb_UP?BKu$C)Te+9R zt8HqUhk}%h0al>Yx3^!Y4nWk`7EG4I!-&(Lw9``<78qQ*MHxN#;ry<T|JBDMqu}&2M@J6q75i%sN2T_#Za}ux+99U_1Ps#hiM1mCRKk>vLa4n(dsJG_ z1|2J#YY!hjq=d)E+p&};?F{UXWl%6do?>3y%*dxhx=q^LjIatB2(m5_C=D>tHEK(W&ZM;R4m3+^;|p{1}7zj zgh0d*3W@x`&@UrhaYx_Gj(-Kp62jEiz;zsqn{iVeN+ak2}CKA^1*`qm{nZY%B zD0(&dCOc&FryeDX>PZQBDy<)TQV?cgd0@#mEDu;pu%6*^BN(-6Q#?+$#So^xu(uON zbo+w-j)wmR6x;d!q*u^~4!QlOVx<3Acp4s^&LdDcJ$#IUh>?$S^_`- zP+W;D-UCD7@Fx~iL$AzXcU_iZ4XUlJU}F-af~EPrn=!2ykmrGU&iwx|oZ4tPp8yn} zZ$m_H3FG9Gg8UvtVFzb-*d+FyS&=H_zJe=f3@nkxk5b(exG+`8LrIddClegg!( zKNSo0zYPyJ{rUsW`A{=|&55hsQ7$Y<7Xu}N&M*=a5!bCFgdc`+DL}QmWx*lo&fsZG z`LV!&ZPK;XG+S=(GWC=Xq_i0k`?;Lxt;o4g$g8b`XE5Q;F>4?SM#yJZuMy@|LA%l}9 z7$XA^WEO)RseRe^O+`1QiI%;<0rd?jPCVgG0=5sgESEKs3Gc{xbOfRTdDe<2UVf44 zj(7!1qk$J*DGb3l-9m-i1X^F-&Ry5x(b1sb`l1fd+{KjY2-p>>hLd25uyL_6F}-S8 zF|Ieq&HyEBn{8VQlxmp2q)x$F3*i_~((v#wXi-CALxl1Pe*zE5p09!=05k?MJUhID zIY|mB8-Om*96)LXp?j)cG&%2L|uov z*QmdT)5Yuv&=il6k4s{)CZX8RI9VpKChSGRei@XCgMf(Nv+s8=(9hyU9RzLZe;7lA zn_;zS9=?>ILDBHQ2ZGqc9P_dvi5UNIaqvIq9KHbCB;5BfVAH7=VDmOSfsb6g|H6fJ zX#&6g&I>``yMNun^g1tmP1F_$b%H(^GEB5}SnfNUb4xLb9)L6U7EIHA*fQX!oZSw# z38*N^7>P6$TY2bW&+`WTgT z+9nns(Pl&tn{G67h|#`*Y|4~~KV9GW=Yj2ACoD%nJJ_%ZBAKE}sDfH0mdh$F>HaB5+aiI)d*X^_!E zK`lyME|Ca71X%p1) zrH>xbF$jk$n`1yR)tz7K#KDkW?+z;drIY#K#oTg0r3wXb4{O}sLuW@G7rzDnvwRG3 zaW^+lj*W410sQSTJbGYT>H^Tntde*zcR4L>-kEy~VK65l2L>bgnzWt&F8baDizlTFqbD=)0f z*GE1e!r@zIe(lSj*pBdoQHWoyfaox3691N1<@TL(O;BY)+03z^T3R$xd$H0%E>b|@ z`VEWd>jk^U;wOWffw_8 zf~E_AR@2+J2HcDe9ACeDnShD~Ae2$Mw*J`$_+X)>_<)jp7;P6zAkiKIr-TY`4s;h- z@uz%5puc!i)m`b1I{)Yoqyosf+6ko~Eb6B^tDoxLzXo0$Yh$ieq>2kp5U}-$2E6GX z=)-S+3%3J#oQACcGfME40Zh?eyW$7;079}bz8nkk*@aPbVA5hEBC6Y*9UW)TlD-J> z{`wnxDYD=4gAoiIhy4ac4G~a4VPg3*u0H7;xx9TF-4_-|&EEqa1D`)*Ndrz)3St*+ zI|icRSW%g2X_brMd(Q#|<-)w9J025c5{D1b91X%q!Z7~h!+gd3ocMdbHIY8oucLLt zp86W*PCstOdXz&gKyIKn#g=gVehZ?yr}0f}E5LH;Lw}*n4Xs0HV;{~y6)l3X z1mHeyr)HRF|LW7}{VC|$X3%@v*Wk7#I$iSZCvtntQ1W5dKNXq|UIUO2%cCA_Ie_!w zMmTW#B}WUi->|7(wQ$bWyv$$6|KC>@k&U{BL4J`!1~pgJ&2p#Ui%3jz zH%3i)8RLZz{DlcOIemsKM8GNAY%uXeO+y1iq*5DUyx9KVIH=KBi(KTg?JhJoeG7j7zSTDv{K!$K$vQt{I@^pap$5h>;%R(SSa&5?L>=+5ILq)y z-G@`hwilGTIYZ*bk&jpUO0nMwL0M%fA%Fp;E2`DP-Y3*D+V~t4bzy-FAO)L zz9L`>Ccc~EQIwUH^%jk(G=Yp^*ZWx2#*r9m(QVF<@ROvK%JKYq%9G`9m*8bovQ>55 zeU_A#_2p2aDT~ zmA;O;t5%wm%jY_VYn9v9X7c?J7={>c#c9&O!)mdxSPuIlI71he{|!t!fNMe$6Kkjo z6il!axIJ~mp}FDnEgjX@{B8+*y<6rzTbg9WcZ7x@WrF@S+8(gR*IG!K?w$MhEmg9Q zE)lnAU~2Oa!*h;@BvNW&UOPfphG1$PJB4Eq`bra&OTOwL2xN2E7#(#5I|Y2|kl zJF)YblPNcYEZsX$L1FRjUZ*NH{say*vBM8|aN4BxM4J>>p%_pD(1F$fNq=sLQD^2QN zynvSRJ5bmk?;c%CEk1`57~r%YCKo`sb(I#^{H{vdx|U;@GxOI?b;g+pG+2cVJ3#Sp z!4qeDwG+a5fRAt-4Se*9)qZ0X1Fj2m z`u!(wYE8AC;0kpb{BK}D^*RodMyKOp)K!pl2nD_wysH)KE}WN_kar)fbch1{di(ZI zz#3=3EU4rdH{PC|xM(Z}5(>!UANXV-8Q0rS@;-DkdV70u9jEbwgX8XT4tVQb75lI{ z?cI@tV5}H5rIc_Lg1$U5d^v}GCn7pJIx-UJI;fnf2}}&o&R+(slliWu1rd`Bo25PQ zAmX&${mRaV$wHU_1r&&#}xkYajl*5Ew z*REmSpLS_7ahpwViE7F*u2TC?OEWGs{RxtlB{g+`wfp~Vi?#dUC zy?_Qi<(CFA7bJE&lCX+F zrR+iRMYL}?HV|TY)0UptED%Q^(;Y;RtZQXAHtpuZFcDz43$q5ubb`k#mXo!Qy!uxR zdQfjmSrBzWWj6loq6=ft0^f}IjE*soWdl^6P)y(^Y!ABaOwTkJhkyNwMK`|Tz#q+b zf}*Cs-+UWKeTPNTA7)zI%rbCF^nE~?61$Fb#ov#ik>AAiSbK&&(lR*{18Z%~(9J3e zKelS&osa;cuXWWVET;kW2qzmG5@>7YE&|hqs1cItc~Cc&!8)i*$Oy0A5}2ZJV*;v6 zr0DDEbs(lL;mepIt`r_91e{@4d|`2&fsM^=v`4X)%)%0hU)qq|Ol1j5K6L*Wya+NaWF3U{^2 zhzkT92Yt#lJD;Oq0#>U+Aw{L}ztcn$F&{7oaz?NK`pzMfApdn=b}{-Rqc*}w@Ab>> z4aoLoN2P=73yo@mI#!LifIE*)1emH~(IjpOIO@n|vhpUZRm4mO=@HhS67zr8o7kc; za*t|$-N%_D6ag5##5Hr2)m1FmI!3X?dmf|^_ELj~GDzufTPF@Qx>e-f*F6&{n*HA} z94B=_cx!ugd`q5Tvx$Ku?LL8rnNP9LhDg^?c;@ev6LbHH1lWJM)Y{mBoF7U4e%fLg9tnf4@5l522|I(t=UR z$JEUEA>KI8AAADBfbRY${$ugbY3}Z+eWIPw3K9oRJf;uL1-bn9Ku47q4-rD<_B&T7 z9&R(cc1m9dWr>6XcJjQA!X39I>>79t4GrNjGqK6V1!(9yFR|(0LPMh|HSBSOf~9ra z^)<~K*!TDeIl{lv=>CVJmiqo`)|n_OEeo^wu^Oc~EV3lOMaMJP)`_#wJ+6A)9ZVeS zfU1e3QFpVX{Wf94e_xcpIW&(bqE~65azGrxH$f;l-k_?IC`W6M*LFA$a?VJBHfR-9 zx;|;89D7t&X7B_wAeCMSBe>UHt5k-MM4TC-ELO0=*Vc!9OK*gERXAKr@8pUC%BP3KE6rfp6y5TA* zxF3p2)wfq!uyu%KjU7M$Nux77l+EjTF-v~TxlITlI^5=Uwh<8jZ|}E^l&hIT&O(}` z;tPO0)DhK9U*5cdMLCs#Vz?OGlK+nW?oT$p9Z8VH8QyzMABq9T_+|*6H!FGyuW>2n zG_Y7=??B5~+PM_ZPjXcgUkgbXf$OSLB{sCMQh@4}j^4}iw{GgZ9f~Ei=js=A*RzQw zV`Cq#fKX-6Nv%zxj#ws9shi)TpM)H=t6`Awe2;hXBQ7lJ!HRA0CSVezosgyICISzp zLiG?(K5%rsLvc<;H@z=)(N^$yr6rF#uTruZXQ0-lLC(zf%e5ymiIXPPA2MmEmCq zL1O>y-;J;7_7WEf`%}ft_c9!^V1Mu60^WMU*rO0B4+z^97RHIYjIF$=)CYL?8isYyY*jFATr186Dkz1G8^MW_*gcA%f@&}eAIWW+pBxA@0vmGYGH zkKz)7(%FJ*upJzY5$l5ZI-?!U2}LZ{62b^;c6ZONy*gpC((Q?Fys zfl~ze>79Gd`jeADL12|AC|J|6!S;s#bG&J<0?8L5Vk@D2Di_B-EFbmLyEz6*ny5r(C)Ul^eh35V+lmvr|r zzy-1)!zulm7`;b&pr1aLj#+8DA{q(`_UIl=SoGczlLo|-so4lilh{MgPB zWRH@_p5|r|aq*VWE9>YMs2eX8MQAvE>p|*j+&`30qw`s;GUC4Y_4s`T>0L`kqCx_< za_l5Cop%#`Dhy57^C%4og=o3pOjzE|g?A4>ikfzH;01c*@jdNjp|?)Z{|KU zVHxeNz^(khs z%tb$4Jt(v78l9G+*H8J9Uk>(y_fG%b?xjq2#ix!tLY31vCQF|8vLprg?ZuC=u1yN? zz9~H?+(274&v1D!_usyrWs&F#nC%f5N(EyL09FXdnMv8VRKibbSX*=Sd7xdL3cB_M(z-bu$=;`(kWg_%Kb z$)#mU#{UvaHLdQXcf-cM;JG1w`n5bqlzV8F>)MYl6P{zex@iFzAK_w?u+;?uG#{+*VLQ!1iD$45s0ziVE73D)=x+b&uY8{z>^3>o)&oJ{_8V>s}deV)dkM-SU@T z<8Ob_PlA3veL6@%M-7bx036*PQ-O~|3k|nH(F^CC?a3BTpLGz{!R?dd0|$aPb2%&y z&k;I-vYb9w?0p?h&I^~XJw17d&IF7xVT3_V=yMj{0O4^l1*+`x|8CRGM|CDU8A6*1YN$TURnQdf`goomYR! zoC{&88_7K*s?v+i4H6bpfk*X%M#uR>cRVuehQd%{7Ium^VV$NNwNRkcp7TsOHlQj} zP9diiE3pDnXrTinLu7$RauFpC=etxZv8edd4^F>|T7JW>&P1VI6i=n^EpDe6ZtXF$ zE_&ttnSJazKL=dnbPKxinJddy4j(uX-M!JkBXZYw^>kY_{QUfbkRDk&5{nDA9>~s-n7gmr^%Sa#kmA_*NsG4i0Oe1|AHl3Jx8aW66K1$u z$Q)@dX=EdzEd$9*rY%BMgG^uKs~tNAk;-)<6tGrnT($~oLYWJ~Hc8mO9r2r~YxbP$ z{!`d)^lNew8INBiHSWEBSK&F2dDX6v$lsErAs5#xaI4f~?>JIod8y3JWDUGX%kjZv zvMjuZbG`Ap#)B%qKmn%Ew> zwM@l*S?g_{I=qA5olLWpb{?$jJ0H@EIEEp%X;3pV9nl~hVDu4GgFRq(zG&y+tsY~D z1%X)Uc%mi=AC5B=#{AP7+@{`R`?(!xwgoe=sd)w7`3r(;T6tFNT^E*nw;6E?vaqsG zOrx;EKt`(yR?}Ps#r54YF!-YwX3)aLz~xdu-I_@av-^yP9;5(7NJv;idJU2e(D?(l z1H4)MZrkQEq;jC}Yqyq(L-$yY3|Q4y)YedzoUJ;UIQ=M;5EFpf0rPZp_;oZj>9I{V zSNwV@gL~l|gsQEPtP>A4cj1HNY^nJbhYOml9_dZ0}UQJE{lSOYfm z6jYewV1E7lNuqTU9n)^yXa<&ydY;1d8e}qPCKES)pJg{yQou!+EU84r9WA6rfiRql z>ssXqR$i2RS5}L60g%_fufZA8D+iGviawU+NbCsMhRd*rC0Q+QJqh&C-zg*l&7oZr z1{eW!TWh5dsvZfA6#Hd%k)zKY;`%zGipUr#o^U|WF+AZ6S|%pj-mV}%^iQ~Kr~|sY zyHPUPW}!jGfIbYhJgHaR+8U7sL&T}m71o9N!cl?MJzTxUmiIj6)U&s?I1@co3%fPi zC$KKRvF!K(S+CQ075o)u_pfE8rHmNSFbQVJs)!Zf0Lv2O+9O(JIGHH)P-wyrGmCyq z)7O_?Z;81m_IUbZI~Ro}etrmHd~rV8udcc}1vf|J75Y1RWXB>K{YJccw6n>cFhCpM zziUuH+~crQx@X?ZDWNr@Hfs_X76Tt;n~`TsYiu@d}x3^pY^gV74TY{dfvc?p7lW2mA{pr*J;oB_sK^#M!LYZs%O*+amjB2 zeL9sMdwfAeENKtK(gt)Ewudo@R@Uuja;r0dvz@@5u@o`BMuY?}rGZ0M;Zp~Pny0{f zcDo{L@AOt{9Om|`R-H5F(dP;F3hv)O3l`=qMU8Z^?aJYikrQYZ z-hODrnxnU>skL3SqDDM z@ook*FPVwXV<BVwtO8esRe*hP9}#|DL(Kw+@`8%1K~E(5izOAV}bmZMHstJYDuY z5?h{DUXFqz=c5}YkdZn(>3#Ef zD5|dJmSc3^!)+gqOrvyN06zd=oRZMB#Nq`SsQ{obp3oS|v&%@{wY&VE-RHlY` zacqXREvf5bCDyp&ZP9wuBhvV`%!P*thp>cmOD$F+xVjx&gOF3V;Ln%_>k4I=AMYl~_*nFT&1BeitKuP$D0LNvzPoU{I%pOeZx!%ibM^b>7 z00#<=zdWGmM2rxeV@TtSfT-M1_zs4zj05(*RqLt~Qmjm`i_@20@%04dYvho0yNLOziOhx`FBs~t;Gxd6)$ zI=c*FCNe*{;MEhW)*@{EA`qonnXN10pEi)Y0>#=Ta31LY``o(Uz9pzdrMd(MgRLBrq6H<|yN7=;hTb&FyL~z<_X$^|$C? znmaa6JSTd>xcNcD(pQE|p^S!$OaGVk)-kI`sYv}{WO z(BjcBZ-L%hN?!&mrpu6b2Ofws&dcHKK0aIu9BIEPW3i-l=>A#+BPYt}2<*@>{23&qe%y zPtFLus$0M~MSK_4=z>zKyk3bg(wtl|x}7HCnx3HLwRsrnJE=`SCuL<&N~m6vMgDWd ze#O6h?Kb~wH=iHGa$mh+@zl`#1ry}i*kzAb{#u>6&`Bo+Owm5S{x2lVHBz2&CHCCF zt!!%O=BsaPxN_cgbjL9wpzz{eFK@Df^#I@w+KqT1HZ0-#TTzV2NNf?O!9N_rAEb3( z+^A`|9q6$`(%USc0+*Kc^emPjH=i$YeTVN=GIhsPX4`Gw4NESqS|rEuA+pDpD9Pe$ zY7rvs;P7jrd_3LG&Ea^6Hb_{Fzzqe!f(f{ zVEE_EO8lyCT2fXux|qZbUVYU6s~Zw_LNGJ}bszu%JkUg{h%OE1 zC)YQ?z;E5_JqzccZ; zWQET>$m}#%h*rZGYA5MGXP2e?K1f{2W?bhT9UB`PTh7qOuY4-L>7y!ryHwYb=8h?P zd2I%(pfjr}4JxzOT8pK=eo$fRsM&REw5`WX_mHLe?!XIcc#1V2ylzix+$SfkT7SQP z&UotALv2i(j(v4zPqXBWwIB9Ge7dk12wMA)g&12|2YPe+2AG4hbP+i*8X6*UkXwXi zX$bB&Z3h0g#?lvK(O5tBcv0>ZD)y4+Dg93two~}--W3HB zZJd@tAI=4RXtvtYdoG&)Fdg`=0jK`6{#(yAe*dC$zXgQ{z#DCTt&dyp{=~QG6FTO5 zn*S0VBktcad1cP{dw43w1g&4^Tul5qv3F8Iv0?4YD~4x8XW)OLOP;d)>7QdJMzHI;B`&9q5_xfpKgG_k($+-5eI`UEdd-YeNl zW3~>+lfvK>?WGVc#fjlxV^S90-BA)!zmH{I>3MMSjziUrG;7ueUDfs%_f?-wzRGba z@6{T8fWvEPmo*&yD3Or~!*s&e-B0-CQ*On_zql2J_+F#z(|0Z__dJc-hPU5~n_X|& z$IT-v=ANIbU|@u$UFbS>>7T%n-;95xKU@|vF3g}V5qrCLPPL37WAd|%t(fm)|D(aI zwGg&@v_;9OLV#qL@F68s7$r;+M_quG9+$`v%?9I*QGd6h&Dj6h60SE?-d>G`Q!Qba0C$d+R{l!9!eZ z{^m+X4gT?x7NYg<0yD?vm!}_N8ugdE%gI{qo%f^XO)aa`$Kj9aOT4cAXYFihnJQW* zws}Lli72MC$dYt6nsRxcdObTaUUBi`S-MfrcBZc8;1k#OIR5Tqm|5n{J4r{&&6-Lo zEo#_X&W+ISJ?4v zD>DJ#s7(gq*4KvtoySbGJzHj!$tSw!4i==Qrpj3ec`76~8FpOnVfWy0Pi{Bg#CYS_ zPlxZLiVsDLbUyEpyZ_>D!CGOV0LF>U{%arj#vR=#oVM)iXB|oxy9mR+n9K_5wX%tg z4=-;sKc7gy+4>a?jg<7NFCf&@?3*lT?+}gdNR=!bLRK+!_9rzqh~Fu>sqRSHr>La6 z5{eUndt^hz-gVU0Wky*Bat2wYg8bGOP8LK-9F?beXa6t`%tIT<;!#P)hmvP z92->ZExa?ngp<{Eoiz&4b1X>Zk8JuAr8@^uoSM&gFubID+dD>)zi17qYBRGaUN9>`)W` z+$T>3S^0GBzby`R9Y@X->r7OyB+Fs?Bsv82!f8=m?Slky=d)+O&(Ai+vZ&v$_-};^ zQ!8hc)J!Lzfu*fPyS*j*{Ugs(XuTQA=zKcA)*~6>+{83E0G{=c;X`eH#0)@?=;wD-)?7I#x$Bx@;mR(4 zgBrL0zQn%Rn-HV)`-Xktl0@UrfqTvN-Tq5+HD`%Rqtler30hJY9DM(mT(F*DQ=UZ3?r#r1 zP3f`l!r2LlfSjBhV7}n2rz+!7wm+aCwM)Kx*Q=Z>tiA)~IUU33h8vsbe%DP+-FmUp z`i69kU$cE7!%_k7XjQ)D9IB#=gHBUyF%^{#lvYt(AoLQPvyQ9&?ohu<55cJlUbT;HjGLc1_9SLuM^ z2O8~(C5I_3e-DMVX9V7W;7=D=sZUANOK@}mN&HK_0OgvKz)^;wPgun>lL9Y$#~< zn59$c+1TnG3{e$BZIx!o;_=mO4(&OzkWJ=cyN`uuvz3Pgh(b~cdkMVC$ht)J1@y-j z%22pF=7iDlwW7@9*LeW=2LDV_#@e&b0n32wnCWe0bMrZre;l>2Il}mM#FcP$=)!g1 z3c;-cDHim+L6M|x-;aBP|mqqF! z0^KSg=b2yEIr=#S$jd+4fNrVX-*Y&o5LL_fAyh1PqmQSVO{(ym$P-aDsI$UVc$i&^ z1*>Jn@))>G-%EAcF9GFcmMgh;Z-ig}n@i;fcdi~d+jT&DRm|wR+RYmdhe-rBcx=xK z*uvmSJIi(_wZAMj_AO#Y;4{_v+y?tQ;#X`wRX}wDFbVoaglYjg>6<_4+(rVh;0PBx zxtkajMRV{^=qcU*OgZosEt7Fwnavi`8?IuZxA1~kj#%L1HBMyi2Ns$S_$O5eGu>tv zUeFm%gN`Q=!Xf9>xfvlpC;83jZpn;bkLiNeFuzTU`i(@5PDhqGy6m-i)2`O8BU?Rx z%S`--fl9SiAP$$TiR}^})b56BQ<$=SoeW$){*Jemk zWiCIjE9GyaXIGPxlLO4#joXCCmntfA8z74w%d@Y0Z~YzIG5%9o&owU$MTaq^OWdzrxmu z2icm+eDxl3J!m@=H70V$-Jic}DoB&R)a9ErFy*#7P?%(RB|-~M6}~Viw1!jjrA7m! zlRkz!b3tg0nk_`=NMpti+H>kdJvbsW1TZcP+KNv7&<5W6?=Trjdd@xW48${l>1HnH z&jTR@9gTShcXzOMgF343zFldeS7aMwH5aAKZQYXk0?iCCvWf~%z%KKMAZiqXT674& z{kA}+3YY+5*V05g;TzoD;Olt(CNP#6s(S=2-Pdi4TXrZm-epb9?l++J$YH@p?6rmuHQ~*XA>@>L3m&vE92vXWPXz{v*?p zauH$Na`Pqv0*GHOYqzoS2dHtn^uhZSS_)hqt!A|ph-~l|KIla3&*(GFTA(n`yJq7& z@Y7R~2JPkWp^sQW2#P%a4IOmv;pmtcq}Ndob~ec zVWD2Jv-F_~iT3cn18~-K8K2=iQiPBgG>9CvNWmH$e9&qQL1RhKX5Td*MuRH~46je7 z-P`c3@%?X~1{;Xe-5%&AUV$6q2Eq;DgW!( zwZ~mwOaQZi=8%GL9Z7^lxr?o_!llKS3M?bNB@kvpnShoQIEkhY|6Qn5V6{CHIcnb4 z1;*d}y*O^<4V+?Ik7KEvhe^{m=PI(7UPSMHcD_0@GZT6}t`km=yKZC58#J!Q_mtMT z1Yq04JRnZ9;$lKqf5Wf8yd!CWY{?L8e;gv-vPrE zP$l`WQS0w_aFJVnGl*_s5kzdbfI#82fd};teSP&e9!OP%!`<9zO~q!U_-rHXHz{1# z6dt{~y?$Cb>e6^|I!Dwb|Jms#K>tt?86$%cG3#8ukOPR6kG%n}1E`l*5W{LTB8!eg zpVdZ*B=eLdMgfh-JedO!a2B($5hDhn1!>LV;^UD90;onBylJkUq1N1I9Gfg6g%Gnc zRO;RvysrZc=1$N>NF{K&4FXu*_y@)p#!3QU&SItpi{>+bGya3|Gx|`JKoReK93xaZ z!FnMgJ%Ew~>jK^B7o*;q6OnES`!aqi9c8&NgHa_UF105CxYhR@f6$^G><VzyPouw98(-l1S~jk=_RxK@aB|K2XgbEkD0a?FxW$LAgx`HX z?!LeI*TU$)y#fNN`0HWbQ-%8^A2s~xcxS!ol7an(qnf`327kEPU-1#bIMcC#mKQbq zkOO3g5`tLzW7H&I+|jk31vm#{^86!NtYe9qSc9S;zQK?;2~&0oZb6PUcfP$ZYbR-o zplS@UEuFsOQRDC~`AC?a$wRZ;i08HL_q!^pe3oaBbc9eba5OHQU5}j9PY8+~n}FvO z+Z7iaS5q)wk+Bgxyu289?Es10#Jt*H50kj*urjD0FE1KgzzJGF?HKqXv@&)G5%N$Q zoa@ZvVZbFLq5W8)(On>aL+bozzkyulV&i1gz?-eY{nP^Q#w4&h-C6D>#uHYrXCylF zACa}XR^szr?%Z351>DQ7{(HFa_;)>y>;KU!Ig>di1e+r0|Kt%@O&^)LeICci6vjwZl7%XqkadJ?x0>E>Y2&XsyN((NJfeNyD-UxUqkqFd2Y^IDd?3U&VR8v3V-- z=x;g&#nz9i2z}cKIlZMo*Zgw^UGsHkjmZQC8KestASSf)9a2Hit%3S`@$!Qz7P8DI z32||rMdE2wk-Ei`}0(`!@MXubjP&Oj^N4=H?x}<`D zu!}z{IfIY7lyY;4^_R?e=Czq$`v9@-`HM4%sng4WbP~B_Hii0fpEjI|pOiGRZOhR9 z+^#0#^2zCu_U?Kh3T#KIB2olaC$XuO8&@SB@9cl?t)FA!I-I$Bl(g z;4D);`USwxi@u8hW_wVLs1|o(S($=M626w4RjjoR=;d(M4#KNKq>m*fk{RWO7&A2r z3UG~o7;+`>15lOi^?7{;4}gRJ%(wlUr{uzL@tA% zj!5Q26L`@N2|JA#>ygpwtsKMY{z2EQQIsw8Q}>qY7#l}ooBj)XE7Oj^jx)5N+CAwLV#Uu9JDgs-SUSL*IwiVIa-T#P^I6q`M ziHqM?33OL@-r=a@F!yvQa=#T@oh(SC_Sj-}giXr;)VD=>f?*NOzfe?bX24>aT@>;5 z%+SzhtYewVRGe7^U;Ko&Jhw53$Ik;{^T_JWneK)`#dN6I1#)Y|n-n{@(F-o~663lg znE&CT(Xh6dEVra&nskztA3r!ldq~whwccLQi95X^3$2sx)^`c#p5|T(_NaF&H}|NS zJw)MV34|B7!A&8?{->T`GYZdYm0&tgg9A(EUI@S`*f##A@*R(^YasHU=WT)!ZIep} zJ-)HAF)^s@UH4&XW=7`nh=SE`R+^!&b;4Wn-k-jy9^J-{OS6-!<+Z2~9m7|yYldX# zpkJLvwDFW-{x8L|(;raKVK;69Wrl*(Ru@l@UHqBDTewk|{Dy4w(n#e>VsIfthojeB zw>~WcQK^8y5X8x-o#3sr>Y8CRwnbpZ@rpaEYx(%^$#BRR4^6S%RXE)8YW-2i(Gx|{ zYuKT=0~+6O{Df@eL1=&VYAeq(+cQOF2t-d>ok>~I@=!~EZ48D@`w7!26k>V(nwcpm zJQTTG=E`y1?Z1>eGu(Tm#I&EEDc0O1Cm|sL+DMS?#aM-)tzaf;93b_!=V(W5fR>B# zH4LsD8t^etjz(6RZA6`l{b`S?oA}$P0Z~8ZzQ$*59Z^s`XMTZgi?JbqDrXp~m@Ej^ zU-1>BtC=COM|eZ?xEgREp(q~TBGjU zHld&Ngpdf-7?KE3`!V`I&wKS2BCjl##{vvzW#{laqt{S_Ph z>ZV4x1H<=g`2|PWcL(q4GgJ&JTT@SllEgv@*83I{-vcpum5vUvQzlE|L2>8LqH=ic zRFC3L)Aawa_TKSW|NY;vN~L9#5hB@Sl(KhG*;|pw7D_3j%qV2EtPsj58QDr%CE0|m zvR7n;5LwsrqwjV7u0QU-?)yF-kMlg8rQ`SlfR#1kKZr=`^5L;Mq@a|fD8dgh= z#XFdkdl@99gY&3ykjFf}0KF*OFE}f&;jw`O*11coVY)sS>96FHcpqT?nEbT&J4Vu4 zic;qXz*OCs`pA$fuMSwJWtr@$4WIqGtZkV%ZfO{H=})nv@$wu_ovFr+@9+edv(lL*Ki>Z@W`%z z>?rk;jmgr}9Va}M_n5W$&Jtb4KCoIErO9zY|oM8K@O7E5aN)Z#VW{bl^!g24lxQ;8~MBw{|{NqK)Sc|RbN(kY(C zq{{P?iG&R4)tN*laBv&dZ;03N4A$yA%DHN{$GT5jFjT3%cBk%$KH1M~-yh8h)fX)- zPW5D*+PXR~h_(fHAW@@%f?!uD2Q?C=@^37vAA^VRNRd4tzY&c0zisCovR5$=3{7`> z#Rhkf;}C(?!UfZeaA|2v2M5qbapg{a>+&qP0`}=!utwAtgjZYO5?i-mOE}W~6*#~h zn(wgbDWF4hlMcq}t<(39znZPLG&` z!#SU~<*>6N_~hH0xl30J&W(pR<=@%POG5cs=2RI|V}$Dqj1dJqF|%_3aCk`b3Pp*O zVvW(}bmwCBlbhT=T2G#P@hGQRT7v)-r`OwEYlr@JR4i3*LX!C!@*=f^Kvfxio-F z?$S)g&w&>LBtN^AFeq-h2*<8g=os||WaA03^`Wn<+hI5}I%*Sgs$57llFvXJ6t3K@ z-`+S7oZ4K`!*k#Hr?NX=75E97BO{DP=m|!R#2gc&#+f~fANNK6Ib+}~B{>OIkM6<_ z%j6!O?u$pzIXF)%SX8~`m_70SYT38dqjL*4%$fcfn7Nd#g~6!UAepY9Go3S zzrJb#BNIh4h^W7z4}&-XHaCP&0n*Sm45t7ZWOcQp0EJ!vwnV6kQ;xXQHG_W#s7xB> zo%aWcC>1dM*f}_olac@>?z|ubBe>QJx;B^-ga2X0@dgk;WNa)G9UV@{FcLJk1$`O2 zVJ}6RlM>cTytl9|+2PNXl?dF!cGPAh5!;ZHTAUHod+`X-91tNKM9c&}oEHH{Q1~4= z@};FEbnq=sF=P#28)yL+eS=J>-|?|>GP*a!_3eMuhx-@p$o;%(0Eg0cZG3U-UK8Ec z?$pD7W!LPi&Wl*;h!m43jdD;1&h@;%p^)Uu8kllG`WAIXW}La?=7r4(J~tGjnW+=J z=k`k6Q72P6lNn>bF8kK2aJ{_1{={ab#iE1L49=+Jt+~$2xOKPl!@>`9evmk-lEt>;r$>c;==-lgPe*^)E(f!NMn3298!rO2qNA?=|z$|7^aI|&qTn0VJ|jClbD>*yEdQc)m?bK za&?tZ2}5#6l*-I^OG{;cZFRITawob8t{GGLD<^hXel66IY#vbE+XMy2)m|~l&Xqt_|k&}z=*aCLGRc_yNOdn zB+895N`%4p7Z5Y{PEi;Dz{rCy0$U8|#wx$n{`lG>rZqJ5I#DQL9fo}G#INJ{AqKq( zph$%G4UUh4l75H`ITMXFH4rVyK%@zIc3MHoeh2f7n}}-rvw$sJvMiS~@6&Bcx+FYT zXs*YFujZb$@48{)w(0@8ZzflkuG7-=;NwkAO%*JT?B^9F`7Ix7k-(jE^8gs?P<-g^ zJ3KxgzG^#C9&|@?`&EfHRYcveU}A$)ef9YCd$oKhiU2lEyg0sD444GJZg`8z@7}XT_sDZUx>u}H9i?;8LL-g5E9-8oOdOW^ zPtFMa&3PyJSI3DqlIoF74ikG7bGqKhz0mg}YfNV3R3Go~kdQn%v&+H9JM;XVSL~py zhpzW4(_H)bb&~EjLugpjx|hUGm*g}0tY-7By0vLL@0~gFozv2%;v#?jp}TZzb<=~B zN}*!I3bHA)8`yrbY|v88JVrfW2W zi!|uNtvcWQ=3@PDEP#Eh0mV?~yU5Vs%)VPcU5XkuL;Vr3hvZrG+(c*SyozP~DVTH^ zTsJQ79y9k5mB-R1*Wz-5O+=2KP1;>4Dy7l+YeRf`s@yxbIZJ-&a-;UJeg)E^*uL0p zc#j@BA{t#}7KPzHpwF&Dk4_)SJkFx0&_=RI8lX#MvgNj|qc&$)YglKUceQQJ8Et)i zMh&5o-{gOkDM{V^NOYl`mUAi+Owmn+!!B!0U0oN9h}|CSmvMIuS)I`G=W5<8*N1ny z>X+#EMn5S~_=QH4fyQ4c`C)6f*NdXoyKWWtw6q|}qmcRYxt4DlSu(4)isNb|{tW&) zI4M4@?z^zhb&8~~1QMJ5ZkGul9n(A|rF$?41`c85{=yJ83fzGpE`S4z%$z7mmn#Yi zcVNbbl`R2eXHeG~*0}0$5$k$;d+kM_i-V*~`gh$iwdms@+8qMieibDeSdd7DihNJd z-MuEVD+9PWIEGiAY2iiHi=5y&EM0`QqR5xC95TW*u|lWA5gn3lo1+n2+r3>-rb zNO@j9T%f6~{gM1MjYw(cRA?|=PfLSMu&xc>iG&UO~a|nXjf-24HSj66??BoDvp!Ti+~VG*s)Bgefj>K2&neJ z^+I@-q87DcQ+YWNT?h)K0h0U*U7$KB&1qBt>kV6UzXW`NFYZD|@ku8*-dghmso0X% zhdXqep1q3>(`pc3)=_=Pli^t(kOC%do7%qWoizbxUbZ~?y(#I*Vve6V$L*=c9`Cc~ zf+;dq4(D(yr)h2?nZm%PR6mU);p4}T#9S7D5aat)NFHDJHianJT2F$``LS<>ixy+b68lF zlT)3PqKq?Lx*HBE8SJUt=}sySYb~0|NPm~;XeqIG+e*x`D(^-Mk)-e<*Va^!k8m`J z1eWs1;~6Mo2u*LSuXkDys%0&}ja3B6LlJig?DjD7c})1^L&p$Lq%tm5G8V7|njsoZ zUjBpZ`ch+2kreN)2|_i72uCgI^_d4=Qp*#$uVs%rz!WQfeT9qQz?%c*wh)yHl}>@B z$+wR7%?>_tRYpg2DgP}4whKXW^{G79i-P*Qm9(Qc0?jhS8W$g=*}th?`l7;aDQskF zdPqe8UMX>WE1r5Dtrnp0fi`p!f2@xEX)kEl7qz}(o?0g;<+UaiK5X;aAuc?89uSj? zY?%?n0T{1ctcBb2jm7iXo%Y5EZhlZ5fIRy@gg5$kfWDZ#I;}qT1eIU^0^|PuDNQz2 zSEP8tx7uP2q`uZye#n^g>g=Fc->(D#E=!+Qi+Y*yi*)(?d@@Jb#}+s;7G5&Q&OM$i zXth-g&G~j%QZj}gT)aY~`&%~aAzcf^uY#wcp}t!-ljtR&BL$x2!$M6<+f#Vm-vqwJ zR_`Q#<}3+(kbMfc@bg+rExfWG!3ctN4b%G0^;518z{{wIBIbM@cY2Ebf`M)&aBG8Rq@ALdS}c@*VHP zE6l_}v@g%32OLE~ct=3vG4cIPzZrY^&e{{@p~2w8b%Te z>pd`<0gg&aN(yC$pPwJhk#cKcUieAhxYSeHhX&W;=}HyDG%Lv&npX{6mM1Pna7;ei z-`2U{*mzN;`1y(1```!;446Sf0qbRv7&|LP3cFQx1whUAqsbnOuFRqRdfv8rmInVa zkT7I^rMvU)W{rbiq_>0OE1%h(C`wPFdztDq#OUY#oD8ZE!i=s!XuS_k<<+!s#+n+q zpmA&8E7YTpL+fg7^Jn9CTn6tfx05fl`oO}`EH6IvT6B!J#4&!y?h#DLHw`@gz?%GN z_pZKi+Cx`KpRPC!h_R?`l*{ka((43x*jS(UyDIm{D_|;sq3h@CKT>yGy;dAY7(RQD1W#la#rd7LnnY4t=cFaPpc` zS+IDyW+_Z7*`f7|OvU%~y#P9sDvKQ61Dnq3b+0WN)6ml9+}7m`t?u7*b8LfNp*NOy z0tE9buP4$=W#TV=G6tvmmHlJoWjk~+Og(VCzE8~O)4K2w!AcJC@$w3gUSrW_O8|Ve z?@^~cq5qn8+O-)~ajT{K=xtEW^fmo>s6l&pCIP*H_bNkVc(}0Fn!Eq_;^Lw^ z06iZTg;S?^sdp|*c*s*iP636ZSEtt(gv372@w8qzNSN@rL3AhbD`YNL2Y_iOwkEo+ zm9TuM_J72C#RcHGT)=E3S8FRV854^ad*-u)M%7!Fibi!m-40zYyPAN2*H-|B$Yw0Q zJf_8Db7_s53X;T$347jO+Z&f3{8Gi3botYmy?Y7^navJjcGgM+k=WnXP*u7gy#v~b zto|q{psp@n2&j)!avqe(H{_?vR;sRUQr(>H6*Z@F0;Bm(b`pgfaIzOIwL)hcR(}W) z4{3)^`eoIk@4oYLzrhyXC)m1EE~ajI#j3u>u>4MIUpd2v1knqI%j@fB9mA!p?r`L7 zr=p>uLFYds^g8^z$$GuR;5FJWPA)-Z54psoO9n{zw_HoBfrrgS-W zf0it-RGIcqu<)PbV-V)eQt!MQ85ya{3G1kaZQ?!gp{fFrzn26I;tlBr1)81{!|l{+ zhyu=a-?FvkwTt>$2l<@{3PO2G%qjKLhPN)OYH2+(5_k|2a-q=C($Fwk{bkU-d&q&B zFa&W6U9s58CxisfBSj3?9-}5CX^Yx?kxOjKO5>xg#g2IHxqmJ;BOokfK9u|SpHYD0 z!$+Oq%=7#Cw~8$t71-;WIP-U6dMLA2*fRZo9Usr(_v5?|NbJ&nbUMNs_R=LDs_Nm{ z?Hvm$NDFprAD;(lTer=xtuqvH(=&905moDL3P;31zZn-+<7|P8SBm|!=X z>7t``UQCaTTgh>z9lS$aqMcUN933o2Xv)pj=Vr3tn2vAUPCPR<$W)fRSGTdT~n ziD$ic|5o`vMdMawoXT+J1=8HkL|4_3bZL4j>9Ru!I1=xXw$?vs>6(72fBmcfTQgsW zDA8Zcqvm}=LZkYmXH$!$u8w2PTE=~UzP35pdttrx)%Rs-40bsfKl^8L5mfj}zixfB)fwYj)XzijDD z$Bes^O!HC*H)HC(c0uBsJEE-20E0$3S%{A>H~_v`1Z3;p{qgM?bp448+5JP6CKAS^ zKW*oYCim~QkzOdsANSg-w&n06Ig7&L(`Q!Rv<^I@d!kMV7{GACZF$!c96x)JT)&)f z$uJ+5tS|6FwaOJoJ!YU`&=#!vEo1jlVrTn0>A<4*J|1QT{nCfi^~YgE>mRyF@c`L@ zO0ABj@+VS)a8WtCWGGZrtsada`fD?90T_wGys8=g0~ z^1x=Nosye`_sK>Defd7+tHGgCS!*jaApxU6)PRfo{Yv9tQU6jvE0CEGIM@AWetv## zF7HyZAjsTaS%cF3N%j3=q1C!zA8Km$*&D0)t8#{_wGq#2nXt{5XI=ESNsbuq1O{?{ z%U5Y!8B1YM=&oiP+SsHle^>qAm=TP84Rv%r2Z+Dq`Eld!6<{j{FX0`43>}y(fJ&-# zBHQr|NaiZa${^&zhNNrXgWQAo5E8P)WX(^Tup8;=C=Cp4|2SIvt{%+&-F}Fe2E7#v zvST3}O(vIahI%5o@Y{N>SI8-IC8ZsneFO`Fh4ipDv#MUSFd!NL8eFoJk8>D4S_q*a zkyz2G3@#-;18D8meSjmj?(?B3lk6*Bak;%_@QE>^e?#L`qCVL7_2*8`r$@xdQ!d`B z@}+sIB6jd+Ptd}u*LH;s@sS)wmi(tJE8G(U<{Twg`X%*ZGcSIuelX}wx9LRE{m}K> zW+7R@r_9wPmmY2ScS{`s`rxIlK(!qYBA0B<{ux#hS1MOmZN-J9myRu5dr$py((TUR zVXKP_Y0_SNcQ<_#jViafLMj=BPHL6x5{6?tlr}0UN-eZwyJ}%1z(U47P$o+0-hJX3 zKACv(uFcBsw%)8#1D*J4QqY|wVteZ|MUp}}Et~=YtiO4?n?@Di|C#s6JD~?NU0tcX zzb&(}MAqu@(wv9w>+9R6DV)}blSH8n^aHa`K-BAt+@aO!h$1%o@PVL;rk>SpH55FnrWGRPs@ay=zaIPr*p^Z%YXVKpVOZ~ju3Euy+?XmCmOnN1B=kWym zdzp^doY=S^8~<1C%!a%E#?NH`&eg`x|K4ijN0xsFyYchCv$OG|_rs0LwDB|L#!+tk zNb>*s%{9{uGTM}l6#wUlp53P+eZKeXzjN^a@!d)OpAWsP{IhNT-v8_H#%_e!v6z1DumNB=$t_Yy$$ zY-A@44Zp#4OD`zyF5IGk>f?nTULHmVCow~Zc`AsyP!pn1DR(w3fjz!ZRB!;gMEE3Z zq5EM$c!@cUbCP`|d@C}ILrO=Z%X)jgkWYZY6-X8d+|jBp;1q@(9f$}*Cc5H1T>J0R z!O*VN)&qnS-Z{EY4bo|?@4!NCw`+)(1)66|&qq@frc!z5HG&3xDKvaiQ)7V`&s2wL>aLipm_1^^x(ZU%^CN{Fo z=Rq%l<6mdCxqoh!Z)P=J-=@8a*J{FefY84kUyd_k1}Bk{@(UU<@EQ0_N{ZgTU6grx zV&ds5qc8UPhD)?v;#!+Xj{GHrKF_B5^q2v*5SrTYU!Le$6cntR0JhZ=XKu`f}b{9LQ2Vlq5l@;dH>)O$pd3Nm$y}hMfTujVjfEBjx zqMd$Hcku|sOeWyMp`1YPsjGiw;o~mB9eCx`#kcGU1Iiz^NH117J74 zK(nh?j~pgB$`I0^f2HL`2zMZnw+>rv4dEQhSNiTCcC})@^wvm#i#1mL4agf{xT~B+ z=rkkfo+qA9&&))jm?Lf$|BReq*$jBcuMLH27|CW-2{Vi7GgZe0K!81oPPf^v{NqOq zC+R3DSwgFs-6pbuvGc+gDOKFwDfjq9Ps>dbS24ZbAQIb+)q$BEV;YokGLt)ezF@RX zcUX3|ip{YIPT*Jihk82sE z+Bnw%C`Z%JWn)hjH_AW^Bp}BJ(hE;m$Z#WK(knZ8F!99m{YNg!;UB>^j5`p%DJdzV zsK^(z?&<@{{7fdP}DGlpq0TL1NVX#t2K}yCm?yc!t5rVOqkH3t!AX6dalb0 zhPuq!qIpTQD(5(&0KnrWka*n8Q12iX9xfIoNG1SWz!`e4o5@_7nU;^HQD#?v87Hbf9f!qvceX zgSTQ2FbQyzF}U8jdp9~lm|}zCW0@S8z)gNY3@J-(Ak4+)MGBhq6kLwUWuv5~J`O{4 zP&~txImmn}%%BLyWHQs^w0psK{WO5IiG*+m2CkrZFw)Y#(BwvQ3Pl=-OF}|I_E#!g zmNDlfLft{rgLmX!oU6cuf7u@Ioil>IX}bZDx77H6M{@GleOUt{M-;(8tbt}=4MOg4 z<%&S5Ggzky+^FZ>aWOF`1b@>W?q1rjj_==%Z_^Dz%z1uZUU&hR+Mh-JgjfV1q5acE z!`63IITv^D|m>L zBkhhy1AWZ5Y5Hht*n~#{0L7W*xA;@wF)1l2xX5DTIwSr4N0G4wg25OQtQA2~KYpca zkqA=26g75LvkT`C3<%xHFAZ=8$lb%q$th7c_7?M99KR$Os+Oc8+2-KG6aM#cV!XQ= zVD8F;KV_;j{r86dI+^=#b`yU(-Cn&|Rqz?rU31}Kr;pUgZIKvn@d9BP;!cprf(+~*E5%v95>*pXUG6qcz zn`BRk$M@L519WtiSK>lL2X8s6BsRGM$0`T~^Y1%sI#+KaxNR7B>5><`nczF(BmuKe zGqe!!8R7O2!6HDgOo7;Tu3VaKio`<)x(=xSZ}M&$U@ZVrPz1FL-F5wUy$uH+(UVaA zSKG$geLd`idz?r#Td6ke9yQ=|j{X%%N!H zaWF%`tO86?5Gwy;R+$iN6O9z`JXWQ}s#T$nT0{bJ&q1 z7#UYF5S(z2_-xTUG==>#-!KDAOAH@=XpLaOKY8K=w$jK*!WVmOCtKhEHdzCXL=Re7 z1gSbWI0RKZj)`%DSOMCJ#@Ch1Zo%QsJ%jeC`+awi+`$lySYQD3tUpB-W_arj1f#Z$ zFlHJFScj|81`UHV&mJ}X8qX=8;36~=;2qXyB5-U5+89MGdw6tU4O?|3*!DpcvzTkn zNKiuy01pkgg0R99`3r~~NAZ&z7>3d)Lb^|u7GgWYrohk1i4NdC{jm{Zpz@@m zBZ+1ukDV}l*Bi}C|MEmq=7ZUd8*^AJy)YYgEiYhhH!d*8u@ExAs1Ym13rN57 zF^*scgQeB>a!1zl8@?!}u{@annFX>v_5IBQ6Gj~Hi8_pR!A|`C>l~I;)I4WlVq9+I z<+c7Y?pENQTBf6BpKy~_H;HxS<>g`RndK#%?bM$fxlL07DqW3J$o^8c0wPKK-J8!H zkQ$##oPmzEZ}=g;%OoU^+OLp3Q@}z7%M^I&i&3y*iKq>nhmrdsj|&4nzP?Tp-8iC# z^><4;Y-AtJQHS7KnyPXTRlD~)P(e>`*fiWdaoZ%1w+0#L^S3aT7c|i&A^GVCxG;xP z6HNl<1dtULy}RS}#=3k8^2j6;P6Q=1^mt=3##zq>d26x?j=}mQ9dZ~rCNEX2g5TGi zIbfB06Dt!zE3CB~o$?W4u5^cwSaXCPZYDy@3ezw<*}vKXk*H;#@MDpcZ+f~4R^=s- zTA1?;f#GVZVZ`CM&$%6|5W!{N5OadSM+|n2uDt%BQHwkA-A%&?$-m8}+YfuDVff4p zqjB6Hm>d!`F?h!#L}AQ(7+XKOk0tgpomjq!Bw9dUg7eVeZ-Nc7EGDd?7@kb=p`LeI!JuCD$0u}M&c z_VYF?i%6#&E)RUeX03809XhnXjs;P`NT3uZ?hmRv`u`3`ns92w@sXVXXZ8T6rGA=I z^<$1^ro-Ia=L#K%QD0&?wmD98S=R>rR`)<(a=Fy)h6_WGC^-p%3J3=R?z7DgUmV@h zOz~%elUtienAOuQHNcT3l^<Uuhu7qj1j3UAf)CE{t^!q+>m|r=96!3ZD)}hoCmP@|GelGA+oWydSW`JZ|* zFDFoY(grC~EBbzT2-m>eP7JL?3@k8EzWFAd0F23PydFcfu}_PjB~{mL`PYN%F3Z|F z^E-UCyq<=&wquTxJpp%GPEv6Aqlonobu@#a)k8;?CgF7S{W2pacp_VlbecT%xDp-} zRn5B(`@Gq8n)u@pGzs5M-^QJi*Z2#QB3PXpEE!{INd8mRvVf|nP<#s~f z_lujI>G7p~J3G7%VT^fn`6@^E<(OY}(2#Es6z-ld%~e@=WEAesaD%CaXuJElMT>1A;3-o- zxJb^}o%i1J%}eJydKB%A}=`#sd~g2J))wc ze@=uxX|l;H4P2M84mEugx?~KJd$m&@nl@^jbA;n{2gC!AXr|@;Mg-F zE^L1J1X@h{D^q+tp`JSj=yLvmiZ_r>9pv>RvPbaX$1%Q2Q;H~$P)H;C>E-7=_KXY; zezncdZHgQ&Ny~8&(4xLf{=S%a_kyggVs2OM_(VA(HDL9FG%5qJ*+tNAsrRJjBw#hd{y|cjotn1{^ z#4n#e^DeH3{r7M$=3iN}auA8Z#G`h&LES3#4CR=a2Oa;Z_hXqTVm{C7SJ&)I1AY6q) zZ!f7l!`qE}xj~l=LgKYUk+?;ZkaV6XbSWbYlthsm=aNM-}?IzoRKa#SsWh`&TOl!9yo$wE_Yz_ zHrp;3)Sqzq@dRAkG@F7days+eM)C*j2lEqK#-4htn2TCGKc+XYQ3b|kQr!p*inEyd zk-&x{YE#J$fO7WGo9A1J`Hw!g?{DnwyC#?N)A4c}Z%1Y2_C0Hx`b-H03)aKHg#AoR z^(A4m&gz(SH+%N8(@*Q^>V9I_R=CK}D7iCyc~6TeQ}KqGeM2+H|n1B-@GDvrMC`O+MzS?4ohYu;=Zy@(OFmwALCs zm~6abdR-=XWB-8eaZ3B9gbHkMi~gaIbO9Ve96dIbZt&`1dVyN)O`+1T^Ky?DYx4VA zErS_iGBokSJ?n!c^EVCQ@o6QOW8ZQ+^_V#%m-j(FFu_UlEkxqCc}O%tk}2HO)>@80@!K338b4BRZ8& z3>_3!=-j2BRVokbtbP*~)q1_}P$@595>j$`Dyqt=eh z!`RRL#d~oM!4=P=kDItEIs8JLJif#cQGO}#e0s$8K;|z98HhvbeB%&5em;Aso%6}- z$AV_$6mCSrp|Eda>+Nn{U}cJ5c9TrY1vrhFCxfv_aWtx4^4*7vZ{shgdfUo{3gwL3 zp3NwAk(N40-NEM6@p>~!-*ZPqU@ttbV#p>c#;2+tDJaGxZ=zS&S={}$FlSCW;D-Y0 zMNY1a*a?S$J0fqc0R;U5JSRQqWY>(|?;jf*F?lN47_iK> z4m73XbPJkCu+JXWzII+YfZ{oRnJPS{DQ@+zy^o3;(KuUrH;jBL^S27=cIyiDEDki3Oka^iU35Xdv#bym>UWxgL@=^au z?{axwV`F0}b4RhiIgTMFMpD8ww-itXTgRZ7e6mWNr@L^SkUo?%tix`K3?s)qbDlAIv~w%2uoEE53P(bvWpU+bNY<<%NZtc31L>en@2@Q*Z9b?6RZtOMraVyQEh03WES*XML^iza)#xWI z2^N+<0DJugKcSVHD&}s?aEM7*#6QVa4_dh>GxKSm{U1;&?W4!VpY~J7Gj8gW@luLx z|EVoxtrPR!HQD(XIaW}_O|OqP>^gehAhR7z3F}FkXHy)K6nY;bg9LTSC^wO$wgJUL z5~7+y`sin~>;qD+51*TYB)3mcUI(v!C2JzSvEjgw-wb(*)Y%P z{HUC=dGqEqu#5kZX1Tbz1NP)dg6mtWW-lW~1n?5nA*5N=4nrm*!VrSh9pEVHGF;`>@>a&@2;ZEwC^VYt@1Mc*6d4vaG%`Xh!aD47S+7@(nzDC|J zT@wL!G?xgjEeO3 z8&|djD7L@tE@l_iF8vYbrcSMh3K397!Lw&9h+S!K7e)Ie5Q#%TY)k6uja#T2K;Xsl z6n2m!hD#d7_n|`swa$kn4_3sTmkJTUHkR?9dBI*SuucgMHs%!Q-tnFQ%(KtoawYw! zE3V{N_|XB@5o|Y3E5NUWu-@9>Y+ZuAGcFmaJQyoyCkw^-nmY6KT;l{yN>&Jqew*V7 z+zYtxfQN&;0hkwXKw9f84<|;Q0Iw%^)wqVr)9KuO7ylxA9YP~)=J49UR1VN3A`%JO zhdHWB2ysq^@A;VOyrv1TOjE~7D5~oLq4Uvxh2PiY4NrghN;33o6{>5(kPZVG$%BH6 zA^@l*BB>I_{l`8GxX5Cn?-wmpY^-6KxYp9#Y*h4+2z3DCaoD^APBhqh>yB*=s>s2d z4*2L?K+HrLyuRKny-zB$W5&T?#GVgM$SRE_F;GEQy`YH)I0(0`qj3#xs)u8-s(e>bKf>_W9GmI`%Aop zOR2AqGT9YH$uW5&+HG+}yC!ot#VkX^Mie4u|w| zHypc*Zj3eK>i%9uqj3o))o0F-$#Q|3hizoxSFg$Q2ku2xWvd##E2V_BwW;ZI?!Zl( zHUajQ;>rn|kzP1I^>R-EQ{MT_W&ZmSM}dde9iygKe7h27IZN^XRvsQY2}B>X z3l_5UudBeeFpxUDtDARbz`pb&G6FC#ldGCk$xCpkkg6M)_pn{dN6#c$`~+wy;AgjJqF*pL2aLKyVgbSS54oCva# za=i*@q2u%yw3#S%v4(*jdbaY5EEta%p9LS3?Wri#z1QN1;0KIT&?PU-&hAx&y&y5l zLh}IfOtIOa`{Q9X^Iee^qQOU{rSXeX=@+6a%yJc7MT?UISkTM(z=1Oe*j>62Vi!Xk#ZpY!b#3yNAcyU8-lqks5HDopn|!V zaL&+>IN1X>AlFRpWnlQZRGdzONh<=dIURzbga{8ESPU9Q* zJc6U6*}dPx;0nGnoQCg10Ux}=%qhP`l!9r#qNJ6(qjh{BC$tLWSE6EdO zf;f>{gI)-{b4#I4jOVdvn{2QXGFxr8Y~H-c1w%&Mjxq-h6Tix+6qWlVckMRrti(A}WA=;krwojBOCe9Qw5dM<9NByQE8?%E?0yjs*Zi8LM$ zclVB*p!C4h*UgQXNMz_2Y{5~&vI#;T5HRkx+75?XiU{8ZJ5@waNcR2;ztwc!gXqBYV#J z=%-hVkedaPLV@f=X3?xNPQX3fRrbIG(aYRQ;r*D}2%+}y__zuY*aQnvIG#+*!a&cZ z=jQ3>n!wG}Fy+Pq{`r5$4?pwIdQqq7Ce+3f9Dw0C*qQ;@WqQ}21hea0&(S1&MgAyX zKUytv{;Lj1q)ESJKQ7=2cAWP%YBt&2Brzj-5ixcnx|NruY|{PZEGi&VX`(A!t>s`&I}Nl5Mf~oj|CR5kJKGXASxc% z4fZ=jH~AN&p1oQatOAFB<>xeG{c;X3q)vKE)1QBZ^ z4r@CFa}k~rbUM)bO~IA!8d@TZn1*N1K2jo!jrd6XD}zrHzkaO~o=P>9 z08$&Gg=;~*qgIrsJkFqoY0DatKq1Qg=0M13p_H=6bb(kNs9)ndW1azi9#5~AE*bU& zyyPx(5&Tx|>dM`1q;Q^Bl&7z)MLjY)H0%h9UBc~{ zlFnSiSz}auOW4ghUEdA_E4a#F%#hGNP9&tFPZeWfDT`e;LqzCZIuG(P?qCFDNba^rwdS0Vs;=Rl^xS3 z84!n?YpoGljS4XtH(FLmK*AzOb-^&$L`h_u5600r2IaWlbw%Ek!D7uWca>;nP?^{l z=H<=7j{wDsRIuAZr?r9oe@lnvcTl_vTM;AE3h>L;S)SfOV`O~1mbcbO_5N7HL#0%| z;6|I{P&7rU;?EHXFmATns{Vkt!PKbA2tE#1ge?JL>#7cwBq)B6^bCK=Df)EM9*DQo!M_uC6B12|rxS zGRy!U1_GO4tg9g*878GAo%Wu%8(pR7;NS=s95nI}Y9RCTqnh6vt>w|g2dK~gs+jNdlI|Y$RYQHriNXuZ1HchH|Bfs#+QF=n9h5Zmc^v0wf#r1{WWwd~B*wxb3 zp8e&uex4zes)?9PpEz*o44jy$B97m^a|foqo%WYOMX~=Nqu|zeoMDe7f2X&;HM(V^ z2S1!#{a{A{s6V$_V1pS4RkDoa_>kig7%xeB_Fz@O?Uv;{bTZPV*dCsSaH^}R4IibK zJPDfQdQx&S;f8iP;=eDe`}^}}tx2_-rxg}bY!{D4jaMH%IC1UA65V$_EouXcku~3B zhJzpQC9+~8^~FPgVM(7Rl!*s9dFG+B(>AER*?xK+>@Mh(AziUKal)tEjf=JINRUZY zc{DPzzI1EpqN^cplNt2|VfL%+#KfwgFa3c``RUU+mD*GHYNkYC*=We{SzX+DE}&|y z!x(92{AUvDFi`ev7ZHzbvT=v^EfMVa>-!su%-Vm)E;l-AGMw$>5k<4y(725k&vjdB zwORJpGSeG3T(XIBRr}SDqy|mYKq&JK((_j=tV%aGmU^x&S^JB^t%&Qq4VIp}yz~2` zR0&r=AOiQDDlne-=C%gM)#RKpq2xEw5>C9|iJiKwOgAR8Z`yriyMN}$yBBqwGj%mJ zIkH6)_s`LZ=LE5I{N8zfw!TTxN&0Y_ z+^)uL7P>P>llYi^&(GJ_)lrkmbB2l^umiectNTVs^g$A2v1!>_w|B?W4PYa1^!PnJ zeO2`RC{(bBxP1+gCdTyhw=q?#t*Zmq3yN{nb8j)_eqV&9bmAI6D8bv-wqo2L&Q251 z;CNi7Mi^{Y-sagxr&4jz>&1w#|3$4&buf2p^V{;;lEuIbFgLgP>4XBfqzS!VMZHTenI$F zXXwLu?zn(}04u9c{?ea6fAD0r%Ob-OUNM6-2mYOYvP8jG(eJ5eKCANapy$s|h^P9l z(WVkKuL;*e@?#|o!Xz!GnkSrBKW^K-U(T|hTFJ2Vw1NU<6L>-389EAb$wAWB@evsF z%`HO9Iv=rQf@`LXmAtsP*xbwvo6|xS+XG5SXJ1zS$v&ibdP3H^YW9~ zM#a3N7F7=cS7{mi@EmIZP!YH4^5vO)(fiV`D3s?3;w_LWBwe~(Gbx%CfY{5E4f9=1 zh)10P%X^?52mq%E5SExT;lXb=do#zjmDf4-6U-X#-t9E{4ywP19%L*xL|$?0CF$lS zh<`(i2{UIRe;a_phqWlPEhn^u2}KoNcanlrz2-R9|JLuUK?M@dW%=4oq)D2hEGZ`` zJ$fox;=W&bAAA7_YeC$w7M;VWm0&6=aN+sew~O!<;_Je#Yl&+{9dsgOt|Ay-<8x`OSED{Br>r{v=M6bB- zkt*jh9&9G?NHRgLgl7nqn*gAja{o-UN#sU#PPK~6a2(98`uO4}H~0I>OVma$_oi>B*Qim@%A=69)eS%3 z#s`et4WeIz#{*+y^w>;5cfKs{1-coXiF=dG&@DVU@WZu+A6b(PMm#|<=< z2Ty!)(Ax@oLO`yC7z43N9w>`YJ7KXzEdJw}!^~g(BS0~@K3+($M(=NcbGiE#gyS~vvscRnv9xGVo+dX6LHI4fte!lR< z9v*NEL8Nm)I|w29cNpBE^oqNVZVC@&eVp|7eyL#EAWL|-0$-o_0L=Ui{sI?qWrQGh zU0R)*ep0mX%?|Thvs&=iu!MnVdNimSyWkJFPSd?qLzV!J$>jNoO)b6>-=Wn4VjKVH z(T4$D%Z4Y>y74fuBm87O;6oAE__EFrcurZo)S+aQnIpGjvSlGQ;h1`M&mfJG`? z)++%5<-v>PYnYZEUDyn#rPQSXlRi5Y6ZVpF$HFeC0%G#AN7%i|Dbg#&{i;2gK!)%Hc*qz z-}2DcZv&wPadm{Q(=kd4l*nKv$%p_#H5?gvI{(lGetv!rD0|?!+yQ|*t}tc*00*J` z0ed>TkhA-s5k{2$~4sN(@#IKJ?0k6QoM>nn2p0*v=9gP;+x!-*RM1)tL%xPM^ z_1I$20XF0-v9W3tH=@trKJ-HR0SXF4)~Wx%eOa%sCfwh*_YrrXy?PlU+u%5l`?KJ> zHkJ*m&v%pwXIm8YrxXAS%z!SaozLj-<=H0Gk(@HWNpxx8z=+iAQNTB1M9Mr~lpA0j zrr+TuY`8PqbT6d$oA1ATN>E>KvUN4{w0ZCP?I{(BrT>=qo*c_v{c-5uzoGmarnmI=OhMhQ z9NS-_S9iZozk>S(jKyTbjQarrED12}r;u6nwym2Z&ritcS&bU`*LM-Lo=0wq+#$2<@Vyjp>VmQeR z0ZR@ww6?I}BQgYG=(r&WBo0k3|0vJ(rl!&ttzm^vL|fK)*Gxn-5^`ft)8)E>lGI%4 z<#oWH!D;qCNg$=fUmf8(k7~0Qy!6GRgM-%(-qPx%;2btRSX1f!(`<{-CyRey7+S$8 zja^qSsdW~2)ncem80sJ}X?fwQ{bQl8-6W>GO8~U0G=BM4!UzvRh zKr3_HzJo~U`(tdN|GX-AvgpPKD_Dx%aeJ!VtQhV0?7zx|tb#)~?Xi&cgAfy-v9| zBErIS->uk!<1WRSn3H7>fTA^4I3S_w$~ll|%=T&oSEgSGKE=jqn$kwM$@$KIC!Dzl zH+=KT6CeeSOZ76`_1W3W-|Uq>4hQ<{q@N7l}hd%3MF5sg^!f_ zv46)Pr_CwBS1@ohs(jOv*ITx>#RM}ez#@G^hNq%(Q__pw(ut(@8mu)M1x>3gXdcxL zvsV+4B=H(8p^N;QjxQi_@fb;ql`!3HpVen7tlzNSt=_9%Jh0XN&4t~?3flVbF>cJs zQhl!3SD5y(lrAC+c=*SUCk{~ldmmSp3J$Y+FSs6t-@TjuBDlsjKgFjQ(a^Y^p_=&h ztI@n)vf4RDc4ruQ>n6<8?B!qS&MI@CP}d00VQYfmz?9gdT zOT;u~wxi}rX|>%jgt_4E53qD-%Yhq8k2gm>e!N#C48*iYiTek7PET)=4$!wmI}GTQ zZy4VbFoVJVokKZ~bqymU;#%QtF z_(9MA-1;d4TaIj43n81&)WAT|Efi3zb(3FET59UEx_Uo5IpLBIvgH3g;&RHj0k#o4F| ziRQ~;^scU{M>JI@hkMcY|HayShhzQ!@5Ajhh=v9tqLQ7=kTNqu$lfb1lr5v36+#H1 z(6F*1TV)fX>{KFqW{3RFm)^a<@85kK_i^9%@3^ny^T+#Bx?I=m^?W_X`8dz>v3^j& zEcV&tm2lkAJr5?Y2h!Hfc})=(C&UXeB@dG1=un3YLs+yEp18Vks;tp7ECsRbOM_#* z9DsxUw>$EUu7R!4pMSFkAuPzv)RaOlc$AZ(&=dr9@@4c@G8g1QmikG~eW?%Iq@_u}RI1JaJ!&+h32$oE>8 zfcF6k+xcjr85pLAxUnx)7i}^L?KD(pQU@<=lUM#e=VN5+?&=ZO?alhe*S7i?e-91{ zVhau};=zj(Nvsozd9MCg$RgkXFD%@brz)}%XwU5Ome6QGG3Pdx*Z<;|TZo`vwZ_k< zt-}zVwB139(R?&$ zg!w(oM?jcB9RNAm>}@t_o)$a{FIj@3IXQL-AErH54vt&LIB{6t^zFLCTXKC)k8lXWMNqLNfgDEG22!6Jaz=DZBbnRx*_**YUa7xLK6CRzDo+}d8# z>?(rtN++x?=lrF%)(Tk;kEhGQwQH^)%!%Y--Bw%WTFTQx_TDH1qOIrY>G$`#c6srh zg9!}vZ%o~BnYy_nvSv;8ITK~8Lj%>msF`I-$n)mh2p8?h;L8g) z(7ZuZ-F%n(Av_^ie#tu#FTT(pR?R`-8G+a1j$#ehmtZk$>4I5BerdgEPQJ^kx_!so zfLsi3D9w%LSB>Fo<`i^vXcq-^m6RrMmayDu9kV~O-Hzq~x!?z(?f1+d1i1I2{8nHI z0b+1M**6Y?NeuhcmRn%nK2N)9k_0;x7;Sm<^Pu3E>Q;l>;fp}xak!d0T=jYUf|SjB z5}{RwD^Pgt>I4jX*?)nYBh;}z1AzbQ8ye_&EtDJ_2p!iS`DKC?K`Vw& zB?f6X8L(g$qa;Xf*|EW3oKN3nDDgRrt9&egy`QT3Mn9zIF{2hX?SWz)!=5I>(+Nv7 zZ|=!h;)e*}RDgjujt z;3tKa5F69f@=suLW`SY^RhJ~z;ZpD2OAk~LY1`&tKh#*Ck}n2&|HBRt(lLiJ3t;GYpr_SGyM#|Y+aP)^vUs5{3iDY!_vK|}F0QT+?E`?5YaM|r zF|Lh=hX-VSRwHh>5~!3r?zw!U`3xxmo{o{q)9Fm{nCph9si@ciV}WF)J@ApmKm1E^ zNSUqBF9IMN_6`mYpCg!QkccI{ivLR3$HI9jSPIA3i|?4RZ5wz!B^&c)q5%YAt^jR% zT3Q;GWlF+h2of={Jb$Nt*5+yGYGYnCASr=mh-B2YtoHX}@}R?6VFx5qZH%UyylpFzlWR`O>DFn8COhHC9X9J{3bb!$RNwf0p(?} zNCad7@KzYe@bE)~^x!2WJ$-}N0Vq~%AjWN?fx~l_G@2yl!5@D$iEqJ^h2|9yKSTj( zTG=)2{TcW#LJ8S@!{668lFvHwRh#~5Bqra_lYoB*woX{UzRPA9!?Sf*VfRtis(n1E z0S?yZJ)dZ$#$a{!w1rG%}E)0be6cW~8-04a9cwrz0N!oY$x6vM5E z0tDr?NPXbI0TBDLwU81qo+@*j|07mBf4+frK~7d){*_Pl=LMpGdYpgpLdBPLd$!&w zH@cD~;O8-RjExOLL{Jz_UK>3%MA6sLb<2bGdr6&;Rge`=goBVSC~R4->~vx8GK9;c z_;I^(#heosO-&t~NB7j4BI^@<=kMeg1~R}zz`ecCJh5nf*@c8U%8%pkfKeJQLZ^HE zU{?Wu?REG7p__Yps2oBl@N4U?Cd)bJ;#}qCwsm%Dr>eVMy?Sm@6+tdNP%rN2uf;_< zLJ$ntZlm~z)t*^Z{VR^Ea84nnKA7oGg&hDP;=(g|!#l=&*_TGX5y6Tr48Kyp=xTG{ zTg6CTAG#s{3ZR!g8ovvPGJtQeC^0YtcsPNfP55F8Ch^%=ndOa>Z+Id%nAj}!jyix| ztez8V@MsbDqEz$W6%D-O}qmbSjKB4Gl!N1<|A6b8D+}#l8JV&ZMuRzXq5V zJrD)-E?pXY{naxHutZ=#@^kyQ7HN792S7r%x4^9Ay3z&p>_t}JJ#?j29U3t3KyeG^ zQN~x*g(K?^J_4~`eO<*S(m65S^gt^BC5RA!YsM-~mW*Q^I#x^R)~{1gELRF}V=&Ea zTroY=T-zOZ8J_Y;Q@9>Y5ARtof$42h3uV~l-1Q*(6AYi_e%gbo*!TJAS(rJvf?&|w zJ$a1!aUgE?`Bso_$xa0gp*P`==e0nA_zKsae##~oU9kjqN2a?5RI@P7nwxVV(L2o44@XD?U#{}Nmv^|qra*z!;nMHx&Iux zvEqv&M8XeZZ&Qs^*37ksR$9FcfMV5VQhu8k`bjYPfD}i~VDUw%sNu7xPbV8fP;wfT zJVC|@2&{a7d-fH(K~mMNAR~17rZM)hjGwu#^&wCsur+e+e@|@9h3KzsT-(sF8@MST zs`8cx70*Vl@7=Sf{S_Be9D-`bu7-9Om884D^0+EjT`X&oZdaS2GkTB7CMX>E1PTg8 zV5|XV#t1nABZ|n#NIX0cxrxp(un-koKId7^Xpd&+me6~jW6lWDIPY-R7{La)Cn9BTcwhDO>C;g8 zy6nGr`t+LiX4%lNFrh+r`n5G@g=l~%vK&baIeRM5_}I=lKuSuQ^W~h_iNvjqM3P>b z8hy$&SY&YIdfTa)S>*@Q>JqzYX-^?8gCS^_c31go%jA*c+}w@bsq^yBnhHa0eFG!1 z-^P#)_HMb2Qg@9~mi`p?A*L@nL~P#Vu^XzxCAeeir077Uj`B^Qpry7p8N23?7GOw* zj8M33!|~B8^ATPOR5sUKF@Dv5^+;$lFD1!8*V%#&tI&8K^|8DWdf zM)6$7VRihlM&Joz_kkgmp1OKisR#)}K(H3!-GSmZiq0U~BfrPm1O!us)~>H`8HsKl zkR?<$C=+ioz`Miyu*g}Wwym<@J(ndW8h^8tdQk-|B$M#4pRUcLv3H0N2-t!28cF*S z*S@xq%_tlYwtFo$ZHbz?L2i_-!A9ZN<&AFa`piT7HVQ1&C@bG}BNDTd#~l}>@2%Q$ zUAL?skc&#>>S7~fWJCe*{vp-={jvcaO1Te(K5I&9qjW6zT+Sd-q&LoWOBkBiWRfK^ z4hAvn!DLWt1cu^0fMGhc6ni!tf(-@cK;UN^I-BS#5o1q8JAr#jG zt-)vxFC2JS?xM!?ZJx{%I5)D>OY&yZbEGplTkWyd(8Z9TLq0gYe_XRpJ3{F&Z0YQ^ zQJkPkoWf6;7ENG4K)BR={D^nf($L7oIAX8yDY`GlmX?-?i{9R$(m^4oE4%qHOy_o) zKm!YjLoV8o`FWR|QfNEO&a$xxgk=ZJZmwZg>Ar65UK)peqIRSf z#Uz&^lNs`F20PZSmso`{h#d6c>}RrfeJtS2m%~8N6*LwMV;vf9yhE$eeqGdjYR&xw znHm;*H01!7P?CcPe3PN9qGBg*02PfGSl&SIiC^2LdHHfW0~wMOAJaUsv69aX9|&Gm z>0iv}_633bgJp|k>te$uzhJQd1q~mFn2mNTf)oHilsBFQe(5LMi#%ExJVtg$GrJV5R+)@SEjLEC4p+l%%vrC9o z(Y&biAFjTeFpN*F1XdfL~63uFmw z8BSJ7%Y4}BTuwc2aNc~&F*hmH+h{9CU-y83ePqso@5&JoZz9J2V+nVC$VPajrhBat zo@i?=1++1U1ZJv}#<(KZ=ltQ|0kLNv<2MitOzs$L5TFk5zlGfhY7Hvd+(U;2FUS!xp=~(NvjonX=zEx z3@ZrajaS~Bk2zIcA@h%#185$$K)Z`a zcc8&(0?t{XZXkG(L}DYl)nvW26^t5T8&K;(hFe8ITGte@k_vnYcpvjbgmLP}1O3NgiT|%W#!|XF-6kFGkAL8{Qgl!I~Fhi5vb&$)C;qrFvi~ zM*Lc0X&NI4cV?!hI9`}drUIpWxp`UyB#^kHJBiy)`W_5m^rxbEz1>rk^Qha9U(pI; zg;rKOzzjm+gemj%aBD+xPQYiH8dQnDK<=6Ok9Go}Es}XySPqWom(JodXjCyqX!{g~ z2e=9KA$UYsGD{5%C0=?59yPusz-JK1jCW+$dY;+j{PQvPlIRz^yW)4TIl#-`dIIBU zAV$d!Ipd*H>%2QX0+AGry~Z z3PCB$ojOZ6Bd@;REK~jOvrIm1{y&aTtA9=T|GN|Ae=RTnKfU?sXv1u+e>_%+&vu=G z?7u#ne=HYQKlC@z`)Gd{S&toio3Z)_#7}!gAX~25R&oRonQ`^?kNMS~|Nf6N=jngP zBWq6o=a*PAB{!e@AJ5P=8^r%N!_w72|L2YW^N$|?s!BGF2POvkEAszt-*emd!-#3q zt+y7uLo>h7_MG}V%fvX9;}}pY7@H@mHWkenjmNTD0GU>u>PGdr+{1fa=*U`saHQ@6(oWFMERU0#O~L3a7vk z4=aGC1E=?t;ol!RebRLrXfPvVa3*79i7JVoBevQTGF_>A2DRwIVG-uo zuLqsqZbaKK^gq8KxajHq`z|0csO>&}6#BI0SK-JBU*+z+XdVJe7*?Q#U>{hWR0B8y zTfgukDv3wtfB)sl{k7RiEXQCfFQKZ|!!f#x-)T!hxZReU49nv%0IMmgMh4R%J+Vum zNem|c_mVa*MP?=`+>)& zT&DAvx_~uGSqlzw)JqXaHMXAhhKy<6_A)EM7WGW(Z8qa z`jCl=)3usjmjRaW+PU$s4PgXQdie)FU1ou(%f#cX{p(pHzY~@*uo1vuZ00n&pa1*bD7YjlM8Mz^#61}d_m>aqBIo!rB|-@#VqN9)0e zV7g1P(bK6OrD4_`B$6=Bukn~T$9iL^_PxLV2-!QBm=Y^v2DhR=1o&)Ri>_F+*j=zB z2`vjC56EQkJK?CueAn>rDR{!azqvdzbJ3mBltaauw;_n(>5CokpyXv?ii6t{hjz}ru34BJRm5UG`R7GA-Eh1&7HPd0ZDA40s!|^#m9d4KcD0Jhv)Z_ zjNZpLTY@rYfu_mZ0wQf(1+(z;F#RIGK{z}4HR;%u23R45CbUmLNZV%N@{I<^Vu zWAV}7Ui$YFK^T(sqW2fJskg1Gt$hfQPn*v0)mwY$ZVi$=F6nCLp-8^{KffGUp&IrT zBd6~NX-Fjz<2qvV2`p36ISep|Ler&c9TDxJsVOD3NxQ19P6Q6V(1)G;%}F)6(jgy7 z%>Pzj;N*>2S5yO@X^1%CJ zY>{$cQ%)>sdUW&OH);%b*zr+XeOevGlR;e-Mj_al;5c0rK^#{TnJAjT_qN-{NFOj#X*gMPCD;|Cvh_Iun?tq zwF$vQ0o;cs2S8ohWAXKQI-Y&+BT#O%+YMok!Hs+`==&z)Wp4yP0LYn~icsKF;-;^F>WHiF{B=y-S?%(UY@NQ^4DB_1(OZ(Y#%SNSR={O`!1=T%u?K)PmU*<`gw1JmQpaAC{FRE&{^p z@OQaNOyt^%LUi2#x&rsMhigg8{)!4%P~hszNT4Mzf!jo>zqGV8@z{`N(^FDX!0P+# zSr4fA@n(#|EO;~L4ky*G4IR-ND@Wi_VA3=%l`mi?v?+L3m6qNbiwUsD%n7!R;CUL+07U`j4B`P8(!H24U&b0zkR~tz zjCJjc!0Z*wPC#6Kae!5TB`7H=0g8@w`R)Ts*QtrkBN%Vi4QMU$K?uym_;N`VNM z)D1{twiUDVAK9&X!(WODq~bvxA~9{fb0)rVdaP@a_+!9PcS^4M#6eC@L4h42^WdZo zV;nd_DJZP;_U&8r3DlCxl()g2gw4r1Vl3JGJ`!X9$EvK!oX3o{ot`g>QnBmie=-c( zO9Xo02?7P(g!@RE-YBuB=Jn<7GmEuQVRt@1Pj61YkzjX_+Ih@7-`Cgc6}oWOpTX11 zz^ML86xN)27VuF*PltXEs3{7-fd=|NEfoYsM3KI--W} zZbQ)dq^W@fgr1nC*)xH(!%2^YLnkGJM9T>X-_34zL^CQG4Ro6wFawwsGmrr;LL$yA z3CAcl*nxU>VgmMuS7!HC&X)Bh0rfr!8=OEZSSW*`aD)+R{_EG*h*Ls_gewN<5_Z;D zSwmp=^qZk;8Xq6Wiq*kESqY>nOr~C?nHoC}O_1hbCk~FYJPQ+Jk=_bc>}7s|&D-gd zT*r>HvzwkcRrAAcmg&6OSlrUeXo6l^y%rZ1)Q+kVQ2}?MIt*X;dp#AUumQO!G?^`H;qeiFv;6$c#h zQG;dj^`ZoEoL4KvswPxgkjQVn^Ug9G3j{zpCp1dvsb`N97ZAOcS=Iufd7 zAO&v_$JrG`9D=6hob!46!^^Mu0B7Y~M!Ey@oKhLnKQNW#B;b70m0>Im`&jG-#-ZgT zBN8ugwsKn>UtvUh#Eh(1Ed`bf%F2z1VYE&{X-8*sF;oUM7DHnEj`mlVE&vOIZ8Eqi z#Mmyh2U{kZphucXU(4J!`?CR8` z1S&Mtdh}?J#+W%d$01f&(t9K+2cp~_esqW8lwciU#E;XfGjgy?Te1^HB-p?}r@Xu> z!C}Q_rrFbW)pp|;Zfm5qW{H|&l*K082NlU#(_LS!vb7@QscC6RVh0}VNuz>5E_nJU z&S%zx3E=7YfQ~sQ@cV7y1PYuCeoiOMwhJ#*kE&h)J0#`Ww&Ab(Hs1u`#sk-Hr^}v5 z0-(45xzTCZi&v!AaIO7*?GLah0bL)L!SUMoRAsQc`>k;%HpXl(LX~_yKM5to(zXSZ zM7sP8V1n$9;*?D998#Kp!aL*g;5zK$u{fbd}+l1G2+wx(+4EOdQ$8F}>ckY(J$jHCp~7?tGX0z7M zI%_CwH}HreP=o=);aA^{(J@=XRbEUH}PAQ!1vCb5!)qV2;&?a{7 zI z13G!*#gF=9iEx&Cu@|-rhoTl;PWPpEGKj4MY~t>C2l&Lz zW1dW0RPVU>c$6HMP9!sES2i?^gT>U85r!6U2+K#%qi&d0#@91b3q_8>e7?2R9+u?-?yuyj z>IJZ(|6*K>M*$57th^97LA6ETL7(2e*$}rAt`Km2vTB9M+`h>J6`q*3DCW~pX}t5Q zl#-N0PVgU{!h*nrMcRW0>!S?m#5~;*#!tC}vQUR_q4~_12wo#h4ggC*W~sG>--0j# z7QVwDlW&0$@AIML&q0wly+UC7eG%p`HJFs_LOTTf(&TC23#p{B)j?; z)xcDTRoa%Dlsk7ac74LfZEpejm7mncP&9-#1P5n7+y*#%K97uu<0nOi4D$Bm)$$stURjcFrO5E?f*GInEx>j~}mP z2?uE?g`a~avhpOU?LK>oPF_>DXjb8^jT%dK2VV=iCm=;&jW~sHuCk^fgW!nW;;NBORIrclAw=4qm%z$y#W^Dx^YT$en4=>S^T)EVhu!c2d!x*#xP zKNp+`rY~psRH0x&$Cl7J4e`Pk-_MFQ^W?gaQX<9!AacQo%f+srZAB%yc^8ZPH?QLm z%Q_xJzW&P}C>q~1UT1P*NA4nuZrDx5{+<}PpjgPjoT9PJ1l)@Hc})citKtb&dsR{yitojN$9= z3SS@5=^YD8gPTqiqv#j{1L?-=v^dLwphBlnEhkr1)!k`t^5@=$stIs~H1OA4ol@2u zlgBDTA9`)kY~~%GP)p+F{USa6L;JVNqyry*Ju55uJ(Gsg8ByD-KOGHr!SX`&c6!lA zr%IYkP@!u zena;FTR5b@%RKjOITS@gbZTjuQMTe(ne0nr&Lo#c${mB4IaIr@dq=Hdd8+FtUipmO-z}WJyVW8kby+3rLDR~E$7HI{7gjX=uVy}1B=`wVizv8hY< zm1O;JUcwA#Grz)<(yCi`6u^Bo-0YAqZ2WzXbyFT&7rn=zS>}b8=wj@?WOucTP8Bwk zr_xf-DQi~#f)uYHM!l(hLO$P%Du}NZ9OsX41NM0VzZvub-PU z?h)AwpnG5nup{k2k3!80QjRaI8CJ{iN1$5%ro_fo4|PX8_853$4HnQG^q%2vcPDZz zP_n%$X)(%J!nTqoIZ#C#;h>5<0@u&`_rG0!x7BF_iY3DS->DixS2SZFP_wPB_8=0( z_mjH_`OcpkiV~;akppEt0bmc$5YuFJ4`SJOhV3H81S3gs?lXV={bDf`;Sqn$ZD+FAB9u`gnYZKB= zJ!;vktGqPuy)iivCM6-^9XF8g7jf>X(HsDx(b|u+F`l3Zj5a;>1HyyVNP>0=rOzLB zS)w~>DtQckG>1+u_$**Z6XPXNJSu8g)~xxnYwxsfwt2(B->JDMA}IQw^%CZlMqqQ7 z9oHmCi@_DLy@k(%9Za32^o0s*qNxpf6RrlR>0;Q0X2WYZy_(x+03vf$k$BGIWd>Xm z9!}~E6-VfE-4}RD?mHg6z#2*st0Yc2PboGSWU_@~SWa@zS#gUOvT5l$rd0J#l$v|H zmG=Y`J4|Up>1Et=_SVgtaCOr3;}_i-cK9rVrv4JP79*N>h+SyvkEFY%yc?Nt{MQ%E zVXoxFf=@dpHiTfCaR;-KHu;R9Gg!1KSE;c!+TlCorvD5l9pypZ!t&&(MU~y<0E#j^ zzx2MKZzuBIPq5Tvc#?unGKA+m2*%=~?VV;I6woWm*z_KtII)d790G}+Jz3;yMmNn&Dvc>0$ zq}@<=1ipb}$hbs-FHR5(qRb=tn0WJuva-t!>@v_n$45L-4%aBEg7s?qT~W3V(1u1g z>>4RnRHNx`V9Yc5+X}W0;ZBqPQs0y+i4hu-L^$DL0Z71Ax&rV@nfQ%St#ID-)=HQMQgB86udBeIb+Au zsx6~dz-m?b#y~Gnaa5Pfqpu`&=J=XZPm_v@O2P!r)2`fIB9rAnrW4ur(~4Yur9c{m&H>LM zY-h7p2;xRP-w!q+c>Hb-4L#rCJm5?vfJ75R&K6qxgW{GMj?(n%zRwpl*b%uyZ0WQF zxbDhFcZ0oyUJ?tR5iW$HIk>o#;v0|d-pWejFpD?MD)a#{QXa!CntTCxD`06DRx$a- zRUNo7Rrn40cWj4~z0(356Eya~MqK+b{XoTNfd0o@*0Q~|(E@PWt+5W9?)2T8N`Fr_3!55r+I@V|naD~#c= z_!2pG;`vWPz4P>`s$=(q;Zk@@B*AIOJ5FP)aTpB>irYt$VW303%2Zz;jaD61vPq>w^+&40thfneF$ERXoqqA#wk-L>IuLjyLETp#~}vJBmoc4NE; zc>BmXh&SSt6#;aMxT*y!yjUgd44ng<2mxfH-2RMIqIYU7p?pJvI~`s2>o~w>2c+^5 zs}gXbJdVU8M|CF196lx=^+#8>5nf@3PMy+;ybg~#LrnS9nzV;dxnSlCK{@{J&w@;J zW^Bjok2DDZ?QFKB6S&NyVo`4AJ+dd`?N6H1A}ED3*z9FKF3a&TZ$9S9p?euFn_U;^CTeTZxw4O>`n zupJN#zV@enw)1GL=&|buiohR=tGZZ(pXkEnYs_bu-Qdhi&toEk-H0lQyEohjRI<-{ z()RY&=7v}?u|JR^ETdP^yg#5Kgdd*cXQw_dY}ajchbh&qy&O9G@CL+GRutew8w8Z>AFGOH#@2=xh`#k^}@V0#e@3}G7u1@#6)K#%n9 z6XK4vYHO|^7#4m1jxF7J00$-R_+g?zBVM0q;pjM)hTUd*gTkP4nVlVJY^4A5cR&ou z>{k~uum1o4&&A*#Fss zOm-8TfWH@H&46cU>VMf(|2OX^;Oa>Fgf&CpArWqTntAMmg5)hWeIwXT9{$VfSks(O zaD}k1@nErN>%e%indEL@Q8n)BdSgp9#nO8mCSYiS$ zWow<%c5DC!jsP6Y~IBOz4XdPIyfr>}Y-)}cW4{rv3 z4Vq>}Y2_Oib<&it^AV3}4N|!7R}&OT%~u~BzhZK5Cmw11k0#(BgNQY`3tc5z+k(sT z!#7WM8EgaR7|!F^`V62%ZQ(al_P-xqtGQA(Az%AcXk=tfz_)*COf8#7L0nK4IxWhhd>x#UEx!Sc@zNj8|0peN?Y;X=H@oOph55q{zzO#`Wg3&ILg?d->g=f;Oo z&_PiJaZ@UYbLB?Zgc$2khxc^KI9UfMTS z(JsdT|_zubn%0VzqG*<`l*CV6PF_!B_`27NaBw8wSPJ zNC(8?gcH)9qOXAWE6U5E;YN{%iahJ>^Y^!Lx6a3q7C-xahbfSTyxFRj0{aAe;U;c>!r+2t2?B`^Gfd^CL>S>V>Vs`dRH;}fi8jA!4{CT6mGxl+1p<@A ziDVeW5)>2IU!u~)(%kQ;J$jwUbF}z{~bEN}7Dh|y& zIdSX2rX(nNk*8dFN??XS*o(pHDefq-gqGrL1#aRI7M5a264>ItgOKF+1?UZ^HxP8EP0|N5LU z52FxFaRJe_r~Zc5wEDCn@kO9!3<9USQtbCzMlR*ukPDm*z7c^X4Cce~@R5srjA)G# zI$aJ^35^eCOqkukQ50HXZwpuhkPLwWH?0kZx@BdlUkt+_#2aGhkm+~wskD!+39cv{ z`P-|Y;^g}Jm*IqpAW7y*`(L`9tP^=p`#W6SFc==4n5Jf#1mZFj!GexhGDw83ihXWqb*h z55Y;n=up>O+a2c5Wizz@a*RP&8(By6ZW&F_K~%-=vS0^q2nz*?DILT?s;(zo2Bi{e zQA}Z)?G51XQP(VBWr+l(qu39b7hNV8#Z5>GNOYL725+A-*#@RZR;l(fcnYLLe0(jx zixd2-2gVLslMV?9{eqPN2#u!96kYg~^<&=F320oiEr!(oxx0^tQcg2YA1l}k${L!P z6fR2u62NCLlA9xu^U4shDl_x*t5Bk>frb2Z>55RU~OL7){-3Fy7HiB^1W z-+u%4tnQfayECCz1It zlGpWTFH$36f^q7JeUW$$m8i#a7!#mxUyy23{g+>;`t;WO-nk?B`LU(naj$VJKw1QT z${LykP!Vuqt7Ue|HVr+!n5-MO)j%-pHznO&%UH8YS=>6Cd^CzKnWzylGq1R7-1 z*wb&yH!Vnk<@gmL5}6yc4!%qzZ-ma?{(j?sFPGBj0T0)}1vNBbhTlIW@;}+p)xW}gfos*)3 zU0xx+1EhKq4uFI~n;ElqeFIN0uBc>`rpQlK)$;39)f-GhHbY_T>kSAVdDZgTSzFt2 z>>hckkzoXq!Jc!Qpu$G%w|)sEgrMzJ+1S0uxyqoSIKW$F*qMf1x} zwRI6Fosf`qWEgub^P#zGpl(x5;NY9DIi8m8-^Pm-u<(g&v%PIAy<2ig_HRW6j_}W# z2MFkLIq9gEa?&9Lcjj~X*(;mFg|-g&VX79c5~3?h_Mzt_Ii|AFs) zyZyhulw;dDe;64XW1joTa>k3^Q^tA6$`W>;xA7F{+X)7WOY*=6hpVP?L?ThLJ2F52Qt)Xe;vfxuJ z6~D03RxqA>w0`v3H*+(ZBK9Jm$2Sdp+T6s8oQ(M^+ICt+^8b+fC_gT>EB@IFZmD=5 z<_hWpW#e&1>fLJxqn~ng_RUpJnR-T9b9ir>p7QK!^-yqnKALrFyLr7}0|;r5U?6GH zf@sEXGxF^l14ReMGozegIvGYAt%_SPnnTMBu{?I!@?UC_cx3+(GgtkRFS8Ux%Eu}@ zZvv;78|@5_j*bouZ3eCA$`SbejNkS~?S&P~J_Eq({R0EjunGt7hNxpR^wXoA z7vV={k_k-a^Y|rVm&2L?{lEtz58K(ti}V+f4_Av8koIj??t(o(ixdM&ITebj483`Y z2SGs@aI4L*Ey_Tlu7`tz`n4x_m3-?BNIFwyKNYG`2n2{_xThL~g@iKqRK2j9!sh|k z!NLEoDUx$&Xee&;97>1TSw1~;jCv44nX`UYd60Oy+(DHOkRVw~ z|1D}ug5(Kb^{^g+L{-aE>JqBT%9DWO_P>_P$}I(>#8)^-=CE_u7AkhE76RI%sHmut z_+ZBB9Yj zkp=XifFd5hq`4hD^y;=nGt^en+M1dRh;>otw=hF%0CK2s!8lKe_AWtT;T`+97#JDP zJW-N4ypwRm_doZP7@n1F+MCSA0W{ zM55>ss5j|F75o`2XvTc_9N-R`xvP%|z8>v1GK2LyCFN-e^nt=yA)MVz_$EN|hV65+ z7w>ANV&{S!JPr_O3=I$ccHIEkzph7Ov*ox|_|Y75r``EpFQ;%IXohX!CkLvx>B<(u z#TLC3I7gUSuWJ7=*-gU@gHI@*jR!%3LT<(W&2&fkA>M1SXa+r$PSia&CFR~8V|dqM zDg~qxbJouSA+?@ZJJEp$94HYSvKYwJ)YRblfpbQ1FWFz}tESRg?Vi6YT{A9qH z(YxWUw4g0b@erbUO$W|F1lH(cfPp%B`f3pa8O|C^Mlf*Of6`Lb$S6)k_%jF&@C`P- zas*u!-&b7GC@#ZyAjq|mAO%WScfd0-qob})T|jOSB8;BVFBwKt-p&FeRN>{#TG+Sy z1vH757wM;9Isy8BX68>6yzaY;)hD;vIXIwIxbI(ZA)sgMfD|lRJ|ls2WSYvsS&aI6 zkANa9m45vKP(KR_v9WlB7UhxCJGO6sm7|EvUOp8V5RfygS=Jv(z5Xl$_|9;+S*7as zI~PUtyXvj#FBOoOuv@Fo%J`f5TqDQ4}?307Lgjyx->12!!w?V!gK zYJ5;yfQ>6wG&XW$VHTbV;?rWG2mNVA=O-&PVbmw;9|ClQ`sTGwr6r!zoY*T7 zod{nTGgcRW7IIU^wjmc-1&?3xLc!!Tb{iGn0^Iif=r65VV{w@vZ|@1X1qUGcig_SE6vW?aaX>Q0`=17cowIJvkDWl#;@$?{zZQ06iM} zisYeCAwj{6g0B!XgoG-k>7|(m6=QM?_q*|z@z0wr;W%w`>HsQnf+M*;8515H9B4D* zvzI?R_F4(;D447PZEXRX+7uDhoPD=;VP9`N3}{Dm#VN7A4O=Eb0AAe%cfZ<%wi3N| z8j>Dj-pPmxC}&*LOF$yqz;8Zp&H12L=|^MM?<3xB81fxVOfrK0@IgO^`QsO zGMNA;sy9?mhAMeIcDsh?qV$JHtTe}xj2g_-%zaz8?X zn%{*Yr_lq#Yg=@~-wS>9*}F&Wh-J{z2i!TAr}`A8oXg*Qz6dBy%)ApK_L0TD;D=ct~gn;jERMl!`Lr^0Z}!l?{OEi_?Y z1mr6yh=p_+jZpxeWakVCqZQscQ$$}kTGsGdN1-m=6SX8~a>{Gr-djwmW0i<(&FcV( zfn|0_V`EUAh;>ZX6mT}5u1ZKHuvMv?QGib#9Fw(~@oFN<0HvfK~)gYWYFQ}+si@R) z|4b!O>8k7tf@b6*v%gXtg|~udURIJkBie0)*V=GI?COe6;XQ^S>b-eqM|`LiAbfCR zMiBF%+P`;iIJ$tM=_a}FMPUmbcL4Ok3tY1@E`E8sG^b+Kv%l0E_DPj zkU-%ikRkRHu@+n;>(OE(JO?{{5&ZRL2dCz?ui&kU`(Cf zzp2&bB5kGS=g^18j|@S>G(Wjf90^WFQPIrDg#hzE=Vp|j1%CzffELv*R#7P0Af+YW zSa}7>2r82orHd|3C*EIqv~SlIa&ou~C1$$`hH&bp>@UPVBwgFt?>#-5`)YACOfnta zymd!$)cp1LPSI*E`E`MvZ^sw1X8(^x~lH zdcBPVG-zD|df>eT%eK_fmR?7i=-=(yDv7U97vbcMh@s%G{kdzrhA%)^;6uPjHi?o0(#g%4^%MgNQjWaz z1Q4Vo67T@)WFK{%%y>jMIerZuh!P(WLG@={YT=A+*&0Yg%|-S)fI z5Qud-(51|dBL9NsekB$kBedECwzcKulJ`=Al+Oi(gkS-&7X`g2iM^`=pVxp3yJIA` zPstt*#*xmdAvR0u<=Opsd4B$$b2h|(eg$|Ul(1$e*9Y5~+NO>lI@FCec5jp?8c@{x zpR}Cz`KiF480f@d78c)F2UN!X2Tp!>^~5a)Vt1l>=GJ?NBh*tMOa|>%bfI@wNK7Gh zzwGW#muP`2H%F}AH~cIi&~oDw(SxcYrW?muh2eMI*O^DA?@Rgu(xHb)a09PP3|>coQ)by zq9*^Arjz$BGpudHI+F)qWTXZSTDBh1tY_NR6FEXOI3u+@iu}T!O9I-+MLB$X9{T6Y zFk+7YVu5ijb;-$?{`MR~OnearlrDhwMt!{7t4wpdW8H$6aWXyhS1Zu9Qr^j}@&ct#Zjpi9-*>wAh zn5&ZvLCNo1B*=FNDhVoy)i=rt1(Bb79*8hD4LT48FI^3KtQagI1A}^aBo0j=hBEgQ z*2+p9+85$NZlzN3-D>t#2O_hfRmZ~%!71i%LLWSEadCkn4Q(d3MeWrkn(dp^W3V5_=ygekPxUX{RU&~yO;LFTs)%_^bjx{U{rbltEYjoT;NTPU zO-{DLxQ_kc97Z%UXV+ps>Kw%R_;xc4S0pW+aSF z3mPc9)-WlY^ptZ!%g_D%wzY46s#!ai!PglqP`*p z=21E(qK8I*gg<&C8V{$N*xgO;pHuu#?LT=kS^4?3p4X6I^HbBS9^K7$(EQrkV-b$r zR_OTTg)NlXGUzq3H;5D271%28|A`fT(|L8P-Mc0R%i0O4Y$Y< ztTZdyM?+Klo9Dc&Ece%}l9H8&+{T3U1pgNoV^or`!y1D0>E5KIzP~ z*zi$ZEayt%h)cNE=N)k()-y;GvvA=@1qF*3Q&e3C?&hL>l{&f1vuguBg2%*S5j+|UZ>DHGfb-AtvE3o78r4#dI(Zp!zXpa;>6 za(#>_HsS~Dgm=p~-_Z%Eh}oYpFvJlvWiYCxo`qId`ROm~N`KMX;Ga|)S&=sEkdMQi z{sV{HCo!7({8@QsduQj@4{OcXQ|>Mlzb1`*uw3%H{A0e{yu6H35ZGG2tKwZ9grc#m z1!S5drM_Ez2B5e4o(EXWcl zcBY>tG~gg?Jk5?}nylGf8adstU*;>^7rYPBofjtyOK&0h3rd8rBYJLT8YTk zW(h^1lB6io{XD~b=JESI?tkw0AK&k1w7uWgb)DyV9Ix%rFtm3&uXFfFb%fk@?ILAO z`;MzzT$1ML>wHBl`-F0VT7WN`-rVD7Byy)% zeOs3sJ*>KYc=whHN;pqMH|e^-u+ zDUl$$_C8w@5B8Lei3Isr7U0wPadFh&ktBh%Yu8SFNLnR6-paqg*OKjWb_4zhJptGj zN^nT6&VF0o9XqzG%1Amov+H(cKjLgO4<^jkxz%*%iB<@>1qD-#S$rw&CJcX!oSs^E zkKlKaZ+4&Ux{(n`AYd3wEwyLH>(&DEq*d<1gfd+*SaQ=W6B7pOEqA>O$X65h-nwV5Qzij>Og=2|vf=RdBgzL0cT55lqcM6H`ZA>PDW#(^;d5AQg z(wgUa#EPmBOF?u#_o%F_jFaNy%G?8$*+ixvKvntyAne?@XSkpO3T5imK)a&p&3j`(12P!m(qU(%%3bS5-MW581nL$!YLURr3V>gITi;)*c&kOJ&4mSAgtp zOPm;Yyd|vAKEWRG>+-o`9DC=EF(r5Ez8x9A+03LCmuHf$rSs~1WyAe|H`}T|pd+^{ z=yN#g_<35_s`Ga$;<`S!U|$XcQa$XKY?!Oy?(9V& zb97#`YSyFo`={H-A1&gjG>^1Ec@EHOQJIb%C}AtTZSnUpEZv+yV>2_i!ey8tdO2%& zOrIiva)1UEsy5BKfx_aWE2G6L> zJ^rpS86!LCJxXnQ;59@useiV!>>vHH>e((lRXdNO+#3U~*xBE_S*l(c;k4r8oiTA4 zC*CnqxifGbyLf`;ZZF7Xac_}yU1c3+)46IVETRWnz(@ph()e7lwKj*rva(0$k^6q@% z*o>T;PhvTCKW++BmFwAag=(_U{1>>~l@l&Tx9c~@)x{Ssbt&Jk77{qb8ZKa`&|1;#+ghM=-hpNlVCx4D*;xUEU|Yw9!KZ*=mY>mcv1FqLqPp?fM?86FSTONp3T)R?I-zr zv?)363zvE0nNuF{Jlb^FIx(YFbLcj#{;~&X<7}@Kx*1FQTjKZOu!>Oq3@UlbdSmU) z!cQ~KdoL8Y4HRfhLFDXy{*}1xg;wD;;!hojozhDRr9n%e*;J zvD$fw#Uo<+yBBwP7d5;7wT`TzT-+*ZTA~IEjz^))pq+ymDqK2}??3yG_sW&A?!T3z z#mL8;f2e}l@T?*-EMZR2GNz@ag(2@*@%yv9UFJ-Krt?GH>sC^*rjkYmc^o}nNJS$C z6p_z2Zk#n?mSe`SH}AOfm=ljP>V106SH2aYRj#uF1c{MRa*73Z_|vBcRKA|6&n$7} z-ibKJ9ZiU|wHxc^3Q#ytSD5(O1&YljFOqJox%RKE$M8FVo}F*mq29Yu{!fP$VC1f# zQ-HpXUNP)|)ZsSq!(}T~1`T3@1k?xRJ>*rXGoUrnovl8dQF4!7%!4Qqq|Rync>Np~ zo13N9AHl-T28GJS3^yN7LW*jx`=QStKRVFub3EkLRjKyK-{XGXk zUw4Z)58b?ZB8jYSZdx5ea9J-fI5pzb{iXBHn)SUq^-XDMXBioogsVmyChZrzxr>GO zva#taX(-N0=VfhYM4v#qik4ds4kh|<%H+x0wr$(7P(8^aU2ucmMd`zhtZk1@^k1_0 zL1^x)d#pXXm^VX)49V;+cXssJH`11pD1Niw#dZsud|al}V`4?i-VUk?56icEwS4vl z$Lk20YErjz4MD!G3~a>a(7ruGGW?)!-PA<@8C|~FM2O`r2gP8S4O0rJSg?lQ{cfKe z{ALfIoc!?b$rt1XPQ$;3j*>aw@5{=r|J9LN+}QX5Oy$#O&))X>ctWKiDsIw;+jkd) z$9-T)0d{9+W>Qtc;H6txTUdDTgd@v>&UjK<@^jb;*d*TvyuC^xJDIAR-NNaa(Xgd6 zugs1sBoj%DFLCWDl&Cz}iwO}zkI#OjokzS!z5^yN#%$P@to6YV(vUlRSF0XTrfJCv zn`O%e-8M!BZn$RIQ1aVPKgiD5@FclT>DQA>N^viG3V~i?Yrb717rq;Q9@^S^Ia~0#mo67Z zq%FI>$lMjxunDr|=g$w{DZ0r{E3xQL@y!>-#3P9pedk$Oea|_y5K%c5$@TB#kijsKb>XH5(EXyOEX+{2q@QBX=dS>=BEx5Rh1z9fUill|j0p|w_N{ofm;TP-)O|GUrs^S8e^icO*K z|KcpKI4k_;zw{zAyiz9V>w|qQ1~i(fUE5~zFG&i2-_!0+_y|UlOjHgb1g-t?*2jZ4 zq$f4TxZyc^3t%_4y7}j9ew)1a-4yqKb>8Fq$xNL{9q*R5U*}O{vCEm`qdtdr%hM49gzB!qbRW@ z@cXYXOPGv{9u~9M9(a^}6pESEN+6bQUG4}VP@;?a9%C6(vkJvmxn=e9%ZDo(;pT`L zY1g(b{na+S*oAHuTQVV!eDe8$N@88)^U(wlB$ab@tacqi` zVnsCKq;FVRs7*M96|$!(bsjb90yW}_l1s%;o_y8KpZ$2ww86b*3=Sm*kK<(-kdb!Y z45i&@KcMvX`XXmRLOcrDgb)<$jf<<{;}cQonAaCPlGr=nNK!u73bUL*ameEE_r*50 zJT86PjMhaaZo2&R!|I1B#({yhYquS30Lt5gw4#xbzUi;a%KF*Q4(HJNf=!RLZV_WN zeR?Vv2yDd@901D3M8fN6v*OI=IxG4P99`6rV z+c}CPwhH#kf0xmbCTs5;4h|om7OE8aLJM6=B?4j;k3qeGW~=Q|vYoV_Snc(==*`ys zBZI5}@WD(mSZnN9+)b5PeU;Q2>?%I}E`VW~fL=Od$T{%^rss-;>|g(ZdnD&Glza*V zJ)Z9FtiEZA!N~o9`I0VOI+#8dOE^(as}u^+e|S!IB9H{;t@hI=W|GVEr~nNXx5u;A zW%kOI@6ipe>iKh7U%3v3+)jJrtId%aUB?+ClbALm0YyZDJzF?o^p;NtGjj-t3_Mmy zjP?#)aBIR0@&L~5ooDYeD{h^}fl{Ch^Q^-|h7EH9^+89`#k2ACzRcm>Wq5+ZUq|qa z*J?Y>oLoX#dp8h;tBRlgwYqO`c!v3}!@tD;+(kBQHx5W$G<)KijIMcZ90cVxT)Vxh zv>?|?=RNoS9ML$BwZusD0gC*5>f^$tYtKeF1eES6y~Xm;C?4Lo??zRVdGiwLPWTj< zB-}B4aO>rWXX)Q1qlF;4U6LecX*HCxAj&ohnbF3b|B z>qc;3(F8^YHKw)Q{1QMw_VeDbu$zH6r%tEJKit2qZ*dbr<%})gQLATz>`?Y~eXxw-=Lv4Z`^wM<4MyHI>FR>TJn1sOEj$!Mv zWGdI~Fo-Z(vS_>PbK7C^mluz<0O~d$tI3KDVRUAMYl&LyRB^7gz8nVT?@O9dm=G2+ zsl{OOnpLY(vfs^cjvixBl&E$=BHOuYlK=L^b$wka#Nd7*P zI=ut943eG^opl9^bYkn0#Wa@lUGDa*ehkRG0r`rl7RRpjrJ-K^_s zSg2mSBc_I_g)6Sp*EQ_b8Xz&(wOv0&CUa)^5jNEvnej^N{d!Yt;^uW21Ez0cY<%qD zTi-Xt8b)WHIQ;9a=yghHdSL4qz3#1r{>Y{G`uGpgXTa=MYA^p`JGvP1=s)rj?v(t6 zng^?6-X%AE#As;a91nkQk90<#_WK)BzS6&1QFDIlk+zTSAATQmr+1 z?~onaP%8dM#MnY?36#hfVLnhxW#;{=U$p+7Ef z?&%o9F>Vm9K1t1d6=wcyF|n4&YnKT-Mf%D6OMN2&UD=JU{QG_+bk64QJBHAL zW1V4EC(oZZk#0jlZ)}`Evq$;yPuH$tadGpaDg2r_IEa2ow~AwOXJX>#s^Sr7qydJ_ z#@^zr7s6&vG}=-?hZ!1gGbXhUn3C)D>G$7%XDY*$5)bt1cgB^QT6<^iMF>~(=8bRk zuLJFc)vB|z7z#J{=&VQ4Bbar;ngk-Iv&o42GxFCBp4F-G>(?ijS2L3`rqip^(o%pS zXN8$TcAmdBCuR%z{6L#qe})2BBhpXA{rz|e0nvYINxt0I?$-k<(P{7bl0)VcEVcE& zzF(s6>3@HCLob^J{S;gK%GNLI-~3;JdBmfn^&4Nz9!`!QF4Cxl3$CEg^gsH?*mUX^ zv3l>On=H0(Wp?Nm!)b&69WQ)QU~>LmEq!bjvfbjG>~~x zivnDMh>UhNrBmUksr&em5v^&N^@2F))-Aj4Qf*^O>x*Dz*UEww@R698G=E<~rSf&) zkK$YY5D8G{Yyzg`;>_9c`Ztoo=HxzK=!7Y8XOE(sJ9h%aX@tTkeo2k;Zvx#3i9uC< zaP%4!JAPB8x4;mP>uhRjin)h+^~H-1P?Tbj3y?^|!HJX)$fi(t&Yc@drl$<@x8!r@ zK48&Mu0%enIcCg$RTdl;czz3mYuN{0y}D6%Y5q_j604y6+|c~AK~(j#ad2BoK_$4Z z^0>>wC}d1bRR(NM$F9%vq5`&SJ?3=3Ho*k=8xT5SO*C|P!f0WC$RE7-)zbN4%Pf~J9hmJ56RN^&YdS{o>H&&^u+%`RPPubsuPdE6EPcp& zJ6KC%fo<2GrF!5UrtJKU#FJKrRZnskL3m90z|_~9pXQ{M+=Lo7C#RSw4s-MHoc&}U zv1H4&-h(Y65=5ja2FBUg^vQPSdS50is3=g9_O!2Wan=%sz$uvD;3J+zYMP zA90*KYEYBV-Q*-xjUgZLR_U;q7i?j%zwuU9wgW;IKrm`| zcR_7`!_0XjUqL~k7bQG#ET9p4Rp@Wmb7Ps1^rqg!If>ixB&A&e5UMXv8Ak;uRzkc8 z^NnQI#i! zSQb+KnCf>l!jNlg*R3me&tT=V;;vn$Q`Y+dJ>Q$S;Gd;=4p1?6eDIusSubC`dW4Rh zJ--n!RC((%+fM;yDagz?!RLA1&d;wubNUd05VLtu(NQB1QqEuJGCrw-X)>h&7MdJ@sn*2UpaK{fo|0`RFl_ zBG9ziE=4Dg9616ojiV6lnlJNj)a`S_;N`-46LR6VH3wphxAc0oyOmWh%cGP}Jf2-l z*{1K@y$eoN|M_!;7TCNyu2|%)3x~{4T}$V4Zp$!5l-Txz+_=ieQYDw_;*_O(C39rJ zfYX;QJx!T#?@MB3eG|#2XqQPCEicbQ%oQas!F`Yl3@iZj07WKjdH85}? zx@z-12-xTBTxPL`e2N~^mawV)M67hy=I_%~zs(iAdW}PFAw7ugOC=ew=QNrZeT+!w zpl0OT^)$OQNJ&X)z<>(eHa1Rdx&scmk#MSwGP+M6y|}_Sv-k-r1x!2gVv~0}gjitT z?blh}$BU*V6#s)lK*VVtS~Yd*O}44xky|-L)Zh27sj7MkQkm=pCrUHy>qHaAyg=v9 zeo6poBS${cwq`G2#BB?Kj<{)CQqF`d_(sUYr14f1=m!O zkfD(VgMRd(pC~9@>e01IcDUnFm8W2+6R& z4LXwbPcY!o;ud%EJ<4Xu$S|{wT{k4+9sH-!-U-|J$fMea#JlD(6_;3#!-p^1-;pm{ zWd)1H$$2Jq&Vh(&3qPBAo5Qy9l{&>+;)s<}UEd(o20B4S`BJV{RQoWfoqg zs-cmDNfasF@&bdM`@7ey5~|&bRUqQDK)|DiDzIC))8RCD&_nqluR3=m%#TbN+(XZ= zUftM#Xt=@*=`FZO_dA`;ci=q(-RmYi7~mhZWE`t9=oOiIkSytP|4 zf6BWkd8dadG+1Jt+BDtHuC`ybM-@j3Fkbm(Os(?4IS&Aj8H17~7ro#Ct1qnAI=N_}i82nl(4uGpP%WAg{^ zx*&vuPVAU5_-Am(Yv-k1yhv&jBGlrNC%gJiG}|+WHI9+|H|=~dP7$*d^|H>680dYs zGcS3Z^G+n*4+|nSVdk_HZnFGz=Zz!?TB0fB;p}aYW_o+sm^mp1)mec99ju#4EBz)X-862Hnrqq^zE5qxep>E`n~qO4eXFljweJ+)Vwb;1wy;zC z_DA1snjZDOd1n4U5z1X;!VX_*{HvgJj!>?M+1%~_+B6G#5ydciQqKN8dx$X|fnhD` zp^18rTK(k}gVr|yU?cc^(sxGkwPqlZB$%1@o5TQ-6%n8ToT~;0eXpFjZ`l)Hf(RFu zA<{}{alcIAozg?~`I?Y(V|cjH>9o&nWrIpxhKbl0MZ{J31tX)Pu7&Y!8jE{1{#|4qg#wsh zI}Lf=dAd^Py6}ksv=whjMd+06%)SQ0tMNUP_cfvZbwB^4%>Kusq6TltahP)Yq2GNq zXJW1-B42LFWrXtd{EZ!><{sML+<&j#l-fm(j%PCq2?T$8ctoeABjsiHIJ>yKcF!UpyvvGS9in>`yyP|Ve3PahmT^=BAR5%WL28`?b_a~x2DGTw}U zI^=CCvG>?@HBUejl-E#)fxm`&8*+FGHrrc=*{Xz8_3xZRm$o7fF1VBCwt`dLLG55{ z-rQgMGVie$g!(qCm5nurF87gCxhn?r842V{uimY5?xsz@&)dLY`TS#(Iwb5F!X|x* zlE^R7?s47iebMtC-iQSnIdD0jUo2~nZ#!$vJjCzk_DWLwE6tcAErwF!nv)%H_I$hB z<2tut!cyU-&zmy)DjrWYDRb`>Ic9l~l+gIOKfs{d?BF`?y2D`iL(F|RpOmM|s3v*u zbv=YQTy`Pl_nj`4q|ys=H6w*v6#jhO-7vMyCm$iwjul(f{Aw!Y#S`VbcP|DUS{}_s ziC<6X6JH4^$uDITf@TS>RgMpCA9M(FpIyWKulLX zy_i^C7vCV9gvF9HQU`n*h$KONjt9ZwTU8SCfhbM?9FHJ=gnjNRk`FSX59)Yob~sE+ zK_?aRz4-kilmu0G6OSBuLA~ZS+|&h|6ThVBBik|Kkp`7C8@rsT;Vr_;W__=tkH{Kt zCZ6TP>K>o=?=i2sJ;Uva)srU(7!2QE;S2KO(9~@xTEzaTo!6K_RaWiid}OARY)66%oCwafo(T zc1Z_J8bXES8;RjVb_a>&6Wmky-(sA0eP za+=F48JsYsd)iz56K7-3O$`swy}`fL7vnt54(L(H%2p)Q%C>%~B7z2V^JRX^9Z&2I zo;~u`_lv`d|K`aS8bfAW`srgFn%UCvm?}-AmlO8Z3zLs`Ck=1;m)A8lPM40-oslo7 z)qaD{0H8+LvCqQ4>K>a8=yU1P&gZsktoI+BMz#l&RiN|o>ge=%aoKH8i0mSs&yJls z4FF3Yn!Ke;8?jZPH8`DiXT!94O$B%uo*aIrbiLAw{KR1kQup-V1H-afD_}Aew7j#u z@hPsVN{1jRH{5$kS35~b#nk<uDyE2&Kg)0zI>!m z!+!&AEwDe8tfoQ>g68}VtHr!LxYfu@`A9msf7rLh8I26zAUEp~q6bY6<-9zAKK7~u zY>bH=&(cm}S7bNj!X!=&Pg)q?Tp#YUj=Tpdrm(OSDwHvYL(?K7MS>(=)xSG*AeOZ? z+8p_%$mG*nwZTx`Cf6%9Z@+AKNgBWE8$2s89Jgfc+O>8g0cJ`{lO?ToT=USbc)g)RV>qhc+k4vcqqXH-{K+1&*-4OqzIjxg<~D zyS^c7U7j%Dbh&pQz~;TX0t3H)S~at8C%(4oveURWAJ~~Fy}msapF+I)p03ix&i$xWXP~Xc`RG|J z^4~IF11XL?GMw(oGG*0EWasF%&dwUB6}V>Y+MT|>a_r!OVX6D(9kb{Bf^Io=mjF0W z&T*!T5REe?JC=!JPP{g}b3aifQ}YKfQdg#E=zF9e@}&yI`67QKoN!P<#H$>py`g*J zi9s$GFKOcD_674a`0gKRCTN$0Cy>uiX5%;f1`xk@Z{4yzW5>rXAF#Qtu*8U$UVThc z!pl<)kETVw2F9}OfDrGnmb0P^io0rt>%_M7_M!iws1@@Wt=wOSG9#tIW7e#D%(+ww z$)T1%WfalGB`wT1+3je#yjw1C7Fos1KORGl&j)_->eUI8!DXeT#Bv23w)*jBCHk0H z7tG|5R>;Pm#8Nb!J6C2l+xk17)Q|~Bmx?r0B6txN%;*>*GaC_+xOAoFXdpDsT)ng} zC@VQBxt+hv`Kl^tM%nn+?7%NQe$kv!d7~E>{N9@djl33y8rBsR3t=RJ{sYzW)$qqP6q6$!vye7(&rASC#UOgR}AK5XP zPwn^;tT!>`8i#t(1i-zj=PRh{G8lQpuhbj|D{R$7aD5t-!AwwLKLSxa{bmiK?+?+SI%c4Grtd0!FE3na$i7pO4+$~^;g7qLectsJ%D zvA#Z5@+CdrfVI>-yb?$EFNFX!$~L7S-n>|@7O@_ z?dGe}ttlQmAA85L2P6iOx4Oya*GD;Ww=SfYetm!cOaIkk+@&A`_|ktw)qHV&?ER$% z`!l|N`67KEMwd19Pkezoc1;ouI{V$o$$1MK+0AoVaqsC}XV&9HQ9cG|2oAE-7rXFB zK%v4#d6@~a%~WtMg@oe-iO5q~V-l;vx_4EQek zf_L+?r%#JE$>k>aX5Po(rPJt)fQVjO9|Ly4bvu#nqQ(K8woL;V>6^eeMfPaWxrV?|MZ;pZ3$y ziSTth6y)z8?NYxu;>5cou3PoPFMoh~nx1|rGn?dKxm{6Cii(Ow6P7JI6Oezym8Djb znkS4NFPj;g%38ar@;-e3eh1G^82kw_eoGSD*A~S2c*~?`uUpqZaZM3Er()90pKnUS z!=_*4z&?IK>zu9Q`BT45N8v-HpGXG(ELW_5QL>A*spe7b~6je-+EN{`mzg}eS~R7&aj`N zscE4A*|V}pc&gJT&833c2d2k3{cQutQR@rv`B{rePo^|DAI#M8pWUm#@bLS-lE#(y z{N@ks#OU_}f+k~Imo>%U6y)n0x&2v^$>4uF)Dda(#4MoxW8vGiu7)}c8rK*^n3VE! z`LWW)-Bup=DOk^3cZ#V6oFY_TWm=#)EBQ~2XQFOteKl8(UYu0fGmAtI3(L!t1&(=H z-f>ks3&PtuM<%ylTzVS!+13CJHU**6Ex3MOJ;G=}NRH#q5p(l`2HCe1e~)Dmg@lBl zQJLmDMIBMZF z+rYy%{kp-qtPd_`KBEwrjCIW{9a}G58ABe@4T-&t;faHW#mt?vpMda1BUF8luef%i z?}C5{(ZZ1+D)AdL1;We=Q_cDTee=Qj{A*>e~mU`tre>oEL@w(u;S!< zc(Ur!1z-Dj^e~b<=}mp7#ubvowd>B>X?st-UF+g9cVJg!3(|k+)MvA(2&tK-FM>qa zsB=x_biK4Jwbp-2(HUz%fWMT~Ypqy1ZGkUa3zRP@!=`A#r!6&bIV*#bBhre|PzDwHQhMPysm;^A8g6sJXZAg18niK_6^7mS!#)v=S)V$~l( zfHV>88sGUo&jq#RVmrGn+qR{qr6o@6Dl3Zu^VZ#Tsy0rJ?*wBiDY79U!c0y{`9guf zkwUGdop+cRKCZB4f+X8Hl>ZXVz7zv^hT-9O<>!aStO9(C&tuZDj{QB1mu8)x| zQ}#$-&uETT4G-!EDlUs(8lKEcRkhTb!ye@!%tw{=Ecq*)aw}m?ZAxm_7#nf9zE(mV{eh}6Yd$IxNF}(4R7}0 z0jKIz*_Hzz)kKCz;hXCP*9T4cd6;hK_4?wl4LL| z0KPjkyz&(@(W=VYv}xn!;bGidx%%|HXlswRC!u&woH~{8IENDg?Jv8ENWs-iJ!ifK zo4`IRkGD`C$WFbNqyAt!2p{K@VV1e9Sa4H90;%V<#D2E&;844<0NMN5x%}Y)~WCQUpvny-zm2w z?@RYl9NFhr{qLa{-!Ef)yXg`xf`k$;y{QRDrzjh@l~N1oaDU{FeKT>kh&>3d(zLZTvPF}>or90M8|H$ z{Vde|nr8j_iC24M)Q@hE9`@_qOgM3NA=WVc8+F>hZ#8iZRu7oGdB(4LQF_pF8#9B7 z;5%EQ>LxonZs%I3od5ZoB!Uy#Zp&P&6&N!8q?}ahg~^WO^#5?-@Eyn%G-lySQ!!Hx zonARt%6{_!*%dGo{PTZ*%eK?!&h1h~HPaMgZa-7mwAt^}G-f%@U#WiR=b!rE zJMqun8aIA72%X*vWa+X{ZH7y3+OSM5C&NS?ck_z~%I@fVxnIi_s75}L>B5kyggCT3kO}_|=h1^!b1HDD?W>@!ItN z{CF=qQknlK(tgoD``6a_L$jBT zJaTvp$=-A4A|IsHQTz2_O`P71kr}jn7vE-lMt{TEjo%;TFirOvY7T-eI7k-?;8wie z{lVq$HFM@n+O;U7S)JQpwzn~he5rqnm{DzL`jf^`d<%bd`MlRi8F~N`eqf!)^vpVO z^m`SLz?nV1SdoSd9=yx^=W_ZmY=@s8^86`Nd>mG_M^=3NzQ8p4NG7>ezT}4)HD^h- zYD)6YEse1Uo00O*U)#C-<*R$=f zB^ve6LDLhSVHm^}H^?Du^kS(}#eQ32b7D92^GCy{-m%}0>vZL^KXsq-Of9gG+}|F1)CKd@`npoIyZ;QFPkvK(#2=ZWzPUXrrZydE ztsk}f0Xl^xxz>Al!>ZHQ2~QJ~lslSwpK~r_+~y$33ED4lQ_f#b=2Q`8@NC*6k+Wbye7l<8hmbc-V@kZSxRtAf2te;E57Xg|TT7j>1>DK|t z(P(m{b5Jvd61J2K-9`8AjS_3|eLMYIM>gh9I%bF0sn<%x7tL4{46TqiVN_cP8teN8 zrPZ(>&oo!7&xz&5`*OBsUm|{Ql~1ktH$xB zx`}o=WED4#3{5&bzevC(fgj}jg_?{x=;SbH)6rSQVdfB}G!>+ywjR0ip4CZ(1cEZu z+Win^Jo-h%(j)TBD}Q-PINvPE0|LZE;qaaytUB~N;|}lIWmA?b>Lr{MGWTi4ot~zs zVW2@h3!Go49hdJwO)g9e)c@vBo@Qo>QR$+Vhv~(3cwAguPD6B2cCW!cRw?Y;zn??u z5u>ZfC`JhiB|t9;flnt@_2fd{2)kSt3u~#deC^X=;Y^0PK$9U8!^X zY-d)sy83EB4^*G$Q>qDZNThWGZWE>1_4pu7T#B^IbBFG;99w|?f1ipu)onMrtiUpc zTj1M8LU?D0WjL)XZWI>+E_fWV*!!~K0M>OFcIg~A8d=BlPx4uXZfN1)Y5M>0OqNLd z4`sMn%jNL0MrK`M$~n-y9*YxvoiLfso%@dr-c>=*lW1gTDy9fKI?|0tpFDk<;hpNt z>y7H?%;HJiBU_Vn)Uf3a{5f!ne#an?E`NQW)~UG&VHq`2 zZmA{2*VwUN!5Da4)5rsuSQiYIq5JK_#M+gf{2O)Kxs-DF9WiuNP7Xd5yJ=!V0^NTL zrk#h+eqSMgXht}JHv9sjU7wwkMe!y(bpBNR9|&~nmnLWz)T6nD$Y~(E5dMJefor!* zb5ee^o#RgD-f2S$UYic6Xl1Ou2##gz^&?lI3hWmBSd~k-K?8j~=nyFfUOZNSo;y>f zfieSu6@R3_L#I0Iy8{C!s;M3kq|q`7pVazo6z-6GKNz*>x7m(`u@P2rhe0Fhi6T>9 zh_ah7OI4w_TFi$gw5dv!l$duqes6fJa6+7tt414TMwkknMHnsP9thV_FLaRKMWwS1PGaC8BiOHhP;DF}ae@>3!EGVcf+* z%F5*c8=5`?S4=?if#{Xz9@pHwfn5n07(E)VA^1%>A8$#}R#hf0I9`W5Nj`e~c)uqf zHh0pEtc#AGY`y@UOlm4X2SHxt!u+vSmG{%>;{~2`M`nIn${y~U^ZFwrxTR;$otux| zLAZ|?SHsmyFoinK?5Hlx=s2yZ^l205rXy;*9AQZa9@m%chu2NxwDWkV9t||G|2E?r zV`#eqjFD4e?OClM+q2AlJptgQmYx0VxBes`%p^ z$KsFMjYP}dw4{UP-e6anPwskw+em;zF&V?EJ2P^Jn(#?HBERlV{3_M!&&8j=%aF7! z*bN1(E8kn7MpD-|r<(Z#_I1arYHEBA z8&Qm0nGeSxMxC(8g*kdj9BRRSjBbrO+z|hKv9Jz`MyZ?=Wl;xOo37TmH*bF7IU;_d z7L4?O({2V=%I@rO(C_n9z%^**q_oR}2M(n9?;75JrGL<1;CPh`D-xAOK6LGkE`dh6L-@pF-mGDlQZe@kPKc$I%)Z8so-2FI_N5 z2L0A;AxtGeYe?Ls-G>k7S&@4AokQGw#4{RC5=TBO_kdA(QYzM{Fxn8AQ(vEy^3uEM z0hR_Al%65oKrOjY6QQS18k56#0aU8pK}k5ZPmASE3U4BFNYoKot)4*gM*$ZRbKiUzZ-s+ICeKutMGm4^n z6Ze%_!|>xvNbfz$UFvzHn_C&|ecc42DubhN26ehQuss%{N4PnnLx<-N^2UVL(grKmKF?pUyzb6AFks{Qb3uz)!se1(E_j3c{Fn2`1W7(@cHxSg?aeTKUw{8&;$qqH}wHLde0qa%awL= z)&KvqR>eBAkXBJ6`7K3koE4-97b3!z6{8{}8R&L1{rNLgjjdiy$eeeVRGT-8sPmiw zhoKq#7wD+u+O3Wr1B_R&O$CXAj?Oc?PX8a;D$;rPvo15W`&tE;_b7poLEv7xja@5o zT*&R`&wa4)LNG2Kb(JHB(?IjkfZLCsB`4L4#6hX^X>v1_{!^w2kLLU=94QbOj0_O#)hUHmZlC!r)n}6HYvqp!e_tQ7VRMk5fAS zJxL&NMH3^+NJjgN5#etItq)|AYTg|eb$#a{d7T00ooB^&*p^w)S4yPCK&VX#n7nAL zN`FngMmSWJ_e=QX)I^y?F1B{nDtgOdmp*Ko zHcVB`{T<35Jg`0FYQy(B|DNQ$CZ69ThBH+iL7hHg1o!*^r5D#0M>;?C`++-8zpRTi zGLEEJk0G#YXuU_e+wzL)>Si3W@khlV<0SwdV(Tyxcd-v5m@qZ7qdb zMI=eJjopB)lihGp-slAj7Es3Nczsz_mWw*G>_&d_%4hPtI3v4vpC-r>zW!QkD0NxM zxM6v96l!+2AxWH4O#PBJ{T*`OJ}8L{EWzc~)3V&|oM6!F_e3cTd12I2UFn9)*1yN& zd(`G?2Fj8X;C0Adfu-HI&(y|7%mFoA=~KOPJr<_0^u@>5)1z&8w|4J4u)lb#MP%hP zg6+{Z-*ENcvBT`xL#!A^hjmv}ycDLq^$`m$xj8c>lMkglYIyl&sha-7l~z81Q(j() z*}99&fi@vg1BOV?xyE_Ce7DO`*r^F$t@m({p!^M4}Fd-Xn#qD z@98E^&_qi$*+Kq6K`rQj-o1KXfTaNiib~tAy?Z;UE((7ZddF7C49L&19B z4K*t-eUW`~@0I4WEt8MOYc5F7^j|^Y;8;N~hkVz_-F@`jad+0U#h_!g@b4swMWT77 zgRk10+xwkkd{zaAAM=&<3$m7j=?a|qO9A6mVGw-2&@$oxdk#wBnf_1ZU+vrRFR3m)yj`8Oo-x<6r zXl6g2J5)c0YGbDj4khXim51-#onocA1x9(iX35*P_bA`mmPeKHEy>3e{J~K!Gi*<0 ze9YOiq#6RKRJb?9`1PpOHvB97X5ByV|_Tn(&9SF=? zVPW!+M>aGh+FoDUxz}A--|UfK5iMXqf1LKf9 z@T*0>MP_C_$#C2v7ztr}*)lBSi!D`1Qds|YSKlBrBQjYC9JnYUEj5(#0R$GvjL+}6 zZpB&UZK9a0DxP(z;|&Rg1AVV*YKD$Yd$serodLIlq0(O;#CX@UqO*?kcz2LhCXi{r z+M;dEovP{e-rYbnWwv%_nU-3tzVPkAsVL#*XKwD`z5QlRo@0rwBAZ=QlK z$CMEpjn_I11LJ>iymt80b!M~X-?`LoNA(a(FVm;@wy|-~sh{7*?qsdGnVFfYuDzb# z75c%M2eoQB8(;JWF>~8>LBv8eQAPVkBCCOQTiC_8%YCqobeeSf2+lvqczRP#S59G0 zZ@)Qa=zo1T4p4lY?m24tNkM^8kr3dCNNxMptryeM)+n4dK4a$7oSN#_c@zQ0)A<{^ zUtVBv-vo(5idk7Z8@T%8&W97&orN3fY-Od;imqNw!6Q^uCWDC$aoeptaY@D7aL4+` z%j3M6rK6pPYfy|FW5;4iqHGeAb*HqE0bsae`+Y1I@=?ntuc*WM`!@A4yOb`SHpkp+ z!?H!&u2R9X$8RswDqC`*9=POe6 z^R`wX;mvmD**x*RJ~d6sB3iKUE1A#$Of& z{Z`8-d)nDML%J{C72p21n*;p^Gn+Yd1@Rtb4G|c`IGH1cFPzX*H|n1Y!Z^i&N~@Wk zo^JU5xh4iL_|&E)y_!(S;Z|FO*%a3u!(E>KS!yYGooMR}$TkoBP*lIKUcBfNJe7*T z@YGwOKLkzs=%nK%Fj$X)(Zgyy1}v-%J~GYIEyPn^egEiMt6oY4D;6>NMeMHDUTVY@ z0*i_yVvYTVXZo_t9Zhf+tnlh^);r89uG^fv8&ZZldK#QNT?$Tjs3y=bbn&7c63#2< zEcYDIC<(qmj%x+7uX8NTcHV-(bIc-`ppe@!r2sn z8v^5I2B9%YQy*5=^M}~V_G`N*(2=hX;-JDM_IyVDV#6AxqJHD!9j?qbFz7hemQg>3 zHKqne`L+*tcm_EG^%Uxml}U#YzP2>3epnisBhZ(uyDLa)Ex#KvW3Rnef>zh8>g74c z2k$mS?99wtDRba&ZiWblTl+j=k;=A?W;U6}DAw2A0HpLCwj+GHO7u-g-0js9pMTXa zvzw=5F>BTp-J9vAekGo{uQPDjrVH;SEzZW!F=(`THCfQmL1FuBXs7FKj^b zzBgD@BOn75rtW9FmSNCF^Ped8VQpk8Rvg zknvD}vz3KGQDc)|SWEM+O&&ge#MzR>&FA7B##LI3#Ufo_Isji6x1+81rNZDTx15k%As9uZbi7PLOS@MuPEf zN{aYPc#%Sf=_7eWLj_?vi;f|yywwU6ZZh(4>n#&)5=FiIGGwN1ZGHV~cdIid@&kOS z07MK&xpjETOE8Lem6f-h4^~U9n7^#llKW%9K`({?on!5Ypv!a@njPMNP$_8c4+dN> z{_=tfa6yLG=tlqUivWboLIP%|Cgh zPZu9Em8A+0$sKufbmdsvgRd}F3bh281Q#5OALQlDJURIw$zp+t$yzQ1 zn`6=Fc=1`qYosKPVatTc|BGvx!^$3)SI1QXaGLDdL0Ls*r!QgIndeoaKDS?*JVW_y z@Y{Ubj|)Za;NbqpwyW2QWyI@90Xc}()~M$Wyzl#p+$KEr?H?*#z1+N2*Z8@{P{$wN zCgM`JEZhCAc)0Brv7PeTFg!+cHVzQlR`{HtzNe<>3bDkE6+gc&dD5bO^y!Y193uT4 z%}o0pTlV9}L_+NZ*-jG2cKrNrsl)$fzRZd_wflzF&wrP=cYV*Cwp%)$`fA-Xk$pTP z*GsxveOvwWZAt{IpYxx5{NnRqbvw=bm%DZ$?CRPtaR$Bwn*nNJilNin&zbh%`mAB> zT*YCD!w3HObHcY^6U3M&*_px9Pqx@fDWBSXHhQun!LUb8le(y?n)%Pq!P>|#HZ~KK zSF~yV(91sIi$6%*v{Oe?@iFPAEaA#!$)mCktNsilpBH?0@m^Z{@zNdJ{xNx*ypi&L z-}Ga~XYT#VRwN*0vjvX|!@-DEBHlpd#Yf-VZPPlHb(KPmBN_7Z!+lFbmECH8LH#k+ zA!!VmO8NOD{GbYt_w5wfH}?-xK3mwh7R8+z4G4WBcQ@R}yeUsqB>TIRpWj6Z_}T7ScT|NTd9hWKZ_iG3v}sFo=H@7KQQ zdtK-O{_`)w3ex&3#Q*t^q~eVXBUb+Rt0az&D)#P|c|<%3BiCx@o+_nxq@FKFtXx-`?o7jp-sw}`{->ID_9-G(V`qFQr5BX!S5CCTkA)iP=wdkF@L ziRe2}daL^WpoqJtCs>@n4}$-*o32RV&EH4Wd%Cs~jE+P#a;D)Xyg$@x>1XpMc}m3P zI5MT5S=R590f`-@lv|_tsjub_T~b2~ri;)@fR&9YxIT-@d#_ffA?%?TshqJjPTE#eQC4?#p8MgOP&>h0Un(B`NuTZa60 z+-KDE%*oR_w*K7Du!DQo&AgX+igBnM@=h=WzSy(IA_tT5I@R!auC?N1Frc47xe^|G zZVgw>+Dui^!~mJkZ|-mQ6)*%7Wy|uhw{kiXTZx)=WyT6fHT?dx3kV06j^qI2sx0yF zXrdW|OY>U_=ftvbe9bQ1njC{1@=qaC+Hc_nzyGCuTGXXVPMyDTgz%Xf}PV}Z_n+U4C z54DeH+CX_x-3(wLxSatS8lh)%Q;!r@QfPQXp8&H^b7Sn62_~s>>eQl(YX+>(B6a9z zfNQE{$Ehl+=i|#MIJiLM&b(?Z!I9<}{64c>Mj@~aG~sPc&9Dp(F&cHj%!8^G`F?pc zvH}wT2jMbOY(M1<`=L z>+fIYUP932(0$8C%GdRzOAJ;u)4o8bC2}U&Xv;oo6Ju6{GXmt0gq%w!PTaUF1KS&% zB!g`~3|;B`q1~*RvX1(Q?%X*t+*)z?@KuzLl)hgn++w^90eU!)(G8XQ^8~O;i3qU(N41K}o~Z+dxnQJQ z{~r~q_9s`*Hprp3|^r)!qFXIFwMs5^_Y1NDB~HHaSq?%&dWDR}5V@6Kkq3 zQ+a)~fx0v#w%Bowi zGePD)gQ%Eu+Fe1xu`Dno!eIS+-2-+<@SwxP+6)iN8!g?f8|KGMP4je*BrR>Kfxu)Hr&d|X(R~p#_=|7+rkJ$l%=3$Be@1GQJCG82yOS(yvx${ z5Mts$TK9LBb!|GJ{ldi;_0Pjlik|u9O}e$e<%VzY1^Idkiw;;7=JnhBx{zaD6+H?Ma%bo zYUcHx&+Yd6U4P7LUM5%9b)M(*`81!;wfH*6tNU7_M!KapI8jn96SEB(Y<%=xwejO+WSxEZFEGhH`O?7@NKjg_5|C zdU_5;W)sADWye=k_R_ldbWZ4`xP7s&Y0si}^BNP42CODoQTY$!xRRaS(f`F2+t2c! zF0qI8nkXC^5~DDMZ02``xjeDJ8q%U>1k#R^PBYyo1*?G_v4dxfofBj$E=K-%=6$b; zq@3{tR|ogryNTx-ggf!-(lxmn2sWLAef#g#iLfv^uz&x{OCz~5Z`342e?NB(CZc=Z zNlh_ud^lV4Snk0QV8ybk8Gjhorjm=E1RmwIruB-xePf zy|r_+kaOA}>qcRmaAFko$12}d*w>%wagjNKom46_7mE`Cq+^c5wTq#Xd(P4tJ}BG- zREVm1*KPBwY5itMcCCquGPIQ#KHOn*EC9r)xjRcOC(J&xg!6n{0OYH{Z(Z5zS07p( zjthriU9y>t&@%l#TcnV6eDd+yHETfdGlDi$KQ#*V9}Rm*SvkmyF^X@aD`$GczJ1RQ zT-YoPkC}(#oST`mc8br96mm4)sS2EfG0f}bT5rw_v-)hh z(Ecg&i0h|no;&;*&O|?+?pHVeT*IVEeyiV;fQj$Nk;Lr=?44t$^jYC3*k!f)-i2J0 zDJnAjZ&7{37bYx;LWavmX_xv}JJSl5gHl{>*`?Mj7FZd(c2?5`MWeE>G0EaeIoa9g z8^j#HUhdUpcYAGk<$}wn{z(~FnRlwa!m092L&GDVpL%=O)P@*AOl;b!us}U%^BGyq ztM;phO**Cpr*Fv6p@3{}fSjtD)0en?GM*wiwD7oZxIVoT7$UbBU64*h;<Kzui)xGTVAi7KnoHfz1$3vt`rQ-H=A&h&N^(F=%U@+t&vNHOyz@w?L0)Jb7K%~ISMb5~d8RZC6nzPD3B@SJZPIbwbDU#E_A5e0HziXp;bKfkRm zF5{#zFpKYe@m#D)bK44J^q}rszAY>bMNnjz9`rK)HE_=ewBQAG#mw`^R^YDLH%FJf z6!%;{eBWqO>uqW4GGt;EYPAl2#Bd9UyM{y=yvg8R16OT+G)Z1PD#^6QS)ZC7~R(j``#c!GJ z)Vfpygk>9xZk9iorNpX}rilkAM~vo#X0Dv0>8Q2w`%v*CyA5J;af_gAc=CjE{+ik! z6lPopGjx0uoLXAFA4|MenJOdm0EPk;W=o#bUB1_enj0*@jEoEv6)4FP6H8DyxVyUx z(au%!S_)$Ijg1Z)MtqX1x%*~q_}R{0?Vt8&<)Y$~_`BhyVR_l^){cG`z=h?YIvLKn z5*WB*@j`L2yg24|j;|;lEgYuR6h^uaIOWyI(9pzjsu+-<7-eVu*_-eqjezd3E8}`F zcVRFi+Fe~_Kj}&)9aG`nrpQaHs07Oi(pOW>-Li|lM+av2M0Z1AR#+WzW2{0-3cFng z*+J%c{!|q(Au=p)vz_l94NQX}=g&lO&DdjjgkF1ze}wRTn!Vn2dz;1l@yO>h0Y5et z|9R-Q)BVa((L_N_6hc;3eFrNDNp4j9a8JJV-JmV!9>%^2an_Z0kCvAUJIy#$C!&*% za;lg2L+j-xa`Anp=CYO&4q)pWcs(P^f{nyz;0*H0pWj?R*3SRU1|!vFxKUIr3tf1Z zxPucr(M1_bb!{HB+5zzw`ywQnGEbyY1(g?!85*s}k1*9IL;c3E>ii8EhMCt9J6g=+ooAT?|udhgPSX4aTeOsE59_wGN=uBv6hVO)So9v63 z3v?@HhS%2X&pzXyW>wXE*bAd%xhDmNg-*8B?f4~HoxV-lDYbDBNREUC>we~bQm6Au~ zI*J%D2nd%D|CpX0Ja1yq^QyjNa8EXTv-umP5L!HRG%-u$rc6N!q;6T|{)ZZNvZS6P~i?ODM^ZO20#O0pxBTnU!x^d+f$ zRAj#unfkl2nA8Zd1`m$i1K5RMlJti9MdC=8!>7vT{lQ$7$Mw$x_a8rwfh^8KN~8Q} zHODko=E{efz)3ST1N`?3yK*j`}0-8=&rt@S1 z#T57K-rZ{vDsGGMcIQ%g)g%I4pVr0VzA;g!YyS$zHK`)m4v(uP8h{xR zWW%!I=QYzMSiJqnPAArF3vo~y5=@w%jvX{W1j{%@p(aNYe^%jPOz%d6p*JFxC4}ULGj> zHfLw^)Wp)oIj=YI<(t77V9cLAd-hWwj!;=>;FcMVzIPdHL)o{S`v<26F|t;(oUnzn zJb<5$Fgp8s0#?&|Z2s53IGH`h>n+uu%)M0iWX?-)ilhfNs(GY*NWPdUSkB^;6u0)h zc)(*|osq}1B;2Li@0JfP4Hk=bn)9IYNH{0WT#jX#zyr;yc}{M;2YP!(n3E5mktyv} z7hR^J=A7#ul2Bpc?oYd4TDD!BOZh%RJ8pDuRKKbkEaYR!M%I?H~SAgfhJ_~2M z7aN!cmWiisio}){K874<$!l9a$2&0^L5P@MB$9{87yNA8V~)vyyAKb;5xtOwxH zGbpiDLa%BCl1`)1gtP4OY{o;RxCy7+n*PSCnRY+rL*WCFs6E_E>rx@r6iCUTzqF{3 zv0zG_JL1-hvLe@myWY3SUUb=b*^+XB7){4t>|LBtOgEVSJ1WXZfBy2t1LhF%RL4%9 zw5f@;R~P_yr{&|vY2M1p5QNmu3P{eQM~V`b372H^nn|qL@4R&I#ivx?&r_-Xi5vNMOOch5vJEo3t-|U?{^)d1!G=;syYZUb znj1%b3$h$-N=yLbw2SSWT&ud09)iri+|x_auS?+Zga%Oo=lnU$uh`rJ1WpM?Fuw@22k~O`4XrngX1dOG2b5p-8viiELY0xi#`r+V*+}A zZtS<6y=&Wc4gBl`A)?FAt!wYsdrcU+x}^U3$p^W%9~%8KyFFfaYg8}NQZ5;;sDd5E zV^S~<-8eX{8P32lf_EcQi?P#!Z-#x*0T=}$$QMAKXfqRx0mg~F* zK^OZfOR0WjyIaw!x!8bH%L$dxUw`%3CCp$N?!c;Hn{*bESUM;)huv-$UaUU2TUOftA}y2#bm9lni&idL zoY8bA?JX-C>?Wfu(%Wn6A(oU4o0&k+Ti$oH<`y=AmZB9)y^}1ORX^Z?{SS)A{y8o= z!=nQ#t!Zr+k?6RL<2rnk2r^SeERZJ%ck4@yTtE>tWn zZI{pZUf&}#GCKiVODfV zlh2pYp{Fi_QcAaV)zEYOrwBF*_310k%01WTQ~YwR5-Nsa_M@YNX^hdL9nz#Bgw1dW zdedC{4H7WGTAaaY!03Pg=~^3fPIKFyZ+UQjW^`CW0$@SYbKE6=cPN>)DLhUR)2)5| zzUrSLoM_z~7;+!icGI({s07GZ5of*X`n)eWVYaK0#_S00*LIA{{bdcvHgkj>ukIfJ zc7S=Z>jP1u6o_G}J3d23PUn98-CkxiQbH7@k>{Qo<8bp3eKp$_0mE;ZnB$@Cy2zA1 zKs@vokSKxg8MYvSg-rQ5AE}S1Up^^S$u9L-ECRrlh(()%s4U(#lkS((!${taWtkt&t>2rySDUhBSxoV@!H=Tc9%%b% z{%zbb{A|y|FUnr*&UuoWR-XG{)7i*gJ$R62(|-PBfjm!X`1p#f(605GOvd#F%WnS6|OwN#5@yba(UEK*goiE?)E1Y{sVRug)Ky?$phFtKx+4q@*nbtmD3$@k_}pYuz*sgyQO20ELxn z4YZ4A9KqY%i%EUl2qhFSUWQ2~f%;csCI&cXU;HujSnZ2#exL6&$rfll6!ux+HJ{qq zOkE^Wvo<3w<-?2FeLOvXL1}?$2rB!?Qqlc_mds!aNnKrChvMj=qKJ0lS3`!1njO=4 z#cu)?YDW6Ctrh#H%$#{Lx=t#7`e>||?uz-^T)Z{vp6sXfk~7r>Y)N=+AJV%mJls?6 z)_nlMd2|FEo3$GWAxOOT!!lxXVfjy+VTbmN z=yc>3fB|)-E=TDw@ekq2t*r$df1d7vzl1wVsS?-mCm+Z4@D>Qhc3wLSC!g?*$PCO9XR^;ttM(9g2verHZldA?KFn17*% zNm6q1@us)uAFNuKC=__YOLgz|Tl12(mJeR%!w%EP7E~!5f^3$S(<5p1h992Ny+@CP z1CU-;eCZb;JCK1*oY=9ev-<_X2{&sRD*zH=b!9`oef_G0t<;EifmNDR1-U-s&lQJ- z%e4SB^~b2e=KoG=nZv)mXJ97TujjS&;AX4aF(A0c6+p@O)B7{4~d= zhXn*I(4gUxoZ%xoQHH)Yc1!4rQVLBXikOaW}=> z2keJU7_qS*ZaqD)_wPG|ZKB%OoUjhXHY>(s6iw^n>PS5jEaSc2vMIAiBVJHaav`mZ zVv|uOdq%7#E8_mRE*hH7p)3uaXOLOSH<(2R1_sd46Ry|@tUm$is>xs3sU>{-?CPk_ zSQc2hyb)A`7cb00cdQWU-dttAwJKUKJU`Yn%?*}XY|5_{OVw8Ky=Z8RjT?Bqc{Bcr zFPkI@uT}{XLTntaCFNL@NpIe|^~Z}}KQHCmqT)v90Hp*GcjgwtZ(q=1mYp1EnEG(^ z=lp-Z?aN);KbEN$bm)83b6KQf6|$tQFff7XgQxDeyk@B^N)%rDTcaHKq2qhq#N{_{ zQp?j%J+@TDf~Mv=bh98l3KbvvQZnmZDrC67yGDgjnYucgPBT*?IQWDk@3!%r3#2TN zXUH4MYibql(SHf`@WUQ+sN_&K@i-IBE{b=8r>AG&Rv;nNq+*G4%2%c49hUmn{$u;Z zP0LFjO~y4XbW%Wr^w=ji%tQRNbVz6woI}x`P%eQJsEYu3{EltSz;#L6l;p&S2tk7d zXVId9CBgKTlN(LG$>Ys}${c8@S3hwPQBfSzcZAuP2rm7Yd*8SklAX}7^Cb`@Tv}p2 z1@<0)2WX$`XbkO6o@`_IU0%5F7o8ipSR)4!>N%HpqN-iGJTunTP+v*q*Qo?=8XR%) zqLAmzOKh-5$=k8dc03XE`oimOx7x6z#gR`&`_~9zUis9gt+C72*FAhGbbh~7A6>i>V5u%5ZP~ba+P}NC0QWDIdhkE9l7{Iei<^EP zi=I4WTyO8pDpPUCe}6qH3oPcvEUTt2|M`pDUdPDKy3iqY^xqAz%enTo&$2f^D*g5E z?{#^5EUr=GcY;yp&n`4*N&FjrEBxTC=#k?;%zS;b4FC5leDnW)-@xTtpDA{Jk-K*+ zaB2n?zutPqIBqJB$Ku6mGE$+%_u9T+$`%bALZjSib2w7Vyv4KY81-8#5Y>cOKHb*l~)S9OT_sYp)OL z4mic$2KS&A45;V6uFocPfq-@~xkM@Ffr-n&V~YMNU4Pr>v|L@1Z0U#D_qxs(N3fvz z$NJn?T(x_oUdSLZF-;AG2rG1SeALuK-M>#F#en>cd-n)u>%$k}XuX3CQ|7!|j^Eea zx^W{XSmAFxJAO)hW1>pB2uz|%;@g4)1J`$00u69^E`Iy>N@8s{xtxb3(mhqmWiu1=@r}Xc3bnk%d$h;bgE6ZiYo>V zS~TulXTI?{QxxS$!K+^+BT6Oo?D9%_pqpz1Gj+kE>x$>0Zc4FtJm0SfMy+Z3{CCg+ z{vg)%)*y)B9uUE=X5Q`JvE}jiSey{UwpaJzJ&>E6kB|TOD`DFUXZ`0DzrN<>h`CPE zU36=Af03>UZB@FC-sA0y48s)^)MlPd<^VSHkvkQg;LxNUn_p@%%un;)L7kx*ZW~Q% z+p;=lzsOb=)A-9nZPbp}2~AIOp4_%^wHiW6wNC08`Qh&#b(GX$H0rMlP2Dxhd9#NoAI9jSgpy$HUu;ojaD&59w~!iGf{K-kH-r z*g3dk5G#E5$Zuxqxm8aYMZ?@m5}U_!Ca$pC_);{QkdBzE>7WPe=LtF|ogLvVSv2nx zPv{7qviL=uy~xBLD9t2&=MHuCuA*=ZIHD5QxMg3Hf}Q_)A@dR}gB;FGOkgC>icuFa z+%TzFfAHDzzozA0Sw*{t;x$agdGC)>RIVu9)?9%8krv)rI~T17LX3)f2(<;HoD({Q zS2O`2MbkyAp<2q-0-q-ea zq#pQ5J!|AhCE&;7`&Z}T1II{9TVM^L;erB_Hxfc1N!o;9QumFWiFw`^a3II*;NV~y zB66}5RZoGuI96Ok!k5!zjaecH-PDmcK=^ZWg)NLAo%;LWyJJMLW5yGFCRt4=cs{kY z(O3`bex8&jFmi)~Q&d%NQx3DF{yea^Mg_=#LWMhoAERyzv%`LfNr&bo);2OEp6lM~ z2>t;Aqag$bP_hLL3c~}VatPu1{E*B~SQ=qI~rd`)#II%?D%o4k?XNu&Eezs;PgN!{MQ0TJ8C`q9 znU=(&4CknH;S4|LR%x{_Q^iXwrdTqq1;rc9j`lOKTNnZmbl~QZ4-l9CnP~~KLg^_7 zrL_;N{5@r2fPMe|6lubtNZ)|9t$kgfnlV+t7zhWG2SF8fcHu3w2>iv%pPMrW2e_9v zj6Zkd#&+x{gt4nx;`QqrA>nb%@NUa_eBbQ>>w+jrk_5M)u-mZH$6$_cM)O(iBb8;R zbV_tAb3zRU=l5*o{4}RqJsJYeVdv(L&vj|Mz-3`d;!dJdw3E&X3f}kc=VDrjHU5+{ zwN3Bdv5QaIz-D)wi7~(f+?#X|F%F>9Y3m?!!Rw5;tj!9?Ui>SLHm^5-qrniSnkPfg zoI!>8?#&x3WyPU*7DM;PoLtQif}pUyY<#f`O*dt^bzUq@4SVnF%{N+<7%(|Y367A;%dIt@dxj76FQP$-+cwdG0Zd;#8*A%7_imzr zqjKFM^$cju{_o`mrbAa`1FbnW-L!sOmptHa%N&|VK~AJW)1#WVezG37bghSXXT3OI zt+u}2ZsW$K8bGON8+~b$-_%{S!xz9wpb2Xr36P#4{I>n9lZ*@v1=}~TUI}V6$;$mOBGCiC{nCzY0*gLA)ylsHpr8;{ zCnv%n!OIsdOs1-$yL$&Hp7?>)IU{eV%#14QHq7MwKncbDH8*%uZb4PLhe^M=0GLYB z(tGu)on`eN+#iny5zG>CYf}gS?@Wi19NQCYxIX5J>LQc{0fB*KH-TGLZQ2z7%`A7v zPt1kI{Kw|6{6%2$X8T#ee2NfqD0pqrKk!+&<29VAGHhK81g zT|#s@Yadfqfs{~h_;1R#7BR) z*nuG@Z@bhlXJ`9r4>gvyf`9!YKDao(OMLmd=eUlPE+c%@%iOl!N7IDPtEW4OM2)Xr z>8E-$l3iWV#6J3$2B24yB{l`z7~`t^Pq%|VaC*lq*3FgUp824ARi z71uQA`u{^YkJ{xrv#q5CY!(C}s+d`B&D?QC|3hea!Rgt6t6>JXqi$H7ZwF=3F;A z8D;BsD0wJ!eu6#7b{Ti_F>xEi1`Vp_YrT6H_pA}s$W((XWK0Q$=QS#$|u^9?lqs*5p_4IOZYrx|<$GRQXH6{Rk_b$V& zhG=;OsY!v^$Qof}LfFPx;3Hh{kdfTqW#kGO)&Yg{&_XLHN^{>9R>ue&T;ABoDBE-c zB_qC0-cdjxnyCs-&sAI_nbZny4&L%$eb|{7Q_V8PiyAw}By4f!#L8aIUftDuMc)|GBayhk#%a2Z zU2~IC(35X2_b<&%O%Y>Y)t4bYo;eF{sCW;VTYJ!p6$#*u`VB z+!=%z)O&2F$KW879y=EDtkJ}{^5Gj5emP(zQ~eW-D0@RXG(^?B$Cim`CAEgD;)jcf z9k@jlbJO6`MX~sA@nCW~XlrSy;DwRD$Hvl<@`PbTc$kw#>??}I0!?eAyl)P8DN zau}(VtQ0hBDaLYs$7gJ;3jIre%jV4hFHo4xAIoE`4sK~Lkwa6CMh8*Ep2o@aA5lz!b!1RprYL#Hmv&?c==qTpt;*pdb@+H z5};daJ!J~9H;nq0ryfs6hebB5bU=`Jw=T=ln%*AyjWVcxR#n7ARzYz*ds2!;rVnPL zikGbuT^{QQ@eHu0G>T%4rYkJf@%0ZeaOV+_XRxYQ8yD{WZ5azRFueur1-xd=`wsJu}Kc)$(O|^QEXPEd(1JK`kB$NRj;XjUf%lq7^_Rf zEr+JHY#F%agXY`Dabibi(--SJSg~Wr%?0P_(F)dSEL(O|+)!cd8=hRMv=fj0Y)b5* zQ1lHKgvGM&lq5F<^(HoVaQ)PTQVn9#^_4VyE9tYni*FQ++3f86O63ZpMvJv$9{;LV zdEz+Ll%^Z<2Nm>=ur@~0m67_fLGL3h5}M`}YL#`fH?&?4Tjot|ZQ?jVNSL%oxlp|W z`ZHE3qr}A@I!ifh+Q;SU zit}aiy`*tAyA<&~_LPB{4;u2g&~T1~6jyB<3ytOFDg*ILF*~dH8$Ma4)>JK@rX5CO zPPLNRb9o7GiCfc&Hxng#{J{f};pEKyCr3n*=fYzJEP3FpgpkNxs zYf8qivvP(4kA%NzZhY{m(Y*_%#oTZ%Jgy);=#u=jN4LX!1uioPZ&z0sw)|AKmDNo?e zc4RXqA@=7=YGl$={hSl*hYh}S@9C3KU7}3$JhJ=zZs^X`4JrYgC zn7I7)IM;LNP^a$*!yl3tGFyJdLax+7=~9@XhxUwqlE&I|6Mx)|@jLDvIQ-PXu{**( za8%jaM)9{ms9nC{(S=_AW31nx$?*i z#menrg27+Ztz9M-t%2fiVxyv_tu=hEV4~x4dC9`OVuKMr_vVC`smAngr%DloN=!re z8(>CqO36Na7`Y0sdd*eWHHzX)X5LyIZlC;yPM^~1vufUzso_;N)%U5o*ym5C7m>R| zF^^U^#W>C&MsRhX?Yu$mlz&3k=hTHjvzHH!xn@6ax2$(wd#~>F%FY*k#}4`(U_bv$ z^x=7xx~c05|4_Dlx(<#Nu}pY@@Odaq#%V{{zJ7SjV#e7P>lDldW?w%0mW`d=jxKTC zq}4cIW(luL+!Fdg)_0tK4#==Z4 zd{u_+;j)uC21j|;{lpd6Fpl1|SmpXs^Yoy;jrJ~@AP`R(?G(-**!P=+ntV^QjD9l| zS4-*C-dbjKWQLb~Lel7w`YE4A>-G&!Kl$#SY*Mz{mtWniH1v)#b-A)O61U-E>N={M z@qyX&l(~btjg^*0JU3OYV?4aniPx;)G9dF+Z&!aQ^x;V=8v&s)2HQCP2J(5mCED7; zo#FN{IRP7?DiqE|$+bOOHWjYV%$S)ZidH6ig2yA3>h8Z6{?qWQ>B%lr1gilIEd0JS zI{^oZPr?XDU^Zr~i37N&45tK+h!kD}B$c8Vbz9He4yT%{C~Om)0eC8Kf`tFf^lgsQ6?rz(TVH@78N|NV{`;IL{gu7@A+(1qb z4r_{kw)0PS{Yp~8nk`x*==UCEWIRo?6w-wFBfQYu@b7F`P=mEj4%_EzVE``MrvPu{iIyyr16_C71sM4K0HIxZ^8Mewn~xp#wk(hvGr+Dat_#sB|YTwi`dkm zC8~J7D*E(Xt8wRk!%M{rL&(oPNBjeJ8|=(tWH3 zquDp1by+r#YiTOmLRT|G?E=EYpqQxTmm4m|e^L)sR2bL-<8I)2$st{K+s|tof8SE5 znO9zok@tT1?Agh`HguB6;}X%2+a*8D&)1iA32<`F@DdeFX72p_B5&X(b0REk$*-pS zP8&DQvwCzKeufQl=bQtzy!84-MRpxFATn<^ z5Z%}k8M|R#mNUoh813glDM1+Q8(5s9f}7wj@cvHins3-^VXHU1ybEp;ZNiMALK{gF zn%&WQJ-Iext%Pf|><;ipf}^~2z%?&A@Nv?BW_V7u+x-0nBQu|rJq5$TVK%ZvA%_8< z)KAul_>A3vTS{jg2Bv{@j5tPOP+*yS@%;KmXk`>O`4Iz@)&N$n-1x&etwxt zLs9nIyJpxll@~h&_x#LQ)u>|{+(x^dK3;-5Y6GhiEWIoN4Uv@aP!J^D|Y4 zyZYBqR@XE(>i!B%KSv*GpK=RUtmf7;#jURs(_$W@Gc{B^t{EPpyy29-h9KVqo&*?Y z&|A={+55`_GtI zAO7*vUuqWqrAIby1<)bI!NX2EP9UVb%x`XMv)Ry5Z#rC-A{`=HiTYWACd{~ba+RG8=~;90?Q zHtZx>>=vCaqg)-qpdmXOCoNR8)fWTVZU}O8d6Bv1nv248wU1DnT!?W8*8|2;-`BJ0 zE;Ubv^}9A4Vl?eC2mvJfO+(GT0N=?*Zke}Z13RWw(o}@J#JNxBxAEC*l{7W+8Z{p64X?Fp z2`3Vtt1RA6I7AbyO{8XR-oN_=yzM(IP0?ZT(+*MVkw>Nve#r3LS<1~tavs=tcPM|SP@_Z)@;W@gq zoMfSZse#%T)(iaR314<%O48VLX_Hs*9%FK*FC=vUIZuufr!siqej{R66{bSMeF=Nc3)wB3*0$1O< zc{4%tvx|I^9cW>}A4tu%G3f|;O@ajT&xp2C@Du#HE=jhAT@N#`*46AcXOr^zJ)rU~ zFRkm+lYgu=Y-zvfNH@8jBQlN0=^q2ZBpC?@rd`!oY?J>kYATsfs3k~`yZPR<&TagZUB#7D+c-fZV4>2!iG>!)8Km>PG&_yJ?Bq_T{@O+0b@c&u^CWEP(0%|l2w!*D5M zo(?Gi1ZmABbK#IZo=BLCg;{s1Yl= zY8|;>5E|T+LqhT?%|}#y8AE0jrf{^CX@#6ffxZVNJpbXSE+fOWB-&J3>o4AVZ?(?q znxorZ$)58{Y@$n6oIm|@$zA8?l1`xIT%p=u(@LW0Q_Fq93;|K z7UmTZGR57NsT?fCr?EFZBbaWrfx$JNoCFh}ufyZ<&>(BnrX_Jq%&}2a-n=`5!=aCvlpCt0@ys_J@^EU zKFmn1-Q9Y3WL+T zftQ%jv92lo**WRvMnBFrLiB4Y4IB}qR$ z8RmE~#dPH`NMG`N&1g?ik3Fp9AwT6#8w9C!uu1qTuJ086rmmV( z7!~n3k4hR@z%-~i=a+p{m9NcJ^9so?-7k)D1A1Z9rcAc3RDL*$){jK>5sH?W&0pfp z9%JhU!;d)~EWkPF%A-#2!da>8XK=08v70w-%Z3DjWAMN;*j>5ydOA1{T>*tRM@Pt+ zxfdeWkDt3 zp)`b{72zItoZTJK1EykV+fRS@O3P%boAE?4hoO>DTOg%nWz(pQT!AS|3N$o`yFF00 zDG zf0}k-V#Tm)X^~Y;!xJh@cAUwRZ_c=6l^;0cUP(=A^VS&&E+}w*{_NP8v}0BAPwm{M z0SrD>p-a1b7`P8YMc|2)?O{SBEjvxjkBZd#7)Ep#`0xU;S&&A+{c4YfGygl=ZF`t! zZHNuq)0AA8CT*`43`rFxBB_HHOIy{%k5Q(yvk-Q2=-*H=o^JRkd)-VA%Hkel_UNTO>R+gQ8G^tC;Uql zvci!H>6LE_dB4@qDA7_|N9WIq`!EaTE1ytLmbQBA+^N^L3?9PGoJ^w!05E!O>P^SZ zFK6{SoJ(qr?pQ}%H(^k&c2FS^-$KNl$IC;PAP+&2x_vt)K>juS0ftR91X=H!{V4Eh2GPnI>-9zqE6_QJwh=jw+n@c(~D>9sV_>pqK65 z-6iM_U7B!)a-5N^u3IB;F9~Bn!5*Ubpm`^AEpzi%OtoXbi{VnmG*=A7(b{cL_w!sx zPUYD&lP<0A?Q28JN zMAb=etutrp>GywguqiNUm2M!8hNU8RX6)ti5vY@TxeH9saAnKo>@Pd;S3|!~qy^1m z#%^PXHq@w~Q}7k8U!NHr>+A1tz3_NwXw&=mQvH1xI!szb*FPLttj8LsXuzXDO^ z4S9R^3kymb>^aHoZy1$04d=qNMjW;8-phCIa({|tN~vxaSr46T{+C!s|2xm}&c?(v z=r;aP&t9PWnFc87=7Ta#PNAz;Kd}2|PQGTG95J)97vpSd`XnfzvQZhv(TDc$zYVJj z%r;256h|Bm=!5(B$Jd^!)|)AW$M?++@q5A%M-`=p@Vv*D(pWv#)YY_B$zR9=RRN72vB#qw=??nO@(^XPkt6 z5xm&hx{|d(7aD1v|{diC{a5tY74HV_$WMr&u zY=-v}v_`a(ViOhmYxiTUoMuTh4b?+IL59Y8mMILC({isumFxm2OCh4<<=xrZ7!;iJ zck_2PMm?Lsuq(DeczJ8;&`J#;Gk$!+G@sGESw182h|HhND5}2b^5BY@>LLtK@q(tr z_*?UCRn;!KyK6&+@;s<^G$l2KFflc|<)SUMFszPK=JA4Yj>cyJa+s4QPD-Ht7J$Of zO?pDf7@i+*7XV&px_X0(y`*76;zgZ^b_(i@^*h+3W&0nO8E=TTk$vBf?Y3<#yKxw1+^B?wMJK7hm^YS9$s59F)<|DogHQ~Yuc&Vfyf&? z_uV_nlpCAlsu=2%g3OFRb5X{G&kEBDuxw*InV5IDOf+c8!vj~>v!(*b z^b{)%>|aH|^2y+VuL{3uzp(Unw`>2jJv^1B2bGeB%;47vJC4DgTB?DeCf#wt&AxGU zu|_^6Lhi8ac(&07Awa=qI3Z?;9fwRzlV(Q7muj23* zpocLEeOh_%-m4RsVNFvtXd~ncV?d6Ig zj)?!bNeF{kr9zNFy*ILKEgc^ zcH-Ubl~8(F<)trOno9L|hK(;t2wEQ5;R>}=?BNzySFy9$GPd(8tj=VpD21yaC@gN9 zJVoxzq(zIegpDNlF`F>VSDR!*nuF2+us(%3eeZ!IzBL^YRc|7wWky=|&Iy_pRA!Yi zA@hNykOd^F2fYbMqjb5)B-1+9HG+e|XYtgvT3oY@avr3~YN^m@_NzB8>*`u!IO&kw z-{TDy;b)+87M@z9d%nV_Z01L;uIz~G2O5UzvOX`L)W~REA+n!F$GE(IiPI6G<;okW zGG`9Y$%&DMb${`!3a?u)LKXePZ@qmhFSexo#!hHwZ%w~HY&vrd$U5*&#sPxrZZ&PwaU-J2wK-&>&!8hd^ruDrH zkC%$UaUc8r%u~6Qj;~kuTgQ}XuiV`W#z18ST1WlHBr!lsYTYT77}5Q0uytT`*dMwH+x98t85u{2FnebG2#^fb^M5<{+s+}-}^^J@N|9A zrJT3v*URT046c&a41c)hoKNvQYU$!+xl=A(;lf_q2wBR~+(()tAoA|fan4O-_~E%o zX~nDp0=hg}QS8(F{8Ex9wby0r)yzmK9p^o6k{3Y+-Y^asEggA0_%10Hj|j*sYZ#fY2Rb}1=gteX%3c@oQ)9N^GBl@6?Edt;}hP6ZyZvy?y;Bo zfFyGn-HUJGIGZmRP+Ckt z6QLhkD4r`jKBXkgw{FMqK&i}XtMG4szBQHM>bf{PkIC+SvY@(*8ItQ?4Eqw)hSbHV zku3I3Q-zYQVfM^_Ol^0+pL2;RZ9{x}fvbhAcDdDaudjm~MlMzEuAXBMf+tt}I(5=p zN>67e>)tBce2rQPC>N?CbJvk0-@kjeVdKU;=g{Ssn+h)-!AFfQS+DQ_rK?e@i{i7I zHEv(QQ>u4+N!SEQ_pbhf8WpPu^ZI#vM?=*l*nR?bnuMrRmccPaIyH4kJ5yC{X3ve3us)2R{`gr}P@qmBv&xkKD@TmoEnP3$}Q(N5gXV z+I?m7D?s+xtAjo^sgV@1yhhw`&)%=`tce(Fi|jPvNB0jZ+(r@%@nxY$Ch3J@xU+bw;8_S{93S?Bd4Og@ zJ^Q#aX-7SZm~cG0a1_8VXl{Mb1HtrThNOApm;3*$^OhT{&C10m;t6Rp@Z#VG3G>`c z?}<~V`s)Sc>(zyilSUa=Xsd;%TI$eY-BxL0B<<+FbAr75MtT{zE=_9N3d&)YjeVUPagF=Nzk#|vH% zak?pC%N|e=h0gN~OH)GPPIaD?R)7PI(X9BG7zY=Z7cWr6fYfV?0wc)Lc#fhiSm6f;%_@&#doxfMsR61x0%3pT)upP2BRUz zKh($a6)Xc6aPDX|c4W+wUL3AZDC)Rz87?arQ8fvBfXk_QpRSnpo2m8TBXV_YQ@${7 z2&q&{uwO3eCAz{<2($N}atwxW37#Rgg$j5{>9*eVM4qu>J%{6{q6nI$Q`W4`E7n0txa z;4BCAOnQ4(LYO32&e>G+p#Sho0hYieCxU|~_WmQUnKLfplue$HF1xmr0v?Ix3^~K9 z6o+A{_^I_j+~c&zu#(SbYiB_)>m4i_8;;mwlZ>taS>5K9&ph#Xk;&bbE;Ouyd1_JAjx#?+V)TtXKDA8`1oc0l11}b z%trT@P&*&$Ct;za1H?@3fc?{Rdz9R@WE+Ri-FZX5_!Wh=p~B@8?~SW<-O@~(4q*g1 zxqmULau(;A4O^g?Yf9_{X=fId3?IKFHpK8#y9mh*hwt6vg2DFApZKsE-nZcBF%Vo1 z0_x*OD#0m)t|@<75Gb?QzAa=;f<=!x=h@K#xFRJ>qA*qRP7@4}<=)+;$}ClZ*2=)J z(D`FaOXzf)B^cwl3zYdFf5BTvcDv+=5xG6z@vo7G6YQp*&>7#?mn2;6tFI(I`Z)4l zZ}by=mh7zmN4TeUVd~PBm+i;Btt?QR%g)Jmyn12({{0v&BxX^**i)fWVT<;cP_%>` zf2BEe;YfMmgq^85ZigMqDq_-K$pn=BQL?CmEzXj`|G062N#B!+Pj zH*bc60X?ak+buY)ICXY<$s-GE>Y9hIL~U*DE$r~i`Y|hD>f`{e2K4I5nvl>?N9BMq4Bbs4{8RA$Ys=c6#orfv`v2LXEZMO}qt zPDs$(!!YvvPKu=vFYx&@K?h}@>3nEtgRYDVucI{wq7)(y1qsM)=bD41_XV3WCuTB~`pVF99ygXjh<#mI@{oZDn*@n4y`xtYJG#(OoN+1EnRDm(@#9=Q;$wY2 zIJox0>qmiN5dg@)rk#(;J>p6O9ua{|3c-dnfw3E;Vl4!j!ONQ87HKXwd9sE1+_4D> z39<%{uQ^)nD-QFBGZ0c%n7XOG?Wgh2j_T3S(+CPMB|^{m9r8V076wcQW+hMa2_mf@hC&JgfH#%IH zPVVi|mj?=WmyF4uF$!nKwt4esN*byur~2(iLu%Mg;4(VO#TmHMx$>JYn9VmC^I&17 z#8=vkT|nQE1g92g?*dd4vBSjW>We}OMZx#kz<`iz-WQY_lUV9?Cne`GWg@4H?(9w^TNr&1}8GeA_EI#7^k$G$V6i`pCfFwIR1-liuDV(b6v@xDl8*cuu zOUuiA=6a5iI#(8dxbFSd;G5Rs_ATNkJKBJp?S!bOpZGL{pA(2C!s}cKjo=2e(&XKF znB}R*?()w%SGX@bY+>-fe`5E@>kYc^t32l)srfg4f&8s`d^aFNUdL9n&Of(MNP8Dv zch@g^5|G``t6|xh%!kwd^>^sfQu@S0J1YI+?6}m|Dg(w$|NV!FE{WZI;JN z{r;!}P}js#W%l%6L3x~)olpWtNM`D%InSK-5hS`vqE zod3-?$c?<9oIfB)|XKla_Ua{T}Ox81)LxSfBDWDQZ#L;q(n@Lzd9;U1lThJS2# z?7esX!1(ZaS)%#Be*W)cEIZ;nP|@c<4h3DLrN5>1`sZ8SFoPjfUvd2^=pz*}@Mh2M zyGhbp2uW?0Kfwh>%+RNeKJtht=4xt=-uGEUruzDOx^^A?`grGNp{jxsg8(jwSf0Or ziudoL-#MQh+{v$ryb22%y(zxyX9b=N2O$zZ1&e_XflPZqtaTEA*IU!No}VdUl-+62 zyu1B=Ufxv%#BAH0Qf4|C7%0@sD6lyGlVWaj)*%4VXn_GSFQo=yw1R!nL}?v-{qeYx zt5Ic9yB6BE1T49MD1G3wIDk;4~ z%%-7JM>-l9lrX5AFt?fU3|1bE4x$Lxo(bn~$LN_G%8&QBgLEG%c76dT_CRwIZRz7tX5uI+Q-k2)BUQR3`O`?G{qkm_t;-$ zN!Secez%)moB@L9Vi+jx*j`Z}@S=oz!$w5IY0+AB@JG+=MCG^jKOK_%?&u}G$vhBo zzANfVxD2mei4Gl_LhVk=TXth0)b1wy@RnS1Z)$ivxBcx{X)Gbl@TWX;c9_YNhd<1& zZ{6Ix`UQB07XLy;QL^m@lv(B_S6n+dzpl({gI-|dR@_u~p~SFqyyyt5 zy=|Whv1_Di*GHj^+Ziy z4Ay6|!t#Strv*JID;WJOaG^-|drj{T96U%jeyZjM@H~#l*SgI$jkl^9D{L4-XyPgBD&2lN?*t{}KRVbVf%f3}qc&>PbZ8P$&`gN7Vi; zJjfh?F(I#troMim?Kim?K?Kef*jtp|+l<;xJ{GD^nE&Sbjat-)Ql@ZQX}DQ%EWAeP zxH_&(Y2_^;XR+V;J!X5L|KKBilOI<#G=e(KI!?;T<6Sa-^Uu!CV(%C~ylmSiI!7&? z{$AP|9@s8MxfINc@3!B2S7xeqOyFl#8BmnYugH@&5n1=)0PwUjEh3y{opf375ICdb z87SDW!|*LVEP=}>Hbkh3`U#gJjb|eJi#R}O1cX9PPTq1w1T3475M(PjVX77E@D66d zDEaV=qW*;Q@yr6MWz@{i%&SD!==U5iq1V7|fix_~vK5IudeLXsh<12PG8hGuU_uOm z8C4@T1$V$bfVE&uRr~(^{h>cVX+Y4yY^*c8`VDXv1yr4=Suc54t~D=jA;gSXXzq#| z)zE7U4hX=+uk7UkFhr6Z?)Q~0W0{(ybJ!)XxBCK>0Jb_XbIv5xz{sfJrj@29t@5VP6SozYXRc&LRUQwZD;mghDqG+g-NmF~ z*_A)O*gfWjGQl+QVLalKHjfJF^{}8)AJNZj8Cxy}l!q1rEUpZ7VTYY9eUzz&8`1O; z!`V&yMMPT0Mg2lTh6Vnr!m$P@!A$Wf+r`DAg$r0)W=gA|Si z)9`@!+>H3era77hT{}A>-|u57KTxCMSW8V3sN3q9zU$7V8sJmK#h} zQ-v6RKve)P`zFQO0uMqqQB_YOkF6(wkV4>V+CD&-%< z>O#K`T+|l^UX= zyWJ=JVvYKjv#ybDH&BnzrKL1pAvhgFkuLSEtxsn>O{2bmkAr5d2-o4}jCa-4UpS&Y z-o>1Z?{@GaLLCSq~S1sx5a6KH6K68afT1+ zf1ZN$>(&ao(GbW{e<=(C^=;)R=2Ev2g$S|@0x~j3n7i;?0K3BC#gqj2d__4AXrWlw z^xYhU9jdsvc410wU0o6QUZH6OnI8n=@S=^=v$H_NW=K235cl(2h1N#hucvJPO9mG|y3 zuU)es&--|KQcrEg>D%Fpgr6n_$7x%a9{}0?2k&j(v`O)xC;85uh|V-x%m?-~_893k z2Y1%BNc=j4oPlxCeLG37zwWKFGZC*<1Qy^~@Ex=X6jcQ*uN9IEW}+9cnuD#+>^+mP zaH_1V#0}DNjYnOZ+QNe~B>F5VE&|7l3E5Ya+jz>UwYF{B7A9!C!OA!bbgSk!JIKkQ z4Ajmrd8mDO7+f+SFq-p+p_eE2e)}}={vphw;n5^5?#opMVxZ9di6F4wyVt?o4`yWS z`R|0kH6BxW5&R=y2&BQAyri?9$wFB*pVtKE-4V#DZc*wWd4DF-gONtupSSHEo-y_! z0`+&cqYOGdh|*U(C=1&^G5=jJuh+Lj;nzL9-D@Xu9Z~|sWA%#@?R~L0zi^-uj+eYU zCgBK3`JR59*d`7slzQ!ohRkdig)z+2dBtG$YTRP3>`Vv@(EQ?zgiS3hqeY%{gFZ6* z6>NF)EXlD82n9ZI-BdKe>*`@7FBOzxqkAe%7-)g@N=Kd}Zq+vTDR|@FkYCf&Dj;?c z@ix zjuI}Ib0?|?$3*B5beQg*{AlTR>rl@m;1b36rt;Z%3lv(AdmN`E6bJP*XK~o>-P?|7 zH;jDfp5BL?0+#z#?d@7g8ksDn@pExt+xh3Vq4@62y~=1MfZE86`)>v;U@Sh2V8!9F z=dDY|M$xob3BF_2EeJXTMPPgh+UV@m6f8(G^!ZtV0TGeTQ4cNPKnRghUn7W>$Zf?0 zTeB(%^(a!H7ptR$8IB04nAhZG{ILF#cTR!~Ko7PAH@N%!`0)cu*VdGFprBSFX?LHB z6l}FmN@=x>vEYi;a*>m}ch0a7SPaypmF?~AJw3(n?7&BW^?^XR)_y)d?}wEWvIh=I zO9J{2ng~ZRo{(^G$J5}aPu-yK4|Z1eXniq$dZIIH2);VCoCjg14X$_QAgetyN4MdD zYq&+hm4}ZUX%8OPDhR1NJy($5`m(3l`E`T3ZJ|PIh>DU@H99F(f~|m`8`>I7C=<@D zn(Qmdl<1Z+ zwG5mdk|gYhjTpEuJ^Tw>JoPOPMod2Bt@ZIz!q)s!4$bNZY1A-~JL2{m%_Jy%OjX$4 zNdB3q_0Xs3b)7*_*H|nD(1&{JJxaW2p`hu}!G84GP#fNnb=Rg)%EyQ4fMH`c=M8dV z(_5=tf2>~MuHn}#LERuYfmBLUyp|Hqec!nDz}u88ODF{W2}5^I8{6kWnW{8cN7?gh z0N+JG45rkCXfKQocAztZCqh~MiejLE&?abMJ*E=dUM*zC zr+C2`vq>3fA3?6NIL^knQ~6db8Z#))61*j`pEk4wY#~`WAJ=sf$oeIab*sMZh}o>m zBR8kaS5D>t+~7w64<@l#?HC&=P1lRS6~*3M!+Z{NcoF6dV1ij&&4;YHxwsUGSR!2t z3SXqB#|Y5oax44G+^LddepF33lLQ8`lgTYDSrL`{yYs&p4Z4>(F%C0zejgkbRvjtw zxvP`(_N@!v81WSfbD2FVHsX+r^K~EDO%)#7WY3pnu>*&RIYq1;TgLh@iwBCX>}}&p zwc61Ve5TYkC8z-iNxJ9yy2USvc4t4C{^y zfAushj63&DAVx*&snBCUg^(#IyQO8}^Z{!99*afG@Nc zHjeE*KQ^i?bg|5u^7!}wN3fd`s$h(QDj``aExoJyN%KdcW3U{J=Jq70cO}yjI>r8) z$fu(>!T)m94fe+meNiLD!chY)+jPU06R;o!iU4~Lpb!`)K&>3yr>}8woraWxazE$n z%n?apg|L0o4W#`wm#G3nlOx*R1)IFt8Vz9uT**W)j^S~{5(2>w)ulYnA-=0~29JgI zf;lDpFq3 zMmyX&$hd}B`UZqv-tRsC$!*|~_Qzf!7k4^ZTEgnifyXMl@JzyCx8>*yvte!fPxtCB z@H16;M6?i2*bcVPC*y@iPZIMvoDmP@gv@J0;jtEdjXKF%Dj}FbiVAfJy=MktDloEd z;K@zsF!pi zSN!z(Q7f2agm<`q%r4A!7)p$P?10G8tflW`mXB1&fVOo!pn=ZcT4YovMS!o)&0>Vo z@>l6NoO^KpRaa_(bZ$ESdbenn8+pZ-4j7B@usQ_)HJjdir9aN(*;(H2JKCK&>DM{G zgw|=T$z5qI`fI6Hp9B1(=7y$CZ3d^-l5f zz(-pAfxXSW4VsPSgNmv*kZ{GRt!BQT4U;6(>HrABeV02m66;T#Y}@<#cN;G~N`A%3 zn^#8$ZJd2r^u@v)f3&hKBP?TOd8xt;qttnf1*ZIDqXAJ78ABkv&}@GQwk=(^mQHP4gF}TPIWL z_~ceV_?YD2g`x(#B4)kUBSiij!q9_4+vRrV*{ajQhuH{M%cYj^;LDjGRoMz|4DDLp zzvVv|wS^Uk$%egNCzCSX7?e65jX^n=iKS) zS0~L3bFBOaHQr)x>M8)UsITEMAgss5l+#)h*@u zrc?fa1Ld2b(XvSQ`PPt}9nn7YhgY#QaJrxyf*lZAaZrM0Z3`p=WA6xodPD-`RFQc) zYyB|l<}5{es~w;-R~`B;eE6`pjEQJ>tM0)i>8?%h@i{c-2sfMDkYK*aGB%xfW!-0+ zVrW}Kf*nqbhdGXS2sMk{Fn^=zX(N?k7W$MzlIIv_;FV$`!>4{+YMn-RZB3u-`uWFn z$DUNJlAWY-;bPnJ9IjTk6FLN=bSZkR^Yfv_ht9ZJNw_7%eK!-8;0Kb}Muuws!?-oMxvW{v zd?ocIQgE>&Dgo7lJ1J!stquC0MKb*AeFk3K-xK6Ud${{a@7K(pd{q<_9*M88SJ?p{ zy`&^!ZetB@?RyqjYlN%5eO+{(jz<@1H72yXl`I!(>u0sc4wXGy&J7C>*F$S(_dGnD zvF4KyNbkSvB_~x7Wl}qzc=FI=zM7iB>$K|lmpR2z*=Ao-c`xDEmLLO*3dA1iGs?>B zN)nOc4M`&pTTqveNxtvfph2{#GtN0r#A*o*Oh=SI!bHblw=rt+g$ zg{o7N+QTKu$=l(YgV*K<1P3NQuWKFr{44iQ&hL_WyXqX_ae?HKR8kbi~{eQJd^yg>jG8V@NH{sAmgQgP!Pn*?WPpdD21^o zx)wpf*8q~{0vpWSzMx%%W6wu6f#9yV*6LEt@Vmm;`A7T@?Rf$SH-m}#ekt-fm4ub< z^*h}LJ-tb60G-D$?9z3v-ISNd=6Lh=XZ^Q|bwt0lpr@+~^~rCr3CDQ}@$uE)-n;Jh zi)THCst971bYe~b$m7*WBy2Q0kPOFrO4)nYPvBnTSxgKJAMa7c+x>VP%&FO|w9eAs z(d;6a5Azv0zn6?gm@7=H(ivWzo8)K~T401a zEbbO`u0mn>gmw1yYr%z~pbaJuiyOt@7WJ+`!cycJT*v7Li*~WfeCh4&t*GFTkm$N1 zv03agzYf`k4K0@`_Adw=^ikZ`w-&VQXo`^{S~OL8!os0@>7Ys|+8~^*T5w?h_N{cm zw6Q2Q8XZ1;PDXk~Y7ha1fy1TL-DkA z^X6YtD{xMj#xW`@i)Y~@VwI8Yb`|9>`n@kJ#YwHB@HK0awTi&>9wu9zWMuaUTv?Yj z)%#X`uz{+w@(waGy2IvZDU|TJP;Effl=l<=$ZO)}3edt;Ad%22ISoaCfdYC9VwIsq zZd>fNo-sjX7p^mjo%_t`)0oksJxmN_+}(U!%;h3R4r0hrlLn`Td&f>VF2c#eqb61F z%>?kE?@q;FU*LQed`6Rv7BeCfqiD&aM|C&@Jsy7oC&a-X)DNI>03+isKmHM!X9Jf7 z964}ZQ>l}LZp{1O1mT?>ByFf3&qM{QpRJ* ziBk&Qe3sdLM3m1oL0JaQk#IytxwG8_L_5yY!{7dYxiyfn;#^SSR902pNlu=f@hQ`R zJ{{K8*i4N(i$PHU>ja)eDB#8iG~o_FYt}x&XoG?V9|^9oL*j&Y6|AW-RoCR&gKh`4 zBITww!AAQETnJSn8CV?m3~t&y;P|1}Yi(^s7`TcP3IlyaUD>ArK4~s$syP*YlkBJ8 z(0R_afv}_g>kS2N?pFUzyZI)}{zW0a4K^Tt;_ps`^3Qdy`xbA1|Lx!TB*o~np=pjUU8J)hXT2?( z=4we*C9-`#j$cvB79c^@iQe6?L{)u%1b2_fx7j8RQjxXM0kSf*%b@97ik;0tIpI)} zrk>Oh3PKIi2XgAKNex_AzF&J!1~gkCrP&LhYXIT*~qofbl{ zRN$dgtMCzsXckx}*#F?-7deFX!ka%}ZS+Bx#$oUS76)6Gmp1QPyP1h^V8l$&IG~n7 z;fFi%x;z7?2e2bh@i96kOn}>XLV0J#gxXbBGTQKFPiU<9YkM9RG0tAj8VPBY= zsnvG*(!Q6N+OizIen3E=!AOwo-iziDVPRncw?%dB_t3eA5I4c0E5aPDrP2)dQ)`U{yWbe?eNZgLTJaD@*v(_1nrfc?=SRwS?>bjNCBkH zYu7i3tNd8*VX@?C!UKFC86fWv&LACGL*B=PfdnBI{z0TqgIM+F+P!_+&yuG0ANbv3 z!oQi3I^8mRieNAr5)s{5d){ScmcmaMMW`_R6x>uZ7&?IT^XX)kbLGt5e)dqOYyELb z(4m}Qd<2_~h%*OQ883Bp7a?oMXE%6bNCvcN2Q9BSR8>k!+gzTy{Q(pCH$ZXCM_gcj zuPm-Cmw*%%5*(cA*=r;S!5NT5-irk-A}X|$l<$_w12HdvHITd0dan}tu3htfoAx&f zHVQ=_d9=#^dl89kmR#9uRWZk+u+BGzuxk}297kG>U-q3h!=sa$H&2X`&NXK|!~A!5 z__z+rc(Icc|HSQogw0IF37FvC^`gXjfk2^enObdZC<7RMLm(>$7g zJ=gEwVfGbJo~iL6xc%-8W_FD-7WV1a-kGGCax#>htf=|`bPPX`0Rq~ed+6l$)wfuu z7LI$l78Qol2oBvpq_?VI>VZwE){am&V*JDI4FdgJUrf2`F7ecJ=vv{En7&!T$#vur z^SdChdY_X>{iLc?$uFbULcxJY)km$3ZD zt)kni3@c!FxR_%lgNy(J*E7c4xBuRh=mz#Q>Fz)kLw{-gyP)SJ_A=R`)%TdTcQ9Xn zXW=V)VLZ&^(DQ-nhRQ}e-wcCy971S*pc3gQ3|@DPLJDfBWAw{ASEr_lr$BNb9fv|5 zgT23iOz5`WIW^{f!*Dy<+8m#F|Nq0lIhI&_z=!mIsY3qqsVpPE>SuM)3e?)2h4q)p_T)_x1p(0_b^)!+Xg0+GDD6xAuF#*J1FRzD~41B~aP0#0AJURQ~t0 zVQio=Gq~~VzyKiti}^&+Foh{PpcPn~sHv#9f@+NYacq=%FRwNw`UZKzt_ttM`t8mH zeBeRBHWEbbv$*^QcAMB%5YvqpHWyB{$kgT$+tz>dtCncAX@G!nLGvV3IP~&tiO2z2 zAe7Kz!^{>Un8iPTjvhTqbq?bWJSvEmgoVU_gwuJS{!Jk5yo&U)Nee8&WEpr;$6aiX zjaM*9hG~zu4**yqbrj-E5C&fv{kyKoLr2YErUbkQV}D&;!dV~wmkJDGqhIo;;0BYT znZnG(6wH5-@V*+JaZZjg>ZG=n5wq!WMbJac!KJ@{QSxp@8=$7vfEaG;-eTsx{QtfC zv513ber-5)vtw}-$__9)<%0qPr(kUxA>-D?^>!2=hS>kOxc0HZAsHDw1C8HprT68? zD4A=)9(M(-+!2FBD%Ss)$zZLm^EiJq>^v=+CMv4q@{lanKR=2c(R?q+70y3$+KFhM zDOYSqm<50+SFQyEfj;aVWO6vBRN9$=zcAVq9+xL9R59vPkq0JYbc0&Z&~1js1$OST zC~Me88)K?)2&ln(>J*g;EosJ93^0-$zTqSV|J=Vn51X`l z-9}>*S7QG<9Vd)l0U04AemKvDgjCsmL0ji7)(X-kIt`zG(5NvGy>RG)q9RF!ll>+1 zyU;&oF^ME!P=x2U6_8n2Ct$n<%N)uF-~?niZNXa|n~M+;H?O6eZGsB-|2^|kWY;I$ z*FTdOo~n=t0=)|HXj*S=F&SMoFSu@RUl_%I35Fn>sra;C-#Pgaaz=NQj>3Cgwo_0X zd3AT@SlB4(zE3%oq^N zl29Y_GG0SLpL^;f%h{Ph41ozY1m`s{dFtx;_U(HIY6uCX67hW@rtM4K{NFbt;=%ey zU#RLJ&E0cy4-HLc-f1NzkCYT1A~G7fqqEF8bHdvLJ!CO$Asib9 zSR-Nl@66wsDl42i)h^OyHp^w{e5JKGBr2*G7mx70($D;NQ7A}-ukKwSlp5byfPnJM z_^P8N9zPCBV(U5a+S4=Oe7%ZmMJWe==Z$O#|6^Cmt_e2MiO?{7B1yPpG0EQ6ckSuy z%m)MwaviuNeNM$_HrFbdcMt!?J*n+yd@q_)*?w}6gX)~QIp>Z!adGjEyeCCP(uC^@ zBB+z!z6*@ocM)Y++y&HY%5!YzH4;3m=klk3ljDhtfZ;t<(;dptw#?4WRc-yZaYV{d zU;t+_6Bo_j6Q7x>PPqv+Fml`zvSo~8q-t_E66Lxk<2Odx+fsT&x{vAZ zOYlD)r{z8Q);eh}dpGyguV2PCsKed$MoScEPoHA7=RJ=Zq|EFKJWF}DtISPY`ceGU z&}AZi$Cde!3n2h6yMPK8Q}XOgQ%H@l$U6E_1q9|ZRD;a(>=hjY!|9y5;u|ynS)NPY z+NCAQHrq=LB-~+C9*p4T+#9EYm+5N>IDV5 zX@q`G$Lg@~5hsov=Wr?OxH=9jWq>$|xmA#+Cc$w5vOP|iDxKEab9sPJeBg{w44|9? zk4A61g~w4llP3KLOy;Z5Qg!sB?fY8xZ?z`!prwR*1KrQ>g6?aG{6r!zdcZ3PzZ)2s z28#+Sm^h@re|HA)arKbS-~e?5T@WU+)v-61(4?WcLl6dn>-|B4d-e>#3KbI*Z%9pl zUmv)a#tX$5Te1q9l2PIF^9c$HA|4qOe}MZj?wSHywr(Y~m2kpA;h&L}1ucYs=wCdx zw}9P_{08^n4;}=_B(7iY0%I87=Rl3oWtNl>_LJ!ba9(B)c40UMzX}No zvhFUJf&O1?*Vn8Dna|MtiuEV$BLkiT;U!)s+do4E0nLmT&%d0o7xmR1+DBF-l=;v` zcfmRWA#q42sIhyl5Yu=?#B_I}3o0UQrcvYDCOGzGH+of0E#_<>2oyI}$3ZiqK<-gT6Tw3d< zGq-*Fc7zoOf`JEI7$ns;d6o2bJg!w06$I7?p<8g7sr()k1rUl5rp>{@+wURO3Hk-X zMgV~>+7^zo_~%5=2@^;oK}vt^1n%#y+wQ>!p%PON0dDT3moM*;P&7cxbUDgR3~@TS z!Ty-|VlR}&^Yrqi9OVO0g7FD3br~;NSrX70E=_M9L3MWv&fmFkO96N@cQ_QgCFY?r zfPj!M`ve@Owb#4*?{k=cv$A~)HhI+iCs%ENYIeA>9~;cUm(c&Nj2W(!asOlJJMucZ zxO^_Q#b;S6+{@!L5H&B96Y%JN74^Qp{FSF%G8w11(u&Lf{O^x5I}*k{D4zP;{V%n3 z-JM#8Y4^+%ii#Znc99ncd6Lf$t!JfW?e{|Uw%Y8?Hc2Tg(cv=11 z?$;%DA=Z>lu8-#5%9m;#n6s4FNw@kp9LsP`5DC&#Uq1nY7M4cU9pW!`2A^Mj`=7A{ zJsxeGK0icWk_yBJ`S3`_jLF~V*k>2~=FTZoU?nIv%zZK6DMYZM*EZ?=sc5X14D?Ps(Tt7BcHoxcVi zC_OSZCJDqP#?E}{=}n1?&~YFk8RZgALvJ38Nrr!U1W_5^QzSlkS$20$BUt-DL#P{3 zF}Xv3vykh8N-rENK6uoiL>_xz9Rb!kAU(%m1Vls6=Ig5l$^r23Adt_mJHWJ!2^ zSXBaL?Cd;>037gqK~SPt*-c}JY#~5+c%eBV$b&E#`8k5C<*dSHw!#0-UE*WBNRa79 zzEW5izP(}FW9f5Tdk`iXfWkwvAMXfOJ%e{;b2HT3DXpWR#Qi}f+)z`KWp^JGydN-~ z@PsJ=m&$3~)v_Km(6&M2=HBuJUd+7Rgss1;6@?lVZ=FJ(T{igi6xLLuqh_#S`Wl?^!nz`%NOtmT&_= zSSUD(3|6WSMu|&E0G>DuWjT>iq6G%vT9j3Dj?CYm2v7l0mDo{{#fE54uz}#U$cA}k ziS4&C5Da&2x!n=^D2pG`dSNIWT|lo};-I7Evr=chNY# zJ`6{A+|V~5JHfjV|;-g)<^vel5oHMV)0yVwhYO|x!kSx%tj5JqA zN0lfU&_tgIbbkK)1_W$gUhBq}CxZ`-Ltc$7MIf*`)(T=8Zcfg4+NFN)S-*a9_&U>5 zSOuBES^ot-D@it!abPl(8uI4Zh!a^Y+CHHNZp=SA%ad6Q>SHNsjs&C+r@bxOc45SBrJnVbaf9UVjJR_mZfy|)T*iEbsSV2`U z76S5IR#p$U)&~15;NCn>KjPj4YD7`|G&s1ah_V)YXx*#0vs{tx%T8e`NP@tw86Fgb z%#M?D1^PwUEmB<@tl?7QCJ92LzCM#@8JS#2)$oW(OxAsDug3P9IAf_qw^MDO;CM z?**`JxZd2n$pUpJ+`h22KEtpj?Bctss>eTCaYUwc>k<>CjTY2y?66=Nznh+(?k&l^ zGZ3U2{Ahpx0Whz|uW`1Wc?JjcfaNxnZkBU+23{=t-6*HpCcl;yx~{cG#>|?t6wX2x zX=Z40Kj&Cg+3AC~0@B5ZeUtYpQbOXw3?D|eCWYMl_8I*is7B)a(~sayqeg1I!Z+)# zH~SMOfjp#FF0`Jw1W963qytIDScR8~BjztmyGe@;0fne&RJO*V<^t=O5Srtsd!LzI z-gpJ(5mk1mih~tQQ<*&Xk>Tr@l4At|d9oKsM@MO~>d*KJf-P`#DZa%b2p#L68n&`D zmtJV*VeYjRB~Wm1bzNPY`=i#>vBsFkbex}YfMMe*4Y2oTT7i{+31e}OqSh^!mGpdfk9U#W2VBepx}9dBdK*S3KdM1vtG zi@#;T*1}mc(W0WJA1H$xHMO<#aHx(bLqvL+xl^V6^#7&#iKtX6#DFD1gd4H>w^GK= z_v5)-HNKizz-KMSiN;igRVn*CP!eWRxw0tbUYj`yT`}WO4~~jzU{OrrPP>B8chA68 zKnh3$4x@^-u6lg)yEbU{1HX%X9J~%cT7opm@eF~O*9T9#K3Tu+U+QkM=fv{=`{+k+Nw58Vkp4kXK*u!XB!fD{`l4a;Xw72SqOhQ#pEUTRPe)Qf{{^IxqR)Rs zx(7>1r^_5OFGOCXv^HgnD|a`SkLLs6pud^`ybX5WKuw06a&#}EKRMPgParvR*D*;F zqkWZGy`ID+DfyZ3$1%TAs)8T<1``Ok8Mh`4nze`$9+hEMLb&&9^(1T>f-ZxoQu;A9 zp1*=qQd-7sH97ds=m{^CuDwyb*%1No4&?8)+Nj1^y5l${zhSWot#C`neK4v+<8?Z< z-E^#A3Xa?&%1vpSJn=I0J1g+qefaKt!QFiY5QoPvcFl>)J2H5}{BgIFjRVuW=%pNO zvD^vD8c>yv+aJc6bkg~9mRvDO&DHSe_%;?v{& zw(=Xg=L8S|jiYrg@RYgqOJj#8Co)cyoU^%|$*_g(B^b#NNl;Rana%oT8Fj_h1fyI_SiS6EG=5Q)zFm_Iabi_$lQR2kPZ5-1_H;K#1U~RP3b#B)80LMv~?dn z^j4XR#rlMIt^tloe14pi<%+@Z&3dnwOMWirsLsusuYaiRK^9ZZ z-8rzZ?aTtf##XraqJB*K`I!^Xieh^aEb-u;p_+aH?XzR$lV?m54|@LlSE=h^q- z!YD=Wr)=^Wk}e2Y_f8fVJ8=ELnaM=W6h6NGfdTpNqw`&FY#MeCXX{=?Oc>y2`20*^ z^kwmNoAg9`Jacc|S+^ zTs+*Zb}Q!WxtNxmtXp8u{WP+f`F!SXTHt-r!O2Nk0D`%L z;H*QbkP@#)*&mKk2HFI%w<_ochok=7*VKng8%G|xe`z}q=5Pdla& zTvcy{`x{hD?s7DrZpn6?PxY zUKU#dOO1@Sf;wbgaN5yP+%u+=?z8i!DVKnU0ZkCkW-Zlz_@G_neDg={B|$nUEbCc( z>4eJc@1n2H*a$h_hSt~vZT0on&_0-6^Es1spIs(b*aT@co4>-9Ji7}{y?0o&Wn{Xw zV!fz0$*ls3cd91e6j@3xWqtCpH?M^^AyIr+nPfkl$T7Oih$}bNnRRgK@L4v8*TPRc zz7)HKPsA~I77r1qItBEzVn*-QEnKdxvDS}=ECP}Y5;%okJxHJ>s>mD`%hZ!IX5Ei! zQQr`QYFf2gB>LuBhw)jRl$S4@s+U}ub6>fOtsnK!^pC zaQnkDbV+gQ;M?JZo|3Z3evBTG_cNjsYr~Jg_bCl})UF;qR4!=G#)|+!Bf$*3=(RA0 z!Qo*=dAv>h=Fl~|#y6+iIR5{~*gzA<`xo%Naa>-y!^xPNGY73;B#T2LnCl=}8J-et z{!oi0Yqat}92JGz4@xEi^oD_roPHi`i;3K}0{i_hLF9sv(%j7Km((QYm1XMi&4k8D zH?6HwS_qOju&!b(GP!~$5` z`fl5{{1f>RgSVIkUo4g`g-HriqA( z<`57MY+ms!`LN0^gW?9vF8ozut7QrJVsPxtj9-Lb>5CU%VKojySABJLM$lIWkb~D? zC5@l}LQ5-2jp4LB?_rY>k}J7QXopQ{!1 zTm3RV5n4j@CAF^zLuypqMEC_TNl|8l)%53Q!NCrgegMs2M_SD~-yN@h{A?gS(A$;u z+~-ZXEw0Qusr74ZEk>jSBvCa@i7o9T=LA7U0SM-O15?(Y_L;vLYz~A8C$?#`YpnSf ze4-9}Geb&BLC=+!$Y$j1- z013@U0H~`{nkKyYKOq=}7{I8XKW`_8()SLFL#)?-1kRs+vGBO=F$F;tBU=TS)nT$w zYV>n<)|^_tsVIJijtAfGKN8G3=j$l~aGC`VMju?#g6_9@dB~F%gba{8FoRnZPWkt< zw&Cwj|Ge?hkH8w%9aGbsF_VVbSw4Dqpx1*e@Iq7FSHJ5z}}*e0FUAMY5eUM zi;?_=k8T+H%zJ^7VUgS4$VEY}vDY z^T*(Cx6x>|T0(;ex*_yNLi>onisjRzAU!N;2Q56~?K_q*0>Gr3=jmzk%`MwjS0~1< zcwN5^Tcni4#CNaP{*`vVH`_o^tB~sJ7Z!4s0_p{}hqPwh?*{r82TVV`R~&FegZ|Kt zya17C@rOJ`svH*b8(y7+6#6DiUEPr@ac>{lhF3fD1f&`u+Lbf!>FL1`3Gf%)+Pw76 zsgw26JMH#O`rK3Inq)Z5UnlYfZdZn=Gt)=_+^iXEj(+gkvbFU`PWZ0sPllna( zC(a}^pR1!j&MN1_C9S$GL#<^mq+TI49v|={fi0Dpx%<-Eu>WV`LXaKPoM}(<_QFyIZ#|Q{bwl4f!LcMEZh% z%%W6B0^g_j<`LwGVS@Fx14O8w(QD5Gp4oK9L$~%jiVyYb#m@>BFBO;EwtgOQc<-n1 z4bD5_&OZo}mix$ZMvr7giu9pfmXB>7IeSe>?eyT$))HPR5RXD?fIkKXQZxKrdq=6= z*S~E$RI;aKCzs=c)L)98Z+dgB%YU2nDL=nYv3Hs6VM(G|R|Ut4LWNRZK%|k$4tJiD zAzWdbl09pMHSc5Ze!LcdibpFUbynkAi)kURf~?-RQxE*{n8udG9QZFg>Gl5}EiT^-jy zbYK2(gr0qVuF+7hI$_C2d9*s_EZ6V|DX9Ch$WHH$3EAWiY-ZyD|1BB0GdkXMEj^jv z4?Fhq=LV@TGzV{|agsB;dp4|!KAH5Uf52hOnd<7+=jZn&yiV`Dn$>l&=l9MccbkGd zKOW_(hbM=_P9}PmKRsWQ$W|}9_DJqm*E}!xb)VBfO?k~#zOYnY%HQb>zhBus+@@h0 z(M+q8oV15TZYfZo5&iY6T4bZ}q(gb9$XVfrE2Fb%-zGQP52@+v9ZU55k2~|CiaTF^ zIW%67Ec(2uYE@@;Y;LXJ@LwmCy2Vira7 zYT4-LWW&~-efr@#QJapnSwAn0+ZM4%c6E81(B5P3sMfB_uw8PotLwFu@uK|T$)6Kc zrg7`({r3+A_~k{^Nb%S0yJ2gKo*J$)6%`-k`W?7wc~OJm#m;tI(AL7`LALb0Q#_W! zK674~nF+4<#TIg-nmQ@OFVep{ELZ!GQuV#~+V@!(t)|nRabvK{wf5){wjE0qB*nxJ zqUG)lW=ynEb})McjSnw-_xjgw?S1-V-;6;}1A-Vc}^X^#tc6UZ^7C-!QeY3GNdsa*vD+1KBDtsRe zbeBD4J$jM5TbZr-iU>d$1v?=OqoB?y)vZHd2UAr>m&z=1d*M{U;2n=a0#f=&^Vvm@ zrti1dzf2vW^PF3`R`+mrCGPO#c{7$*m)6N$Jh1j1TlxzFS5AI>u;3ovxo|?$-Xi#{ z&XxH^o{0(W2?LFq&Q6^;b&vJJ_eO?`}B(It2N9emu~RaJ!oV7b#3PQmB#P= zLmqr_%=8kfNl5QE&PIDyr7G^$Uva`1qOZ?Y`aLl5^1&lOLJl&>ieag@|iD= z%l+iiw>?HWv|)epniEFJ+4A^ySh~>A`N}HYyZ0s{x0R^wjq`QY*%tR}Jq7Jle zds|x=S^zCjRaDe2x$TB_WaiBAxuzkJ8D048;H2}?8>9JY74oq@Y3ifkf6esvauS_l&cFnBPGa~)~o zXdVQ+rUD#ZohNrN;RIYk)S38s<87u_xigqf!lHU`D?_cE#@ceeBRCf)D$H@R(0b(L z(vSE0U*u*xKnjN;NtNiK?9^^+XI^7f%1xSXFrs;NmJ4q{BE9_?cKp-SUAMZer(PQF z-KENQk$ykL@QBPNRP`W!mA`)<)5Q#+&m0$}f)(m7m=3~r5Jh(nDE%PAD%%Kw>w}tF zQEQs8YMioy>kbHN;pnKe&MQ4RT-il;5?qVwYHpCJrKAcm`-dTlrYf~8Tx*2DmmML< z?Sxw)YZM08%y8hqU^un3-Ag6D@d{{wtw5NNIYclt%};IIX1KQE9>|#aYM0j(#z+5= z{PQ;B%OADQOHg9aa)LsPYpJmGVdAowG}5qOPcRXMC{Q?StY{X_$9=0;%DUD1{G;j> zv9M3&OC$WIkp012Bt6bpSXuIKy8DtcIpHJ>dzwGsq+1tkgZB__?JgG<6@lpffvsN58Nd`WQ7UF% z=jF|Vbx!jI&_onb~c$cmuwJ$VQk=*5{9symbwU$#FtLE}^6s zYo~;i(m>hDY5KA5y2!7c3T?J`Ml=HC5A5mLQ-8ZWlJItkD1$W@dbsx#RTv|zGRYxI z2dhFmdDq3iPo4++#YBx9%2arN^Onyqrw}rs%mj6S?+}-~+ma9K9?V%Gl@XKtj#d>) zXQP9IAjZ+&h(MSM2m)A=FgXCSjUVE2feHSv?o}RKiNJ7C=4rZL-4C|H9LV7(IYq;x zZZ9P1ra9cYMd;x{l)hG@N8~{!;3+hRs+39;BPfiRf;Hg)ix5~8X~$xu;u@yW8-g#gQ}CM zz|N1;C|>6h#jvj2e>Q`6MvbF{Pq@`^?-;JhKNaeBl68*H(9xTOO(!BEqLVpOGbOIz z$}vqV-L&9-@yH?kAy`46o`<-fytSs^nPd8ifumRbIVYz-a9Zg! z-3&LlzOAgTMg>s+G-B}S-J@I37sP#@=th&3hL%CY0k5cXd=Fq<5WVc6W6qfvfFpW? zX126BSUu%iK4pi~-GCF?z<_B6(`WU8_Gx8cSii9`1IRe=64av=bkSm(hHGW%I%$ui zW-~l5-ibOz*53NH5~$pDhHyGH2|w5@*y$#=FQ#itO^0{(XwG{b`**@cV#JaO>>?LJ?UuCzXv_VG zRDiV$m)+Q0Y%v2>=E-C+hAnIEqyvEM?+sDCg-$nq?RlG@55JOrn7JVpsm-^d=_E&_ z=x1uV&UnYjBcs0+R;(w)K!SlpC?(!-1Cf;=TD9}ff4ireRAiurXxrZXF?lY`#a@%I z)6M5>co`xZ+h&+!s-5s4!$=iUpRO z6;M@HRG@0fI4pYmuVcn8v|1P8u`J!t^vnw;3~+a6YZ{xurJ{*qMLG$%`W?lv21Q&A z%oR{ddKPs7{01aaBD0k~P9V*&MCS0}tgf7L#ZTB7s^v_@AUh?&lb9}rjg6oNYQd?- zn1>;!=uIq+X*_1Tcj&?(7@P(Wi(x#Dr`yre67|n<>k4Qg8MWYvU__yP-K3lL=#v_{ zy6x&VJWgvn>7B?Y`2rgkLT5RtNz+Hf?BYh`V;iq%i#>q^vQtRICV5o*waThp5hWE( zcax!^Rqkg7*1zPf{hWqkl`=S+XyCkmZwGNY!O8&p<|2$AVdsR&swF@iVmIe?>P1Q!qD&1P&X5~i?D9Q7=@tKfj&S)|7o9%xiM6q+UO#kbTMv` ztXXj7?QN2_Mh!2m&#I%l#SQ1rTI6J8i4IlRk+E5IP7q5JuqsH`Q3zLq(gGOO4nP69h8_y+5}Yrj}qDGyg}apV$4jKBLEJ@9%Ge5C_JA$ z+5YtO;MmyM@UXVJI@?7v*x!ubr88rHiNhx2XD2@FETm3KA+k7SA@L!Wk5519EJ9M& z+<$cHjs@4_3kkXgR6qkb>BJOhd=YDcCm%HdwZErl4Zz4G zkj?@F!E`{)#@qeo8JxYWw2#~JzlT|pj*wfS3JD8eZ~Cd|YZNHNOemBmlKD`*xO@rX}QX|I$H$!r3!z~2q&++x2PMVE#=-&yV_}U$S0{I>%~)@LxX3*b_{yp#HmWZrcbc2}GP(Hezj>XT>!V~@ znV9rFllG%)A@n~Wj|f~W$X4Kqgy|@>Za7z%WLzCFhV`_yV}j&E6n~5Xj@z$_y;t${Q8jLxz94?vX{>qQb_PzCoSoloSza^+)kYLyr>gCE@el&vMB%6c1Ep=up3 z1)T!^YvyK3n!7*5#>IglhntP5D?*6i|CKdVQMuBzTUkhjde0u1t&48N{(PZ%6Gw8o zk}H})jGv>=YU^l?FGL|sEBj>!T|IB`Hbr^x7&#e*w|YRO7tR?Zq#(q7o{+R54w25R z?TQFE;+ELgw6*CPrtrA%!lpAhC8q)&=7fwup2&=YK*!!*G_i}dU_c^()+iVa8 z6QuU|A{i|?NQ#;OJ^>QoC?vCR4`nv%NqFC{=f=H7Ke?pdF;cY01U??`SU?@UEB)z; z2pAjtUZO#rm5I9e&n3u2cCFY;#B` zwRQKgv%hy{>P%$6jS2-%mn>{hU>o*21pEDk80xD7;DDluA^d1@n%D0##5hIuu_cFi zT>BU}GthQR5u|}m|Gt9UQ+X3Nod4r_zBu*#yr%5WNFnoUSvz*;6^aK8aI|Kh=ww&w zqip`zVhLY1AuY%5vjO#z3-rBWF1PlGM{e3E{irp>6tr*glqM4YY`@(*_c2^ssOq*Uy@IcnRGIpW85Gx6 z-v=SQ$)#c6;V+zVo{ox!mNt`H0G%0*&su}OCb6}rRe6-;Y*4c_9ZT@XbEZ2vU^b!; zkCc595)z!8k4OdfBSaSWB2z6f7y&0288QHDfO5d4tvaEv7-`X!*6-g+JpcY*2{tU` z6J#eZS2V27DSm|gVa&+X!dv%W0o#$pX6amGd8yj8TT>Z0R_@E9A~#$Is%>EjiNCn| z2TdIY8Ur#{URJ$cP*1(4tfcg3JgXs*a~qM@0a_>OdEOVzgwe+Y;r_)RhR8n_G(8?u z8(TjAL~Pw|Ly&ohITT9v;|iC4m-xOy;22xyQ}cp8gi0bu-AaNC!`7!)M`xVJ&VvWu z*$Mx5YY8nOu|qU;blPK$;P!HP2IX97>|16xBM@2Gw?ki9W~YzC6o-PQRy+B<{|?Ou z&odu?%!EOQpz=Q?hAvzv_@?er;i zE%FFz{ebDn(N#w(kX}{WyFs}_&viz=L3-{Byn3|95z%<^d9&X{MGFM2pkkm{ZJ6>k zWq5qb1e-ALgO-d-r?UJrq4auv`>z5`5V}e;9*5Bu3oJEF&1{Znud7iHdx#qcgTv;{ z7-_zjVQ)(jsKfk+UCBG)CeCL_%MMdrMB59xb9!8bHh*N}`mW#Hx!GRX3r!z0UaY5I z_r(__`y0#FCvM*Q`PVU!;v?0|IuK1dxb5fwD>G&>W0-X z-yT!=kI(ux)l_k5vTd2^sK1fFxB7+X)$+=>A}<$$&YHVKWuo&1Wo9;96DoSuy(ABh zPGoRkQU-g{6P*70#0ZTGgN&=VOD}eFzx)6RB25(KR^&(u_83gJ|3G60D&KF%oA*uF zt&t%vkxr_h3o zm!f6h3BDR|Ai5UjRBgt?#cnG{+OJ|u%yGH?civ8Hs~MuQ+bmhD=NN;ARk z5VYx$5>n9|YCq;JmUxy63mSuN17JuWJ}By25Es-qHdW>;ldDRM;zH_FZ2AGV9NoHQ z?+}HLqkks-O>~$(JFyAx`68V4JpP=twVHYV)fJ#-igU}FM+v&X&~pzImtlyagnuYu z_O>v_u%ncSOu%MHEDIz+TP@JyizWww08AjZ^;u&IZux!qiGlixzTRqS>?=FdRi5Z- zjPJ9yqQJ2FoSQZs(hhhf>!%e2d{EwfIAG{29g6f06tuA=RRkT|x&r%nGff>V9lgYh z-a+}zwKEuUEJD35`sCH=9GhMP@@UbRv*m@$GYNq6ADSNbL%1~W+)$$oQqG6{8$^`CFM+pN?SmMS=U_ca#r$ud4dn&{>61Dx> zUhf=2D?~}8C)dIZ>y!4=^rXt}eU7fdA|fIvdwm-M{$@ev$SM?UrorR_4Q^#XfEN*B z5flWTxuzaDRGXD(&_KLLtea*EBkVEYB5J`k1TSeHZ*RBxRw0UokVOjFzl2RLmdiml zxZHx`;AeXJBjl@E&ly`j07uz(Q3xKU2hw_>4lDxwqN_L%O}`E{aaf9e@ab-Cy)eER zuGX23zxp6w(xDHASd@oR77A0m%jNz^L<%GN_6pEd-oy3d51s$NuD&`T%B_1BMWtit zZjh7|L{dspx*O?~kS@ugrAxYz?rxBjkZx(|kgmJueD`;L_q%icIOhy7^Sp?TUlqqotOkXv!NCp)0JICj6ml>!2yF))BMY0UeWBe#!7c;@H2c>$X@TL%Jpnu58!Ou>ri1xHL3F}pG0_7q2h8$TVN5Uqg?M3k zsrNp=qhH3)d8FEG%bU1JVp+J$0=I|>B7!6;GpC9d$pVjW^d*3>n;~f50<3F5A_42~ znm%J$3z%Urc}5VQ3=sqz0gXvpt;r3lL3!rR%E({2PGf zfyN&@afk=UE?*n?b09?wa@VlVu(xkPn*sQ6&&HY-~O-%l^KBA!S4YEy#U~LFXa$(1lLG)&V#Y_a=&f zH?py@zfeC~_z}kU2Z5U8-^cpP(oP4RsYa`V^yD*PL3f!1Cz>IVx@ha6sx^W&s=JO} zY6j%u5X2z)`r`nJf*xlmSB6|#87u8~@|D?Ql`lM_k81z}r?--=&D;Teo@UkF9B z42iI6k2*#=ac#4gAGRJw34lE45@&(ghtY58e~!9ow}ub@EblFV?R~jhdyx+~wgRAG zuPxQ{G&Y0lh%(*tbb$PB6`}3-(XWh$8YOA5hnw=pw>O8nc3ykh<&9fJj!n_)Z}OD> zfYE-}^**;eD~qczai+`Wm8B9R+oi>V75BZ;<$8QTow@toe#3NjZTEO#XR~mq+1r97oII%`A4EMx)=e zk_S0ydy?P-w-pI%)kIBaO)SgX>ro#;cO|mtPu&D>#-r+PzmIxfRA=1pn^EiWzkZGD zrPfMnfp!_da*s|URU^oMfM$3%JLX)Pg{??%IBJ*`N?8|pIinh z9@duQu8&ayTi)$HkKXGH#N^}!9h&Tif22TzP-8hD+?8=WWP;Qmv=%72KCTNrtXp&M zd4JTL-r3oa<5_Y7ntp;;tNy@Z{FzNA@Z_mhQG;dw4-MqxL<&}JmoKqk7B z*H0YtnalwrDFJXO} z^epKdA0#Kf;OZ$fNs2*6QFpOKR%|4Kd1EV^($r{a5`shCxF9(};FLV#Xcmp(;GDjB zxh>d$53L({qgFEw6MWqQZ3n5%2SECuZo&2zEcd(DX74LCNB|p;l=PhT*8vzJdfx&t z*ow0A(Xx#o7%a*rT?+E=ECxlB=133FCW2;n#?%qml4PO2=bgT;8hXW<0mMoJyaAMjQz$kXOz=#mBRNFD zwyA!3%h5L{AEhru1c@U}r=g!l#2f6031FdU05qjcpK4ZcP*T9%6Gru6_*`|r%6*!M1Yfj)qBV0}5wlC41f^Oa0<0#^W(YnvLWm=$iz)2}R{BZe(O6 z&{lFKEZK==skZlZoz1obuU!p(t(&kjPS;59ro%Op{`DYW5NWqIA2tnr8WSXkb2AM`%|u1_dG2V8ke zdKH+k83zT-J?}Y_MD{aKyU~%?e3i&~q5w*eirg<+0f`UHn1)JP-2+(R&huk<`%M3K ze&%6+^dUT1y+k8=z<4;_sH~#&z_NN`w)BK=`2_uv-voJIe3eeIZR>TpJw}POeLV;b zM@(6UEP1jx)_R9qz%Ejb1=xJ@b1Wp68i}f1!9_CGuFH(c;S8=aK6-?9^tCi9z zJtD=1?~ry8F_oSpgdqeRJ%Jl*-5&yr<>>9ysPk~>(oWp6y0G$a0RqF=q0Lu~8!_OA z*t6%LNRevX0nSOYI!Lc}S<_ztnV&Myz5`I&a_J!KSnL9)UrAq;6q^{bVo(=Tj?HV2nwkEdU7UJU#O&ojm5o}H6(-F*=7^2KUn+q4%;73h-^w16Jh zOa$)C5J7zSli zg{J;UK@)DN2NEIsu|hN0@_T<0O%AzKO^eu5L8n+9DqIYC8_1S~d!jUyMS#TG*a1V$ zG^mNUg%#B?fx?O7C3W-fAmZU*P?x&^{a#|meqDtL>lT7y{5PLKZc2r(HYkN8VeNCQ zIOV$(&>8&6Gu(V&iAZA6b!MAe7ZV6o=eZ?%0#OPD?!|9&&cS4+-rIaDZXnDq(QU>{ zgI#PUS8rTp$b!}yl99#R76Vr_Rnz%UhA;u>$IcuiZD9tJt|e{0+X~Ycn4JOt5KiVD z?n}U=JP=uetdA9JC<53emW#Qt+4Mlj5kH#PwxM&M)~6R&9TQM*>G8q-gY zE(yAM#*O1rWb4q?L?nyC4?@kski~Pulxxq2w%fxq?`nX%U|pdAc7UcqfSjnwuloV7 zGin{yW#k)Y9hH+5fQ?z))fbqBc*Bdsd()B61 zAigUuxUaXaC`2Y#pLk|ms!5C7s5Zd2-kMZuaEX#nxgL$VH?>Yo_U(%t7Wt ztj27V?2PGB))(4sR84w}NY1>P3EiQrSc=VC3ByBN z&U%ZqSV=>O{0Pb0t=sf8FGAA3$M_M`k>6SGbUsh1nx+6g*u1JQ)S*!-6Q)N%7c7*d z{aHB9W&sQ!6pI@$ZfTx@mAX3ML}_V*x)>CJD*L3_)Q2On+#_`8-&XN`D<5(-=71a; z>fJX58o!Um$fpYrvQ&*F5~<=73j z1d-bv{I{0~B?=2rpO_#8-`bL>bVN@QKgnTz{{)Ue5*r^3W^ZvFr%^;lLtAnfV(CPI zB_*n!0)Z6JyX~bYX=)V)OQdWVxbygfDpDLn>tWNLEY>D?lSCKuGPf6Yft?jqpc4)N zpG$!wx6uNj`#;gqkh4vY=pLqV1&xTEvVEZV)A*Syd4Nn8>+b4@5cg{U%ss|Tc){3H zK|x!;Ei!=lUQPW5zHqA>luCf%h2$0{4{;14)5H!C_>ENoL)EikzgA{+S3W#>p58oY+IdCpc6Z5t< zq^W?PnL*=OTxDqjL)k&tskLH)GrR8t(tdOb{nlqkEV$82%`T}z8Ln#~JGJgWUuoon znRJoRb*zxRJQ+nch}1E>c0^6LIC**h$VqZ##4FV=Ij6c{M%o>_dk+5QlHM7SeTpd0Cin1Y%LxYOz;rkUXUgCq5Vz7+15X{-9C!k zaKA?X{9F8oJ?JBRl_*oCKTuCvo~#i!WKw&kx+14ZXn(5sTF83h8B`@3x+o{S8S!$y!;UEWx1T_-)d>K3}| z23Z&SJa7`Xf4%}!iceu(EaaCU$qbZ72cCTbk=kXPtUs*L82ta6aextlPK$Qc9SltC zx-9h$16|0Qo|l5`Kx_)I_JISAii{)*5!=Xk_><8hbp1!(=cdf1RYnX{A&!8kC+I9l z7}^Aj2BQ3A(H9~-1B2UVKs&==tNkjh{SD|Bpo?__VznN3c-ze@_lNI=ZeTn}YwlYB zvH3Gb!O(`36kSfML_A=uCpC~)o{f#^UdCzD*xd;AUUm7D38f+t8r zRaJBQ;7U~)<3FVH^Kx_70i2wtBA79{=>VoTwGlVDzy1$$vE=#JZkt-ljyhidh<4Q6#r>$xz_=p^=vw5T9wvvQ;LI{g$Kwi7hNX@U3)GsvNgHi!Vu_zr8GY47%`0ZD{W`bwQATx>Jb3S6{ zedYn;L%?CNS-^`1X$)|}z*9T|DRk$fRgXm&qv39o6}DM3pfE{1kCj7E+egvA>higt z@d2s$!5R^uB;j_t;dA%H$McVj{^|2v&NC22uK)RCQF;#G{RSHSS&^8wG;AVe`{Ql9 z&u&ox(5%Tavb8-t{@o3F!ZPMwymB(4FdWn3iv-)x5pW1zrn|0%>EEsO^gP?A40w*Q zYS`i0@s~~!hZfCqz4S7sKU$7l`{IDfeEilbX(p@FCkM~QqyF=Gu)p}}DYe5fr73KX zPk%oYiWAn`EBO7QoNwLwd%ufDe0e{~L~a}yhG1HNQCjOZzTLcBRU%7=jo}f-WXYmv zCN9II*uq}|u(!b1u;iui`7bzl5zCxp;WD}mCZVU-plNtJ3&X1zL&5tPQ)a0p*=5XU zN5~ZpgY7AjwIbndi=xVB%A?ye6udzPkyE31v;c%Wy1o z>`%r}Bwtdp*y2)DlBC_8m$yX*bmPpe+C+jowbsU?lURb`G*4E7nQBz6$~1nDh2s`2 z+2-|d;-X31qCR9T|mvJdaeYTB*CNi zQ(S3-$+Ava$_E!My&}wRZW>fNOe`(Iw1-Z_zqRZnl7^k4QSm}(HyI@Hz7O43{=tU~ zj|SmRY)=)u!+ix}p;(PMKHn7G$~N9vXp?Mq539WSK6Lx3P16m)=G6Bv_aQa$H%ikVmFNX?cxxKdK6R0Pt_4vqYLd??go?+4pm z$!;p?H~If6T_bdKT^5n5UCQ4`A#2BIYu;7F*K@m8JK|NLpk<2fUWt~vQ z(_UOE$~bFUDWwyduDdWZZM(yBKQEvwu{JjDX-M`nG--{hK*MXCi`#-anU<+AW_303 z>CrNpCf%@I)g@vzl102j#*w$qDF{X=Um(5lT}0+`A9NFL?yl-Y_eG!=3Wtj$@5-0E zF1inw*nOBw40}61YF)8wTfIw10G9GlUqXY>Fhd1td7priQ0A0vg(z z%_}!i#q8opzM`BWz*N(9g4bwh1X?8l+Z;M<@xC+n!zJeEC$jlCbE$v`jT(9{s9GEe z*&3E}bObut3)$;V(TQ+j5gI=Tfy(Cl=F8_8MJdZ2;Hpslz$!iv1llOZnQq$gEZ0vgfp;47~Fu$t4mwBG}{DZ4z&(} z;W0&dG^!+Ux93hhCtZW}Vg36Wn>g4hr*HEbd@J(I?C$_@nqB_KuzXm(d=+<5i(@VapvU9m~Aw#G#MY!t|z{%kQ^kzE#0|8)U%xhK z1DiW5Ig3sz_TOg*`wya>{UKM8Q^`H*$0#u|tF!W3a*Xl`^(DhHA)g1(H>a6ka&hP2 z$X&$l$oY4uA$0hcI2d&+D<_xSG*?$zi{85YEbd%fjP~<^uc72KvJTPqGhYb=jMXfe z&!WwukDc(qBCS5*7oK%G{fgPM&VV8@#v)h{q<)-jjLius29RJ|X=R~CZz6bP#}8MZ z^pW!mU&FRn#PUb7o7cB7RL^DzYt&Wvw_C2xO$VZ;FIu_0IU(Rn0R7?2i#t_S`ZHhu z*yq@gegGCglFUAqH!`QznQ8S_6?O*P2&1WXy=Q4YQ=)T9=OEZkWz!FI^_ARtE$pls zvUgojV}#i`5j-Jy67JiUjZ^1Aj)Zdmg97>vQvyzc!e*!=5*rmk_S$0r9!=tc_A|?a zLfS*J}&i$)iiI$5qfr=Jjr=&2+MH+*CsiuyU=Y-rc$y~n;fHczuPFu@kdvr3cV;0r3l6H#KDVHMTSi_=^$f=BE*^+S%UQhu8GJe#cUVq5W z2x5}ReZjMBJhXJu4vN{z3V2>bx`5!eq{A*m)L9NT)re($3Evm5Z>z+`O!f?h=)d^r zq}OHK3#lYi+iy&cUhzDte^Uz62jc#KUpBa}V`|h1F#6co*EzYB+aA0r3XmJ9dYwea z#Uli!6727n9H|Q$%E6`Vm4*cdZ{S=9*eFh-c}X&3z*8tDx=JF6!;gw;V|^wu6*A!O zKx`2AXSCjzfV%FZy%D!meuApW$VhV9Ew!}qtHafbAv}ImJpvY2NyB|Ea8#J{ z^*a!Sua&cb6E#F&L+nB~zTK)V^o)bpk>{8Kc{0*PcZxmT8Vecgy>0 z8N)68vtc(}LR`PQ=qR0=^oaz}ik|{|4@@lKldQ+ptf&|70EU9Yupl@we3p5q8(&!K zV*zrX<7tX?kL4f3BO(;{=MqCA3QJTNuRXni9+Z(0RmLbjGOV{0$T=_6)!SGv0QU{> zNT5ikjt_`0JO%ZOR79v{TE~MR)H2O-ihq4)ox|2}xqtK1RG1_NP#pt7G8Muv-WE;- zUvP-NbP|9|serG5CwP{12llIhZAP7hJkb>zd4sxSq^*|9g-IA<)6YPBQ4jJ zX@MvgRnQyGY%KmvlTLVgX;EeYEj)haHu^MO_ycsZ4)}lW%J;Ouc}rA^?=}XP^`S+^ z(xK@cg1ErXtTCER=00I4r29KfJlH*m5pS$6_tPb?@#+rkANDi(hGg@ktNbOl6<(Q46En|P3W zq5#MCLR*7g$Q5iWgIe&MpE$$$Q^b)Et{e>>G41jhX5F=JP+F`K3w>gZCRuX0v?9~9 zzA#zoCBG2VMh3RyM!!eEFb5`X2RN8hWsYiAe4h!E@aXGS zRD7L^!lfJ%rCw2<0J%39CqdYkITZ{WPXHwA@+5&)-Ln0Xp>S^GXREukKs1$h6pB<%=Ar-P+j(v z8Zt-Q3Gxb;H0EZ*UPJ|Nd~1l-@RF1SF2@@OPl6&LU6766;8_o15+mcV~2J}*Ymr^VaM`*@PhQQy}_ z9E8{C)2D}Hz1k2ef=eG-5we-pPNU^OHlK~i|F;~n6I8+W)IaNPU^|OvFbY!Pf%OHs zut)fViy=bhT~XQ-5!gaQl_tyfiu|+b8{AJHaP#ohaWJ&jX8_oI^~y(d>zY-`b1spk zi;jT#)4JUm3=&A@e@At|#3U43NM zNMu+s9CI>cHHs-c3L@Q@!Bz0ctL4#5~i<}U~^Joz8nC5eH7^dy$bDEsPy zL0k5#q1_rr&a)n+%Wm_;Z^3$fs4q1&jsbvfn)$U5g9wfl+td6-M6n`g>Aym%Au90J z2sc)k`_z1JV4b>3-d+Fd78O~AeuaKLKJq#iq~dLIL;Bf<6K(3<^E31Y2V6;DM61d8 z&T5vvtME`4qHk~mz2AdB!t4`NKX5L;YJuhY@ifCd)r(i?p{SI)J%6^Ebpd&R(_9y) zMKz3ScNB5q$$Hkh@YqjRNr#;G(dMgelJSt8%F~z0!L#u`zOVVws14j*p}mO0kA?^@ zlpd9P;p2pCs7b?xFT>#^I%){N&<5M!OL2unUhfahik`;cF)jGJPDZ-z@dgy0F6p$pj)c^bQmYMt54$j5O$BBRt?{ zDS7L7gQ{6Q2sP(Z$9e+5BQ_U?&xJwLF4zvf5zn4@1%pXxr2k``1T4Bh1rVrJE97Rf zd4TEJA5YRw9_!q$!1PR6`m$%k=tg26S+lRGkWv9Cem}c$jHy0+921P~LSY?sPRR?* zWoALaG$M29>C1%R$w=Q5>B$*?3b2QSEfL-g!+~31^f`5t+-F1BJi@|w7dNyC)hhxL zRRjgocatH~_W3&f-?hRuQOn+2hG$wqbY|WjzM}l?2MNQYE`k0{tVnyVjjosDB1L|q zJL~Kw3_R3n!5|{D(lz_jF2ovFWY}Is^;>=7zuLj&Uag%sf{427gg;2mhY@z^17iQ) ztJ?fX0rm8>vSHngRKY7=h?mjX(wtqZGuwyDojvFvK^O=FB}Kt;1tRJ^&K_ zF(DoB;1Ii3f+_`IEltmW;p+e|zUoW+ysvT5v^R}iNkx_a=FA|G|AB4!)4SbZo$9gd z&h;#_$}0a6(fYtZ5Riyo|LPF!!e&7bzBWXN;cxvyD!KX%ZxCbihZY9F>joX+z8D}e z)mDw}M^+D7D#V+raT1dQ=M-(xEbQ%#mlc{|M+pgEMjIu1u?x7eo*_{y^HM+xLEKp#shth=?qLYh)({LqS#!|xX95%r0vh1My5Y_+o5euH3~(X~ zN@#~DSl60;gM!F9CTn^L&=>t$g%Nk5P6mkhXoUAhpAS~9y`Hh5HER0vrXO{GPK7*jf7^8gg80pWz0Sk5J_{*P4hKffUPGAW?& zkl`cS|0Rt7=dW_Hy0#jIQ2qZQ=={$I`A(21<|hY0aU1^UoqzsKKb2m^h;spQ|NqZx z0yWF>g4XC?DU^SHa(I*FMA zl^P|H#@6>-GbjNeVzM>Kj~-OIDB{=<3wo0ncc|7u&dv=F)iR1Dac&6WlpT%EExlI1 zBY%P9ePeC2<2m;^aoFXYShi(*;eFdpdxid=c{9VJL8WGq*=Q{|y#urCC6op&cHM%3 z$%RL`SVO<3rA51 z`{T5+y-t;ehFF~9lkp&V7Va=S`v4Lu%7d?Oa!IKSO9L58YIj1-e?*7QMwaPmCU*Uh zsx2PQ%8F8QJa?#F%N58QUYx~=xIKB^(xWHvvi)PS_TTKLCJq+QB#COFr>`;m(Oa{I zk{?~bSPG5zMZ+TT$X~DnCYH2hx=CBZJhrwK%@j56S)^sG@^zd*b^5(m1s0sHY{dkXTZsQFaagg||60=BM zDd~?{IurM1JP&%nC;NdV5Hlht_aR;IoYCrlTl4%ep0-C!wgjpY4`)hcx|Nb%nBrhx zn~Y=S;k}d)q26YzS}7YCqsy#Xqt>PAfMYR%&OrIz%ASOU3O$6*zGgaww6qrQ$Mihq z56a5g185mEN8EZ`WS-LDn{*&9gfE+E*o0QRAddAz1~p^#tn7!W2}GIizw{bH5*Qy9 zAUpUYpBHi}(NrAHJ`AB`=&bt`o?a>&>K9%9!7_TbtCn{WQ9MUq|5HS4_={QNWw}sv zYk5i1oObZ8u*U1D{pG9+Ox03vx#8lLEonoe`gFA^b54l%`747qv)(T)U;+gp_(3)b>+X8CUn<;ME8mf<3T`W4jR}4ap)QU(EBP8InPLuuZgb7 z$RyfG4Np5%H7K4onYp@Gt*;8F(j;Y8;lt}!NlEclNds6+>*h9QZvC(DIFwZ4?>7G6 zhyE(BO0Vx%%ne7j*fdwg!=vTJl1X}+7MUzH(}I%16z1l2@}<(8JTF2Uin zqN7u_QVO&DT(((U$E?%!IM+k)VaQ=lZ$d~N4 zhPz>!s)fc=)%CyPBa{og+aFjVqg{xu`bbzbfHj1b7cQv5*sb;BAcior&+%bKB7BhEoAaevS=PY1JEcpsBj4BgQypXPOZp&WjrEm<*sKBr8rF7v3th|cS9>BV>7pqlKO5)<-3Kg%Vx*~47YA}K$Y=< z{{nS!--dd0tc3W*%`Tg6YlAmAX9*Ll&|$e#?Y3K)ox<7;+d-=R468Cpm8C)PpAf$1 z=eo+}^8PkBrl#dO=?K+!mY`5qT7^tZ)9Q0OztpXtBgnPbVOeDwJ;sp1$IV%3ahj5= zCc5WAXPusa{Hjys%%UJ6h?nWsEcMH5<`xqlZX2o#TA1s0@>+94U6M`V_sN7&y#mP* zcFNiU=_T(QtL@}P6#;_b9x5Y9bPOs)QJf^T(3rtS-83xEv+<2G<47Dbr%DQKv!pcC z8cS{i(+Vq9a)$Avb)2{{gn>$?pRPE`Fji8s#}#S#>HB1R38L^~g-_nBX!G6k!^Vpi zs#hB;=G511C-vn8d8K2$-<`Laz3+dI-4;rq{njErPF;Rb6&UjSZaHom zzuB5Pvq?RkxQVr;i77|<5SM>xd~*AZs0>vwtCxpra!fUM)6OcrCFs4nmmf)?Rfqf* zt4l_|Vd>P4$6xMtuCLTtdwMIaDR+M!8?RE;otAL_?8ZZXKp6Ty($gq4C$*se>bK3O zmoYh_T|0|Aq0$kPOQ#6qkFF!FoNuX*e7svE@OdRtA_k3%CnQpuD^ZydI`m~2R!Or= zU1n8^4G~CRoJQMtnUKn^Uwx_N`F21=s}gIRIXABAK)O+*q?OfRrMriZ8kt}BX{=hc z_fNEKiTBl!bS&AVEM@Wd^7uGKUWHzp?kV3T`u2X5<2l}s_+BejC zsn5%uFt(&B{V|!qGG|DR_CPywp0l(Nblsen#>eJS>Y>ctB29}Hl%A>*3Qs=w@lw|X zmnT_R%?w4aJ>q-I#G8@LK3Zry`eL>;n*63S!8V?r(&J}Uy}8Yl@AzR~*Uf*}*Kns3 z9_@$v*Gx{o*mpK5w@MT>w^FhxYsI0NpmNSr@;MtX%v@|q{p2g=EN6Cp{|!2=Wp&b` zZg})|y5VcMcCW6Uga7`%2bKCt0Xx$;dKjBkv(6_*ajb$i9iyX3N*!GPqx9TL?5PIR zqb{agrr;W_B)hjN#KehTDUtWTf7%gf&9#S~e)7^mWW+_#r}>1lX{NQRPG%8)t5Nd` z7dgAPMZ(0k#B(a`htg*>M^y%uOfxRxWswr%*Ua5>Uhm5^`2yz%i{s{6^2*vuOpols zGc+F-L0W68M-6a6}Agjeo`eB zZRJ=@i*YS0?H>F6P(1B#$QBDpgY&scGt2Al*^$NGmYm-_4q`C*yzStyV>znnqpGKG zzvTQdltiJH{X+Qu7cDm@&&mC#9>tnD?C<0p=bF48cveruFRdaxq~6e$e8$EuxZrjn$R2jYlYQt8UuGVc5u9_(YS2~lTrut;7i>Dqea{SsUHyNOFkGW$`Iz0>Mzb%YNYwqC3%iF*^D;yX1M;u_q#+l*a|@XoQ-=tL~CnqaT$5gp^a2k?~j-lTR$SS|KYa z-+kvyk9NfxLNF?tE{shdpQaK$^%iMSs>SzP`-!iaON2{f{LN1`r50;CeSzK(uTteF zQB_r*NV-#=^z#{#;|FzTnld^`1N7~I1NNnm-hy@1?f2apJy}5prGO$>wtsH#(r`t|t8-B1?|>z- zM0B1w6Esl^iMfutM5je|deh|h^KFzho7|wh=73jYBQa{bb~w4c*ga?D9?QQP-<6ad zb>&PjuZ;+w$Wd6md%53sfS1|>v9DP-*9EZ7b#OS9QNBi6I3%yqq_^4nbHN9!hq|J- ztjNwvXV@ohWO)zX&|bd4VtE$ct_kgwd_Aco_%Dq%te#hsDIq(L@B^XUj8E^MfYt?| Oyea-ctW3nf@BaW@j!5(X literal 0 HcmV?d00001 From f8c9c4ca21c0de6d5f2c4a0f95fbbb10750a266f Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:35:36 +0200 Subject: [PATCH 266/460] Bring the README in line with the build --- README.md | 53 +++++++++++++++++++++++++++++------------------------ 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 6bfd28c..e29a3b4 100644 --- a/README.md +++ b/README.md @@ -55,8 +55,8 @@ them. This README uses the English names. ## Status -Version 0.1.0, alpha. The protocol layer, the polling and the interface are covered by automated tests -against a built-in speaker simulator for the KH 120 II and the KH 750. On real KH 120 II and +Version 0.1.0, alpha. The protocol layer, the polling and the interface are covered by automated +tests against a built-in speaker simulator for the KH 120 II and the KH 750. On real KH 120 II and KH 750 speakers the app was checked for scanning, reading, metering and writing level, mute, identify and standby time. Groups, the compact mode and the English interface are so far covered by the simulator tests only. @@ -77,15 +77,15 @@ The app needs no installer. A release folder holds: | File | Content | |------|---------| | `neumann-control.exe` | The app | -| `neumann-control-manual.html` | This manual, opened by **?** → **Manual** in the app | +| `neumann-control-manual.html` | This README as one HTML page, opened by **?** → **Manual** in the app | | `LICENSE` | The app's licence (MIT) | | `THIRD-PARTY-LICENSES.html` | The licences of the libraries and fonts built into it | | `SHA256SUMS` | Checksums of the files above | Put the folder anywhere you can write to, for example in your documents, and start `neumann-control.exe`. The app keeps its settings and log in that folder (see -[Files and privacy](#files-and-privacy)). To update, replace the executable; to remove the app, -delete the folder. +[Files and privacy](#files-and-privacy)). To update, replace the executable and the manual; to +remove the app, delete the folder. ## Getting started @@ -143,8 +143,9 @@ speaker of that model at once: - The pen in the toolbar (**Customize strips**) switches every strip into edit mode: **+ Add** adds an element, dragging one with the left button moves it (also across the fader; while you drag, the strip already shows it where it would land, faded, and the others make room; it - goes to strips of the same model only), right-click hides it, and **Default** brings back the strip above. The controls write nothing - while you edit; the pen again (**Done**) ends it. + goes to strips of the same model only), right-click hides it, and **Default** brings back the + model's default strip. The controls write nothing while you edit; the pen again (**Done**) ends + it. - Any number, switch, choice or read-only value of the device panel can go on a strip; the device name and lists such as the input mix stay in the panel. @@ -193,9 +194,9 @@ name; what it does has not been measured. **All values…** opens a window of its own (Escape closes it) that lists every value the speaker reports in its own schema, with a path filter and copy buttons, including paths the catalog does not know yet. **Full scan** reads the -same and writes it next to the executable as `scan---.json` and `.txt`, -with what the app knows about each path; serial number, MAC address, the speaker's name and any -network address are left out. It is meant for collecting what other models report. +same and writes it next to the executable as `scan----

    yr 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

    rss7`AzOrYXI z`3ooeUz6$h8)gunA;wnFBho<9f8w{?8s##ai+a+h4E$U8IdU`|_%;tBObD0C+sevH zV*x`)R`pnF2~p27m5`264#M$sblK7$Oa%~wkmz&(Wv}S9lp|C3ATIz5HKPgM zLf9qP2gD2vupA=bmCX7F`&E&VbFd;}fwhHV@1yBQD4kHAyzjHo!l>Z#E7;aJobAN_ z%wm{DnzwP|#%+PX5DGEHdG-T@WTWl|;OZh(Vzq@s0%0CYxs_$)&De}?GtLZ@T0d+( zhK<+RK4w+biPW%XbQ4_FqlwAZnU0Au?Y4VnVHm+~%y7{s&crg6f;cf70Ya8thRg|v zkw2mFItUaJZ0T&oykFoID`s-w=is|7lEDaY(?lyNRdegMZNx$gkrJ1&p|rHr;2#Td>CCg1sDbxIPtGUfsJv6H;eYLas-ukbQB^QgJ)U{4?Epfh6R`A zof}cPy$%jJJUs8S2sR?p|1HEn1svMGufxMA`arDR8VKs#e$_avl@$8VG!gU5%P41D z^Z9MyEw4O^&^c2L)_~mD=OonWRh=A@RH^@=9>2cD4PZBfq5pnBS!YR z>CJ!7Zn3L5_btwGGfJ7CkSo^>{S#4g%z)1nbvPLu2TD)NclY&;}Zh za@|WnUW$=Nsgd+~PJf#Tg<}rnri>Cc@>j(y6{{fem zHzyZcHuh#V>Vu$9uGkOU8$cXVV;z#G(H0uVECNh=0zO+0udwL@PhiM8*{+X77;>Sz za8;fxh6z6WjF8|6DDhyb#4<9&(D%Z3sNGtee^^-TTvA?N2}rAM$Zr7MBIPFgqWKtD z#}`fS78e)uX{EE>bv(N=MQCh+Pl9u)4MWabu<{bpG~zEjx0KAh`0h5oXV5q~Z$0<& z@mWAlN!fC|9l;U3sQs)^!~je`N0VhzS2D?$=S-Agtd|Zy0#0Jjz@ufzO0`OAze8z&eA|clB@^xeu@&0lS%Q3nV&Q_Q{K+UI7NqZRzFX z!+6v#K@YVXAwolrcRwtQn-$*(;tPlA~56Svtm<+L~|-f`&G! z`#UB(SPx3aSEp6{^++)et}}V-odM#aNJCPcJ)#LG=$M%$;Ql6_?+u{6CgyJp zv7ipJ^74K#7HEGN{{e0K`dCveDKN}IY)(X)jS4p`4#?vv%Wg@06OdazrmSfP;D2tl zBiM!zGCjI~|EpaV>IfpdNXfIKy$)tiFTXN!s$o)gSoKY%~lp zDs%v#)c1Slb+`lbv~unUj)+Kh^kzJK#Vc=D^)S5!jxOqYDg{2!$-6Zpqb!?sJ0ioS zsd6We6P#%Fe1w#TJB;CHZ%ib~m3rhfv^*a~RVHBzv|e2~7CmK5Lr-Oxo|=OF#j@M_X-2qXP`yrS z=?P=vNs^=_;7cWC+yQemo5t0ut*@9ggN+fPF`ZUKZyu`s9zMbRXXBCM|Oz&859tE1K zP6ufGLqBWn!fR#Z2BzP@#LllOGfhHehZ11NMvClnukWi&;QDkmDLhzgB7x*C!>tBH zCTRKcaz%d*hFRda2YFs!ieEWfNVEM0Wja~&lyPHfB$`FUhMg}8po1+ZQd!GBnGtav zd?e!K6@G*TR83@6x0-RF+%G&5_Wb!l`yfE~X%(-z;VOETjz(hQUY(i9Le}vSUzD9} zg6(JV{FY3!-o;Idkc@;Wv79?i_7kOn5Lh71(0TQq7slz$8wIZhiA@BEn!e}B+&Zb9 z#q+{#5;zkmsdk714oeoWF;cpailtz>LEZ6)W5Tcj4kYEfzA#{{BgyGMG=l+^N{$9u zscqB`#6qg;l#{cwa+ts=dQKcT+1&R|k?)fm%iz3sH22<8J&l82vm)s);J^^|z&B#+ zFcQF7ts%Sv4TP+Mdn%4eNETqp8lV~(iyzyy7hMsiiy$c+=3A+oGBXct|xuRRMfcLiU_;$+D?>dD8 z)3|I4>h85WCgL7gwkIW+E2wX_wW-Fko$P|joUoF^EvHf@y7O>N0a;9VKtFRK^Ulib zblId$(h(b1=0I-6a0E^7D?o77&Y&hJ<0I?_VAU#e)+rhTAZHJ0`+nd1kL1I^fvX%9 zP+}o6=mF&e_9Tu560)OsUmiJ(iidTDpdL^}guEt8&vy9|2oK<)OrcZynAZ;rCH0ik z1^XTOt*H^)edRX!v#~Cfw!4*{P#L|eB7SN`=E^B;eh+=%wPVN3t_ zh8sQ2Ed`Iw@*lVGeV*JW^vo+E;mNvl584CUZ%J=m_iXw~or8x*1k1QywxKFl|G>q9urUK#hGf4^?SiTKmyf)aQ*ppu?hcu#UY^TN}Ja6o_}sI@x8_;8Y(cW z&(FVPA5#Cprnlk7*+#3;&As0W>rpXoVPFup?vySKMbGS#&T7TKb|X;y+|*m+w@X-g z&XB(^8vV-|Y2T>tlg>@^)F7XSsG6+W^3)}r&_hq6P?W})d;5i3t#4+v+`9E={;HFw zPuJ>GP17TGdvX@~Rk+e&pQ3t^7am5FSNGn@?XG`?%fGdS9&fMz@H2$fv2_-^7IJKV z{eNhmRA=hG9~7(E`})ly>*l0 zpQF6G=?DHM8N}33dT?-Xtl%Xq4$q$!rN(ONL!V#7+}TB%zr)iCDIPvuR^zy?0gA&g zXnX|NHr#s_b;Yex1=Z~5Y$rC{@MDpBU9I|oatDC`inEo7hZRW6o!otHPY;(Yc_UCi{qke z(s}Ur4-Am;@NRq?_96p9+`VE&?8Uu0TvO_RAimTuq>=pjq|Ql2TKWwZKn&MufZKpl zEi5czW*EnWD{+c1Ac^mbR&mM-bdrTxH9v6f*x7?PS`w=0Yr@w$ExLWt#1s0L?~Wmax-2c{85W z0lU@5wr;ybTfKD~*XXs;fl%n{!D&Ii&1`8z41}3?NVxa7b21eR&}l$B#v zMP=o+Yu9>`Px1JTU736guOs+Me=^OaP$&x}`+#iv^(E6lRw6Z9vbT%mF^w$rR ztFiI$3}C~L>5}=cNDt$~;di+-ndT8ZAWf{0_}5oha)Z8obiR7D0?-(wV~m1v@IFbP+J-R@DAfVV@UIhi+3M#E_lsecz$X8 z@zR4k+t5eAsQ^Lex;q>dpu&Y={KL@r zm1t}WVAS7c!`typVr6tN?)f{2LvE+dc+M0=f1hlymXwSQ2Y(p<^A~a1MLXDwD&?6 z-t`^G5fl_8qx%peGc_%QvDfwBOtMkYxWGJ;o1dSbi)-D64XWD=p|{nZqvvyjkIGDL zZ&clO&h$AYXB_jyztrL6(p=%v9FU1!{_;3W$n3r7=&z`sV`F0h;-Ef5M)mcQRVTTx zCi^_GQkLd>r9d}?zw(dCNjFkZ@$g_(DV#nHWZXjYV`^I3QmNG9Md4S&8SX%hvOtx= zc2iY(fJ&n(QY;&}2^3O!c}FqB0w++q`)PfVT;-s;;)kstj+&a9f(3gMq)&lnJNa+x za~aR?eJ`~*O4Bly%~9pV_1@t0fdtu=!iCoDQV;*YSwvfyw?j$6y#~X%3V#}g`B_J{ zLUYuIH|F-S9Z1Q34KwkmC41l;{s2S#?%s8DH37CT)=BF5FEX>!5T0$5+j-RPJIEVD z4ZC2OJOWNd_=V)m9saam(VT!v=M6+q44910 z7O^}GZ>Gk_uZfq!U<@-GKfbuA6ccI=2n6k2xMe5k&Wk}dY$<4o0jj76ap1J6_h`m+ znKP*VX0UP3x3o7z7+S z;o-LgxS~+Fovv@%ve0ZFqnAH!Jv873im#Zj;+9UpIJR!n^C>X2rfo6}IEo#)GoUs?eI)qfHy5vP~{n zK^HH%XuatN`<}Aq6~a<@Rr**BO-&3VzHwtEez9m>RYh|)vXsE0EBG_;L&%%;^iu9; z7MZime+dg`dGD0?4pNsl^zmaY;u(yK29*z@!XM88pudrezo9Km+OesyYTQ>+)!AC= zUN+ZB0`0GO9~@9~FSE#MT(Pex6mQ)1rmA}B4`h$;V|{C0eoa%oVu7PDF5UI7gOxqs zdY3%s2T{4m_{BN(_xInTP42E@NiA<);l4|27oKul^UH#+s_Hw^=f`tY#u_=kuACRD zW=VZial?SWl5LdT+B;Rn!h$cG2OsIZF(bQ&#kp;AX^r??8M^_qc6`IwsFMyzVjj^`R1;?S9R z5E8Ox&6fw#n77+TcXOe3y&3DmQlc_xkwyB0qvqv*$tq zmoqM6wBe?a^&2*5)j&|P?vg;XR+E6Js3@vaybs`HgM(*-@T}u>$BtxXX5#Fec=GF& zjBPetl>aTg&|Qow!bQUaSLN3~@Wp@q1F!k(A2)XVZZQ4#D;dl)S$_QcD|~-kGxGTN zSJJebq-1KR%mNd^!3bvOA=Y276?y?L^Qpm}n}_G}@n1iBZ+O5Cc?w-J z^mF84y_%kZp>n%`bII5Kc&-YZExQ;QopF9ap>S}|@>AxoGKpAAOYV^$=lO(TCeVZL zJr1(f`Sa&xWd68JHrR?cR_~CCBE@*ZRl)98gD$5ZqaZgZM3t2a>eS}uW^}%h2qO|7 z+_&$hnWsiK2Ax8|vfqdGR#|1G3kb6*8c9k15X}<~#*E0meV0MkE^T;Mcx zKt2(E`8q(Xp0KIO$;qXkrBbbfs_RA(UkB`tCQ%6mO}NVyMXV$!&u`vH<6xqgy1BV2 zDOI7c2Slf?uHI9=8y0^+)BubCELz?SiOI>YkRkv9M+&2-qtjA<%=z0RBQl04_LsH<-i4Br%zFZUvBS$wP4Q#v?#^MDRF2+DNUtP11`t_U{>1} z`|8y^Xi~Nw^BxNW7bcwxhDJe`lmvJ^7Ppnb?6}3!es?WN3ReaC3}tp?U4JqUWs~n3 z7!X7A0l~aQ%fH5$3}U09IisLJy4{3jGRHKEY@z}q5ih-%AF}co|G|gae-N(v$@a7M z>@^@Jrr^Mr^+MYm5c6!U+b3YzNlH<`sgaa}j%(d=@02Ch8Xg{SS^JXJkX*CM3)39` zVBC94Kkdx2?R4RfCkJr#hhsz?FbIp>C$q<~Zqf5(kQ)ZaTLU>Bc07FPy%*?&N&QP^ zCt@k4APnFER1shqakzk(wI*3vGgY|`Q(}-W0WdMHb!k&o2NIPH<_UHI7?B5H2J&@+ zyVCc&`;x6fi`mfiwzk&4qFUL=`s%DlALv@*ELmBt_KkY*09nk)%gY1D9Os~bfB@zZ z7#v);7xxr{&%S`dtFESIeMtP#G%V7?ziPIh>%BiLB`Vs4CUA;|x;nkwPRw;dtu7uh z-eFtGN9;EAEC#BRXC*d6ZAz$-vwsd-MI#Qs?u@4&pBYy=gxs;3MXr# zVs|cx=7Pc+c^pcYq@<*4c|llcP;~kFI&uB;&p!b1*WBETq6h%`+89k8zU^i0q88XxC;^A+Fu79fYNWCw(kpiWrVdYI6k+tJ0}zHNJ&AC16F$dz+`GPTNSZt+WBOQEcQ1Mn+YYJR(SyG z2Mx@oRUTk~1+EU}-OdGw!Pi39)%BHl4AwHCVrB4|fm^l{#yl7hM=Kk`EVxgx%l-36 zPFDc9-&AT+Ksj&Lk~los!TY;7%@4xU&LdYrj^^<>47Ccjr;GrPR=LehB)+A06_k|& ze0_udK(-W;`L&ZQAx}7Z)B;+@CeN-I22Md~9QcP8=^AjS%nhH`?ZpJ9pP)ZghU~;8 zdwc6I-BqJHXI7f(@AU13UY~oq5E&(a4mlOH|8ehkNl@nJU1#=y6cu23LiU>;Ujelp>-?++;mV)I;z z(Ih_AKePb3vks8KE(qwdUL>_1^#@oi+-4U<`xUJ{2&Jh}9+2SE%kAgob;WJ~s|KEZ z%=GlJlz!k1hPKC%5nJZ=Y~TAHM*{|2hog}L#&?C(D-SeOwI^>yNVi+R+XRrD+Gj_h zG%P11M533~V%#nBt*55|4d53qUVusr;V^7D%zzo;Bf?%T%Q=LWm7Tp=0YPNwd1@F@Ym}x%FPo1)dB!POf(mcdEy%J@=VQeM*eV~g5<}F3ewQj zo|Yt(@Mtb@=g|^Hg^wGsRTd6Kio3mcA@xCk47ht&b6XFM$Io|WX$1|h-V_No>h3$^ zfGPm1tx@-y|0_M>>oomhW3|E?7@6_Ad{WcUAkCRnp~{aR^>HB)o^H_+*wV~a9DSr2 zZ~XDKx+OBb6PIs(Gqf1? z*Lk4Ug%ulTDYNsAb0!l$*LQ1>IO7?2F0)Y1x8ZvWw~-9GraT7@IA-+VAm`!c?lwO$ z^&nG$-@4<~JIhr=dN`wV`r^Vdm&+pjX|n|ph@nY}?iXe+;_Hp$(h#bj!ND?#>rPH` z$B&~m*cBi)Y`yWaN!=j238*peqMsH>FMKreLEL55^Dq`7wEjvoa7kUkSsdvTDWW}f}h zEuGq%jmvXCQ3n_tgX|zWHtsJsw&qA_14u=0qta6i+)d9vR)rlh(cBh`;`s16f(%qt zoPNY{_3$I0Ct;D-S(&!m-q&fC=ZtK|Dx0^N8XLR6<$SrZK!xsJ-{P<5-(K!Tb3-s3 z(yCnSi7z$Djejc76sXb(;_hu?bZ47)hE4wa!?VwYNYS7U}+GRoQr@V34H4tC~7&g@2jvg#%1-`}S zU?`=lYfcP^GF(mIUP92Py8{CQ2p}jUVIg(pB@s1ex7zyFWhP8GFKiNS-7}}UZQC|- zPvqq*0>r_LgOUTQd%G;iAAqOMst`|`-0$h;C%A8k0G%?nynjP{T5LGy9md0JYBE40 zL#5)6XJ=-B*YD&`SKtgBSC6T=j$8n!2_8s0ksyJ-sKp7tlxw(43%ycHHW`YxF$}|C z<^co3sszyC6Admnmiqd=t!Yo{HzF*A7`;SRh_qHyQ&U$LL{s4DyY!x)imy}G)P$k^ zF^!ImERwEJO*iv>*=zClGvpGYo6%~3(4T?qx)0Q?3QF9M0P-I$OioSx7}OJjJfb%K z49nj4B;Vn?Bw_Go#mrOGD##dzrwQ)?(y)${>yNr=iKobss~^s8i^Zvg#VHEA8BVE1 zrS--~H+5!NrUcDD-6u_UN+(6%MJMhEM&Qjf+S-^L4PV!|!ChED05YD>HrlCGafk=l zH0^-w^Y=|mPqPS_KXfIUIP@D#4iGF303L;cAQxZP z<~H3H7#Jw)g^6%wJCtN({#Mk#4v)7hmI^Vs8>4tb_kmxAb7}x9kE+jzBojo$TeKiv zPzNR*uGz||Tp8@cd4#6wi|xK9a7HHcO4KLE#=vbW0N@=ff@cPT!8WYA;sIr)1i;DSR!D&B=C-H1rNGLMnmP0IH( z($i5YunT|3P#25xhj)Va`0M94P%%Ht>0WMbZnL*>mD`|l>!`sM*`Vk9_n6{KqFdH` zVt8NxmUF!0j)XlJBqt}A-io2HEm_S8O^&h%X5xOewdc-c9pL8=p}9gI&UVD+7Oj($ zQ|L>nmq{$9GX@~i+UDXAP6KG)6y)q z;zco26_qD&fOb!I_IX}yj(?Iw^-rDhu47^mEy_60>kzov1j#iL23arELZuRDhC>Uu z;SNWfZ4__#tCtnCf|g%b!@=Cv_1p%{rhm9!Mq=oF^QayQ30`h)@U$ot2apGQ_A7@9 zG)ikstlJBSk=;CmmYbLNfl)J-fadB;S%2R|odd0o?CYZdR`cEE-&)XcFIohydx%VSb^GL@sw|Kn9+L_-$SlYs32rxb8 zW5DD2=066n>xr8jXP=pwsfv=&*?q;(Ft*LPw|R3ov%Vd*`SyzC3f6|&aMWb=>aX|d z1#&&gm3D0zO6aa{1Aph2wTnb1=PoDukg32VUEbHJ9k>>kDkI|zZ*Ws zw9(){WPRBsJg}Azf>Zd-LI)`yGm6aGfqJ4u8RH6FpWAfFxAYRbVY4Twp9WrcTnlo~ z5g(!Le!Y-oo#jd<&qThS0L$BDEZ09OaH&u+ev>9Tg?Ez^@ zv%C=oje&)_@zePfjdH?Vm43avhBxv_1!Tw>$yxNDEBFMz7Jw)JRs23*3_5z@u9=68;&ht3l(-B=`JKZM(WMYNndR*La9qUz+^m)1FL%PHl zt68-5#6_CX?;6lAf}TOW5}}7vEnk#0ZsG_+&7=Hx3q%h>Drhxyn)J&1yNt*PF8ASR zaP1}EPMK6kh#LAPZ(VEnoLK(^y$-z{B{L!OjRNu?4L&g6)U?$ff2am@>C>k!b=Sh& z(I>3rhn7YScgnzq_H4lJ@ewi3ix)qID&=}UjoLJAtvT-1?$Z*ba~u6~a&nw~4EAj^ z8C{{lL(*bHt5Jt5Dr|Ln&{Q}X9-a2VQh)X5sb29~S6{mtIb^IJ8!0s0-6NgK8*J6{ z6Rb`R5tWgboV0FaS8&(YCQPVMf_3%xQa?GY|!>7S#H^x;v~aIK6f z$}S9dIL|yJXT((L4ta{vds7{jCHU6+7o=wZjkefSWL117Il0JDlR}AbM5~BX;fmoG z#zbnJIC*l)1WM|MrI&QW*JqD8srRj{DE#7>$b(tyi#te-2l*uZ7{fVZUTLrQ7do3U zS37p<^)juK>F+0>@ql!5(a;u zlmixYx^!UMhjnTv9}rJ6JGnTdBw6hVTZ4EN5oBUQUXy$QAkIHahE-4m%N@2 z_=Mlzp+`_4bih$)T1e4!yXujDVDCZqLY-F3tFDake2e6*F7uO3+D8i>)TLu0o;&1S zIs^0yz+^R(+()TVgb_XR&iqG`-VQn{VzpP8D@K-Z^+fC39~mGY+G8p_=L`UY?zGsKH&xFg_sxc69|f}$?$amH zuT!?ue2%?zGUx-+D2d=Is!lO{eF7mTu_;!`uVZ(%&hh;LlTqUx){J|!S5BZ^LZ?^S zDKyOVF|EwuqeuJT4JochE9$VI+l$mwMIB#K6(%Tl`1HEqC4?ZQI08zL+pj3rOB;^uL#vbNs2vOt&ZvFmExl&63fbcj10 zql=QxyigMem$PgC2N7c|HTL2;tlYxJ+oa`(X(@fk@>RbHTmxgBYlz4aN$;6A?dhtH`wBpPg5 zlfm@n9y=k^kuB$5Y{}qGj4?v+j_4Fo0__s?pZ@uKvTCn0z}(qgwXM6BSRhDUF~I-`jwZL7Ve3}87@q; zmxhfR9)lY0Sj}CU{}p0h$GY*DqDAGR?(+i8Fw7nCO1Vm8 z8fEA0Ym7MX;5LHvdA>(VjUHUA*Atl6+69+R7FPpz)~+5y3X6M2t(J}>fPhb6-n?o7 zt}ya9~ihg^~5zjQo*A24o}Jr14YtdxekzV?CfH5Dy(0 zymt4SOw#bpbqP;QOst$SH>KQXp@TzIiR)wXeB6uzUXH#VGJFg5=F*WpLH>W#hhFa1 zy*tz$*_V#(bx1XL)afCTN`Ox*UcQ7!2w(Mh0R(}CMNe{M|N3jmTBn*$^#Ef4PWuYp zo;!CARTaRSlab9~qt(f$>h7_b(;b*p^g$%}y_4*cZm!>N zl)Lb}-N>FWP)Xl2CT>D7x}f!foraNa1_yoQZ8r{KNY{kzFlRwQh1b*{@VEfdoUKa! zqjl?Kmtu8la%3j%P7=$`eJ6YaXdY{AIKyg|kCgDUi>@{X)MJOQ#6gPI)f90Ky7lV0VoCr?l_~$qB^?=?T11tf6EBoJ-ue9MXwX^DCNlA%dCpnrf^72Y#+m zcpZLidIWrOi~BkAqS^5NRIdNEqbcllW}s30Ss`;C9K`+ApaqIME&_Aes%7oYsCsQB@%!WuxsW!Mh+k*oZd zPyUzL{l#JbqMCnk*uVbh7l-}pkN)Mb1?4Y$;a?m!yn8bx`(GCN*T?zyJ%9btulM}L zeE)jSUw`!LJ%9btulM}*NB_R(|Nn7hB8=uc^1pv|QfCPF68q|>hK51^h#8jOM5bzL zigjqmKTFMO6y+j`!P?s9#V7u5qdbKZ7+J}48Rgz$xsI4v1o)beU_8Q7vWba6p4SX! zolZ|*17imtnVXka^}AKa3539dAIBE1DPLLq&!V$hfZZiECMLmlSVV(!EQ}WFzhtm* zrZ9}EYrYEl+eND7m@$QL_#^yYQTTv7yE51?3x5>_2~ex-8A)Q2I)tFS0G$ zzQ@=l7nxR(R4p3W7A+D!542{cEaEN#%`+bMg}7Y_x(qbjUio5XCRjqM?DvRvZEIli zkrsh7I5gVlmhJf(%;adKoZLy&#mL^mNP@)LO5Z zso=iro5#klQW_h7Iog1?Ad^veyI{tln!=62w^vb^KxH;i1tmPLL;t`WOS0+p$V4(V z1>K)JS18TYEzTXkbm_7CbLx>H0iu|J=0sAk61IeTyd#&}(NR@heFG#O$vV&9Y~f10 z1o~yq=9uH1=FNjYxQHZN2xbc#A}VANu$yB4|%uMJt;L{Cs1C(s@P31*J4-ELdYQ#na+&>0b`R!rE z1_D0q=Fq?<^?T2|nZ>T;yoHxshIqq<4)3O;rbs@57DNW(^Cg6yU_B-?ICd7${=U&M zB2|N;QB2SRMSI!9P!{MV)VfzT{f1OBAmUZS7bKb!e>U9{!jB`DPI2O(C~hG406fe% zx~slmISdT+5Z6(iQEGTvR!_OvoWW_{Qj(l(W_9ZaK}yQ?lWDXXPZ~!rB;N?guWULt zbE;S4qvIFMn>WBu2rohAIpk`68VEokYCQKHXWn%RXDX18@NhNtTbufyzxWQsI+F*a%UMC78RD7ZAtfgBxi@!jSKRgB2z zI?i%bCL{77`wx-w+9O8H)*SWiA>v>9D1<4_z*Y4MVA&hLJ@3n3)ZY#);KHgZecvnS z*0N*k%_fMnszpD)n5*5zXv?HpTWp2sIndh!+)3}v?KVhPmq`=fD1JmBnU*5txgb$_ zzM#;Ekz2ghPl#og?z_vwR>vB3)9gQBfEZ}PYf$|ma%C9CxVSo5U2w&8TIP|yA$VsF z9#rkSk>|d`wx1o7vh}wgK71Ii#_6|C%2SYz|J_+{zNO_*zwNtszXeEWoJ)O!QUvgl zX+RgqU6XEQz(?J8Y6E{cjKZKh*OYta-@m_{L8+_z96v8$JeS`TeIE(m;&@kt9Jmdn zMZ?xZo8wQ+x_fs+&H3A|xj8gp6Id-x7Fw@7#RX8CG+^p;9LW29M-2|1{w=Z4m`z%) zKLsKiiiBX+GOOA{^hJM~t&$x9lfD(%X(l*)C5s2R;v1^q_Y|sG%S4_^SlBT2#&#j? z`oS@(4jt;X?kl)bdmkA7z``4DZ#Zj%bRrb~m zcRT2ScB1}21)HPF%F589Wv`+u@+^=E_WEs*N{kN+R$S}18^%0z%*%=UzJe#b{y!n+ zwj#QXz4NS`U@?O>cr%OznMBfh!>Lo-ZR?@#=GDSK4Wzm1RZBmZ*TOT_+@x# z#7;&(MnkF9U4VxQ+&>P5TX6YC0=?K^)sCS`?xBy51hW6$>kN46y`sr{g;uI45s5QD z+n+3(s2F%N5hWKAky7=s@vX)PHZR}e+s*d^y1^=I?6Y!jd$loZRwr7W_5~q${^k@Q zE}4I-0aq8FtKB}_?-fsF-yiR`(RQHyp|16P@VjB-OrMh-!)<>4ObEB?J>zU~MmsSI zbh_mMkpn?pc{#ghP>Ab3PE=Al4NNLW({Lr4F1NNWXSS1TkN)pDtCs&vJbTUov~}Z; z569fb-F4h%GH)IxZ#B#N(np00u^Wvjy9L#SoQHm=!Z!CYa;bR>^K#tRW{h_BS$fDy zJEtq1-Loj!*M0g%S>>}?Y{R~Xy6h_-`~LjA?VMA11_J+*tDk0X{1R+^uUmX@op{6~ zmP=3VZd|17*El=gKS<7&-ABmSB>pKde5+tJ3BLw5LJIZ{8*fjB)tqWOG41us{#`ed zjn(~lyY}tBvV+=p%VvV{%a_N1bpcdpOP*kb#WwyJGii}7q8jP|0qZ+|tla39p(I-^ zn}!*;*6wDI)>~rCE+>W`AWZR-gAfbX_OM}(J-6W5G@qaaU<%-Rdf(!(%xCRpD}2J@ zUY3#5uf)&7YO_IL*$qc~{XJIxGY%6d*Wo_RlOB5-DD;K$$H^9F{3ETon9rX_#>BWe z#+eq)XkHSb+e&w~PLJ48t=mqS5^A*D$~P*<);(xE1)&)bD47|t<9rudZxMc+?7sJu zX+*;{xzY`EJI}ng-~-EcyL(hGRVmS!Vc z1yAc&6RnA)XVJWQkv7S@*P)BKdDH(0Aw!avL?ZR?i=Hya-G6`+3s1C9PpF>21Z$_4 zJdA?&PX2x_HFefpF6fOxr#p7lr>SP($&EVGOq-t*e}0&FZOJp0_!Y~cbeFgN0&N~A zu^q(ODOvZ9y=q+fQzrgeTwMJgNTkl&Pc&LWVr`SWGMo})KrhMZ80TIyPRGPRVTH-* zM;ENe_y<-{5gPO#6{Ex5diESk;v#iUZ4(b}J7tb60dGlbBO@ZPtDh{?-7#~IR(sZOuG)7b*g z0F_NX%ZQ9%&ch%~=h$~@H_x-Y+G}F75O@2N2hd$2zgC=Le=4(S7lC4spON$O7zG@M z+%y<-xRW?31Yb3g5nkI*|Wh*hYgEG^u;cpu)&%* z-<~~H)hB;Fz`n+d*S_8ObFGOBx*u;69cCMCqzhdLP7#S9%UoWo3%#_11}_uWau1E<3H+dZp{V@gF*D zkzK(SfB61y$>slr8h(KW|ND5t)07z|-Si@xzuEFuuN&4x=1=R>42E2hx6?QTig>*F zlB2%ILDb{pzY6}39h#s0|2JU7eD=;;&JvC0d(pyt&yP1c-3(0XSTo?z#GByDJ?X!; zn&I5EW@zR_C_Fl&gyt&@Hsl+@dLg<__JD5n_m?d9pbQE(j%J>g(irD%vPdUUM9l7! zhy0ew=;##7KXeZ~1*V#w{soOL)ikJHMWTIG)&S4(N6j9x`9mKVwDm-gM0u^9{+%VO zC@0s?Z#ADRwMlj{Hl=nOrKmRCb}Gx=v}5bP&yq^sSmO)I$h_-2=iA-o+=H1I;u6lN zR?F2Rc^5vBsJy9kH(acV_qLDn@|lHm^ijdj@s) zRvk91!178nZi-w4AEL5fgeqI@dg5uQh05Q&an48)`*8fqk(BQ6VJXX%jMMu+H@0fN z2akph8S=id(X5-nlQ*Mi$Tp7%bs9anDoCqEQfu2$_E~$7bd_AHCNtNx+o{CFdXnpwuk%1~jZZ<=$i~OU;)ccS zSj9%ihNuX^1 z(jSLi5)??EmFv%eBuieB>-XG*7gMyv(s8@%?`_0^>n5MIu7)r^|zvitYnVPw3Fh>vRTEpBLG z-9#RUIBzvsmFt9E6hBpA*=P%^xRv`xwKrD|Lu>@YdmF~uQ4@&24v-=!q|=Z94dg;3 zu+%D$wXCG{owmi3tE_f*`~#RPA?j|`s-#svPEFA_ZRG#pG>+42_h}91xEB2ZLf=}2 zL4yawpe!Xsl8*brIxkvpKAL&AL9$qvqN1Wdm$`%l1{#GbjhM<9OX~7A3SoQNJjGW* zu>x2xdPBP3KD#C%;0oWhNv{9Uq0X#L`nl542!`(6^B{4B`gk=u{F>I$Pn=aJrsI}e8*Uk+rDmiNG?fsGZ78L`NLyo1dv1^VXU%Gz=fORzo53?cY z+bafNlWqHxMq6F7R9Pqp#j-02W!YMJH;RPev_;TOTOEi|V`F2u`3O}~RYH2=(r$TrtjV0fxXMm}b|0A-V|+*hRbpXL(Tf)^m=IzZ zSPy6QnCYO&|LO8+K)D0Ni$W6aFVzjsW#rK9y zY25<{VO*q+x^&6r(&3PhB36~zMHL>o7^QQ3&y7FUxw|KHx0ya;#$kBR*j(@1N~-{w z5DuY1s@0m)?WVm(cB^7pSt@6~tWVI(^)>|75qrH%VhM7kSU#3HpMeXY@?=WUnaz<^ zYh7LA!dmXR>W`p=(oer_e%gv<;l})(s)=saIL<+Wyfl8oW=jWuW&mOm;scDembIh=G{2vAfU7`BG*&n5csmViOd3IjjdidKU zJN!3X@2HpOK&3u)WljrAG@T!+`hH`#KsRdXD63*xc$!w_ukI5^ zCg=Xw19`-PLi%N&$&jl+;F3(&B7&CphKJW)D$1@~T-IBP9BQrF)U?jzAqHpvM@u>O=MQ^<0@bb+_#`BrtaqHr7{8KlBZ_hk0_tdjD=w zueR%1FSYYkzyL{Vce2sGOPmj^Gp8nWnH& zTIh&$v^UU@KmieinfCCg3-0tI1eoMX@24~}EQ4tPIiJM+z z6Q~`CYr)XirORMb?d?<76uN354NJ52%{AYuiwd=a?;|vlW!zh~bALGjr55EkvaU3` z335$(`ac9CF>GnPRz_n=ssCqkf|Mq%rzOQ7M9VzSR06okxwGfqF%gh)W?tRYE7<;h z{@}9TxVh^tk5F>?1~C9o+dWA-ef@fzZlLe<3qDHQNu(_cSQVLVzpvwtYSect{53g4 zzvIDo)l?)86SUOM zKv&C4r<-f0>^*0fHxq1zQfKMLMFu9WVb8TPik$Gg8_%9PY`}w@3_m$`?+$9`X$7y3?Ek>+Nz-HnJh2yB{rq@1#`+YxWk)+k4<7eh`(+Jg z8V{GQHH1EBR&0^Q#_mx1rNT1Huzf|_Z71c;7fhrl(-`@zr)mCcZxH~u{0|nI_cIY|sIC~5l4Q@2iK%rp+qa60E+I4P6 zOxtC@g`x#h_M0^|(Z$M5D=fUz8J}bC8k+aK%Wotp{&KviT#xI+>Q>k!)yQj)t|cqr z%9Sf2#eLf;CtUuqCca&QTFJee@0%P{UOVb}EB)QVtAnI4@f27eqZ3eb5oVut_$(0z z3tC|xtxVNU>l!aNeQYi?cn^pA<$sXIUT!j-sR8+1^|c>gDNC!^^u=%@o*hm;JwP|> zV~43w zR3T`tkqS&?58bfg2g%ZJQ~xx*u&lbOz%WXPnEWv*TEdi*ZH*_j&1w;OG0K zr3=>j`ljh#V`=M@@-oQ-_TaC2GHmKo0CN^IW`|ieHb)Eo+9Moka2#xXmpJ4>Q^C>d z9i{=-2L!AEw7ogv!%-pUF)*QPu57Jw6qf>HP!!0Egt<@o=8g_*U+2owN@J%HPQAvt z1w0HmwL{hTuCg3E1!=)9BYQtN1O@y1E(!f^vS`tW_A998Nlam5WnUQRhkF>z$E(2; z6;$C&{zm;4NDQZOzt?B8HRvk+_Gwx47;CMb9f0zu8zK2XJdgwiL&dYAcK}FMj(<94 zGOE!Ra60XjDI4Ci8ISF_c+3v7zZ|3`w|6z}OCEJdrm1c5UTU#c8eDTgsP4Z2nk!}` z58w6n!J7S!E+*}_*{TBIhinUmCA9xp3N(6~vqAsG*6krnO&z674;#1MgGZ$5e2Ln6 zHP?y-g8LyJAva{1gF|UTZ0+jJmJWNAO18;*qIMa7QJbz9^LwA84zx2QImD~=I92r{ zb*`1wH)7kmV_H_V0CmarxG6y9HVIxE{fwTz<;;rjwO!WTOtQM#Ge|{Z%>HKia4`kD zvC5U?w~SWZzutfSx^?zP1H-A3&rHwn-*nM#-IPG@M zaB|FJJ`>3^ZHv`Bl1y&_F}|he{MmHur|2b~H?rH*&k0Am5`!!hd`FpiXQ2hV!$2u_ z%7=u8y1ce5y`L6v$wVd(11S!Wq?BKbDR_-}QP;g}>;jDyR3sFYYc)C+Js9QAO#~8@ zh8phfqZYc$r$b$8K=~%D?%^u?yBeMe>RvVv1dm2VtvA7e*n(o5&H?BrY0z0m!%mYKAS?!*cm6!J&=a>Jo>3@oF z7+@~uJj93r7YV0NXWhG}-LpgDXN9vYi$wAQ+5RvtCSHUi=iqPMNY`H2&PZN6i+bOOoiZg!yY;o@OgfnOM&YaRN zEbbJpT3Gz4{bX%Qj^wn`)WzstG`@|ZE8)betg7M}rSf#nHGt>_8?&^ss!j)7rDrqQ zPw@qn{PM*M1Trvf&eL^a$K&Puin(*B>aC{KAf4)s7KoBMLKRC&_3Yxc zqQwn&*|n`OpI;4V`v?pIuO_X$9)}&m(1i(EFz3i*;h{t1X9X4MP8WS@(Uc;RY5Ndti*8iJhxG}qr6}Jpy1-!S)Y~_2I{kJS1ezHt zH+{YeYix!MJ3BD%?(N&}|1Jjd9Gtju!?4G}6e7i5{_{=U*m*%Z&GNi!9*w$fal-4b zW;aEP_q{BNv(>MjL=YAe^M!SUJxeL){0aiXe~UMEnBu+sf)VP+Sj)U2LJ`{XqrCr z=o*I^Z=JW_arl7d!UJ0@k}Yoc(15w-=0s6}(f*lXn&SWWurdJ2_R8qi$~}g39u7?i zjE|>EA2EhifE05pHF)8xr#~2~ne5g#gW=Sv|gPF87hTd6h&WYJ?yu zD0BeSii=RCJ|YCC%d=)sDZ!Fw-+9aC{bq~Se(f@fXFGQL@}d9>brbvZ78^eNrf6Uy zI|*@3JffspyrWs}^@qebG#4*Mn~sVnRdZP7WKTNvT2C5sxW>`|t0Ywwm6zHZC5AhD zDhKXpeEa0>gg+xCR>~GK;_1rSO@&{EZO?DWbUayv1{+HYAHZrnprv6O=)ZEI2) zw_^jh0gCW)VXmOfna(JD^TQbiw7TfX!B--Wh$t4zXftm%dB*HGF^vF^A^=-Rnljc? zwt8k5ajfl{+3mZ7;|ccFxh5v>!H`zZEgZCM`@m*-*&o&aWSmU;7E<&Nnk;v@eCyR; zh;!4 zrbXz2#FSO>#wy+Xn-#i9zg#?IcfQ5{a}tdC(ZU)3eAuTm?5t&roB*1CF2^tZ96Fn$ z%FS<_I=53;-jW|bHjn>T>G$iG|Jh(#n9q@YlG~-y=D)JJ`Qbs7_h;kdoWTg!0cB=?;!w)S!t%=qV@<~tLI z21I1eM^A)C=D-07SqfBj>O`-PzBfKH#`r zVH@#D&tuy}j4Uc%X2`3~04P8U##lnp$&;h|>MzvmVvw9mB{E!gH)>rcVkp%u!Uk5vHzr=@m5{u1)MqC zh&b91u-~+O_Cr*J{SxZOD@qj(Yh~~4+6QS0i3t1vw+iwN!oGvvhL(6^i=eCwOkUhO zwh=IJ=Enm2nWm;xw)`sQ+j3W}5jGdcjvFWSw98Q4vb^nCMg~glHA*JD#s!A~v`3F( zo@R=}k=okD-~Y0~l2jTE>e{QrR$_1*jznEuOmG}O%p+@h6MI(dJq~LPOvK%Ut9~Jp+B1AxYTj-G^t|CSycKkuWNM={xEo_^0 zorEKPR?MCQY6xcumLqg~Ye*xtpFm zm_5Jx?jw1Fyq1T#aPd`5f=gw_mBNR83lX4aOXBh67n5d{I(blI1X%7h?_eJS)Zxjf(c2bA! zj!;GIjEBwLl%=XckTnC93avEJ}8c}O{u!YE4?OKpNWk{ zH1(-c3#~^7y&E2*M#ryQ^~za<;jD)Og*X}Zw~mMs=#a7fR*o;#v%99EzZ{oKIGF|9 zWio&MN1E2$Ze3W&2oY0tFH(r**a_bdO|4x2e*OB<>Xo#Vcva#Q<;NQ$y|d(01JZI5M3gig=YRmCM5lZq zMI%%m>urX}z1B?b#4Qm4)(gF#eF}0!nZmF?XO3I#$}`K4fdi z*#c^zJj;CE3j@VCf@cY92sc*QUCXgVIk4(*$=?0@2l|QQo|c2kV?P(ZOP2`M8Sd_N zkkb7lTkD3qKRj(hVvyzjXZ$T;W?xxJ+zkH_MgHT*k8zn67Mhxw0iqN-9lq7f34*K2 z*cXmPp|7W`!T2gmFa>9Vec5F``Rhx1u0cZLw;n-Os4|wLO9Cqq$(x{lC@%OsNz4l{ z>{XD&T!aaqfmE&6zhdLIZ_9AKA85afQ<$)J;iAbs^sP^?{HCx{Nz3&SQoW*@;Jln1 z7{)I8^gv@X4eP>cl5A`4I8flKY`NXwXuG2)i=AJn#FnC&uIrCB{Oy%h)jLR~m#1vIs^RY+?jna9R=ds3wNQp zxwQ5ju0-c2mojU=&EsRhfiVi}^$wS9dqYBe3XHT%F6G*51ofRbK}W}zs?0|kHYcb1P`t2&=>-Tea(xDr`rWvzrebJebL4v6zr{ZBt9I zsuwW%SYc8X+ywvp$T4;D!r*B|HDe_*;`UQ|QyCZnM7#10urNP4KqSQp>kx!%EPwvv zWmevD+WV|F9HA96LQkam7E?}b`1$?Quz)VYDL6*fMp9x|Jn+u=tCQaFO3gd2T2dod zLHH;f-+Lj4BLpy>lFM<0{-MMr)o5x8e%FM=X7^?nOWbiL-;r^M$+fqBQAm`p%@mUS z8b5!3(HawpCc-)cLVF6xJx0H^3#*bB2zKW)-stT)cVtTq|Ck73_ zW#!hi7BN*~D({ik7)h>O)33cDZ}6H0K^n?a@6}vzmYJi(a^}pL*x-E$%~u;SL|Iu3 zt-ZFSRe!Zf^A;__Eu^DkTYrdd2f(;AQw+0_wX1Y92!+xU6X&+gN6}lmMyJo?EuAN* zZW#hJ4b2Qx53~psCzCvb#p(3xcjf%206ANfrlEzKA*tPY22%# z)1z^;JUSm_PG#1?#j84SG>}blzqEqBQcOZUcW#cMD%cvn!)Gx|a+Fq;EzwkL24_Ux zL{m<`Vsr+QS~NEsQ4-b`5*RFGdDLHZk-hS^UrXKnn;Y5JoeXID+^n(>aD%o7D!CoD zju{Pj;5#27!QS^~zUP5dV>2T1->8|I@?nPc9oO;M4u?TElh%l1$HGE27kv;|V%5+7 z;&H!mR(<#2{{3z7z#wc-2~AIaV_(L0xD*XHFku&^UPv0UDze}O z5@Cd;uszudi8f~i)d;Z0=t`Dcb1Zd|?p|s&G+8k*$h90ZrktxMRI*{DSa*JvUY*dsUAw)%1@iEGhPN)A33*h< zq^Af~V9O-yA$N za~`qzrkSXmM1^}h%N$Oc%0}43nszdfag2)@x!EGJCdL`T@j{QxQwBvCr?eSmq;T!R zxUE9mIF34MMRK7k_%sRd2x0gUDb{v7LaZEwlqy*}tapFqDPUqYXG5RA!&R7_6-qr5 zp$bvtS}sY^J6lIDzi(oDpdt7S0&&oACMIeK8EXzAj}*FoYK+IG?T*I&%_(}|(*us> zq;d8v-+%}!s#%_3$}X z_qWreI?#AN>4#2fZtgHZtmGbKk8(BG;DXBseqeo)*`(`Rf3kcguMN__o035>2;p1o zo(I?3uqY()P8v8ad#H;UsO$pY7N&5t_E#?&^2qoKdNBZ%ZCH@0QCDlUd}t;mL0;5J z-II9^7cqJsHI});uqK#Zw_d)@u}32Z4Kgsf@0Ry4IMO z#_HmGT85Eoo!0;O<|}Ox)H?#KK%sbhP>`h86^Ct|Er;2VvNhCBlZtNFC`CpXeazSU zR5rkTUmg1$3?sc1r{AZVlHdae3SUX7!6eJ0wO$~2AvJN&&IPF(GZ>H%vJ2&NObpN= zsEF4wI4zfQ`6r~E-sjUr&$MFm6%Ld<+h^{E6STEK1QP-x9xav0ODBzP?ny^X7t$<_ zK6KjU?@!So5l1+w@!YwvLn9dXQP_#vj?6KCIZ#&1Y5Kz+c*ezNWoI8^!e@DKY1srl zz4d@j$s+_E1siH~c;v7Ej5M~zA4pdMeI`h~y)wtD-KSe9x)fBHF?oT|k6SyWTU!>L zUF7kJq2uI)RKSXvnsUlWz z@0GQ_)Za2`yP=Oy@~oHbI(KIDVM<0~n6`({x? zKB695`S~%MoUly^rDvSlTp^4&MS~I*Ra96Qj(!z?g}d%GBmm|U&bd`Dly&Ys313j1 zfpUx|ZjhC>m(Q0fRNYKws~f6vGuTs3UDqbVw?SylZT$CJnaunLc_3D!__0s0NFjsk z7NK6*mk((V9XPNOTUDX6bLHU?`^@aQ^P{kQ8U9?ekr@bXTXh(Hl4dh|rtyW;)JN}U zEe+aQ=)Ces1+1u(wbOW=_Z)aGD(=!(=HoU&A3f_It3P4+h2@EE`8k42wwi8L^J=)t zgT%Dy91%r5Zq@ZXqTZ^I@wQD@oZ8q*+b_%bk*?J@Bz*mmPOGUdpU*_vSwFqDo1a`_ zTeNmpP2cZLK50UVRqsVBcXr(onCIy)w@c4T-6Z#s;l{(g?R+fF0&9bfKh5D`3-c%|@n~`k2**v;nWo_vgihL%k4IUxp4hR&zc0bgWf@Q)( z5(=YZV#+(@G#;ZG5W#&i{g;+jqwovy#a&Y~5NqB3e3SDbc;7xEF^Mn zx^(S|YRP{6p4cr9Uu3&0euT5l8rVuY;9>tcK6+vP7sl>io_sWExwLhbr|sL%C*vw$ zx+!Ome6?fmDBL$WX;g|@wAc8Nw-rT4p zQbZO&Rsa;{&8=%$2OMh%_;%{`GQ z+TSOl?;Yvn-TFXSSjM_0ue7;dY==objhg#) z)t3gST2YeGE`nX-W8g=0Lm-9)O8<}sJV#_xigJWXvx5(z@_IPgNFqtJKKMcq?En|l zY}A_yUAv<0wSO>!L!1Fa27$1)QC{LuB&GCd2gTOrY)2K z$(zu)B5V60Bl1(1^Zj6acPJ`i0V5tbX+CUIdh|nCBVVyV_|| za#f{2iXot8{IShZwX!4igk)h&2%4y=nM@6<5c4 zz~BJ`{x-hbvGsMv2a?5$3>ayC@!2OV#f2IhU7X{Cx2e==w$48};9-cQG}SZGE_%uB zh-krOUP-|I(&GN(H{km3wK`sO4+n@=dFZO`H)uQ723oWzG2w5q9Q4sPm@|D z#_GY-<*vL6O_Xi#HOs`wd6((v(hh6u6L_k!RQI8-w|>?~bP5Rxl9XQYEgv|Cx<3D1 zW5;#Wy(Bp7YBe>n@40O&;=!X38;U{L*P{AdJ%G%F2glNsY?1lOQ*fUu2IW60(oM_$ z{BGp4Yu|Uc=#4mRQf;3x8BS@|2m;PmbRfgG_c&E^GcyuqOijOk`{r7_t;^jUJ=fip zXfo^9fHC;?iF|MGUgJC05KSahc!VksRD=Df*Rjb(XY!9n+ng11E_(muQ6Hl_B_Lw_ zhdZO|v&BHVH>bJ)Rj3=5xr#~WdphH5seDsGrC_Qq@!^^g0XP4vcjtxuz#B;2QVpm` za!iUPp6^S7!fDk}DV-c3jg_bkzN zQ6$;Hjyzj9WP|Q&wl7LJ(%H{|Myu8IDU;_Hxu)C+qS0kC787gE;60wbYum~A*_AQv zJzV53k2U^Tc8o!WsRdUiM@o!+-ahcD(|q`ViQ>2Gu2t9$6K6{$AzWZtRTI}FdpnRt;5*du+G1UuSQ z`wlo-6ri9Gbc^ujg@#X{XPLrOkv)O7da3zS3dxL8*Q`Ct_5X1@Z^5Po0#&tpN7ySV zh*RYjGUT+*6ish?qPV*1O*u>2cx|;+M^4wlhgx!|@wsmOl<5GH?)E-cXr~!u_5~D@ zIi98yCaiq3|8v!~TjZ_~ftoA@q!AWX``uT2#jSLnn`cafqSdRR{rmSP z?L&Ri3dFy<<@e~%D!0%sWHhbga4NVGB*M21ji1UJq+zU~M6I2WKX-!5zKLUs;!6p$r3en9rD>NK-8!GDIpJ*R=9}8u^&H5K!%AENZQ6y&hg-!vs;f6H+p8VMx2|^| zuVB#>JnXuEvccTY`1sk8C(a{6puX2cA8WPK(8=wBSb&qP{DE$4_ugeyM&zxSZ#w1> z3a;s~XXp&Y4pMv4GNEETA<8Jm&^>atLj)XpF~`*ORQ#*B1(YF7`Sp{dgT#Uo#k|34O976GLB^K^M|)_6)&8!aPw>r7F^Z7vApPyw?1Y;h z7Y9O%{{62#dGbVt2ss&yN)|pbm7}Y#p}Z?DUPW_{)EoQ}aX0DcrC+QNg!yz_uXko; z{l&~I1YyD+g=xvw_UO9$Y|3n6Y`9JI1!_*>B-@qis(q&G1z8w&;#uNDy~sA2Jozf# zfnM`509Nx*9r z&)kaimlPytrit3Nk2Ij!1N=v&Hn$9T=^Hg5Y2H*4Q%EEu6TM2+#h4?CAHC7`jLCeBrWjc_JI5ELDux=Z^yGY)7_tfSK==AB;IMZL>@{L-1Uu(_v>qx^AO zJHNd$TFuu(QTeNIiin(d^xWU>iT2@2;iZJ&<(W|)K%_h|(4*9G zy;YMX2u54ZPaOQv&9h)eDQ()iJzriD|lzX0G^L{tF%ON zAnOKnM-L1{O@GROTn1$rm_V(vO;a&ChdnIVCrJq#HGy}UNX-ixLytf)#hJvRiosBQ zc3ED!vC$g~I6qeFbvT4UDk_Yyc;l^`w)O7+O}BFUBBQ8)SNILy2uUmzzL{AvAB97< zBVv%k3^XkaY{LYg1u(g3QwwNIi6t6kVXjZGUdzyd?%hwWe(+rdd4}-Nu~Ua0K8#Ii z*p^ll)m-$SfGAcs$0+6*dwt|C2f^5oyXB|}RlU?31IIcFEX~3A(@7J*d#>pDN%D#x>PL@AZxz#Qgc?4y6Y3ZJD)qP@4}|* zOgvkMLBW zb$oo7j;I7%TzR`K*=k>o#g&fI@uZ`>A^Hko}j5VN*zRLtJ{(mPab6w*MU1 zD!Vw@ugcma#c*S6iwUUWE)frrICF1=DsY|1lm;sAejH3r^g-wC+SoN)Q*vJ;@EPHU z>(|SwZM(}WIHY+x2^yxnUz2H*um{&PK_f>HBq4I92N*R&_GU>_;?8<(NlDRfaU zwiB}(Sq~jMG;29RyZx(t_*Z_?;@>$vze0EZ9q#kXcR1->qn7NYugH5pa_#itQ;&bM znA-j;VCeU+Q8IP&zRXM)Z(aX?Wf1>Epz%NRmafQyr2VUa*)(jXNw;6(i#g5G%l{4< zdOGyPvStHki-i3sWEeWo@1Hq9ldOm@Zg+2oP^b~`;`0;FIr-@MqFaJLp7+&dnv#_bfugmhyctQNXSMiE` zl%nynk(PThuzP#=_@ZU!3om(k3H_3UP7wC(9p>kLdx+PUE*P&o0^7!YUj>7 z1Zl^{+TB;*3#I~#B`3EWt)DD->9X@uhe+plV~VYF#vi6Q<50!c+%w3KOaeBh88i4B zL^E97xwAvGhi~QHsnkk^&fld^emF3<_*iiiypc%Um&3@}m1&Fa-d~{~I3}g9XR>8s zF>Y4-i)U4DVr!kx0TJ&aBRbYo;FRs$QRsYNgvgo!vr7Lq_4A$4_fE$IKQNGKx%LJJ zuRsC9*{QYWr^s}nfeG4B=9W?(A|g!K8h!#`y|3&iT54o?$~U;S)zmRD#3YFxgQt++>M z^RQuyRzGW1n=Oh+f{NBQta9P&5CC((x`8J6D}0Gs_x6kCai1{C>Qs^Dgr9HJz=1b> z^j$O+q3MY*Kt4yRXgyv`9m6F_wHs4@JM3^IBWxPE8UNM7kf7YT_t>Y;CJ?Zz;u5}L zWnpix3be1^5XmIh9W9?4XZgO!t$f}b(?XevpGukTYduPVx_d6rb68y5yv6tj=SHR2 zn-|otXIK`ay{KFghd`z76dEFe1HXZXVnvSewojkFbvzR^2>_liv{JX6laBz3 zD+l=|$khJ2`(RmJdbRq3P7Gc{{5W)IOk7+CF5RDh?z^|DwzPp-Z3EUsg6EDHud>dY z_0%DmJj`WbVPJJ=S&4f`ZXt8-eX6eqn+sL>T85B-kD;*ecDof|IVhWxMy-NwCmUdC z=efBRdv7y?La66o7?2v2@A!>-SAM6F6cfeol#ksqZ8q+RuuiaFwv1%Yv@ezf$@qNv zMxj`~?oe;NH4i_ueD~o)`_7$vh?$zOf6`2L@I_7iI7`DkF41GWXQ)b3=%YfpL+Q@z zp>tW-l%Ra7AN2S#VcoYigKiFy#LThOGbG9po%zs~qX>Ut)GFz+C(CQ}<-4%bzRS-+p;}r-EP~4NPeEUM$)9V%4~6BW!X``f(j3Hie4k*qf8z=%)ms?VWjlfHJ7=*qAMxoUmI-_z8j5M*c>L&L-Q zksMC^P_CoqpUMP<+mJnxLCkIi7D+)ZW-F zS~b|J^?aGDrR`omZ!D^_qMd^YMSFcl%uVYoX@B+6d*UNr57g+A|M2h3@z+-mQ5=dl zHD|NY_Rzk4V)PnkJDy6q6W9*ikZ`Q#sgC=;+#UC}^2H0G;o$j!50VY$ zp@zc~eeGCp}(E=$!hv@_qc27 z3zKK{n&ehS-Toet!l{!dKfWpCG0C~^xt0fp)`>+?#G=Dw?i+;S5A)*dNB2_mZ)KCe zdlKBY&{^z={&Hg2gxZ64ojMii#{3`!3Mr3Ad4S4n2eF0mhkF-7h5VE(?DL4xRIK38 z?@L92dgsm^kqyhoDVlJqaP|_4JFh-cBB?>1$GXpf9nv`Tam{)_5?-*wNnJ$^`|TGC zgo*p|#*LmUZV{PDreYf<@UeKJa?&z{{E zAhOUIfADF$(fu;MkJF1pUZyf=P&;LsVDq_iy>JeJ)KBiduw=chmsdTt>`Qb^7nB&V zC#><|^X$3sX}A6Q&5@wFcM5h*k3NjHIA)bL>zIHNN~_ z1d`pm{GKR#cxl*4%7$;#adC4I&~*?f;5oXRDBSfL=A$sjkTl;EQROea4|4fUW=0OY zu zU9txg)6Yfmx%}h_p#m_;#kzBduRRJd_O-nmA_Sp|`Ph?I7~T{;FPH`glNV#GYlwM) z+msr4C3PlSV9K;<(8xb zScHAF>8~L-c$r2Y_0=_+hR5}MM!QowEI-nF2KgD*fFAK?E|ASEa`+HQDBST#`ofSq z9}t>GQ};Mmjc24*n-Pg_s~-+!s?zNq!eVmh=K~BpQvW>=TJ*eCo*Q^hOI<&pi}Q)@ zckF#-H$}BBvu4i@?aXeqEV>uCgIdi4K}t(++`QTJ<;$PSlyx;4ThyJdhezESVVPNU zhF#9f!>4I5<ArFbU5 z;bfv36+Sz_4n|y>N1TZgt4)-#$eIHJhZ%a5BpQ4jwu<}ABPTC(JKt%bd%x_%3lK;` zCi?sJ4hmDBgbm9|*)*kdx82%Zj=a!>Kw7h`{E7RJloVxuA77N4>w<#M%Vs|k0>?i$seD~E+C3m5RWXGJvb5vo ztBX^cl{@}&9FyhLM_RQWAIoaH|0LTDa%O7M@ZG?I(eW#8E)`aMRFUg3*T@Fa*|PPB zNMy;5q;*2#;`BbW`_au`&wRy<*|$dzeA)>KMb{?nGG7UC z%*}{Yt9`iB{@7su^uy&2sq$U-LYr1C$(lbPC^^IzxQm0|FYstypFUgd76dZ`93BQHtvmI8rMXpl(&Hkt6Uew|5)<1X zy7Fzv9U zz;gMUjzAZc9*=xx-qQ5YKM)*j_m2RD$d&1hqjm%a7Nq}WZfaWl*~4hsX}$%UbC&T{k#K0P*k$3z>eXu2!B?&ZBa^8j-B;f5U=6q zvxYL2E8RZ33;Gc`TX0r37NHKky0|C17gF$r7B4HXsu5rFbCpxW7qC)5;sx_o0FwYl zo~s(YP{cw7Ul^X@c_G|Q47W?(0_~mxLl3VGBPW&_ulH#AQTB@RyHA-nROK@A>Kw-WM;3Btl#r^OP}xKz90AR zcYpu*`TX&@t}b!D&+|BsH5~6k(CAI`J@LP88ARkt;{oyAbT^}w=Pj6KyTmn$jX=Oh+(DL{9fPg^I3J`;~8Th ze`)jIlgju&oa_H-h`t1L$c_IBNGe>s7&Uva5qv?obZ@8Kj~h!7X+yFouW0C;4Mc zARtK4L3PzvHBLPh7(h#HCu};p!d$g)PfMrDHH0R^Nk9aE{qCD46s-;Zp1}=2Po*EZ zQ*!xmcEYkvCy9uj^A{X3|AkTTImMbA%T}6x&3gr^-ANC4&cxPBmdP?!X4=_o=x-@( zDLa2x7ab#yB+$@EN{=yxTPO3@#x}rnkPYzMm)yt4Z+1Gw`W{r8w&U`c?V@L?kE(=G zsj{*YQ>5QkO`ew&@8j|qYsqps@*0MzZ{-C={7aEX5ts5q9ITLMWxoHMXC^Y$uBE0% zoQK|TAU1uXA?mpQX*CtkFFGO|qO zSGA~)FNYMOq2;vbl!nNjJv!+ARqrfs6r(k=+RzwyAhYA@Ue$wlcD|QD9tDU#qc<>h zGXI|3o3PBI-Z(iaDPZriGR?XV{tnaF70EmS)$e}&yPo+b zOw!-8X@hKHcb)xLm)em?L~aAv-GB3Qk2QITX3oIdFeA}BP4|vSlbLUD@Go?Plf5;i zvy9-~>2U2=R0OMAXIdUA(GGWT%AVeeY_3+_U#TJ-kQn@=w)Ye`{2X@Q+3v^OayTW+ z$|c*<2^SA;``)=zI5>eEA1ZX)B4N?MUOZ-&2ch%SlNT>O_q@@{Hb3FoULW?H{l1~T z{tJ`yy3*6D1ET$GX?Iv~2l>xO;ygO;D-}6A%Hh@>=w@#E&G}1D6)&EnrRp`t4OM&g z?j0X&J_-~Twn&`&s=oJ^*X*fxq0l9j#^@XGj%sbaY+{*MW_HVK%cH=R48L2lMm@C2 zb>LZI%m()<=uaTaCA0Oxpr%prv5)oY$c=BkH^W$vkRa3hs9kr{Ax@bj1&}2{iVXQh z3wl})Fc!>%XL=w5HnU#-1C8{KI@R@m|BZ4T;SC6+LT-^@lR3=X0!(}Y+Fu!e&AT-5 z8UMA8M+SCyojbB~avEeLi=&zEz;;zy_uLVUdmp*$#c$t$fmUJ0$64lWCy|RbI8-4m zH&qj*x_{Cr9qY+_`S3^H7Ma=4Z9WUh*=?3|r(px;Me8TrX(Z zz?F-<=;_jp@?*zJZCzc97*{mbgM7Kxc~yvHTF{kDEp&}whmko-uE&`75dNATz)swc>Q?+cRZHa#2GN|Io7;hajrcA(agO;`tCZhPM#edRngLjxUc{*r2d@hn#V#er6`Scnm+>3 z4&>Be&vvv7tg>jv87s^QBk(p{~ zF?+{)11kp8C5f9xc$BPqPCIll*zW}&pM!CP4iA4y(+B_Y7tJC=*7^G_EF{-u!o^AdnbN$GsjPh5jx6;vl9BfC!lk6B{Z#jP?|5nqM9zOo?PDAbbm8z>! zLEnU5nAWpH&s^GH8My2%oVbA-cRT#lTl=lH3d@P*dam>4b3%-m;ATue4Fg_b+)@Kj4JA|K~;U(4c-3Z zoAR2PbMQ|1aP~1;)|?Og`e(!#1XB!)m}S-8-qo5Acu;1MpO=Sb3jXj6Q!_w1RPEz} ztSWzW?d?Aj-Rz4)ylndv3R{OgD5xx)`N1(zOj1x7GLOAOyJ-`P^a+3gJD2hOE72g$ zTQGtOcA(2kc@^!(jjcbXrD`eh#DcXkMw4M`9-dplK|$~58-Q>ztJIxAqjk$6vWMLLs@EZD$}fK0GcdIfBkZ7l=x>a7Idszxy}4RpIRCFxnx`}5KbYf zDkYKBt-E&3qfRJ$#C>Y|(B$5`44496D!rhkHT2U z9YQZ@tZBI^^SPe;I0=E7`KpZ!P;4iqd`tesS)Dl&)kI(TQ3^GB7;;QxQnP3HGUildIWUnnm+}+)GIevc% z&6X`_{W79+fP_9;reQZZ-)2o*=4LKo_wW^ap{n_1tA$Ws!_73BbP<}#`t#3H|MCYX z<L?gAC2ijZ@!NIh!*GtJ`i&6x zp~hWqxgj#e_s{L400HT@H2F<+0T_WFo2!zSDU2PzCMEUudffPpLCL#-)n;bef;#}d zHUIXtsNTS82D@Dm?+B_gNRIpOuy7`i@|t}W$-QOVeBM1{FfFst3nt#@W8MSHcg7Zo z{R|Xf!awKe##{BiFxOEfjWVjbUuY789}|p?;2P0vO~D`l)jXgd5ItG%Uswe%okFl1 zE7Ta))M;oyh^U{t3vUII(4f~Fzo_}j@>=3M7m@o{_2)m8mm^9(1k$nvtP?N@)OVTl z*sV%KS+8vV`e4m&^anGr94;>_OG-*ww_yYJ)t9;E_%=$9J}86F4q!a{4iwCbiW)C< z&iL1k*;W9u3{*3ja?w21}=FA-*Eso6`ba9p@Z-U z3Jw4B#~gnBFv!;|gkAiUCv0)@+2;UeKxJub`~cA$1ES1BcX88UN5hd)rnqazdw*fq z8GGdXgdPTph$BAJc^{YCO^^qYYt9Sp`#6gbH*^l0lKg(?2r}4y;0JFCRn-0cN7Gf< z0lolc9M$`n8tFehl#G6C1IG9WnpdoW>MjkVa{L~A!8kCMa!pqkE|1A)mLZ2~|88+z zU0rDL5)$z8@r9H@6g50Lx*D1qTKTm4ewBZ!^SEw+7QGMsYj{u88GlTgkamrHDm-{9 z1{~m%x}ybr+<$R3nAmOJJOId|f5Lzn8ZGpBN6)%01mTjfIr&Q(;J5LE*`D+9PcW4R;-HSz}q z-z*uDKpfkeUM$X-(^(pn%Vuqv_guC6@@{dQ*m#|c)e1;+I+V`|M^0KnnJ4ES~%6sRQKgt$0;fB-Lt%eV`ARFZ4F z@r22=kxYn;YB40~TxNh$3ujB4nsxpH z+zplsNGg~jK;GSAa6P6{;Bj)>Hjr56`_n2q?(#y7hprAL1RwudXiKShl5Daqr8MwIzwc2fh%kT03y;~f8`wsoYd`ZHJzMjA z35xn*;1}$ioS7=@_$Absm~PBDYyzI70^7j7$5-jnJ7{X)+DW+182Z`p9|LAc#vc&u zbRI$OV628JmpiOsWMYz0C0zkBWSf$#nsK&k-SQPH?tvda^HMZPWVu6aX6y^xI0Q!{ zzXw$B_j?$W>)=p?nc*BxQ35Q8^oXZYhJ) z{V$gwo+D@z#DtD0VOrC}_Iw__aO;!aCCK$_6PNzv59Zds4Tbhg9Z$-{2mNZO0-3Em z<}lnTy)9K{i^cw-rVVD3rLe-}&BRapIo=z#q^2;i?viKp|=j5N(0SH9PZ*_<0bTY}(*_XAi7 zMpP)iQhm0dY7@Xas;Efjym++rnSJYj`^Mb_WQy{dI*{#M+wXcv>c`hRKYGAA}Mcl20-@a+72id9V1`3GnA?)hu! z&EqGtm`_Xn^;i7eoW!Mf@8%xL$!QCm%;}t+>rc)#x};HhHRXT`T`CRRs?%ImPS^L` z^gZbqqND5TmRAtka)g(D-}bws+4HGc7E~F7Y<6?GRZ5>DVX~h59i2t9d(jV^=eN~L zw#28<1^|jFmnMgUxSGb<1yeR`@Sw zaOqe7{-;&E?&No(+Sd8~egEy(`+Dt(w8(7^{ml@1dT)Vr(<-mqwT1duY19umjWt_X zn95M%7BAV6ni2^_W$|)uQOM;4|K}Iy|9&vci+^SDQ?8<4FdMq=yLh#2O)QI7Wq!rr z&p$K&Sabmr{}f6dHoPdP_l2`O$>7iDUc4bR-+vngWt%^z7w|7-`|sazkz~+AJzxB4 z;dA+}c~oiLTit`oyF-29k4kP0*i`ZPSS{Q%^DEg!>?}BmVRhSStey`0D`IU0kQ*Yw zUSNISZc_gmv)D)BVC?QQGtT8j5=KzIqq@Tk3$ww{g%9ic@RLb)N*@&c=rFeVffA4h zC_S_(v&}ZQlf~!^9QZFotwPjJr%r_g2cv?uva&*vPC-NuofC+UxhN1Y`rJlOe~<+d zEHwOD;3uKCZ&G+exJ;dM0iDf{jc zm(<>~N-}2n6mf8aRK=qN+6>Y`cnu5#CP;7$Q+lpq@n(Z$-8h-1a&uSOL-QB|D}|^i zb=&%DM9_!VQ7z*wMz3fiLe&_^EYzr|Adl9^1wt^fNUWhKH;5age}P z$L!%J@KaD(i9&>6n1SAK7Pg3Mbj`3cN*+%Gff>9sRigJa+{f?mo*Hddir zX8KVqC=AGdYMGn@VxWN}l;D_kyN-n}I5RPKTMw8RL^k|o(>LwD#^}Jz-}0zRB;Ive z=3{gpE-C{kn~4c)A6~4kbYiUs87VPw=*6WMp=V*qfus1#P0VGa)Hy7C*kx*7a_3U-3CD?bc-bi!sm& z;KWp{8^Rulnd$tgK<9h`CLS0e(16V3@Z& z?%wU7`K-8|Mot552jn>(ppJOSQ}^xTj4yS+oa0PPH<7d|9({~=02DhliA`sLMQv49 zuf$p*bpJ!<+->0)F_LufoMfj0BYYf?9$YXXRtvts`15kPy&9AAHJ{YrfY8BTn45tY z)Q7zYP7pnI&={DYP45kF8S^2QNZ4A?N@rrg*;1;c_*p9z8bD6cQu73*1>SKJ0W~ywm;t#&9-3Fq ztRdxFf>q+ajcCo#&tMWb2FI6CCx^cjCQyRKFhSKmTtVgg9_O6X++Qe|FWE3kiI}Ut znhM!fH%6OXT?|>dXqgja#ldIG?+jG4)-W?P#B@j)%oN(cuzqK;L?=I){`0DDqPG|V zk9O1(s6%V~@z+sU06mXYK3nwwk{!k+K8*vIFay-phrZV12h>pL0$f}8wTsuXJ~1#g zJ%(lyq6}IA5U(WzmWGWu#cwte2!UkgP(9*_I9wWX0)+S%R#2GYTgt^R)EyKD# z?&Fiz!iA&T(jvJp%GAY1E@hNBZL=XQyW340Nbr=5~N z5iFTUXdu3fEX3*C+fLAxID?(T!h;gkZ$5Bk2ML`3rtN+Y50B)iH-S*WNsv~9Pu+NruI)YY* zTN)n@2mq`r82H!nnSgo;EJ}ETvOIWwtEG0VmjeBdKI^`hm%;|Tuc{4Tk*kXitM?wg z4#EZFRz7q{P3XZ#s<2&P58R2bCv_j--Ruk20GMwcr}2J-oadsJ7sne!BL7y;e`cV1 z<;v5#?~gGdocLaOs#z#DI(ny&<-5u9SI?fc;i|`*957bHijI-Y2Z_)PMnMI;cIu>1 zCb{ucGa_&)5?*u;1?f;9^EPCvtj`Zu4_sv)wxKMz9??Pw^^! z@pnDegiwy2m}1kcUnNvWh!W%cF8hamUV8_|>Rt{L?dRVHY)+~YIBT2`ZNiuMcsOI8 zYnzKBfaA3dBORRp4k7Iy86R|BHV#1RpPgm0K5q60r=2Z zsGUCilT5Z$*vXkAzwy)G^LUbZnud6Wad9si2iRf|9V8YTR0*;z9dv&zP*9@8U$6VG zG_)z92P3&Pc?8z8i-@TEJe)Xs%aNo|OxIT|eWwX|ESgl;lt3I}W*{!PaZ#^-)cuhZ zMSkhknb;QNXK`_-WzTP!a0x2y9A)!39Jl*ra179s6+)Vm&trE^=SLv4h^5W^hoVS1|8{aQw32>4elD@@LO}6w1F{e1BI2;ul<>6Q0;Q zWlgi1l*hilPx^9V3^@4D2RwWjXI0a{dNIuy$N09Hmu=LGDv>yHzc{;PBG>so_uC?) zUnvb|P$@~MX9@ejb}l63R&P_F5j6QrwkOnC@`gx+G~_ zSJkT#?%g8>&oJM>(uW|wNVnewiIl{5IUe&54<`|k;F}m7R7p6laFm9&h4uS2aGw-}1^|p4b%%7N`XI)mfQy!}! zdJhXfzhZue(C0lRgO7$Hw6=HhE(x?!Qn;9RPX|mEaMUlH7v6CXCyQ}&&N$_~7&)v+ z#AyOMbIInujC=OJLxzXo4n)wtUk@QdS@}x!)tk8NzYlLXA|Rkt&X-nvc}x4A{rj`h z%j#t=m>L>7BCYnFmK0TXOJnkequXj=Ag$38(vY43yfO(VK=3(L5Yd_SMyf2s#NQ^& zn=PEC-FP-;1CCLp4N6I>ok#lwU*8C8HZV5X5cF4mRc5L$1*`|6s;eD*nw_m8TJrJZ zsOW~f3|6d6r@WSafm#LvQa+hA7anOpRG$*vzeM&T783@oWx8o9>;fS^w)@H5Ij&~W zp(NDK9vq|bQ!|#gAQ5x{*Oo>M0mf%QODxjMAWr1Vv%BZ#5Cc}@huUg;<^63f|E$k# zPF8YENKT$$JQqjH;d)+9TH)f-h%Z*OpJA_0pfuTv8$`f=sY43OMrpGZaSgIf1U4Ht z{tA5n_V_&>|L(Ba&JmCk6%(VHR7+DNb))cBs(@0+k6LQk%G{k^_hD$Ac`L5$_A10j zOu7(9amCQe>gtYHFXT>~xZ(I2h7m0q>lpY-hMKdvHS4aX{x$F7Q=d+r&QKyivN02P zp5n5fVSM0w+qGomH0F8sI{|%$r%)~^R51ov%Ya{g)ep4vSSOgvP}0G~`DuDtgns5( z9TWpBxNaW!XtXG&pg2iepM-Pmu4`~eh`T`^wRJnr+27FEyCPf0F-+vt4Yc=8gS@P) z+6b%ZeT-ViVQ?O@3d@|U*YGE!&3m7z1z)_n=O{3UH@m(-?){X>y06v*ja4`o)RWp+ zf(5MFKyVlt871~`bEo1*gqxvuDHthcRI@2A==SYO+lWg7K`i6DJ;v?O{<}LUBaDOf zzQ`za5Va*G4?*1(cew@EI=HBP9gBwcE^%+FI%gf2Lhd4YDB(Z$+~$dl{q*Ijotev6V1Fo1svo9!QE&gMaQ?TtcVG7U(TzFu*$C&_p*e#uH`=NBF2O>eAJC7$ z9^&rDRpL?5@WZJ7;SgsNyy|X1Y)?xM4lP_RfQFyjr+^9@l>~@|n8mt;+y({ao8>EO zjr%b4N1PSHc<^bV&1`=yg``v?=i2K_mVvIO0Ho~87r2hc1uo;_FkErFLW zCmHnCTwsdu7&pu)E-H#&9}Fi%^sj*@lheN27`}ah1}|c^Ul00H!|wg(!+)sFv#{X* z0cZM*L;$;0+$#b0qYA1ZxeU0f0_6-w>)};$El7i8;~K9J z9~f>%0^Sh7w!t_zMI7+7uv;E}d=7i4QesV~B@QM~tnD>yiraB5_`r^Xhv5e%EG)cl zpJY-#IuyV|3UgD`ohfup*H~tF$k;e({h)IroeJpb{Ejb0hN_fPG~p_Gf!6aL`LfMzMuX@%RGyy)PG8nn z$Hd;m)$7)cv-{M8mI&I?&`bmTyqjR@hN}9ancRklK-gfM^~UbOJ>knOZ;xrG__x@F zWt4K}e%kK)+&~q@oNB{MVA43;TaJrQq1QNc=n!59VHpZe2~<(t=jWk7=x@kKR9ra) zM`?0xifc|jF4I-q-f5ew!C423&t!;ZXoe6ThQrLbNAb>EZUpi$EWjjrBnUDR6_rN`uL*>P5GyP7>SDMJa zR!mfaSOvt14+FM>{QM}M^I;c;F6dI?gXcKkGfZ;*6ZQ(NA-Wc0WB9paC>1@#ZT2;9 z3Po=tYANik!c+Kr=qH@uB&f#tIZkUH_B5)Q#+Q(z)`)7g+3>~~=9|bdes5+XcRj9j zz_t!s8I+M)g>(2;mM7E~FZ5^wI1Ok?(SORwlu6j=5EX2U0cITpOK^L#f0ER}@IUi# z1~nfrd$%F=1g6pK&zHZdPHTCuSD*7BaCD=dX!tUYgGc1Mc>#axnE4cGpjONtAP^c? zl?lWV(&``s3kR|KVQ_n-f$F()=U^d- z-|Br`l4D!OmA5O(w^D!nM#r%7Ff`J@^PNHQ!q)BO;el6+Tm<$*w1lq^you2`=R*7i zvslxjU$pBFVlv&<#wQc`8%;d^NNVAZT=%kHj4r%S_A|>%xBeUQ$ns1py;ODK`_)%B zwbAwd*4o|Fq#lyMaQ`c^cf4frTNWL&|KNPwhpkpqKM)P)7Aez!L**=gAj?JM5FhpT zcQ6~>ma)6USNY%J$KP1$A9VA7L>pgAyq5X>&E{_m7QxVM;g5+53}5C?k60$EThLwk zeIKqL=3R&_{XY;_ow64_oLr&o&3`c2|9TZ07qQMoi1!cHxp<3cZZ6t#U%mVv znDt&0n{uecfBTewfKc`0wQEBLpDjMUMa1*E$0C%v_~TzkmvNXJMz7?R)OT3;~w@4BFW)8<#;(&uRtT#g^DCw~Ej0JG7G=B+Ry;A&{RetS5i3?4r~GkC)>9wkXk*b`tPHPPn9wPMT&TvEbV21-!OYhu4H z)IwSEcqHe^wE7(|VA{0{5oANKUAqjZOx>9JJYI>{ew|>~VSX~T1geihpomawhyzB0 z8w->^up7mXGz_lA@0rGUl3-i7^6Oy)0mf7tz~i=#Lq~Sp!@!1dKEBTpub{fRI!1jS zkirUqQHdsSW+C?R)fOHGp_Mh8gH~1S3mY>7zW`?rOI1@78K1(-C7~O584@v>fLSs! z9yM*sMq`zgeg};QaU<+igV-CLs4lqECe!)V$*K)kfU99j!o~5U#UNPV>bq47E9km9 z3$MG++`gK=cOL?tj6GlT$na89Ekg3*v`OqNHV1K(4w?>~TZ2mXvH%ZWJsbijz4Q5~ zyr#cs-wy~3%$%BBo%c5I-T?Og__(-@erS3W6{xTAb@kOYe0t4&55vQc+4igOC)HwC z7ZeOaCImK27(RrDXjql;yRTTc$qx>aVO^?Jm6;2Nwls6Fsul&)|f}565E&4 zI50=-ac3R|0+&ImByb#q;4aHo(B+dzCa_ymxE}fY^MXbcxc;0tY3u0dbe(^})8hg3 zE_n<{)>=XFC?UD+D+bvN)gmQ*s%$jGp>tA~mv0+4#9SE7FOJfBVdAdM(BP)K1{aUUVcGZH=?sa{C@sMJP&Gs<3u&m=6mQJ`GKF z9QDl)6iAK~>U-u1;L!my8=!!O!JaV&2m*xlFB*6VQzo0Lvfs3ZRVMD~J_0DKfEd=8xlK1hpQhwum$HsCTsRUzj&Uos zT*d=h)_JSP;LdOibfCE*pt|U?;MYlKT;Wzg(0IHOU=7vSXoL;GLu4hBJu_S?A4xPf za1n<*CYS||1W`YPuk_QpGa?T>T8E;|atAU|)6$!Xf#(i&m!g}GhxO1`JUlZ&fn+qI zDJk3hVDz0*8-T&Qr_`mpb+Oy0{v{Lxp#*Tou`cO1o5SA#dYT$3FBrj)T?F-g`7d$I z%1ss~dk-nBW_ICS*1{FtTc5~k<4ppP)3Od_Z>;P3tv9AJFp|gD5asPw*zKW|X}>AR z#|JSUoMDfI$$Ay>0O7Kc$k<|&VV!Rf90)H}rd>zOuZ!7}XM&ZPFB>jxy3)ZaJS;2> zAORCm*FfwqZ&qzHd-qT>82#QJcy$i~0>(JdFb#p%0X;u}B7+lJoJ_KNSU7{E>ufaY zpyWmE?4ykTuXYE{ywd72srM3c@jBRJ{^)&>13oPv;iuOdqhNi^LxkL`c>G5jE~9*g z5Q5f^2vbgj?$zR#)qMjgqO^D3(q0bf>e5ZDL0MoqxW0YA*p#R61Js=8#J`?)1`Px5 zl$#OHn#9!%`p0ABBMSGUo(}6Crf!edFRSnx=ag1W7@0{QYE-+pnw15s@+$yB`?Z3X z3SC`*N?zPw4g)Q3Z(ZP2Y>_>ck$3bYi6+G6&{sI7%TRtz3>fLaSwyksI{3EnANE4%m1DsL#vxtjN#>>YCG=vl7_k=3asC-dzNYD1g?q34KwX?Da z^JpnOK>L7BDHHNqwi%wYp&d@_Z#!~Re@%(&y!mH&_+B9lB4D2LDaCEpXxs*S7N#c< z==g9M@jS**1uNv@XE?i!z*;sy%xU2<3iG-*n`T*9xfYB33!w7AyKm-53n{U4hf-$_ zxCnk1woZW%nX=7D0|NDk@9{Z6X~lt1oF&NU;!wcZfbT%nx#_;-ej5?!hSqhdL2r9O z6fwBbddDL}L1DrC9z&udXts<(@|LD|qz`PUMBvrd^FPsh3-APESthPTeYIplNlPn* zt~;}F;LU~~e;@NlOrgKt5&IWI64&^SLw2C(*WpA%@DdQ*rxd7= zcFEq13ERY48y?dHpXgv0vll+%ugBJ_iyCC85}>sURlhPxzD({)h>Ldau$BrC7O9XA zqwzk$i+5)%_XL1wz2lod{iAe4;nAAxiHZ88#=R!O$8)fY7)~Ru>*uUTV6C>Wc28i8 z0owD~Jclun&7$OP(saDnkfJm^dGn~>2~4@bOd%+o&{@`PZw1K-B2G`zmBG-8hgPmn z1^qyLMk?6G{4#y;%Rtj6A)fT6!&=z=d8AyQo0o?fZP}%W8)Hppl+HqCpZW;i{^xj$ihTU_oo+3S_xlb|$>8=LRtu|3@FuA_D zv8m}f&ru}9_fr=t%`0)ouz%`59WdtB{c*Om-3hx3$K34_1QH-JN=_Wjf=>lloBpZK z(h|bKpi%lDeb_tx5RRPj+!L4qpq~}lCd(6SEuy#8OX~7do?x$}x-;U{B}}x${Ig{p zyeOYBZ7+?GR@=`%JvkIaStByld>J-OE!|tLS98CKYxCaDnG-X z;Uf45ZDj8o;Ck4EeTS4Z$KuE*a0Q0(VVJ8@F5>zlh83Y--+9Vw;3ZFxk!eJo3%#=~fqz?JG zNP~eFUofLc;J|UMF^#r(zq8m_n>MxH7{tPherMr$qc?q@trmS7IDf;R)FIE}H*b9%DC_084>tQJ z1FHB01QI%vtu+d}!<|HOZ@`iq2BFafr9hT_Tsdj6M>+r#az&#{X5CeEx0Epv4WhgW zRZW+cy?-biX2W;$%d?FG$p8ktM}@824jHA?cGH~OyhG_#LWp{eOt;R!0B(CT3=BDv z2CV@~p~L^s!xx2`#BlWoj5FT*FoF>k)#nJV9<3G5Z$L|L?Zoi*>w~D8sB;A;q`Yn=)N>x_tU#o_lb zU&4AHHrZ9*%rS)gVk{6;ihXA5&Yc5JC$UVS`oXkb(CjPTa~lme*lU%KeF;Wk2i!^w z))BDxAvs+@A<@*}GW~YoZ@WOe7jA}fFqC1^A9%xq2W`1b3W>E}>HH9{)(sp6Ck>s~ zHAn$aVF8}<0NRNY^6@$RWw-_5i(}w0JG(LF%i-M|RCG!;Ol{oB!R0qZAf+x9Y5D-W zABsMd7V@)lr<_G8zIcD=y)oM{12A~&fO5esDrtEtbv>L!b(UE1=8=Gc1!A=$v@Mvf8 zuyg=tpjiMzY|)LdO3KWf@Z2AMfQw7Ho1&X$(^G|MFMam}(2`MA&l&G;(us~?;!(-T zgCM`%xA+$%6UA*IA9OG~az+KTG$kK~hnPS)I)49>8rf{B%!oz^mCPI}=9xFtw}4Z% zE7QbUp4C9LF*6OM!5=?eFtbH^dU%|cmTvz|WD3;-RKR3V8OfgG5fzKX^5+0=%~d*1rU4qymc&Lc(WLe(6#A*I-SUc|yyirN7AdhL%yYIrXE z3URoJMpWB~BKrxeaS|p@_zPN~N#WOI{(1-c0JUNvv1CwMYQAS{ZK;d{*=yk?k6F z05WirVUv4L97N!o%NdlY81cx@&!Utg>rt6*g*Lov1VWPFo&Z?L4M+aY^-{iKD9v#` z5N~r-)~FlcWoVT$kk7M|yffEl%z0rG^bpbsc>3C;!SYHO%m)n(7%N=MYQ%vdzUSb< z9ysN8dT|w~z;lJu^r@S<*xL<#$=Qm#C47 zO&~v)ZEb+{Bk*U;O}5${q;lv;Mh@ISRe-WKI0mC_fJyTtLIRDsZ|=@x&OcE{)wH8O zMNWu}yfq|^>{(=%ks*6{KlX0re{ zm2!#Rk!4k+CHo9oAL0lISHDe8!V*rkhQWb23lMi^BQ*692ip1bVs_)q#ilNO3f1la z%LgHzaTbmmgcmqoiIB3|LbrK5=hNmcilE-%8NgBG-?97ASUV^SwSmjj8157_B~hnc zxiHYZ4eFhi^U^nrW_hU~rm*m!(&9I&1s7?Wu5u$~;h8P39r@i&wU+oYDC`P;l-2$@}hv>)XXI*;ZW2^nisB>P@G;12< zZBf_-H^2hxy!0IY*Z_o-!tT}2JDi;IzChWWzQ;z-k zd~nvnXS@CTcm zj*-zQRV7qb&&FmPewk=#Ig>)Yr@`YQu1hR0aYT|i~q2K80A z&82m_*n@P4+7l6smjy!jWsJaZp;GP7;60LlUw=Dm1gJpoOU(7pJ8PlZK>RZ{Jt(DN z9~q)Ib}tBCT`;TlC;`@6pvM^2Yzh;glYp0j3xWt3A1{P<4^)Uw<2BiMcGzQ+`vCZz zU^)f!Ea4jb6g2AE+Jtk4sthA0h=PWc*PCBNDVLFR6-{;iiVa$AiW+3?3~QI@LxYk$ zI9LFT4fq<38iKxoWj%rR23(P>f)iiq9RqoP`UuP%6Egnt0xC=fuE*zMFtR249N4sk z-dQIV!R(4FeHq3~Z?QZm8{15=TZx%Is6Xb3;4e zV#kjtr12AQkBFW2HOzmi?H|HxCs4K> z-Unz}B=+$0XGDANLz)z79CBq<$XRX`_)kT{gc z#>yu%CQ<^>fk+IRUMV!=crvxwj(=IJ+x$K7m=W+>3=0C!m6w((P)=EE?1Ed?*}a5? zQrZ&`J3CnB!Qt)C#R3Zqr6oSAmm~4&F$U`5#<0PD49_v40UMsc=j#tYE>WpDaPVM! zG^{}UBjQf|f|>*kZ3SBWqI+^r5?*yIK$9He5rCgbK`sobjBAg?hf6p z2Q@^nvDSGU0Rw z++yOQv27$hiigPoKiPePXF;t*Y1()F(M0T#vpP6I(tbrJXAy0+5 zK_A{D_f(ad25g;H=-hC@R2cG>oMez_h~f%fG=Rp|b@ly~=HoX@f9CW3%Pv$Y!_VE8 zXk**ht8Uxy6_;6|rSY}1OXxXOXHyaIpyH=yVPfxJ4uv_|@7I$p8oLrB>-2Wmd8*ZT zrcdO%W|5$)nlZ$C53ddfoBvQv;?-0Yu)+7oCSJz1Kb18AF(Yq6_qp+aP{|Z9aE0+L z0H8Wk}u*={K8J@n#h=FUTE2+UFc8zhP0W*mW~rdg#u7e3gzqj{;(>$c4$v6Zbx zXPlw&ON>i>^{Srt39TR2HtDXsow9oq5@Q^Rtq?-(-@q-bC+BNZQyz9o7_l&Kh=D^h zdcK6b5@P`^jAG`;#OBA+B4&+$e6QrVrugO#fAP3EvcMJ!$m6c(@?}v2^bVG*yr1;O zG^I#er++f9+xXy-!-GO<3}>b>y^e2e{qjX6th)n8p9werxkpbjt((+aRVL0s@>5+ zhBE=Y-2yhq*XgXn*|Mw)hh893;TTFlP(&o%aEqhKp{P5SKN~hvqC{Gz!w>}t>)lEuH2J54i zk3&?nIqf;~>#$yW%Cn(ME))dfjVO0rMrPlIva4)MFTiPuQe6%AMm!^H4Z(eRUj`=d zENS60&tv+NgU;(?6gtZ2&Yp6d*g44|Wx6$THk@ZUpeWPLYD|0iQd|f1HWXFBLvX^u zX})*W`7q(FFp%lXF*B`-l-dv=uLye>5Rr>2uo9lRTBmfJ6p7ge%pg6X?##ev(=0+A zs)xT6ZSwN?sHlz)`y()oM7FLDVFYaFW@qom2;@7wXt9b+Z>oo{1Z4o!A%eyizDJRS z@(wm*pC&Km+9nMhqyiS9=gI6i{WPO4AE2$`ByC^2B2>i18u~`l`sL5+K3Lr{;{v5U z;Ua<@%S*)nnOW6RR(>_`&XDZI$L=2L78q)&TSy#^m6J^wX@W7C31jN~_I!hKaB=^s!Z{z!*HNO|!q#U%IF5QZenqBFuVd z!?R$1+_AEu@1mwAXDn|pa|@;y&jz=GaaZ8x`H|iU;PsFwee~XhFHws5vL=>?`lc^o zM?4X}QidX;mhP?k0Hp=PdFX}jdX#pm!a-srio0X)cFjY45nhJDm~K#$YbJ-36Rjck z-YMpa(x?+6M8V?EF#dEbxQ0@>Hy2^|b>C0^I#%e#cGv)fnVv>^ekqP|VF!fm;)6Cr zZb$sA#?N8HQM2iCNvT2AC<+P~sB0bIO;t;shXLpTpTB1)D67#fVaqNY{+IA?M00K4 zQQJ^Uzm&e}rX~ajLiS=J&`2L-d-lv3?%Erps&xlnmwl_x-*UO>IiizOOwXe9b6n!K zwju?tSFv+xFJ25|Nx7!&Z$A^c=8QNbHb7$lRl#8s9&peWL86_IaS&+^-fz8iV00{i z@$vB<$Rol}c~vwwPXqk|GT1lmj8>^5SMG_Ud`&ZuCET8~YU$xZJMIC+0zMfC(0#7C zVXZy}U7)S8vG-A8D=_FJxCrN0Lsq^`2*++UteDW(x(j}J|*$Q3^OF1w-hdu z6FY&=o_ISd6hPl=GBjYapF57(&BbnLgA!y96k`0CEc^;a?GD8$peNBYFeLOjf`r}D zb)?1d8O_^(JZBigVW!vgYHYGX{~^|?&kiAZaWrS%-d)pMHdijdB%`N>Y&Q8Jr7B@M z2Oa89jlWB9yL=x%jvrhBHl|Sp%GC89a>Bb?k_!HI-oK-@7(gWt{EH_Y;D!=DsmkBf zV|`bOj+Nt+*Wa$T&(mY-KB&-?Sk=cc)5r4d-!CPO*EFi29O9FX+&;tjr904G$#1c7 zoxyC*6?y`<$=sGHlZOq0o^03qpIx(fbBdzjqO|G3;A!FPYp5rMB*VnXy^l#~S z?i2m}J&@Bvg){qh*W55<`qHn*t2=U^Ql~M1NB}ed&8sdOLlo#Yp*=-NY5#&)m}=1h z)C~DK&p_v|b_b>IoU`sN9UYyY&9QPWo9XCktIEHa|K9fV6A? ztUt~;nSMo(&36^IGen(}ZGFM6vP`w%hZz%M=y)T}!6$uGgn~)`4 zHx3G`N1?$x&`4S#t|o}1RnEi&Bz1f;AiRL?G3H_e$BW*TBw)nZ&NCW~HY9%SZtK7` zm(-QRI}(n_kr#i-t%+g6w*2qpep%>KdHm8=wS&fYHs$=^tth@tv%qS*bLUQCFJoa( zz(^Ge>BbJh)t351WK46Mv&&mj9OI02{3Gvl(`HA53j0wn|9rSxWcc;6-odQYR11t> zHSOGs#UEh}*cUXT`$m5If#buDL4N%ZMXWqv{NdwA0`7G0?8iIcb20$y)w8hY1wMm< z(f5)bmM6&O%!uZF^3PToifT?u;1mCy+GSz50hpLqjO`66vjyOB^Y(4z>X0xFJ7uT-hQu*mx8ysYQnhEF<92qc8AFZfQ4u+h`v-0!MM>a5xJlebFu|2)+jBf=PxfFtICFz9G7-BR6axN-WS}`XOY5PO9iCndvQO zFwHtDCf1_QC4cPK_ed}`j*DZXB$W&52&`&o%fSUVPz4*ig)<7p9sa;ZD=a9e?#q{K z&^6F3FSnBJw~-Nc_SE^r0N)12r$PZ_neS-v8l1C(elk7m>PniN-zMHCUSj$=Z$1jlgAleWN* z8Ob@lY#BE~%jwXN!W5f!l303MrB2ZhvM8aeh=?>Hq#6e{#0mHTsOs=EF!q%E@QBi* z{^IhD^q$sUIL#4fj_6%E&z@ZcMljPy6+3UUkz}Qo3K->(;n%afsKY9jCt;TJAV||O z9@&XQOmyrjhoSTG|FDMz&&W`W#S$ZZVvvm|W%4?t2?V~7cn z;YRP$PGN0HN>$k~?zD=6cIYu0IeRdtcCNqEva<_`iLtSX%_M9k`o3nGt_;RR zBr3Nrr^ZlGui^+(P-zC7La^Av`sBR`j)T3%Fn<`uxpvci4?aP`LQwZyd1LTDJquqP zq_gNh6%|3W#xO{7YYo7IZ5*mmERO=KB2Y`y(AQ#!LRdr0_oL$Ddq83#eMOCGGs--C zNu~vaYQE|r1E4y4?TWdAv}qx0jY$|%6!?wYAzKlsP`nJZ$m^gQX(%Y5vkXHqiNh<< z6R#Xv;VBTU&>PXV4qyiasi}oH<^X|%+7w5B1bpmKs3213@F1hhkgt|wPc#~QfE0~a zQ@`gV_i{Opd0-fe`Aku9F~CvkoDVl&%V8abAFT0S6#FnD@}x3V*58Q)WQ1WE-m}T0 z4U)GQh#`%PU~@GU5AC(hUG{wc)^oihU{M2-zK2j1$ds?9>IZ|BggQk4v^%iXX>3@4 z+KfXJwU0wkm>%#jdf#wJo~;L)>J2k`bCO-36Kogym4wOe&6Tq|+5HFhsKLk-C~d$1*E5ujqbh z%Yp#-xT|x%?6%!Y7xakcw9uql$4Xq>DTB;4Fg#}BDD*NE71;&4t9rUMEqHQ~s>Ujd zGaKJE4=YzqElfSHtABUk!@2+f^8Nle;~)S8U^X*V{aZlKye;#O zal!vcl700~Z^Z(wxb+`0nQmi0x%0P0z~Z#%57)MJDY^RnixlBMLmlx&KJ96j1K->+v3Ik@q#e_zo$c2{T`(K=L*IDf0~?)vL;5-Bi|3?qOQrkL8UdHKfeIO zfq<6i(E4g{NZ?;J#8F1MW2YZZ^eDm00W>bu*BAILdMa>K5ztWxPnfhXicsK#)rP4* zQj`Gvf!mn}U#OtcqB9H1P}hg_(7BoR?4^!paGJG1riE$$0Q3N3iZT@MmBBH~zf|lU z@qbhXpx`_p+Y#>?L-=L^j~+k+4!M3y!WhKz=AbD*E}L)JfNG@%BT(@0AtViaMEyP2 z*OZg-pGk9{jyMO#)kj6qUq?NAc5?4hFgDEJu7f{c)yZrt&y^%{8Y6_l)%rk!F&)4O z0a9}uV6JNBeg3pFZzk7I40qp!{Q*e^Ucq>HU5PC4S^a>8KNWw(^1OYVZX+C|$ zdv_&OMO>#7G#2ILEwnNeS77Mxi;4irS&UO(Be+pyWG&lIjIdy{9-7d;YQqNQ`Wtml ziYb)6)7mQRfjIsW(}=DF5@v!$p+>78ur&%MiXP?)d1!|vppa#7H*PYWgr6iBe93*_ z$+S|brZhBDBQ=%V-<*)XLXI@l?I3OMV*zGI2yQ!0Y3JF2*Nip}=W$N5pnw1!9UW89~?^0k7+UoI+T*f!^z2s%ymjS##J~j$i0Ww zSk*&T5(g5@q0y@MIidrDAQ}4)COuy=#ftgs)B&(~>^SC}jTq;?8kAVL>#;F0!DU_K zkVfS8i8&V3;$z$7xF6p&__?sRV|~)xiq|jcm!aFAm|pyD2hKz$9Ph}IZ24& zW1*YQkXtLSq2U6O5BaRZeki+yth(%7exb)#oWAdY#vGcW2M;#2pC)+C_wV`@18^J> zG^%AML7YHLzW%|b3x2mSRezg z6y3uDxg1*qs;-f99(WPSolsH=3<*gLc7F&kEu?H83toEivuET{SmPg*_j2H3u7!lA z!`FQLNkPG}*SftOagr}C+d84RgpP6qYX>i)X%=i<9Wl89pVwQM+C>3DsTF3(nDG5s z$~>6ezP={-qMBNtBFD3@Hc0Mn>A#E&R*<2$_`y?j#Dk6aaKntS@I8#opD>|S-|p|? z^AAJDT$WzfZy|)!z`Ipif$W#srfIRiqF}@p^4Ui8*s%gwi#Ua6;eM3%cprSNi%tzM z5zD4++qQMwLDXbEILqpN)HPDF_d(_MK0Um~=vov-s^~H40aksy-8Od{y9#s5PWi~I zVc-k4FzjG8|66yJG!j>Vn~{0koI(-(Sxf~Wzl1$sy|F9!a)@m)u$G;Iw0sY-5{a(Q zS-F7;1Ltjipw|yYRQ||+ImwO2nOJ!+o*cnbLhYmh5hcKi zcP-Z2Vc-eJl-=e9)-{Z}SK4`1aUb;;-}IJTx{sX@9^v>AHNN~v zf=aQV%dn3$8Q{8(tVyeXek=<`E?h5wCLX*%M{)??UFKNx)RO_Rd?~3$N`q9@Ii-X`5Zj&i?^Z$QAs^1PJ#(> z-yc2NPWzb8po&!A891{Lg;v8iFgw`-?Ic+t$aAeX#-rn8V;gN|=H*o=`+;_+m1UtG zQE^gRs`M^&{756sw|jM#a>Wc^5#cnlb1yPjCNBraKE&=X3huLEDWZ({Z%fK*RKC?Wr6GX8!c1?yCeN-&-?rFZf&*a9PY@yQ8$KdruPk6Iv2rmgkG6E zAU*^`=*P*v$-QaEh(3>ez`^h{pP{;`cE`psCEAZYYYg5X$P2!TWgimynlMzO-{)HQ z;8ogx%*!fq{1s-~2{a4DualCj9QOl+yk6`ZcB=6Y=Nf3vRi5pJjb*`@i?s_#1gRbE7iW$wc?#$R^i&=mWx64vPMLKFS}N9^@8~9||HUrAi9Tm+m z;CBEn!SWzHeC~VXeZWJ=3WM((kcplgtoH8Qe+UJEKx+OUFqv^33u%F>6*?tEqew_e zF>qg(2dJF!>tf$x&bE?ROi_6v#GkR| z1kyMq&^Nh1Pe}L$PC>Rg^>p3Xs~?r*6sDTno9ThKBM*m00`F-GUv4wWHpcO&QCA~f za1^jou$Wsxf?6@iBg)BMolXVQK5yR5zTL!RMH%gXwpnGMJY+8LF0Gc&#Qj+ zUf=%hz4rHg$MGG%2up63;5f}{wFQ-yu|1ZD;Rf^tULY3{HH zc`&iu@9VQxS6sGcO(Py8(Q#bIzJerlxfk6C4V<%m^|#6#d;-N=^9&LeB6S|a@IB(< zyJ5pl5q1$_VWdC9uzB*ldUY@}6duXBuj9zqFb`yZxx?~*I=Md zf8D&P%1(xd?D+71E_Iw%QchP_(>y`u0PX3LXiCoN>G}7H&p&e%|6Wb`pS;C`Y3{O!{h%_)wCA6C->Hh0;jdp=%Tn~Q zFHG?qr{t;A7eh#w9{n8R;@{C-2ANtn>G*Rf^Z1@@69B;$u5Y*e{Xx7J8iuWtJ+nQy9s7|a8?B;o{glQof_6VsCoOR$Ir^xHKG929yGbl`< zk*c+2g0dCEsKHg2uxeqL6d-XbFCyPG^&(MP*7&h18W}~tBA#_*lojNwU>8Cv1QSu> zK>$r8*c-;i>rm)IiSA&Cu_CzcNE1{MD7B0TbwndBWA-ezlPIPMY6b$1>)jcOXwtxN z3mEU7XWe$7Gh%MT5l|bC?cK8nWz}h5bUR%$0YQ=!ba1!z2{_68J3aL~TgXu<6Jqa1!;$yiAfnk`$}Q{0LymI>i?1mG)# z!1qEh4kHVdB&bgyh@m@2%rFcF?ck*qjlaZu+#?z$9!&QpM9lf8gB5v%5k#buhgjvyef( zh=CVC`*mw!-vJ9Vm{c?t6v*d}E`UEPJP3)~|%F_y)4M0j)vKb@_qsd2@z1dLli6o@jh=Gz0gG@;b?jqZeWHFu;s zK~1FsnsClC8(jPjwk*Lh#Lf?CaOtaY7>4BzI>Mh%b80C}O=7JfPl zGGhV~2Q-CqRuPp8Wi6F}yH3>&UVLqDkQjwpLR`w=jaUte zOmxpy37L5n;RlJ^pxQ!(il)TCJ>VR9Xm;Qn^62hQejyE820NE1{Nj1#3 zjxY$SeuKLR01R$_+(W?V^NlWnn-~yDnlKwT7Ji!8Z9B(lg>IJpvzhU(Gu2fazZ5VI( zqKg*~tE)QJ-{~`D#sS39dqV?Pw(|0d9QQ)~r?-cY%Mk}qbbtlsX$jsyPv-7s zS+J3RgV&d!9xp$?3_SXhLIFnr(Rj4n(r2wy9r}vFN@a}la&$u<&O6VE+c))I#(P_XmgG({i7zH|5#;qToFX3ia{b9Sgu zpaVtZ^5@mwY+OGtQb~(4AJj@YRgPoA`{p~G@_1}Zec_RcR-3jI3EvB#{FlSY>m0>{ zQi4DpKrc`$7R)Ca#2~(*VQeEQgq5dboh|9$CHH z)!Y?_9xV(7P}Tm?+XXnPo6Hp^IX}+8qGZ+2=i)QsgaR1q(Ye!((+ zF;d-jRL{A0PnqXEG*`j%|E=q&ha_i#1sKJKvxV8;pOx@Y+gDN|7y`S1-z8=dPIp~B z5ev*JFj||bkbv$5vpF_%C42)rJfR_f*klEMjNq5ReoZ99nEhINfkn8ZrF*e z((|;nOMQ`opvWrgk18uGn?zCP-CSQh9RV#ECW#?A5qY2ev!v zn>SRhm3Ds9At_`ALwGz!_TvY?92&|m0$c(Y*%X1#yOo$f(jh9e;kz>0=Zc7l5fAc; zUBRBd#~q;R#b#Bl7Q_9NpZof4@anYsaWp2sH}#_$jSa(mjD*qBk4!6|+Lr{LPE_WF8Ikl8mrB*IaMZDyjeFJH;;JC;^oEpQWj zbweNSu+aj05%zo}$8=IZZinDJ`QU!2GyIycxB)?PGQlL_Bj61}#%;}Bs&n9Es+P}+ zNN*J(J)ErJzuINkb(@|L4q^6A33`o<_;St_neRhXUvfQjeqBAW4Cs6AACU20eCU}f z><^mLW*_0>#sC;BPMiT?9>DVE^r{4%mbkR0!>N*Z0a~~$GOk$xhcMZgt=U^vq!xW@ zzZZV)wkwTwF71#_fc0I7l(g%$=F^Q4AlG5r!}|_B3Tq_GeU0)_$gyhP2%&`NCMMZE z%)OFH=z)FH+O|XXdl>*48+fY%RMF<~1KVB?L^5PwfXbS|C}QHA6M0op@<5O?^f?X~ z48#6j+)H!edle2p?F@rDfvtjn+|A>by*MXH|3`b;Pc-+K#?D@nL3> z+TnU*X0!NX-bS+*&z_O?lI!VBYT4dBs~CU%6?^|V0&1#dv)+Ic-~>p6K*|CClwT_a zT2I5Aoq4RekK2ni#l>zh>#cG|{$SeMhsDFj>LbYHSP~$j_u`{?>D$wqA^bo+N()bw zI_z6?sr8O=2zr9f$UCc+_@UTAB7_;2m-3UWKOAVyleF(h*KW=F?xWTAO^vPh$k7<} zRBrQI?OImfi+Iz8P7Q!(VtlvcAw{s!NK!jL{@vl4kN_9jmh zbGu(pq_}m)%}+cz`vfAk@cEy{!DssT8bmOdRmxUbi6WJ-vuX+*To_y5%se4nP$Ay12p%C8tU)Sifqd#IS09thq*c##p~b4B6EwWQM?>nUQj`r^yJN{zmOw@` zm~?0N9&Jznr)#g&ZK0D_Y86L=#BGoZ^(=J8R6FEQ@m(lOM6R5yGQxV%WJOVaA!z}r zKC2Rs1eS=xdnds$>BeTo16b6TFMn#xOiLO`P06X{l8q>4RW3)3uQn`%lRkA_b@qo3 zu!&Of)xfnvCDJm``Gf0s{rYPxo0c*+bW=bodu#Ruxpt?S5UNz7&0LeLsd6m=Wb7j@ zsStzU2E|N>^d5cUf(^*atL*B!ef1)a&J1`NRLzO};d~lAi6-GT0g86Ap82I@!1~%< z60K?m^N$E8f$fj%6J}kgm0;901V{mE0+kG6ct(7zV}_okK1N8AGqWq>t=UA0rK_s0 zt}gg#=pkWt@aMOu=XVp22pE0U2*+VlLSbeF90nyMwjyqtV7voL7NAw_9cSlA-$0JT z21lXzE{Hl;_-2rihr60fkbn>l76euDl__Yx3?b@(1N!ix5EmE9G1hhO#G8k_=aK>L zXyPHaSFAKD4LYi?udk!C8Vh9II!m5quFz_1<15E#>}2qUAWGDb14|u64lEInB_Sm@ zUeFN-zBdx#qv7Yw-*jzcC%nNj*TH1emdS6&@@6fD(-XYBHgDX>;;0A;0;U)Zi~TtH zvaS}KD)xQ!#kP-E6m1I!2t(CNJc#uI;q2m!!wvIywuiZhFgpGND-|ovM75E9RYw~z zok`ary$sW6K-3h7-GT-kl6(|?8#X-IGV}(#1DH(K(X{RnyHJx^*us918VPKvdvd`1 z3RIRESL;S31b`0IgYDf-aN*%*3v5_hy`>+LZ zb&`f^w)H*8UW&@fVl#==ZB&R;=jSV`b$0iy(A$St7?1lNAE5RQFcx5@fb=2MsgtYh zc@(whuQTQh+64|teCHK~ea~0WCbq%y3THrdjGDf_8z_fFR~TXI0m@o0^tLA^g+T_{Jl z)}`J&Ev95l6ofL+%+PyB9JWKW0BwPHYZGR6AmzZ#sj`3Gv8`q|&U`}JCstqx*6i%; zY7(lD4QXf)n|_Ahd=p5i51Jb=0Pf&&Va0JibPnIb`NuK#rg#`&8**cy?EZu_33L}& zLBK2@j`z_0wcMac8F{QGKvZlq8-ddI>D1G?Tnz0y(b{UXs1;Uh|6vO-@3bYI_wXm# zy6oTcv!<*RAGh$V^x|U=h@7$n;3M7VDf36Za8UTMOn;!t?u=-{eacY!@yo0>hzBeA z7wwt;D_RuL-A>P`F+iEZ@nb;Do8V&yw z(dysA*7^SMF6g$iVW$=y1K+$`#ZzGVH2LbE-_HLMm_PLi|1pPc8jY7F!*cSFk2|Bx zz;xdSn6-;6E@%n4`n>eWE(u#<_cVkdQGkGEq7X3NOs3jh+ZU5rlj4hQg<+dZRGlJ< z%Y2E4EIPSCBVHvivD5Z?jxh4g4Mva!H;DTps#3b=Um=&N4angDTq-H(k#H~SyQRzK zE`j?SN@?uhOTYhq``~6+gE%F z#ouG(H}BAwLlFwE0I|B_hj3?z5;df(?6zPS3Uu~9lx--S!xc-@N3^$3&M2XG79`zt zDEnTdoS=>z(bOD5jST>Dj&vd@1Nc~&>G(KeVO2f<69sCvPN_$U5$It%F0m@&@KaZ~ z1(3#8ii6|)4OgbVz~wB;rU7;nyMJfwK)ykIYCjyDUo<4i+4(GFavlei^Ek2KPSz>xhYM8hg=V$j(SUcZU z>lsAp)oyB(h)nX}!R5Xnz@vDeagwn~{eD7)dxG3`%n{CqPfu`mEQjHD)J-=0eJJ3x^X!ikIymBQ8+ zjtxlpLB|6-9PUh=B)=i{U6xIfEBqbS$h%us0WxmJ=ymXcu6lc8j%Yzg4LX}rpvPiG z^15Wg+XeO9Qn;bXh z@tDWRI0~lWom;n7u(HN9!Jz?(=UQN9@XatBMLG#15GD+=fE8@tXdGHgwBwn`SPfa`)w!O$c*14I9U}zC_8&L z#S^&#+{ZjURo=imB-igt!%Tjg)`570MC^)gNdMJ8K=(*&0I&pbVIf6dh+{h#7TYHD z$3b&+;5!1Ej(r#pI}6~*gwB;rB)KP2J$3b%?4vw>z{>Y#e}j0HW~e#|P{l69sA>rq zpQs&tH9@^y>%!&F_DIV2*z zOLC>awqJ7SaxMTBBk$1+o6&iI`0nBfK?gk$3eu#COhmg=1)3nMQSBsYsp1-txq4p- z!2lyv$xd-$;fqM4DL{C?96EZ7(Shk}+wWf#@i38zLmuLSN7bQiXf0M>a>d3r_6~)6 z&OJWdrXvH(9L9aYwS^}Mnsd>Us}aJE9ATmJIjrokg@_k(@D05v{G#X` z#0kzY7DI6FG8E|uFY2*X*!Skn7ZB>uE9OUgL{c&f;|PeJ7SoNu$&?Ex2=QUi8_${H zWXz%+*RjD0;1&}HlMNTfd_!RIQ zWGvOfyqh*X-x?}(c&%b3sH>85zv0JJgAbRv7dTWuUL-Tp!NGJ#&=8b!2y%&dMeCgQ zt-UM4=mwGLX(m9?YYn0yrm%rLU=|b&L>BC=jtuED@8zm3 z2gae>q4B2zK{R+oW=+h{!88FZ;w-*h)&6N{p+mU_v@0hkO#5Y*BOuL;q2LgtJ}T%Jj`IPcBA8?b`73wZ9@yt6?XCo zA&0hltZyW^*CrwB%)cFk*7KaaEC)OID4@R{G&Brr&Q}eMSn7i+>lI<)q74Y>U*wgn z#MzGZQU1Sf-LmBnR|P)Nr_|l8d&QK_86b*wK0yrcacsx%ug5^_5Qf2Xj$N|Xg?V5A zyH#!J#wcyus!M1~99j5FUxRvrUUrq+i0+Auh}3vzp)a4D1z=yMjy6%sjfyGJN-3>z)?juw| z2PEF@Fr;;Ztcr+?Xj|<8nRpQOVnIKM9H|*xXsMlZHxY^po>fsX2r@}y{EpR&)X%h@ zeE0N1r>h9E50*{KigjaqO6-8DqV^`bs+jBSw!5KwtI5^7@IYz|YXOpZ+z0*yADiE1I{68=S%cFR$ra zyQq3$R(-dq>He9gVY`soi77_43Rs~|$aDO8jvgOl57CFo(}dnSE!g!2+*9N78BW>h zXe4Z`GJbC`#-t7%@6Mr1WN?oDM4vx+&E-Siz^@s8bac&jE7@$?#MQdrX%TSo-08Zr z^`Y8n+tG{m)*1a>XdidJL>{mzhLT3zRmX{0x?g@9h^qqz=(vTHf!Y#H^GQrhgjl4e zX~C*|X^#gvDlYQu@ZzdFyirX{t1~re_UejzgP6E^76$^$07pe6qo1*n5ef1xTnQ@c z<$FCe6MTX~X2z`r@rYF!N3VKzM8qIFN0`Z{XBN#ljXiHM3nRBq*0zN8oyV_di2+(v z8)?ZqZUWSm)203$9-?}wlcdoA&lTeZ<=71gSCbLL3O4y(zrLOwU^p_@!2`~C)YVHh z(QgjX7m$_f5Zj%i6-SecK#zEv@3#~LI>I%HO1ppmX%DBUurSM$D>|MR@_jT?{f;vm zYJ%AtGEz=X7qc~+=aQ8q8;fnT7CdCRI@h&6NgAc7$+~ToAG=MuruLPhJx-jiOiCo# z5+-Nqd#uWP$Er76B2qPbnPDpfaB_@1RuFN7FAW(=2$l$_-e@Kwd7A(5f}Ha&BS4CU zrKF@V>Sg1`XI=H*<5#F8oIka z5hi=lnF2O}%J)^o3IN0-Tj~^jGpyh0Dh(SF8>iDP2Ux}HQzP2GJuSw>xvVzvXB*vN zO@`zIXXrr@_akvNI7RRWh#E79@i;touDl$*kr>iLO)!+b80rGd>r&t9o3>FxLaRF* zmYUEk$9EAE38nkKJ@UJ_8+vI2q)ncjoLnvRhS<&refb5AYbbmS1JC!O?gh^v1KB9; z11FA394+Wzt%j@ycMutPTWTzDW&6&mmu8}zn>WMT3L^%Ub!|~lGW$X>0#Zx#g&?_b z?wX6D1qbXr|PNk(jz2=vdc&2@g2j-fj z&to4pBkfNzV2${GA^-!>i`{eumSMH=jk6X@wq(vcqd){ws}|~;vi1OGbHn%o0%}Y) zPma3HP}&o_Yj*0_bkErx#fQT#YXvaYA9gyaHtrCdLAbao@R?RSSd)5MbASopaa^{c zKJ2v(uSpG}G1^m?T6BsLwf&AAqoh++TTpQPjp;^3BxE9rvGB_d-fmiAf>g|VZS{R< z<_v=FC!c0MTn_hV4X+Y~t*eH#em&(8RI)q^ZzY~>pdnBnE3*(@0rt;80m??2$mfsI zH&72C+SMh0_4!f99M(1xrZ~>hvZgJL`eK;+ohxR^Tax>=Ge(>vY;XR&i_e-nH9B`H z+>o33*%{bnne^!AP<&{x9b%r&hB->5qS%~%ly>plXJGF~XygD9s7XF<6W>vaNogC+ zY{;I=%Dx=Ya8j7<^)us#)-C&sLza1d$UJXdUDlp?^VX{aYMD3M7n`R@r)pSOe8K1< zLSZH>GExnGKaQ4$mPt_}INt+*95`0RiV+jHo$Z%cVq+rztzL2mYbW`LbaDTt>e?{O zy-VA)les*om!nRgQQ*}DL_M2=Mv~Bsvw{$QZbf6tSp(X#LrMwnB9trxITaDA7{0dF z_+=}>mgYi6YV3mt&hIaCj#(^QyLKX0fBe7-A)o?=@SEVlEV6($Zr;U(^9Ug^KK&z` z$+2F8?_V;WA}~mFS!N?VAa^9*5?ax3d$aF#V`z|5B1Q%$ynJQKX`C&6wRZd~`nN>x zz9I&QT7~^6VUB*4=pcj{kO6zlA87?CR0=oGC_t>kR4+)#(Q80~k2HJ*N!c+HN~<}s zAKeVi$`s%Lp8JrGO76G?O(B*R$+pbQfYx$1y#@c~%vU6npju&%qwy0ZG0dy|X!Z@x zk?xtQP&=$%bSYrpl{>Hl=^o>uSLL)jujE(kM{$az$@x-gbRtKd1xmQjfdLqMfgX!$ zHsPPlDflg}_D>Ii1WPevG0()3P-KFc2xB!u_;0l%ly`XZ)iao*8xBaN|rT zQgdyKx|ID=9Y|-|eS(sf4#K2ZvQ|q8tW~h@HnlWBXN_4>*?L-_Aq9PDeEj)gQCsGj zKLnQ;W4!Pa=O?5R;IKhqRb(HSh+i17cAccd>#l;PZaZ}K95<~mU?s1Stc_mj3mV4| zF&!|sYPs{ubvHbi&D-{pC&JSY?2Q!VQe3~;k78c-Q=2!CG8RJtA1NIV-mhg=D##A% zre}bE#s;tIKYw0DUq2K^RPoV}2^87aKdY+HVt;7SC}DChZ0~qO!HP{$wS=SzYjoa^ z9|mZeRUd4nQPfB19bV=urB5UiY648lmf7Sl6muvYf*c<9n?XVGclv1Z;)Y_X^)>e% z=w)Ywhs%M)R*7mB-TIBvb9BgD3bcl*nMf;%H?FX$;x|ym(FIX4N6s+!INc+Uv7$>D z@&fVfCEk|5ov)4SBT7$`fD$7wuv%6=`wnsu9pL(Vzl{~+Bz(oQ@`KO^*#$8X`35Wm zEO3-wF(bZ!4G`-z)jxPcc7@gpCNiiD7ciahrb_I>aXHBHm7oK`@YAuuZWm z(fw4>fc%X3L4WrHyQTU*u%9eI3K$Sha8^t!1GBcKhSXh{V+gwRXXm|2sIfGI<;WJG~Ho;4p@9y=ID<7PSm$q)>s(S^Y%$qy`y4AwLQJg{9IfZf((6==&lG7 zh(zHbNdxf)se0>zA~= z=acl1GV(y1kw*0qbCOMN5gwZ9gP4~mpu)r$M8Eb|BA_5!5N;ip9V9(5Y*-zzk)0Yc6oCdF3O$z+FwZ$dohy1)0c!Ss zL5s}4Vf^JaEsVVEO!;O`PLO`d{1Zv}B$hqe?8npUE3t>PYS~f#^NW&RN&9M!u0E{rk}@rBaXujoTrgqYMQFh3#_M4IBVGJ{@=zq_?JZrO{q z=gg3JVCYllaxmo9p>{{kLws=DpW9cdNcBEjhJp!f8%tgBdGLUGi)w0XiL?%E9IP5M0u$J?GGS2HdA5CRFK^${*3>k9P`b7) zLka%F??EU5ccxlYe$TZai!3K(BIpQ2U)7MqB(9>OA{l_cM673Q3qa9C4y_-*nSn3{k!q3NZYjkWVVL$Tv3gkn?G=o4aF&Cfl251P zGi(oq69tADMVd@q@U$v#iW4w>O@C_CPu-d67JQ3c=F6!mkW(VOKfUT-e~Qx`@acxP zj#WUUZ#lK-7^dC+|BVZsUNBQDX!@-lvvgRb1t!zq!fFPXY*XxqsaXOH|E-^B>Oucc zZxJiO*CL9obYdxkj%+}r%HO-*{2R1};mK5cnXT4FCmAleMRjhC{XE-4YT-5^);les zaS9uR9Ol|E>UlKzDA;?fVotW&;L-9lB7V(zk8OOLPi$tD_#<8|Lx$PM$AeL9u=a3S z@_tQ?cMtnS?Vi;301V%KQRq4Rwa++$ZN^6L28v**UTsX;6t?A>#48q z0p8?a47iZBKtOPgdZ{?&o?}%Eneeu@N@1H~7WR^w|0zrV`*n)9LjOvC=f8P> z`c;HeYmff*pS%DU?0t-y67`lMz3ijD(l7JJ#R`+8*WGY%xYv;W8R*yh;tQIO)Mw9o z8Y1a2ULXq`NsVGJ^k|j@vcqV?uB_lQt`|4e<%yFcCFpTGYnH~Y_@q?QDQvZ=}Zhx)+y_6Qae><@?~JOO5&YgI%%tU)~)Xil^4ZYaySHjkWsY7n7Cn zQw)xjPTAc}G~n$<@^`I_@&z}@H-$$z@ztGrstgs>3Z;LgKl*>T0RF-YK0L^oEOGrB z>^g3kL{`0lV5OaDNC}lRTqu*!)f*Vi?WAtF*B#fP_A|Xs6W|D$FC+H7@$7rNG)bxx0 zT>5&Y|IE+Z!758*opWqYPOY+AN4Gh7x91N;yS{%5AIkYyYC!%TQ5>6YI_6@z5`SU9 zRrbFKXb&B4dqssNYhwZYYhKbF`kHv|L*F7nw&eNz&EA>FE-&6pKKJViAEVnOvyoL0 zWpkl!%F%qfYj`?+tS|jqsjm$5d$H4ozP&0en(9MDnL|a(7E6zIq{ra5Ff_!^4RuLL zAFl5(9eMRUs$WuO`=l3r9dwJK67_yZ?Zmvk$Z=PhvVQaa4a?aWE`apV)Hf(f zep$p%%lR)a;s3QSc)i9%*l@vl@&S)Lv%QlHG2L@Bc#N{PSl1UwbF$ z7xr%+7kRpgQ2f<*4}Sh!Os)Qh)Qz-Ci3F#t69BgnS>K5_F_Y})ZSda_N9e!rF9e?d zcV5tnF{B!X-X;OXB&HZ}RkG}+PUaucn?tO7XtcAxOh$}k>+bYZ>E{UdUwh=`T5+{q z15Pg1*2jND4Y2AZN)J{F>$-_9C+}#HU9R(j)i@a6Gx(*s!uV>@rF@i=Q$LUC(tswc zw!fiYbtZKt{k+qD{{H{(x_>>ZYnT&@;vIjyycntc|MAJiYb7zE5Vrrez>k3z5eJ!z zWZ761DJd%xtLIExDhD;d=-#e%HE`#wam}5Cb*p`5Fg0eaA+o(Jw8t*9HbKAs&ku~( zZJiJO0;Na}#6aI=E2X=H3tm(CL&nx~!eT>}3N;6<9`o1N|pj<|)ckzb`F~IzE5OQ|RwW&spf-Ykkv=nn*2gaHdjh zdfxQ&75uz0Ui)TGUpGCHx&L(YfBya-#>*FG-hu@kk>xsW~2*1AbLeTxo@*`RezytT+=9z%=0 z_}7KP^DxrFqtg-PrT4R3#W54iNr%X<91q+1B*=c3 z31jMW?#tWAw$a_beC^1!YZaYyeh+q4Bs_SqS3ykd!-L~~e%#y6DkL%ero3{4qj-LG z@N>!dJ9FAoGwYXxh3ls3rzYx$8sl<_|EM|c7jOE%k%<#z)b;iABPWw? z4LWwQN(KYKqaZR}DUFh>KzjDPR*g!st_7Qw-_MlsW^zq@z7<0iZOy%AlHoqzuy z)=S-R%|HH7%HPjl2@sdK-1F%C-^(8Q_r{)lp-en$tWwt@vf1-zG@&fvMEKu3-8(NB z{v^|LW5siG$x}L-*TGp^NHI&Zu)p)QjVHy{B}9E%Zp`j7cB|I;(%KrsZ4o`K`*#Q* z)lQ#HA(C#Q7iv3nIznECMe5V6wd2h94-fZ{9y}%(rgi@%ouGZHVB*)UFFLNOMBm_G z(M=n?IiIi@K*u*Vo-A3eUDW1eJNWoOW>%qi>|^0=)_m@NezR*8ojP^Oeyq*6lrXF? zkIR=YbCfOXHpL5B@Wv!$=o=WguP<~GUNSUfzSx}4pn6Uu&VKR!p2x{4DSrwjjE#SK zGkC~d$hi^`Q}mf4@_Ozt>fbt7;G(U!!}JN~h*vtgxbO8uwvlspYBSXf_wmg9B)MB`D;}*Fk{Vw z%z!_~;&^A%CQ+czSMzrs>wij@2mB{W-Qr{fyGu-3gGK(F;pSFaBWZS9Yq`!A&2ur% zUL~vkQuK8O*LtU(LP;tse}Pv`VPY)OOH;i%F29m}eD+3+4h|3=W9=E8nHqeJ(~>V; zm!=M%mf25CENC-OF`3`*@%v|s|6r1+F1`U_t?}@JYU#}7j_xxFWzvGke#?M4% zRlbeo#NesYY2p4ZZWhtjp|L8Bd06Q9jRz;ldc?tO!Yik zUURhwb}e09-I4kz#fg(o>7BlBYox*Gp6{3Ei=pGQyyUr2l92IQJ@2y`SsCJ|&$Zuu z`14(=6kyCK`ambC)gSYAphApfeO@%3Q(g%FdM;%rQ(PY2WTd|p|3^5hVifb(2Z!Y!6y;XkQ9 z{Hoqv1vZ#jI^}k19!B3sRJbdmT`$K4FJAWJky3oOom=6{yx_{s;de)hRWj8pD=U3# zqIt|NiEn$xron66e7teO-)eTC^1_7+jGS0u-TlosJfbdIbibnrNtRL0Ao<~YI$rMT zD6@3*Da$VQ+S*#%6PaX$iyUV>Or9V}^ zX}iRl%)Se}<{uyJ^XewoQ#i5k*q@4HQ7ACvNb61wmMDY8iLR;ar%zkcm01~Xa+{7W z-#UDq$!M~_yniqQF-IBl{Q2?CV8hEzQo+~dCN)M|+pB+_?mPYUTcMp1HkH=FsOZ=_ z{y0RVg~?b$vHYA(ZoqFLmHC0UVmZu_5dv#yt9 zoSc>s1KoLMR16ILZ*ADO~P?2N33*3lB4vL#}T^=Rm6pRjEUbe*`63k>x5e?&;>z(?v* z3I+zxma^|*XBA^*J3~%hkLS0!5@)hvcrDF3srpZY-AKKB)5XmbQc;}Oyo6C$teyGQ zc1{R|HEylz8i#n|2tB(t_M=@;9kGQ>wV4U>+?;h7>Xs`|6s#j(5p&a zZ7(P&m>sNMJ)Gw2@1LQR_T|Zmt5v}aZ>q}8dkSXn@1d8=4tW0jIhBr^eC+O))PHI| zhqEHhrdZkp(+dfDtSxVSi+;q98yfG-f3+l5r^+V=cwG;qP1Y49BO@zQG>P>uEZAvG zoXu3VbWGUI$@kgg>@IUt^54D1j!VBOp(0cjjHF1X<}+`Nl0*h%bJ4CXukRHs2Jtd5Bpy zUUP!We+!R@Rp{rbkGG|@KEJ%tdMrSMvO zJBI~jYNHOJ?SJv4No3OvbI2|$6MyNJ!6E+sCE>-ddW=tFV<+N0a?HDO*fiex)bMNu z2`yj{rN3)YsZ;6O(iE@Hv5pMbyp}uhPK-UQ&|x!YAlKU0wVk{lxm!s~Tcr9h1&7`% zXN~W@j{R}x?>q=$;k@&4XI}Ax2M>0W(yt*sgw2WQEk)G@(F+xsv@#c-=aig_xow6d zO=a5m+2;eNmnF+cj`U(3boKOf z+-57^S#-)_AGlwAPlP%0XdKxYXymUY;R)sB;Z{F5{W< ziQ+4{9Z#7g-&yvu2p_hn2xh4?eHeHq(uv`D(!mN82cw4Q_EJ~J@KrA3=I6Dcrz7U5 z1FKIL+y7!@Bt(K!QtX}v=d}mO?g$&l9-C_Xjqi6t%6gJ>=y~B%*{Yb>`ut$9FpI{? z_;bUb!;x=lFtnsoqYesP+*9|dt(qbpC0-8}o_W8cF@%<4&bquwRWGrkPEES-iOkC> zb>$NAaL(Cx&#Kenybm7xmUJ`p$&)AF^33YIY?ua0mHZ@jqt^Q8XJ7H^ds@Enr*5-% zxm{H!jcsLlFqq`+=IZZXxkv~|0DYyd2_l|-2{WHNGBww%C#!X5qJ}x>xO5)g>{?uTYmlL#i0zkG(PbU%z9Urh$&cnBC>DK zP4A1BIPD+~7_fSSBl?o_TusQy_T@694?w;mvqCltUC&lI!#+;m- z>?5OT&+98m8w&Q&8rDaiU<3%36<$f&WTkZ+VUHroHELS4dp5ot#M@4?}prM;JBk$7_5)dua|gf`!!wybr5B@ zyeIzIFtW$H{&sH<^>bOr8j`pZ(mN;dn*R2BH(#f)`{Ua&{OaY29u_2tD zo!uAjxH4+jBs6M7?njnl<4bnLQj%TS{(Vi&nZo;A;?x<|cH|DzeOk)b@22ssjl@`` zhb2d7yWH$yIdDx(OyAJ3Y-J?IV8E(^NlLMKe2X<&wOI@zb`Bopw{8|mXY`(5XRa`t zZ6;mY*X*MaY53MU9@h>O!3ViQ~mhy1IXkL>Q4KURKN_8wV6)~W9^C={8Wii zJZ37J8|$c8orsj_vQ^7UKMK=u0N?{h`J0!I7ak%V>n+arIj8>i=R;Cqx#@YNex$u) z3h!$lE0KRjb+ZgeJKOwB(-zToFM3sgnb{CUpts0@burkuryyG}y3C)E^qAmV&&>@w zUi0&Yk@O&{7eZ7F4M#{gN{(-`E9APZ%pqHKW~fdVPnWq)moBwkrr|Y@j2{1ksN?u4 z`U;@h>D)^<;KQf!zg>_y0_pfv_UHnRSG~SdWqIqJdDOT2V;9rgk`%Np_F(x7hP2&g zuaW)g%6-*fc(~k?c>T}#n|8mMOkI61X^nUL>3DmZ_Qfb|O);yuO2HRB?$0CAF|p%2 zGl&9-8VY{o$d$25aK?jde@g%mQ_{?i5OlBc(6(4;7W=Y^?Qzzn4!`e|bQPS|? z7)}Gfd39FRtaD=S1DL|%goLpt;r0RI$M#Zin0364n!kVkuKy}SkbL7lLe{$;6%_@L z`MXtCq#^6fJ}t{J^=4%SMa4b)3lvkY>lyc?hRlmhJN+S9_&!rR&opa;!Wn)3&PSkN za&q$6j5X#{rw}J8I^`bpJGgC69OOiKLt)IIz`zKI6d)nkhEU(`j=d&NG zA?x?gRLi~+cd9Y)7b4fFDZV#Fn$D^^SvEl!Qo?zb!!$-UJ4i3dQPLF%yZ-^;xpr(X z4>KW%C7+$M>0_(%9U3Gh1O$$sBkDlSF9rLy{eAa%h4Ii+aoN4igj**fdO;t!Om%hElXpsiF3$P;{XP7rw_d*& z_AB~>e(~0GqMBsd@xMwH(Z50$zUJ?H|2_FnCHwcuy+etANAcg2|CFr%p8Th^{rdzu z)!*;^@5%p**!KU+&4`jkef>KxX;6MWTp^sBUR$YC1VNt;`KeL!JCrHDvSu^rP89 zh=U{8{qI1mLxzWk1&$t+ZvFQzYnXyt4jX}mUxt{sx!P zBwy#{+2Xew95J7#S6R9#smBI zk%hc|{rbe!$Faih>SALZnHMd-aZ9&0#tO8SdwK?s(|Rob*yCS0V9j4-|LbLZ8YmOk z{8~?Anfog9>C<&yx2pk2+&MNO#<7D?lvGHlzcas8>f(^m9vv0s{ImH*zvreqzHiqz zE9h13V29U+pbhNUy?fspt~nd}|G z_aEQ8cMs~zja|lbb93|_%XcWA+x(Dy^0GiFLnYIib@%SwG6}-H&I{kae@Bu9p??ED zMJlrP&!-S2&h`>-9RCy zrl3e3*e)xy(1wgDl$C4}J$*iKbrleJqng2!yTf6n+-K*`oo}uBwb^20V$5DsA3mJ; zYy}0(*48#4FSg&b`<(?l0~IADFOoPUz6C9eJ~T3tnu>~wh9=EK9W{-RI$fse0b&Kv$?;>Y97%Ry6)@;==v9-0ZVtqY-@`tCO@mrl|(n9xKC{8u>07`G+n%hkT~0 z?Oa*S0?wvLij4Z@%a?#rh!JhYfea#jo5w4;nl2@7=kDt20xB1ReC1Lm9!T5zN;Y5Z z925YL-!0es-R7mw-cTA>xOQ_bIOuOko^9oMSPk-0FUoE4?JZ>0T1R#7Wxh+*vb-^w zdeuQ=*^)hC2TEAvKKeHpnzg5g`+S{T^rm;I+1u=MLsxe|FIXsr_YDTa9GWJ&BZ$4$ z_~dA|%+9~L<4N7|;3{ghA#!n=3%V76MsMgjGz(_@Im({AG_X6u{R~2hY^wFmD_1^3 zwjJ&_Fc3Q>B-Fb(Qvv8!ZQ!-F>c z*aQpXuQmz+;Wt;yH|LMV&xoiD7$sF1=Jr~DIxsd1))lMc=jW$jMWE82J+H7FV;}0H zxP;hex{MOi_VD%EE1nRiqgXFCi(Z__Z9==_9mI+cA3j8c-}u$~Y6+@yQ_cqp zR+Y8#%~cVK=PN(F88rP42Oqn5x2M1c>}D@1DPNQnb7-w$E|2+FYXn@?#@{8itsQSk z^?Ya5zwyGGULlyzs&DO^*_)MHA3GBC4~rxUAUhO|?Mve5P$R zKdg@m*baTo)@x~Lv9YmX4!zm>_Rgor>ocE7v0Qe+%luZNYHDAvGq@)`4d}?yS_Q7U z=s5tKX7#8vCnsldvYU#QRz1&DIk)Sz$u~%@U~xm2cJAL=0;j{+r6Z5oLkja9v^pJ1 z(J>mV4pwaf{{5czDt?Y@w|szJ+-nsx?5hez@<5-qEG-QoJ~ zxxlurgMoY%79AF3we|JCXA1=c1i;xa|6Y;0H~lX0rUNILn3w>bNl8g5H7R9jI{*3o z3-2?B{=$`90QW15Mp`gEO3Cq9j{=S!Ad7_-aEvufMSCc7sL9+=t*VZDUY9x2_wUFH zddpl}yZpEglJ+K2&KV}|rFWlxU=cl6C-arJOPZRF&HVVZ9w7Ya(W6Tc)TidJ7RtVw zlua6Gxz3>6{UkE-=TothjP2>lidMRuQ4@Dp(-dDFWDq`j?3h-jI*+nViblRA5IdJ) z{dtwMI%Os5mCm+j90A%eHcJ0Wea^Jokw?bsCCaMB)6yCOL7I<>c&ve%dn~l8UOOc$ z+*fU`8J*G)D?q^1jJDt3-^WU|8T>$4?Af);_r{eg56GU&?{Tk>ynw7CVmA^X&$tDT zh?d_OHb9xqmbqKJj;>yQm$~hU2?fwlOwNsMb=60*ODu2SelAZ|CFCnE=7E+FbP0)Y zmSYeV@;bF8Wo7xaUB=R06*-JEGw#@P@RHl|%-nEY`#ko$DyimUl0tsZ4L2`h3b7>9 z_H;-aEQ{GPWlT&=g~6ZH^DX1@$y7gyOaxyO$3tzt${NP=3F#PMd;q0#fKR0xo@m3cPz)w!;EhnNjzKVTut)ZvQhrkXo6>7HB}E(9^~s1G%NBx80HpY z_49jd>t3Nut&AqyO)y0F~9*MIK!D)p@som!4@v-*~nS(-i{eU=pXpZ532GVyh!HJcr~}i!qqTvXpJ6`6}tIojY%kJv9#d`O|pe zJ1$fe%CZPSH!!WZ(!$hKc~5nBWFoo5lM5XSl&@!(N5pUcoG#xi1J@Q0rqA2}=2{@= zk{cBP0Rd55M#6J7C&8#ZN1}|NzP#vr;+wxUJ2S05WG9n`ssszG_tJZ=*(je^Q$9ai zYAJ7fQ|Z*tXBw7dB2SGe&#BD}28(2YGwrhPNh|t1p!8T=4Eo9Zkb=88@!2LCq+`~XRr9u2AR@^f0#Fe~yvmv(brI(Z5sf{s?J zPac8_ot&IZ(^L*|HvO{{He$L;=2(u(`X59<4`K&g^XY?%cWc3!Va;l%4Nwfb^Q}~a zb)0^90gjETP6IOt*o~Y+UPL)&XB`?niYJc;cH=12(C)R?0TSLm_{Kj zb_q3o+}!IG%+z3L2QXbxF2Rj9s3Iwfl06EDV7WbDIbAJ3`-^9oe&l{dv2&Dl$n8Hh z@4Nyg#WjPzl^|5pM0Y(*uE}#E7V4;V&qQ|0xUUX18(KyD{2UY(<#eTed-pmmb{WNy zzO2;;_ER!8PW6f9w_&_^F-C-I>mKJE{`@xKVX3seAmT9bH5)xNl^u8b+~ymu(>sf8 z97{j*xxV!?=(q|PCsxVe?M946~}Yto8p zG@UcR`le(K(dzB(-EIC3jk`DjJ7m3V@OKIpCAky9SX=_;8gMkPjJ#D_sPN_j>$`8K zPs_%yHF<6*{?3+}dXp||;~~t!F`ZpaA9F$PG4hA6uk*&rC_%~0qrvhNAy;n4W5hr^Pu^buR9ev8G$TV(ao-p!&vtGo+vAF|Lc?V$wzhL%D!~jQS;E|c zf<;K+At52AgRlnxGl=S?pPaoIUZtDOZH-#xKDbGcjIsD+trnM5NEEiwE_od$Cl9#S za)7U=8Ny?pnu2*bne)GJv$3$GLzzHFmqk=BzZ-K=V*TUFM%C6@%~aDtiMXp|D2ri@N-Vm0Mfxqq+R~bYmU;jj(51vzIcPOCHugbr z#byEmowmpX^e1Rr=t%8f3em* ze`aQ8@dj24U7f7~5oy}~e`dV@2W$QNNB@7)0LZcvanc#r`J^ zLGDtxg{4I>(a_A3P8bI%<;Jk!MxkK%O3JTNXY!`T{IGH=F@TnqIm^0XX9A9=oCE1qwS53 zCkpTi{qWbXUte7HrqrFe4MvHc?pc}eQbQqh54Kg6N7-4speeO|6_Vd`DEa|#y{d+Bbk9TsMIwjV*@cXyD-a~2TP#<&DHDr=0 zvp6Bwj+J5nOLW*W_-kN*xjXUoPJTDkY&F0%kVqjkT|=3rSr*M{^Mvp+=#zIHpTefB zgoJ`5I#oZS&9bu9Gx5&%diJ;f{Bxe-d5eQP?i_2LqjoNkmd7tRn3R~s(jrlcwov;u$xLL~*^1jQ&eZD$EONEn}Hl@yauM;cNf zCV-sG`xDyx0at=|&k0ep=;ZggWcWFr(}LM82WvBSGx1WO)mA5n4YI`E<}=t#7YQaP zXeDh;Nxs{eTCAhBrPAl*6%@WeLyt@72ZvdiAH9}@yX@=8lzZ{ON8iW@lCwK_=SFZ# z24+m+7onjN7lDb#h^WXi+G)wjKelXvq!Yp7#5^5oAh&$|Zn-XoQ$2;yH&`_HrubaF z^fHm~*wWHa85}*dg=VtvJSJ#G@-p-!1(sbo$x!%IGV-94d#*1;9C3`SY7%;G%7Vl& zjn>UWO=<%b%nS0(=EhPvU!Rj6)TFCduiDw!0Zc2Cne>$ueqMC~AxwPv66&7+kOgZ0t-rm_;7&9g+axOIljm#k&*d`!vO%@FT!vrCZ?uS;QXax2gwshkTPY4x!!J zV*?>vIQ&dQ>C#7M9enmR;2b0cu9${{-X~GXFsZmH+m)%Yi|puC!I+43Fa!4%6@5?O z)DQ?Vb|C>mM1aPRSm2hfYiN*`kI+0-2SMwdM*OpL0N~3Bp~*Ch!fTSd75OkwZnM+IB=6Zz{oGQn9Hap`DJBp< zDyb7MSWfduv-ZfGSAi`^5+Xz?8|9%x)_{HKg=Jz%SYmKWSCC<|w$uy8&Byh)K7Ra| zRXkOM$a7yAj<7vZEH>E(BrRBN4D`@onEP!GY5!#NamE95yhjka*=C*WgWZ8g1r&}Q z9E<{|hJsREUELIU;ggWDLAKgd>5}$nPFdA#^=)_362rMxJSS{tKzu+1W8xL~Zo$t)hz#!{!g71<#Cj7gVK;A9Upi zDfk!W8xGGNdwAyelZ2TWYvsd3s4?tJT9DtbokC@SwT>WyMEgMW97Vn)*fi@trjKNQ zp0?f$D=E2D+-u1j({AB*Gr(a^M0BuB|MuMUYvifr@<*fo%@%=yXM=OoA&z ze8Xl;hs97%P7b7lkRbBAkpetHQ8V6@6`}VCmWWgU^xvOvmGJEZ7#P?(5Vnm+6D6BE zHC_MfTU?{m(ulwIA961)kQELFU4#IDJ3MjX4k8R$xo9js!<<(GtAiYyUbV`fun*%HSmnFUYx#42siXhLe_G$!pKgFryli2puu9m*sp|apbkA}iTK=D0(Nrq zQjzJYARqqzQP%YL-tDF+tA(>K?%%(U1;Q00dCZ>KMT%GQT%x?BVryr|M(OXjy4+Fj zxrsc4F5?XFbNE1HXV3LZl0v*uQphFIycW^GgI#_?2I!m>|V)MM*h~wb_W=HOzHJBNk|i{l|XgbB2gZ$RcrnDo_*P zW`^F7@K!w&{?}sKA?3Y}y$%t7C^sU!*j?COz^sP$J(+;h%yY6YTlE<|5Kl`99_jJ! z{5K(KWHdCtWZhn1?|>s(Wk=K;ASO;nyU`~pC8g-J92Cn1-|Im~S3q&mepSQDB47mB z*&Fpa572N90W6%CXivQ-BO?=(@0NfJ2>_*>EAA#U`Fdb4l2(APugx2g0h=oshyb`` z+T96-t{&aU1g#53jYkK(%YMVO17Y7wdSVV>OYF4YQD*ktGNL-;Vl9=K|;|j7?r%UWHaG zppIJGxf#D8gwd~w7c$#vJalE*98E1kK^%-xM-NuNyyhS2wACqkmari#ilpEejUEG* iU%-`tkLjI}g!^ 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

    N{TPe{E-uShhGs2m|J41-vu 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 z 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 Oyea-ctW3nf@BaW@j!5(X From e3533509ceff8eff53d83ceb035a017fc27517d3 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:25:11 +0100 Subject: [PATCH 459/460] Bring the README and the CHANGELOG up to date --- CHANGELOG.md | 6 ++---- README.md | 15 +++++++-------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e39cfce..5c96d8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -103,8 +103,7 @@ without a temperature (KH 120 II) show no trend section. - The lowest level the meters show is chosen from a list in 10 dB steps (−120 to −30 dB) instead of −120, −90 or −60 dB; a saved choice stays. -- The device panel lists its settings by topic, the outputs first and together, and in each topic - what can be set before what is only shown. +- In each topic of the device panel what can be set comes before what is only shown. - On the KH 750 the outputs are named after what they drive: Subwoofer (its own woofer), Output A and Output B (its two output sockets), formerly Output 1 and Output 2. - The language follows the Windows display language unless one is chosen in the settings. @@ -124,8 +123,7 @@ current input follows a switch of the interface right away. - The device panel opens in a window of its own, one per speaker, that can be moved anywhere (also outside the main window); a click beside it no longer closes it. **Details** under each - strip opens it, as does the speaker's name. It opens at the main window and no taller than the - screen leaves, so its foot stays visible. + strip opens it, as does the speaker's name. ### Fixed diff --git a/README.md b/README.md index 8f87b50..3fd02b1 100644 --- a/README.md +++ b/README.md @@ -59,9 +59,8 @@ them. This README uses the English names. Alpha, below version 1.0 (**?** > **About Neumann Control** shows which one). The protocol layer, the polling and the interface are covered by automated tests against a built-in speaker simulator for the KH 120 II and the KH 750. On real KH 120 II and KH 750 speakers the app was -checked for scanning, reading, metering and writing level, mute, identify and standby time. -Groups, the compact mode and the English interface are so far covered by the simulator tests -only. +checked for scanning, reading, metering and writing level, mute, identify and standby time, and +for groups, the compact mode and the English interface. ## Requirements @@ -219,10 +218,10 @@ same and writes it next to the executable as `scan---- Date: Sun, 25 Oct 2026 18:59:29 +0100 Subject: [PATCH 460/460] Let tests wait for every write before checking it On a loaded CI runner the import's frequency write had not reached the simulator when the test read it; checks for writes that must not come had the same gap and could pass too early. --- crates/app/tests/app/eq.rs | 7 +++++++ crates/app/tests/app/groups.rs | 2 ++ 2 files changed, 9 insertions(+) diff --git a/crates/app/tests/app/eq.rs b/crates/app/tests/app/eq.rs index e07a6ab..830290a 100644 --- a/crates/app/tests/app/eq.rs +++ b/crates/app/tests/app/eq.rs @@ -108,6 +108,8 @@ fn dragging_a_point_writes_frequency_and_boost_of_that_band_only() { !sets(&simulator, BOOST_POINTER).is_empty() && !sets(&simulator, FREQUENCY_POINTER).is_empty() }); + // Whatever the drag sent has arrived before "no gain" is asserted. + barrier(&simulator); // The flat gain stays: dragging a point shapes the filter, not the level of everything. assert!(sets(&simulator, GAIN_POINTER).is_empty()); let boost = sets(&simulator, BOOST_POINTER)[0].clone(); @@ -305,6 +307,7 @@ fn reset_asks_then_flattens_gain_and_boost() { wait_until(&mut harness, "both arrays written", |_| { !sets(&simulator, GAIN_POINTER).is_empty() && !sets(&simulator, BOOST_POINTER).is_empty() }); + barrier(&simulator); assert_eq!(sets(&simulator, GAIN_POINTER), vec![json!(vec![0.0; 10])]); assert_eq!(sets(&simulator, BOOST_POINTER), vec![json!(vec![0.0; 10])]); assert!(sets(&simulator, FREQUENCY_POINTER).is_empty()); @@ -822,12 +825,16 @@ fn an_apo_file_is_previewed_then_written_as_one_history_entry() { harness.get_by_label(&texts::eq_preset_band(10)); barrier(&simulator); assert!(sets(&simulator, "/audio/out/eq2/enabled").is_empty()); + // Slow answers space the import's writes as a loaded CI runner did: the boost had arrived, + // the frequency not yet. + simulator.set_reply_delay(std::time::Duration::from_millis(200)); harness .get_by_role_and_label(Role::Button, texts::WRITE_PRESET.get()) .click(); wait_until(&mut harness, "the import written", |_| { !sets(&simulator, BOOST_POINTER).is_empty() }); + barrier(&simulator); let mut enabled = vec![false; 10]; enabled[0] = true; assert_eq!( diff --git a/crates/app/tests/app/groups.rs b/crates/app/tests/app/groups.rs index 1b82235..7685083 100644 --- a/crates/app/tests/app/groups.rs +++ b/crates/app/tests/app/groups.rs @@ -431,6 +431,8 @@ fn copy_to_group_sends_the_members_value_to_the_others() { sets(&kh750, "/audio/out/level"), vec![serde_json::json!(90.0)] ); + // Anything sent to the member copied from has arrived before "nothing" is asserted. + barrier(&kh120); assert!(sets(&kh120, "/audio/out/level").is_empty()); }

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 literal 0 HcmV?d00001 diff --git a/crates/app/assets/brand/icon.png b/crates/app/assets/brand/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..714b1172c2c0e2d68b72cf7d338bd608a6c29831 GIT binary patch literal 6532 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 KmN{E!VgCa)B_f{y literal 0 HcmV?d00001 diff --git a/crates/app/assets/brand/logo.png b/crates/app/assets/brand/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..1c693b09f30a597fe5a79653ddc64fbfcd09ee8c GIT binary patch literal 25745 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 literal 0 HcmV?d00001 diff --git a/crates/app/build.rs b/crates/app/build.rs new file mode 100644 index 0000000..d4a2c77 --- /dev/null +++ b/crates/app/build.rs @@ -0,0 +1,14 @@ +//! Gives the Windows executable its icon (Explorer, desktop shortcuts); the running window sets +//! its own from the same picture in `main.rs`. + +fn main() { + println!("cargo:rerun-if-changed=assets/brand/icon.ico"); + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { + return; + } + let mut resource = winresource::WindowsResource::new(); + resource.set_icon("assets/brand/icon.ico"); + resource + .compile() + .expect("the icon resource compiles with the Windows SDK's rc.exe"); +} diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 46291b9..45c4296 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -4,6 +4,7 @@ use std::error::Error; use std::fs::File; use std::path::Path; +use std::sync::Arc; use eframe::egui::ViewportBuilder; use eframe::egui_wgpu::{SurfaceConfig, WgpuConfiguration, WgpuSetup}; @@ -15,6 +16,9 @@ use neumann_control_app::window_placement::{first_window_size, place_window, WIN use neumann_control_core::settings::{self, LOG_FILE, SETTINGS_FILE}; use simplelog::{ConfigBuilder, LevelFilter, WriteLogger}; +/// Title bar and taskbar; the executable's own icon comes from `build.rs`. +const WINDOW_ICON: &[u8] = include_bytes!("../assets/brand/icon.png"); + // Our crates only: wgpu logs the graphics adapters with their bus ids at info level. const LOG_TARGET_PREFIX: &str = "neumann_control"; @@ -35,6 +39,10 @@ fn wgpu_options() -> WgpuConfiguration { options } +fn window_icon() -> Result> { + Ok(eframe::icon_data::from_png_bytes(WINDOW_ICON)?) +} + /// The app still runs without a log, e.g. from a read-only folder. fn start_log(directory: &Path) { let Ok(file) = File::create(directory.join(LOG_FILE)) else { @@ -58,6 +66,7 @@ fn main() -> Result<(), Box> { let options = eframe::NativeOptions { viewport: ViewportBuilder::default() .with_title(texts::WINDOW_TITLE) + .with_icon(Arc::new(window_icon()?)) .with_inner_size(first_window_size(saved.desk.compact)) .with_min_inner_size(WINDOW_MIN_SIZE), centered: true, From 6e6553b50d5c6bcd03d7bd8f00d83500e5c7a702 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:07:29 +0200 Subject: [PATCH 107/460] Show NC on the app icon The icon read N like the integration's; NC names the app, its C in the gold of CONTROL. --- crates/app/assets/brand/icon.ico | Bin 34122 -> 38160 bytes crates/app/assets/brand/icon.png | Bin 6532 -> 8933 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/crates/app/assets/brand/icon.ico b/crates/app/assets/brand/icon.ico index c4caefe130bf02604bd135e64081f18fc957a7a7..461a7ea055acbe4b0bd924d7526e03c20ecae08e 100644 GIT binary patch literal 38160 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