// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>

//! Combo / select / dropdown widget.
//!
//! Editable, searchable, multi-select, scrollable. Built as a pure
//! composition over existing widgets ([`text_edit`](super::text_edit),
//! [`list_item`](super::list_item), [`pressable`](super::pressable),
//! [`scroll`](super::scroll), [`viewport`](super::viewport),
//! [`container`](super::container), [`stack`](crate::layout::stack)),
//! so the runtime needs no special layout / dispatch path **and the
//! widget works in plain `xdg-shell` application windows** — the popup
//! is a `Stack` overlay layered into the same surface as the trigger,
//! not a separate Wayland surface.
//!
//! ## Mental model
//!
//! A `Combo` is a *projection* over a [`ComboState`] that the
//! application owns and mutates from `update()`. The widget itself is
//! stateless: every frame the app rebuilds it from current state and
//! consumes the messages the user produces.
//!
//! Two pieces flow into the app's view tree:
//!
//! 1. The **trigger** (returned by [`Combo::trigger`]) lives wherever the
//!    app puts a normal widget: a column, a row, inside a container. It
//!    paints the labelled pill, the optional chips for multi-select
//!    selections, the query text edit, the down-arrow toggle, and the
//!    helper / error rows.
//! 2. The **popup** (returned by [`Combo::popup`]) is `None` when the
//!    combo is closed and `Some(Element)` when open. The application
//!    layers it on top of the rest of the view via [`stack`](crate::stack).
//!    The returned element already contains a full-surface dismiss
//!    layer behind the panel so a tap outside fires
//!    [`Combo::on_dismiss`].
//!
//! ## Wiring
//!
//! ```rust,no_run
//! # #[ derive( Clone ) ] enum Msg {
//! #     FruitQuery( String ), FruitToggle,
//! #     FruitSelect( usize ), FruitUnselect( usize ), FruitDismiss,
//! # }
//! use ltk::{ App, Combo, ComboState, Element, combo, column, stack };
//!
//! struct AppState
//! {
//!     fruits: ComboState,
//! }
//!
//! impl AppState
//! {
//!     fn build_combo( &self ) -> Combo<Msg>
//!     {
//!         combo( self.fruits.clone(), [ "Apple", "Banana", "Cherry", "Date" ]
//!                 .iter().map( |s| s.to_string() ).collect() )
//!             .label( "Fruits" )
//!             .placeholder( "Pick one or more…" )
//!             .multi_select( true )
//!             .searchable( true )
//!             .on_query_change( Msg::FruitQuery )
//!             .on_toggle_open( Msg::FruitToggle )
//!             .on_select_idx( Msg::FruitSelect )
//!             .on_unselect_idx( Msg::FruitUnselect )
//!             .on_dismiss( Msg::FruitDismiss )
//!     }
//! }
//!
//! impl App for AppState
//! {
//!     type Message = Msg;
//!     fn view( &self ) -> Element<Msg>
//!     {
//!         let combo  = self.build_combo();
//!         let mut s  = stack::<Msg>().push( column().push( combo.trigger() ) );
//!         if let Some( p ) = combo.popup() { s = s.push( p ); }
//!         s.into()
//!     }
//! #     fn update( &mut self, _msg: Msg ) {}
//! }
//! ```
//!
//! `update()` then handles the messages: append to / remove from
//! `fruits.selected` on `FruitSelect` / `FruitUnselect`, flip
//! `fruits.is_open` on `FruitToggle` / `FruitDismiss`, and copy the new
//! query string into `fruits.query` on `FruitQuery`.
//!
//! ## Dismiss behaviour
//!
//! The runtime fires [`Combo::on_dismiss`] in three situations: the
//! compositor sends `xdg_popup.popup_done`; the user taps the main
//! surface outside the trigger pill while the popup is mapped; or the
//! user presses Escape. The app's only job is to flip `is_open` to
//! `false` in `update()` — the runtime is idempotent if the message
//! arrives more than once for the same open / close cycle. See
//! [`crate::app::OverlaySpec::on_dismiss`] for the full contract.

use std::sync::Arc;
use std::collections::hash_map::DefaultHasher;
use std::hash::{ Hash, Hasher };

use crate::app::{ Anchor, Layer, OverlayId, OverlaySpec };
use crate::types::{ Length, WidgetId };

use super::Element;

#[ cfg( test ) ]
mod tests;

/// Stable identifier of the trigger pill used as the popup's anchor.
/// Read by [`crate::widget::anchored_overlay::AnchoredOverlay`] in the
/// draw pass to position the popup flush below the trigger. A single
/// constant is enough as long as only one combo is open at a time —
/// the popup of a closed combo contributes nothing to the view tree,
/// so two combos with the same anchor id coexist fine when they don't
/// open simultaneously. Apps that need overlapping open combos should
/// switch to [`Combo::anchor_id`] with distinct ids.
const COMBO_ANCHOR_DEFAULT: WidgetId = WidgetId( "ltk-combo-trigger" );

/// Application-owned state for a [`Combo`].
///
/// Lives on the app struct and is mutated from `update()`. The widget
/// reads it once per frame to render the trigger and popup; nothing
/// inside ltk keeps a copy.
#[ derive( Debug, Clone, Default ) ]
pub struct ComboState
{
	/// Current text in the trigger's search field. Drives item filtering.
	/// Empty string means "no filter — show every item".
	pub query:    String,
	/// `true` when the popup is visible.
	pub is_open:  bool,
	/// Indices into the `items` slice currently selected. In single-select
	/// mode this is empty or has a single entry; in multi-select mode it
	/// can hold any subset.
	pub selected: Vec<usize>,
}

impl ComboState
{
	/// Empty state: no query, popup closed, nothing selected.
	pub fn new() -> Self { Self::default() }

	/// Convenience: toggle `is_open`.
	pub fn toggle_open( &mut self ) { self.is_open = !self.is_open; }

	/// Convenience: add `idx` to `selected` if not present.
	pub fn select( &mut self, idx: usize )
	{
		if !self.selected.contains( &idx ) { self.selected.push( idx ); }
	}

	/// Convenience: remove `idx` from `selected` if present.
	pub fn unselect( &mut self, idx: usize )
	{
		self.selected.retain( |&i| i != idx );
	}
}

/// A combo / select / dropdown widget.
///
/// See the module-level documentation for the full wiring pattern.
/// Build via [`combo`] and configure with the chained builders.
pub struct Combo<Msg: Clone>
{
	state:             ComboState,
	items:             Vec<String>,
	label:             Option<String>,
	description:       Option<String>,
	placeholder:       Option<String>,
	helper:            Option<String>,
	error:             Option<String>,
	disabled:          bool,
	multi_select:      bool,
	searchable:        bool,
	max_chips_visible: usize,
	anchor_id:         WidgetId,
	popup_gap:         f32,
	popup_width:       f32,
	popup_max_height:  f32,
	on_query_change:   Option<Arc<dyn Fn( String ) -> Msg>>,
	on_toggle_open:    Option<Msg>,
	on_select_idx:     Option<Arc<dyn Fn( usize ) -> Msg>>,
	on_unselect_idx:   Option<Arc<dyn Fn( usize ) -> Msg>>,
	on_dismiss:        Option<Msg>,
}

impl<Msg: Clone> Combo<Msg>
{
	/// Construct a combo over `items` driven by `state`. Both arguments
	/// are taken by value because the widget tree is rebuilt every
	/// frame; clone the app's state slice into here.
	pub fn new( state: ComboState, items: Vec<String> ) -> Self
	{
		Self
		{
			state,
			items,
			label:             None,
			description:       None,
			placeholder:       None,
			helper:            None,
			error:             None,
			disabled:          false,
			multi_select:      false,
			searchable:        false,
			max_chips_visible: 4,
			anchor_id:         COMBO_ANCHOR_DEFAULT,
			popup_gap:         4.0,
			popup_width:       320.0,
			popup_max_height:  280.0,
			on_query_change:   None,
			on_toggle_open:    None,
			on_select_idx:     None,
			on_unselect_idx:   None,
			on_dismiss:        None,
		}
	}

	/// Bold label drawn above the trigger.
	pub fn label( mut self, s: impl Into<String> ) -> Self
	{
		self.label = Some( s.into() );
		self
	}

	/// Optional descriptive paragraph drawn below the label.
	pub fn description( mut self, s: impl Into<String> ) -> Self
	{
		self.description = Some( s.into() );
		self
	}

	/// Placeholder for the trigger's text edit when the query is empty.
	pub fn placeholder( mut self, s: impl Into<String> ) -> Self
	{
		self.placeholder = Some( s.into() );
		self
	}

	/// Helper / informative text below the trigger. Hidden when an
	/// error message is set.
	pub fn helper( mut self, s: impl Into<String> ) -> Self
	{
		self.helper = Some( s.into() );
		self
	}

	/// Error message below the trigger. Replaces the helper text and
	/// applies the destructive theme tokens to the trigger surface.
	pub fn error( mut self, s: impl Into<String> ) -> Self
	{
		self.error = Some( s.into() );
		self
	}

	/// Render in the disabled style. Activations / selections still emit
	/// messages — the consumer is responsible for ignoring them in
	/// `update()`.
	pub fn disabled( mut self, yes: bool ) -> Self
	{
		self.disabled = yes;
		self
	}

	/// Allow multiple items to be selected at once. Selected items are
	/// rendered as chips above the trigger.
	pub fn multi_select( mut self, yes: bool ) -> Self
	{
		self.multi_select = yes;
		self
	}

	/// Make the trigger an editable text field that filters the popup
	/// list as the user types. Without `searchable`, the trigger is a
	/// pressable button that displays the current selection.
	pub fn searchable( mut self, yes: bool ) -> Self
	{
		self.searchable = yes;
		self
	}

	/// Cap on the number of selection chips drawn above the trigger
	/// before falling back to a "+N more" indicator. Default `4`.
	pub fn max_chips_visible( mut self, n: usize ) -> Self
	{
		self.max_chips_visible = n;
		self
	}

	/// Stable identifier of the trigger pill. The popup looks the rect
	/// of this widget up in the previous frame's layout snapshot to
	/// place itself flush below the trigger. Apps with several combos
	/// that may open simultaneously must give each a distinct id.
	pub fn anchor_id( mut self, id: WidgetId ) -> Self
	{
		self.anchor_id = id;
		self
	}

	/// Vertical gap (logical pixels) between the bottom of the trigger
	/// and the top of the popup panel. Default `4`.
	pub fn popup_gap( mut self, px: f32 ) -> Self
	{
		self.popup_gap = px.max( 0.0 );
		self
	}

	/// Width of the popup in logical pixels. Default `320`.
	pub fn popup_width( mut self, px: f32 ) -> Self
	{
		self.popup_width = px.max( 80.0 );
		self
	}

	/// Maximum height of the popup before it scrolls internally. Default
	/// `280`. The popup never grows past this height; it shrinks to fit
	/// the filtered item list when shorter.
	pub fn popup_max_height( mut self, px: f32 ) -> Self
	{
		self.popup_max_height = px.max( 80.0 );
		self
	}

	/// Callback fired with the new query string on every keystroke
	/// inside the trigger's search field. Required when `searchable( true )`.
	pub fn on_query_change( mut self, f: impl Fn( String ) -> Msg + 'static ) -> Self
	{
		self.on_query_change = Some( Arc::new( f ) );
		self
	}

	/// Message emitted when the user activates the trigger to toggle the
	/// popup open / closed (tap on the trigger pill or press the
	/// down-arrow icon button).
	pub fn on_toggle_open( mut self, msg: Msg ) -> Self
	{
		self.on_toggle_open = Some( msg );
		self
	}

	/// Callback fired with the index of the item the user selects from
	/// the popup. The application's `update()` should add that index to
	/// `state.selected` (multi-select) or replace it (single-select).
	pub fn on_select_idx( mut self, f: impl Fn( usize ) -> Msg + 'static ) -> Self
	{
		self.on_select_idx = Some( Arc::new( f ) );
		self
	}

	/// Callback fired with the index of a chip the user dismisses
	/// (tapping its `×`). Multi-select only.
	pub fn on_unselect_idx( mut self, f: impl Fn( usize ) -> Msg + 'static ) -> Self
	{
		self.on_unselect_idx = Some( Arc::new( f ) );
		self
	}

	/// Message emitted when the user taps outside the popup. Typically
	/// flips `state.is_open` back to `false` in `update()`.
	pub fn on_dismiss( mut self, msg: Msg ) -> Self
	{
		self.on_dismiss = Some( msg );
		self
	}

	// ── filtering / item lookup ──────────────────────────────────────────

	/// Return the indices of items that match the current query (case
	/// insensitive `contains` match). Empty query passes every item.
	pub fn filtered_indices( &self ) -> Vec<usize>
	{
		let q = self.state.query.to_lowercase();
		if q.is_empty()
		{
			return ( 0..self.items.len() ).collect();
		}
		self.items.iter().enumerate()
			.filter( |( _, item )| item.to_lowercase().contains( &q ) )
			.map( |( i, _ )| i )
			.collect()
	}

	fn first_selected_label( &self ) -> Option<&str>
	{
		self.state.selected.first()
			.and_then( |&i| self.items.get( i ) )
			.map( |s| s.as_str() )
	}
}

// Methods that build widget trees. Split into a `'static` impl block so
// `From<Combo<Msg>>` and the constructor functions don't fight over
// generic bounds.
impl<Msg: Clone + 'static> Combo<Msg>
{
	/// Build the trigger: label / description / chips (multi-select) /
	/// pill with text-edit + arrow / helper or error row. Returns an
	/// [`Element`] ready to drop into [`App::view`](crate::App::view).
	pub fn trigger( &self ) -> Element<Msg>
	{
		use super::{ container, text, text_edit };
		use super::flex::flex;
		use super::pressable::pressable;
		use crate::layout::column::column;
		use crate::layout::row::row;
		use crate::layout::spacer::spacer;

		let palette = crate::theme::palette();

		let mut col = column::<Msg>().padding( 0.0 ).spacing( 8.0 ).align_center_x( false );

		// Label.
		if let Some( ref s ) = self.label
		{
			let c = if self.disabled { palette.text_secondary } else { palette.text_primary };
			col = col.push( text( s.clone() ).size( 16.0 ).color( c ) );
		}

		// Descriptive text.
		if let Some( ref s ) = self.description
		{
			col = col.push( text( s.clone() ).size( 16.0 ).color( palette.text_secondary ) );
		}

		// Chip strip (multi-select with at least one selection).
		if self.multi_select && !self.state.selected.is_empty()
		{
			const CHIP_X_PX: u32 = 14;
			let chip_x = crate::theme::icon_rgba( "window/close", CHIP_X_PX )
				.map( | ( rgba, w, h ) |
				{
					let tinted = std::sync::Arc::new(
						crate::theme::tint_symbolic( &rgba, palette.text_primary ),
					);
					( tinted, w, h )
				} );
			let mut chips = row::<Msg>().padding( 0.0 ).spacing( 6.0 );
			let visible   = self.state.selected.iter().take( self.max_chips_visible );
			for &idx in visible
			{
				if let Some( label ) = self.items.get( idx ).cloned()
				{
					let label_color = palette.text_primary;
					let mut chip_row = row::<Msg>().padding( 0.0 ).spacing( 10.0 )
						.push( text( label ).size( 14.0 ).color( label_color ) );
					if let Some( cb ) = self.on_unselect_idx.as_ref()
					{
						let msg = cb( idx );
						if let Some( ( ref rgba, w, h ) ) = chip_x
						{
							let btn = crate::widget::icon_button::<Msg>( std::sync::Arc::clone( rgba ), w, h )
								.icon_size( CHIP_X_PX as f32 )
								.on_press( msg );
							chip_row = chip_row.push( btn );
						}
					}
					let chip: Element<Msg> = container::<Msg>( chip_row )
						.background( palette.surface_alt )
						.padding_h( 10.0 )
						.padding_v( 4.0 )
						.radius( 16.0 )
						.into();
					chips = chips.push( chip );
				}
			}
			let extra = self.state.selected.len().saturating_sub( self.max_chips_visible );
			if extra > 0
			{
				chips = chips.push(
					text( format!( "+{extra}" ) ).size( 14.0 ).color( palette.text_secondary ),
				);
			}
			col = col.push( chips );
		}

		// Trigger pill: text-edit (if searchable) + arrow toggle.
		let trigger_inner: Element<Msg> = {
			let mut r = row::<Msg>().padding( 0.0 ).spacing( 8.0 );

			if self.searchable
			{
				let placeholder = self.placeholder.clone().unwrap_or_default();
				let value       = self.state.query.clone();
				let mut te      = text_edit::<Msg>( placeholder, value );
				if let Some( cb ) = self.on_query_change.as_ref()
				{
					let cb = Arc::clone( cb );
					te = te.on_change( move |s| cb( s ) );
				}
				// Wrap in `flex` so the text edit consumes the leftover
				// width inside the row instead of claiming `max_width` for
				// itself and pushing the down-arrow off-screen.
				r = r.push( flex( te ) );
			}
			else
			{
				let display = self.first_selected_label()
					.map( |s| s.to_string() )
					.or_else( || self.placeholder.clone() )
					.unwrap_or_default();
				let c = if self.first_selected_label().is_some()
				{
					palette.text_primary
				}
				else
				{
					palette.text_secondary
				};
				r = r.push( text( display ).size( 16.0 ).color( c ) );
				// Push the down-arrow to the right edge.
				r = r.push( spacer() );
			}

			const CHEVRON_PX: u32 = 18;
			let icon_name = if self.state.is_open
			{
				"general/up-simple"
			} else {
				"general/down-simple"
			};
			if let Some( ( rgba, w, h ) ) = crate::theme::icon_rgba( icon_name, CHEVRON_PX )
			{
				let tinted = std::sync::Arc::new(
					crate::theme::tint_symbolic( &rgba, palette.text_primary ),
				);
				let toggle_button = crate::widget::icon_button::<Msg>( tinted, w, h )
					.icon_size( CHEVRON_PX as f32 );
				if let Some( ref msg ) = self.on_toggle_open
				{
					r = r.push( toggle_button.on_press( msg.clone() ) );
				}
				else
				{
					r = r.push( toggle_button );
				}
			}
			r.into()
		};

		let ( pill_bg, pill_border ) = if self.error.is_some()
		{
			( palette.danger_bg, palette.danger )
		}
		else
		{
			( palette.surface_alt, palette.divider )
		};
		let pill: Element<Msg> = container::<Msg>( trigger_inner )
			.background( pill_bg )
			.border( pill_border, 1.0 )
			.padding_h( 16.0 )
			.padding_v( 12.0 )
			.radius( 32.0 )
			.into();

		// Always wrap the pill in a pressable so the popup toggles when
		// the user taps anywhere on the pill chrome. Inner interactive
		// widgets (text_edit, button) keep priority because the layout
		// pass pushes the pressable's hit rect *before* recursing into
		// the children — `find_widget_at` iterates the rect list in
		// reverse, so deeper widgets win on overlap. Without this
		// fallback, clicks that hit the gap between the text edit and
		// the chevron button (or the rounded corners outside any child
		// rect) silently land on nothing.
		//
		// The pressable also carries the anchor id so the popup can
		// look the trigger pill's rect up at draw time and place
		// itself flush below.
		let pill: Element<Msg> = {
			let p = pressable::<Msg>( pill ).id( self.anchor_id );
			let p = if let Some( ref msg ) = self.on_toggle_open
			{
				p.on_press( msg.clone() )
			} else { p };
			p.into()
		};

		col = col.push( pill );

		// Helper / error row.
		if let Some( ref err ) = self.error
		{
			col = col.push( text( err.clone() ).size( 14.0 ).color( palette.danger ) );
		}
		else if let Some( ref h ) = self.helper
		{
			col = col.push( text( h.clone() ).size( 14.0 ).color( palette.text_secondary ) );
		}

		col.into()
	}

	/// Build the popup as a [`Stack`](crate::Stack)-overlayable element.
	/// Returns `None` when the combo is closed.
	///
	/// The returned element is meant to be layered **on top of the rest
	/// of the application's view tree** in the same surface — typically
	/// by wrapping the app's `view()` output in a [`stack`](crate::stack)
	/// and pushing this element when it is `Some`. It already contains:
	///
	/// 1. A full-surface dismiss layer behind the panel — a transparent
	///    [`pressable`](super::pressable::Pressable) that fires
	///    [`Combo::on_dismiss`] when the user taps anywhere outside the
	///    panel.
	/// 2. The panel itself, centred horizontally and anchored 80 px from
	///    the top of the available rect, with the filtered item list
	///    inside a scrolling viewport bounded by [`Combo::popup_width`]
	///    and [`Combo::popup_max_height`].
	///
	/// The popup centres modal-style — it does not anchor to the trigger
	/// position because the trigger's screen-space rect is not known at
	/// `view()` time. For a trigger-anchored popup, drive the popup's
	/// position from a runtime hint the application stores after the
	/// first hit-test.
	pub fn popup( &self ) -> Option<Element<Msg>>
	{
		if !self.state.is_open { return None; }

		use super::container;
		use super::list_item::list_item;
		use super::pressable::pressable;
		use super::scroll::scroll;
		use super::text;
		use super::viewport::viewport;
		use crate::layout::column::column;
		use crate::layout::spacer::spacer;
		use crate::layout::stack::{ stack, HAlign, VAlign };

		let palette = crate::theme::palette();

		// Build the filtered list of items. The list_item widget paints
		// its own hover / pressed surfaces; `selected( true )` overrides
		// both with the dark surface + white text variant the design
		// system specifies for the picked option.
		let mut list = column::<Msg>().padding( 4.0 ).spacing( 0.0 ).align_center_x( false );
		let filtered = self.filtered_indices();
		for idx in &filtered
		{
			let label       = self.items[ *idx ].clone();
			let is_selected = self.state.selected.contains( idx );
			let mut li      = list_item::<Msg>( label ).selected( is_selected );
			if let Some( cb ) = self.on_select_idx.as_ref()
			{
				li = li.on_press( cb( *idx ) );
			}
			list = list.push( li );
		}

		// Empty-list hint when no item matches the query.
		if filtered.is_empty()
		{
			list = list.push(
				text( "No matches" ).size( 14.0 ).color( palette.text_secondary ),
			);
		}

		// Wrap the list in a viewport that pins the popup's max height
		// and lets the inner scroll do its thing when the content
		// overflows.
		let scroller: Element<Msg> = scroll::<Msg>( list ).into();
		let bounded:  Element<Msg> = viewport::<Msg>( scroller )
			.width( self.popup_width )
			.height( self.popup_max_height )
			.into();

		let panel: Element<Msg> = container::<Msg>( bounded )
			.background( palette.surface_alt )
			.border( palette.divider, 1.0 )
			.padding( 8.0 )
			.radius( 32.0 )
			.into();

		// Build the popup as a Stack with two layers:
		//   - layer 0 (Fill, Fill): full-surface dismiss layer. Built
		//     as `pressable( spacer() )` because Spacer reports zero
		//     intrinsic size and Stack's `Fill` alignment grows it to
		//     the full Stack rect (which the user installs at the root
		//     of `view()`, so it spans the whole surface).
		//   - layer 1 (Start, Top): the actual panel. The outer
		//     `AnchoredOverlay` below relocates this layer to flush
		//     below the trigger; without an anchor (first frame after
		//     open) the layer renders at the surface's top-left, which
		//     is acceptable as a one-frame artefact.
		let mut s = stack::<Msg>();
		if let Some( ref dismiss ) = self.on_dismiss
		{
			let backdrop: Element<Msg> = pressable::<Msg>( spacer() )
				.on_press( dismiss.clone() )
				.into();
			s = s.push( backdrop );
		}

		// The dismiss layer wants the FULL surface; the panel wants the
		// anchored rect under the trigger. They cannot share a single
		// `AnchoredOverlay` — wrapping the Stack root would relocate
		// both layers and break the dismiss layer's full-coverage
		// requirement.
		//
		// Solution: keep the dismiss layer at root level, and wrap
		// only the panel in `AnchoredOverlay` so it (alone) reads
		// the trigger's anchor.
		let anchored_panel: Element<Msg> = super::anchored_overlay::AnchoredOverlay::new(
			panel,
			self.anchor_id,
			self.popup_gap,
		).into();
		s = s.push_aligned( anchored_panel, HAlign::Start, VAlign::Top );

		Some( s.into() )
	}

	/// Build the [`OverlaySpec`] that the application should return from
	/// [`App::overlays`](crate::App::overlays) when this combo is open.
	///
	/// Returns `None` when the combo is closed.
	///
	/// Unlike [`Combo::popup`], the overlay is rendered as a real Wayland
	/// **xdg-popup** child of the application's main window — it can extend
	/// outside the parent surface (the canonical select / dropdown
	/// behaviour) and is positioned by the compositor relative to the
	/// trigger pill rect from the previous frame's layout. The
	/// [`OverlayId`] is derived from [`Combo::anchor_id`] so two combos
	/// with distinct anchor ids get distinct overlay ids automatically.
	///
	/// The spec's `view` is the panel itself (background, border, padding,
	/// rounded corners and the bounded scrolling item list). No extra
	/// dismiss layer is needed: tap-outside dismissal is handled by the
	/// usual ltk mechanism — wire [`App::on_tap`](crate::App::on_tap) to
	/// close the combo, or rely on the spec's `on_dismiss` which fires
	/// when the compositor sends `popup_done`.
	pub fn overlay( &self ) -> Option<OverlaySpec<Msg>>
	{
		if !self.state.is_open { return None; }

		use super::container;
		use super::list_item::list_item;
		use super::scroll::scroll;
		use super::text;
		use super::viewport::viewport;
		use crate::layout::column::column;

		let palette = crate::theme::palette();

		let mut list = column::<Msg>().padding( 4.0 ).spacing( 0.0 ).align_center_x( false );
		let filtered = self.filtered_indices();
		for idx in &filtered
		{
			let label       = self.items[ *idx ].clone();
			let is_selected = self.state.selected.contains( idx );
			let mut li      = list_item::<Msg>( label ).selected( is_selected );
			if let Some( cb ) = self.on_select_idx.as_ref()
			{
				li = li.on_press( cb( *idx ) );
			}
			list = list.push( li );
		}
		if filtered.is_empty()
		{
			list = list.push(
				text( "No matches" ).size( 14.0 ).color( palette.text_secondary ),
			);
		}

		let scroller: Element<Msg> = scroll::<Msg>( list ).into();
		let bounded:  Element<Msg> = viewport::<Msg>( scroller )
			.width( self.popup_width )
			.height( self.popup_max_height )
			.into();

		let panel: Element<Msg> = container::<Msg>( bounded )
			.background( palette.surface_alt )
			.border( palette.divider, 1.0 )
			.padding( 8.0 )
			.radius( 32.0 )
			.into();

		// Derive the OverlayId from the anchor_id's static-str so apps
		// don't have to manually pick non-colliding ids for each combo.
		let mut hasher = DefaultHasher::new();
		self.anchor_id.0.hash( &mut hasher );
		let overlay_id = OverlayId( hasher.finish() as u32 );

		Some( OverlaySpec
		{
			id:                 overlay_id,
			// `layer` / `anchor` / `exclusive_zone` / `keyboard_exclusive`
			// are ignored on the xdg-popup path; the values below are the
			// neutral defaults a layer-shell fallback would expect.
			layer:              Layer::Overlay,
			anchor:             Anchor::ALL,
			// `size.0 == 0` asks the runtime to size the popup to the
			// trigger pill width (the canonical select / dropdown
			// behaviour). Height stays capped at `popup_max_height`.
			size:               ( Length::px( 0.0 ), Length::px( self.popup_max_height ) ),
			exclusive_zone:     0,
			keyboard_exclusive: false,
			input_region:       None,
			view:               panel,
			on_dismiss:         self.on_dismiss.clone(),
			anchor_widget_id:   Some( self.anchor_id ),
		} )
	}
}

/// Create a [`Combo`] over `items` driven by `state`.
pub fn combo<Msg: Clone>( state: ComboState, items: Vec<String> ) -> Combo<Msg>
{
	Combo::new( state, items )
}
