From f7a83f6dfa0d57a9c70d598184d29fc5e0fec621 Mon Sep 17 00:00:00 2001 From: Terry Jia Date: Fri, 20 Feb 2026 20:51:10 -0500 Subject: [PATCH] feat: add gradient-slider widget for FLOAT inputs (#8992) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add a new 'gradient-slider' display mode for FLOAT widget inputs. Nodes can specify gradient_stops (color stop arrays) to render a colored gradient track behind the slider thumb, useful for color adjustment parameters like hue, saturation, brightness, etc. - GradientSlider.vue: reusable Reka UI-based gradient slider component - GradientSliderWidget.ts: litegraph canvas-mode fallback rendering - WidgetInputNumberGradientSlider.vue: Vue node widget integration - Schema, registry, and type updates for gradient-slider support this is prerequisite for color correct and balance BE changes https://github.com/Comfy-Org/ComfyUI/pull/12536 ## Screenshots (if applicable) image ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8992-feat-add-gradient-slider-widget-for-FLOAT-inputs-30d6d73d36508199b3e8db6a0c213ab4) by [Unito](https://www.unito.io) --------- Co-authored-by: Alexander Brown --- .../gradientslider/GradientSlider.test.ts | 80 +++++++++++++ .../gradientslider/GradientSlider.vue | 90 +++++++++++++++ src/components/gradientslider/gradients.ts | 40 +++++++ src/extensions/core/customWidgets.test.ts | 107 ++++++++++++++++++ src/extensions/core/customWidgets.ts | 20 ++++ src/lib/litegraph/src/interfaces.ts | 5 + src/lib/litegraph/src/types/widgets.ts | 25 +++- .../src/widgets/GradientSliderWidget.ts | 85 ++++++++++++++ src/lib/litegraph/src/widgets/widgetMap.ts | 4 + .../widgets/components/WidgetInputNumber.vue | 34 +++--- .../WidgetInputNumberGradientSlider.vue | 93 +++++++++++++++ .../widgets/composables/useFloatWidget.ts | 17 ++- .../widgets/registry/widgetRegistry.ts | 2 +- src/schemas/nodeDefSchema.ts | 12 +- 14 files changed, 590 insertions(+), 24 deletions(-) create mode 100644 src/components/gradientslider/GradientSlider.test.ts create mode 100644 src/components/gradientslider/GradientSlider.vue create mode 100644 src/components/gradientslider/gradients.ts create mode 100644 src/extensions/core/customWidgets.test.ts create mode 100644 src/lib/litegraph/src/widgets/GradientSliderWidget.ts create mode 100644 src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumberGradientSlider.vue diff --git a/src/components/gradientslider/GradientSlider.test.ts b/src/components/gradientslider/GradientSlider.test.ts new file mode 100644 index 000000000..711f04bdd --- /dev/null +++ b/src/components/gradientslider/GradientSlider.test.ts @@ -0,0 +1,80 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' + +import GradientSlider from './GradientSlider.vue' +import type { ColorStop } from '@/lib/litegraph/src/interfaces' +import { interpolateStops, stopsToGradient } from './gradients' + +const TEST_STOPS: ColorStop[] = [ + { offset: 0, color: [0, 0, 0] }, + { offset: 1, color: [255, 255, 255] } +] + +function mountSlider(props: { + stops?: ColorStop[] + modelValue: number + min?: number + max?: number + step?: number +}) { + return mount(GradientSlider, { + props: { stops: TEST_STOPS, ...props } + }) +} + +describe('GradientSlider', () => { + it('passes min, max, step to SliderRoot', () => { + const wrapper = mountSlider({ + modelValue: 50, + min: -100, + max: 100, + step: 5 + }) + const thumb = wrapper.find('[role="slider"]') + expect(thumb.attributes('aria-valuemin')).toBe('-100') + expect(thumb.attributes('aria-valuemax')).toBe('100') + }) + + it('renders slider root with track and thumb', () => { + const wrapper = mountSlider({ modelValue: 0 }) + expect(wrapper.find('[data-slider-impl]').exists()).toBe(true) + expect(wrapper.find('[role="slider"]').exists()).toBe(true) + }) + + it('does not render SliderRange', () => { + const wrapper = mountSlider({ modelValue: 50 }) + expect(wrapper.find('[data-slot="slider-range"]').exists()).toBe(false) + }) +}) + +describe('stopsToGradient', () => { + it('returns transparent for empty stops', () => { + expect(stopsToGradient([])).toBe('transparent') + }) +}) + +describe('interpolateStops', () => { + it('returns transparent for empty stops', () => { + expect(interpolateStops([], 0.5)).toBe('transparent') + }) + + it('returns start color at t=0', () => { + expect(interpolateStops(TEST_STOPS, 0)).toBe('rgb(0,0,0)') + }) + + it('returns end color at t=1', () => { + expect(interpolateStops(TEST_STOPS, 1)).toBe('rgb(255,255,255)') + }) + + it('returns midpoint color at t=0.5', () => { + expect(interpolateStops(TEST_STOPS, 0.5)).toBe('rgb(128,128,128)') + }) + + it('clamps values below 0', () => { + expect(interpolateStops(TEST_STOPS, -1)).toBe('rgb(0,0,0)') + }) + + it('clamps values above 1', () => { + expect(interpolateStops(TEST_STOPS, 2)).toBe('rgb(255,255,255)') + }) +}) diff --git a/src/components/gradientslider/GradientSlider.vue b/src/components/gradientslider/GradientSlider.vue new file mode 100644 index 000000000..8b65e77ba --- /dev/null +++ b/src/components/gradientslider/GradientSlider.vue @@ -0,0 +1,90 @@ + + + diff --git a/src/components/gradientslider/gradients.ts b/src/components/gradientslider/gradients.ts new file mode 100644 index 000000000..b9a1da90c --- /dev/null +++ b/src/components/gradientslider/gradients.ts @@ -0,0 +1,40 @@ +import type { ColorStop } from '@/lib/litegraph/src/interfaces' + +export function stopsToGradient(stops: ColorStop[]): string { + if (!stops.length) return 'transparent' + const colors = stops.map( + ({ offset, color: [r, g, b] }) => `rgb(${r},${g},${b}) ${offset * 100}%` + ) + return `linear-gradient(to right, ${colors.join(', ')})` +} + +export function interpolateStops(stops: ColorStop[], t: number): string { + if (!stops.length) return 'transparent' + const clamped = Math.max(0, Math.min(1, t)) + + if (clamped <= stops[0].offset) { + const [r, g, b] = stops[0].color + return `rgb(${r},${g},${b})` + } + + for (let i = 0; i < stops.length - 1; i++) { + const { + offset: o1, + color: [r1, g1, b1] + } = stops[i] + const { + offset: o2, + color: [r2, g2, b2] + } = stops[i + 1] + if (clamped >= o1 && clamped <= o2) { + const f = o2 === o1 ? 0 : (clamped - o1) / (o2 - o1) + const r = Math.round(r1 + (r2 - r1) * f) + const g = Math.round(g1 + (g2 - g1) * f) + const b = Math.round(b1 + (b2 - b1) * f) + return `rgb(${r},${g},${b})` + } + } + + const [r, g, b] = stops[stops.length - 1].color + return `rgb(${r},${g},${b})` +} diff --git a/src/extensions/core/customWidgets.test.ts b/src/extensions/core/customWidgets.test.ts new file mode 100644 index 000000000..e76ce4337 --- /dev/null +++ b/src/extensions/core/customWidgets.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest' + +describe('PrimitiveFloat widget type bridging', () => { + function createMockNodeAndWidget() { + const properties: Record = {} + const options: Record = { + min: -Infinity, + max: Infinity + } + const widget = { type: 'number', options, value: 0, callback: undefined } + return { properties, widget } + } + + function applyFloatPropertyBridges( + properties: Record, + widget: { type: string; options: Record } + ) { + const DISPLAY_WIDGET_TYPES = new Set(['gradientslider', 'slider', 'knob']) + + let baseType = widget.type + Object.defineProperty(widget, 'type', { + get: () => { + const display = properties.display as string | undefined + if (display && DISPLAY_WIDGET_TYPES.has(display)) return display + return baseType + }, + set: (v: string) => { + baseType = v + } + }) + + Object.defineProperty(widget.options, 'gradient_stops', { + get: () => properties.gradient_stops, + set: (v) => { + properties.gradient_stops = v + } + }) + } + + it('returns base type when display property is not set', () => { + const { properties, widget } = createMockNodeAndWidget() + applyFloatPropertyBridges(properties, widget) + + expect(widget.type).toBe('number') + }) + + it('returns gradientslider when display property is set', () => { + const { properties, widget } = createMockNodeAndWidget() + applyFloatPropertyBridges(properties, widget) + + properties.display = 'gradientslider' + expect(widget.type).toBe('gradientslider') + }) + + it('returns slider when display property is slider', () => { + const { properties, widget } = createMockNodeAndWidget() + applyFloatPropertyBridges(properties, widget) + + properties.display = 'slider' + expect(widget.type).toBe('slider') + }) + + it('returns base type for unknown display values', () => { + const { properties, widget } = createMockNodeAndWidget() + applyFloatPropertyBridges(properties, widget) + + properties.display = 'unknown' + expect(widget.type).toBe('number') + }) + + it('bridges gradient_stops from properties to options', () => { + const { properties, widget } = createMockNodeAndWidget() + applyFloatPropertyBridges(properties, widget) + + expect(widget.options.gradient_stops).toBeUndefined() + + const stops = [ + { offset: 0, color: [255, 0, 0] }, + { offset: 1, color: [0, 0, 255] } + ] + properties.gradient_stops = stops + expect(widget.options.gradient_stops).toBe(stops) + }) + + it('writes gradient_stops back to properties', () => { + const { properties, widget } = createMockNodeAndWidget() + applyFloatPropertyBridges(properties, widget) + + const stops = [ + { offset: 0, color: [0, 0, 0] }, + { offset: 1, color: [255, 255, 255] } + ] + widget.options.gradient_stops = stops + expect(properties.gradient_stops).toBe(stops) + }) + + it('allows type to be set and overridden', () => { + const { properties, widget } = createMockNodeAndWidget() + applyFloatPropertyBridges(properties, widget) + + widget.type = 'slider' + expect(widget.type).toBe('slider') + + properties.display = 'gradientslider' + expect(widget.type).toBe('gradientslider') + }) +}) diff --git a/src/extensions/core/customWidgets.ts b/src/extensions/core/customWidgets.ts index 9c35813c4..3b98c5594 100644 --- a/src/extensions/core/customWidgets.ts +++ b/src/extensions/core/customWidgets.ts @@ -141,10 +141,30 @@ function onCustomIntCreated(this: LGraphNode) { } }) } +const DISPLAY_WIDGET_TYPES = new Set(['gradientslider', 'slider', 'knob']) + function onCustomFloatCreated(this: LGraphNode) { const valueWidget = this.widgets?.[0] if (!valueWidget) return + let baseType = valueWidget.type + Object.defineProperty(valueWidget, 'type', { + get: () => { + const display = this.properties.display as string | undefined + if (display && DISPLAY_WIDGET_TYPES.has(display)) return display + return baseType + }, + set: (v: string) => { + baseType = v + } + }) + + Object.defineProperty(valueWidget.options, 'gradient_stops', { + get: () => this.properties.gradient_stops, + set: (v) => { + this.properties.gradient_stops = v + } + }) Object.defineProperty(valueWidget.options, 'min', { get: () => this.properties.min ?? -Infinity, set: (v) => { diff --git a/src/lib/litegraph/src/interfaces.ts b/src/lib/litegraph/src/interfaces.ts index c189481c8..51838a6fb 100644 --- a/src/lib/litegraph/src/interfaces.ts +++ b/src/lib/litegraph/src/interfaces.ts @@ -48,6 +48,11 @@ export type SharedIntersection = { export type CanvasColour = string | CanvasGradient | CanvasPattern +export interface ColorStop { + readonly offset: number + readonly color: readonly [r: number, g: number, b: number] +} + /** * Any object that has a {@link boundingRect}. */ diff --git a/src/lib/litegraph/src/types/widgets.ts b/src/lib/litegraph/src/types/widgets.ts index 25e86ff70..661440c3f 100644 --- a/src/lib/litegraph/src/types/widgets.ts +++ b/src/lib/litegraph/src/types/widgets.ts @@ -1,6 +1,12 @@ import type { Bounds } from '@/renderer/core/layout/types' -import type { CanvasColour, Point, RequiredProps, Size } from '../interfaces' +import type { + CanvasColour, + ColorStop, + Point, + RequiredProps, + Size +} from '../interfaces' import type { CanvasPointer, LGraphCanvas, @@ -64,6 +70,13 @@ interface IWidgetSliderOptions extends IWidgetOptions { marker_color?: CanvasColour } +export interface IWidgetGradientSliderOptions extends IWidgetOptions { + min: number + max: number + step2: number + gradient_stops?: ColorStop[] +} + interface IWidgetKnobOptions extends IWidgetOptions { min: number max: number @@ -93,6 +106,7 @@ export type IWidget = | IStringComboWidget | ICustomWidget | ISliderWidget + | IGradientSliderWidget | IButtonWidget | IKnobWidget | IFileUploadWidget @@ -131,6 +145,15 @@ export interface ISliderWidget extends IBaseWidget< marker?: number } +export interface IGradientSliderWidget extends IBaseWidget< + number, + 'gradientslider', + IWidgetGradientSliderOptions +> { + type: 'gradientslider' + value: number +} + export interface IKnobWidget extends IBaseWidget< number, 'knob', diff --git a/src/lib/litegraph/src/widgets/GradientSliderWidget.ts b/src/lib/litegraph/src/widgets/GradientSliderWidget.ts new file mode 100644 index 000000000..9d766329a --- /dev/null +++ b/src/lib/litegraph/src/widgets/GradientSliderWidget.ts @@ -0,0 +1,85 @@ +import { clamp } from 'es-toolkit/compat' + +import type { IGradientSliderWidget } from '@/lib/litegraph/src/types/widgets' + +import { BaseWidget } from './BaseWidget' +import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget' + +export class GradientSliderWidget + extends BaseWidget + implements IGradientSliderWidget +{ + override type = 'gradientslider' as const + + override drawWidget( + ctx: CanvasRenderingContext2D, + { width, showText = true }: DrawWidgetOptions + ) { + ctx.save() + + const { height, y } = this + const { margin } = BaseWidget + + ctx.fillStyle = this.background_color + ctx.fillRect(margin, y, width - margin * 2, height) + + const range = this.options.max - this.options.min + let nvalue = (this.value - this.options.min) / range + nvalue = clamp(nvalue, 0, 1) + + ctx.fillStyle = '#678' + ctx.fillRect(margin, y, nvalue * (width - margin * 2), height) + + if (showText && !this.computedDisabled) { + ctx.strokeStyle = this.outline_color + ctx.strokeRect(margin, y, width - margin * 2, height) + } + + if (showText) { + ctx.textAlign = 'center' + ctx.fillStyle = this.text_color + const fixedValue = Number(this.value).toFixed(this.options.precision ?? 3) + ctx.fillText( + `${this.label || this.name} ${fixedValue}`, + width * 0.5, + y + height * 0.7 + ) + } + + ctx.restore() + } + + override onClick(options: WidgetEventOptions) { + if (this.options.read_only) return + + const { e, node } = options + const width = this.width || node.size[0] + const x = e.canvasX - node.pos[0] + + const { margin } = BaseWidget + const slideFactor = clamp((x - margin) / (width - margin * 2), 0, 1) + const newValue = + this.options.min + (this.options.max - this.options.min) * slideFactor + + if (newValue !== this.value) { + this.setValue(newValue, options) + } + } + + override onDrag(options: WidgetEventOptions) { + if (this.options.read_only) return false + + const { e, node } = options + const width = this.width || node.size[0] + const x = e.canvasX - node.pos[0] + + const { margin } = BaseWidget + const slideFactor = clamp((x - margin) / (width - margin * 2), 0, 1) + const newValue = + this.options.min + (this.options.max - this.options.min) * slideFactor + + if (newValue !== this.value) { + this.setValue(newValue, options) + } + } +} diff --git a/src/lib/litegraph/src/widgets/widgetMap.ts b/src/lib/litegraph/src/widgets/widgetMap.ts index 7b2eaea6a..c145494a1 100644 --- a/src/lib/litegraph/src/widgets/widgetMap.ts +++ b/src/lib/litegraph/src/widgets/widgetMap.ts @@ -18,6 +18,7 @@ import { ColorWidget } from './ColorWidget' import { ComboWidget } from './ComboWidget' import { FileUploadWidget } from './FileUploadWidget' import { GalleriaWidget } from './GalleriaWidget' +import { GradientSliderWidget } from './GradientSliderWidget' import { ImageCompareWidget } from './ImageCompareWidget' import { ImageCropWidget } from './ImageCropWidget' import { KnobWidget } from './KnobWidget' @@ -35,6 +36,7 @@ export type WidgetTypeMap = { button: ButtonWidget toggle: BooleanWidget slider: SliderWidget + gradientslider: GradientSliderWidget knob: KnobWidget combo: ComboWidget number: NumberWidget @@ -92,6 +94,8 @@ export function toConcreteWidget( return toClass(BooleanWidget, narrowedWidget, node) case 'slider': return toClass(SliderWidget, narrowedWidget, node) + case 'gradientslider': + return toClass(GradientSliderWidget, narrowedWidget, node) case 'knob': return toClass(KnobWidget, narrowedWidget, node) case 'combo': diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumber.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumber.vue index 007d3adf2..aad5fac00 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumber.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumber.vue @@ -6,6 +6,7 @@ import type { SimplifiedWidget } from '@/types/simplifiedWidget' +import WidgetInputNumberGradientSlider from './WidgetInputNumberGradientSlider.vue' import WidgetInputNumberInput from './WidgetInputNumberInput.vue' import WidgetInputNumberSlider from './WidgetInputNumberSlider.vue' import WidgetWithControl from './WidgetWithControl.vue' @@ -16,28 +17,33 @@ const props = defineProps<{ const modelValue = defineModel({ default: 0 }) -const hasControlAfterGenerate = computed(() => { - return !!props.widget.controlWidget +const controlWidget = computed | null>(() => + props.widget.controlWidget + ? (props.widget as SimplifiedControlWidget) + : null +) + +const widgetComponent = computed(() => { + switch (props.widget.type) { + case 'gradientslider': + return WidgetInputNumberGradientSlider + case 'slider': + return WidgetInputNumberSlider + default: + return WidgetInputNumberInput + } }) + + diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useFloatWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useFloatWidget.ts index 6595f3015..0d5ee3a95 100644 --- a/src/renderer/extensions/vueNodes/widgets/composables/useFloatWidget.ts +++ b/src/renderer/extensions/vueNodes/widgets/composables/useFloatWidget.ts @@ -43,11 +43,13 @@ export const useFloatWidget = () => { const display_type = inputSpec.display const widgetType = - sliderEnabled && display_type == 'slider' - ? 'slider' - : display_type == 'knob' - ? 'knob' - : 'number' + display_type == 'gradientslider' + ? 'gradientslider' + : sliderEnabled && display_type == 'slider' + ? 'slider' + : display_type == 'knob' + ? 'knob' + : 'number' const step = inputSpec.step ?? 0.5 const precision = @@ -72,7 +74,10 @@ export const useFloatWidget = () => { /** @deprecated Use step2 instead. The 10x value is a legacy implementation. */ step: step * 10.0, step2: step, - precision + precision, + ...(inputSpec.gradient_stops + ? { gradient_stops: inputSpec.gradient_stops } + : {}) } ) diff --git a/src/renderer/extensions/vueNodes/widgets/registry/widgetRegistry.ts b/src/renderer/extensions/vueNodes/widgets/registry/widgetRegistry.ts index 41888823d..5f8903008 100644 --- a/src/renderer/extensions/vueNodes/widgets/registry/widgetRegistry.ts +++ b/src/renderer/extensions/vueNodes/widgets/registry/widgetRegistry.ts @@ -93,7 +93,7 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [ 'float', { component: WidgetInputNumber, - aliases: ['FLOAT', 'number', 'slider'], + aliases: ['FLOAT', 'number', 'slider', 'gradientslider'], essential: true } ], diff --git a/src/schemas/nodeDefSchema.ts b/src/schemas/nodeDefSchema.ts index 5c89f8cd8..97d0dc6e5 100644 --- a/src/schemas/nodeDefSchema.ts +++ b/src/schemas/nodeDefSchema.ts @@ -43,7 +43,7 @@ const zNumericInputOptions = zBaseInputOptions.extend({ step: z.number().optional(), /** Note: Many node authors are using INT/FLOAT to pass list of INT/FLOAT. */ default: z.union([z.number(), z.array(z.number())]).optional(), - display: z.enum(['slider', 'number', 'knob']).optional() + display: z.enum(['slider', 'number', 'knob', 'gradientslider']).optional() }) export const zIntInputOptions = zNumericInputOptions.extend({ @@ -57,7 +57,15 @@ export const zIntInputOptions = zNumericInputOptions.extend({ }) export const zFloatInputOptions = zNumericInputOptions.extend({ - round: z.union([z.number(), z.literal(false)]).optional() + round: z.union([z.number(), z.literal(false)]).optional(), + gradient_stops: z + .array( + z.object({ + offset: z.number(), + color: z.tuple([z.number(), z.number(), z.number()]) + }) + ) + .optional() }) export const zBooleanInputOptions = zBaseInputOptions.extend({