1use crate::views::selectable_text::text;
2use iced::widget::{
3 button, column, container, pick_list, row, scrollable, slider, text_input, toggler,
4};
5use iced::{Alignment, Element, Length, color};
6
7use modde_games::tools::ToolSettingKind;
8
9use crate::action_button::{ButtonAction, DescribedButtonExt};
10use crate::app::{Message, ToolHistoryUiEntry, ToolState, ToolUiEntry};
11use crate::semantics;
12use crate::views::tabs::{Tab, tab_bar};
13
14pub fn view(state: &ToolState) -> Element<'_, Message> {
16 let title = state.game_label.as_deref().map_or_else(
17 || "Gaming Tools".to_string(),
18 |game| format!("Gaming Tools - {game}"),
19 );
20 let title_bar = row![
21 text(title).size(20),
22 iced::widget::space::horizontal(),
23 semantics::test_id(
24 "tools.refresh",
25 button(text("Refresh").size(14))
26 .style(button::secondary)
27 .padding([6, 14])
28 .on_action_maybe(
29 (!state.loading).then_some(ButtonAction::RefreshTools),
30 "Tools are already loading.",
31 ),
32 ),
33 ]
34 .align_y(Alignment::Center);
35
36 if state.entries.is_empty() {
37 let message = if state.loading {
38 "Loading tools..."
39 } else {
40 "Select a game to manage tools, or click Refresh."
41 };
42 return column![
43 title_bar,
44 container(text(message).size(14))
45 .padding(20)
46 .width(Length::Fill)
47 .center_x(Length::Fill),
48 ]
49 .spacing(12)
50 .padding(12)
51 .width(Length::Fill)
52 .into();
53 }
54
55 let active_tool_id = state
56 .active_tool_id
57 .as_deref()
58 .unwrap_or_else(|| state.entries[0].tool_id.as_str());
59 let active_entry = state
60 .entries
61 .iter()
62 .find(|entry| entry.tool_id == active_tool_id)
63 .unwrap_or(&state.entries[0]);
64
65 let tabs = tab_bar(state.entries.iter().map(|entry| {
66 Tab::new(
67 entry.display_name.clone(),
68 entry.tool_id == active_entry.tool_id,
69 ButtonAction::SelectToolTab(entry.tool_id.clone()),
70 )
71 .test_id(format!("tools.tab.{}", entry.tool_id))
72 }));
73
74 let mut content = column![title_bar, tabs, iced::widget::rule::horizontal(1)].spacing(10);
75 if state.loading {
76 content = content.push(text("Loading tools...").size(12).color(color!(0xAAAAAA)));
77 }
78 if let Some(error) = &state.load_error {
79 content = content.push(
80 text(format!("Failed to load tools: {error}"))
81 .size(12)
82 .color(color!(0xFF8888)),
83 );
84 }
85 let panel = tool_panel(active_entry, state);
86
87 content
88 .push(panel)
89 .padding(12)
90 .width(Length::Fill)
91 .height(Length::Fill)
92 .into()
93}
94
95fn tool_panel<'a>(entry: &'a ToolUiEntry, state: &'a ToolState) -> Element<'a, Message> {
96 let available_color = if entry.available {
97 color!(0x88CC88)
98 } else {
99 color!(0xFF6666)
100 };
101
102 let toggle = toggler(entry.enabled)
103 .on_toggle_maybe((entry.available && !state.loading).then_some({
104 let tool_id = entry.tool_id.clone();
105 move |enabled| Message::ToggleTool {
106 tool_id: tool_id.clone(),
107 enabled,
108 }
109 }))
110 .size(18.0);
111
112 let header = row![
113 column![
114 text(entry.display_name.as_str()).size(18),
115 text(entry.category.as_str())
116 .size(12)
117 .color(color!(0x888888)),
118 ]
119 .spacing(2),
120 iced::widget::space::horizontal(),
121 text(entry.availability_text.as_str())
122 .size(12)
123 .color(available_color),
124 row![text("Enabled").size(12).color(color!(0xAAAAAA)), toggle,]
125 .spacing(8)
126 .align_y(Alignment::Center),
127 ]
128 .align_y(Alignment::Center)
129 .spacing(16);
130
131 let mut body = column![
132 header,
133 text(entry.description.as_str()).size(13),
134 tool_specific_actions(entry, state),
135 settings_panel(entry, state.show_advanced_settings),
136 history_panel(entry),
137 derived_facts_panel(entry),
138 preview_panel(entry),
139 ]
140 .spacing(12);
141
142 if entry.tool_id == "optiscaler" {
143 body = body.push(optiscaler_state_actions(entry, state.game_dir_configured));
144 }
145
146 if let Some(ref msg) = entry.status_message {
147 body = body.push(text(msg.as_str()).size(12).color(color!(0x88CC88)));
148 }
149
150 let scrollable_panel = scrollable(
151 container(body)
152 .padding(12)
153 .width(Length::Fill)
154 .style(container::rounded_box),
155 )
156 .id(semantics::widget_id(format!(
157 "tools.{}.scroll",
158 entry.tool_id
159 )))
160 .height(Length::Fill);
161
162 if entry.has_file_patching {
163 column![
164 scrollable_panel,
165 bottom_action_bar(
166 entry,
167 state.game_dir_configured,
168 state.is_tool_busy(&entry.tool_id),
169 state.loading,
170 ),
171 ]
172 .spacing(8)
173 .height(Length::Fill)
174 .into()
175 } else {
176 scrollable_panel.into()
177 }
178}
179
180fn optiscaler_state_actions(
181 entry: &ToolUiEntry,
182 game_dir_configured: bool,
183) -> Element<'_, Message> {
184 let can_adopt = game_dir_configured && entry.optiscaler_detected_files > 0;
185 let can_restore = game_dir_configured && entry.optiscaler_latest_backup.is_some();
186 let state = entry
187 .optiscaler_state
188 .as_deref()
189 .unwrap_or("no OptiScaler install detected");
190 row![
191 text(state).size(12).color(color!(0xAAAAAA)),
192 iced::widget::space::horizontal(),
193 semantics::test_id(
194 "tools.optiscaler.adopt",
195 button(text("Adopt").size(12))
196 .style(button::secondary)
197 .padding([4, 10])
198 .on_action_maybe(
199 can_adopt.then_some(ButtonAction::AdoptOptiScaler),
200 "No detected OptiScaler files are available to adopt.",
201 ),
202 ),
203 semantics::test_id(
204 "tools.optiscaler.restore_backup",
205 button(text("Restore backup").size(12))
206 .style(button::secondary)
207 .padding([4, 10])
208 .on_action_maybe(
209 can_restore.then_some(ButtonAction::RestoreOptiScalerBackup),
210 "No OptiScaler backup exists for this game.",
211 ),
212 ),
213 semantics::test_id(
214 "tools.optiscaler.reset_config",
215 button(text("Reset config").size(12))
216 .style(button::danger)
217 .padding([4, 10])
218 .on_action(ButtonAction::ResetOptiScalerConfig),
219 ),
220 ]
221 .spacing(8)
222 .align_y(Alignment::Center)
223 .into()
224}
225
226fn tool_specific_actions<'a>(entry: &'a ToolUiEntry, state: &'a ToolState) -> Element<'a, Message> {
227 if entry.tool_id == "optiscaler" && entry.release_support.is_supported() {
228 return optiscaler_release_panel(entry, state);
229 }
230
231 match entry.tool_id.as_str() {
232 "proton" => {
233 let versions = state
234 .tool_option_catalog
235 .get("proton.selected_version")
236 .map_or(0, Vec::len);
237 row![
238 semantics::test_id(
239 "tools.proton.versions.refresh",
240 button(
241 text(if state.proton_versions_loading {
242 "Loading versions"
243 } else {
244 "Refresh versions"
245 })
246 .size(12)
247 )
248 .style(button::secondary)
249 .padding([4, 10])
250 .on_action_maybe(
251 (!state.loading && !state.proton_versions_loading)
252 .then_some(ButtonAction::RefreshProtonVersions),
253 "Proton versions are already loading.",
254 ),
255 ),
256 semantics::test_id(
257 "tools.proton.install_selected",
258 button(text("Install with protonup-rs").size(12))
259 .style(button::primary)
260 .padding([4, 10])
261 .on_action_maybe(
262 (!state.loading && versions > 0)
263 .then_some(ButtonAction::InstallProtonVersion),
264 "No Proton versions are available from protonup-rs.",
265 ),
266 ),
267 text(format!("{versions} version option(s)")).size(12),
268 ]
269 .spacing(8)
270 .align_y(Alignment::Center)
271 .into()
272 }
273 _ => iced::widget::space::vertical()
274 .height(Length::Shrink)
275 .into(),
276 }
277}
278
279fn optiscaler_release_panel<'a>(
280 entry: &'a ToolUiEntry,
281 state: &'a ToolState,
282) -> Element<'a, Message> {
283 let source_mode = setting_value_as_string(setting_value(&entry.settings, "source_mode"));
284 let mut rows = column![text("OptiScaler Release").size(14)].spacing(10);
285
286 if let Some(spec) = tool_setting_spec(entry, "source_mode") {
287 rows = rows.push(setting_row(entry, spec));
288 }
289 if source_mode == "goverlay_builds"
290 && let Some(spec) = tool_setting_spec(entry, "goverlay_channel")
291 {
292 rows = rows.push(setting_row(entry, spec));
293 }
294 if source_mode == "local_dir" {
295 if let Some(spec) = tool_setting_spec(entry, "local_source_dir") {
296 rows = rows.push(setting_row(entry, spec));
297 }
298 return container(rows)
299 .padding(10)
300 .width(Length::Fill)
301 .style(container::rounded_box)
302 .into();
303 }
304 if let Some(spec) = tool_setting_spec(entry, "release_tag") {
305 rows = rows.push(setting_row(entry, spec));
306 }
307 if let Some(spec) = tool_setting_spec(entry, "release_asset") {
308 rows = rows.push(optiscaler_release_asset_row(entry, spec, state));
309 } else {
310 rows = rows.push(optiscaler_release_action_row(state));
311 }
312
313 container(rows)
314 .padding(10)
315 .width(Length::Fill)
316 .style(container::rounded_box)
317 .into()
318}
319
320fn optiscaler_release_asset_row<'a>(
321 entry: &'a ToolUiEntry,
322 spec: &'a modde_games::tools::ToolSettingSpec,
323 state: &'a ToolState,
324) -> Element<'a, Message> {
325 row![
326 column![
327 text(spec.label).size(13),
328 text(spec.description).size(11).color(color!(0x888888)),
329 ]
330 .spacing(2)
331 .width(Length::FillPortion(1)),
332 row![
333 container(setting_control(entry, spec)).width(Length::Fill),
334 optiscaler_release_action_row(state),
335 ]
336 .spacing(8)
337 .align_y(Alignment::Center)
338 .width(Length::FillPortion(2)),
339 ]
340 .spacing(12)
341 .align_y(Alignment::Center)
342 .into()
343}
344
345fn optiscaler_release_action_row(state: &ToolState) -> Element<'_, Message> {
346 let can_refresh = !state.loading && !state.optiscaler_releases_loading;
347 let can_install = !state.loading
348 && !state.optiscaler_releases_loading
349 && state
350 .tool_option_catalog
351 .get("optiscaler.release_asset")
352 .is_some_and(|options| !options.is_empty());
353 row![
354 semantics::test_id(
355 "tools.optiscaler.releases.refresh",
356 button(
357 text(if state.optiscaler_releases_loading {
358 "Loading releases"
359 } else {
360 "Refresh releases"
361 })
362 .size(12)
363 )
364 .style(button::secondary)
365 .padding([4, 10])
366 .on_action_maybe(
367 can_refresh.then_some(ButtonAction::RefreshOptiScalerReleases),
368 "OptiScaler releases are already loading.",
369 ),
370 ),
371 semantics::test_id(
372 "tools.optiscaler.releases.install_selected",
373 button(text("Install selected release").size(12))
374 .style(button::primary)
375 .padding([4, 10])
376 .on_action_maybe(
377 can_install.then_some(ButtonAction::InstallOptiScalerRelease),
378 "Load releases and select an installable asset before installing.",
379 ),
380 ),
381 ]
382 .spacing(8)
383 .align_y(Alignment::Center)
384 .into()
385}
386
387fn tool_setting_spec<'a>(
388 entry: &'a ToolUiEntry,
389 key: &str,
390) -> Option<&'a modde_games::tools::ToolSettingSpec> {
391 entry.setting_specs.iter().find(|spec| spec.key == key)
392}
393
394fn derived_facts_panel(entry: &ToolUiEntry) -> Element<'_, Message> {
395 if entry.derived_facts.is_empty() {
396 return iced::widget::space::vertical()
397 .height(Length::Shrink)
398 .into();
399 }
400 let mut facts = column![text("Detected Game").size(14)].spacing(4);
401 for (label, value) in &entry.derived_facts {
402 facts = facts.push(
403 row![
404 text(label.as_str())
405 .size(11)
406 .color(color!(0x888888))
407 .width(Length::FillPortion(1)),
408 text(value.as_str()).size(11).width(Length::FillPortion(2)),
409 ]
410 .spacing(8),
411 );
412 }
413 facts.into()
414}
415
416fn settings_panel(entry: &ToolUiEntry, show_advanced: bool) -> Element<'_, Message> {
417 let advanced_count = entry
418 .setting_specs
419 .iter()
420 .filter(|spec| !is_extracted_optiscaler_release_setting(entry, spec.key))
421 .filter(|spec| spec.advanced)
422 .count();
423 let header = row![
424 text("Settings").size(14),
425 iced::widget::space::horizontal(),
426 if advanced_count > 0 {
427 semantics::test_id(
428 "tools.settings.toggle_advanced",
429 button(
430 text(if show_advanced {
431 "Hide advanced"
432 } else {
433 "Show advanced"
434 })
435 .size(12),
436 )
437 .style(button::secondary)
438 .padding([4, 10])
439 .on_action(ButtonAction::ToggleToolAdvancedSettings),
440 )
441 } else {
442 iced::widget::space::horizontal()
443 .width(Length::Shrink)
444 .into()
445 },
446 ]
447 .align_y(Alignment::Center);
448 let mut rows = column![header].spacing(10);
449 let mut sections: Vec<(&str, Vec<&modde_games::tools::ToolSettingSpec>)> = Vec::new();
450 for spec in &entry.setting_specs {
451 if is_extracted_optiscaler_release_setting(entry, spec.key) {
452 continue;
453 }
454 if spec.advanced && !show_advanced {
455 continue;
456 }
457 if let Some((_, specs)) = sections
458 .iter_mut()
459 .find(|(section, _)| *section == spec.section)
460 {
461 specs.push(spec);
462 } else {
463 sections.push((spec.section, vec![spec]));
464 }
465 }
466 for (section, specs) in sections {
467 rows = rows.push(text(section).size(13).color(color!(0xBBBBBB)));
468 for spec in specs {
469 rows = rows.push(setting_row(entry, spec));
470 }
471 }
472 rows.into()
473}
474
475fn is_extracted_optiscaler_release_setting(entry: &ToolUiEntry, key: &str) -> bool {
476 entry.tool_id == "optiscaler"
477 && matches!(
478 key,
479 "source_mode"
480 | "goverlay_channel"
481 | "release_tag"
482 | "release_asset"
483 | "local_source_dir"
484 )
485}
486
487fn setting_row<'a>(
488 entry: &'a ToolUiEntry,
489 spec: &'a modde_games::tools::ToolSettingSpec,
490) -> Element<'a, Message> {
491 let control = setting_control(entry, spec);
492
493 row![
494 column![
495 text(spec.label).size(13),
496 text(spec.description).size(11).color(color!(0x888888)),
497 ]
498 .spacing(2)
499 .width(Length::FillPortion(1)),
500 container(control).width(Length::FillPortion(2)),
501 ]
502 .spacing(12)
503 .align_y(Alignment::Center)
504 .into()
505}
506
507fn setting_control<'a>(
508 entry: &'a ToolUiEntry,
509 spec: &'a modde_games::tools::ToolSettingSpec,
510) -> Element<'a, Message> {
511 let value = setting_value(&entry.settings, spec.key);
512 let setting_test_id = tool_setting_test_id(&entry.tool_id, spec.key);
513 match &spec.kind {
514 ToolSettingKind::Bool => {
515 let current = setting_value_as_bool(value).unwrap_or(false);
516 semantics::test_id(
517 setting_test_id,
518 toggler(current)
519 .on_toggle({
520 let tool_id = entry.tool_id.clone();
521 let key = spec.key.to_string();
522 move |enabled| Message::UpdateToolSetting {
523 tool_id: tool_id.clone(),
524 key: key.clone(),
525 value: serde_json::json!(enabled),
526 }
527 })
528 .size(18.0),
529 )
530 }
531 ToolSettingKind::TriStateBool => {
532 let selected = tri_state_label(value);
533 row![
534 tri_state_button(entry, spec.key, "Auto", &selected),
535 tri_state_button(entry, spec.key, "On", &selected),
536 tri_state_button(entry, spec.key, "Off", &selected),
537 ]
538 .spacing(6)
539 .into()
540 }
541 ToolSettingKind::Text | ToolSettingKind::Path => {
542 text_input(spec.label, &setting_value_as_string(value))
543 .id(semantics::widget_id(setting_test_id))
544 .on_input({
545 let tool_id = entry.tool_id.clone();
546 let key = spec.key.to_string();
547 move |input| Message::UpdateToolSetting {
548 tool_id: tool_id.clone(),
549 key: key.clone(),
550 value: serde_json::json!(input),
551 }
552 })
553 .padding(6)
554 .width(Length::Fill)
555 .into()
556 }
557 ToolSettingKind::Select { options } => {
558 let selected = value
559 .and_then(serde_json::Value::as_str)
560 .and_then(|value| options.iter().find(|option| option.value == value).cloned())
561 .or_else(|| options.first().cloned());
562 semantics::test_id(
563 setting_test_id,
564 pick_list(options.clone(), selected, {
565 let tool_id = entry.tool_id.clone();
566 let key = spec.key.to_string();
567 move |selected| Message::UpdateToolSetting {
568 tool_id: tool_id.clone(),
569 key: key.clone(),
570 value: serde_json::json!(selected.value),
571 }
572 })
573 .width(Length::Fill),
574 )
575 }
576 ToolSettingKind::Number { min, max, step } => {
577 let current = setting_value_as_f64(value)
578 .unwrap_or(*min)
579 .clamp(*min, *max);
580 semantics::test_id(
581 setting_test_id,
582 row![
583 slider(*min..=*max, current, {
584 let tool_id = entry.tool_id.clone();
585 let key = spec.key.to_string();
586 move |number| Message::UpdateToolSetting {
587 tool_id: tool_id.clone(),
588 key: key.clone(),
589 value: serde_json::json!(number),
590 }
591 })
592 .step(*step),
593 text(format_number(current))
594 .size(12)
595 .width(Length::Fixed(56.0)),
596 ]
597 .spacing(8)
598 .align_y(Alignment::Center),
599 )
600 }
601 ToolSettingKind::ReadOnly => {
602 text(setting_value_as_string(value).if_empty(spec.description))
603 .size(12)
604 .color(color!(0xAAAAAA))
605 .into()
606 }
607 }
608}
609
610fn tri_state_button<'a>(
611 entry: &'a ToolUiEntry,
612 key: &'a str,
613 label: &'static str,
614 selected: &str,
615) -> Element<'a, Message> {
616 let style = if selected == label {
617 button::primary
618 } else {
619 button::secondary
620 };
621 semantics::test_id(
622 format!(
623 "{}.{}",
624 tool_setting_test_id(&entry.tool_id, key),
625 label.to_ascii_lowercase()
626 ),
627 button(text(label).size(12))
628 .style(style)
629 .padding([4, 10])
630 .on_action(ButtonAction::UpdateToolSetting {
631 tool_id: entry.tool_id.clone(),
632 key: key.to_string(),
633 value: tri_state_value(label),
634 }),
635 )
636}
637
638fn tool_setting_test_id(tool_id: &str, key: &str) -> String {
639 format!("tools.{tool_id}.setting.{}", key.replace('.', "__"))
640}
641
642fn setting_value<'a>(settings: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> {
643 settings
644 .get(key)
645 .or_else(|| nested_setting_value(settings, key))
646 .or_else(|| legacy_flat_child_setting_value(settings, key))
647}
648
649fn nested_setting_value<'a>(
650 settings: &'a serde_json::Value,
651 key: &str,
652) -> Option<&'a serde_json::Value> {
653 let mut current = settings;
654 for part in key.split('.') {
655 current = current.as_object()?.get(part)?;
656 }
657 Some(current)
658}
659
660fn legacy_flat_child_setting_value<'a>(
661 settings: &'a serde_json::Value,
662 key: &str,
663) -> Option<&'a serde_json::Value> {
664 let (root, child) = key.split_once('.')?;
665 settings.as_object()?.get(root)?.as_object()?.get(child)
666}
667
668fn setting_value_as_bool(value: Option<&serde_json::Value>) -> Option<bool> {
669 match value {
670 Some(serde_json::Value::Bool(value)) => Some(*value),
671 Some(serde_json::Value::String(value)) => parse_bool_string(value),
672 _ => None,
673 }
674}
675
676fn parse_bool_string(value: &str) -> Option<bool> {
677 match value.trim().to_ascii_lowercase().as_str() {
678 "true" | "1" | "yes" | "on" => Some(true),
679 "false" | "0" | "no" | "off" => Some(false),
680 _ => None,
681 }
682}
683
684fn setting_value_as_f64(value: Option<&serde_json::Value>) -> Option<f64> {
685 match value {
686 Some(serde_json::Value::Number(value)) => value.as_f64(),
687 Some(serde_json::Value::String(value)) => value.trim().parse().ok(),
688 _ => None,
689 }
690}
691
692fn tri_state_label(value: Option<&serde_json::Value>) -> String {
693 match setting_value_as_bool(value) {
694 Some(true) => "On".to_string(),
695 Some(false) => "Off".to_string(),
696 None => "Auto".to_string(),
697 }
698}
699
700fn tri_state_value(selected: &str) -> serde_json::Value {
701 match selected {
702 "On" => serde_json::json!(true),
703 "Off" => serde_json::json!(false),
704 _ => serde_json::json!("auto"),
705 }
706}
707
708fn format_number(value: f64) -> String {
709 let mut formatted = format!("{value:.2}");
710 while formatted.contains('.') && formatted.ends_with('0') {
711 formatted.pop();
712 }
713 if formatted.ends_with('.') {
714 formatted.pop();
715 }
716 formatted
717}
718
719fn preview_panel(entry: &ToolUiEntry) -> Element<'_, Message> {
720 let mut preview = column![text("Launch Integration").size(14)].spacing(6);
721
722 if let Some(path) = &entry.generated_config_path {
723 preview = preview.push(text(format!("Config: {path}")).size(12));
724 }
725 if !entry.env_preview.is_empty() {
726 preview = preview.push(
727 text(format!(
728 "Env: {}",
729 entry
730 .env_preview
731 .iter()
732 .map(|(key, value)| format!("{key}={value}"))
733 .collect::<Vec<_>>()
734 .join(", ")
735 ))
736 .size(12),
737 );
738 }
739 if !entry.dll_overrides.is_empty() {
740 preview = preview
741 .push(text(format!("DLL overrides: {}", entry.dll_overrides.join(", "))).size(12));
742 }
743 if !entry.wrapper_preview.is_empty() {
744 preview =
745 preview.push(text(format!("Wrappers: {}", entry.wrapper_preview.join(", "))).size(12));
746 }
747 if entry.generated_config_path.is_none()
748 && entry.env_preview.is_empty()
749 && entry.dll_overrides.is_empty()
750 && entry.wrapper_preview.is_empty()
751 {
752 preview =
753 preview.push(text("No launch integration preview for current settings.").size(12));
754 }
755
756 preview.into()
757}
758
759fn history_panel(entry: &ToolUiEntry) -> Element<'_, Message> {
760 let mut rows = column![text("Settings History").size(14)].spacing(6);
761 if entry.setting_history.is_empty() {
762 return rows
763 .push(
764 text("No settings history recorded yet.")
765 .size(12)
766 .color(color!(0x888888)),
767 )
768 .into();
769 }
770
771 for node in &entry.setting_history {
772 rows = rows.push(history_row(entry, node));
773 }
774 rows.into()
775}
776
777fn history_row<'a>(entry: &'a ToolUiEntry, node: &'a ToolHistoryUiEntry) -> Element<'a, Message> {
778 let marker = if node.is_current {
779 "current"
780 } else {
781 "version"
782 };
783 let state = if node.enabled { "enabled" } else { "disabled" };
784 row![
785 column![
786 text(format!("{marker}: {}", node.label)).size(12),
787 text(format!("{} - {state}", node.reason))
788 .size(11)
789 .color(color!(0x888888)),
790 ]
791 .spacing(2)
792 .width(Length::Fill),
793 button(text("Restore").size(12))
794 .style(button::secondary)
795 .padding([4, 10])
796 .on_action_maybe(
797 (!node.is_current).then_some(ButtonAction::RestoreToolSettings {
798 tool_id: entry.tool_id.clone(),
799 node_id: node.node_id.clone(),
800 }),
801 "This settings version is already current.",
802 ),
803 ]
804 .spacing(8)
805 .align_y(Alignment::Center)
806 .into()
807}
808
809fn bottom_action_bar(
810 entry: &ToolUiEntry,
811 game_dir_configured: bool,
812 tool_busy: bool,
813 tools_loading: bool,
814) -> Element<'_, Message> {
815 let (can_apply, apply_disabled_reason) =
816 apply_readiness(entry, game_dir_configured, tool_busy, tools_loading);
817 let can_revert =
818 game_dir_configured && !entry.applied_files.is_empty() && !tool_busy && !tools_loading;
819 let revert_readiness = if tools_loading {
820 RevertReadiness::ToolsLoading
821 } else if tool_busy {
822 RevertReadiness::ToolBusy
823 } else if !game_dir_configured {
824 RevertReadiness::MissingGameDir
825 } else if entry.applied_files.is_empty() {
826 RevertReadiness::NoAppliedFiles
827 } else {
828 RevertReadiness::Ready
829 };
830 let applied_count = entry.applied_files.len();
831 let mut actions = row![
832 text(format!("{applied_count} file(s) applied to game directory"))
833 .size(12)
834 .color(color!(0xAAAA66)),
835 iced::widget::space::horizontal(),
836 ];
837 if entry.tool_id == "optiscaler" {
838 let (can_activate, activate_disabled_reason) =
839 activation_readiness(entry, game_dir_configured, tool_busy, tools_loading);
840 let can_deactivate = game_dir_configured && !tool_busy && !tools_loading && entry.enabled;
841 actions = actions
842 .push(semantics::test_id(
843 "tools.optiscaler.activate",
844 button(text("Activate").size(12))
845 .style(button::success)
846 .padding([6, 14])
847 .on_action_maybe(
848 can_activate.then_some(ButtonAction::ActivateOptiScaler),
849 activate_disabled_reason,
850 ),
851 ))
852 .push(semantics::test_id(
853 "tools.optiscaler.deactivate",
854 button(text("Deactivate").size(12))
855 .style(button::danger)
856 .padding([6, 14])
857 .on_action_maybe(
858 can_deactivate.then_some(ButtonAction::DeactivateOptiScaler),
859 "OptiScaler must be enabled and idle before it can be deactivated.",
860 ),
861 ));
862 }
863 actions = actions
864 .push(semantics::test_id(
865 format!("tools.{}.apply", entry.tool_id),
866 button(text(apply_button_label(entry, tool_busy)).size(12))
867 .style(button::primary)
868 .padding([6, 14])
869 .on_action_maybe(
870 can_apply.then_some(ButtonAction::ApplyTool(entry.tool_id.clone())),
871 apply_disabled_reason,
872 ),
873 ))
874 .push(semantics::test_id(
875 format!("tools.{}.revert", entry.tool_id),
876 button(text("Revert").size(12))
877 .style(button::danger)
878 .padding([6, 14])
879 .on_action_maybe(
880 can_revert.then_some(ButtonAction::RevertTool(entry.tool_id.clone())),
881 revert_disabled_reason(revert_readiness),
882 ),
883 ));
884 container(actions.spacing(8).align_y(Alignment::Center))
885 .padding([8, 12])
886 .width(Length::Fill)
887 .style(container::rounded_box)
888 .into()
889}
890
891fn activation_readiness(
892 entry: &ToolUiEntry,
893 game_dir_configured: bool,
894 tool_busy: bool,
895 tools_loading: bool,
896) -> (bool, &'static str) {
897 let (can_apply, apply_disabled_reason) =
898 apply_readiness(entry, game_dir_configured, tool_busy, tools_loading);
899 if can_apply
900 || !entry.enabled
901 && apply_disabled_reason == "This tool is already applied for the current settings."
902 {
903 return (true, "");
904 }
905 (false, apply_disabled_reason)
906}
907
908#[derive(Debug, Clone, Copy)]
909enum RevertReadiness {
910 Ready,
911 ToolsLoading,
912 ToolBusy,
913 MissingGameDir,
914 NoAppliedFiles,
915}
916
917fn revert_disabled_reason(readiness: RevertReadiness) -> &'static str {
918 match readiness {
919 RevertReadiness::ToolsLoading => "Tool state is still loading.",
920 RevertReadiness::ToolBusy => "A tool operation is already in progress.",
921 RevertReadiness::MissingGameDir => {
922 "Configure the game install path before reverting files."
923 }
924 RevertReadiness::NoAppliedFiles => {
925 "This tool has no applied files to revert for the current game."
926 }
927 RevertReadiness::Ready => "This tool cannot be reverted right now.",
928 }
929}
930
931fn apply_readiness(
932 entry: &ToolUiEntry,
933 game_dir_configured: bool,
934 tool_busy: bool,
935 tools_loading: bool,
936) -> (bool, &'static str) {
937 if tools_loading {
938 return (false, "Tool state is still loading.");
939 }
940 if tool_busy {
941 return (false, "A tool operation is already in progress.");
942 }
943 if !game_dir_configured {
944 return (
945 false,
946 "Configure the game install path before applying files.",
947 );
948 }
949 if !entry.available {
950 return (
951 false,
952 "Make this tool available on the system before applying files.",
953 );
954 }
955 if !entry.apply_missing_inputs.is_empty() {
956 return (false, "Resolve missing source files before applying.");
957 }
958 if entry.tool_id == "optiscaler" {
959 match setting_value_as_string(setting_value(&entry.settings, "source_mode")).as_str() {
960 "github_release" | "goverlay_builds" => {
961 let tag = setting_value_as_string(setting_value(&entry.settings, "release_tag"));
962 let asset =
963 setting_value_as_string(setting_value(&entry.settings, "release_asset"));
964 if tag.trim().is_empty() || asset.trim().is_empty() {
965 return (
966 false,
967 "Select an OptiScaler release tag and asset before applying files.",
968 );
969 }
970 }
971 "local_dir" => {
972 let path =
973 setting_value_as_string(setting_value(&entry.settings, "local_source_dir"));
974 if path.trim().is_empty() {
975 return (
976 false,
977 "Choose a local OptiScaler source directory before applying files.",
978 );
979 }
980 }
981 _ => {}
982 }
983 }
984 if !entry.apply_pending {
985 return (
986 false,
987 "This tool is already applied for the current settings.",
988 );
989 }
990 (true, "")
991}
992
993fn apply_button_label(entry: &ToolUiEntry, tool_busy: bool) -> &'static str {
994 if tool_busy {
995 "Applying"
996 } else if !entry.apply_pending && entry.apply_missing_inputs.is_empty() {
997 "No changes"
998 } else {
999 "Apply"
1000 }
1001}
1002
1003fn setting_value_as_string(value: Option<&serde_json::Value>) -> String {
1004 match value {
1005 Some(serde_json::Value::String(value)) => value.clone(),
1006 Some(serde_json::Value::Bool(value)) => value.to_string(),
1007 Some(serde_json::Value::Number(value)) => value.to_string(),
1008 Some(serde_json::Value::Array(values)) => values
1009 .iter()
1010 .filter_map(serde_json::Value::as_str)
1011 .collect::<Vec<_>>()
1012 .join(", "),
1013 Some(value) => value.to_string(),
1014 None => String::new(),
1015 }
1016}
1017
1018trait EmptyFallback {
1019 fn if_empty(self, fallback: &str) -> String;
1020}
1021
1022impl EmptyFallback for String {
1023 fn if_empty(self, fallback: &str) -> String {
1024 if self.is_empty() {
1025 fallback.to_string()
1026 } else {
1027 self
1028 }
1029 }
1030}