//! Quick Settings pull-down panel — top-level state machine and the
//! glue that hooks into `Crustace::handle_quicksettings_msg`. The
//! actual rendering tree lives in [`view`]; the bundled asset
//! buffers + tint cache live in [`icons`]. This module owns only
//! state mutations and the public accessors the view side needs.
//!
//! Animation model: `progress` is the currently rendered height as
//! a fraction `[0.0, 1.0]` of `MAX_HEIGHT_FRAC * screen_height`.
//! During a drag it tracks the finger live; when the drag ends
//! `target` is set to either 1.0 (commit past threshold) or 0.0
//! (cancel) and `tick_animation` steps the value toward it.
//! `dragging` distinguishes the live-follow from the settle so a
//! final `on_swipe_progress(0.0)` cancellation pulse can be
//! interpreted correctly. `last_tick` paces the integrator in
//! `poll_external`.

mod icons;
mod view;

use std::time::Instant;

use crate::message::Message;
use crate::mpris::MediaState;
use crate::system;
use crate::system::SystemState;
use crate::system::toggles::{ ToggleId, ToggleState };

use icons::Icons;
use rust_i18n::t;

// ─── Public layout constants ────────────────────────────────────────────────

/// Fraction of the below-topbar strip that the fully-opened QS panel
/// covers. `1.0` makes the panel reach the bottom edge of the screen;
/// the forge topbar stays visible above because the surface itself is
/// shifted down by [`TOPBAR_PX`].
pub const MAX_HEIGHT_FRAC: f32 = 1.0;

/// Height (logical pixels) of the forge-rendered topbar. Mirrors the
/// constant used in `app.rs` for the swipe-down edge band so the QS
/// surface stops exactly at the topbar's lower edge.
pub const TOPBAR_PX: u32 = 28;

/// Width-to-height ratio at which the QS panel still reads as
/// "phone-shaped". On phone displays the natural aspect is narrower
/// than this so the panel takes the full screen width; on tablet /
/// desktop the wallpaper + chrome are clamped to this aspect (and
/// centered) so the layout keeps its mobile feel instead of
/// stretching cards and sliders across a 1080-wide monitor.
/// `9:16` is the modern landscape-phone ratio inverted.
pub const PANEL_ASPECT: f32 = 9.0 / 16.0;

/// Pixel gap between cells of the 4-column grid that drives the QS
/// chrome. Cards (top row), buttons (left half of middle row) and
/// sliders (right half of middle row) all use this same gap so the
/// columns line up vertically as a single grid, rather than three
/// independent layouts that happen to share a screen.
pub const GRID_GAP: f32 = 12.0;

/// Pill width of each vslider in pixels. Constant — does not depend
/// on the screen — because the slider is intrinsically sized; the
/// surrounding pill column claims the column width and centers the
/// track inside.
pub const VSLIDER_W: f32 = 80.0;

/// Pill height of each vslider in pixels.
pub const VSLIDER_H: f32 = 220.0;

/// Outer column padding inside the panel chrome — surfaced here
/// (instead of inlined in `view::build`) so [`desktop_panel_height`]
/// and [`desktop_panel_width`] can derive the content-driven panel
/// envelope from the same constant the renderer reads.
pub const PANEL_OUTER_PAD: f32 = 20.0;

/// Vertical spacing between the three panel stripes (chips / middle
/// row / round-button bank). Mirrors the literal in `view::build`.
pub const PANEL_ROW_SPACING: f32 = 20.0;

// =============================================================================
// Portrait QS sizing as viewport-width percentages.
//
// Reference screen: 480 px wide at scale=1 (the winit test window).
// `Length::vw(X)` resolves to X % of the logical viewport width at
// layout time — ltk handles dpi_scale automatically, so the same
// constants give correct sizes on any resolution or scale factor
// without any screen_width arithmetic in the view functions.
//
// All derived constants are computed at compile time from the primary
// ones so a single edit propagates everywhere.
// =============================================================================

const REF_W: f32 = 480.0;

/// Global size multiplier for the portrait QS content (buttons, sliders,
/// icons, text). Raises the on-screen size of everything inside the panel
/// without touching the grid structure (gaps / outer padding), so the
/// round buttons fill more of their grid cell. The button diameter is
/// `QS_VSLIDER_W_VW * QS_SCALE`; keep it at or below the grid cell width
/// (~21 % for 4 columns) so a row of four still fits. One knob to tune.
const QS_SCALE: f32 = 1.25;

pub( crate ) const QS_VSLIDER_W_VW:    f32 = VSLIDER_W    / REF_W * 100.0 * QS_SCALE; // ≈ 20.8
pub( crate ) const QS_VSLIDER_H_VW:    f32 = VSLIDER_H    / REF_W * 100.0 * QS_SCALE; // ≈ 57.3
pub( crate ) const QS_BUTTON_ICON_VW:  f32 = BUTTON_ICON_PX / REF_W * 100.0 * QS_SCALE; // ≈ 8.3
pub( crate ) const QS_CARD_ICON_VW:    f32 = CARD_ICON_PX  / REF_W * 100.0 * QS_SCALE; // ≈ 6.25
pub( crate ) const QS_GRID_GAP_VW:     f32 = GRID_GAP      / REF_W * 100.0; //  2.5
pub( crate ) const QS_OUTER_PAD_VW:    f32 = PANEL_OUTER_PAD  / REF_W * 100.0; //  4.167
pub( crate ) const QS_ROW_SPACING_VW:  f32 = PANEL_ROW_SPACING / REF_W * 100.0; //  4.167
pub( crate ) const QS_CARD_PADDING_H_VW: f32 = CARD_PADDING_H / REF_W * 100.0; //  3.4375

// Derived — row height and padding kept consistent automatically.
pub( crate ) const QS_ROW_H_VW:       f32 = ( QS_VSLIDER_H_VW - QS_GRID_GAP_VW ) / 2.0; // 21.667
pub( crate ) const QS_CARD_PAD_V_VW:  f32 = ( QS_ROW_H_VW - QS_CARD_ICON_VW ) / 2.0;    //  8.333
pub( crate ) const QS_BTN_PAD_VW:     f32 = ( QS_VSLIDER_W_VW - QS_BUTTON_ICON_VW ) / 2.0; // 5.0
pub( crate ) const QS_TEXT_GAP_VW:    f32 = 2.0 / REF_W * 100.0; //  0.417

// Text, transport-control and card-chrome sizes, all as % of width so
// the whole panel reads identically at any viewport width, scaled by the
// same `QS_SCALE` knob as the rest of the content.
pub( crate ) const QS_TITLE_VW:        f32 = 12.0 / REF_W * 100.0 * QS_SCALE;
pub( crate ) const QS_STATUS_VW:       f32 = 10.0 / REF_W * 100.0 * QS_SCALE;
pub( crate ) const QS_MEDIA_TEXT_VW:   f32 = 14.0 / REF_W * 100.0 * QS_SCALE;
pub( crate ) const QS_CARD_SIDE_VW:    f32 =  8.0 / REF_W * 100.0;
pub( crate ) const QS_CARD_ROW_GAP_VW: f32 =  6.0 / REF_W * 100.0;
pub( crate ) const QS_TRANSPORT_ICON_VW: f32 = 18.0 / REF_W * 100.0 * QS_SCALE;
pub( crate ) const QS_TRANSPORT_PAD_VW:  f32 =  6.0 / REF_W * 100.0 * QS_SCALE;
pub( crate ) const QS_TRANSPORT_MARGIN_VW: f32 = 16.0 / REF_W * 100.0;

/// Fraction of screen width used as the gap between the desktop QS
/// pill and the right edge of the screen. Scales linearly with the
/// active surface so the gap reads "the same" relative size on a
/// 1366-wide laptop and a 3840-wide desktop instead of getting lost
/// or dominating either. ~1.2 % of width is small enough that the
/// pill reads as anchored to the corner without kissing the bezel.
pub const PANEL_DESKTOP_RIGHT_MARGIN_FRAC: f32 = 0.012;

/// Resolve [`PANEL_DESKTOP_RIGHT_MARGIN_FRAC`] against the active
/// surface width — single source of truth so both the renderer
/// (`view::build`) and the input-region computation in `overlays.rs`
/// see the same gap.
pub fn desktop_right_margin( screen_width: u32 ) -> f32
{
	( screen_width as f32 * PANEL_DESKTOP_RIGHT_MARGIN_FRAC ).round().max( 1.0 )
}

/// Intrinsic content width of the desktop QS pill, derived from the
/// chrome primitives ([`VSLIDER_W`], [`GRID_GAP`], [`PANEL_OUTER_PAD`])
/// instead of a hard-coded pixel literal. The buttons row is the
/// widest stripe inside the panel — a 4-column grid of round pills
/// `VSLIDER_W` in diameter separated by `GRID_GAP` — and the middle
/// + chips rows expand to fill whatever width the buttons row
/// dictates. So summing the buttons row and adding a `PANEL_OUTER_PAD`
/// pad on each side gives the column's natural width with no slack.
pub fn desktop_panel_width() -> f32
{
	4.0 * VSLIDER_W + 3.0 * GRID_GAP + 2.0 * PANEL_OUTER_PAD
}

/// Intrinsic content height of the desktop QS pill — sum of the
/// three stripes plus the column's padding and inter-stripe spacing.
/// Desktop reorganises the layout: the top row stacks the WiFi + BT
/// chips on the left against a full-height media card on the right
/// (preferred height `VSLIDER_H` by construction — `2 × grid_row_h
/// + GRID_GAP`), the middle stripe replaces the vslider pair with
/// two horizontal slider pills (`2 × VSLIDER_W + GRID_GAP`), and the
/// round-button bank stays unchanged. Each term reads off the chrome
/// primitives the renderer already uses:
///
/// * `pad_v`    — `2 × PANEL_OUTER_PAD`
/// * `spacing`  — `2 × PANEL_ROW_SPACING` (gaps between the 3 stripes)
/// * `top_row`  — `VSLIDER_H` — chips column / media card both clamp
///                to this height.
/// * `sliders`  — `2 × VSLIDER_W + GRID_GAP` — the two horizontal
///                slider pills stacked.
/// * `buttons`  — `2 × VSLIDER_W + GRID_GAP` — the round-button bank
///                wraps a 4-column grid of 8 pills into 2 rows.
pub fn desktop_panel_height() -> f32
{
	let pad_v   = 2.0 * PANEL_OUTER_PAD;
	let spacing = 2.0 * PANEL_ROW_SPACING;
	let top_row = VSLIDER_H;
	let sliders = 2.0 * VSLIDER_W + GRID_GAP;
	let buttons = 2.0 * VSLIDER_W + GRID_GAP;
	pad_v + spacing + top_row + sliders + buttons
}

/// Logical size of the round-button glyph in pixels. The icon is
/// wrapped in a `column` (default `align_center_x`) so it stays at
/// this preferred size when the surrounding container stretches to
/// fill its grid cell; without the column the `Image` would be
/// drawn into the full inner rect and squashed into a wide ellipse.
pub const BUTTON_ICON_PX: f32 = 32.0;

/// Icon size (logical px) inside the connectivity chips and the slider pills.
pub const CARD_ICON_PX: f32 = 24.0;

/// Corner radius of every panel-level pill in the QS chrome — the
/// `surface-card-flat` chips, the media card, and the round toggle
/// pills all share it so the curvature is visually consistent across
/// the panel.
pub const CARD_RADIUS: f32 = 32.0;

/// Horizontal padding (logical px) inside every `surface-card-flat`
/// envelope. The half-pixel value falls out of the Figma reference
/// and lining the icon column up against the card's rounded corner
/// needs the extra 0.5 px of breathing room on each side.
pub const CARD_PADDING_H: f32 = 16.5;

/// Fraction of screen height the user must drag down from the top
/// before the release is interpreted as a commit to fully-open.
/// Below that the drag is treated as a cancel and the panel settles
/// back to closed.
pub const SWIPE_THRESHOLD: f32 = 0.35;

/// Linear settle speed for the OPEN direction in progress units per
/// second. A full open from rest takes ~166 ms, matching the Android
/// pull-down feel.
const OPEN_SPEED:  f32 = 6.0;

/// Linear settle speed for the CLOSE direction. Half of [`OPEN_SPEED`]
/// so the panel takes ~333 ms to dismiss — the symmetric 166 ms close
/// felt clipped, like the chrome was being snatched away the instant
/// the user released the swipe / tapped outside. The asymmetry mirrors
/// the platform convention (Android, iOS Control Center) where dismiss
/// is consistently slower than reveal.
const CLOSE_SPEED: f32 = 3.0;

/// Step applied by the hardware volume / brightness keys in one press.
const VOLUME_STEP:     u8 = 5;
const BRIGHTNESS_STEP: u8 = 10;

// ─── State struct ───────────────────────────────────────────────────────────

/// All state belonging to the Quick Settings pull-down panel: the
/// slide animation, the toggle bank state, and the WiFi / Bluetooth
/// sub-pickers that only exist because of QS. Owned by `Crustace`
/// through a single field so the shell's top-level state stays
/// narrow.
pub struct QuickSettings
{
	progress:  f32,
	target:    Option<f32>,
	dragging:  bool,
	last_tick: Option<Instant>,
	ignore_local_swipe_until_idle: bool,

	pub toggle_state: ToggleState,

	/// Wallpaper image used as the panel background — the active
	/// mode's **lockscreen** crop, not the homescreen wallpaper.
	/// Distinguishing the two lets the QS reveal feel like a
	/// separate "card" sliding in over the desktop instead of a
	/// translucent overlay on the same imagery, and the typically
	/// darker / quieter lockscreen image gives chips / toggles /
	/// sliders consistent contrast without needing a scrim layer
	/// on top.
	wallpaper: ltk::WallpaperBundle,

	/// Pre-decoded SVG bundle + per-frame tint cache. The view
	/// sub-modules borrow `&Icons` to render; QS state mutations
	/// never touch this field.
	icons: Icons,

	pub wifi_picker_visible: bool,
	pub wifi_networks:       Vec<system::network::WifiNetwork>,
	pub bt_picker_visible:   bool,
	pub bt_devices:          Vec<system::network::BluetoothDevice>,

	/// Bottom-edge zone reserved by an always-visible dock that the QS
	/// layer-surface (`exclusive_zone: 0`) honours. Subtracted from
	/// `max_height` so the panel does not request more height than the
	/// compositor will grant — without this the surface gets clamped at
	/// the top edge and the panel chrome disappears under the topbar.
	dock_reserve: u32,
}

impl QuickSettings
{
	pub fn new() -> Self
	{
		Self
		{
			progress:  0.0,
			target:    None,
			dragging:  false,
			last_tick: None,
			ignore_local_swipe_until_idle: false,
			toggle_state: ToggleState::default(),
			wallpaper:    crate::assets::decode_lockscreen_bundle( 0, 0 ),
			icons:        Icons::new(),
			wifi_picker_visible: false,
			wifi_networks:       Vec::new(),
			bt_picker_visible:   false,
			bt_devices:          Vec::new(),
			dock_reserve:        0,
		}
	}

	pub fn set_dock_reserve( &mut self, px: u32 )
	{
		self.dock_reserve = px;
	}

	// ── view-side accessors ────────────────────────────────────

	/// Borrow the asset bundle. Used by `view/*` sub-modules.
	pub( crate ) fn icons( &self ) -> &Icons { &self.icons }

	/// Borrow the lockscreen wallpaper. Used by the view assembly
	/// to paint the panel background.
	pub( crate ) fn wallpaper( &self ) -> &ltk::WallpaperBundle { &self.wallpaper }

	/// Re-decode the lockscreen wallpaper for the currently active theme
	/// mode at the given surface size. Called from the shell after a
	/// Dark/Light flip so the panel background swaps
	/// `branding/{light,dark}/lockscreen.svg` (or its raster variant
	/// under `branding/{mode}/lockscreen/<W>x<H>.webp` when one covers
	/// the surface) alongside the homescreen wallpaper.
	pub( crate ) fn reload_wallpaper( &mut self, sw: u32, sh: u32 )
	{
		self.wallpaper = crate::assets::decode_lockscreen_bundle( sw, sh );
	}

	/// Current animation progress in `[0, 1]`. Drives the panel
	/// content's `opacity` fade alongside the slide.
	pub( crate ) fn progress( &self ) -> f32 { self.progress }

	// ── state-machine API ──────────────────────────────────────

	/// Maximum rendered height of the layer surface in pixels — the
	/// strip below the forge topbar that the panel can occupy. Always
	/// the full strip in either orientation: keeping the surface size
	/// fixed across the whole drag avoids per-frame Wayland configure
	/// round-trips during the slide. The visible chrome inside is
	/// what differs between portrait and landscape (see
	/// [`Self::target_height`]).
	pub fn max_height( &self, screen_height: u32, display_scale: u32 ) -> u32
	{
		let base = if screen_height == 0 { 720 } else { screen_height };
		let topbar_phys = TOPBAR_PX * display_scale.max( 1 );
		let usable = base.saturating_sub( topbar_phys ).saturating_sub( self.dock_reserve );
		( usable as f32 * MAX_HEIGHT_FRAC ) as u32
	}

	pub fn target_height( &self, screen_width: u32, screen_height: u32, display_scale: u32 ) -> u32
	{
		if screen_width > screen_height
		{
			let cap = self.max_height( screen_height, display_scale );
			( desktop_panel_height().round() as u32 ).min( cap ).max( 1 )
		}
		else
		{
			self.max_height( screen_height, display_scale )
		}
	}

	/// Currently visible chrome height in pixels — `progress * target`
	/// clamped to ≥ 1 so the layer surface never collapses to zero
	/// (which would drop its buffer).
	pub fn current_height( &self, screen_width: u32, screen_height: u32, display_scale: u32 ) -> u32
	{
		let target = self.target_height( screen_width, screen_height, display_scale ) as f32;
		( self.progress * target ).round().max( 1.0 ) as u32
	}

	/// True whenever the QS surface should be on screen — the
	/// overlay is emitted from `Crustace::overlays` under the same
	/// condition.
	pub fn is_shown( &self ) -> bool
	{
		self.dragging || self.target.is_some() || self.progress > 0.0
	}

	/// `true` while the panel is sliding or settling (not statically open).
	/// Lets the shell skip the homescreen re-raster while QS is in motion —
	/// the panel re-renders in its own overlay every frame, but the static
	/// main surface behind it does not need repainting.
	pub fn in_motion( &self ) -> bool
	{
		self.dragging || self.target.is_some()
	}

	/// Only the settle animation needs continuous redraws. Live
	/// drag already emits pointer motion events that wake the loop.
	pub fn is_animating( &self ) -> bool
	{
		self.target.is_some()
	}

	/// `true` while the panel is visually moving — either the user
	/// is dragging it or it is settling toward a target. Drives
	pub fn open_animated( &mut self )
	{
		if self.target == Some( 1.0 )
		{
			self.dragging = false;
			return;
		}
		self.dragging  = false;
		self.target    = Some( 1.0 );
		self.last_tick = Some( Instant::now() );
	}

	/// Forge sends an explicit open after it commits the topbar
	/// gesture. The local ltk swipe recogniser can still deliver
	/// its trailing progress/cancel pulses afterwards; ignore those
	/// so the committed Forge open cannot be immediately reversed
	/// by stale local input.
	pub fn open_from_external_gesture( &mut self )
	{
		self.ignore_local_swipe_until_idle = true;
		self.open_animated();
	}

	pub fn toggle_from_topbar_press( &mut self ) -> bool
	{
		let is_down_or_going_down =
			self.dragging
			|| self.target == Some( 1.0 )
			|| ( self.target.is_none() && self.progress > 0.0 );

		if is_down_or_going_down
		{
			self.close_animated();
			true
		}
		else
		{
			self.open_from_external_gesture();
			false
		}
	}

	pub fn start_external_wipe( &mut self )
	{
		self.ignore_local_swipe_until_idle = false;
		self.dragging  = true;
		self.target    = None;
		self.last_tick = None;
		self.progress  = 0.0;
	}

	pub fn set_external_wipe_progress( &mut self, progress: f32 )
	{
		self.ignore_local_swipe_until_idle = false;
		self.dragging  = true;
		self.target    = None;
		self.last_tick = None;
		self.progress  = progress.clamp( 0.0, 1.0 );
	}

	pub fn finish_external_wipe( &mut self, committed: bool, progress: f32 )
	{
		self.ignore_local_swipe_until_idle = false;
		self.dragging = false;
		self.progress = progress.clamp( 0.0, 1.0 );
		if committed
		{
			self.open_animated();
		} else {
			self.close_animated();
		}
	}

	pub fn close_animated( &mut self )
	{
		self.ignore_local_swipe_until_idle = false;
		self.dragging  = false;
		self.target    = Some( 0.0 );
		self.last_tick = Some( Instant::now() );
	}

	/// Collapse the panel immediately — use this when another
	/// overlay is being shown in its place so two overlays don't
	/// fight for the region.
	pub fn close_immediate( &mut self )
	{
		self.ignore_local_swipe_until_idle = false;
		self.dragging  = false;
		self.target    = None;
		self.last_tick = None;
		self.progress  = 0.0;
	}

	/// Advance the settle animation. Called from `poll_external`
	/// so the integrator sees the same ~60 Hz cadence as paints
	/// while `is_animating()` is `true`.
	pub fn tick_animation( &mut self ) -> bool
	{
		let Some( target ) = self.target else { return false };

		let now = Instant::now();
		let dt  = match self.last_tick
		{
			Some( prev ) => ( now - prev ).as_secs_f32(),
			None         => 0.0,
		};
		self.last_tick = Some( now );

		// Settle speed is direction-dependent: opens snap up at
		// `OPEN_SPEED` while closes drift down at the slower
		// `CLOSE_SPEED`. The settle target is always exactly `0.0`
		// (close) or `1.0` (open) — set by `close_animated` /
		// `open_animated` / the swipe commit/cancel — so a strict
		// equality on `target` is a clean discriminator and avoids
		// reading direction from the (noisy mid-drag) diff sign.
		let speed = if target == 0.0 { CLOSE_SPEED } else { OPEN_SPEED };
		let step  = speed * dt;
		let diff  = target - self.progress;

		if diff.abs() <= step
		{
			self.progress  = target;
			self.target    = None;
			self.last_tick = None;
			self.ignore_local_swipe_until_idle = false;
			true
		} else {
			self.progress += step * diff.signum();
			true
		}
	}

	/// Route a downward-swipe progress event into the panel's drag
	/// state. `progress` starts at 0.0 when the drag begins and
	/// grows unclamped past 1.0 as the finger keeps moving. The
	/// `SWIPE_THRESHOLD / MAX_HEIGHT_FRAC` ratio gives a 1:1
	/// finger-tracking conversion; at the commit threshold the
	/// panel stops partway and the settle started by `on_swipe_down`
	/// takes it the rest of the way.
	pub fn on_swipe_progress( &mut self, progress: f32 )
	{
		if self.ignore_local_swipe_until_idle
		{
			// Keep the shield up until the externally-triggered
			// settle reaches its target. A click can produce a
			// local `0.0` pulse before stale positive progress; if
			// that cleared the guard, the stale pulse would cancel
			// `target` and leave the panel parked halfway open.
			self.dragging = false;
			return;
		}

		if progress > 0.0
		{
			self.dragging = true;
			self.progress = ( progress * SWIPE_THRESHOLD / MAX_HEIGHT_FRAC )
				.clamp( 0.0, 1.0 );
			self.target   = None;
		} else if self.dragging {
			// Final cancellation pulse (released below threshold):
			// only animate back to closed if we actually were
			// dragging — an idle tap also lands here and shouldn't
			// disturb anything.
			self.dragging = false;
			self.close_animated();
		}
	}

	pub fn toggle( &mut self, id: ToggleId )
	{
		self.toggle_state.toggle( id );
	}

	pub fn open_wifi_picker( &mut self, networks: Vec<system::network::WifiNetwork> )
	{
		self.wifi_networks       = networks;
		self.wifi_picker_visible = true;
		self.bt_picker_visible   = false;
	}

	pub fn open_bluetooth_picker( &mut self, devices: Vec<system::network::BluetoothDevice> )
	{
		self.bt_devices          = devices;
		self.bt_picker_visible   = true;
		self.wifi_picker_visible = false;
	}

	pub fn close_pickers( &mut self )
	{
		self.wifi_picker_visible = false;
		self.bt_picker_visible   = false;
	}

	// ── view delegation ───────────────────────────────────────

	/// Build the main QS panel view. Pure rendering: delegates to
	/// the `view::` sub-modules which read from `&self.icons`,
	/// `&self.toggle_state`, `&self.progress` and the live state
	/// slices passed in.
	pub fn build(
		&self,
		system_state:  &SystemState,
		media_state:   Option<&MediaState>,
		screen_width:  u32,
		screen_height: u32,
		display_scale: u32,
	) -> ltk::Element<Message>
	{
		view::build( self, system_state, media_state, screen_width, screen_height, display_scale )
	}

	pub fn build_wifi_picker( &self ) -> ltk::Element<Message>
	{
		view::wifi_picker( self )
	}

	pub fn build_bluetooth_picker( &self ) -> ltk::Element<Message>
	{
		view::bluetooth_picker( self )
	}
}

#[cfg(test)]
mod tests
{
	use super::*;

	#[test]
	fn external_open_ignores_local_swipe_progress_until_settle_finishes()
	{
		let mut qs = QuickSettings::new();
		qs.progress = 0.2;

		qs.open_from_external_gesture();
		assert_eq!( qs.target, Some( 1.0 ) );

		qs.on_swipe_progress( 0.0 );
		assert_eq!( qs.target, Some( 1.0 ) );
		assert!( !qs.dragging );
		assert!( qs.ignore_local_swipe_until_idle );

		let progress_before = qs.progress;
		qs.on_swipe_progress( 0.2 );
		assert_eq!( qs.target, Some( 1.0 ) );
		assert_eq!( qs.progress, progress_before );
		assert!( !qs.dragging );

		qs.progress = 1.0;
		qs.tick_animation();
		assert_eq!( qs.target, None );
		assert!( !qs.ignore_local_swipe_until_idle );

		qs.on_swipe_progress( 0.2 );
		assert!( qs.dragging );
		assert_eq!( qs.target, None );
	}
}

// ─── Message dispatch ───────────────────────────────────────────────────────

impl crate::state::Crustace
{
	/// Dispatch QS-owned messages — sliders, toggle bank, WiFi /
	/// Bluetooth pickers, media transport, and the audio /
	/// brightness hardware keys. Returns `true` when the message
	/// was handled; the caller then skips its own match arm.
	/// Cross-panel routing (OpenQuickSettings, wipe progress,
	/// ToggleCalendar, …) deliberately falls through and is
	/// handled by `app::update` as the shell-wide coordinator.
	pub( crate ) fn handle_quicksettings_msg( &mut self, msg: &Message ) -> bool
	{
		match msg
		{
			Message::SetBrightness( val ) =>
			{
				// The slider feeds f32 in [0,1] but the actual
				// setter works at percent (u8) resolution — many
				// drag events round to the same integer percent.
				// Skip the setter (and avoid waking the worker
				// thread) when the percent has not actually
				// changed; otherwise even no-op micro-moves would
				// queue redundant subprocess spawns. Same dedupe
				// applied for `SetVolume` below.
				let new_bright = ( val * 100.0 ).clamp( 0.0, 100.0 ) as u8;
				if new_bright != self.system_state.brightness
				{
					crate::system::brightness::set_brightness( new_bright );
					self.system_state.brightness = new_bright;
				}
			}

			Message::SetVolume( val ) =>
			{
				let new_vol = ( val * 100.0 ).clamp( 0.0, 100.0 ) as u8;
				if new_vol != self.system_state.audio.volume
				{
					crate::system::audio::set_volume( new_vol );
					self.system_state.audio.volume = new_vol;
				}
				if self.system_state.audio.muted && new_vol > 0
				{
					crate::system::audio::set_muted( false );
					self.system_state.audio.muted = false;
				}
			}

			Message::CloseQuickSettings =>
			{
				self.quick_settings.close_animated();
				self.quick_settings.close_pickers();
			}

			Message::Toggle( id ) =>
			{
				self.quick_settings.toggle( *id );

				// DarkMode is cross-process and overrides any prior
				// `ThemePreference` (CLI-forced or `Auto`). The shell
				// helper writes ltk's `active_mode`, syncs the QS
				// pill, re-decodes every wallpaper bundle, IPC's
				// the new mode to forge AND promotes
				// `theme_preference` to the chosen variant so the
				// next `Tick` does not undo the toggle. The previous
				// inline block missed the preference-promotion step
				// — running under `crustace --dark` (`--light`) the
				// tick would re-resolve `Dark`/`Light` and clobber
				// the user's tap within the second.
				if *id == ToggleId::DarkMode
				{
					let new_mode = if self.quick_settings.toggle_state.dark_mode
					{
						ltk::ThemeMode::Dark
					}
					else
					{
						ltk::ThemeMode::Light
					};
					self.set_theme_explicit( new_mode );
				}
			}

			Message::OpenWifiPicker =>
			{
				let nets = crate::system::network::scan_wifi_networks();
				self.quick_settings.open_wifi_picker( nets );
			}

			Message::ConnectWifi( ssid ) =>
			{
				crate::system::network::connect_wifi( ssid );
				self.quick_settings.wifi_picker_visible = false;
			}

			Message::OpenBluetoothPicker =>
			{
				let devs = crate::system::network::scan_bluetooth_devices();
				self.quick_settings.open_bluetooth_picker( devs );
			}

			Message::OpenSettings =>
			{
				// Closing the QS panel matches the user
				// expectation that tapping a button in the
				// pull-down dismisses it once the action is
				// dispatched.
				//
				// Forwarding the current ltk mode keeps the
				// Settings window aligned with the shell
				// instead of letting it auto-resolve on its
				// own clock (would visibly mismatch during the
				// dawn / dusk window).
				let mode_flag = match ltk::active_mode()
				{
					ltk::ThemeMode::Dark  => "--dark",
					ltk::ThemeMode::Light => "--light",
				};
				match std::process::Command::new( "liberux-settings" ).arg( mode_flag ).spawn()
				{
					Ok( child )  => tracing::info!( "Launched liberux-settings (PID {})", child.id() ),
					Err( e )     => tracing::error!( "Failed to launch liberux-settings: {}", e ),
				}
				self.quick_settings.close_animated();
			}

			Message::ConnectBluetooth( addr ) =>
			{
				crate::system::network::connect_bluetooth( addr );
				self.quick_settings.bt_picker_visible = false;
			}

			Message::MediaPlayPause =>
			{
				if let Some( ref state ) = self.media_state
				{
					self.mpris_poller.play_pause( &state.player );
				}
			}

			Message::MediaNext =>
			{
				if let Some( ref state ) = self.media_state
				{
					self.mpris_poller.next_track( &state.player );
				}
			}

			Message::MediaPrevious =>
			{
				if let Some( ref state ) = self.media_state
				{
					self.mpris_poller.previous_track( &state.player );
				}
			}

			Message::MediaStateChanged =>
			{
				// No-op: `poll_external` already wrote the fresh
				// state into `self.media_state`. The message
				// exists solely so the run loop's
				// `apply_invalidation` fires (see
				// `Crustace::invalidate_after`) and the QS card
				// redraws with the new title / playing status /
				// cover art.
			}

			Message::HwVolumeUp =>
			{
				let new_vol = ( self.system_state.audio.volume as u16 + VOLUME_STEP as u16 ).min( 100 ) as u8;
				crate::system::audio::set_volume( new_vol );
				self.system_state.audio.volume = new_vol;
				self.osd.show( t!( "osd_volume", value = new_vol ).to_string(), new_vol as f32 / 100.0 );
			}

			Message::HwVolumeDown =>
			{
				let new_vol = self.system_state.audio.volume.saturating_sub( VOLUME_STEP );
				crate::system::audio::set_volume( new_vol );
				self.system_state.audio.volume = new_vol;
				self.osd.show( t!( "osd_volume", value = new_vol ).to_string(), new_vol as f32 / 100.0 );
			}

			Message::HwMute =>
			{
				let new_muted = !self.system_state.audio.muted;
				crate::system::audio::set_muted( new_muted );
				self.system_state.audio.muted = new_muted;
				let label = if new_muted { t!( "osd_muted" ) } else { t!( "osd_unmuted" ) };
				let vol = self.system_state.audio.volume;
				self.osd.show(
					format!( "{} ({}%)", label, vol ),
					if new_muted { 0.0 } else { vol as f32 / 100.0 },
				);
			}

			Message::HwBrightnessUp =>
			{
				let new_bright = ( self.system_state.brightness as u16 + BRIGHTNESS_STEP as u16 ).min( 100 ) as u8;
				crate::system::brightness::set_brightness( new_bright );
				self.system_state.brightness = new_bright;
				self.osd.show( t!( "osd_brightness", value = new_bright ).to_string(), new_bright as f32 / 100.0 );
			}

			Message::HwBrightnessDown =>
			{
				let new_bright = self.system_state.brightness.saturating_sub( BRIGHTNESS_STEP );
				crate::system::brightness::set_brightness( new_bright );
				self.system_state.brightness = new_bright;
				self.osd.show( t!( "osd_brightness", value = new_bright ).to_string(), new_bright as f32 / 100.0 );
			}

			_ => return false,
		}
		true
	}
}
