export class Control { constructor(offsetX, offsetY, width, height, label = "", readOnly = false, hidden = false) { this.offsetX = offsetX; this.offsetY = offsetY; this.width = width; this.height = height; this.label = label; this.isHovered = false; this.isFocused = false; this.value = null; this.onChange = null; this.readOnly = readOnly; this.hidden = hidden; this.dragStartX = 0; this.dragAccum = 0; this.dragging = false; } getValue() { return this.value; } draw(ctx, parentX, parentY) { const x = parentX + this.offsetX; const y = parentY + this.offsetY; ctx.save(); ctx.fillStyle = this.isHovered ? "#eee" : "#f8f8f8"; ctx.strokeStyle = this.isFocused ? "#0af" : "#999"; ctx.lineWidth = 1; ctx.beginPath(); ctx.roundRect(x, y, this.width, this.height, 6); ctx.fill(); ctx.stroke(); this.drawLabel(ctx, x, y); ctx.restore(); } drawLate(ctx, parentX, parentY) { } drawLabel(ctx, x, y) { ctx.fillStyle = "#333"; ctx.font = "12px sans-serif"; ctx.textAlign = "left"; ctx.textBaseline = "top"; ctx.fillText(this.label, x + 6, y + 6); } contains(mx, my, parentX, parentY) { const x = parentX + this.offsetX; const y = parentY + this.offsetY; return mx > x && mx < x + this.width && my > y && my < y + this.height; } onEvent(type, data) { if (this.readOnly) { if (type === "mouseMove") { return this.onMouseMove?.(data.mx, data.my, data.parentX, data.parentY, data.modifiers); } return; } switch (type) { case "mouseDown": return this.onMouseDown?.(data.mx, data.my, data.parentX, data.parentY); case "mouseUp": return this.onMouseUp?.(data.mx, data.my, data.parentX, data.parentY); case "mouseMove": return this.onMouseMove?.(data.mx, data.my, data.parentX, data.parentY, data.modifiers); case "keyDown": return this.onKeyDown?.(data.key); case "scroll": return this.onScroll?.(data.delta); case "clipboard": return this.onClipboard?.(data.event); default: console.warn(`Unhandled event type: ${type}`, data); } } onMouseDown(mx, my, parentX, parentY) { const x = parentX + this.offsetX; const y = parentY + this.offsetY; if (mx > x && mx < x + this.width && my > y && my < y + this.height) { this.onClick(); // delegate to subclass return true; } return false; } onMouseUp(mx, my, parentX, parentY) { const x = parentX + this.offsetX; const y = parentY + this.offsetY; const inside = mx > x && mx < x + this.width && my > y && my < y + this.height; this.dragging = false; if (!inside) { this.onBlur(); } } onMouseMove(mx, my, parentX, parentY, modifiers = {}) { if (!this.dragging) return; const deltaX = mx - this.dragStartX; this.dragStartX = mx; this.dragAccum += deltaX; this.onDragDelta?.(deltaX, modifiers); } onScroll(delta) { const step = 1; this.value = Math.max(this.min, Math.min(this.max, this.value + delta * step)); if (this.onChange) this.onChange(this.value); } onHover(state) { this.isHovered = state; } onFocus(state) { this.isFocused = state; } onClick() { this.onFocus(true); } onBlur() { this.onFocus(false); } onClipboard(e) { // Default: do nothing } } export class SliderControl extends Control { constructor(x, y, width, height, label = "", min = 0, max = 100, initial = 50, readOnly = false) { super(x, y, width, height, label, readOnly); this.min = min; this.max = max; this.value = initial; } getValue() { return this.value; } draw(ctx, parentX, parentY) { const x = parentX + this.offsetX; const y = parentY + this.offsetY; // Draw background and border ctx.save(); ctx.fillStyle = this.isHovered ? "#eee" : "#f8f8f8"; ctx.strokeStyle = this.isFocused ? "#0af" : "#999"; ctx.lineWidth = 1; ctx.beginPath(); ctx.roundRect(x, y, this.width, this.height, 6); ctx.fill(); ctx.stroke(); this.drawLabel(ctx, x, y); // Draw value (top-right) ctx.textAlign = "right"; ctx.fillText(this.value.toFixed(0), x + this.width - 6, y + 6); // Draw slider track const padding = 10; const trackY = y + this.height - 14; // lower in control const trackStart = x + padding; const trackEnd = x + this.width - padding; const trackWidth = trackEnd - trackStart; const percent = (this.value - this.min) / (this.max - this.min); const knobX = trackStart + percent * trackWidth; ctx.strokeStyle = "#aaa"; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(trackStart, trackY); ctx.lineTo(trackEnd, trackY); ctx.stroke(); // Draw knob ctx.fillStyle = this.isFocused ? "#0af" : "#666"; ctx.beginPath(); ctx.arc(knobX, trackY, 6, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } onMouseDown(mx, my, parentX, parentY) { const handled = super.onMouseDown(mx, my, parentX, parentY); if (handled) { this.dragging = true; this.dragStartX = mx; this.dragAccum = 0; return true; } return false; } onMouseUp(mx, my, parentX, parentY) { this.dragging = false; this.onBlur(); } onScroll(delta) { super.onScroll?.(delta); // optional chaining in case base is extended later } onDragDelta(accumulatedDelta, modifiers = {}) { const trackWidth = this.width - 20; const valuePerPixel = (this.max - this.min) / trackWidth; const screenDelta = accumulatedDelta / (1 / modifiers.zoom || 1); const sensitivity = modifiers.ctrl ? 0.015 : 1.0; const deltaValue = screenDelta * valuePerPixel * sensitivity; this.dragAccum += deltaValue; if (Math.abs(this.dragAccum) >= 1) { const change = Math.round(this.dragAccum); this.value = Math.max(this.min, Math.min(this.max, this.value + change)); this.dragAccum -= change; if (this.onChange) this.onChange(this.value); } } } export class TextInputControl extends Control { constructor(x, y, width, height, inputType = "text", label = "", initial = "", readOnly = false) { super(x, y, width, height, label, readOnly); this.text = String(initial); this.cursorVisible = false; this.cursorTimer = 0; this.selecting = false; this.selectionStart = 0; this.selectionEnd = 0; this.cursorIndex = this.text.length; this.inputType = inputType;//"float"; // "text", "int", "float", "password" this.allowNegative = true; this.maxLength = 20; this.regex = /^[a-zA-Z0-9]+$/ } getValue() { return this.text; } draw(ctx, parentX, parentY) { const x = parentX + this.offsetX; const y = parentY + this.offsetY; ctx.save(); ctx.fillStyle = this.isHovered ? "#eee" : "#fff"; ctx.strokeStyle = this.isFocused ? "#0af" : "#999"; ctx.lineWidth = 1; ctx.beginPath(); ctx.roundRect(x, y, this.width, this.height, 6); ctx.fill(); ctx.stroke(); this.drawLabel(ctx, x, y); const textBefore = this.text.slice(0, this.selectionStart); const selectedText = this.text.slice(this.selectionStart, this.selectionEnd); const textWidthBefore = ctx.measureText(textBefore).width; const selectedWidth = ctx.measureText(selectedText).width; if (this.selectionStart !== this.selectionEnd) { ctx.fillStyle = "#cce5ff"; ctx.fillRect(x + 6 + textWidthBefore, textY - 10, selectedWidth, 20); } // Text field ctx.font = "13px monospace"; ctx.textBaseline = "middle"; ctx.fillStyle = "#000"; ctx.textAlign = "left"; const textY = y + this.height / 2 + 4; const displayText = this.inputType === "password" ? "*".repeat(this.text.length) : this.text; ctx.fillText(displayText, x + 6, textY); // Blinking cursor if (this.isFocused && this.cursorVisible) { const textWidth = ctx.measureText(this.text).width; const cursorX = x + 6 + ctx.measureText(this.text.slice(0, this.cursorIndex)).width; ctx.beginPath(); ctx.moveTo(cursorX, textY - 8); ctx.lineTo(cursorX, textY + 8); ctx.strokeStyle = "#0af"; ctx.stroke(); } ctx.restore(); } onClick() { super.onClick(); this.cursorVisible = true; this.cursorTimer = 0; } onScroll(delta) { const current = parseFloat(this.text) || 0; const step = this.inputType === "int" ? 1 : 0.1; const next = current + delta * step; // Clamp if min/max are defined const clamped = Math.max(this.min ?? -Infinity, Math.min(this.max ?? Infinity, next)); // Round to avoid floating point artifacts const precision = step < 1 ? 2 : 0; const factor = Math.pow(10, precision); const rounded = Math.round(clamped * factor) / factor; this.text = String(rounded); this.cursorIndex = this.text.length; this.selectionStart = this.selectionEnd = this.cursorIndex; if (this.onChange) this.onChange(this.getValue()); } onKeyDown(key) { if (!this.isFocused) return; const hasShift = window.event?.shiftKey; if (key === "ArrowLeft") { if (hasShift) { this.cursorIndex = Math.max(0, this.cursorIndex - 1); this.selectionEnd = this.cursorIndex; } else { this.cursorIndex = Math.max(0, this.cursorIndex - 1); this.selectionStart = this.selectionEnd = this.cursorIndex; } } else if (key === "ArrowRight") { if (hasShift) { this.cursorIndex = Math.min(this.text.length, this.cursorIndex + 1); this.selectionEnd = this.cursorIndex; } else { this.cursorIndex = Math.min(this.text.length, this.cursorIndex + 1); this.selectionStart = this.selectionEnd = this.cursorIndex; } } else if (key === "Backspace") { if (this.selectionStart !== this.selectionEnd) { this.text = this.text.slice(0, this.selectionStart) + this.text.slice(this.selectionEnd); this.cursorIndex = this.selectionStart; } else if (this.cursorIndex > 0) { this.text = this.text.slice(0, this.cursorIndex - 1) + this.text.slice(this.cursorIndex); this.cursorIndex--; } this.selectionStart = this.selectionEnd = this.cursorIndex; } else if (key === "Delete") { if (this.selectionStart !== this.selectionEnd) { this.text = this.text.slice(0, this.selectionStart) + this.text.slice(this.selectionEnd); this.cursorIndex = this.selectionStart; } else if (this.cursorIndex < this.text.length) { this.text = this.text.slice(0, this.cursorIndex) + this.text.slice(this.cursorIndex + 1); } this.selectionStart = this.selectionEnd = this.cursorIndex; } else if (key.length === 1) { const preview = this.text.slice(0, this.selectionStart) + key + this.text.slice(this.selectionEnd); if (!this.isValidInput(preview)) return; if (this.selectionStart !== this.selectionEnd) { this.text = this.text.slice(0, this.selectionStart) + key + this.text.slice(this.selectionEnd); this.cursorIndex = this.selectionStart + 1; } else { this.text = this.text.slice(0, this.cursorIndex) + key + this.text.slice(this.cursorIndex); this.cursorIndex++; } this.selectionStart = this.selectionEnd = this.cursorIndex; } if (this.onChange) this.onChange(this.text); } onClipboard(e) { if (e.ctrlKey && e.key === "c") { const selected = this.text.slice(this.selectionStart, this.selectionEnd); navigator.clipboard.writeText(selected); e.preventDefault(); } if (e.ctrlKey && e.key === "v") { navigator.clipboard.readText().then((clip) => { let filtered = clip; const preview = this.text.slice(0, this.selectionStart) + clip + this.text.slice(this.selectionEnd); if (!this.isValidInput(preview)) return; const before = this.text.slice(0, this.selectionStart); const after = this.text.slice(this.selectionEnd); this.text = before + filtered + after; this.cursorIndex = before.length + filtered.length; this.selectionStart = this.selectionEnd = this.cursorIndex; if (this.onChange) this.onChange(this.text); }); e.preventDefault(); } } isValidInput(input, strict = false) { // Allow empty input if (input === "") return true; if (this.inputType === "int") { if (!strict) { // Allow "-" while typing if (input === "-" && this.allowNegative) return true; } const intPattern = this.allowNegative ? /^-?\d+$/ : /^\d+$/; return intPattern.test(input); } if (this.inputType === "float") { if (!strict) { // Relaxed pattern for typing const relaxedPattern = this.allowNegative ? /^-?(\d+)?(\.)?(\d*)?$/ : /^(\d+)?(\.)?(\d*)?$/; return relaxedPattern.test(input); } // Auto-correct ".123" → "0.123", "-.123" → "-0.123" if (/^\.\d+$/.test(input)) { this.text = "0" + input; } else if (/^-\.\d+$/.test(input)) { input = "-0" + input.slice(1); console.log(">"); } // Strict pattern for finalized float const strictPattern = this.allowNegative ? /^-?\d+(\.\d+)?$/ : /^\d+(\.\d+)?$/; return strictPattern.test(input); } // Regex check (applies to all types) if (this.regex && !this.regex.test(input)) return false; // Max length const currentLength = this.text.length - (this.selectionEnd - this.selectionStart); if (input.length + currentLength > this.maxLength) return false; return true; } onBlur() { super.onBlur(); if (!this.isValidInput(this.text, true)) { // Optionally reset or trim invalid input // this.text = ""; // this.cursorIndex = 0; // this.selectionStart = this.selectionEnd = 0; if (this.onChange) this.onChange(this.text); } } update(deltaTime) { if (this.isFocused) { this.cursorTimer += deltaTime; if (this.cursorTimer > 500) { this.cursorVisible = !this.cursorVisible; this.cursorTimer = 0; } } } } export class DropdownControl extends Control { constructor(x, y, width, height, label = "", options = [], initial = "", readOnly = false) { super(x, y, width, height, label, readOnly); this.options = options; if (typeof initial === "number" && initial >= 0 && initial < options.length) { this.value = options[initial]; } else if (typeof initial === "string" && options.includes(initial)) { this.value = initial; } else { this.value = options.length > 0 ? options[0] : ""; } this.isOpen = false; this.hoveredIndex = -1; } getValue() { return this.options.indexOf(this.value); //return this.value; // returns string vlue } draw(ctx, parentX, parentY) { const x = parentX + this.offsetX; const y = parentY + this.offsetY; ctx.save(); ctx.fillStyle = this.isHovered ? "#eee" : "#f8f8f8"; ctx.strokeStyle = this.isFocused ? "#0af" : "#999"; ctx.lineWidth = 1; ctx.beginPath(); ctx.roundRect(x, y, this.width, this.height, 6); ctx.fill(); ctx.stroke(); this.drawLabel(ctx, x, y); // Draw selected value ctx.fillStyle = "#000"; ctx.font = "13px sans-serif"; ctx.textAlign = "right"; ctx.textBaseline = "middle"; ctx.fillText(this.value, x + this.width - 20, y + this.height / 2); // Draw dropdown arrow ctx.beginPath(); ctx.moveTo(x + this.width - 16, y + this.height / 2 - 4); ctx.lineTo(x + this.width - 10, y + this.height / 2 - 4); ctx.lineTo(x + this.width - 13, y + this.height / 2 + 2); ctx.closePath(); ctx.fillStyle = "#666"; ctx.fill(); ctx.restore(); } drawLate(ctx, parentX, parentY) { const x = parentX + this.offsetX; const y = parentY + this.offsetY; // Draw options if open if (this.isOpen) { for (let i = 0; i < this.options.length; i++) { const optY = y + this.height + i * this.height; const isHovered = i === this.hoveredIndex; ctx.fillStyle = isHovered ? "#cce5ff" : (this.options[i] === this.value ? "#ddd" : "#fff"); ctx.strokeStyle = "#999"; ctx.beginPath(); ctx.rect(x, optY, this.width, this.height); ctx.fill(); ctx.stroke(); ctx.fillStyle = "#000"; ctx.textAlign = "left"; ctx.fillText(this.options[i], x + 6, optY + this.height / 2); } } } onClick() { super.onClick(); this.isOpen = !this.isOpen; } onMouseDown(mx, my, parentX, parentY) { const x = parentX + this.offsetX; const y = parentY + this.offsetY; if (this.isOpen) { for (let i = 0; i < this.options.length; i++) { const optY = y + this.height + i * this.height; if (mx > x && mx < x + this.width && my > optY && my < optY + this.height) { this.value = this.options[i]; this.isOpen = false; if (this.onChange) this.onChange(this.value); return true; } } } return super.onMouseDown(mx, my, parentX, parentY); } onMouseMove(mx, my, parentX, parentY) { if (!this.isOpen) { this.hoveredIndex = -1; return; } const x = parentX + this.offsetX; const y = parentY + this.offsetY; this.hoveredIndex = -1; for (let i = 0; i < this.options.length; i++) { const optY = y + this.height + i * this.height; if (mx > x && mx < x + this.width && my > optY && my < optY + this.height) { this.hoveredIndex = i; break; } } } onScroll(delta) { delta = -delta; const currentIndex = this.options.indexOf(this.value); if (currentIndex === -1 || this.options.length === 0) return; const nextIndex = Math.max(0, Math.min(this.options.length - 1, currentIndex + delta)); this.value = this.options[nextIndex]; if (this.onChange) this.onChange(this.getValue()); } onBlur() { super.onBlur(); this.isOpen = false; } }