diff --git a/docs/HLSD/color-picker.md b/docs/HLSD/color-picker.md new file mode 100644 index 0000000000..b753477b3b --- /dev/null +++ b/docs/HLSD/color-picker.md @@ -0,0 +1,131 @@ +# Color picker + +The color picker is a local HTML dialog hosted by `ColorPickerDialog`, a +`WebViewHostDialog` subclass. Its header and implementation own the ordered +selection contract and shared filament conversion. Bridge validation, favorites +persistence, placement and gettext strings remain private to the implementation. +Its colors use the existing `ColorRGBA` utility. +It uses a native title bar and fixed window border, allowing dragging but no +manual resizing or maximization. Native title-bar colors use the application's +shared dialog dark-theme update on creation and theme changes. +Like SpeedDial, the host disables browser zoom accelerators, restores native +WebView focus on activation and readiness, and synchronizes layout and the macOS +viewport before repainting the backend. Activation never dismisses this modal +dialog; focus restoration preserves an active editor and its partial input. +The initialized page reports +its intrinsic content height; native code validates the current page identifier +and bounded numeric height, converts CSS pixels to device-independent window +size, and fits within the display's work area. Identical sizes are ignored. The +page observes content rather than viewport height, so resizing does not create +a feedback loop; oversized content remains scrollable. +The panel snapshots the triggering color or More Colors button's screen rectangle. +It aligns below the button, flips above when needed, and clamps to that button's +display work area on initial display. Later content and DPI changes update only +the client size, preserving the position chosen by dragging the title bar. +wxGTK clears the dialog's default +parent-centering constraint and reapplies its native position after showing, +bypassing wxGTK's requested-position cache. Absolute top-level placement remains +subject to the window manager; Wayland compositors control it. The official filament palette's +existing placement is independent of this custom panel. +A solid selection contains one color; a gradient contains exactly two ordered +endpoints. The bridge represents these as `{"type":"solid|gradient", +"colors":["#RRGGBBAA", ...]}`. RGB input is also accepted and becomes opaque. +Native validation rejects malformed hex and incorrect cardinality. + +The project filament color action and the official filament picker's More Colors +action enable two-endpoint gradients and independent endpoint alpha. The preset +combo box reads project colors in their stored vector order locally and applies +changes through `sync_colour_config`, +retaining the existing RGB/RGBA profile/project formats and dirty marking. +Sidebar gradient and segmented multicolor swatches draw colors in the supplied +order without sorting them. +Opaque colors use `#RRGGBB`; nonopaque colors use `#RRGGBBAA`, including fully +transparent alpha `00`. Initial selections, alpha-only edits and cancellation +retain stored alpha. Ordered +custom results bypass the official palette's `FilamentColor` set. Choosing an +official swatch retains its existing palette behavior. +The Linux sidebar's Change extruder color menu uses this same custom editor and +the filament swatch anchor, including alpha, ordered gradients and configuration syncing. + +Striped colors and gradients with more than two endpoints cannot be represented +by this editor. A notice explains that the current selection is retained; the +editor starts from the first color, and unchanged confirmation is a no-op. +Cancellation is also a no-op. If the WebView backend cannot initialize, a local +unavailable panel offers Cancel and Escape, without opening a system color picker. + +Generic color option fields use an ordinary color button on every platform and +open the same panel with gradients and alpha disabled. The panel is anchored to +the field button. Empty or invalid field values remain undefined (the empty +string), distinct from opaque black. Right-click resets to undefined. Cancel does +not notify the field; confirmation and reset notify only when its RGB string +changes. Programmatic assignments remain silent, and reading a field never +writes legacy favorites. + +AMS material settings retain their fixed/AMS swatches and first-level popup. Only +its custom-color action opens the solid, opaque editor, anchored to that custom +control. The transient popup releases its grab while the modal is open and returns +to the same selection on cancellation. Confirmation uses the existing default-color +and packed RGBA event path. Device permission/status checks remain at the first-level +entry. + +Texture import's color-mapping Add Material action opens a solid, opaque editor +anchored to the mapping row. The existing filament limit is checked before opening. +The first-level material popup closes as an action; its callback is copied before +dismissal, so popup destruction does not invalidate the modal's completion path. +Confirmation invokes the existing virtual-filament callback once; cancellation +adds no material. + +Native gettext strings are installed at document start. Static page labels and +palette names use stable English keys; RAL codes, channel symbols, and numeric +labels remain data. The palette color values do not depend on the UI language. +The application locale also sets the document language. Numeric editor validation +uses the application's gettext messages, rather than browser language defaults; +missing translations fall back to the stable English keys. + +The pure color/state regressions run with +`node --test resources/web/dialog/ColorPickerDialog/colorpicker.test.js`. +Optional real-browser regressions live beside them in +`colorpicker.browser.test.js`. They use an existing Playwright installation +(`ORCA_PLAYWRIGHT_MODULE` can name its module path) and an installed browser +(`ORCA_BROWSER_EXECUTABLE` can name its executable), without adding a production +dependency. They cover bridge initialization, capability combinations, invalid +editor recovery, ordered endpoint alpha, favorites, gettext keys, and live theme +changes. These browser checks complement native contract/persistence tests; they +do not establish the full native modal confirmation and favorites journey. + +Gradient and alpha capabilities are independent and default off. Unsupported +gradients are rejected; disabled alpha normalizes the selected result to opaque. +Favorites retain both capabilities independently of the current caller. The page +stores alpha as a byte and only converts to percentage when displaying or editing +opacity, so switching endpoints does not change alpha precision. + +The page starts disabled. Each document supplies a page identifier in its ready +message, receives native initialization, and acknowledges it before native code +accepts confirmation or favorite updates. Repeated initialization does not reset +edits, and messages from an obsolete document are ignored. Native WebView message +handling is deferred and guarded by a lifetime token. Only a validated confirmation +sets the optional result. Cancel, Escape and native window close leave it empty. + +Favorites belong to application configuration, separately from the selected result, +in the editor configuration returned by `AppConfig::config_path()` under `data_dir()`. +The `color_picker` section has string `version` equal to `1` and a `favorites` +string containing a JSON array of typed selections. Native helpers normalize and +deduplicate this array in order and limit it to 24 entries. Updates replace the +entire section and immediately call `AppConfig::save()`, so accepted favorite +changes survive cancellation. Empty arrays are saved explicitly. Core `AppConfig` +only stores the raw section; color validation belongs to the GUI boundary. + +When the section is absent, valid solid colors from `custom_color_list` are imported +once, without changing that legacy section. The legacy native picker saves decimal +`r,g,b,a` byte strings; hexadecimal RGB and RGBA strings are also supported. Migration +strictly validates all four decimal channels and preserves alpha. Gradient strings +and malformed values are ignored. The new version and an empty array are +saved even when there is nothing to import. A present section with an unknown or +missing version is left untouched and disables favorite writes. Malformed version +1 data is displayed as empty and remains on disk until the user explicitly saves +a valid replacement. Other configuration sections and project/profile color +formats are independent of this storage. + +Interface colors consume the host's `data-orca-theme` and `--orca-*` contract through +shared styles. The picker opts into scoped global control roles. Application theme +changes update UI surfaces without reloading the page or changing color data. diff --git a/localization/i18n/list.txt b/localization/i18n/list.txt index 3fc3c87424..128318039f 100644 --- a/localization/i18n/list.txt +++ b/localization/i18n/list.txt @@ -325,6 +325,7 @@ src/slic3r/GUI/BaseTransparentDPIFrame.cpp src/slic3r/GUI/CloneDialog.cpp src/slic3r/GUI/ColorDecomposeDialog.cpp src/slic3r/GUI/ColorDecomposeSupport.cpp +src/slic3r/GUI/ColorPickerDialog.cpp src/slic3r/GUI/ConfigWizard.cpp src/slic3r/GUI/DeviceCore/DevCalib.cpp src/slic3r/GUI/DeviceCore/DevUpgrade.cpp diff --git a/resources/web/dialog/ColorPickerDialog/colorpicker-model.js b/resources/web/dialog/ColorPickerDialog/colorpicker-model.js new file mode 100644 index 0000000000..c95aed367d --- /dev/null +++ b/resources/web/dialog/ColorPickerDialog/colorpicker-model.js @@ -0,0 +1,155 @@ +/* Pure color/state helpers shared by the page and its node:test contract tests. */ +(function (root, factory) { + "use strict"; + const api = factory(); + if (typeof module === "object" && module.exports) module.exports = api; + else root.ColorPickerModel = api; +})(typeof globalThis === "object" ? globalThis : this, function () { + "use strict"; + + function parseHex(hex) { + if (typeof hex !== "string" || !/^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(hex)) return null; + return [1, 3, 5, 7].map((offset) => offset === 7 && hex.length === 7 ? 255 : parseInt(hex.slice(offset, offset + 2), 16)); + } + + function toHex(color) { + return "#" + color.map((byte) => Math.round(byte).toString(16).padStart(2, "0")).join("").toUpperCase(); + } + + const MAX_GRADIENT_COLORS = 7; // Most colors a gradient may have. A caller allows 2 (the default) up to this many. + const NEW_GRADIENT_HUE_SHIFT = 75; // Hue step used for every newly created gradient color. + const gradientLimit = (value) => Math.max(2, Math.min(MAX_GRADIENT_COLORS, Math.floor(Number(value)) || 2)); + + // `maxGradientColors` caps how many colors a gradient may have (2 when omitted). + function normalizeSelection(value, options = {}, maxGradientColors = 2) { + if (!value || !["solid", "gradient"].includes(value.type) || !Array.isArray(value.colors)) return null; + if (value.type === "gradient" && !options.allow_gradient) return null; + const count = value.colors.length; + if (value.type === "solid" ? count !== 1 : count < 2 || count > gradientLimit(maxGradientColors)) return null; + const colors = value.colors.map(parseHex); + if (colors.some((color) => color === null)) return null; + if (!options.allow_alpha) colors.forEach((color) => { color[3] = 255; }); + return {type: value.type, colors: colors.map(toHex)}; + } + + function rgbToHsl(r, g, b) { + r /= 255; g /= 255; b /= 255; + const max = Math.max(r, g, b), min = Math.min(r, g, b), delta = max - min; + let h = 0, s = 0; + const l = (max + min) / 2; + if (delta) { + s = delta / (1 - Math.abs(2 * l - 1)); + if (max === r) h = ((g - b) / delta + (g < b ? 6 : 0)) / 6; + else if (max === g) h = ((b - r) / delta + 2) / 6; + else h = ((r - g) / delta + 4) / 6; + } + return [h * 360, s * 100, l * 100]; + } + + function hslToRgb(h, s, l) { + h = ((h % 360) + 360) % 360 / 60; s /= 100; l /= 100; + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs(h % 2 - 1)), m = l - c / 2; + const sectors = [[c,x,0], [x,c,0], [0,c,x], [0,x,c], [x,0,c], [c,0,x]]; + return sectors[Math.floor(h)].map((channel) => Math.round((channel + m) * 255)); + } + + function contrastColor(color, hueShift = 90) { + if (!Number.isFinite(hueShift)) hueShift = 90; + const degrees = Math.max(-180, Math.min(180, hueShift)); + const amount = Math.abs(degrees) / 180; + const [h, s, l] = rgbToHsl(color[0], color[1], color[2]); + const grey = s < 10; + const target = grey ? (l < 50 ? 100 : 0) : 100 - l; + const rgb = hslToRgb(h + degrees, grey ? 0 : s, l + (target - l) * amount); + return [...rgb, color[3]]; + } + + // options.max_gradient_colors (2..7, default 2) is how many colors this page lets a gradient have. + function createState(options = {}, selection = {type: "solid", colors: ["#808080"]}) { + const capabilities = {allow_gradient: options.allow_gradient === true, allow_alpha: options.allow_alpha === true}; + // gradient stays null until a selection provides one or the user switches to gradient mode. + const state = {options: capabilities, maxGradientColors: gradientLimit(options.max_gradient_colors), + mode: "solid", active: 0, solid: [128,128,128,255], gradient: null}; + if (!applySelection(state, selection)) return null; + return state; + } + + function applySelection(state, value) { + const selection = normalizeSelection(value, state.options, state.maxGradientColors); + if (!selection) return false; + state.mode = selection.type; + const colors = selection.colors.map(parseHex); + if (state.mode === "solid") state.solid = colors[0]; + else { + state.gradient = colors; + state.active = Math.min(state.active, colors.length - 1); // A gradient with fewer colors must not leave the selected stop past its end. + } + return true; + } + + function activeColor(state) { return state.mode === "solid" ? state.solid : state.gradient[state.active]; } + + function setMode(state, mode) { + if (mode !== "solid" && (mode !== "gradient" || !state.options.allow_gradient)) return false; + // A new gradient starts at the current solid color and ends at its contrast color. + if (mode === "gradient" && !state.gradient) state.gradient = [[...state.solid], contrastColor(state.solid, NEW_GRADIENT_HUE_SHIFT)]; + state.mode = mode; + return true; + } + + function addGradientColor(state) { + if (state.mode !== "gradient" || state.gradient.length >= state.maxGradientColors) return false; + state.gradient.splice(state.active + 1, 0, contrastColor(state.gradient[state.active], NEW_GRADIENT_HUE_SHIFT)); + state.active += 1; + return true; + } + + function removeGradientColor(state) { + if (state.mode !== "gradient" || state.gradient.length <= 2) return false; + state.gradient.splice(state.active, 1); + state.active = Math.min(state.active, state.gradient.length - 1); + return true; + } + + function setRgb(state, rgb) { + if (rgb.length !== 3 || rgb.some((v) => !Number.isFinite(v) || v < 0 || v > 255)) return false; + const color = activeColor(state); + rgb.forEach((v, i) => { color[i] = Math.round(v); }); + return true; + } + + function setAlphaPercent(state, value) { + if (!state.options.allow_alpha || !Number.isFinite(value) || value < 0 || value > 100) return false; + activeColor(state)[3] = Math.round(value * 255 / 100); + return true; + } + + function setHex(state, hex) { + const color = parseHex(hex); + if (!color) return false; + if (hex.length === 7) color[3] = activeColor(state)[3]; + if (!state.options.allow_alpha) color[3] = 255; + if (state.mode === "solid") state.solid = color; + else state.gradient[state.active] = color; + return true; + } + + function exportSelection(state) { + const colors = state.mode === "solid" ? [state.solid] : state.gradient; + return normalizeSelection({type: state.mode, colors: colors.map(toHex)}, state.options, state.maxGradientColors); + } + + // Favorites retain full capabilities regardless of what this caller can select. + function normalizeFavorites(values) { + if (!Array.isArray(values)) return []; + return values.slice(0, 24).map((value) => normalizeSelection(value, {allow_gradient: true, allow_alpha: true}, MAX_GRADIENT_COLORS)).filter(Boolean); + } + + function selectionBackground(value) { + return value.type === "gradient" ? `linear-gradient(to right, ${value.colors.join(", ")})` : `linear-gradient(${value.colors[0]}, ${value.colors[0]})`; + } + + return {MAX_GRADIENT_COLORS, NEW_GRADIENT_HUE_SHIFT, contrastColor, parseHex, toHex, normalizeSelection, rgbToHsl, hslToRgb, createState, applySelection, activeColor, setMode, + addGradientColor, removeGradientColor, setRgb, setAlphaPercent, setHex, exportSelection, normalizeFavorites, selectionBackground}; +}); diff --git a/resources/web/dialog/ColorPickerDialog/colorpicker.browser.test.js b/resources/web/dialog/ColorPickerDialog/colorpicker.browser.test.js new file mode 100644 index 0000000000..94dd7b77b0 --- /dev/null +++ b/resources/web/dialog/ColorPickerDialog/colorpicker.browser.test.js @@ -0,0 +1,314 @@ +// Optional real-DOM regressions. Use an existing Playwright installation: +// ORCA_PLAYWRIGHT_MODULE= ORCA_BROWSER_EXECUTABLE= +// ORCA_BROWSER_ENGINE=webkit exercises the macOS WebView layout engine. +// node --test resources/web/dialog/ColorPickerDialog/colorpicker.browser.test.js +const {test, before, after} = require("node:test"); +const assert = require("node:assert/strict"); +const {readFileSync} = require("node:fs"); +const {resolve} = require("node:path"); +const {pathToFileURL} = require("node:url"); +const engineName = process.env.ORCA_BROWSER_ENGINE || "chromium"; +let engine; +try { engine = require(process.env.ORCA_PLAYWRIGHT_MODULE || "playwright")[engineName]; } +catch (error) { if (error.code !== "MODULE_NOT_FOUND") throw error; } +const options = {skip: engine ? false : "Use an existing Playwright installation via ORCA_PLAYWRIGHT_MODULE"}; +const url = pathToFileURL(resolve(__dirname, "index.html")).href; +let browser; +before(async () => { if (engine) browser = await engine.launch({headless: true, ...(process.env.ORCA_BROWSER_EXECUTABLE ? {executablePath: process.env.ORCA_BROWSER_EXECUTABLE} : {})}); }); +after(async () => { if (browser) await browser.close(); }); +async function open(t, strings = {}, language = "") { + const page = await browser.newPage({viewport: {width: 600, height: 660}}), errors = []; + page.on("pageerror", error => errors.push(error.message)); + t.after(async () => { await page.close(); assert.deepEqual(errors, []); }); + await page.addInitScript(({table, language}) => { + window.ORCA_UI_STRINGS = table; + window.ORCA_COLOR_PICKER_LANGUAGE = language; + window.messages = []; + window.wx = {postMessage: text => messages.push(JSON.parse(text))}; + }, {table: strings, language}); + await page.goto(url); + const ready = await page.evaluate(() => messages[0]); + assert.equal(ready.command, "ready"); + return {page, ready, send: payload => page.evaluate(value => ColorPickerDialog.handleMessage(value), payload)}; +} +function init(ready, gradient = false, alpha = false) { + return {command: "init", page_id: ready.page_id, options: {allow_gradient: gradient, allow_alpha: alpha}, + selection: {type: "solid", colors: ["#01234567"]}, + ui: {sidebar_open: true, palette: "basic"}, + favorites: [{type: "gradient", colors: ["#FF000040", "#0000FF90"]}, {type: "solid", colors: ["#ABCDEF40"]}]}; +} +async function count(page, command) { return page.evaluate(value => messages.filter(message => message.command === value).length, command); } + +for (const gradient of [false, true]) for (const alpha of [false, true]) { + test(`Native capabilities gate gradient=${gradient} and alpha=${alpha}`, options, async t => { + const {page, ready, send} = await open(t); + assert.equal(await page.locator("#confirmBtn").isDisabled(), true); + assert.equal(await send({...init(ready), options: {allow_gradient: "yes", allow_alpha: alpha}}), false); + assert.equal(await send({...init(ready), page_id: "stale"}), false); + assert.equal(await send(init(ready, gradient, alpha)), true); + assert.equal(await page.locator("#modeControls").isVisible(), gradient); + assert.equal(await page.locator("#alphaControls").isVisible(), alpha); + assert.equal(await page.locator("#userColors button:not(.empty)").count(), gradient ? 2 : 1); + await page.locator("#userColors button:not(.empty)").last().click(); + const selection = await page.evaluate(() => ColorPickerDialog.exportSelection()); + assert.deepEqual(selection, {type: "solid", colors: [alpha ? "#ABCDEF40" : "#ABCDEFFF"]}); + assert.equal(await send(init(ready, gradient, alpha)), true); + assert.deepEqual(await page.evaluate(() => ColorPickerDialog.exportSelection()), selection, "repeated ready/init retains edits"); + await page.locator("#colorPreview").click(); + const favorites = await page.evaluate(() => messages.at(-1).favorites); + assert(favorites.some(favorite => favorite.colors[0] === "#ABCDEF40"), "original favorites retain their alpha"); + assert(favorites.some(favorite => favorite.type === "gradient"), "filtered gradients remain stored"); + await page.locator("#confirmBtn").click(); + assert.deepEqual(await page.evaluate(() => messages.at(-1).selection), selection); + }); +} + +test("Visible incomplete editors block confirmation and favorites until recovery", options, async t => { + const {page, ready, send} = await open(t); await send(init(ready, true, true)); + for (const [id, value, valid] of [["rValue", "", "21"], ["oValue", "", "50"], ["hexInput", "GG", "12345680"]]) { + await page.locator("#" + id).fill(value); + const confirms = await count(page, "confirm"), saves = await count(page, "update_favorites"); + await page.locator("#confirmBtn").evaluate(element => element.click()); await page.locator("#colorPreview").click(); + assert.equal(await count(page, "confirm"), confirms); assert.equal(await count(page, "update_favorites"), saves); + await page.locator("#" + id).fill(valid); await page.locator("#" + id).press("Enter"); + assert.equal(await count(page, "confirm"), confirms + 1); + } + await page.locator("#hValue").fill(""); // HSL is now visible beside RGB, so incomplete HSL must block. + const hslConfirms = await count(page, "confirm"); + await page.locator("#confirmBtn").evaluate(element => element.click()); + assert.equal(await count(page, "confirm"), hslConfirms); + await page.locator("#hValue").fill("100"); + const confirms = await count(page, "confirm"); await page.locator("#confirmBtn").click(); + assert.equal(await count(page, "confirm"), confirms + 1); + await page.locator("#rValue").fill(""); await page.locator("#rValue").press("Escape"); + assert.equal(await page.evaluate(() => messages.at(-1).command), "cancel"); +}); + +test("Numeric channel and opacity edits clamp to their field bounds", options, async t => { + const {page, ready, send} = await open(t); await send(init(ready, true, true)); + for (const [id, input, expected] of [["rValue", "999", "255"], ["rValue", "-1", "0"], + ["hValue", "361", "360"], ["oValue", "101", "100"], ["oValue", "-1", "0"]]) { + await page.locator("#" + id).fill(input); + assert.equal(await page.locator("#" + id).inputValue(), expected); + assert.equal(await page.locator("#" + id).evaluate(element => element.checkValidity()), true); + } + assert.equal((await page.evaluate(() => ColorPickerDialog.exportSelection())).colors[0].slice(-2), "00"); +}); + +test("Endpoint edits and favorites retain ordered byte alpha", options, async t => { + const {page, ready, send} = await open(t); + await send({...init(ready, true, true), selection: {type: "gradient", colors: ["#FF000001", "#0000FF80"]}}); + await page.locator(".gradient-thumb[data-index='1']").click(); + await page.locator('.color-item[title="Red"]').first().click(); + assert.deepEqual(await page.evaluate(() => ColorPickerDialog.exportSelection()), {type: "gradient", colors: ["#FF000001", "#FF000080"]}); + await page.locator("#hexInput").fill("0000FF80"); await page.locator(".gradient-thumb[data-index='0']").click(); + assert.equal(await page.locator("#hexInput").inputValue(), "FF000001"); + await page.locator("#colorPreview").click(); + assert.deepEqual(await page.evaluate(() => messages.at(-1).favorites[0]), {type: "gradient", colors: ["#FF000001", "#0000FF80"]}); +}); + +test("Read-only favorites remain selectable and late documents cannot initialize", options, async t => { + const {page, ready, send} = await open(t); await send({...init(ready), favorites_writable: false}); + assert.equal(await page.locator("#colorPreview").isDisabled(), true); + await page.locator("#userColors button:not(.empty)").first().click(); + assert.equal(await page.locator("#hexInput").inputValue(), "ABCDEF"); + await page.reload(); + assert.equal(await send(init(ready)), false); assert.equal(await page.locator("#confirmBtn").isDisabled(), true); +}); + +test("Document strings translate palette names and editor errors while retaining RAL codes and numeric labels", options, async t => { + const source = readFileSync(resolve(__dirname, "../../../../src/slic3r/GUI/ColorPickerDialog.cpp"), "utf8"); + const keys = [...source.matchAll(/_u8L\("([^"\n]+)"\)/g)].map(match => match[1]); + const strings = Object.fromEntries(keys.map(key => [key, "Translated: " + key])); + const {page, ready, send} = await open(t, strings); await send({...init(ready, true, true), options: {allow_gradient: true, allow_alpha: true, max_gradient_colors: 7}, preserve_multi_color: true}); + assert.equal(await page.title(), strings["Color Picker"]); + for (const element of await page.locator("[data-i18n]").all()) assert.equal(await element.textContent(), strings[await element.getAttribute("data-i18n")]); + for (const attribute of ["aria-label", "title"]) for (const element of await page.locator(`[data-i18n-${attribute}]`).all()) + assert.equal((await element.getAttribute(attribute)).split("\n")[0], strings[await element.getAttribute(`data-i18n-${attribute}`)]); + assert.equal(await page.locator("#selectionNotice").textContent(), strings["The current multi-color selection is kept until you choose a different color."]); + for (const palette of ["basic", "orca", "ral", "gradient"]) { + await page.selectOption("#paletteCombo", palette); + const names = await page.evaluate(value => ColorPickerPalettes[value].map(entry => entry.name), palette); + assert.deepEqual(await page.locator(".color-name").allTextContents(), names.map(name => { + const ral = name.match(/^(\d{4}) (.+)$/); return ral ? `${ral[1]} ${strings[ral[2]]}` : /^%\d+$/.test(name) ? name : strings[name]; + })); + } + await page.locator("#hexInput").fill("bad"); + assert.equal(await page.locator("#hexInput").evaluate(element => element.validationMessage), strings["Enter a complete hexadecimal color"]); + await page.locator("#hexInput").fill("12345680"); + for (const [id, invalid, valid, key] of [["rValue", "", "18", "Invalid input"], + ["oValue", "", "50", "Invalid input"]]) { + const confirms = await count(page, "confirm"); + await page.locator("#" + id).fill(invalid); + await page.locator("#confirmBtn").evaluate(element => element.click()); + await page.locator("#colorPreview").click(); // Saving invokes the same translated validation while OK is disabled. + assert.equal(await count(page, "confirm"), confirms); + assert.equal(await page.locator("#" + id).evaluate(element => element.validationMessage), strings[key]); + await page.locator("#" + id).fill(valid); + assert.equal(await page.locator("#" + id).evaluate(element => element.validationMessage), ""); + await page.locator("#confirmBtn").click(); + assert.equal(await count(page, "confirm"), confirms + 1); + } + await page.locator("#hValue").fill("361"); + await page.locator("#colorPreview").click(); + assert.equal(await page.locator("#hValue").inputValue(), "360", "out-of-range hue clamps at its boundary"); + await page.locator(".color-item").first().click(); + assert.equal(await page.locator("#hValue").evaluate(element => element.validationMessage), "", "a new palette selection clears the previous editor error"); +}); + +test("Document language and escaped labels preserve untranslated English fallback", options, async t => { + const cancel = '取消 "Cancel" \\ /\n下一行'; + const {page, ready, send} = await open(t, {Cancel: cancel}, "zh-CN"); + await send(init(ready)); + assert.equal(await page.locator("html").getAttribute("lang"), "zh-CN"); + assert.equal(await page.locator("#cancelBtn").textContent(), cancel); + assert.equal(await page.title(), "Color Picker"); + assert.equal(await page.locator(".color-name").first().textContent(), "Red"); + assert.deepEqual(await page.locator("label[for=hValue],label[for=sValue],label[for=lValue],label[for=rValue],label[for=gValue],label[for=bValue]").allTextContents(), ["H", "S", "L", "R", "G", "B"]); + assert.deepEqual(await page.evaluate(() => ColorPickerDialog.exportSelection()), {type: "solid", colors: ["#012345FF"]}); +}); + +test("Global control colors follow the system while host semantic colors follow the app", options, async t => { + const {page, ready, send} = await open(t); await send({...init(ready, true, true), selection: {type: "gradient", colors: ["#FF000001", "#0000FF80"]}}); + const initial = await page.evaluate(() => ({selection: ColorPickerDialog.exportSelection(), spectrum: document.getElementById("spectrum").toDataURL()})); + for (const theme of ["light", "dark"]) for (const system of ["light", "dark"]) { + await page.emulateMedia({colorScheme: system}); + await page.evaluate(value => { + document.documentElement.dataset.orcaTheme = value; + const colors = value === "light" ? {bg: "#ffffff", fg: "#262e30", muted: "#363636", border: "#dbdbdb", accent: "#009688"} : {bg: "#2d2d31", fg: "#efeff0", muted: "#b2b3b5", border: "#36363b", accent: "#00675b"}; + for (const [key, color] of Object.entries(colors)) document.documentElement.style.setProperty("--orca-" + key, color); + document.documentElement.style.setProperty("--orca-accent-fg", "#ffffff"); + }, theme); + await page.evaluate(async () => { + await Promise.all(document.getAnimations().map(animation => animation.finished.catch(() => {}))); + }); + const colors = await page.evaluate(() => { + const body = getComputedStyle(document.body); + const tokenColor = token => { + const probe = document.createElement("span"); probe.style.cssText = `position:absolute;visibility:hidden;color:var(${token})`; + document.body.appendChild(probe); const color = getComputedStyle(probe).color; probe.remove(); return color; + }; + const mode = document.querySelector("label[for=modeSwitch]"); + return {background: body.backgroundColor, foreground: body.color, + modeBackground: getComputedStyle(mode).backgroundColor, + accent: getComputedStyle(mode, "::before").backgroundColor, + hostPanel: tokenColor("--panel"), hostText: tokenColor("--text")}; + }); + assert.equal(colors.background, system === "light" ? "rgb(255, 255, 255)" : "rgb(45, 45, 49)", `${theme} app / ${system} system body`); + assert.equal(colors.foreground, system === "light" ? "rgb(38, 46, 48)" : "rgb(239, 239, 240)"); + assert.equal(colors.modeBackground, system === "light" ? "rgb(240, 240, 240)" : "rgb(36, 36, 40)"); + assert.equal(colors.accent, theme === "light" ? "rgb(0, 150, 136)" : "rgb(0, 103, 91)", "shared accent follows the injected app theme"); + assert.equal(colors.hostPanel, theme === "light" ? "rgb(255, 255, 255)" : "rgb(45, 45, 49)"); + assert.equal(colors.hostText, theme === "light" ? "rgb(38, 46, 48)" : "rgb(239, 239, 240)"); + assert.deepEqual(await page.evaluate(() => ({selection: ColorPickerDialog.exportSelection(), spectrum: document.getElementById("spectrum").toDataURL()})), initial); + for (const id of ["modeSwitch"]) for (const checked of [false, true]) { + if (await page.locator("#" + id).isChecked() !== checked) await page.locator(`label[for=${id}]`).click(); + await page.evaluate(async () => { + await Promise.all(document.getAnimations().map(animation => animation.finished.catch(() => {}))); + }); + const result = await page.locator(`label[for=${id}]`).evaluate((element, selected) => { + const tokenColor = token => { + const probe = document.createElement("span"); probe.style.cssText = `position:absolute;visibility:hidden;color:var(${token})`; + document.body.appendChild(probe); const color = getComputedStyle(probe).color; probe.remove(); return color; + }; + const labels = element.querySelectorAll(".switch-label"); + return {selected: getComputedStyle(labels[selected ? 1 : 0]).color, + unselected: getComputedStyle(labels[selected ? 0 : 1]).color, + accentForeground: tokenColor("--button-fg-light"), mutedForeground: tokenColor("--fg-color-label")}; + }, checked); + assert.equal(result.selected, result.accentForeground, `${theme} app / ${system} system ${id} selected text uses the accent foreground`); + assert.equal(result.selected, "rgb(254, 254, 254)", "global selected text stays light in both system schemes"); + assert.equal(result.unselected, result.mutedForeground, `${theme} app / ${system} system ${id} unselected text stays muted`); + assert.equal(result.unselected, system === "light" ? "rgb(54, 54, 54)" : "rgb(178, 179, 181)"); + const selected = await page.evaluate(() => ColorPickerDialog.exportSelection()); + assert.equal(selected.type, checked ? "gradient" : "solid"); + if (checked) assert.deepEqual(selected, initial.selection, "returning to gradient keeps ordered endpoint byte alpha"); + } + } +}); + +test("Sidebar resizes from a compact viewport and shrinks after every collapse", options, async t => { + const {page, ready, send} = await open(t); + await page.setViewportSize({width: 340, height: 380}); + await send({...init(ready, true, true), ui: {sidebar_open: false, palette: "basic"}}); + const selection = await page.evaluate(() => ColorPickerDialog.exportSelection()); + for (const [index, sidebarOpen] of [false, true, false, true, false].entries()) { + const width = sidebarOpen ? 510 : 340; + await page.waitForFunction(expected => messages.filter(message => message.command === "resize").at(-1)?.width === expected, width); + const size = await page.evaluate(() => messages.filter(message => message.command === "resize").at(-1)); + assert.equal(size.height, 380); + await page.setViewportSize({width: size.width, height: size.height}); + await page.waitForTimeout(100); + assert(await page.evaluate(() => { + const body = document.body.getBoundingClientRect(); + const picker = document.querySelector(".right-panel").getBoundingClientRect(); + return body.width === innerWidth && picker.right <= body.right && picker.bottom <= body.bottom; + }), "the host viewport contains the full picker"); + const resizes = await count(page, "resize"); + await page.waitForTimeout(100); + assert.equal(await count(page, "resize"), resizes, "fitting the host does not cause a resize loop"); + assert.deepEqual(await page.evaluate(() => ColorPickerDialog.exportSelection()), selection); + if (index < 4) await page.locator("#paletteToggle").click(); + } +}); + +test("Floating panel fits capabilities and wrapped translations without a resize feedback loop", options, async t => { + const strings = {Color: "A long translated color label", Gradient: "A long translated gradient label", Cancel: "A longer translated cancel action", OK: "A longer translated confirmation action", + "The current multi-color selection is kept until you choose a different color.": "A translated explanation that wraps across the compact floating panel and keeps the original multi-color selection until another color is chosen."}; + for (const gradient of [false, true]) for (const alpha of [false, true]) { + const {page, ready, send} = await open(t, strings); + await page.setViewportSize({width: 550, height: 520}); + await send({...init(ready, gradient, alpha), preserve_multi_color: true}); + await page.waitForFunction(() => messages.some(message => message.command === "resize")); + const height = await page.evaluate(() => messages.filter(message => message.command === "resize").at(-1).height); + assert(height >= 300 && height <= 900); + assert.equal(height, await page.locator("body").evaluate(element => Math.ceil(element.getBoundingClientRect().height))); + assert(await page.evaluate(() => messages.findIndex(message => message.command === "initialized") < messages.findIndex(message => message.command === "resize"))); + await page.setViewportSize({width: 550, height}); + await page.waitForTimeout(100); + const resizes = await count(page, "resize"); + await page.setViewportSize({width: 550, height: height + 100}); + await page.waitForTimeout(100); + assert.equal(await count(page, "resize"), resizes, "extra viewport height never changes intrinsic content height"); + const controls = await page.evaluate(() => { + const body = document.body.getBoundingClientRect(); + return ["confirmBtn", "cancelBtn", ...(document.getElementById("alphaControls").classList.contains("hidden") ? [] : ["oValue"])].map(id => { + const rect = document.getElementById(id).getBoundingClientRect(); + return rect.left >= body.left && rect.right <= body.right && rect.bottom <= body.bottom; + }); + }); + assert(controls.every(Boolean), "wrapped buttons and opacity fit inside the panel"); + const edited = await page.evaluate(() => ColorPickerDialog.exportSelection()); + await page.evaluate(() => { + document.getElementById("selectionNotice").textContent += " Additional wrapped text. ".repeat(15); + }); + await page.waitForFunction(previous => messages.filter(message => message.command === "resize").length > previous, resizes); + assert.deepEqual(await page.evaluate(() => ColorPickerDialog.exportSelection()), edited, "content resize leaves the selection untouched"); + // The compact layout now puts saved swatches to the right of both previews. + assert(await page.evaluate(() => { + const favorites = document.getElementById("userColors").getBoundingClientRect(); + const preview = document.getElementById("colorPreview").getBoundingClientRect(); + return favorites.left >= preview.right; + }), "favorites do not overlap the preview"); + await page.locator("#confirmBtn").scrollIntoViewIfNeeded(); + assert.equal(await page.locator("#confirmBtn").isVisible(), true); + } +}); + +test("Native focus hook waits for initialization and preserves active partial edits", options, async t => { + const {page, ready, send} = await open(t); + await page.evaluate(() => ColorPickerDialog.focusInput()); + assert.notEqual(await page.evaluate(() => document.activeElement.id), "hexInput"); + await send(init(ready, true, true)); + await page.evaluate(() => ColorPickerDialog.focusInput()); + assert.equal(await page.evaluate(() => document.activeElement.id), "hexInput"); + await page.locator("#rValue").fill(""); + const selection = await page.evaluate(() => ColorPickerDialog.exportSelection()); + await page.evaluate(() => ColorPickerDialog.focusInput()); + assert.equal(await page.evaluate(() => document.activeElement.id), "rValue"); + assert.equal(await page.locator("#rValue").inputValue(), ""); + assert.deepEqual(await page.evaluate(() => ColorPickerDialog.exportSelection()), selection); + assert.equal(await count(page, "confirm"), 0); + await page.locator("#rValue").press("Escape"); + assert.equal(await count(page, "cancel"), 1); +}); diff --git a/resources/web/dialog/ColorPickerDialog/colorpicker.js b/resources/web/dialog/ColorPickerDialog/colorpicker.js new file mode 100644 index 0000000000..42e82b8c6c --- /dev/null +++ b/resources/web/dialog/ColorPickerDialog/colorpicker.js @@ -0,0 +1,464 @@ +"use strict"; +(() => { + if (window.ORCA_COLOR_PICKER_LANGUAGE) document.documentElement.lang = window.ORCA_COLOR_PICKER_LANGUAGE; + const T = (key) => window.ORCA_UI_STRINGS?.[key] || key; + document.title = T("Color Picker"); + for (const element of document.querySelectorAll("[data-i18n]")) element.textContent = T(element.dataset.i18n); + for (const attribute of ["aria-label", "title", "placeholder"]) + for (const element of document.querySelectorAll(`[data-i18n-${attribute}]`)) + element.setAttribute(attribute, T(element.getAttribute(`data-i18n-${attribute}`))); + function paletteName(name) { + const ral = name.match(/^(\d{4}) (.+)$/); + return ral ? `${ral[1]} ${T(ral[2])}` : /^%\d+$/.test(name) ? name : T(name); + } + + const FAVORITE_SLOTS = 16; + const SPECTRUM_FOLLOWS_SATURATION = true; // true: the spectrum is drawn at the current saturation, false: it always shows full saturation + const MULTI_COLOR_GRADIENTS = true; // true: gradients may have more than 2 colors (− / + buttons and the multi-color palette entries), false: exactly 2 colors. + // up to the native limit the host sends as options.max_gradient_colors. + let maxGradientColors = 2; // Stays 2 until init, and with a host that does not send a limit. + + const model = window.ColorPickerModel; + const palettes = window.ColorPickerPalettes; + const byId = (id) => document.getElementById(id); + const canvas = byId("spectrum"), context = canvas.getContext("2d"); + const paletteCombo = byId("paletteCombo"), palette = byId("palette"), preview = byId("colorPreview"), confirmBtn = byId("confirmBtn"); + const modeSwitch = byId("modeSwitch"), satSlider = byId("satSlider"), opacitySlider = byId("opacitySlider"); + const gradientBar = byId("gradientCtrlBar"), gradientStops = byId("gradientStops"), gradientAdd = byId("gradientAdd"), gradientRemove = byId("gradientRemove"); + const hostGradientLimit = (options) => options.max_gradient_colors === undefined ? 2 : options.max_gradient_colors; + const originalPreview = byId("originalPreview"), palettePanel = byId("palettePanel"), paletteToggle = byId("paletteToggle"); + const paletteSelect = byId("paletteSelect"), paletteFilter = byId("paletteFilter"), paletteSearchToggle = byId("paletteSearchToggle"); + const rgbInputs = ["r", "g", "b"].map((name) => byId(name + "Value")); + const hslInputs = ["h", "s", "l"].map((name) => byId(name + "Value")); + const hexInput = byId("hexInput"), opacityInput = byId("oValue"); + const pageId = Date.now().toString(36) + Math.random().toString(36).slice(2); + let state = model.createState(), favorites = [], initialized = false, favoritesWritable = true; + // The selection the dialog opened with; the original swatch shows it and restores it on click. + let originalSelection = model.exportSelection(state); + let lastContentHeight = 0, lastContentWidth = 0, resizePending = false; + // Last HSL shown or set. Kept while it still produces the current color: hue 360 and 0 are + // both red, and greys, black and white have no hue or saturation in RGB. + let hslMemory = [0, 0, 50]; + function displayHsl(color) { + const rgb = model.hslToRgb(...hslMemory); + if (!rgb.every((value, index) => value === color[index])) hslMemory = model.rgbToHsl(...color.slice(0, 3)); + return hslMemory; + } + // Copy of `values` with one entry replaced. + const replaced = (values, index, value) => values.map((current, i) => i === index ? value : current); + function setHslChannel(index, value) { + hslMemory = replaced(displayHsl(model.activeColor(state)), index, value); + model.setRgb(state, model.hslToRgb(...hslMemory)); + } + const round1 = (value) => Math.round(value * 10) / 10; + + function emit(command, data = {}) { + window.dispatchEvent(new CustomEvent("color-picker-message", {detail: {command, ...data}})); + if (window.wx && typeof window.wx.postMessage === "function") + window.wx.postMessage(JSON.stringify({command, page_id: pageId, ...data})); + } + + function scheduleResize() { + if (!initialized || resizePending) return; + resizePending = true; + requestAnimationFrame(() => { + resizePending = false; + const rect = document.body.getBoundingClientRect(); + const height = Math.min(900 , Math.max(300, Math.ceil(rect.height))); + const width = Math.min(1200, Math.max(300, Math.ceil(rect.width ))); + if (height !== lastContentHeight || width !== lastContentWidth) { + lastContentHeight = height; lastContentWidth = width; + emit("resize", {width, height}); + } + }); + } + new ResizeObserver(scheduleResize).observe(document.body); + + function setSwatch(element, selection) { + element.style.setProperty("--swatch-color", model.selectionBackground(selection)); + } + + // `editing` is the control the user is typing in. Its text is left alone so the caret, + // partial input like "05" and fine steps that round-trip through bytes aren't overwritten. + function render(resetEditors = false, editing = null) { + const color = model.activeColor(state), hsl = displayHsl(color); + const show = (input, value) => { + if (input === editing) return; + if (resetEditors) input.setCustomValidity(""); + if (resetEditors || input.checkValidity()) input.value = value; + }; + show(hexInput, model.toHex(color).slice(1, state.options.allow_alpha ? 9 : 7)); + rgbInputs.forEach((input, index) => show(input, color[index])); + // Shown in whole numbers; decimals can still be typed, and hslMemory keeps exact values. + hslInputs.forEach((input, index) => show(input, Math.round(hsl[index]))); + // Percentage is a display only until explicitly edited. Never round it back + // into the alpha byte on endpoint switches, RGB edits, or confirmation. + show(opacityInput, Math.round(color[3] / 255 * 100)); + if (opacitySlider !== editing) opacitySlider.value = color[3] / 255 * 100; + const rgb = color.slice(0, 3).join(","); + opacitySlider.style.setProperty("--track-gradient", `linear-gradient(to right, rgba(${rgb},0), rgb(${rgb}))`); + opacitySlider.style.setProperty("--thumb-color", `rgba(${rgb},${color[3] / 255})`); + if (satSlider !== editing) satSlider.value = hsl[1]; + satSlider.style.setProperty("--track-gradient", `linear-gradient(to right, hsl(${hsl[0]},0%,${hsl[2]}%), hsl(${hsl[0]},100%,${hsl[2]}%))`); + satSlider.style.setProperty("--thumb-color", `hsl(${hsl[0]},${hsl[1]}%,${hsl[2]}%)`); + modeSwitch.checked = state.mode === "gradient"; + const selection = model.exportSelection(state); + // Kept in the layout (visibility only) so the grid doesn't shift between Solid and Gradient. + gradientBar.classList.toggle("invisible", state.mode !== "gradient"); + if (state.mode === "gradient") renderGradient(selection); + setSwatch(preview, selection); + preview.title = `${T("Save current color")}\n${selection.colors.join(" → ")}`; + if (SPECTRUM_FOLLOWS_SATURATION && hsl[1] !== spectrumSaturation) { spectrumSaturation = hsl[1]; paintSpectrum(); } + updateIndicator(hsl); + updateConfirm(); + } + + // One thumb per gradient color, evenly spaced; rebuilt only when the number of colors changes. + function renderGradient(selection) { + const count = selection.colors.length; + setSwatch(gradientStops.querySelector(".gradient-track"), selection); + let thumbs = [...gradientStops.querySelectorAll(".gradient-thumb")]; + if (thumbs.length !== count) { + thumbs.forEach((thumb) => thumb.remove()); + thumbs = selection.colors.map((_, index) => { + const thumb = document.createElement("button"); + thumb.type = "button"; thumb.className = "gradient-thumb"; thumb.dataset.index = index; + thumb.style.setProperty("--stop", index / (count - 1)); + thumb.setAttribute("aria-label", count === 2 ? T(index ? "Gradient end" : "Gradient start") : `${T("Gradient color")} ${index + 1}`); + gradientStops.appendChild(thumb); + return thumb; + }); + } + thumbs.forEach((thumb, index) => { + thumb.style.setProperty("--thumb-color", selection.colors[index]); + thumb.setAttribute("aria-pressed", String(state.active === index)); + }); + + const multi = maxGradientColors > 2; // −/+ buttons only exist when more than 2 colors are allowed; − also needs a color to spare. + gradientAdd.parentElement.classList.toggle("hidden", !multi); + gradientRemove.classList.toggle("hidden", !multi || count <= 2); + gradientAdd.classList.toggle("hidden", !multi); + // aria-disabled instead of disabled: a disabled button gets no hover, so its tooltip would never show. + const full = count >= maxGradientColors; + gradientAdd.setAttribute("aria-disabled", String(full)); + gradientAdd.title = full ? T("Up to %d colors supported.").replace("%d", maxGradientColors) : T("Add color"); + } + + // Every editor the user can see; alpha only when this caller allows it. + function editors() { + const inputs = [hexInput, ...rgbInputs, ...hslInputs]; + if (state.options.allow_alpha) inputs.push(opacityInput); + return inputs; + } + // OK stays disabled while any visible editor holds an invalid or incomplete value. + function updateConfirm() { + confirmBtn.disabled = !editors().every((input) => input.checkValidity()); + } + + // Matches the filter against the shown (translated) name, the English name and the hex digits. + function paletteMatches(entry, query) { + if (!query) return true; + const haystack = [paletteName(entry.name), entry.name, entry.hex || "", entry.grad || ""].join(" ").toLowerCase(); + return query.split(/\s+/).every((word) => haystack.includes(word.replace(/^#/, ""))); + } + + function populatePalette() { + palette.replaceChildren(); + const query = paletteSearchToggle.getAttribute("aria-pressed") === "true" ? paletteFilter.value.trim().toLowerCase() : ""; + for (const entry of palettes[paletteCombo.value] || []) { + const selection = {type: entry.grad ? "gradient" : "solid", colors: entry.grad ? entry.grad.split("-") : [entry.hex]}; + if (!model.normalizeSelection(selection, state.options, maxGradientColors) || !paletteMatches(entry, query)) continue; + const row = document.createElement("button"); row.type = "button"; row.className = "color-item"; + const swatch = document.createElement("span"); swatch.className = "color-swatch"; + const name = document.createElement("span"); name.className = "color-name"; name.textContent = paletteName(entry.name); + setSwatch(swatch, model.normalizeSelection(selection, {allow_gradient: true, allow_alpha: true}, maxGradientColors)); + row.append(swatch, name); row.title = paletteName(entry.name); + row.addEventListener("click", () => { + // Solid palette colors edit the active endpoint while gradient mode is on. + const changed = entry.grad ? model.applySelection(state, selection) : model.setHex(state, entry.hex); + if (changed) render(true); + }); + palette.appendChild(row); + } + byId("paletteEmpty").classList.toggle("hidden", palette.childElementCount > 0); + palette.classList.toggle("hidden", palette.childElementCount === 0); + } + + // Sidebar state the host keeps in the app config: open/closed and the chosen palette. + const uiState = () => ({sidebar_open: palettePanel.classList.contains("open"), palette: paletteCombo.value}); + function setSidebarOpen(open) { + palettePanel.classList.toggle("open", open); + paletteToggle.setAttribute("aria-expanded", String(open)); + } + // The search button swaps the combo box for a filter box; closing it clears the filter. + function setPaletteSearch(open) { + paletteSearchToggle.setAttribute("aria-pressed", String(open)); + paletteSelect.classList.toggle("hidden", open); + paletteFilter.classList.toggle("hidden", !open); + if (open) paletteFilter.focus(); + else paletteFilter.value = ""; + populatePalette(); + } + + function drawFavorites() { + const grid = byId("userColors"); grid.replaceChildren(); + // Favorites keep every color, but only those this page can select are shown. + const visible = favorites.filter((favorite) => model.normalizeSelection(favorite, {...state.options, allow_alpha: true}, maxGradientColors)); + for (let index = 0; index < FAVORITE_SLOTS; ++index) { + const swatch = document.createElement("button"); swatch.type = "button"; swatch.className = "saved-swatch"; + const favorite = visible[index]; + if (favorite) { + setSwatch(swatch, favorite); swatch.title = favorite.colors.join(" → "); + swatch.addEventListener("click", () => { if (model.applySelection(state, favorite)) render(true); }); + } else { swatch.classList.add("empty"); swatch.disabled = true; } + grid.appendChild(swatch); + } + } + + function init(payload) { + if (!payload || typeof payload !== "object" || (payload.options !== undefined && (!payload.options || typeof payload.options !== "object"))) return false; + maxGradientColors = Math.min(MULTI_COLOR_GRADIENTS ? model.MAX_GRADIENT_COLORS : 2, hostGradientLimit(payload.options || {})); + // A gradient the host allows but this page does not (switch off) opens as its first color, like + // other multi-color selections the picker cannot edit; OK then replaces it, Cancel keeps it. + let selection = payload.selection, preserve = payload.preserve_multi_color === true; + if (selection?.type === "gradient" && selection.colors?.length > maxGradientColors) { + selection = {type: "solid", colors: [selection.colors[0]]}; + preserve = true; + } + const next = model.createState({...payload.options, max_gradient_colors: maxGradientColors}, selection); + if (!next) return false; + byId("selectionNotice").hidden = !preserve; + state = next; favorites = model.normalizeFavorites(payload.favorites || []); + originalSelection = model.exportSelection(state); + setSwatch(originalPreview, originalSelection); + originalPreview.title = `${T("Original color")}\n${originalSelection.colors.join(" → ")}`; + favoritesWritable = payload.favorites_writable !== false; + preview.disabled = !favoritesWritable; + byId("modeControls").classList.toggle("hidden", !state.options.allow_gradient); + for (const option of paletteCombo.options) option.hidden = option.value === "gradient" && !state.options.allow_gradient; + const ui = payload.ui || {}; // Restore the sidebar the way it was left; a palette this caller can't use falls back to basic. + const saved = [...paletteCombo.options].find((option) => option.value === ui.palette && !option.hidden); + if (saved) paletteCombo.value = saved.value; + if (!state.options.allow_gradient && paletteCombo.value === "gradient") paletteCombo.value = "basic"; + setSidebarOpen(ui.sidebar_open === true); + byId("alphaControls").classList.toggle("hidden", !state.options.allow_alpha); + hexInput.maxLength = state.options.allow_alpha ? 9 : 7; + populatePalette(); drawFavorites(); render(true); + initialized = true; + byId("pickerEditors").disabled = false; + return true; + } + + function focusInput() { + if (!initialized) return; + // Restore initial keyboard focus without replacing an editor's in-progress input. + const active = document.activeElement; + if (!active || active === document.body || active === document.documentElement) + hexInput.focus({preventScroll: true}); + } + + function handleMessage(payload) { + if (!payload || typeof payload !== "object" || payload.page_id !== pageId) return false; + if (payload.command === "init") { + const options = payload.options; + const hostLimit = options && hostGradientLimit(options); + if (!options || typeof options.allow_gradient !== "boolean" || typeof options.allow_alpha !== "boolean" || + !Number.isInteger(hostLimit) || hostLimit < 2 || hostLimit > model.MAX_GRADIENT_COLORS || + !model.normalizeSelection(payload.selection, options, hostLimit) || !validFavorites(payload.favorites) || + (payload.favorites_writable !== undefined && typeof payload.favorites_writable !== "boolean") || + (payload.preserve_multi_color !== undefined && typeof payload.preserve_multi_color !== "boolean") || + (payload.ui !== undefined && (!payload.ui || typeof payload.ui !== "object" || + (payload.ui.sidebar_open !== undefined && typeof payload.ui.sidebar_open !== "boolean") || + (payload.ui.palette !== undefined && typeof payload.ui.palette !== "string")))) return false; + if (initialized) return true; + if (!init(payload)) return false; + emit("initialized"); + scheduleResize(); + return true; + } + if (payload.command === "favorites" && initialized && validFavorites(payload.favorites)) { + favorites = model.normalizeFavorites(payload.favorites); drawFavorites(); return true; + } + return false; + } + + function validFavorites(values) { + return Array.isArray(values) && values.length <= 24 && values.every((value) => model.normalizeSelection(value, {allow_gradient: true, allow_alpha: true}, model.MAX_GRADIENT_COLORS)); + } + + // Saturation the spectrum is currently painted at (always 100 when the option is off). + let spectrumSaturation = 100; + function drawSpectrum() { + const rect = canvas.getBoundingClientRect(); + canvas.width = Math.max(1, Math.round(rect.width)); canvas.height = Math.max(1, Math.round(rect.height)); + paintSpectrum(); + } + function paintSpectrum() { + const hue = context.createLinearGradient(0, 0, canvas.width, 0); + for (let i = 0; i <= 6; ++i) hue.addColorStop(i / 6, `hsl(${i * 60},100%,50%)`); + context.fillStyle = hue; context.fillRect(0, 0, canvas.width, canvas.height); + const shade = context.createLinearGradient(0, 0, 0, canvas.height); + shade.addColorStop(0, "#FFFFFF"); shade.addColorStop(0.5, "#FFFFFF00"); shade.addColorStop(0.5, "#00000000"); shade.addColorStop(1, "#000000"); + context.fillStyle = shade; context.fillRect(0, 0, canvas.width, canvas.height); + // HSL desaturation blends each color toward the grey of the same lightness, and on this + // canvas that grey only depends on the row (white at the top, black at the bottom). + if (spectrumSaturation < 100) { + const grey = context.createLinearGradient(0, 0, 0, canvas.height); + grey.addColorStop(0, "#FFFFFF"); grey.addColorStop(1, "#000000"); + context.globalAlpha = 1 - spectrumSaturation / 100; + context.fillStyle = grey; context.fillRect(0, 0, canvas.width, canvas.height); + context.globalAlpha = 1; + } + } + + const indicator = byId("colorIndicator"); + function updateIndicator(hsl) { + indicator.style.left = hsl[0] / 360 * (canvas.width - 1) + "px"; + indicator.style.top = (1 - hsl[2] / 100) * (canvas.height - 1) + "px"; + } + + // Spectrum position maps straight to hue (x, 0..360) and lightness (y, 100..0); saturation is + // the third dimension and comes from the slider below. hslMemory keeps these exact values, + // including hue 360 at the right edge and the hue of the white and black corners. + function sampleSpectrum(event) { + const rect = canvas.getBoundingClientRect(); + const fraction = (client, start, size, pixels) => + Math.max(0, Math.min(pixels - 1, Math.floor((client - start) * pixels / size))) / Math.max(1, pixels - 1); + const saturation = displayHsl(model.activeColor(state))[1]; + hslMemory = [round1(fraction(event.clientX, rect.left, rect.width, canvas.width) * 360), saturation, + round1((1 - fraction(event.clientY, rect.top, rect.height, canvas.height)) * 100)]; + model.setRgb(state, model.hslToRgb(...hslMemory)); + // A pick replaces every editor's value, so clear any invalid state left from typing. + render(true); + } + + const clamp = (control, value) => Math.min(Number(control.max), Math.max(Number(control.min), value)); + function bindValue(control, apply) { + // Number fields are required, so an empty field is invalid; a range input always has a value. + if (control.type === "number") control.required = true; + const commit = (value) => { apply(value); render(false, control); }; + control.addEventListener("input", () => { + control.setCustomValidity(""); + // Out-of-range numbers snap to the field's min/max instead of becoming invalid. + if (control.value !== "") { + const clamped = clamp(control, Number(control.value)); + if (clamped !== Number(control.value)) control.value = clamped; + } + if (control.checkValidity()) commit(Number(control.value)); + }); + control.addEventListener("wheel", (event) => { + event.preventDefault(); + const step = control.type === "range" ? 5 : 1; + control.value = clamp(control, (Number(control.value) || 0) + (event.deltaY > 0 ? -step : step)); + control.setCustomValidity(""); + commit(Number(control.value)); + }, {passive: false}); + // Normalize the text (e.g. "05" → "5") once the user leaves the field. + control.addEventListener("blur", () => { if (control.checkValidity()) render(); }); + } + + rgbInputs.forEach((input, index) => bindValue(input, (value) => model.setRgb(state, replaced(model.activeColor(state).slice(0, 3), index, value)))); + hslInputs.forEach((input, index) => bindValue(input, (value) => setHslChannel(index, value))); + bindValue(satSlider, (value) => setHslChannel(1, value)); + bindValue(opacityInput, (value) => model.setAlphaPercent(state, value)); + bindValue(opacitySlider, (value) => model.setAlphaPercent(state, value)); + // The opacity slider takes the gradient bar's row while the opacity field or the slider is in + // use: it opens when the field is clicked or focused, and closes on a click or focus elsewhere. + const thirdRow = byId("thirdRow"), alphaControls = byId("alphaControls"); + const inOpacityControls = (target) => target instanceof Element && target.closest("#alphaControls, #opacitySliderBar"); + const setOpacitySliderOpen = (open) => thirdRow.classList.toggle("opacity-open", open); + for (const type of ["focusin", "click"]) alphaControls.addEventListener(type, () => setOpacitySliderOpen(true)); + for (const type of ["pointerdown", "focusin"]) + document.addEventListener(type, (event) => { if (!inOpacityControls(event.target)) setOpacitySliderOpen(false); }); + hexInput.addEventListener("input", () => { + const text = hexInput.value; + const valid = model.setHex(state, text.startsWith("#") ? text : "#" + text); + hexInput.setCustomValidity(valid ? "" : T("Enter a complete hexadecimal color")); + // render() leaves the typed text alone, so alpha isn't appended after the sixth digit. + if (valid) render(false, hexInput); + }); + hexInput.addEventListener("blur", () => { if (hexInput.checkValidity()) render(); }); + modeSwitch.addEventListener("change", () => { + + setOpacitySliderOpen(false); // Close the opacity slider before the row changes, so it doesn't linger over the gradient bar. + model.setMode(state, modeSwitch.checked ? "gradient" : "solid"); + render(true); + }); + // Click on the track selects the nearest thumb. Keyboard users tab to a thumb and press Enter or Space. + gradientStops.addEventListener("click", (event) => { + if (state.mode !== "gradient") return; + // A thumb is read from its index: a keyboard-triggered click has no pointer position. + const thumb = event.target.closest(".gradient-thumb"), rect = gradientStops.getBoundingClientRect(); + const last = state.gradient.length - 1; + // Thumbs sit at 10px + (width - 20px) * i / last, matching the CSS. + const fraction = Math.max(0, Math.min(1, (event.clientX - rect.left - 10) / Math.max(1, rect.width - 20))); + const index = thumb ? Number(thumb.dataset.index) : Math.round(fraction * last); + if (index !== state.active) { state.active = index; render(true); } + }); + gradientAdd.addEventListener("click", () => { if (gradientAdd.getAttribute("aria-disabled") !== "true" && model.addGradientColor(state)) render(true); }); + gradientRemove.addEventListener("click", () => { if (model.removeGradientColor(state)) render(true); }); + originalPreview.addEventListener("click", () => { if (model.applySelection(state, originalSelection)) render(true); }); + paletteToggle.addEventListener("click", () => { + setSidebarOpen(!palettePanel.classList.contains("open")); + if (initialized) emit("update_ui", uiState()); + }); + paletteCombo.addEventListener("change", () => { + populatePalette(); + if (initialized) emit("update_ui", uiState()); + }); + paletteSearchToggle.addEventListener("click", () => setPaletteSearch(paletteSearchToggle.getAttribute("aria-pressed") !== "true")); + paletteFilter.addEventListener("input", populatePalette); + // Pointer capture ends by itself on pointerup/pointercancel, so it doubles as the drag state. + canvas.addEventListener("pointerdown", (event) => { canvas.setPointerCapture(event.pointerId); sampleSpectrum(event); }); + canvas.addEventListener("pointermove", (event) => { if (canvas.hasPointerCapture(event.pointerId)) sampleSpectrum(event); }); + // The spectrum takes the panel's free height, which also changes with the notice, alpha and + // gradient controls, so redraw on any size change rather than on window resize only. + new ResizeObserver(() => { drawSpectrum(); updateIndicator(displayHsl(model.activeColor(state))); }).observe(canvas.parentElement); + // Bubble phase on document runs after each editor's own handler has set its validity. + document.addEventListener("input", updateConfirm); + // Hex keeps its own message from its input handler; number fields get a translated one. + function validateEditors() { + const inputs = editors(); + for (const input of inputs.filter((input) => input !== hexInput)) { + input.setCustomValidity(""); + const {valid, rangeUnderflow, rangeOverflow} = input.validity; + if (!valid) input.setCustomValidity(T(rangeUnderflow || rangeOverflow ? "Value is out of range." : "Invalid input")); + } + return inputs.every((input) => input.reportValidity()); + } + function confirm() { + if (initialized && validateEditors()) emit("confirm", {selection: model.exportSelection(state)}); + } + preview.addEventListener("click", () => { + if (!initialized || !favoritesWritable || !validateEditors()) return; + const selection = model.exportSelection(state); + const key = JSON.stringify(selection); + favorites = [selection, ...favorites.filter((favorite) => JSON.stringify(favorite) !== key)].slice(0, FAVORITE_SLOTS); + drawFavorites(); emit("update_favorites", {favorites}); + }); + confirmBtn.addEventListener("click", confirm); + byId("cancelBtn").addEventListener("click", () => emit("cancel")); + document.addEventListener("keydown", (event) => { + // Escape in the filter box closes the search first; a second Escape cancels the dialog. + if (event.key === "Escape" && event.target === paletteFilter) { event.preventDefault(); setPaletteSearch(false); paletteSearchToggle.focus(); } + else if (event.key === "Escape") { event.preventDefault(); emit("cancel"); } + else if (event.key === "Enter" && event.target instanceof HTMLInputElement && ["text", "number"].includes(event.target.type)) { + event.preventDefault(); confirm(); + } + }); + + window.ColorPickerDialog = { + init, handleMessage, focusInput, exportSelection: () => model.exportSelection(state), + setFavorites: (values) => { favorites = model.normalizeFavorites(values); drawFavorites(); } + }; + // The host passes the saved sidebar state before the page loads (the same values come again in + // init), so the first paint already matches the window size the host opened with. + const earlyUi = window.ORCA_COLOR_PICKER_UI; + if (earlyUi && typeof earlyUi === "object") { + if (typeof earlyUi.palette === "string" && [...paletteCombo.options].some((option) => option.value === earlyUi.palette)) + paletteCombo.value = earlyUi.palette; + if (typeof earlyUi.sidebar_open === "boolean") setSidebarOpen(earlyUi.sidebar_open); + } + setSwatch(originalPreview, originalSelection); + drawSpectrum(); populatePalette(); drawFavorites(); render(true); emit("ready"); +})(); diff --git a/resources/web/dialog/ColorPickerDialog/colorpicker.test.js b/resources/web/dialog/ColorPickerDialog/colorpicker.test.js new file mode 100644 index 0000000000..2619a84930 --- /dev/null +++ b/resources/web/dialog/ColorPickerDialog/colorpicker.test.js @@ -0,0 +1,103 @@ +"use strict"; +const {test} = require("node:test"); +const assert = require("node:assert/strict"); +const model = require("./colorpicker-model.js"); + +test("complete RGB/RGBA hex accepts mixed case and rejects partial or unbounded input", () => { + assert.deepEqual(model.parseHex("#Ab12cD80"), [171,18,205,128]); + for (const hex of ["", "123456", "#123", "#1234567", "#123456789", "#12345Z", "#123456GG", " #123456", null]) assert.equal(model.parseHex(hex), null); + for (let byte = 0; byte <= 255; ++byte) { + const hex = model.toHex([byte, byte, byte, byte]); + assert.deepEqual(model.parseHex(hex), [byte,byte,byte,byte]); + } +}); + +test("HSL conversion preserves primary channels and hue wraps at 360", () => { + for (const rgb of [[255,0,0], [0,255,0], [0,0,255], [128,128,128], [37,129,240]]) assert.deepEqual(model.hslToRgb(...model.rgbToHsl(...rgb)), rgb); + assert.deepEqual(model.hslToRgb(360,100,50), [255,0,0]); + assert.deepEqual(model.hslToRgb(240,100,50), [0,0,255]); +}); + +test("capabilities default off and reject gradient through every model entry", () => { + const gradient = {type: "gradient", colors: ["#FF000080", "#0000FF20"]}; + const state = model.createState(); + assert.deepEqual(state.options, {allow_gradient: false, allow_alpha: false}); + assert.equal(model.createState({}, gradient), null); + assert.equal(model.applySelection(state, gradient), false); + assert.equal(model.setMode(state, "gradient"), false); + assert.equal(model.setAlphaPercent(state, 20), false); + assert.equal(model.createState({allow_alpha: true}, gradient), null); + const opaque = model.createState({allow_gradient: true}, gradient); + assert.deepEqual(model.exportSelection(opaque), {type: "gradient", colors: ["#FF0000FF", "#0000FFFF"]}); +}); + +test("gradient endpoint edits keep order and preserve unedited byte alpha", () => { + const initial = {type: "gradient", colors: ["#FF000001", "#0000FF80"]}; + const state = model.createState({allow_gradient: true, allow_alpha: true}, initial); + state.active = 0; model.setRgb(state, [0,255,0]); + state.active = 1; model.setHex(state, "#112233"); + assert.deepEqual(model.exportSelection(state), {type: "gradient", colors: ["#00FF0001", "#11223380"]}); + model.setAlphaPercent(state, 0); + assert.deepEqual(model.exportSelection(state).colors, ["#00FF0001", "#11223300"]); + model.setAlphaPercent(state, 100); + assert.equal(model.exportSelection(state).colors[1], "#112233FF"); + assert.deepEqual(initial.colors, ["#FF000001", "#0000FF80"]); +}); + +test("favorites retain alpha and gradient even when the selection disables them", () => { + const favorite = {type: "solid", colors: ["#ABCDEF80"]}; + const gradient = {type: "gradient", colors: ["#FFFFFF00", "#00000010"]}; + const favorites = model.normalizeFavorites([favorite, gradient]); + const state = model.createState(); + assert.equal(model.applySelection(state, favorites[0]), true); + assert.deepEqual(model.exportSelection(state), {type: "solid", colors: ["#ABCDEFFF"]}); + assert.deepEqual(favorites, [favorite, gradient]); + assert.equal(model.applySelection(state, favorites[1]), false); + assert.equal(model.normalizeFavorites(Array(25).fill(favorite)).length, 24); +}); + +test("invalid cardinality and channel edits cannot change a selection", () => { + const state = model.createState({allow_alpha: true}); + const before = model.exportSelection(state); + for (const value of [{type: "solid", colors: []}, {type: "solid", colors: ["#112233", "#445566"]}, {type: "gradient", colors: ["#112233"]}, {type: "other", colors: ["#112233"]}]) assert.equal(model.applySelection(state, value), false); + assert.equal(model.setHex(state, "#GG2233"), false); + for (const value of [-1, 101, NaN, Infinity]) assert.equal(model.setAlphaPercent(state, value), false); + assert.equal(model.setRgb(state, [256,0,0]), false); + assert.deepEqual(model.exportSelection(state), before); +}); + +test("multi-color gradients stay at 2 colors unless the caller raises the limit", () => { + const five = {type: "gradient", colors: ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#00FFFF"]}; + assert.equal(model.normalizeSelection(five, {allow_gradient: true}), null); + assert.equal(model.createState({allow_gradient: true}, five), null); + const state = model.createState({allow_gradient: true, max_gradient_colors: 7}, five); + assert.equal(state.gradient.length, 5); + assert.equal(model.normalizeSelection({...five, colors: Array(8).fill("#000000")}, {allow_gradient: true}, 7), null); + assert.equal(model.createState({allow_gradient: true, max_gradient_colors: 99}).maxGradientColors, model.MAX_GRADIENT_COLORS); + assert.equal(model.normalizeFavorites([five]).length, 1, "favorites keep every color"); +}); + +test("adding and removing gradient colors keeps order, selection and the 2..max limits", () => { + const state = model.createState({allow_gradient: true, allow_alpha: true, max_gradient_colors: 4}, {type: "gradient", colors: ["#FF000080", "#0000FFFF"]}); + assert.equal(model.removeGradientColor(state), false, "never fewer than 2"); + assert.equal(model.addGradientColor(state), true); + assert.deepEqual(model.exportSelection(state).colors, ["#FF000080", "#BFFF0080", "#0000FFFF"], "selected color turned 75° in hue, alpha kept"); + assert.equal(model.NEW_GRADIENT_HUE_SHIFT, 75); + assert.equal(state.active, 1); + state.active = 2; assert.equal(model.addGradientColor(state), true); + assert.equal(state.active, 3); assert.equal(state.gradient.length, 4); + assert.equal(model.addGradientColor(state), false, "limit reached"); + state.active = 1; assert.equal(model.removeGradientColor(state), true); + assert.deepEqual(model.exportSelection(state).colors.slice(0, 2), ["#FF000080", "#0000FFFF"]); + assert.equal(state.active, 1); + assert.equal(model.applySelection(state, {type: "gradient", colors: ["#111111", "#222222"]}), true); + state.active = 1; model.addGradientColor(state); model.addGradientColor(state); + model.applySelection(state, {type: "gradient", colors: ["#111111", "#222222"]}); + assert.equal(state.active, 1, "a shorter gradient clamps the selected color"); +}); + +test("a new gradient starts at the solid color and its 75° hue neighbour", () => { + const state = model.createState({allow_gradient: true, allow_alpha: true}, {type: "solid", colors: ["#FF000080"]}); + assert.equal(model.setMode(state, "gradient"), true); + assert.deepEqual(model.exportSelection(state).colors, ["#FF000080", "#BFFF0080"]); +}); diff --git a/resources/web/dialog/ColorPickerDialog/index.html b/resources/web/dialog/ColorPickerDialog/index.html new file mode 100644 index 0000000000..9f6f332af8 --- /dev/null +++ b/resources/web/dialog/ColorPickerDialog/index.html @@ -0,0 +1,154 @@ + + +Color Picker + + + + + + + + + +
+
+ +
+
+ +
+
+
+ +
+ + +
+
+ +
+
+
+ +
+
+ +
+
+
+
+
+ + +
+
+ + +
+ +
+ + +
+ + + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + +
+
+ +
+ +
+ + +
+
+
+
+ diff --git a/resources/web/dialog/ColorPickerDialog/palettes.js b/resources/web/dialog/ColorPickerDialog/palettes.js new file mode 100644 index 0000000000..4f65f101db --- /dev/null +++ b/resources/web/dialog/ColorPickerDialog/palettes.js @@ -0,0 +1,459 @@ +"use strict"; +(() => { +const cssColors = [ + {hex: "#FF0000", name: "Red"}, + {hex: "#DC143C", name: "Crimson"}, + {hex: "#FF4500", name: "Orange red"}, + {hex: "#FF6347", name: "Tomato"}, + {hex: "#FFA500", name: "Orange"}, + {hex: "#FF8C00", name: "Dark orange"}, + {hex: "#FFD700", name: "Gold"}, + {hex: "#DEB887", name: "Burly wood"}, + {hex: "#FFE4C4", name: "Bisque"}, + {hex: "#FFE4E1", name: "Misty rose"}, + {hex: "#FFFF00", name: "Yellow"}, + {hex: "#FFFFE0", name: "Light yellow"}, + {hex: "#FFFACD", name: "Lemon chiffon"}, + {hex: "#F5F5DC", name: "Beige"}, + {hex: "#FAFAD2", name: "Light golden rod yellow"}, + {hex: "#EEE8AA", name: "Pale golden rod"}, + {hex: "#F0E68C", name: "Khaki"}, + {hex: "#BDB76B", name: "Dark khaki"}, + {hex: "#FFDAB9", name: "Peach puff"}, + {hex: "#FFE4B5", name: "Moccasin"}, + {hex: "#FFEFD5", name: "Papaya whip"}, + {hex: "#FFEBCD", name: "Blanched almond"}, + {hex: "#FAEBD7", name: "Antique white"}, + {hex: "#FFDEAD", name: "Navajo white"}, + {hex: "#F5DEB3", name: "Wheat"}, + {hex: "#DAA520", name: "Golden rod"}, + {hex: "#D2691E", name: "Chocolate"}, + {hex: "#CD853F", name: "Peru"}, + {hex: "#B22222", name: "Fire brick"}, + {hex: "#A0522D", name: "Sienna"}, + {hex: "#8B4513", name: "Saddle brown"}, + {hex: "#A52A2A", name: "Brown"}, + {hex: "#BC8F8F", name: "Rosy brown"}, + {hex: "#FA8072", name: "Salmon"}, + {hex: "#FFA07A", name: "Light salmon"}, + {hex: "#E9967A", name: "Dark salmon"}, + {hex: "#F4A460", name: "Sandy brown"}, + {hex: "#FF7F50", name: "Coral"}, + {hex: "#F08080", name: "Light coral"}, + {hex: "#CD5C5C", name: "Indian red"}, + {hex: "#8B0000", name: "Dark red"}, + {hex: "#800000", name: "Maroon"}, + {hex: "#ADFF2F", name: "Green yellow"}, + {hex: "#9ACD32", name: "Yellow green"}, + {hex: "#7FFF00", name: "Chartreuse"}, + {hex: "#7CFC00", name: "Lawn green"}, + {hex: "#32CD32", name: "Lime green"}, + {hex: "#00FF7F", name: "Spring green"}, + {hex: "#00FA9A", name: "Medium spring green"}, + {hex: "#00FF00", name: "Lime"}, + {hex: "#008000", name: "Green"}, + {hex: "#228B22", name: "Forest green"}, + {hex: "#006400", name: "Dark green"}, + {hex: "#6B8E23", name: "Olive drab"}, + {hex: "#808000", name: "Olive"}, + {hex: "#556B2F", name: "Dark olive green"}, + {hex: "#98FB98", name: "Pale green"}, + {hex: "#90EE90", name: "Light green"}, + {hex: "#3CB371", name: "Medium sea green"}, + {hex: "#2E8B57", name: "Sea green"}, + {hex: "#8FBC8F", name: "Dark sea green"}, + {hex: "#5F9EA0", name: "Cadet blue"}, + {hex: "#66CDAA", name: "Medium aqua marine"}, + {hex: "#40E0D0", name: "Turquoise"}, + {hex: "#48D1CC", name: "Medium turquoise"}, + {hex: "#00CED1", name: "Dark turquoise"}, + {hex: "#AFEEEE", name: "Pale turquoise"}, + {hex: "#00FFFF", name: "Aqua"}, + {hex: "#00FFFF", name: "Cyan"}, + {hex: "#E0FFFF", name: "Light cyan"}, + {hex: "#F5FFFA", name: "Mint cream"}, + {hex: "#F0FFF0", name: "Honey dew"}, + {hex: "#7FFFD4", name: "Aquamarine"}, + {hex: "#20B2AA", name: "Light sea green"}, + {hex: "#008B8B", name: "Dark cyan"}, + {hex: "#008080", name: "Teal"}, + {hex: "#2F4F4F", name: "Dark slate gray"}, + {hex: "#778899", name: "Light slate gray"}, + {hex: "#708090", name: "Slate gray"}, + {hex: "#00BFFF", name: "Deep sky blue"}, + {hex: "#1E90FF", name: "Dodger Blue"}, + {hex: "#87CEEB", name: "Sky blue"}, + {hex: "#87CEFA", name: "Light sky blue"}, + {hex: "#ADD8E6", name: "Light blue"}, + {hex: "#B0E0E6", name: "Powder blue"}, + {hex: "#B0C4DE", name: "Light steel blue"}, + {hex: "#4682B4", name: "Steel blue"}, + {hex: "#6495ED", name: "Cornflower blue"}, + {hex: "#4169E1", name: "Royal blue"}, + {hex: "#0000FF", name: "Blue"}, + {hex: "#0000CD", name: "Medium blue"}, + {hex: "#191970", name: "Midnight blue"}, + {hex: "#00008B", name: "Dark blue"}, + {hex: "#000080", name: "Navy"}, + {hex: "#6A5ACD", name: "Slate blue"}, + {hex: "#483D8B", name: "Dark slate blue"}, + {hex: "#7B68EE", name: "Medium slate blue"}, + {hex: "#8A2BE2", name: "Blue violet"}, + {hex: "#4B0082", name: "Indigo"}, + {hex: "#9400D3", name: "Dark violet"}, + {hex: "#9932CC", name: "Dark orchid"}, + {hex: "#BA55D3", name: "Medium orchid"}, + {hex: "#DA70D6", name: "Orchid"}, + {hex: "#EE82EE", name: "Violet"}, + {hex: "#9370DB", name: "Medium purple"}, + {hex: "#800080", name: "Purple"}, + {hex: "#663399", name: "Rebecca purple"}, + {hex: "#C71585", name: "Medium violet red"}, + {hex: "#DB7093", name: "PaleV violet red"}, + {hex: "#FF1493", name: "Deep pink"}, + {hex: "#FF69B4", name: "Hot pink"}, + {hex: "#FFB6C1", name: "Light pink"}, + {hex: "#FFC0CB", name: "Pink"}, + {hex: "#DDA0DD", name: "Plum"}, + {hex: "#D8BFD8", name: "Thistle"}, + {hex: "#FF00FF", name: "Magenta"}, + {hex: "#FF00FF", name: "Fuchsia"}, + {hex: "#8B008B", name: "Dark magenta"}, + + {hex: "#FAF0E6", name: "Linen"}, + {hex: "#FDF5E6", name: "Old lace"}, + {hex: "#FFF5EE", name: "Sea shell"}, + {hex: "#FFFFF0", name: "Ivory"}, + {hex: "#FFFAF0", name: "Floral white"}, + {hex: "#FFF0F5", name: "Lavender blush"}, + {hex: "#F8F8FF", name: "Ghost white"}, + {hex: "#F0F8FF", name: "Alice blue"}, + {hex: "#E6E6FA", name: "Lavender"}, + {hex: "#F5F5F5", name: "White smoke"}, + {hex: "#DCDCDC", name: "Gainsboro"}, +]; + +const palettes = { + basic: [ + {hex: "#FF0000", name: "Red"}, + {hex: "#FF8000", name: "Orange"}, + {hex: "#FFFF00", name: "Yellow"}, + {hex: "#80FF00", name: "Lime"}, + {hex: "#00FF00", name: "Green"}, + {hex: "#00FFFF", name: "Cyan"}, + {hex: "#0000FF", name: "Blue"}, + {hex: "#8000FF", name: "Purple"}, + {hex: "#FF00FF", name: "Magenta"}, + {hex: "#FF8080", name: "Pink"}, + {hex: "#804000", name: "Brown"}, + {hex: "#000000", name: "Black"}, + {hex: "#191919", name: "%90"}, + {hex: "#333333", name: "%80"}, + {hex: "#4c4c4c", name: "%70"}, + {hex: "#666666", name: "%60"}, + {hex: "#808080", name: "%50"}, + {hex: "#999999", name: "%40"}, + {hex: "#b3b3b3", name: "%30"}, + {hex: "#cccccc", name: "%20"}, + {hex: "#e6e6e6", name: "%10"}, + {hex: "#FFFFFF", name: "White"}, + ...cssColors + ], + orca: [ + {hex: "#ED1C24", name: "Coral fire"}, + {hex: "#C41E3A", name: "Sea anemone"}, + {hex: "#800000", name: "Deep abyss"}, + {hex: "#B22222", name: "Lobster red"}, + {hex: "#A0522D", name: "Sandy clay"}, + {hex: "#8B4513", name: "Driftwood"}, + {hex: "#D2691E", name: "Sea buckthorn"}, + {hex: "#F26722", name: "Sunset reef"}, + {hex: "#FF8C00", name: "Tiger coral"}, + {hex: "#FA8173", name: "Pink coral"}, + {hex: "#FFA07A", name: "Seashell glow"}, + {hex: "#F4E2C1", name: "Sand"}, + {hex: "#FFD700", name: "Golden kelp"}, + {hex: "#F7B763", name: "Sunlit dune"}, + {hex: "#FFAA33", name: "Amber nautilus"}, + {hex: "#FFEB31", name: "Sea lemon"}, + {hex: "#D4ED91", name: "Pale starfish"}, + {hex: "#00FF7F", name: "Spring tide"}, + {hex: "#32CD32", name: "Lime algae"}, + {hex: "#A4C41E", name: "Sea grass"}, + {hex: "#7CFC00", name: "Neon plankton"}, + {hex: "#228B22", name: "Kelp forest"}, + {hex: "#008000", name: "Emerald wave"}, + {hex: "#345B2F", name: "Whale shadow"}, + {hex: "#2E8B57", name: "Sea turtle"}, + {hex: "#00C1AE", name: "Lagoon"}, + {hex: "#20B2AA", name: "Tropical current"}, + {hex: "#115877", name: "Midnight trench"}, + {hex: "#008B8B", name: "Dark kelp"}, + {hex: "#00FFFF", name: "Crystal surf"}, + {hex: "#40E0D0", name: "Turquoise bay"}, + {hex: "#2EBDEF", name: "Sky over reef"}, + {hex: "#87CEEB", name: "Shallow sky"}, + {hex: "#1E90FF", name: "Electric bel"}, + {hex: "#4169E1", name: "Dolphin"}, + {hex: "#0000CD", name: "Ocean depths"}, + {hex: "#191970", name: "Whale"}, + {hex: "#4B0082", name: "Indigo abyss"}, + {hex: "#7841CE", name: "Sea urchin"}, + {hex: "#9370DB", name: "Lavender coral"}, + {hex: "#800080", name: "Violet tide"}, + {hex: "#8B008B", name: "Deep sea orchid"}, + {hex: "#FF00FF", name: "Neon jelly"}, + {hex: "#ED1E79", name: "Fuchsia coral"}, + {hex: "#FF69B4", name: "Pink jellyfish"}, + {hex: "#FFFFFF", name: "Foam"}, + {hex: "#D3D3D3", name: "Seashell white"}, + {hex: "#808080", name: "Pebble gray"}, + {hex: "#696969", name: "Storm cloud"}, + {hex: "#000000", name: "Ink black"} + ], + ral: [ + {hex: "#716F52", name: "1000 Green beige"}, + {hex: "#9B8E79", name: "1001 Beige"}, + {hex: "#D4BB87", name: "1002 Sand yellow"}, + {hex: "#F4A460", name: "1003 Signal yellow"}, + {hex: "#EFBF04", name: "1004 Golden yellow"}, + {hex: "#D7A500", name: "1005 Honey yellow"}, + {hex: "#F2D185", name: "1006 Maize yellow"}, + {hex: "#FDD185", name: "1007 Dalmatian yellow"}, + {hex: "#8B7D6B", name: "1011 Brown beige"}, + {hex: "#F5DB50", name: "1012 Lemon yellow"}, + {hex: "#F3E5AB", name: "1013 Oyster white"}, + {hex: "#F8E4BC", name: "1014 Ivory"}, + {hex: "#FAE6D3", name: "1015 Light ivory"}, + {hex: "#E4B922", name: "1018 Zinc yellow"}, + {hex: "#A19D94", name: "1019 Grey beige"}, + {hex: "#B9975B", name: "1020 Olive yellow"}, + {hex: "#B8860B", name: "1021 Rape yellow"}, + {hex: "#FF8C00", name: "1023 Traffic yellow"}, + {hex: "#CC7722", name: "1024 Ochre yellow"}, + {hex: "#FDBA37", name: "1026 Traffic orange yellow"}, + {hex: "#F3A55C", name: "1027 Curry"}, + {hex: "#E87C33", name: "1028 Melon yellow"}, + {hex: "#F2A641", name: "1032 Broom yellow"}, + {hex: "#E09F39", name: "1033 Dahlia yellow"}, + {hex: "#F0B76E", name: "1034 Pastel yellow"}, + {hex: "#D1BCA0", name: "1035 Pearl beige"}, + {hex: "#D4AF37", name: "1036 Pearl gold"}, + {hex: "#F7C64F", name: "1037 Sun yellow"}, + {hex: "#F28C38", name: "2000 Yellow orange"}, + {hex: "#E55B2B", name: "2001 Deep orange"}, + {hex: "#E34234", name: "2002 Vermilion"}, + {hex: "#F27B33", name: "2003 Pearl orange"}, + {hex: "#F37322", name: "2004 Pure orange"}, + {hex: "#F75C03", name: "2005 Luminous orange"}, + {hex: "#F46C1F", name: "2007 Luminous bright orange"}, + {hex: "#E14423", name: "2008 Bright red orange"}, + {hex: "#F28500", name: "2009 Traffic orange"}, + {hex: "#F46A0A", name: "2010 Signal orange"}, + {hex: "#E0490C", name: "2011 Deep orange"}, + {hex: "#F46F42", name: "2012 Salmon orange"}, + {hex: "#E6761C", name: "2013 Pearl orange"}, + {hex: "#E8A18B", name: "2015 Pearl pink"}, + {hex: "#B8612C", name: "2016 Copper orange"}, + {hex: "#8B4513", name: "2017 Signal brown"}, + {hex: "#C14A0F", name: "2018 Fir orange"}, + {hex: "#BC2E1F", name: "2020 Signal red"}, + {hex: "#D52713", name: "2021 Luminous red"}, + {hex: "#9F261E", name: "2022 Carmine red"}, + {hex: "#D53832", name: "2023 Pearl carmine red"}, + {hex: "#C2341C", name: "2024 Pure red"}, + {hex: "#B32D1C", name: "2025 Luminous deep red"}, + {hex: "#A62320", name: "2027 Currant red"}, + {hex: "#B9301E", name: "2028 Pearl ruby red"}, + {hex: "#A91D2E", name: "3000 Fire red"}, + {hex: "#A1262A", name: "3001 Signal red"}, + {hex: "#8F2626", name: "3002 Carmine red"}, + {hex: "#9B2428", name: "3003 Ruby red"}, + {hex: "#872241", name: "3004 Purple red"}, + {hex: "#722F37", name: "3005 Wine red"}, + {hex: "#3D1818", name: "3007 Black red"}, + {hex: "#8A3324", name: "3009 Oxide red"}, + {hex: "#A22C29", name: "3011 Brown red"}, + {hex: "#B22D20", name: "3012 Pine red"}, + {hex: "#D23741", name: "3013 Tomato red"}, + {hex: "#A8282E", name: "3014 Antique pink"}, + {hex: "#C8283C", name: "3015 Light pink"}, + {hex: "#D23F39", name: "3016 Coral red"}, + {hex: "#D1425E", name: "3017 Rose"}, + {hex: "#C4232F", name: "3018 Strawberry red"}, + {hex: "#C8102E", name: "3020 Traffic red"}, + {hex: "#E36A5A", name: "3022 Salmon pink"}, + {hex: "#E63E62", name: "3024 Luminous rose"}, + {hex: "#E02A27", name: "3026 Luminous bright red"}, + {hex: "#B8315F", name: "3027 Raspberry red"}, + {hex: "#C61C23", name: "3028 Pure red"}, + {hex: "#AD2932", name: "3031 Orient red"}, + {hex: "#D32F2F", name: "3032 Pearl ruby"}, + {hex: "#D13C6F", name: "3033 Pearl pink"}, + {hex: "#7B2B5F", name: "4001 Red purple"}, + {hex: "#912E8C", name: "4002 Red violet"}, + {hex: "#9C366F", name: "4003 Heather violet"}, + {hex: "#6A1F49", name: "4004 Bordeaux violet"}, + {hex: "#4B2663", name: "4005 Blue violet"}, + {hex: "#5C2D3C", name: "4006 Mahogany"}, + {hex: "#5E3559", name: "4007 Grey purple"}, + {hex: "#9C3568", name: "4008 Signal violet"}, + {hex: "#5E6E3A", name: "4009 Fir green"}, + {hex: "#5B5B8A", name: "4010 Sabine grey"}, + {hex: "#8F3A70", name: "4011 Pearl violet"}, + {hex: "#5E3252", name: "4012 Pearl blackberry"}, + {hex: "#1B4F72", name: "5001 Green blue"}, + {hex: "#18669E", name: "5002 Ultramarine blue"}, + {hex: "#0F4C8A", name: "5003 Sapphire blue"}, + {hex: "#0D324A", name: "5004 Black blue"}, + {hex: "#1B5583", name: "5005 Signal blue"}, + {hex: "#2C5F9D", name: "5007 Brilliant blue"}, + {hex: "#3B5AA9", name: "5008 Grey blue"}, + {hex: "#0077A3", name: "5009 Azure blue"}, + {hex: "#0A558C", name: "5010 Gentian blue"}, + {hex: "#2B547E", name: "5011 Steel blue"}, + {hex: "#0066A1", name: "5012 Light blue"}, + {hex: "#124780", name: "5013 Cobalt blue"}, + {hex: "#1F4E79", name: "5014 Pigeon blue"}, + {hex: "#0384A7", name: "5015 Sky blue"}, + {hex: "#006A9E", name: "5017 Traffic blue"}, + {hex: "#005F87", name: "5018 Turquoise blue"}, + {hex: "#5A3D5B", name: "5019 Capsanthin violet"}, + {hex: "#04749F", name: "5020 Ocean blue"}, + {hex: "#009AAA", name: "5021 Water blue"}, + {hex: "#1A3D5F", name: "5022 Night blue"}, + {hex: "#047AA6", name: "5023 Distant blue"}, + {hex: "#5B9BB8", name: "5024 Pastel blue"}, + {hex: "#3C6A98", name: "5025 Pearl gentian blue"}, + {hex: "#1C3F5F", name: "5026 Pearl midnight blue"}, + {hex: "#36673A", name: "6000 Patina green"}, + {hex: "#0E5C34", name: "6001 Emerald green"}, + {hex: "#437034", name: "6002 Leaf green"}, + {hex: "#3F3F2A", name: "6003 Olive green"}, + {hex: "#2B4E52", name: "6004 Blue green"}, + {hex: "#3B5F3F", name: "6005 Moss green"}, + {hex: "#4A563C", name: "6006 Grey olive"}, + {hex: "#2B4638", name: "6007 Pine green"}, + {hex: "#4A4139", name: "6008 Pine brown"}, + {hex: "#3B3A2E", name: "6009 Fir green"}, + {hex: "#3E7A5F", name: "6010 Grass green"}, + {hex: "#5E716A", name: "6011 Reseda green"}, + {hex: "#3A4E48", name: "6012 Brown green"}, + {hex: "#6B8E23", name: "6013 Reed green"}, + {hex: "#6B8E23", name: "6014 Yellow green"}, + {hex: "#1E3E2F", name: "6015 Black green"}, + {hex: "#1E4D4B", name: "6016 Turquoise green"}, + {hex: "#4C7C47", name: "6017 May green"}, + {hex: "#9CBF60", name: "6018 Yellow green"}, + {hex: "#2E5A3A", name: "6019 Pine green"}, + {hex: "#2E5A3A", name: "6020 Chrome oxide green"}, + {hex: "#9DC0A5", name: "6021 Pale green"}, + {hex: "#4A563C", name: "6022 Brown green"}, + {hex: "#009E60", name: "6024 Traffic green"}, + {hex: "#3F7044", name: "6025 Fern green"}, + {hex: "#4F7942", name: "6026 Opal green"}, + {hex: "#3B5F3F", name: "6027 Pine green"}, + {hex: "#3B5F3F", name: "6028 Pine green"}, + {hex: "#00A550", name: "6029 Mint green"}, + {hex: "#3E5A40", name: "6031 Bronze green"}, + {hex: "#3D7E5A", name: "6032 Pearl green"}, + {hex: "#007D6E", name: "6033 Sax turquoise"}, + {hex: "#3D7E5A", name: "6034 Pearl turquoise"}, + {hex: "#3D7E5A", name: "6035 Pearl green"}, + {hex: "#3D7E5A", name: "6036 Pearl jade"}, + {hex: "#00A550", name: "6037 Pure green"}, + {hex: "#00A550", name: "6038 Luminous green"}, + {hex: "#00A550", name: "6039 Mint green"}, + {hex: "#7D7D7D", name: "7000 Squirrel grey"}, + {hex: "#A8A9AD", name: "7001 Silver grey"}, + {hex: "#6F7368", name: "7002 Olivestone"}, + {hex: "#8F9080", name: "7003 Moss grey"}, + {hex: "#8E9291", name: "7004 Signal grey"}, + {hex: "#8B8788", name: "7005 Mouse grey"}, + {hex: "#9A8373", name: "7006 Beige grey"}, + {hex: "#828C6A", name: "7008 Khaki grey"}, + {hex: "#6F7368", name: "7009 Green grey"}, + {hex: "#5F6563", name: "7010 Tarpaulin grey"}, + {hex: "#4A5656", name: "7011 Iron grey"}, + {hex: "#4D5656", name: "7012 Basalt grey"}, + {hex: "#635147", name: "7013 Brown grey"}, + {hex: "#6B6B83", name: "7015 Slate grey"}, + {hex: "#32373D", name: "7016 Anthracite grey"}, + {hex: "#8B8B83", name: "7021 Light grey"}, + {hex: "#716347", name: "7022 Umber grey"}, + {hex: "#76787A", name: "7023 Concrete grey"}, + {hex: "#5E6565", name: "7024 Graphite grey"}, + {hex: "#5E6565", name: "7026 Granite grey"}, + {hex: "#8F9494", name: "7030 Stone grey"}, + {hex: "#4A5656", name: "7031 Iron grey"}, + {hex: "#B2BFC0", name: "7032 Pebble grey"}, + {hex: "#9B9B9B", name: "7033 Cement grey"}, + {hex: "#8F9D9B", name: "7034 Yellow grey"}, + {hex: "#CBD0CC", name: "7035 Light grey"}, + {hex: "#6D7A80", name: "7036 Platinum grey"}, + {hex: "#8A9191", name: "7037 Dusty grey"}, + {hex: "#7E8181", name: "7038 Agate grey"}, + {hex: "#6E7D7E", name: "7039 Quartz grey"}, + {hex: "#8A9191", name: "7040 Window grey"}, + {hex: "#8A9191", name: "7042 Traffic grey A"}, + {hex: "#6E7D7E", name: "7043 Traffic grey B"}, + {hex: "#9B9B9B", name: "7044 Silk grey"}, + {hex: "#A1A7B0", name: "7045 Telegrey 1"}, + {hex: "#7E848C", name: "7046 Telegrey 2"}, + {hex: "#7E848C", name: "7047 Telegrey 4"}, + {hex: "#6D7A80", name: "7048 Pearl mouse grey"}, + {hex: "#786D5F", name: "8000 Green brown"}, + {hex: "#8B7355", name: "8001 Ochre brown"}, + {hex: "#8B6F47", name: "8003 Clay brown"}, + {hex: "#8B6F47", name: "8004 Copper brown"}, + {hex: "#5C4033", name: "8007 Chocolate brown"}, + {hex: "#6E5C4A", name: "8008 Olive brown"}, + {hex: "#8B4513", name: "8011 Nut brown"}, + {hex: "#8B4513", name: "8012 Red brown"}, + {hex: "#5C3317", name: "8014 Sepia brown"}, + {hex: "#854636", name: "8015 Chestnut brown"}, + {hex: "#8B4513", name: "8016 Mahogany brown"}, + {hex: "#5C3317", name: "8017 Chocolate brown"}, + {hex: "#786D5F", name: "8019 Grey brown"}, + {hex: "#854636", name: "8022 Chestnut brown"}, + {hex: "#8B6F47", name: "8023 Pearl copper"}, + {hex: "#8B7355", name: "8024 Beige brown"}, + {hex: "#8B6F47", name: "8025 Pearl mocha brown"}, + {hex: "#8B6F47", name: "8027 Clay brown"}, + {hex: "#8B6F47", name: "8028 Earth brown"}, + {hex: "#8B6F47", name: "8029 Pearl brown"}, + {hex: "#FAF0E6", name: "9001 Cream"}, + {hex: "#EDECE6", name: "9002 Grey white"}, + {hex: "#FFFFFF", name: "9003 Signal white"}, + {hex: "#000000", name: "9004 Signal black"}, + {hex: "#1D1D1D", name: "9005 Jet black"}, + {hex: "#C0C0C0", name: "9006 White aluminium"}, + {hex: "#8C7853", name: "9007 Grey aluminium"}, + {hex: "#FFFFFF", name: "9010 Pure white"}, + {hex: "#1D1D1D", name: "9011 Graphite black"}, + {hex: "#FFFFFF", name: "9016 Traffic white"}, + {hex: "#000000", name: "9017 Traffic black"}, + {hex: "#FAF0E6", name: "9018 Papyrus white"}, + {hex: "#B2BFC0", name: "9022 Pearl light grey"} + ], + gradient: [ + {grad: "#FFF47B-#CD9800", name: "Metal: Gold"}, + {grad: "#373737-#F5F4F4", name: "Metal: Silver"}, + {grad: "#C39292-#C73725", name: "Metal: Copper"}, + {grad: "#DED699-#C99620", name: "Metal: Brass"}, + {grad: "#6E4316-#B0742E", name: "Metal: Bronze"}, + // More than two colors: only listed when the page enables multi-color gradients. + {grad: "#FF0000-#FFFF00-#00FF00-#00FFFF-#0000FF-#FF00FF-#FF0000", name: "Nature: Rainbow"}, + + {grad: "#D8FEE7-#FFF5BD", name: "Nature: Pearl"}, + {grad: "#1E5AA8-#3D8BD9-#7DB9F0-#C9E6FF", name: "Nature: Clear Sky"}, + {grad: "#2B1B5A-#7A2E7E-#D9466E-#F57C3A-#FFC857", name: "Nature: Sunset"}, + {grad: "#3B2416-#5A3A24-#45291A-#6B4630-#3B2416", name: "Wood: Dark"}, + {grad: "#8B5A2B-#A8733F-#946235-#B5844F-#8B5A2B", name: "Wood: Medium"}, + {grad: "#D9B98C-#E8CFA6-#D3AE7C-#EBD5B0-#D9B98C", name: "Wood: Light"}, + ] +}; + + +window.ColorPickerPalettes = palettes; +})(); diff --git a/resources/web/dialog/ColorPickerDialog/style.css b/resources/web/dialog/ColorPickerDialog/style.css new file mode 100644 index 0000000000..3b5543452f --- /dev/null +++ b/resources/web/dialog/ColorPickerDialog/style.css @@ -0,0 +1,534 @@ +:root { + --thumb-size : 20px; + --field-height: 28px; + --number-width: 52px; + --preview-size: 52px; + --checkerboard: conic-gradient(var(--checker-light) 25%, var(--checker-dark) 0 50%, var(--checker-light) 0 75%, var(--checker-dark) 0); +} + +* { + padding: 0; + border: 0; + margin: 0; + user-select: none; + -webkit-user-select: none; + box-sizing: border-box; + min-width: 0; + min-height: 0; +} + +html, body { + /* Report the full content width even before the native host expands its viewport. + WebKit's fit-content can clip the sidebar and retain the expanded width on close. */ + width: max-content; + overflow: hidden; +} + +body { + font-family: var(--orca-font, system-ui, sans-serif); + background: var(--bg-color); + color: var(--fg-color-text); + padding: 10px; +} + +#selectionNotice { + width: 0; + min-width: 100%; + margin-bottom: 8px; + color: var(--fg-color-label); +} + +.main-wrapper { + display: flex; + gap: 10px; + height: 360px; +} + +/* ---------- Palette sidebar ---------- */ +.left-panel { + flex: 0 0 160px; + width: 160px; + display: none; + flex-direction: column; +} +.left-panel.open { + display: flex; + padding-right: 0px; + border-right: 1px solid var(--border-color); +} + +#paletteCombo { + font-size: 13px; + width: 100%; +} + +/* Combo box or filter box, with the search / close button on the right. */ +.palette-toolbar { + display: flex; + align-items: center; + gap: 0; + height: 25px; + margin-right:5px; +} +.palette-toolbar > .palette-select { flex: 1 } +.palette-filter { + flex: 1; + height: 25px; + padding: 0 6px; + font-size: 13px; + font-family: inherit; + color: var(--fg-color-text); + background: var(--bg-color); + border: 1px solid var(--main-color); + outline: none; + -webkit-user-select: text; + user-select: text; +} +.palette-filter::-webkit-search-cancel-button { -webkit-appearance: none } +.palette-filter:focus-visible{ + background-color: var(--focus-bg-box); +} + +.palette-search-button { + display: flex; + flex: none; + align-items: center; + justify-content: center; + width: 25px; + height: 25px; + background-color: var(--button-bg-normal); + border: 1px solid var(--border-color); + border-left: 0; +} +.palette-search-button:hover { + background-color: var(--button-bg-hover); +} + +.palette-search-button .icon-search { + --icon-url: var(--icon-search); +} +.palette-search-button .icon-close { + --icon-url: url('data:image/svg+xml;utf8,');; +} +.palette-search-button .icon-close, +.palette-search-button[aria-pressed="true"] .icon-search { display: none } +.palette-search-button[aria-pressed="true"] .icon-close { display: block } +.palette-empty { + flex: 1; + padding: 8px 2px; + font-size: 13px; + color: var(--fg-color-label); +} + +.palette { + flex: 1; + overflow-y: auto; + padding-right: 1px; + margin-bottom: 5px; +} + +.color-item { + display: flex; + align-items: center; + padding: 1px 2px 1px 0; + border:1px solid transparent; + font-size: 13px; + color: var(--fg-color-text); +} + +.color-item:hover { + border-color: var(--main-color); +} + +.color-swatch { + flex: 0 0 18px; + height: 18px; + border-radius: 3px; + margin-right: 5px; + margin: 0 5px 0 1px; + border: 1px solid var(--border-color); +} + +.color-name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ---------- Picker ---------- */ +.right-panel { + flex: 0 0 330px; + /* WebKit uses width, rather than flex-basis, for the parent's intrinsic width. */ + width: 330px; + display: flex; + flex-direction: column; +} + +.spectrum-row { + --spectrum-height: 150px; + display: flex; + gap: 8px; + height: var(--spectrum-height); + margin-bottom: 8px; +} + +.spectrum-container { + position: relative; + flex: 1; + border-radius: 7px; + overflow: hidden; +} + +#spectrum { + position: absolute; /* Absolute so the canvas's pixel size never feeds back into the flex layout */ + inset: 0; + width: 100%; + height: 100%; + touch-action: none; +} + +#pickerEditors:disabled #spectrum { pointer-events: none } + +#colorIndicator { + position: absolute; + width: var(--thumb-size); + height: var(--thumb-size); + border: 2px solid var(--color-marker-outline); + box-shadow: 0 0 0 2px var(--color-marker-shadow); + border-radius: 50%; + pointer-events: none; + transform: translate(-50%, -50%); + z-index: 10; +} + +.saturation-slider { + position: relative; + flex: 0 0 var(--thumb-size); +} + +#satSlider { + position: absolute; + top: 50%; + left: 50%; + flex: none; + width: var(--spectrum-height); + transform: translate(-50%, -50%) rotate(-90deg); +} + +.valueSlider { + height: 14px; + border-radius: 8px; + outline: none; + -webkit-appearance: none; + background: transparent; +} + +.valueSlider::-webkit-slider-runnable-track { + height: 14px; + border-radius: 8px; + background: var(--track-gradient); + border: 1px solid var(--border-color); +} + +.valueSlider::-webkit-slider-thumb { + -webkit-appearance: none; + width: var(--thumb-size); + height: var(--thumb-size); + border-radius: 50%; + border: 2px solid var(--color-marker-outline); + box-shadow: 0 0 0 2px var(--color-marker-shadow); + background: var(--thumb-color); + margin-top: -4px; +} + +/* Left column: mode switch, hex + alpha, gradient endpoints. Right: H S L and R G B columns. */ +.editor-grid { + --editor-grid-gap: 5px; + display: flex; + gap: var(--editor-grid-gap); + margin-bottom: 10px; +} + +.editor-column:first-of-type {flex:1} +.editor-column { + display: grid; + grid-template-rows: repeat(3, var(--field-height)); + gap: var(--editor-grid-gap); +} + +.hex-row { + display: flex; + gap: var(--editor-grid-gap); +} + +.switch-check { + display: none !important; +} +.switch-label { + font-size: 13px; + padding: 2px 10px; + text-align: center; + line-height: 18px; + z-index: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.switch-slider { + position: relative; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-radius: 20px; + background: var(--bg-color-alt); + border: 2px solid var(--bg-color-alt); + height:26px; +} +.switch-slider:before { + content: ''; + position: absolute; + width: 50%; + height: 100%; + left: 0%; + border-radius:20px; + background: var(--main-color); + transition: all 0.2s; +} +.switch-check:checked + .switch-slider:before { + left: 50%; +} +.switch-check:checked + .switch-slider div:first-child, +.switch-check + .switch-slider div:last-child{ + color: var(--fg-color-label); + transition: all 0.2s; +} +.switch-check:checked + .switch-slider div:last-child, +.switch-check + .switch-slider div:first-child{ + color: var(--button-fg-light); + transition: all 0.2s; +} + +/* H S L / R G B / Hex / Alpha input boxes */ +.inline-field { + display: flex; + align-items: center; + padding-left: 5px; + border: 1px solid var(--border-color); + background: var(--bg-color); +} +.inline-field > label { + width: 8px; + font-size: 12px; + color: var(--fg-color-text); + opacity: 0.6; +} +.inline-field > input { + flex: 1; + border: 0; + background: transparent; + padding: 0 2px 0 5px; + color: var(--fg-color-text); + font-size: 15px; + line-height: var(--field-height); + appearance: textfield; /* No spinner buttons; Up/Down keys and the wheel still step by 1. */ +} +.inline-field:focus-within { + border-color: var(--main-color); + background: var(--focus-bg-box); +} +.inline-field:has(input:invalid) { + border-color: var(--control-invalid); +} +.inline-field > input:focus { + outline: none; +} + +.inline-field > input[type="number"]::-webkit-inner-spin-button, +.inline-field > input[type="number"]::-webkit-outer-spin-button { -webkit-appearance: none } + +.value-field { width: var(--number-width)} +.field-hex { flex: 1 } +#alphaControls { + width: calc(var(--number-width) + 2px) +} +#alphaControls > input { + padding-left: 7px; +} + +/* Third row holds the gradient bar and the opacity slider in the same cell. The slider only + shows while the opacity field or the slider itself is in use, and hides the gradient bar. */ +.third-row { position: relative } +.third-row > * { + position: absolute; + inset: 0; + height: 100%; + align-items: center; +} + +.third-row.opacity-open > #opacitySliderBar { display: flex } +.third-row.opacity-open > #gradientCtrlBar { display: none } + +#opacitySlider { width: 100%; } +#opacitySlider::-webkit-slider-runnable-track { + background: var(--track-gradient), var(--checkerboard); /* Transparent end shows the checkerboard through the color. */ + background-size: auto, 8px 8px; +} + +/* Gradient colors: thumbs are evenly spaced and fixed; a click anywhere selects the nearest. */ +#gradientCtrlBar { + display: flex; +} +.gradient-stops { + position: relative; + flex: 1; + height: 100%; +} +/* Kept 10px away from the gradient bar so a click near its right end can't hit − or +. */ +.gradient-step-buttons { + display: flex; + align-items: center; + gap: 6px; + margin-left: 10px; + margin-right: 4px; +} +.gradient-step-button { + display: flex; + align-items: center; + justify-content: center; + width: var(--thumb-size); + height: var(--thumb-size); + border-radius: 4px; + background: var(--button-bg-normal); + color: var(--icon-color); +} +.gradient-step-button svg { display: block; flex: none } +.gradient-step-button:hover:not([aria-disabled="true"]) { background: var(--button-bg-hover) } +.gradient-step-button[aria-disabled="true"] { opacity: 0.4; cursor: default } +.gradient-track { + position: absolute; + inset: 0; + top: 50%; + height: var(--thumb-size); + transform: translateY(-50%); + border-radius: 4px; + border: 1px solid var(--border-color); + background-image: var(--swatch-color, linear-gradient(transparent, transparent)), var(--checkerboard); + background-size: auto, 8px 8px; +} +.gradient-thumb { + position: absolute; + top: 50%; + width: 10px; + height: 10px; + border-radius: 1px; + border: 1px solid var(--color-marker-outline); + box-shadow: 0 0 0 1px var(--color-marker-shadow); + background: var(--thumb-color); + transform: translate(-50%, -50%); +} +/* Thumb i of n sits at 10px + (100% - 20px) * i / (n - 1), set inline as --stop. */ +.gradient-thumb { left: calc(10px + (100% - 20px) * var(--stop, 0)) } +.gradient-thumb[aria-pressed="true"] { + width: var(--thumb-size); + height: var(--thumb-size); + border: 2px solid var(--color-marker-outline); + box-shadow: 0 0 0 2px var(--color-marker-shadow); + border-radius: 4px; +} + +.gradient-thumb:hover { + box-shadow: 0 0 0 3px var(--color-marker-shadow); +} + +/* ---------- Original / current / favorites ---------- */ +.bottom-container { + display: flex; + align-items: center; + gap: 4px; + height: var(--preview-size); +} + +.color-preview { + display: flex; + align-items: center; + justify-content: center; + width: var(--preview-size); + height: var(--preview-size); + border-radius: 4px; + border: 1px solid var(--border-color); +} + +.color-preview svg { + display: none; + pointer-events: none; +} + +.color-preview:hover svg { + display: block; +} + +.saved-grid { + display: grid; + grid-template-columns: repeat(8, minmax(0, 24px)); + grid-template-rows: repeat(2, minmax(0, 24px)); + gap: 4px; +} + +.saved-swatch { + position: relative; + width: 24px; + height: 24px; + border-radius: 4px; + border: 1px solid var(--border-color); + cursor: pointer; +} + +.saved-swatch.empty { + background: var(--bg-color); + cursor: default; +} + +/* Each layer is separate so transparent RGBA shows the checkerboard beneath it. */ +.color-swatch, .saved-swatch, .color-preview { + background-image: var(--swatch-color, linear-gradient(transparent, transparent)), var(--checkerboard); + background-size: auto, 12px 12px; +} +.color-item { width: 100%; background: transparent; text-align: left; font-family: inherit; } + +/* ---------- Actions ---------- */ + +.dialog-actions { + display: flex; + align-items: center; + gap: 8px; + padding-top: 12px; +} +.dialog-actions button { margin: 0 } +#confirmBtn[disabled] { opacity: 0.5 } +.stretch-spacer { flex: 1 } + +.icon-button { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + background: var(--button-bg-normal); + color: var(--fg-color-text); +} +.icon-button:hover { + background: var(--button-bg-hover); +} +.icon-button[aria-expanded="true"] { + background: var(--main-color); + color: var(--button-fg-light); +} +.icon-button[aria-expanded="true"]:hover { + background: var(--main-color-hover); +} + +.hidden { display: none !important } +.invisible { visibility: hidden } + +.color-item:focus-visible, .saved-swatch:focus-visible, +select:focus-visible, button:focus-visible { outline: 2px solid var(--main-color); outline-offset: -1px; } diff --git a/resources/web/dialog/css/theme.css b/resources/web/dialog/css/theme.css index 00680bf0b9..8c4be59b79 100644 --- a/resources/web/dialog/css/theme.css +++ b/resources/web/dialog/css/theme.css @@ -55,6 +55,13 @@ /* Speed dial icon tile treatment */ --speed-tile-bg: #efefef; --speed-tile-border: #d8d8d8; + + /* Transparency and control markers are UI surfaces, never color data. */ + --checker-light: #eeeeee; + --checker-dark: #aaaaaa; + --color-marker-outline: #ffffff; + --color-marker-shadow: rgba(0, 0, 0, 0.7); + --control-invalid: var(--plugin-status-danger); } :root[data-orca-theme="dark"] { @@ -93,6 +100,8 @@ /* Speed dial icon tile treatment */ --speed-tile-bg: #34343b; --speed-tile-border: #50505a; + --checker-light: #68686f; + --checker-dark: #414148; } /* Re-theme the shared common.css chrome through variables (replaces dark.css's diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 75e5897f35..c963bbba72 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -129,6 +129,8 @@ set(SLIC3R_GUI_SOURCES GUI/Shortcuts.hpp GUI/SpeedDialDialog.cpp GUI/SpeedDialDialog.hpp + GUI/ColorPickerDialog.cpp + GUI/ColorPickerDialog.hpp GUI/ActionRegistry.cpp GUI/ActionRegistry.hpp GUI/NativeCommands.cpp diff --git a/src/slic3r/GUI/AMSMaterialsSetting.cpp b/src/slic3r/GUI/AMSMaterialsSetting.cpp index 41047dffd9..87b00c940d 100644 --- a/src/slic3r/GUI/AMSMaterialsSetting.cpp +++ b/src/slic3r/GUI/AMSMaterialsSetting.cpp @@ -1,4 +1,9 @@ #include "AMSMaterialsSetting.hpp" +#include "ColorPickerDialog.hpp" +#include "libslic3r/Color.hpp" +#include +#include +#include #include "ExtrusionCalibration.hpp" #include "MsgDialog.hpp" #include "GUI_App.hpp" @@ -36,11 +41,9 @@ #include #include "slic3r/GUI/Widgets/PopupWindow.hpp" #include "slic3r/GUI/Widgets/StaticBox.hpp" -#include #include #include #include -#include #include #include #include @@ -1963,11 +1966,6 @@ ColorPickerPopup::ColorPickerPopup(wxWindow* parent) sizer_custom->Add(m_ts_stbitmap_custom, 0, wxEXPAND, 0); m_custom_cp->Layout(); - m_clrData = new wxColourData(); - m_clrData->SetChooseFull(true); - m_clrData->SetChooseAlpha(false); - - m_sizer_box->Add(0, 0, 0, wxTOP, FromDIP(10)); m_sizer_box->Add(m_sizer_ams, 1, wxEXPAND|wxLEFT|wxRIGHT, FromDIP(10)); m_sizer_box->Add(m_ams_fg_sizer, 0, wxEXPAND|wxLEFT|wxRIGHT, FromDIP(10)); @@ -1991,46 +1989,34 @@ ColorPickerPopup::ColorPickerPopup(wxWindow* parent) wxGetApp().UpdateDarkUIWin(this); } -void ColorPickerPopup::on_custom_clr_picker(wxMouseEvent& event) +void ColorPickerPopup::on_custom_clr_picker(wxMouseEvent& /*event*/) { - std::vector colors = wxGetApp().app_config->get_custom_color_from_config(); - for (int i = 0; i < colors.size(); i++) { - m_clrData->SetCustomColour(i, string_to_wxColor(colors[i])); + const bool was_shown = IsShown(); + std::optional picker_color; + { + const ColorRGBA initial(m_def_col.Red(), m_def_col.Green(), m_def_col.Blue(), static_cast(255)); + // Snapshot the custom control's anchor before releasing the transient popup grab. + ColorPickerDialog dialog(GetParent(), initial, {}, false, m_custom_cp); + Dismiss(); + if (dialog.ShowModal() == wxID_OK && dialog.selection()) { + const auto& result = std::get(*dialog.selection()); + picker_color = wxColour(static_cast(std::lround(result.r() * 255.f)), + static_cast(std::lround(result.g() * 255.f)), + static_cast(std::lround(result.b() * 255.f))); + } + } // Destroy the modal before restoring the first-level popup and its focus/grab. + if (!picker_color) { + if (was_shown) Popup(); + return; } - auto clr_dialog = new wxColourDialog(nullptr, m_clrData); - wxColour picker_color; - if (clr_dialog->ShowModal() == wxID_OK) { - m_clrData = &(clr_dialog->GetColourData()); - if (colors.size() != CUSTOM_COLOR_COUNT) { - colors.resize(CUSTOM_COLOR_COUNT); - } - for (int i = 0; i < CUSTOM_COLOR_COUNT; i++) { - colors[i] = color_to_string(m_clrData->GetCustomColour(i)); - } - wxGetApp().app_config->save_custom_color_to_config(colors); - - picker_color = wxColour( - m_clrData->GetColour().Red(), - m_clrData->GetColour().Green(), - m_clrData->GetColour().Blue(), - 255 - ); - - if (picker_color.Alpha() == 0) { - m_ts_stbitmap_custom->Show(); - } - else { - m_ts_stbitmap_custom->Hide(); - m_custom_cp->SetBackgroundColor(picker_color); - } - - set_def_colour(picker_color); - wxCommandEvent evt(EVT_SELECTED_COLOR); - unsigned long g_col = ((picker_color.Red() & 0xff) << 24) + ((picker_color.Green() & 0xff) << 16) + ((picker_color.Blue() & 0xff) << 8) + (picker_color.Alpha() & 0xff); - evt.SetInt(g_col); - wxPostEvent(GetParent(), evt); - } + set_def_colour(*picker_color); + wxCommandEvent evt(EVT_SELECTED_COLOR); + const std::uint32_t packed = (std::uint32_t(picker_color->Red()) << 24) | + (std::uint32_t(picker_color->Green()) << 16) | + (std::uint32_t(picker_color->Blue()) << 8) | 255u; + evt.SetInt(static_cast(packed)); + wxPostEvent(GetParent(), evt); } void ColorPickerPopup::set_ams_colours(std::vector ams) diff --git a/src/slic3r/GUI/AMSMaterialsSetting.hpp b/src/slic3r/GUI/AMSMaterialsSetting.hpp index 6fa215063a..d895feb551 100644 --- a/src/slic3r/GUI/AMSMaterialsSetting.hpp +++ b/src/slic3r/GUI/AMSMaterialsSetting.hpp @@ -85,7 +85,6 @@ public: ScalableBitmap m_ts_bitmap_custom; wxStaticBitmap* m_ts_stbitmap_custom; StaticBox* m_custom_cp; - wxColourData* m_clrData; StaticBox* m_def_color_box; wxFlexGridSizer* m_ams_fg_sizer; wxColour m_def_col; diff --git a/src/slic3r/GUI/ColorPickerDialog.cpp b/src/slic3r/GUI/ColorPickerDialog.cpp new file mode 100644 index 0000000000..086eae3a70 --- /dev/null +++ b/src/slic3r/GUI/ColorPickerDialog.cpp @@ -0,0 +1,1089 @@ +#include "ColorPickerDialog.hpp" +#include "Widgets/WebViewHostDialog.hpp" +#include "Widgets/WebView.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "GUI_App.hpp" +#include "GUI_Utils.hpp" +#include "slic3r/Utils/MacDarkMode.hpp" +#include "I18N.hpp" +#include "libslic3r/AppConfig.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef __linux__ +#include +#endif + + +#include "libslic3r/Color.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Slic3r::GUI { + +namespace { + +struct ColorPickerFavoritesState { + std::vector favorites; + bool writable = true; +}; + +bool normalize_color(ColorRGBA& color, bool allow_alpha) +{ + for (int i = 0; i < 4; ++i) + if (!std::isfinite(color[i]) || color[i] < 0.0f || color[i] > 1.0f) + return false; + if (!allow_alpha) + color.a(1.0f); + return true; +} + +bool parse_color(const nlohmann::json& value, ColorRGBA& color) +{ + if (!value.is_string()) + return false; + const std::string& hex = value.get_ref(); + if (!can_decode_color(hex)) + return false; + // decode_color accepts invalid hex digits, so validate before reusing it. + for (std::size_t i = 1; i < hex.size(); ++i) { + const char c = hex[i]; + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) + return false; + } + return decode_color(hex, color); +} + +std::string rgba_hex(const ColorRGBA& color) +{ + constexpr char digits[] = "0123456789ABCDEF"; + std::string hex(9, '#'); + for (int i = 0; i < 4; ++i) { + const unsigned int byte = static_cast(std::lround(color[i] * 255.0f)); + hex[1 + 2 * i] = digits[byte >> 4]; + hex[2 + 2 * i] = digits[byte & 15]; + } + return hex; +} + +std::optional normalize_color_selection(const ColorSelection& selection, ColorPickerOptions options) +{ + ColorSelection normalized = selection; + if (auto* solid = std::get_if(&normalized)) { + if (!normalize_color(*solid, options.allow_alpha)) + return std::nullopt; + } else { + auto& gradient = std::get(normalized); + if (!options.allow_gradient || gradient.size() < 2 || gradient.size() > COLOR_PICKER_MAX_GRADIENT_COLORS) + return std::nullopt; + for (ColorRGBA& color : gradient) + if (!normalize_color(color, options.allow_alpha)) + return std::nullopt; + } + return normalized; +} + +std::optional color_selection_from_json(const nlohmann::json& value, ColorPickerOptions options) +{ + if (!value.is_object()) + return std::nullopt; + const auto type = value.find("type"); + const auto colors = value.find("colors"); + if (type == value.end() || !type->is_string() || colors == value.end() || !colors->is_array()) + return std::nullopt; + if (*type == "solid" && colors->size() == 1) { + ColorRGBA solid; + if (parse_color((*colors)[0], solid)) + return normalize_color_selection(solid, options); + } else if (*type == "gradient" && options.allow_gradient && colors->size() >= 2 && colors->size() <= COLOR_PICKER_MAX_GRADIENT_COLORS) { + ColorGradient gradient(colors->size()); + for (std::size_t i = 0; i < gradient.size(); ++i) + if (!parse_color((*colors)[i], gradient[i])) + return std::nullopt; + return normalize_color_selection(gradient, options); + } + return std::nullopt; +} + +nlohmann::json color_selection_to_json(const ColorSelection& selection) +{ + const auto normalized = normalize_color_selection(selection, {true, true}); + if (!normalized) + throw std::invalid_argument("Invalid color selection channels"); + if (const auto* solid = std::get_if(&*normalized)) + return {{"type", "solid"}, {"colors", {rgba_hex(*solid)}}}; + nlohmann::json colors = nlohmann::json::array(); + for (const ColorRGBA& color : std::get(*normalized)) + colors.push_back(rgba_hex(color)); + return {{"type", "gradient"}, {"colors", std::move(colors)}}; +} + +std::optional> color_favorites_from_json(const nlohmann::json& values) +{ + if (!values.is_array() || values.size() > 24) + return std::nullopt; + std::vector favorites; + favorites.reserve(values.size()); + std::set seen; + for (const auto& value : values) { + const auto selection = color_selection_from_json(value, {true, true}); + if (!selection) + return std::nullopt; + if (seen.insert(color_selection_to_json(*selection).dump()).second) + favorites.push_back(*selection); + } + return favorites; +} + +std::optional legacy_color(const std::string& value) +{ + if (!value.empty() && value.front() == '#') + return color_selection_from_json({{"type", "solid"}, {"colors", {value}}}, {false, true}); + + // string_to_wxColor uses stoi without checking the consumed length or byte + // range. Parse the actual color_to_string RGBA format strictly for migration. + std::array bytes; + std::size_t begin = 0; + for (std::size_t i = 0; i < bytes.size(); ++i) { + const auto comma = value.find(',', begin); + if ((i < 3 && comma == std::string::npos) || (i == 3 && comma != std::string::npos)) + return std::nullopt; + const auto end = comma == std::string::npos ? value.size() : comma; + const auto parsed = std::from_chars(value.data() + begin, value.data() + end, bytes[i]); + if (parsed.ec != std::errc{} || parsed.ptr != value.data() + end || bytes[i] > 255) + return std::nullopt; + begin = end + 1; + } + return ColorRGBA(static_cast(bytes[0]), static_cast(bytes[1]), + static_cast(bytes[2]), static_cast(bytes[3])); +} +bool save_color_picker_favorites(AppConfig& config, const nlohmann::json& values) +{ + if (config.has_section("color_picker") && config.get("color_picker", "version") != "1") + return false; + const auto favorites = color_favorites_from_json(values); + if (!favorites) + return false; + nlohmann::json canonical = nlohmann::json::array(); + for (const auto& selection : *favorites) + canonical.push_back(color_selection_to_json(selection)); + // set_section is a raw replacement and does not mark AppConfig dirty itself. + config.set_section("color_picker", {{"version", "1"}, {"favorites", canonical.dump()}}); + config.set_dirty(); + config.save(); + return true; +} + +ColorPickerFavoritesState load_color_picker_favorites(AppConfig& config) +{ + if (config.has_section("color_picker")) { + if (config.get("color_picker", "version") != "1") + return {{}, false}; + const auto values = nlohmann::json::parse(config.get("color_picker", "favorites"), nullptr, false); + const auto favorites = color_favorites_from_json(values); + return {favorites ? *favorites : std::vector{}, true}; + } + + nlohmann::json imported = nlohmann::json::array(); + for (const std::string& value_string : config.get_custom_color_from_config()) { + const auto selection = legacy_color(value_string); + if (!selection) + continue; + const auto value = color_selection_to_json(*selection); + bool duplicate = false; + for (const auto& existing : imported) + if (existing == value) { + duplicate = true; + break; + } + if (!duplicate) + imported.push_back(value); + if (imported.size() == 24) + break; + } + // Persist the version even for an empty import: clearing never reimports. + save_color_picker_favorites(config, imported); + return {*color_favorites_from_json(imported), true}; +} + +} // namespace + +std::optional filament_color_picker_selection(const FilamentColorPickerValue& value) +{ + return color_selection_from_json({{"type", value.gradient ? "gradient" : "solid"}, {"colors", value.colors}}, {true, true}); +} + +ColorSelection filament_color_picker_initial(const FilamentColorPickerValue& value) +{ + if (const auto exact = filament_color_picker_selection(value)) + return *exact; + if (!value.colors.empty()) { + if (const auto primary = color_selection_from_json({{"type", "solid"}, {"colors", {value.colors.front()}}}, {false, true})) + return *primary; + } + if (const auto primary = color_selection_from_json({{"type", "solid"}, {"colors", {value.primary}}}, {false, true})) + return *primary; + return ColorRGBA(0.f, 0.f, 0.f, 1.f); +} + +FilamentColorPickerValue filament_color_picker_result(const ColorSelection& selection) +{ + // Keep legacy RGB spelling for opaque colors; retain RGBA for transparency. + const auto canonical = color_selection_to_json(selection); + FilamentColorPickerValue value; + value.gradient = canonical.at("type") == "gradient"; + for (const auto& color : canonical.at("colors")) { + auto hex = color.get(); + if (hex.compare(7, 2, "FF") == 0) hex.resize(7); + value.colors.push_back(std::move(hex)); + } + return value; +} + +bool filament_color_picker_changed(const ColorSelection& initial, const std::optional& result) +{ + // Compare encoded bytes, not ColorRGBA's tolerant floating point equality. + // An unchanged seed also leaves unrepresentable multi-color data untouched. + return result && color_selection_to_json(initial) != color_selection_to_json(*result); +} + +namespace { + +// Sidebar state lives in its own section: "color_picker" is replaced wholesale when favorites are +// saved, and its presence alone marks favorites as already migrated. +constexpr const char* COLOR_PICKER_UI_SECTION = "color_picker_ui"; + +// The page's own size in DIP (style.css): body padding 10 oneach side around the 330 x 360 picker, +// and the 160px sidebar plus its 10px gap. Opening at this size means the +// first resize message from the page normally changes nothing, so the window doesn't jump. +constexpr int COLOR_PICKER_PAGE_WIDTH = 330 + 2 * 10; +constexpr int COLOR_PICKER_PAGE_HEIGHT = 360 + 2 * 10; +constexpr int COLOR_PICKER_SIDEBAR_WIDTH = 160 + 10; + +// Palette ids are short lowercase words defined by the page (basic, orca, ral, gradient, ...). +bool valid_palette_id(const std::string& id) +{ + return !id.empty() && id.size() <= 32 && + std::all_of(id.begin(), id.end(), [](char c) { return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-'; }); +} + +// English palette names are stable translation keys; RAL codes stay in the page data. +nlohmann::json color_picker_ui_strings() +{ + return { + {"Color Picker", _u8L("Color Picker")}, + {"Basic Colors", _u8L("Basic Colors")}, + {"Orca Palette", _u8L("Orca Palette")},// TRN: Keep the brand name "Orca" unchanged; translate only the palette descriptor. + {"RAL Classic", _u8L("RAL Classic")}, // TRN: Keep the standard/brand name "RAL" unchanged. + {"Gradients", _u8L("Gradients")}, + {"Solid", _u8L("Solid")}, + {"Gradient", _u8L("Gradient")}, + {"Gradient start", _u8L("Gradient start")}, + {"Gradient end", _u8L("Gradient end")}, + {"Gradient color", _u8L("Gradient color")}, + {"Add color", _u8L("Add color")}, + {"Remove color", _u8L("Remove color")}, + {"Saturation", _u8L("Saturation")}, + {"Original color", _u8L("Original color")}, + // TRN: Clicking the current color preview adds it to the saved favorites. + {"Save current color", _u8L("Save current color")}, + {"Palettes", _u8L("Palettes")}, + {"Search colors", _u8L("Search colors")}, + {"Filter colors", _u8L("Filter colors")}, + {"No matching colors", _u8L("No matching colors")}, + {"Hex color", _u8L("Hex color")}, + {"Opacity", _u8L("Opacity")}, + {"Opacity percent", _u8L("Opacity percent")}, + {"Save color", _u8L("Save color")}, + {"Cancel", _u8L("Cancel")}, + {"OK", _u8L("OK")}, + {"Enter a complete hexadecimal color", _u8L("Enter a complete hexadecimal color")}, + {"Invalid input", _u8L("Invalid input")}, + {"Value is out of range.", _u8L("Value is out of range.")}, + {"The current multi-color selection is kept until you choose a different color.", _u8L("The current multi-color selection is kept until you choose a different color.")}, + {"Up to %d colors supported.", _u8L("Up to %d colors supported.")}, + {"Red", _u8L("Red")}, + {"Crimson", _u8L("Crimson")}, + {"Orange red", _u8L("Orange red")}, + {"Tomato", _u8L("Tomato")}, + {"Orange", _u8L("Orange")}, + {"Dark orange", _u8L("Dark orange")}, + {"Gold", _u8L("Gold")}, + {"Burly wood", _u8L("Burly wood")}, + {"Bisque", _u8L("Bisque")}, + {"Misty rose", _u8L("Misty rose")}, + {"Yellow", _u8L("Yellow")}, + {"Light yellow", _u8L("Light yellow")}, + {"Lemon chiffon", _u8L("Lemon chiffon")}, + {"Beige", _u8L("Beige")}, + {"Light golden rod yellow", _u8L("Light golden rod yellow")}, + {"Pale golden rod", _u8L("Pale golden rod")}, + {"Khaki", _u8L("Khaki")}, + {"Dark khaki", _u8L("Dark khaki")}, + {"Peach puff", _u8L("Peach puff")}, + {"Moccasin", _u8L("Moccasin")}, + {"Papaya whip", _u8L("Papaya whip")}, + {"Blanched almond", _u8L("Blanched almond")}, + {"Antique white", _u8L("Antique white")}, + {"Navajo white", _u8L("Navajo white")}, + {"Wheat", _u8L("Wheat")}, + {"Golden rod", _u8L("Golden rod")}, + {"Chocolate", _u8L("Chocolate")}, + {"Peru", _u8L("Peru")}, + {"Fire brick", _u8L("Fire brick")}, + {"Sienna", _u8L("Sienna")}, + {"Saddle brown", _u8L("Saddle brown")}, + {"Brown", _u8L("Brown")}, + {"Rosy brown", _u8L("Rosy brown")}, + {"Salmon", _u8L("Salmon")}, + {"Light salmon", _u8L("Light salmon")}, + {"Dark salmon", _u8L("Dark salmon")}, + {"Sandy brown", _u8L("Sandy brown")}, + {"Coral", _u8L("Coral")}, + {"Light coral", _u8L("Light coral")}, + {"Indian red", _u8L("Indian red")}, + {"Dark red", _u8L("Dark red")}, + {"Maroon", _u8L("Maroon")}, + {"Green yellow", _u8L("Green yellow")}, + {"Yellow green", _u8L("Yellow green")}, + {"Chartreuse", _u8L("Chartreuse")}, + {"Lawn green", _u8L("Lawn green")}, + {"Lime green", _u8L("Lime green")}, + {"Spring green", _u8L("Spring green")}, + {"Medium spring green", _u8L("Medium spring green")}, + {"Lime", _u8L("Lime")}, + {"Green", _u8L("Green")}, + {"Forest green", _u8L("Forest green")}, + {"Dark green", _u8L("Dark green")}, + {"Olive drab", _u8L("Olive drab")}, + {"Olive", _u8L("Olive")}, + {"Dark olive green", _u8L("Dark olive green")}, + {"Pale green", _u8L("Pale green")}, + {"Light green", _u8L("Light green")}, + {"Medium sea green", _u8L("Medium sea green")}, + {"Sea green", _u8L("Sea green")}, + {"Dark sea green", _u8L("Dark sea green")}, + {"Cadet blue", _u8L("Cadet blue")}, + {"Medium aqua marine", _u8L("Medium aqua marine")}, + {"Turquoise", _u8L("Turquoise")}, + {"Medium turquoise", _u8L("Medium turquoise")}, + {"Dark turquoise", _u8L("Dark turquoise")}, + {"Pale turquoise", _u8L("Pale turquoise")}, + {"Aqua", _u8L("Aqua")}, + {"Cyan", _u8L("Cyan")}, + {"Light cyan", _u8L("Light cyan")}, + {"Mint cream", _u8L("Mint cream")}, + {"Honey dew", _u8L("Honey dew")}, + {"Aquamarine", _u8L("Aquamarine")}, + {"Light sea green", _u8L("Light sea green")}, + {"Dark cyan", _u8L("Dark cyan")}, + {"Teal", _u8L("Teal")}, + {"Dark slate gray", _u8L("Dark slate gray")}, + {"Light slate gray", _u8L("Light slate gray")}, + {"Slate gray", _u8L("Slate gray")}, + {"Deep sky blue", _u8L("Deep sky blue")}, + {"Dodger Blue", _u8L("Dodger Blue")}, + {"Sky blue", _u8L("Sky blue")}, + {"Light sky blue", _u8L("Light sky blue")}, + {"Light blue", _u8L("Light blue")}, + {"Powder blue", _u8L("Powder blue")}, + {"Light steel blue", _u8L("Light steel blue")}, + {"Steel blue", _u8L("Steel blue")}, + {"Cornflower blue", _u8L("Cornflower blue")}, + {"Royal blue", _u8L("Royal blue")}, + {"Blue", _u8L("Blue")}, + {"Medium blue", _u8L("Medium blue")}, + {"Midnight blue", _u8L("Midnight blue")}, + {"Dark blue", _u8L("Dark blue")}, + {"Navy", _u8L("Navy")}, + {"Slate blue", _u8L("Slate blue")}, + {"Dark slate blue", _u8L("Dark slate blue")}, + {"Medium slate blue", _u8L("Medium slate blue")}, + {"Blue violet", _u8L("Blue violet")}, + {"Indigo", _u8L("Indigo")}, + {"Dark violet", _u8L("Dark violet")}, + {"Dark orchid", _u8L("Dark orchid")}, + {"Medium orchid", _u8L("Medium orchid")}, + {"Orchid", _u8L("Orchid")}, + {"Violet", _u8L("Violet")}, + {"Medium purple", _u8L("Medium purple")}, + {"Purple", _u8L("Purple")}, + {"Rebecca purple", _u8L("Rebecca purple")}, + {"Medium violet red", _u8L("Medium violet red")}, + {"PaleV violet red", _u8L("PaleV violet red")}, + {"Deep pink", _u8L("Deep pink")}, + {"Hot pink", _u8L("Hot pink")}, + {"Light pink", _u8L("Light pink")}, + {"Pink", _u8L("Pink")}, + {"Plum", _u8L("Plum")}, + {"Thistle", _u8L("Thistle")}, + {"Magenta", _u8L("Magenta")}, + {"Fuchsia", _u8L("Fuchsia")}, + {"Dark magenta", _u8L("Dark magenta")}, + {"Linen", _u8L("Linen")}, + {"Old lace", _u8L("Old lace")}, + {"Sea shell", _u8L("Sea shell")}, + {"Ivory", _u8L("Ivory")}, + {"Floral white", _u8L("Floral white")}, + {"Lavender blush", _u8L("Lavender blush")}, + {"Ghost white", _u8L("Ghost white")}, + {"Alice blue", _u8L("Alice blue")}, + {"Lavender", _u8L("Lavender")}, + {"White smoke", _u8L("White smoke")}, + {"Gainsboro", _u8L("Gainsboro")}, + {"Black", _u8L("Black")}, + {"White", _u8L("White")}, + {"Coral fire", _u8L("Coral fire")}, + {"Sea anemone", _u8L("Sea anemone")}, + {"Deep abyss", _u8L("Deep abyss")}, + {"Lobster red", _u8L("Lobster red")}, + {"Sandy clay", _u8L("Sandy clay")}, + {"Driftwood", _u8L("Driftwood")}, + {"Sea buckthorn", _u8L("Sea buckthorn")}, + {"Sunset reef", _u8L("Sunset reef")}, + {"Tiger coral", _u8L("Tiger coral")}, + {"Pink coral", _u8L("Pink coral")}, + {"Seashell glow", _u8L("Seashell glow")}, + {"Sand", _u8L("Sand")}, + {"Golden kelp", _u8L("Golden kelp")}, + {"Sunlit dune", _u8L("Sunlit dune")}, + {"Amber nautilus", _u8L("Amber nautilus")}, + {"Sea lemon", _u8L("Sea lemon")}, + {"Pale starfish", _u8L("Pale starfish")}, + {"Spring tide", _u8L("Spring tide")}, + {"Lime algae", _u8L("Lime algae")}, + {"Sea grass", _u8L("Sea grass")}, + {"Neon plankton", _u8L("Neon plankton")}, + {"Kelp forest", _u8L("Kelp forest")}, + {"Emerald wave", _u8L("Emerald wave")}, + {"Whale shadow", _u8L("Whale shadow")}, + {"Sea turtle", _u8L("Sea turtle")}, + {"Lagoon", _u8L("Lagoon")}, + {"Tropical current", _u8L("Tropical current")}, + {"Midnight trench", _u8L("Midnight trench")}, + {"Dark kelp", _u8L("Dark kelp")}, + {"Crystal surf", _u8L("Crystal surf")}, + {"Turquoise bay", _u8L("Turquoise bay")}, + {"Sky over reef", _u8L("Sky over reef")}, + {"Shallow sky", _u8L("Shallow sky")}, + {"Electric bel", _u8L("Electric bel")}, + {"Dolphin", _u8L("Dolphin")}, + {"Ocean depths", _u8L("Ocean depths")}, + {"Whale", _u8L("Whale")}, + {"Indigo abyss", _u8L("Indigo abyss")}, + {"Sea urchin", _u8L("Sea urchin")}, + {"Lavender coral", _u8L("Lavender coral")}, + {"Violet tide", _u8L("Violet tide")}, + {"Deep sea orchid", _u8L("Deep sea orchid")}, + {"Neon jelly", _u8L("Neon jelly")}, + {"Fuchsia coral", _u8L("Fuchsia coral")}, + {"Pink jellyfish", _u8L("Pink jellyfish")}, + {"Foam", _u8L("Foam")}, + {"Seashell white", _u8L("Seashell white")}, + {"Pebble gray", _u8L("Pebble gray")}, + {"Storm cloud", _u8L("Storm cloud")}, + {"Ink black", _u8L("Ink black")}, + {"Green beige", _u8L("Green beige")}, + {"Sand yellow", _u8L("Sand yellow")}, + {"Signal yellow", _u8L("Signal yellow")}, + {"Golden yellow", _u8L("Golden yellow")}, + {"Honey yellow", _u8L("Honey yellow")}, + {"Maize yellow", _u8L("Maize yellow")}, + {"Dalmatian yellow", _u8L("Dalmatian yellow")}, + {"Brown beige", _u8L("Brown beige")}, + {"Lemon yellow", _u8L("Lemon yellow")}, + {"Oyster white", _u8L("Oyster white")}, + {"Light ivory", _u8L("Light ivory")}, + {"Zinc yellow", _u8L("Zinc yellow")}, + {"Grey beige", _u8L("Grey beige")}, + {"Olive yellow", _u8L("Olive yellow")}, + {"Rape yellow", _u8L("Rape yellow")}, + {"Traffic yellow", _u8L("Traffic yellow")}, + {"Ochre yellow", _u8L("Ochre yellow")}, + {"Traffic orange yellow", _u8L("Traffic orange yellow")}, + {"Curry", _u8L("Curry")}, + {"Melon yellow", _u8L("Melon yellow")}, + {"Broom yellow", _u8L("Broom yellow")}, + {"Dahlia yellow", _u8L("Dahlia yellow")}, + {"Pastel yellow", _u8L("Pastel yellow")}, + {"Pearl beige", _u8L("Pearl beige")}, + {"Pearl gold", _u8L("Pearl gold")}, + {"Sun yellow", _u8L("Sun yellow")}, + {"Yellow orange", _u8L("Yellow orange")}, + {"Deep orange", _u8L("Deep orange")}, + {"Vermilion", _u8L("Vermilion")}, + {"Pearl orange", _u8L("Pearl orange")}, + {"Pure orange", _u8L("Pure orange")}, + {"Luminous orange", _u8L("Luminous orange")}, + {"Luminous bright orange", _u8L("Luminous bright orange")}, + {"Bright red orange", _u8L("Bright red orange")}, + {"Traffic orange", _u8L("Traffic orange")}, + {"Signal orange", _u8L("Signal orange")}, + {"Salmon orange", _u8L("Salmon orange")}, + {"Pearl pink", _u8L("Pearl pink")}, + {"Copper orange", _u8L("Copper orange")}, + {"Signal brown", _u8L("Signal brown")}, + {"Fir orange", _u8L("Fir orange")}, + {"Signal red", _u8L("Signal red")}, + {"Luminous red", _u8L("Luminous red")}, + {"Carmine red", _u8L("Carmine red")}, + {"Pearl carmine red", _u8L("Pearl carmine red")}, + {"Pure red", _u8L("Pure red")}, + {"Luminous deep red", _u8L("Luminous deep red")}, + {"Currant red", _u8L("Currant red")}, + {"Pearl ruby red", _u8L("Pearl ruby red")}, + {"Fire red", _u8L("Fire red")}, + {"Ruby red", _u8L("Ruby red")}, + {"Purple red", _u8L("Purple red")}, + {"Wine red", _u8L("Wine red")}, + {"Black red", _u8L("Black red")}, + {"Oxide red", _u8L("Oxide red")}, + {"Brown red", _u8L("Brown red")}, + {"Pine red", _u8L("Pine red")}, + {"Tomato red", _u8L("Tomato red")}, + {"Antique pink", _u8L("Antique pink")}, + {"Coral red", _u8L("Coral red")}, + {"Rose", _u8L("Rose")}, + {"Strawberry red", _u8L("Strawberry red")}, + {"Traffic red", _u8L("Traffic red")}, + {"Salmon pink", _u8L("Salmon pink")}, + {"Luminous rose", _u8L("Luminous rose")}, + {"Luminous bright red", _u8L("Luminous bright red")}, + {"Raspberry red", _u8L("Raspberry red")}, + {"Orient red", _u8L("Orient red")}, + {"Pearl ruby", _u8L("Pearl ruby")}, + {"Red purple", _u8L("Red purple")}, + {"Red violet", _u8L("Red violet")}, + {"Heather violet", _u8L("Heather violet")}, + {"Bordeaux violet", _u8L("Bordeaux violet")}, + {"Mahogany", _u8L("Mahogany")}, + {"Grey purple", _u8L("Grey purple")}, + {"Signal violet", _u8L("Signal violet")}, + {"Fir green", _u8L("Fir green")}, + {"Sabine grey", _u8L("Sabine grey")}, + {"Pearl violet", _u8L("Pearl violet")}, + {"Pearl blackberry", _u8L("Pearl blackberry")}, + {"Green blue", _u8L("Green blue")}, + {"Ultramarine blue", _u8L("Ultramarine blue")}, + {"Sapphire blue", _u8L("Sapphire blue")}, + {"Black blue", _u8L("Black blue")}, + {"Signal blue", _u8L("Signal blue")}, + {"Brilliant blue", _u8L("Brilliant blue")}, + {"Grey blue", _u8L("Grey blue")}, + {"Azure blue", _u8L("Azure blue")}, + {"Gentian blue", _u8L("Gentian blue")}, + {"Cobalt blue", _u8L("Cobalt blue")}, + {"Pigeon blue", _u8L("Pigeon blue")}, + {"Traffic blue", _u8L("Traffic blue")}, + {"Turquoise blue", _u8L("Turquoise blue")}, + {"Capsanthin violet", _u8L("Capsanthin violet")}, + {"Ocean blue", _u8L("Ocean blue")}, + {"Water blue", _u8L("Water blue")}, + {"Night blue", _u8L("Night blue")}, + {"Distant blue", _u8L("Distant blue")}, + {"Pastel blue", _u8L("Pastel blue")}, + {"Pearl gentian blue", _u8L("Pearl gentian blue")}, + {"Pearl midnight blue", _u8L("Pearl midnight blue")}, + {"Patina green", _u8L("Patina green")}, + {"Emerald green", _u8L("Emerald green")}, + {"Leaf green", _u8L("Leaf green")}, + {"Olive green", _u8L("Olive green")}, + {"Blue green", _u8L("Blue green")}, + {"Moss green", _u8L("Moss green")}, + {"Grey olive", _u8L("Grey olive")}, + {"Pine green", _u8L("Pine green")}, + {"Pine brown", _u8L("Pine brown")}, + {"Grass green", _u8L("Grass green")}, + {"Reseda green", _u8L("Reseda green")}, + {"Brown green", _u8L("Brown green")}, + {"Reed green", _u8L("Reed green")}, + {"Black green", _u8L("Black green")}, + {"Turquoise green", _u8L("Turquoise green")}, + {"May green", _u8L("May green")}, + {"Chrome oxide green", _u8L("Chrome oxide green")}, + {"Traffic green", _u8L("Traffic green")}, + {"Fern green", _u8L("Fern green")}, + {"Opal green", _u8L("Opal green")}, + {"Mint green", _u8L("Mint green")}, + {"Bronze green", _u8L("Bronze green")}, + {"Pearl green", _u8L("Pearl green")}, + {"Sax turquoise", _u8L("Sax turquoise")}, + {"Pearl turquoise", _u8L("Pearl turquoise")}, + {"Pearl jade", _u8L("Pearl jade")}, + {"Pure green", _u8L("Pure green")}, + {"Luminous green", _u8L("Luminous green")}, + {"Squirrel grey", _u8L("Squirrel grey")}, + {"Silver grey", _u8L("Silver grey")}, + {"Olivestone", _u8L("Olivestone")}, + {"Moss grey", _u8L("Moss grey")}, + {"Signal grey", _u8L("Signal grey")}, + {"Mouse grey", _u8L("Mouse grey")}, + {"Beige grey", _u8L("Beige grey")}, + {"Khaki grey", _u8L("Khaki grey")}, + {"Green grey", _u8L("Green grey")}, + {"Tarpaulin grey", _u8L("Tarpaulin grey")}, + {"Iron grey", _u8L("Iron grey")}, + {"Basalt grey", _u8L("Basalt grey")}, + {"Brown grey", _u8L("Brown grey")}, + {"Slate grey", _u8L("Slate grey")}, + {"Anthracite grey", _u8L("Anthracite grey")}, + {"Light grey", _u8L("Light grey")}, + {"Umber grey", _u8L("Umber grey")}, + {"Concrete grey", _u8L("Concrete grey")}, + {"Graphite grey", _u8L("Graphite grey")}, + {"Granite grey", _u8L("Granite grey")}, + {"Stone grey", _u8L("Stone grey")}, + {"Pebble grey", _u8L("Pebble grey")}, + {"Cement grey", _u8L("Cement grey")}, + {"Yellow grey", _u8L("Yellow grey")}, + {"Platinum grey", _u8L("Platinum grey")}, + {"Dusty grey", _u8L("Dusty grey")}, + {"Agate grey", _u8L("Agate grey")}, + {"Quartz grey", _u8L("Quartz grey")}, + {"Window grey", _u8L("Window grey")}, + {"Traffic grey A", _u8L("Traffic grey A")}, + {"Traffic grey B", _u8L("Traffic grey B")}, + {"Silk grey", _u8L("Silk grey")}, + {"Telegrey 1", _u8L("Telegrey 1")}, + {"Telegrey 2", _u8L("Telegrey 2")}, + {"Telegrey 4", _u8L("Telegrey 4")}, + {"Pearl mouse grey", _u8L("Pearl mouse grey")}, + {"Green brown", _u8L("Green brown")}, + {"Ochre brown", _u8L("Ochre brown")}, + {"Clay brown", _u8L("Clay brown")}, + {"Copper brown", _u8L("Copper brown")}, + {"Chocolate brown", _u8L("Chocolate brown")}, + {"Olive brown", _u8L("Olive brown")}, + {"Nut brown", _u8L("Nut brown")}, + {"Red brown", _u8L("Red brown")}, + {"Sepia brown", _u8L("Sepia brown")}, + {"Chestnut brown", _u8L("Chestnut brown")}, + {"Mahogany brown", _u8L("Mahogany brown")}, + {"Grey brown", _u8L("Grey brown")}, + {"Pearl copper", _u8L("Pearl copper")}, + {"Beige brown", _u8L("Beige brown")}, + {"Pearl mocha brown", _u8L("Pearl mocha brown")}, + {"Earth brown", _u8L("Earth brown")}, + {"Pearl brown", _u8L("Pearl brown")}, + {"Cream", _u8L("Cream")}, + {"Grey white", _u8L("Grey white")}, + {"Signal white", _u8L("Signal white")}, + {"Signal black", _u8L("Signal black")}, + {"Jet black", _u8L("Jet black")}, + {"White aluminium", _u8L("White aluminium")}, + {"Grey aluminium", _u8L("Grey aluminium")}, + {"Pure white", _u8L("Pure white")}, + {"Graphite black", _u8L("Graphite black")}, + {"Traffic white", _u8L("Traffic white")}, + {"Traffic black", _u8L("Traffic black")}, + {"Papyrus white", _u8L("Papyrus white")}, + {"Pearl light grey", _u8L("Pearl light grey")}, + {"Metal: Silver", _u8L("Metal: Silver")}, + {"Metal: Brass", _u8L("Metal: Brass")}, + {"Metal: Gold", _u8L("Metal: Gold")}, + {"Metal: Copper", _u8L("Metal: Copper")}, + {"Metal: Bronze", _u8L("Metal: Bronze")}, + {"Nature: Pearl", _u8L("Nature: Pearl")}, + {"Nature: Rainbow", _u8L("Nature: Rainbow")}, + {"Nature: Clear Sky", _u8L("Nature: Clear Sky")}, + {"Nature: Sunset", _u8L("Nature: Sunset")}, + {"Wood: Dark", _u8L("Wood: Dark")}, + {"Wood: Medium", _u8L("Wood: Medium")}, + {"Wood: Light", _u8L("Wood: Light")} + }; +} + +} // namespace + + +namespace { + +// All geometry uses native screen coordinates, including negative monitor origins. +wxPoint color_picker_panel_position(const wxRect& anchor, const wxRect& work_area, const wxSize& size, int gap) +{ + const int below = anchor.y + anchor.height + gap; + const int above = anchor.y - gap - size.y; + const int y = below + size.y <= work_area.y + work_area.height ? below : above; + return {std::clamp(anchor.x, work_area.x, work_area.x + std::max(0, work_area.width - size.x)), + std::clamp(y, work_area.y, work_area.y + std::max(0, work_area.height - size.y))}; +} + +} // namespace + +ColorPickerDialog::ColorPickerDialog(wxWindow* parent, const ColorSelection& initial, ColorPickerOptions options, + bool preserve_multi_color, wxWindow* anchor) + : WebViewHostDialog(parent, wxID_ANY, _L("Color Picker"), wxDefaultPosition, wxDefaultSize, + wxCAPTION | wxSYSTEM_MENU | wxCLOSE_BOX | wxFRAME_NO_TASKBAR), + m_initial(initial), m_options(options), m_preserve_multi_color(preserve_multi_color) +{ + Bind(wxEVT_CLOSE_WINDOW, [this](wxCloseEvent&) { finish(wxID_CANCEL); }); + Bind(wxEVT_CHAR_HOOK, [this](wxKeyEvent& event) { + if (event.GetKeyCode() == WXK_ESCAPE) + finish(wxID_CANCEL); + else + event.Skip(); + }); + SetBackgroundColour(wxGetApp().get_window_default_clr()); + wxGetApp().UpdateDlgDarkUI(this); +#ifdef __WXGTK__ + // wxGTK dialogs default to CENTER_ON_PARENT, which conflicts with the anchor. + gtk_window_set_position(GTK_WINDOW(GetHandle()), GTK_WIN_POS_NONE); +#endif + // Snapshot the trigger instead of following the mouse or retaining a raw pointer. + wxWindow* trigger = anchor ? anchor : parent; + if (trigger) + m_anchor_rect = trigger->GetScreenRect(); + int display_index = wxDisplay::GetFromPoint(m_anchor_rect.GetPosition() + wxPoint(m_anchor_rect.width / 2, m_anchor_rect.height / 2)); + if (display_index == wxNOT_FOUND) + display_index = 0; + m_work_area = wxDisplay(static_cast(display_index)).GetClientArea(); + const auto normalized = normalize_color_selection(initial, options); + if (normalized) + m_initial = *normalized; + // Read the sidebar state before the WebView exists: add_user_scripts() passes it to the page, + // and the window opens at the matching size instead of resizing after the page loads. + if (wxGetApp().app_config) { + const AppConfig& config = *wxGetApp().app_config; + m_sidebar_open = config.get(COLOR_PICKER_UI_SECTION, "sidebar_open") == "1"; + if (const std::string palette = config.get(COLOR_PICKER_UI_SECTION, "palette"); valid_palette_id(palette)) + m_palette = palette; + } + m_content_width = COLOR_PICKER_PAGE_WIDTH + (m_sidebar_open ? COLOR_PICKER_SIDEBAR_WIDTH : 0); + m_content_height = COLOR_PICKER_PAGE_HEIGHT; + if (!normalized || !create_webview("web/dialog/ColorPickerDialog/index.html", _L("Color Picker"), + wxSize(m_content_width, m_content_height), + wxSize(COLOR_PICKER_PAGE_WIDTH, COLOR_PICKER_PAGE_HEIGHT))) { + auto* sizer = new wxBoxSizer(wxVERTICAL); + sizer->Add(new wxStaticText(this, wxID_ANY, wxS("wxWebView unavailable")), wxSizerFlags().Border(wxALL, FromDIP(20))); + sizer->Add(new wxButton(this, wxID_CANCEL, _L("Cancel")), wxSizerFlags().Right().Border(wxALL, FromDIP(8))); + SetSizerAndFit(sizer); + Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { finish(wxID_CANCEL); }, wxID_CANCEL); + Bind(wxEVT_SHOW, [this](wxShowEvent& event) { + if (event.IsShown() && !m_initial_positioned) { + move_to_anchor(); + wxGetApp().CallAfter([this, alive = m_alive] { + if (alive->load(std::memory_order_acquire) && !m_closing) { + move_to_anchor(); + m_initial_positioned = true; + } + }); + } + event.Skip(); + }); + wxGetApp().UpdateDlgDarkUI(this); + move_to_anchor(); + return; + } + browser()->EnableBrowserAcceleratorKeys(false); + browser()->Bind(EVT_WEBVIEW_RECREATED, [this](wxCommandEvent& event) { + wxGetApp().UpdateDlgDarkUI(this); + event.Skip(); // The shared host updates the page theme. + }); + Bind(wxEVT_ACTIVATE, [this](wxActivateEvent& event) { + if (event.GetActive() && IsShown() && !m_closing) + focus_webview(); + event.Skip(); + }); + if (wxGetApp().app_config) { + const auto stored = load_color_picker_favorites(*wxGetApp().app_config); + m_favorites = stored.favorites; + m_favorites_writable = stored.writable; + } + // The shared host attaches its sizer before constructing the full layout. + GetSizer()->SetSizeHints(this); + Move(m_work_area.GetPosition()); + // SetSizeHints may fit the window to the WebView's small initial best size. + // Restore the intended dialog size only after applying the layout hints. + resize_to_content(m_content_height); + move_to_anchor(); + Bind(wxEVT_SHOW, [this](wxShowEvent& event) { + if (event.IsShown() && !m_initial_positioned) { + resize_to_content(m_content_height); + move_to_anchor(); + // GTK may ignore the requested position until the window is shown. + wxGetApp().CallAfter([this, alive = m_alive] { + if (alive->load(std::memory_order_acquire) && !m_closing) { + resize_to_content(m_content_height); + move_to_anchor(); + m_initial_positioned = true; + focus_webview(); + } + }); + } + event.Skip(); + }); +} + +ColorPickerDialog::~ColorPickerDialog() { m_alive->store(false, std::memory_order_release); } + +void ColorPickerDialog::position_panel() +{ + if (m_positioning) + return; + m_positioning = true; + // Both dimensions come from the page: width changes when the palette sidebar opens or closes. + wxSize size = FromDIP(wxSize(m_content_width, m_content_height)); + // The work-area limit includes the native title bar and fixed frame. + const wxSize frame = GetSize() - GetClientSize(); + size.x = std::min(size.x, std::max(1, m_work_area.width - frame.x)); + size.y = std::min(size.y, std::max(1, m_work_area.height - frame.y - FromDIP(16))); + SetMinSize(wxSize(std::min(FromDIP(320), size.x), std::min(FromDIP(360), size.y))); + if (size != GetClientSize()) + SetClientSize(size); + m_positioning = false; +} + +void ColorPickerDialog::move_to_anchor() +{ + const wxPoint position = color_picker_panel_position(m_anchor_rect, m_work_area, GetSize(), FromDIP(4)); + if (position != GetPosition()) + Move(position); +#ifdef __WXGTK__ + // wxGTK caches requested coordinates: Move can skip a post-show request even + // when the window manager ignored the initial one. Reapply at native level. + gtk_window_move(GTK_WINDOW(GetHandle()), position.x, position.y); +#endif +} + +void ColorPickerDialog::resize_to_content(int height) +{ + m_content_height = height; + position_panel(); + Layout(); +#ifdef __WXOSX__ + // Like SpeedDial, explicitly match WKWebView's viewport even at unchanged size. + if (wxWebView* view = browser()) + view->SetSize(GetClientSize()); +#endif + repaint_webview(); +} + +void ColorPickerDialog::focus_webview() +{ + wxWebView* view = browser(); + if (!view) + return; +#ifdef __linux__ + if (void* backend = view->GetNativeBackend()) + gtk_widget_grab_focus(static_cast(backend)); +#else + view->SetFocus(); +#endif + if (m_page_ready) + run_script("window.ColorPickerDialog.focusInput();"); +} + +void ColorPickerDialog::repaint_webview() +{ + wxWebView* view = browser(); + if (!view) + return; + view->Refresh(); +#ifdef __WXOSX__ + if (void* backend = view->GetNativeBackend()) + WKWebView_force_display(backend); + view->Update(); +#elif defined(__linux__) + if (void* backend = view->GetNativeBackend()) + gtk_widget_queue_draw(static_cast(backend)); +#else + view->Update(); +#endif +} + +void ColorPickerDialog::on_dpi_changed(const wxRect&) +{ + resize_to_content(m_content_height); + Refresh(); +} + +void ColorPickerDialog::on_sys_color_changed() +{ + wxGetApp().UpdateDlgDarkUI(this); +} + +void ColorPickerDialog::add_user_scripts() +{ + if (wxWebView* view = browser()) { + auto language = wxGetApp().current_language_code().ToStdString(); + std::replace(language.begin(), language.end(), '_', '-'); + const std::string script = "window.ORCA_UI_STRINGS = " + + color_picker_ui_strings().dump(-1, ' ', false, nlohmann::json::error_handler_t::ignore) + + ";window.ORCA_COLOR_PICKER_LANGUAGE = " + nlohmann::json(language).dump() + + // Lets the page lay out the sidebar before its first paint, matching the window size. + ";window.ORCA_COLOR_PICKER_UI = " + nlohmann::json({{"sidebar_open", m_sidebar_open}, {"palette", m_palette}}).dump() + ";"; + view->AddUserScript(wxString::FromUTF8(script)); + } +} + +void ColorPickerDialog::on_script_message(const nlohmann::json& payload) +{ + // GTK/WebKit may call on the native script-message stack. All dialog work, + // including EndModal, runs after that callback and checks the lifetime token. + wxGetApp().CallAfter([this, alive = m_alive, payload] { + if (alive->load(std::memory_order_acquire) && !m_closing) + handle_web_command(payload); + }); +} + +void ColorPickerDialog::handle_web_command(const nlohmann::json& payload) +{ + if (!browser() || !payload.is_object()) + return; + const auto command = payload.find("command"); + const auto page_id = payload.find("page_id"); + if (command == payload.end() || !command->is_string() || page_id == payload.end() || !page_id->is_string()) + return; + const auto& id = page_id->get_ref(); + if (id.empty() || id.size() > 64) + return; + if (*command == "ready") { + // A repeated ready from the same document must not reset its edits. A new + // document (e.g. WebView recreation) gets a fresh initialization handshake. + if (id != m_page_id) { + m_page_id = id; + m_page_ready = false; + m_init_sent = false; + } + if (!m_init_sent) + send_initial_state(); + return; + } + if (id != m_page_id) + return; + if (*command == "cancel") { + finish(wxID_CANCEL); + return; + } + if (*command == "initialized") { + if (m_init_sent && !m_page_ready) { + m_page_ready = true; + focus_webview(); + repaint_webview(); + } + return; + } + if (!m_page_ready) + return; + if (*command == "resize") { + const auto height = payload.find("height"); + if (height == payload.end() || !height->is_number()) + return; + const double value = height->get(); + if (!std::isfinite(value) || value < 300 || value > 900) + return; + // Width is optional so an older page that reports only height keeps the current width. + const auto width = payload.find("width"); + if (width != payload.end()) { + if (!width->is_number()) + return; + const double width_value = width->get(); + if (!std::isfinite(width_value) || width_value < 300 || width_value > 1200) + return; + m_content_width = static_cast(std::ceil(width_value)); + } + resize_to_content(static_cast(std::ceil(value))); + return; + } + if (*command == "confirm") { + const auto value = payload.find("selection"); + if (value == payload.end()) + return; + const auto selection = color_selection_from_json(*value, m_options); + if (!selection) + return; + m_selection = *selection; + finish(wxID_OK); + } else if (*command == "update_favorites") { + const auto values = payload.find("favorites"); + if (values == payload.end() || !m_favorites_writable || !wxGetApp().app_config) + return; + const auto favorites = color_favorites_from_json(*values); + if (favorites && save_color_picker_favorites(*wxGetApp().app_config, *values)) { + m_favorites = *favorites; + nlohmann::json canonical = nlohmann::json::array(); + for (const ColorSelection& selection : m_favorites) + canonical.push_back(color_selection_to_json(selection)); + const nlohmann::json response = {{"command", "favorites"}, {"page_id", m_page_id}, {"favorites", std::move(canonical)}}; + run_script(wxString::FromUTF8("window.ColorPickerDialog.handleMessage(" + response.dump() + ")")); + } + } else if (*command == "update_ui") { + // Sidebar open/closed and the chosen palette, restored the next time the picker opens. + const auto open = payload.find("sidebar_open"); + const auto palette = payload.find("palette"); + if (open == payload.end() || !open->is_boolean() || palette == payload.end() || !palette->is_string() || !wxGetApp().app_config) + return; + const auto& id = palette->get_ref(); + if (!valid_palette_id(id)) + return; + m_sidebar_open = open->get(); + m_palette = id; + // set() marks the config dirty; it is written with the rest of the app config. + AppConfig& config = *wxGetApp().app_config; + config.set(COLOR_PICKER_UI_SECTION, "sidebar_open", m_sidebar_open ? "1" : "0"); + config.set(COLOR_PICKER_UI_SECTION, "palette", m_palette); + } +} + +void ColorPickerDialog::send_initial_state() +{ + nlohmann::json favorites = nlohmann::json::array(); + for (const ColorSelection& selection : m_favorites) + favorites.push_back(color_selection_to_json(selection)); + const nlohmann::json payload = {{"command", "init"}, {"page_id", m_page_id}, + {"options", {{"allow_gradient", m_options.allow_gradient}, {"allow_alpha", m_options.allow_alpha}, + {"max_gradient_colors", COLOR_PICKER_MAX_GRADIENT_COLORS}}}, + {"selection", color_selection_to_json(m_initial)}, {"favorites", std::move(favorites)}, + {"favorites_writable", m_favorites_writable}, {"preserve_multi_color", m_preserve_multi_color}, + {"ui", {{"sidebar_open", m_sidebar_open}, {"palette", m_palette}}}}; + m_init_sent = true; + // Already deferred and guarded: avoid the shared call_web_handler's unguarded + // second CallAfter, which could run after this modal dialog is destroyed. + if (!run_script(wxString::FromUTF8("window.ColorPickerDialog.handleMessage(" + payload.dump() + ")"))) + m_init_sent = false; +} + +void ColorPickerDialog::finish(int return_code) +{ + if (m_closing) + return; + m_closing = true; + if (return_code != wxID_OK) + m_selection.reset(); + if (IsModal()) + EndModal(return_code); + else + Hide(); +} + +} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/ColorPickerDialog.hpp b/src/slic3r/GUI/ColorPickerDialog.hpp new file mode 100644 index 0000000000..33aa59e087 --- /dev/null +++ b/src/slic3r/GUI/ColorPickerDialog.hpp @@ -0,0 +1,92 @@ +#pragma once + +#include "libslic3r/Color.hpp" +#include "Widgets/WebViewHostDialog.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class wxWindow; + +namespace Slic3r::GUI { + +// Most colors an ordered gradient may hold. This is the native switch for multi-color gradients: +// 2 keeps the classic two-color gradients. The page receives it as options.max_gradient_colors +// and never offers more, so the picker, favorites and project colors all share one limit. +inline constexpr std::size_t COLOR_PICKER_MAX_GRADIENT_COLORS = 7; + +// Gradient colors retain their order (2..COLOR_PICKER_MAX_GRADIENT_COLORS); a solid has exactly one color. +using ColorGradient = std::vector; +using ColorSelection = std::variant; + +struct ColorPickerOptions { + bool allow_gradient = false; + bool allow_alpha = false; +}; + +// Project storage preserves direction; FilamentColor's set is only for the +// official palette and cannot represent ordered custom gradients. +struct FilamentColorPickerValue { + std::vector colors; + bool gradient = false; + std::string primary; +}; + +std::optional filament_color_picker_selection(const FilamentColorPickerValue& value); +ColorSelection filament_color_picker_initial(const FilamentColorPickerValue& value); +FilamentColorPickerValue filament_color_picker_result(const ColorSelection& selection); +bool filament_color_picker_changed(const ColorSelection& initial, const std::optional& result); + +class ColorPickerDialog : public WebViewHostDialog +{ +public: + ColorPickerDialog(wxWindow* parent, const ColorSelection& initial, ColorPickerOptions options = {}, + bool preserve_multi_color = false, wxWindow* anchor = nullptr); + ~ColorPickerDialog() override; + + const std::optional& selection() const { return m_selection; } + +private: + void add_user_scripts() override; + void on_script_message(const nlohmann::json& payload) override; + void handle_web_command(const nlohmann::json& payload); + void send_initial_state(); + void finish(int return_code); + void position_panel(); + void move_to_anchor(); + void resize_to_content(int height); + void focus_webview(); + void repaint_webview(); + void on_dpi_changed(const wxRect& suggested_rect) override; + void on_sys_color_changed() override; + + ColorSelection m_initial; + ColorPickerOptions m_options; + std::optional m_selection; + std::vector m_favorites; + std::string m_page_id; + bool m_preserve_multi_color = false; + bool m_init_sent = false; + bool m_page_ready = false; + bool m_closing = false; + bool m_favorites_writable = false; + bool m_sidebar_open = false; + std::string m_palette; + bool m_initial_positioned = false; + wxRect m_anchor_rect; + wxRect m_work_area; + int m_content_height = 360; + int m_content_width = 330; + bool m_positioning = false; + std::shared_ptr> m_alive = std::make_shared>(true); +}; + +} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/Field.cpp b/src/slic3r/GUI/Field.cpp index c7d98bfd6f..f2ae0490d9 100644 --- a/src/slic3r/GUI/Field.cpp +++ b/src/slic3r/GUI/Field.cpp @@ -2,6 +2,7 @@ #include "GUI_App.hpp" #include "I18N.hpp" #include "Field.hpp" +#include "ColorPickerDialog.hpp" #include "libslic3r/GCode/Thumbnails.hpp" #include "wxExtensions.hpp" #include "Plater.hpp" @@ -45,7 +46,11 @@ #include #include #include -#include +#include +#include +#include +#include +#include #include #include #include @@ -2618,92 +2623,41 @@ void PluginConfigField::msw_rescale() void ColourPicker::BUILD() { - auto size = wxSize(def_width_wider() * m_em_unit, -1); // ORCA match color picker width + // An empty borderless wxButton is created with zero height on macOS. Give the bitmap + // field the same height as other parameter buttons before drawing its swatch. + wxSize size(def_width_wider() * m_em_unit, m_parent->FromDIP(26)); if (m_opt.height >= 0) size.SetHeight(m_opt.height*m_em_unit); if (m_opt.width >= 0) size.SetWidth(m_opt.width*m_em_unit); - // Validate the color - wxString clr_str(m_opt.type == coString ? m_opt.get_default_value()->value : m_opt.get_default_value()->get_at(m_opt_idx)); - wxColour clr(clr_str); - if (clr_str.IsEmpty() || !clr.IsOk()) { - clr = wxTransparentColour; - } - - auto temp = new wxColourPickerCtrl(m_parent, wxID_ANY, clr, wxDefaultPosition, size); + const wxString clr_str(m_opt.type == coString ? m_opt.get_default_value()->value : + m_opt.get_default_value()->get_at(m_opt_idx)); + auto* button = new wxButton(m_parent, wxID_ANY, wxEmptyString, wxDefaultPosition, size, wxBORDER_NONE); + window = button; + button->SetBitmapMargins(0, 0); if (parent_is_custom_ctrl && m_opt.height < 0) - opt_height = (double)temp->GetSize().GetHeight() / m_em_unit; - temp->SetFont(Slic3r::GUI::wxGetApp().normal_font()); - convert_to_picker_widget(temp); - if (!wxOSX) temp->SetBackgroundStyle(wxBG_STYLE_PAINT); - - wxGetApp().UpdateDarkUI(temp->GetPickerCtrl()); - - // // recast as a wxWindow to fit the calling convention - window = dynamic_cast(temp); - - temp->Bind(wxEVT_COLOURPICKER_CHANGED, ([ - #ifdef __WXMSW__ - temp, - #endif - this](wxCommandEvent e) { - #ifdef __WXMSW__ - draw_bmp_btn(temp, temp->GetColour()); - #endif - on_change_field(); - }), temp->GetId()); - - // ORCA reset value to default on right click. previously no way to switch back on windows - temp->GetPickerCtrl()->Bind(wxEVT_RIGHT_DOWN, [this, temp](wxMouseEvent e){ - #ifdef __WXMSW__ - temp->SetColour(wxTransparentColour); - draw_bmp_btn(temp, wxTransparentColour); - #else - set_undef_value(temp); - #endif - on_change_field(); - e.Skip(); + opt_height = double(button->GetSize().GetHeight()) / m_em_unit; + button->SetFont(wxGetApp().normal_font()); + if (!wxOSX) button->SetBackgroundStyle(wxBG_STYLE_PAINT); + set_value(boost::any(clr_str)); + button->Bind(wxEVT_BUTTON, &ColourPicker::on_button_click, this); + button->Bind(wxEVT_RIGHT_DOWN, [this](wxMouseEvent& event) { + apply_user_color(wxTransparentColour); + event.Skip(); }); - - temp->SetToolTip(get_tooltip_text(clr_str)); + // Native layouts may assign the final field size after construction. + button->Bind(wxEVT_SIZE, [this](wxSizeEvent& event) { + draw_bmp_btn(); + event.Skip(); + }); + button->SetToolTip(get_tooltip_text(clr_str)); } -void ColourPicker::set_undef_value(wxColourPickerCtrl* field) +void ColourPicker::draw_bmp_btn() { - field->SetColour(wxTransparentColour); - - wxButton* btn = dynamic_cast(field->GetPickerCtrl()); - if (!btn->GetBitmap().IsOk()) return; - - wxImage image(btn->GetBitmap().GetSize()); - image.InitAlpha(); - memset(image.GetAlpha(), 0, image.GetWidth() * image.GetHeight()); - wxBitmap bmp(std::move(image)); - wxMemoryDC dc(bmp); - if (!dc.IsOk()) return; -#ifdef __WXMSW__ - wxGCDC dc2(dc); -#else - wxDC &dc2(dc); -#endif - dc2.SetPen(wxPen("#F1754E", 1)); - - const wxRect rect = wxRect(0, 0, bmp.GetWidth(), bmp.GetHeight()); - dc2.DrawLine(rect.GetLeftBottom(), rect.GetTopRight()); - - dc.SelectObject(wxNullBitmap); - btn->SetBitmapLabel(bmp); -} - -// ORCA match style with button on windows -void ColourPicker::draw_bmp_btn(wxColourPickerCtrl* field, wxColour color) -{ - wxButton* btn = dynamic_cast(field->GetPickerCtrl()); - - if (!btn->GetBitmap().IsOk()) return; - btn->SetWindowStyle(wxBORDER_NONE); // ORCA just in case to prevent any overflow - btn->SetBackgroundColour(*wxWHITE); + if (!window) return; + auto* btn = static_cast(window); + if (btn->GetSize().x <= 0 || btn->GetSize().y <= 0) return; wxGetApp().UpdateDarkUI(btn); - auto create_bitmap = [btn](const wxColour& picker_color,const wxColour& bg_color, bool focus) -> wxBitmap { wxSize btn_sz = btn->GetSize(); wxImage image(btn_sz); @@ -2712,11 +2666,11 @@ void ColourPicker::draw_bmp_btn(wxColourPickerCtrl* field, wxColour color) wxBitmap bmp(std::move(image)); wxMemoryDC dc(bmp); if (!dc.IsOk()) return bmp; - wxGCDC dc2(dc); // just use wxGCDC since bitmap button only used for windows + wxGCDC dc2(dc); dc2.SetPen(focus ? wxPen(wxColour(StateColor::darkModeColorFor(wxColour("#009688"))), 1) : *wxTRANSPARENT_PEN); dc2.SetBrush(wxBrush(StateColor::darkModeColorFor(bg_color))); - dc2.DrawRoundedRectangle(btn->GetRect(), btn->FromDIP(4)); + dc2.DrawRoundedRectangle(wxRect(wxPoint(0, 0), btn_sz), btn->FromDIP(4)); int padding = btn->FromDIP(5); dc2.SetPen(*wxTRANSPARENT_PEN); @@ -2735,117 +2689,72 @@ void ColourPicker::draw_bmp_btn(wxColourPickerCtrl* field, wxColour color) return bmp; }; - btn->SetBitmap( create_bitmap(color, wxColour("#DFDFDF"), false)); // Normal - btn->SetBitmapFocus( create_bitmap(color, wxColour("#DFDFDF"), true )); // Focus - btn->SetBitmapCurrent( create_bitmap(color, wxColour("#D4D4D4"), false)); // Hover + btn->SetBitmap( create_bitmap(m_colour, wxColour("#DFDFDF"), false)); // Normal + btn->SetBitmapFocus( create_bitmap(m_colour, wxColour("#DFDFDF"), true )); // Focus + btn->SetBitmapCurrent( create_bitmap(m_colour, wxColour("#D4D4D4"), false)); // Hover } -void ColourPicker::set_value(const boost::any& value, bool change_event) +void ColourPicker::set_value(const std::string& value, bool change_event) { - m_disable_change_event = !change_event; - const wxString clr_str(boost::any_cast(value)); - auto field = dynamic_cast(window); + set_value(boost::any(wxString::FromUTF8(value)), change_event); +} - #ifdef __WXMSW__ - const wxColour parsed_clr(clr_str); - wxColour clr = (clr_str.IsEmpty() || !parsed_clr.IsOk()) ? wxTransparentColour : parsed_clr; - field->SetColour(clr); - draw_bmp_btn(field, clr); - #else - wxColour clr(clr_str); - if (clr_str.IsEmpty() || !clr.IsOk()) - set_undef_value(field); - else - field->SetColour(clr); - #endif - - m_disable_change_event = false; +void ColourPicker::set_value(const boost::any& value, bool /*change_event*/) +{ + // Like wxColourPickerCtrl::SetColour, programmatic assignment is silent. + const wxString clr_str = boost::any_cast(value); + const wxColour parsed(clr_str); + m_colour = clr_str.IsEmpty() || !parsed.IsOk() ? wxTransparentColour : parsed; + draw_bmp_btn(); } boost::any& ColourPicker::get_value() { - save_colors_to_config(); - auto colour = static_cast(window)->GetColour(); - if (colour == wxTransparentColour) - m_value = std::string(""); - else { - m_value = encode_color(ColorRGB(colour.Red(), colour.Green(), colour.Blue())); - } - return m_value; + m_value = m_colour == wxTransparentColour ? std::string() : + encode_color(ColorRGB(m_colour.Red(), m_colour.Green(), m_colour.Blue())); + return m_value; +} + +void ColourPicker::apply_user_color(const std::optional& color) +{ + if (!color || !color->IsOk() || *color == m_colour) return; + const std::string previous = boost::any_cast(get_value()); + m_colour = *color; + draw_bmp_btn(); + if (previous != boost::any_cast(get_value())) on_change_field(); } void ColourPicker::msw_rescale() { Field::msw_rescale(); - - wxColourPickerCtrl* field = dynamic_cast(window); - auto size = wxSize(def_width_wider() * m_em_unit, -1); // ORCA match color picker width with parameters + wxSize size(def_width_wider() * m_em_unit, m_parent->FromDIP(26)); if (m_opt.height >= 0) size.SetHeight(m_opt.height * m_em_unit); else if (parent_is_custom_ctrl && opt_height > 0) size.SetHeight(lround(opt_height * m_em_unit)); if (m_opt.width >= 0) size.SetWidth(m_opt.width * m_em_unit); - if (parent_is_custom_ctrl) - field->SetSize(size); - else - field->SetMinSize(size); - - #ifdef __WXMSW__ - draw_bmp_btn(field, field->GetColour()); - #else - if (field->GetColour() == wxTransparentColour) - set_undef_value(field); - #endif - + if (parent_is_custom_ctrl) window->SetSize(size); + else window->SetMinSize(size); + draw_bmp_btn(); } void ColourPicker::sys_color_changed() { -#ifdef _WIN32 - if (wxWindow* win = this->getWindow()) - if (wxColourPickerCtrl* picker = dynamic_cast(win)){ - wxGetApp().UpdateDarkUI(picker->GetPickerCtrl(), true); - draw_bmp_btn(picker, picker->GetColour()); - } -#endif + if (window) wxGetApp().UpdateDarkUI(window, true); + draw_bmp_btn(); } -void ColourPicker::on_button_click(wxCommandEvent &event) { -#if !defined(__linux__) && !defined(__LINUX__) - if (m_clrData) { - std::vector colors = wxGetApp().app_config->get_custom_color_from_config(); - for (int i = 0; i < colors.size(); i++) { - m_clrData->SetCustomColour(i, string_to_wxColor(colors[i])); - } - } - m_picker_widget->OnButtonClick(event); -#endif -} - -void ColourPicker::convert_to_picker_widget(wxColourPickerCtrl *widget) +void ColourPicker::on_button_click(wxCommandEvent& /*event*/) { -#if !defined(__linux__) && !defined(__LINUX__) - m_picker_widget = dynamic_cast(widget->GetPickerCtrl()); - if (m_picker_widget) { - m_picker_widget->Bind(wxEVT_BUTTON, &ColourPicker::on_button_click, this); - m_clrData = m_picker_widget->GetColourData(); + const wxColour initial = m_colour == wxTransparentColour ? wxColour(0, 0, 0) : m_colour; + const ColorRGBA color(initial.Red(), initial.Green(), initial.Blue(), static_cast(255)); + ColorPickerDialog dialog(m_parent, color, {}, false, window); + if (dialog.ShowModal() == wxID_OK && dialog.selection()) { + const auto& result = std::get(*dialog.selection()); + apply_user_color(wxColour(static_cast(std::lround(result.r() * 255.f)), + static_cast(std::lround(result.g() * 255.f)), + static_cast(std::lround(result.b() * 255.f)))); } -#endif -} - -void ColourPicker::save_colors_to_config() { -#if !defined(__linux__) && !defined(__LINUX__) - if (m_clrData) { - std::vector colors; - if (colors.size() != CUSTOM_COLOR_COUNT) { - colors.resize(CUSTOM_COLOR_COUNT); - } - for (int i = 0; i < CUSTOM_COLOR_COUNT; i++) { - colors[i] = color_to_string(m_clrData->GetCustomColour(i)); - } - wxGetApp().app_config->save_custom_color_to_config(colors); - } -#endif } void PointCtrl::BUILD() diff --git a/src/slic3r/GUI/Field.hpp b/src/slic3r/GUI/Field.hpp index 81a14be3ac..acdf0c21a2 100644 --- a/src/slic3r/GUI/Field.hpp +++ b/src/slic3r/GUI/Field.hpp @@ -22,10 +22,10 @@ #include #include "I18N.hpp" -#include +#include #include #include -#include +#include #include "libslic3r/libslic3r.h" #include "libslic3r/Config.hpp" @@ -610,41 +610,27 @@ private: }; class ColourPicker : public Field { - using Field::Field; - - void set_undef_value(wxColourPickerCtrl* field); - void draw_bmp_btn(wxColourPickerCtrl* field, wxColour color); public: ColourPicker(const ConfigOptionDef& opt, const t_config_option_key& id) : Field(opt, id) {} ColourPicker(wxWindow* parent, const ConfigOptionDef& opt, const t_config_option_key& id) : Field(parent, opt, id) {} - ~ColourPicker() {} wxWindow* window{ nullptr }; void BUILD() override; - - void set_value(const std::string& value, bool change_event = false) { - m_disable_change_event = !change_event; - dynamic_cast(window)->SetColour(value); - m_disable_change_event = false; - } + void set_value(const std::string& value, bool change_event = false); void set_value(const boost::any& value, bool change_event = false) override; boost::any& get_value() override; void msw_rescale() override; void sys_color_changed() override; - - void enable() override { dynamic_cast(window)->Enable(); } - void disable() override{ dynamic_cast(window)->Disable(); } + void enable() override { if (window) window->Enable(); } + void disable() override{ if (window) window->Disable(); } wxWindow* getWindow() override { return window; } private: - void convert_to_picker_widget(wxColourPickerCtrl *widget); - void on_button_click(wxCommandEvent &WXUNUSED(ev)); - void save_colors_to_config(); -private: -#if !defined(__linux__) && !defined(__LINUX__) - wxColourData* m_clrData{nullptr}; - wxColourPickerWidget* m_picker_widget{nullptr}; -#endif + // An absent result is cancellation; transparent color is the undefined field. + void apply_user_color(const std::optional& color); + void draw_bmp_btn(); + void on_button_click(wxCommandEvent& event); + wxColour m_colour = wxTransparentColour; }; class PointCtrl : public Field { diff --git a/src/slic3r/GUI/FilamentBitmapUtils.cpp b/src/slic3r/GUI/FilamentBitmapUtils.cpp index c3381bd3a7..02a7b9fb46 100644 --- a/src/slic3r/GUI/FilamentBitmapUtils.cpp +++ b/src/slic3r/GUI/FilamentBitmapUtils.cpp @@ -23,7 +23,6 @@ #include #include -#include "EncodedFilament.hpp" #include "FilamentBitmapUtils.hpp" #include "GUI_App.hpp" #include "GuiColor.hpp" @@ -278,19 +277,6 @@ static wxBitmap create_transparent_bitmap(const wxSize& size) { return bdc.bitmap; } -// Sort colors by HSV values (primarily by hue, then saturation, then value) -static void sort_colors_by_hsv(std::vector& colors) { - if (colors.size() < 2) return; - std::sort(colors.begin(), colors.end(), - [](const wxColour& a, const wxColour& b) { - ColourHSV ha = wxColourToHSV(a); - ColourHSV hb = wxColourToHSV(b); - if (ha.h != hb.h) return ha.h < hb.h; - if (ha.s != hb.s) return ha.s < hb.s; - return ha.v < hb.v; - }); -} - static wxBitmap create_single_filament_bitmap(const wxColour& color, const wxSize& size) { // Check if color is transparent @@ -430,24 +416,16 @@ wxBitmap create_filament_bitmap(const std::vector& colors, const wxSiz { if (colors.empty()) return wxNullBitmap; - // Make a copy to sort without modifying original - std::vector sorted_colors = colors; - - // Sort colors by HSV when there are 2 or more colors - if (sorted_colors.size() >= 2) { - sort_colors_by_hsv(sorted_colors); + if (force_gradient && colors.size() >= 2) { + return create_gradient_filament_bitmap(colors, size); } - if (force_gradient && sorted_colors.size() >= 2) { - return create_gradient_filament_bitmap(sorted_colors, size); - } - - switch (sorted_colors.size()) { - case 1: return create_single_filament_bitmap(sorted_colors[0], size); - case 2: return create_dual_filament_bitmap(sorted_colors[0], sorted_colors[1], size); - case 3: return create_triple_filament_bitmap(sorted_colors, size); - case 4: return create_quadruple_filament_bitmap(sorted_colors, size); - default: return create_gradient_filament_bitmap(sorted_colors, size); + switch (colors.size()) { + case 1: return create_single_filament_bitmap(colors[0], size); + case 2: return create_dual_filament_bitmap(colors[0], colors[1], size); + case 3: return create_triple_filament_bitmap(colors, size); + case 4: return create_quadruple_filament_bitmap(colors, size); + default: return create_gradient_filament_bitmap(colors, size); } } diff --git a/src/slic3r/GUI/FilamentPickerDialog.cpp b/src/slic3r/GUI/FilamentPickerDialog.cpp index 61db61bac2..5a80dd2fa6 100644 --- a/src/slic3r/GUI/FilamentPickerDialog.cpp +++ b/src/slic3r/GUI/FilamentPickerDialog.cpp @@ -1,4 +1,8 @@ #include "FilamentPickerDialog.hpp" +#include "ColorPickerDialog.hpp" +#include "libslic3r/Color.hpp" +#include +#include #include "GUI.hpp" #include "I18N.hpp" #include "GUI_App.hpp" @@ -79,7 +83,8 @@ void FilamentPickerDialog::StartFlashing() m_flash_timer->Start(50); } -FilamentPickerDialog::FilamentPickerDialog(wxWindow *parent, const wxString& fila_id, const FilamentColor& fila_color, const std::string& fila_type) +FilamentPickerDialog::FilamentPickerDialog(wxWindow *parent, const wxString& fila_id, const FilamentColor& fila_color, const std::string& fila_type, + std::optional ordered_color) : DPIDialog(parent ? parent : wxGetApp().mainframe, wxID_ANY, _L("Select Filament"), @@ -92,6 +97,7 @@ FilamentPickerDialog::FilamentPickerDialog(wxWindow *parent, const wxString& fil m_color_query = new FilamentColorCodeQuery(); m_is_data_loaded = LoadFilamentData(fila_id); m_cur_filament_color = fila_color; + m_ordered_color = std::move(ordered_color); wxString color_name = m_color_query->GetFilaColorName(fila_id, fila_color); m_cur_color_name = new wxString(color_name); @@ -441,6 +447,9 @@ wxScrolledWindow* FilamentPickerDialog::CreateColorGrid() // Bind click btn->Bind(wxEVT_LEFT_DOWN, [this, btn, color_code](wxMouseEvent& evt) { + m_selection_changed = true; + m_ordered_color.reset(); + m_custom_selection.reset(); m_cur_filament_color = color_code->GetFilaColor(); UpdatePreview(*color_code); UpdateButtonStates(btn); @@ -511,24 +520,27 @@ void FilamentPickerDialog::UpdatePreview(const FilamentColorCode& color_code) Layout(); } -void FilamentPickerDialog::UpdateCustomColorPreview(const wxColour& custom_color) +void FilamentPickerDialog::UpdateCustomColorPreview(const std::vector& wx_colors, bool gradient) { - std::vector wx_colors = {custom_color}; - // Update preview bitmap - wxBitmap bmp = create_filament_bitmap(wx_colors, COLOR_DEMO_SIZE, false); + wxBitmap bmp = create_filament_bitmap(wx_colors, COLOR_DEMO_SIZE, gradient); if (bmp.IsOk()) { BOOST_LOG_TRIVIAL(debug) << "Custom color bitmap created successfully: " << bmp.GetWidth() << "x" << bmp.GetHeight(); m_color_demo->SetBitmap(bmp); - m_color_demo->SetBackgroundColour(custom_color); + m_color_demo->SetBackgroundColour(wx_colors.front()); m_color_demo->Refresh(); } else { BOOST_LOG_TRIVIAL(error) << "Failed to create custom color bitmap"; } // Update preview labels for custom color - m_label_preview_color->SetLabel(custom_color.GetAsString(wxC2S_HTML_SYNTAX)); + wxString label; + for (const auto& color : wx_colors) { + if (!label.empty()) label += " / "; + label += color.GetAsString(wxC2S_HTML_SYNTAX); + } + m_label_preview_color->SetLabel(label); m_label_preview_idx->SetLabel(""); Layout(); } @@ -557,16 +569,6 @@ void FilamentPickerDialog::CreateMoreInfoButton() m_more_btn->SetStyle(ButtonStyle::Regular, ButtonType::Expanded); } -wxColourData FilamentPickerDialog::GetSingleColorData() -{ - wxColourData data; - data.SetChooseFull(true); - if (m_cur_filament_color.ColorCount() > 0) { - data.SetColour(*m_cur_filament_color.m_colors.begin()); - } - return data; -} - void FilamentPickerDialog::BindEvents() { // Bind mouse events for window dragging @@ -588,27 +590,37 @@ void FilamentPickerDialog::BindEvents() // Pause click detection while color picker is open StopClickDetection(); - wxColourData original_data = GetSingleColorData(); - wxColourData result = show_sys_picker_dialog(this, original_data); - - // Resume click detection after color picker closes - StartClickDetection(); - - // Check if user actually selected a different color - if (result.GetColour() != original_data.GetColour()) { - wxColour selected_color = result.GetColour(); - - // Update m_current_filament_color with the selected color - m_cur_filament_color.m_colors.clear(); - m_cur_filament_color.m_colors.insert(selected_color); - m_cur_filament_color.m_color_type = FilamentColor::ColorType::SINGLE_CLR; - - // Update preview - UpdateCustomColorPreview(selected_color); - - // Clear currently selected button since custom color selected - UpdateButtonStates(nullptr); + FilamentColorPickerValue current; + if (m_ordered_color) { + current = *m_ordered_color; + } else { + for (const auto& color : m_cur_filament_color.m_colors) + current.colors.push_back(color.GetAsString(wxC2S_HTML_SYNTAX).ToStdString()); + current.gradient = m_cur_filament_color.m_color_type == FilamentColor::ColorType::GRADIENT_CLR; } + const auto initial = filament_color_picker_initial(current); + std::optional selected; + { + ColorPickerDialog dialog(this, initial, {true, true}, !filament_color_picker_selection(current), m_more_btn); + if (dialog.ShowModal() == wxID_OK) + selected = dialog.selection(); + } + StartClickDetection(); + if (!filament_color_picker_changed(initial, selected)) + return; + + m_custom_selection = selected; + m_ordered_color = filament_color_picker_result(*selected); + m_selection_changed = true; + std::vector colors; + m_cur_filament_color.m_colors.clear(); + for (const auto& color : m_ordered_color->colors) { + colors.emplace_back(wxString::FromUTF8(color)); + m_cur_filament_color.m_colors.insert(colors.back()); + } + m_cur_filament_color.m_color_type = m_ordered_color->gradient ? FilamentColor::ColorType::GRADIENT_CLR : FilamentColor::ColorType::SINGLE_CLR; + UpdateCustomColorPreview(colors, m_ordered_color->gradient); + UpdateButtonStates(nullptr); }); } diff --git a/src/slic3r/GUI/FilamentPickerDialog.hpp b/src/slic3r/GUI/FilamentPickerDialog.hpp index 06b73bab56..1521cf5a09 100644 --- a/src/slic3r/GUI/FilamentPickerDialog.hpp +++ b/src/slic3r/GUI/FilamentPickerDialog.hpp @@ -5,9 +5,10 @@ #include "GUI.hpp" #include "GUI_Utils.hpp" #include "FilamentBitmapUtils.hpp" +#include "ColorPickerDialog.hpp" +#include #include "Widgets/Button.hpp" #include "EncodedFilament.hpp" -#include #include #include #include @@ -25,10 +26,13 @@ namespace Slic3r { namespace GUI { class FilamentPickerDialog : public DPIDialog { public: - FilamentPickerDialog(wxWindow *parent, const wxString &fila_id, const FilamentColor &fila_color, const std::string &fila_type); + FilamentPickerDialog(wxWindow *parent, const wxString &fila_id, const FilamentColor &fila_color, const std::string &fila_type, + std::optional ordered_color = std::nullopt); virtual ~FilamentPickerDialog(); // Public interface methods + bool HasSelectionChanged() const { return m_selection_changed; } + const std::optional& GetCustomColorSelection() const { return m_custom_selection; } bool IsDataLoaded() const { return m_is_data_loaded; } wxColour GetSelectedColour() const; const FilamentColor& GetSelectedFilamentColor() const { return m_cur_filament_color; } @@ -68,7 +72,7 @@ private: // UI update methods void UpdatePreview(const FilamentColorCode& filament); - void UpdateCustomColorPreview(const wxColour& custom_color); + void UpdateCustomColorPreview(const std::vector& colors, bool gradient); void UpdateButtonStates(wxBitmapButton* selected_btn); // Shaped window methods @@ -77,7 +81,6 @@ private: // Data loading bool LoadFilamentData(const wxString& fila_id); - wxColourData GetSingleColorData(); // Flash effect void StartFlashing(); @@ -98,6 +101,9 @@ private: FilamentColorCodes* m_cur_color_codes{nullptr}; wxBitmapButton* m_cur_selected_btn{nullptr}; FilamentColor m_cur_filament_color; + std::optional m_ordered_color; + std::optional m_custom_selection; + bool m_selection_changed = false; // Shaped window members wxBitmap m_shape_bmp; diff --git a/src/slic3r/GUI/PresetComboBoxes.cpp b/src/slic3r/GUI/PresetComboBoxes.cpp index 9611e7ec5c..fdf5cbcc96 100644 --- a/src/slic3r/GUI/PresetComboBoxes.cpp +++ b/src/slic3r/GUI/PresetComboBoxes.cpp @@ -23,6 +23,8 @@ #include "slic3r/GUI/CalibrationWizardPage.hpp" #include #include +#include +#include #include #include #include @@ -34,14 +36,12 @@ #include #include #include -#include #include #include #include #include #include #include -#include #include #include #include @@ -69,6 +69,7 @@ #include "MsgDialog.hpp" #include "ParamsDialog.hpp" #include "FilamentPickerDialog.hpp" +#include "ColorPickerDialog.hpp" #include "wxExtensions.hpp" #include "DeviceCore/DevManager.h" @@ -84,6 +85,24 @@ namespace Slic3r { namespace GUI { +static FilamentColorPickerValue filament_color_picker_value(const DynamicPrintConfig& config, std::size_t index) +{ + FilamentColorPickerValue value; + if (const auto* primary = config.opt("filament_colour"); primary && index < primary->values.size()) + value.primary = primary->values[index]; + if (const auto* multi = config.opt("filament_multi_colour"); multi && index < multi->values.size()) { + std::istringstream stream(multi->values[index]); + for (std::string color; stream >> color;) + value.colors.push_back(std::move(color)); + } + if (value.colors.empty() && !value.primary.empty()) + value.colors.push_back(value.primary); + if (const auto* types = config.opt("filament_colour_type"); types && index < types->values.size()) + value.gradient = value.colors.size() > 1 && types->values[index] == "0"; + return value; +} + + #define BORDER_W 10 // --------------------------------- @@ -911,31 +930,37 @@ PlaterPresetComboBox::PlaterPresetComboBox(wxWindow *parent, Preset::Type preset FilamentColor fila_color = get_cur_color_info(); // Show filament picker dialog - FilamentPickerDialog dialog(this, fila_id, fila_color, fila_type); + FilamentPickerDialog dialog(this, fila_id, fila_color, fila_type, + filament_color_picker_value(m_preset_bundle->project_config, m_filament_idx)); if (!dialog.IsDataLoaded()) { // If FilamentPicker fails, fallback to default color picker show_default_color_picker(); - } else if (dialog.ShowModal() == wxID_OK) { - // Get selected filament color data - FilamentColor fila_color = dialog.GetSelectedFilamentColor(); - - // Check if we have valid color data - if (!fila_color.m_colors.empty()) { - // Convert to storage format - std::vector colors; - for (const wxColour& color : fila_color.m_colors) { - colors.push_back(color.GetAsString(wxC2S_HTML_SYNTAX).ToStdString()); - } - - bool is_gradient = (fila_color.m_color_type == FilamentColor::ColorType::GRADIENT_CLR); - this->sync_colour_config(colors, is_gradient); + } else if (dialog.ShowModal() == wxID_OK && dialog.HasSelectionChanged()) { + if (const auto& custom = dialog.GetCustomColorSelection()) { + const auto value = filament_color_picker_result(*custom); + sync_colour_config(value.colors, value.gradient); } else { - // Fallback to basic color if no FilamentColor data - wxColour selected_color = dialog.GetSelectedColour(); - if (selected_color.IsOk()) { - std::vector color = {selected_color.GetAsString(wxC2S_HTML_SYNTAX).ToStdString()}; - this->sync_colour_config(color, false); + // Get selected filament color data + FilamentColor fila_color = dialog.GetSelectedFilamentColor(); + + // Check if we have valid color data + if (!fila_color.m_colors.empty()) { + // Convert to storage format + std::vector colors; + for (const wxColour& color : fila_color.m_colors) { + colors.push_back(color.GetAsString(wxC2S_HTML_SYNTAX).ToStdString()); + } + + bool is_gradient = (fila_color.m_color_type == FilamentColor::ColorType::GRADIENT_CLR); + this->sync_colour_config(colors, is_gradient); + } else { + // Fallback to basic color if no FilamentColor data + wxColour selected_color = dialog.GetSelectedColour(); + if (selected_color.IsOk()) { + std::vector color = {selected_color.GetAsString(wxC2S_HTML_SYNTAX).ToStdString()}; + this->sync_colour_config(color, false); + } } } } @@ -1091,30 +1116,8 @@ bool PlaterPresetComboBox::switch_to_tab() void PlaterPresetComboBox::change_extruder_color() { - // get current color - DynamicPrintConfig* cfg = &wxGetApp().preset_bundle->project_config; - auto colors = static_cast(cfg->option("filament_colour")->clone()); - wxColour clr(colors->values[m_filament_idx]); - if (!clr.IsOk()) - clr = wxColour(0, 0, 0); // Don't set alfa to transparence - - auto data = new wxColourData(); - data->SetChooseFull(1); - data->SetColour(clr); - - wxColourDialog dialog(this, data); - dialog.CenterOnParent(); - if (dialog.ShowModal() == wxID_OK) - { - colors->values[m_filament_idx] = dialog.GetColourData().GetColour().GetAsString(wxC2S_HTML_SYNTAX).ToStdString(); - - DynamicPrintConfig cfg_new = *cfg; - cfg_new.set_key_value("filament_colour", colors); - - wxGetApp().get_tab(Preset::TYPE_PRINTER)->load_config(cfg_new); - this->update(); - wxGetApp().plater()->on_config_change(cfg_new); - } + // The Linux sidebar menu edits the same ordered project color as its swatch. + show_default_color_picker(); } void PlaterPresetComboBox::show_add_menu() @@ -1608,19 +1611,12 @@ FilamentColor PlaterPresetComboBox::get_cur_color_info() void PlaterPresetComboBox::show_default_color_picker() { - DynamicPrintConfig* cfg = &wxGetApp().preset_bundle->project_config; - auto colors = static_cast(cfg->option("filament_colour")->clone()); - wxColour current_clr(colors->values[m_filament_idx]); - if (!current_clr.IsOk()) - current_clr = wxColour(0, 0, 0); // Don't set alfa to transparence - - m_clrData.SetColour(current_clr); - - wxColourData data = show_sys_picker_dialog(this, m_clrData); - if (m_clrData.GetColour() != data.GetColour()) { - std::vector color = {data.GetColour().GetAsString(wxC2S_HTML_SYNTAX).ToStdString()}; - m_clrData.SetColour(data.GetColour()); - sync_colour_config(color, false); + const auto value = filament_color_picker_value(wxGetApp().preset_bundle->project_config, m_filament_idx); + const auto initial = filament_color_picker_initial(value); + ColorPickerDialog dialog(this, initial, {true, true}, !filament_color_picker_selection(value), clr_picker); + if (dialog.ShowModal() == wxID_OK && filament_color_picker_changed(initial, dialog.selection())) { + const auto selected = filament_color_picker_result(*dialog.selection()); + sync_colour_config(selected.colors, selected.gradient); } } diff --git a/src/slic3r/GUI/PresetComboBoxes.hpp b/src/slic3r/GUI/PresetComboBoxes.hpp index e7de321fc6..c00ad36eb2 100644 --- a/src/slic3r/GUI/PresetComboBoxes.hpp +++ b/src/slic3r/GUI/PresetComboBoxes.hpp @@ -11,7 +11,6 @@ #include "libslic3r/PrintConfig.hpp" #include #include -#include #include #include #include @@ -201,7 +200,6 @@ public: // BBS wxButton* clr_picker { nullptr }; - wxColourData m_clrData; wxColor get_color() { return m_color; } diff --git a/src/slic3r/GUI/TextureImportDialog.cpp b/src/slic3r/GUI/TextureImportDialog.cpp index 3311a85b3a..8e83f0302a 100644 --- a/src/slic3r/GUI/TextureImportDialog.cpp +++ b/src/slic3r/GUI/TextureImportDialog.cpp @@ -10,6 +10,11 @@ #include "GUI_App.hpp" #include "MsgDialog.hpp" #include "ColorDecomposeDialog.hpp" +#include "ColorPickerDialog.hpp" +#include "libslic3r/Color.hpp" +#include +#include +#include #include "ColorDecomposeSupport.hpp" #include "Widgets/StateColor.hpp" #include "Widgets/StaticLine.hpp" @@ -32,9 +37,7 @@ #include #include #include -#include #include -#include #include #include #include @@ -509,30 +512,6 @@ static wxString auto_mix_mode_label(TextureAutoMixMode mode) _L("One-click RYBW auto-mix"); } -static wxPoint constrained_dialog_position(wxWindow* anchor, const wxSize& dialog_size) -{ - if (!anchor) - return wxDefaultPosition; - - wxSize size = dialog_size; - if (size.x <= 0 || size.y <= 0) - size = wxSize(anchor->FromDIP(450), anchor->FromDIP(350)); - - wxPoint pos = anchor->ClientToScreen(wxPoint(0, anchor->GetSize().y)); - wxRect display_rect; - int display_idx = wxDisplay::GetFromPoint(pos); - if (display_idx != wxNOT_FOUND) - display_rect = wxDisplay(display_idx).GetClientArea(); - else - display_rect = wxDisplay().GetClientArea(); - - pos.x = std::clamp(pos.x, display_rect.GetLeft(), - std::max(display_rect.GetLeft(), display_rect.GetRight() - size.x)); - pos.y = std::clamp(pos.y, display_rect.GetTop(), - std::max(display_rect.GetTop(), display_rect.GetBottom() - size.y)); - return pos; -} - // ============================================================ // FilamentSelectPopup // ============================================================ @@ -653,24 +632,22 @@ public: auto on_add_filament = m_on_add_filament; wxWindow* popup_parent = GetParent(); wxWindow* color_anchor = m_dialog_anchor ? m_dialog_anchor : popup_parent; + wxWeakRef popup(this); m_closing_from_action = true; - Dismiss(); - wxColourData cd; - cd.SetChooseFull(true); - wxColourDialog dlg(popup_parent, &cd); - auto move_color_dialog = [&dlg, color_anchor]() { - dlg.Move(constrained_dialog_position(color_anchor, dlg.GetBestSize())); - }; - dlg.Bind(wxEVT_SHOW, [move_color_dialog](wxShowEvent& e) mutable { - e.Skip(); - if (e.IsShown()) - move_color_dialog(); - }); - move_color_dialog(); - if (dlg.ShowModal() == wxID_OK) { - wxColour clr = dlg.GetColourData().GetColour(); - if (on_add_filament) on_add_filament(clr); + std::optional selected; + { + // Capture the stable mapping row before the transient popup is dismissed. + ColorPickerDialog dlg(popup_parent, ColorRGBA(0.f, 0.f, 0.f, 1.f), {}, false, color_anchor); + if (popup) popup->Dismiss(); + // Dismissal can delete the popup. Only copied locals are used below. + if (dlg.ShowModal() == wxID_OK && dlg.selection()) { + const auto& color = std::get(*dlg.selection()); + selected = wxColour(static_cast(std::lround(color.r() * 255.f)), + static_cast(std::lround(color.g() * 255.f)), + static_cast(std::lround(color.b() * 255.f))); + } } + if (selected && on_add_filament) on_add_filament(*selected); }); m_content->SetSizer(outer); diff --git a/src/slic3r/GUI/wxExtensions.cpp b/src/slic3r/GUI/wxExtensions.cpp index 56b58b5c57..5681aa5a49 100644 --- a/src/slic3r/GUI/wxExtensions.cpp +++ b/src/slic3r/GUI/wxExtensions.cpp @@ -19,7 +19,7 @@ #include #include #include -#include +#include #include #include #include @@ -630,43 +630,6 @@ std::vector> read_color_pack(std::vector c return color_info; } -wxColourData show_sys_picker_dialog(wxWindow *parent, const wxColourData &clr_data) -{ - wxColourData data = clr_data; - data.SetChooseFull(true); - - // Load custom colors from config (support both "r,g,b,a" and "#RRGGBB" formats) - std::vector colors = Slic3r::GUI::wxGetApp().app_config->get_custom_color_from_config(); - for (int i = 0; i < (int)colors.size(); i++) { - wxColour c; - if (colors[i].find(',') != std::string::npos) - c = string_to_wxColor(colors[i]); - else - c = wxColour(colors[i]); - if (c.IsOk()) - data.SetCustomColour(i, c); - } - - wxColourDialog dialog(parent, &data); - dialog.SetTitle(_L("Please choose the filament color")); - - if (dialog.ShowModal() == wxID_OK) { - data = dialog.GetColourData(); - - // Save custom colors to config (use RGBA string format for consistency) - std::vector colors; - colors.resize(CUSTOM_COLOR_COUNT); - for (int i = 0; i < CUSTOM_COLOR_COUNT; i++) { - wxColour custom_clr = data.GetCustomColour(i); - if (custom_clr.IsOk()) - colors[i] = color_to_string(custom_clr); - } - Slic3r::GUI::wxGetApp().app_config->save_custom_color_to_config(colors); - } - - return data; -} - wxBitmap *get_extruder_color_icon(std::vector colors, bool is_gradient, std::string label, int icon_width, int icon_height, const std::vector *ramp){ diff --git a/src/slic3r/GUI/wxExtensions.hpp b/src/slic3r/GUI/wxExtensions.hpp index d2d621328d..b323b363f8 100644 --- a/src/slic3r/GUI/wxExtensions.hpp +++ b/src/slic3r/GUI/wxExtensions.hpp @@ -6,7 +6,6 @@ #include #include #include -#include #include #include #include @@ -21,7 +20,6 @@ #include #include #include -#include #include #include @@ -89,7 +87,6 @@ wxBitmap * get_extruder_color_icon(std::string color, std::string label, int ico wxBitmap * get_extruder_color_icon(std::vector colors, bool is_gradient, std::string label, int icon_width, int icon_height, const std::vector *ramp = nullptr); std::vector> read_color_pack(std::vector color_pack); -wxColourData show_sys_picker_dialog(wxWindow *parent, const wxColourData &clr_data); namespace Slic3r { namespace GUI { diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index 298610644b..36e88cb37d 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -9,6 +9,8 @@ add_executable(${_TEST_NAME}_tests test_moonraker_discovery.cpp test_network_discovery.cpp test_filament_bitmap_utils.cpp + test_colour_picker_field.cpp + test_filament_color_picker.cpp test_device_progress.cpp test_device_manager_integration.cpp test_web_media_controller.cpp diff --git a/tests/slic3rutils/test_colour_picker_field.cpp b/tests/slic3rutils/test_colour_picker_field.cpp new file mode 100644 index 0000000000..66608abf1f --- /dev/null +++ b/tests/slic3rutils/test_colour_picker_field.cpp @@ -0,0 +1,47 @@ +#include "slic3r/GUI/Field.hpp" +#include "libslic3r/Config.hpp" +#include +#include +#include +#include +#include + +using namespace Slic3r; +using namespace Slic3r::GUI; + +namespace { +class TestColourPicker : public ColourPicker { +public: + TestColourPicker() : ColourPicker(ConfigOptionDef{}, "filament_colour") {} + std::string value() { return boost::any_cast(get_value()); } +}; +} + +TEST_CASE("Undefined color fields remain distinct from opaque black", "[ColourPickerField]") +{ + TestColourPicker field; + REQUIRE(field.value().empty()); + field.set_value(std::string("#000000")); + REQUIRE(field.value() == "#000000"); + field.set_value(std::string("invalid color")); + REQUIRE(field.value().empty()); + field.set_value(boost::any(wxString("#123456"))); + REQUIRE(field.value() == "#123456"); + field.set_value(std::string()); + REQUIRE(field.value().empty()); +} + +TEST_CASE("Programmatic color field assignment stays silent", "[ColourPickerField]") +{ + TestColourPicker field; + int changes = 0; + field.m_on_change = [&](const std::string&, const boost::any&) { ++changes; }; + field.set_value(std::string("#123456"), false); + field.set_value(boost::any(wxString("#654321")), true); + REQUIRE(changes == 0); + REQUIRE(field.value() == "#654321"); + field.m_disable_change_event = true; + field.set_value(std::string("#FF0000")); + REQUIRE(field.value() == "#FF0000"); + REQUIRE(changes == 0); +} diff --git a/tests/slic3rutils/test_filament_bitmap_utils.cpp b/tests/slic3rutils/test_filament_bitmap_utils.cpp index df5bba0567..600e8a23c8 100644 --- a/tests/slic3rutils/test_filament_bitmap_utils.cpp +++ b/tests/slic3rutils/test_filament_bitmap_utils.cpp @@ -22,6 +22,13 @@ #include #include +#ifdef __WXMSW__ +#include +#include +#include +#include +#include +#endif #include #include "libslic3r/FilamentMixer.hpp" @@ -260,3 +267,71 @@ TEST_CASE("sample_gradient_ramp returns nothing without a usable curve or step c curve.points = {{0.0, kGradientMaxRatio, NAN, NAN}, {1.0, kGradientMinRatio, NAN, NAN}}; REQUIRE(Slic3r::GUI::sample_gradient_ramp(wxColour(255, 0, 0), wxColour(0, 0, 255), curve, 0).empty()); } + +// The headless GTK/macOS suite does not initialize GUI display/font stock objects. +// Exercise native bitmap pixels on Windows without adding that dependency elsewhere. +#ifdef __WXMSW__ +TEST_CASE("Filament gradient pixels follow both endpoint directions without changing input", "[FilamentBitmapUtils]") +{ + const bool reverse = GENERATE(false, true); + const std::vector colors = reverse ? std::vector{wxColour(0, 0, 255), wxColour(255, 0, 0)} + : std::vector{wxColour(255, 0, 0), wxColour(0, 0, 255)}; + const auto original = colors; + const wxBitmap bitmap = Slic3r::GUI::create_filament_bitmap(colors, wxSize(64, 16), true); + REQUIRE(bitmap.IsOk()); + const wxImage image = bitmap.ConvertToImage(); + REQUIRE(image.IsOk()); + // Interior pixels avoid backend-specific rasterization at the gradient boundary. + for (const auto& sample : {std::pair{2, colors.front()}, {61, colors.back()}}) { + REQUIRE(std::abs(int(image.GetRed(sample.first, 8)) - int(sample.second.Red())) <= 16); + REQUIRE(std::abs(int(image.GetBlue(sample.first, 8)) - int(sample.second.Blue())) <= 16); + } + REQUIRE(colors == original); +} + +TEST_CASE("Filament halves preserve the same caller color order as gradients", "[FilamentBitmapUtils]") +{ + const bool reverse = GENERATE(false, true); + const std::vector colors = reverse ? std::vector{wxColour(0, 0, 255), wxColour(255, 0, 0)} + : std::vector{wxColour(255, 0, 0), wxColour(0, 0, 255)}; + const auto original = colors; + const wxBitmap bitmap = Slic3r::GUI::create_filament_bitmap(colors, wxSize(64, 16), false); + REQUIRE(bitmap.IsOk()); + const wxImage image = bitmap.ConvertToImage(); + REQUIRE(image.IsOk()); + require_same_rgb(wxColour(image.GetRed(16, 8), image.GetGreen(16, 8), image.GetBlue(16, 8)), colors.front()); + require_same_rgb(wxColour(image.GetRed(48, 8), image.GetGreen(48, 8), image.GetBlue(48, 8)), colors.back()); + REQUIRE(colors == original); +} + +TEST_CASE("Three color filament stripes retain caller order", "[FilamentBitmapUtils]") +{ + const std::vector colors{wxColour(0, 0, 255), wxColour(0, 255, 0), wxColour(255, 0, 0)}; + const auto original = colors; + const wxBitmap bitmap = Slic3r::GUI::create_filament_bitmap(colors, wxSize(72, 24), false); + REQUIRE(bitmap.IsOk()); + const wxImage image = bitmap.ConvertToImage(); + REQUIRE(image.IsOk()); + for (int i = 0; i < 3; ++i) { + const int x = 12 + i * 24; + require_same_rgb(wxColour(image.GetRed(x, 12), image.GetGreen(x, 12), image.GetBlue(x, 12)), colors[i]); + } + REQUIRE(colors == original); +} + +TEST_CASE("Four color filament quadrants retain caller order", "[FilamentBitmapUtils]") +{ + const std::vector colors{wxColour(0, 0, 255), wxColour(0, 255, 0), wxColour(255, 0, 0), wxColour(255, 255, 0)}; + const auto original = colors; + const wxBitmap bitmap = Slic3r::GUI::create_filament_bitmap(colors, wxSize(64, 32), false); + REQUIRE(bitmap.IsOk()); + const wxImage image = bitmap.ConvertToImage(); + REQUIRE(image.IsOk()); + for (int i = 0; i < 4; ++i) { + const int x = i % 2 ? 48 : 16, y = i / 2 ? 24 : 8; + require_same_rgb(wxColour(image.GetRed(x, y), image.GetGreen(x, y), image.GetBlue(x, y)), colors[i]); + } + REQUIRE(colors == original); +} + +#endif // __WXMSW__ diff --git a/tests/slic3rutils/test_filament_color_picker.cpp b/tests/slic3rutils/test_filament_color_picker.cpp new file mode 100644 index 0000000000..b3da29a101 --- /dev/null +++ b/tests/slic3rutils/test_filament_color_picker.cpp @@ -0,0 +1,179 @@ +#include "slic3r/GUI/ColorPickerDialog.hpp" +#include "libslic3r/Color.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace Slic3r; +using namespace Slic3r::GUI; + +// One color more than an ordered gradient may hold. +static std::vector too_many_gradient_colors() +{ + return std::vector(COLOR_PICKER_MAX_GRADIENT_COLORS + 1, "#FF0000"); +} + +TEST_CASE("Project gradient direction survives editing and RGB serialization", "[FilamentColorPicker]") +{ + const std::vector endpoints = {"#FF0000", "#0000FF"}; + const FilamentColorPickerValue stored{endpoints, true}; + const auto initial = filament_color_picker_selection(stored); + REQUIRE(initial); + REQUIRE(filament_color_picker_result(*initial).colors == endpoints); + REQUIRE_FALSE(filament_color_picker_changed(*initial, initial)); + REQUIRE_FALSE(filament_color_picker_changed(*initial, std::nullopt)); + const auto reverse = filament_color_picker_selection({{endpoints[1], endpoints[0]}, true}); + REQUIRE(filament_color_picker_changed(*initial, reverse)); + REQUIRE(filament_color_picker_result(*reverse).colors.front() == endpoints.back()); +} + +TEST_CASE("Unrepresentable project colors remain unchanged until a different selection is confirmed", "[FilamentColorPicker]") +{ + const auto value = GENERATE(FilamentColorPickerValue{too_many_gradient_colors(), true}, + FilamentColorPickerValue{{"#FF0000", "#0000FF"}, false}); + REQUIRE_FALSE(filament_color_picker_selection(value)); + const auto seed = filament_color_picker_initial(value); + REQUIRE_FALSE(filament_color_picker_changed(seed, seed)); + REQUIRE_FALSE(filament_color_picker_changed(seed, std::nullopt)); + const auto changed = filament_color_picker_selection({{"#FE0000"}, false}); + REQUIRE(filament_color_picker_changed(seed, changed)); + REQUIRE(filament_color_picker_result(*changed).colors == std::vector{"#FE0000"}); +} + +TEST_CASE("Filament seeds preserve primary alpha when colors are invalid or absent", "[FilamentColorPicker]") +{ + const FilamentColorPickerValue stale{{"invalid", "#00FF00"}, false, "#12345680"}; + REQUIRE_FALSE(filament_color_picker_selection(stale)); + REQUIRE(filament_color_picker_result(filament_color_picker_initial(stale)).colors == std::vector{"#12345680"}); + const auto value = filament_color_picker_selection({{"#12345680", "#ABCDEF01"}, true}); + REQUIRE(value); + REQUIRE(filament_color_picker_result(*value).colors == std::vector{"#12345680", "#ABCDEF01"}); + const auto missing = filament_color_picker_initial({}); + REQUIRE(filament_color_picker_result(missing).colors == std::vector{"#000000"}); +} + +TEST_CASE("Filament project colors round trip every alpha boundary with compatible opaque spelling", "[FilamentColorPicker]") +{ + const auto hex = GENERATE(std::string("#12345600"), std::string("#12345601"), + std::string("#12345680"), std::string("#123456FE"), + std::string("#123456FF"), std::string("#123456")); + const FilamentColorPickerValue stored{{hex}, false}; + const auto selection = filament_color_picker_selection(stored); + REQUIRE(selection); + REQUIRE_FALSE(filament_color_picker_changed(*selection, selection)); + REQUIRE_FALSE(filament_color_picker_changed(*selection, std::nullopt)); + const auto result = filament_color_picker_result(*selection); + const std::string expected = hex == "#123456FF" ? "#123456" : hex; + REQUIRE(result.colors == std::vector{expected}); + ColorRGBA decoded; + REQUIRE(decode_color(result.colors.front(), decoded)); + REQUIRE(filament_color_picker_result(ColorSelection{decoded}).colors == result.colors); + const auto reloaded = filament_color_picker_selection(result); + REQUIRE(reloaded); + REQUIRE(filament_color_picker_result(*reloaded).colors == result.colors); +} + +TEST_CASE("Alpha only filament edits preserve ordered independent gradient endpoint alpha", "[FilamentColorPicker]") +{ + const auto original = filament_color_picker_selection({{"#FF000000", "#0000FF80"}, true}); + const auto edited = filament_color_picker_selection({{"#FF000001", "#0000FFFE"}, true}); + REQUIRE(original); + REQUIRE(edited); + REQUIRE(filament_color_picker_changed(*original, edited)); + const auto result = filament_color_picker_result(*edited); + REQUIRE(result.gradient); + REQUIRE(result.colors == std::vector{"#FF000001", "#0000FFFE"}); + const auto reloaded = filament_color_picker_selection(result); + REQUIRE(reloaded); + REQUIRE_FALSE(filament_color_picker_changed(*edited, reloaded)); + const auto reversed = filament_color_picker_selection({{result.colors[1], result.colors[0]}, true}); + REQUIRE(filament_color_picker_changed(*edited, reversed)); + const auto solid = filament_color_picker_selection({{"#12345680"}, false}); + const auto alpha_only = filament_color_picker_selection({{"#12345681"}, false}); + REQUIRE(solid); + REQUIRE(alpha_only); + REQUIRE(filament_color_picker_changed(*solid, alpha_only)); + REQUIRE(filament_color_picker_result(*alpha_only).colors == std::vector{"#12345681"}); + const auto mixed = filament_color_picker_selection({{"#FF0000FF", "#0000FF00"}, true}); + REQUIRE(mixed); + REQUIRE(filament_color_picker_result(*mixed).colors == std::vector{"#FF0000", "#0000FF00"}); +} + +TEST_CASE("Unrepresentable filament seeds retain first endpoint or valid primary alpha", "[FilamentColorPicker]") +{ + auto too_many = too_many_gradient_colors(); + too_many.front() = "#FF000000"; + const auto value = GENERATE_COPY(FilamentColorPickerValue{too_many, true}, + FilamentColorPickerValue{{"#FF000000", "#0000FF80"}, false}, + FilamentColorPickerValue{{"invalid", "#FF0000"}, true, "#FF000000"}); + REQUIRE_FALSE(filament_color_picker_selection(value)); + const auto initial = filament_color_picker_initial(value); + REQUIRE(filament_color_picker_result(initial).colors == std::vector{"#FF000000"}); + REQUIRE_FALSE(filament_color_picker_changed(initial, initial)); + REQUIRE_FALSE(filament_color_picker_changed(initial, std::nullopt)); + const auto edited = filament_color_picker_selection({{"#FF000001"}, false}); + REQUIRE(filament_color_picker_changed(initial, edited)); +} + +TEST_CASE("Ordered multi-color gradients round trip up to the color limit", "[FilamentColorPicker]") +{ + const std::vector all = {"#FF0000", "#FFFF0080", "#00FF00", "#00FFFF01", "#0000FF", "#FF00FF00", "#123456"}; + for (std::size_t count = 2; count <= std::min(all.size(), COLOR_PICKER_MAX_GRADIENT_COLORS); ++count) { + const std::vector colors(all.begin(), all.begin() + count); + const auto selection = filament_color_picker_selection({colors, true}); + REQUIRE(selection); + const auto result = filament_color_picker_result(*selection); + REQUIRE(result.gradient); + REQUIRE(result.colors == colors); + REQUIRE_FALSE(filament_color_picker_changed(*selection, filament_color_picker_selection(result))); + std::vector reordered = colors; + std::swap(reordered.front(), reordered.back()); + REQUIRE(filament_color_picker_changed(*selection, filament_color_picker_selection({reordered, true}))); + } + REQUIRE_FALSE(filament_color_picker_selection({too_many_gradient_colors(), true})); +} + +TEST_CASE("Filament selections require complete hexadecimal colors and exact endpoint counts", "[FilamentColorPicker]") +{ + const std::string hex = GENERATE("", "112233", "#ABC", "#1234567", "#123456789", "#GG2233", "#11223Z", "#112233G0", " #112233", "#112233 "); + REQUIRE_FALSE(filament_color_picker_selection({{hex}, false})); + REQUIRE_FALSE(filament_color_picker_selection({{"#112233", hex}, true})); + const auto malformed = GENERATE(FilamentColorPickerValue{{}, false}, + FilamentColorPickerValue{{}, true}, + FilamentColorPickerValue{{"#112233"}, true}); + REQUIRE_FALSE(filament_color_picker_selection(malformed)); +} + +TEST_CASE("Every RGBA byte survives the public filament conversion round trip", "[FilamentColorPicker]") +{ + constexpr char digits[] = "0123456789ABCDEF"; + for (int byte = 0; byte < 256; ++byte) { + std::string hex = "#"; + for (int channel = 0; channel < 4; ++channel) { + hex += digits[byte >> 4]; + hex += digits[byte & 15]; + } + const auto selection = filament_color_picker_selection({{hex}, false}); + REQUIRE(selection); + const auto result = filament_color_picker_result(*selection); + REQUIRE(result.colors == std::vector{byte == 255 ? hex.substr(0, 7) : hex}); + REQUIRE_FALSE(filament_color_picker_changed(*selection, filament_color_picker_selection(result))); + } +} + +TEST_CASE("Filament serialization rejects invalid native RGBA channels", "[FilamentColorPicker]") +{ + const float invalid = GENERATE(-0.1f, 1.1f, std::numeric_limits::infinity(), std::numeric_limits::quiet_NaN()); + const int channel = GENERATE(0, 1, 2, 3); + ColorRGBA color; + color[channel] = invalid; + REQUIRE_THROWS_AS(filament_color_picker_result(color), std::invalid_argument); + REQUIRE_THROWS_AS(filament_color_picker_result(ColorGradient{ColorRGBA::WHITE(), color}), std::invalid_argument); +}