Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488use std::sync::OnceLock;
use chrono::{DateTime, Utc};use iced::{ Background, Element, Length, Padding, widget::{Column, Row, container, scrollable},};
use super::{ HEADER_SIDE_PADDING, Message, Pane, Scope, State, Tab, abyssals, fmt_count, header, inventory, stockpiles, tracker, tree, values,};use crate::ui::{ components::{ add_tag_modal, backdrop, context_menu, forbidden, icon::Icon, modal_overlay::{modal_layers, stable_overlay}, positioned_dropdown::positioned_dropdown, resizable_pane::pane_handle, rule, tab_select::{self, TabLayout}, }, style::{color, control, spacing},};const PICKER_OVERLAY_TOP: f32 = spacing::layout::HEADER_HEIGHT + 6.0;const PICKER_OVERLAY_LEFT: f32 = HEADER_SIDE_PADDING;const HELP_OVERLAY_TOP: f32 = spacing::layout::HEADER_HEIGHT + 96.0;const TAB_STRIP_HEIGHT: f32 = 48.0;
pub(super) fn shell(state: &State, now: DateTime<Utc>) -> Element<'_, Message> { let body = Column::with_children(vec![header::header(state), self::body(state, now)]) .width(Length::Fill) .height(Length::Fill);
let surface = container(body) .width(Length::Fill) .height(Length::Fill) .style(|_| container::Style { background: Some(Background::Color(color::surface::BASE)), ..container::Style::default() });
// On the Inventory tab the feature-root base is wrapped in a cursor-tracking `mouse_area` so a // right-click can anchor its context menu at the pointer: `mouse_area`'s `on_move` reports the // cursor relative to its own bounds, so tracking at the base (which shares the overlay `Stack`'s // origin) yields coordinates in the same space the menu is laid out in. let base: Element<'_, Message> = if state.tab() == Tab::Inventory { iced::widget::mouse_area(surface) .on_move(Message::InventoryCursorMoved) .into() } else { surface.into() };
// Always render through the overlay `Stack` with `base` pinned at child[0], even when no overlay // is active (empty `layers`). The inventory scrollable lives inside `base`; if it were sometimes // the root container and sometimes child[0] of a `Stack`, Iced would drop its internal scroll // offset on the reshape and snap the list to the top the moment a menu opened. stable_overlay(base, overlay_layers(state))}
fn overlay_layers(state: &State) -> Vec<Element<'_, Message>> { if state.picker_open { let dropdown = positioned_dropdown(header::picker_dropdown(state), PICKER_OVERLAY_TOP, PICKER_OVERLAY_LEFT); return vec![backdrop::click_catcher(Message::PickerToggled), dropdown]; }
if state.tab() == Tab::Inventory && state.inventory_help_open() { let popover = container( Row::with_children(vec![ iced::widget::Space::new().width(Length::Fill).into(), inventory::help_popover(), ]) .width(Length::Fill), ) .width(Length::Fill) .padding(Padding { top: HELP_OVERLAY_TOP, right: HEADER_SIDE_PADDING, ..Padding::ZERO });
return vec![backdrop::click_catcher(Message::InventoryHelpToggled), popover.into()]; }
if state.tab() == Tab::Abyssals && state.abyssal_picker_open() { return modal_layers(Message::AbyssalPickerToggled, abyssals::picker_modal(state)); }
let stockpile_menu = (state.tab() == Tab::Stockpiles) .then(|| state.stockpile_context_menu()) .flatten(); if let Some(menu) = stockpile_menu { return vec![ backdrop::backdrop(Message::StockpileContextMenuClosed), stockpiles::context_menu_view(menu), ]; }
if state.tab() == Tab::Inventory && let Some(anchor) = state.inventory_menu() { let count = state.inventory_selection_count(); let title = if count == 1 { t!("assets.inventory.stack_count_one", count => count).into_owned() } else { t!("assets.inventory.stack_count_other", count => count).into_owned() }; return vec![ backdrop::backdrop(Message::InventoryMenuDismissed), context_menu::context_menu( &title, vec![context_menu::Item::action( t!("assets.inventory.edit_tags"), Message::OpenSelectionTagModal, )], anchor, ), ]; }
if state.tab() == Tab::Inventory && let Some(modal) = state.asset_tag_modal() { let (assigned, assignable) = state.asset_tag_modal_partition(); return modal_layers( Message::AssetTagModal(add_tag_modal::AddTagMessage::Close), add_tag_modal::view( modal, state.asset_tag_modal_entity_name(), assigned, assignable, Message::AssetTagModal, ), ); }
if state.tab() == Tab::Inventory && state.saved_filter_modal_open() { return modal_layers(Message::SaveFilterCancelled, tree::save_filter_modal(state)); }
if state.tab() == Tab::Inventory && let Some(menu) = state.saved_filter_context_menu() { return vec![ backdrop::backdrop(Message::SavedFilterContextMenuClosed), tree::context_menu_view(menu), ]; }
let multibuy_export = (state.tab() == Tab::Stockpiles) .then(|| state.stockpile_multibuy_export()) .flatten() .and_then(|id| state.stockpiles().iter().find(|card| card.id == id)); if let Some(card) = multibuy_export { return modal_layers( Message::StockpileMultibuyExportClosed, stockpiles::multibuy_export_overlay(card, state.stockpile_multibuy_mode(), state.stockpile_multibuy_copied()), ); }
Vec::new()}
pub fn escape_dismiss(state: &State) -> Option<Message> { if state.tab() == Tab::Abyssals && state.abyssal_picker_open() { return Some(Message::AbyssalPickerToggled); }
if state.tab() == Tab::Inventory && state.inventory_menu().is_some() { return Some(Message::InventoryMenuDismissed); }
if state.tab() == Tab::Inventory && state.asset_tag_modal().is_some() { return Some(Message::AssetTagModal(add_tag_modal::AddTagMessage::Close)); }
if state.tab() == Tab::Inventory && state.saved_filter_modal_open() { return Some(Message::SaveFilterCancelled); }
let multibuy_open = (state.tab() == Tab::Stockpiles) .then(|| state.stockpile_multibuy_export()) .flatten() .is_some_and(|id| state.stockpiles().iter().any(|card| card.id == id)); if multibuy_open { return Some(Message::StockpileMultibuyExportClosed); }
None}
fn body(state: &State, now: DateTime<Utc>) -> Element<'_, Message> { if let Some((id, name, missing)) = state.tab_scope_gate() { let noun = tab_noun(state.tab()); return Column::with_children(vec![ tab_strip(state), forbidden::forbidden(noun, name, &missing, Message::ReauthRequested(id)), ]) .width(Length::Fill) .height(Length::Fill) .into(); }
Column::with_children(vec![tab_strip(state), tab_body(state, now)]) .width(Length::Fill) .height(Length::Fill) .into()}
fn location_tree(state: &State) -> Element<'_, Message> { container(tree::pane(state)) .width(Length::Fixed(state.pane(Pane::Sidebar).width())) .height(Length::Fill) .style(control::sunken_pane) .into()}
fn tab_strip(state: &State) -> Element<'_, Message> { let tabs = state .enabled_tabs() .iter() .map(|&t| { let count = match t { Tab::Inventory => fmt_count(state.inventory_total()), Tab::Abyssals => fmt_count(state.abyssal_total()), Tab::Stockpiles => tab_count(state.stockpiles().len()), Tab::Values => values_count(state), Tab::Tracker => String::new(), }; tab(state, t, tab_noun(t), count) }) .collect::<Vec<_>>();
let strip = container(tab_select::tab_select_with(tabs, TabLayout::Start)) .width(Length::Fill) .height(Length::Fixed(TAB_STRIP_HEIGHT)) .padding(Padding { top: 0.0, right: HEADER_SIDE_PADDING, bottom: 0.0, left: HEADER_SIDE_PADDING, });
Column::with_children(vec![strip.into(), rule::horizontal()]) .width(Length::Fill) .into()}
fn tab<'a>(state: &State, tab: Tab, label: &'a str, count: String) -> tab_select::Tab<'a, Message> { let selected = state.tab() == tab; tab_select::Tab { count_danger: false, count, icon: Some(tab_icon(tab)), label, on_press: (!selected).then_some(Message::TabSelected(tab)), selected, }}
fn tab_icon(tab: Tab) -> Icon { match tab { Tab::Abyssals => Icon::abyssals(), Tab::Inventory => Icon::inventory(), Tab::Stockpiles => Icon::stockpiles(), Tab::Tracker => Icon::tracker(), Tab::Values => Icon::values(), }}
fn tab_noun(tab: Tab) -> &'static str { static ABYSSALS: OnceLock<String> = OnceLock::new(); static INVENTORY: OnceLock<String> = OnceLock::new(); static STOCKPILES: OnceLock<String> = OnceLock::new(); static TRACKER: OnceLock<String> = OnceLock::new(); static VALUES: OnceLock<String> = OnceLock::new(); match tab { Tab::Abyssals => ABYSSALS.get_or_init(|| t!("assets.tabs.abyssals").into_owned()), Tab::Inventory => INVENTORY.get_or_init(|| t!("assets.tabs.inventory").into_owned()), Tab::Stockpiles => STOCKPILES.get_or_init(|| t!("assets.tabs.stockpiles").into_owned()), Tab::Tracker => TRACKER.get_or_init(|| t!("assets.tabs.tracker").into_owned()), Tab::Values => VALUES.get_or_init(|| t!("assets.tabs.values").into_owned()), }}
fn tab_count(len: usize) -> String { fmt_count(len as i64)}
fn values_count(state: &State) -> String { let count = match state.active() { Scope::All => state.roster().len() as i64, Scope::Character(_) => 1, Scope::Corporation(_) => 0, }; fmt_count(count)}
fn tab_body(state: &State, now: DateTime<Utc>) -> Element<'_, Message> { match state.tab() { Tab::Inventory => inventory_body(state), Tab::Abyssals => abyssals_body(state), Tab::Stockpiles => iced::widget::mouse_area( container(stockpiles::body(state.stockpiles(), state.stockpile_expanded())) .width(Length::Fill) .height(Length::Fill), ) .on_move(Message::StockpileCursorMoved) .into(), Tab::Values => container( scrollable(values::body(state.values())) .style(crate::ui::style::control::scrollbar) .width(Length::Fill) .height(Length::Fill), ) .width(Length::Fill) .height(Length::Fill) .into(), Tab::Tracker => container( scrollable(tracker::body(state.nav(), state.chart_hover(), now)) .style(crate::ui::style::control::scrollbar) .width(Length::Fill) .height(Length::Fill), ) .width(Length::Fill) .height(Length::Fill) .into(), }}
fn inventory_body(state: &State) -> Element<'_, Message> { let mut table: Vec<Element<'_, Message>> = Vec::new(); if inventory::has_rows(state) { table.push(inventory::header(state)); } table.push( container(inventory::body(state)) .width(Length::Fill) .height(Length::Fill) .into(), );
let inventory = Column::with_children(vec![ inventory::filter_bar(state), Column::with_children(table) .width(Length::Fill) .height(Length::Fill) .into(), ]) .width(Length::Fill) .height(Length::Fill);
Row::with_children(vec![ location_tree(state), pane_handle(Message::PaneDragStart(Pane::Sidebar)), inventory.into(), ]) .width(Length::Fill) .height(Length::Fill) .into()}
fn abyssals_body(state: &State) -> Element<'_, Message> { let rail = container(abyssals::filter_rail(state)) .width(Length::Fixed(state.pane(Pane::AbyssalsFilter).width())) .height(Length::Fill) .style(control::sunken_pane);
let cards: Vec<&abyssals::AbyssalCard> = state.abyssals().iter().collect(); let any_owned = !state.abyssals().is_empty() || !state.abyssal_source_types().is_empty(); let results = container(abyssals::body(cards, any_owned, state.abyssal_scroll_offset())) .width(Length::Fill) .height(Length::Fill);
Row::with_children(vec![ rail.into(), pane_handle(Message::PaneDragStart(Pane::AbyssalsFilter)), results.into(), ]) .width(Length::Fill) .height(Length::Fill) .into()}
#[cfg(test)]mod tests { use super::*;
fn state() -> State { State::new(crate::config::FeatureFlags::default()) }
fn card(id: i64, name: &str) -> stockpiles::StockpileCard { stockpiles::StockpileCard { character_scope: None, fill_isk: 0.0, id, items: vec![], location_id: None, location_name: None, name: name.to_owned(), overall_pct: 0.0, scope_pilots: 0, target_isk: 0.0, } }
#[test] fn it_dismisses_the_abyssal_picker() { let mut state = state(); state.tab = Tab::Abyssals; state.abyssal_picker_open = true;
assert!(matches!(escape_dismiss(&state), Some(Message::AbyssalPickerToggled))); }
#[test] fn it_dismisses_the_inventory_context_menu() { let mut state = state(); state.tab = Tab::Inventory; state.inventory_menu = Some(iced::Point::new(10.0, 10.0));
assert!(matches!(escape_dismiss(&state), Some(Message::InventoryMenuDismissed))); }
#[test] fn it_dismisses_the_asset_tag_modal() { let mut state = state(); state.tab = Tab::Inventory; state.add_tag_modal = Some(add_tag_modal::AddTagModal::new( 5001, crate::store::model::ENTITY_TYPE_ASSET, ));
assert!(matches!( escape_dismiss(&state), Some(Message::AssetTagModal(add_tag_modal::AddTagMessage::Close)) )); }
#[test] fn it_dismisses_the_saved_filter_modal() { let mut state = state(); state.tab = Tab::Inventory; state.saved_filter_modal_open = true;
assert!(matches!(escape_dismiss(&state), Some(Message::SaveFilterCancelled))); }
#[test] fn it_dismisses_the_stockpile_multibuy_export() { let mut state = state(); state.tab = Tab::Stockpiles; state.stockpiles = vec![card(7, "Ammo")]; state.stockpile_multibuy_export = Some(7);
assert!(matches!( escape_dismiss(&state), Some(Message::StockpileMultibuyExportClosed) )); }
#[test] fn it_ignores_a_multibuy_export_for_an_unknown_card() { let mut state = state(); state.tab = Tab::Stockpiles; state.stockpiles = vec![card(7, "Ammo")]; state.stockpile_multibuy_export = Some(404);
assert!(escape_dismiss(&state).is_none()); }
#[test] fn it_returns_none_when_nothing_is_open() { assert!(escape_dismiss(&state()).is_none()); }
#[test] fn it_ignores_an_inventory_menu_on_another_tab() { let mut state = state(); state.tab = Tab::Abyssals; state.inventory_menu = Some(iced::Point::new(10.0, 10.0));
assert!(escape_dismiss(&state).is_none()); }}