feat(print-modal): show each printer slot's colour in the filament mapping (issue #3159)

The Print / Schedule dialog's filament mapping is where the colour a slice
asked for is compared against the colour actually loaded, and only the
left-hand side of that comparison had a swatch. The slot, and every slot in
its dropdown, was text -- and the text cannot be trusted: a slot's colour name
is resolved from the Color Catalog or, failing that, from hue, so a
third-party beige is announced as "Orange". A "Color mismatch" warning then
gives no way to tell a real mismatch from two names for the same hex without
opening the printer card in another tab, which on a farm swapping twenty or
thirty non-Bambu colours between machines is a check made many times a day.

Each slot now carries its colour and its hex, and the slot whose colour is
exactly the one the slice asked for is ticked. This works for a slot bound to
an inventory spool and for one configured through Configure Slot or on the
printer itself: the second kind has no inventory row behind it, and the
printer's own tray colour is then what draws. A bound spool contributes what a
tray record cannot -- SlotSpoolIdentity gains extra_colors and effect_type, so
a two-tone or glittery spool draws as itself rather than as its base colour.

The same treatment goes to the filament-override picker used for model-based
assignment. It is the same choice on the other dispatch path, and leaving it
text-only would have made one decision read two ways.

Both controls stop being <select>s to do it, because an <option> renders text
and nothing else. SlotPicker keeps what the select gave for free -- arrow,
Home/End, Enter and Escape keys, listbox semantics, and the border colouring
that encodes match, same-type-different-colour and not-loaded -- and is
portaled with position:fixed so it is not clipped by the dialog's own scroll
container, flipping above the row when there is no room below.
This commit is contained in:
maziggy
2026-09-25 11:38:13 +02:00
parent 64817ecc18
commit 1a84dfea5b
33 changed files with 1110 additions and 249 deletions
@@ -5,8 +5,10 @@ import { Circle, Check, AlertTriangle, RefreshCw, ChevronDown, ChevronUp, Palett
import { api } from '../../api/client';
import type { SlotSpoolIdentity } from '../../api/client';
import { useFilamentMapping } from '../../hooks/useFilamentMapping';
import { getGlobalTrayId, effectivePreferLowest, FTS_INLET_SIDE } from '../../utils/amsHelpers';
import { getGlobalTrayId, effectivePreferLowest, FTS_INLET_SIDE, normalizeColorForCompare } from '../../utils/amsHelpers';
import { disambiguateColorNames, getColorName } from '../../utils/colors';
import { SlotPicker } from './SlotPicker';
import { displayHex } from './displayHex';
import { useFilamentLabels } from './useFilamentLabels';
import { autoAssignRackPositions, rackOptionsForGroup } from '../../utils/nozzleRack';
import type { FilamentMappingProps, RackGroupInfo } from './types';
@@ -490,11 +492,32 @@ export function FilamentMapping({
</span>
{/* Arrow */}
<span className="text-bambu-gray">→</span>
{/* Slot selector dropdown */}
<select
value={item.loaded?.globalTrayId ?? ''}
onChange={(e) => handleSlotChange(slotId, e.target.value)}
className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${
{/*
Slot picker. Not a <select>: an <option> renders text only,
and the colour of the slot is the whole point of this row
(#3159) -- colour names come from the catalogue or from hue,
so a third-party beige reads as "Orange" and the mismatch
warning cannot be judged without seeing both swatches.
#1722: every loaded slot is offered for every filament row,
regardless of which extruder the slot is wired to. Before that
change a slot was only listed when its extruder matched the
filament's slicer-assigned nozzle (item.nozzle_id), which
locked users out of cross-extruder picks even when they'd
intentionally loaded the required filament into the "other"
AMS. The L/R badge on the filament row still tells the user
what the slicer planned; the picker trusts the user to pick
based on their physical setup. Printer firmware accepts or
rejects the ams_mapping at start-print -- failure is loud,
not silent.
*/}
<SlotPicker
value={item.loaded?.globalTrayId != null ? String(item.loaded.globalTrayId) : ''}
onChange={(next) => handleSlotChange(slotId, next)}
placeholder={`-- ${t('printModal.selectSlot')} --`}
ariaLabel={t('printModal.slotForFilament', { name: resolvedName })}
exactMatchLabel={t('printModal.exactColorMatch')}
className={`${
item.status === 'match'
? 'border-bambu-green/50 text-bambu-green'
: item.status === 'type_only'
@@ -502,24 +525,7 @@ export function FilamentMapping({
: 'border-orange-500 dark:border-orange-400/50 text-orange-700 dark:text-orange-400'
} ${item.isManual ? 'ring-1 ring-blue-400/50' : ''}`}
title={item.isManual ? t('printModal.manuallySelected') : t('printModal.autoMatched')}
>
<option value="" className="bg-bambu-dark text-bambu-gray">
-- {t('printModal.selectSlot')} --
</option>
{/*
#1722: every loaded slot is offered for every filament row,
regardless of which extruder the slot is wired to. Before this
change a slot was only listed when its extruder matched the
filament's slicer-assigned nozzle (item.nozzle_id), which
locked users out of cross-extruder picks even when they'd
intentionally loaded the required filament into the "other"
AMS. The L/R badge on the filament row still tells the user
what the slicer planned; the dropdown now trusts the user to
pick based on their physical setup. Printer firmware accepts
or rejects the ams_mapping at start-print — failure is loud,
not silent.
*/}
{loadedFilaments.map((f) => {
options={loadedFilaments.map((f) => {
const remainingWeight = trayRemainingWeightMap.get(f.globalTrayId);
const remainingLabel = remainingWeight != null
? t('printModal.slotRemainingShort', {
@@ -536,13 +542,24 @@ export function FilamentMapping({
// two views agree. Not translated: L and R are the letters on
// the machine.
const ftsBadge = ftsInlet == null ? '' : ` [${FTS_INLET_SIDE[ftsInlet]}]`;
return (
<option key={f.globalTrayId} value={f.globalTrayId} className="bg-bambu-dark text-white">
{f.label}: {f.spoolName || f.traySubBrands || f.type} ({f.colorName}){remainingLabel}{ftsBadge}
</option>
);
return {
value: String(f.globalTrayId),
label: `${f.label}: ${f.spoolName || f.traySubBrands || f.type} (${f.colorName})`,
meta: `${remainingLabel}${ftsBadge}`,
rgba: f.color,
extraColors: f.extraColors,
effectType: f.effectType,
subtype: f.spoolSubtype,
hex: displayHex(f.color),
// The hex the slice asked for, against the hex the
// printer reports -- the comparison the colour names
// cannot be trusted to make.
exactMatch:
normalizeColorForCompare(item.color) !== '' &&
normalizeColorForCompare(f.color) === normalizeColorForCompare(item.color),
};
})}
</select>
/>
{/* Status icon */}
{item.status === 'match' ? (
<Check className="w-3 h-3 text-bambu-green" />
@@ -2,7 +2,9 @@ import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Circle, RotateCcw, Palette } from 'lucide-react';
import { getColorName } from '../../utils/colors';
import { canonicalFilamentType } from '../../utils/amsHelpers';
import { canonicalFilamentType, normalizeColorForCompare } from '../../utils/amsHelpers';
import { SlotPicker } from './SlotPicker';
import { displayHex } from './displayHex';
import { useFilamentLabels } from './useFilamentLabels';
import type { FilamentReqsData } from './types';
@@ -123,30 +125,42 @@ export function FilamentOverride({
</span>
{/* Arrow */}
<span className="text-bambu-gray">→</span>
{/* Override dropdown — only compatible (same-type) filaments */}
<select
{/* Override picker — only compatible (same-type) filaments.
Carries a swatch for the same reason the mapping row does
(#3159): this is the model-mode half of the same choice,
and leaving it text-only would make the two paths read
differently for one decision. Model mode pools filaments
across every printer of the model, so there is no slot
binding behind an entry and the swatch is the plain tray
colour -- solid, with no multi-colour stops to draw. */}
<SlotPicker
value={isOverridden ? `${override.type}|${override.color}` : ''}
onChange={(e) => handleChange(req.slot_id, e.target.value)}
onChange={(next) => handleChange(req.slot_id, next)}
placeholder={`${t('printModal.originalFilament')}: ${resolvedName} (${colorLabel})`}
placeholderOption={{ rgba: req.color, hex: displayHex(req.color) }}
disabled={compatible.length === 0}
className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${
ariaLabel={t('printModal.overrideForFilament', { name: resolvedName })}
exactMatchLabel={t('printModal.exactColorMatch')}
className={
isOverridden
? 'border-blue-500/50 dark:border-blue-400/50 text-blue-700 dark:text-blue-400'
: 'border-bambu-gray/30 text-bambu-gray'
}`}
>
<option value="" className="bg-bambu-dark text-bambu-gray">
{t('printModal.originalFilament')}: {resolvedName} ({colorLabel})
</option>
{compatible.map((f, idx) => (
<option
key={`${f.type}-${f.color}-${f.tray_sub_brands}-${idx}`}
value={`${f.type}|${f.color}`}
className="bg-bambu-dark text-white"
>
{f.tray_sub_brands || f.type} ({getColorName(f.color, f.tray_sub_brands)})
</option>
))}
</select>
}
options={compatible.map((f, idx) => ({
// The value has to stay `TYPE|COLOR` -- `handleChange`
// parses it -- but two spools of the same type and colour
// on different printers collapse to one value, so the key
// keeps the sub-brand and the index as it did before.
value: `${f.type}|${f.color}`,
key: `${f.type}-${f.color}-${f.tray_sub_brands}-${idx}`,
label: `${f.tray_sub_brands || f.type} (${getColorName(f.color, f.tray_sub_brands)})`,
rgba: f.color,
hex: displayHex(f.color),
exactMatch:
normalizeColorForCompare(req.color) !== '' &&
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color),
}))}
/>
{/* Reset button */}
{isOverridden ? (
<button
@@ -0,0 +1,302 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ChevronDown, Check } from 'lucide-react';
import { FilamentSwatch } from '../FilamentSwatch';
/**
* One-line filament picker that can show a colour, which a `<select>` cannot.
*
* The Print / Schedule dialog is where the slicer's colour is compared against
* what is actually in the machine, and until now the right-hand side of that
* comparison was text only (#3159). Colour names are not a substitute: they are
* resolved from the Color Catalog or from hue, so a third-party beige reads as
* "Orange" and a "Color mismatch" warning gives no way to tell a real mismatch
* from two names for the same hex. `<option>` renders text and nothing else, so
* the swatch means this cannot be a native select.
*
* What is kept from the select it replaces: full keyboard operation (arrows,
* Home/End, Enter, Escape), `role="listbox"` semantics, and the caller's own
* border colouring, which encodes match / type-only / mismatch on the mapping
* row and overridden / original on the override row.
*
* Deliberately not a shared "Select": the rows are a swatch, a name, a hex and
* a badge, and this is the second such control in the app after
* `spool-form/PresetPicker` — which documents the same reasoning. A shared
* abstraction over two call sites with different row shapes would be guessing
* at the third.
*/
export interface SlotPickerOption {
/** The value handed back to `onChange`; mirrors the old `<option value>`. */
value: string;
/** Main text of the row. */
label: string;
/** RRGGBBAA (or RRGGBB) without `#`. Absent renders no swatch at all rather
* than a grey one, so "no colour known" never looks like a grey spool. */
rgba?: string | null;
/** Comma-separated extra hex stops for a multi-colour spool. */
extraColors?: string | null;
/** Effect overlay name, as `FilamentSwatch` understands it. */
effectType?: string | null;
/** Filament subtype; `Multicolor` renders the stops as a conic sweep. */
subtype?: string | null;
/** Muted suffix after the label — remaining grams, FTS inlet badge. */
meta?: string;
/** Shown in the dropdown row so two similar colours can be told apart by
* their hex rather than by two names that resolved the same way. */
hex?: string | null;
/** Marks the row whose colour is exactly the one the slice asked for. */
exactMatch?: boolean;
/** React key, when `value` is not unique. Model-mode pools filaments across
* every printer of a model, so two spools of the same type and colour
* collapse to one `TYPE|COLOR` value while still being two rows. */
key?: string;
}
interface SlotPickerProps {
/** Currently chosen option value, or '' for the placeholder row. */
value: string;
options: SlotPickerOption[];
/** What the '' row reads as. */
placeholder: string;
/** Colour for the '' row, when it has one (the override panel's '' row is
* the 3MF's own filament, which does have a colour). */
placeholderOption?: Omit<SlotPickerOption, 'value' | 'label'>;
onChange: (value: string) => void;
disabled?: boolean;
ariaLabel: string;
/** Native tooltip on the trigger. */
title?: string;
/** Border / text classes the caller uses to encode status. */
className?: string;
/** Tooltip for the exact-match marker. */
exactMatchLabel?: string;
}
const SWATCH_CLASS = 'w-3 h-3';
export function SlotPicker({
value,
options,
placeholder,
placeholderOption,
onChange,
disabled = false,
ariaLabel,
title,
className = '',
exactMatchLabel,
}: SlotPickerProps) {
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const [coords, setCoords] = useState<{ top: number; left: number; width: number } | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const listRef = useRef<HTMLDivElement>(null);
// The placeholder is row 0, so an index addresses both it and the options
// with one number — which is what the arrow keys and aria-activedescendant
// need.
const rows: SlotPickerOption[] = [
{ ...(placeholderOption ?? {}), value: '', label: placeholder },
...options,
];
const selectedIndex = Math.max(
0,
rows.findIndex((r) => r.value === value),
);
const selected = rows[selectedIndex];
const place = useCallback(() => {
const el = triggerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
// Flip above when the row sits low enough that a downward list would run
// off the viewport. The dialog body scrolls, so a mapping row near its
// bottom is the ordinary case, not the edge one.
const below = window.innerHeight - rect.bottom;
const height = Math.min(listRef.current?.scrollHeight ?? 0, 240);
const flip = below < height + 8 && rect.top > below;
setCoords({
top: flip ? Math.max(8, rect.top - height - 4) : rect.bottom + 4,
left: rect.left,
width: rect.width,
});
}, []);
useLayoutEffect(() => {
if (open) place();
}, [open, place]);
useEffect(() => {
if (!open) return;
const onPointerDown = (e: MouseEvent) => {
const target = e.target as Node;
if (triggerRef.current?.contains(target) || listRef.current?.contains(target)) return;
setOpen(false);
};
// Escape closes this control WITHOUT closing the dialog around it, which a
// bare document listener would otherwise let happen — same guard
// `PresetPicker` documents.
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
e.stopPropagation();
setOpen(false);
triggerRef.current?.focus();
};
// The list is portaled to <body>, so it does not travel with the dialog's
// own scroll container: follow it rather than leaving it behind.
const reposition = () => place();
document.addEventListener('mousedown', onPointerDown);
document.addEventListener('keydown', onKeyDown, true);
window.addEventListener('scroll', reposition, true);
window.addEventListener('resize', reposition);
return () => {
document.removeEventListener('mousedown', onPointerDown);
document.removeEventListener('keydown', onKeyDown, true);
window.removeEventListener('scroll', reposition, true);
window.removeEventListener('resize', reposition);
};
}, [open, place]);
const choose = (index: number) => {
onChange(rows[index].value);
setOpen(false);
triggerRef.current?.focus();
};
const openAt = (index: number) => {
setActiveIndex(index);
setOpen(true);
};
const handleTriggerKey = (e: React.KeyboardEvent) => {
if (disabled) return;
if (!open) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openAt(selectedIndex);
}
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex((i) => Math.min(rows.length - 1, i + 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex((i) => Math.max(0, i - 1));
} else if (e.key === 'Home') {
e.preventDefault();
setActiveIndex(0);
} else if (e.key === 'End') {
e.preventDefault();
setActiveIndex(rows.length - 1);
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
choose(activeIndex);
} else if (e.key === 'Tab') {
setOpen(false);
}
};
const listId = `slot-picker-${ariaLabel.replace(/\W+/g, '-')}`;
const renderSwatch = (row: SlotPickerOption) =>
row.rgba ? (
<FilamentSwatch
rgba={row.rgba}
extraColors={row.extraColors}
effectType={row.effectType}
subtype={row.subtype}
className={SWATCH_CLASS}
effectSize="table"
/>
) : null;
return (
<>
<button
ref={triggerRef}
type="button"
role="combobox"
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={open ? listId : undefined}
// On the combobox, not on the listbox: focus stays on the trigger
// while the list is open, and this is what tells a screen reader which
// row the arrow keys are on.
aria-activedescendant={open ? `${listId}-${activeIndex}` : undefined}
disabled={disabled}
title={title}
onClick={() => (open ? setOpen(false) : openAt(selectedIndex))}
onKeyDown={handleTriggerKey}
className={`flex-1 min-w-0 flex items-center gap-1.5 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary text-left focus:outline-none focus:ring-1 focus:ring-bambu-green disabled:opacity-50 ${className}`}
>
{renderSwatch(selected)}
<span className="flex-1 min-w-0 truncate">
{selected.label}
{selected.meta ? <span className="text-bambu-gray">{selected.meta}</span> : null}
</span>
<ChevronDown className="w-3 h-3 shrink-0 opacity-70" />
</button>
{open &&
createPortal(
<div
ref={listRef}
id={listId}
role="listbox"
aria-label={ariaLabel}
className="fixed z-[60] max-h-60 overflow-y-auto rounded-lg border border-bambu-dark-tertiary bg-bambu-dark-secondary p-1 shadow-xl"
style={{
top: coords?.top ?? -9999,
left: coords?.left ?? -9999,
width: coords?.width,
// Hide until placed, so there is no flash at (-9999,-9999) —
// same trick FilamentHoverCard uses.
visibility: coords ? 'visible' : 'hidden',
}}
>
{rows.map((row, index) => (
<button
key={row.key ?? `${row.value}-${index}`}
id={`${listId}-${index}`}
type="button"
role="option"
// What `<option value>` carried before this was a listbox: the
// identity of the choice, separate from how it reads.
data-value={row.value}
aria-selected={index === selectedIndex}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => choose(index)}
className={`w-full flex items-center gap-1.5 text-left px-2 py-1.5 rounded-md text-xs ${
index === selectedIndex
? 'bg-bambu-green/15 text-bambu-green'
: index === activeIndex
? 'bg-bambu-dark text-white'
: 'text-white'
}`}
>
{renderSwatch(row)}
<span className="flex-1 min-w-0 truncate" title={row.label}>
{row.label}
{row.meta ? <span className="text-bambu-gray">{row.meta}</span> : null}
</span>
{row.hex ? (
<span className="shrink-0 font-mono text-[10px] uppercase text-bambu-gray">{row.hex}</span>
) : null}
{row.exactMatch ? (
<span title={exactMatchLabel} className="shrink-0 text-bambu-green">
<Check className="w-3 h-3" />
</span>
) : null}
</button>
))}
</div>,
document.body,
)}
</>
);
}
export default SlotPicker;
@@ -0,0 +1,19 @@
/**
* The hex to print next to a swatch in the slot picker (#3159).
*
* Shown because two slots whose colour names resolved the same way -- both
* "Orange", one a beige -- are otherwise indistinguishable in the list, and
* comparing them against the slice's colour is the reason the picker exists.
*
* Alpha is kept only when it carries information: a translucent spool prints
* all eight digits, an opaque one prints six, so a Clear filament is not shown
* as if it were solid. Returns null for anything unreadable, and the caller
* then prints no hex rather than a misleading one.
*/
export function displayHex(color: string | null | undefined): string | null {
if (!color) return null;
const clean = color.trim().replace(/^#/, '');
if (!/^[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(clean)) return null;
const keepAlpha = clean.length === 8 && clean.substring(6, 8).toLowerCase() !== 'ff';
return `#${(keepAlpha ? clean : clean.substring(0, 6)).toUpperCase()}`;
}