/**
 * Process-settings editor mirroring OrcaSlicer's own Print Settings tabs.
 *
 * Structure, labels, tooltips, bounds, defaults and enable/disable rules all
 * come from metadata extracted from OrcaSlicer's C++ sources (see
 * `src/data/slicer/`), so the pages, groups and ordering match what users see
 * in the desktop slicer rather than a hand-picked subset.
 *
 * Option labels and tooltips are deliberately English-only for now: they are
 * 348 strings lifted verbatim from `PrintConfig.cpp`, and hand-translating them
 * into all 13 locales is not viable. The panel's own chrome — mode switch,
 * search, buttons, empty states — goes through i18n as usual. OrcaSlicer ships
 * its own translation catalogs for these strings, which is the obvious source
 * if they are ever picked up.
 *
 * Values are held sparsely: only options the user actually changed are tracked
 * and sent, so a slice with an untouched panel is byte-identical to one from
 * before this panel existed.
 */

import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Search, RotateCcw, Loader2, ChevronDown } from 'lucide-react';

import { disabledKeys, type ToggleRules } from '../lib/slicerToggle';
import { baselineForDisplay, displaySidetext, isModified, numericBound, serializeOverrides } from '../lib/slicerSettings';
import type { OptionMode, ProcessOption, ProcessSchema, ProcessUiTree, SettingValue } from '../types/slicerSettings';
import type { DesignOverride } from '../types/plates';
import type { SlicerPresetValuesReason } from '../api/client';

interface SlicerData {
  schema: ProcessSchema;
  tree: ProcessUiTree;
  toggles: ToggleRules;
}

interface Props {
  values: Record<string, SettingValue>;
  /**
   * Reports both the panel's editing state and the same values serialised for
   * the slice request. Serialising here rather than in the caller keeps the
   * option schema — the only thing that knows a percent needs its `%` back —
   * in the one component that has already loaded it.
   *
   * `serialized` carries only options that actually differ from their default,
   * so an untouched panel sends nothing at all.
   */
  onChange: (values: Record<string, SettingValue>, serialized: Record<string, string | string[]>) => void;
  disabled?: boolean;
  /**
   * Process settings the source 3MF's designer moved off the stock preset
   * (#2622), as recorded by BambuStudio in `different_settings_to_system`.
   *
   * These are shown inline against the options they belong to rather than in a
   * list of their own, so there is one place to see what this slice will use.
   * Their *values* are not routed through this component: the backend reads
   * them straight out of the file, which keeps settings faithful even for keys
   * outside the option schema we vendor. All this panel decides is which of
   * them are switched on.
   */
  sourceOverrides?: DesignOverride[];
  /** Which source-override keys are currently switched on. */
  sourceSelected?: Set<string>;
  onToggleSource?: (key: string, on: boolean) => void;
  /**
   * The filaments picked on the slice dialog's left-hand side, in slot order.
   *
   * A handful of options select *which filament* prints a given feature —
   * supports, outer walls, infill. The slicer stores those as a plain integer
   * where 0 means "whatever filament the region already uses" and 1..N is a
   * slot. A bare number field makes the user count their own AMS slots, so
   * when this is supplied those options become a dropdown of the actual
   * picks instead.
   */
  filamentChoices?: FilamentChoice[];
  /**
   * The picked process preset's effective values, flattened by the sidecar.
   * Used as the baseline an untouched field shows and a revert returns to.
   * Empty when unavailable, in which case the panel falls back to the option
   * schema's compiled-in defaults and says the values are indicative.
   */
  presetValues?: Record<string, SettingValue>;
  /** False when the preset's values could not be fetched. */
  presetValuesResolved?: boolean;
  /**
   * Why they could not be fetched, so the notice can name a fix. Left
   * unset while the fetch is still in flight.
   */
  presetValuesReason?: SlicerPresetValuesReason;
}

export interface FilamentChoice {
  /** 1-based slot index, matching the integer the slicer stores. */
  index: number;
  /** Preset name, or a fallback when the slot has no pick yet. */
  label: string;
  /** Slot colour from the source plate, for the swatch. */
  color?: string;
}

/**
 * Options whose integer value names a filament slot rather than a quantity.
 * All use the same encoding: 0 = "default / current filament", 1..N = slot.
 * Support base and interface are the pair on the Support page; the rest are
 * the Multimaterial page's per-region pickers, which have the same wart.
 */
const FILAMENT_SLOT_OPTIONS = new Set([
  'support_filament',
  'support_interface_filament',
  'outer_wall_filament_id',
  'inner_wall_filament_id',
  'top_surface_filament_id',
  'bottom_surface_filament_id',
  'internal_solid_filament_id',
  'sparse_infill_filament_id',
]);

/** Visibility tiers, in increasing order of how much they reveal. */
const MODES: OptionMode[] = ['simple', 'advanced', 'expert'];
const MODE_RANK: Record<string, number> = { simple: 0, advanced: 1, expert: 2, develop: 3 };

export default function SlicerSettingsPanel({
  values,
  onChange,
  disabled = false,
  sourceOverrides = [],
  sourceSelected,
  onToggleSource,
  filamentChoices,
  presetValues,
  presetValuesResolved = true,
  presetValuesReason,
}: Props) {
  const { t } = useTranslation();
  const [data, setData] = useState<SlicerData | null>(null);
  const [mode, setMode] = useState<OptionMode>('simple');
  const [page, setPage] = useState<string | null>(null);
  const [query, setQuery] = useState('');

  // 150 KB of extracted metadata has no business in the main bundle — it is
  // only needed once someone opens this panel.
  useEffect(() => {
    let cancelled = false;
    Promise.all([
      import('../data/slicer/process-schema.json'),
      import('../data/slicer/process-ui-tree.json'),
      import('../data/slicer/process-toggle-rules.json'),
    ]).then(([schema, tree, toggles]) => {
      if (cancelled) return;
      setData({
        schema: (schema.default ?? schema) as unknown as ProcessSchema,
        tree: (tree.default ?? tree) as unknown as ProcessUiTree,
        toggles: (toggles.default ?? toggles) as unknown as ToggleRules,
      });
    });
    return () => {
      cancelled = true;
    };
  }, []);

  const off = useMemo(
    () => (data ? disabledKeys(values, data.schema, data.toggles) : new Set<string>()),
    [data, values],
  );

  const sourceByKey = useMemo(
    () => new Map(sourceOverrides.map((o) => [o.key, o])),
    [sourceOverrides],
  );

  // Source overrides for keys the vendored schema doesn't cover. They still
  // apply — the backend reads their values from the file — so they get a group
  // of their own rather than being dropped from view.
  const unlistedSource = useMemo(() => {
    if (!data) return [];
    return sourceOverrides.filter((o) => !data.schema[o.key]);
  }, [data, sourceOverrides]);

  const emit = (next: Record<string, SettingValue>) => {
    if (!data) return;
    // Only genuine deviations are worth sending: an override that equals the
    // preset's own value is noise in the process JSON and makes the slice
    // request harder to read when something goes wrong.
    const changed: Record<string, SettingValue> = {};
    for (const [k, v] of Object.entries(next)) {
      if (data.schema[k] && isModified(data.schema[k], v, presetValues?.[k])) changed[k] = v;
    }
    onChange(next, serializeOverrides(changed, data.schema));
  };

  const setValue = (key: string, value: SettingValue | undefined) => {
    const next = { ...values };
    if (value === undefined) delete next[key];
    else next[key] = value;
    emit(next);
  };

  // Search cuts across every page; without a query we show the selected page.
  const visiblePages = useMemo(() => {
    if (!data) return [];
    // Underscores and spaces are interchangeable so "outer wall speed" finds
    // `outer_wall_speed`. That matters more than it looks: several labels are
    // only meaningful with their group ("Outer wall" under Speed), so the key
    // is often the only place the full phrase appears.
    const flatten = (s: string) => s.toLowerCase().replace(/[_\s]+/g, ' ').trim();
    const needle = flatten(query);
    const withinMode = (key: string) => MODE_RANK[data.schema[key]?.mode ?? 'expert'] <= MODE_RANK[mode];
    const matches = (key: string, group: string, page: string) => {
      if (!needle) return true;
      const opt = data.schema[key];
      // Group and page are matched too, so "speed" lists the Speed page's
      // options rather than only the handful with "speed" in their label.
      const haystack = [key, opt?.label ?? '', opt?.tooltip ?? '', group, page];
      return haystack.some((h) => flatten(h).includes(needle));
    };

    return data.tree
      .map((p) => ({
        ...p,
        groups: p.groups
          .map((g) => ({
            ...g,
            options: g.options.filter((k) => withinMode(k) && matches(k, g.group, p.page)),
          }))
          .filter((g) => g.options.length > 0),
      }))
      .filter((p) => p.groups.length > 0);
  }, [data, mode, query]);

  const activePage = useMemo(() => {
    if (visiblePages.length === 0) return null;
    if (query.trim()) return null; // Searching shows every match, not one page.
    return visiblePages.find((p) => p.page === page) ?? visiblePages[0];
  }, [visiblePages, page, query]);

  const modifiedCount = useMemo(() => {
    if (!data) return 0;
    return Object.keys(values).filter((k) => data.schema[k] && isModified(data.schema[k], values[k], presetValues?.[k])).length;
  }, [data, values, presetValues]);

  if (!data) {
    return (
      <div className="flex items-center justify-center gap-2 py-8 text-sm text-bambu-gray">
        <Loader2 className="w-4 h-4 animate-spin" />
        {t('slicerSettings.loading', 'Loading slicer settings...')}
      </div>
    );
  }

  const shownPages = activePage ? [activePage] : visiblePages;

  return (
    <div className="flex flex-col gap-3">
      <div className="flex flex-wrap items-center gap-2">
        <div className="flex overflow-hidden rounded border border-bambu-dark-tertiary">
          {MODES.map((m) => (
            <button
              key={m}
              type="button"
              onClick={() => setMode(m)}
              disabled={disabled}
              className={`px-2.5 py-1 text-xs capitalize transition-colors ${
                mode === m ? 'bg-bambu-green text-white' : 'text-bambu-gray hover:text-white'
              }`}
            >
              {t(`slicerSettings.mode.${m}`, m)}
            </button>
          ))}
        </div>

        <div className="relative flex-1 min-w-[10rem]">
          <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-bambu-gray" />
          <input
            type="search"
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            disabled={disabled}
            placeholder={t('slicerSettings.searchPlaceholder', 'Search settings')}
            className="w-full rounded border border-bambu-dark-tertiary bg-bambu-dark pl-7 pr-2 py-1 text-xs text-white placeholder:text-bambu-gray/60 focus:border-bambu-green focus:outline-none disabled:opacity-40"
          />
        </div>

        {modifiedCount > 0 && (
          <button
            type="button"
            onClick={() => emit({})}
            disabled={disabled}
            className="flex items-center gap-1 text-xs text-bambu-gray hover:text-white"
          >
            <RotateCcw className="w-3 h-3" />
            {t('slicerSettings.resetAll', 'Reset {{count}}', { count: modifiedCount })}
          </button>
        )}
      </div>

      {!presetValuesResolved && (
        <p className="rounded border border-amber-300 bg-amber-50 px-2 py-1 text-[0.7rem] text-amber-800 dark:border-amber-700/40 dark:bg-amber-900/20 dark:text-amber-200">
          {presetValuesReason === 'sidecar_outdated'
            ? t(
                'slicerSettings.presetValuesOutdatedSidecar',
                "Showing slicer defaults: your slicer sidecar is older than this feature and can't report a preset's values. Update the sidecar image to see them. Anything you don't change still uses the preset.",
              )
            : presetValuesReason === 'not_configured'
              ? t(
                  'slicerSettings.presetValuesNotConfigured',
                  "Showing slicer defaults: no slicer sidecar is configured, so a preset's values can't be read. Anything you don't change still uses the preset.",
                )
              : presetValuesReason === 'sidecar_unavailable'
                ? t(
                    'slicerSettings.presetValuesSidecarUnavailable',
                    "Showing slicer defaults: the slicer sidecar did not answer, so a preset's values can't be read. Anything you don't change still uses the preset.",
                  )
                : t(
                    'slicerSettings.presetValuesUnavailable',
                    "Showing slicer defaults: the picked preset's own values could not be read. Anything you don't change still uses the preset.",
                  )}
        </p>
      )}

      {!query.trim() && (
        <div className="flex flex-wrap gap-1">
          {visiblePages.map((p) => (
            <button
              key={p.page}
              type="button"
              onClick={() => setPage(p.page)}
              disabled={disabled}
              className={`px-2 py-1 text-xs rounded transition-colors ${
                activePage?.page === p.page ? 'bg-bambu-dark-tertiary text-white' : 'text-bambu-gray hover:text-white'
              }`}
            >
              {p.page}
            </button>
          ))}
        </div>
      )}

      {shownPages.length === 0 ? (
        <p className="py-6 text-center text-xs text-bambu-gray">
          {t('slicerSettings.noMatches', 'No settings match this search.')}
        </p>
      ) : (
        // Taller once the panel has a column of its own; the narrow cap keeps
        // it from swallowing the single-column stack on small screens.
        <div className="flex flex-col gap-4 max-h-[22rem] lg:max-h-[58vh] overflow-y-auto pr-1">
          {shownPages.map((p) => (
            <div key={p.page} className="flex flex-col gap-3">
              {query.trim() && <p className="text-[0.7rem] uppercase tracking-wide text-bambu-gray/70">{p.page}</p>}
              {p.groups.map((g) => (
                <fieldset key={`${p.page}:${g.group}`} className="flex flex-col gap-1.5">
                  <legend className="mb-1 text-xs font-medium text-white">{g.group}</legend>
                  {g.options.map((key) => (
                    <OptionRow
                      key={key}
                      optionKey={key}
                      option={data.schema[key]}
                      value={values[key]}
                      onChange={(v) => setValue(key, v)}
                      disabled={disabled || off.has(key)}
                      disabledBySlicer={off.has(key)}
                      source={sourceByKey.get(key)}
                      sourceOn={sourceSelected?.has(key) ?? false}
                      onToggleSource={onToggleSource}
                      filamentChoices={FILAMENT_SLOT_OPTIONS.has(key) ? filamentChoices : undefined}
                      presetValue={presetValues?.[key]}
                    />
                  ))}
                </fieldset>
              ))}
            </div>
          ))}

          {/* Source-file settings the vendored schema has no entry for: they
              still apply (the backend reads their values from the file), so
              they get a plain key/value group rather than disappearing from a
              panel that claims to show what this slice will use. */}
          {unlistedSource.length > 0 && !query.trim() && (
            <fieldset className="flex flex-col gap-1.5">
              <legend className="mb-1 text-xs font-medium text-white">
                {t('slicerSettings.otherFromFile', 'Other settings from this file')}
              </legend>
              {unlistedSource.map((o) => (
                <label key={o.key} className="flex items-center gap-2 text-xs cursor-pointer">
                  <input
                    type="checkbox"
                    checked={sourceSelected?.has(o.key) ?? false}
                    disabled={disabled || !onToggleSource}
                    onChange={(e) => onToggleSource?.(o.key, e.target.checked)}
                    className="shrink-0 cursor-pointer disabled:opacity-40"
                  />
                  <span className="min-w-0 flex-1 truncate">
                    <span className="font-mono text-bambu-gray">{o.key}</span>
                    <span className="ml-1.5 text-white">{formatSourceValue(o.value)}</span>
                  </span>
                  {o.printer_coupled && (
                    <span className="shrink-0 rounded bg-amber-100 px-1 py-0.5 text-[10px] text-amber-700 dark:bg-amber-500/20 dark:text-amber-400">
                      {t('slicerSettings.fromFilePrinterCoupled', "designer's printer")}
                    </span>
                  )}
                </label>
              ))}
            </fieldset>
          )}
        </div>
      )}
    </div>
  );
}

interface RowProps {
  optionKey: string;
  option: ProcessOption;
  value: SettingValue | undefined;
  onChange: (value: SettingValue | undefined) => void;
  disabled: boolean;
  /** Greyed because the slicer's own rules turn it off, not because the form is busy. */
  disabledBySlicer: boolean;
  /** Set when the source file's designer moved this option off the stock preset. */
  source?: DesignOverride;
  sourceOn?: boolean;
  onToggleSource?: (key: string, on: boolean) => void;
  /** Set only for options whose integer value names a filament slot. */
  filamentChoices?: FilamentChoice[];
  /** The picked preset's value for this option, when known. */
  presetValue?: SettingValue;
}

function OptionRow({
  optionKey,
  option,
  value,
  onChange,
  disabled,
  disabledBySlicer,
  source,
  sourceOn = false,
  onToggleSource,
  filamentChoices,
  presetValue,
}: RowProps) {
  const { t } = useTranslation();
  const modified = isModified(option, value, presetValue);
  const unit = displaySidetext(option);
  // What this slice will actually use, in precedence order: a value typed here
  // wins, then the designer's value if it is switched on, then the preset's own
  // (or the schema default when the preset's values are unavailable).
  const current =
    value !== undefined
      ? String(value)
      : sourceOn && source
        ? formatSourceValue(source.value)
        : baselineForDisplay(option, presetValue);

  return (
    <div className="flex items-center gap-2 group" title={option.tooltip}>
      {/* Label takes the slack; the control group is a fixed width anchored to
          the right edge. Fixed widths on the control *and* the unit are what
          keep that column straight — sizing either to content makes each row's
          input land at a different x. */}
      <label
        htmlFor={`slicer-opt-${optionKey}`}
        className={`flex min-w-0 flex-1 items-center gap-1 text-xs ${disabledBySlicer ? 'text-bambu-gray/40' : 'text-bambu-gray'}`}
      >
        {/* Own title: a fixed column truncates more than the old flex-1 label
            did, and the row's title carries the tooltip, not the name. */}
        <span className="truncate" title={option.label || optionKey}>
          {option.label || optionKey}
        </span>
        {modified && <span className="shrink-0 text-bambu-green" aria-hidden="true">•</span>}
        {source && (
          <span
            className={`shrink-0 rounded px-1 py-0.5 text-[10px] ${
              source.printer_coupled
                ? 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400'
                : 'bg-bambu-green/15 text-bambu-green'
            }`}
            title={
              source.printer_coupled
                ? t('slicerSettings.fromFilePrinterCoupledHint', "Tuned for the printer this file was designed for -- may be wrong or out of range on yours.")
                : t('slicerSettings.fromFileHint', "The designer changed this in the source file. Its value is {{value}}.", { value: formatSourceValue(source.value) })
            }
          >
            {source.printer_coupled
              ? t('slicerSettings.fromFilePrinterCoupled', "designer's printer")
              : t('slicerSettings.fromFile', 'from file')}
          </span>
        )}
      </label>

      <div className="flex shrink-0 items-center gap-1.5">
        {/* The "use the file's value" tick comes *before* the control it
            qualifies, as a checkbox that gates a field conventionally does —
            it used to sit past the unit, out at the right edge, reading as
            unrelated to the field. The slot is reserved on every row so rows
            with and without a source override keep the control column
            straight. */}
        <span className="flex w-3 shrink-0 justify-center">
          {source && onToggleSource && (
            <input
              type="checkbox"
              checked={sourceOn}
              disabled={disabled}
              onChange={(e) => onToggleSource(optionKey, e.target.checked)}
              aria-label={t('slicerSettings.useFromFile', "Use the source file's value for {{option}}", {
                option: option.label || optionKey,
              })}
              title={t('slicerSettings.useFromFile', "Use the source file's value for {{option}}", {
                option: option.label || optionKey,
              })}
              className="w-3 h-3 cursor-pointer disabled:opacity-40"
            />
          )}
        </span>
        <div className="w-40">
          <OptionControl
            id={`slicer-opt-${optionKey}`}
            option={option}
            current={current}
            onChange={onChange}
            disabled={disabled}
            filamentChoices={filamentChoices}
          />
        </div>
        {/* Fixed width so the control column stays straight, but wide enough
            for the longest unit in the schema ("mm/s² or %") — a narrower cap
            truncated those to "mm o...". Rendered even when empty so rows
            without a unit keep the revert button aligned. */}
        <span className="w-16 shrink-0 whitespace-nowrap text-[0.65rem] text-bambu-gray/60">{unit ?? ''}</span>
        <button
          type="button"
          onClick={() => onChange(undefined)}
          disabled={disabled || !modified}
          aria-label={t('slicerSettings.resetOption', 'Reset to default')}
          className={`p-0.5 transition-opacity ${modified ? 'text-bambu-gray hover:text-white' : 'opacity-0 pointer-events-none'}`}
        >
          <RotateCcw className="w-3 h-3" />
        </button>
      </div>
    </div>
  );
}

/**
 * Render a value read out of the source file. Bambu's process config stores
 * everything as strings or arrays of strings, so this only has to flatten
 * arrays — no unit or type interpretation, which would rot against every
 * slicer release.
 */
function formatSourceValue(value: unknown): string {
  if (Array.isArray(value)) return value.map((v) => String(v)).join(', ');
  if (value == null) return '';
  return String(value);
}

interface ControlProps {
  id: string;
  option: ProcessOption;
  current: string;
  onChange: (value: SettingValue | undefined) => void;
  disabled: boolean;
  filamentChoices?: FilamentChoice[];
}

function OptionControl({ id, option, current, onChange, disabled, filamentChoices }: ControlProps) {
  const { t } = useTranslation();
  // Theme tokens rather than raw black/white: bambu-dark and
  // bambu-dark-tertiary are CSS variables that follow the active theme, and
  // `text-white` is remapped to --text-primary in index.css.
  const inputClass =
    'w-full rounded border border-bambu-dark-tertiary bg-bambu-dark px-1.5 py-0.5 text-xs text-white focus:border-bambu-green focus:outline-none disabled:opacity-40';

  // Filament-slot pickers come before the generic branches: the value is an
  // integer, but offering a spinner over "1, 2, 3" makes the user map slot
  // numbers to their own AMS by hand.
  if (filamentChoices && filamentChoices.length > 0) {
    const selected = filamentChoices.find((c) => String(c.index) === current);
    return (
      <div className="relative w-full">
        <select
          id={id}
          value={current}
          onChange={(e) => onChange(e.target.value)}
          disabled={disabled}
          className={`${inputClass} w-full cursor-pointer appearance-none pr-5`}
          // The full name rarely fits in the control, so the hover carries it.
          title={selected?.label}
        >
          {/* 0 is the slicer's "no specific filament — use the region's own". */}
          <option value="0">{t('slicerSettings.filamentDefault', 'Default')}</option>
          {filamentChoices.map((choice) => (
            <option key={choice.index} value={String(choice.index)}>
              {choice.index}: {choice.label}
            </option>
          ))}
        </select>
        <ChevronDown className="pointer-events-none absolute right-1.5 top-1/2 h-3 w-3 -translate-y-1/2 text-bambu-gray" />
      </div>
    );
  }

  if (option.type === 'coBool') {
    return (
      <input
        id={id}
        type="checkbox"
        checked={current === '1' || current === 'true'}
        onChange={(e) => onChange(e.target.checked)}
        disabled={disabled}
        className="w-3.5 h-3.5 cursor-pointer disabled:opacity-40"
      />
    );
  }

  if (option.type === 'coEnum' && option.enum_values) {
    // Native select chrome is replaced the same way as everywhere else in
    // Bambuddy: appearance-none plus our own chevron, so the control matches
    // the app in both themes instead of whatever the browser paints.
    return (
      <div className="relative w-full">
        <select
          id={id}
          value={current}
          onChange={(e) => onChange(e.target.value)}
          disabled={disabled}
          className={`${inputClass} w-full cursor-pointer appearance-none pr-5`}
        >
          {option.enum_values.map((v, i) => (
            <option key={v} value={v}>
              {option.enum_labels?.[i] ?? v}
            </option>
          ))}
        </select>
        <ChevronDown className="pointer-events-none absolute right-1.5 top-1/2 h-3 w-3 -translate-y-1/2 text-bambu-gray" />
      </div>
    );
  }

  if (option.type === 'coInt' || option.type === 'coFloat' || option.type === 'coPercent') {
    return (
      <input
        id={id}
        type="number"
        value={current.replace('%', '')}
        min={numericBound(option.min)}
        max={numericBound(option.max)}
        step={option.type === 'coInt' ? 1 : 'any'}
        // An empty field is kept as an empty string rather than dropped.
        // Dropping it would fall the input straight back to the default, so
        // clearing a value to retype it would silently append to the old one.
        // Empty never counts as modified, so nothing is sent for it either way;
        // the revert button is what actually removes the key.
        onChange={(e) => onChange(e.target.value)}
        disabled={disabled}
        className={inputClass}
      />
    );
  }

  // coFloatOrPercent, the vector types and coString all accept free text: they
  // hold values like "50%", "0.42" or a comma-separated per-extruder list, none
  // of which a number input can represent.
  return (
    <input
      id={id}
      type="text"
      value={current}
      onChange={(e) => onChange(e.target.value)}
      disabled={disabled}
      className={inputClass}
    />
  );
}
