Skip to main content

modde_ui/views/
sidebar.rs

1use std::collections::HashSet;
2
3use crate::views::selectable_text::text;
4use iced::widget::{button, column, container, image, mouse_area, pick_list, row};
5use iced::{Element, Length, color};
6
7use crate::action_button::{ButtonAction, DescribedButtonExt};
8use crate::app::{Message, SidebarGroup, View};
9use crate::views::mod_details::ModDetailsState;
10use crate::views::save_details::SaveDetailsState;
11
12struct NavItem {
13    label: &'static str,
14    target: NavTarget,
15}
16
17struct NavGroup {
18    group: SidebarGroup,
19    items: &'static [NavItem],
20}
21
22#[derive(Clone, Copy)]
23enum NavTarget {
24    ModList,
25    Saves,
26    DataTab,
27    BrowseNexus,
28    Collections,
29    Wabbajack,
30    Downloads,
31    Diagnostics,
32    Tools,
33    Executables,
34    Settings,
35}
36
37impl NavTarget {
38    fn view(self) -> View {
39        match self {
40            NavTarget::ModList => View::ModList,
41            NavTarget::Saves => View::Saves,
42            NavTarget::DataTab => View::DataTab,
43            NavTarget::BrowseNexus => View::BrowseNexus,
44            NavTarget::Collections => View::Collections,
45            NavTarget::Wabbajack => View::WabbajackInstaller(Default::default()),
46            NavTarget::Downloads => View::Downloads,
47            NavTarget::Diagnostics => View::Diagnostics,
48            NavTarget::Tools => View::Tools,
49            NavTarget::Executables => View::Executables,
50            NavTarget::Settings => View::Settings,
51        }
52    }
53}
54
55const GAME_ITEMS: &[NavItem] = &[
56    NavItem {
57        label: "Mod List",
58        target: NavTarget::ModList,
59    },
60    NavItem {
61        label: "Saves",
62        target: NavTarget::Saves,
63    },
64    NavItem {
65        label: "Data Files",
66        target: NavTarget::DataTab,
67    },
68    NavItem {
69        label: "Diagnostics",
70        target: NavTarget::Diagnostics,
71    },
72    NavItem {
73        label: "Tools",
74        target: NavTarget::Tools,
75    },
76    NavItem {
77        label: "Executables",
78        target: NavTarget::Executables,
79    },
80];
81
82const INSTALL_ITEMS: &[NavItem] = &[
83    NavItem {
84        label: "Browse Nexus",
85        target: NavTarget::BrowseNexus,
86    },
87    NavItem {
88        label: "Collections",
89        target: NavTarget::Collections,
90    },
91    NavItem {
92        label: "Wabbajack",
93        target: NavTarget::Wabbajack,
94    },
95    NavItem {
96        label: "Downloads",
97        target: NavTarget::Downloads,
98    },
99];
100
101const GENERAL_ITEMS: &[NavItem] = &[NavItem {
102    label: "Settings",
103    target: NavTarget::Settings,
104}];
105
106const NAV_GROUPS: &[NavGroup] = &[
107    NavGroup {
108        group: SidebarGroup::Game,
109        items: GAME_ITEMS,
110    },
111    NavGroup {
112        group: SidebarGroup::Install,
113        items: INSTALL_ITEMS,
114    },
115    NavGroup {
116        group: SidebarGroup::General,
117        items: GENERAL_ITEMS,
118    },
119];
120
121/// Render the navigation sidebar.
122pub fn view<'a>(
123    active_view: &View,
124    collapsed_groups: &HashSet<SidebarGroup>,
125    profiles: &'a [modde_core::profile::ProfileSummary],
126    active_profile: &'a Option<String>,
127    experiment_depth: usize,
128    save_profiles_supported: bool,
129    mod_details: Option<&'a ModDetailsState>,
130    save_details: Option<&'a SaveDetailsState>,
131) -> Element<'a, Message> {
132    let nav_button = |label: &'static str, target: View, current: &View| -> Element<'a, Message> {
133        let is_active = std::mem::discriminant(&target) == std::mem::discriminant(current);
134        let btn = button(text(label).size(14))
135            .width(Length::Fill)
136            .padding([6, 12]);
137        if is_active {
138            btn.style(button::primary)
139                .described_disabled("This section is already open.")
140        } else {
141            btn.style(button::secondary)
142                .on_action(ButtonAction::SwitchView(target))
143        }
144    };
145
146    let mut nav = column![].spacing(6);
147    for group in NAV_GROUPS {
148        nav = nav.push(render_group_header(
149            group.group,
150            collapsed_groups.contains(&group.group),
151        ));
152        let contains_active = group
153            .items
154            .iter()
155            .any(|item| same_view_kind(&item.target.view(), active_view));
156        let show_all_items = !collapsed_groups.contains(&group.group);
157        if show_all_items || contains_active {
158            let mut group_items = column![].spacing(4);
159            for item in group.items {
160                let view = item.target.view();
161                if matches!(item.target, NavTarget::Saves)
162                    && !save_profiles_supported
163                    && !same_view_kind(&view, active_view)
164                {
165                    continue;
166                }
167                if show_all_items || same_view_kind(&view, active_view) {
168                    group_items = group_items.push(nav_button(item.label, view, active_view));
169                }
170            }
171            nav = nav.push(group_items);
172        }
173    }
174
175    // ── Profile section ──
176    let profile_names: Vec<String> = profiles.iter().map(|p| p.name.clone()).collect();
177    let profile_selector = column![
178        text("Profile").size(12),
179        pick_list(
180            profile_names,
181            active_profile.clone(),
182            Message::SwitchProfile,
183        )
184        .width(Length::Fill)
185        .placeholder("No profiles"),
186    ]
187    .spacing(4);
188
189    // ── Profile actions ──
190    let mut profile_actions = row![].spacing(4);
191    if let Some(name) = active_profile {
192        let name_del = name.clone();
193        profile_actions = profile_actions.push(
194            button(text("Del").size(11))
195                .style(button::danger)
196                .padding([3, 8])
197                .on_action(ButtonAction::DeleteProfile(name_del)),
198        );
199    }
200    profile_actions = profile_actions.push(
201        button(text("New").size(11))
202            .style(button::success)
203            .padding([3, 8])
204            .on_action(ButtonAction::OpenNewProfileDialog),
205    );
206    if let Some(name) = active_profile {
207        let name_fork = name.clone();
208        profile_actions = profile_actions.push(
209            button(text("Fork").size(11))
210                .style(button::secondary)
211                .padding([3, 8])
212                .on_action(ButtonAction::ForkProfile {
213                    source: name_fork.clone(),
214                    new_name: format!("{name_fork}-fork"),
215                }),
216        );
217    }
218
219    // ── Experiment indicator ──
220    let mut sections = column![
221        nav,
222        iced::widget::rule::horizontal(1),
223        profile_selector,
224        profile_actions,
225    ]
226    .spacing(10)
227    .padding(12)
228    .width(Length::Fixed(190.0));
229
230    if experiment_depth > 0 {
231        let experiment_section = column![
232            text(format!("Experiment (depth {experiment_depth})"))
233                .size(12)
234                .color(color!(0xFFAA44)),
235            row![
236                button(text("Rollback").size(11))
237                    .style(button::danger)
238                    .padding([3, 8])
239                    .on_action(ButtonAction::RollbackExperiment),
240                button(text("Commit").size(11))
241                    .style(button::success)
242                    .padding([3, 8])
243                    .on_action(ButtonAction::CommitExperiment),
244            ]
245            .spacing(4),
246        ]
247        .spacing(4);
248
249        sections = sections.push(iced::widget::rule::horizontal(1));
250        sections = sections.push(experiment_section);
251    } else if active_profile.is_some() {
252        sections = sections.push(iced::widget::rule::horizontal(1));
253        sections = sections.push(
254            button(text("Try Profile").size(11))
255                .style(button::secondary)
256                .padding([3, 8])
257                .width(Length::Fill)
258                .on_action(ButtonAction::TryProfile),
259        );
260    }
261
262    // ── Detail panel (mod details or save details, mutually exclusive) ──
263    if let Some(details) = mod_details {
264        sections = sections.push(iced::widget::rule::horizontal(1));
265        sections = sections.push(render_mod_details(details));
266    } else if let Some(details) = save_details {
267        sections = sections.push(iced::widget::rule::horizontal(1));
268        sections = sections.push(render_save_details(details));
269    }
270
271    iced::widget::row![
272        iced::widget::scrollable(container(sections).style(container::rounded_box))
273            .height(Length::Fill),
274        iced::widget::rule::vertical(1),
275    ]
276    .into()
277}
278
279fn same_view_kind(a: &View, b: &View) -> bool {
280    std::mem::discriminant(a) == std::mem::discriminant(b)
281}
282
283fn render_group_header(group: SidebarGroup, collapsed: bool) -> Element<'static, Message> {
284    let icon = if collapsed { ">" } else { "v" };
285    button(row![text(icon).size(12), text(group.label()).size(12)].spacing(6))
286        .style(button::text)
287        .padding([2, 4])
288        .width(Length::Fill)
289        .on_action(ButtonAction::ToggleSidebarGroup(group))
290}
291
292/// Maximum character count for the mod summary text before it is
293/// truncated with an ellipsis. ~160 chars is roughly 4-5 wrapped lines
294/// at the sidebar width.
295const SUMMARY_MAX: usize = 160;
296
297/// Render the mod detail panel appended to the bottom of the left sidebar.
298/// Width budget is ~166px (190px sidebar minus 12px padding each side minus
299/// a little breathing room), so text is sized small and the thumbnail is
300/// clamped to `Length::Fill` within that column.
301fn render_mod_details(state: &ModDetailsState) -> Element<'_, Message> {
302    // Loading state — show only a minimal placeholder, no partial data.
303    if state.loading {
304        return column![
305            text(&state.name).size(13),
306            text("Loading…").size(11).color(color!(0x888888)),
307        ]
308        .spacing(4)
309        .width(Length::Fill)
310        .into();
311    }
312
313    // Error state — show the error instead of the metadata block.
314    if let Some(ref err) = state.error {
315        return column![
316            text(&state.name).size(13),
317            text(err.as_str()).size(11).color(color!(0xFF6666)),
318            button(text("Open in Nexus").size(11))
319                .style(button::text)
320                .padding([2, 4])
321                .on_action(ButtonAction::OpenModPage),
322        ]
323        .spacing(4)
324        .width(Length::Fill)
325        .into();
326    }
327
328    // ── Thumbnail ──
329    let thumb_slot: Element<Message> = match &state.thumbnail {
330        Some(handle) => image(handle.clone())
331            .width(Length::Fill)
332            .height(Length::Fixed(96.0))
333            .content_fit(iced::ContentFit::Contain)
334            .into(),
335        None => container(text("…").size(14).color(color!(0x888888)))
336            .width(Length::Fill)
337            .height(Length::Fixed(96.0))
338            .center_x(Length::Fill)
339            .center_y(Length::Fixed(96.0))
340            .style(container::bordered_box)
341            .into(),
342    };
343
344    // Wrap the thumbnail in a mouse_area so clicking cycles the gallery.
345    // Only attach the on_press handler when there's actually more than one
346    // image to cycle through.
347    let thumb_area: Element<Message> = if state.gallery.len() > 1 {
348        mouse_area(thumb_slot)
349            .on_press(Message::ModGalleryNext)
350            .into()
351    } else {
352        thumb_slot
353    };
354
355    // Gallery position indicator ("2 / 5") — only shown when multiple images.
356    let gallery_indicator: Element<Message> = if state.gallery.len() > 1 {
357        text(format!(
358            "{} / {}",
359            state.gallery_index + 1,
360            state.gallery.len()
361        ))
362        .size(10)
363        .color(color!(0x888888))
364        .into()
365    } else {
366        iced::widget::Space::new().into()
367    };
368
369    // ── Metadata text ──
370    let author_version: Element<Message> = if state.author.is_empty() {
371        text(&state.version).size(11).color(color!(0xAAAAAA)).into()
372    } else {
373        text(format!("by {} · v{}", state.author, state.version))
374            .size(11)
375            .color(color!(0xAAAAAA))
376            .into()
377    };
378
379    // Summary — clamp length so it doesn't blow out the sidebar.
380    let summary_text: Element<Message> = match state.summary.as_deref() {
381        Some(s) if !s.is_empty() => {
382            let truncated = if s.chars().count() > SUMMARY_MAX {
383                let mut t: String = s.chars().take(SUMMARY_MAX).collect();
384                t.push('…');
385                t
386            } else {
387                s.to_string()
388            };
389            text(truncated).size(11).into()
390        }
391        _ => iced::widget::Space::new().into(),
392    };
393
394    // ── Endorse / Track action buttons ──
395    // Both buttons reflect the user's current relationship with the mod on
396    // Nexus. While any action is in flight (`action_pending`), both buttons
397    // lose their `on_press` to block double-submits. Before the initial
398    // fetch resolves (`endorse_status`/`is_tracked` are None), the buttons
399    // render but are disabled.
400    let disabled = state.action_pending;
401
402    let endorsed = state.endorse_status.as_deref() == Some("Endorsed");
403    let endorse_label = if endorsed { "✓ Endorsed" } else { "Endorse" };
404    let endorse_style = if endorsed {
405        button::success
406    } else if state.endorse_status.is_some() {
407        button::primary
408    } else {
409        button::secondary
410    };
411    let endorse_btn = button(text(endorse_label).size(11))
412        .style(endorse_style)
413        .padding([3, 8])
414        .width(Length::Fill)
415        .on_action_maybe(
416            (!disabled && state.endorse_status.is_some()).then_some(ButtonAction::ModEndorseToggle),
417            "Nexus endorsement status is still loading or an action is already in progress.",
418        );
419
420    let tracked = state.is_tracked == Some(true);
421    let track_label = if tracked { "Tracked" } else { "Track" };
422    let track_style = if tracked {
423        button::success
424    } else if state.is_tracked.is_some() {
425        button::primary
426    } else {
427        button::secondary
428    };
429    let track_btn = button(text(track_label).size(11))
430        .style(track_style)
431        .padding([3, 8])
432        .width(Length::Fill)
433        .on_action_maybe(
434            (!disabled && state.is_tracked.is_some()).then_some(ButtonAction::ModTrackToggle),
435            "Nexus tracking status is still loading or an action is already in progress.",
436        );
437
438    let action_row = row![endorse_btn, track_btn].spacing(4);
439
440    // Endorsement count line — only rendered when > 0 to keep the panel
441    // uncluttered for mods that have just been published.
442    let count_line: Element<Message> = if state.endorsement_count > 0 {
443        text(format!("{} endorsements", state.endorsement_count))
444            .size(10)
445            .color(color!(0x888888))
446            .into()
447    } else {
448        iced::widget::Space::new().into()
449    };
450
451    let link_button = button(text("Open in Nexus").size(11))
452        .style(button::text)
453        .padding([2, 4])
454        .on_action(ButtonAction::OpenModPage);
455
456    column![
457        thumb_area,
458        gallery_indicator,
459        text(&state.name).size(13),
460        author_version,
461        summary_text,
462        action_row,
463        count_line,
464        link_button,
465    ]
466    .spacing(4)
467    .width(Length::Fill)
468    .into()
469}
470
471/// Render the save detail panel appended to the bottom of the left sidebar.
472fn render_save_details(state: &SaveDetailsState) -> Element<'_, Message> {
473    use iced::widget::scrollable;
474    use modde_core::save::FingerprintCheck;
475
476    let mut col = column![].spacing(4).width(Length::Fill);
477
478    // Date
479    col = col.push(
480        text(state.formatted_date())
481            .size(12)
482            .color(color!(0xAAAAAA)),
483    );
484
485    // Title: character + save label
486    col = col.push(text(state.display_title()).size(13));
487
488    // Category badge
489    if let Some(ref cat) = state.category {
490        col = col.push(text(format!("[{cat}]")).size(11).color(color!(0x888888)));
491    }
492
493    // Profile name
494    if let Some(ref name) = state.profile_name {
495        col = col.push(
496            text(format!("Profile: {name}"))
497                .size(11)
498                .color(color!(0xAAAAAA)),
499        );
500    }
501
502    // File count
503    col = col.push(text(format!("{} file(s)", state.file_count)).size(11));
504
505    // File list
506    match &state.file_paths {
507        Some(paths) if !paths.is_empty() => {
508            let file_list = paths.iter().fold(column![].spacing(1), |col, path| {
509                // Show only the filename for brevity in the narrow sidebar
510                let display = std::path::Path::new(path)
511                    .file_name()
512                    .and_then(|n| n.to_str())
513                    .unwrap_or(path);
514                col.push(text(display).size(10).color(color!(0x888888)))
515            });
516            col = col.push(scrollable(file_list).height(Length::Fixed(80.0)));
517        }
518        Some(_) => {} // empty list, skip
519        None => {
520            col = col.push(text("Loading files...").size(10).color(color!(0x888888)));
521        }
522    }
523
524    // Fingerprint + compatibility
525    if let Some(ref fp) = state.fingerprint {
526        let fp_element: Element<Message> = match &state.compatibility {
527            Some(FingerprintCheck::Compatible) => {
528                text(format!("Mods: {} [compatible]", fp.short_hash()))
529                    .size(11)
530                    .color(color!(0x44AA44))
531                    .into()
532            }
533            Some(FingerprintCheck::Mismatch { removed, added }) => column![
534                text(format!("Mods: {} [mismatch]", fp.short_hash()))
535                    .size(11)
536                    .color(color!(0xFF6644)),
537                text(format!(
538                    "-{} removed, +{} added",
539                    removed.len(),
540                    added.len()
541                ))
542                .size(10)
543                .color(color!(0xFF6644)),
544            ]
545            .spacing(1)
546            .into(),
547            Some(FingerprintCheck::NoFingerprint) | None => {
548                text(format!("Mods: {}", fp.short_hash()))
549                    .size(11)
550                    .color(color!(0x888888))
551                    .into()
552            }
553        };
554        col = col.push(fp_element);
555    }
556
557    // Restore button
558    col = col.push(
559        button(text("Restore").size(12))
560            .style(button::secondary)
561            .padding([4, 8])
562            .width(Length::Fill)
563            .on_action(ButtonAction::RestoreSaveSnapshot(state.commit_id.clone())),
564    );
565
566    // Commit ID (subtle)
567    col = col.push(text(&state.short_id).size(10).color(color!(0x666666)));
568
569    col.into()
570}