SlicerSettingsPanel.tsx 28 KB

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