Skip to main content

modde_ui/views/
mod_list.rs

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
13// ─── Constants ────────────────────────────────────────────────────
14
15const UNCATEGORIZED_LABEL: &str = "Uncategorized";
16
17// ─── View function ────────────────────────────────────────────────
18
19/// Render the mod list view with filter toolbar and collapsible category separators.
20///
21/// `profile_locked` is `true` when the containing profile carries a
22/// `Profile::load_order_lock`; it disables *all* reorder buttons at the
23/// view layer, complementing the `Message::ReorderMod` handler's own
24/// refusal check (defense in depth — the view won't let the user try a
25/// gesture the handler will reject).
26pub 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    // ── Action toolbar ──
39    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    // ── Filter toolbar ──
61    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    // ── Column header ──
109    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    // ── Apply filters ──
120    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    // ── Group by category ──
131    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    // Sort: uncategorized (None) first, then by category name
140    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    // ── Build rows ──
151    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        // No categories defined — flat list
159        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        // Categorized list with collapsible separators
169        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
197// ─── Helpers ──────────────────────────────────────────────────────
198
199/// Find the current tri-state for a given filter kind.
200fn 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
207/// Build a tri-state toggle button.
208fn 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
222/// Group filtered mod indices by category.
223fn 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
250/// Build a flat list of mod rows (no category separators).
251fn 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
270/// Build categorized rows with collapsible separators.
271fn 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
316/// Render a single mod row.
317///
318/// `profile_locked` disables the reorder buttons for *every* row when the
319/// containing profile has a `Profile::load_order_lock`. `entry.lock`
320/// disables only this one row (per-mod pin), independent of the profile
321/// lock.
322fn 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    // Prefix per-mod-pinned rows with a marker, matching load_order.rs.
375    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}