Files
bambuddy/frontend/src/components/SlicerSettingsPanel.tsx
T
maziggy b6027b7138 Say why a preset's values are unavailable, not just that they are
The settings panel collapsed four causes into one message -- "the picked
preset's own values could not be read" -- with no indication of what to do
about it.

The overwhelmingly common cause has an obvious fix, and it isn't an edge
case: an install pulls its sidecar as SIDECAR_TAG:-latest regardless of
which Bambuddy channel it is on, so a current Bambuddy talking to a
sidecar that predates POST /profiles/resolve is the normal state, not a
misconfiguration. Those users would have seen an amber warning on every
slice with nothing pointing at the sidecar image.

resolve_profile now returns ResolvedProfile(values, reason) instead of
None for everything, the route passes the reason through, and the panel
picks its message from it:

  sidecar_outdated     -> name the fix: update the sidecar image
  sidecar_unavailable  -> the sidecar did not answer
  not_configured       -> no sidecar is configured
  preset_unresolved    -> the previous generic wording

A request that fails outright maps to sidecar_unavailable, since a
backend we cannot reach and a sidecar that will not answer are the same
thing from the dialog.

Every variant still ends with "anything you don't change still uses the
preset" -- that reassurance is the point of the notice, and it is true
whichever way the lookup failed.

Tests pin the distinction rather than just the happy path: a 404 and a
500 must produce different reasons, and each panel case asserts both that
its own message appears and that the "update the sidecar image" line does
not leak into the others.
2026-08-09 11:25:09 +02:00

673 lines
28 KiB
TypeScript

/**
* 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}
/>
);
}