SlicerSettingsPanel.tsx 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603
  1. /**
  2. * Process-settings editor mirroring OrcaSlicer's own Print Settings tabs.
  3. *
  4. * Structure, labels, tooltips, bounds, defaults and enable/disable rules all
  5. * come from metadata extracted from OrcaSlicer's C++ sources (see
  6. * `src/data/slicer/`), so the pages, groups and ordering match what users see
  7. * in the desktop slicer rather than a hand-picked subset.
  8. *
  9. * Option labels and tooltips are deliberately English-only for now: they are
  10. * 348 strings lifted verbatim from `PrintConfig.cpp`, and hand-translating them
  11. * into all 13 locales is not viable. The panel's own chrome — mode switch,
  12. * search, buttons, empty states — goes through i18n as usual. OrcaSlicer ships
  13. * its own translation catalogs for these strings, which is the obvious source
  14. * if they are ever picked up.
  15. *
  16. * Values are held sparsely: only options the user actually changed are tracked
  17. * and sent, so a slice with an untouched panel is byte-identical to one from
  18. * before this panel existed.
  19. */
  20. import { useEffect, useMemo, useState } from 'react';
  21. import { useTranslation } from 'react-i18next';
  22. import { Search, RotateCcw, Loader2, ChevronDown } from 'lucide-react';
  23. import { disabledKeys, type ToggleRules } from '../lib/slicerToggle';
  24. import { defaultForDisplay, displaySidetext, isModified, numericBound, serializeOverrides } from '../lib/slicerSettings';
  25. import type { OptionMode, ProcessOption, ProcessSchema, ProcessUiTree, SettingValue } from '../types/slicerSettings';
  26. import type { DesignOverride } from '../types/plates';
  27. interface SlicerData {
  28. schema: ProcessSchema;
  29. tree: ProcessUiTree;
  30. toggles: ToggleRules;
  31. }
  32. interface Props {
  33. values: Record<string, SettingValue>;
  34. /**
  35. * Reports both the panel's editing state and the same values serialised for
  36. * the slice request. Serialising here rather than in the caller keeps the
  37. * option schema — the only thing that knows a percent needs its `%` back —
  38. * in the one component that has already loaded it.
  39. *
  40. * `serialized` carries only options that actually differ from their default,
  41. * so an untouched panel sends nothing at all.
  42. */
  43. onChange: (values: Record<string, SettingValue>, serialized: Record<string, string | string[]>) => void;
  44. disabled?: boolean;
  45. /**
  46. * Process settings the source 3MF's designer moved off the stock preset
  47. * (#2622), as recorded by BambuStudio in `different_settings_to_system`.
  48. *
  49. * These are shown inline against the options they belong to rather than in a
  50. * list of their own, so there is one place to see what this slice will use.
  51. * Their *values* are not routed through this component: the backend reads
  52. * them straight out of the file, which keeps settings faithful even for keys
  53. * outside the option schema we vendor. All this panel decides is which of
  54. * them are switched on.
  55. */
  56. sourceOverrides?: DesignOverride[];
  57. /** Which source-override keys are currently switched on. */
  58. sourceSelected?: Set<string>;
  59. onToggleSource?: (key: string, on: boolean) => void;
  60. /**
  61. * The filaments picked on the slice dialog's left-hand side, in slot order.
  62. *
  63. * A handful of options select *which filament* prints a given feature —
  64. * supports, outer walls, infill. The slicer stores those as a plain integer
  65. * where 0 means "whatever filament the region already uses" and 1..N is a
  66. * slot. A bare number field makes the user count their own AMS slots, so
  67. * when this is supplied those options become a dropdown of the actual
  68. * picks instead.
  69. */
  70. filamentChoices?: FilamentChoice[];
  71. }
  72. export interface FilamentChoice {
  73. /** 1-based slot index, matching the integer the slicer stores. */
  74. index: number;
  75. /** Preset name, or a fallback when the slot has no pick yet. */
  76. label: string;
  77. /** Slot colour from the source plate, for the swatch. */
  78. color?: string;
  79. }
  80. /**
  81. * Options whose integer value names a filament slot rather than a quantity.
  82. * All use the same encoding: 0 = "default / current filament", 1..N = slot.
  83. * Support base and interface are the pair on the Support page; the rest are
  84. * the Multimaterial page's per-region pickers, which have the same wart.
  85. */
  86. const FILAMENT_SLOT_OPTIONS = new Set([
  87. 'support_filament',
  88. 'support_interface_filament',
  89. 'outer_wall_filament_id',
  90. 'inner_wall_filament_id',
  91. 'top_surface_filament_id',
  92. 'bottom_surface_filament_id',
  93. 'internal_solid_filament_id',
  94. 'sparse_infill_filament_id',
  95. ]);
  96. /** Visibility tiers, in increasing order of how much they reveal. */
  97. const MODES: OptionMode[] = ['simple', 'advanced', 'expert'];
  98. const MODE_RANK: Record<string, number> = { simple: 0, advanced: 1, expert: 2, develop: 3 };
  99. export default function SlicerSettingsPanel({
  100. values,
  101. onChange,
  102. disabled = false,
  103. sourceOverrides = [],
  104. sourceSelected,
  105. onToggleSource,
  106. filamentChoices,
  107. }: Props) {
  108. const { t } = useTranslation();
  109. const [data, setData] = useState<SlicerData | null>(null);
  110. const [mode, setMode] = useState<OptionMode>('simple');
  111. const [page, setPage] = useState<string | null>(null);
  112. const [query, setQuery] = useState('');
  113. // 150 KB of extracted metadata has no business in the main bundle — it is
  114. // only needed once someone opens this panel.
  115. useEffect(() => {
  116. let cancelled = false;
  117. Promise.all([
  118. import('../data/slicer/process-schema.json'),
  119. import('../data/slicer/process-ui-tree.json'),
  120. import('../data/slicer/process-toggle-rules.json'),
  121. ]).then(([schema, tree, toggles]) => {
  122. if (cancelled) return;
  123. setData({
  124. schema: (schema.default ?? schema) as unknown as ProcessSchema,
  125. tree: (tree.default ?? tree) as unknown as ProcessUiTree,
  126. toggles: (toggles.default ?? toggles) as unknown as ToggleRules,
  127. });
  128. });
  129. return () => {
  130. cancelled = true;
  131. };
  132. }, []);
  133. const off = useMemo(
  134. () => (data ? disabledKeys(values, data.schema, data.toggles) : new Set<string>()),
  135. [data, values],
  136. );
  137. const sourceByKey = useMemo(
  138. () => new Map(sourceOverrides.map((o) => [o.key, o])),
  139. [sourceOverrides],
  140. );
  141. // Source overrides for keys the vendored schema doesn't cover. They still
  142. // apply — the backend reads their values from the file — so they get a group
  143. // of their own rather than being dropped from view.
  144. const unlistedSource = useMemo(() => {
  145. if (!data) return [];
  146. return sourceOverrides.filter((o) => !data.schema[o.key]);
  147. }, [data, sourceOverrides]);
  148. const emit = (next: Record<string, SettingValue>) => {
  149. if (!data) return;
  150. // Only genuine deviations are worth sending: an override that equals the
  151. // preset's own value is noise in the process JSON and makes the slice
  152. // request harder to read when something goes wrong.
  153. const changed: Record<string, SettingValue> = {};
  154. for (const [k, v] of Object.entries(next)) {
  155. if (data.schema[k] && isModified(data.schema[k], v)) changed[k] = v;
  156. }
  157. onChange(next, serializeOverrides(changed, data.schema));
  158. };
  159. const setValue = (key: string, value: SettingValue | undefined) => {
  160. const next = { ...values };
  161. if (value === undefined) delete next[key];
  162. else next[key] = value;
  163. emit(next);
  164. };
  165. // Search cuts across every page; without a query we show the selected page.
  166. const visiblePages = useMemo(() => {
  167. if (!data) return [];
  168. // Underscores and spaces are interchangeable so "outer wall speed" finds
  169. // `outer_wall_speed`. That matters more than it looks: several labels are
  170. // only meaningful with their group ("Outer wall" under Speed), so the key
  171. // is often the only place the full phrase appears.
  172. const flatten = (s: string) => s.toLowerCase().replace(/[_\s]+/g, ' ').trim();
  173. const needle = flatten(query);
  174. const withinMode = (key: string) => MODE_RANK[data.schema[key]?.mode ?? 'expert'] <= MODE_RANK[mode];
  175. const matches = (key: string, group: string, page: string) => {
  176. if (!needle) return true;
  177. const opt = data.schema[key];
  178. // Group and page are matched too, so "speed" lists the Speed page's
  179. // options rather than only the handful with "speed" in their label.
  180. const haystack = [key, opt?.label ?? '', opt?.tooltip ?? '', group, page];
  181. return haystack.some((h) => flatten(h).includes(needle));
  182. };
  183. return data.tree
  184. .map((p) => ({
  185. ...p,
  186. groups: p.groups
  187. .map((g) => ({
  188. ...g,
  189. options: g.options.filter((k) => withinMode(k) && matches(k, g.group, p.page)),
  190. }))
  191. .filter((g) => g.options.length > 0),
  192. }))
  193. .filter((p) => p.groups.length > 0);
  194. }, [data, mode, query]);
  195. const activePage = useMemo(() => {
  196. if (visiblePages.length === 0) return null;
  197. if (query.trim()) return null; // Searching shows every match, not one page.
  198. return visiblePages.find((p) => p.page === page) ?? visiblePages[0];
  199. }, [visiblePages, page, query]);
  200. const modifiedCount = useMemo(() => {
  201. if (!data) return 0;
  202. return Object.keys(values).filter((k) => data.schema[k] && isModified(data.schema[k], values[k])).length;
  203. }, [data, values]);
  204. if (!data) {
  205. return (
  206. <div className="flex items-center justify-center gap-2 py-8 text-sm text-bambu-gray">
  207. <Loader2 className="w-4 h-4 animate-spin" />
  208. {t('slicerSettings.loading', 'Loading slicer settings...')}
  209. </div>
  210. );
  211. }
  212. const shownPages = activePage ? [activePage] : visiblePages;
  213. return (
  214. <div className="flex flex-col gap-3">
  215. <div className="flex flex-wrap items-center gap-2">
  216. <div className="flex overflow-hidden rounded border border-bambu-dark-tertiary">
  217. {MODES.map((m) => (
  218. <button
  219. key={m}
  220. type="button"
  221. onClick={() => setMode(m)}
  222. disabled={disabled}
  223. className={`px-2.5 py-1 text-xs capitalize transition-colors ${
  224. mode === m ? 'bg-bambu-green text-white' : 'text-bambu-gray hover:text-white'
  225. }`}
  226. >
  227. {t(`slicerSettings.mode.${m}`, m)}
  228. </button>
  229. ))}
  230. </div>
  231. <div className="relative flex-1 min-w-[10rem]">
  232. <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-bambu-gray" />
  233. <input
  234. type="search"
  235. value={query}
  236. onChange={(e) => setQuery(e.target.value)}
  237. disabled={disabled}
  238. placeholder={t('slicerSettings.searchPlaceholder', 'Search settings')}
  239. 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"
  240. />
  241. </div>
  242. {modifiedCount > 0 && (
  243. <button
  244. type="button"
  245. onClick={() => emit({})}
  246. disabled={disabled}
  247. className="flex items-center gap-1 text-xs text-bambu-gray hover:text-white"
  248. >
  249. <RotateCcw className="w-3 h-3" />
  250. {t('slicerSettings.resetAll', 'Reset {{count}}', { count: modifiedCount })}
  251. </button>
  252. )}
  253. </div>
  254. {!query.trim() && (
  255. <div className="flex flex-wrap gap-1">
  256. {visiblePages.map((p) => (
  257. <button
  258. key={p.page}
  259. type="button"
  260. onClick={() => setPage(p.page)}
  261. disabled={disabled}
  262. className={`px-2 py-1 text-xs rounded transition-colors ${
  263. activePage?.page === p.page ? 'bg-bambu-dark-tertiary text-white' : 'text-bambu-gray hover:text-white'
  264. }`}
  265. >
  266. {p.page}
  267. </button>
  268. ))}
  269. </div>
  270. )}
  271. {shownPages.length === 0 ? (
  272. <p className="py-6 text-center text-xs text-bambu-gray">
  273. {t('slicerSettings.noMatches', 'No settings match this search.')}
  274. </p>
  275. ) : (
  276. // Taller once the panel has a column of its own; the narrow cap keeps
  277. // it from swallowing the single-column stack on small screens.
  278. <div className="flex flex-col gap-4 max-h-[22rem] lg:max-h-[58vh] overflow-y-auto pr-1">
  279. {shownPages.map((p) => (
  280. <div key={p.page} className="flex flex-col gap-3">
  281. {query.trim() && <p className="text-[0.7rem] uppercase tracking-wide text-bambu-gray/70">{p.page}</p>}
  282. {p.groups.map((g) => (
  283. <fieldset key={`${p.page}:${g.group}`} className="flex flex-col gap-1.5">
  284. <legend className="mb-1 text-xs font-medium text-white">{g.group}</legend>
  285. {g.options.map((key) => (
  286. <OptionRow
  287. key={key}
  288. optionKey={key}
  289. option={data.schema[key]}
  290. value={values[key]}
  291. onChange={(v) => setValue(key, v)}
  292. disabled={disabled || off.has(key)}
  293. disabledBySlicer={off.has(key)}
  294. source={sourceByKey.get(key)}
  295. sourceOn={sourceSelected?.has(key) ?? false}
  296. onToggleSource={onToggleSource}
  297. filamentChoices={FILAMENT_SLOT_OPTIONS.has(key) ? filamentChoices : undefined}
  298. />
  299. ))}
  300. </fieldset>
  301. ))}
  302. </div>
  303. ))}
  304. {/* Source-file settings the vendored schema has no entry for: they
  305. still apply (the backend reads their values from the file), so
  306. they get a plain key/value group rather than disappearing from a
  307. panel that claims to show what this slice will use. */}
  308. {unlistedSource.length > 0 && !query.trim() && (
  309. <fieldset className="flex flex-col gap-1.5">
  310. <legend className="mb-1 text-xs font-medium text-white">
  311. {t('slicerSettings.otherFromFile', 'Other settings from this file')}
  312. </legend>
  313. {unlistedSource.map((o) => (
  314. <label key={o.key} className="flex items-center gap-2 text-xs cursor-pointer">
  315. <input
  316. type="checkbox"
  317. checked={sourceSelected?.has(o.key) ?? false}
  318. disabled={disabled || !onToggleSource}
  319. onChange={(e) => onToggleSource?.(o.key, e.target.checked)}
  320. className="shrink-0 cursor-pointer disabled:opacity-40"
  321. />
  322. <span className="min-w-0 flex-1 truncate">
  323. <span className="font-mono text-bambu-gray">{o.key}</span>
  324. <span className="ml-1.5 text-white">{formatSourceValue(o.value)}</span>
  325. </span>
  326. {o.printer_coupled && (
  327. <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">
  328. {t('slicerSettings.fromFilePrinterCoupled', "designer's printer")}
  329. </span>
  330. )}
  331. </label>
  332. ))}
  333. </fieldset>
  334. )}
  335. </div>
  336. )}
  337. </div>
  338. );
  339. }
  340. interface RowProps {
  341. optionKey: string;
  342. option: ProcessOption;
  343. value: SettingValue | undefined;
  344. onChange: (value: SettingValue | undefined) => void;
  345. disabled: boolean;
  346. /** Greyed because the slicer's own rules turn it off, not because the form is busy. */
  347. disabledBySlicer: boolean;
  348. /** Set when the source file's designer moved this option off the stock preset. */
  349. source?: DesignOverride;
  350. sourceOn?: boolean;
  351. onToggleSource?: (key: string, on: boolean) => void;
  352. /** Set only for options whose integer value names a filament slot. */
  353. filamentChoices?: FilamentChoice[];
  354. }
  355. function OptionRow({
  356. optionKey,
  357. option,
  358. value,
  359. onChange,
  360. disabled,
  361. disabledBySlicer,
  362. source,
  363. sourceOn = false,
  364. onToggleSource,
  365. filamentChoices,
  366. }: RowProps) {
  367. const { t } = useTranslation();
  368. const modified = isModified(option, value);
  369. const unit = displaySidetext(option);
  370. // What this slice will actually use, in precedence order: a value typed here
  371. // wins, then the designer's value if it is switched on, then the preset's.
  372. const current =
  373. value !== undefined
  374. ? String(value)
  375. : sourceOn && source
  376. ? formatSourceValue(source.value)
  377. : defaultForDisplay(option);
  378. return (
  379. <div className="flex items-center gap-2 group" title={option.tooltip}>
  380. <label
  381. htmlFor={`slicer-opt-${optionKey}`}
  382. className={`flex-1 text-xs truncate ${disabledBySlicer ? 'text-bambu-gray/40' : 'text-bambu-gray'}`}
  383. >
  384. {option.label || optionKey}
  385. {modified && <span className="ml-1 text-bambu-green" aria-hidden="true">•</span>}
  386. {source && (
  387. <span
  388. className={`ml-1.5 rounded px-1 py-0.5 text-[10px] ${
  389. source.printer_coupled
  390. ? 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400'
  391. : 'bg-bambu-green/15 text-bambu-green'
  392. }`}
  393. title={
  394. source.printer_coupled
  395. ? t('slicerSettings.fromFilePrinterCoupledHint', "Tuned for the printer this file was designed for -- may be wrong or out of range on yours.")
  396. : t('slicerSettings.fromFileHint', "The designer changed this in the source file. Its value is {{value}}.", { value: formatSourceValue(source.value) })
  397. }
  398. >
  399. {source.printer_coupled
  400. ? t('slicerSettings.fromFilePrinterCoupled', "designer's printer")
  401. : t('slicerSettings.fromFile', 'from file')}
  402. </span>
  403. )}
  404. </label>
  405. <div className="flex items-center gap-1 shrink-0">
  406. <OptionControl
  407. id={`slicer-opt-${optionKey}`}
  408. option={option}
  409. current={current}
  410. onChange={onChange}
  411. disabled={disabled}
  412. filamentChoices={filamentChoices}
  413. />
  414. {unit && <span className="text-[0.65rem] text-bambu-gray/60 w-10 truncate">{unit}</span>}
  415. {source && onToggleSource && (
  416. <input
  417. type="checkbox"
  418. checked={sourceOn}
  419. disabled={disabled}
  420. onChange={(e) => onToggleSource(optionKey, e.target.checked)}
  421. aria-label={t('slicerSettings.useFromFile', "Use the source file's value for {{option}}", {
  422. option: option.label || optionKey,
  423. })}
  424. title={t('slicerSettings.useFromFile', "Use the source file's value for {{option}}", {
  425. option: option.label || optionKey,
  426. })}
  427. className="w-3 h-3 cursor-pointer disabled:opacity-40"
  428. />
  429. )}
  430. <button
  431. type="button"
  432. onClick={() => onChange(undefined)}
  433. disabled={disabled || !modified}
  434. aria-label={t('slicerSettings.resetOption', 'Reset to default')}
  435. className={`p-0.5 transition-opacity ${modified ? 'text-bambu-gray hover:text-white' : 'opacity-0 pointer-events-none'}`}
  436. >
  437. <RotateCcw className="w-3 h-3" />
  438. </button>
  439. </div>
  440. </div>
  441. );
  442. }
  443. /**
  444. * Render a value read out of the source file. Bambu's process config stores
  445. * everything as strings or arrays of strings, so this only has to flatten
  446. * arrays — no unit or type interpretation, which would rot against every
  447. * slicer release.
  448. */
  449. function formatSourceValue(value: unknown): string {
  450. if (Array.isArray(value)) return value.map((v) => String(v)).join(', ');
  451. if (value == null) return '';
  452. return String(value);
  453. }
  454. interface ControlProps {
  455. id: string;
  456. option: ProcessOption;
  457. current: string;
  458. onChange: (value: SettingValue | undefined) => void;
  459. disabled: boolean;
  460. filamentChoices?: FilamentChoice[];
  461. }
  462. function OptionControl({ id, option, current, onChange, disabled, filamentChoices }: ControlProps) {
  463. const { t } = useTranslation();
  464. // Theme tokens rather than raw black/white: bambu-dark and
  465. // bambu-dark-tertiary are CSS variables that follow the active theme, and
  466. // `text-white` is remapped to --text-primary in index.css.
  467. const inputClass =
  468. 'w-24 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';
  469. // Filament-slot pickers come before the generic branches: the value is an
  470. // integer, but offering a spinner over "1, 2, 3" makes the user map slot
  471. // numbers to their own AMS by hand.
  472. if (filamentChoices && filamentChoices.length > 0) {
  473. const selected = filamentChoices.find((c) => String(c.index) === current);
  474. return (
  475. <div className="relative w-24">
  476. <select
  477. id={id}
  478. value={current}
  479. onChange={(e) => onChange(e.target.value)}
  480. disabled={disabled}
  481. className={`${inputClass} w-full cursor-pointer appearance-none pr-5`}
  482. // The full name rarely fits in the control, so the hover carries it.
  483. title={selected?.label}
  484. >
  485. {/* 0 is the slicer's "no specific filament — use the region's own". */}
  486. <option value="0">{t('slicerSettings.filamentDefault', 'Default')}</option>
  487. {filamentChoices.map((choice) => (
  488. <option key={choice.index} value={String(choice.index)}>
  489. {choice.index}: {choice.label}
  490. </option>
  491. ))}
  492. </select>
  493. <ChevronDown className="pointer-events-none absolute right-1.5 top-1/2 h-3 w-3 -translate-y-1/2 text-bambu-gray" />
  494. </div>
  495. );
  496. }
  497. if (option.type === 'coBool') {
  498. return (
  499. <input
  500. id={id}
  501. type="checkbox"
  502. checked={current === '1' || current === 'true'}
  503. onChange={(e) => onChange(e.target.checked)}
  504. disabled={disabled}
  505. className="w-3.5 h-3.5 cursor-pointer disabled:opacity-40"
  506. />
  507. );
  508. }
  509. if (option.type === 'coEnum' && option.enum_values) {
  510. // Native select chrome is replaced the same way as everywhere else in
  511. // Bambuddy: appearance-none plus our own chevron, so the control matches
  512. // the app in both themes instead of whatever the browser paints.
  513. return (
  514. <div className="relative w-24">
  515. <select
  516. id={id}
  517. value={current}
  518. onChange={(e) => onChange(e.target.value)}
  519. disabled={disabled}
  520. className={`${inputClass} w-full cursor-pointer appearance-none pr-5`}
  521. >
  522. {option.enum_values.map((v, i) => (
  523. <option key={v} value={v}>
  524. {option.enum_labels?.[i] ?? v}
  525. </option>
  526. ))}
  527. </select>
  528. <ChevronDown className="pointer-events-none absolute right-1.5 top-1/2 h-3 w-3 -translate-y-1/2 text-bambu-gray" />
  529. </div>
  530. );
  531. }
  532. if (option.type === 'coInt' || option.type === 'coFloat' || option.type === 'coPercent') {
  533. return (
  534. <input
  535. id={id}
  536. type="number"
  537. value={current.replace('%', '')}
  538. min={numericBound(option.min)}
  539. max={numericBound(option.max)}
  540. step={option.type === 'coInt' ? 1 : 'any'}
  541. // An empty field is kept as an empty string rather than dropped.
  542. // Dropping it would fall the input straight back to the default, so
  543. // clearing a value to retype it would silently append to the old one.
  544. // Empty never counts as modified, so nothing is sent for it either way;
  545. // the revert button is what actually removes the key.
  546. onChange={(e) => onChange(e.target.value)}
  547. disabled={disabled}
  548. className={inputClass}
  549. />
  550. );
  551. }
  552. // coFloatOrPercent, the vector types and coString all accept free text: they
  553. // hold values like "50%", "0.42" or a comma-separated per-extruder list, none
  554. // of which a number input can represent.
  555. return (
  556. <input
  557. id={id}
  558. type="text"
  559. value={current}
  560. onChange={(e) => onChange(e.target.value === '' ? undefined : e.target.value)}
  561. disabled={disabled}
  562. className={inputClass}
  563. />
  564. );
  565. }