FERS 0.1.0
The Flexible Extensible Radar Simulator
Loading...
Searching...
No Matches
InspectorControls.tsx
Go to the documentation of this file.
1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2025-present FERS Contributors (see AUTHORS.md).
3
4import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
5import {
6 Accordion,
7 AccordionDetails,
8 AccordionSummary,
9 Box,
10 TextField,
11 Typography,
12} from '@mui/material';
13import { open } from '@tauri-apps/plugin-dialog';
14import React from 'react';
15
16export type EmptyFieldBehavior = 'revert' | 'null';
17
18type NumberBlurResolution =
19 | {
20 action: 'commit';
21 nextDraft: string;
22 value: number | null;
23 }
24 | {
25 action: 'revert';
26 error: string;
27 nextDraft: string;
28 };
29
30type TextBlurResolution =
31 | {
32 action: 'commit';
33 nextDraft: string;
34 value: string;
35 }
36 | {
37 action: 'revert';
38 error: string;
39 nextDraft: string;
40 };
41
42export function formatNumberFieldValue(value: number | null): string {
43 return value === null ? '' : String(value);
44}
45
46export function resolveNumberFieldBlur(
47 draft: string,
48 committedValue: number | null,
49 emptyBehavior: EmptyFieldBehavior
50): NumberBlurResolution {
51 const trimmedDraft = draft.trim();
52
53 if (trimmedDraft === '') {
54 if (emptyBehavior === 'null') {
55 return {
56 action: 'commit',
57 nextDraft: '',
58 value: null,
59 };
60 }
61
62 return {
63 action: 'revert',
64 error: 'Value required.',
65 nextDraft: formatNumberFieldValue(committedValue),
66 };
67 }
68
69 const parsedValue = Number(trimmedDraft);
70 if (!Number.isFinite(parsedValue)) {
71 return {
72 action: 'revert',
73 error: 'Invalid number.',
74 nextDraft: formatNumberFieldValue(committedValue),
75 };
76 }
77
78 return {
79 action: 'commit',
80 nextDraft: String(parsedValue),
81 value: parsedValue,
82 };
83}
84
85export function resolveTextFieldBlur(
86 draft: string,
87 committedValue: string,
88 allowEmpty: boolean
89): TextBlurResolution {
90 if (!allowEmpty && draft.trim() === '') {
91 return {
92 action: 'revert',
93 error: 'Value required.',
94 nextDraft: committedValue,
95 };
96 }
97
98 return {
99 action: 'commit',
100 nextDraft: draft,
101 value: draft,
102 };
103}
104
105export const Section = ({
106 title,
107 children,
108}: {
109 title: string;
110 children: React.ReactNode;
111}) => (
112 <Accordion
113 defaultExpanded
114 disableGutters
115 elevation={0}
116 sx={{
117 border: 1,
118 borderColor: 'divider',
119 borderRadius: 1,
120 '&:before': {
121 display: 'none',
122 },
123 overflow: 'hidden',
124 }}
125 >
126 <AccordionSummary
127 expandIcon={<ExpandMoreIcon />}
128 sx={{
129 bgcolor: 'action.hover',
130 px: 2,
131 minHeight: 48,
132 '& .MuiAccordionSummary-content': {
133 my: 0,
134 },
135 '& .MuiAccordionSummary-expandIconWrapper': {
136 color: 'text.secondary',
137 },
138 }}
139 >
140 <Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
141 {title}
142 </Typography>
143 </AccordionSummary>
144 <AccordionDetails
145 sx={{
146 p: 2,
147 display: 'flex',
148 flexDirection: 'column',
149 gap: 2,
150 borderTop: 1,
151 borderColor: 'divider',
152 }}
153 >
154 {children}
155 </AccordionDetails>
156 </Accordion>
157);
158
159export const NumberField = ({
160 label,
161 value,
162 onChange,
163 emptyBehavior = 'revert',
164 helperText,
165 externalError = false,
166}: {
167 label: string;
168 value: number | null;
169 onChange: (val: number | null) => void;
170 emptyBehavior?: EmptyFieldBehavior;
171 helperText?: string;
172 externalError?: boolean;
173}) => {
174 const [draft, setDraft] = React.useState(() =>
175 formatNumberFieldValue(value)
176 );
177 const [isFocused, setIsFocused] = React.useState(false);
178 const [error, setError] = React.useState<string | null>(null);
179
180 React.useEffect(() => {
181 if (!isFocused) {
182 setDraft(formatNumberFieldValue(value));
183 }
184 }, [isFocused, value]);
185
186 const handleBlur = () => {
187 setIsFocused(false);
188
189 const resolution = resolveNumberFieldBlur(draft, value, emptyBehavior);
190 setDraft(resolution.nextDraft);
191
192 if (resolution.action === 'commit') {
193 setError(null);
194 if (resolution.value !== value) {
195 onChange(resolution.value);
196 }
197 return;
198 }
199
200 setError(resolution.error);
201 };
202
203 return (
204 <TextField
205 label={label}
206 type="text"
207 variant="outlined"
208 size="small"
209 fullWidth
210 value={draft}
211 error={error !== null || externalError}
212 helperText={error ?? helperText}
213 onFocus={() => {
214 setIsFocused(true);
215 setError(null);
216 }}
217 onBlur={handleBlur}
218 onChange={(e) => {
219 setDraft(e.target.value);
220 if (error) {
221 setError(null);
222 }
223 }}
224 onKeyDown={(e) => {
225 if (e.key === 'Enter') {
226 e.currentTarget.blur();
227 } else if (e.key === 'Escape') {
228 setDraft(formatNumberFieldValue(value));
229 setError(null);
230 e.currentTarget.blur();
231 }
232 }}
233 inputProps={{
234 inputMode: 'decimal',
235 }}
236 />
237 );
238};
239
240export const BufferedTextField = ({
241 label,
242 value,
243 onChange,
244 allowEmpty = true,
245 ...textFieldProps
246}: Omit<
247 React.ComponentProps<typeof TextField>,
248 'label' | 'value' | 'onChange'
249> & {
250 label: string;
251 value: string;
252 onChange: (val: string) => void;
253 allowEmpty?: boolean;
254}) => {
255 const [draft, setDraft] = React.useState(value);
256 const [isFocused, setIsFocused] = React.useState(false);
257 const [error, setError] = React.useState<string | null>(null);
258
259 React.useEffect(() => {
260 if (!isFocused) {
261 setDraft(value);
262 }
263 }, [isFocused, value]);
264
265 const handleBlur = () => {
266 setIsFocused(false);
267
268 const resolution = resolveTextFieldBlur(draft, value, allowEmpty);
269 setDraft(resolution.nextDraft);
270
271 if (resolution.action === 'commit') {
272 setError(null);
273 if (resolution.value !== value) {
274 onChange(resolution.value);
275 }
276 return;
277 }
278
279 setError(resolution.error);
280 };
281
282 return (
283 <TextField
284 {...textFieldProps}
285 label={label}
286 value={draft}
287 error={error !== null || textFieldProps.error === true}
288 helperText={error ?? textFieldProps.helperText}
289 onFocus={(e) => {
290 setIsFocused(true);
291 setError(null);
292 textFieldProps.onFocus?.(e);
293 }}
294 onBlur={(e) => {
295 handleBlur();
296 textFieldProps.onBlur?.(e);
297 }}
298 onChange={(e) => {
299 setDraft(e.target.value);
300 if (error) {
301 setError(null);
302 }
303 }}
304 onKeyDown={(e) => {
305 if (e.key === 'Enter') {
306 e.currentTarget.blur();
307 } else if (e.key === 'Escape') {
308 setDraft(value);
309 setError(null);
310 e.currentTarget.blur();
311 }
312 textFieldProps.onKeyDown?.(e);
313 }}
314 />
315 );
316};
317
318export const FileInput = ({
319 label,
320 value,
321 onChange,
322 filters,
323}: {
324 label: string;
325 value?: string;
326 onChange: (val: string) => void;
327 filters: { name: string; extensions: string[] }[];
328}) => {
329 const handleOpenFile = async () => {
330 try {
331 const selected = await open({
332 multiple: false,
333 filters,
334 });
335 if (typeof selected === 'string') {
336 onChange(selected);
337 }
338 } catch (err) {
339 console.error('Error opening file dialog:', err);
340 }
341 };
342
343 return (
344 <Box>
345 <Typography variant="caption" color="text.secondary">
346 {label}
347 </Typography>
348 <TextField
349 variant="outlined"
350 size="small"
351 fullWidth
352 value={value?.split(/[/\\]/).pop() ?? 'No file selected.'}
353 onClick={handleOpenFile}
354 sx={{ cursor: 'pointer' }}
355 slotProps={{
356 input: {
357 readOnly: true,
358 style: { cursor: 'pointer' },
359 },
360 }}
361 />
362 </Box>
363 );
364};