Skip to content

feat: input_code_editor() Component#1274

Merged
gadenbuie merged 39 commits intomainfrom
feat/input-code
Jan 7, 2026
Merged

feat: input_code_editor() Component#1274
gadenbuie merged 39 commits intomainfrom
feat/input-code

Conversation

@gadenbuie
Copy link
Member

@gadenbuie gadenbuie commented Dec 31, 2025

Overview

input_code_editor() is a Shiny input that provides a lightweight code editor with syntax highlighting, powered by prism-code-editor. It supports 20+ languages (a subset of those supported by prism-code-editor), multiple themes, and automatic light/dark mode switching.

File Layout

bslib/
├── R/
│   └── input-code-editor.R          # R API: input_code_editor(), update_code_editor()
├── srcts/src/components/
│   ├── codeEditor.ts                # BslibCodeEditor web component
│   └── codeEditor.css               # BslibCodeEditor styles
├── inst/
│   ├── lib/
│   │   └── prism-code-editor/       # Vendored prism-code-editor library
│   └── examples-shiny/
│       └── code-editor/app.R        # Demo application
│
└── tests/testthat/
    └── test-input-code-editor.R     # Unit tests

Architecture

Key Design Decisions

  1. Custom Element: The editor is a web component (<bslib-code-editor>) extending HTMLElement. This provides standard lifecycle hooks and attribute reflection, simplifying Shiny integration via the shared makeInputBinding() helper.

  2. Separate Bundle: code-editor.js is NOT bundled into components.min.js. It's loaded only when input_code_editor() is used, keeping the main bslib bundle small.

  3. ES Modules: The code-editor bundle uses ESM format to support dynamic imports of language grammars at runtime.

  4. Lazy Loading: Language grammars and themes are loaded on-demand when the editor initializes (connectedCallback()) or when receiving an update messages from the server, not upfront.

  5. Theme Watching: Each editor instance creates a MutationObserver that watches <html data-bs-theme> to automatically switch between light/dark themes. Theme stylesheets are shared across all instances and never unloaded.

R API (R/input-code-editor.R)

Exported Functions

Function Purpose
input_code_editor() Create a code editor input
update_code_editor() Update editor from server
code_editor_themes() List available themes

Internal Functions

Function Purpose
code_editor_dependencies() Returns all HTML dependencies
code_editor_dependency_prism() Prism library dependency
code_editor_dependency_js() bslib binding JS dependency
arg_match_language() Validate language parameter
arg_match_theme() Validate theme parameter
check_value_line_count() Warn if >1000 lines

HTML Output Structure

The component uses a custom element <bslib-code-editor> with kebab-case attributes:

<bslib-code-editor
    id="{id}"
    language="{language}"
    value="{value}"
    theme-light="{theme_light}"
    theme-dark="{theme_dark}"
    readonly="{read_only}"
    line-numbers="{line_numbers}"
    word-wrap="{word_wrap}"
    tab-size="{tab_size}"
    insert-spaces="{insert_spaces}">
  <label for="{id}">{label}</label>
  <div class="code-editor"></div>  <!-- Editor mounts here -->
</bslib-code-editor>

TypeScript Web Component (srcts/src/components/codeEditor.ts)

The editor is implemented as a custom element (<bslib-code-editor>) that extends HTMLElement and implements CustomElementInputGetValue<string> for Shiny integration.

Class: BslibCodeEditor

Static Properties:

  • tagName = "bslib-code-editor": Custom element tag name
  • isShinyInput = true: Marks this as a Shiny input for makeInputBinding()
  • observedAttributes: List of attributes that trigger attributeChangedCallback()

Static Methods (shared across all instances):

  • #getBasePath(): Locates and caches path to prism-code-editor assets
  • #loadLanguage(): Dynamically imports language grammars (cached)
  • #loadTheme(): Loads theme stylesheets (cached, never unloaded)

Instance Properties (reflect to/from HTML attributes):

  • language, readonly, lineNumbers, wordWrap, tabSize, insertSpaces
  • themeLight, themeDark
  • value: Current editor content (get/set on prismEditor)

Lifecycle Methods:

  • connectedCallback(): Initializes editor when element is added to DOM
  • disconnectedCallback(): Cleans up MutationObserver when removed
  • attributeChangedCallback(): Responds to attribute changes by updating prism-code-editor

Key Instance Methods:

  • getValue(): Returns current content (for Shiny input binding)
  • receiveMessage(): Handles update_code_editor() calls from R
  • _initializeEditor(): Creates prism-code-editor instance, sets up Ctrl+Enter and blur handlers
  • _setupThemeWatcher(): Watches data-bs-theme on <html> to switch themes
  • _handleLanguageChange(): Loads new grammar and updates editor

Shiny Integration

The Shiny input binding is created via the shared makeInputBinding() helper:

customElements.define(BslibCodeEditor.tagName, BslibCodeEditor);

if (window.Shiny) {
  makeInputBinding<string>(BslibCodeEditor.tagName);
}

The makeInputBinding() helper (from webcomponents/_makeInputBinding.ts) creates a standard Shiny input binding that:

  • Finds elements by tag name
  • Delegates getValue() and receiveMessage() to the custom element instance
  • Uses the element's onChangeCallback for value updates

tsconfig.json Note

The module: esnext setting is required for dynamic imports. The ts-node section overrides this to commonjs for the build script only.

Vendoring (tools/yarn_install.R)

How It Works

  1. inst/package.json declares prism-code-editor-full dependency (aliased from prism-code-editor-lightweight)
  2. yarn install in inst/ downloads to node_modules/
  3. node_modules/ is moved to lib/
  4. Script copies needed files from prism-code-editor-full/dist/ to prism-code-editor/
  5. Full package is deleted, only selective files remain
  6. Version is written to R/versions.R

Updating prism-code-editor

  1. Update version in inst/package.json:

    "prism-code-editor-full": "npm:prism-code-editor-lightweight@X.Y.Z"
  2. Run Rscript tools/yarn_install.R or Rscript tools/main.R

  3. Verify R/versions.R has updated version

  4. Run tests: devtools::test(filter = "code-editor")

Adding New Languages

Languages are loaded dynamically from inst/lib/prism-code-editor/prism/languages/. To add a new language:

  1. Verify the grammar file exists in prism/languages/{lang}.js
  2. Add to code_editor_bundled_languages in tools/yarn_install.R and re-run the yarn install script.
  3. Verify code_editor_bundled_languages in R/versions.R is updated.
  4. Update @param language documentation in R/input-code-editor.R.

Adding New Themes

Themes are CSS files in inst/lib/prism-code-editor/themes/. Available themes are auto-discovered by code_editor_themes().

To add a custom theme:

  1. Add {theme-name}.css to inst/lib/prism-code-editor/themes/
  2. It will automatically appear in code_editor_themes() output

Testing

Unit tests of input_code_editor() are in tests/testthat/test-input-code-editor.R and can be run with devtools::test(filter = "code-editor").

An example Shiny app demonstrating the editor is in inst/examples-shiny/code-editor/app.R and can be run with:

shiny::runApp("inst/examples-shiny/code-editor")

# For package users:
shiny::runExample("code-editor", package = "bslib")

CSS Customization

The editor uses CSS variables for Bootstrap integration. Key selectors:

  • bslib-code-editor - Custom element (outer container)
  • .code-editor - Inner editor container where prism-code-editor mounts
  • .code-editor-submit-flash - Flash animation on Ctrl+Enter

See srcts/src/components/codeEditor.css for full styles.

gadenbuie and others added 26 commits December 29, 2025 15:35
Each theme is wrapped with attribute
selectors that match the editor's `data-theme-light`/`data-theme-dark` attributes, combined with the page's `data-bs-theme` attribute, using CSS nesting (supported in all modern browsers since late 2023).
gadenbuie added a commit to posit-dev/py-shiny that referenced this pull request Dec 31, 2025
Ports the `input_code_editor()` component from bslib PR #1274 to py-shiny.

## Overview
`input_code_editor()` is a lightweight code editor input with syntax
highlighting powered by prism-code-editor. It supports 20+ languages,
multiple themes, and automatic light/dark mode switching.

## Added
- `input_code_editor()` - Create a code editor input
- `update_code_editor()` - Update editor from server
- `code_editor_themes()` - List available themes

## Key decisions for human review

1. **Dependency structure**: The code editor uses two HTML dependencies:
   - `prism-code-editor` (vendored library for syntax highlighting)
   - `bslib-code-editor-js` (the Shiny input binding)

2. **Themes list**: Manually hardcoded the theme list to match the
   vendored CSS files. The R version discovers these dynamically, but
   Python doesn't have easy access to the vendored directory at runtime.

3. **Language validation**: Using a hardcoded tuple of supported languages
   instead of dynamic discovery, matching the R implementation's approach.

4. **Manual vendoring**: Due to issues with the automated vendoring script
   (ionRangeSlider CSS step fails), assets were manually copied from the
   bslib feat/input-code branch. Updated htmlDependencies.R to include
   the prism-code-editor copy for future runs once bslib PR is merged.

5. **Value handling**: The `value` parameter accepts either a string or
   a sequence of strings (lines), matching Python idioms.

6. **Fill behavior**: Added `html-fill-container` and `html-fill-item`
   classes for fillable layout support, similar to other fill components.

Related: rstudio/bslib#1274

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@gadenbuie gadenbuie requested a review from cpsievert January 5, 2026 19:31
Copy link
Collaborator

@cpsievert cpsievert left a comment

Choose a reason for hiding this comment

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

Well done and nice feature! 👏

One slight concern I have is that it makes the problem in posit-dev/py-shiny#2125 slightly worse (will add ~100 new files to shiny wheel). That said, I think we could cut ~300 (about 25%) by moving Theme to a separate package, so maybe that's the more worthwhile cost saving?

Copy link
Collaborator

@cpsievert cpsievert Jan 6, 2026

Choose a reason for hiding this comment

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

I'm tempted to say that, because this doesn't depend on Bootstrap Sass, it should get compiled separately and brought in with the code editor dependency instead.

I think this would simply things on the py-shiny end, but not sure if it's actually worth it or not -- your call

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah this is how I set it up initially and I'd be okay with moving back to it. I was trying to integrate everything into the existing build process a little more, but there's not too far you can go if we want to keep the code editor dependencies separate.

I'll switch it back. We don't really gain anything by keeping them together, but separating them would untangle the code editor enough that it would help us move it into a separate package if we ever needed to. It just wasn't super clear where I'd put that .css file: I had it in inst/components/dist/ initially, but didn't like that dist/ sounds like something you can delete and have re-built. Maybe could just be a file in components/...

Copy link
Member Author

Choose a reason for hiding this comment

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

I moved it into a static file that lives in srcts/src/components/codeEditor.css and gets moved into inst/components/dist during the build process.

Sys.glob(file.path(src, "*.css"))
)
core_files <- core_files[!grepl(theme_js_pattern, basename(core_files))]
file.copy(core_files, dest)
Copy link
Collaborator

Choose a reason for hiding this comment

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

I feel like it's maybe worth removing some of the CSS/JS that isn't needed? Here's a handful that Claude identified:

- autocomplete*.css, code-block.css, folding.css, guides.css
- extensions/cursor.js, extensions/guides.js, extensions/matchTags.js

Copy link
Member Author

Choose a reason for hiding this comment

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

Agreed we could probably cut more of these. I was thinking we might want to turn some of these on or enable them behind flags, which gets harder to do when the files aren't around.

I'll take a pass at slimming this down tomorrow in a way that would be easy for us to unroll later if we want to add extensions.

Copy link
Member Author

Choose a reason for hiding this comment

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

I went through and aggressively removed extensions and files we don't need. There might be one or two that we don't fully use, but it's significantly slimmer now.

While looking into this, I learned about a built-in method for adding tooltips to the editor, so I updated the editor to give decent visual feedback when you try to modify a read-only editor

image

@gadenbuie gadenbuie merged commit bc56d8e into main Jan 7, 2026
12 checks passed
@gadenbuie gadenbuie deleted the feat/input-code branch January 7, 2026 14:24

build.onEnd(() => {
copyFileSync(source, dest);
console.log("√ -", "code-editor.css", "-", new Date().toJSON());
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think you meant to log source or dest here?

Copy link
Member Author

Choose a reason for hiding this comment

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

thanks, fixed it over here: 93422f5

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.

2 participants