modde-ui 0.2.1

GUI application for modde
Documentation
use crate::views::selectable_text::text;
use iced::widget::{button, column, container, pick_list, row, scrollable, text_input};
use iced::{Alignment, Element, Length, color};

use crate::action_button::{ButtonAction, DescribedButtonExt};
use crate::app::{AddCustomGameDraftField, AddCustomGameState, Message};
use crate::semantics;

pub fn add_dialog(state: &AddCustomGameState) -> Element<'_, Message> {
    let draft = &state.draft;
    let selected_dir = draft
        .executable_dir
        .as_ref()
        .and_then(|value| {
            state
                .detected_dirs
                .iter()
                .find(|dir| dir.relative_dir == *value)
        })
        .cloned();

    let mut body = column![
        text("Add Custom Game").size(18),
        row![
            text_input("Game id", &draft.id)
                .id(semantics::widget_id("add_custom_game.input.id"))
                .on_input(|value| Message::AddCustomGameFieldChanged {
                    field: AddCustomGameDraftField::Id,
                    value,
                })
                .padding(8)
                .width(Length::Fill),
            text_input("Display name", &draft.display_name)
                .id(semantics::widget_id("add_custom_game.input.display_name"))
                .on_input(|value| Message::AddCustomGameFieldChanged {
                    field: AddCustomGameDraftField::DisplayName,
                    value,
                })
                .padding(8)
                .width(Length::Fill),
        ]
        .spacing(8),
        row![
            text_input("Install path", &draft.install_path)
                .id(semantics::widget_id("add_custom_game.input.install_path"))
                .on_input(|value| Message::AddCustomGameFieldChanged {
                    field: AddCustomGameDraftField::InstallPath,
                    value,
                })
                .padding(8)
                .width(Length::Fill),
            semantics::test_id(
                "add_custom_game.install_path.browse",
                button(text("Browse").size(13))
                    .style(button::secondary)
                    .padding([6, 12])
                    .on_action(ButtonAction::BrowseAddCustomGameInstallPath),
            ),
        ]
        .spacing(8)
        .align_y(Alignment::Center),
        semantics::test_id(
            "add_custom_game.input.executable_dir",
            pick_list(state.detected_dirs.clone(), selected_dir, |dir| {
                Message::AddCustomGameFieldChanged {
                    field: AddCustomGameDraftField::ExecutableDir,
                    value: dir.relative_dir,
                }
            })
            .placeholder("Executable directory")
            .width(Length::Fill),
        ),
        row![
            text_input(
                "Steam app id (optional)",
                draft.steam_app_id.as_deref().unwrap_or(""),
            )
            .id(semantics::widget_id("add_custom_game.input.steam_app_id"))
            .on_input(|value| Message::AddCustomGameFieldChanged {
                field: AddCustomGameDraftField::SteamAppId,
                value,
            })
            .padding(8)
            .width(Length::Fill),
            text_input(
                "Nexus domain (optional)",
                draft.nexus_domain.as_deref().unwrap_or(""),
            )
            .id(semantics::widget_id("add_custom_game.input.nexus_domain"))
            .on_input(|value| Message::AddCustomGameFieldChanged {
                field: AddCustomGameDraftField::NexusDomain,
                value,
            })
            .padding(8)
            .width(Length::Fill),
        ]
        .spacing(8),
        text_input(
            "Proxy DLLs, comma-separated (optional)",
            &draft.proxy_dlls_csv,
        )
        .id(semantics::widget_id("add_custom_game.input.proxy_dlls"))
        .on_input(|value| Message::AddCustomGameFieldChanged {
            field: AddCustomGameDraftField::ProxyDlls,
            value,
        })
        .padding(8)
        .width(Length::Fill),
    ]
    .spacing(10);

    if !state.detected_dirs.is_empty() {
        let detect_rows = state.detected_dirs.iter().enumerate().fold(
            column![text("Detected executable directories").size(12)],
            |column, (index, dir)| {
                column.push(semantics::test_id(
                    format!("add_custom_game.detect.{index}"),
                    text(format!(
                        "{} ({})",
                        dir.relative_dir,
                        dir.exe_names.join(", ")
                    ))
                    .size(11),
                ))
            },
        );
        body = body.push(detect_rows.spacing(4));
    }

    if let Some(error) = &state.error {
        body = body.push(text(error).size(12).color(color!(0xFF6666)));
    }

    body = body.push(
        row![
            iced::widget::Space::new().width(Length::Fill),
            semantics::test_id(
                "add_custom_game.cancel",
                button(text("Cancel").size(13))
                    .style(button::secondary)
                    .padding([6, 14])
                    .on_action(ButtonAction::AddCustomGameCancel),
            ),
            semantics::test_id(
                "add_custom_game.submit",
                button(text("Save").size(13))
                    .style(button::success)
                    .padding([6, 14])
                    .on_action_maybe(
                        state
                            .can_submit()
                            .then_some(ButtonAction::AddCustomGameSubmit),
                        "Enter a valid id, display name, install path, and executable directory.",
                    ),
            ),
        ]
        .spacing(8)
        .align_y(Alignment::Center),
    );

    container(body)
        .width(Length::Fixed(720.0))
        .padding(16)
        .style(container::rounded_box)
        .into()
}

pub fn manage_dialog(custom_games: Vec<(String, String)>) -> Element<'static, Message> {
    let mut rows = column![text("Manage Custom Games").size(18)].spacing(10);
    if custom_games.is_empty() {
        rows = rows.push(
            text("No custom games are registered.")
                .size(12)
                .color(color!(0xAAAAAA)),
        );
    } else {
        let list = custom_games
            .into_iter()
            .fold(column![].spacing(8), |column, (id, label)| {
                column.push(
                    container(
                        row![
                            column![
                                text(label).size(14),
                                text(id.clone()).size(11).color(color!(0xAAAAAA))
                            ]
                            .spacing(2)
                            .width(Length::Fill),
                            semantics::test_id(
                                format!("manage_custom_games.remove.{id}"),
                                button(text("Remove").size(12))
                                    .style(button::danger)
                                    .padding([5, 12])
                                    .on_action(ButtonAction::RemoveCustomGame(id.clone())),
                            ),
                        ]
                        .spacing(8)
                        .align_y(Alignment::Center),
                    )
                    .padding(8)
                    .style(container::rounded_box),
                )
            });
        rows = rows.push(scrollable(list).height(Length::Fixed(280.0)));
    }

    rows = rows.push(
        row![
            iced::widget::Space::new().width(Length::Fill),
            semantics::test_id(
                "manage_custom_games.close",
                button(text("Close").size(13))
                    .style(button::secondary)
                    .padding([6, 14])
                    .on_action(ButtonAction::CloseManageCustomGames),
            ),
        ]
        .align_y(Alignment::Center),
    );

    container(rows)
        .width(Length::Fixed(520.0))
        .padding(16)
        .style(container::rounded_box)
        .into()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::app::AddCustomGameDraft;

    #[test]
    fn add_custom_game_view_renders() {
        let state = AddCustomGameState {
            draft: AddCustomGameDraft {
                id: "elden-ring".to_string(),
                display_name: "ELDEN RING".to_string(),
                install_path: "/games/elden-ring".to_string(),
                executable_dir: Some("Game".to_string()),
                steam_app_id: Some("1245620".to_string()),
                nexus_domain: Some("eldenring".to_string()),
                proxy_dlls_csv: "dxgi,winmm".to_string(),
            },
            detected_dirs: vec![modde_games::DetectCandidateDir {
                relative_dir: "Game".to_string(),
                exe_names: vec!["eldenring.exe".to_string()],
                total_size: 1,
            }],
            error: None,
        };

        let element = add_dialog(&state);
        let _ = element;
    }
}