Files
picoGraph/scripts/ui/workspace/WorkspaceInlineEditorManager.js
Max Litruv Boonzaayer e64f3e881e Initial commit
moved from private version control
2025-10-19 21:12:41 +11:00

942 lines
29 KiB
JavaScript

/** @typedef {import('../../core/BlueprintNode.js').BlueprintNode} BlueprintNode */
/** @typedef {import('../../core/BlueprintNode.js').PinDescriptor} PinDescriptor */
/** @typedef {import('../../core/NodeGraph.js').NodeGraph} NodeGraph */
/** @typedef {import('../../nodes/NodeRegistry.js').NodeRegistry} NodeRegistry */
/** @typedef {import('../BlueprintWorkspace.js').VariableType} VariableType */
/**
* Manages inline literal and pin editors for workspace nodes.
*/
export class WorkspaceInlineEditorManager {
/**
* @param {{
* getGraph: () => NodeGraph,
* registry: NodeRegistry,
* formatVariableType?: (type: VariableType) => string,
* openLocalVariableTypeDropdown?: (
* nodeId: string,
* trigger: HTMLButtonElement,
* anchor: HTMLElement,
* currentType: VariableType,
* onSelect: (type: VariableType) => void
* ) => void,
* closeTypeDropdown?: () => void,
* isTypeDropdownOpen?: (context: { kind: 'local_variable', nodeId: string }) => boolean,
* }} options Inline editor dependencies.
*/
constructor(options) {
this.#getGraph = options.getGraph;
this.registry = options.registry;
this.#formatVariableType =
typeof options.formatVariableType === "function"
? (type) => options.formatVariableType(type)
: (type) => {
const value = typeof type === "string" ? type : "";
if (!value.length) {
return "";
}
return value.charAt(0).toUpperCase() + value.slice(1);
};
this.#openLocalTypeDropdown =
typeof options.openLocalVariableTypeDropdown === "function"
? options.openLocalVariableTypeDropdown
: null;
this.#closeTypeDropdown =
typeof options.closeTypeDropdown === "function"
? options.closeTypeDropdown
: () => {};
this.#isTypeDropdownOpen =
typeof options.isTypeDropdownOpen === "function"
? options.isTypeDropdownOpen
: () => false;
/**
* @type {Map<string, {
* literal?: { setValue: (value: unknown) => void },
* pinEditors: Map<string, { setValue: (value: unknown) => void, setConnected: (connected: boolean) => void }>
* }>}
*/
this.inlineEditors = new Map();
}
/**
* Creates inline literal editor controls for supported nodes.
*
* @param {BlueprintNode} node Target node instance.
* @param {HTMLElement} article Node root article element.
* @param {HTMLElement} inputContainer Container holding input pins.
* @returns {{ setValue: (value: unknown) => void } | null}
*/
setupLiteralEditor(node, article, inputContainer) {
if (node.type === "set_local_var" || node.type === "get_local_var") {
return this.#setupLocalVariableEditor(node, article, inputContainer);
}
const supported = new Set([
"number_literal",
"string_literal",
"boolean_literal",
]);
if (!supported.has(node.type)) {
return null;
}
const existing = article.querySelector(".node-body");
const body = existing ?? document.createElement("div");
body.className = "node-body";
if (!existing) {
article.insertBefore(body, inputContainer);
}
body.innerHTML = "";
const editor = document.createElement("div");
editor.className = "literal-inline-editor";
body.appendChild(editor);
const controlId = `${node.id}_inline_value`;
const label = document.createElement("label");
label.className = "literal-inline-label";
label.setAttribute("for", controlId);
label.textContent = "Value";
editor.appendChild(label);
const graph = this.#getGraph();
let control;
const commitNumber = () => {
const numeric = Number(control.value);
const value = Number.isFinite(numeric) ? numeric : 0;
graph.setNodeProperty(node.id, "value", value);
};
switch (node.type) {
case "number_literal": {
control = document.createElement("input");
control.type = "number";
control.step = "any";
control.className = "literal-inline-input";
control.addEventListener("change", commitNumber);
control.addEventListener("blur", commitNumber);
break;
}
case "string_literal": {
control = document.createElement("input");
control.type = "text";
control.className = "literal-inline-input";
control.spellcheck = false;
control.addEventListener("input", () => {
graph.setNodeProperty(node.id, "value", control.value);
});
break;
}
case "boolean_literal": {
control = document.createElement("input");
control.type = "checkbox";
control.className = "literal-inline-checkbox";
control.addEventListener("change", () => {
graph.setNodeProperty(node.id, "value", control.checked);
});
break;
}
default:
return null;
}
control.id = controlId;
editor.appendChild(control);
const setValue = (rawValue) => {
switch (node.type) {
case "number_literal": {
const numeric = Number(rawValue);
control.value = Number.isFinite(numeric) ? String(numeric) : "0";
break;
}
case "string_literal": {
control.value = rawValue == null ? "" : String(rawValue);
break;
}
case "boolean_literal": {
control.checked = this.#isTruthy(rawValue);
break;
}
default:
break;
}
};
setValue(node.properties.value);
const entry = this.#ensureEntry(node.id);
entry.literal = { setValue };
return entry.literal;
}
#setupLocalVariableEditor(node, article, inputContainer) {
const typeOptions = [
{ value: "number", label: "Number" },
{ value: "string", label: "String" },
{ value: "boolean", label: "Boolean" },
{ value: "table", label: "Table" },
];
const ensureBody = () => {
const existing = article.querySelector(".node-body");
if (existing) {
existing.innerHTML = "";
return existing;
}
const body = document.createElement("div");
body.className = "node-body";
article.insertBefore(body, inputContainer);
return body;
};
const body = ensureBody();
const editor = document.createElement("div");
editor.className = "local-variable-editor";
body.appendChild(editor);
const headerRow = document.createElement("div");
headerRow.className = "local-variable-row local-variable-row--header";
editor.appendChild(headerRow);
const typeAnchor = document.createElement("div");
typeAnchor.className = "local-variable-type";
headerRow.appendChild(typeAnchor);
const typeButton = document.createElement("button");
typeButton.type = "button";
typeButton.className = "variable-type-button local-variable-type-button";
typeButton.setAttribute("aria-haspopup", "menu");
typeButton.setAttribute("aria-expanded", "false");
typeAnchor.appendChild(typeButton);
const nameInput = document.createElement("input");
nameInput.type = "text";
nameInput.className = "local-variable-name-input";
nameInput.placeholder = "localVar";
nameInput.setAttribute("spellcheck", "false");
nameInput.setAttribute("aria-label", "Local variable name");
headerRow.appendChild(nameInput);
const valueContainer = document.createElement("div");
valueContainer.className = "local-variable-inline-value";
const valueField = document.createElement("div");
valueField.className = "local-variable-inline-value-field";
valueContainer.appendChild(valueField);
const attachValueContainer = () => {
if (node.type !== "set_local_var") {
if (valueContainer.isConnected) {
valueContainer.remove();
}
return;
}
const valuePin = article.querySelector(
".node-inputs .pin[data-pin-id='value']"
);
if (!valuePin) {
return;
}
const parent = valuePin.parentElement;
if (!parent) {
return;
}
if (valueContainer.parentElement !== parent) {
valuePin.insertAdjacentElement("afterend", valueContainer);
return;
}
if (valuePin.nextElementSibling !== valueContainer) {
valuePin.insertAdjacentElement("afterend", valueContainer);
}
};
const graph = this.#getGraph();
const normalizeType = (value) => {
const candidate = typeof value === "string" ? value : "number";
return typeOptions.some((option) => option.value === candidate)
? candidate
: "number";
};
const formatType = (type) => {
return this.#formatVariableType(normalizeType(type));
};
const resolveValuePropertyKey = (type) => {
switch (type) {
case "string":
return "valueString";
case "boolean":
return "valueBoolean";
case "table":
return "valueTable";
case "number":
default:
return "valueNumber";
}
};
const resolveValueControlKind = (type) => {
switch (type) {
case "boolean":
return "boolean";
case "string":
return "string";
case "table":
return "table";
default:
return "number";
}
};
const defaultValueForType = (type) => {
switch (type) {
case "string":
return "";
case "boolean":
return false;
case "table":
return "{}";
case "number":
default:
return 0;
}
};
let activeType = normalizeType(node.properties.variableType);
const updateTypeButton = (type) => {
const normalized = normalizeType(type);
activeType = normalized;
typeButton.dataset.variableType = normalized;
const label = formatType(normalized);
typeButton.title = `Type: ${label}`;
typeButton.setAttribute(
"aria-label",
`Change local variable type (currently ${label})`
);
};
const renderValueControl = (currentNode, type) => {
if (currentNode.type !== "set_local_var") {
if (valueContainer.isConnected) {
valueContainer.remove();
}
valueField.innerHTML = "";
return;
}
const key = resolveValuePropertyKey(type);
const current = currentNode.properties?.[key];
const ensureValueControl = () => {
const desiredKind = resolveValueControlKind(type);
const existing = valueField.firstElementChild;
if (
existing instanceof HTMLElement &&
existing.dataset.localValueKind === desiredKind
) {
return /** @type {HTMLElement} */ (existing);
}
valueField.innerHTML = "";
let control;
if (desiredKind === "boolean") {
const select = document.createElement("select");
select.className = "local-variable-boolean";
select.setAttribute("aria-label", "Local variable value");
const trueOption = document.createElement("option");
trueOption.value = "true";
trueOption.textContent = "True";
const falseOption = document.createElement("option");
falseOption.value = "false";
falseOption.textContent = "False";
select.appendChild(trueOption);
select.appendChild(falseOption);
select.addEventListener("change", () => {
const next = select.value === "true";
graph.setNodeProperty(currentNode.id, key, next);
});
control = select;
} else if (desiredKind === "string") {
const input = document.createElement("input");
input.type = "text";
input.className = "local-variable-text";
input.setAttribute("aria-label", "Local variable value");
input.addEventListener("input", () => {
graph.setNodeProperty(currentNode.id, key, input.value);
});
control = input;
} else if (desiredKind === "table") {
const textarea = document.createElement("textarea");
textarea.className = "local-variable-textarea";
textarea.rows = 2;
textarea.setAttribute("aria-label", "Local variable value");
textarea.addEventListener("input", () => {
graph.setNodeProperty(currentNode.id, key, textarea.value);
});
control = textarea;
} else {
const numberInput = document.createElement("input");
numberInput.type = "number";
numberInput.step = "any";
numberInput.className = "local-variable-number";
numberInput.setAttribute("aria-label", "Local variable value");
const commit = () => {
const value = Number(numberInput.value);
const next = Number.isFinite(value) ? value : 0;
numberInput.value = String(next);
graph.setNodeProperty(currentNode.id, key, next);
};
numberInput.addEventListener("change", commit);
numberInput.addEventListener("blur", commit);
control = numberInput;
}
control.dataset.localValueKind = desiredKind;
valueField.appendChild(control);
return control;
};
const control = ensureValueControl();
if (control instanceof HTMLSelectElement) {
const nextValue = current ? "true" : "false";
if (control.value !== nextValue) {
control.value = nextValue;
}
} else if (control instanceof HTMLTextAreaElement) {
const nextValue = typeof current === "string" ? current : "{}";
if (control.value !== nextValue) {
control.value = nextValue;
}
} else if (control instanceof HTMLInputElement) {
if (control.type === "text") {
const nextValue = typeof current === "string" ? current : "";
if (control.value !== nextValue) {
control.value = nextValue;
}
} else {
const numeric = Number(current);
const nextValue = Number.isFinite(numeric) ? String(numeric) : "0";
if (control.value !== nextValue) {
control.value = nextValue;
}
}
}
attachValueContainer();
};
const applyTypeChange = (rawType) => {
const nextType = normalizeType(rawType);
const key = resolveValuePropertyKey(nextType);
const currentNode = graph.nodes.get(node.id) ?? node;
const existing = currentNode.properties?.[key];
const fallback = existing ?? defaultValueForType(nextType);
updateTypeButton(nextType);
graph.setNodeProperty(currentNode.id, "variableType", nextType);
graph.setNodeProperty(currentNode.id, key, fallback);
const refreshed = graph.nodes.get(node.id) ?? currentNode;
renderValueControl(refreshed, nextType);
};
const entry = this.#ensureEntry(node.id);
const setState = (currentNode) => {
const currentType = normalizeType(currentNode.properties.variableType);
updateTypeButton(currentType);
const currentName =
typeof currentNode.properties.name === "string"
? currentNode.properties.name
: "localVar";
if (nameInput.value !== currentName) {
nameInput.value = currentName;
}
renderValueControl(currentNode, currentType);
};
const openDropdown = () => {
if (!this.#openLocalTypeDropdown) {
return;
}
this.#openLocalTypeDropdown(
node.id,
typeButton,
typeAnchor,
activeType,
(selectedType) => {
applyTypeChange(selectedType);
}
);
};
typeButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
if (!this.#openLocalTypeDropdown) {
return;
}
const context = { kind: "local_variable", nodeId: node.id };
const isOpen = this.#isTypeDropdownOpen(context);
if (isOpen) {
this.#closeTypeDropdown();
return;
}
openDropdown();
});
typeButton.addEventListener("pointerdown", (event) => {
event.stopPropagation();
});
typeButton.addEventListener("keydown", (event) => {
const isToggle =
event.key === "Enter" || event.key === " " || event.key === "Spacebar";
if (isToggle || event.key === "ArrowDown") {
event.preventDefault();
openDropdown();
} else if (event.key === "Escape") {
event.preventDefault();
this.#closeTypeDropdown();
}
});
nameInput.addEventListener("blur", () => {
graph.setNodeProperty(node.id, "name", nameInput.value);
});
nameInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
graph.setNodeProperty(node.id, "name", nameInput.value);
nameInput.blur();
}
});
entry.literal = { setState };
setState(node);
return entry.literal;
}
/**
* Creates inline pin editors when supported.
*
* @param {BlueprintNode} node Current node instance.
* @param {PinDescriptor} pin Pin descriptor.
* @param {HTMLElement} container Pin container element.
*/
setupPinEditor(node, pin, container) {
if (pin.direction !== "input") {
return;
}
const isIfCondition = node.type === "if" && pin.id === "condition";
const isExtcmdCommand =
node.type === "system_extcmd" && pin.id === "command";
const isNamedButtonSelector =
node.type === "input_button_constants" && pin.id === "button";
const isNamedPlayerSelector =
node.type === "input_button_constants" && pin.id === "player";
if (
!isIfCondition &&
!isExtcmdCommand &&
!isNamedButtonSelector &&
!isNamedPlayerSelector
) {
container.classList.remove("has-inline-dropdown");
return;
}
const entry = this.#ensureEntry(node.id);
const propertyKey = this.#pinPropertyKey(pin.id);
const graph = this.#getGraph();
if (!(propertyKey in node.properties)) {
if (isExtcmdCommand || isNamedButtonSelector || isNamedPlayerSelector) {
const definition = this.registry.get(node.type);
const targetKey = isExtcmdCommand
? "command"
: isNamedButtonSelector
? "button"
: "player";
const schema = definition?.properties?.find(
(prop) => prop.key === targetKey
);
const options = Array.isArray(schema?.options) ? schema.options : [];
const defaultOption =
typeof node.properties[targetKey] === "string"
? node.properties[targetKey]
: String(schema?.defaultValue ?? options[0]?.value ?? "");
if (typeof node.properties[targetKey] !== "string") {
node.properties[targetKey] = defaultOption;
}
if (isExtcmdCommand) {
node.properties[propertyKey] = defaultOption;
} else {
const numericDefault = Number.parseInt(defaultOption, 10);
node.properties[propertyKey] = Number.isFinite(numericDefault)
? numericDefault
: 0;
}
} else {
node.properties[propertyKey] = this.#defaultValueForPin(pin);
}
}
container.querySelectorAll(".pin-inline-control").forEach((element) => {
element.remove();
});
let wrapper;
if (isExtcmdCommand || isNamedButtonSelector || isNamedPlayerSelector) {
wrapper = document.createElement("div");
wrapper.className = "pin-inline-control pin-inline-dropdown";
container.appendChild(wrapper);
} else {
wrapper = document.createElement("span");
wrapper.className = "pin-inline-control";
container.classList.remove("has-inline-dropdown");
const label = container.querySelector(".pin-label");
if (label) {
container.insertBefore(wrapper, label);
} else {
container.appendChild(wrapper);
}
}
if (isIfCondition && pin.kind === "boolean") {
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.className = "pin-inline-checkbox";
wrapper.appendChild(checkbox);
const setValue = (value) => {
checkbox.checked = this.#isTruthy(value);
};
const setConnected = (connected) => {
wrapper.classList.toggle("is-hidden", connected);
};
checkbox.addEventListener("change", () => {
graph.setNodeProperty(node.id, propertyKey, checkbox.checked);
});
entry.pinEditors.set(pin.id, { setValue, setConnected });
setValue(node.properties[propertyKey]);
setConnected(this.#hasConnection(node.id, pin.id));
return;
}
if (isExtcmdCommand || isNamedButtonSelector || isNamedPlayerSelector) {
const definition = this.registry.get(node.type);
const targetKey = isExtcmdCommand
? "command"
: isNamedButtonSelector
? "button"
: "player";
const schema = definition?.properties?.find(
(prop) => prop.key === targetKey
);
const options = Array.isArray(schema?.options) ? schema.options : [];
const select = document.createElement("select");
select.className = "pin-inline-select";
const ariaLabel = isExtcmdCommand
? "Command"
: isNamedButtonSelector
? "Button"
: "Player";
select.setAttribute("aria-label", ariaLabel);
const values = options.map((option) => String(option.value));
options.forEach((option) => {
const element = document.createElement("option");
element.value = String(option.value);
element.textContent = option.label ?? String(option.value);
select.appendChild(element);
});
const normalizeValue = (value) => {
const candidate =
typeof value === "string" ? value : String(value ?? "");
if (values.length === 0) {
return candidate;
}
if (values.includes(candidate)) {
return candidate;
}
const schemaDefault =
schema?.defaultValue != null ? String(schema.defaultValue) : null;
if (schemaDefault && values.includes(schemaDefault)) {
return schemaDefault;
}
return values[0];
};
const setValue = (value) => {
const normalized = normalizeValue(
(isNamedButtonSelector || isNamedPlayerSelector) &&
typeof node.properties[targetKey] === "string"
? node.properties[targetKey]
: value
);
select.value = normalized;
};
const setConnected = (connected) => {
wrapper.classList.toggle("is-hidden", connected);
container.classList.toggle("has-inline-dropdown", !connected);
};
select.addEventListener("change", () => {
const nextValue = select.value;
if (isExtcmdCommand) {
graph.setNodeProperty(node.id, propertyKey, nextValue);
graph.setNodeProperty(node.id, "command", nextValue);
} else {
const normalized = normalizeValue(nextValue);
const numeric = Number.parseInt(normalized, 10);
graph.setNodeProperty(
node.id,
propertyKey,
Number.isFinite(numeric) ? numeric : 0
);
graph.setNodeProperty(node.id, targetKey, normalized);
}
});
wrapper.appendChild(select);
entry.pinEditors.set(pin.id, { setValue, setConnected });
setValue(node.properties[propertyKey]);
setConnected(this.#hasConnection(node.id, pin.id));
}
}
/**
* Removes inline editors for pins no longer present on a node.
*
* @param {BlueprintNode} node Target node reference.
*/
prunePinEditors(node) {
const entry = this.inlineEditors.get(node.id);
if (!entry?.pinEditors) {
return;
}
const activePins = new Set(node.inputs.map((pin) => pin.id));
[...entry.pinEditors.keys()].forEach((pinId) => {
if (!activePins.has(pinId)) {
entry.pinEditors.delete(pinId);
}
});
}
/**
* Synchronizes inline editor controls with the graph state for the supplied node.
*
* @param {string} nodeId Node identifier.
*/
syncNode(nodeId) {
const entry = this.inlineEditors.get(nodeId);
if (!entry) {
return;
}
const graph = this.#getGraph();
const node = graph.nodes.get(nodeId);
if (!node) {
return;
}
if (entry.literal) {
if (typeof entry.literal.setState === "function") {
entry.literal.setState(node);
} else if (typeof entry.literal.setValue === "function") {
entry.literal.setValue(node.properties.value);
}
}
entry.pinEditors.forEach((pinEditor, pinId) => {
const pin = node.getPin(pinId);
const propertyKey = this.#pinPropertyKey(pinId);
let value;
if (node.type === "system_extcmd" && pinId === "command") {
const commandValue =
typeof node.properties.command === "string"
? node.properties.command
: null;
if (
commandValue !== null &&
node.properties[propertyKey] !== commandValue
) {
node.properties[propertyKey] = commandValue;
}
} else if (node.type === "input_button_constants") {
if (pinId === "button") {
const rawButton =
typeof node.properties.button === "string"
? node.properties.button
: null;
if (rawButton !== null) {
const numericButton = Number.parseInt(rawButton, 10);
const safeButton = Number.isFinite(numericButton)
? numericButton
: 0;
if (node.properties[propertyKey] !== safeButton) {
node.properties[propertyKey] = safeButton;
}
}
} else if (pinId === "player") {
const rawPlayer =
typeof node.properties.player === "string"
? node.properties.player
: null;
if (rawPlayer !== null) {
const numericPlayer = Number.parseInt(rawPlayer, 10);
const safePlayer = Number.isFinite(numericPlayer)
? numericPlayer
: 0;
if (node.properties[propertyKey] !== safePlayer) {
node.properties[propertyKey] = safePlayer;
}
}
}
}
if (propertyKey in node.properties) {
value = node.properties[propertyKey];
} else if (pin) {
value = this.#defaultValueForPin(pin);
} else {
value = null;
}
pinEditor.setValue(value);
pinEditor.setConnected(this.#hasConnection(nodeId, pinId));
});
}
/** Synchronizes inline editors for every tracked node. */
syncAll() {
this.inlineEditors.forEach((_, nodeId) => this.syncNode(nodeId));
}
/**
* Removes inline editor tracking for a node.
*
* @param {string} nodeId Node identifier.
*/
removeNode(nodeId) {
this.inlineEditors.delete(nodeId);
}
/** Clears all inline editor state. */
clear() {
this.inlineEditors.clear();
}
/**
* Ensures inline editor entry exists for a node.
*
* @param {string} nodeId Node identifier.
* @returns {{ literal?: { setValue: (value: unknown) => void }, pinEditors: Map<string, { setValue: (value: unknown) => void, setConnected: (connected: boolean) => void }> }}
*/
#ensureEntry(nodeId) {
let entry = this.inlineEditors.get(nodeId);
if (!entry) {
entry = { pinEditors: new Map() };
this.inlineEditors.set(nodeId, entry);
} else if (!entry.pinEditors) {
entry.pinEditors = new Map();
}
return entry;
}
/**
* Generates the property key used for storing inline pin overrides.
*
* @param {string} pinId Pin identifier.
* @returns {string}
*/
#pinPropertyKey(pinId) {
return `pin:${pinId}`;
}
/**
* Determines whether a pin has any connections.
*
* @param {string} nodeId Node identifier.
* @param {string} pinId Pin identifier.
* @returns {boolean}
*/
#hasConnection(nodeId, pinId) {
const graph = this.#getGraph();
return graph.getConnectionsForNode(nodeId, pinId).some((connection) => {
const isTarget =
connection.to.nodeId === nodeId && connection.to.pinId === pinId;
const isSource =
connection.from.nodeId === nodeId && connection.from.pinId === pinId;
return isTarget || isSource;
});
}
/**
* Provides a reasonable default value for the supplied pin.
*
* @param {PinDescriptor} pin Pin descriptor.
* @returns {unknown}
*/
#defaultValueForPin(pin) {
if (!pin) {
return null;
}
if (pin.defaultValue !== undefined) {
return pin.defaultValue;
}
switch (pin.kind) {
case "boolean":
return false;
case "number":
return 0;
case "string":
return "";
default:
return null;
}
}
/**
* Normalizes truthy values for inline boolean controls.
*
* @param {unknown} value Candidate value.
* @returns {boolean}
*/
#isTruthy(value) {
if (typeof value === "string") {
return value.toLowerCase() !== "false";
}
return Boolean(value);
}
/** @type {() => NodeGraph} */
#getGraph;
/** @type {(type: VariableType) => string} */
#formatVariableType;
/** @type {(nodeId: string, trigger: HTMLButtonElement, anchor: HTMLElement, currentType: VariableType, onSelect: (type: VariableType) => void) => void} */
#openLocalTypeDropdown;
/** @type {() => void} */
#closeTypeDropdown;
/** @type {(context: { kind: 'local_variable', nodeId: string }) => boolean} */
#isTypeDropdownOpen;
}