1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2025-present FERS Contributors (see AUTHORS.md).
4import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
12} from '@mui/material';
13import { open } from '@tauri-apps/plugin-dialog';
14import React from 'react';
16export type EmptyFieldBehavior = 'revert' | 'null';
18type NumberBlurResolution =
30type TextBlurResolution =
42export function formatNumberFieldValue(value: number | null): string {
43 return value === null ? '' : String(value);
46export function resolveNumberFieldBlur(
48 committedValue: number | null,
49 emptyBehavior: EmptyFieldBehavior
50): NumberBlurResolution {
51 const trimmedDraft = draft.trim();
53 if (trimmedDraft === '') {
54 if (emptyBehavior === 'null') {
64 error: 'Value required.',
65 nextDraft: formatNumberFieldValue(committedValue),
69 const parsedValue = Number(trimmedDraft);
70 if (!Number.isFinite(parsedValue)) {
73 error: 'Invalid number.',
74 nextDraft: formatNumberFieldValue(committedValue),
80 nextDraft: String(parsedValue),
85export function resolveTextFieldBlur(
87 committedValue: string,
89): TextBlurResolution {
90 if (!allowEmpty && draft.trim() === '') {
93 error: 'Value required.',
94 nextDraft: committedValue,
105export const Section = ({
110 children: React.ReactNode;
118 borderColor: 'divider',
127 expandIcon={<ExpandMoreIcon />}
129 bgcolor: 'action.hover',
132 '& .MuiAccordionSummary-content': {
135 '& .MuiAccordionSummary-expandIconWrapper': {
136 color: 'text.secondary',
140 <Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
148 flexDirection: 'column',
151 borderColor: 'divider',
159export const NumberField = ({
163 emptyBehavior = 'revert',
165 externalError = false,
168 value: number | null;
169 onChange: (val: number | null) => void;
170 emptyBehavior?: EmptyFieldBehavior;
172 externalError?: boolean;
174 const [draft, setDraft] = React.useState(() =>
175 formatNumberFieldValue(value)
177 const [isFocused, setIsFocused] = React.useState(false);
178 const [error, setError] = React.useState<string | null>(null);
180 React.useEffect(() => {
182 setDraft(formatNumberFieldValue(value));
184 }, [isFocused, value]);
186 const handleBlur = () => {
189 const resolution = resolveNumberFieldBlur(draft, value, emptyBehavior);
190 setDraft(resolution.nextDraft);
192 if (resolution.action === 'commit') {
194 if (resolution.value !== value) {
195 onChange(resolution.value);
200 setError(resolution.error);
211 error={error !== null || externalError}
212 helperText={error ?? helperText}
219 setDraft(e.target.value);
225 if (e.key === 'Enter') {
226 e.currentTarget.blur();
227 } else if (e.key === 'Escape') {
228 setDraft(formatNumberFieldValue(value));
230 e.currentTarget.blur();
234 inputMode: 'decimal',
240export const BufferedTextField = ({
247 React.ComponentProps<typeof TextField>,
248 'label' | 'value' | 'onChange'
252 onChange: (val: string) => void;
253 allowEmpty?: boolean;
255 const [draft, setDraft] = React.useState(value);
256 const [isFocused, setIsFocused] = React.useState(false);
257 const [error, setError] = React.useState<string | null>(null);
259 React.useEffect(() => {
263 }, [isFocused, value]);
265 const handleBlur = () => {
268 const resolution = resolveTextFieldBlur(draft, value, allowEmpty);
269 setDraft(resolution.nextDraft);
271 if (resolution.action === 'commit') {
273 if (resolution.value !== value) {
274 onChange(resolution.value);
279 setError(resolution.error);
287 error={error !== null || textFieldProps.error === true}
288 helperText={error ?? textFieldProps.helperText}
292 textFieldProps.onFocus?.(e);
296 textFieldProps.onBlur?.(e);
299 setDraft(e.target.value);
305 if (e.key === 'Enter') {
306 e.currentTarget.blur();
307 } else if (e.key === 'Escape') {
310 e.currentTarget.blur();
312 textFieldProps.onKeyDown?.(e);
318export const FileInput = ({
326 onChange: (val: string) => void;
327 filters: { name: string; extensions: string[] }[];
329 const handleOpenFile = async () => {
331 const selected = await open({
335 if (typeof selected === 'string') {
339 console.error('Error opening file dialog:', err);
345 <Typography variant="caption" color="text.secondary">
352 value={value?.split(/[/\\]/).pop() ?? 'No file selected.'}
353 onClick={handleOpenFile}
354 sx={{ cursor: 'pointer' }}
358 style: { cursor: 'pointer' },