import type { CanvasPointer, LGraphCanvas, LGraphNode, Size } from "@/litegraph" import type { CanvasMouseEvent, CanvasPointerEvent } from "@/types/events" import type { IBaseWidget, IWidget } from "@/types/widgets" import { drawTextInArea } from "@/draw" import { Rectangle } from "@/infrastructure/Rectangle" import { Point } from "@/interfaces" import { LiteGraph } from "@/litegraph" export interface DrawWidgetOptions { /** The width of the node where this widget will be displayed. */ width: number /** Synonym for "low quality". */ showText?: boolean } export interface DrawTruncatingTextOptions extends DrawWidgetOptions { /** The canvas context to draw the text on. */ ctx: CanvasRenderingContext2D /** The amount of padding to add to the left of the text. */ leftPadding?: number /** The amount of padding to add to the right of the text. */ rightPadding?: number } export interface WidgetEventOptions { e: CanvasMouseEvent node: LGraphNode canvas: LGraphCanvas } export abstract class BaseWidget implements IBaseWidget { /** From node edge to widget edge */ static margin = 15 /** From widget edge to tip of arrow button */ static arrowMargin = 6 /** Arrow button width */ static arrowWidth = 10 /** Absolute minimum display width of widget values */ static minValueWidth = 42 /** Minimum gap between label and value */ static labelValueGap = 5 linkedWidgets?: IWidget[] name: string options: TWidget["options"] label?: string type: TWidget["type"] value: TWidget["value"] y: number = 0 last_y?: number width?: number disabled?: boolean computedDisabled?: boolean hidden?: boolean advanced?: boolean tooltip?: string element?: HTMLElement callback?( value: any, canvas?: LGraphCanvas, node?: LGraphNode, pos?: Point, e?: CanvasMouseEvent, ): void mouse?(event: CanvasPointerEvent, pointerOffset: Point, node: LGraphNode): boolean draw?( ctx: CanvasRenderingContext2D, node: LGraphNode, widget_width: number, y: number, H: number, ): void computeSize?(width?: number): Size onPointerDown?(pointer: CanvasPointer, node: LGraphNode, canvas: LGraphCanvas): boolean constructor(widget: TWidget) { // TODO: Resolve this workaround. Ref: https://github.com/Comfy-Org/litegraph.js/issues/1022 // @ts-expect-error Prevent naming conflicts with custom nodes. // eslint-disable-next-line unused-imports/no-unused-vars const { outline_color, background_color, height, text_color, secondary_text_color, disabledTextColor, displayName, displayValue, labelBaseline, ...safeValues } = widget Object.assign(this, safeValues) this.name = widget.name this.options = widget.options this.type = widget.type this.value = widget.value } get outline_color() { return this.advanced ? LiteGraph.WIDGET_ADVANCED_OUTLINE_COLOR : LiteGraph.WIDGET_OUTLINE_COLOR } get background_color() { return LiteGraph.WIDGET_BGCOLOR } get height() { return LiteGraph.NODE_WIDGET_HEIGHT } get text_color() { return LiteGraph.WIDGET_TEXT_COLOR } get secondary_text_color() { return LiteGraph.WIDGET_SECONDARY_TEXT_COLOR } get disabledTextColor() { return LiteGraph.WIDGET_DISABLED_TEXT_COLOR } get displayName() { return this.label || this.name } get displayValue(): string { return String(this.value) } get labelBaseline() { return this.y + this.height * 0.7 } /** * Draws the widget * @param ctx The canvas context * @param options The options for drawing the widget * @remarks Not naming this `draw` as `draw` conflicts with the `draw` method in * custom widgets. */ abstract drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void /** * Draws the standard widget shape - elongated capsule. The path of the widget shape is not * cleared, and may be used for further drawing. * @param ctx The canvas context * @param options The options for drawing the widget * @remarks Leaves {@link ctx} dirty. */ protected drawWidgetShape(ctx: CanvasRenderingContext2D, { width, showText }: DrawWidgetOptions) { const { height, y } = this const { margin } = BaseWidget ctx.textAlign = "left" ctx.strokeStyle = this.outline_color ctx.fillStyle = this.background_color ctx.beginPath() if (showText) { ctx.roundRect(margin, y, width - margin * 2, height, [height * 0.5]) } else { ctx.rect(margin, y, width - margin * 2, height) } ctx.fill() if (showText && !this.computedDisabled) ctx.stroke() } /** * A shared routine for drawing a label and value as text, truncated * if they exceed the available width. */ protected drawTruncatingText({ ctx, width, leftPadding = 5, rightPadding = 20, }: DrawTruncatingTextOptions) { const { height, y } = this const { margin } = BaseWidget // Measure label and value const { displayName, displayValue } = this const labelWidth = ctx.measureText(displayName).width const valueWidth = ctx.measureText(displayValue).width const gap = BaseWidget.labelValueGap const x = margin * 2 + leftPadding const totalWidth = width - x - 2 * margin - rightPadding const requiredWidth = labelWidth + gap + valueWidth const area = new Rectangle(x, y, totalWidth, height * 0.7) ctx.fillStyle = this.secondary_text_color if (requiredWidth <= totalWidth) { // Draw label & value normally drawTextInArea({ ctx, text: displayName, area, align: "left" }) } else if (LiteGraph.truncateWidgetTextEvenly) { // Label + value will not fit - scale evenly to fit const scale = (totalWidth - gap) / (requiredWidth - gap) area.width = labelWidth * scale drawTextInArea({ ctx, text: displayName, area, align: "left" }) // Move the area to the right to render the value area.right = x + totalWidth area.setWidthRightAnchored(valueWidth * scale) } else if (LiteGraph.truncateWidgetValuesFirst) { // Label + value will not fit - use legacy scaling of value first const cappedLabelWidth = Math.min(labelWidth, totalWidth) area.width = cappedLabelWidth drawTextInArea({ ctx, text: displayName, area, align: "left" }) area.right = x + totalWidth area.setWidthRightAnchored(Math.max(totalWidth - gap - cappedLabelWidth, 0)) } else { // Label + value will not fit - scale label first const cappedValueWidth = Math.min(valueWidth, totalWidth) area.width = Math.max(totalWidth - gap - cappedValueWidth, 0) drawTextInArea({ ctx, text: displayName, area, align: "left" }) area.right = x + totalWidth area.setWidthRightAnchored(cappedValueWidth) } ctx.fillStyle = this.text_color drawTextInArea({ ctx, text: displayValue, area, align: "right" }) } /** * Handles the click event for the widget * @param options The options for handling the click event */ abstract onClick(options: WidgetEventOptions): void /** * Handles the drag event for the widget * @param options The options for handling the drag event */ onDrag?(options: WidgetEventOptions): void /** * Sets the value of the widget * @param value The value to set * @param options The options for setting the value */ setValue(value: TWidget["value"], { e, node, canvas }: WidgetEventOptions) { const oldValue = this.value if (value === this.value) return const v = this.type === "number" ? Number(value) : value this.value = v if ( this.options?.property && node.properties[this.options.property] !== undefined ) { node.setProperty(this.options.property, v) } const pos = canvas.graph_mouse this.callback?.(this.value, canvas, node, pos, e) node.onWidgetChanged?.(this.name ?? "", v, oldValue, this as IWidget) if (node.graph) node.graph._version++ } }