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({