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
121pub 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 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 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 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 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
292const SUMMARY_MAX: usize = 160;
296
297fn render_mod_details(state: &ModDetailsState) -> Element<'_, Message> {
302 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 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 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 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 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 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 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 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 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
471fn 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 col = col.push(
480 text(state.formatted_date())
481 .size(12)
482 .color(color!(0xAAAAAA)),
483 );
484
485 col = col.push(text(state.display_title()).size(13));
487
488 if let Some(ref cat) = state.category {
490 col = col.push(text(format!("[{cat}]")).size(11).color(color!(0x888888)));
491 }
492
493 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 col = col.push(text(format!("{} file(s)", state.file_count)).size(11));
504
505 match &state.file_paths {
507 Some(paths) if !paths.is_empty() => {
508 let file_list = paths.iter().fold(column![].spacing(1), |col, path| {
509 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(_) => {} None => {
520 col = col.push(text("Loading files...").size(10).color(color!(0x888888)));
521 }
522 }
523
524 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 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 col = col.push(text(&state.short_id).size(10).color(color!(0x666666)));
568
569 col.into()
570}