From 72333047bb570f8d6a9855e051ba1a650c196f2e Mon Sep 17 00:00:00 2001 From: henz Date: Thu, 6 Aug 2026 23:29:10 +0800 Subject: [PATCH 1/2] Stepper: add a fixed-text hover popover capability to SyntaxProfile A named function value (SyntaxProfile.functionValues) already gets a mu-term + hover popover showing its body, but there was no way to show a popover for something that isn't a steppable function value at all -- e.g. a builtin referenced as a bare value, or a name imported from a module -- since there is no body to expand. Add SyntaxProfile.hoverText: a node type can declare a property holding an already-formatted, static line of text (e.g. "built-in function print"); the host wraps that node's existing inline rendering in a Popover showing it, without collapsing/replacing the rendering the way functionValues does. Generic in the host -- any language opts in per node type, no per-language host code -- exactly mirroring how functionValues itself works. Needed by py-slang#404 (a builtin used as a value has no hover at all today) and the pop-up half of py-slang#406 (imported module functions). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Hv4gVtVgskzLuPpPh3woaE --- .changeset/stepper-hover-text-popover.md | 6 ++++ src/common/stepper/src/index.ts | 27 +++++++++++++++++ src/web/stepper/src/SubstVisualizer.tsx | 38 ++++++++++++++++++++++++ 3 files changed, 71 insertions(+) create mode 100644 .changeset/stepper-hover-text-popover.md diff --git a/.changeset/stepper-hover-text-popover.md b/.changeset/stepper-hover-text-popover.md new file mode 100644 index 0000000..96eedf3 --- /dev/null +++ b/.changeset/stepper-hover-text-popover.md @@ -0,0 +1,6 @@ +--- +"@sourceacademy/common-stepper": patch +"@sourceacademy/web-stepper": patch +--- + +Add a `hoverText` `SyntaxProfile` capability: a node type can now show a fixed-text hover popover (e.g. `built-in function print`) alongside its normal inline rendering, without collapsing/replacing that rendering the way `functionValues`'s mu-term does. Unlike a function value's popover (the node's own template, i.e. a body, rendered on demand), this shows a single already-formatted line the language stashed on the node ahead of time — there is no body to expand. A language opts in by listing the node type and the property holding that text in its `SyntaxProfile.hoverText`; `web-stepper` renders the popover generically from the rule, no per-language host code. diff --git a/src/common/stepper/src/index.ts b/src/common/stepper/src/index.ts index 63f1050..39d9440 100644 --- a/src/common/stepper/src/index.ts +++ b/src/common/stepper/src/index.ts @@ -162,6 +162,28 @@ export interface FunctionValueRule { nameProp: string; } +/** + * Declares a node type that shows a fixed hover popover alongside its normal inline rendering. + * + * Unlike {@link FunctionValueRule} — whose popover is the node's own template, i.e. a function's body, + * rendered on demand — this popover is a single line of *plain text* the language computed ahead of + * time and stashed on the node (e.g. `"built-in function print"` for a builtin referenced as a value, + * or `"module function stack"` for a name imported from a module): there is no body to expand, so + * nothing is collapsed or replaced — the node still renders exactly as `templates[type]` produces it, + * with a popover merely added on top. The host implements this generically from these rules, so any + * language gets the behaviour for any node type by listing it here — no per-language host code. + */ +export interface HoverTextRule { + /** The node `type` this applies to, e.g. `"Builtin"`. */ + type: string; + /** + * Dotted path to the node property holding the popover's already-formatted plain-text content (e.g. + * `"hoverText"`, or `"decl.hoverText"` for a node whose text lives on a child). When the path + * resolves to an empty value, no popover is added for that particular node. + */ + textProp: string; +} + /** * A language's complete rendering rules: a per-node-type template table plus the precedence maps the * host uses to insert parentheses generically. Authored once per language and shipped by its runner. @@ -178,6 +200,11 @@ export interface SyntaxProfile { * collapsed mu-term + hover popover instead of expanding its body inline. See {@link FunctionValueRule}. */ functionValues?: FunctionValueRule[]; + /** + * Node types that show a fixed-text hover popover alongside their normal inline rendering. See + * {@link HoverTextRule}. + */ + hoverText?: HoverTextRule[]; } /* -------------------------------------------------------------------------- */ diff --git a/src/web/stepper/src/SubstVisualizer.tsx b/src/web/stepper/src/SubstVisualizer.tsx index ea574fb..aae5e05 100644 --- a/src/web/stepper/src/SubstVisualizer.tsx +++ b/src/web/stepper/src/SubstVisualizer.tsx @@ -380,6 +380,23 @@ function ProfileFunctionDefinitionPopover({ ); } +/** + * The popover body for a {@link HoverTextRule}: a single already-formatted line the language + * computed ahead of time (e.g. `"built-in function print"`), unlike + * {@link ProfileFunctionDefinitionPopover}'s expanded function body — there is no body to render here, + * just the text, inside the same chrome the function-definition popover uses. + */ +function ProfileHoverTextPopover({ text }: { text: string }) { + return ( +
+
+ + {` ${text}`} +
+
+ ); +} + /** * renderNode renders a serialized Stepper AST node to a React ReactNode. */ @@ -940,6 +957,27 @@ function renderNode( // A profile is authoritative: never fall back to JS syntax for an unmapped node type. const template = profile.templates[currentNode.type]; result = template ? renderTemplate(currentNode, template) : `<${currentNode.type}>`; + + // A fixed-text hover popover (e.g. a builtin's ``) — independent of the + // function-value mu-term collapse above: the inline rendering stays exactly what the template + // just produced, only a Popover is added on top. See SyntaxProfile.hoverText. + const hoverRule = profile.hoverText?.find(rule => rule.type === currentNode.type); + const hoverText = hoverRule ? readNodeProp(currentNode, hoverRule.textProp) : undefined; + if (typeof hoverText === "string" && hoverText !== "") { + const content = result; + result = ( + } + > + {content} + + ); + } } } else { const renderer = ( From 32b2d3a51b2943df6f627c709aa6e379ed2e3a02 Mon Sep 17 00:00:00 2001 From: henz Date: Thu, 6 Aug 2026 23:55:45 +0800 Subject: [PATCH 2/2] Apply hoverText regardless of the function-value mu-term branch CodeRabbit caught this on #92: the hoverText popover check only ran in the *else* branch (the plain-template render), so a node type listed in both functionValues and hoverText would silently get only the mu-term treatment -- contradicting the doc comment's own claim that the two are independent. Move the hoverText wrap after the if/else so it applies to whichever result either branch produced. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Hv4gVtVgskzLuPpPh3woaE --- src/web/stepper/src/SubstVisualizer.tsx | 41 +++++++++++++------------ 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/src/web/stepper/src/SubstVisualizer.tsx b/src/web/stepper/src/SubstVisualizer.tsx index aae5e05..8b81e24 100644 --- a/src/web/stepper/src/SubstVisualizer.tsx +++ b/src/web/stepper/src/SubstVisualizer.tsx @@ -957,27 +957,28 @@ function renderNode( // A profile is authoritative: never fall back to JS syntax for an unmapped node type. const template = profile.templates[currentNode.type]; result = template ? renderTemplate(currentNode, template) : `<${currentNode.type}>`; + } - // A fixed-text hover popover (e.g. a builtin's ``) — independent of the - // function-value mu-term collapse above: the inline rendering stays exactly what the template - // just produced, only a Popover is added on top. See SyntaxProfile.hoverText. - const hoverRule = profile.hoverText?.find(rule => rule.type === currentNode.type); - const hoverText = hoverRule ? readNodeProp(currentNode, hoverRule.textProp) : undefined; - if (typeof hoverText === "string" && hoverText !== "") { - const content = result; - result = ( - } - > - {content} - - ); - } + // A fixed-text hover popover (e.g. a builtin's ``) is independent of the + // function-value mu-term collapse above and applies regardless of which branch produced `result` — + // a node type can be listed in both `functionValues` and `hoverText` and get both behaviours, not + // just whichever branch happened to run first. See SyntaxProfile.hoverText. + const hoverRule = profile.hoverText?.find(rule => rule.type === currentNode.type); + const hoverText = hoverRule ? readNodeProp(currentNode, hoverRule.textProp) : undefined; + if (typeof hoverText === "string" && hoverText !== "") { + const content = result; + result = ( + } + > + {content} + + ); } } else { const renderer = (