Skip to content

Commit 7ecbdfd

Browse files
Launch profile system + migration
1 parent 53a08f8 commit 7ecbdfd

12 files changed

Lines changed: 593 additions & 136 deletions

File tree

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { useState, useEffect } from "react";
2+
import Modal from "react-bootstrap/Modal";
3+
import Form from "react-bootstrap/Form";
4+
5+
import Button from "./Button";
6+
7+
import { LaunchProfile } from "@/app/types";
8+
9+
const DEFAULT_NAME = "New Profile";
10+
const DEFAULT_COMMAND = "{}";
11+
12+
export default function EditProfileModal({
13+
profile,
14+
isAdd,
15+
show,
16+
setShow,
17+
saveProfile,
18+
}: {
19+
profile?: LaunchProfile;
20+
isAdd: boolean;
21+
show: boolean;
22+
setShow: (newShow: boolean) => void;
23+
saveProfile: (name: string, command: string, uuid?: string) => void;
24+
deleteProfile?: (uuid: string) => void;
25+
}) {
26+
const doHide = () => {
27+
setShow(false);
28+
};
29+
30+
const [name, setName] = useState<string>("");
31+
const [command, setCommand] = useState<string>("");
32+
33+
useEffect(() => {
34+
setName(isAdd ? "" : profile?.name || "");
35+
setCommand(isAdd ? "" : profile?.command || "");
36+
}, [profile, isAdd, show]);
37+
38+
const isValid = () => {
39+
const nameTrimmed = name.trim();
40+
const commandTrimmed = command.trim();
41+
if (nameTrimmed === "") {
42+
return false;
43+
}
44+
if (commandTrimmed === "" || !commandTrimmed.includes("{}")) {
45+
return false;
46+
}
47+
return true;
48+
};
49+
50+
const isPreset = !isAdd && profile!.preset;
51+
52+
return (
53+
<Modal show={show} onHide={() => doHide()} centered={true} size="lg">
54+
<Modal.Header>
55+
<Modal.Title>
56+
{isAdd ? "Add Launch Profile" : "Edit Launch Profile"}
57+
</Modal.Title>
58+
</Modal.Header>
59+
<Modal.Body>
60+
<Form>
61+
<Form.Group className="mb-3" controlId="editProfileName">
62+
<Form.Label>Profile Name</Form.Label>
63+
<Form.Control
64+
type="text"
65+
value={name}
66+
onChange={(e) => setName(e.target.value)}
67+
placeholder={DEFAULT_NAME}
68+
/>
69+
</Form.Group>
70+
<Form.Group className="mb-3" controlId="editProfileCommand">
71+
<Form.Label>Launch Command</Form.Label>
72+
<Form.Control
73+
as="textarea"
74+
rows={3}
75+
value={command}
76+
onChange={(e) => setCommand(e.target.value)}
77+
placeholder={DEFAULT_COMMAND}
78+
isInvalid={command.trim() !== "" && !command.includes("{}")}
79+
readOnly={isPreset}
80+
disabled={isPreset}
81+
/>
82+
<Form.Text className="text-muted">
83+
Use <code>{"{}"}</code> as a placeholder for the game executable.
84+
</Form.Text>
85+
</Form.Group>
86+
</Form>
87+
</Modal.Body>
88+
<Modal.Footer>
89+
<Button onClick={() => doHide()} className="me-auto" variant="primary" text="Cancel" />
90+
{!isAdd && <Button icon="copy" text="Duplicate" onClick={() => {
91+
const profileToDuplicate = profile!;
92+
saveProfile(profileToDuplicate.name + " (copy)", profileToDuplicate.command);
93+
doHide();
94+
}} />}
95+
{
96+
!isPreset && <Button
97+
onClick={() => {
98+
const nameTrimmed = name.trim();
99+
const commandTrimmed = command.trim();
100+
saveProfile(nameTrimmed, commandTrimmed, profile?.uuid);
101+
doHide();
102+
}}
103+
variant="success"
104+
text={isAdd ? "Add Profile" : "Save Profile"}
105+
enabled={isValid()}
106+
/>
107+
}
108+
</Modal.Footer>
109+
</Modal>
110+
);
111+
}

app/settings/GameSettingsTab.tsx

Lines changed: 117 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,37 @@
1-
import { GameSettings, WindowSize } from "@/app/types";
1+
import { GameSettings, LaunchProfiles, WindowSize } from "@/app/types";
22
import { useContext, useEffect, useState } from "react";
33
import { Col, Container, Form, Row } from "react-bootstrap";
44
import SettingControlDropdown from "./SettingControlDropdown";
55
import SettingControlWindowSize from "./SettingControlWindowSize";
6-
import SettingControlText from "./SettingControlText";
76
import { deepEqual, getDebugMode } from "@/app/util";
87
import SettingsHeader from "./SettingsHeader";
98
import { SettingsCtx } from "@/app/contexts";
109
import SettingControlFpsFix from "./SettingControlFpsFix";
10+
import Button from "@/app/components/Button";
11+
import EditProfileModal from "@/app/components/EditProfileModal";
12+
import { invoke } from "@tauri-apps/api/core";
1113

1214
export default function GameSettingsTab({
1315
active,
16+
currentProfiles,
1417
currentSettings,
1518
updateSettings,
1619
}: {
1720
active: boolean;
1821
currentSettings: GameSettings;
22+
currentProfiles: LaunchProfiles;
1923
updateSettings: (
2024
newSettings: GameSettings | undefined,
2125
) => Promise<GameSettings>;
26+
2227
}) {
2328
const [settings, setSettings] = useState<GameSettings>(currentSettings);
29+
const [launchProfiles, setLaunchProfiles] = useState<LaunchProfiles>(currentProfiles);
2430
const [working, setWorking] = useState<boolean>(false);
2531

32+
const [showAddProfile, setShowAddProfile] = useState<boolean>(false);
33+
const [showEditProfile, setShowEditProfile] = useState<boolean>(false);
34+
2635
const [debug, setDebug] = useState<boolean>(false);
2736

2837
const ctx = useContext(SettingsCtx);
@@ -62,6 +71,86 @@ export default function GameSettingsTab({
6271
}
6372
};
6473

74+
const selectedLaunchProfile = launchProfiles.profiles.find(
75+
(p) => p.uuid === settings.launch_profile,
76+
);
77+
78+
const canModify = (selectedLaunchProfile !== undefined) && !selectedLaunchProfile!.preset;
79+
80+
const saveProfile = async (name: string, command: string, uuid?: string) => {
81+
setWorking(true);
82+
if (uuid) {
83+
// existing profile
84+
const updatedProfile = {
85+
uuid,
86+
name,
87+
command,
88+
preset: false,
89+
};
90+
91+
try {
92+
await invoke("update_launch_profile", { profile: updatedProfile });
93+
setLaunchProfiles({
94+
profiles: launchProfiles.profiles.map((p) =>
95+
p.uuid === uuid ? updatedProfile : p
96+
),
97+
});
98+
} catch (e: unknown) {
99+
if (ctx.alertError) {
100+
ctx.alertError("Failed to update launch profile: (" + e + ")");
101+
}
102+
}
103+
} else {
104+
// new profile
105+
const newUuid: string = await invoke("add_launch_profile", { name, command });
106+
setLaunchProfiles({
107+
profiles: [
108+
...launchProfiles.profiles,
109+
{
110+
uuid: newUuid,
111+
name,
112+
command,
113+
preset: false,
114+
},
115+
],
116+
});
117+
setSettings({
118+
...settings!,
119+
launch_profile: newUuid,
120+
})
121+
}
122+
setWorking(false);
123+
};
124+
125+
const deleteProfile = async (uuid: string) => {
126+
await invoke("delete_launch_profile", { uuid });
127+
const newProfiles = launchProfiles.profiles.filter((p) => p.uuid !== uuid);
128+
setLaunchProfiles({
129+
profiles: newProfiles,
130+
});
131+
132+
if (newProfiles.length > 0) {
133+
const newSettings = {
134+
...settings!,
135+
launch_profile: newProfiles[0].uuid,
136+
};
137+
setSettings(newSettings);
138+
await updateSettings(newSettings);
139+
}
140+
};
141+
142+
const showDeleteProfileConfirmation = () => {
143+
const selectedProfile = selectedLaunchProfile;
144+
if (ctx.showConfirmationModal && selectedProfile) {
145+
ctx.showConfirmationModal(
146+
"Are you sure you want to delete the launch profile \"" + selectedProfile.name + "\"?",
147+
"Delete Launch Profile",
148+
"danger",
149+
async () => deleteProfile(selectedProfile!.uuid),
150+
);
151+
}
152+
};
153+
65154
return (
66155
<Container fluid id="settings-container" className="bg-footer">
67156
<Row>
@@ -99,22 +188,24 @@ export default function GameSettingsTab({
99188
setSettings({ ...settings!, graphics_api: value })
100189
}
101190
/>
102-
<SettingControlText
103-
id="launch_command"
104-
name="Custom Launch Command"
105-
oldValue={currentSettings.launch_command}
106-
value={settings.launch_command}
107-
placeholder="{}"
108-
validator={(value) =>
109-
value === "" || value.indexOf("{}") !== -1
110-
}
191+
<SettingControlDropdown
192+
id="launch_profile"
193+
name="Launch Profile"
194+
options={launchProfiles.profiles.map((profile) => ({ key: profile.uuid, label: profile.preset ? profile.name + " (preset)" : profile.name }))}
195+
defaultKey={launchProfiles.profiles.length > 0 ? launchProfiles.profiles[0].uuid : ""}
196+
oldValue={currentSettings.launch_profile}
197+
value={settings.launch_profile}
111198
onChange={(value) =>
112199
setSettings({
113200
...settings!,
114-
launch_command: value === "" ? undefined : value,
201+
launch_profile: value,
115202
})
116203
}
117-
/>
204+
>
205+
<Button className="ms-1" icon="trash" tooltip="Delete..." variant="danger" enabled={canModify} onClick={() => showDeleteProfileConfirmation()} />
206+
<Button className="ms-1" icon="edit" tooltip="Edit..." enabled={selectedLaunchProfile !== undefined} onClick={() => setShowEditProfile(true)} />
207+
<Button className="ms-1" icon="plus" tooltip="Add..." variant="success" onClick={() => setShowAddProfile(true)} />
208+
</SettingControlDropdown>
118209
<SettingControlWindowSize
119210
id="window_size"
120211
name="Window Size"
@@ -163,6 +254,19 @@ export default function GameSettingsTab({
163254
</Col>
164255
<Col />
165256
</Row>
257+
<EditProfileModal
258+
isAdd={true}
259+
show={showAddProfile}
260+
setShow={setShowAddProfile}
261+
saveProfile={saveProfile}
262+
/>
263+
<EditProfileModal
264+
profile={selectedLaunchProfile}
265+
isAdd={false}
266+
show={showEditProfile}
267+
setShow={setShowEditProfile}
268+
saveProfile={saveProfile}
269+
/>
166270
</Container>
167271
);
168272
}

app/settings/SettingControlDropdown.tsx

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { SettingsOption } from "@/app/types";
2-
import { Form } from "react-bootstrap";
2+
import { Form, InputGroup } from "react-bootstrap";
33
import SettingControlBase from "./SettingControlBase";
44
import { useEffect, useState } from "react";
55

@@ -11,6 +11,7 @@ export default function SettingControlDropdown({
1111
value,
1212
defaultKey,
1313
onChange,
14+
children,
1415
}: {
1516
id: string;
1617
name?: string;
@@ -19,6 +20,7 @@ export default function SettingControlDropdown({
1920
value?: any;
2021
defaultKey: string;
2122
onChange: (value: any) => void;
23+
children?: React.ReactNode;
2224
}) {
2325
const getOptionValueFromKey = (key: string) => {
2426
const option = options.find((option) => option.key === key);
@@ -49,26 +51,37 @@ export default function SettingControlDropdown({
4951
setSelected(newKey);
5052
}, [oldValue, value, options]);
5153

54+
const select = (
55+
<Form.Select
56+
className={value !== oldValue ? "border-success" : ""}
57+
value={selected}
58+
onChange={(e) => {
59+
const key = e.target.value;
60+
setSelected(key);
61+
const optionVal = getOptionValueFromKey(key);
62+
onChange(optionVal);
63+
}}
64+
>
65+
{options.map((option) => (
66+
<option key={option.key} value={option.key}>
67+
{(option.label ?? option.key) +
68+
(option.key === defaultKey ? " (default)" : "")}
69+
{option.description && <p>: {option.description}</p>}
70+
</option>
71+
))}
72+
</Form.Select>
73+
);
74+
5275
return (
5376
<SettingControlBase id={id} name={name}>
54-
<Form.Select
55-
className={value !== oldValue ? "border-success" : ""}
56-
value={selected}
57-
onChange={(e) => {
58-
const key = e.target.value;
59-
setSelected(key);
60-
const optionVal = getOptionValueFromKey(key);
61-
onChange(optionVal);
62-
}}
63-
>
64-
{options.map((option) => (
65-
<option key={option.key} value={option.key}>
66-
{option.label +
67-
(option.description ? " - " + option.description : "") +
68-
(option.key === defaultKey ? " (default)" : "")}
69-
</option>
70-
))}
71-
</Form.Select>
77+
{children ? (
78+
<InputGroup>
79+
{select}
80+
{children}
81+
</InputGroup>
82+
) : (
83+
select
84+
)}
7285
</SettingControlBase>
7386
);
7487
}

0 commit comments

Comments
 (0)