Skip to content

Commit 9569e21

Browse files
Add Joystick Gremlin profile import for Advanced Mapping (#51)
Best-effort import of a Gremlin XML profile's button/axis remaps, Tempo containers, Macros, and temporary-mode-switch (Shift-style) conditions into MIM's Rules model. Anything Gremlin supports that MIM's model has no equivalent for (custom response curves, mouse output, keyboard steps in a Macro, mode cycling) is skipped and reported back to the user rather than silently dropped or guessed at, by design, not an attempt at full Gremlin parity. Co-authored-by: Constantinos-T <Tsigaridas.constantinos@gmail.com>
1 parent d4a0de9 commit 9569e21

5 files changed

Lines changed: 536 additions & 0 deletions

File tree

src/main/main.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,28 @@ ipcMain.handle('backup:export', async (event) => {
445445
// "are you sure, this replaces everything" confirmation can be a normal
446446
// in-app modal styled like the rest of MIM instead of a native OS message
447447
// box that looked out of place next to a fully custom UI.
448+
// Only reads the raw file and hands the text back, actual XML parsing happens
449+
// in the renderer via its native DOMParser, no XML library needed as a
450+
// dependency here. Joystick Gremlin profile filenames often contain
451+
// bracketed tags (e.g. "[ENH][NXT]"), the file filter is by extension only.
452+
ipcMain.handle('gremlin:pick-import-file', async (event) => {
453+
const win = BrowserWindow.fromWebContents(event.sender);
454+
const picked = await dialog.showOpenDialog(win, {
455+
title: 'Import Joystick Gremlin Profile',
456+
properties: ['openFile'],
457+
filters: [{ name: 'Joystick Gremlin Profile', extensions: ['xml'] }]
458+
});
459+
if (picked.canceled || picked.filePaths.length === 0) {
460+
return { ok: false, cancelled: true };
461+
}
462+
try {
463+
const text = fs.readFileSync(picked.filePaths[0], 'utf8');
464+
return { ok: true, text, fileName: path.basename(picked.filePaths[0]) };
465+
} catch (err) {
466+
return { ok: false, error: err.message };
467+
}
468+
});
469+
448470
ipcMain.handle('backup:pick-import-file', async (event) => {
449471
const win = BrowserWindow.fromWebContents(event.sender);
450472
const picked = await dialog.showOpenDialog(win, {

src/renderer/components/mapping/AdvancedMappingTab.jsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { useEffect, useState } from 'react';
22
import { AnimatePresence, motion } from 'framer-motion';
3+
import { Upload } from 'lucide-react';
34
import Card from '../Card';
45
import InputPicker from './InputPicker';
56
import RuleCard from './RuleCard';
7+
import GremlinImportDialog from './GremlinImportDialog';
68
import { getRuleFor, setBaseAction, clearBaseAction, setConditionAction, clearCondition } from '../../lib/mappingEngine';
79

810
const mim = typeof window !== 'undefined' ? window.mim : undefined;
@@ -16,6 +18,7 @@ const mim = typeof window !== 'undefined' ? window.mim : undefined;
1618
export default function AdvancedMappingTab({ profile, devices, activeOutputs, onSaved }) {
1719
const [setup, setSetup] = useState(profile);
1820
const [selectedInput, setSelectedInput] = useState(null);
21+
const [showGremlinImport, setShowGremlinImport] = useState(false);
1922

2023
useEffect(() => {
2124
setSetup(profile);
@@ -53,6 +56,16 @@ export default function AdvancedMappingTab({ profile, devices, activeOutputs, on
5356

5457
return (
5558
<div className="flex flex-col gap-4">
59+
<div className="flex justify-end">
60+
<button
61+
onClick={() => setShowGremlinImport(true)}
62+
className="glass-surface flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium text-mim-muted transition-colors hover:text-white"
63+
>
64+
<Upload size={12} />
65+
Import from Joystick Gremlin...
66+
</button>
67+
</div>
68+
5669
<Card hover={false} className="p-4">
5770
<InputPicker
5871
devices={devices}
@@ -93,6 +106,14 @@ export default function AdvancedMappingTab({ profile, devices, activeOutputs, on
93106
</motion.div>
94107
)}
95108
</AnimatePresence>
109+
110+
<GremlinImportDialog
111+
open={showGremlinImport}
112+
devices={devices}
113+
setup={setup}
114+
onImport={(nextSetup) => persist(nextSetup)}
115+
onClose={() => setShowGremlinImport(false)}
116+
/>
96117
</div>
97118
);
98119
}
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import { useState } from 'react';
2+
import { AnimatePresence, motion } from 'framer-motion';
3+
import { Upload, TriangleAlert, CircleCheck } from 'lucide-react';
4+
import Select from '../Select';
5+
import { parseGremlinXml, importGremlinDevice } from '../../lib/gremlinImport';
6+
7+
const mim = typeof window !== 'undefined' ? window.mim : undefined;
8+
9+
// Rough first guess at which connected physical device a Gremlin profile
10+
// entry refers to, by substring match on name. Always left for the user to
11+
// confirm/correct in the UI, this is only a convenience default.
12+
function guessMatch(gremlinName, devices) {
13+
const normalized = gremlinName.trim().toLowerCase();
14+
return devices.find((d) => d.id.toLowerCase().includes(normalized))?.id ?? '';
15+
}
16+
17+
// Best-effort Joystick Gremlin profile import, closes only via its own
18+
// explicit buttons (no backdrop-click dismissal), matching ConfirmDialog's
19+
// established pattern in this app.
20+
export default function GremlinImportDialog({ open, devices, setup, onImport, onClose }) {
21+
const [step, setStep] = useState('pick');
22+
const [error, setError] = useState(null);
23+
const [parsed, setParsed] = useState(null);
24+
const [deviceMatches, setDeviceMatches] = useState({});
25+
const [modeChoices, setModeChoices] = useState({});
26+
const [warnings, setWarnings] = useState([]);
27+
28+
function reset() {
29+
setStep('pick');
30+
setError(null);
31+
setParsed(null);
32+
setDeviceMatches({});
33+
setModeChoices({});
34+
setWarnings([]);
35+
}
36+
37+
function handleClose() {
38+
reset();
39+
onClose();
40+
}
41+
42+
async function pickFile() {
43+
setError(null);
44+
const result = await mim?.gremlin?.pickImportFile();
45+
if (!result?.ok) {
46+
if (!result?.cancelled) setError(result?.error ?? 'Could not read that file.');
47+
return;
48+
}
49+
try {
50+
const data = parseGremlinXml(result.text);
51+
if (data.devices.length === 0) {
52+
setError('No joystick devices found in this profile (only keyboard/mouse entries, which MIM cannot use as trigger inputs).');
53+
return;
54+
}
55+
const matches = {};
56+
const modes = {};
57+
for (const d of data.devices) {
58+
matches[d.guid] = guessMatch(d.name, devices);
59+
modes[d.guid] = d.modes[0]?.name ?? '';
60+
}
61+
setParsed(data);
62+
setDeviceMatches(matches);
63+
setModeChoices(modes);
64+
setStep('configure');
65+
} catch (err) {
66+
setError(err.message);
67+
}
68+
}
69+
70+
function runImport() {
71+
let nextSetup = setup;
72+
const allWarnings = [];
73+
for (const d of parsed.devices) {
74+
const physicalId = deviceMatches[d.guid];
75+
const modeName = modeChoices[d.guid];
76+
if (!physicalId || !modeName) continue;
77+
const result = importGremlinDevice(nextSetup, physicalId, d, modeName);
78+
nextSetup = result.setup;
79+
allWarnings.push(...result.warnings.map((w) => `${d.name.trim()}: ${w}`));
80+
}
81+
onImport(nextSetup);
82+
setWarnings(allWarnings);
83+
setStep('done');
84+
}
85+
86+
return (
87+
<AnimatePresence>
88+
{open && (
89+
<motion.div
90+
initial={{ opacity: 0 }}
91+
animate={{ opacity: 1 }}
92+
exit={{ opacity: 0 }}
93+
transition={{ duration: 0.15 }}
94+
className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
95+
>
96+
<motion.div
97+
initial={{ opacity: 0, y: 12, scale: 0.97 }}
98+
animate={{ opacity: 1, y: 0, scale: 1 }}
99+
exit={{ opacity: 0, y: 12, scale: 0.97 }}
100+
transition={{ type: 'spring', stiffness: 420, damping: 32 }}
101+
className="glass-panel w-full max-w-lg rounded-2xl p-5"
102+
>
103+
<h3 className="mb-4 text-sm font-semibold text-white">Import Joystick Gremlin Profile</h3>
104+
105+
{step === 'pick' && (
106+
<div className="flex flex-col items-center gap-4 py-4 text-center">
107+
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-mim-accent/15 text-mim-accent">
108+
<Upload size={20} />
109+
</span>
110+
<p className="text-sm text-mim-muted">
111+
MIM imports what it has a real equivalent for (button presses, Tempo, Macro, and Shift-style "while holding" conditions), and shows you
112+
a list of anything it had to skip, like custom response curves, mouse output, or mode cycling.
113+
</p>
114+
{error && <p className="text-xs text-red-400">{error}</p>}
115+
<div className="mt-1 flex w-full justify-end gap-2">
116+
<button onClick={handleClose} className="glass-surface rounded-full px-4 py-2 text-xs font-semibold text-white transition-colors hover:bg-white/10">
117+
Cancel
118+
</button>
119+
<motion.button
120+
whileHover={{ scale: 1.03 }}
121+
whileTap={{ scale: 0.97 }}
122+
onClick={pickFile}
123+
className="rounded-full bg-mim-accent/15 px-4 py-2 text-xs font-semibold text-mim-accent"
124+
>
125+
Choose .xml file...
126+
</motion.button>
127+
</div>
128+
</div>
129+
)}
130+
131+
{step === 'configure' && parsed && (
132+
<div className="flex flex-col gap-3">
133+
<p className="text-xs text-mim-muted">
134+
Match each Gremlin device to a connected controller, and pick which mode to import as this profile's normal behavior. Leave a device
135+
unmatched to skip it.
136+
</p>
137+
<div className="flex max-h-80 flex-col gap-3 overflow-y-auto pr-1">
138+
{parsed.devices.map((d) => (
139+
<div key={d.guid} className="flex flex-col gap-2 rounded-xl border border-mim-border p-3">
140+
<span className="text-sm font-medium text-white">{d.name.trim()}</span>
141+
<div className="flex items-center gap-2">
142+
<span className="w-14 shrink-0 text-xs text-mim-muted">Device</span>
143+
<Select
144+
value={deviceMatches[d.guid] ?? ''}
145+
onChange={(v) => setDeviceMatches((prev) => ({ ...prev, [d.guid]: v }))}
146+
options={devices.map((dev) => ({ value: dev.id, label: dev.id }))}
147+
placeholder="Skip this device..."
148+
/>
149+
</div>
150+
<div className="flex items-center gap-2">
151+
<span className="w-14 shrink-0 text-xs text-mim-muted">Mode</span>
152+
<Select
153+
value={modeChoices[d.guid] ?? ''}
154+
onChange={(v) => setModeChoices((prev) => ({ ...prev, [d.guid]: v }))}
155+
options={d.modes.map((m) => ({ value: m.name, label: m.name }))}
156+
/>
157+
</div>
158+
</div>
159+
))}
160+
</div>
161+
<div className="mt-1 flex justify-end gap-2">
162+
<button onClick={handleClose} className="glass-surface rounded-full px-4 py-2 text-xs font-semibold text-white transition-colors hover:bg-white/10">
163+
Cancel
164+
</button>
165+
<motion.button
166+
whileHover={{ scale: 1.03 }}
167+
whileTap={{ scale: 0.97 }}
168+
onClick={runImport}
169+
className="rounded-full bg-mim-accent/15 px-4 py-2 text-xs font-semibold text-mim-accent"
170+
>
171+
Import
172+
</motion.button>
173+
</div>
174+
</div>
175+
)}
176+
177+
{step === 'done' && (
178+
<div className="flex flex-col gap-3">
179+
<div className="flex items-center gap-2 text-mim-accent">
180+
<CircleCheck size={18} />
181+
<span className="text-sm font-medium">Import complete.</span>
182+
</div>
183+
{warnings.length > 0 ? (
184+
<>
185+
<div className="flex items-center gap-2 text-amber-300">
186+
<TriangleAlert size={16} />
187+
<span className="text-xs font-semibold">{warnings.length} item(s) skipped:</span>
188+
</div>
189+
<div className="max-h-64 overflow-y-auto rounded-lg bg-black/20 p-2 text-xs text-mim-muted">
190+
{warnings.map((w, i) => (
191+
<p key={i} className="border-b border-white/5 py-1 last:border-0">
192+
{w}
193+
</p>
194+
))}
195+
</div>
196+
</>
197+
) : (
198+
<p className="text-xs text-mim-muted">Everything in the selected mode(s) was imported cleanly.</p>
199+
)}
200+
<div className="mt-1 flex justify-end">
201+
<motion.button
202+
whileHover={{ scale: 1.03 }}
203+
whileTap={{ scale: 0.97 }}
204+
onClick={handleClose}
205+
className="rounded-full bg-mim-accent/15 px-4 py-2 text-xs font-semibold text-mim-accent"
206+
>
207+
Done
208+
</motion.button>
209+
</div>
210+
</div>
211+
)}
212+
</motion.div>
213+
</motion.div>
214+
)}
215+
</AnimatePresence>
216+
);
217+
}

0 commit comments

Comments
 (0)