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
This commit is contained in:
filtered
2025-05-05 10:48:06 +10:00
committed by GitHub
parent 406abd7731
commit 75df19521b
14 changed files with 591 additions and 261 deletions

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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<ArrayBuffer> {
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
}
}
}

View File

@@ -214,6 +214,12 @@ export type ReadOnlyPoint =
| ReadOnlyTypedArray<Float32Array>
| ReadOnlyTypedArray<Float64Array>
/** A size represented as `[width, height]` that will not be modified */
export type ReadOnlySize =
| readonly [width: number, height: number]
| ReadOnlyTypedArray<Float32Array>
| ReadOnlyTypedArray<Float64Array>
/** 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]

View File

@@ -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 })
}
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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) {

View File

@@ -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<string, string> | ((widget: ComboWidget, node: LGraphNode) => string[])
type Values = string[] | Record<string, string> | ((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<string, string> not being typed in IWidgetOptions
declare options: Omit<IWidgetOptions<string>, "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]

View File

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

View File

@@ -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<number>
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]

View File

@@ -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 })
}
/**

View File

@@ -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) {

View File

@@ -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],
}