feat: shinyui prototype — class-per-component UI hierarchy (#69) - #100
Merged
Merged
Conversation
Stage A design for umbrella #68 / issue #69. Specs a new sibling Python package `shinyui` at pkg-py/src/shinyui/ that prototypes a class-per-component UI hierarchy with seven concrete archetypes, resolves the three umbrella open questions (handler registration, bookmark lookup, update signature), and refactors the umbrella's UiInput/UiLayout straddler into orthogonal HasInputValue + Updatable mixins so layouts-with-state read honestly.
Adds AllowsChildren mixin (pkg-py/src/shinyui/_children.py) with children list, append(), __enter__/__exit__ overrides. Also fixes UiComponent.__init__ to forward *args cooperatively so AllowsChildren receives positional children when MRO order is MyComp(UiComponent, AllowsChildren).
htmltools' built-in Tag.tagify walks only one level: it replaces direct Tagifiable children with their tagify() result but does not recurse into the resulting Tag's own children. Containers whose tagify wraps un-resolved Tagifiable descendants therefore reached htmltools' rendering layer with nested Tagifiables, triggering 'non-tagified object' at render time. Add UiComponent._deep_tagify(node) — a small recursive helper that walks Tag/TagList children, calling tagify() on every Tagifiable it encounters. Apply it in UiCard and UiAccordion (the two containers whose children can themselves be Tagifiable). The example app now renders successfully.
…ule-level demo components - UiCard.full_screen_value() now reads input.<id>_full_screen (matches Shiny's card binding wire format) instead of input.<id>. Update card and read-accessors tests to match. - Example app 14 constructs components at module level so closures share them between app_ui (HTTP phase, no session) and server (WebSocket phase, session bound). The lookup_component approach in the previous version returned None during HTTP-phase construction, crashed the session, and showed the grey 'Disconnected' overlay. - Replace matplotlib placeholder with a PIL solid-color image so the example runs without matplotlib in the venv.
Solid-color placeholder was indistinguishable from the card background. Added a border, crosshair, and label text via PIL.ImageDraw so the plot output has a clear visual target for click and brush interactions.
WeakKeyDictionary keyed only on the component instance kept the same reactive.calc alive across sessions. Module-level components see many sessions; the calc bound to session #1 is destroyed when that session ends, and session #2 hit a DestroyedReactiveError when re-using the cached calc -> grey 'Disconnected' overlay. Switch to a per-instance attribute that stores (session_obj, calc). If the captured session differs from the current one, recreate the calc.
UiInputActionButton(UiInput, Updatable) ships the same shape as the other input classes: - typed __init__ mirroring shiny.ui.input_action_button (label, icon, width, disabled) - .count() reactive accessor exposing the click counter (0 before first click) - typed update(*, label, icon, disabled) delegating to shiny.ui.update_action_button - factory function input_action_button(...) - 6 unit tests pinning factory/snapshot/count/update behavior Example 14 swaps ui.input_action_button for su.input_action_button on both Open-all and Close-all controls and wires the @reactive.event deps via btn.count instead of input.<id>, so the demo's button surface is fully class-based now.
Matches the shorter accessor name and reads the same _dblclick wire suffix. Add module-docstring note that limits_value / selection_value accessors are intentionally absent: shiny.ui.output_plot only pushes the four documented interaction signals (click, dblclick, hover, brush). If shiny gains _limits or _selection upstream, add the accessors then.
Switch from def app_ui(request) / def server() to a top-level expressify script. Construct shinyui components programmatically (factory calls with children as args) since AllowsChildren is not wired into Express's RecallContextManager yet — sub-issue 3 territory. Wrap the @render decorators in 'with ui.hold():' to suppress Express's auto-placement; the renderers still register with the session by id, binding to the output_code / output_plot elements we placed inside the accordion. Without hold(), Express would inject duplicate <pre id=...> elements at the page tail. Use plain shiny.ui.layout_column_wrap (imported as _sui) for the inline button row; shiny.express.ui.layout_column_wrap is the recall-context variant that takes 0 positional args.
Reads more naturally at the call site ("btn.clicked() > 0" vs the
slightly ambiguous "btn.count() > 0"). Wire suffix unchanged
(reads input.<id> directly). Tests and example 14 updated.
…ActionButton Most shinyui classes register input handlers via an explicit cls._register_input_handler() call at module load. The action button now demonstrates the alternative: a small _InputHandlerAutoRegister mixin whose __init_subclass__ hook auto-fires registration when the class is defined. Concretely: - Add input_handler_name = 'shinyui.action' and a small _input_handler staticmethod that coerces wire value -> int (parallel of py-shiny's 'shiny.action' handler). - Register under 'shinyui.action' (not 'shiny.action') so we don't collide with shiny's built-in; the demo's purpose is the registration mechanism, not real wire traffic — shiny's markup still routes action-button events through its own 'shiny.action' handler. - Pin behavior with two new tests: registry contains 'shinyui.action' after import, and _input_handler returns plain ints. Update the cross-cutting test_input_handler_registration.py to reflect the exception.
htmltools' Tag.tagify() iterates Tagifiable->Tagifiable chains inside its single-level TagList walk, so calling .tagify() once on the outer Tag is enough to fully resolve our Tagifiable descendants. The custom recursive _deep_tagify helper I'd added is unnecessary. UiAccordion still pre-resolves its children via [c.tagify() for c in ...] because shiny.ui.accordion does an explicit isinstance(panel, AccordionPanel) check on positional args and rejects UiAccordionPanel (which is Tagifiable but not an AccordionPanel subclass). UiCard has no such isinstance check, so it hands its children in unchanged and lets the outer .tagify() resolve them. Both files document the rule inline; the module-docstring of _base.py adds a one-line guidance pointer for future container subclasses.
…Value The standalone _InputHandlerAutoRegister mixin duplicated logic that HasInputValue already owns (input_handler_name, _input_handler, _register_input_handler classmethod). Move the __init_subclass__ hook onto HasInputValue itself — now every HasInputValue subclass auto-fires registration on class-definition. Classes with the defaults (input_handler_name='' or _input_handler is None) skip silently, so this is no-op for slider/select/card/accordion and active only for UiInputActionButton. Drops the extra base from UiInputActionButton's MRO and removes ~20 lines of plumbing. The action-button module docstring still explains the demo and the 'shinyui.action' vs 'shiny.action' separation.
Match shiny.render.* convention (e.g. shiny.render.data_frame) by naming concrete user-facing classes in snake_case. Drops the parallel factory functions since the class name now equals the call-site name — no more double record keeping. Bases and mixins (UiComponent, UiInput, UiOutput, UiLayout, HasInputValue, Updatable, AllowsChildren) stay PascalCase, mirroring shiny.render.Renderer. Class rename map: UiInputSlider -> input_slider UiInputSelect -> input_select UiInputActionButton -> input_action_button UiOutputCode -> output_code UiOutputPlot -> output_plot UiCard -> card UiAccordion -> accordion UiAccordionPanel -> accordion_panel Docs (spec + plan) updated to reflect the new names; the older 2026-05-06 umbrella spec is left alone since it describes the original design vision.
The three classes with AllowsChildren (card, accordion, accordion_panel) now expose two @overload signatures on __init__: 1. Express overload (no positional children) — listed first so IDEs prefer it when the user writes `with card(id=...) as c: ...`. 2. Core overload (positional children) — for inline construction like `card(child_a, child_b, id=...)`. The runtime __init__ is unchanged; the overloads are pure type hints for IDE / pyright consumption, mirroring the umbrella spec's sub-issue 2 plan for Core/Express signature unification.
schloerke
force-pushed
the
schloerke/issue-69-brainstorm
branch
from
May 14, 2026 13:57
0129ce8 to
c8f5691
Compare
There was a problem hiding this comment.
Pull request overview
This PR adds a new Python shinyui prototype package that explores a class-per-component UI hierarchy for Shiny UI components, alongside tests, docs, and an Express demo app.
Changes:
- Adds
shinyuibase classes, mixins, component classes, read accessors, update methods, and public exports. - Adds a comprehensive
pkg-py/tests/shinyuitest suite covering hierarchy, sessions, accessors, updates, tagification, and registration behavior. - Adds design/implementation docs and a demo app under
examples/app-py/14-unified-ui-prototype.
Reviewed changes
Copilot reviewed 44 out of 46 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
pyproject.toml |
Includes shinyui in wheel packaging and pyright checks. |
pkg-py/src/shinyui/* |
Adds the new prototype package, hierarchy, components, session helpers, and accessors. |
pkg-py/tests/shinyui/* |
Adds unit and integration-style tests for the prototype package. |
examples/app-py/14-unified-ui-prototype/* |
Adds an Express demo and README for the prototype. |
docs/superpowers/specs/2026-05-13-shinyui-metadata-consolidation-design.md |
Adds the design spec for the Stage A prototype. |
docs/superpowers/plans/2026-05-13-shinyui-metadata-consolidation.md |
Adds the implementation plan. |
.gitignore |
Ignores a prototype screenshot artifact. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Each of the eight concrete classes (input_slider, input_select, input_action_button, output_code, output_plot, card, accordion, accordion_panel) now ships: - A class-level docstring naming the wire id, the matching class accessor (if any), and a small idiomatic Example block. - An __init__ docstring documenting id/label (or title), the key shape parameters, and a pointer to shiny.ui.<name> for the long tail of pass-through kwargs. Pure documentation — no signature or runtime behaviour changes. input_slider already had this treatment; the others now match its template.
…nore Honor the UiComponent.tagify() -> Tag contract on accordion_panel by chaining .tagify() on shiny's AccordionPanel wrapper. The previous override that returned AccordionPanel (a Tagifiable, not a Tag) needed a # type: ignore[override] annotation; that's now gone. Two coupling points required care: - shiny.ui.accordion does an explicit isinstance(panel, AccordionPanel) check on its positional args, so the parent accordion can't consume our rendered Tag. Add a small private helper _build_accordion_panel() that returns the AccordionPanel wrapper; the accordion's tagify() uses that helper instead of calling child.tagify(). - shiny's AccordionPanel.tagify() raises if _accordion_id is not set (normally written by the parent accordion). For standalone rendering (snapshot tests, ad-hoc inspection), stamp a placeholder id keyed off the panel's value before calling .tagify(). The parent path is unaffected — it gets a fresh AccordionPanel each time via the helper. Snapshot test for accordion_panel switched from attribute comparison (_title, _args, _data_value, _icon) to asserting the returned object isinstance(Tag) with the title and body content present in the rendered HTML.
card, accordion, and accordion_panel each have an Express overload (no positional children, used in 'with X():' blocks) and a Core overload (inline positional children). Move the doc + Example block onto each overload separately so IDE tooltips show the right idiom for the call site. The implementation __init__ at the bottom of each class loses its docstring (overload stubs carry the docs now).
…accordion_panel helper Honor the UiComponent.tagify() -> Tag contract on accordion_panel by chaining .tagify() on shiny's AccordionPanel wrapper. Stamp a placeholder _accordion_id so standalone .tagify() works outside a parent accordion (shiny's AccordionPanel.tagify() raises if _accordion_id is unset). The parent accordion now builds AccordionPanel wrappers inline from each child's stored title/children/_value/icon — rather than calling a helper method on the child — since shiny.ui.accordion does an isinstance check on positional args and rejects rendered Tags. No _build_accordion_panel indirection.
Concrete shinyui classes are intentionally snake_case (matching
shiny.render.data_frame's convention — class name == call-site name,
no parallel factory functions). Configure ruff to ignore N801
('class name should use CapWords') package-wide for pkg-py/src/shinyui
in pyproject.toml, and remove the eight per-line '# noqa: N801'
pragmas the concrete classes carried.
Addresses PR #100 review threads from Copilot: - examples/.../README.md: full rewrite for the current Express app — drops the stale lookup_component snippet, the removed _auto_expand_at_high_n effect, and the n>800 'What to try' bullet. Adds the Open all / Close all button section that's actually wired in app.py. Corrects the card full_screen wire id to _full_screen. - docs/.../spec: 'seven concrete classes' -> 'at least seven', and the lifecycle bullet for handler registration now describes the __init_subclass__ approach actually used (HasInputValue auto-fires cls._register_input_handler() on subclass creation), with input_action_button called out as the demonstrating class. - pkg-py/src/shinyui/_card.py: module docstring corrected to say the accessor reads input.<id>_full_screen (the actual wire suffix) — the previous text contradicted full_screen_value()'s implementation. - pkg-py/src/shinyui/_reactive.py: implement the on_ended cache eviction the module docstring already promised. The cached (session, calc) attribute is now dropped proactively when the captured session ends, before the lazy mismatch-detection at the next accessor call would have replaced it.
Collaborator
Author
|
Merging to move forward to Express hooks in another PR |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #69 (Stage A only). Builds the class-per-component prototype the
umbrella spec (#68) called for, in a new sibling package
shinyuinextto
shinyreact. Design and plan live underdocs/superpowers/.Summary
pkg-py/src/shinyui/depends only onshiny + htmltools(no
shinyreactimport), so the eventual Stage B port intopy-shinyis a near-mechanical copy.
UiInput/UiOutput/UiLayout) plus three orthogonal mixins (HasInputValue,Updatable,AllowsChildren). Replaces the umbrella's straddler model — a cardwith a full-screen toggle reads cleanly as
UiLayout + HasInputValueinstead of awkwardly inheriting from
UiInput.shiny.render.data_frame):input_slider,input_select,input_action_button,output_code,output_plot,card,accordion,accordion_panel. The class IS thecall site — no parallel factory functions.
UiComponent:_sessioncapturedat
__init__,_require_session(for_op=...)resolves at call time witha fallback,
_read_input(suffix)powers every accessor.@reactive_calc_method-wrappedmethods (mirrors
@render.data_frame'sdf.cell_selection()pattern):slider.value(),card.full_screen_value(),accordion.open_panels(),plot.click_value(),btn.clicked(), etc..update()per class — nosession=kwarg; init captures,call-time falls back.
UiInputActionButtondemonstrates__init_subclass__handler-registration onHasInputValue(mostclasses leave the default
Nonehandler, so the hook is a no-op for them).card,accordion,accordion_panel): Express overload first (no positional children, forwith X(...) as x:blocks), Core overload second (positional childrenfor inline construction).
shiny.ui.*,cross-cutting MRO / handler-registration / update-resolution /
read-accessor /
AllowsChildrenbehaviour, bookmark id→instanceround-trip, public exports.
examples/app-py/14-unified-ui-prototype/exercising every class. Real matplotlib scatter driven by slider/select/seed
reads; Open all / Close all buttons fire
accordion.update(); plotclick/brush populate the diagnostics panel.
docs/superpowers/specs/anddocs/superpowers/plans/.Notable design refinements
UiAccordionships no custom input handler — shiny's accordionbinding pushes a plain JSON list, so the accessor coerces list→tuple
at read time.
UiInputSelect.update()delegates toshiny.ui.update_selectratherthan hand-rolling the payload — reuses shiny's choice-rendering pipeline.
UiAccordionPanel.tagify()returnsAccordionPanel(notTag) sothe outer accordion's
isinstance(panel, AccordionPanel)check passes.Documented inline.
UiCard.full_screen_value()readsinput.<id>_full_screen(theactual wire suffix shiny pushes), not
input.<id>()as the specoriginally indicated. Browser-side
update()wiring for full-screenremains Stage B work.
_deep_tagifyhelper that I'd added briefly was removed — single.tagify()on the outer tag iterates Tagifiable→Tagifiable chainsinside htmltools' walker, so it's enough. Accordion still pre-tagifies
panels because of the isinstance check.
reactive_calc_methodcache is keyed by(instance, session)—without that, module-level components reused across sessions hit
DestroyedReactiveErroron session chore: add Makefile, GHA workflows, and tox config #2 and the browser went grey.Test plan
make py-checkis green (126 tests, ruff clean, pyright 0 errors)tagify()matchesshiny.ui.*markup byte-for-byte.update()outside a session raises with class name + method name in messagewith card(...): ...returnsself;with input_slider(...):raisesTypeError__init_subclass__auto-registersUiInputActionButton'sshinyui.actionhandlerinputs drive the scatter plot, Open/Close buttons fire
accordion.update(),plot click/brush populate the diagnostics panel
Out of scope (intentionally deferred)
py-shiny(separate issue when this prototype is accepted)card.update(full_screen=True)to actually flip the card