From 75df19521baf2fc5114d9cdd3cb7e873c439c90e Mon Sep 17 00:00:00 2001 From: filtered <176114999+webfiltered@users.noreply.github.com> Date: Mon, 5 May 2025 10:48:06 +1000 Subject: [PATCH] Widget overhaul (#1010) ### Widget text overhaul #### Current - Numbers and text overlap - Combo boxes truncate the value before the label ![image](https://github.com/user-attachments/assets/c991b0b6-879f-4455-92d4-4254ef25b55c) #### Proposed **By default, widgets will now truncate their labels before their values.** https://github.com/user-attachments/assets/296ea5ab-d2ff-44f2-9139-5d97789e4f12 - Changes the way widget text is rendered, calculated, and truncated - Truncation now applies in a standard way to the following widgets: - Text - Combo - Number - Centralises widget draw routines in base class ### Config ```ts // Truncate **both** widgets and labels evenly LiteGraph.truncateWidgetTextEvenly = true // Swap the default from truncating labels before values, to truncating values first (restores legacy behaviour) // truncateWidgetTextEvenly **must** be `false`. LiteGraph.truncateWidgetValuesFirst = true ``` ### API / interfaces - Adds rich `Rectangle` concrete impl., with many methods and helpful accessors (e.g. `right`, `bottom`) - Actually _improves_ performance due to switch from Float32Array to Float64Array - Impact vs plain Float64Array was not detectable outside of a 2M+ instantiation-loop with random data - Lazy `pos` & `size` `subarray` properties - Adds `ReadOnlySize` - Adds higher-level text draw functions to abstract the nitty gritty in a performant way (binary search) - Resolves Comfy-Org/ComfyUI_frontend/issues/457 --- src/LiteGraphGlobal.ts | 16 ++ src/draw.ts | 103 +++++++++ src/infrastructure/Rectangle.ts | 270 ++++++++++++++++++++++ src/interfaces.ts | 6 + src/widgets/BaseSteppedWidget.ts | 25 +- src/widgets/BaseWidget.ts | 116 ++++++++++ src/widgets/BooleanWidget.ts | 40 ++-- src/widgets/ButtonWidget.ts | 24 +- src/widgets/ComboWidget.ts | 117 ++-------- src/widgets/KnobWidget.ts | 8 +- src/widgets/NumberWidget.ts | 71 +----- src/widgets/SliderWidget.ts | 8 +- src/widgets/TextWidget.ts | 46 +--- test/__snapshots__/litegraph.test.ts.snap | 2 + 14 files changed, 591 insertions(+), 261 deletions(-) create mode 100644 src/infrastructure/Rectangle.ts diff --git a/src/LiteGraphGlobal.ts b/src/LiteGraphGlobal.ts index 863588703..cff4ff487 100644 --- a/src/LiteGraphGlobal.ts +++ b/src/LiteGraphGlobal.ts @@ -292,6 +292,22 @@ export class LiteGraphGlobal { */ macGesturesRequireMac: boolean = true + /** + * If `true`, widget labels and values will both be truncated (proportionally to size), + * until they fit within the widget. + * + * Otherwise, the label will be truncated completely before the value is truncated. + * @default false + */ + truncateWidgetTextEvenly: boolean = false + + /** + * If `true`, widget values will be completely truncated when shrinking a widget, + * before truncating widget labels. {@link truncateWidgetTextEvenly} must be `false`. + * @default false + */ + truncateWidgetValuesFirst: boolean = false + // TODO: Remove legacy accessors LGraph = LGraph LLink = LLink diff --git a/src/draw.ts b/src/draw.ts index f6a770aa8..f79810091 100644 --- a/src/draw.ts +++ b/src/draw.ts @@ -1,8 +1,13 @@ +import type { Rectangle } from "./infrastructure/Rectangle" import type { CanvasColour, Rect } from "./interfaces" import { LiteGraph } from "./litegraph" import { LinkDirection, RenderShape, TitleMode } from "./types/globalEnums" +const ELLIPSIS = "\u2026" +const TWO_DOT_LEADER = "\u2025" +const ONE_DOT_LEADER = "\u2024" + export enum SlotType { Array = "array", Event = -1, @@ -49,6 +54,17 @@ export interface IDrawBoundingOptions { lineWidth?: number } +export interface IDrawTextInAreaOptions { + /** The canvas to draw the text on. */ + ctx: CanvasRenderingContext2D + /** The text to draw. */ + text: string + /** The area the text will be drawn in. */ + area: Rectangle + /** The alignment of the text. */ + align?: "left" | "right" | "center" +} + /** * Draws only the path of a shape on the canvas, without filling. * Used to draw indicators for node status, e.g. "selected". @@ -135,3 +151,90 @@ export function strokeShape( // TODO: Store and reset value properly. Callers currently expect this behaviour (e.g. muted nodes). ctx.globalAlpha = 1 } + +/** + * Truncates text using binary search to fit within a given width, appending an ellipsis if needed. + * @param ctx The canvas rendering context. + * @param text The text to truncate. + * @param maxWidth The maximum width the text (plus ellipsis) can occupy. + * @returns The truncated text, or the original text if it fits. + */ +function truncateTextToWidth(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string { + if (!(maxWidth > 0)) return "" + + // Text fits + const fullWidth = ctx.measureText(text).width + if (fullWidth <= maxWidth) return text + + const ellipsisWidth = ctx.measureText(ELLIPSIS).width * 0.75 + + // Can't even fit ellipsis + if (ellipsisWidth > maxWidth) { + const twoDotsWidth = ctx.measureText(TWO_DOT_LEADER).width * 0.75 + if (twoDotsWidth < maxWidth) return TWO_DOT_LEADER + + const oneDotWidth = ctx.measureText(ONE_DOT_LEADER).width * 0.75 + return oneDotWidth < maxWidth ? ONE_DOT_LEADER : "" + } + + let min = 0 + let max = text.length + let bestLen = 0 + + // Binary search for the longest substring that fits with the ellipsis + while (min <= max) { + const mid = Math.floor((min + max) * 0.5) + + // Avoid measuring empty string + ellipsis + if (mid === 0) { + min = mid + 1 + continue + } + + const sub = text.substring(0, mid) + const currentWidth = ctx.measureText(sub).width + ellipsisWidth + + if (currentWidth <= maxWidth) { + // This length fits, try potentially longer + bestLen = mid + min = mid + 1 + } else { + // Too long, try shorter + max = mid - 1 + } + } + + return bestLen === 0 + ? ELLIPSIS + : text.substring(0, bestLen) + ELLIPSIS +} + +/** + * Draws text within an area, truncating it and adding an ellipsis if necessary. + */ +export function drawTextInArea({ ctx, text, area, align = "left" }: IDrawTextInAreaOptions) { + const { left, right, bottom, width, centreX } = area + + // Text already fits + const fullWidth = ctx.measureText(text).width + if (fullWidth <= width) { + ctx.textAlign = align + const x = align === "left" ? left : (align === "right" ? right : centreX) + ctx.fillText(text, x, bottom) + return + } + + // Need to truncate text + const truncated = truncateTextToWidth(ctx, text, width) + if (truncated.length === 0) return + + // Draw text - left-aligned to prevent bouncing during resize + ctx.textAlign = "left" + ctx.fillText(truncated.slice(0, -1), left, bottom) + ctx.rect(left, bottom, width, 1) + + // Draw the ellipsis, right-aligned to the button + ctx.textAlign = "right" + const ellipsis = truncated.at(-1)! + ctx.fillText(ellipsis, right, bottom, ctx.measureText(ellipsis).width * 0.75) +} diff --git a/src/infrastructure/Rectangle.ts b/src/infrastructure/Rectangle.ts new file mode 100644 index 000000000..a8ca87f07 --- /dev/null +++ b/src/infrastructure/Rectangle.ts @@ -0,0 +1,270 @@ +import type { Point, ReadOnlyPoint, ReadOnlyRect, ReadOnlySize, Size } from "@/interfaces" + +export class Rectangle extends Float64Array { + #pos: Point | undefined + #size: Size | undefined + + constructor(x: number = 0, y: number = 0, width: number = 0, height: number = 0) { + super(4) + + this[0] = x + this[1] = y + this[2] = width + this[3] = height + } + + override subarray(begin?: number, end?: number): Float64Array { + return new Float64Array(this.buffer, begin, end) + } + + /** + * A reference to the position of the top-left corner of this rectangle. + * + * Updating the values of the returned object will update this rectangle. + */ + get pos(): Point { + this.#pos ??= this.subarray(0, 2) + return this.#pos + } + + set pos(value: ReadOnlyPoint) { + this[0] = value[0] + this[1] = value[1] + } + + /** + * A reference to the size of this rectangle. + * + * Updating the values of the returned object will update this rectangle. + */ + get size(): Size { + this.#size ??= this.subarray(2, 4) + return this.#size + } + + set size(value: ReadOnlySize) { + this[2] = value[0] + this[3] = value[1] + } + + // #region Property accessors + /** The x co-ordinate of the top-left corner of this rectangle. */ + get x() { + return this[0] + } + + set x(value: number) { + this[0] = value + } + + /** The y co-ordinate of the top-left corner of this rectangle. */ + get y() { + return this[1] + } + + set y(value: number) { + this[1] = value + } + + /** The width of this rectangle. */ + get width() { + return this[2] + } + + set width(value: number) { + this[2] = value + } + + /** The height of this rectangle. */ + get height() { + return this[3] + } + + set height(value: number) { + this[3] = value + } + + /** The x co-ordinate of the left edge of this rectangle. */ + get left() { + return this[0] + } + + set left(value: number) { + this[0] = value + } + + /** The y co-ordinate of the top edge of this rectangle. */ + get top() { + return this[1] + } + + set top(value: number) { + this[1] = value + } + + /** The x co-ordinate of the right edge of this rectangle. */ + get right() { + return this[0] + this[2] + } + + set right(value: number) { + this[0] = value - this[2] + } + + /** The y co-ordinate of the bottom edge of this rectangle. */ + get bottom() { + return this[1] + this[3] + } + + set bottom(value: number) { + this[1] = value - this[3] + } + + /** The x co-ordinate of the centre of this rectangle. */ + get centreX() { + return this[0] + (this[2] * 0.5) + } + + /** The y co-ordinate of the centre of this rectangle. */ + get centreY() { + return this[1] + (this[3] * 0.5) + } + // #endregion Property accessors + + /** + * Updates the rectangle to the values of {@link rect}. + * @param rect The rectangle to update to. + */ + updateTo(rect: ReadOnlyRect) { + this[0] = rect[0] + this[1] = rect[1] + this[2] = rect[2] + this[3] = rect[3] + } + + /** + * Checks if the point [{@link x}, {@link y}] is inside this rectangle. + * @param x The x-coordinate to check + * @param y The y-coordinate to check + * @returns `true` if the point is inside this rectangle, otherwise `false`. + */ + containsXy(x: number, y: number): boolean { + const { x: left, y: top, width, height } = this + return left <= x && + top <= y && + left + width >= x && + top + height >= y + } + + /** + * Checks if {@link point} is inside this rectangle. + * @param point The point to check + * @returns `true` if {@link point} is inside this rectangle, otherwise `false`. + */ + containsPoint(point: ReadOnlyPoint): boolean { + return this.x <= point[0] && + this.y <= point[1] && + this.x + this.width >= point[0] && + this.y + this.height >= point[1] + } + + /** + * Checks if {@link rect} is inside this rectangle. + * @param rect The rectangle to check + * @returns `true` if {@link rect} is inside this rectangle, otherwise `false`. + */ + containsRect(rect: ReadOnlyRect): boolean { + return this.x <= rect[0] && + this.y <= rect[1] && + this.x + this.width >= rect[0] + rect[2] && + this.y + this.height >= rect[1] + rect[3] + } + + /** + * Checks if {@link rect} overlaps with this rectangle. + * @param rect The rectangle to check + * @returns `true` if {@link rect} overlaps with this rectangle, otherwise `false`. + */ + overlaps(rect: ReadOnlyRect): boolean { + return this.x < rect[0] + rect[2] && + this.y < rect[1] + rect[3] && + this.x + this.width > rect[0] && + this.y + this.height > rect[1] + } + + /** @returns The centre point of this rectangle, as a new {@link Point}. */ + getCentre(): Point { + return [this.centreX, this.centreY] + } + + /** @returns The area of this rectangle. */ + getArea(): number { + return this.width * this.height + } + + /** @returns The perimeter of this rectangle. */ + getPerimeter(): number { + return 2 * (this.width + this.height) + } + + /** @returns The top-left corner of this rectangle, as a new {@link Point}. */ + getTopLeft(): Point { + return [this[0], this[1]] + } + + /** @returns The bottom-right corner of this rectangle, as a new {@link Point}. */ + getBottomRight(): Point { + return [this.right, this.bottom] + } + + /** @returns The width and height of this rectangle, as a new {@link Size}. */ + getSize(): Size { + return [this[2], this[3]] + } + + /** @returns The offset from the top-left of this rectangle to the point [{@link x}, {@link y}], as a new {@link Point}. */ + getOffsetTo([x, y]: ReadOnlyPoint): Point { + return [x - this[0], y - this[1]] + } + + /** @returns The offset from the point [{@link x}, {@link y}] to the top-left of this rectangle, as a new {@link Point}. */ + getOffsetFrom([x, y]: ReadOnlyPoint): Point { + return [this[0] - x, this[1] - y] + } + + /** Sets the width without moving the right edge (changes position) */ + setWidthRightAnchored(width: number) { + const currentWidth = this[2] + this[2] = width + this[0] += currentWidth - width + } + + /** Sets the height without moving the bottom edge (changes position) */ + setHeightBottomAnchored(height: number) { + const currentHeight = this[3] + this[3] = height + this[1] += currentHeight - height + } + + /** Alias of {@link export}. */ + toArray() { return this.export() } + + /** @returns A new, untyped array (serializable) containing the values of this rectangle. */ + export(): [number, number, number, number] { + return [this[0], this[1], this[2], this[3]] + } + + /** Draws a debug outline of this rectangle. */ + _drawDebug(ctx: CanvasRenderingContext2D, colour = "red") { + const { strokeStyle, lineWidth } = ctx + try { + ctx.strokeStyle = colour + ctx.lineWidth = 0.5 + ctx.beginPath() + ctx.strokeRect(this[0], this[1], this[2], this[3]) + } finally { + ctx.strokeStyle = strokeStyle + ctx.lineWidth = lineWidth + } + } +} diff --git a/src/interfaces.ts b/src/interfaces.ts index f415d5e20..eae429efa 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -214,6 +214,12 @@ export type ReadOnlyPoint = | ReadOnlyTypedArray | ReadOnlyTypedArray +/** A size represented as `[width, height]` that will not be modified */ +export type ReadOnlySize = + | readonly [width: number, height: number] + | ReadOnlyTypedArray + | ReadOnlyTypedArray + /** A rectangle starting at top-left coordinates `[x, y, width, height]` that will not be modified */ export type ReadOnlyRect = | readonly [x: number, y: number, width: number, height: number] diff --git a/src/widgets/BaseSteppedWidget.ts b/src/widgets/BaseSteppedWidget.ts index 434fcc64d..a3da8cf30 100644 --- a/src/widgets/BaseSteppedWidget.ts +++ b/src/widgets/BaseSteppedWidget.ts @@ -1,4 +1,4 @@ -import { BaseWidget, type WidgetEventOptions } from "./BaseWidget" +import { BaseWidget, type DrawWidgetOptions, type WidgetEventOptions } from "./BaseWidget" /** * Base class for widgets that have increment and decrement buttons. @@ -28,13 +28,11 @@ export abstract class BaseSteppedWidget extends BaseWidget { /** * Draw the arrow buttons for the widget * @param ctx The canvas rendering context - * @param margin The margin of the widget - * @param y The y position of the widget * @param width The width of the widget */ - drawArrowButtons(ctx: CanvasRenderingContext2D, margin: number, y: number, width: number) { - const { height, text_color, disabledTextColor } = this - const { arrowMargin, arrowWidth } = BaseWidget + drawArrowButtons(ctx: CanvasRenderingContext2D, width: number) { + const { height, text_color, disabledTextColor, y } = this + const { arrowMargin, arrowWidth, margin } = BaseWidget const arrowTipX = margin + arrowMargin const arrowInnerX = arrowTipX + arrowWidth @@ -54,4 +52,19 @@ export abstract class BaseSteppedWidget extends BaseWidget { ctx.lineTo(width - arrowInnerX, y + height - 5) ctx.fill() } + + override drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions) { + // Store original context attributes + const { fillStyle, strokeStyle, textAlign } = ctx + + this.drawWidgetShape(ctx, options) + if (options.showText) { + if (!this.computedDisabled) this.drawArrowButtons(ctx, options.width) + + this.drawTruncatingText({ ctx, width: options.width }) + } + + // Restore original context attributes + Object.assign(ctx, { textAlign, strokeStyle, fillStyle }) + } } diff --git a/src/widgets/BaseWidget.ts b/src/widgets/BaseWidget.ts index 2ed6aa33d..e421dce56 100644 --- a/src/widgets/BaseWidget.ts +++ b/src/widgets/BaseWidget.ts @@ -2,14 +2,27 @@ import type { CanvasPointer, LGraphCanvas, LGraphNode, Size } from "@/litegraph" import type { CanvasMouseEvent, CanvasPointerEvent } from "@/types/events" import type { IBaseWidget, IWidget, IWidgetOptions, TWidgetType, TWidgetValue } 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 @@ -25,6 +38,8 @@ export abstract class BaseWidget implements IBaseWidget { 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 @@ -90,6 +105,18 @@ export abstract class BaseWidget implements IBaseWidget { 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 @@ -99,6 +126,95 @@ export abstract class BaseWidget implements IBaseWidget { */ 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 diff --git a/src/widgets/BooleanWidget.ts b/src/widgets/BooleanWidget.ts index fd7839a75..7aeac651c 100644 --- a/src/widgets/BooleanWidget.ts +++ b/src/widgets/BooleanWidget.ts @@ -20,16 +20,8 @@ export class BooleanWidget extends BaseWidget implements IBooleanWidget { const { height, y } = this const { margin } = BaseWidget - ctx.textAlign = "left" - ctx.strokeStyle = this.outline_color - ctx.fillStyle = this.background_color - ctx.beginPath() + this.drawWidgetShape(ctx, { width, showText }) - 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() ctx.fillStyle = this.value ? "#89A" : "#333" ctx.beginPath() ctx.arc( @@ -40,22 +32,28 @@ export class BooleanWidget extends BaseWidget implements IBooleanWidget { Math.PI * 2, ) ctx.fill() + if (showText) { - ctx.fillStyle = this.secondary_text_color - const label = this.label || this.name - if (label != null) { - ctx.fillText(label, margin * 2, y + height * 0.7) - } - ctx.fillStyle = this.value ? this.text_color : this.secondary_text_color - ctx.textAlign = "right" - ctx.fillText( - this.value ? this.options.on || "true" : this.options.off || "false", - width - 40, - y + height * 0.7, - ) + this.drawLabel(ctx, margin * 2) + this.drawValue(ctx, width - 40) } } + drawLabel(ctx: CanvasRenderingContext2D, x: number): void { + // Draw label + ctx.fillStyle = this.secondary_text_color + const { displayName } = this + if (displayName) ctx.fillText(displayName, x, this.labelBaseline) + } + + drawValue(ctx: CanvasRenderingContext2D, x: number): void { + // Draw value + ctx.fillStyle = this.value ? this.text_color : this.secondary_text_color + ctx.textAlign = "right" + const value = this.value ? this.options.on || "true" : this.options.off || "false" + ctx.fillText(value, x, this.labelBaseline) + } + override onClick(options: WidgetEventOptions) { this.setValue(!this.value, options) } diff --git a/src/widgets/ButtonWidget.ts b/src/widgets/ButtonWidget.ts index 4f69eb975..a37fb7d03 100644 --- a/src/widgets/ButtonWidget.ts +++ b/src/widgets/ButtonWidget.ts @@ -25,9 +25,7 @@ export class ButtonWidget extends BaseWidget implements IButtonWidget { showText = true, }: DrawWidgetOptions) { // Store original context attributes - const originalTextAlign = ctx.textAlign - const originalStrokeStyle = ctx.strokeStyle - const originalFillStyle = ctx.fillStyle + const { fillStyle, strokeStyle, textAlign } = ctx const { height, y } = this const { margin } = BaseWidget @@ -47,20 +45,16 @@ export class ButtonWidget extends BaseWidget implements IButtonWidget { } // Draw button text - if (showText) { - ctx.textAlign = "center" - ctx.fillStyle = this.text_color - ctx.fillText( - this.label || this.name || "", - width * 0.5, - y + height * 0.7, - ) - } + if (showText) this.drawLabel(ctx, width * 0.5) // Restore original context attributes - ctx.textAlign = originalTextAlign - ctx.strokeStyle = originalStrokeStyle - ctx.fillStyle = originalFillStyle + Object.assign(ctx, { textAlign, strokeStyle, fillStyle }) + } + + drawLabel(ctx: CanvasRenderingContext2D, x: number): void { + ctx.textAlign = "center" + ctx.fillStyle = this.text_color + ctx.fillText(this.displayName, x, this.y + this.height * 0.7) } override onClick({ e, node, canvas }: WidgetEventOptions) { diff --git a/src/widgets/ComboWidget.ts b/src/widgets/ComboWidget.ts index 45e6c06de..5bbbac578 100644 --- a/src/widgets/ComboWidget.ts +++ b/src/widgets/ComboWidget.ts @@ -1,3 +1,4 @@ +import type { WidgetEventOptions } from "./BaseWidget" import type { LGraphNode } from "@/LGraphNode" import type { IComboWidget, IWidgetOptions } from "@/types/widgets" @@ -5,7 +6,6 @@ import { clamp, LiteGraph } from "@/litegraph" import { warnDeprecated } from "@/utils/feedback" import { BaseSteppedWidget } from "./BaseSteppedWidget" -import { BaseWidget, type DrawWidgetOptions, type WidgetEventOptions } from "./BaseWidget" /** * This is used as an (invalid) assertion to resolve issues with legacy duck-typed values. @@ -13,7 +13,7 @@ import { BaseWidget, type DrawWidgetOptions, type WidgetEventOptions } from "./B * Function style in use by: * https://github.com/kijai/ComfyUI-KJNodes/blob/c3dc82108a2a86c17094107ead61d63f8c76200e/web/js/setgetnodes.js#L401-L404 */ -type Values = string[] | Record | ((widget: ComboWidget, node: LGraphNode) => string[]) +type Values = string[] | Record | ((widget?: ComboWidget, node?: LGraphNode) => string[]) function toArray(values: Values): string[] { return Array.isArray(values) ? values : Object.keys(values) @@ -26,6 +26,18 @@ export class ComboWidget extends BaseSteppedWidget implements IComboWidget { // @ts-expect-error Workaround for Record not being typed in IWidgetOptions declare options: Omit, "values"> & { values: Values } + override get displayValue() { + const { values: rawValues } = this.options + if (rawValues) { + const values = typeof rawValues === "function" ? rawValues() : rawValues + + if (values && !Array.isArray(values)) { + return values[this.value] + } + } + return typeof this.value === "number" ? String(this.value) : this.value + } + constructor(widget: IComboWidget) { super(widget) this.type = "combo" @@ -102,107 +114,6 @@ export class ComboWidget extends BaseSteppedWidget implements IComboWidget { this.setValue(value, options) } - /** - * Draws the widget - * @param ctx The canvas context - * @param options The options for drawing the widget - */ - override drawWidget(ctx: CanvasRenderingContext2D, { - width, - showText = true, - }: DrawWidgetOptions) { - // Store original context attributes - const originalTextAlign = ctx.textAlign - const originalStrokeStyle = ctx.strokeStyle - const originalFillStyle = ctx.fillStyle - - 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) { - if (!this.computedDisabled) { - ctx.stroke() - this.drawArrowButtons(ctx, margin, y, width) - } - - // Draw label - ctx.fillStyle = this.secondary_text_color - const label = this.label || this.name - if (label != null) { - ctx.fillText(label, margin * 2 + 5, y + height * 0.7) - } - - // Draw value - ctx.fillStyle = this.text_color - ctx.textAlign = "right" - - let displayValue = typeof this.value === "number" ? String(this.value) : this.value - if (this.options.values) { - let values = this.options.values - if (typeof values === "function") { - // @ts-expect-error handle () => string[] type that is not typed in IWidgetOptions - values = values() - } - if (values && !Array.isArray(values)) { - displayValue = values[this.value] - } - } - - const labelWidth = ctx.measureText(label || "").width + margin * 2 - const inputWidth = width - margin * 4 - const availableWidth = inputWidth - labelWidth - const textWidth = ctx.measureText(displayValue).width - - if (textWidth > availableWidth) { - const ELLIPSIS = "\u2026" - const ellipsisWidth = ctx.measureText(ELLIPSIS).width - const charWidthAvg = ctx.measureText("a").width - - if (availableWidth <= ellipsisWidth) { - // One dot leader - displayValue = "\u2024" - } else { - displayValue = `${displayValue}` - const overflowWidth = (textWidth + ellipsisWidth) - availableWidth - - // Only first 3 characters need to be measured precisely - if (overflowWidth + charWidthAvg * 3 > availableWidth) { - const preciseRange = availableWidth + charWidthAvg * 3 - const preTruncateCt = Math.floor((preciseRange - ellipsisWidth) / charWidthAvg) - displayValue = displayValue.substr(0, preTruncateCt) - } - - while (ctx.measureText(displayValue).width + ellipsisWidth > availableWidth) { - displayValue = displayValue.substr(0, displayValue.length - 1) - } - displayValue += ELLIPSIS - } - } - - ctx.fillText( - displayValue, - width - margin * 2 - 20, - y + height * 0.7, - ) - } - - // Restore original context attributes - ctx.textAlign = originalTextAlign - ctx.strokeStyle = originalStrokeStyle - ctx.fillStyle = originalFillStyle - } - override onClick({ e, node, canvas }: WidgetEventOptions) { const x = e.canvasX - node.pos[0] const width = this.width || node.size[0] diff --git a/src/widgets/KnobWidget.ts b/src/widgets/KnobWidget.ts index ecd975142..337ac959a 100644 --- a/src/widgets/KnobWidget.ts +++ b/src/widgets/KnobWidget.ts @@ -48,9 +48,7 @@ export class KnobWidget extends BaseWidget implements IKnobWidget { }: DrawWidgetOptions, ): void { // Store original context attributes - const originalTextAlign = ctx.textAlign - const originalStrokeStyle = ctx.strokeStyle - const originalFillStyle = ctx.fillStyle + const { fillStyle, strokeStyle, textAlign } = ctx const { y } = this const { margin } = BaseWidget @@ -190,9 +188,7 @@ export class KnobWidget extends BaseWidget implements IKnobWidget { } // Restore original context attributes - ctx.textAlign = originalTextAlign - ctx.strokeStyle = originalStrokeStyle - ctx.fillStyle = originalFillStyle + Object.assign(ctx, { textAlign, strokeStyle, fillStyle }) } onClick(): void { diff --git a/src/widgets/NumberWidget.ts b/src/widgets/NumberWidget.ts index becb9f510..1ba13bbb5 100644 --- a/src/widgets/NumberWidget.ts +++ b/src/widgets/NumberWidget.ts @@ -1,9 +1,9 @@ +import type { WidgetEventOptions } from "./BaseWidget" import type { INumericWidget, IWidgetOptions } from "@/types/widgets" import { getWidgetStep } from "@/utils/widget" import { BaseSteppedWidget } from "./BaseSteppedWidget" -import { BaseWidget, type DrawWidgetOptions, type WidgetEventOptions } from "./BaseWidget" export class NumberWidget extends BaseSteppedWidget implements INumericWidget { // INumberWidget properties @@ -11,6 +11,14 @@ export class NumberWidget extends BaseSteppedWidget implements INumericWidget { declare value: number declare options: IWidgetOptions + override get displayValue() { + return Number(this.value).toFixed( + this.options.precision !== undefined + ? this.options.precision + : 3, + ) + } + constructor(widget: INumericWidget) { super(widget) this.type = "number" @@ -46,67 +54,6 @@ export class NumberWidget extends BaseSteppedWidget implements INumericWidget { super.setValue(newValue, options) } - /** - * Draws the widget - * @param ctx The canvas context - * @param options The options for drawing the widget - */ - override drawWidget(ctx: CanvasRenderingContext2D, { - width, - showText = true, - }: DrawWidgetOptions) { - // Store original context attributes - const originalTextAlign = ctx.textAlign - const originalStrokeStyle = ctx.strokeStyle - const originalFillStyle = ctx.fillStyle - - 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) { - if (!this.computedDisabled) { - ctx.stroke() - this.drawArrowButtons(ctx, margin, y, width) - } - - // Draw label - ctx.fillStyle = this.secondary_text_color - const label = this.label || this.name - if (label != null) { - ctx.fillText(label, margin * 2 + 5, y + height * 0.7) - } - - // Draw value - ctx.fillStyle = this.text_color - ctx.textAlign = "right" - ctx.fillText( - Number(this.value).toFixed( - this.options.precision !== undefined - ? this.options.precision - : 3, - ), - width - margin * 2 - 20, - y + height * 0.7, - ) - } - - // Restore original context attributes - ctx.textAlign = originalTextAlign - ctx.strokeStyle = originalStrokeStyle - ctx.fillStyle = originalFillStyle - } - override onClick({ e, node, canvas }: WidgetEventOptions) { const x = e.canvasX - node.pos[0] const width = this.width || node.size[0] diff --git a/src/widgets/SliderWidget.ts b/src/widgets/SliderWidget.ts index 0d496bb91..3bea50aa4 100644 --- a/src/widgets/SliderWidget.ts +++ b/src/widgets/SliderWidget.ts @@ -29,9 +29,7 @@ export class SliderWidget extends BaseWidget implements ISliderWidget { showText = true, }: DrawWidgetOptions) { // Store original context attributes - const originalTextAlign = ctx.textAlign - const originalStrokeStyle = ctx.strokeStyle - const originalFillStyle = ctx.fillStyle + const { fillStyle, strokeStyle, textAlign } = ctx const { height, y } = this const { margin } = BaseWidget @@ -81,9 +79,7 @@ export class SliderWidget extends BaseWidget implements ISliderWidget { } // Restore original context attributes - ctx.textAlign = originalTextAlign - ctx.strokeStyle = originalStrokeStyle - ctx.fillStyle = originalFillStyle + Object.assign(ctx, { textAlign, strokeStyle, fillStyle }) } /** diff --git a/src/widgets/TextWidget.ts b/src/widgets/TextWidget.ts index f37417674..2d7c46ed5 100644 --- a/src/widgets/TextWidget.ts +++ b/src/widgets/TextWidget.ts @@ -24,54 +24,16 @@ export class TextWidget extends BaseWidget implements IStringWidget { showText = true, }: DrawWidgetOptions) { // Store original context attributes - const originalTextAlign = ctx.textAlign - const originalStrokeStyle = ctx.strokeStyle - const originalFillStyle = ctx.fillStyle + const { fillStyle, strokeStyle, textAlign } = ctx - 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() + this.drawWidgetShape(ctx, { width, showText }) if (showText) { - if (!this.computedDisabled) ctx.stroke() - ctx.save() - ctx.beginPath() - ctx.rect(margin, y, width - margin * 2, height) - ctx.clip() - - // Draw label - ctx.fillStyle = this.secondary_text_color - const label = this.label || this.name - if (label != null) { - ctx.fillText(label, margin * 2, y + height * 0.7) - } - - // Draw value - ctx.fillStyle = this.text_color - ctx.textAlign = "right" - ctx.fillText( - // 30 chars max - String(this.value).substr(0, 30), - width - margin * 2, - y + height * 0.7, - ) - ctx.restore() + this.drawTruncatingText({ ctx, width, leftPadding: 0, rightPadding: 0 }) } // Restore original context attributes - ctx.textAlign = originalTextAlign - ctx.strokeStyle = originalStrokeStyle - ctx.fillStyle = originalFillStyle + Object.assign(ctx, { textAlign, strokeStyle, fillStyle }) } override onClick({ e, node, canvas }: WidgetEventOptions) { diff --git a/test/__snapshots__/litegraph.test.ts.snap b/test/__snapshots__/litegraph.test.ts.snap index 226749622..9e31d2e33 100644 --- a/test/__snapshots__/litegraph.test.ts.snap +++ b/test/__snapshots__/litegraph.test.ts.snap @@ -188,6 +188,8 @@ LiteGraphGlobal { "snap_highlights_node": true, "snaps_for_comfy": true, "throw_errors": true, + "truncateWidgetTextEvenly": false, + "truncateWidgetValuesFirst": false, "use_uuids": false, "uuidv4": [Function], }