From b9b4679f886f4e387e1627879ddc992d9963505e Mon Sep 17 00:00:00 2001 From: hongyu Date: Wed, 29 Jul 2026 11:37:52 +0100 Subject: [PATCH 01/16] DEV: justfile --- packages/imandrax-tools/justfile | 4 ++++ packages/imandrax-tools/widget-js/justfile | 3 +++ 2 files changed, 7 insertions(+) create mode 100644 packages/imandrax-tools/justfile diff --git a/packages/imandrax-tools/justfile b/packages/imandrax-tools/justfile new file mode 100644 index 00000000..d1eb92f3 --- /dev/null +++ b/packages/imandrax-tools/justfile @@ -0,0 +1,4 @@ +default: + just --list + +mod js "widget-js/justfile" diff --git a/packages/imandrax-tools/widget-js/justfile b/packages/imandrax-tools/widget-js/justfile index e1f22990..d9f477ce 100644 --- a/packages/imandrax-tools/widget-js/justfile +++ b/packages/imandrax-tools/widget-js/justfile @@ -6,3 +6,6 @@ gen-ts-widget-types-from-py: gen-widget-input-fixtures refresh="false": uv run --env-file .env scripts/gen_widget_input_fixtures {{ if refresh == "true" { "--refresh" } else { "" } }} + +npm-run args: + npm run {{ args }} From d04dee3f53d44a5d4ac653fb9bfb002814a5e80b Mon Sep 17 00:00:00 2001 From: hongyu Date: Wed, 29 Jul 2026 11:38:02 +0100 Subject: [PATCH 02/16] symlink skill to same-name dir --- packages/codelogician-skill/codelogician | 1 + 1 file changed, 1 insertion(+) create mode 120000 packages/codelogician-skill/codelogician diff --git a/packages/codelogician-skill/codelogician b/packages/codelogician-skill/codelogician new file mode 120000 index 00000000..ecb26f69 --- /dev/null +++ b/packages/codelogician-skill/codelogician @@ -0,0 +1 @@ +skill/ \ No newline at end of file From 7cfe61f977553e366775de2d7930a27bbf86b6da Mon Sep 17 00:00:00 2001 From: hongyu Date: Wed, 29 Jul 2026 11:55:10 +0100 Subject: [PATCH 03/16] upstream yaml-str printer --- .../src/imandrax_api_models/yaml_utils.py | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/imandrax-api-models/src/imandrax_api_models/yaml_utils.py b/packages/imandrax-api-models/src/imandrax_api_models/yaml_utils.py index b496fcea..d5099970 100644 --- a/packages/imandrax-api-models/src/imandrax_api_models/yaml_utils.py +++ b/packages/imandrax-api-models/src/imandrax_api_models/yaml_utils.py @@ -1,8 +1,9 @@ # pyright: reportUnknownMemberType=false, reportUnknownVariableType=false from enum import Enum -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any +import yaml from pydantic import BaseModel if TYPE_CHECKING: @@ -59,3 +60,28 @@ def basemodel_representer(dumper: Dumper, data: BaseModel): ImandraXAPIModelDumper.add_representer(str, str_representer) ImandraXAPIModelDumper.add_multi_representer(Enum, enum_representer) ImandraXAPIModelDumper.add_multi_representer(BaseModel, basemodel_representer) + + +# ==================== + + +class _YDumper(Dumper): + pass + + +# Merge representers +# Multiple inheritance resolve representer to the first parent, so we do it manually. +_YDumper.yaml_representers = {**ImandraXAPIModelDumper.yaml_representers} +_YDumper.yaml_multi_representers = {**ImandraXAPIModelDumper.yaml_multi_representers} +# Emit tuples as plain sequences instead of `!!python/tuple`. +_YDumper.add_representer( + tuple, + lambda dumper, data: dumper.represent_sequence('tag:yaml.org,2002:seq', list(data)), +) + + +def to_yaml_str(v: Any) -> str: + if isinstance(v, str): + return v + + return yaml.dump(v, Dumper=_YDumper, sort_keys=False, allow_unicode=True) From 517486b4b04788c476543612a4c6179c14f11446 Mon Sep 17 00:00:00 2001 From: hongyu Date: Wed, 29 Jul 2026 12:29:34 +0100 Subject: [PATCH 04/16] TasksRepr.is_nil --- .../imandrax-api-models/src/imandrax_api_models/artifacts.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/imandrax-api-models/src/imandrax_api_models/artifacts.py b/packages/imandrax-api-models/src/imandrax_api_models/artifacts.py index db12d18f..f3ac7f6b 100644 --- a/packages/imandrax-api-models/src/imandrax_api_models/artifacts.py +++ b/packages/imandrax-api-models/src/imandrax_api_models/artifacts.py @@ -67,6 +67,10 @@ class TasksRepr(BaseModel): tasks: list[TaskEntry] other: JSONObject = Field(default_factory=dict) + @property + def is_nil(self) -> bool: + return len(self.tasks) == 0 + def to_json(self, skip_task_without_artifacts: bool = False) -> JSONObject: res: JSONObject = {} for task in self.tasks: From 29ab6885dd847a88b1e4eec7268058a9f4e4b08a Mon Sep 17 00:00:00 2001 From: hongyu Date: Wed, 29 Jul 2026 12:29:44 +0100 Subject: [PATCH 05/16] yaml-to-str extra kwargs --- .../imandrax-api-models/src/imandrax_api_models/yaml_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/imandrax-api-models/src/imandrax_api_models/yaml_utils.py b/packages/imandrax-api-models/src/imandrax_api_models/yaml_utils.py index d5099970..52b4f071 100644 --- a/packages/imandrax-api-models/src/imandrax_api_models/yaml_utils.py +++ b/packages/imandrax-api-models/src/imandrax_api_models/yaml_utils.py @@ -80,8 +80,8 @@ class _YDumper(Dumper): ) -def to_yaml_str(v: Any) -> str: +def to_yaml_str(v: Any, **kwargs: Any) -> str: if isinstance(v, str): return v - return yaml.dump(v, Dumper=_YDumper, sort_keys=False, allow_unicode=True) + return yaml.dump(v, Dumper=_YDumper, sort_keys=False, allow_unicode=True, **kwargs) From e7df99d407b95ee50812d21c86d5242e8b2bf967 Mon Sep 17 00:00:00 2001 From: hongyu Date: Wed, 29 Jul 2026 12:30:16 +0100 Subject: [PATCH 06/16] Jsonable widget --- .../src/imandrax_tools/widget/__init__.py | 3 +- .../src/imandrax_tools/widget/widgets.py | 39 ++++++++++++++++++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/packages/imandrax-tools/src/imandrax_tools/widget/__init__.py b/packages/imandrax-tools/src/imandrax_tools/widget/__init__.py index 7f8883bb..4b1b9a54 100644 --- a/packages/imandrax-tools/src/imandrax_tools/widget/__init__.py +++ b/packages/imandrax-tools/src/imandrax_tools/widget/__init__.py @@ -6,12 +6,13 @@ from .embed import render_anywidget from .nb_hooks import register_widgets -from .widgets import IDFWidget, RegionDecompWidget, TasksWidget +from .widgets import IDFWidget, JsonableWidget, RegionDecompWidget, TasksWidget __all__ = ( 'TasksWidget', 'RegionDecompWidget', 'IDFWidget', + 'JsonableWidget', 'register_widgets', 'render_anywidget', ) diff --git a/packages/imandrax-tools/src/imandrax_tools/widget/widgets.py b/packages/imandrax-tools/src/imandrax_tools/widget/widgets.py index 6f4a6dcc..24144076 100644 --- a/packages/imandrax-tools/src/imandrax_tools/widget/widgets.py +++ b/packages/imandrax-tools/src/imandrax_tools/widget/widgets.py @@ -19,8 +19,14 @@ from imandrax_api_models import DecomposeRes from imandrax_api_models.artifacts import TasksRepr, artifact_reprs_of_tasks from imandrax_api_models.client import ImandraXAsyncClient, ImandraXClient -from imandrax_api_models.context_utils import string_of_model as xapi_to_string +from imandrax_api_models.context_utils import ( + FormattableModel, + JSONValue, + jsonable_of_model, + string_of_model as xapi_to_string, +) from imandrax_api_models.region_decomp import DecomposeRes_, EnrichedDecomposeRes +from imandrax_api_models.yaml_utils import to_yaml_str from imandrax_tools.idf.viz_view import View as IDFView from imandrax_tools.widget_types import HasTasks @@ -28,6 +34,37 @@ _DIST = Path(__file__).parent / 'static' +class JsonableWidget(anywidget.AnyWidget): + """ + Collapsible, syntax-highlighted view of any JSON-able value, shown as YAML. + + The general-purpose widget: anything that can be reduced to a `JSONValue` + (`context_utils.jsonable_of_model`, a `model_dump(mode='json')`, a plain + dict) can be displayed. + + Python-side owns the formatting: the value is rendered by a yaml dumper + (e.g. `yaml_utils.to_yaml_str`) + + Current behavior: The frontend recovers the fold structure from indentation. + """ + + _esm = _DIST / 'jsonable.js' + + label = traitlets.Unicode('').tag(sync=True) + yaml_str = traitlets.Unicode().tag(sync=True) + + @classmethod + def from_json_value(cls, v: JSONValue, label: str = '') -> Self: + return cls(label=label, yaml_str=to_yaml_str(v)) + + @classmethod + def from_api_model(cls, model: FormattableModel, label: str = '') -> Self: + return cls(label=label, yaml_str=to_yaml_str(jsonable_of_model(model))) + + def _repr_mimebundle_(self, **kwargs: Any) -> Any: + return anywidget.AnyWidget._repr_mimebundle_(self, **kwargs) + + class TasksWidget(anywidget.AnyWidget): """Collapsible view of pretty-printed artifacts for a list of tasks.""" From f0f63d0553bd7d93510f1bc8e21e9e3501216a50 Mon Sep 17 00:00:00 2001 From: hongyu Date: Wed, 29 Jul 2026 12:30:24 +0100 Subject: [PATCH 07/16] js: Jsonable widget --- packages/imandrax-tools/widget-js/README.md | 3 + .../imandrax-tools/widget-js/package.json | 4 +- .../widget-js/src/jsonable/fold.ts | 143 +++++++++++ .../widget-js/src/jsonable/highlight.ts | 111 ++++++++ .../widget-js/src/jsonable/index.ts | 20 ++ .../widget-js/src/jsonable/style.ts | 65 +++++ .../widget-js/src/jsonable/view.ts | 141 ++++++++++ .../widget-js/test/jsonable.test.js | 240 ++++++++++++++++++ 8 files changed, 726 insertions(+), 1 deletion(-) create mode 100644 packages/imandrax-tools/widget-js/src/jsonable/fold.ts create mode 100644 packages/imandrax-tools/widget-js/src/jsonable/highlight.ts create mode 100644 packages/imandrax-tools/widget-js/src/jsonable/index.ts create mode 100644 packages/imandrax-tools/widget-js/src/jsonable/style.ts create mode 100644 packages/imandrax-tools/widget-js/src/jsonable/view.ts create mode 100644 packages/imandrax-tools/widget-js/test/jsonable.test.js diff --git a/packages/imandrax-tools/widget-js/README.md b/packages/imandrax-tools/widget-js/README.md index 4bc49033..52f7f724 100644 --- a/packages/imandrax-tools/widget-js/README.md +++ b/packages/imandrax-tools/widget-js/README.md @@ -9,6 +9,9 @@ attaches to a result type's `_repr_mimebundle_`. Widgets: - `TasksWidget`: `EvalRes`, `CodeSnippetEvalResult` - `RegionDecompWidget`: `EnrichedDecomposeRes` / `DecomposeRes` +- `JsonableWidget`: any `JSONValue` (the general-purpose fallback) — Python renders + it with `yaml_utils.to_yaml_str` and syncs the resulting *string*; the front end + recovers the fold structure from indentation. ## Development diff --git a/packages/imandrax-tools/widget-js/package.json b/packages/imandrax-tools/widget-js/package.json index 0bd35f89..79dfacbf 100644 --- a/packages/imandrax-tools/widget-js/package.json +++ b/packages/imandrax-tools/widget-js/package.json @@ -5,13 +5,15 @@ "type": "module", "private": true, "scripts": { - "build": "npm run build:treemap && npm run build:task && npm run build:idf", + "build": "npm run build:treemap && npm run build:task && npm run build:idf && npm run build:jsonable", "build:treemap": "esbuild src/region_decomp/index.ts --bundle --format=esm --minify --outfile=../src/imandrax_tools/widget/static/region_decomp.js", "build:task": "esbuild src/task/index.ts --bundle --format=esm --minify --outfile=../src/imandrax_tools/widget/static/task.js", "build:idf": "esbuild src/idf/index.ts --bundle --format=esm --minify --outfile=../src/imandrax_tools/widget/static/idf.js", + "build:jsonable": "esbuild src/jsonable/index.ts --bundle --format=esm --minify --outfile=../src/imandrax_tools/widget/static/jsonable.js", "dev": "esbuild src/region_decomp/index.ts --bundle --format=esm --watch --outfile=../src/imandrax_tools/widget/static/region_decomp.js", "dev:task": "esbuild src/task/index.ts --bundle --format=esm --watch --outfile=../src/imandrax_tools/widget/static/task.js", "dev:idf": "esbuild src/idf/index.ts --bundle --format=esm --watch --outfile=../src/imandrax_tools/widget/static/idf.js", + "dev:jsonable": "esbuild src/jsonable/index.ts --bundle --format=esm --watch --outfile=../src/imandrax_tools/widget/static/jsonable.js", "build:gallery": "node scripts/gallery/build_gallery.mjs", "typecheck": "tsc --noEmit", "test": "vitest run", diff --git a/packages/imandrax-tools/widget-js/src/jsonable/fold.ts b/packages/imandrax-tools/widget-js/src/jsonable/fold.ts new file mode 100644 index 00000000..b5860740 --- /dev/null +++ b/packages/imandrax-tools/widget-js/src/jsonable/fold.ts @@ -0,0 +1,143 @@ +// Fold structure for a YAML document, derived from indentation alone. +// +// The Python side is the only YAML formatter (`yaml_utils.to_yaml_str`), so the +// widget receives *text*, not a tree. Rather than parse YAML, we recover the +// nesting the way an editor's code folding does: a line owns every following +// line indented past it. That is exactly the structure a reader wants to +// collapse, and it stays correct for constructs a real parser would be needed +// for (anchors, tags, flow collections) because they never break the indent +// invariant. +// +// Block scalars (`key: |`) are the one construct handled specially: their body +// is arbitrary text (ImandraX proof output, source snippets), so it is captured +// verbatim and never highlighted or folded internally. + +export interface YamlNode { + /** The source line, verbatim — leading indentation included. */ + text: string; + /** Number of leading spaces. */ + indent: number; + /** Lines nested under this one. */ + children: YamlNode[]; + /** + * Body of a block scalar (`|`, `>`) opened by this line, verbatim. Empty for + * every other line. Rendered as raw text, never tokenized. + */ + block: string[]; +} + +const BLANK = /^\s*$/; +// One or more `- ` sequence markers at the head of a line. A sequence item's +// siblings hang off the *content* column (after the dash), not the dash column. +const DASHES = /^(?:-(?:\s+|$))+/; +// A mapping key with no inline value (`region_groups:`), i.e. one whose value is +// the block that follows. PyYAML writes such a key's sequence items at the key's +// own indentation, so those items need to nest without being indented past it. +const EMPTY_KEY = /:[ \t]*(?:#.*)?$/; +// A block-scalar header: `|`/`>` with optional chomping (`-`/`+`) and explicit +// indentation indicator, standing alone as the line's value (so `re: a|b` and +// `filter: x > 0` are not mistaken for one). A trailing comment is legal there. +const BLOCK_HEADER = /(?:^|[:-])[ \t]*[|>][+-]?\d{0,2}[ \t]*(?:#.*)?$/; + +function indentOf(line: string): number { + return /^ */.exec(line)![0].length; +} + +function dashLength(line: string, indent: number): number { + return DASHES.exec(line.slice(indent))?.[0].length ?? 0; +} + +/** Column at which a line's children must start to be nested under it. */ +function childIndent(line: string, indent: number): number { + return indent + Math.max(1, dashLength(line, indent)); +} + +/** + * Column at which a *sequence item* may nest under this line, which is the + * line's own column for a value-less mapping key (`key:` followed by `- x` at the + * same indent — PyYAML's default). `Infinity` when no such exception applies: + * a sequence item under a sequence item must be indented, else it is a sibling. + */ +function seqChildIndent(line: string, indent: number): number { + const isKey = dashLength(line, indent) === 0 && EMPTY_KEY.test(line); + return isKey ? indent : Infinity; +} + +function isBlockHeader(line: string): boolean { + return BLOCK_HEADER.test(line); +} + +function node(text: string): YamlNode { + return { text, indent: indentOf(text), children: [], block: [] }; +} + +/** + * Split `yaml` into a forest of fold nodes. + * + * Trailing blank lines are dropped; interior blank lines are kept as leaves at + * whatever level is open, so round-tripping the rendered text reproduces the + * input. + */ +export function foldYaml(yaml: string): YamlNode[] { + const lines = yaml.replace(/\n+$/, '').split('\n'); + const roots: YamlNode[] = []; + // Open ancestors, outermost first, paired with the columns their children need + // (`seqIndent` is the looser column a sequence item may use — see above). + const stack: { node: YamlNode; childIndent: number; seqIndent: number }[] = []; + + const push = (n: YamlNode, childCol: number, seqCol: number) => { + const parent = stack.length ? stack[stack.length - 1].node : null; + (parent ? parent.children : roots).push(n); + stack.push({ node: n, childIndent: childCol, seqIndent: seqCol }); + }; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Blank lines carry no indentation information — keep them where we are. + if (BLANK.test(line)) { + const n = node(line); + if (stack.length) stack[stack.length - 1].node.children.push(n); + else roots.push(n); + continue; + } + + const indent = indentOf(line); + const isSeq = dashLength(line, indent) > 0; + while (stack.length) { + const top = stack[stack.length - 1]; + const need = isSeq ? Math.min(top.childIndent, top.seqIndent) : top.childIndent; + if (indent >= need) break; + stack.pop(); + } + + const n = node(line); + push(n, childIndent(line, indent), seqChildIndent(line, indent)); + + if (!isBlockHeader(line)) continue; + + // Consume the block body: everything indented past the header, plus any + // blank lines inside it. Never tokenized, never folded further. + while (i + 1 < lines.length) { + const next = lines[i + 1]; + if (!BLANK.test(next) && indentOf(next) <= indent) break; + n.block.push(next); + i++; + } + // Blank lines that trail the block belong after it, not inside it: give + // them back to the main loop. + while (n.block.length && BLANK.test(n.block[n.block.length - 1])) { + n.block.pop(); + i--; + } + } + + return roots; +} + +/** Lines a node hides when collapsed: its block body plus all descendants. */ +export function hiddenLineCount(n: YamlNode): number { + let total = n.block.length; + for (const c of n.children) total += 1 + hiddenLineCount(c); + return total; +} diff --git a/packages/imandrax-tools/widget-js/src/jsonable/highlight.ts b/packages/imandrax-tools/widget-js/src/jsonable/highlight.ts new file mode 100644 index 00000000..72984bb4 --- /dev/null +++ b/packages/imandrax-tools/widget-js/src/jsonable/highlight.ts @@ -0,0 +1,111 @@ +// Syntax highlighting for one YAML line, hand-rolled — same trade as +// `task/highlight.ts`: the input grammar is narrow (block-style YAML emitted by +// PyYAML via `yaml_utils.to_yaml_str`, never hand-written), so a few regexes beat +// pulling a highlighting library into the bundle. +// +// Line-wise rather than document-wise, which is what makes the fold structure in +// `fold.ts` usable: every visible line is tokenized independently, and block +// scalar bodies are handed to `highlightBlockLine` instead, which only escapes. +// +// `highlightLine` returns an HTML string of `` tokens; every +// character of the input survives, escaped, so `textContent` of the result is +// byte-for-byte the original line. + +// Leading `- ` sequence markers (`- - a` for nested sequences). +const DASHES = /^(?:-(?:[ \t]+|$))+/; +// `key:` — quoted or plain. Plain keys stop at the first `:`, which is safe +// because PyYAML quotes any key containing one. +const KEY = /^("(?:[^"\\]|\\.)*"|'(?:[^']|'')*'|[^:#\s][^:]*?)(:)([ \t]|$)/; +// A block-scalar indicator standing alone as the value. +const BLOCK_IND = /^[|>][+-]?\d{0,2}$/; +// Anchors, aliases, and tags, which may prefix a value (`&a`, `*a`, `!!str`). +const REF = /^([&*]\S+|!!?\S*)([ \t]+|$)/; +const NUM = /^-?(?:\d[\d_]*(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$|^-?0[xXoObB][0-9a-fA-F_]+$|^[-+]?\.(?:inf|Inf|INF)$|^\.(?:nan|NaN|NAN)$/; +const LIT = /^(?:true|True|TRUE|false|False|FALSE|null|Null|NULL|~)$/; +// A quoted scalar at the head of a value, used to skip over `#` inside quotes. +const LEADING_QUOTED = /^"(?:[^"\\]|\\.)*"|^'(?:[^']|'')*'/; + +export function escapeHtml(s: string): string { + return s.replace(/[&<>]/g, (c) => (c === '&' ? '&' : c === '<' ? '<' : '>')); +} + +function tok(cls: string, text: string): string { + return `${escapeHtml(text)}`; +} + +/** + * Split a value off its trailing comment. + * + * A `#` only starts a comment when preceded by whitespace and outside quotes — + * `msg: 'a # b'` is one string, `x: 1 # note` is a value plus a comment. + */ +function splitComment(v: string): [string, string] { + const lead = /^[ \t]*/.exec(v)![0].length; + // Skip a leading quoted scalar; a plain scalar cannot contain ` #` at all. + const quoted = LEADING_QUOTED.exec(v.slice(lead)); + const from = lead + (quoted ? quoted[0].length : 0); + const at = /(?:^|[ \t])#/.exec(v.slice(from)); + if (!at) return [v, '']; + const cut = from + at.index; // any whitespace before `#` goes with the comment + return [v.slice(0, cut), v.slice(cut)]; +} + +/** Highlight a scalar value (everything after `key:` or a `-`). */ +function value(v: string): string { + const [raw, comment] = splitComment(v); + const lead = /^[ \t]*/.exec(raw)![0]; + let body = raw.slice(lead.length); + let out = lead; + + // An anchor/alias/tag can precede the scalar; emit it and continue. + const ref = REF.exec(body); + if (ref) { + out += tok('ref', ref[1]) + ref[2]; + body = body.slice(ref[0].length); + } + + if (body) { + const cls = BLOCK_IND.test(body) + ? 'block' + : LIT.test(body) + ? 'lit' + : NUM.test(body) + ? 'num' + : 'str'; // quoted and plain scalars alike + out += tok(cls, body); + } + return out + (comment ? tok('comment', comment) : ''); +} + +export function highlightLine(line: string): string { + const indent = /^[ \t]*/.exec(line)![0]; + let rest = line.slice(indent.length); + let out = indent; + + if (!rest) return out; + + // Document markers stand alone. + if (rest === '---' || rest === '...') return out + tok('punct', rest); + + const dashes = DASHES.exec(rest); + if (dashes) { + out += tok('punct', dashes[0]); + rest = rest.slice(dashes[0].length); + } + + if (rest.startsWith('#')) return out + tok('comment', rest); + + const key = KEY.exec(rest); + if (key) { + out += tok('key', key[1]) + tok('punct', ':'); + return out + value(rest.slice(key[1].length + 1)); + } + + // No key: a sequence item's scalar, or a plain-scalar continuation line. + return out + value(rest); +} + +/** Block-scalar body lines are opaque text — escape only, never tokenize. */ +export function highlightBlockLine(line: string): string { + return escapeHtml(line); +} diff --git a/packages/imandrax-tools/widget-js/src/jsonable/index.ts b/packages/imandrax-tools/widget-js/src/jsonable/index.ts new file mode 100644 index 00000000..0f1b6a06 --- /dev/null +++ b/packages/imandrax-tools/widget-js/src/jsonable/index.ts @@ -0,0 +1,20 @@ +// anywidget entry point for the jsonable view: the general-purpose front end for +// any `JSONValue`, which the Python side hands over as a YAML string. A thin +// adapter over the pure `drawJsonable` -- pull the one-directional `yaml_str` / +// `label` traitlets off the model, render, and re-render when either changes. + +import { drawJsonable } from './view'; + +interface Model { + get(key: 'yaml_str' | 'label'): string; + on(event: 'change:yaml_str' | 'change:label', cb: () => void): void; +} + +export default { + render({ model, el }: { model: Model; el: HTMLElement }) { + const rerender = () => drawJsonable(el, model.get('yaml_str'), model.get('label')); + rerender(); + model.on('change:yaml_str', rerender); + model.on('change:label', rerender); + }, +}; diff --git a/packages/imandrax-tools/widget-js/src/jsonable/style.ts b/packages/imandrax-tools/widget-js/src/jsonable/style.ts new file mode 100644 index 00000000..2a0a8077 --- /dev/null +++ b/packages/imandrax-tools/widget-js/src/jsonable/style.ts @@ -0,0 +1,65 @@ +// Scoped styles for the jsonable view. Namespaced under `.imdx-jsonable`, injected +// once per widget root, sharing the task/region-decomposition palette (borders +// #d8dde2, muted #6b727b, code bg #fff on a #fafbfc chrome) so the widgets look +// of a piece. +// +// Layout note: nesting is conveyed by the source line's own leading spaces, not +// by per-level padding — that keeps every column exactly where PyYAML put it. +// The fold arrow therefore lives in a fixed-width gutter present on *every* line +// (empty for leaves), so it shifts all lines equally. + +export const ROOT_CLASS = 'imdx-jsonable'; + +export const JSONABLE_STYLE = ` +.${ROOT_CLASS} { font-family: ui-sans-serif, system-ui, sans-serif; font-size: 12px; + color: #1a1d21; border: 1px solid #d8dde2; border-radius: 6px; overflow: hidden; + background: #fff; box-sizing: border-box; } +.${ROOT_CLASS} *, .${ROOT_CLASS} *::before, .${ROOT_CLASS} *::after { box-sizing: border-box; } + +.${ROOT_CLASS}-bar { display: flex; align-items: center; gap: 8px; padding: 6px 10px; + background: #fafbfc; border-bottom: 1px solid #d8dde2; } +.${ROOT_CLASS}-label { font-weight: 600; letter-spacing: 0.02em; } +.${ROOT_CLASS}-meta { color: #6b727b; font-size: 11px; font-variant-numeric: tabular-nums; } +.${ROOT_CLASS}-actions { margin-left: auto; display: flex; gap: 6px; } +.${ROOT_CLASS}-btn { font: inherit; font-size: 11px; color: #6b727b; background: transparent; + border: 1px solid #d8dde2; border-radius: 4px; padding: 1px 6px; cursor: pointer; } +.${ROOT_CLASS}-btn:hover { color: #1a1d21; border-color: #b7c0c9; } + +.${ROOT_CLASS}-scroll { max-height: 720px; overflow: auto; padding: 8px 0; } +.${ROOT_CLASS}-doc { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; line-height: 1.5; tab-size: 2; } + +.${ROOT_CLASS}-line { display: flex; align-items: baseline; padding: 0 10px 0 4px; } +.${ROOT_CLASS}-line:hover { background: #f4f6f8; } +summary.${ROOT_CLASS}-line { cursor: pointer; user-select: none; list-style: none; } +summary.${ROOT_CLASS}-line::-webkit-details-marker { display: none; } + +/* The fold gutter: same width on foldable and leaf lines, so text stays aligned. */ +.${ROOT_CLASS}-arrow { flex: 0 0 1.1em; color: #9aa1a9; font-size: 9px; line-height: 1.7; + text-align: center; } +summary.${ROOT_CLASS}-line > .${ROOT_CLASS}-arrow::before { content: "\\25B8"; display: inline-block; + transition: transform 0.12s ease; } +details[open] > summary.${ROOT_CLASS}-line > .${ROOT_CLASS}-arrow::before { transform: rotate(90deg); } +summary.${ROOT_CLASS}-line:hover > .${ROOT_CLASS}-arrow { color: #1a1d21; } + +.${ROOT_CLASS}-text { white-space: pre; } +.${ROOT_CLASS}-count { margin-left: 10px; color: #9aa1a9; font-size: 11px; font-style: italic; + font-variant-numeric: tabular-nums; } +details[open] > summary > .${ROOT_CLASS}-count { display: none; } + +/* Block-scalar bodies (\`key: |\`) — opaque text, dimmed and rendered verbatim. */ +.${ROOT_CLASS}-block { margin: 0; padding: 0 10px 0 calc(1.1em + 4px); white-space: pre; + color: #3c4249; } + +/* Token colors (see jsonable/highlight.ts); light palette tuned for the #fff bg. */ +.${ROOT_CLASS}-text .t-key { color: #0550ae; } /* mapping keys */ +.${ROOT_CLASS}-text .t-str { color: #0a7d33; } /* quoted and plain scalars */ +.${ROOT_CLASS}-text .t-num { color: #953800; } /* numbers */ +.${ROOT_CLASS}-text .t-lit { color: #cf222e; } /* true / false / null / ~ */ +.${ROOT_CLASS}-text .t-punct { color: #6b727b; } /* \`-\`, \`:\`, \`---\` */ +.${ROOT_CLASS}-text .t-ref { color: #8250df; } /* anchors / aliases / tags */ +.${ROOT_CLASS}-text .t-block { color: #8250df; } /* \`|\` / \`>\` indicators */ +.${ROOT_CLASS}-text .t-comment { color: #9aa1a9; font-style: italic; } + +.${ROOT_CLASS}-placeholder { color: #9aa1a9; font-style: italic; padding: 10px; } +`; diff --git a/packages/imandrax-tools/widget-js/src/jsonable/view.ts b/packages/imandrax-tools/widget-js/src/jsonable/view.ts new file mode 100644 index 00000000..110e28de --- /dev/null +++ b/packages/imandrax-tools/widget-js/src/jsonable/view.ts @@ -0,0 +1,141 @@ +// The jsonable view: a toolbar plus the YAML document rendered line-by-line, +// where any line with nested content becomes a
the reader can fold. +// +// `drawJsonable(el, yaml, label?)` builds the DOM, wires interaction, and returns +// nothing. Rendering never rewrites the text it is given, so what is shown is +// also what the copy button yields. + +import { foldYaml, hiddenLineCount, type YamlNode } from './fold'; +import { highlightBlockLine, highlightLine } from './highlight'; +import { JSONABLE_STYLE, ROOT_CLASS } from './style'; + +/** How deep to leave folds open initially; deeper levels start collapsed. */ +const OPEN_DEPTH = 3; + +function gutter(): HTMLElement { + const arrow = document.createElement('span'); + arrow.className = `${ROOT_CLASS}-arrow`; + return arrow; +} + +function lineText(html: string): HTMLElement { + const text = document.createElement('span'); + text.className = `${ROOT_CLASS}-text`; + text.innerHTML = html; // tokens are HTML-escaped by highlightLine + return text; +} + +function makeBlock(lines: string[]): HTMLElement { + const pre = document.createElement('div'); + pre.className = `${ROOT_CLASS}-block`; + pre.innerHTML = lines.map(highlightBlockLine).join('\n'); + return pre; +} + +function makeNode(node: YamlNode, depth: number): HTMLElement { + const foldable = node.children.length > 0 || node.block.length > 0; + + if (!foldable) { + const line = document.createElement('div'); + line.className = `${ROOT_CLASS}-line`; + line.append(gutter(), lineText(highlightLine(node.text))); + return line; + } + + const details = document.createElement('details'); + details.className = `${ROOT_CLASS}-fold`; + details.open = depth < OPEN_DEPTH; + + const summary = document.createElement('summary'); + summary.className = `${ROOT_CLASS}-line`; + summary.append(gutter(), lineText(highlightLine(node.text))); + + // Shown only while collapsed (hidden via CSS when open). + const count = document.createElement('span'); + count.className = `${ROOT_CLASS}-count`; + const n = hiddenLineCount(node); + count.textContent = `…${n} line${n === 1 ? '' : 's'}`; + summary.appendChild(count); + details.appendChild(summary); + + if (node.block.length) details.appendChild(makeBlock(node.block)); + for (const child of node.children) details.appendChild(makeNode(child, depth + 1)); + return details; +} + +export function drawJsonable(el: HTMLElement, yaml: string, label = ''): void { + el.innerHTML = ''; + el.classList.add(ROOT_CLASS); + + const style = document.createElement('style'); + style.textContent = JSONABLE_STYLE; + el.appendChild(style); + + if (!yaml || !yaml.trim()) { + const empty = document.createElement('div'); + empty.className = `${ROOT_CLASS}-placeholder`; + empty.textContent = 'Nothing to show.'; + el.appendChild(empty); + return; + } + + const nodes = foldYaml(yaml); + + const doc = document.createElement('div'); + doc.className = `${ROOT_CLASS}-doc`; + for (const node of nodes) doc.appendChild(makeNode(node, 0)); + + const scroll = document.createElement('div'); + scroll.className = `${ROOT_CLASS}-scroll`; + scroll.appendChild(doc); + + el.appendChild(makeBar(doc, yaml, label)); + el.appendChild(scroll); +} + +function makeBar(doc: HTMLElement, yaml: string, label: string): HTMLElement { + const bar = document.createElement('div'); + bar.className = `${ROOT_CLASS}-bar`; + + if (label) { + const name = document.createElement('span'); + name.className = `${ROOT_CLASS}-label`; + name.textContent = label; + bar.appendChild(name); + } + + const meta = document.createElement('span'); + meta.className = `${ROOT_CLASS}-meta`; + const lines = yaml.replace(/\n+$/, '').split('\n').length; + meta.textContent = `${lines.toLocaleString()} line${lines === 1 ? '' : 's'}`; + bar.appendChild(meta); + + const actions = document.createElement('div'); + actions.className = `${ROOT_CLASS}-actions`; + + const button = (text: string, onClick: () => void) => { + const b = document.createElement('button'); + b.className = `${ROOT_CLASS}-btn`; + b.type = 'button'; + b.textContent = text; + b.addEventListener('click', onClick); + actions.appendChild(b); + return b; + }; + + const setAll = (open: boolean) => { + for (const d of doc.querySelectorAll('details')) d.open = open; + }; + button('expand all', () => setAll(true)); + button('collapse all', () => setAll(false)); + + const copy = button('copy', () => { + navigator.clipboard?.writeText(yaml).then(() => { + copy.textContent = 'copied'; + setTimeout(() => (copy.textContent = 'copy'), 1200); + }); + }); + + bar.appendChild(actions); + return bar; +} diff --git a/packages/imandrax-tools/widget-js/test/jsonable.test.js b/packages/imandrax-tools/widget-js/test/jsonable.test.js new file mode 100644 index 00000000..14cff3ff --- /dev/null +++ b/packages/imandrax-tools/widget-js/test/jsonable.test.js @@ -0,0 +1,240 @@ +import { describe, expect, it } from "vitest"; + +import { foldYaml, hiddenLineCount } from "../src/jsonable/fold"; +import { highlightLine } from "../src/jsonable/highlight"; +import { drawJsonable } from "../src/jsonable/view"; + +// The widget input is a single YAML string produced by `yaml_utils.to_yaml_str` +// on the Python side, so the fixtures here are literal YAML rather than +// generated JSON: block style, insertion-ordered keys, literal blocks for +// multi-line strings, and — PyYAML's default — sequence items at the *same* +// indentation as the key they belong to. +const DOC = `region_groups: +- id: 3 + status: Unknown + constraints: + - x > 0 + - y <= 10 +- id: 4 + status: Verified +errors: [] +`; + +// The same document written with sequences indented under their key, which other +// emitters (and hand-written YAML) produce. +const INDENTED_SEQ = `region_groups: + - id: 3 + constraints: + - x > 0 + - id: 4 +errors: [] +`; + +const WITH_BLOCK = `po_res: + proof: | + goal: + x > 0 + qed + count: 2 +`; + +describe("jsonable/fold", () => { + it("nests lines by indentation", () => { + const [groups, errors] = foldYaml(DOC); + expect(groups.text).toBe("region_groups:"); + expect(groups.children.length).toBe(2); // two sequence items + expect(errors.text).toBe("errors: []"); + expect(errors.children).toEqual([]); + }); + + it("nests a sequence written at its key's own indentation", () => { + // PyYAML's default: `- id: 3` sits in column 0 yet belongs to `region_groups:`, + // while `errors:` -- not a sequence item -- is a sibling of that key. + const roots = foldYaml(DOC); + expect(roots.map((n) => n.text)).toEqual(["region_groups:", "errors: []"]); + expect(roots[0].children.map((n) => n.text)).toEqual([ + "- id: 3", + "- id: 4", + ]); + }); + + it("nests a sequence written indented under its key", () => { + const roots = foldYaml(INDENTED_SEQ); + expect(roots.map((n) => n.text)).toEqual(["region_groups:", "errors: []"]); + expect(roots[0].children.map((n) => n.text)).toEqual([ + " - id: 3", + " - id: 4", + ]); + }); + + it("hangs a sequence item's keys off the item, not the dash column", () => { + const [first] = foldYaml(DOC)[0].children; + expect(first.text).toBe("- id: 3"); + // `status:`/`constraints:` sit at the item's content column, so they nest + // under it rather than under `region_groups:`. + expect(first.children.map((c) => c.text.trim())).toEqual([ + "status: Unknown", + "constraints:", + ]); + expect(first.children[1].children.map((c) => c.text.trim())).toEqual([ + "- x > 0", + "- y <= 10", + ]); + }); + + it("captures a block scalar's body verbatim, unparsed", () => { + const proof = foldYaml(WITH_BLOCK)[0].children[0]; + expect(proof.text).toBe(" proof: |"); + expect(proof.block).toEqual([" goal:", " x > 0", " qed"]); + // The body's own indentation does not create fold nodes... + expect(proof.children).toEqual([]); + // ...and the key that follows the block is a sibling of `proof:`. + expect(foldYaml(WITH_BLOCK)[0].children[1].text).toBe(" count: 2"); + }); + + it("counts the lines a fold hides", () => { + expect(hiddenLineCount(foldYaml(DOC)[0])).toBe(7); // 2 items + 5 nested lines + expect(hiddenLineCount(foldYaml(WITH_BLOCK)[0])).toBe(5); // 2 keys + 3 block lines + }); + + it("preserves every line of the input", () => { + const flat = (n) => [n.text, ...n.block, ...n.children.flatMap(flat)]; + for (const doc of [DOC, INDENTED_SEQ, WITH_BLOCK]) { + expect(foldYaml(doc).flatMap(flat).join("\n")).toBe(doc.trimEnd()); + } + }); +}); + +describe("jsonable/highlight", () => { + // Token text is what matters; the CSS classes are asserted by kind. + const tokens = (line) => { + const el = document.createElement("div"); + el.innerHTML = highlightLine(line); + return [...el.querySelectorAll("span")].map((s) => [ + s.className, + s.textContent, + ]); + }; + + it("marks keys, punctuation, and scalars", () => { + expect(tokens(" status: Unknown")).toEqual([ + ["t-key", "status"], + ["t-punct", ":"], + ["t-str", "Unknown"], + ]); + }); + + it("distinguishes numbers and literals from strings", () => { + expect(tokens("count: 42").at(-1)).toEqual(["t-num", "42"]); + expect(tokens("count: -1.5e3").at(-1)).toEqual(["t-num", "-1.5e3"]); + expect(tokens("res: null").at(-1)).toEqual(["t-lit", "null"]); + expect(tokens("ok: true").at(-1)).toEqual(["t-lit", "true"]); + expect(tokens("name: 'len_append'").at(-1)).toEqual([ + "t-str", + "'len_append'", + ]); + }); + + it("marks sequence dashes and block indicators", () => { + expect(tokens(" - x > 0")[0][0]).toBe("t-punct"); + expect(tokens(" proof: |").at(-1)).toEqual(["t-block", "|"]); + expect(tokens(" proof: |-").at(-1)).toEqual(["t-block", "|-"]); + }); + + it("treats a trailing # as a comment but not one inside quotes", () => { + expect(tokens("x: 1 # note").at(-1)).toEqual(["t-comment", " # note"]); + expect(tokens("msg: 'a # b'").at(-1)).toEqual(["t-str", "'a # b'"]); + }); + + it("leaves the line's text byte-for-byte intact", () => { + for (const line of DOC.trimEnd().split("\n")) { + const el = document.createElement("div"); + el.innerHTML = highlightLine(line); + expect(el.textContent).toBe(line); + } + }); + + it("escapes HTML in the source text", () => { + const el = document.createElement("div"); + el.innerHTML = highlightLine("expr: & "); + expect(el.textContent).toBe("expr: & "); + expect(el.querySelector("a")).toBeNull(); + }); +}); + +describe("jsonable/view", () => { + const render = (yaml, label) => { + const el = document.createElement("div"); + drawJsonable(el, yaml, label); + return el; + }; + + it("renders the document verbatim", () => { + const doc = render(DOC).querySelector(".imdx-jsonable-doc"); + expect(doc.textContent).toContain("region_groups:"); + // Collapsed-fold hints are the only added text, so strip them out. + for (const c of doc.querySelectorAll(".imdx-jsonable-count")) c.remove(); + expect(doc.textContent).toBe(DOC.trimEnd().split("\n").join("")); + }); + + it("makes lines with nested content foldable and leaves leaves alone", () => { + const el = render(DOC); + const folds = [...el.querySelectorAll(".imdx-jsonable-fold")]; + const summaries = folds.map((f) => f.querySelector("summary").textContent); + expect(summaries[0]).toContain("region_groups:"); + // `errors: []` has nothing nested under it -> a plain line, not a
. + expect(summaries.some((s) => s.includes("errors:"))).toBe(false); + }); + + it("labels a collapsed fold with the number of hidden lines", () => { + const el = render(DOC); + const count = el.querySelector(".imdx-jsonable-count"); + expect(count.textContent).toBe("…7 lines"); + }); + + it("opens the outer levels and collapses deeper ones", () => { + // One fold per level: a: / b: / c: / d:, at depths 0..3. + const deep = "a:\n b:\n c:\n d:\n e: 1\n"; + const open = [...render(deep).querySelectorAll(".imdx-jsonable-fold")].map( + (f) => f.open, + ); + expect(open).toEqual([true, true, true, false]); + }); + + it("expands and collapses everything from the toolbar", () => { + const el = render(DOC); + const btn = (text) => + [...el.querySelectorAll(".imdx-jsonable-btn")].find( + (b) => b.textContent === text, + ); + const folds = [...el.querySelectorAll(".imdx-jsonable-fold")]; + + btn("expand all").click(); + expect(folds.every((f) => f.open)).toBe(true); + btn("collapse all").click(); + expect(folds.some((f) => f.open)).toBe(false); + }); + + it("shows the label and line count in the toolbar", () => { + const el = render(DOC, "verify result"); + expect(el.querySelector(".imdx-jsonable-label").textContent).toBe( + "verify result", + ); + expect(el.querySelector(".imdx-jsonable-meta").textContent).toBe("9 lines"); + }); + + it("renders a block scalar's body as one unhighlighted chunk", () => { + const block = render(WITH_BLOCK).querySelector(".imdx-jsonable-block"); + expect(block.textContent).toBe(" goal:\n x > 0\n qed"); + expect(block.querySelector("span")).toBeNull(); + }); + + it("tolerates empty input", () => { + for (const empty of ["", "\n", " "]) { + const el = render(empty); + expect(el.querySelector(".imdx-jsonable-placeholder").textContent).toBe( + "Nothing to show.", + ); + } + }); +}); From 7d49ea71db33e3cd509b7dc572a725e759fdd842 Mon Sep 17 00:00:00 2001 From: hongyu Date: Wed, 29 Jul 2026 12:36:49 +0100 Subject: [PATCH 08/16] js/gallery: add jsonable widget --- .../scripts/gallery/gallery_entry.js | 4 ++ .../gen_widget_input_fixtures/__main__.py | 32 +++++++++- .../widget-js/test/jsonable.test.js | 63 +++++++++++++++++++ 3 files changed, 97 insertions(+), 2 deletions(-) diff --git a/packages/imandrax-tools/widget-js/scripts/gallery/gallery_entry.js b/packages/imandrax-tools/widget-js/scripts/gallery/gallery_entry.js index 00d2a605..d8451b75 100644 --- a/packages/imandrax-tools/widget-js/scripts/gallery/gallery_entry.js +++ b/packages/imandrax-tools/widget-js/scripts/gallery/gallery_entry.js @@ -3,6 +3,7 @@ // hovers, zoom) when opened directly from the filesystem — no server needed. import { drawGraph } from "../../src/idf/graph"; +import { drawJsonable } from "../../src/jsonable/view"; import { drawTreemap } from "../../src/region_decomp/treemap"; import { drawTasks } from "../../src/task/view"; @@ -17,6 +18,9 @@ const TABS = [ { type: "decomp", label: "Region Decomposition", draw: drawTreemap }, { type: "idf", label: "IDF", draw: drawGraph }, { type: "tasks", label: "Tasks", draw: drawTasks }, + // `jsonable` fixtures are a YAML string rather than an object -- `drawJsonable` + // takes it as-is, same as the traitlet the Python side syncs. + { type: "jsonable", label: "Jsonable", draw: drawJsonable }, ]; const wrap = document.querySelector(".wrap"); diff --git a/packages/imandrax-tools/widget-js/scripts/gen_widget_input_fixtures/__main__.py b/packages/imandrax-tools/widget-js/scripts/gen_widget_input_fixtures/__main__.py index 27531451..6158fe0b 100644 --- a/packages/imandrax-tools/widget-js/scripts/gen_widget_input_fixtures/__main__.py +++ b/packages/imandrax-tools/widget-js/scripts/gen_widget_input_fixtures/__main__.py @@ -4,8 +4,10 @@ Each fixture is the *exact widget input* -- the list the Python side syncs to the frontend traitlet, not the raw API response: -- decomp -> `EnrichedDecomposeRes.region_group_views()` (the `data` traitlet) -- tasks -> `collect_tasks_artifacts(eval_res.tasks, c)` (the `task_entries` traitlet) +- decomp -> `EnrichedDecomposeRes.region_group_views()` (the `data` traitlet) +- tasks -> `collect_tasks_artifacts(eval_res.tasks, c)` (the `task_entries` traitlet) +- jsonable -> `to_yaml_str()` (the `yaml_str` traitlet), derived + offline from the fixtures above -- no API call of its own Flag: --refresh: force re-calling the API and regenerating the fixture @@ -24,6 +26,7 @@ from imandrax_api_models.artifacts import artifact_reprs_of_tasks from imandrax_api_models.client import ImandraXClient, get_imandrax_async_client from imandrax_api_models.region_decomp import EnrichedDecomposeRes +from imandrax_api_models.yaml_utils import to_yaml_str from imandrax_tools.idf.iter_decomp import Step, iter_decomp from imandrax_tools.idf.viz_view import View @@ -37,6 +40,15 @@ # Which of those to emit widget fixtures for (name -> fixture stem `idf.`). IDF_FIXTURES = ['addx', 'choose', 'xy_template'] +# `JsonableWidget` accepts any `JSONValue`, so its fixtures are derived from the +# widget inputs generated above rather than from a fresh API call: read one back +# and run it through the same dumper the widget uses. Values are stems of files in +# OUT_DIR; keys are the `jsonable.` fixture stem. +JSONABLE_FIXTURES = { + 'tasks': 'tasks.admit_rec.iml', + 'decomp': 'decomp.simple.classify.iml', +} + # ==================== # Fixture sources are named `...iml` # @@ -133,6 +145,22 @@ def main() -> None: out_path.write_text(json.dumps(widget_input, indent=2)) print(f'[idf.{name}] wrote {out_path.relative_to(PKG_JSON_DIR)}') + # Jsonable fixtures: a single YAML *string*, derived from the fixtures above. + for name, source_stem in JSONABLE_FIXTURES.items(): + out_path = OUT_DIR / f'jsonable.{name}.iml.widget_input.json' + src_path = OUT_DIR / f'{source_stem}.widget_input.json' + if not src_path.exists(): + print(f'[jsonable.{name}] skipped: {source_stem} not generated yet') + continue + if not (refresh or not out_path.exists()): + print( + f'[jsonable.{name}] using cached {out_path.relative_to(PKG_JSON_DIR)}' + ) + continue + yaml_str = to_yaml_str(json.loads(src_path.read_text())) + out_path.write_text(json.dumps(yaml_str, indent=2)) + print(f'[jsonable.{name}] wrote {out_path.relative_to(PKG_JSON_DIR)}') + if __name__ == '__main__': main() diff --git a/packages/imandrax-tools/widget-js/test/jsonable.test.js b/packages/imandrax-tools/widget-js/test/jsonable.test.js index 14cff3ff..cf3ca9ce 100644 --- a/packages/imandrax-tools/widget-js/test/jsonable.test.js +++ b/packages/imandrax-tools/widget-js/test/jsonable.test.js @@ -1,3 +1,6 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + import { describe, expect, it } from "vitest"; import { foldYaml, hiddenLineCount } from "../src/jsonable/fold"; @@ -238,3 +241,63 @@ describe("jsonable/view", () => { } }); }); + +// The fixtures are the exact `yaml_str` traitlet -- real `to_yaml_str` output over +// real API data, generated by scripts/gen_widget_input_fixtures. They are what +// keeps the hand-rolled fold/highlight honest about what PyYAML actually emits. +function loadFixture(name) { + // vitest runs with the package dir as cwd. + const path = resolve( + process.cwd(), + `test/fixtures/inputs/${name}.widget_input.json`, + ); + return JSON.parse(readFileSync(path, "utf8")); +} + +describe("jsonable/view on real to_yaml_str output", () => { + const TASKS = loadFixture("jsonable.tasks.iml"); // task entries, with a `|-` block + const DECOMP = loadFixture("jsonable.decomp.iml"); // deeply nested region views + + it("takes the traitlet as a plain string", () => { + expect(typeof TASKS).toBe("string"); + expect(typeof DECOMP).toBe("string"); + }); + + it("shows every line of the dumper's output", () => { + for (const yaml of [TASKS, DECOMP]) { + const el = document.createElement("div"); + drawJsonable(el, yaml); + const doc = el.querySelector(".imdx-jsonable-doc"); + for (const c of doc.querySelectorAll(".imdx-jsonable-count")) c.remove(); + // Line divs concatenate without separators; block bodies keep their \n. + expect(doc.textContent.replace(/\n/g, "")).toBe( + yaml.trimEnd().split("\n").join(""), + ); + } + }); + + it("keeps a multi-line artifact repr in one block, not folded per line", () => { + const el = document.createElement("div"); + drawJsonable(el, TASKS); + const block = el.querySelector(".imdx-jsonable-block"); + // `repr: |-` opens the literal block the str representer produced. + expect(block.previousElementSibling.textContent).toContain("repr: |-"); + expect(block.textContent).toContain("PORes("); + // Its indented body is opaque text: no nested folds, no tokens. + expect(block.querySelector("details")).toBeNull(); + expect(block.querySelector("span")).toBeNull(); + }); + + it("nests the region tree, whose sequences sit at their key's indent", () => { + const el = document.createElement("div"); + drawJsonable(el, DECOMP); + const top = el.querySelector(".imdx-jsonable-fold"); + expect(top.querySelector("summary").textContent).toContain("- constraints:"); + // `children:` nests under the region item rather than becoming its sibling. + expect( + [...top.querySelectorAll(".imdx-jsonable-fold")].some((f) => + f.querySelector("summary").textContent.includes("children:"), + ), + ).toBe(true); + }); +}); From f8e8fd7ac4da0f1d5c43ae617090a8225a8ead6e Mon Sep 17 00:00:00 2001 From: hongyu Date: Wed, 29 Jul 2026 16:22:00 +0100 Subject: [PATCH 09/16] widget: add pre, post slots for failed case --- .../src/imandrax_tools/widget/widgets.py | 66 +++++++++++-------- 1 file changed, 38 insertions(+), 28 deletions(-) diff --git a/packages/imandrax-tools/src/imandrax_tools/widget/widgets.py b/packages/imandrax-tools/src/imandrax_tools/widget/widgets.py index 24144076..b13b3e5d 100644 --- a/packages/imandrax-tools/src/imandrax_tools/widget/widgets.py +++ b/packages/imandrax-tools/src/imandrax_tools/widget/widgets.py @@ -4,10 +4,11 @@ Widgets are backed by JS bundles under `widget/static`: -Each widget also overrides `_repr_mimebundle_` to fall back to a `text/plain` -pretty-print when there is nothing to render (no tasks / decomposition errors). +Some widgets carry optional `pre` / `post` YAML +panels (rendered by the same front end as `JsonableWidget`). + +`IDFWidget` still uses the older `_repr_mimebundle_` text fallback. """ -# TODO: have a more systematic way to handle failed case (api resp) from __future__ import annotations @@ -23,7 +24,6 @@ FormattableModel, JSONValue, jsonable_of_model, - string_of_model as xapi_to_string, ) from imandrax_api_models.region_decomp import DecomposeRes_, EnrichedDecomposeRes from imandrax_api_models.yaml_utils import to_yaml_str @@ -70,32 +70,37 @@ class TasksWidget(anywidget.AnyWidget): _esm = _DIST / 'task.js' - # Synced to JS (the `task.js` bundle reads `task_entries`). task_entries = traitlets.List(traitlets.Dict()).tag(sync=True) + pre = traitlets.Unicode('').tag(sync=True) + post = traitlets.Unicode('').tag(sync=True) - # Non-JS fallback, not synced b/c a pydantic model is not + # Provenance for introspection, not synced b/c a pydantic model is not # JSON-serialisable over the comm, and the front end never reads it. api_resp_with_tasks = traitlets.Any() @classmethod def from_has_tasks( - cls, obj: HasTasks, c: ImandraXClient | ImandraXAsyncClient + cls, + obj: HasTasks, + c: ImandraXClient | ImandraXAsyncClient, + pre: str = '', + post: str = '', ) -> Self: entries = artifact_reprs_of_tasks(obj.tasks, c) return cls( task_entries=[e.model_dump(mode='json') for e in entries], + pre=pre, + post=post, api_resp_with_tasks=obj, ) @classmethod - def from_tasks_repr(cls, obj: TasksRepr) -> Self: - return cls(task_entries=[e.model_dump(mode='json') for e in obj.tasks]) - - def _repr_mimebundle_(self, **kwargs: Any) -> Any: - if len(self.task_entries) == 0: - return {'text/plain': xapi_to_string(self.api_resp_with_tasks)} - else: - return anywidget.AnyWidget._repr_mimebundle_(self, **kwargs) + def from_tasks_repr(cls, obj: TasksRepr, pre: str = '', post: str = '') -> Self: + return cls( + task_entries=[e.model_dump(mode='json') for e in obj.tasks], + pre=pre, + post=post, + ) class RegionDecompWidget(anywidget.AnyWidget): @@ -103,14 +108,20 @@ class RegionDecompWidget(anywidget.AnyWidget): _esm = _DIST / 'region_decomp.js' - # Synced to JS (the `region_decomp.js` bundle reads `data`) data = traitlets.List().tag(sync=True) + pre = traitlets.Unicode('').tag(sync=True) + post = traitlets.Unicode('').tag(sync=True) - # Non-JS fallback + # Provenance for introspection; not synced (see `TasksWidget`). decomp_res = traitlets.Any() @classmethod - def from_decomp_res(cls, decomp_res: EnrichedDecomposeRes | DecomposeRes) -> Self: + def from_decomp_res( + cls, + decomp_res: EnrichedDecomposeRes | DecomposeRes, + pre: str = '', + post: str = '', + ) -> Self: enriched = ( decomp_res if isinstance(decomp_res, EnrichedDecomposeRes) @@ -118,26 +129,25 @@ def from_decomp_res(cls, decomp_res: EnrichedDecomposeRes | DecomposeRes) -> Sel ) return cls( data=[v.model_dump(mode='json') for v in enriched.region_group_views()], + pre=pre, + post=post, decomp_res=enriched, ) @classmethod - def from_decomp_res_(cls, decomp_res: DecomposeRes_) -> Self: + def from_decomp_res_( + cls, decomp_res: DecomposeRes_, pre: str = '', post: str = '' + ) -> Self: region_group_views = decomp_res.artifact - if not isinstance(region_group_views, list): + if region_group_views is not None and not isinstance(region_group_views, list): raise ValueError('Regions are not parsed') return cls( - data=[r.model_dump(mode='json') for r in region_group_views], + data=[r.model_dump(mode='json') for r in region_group_views or []], + pre=pre, + post=post, decomp_res=decomp_res, ) - def _repr_mimebundle_(self, **kwargs: Any) -> Any: - if self.decomp_res.errors: - return {'text/plain': xapi_to_string(self.decomp_res)} - else: - # Only resolve to JS if there are no errors. - return anywidget.AnyWidget._repr_mimebundle_(self, **kwargs) - class IDFWidget(anywidget.AnyWidget): """Two-panel graph of an iterative-decomposition (IDF) region tree.""" From e9e24f86f8de2cd7d16904a09563266b09fa6dac Mon Sep 17 00:00:00 2001 From: hongyu Date: Wed, 29 Jul 2026 16:51:57 +0100 Subject: [PATCH 10/16] nullable widget core data --- .../src/imandrax_tools/widget/nb_hooks.py | 28 ++++++++++- .../src/imandrax_tools/widget/widgets.py | 46 +++++++++++-------- 2 files changed, 52 insertions(+), 22 deletions(-) diff --git a/packages/imandrax-tools/src/imandrax_tools/widget/nb_hooks.py b/packages/imandrax-tools/src/imandrax_tools/widget/nb_hooks.py index 2282840e..78950fff 100644 --- a/packages/imandrax-tools/src/imandrax_tools/widget/nb_hooks.py +++ b/packages/imandrax-tools/src/imandrax_tools/widget/nb_hooks.py @@ -2,11 +2,13 @@ from __future__ import annotations -from typing import Any +from typing import Any, cast from imandrax_api_models import CodeSnippetEvalResult, DecomposeRes, EvalRes from imandrax_api_models.client import ImandraXAsyncClient, ImandraXClient +from imandrax_api_models.context_utils import FormattableModel, jsonable_of_model from imandrax_api_models.region_decomp import EnrichedDecomposeRes +from imandrax_api_models.yaml_utils import to_yaml_str from imandrax_tools.idf.viz_view import View as IDFView from imandrax_tools.widget_types import HasTasks @@ -16,9 +18,18 @@ _client: ImandraXClient | ImandraXAsyncClient | None = None +def _yaml_of(model: FormattableModel) -> str: + """The whole result as YAML, for a widget's `pre` panel.""" + return to_yaml_str(jsonable_of_model(model)) + + def register_tasks_widget(c: ImandraXClient | ImandraXAsyncClient) -> None: """ Make `EvalRes` and `CodeSnippetEvalResult` render as a `TasksWidget`. + + With no tasks to show -- an eval that errored out -- the whole result goes + into the widget's `pre` panel as YAML, so the failure stays visible instead + of rendering as an empty tasks view. """ global _client _client = c @@ -26,7 +37,13 @@ def register_tasks_widget(c: ImandraXClient | ImandraXAsyncClient) -> None: def repr_mimebundle(self: HasTasks, **kwargs: Any) -> Any: assert _client is not None widget = TasksWidget.from_has_tasks(self, _client) - # Delegate to the widget's own hook so its text fallback still applies. + # Keyed off the entries rather than `self.tasks`: a task whose artifacts + # were all excluded yields no entry, and an empty panel either way. The + # panel is dropped (not left to say "No tasks.") because the YAML now in + # `pre` reports the task list along with everything else. + if not widget.task_entries: + widget.task_entries = None + widget.pre = _yaml_of(cast(FormattableModel, self)) return widget._repr_mimebundle_(**kwargs) setattr(EvalRes, '_repr_mimebundle_', repr_mimebundle) @@ -36,12 +53,19 @@ def repr_mimebundle(self: HasTasks, **kwargs: Any) -> Any: def register_region_decomp_widget() -> None: """ Make `EnrichedDecomposeRes` / `DecomposeRes` render as a `RegionDecompWidget`. + + An errored decomposition has no region groups to lay out, so the whole result + goes into the widget's `pre` panel as YAML -- what the `text/plain` fallback + used to cover. """ def repr_mimebundle( self: DecomposeRes | EnrichedDecomposeRes, **kwargs: Any ) -> Any: widget = RegionDecompWidget.from_decomp_res(self) + if not widget.data: + widget.data = None + widget.pre = _yaml_of(self) return widget._repr_mimebundle_(**kwargs) setattr(EnrichedDecomposeRes, '_repr_mimebundle_', repr_mimebundle) diff --git a/packages/imandrax-tools/src/imandrax_tools/widget/widgets.py b/packages/imandrax-tools/src/imandrax_tools/widget/widgets.py index b13b3e5d..766d64cb 100644 --- a/packages/imandrax-tools/src/imandrax_tools/widget/widgets.py +++ b/packages/imandrax-tools/src/imandrax_tools/widget/widgets.py @@ -17,7 +17,7 @@ import anywidget import traitlets -from imandrax_api_models import DecomposeRes +from imandrax_api_models import Art, DecomposeRes from imandrax_api_models.artifacts import TasksRepr, artifact_reprs_of_tasks from imandrax_api_models.client import ImandraXAsyncClient, ImandraXClient from imandrax_api_models.context_utils import ( @@ -70,14 +70,12 @@ class TasksWidget(anywidget.AnyWidget): _esm = _DIST / 'task.js' - task_entries = traitlets.List(traitlets.Dict()).tag(sync=True) + task_entries = traitlets.List( + traitlets.Dict(), allow_none=True, default_value=None + ).tag(sync=True) pre = traitlets.Unicode('').tag(sync=True) post = traitlets.Unicode('').tag(sync=True) - # Provenance for introspection, not synced b/c a pydantic model is not - # JSON-serialisable over the comm, and the front end never reads it. - api_resp_with_tasks = traitlets.Any() - @classmethod def from_has_tasks( cls, @@ -91,7 +89,6 @@ def from_has_tasks( task_entries=[e.model_dump(mode='json') for e in entries], pre=pre, post=post, - api_resp_with_tasks=obj, ) @classmethod @@ -108,13 +105,10 @@ class RegionDecompWidget(anywidget.AnyWidget): _esm = _DIST / 'region_decomp.js' - data = traitlets.List().tag(sync=True) + data = traitlets.List(allow_none=True, default_value=None).tag(sync=True) pre = traitlets.Unicode('').tag(sync=True) post = traitlets.Unicode('').tag(sync=True) - # Provenance for introspection; not synced (see `TasksWidget`). - decomp_res = traitlets.Any() - @classmethod def from_decomp_res( cls, @@ -131,27 +125,39 @@ def from_decomp_res( data=[v.model_dump(mode='json') for v in enriched.region_group_views()], pre=pre, post=post, - decomp_res=enriched, ) @classmethod def from_decomp_res_( cls, decomp_res: DecomposeRes_, pre: str = '', post: str = '' ) -> Self: + """ + _ + + Raises: + TypeError + If the artifact is not a list of region group views. + + """ region_group_views = decomp_res.artifact - if region_group_views is not None and not isinstance(region_group_views, list): - raise ValueError('Regions are not parsed') - return cls( - data=[r.model_dump(mode='json') for r in region_group_views or []], - pre=pre, - post=post, - decomp_res=decomp_res, - ) + match region_group_views: + case None: + raise TypeError('Artifact is None') + case Art(): + raise TypeError('Regions are not parsed from artifact') + case _: + return cls( + data=[r.model_dump(mode='json') for r in region_group_views], + pre=pre, + post=post, + ) class IDFWidget(anywidget.AnyWidget): """Two-panel graph of an iterative-decomposition (IDF) region tree.""" + # TODO: add the same pre and post slots. remove view + _esm = _DIST / 'idf.js' # Synced to JS (the `idf.js` bundle reads `data` -- a serialized `View`). From c654263414f626fac76ea8d82008c6275d5bb605 Mon Sep 17 00:00:00 2001 From: hongyu Date: Thu, 30 Jul 2026 10:51:49 +0100 Subject: [PATCH 11/16] CHORE --- .../imandrax_tools/widget/static/jsonable.js | 56 +++++++ .../widget/static/region_decomp.js | 150 ++++++++++++------ .../src/imandrax_tools/widget/static/task.js | 116 ++++++++++---- .../src/imandrax_tools/widget/widgets.py | 4 +- .../widget-js/src/common/stack.ts | 68 ++++++++ .../widget-js/src/region_decomp/index.ts | 29 +++- .../widget-js/src/region_decomp/treemap.ts | 10 ++ .../widget-js/src/task/index.ts | 30 +++- .../widget-js/test/adapters.test.js | 107 +++++++++++++ .../widget-js/test/stack.test.js | 94 +++++++++++ 10 files changed, 579 insertions(+), 85 deletions(-) create mode 100644 packages/imandrax-tools/src/imandrax_tools/widget/static/jsonable.js create mode 100644 packages/imandrax-tools/widget-js/src/common/stack.ts create mode 100644 packages/imandrax-tools/widget-js/test/adapters.test.js create mode 100644 packages/imandrax-tools/widget-js/test/stack.test.js diff --git a/packages/imandrax-tools/src/imandrax_tools/widget/static/jsonable.js b/packages/imandrax-tools/src/imandrax_tools/widget/static/jsonable.js new file mode 100644 index 00000000..aed6b683 --- /dev/null +++ b/packages/imandrax-tools/src/imandrax_tools/widget/static/jsonable.js @@ -0,0 +1,56 @@ +var g=/^\s*$/,v=/^(?:-(?:\s+|$))+/,H=/:[ \t]*(?:#.*)?$/,Y=/(?:^|[:-])[ \t]*[|>][+-]?\d{0,2}[ \t]*(?:#.*)?$/;function h(e){return/^ */.exec(e)[0].length}function b(e,n){return v.exec(e.slice(n))?.[0].length??0}function I(e,n){return n+Math.max(1,b(e,n))}function _(e,n){return b(e,n)===0&&H.test(e)?n:1/0}function A(e){return Y.test(e)}function E(e){return{text:e,indent:h(e),children:[],block:[]}}function k(e){let n=e.replace(/\n+$/,"").split(` +`),l=[],o=[],s=(r,c,i)=>{let u=o.length?o[o.length-1].node:null;(u?u.children:l).push(r),o.push({node:r,childIndent:c,seqIndent:i})};for(let r=0;r0;for(;o.length;){let a=o[o.length-1],f=u?Math.min(a.childIndent,a.seqIndent):a.childIndent;if(i>=f)break;o.pop()}let d=E(c);if(s(d,I(c,i),_(c,i)),!!A(c)){for(;r+1][+-]?\d{0,2}$/,z=/^([&*]\S+|!!?\S*)([ \t]+|$)/,D=/^-?(?:\d[\d_]*(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$|^-?0[xXoObB][0-9a-fA-F_]+$|^[-+]?\.(?:inf|Inf|INF)$|^\.(?:nan|NaN|NAN)$/,K=/^(?:true|True|TRUE|false|False|FALSE|null|Null|NULL|~)$/,F=/^"(?:[^"\\]|\\.)*"|^'(?:[^']|'')*'/;function N(e){return e.replace(/[&<>]/g,n=>n==="&"?"&":n==="<"?"<":">")}function m(e,n){return`${N(n)}`}function R(e){let n=/^[ \t]*/.exec(e)[0].length,l=F.exec(e.slice(n)),o=n+(l?l[0].length:0),s=/(?:^|[ \t])#/.exec(e.slice(o));if(!s)return[e,""];let r=o+s.index;return[e.slice(0,r),e.slice(r)]}function y(e){let[n,l]=R(e),o=/^[ \t]*/.exec(n)[0],s=n.slice(o.length),r=o,c=z.exec(s);if(c&&(r+=m("ref",c[1])+c[2],s=s.slice(c[0].length)),s){let i=q.test(s)?"block":K.test(s)?"lit":D.test(s)?"num":"str";r+=m(i,s)}return r+(l?m("comment",l):"")}function $(e){let n=/^[ \t]*/.exec(e)[0],l=e.slice(n.length),o=n;if(!l)return o;if(l==="---"||l==="...")return o+m("punct",l);let s=B.exec(l);if(s&&(o+=m("punct",s[0]),l=l.slice(s[0].length)),l.startsWith("#"))return o+m("comment",l);let r=O.exec(l);return r?(o+=m("key",r[1])+m("punct",":"),o+y(l.slice(r[1].length+1))):o+y(l)}function L(e){return N(e)}var t="imdx-jsonable",C=` +.${t} { font-family: ui-sans-serif, system-ui, sans-serif; font-size: 12px; + color: #1a1d21; border: 1px solid #d8dde2; border-radius: 6px; overflow: hidden; + background: #fff; box-sizing: border-box; } +.${t} *, .${t} *::before, .${t} *::after { box-sizing: border-box; } + +.${t}-bar { display: flex; align-items: center; gap: 8px; padding: 6px 10px; + background: #fafbfc; border-bottom: 1px solid #d8dde2; } +.${t}-label { font-weight: 600; letter-spacing: 0.02em; } +.${t}-meta { color: #6b727b; font-size: 11px; font-variant-numeric: tabular-nums; } +.${t}-actions { margin-left: auto; display: flex; gap: 6px; } +.${t}-btn { font: inherit; font-size: 11px; color: #6b727b; background: transparent; + border: 1px solid #d8dde2; border-radius: 4px; padding: 1px 6px; cursor: pointer; } +.${t}-btn:hover { color: #1a1d21; border-color: #b7c0c9; } + +.${t}-scroll { max-height: 720px; overflow: auto; padding: 8px 0; } +.${t}-doc { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; line-height: 1.5; tab-size: 2; } + +.${t}-line { display: flex; align-items: baseline; padding: 0 10px 0 4px; } +.${t}-line:hover { background: #f4f6f8; } +summary.${t}-line { cursor: pointer; user-select: none; list-style: none; } +summary.${t}-line::-webkit-details-marker { display: none; } + +/* The fold gutter: same width on foldable and leaf lines, so text stays aligned. */ +.${t}-arrow { flex: 0 0 1.1em; color: #9aa1a9; font-size: 9px; line-height: 1.7; + text-align: center; } +summary.${t}-line > .${t}-arrow::before { content: "\\25B8"; display: inline-block; + transition: transform 0.12s ease; } +details[open] > summary.${t}-line > .${t}-arrow::before { transform: rotate(90deg); } +summary.${t}-line:hover > .${t}-arrow { color: #1a1d21; } + +.${t}-text { white-space: pre; } +.${t}-count { margin-left: 10px; color: #9aa1a9; font-size: 11px; font-style: italic; + font-variant-numeric: tabular-nums; } +details[open] > summary > .${t}-count { display: none; } + +/* Block-scalar bodies (\`key: |\`) \u2014 opaque text, dimmed and rendered verbatim. */ +.${t}-block { margin: 0; padding: 0 10px 0 calc(1.1em + 4px); white-space: pre; + color: #3c4249; } + +/* Token colors (see jsonable/highlight.ts); light palette tuned for the #fff bg. */ +.${t}-text .t-key { color: #0550ae; } /* mapping keys */ +.${t}-text .t-str { color: #0a7d33; } /* quoted and plain scalars */ +.${t}-text .t-num { color: #953800; } /* numbers */ +.${t}-text .t-lit { color: #cf222e; } /* true / false / null / ~ */ +.${t}-text .t-punct { color: #6b727b; } /* \`-\`, \`:\`, \`---\` */ +.${t}-text .t-ref { color: #8250df; } /* anchors / aliases / tags */ +.${t}-text .t-block { color: #8250df; } /* \`|\` / \`>\` indicators */ +.${t}-text .t-comment { color: #9aa1a9; font-style: italic; } + +.${t}-placeholder { color: #9aa1a9; font-style: italic; padding: 10px; } +`;var J=3;function T(){let e=document.createElement("span");return e.className=`${t}-arrow`,e}function w(e){let n=document.createElement("span");return n.className=`${t}-text`,n.innerHTML=e,n}function U(e){let n=document.createElement("div");return n.className=`${t}-block`,n.innerHTML=e.map(L).join(` +`),n}function M(e,n){if(!(e.children.length>0||e.block.length>0)){let i=document.createElement("div");return i.className=`${t}-line`,i.append(T(),w($(e.text))),i}let o=document.createElement("details");o.className=`${t}-fold`,o.open=n{let p=document.createElement("button");return p.className=`${t}-btn`,p.type="button",p.textContent=a,p.addEventListener("click",f),c.appendChild(p),p},u=a=>{for(let f of e.querySelectorAll("details"))f.open=a};i("expand all",()=>u(!0)),i("collapse all",()=>u(!1));let d=i("copy",()=>{navigator.clipboard?.writeText(n).then(()=>{d.textContent="copied",setTimeout(()=>d.textContent="copy",1200)})});return o.appendChild(c),o}var te={render({model:e,el:n}){let l=()=>S(n,e.get("yaml_str"),e.get("label"));l(),e.on("change:yaml_str",l),e.on("change:label",l)}};export{te as default}; diff --git a/packages/imandrax-tools/src/imandrax_tools/widget/static/region_decomp.js b/packages/imandrax-tools/src/imandrax_tools/widget/static/region_decomp.js index 5a4a5c56..6a8c8416 100644 --- a/packages/imandrax-tools/src/imandrax_tools/widget/static/region_decomp.js +++ b/packages/imandrax-tools/src/imandrax_tools/widget/static/region_decomp.js @@ -1,67 +1,127 @@ -function be(t){var e=0,n=t.children,o=n&&n.length;if(!o)e=1;else for(;--o>=0;)e+=n[o].value;t.value=e}function lt(){return this.eachAfter(be)}function st(t,e){let n=-1;for(let o of this)t.call(e,o,++n,this);return this}function ct(t,e){for(var n=this,o=[n],r,i,a=-1;n=o.pop();)if(t.call(e,n,++a,this),r=n.children)for(i=r.length-1;i>=0;--i)o.push(r[i]);return this}function ut(t,e){for(var n=this,o=[n],r=[],i,a,l,c=-1;n=o.pop();)if(r.push(n),i=n.children)for(a=0,l=i.length;a=0;)n+=o[r].value;e.value=n})}function ht(t){return this.eachBefore(function(e){e.children&&e.children.sort(t)})}function dt(t){for(var e=this,n=$e(e,t),o=[e];e!==n;)e=e.parent,o.push(e);for(var r=o.length;t!==n;)o.splice(r,0,t),t=t.parent;return o}function $e(t,e){if(t===e)return t;var n=t.ancestors(),o=e.ancestors(),r=null;for(t=n.pop(),e=o.pop();t===e;)r=t,t=n.pop(),e=o.pop();return r}function mt(){for(var t=this,e=[t];t=t.parent;)e.push(t);return e}function gt(){return Array.from(this)}function xt(){var t=[];return this.eachBefore(function(e){e.children||t.push(e)}),t}function yt(){var t=this,e=[];return t.each(function(n){n!==t&&e.push({source:n.parent,target:n})}),e}function*_t(){var t=this,e,n=[t],o,r,i;do for(e=n.reverse(),n=[];t=e.pop();)if(yield t,o=t.children)for(r=0,i=o.length;r=0;--l)r.push(i=a[l]=new F(a[l])),i.parent=o,i.depth=o.depth+1;return n.eachBefore(Le)}function Ae(){return M(this).eachBefore(Se)}function Ee(t){return t.children}function Re(t){return Array.isArray(t)?t[1]:null}function Se(t){t.data.value!==void 0&&(t.value=t.data.value),t.data=t.data.data}function Le(t){var e=0;do t.height=e;while((t=t.parent)&&t.height<++e)}function F(t){this.data=t,this.depth=this.height=0,this.parent=null}F.prototype=M.prototype={constructor:F,count:lt,each:st,eachAfter:ut,eachBefore:ct,find:ft,sum:pt,sort:ht,path:dt,ancestors:mt,descendants:gt,leaves:xt,links:yt,copy:Ae,[Symbol.iterator]:_t};function vt(t){if(typeof t!="function")throw new Error;return t}function B(){return 0}function D(t){return function(){return t}}function wt(t){t.x0=Math.round(t.x0),t.y0=Math.round(t.y0),t.x1=Math.round(t.x1),t.y1=Math.round(t.y1)}function bt(t,e,n,o,r){for(var i=t.children,a,l=-1,c=i.length,p=t.value&&(o-e)/t.value;++lb&&(b=p),N=x*x*S,R=Math.max(b/N,N/v),R>L){x-=p;break}L=R}a.push(c={value:x,dice:h1?o:1)},n})(Ne);function V(){var t=At,e=!1,n=1,o=1,r=[0],i=B,a=B,l=B,c=B,p=B;function f(s){return s.x0=s.y0=0,s.x1=n,s.y1=o,s.eachBefore(d),r=[0],e&&s.eachBefore(wt),s}function d(s){var h=r[s.depth],g=s.x0+h,y=s.y0+h,x=s.x1-h,v=s.y1-h;x=0&&(e=t.slice(0,n))!=="xmlns"&&(t=t.slice(n+1)),et.hasOwnProperty(e)?{space:et[e],local:t}:t}function Te(t){return function(){var e=this.ownerDocument,n=this.namespaceURI;return n===z&&e.documentElement.namespaceURI===z?e.createElement(t):e.createElementNS(n,t)}}function ke(t){return function(){return this.ownerDocument.createElementNS(t.space,t.local)}}function U(t){var e=G(t);return(e.local?ke:Te)(e)}function Me(){}function W(t){return t==null?Me:function(){return this.querySelector(t)}}function Et(t){typeof t!="function"&&(t=W(t));for(var e=this._groups,n=e.length,o=new Array(n),r=0;r=R&&(R=b+1);!(S=x[R])&&++R=0;)(a=o[r])&&(i&&a.compareDocumentPosition(i)^4&&i.parentNode.insertBefore(a,i),i=a);return this}function Ft(t){t||(t=We);function e(d,s){return d&&s?t(d.__data__,s.__data__):!d-!s}for(var n=this._groups,o=n.length,r=new Array(o),i=0;ie?1:t>=e?0:NaN}function Pt(){var t=arguments[0];return arguments[0]=this,t.apply(null,arguments),this}function qt(){return Array.from(this)}function Vt(){for(var t=this._groups,e=0,n=t.length;e1?this.each((e==null?je:typeof e=="function"?en:tn)(t,e,n??"")):nn(this.node(),t)}function nn(t,e){return t.style.getPropertyValue(e)||X(t).getComputedStyle(t,null).getPropertyValue(e)}function rn(t){return function(){delete this[t]}}function on(t,e){return function(){this[t]=e}}function an(t,e){return function(){var n=e.apply(this,arguments);n==null?delete this[t]:this[t]=n}}function Kt(t,e){return arguments.length>1?this.each((e==null?rn:typeof e=="function"?an:on)(t,e)):this.node()[t]}function Xt(t){return t.trim().split(/^|\s+/)}function rt(t){return t.classList||new Zt(t)}function Zt(t){this._node=t,this._names=Xt(t.getAttribute("class")||"")}Zt.prototype={add:function(t){var e=this._names.indexOf(t);e<0&&(this._names.push(t),this._node.setAttribute("class",this._names.join(" ")))},remove:function(t){var e=this._names.indexOf(t);e>=0&&(this._names.splice(e,1),this._node.setAttribute("class",this._names.join(" ")))},contains:function(t){return this._names.indexOf(t)>=0}};function Jt(t,e){for(var n=rt(t),o=-1,r=e.length;++o=0&&(n=e.slice(o+1),e=e.slice(0,o)),{type:e,name:n}})}function An(t){return function(){var e=this.__on;if(e){for(var n=0,o=-1,r=e.length,i;ne.children&&e.children.length?0:e.weight||1).sort((e,n)=>n.value-e.value||(E(e)][+-]?\d{0,2}[ \t]*(?:#.*)?$/;function it(t){return/^ */.exec(t)[0].length}function st(t,e){return ze.exec(t.slice(e))?.[0].length??0}function Ve(t,e){return e+Math.max(1,st(t,e))}function Ke(t,e){return st(t,e)===0&&Pe.test(t)?e:1/0}function Ge(t){return Ye.test(t)}function gt(t){return{text:t,indent:it(t),children:[],block:[]}}function xt(t){let e=t.replace(/\n+$/,"").split(` +`),n=[],r=[],o=(i,s,a)=>{let c=r.length?r[r.length-1].node:null;(c?c.children:n).push(i),r.push({node:i,childIndent:s,seqIndent:a})};for(let i=0;i0;for(;r.length;){let u=r[r.length-1],h=c?Math.min(u.childIndent,u.seqIndent):u.childIndent;if(a>=h)break;r.pop()}let f=gt(s);if(o(f,Ve(s,a),Ke(s,a)),!!Ge(s)){for(;i+1][+-]?\d{0,2}$/,Xe=/^([&*]\S+|!!?\S*)([ \t]+|$)/,Ze=/^-?(?:\d[\d_]*(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$|^-?0[xXoObB][0-9a-fA-F_]+$|^[-+]?\.(?:inf|Inf|INF)$|^\.(?:nan|NaN|NAN)$/,Qe=/^(?:true|True|TRUE|false|False|FALSE|null|Null|NULL|~)$/,je=/^"(?:[^"\\]|\\.)*"|^'(?:[^']|'')*'/;function bt(t){return t.replace(/[&<>]/g,e=>e==="&"?"&":e==="<"?"<":">")}function H(t,e){return`${bt(e)}`}function tn(t){let e=/^[ \t]*/.exec(t)[0].length,n=je.exec(t.slice(e)),r=e+(n?n[0].length:0),o=/(?:^|[ \t])#/.exec(t.slice(r));if(!o)return[t,""];let i=r+o.index;return[t.slice(0,i),t.slice(i)]}function yt(t){let[e,n]=tn(t),r=/^[ \t]*/.exec(e)[0],o=e.slice(r.length),i=r,s=Xe.exec(o);if(s&&(i+=H("ref",s[1])+s[2],o=o.slice(s[0].length)),o){let a=Je.test(o)?"block":Qe.test(o)?"lit":Ze.test(o)?"num":"str";i+=H(a,o)}return i+(n?H("comment",n):"")}function lt(t){let e=/^[ \t]*/.exec(t)[0],n=t.slice(e.length),r=e;if(!n)return r;if(n==="---"||n==="...")return r+H("punct",n);let o=Ue.exec(n);if(o&&(r+=H("punct",o[0]),n=n.slice(o[0].length)),n.startsWith("#"))return r+H("comment",n);let i=We.exec(n);return i?(r+=H("key",i[1])+H("punct",":"),r+yt(n.slice(i[1].length+1))):r+yt(n)}function vt(t){return bt(t)}var d="imdx-jsonable",_t=` +.${d} { font-family: ui-sans-serif, system-ui, sans-serif; font-size: 12px; + color: #1a1d21; border: 1px solid #d8dde2; border-radius: 6px; overflow: hidden; + background: #fff; box-sizing: border-box; } +.${d} *, .${d} *::before, .${d} *::after { box-sizing: border-box; } + +.${d}-bar { display: flex; align-items: center; gap: 8px; padding: 6px 10px; + background: #fafbfc; border-bottom: 1px solid #d8dde2; } +.${d}-label { font-weight: 600; letter-spacing: 0.02em; } +.${d}-meta { color: #6b727b; font-size: 11px; font-variant-numeric: tabular-nums; } +.${d}-actions { margin-left: auto; display: flex; gap: 6px; } +.${d}-btn { font: inherit; font-size: 11px; color: #6b727b; background: transparent; + border: 1px solid #d8dde2; border-radius: 4px; padding: 1px 6px; cursor: pointer; } +.${d}-btn:hover { color: #1a1d21; border-color: #b7c0c9; } + +.${d}-scroll { max-height: 720px; overflow: auto; padding: 8px 0; } +.${d}-doc { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; line-height: 1.5; tab-size: 2; } + +.${d}-line { display: flex; align-items: baseline; padding: 0 10px 0 4px; } +.${d}-line:hover { background: #f4f6f8; } +summary.${d}-line { cursor: pointer; user-select: none; list-style: none; } +summary.${d}-line::-webkit-details-marker { display: none; } + +/* The fold gutter: same width on foldable and leaf lines, so text stays aligned. */ +.${d}-arrow { flex: 0 0 1.1em; color: #9aa1a9; font-size: 9px; line-height: 1.7; + text-align: center; } +summary.${d}-line > .${d}-arrow::before { content: "\\25B8"; display: inline-block; + transition: transform 0.12s ease; } +details[open] > summary.${d}-line > .${d}-arrow::before { transform: rotate(90deg); } +summary.${d}-line:hover > .${d}-arrow { color: #1a1d21; } + +.${d}-text { white-space: pre; } +.${d}-count { margin-left: 10px; color: #9aa1a9; font-size: 11px; font-style: italic; + font-variant-numeric: tabular-nums; } +details[open] > summary > .${d}-count { display: none; } + +/* Block-scalar bodies (\`key: |\`) \u2014 opaque text, dimmed and rendered verbatim. */ +.${d}-block { margin: 0; padding: 0 10px 0 calc(1.1em + 4px); white-space: pre; + color: #3c4249; } + +/* Token colors (see jsonable/highlight.ts); light palette tuned for the #fff bg. */ +.${d}-text .t-key { color: #0550ae; } /* mapping keys */ +.${d}-text .t-str { color: #0a7d33; } /* quoted and plain scalars */ +.${d}-text .t-num { color: #953800; } /* numbers */ +.${d}-text .t-lit { color: #cf222e; } /* true / false / null / ~ */ +.${d}-text .t-punct { color: #6b727b; } /* \`-\`, \`:\`, \`---\` */ +.${d}-text .t-ref { color: #8250df; } /* anchors / aliases / tags */ +.${d}-text .t-block { color: #8250df; } /* \`|\` / \`>\` indicators */ +.${d}-text .t-comment { color: #9aa1a9; font-style: italic; } + +.${d}-placeholder { color: #9aa1a9; font-style: italic; padding: 10px; } +`;var en=3;function wt(){let t=document.createElement("span");return t.className=`${d}-arrow`,t}function $t(t){let e=document.createElement("span");return e.className=`${d}-text`,e.innerHTML=t,e}function nn(t){let e=document.createElement("div");return e.className=`${d}-block`,e.innerHTML=t.map(vt).join(` +`),e}function Et(t,e){if(!(t.children.length>0||t.block.length>0)){let a=document.createElement("div");return a.className=`${d}-line`,a.append(wt(),$t(lt(t.text))),a}let r=document.createElement("details");r.className=`${d}-fold`,r.open=e{let l=document.createElement("button");return l.className=`${d}-btn`,l.type="button",l.textContent=u,l.addEventListener("click",h),s.appendChild(l),l},c=u=>{for(let h of t.querySelectorAll("details"))h.open=u};a("expand all",()=>c(!0)),a("collapse all",()=>c(!1));let f=a("copy",()=>{navigator.clipboard?.writeText(e).then(()=>{f.textContent="copied",setTimeout(()=>f.textContent="copy",1200)})});return r.appendChild(s),r}var V="imdx-stack",on=` +.${V} { display: flex; flex-direction: column; gap: 8px; box-sizing: border-box; } +.${V}-placeholder { font-family: ui-sans-serif, system-ui, sans-serif; + font-size: 12px; color: #9aa1a9; font-style: italic; padding: 10px; + border: 1px solid #d8dde2; border-radius: 6px; background: #fff; } +`;function Lt(t,e){t.innerHTML="",t.classList.add(V);let n=document.createElement("style");n.textContent=on,t.appendChild(n);let r=()=>{let s=document.createElement("div");return t.appendChild(s),s},o=!!(e.pre&&e.pre.trim()),i=!!(e.post&&e.post.trim());if(o&&ct(r(),e.pre),e.hasMain&&e.main(r()),i&&ct(r(),e.post),!o&&!i&&!e.hasMain){let s=document.createElement("div");s.className=`${V}-placeholder`,s.textContent="Nothing to show.",t.appendChild(s)}}function sn(t){var e=0,n=t.children,r=n&&n.length;if(!r)e=1;else for(;--r>=0;)e+=n[r].value;t.value=e}function Nt(){return this.eachAfter(sn)}function At(t,e){let n=-1;for(let r of this)t.call(e,r,++n,this);return this}function St(t,e){for(var n=this,r=[n],o,i,s=-1;n=r.pop();)if(t.call(e,n,++s,this),o=n.children)for(i=o.length-1;i>=0;--i)r.push(o[i]);return this}function Ct(t,e){for(var n=this,r=[n],o=[],i,s,a,c=-1;n=r.pop();)if(o.push(n),i=n.children)for(s=0,a=i.length;s=0;)n+=r[o].value;e.value=n})}function Tt(t){return this.eachBefore(function(e){e.children&&e.children.sort(t)})}function Mt(t){for(var e=this,n=an(e,t),r=[e];e!==n;)e=e.parent,r.push(e);for(var o=r.length;t!==n;)r.splice(o,0,t),t=t.parent;return r}function an(t,e){if(t===e)return t;var n=t.ancestors(),r=e.ancestors(),o=null;for(t=n.pop(),e=r.pop();t===e;)o=t,t=n.pop(),e=r.pop();return o}function Ht(){for(var t=this,e=[t];t=t.parent;)e.push(t);return e}function It(){return Array.from(this)}function Bt(){var t=[];return this.eachBefore(function(e){e.children||t.push(e)}),t}function Ot(){var t=this,e=[];return t.each(function(n){n!==t&&e.push({source:n.parent,target:n})}),e}function*Dt(){var t=this,e,n=[t],r,o,i;do for(e=n.reverse(),n=[];t=e.pop();)if(yield t,r=t.children)for(o=0,i=r.length;o=0;--a)o.push(i=s[a]=new z(s[a])),i.parent=r,i.depth=r.depth+1;return n.eachBefore(pn)}function ln(){return I(this).eachBefore(fn)}function cn(t){return t.children}function un(t){return Array.isArray(t)?t[1]:null}function fn(t){t.data.value!==void 0&&(t.value=t.data.value),t.data=t.data.data}function pn(t){var e=0;do t.height=e;while((t=t.parent)&&t.height<++e)}function z(t){this.data=t,this.depth=this.height=0,this.parent=null}z.prototype=I.prototype={constructor:z,count:Nt,each:At,eachAfter:Ct,eachBefore:St,find:kt,sum:Rt,sort:Tt,path:Mt,ancestors:Ht,descendants:It,leaves:Bt,links:Ot,copy:ln,[Symbol.iterator]:Dt};function Ft(t){if(typeof t!="function")throw new Error;return t}function D(){return 0}function F(t){return function(){return t}}function qt(t){t.x0=Math.round(t.x0),t.y0=Math.round(t.y0),t.x1=Math.round(t.x1),t.y1=Math.round(t.y1)}function zt(t,e,n,r,o){for(var i=t.children,s,a=-1,c=i.length,f=t.value&&(r-e)/t.value;++a$&&($=f),k=y*y*S,A=Math.max($/k,k/w),A>C){y-=f;break}C=A}s.push(c={value:y,dice:m1?r:1)},n})(dn);function K(){var t=Yt,e=!1,n=1,r=1,o=[0],i=D,s=D,a=D,c=D,f=D;function u(l){return l.x0=l.y0=0,l.x1=n,l.y1=r,l.eachBefore(h),o=[0],e&&l.eachBefore(qt),l}function h(l){var m=o[l.depth],x=l.x0+m,b=l.y0+m,y=l.x1-m,w=l.y1-m;y=0&&(e=t.slice(0,n))!=="xmlns"&&(t=t.slice(n+1)),ut.hasOwnProperty(e)?{space:ut[e],local:t}:t}function mn(t){return function(){var e=this.ownerDocument,n=this.namespaceURI;return n===G&&e.documentElement.namespaceURI===G?e.createElement(t):e.createElementNS(n,t)}}function gn(t){return function(){return this.ownerDocument.createElementNS(t.space,t.local)}}function W(t){var e=U(t);return(e.local?gn:mn)(e)}function xn(){}function J(t){return t==null?xn:function(){return this.querySelector(t)}}function Vt(t){typeof t!="function"&&(t=J(t));for(var e=this._groups,n=e.length,r=new Array(n),o=0;o=A&&(A=$+1);!(S=y[A])&&++A=0;)(s=r[o])&&(i&&s.compareDocumentPosition(i)^4&&i.parentNode.insertBefore(s,i),i=s);return this}function oe(t){t||(t=kn);function e(h,l){return h&&l?t(h.__data__,l.__data__):!h-!l}for(var n=this._groups,r=n.length,o=new Array(r),i=0;ie?1:t>=e?0:NaN}function ie(){var t=arguments[0];return arguments[0]=this,t.apply(null,arguments),this}function se(){return Array.from(this)}function ae(){for(var t=this._groups,e=0,n=t.length;e1?this.each((e==null?On:typeof e=="function"?Fn:Dn)(t,e,n??"")):qn(this.node(),t)}function qn(t,e){return t.style.getPropertyValue(e)||Q(t).getComputedStyle(t,null).getPropertyValue(e)}function zn(t){return function(){delete this[t]}}function Pn(t,e){return function(){this[t]=e}}function Yn(t,e){return function(){var n=e.apply(this,arguments);n==null?delete this[t]:this[t]=n}}function de(t,e){return arguments.length>1?this.each((e==null?zn:typeof e=="function"?Yn:Pn)(t,e)):this.node()[t]}function he(t){return t.trim().split(/^|\s+/)}function pt(t){return t.classList||new me(t)}function me(t){this._node=t,this._names=he(t.getAttribute("class")||"")}me.prototype={add:function(t){var e=this._names.indexOf(t);e<0&&(this._names.push(t),this._node.setAttribute("class",this._names.join(" ")))},remove:function(t){var e=this._names.indexOf(t);e>=0&&(this._names.splice(e,1),this._node.setAttribute("class",this._names.join(" ")))},contains:function(t){return this._names.indexOf(t)>=0}};function ge(t,e){for(var n=pt(t),r=-1,o=e.length;++r=0&&(n=e.slice(r+1),e=e.slice(0,r)),{type:e,name:n}})}function ar(t){return function(){var e=this.__on;if(e){for(var n=0,r=-1,o=e.length,i;ne.children&&e.children.length?0:e.weight||1).sort((e,n)=>n.value-e.value||(N(e)Click a region to see its constraints and example.

`;function xe(t){let e=q(t),n=[];n.push(`

[${T(E(t))}]

`),e&&n.push(`

${T(e)}

`),n.push(`
${kn(t)}
`);let o=t.data.constraints||[];if(o.length){n.push('
Constraint path
');let i=o.map(a=>`${T(a)}`).join("");n.push(`
    ${i}
`)}let r=t.data.region_stat;return r&&(n.push(`
Invariant
${T(r.invariant)}
`),n.push(`
Example input
${T(Mn(r.model))}
`),n.push(`
Example output
${T(r.model_eval)}
`)),n.join("")}function kn(t){let e=(o,r)=>`${o}: ${r}`,n=t.children?t.children.length:0;return[e("leaf regions",t.leaves().length),e("direct children",n),e("descendants",t.descendants().length-1)].filter(Boolean).join("")}function Mn(t){if(t==null)return"(no inputs)";if(typeof t!="object")return String(t);let e=Object.entries(t);return e.length?e.map(([n,o])=>`${n} = ${o}`).join(` -`):"(no inputs)"}function T(t){return t==null?"":String(t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}var Hn={width:720,height:520,detailWidth:300,maxDepth:3},In=!0,Bn=!0,Dn=!0,On=31,ye=2,Fn=40,Pn=15,H=2,ve=16;function qn(t){let e=t.children&&t.children.length?ve:H;return{x0:t.x0+H,y0:t.y0+e,x1:t.x1-H,y1:t.y1-H}}function _e(t,e,n,o){let r=e.x1-e.x0||1,i=e.y1-e.y0||1;return{left:(t.x0-e.x0)/r*n,top:(t.y0-e.y0)/i*o,width:(t.x1-t.x0)/r*n,height:(t.y1-t.y0)/i*o}}function Vn(t,e){let n=[],o=(r,i)=>{if(n.push(r),i{if(r.depth===e){r.children&&r.children.length&&n.push(r);return}if(r.children)for(let i of r.children)o(i)};if(t.children)for(let r of t.children)o(r);return n}function Gn(t,e){let n=M(t.data).sum(r=>r.children&&r.children.length?0:r.weight||1).sort((r,i)=>i.value-r.value||(E(r)({node:r,ghost:!0,box:{left:e.left+r.x0,top:e.top+r.y0,width:r.x1-r.x0,height:r.y1-r.y0}}))}function Q(t){return!!t.children&&t.children.length>0}function Un(t){return t.ghost?`${u}-tile is-ghost`:Q(t.node)?`${u}-tile`:`${u}-tile is-leaf`}function Wn(t){let e=t.leaves().length;return`${e} leaf ${e===1?"region":"regions"}`}function we(t,e,n={}){let o={...Hn,...n},r=he(e);t.innerHTML="",t.classList.add(u),t.style.setProperty("--imdx-h",`${o.height}px`),t.style.setProperty("--imdx-dw",`${o.detailWidth}px`);let i=document.createElement("style");i.textContent=de+me;let a=document.createElement("div");a.className=`${u}-main`;let l=document.createElement("div");l.className=`${u}-topbar`;let c=document.createElement("div");c.className=`${u}-tiles`,a.append(l,c);let p=document.createElement("div");p.className=`${u}-divider`;let f=document.createElement("div");f.className=`${u}-detail`,f.innerHTML=ge,t.append(i,a,p,f);let d=r,s=null,h=Bn,g=0,y=0;function x(){return{vw:c.clientWidth||o.width,vh:c.clientHeight||o.height-On}}function v(w,A){w===g&&A===y||(V().size([w,A]).paddingInner(H).paddingOuter(H).paddingTop(C=>C.children&&C.children.length?ve:0).round(!1)(r),g=w,y=A)}function b(w){d=w,N(),j()}function R(w){s=w,f.innerHTML=xe(w),L()}function L(){c.querySelectorAll(`.${u}-tile`).forEach(w=>w.classList.remove("is-selected")),s&&S&&S.classList.add("is-selected")}let S=null;function N(){let w=d.ancestors().reverse();l.replaceChildren(),w.forEach((I,m)=>{if(m>0){let tt=document.createElement("span");tt.className=`${u}-sep`,tt.textContent="\u203A",l.appendChild(tt)}let $=document.createElement("button");$.type="button";let at=I===d;$.className=`${u}-crumb${at?" -current":""}`,$.textContent=Z(I)?"root":`[${E(I)}]`,at||$.addEventListener("click",()=>b(I)),l.appendChild($)});let A=document.createElement("span");A.className=`${u}-sep`,A.style.marginLeft="auto",A.textContent=Wn(d),l.appendChild(A);let C=document.createElement("label");C.className=`${u}-toggle`;let k=document.createElement("input");k.type="checkbox",k.checked=h,k.addEventListener("change",()=>{h=k.checked,j()});let O=document.createElement("span");O.textContent="leaf counts",C.append(k,O),l.appendChild(C)}function j(){let{vw:w,vh:A}=x();v(w,A);let C=qn(d),k=d.depth+o.maxDepth,O=Vn(d,o.maxDepth).map(m=>({node:m,ghost:!1,box:_e(m,C,w,A)}));for(let m of zn(d,k)){let $=_e(m,C,w,A);O.push(...Gn(m,$))}let I=O.filter(({box:m})=>m.width>=ye&&m.height>=ye);it(c).selectAll(`div.${u}-tile`).data(I,m=>E(m.node)).join("div").attr("class",m=>Un(m)).style("left",m=>`${J(m.box.left)}px`).style("top",m=>`${J(m.box.top)}px`).style("width",m=>`${J(m.box.width)}px`).style("height",m=>`${J(m.box.height)}px`).attr("title",m=>m.ghost?null:Jn(m.node)).html(m=>Xn(m,k,h)).on("click",(m,$)=>{$.ghost||(S=Qn(m),R($.node))}).on("dblclick",(m,$)=>{!$.ghost&&Q($.node)&&b($.node)}).order(),L()}N(),j()}function Yn(t){return t.width>=Fn&&t.height>=Pn}function Kn(t){return t.width>=26&&t.height>=22}function Xn(t,e,n){if(t.ghost)return"";let o=t.node.depth===e&&Q(t.node),r=Dn&&!o&&Q(t.node),i=Yn(t.box)?Zn(t.node,r):"";return n&&o&&Kn(t.box)&&(i+=`${t.node.leaves().length}`),i}function Zn(t,e=!1){let n=e?` (${t.leaves().length})`:"";return`${T(E(t))}${n} ${T(q(t))}`}function Jn(t){let e=q(t);return`[${E(t)}]${e?` ${e}`:""}`}function Qn(t){return t.currentTarget}function J(t){return Math.round(t*100)/100}var ia={render({model:t,el:e}){let n=()=>we(e,t.get("data"));n(),t.on("change:data",n)}};export{ia as default}; +.${p}-toggle input { margin: 0; cursor: pointer; } +`;var Ie=`

Click a region to see its constraints and example.

`;function Be(t){let e=Y(t),n=[];n.push(`

[${T(N(t))}]

`),e&&n.push(`

${T(e)}

`),n.push(`
${mr(t)}
`);let r=t.data.constraints||[];if(r.length){n.push('
Constraint path
');let i=r.map(s=>`${T(s)}`).join("");n.push(`
    ${i}
`)}let o=t.data.region_stat;return o&&(n.push(`
Invariant
${T(o.invariant)}
`),n.push(`
Example input
${T(gr(o.model))}
`),n.push(`
Example output
${T(o.model_eval)}
`)),n.join("")}function mr(t){let e=(r,o)=>`${r}: ${o}`,n=t.children?t.children.length:0;return[e("leaf regions",t.leaves().length),e("direct children",n),e("descendants",t.descendants().length-1)].filter(Boolean).join("")}function gr(t){if(t==null)return"(no inputs)";if(typeof t!="object")return String(t);let e=Object.entries(t);return e.length?e.map(([n,r])=>`${n} = ${r}`).join(` +`):"(no inputs)"}function T(t){return t==null?"":String(t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}var xr={width:720,height:520,detailWidth:300,maxDepth:3},yr=!0,br=!0,vr=!0,_r=31,Oe=2,wr=40,$r=15,B=2,Fe=16;function Er(t){let e=t.children&&t.children.length?Fe:B;return{x0:t.x0+B,y0:t.y0+e,x1:t.x1-B,y1:t.y1-B}}function De(t,e,n,r){let o=e.x1-e.x0||1,i=e.y1-e.y0||1;return{left:(t.x0-e.x0)/o*n,top:(t.y0-e.y0)/i*r,width:(t.x1-t.x0)/o*n,height:(t.y1-t.y0)/i*r}}function Lr(t,e){let n=[],r=(o,i)=>{if(n.push(o),i{if(o.depth===e){o.children&&o.children.length&&n.push(o);return}if(o.children)for(let i of o.children)r(i)};if(t.children)for(let o of t.children)r(o);return n}function Ar(t,e){let n=I(t.data).sum(o=>o.children&&o.children.length?0:o.weight||1).sort((o,i)=>i.value-o.value||(N(o)({node:o,ghost:!0,box:{left:e.left+o.x0,top:e.top+o.y0,width:o.x1-o.x0,height:o.y1-o.y0}}))}function et(t){return!!t.children&&t.children.length>0}function Sr(t){return t.ghost?`${p}-tile is-ghost`:et(t.node)?`${p}-tile`:`${p}-tile is-leaf`}function Cr(t){let e=t.leaves().length;return`${e} leaf ${e===1?"region":"regions"}`}function qe(t,e,n={}){let r={...xr,...n},o=Te(e);t.innerHTML="",t.classList.add(p),t.style.setProperty("--imdx-h",`${r.height}px`),t.style.setProperty("--imdx-dw",`${r.detailWidth}px`);let i=document.createElement("style");i.textContent=Me+He;let s=document.createElement("div");s.className=`${p}-main`;let a=document.createElement("div");a.className=`${p}-topbar`;let c=document.createElement("div");if(c.className=`${p}-tiles`,s.append(a,c),!o.children||o.children.length===0){let _=document.createElement("p");_.className=`${p}-placeholder`,_.textContent="No regions.",c.appendChild(_)}let f=document.createElement("div");f.className=`${p}-divider`;let u=document.createElement("div");u.className=`${p}-detail`,u.innerHTML=Ie,t.append(i,s,f,u);let h=o,l=null,m=br,x=0,b=0;function y(){return{vw:c.clientWidth||r.width,vh:c.clientHeight||r.height-_r}}function w(_,L){_===x&&L===b||(K().size([_,L]).paddingInner(B).paddingOuter(B).paddingTop(R=>R.children&&R.children.length?Fe:0).round(!1)(o),x=_,b=L)}function $(_){h=_,k(),nt()}function A(_){l=_,u.innerHTML=Be(_),C()}function C(){c.querySelectorAll(`.${p}-tile`).forEach(_=>_.classList.remove("is-selected")),l&&S&&S.classList.add("is-selected")}let S=null;function k(){let _=h.ancestors().reverse();a.replaceChildren(),_.forEach((O,g)=>{if(g>0){let rt=document.createElement("span");rt.className=`${p}-sep`,rt.textContent="\u203A",a.appendChild(rt)}let E=document.createElement("button");E.type="button";let mt=O===h;E.className=`${p}-crumb${mt?" -current":""}`,E.textContent=j(O)?"root":`[${N(O)}]`,mt||E.addEventListener("click",()=>$(O)),a.appendChild(E)});let L=document.createElement("span");L.className=`${p}-sep`,L.style.marginLeft="auto",L.textContent=Cr(h),a.appendChild(L);let R=document.createElement("label");R.className=`${p}-toggle`;let M=document.createElement("input");M.type="checkbox",M.checked=m,M.addEventListener("change",()=>{m=M.checked,nt()});let q=document.createElement("span");q.textContent="leaf counts",R.append(M,q),a.appendChild(R)}function nt(){let{vw:_,vh:L}=y();w(_,L);let R=Er(h),M=h.depth+r.maxDepth,q=Lr(h,r.maxDepth).map(g=>({node:g,ghost:!1,box:De(g,R,_,L)}));for(let g of Nr(h,M)){let E=De(g,R,_,L);q.push(...Ar(g,E))}let O=q.filter(({box:g})=>g.width>=Oe&&g.height>=Oe);ht(c).selectAll(`div.${p}-tile`).data(O,g=>N(g.node)).join("div").attr("class",g=>Sr(g)).style("left",g=>`${tt(g.box.left)}px`).style("top",g=>`${tt(g.box.top)}px`).style("width",g=>`${tt(g.box.width)}px`).style("height",g=>`${tt(g.box.height)}px`).attr("title",g=>g.ghost?null:Hr(g.node)).html(g=>Tr(g,M,m)).on("click",(g,E)=>{E.ghost||(S=Ir(g),A(E.node))}).on("dblclick",(g,E)=>{!E.ghost&&et(E.node)&&$(E.node)}).order(),C()}k(),nt()}function kr(t){return t.width>=wr&&t.height>=$r}function Rr(t){return t.width>=26&&t.height>=22}function Tr(t,e,n){if(t.ghost)return"";let r=t.node.depth===e&&et(t.node),o=vr&&!r&&et(t.node),i=kr(t.box)?Mr(t.node,o):"";return n&&r&&Rr(t.box)&&(i+=`${t.node.leaves().length}`),i}function Mr(t,e=!1){let n=e?` (${t.leaves().length})`:"";return`${T(N(t))}${n} ${T(Y(t))}`}function Hr(t){let e=Y(t);return`[${N(t)}]${e?` ${e}`:""}`}function Ir(t){return t.currentTarget}function tt(t){return Math.round(t*100)/100}var Br=["data","pre","post"],js={render({model:t,el:e}){let n=()=>{let r=t.get("data");Lt(e,{pre:t.get("pre"),post:t.get("post"),main:o=>qe(o,r),hasMain:r!=null})};n();for(let r of Br)t.on(`change:${r}`,n)}};export{js as default}; diff --git a/packages/imandrax-tools/src/imandrax_tools/widget/static/task.js b/packages/imandrax-tools/src/imandrax_tools/widget/static/task.js index 34fa99f3..4833012e 100644 --- a/packages/imandrax-tools/src/imandrax_tools/widget/static/task.js +++ b/packages/imandrax-tools/src/imandrax_tools/widget/static/task.js @@ -1,48 +1,108 @@ -var u=new RegExp([/(?'''[\s\S]*?'''|"""[\s\S]*?"""|'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")/,/(?\b(?:None|True|False)\b)/,/(?[A-Za-z_]\w*(?=\())/,/(?[A-Za-z_]\w*(?=\s*=))/,/(?[A-Za-z_]\w*)/,/(?-?\d+(?:\.\d+)?)/].map(t=>t.source).join("|"),"g"),f={str:"t-str",lit:"t-lit",cls:"t-cls",attr:"t-attr",num:"t-num"};function l(t){return t.replace(/[&<>]/g,n=>n==="&"?"&":n==="<"?"<":">")}function g(t){let n="",o=0;for(let r=u.exec(t);r;r=u.exec(t)){n+=l(t.slice(o,r.index));let c=r.groups??{},s=Object.keys(f).find(a=>c[a]!==void 0);n+=s?`${l(r[0])}`:l(r[0]),o=r.index+r[0].length}return n+=l(t.slice(o)),n}var e="imdx-task",x=` -.${e} { display: flex; flex-direction: column; gap: 8px; +var b=/^\s*$/,K=/^(?:-(?:\s+|$))+/,D=/:[ \t]*(?:#.*)?$/,F=/(?:^|[:-])[ \t]*[|>][+-]?\d{0,2}[ \t]*(?:#.*)?$/;function $(e){return/^ */.exec(e)[0].length}function y(e,t){return K.exec(e.slice(t))?.[0].length??0}function P(e,t){return t+Math.max(1,y(e,t))}function q(e,t){return y(e,t)===0&&D.test(e)?t:1/0}function j(e){return F.test(e)}function T(e){return{text:e,indent:$(e),children:[],block:[]}}function L(e){let t=e.replace(/\n+$/,"").split(` +`),o=[],n=[],c=(a,s,l)=>{let m=n.length?n[n.length-1].node:null;(m?m.children:o).push(a),n.push({node:a,childIndent:s,seqIndent:l})};for(let a=0;a0;for(;n.length;){let d=n[n.length-1],g=m?Math.min(d.childIndent,d.seqIndent):d.childIndent;if(l>=g)break;n.pop()}let p=T(s);if(c(p,P(s,l),q(s,l)),!!j(s)){for(;a+1][+-]?\d{0,2}$/,G=/^([&*]\S+|!!?\S*)([ \t]+|$)/,Q=/^-?(?:\d[\d_]*(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$|^-?0[xXoObB][0-9a-fA-F_]+$|^[-+]?\.(?:inf|Inf|INF)$|^\.(?:nan|NaN|NAN)$/,W=/^(?:true|True|TRUE|false|False|FALSE|null|Null|NULL|~)$/,X=/^"(?:[^"\\]|\\.)*"|^'(?:[^']|'')*'/;function w(e){return e.replace(/[&<>]/g,t=>t==="&"?"&":t==="<"?"<":">")}function u(e,t){return`${w(t)}`}function V(e){let t=/^[ \t]*/.exec(e)[0].length,o=X.exec(e.slice(t)),n=t+(o?o[0].length:0),c=/(?:^|[ \t])#/.exec(e.slice(n));if(!c)return[e,""];let a=n+c.index;return[e.slice(0,a),e.slice(a)]}function S(e){let[t,o]=V(e),n=/^[ \t]*/.exec(t)[0],c=t.slice(n.length),a=n,s=G.exec(c);if(s&&(a+=u("ref",s[1])+s[2],c=c.slice(s[0].length)),c){let l=Z.test(c)?"block":W.test(c)?"lit":Q.test(c)?"num":"str";a+=u(l,c)}return a+(o?u("comment",o):"")}function E(e){let t=/^[ \t]*/.exec(e)[0],o=e.slice(t.length),n=t;if(!o)return n;if(o==="---"||o==="...")return n+u("punct",o);let c=J.exec(o);if(c&&(n+=u("punct",c[0]),o=o.slice(c[0].length)),o.startsWith("#"))return n+u("comment",o);let a=U.exec(o);return a?(n+=u("key",a[1])+u("punct",":"),n+S(o.slice(a[1].length+1))):n+S(o)}function M(e){return w(e)}var r="imdx-jsonable",v=` +.${r} { font-family: ui-sans-serif, system-ui, sans-serif; font-size: 12px; + color: #1a1d21; border: 1px solid #d8dde2; border-radius: 6px; overflow: hidden; + background: #fff; box-sizing: border-box; } +.${r} *, .${r} *::before, .${r} *::after { box-sizing: border-box; } + +.${r}-bar { display: flex; align-items: center; gap: 8px; padding: 6px 10px; + background: #fafbfc; border-bottom: 1px solid #d8dde2; } +.${r}-label { font-weight: 600; letter-spacing: 0.02em; } +.${r}-meta { color: #6b727b; font-size: 11px; font-variant-numeric: tabular-nums; } +.${r}-actions { margin-left: auto; display: flex; gap: 6px; } +.${r}-btn { font: inherit; font-size: 11px; color: #6b727b; background: transparent; + border: 1px solid #d8dde2; border-radius: 4px; padding: 1px 6px; cursor: pointer; } +.${r}-btn:hover { color: #1a1d21; border-color: #b7c0c9; } + +.${r}-scroll { max-height: 720px; overflow: auto; padding: 8px 0; } +.${r}-doc { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; line-height: 1.5; tab-size: 2; } + +.${r}-line { display: flex; align-items: baseline; padding: 0 10px 0 4px; } +.${r}-line:hover { background: #f4f6f8; } +summary.${r}-line { cursor: pointer; user-select: none; list-style: none; } +summary.${r}-line::-webkit-details-marker { display: none; } + +/* The fold gutter: same width on foldable and leaf lines, so text stays aligned. */ +.${r}-arrow { flex: 0 0 1.1em; color: #9aa1a9; font-size: 9px; line-height: 1.7; + text-align: center; } +summary.${r}-line > .${r}-arrow::before { content: "\\25B8"; display: inline-block; + transition: transform 0.12s ease; } +details[open] > summary.${r}-line > .${r}-arrow::before { transform: rotate(90deg); } +summary.${r}-line:hover > .${r}-arrow { color: #1a1d21; } + +.${r}-text { white-space: pre; } +.${r}-count { margin-left: 10px; color: #9aa1a9; font-size: 11px; font-style: italic; + font-variant-numeric: tabular-nums; } +details[open] > summary > .${r}-count { display: none; } + +/* Block-scalar bodies (\`key: |\`) \u2014 opaque text, dimmed and rendered verbatim. */ +.${r}-block { margin: 0; padding: 0 10px 0 calc(1.1em + 4px); white-space: pre; + color: #3c4249; } + +/* Token colors (see jsonable/highlight.ts); light palette tuned for the #fff bg. */ +.${r}-text .t-key { color: #0550ae; } /* mapping keys */ +.${r}-text .t-str { color: #0a7d33; } /* quoted and plain scalars */ +.${r}-text .t-num { color: #953800; } /* numbers */ +.${r}-text .t-lit { color: #cf222e; } /* true / false / null / ~ */ +.${r}-text .t-punct { color: #6b727b; } /* \`-\`, \`:\`, \`---\` */ +.${r}-text .t-ref { color: #8250df; } /* anchors / aliases / tags */ +.${r}-text .t-block { color: #8250df; } /* \`|\` / \`>\` indicators */ +.${r}-text .t-comment { color: #9aa1a9; font-style: italic; } + +.${r}-placeholder { color: #9aa1a9; font-style: italic; padding: 10px; } +`;var ee=3;function _(){let e=document.createElement("span");return e.className=`${r}-arrow`,e}function A(e){let t=document.createElement("span");return t.className=`${r}-text`,t.innerHTML=e,t}function te(e){let t=document.createElement("div");return t.className=`${r}-block`,t.innerHTML=e.map(M).join(` +`),t}function H(e,t){if(!(e.children.length>0||e.block.length>0)){let l=document.createElement("div");return l.className=`${r}-line`,l.append(_(),A(E(e.text))),l}let n=document.createElement("details");n.className=`${r}-fold`,n.open=t{let f=document.createElement("button");return f.className=`${r}-btn`,f.type="button",f.textContent=d,f.addEventListener("click",g),s.appendChild(f),f},m=d=>{for(let g of e.querySelectorAll("details"))g.open=d};l("expand all",()=>m(!0)),l("collapse all",()=>m(!1));let p=l("copy",()=>{navigator.clipboard?.writeText(t).then(()=>{p.textContent="copied",setTimeout(()=>p.textContent="copy",1200)})});return n.appendChild(s),n}var h="imdx-stack",oe=` +.${h} { display: flex; flex-direction: column; gap: 8px; box-sizing: border-box; } +.${h}-placeholder { font-family: ui-sans-serif, system-ui, sans-serif; + font-size: 12px; color: #9aa1a9; font-style: italic; padding: 10px; + border: 1px solid #d8dde2; border-radius: 6px; background: #fff; } +`;function z(e,t){e.innerHTML="",e.classList.add(h);let o=document.createElement("style");o.textContent=oe,e.appendChild(o);let n=()=>{let s=document.createElement("div");return e.appendChild(s),s},c=!!(t.pre&&t.pre.trim()),a=!!(t.post&&t.post.trim());if(c&&C(n(),t.pre),t.hasMain&&t.main(n()),a&&C(n(),t.post),!c&&!a&&!t.hasMain){let s=document.createElement("div");s.className=`${h}-placeholder`,s.textContent="Nothing to show.",e.appendChild(s)}}var O=new RegExp([/(?'''[\s\S]*?'''|"""[\s\S]*?"""|'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")/,/(?\b(?:None|True|False)\b)/,/(?[A-Za-z_]\w*(?=\())/,/(?[A-Za-z_]\w*(?=\s*=))/,/(?[A-Za-z_]\w*)/,/(?-?\d+(?:\.\d+)?)/].map(e=>e.source).join("|"),"g"),Y={str:"t-str",lit:"t-lit",cls:"t-cls",attr:"t-attr",num:"t-num"};function x(e){return e.replace(/[&<>]/g,t=>t==="&"?"&":t==="<"?"<":">")}function I(e){let t="",o=0;for(let n=O.exec(e);n;n=O.exec(e)){t+=x(e.slice(o,n.index));let c=n.groups??{},a=Object.keys(Y).find(s=>c[s]!==void 0);t+=a?`${x(n[0])}`:x(n[0]),o=n.index+n[0].length}return t+=x(e.slice(o)),t}var i="imdx-task",B=` +.${i} { display: flex; flex-direction: column; gap: 8px; font-family: ui-sans-serif, system-ui, sans-serif; font-size: 12px; color: #1a1d21; box-sizing: border-box; } -.${e} *, .${e} *::before, .${e} *::after { box-sizing: border-box; } +.${i} *, .${i} *::before, .${i} *::after { box-sizing: border-box; } -.${e}-task, .${e}-art { border: 1px solid #d8dde2; border-radius: 6px; +.${i}-task, .${i}-art { border: 1px solid #d8dde2; border-radius: 6px; overflow: hidden; } -.${e}-task { background: #fafbfc; } +.${i}-task { background: #fafbfc; } -.${e}-summary { display: flex; align-items: center; gap: 8px; padding: 6px 10px; +.${i}-summary { display: flex; align-items: center; gap: 8px; padding: 6px 10px; cursor: pointer; user-select: none; list-style: none; } -.${e}-summary::-webkit-details-marker { display: none; } -.${e}-summary::before { content: "\\25B8"; color: #6b727b; font-size: 10px; +.${i}-summary::-webkit-details-marker { display: none; } +.${i}-summary::before { content: "\\25B8"; color: #6b727b; font-size: 10px; transition: transform 0.12s ease; } -details[open] > .${e}-summary::before { transform: rotate(90deg); } +details[open] > .${i}-summary::before { transform: rotate(90deg); } -.${e}-kind { font-weight: 600; letter-spacing: 0.02em; } -.${e}-id { color: #6b727b; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +.${i}-kind { font-weight: 600; letter-spacing: 0.02em; } +.${i}-id { color: #6b727b; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; } -.${e}-meta { margin-left: auto; color: #6b727b; font-size: 11px; +.${i}-meta { margin-left: auto; color: #6b727b; font-size: 11px; font-variant-numeric: tabular-nums; } -.${e}-body { padding: 8px; display: flex; flex-direction: column; gap: 8px; } -.${e}-art { background: #fff; } -.${e}-art-icon { font-size: 12px; line-height: 1; } -.${e}-art-kind { font-weight: 600; color: #1a1d21; +.${i}-body { padding: 8px; display: flex; flex-direction: column; gap: 8px; } +.${i}-art { background: #fff; } +.${i}-art-icon { font-size: 12px; line-height: 1; } +.${i}-art-kind { font-weight: 600; color: #1a1d21; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } -.${e}-copy { margin-left: auto; font: inherit; font-size: 11px; color: #6b727b; +.${i}-copy { margin-left: auto; font: inherit; font-size: 11px; color: #6b727b; background: transparent; border: 1px solid #d8dde2; border-radius: 4px; padding: 1px 6px; cursor: pointer; } -.${e}-copy:hover { color: #1a1d21; border-color: #b7c0c9; } +.${i}-copy:hover { color: #1a1d21; border-color: #b7c0c9; } -.${e}-scroll { max-height: 720px; overflow: auto; border-top: 1px solid #d8dde2; } -.${e}-pre { margin: 0; padding: 10px; white-space: pre; tab-size: 2; font-size: 12px; +.${i}-scroll { max-height: 720px; overflow: auto; border-top: 1px solid #d8dde2; } +.${i}-pre { margin: 0; padding: 10px; white-space: pre; tab-size: 2; font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } /* Syntax highlighting for the Python-repr artifact text (see task/highlight.ts). Light palette tuned for the #fff code bg. */ -.${e}-pre .t-cls { color: #8250df; } /* constructor / class names */ -.${e}-pre .t-attr { color: #0550ae; } /* keyword-arg names */ -.${e}-pre .t-str { color: #0a7d33; } /* string literals */ -.${e}-pre .t-num { color: #953800; } /* numbers */ -.${e}-pre .t-lit { color: #cf222e; } /* None / True / False */ - -.${e}-placeholder { color: #9aa1a9; font-style: italic; padding: 8px; } -`;var m={success:"\u2705",error:"\u274C",warning:"\u26A0\uFE0F",info:"\u2139\uFE0F",in_progress:"\u{1F6A7}",pending:"\u23F3",running:"\u23F1\uFE0F",skipped:"\u23ED\uFE0F",unknown:"\u2753",healthy:"\u{1F7E2}",degraded:"\u{1F7E1}",down:"\u{1F534}"};function $(t,n){if(t==="po_res")return n.includes("res=POSuccessProof")?m.success:n.includes("res=POErrorProof")?m.warning:m.error}function y(t){let n=document.createElement("details");n.className=`${e}-art`,n.open=!0;let o=document.createElement("summary");o.className=`${e}-summary`;let r=document.createElement("span");r.className=`${e}-art-kind`,r.textContent=t.kind,o.appendChild(r);let c=$(t.kind,t.repr);if(c){let d=document.createElement("span");d.className=`${e}-art-icon`,d.textContent=c,o.appendChild(d)}let s=document.createElement("span");s.className=`${e}-meta`,s.textContent=`${t.repr.length.toLocaleString()} chars`,o.appendChild(s);let a=document.createElement("button");a.className=`${e}-copy`,a.type="button",a.textContent="copy",a.addEventListener("click",d=>{d.preventDefault(),d.stopPropagation(),navigator.clipboard?.writeText(t.repr).then(()=>{a.textContent="copied",setTimeout(()=>a.textContent="copy",1200)})}),o.appendChild(a),n.appendChild(o);let i=document.createElement("div");i.className=`${e}-scroll`;let p=document.createElement("pre");return p.className=`${e}-pre`,p.innerHTML=g(t.repr),i.appendChild(p),n.appendChild(i),n}function b(t){let n=document.createElement("details");n.className=`${e}-task`,n.open=!0;let o=document.createElement("summary");o.className=`${e}-summary`;let r=document.createElement("span");if(r.className=`${e}-kind`,r.textContent=t.kind,o.appendChild(r),t.id){let i=document.createElement("span");i.className=`${e}-id`,i.textContent=t.id,o.appendChild(i)}let c=document.createElement("span");c.className=`${e}-meta`;let s=t.artifacts.length;c.textContent=`${s} artifact${s===1?"":"s"}`,o.appendChild(c),n.appendChild(o);let a=document.createElement("div");a.className=`${e}-body`;for(let i of t.artifacts)a.appendChild(y(i));return n.appendChild(a),n}function h(t,n){t.innerHTML="",t.classList.add(e);let o=document.createElement("style");if(o.textContent=x,t.appendChild(o),!n||n.length===0){let r=document.createElement("div");r.className=`${e}-placeholder`,r.textContent="No tasks.",t.appendChild(r);return}for(let r of n)t.appendChild(b(r))}var _={render({model:t,el:n}){let o=()=>h(n,t.get("task_entries"));o(),t.on("change:task_entries",o)}};export{_ as default}; +.${i}-pre .t-cls { color: #8250df; } /* constructor / class names */ +.${i}-pre .t-attr { color: #0550ae; } /* keyword-arg names */ +.${i}-pre .t-str { color: #0a7d33; } /* string literals */ +.${i}-pre .t-num { color: #953800; } /* numbers */ +.${i}-pre .t-lit { color: #cf222e; } /* None / True / False */ + +.${i}-placeholder { color: #9aa1a9; font-style: italic; padding: 8px; } +`;var N={success:"\u2705",error:"\u274C",warning:"\u26A0\uFE0F",info:"\u2139\uFE0F",in_progress:"\u{1F6A7}",pending:"\u23F3",running:"\u23F1\uFE0F",skipped:"\u23ED\uFE0F",unknown:"\u2753",healthy:"\u{1F7E2}",degraded:"\u{1F7E1}",down:"\u{1F534}"};function re(e,t){if(e==="po_res")return t.includes("res=POSuccessProof")?N.success:t.includes("res=POErrorProof")?N.warning:N.error}function se(e){let t=document.createElement("details");t.className=`${i}-art`,t.open=!0;let o=document.createElement("summary");o.className=`${i}-summary`;let n=document.createElement("span");n.className=`${i}-art-kind`,n.textContent=e.kind,o.appendChild(n);let c=re(e.kind,e.repr);if(c){let p=document.createElement("span");p.className=`${i}-art-icon`,p.textContent=c,o.appendChild(p)}let a=document.createElement("span");a.className=`${i}-meta`,a.textContent=`${e.repr.length.toLocaleString()} chars`,o.appendChild(a);let s=document.createElement("button");s.className=`${i}-copy`,s.type="button",s.textContent="copy",s.addEventListener("click",p=>{p.preventDefault(),p.stopPropagation(),navigator.clipboard?.writeText(e.repr).then(()=>{s.textContent="copied",setTimeout(()=>s.textContent="copy",1200)})}),o.appendChild(s),t.appendChild(o);let l=document.createElement("div");l.className=`${i}-scroll`;let m=document.createElement("pre");return m.className=`${i}-pre`,m.innerHTML=I(e.repr),l.appendChild(m),t.appendChild(l),t}function ae(e){let t=document.createElement("details");t.className=`${i}-task`,t.open=!0;let o=document.createElement("summary");o.className=`${i}-summary`;let n=document.createElement("span");if(n.className=`${i}-kind`,n.textContent=e.kind,o.appendChild(n),e.id){let l=document.createElement("span");l.className=`${i}-id`,l.textContent=e.id,o.appendChild(l)}let c=document.createElement("span");c.className=`${i}-meta`;let a=e.artifacts.length;c.textContent=`${a} artifact${a===1?"":"s"}`,o.appendChild(c),t.appendChild(o);let s=document.createElement("div");s.className=`${i}-body`;for(let l of e.artifacts)s.appendChild(se(l));return t.appendChild(s),t}function R(e,t){e.innerHTML="",e.classList.add(i);let o=document.createElement("style");if(o.textContent=B,e.appendChild(o),!t||t.length===0){let n=document.createElement("div");n.className=`${i}-placeholder`,n.textContent="No tasks.",e.appendChild(n);return}for(let n of t)e.appendChild(ae(n))}var ie=["task_entries","pre","post"],Ne={render({model:e,el:t}){let o=()=>{let n=e.get("task_entries");z(t,{pre:e.get("pre"),post:e.get("post"),main:c=>R(c,n??[]),hasMain:n!=null})};o();for(let n of ie)e.on(`change:${n}`,o)}};export{Ne as default}; diff --git a/packages/imandrax-tools/src/imandrax_tools/widget/widgets.py b/packages/imandrax-tools/src/imandrax_tools/widget/widgets.py index 766d64cb..d20179f7 100644 --- a/packages/imandrax-tools/src/imandrax_tools/widget/widgets.py +++ b/packages/imandrax-tools/src/imandrax_tools/widget/widgets.py @@ -105,7 +105,9 @@ class RegionDecompWidget(anywidget.AnyWidget): _esm = _DIST / 'region_decomp.js' - data = traitlets.List(allow_none=True, default_value=None).tag(sync=True) + data = traitlets.List(traitlets.Any(), allow_none=True, default_value=None).tag( + sync=True + ) pre = traitlets.Unicode('').tag(sync=True) post = traitlets.Unicode('').tag(sync=True) diff --git a/packages/imandrax-tools/widget-js/src/common/stack.ts b/packages/imandrax-tools/widget-js/src/common/stack.ts new file mode 100644 index 00000000..53ecc532 --- /dev/null +++ b/packages/imandrax-tools/widget-js/src/common/stack.ts @@ -0,0 +1,68 @@ +// Vertical stacking of an optional YAML preamble, a widget's own view, and an +// optional YAML appendix. +// +// This is how a result that is only *partly* renderable by a native view gets +// shown whole: the native panel covers what it understands (tasks, a region +// forest), and the surrounding fields (`eval_res`, `diagnostics`) ride along as +// folded YAML via `drawJsonable`. Each slot is independent, so a failed result +// whose native panel would be empty degrades to just the YAML rather than to a +// text/plain fallback. +// +// Labels are deliberately absent: the YAML the Python side hands over is a +// mapping whose own top-level keys already name each field. + +import { drawJsonable } from '../jsonable/view'; + +export const ROOT_CLASS = 'imdx-stack'; + +export const STACK_STYLE = ` +.${ROOT_CLASS} { display: flex; flex-direction: column; gap: 8px; box-sizing: border-box; } +.${ROOT_CLASS}-placeholder { font-family: ui-sans-serif, system-ui, sans-serif; + font-size: 12px; color: #9aa1a9; font-style: italic; padding: 10px; + border: 1px solid #d8dde2; border-radius: 6px; background: #fff; } +`; + +export interface StackInput { + /** YAML shown above the native panel; blank or whitespace renders nothing. */ + pre: string; + /** YAML shown below the native panel; blank or whitespace renders nothing. */ + post: string; + /** + * Draws the native panel into a fresh child element. Called only when + * `hasMain` holds, since the `draw*` functions have no useful rendering for + * empty input (`drawTreemap` would lay out an empty treemap). + */ + main: (el: HTMLElement) => void; + hasMain: boolean; +} + +export function drawStacked(el: HTMLElement, input: StackInput): void { + el.innerHTML = ''; + el.classList.add(ROOT_CLASS); + + const style = document.createElement('style'); + style.textContent = STACK_STYLE; + el.appendChild(style); + + // Every slot draws into its own child: the `draw*` functions all wipe their + // target and stamp a root class on it, so none of them may touch `el` itself. + const section = (): HTMLElement => { + const child = document.createElement('div'); + el.appendChild(child); + return child; + }; + + const hasPre = Boolean(input.pre && input.pre.trim()); + const hasPost = Boolean(input.post && input.post.trim()); + + if (hasPre) drawJsonable(section(), input.pre); + if (input.hasMain) input.main(section()); + if (hasPost) drawJsonable(section(), input.post); + + if (!hasPre && !hasPost && !input.hasMain) { + const empty = document.createElement('div'); + empty.className = `${ROOT_CLASS}-placeholder`; + empty.textContent = 'Nothing to show.'; + el.appendChild(empty); + } +} diff --git a/packages/imandrax-tools/widget-js/src/region_decomp/index.ts b/packages/imandrax-tools/widget-js/src/region_decomp/index.ts index ecff3ffb..1f04b7d8 100644 --- a/packages/imandrax-tools/widget-js/src/region_decomp/index.ts +++ b/packages/imandrax-tools/widget-js/src/region_decomp/index.ts @@ -1,19 +1,38 @@ // anywidget entry point for the treemap view (the primary region-decomposition -// widget). A thin adapter over the pure `drawTreemap`: pull the one-directional -// `data` traitlet off the model, render, and re-render when it changes. +// widget). A thin adapter over the pure `drawTreemap`, stacked between the +// optional `pre` / `post` YAML panels: pull the one-directional traitlets off the +// model, render, and re-render when any change. +// +// A null `data` drops the treemap, leaving a widget that is only its `pre` / +// `post` slots -- how a decomposition that errored renders. An empty array still +// draws the treemap, whose own "No regions." says it ran and found none. +import { drawStacked } from '../common/stack'; import { drawTreemap } from './treemap'; import type { DrawInput } from './types'; +type Key = 'data' | 'pre' | 'post'; + interface Model { get(key: 'data'): DrawInput; - on(event: 'change:data', cb: () => void): void; + get(key: 'pre' | 'post'): string; + on(event: `change:${Key}`, cb: () => void): void; } +const KEYS: Key[] = ['data', 'pre', 'post']; + export default { render({ model, el }: { model: Model; el: HTMLElement }) { - const rerender = () => drawTreemap(el, model.get('data')); + const rerender = () => { + const data = model.get('data'); + drawStacked(el, { + pre: model.get('pre'), + post: model.get('post'), + main: (target) => drawTreemap(target, data), + hasMain: data != null, + }); + }; rerender(); - model.on('change:data', rerender); + for (const key of KEYS) model.on(`change:${key}`, rerender); }, }; diff --git a/packages/imandrax-tools/widget-js/src/region_decomp/treemap.ts b/packages/imandrax-tools/widget-js/src/region_decomp/treemap.ts index a6dbf082..43691d95 100644 --- a/packages/imandrax-tools/widget-js/src/region_decomp/treemap.ts +++ b/packages/imandrax-tools/widget-js/src/region_decomp/treemap.ts @@ -201,6 +201,16 @@ export function drawTreemap(el: HTMLElement, input: DrawInput, opts: TreemapOpti tiles.className = `${ROOT_CLASS}-tiles`; main.append(topbar, tiles); + // A forest with no groups still lays out (synthetic root, no tiles), so say so + // rather than leaving the tiles pane blank. Callers that want no panel at all + // pass `null` -- see the widget adapter. + if (!root.children || root.children.length === 0) { + const empty = document.createElement('p'); + empty.className = `${ROOT_CLASS}-placeholder`; + empty.textContent = 'No regions.'; + tiles.appendChild(empty); + } + const divider = document.createElement('div'); divider.className = `${ROOT_CLASS}-divider`; const detail = document.createElement('div'); diff --git a/packages/imandrax-tools/widget-js/src/task/index.ts b/packages/imandrax-tools/widget-js/src/task/index.ts index 413e1899..e52df212 100644 --- a/packages/imandrax-tools/widget-js/src/task/index.ts +++ b/packages/imandrax-tools/widget-js/src/task/index.ts @@ -1,19 +1,37 @@ // anywidget entry point for the task-artifact view. A thin adapter over the pure -// `drawTasks`: pull the one-directional `task_entries` traitlet off the model, -// render, and re-render when it changes. +// `drawTasks`, stacked between the optional `pre` / `post` YAML panels: pull the +// one-directional traitlets off the model, render, and re-render when any change. +// +// A null `task_entries` drops the tasks panel, leaving a widget that is only its +// `pre` / `post` slots -- how a failed eval renders. An empty array still draws +// the panel, whose own "No tasks." says the eval ran and produced nothing. +import { drawStacked } from '../common/stack'; import type { TaskData } from './types'; import { drawTasks } from './view'; +type Key = 'task_entries' | 'pre' | 'post'; + interface Model { - get(key: 'task_entries'): TaskData[]; - on(event: 'change:task_entries', cb: () => void): void; + get(key: 'task_entries'): TaskData[] | null; + get(key: 'pre' | 'post'): string; + on(event: `change:${Key}`, cb: () => void): void; } +const KEYS: Key[] = ['task_entries', 'pre', 'post']; + export default { render({ model, el }: { model: Model; el: HTMLElement }) { - const rerender = () => drawTasks(el, model.get('task_entries')); + const rerender = () => { + const tasks = model.get('task_entries'); + drawStacked(el, { + pre: model.get('pre'), + post: model.get('post'), + main: (target) => drawTasks(target, tasks ?? []), + hasMain: tasks != null, + }); + }; rerender(); - model.on('change:task_entries', rerender); + for (const key of KEYS) model.on(`change:${key}`, rerender); }, }; diff --git a/packages/imandrax-tools/widget-js/test/adapters.test.js b/packages/imandrax-tools/widget-js/test/adapters.test.js new file mode 100644 index 00000000..302e2372 --- /dev/null +++ b/packages/imandrax-tools/widget-js/test/adapters.test.js @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; + +import decompAdapter from "../src/region_decomp/index"; +import taskAdapter from "../src/task/index"; + +// The anywidget adapters: which traitlets each reads, and when the native panel +// is dropped in favour of the `pre` / `post` YAML. The panels' own rendering is +// covered by stack.test.js and jsonable.test.js. + +// Minimal stand-in for the anywidget model: `get` plus change subscriptions. +function mockModel(state) { + const listeners = {}; + return { + get: (key) => state[key], + on: (event, cb) => ((listeners[event] ||= []).push(cb), undefined), + set(key, value) { + state[key] = value; + for (const cb of listeners[`change:${key}`] || []) cb(); + }, + }; +} + +function mount(adapter, state) { + const el = document.createElement("div"); + const model = mockModel(state); + adapter.render({ model, el }); + return { el, model }; +} + +const TASK = { kind: "verify", id: "1", artifacts: [] }; +const GROUP = { label_path: ["a"], constraints: [], weight: 1, region_stat: null, children: [] }; +const PRE = "eval_res:\n errors: []\n"; + +const hasJsonable = (el) => el.querySelectorAll(".imdx-jsonable").length; + +describe("task adapter", () => { + const state = (over) => ({ task_entries: null, pre: "", post: "", ...over }); + + it("renders the tasks panel when there are entries", () => { + const { el } = mount(taskAdapter, state({ task_entries: [TASK] })); + expect(el.querySelectorAll(".imdx-task-task").length).toBe(1); + expect(hasJsonable(el)).toBe(0); + }); + + it("renders pre and post alongside the tasks panel", () => { + const { el } = mount(taskAdapter, state({ task_entries: [TASK], pre: PRE, post: PRE })); + expect(el.querySelectorAll(".imdx-task-task").length).toBe(1); + expect(hasJsonable(el)).toBe(2); + }); + + it("drops the tasks panel when task_entries is null", () => { + const { el } = mount(taskAdapter, state({ pre: PRE })); + expect(el.querySelector(".imdx-task-task")).toBeNull(); + expect(el.querySelector(".imdx-task-placeholder")).toBeNull(); + expect(hasJsonable(el)).toBe(1); + }); + + it("keeps the panel for an empty array, which reports no tasks", () => { + // [] and null differ: an eval that ran and produced nothing says so. + const { el } = mount(taskAdapter, state({ task_entries: [], pre: PRE })); + expect(el.querySelector(".imdx-task-task")).toBeNull(); + expect(el.querySelector(".imdx-task-placeholder").textContent).toBe("No tasks."); + expect(hasJsonable(el)).toBe(1); + }); + + it("re-renders when pre changes", () => { + const { el, model } = mount(taskAdapter, state({ task_entries: [TASK] })); + expect(hasJsonable(el)).toBe(0); + model.set("pre", PRE); + expect(hasJsonable(el)).toBe(1); + expect(el.querySelectorAll(".imdx-task-task").length).toBe(1); + }); +}); + +describe("region_decomp adapter", () => { + const state = (over) => ({ data: null, pre: "", post: "", ...over }); + + it("renders the treemap when there are region groups", () => { + const { el } = mount(decompAdapter, state({ data: [GROUP] })); + expect(el.querySelector(".imdx-rd-tiles")).not.toBeNull(); + expect(el.querySelector(".imdx-rd-placeholder").textContent).not.toBe("No regions."); + expect(hasJsonable(el)).toBe(0); + }); + + it("drops the treemap when data is null", () => { + // What an errored decomposition looks like: no forest, result in `pre`. + const { el } = mount(decompAdapter, state({ pre: PRE })); + expect(el.querySelector(".imdx-rd-tiles")).toBeNull(); + expect(hasJsonable(el)).toBe(1); + }); + + it("keeps the treemap for an empty array, which reports no regions", () => { + const { el } = mount(decompAdapter, state({ data: [], pre: PRE })); + expect(el.querySelectorAll(".imdx-rd-tile").length).toBe(0); + expect(el.querySelector(".imdx-rd-tiles .imdx-rd-placeholder").textContent).toBe( + "No regions.", + ); + expect(hasJsonable(el)).toBe(1); + }); + + it("re-renders when data arrives", () => { + const { el, model } = mount(decompAdapter, state({ pre: PRE })); + expect(el.querySelector(".imdx-rd-tiles")).toBeNull(); + model.set("data", [GROUP]); + expect(el.querySelector(".imdx-rd-tiles")).not.toBeNull(); + }); +}); diff --git a/packages/imandrax-tools/widget-js/test/stack.test.js b/packages/imandrax-tools/widget-js/test/stack.test.js new file mode 100644 index 00000000..8c3cb15e --- /dev/null +++ b/packages/imandrax-tools/widget-js/test/stack.test.js @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from "vitest"; + +import { drawStacked } from "../src/common/stack"; + +// `drawStacked` is what lets a partly-renderable result show whole: the native +// panel covers what it understands, and the surrounding fields ride along as +// folded YAML above and below it. The contract worth pinning down is which slots +// render, in what order, and that the native `draw*` functions -- all of which +// wipe their target -- never receive the widget root itself. + +const YAML = "eval_res:\n errors: []\n"; +const OTHER = "diagnostics:\n - kind: unused\n"; + +// Stands in for drawTasks / drawTreemap: marks whatever element it is handed. +function fakeMain(el) { + el.classList.add("fake-main"); + el.textContent = "native panel"; +} + +function render(input) { + const el = document.createElement("div"); + drawStacked(el, { pre: "", post: "", main: fakeMain, hasMain: true, ...input }); + return el; +} + +// The rendered slots in document order; `style` and the placeholder are not slots. +// A jsonable slot reports its YAML body, whitespace-collapsed: each line is its +// own element, and the fidelity of the YAML rendering itself is jsonable.test.js's +// business, not this file's. `drawJsonable` also injects a