Skip to main content

modde_ui/views/
tools.rs

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
14/// Render the gaming tools/overlays management view.
15pub 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}