Creación de plugins — Añade un dominio con cero modificaciones al Core
Un dominio (habilidades, items, misiones, …) se suma a SheetForge como un paquete separado que hace referencia a SheetForge.Core — Core nunca hace referencia de vuelta a él.
Un plugin puede añadir enums, tipos de celda personalizados, tipos wrapper, validadores de dominio, aristas de grafo, marcadores estructurales, plantillas de "Crear hoja", orígenes de importación completos, los overrides de lienzo de Data Studio, registros de código, superficies de creación declarativas, widgets y acciones, preajustes de color, widgets de celda personalizados, observadores de canalización, y sus propias cadenas de UI localizadas — los dieciséis contratos de abajo.
«Añadir un dominio = cero líneas del Core cambiadas» está impuesto por el compilador. Un ensamblado de prueba sin InternalsVisibleTo (SheetForge.Tests.Consumer) implementa quince de los dieciséis — y las interfaces de capacidad junto a ellos — usando únicamente la superficie pública. Si alguno se redujera a internal la build fallaría (CS0122). El decimosexto, la vía de escape de panel enriquecido solo del editor, devuelve un VisualElement, así que se ejercita mediante una prueba del lado del editor en su lugar.
Los once contratos del Core son C# puro. Eso es lo que permite que una sola DLL de plugin compilada active los mismos slots en el editor de Unity y en el navegador (SheetForge Web) — el ensamblaje y el aislamiento son una única función compartida del Core, y solo el descubrimiento difiere según el host (TypeCache de Unity, el escaneo del ensamblado subido del navegador).
Los cinco contratos del Editor devuelven elementos de UIToolkit o tocan el estado de la ventana, así que existen solo en el editor.
Los dieciséis se descubren automáticamente — un constructor sin parámetros es todo el requisito, sin referencia de ensamblado, llamada de registro ni manifiesto que editar:
| Contrato | Registra | ¿Opcional? |
|---|---|---|
ISheetForgePlugin | Enums + analizadores de tipo de celda personalizados | El contrato base |
ISheetForgeValidatorPlugin | Reglas de validación de dominio (entre columnas / entre pestañas) | Complemento opcional |
ISheetForgeEdgePlugin | Declaraciones de aristas de grafo que el escáner del Core no puede ver | Complemento opcional |
ISheetForgeMarkerPlugin | Marcadores estructurales personalizados (filas @marker por columna) | Complemento opcional |
ISheetForgeTemplatePlugin | Plantillas de "Crear hoja" (pestañas + datos de ejemplo) | Complemento opcional |
ISheetForgeGraphPlugin | Overrides de lienzo por pestaña para Data Studio | Complemento opcional |
ISheetForgeCodeRegistryPlugin | Espacios de clave de solo lectura que viven en código, como pestañas virtuales bloqueadas | Complemento opcional |
ISheetForgeThemePlugin | Preajustes de color para las ventanas de SheetForge (oscuro y claro) | Complemento opcional |
ISheetForgeStudioPlugin | Superficies de creación declarativas — acciones, paneles, insignias de columna, pistas de editor de celda | Complemento opcional |
ISheetForgeStringsPlugin | Las cadenas de UI de tu paquete, por idioma (una superposición consultada antes que las tablas del producto) | Complemento opcional |
ISheetForgePipelinePlugin | Observadores de canalización — notificación de solo lectura de lo que produjo una importación | Complemento opcional |
ISheetSourceProvider | Un origen de importación completo (DB / REST / propio) | Independiente (ensamblado Editor) |
IStudioGraphWidget | Un widget de dominio encima del lienzo de Data Studio | Independiente (ensamblado Editor) |
IStudioInspectorAction | Un botón extra en el inspector de nodo de Data Studio | Independiente (ensamblado Editor) |
IStudioCellEditorProvider | Un widget de entrada personalizado para un tipo de celda en la cuadrícula de Data Studio | Independiente (ensamblado Editor) |
IStudioPanelProvider | Un panel arbitrario de UIToolkit en Studio — la vía de escape junto a la declarativa | Independiente (ensamblado Editor) |
El ejemplo de referencia es una importación selectiva. El ejemplo completo y desarrollado (
SheetForge.PluginDemo) se distribuye como un paquete de Unity enAssets/SheetForge/Examples/SheetForgePluginDemo.unitypackage— haz doble clic en él, o pulsa Import Plugin Demo en la ventana Primeros pasos (Tools ▸ SheetForge ▸ Getting Started, el único lugar donde viven las importaciones de demo), para restaurarlo bajoAssets/SheetForge.PluginDemo/…. Hasta que lo importes, no está en tu proyecto en absoluto — el ejemplo se distribuye únicamente como ese paquete — así que sus ensamblados/tipos/pestañas/direcciones nunca chocan con el tuyo. Las rutas referenciadas más abajo (Assets/SheetForge.PluginDemo/ModifierCellParser.cs, etc.) existen una vez que has importado el paquete. (Un segundo ejemplo, sin plugin —SheetForge.CoreDemo— demuestra la canalización usando únicamente tipos integrados del Core.)
Los complementos extienden la interfaz base sin modificarla — un plugin que no necesita validación ni aristas no se ve afectado por su existencia.
Otras siete interfaces son capacidades en lugar de contratos:
- No se descubren por sí solas.
- Las implementa además algo que ya está registrado.
- El Core las encuentra haciendo cast de ese objeto registrado.
Seis se hacen cast desde un contribuyente de aristas o un override de lienzo registrado — consulta §4.12 para la regla de descubrimiento y cada una de ellas. La séptima, IReferencingCellType, se hace cast desde un parser de celda registrado. Le da a tu propia notación el mismo tratamiento de referencia que recibe RecordId@Tab — consulta §4.4a. Ignorar cualquiera de ellas no cambia nada.
1. Configuración del paquete
Crea una carpeta con su propio .asmdef que haga referencia a SheetForge.Core (más SheetForge.Runtime si necesitas consulta en tiempo de ejecución). Eso es todo. El PluginRegistry del Editor descubre tu implementación de ISheetForgePlugin mediante TypeCache y llama a tus métodos de registro, y el registro es tu código explícito, no un escaneo de ensamblados.
Mantén las implementaciones de los contratos del Core en ese ensamblado principal. Un ensamblado complementario del lado del editor (que también haga referencia a SheetForge.Editor) es donde van las cinco implementaciones IStudio* / ISheetSourceProvider — el navegador solo carga tu DLL principal, así que un contrato del Core implementado en el complemento del editor faltaría ahí en silencio.
1.1 Declarar compatibilidad (opcional, una línea)
Un atributo a nivel de ensamblado indica contra qué generación del formato de plugin se compiló tu ensamblado, y el host mínimo que requiere:
using SheetForge.Core.Plugins;
[assembly: SheetForgePluginCompat(
SheetForgePluginFormat.Current, // the generation constant of the SDK you compiled against
MinHostVersion = "0.1.0", // optional — omit for "any host"
PluginVersion = "1.0.0")] // optional, display only- Omitirlo está bien. Un ensamblado sin declaración se lee como generación
SheetForgePluginFormat.Minimumsin requisito de host, así que los plugins escritos antes de que existiera el atributo cargan exactamente como siempre lo hicieron. - La unidad de juicio es el ensamblado, y un ensamblado rechazado pierde todos sus registros. Una declaración por tipo dejaría pasar a un tipo vecino sin declarar y te dejaría con "rechazado, pero medio registrado".
- La DLL es el juez, no el catálogo. El registro del mercado anuncia los mismos dos valores (
pluginFormat,minHost) para que un listado se pueda filtrar antes de la descarga, pero la barrera lee el atributo de los bytes verificados — un listado puede estar equivocado, la declaración compilada no. - El rechazo es un diagnóstico
PluginIncompatibleque nombra lo que el ensamblado declaró y lo que este host lee, no una desaparición silenciosa. Esto es una declaración de compatibilidad, no una firma: la integridad es tarea del canal de distribución (consulta Mercado de plugins web). - El número de generación solo se mueve si el propio formato de plugin se reemplaza. El crecimiento puramente aditivo — un contrato nuevo, un miembro nuevo en un registro — nunca lo mueve, porque tu plugin existente sigue funcionando sin una recompilación.
2. El plugin base: enums + tipos de celda personalizados
using SheetForge.Core.Model;
using SheetForge.Core.Plugins;
public sealed class SkillsPlugin : ISheetForgePlugin
{
public string Name => "Skills"; // for diagnostics / duplicate-conflict reports
public void RegisterEnums(EnumRegistry enums)
{
// Any Enum<ActionType> / Enum<EffectType> cell in a sheet now resolves,
// and codegen emits the real CLR enum type on the generated field.
enums.Register<ActionType>();
enums.Register<EffectType>();
}
public void RegisterCellParsers(CellParserRegistry parsers)
{
// A custom cell type joins parsing, validation, codegen, bake and
// round-trip by registration alone (open-closed — zero pipeline edits).
parsers.Register(new ModifierCellParser());
}
}Un tipo de celda personalizado de principio a fin
Implementa ICellValueParser (string → valor) y, para completar tanto el bake fuertemente tipado como el ciclo de ida y vuelta de exportación/Push, también ICustomCellType (tipo CLR + valor → string canónico). La minigramática Modifier del ejemplo (stat:op:value, por ejemplo attack:add:10):
using System;
using SheetForge.Core.Model;
using SheetForge.Core.Unparse;
public sealed class ModifierCellParser : ICellValueParser, ICustomCellType
{
// The @type cell text: a column declares "Modifier" or "List<Modifier>".
public string TypeName => "Modifier";
// ICustomCellType: the CLR value type codegen emits ([Serializable] struct).
public Type ValueType => typeof(Modifier);
public bool TryParse(CellParseContext context, string text, out object value)
{
value = null;
string[] parts = text.Split(':');
if (parts.Length != 3)
{
// Failure = collect a structured error and return false. Never throw.
context.Errors.Add(new ImportError(
ImportErrorCode.CustomTypeParseFailed, context.Coordinate,
text, "'stat:op:value' form (e.g. attack:add:10)", null));
return false;
}
// ... parse the three parts (InvariantCulture; reject NaN/Infinity) ...
value = new Modifier(parts[0].Trim(), /*op*/ default, /*value*/ 0f);
return true;
}
// ICustomCellType: value → canonical cell string (the exact inverse of TryParse).
public bool TryRender(object value, out string text, out string reason)
{
reason = null;
if (!(value is Modifier m)) { text = null; reason = "Not a Modifier."; return false; }
// Use CanonicalValueRenderer.RenderFloat for floats — round-trip-safe on Mono.
text = m.stat + ":" + "add" + ":" + CanonicalValueRenderer.RenderFloat(m.value);
return true;
}
}(Consulta Assets/SheetForge.PluginDemo/ModifierCellParser.cs para la versión completa de producción, con validación de token de operador y sugerencias de coincidencia más cercana.)
El @target en tipos personalizados funciona solo con el registro: declara una columna como Modifier@Stats y tu analizador lee context.Type.TargetName ("Stats"). La comprobación de integridad de ese destino (¿existe la pestaña? ¿se resuelve el id?) le corresponde a un validador de dominio — la misma división de responsabilidades que RecordId@Tab. Un nombre de tipo no registrado con @ sigue siendo un error con una sugerencia, así que la seguridad frente a errores tipográficos se preserva.
Nombres de tipo que el Core ya posee. Los nombres escalares integrados — int, float, bool, string, Enum, RecordId, IntId, AssetRef, Color, AnimationCurve y Gradient — se registran antes que cualquier plugin. Un parser que reutilice uno de ellos falla el registro con PluginRegistrationConflict — el integrado permanece, esa llamada a RegisterCellParsers se detiene en el parser en conflicto, y los demás slots del plugin igual se cargan — así que un paquete que traiga su propio tipo Color o Gradient debe renombrarlo (consulta las notas de actualización en el changelog). Si tu tipo almacena un color, una curva o un degradado, no tienes que reimplementar la notación: los modelos de valor del Core ColorValue, CurveValue y GradientValue exponen TryParse(text, out value, out error) y Render(), CurveEvaluator / GradientEvaluator los muestrean exactamente igual que Unity, y un StudioCellEditorHint con el arquetipo ColorPicker, CurveEditor o GradientEditor (§4.16) abre el editor nativo para tu tipo en ambos hosts.
Un tipo wrapper de principio a fin (MyWrapper<T>)
Un wrapper es una forma de valor genérica — Pair<int> = 1~2 — que empaqueta varios valores internos T en una sola celda. Tú solo posees la sintaxis externa (delimitador, aridad); el Core analiza el T interno recursivamente, así que Pair<RecordId@Effects>, Pair<Enum<DamageType>> y el anidado Box<Pair<int>> simplemente funcionan, y las referencias internas se validan por completo. Implementa ICellWrapperType y regístralo en el mismo hook RegisterCellParsers mediante parsers.RegisterWrapper(...):
// A [Serializable] generic value type — codegen emits Pair<int>, Pair<RecordRef>, ...
[Serializable] public struct Pair<T> { public T First; public T Second; public Pair(T a, T b){First=a;Second=b;} }
public sealed class PairWrapper : ICellWrapperType
{
public string Name => "Pair"; // the @type token: Pair<Inner>
public Type OpenClrType => typeof(Pair<>); // generic open type — exactly one type parameter
// Outer syntax only: split "1~2" into ["1","2"]. Use a delimiter OTHER than ';'
// so List<Pair<T>> doesn't clash with the list separator.
public bool TrySplit(string cell, out IReadOnlyList<string> pieces, out string reason)
{
reason = null;
var parts = (cell ?? "").Split('~');
if (parts.Length != 2) { pieces = null; reason = "'a~b' form (two parts)."; return false; }
pieces = new[] { parts[0], parts[1] };
return true; // the Core parses each piece as the inner type
}
public string JoinCanonical(IReadOnlyList<string> inner) => inner[0] + "~" + inner[1]; // inverse of TrySplit
public object Assemble(IReadOnlyList<object> inner, Type closed) =>
Activator.CreateInstance(closed, inner[0], inner[1]); // bake: build Pair<TInner>
public bool TryDisassemble(object v, out IReadOnlyList<object> inner, out string reason)
{
reason = null;
var t = v.GetType();
inner = new[] { t.GetField("First").GetValue(v), t.GetField("Second").GetValue(v) };
return true; // Export: read the values back out (inverse of Assemble)
}
}
// In your ISheetForgePlugin.RegisterCellParsers:
public void RegisterCellParsers(CellParserRegistry parsers) => parsers.RegisterWrapper(new PairWrapper());Ese único registro te da:
- Resolución recursiva de
@type. - Codegen fuertemente tipado (
Pair<RecordRef> First;). - Bake.
- El ciclo de ida y vuelta de exportación/Push.
- Pass-through de referencias — un
RecordId@Tabdentro del wrapper se comprueba por integridad, se propaga al renombrar una clave, y se reescribe al renombrar una pestaña.
Las reglas de rechazo y la advertencia sobre el delimitador ; están documentadas en Sintaxis de la hoja.
3. Validadores de dominio (opcional)
La validación del Core está fijada en cuatro tipos (claves, referencias, @overlap, claves de asset). Para reglas entre columnas ("si type es Custom, script es obligatorio") o reglas entre pestañas (comprobando el significado de un registro referenciado), implementa ISheetForgeValidatorPlugin:
using SheetForge.Core.Model;
using SheetForge.Core.Plugins;
using SheetForge.Core.Validation;
public sealed class SkillsPlugin : ISheetForgePlugin, ISheetForgeValidatorPlugin
{
// ... Name / RegisterEnums / RegisterCellParsers unchanged ...
public void RegisterValidators(DomainValidatorRegistry validators)
{
validators.Register(new CustomEffectRequiresScriptValidator());
}
}
public sealed class CustomEffectRequiresScriptValidator : IDomainValidator
{
public string Name => "CustomEffectRequiresScript";
public void Validate(DomainValidationContext ctx)
{
if (!ctx.Tables.TryGetValue("ExampleEffects", out var effects)) return;
if (!effects.Schema.TryGetField("script", out var scriptField)) return;
foreach (var rec in effects.Records)
{
if (!(rec["type"].Value is EnumValue ev) || ev.MemberName != "Custom") continue;
var scripts = rec["script"].AsList;
if (scripts != null && scripts.Count == 0)
ctx.Errors.Add(new ImportError(ImportErrorCode.DomainRuleViolation,
new CellCoordinate("ExampleEffects", rec.RowNumber, scriptField.ColumnNumber, "script"),
/* what */ rec["codeName"].Value.ToString(),
/* why */ "A Custom effect must specify a script to run, but 'script' is empty.",
/* how */ "Put a script address in the 'script' column, or change 'type'."));
}
}
}Los validadores registrados se suman automáticamente tanto a la validación de importación como a la validación previa de creación. Reglas:
- Reporta las infracciones en
ctx.ErrorscomoImportErrorCode.DomainRuleViolation— nunca lances una excepción. Una excepción lanzada se aísla y se promueve; los demás validadores igual se ejecutan. - Completa los cuatro elementos — dónde (
CellCoordinate), qué (ActualValue), por qué (Expected), cómo (Suggestion). El "cómo" se muestra textualmente como la frase accionable. ctxte da:- todas las tablas analizadas (
Tables), - los índices de clave (
KeyIndices), - las claves de asset (
AssetKeys—nullsignifica que la validación de assets se omitió).
- todas las tablas analizadas (
- "Recopilarlo todo" y "sin ensamblaje parcial" se heredan automáticamente.
4. Contribuyentes de aristas (opcional)
Si construyes herramientas sobre el grafo de datos (o quieres que un futuro lienzo de grafo vea las conexiones de tu dominio), declara aristas que el escáner de referencias del Core no puede ver — por ejemplo, una estadística referenciada dentro de un valor de minigramática:
public sealed class SkillsPlugin : /* ... */, ISheetForgeEdgePlugin
{
public void RegisterEdgeContributors(EdgeContributorRegistry contributors)
{
contributors.Register(new ModifierStatEdgeContributor()); // effect → stat edges
}
}Un IEdgeContributor recibe un contexto de solo lectura entre pestañas y añade elementos EdgeSpec (pestaña de origen/destino + id de registro, campo opcional, registro de carga útil, etiqueta). Los contribuyentes nunca emiten diagnósticos — las aristas son material de proyección, no de validación. Consulta Núcleo de creación.
4.4 Receta: un tipo personalizado que lleva una clave dentro
RecordId@Tab es la única forma de referencia que el Core entiende, y recibe comprobación de integridad, aristas de grafo, sugerencias de coincidencia más cercana y propagación de renombrado gratis. En el momento en que tu propia notación se traga una clave — attack:add:10, stat.hp>50, fire@0.4 — el Core ve un solo string opaco, así que esos cuatro servicios se detienen en tu puerta. Tres registros devuelven tres de ellos. Escríbelos como un conjunto; una minisintaxis con solo uno de los tres es la forma que produce "importa bien pero nada apunta a nada".
| Pieza | Contrato | Qué restaura | Sin ella |
|---|---|---|---|
| 1. Integridad | IDomainValidator (§3) | Una clave dentro de tu notación que no existe se reporta, con la coordenada y una frase accionable | Un error tipográfico se importa limpiamente y falla en tiempo de ejecución |
| 2. Visibilidad | IEdgeContributor (§4) | El enlace enterrado se convierte en una arista real: el lienzo la dibuja, la lista Used by la cuenta, el índice de referencias la indexa | La conexión existe en los datos y en ningún lugar en pantalla |
| 3. El "cómo" | TextSuggestion.FindNearest dentro de la pieza 1 | "Unknown stat 'atack'. Did you mean 'attack'?" — la misma forma de frase que usan los errores de referencia integrados | Un diagnóstico correcto sin forma de actuar sobre él |
// Piece 1 + 3 together — the validator is where the suggestion belongs, because it is the
// only one of the three that produces a sentence a person reads.
using SheetForge.Core.Model;
using SheetForge.Core.Validation;
public sealed class ModifierStatExistsValidator : IDomainValidator
{
public string Name => "ModifierStatExists";
public void Validate(DomainValidationContext ctx)
{
if (!ctx.KeyIndices.TryGetValue("Stats", out var stats)) return; // no target tab: nothing to check
if (!ctx.Tables.TryGetValue("Effects", out var effects)) return;
if (!effects.Schema.TryGetField("modifier", out var field)) return;
foreach (var rec in effects.Records)
foreach (string statKey in StatKeysIn(rec["modifier"])) // your notation's own split
{
if (stats.Contains(statKey)) continue;
string near = TextSuggestion.FindNearest(statKey, stats.Keys); // piece 3
ctx.Errors.Add(new ImportError(ImportErrorCode.DomainRuleViolation,
new CellCoordinate("Effects", rec.RowNumber, field.ColumnNumber, "modifier"),
/* what */ statKey,
/* why */ "This modifier points at a stat that does not exist in 'Stats'.",
/* how */ near != null
? "Did you mean '" + near + "'? Fix the stat name in the modifier value."
: "Add that record to 'Stats', or correct the stat name."));
}
}
}Reutiliza un único divisor para la notación — el parser, el validador y el contribuyente de aristas deben ponerse de acuerdo sobre dónde empieza y termina una clave, y tres copias privadas de esa división es cómo se desalinean. (Un tipo wrapper, §2, obtiene esto gratis: TrySplit es el divisor compartido.)
El cuarto servicio — la propagación de renombrado — necesita una cosa más, y hay dos formas de conseguirla. Renombrar un registro reescribe las celdas que lo referencian solo donde el Core puede encontrar la clave en el texto. Puede hacerlo para un campo RecordId@Tab, una lista de ellos, y un wrapper cuyo TrySplit expone la clave como un elemento. No puede adivinar por sí solo los límites de subcadena de tu gramática. Así que, o bien:
- Le dices cómo — implementa
IReferencingCellType(§4.4a), que reemplaza toda esta receta de tres piezas con una sola adhesión opcional y restaura los cuatro servicios a la vez. - Aceptas el límite, que al menos es honesto en lugar de silencioso: la pieza 1 reporta la clave ahora colgante en la siguiente importación, con la coordenada y la sugerencia.
La receta de arriba sigue siendo la respuesta correcta en un caso: cuando la columna no tiene @target porque no hay una sola pestaña donde viva la clave. El ejemplo incluido es exactamente eso — List<Modifier> no nombra ningún destino, así que el Core no puede saber dónde debería resolver attack, y ModifierStatEdgeContributor abre esas aristas a mano. Dale a la columna un destino (List<Modifier@Stats>) y §4.4a toma el control.
4.4a Dar a tu propia notación paridad de referencia completa (opcional)
Implementa IReferencingCellType en un parser que ya tengas registrado, y una columna MyType@Tab deja de ser un caso especial: se valida, se sugiere, se propaga, se dibuja, se selecciona y se indexa exactamente igual que RecordId@Tab. No hay un canal de registro nuevo — el Core hace cast de los parsers que ya están en CellParserRegistry, de la misma manera que las capacidades de lienzo se hacen cast desde contribuyentes de aristas registrados (§4.12). Un tipo personalizado que no lo implementa se comporta exactamente igual que antes, bit a bit.
Los cinco hooks
Todos funcionan sobre un elemento: toda la celda para una columna escalar, o un elemento separado por ; para List<MyType@Tab> — la misma unidad que recibe tu ICellValueParser.TryParse.
| Hook | Responde | Se usa para |
|---|---|---|
bool TryGetTokenKey(elementText, out key) | "¿A qué apunta este elemento?" | Pertenencia — ¿ya está esta celda enlazada a ese registro? |
string MakeToken(key) | "Escribe un nuevo enlace a esta clave" | Una celda vacía, o añadir a una lista. Rellena la carga útil con un punto de partida neutral; una superficie de creación no debe inventar valores. Devuelve null/vacío y el gesto se deshabilita con un motivo en lugar de simularse |
bool TryRetargetToken(elementText, newKey, out newText) | "Apunta esto a otra cosa" | Elegir un registro distinto en la celda ▾, y reapuntar un cable en el lienzo. Cambia solo el destino — quitar y volver a crear el token reiniciaría los números que una persona escribió |
bool TryRemoveToken(elementText, key, out newText) | "Desenlaza esto" | Devuelve texto vacío y el elemento desaparece (la celda escalar se limpia, el elemento de lista se elimina); devuelve algo no vacío y eso se conserva |
bool TryRewriteKeys(elementText, renames, out newText) | "Sustituye todas estas claves" | El barrido de renombrado. Separado de TryRetargetToken porque esa es una sola instrucción de una persona mientras que esta es un paso masivo — y un elemento que contiene dos referencias debe reescribir ambas |
La mitad de texto y la mitad de valor
Añade también IRefBearingValue al valor analizado — las dos mitades hacen trabajos distintos y se necesitan ambas. La mitad de texto no puede ver un valor analizado; la mitad de valor no puede restaurar la notación que escribió el autor:
using System.Collections.Generic;
using SheetForge.Core.Model;
// Text half — on the parser. `stat:op:value`, e.g. attack:add:10
public sealed class ModifierCellParser : ICellValueParser, ICustomCellType, IReferencingCellType
{
public bool TryGetTokenKey(string t, out string key)
{
key = Head(t); // the first segment is the reference
return key.Length != 0;
}
public string MakeToken(string key) => key + ":add:0"; // neutral, ready to edit
public bool TryRetargetToken(string t, string newKey, out string newText)
{
newText = newKey + Rest(t); // the residue is preserved
return Head(t).Length != 0;
}
public bool TryRemoveToken(string t, string key, out string newText)
{
newText = string.Empty; // nothing is left without the key
return Head(t) == key; // not ours → false, never overwrite blindly
}
public bool TryRewriteKeys(string t, IReadOnlyDictionary<string, string> renames, out string newText)
{
newText = t;
if (!renames.TryGetValue(Head(t), out string to)) return false;
newText = to + Rest(t); // attack:add:10 → power:add:10
return true;
}
// … TypeName / TryParse / ValueType / TryRender as in §2
}
// Value half — on the value the parser produces.
public struct Modifier : IRefBearingValue
{
public string stat; public string op; public float value;
IEnumerable<string> IRefBearingValue.ReferencedKeys =>
string.IsNullOrEmpty(stat) ? System.Array.Empty<string>() : new[] { stat };
}Implementar una interfaz no añade campos, así que el ScriptableObject horneado (bake) y el código generado no cambian.
Lo que obtienes, de una sola adhesión opcional — cada una de estas cosas es el propio camino de código del Core, no una reimplementación:
- Integridad + sugerencias — una clave que no existe se reporta como
UnresolvedRecordIdcon la coordenada y "¿quisiste decir …?", compartiendo el presupuesto de sugerencias por campo con las referencias integradas. - Propagación de renombrado con la carga útil intacta — renombrar
attackapowerreescribeattack:add:10enpower:add:10; el operador y el número son del autor, y sobreviven. - Grafo — el enlace se convierte en una arista real con coordenadas: se dibuja, el nodo recibe un puerto, la lista Used by la cuenta, y el índice de referencias la tiene en ambos sentidos.
- El selector
▾— la celda recibe el mismo menú desplegable con búsqueda que tiene una celdaRecordId@Tab, y elegir un registro distinto reemplaza el destino y conserva el residuo. Sin el registro, el selector declina en lugar de pegar una clave desnuda sobre tu valor. - Detección de huérfanos y la regla de lista desplegable exportada — una fila cuyo único enlace saliente vive dentro de tu notación ya no se trata como sin conectar, y una columna escalar de tu tipo recibe una lista desplegable de validación de datos sobre las claves de la pestaña destino (Fuentes, exportación y envío).
El uso más simple es un tipo alias. Digamos que el valor es solo una clave y el texto de la celda es esa clave:
TryGetTokenKeyrecorta.MakeTokendevuelve la clave.TryRetargetTokendevuelve la nueva clave.TryRemoveTokendevuelve vacío.
La columna es entonces un RecordId@Tab en todo sentido funcional. Lo único que te queda a ti es la presentación: aparece bajo su propio nombre en @type, y puedes adjuntar un widget de celda (§4.13) o una forma de lienzo (§4.7) a esa columna en particular. No se necesita un contrato separado para un alias.
Dos restricciones, ambas estructurales:
- Sin
;en la carga útil. El Core divide una celda de lista en elementos antes de que tu parser o cualquiera de estos hooks vea el texto, así que un punto y coma dentro de un valor se haría pedazos en dos elementos. (Los tipos wrapper llevan la misma restricción por la misma razón.) @targetdebe nombrar una pestaña de hoja real, exactamente igual queRecordId@Tab— la pestaña virtual de un registro de código se rechaza conUnknownTargetTab. Esa restricción es lo que permite que el reporte de referencias sin resolver, las sugerencias de coincidencia más cercana y la propagación de renombrado sean las propias del Core, sin modificar.
Ninguno de los cinco hooks puede lanzar una excepción: responde false o null para cualquier cosa que no puedas interpretar, y preserva el residuo siempre que reescribas.
También funciona contra un espacio de clave entero. Si la pestaña que nombra tu @target tiene como clave IntId en lugar de RecordId, nada cambia en tu código — la clave que tus hooks devuelven y reciben es simplemente el entero escrito como texto. Qué espacio de clave usar para comparar lo decide la propia identidad de la pestaña destino, no tu tipo.
- La validación, las sugerencias de coincidencia más cercana, la propagación de renombrado, las aristas, el selector y la detección de huérfanos se activan todos de la misma manera.
- Una comodidad que el Core añade ahí por ti: como un entero se puede escribir de varias formas, un renombrado le entrega a
TryRewriteKeysla forma tal como aparece en ese elemento junto con la canónica (007y7ambos se mapean a12), así que una búsqueda ordinal dentro de tu tipo no se pierde un valor con ceros a la izquierda. - La demo incluida no incluye un tipo personalizado de referencia dirigido a una pestaña
IntId— su ejemploModifierapunta a una con clave de tipo string — así que este camino tiene pruebas pero ningún ejemplo trabajado para copiar.
4.5 Marcadores estructurales personalizados (opcional)
Los marcadores integrados son @name, @type, @desc, y tres opcionales:
@overlap.@style, que describe la hoja — su etiqueta de grupo y su color — en lugar de sus columnas.@enum, que marca la hoja como un conjunto de definiciones de enum en lugar de una tabla.
@overlap es un marcador por columna: su fila lleva un valor por columna, validado columna por columna. Puedes registrar tus propios marcadores de la misma manera — por ejemplo, un marcador @curve que registra cómo interpola cada columna numérica. Implementa IStructuralMarkerDefinition y regístralo mediante ISheetForgeMarkerPlugin:
// A hypothetical plugin (the bundled Plugin Demo does not register a marker):
public sealed class CurvesPlugin : /* ... */, ISheetForgeMarkerPlugin
{
public void RegisterStructuralMarkers(MarkerRegistry markers)
{
markers.Register(new CurveMarker());
}
}
public sealed class CurveMarker : IStructuralMarkerDefinition
{
public string MarkerName => "curve"; // without '@' → the sheet row is @curve
public string Description => "How this column interpolates (linear/ease/step).";
// Validate this column's @curve cell. Empty is allowed (defaults to linear).
public void ValidateCell(MarkerCellContext context)
{
string v = context.RawText.Trim();
if (v.Length == 0) return; // you decide what an empty cell means
if (v != "linear" && v != "ease" && v != "step")
context.Reject("@curve must be linear, ease, or step", "use one of: linear, ease, step");
}
}La hoja entonces acepta una fila @curve (en cualquier orden, por encima de los datos):
@name | level | atk
@type | int | int
@curve | | ease
| 1 | 10- El valor se almacena como metadatos agnósticos de dominio:
field.MarkerValues["curve"]. Un validador de dominio o un contribuyente de aristas lo lee desdecontext.Tables[tab].Schema.Fields[i].MarkerValues; la ventana de creación lo muestra en el tooltip del encabezado de columna. - Una celda rechazada se convierte en un diagnóstico
MarkerCellInvalid— tú aportas el "por qué" y el "cómo solucionarlo"; el Core aporta la coordenada y el valor causante. - Los nombres de marcador deben ser identificadores válidos y no deben chocar con los seis integrados (
@name/@type/@desc/@overlap/@style/@enum—Registerlanza una excepción en caso contrario, expuesta como unPluginRegistrationConflict). - Los marcadores son para metadatos por columna, no para nuevas formas de datos — un marcador posee su validación de celda, no toda la fila. Las filas de marcador personalizado se preservan textualmente en la exportación/ciclo de ida y vuelta, y se mueven junto con su columna en cada edición de estructura (añadir / eliminar / mover / renombrar).
- El codegen no incorpora los valores de marcador en el bake (al igual que
@overlap, son solo metadatos de validación/visualización, invisibles para la huella del esquema).
4.6 Plantillas de "Crear hoja" (opcional)
El flujo Create sheet incluye dos plantillas integradas — una hoja de items que usa solo tipos del Core, y una hoja de definiciones @enum — más "from scratch". Las plantillas de dominio — esqueletos de hoja que usan tus enums, tipos personalizados y referencias — provienen de los plugins, así que una plantilla está presente exactamente cuando su plugin lo está. Implementa ISheetForgeTemplatePlugin:
public sealed class SkillsPlugin : /* ... */, ISheetForgeTemplatePlugin
{
public void RegisterTemplates(TemplateRegistry templates)
{
templates.Register(new DataTemplate(
"skills.demo", // registry key (unique; duplicates rejected)
"Skill demo (Actions · Effects · Skills)", // your own display string
new List<DataTemplateTab>
{
// Each tab carries a full TSV: marker rows + example data.
new DataTemplateTab("Actions", "@name\tcodeName\ttype\n@type\tRecordId\tEnum<ActionType>\n\tfireball\tProjectile"),
new DataTemplateTab("Effects", /* ... */ ""),
new DataTemplateTab("Skills", /* ... */ ""),
}));
}
}- Una plantilla lleva una o más pestañas, cada una un TSV normalizado completo (filas de comentario/marcador más datos de ejemplo) — a diferencia del ejemplo de item integrado, que es un esqueleto de 0 filas. Como tus tipos de dominio ya están registrados (el plugin está cargado), las hojas creadas se reimportan con éxito de inmediato.
- Las cadenas de visualización son tuyas. Un plugin posee su propio texto (el paquete de ejemplo está fuera del guardián de palabras de dominio) — no estás restringido a las claves
Locdel Core. - Las plantillas multi-pestaña crean todas sus pestañas y reimportan una sola vez, de modo que las referencias entre pestañas se resuelven juntas. El panel Create oculta el campo de nombre de pestaña para estas (los nombres de pestaña están fijados por la plantilla).
- Las claves, los nombres de visualización vacíos, cero pestañas, y el TSV de pestaña vacío se rechazan (
Registerlanza una excepción, expuesta como unPluginRegistrationConflict).
4.7 Overrides de lienzo por pestaña (opcional)
El lienzo de Data Studio decide qué dibujar por sí solo: abres un registro — el término — y recorre el índice de referencias hacia afuera, recopilando todo lo que ese registro consume, y luego dispone el resultado de izquierda a derecha. Eso funciona sin ningún plugin.
Lo que añade un plugin es lo que el core no puede ver o no puede saber:
- una identidad que no es un registro de hoja,
- un enlace que no está escrito en una columna
RecordId@Tab, - un orden que es regla de dominio en lugar de profundidad de referencia.
Implementa IRecordCanvasAugmenter y regístralo por pestaña mediante ISheetForgeGraphPlugin:
using SheetForge.Core.Graphing;
using SheetForge.Core.Plugins;
public sealed class SkillsPlugin : /* ... */, ISheetForgeGraphPlugin
{
public void RegisterGraphShapes(GraphShapeRegistry shapes)
{
shapes.Register("ExampleActions", new ExampleReactiveAugmenter()); // tab name → override
}
}
public sealed class ExampleReactiveAugmenter : IRecordCanvasAugmenter
{
public void Augment(GraphBuildContext context, CanvasAugmentBuilder builder,
string terminusTab, string terminusRecordId)
{
// context = Tables (parsed sheets) · References (indexed both ways) · CodeRegistries
// ① A virtual node: an identity that is not a sheet record. The tab may be empty —
// then the key alone identifies it. The last argument is where clicking it jumps.
builder.AddNode(string.Empty, "evt:impact_landed", "impact_landed", "event");
// ② An extra edge the core scanner cannot see (this link lives in a plain string column).
// Naming the field says *which cell* it is written in; leaving it out keeps the wire
// display-only. Direction is "A uses B", and B is drawn to the left of A.
builder.AddEdge(terminusTab, terminusRecordId, string.Empty, "evt:impact_landed",
/*label*/ "listen", /*fieldName*/ "listen");
// ②b An edge drawn one way whose cell lives on the other end, and a loop you know about.
// Both are trailing arguments — the short call above still compiles unchanged.
builder.AddEdge(string.Empty, "evt:impact_landed", terminusTab, terminusRecordId,
label: "raises", fieldName: "raises", fieldOnTarget: true,
isCyclic: true, cyclicNote: "brake 0s — no damping");
// ③ A layer hint. Absolute columns count from 0 at the left (negative goes further left);
// relative columns count from the terminus, which is what a fixed stage usually means.
builder.SetLayerRelative(string.Empty, "evt:impact_landed", -2);
// ④ A display hint: what a human calls this record. Only you know which column is a name.
builder.SetSubtitle(terminusTab, terminusRecordId, "Counter strike");
}
}- El registro es por nombre de pestaña. Las pestañas que no registras igual reciben un lienzo — el cierre del core — así que un plugin nunca tiene que cubrir cada hoja. Una pestaña duplicada, un nombre de pestaña vacío y un override null se rechazan (
Registerlanza una excepción, expuesta como unPluginRegistrationConflict). - Añades, no reemplazas. Qué registros aparecen es la respuesta del cierre. Un nodo virtual cuyo (pestaña, clave) ya está en pantalla se descarta — gana el registro real — así que un override no puede inventar un registro que existe en una hoja. Lo que sí puede hacer es traer identidades que no tienen ninguna fila de hoja.
- Los nombres son la única excepción. Un hint de visualización es presentación en lugar de identidad, así que sí se aplica a registros que ya existen, y puede nombrar registros que no están en pantalla en absoluto — el selector de conectar los lee, que es por lo que el subtítulo de una tarjeta y una fila del selector dicen lo mismo. Los nombres en blanco se ignoran (es lo mismo que "usar el predeterminado"), y el primer nombre para un registro gana.
- Una arista trae su propio nodo. Si un extremo de una arista extra no está en pantalla, se añade como nodo para que el enlace nunca quede colgando. Una arista con una clave vacía en cualquiera de los dos extremos se ignora.
- Dónde está la celda, y hacia dónde apunta la flecha, pueden diferir. Por defecto se asume que la celda nombrada por
fieldNameestá en el registro de salida. PasafieldOnTarget: truecuando está en la llegada en su lugar — un cable de evento publicado se dibuja evento → registro, pero el texto está en la propia columna del registro. El inspector de cable entonces apunta a la celda real en lugar de a nada. - Ciclos: el core marca los que puede ver, tú declaras los que conoces. Si tus aristas extra cierran un bucle, el lienzo clasifica la arista de retorno y la dibuja discontinua por su cuenta. Juzgar si un ciclo es un problema es trabajo de un validador de dominio (§3); el lienzo es material de visualización, nunca de validación.
isCyclicmarca un cable como ciclo para visualización sin tocar el layout.cyclicNotelleva lo que solo tú sabes (un valor de amortiguación, por ejemplo) — mantén la etiqueta como el nombre de columna y pon la explicación en la nota.
- Los hints de capa vienen en dos sabores.
SetLayeres absoluto — la columna 0 es la más a la izquierda y los negativos van más a la izquierda todavía.SetLayerRelativecuenta desde el término (−1 es la columna inmediatamente a su izquierda), que suele ser lo que significa una etapa fija. La imagen entonces se lee igual sea la cadena poco profunda o profunda, y no tienes que fijar el propio término para evitar que las etapas choquen.- Los hints relativos se resuelven contra la columna del término antes de que cualquier hint la moviera, así que el orden en que añades los hints no puede cambiar el resultado. Si el resultado va a la izquierda de cero, toda la imagen se desplaza a la derecha.
- Un hint para un nodo que no está en pantalla se descarta, y el primer hint para un nodo gana.
- Los fallos están contenidos.
Augmentse ejecuta dentro de try/catch: una excepción se convierte en una advertencia de consola en inglés y la imagen del core, nunca en una ventana rota. - Ampliar nunca te rompe. Cada capacidad añadida desde el primer lanzamiento es un argumento al final o un método nuevo; un override escrito contra la superficie anterior compila y se comporta igual.
(Consulta Assets/SheetForge.PluginDemo/Graphing/ExampleReactiveAugmenter.cs y ExamplePipelineAugmenter.cs para ver los overrides completos — una reacción que hace crecer nodos de evento y un bloque de código alrededor del registro, y un lanzamiento cuyas etapas fijas están ancladas a sus propias columnas.)
4.8 Registros de código — destinos de referencia que viven en código (opcional)
Algunos destinos de referencia no se crean en absoluto en una hoja: los átomos de ejecución a los que despacha tu runtime. Registrarlos como una pestaña virtual bloqueada los coloca en la superficie de creación como solo lectura, y evita que las aristas que apuntan a ellos se dibujen como rotas. Implementa ISheetForgeCodeRegistryPlugin:
using System.Collections.Generic;
using SheetForge.Core.Graphing;
using SheetForge.Core.Plugins;
public sealed class SkillsPlugin : /* ... */, ISheetForgeCodeRegistryPlugin
{
public void RegisterCodeRegistries(CodeRegistryCatalog catalog)
{
catalog.Register(new CodeRegistrySource("_Refs", new List<CodeRegistryEntry>
{
// key = the referenceable id · label = shown text · raises = optional related keys
new CodeRegistryEntry("action.projectile", "Projectile launch", new[] { "impact_landed" }),
new CodeRegistryEntry("effect.script", "Script effect", null),
}));
}
}- Tres puntos de consumo:
- la barra lateral de Data Studio muestra la pestaña virtual bajo READ-ONLY como una cuadrícula de clave/etiqueta/raises;
- un override de lienzo puede consultar las entradas mediante
context.CodeRegistries; - y el inspector de nodo lista los
Raisesde una entrada.
- Las claves se suman a la comprobación de existencia de Studio. Una arista cuyo destino es una clave registrada — normalmente una declarada por un
IEdgeContributor(§4) o construida por tu forma — no se pinta como una referencia rota. - El validador de importación no conoce las pestañas virtuales. Los registros de código son un concepto de la superficie de creación, así que no tipes una columna de hoja como
RecordId@_Refs(la importación reportaríaUnknownTargetTab). Conecta los datos de la hoja con los átomos de código de la forma en que lo hace la demo — una columnatypemás una consulta de contribuyente de aristas / forma. - Elige un nombre que no pueda chocar con una hoja real (la demo usa el prefijo
_). Si uno choca, Studio marca la colisión en la barra lateral en lugar de ocultar cualquiera de las dos en silencio. - Rechazos: un origen
null, un nombre de pestaña vacío, o un nombre de pestaña duplicado lanzan una excepción (expuesta comoPluginRegistrationConflict); una listaRaisesnullse normaliza a vacía. Core trata clave / etiqueta / raises como strings opacos — nunca los interpreta.
(Consulta Assets/SheetForge.PluginDemo/Graphing/ExampleCodeAtoms.cs.)
4.9 Widgets de grafo de Data Studio (opcional, ensamblado Editor)
Un widget es una franja de tu propia UI encima del lienzo de grafo — una vista general de etapas fija, una insignia agregada, lo que el dominio necesite. El core no incluye ningún widget, así que esta área está vacía hasta que un plugin la llena. Como el tipo de retorno es un VisualElement, este contrato vive en el ensamblado Editor (la misma asimetría justificada que ISheetSourceProvider); impleméntalo en un ensamblado del lado del Editor que haga referencia a SheetForge.Editor y SheetForge.Core:
using SheetForge.Editor.Studio;
using UnityEngine.UIElements;
public sealed class ExampleStageStripWidget : IStudioGraphWidget
{
// context = Tab · ShapeId · ModeId · FocusRecordId · FocusRecord · Tables · References · CodeRegistries
public bool AppliesTo(StudioGraphContext context) =>
context.Tab == "ExampleSkills" && context.FocusRecord != null;
public VisualElement Create(StudioGraphContext context)
{
var strip = new VisualElement();
strip.Add(new Label("VALIDATE → CAST → COMMIT → DELIVER → APPLY"));
return strip; // return null to add nothing
}
}- El descubrimiento es automático —
TypeCacheencuentra cada implementación con un constructor sin parámetros; no hay llamada de registro ni registro al que vincularse. Un fallo de instanciación se registra en el log y se omite. - Solo lectura por contrato. El contexto expone las tablas analizadas, el índice de referencias y los registros de código — pero ninguna superficie de preparación. La creación desde el grafo le corresponde a una acción de inspector (§4.10), que la media.
- Sin estado dentro del elemento. Los widgets se recrean en cada reconstrucción del grafo; mantén el estado en tus propios objetos. Las reconstrucciones se fusionan a la frecuencia de acción humana, no por pulsación de tecla.
- Las excepciones están aisladas — que
AppliesTo/Createlancen una excepción produce una advertencia de consola en inglés; el grafo se sigue dibujando.
(Consulta Assets/SheetForge.PluginDemo/Demo/Editor/ExampleStageStripWidget.cs.)
4.10 Acciones de inspector de Data Studio (opcional, ensamblado Editor)
Una acción es un botón extra en el inspector de nodo — "qué puede hacer este dominio con este registro". El core provee una acción integrada (Go to this sheet); todo lo demás llega a través de este contrato:
using SheetForge.Editor.Studio;
public sealed class ExampleInspectorAction : IStudioInspectorAction
{
// A Loc key. The demo registers this key's sentences per language (§4.14);
// an unregistered key is displayed verbatim, so plain text also works.
public string LabelKey => ExampleLocStrings.BrakeActionKey;
public bool AppliesTo(StudioInspectorContext context) =>
context.Tab == "ExampleActions" && context.Record != null;
public void Execute(StudioInspectorContext context)
{
// Mediated mutation: the window turns this into one Undo step + one staged edit
// carrying the logical address (tab · record id · field).
context.StageCell(context.Tab, context.RecordId, "brakeSeconds", "0.25");
// Show the user what changed: (tab, original sheet row number, field); row 0 = tab only.
context.FocusCell(context.Tab, context.Record.RowNumber, "brakeSeconds");
context.RequestRebuild();
}
}- La sesión de creación deliberadamente no se expone. Cada cambio de preparación debe ser un solo paso de Undo nativo con la generación de proyección incrementada; entregar la sesión en bruto institucionalizaría una forma de saltarse esa regla.
StageCell(tab, recordId, field, rawText)yStageCells(writes)son toda la superficie de mutación, y la ventana es dueña de la contabilidad. - ¿Cambiar varias celdas? Usa
StageCells.context.StageCells(new[] { new EdgeCellWrite(tab, recordId, field, text), … })prepara toda la lista como un solo paso de Undo, todo o nada (si una escritura no se puede aplicar, ninguna lo es). Llamar aStageCellvarias veces divide el Ctrl+Z en esos mismos pasos — y para columnas paralelas eso significa que aparece un estado a medio validar en medio de deshacer. Una lista null o vacía no hace nada. - Pasa texto canónico. El texto preparado lo analiza el mismo parser que usa el importador, en el momento del reflejo — así que escribe lo que la hoja contendría.
- Una clave que no está en el baseline es un no-op (un registro nuevo o sin resolver): no se escribe nada en silencio.
- Servicios:
FocusCelldesplaza la cuadrícula hasta una coordenada,RequestRebuildpide un refresco después de que preparas algo. - Descubrimiento, etiquetas y aislamiento funcionan exactamente igual que los widgets: descubrimiento por
TypeCache, fallback textual para unLabelKeyno registrado (una clave vacía recae en el nombre del tipo), y try/catch alrededor deAppliesTo/Execute.
(Consulta Assets/SheetForge.PluginDemo/Demo/Editor/ExampleInspectorAction.cs. Su ensamblado Editor — SheetForge.PluginDemo.Demo.Editor — hace referencia a SheetForge.Editor, SheetForge.Core y al ensamblado del plugin; eso es toda la conexión que necesita una extensión del lado del Editor.)
4.11 Preajustes de color (opcional)
SheetForge pinta sus propias ventanas a partir de un pequeño vocabulario de slots de color (superficies, líneas, texto, colores semánticos, marcas de preparación). Un preajuste recolorea los slots que le importan; todos los demás slots mantienen el valor predeterminado del producto. Implementa ISheetForgeThemePlugin:
public sealed class SkillsPlugin : /* ... */, ISheetForgeThemePlugin
{
public void RegisterThemes(ThemeRegistry themes)
{
themes.Register(new SheetForgeTheme(
"skills.forge", // registry key (unique; the built-in ids are reserved)
"Forge (Skill demo)", // your own display string
new Dictionary<ThemeColorSlot, uint> // dark screens
{
{ ThemeColorSlot.Accent, 0xff9a4d },
{ ThemeColorSlot.Canvas, 0x120d0a },
{ ThemeColorSlot.Text, 0xe8dccf },
},
new Dictionary<ThemeColorSlot, uint> // light screens
{
{ ThemeColorSlot.Accent, 0x9c4a10 },
{ ThemeColorSlot.Canvas, 0xf7f2ec },
{ ThemeColorSlot.Text, 0x2b1f16 },
}));
}
}- Los colores son
0xRRGGBB. Core no hace referencia a ningún tipo del motor, así que aquí no hayUnityEngine.Color; el byte superior se ignora. Las superficies translúcidas (rellenos de insignia, el scrim del modal) derivan de un color de slot más un alfa fijo — tú defines el color, no el alfa. - Da ambas pantallas. Suministra un mapa oscuro y un mapa claro; la elección de brillo del usuario (seguir al editor / siempre oscuro / siempre claro) elige uno. Los slots que omites recaen en el valor predeterminado del producto para ese brillo, así que un preajuste de tres slots es perfectamente normal.
- Registrar no lo aplica. Tu preajuste aparece en
Preferences ▸ SheetForge ▸ Theme ▸ Colour presetjunto a los integrados Default y High contrast; solo la elección del usuario surte efecto. Las cadenas de visualización son tuyas (no se necesita ninguna claveLocdel Core). - Los ids en blanco, los duplicados, y los ids integrados reservados (
default,highContrast) se rechazan (Registerlanza una excepción, expuesta como unPluginRegistrationConflict). - Lo que un tema no puede restilizar: los widgets nativos de Unity dibujados dentro de nuestras ventanas (chrome de botones, bordes de campo) siguen siguiendo el skin del editor — consulta Capacidades y límites.
4.12 Editar en el lienzo de grafo (opcional)
El grafo de Data Studio es una superficie de creación, no una imagen: el clic derecho crea registros, los conecta y desconecta cables (consulta Data Studio). Todo eso funciona en un proyecto sin plugins para columnas RecordId@Tab ordinarias. Las capacidades de abajo lo extienden donde el core no puede llegar — ninguna de ellas cambia un contrato existente, así que un plugin que las ignora compila sin cambios.
Cómo se descubren las capacidades (lee esto primero)
Una capacidad nunca se descubre por sí sola. La ventana encuentra cada una de ellas haciendo cast de los objetos que ya están registrados:
| Capacidad | Se hace cast desde | Qué añade |
|---|---|---|
IAuthorableGraphShape | el override de lienzo registrado por ISheetForgeGraphPlugin | Dónde se pueden crear registros nuevos |
IAuthorableEdgeContributor | el contribuyente de aristas registrado por ISheetForgeEdgePlugin | Convertir un gesto en una escritura de celda |
IBatchAuthorableEdgeContributor | el mismo contribuyente de aristas | Convertir un gesto en varias escrituras de celda |
IVirtualNodeFactory | el mismo contribuyente de aristas | Ofrecer "crear uno más" en el menú de nodo |
IEdgeSlotDeclarer | el mismo contribuyente de aristas | Declarar slots de conexión que el esquema no puede derivar |
IEdgeTokenEditor | el mismo contribuyente de aristas | Describir un token y editar la parte que no es la clave |
Así que las cinco capacidades del lado de las aristas solo se alcanzan si la clase está registrada como IEdgeContributor (mediante ISheetForgeEdgePlugin, §4). Si tu dominio no abre ninguna arista propia, eso no es motivo para saltarte el registro — implementa ContributeEdges como un método vacío y regístralo de todas formas. Ese contribuyente vacío es la forma oficialmente soportada de sumarse:
public sealed class ExampleSlotPlugin : ISheetForgeEdgePlugin
{
public void RegisterEdgeContributors(EdgeContributorRegistry contributors)
=> contributors.Register(new ExampleSlotContributor());
}
public sealed class ExampleSlotContributor : IEdgeContributor, IEdgeSlotDeclarer
{
public string Name => "ExampleSlots";
// Nothing to declare — this class is here for the capabilities below.
public void ContributeEdges(EdgeContributionContext context, ICollection<EdgeSpec> edges) { }
public IReadOnlyList<DeclaredSlot> DeclareSlots(EdgeAuthoringContext context,
string nodeTab, string nodeRecordId) => …;
}Todas ellas se ejecutan dentro de try/catch: una excepción se convierte en una advertencia de consola en inglés y deshabilita solo esa affordance, nada más.
Dónde se pueden crear registros nuevos — IAuthorableGraphShape
Hay dos valores predeterminados, y son deliberadamente distintos.
- La lista de pestañas creables — el eje que esta capacidad reemplaza, que también decide si un lienzo se abre siquiera — cubre toda pestaña que el esquema de la pestaña de foco puede alcanzar, siguiendo referencias transitivamente. Se calcula a partir del esquema, no de los datos, así que se sostiene incluso en una hoja que todavía no tiene filas. Alcanzar una pestaña a dos enlaces de profundidad es progresivo: crea el registro intermedio, aparecen sus puertos, y el siguiente salto se suma a la cascada.
- La cascada de enlace — el selector que realmente ves en lienzo vacío — es más estrecha. Empieza desde las pestañas a las que apuntan los puertos actualmente dibujados en pantalla.
En cualquier caso, las pestañas propiedad de un registro de código y las pestañas sin columna clave se descartan, porque un registro nuevo ahí no podría tener una identidad.
Un override registrado para esa pestaña (§4.7) puede añadir esta interfaz para reemplazar ambos valores predeterminados. Una pestaña que nombra y que ningún puerto en pantalla acepta permanece listada en la cascada de enlace con su motivo adjunto en lugar de desaparecer:
using SheetForge.Core.Graphing;
public sealed class ExamplePipelineAugmenter : IRecordCanvasAugmenter, IAuthorableGraphShape
{
// Empty list = no creating from this canvas. The window still applies its own gates
// (read-only source, running pipeline, workbook-backed tab, no key column) on top.
public IReadOnlyList<string> CreatableTabs(GraphBuildContext context, string tabName)
=> new[] { "ExampleEffects", "ExampleActions" };
}Hacer editable tu propia arista — IAuthorableEdgeContributor
Una arista que abriste con IEdgeContributor (§4) se dibuja pero no es editable, porque solo tú conoces la notación en la que vive. Añade esta interfaz para convertir un gesto de vuelta en texto de celda; la ventana prepara exactamente lo que devuelves y el parser sigue siendo el juez final:
using SheetForge.Core.Edges;
public sealed class ModifierStatEdgeContributor : IEdgeContributor, IAuthorableEdgeContributor
{
public bool TryPlanConnect(EdgeAuthoringContext context, string fromTab, string fromRecordId,
string toTab, string toRecordId, out EdgeCellWrite write)
{
write = default;
if (fromTab != "ExampleEffects" || toTab != "ExampleStats") return false; // not mine
// CellText = the cell as it reads right now (baseline + staging), not the parsed value.
string current = context.CellText(fromTab, fromRecordId, "modifier");
if (current.Contains(toRecordId + ":")) return false; // already linked
string next = current.Length == 0 ? toRecordId + ":add:0"
: current + "; " + toRecordId + ":add:0";
write = new EdgeCellWrite(fromTab, fromRecordId, "modifier", next);
return true;
}
public bool TryPlanDisconnect(EdgeAuthoringContext context, RecordEdge edge, out EdgeCellWrite write)
{
write = default;
if (edge.FieldName != "modifier") return false;
// …remove the fragment naming edge.ToRecordId, hand back the rewritten cell…
write = new EdgeCellWrite(edge.FromTab, edge.FromRecordId, "modifier", rewritten);
return true;
}
}falsesignifica que no pasa nada. No se crea ninguna preparación y el elemento de menú se deshabilita con un motivo honesto — nunca una edición a medio aplicar. Devolvertruecon texto sin sentido está permitido pero es inútil: el valor preparado pasa por la misma validación de pre-flight que uno escrito y aparece en Problems.- Direcciona por clave, no por fila.
EdgeCellWritenombra (pestaña, id de registro, campo); los números de fila se vuelven a resolver en el momento de la escritura, así que un plan preparado sobrevive a que las filas se muevan. - Te llaman durante un gesto. Ambos métodos se ejecutan dentro de try/catch — una excepción se convierte en una advertencia de consola en inglés y deshabilita solo esa affordance, nada más.
- Pregunta al contexto, no a la hoja.
CellTextdevuelve el valor incluyendo la preparación, así que dos enlaces hechos seguidos se ven entre sí. Leer la tabla analizada en su lugar se perdería el primero.
Cambiar varias celdas en un gesto — IBatchAuthorableEdgeContributor
Algunos datos mantienen un elemento repartido entre columnas paralelas: stepDelays | stepTargets | stepCounts, donde el índice i de cada columna es un paso. Añadir un enlace ahí tiene que hacer crecer cada columna a la vez, o las columnas terminan con longitudes distintas — un estado a medio validar que un plan de una sola celda no puede evitar. Esta capacidad es la hermana de IAuthorableEdgeContributor (no una subclase), así que los contribuyentes que solo tienen la forma singular quedan intactos:
using SheetForge.Core.Edges;
public sealed class ExampleStepContributor : IEdgeContributor, IBatchAuthorableEdgeContributor
{
public bool TryPlanConnectMany(EdgeAuthoringContext context, string fromTab, string fromRecordId,
string toTab, string toRecordId,
out IReadOnlyList<EdgeCellWrite> writes)
{
writes = new[]
{
new EdgeCellWrite(fromTab, fromRecordId, "stepTargets", Append(context, fromTab, fromRecordId, toRecordId)),
new EdgeCellWrite(fromTab, fromRecordId, "stepDelays", AppendDefault(context, fromTab, fromRecordId)),
};
return true;
}
public bool TryPlanDisconnectMany(EdgeAuthoringContext context, RecordEdge edge,
out IReadOnlyList<EdgeCellWrite> writes) => …;
}- Todo o nada. Cada escritura de la lista se prepara como un solo paso de Undo nativo; si una sola no se puede escribir (no existe esa fila, origen de solo lectura, canalización en ejecución) no se prepara nada en absoluto.
- El batch gana. Si una clase implementa tanto la forma singular como la de batch, la ventana solo pregunta a la forma de batch — un gesto nunca tiene dos respuestas distintas. Los contribuyentes se siguen consultando en orden de registro y gana el primero que planifica.
- Cada escritura necesita una dirección. Una lista que contiene una escritura con una pestaña o campo vacíos (o una lista vacía) cuenta como "sin plan".
- Desenlazar se ejecuta en cadena. Cuando se cortan varios cables de una tarjeta en un solo gesto, el contexto que lees ya lleva los planes anteriores de este gesto, así que cortar dos tokens de la misma celda elimina ambos. El contrato singular no tiene superficie para recibir ese valor intermedio — esta capacidad es cómo se levanta ese límite.
- Un registro que se está creando no puede ser un destino. En el flujo de "crear y enlazar en un solo gesto", las direcciones de escritura se resuelven antes de que la fila nueva entre en la sesión, así que un plan dirigido al registro que se está creando no puede sostenerse y todo el gesto falla honestamente. Apuntar a filas que ya existen (el caso de columnas paralelas) no se ve afectado.
Crear uno más de algo — IVirtualNodeFactory
Cuando "uno más" no es una fila nueva sino un elemento más en cada una de varias celdas, el lienzo no puede inventar el gesto. Declara los tipos que puedes crear y entrega las escrituras de celda cuando se elige uno:
using SheetForge.Core.Edges;
public sealed class ExampleStepContributor : IEdgeContributor, IVirtualNodeFactory
{
// Called every time the node menu is built — keep it cheap and side-effect free.
public IReadOnlyList<VirtualNodeKind> KindsFor(EdgeAuthoringContext context, string tab, string recordId)
=> tab == "ExampleSkills"
? new[] { new VirtualNodeKind("step", Loc("Add a step")) } // your own translated string
: null;
public bool TryPlanCreate(EdgeAuthoringContext context, string tab, string recordId,
VirtualNodeKind kind, out IReadOnlyList<EdgeCellWrite> writes)
{
writes = null;
if (kind.Id != "step") return false; // not mine → nothing happens
writes = new[] { … }; // one element appended per column
return true;
}
}- La etiqueta ya está traducida. Core no la traduce — suministra el string que tu paquete resolvió (consulta §4.14). Una
/en la etiqueta crea un submenú, así que puedes agrupar tus propias entradas. tabpuede ser un nombre de pestaña virtual o estar vacío. Los nodos que tu override de lienzo coloca en pantalla no viven en una hoja; el menú igual ofrece lo que declaras, porque las celdas que escribes las nombra tu plan, no la identidad del nodo. Las pestañas propiedad de un registro de código quedan excluidas.- Un solo paso de Undo, todo o nada — la misma regla que la capacidad de batch de arriba.
falseno prepara nada en absoluto.
Declarar slots de conexión — IEdgeSlotDeclarer
Los slots de conexión normalmente vienen del esquema (columnas RecordId@Tab). Un nodo que tu override colocó en pantalla no tiene columnas, y una arista de contribuyente solo revela un slot una vez que un enlace ya existe — así que el primer enlace no tenía dónde empezar. Declara los slots en su lugar:
using SheetForge.Core.Edges;
public sealed class ExampleStepContributor : IEdgeContributor, IEdgeSlotDeclarer, IBatchAuthorableEdgeContributor
{
// Called per card and per port gate — keep it cheap and side-effect free.
public IReadOnlyList<DeclaredSlot> DeclareSlots(EdgeAuthoringContext context,
string nodeTab, string nodeRecordId)
=> nodeTab == "#step"
? new[] { new DeclaredSlot("target", "ExampleEffects", /*isList*/ false) }
: null;
}- El nombre tiene dos funciones. Debe ser único dentro de ese nodo, y debe ser igual al
FieldNamede la arista que dibujas hacia él — tanto la búsqueda de slot como el anclaje de cable coinciden por ese nombre. Si una columna de hoja ya tiene ese nombre, la hoja gana y tu declaración se descarta silenciosamente. - Declarar no es planificar. Un slot declarado se conecta a través de tu plan (
IAuthorableEdgeContributoro la forma de batch). Declara sin planificar y el puerto se abre pero no se prepara nada — implementa ambos. - Los puertos se abren en nodos sin fila de hoja. Para un nodo cuya pestaña no es una hoja, la ventana no busca una fila por ese nombre; la dirección de escritura viene de tu plan y se comprueba en el momento de la preparación.
Editar lo que dice el token — IEdgeTokenEditor
Enlazar y desenlazar mueve un token completo. A menudo el token es más que una clave: attack:add:10 nombra una estadística y cuánto. Añade esta capacidad al mismo contribuyente y el inspector de cable gana una fila para ese resto — la parte que no es la clave:
using SheetForge.Core.Edges;
public sealed class ModifierStatEdgeContributor : IEdgeContributor, IAuthorableEdgeContributor, IEdgeTokenEditor
{
public bool TryDescribeToken(EdgeAuthoringContext context, RecordEdge edge,
out EdgeTokenDescription description)
{
description = null;
if (edge.FieldName != "modifier") return false; // not mine
// Read the fragment out of the cell — never rebuild it from the edge, or the
// highlight points at a piece that is not there.
string fragment = FindFragment(context.CellText(edge.FromTab, edge.FromRecordId, "modifier"),
edge.ToRecordId);
if (fragment == null) return false; // hand-edited away
description = new EdgeTokenDescription(
/*tokenText*/ fragment, // "attack:add:10"
/*modifierText*/ fragment.Substring(fragment.IndexOf(':') + 1),// "add:10"
/*modifierLabel*/ "op:value",
/*isChoice*/ false, /*options*/ null, /*optionLabels*/ null); // free text
return true;
}
public bool TryPlanSetModifier(EdgeAuthoringContext context, RecordEdge edge,
string newModifier, out EdgeCellWrite write)
{
// …rebuild the cell with that one fragment's leftover replaced, key untouched…
}
}- Ambas mitades leen la misma celda. Una arista sabe a dónde apunta, no con qué letras está escrita hoy, así que describir toma el mismo
EdgeAuthoringContextque toma la escritura. Eso es lo que hace que el fragmento resaltado y el fragmento reescrito sean demostrablemente el mismo. - La clave nunca pasa por esta puerta. Cambiar a qué apunta un enlace es reapuntar (arrastrar el cable); esta fila solo cambia el resto. Devolver
falsedesde cualquiera de las dos mitades oculta o deshabilita honestamente la fila — sin preparación, sin fallo silencioso. - El widget es tuyo para describir.
isChoicecon opciones dibuja un popup, si no un campo de texto; la etiqueta de la fila y las etiquetas de las opciones son tus strings. Si no hay ningún resto en absoluto, construyenew EdgeTokenDescription(tokenText)y la fila no se dibuja — una referencia del core (cuya clave es todo el token) se comporta así sin ningún código.
Hacer editables tus propios cables, en absoluto
Un cable solo se puede editar si nombra la celda en la que está escrito. El core lo rellena para las referencias que él mismo lee; una arista extra que añades (§4.7) lo hace nombrando el campo:
// Display-only edge — the canvas honestly reports it cannot be edited.
builder.AddEdge(tab, recordId, targetTab, targetKey, "raises");
// Edge that names its cell: "this link is written in (tab, record, column)".
builder.AddEdge(tab, recordId, targetTab, targetKey, "listen", /*fieldName*/ "listen");Nombrar una celda no promete que sea editable — dice dónde vive el enlace. Una arista que añades se entrega a la misma cañería que usa una arista de contribuyente, así que se vuelve editable exactamente cuando un IAuthorableEdgeContributor la reclama. Si esa columna es una columna de texto plano o enum sin nadie que la reescriba, el lienzo reporta el cable como no editable aquí, que es la verdad en lugar de un no-op silencioso.
4.13 Widgets de celda personalizados (opcional, ensamblado Editor)
La cuadrícula dibuja cada celda con un widget integrado (interruptor booleano, popup de enum, selector de referencia, texto en bruto). Cuando un tipo merece una entrada mejor — una curva, un color, un compositor de minigramática, un cuadro multilínea — reemplaza el widget de ese nombre de tipo sin tocar cómo se analiza el valor:
using SheetForge.Editor.Studio;
using UnityEngine.UIElements;
public sealed class ModifierCellEditor : IStudioCellEditorProvider
{
// The base type name from @type (a CellParserRegistry name; for a wrapper, the wrapper name).
public string TypeName => "Modifier";
public VisualElement CreateEditor(StudioCellEditorContext context)
{
if (context.Type.IsList) return null; // decline — the built-in widget takes this cell.
var field = new TextField { value = context.CurrentRawText };
// Typing burst: coalesced into ONE Undo step for this cell.
field.RegisterValueChangedCallback(e => context.CommitTyping(e.newValue));
// Discrete confirmation (focus out): its own Undo step.
field.RegisterCallback<FocusOutEvent>(_ => context.Commit(field.value));
return field;
}
}- El widget da forma a la entrada, el parser posee el significado. Lo que confirmes es texto de hoja canónico; pasa por la misma validación de pre-flight que un valor escrito, y los problemas aparecen en el panel Problems. El widget nunca necesita validar.
- Dos superficies de commit, a propósito.
Commit(elegir de una lista, soltar un slider, perder el foco) crea un paso de Undo;CommitTyping(por pulsación de tecla) fusiona una ráfaga en un solo paso. Colapsarlas en una sola llamada rociaría pasos de Undo por letra, o fusionaría dos elecciones distintas. - Devolver
nulldeclina la celda y el widget integrado toma el control — la respuesta honesta para formas que no manejas (List<T>de tu tipo, campos opcionales).context.Type(el token@typeanalizado) lleva todo lo necesario para decidir. ReferenceKeys(tab)te entrega la misma lista de candidatos que usa el selector de referencia integrado (claves proyectadas ∪ claves de registro de código ∪ claves de fila nueva en preparación, ordenadas) — no hace falta que reúnas las tuyas propias. Para dejar que la persona elija de esa lista en el mismo menú desplegable que abre la celda integrada, llama aStudioKeyPicker.Show(screenAnchor, tab, candidates, picked)y empalma la clave devuelta en tu propia notación antes de confirmar. (Crear un registro, dejar la celda vacía y marcar varios elementos de una lista son reglas propias de la celda de referencia integrada y no están en esa fachada — un widget que posee todo el texto de la celda también posee esas decisiones.)- Puedes reclamar un nombre de tipo integrado, no solo el tuyo. La rama del widget registrado se ejecuta primero, así que
TypeName => "float"realmente reemplaza el cuadro de texto en bruto de cada columnafloat. Así es como entra un slider, un campo de porcentaje o un cuadro con sufijo de unidad. Vienen dos precauciones con eso:- Se aplica a cada columna de ese tipo en el proyecto, así que acótalo leyendo
context.FieldName/context.Taby devolviendonullpara las columnas que no querías incluir. - Lo que confirmas sigue siendo texto de hoja canónico, así que un slider debe renderizar su valor de la forma en que el parser lo vuelve a leer (consulta
CanonicalValueRenderer.RenderFloatpara la forma de escribir floats que espera el ciclo de ida y vuelta).
- Se aplica a cada columna de ese tipo en el proyecto, así que acótalo leyendo
- Los conflictos avisan, el descubrimiento es automático. El mismo descubrimiento por
TypeCacheque cualquier otro contrato; si dos proveedores reclaman un nombre de tipo, gana el primero encontrado y una advertencia de consola nombra a ambos. UnCreateEditorque lanza una excepción se captura, se avisa, y la celda recae en el widget integrado. - Antes de escribir uno, comprueba si una pista (hint) bastaría. Si todo lo que quieres es un menú desplegable, un cuadro multilínea, un control deslizante, un interruptor, un selector de color, un editor de curva o un editor de degradado, registra en su lugar un
StudioCellEditorHint(§4.16) — sin código de widget, y también funciona en el navegador. El orden es: primero este contrato, luego la pista, luego los valores predeterminados del core; así que una pista es lo que recibe la celda siempre que ningún widget haya reclamado el tipo o el que lo hizo haya declinado.
4.14 Cadenas de UI del plugin (opcional)
Las etiquetas que muestra tu paquete — acciones de inspector, títulos de widget, las superficies declarativas de §4.16 — pueden seguir el idioma del usuario. Registra frases por clave de idioma; Loc.Tr consulta esta superposición antes que las tablas del producto, y el t() del navegador hace lo mismo:
using System.Collections.Generic;
using SheetForge.Core.Model;
using SheetForge.Core.Plugins;
public sealed class ExampleLocStrings : ISheetForgeStringsPlugin
{
// Prefix keys with your pack name so packs never collide.
public const string BrakeActionKey = "plugin.skillsDemo.action.setBrake";
public void RegisterStrings(StringOverlayRegistry strings)
{
strings.Register(BrakeActionKey, new Dictionary<string, string>
{
{ "en", "Set reaction brake to 0.25s" },
{ "ko", "반응 제동을 0.25초로 넣기" },
});
// Or one language at a time: strings.Register(key, "en", "…");
}
}- Este contrato vive en Core, así que colócalo en tu ensamblado principal. Ambos hosts muestran las etiquetas de tu paquete, y el navegador solo carga el DLL principal — un plugin de cadenas ubicado en el ensamblado complementario del editor dejaría la app web mostrando claves en bruto.
- Registrar es opcional. Una clave no registrada sigue mostrándose textualmente — este contrato es una vía de mejora, no un requisito.
- Los idiomas son códigos IETF (
"en","ko","zh-Hans","pt-BR", …), comparados sin distinguir mayúsculas/minúsculas.- Registra al menos inglés: la búsqueda recae en idioma solicitado → inglés → fallo, así que un usuario en cualquier otro idioma lee tu frase en inglés en lugar de la clave en bruto.
- Un código que el producto no conoce se rechaza con un motivo en lugar de plegarse silenciosamente a inglés — un error tipográfico que se volviera inglés en silencio sería imposible de rastrear.
- Las claves del producto no se pueden sobrescribir — un registro que nombra una clave integrada se rechaza, así que una superposición nunca puede hacer que la UI contradiga las propias frases del producto. Las etiquetas de menú en particular se generan directamente desde las tablas de idioma, así que una superposición que pudiera reescribirlas haría que el texto de orientación y la ruta de menú real no coincidieran. La superposición es para claves nuevas.
- Los registros duplicados entre paquetes conservan el primero encontrado, con un motivo registrado — si el último registro ganara en silencio, la pantalla dependería del orden de instalación de los plugins.
- Las claves vacías y los valores vacíos también se rechazan. Cada rechazo es una línea en inglés dirigida al desarrollador, porque la audiencia es el autor del plugin, no el usuario final.
- La regla de paridad de 10 idiomas del producto queda intacta: tus cadenas viven en una superposición de consulta junto a las tablas del core, nunca dentro de ellas.
(La demo distribuye esto en Assets/SheetForge.PluginDemo/ExampleLocStrings.cs — en el ensamblado principal, por la razón de arriba — registrando las etiquetas que muestran su acción de inspector (§4.10) y sus superficies declarativas (§4.16).)
4.15 Texto multilínea en una celda (diálogo, descripciones, scripts)
Un salto de línea real nunca puede vivir dentro de una celda. La entrada de la canalización es TSV, donde un tabulador separa celdas y un salto de línea separa filas, así que una celda que contenga cualquiera de los dos caracteres no tiene representación en absoluto.
Cada origen impone esto en la puerta en lugar de dejar pasar una cuadrícula corrupta:
- los lectores de CSV y xlsx reportan
UnsupportedCellCharactercon la coordenada de la celda, recopilando cada celda causante, no solo la primera; - la obtención de Google hace lo mismo;
- y en un archivo
.tsvel carácter ya era el separador de fila.
Esto es una constante de diseño del formato, no un vacío esperando cerrarse. Así que un dominio con texto largo trabaja con ello, mediante una convención de tres partes que está enteramente en territorio del plugin.
1. Elige un escape y escríbelo en tu parser. La elección convencional es un \n literal de dos caracteres en la hoja, sin escapar a la entrada y vuelto a escapar a la salida:
public sealed class ProseCellParser : ICellValueParser, ICustomCellType
{
public string TypeName => "Prose";
public Type ValueType => typeof(string);
public bool TryParse(CellParseContext ctx, string text, out object value)
{
value = text.Replace("\\n", "\n"); // sheet spelling → the value your game sees
return true;
}
public bool TryRender(object value, out string text, out string reason)
{
reason = null;
text = ((string)value).Replace("\r\n", "\n").Replace("\n", "\\n"); // the exact reverse
return true;
}
}Haz que las dos direcciones sean inversas exactas, y demuéstralo. TryRender es lo que escriben de vuelta Export y Push, así que si no deshace TryParse carácter por carácter, un ciclo "hoja → importación → exportación → hoja" reescribe texto que nadie editó. Normalizar \r\n a \n a la salida (como arriba) es lo que evita que un valor creado en Windows alterne entre dos formas de escritura en exportaciones sucesivas. Una sola prueba que renderiza un valor analizado y lo compara con el texto de celda original basta para fijarlo.
2. Dale a la celda un editor de verdad. Un valor escapado con \n es desagradable de escribir en un cuadro de una sola línea, que es exactamente para lo que sirve §4.13 — registra un IStudioCellEditorProvider para "Prose" que devuelva un TextField multilínea (multiline = true), mostrando el valor con saltos de línea reales y confirmándolo vuelto a escapar. Confirma al perder el foco con Commit (un paso de undo por sesión de edición) en lugar de por pulsación de tecla.
3. Conoce el único lugar al que la convención no llega. Alguien que escribe Alt+Enter directamente en la Hoja de Google crea un salto de línea genuino en la celda en vivo, y esa celda se rechaza en la siguiente obtención con una coordenada que apunta a ella. El rechazo es honesto y solucionable, pero sigue siendo un rechazo — así que si los redactores de tu equipo crean prosa en la propia hoja de cálculo, indica en tu propia documentación que el texto largo se escribe con \n, o déjales crearlo en el widget de celda de Data Studio del paso 2, donde el escapado ocurre por ellos.
4.16 Superficies de creación declarativas (opcional)
§4.9, §4.10 y §4.13 devuelven un VisualElement, que es exactamente por qué son solo del editor: el navegador no puede cargar un tipo de UIToolkit, así que una extensión escrita de esa forma existe en una pantalla y no en la otra.
Este contrato responde a las mismas necesidades como datos. Describes la carcasa — un id, una clave de etiqueta, una ubicación, un tono — y suministras solo el predicado y el efecto como delegados. Un solo registro entonces lo dibujan tanto el renderizador de UIToolkit del editor como el renderizador de React del navegador.
using SheetForge.Core.Plugins;
using SheetForge.Core.Studio;
using SheetForge.Core.Theming; // ThemeColorSlot — tones are slots, never hard-coded colours
public sealed class ExampleStudioUi : ISheetForgeStudioPlugin
{
public void RegisterStudioUi(StudioUiRegistry ui)
{
// ① A verb — right-click a row, and this appears at the end of the menu.
ui.AddAction(new StudioActionDescriptor(
"skillsDemo.setBrake", // unique id ("pack.verb" reads well)
ExampleLocStrings.BrakeActionKey, // a Loc key (§4.14); unregistered = shown verbatim
StudioActionPlacement.RowContextMenu,
ctx => ctx.Tab == "ExampleActions" && !string.IsNullOrEmpty(ctx.RecordId), // cheap predicate
ctx => ctx.StageCell(ctx.Tab, ctx.RecordId, "brakeSeconds", "0.25")));
// ② A summary panel — a node tree, rebuilt each recompute tick.
ui.AddPanel(new StudioPanelDescriptor("skillsDemo.summary", ExampleLocStrings.PanelTitleKey, ctx =>
StudioUiNode.List(
StudioUiNode.Heading("Cast summary"),
StudioUiNode.KeyValue("Total damage", TotalDamage(ctx).ToString()),
StudioUiNode.Progress("Cast time", CastRatio(ctx), ThemeColorSlot.Accent),
StudioUiNode.Button("Fill every unbraked reaction", "skillsDemo.fillBrakes"))));
// ③ A column badge — one node beside a column header (null = nothing on that column).
ui.AddColumnBadge(new StudioColumnBadgeDescriptor((ctx, tab, field) =>
field == "brakeSeconds" ? StudioUiNode.Badge(UnbrakedCount(ctx) + " unbraked", ThemeColorSlot.Warning) : null));
// ④ A cell-editor hint — pick a built-in widget for your type without writing one.
ui.AddCellEditorHint(new StudioCellEditorHint("Modifier", StudioCellEditorArchetype.Dropdown, Options));
}
}El vocabulario está deliberadamente acotado — solo crece añadiendo, nunca insertando, así que un registro existente conserva su significado.
- Cinco ubicaciones para una acción:
Inspector,RowContextMenu,TopbarMenu,ColumnHeaderMenu,CanvasNodeMenu.- Cada una llena el contexto con lo que ese asiento sabe — la ubicación de fila lleva el registro, la ubicación de columna lleva el nombre de columna, la ubicación de lienzo lleva el registro del nodo — y deja el resto vacío, así que protégete antes de leer un campo que un asiento no suministra.
- Trece tipos de nodo para un panel o insignia:
Row,Label,Chip,Badge,Button,Rule,Heading,KeyValue,Table,List,Progress,Input,Link.- Se construyen mediante fábricas estáticas (
StudioUiNode.Label(…),.WithTooltip(…)), así que un nodo es inmutable y solo se establecen los campos que significan algo para su tipo.
- Se construyen mediante fábricas estáticas (
- Siete arquetipos de editor de celda:
Dropdown(tú suministras los candidatos),MultilineText,Slider(tú suministras el rango),Toggle(tú suministras los dos textos canónicos),ColorPicker(#RRGGBB/#RRGGBBAA),CurveEditoryGradientEditor(el texto de la celda es la notación canónica de curva / degradado de Sintaxis de la hoja — un paquete cuyo propio tipo escriba esa notación, por ejemplo medianteCurveValue.Render(), puede declararlos). Los tipos integradosColor,AnimationCurveyGradientestán conectados mediante ese mismo mecanismo —BuiltinCellEditorHintscontiene sus tres pistas — y un host consulta primero los registros de un paquete, así que registrar una pista bajo uno de esos nombres de tipo anula la elección integrada en lugar de ser rechazado. En el editor, los últimos tres arquetipos son los campos de color, curva y degradado de Unity; en el navegador son los propios editores de la app; unList<>de un tipo que lleve uno de ellos se convierte en un editor de chips en ambos. El tipoFalloffdel Plugin Demo hace exactamente eso: su parser lee la celda conCurveValue.TryParse, y un solo registro de pista le da un campo de curva en Unity y el editor de curva en el navegador. - Ningún número de layout en ningún lugar. Los píxeles y las proporciones filtrarían el grano de una pantalla a la otra; tú dices qué mostrar y cada renderizador decide cómo colocarlo.
Reglas que vale la pena conocer antes de escribir una:
- La mutación pasa por la misma puerta que tu mano.
StudioSurfaceContextle da a una acción exactamente cuatro poderes —StageCell,StageCells(varias celdas, un paso de Undo, todo o nada),FocusRecord,RequestRebuild— encima de losTables/References/CodeRegistriesde solo lectura.- Así que el verbo de un plugin es una edición preparada ordinaria: un paso de
Ctrl+Z, nada llega a la hoja hasta que haces push, mismo pre-flight. - La barrera de preparación también aplica — un origen de solo lectura, una canalización en ejecución o una pestaña respaldada por un libro la bloquean con el motivo mostrado.
- Así que el verbo de un plugin es una edición preparada ordinaria: un paso de
- Los predicados se ejecutan constantemente.
AppliesTo, la construcción de paneles y la provisión de insignias se ejecutan en cada gesto y en cada ciclo de recálculo. Lee la instantánea que te entregaron; sin E/S, sin red, sin cómputo largo. - Mostrado no es ejecutado. El host vuelve a comprobar el predicado al invocar. Si la situación cambió desde que se dibujó el menú, la respuesta es un no-op honesto más un redibujado en lugar de un segundo fallo. El navegador hace lo mismo para un id obsoleto.
ConfirmKeypregunta primero. Dale a una acción una clave de confirmación y el host muestra esa frase antes de ejecutarla — lo correcto para un verbo que prepara muchas celdas a la vez.- Un nodo
Linksolo abrehttp/https. La regla es un único predicado del Core (StudioUiNode.IsAllowedUrl) que ambos hosts consultan, así que no pueden discrepar sobre qué es seguro abrir; el navegador luego vuelve a comprobar la misma forma antes de renderizar un enlace, lo que solo puede rechazar más, nunca menos.- La url se almacena exactamente como la escribiste y se rechaza en el extremo de apertura con un motivo, en lugar de depurarse en el momento del registro — el paquete que la escribió debería poder averiguar por qué no pasó nada.
- Los paneles no guardan estado. Se reconstruyen en cada ciclo; el único lugar al que pertenece un valor es la hoja (preparado). Si nada registra un panel, el panel no se dibuja en absoluto.
- Las excepciones están aisladas — una excepción lanzada se convierte en una advertencia de consola en inglés y elimina solo esa affordance, no la ventana.
Cuando la descripción no basta — IStudioPanelProvider (ensamblado Editor)
El renderizado arbitrario, la entrada compuesta y los flujos de varios pasos no tienen vocabulario aquí, e inventar uno significaría mantener un miniframework de UI para siempre. Así que el techo es deliberado y la vía de escape es amplia: implementa IStudioPanelProvider en tu ensamblado complementario del editor y pinta lo que quieras.
using SheetForge.Editor.Studio;
using UnityEngine.UIElements;
public sealed class ExampleStudioPanel : IStudioPanelProvider
{
public string Id => "skillsDemo.summary"; // same id as the descriptive panel above
public string TitleKey => ExampleLocStrings.PanelTitleKey;
public bool AppliesTo(StudioSurfaceContext context) => context.Tab == "ExampleSkills";
public VisualElement CreatePanel(StudioSurfaceContext context) => new Label("…anything…");
}Registra ambos bajo el mismo Id y cada host toma lo que puede dibujar: el editor usa el enriquecido, el navegador usa el descriptivo. Así es como "hasta donde llega el navegador, todo el camino en el editor" se sostiene sin un segundo conjunto de contratos.
No hay una variante solo-web — un panel enriquecido faltante significa que se dibuja el descriptivo, no que el panel desaparezca. El elemento vive un ciclo de recálculo, así que tampoco guarda estado.
4.17 Observar la canalización (opcional)
Un puente entre productos, telemetría de dominio o un generador posterior a menudo necesita saber qué produjo una importación sin volver a analizarla. Implementa IPipelineObserver y regístralo mediante ISheetForgePipelinePlugin:
using SheetForge.Core.Model;
using SheetForge.Core.Plugins;
public sealed class ExampleImportObserver : IPipelineObserver, ISheetForgePipelinePlugin
{
public void RegisterPipelineObservers(PipelineObserverRegistry observers) => observers.Register(this);
public void OnImportCompleted(PipelineRunView view)
{
// view = Success · Tables · Diagnostics · SkippedTabs · EnumTabs — an immutable snapshot.
if (!view.Success) return;
// … cache what you need; do not hold the tables ...
}
}- Observar no puede cambiar el resultado. Recibes una instantánea inmutable y no devuelves nada. Deliberadamente no hay ningún hook para alterar un valor o añadir un diagnóstico: interpretar un valor le corresponde a un tipo de celda (§2) y reportar una infracción de regla le corresponde a un validador de dominio (§3). Mezclar participación en un contrato de observación haría que "los observadores no pueden cambiar el resultado" fuera falso en la práctica.
- Una vez por cada ciclo de importación explícito, al final de este, haya tenido éxito o haya fallado. No se ejecuta en la proyección de pre-flight que se recalcula mientras preparas — ningún código de terceros está atado a la frecuencia de pulsación de tecla.
- Una ejecución fallida igual reporta lo que analizó.
Tableslleva las pestañas que se analizaron antes de que la validación fallara, que es el mismo material que usa el flujo de cuarentena (Data Studio), así que un observador ve una imagen veraz de una ejecución fallida en lugar de nada en absoluto. - Dos vacíos honestos. El observador se dispara desde el propio punto de finalización del ciclo de importación, así que una ejecución que nunca llega ahí no se dispara en absoluto.
- Una importación abortada antes de que la canalización se ejecute (sin ajustes activos, la barrera de Addressables rechazando).
- El tramo codegen→compilación interrumpido por un error de compilación.
- Eso es un no-disparo, nunca un disparo incorrecto: si necesitas "se intentó una importación", combina esto con el bus
ImportEventsdel lado del editor.
- Una excepción se aísla a ese observador, con el motivo recopilado; la salida de la importación no cambia ni un poco.
- Los puntos de observación futuros (justo después del análisis, un ciclo de exportación) llegarán como interfaces de capacidad hermanas descubiertas haciendo cast del observador registrado, así que añadir uno no romperá una implementación escrita hoy.
5. Orígenes de importación personalizados (ISheetSourceProvider)
Un nuevo origen (base de datos, endpoint REST, formato propio) se suma con cero modificaciones al Core/Editor. Implementa ISheetSourceProvider en un ensamblado Editor; SourceProviderRegistry lo descubre mediante TypeCache y aparece en el menú desplegable "Origen" de los ajustes, junto a los integrados. Las cuatro cosas que un proveedor debe responder:
- Obtención (Fetch) —
CreateTabSource(settings)devuelve unITabSourceque suministra nombre de pestaña → texto TSV en bruto (async; los problemas de entorno son diagnósticos, no excepciones; se permite salida parcial). - Escritura de vuelta (Write-back) —
CreateReflectTarget(dispatcher, settings)devuelve unISourceReflectTargetque se conecta al dispatcher de creación (usa losSession/Callbacks/Baselinespúblicos del dispatcher para ensamblar tu destino). Devuelve un destino solo si tu origen se puede escribir. - Visibilidad (Visibility) —
GetVisibility(settings)devuelve qué campos de ajustes debe mostrar el inspector para ti. CanAuthor— devuelvefalsepara orígenes de solo lectura; las ventanas de creación deshabilitan su UI de edición (igual que Google ExportUrl).
El string estable Id se guarda en sourceProviderId. Los integrados usan "LocalFile" / "GoogleSheet" como sus Ids; un sourceProviderId vacío se resuelve al valor predeterminado integrado LocalFile. Un Id vacío excluye al proveedor de la UI (útil para sondas de prueba).
Los proveedores viven deliberadamente en el ensamblado Editor — los orígenes son el límite de E/S, y mantener la E/S fuera del Core preserva su pureza (los otros tres contratos son Core puro).
5.5 Herramientas públicas para automatización e integración
Más allá de los contratos de registro, existen cinco puntos de entrada públicos para código que conduce SheetForge en lugar de extenderlo — un script de CI, un hook de build, tu propio botón de inspector, o un segundo producto que hornea sus propios assets a partir de las mismas hojas.
Ejecuta un ciclo — SheetForge.Editor.Pipeline.SheetForgeActions:
SheetForgeActions.RunImport(); // exactly what the toolbar's "Pull from source" does
SheetForgeActions.RunExport();
SheetForgeActions.RunPush();
SheetForgeActions.RunHealthCheck();Cada llamada es el ciclo completo: resolución de ajustes, la barrera de Addressables, exclusión mutua, modales de confirmación y aprobación, la barra de progreso, y la reanudación codegen→compilación→bake a través del domain reload. No hay medio ciclo que ensamblar y por lo tanto ninguna barrera que saltarse por accidente.
Dos cosas que saber:
RunImportyRunPushson fire-and-forget — sus cuerpos sonasync voidporque el hilo principal del editor no debe bloquearse en E/S de red. El retorno por lo tanto no es la finalización; suscríbete aImportEvents.ImportCompletedpara eso.- Push sigue mostrando su modal de aprobación, así que un script desatendido no puede enviar sin una persona.
Toma el mismo bloqueo que toman los integrados — para un proveedor de origen personalizado que escribe en su propio backend:
if (!SheetForgeActions.TryBeginExclusiveScope(out IDisposable scope)) return; // something is running
using (scope) { /* write to your source */ } // Dispose releases; a second Dispose is harmless
// schedule any re-import AFTER the scope closes — the lock is not re-entrantSheetForgeActions.IsBusy responde la misma pregunta sin tomar nada. El bloqueo en sí permanece internal a propósito: si fuera público, llamar a su End() podría liberar la ejecución de otra persona — el scope hace eso imposible, porque solo quien lo tiene puede liberarlo.
Termina una escritura de vuelta de la misma forma que terminan los integrados — AuthoringDispatcher.FinalizeReflectSuccess(writtenTabs) ejecuta el final al que tiene que llegar un ISourceReflectTarget:
- la poda de retención para las pestañas que escribió,
- el límite de confirmación
ClearUndo, - y la reimportación automática.
Las rutas integradas local y de Google ejecutan el mismo cuerpo, así que tu proveedor termina de forma idéntica en lugar de aproximarlo. BuildProjectedTabs() a su lado te entrega la proyección como TSV por pestaña — lo que estás a punto de enviar — así que un proveedor puede previsualizarla o transformarla sin escribir. Una lista writtenTabs vacía es un no-op que mantiene la preparación intacta.
Muestra las propias frases del producto en tu propia UI — ImportReportText.Render(report) (Core.Tooling) devuelve el informe legible para humanos como un string, sin escribir nada en la consola; SheetForgeActions.RenderReportText(report) es lo mismo en el idioma actual del editor del usuario. Úsalo con AuthoringDispatchCallbacks.RenderReport para que una segunda superficie de creación reporte los fallos exactamente con las palabras que usa el producto.
Enumera una pestaña horneada sin conocer su tipo generado — DefinitionDatabase.RecordsUntyped:
foreach (DefinitionDatabase db in myBakedDatabases)
foreach (object record in db.RecordsUntyped) // reflect on the fields you care about
;Esta es la vía sancionada para un segundo baker (un producto distinto que convierte las mismas hojas en sus propios assets). No uses reflexión sobre el campo privado records: hacerlo convierte un nombre de campo en un contrato no declarado que se rompe en silencio el día que el codegen lo renombra. La lista es de solo lectura — la hoja es canónica. Su valor predeterminado es vacío en el código generado escrito antes de que este miembro existiera; una reimportación emite el override.
Extiende las clases generadas de forma segura — ambas clases generadas son partial, así que un miembro derivado (una propiedad calculada, una implementación de interfaz, un operador) puede vivir en tu propio archivo junto a ellas y sobrevivir a cada reimportación. No añadas campos serializados ahí: el ScriptableObject horneado se reconstruye desde la hoja en cada importación, así que un campo que solo serializa tu parte vuelve a su valor predeterminado. Si un valor pertenece a los datos, pertenece a una columna.
Lo que permanece cerrado — a propósito
Las superficies de arriba son el borde exterior sancionado. Lo siguiente permanece internal sin importar lo conveniente que parecería abrirlo, porque cada uno es un límite de confianza o integridad, no un límite de conveniencia:
- Credenciales y firma — el localizador de la clave de cuenta de servicio, las primitivas JWT/PEM/PKCS8 y el proveedor de token de acceso de Google. Abrirlos le entregaría a cualquier plugin un token portador con alcance sobre tu hoja de cálculo.
- La cadena de push en bruto (el ejecutor de push, las pasarelas de hoja, las escrituras de celda) — la aprobación (
IPushApprover) se impone dentro de esa orquestación; un escritor en bruto público sería una escritura de hoja sin ningún paso de aprobación. - La verificación previa al envío y los motores de escritura de reflejo — el código externo entra solo a través de
AuthoringDispatcher.Reflect(), que pasa por comprobaciones de ancla obsoleta, validación de pre-flight y aprobación en el camino; el motor de escritura de abajo no es un contrato. - La cadena de integridad de bake/codegen (huellas de esquema, escritor de código fuente generado, limpieza de huérfanos) y la barrera de vigencia de build — abrirlos convertiría en una línea de código el falsificar o saltarse el estado del bake.
- La superposición efímera del SO — "la hoja es la fuente de verdad" tiene exactamente una excepción sancionada (el interruptor de edición de prueba del inspector), y deliberadamente no se ofrece como API.
Si un flujo de trabajo parece necesitar una de estas cosas, necesita una solicitud de función, no reflexión.
6. Ubicación del código generado y espacios de nombres
generatedCodeFolderpuede ser cualquier carpeta (el asmdef complementario autorreparable conecta las referencias de tipo de plugin automáticamente), pero colocarla dentro de tu paquete (por ejemplo,Assets/MyDomain/Runtime/Generated) es lo más ordenado — así los tipos generados compilan en el mismo ensamblado que tus enums/tipos personalizados, sin necesitar un asmdef complementario.- Destino por pestaña: una pestaña cuyo tipo generado ya existe en algún lugar se regenera en el mismo lugar — la carpeta
Generatedconfirmada de tu paquete se mantiene como autoridad incluso si los ajustes apuntan a otro lugar. Los duplicados obsoletos se limpian automáticamente (registrado, nunca en silencio). generatedNamespaceaísla tus tipos generados (por ejemplo,MyGame.Data). El descubrimiento de tipos usa el marcador intrínsecoSchemaFingerprintde los tipos generados, no el espacio de nombres, así que cualquier espacio de nombres funciona. Cambiar el valor activa automáticamente la regeneración.- Si confirmas (commit) la carpeta
Generatedde tu paquete es una decisión de política de tu paquete. El ejemplo confirma la suya (clasesExample*en el espacio de nombres predeterminadoSheetForge.Generated, pestañasExampleSkills/ExampleEffects/ExampleActions) para que un clon recién hecho compile de inmediato — y el prefijo de nombre de claseExample*, no un espacio de nombres separado, es lo que evita que choquen con las pestañas realesSkills/Effectsde tu proyecto.
7. Consumo en tiempo de ejecución — "ensambla, no programes"
Tu runtime lee las bases de datos generadas y despacha según el enum type hacia átomos de código:
using SheetForge.Runtime;
using SheetForge.Generated;
var hSkills = SheetForgeDatabases.LoadAsync<ExampleSkillsDatabase>("ExampleSkills");
var hActions = SheetForgeDatabases.LoadAsync<ExampleActionsDatabase>("ExampleActions");
var hEffects = SheetForgeDatabases.LoadAsync<ExampleEffectsDatabase>("ExampleEffects");
var runner = new SkillRunner(await hSkills.Task, await hActions.Task, await hEffects.Task);
// keep the handles for the system's lifetime; Release each on shutdownPara lógica verdaderamente procedural y puntual, haz referencia a un asset de script mediante AssetRef — SheetForge valida la referencia y hace bake del addressable (exactamente igual que una imagen); ejecutarlo es responsabilidad del juego.
8. Detectar SheetForge desde otro asset
Un asset distinto — uno que se integra con SheetForge en lugar de extenderlo (un sistema de estadísticas, por ejemplo) — puede detectar que SheetForge está instalado. Como un producto de pago del Asset Store es un producto de carpeta (sin package.json / UPM), no puede distribuir una entrada versionDefines; en su lugar, el ensamblado Editor de SheetForge autorregistra un símbolo de definición de scripting SHEETFORGE en cada build target.
(a) En tiempo de compilación (preferido):
- Si tu integración vive en su propia definición de ensamblado, añade
SHEETFORGEa las Define Constraints de ese asmdef — el ensamblado entonces solo compila cuando SheetForge está presente. - Si el código que toca SheetForge comparte ensamblado con código que debe compilar de todos modos, protege solo esas partes con
#if SHEETFORGE … #endif.
(b) En tiempo de Editor (alternativa): cuando no puedas depender del orden de compilación, sondea mediante reflexión — por ejemplo, System.Type.GetType("SheetForge.Editor.Pipeline.ImportEvents, SheetForge.Editor") != null — y luego conecta (por ejemplo) el bus de finalización de forma dinámica.
SHEETFORGE significa "SheetForge está instalado". Es independiente de SHEETFORGE_ADDRESSABLES, un version-define interno en los propios ensamblados de SheetForge que solo indica si el paquete Addressables está presente — no uses este último como sonda de instalación.
El define persiste si más adelante se elimina SheetForge (no hay ningún watcher que lo desactive); elimínalo a mano en Project Settings ▸ Player. Consulta Capacidades y límites.
Qué todavía necesita modificaciones al Core
Todo lo anterior se suma con cero modificaciones al Core. Lo que un plugin todavía no puede hacer sin cambios en el Core:
- Emitir valores de marcador en el código generado — los marcadores personalizados son metadatos de validación/visualización; incorporarlos como constantes o atributos del codegen queda fuera de alcance hasta que algún consumidor lo necesite.
- Hacer que la propagación de renombrado de clave llegue dentro de una notación personalizada sin que se le diga cómo — un registro renombrado se reescribe en celdas
RecordId@Tab, listas de ellas, y elementos de wrapper por cuenta propia del Core. Para tu propia gramática, implementaIReferencingCellType(§4.4a) y se reescribe con la carga útil preservada; eso es una adhesión opcional, no una modificación al Core. Rechaza la adhesión opcional y el límite se mantiene: tu validador de dominio reporta la clave colgante en lugar de que el renombrado la arregle en silencio. - Añadir miembros a un enum de C# registrado por plugin desde una hoja — un enum registrado con
enums.Register<T>()es propiedad del código, así que una hoja de definición de enum no puede extenderlo y Data Studio no ofrece la fila. Mueve el enum a una hoja de enum si la hoja debería ser su propietaria (consulta Sintaxis de la hoja).
El tipo wrapper <> (ICellWrapperType, ver §2) y el marcador estructural personalizado (IStructuralMarkerDefinition, ver §4.5) extienden ambos la canalización sin modificaciones al Core.
Páginas relacionadas
- Sintaxis de la hoja — cómo aparecen los tipos registrados en las hojas
- Data Studio — dónde aparecen los overrides de lienzo, los registros de código, los widgets y las acciones
- Referencia de la API — la firma completa de cada contrato
- Núcleo de creación — las aristas y la superficie del motor
- Capacidades y límites — los límites de extensión de plugins (reglas de rechazo de wrapper, límites de marcador) y las costuras reservadas