diff --git a/.gitattributes b/.gitattributes index e1202ef1..f60661b8 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,5 @@ -# The English locale is historically CRLF. Keep Git's whitespace checker -# strict while treating the carriage return at its line ending as intentional. -locales/en.yml whitespace=cr-at-eol +# Keep repository text LF-only on every development host, including Windows. +* text=auto eol=lf # The vendored Flatpak generator is verified byte-for-byte at build time. build-aux/flatpak/flatpak-cargo-generator.py text eol=lf diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 10b01327..16ab5881 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -1,57 +1,59 @@ -name: Fuzz -on: - schedule: - # Run weekly on Sunday at 04:00 UTC. - - cron: '0 4 * * 0' - workflow_dispatch: -permissions: - contents: read -env: - CARGO_TERM_COLOR: always -jobs: - fuzz: - name: Fuzz Targets - runs-on: ubuntu-24.04 - # The fuzz crate depends on the full `tributary` library, so this builds - # and ASAN-instruments the entire dependency tree (GTK4, GStreamer, - # SeaORM, aws-lc-sys, ...) with codegen-units=1 from a cold cache before - # the 5-minute fuzz run. Allow generous headroom for that compile. - timeout-minutes: 60 - container: - image: fedora:44 - steps: - - name: Install system dependencies - run: | - dnf install -y gcc gcc-c++ gtk4-devel libadwaita-devel \ - dbus-devel \ - gstreamer1-devel gstreamer1-plugins-base-devel \ - pkgconf-pkg-config git curl - - uses: actions/checkout@v7 - - name: Cache Cargo registry - uses: actions/cache@v6 - with: - path: | - ~/.cargo/registry/index - ~/.cargo/registry/cache - ~/.cargo/git/db - key: fuzz-cargo-${{ hashFiles('**/Cargo.lock') }} - restore-keys: fuzz-cargo- - - name: Cache fuzz corpus - uses: actions/cache@v6 - with: - path: fuzz/corpus - key: fuzz-corpus-${{ github.run_id }} - restore-keys: fuzz-corpus- - - name: Install Rust nightly (required for cargo-fuzz) - uses: dtolnay/rust-toolchain@nightly - - name: Install cargo-fuzz - run: cargo install cargo-fuzz - - name: Fuzz DMAP parser (5 minutes) - run: cargo fuzz run fuzz_dmap -- -max_total_time=300 - - name: Upload crash artifacts - if: always() - uses: actions/upload-artifact@v7 - with: - name: fuzz-artifacts - path: fuzz/artifacts/ - if-no-files-found: ignore +name: Fuzz +on: + schedule: + # Run weekly on Sunday at 04:00 UTC. + - cron: '0 4 * * 0' + workflow_dispatch: +permissions: + contents: read +env: + CARGO_TERM_COLOR: always +jobs: + fuzz: + name: Fuzz Targets + runs-on: ubuntu-24.04 + # The fuzz crate depends on the full `tributary` library, so this builds + # and ASAN-instruments the entire dependency tree (GTK4, GStreamer, + # SeaORM, aws-lc-sys, ...) with codegen-units=1 from a cold cache before + # the 5-minute fuzz run. Allow generous headroom for that compile. + timeout-minutes: 60 + container: + image: fedora:44 + steps: + - name: Install system dependencies + run: | + dnf install -y gcc gcc-c++ gtk4-devel libadwaita-devel \ + dbus-devel \ + gstreamer1-devel gstreamer1-plugins-base-devel \ + pkgconf-pkg-config git curl + - uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Cache Cargo registry + uses: actions/cache@v6 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + key: fuzz-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: fuzz-cargo- + - name: Cache fuzz corpus + uses: actions/cache@v6 + with: + path: fuzz/corpus + key: fuzz-corpus-${{ github.run_id }} + restore-keys: fuzz-corpus- + - name: Install Rust nightly (required for cargo-fuzz) + uses: dtolnay/rust-toolchain@nightly + - name: Install cargo-fuzz + run: cargo install cargo-fuzz + - name: Fuzz DMAP parser (5 minutes) + run: cargo fuzz run fuzz_dmap -- -max_total_time=300 + - name: Upload crash artifacts + if: always() + uses: actions/upload-artifact@v7 + with: + name: fuzz-artifacts + path: fuzz/artifacts/ + if-no-files-found: ignore diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index a2bbe378..bf71e047 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -1,23 +1,23 @@ -[package] -name = "tributary-fuzz" -version = "0.0.0" -publish = false -edition = "2021" - -[package.metadata] -cargo-fuzz = true - -[dependencies] -libfuzzer-sys = "0.4" - -[dependencies.tributary] -path = ".." - -# Prevent this from interfering with workspaces -[workspace] -members = ["."] - -[[bin]] -name = "fuzz_dmap" -path = "fuzz_targets/fuzz_dmap.rs" -doc = false +[package] +name = "tributary-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" + +[dependencies.tributary] +path = ".." + +# Prevent this from interfering with workspaces +[workspace] +members = ["."] + +[[bin]] +name = "fuzz_dmap" +path = "fuzz_targets/fuzz_dmap.rs" +doc = false diff --git a/fuzz/fuzz_targets/fuzz_dmap.rs b/fuzz/fuzz_targets/fuzz_dmap.rs index d57889ff..7bb387dd 100644 --- a/fuzz/fuzz_targets/fuzz_dmap.rs +++ b/fuzz/fuzz_targets/fuzz_dmap.rs @@ -1,9 +1,9 @@ -#![no_main] - -use libfuzzer_sys::fuzz_target; - -fuzz_target!(|data: &[u8]| { - // Exercise the DMAP binary parser with arbitrary input. - // The parser should never panic — only return Ok or Err. - let _ = tributary::daap::dmap::parse_dmap(data); -}); +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + // Exercise the DMAP binary parser with arbitrary input. + // The parser should never panic — only return Ok or Err. + let _ = tributary::daap::dmap::parse_dmap(data); +}); diff --git a/hooks/pre-commit b/hooks/pre-commit old mode 100644 new mode 100755 index 3ba9ca29..979201d3 --- a/hooks/pre-commit +++ b/hooks/pre-commit @@ -1,36 +1,38 @@ -#!/bin/sh -# -# Git pre-commit hook for Tributary. -# -# Runs `cargo fmt --check` and aborts the commit if formatting issues -# are found. To enable this hook after cloning: -# -# git config core.hooksPath hooks -# -# To format your code before committing: -# -# cargo fmt -# # or on Windows via PowerShell: -# .\scripts\build-windows.ps1 -Fmt -# - -set -e - -echo "[pre-commit] Checking code formatting..." - -if ! cargo fmt --check 2>/dev/null; then - echo "" - echo "╔══════════════════════════════════════════════════════════╗" - echo "║ Commit blocked: code formatting issues detected. ║" - echo "║ ║" - echo "║ Run one of the following to fix: ║" - echo "║ cargo fmt ║" - echo "║ ./scripts/build-windows.ps1 -Fmt (Windows) ║" - echo "║ ║" - echo "║ Then stage the changes and try again. ║" - echo "╚══════════════════════════════════════════════════════════╝" - echo "" - exit 1 -fi - -echo "[pre-commit] Formatting OK." +#!/bin/sh +# +# Git pre-commit hook for Tributary. +# +# Runs `cargo fmt --check` and aborts the commit if formatting issues +# are found. To enable this hook after cloning: +# +# git config core.hooksPath hooks +# +# To format your code before committing: +# +# cargo fmt +# # or on Windows via PowerShell: +# .\scripts\build-windows.ps1 -Fmt +# + +set -e + +echo "[pre-commit] Checking code formatting..." + +if ! cargo fmt --check 2>/dev/null || + ! cargo fmt --manifest-path fuzz/Cargo.toml --package tributary-fuzz -- --check 2>/dev/null; then + echo "" + echo "╔══════════════════════════════════════════════════════════╗" + echo "║ Commit blocked: code formatting issues detected. ║" + echo "║ ║" + echo "║ Run one of the following to fix: ║" + echo "║ cargo fmt ║" + echo "║ cargo fmt --manifest-path fuzz/Cargo.toml ║" + echo "║ ./scripts/build-windows.ps1 -Fmt (Windows) ║" + echo "║ ║" + echo "║ Then stage the changes and try again. ║" + echo "╚══════════════════════════════════════════════════════════╝" + echo "" + exit 1 +fi + +echo "[pre-commit] Formatting OK." diff --git a/scripts/build-windows.ps1 b/scripts/build-windows.ps1 index 36c8b72e..836a245c 100644 --- a/scripts/build-windows.ps1 +++ b/scripts/build-windows.ps1 @@ -1391,18 +1391,22 @@ $env:PKG_CONFIG_PATH = $pkgConfigPath $env:PKG_CONFIG_ALLOW_CROSS = "1" $env:PATH = "$MsysPath\bin;" + $env:PATH -# Force Cargo to use MSYS2 tools instead of Rustup's incomplete bundled toolchain. +# Force target build scripts to use MSYS2 tools without overriding the native +# compiler used by host build dependencies. Generic CC/CXX/AR variables also +# apply to MSVC-hosted build scripts (for example `vswhom-sys`) and make them +# invoke the MinGW compiler without the Visual Studio include environment. +$ToolEnvTarget = $RustTarget.Replace("-", "_").Replace(".", "_") if ($MsysEnv -match "clang") { - $env:DLLTOOL = Join-Path $MsysPath "bin\llvm-dlltool.exe" - $env:CC = Join-Path $MsysPath "bin\clang.exe" - $env:CXX = Join-Path $MsysPath "bin\clang++.exe" - $env:AR = Join-Path $MsysPath "bin\llvm-ar.exe" + [Environment]::SetEnvironmentVariable("DLLTOOL_$ToolEnvTarget", (Join-Path $MsysPath "bin\llvm-dlltool.exe"), "Process") + [Environment]::SetEnvironmentVariable("CC_$ToolEnvTarget", (Join-Path $MsysPath "bin\clang.exe"), "Process") + [Environment]::SetEnvironmentVariable("CXX_$ToolEnvTarget", (Join-Path $MsysPath "bin\clang++.exe"), "Process") + [Environment]::SetEnvironmentVariable("AR_$ToolEnvTarget", (Join-Path $MsysPath "bin\llvm-ar.exe"), "Process") } else { - $env:DLLTOOL = Join-Path $MsysPath "bin\dlltool.exe" - $env:CC = Join-Path $MsysPath "bin\gcc.exe" - $env:CXX = Join-Path $MsysPath "bin\g++.exe" - $env:AR = Join-Path $MsysPath "bin\ar.exe" + [Environment]::SetEnvironmentVariable("DLLTOOL_$ToolEnvTarget", (Join-Path $MsysPath "bin\dlltool.exe"), "Process") + [Environment]::SetEnvironmentVariable("CC_$ToolEnvTarget", (Join-Path $MsysPath "bin\gcc.exe"), "Process") + [Environment]::SetEnvironmentVariable("CXX_$ToolEnvTarget", (Join-Path $MsysPath "bin\g++.exe"), "Process") + [Environment]::SetEnvironmentVariable("AR_$ToolEnvTarget", (Join-Path $MsysPath "bin\ar.exe"), "Process") } Write-Info "PKG_CONFIG_PATH set to $pkgConfigPath" diff --git a/src/audio/output.rs b/src/audio/output.rs index d5ef04bc..194835af 100644 --- a/src/audio/output.rs +++ b/src/audio/output.rs @@ -1,76 +1,76 @@ -//! Audio output abstraction layer. -//! -//! Defines the [`AudioOutput`] trait that all playback backends implement, -//! and the [`OutputTarget`] enum for identifying output types. -//! -//! # Current implementations -//! -//! - [`LocalOutput`](super::local_output::LocalOutput) — wraps the existing -//! GStreamer `playbin3` pipeline for local speaker output. -//! - [`MpdOutput`](super::mpd_output::MpdOutput) — sends MPD protocol -//! commands over TCP to a remote (or local) MPD server. -//! -//! - [`AirPlayOutput`](super::airplay_output::AirPlayOutput) — streams -//! to AirPlay (RAOP) receivers discovered via `_raop._tcp.local.` -//! mDNS browsing. Discovered devices appear automatically in the -//! output selector popover. -//! -//! - [`ChromecastOutput`](super::chromecast_output::ChromecastOutput) — -//! streams to Chromecast (audio) devices discovered via -//! `_googlecast._tcp.local.` mDNS browsing using the Cast V2 protocol -//! (`rust_cast` crate). Discovered devices appear automatically in -//! the output selector popover. -//! -//! # Architecture -//! -//! The output layer is strictly a **sink** abstraction. It controls -//! *where audio plays*, not *where music comes from*. Library sources -//! (local, Subsonic, Jellyfin, Plex, DAAP, radio) are managed by the -//! sidebar and are completely independent of the active output. - +//! Audio output abstraction layer. +//! +//! Defines the [`AudioOutput`] trait that all playback backends implement, +//! and the [`OutputTarget`] enum for identifying output types. +//! +//! # Current implementations +//! +//! - [`LocalOutput`](super::local_output::LocalOutput) — wraps the existing +//! GStreamer `playbin3` pipeline for local speaker output. +//! - [`MpdOutput`](super::mpd_output::MpdOutput) — sends MPD protocol +//! commands over TCP to a remote (or local) MPD server. +//! +//! - [`AirPlayOutput`](super::airplay_output::AirPlayOutput) — streams +//! to AirPlay (RAOP) receivers discovered via `_raop._tcp.local.` +//! mDNS browsing. Discovered devices appear automatically in the +//! output selector popover. +//! +//! - [`ChromecastOutput`](super::chromecast_output::ChromecastOutput) — +//! streams to Chromecast (audio) devices discovered via +//! `_googlecast._tcp.local.` mDNS browsing using the Cast V2 protocol +//! (`rust_cast` crate). Discovered devices appear automatically in +//! the output selector popover. +//! +//! # Architecture +//! +//! The output layer is strictly a **sink** abstraction. It controls +//! *where audio plays*, not *where music comes from*. Library sources +//! (local, Subsonic, Jellyfin, Plex, DAAP, radio) are managed by the +//! sidebar and are completely independent of the active output. + use super::{PlayerEventGeneration, PlayerState}; use crate::architecture::media::ResolvedHttpRequest; use crate::local::resolver::ResolvedLocalMedia; - -/// Identifies the type of an audio output for UI purposes (icon, label). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum OutputType { - /// Local GStreamer pipeline (system speakers / headphones). - Local, - /// MPD server (Music Player Daemon) over TCP. - Mpd, - /// AirPlay receiver (RAOP protocol) discovered via mDNS. - AirPlay, - /// Chromecast (Cast V2 protocol) discovered via mDNS. - Chromecast, - // Future: PulseAudio, PipeWire, JACK, … -} - -/// Trait that all audio output backends implement. -/// -/// All methods are designed to be called from the **GTK main thread**. -/// Implementations that perform network I/O (e.g. MPD) must handle -/// that internally without blocking the main thread. -pub trait AudioOutput { - /// Human-readable display name for the output selector UI. - /// - /// Examples: "My Computer", "Living Room MPD". Currently consumed - /// only by tests; the active-output label in the header bar is - /// driven from the source list rather than the output instance. - #[allow(dead_code)] - fn name(&self) -> &str; - - /// The output type, used for icon selection in the popover. - fn output_type(&self) -> OutputType; - - /// Whether this output supports application-controlled volume. - /// - /// When `false`, the header bar volume slider should be disabled - /// (greyed out). MPD manages its own volume independently. - fn supports_volume(&self) -> bool; - - // ── Playback controls ─────────────────────────────────────────── - + +/// Identifies the type of an audio output for UI purposes (icon, label). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OutputType { + /// Local GStreamer pipeline (system speakers / headphones). + Local, + /// MPD server (Music Player Daemon) over TCP. + Mpd, + /// AirPlay receiver (RAOP protocol) discovered via mDNS. + AirPlay, + /// Chromecast (Cast V2 protocol) discovered via mDNS. + Chromecast, + // Future: PulseAudio, PipeWire, JACK, … +} + +/// Trait that all audio output backends implement. +/// +/// All methods are designed to be called from the **GTK main thread**. +/// Implementations that perform network I/O (e.g. MPD) must handle +/// that internally without blocking the main thread. +pub trait AudioOutput { + /// Human-readable display name for the output selector UI. + /// + /// Examples: "My Computer", "Living Room MPD". Currently consumed + /// only by tests; the active-output label in the header bar is + /// driven from the source list rather than the output instance. + #[allow(dead_code)] + fn name(&self) -> &str; + + /// The output type, used for icon selection in the popover. + fn output_type(&self) -> OutputType; + + /// Whether this output supports application-controlled volume. + /// + /// When `false`, the header bar volume slider should be disabled + /// (greyed out). MPD manages its own volume independently. + fn supports_volume(&self) -> bool; + + // ── Playback controls ─────────────────────────────────────────── + /// Load a URI and start playback. /// /// `uri` may be a `file:///…` path or an `http(s)://…` stream URL. @@ -102,7 +102,7 @@ pub trait AudioOutput { /// authority alive for the complete output/ticket lifecycle and must not /// recover or reopen a pathname from generic queue state. fn load_local(&self, media: ResolvedLocalMedia) -> bool; - + /// Tag subsequent events with the playback load that owns them. /// /// The UI calls this immediately before `load_uri`. Implementations must @@ -111,40 +111,40 @@ pub trait AudioOutput { fn set_event_generation(&self, generation: PlayerEventGeneration); /// Resume playback from a paused state. - fn play(&self); - - /// Pause playback. - fn pause(&self); - - /// Stop playback and reset to idle. - fn stop(&self); - - /// Toggle between playing and paused states. - fn toggle_play_pause(&self); - - /// Seek to an absolute position (milliseconds from start). - fn seek_to(&self, position_ms: u64); - - // ── Volume ────────────────────────────────────────────────────── - - /// Set volume from a linear slider position (0.0–1.0). - /// - /// No-op if [`supports_volume`](Self::supports_volume) returns `false`. - fn set_volume(&mut self, level: f64); - - /// Current volume (0.0–1.0). - fn volume(&self) -> f64; - - // ── State queries ─────────────────────────────────────────────── - - /// Non-blocking query of the current playback state. - /// - /// Currently every output reports state asynchronously through the - /// `PlayerEvent` channel; this method exists for future on-demand - /// queries and is not yet polled by the UI. - #[allow(dead_code)] - fn state(&self) -> PlayerState; - - /// Current playback position in milliseconds, or `None` if unknown. - fn position_ms(&self) -> Option; -} + fn play(&self); + + /// Pause playback. + fn pause(&self); + + /// Stop playback and reset to idle. + fn stop(&self); + + /// Toggle between playing and paused states. + fn toggle_play_pause(&self); + + /// Seek to an absolute position (milliseconds from start). + fn seek_to(&self, position_ms: u64); + + // ── Volume ────────────────────────────────────────────────────── + + /// Set volume from a linear slider position (0.0–1.0). + /// + /// No-op if [`supports_volume`](Self::supports_volume) returns `false`. + fn set_volume(&mut self, level: f64); + + /// Current volume (0.0–1.0). + fn volume(&self) -> f64; + + // ── State queries ─────────────────────────────────────────────── + + /// Non-blocking query of the current playback state. + /// + /// Currently every output reports state asynchronously through the + /// `PlayerEvent` channel; this method exists for future on-demand + /// queries and is not yet polled by the UI. + #[allow(dead_code)] + fn state(&self) -> PlayerState; + + /// Current playback position in milliseconds, or `None` if unknown. + fn position_ms(&self) -> Option; +} diff --git a/src/radio/api.rs b/src/radio/api.rs index 87e2b064..5e908c37 100644 --- a/src/radio/api.rs +++ b/src/radio/api.rs @@ -1,114 +1,114 @@ -//! Radio-Browser API response types. -//! -//! Only the subset of fields Tributary uses are deserialized; -//! unknown fields are silently ignored via `serde(default)`. - -use serde::{Deserialize, Serialize}; - -/// A radio station from the Radio-Browser API. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RadioStation { - /// Unique station identifier. - pub stationuuid: String, - - /// Human-readable station name. - pub name: String, - - /// Resolved stream URL (may differ from the original `url`). - #[serde(default)] - pub url_resolved: String, - - /// Country name (e.g. "United States"). - #[serde(default)] - pub country: String, - - /// ISO 3166-1 alpha-2 country code (e.g. "US"). - #[serde(default)] - pub countrycode: String, - - /// State/province name (e.g. "Indiana"). - #[serde(default)] - pub state: String, - - /// Audio codec (e.g. "MP3", "AAC", "OGG"). - #[serde(default)] - pub codec: String, - - /// Stream bitrate in kbps. - #[serde(default)] - pub bitrate: u32, - - /// Comma-separated tags (e.g. "rock,alternative,indie"). - #[serde(default)] - pub tags: String, - - /// URL to the station's favicon/logo. - #[serde(default)] - pub favicon: String, - - /// Geographic latitude of the station. - #[serde(default)] - pub geo_lat: Option, - - /// Geographic longitude of the station. - #[serde(default)] - pub geo_long: Option, -} - -/// Geolocation result from the multi-provider cascade. -#[derive(Debug)] -pub struct GeoLocation { - pub latitude: f64, - pub longitude: f64, - pub country_code: String, - /// State/region name (e.g. "Indiana", "California"). - pub region: String, -} - -// ── Provider-specific response types (internal) ───────────────────── - -/// Response from ipapi.co (HTTPS, free tier). -#[derive(Debug, Deserialize)] -pub struct IpApiCoResponse { - #[serde(default)] - pub latitude: f64, - #[serde(default)] - pub longitude: f64, - #[serde(default)] - pub country_code: String, - /// State/region name (e.g. "California"). - #[serde(default)] - pub region: String, - #[serde(default)] - pub error: bool, -} - -/// Response from ipwho.is (HTTPS, free tier). -#[derive(Debug, Deserialize)] -pub struct IpWhoIsResponse { - #[serde(default)] - pub success: bool, - #[serde(default)] - pub latitude: f64, - #[serde(default)] - pub longitude: f64, - #[serde(default)] - pub country_code: String, - /// State/region name. - #[serde(default)] - pub region: String, -} - -/// Response from freeipapi.com (HTTPS, free tier). -#[derive(Debug, Deserialize)] -pub struct FreeIpApiResponse { - #[serde(default)] - pub latitude: f64, - #[serde(default)] - pub longitude: f64, - #[serde(default, rename = "countryCode")] - pub country_code: String, - /// State/region name. - #[serde(default, rename = "regionName")] - pub region: String, -} +//! Radio-Browser API response types. +//! +//! Only the subset of fields Tributary uses are deserialized; +//! unknown fields are silently ignored via `serde(default)`. + +use serde::{Deserialize, Serialize}; + +/// A radio station from the Radio-Browser API. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RadioStation { + /// Unique station identifier. + pub stationuuid: String, + + /// Human-readable station name. + pub name: String, + + /// Resolved stream URL (may differ from the original `url`). + #[serde(default)] + pub url_resolved: String, + + /// Country name (e.g. "United States"). + #[serde(default)] + pub country: String, + + /// ISO 3166-1 alpha-2 country code (e.g. "US"). + #[serde(default)] + pub countrycode: String, + + /// State/province name (e.g. "Indiana"). + #[serde(default)] + pub state: String, + + /// Audio codec (e.g. "MP3", "AAC", "OGG"). + #[serde(default)] + pub codec: String, + + /// Stream bitrate in kbps. + #[serde(default)] + pub bitrate: u32, + + /// Comma-separated tags (e.g. "rock,alternative,indie"). + #[serde(default)] + pub tags: String, + + /// URL to the station's favicon/logo. + #[serde(default)] + pub favicon: String, + + /// Geographic latitude of the station. + #[serde(default)] + pub geo_lat: Option, + + /// Geographic longitude of the station. + #[serde(default)] + pub geo_long: Option, +} + +/// Geolocation result from the multi-provider cascade. +#[derive(Debug)] +pub struct GeoLocation { + pub latitude: f64, + pub longitude: f64, + pub country_code: String, + /// State/region name (e.g. "Indiana", "California"). + pub region: String, +} + +// ── Provider-specific response types (internal) ───────────────────── + +/// Response from ipapi.co (HTTPS, free tier). +#[derive(Debug, Deserialize)] +pub struct IpApiCoResponse { + #[serde(default)] + pub latitude: f64, + #[serde(default)] + pub longitude: f64, + #[serde(default)] + pub country_code: String, + /// State/region name (e.g. "California"). + #[serde(default)] + pub region: String, + #[serde(default)] + pub error: bool, +} + +/// Response from ipwho.is (HTTPS, free tier). +#[derive(Debug, Deserialize)] +pub struct IpWhoIsResponse { + #[serde(default)] + pub success: bool, + #[serde(default)] + pub latitude: f64, + #[serde(default)] + pub longitude: f64, + #[serde(default)] + pub country_code: String, + /// State/region name. + #[serde(default)] + pub region: String, +} + +/// Response from freeipapi.com (HTTPS, free tier). +#[derive(Debug, Deserialize)] +pub struct FreeIpApiResponse { + #[serde(default)] + pub latitude: f64, + #[serde(default)] + pub longitude: f64, + #[serde(default, rename = "countryCode")] + pub country_code: String, + /// State/region name. + #[serde(default, rename = "regionName")] + pub region: String, +} diff --git a/src/ui/header_bar.rs b/src/ui/header_bar.rs index 79b0f80e..f7ce3bdb 100644 --- a/src/ui/header_bar.rs +++ b/src/ui/header_bar.rs @@ -300,8 +300,6 @@ pub fn build_header_bar() -> HeaderBarWidgets { // Modern GNOME primary menu (Ptyxis-style) let menu = gtk::gio::Menu::new(); let section1 = gtk::gio::Menu::new(); - let migrate_label = rust_i18n::t!("rhythmbox_migration.menu_action"); - section1.append(Some(migrate_label.as_ref()), Some("win.migrate-rhythmbox")); section1.append(Some("_Preferences"), Some("win.show-preferences")); section1.append(Some("_About Tributary"), Some("app.about")); menu.append_section(None, §ion1); diff --git a/src/ui/persistence.rs b/src/ui/persistence.rs index 26e777f6..988afbc4 100644 --- a/src/ui/persistence.rs +++ b/src/ui/persistence.rs @@ -1,197 +1,197 @@ -//! Settings persistence helpers — playback modes, sort state, CSS, HWND. -//! -//! All functions use best-effort file I/O (silently ignore errors) to -//! persist small state values to `/tributary/`. - -use adw::prelude::*; - -use crate::ui::header_bar::RepeatMode; - -// ── Settings file helpers ─────────────────────────────────────────── - -fn settings_path(name: &str) -> Option { - dirs::data_dir().map(|d| d.join("tributary").join(name)) -} - -/// Ensure the tributary data directory exists, then write a settings file. -/// Silently ignores errors (best-effort persistence). -fn write_setting(name: &str, content: &str) { - if let Some(path) = settings_path(name) { - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } - let _ = std::fs::write(path, content); - } -} - -// ── Repeat mode ───────────────────────────────────────────────────── - -pub fn load_repeat_mode() -> RepeatMode { - settings_path("repeat") - .and_then(|p| std::fs::read_to_string(p).ok()) - .map(|s| match s.trim() { - "all" => RepeatMode::All, - "one" => RepeatMode::One, - _ => RepeatMode::Off, - }) - .unwrap_or(RepeatMode::Off) -} - -pub fn save_repeat_mode(mode: RepeatMode) { - let s = match mode { - RepeatMode::Off => "off", - RepeatMode::All => "all", - RepeatMode::One => "one", - }; - write_setting("repeat", s); -} - -// ── Shuffle ───────────────────────────────────────────────────────── - -pub fn load_shuffle() -> bool { - settings_path("shuffle") - .and_then(|p| std::fs::read_to_string(p).ok()) - .map(|s| s.trim() == "true") - .unwrap_or(false) -} - -pub fn save_shuffle(active: bool) { - write_setting("shuffle", if active { "true" } else { "false" }); -} - -// ── Column sort state ─────────────────────────────────────────────── - -pub fn save_sort_state(column_view: >k::ColumnView) { - let Some(sorter) = column_view.sorter() else { - return; - }; - let Some(cv_sorter) = sorter.downcast_ref::() else { - return; - }; - - match cv_sorter.primary_sort_column() { - Some(column) => { - let title = column.title().map(|t| t.to_string()).unwrap_or_default(); - let dir = match cv_sorter.primary_sort_order() { - gtk::SortType::Descending => "desc", - _ => "asc", - }; - write_setting("sort", &format!("{title}\n{dir}")); - } - None => { - // No active sort — remove saved state. - if let Some(path) = settings_path("sort") { - let _ = std::fs::remove_file(path); - } - } - } -} - -pub fn restore_sort_state(column_view: >k::ColumnView) { - let Some(text) = settings_path("sort").and_then(|p| std::fs::read_to_string(p).ok()) else { - return; - }; - let mut lines = text.lines(); - let Some(title) = lines.next() else { return }; - let order = match lines.next() { - Some("desc") => gtk::SortType::Descending, - _ => gtk::SortType::Ascending, - }; - - let columns = column_view.columns(); - for i in 0..columns.n_items() { - if let Some(col) = columns.item(i) { - let Some(col) = col.downcast_ref::() else { - continue; - }; - if col.title().is_some_and(|t| t == title) { - column_view.sort_by_column(Some(col), order); - return; - } - } - } -} - -// ── CSS loading ───────────────────────────────────────────────────── - -/// Load the custom CSS from the embedded stylesheet. -pub fn load_css() { - let provider = gtk::CssProvider::new(); - provider.load_from_string(include_str!("style.css")); - - gtk::style_context_add_provider_for_display( - >k::gdk::Display::default().expect("Could not get default display"), - &provider, - gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, - ); -} - -// ── Window geometry persistence ───────────────────────────────── - -/// Sane bounds for a persisted window dimension. Values outside this range -/// (0, negative, or absurdly large) usually mean a corrupted or hand-edited -/// `window.json`; they are replaced with a default so the app never launches -/// with a degenerate / off-screen window or feeds nonsensical coordinates -/// into the Win32 Snap-Layout hit-test math. -const MIN_WINDOW_DIM: i32 = 200; -const MAX_WINDOW_DIM: i32 = 20_000; -const DEFAULT_WINDOW_WIDTH: i32 = 1400; -const DEFAULT_WINDOW_HEIGHT: i32 = 850; - -/// Persisted window size + maximized state. -#[derive(serde::Serialize, serde::Deserialize)] -pub struct WindowGeometry { - pub width: i32, - pub height: i32, - pub is_maximized: bool, -} - -/// Save window geometry to disk. -pub fn save_window_geometry(window: &adw::ApplicationWindow) { - let (width, height) = window.default_size(); - let geo = WindowGeometry { - width, - height, - is_maximized: window.is_maximized(), - }; - if let Ok(json) = serde_json::to_string(&geo) { - write_setting("window.json", &json); - } -} - -/// Load persisted window geometry, if any. -/// -/// Validates the deserialized dimensions: structurally-valid JSON with -/// out-of-range numbers (0, negative, or enormous) is clamped to a default -/// rather than applied verbatim. -pub fn load_window_geometry() -> Option { - let mut geo: WindowGeometry = settings_path("window.json") - .and_then(|p| std::fs::read_to_string(p).ok()) - .and_then(|s| serde_json::from_str(&s).ok())?; - - if !(MIN_WINDOW_DIM..=MAX_WINDOW_DIM).contains(&geo.width) { - geo.width = DEFAULT_WINDOW_WIDTH; - } - if !(MIN_WINDOW_DIM..=MAX_WINDOW_DIM).contains(&geo.height) { - geo.height = DEFAULT_WINDOW_HEIGHT; - } - Some(geo) -} - -// ── Native window handle extraction ───────────────────────────── - -/// Extract the native window handle for `souvlaki`. -#[cfg(target_os = "windows")] -pub fn extract_hwnd(window: &adw::ApplicationWindow) -> Option<*mut std::ffi::c_void> { - use gtk::prelude::NativeExt; - - let surface = window.surface()?; - let win32_surface = surface.downcast_ref::()?; - let hwnd = win32_surface.handle(); - Some(hwnd.0) -} - -#[cfg(not(target_os = "windows"))] -pub fn extract_hwnd(_window: &adw::ApplicationWindow) -> Option<*mut std::ffi::c_void> { - None -} +//! Settings persistence helpers — playback modes, sort state, CSS, HWND. +//! +//! All functions use best-effort file I/O (silently ignore errors) to +//! persist small state values to `/tributary/`. + +use adw::prelude::*; + +use crate::ui::header_bar::RepeatMode; + +// ── Settings file helpers ─────────────────────────────────────────── + +fn settings_path(name: &str) -> Option { + dirs::data_dir().map(|d| d.join("tributary").join(name)) +} + +/// Ensure the tributary data directory exists, then write a settings file. +/// Silently ignores errors (best-effort persistence). +fn write_setting(name: &str, content: &str) { + if let Some(path) = settings_path(name) { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write(path, content); + } +} + +// ── Repeat mode ───────────────────────────────────────────────────── + +pub fn load_repeat_mode() -> RepeatMode { + settings_path("repeat") + .and_then(|p| std::fs::read_to_string(p).ok()) + .map(|s| match s.trim() { + "all" => RepeatMode::All, + "one" => RepeatMode::One, + _ => RepeatMode::Off, + }) + .unwrap_or(RepeatMode::Off) +} + +pub fn save_repeat_mode(mode: RepeatMode) { + let s = match mode { + RepeatMode::Off => "off", + RepeatMode::All => "all", + RepeatMode::One => "one", + }; + write_setting("repeat", s); +} + +// ── Shuffle ───────────────────────────────────────────────────────── + +pub fn load_shuffle() -> bool { + settings_path("shuffle") + .and_then(|p| std::fs::read_to_string(p).ok()) + .map(|s| s.trim() == "true") + .unwrap_or(false) +} + +pub fn save_shuffle(active: bool) { + write_setting("shuffle", if active { "true" } else { "false" }); +} + +// ── Column sort state ─────────────────────────────────────────────── + +pub fn save_sort_state(column_view: >k::ColumnView) { + let Some(sorter) = column_view.sorter() else { + return; + }; + let Some(cv_sorter) = sorter.downcast_ref::() else { + return; + }; + + match cv_sorter.primary_sort_column() { + Some(column) => { + let title = column.title().map(|t| t.to_string()).unwrap_or_default(); + let dir = match cv_sorter.primary_sort_order() { + gtk::SortType::Descending => "desc", + _ => "asc", + }; + write_setting("sort", &format!("{title}\n{dir}")); + } + None => { + // No active sort — remove saved state. + if let Some(path) = settings_path("sort") { + let _ = std::fs::remove_file(path); + } + } + } +} + +pub fn restore_sort_state(column_view: >k::ColumnView) { + let Some(text) = settings_path("sort").and_then(|p| std::fs::read_to_string(p).ok()) else { + return; + }; + let mut lines = text.lines(); + let Some(title) = lines.next() else { return }; + let order = match lines.next() { + Some("desc") => gtk::SortType::Descending, + _ => gtk::SortType::Ascending, + }; + + let columns = column_view.columns(); + for i in 0..columns.n_items() { + if let Some(col) = columns.item(i) { + let Some(col) = col.downcast_ref::() else { + continue; + }; + if col.title().is_some_and(|t| t == title) { + column_view.sort_by_column(Some(col), order); + return; + } + } + } +} + +// ── CSS loading ───────────────────────────────────────────────────── + +/// Load the custom CSS from the embedded stylesheet. +pub fn load_css() { + let provider = gtk::CssProvider::new(); + provider.load_from_string(include_str!("style.css")); + + gtk::style_context_add_provider_for_display( + >k::gdk::Display::default().expect("Could not get default display"), + &provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); +} + +// ── Window geometry persistence ───────────────────────────────── + +/// Sane bounds for a persisted window dimension. Values outside this range +/// (0, negative, or absurdly large) usually mean a corrupted or hand-edited +/// `window.json`; they are replaced with a default so the app never launches +/// with a degenerate / off-screen window or feeds nonsensical coordinates +/// into the Win32 Snap-Layout hit-test math. +const MIN_WINDOW_DIM: i32 = 200; +const MAX_WINDOW_DIM: i32 = 20_000; +const DEFAULT_WINDOW_WIDTH: i32 = 1400; +const DEFAULT_WINDOW_HEIGHT: i32 = 850; + +/// Persisted window size + maximized state. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct WindowGeometry { + pub width: i32, + pub height: i32, + pub is_maximized: bool, +} + +/// Save window geometry to disk. +pub fn save_window_geometry(window: &adw::ApplicationWindow) { + let (width, height) = window.default_size(); + let geo = WindowGeometry { + width, + height, + is_maximized: window.is_maximized(), + }; + if let Ok(json) = serde_json::to_string(&geo) { + write_setting("window.json", &json); + } +} + +/// Load persisted window geometry, if any. +/// +/// Validates the deserialized dimensions: structurally-valid JSON with +/// out-of-range numbers (0, negative, or enormous) is clamped to a default +/// rather than applied verbatim. +pub fn load_window_geometry() -> Option { + let mut geo: WindowGeometry = settings_path("window.json") + .and_then(|p| std::fs::read_to_string(p).ok()) + .and_then(|s| serde_json::from_str(&s).ok())?; + + if !(MIN_WINDOW_DIM..=MAX_WINDOW_DIM).contains(&geo.width) { + geo.width = DEFAULT_WINDOW_WIDTH; + } + if !(MIN_WINDOW_DIM..=MAX_WINDOW_DIM).contains(&geo.height) { + geo.height = DEFAULT_WINDOW_HEIGHT; + } + Some(geo) +} + +// ── Native window handle extraction ───────────────────────────── + +/// Extract the native window handle for `souvlaki`. +#[cfg(target_os = "windows")] +pub fn extract_hwnd(window: &adw::ApplicationWindow) -> Option<*mut std::ffi::c_void> { + use gtk::prelude::NativeExt; + + let surface = window.surface()?; + let win32_surface = surface.downcast_ref::()?; + let hwnd = win32_surface.handle(); + Some(hwnd.0) +} + +#[cfg(not(target_os = "windows"))] +pub fn extract_hwnd(_window: &adw::ApplicationWindow) -> Option<*mut std::ffi::c_void> { + None +} diff --git a/src/ui/preferences.rs b/src/ui/preferences.rs index bc4377a1..78a32945 100644 --- a/src/ui/preferences.rs +++ b/src/ui/preferences.rs @@ -745,7 +745,17 @@ pub fn show_preferences( }); } + let import_rhythmbox_btn = adw::ButtonRow::builder() + .title(rust_i18n::t!("rhythmbox_migration.menu_action").as_ref()) + .start_icon_name("document-open-symbolic") + // Reuse the window action so the Preferences entry follows the same + // admission, shutdown, and migration-dialog path as the former menu + // item. + .action_name("win.migrate-rhythmbox") + .build(); + library_group.add(&library_box); + library_group.add(&import_rhythmbox_btn); page.add(&library_group); // ── Browser Views group (dense horizontal checkboxes) ─────────── diff --git a/src/ui/rhythmbox_migration.rs b/src/ui/rhythmbox_migration.rs index f588c132..6a51d9e8 100644 --- a/src/ui/rhythmbox_migration.rs +++ b/src/ui/rhythmbox_migration.rs @@ -1827,7 +1827,7 @@ mod tests { let prefix = concat!("rhythmbox", "_migration."); [ include_str!("rhythmbox_migration.rs"), - include_str!("header_bar.rs"), + include_str!("preferences.rs"), ] .into_iter() .flat_map(|source| { diff --git a/src/ui/win32_snap.rs b/src/ui/win32_snap.rs index 06854493..f4619f53 100644 --- a/src/ui/win32_snap.rs +++ b/src/ui/win32_snap.rs @@ -3,34 +3,46 @@ //! GTK4's Client-Side Decorations draw their own title bar, so Windows //! doesn't recognise the maximize button for Snap Layouts. This module //! installs a Win32 window subclass that returns `HTMAXBUTTON` when the -//! cursor hovers over the maximize button area, enabling the native -//! Windows 11 Snap Layout flyout. +//! cursor hovers over GTK's actual maximize button allocation. This gives +//! the Windows shell the documented hook it needs to offer Snap Layouts. //! //! # Safety //! //! The `unsafe` surface is minimal and well-contained: -//! - A stateless `extern "system"` callback (~15 lines). +//! - An `extern "system"` callback backed by per-HWND immutable ownership. //! - `SetWindowSubclass` / `RemoveWindowSubclass` lifecycle. -//! - No memory allocation, no pointer arithmetic, no closures captured. +//! - No GTK calls, Rust heap allocation, or panic paths inside the callback. //! -//! The callback is read-only (hit-test override). Worst case: snap menu -//! doesn't appear and `DefSubclassProc` handles the message normally. +//! The callback never changes frame styles. It owns maximize-button clicks and +//! constrains the maximized work-area rectangle. During GTK's manual CSD sizing +//! loop it clips only a dragged edge that enters a taskbar/appbar strip; it +//! deliberately leaves all other tracking dimensions unrestricted. #![cfg(target_os = "windows")] use std::ffi::c_void; -use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +use adw::prelude::*; +use gtk::glib; // Win32 constants. const WM_NCHITTEST: u32 = 0x0084; const WM_NCDESTROY: u32 = 0x0082; +const WM_NCMOUSELEAVE: u32 = 0x02A2; const WM_GETMINMAXINFO: u32 = 0x0024; +const WM_WINDOWPOSCHANGING: u32 = 0x0046; +const WM_NCLBUTTONDOWN: u32 = 0x00A1; +const WM_NCLBUTTONUP: u32 = 0x00A2; const HTMAXBUTTON: isize = 9; const MONITOR_DEFAULTTONEAREST: u32 = 0x0002; - -const GWL_STYLE: i32 = -16; -const GWL_EXSTYLE: i32 = -20; +const SW_MAXIMIZE: i32 = 3; +const SW_RESTORE: i32 = 9; +const SWP_NOSIZE: u32 = 0x0001; +const SWP_NOMOVE: u32 = 0x0002; +const VK_LBUTTON: i32 = 0x01; const SUBCLASS_ID: usize = 0x5472_6962; // "Trib" in hex @@ -76,25 +88,40 @@ extern "system" { #[allow(non_snake_case)] extern "system" { + fn GetAsyncKeyState(virtual_key: i32) -> i16; + fn GetCapture() -> *mut c_void; fn GetCursorPos(point: *mut Point) -> i32; + fn GetWindowRect(hwnd: *mut c_void, rect: *mut Rect) -> i32; fn ScreenToClient(hwnd: *mut c_void, point: *mut Point) -> i32; - fn MonitorFromWindow(hwnd: *mut c_void, dwFlags: u32) -> *mut c_void; - fn GetMonitorInfoW(hMonitor: *mut c_void, lpmi: *mut MonitorInfo) -> i32; - fn GetWindowLongPtrW(hwnd: *mut c_void, nIndex: i32) -> isize; - fn GetDpiForWindow(hwnd: *mut c_void) -> u32; + fn MonitorFromPoint(point: Point, flags: u32) -> *mut c_void; + fn MonitorFromWindow(hwnd: *mut c_void, flags: u32) -> *mut c_void; + fn GetMonitorInfoW(monitor: *mut c_void, info: *mut MonitorInfo) -> i32; + fn IsZoomed(hwnd: *mut c_void) -> i32; + fn ShowWindow(hwnd: *mut c_void, command: i32) -> i32; +} + +#[link(name = "dwmapi")] +#[allow(non_snake_case)] +extern "system" { + fn DwmDefWindowProc( + hwnd: *mut c_void, + msg: u32, + wparam: usize, + lparam: isize, + result: *mut isize, + ) -> i32; } /// Win32 POINT structure (client coordinates). #[repr(C)] -#[derive(Default)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] struct Point { x: i32, y: i32, } -/// Win32 RECT structure. #[repr(C)] -#[derive(Default, Clone, Copy)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] struct Rect { left: i32, top: i32, @@ -102,80 +129,599 @@ struct Rect { bottom: i32, } -/// Win32 MONITORINFO structure. #[repr(C)] struct MonitorInfo { - cb_size: u32, - rc_monitor: Rect, - rc_work: Rect, - dw_flags: u32, + size: u32, + monitor: Rect, + work_area: Rect, + flags: u32, } -/// Win32 MINMAXINFO structure (passed via `lparam` on `WM_GETMINMAXINFO`). #[repr(C)] struct MinMaxInfo { - pt_reserved: Point, - pt_max_size: Point, - pt_max_position: Point, - pt_min_track_size: Point, - pt_max_track_size: Point, + reserved: Point, + max_size: Point, + max_position: Point, + min_track_size: Point, + max_track_size: Point, +} + +#[repr(C)] +struct WindowPos { + hwnd: *mut c_void, + insert_after: *mut c_void, + x: i32, + y: i32, + width: i32, + height: i32, + flags: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum HorizontalEdge { + Left, + Right, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum VerticalEdge { + Top, + Bottom, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct SizingEdges { + horizontal: Option, + vertical: Option, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct CsdInsets { + left: i32, + right: i32, + top: i32, + bottom: i32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct HitRect { + left: i32, + top: i32, + right: i32, + bottom: i32, +} + +impl HitRect { + fn contains(self, point: Point) -> bool { + point.x >= self.left && point.x < self.right && point.y >= self.top && point.y < self.bottom + } +} + +struct SnapState { + maximize_rect: Mutex>, + csd_insets: Mutex, + update_queued: AtomicBool, +} + +impl SnapState { + fn new() -> Self { + Self { + maximize_rect: Mutex::new(None), + csd_insets: Mutex::new(CsdInsets::default()), + update_queued: AtomicBool::new(false), + } + } + + fn set_maximize_rect(&self, rect: Option) { + if let Ok(mut current) = self.maximize_rect.lock() { + *current = rect; + } + } + + fn is_over_maximize(&self, point: Point) -> bool { + self.maximize_rect + .lock() + .ok() + .and_then(|rect| *rect) + .is_some_and(|rect| rect.contains(point)) + } + + fn set_csd_insets(&self, insets: CsdInsets) { + if let Ok(mut current) = self.csd_insets.lock() { + *current = insets; + } + } + + fn csd_insets(&self) -> CsdInsets { + self.csd_insets + .lock() + .map_or_else(|_| CsdInsets::default(), |insets| *insets) + } } -/// Stored maximize button rectangle (client coordinates). -/// Updated by the GTK side whenever the header bar layout changes. -static MAX_BUTTON_RECT: Mutex> = Mutex::new(None); +fn find_maximize_button(widget: >k::Widget) -> Option { + if widget.has_css_class("maximize") && widget.downcast_ref::().is_some() { + return Some(widget.clone()); + } + + let mut child = widget.first_child(); + while let Some(current) = child { + if let Some(button) = find_maximize_button(¤t) { + return Some(button); + } + child = current.next_sibling(); + } + None +} + +fn physical_hit_rect( + logical_x: f64, + logical_y: f64, + logical_width: f64, + logical_height: f64, + surface_transform: (f64, f64), + scale: f64, +) -> Option { + if logical_width <= 0.0 || logical_height <= 0.0 { + return None; + } + + let scale = scale.max(1.0); + // GtkNative's surface transform is the translation GTK itself applies + // when snapshotting widget coordinates into its GdkSurface. Add it before + // converting the logical surface rectangle to Win32 client pixels. + let left = ((logical_x + surface_transform.0) * scale).floor() as i32; + let top = ((logical_y + surface_transform.1) * scale).floor() as i32; + let right = ((logical_x + logical_width + surface_transform.0) * scale).ceil() as i32; + let bottom = ((logical_y + logical_height + surface_transform.1) * scale).ceil() as i32; + + (right > left && bottom > top).then_some(HitRect { + left, + top, + right, + bottom, + }) +} + +fn physical_csd_insets( + surface_width: i32, + surface_height: i32, + widget_width: i32, + widget_height: i32, + surface_transform: (f64, f64), + scale: f64, +) -> CsdInsets { + let scale = scale.max(1.0); + let left = surface_transform.0.max(0.0); + let top = surface_transform.1.max(0.0); + let right = (f64::from(surface_width) - left - f64::from(widget_width)).max(0.0); + let bottom = (f64::from(surface_height) - top - f64::from(widget_height)).max(0.0); + + // Round toward the widget on every edge: an uncertain fractional pixel is + // kept out of the appbar strip rather than allowing painted content into it. + CsdInsets { + left: (left * scale).floor() as i32, + right: (right * scale).floor() as i32, + top: (top * scale).floor() as i32, + bottom: (bottom * scale).floor() as i32, + } +} + +fn current_csd_insets(window: &adw::ApplicationWindow) -> CsdInsets { + let Some(surface) = window.surface() else { + return CsdInsets::default(); + }; + + physical_csd_insets( + surface.width(), + surface.height(), + window.width(), + window.height(), + window.surface_transform(), + surface.scale(), + ) +} + +fn current_maximize_rect( + window: &adw::ApplicationWindow, + header: &adw::HeaderBar, +) -> Option { + let button = find_maximize_button(header.upcast_ref())?; + if !button.is_mapped() { + return None; + } + + let bounds = button.compute_bounds(window)?; + let surface = window.surface()?; + physical_hit_rect( + f64::from(bounds.x()), + f64::from(bounds.y()), + f64::from(bounds.width()), + f64::from(bounds.height()), + window.surface_transform(), + surface.scale(), + ) +} + +fn queue_maximize_rect_update( + window: &adw::ApplicationWindow, + header: &adw::HeaderBar, + state: &Arc, +) { + if state.update_queued.swap(true, Ordering::AcqRel) { + return; + } + + let window = window.downgrade(); + let header = header.downgrade(); + let state = Arc::clone(state); + glib::idle_add_local_once(move || { + state.update_queued.store(false, Ordering::Release); + let Some((window, header)) = window.upgrade().zip(header.upgrade()) else { + state.set_maximize_rect(None); + state.set_csd_insets(CsdInsets::default()); + return; + }; + + state.set_maximize_rect(current_maximize_rect(&window, &header)); + state.set_csd_insets(current_csd_insets(&window)); + }); +} + +fn track_maximize_rect( + window: &adw::ApplicationWindow, + header: &adw::HeaderBar, + state: &Arc, +) { + if let Some(surface) = window.surface() { + let window_weak = window.downgrade(); + let header_weak = header.downgrade(); + let state_for_layout = Arc::clone(state); + surface.connect_layout(move |_, _, _| { + if let Some((window, header)) = window_weak.upgrade().zip(header_weak.upgrade()) { + queue_maximize_rect_update(&window, &header, &state_for_layout); + } + }); + + let window_weak = window.downgrade(); + let header_weak = header.downgrade(); + let state_for_scale = Arc::clone(state); + surface.connect_scale_notify(move |_| { + if let Some((window, header)) = window_weak.upgrade().zip(header_weak.upgrade()) { + queue_maximize_rect_update(&window, &header, &state_for_scale); + } + }); + } + + let header_weak = header.downgrade(); + let state_for_maximize = Arc::clone(state); + window.connect_maximized_notify(move |window| { + if let Some(header) = header_weak.upgrade() { + queue_maximize_rect_update(window, &header, &state_for_maximize); + } + }); + + let window_weak = window.downgrade(); + let state_for_layout = Arc::clone(state); + header.connect_decoration_layout_notify(move |header| { + if let Some(window) = window_weak.upgrade() { + queue_maximize_rect_update(&window, header, &state_for_layout); + } + }); + + queue_maximize_rect_update(window, header, state); +} + +fn apply_monitor_work_area(info: &mut MinMaxInfo, monitor: Rect, work_area: Rect) { + info.max_position.x = work_area.left - monitor.left; + info.max_position.y = work_area.top - monitor.top; + info.max_size.x = work_area.right - work_area.left; + info.max_size.y = work_area.bottom - work_area.top; +} + +fn apply_sizing_work_area( + rect: &mut Rect, + edges: SizingEdges, + monitor: Rect, + work_area: Rect, + insets: CsdInsets, +) -> bool { + let original = *rect; + + // Only an inset between rcMonitor and rcWork represents a taskbar or + // another docked appbar. Do not clamp ordinary monitor edges: doing so + // would prevent a restored window from spanning monitors and would + // recreate the portrait-monitor width regression caused by globally + // capping MINMAXINFO::max_track_size. + if work_area.left > monitor.left && edges.horizontal == Some(HorizontalEdge::Left) { + rect.left = rect.left.max(work_area.left.saturating_sub(insets.left)); + } + if work_area.right < monitor.right && edges.horizontal == Some(HorizontalEdge::Right) { + rect.right = rect.right.min(work_area.right.saturating_add(insets.right)); + } + if work_area.top > monitor.top && edges.vertical == Some(VerticalEdge::Top) { + rect.top = rect.top.max(work_area.top.saturating_sub(insets.top)); + } + if work_area.bottom < monitor.bottom && edges.vertical == Some(VerticalEdge::Bottom) { + rect.bottom = rect + .bottom + .min(work_area.bottom.saturating_add(insets.bottom)); + } + + *rect != original +} + +fn active_axis_edge( + current_start: i32, + current_end: i32, + proposed_start: i32, + proposed_end: i32, + cursor: i32, + start_edge: T, + end_edge: T, +) -> Option { + let current_size = current_end - current_start; + let proposed_size = proposed_end - proposed_start; + if current_size == proposed_size { + return None; + } + + match (proposed_start != current_start, proposed_end != current_end) { + (true, false) => Some(start_edge), + (false, true) => Some(end_edge), + _ => { + // Style/DPI bookkeeping can shift both outer bounds by a pixel. + // In that ambiguous case, the pointer remains on the edge GTK is + // actively dragging, so proximity identifies the intended side. + let start_distance = (i64::from(cursor) - i64::from(proposed_start)).abs(); + let end_distance = (i64::from(cursor) - i64::from(proposed_end)).abs(); + Some(if start_distance <= end_distance { + start_edge + } else { + end_edge + }) + } + } +} + +fn active_sizing_edges(current: Rect, proposed: Rect, cursor: Point) -> SizingEdges { + SizingEdges { + horizontal: active_axis_edge( + current.left, + current.right, + proposed.left, + proposed.right, + cursor.x, + HorizontalEdge::Left, + HorizontalEdge::Right, + ), + vertical: active_axis_edge( + current.top, + current.bottom, + proposed.top, + proposed.bottom, + cursor.y, + VerticalEdge::Top, + VerticalEdge::Bottom, + ), + } +} + +fn proposed_window_rect(current: Rect, position: &WindowPos) -> Rect { + let left = if position.flags & SWP_NOMOVE != 0 { + current.left + } else { + position.x + }; + let top = if position.flags & SWP_NOMOVE != 0 { + current.top + } else { + position.y + }; + + Rect { + left, + top, + right: left.saturating_add(position.width), + bottom: top.saturating_add(position.height), + } +} + +fn rect_size(rect: Rect) -> (i32, i32) { + ( + rect.right.saturating_sub(rect.left), + rect.bottom.saturating_sub(rect.top), + ) +} + +fn apply_window_pos(position: &mut WindowPos, rect: Rect) { + if position.flags & SWP_NOMOVE == 0 { + position.x = rect.left; + position.y = rect.top; + } + position.width = rect.right.saturating_sub(rect.left); + position.height = rect.bottom.saturating_sub(rect.top); +} + +unsafe fn window_rect(hwnd: *mut c_void) -> Option { + let mut rect = Rect::default(); + // SAFETY: hwnd is live and rect points to writable RECT storage. + (unsafe { GetWindowRect(hwnd, &raw mut rect) } != 0).then_some(rect) +} + +unsafe fn cursor_monitor_info() -> Option<(Point, MonitorInfo)> { + let mut cursor = Point::default(); + // SAFETY: cursor points to writable storage for a screen-space POINT. + if unsafe { GetCursorPos(&raw mut cursor) } == 0 { + return None; + } + + // The sizing cursor identifies which monitor's taskbar matters when the + // proposed window spans monitors. These APIs all use physical screen + // coordinates, so no GTK scale or DPI conversion belongs here. + // SAFETY: MONITOR_DEFAULTTONEAREST guarantees a monitor for a valid point. + let monitor = unsafe { MonitorFromPoint(cursor, MONITOR_DEFAULTTONEAREST) }; + if monitor.is_null() { + return None; + } + + let mut info = MonitorInfo { + size: std::mem::size_of::() as u32, + monitor: Rect::default(), + work_area: Rect::default(), + flags: 0, + }; + // SAFETY: monitor is valid and info has the documented size. + (unsafe { GetMonitorInfoW(monitor, &raw mut info) } != 0).then_some((cursor, info)) +} + +unsafe fn constrain_maximized_window_to_work_area(hwnd: *mut c_void, lparam: isize) { + if lparam == 0 { + return; + } + + // SAFETY: hwnd belongs to the current window procedure. The nearest + // monitor fallback also covers transient monitor reconfiguration. + let monitor = unsafe { MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST) }; + if monitor.is_null() { + return; + } + + let mut monitor_info = MonitorInfo { + size: std::mem::size_of::() as u32, + monitor: Rect::default(), + work_area: Rect::default(), + flags: 0, + }; + // SAFETY: monitor is valid and monitor_info has the documented size. + if unsafe { GetMonitorInfoW(monitor, &raw mut monitor_info) } == 0 { + return; + } + + // SAFETY: WM_GETMINMAXINFO supplies a writable MINMAXINFO pointer. GTK has + // already populated it; change only maximized bounds and deliberately + // preserve both tracking-size fields so ordinary resizing remains free. + let info = unsafe { &mut *(lparam as *mut MinMaxInfo) }; + apply_monitor_work_area(info, monitor_info.monitor, monitor_info.work_area); +} + +unsafe fn constrain_interactive_window_pos_to_work_area( + hwnd: *mut c_void, + lparam: isize, + state: &SnapState, +) -> bool { + if lparam == 0 { + return false; + } + + // GTK 4.22 implements CSD resizing with a pointer grab plus repeated + // SetWindowPos calls instead of Windows' modal sizing loop. Restrict this + // hook to that active left-button grab so programmatic layout, maximize, + // Snap, initial placement, and ordinary moves keep their native behavior. + // SAFETY: both calls are stateless queries on the current UI thread. + if unsafe { GetCapture() } != hwnd || unsafe { GetAsyncKeyState(VK_LBUTTON) } >= 0 { + return false; + } + + // SAFETY: WM_WINDOWPOSCHANGING supplies a writable WINDOWPOS for the + // duration of this synchronous callback. + let position = unsafe { &mut *(lparam as *mut WindowPos) }; + if position.flags & SWP_NOSIZE != 0 { + return false; + } + + // SAFETY: hwnd is the live window whose placement is changing. + let Some(current) = (unsafe { window_rect(hwnd) }) else { + return false; + }; + let mut proposed = proposed_window_rect(current, position); + + if rect_size(proposed) == rect_size(current) { + return false; + } + + // SAFETY: the cursor and monitor queries are stateless UI-thread calls. + let Some((cursor, monitor_info)) = (unsafe { cursor_monitor_info() }) else { + return false; + }; + + let edges = active_sizing_edges(current, proposed, cursor); + if !apply_sizing_work_area( + &mut proposed, + edges, + monitor_info.monitor, + monitor_info.work_area, + state.csd_insets(), + ) { + return false; + } + + apply_window_pos(position, proposed); + true +} + +unsafe fn toggle_maximized(hwnd: *mut c_void) { + // Returning HTMAXBUTTON redirects the click away from GTK's client-side + // button. Drive the same native maximize/restore state transition here; + // GDK observes the resulting window-state messages and updates GTK. + let command = if unsafe { IsZoomed(hwnd) } != 0 { + SW_RESTORE + } else { + SW_MAXIMIZE + }; + // SAFETY: hwnd is the live window currently handling the button message. + unsafe { + ShowWindow(hwnd, command); + } +} /// Enable Windows 11 Snap Layout support for the given window. /// -/// `maximize_rect` is `(x, y, width, height)` in client coordinates -/// of the maximize/restore button in the header bar. -/// /// Call this once after the window has been realised and the HWND extracted. -pub fn enable_snap_layout(hwnd: *mut c_void, maximize_rect: (i32, i32, i32, i32)) { - // Store the initial button rect. - if let Ok(mut rect) = MAX_BUTTON_RECT.lock() { - *rect = Some(maximize_rect); - } - - // Diagnostic: dump the window styles so we can see what GTK4 actually - // sets on Windows. Snap Assist's "fill the other quadrants" picker only - // includes windows that look like a normal top-level (WS_THICKFRAME + - // WS_MAXIMIZEBOX present, WS_EX_TOOLWINDOW absent). If GTK's CSD is - // missing one of these, that's the root of the snap-list-omission bug. - // SAFETY: GetWindowLongPtrW is a stateless query against a valid HWND. - let (style, ex_style) = unsafe { - ( - GetWindowLongPtrW(hwnd, GWL_STYLE), - GetWindowLongPtrW(hwnd, GWL_EXSTYLE), - ) - }; - tracing::info!( - style = format!("{:#010x}", style as u32), - ex_style = format!("{:#010x}", ex_style as u32), - ws_thickframe = (style as u32 & 0x0004_0000) != 0, - ws_maximizebox = (style as u32 & 0x0001_0000) != 0, - ws_caption = (style as u32 & 0x00C0_0000) == 0x00C0_0000, - ws_ex_toolwindow = (ex_style as u32 & 0x0000_0080) != 0, - "Window styles before Snap Layout subclass install" - ); - - // SAFETY: SetWindowSubclass is a standard Win32 API. We pass a valid HWND - // (extracted by gdk4-win32), a static extern fn, and a unique subclass ID. - // The callback is stateless — no heap data is referenced via dwRefData. - unsafe { - SetWindowSubclass(hwnd, subclass_proc, SUBCLASS_ID, 0); +pub fn enable_snap_layout( + hwnd: *mut c_void, + window: &adw::ApplicationWindow, + header: &adw::HeaderBar, +) -> bool { + let state = Arc::new(SnapState::new()); + state.set_maximize_rect(current_maximize_rect(window, header)); + state.set_csd_insets(current_csd_insets(window)); + let subclass_state = Arc::into_raw(Arc::clone(&state)); + + // SAFETY: SetWindowSubclass is a standard Win32 API. The raw Arc owns one + // state reference until WM_NCDESTROY; all GTK-facing signal handlers keep + // independent Arc references and never enter the native callback. + let installed = + unsafe { SetWindowSubclass(hwnd, subclass_proc, SUBCLASS_ID, subclass_state as usize) }; + if installed == 0 { + // SAFETY: SetWindowSubclass did not retain the pointer, so reclaim the + // one reference created by Arc::into_raw above. + drop(unsafe { Arc::from_raw(subclass_state) }); + tracing::warn!("Failed to install Windows 11 Snap Layout subclass"); + return false; } + track_maximize_rect(window, header, &state); tracing::info!("Windows 11 Snap Layout subclass installed"); + true } -/// Update the maximize button rectangle (call when the header bar is resized). -pub fn update_maximize_rect(rect: (i32, i32, i32, i32)) { - if let Ok(mut r) = MAX_BUTTON_RECT.lock() { - *r = Some(rect); +fn point_from_lparam(lparam: isize) -> Point { + let packed = lparam as u32; + Point { + x: i32::from(packed as u16 as i16), + y: i32::from((packed >> 16) as u16 as i16), } } +unsafe fn dwm_result(hwnd: *mut c_void, msg: u32, wparam: usize, lparam: isize) -> Option { + let mut result = 0; + // SAFETY: hwnd and message parameters come directly from the window + // procedure; result points to a live LRESULT for the duration of the call. + let handled = unsafe { DwmDefWindowProc(hwnd, msg, wparam, lparam, &raw mut result) }; + (handled != 0).then_some(result) +} + /// The Win32 subclass callback. /// /// Intercepts `WM_NCHITTEST` to return `HTMAXBUTTON` when the cursor @@ -193,38 +739,63 @@ unsafe extern "system" fn subclass_proc( wparam: usize, lparam: isize, _uid: usize, - _ref_data: usize, + ref_data: usize, ) -> isize { + if matches!(msg, WM_NCHITTEST | WM_NCMOUSELEAVE) { + // Windows requires custom frames to give DWM first refusal for + // non-client caption-button messages, including mouse leave. + // SAFETY: all arguments came directly from the native window proc. + if let Some(result) = unsafe { dwm_result(hwnd, msg, wparam, lparam) } { + return result; + } + } + match msg { - WM_NCHITTEST => { - // Get cursor position in client coordinates. - let mut pt = Point::default(); - // SAFETY: GetCursorPos and ScreenToClient are standard Win32. + WM_GETMINMAXINFO => { + // Preserve GTK's minimum size, virtual-desktop tracking maximum, + // and CSD bookkeeping, then correct only the maximized outer + // rectangle so it cannot cover the taskbar or another appbar. + // SAFETY: DefSubclassProc handles the message synchronously. + let result = unsafe { DefSubclassProc(hwnd, msg, wparam, lparam) }; + // SAFETY: lparam is the OS-owned MINMAXINFO for this message. unsafe { - GetCursorPos(&raw mut pt); - ScreenToClient(hwnd, &raw mut pt); + constrain_maximized_window_to_work_area(hwnd, lparam); + } + result + } + WM_WINDOWPOSCHANGING => { + // GTK performs CSD resize drags with SetWindowPos rather than the + // native WM_SIZING loop. Let GTK process the proposed placement, + // then clip only the active edge if it enters an appbar strip. + // SAFETY: DefSubclassProc handles the message synchronously. + let result = unsafe { DefSubclassProc(hwnd, msg, wparam, lparam) }; + // SAFETY: lparam is the OS-owned WINDOWPOS for this message. + if ref_data != 0 { + // SAFETY: dwRefData owns this state until WM_NCDESTROY. + let state = unsafe { &*(ref_data as *const SnapState) }; + unsafe { + constrain_interactive_window_pos_to_work_area(hwnd, lparam, state); + } + } + result + } + WM_NCHITTEST => { + // WM_NCHITTEST packs signed screen coordinates into lparam. Using + // the message coordinates (rather than sampling the cursor again) + // also works on monitors with negative virtual-screen origins. + let mut point = point_from_lparam(lparam); + // SAFETY: ScreenToClient mutates a live POINT for this valid HWND. + if unsafe { ScreenToClient(hwnd, &raw mut point) } == 0 { + // SAFETY: DefSubclassProc forwards to the original wndproc. + return unsafe { DefSubclassProc(hwnd, msg, wparam, lparam) }; } - // Check if cursor is within the maximize button rect. - if let Ok(guard) = MAX_BUTTON_RECT.lock() { - if let Some((x, y, w, h)) = *guard { - // The rect is stored in GTK *logical* pixels, but the - // cursor coords from ScreenToClient are *physical* - // device pixels (GTK4 is per-monitor DPI aware on - // Windows). Scale the rect to physical pixels using the - // window's DPI so the hit-test lines up at any display - // scale (e.g. 150% / 200%), not just 100%. - // SAFETY: GetDpiForWindow is a stateless query on a - // valid HWND; it returns 0 only on failure (→ scale 1.0). - let dpi = unsafe { GetDpiForWindow(hwnd) }; - let scale = if dpi == 0 { 1.0 } else { f64::from(dpi) / 96.0 }; - let px = (f64::from(x) * scale) as i32; - let py = (f64::from(y) * scale) as i32; - let pw = (f64::from(w) * scale) as i32; - let ph = (f64::from(h) * scale) as i32; - if pt.x >= px && pt.x <= px + pw && pt.y >= py && pt.y <= py + ph { - return HTMAXBUTTON; - } + if ref_data != 0 { + // SAFETY: enable_snap_layout stores one Arc-owned SnapState in + // dwRefData and reclaims it only in WM_NCDESTROY below. + let state = unsafe { &*(ref_data as *const SnapState) }; + if state.is_over_maximize(point) { + return HTMAXBUTTON; } } @@ -232,57 +803,35 @@ unsafe extern "system" fn subclass_proc( // SAFETY: DefSubclassProc forwards to the original wndproc. unsafe { DefSubclassProc(hwnd, msg, wparam, lparam) } } - WM_GETMINMAXINFO => { - // Clip the maximised window's bounds to the monitor's *work area* - // (screen rect minus taskbar / docked appbars) so a maximised - // window doesn't overhang the taskbar. GTK4 CSD on Windows - // doesn't override this message, so without this handler the - // default tracking size lets the window cover the taskbar until - // the next move forces Windows to re-evaluate. - // - // SAFETY: lparam on WM_GETMINMAXINFO is always a valid - // pointer to a MINMAXINFO supplied by the OS. MonitorFromWindow - // returns NULL only on invalid HWND; we null-check before deref. + WM_NCLBUTTONDOWN if wparam == HTMAXBUTTON as usize => { + // We own the matching button-up because HTMAXBUTTON prevents GTK + // from receiving its ordinary client-side pointer sequence. + 0 + } + WM_NCLBUTTONUP if wparam == HTMAXBUTTON as usize => { + // SAFETY: this is the live HWND associated with the hit-tested + // maximize button. unsafe { - let monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST); - if !monitor.is_null() { - let mut mi = MonitorInfo { - cb_size: std::mem::size_of::() as u32, - rc_monitor: Rect::default(), - rc_work: Rect::default(), - dw_flags: 0, - }; - if GetMonitorInfoW(monitor, &raw mut mi) != 0 { - let mmi = lparam as *mut MinMaxInfo; - if !mmi.is_null() { - // ptMaxPosition is expressed relative to the - // primary monitor's coordinate system. - (*mmi).pt_max_position.x = mi.rc_work.left - mi.rc_monitor.left; - (*mmi).pt_max_position.y = mi.rc_work.top - mi.rc_monitor.top; - (*mmi).pt_max_size.x = mi.rc_work.right - mi.rc_work.left; - (*mmi).pt_max_size.y = mi.rc_work.bottom - mi.rc_work.top; - // Cap the user-resizable maximum at the work - // area too, so dragging-resize can't exceed it. - (*mmi).pt_max_track_size.x = mi.rc_work.right - mi.rc_work.left; - (*mmi).pt_max_track_size.y = mi.rc_work.bottom - mi.rc_work.top; - return 0; - } - } - } + toggle_maximized(hwnd); } - // SAFETY: fall through to the default handler if any of the - // monitor queries failed. - unsafe { DefSubclassProc(hwnd, msg, wparam, lparam) } + 0 } WM_NCDESTROY => { - // Clean up: remove our subclass before the window is destroyed. + // This is the only removal site, reached after WM_NCDESTROY has + // already entered our callback, so removal cannot bypass the raw + // Arc reclamation below. // SAFETY: RemoveWindowSubclass with the same fn + ID we registered. unsafe { RemoveWindowSubclass(hwnd, subclass_proc, SUBCLASS_ID); } - tracing::debug!("Snap Layout subclass removed (WM_NCDESTROY)"); // SAFETY: Forward to the next handler in the chain. - unsafe { DefSubclassProc(hwnd, msg, wparam, lparam) } + let result = unsafe { DefSubclassProc(hwnd, msg, wparam, lparam) }; + if ref_data != 0 { + // SAFETY: this exactly balances Arc::into_raw at successful + // installation, and WM_NCDESTROY is delivered once per HWND. + drop(unsafe { Arc::from_raw(ref_data as *const SnapState) }); + } + result } _ => { // SAFETY: Forward all other messages unchanged. @@ -290,3 +839,328 @@ unsafe extern "system" fn subclass_proc( } } } + +#[cfg(test)] +mod tests { + use super::{ + active_sizing_edges, apply_monitor_work_area, apply_sizing_work_area, physical_csd_insets, + physical_hit_rect, point_from_lparam, proposed_window_rect, CsdInsets, HitRect, + HorizontalEdge, MinMaxInfo, Point, Rect, SizingEdges, VerticalEdge, WindowPos, SWP_NOMOVE, + }; + use std::ptr; + + fn pack_point(x: i16, y: i16) -> isize { + (((y as u16 as u32) << 16) | u32::from(x as u16)) as isize + } + + fn window_pos(x: i32, y: i32, width: i32, height: i32, flags: u32) -> WindowPos { + WindowPos { + hwnd: ptr::null_mut(), + insert_after: ptr::null_mut(), + x, + y, + width, + height, + flags, + } + } + + fn left_csd_inset(left: i32) -> CsdInsets { + CsdInsets { + left, + ..CsdInsets::default() + } + } + + #[test] + fn hit_test_message_coordinates_stay_signed() { + assert_eq!( + point_from_lparam(pack_point(-1_920, -240)), + Point { x: -1_920, y: -240 } + ); + assert_eq!( + point_from_lparam(pack_point(3_840, 1_080)), + Point { x: 3_840, y: 1_080 } + ); + } + + #[test] + fn gtk_surface_transform_and_scale_are_applied_outward() { + assert_eq!( + physical_hit_rect(100.25, 4.5, 46.0, 36.0, (12.0, 8.0), 2.0), + Some(HitRect { + left: 224, + top: 25, + right: 317, + bottom: 97, + }) + ); + } + + #[test] + fn hit_rect_uses_half_open_edges() { + let rect = HitRect { + left: 10, + top: 20, + right: 30, + bottom: 40, + }; + + assert!(rect.contains(Point { x: 10, y: 20 })); + assert!(rect.contains(Point { x: 29, y: 39 })); + assert!(!rect.contains(Point { x: 30, y: 39 })); + assert!(!rect.contains(Point { x: 29, y: 40 })); + } + + #[test] + fn gtk_surface_and_widget_sizes_yield_physical_csd_insets() { + assert_eq!( + physical_csd_insets(985, 541, 960, 516, (13.0, 13.0), 2.0), + CsdInsets { + left: 26, + right: 24, + top: 26, + bottom: 24, + } + ); + } + + #[test] + fn work_area_fix_preserves_both_manual_tracking_limits() { + let mut info = MinMaxInfo { + reserved: Point::default(), + max_size: Point { x: 1, y: 2 }, + max_position: Point { x: 3, y: 4 }, + min_track_size: Point { x: 330, y: 240 }, + max_track_size: Point { x: 7_680, y: 4_320 }, + }; + + apply_monitor_work_area( + &mut info, + Rect { + left: 3_840, + top: -1_080, + right: 4_920, + bottom: 840, + }, + Rect { + left: 3_888, + top: -1_080, + right: 4_920, + bottom: 840, + }, + ); + + assert_eq!(info.max_position, Point { x: 48, y: 0 }); + assert_eq!(info.max_size, Point { x: 1_032, y: 1_920 }); + assert_eq!(info.min_track_size, Point { x: 330, y: 240 }); + assert_eq!(info.max_track_size, Point { x: 7_680, y: 4_320 }); + } + + #[test] + fn bottom_taskbar_clamps_only_the_dragged_bottom_edge() { + let monitor = Rect { + left: 0, + top: 0, + right: 3_840, + bottom: 2_160, + }; + let work_area = Rect { + bottom: 2_064, + ..monitor + }; + let mut proposed = Rect { + left: -400, + top: -200, + right: 4_400, + bottom: 2_200, + }; + + assert!(apply_sizing_work_area( + &mut proposed, + SizingEdges { + horizontal: Some(HorizontalEdge::Right), + vertical: Some(VerticalEdge::Bottom), + }, + monitor, + work_area, + CsdInsets { + left: 26, + right: 24, + top: 26, + bottom: 24, + }, + )); + assert_eq!( + proposed, + Rect { + left: -400, + top: -200, + right: 4_400, + bottom: 2_088, + } + ); + } + + #[test] + fn docked_appbar_clamps_only_when_its_edge_is_being_dragged() { + let monitor = Rect { + left: -2_160, + top: 0, + right: 0, + bottom: 3_840, + }; + let work_area = Rect { + left: -2_064, + ..monitor + }; + let proposed = Rect { + left: -2_200, + top: -100, + right: 200, + bottom: 4_000, + }; + + let mut right_edge = proposed; + assert!(!apply_sizing_work_area( + &mut right_edge, + SizingEdges { + horizontal: Some(HorizontalEdge::Right), + vertical: Some(VerticalEdge::Bottom), + }, + monitor, + work_area, + left_csd_inset(24), + )); + assert_eq!(right_edge, proposed); + + let mut left_edge = proposed; + assert!(apply_sizing_work_area( + &mut left_edge, + SizingEdges { + horizontal: Some(HorizontalEdge::Left), + vertical: None, + }, + monitor, + work_area, + left_csd_inset(24), + )); + assert_eq!( + left_edge, + Rect { + left: -2_088, + ..proposed + } + ); + } + + #[test] + fn active_edges_preserve_the_fixed_sides_of_a_corner_drag() { + let current = Rect { + left: 100, + top: 200, + right: 1_100, + bottom: 1_000, + }; + let proposed = Rect { + left: 100, + top: 200, + right: 1_400, + bottom: 1_300, + }; + + assert_eq!( + active_sizing_edges(current, proposed, Point { x: 1_400, y: 1_300 }), + SizingEdges { + horizontal: Some(HorizontalEdge::Right), + vertical: Some(VerticalEdge::Bottom), + } + ); + } + + #[test] + fn active_edges_use_cursor_proximity_when_both_bounds_shift() { + let current = Rect { + left: 100, + top: 200, + right: 1_100, + bottom: 1_000, + }; + let proposed = Rect { + left: 99, + top: 201, + right: 1_400, + bottom: 1_301, + }; + + assert_eq!( + active_sizing_edges(current, proposed, Point { x: 100, y: 1_300 }), + SizingEdges { + horizontal: Some(HorizontalEdge::Left), + vertical: Some(VerticalEdge::Bottom), + } + ); + } + + #[test] + fn active_edges_break_equal_distance_ties_toward_start_edges() { + let current = Rect { + left: 100, + top: 200, + right: 1_100, + bottom: 1_000, + }; + let proposed = Rect { + left: 0, + top: 100, + right: 1_200, + bottom: 1_100, + }; + + assert_eq!( + active_sizing_edges(current, proposed, Point { x: 600, y: 600 }), + SizingEdges { + horizontal: Some(HorizontalEdge::Left), + vertical: Some(VerticalEdge::Top), + } + ); + } + + #[test] + fn proposed_window_rect_uses_the_proposed_origin() { + let current = Rect { + left: -2_160, + top: 0, + right: 0, + bottom: 3_840, + }; + assert_eq!( + proposed_window_rect(current, &window_pos(20, 40, 800, 600, 0)), + Rect { + left: 20, + top: 40, + right: 820, + bottom: 640, + } + ); + } + + #[test] + fn proposed_window_rect_preserves_origin_when_not_moving() { + let current = Rect { + left: -2_160, + top: 0, + right: 0, + bottom: 3_840, + }; + assert_eq!( + proposed_window_rect(current, &window_pos(20, 40, 800, 600, SWP_NOMOVE)), + Rect { + left: -2_160, + top: 0, + right: -1_360, + bottom: 600, + } + ); + } +} diff --git a/src/ui/window.rs b/src/ui/window.rs index 0a1d29b6..9f3e5e2d 100644 --- a/src/ui/window.rs +++ b/src/ui/window.rs @@ -65,6 +65,17 @@ type PlaybackUiReset = Rc; type SourcePlaybackInvalidator = Rc; type PlaylistPlaybackInvalidator = Rc; +#[cfg(target_os = "windows")] +fn try_enable_snap_layout( + window: &adw::ApplicationWindow, + header: &adw::HeaderBar, + attempt: &'static str, +) -> Option { + let hwnd = extract_hwnd(window)?; + tracing::info!(attempt, "Installing Windows Snap Layout subclass"); + Some(super::win32_snap::enable_snap_layout(hwnd, window, header)) +} + /// Keep coordinator ingress ahead of the history/UI reducers for one current /// player event. The two callbacks make the ordering explicit and keep the /// rule independently testable without a GTK main loop. @@ -2459,7 +2470,7 @@ pub(crate) fn build_window( let hwnd = extract_hwnd(&window); // ── Enable Windows 11 Snap Layout ─────────────────────────────── - // Install a WM_NCHITTEST / WM_GETMINMAXINFO subclass on the + // Install the narrowly scoped hit-test and work-area subclass on the // top-level HWND. // // `window.present()` is supposed to allocate the native surface, @@ -2469,36 +2480,49 @@ pub(crate) fn build_window( // the window is mapped. #[cfg(target_os = "windows")] { - if let Some(hwnd_ptr) = hwnd { - tracing::info!("Installing Snap Layout subclass (HWND ready at present)"); - super::win32_snap::enable_snap_layout(hwnd_ptr, (win_width - 92, 0, 46, 36)); - - window.connect_default_width_notify(move |win| { - let (w, _) = win.default_size(); - super::win32_snap::update_maximize_rect((w - 92, 0, 46, 36)); - }); - } else { - tracing::warn!( - "HWND not available immediately after window.present() — deferring Snap Layout install to first notify::is-active" + let installed = Rc::new(Cell::new(false)); + match try_enable_snap_layout(&window, &hb.header, "immediate") { + Some(result) => installed.set(result), + None => { + tracing::warn!( + "HWND not available immediately after window.present() — scheduling Snap Layout retries" ); - let installed = std::rc::Rc::new(std::cell::Cell::new(false)); + } + } + + if !installed.get() { let installed_for_handler = installed.clone(); + let header_for_handler = hb.header.downgrade(); window.connect_is_active_notify(move |w| { if installed_for_handler.get() { return; } - let Some(hwnd_ptr) = extract_hwnd(w) else { + let Some(header) = header_for_handler.upgrade() else { return; }; - tracing::info!("Installing Snap Layout subclass (deferred, HWND now ready)"); - let (cw, _) = w.default_size(); - super::win32_snap::enable_snap_layout(hwnd_ptr, (cw - 92, 0, 46, 36)); - installed_for_handler.set(true); - - w.connect_default_width_notify(move |win| { - let (cw, _) = win.default_size(); - super::win32_snap::update_maximize_rect((cw - 92, 0, 46, 36)); - }); + if let Some(result) = try_enable_snap_layout(w, &header, "activation retry") { + installed_for_handler.set(result); + } + }); + + // An already-active window might not emit another activation + // notification. Retry once at idle after GTK has finished mapping; + // later focus changes remain a fallback if this attempt also fails. + let installed_for_idle = installed.clone(); + let window_for_idle = window.downgrade(); + let header_for_idle = hb.header.downgrade(); + glib::idle_add_local_once(move || { + if installed_for_idle.get() { + return; + } + let Some((window, header)) = + window_for_idle.upgrade().zip(header_for_idle.upgrade()) + else { + return; + }; + if let Some(result) = try_enable_snap_layout(&window, &header, "idle retry") { + installed_for_idle.set(result); + } }); } } diff --git a/tests/packaging_metadata.rs b/tests/packaging_metadata.rs index 6fbcf0d5..aa8c09be 100644 --- a/tests/packaging_metadata.rs +++ b/tests/packaging_metadata.rs @@ -337,6 +337,44 @@ fn bundle_policy_matches_relative_path(path: &str, tokens: &[&str]) -> bool { .any(|component| bundle_policy_matches(component, tokens)) } +#[test] +fn windows_build_scopes_compiler_tools_to_the_rust_target() { + let compact_build_windows: String = BUILD_WINDOWS + .chars() + .filter(|character| !character.is_ascii_whitespace()) + .map(|character| character.to_ascii_lowercase()) + .collect(); + assert!( + BUILD_WINDOWS + .contains(r#"$ToolEnvTarget = $RustTarget.Replace("-", "_").Replace(".", "_")"#), + "Windows tool variables must use Cargo's target-qualified spelling" + ); + + for (tool, clang_binary, gcc_binary) in [ + ("DLLTOOL", "llvm-dlltool.exe", "dlltool.exe"), + ("CC", "clang.exe", "gcc.exe"), + ("CXX", "clang++.exe", "g++.exe"), + ("AR", "llvm-ar.exe", "ar.exe"), + ] { + for binary in [clang_binary, gcc_binary] { + let assignment = format!( + r#"[Environment]::SetEnvironmentVariable("{tool}_$ToolEnvTarget", (Join-Path $MsysPath "bin\{binary}"), "Process")"# + ); + assert!( + BUILD_WINDOWS.contains(&assignment), + "Windows build is missing target-qualified {tool} mapping to {binary}" + ); + } + let tool = tool.to_ascii_lowercase(); + assert!( + !compact_build_windows.contains(&format!("$env:{tool}=")) + && !compact_build_windows.contains(&format!(r#"setenvironmentvariable("{tool}","#)) + && !compact_build_windows.contains(&format!("setenvironmentvariable('{tool}',")), + "generic {tool} assignment must not contaminate MSVC host build dependencies" + ); + } +} + #[test] fn bundled_component_policy_blocks_disc_decryption_without_hiding_codecs() { let tokens = forbidden_bundle_tokens();