//! ltk `App` trait implementation and shell entry point.
//!
//! Keeps the ltk-facing plumbing in one place: `view()` composes the
//! homescreen plus any drag ghost, `overlays()` delegates to
//! [`crate::overlays::build_overlays`], `update()` dispatches the
//! shell-wide [`Message`] enum, `poll_external()` pumps subsystem
//! tickers (system poller, notifications, mpris, QS animation, page
//! settle, edge-dwell fire), and the gesture / pointer callbacks
//! delegate to helpers in [`crate::paging`] and [`crate::drag`].

use crate::config::Config;
use crate::homescreen::DragOrigin;
use crate::message::Message;
use crate::powermenu::{ PowerAction, PowerMenu };
use crate::state::Crustace;

use rust_i18n::t;

const POLL_INTERVAL_SECS: u64 = 30;

impl ltk::App for Crustace
{
	type Message = Message;

	fn shell_mode( &self ) -> ltk::ShellMode
	{
		// Main surface is the wallpaper / homescreen behind regular
		// app windows. Panels (launcher, quick settings, power menu,
		// …) are returned from `overlays()` and live on their own
		// layer-shell surfaces above apps.
		ltk::ShellMode::Layer( ltk::Layer::Background )
	}

	fn exclusive_zone( &self ) -> i32
	{
		// `-1` so the homescreen surface ignores the dock overlay's
		// reserved band and keeps covering the full output, wallpaper
		// included. App windows live on a separate xdg-shell layer, so
		// they are still unaffected.
		-1
	}

	fn set_channel_sender( &mut self, sender: ltk::ChannelSender<Message> )
	{
		crate::system::color_scheme_watcher::start( sender.clone() );
		crate::ipc::ForgeIpc::start_listener( sender );
	}

	fn on_scale_changed( &mut self, scale: u32 )
	{
		self.display_scale = scale.max( 1 );
	}

	fn on_resize( &mut self, width: u32, height: u32 )
	{
		self.screen_width  = width;
		self.screen_height = height;

		let mut qs_dock_reserve = 0u32;
		if !self.use_mobile_layout() && width > 0 && height > 0
		{
			let is_landscape = width > height;
			let dims = crate::homescreen::geometry::grid_dims( width, height, is_landscape );
			let geom = crate::homescreen::geometry::dock_geom_desktop( width, height, &dims );
			let pill_h = crate::dock::desktop_pill_height( geom.icon as u32 ).ceil() as u32;
			if let Some( ipc ) = &self.forge_ipc
			{
				ipc.set_dock_zone( pill_h );
			}
			if self.config.dock.always_visible
			{
				qs_dock_reserve = pill_h;
			}
		}
		self.quick_settings.set_dock_reserve( qs_dock_reserve );
	}

	fn view( &self ) -> ltk::Element<Message>
	{
		// Main surface is the wallpaper + clock + dock. Panels live in overlays().
		let mut cache = self.icon_cache.borrow_mut();
		// Live drop-preview slot for the desktop dock. While a drag is
		// in flight and the cursor is hovering over the dock band, the
		// dock parts to make a slot at the index a release would
		// commit to. `homescreen::dock_preview_index` returns `None`
		// when the cursor is outside the band (so the dock returns to
		// its unparted layout) or when accepting the drop would push
		// the pill past the icon grid's width (so the constraint is
		// communicated visually before the user releases).
		let dock_preview = self.dragging_item.as_ref().and_then( | drag |
		{
			let is_landscape = self.screen_width > self.screen_height;
			let adds_item    = !matches!( drag.origin, crate::homescreen::DragOrigin::Dock { .. } );
			crate::homescreen::dock_preview_index(
				drag.cursor.0, drag.cursor.1,
				self.screen_width, self.screen_height,
				self.config.dock.items.len(),
				adds_item,
				is_landscape,
			)
		} );

		let homescreen = self.homescreen.view(
			&self.config,
			&self.launcher.apps,
			&mut cache,
			self.launcher.default_icon(),
			self.screen_width,
			self.screen_height,
			self.current_page,
			self.drag_dx,
			Message::ToggleLauncher,
			Message::LaunchApp,
			Message::ActivateApp,
			|origin, app_id| Message::ShowEditMenu { origin, app_id },
			|origin, app_id| Message::StartDrag    { origin, app_id },
			Message::GoToPage,
			dock_preview,
			&self.running_apps,
		);

		homescreen
	}

	fn overlays( &self ) -> Vec<ltk::OverlaySpec<Message>>
	{
		self.build_overlays()
	}

	fn subsurfaces( &self ) -> Vec<ltk::SubsurfaceSpec<Message>>
	{
		self.build_subsurfaces()
	}

	fn subsurface_motion_only( &self ) -> bool
	{
		// While the launcher slides (subsurface) or QS slides (in its own
		// overlay), the homescreen behind stays static — skip its per-frame
		// re-raster. The launcher panel rides a subsurface; QS re-renders in
		// its overlay each frame but the main surface still doesn't change.
		self.launcher.subsurface_shown() || self.quick_settings.in_motion()
	}

	fn update( &mut self, msg: Message )
	{
		if self.handle_quicksettings_msg( &msg )
		{
			return;
		}
		match msg
		{
			Message::Tick =>
			{
				// Re-resolve the theme on every tick so
				// `ThemePreference::Auto` flips Light/Dark as the
				// clock crosses the sunrise / sunset threshold
				// without needing a shell restart. `apply_theme_mode`
				// is a no-op when the resolved value matches the
				// cached one. The shell-wide helper also forwards
				// the mode to forge and re-decodes the wallpapers
				// — see `ForgeIpc::set_theme` for why this can't
				// come from gsettings alone, and the helper's doc
				// comment for why `Tick` does NOT promote
				// `theme_preference` to a concrete variant.
				use chrono::Timelike as _;
				let resolved = self.theme_preference.resolve( chrono::Local::now().hour() );
				self.apply_theme_mode( resolved );

				self.splash.gc( std::time::Instant::now() );
			}

			Message::AnimationFrame =>
			{
				// State-only animation progress is advanced from
				// `poll_external`; the message exists so the runtime
				// applies invalidation for the frame.
			}

			Message::ExternalColorScheme( mode ) =>
			{
				// Skip the bounce from our own gsettings write — it would clobber Auto.
				if mode == self.current_mode { return; }
				if matches!( self.theme_preference, ltk::ThemePreference::Auto )
				{
					self.set_theme_explicit( mode );
				}
			}

			Message::OpenQuickSettings =>
			{
				self.calendar_visible = false;
				if self.quick_settings.toggle_from_topbar_press()
				{
					self.quick_settings.close_pickers();
				}
			}

			Message::StartQuickSettingsWipe =>
			{
				self.calendar_visible = false;
				self.quick_settings.start_external_wipe();
			}

			Message::QuickSettingsWiping( progress ) =>
			{
				self.calendar_visible = false;
				self.quick_settings.set_external_wipe_progress( progress );
			}

			Message::FinishQuickSettingsWipe { committed, progress } =>
			{
				self.calendar_visible = false;
				self.quick_settings.finish_external_wipe( committed, progress );
			}

			Message::ToggleCalendar =>
			{
				self.quick_settings.close_immediate();
				self.calendar_visible = !self.calendar_visible;
				if self.calendar_visible
				{
					use chrono::Datelike as _;
					let today = chrono::Local::now().date_naive();
					self.calendar_view_year  = today.year();
					self.calendar_view_month = today.month() as u8;
				}
			}

			Message::CalendarNavigate { year, month } =>
			{
				self.calendar_view_year  = year;
				self.calendar_view_month = month;
			}

			Message::RevealDock =>
			{
				if !self.config.dock.always_visible
				{
					self.dock_reveal.reveal();
				}
			}

			Message::HideDock =>
			{
				if !self.config.dock.always_visible
				{
					self.dock_reveal.schedule_hide();
				}
			}

			Message::LaunchApp( app_id ) =>
			{
				if let Some( entry ) = self.launcher.apps.iter().find( |a| a.app_id == app_id )
				{
					let timeout = std::time::Duration::from_secs( self.config.shell.splash_timeout_secs );
					self.splash.start( entry, timeout );
				}
				self.launcher.launch( &app_id );
			}

			Message::ActivateApp( app_id ) =>
			{
				if let Some( ipc ) = &self.forge_ipc
				{
					ipc.activate_app_id( &app_id );
				}
			}

			Message::LauncherSearch( q ) =>
			{
				self.launcher.set_search_query( q );
			}

			Message::LauncherSearchSubmit =>
			{
				if let Some( app_id ) = self.launcher.selected_search_app_id()
				{
					if let Some( entry ) = self.launcher.apps.iter().find( |a| a.app_id == app_id )
					{
						let timeout = std::time::Duration::from_secs( self.config.shell.splash_timeout_secs );
						self.splash.start( entry, timeout );
					}
					self.launcher.launch( &app_id );
				}
			}

			Message::LauncherSearchPrev =>
			{
				self.launcher.move_search_selection( -1 );
			}

			Message::LauncherSearchNext =>
			{
				self.launcher.move_search_selection( 1 );
			}

			Message::LauncherSearchRowUp =>
			{
				let cols = crate::launcher::column_count_for( self.screen_width, self.screen_width > self.screen_height ) as i32;
				self.launcher.move_search_selection( -cols );
			}

			Message::LauncherSearchRowDown =>
			{
				let cols = crate::launcher::column_count_for( self.screen_width, self.screen_width > self.screen_height ) as i32;
				self.launcher.move_search_selection( cols );
			}

			Message::ShowEditMenu { origin, app_id } =>
			{
				// Open the context menu only — no drag arming. Touch
				// pairs this with `StartDrag` in the same batch so the
				// menu opens AND the drag is armed; mouse right-click
				// fires this alone so the menu opens without entering
				// drag mode. Launcher icons do not wire `on_long_press`
				// at all (no menu — items are append-only) so this
				// arm only ever sees Dock / Grid origins.
				self.edit_mode = true;
				self.edit_menu = self.build_edit_menu( &origin, &app_id );
			}

			Message::StartDrag { origin, app_id } =>
			{
				// Arm the drag regardless of origin.
				// `drag_origin_cursor` fills on the first
				// `on_drag_move`, at which point the threshold
				// check in there decides whether to stay in
				// menu-only mode or commit to a real drag.
				self.drag_origin_cursor = None;
				self.drag_moved         = false;
				self.dragging_item = Some( crate::drag::DraggingItem {
					app_id: app_id.clone(),
					cursor: ( 0.0, 0.0 ),
					origin: origin.clone(),
					edge:   None,
					initial_page_count: self.config.mobile.pages.len(),
				} );

				if matches!( origin, DragOrigin::Launcher )
				{
					// Launcher drag keeps the existing "hide and
					// drag" flow — no menu, no edit mode: launcher
					// items only ever get *added* somewhere, and the
					// launcher gets out of the way for the drag.
					self.launcher.hide();
				}
			}

			Message::EditMenuInfo =>
			{
				// Swap menu → info overlay for the same item.
				let app_id = self.edit_menu.as_ref().map( |m| m.app_id.clone() );
				self.edit_menu = None;
				if let Some( app_id ) = app_id
				{
					self.app_info = Some( crate::appinfo::AppInfo { app_id } );
				}
			}

			Message::EditMenuRemove =>
			{
				if let Some( menu ) = self.edit_menu.take()
				{
					self.remove_by_origin( &menu.origin );
					if let Err( e ) = self.config.save()
					{
						eprintln!( "[crustace] failed to save config after remove: {e}" );
					}
				}
				// Stay in edit mode so the user can keep tidying up.
			}

			Message::EditMenuUninstall =>
			{
				// Phase 3 ships the menu item but not the uninstall
				// machinery — that requires per-app-type backends
				// and a privileged helper, both outside the current
				// scope.
				let fallback_name = t!( "uninstall_fallback_name" ).to_string();
				let name = self.edit_menu
					.as_ref()
					.map( |m| m.app_name.clone() )
					.unwrap_or( fallback_name );
				self.osd.show(
					t!( "uninstall_not_implemented", name = name ).to_string(),
					0.0,
				);
				self.edit_menu = None;
			}

			Message::EditMenuClose =>
			{
				if let Some( menu ) = self.edit_menu.take()
				{
					if let Some( ipc ) = &self.forge_ipc
					{
						let wayland_id = self.running_apps.wayland_id_for( &menu.app_id )
							.map( str::to_string )
							.unwrap_or( menu.app_id.clone() );
						ipc.close_app_id( &wayland_id );
					}
				}
				self.edit_mode = false;
			}

			Message::EditMenuCancel =>
			{
				self.edit_menu = None;
				self.edit_mode = false;
			}

			Message::CloseAppInfo =>
			{
				self.app_info = None;
			}

			Message::ShowPowerMenu =>
			{
				self.power_menu.show();
				self.quick_settings.close_immediate();
			}

			Message::PowerAction( action ) =>
			{
				self.power_menu.dismiss();
				match action
				{
					PowerAction::Logout =>
					{
						// Stop the systemd user unit that owns the
						// whole session (`crustace.service` in
						// `data/crustace.service`). Forge is the unit's
						// main process, so systemd's SIGTERM tears it
						// down; that cascades to `graphical-session.target`
						// via `BindsTo` and brings down crustace itself
						// once the Wayland connection drops. If the unit
						// is not loaded (running crustace standalone for
						// dev) the spawn errors out silently and we fall
						// through to `exit(0)` so the shell still quits.
						let _ = std::process::Command::new( "systemctl" )
							.args( [ "--user", "stop", "crustace.service" ] )
							.spawn();
						std::process::exit( 0 );
					}
					PowerAction::Cancel => {}
					other =>
					{
						PowerMenu::execute( other );
					}
				}
			}

			Message::ToggleLauncher =>
			{
				self.launcher.toggle();
				if self.launcher.is_shown()
				{
					self.quick_settings.close_immediate();
				}
			}

			Message::StartLauncherWipe =>
			{
				if !self.quick_settings.is_shown() { self.launcher.start_external_wipe(); }
			}

			Message::LauncherWiping( progress ) =>
			{
				if !self.quick_settings.is_shown() { self.launcher.set_external_wipe_progress( progress ); }
			}

			Message::FinishLauncherWipe { committed, progress } =>
			{
				if !self.quick_settings.is_shown() { self.launcher.finish_external_wipe( committed, progress ); }
			}

			Message::ShowHomescreen =>
			{
				// Return to the bare homescreen: hide every overlay
				// panel regardless of its current state. Forge fires
				// this when the user closes the last application, so
				// the shell should land on the desktop, not reopen
				// whatever was on top last.
				self.launcher.hide();
				self.quick_settings.close_immediate();
				self.quick_settings.close_pickers();
				self.calendar_visible = false;
				self.power_menu.dismiss();
			}

			Message::GoToPage( target ) =>
			{
				self.jump_to_page( target );
			}

			Message::ToggleOverview =>
			{
				// Forge fires `toggle_overview` when the user throws the
				// pointer into the top-left active corner. The shell
				// interprets it as "open / close the app launcher" —
				// the GNOME Activities affordance lands on the app
				// drawer, which on a Liberux session is the more
				// useful first surface than a window-overview pane.
				// Mirrors `ToggleLauncher` so a shortcut and a hot
				// corner share one code path.
				self.launcher.toggle();
				if self.launcher.is_shown()
				{
					self.quick_settings.close_immediate();
				}
			}

			Message::ToplevelEvent( event ) =>
			{
				match event
				{
					ltk::ToplevelEvent::Opened { id, app_id } if !app_id.is_empty() =>
					{
						let resolved = crate::running::match_app_id( &app_id, &self.launcher.apps )
							.map( |a| ( a.app_id.clone(), a.exec.clone(), a.is_terminal_emulator ) );

						// Some apps (e.g. foot) reset their app_id to a random
						// token right before sending the `closed` event. If the
						// new app_id does not resolve to any known .desktop entry
						// AND this handle is already tracked under a real entry,
						// ignore the update so the dock indicator stays correct
						// until the actual close event fires.
						if resolved.is_none() && self.running_apps.has_handle( id )
						{
							tracing::debug!(
								"toplevel update id={} app_id='{}' (unresolved, handle already tracked — ignored)",
								id, app_id
							);
						}
						else
						{
							let ( desktop_app_id, resolved_exec, is_term_emu ) = match resolved
							{
								Some( ( id, exec, te ) ) => ( id, Some( exec ), te ),
								None                     => ( app_id.clone(), None, false ),
							};
							tracing::info!( "toplevel opened id={} app_id='{}' → desktop='{}'", id, app_id, desktop_app_id );
							self.splash.dismiss( &desktop_app_id, resolved_exec.as_deref(), is_term_emu );
							self.running_apps.open( id, desktop_app_id, app_id );
						}
					}
					ltk::ToplevelEvent::Opened { id, app_id } => {
						tracing::info!( "toplevel opened id={} app_id='{}' (empty, ignored)", id, app_id );
					}
					ltk::ToplevelEvent::Closed { id } =>
					{
						tracing::info!( "toplevel closed id={}", id );
						self.running_apps.close( id );
					}
				}
			}

			Message::SetBrightness( .. )
			| Message::SetVolume( .. )
			| Message::CloseQuickSettings
			| Message::Toggle( .. )
			| Message::OpenWifiPicker
			| Message::ConnectWifi( .. )
			| Message::OpenBluetoothPicker
			| Message::ConnectBluetooth( .. )
			| Message::OpenSettings
			| Message::MediaPlayPause
			| Message::MediaNext
			| Message::MediaPrevious
			| Message::MediaStateChanged
			| Message::HwVolumeUp
			| Message::HwVolumeDown
			| Message::HwMute
			| Message::HwBrightnessUp
			| Message::HwBrightnessDown => unreachable!(),
		}
	}

	/// Slider drags fire `SetBrightness` / `SetVolume` on every pointer
	/// event — typically 100+/s. The default `InvalidationScope::All`
	/// rebuilds and redraws every surface for each one, which on a
	/// Glass-heavy QS panel pushes per-frame cost close enough to the
	/// 16 ms budget that the slider visibly stutters.
	///
	/// Two trims, both safe because the message handler in
	/// `quicksettings::handle_quicksettings_msg` already dedupes
	/// state writes against the current percent:
	///
	/// * **Skip-redraw on no-op moves** — when the slider's f32 value
	///   rounds to the same `u8` percent as the current state, return
	///   `Only( vec![ ] )` so the run loop neither rebuilds the view
	///   tree nor requests a redraw. A typical full sweep of the
	///   slider has ~100 distinct percents but ~thousands of pointer
	///   events; without this, ~95 % of those events were paying for
	///   a render that produced an identical pixel output.
	/// * **Scope to the QS overlay** — when the percent does change,
	///   only the QS overlay's contents could have changed; the
	///   homescreen / dock on `Main` did not. Returning
	///   `Only( vec![ Overlay( OVERLAY_QUICK_SETTINGS ) ] )` skips the
	///   `Main` view rebuild + redraw entirely for the duration of
	///   the drag. Saves the homescreen page-row layout pass, which
	///   is non-trivial on the L5.
	fn invalidate_after( &self, msg: &Message ) -> ltk::InvalidationScope
	{
		match msg
		{
			Message::SetBrightness( val ) =>
			{
				let new_bright = ( val * 100.0 ).clamp( 0.0, 100.0 ) as u8;
				if new_bright == self.system_state.brightness
				{
					ltk::InvalidationScope::Only( vec![] )
				}
				else
				{
					ltk::InvalidationScope::Only( vec![
						ltk::SurfaceTarget::Overlay( crate::overlays::OVERLAY_QUICK_SETTINGS ),
					] )
				}
			}
			Message::SetVolume( val ) =>
			{
				let new_vol = ( val * 100.0 ).clamp( 0.0, 100.0 ) as u8;
				if new_vol == self.system_state.audio.volume
				{
					ltk::InvalidationScope::Only( vec![] )
				}
				else
				{
					ltk::InvalidationScope::Only( vec![
						ltk::SurfaceTarget::Overlay( crate::overlays::OVERLAY_QUICK_SETTINGS ),
					] )
				}
			}
			// MPRIS pushed a fresh state — only the QS card cares; the
			// homescreen, dock and lockscreen are blind to media state,
			// so skip their rebuild.
			Message::MediaStateChanged => ltk::InvalidationScope::Only( vec![
				ltk::SurfaceTarget::Overlay( crate::overlays::OVERLAY_QUICK_SETTINGS ),
			] ),
			Message::AnimationFrame => ltk::InvalidationScope::Only( vec![
				ltk::SurfaceTarget::Overlay( crate::overlays::OVERLAY_QUICK_SETTINGS ),
				ltk::SurfaceTarget::Overlay( crate::overlays::OVERLAY_LAUNCHER ),
				ltk::SurfaceTarget::Overlay( crate::overlays::OVERLAY_DOCK ),
				ltk::SurfaceTarget::Overlay( crate::overlays::OVERLAY_SPLASH ),
			] ),
			Message::ToplevelEvent( _ ) => ltk::InvalidationScope::Only( vec![
				ltk::SurfaceTarget::Main,
				ltk::SurfaceTarget::Overlay( crate::overlays::OVERLAY_DOCK ),
			] ),
			Message::RevealDock | Message::HideDock => ltk::InvalidationScope::Only( vec![
				ltk::SurfaceTarget::Overlay( crate::overlays::OVERLAY_DOCK ),
			] ),
			// Forge's topbar pull-down drives QS through these. The panel
			// slides as a subsurface (repositioned by the per-frame reconcile),
			// so only the QS host overlay needs its cheap transparent buffer
			// refreshed — falling through to `All` re-rastered the whole
			// homescreen behind it on every progress message, which is what
			// made the pull-down crawl.
			Message::StartQuickSettingsWipe
			| Message::QuickSettingsWiping( _ )
			| Message::FinishQuickSettingsWipe { .. } => ltk::InvalidationScope::Only( vec![
				ltk::SurfaceTarget::Overlay( crate::overlays::OVERLAY_QUICK_SETTINGS ),
			] ),
			// Forge's bottom swipe-up drives the launcher reveal through these,
			// the same way the topbar drives QS — the panel rides its subsurface,
			// so only the launcher host overlay needs refreshing.
			Message::StartLauncherWipe
			| Message::LauncherWiping( _ )
			| Message::FinishLauncherWipe { .. } => ltk::InvalidationScope::Only( vec![
				ltk::SurfaceTarget::Overlay( crate::overlays::OVERLAY_LAUNCHER ),
			] ),
			_ => ltk::InvalidationScope::All,
		}
	}

	fn poll_external( &mut self ) -> Vec<Message>
	{
		let mut msgs = Vec::new();

		// Receive system state from background poller
		if let Some( state ) = self.system_poller.try_recv()
		{
			self.system_state = state;
			msgs.push( Message::Tick );
		}

		// Update media player state (non-blocking — DBus listener
		// thread pushes new states as MPRIS PropertiesChanged
		// signals fire). Each fresh state pushes a
		// `MediaStateChanged` so the run loop applies the QS-only
		// invalidation in `invalidate_after` and the card
		// redraws with the new playing/title/cover.
		if let Some( state ) = self.mpris_poller.try_recv()
		{
			self.media_state = state;
			msgs.push( Message::MediaStateChanged );
		}

		// Auto-dismiss OSD
		self.osd.tick();

		// Advance the QS slide-down settle animation. poll_external
		// is called after every wayland event and every redraw while
		// `is_animating()` is true, so this runs at the same cadence
		// as the renderer.
		if self.quick_settings.tick_animation()
		{
			msgs.push( Message::AnimationFrame );
		}

		// Same cadence for the launcher's slide-up reveal — the two
		// panels never animate concurrently (each closes the other on
		// open), so a single `AnimationFrame` message covers either.
		// Invalidation routing in `invalidate_after` widens the scope
		// to both overlays so whichever one is moving redraws.
		if self.launcher.tick_animation()
		{
			msgs.push( Message::AnimationFrame );
		}

		if self.dock_reveal.tick_animation()
		{
			msgs.push( Message::AnimationFrame );
		}

		if self.splash.is_active()
		{
			msgs.push( Message::AnimationFrame );
		}

		// Homescreen paging and drag-edge nav.
		self.tick_page_anim();
		self.tick_edge_fire();

		msgs
	}

	fn on_first_frame_committed( &mut self )
	{
		let Some( sock_path ) = self.loginmanager_sock_path.take() else { return };
		if let Ok( mut stream ) = std::os::unix::net::UnixStream::connect( &sock_path )
		{
			use std::io::Write;
			let _ = stream.write_all( &[ 1u8 ] );
		}
	}

	fn poll_interval( &self ) -> Option<std::time::Duration>
	{
		// Mobile device — minimise wakeups for battery life.
		// Clock only shows HH:MM, so half-minute granularity is enough.
		Some( std::time::Duration::from_secs( POLL_INTERVAL_SECS ) )
	}

	fn is_animating( &self ) -> bool
	{
		// Keep polling while a lateral edge dwell is armed so the
		// dwell timer fires even if the finger sits perfectly still
		// on the edge.
		self.quick_settings.is_animating()
			|| self.launcher.is_animating()
			|| self.dock_reveal.is_animating()
			|| self.page_anim.is_some()
			|| self.splash.is_active()
			|| self.has_armed_edge_dwell()
	}

	fn on_swipe_up( &mut self ) -> Option<Message>
	{
		// The launcher reveal is owned by forge (bottom swipe-up → IPC).
		// ltk's up-swipe only dismisses an open QS panel.
		if self.quick_settings.is_shown()
		{
			self.quick_settings.close_animated();
		}
		None
	}

	fn on_swipe_down( &mut self ) -> Option<Message>
	{
		// User released past the commit threshold → finish opening
		// the panel. We mutate state directly here rather than
		// routing through a Message so the settle starts from the
		// exact progress the live drag left us at.
		self.calendar_visible = false;
		self.quick_settings.open_animated();
		None
	}

	fn on_swipe_down_progress( &mut self, progress: f32 )
	{
		self.quick_settings.on_swipe_progress( progress );
	}

	fn take_focus_request( &mut self ) -> Option<ltk::WidgetId>
	{
		// Mobile: don't auto-focus the launcher search field on open — that
		// raises the on-screen keyboard unbidden. The user taps the search
		// box to focus it and bring up the OSK.
		if self.use_mobile_layout()
		{
			self.launcher.clear_focus_request();
			return None;
		}
		self.launcher.take_focus_request()
	}

	fn on_tap( &mut self ) -> Option<Message>
	{
		// Each overlay dismisses itself via its own `on_dismiss`;
		// for the bare homescreen, a tap outside any icon exits edit
		// mode if we were in it (e.g. after a Remove closed the menu
		// but left edit mode on so the user could keep editing).
		if self.edit_mode && self.edit_menu.is_none() && self.app_info.is_none()
		{
			self.edit_mode = false;
		}
		None
	}

	fn on_key( &mut self, keysym: ltk::Keysym ) -> Option<Message>
	{
		match keysym
		{
			ltk::Keysym::XF86_PowerOff          => Some( Message::ShowPowerMenu ),
			ltk::Keysym::XF86_AudioRaiseVolume   => Some( Message::HwVolumeUp ),
			ltk::Keysym::XF86_AudioLowerVolume   => Some( Message::HwVolumeDown ),
			ltk::Keysym::XF86_AudioMute          => Some( Message::HwMute ),
			ltk::Keysym::XF86_MonBrightnessUp    => Some( Message::HwBrightnessUp ),
			ltk::Keysym::XF86_MonBrightnessDown  => Some( Message::HwBrightnessDown ),
			_ => None,
		}
	}

	fn on_key_with_modifiers( &mut self, keysym: ltk::Keysym, _ctrl: bool, shift: bool ) -> Option<Message>
	{
		if !self.launcher.is_shown() { return None; }
		match keysym
		{
			ltk::Keysym::Escape    => Some( Message::ToggleLauncher ),
			ltk::Keysym::Tab       => Some( if shift { Message::LauncherSearchPrev } else { Message::LauncherSearchNext } ),
			ltk::Keysym::ISO_Left_Tab => Some( Message::LauncherSearchPrev ),
			ltk::Keysym::Right     => Some( Message::LauncherSearchNext ),
			ltk::Keysym::Left      => Some( Message::LauncherSearchPrev ),
			ltk::Keysym::Down      => Some( Message::LauncherSearchRowDown ),
			ltk::Keysym::Up        => Some( Message::LauncherSearchRowUp ),
			_                      => None,
		}
	}

	fn swipe_threshold( &self ) -> f32
	{
		0.25
	}

	fn swipe_horizontal_threshold( &self ) -> f32
	{
		Crustace::swipe_horizontal_threshold( self )
	}

	fn on_swipe_horizontal_progress( &mut self, progress: f32 )
	{
		self.handle_swipe_horizontal_progress( progress );
	}

	fn on_swipe_left( &mut self ) -> Option<Message>
	{
		self.handle_swipe_left()
	}

	fn on_swipe_right( &mut self ) -> Option<Message>
	{
		self.handle_swipe_right()
	}

	fn on_drag_move( &mut self, x: f32, y: f32 )
	{
		self.handle_drag_move( x, y );
	}

	fn cursor_override( &self ) -> Option<ltk::CursorShape>
	{
		// Flip to a no-drop cursor whenever the current drag position
		// would be rejected on release — same predicate that drives
		// the ghost-opacity feedback in `view()`. forge does not yet
		// render `wp_cursor_shape_v1` so this is largely an idle hint
		// today; the ghost fade is the actual visible cue.
		let drag = self.dragging_item.as_ref()?;
		if self.drop_would_succeed( drag.cursor.0, drag.cursor.1 ) { None }
		else                                                       { Some( ltk::CursorShape::NoDrop ) }
	}

	fn on_drop( &mut self, x: f32, y: f32 ) -> Option<Message>
	{
		self.handle_drop( x, y )
	}

	fn on_toplevel_event( &self, event: ltk::ToplevelEvent ) -> Option<Message>
	{
		// Every open / close routes through the `ToplevelEvent` arm of
		// `update`, which is the only place that translates Wayland
		// `app_id` strings to `.desktop` entries and mutates
		// `running_apps`. `invalidate_after` scopes the redraw to
		// `Main` so the dock refresh does not drag overlays along.
		Some( Message::ToplevelEvent( event ) )
	}

	fn swipe_down_threshold( &self ) -> f32
	{
		crate::quicksettings::SWIPE_THRESHOLD
	}

	fn swipe_down_edge( &self ) -> f32
	{
		// Match the forge topbar height so the Quick Settings
		// pull-down only fires when the press starts inside the
		// status-bar strip — mirrors the Android / iOS status-bar
		// gesture. Below the topbar, vertical drags on the wallpaper
		// do nothing.
		const TOPBAR_PX: f32 = 28.0;
		if self.screen_height == 0 { return 0.05; }
		TOPBAR_PX * self.display_scale as f32 / self.screen_height as f32
	}

	fn background_color( &self ) -> ltk::Color
	{
		ltk::Color::rgba( 0.0, 0.0, 0.0, 0.0 )
	}
}

// ============================================================================
// RUN
// ============================================================================

pub fn run( config: Config )
{
	let shell = Crustace::new( config );
	ltk::run( shell );
}
