1use std::collections::{HashMap, HashSet};
2
3use crate::views::selectable_text::text;
4use iced::widget::{button, checkbox, column, container, row, scrollable, text_input};
5use iced::{Alignment, Element, Length};
6
7use modde_core::filter::{self, FilterCriterion, FilterKind, FilterMode, TriState};
8use modde_core::profile::EnabledMod;
9
10use crate::action_button::{ButtonAction, DescribedButtonExt};
11use crate::app::Message;
12
13const UNCATEGORIZED_LABEL: &str = "Uncategorized";
16
17pub fn view_filtered<'a>(
27 mods: &'a [EnabledMod],
28 mod_id_filter_keys: &'a [String],
29 filter_text: &'a str,
30 selected_index: Option<usize>,
31 filter_mode: FilterMode,
32 active_filters: &'a [FilterCriterion],
33 collapsed_categories: &'a HashSet<Option<i64>>,
34 categories: &'a [(Option<i64>, String)],
35 compact: bool,
36 profile_locked: bool,
37) -> Element<'a, Message> {
38 let toolbar = row![
40 button(text("Add Mod").size(14))
41 .style(button::primary)
42 .padding([6, 14])
43 .on_action(ButtonAction::AddMod),
44 button(text("Remove").size(14))
45 .style(button::secondary)
46 .padding([6, 14])
47 .on_action_maybe(
48 selected_index.map(ButtonAction::RemoveMod),
49 "Select a mod before removing it from the active profile.",
50 ),
51 iced::widget::space::horizontal(),
52 button(text("Deploy").size(14))
53 .style(button::success)
54 .padding([6, 14])
55 .on_action(ButtonAction::Deploy),
56 ]
57 .spacing(8)
58 .align_y(Alignment::Center);
59
60 let search = text_input("Filter mods...", filter_text)
62 .on_input(Message::FilterChanged)
63 .padding(6)
64 .width(Length::Fill);
65
66 let mode_label = filter_mode.label();
67 let mode_btn = button(text(mode_label).size(11))
68 .style(if filter_mode == FilterMode::And {
69 button::primary
70 } else {
71 button::secondary
72 })
73 .padding([3, 8])
74 .on_action(ButtonAction::ToggleFilterMode);
75
76 let filter_buttons = row![
77 mode_btn,
78 tri_state_button(
79 "Enabled",
80 FilterKind::Enabled,
81 find_filter_state(active_filters, FilterKind::Enabled)
82 ),
83 tri_state_button(
84 "Notes",
85 FilterKind::HasNotes,
86 find_filter_state(active_filters, FilterKind::HasNotes)
87 ),
88 tri_state_button(
89 "Nexus",
90 FilterKind::HasNexusId,
91 find_filter_state(active_filters, FilterKind::HasNexusId)
92 ),
93 button(text("Clear").size(11))
94 .style(button::secondary)
95 .padding([3, 8])
96 .on_action(ButtonAction::ClearFilters),
97 iced::widget::space::horizontal(),
98 button(text(if compact { "Normal" } else { "Compact" }).size(11))
99 .style(button::text)
100 .padding([3, 8])
101 .on_action(ButtonAction::ToggleCompactModList),
102 ]
103 .spacing(4)
104 .align_y(Alignment::Center);
105
106 let filter_toolbar = column![search, filter_buttons].spacing(4);
107
108 let header = row![
110 text("").width(Length::Fixed(32.0)),
111 text("Enabled").size(12).width(Length::Fixed(60.0)),
112 text("Mod Name").size(12).width(Length::Fill),
113 text("Version").size(12).width(Length::Fixed(80.0)),
114 text("Reorder").size(12).width(Length::Fixed(80.0)),
115 ]
116 .spacing(8)
117 .padding([4, 0]);
118
119 let filtered_indices = filter::apply_filters_with_mod_id_keys(
121 mods,
122 mod_id_filter_keys,
123 filter_text,
124 active_filters,
125 filter_mode,
126 );
127
128 let total_shown = filtered_indices.len();
129
130 let category_map: HashMap<Option<i64>, &str> = categories
132 .iter()
133 .map(|(id, name)| (*id, name.as_str()))
134 .collect();
135
136 let mut grouped: Vec<(Option<i64>, &str, Vec<usize>)> =
137 build_category_groups(&filtered_indices, mods, &category_map);
138
139 grouped.sort_by(|a, b| {
141 if a.0.is_none() {
142 std::cmp::Ordering::Less
143 } else if b.0.is_none() {
144 std::cmp::Ordering::Greater
145 } else {
146 a.1.cmp(b.1)
147 }
148 });
149
150 let mod_rows: Element<Message> = if filtered_indices.is_empty() {
152 container(text("No mods found. Click 'Add Mod' to get started.").size(14))
153 .padding(20)
154 .width(Length::Fill)
155 .center_x(Length::Fill)
156 .into()
157 } else if categories.is_empty() {
158 let rows = build_flat_mod_rows(
160 &filtered_indices,
161 mods,
162 selected_index,
163 compact,
164 profile_locked,
165 );
166 scrollable(rows).height(Length::Fill).into()
167 } else {
168 let rows = build_categorized_rows(
170 &grouped,
171 mods,
172 selected_index,
173 collapsed_categories,
174 compact,
175 profile_locked,
176 );
177 scrollable(rows).height(Length::Fill).into()
178 };
179
180 let status = text(format!("{total_shown} mod(s) shown")).size(12);
181
182 column![
183 toolbar,
184 filter_toolbar,
185 header,
186 iced::widget::rule::horizontal(1),
187 mod_rows,
188 status,
189 ]
190 .spacing(8)
191 .padding(16)
192 .width(Length::Fill)
193 .height(Length::Fill)
194 .into()
195}
196
197fn find_filter_state(criteria: &[FilterCriterion], kind: FilterKind) -> TriState {
201 criteria
202 .iter()
203 .find(|c| c.kind == kind)
204 .map_or(TriState::Ignore, |c| c.state)
205}
206
207fn tri_state_button(label: &str, kind: FilterKind, state: TriState) -> Element<'_, Message> {
209 let prefix = state.label();
210 let display = format!("{prefix} {label}");
211 let style = match state {
212 TriState::Ignore => button::text,
213 TriState::Include => button::success,
214 TriState::Exclude => button::danger,
215 };
216 button(text(display).size(11))
217 .style(style)
218 .padding([3, 8])
219 .on_action(ButtonAction::CycleFilter(kind))
220}
221
222fn build_category_groups<'a>(
224 filtered_indices: &[usize],
225 mods: &'a [EnabledMod],
226 category_map: &HashMap<Option<i64>, &'a str>,
227) -> Vec<(Option<i64>, &'a str, Vec<usize>)> {
228 let mut groups: HashMap<Option<i64>, Vec<usize>> = HashMap::new();
229 for &idx in filtered_indices {
230 let cat_id = mods[idx].category_id;
231 groups.entry(cat_id).or_default().push(idx);
232 }
233
234 groups
235 .into_iter()
236 .map(|(cat_id, indices)| {
237 let name = category_map
238 .get(&cat_id)
239 .copied()
240 .unwrap_or(if cat_id.is_none() {
241 UNCATEGORIZED_LABEL
242 } else {
243 "Unknown"
244 });
245 (cat_id, name, indices)
246 })
247 .collect()
248}
249
250fn build_flat_mod_rows<'a>(
252 indices: &[usize],
253 mods: &'a [EnabledMod],
254 selected_index: Option<usize>,
255 compact: bool,
256 profile_locked: bool,
257) -> iced::widget::Column<'a, Message> {
258 indices.iter().fold(column![].spacing(2), |col, &idx| {
259 col.push(mod_row(
260 idx,
261 &mods[idx],
262 selected_index,
263 mods.len(),
264 compact,
265 profile_locked,
266 ))
267 })
268}
269
270fn build_categorized_rows<'a>(
272 groups: &[(Option<i64>, &str, Vec<usize>)],
273 mods: &'a [EnabledMod],
274 selected_index: Option<usize>,
275 collapsed: &HashSet<Option<i64>>,
276 compact: bool,
277 profile_locked: bool,
278) -> iced::widget::Column<'a, Message> {
279 let mut col = column![].spacing(2);
280
281 for (cat_id, cat_name, indices) in groups {
282 let is_collapsed = collapsed.contains(cat_id);
283 let toggle_icon = if is_collapsed { ">" } else { "v" };
284 let count_label = format!("{} ({} mods)", cat_name, indices.len());
285
286 let separator = button(
287 row![text(toggle_icon).size(12), text(count_label).size(12),]
288 .spacing(6)
289 .align_y(Alignment::Center),
290 )
291 .style(button::text)
292 .padding([4, 8])
293 .width(Length::Fill)
294 .on_action(ButtonAction::ToggleSeparator(*cat_id));
295
296 col = col.push(separator);
297 col = col.push(iced::widget::rule::horizontal(1));
298
299 if !is_collapsed {
300 for &idx in indices {
301 col = col.push(mod_row(
302 idx,
303 &mods[idx],
304 selected_index,
305 mods.len(),
306 compact,
307 profile_locked,
308 ));
309 }
310 }
311 }
312
313 col
314}
315
316fn mod_row(
323 idx: usize,
324 entry: &EnabledMod,
325 selected_index: Option<usize>,
326 total: usize,
327 compact: bool,
328 profile_locked: bool,
329) -> Element<'_, Message> {
330 let is_selected = selected_index == Some(idx);
331 let font_size: f32 = if compact { 12.0 } else { 14.0 };
332 let row_pad: u16 = if compact { 2 } else { 4 };
333
334 let row_blocked = profile_locked || entry.lock.is_some();
335
336 let up_btn = button(text("^").size(12)).padding([2, 6]).on_action_maybe(
337 if !row_blocked && idx > 0 {
338 Some(ButtonAction::ReorderMod {
339 mod_id: entry.mod_id.clone(),
340 direction: crate::app::ReorderDirection::Up,
341 })
342 } else {
343 None
344 },
345 "This mod cannot move up because it is first, pinned, or the profile load order is locked.",
346 );
347
348 let down_btn = button(text("v").size(12))
349 .padding([2, 6])
350 .on_action_maybe(
351 if !row_blocked && idx < total - 1 {
352 Some(ButtonAction::ReorderMod {
353 mod_id: entry.mod_id.clone(),
354 direction: crate::app::ReorderDirection::Down,
355 })
356 } else {
357 None
358 },
359 "This mod cannot move down because it is last, pinned, or the profile load order is locked.",
360 );
361
362 let priority = text(format!("{:>3}", idx + 1))
363 .size(12)
364 .width(Length::Fixed(32.0));
365
366 let cb = checkbox(entry.enabled).on_toggle({
367 let mod_id = entry.mod_id.clone();
368 move |val| Message::ToggleMod {
369 mod_id: mod_id.clone(),
370 enabled: val,
371 }
372 });
373
374 let label_owned: String = {
376 let base = entry.display_name.as_deref().unwrap_or(&entry.mod_id);
377 if entry.lock.is_some() {
378 format!("[pinned] {base}")
379 } else {
380 base.to_string()
381 }
382 };
383 let name = button(text(label_owned).size(font_size))
384 .style(if is_selected {
385 button::primary
386 } else {
387 button::text
388 })
389 .padding([2, 4])
390 .on_action(ButtonAction::SelectMod(idx));
391
392 let version_str = entry.version.as_deref().unwrap_or("-");
393 let version = text(version_str).size(12).width(Length::Fixed(80.0));
394
395 row![
396 priority,
397 container(cb).width(Length::Fixed(60.0)),
398 container(name).width(Length::Fill),
399 version,
400 row![up_btn, down_btn].spacing(2).width(Length::Fixed(80.0)),
401 ]
402 .spacing(8)
403 .align_y(Alignment::Center)
404 .padding([row_pad, 8])
405 .into()
406}