Skip to content

feat: shinyui prototype — class-per-component UI hierarchy (#69) - #100

Merged
schloerke merged 45 commits into
mainfrom
schloerke/issue-69-brainstorm
May 14, 2026
Merged

schloerke merged 45 commits into
mainfrom
schloerke/issue-69-brainstorm

Conversation

@schloerke

Copy link
Copy Markdown
Collaborator

Closes #69 (Stage A only). Builds the class-per-component prototype the
umbrella spec (#68) called for, in a new sibling package shinyui next
to shinyreact. Design and plan live under docs/superpowers/.

Summary

  • New package pkg-py/src/shinyui/ depends only on shiny + htmltools
    (no shinyreact import), so the eventual Stage B port into py-shiny
    is a near-mechanical copy.
  • Hierarchy: three semantic role bases (UiInput / UiOutput /
    UiLayout) plus three orthogonal mixins (HasInputValue, Updatable,
    AllowsChildren). Replaces the umbrella's straddler model — a card
    with a full-screen toggle reads cleanly as UiLayout + HasInputValue
    instead of awkwardly inheriting from UiInput.
  • Eight concrete classes in snake_case (matching shiny.render.data_frame):
    input_slider, input_select, input_action_button, output_code,
    output_plot, card, accordion, accordion_panel. The class IS the
    call site — no parallel factory functions.
  • Single source of session truth on UiComponent: _session captured
    at __init__, _require_session(for_op=...) resolves at call time with
    a fallback, _read_input(suffix) powers every accessor.
  • Server-side read accessors as @reactive_calc_method-wrapped
    methods (mirrors @render.data_frame's df.cell_selection() pattern):
    slider.value(), card.full_screen_value(), accordion.open_panels(),
    plot.click_value(), btn.clicked(), etc.
  • Typed .update() per class — no session= kwarg; init captures,
    call-time falls back. UiInputActionButton demonstrates
    __init_subclass__ handler-registration on HasInputValue (most
    classes leave the default None handler, so the hook is a no-op for them).
  • Express/Core overloads on container classes (card, accordion,
    accordion_panel): Express overload first (no positional children, for
    with X(...) as x: blocks), Core overload second (positional children
    for inline construction).
  • 126 tests across 16 test files: per-class snapshots vs shiny.ui.*,
    cross-cutting MRO / handler-registration / update-resolution /
    read-accessor / AllowsChildren behaviour, bookmark id→instance
    round-trip, public exports.
  • Working end-to-end Express demo at 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(); plot
    click/brush populate the diagnostics panel.
  • Spec & plan checked in under docs/superpowers/specs/ and
    docs/superpowers/plans/.

Notable design refinements

  1. UiAccordion ships no custom input handler — shiny's accordion
    binding pushes a plain JSON list, so the accessor coerces list→tuple
    at read time.
  2. UiInputSelect.update() delegates to shiny.ui.update_select rather
    than hand-rolling the payload — reuses shiny's choice-rendering pipeline.
  3. UiAccordionPanel.tagify() returns AccordionPanel (not Tag) so
    the outer accordion's isinstance(panel, AccordionPanel) check passes.
    Documented inline.
  4. UiCard.full_screen_value() reads input.<id>_full_screen (the
    actual wire suffix shiny pushes), not input.<id>() as the spec
    originally indicated. Browser-side update() wiring for full-screen
    remains Stage B work.
  5. _deep_tagify helper that I'd added briefly was removed — single
    .tagify() on the outer tag iterates Tagifiable→Tagifiable chains
    inside htmltools' walker, so it's enough. Accordion still pre-tagifies
    panels because of the isinstance check.
  6. reactive_calc_method cache is keyed by (instance, session) —
    without that, module-level components reused across sessions hit
    DestroyedReactiveError on session chore: add Makefile, GHA workflows, and tox config #2 and the browser went grey.

Test plan

  • make py-check is green (126 tests, ruff clean, pyright 0 errors)
  • Snapshot tests confirm tagify() matches shiny.ui.* markup byte-for-byte
  • Bookmark id→instance registration works under a mocked session
  • .update() outside a session raises with class name + method name in message
  • Read accessors raise outside a session
  • with card(...): ... returns self; with input_slider(...): raises TypeError
  • __init_subclass__ auto-registers UiInputActionButton's shinyui.action handler
  • Express example app renders end-to-end (verified in browser via Playwright):
    inputs drive the scatter plot, Open/Close buttons fire accordion.update(),
    plot click/brush populate the diagnostics panel

Out of scope (intentionally deferred)

  • Stage B port into py-shiny (separate issue when this prototype is accepted)
  • Tag-as-context-manager / parent-tag stack (umbrella sub-issue 3)
  • Browser-side JS for card.update(full_screen=True) to actually flip the card

schloerke added 29 commits May 14, 2026 09:57
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.
schloerke added 10 commits May 14, 2026 09:57
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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 shinyui base classes, mixins, component classes, read accessors, update methods, and public exports.
  • Adds a comprehensive pkg-py/tests/shinyui test 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.

Comment thread examples/app-py/14-unified-ui-prototype/README.md Outdated
Comment thread examples/app-py/14-unified-ui-prototype/README.md Outdated
Comment thread docs/superpowers/specs/2026-05-13-shinyui-metadata-consolidation-design.md Outdated
Comment thread docs/superpowers/specs/2026-05-13-shinyui-metadata-consolidation-design.md Outdated
Comment thread pkg-py/src/shinyui/_card.py Outdated
Comment thread pkg-py/src/shinyui/_reactive.py
schloerke added 6 commits May 14, 2026 10:53
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.
@schloerke

Copy link
Copy Markdown
Collaborator Author

Merging to move forward to Express hooks in another PR

@schloerke
schloerke merged commit ba858f4 into main May 14, 2026
3 checks passed
@schloerke
schloerke deleted the schloerke/issue-69-brainstorm branch May 14, 2026 15:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Metadata consolidation: UiComponent / UiInput / UiOutput / UiLayout / AllowsChildren

3 participants