- name
- platform-renderers
- description
- How each platform renderer maps UI::View types to native elements across Web, macOS, iOS, and Android
- version
- 2.0
# Platform Renderers
Each platform renderer is a subclass of `UI::PlatformVisitor` that translates the abstract `UI::View` tree into platform-native elements. Only one renderer is compiled into any given binary, selected at compile time via `flag?()`.
## Compile-Time Selection
```crystal
{% if flag?(:macos) %}
require "./renderers/appkit_renderer"
alias PlatformRenderer = UI::AppKit::Renderer
{% elsif flag?(:ios) %}
require "./renderers/uikit_renderer"
alias PlatformRenderer = UI::UIKit::Renderer
{% elsif flag?(:android) %}
require "./renderers/android_renderer"
alias PlatformRenderer = UI::Android::Renderer
{% else %}
require "./renderers/web_renderer"
alias PlatformRenderer = UI::Web::Renderer
{% end %}
```
---
## Web::Renderer
**Target file:** `src/ui/renderers/web_renderer.cr`
**Compiled when:** No platform flag set (default), or explicitly for web targets
The web renderer delegates to the existing `Components::Elements` classes in the asset_pipeline shard. It does NOT reimplement HTML generation -- it maps each `UI::View` to the appropriate `Components::Elements` class and applies CSS styles.
### View-to-Element Mapping
| UI::View | Components::Elements class | CSS applied |
|----------|---------------------------|-------------|
| `Label` | `Elements::Span` | `font-family`, `font-size`, `font-weight`, `color`, `text-align`, line clamping |
| `Button` | `Elements::Button` | Foreground color, `data-action` attribute for reactive dispatch |
| `VStack` | `Elements::Div` | `display:flex; flex-direction:column; gap:{spacing}px; align-items:{alignment}` |
| `HStack` | `Elements::Div` | `display:flex; flex-direction:row; gap:{spacing}px; align-items:{alignment}` |
| `ZStack` | `Elements::Div` | `position:relative` with `position:absolute` on children |
| `Image` | `Elements::Img` | `object-fit` mapped from `ContentMode` |
| `TextField` | `Elements::Input` | `.text` or `.password` factory depending on `secure_entry` |
| `ScrollView` | `Elements::Div` | `overflow-x`/`overflow-y` based on scroll axes |
| `Spacer` | `Elements::Div` | `flex:1 1 0%; min-height:{min_length}px` or `min-width:{min_length}px` |
| `Toggle` | `Elements::Input` | `type="checkbox" role="switch"` |
| `Checkbox` | `Elements::Input` | `type="checkbox"` with label |
| `RadioGroup` | `Elements::Div` | Multiple `<input type="radio" name="...">` with shared name |
| `Slider` | `Elements::Input` | `type="range" min max step` |
| `Stepper` | `Elements::Input` | `type="number" min max step` |
| `SegmentedControl` | `Elements::Div` | CSS button group with `aria-pressed` on active segment |
| `NavigationStack` | `Elements::Div` | JS-router `<div>` visibility toggling, browser history API |
| `NavigationLink` | `Elements::A` | `<a href="...">` with SPA router integration |
| `TabView` | `Elements::Div` | `role="tablist"` nav + `role="tabpanel"` content divs |
| `NavigationSplitView` | `Elements::Div` | `display:grid; grid-template-columns: {sidebar_width}px 1fr` |
| `Toolbar` | `Elements::Header` | `<header>` or `<nav>` with action `<button>` elements |
| `ProgressView` | `Elements::Div` | `<progress>` (determinate) or CSS spinner (indeterminate Circular) |
| `ActivityIndicator` | `Elements::Div` | CSS keyframe animation spinner |
| `Alert` | `Elements::Div` | `<dialog>` element or modal overlay div |
| `Picker` | `Elements::Select` | `<select>` with `<option>` children |
| `IconButton` | `Elements::Button` | `<button>` with icon `<img>` or CSS icon class |
| `ListView` | `Elements::Div` | `<ul>` or `<div>` with repeated child elements and section headers |
| `SecureField` | `Elements::Input` | `type="password"` |
| `SearchField` | `Elements::Input` | `type="search"` with cancel button |
| `TextArea` | `Elements::Div` | `<textarea rows="N">` |
| `TextEditor` | `Elements::Div` | `<textarea>` with optional syntax highlighting via CSS/JS |
| `DatePicker` | `Elements::Input` | `type="date"`, `type="time"`, or `type="datetime-local"` per mode |
| `TimePicker` | `Elements::Input` | `type="time"` with step for minute_interval |
| `Grid` | `Elements::Div` | `display:grid; grid-template-columns:...` with child cells |
| `Form` | `Elements::Form` | `<form>` with `<fieldset>`/`<legend>` per section |
| `AsyncImage` | `Elements::Img` | `<img loading="lazy">` with placeholder swap via JS |
| `RichText` | `Elements::Div` | Multiple `<span>` with inline styles per Span record |
| `LinkButton` | `Elements::A` | `<a href="...">` styled as button |
| `MenuButton` | `Elements::Div` | `<button>` + hidden `<ul>` dropdown, shown on click |
| `ToggleButton` | `Elements::Button` | `<button aria-pressed="true/false">` |
| `Sheet` | `Elements::Div` | Fixed overlay from bottom with CSS animation, `is_presented` toggles visibility |
| `Popover` | `Elements::Div` | Absolutely positioned `<div>` relative to anchor, Popover API when available |
| `ConfirmationDialog` | `Elements::Div` | `<dialog>` with confirm/cancel buttons |
| `Snackbar` | `Elements::Div` | Fixed-position `<div>` at bottom with CSS slide/fade animation |
| `Card` | `Elements::Div` | `border-radius`, `box-shadow`, `background-color` |
| `Surface` | `Elements::Div` | `background-color`, optional `border` |
| `Divider` | `Elements::Div` | `<hr>` or `<div style="height:1px;background:...">` |
| `GlassBackground` | `Elements::Div` | `backdrop-filter:blur(Xpx); background:rgba(...,0.5)` |
| `Circle` | `Elements::Div` | `border-radius:50%; width/height={size}px` or SVG `<circle>` |
| `Rectangle` | `Elements::Div` | `<div>` with explicit width/height and background |
| `RoundedRectangle` | `Elements::Div` | `border-radius:{corner_radius}px` |
| `Capsule` | `Elements::Div` | `border-radius:9999px` |
| `Canvas` | `Elements::Div` | `<canvas>` element; operations serialized to JS 2D context calls |
| `PathView` | `Elements::Div` | SVG `<path>` element (stub) |
| `MapView` | `Elements::Div` | `<div id="map">` + Leaflet.js / Google Maps JS API |
| `ChartView` | `Elements::Div` | `<canvas>` + Chart.js or D3.js integration |
| `WebViewComponent` | `Elements::Div` | `<iframe src="...">` |
| `ColorPicker` | `Elements::Input` | `type="color"` |
| `VideoPlayer` | `Elements::Div` | `<video src="..." controls>` |
| `Tooltip` | `Elements::Div` | `title` attribute or custom `:hover` CSS tooltip |
### CSS Strategy
- Layout CSS uses utility classes: `ui-vstack`, `ui-hstack`, `ui-spacer`, etc.
- Inline styles handle dynamic values (spacing, colors, fonts)
- Base class modifier properties (`corner_radius`, `shadow_*`, `border_*`, `blur_radius`) are applied as inline CSS on every rendered element
- The renderer produces standard HTML that works with the existing `ReactiveHandler` WebSocket system
### Integration with Components System
The `UI::ViewAdapter` class bridges the UI view tree into the reactive component system:
```crystal
class UI::ViewAdapter < Components::Reactive::ReactiveComponent
def render_content : String
renderer = UI::Web::Renderer.new
@root.accept(renderer)
renderer.output
end
end
```
Existing `StatelessComponent`, `StatefulComponent`, and `ReactiveComponent` subclasses continue working without modifications.
---
## AppKit::Renderer (macOS)
**Target file:** `src/ui/renderers/appkit_renderer.cr`
**Compiled when:** `flag?(:macos)` — you MUST pass `-Dmacos` to crystal-alpha
**Bridge:** `src/ui/native/objc_bridge.m` (compile with `clang -c ... -fno-objc-arc`)
**Build prerequisites:**
```bash
# 1. Compile the ObjC bridge (required — provides typed objc_msgSend wrappers)
clang -c src/ui/native/objc_bridge.m -o objc_bridge.o -fno-objc-arc
# 2. Build with -Dmacos flag and link the bridge
crystal-alpha build app.cr -o app -Dmacos \
--link-flags="objc_bridge.o -framework AppKit -framework Foundation -lobjc"
```
Maps `UI::View` types to AppKit `NSView` subclasses via the ObjC runtime C API.
### View-to-NSView Mapping
| UI::View | AppKit class | Configuration |
|----------|-------------|---------------|
| `Label` | `NSTextField` | Non-editable, `setBezeled:NO`, `setDrawsBackground:NO` |
| `Button` | `NSButton` | `NSBezelStyleRounded`, target-action via `CrystalActionDispatcher` |
| `VStack` | `NSStackView` | `orientation: .vertical`, `spacing`, `alignment` |
| `HStack` | `NSStackView` | `orientation: .horizontal`, `spacing`, `alignment` |
| `ZStack` | `NSView` | Children added as subviews with manual frame layout |
| `Image` | `NSImageView` | `imageScaling` mapped from `ContentMode` |
| `TextField` | `NSTextField` | Editable, delegate for change notifications |
| `ScrollView` | `NSScrollView` | `hasVerticalScroller`/`hasHorizontalScroller` |
| `Spacer` | (no view) | Flexible space via NSStackView gravity areas |
| `Toggle` | `NSButton` | `setButtonType: NSButtonTypeSwitch` (switch style) or `NSButtonTypeCheck` (checkbox style) |
| `Checkbox` | `NSButton` | `setButtonType: NSButtonTypeCheck` (buttonType=check) |
| `RadioGroup` | `NSMatrix` or group of `NSButton` | `setButtonType: NSButtonTypeRadio` per option |
| `Slider` | `NSSlider` | `minValue`, `maxValue`, `numberOfTickMarks` for step |
| `Stepper` | `NSStepper` | `minValue`, `maxValue`, `increment` |
| `SegmentedControl` | `NSSegmentedControl` | `trackingMode: .selectOne`, one segment per label |
| `NavigationStack` | Custom `NSViewController` stack | Push via `presentAsModalWindow:` or custom stack |
| `NavigationLink` | `NSButton` | Action triggers push on parent NavigationStack |
| `TabView` | `NSTabViewController` | One `NSTabViewItem` per `Tab` record |
| `NavigationSplitView` | `NSSplitViewController` | `NSSplitViewItem` per column |
| `Toolbar` | `NSToolbar` | `NSToolbarItem` per ToolbarItem; window decoration |
| `ProgressView` | `NSProgressIndicator` | `style: .bar` (Linear) or `.spinning` (Circular); `isIndeterminate` when value=nil |
GitHubで見る