@maxenve/ren-canv
v0.1.14
Published
The engine of ren-core's drawn renderers: a retained scene of the portable primitives with its own layout, portable style, text, painting into a flat command buffer, damage, hit testing, input, editing, selection, native text entry and an accessibility mi
Readme
ren-canv
The engine of ren-core's drawn renderers. A Document is a ren-core Host
(ren-core/host) over a retained scene of the portable primitives (View,
Layout, Scroll, Label, Edit, Action, Image, Shape). It lays the
scene out, measures text through a Fonts, paints it into a flat typed
command buffer, finds the damaged rectangles between frames, hit-tests and
routes press, key, wheel, focus, editing and IME composition, and in a
document keeps a native text entry and an accessibility mirror beside the
canvas. Plain JavaScript with JSDoc types, strict tsc checking and
generated declarations.
A renderer package subclasses Document and replays its frames on a surface:
| Package | Measures text with | Draws on |
| --- | --- | --- |
| ren-skia | the font file's own advances (Sfnt), shaped where text needs it | a CanvasKit surface |
| ren-three | the font file's own advances (Sfnt), shaped where text needs it | a 2D canvas, as a three.js texture |
The package is published on npm as @maxenve/ren-canv under the MIT license;
install it under its import name: npm install ren-canv@npm:@maxenve/ren-canv.
Its runtime dependencies are ren-core (linked
from the sibling checkout), taffy-layout (pinned; MIT) and harfbuzzjs
(pinned to 1.6.2; MIT: HarfBuzz's WebAssembly build, of which only its
dist/harfbuzz.wasm is used).
| Entry | What |
| --- | --- |
| ren-canv | Document, render, attach, layout (loadLayout, useLayout, remoteLayout, serveLayout, Flow, CanvasFonts), shaping (loadShaper, useShaper, and the Shaper and Glyphs types), the events (Press, Pointer, Change, Key, Scrolled), Item, Theme, Sfnt (the font file's measure), Faces (typefaces over font files: by family name and as fallbacks), the command buffer for tests (Frame, decode, commands, operands, hex, Damage), and the Fonts, Typefaces and Options types |
| ren-canv/worker | a layout worker's imports: serveLayout, loadLayout, useLayout |
| ren-canv/backend | what a renderer package replays with: the opcodes (TEXT_OP, RECT, ROUND, CLIP, SAVE, RESTORE, LINE, OVAL, PATH, IMAGE_OP, BORDER, SHADOW, LAYER), the face bits (BOLD, ITALIC, MONO, RTL, and SLOT, the shift of a typeface's slot), reversed (a right-to-left run in the order it is drawn from its left), commands, operands, Frame, Damage, Sfnt, the 2D canvas replay Replay2D, and the Fonts and Picture types |
Layout: a replaceable engine, Taffy by default
Layout follows ren-core's portable style (CSS flexbox and grid, with CSS's defaults). The DOM keeps the browser's own layout. The engine sits behind a neutral boundary (src/engine.js), so it can be replaced or reimplemented without touching anything else:
Boxes(src/layout.js) keeps a layout node per item, in step with the scene, as flat commands (new node, style, per-node content sizes, children, remove) whose styles are plain CSSBoxrecords (display,direction,padding,minWidth,columnsas CSS text, ...). Text is measured here: one-line text, Actions and Edits send their content as their minimum and basis (one stretched across a parent too short for it, found after a layout, keeps its content as a child instead: CSS's automatic minimum is along the main axis only); wrapping text has a content child, a wrapping row of zero-high pieces as wide as its text side by side, the widest its longest word, so it has CSS text's max-content and min-content widths with no measuring callback; it is as high as its rows at the width it got (a second pass settles heights). A box sized to fit its content (no width of its own, not stretched across a column, or absolute) is fitted after a layout as CSS's fit-content (fits): Taffy lays a row that does not wrap, a box holding one and a grid with flexible columns (minmax(0, 1fr),minmax(80px, 2fr), lengths) at their max-content past the room they have, and an absolute box in its whole containing block, not in the room its insets leave it; a box found wider than its room is as wide as that room from the next layout (its min-content when that is more, found from what the scene knows: set widths, text's longest word, a grid's tracks' minimums, a row's items summed; a box whose min-content is not known so, a percentage or a track sized by its content, keeps the engine's width). It follows the room up to its max-content, and is laid out at its max-content again when its content changes. A tree the engine lays out right costs nothing more; a fitted box costs a layout pass or two more where it changes (node, ABBA, N=20, median, 1,000 boxes: a first layout 1.6 times, a resize 4.9 times).- A
Solveris the engine itself:leaf,define(a Box),set,sized,children,remove,compute,read.useLayout(make)sets one. The default is Taffy throughtaffy-layout(its WebAssembly build, pinned to 3.0.0):await loadLayout()loads and sets it (in node it reads the module beside taffy-layout; in a browser pass the URL oftaffy_wasm_bg.wasm, which ren-show copies beside its page astaffy.wasm, or its bytes). src/taffy.js is the only module that imports taffy-layout; ren-skia and ren-three re-exportloadLayout. Its cost on a 4,000-node tree (1,000 wrapping rows of three leaves under a page column; node, median of 7): build 3.6 ms, full layout 13.8, relayout at a new width 12.7, 100 leaves changed 2.4, reading every box 5.0. No other WebAssembly or JavaScript engine on npm lays out materially faster with grid support; the cost is the WebAssembly build's (Taffy's algorithm runs 2.4 times faster natively), and the layout worker takes it off the main thread. - Where it runs: in the Document's thread by default (boxes are read only
for the items drawn), or in a worker. There,
serveLayout(port)fromren-canv/worker(withloadLayoutoruseLayout) serves the Documents givenlayout: remoteLayout(port): the commands go over the port, the worker lays the page out and sends back only the boxes that changed, and the Document paints when they come. A change that moves no box paints at once. One worker serves any number of Documents. - CSS's initial content alignment holds: without a
justify, a Box'sjustifyis'normal', Taffy's own default (the start in a flex container; a grid'sautotracks stretched over its free space, as a browser shares it), andalign-contentis never set (a grid's auto rows stretch too). - Right to left (ren-core's
dir, HTML's, inherited: the Box'sdir), a box's children are placed from its right, as CSS'sdirection: rtl: a row's main axis and a grid's columns start at the right, and so dojustify,alignandselfstart (their end at the left); insets and margins stay physical. Taffy lays out left to right only, so the adapter mirrors (src/taffy.jsmirror, as a box is read): a child's margin box is mirrored in its parent's content box (x' = left + right - x - width, its margins swapped), a relative offset is taken right to left (rightbeforeleft), an absolute box placed byrightis placed from the parent's padding box's right (byleftit stays, by neither its static place is mirrored), and a Scroll's bar's room falls on its left; each level mirrors in its parent, here and in a layout worker. A tree with no right-to-left box reads its boxes unmirrored (one count,rtl, says there is none). A right-to-left Scroll keeps its bar down on its left and scrolls across from its right: its content overflows to the left, and itsscrollLeftis 0 at its start and below 0 toward its end (a wheel to the right goes back toward its start), as the DOM's. A Scroll scrolls as far as its content reaches past the room inside its border (scrollHeightlessclientHeight). - Chrome is the oracle: test/oracle.test.jsx lays a hundred and fifty views out
(flex, grid, wrapping and clipped text, sizes, insets, alignment, right
to left) in
DejaVu Sans and checks every box against the one Chrome gives the
markup ren-dom writes for it, within a 64th of a pixel
(
scripts/layout-oracle.mjsrecords them). Flowlays the primitives out without drawing them, for a renderer that draws them itself: ren-dom'slayoutoption (see ren-dom). Each island (the top of a view) is laid out at the width it is given;arrange(done)gives the items whose boxes changed, here or from a layout worker. Itsscaleoption (the window's devicePixelRatio by default) is what borders snap to, as the browser snaps them;rescale(scale)changes it, andretheme(tokens)takes a new theme's metrics (a field's height, an entry's width, the text size and leading: ren-dom'ssetThemecalls it), every item laid out on them at the nextarrange.CanvasFontsmeasures with a 2D context in a CSS font family, as the browser sets DOM text. A measure gets the face (BOLD,ITALIC) text is set in.- Placement is CSS's with every primitive positioned:
position: 'absolute'takes an item out of the flow and places it by its insets (top,right,bottom,left,inset; px or percentages) against its parent's padding box,'relative'(or none) moves it by them from its place in the flow; sizes take percentages. The Box carries them (position,inset) to the engine, here or in a layout worker. The overlay (the layer portals render into) is as wide and as high as the viewport, soinset: 0and percentages there cover it, as a DOM portal layer does. A sticky item (position: 'sticky') is laid out in the flow (its insets move nothing there) and painted held by itstop,right,bottomandleft(px, or percentages of the scrollport) inside its nearest Scroll's scrollport (its padding box less its padding and bars; the viewport without one), its margin box never past its containing block's content box (a Scroll's content as far as it reaches, to its left right to left); where both insets of an axis cannot hold, the start's wins (rightright to left). ItsgetBoundingClientRect()is where it is held, as the DOM's. test/rtl-oracle.test.jsx checks every box, scrolled across left to right and right to left and down, against Chrome's (scripts/rtl-oracle.mjsrecords them). A fixed item (position: 'fixed') is laid out out of the flow and painted after its layer, in z order, placed by its insets against the viewport, over the page's scroll and its ancestors' clips (itsgetBoundingClientRect()is that place); its sizes (percentages too) are laid out in its parent, but set between two insets on an axis without a size (leftandright,topandbottom) it is as long as the viewport between them less its margins, as CSS's (the page's viewport stops at its scrollbar), and follows a resize. display: 'contents'(a View, Layout or Scroll's) makes no box, as CSS's: the item's layout node lists nothing and its parent's node lists its children in its place (Boxeslists them again when they change, or when it goes boxless or back; hidden, it is a box again, with no display, and its children go with it), so they lay out as its parent's, here and in a layout worker (a table row withrole="row"whose cells sit in the table's grid). Its box's own style does nothing (padding, border, margin, sizes, placement, paint, transform, opacity, clip); text style passes through it and pointer events bubble through it; it is a press, hover and pointer target where its children are (its hit regions are their boxes, under theirs). Its box is empty at its parent's corner (itsgetBoundingClientRect()too). A sticky child of it is held within the nearest ancestor that makes a box (its containing block, as CSS's: a sticky header cell in a boxless row stays within the table).- The wheel goes to the Scrolls around the topmost box under the pointer, innermost first (then the page), as the DOM's: an item lying over a Scroll without being in it keeps the wheel from it.
- Painting follows CSS's stacking contexts. Items paint in tree order;
an item with a
zIndex(or anopacitybelow 1, at 0), the page and the overlay are contexts. The contexts met inside one with a negative z paint over its own box and under its children, those with a positive z over everything else in it, in z order and equal ones in tree order, in the clip they were met in; a z of 0 paints in place. Hit regions follow the painting order, so the topmost takes the pointer. Pieces drawn out of the flow's order are damaged when their order alone changes. - A box that clips (
overflow: 'hidden', a Scroll) clips its content to its padding box's rounded corners, as CSS does (the radius less the border: therclipcommand); hit regions keep the rectangle. Each corner may have its own radius (fills, borders, shadows, focus rings and clips:rround,rborder,rshadow), scaled to fit the box as CSS scales them. The focus ring is a 2 wide stroke outside the box, as the DOM's outline; an item whoseringis false draws none, and those inside it marked'outer', or, with none, the nearest item around it marked'inner', draw it along their own corners. Borders take each side's width and color (edges, split at the corners as CSS splits them) and a style: dashed (dashes and gaps three widths long) or dotted (round dots), throughdash. A gradient background fills the box in its corners (gradient): linear along CSS's gradient line at its angle, or radial from the center to the farthest corner. A Shape figure'sfill(a rect, round, oval or closed path, not a stroke) is a color in place of itscolor, or a gradient laid the same way over its bounds (a path's points' box): theshadecommand before the figure, which a replay gives the next fill alone. - An opacity below 1 composites the item in a layer (
layer), except an empty box that draws one fill (no border, shadow, ring or gradient): its fill takes the opacity in its alpha, the same pixels without an offscreen layer. Measured with ren-bench's opacity harness (60 pulsing empty round boxes, node, CanvasKit raster, 400 frames, ABBA, load 9.4-10.4): a frame 19.8 / 21.1 ms with layers, 1.43 / 1.48 ms folded (the replay 19.4-20.7 → 1.07-1.12 ms). An opacity-only restyle runs no layout (the engine is not called: its style key does not change); the scene's sync costs 0.19 ms a frame there. - An Image fits its box as the style's
objectFitsays (picture: contain, cover or none, centered and clipped; fill drawsimage). A renderer's replay tells the Document a source that failed (failed(source): Replay2D's and ren-skia's do): its Images hearonError, as a DOM img'serror, and so does an Image that reaches the source later, with no load again. When the replay's cache evicts a failed entry, it tells the Documentforgot(source)(an optional argument besidefailedin Replay2D's and ren-skia's constructors): the next draw loads the source again, and only a failure of that load is an error. - Transforms (
scale,rotate,translateabouttransformOrigin, as CSS's, thenskewXandskewYas CSS'stransform: skew()after them) are painted (transform, around the item and what it holds, a Label's text too) and hit-tested (a point maps into the item's space for presses, pointer offsets, hover, text selection and wheel), never laid out; a transformed item is a stacking context, and its damage is the bounds of its transformed pieces.getBoundingClientRect()gives the bounds of its box under its own and its ancestors' transforms, as the DOM's.test/paint.test.jsxchecks the paint commands. - Text is set in a face (
BOLD,ITALIC, andMONO4 for a family naming monospace, inherited): a measure (Fonts.width,metrics) and the text command's flags get it, and a renderer draws MONO in its monospace typeface when it has one. Weights take the nearest face: 600 and above bold. - Typefaces: a style's
fontFamily(inherited) is set in the typeface the Fonts give for it (Fonts.typeface: the slot of the first name of the list it has, before a generic family; the root face otherwise), its slot carried in the face (face >>> SLOT). Text a typeface lacks falls back by cluster, as Chrome's: a character with the marks, joiners and modifiers that continue it is measured and drawn in the first of the typeface and the fallbacks that has all of it (the default ignorables need no glyph), in the typeface's own missing glyphs when none has (Fonts.faces: a piece in a fallback is a text command in that typeface's slot). A mark only a fallback has takes its base there when the fallback has the base too; a base only a fallback has stays in the typeface, as its missing glyph, under a mark only the typeface has.Facesis such Fonts over font files (font,mono,familiesby name,fallbackin order), which ren-skia and ren-three build from their options; a character no typeface has measures as the missing glyph. Each piece is shaped in its own typeface (below). - Shaping: text whose script or marks a shaper sets otherwise than by the
typeface's advances is measured, drawn, hit and selected as its shaped
glyphs: Arabic and the other joining scripts joined (lam-alef one
glyph), Indic and the other complex scripts in their clusters, marks
placed on their bases, default ignorables (a soft hyphen, joiners, bidi
controls) hidden. Which text that is (
complexin src/shape.js): a mark, a format character, a character of a joining or complex script (Arabic, Syriac, N'Ko, the Indic scripts, Thai, Lao, Tibetan, Myanmar, Khmer, ...), Hangul jamo, emoji modifiers and flags. Everything else (Latin, Greek, Cyrillic, CJK, Hebrew without points) keeps the advance path, one scan of the text on a measure that misses the cache: a shaper would set it at its advances too, since kerning and the optional ligatures (common, discretionary, historical, contextual) are off, as ren-dom sets DOM text (font-kerning: none,font-variant-ligatures: none; a script's required features stay). Chrome with its defaults kerns and tiesfi,ffiin DejaVu Sans; with those two off it sets Latin at the advances, as the canvases do.- The shaper is replaceable: the engine sits behind a neutral boundary
(src/shape.js). A
Shapergives a font file's typeface a handle (face) and sets a run of one script in its glyphs (shape(face, text, start, end, rtl, features): ids, advances, offsets and cluster indices, in font units, in visual order).useShaper(shaper)sets one, or none (null: the advances). The default is HarfBuzz, the shaper Chrome uses, through the pinned harfbuzzjs package's WebAssembly (src/harfbuzz.js is the only module that knows it, calling HarfBuzz's C functions itself: the package's own entry instantiates its module at import from a file beside its script).await loadShaper()loads and sets it: in node it readsharfbuzz.wasmbeside harfbuzzjs; in a browser pass its URL (copied beside the page, astaffy.wasm), a Response, the bytes or the compiled module. In node it also loads on demand, at once, the first time text needs shaping (unlessuseShaperchose); in a browser text measured beforeloadShaperis set by the advances. Shaping runs where text is measured, the Document's thread: a layout worker gets content sizes and needs no shaper. The module is 434 KB (harfbuzz.wasm); a typeface's file is copied into its memory once per content. - Cost (node, load under 3): Latin keeps its cost, ren-bench's Skia frame harness (1,000 rows, damage arm, ABBA, N=20, median paired ratio) within 0.995–1.006 of the advance path on every operation, its A/A control 0.984–1.000; a width not cached costs 1.1 µs for 50 Latin characters and 21 µs for 50 Arabic ones shaped (1.1 µs by their advances); a cached width costs the same either way.
- Text is shaped per typeface piece (a fallback's piece in the fallback),
per run of one script (neutrals join the run before them), each in its
direction, the text around a run its context; a right-to-left piece
lays its runs from the right.
Sfnt.shapedcaches the glyphs by text and direction;Sfnt.widthis the shaped advance. - Carets follow clusters (
Fonts.offsets): the caret at an offset is the advance of the clusters before it in the text's order, a ligature's advance shared evenly among the grapheme clusters it holds, an offset inside a cluster at its start. The caret, the selection's pieces, a point's offset (offsetAt, a press in an Edit) and a row's hits read them. Rows wrap by the widths of their texts, each row shaped on its own. - Drawing agrees with measuring: ren-skia draws the shaped glyph ids at
their places (the same
Sfnt.shaped); a 2D canvas (Replay2D, ren-three) draws the string with kerning off andtextRendering: 'optimizeSpeed'(no optional ligatures), the browser shaping it with the same HarfBuzz and features, so at the measured width. - Chrome is the oracle: test/shaping-oracle.test.js checks the widths,
each grapheme cluster's box and the caret before it in lines of
Arabic (joined, with marks, lam-alef), Hebrew with points, Devanagari
(Noto Sans Devanagari as a fallback), Latin with marks, Latin, and
marks one typeface lacks (fallback by cluster) against Chrome's, within a 32nd of a pixel
(
scripts/shaping-oracle.mjsrecords them).
- The shaper is replaceable: the engine sits behind a neutral boundary
(src/shape.js). A
- Right-to-left text: each row is laid out by the Unicode bidirectional
algorithm (src/bidi.js, UAX #9 of Unicode 16.0.0, its data generated by
scripts/bidi-data.mjs; test/bidi.test.js runs a subset of the Unicode
BidiCharacterTest cases, and the whole file passes) as a paragraph in
the item's direction: left to right, the DOM's default, or right to left
with
dir="rtl"(inherited;dir="auto": the direction of the first strong character of its text, an Edit's value as it is typed, a container's descendants' past those with adirand Edits, left to right without one, as Chrome resolves it). A row that runs left to right whole (no right-to-left character, left to right) is drawn, measured and hit as it is (one scan of its characters,orderin src/text.js); otherwise its level runs are drawn in their visual places, a right-to-left run a text command of its own (RTL, its x its right edge) that a replay draws reversed and mirrored (ren-skia) or sets right to left (a 2D canvas).textAlign'start' and 'end' land on the sides of the item's own direction (inherited as given: 'start' is the right of a right-to-left child); an Edit takes only its own (at its start without one) and an Action centers, as a browser's form controls reset it (test/rtl-oracle.test.jsx checks the side against Chrome's field). The caret stands at the start edge of the character after it (its right in a right-to-left run); the arrow keys move it in the text's logical order the way its direction runs (right to left, the left arrow goes on), as Chrome's fields do; a point maps to the offset nearest it in the run under it, and a selection shows as a piece for each visual run it reaches. A right-to-left Edit sets its value at its right, its caret 2 wide on its left side; one line wider than the field starts at its right and scrolls the least that shows the caret, and a multi-line Edit's rows align at the right, the spaces a soft wrap leaves hanging past its left (indentin src/layout.js, which drawing and hitting share). The accessibility mirror's element and the native text entry take the item'sdir. test/bidi-oracle.test.js checks each character's place in rows of Hebrew, Latin, digits and brackets, left to right and right to left, against Chrome's (scripts/bidi-oracle.mjsrecords them); test/direction.test.jsx checks the rest. - Adjacent text in a container (
<View>Count: {n}</View>) is one anonymous box, as CSS wraps a run of text: its first text node lays out, draws and selects the run's text, the others take no room. It is CSS'swhite-space: normal, as on the DOM: it wraps, each run of spaces, tabs and line breaks is one space, none is left at its ends, and white space alone takes no room. Inside an Action it keeps its spaces and line breaks on one line (pre, a DOM button's). A Label keeps them always. - Text inherits color, size, weight, italic, alignment and line height as in CSS, its decorations (underline, strike) reach the text below, and it sits in its line box where CSS puts it; the page takes its scrollbar's width while it overflows; painted boxes snap to device pixels.
textTransformshows text in a case, as CSS's text-transform (casedin src/text.js): uppercase and lowercase as JavaScript's (the whole text, so a final sigma lowers as it should), capitalize the first letter of each word as ICU's word breaks find them (Intl.Segmenter, as Chrome's: "quick-brown fox's" is "Quick-Brown Fox's"). It inherits, but an Action and an Edit take only their own (a browser's form controls reset it), and an Edit shows its value as typed. The item keeps the text given (itstext, runs and TEXT children, which ren-json reads); what is measured, wrapped, painted, selected and copied is the text shown, as Chrome copies a transformed text. It keeps the text's length, so runs, selection andoffsetAtkeep their offsets: a character whose case takes more or fewer UTF-16 units keeps its own (ßstaysßin uppercase, where Chrome showsSS).- Rounding as Blink's (checked box by box against headless Chrome,
src/units.js): a text width up to a 64th of a pixel, each styled run's on its own; a line box the font size to the nearest 64th times the leading, down to a 64th; text set at its size down to a 64th (Sfnt.sized, as FreeType takes sizes); lengths (padding, margins, gaps, sizes, insets) in 64ths toward zero; borders in whole device pixels, one thinner than a pixel as one; and the canvas's own sub-pixel place kept as a shift. Percentages are Taffy's, in 32-bit floats; Taffy wraps a flex line that overflows by any amount, so a percentage main size in a wrapping row (a height in a wrapping column) is shaved by 2^-16 of itself (at most 0.015 px at 1000 px, under the 64th Chrome floors away), and percentages that sum to 100% keep one line, as in Chrome. The adapter (src/taffy.js) also holds a grid in a column of fixed height from shrinking below its content, and clamps a size in pixels by its minimum and maximum before a flex column sized by its content uses it.test/layout.test.jsxpins flexbox and grid to the numbers a browser computes;test/worker.test.jsxlays out in a worker exactly as in the thread, and through a replacement engine.
Input as the DOM has it
The same component code handles input the same way on the DOM and on the canvases: the event names, fields and semantics are the DOM's.
- Pointer events.
onPointerDown,onPointerMove,onPointerUp,onWheel,onPointerEnterandonPointerLeave. The event (Pointer) hasxandyin the surface's logical pixels (alsoclientXandclientY; these cross to a worker),offsetXandoffsetYfrom the target's padding box,button,buttons,pointerId,pointerType('mouse','touch'or'pen': the platform's,'mouse'without one), the modifier keys, and a wheel'sdeltaX,deltaYanddeltaMode. Its target is the topmost box drawn under the pointer (the DOM's element under it; offsets are from its padding box), and it bubbles through the target's ancestors' handlers (stopPropagation,target,currentTarget); enter and leave go to each item entered or left and do not bubble. Nothing holds the pointer until a handler captures it:e.currentTarget.setPointerCapture( e.pointerId)keeps its moves and release on that item until the release (as on the DOM; a worker cannot capture).preventDefaulton a pointerdown keeps its own response (focus, a selection), as a DOM mousedown's; the press still happens (a click is not its default); on a wheel, the scroll.attachpasses the browser event's buttons, pointer type, modifiers and deltas, and scrolls lines and pages as far as a browser does. A pointerdown carries no click count (Chrome'sdetailis 0, and a prevented one sends no mousedown), so the Document counts runs of clicks itself from the presses' platform times: the same button within 500 ms and 4 px of the last press is its next click (a double or triple click); a count the platform does give wins, and a press made by code keeps its own.preventnames default actions the canvas cancels, as ren-dom does: over an item that namescontextmenu(or under one that does), the browser's context menu stays away, for the app's own (cancels(type, x, y)answers for a point); elsewhere thebehaviordecides (§Behavior: an app keeps it in an Edit only;menu(x, y)answers). A touch follows the style'stouchAction, as CSS's touch-action: the topmost item under the press and each ancestor that sets one allow pans and a pinch zoom together (touchAction(x, y)answers the bits: 1 pan-x, 2 pan-y, 4 pinch-zoom, -1 none set). They allow nothing: its touchstart is cancelled; some pans: its first move is, when it goes along a forbidden axis (the larger of its moves across and down); a second finger's start and moves, when they forbid a pinch zoom. Where none sets one, a touch that presses something (a handler, a selection, a field) keeps the page from panning (its touchstart is cancelled), so a drag on the canvas stays the app's, and a touch on nothing lets the page scroll. When the browser takes the pointer back anyway (a pan or zoom the items allow, a palm),cancel(x, y)sendspointercancelto the item holding the pointer (captured), else the one under where it last was (the browser's cancel says 0, 0), bubbling, and ends the press with nopointerup, as the DOM's. A press of another button (a right press) reaches the pointer handlers alone (secondary), so an app can open its own menu there; it presses, focuses and selects nothing, as a DOM click is the main button's, and underattachit leaves a focused Edit's native entry the DOM focus and its selection (its mousedown's default, which would focus the canvas, is cancelled). - Presses. A press reaches the topmost box drawn under the pointer,
and the nearest of it and its ancestors that takes presses (an Edit, an
Action, an item with
onPress), as a DOM click reaches its element: a box lying over a target without being inside it keeps the press from it, unless its style'spointerEventsis'none'(inherited; a child's'auto'takes the pointer again): then presses, hover, pointer events, the wheel and text selection go through it, as CSS's pointer-events. The target is the one under the pointer before the pointerdown handlers ran, as the DOM's: one that lays a backdrop over it leaves it focused. The press (Press:x,y,target,currentTarget,stopPropagation) bubbles from that item through its ancestors'onPress, as a click does, from a pointer, Enter or Space on a focused Action, and the mirror. A press focuses the Edit or Action it presses, else the nearest View, Layout or Scroll with atabIndexaround the point, as a DOM click focuses its nearest focusable ancestor. - Inert.
inerton any primitive makes a region that takes no part in input, as the DOM'sinertattribute: nothing in it takes the focus (Tab,focus()andfocusFirstpass it by; made inert while it holds the focus, it gives the focus up), the pointer goes through it to what is under it aspointerEvents: 'none'would send it, with no child taking it back, its text is not selected, and the accessibility mirror marks its elementinert, which leaves it out of the accessibility tree. - Keys.
onKeyhears the key pressed while an item has focus, from the focused item up through its ancestors (Key:key, the modifiers,preventDefaultkeeps a field from editing,stopPropagation). Then the root's handlers hear it (ren-core's$keys,listen(root, 'keys')), as a document's keydown listener hears it last, and with nothing focused they alone do; a focusedstopPropagationkeeps it from them. Chords (Ctrl, Cmd or Alt with a character) reach the handlers and edit nothing;attachlets the platform have them (copy) unless one prevents it, and Ctrl or Cmd+A selects all after them, as thebehaviorallows (§Behavior). In an Edit, Ctrl or Cmd+Z undoes and Ctrl or Cmd+Shift+Z or Ctrl+Y redoes, from the Edit's own history (§Text actions), never the native entry's. A modifier with an arrow, Backspace, Delete, Home or End (a word or line move, a word deletion) is the native entry's underattach, which edits and mirrors back; without one the plain key's move stands in. ArrowUp and ArrowDown move a multi-line Edit's caret along its drawn (wrapped) rows, keeping its x through a run of them, and Home and End go to a drawn row's start and end, as a textarea's (test/rows.test.jsx replays Chrome's recorded moves). The key'seditableis true while an Edit has focus, so a root-wide shortcut can leave keys typed in fields alone. - Root-wide presses. Every pointer press (
press,secondary, a touch's) on the page or the overlay reaches the root's handlers (ren-core's$presses,listen(root, 'presses')) after the item pressed and its ancestors heard it (theirstopPropagationkeeps it from them), as a document's pointerdown listener hears it last: the pointerdown's event (button,pointerType,target, the point), its target the page on nothing. ItspreventDefaultkeeps the press's own action as an item handler's does; it counts as no handler, so a touch on nothing still pans the page. - Root-wide scrolls. When a Scroll scrolls (the wheel, a ref's
scrollTop), the root's handlers (ren-core's$scrolls,listen(root, 'scrolls')) hear it first, as a document's capture-phase scroll listener, then itsonScroll, each with its own event (a scroll does not bubble: itsstopPropagationends nothing); the page's own scroll reaches them too, its target the root. A popup follows its target through it. - Element methods. A ref is an item with the DOM's methods:
focus()(on an item that cannot take the focus, a Label or a View without atabIndex, or in an inert region, it does nothing, as on a DOM element that is not focusable),blur(),setPointerCapture,getBoundingClientRect(), on a ScrollscrollTopandscrollLeft(settable),scrollHeight,scrollWidth,clientHeight,clientWidth(a Scroll scrolls along itsaxis:'y'by default,'x'or'both', with a bar along each; the wheel'sdeltaX, and Shift with the wheel, scroll across), on a LabeloffsetAt(x, y)(the offset in its text nearest a point of the surface, where it was last drawn; -1 when not: the DOM's counterpart isdocument.caretPositionFromPoint), and on an Edit an input's caret API:setSelectionRange(start, end, direction)(clamped to the value; an end before the start collapses at the end; the caret at the end unless'backward'),selectionStartandselectionEnd(settable) andselectionDirection, andcaretRow(): the drawn row its caret is in and its row count,{ row, rows }from 0 (the caret where a row wraps on the next row, unless End put it at the row's end; a one-line Edit's is row 0 of 1), test/rows.test.jsx checking them against the rows Chrome wraps a textarea into. An unfocused Edit keeps its selection, as a DOM field does: the one it had when it gave up the focus, or one set so, until it gets the focus again (at the end of its value before either, or after a new value is set); a Scroll sendsonScroll(Scrolled) as it moves.getBoundingClientRect()gives the item's border box where it was last laid out, in the DOM's shape (x,y,left,top,right,bottom,width,height) and the Document's CSS pixels (its canvas's; a ren-three panel's own), the offsets of the Scrolls around it and of the page applied, a sticky item where it is held, a fixed one where it is placed, and transforms applied (the bounds), as the DOM's; it does not lay out. A component measures an anchor with it on every renderer. - Scrollbars. A Scroll draws a bar along each axis it scrolls while
its content overflows: the theme's
barwide, a muted round thumb on a soft track, with that room kept beside its content whether or not it overflows (the DOM'sscrollbar-gutter: stable). Its style'sscrollbarWidth(CSS's scrollbar-width, its own) makes it half as wide ('thin') or takes the bar and its room away ('none': the wheel, touch andscrollTopstill scroll it), andscrollbarColor([thumb, track], inherited as CSS's scrollbar-color) colors it. - Tab order. Tab and Shift+Tab move among the items Tab reaches: the
Edits and Actions, and the Views, Layouts and Scrolls whose
tabIndexis 0 or more, shown, enabled and not inert, those with atabIndexabove 0 first (ascending), then the rest in document order, as the DOM's; atabIndexof -1 keeps an Edit or Action out of that order (focusable from code and by a press) and makes a View, Layout or Scroll focusable without joining it (a skip link's target): Tab from it goes on from its place in the document. A keyboard focus rings an Action or a region as it does on the DOM's:focus-visible, and so does a focus from code while the last input was a key, not the pointer (a roving focus the arrow keys move); Enter and Space press only an Action. - Focus trap. Inside a
trap(a View, Layout or Scroll), Tab and Shift+Tab move among its own, cycling. When the focused item inside it is removed or hidden, the trap, still shown, takes the focus back: its first focusable, else itself when it takes the focus (atabIndex). An item'sgetRootNode()is its Document, whoseactiveElementis the focused item, as a DOM element's root node. An item'sfocusFirst()andfocusLast()focus the first or last item Tab reaches inside it, in document order, and return whether one took the focus: ren-core's$focus(ref), which a FocusTrap opens with. - Focus events. As the DOM's: the item losing focus hears
onBlur, thenonFocusOut, the item taking itonFocus, thenonFocusIn, each its own event (target,relatedTarget; focus and blur do not bubble); focusout and focusin bubble through the ancestors (stopPropagationends them), withrelatedTargetthe item on the other side (null: none), so a wrapper hears focus move into or out of it. The focus and the focusin carry ren-core'sfocusVisible: true when the item shows its ring as drawn (an Edit always, any other item when the keyboard gave the focus or code moved it while the last input was a key), as the DOM's:focus-visible. A focused item hidden or disabled gives the focus up the same way:onBlur, thenonFocusOut(relatedTargetnull) through the ancestors it had. A focused item removed hears nothing (ren-core drops its handlers before it goes); only the ancestors it had hearonFocusOut. Chrome fires the removed element's own blur too. Nothing has the focus while the old item hears them: a handler there that moves the focus elsewhere wins, and the item it was going to hears nothing, as in Chrome; a focus handler that moves it on leaves its item's focusin unheard, as Chrome does (scripts/focus-oracle.mjsrecords these). - Drop.
onDrophears files dropped on the canvas (attachaccepts the drag and passes the drop todropFiles(x, y, files)):{ files, x, y }, bubbling from the item under the point,stopPropagationending it. The drag before it goes to the items under it as the DOM's:attachpasses the canvas's dragenter and dragover todragOver(x, y, files, types)(the file items' count and MIME types) and its dragleave todragLeave(); when the item under the drag changes, the new one hearsonDragEnter, then the last oneonDragLeave(one hidden or removed since: its nearest ancestor still attached), and the one under itonDragOver, each bubbling; a drop ends the drag without a leave. On the main thread only (a worker's handlers hear no drag).dropTarget(x, y)is the item a drag or drop there reaches (the one under it, or its nearest ancestor that listens for one; null: none): a host that lays the page over other content (ren-three's overlay) takes files where it is not painted but an item hears them. - Viewport. The Document's
viewport(report)(ren-core's$viewportand$media) reports its size in logical pixels (the canvas's; a ren-three panel's own) and the window'sprefers-color-scheme: darkandprefers-reduced-motion: reduce(its host element's window, else the global one; none in node), now and after eachresizeor change of preference; it follows the preferences only while one reads them. - Resize.
onResizehears an item's border box{ width, height }after the layout that first places it and after each that changes its size, as a ResizeObserver on the DOM. - Moves. ren-core's
$moves(listen(item, 'move')) hears an item's border box painted somewhere new: after each frame the Document compares the listening items'getBoundingClientRect()with the place last told (the first, where it was when the listening started, is only noted) and tells{ type: 'move', x, y }, whatever moved it: a layout shift, a scroll, a transform, an animation. It measures nothing on a frame that does not paint. - Cursor. The portable style's
cursorshows on the canvas over the item, as the DOM shows it: a state style's while the item is in that state (focusoverpressoverhover, as they overlay), else its own, else its nearest ancestor's; over an Edit, and over the selectable text thebehaviorgives the I-beam (§Behavior),text. It is found on each move and again after each frame at the point the pointer last moved to, so what a press or a render changes under a still pointer (a pressed style, a panel opened there) shows with no move;attachwrites it to the canvas'sstyle.cursor. - An Action holding content. As a DOM button holds its content, an
Action with children (not
textalone) lays them out as a View does (the portable style's direction, gap, padding and alignment apply; it centers them by default) and paints them inside it. It stays one target: a press anywhere on it presses it and focuses it, its content is not a target of its own (unless an Edit, an Action or a handler makes it one) and its text is not selectable (untilselectTextopens it); its hover, press and focus states, keys and focus ring are a text Action's. The accessibility mirror names it by itslabel, else by the text it holds. An item'slabelreads that name, the DOM's accessible name: the label given, else, on an Action or an item whose role ARIA names by its content (radio, tab, option, menuitem, ...), the text it holds, white space collapsed. - Edits as DOM inputs. A controlled Edit (a bound
value) shows its bound value again when the view does not adopt an edit (ren-core's rule, so worker mode too). When the view rewrites the focused Edit's value (a mask, a rejected keystroke), the caret goes to the end, as the DOM puts it; a value written back as typed keeps it where it was.disabledtakes no focus (a press, Tab andfocus()pass it by; disabled while focused, it gives the focus up) and no edits, and is drawn on the theme'ssoftwithmutedtext unless its style says otherwise.readOnlytakes focus, selection and copy, not edits (keys, typing, composition and the native entry leave the value).secret(one line) draws a bullet per UTF-16 unit of its value, which stays real; copy gives nothing, a double press selects it all, the native text entry is a password input and the mirror reads bullets.autosizemakes a multi-line Edit as high as its wrapped rows, fromlinesup toautosizerows (true: no limit), laid out again as the value changes. The placeholder takes the style'splaceholderColor(inherited), else the theme'smuted. - Labels that clip.
linesclamps a Label to that many wrapped rows, the last kept ending with an ellipsis (…) cut to its width (after the spaces it wrapped at);ellipsiskeeps it on one line and ends each line too wide with one. Either lets the Label shrink below its text (its automatic minimum is 0, as CSS's for a box that clips). A row is cut where it is drawn, as Chrome cuts it: laid out from its start edge, the characters past its width less the ellipsis go (spaces as any), and those kept stay in their places, the ellipsis on their end side. A right-to-left run in a left-to-right row (or one left to right in a right-to-left row) keeps its last characters, so a row of mixed directions may keep characters apart in the text (kept, with their levels in the whole row,levels); hits, selection and runs map through them. test/rtl-oracle.test.jsx checks which characters show, where, and the ellipsis against Chrome's screenshot, one line and clamped, left to right, right to left and mixed. - Styled runs. A Label's
runs(ren-core's{ text, style, onPress }) are measured, wrapped and painted piece by piece: each run's style (the text part of the vocabulary) over the Label's inherited text; text is measured through aMeasure(text.js: oneFace, orRuns), so a word may span runs and wraps as one. Each row's line box is CSS's: every run in it and the Label's own text (its strut) have their own leading on one baseline, and the row is as high as the highest above and the deepest below it (tops,bases). A piece is a text command in its run's face, size and color, its background a rect behind its text (its font's ascent and descent, as a span's), its underline and strike its own.linesandellipsiscut the joined text; the ellipsis takes the run of the character before it. Selection, copy, a double press's word andoffsetAtread the plain text's offsets; the selection fills each row's line box. A run'sonPresshears a press on its characters first (aPresswhose target is the Label), then the Label's own unless it stops it; direct mode only. - Runs over an Edit. An Edit's
runs(ren-core's{ text, style }) style its value while their texts joined are its value (editedin scene.js: a draft the view has not answered yet, or a secret's bullets, draws plain). A run keeps the Edit's size and family (one line height, as a native field and ren-dom's mirror), so rows keep the Edit's line and baseline; its weight and italic are its face. The value is measured through the runs (measureOf): soft wraps (Lines), the caret's x, the selection and composition marks (span), the caret under a press (place) and an autosized Edit's rows; pieces paint as a Label's (typedin draw.js: backgrounds, then text and decorations). test/edit-runs.test.jsx pins them. - Words. A double press selects a word by Unicode's rules, so
218.41,3,284andcan'tare one word, as in a browser.
Accessibility mirror
Under attach (the aria option, on by default), a visually hidden DOM
tree beside the canvas mirrors the scene for assistive technology
(src/aria.js), kept in step incrementally: a div per container, a p per
Label, a button per Action (activating it presses the Action), a textbox
per Edit, an img per Image, and a div per Shape marked with a role. The
caller's meaning marks (ren-core's Meaning props) are written on each
element as ren-dom writes them: role, every aria-* state and property
(a boolean as its text), id and the microdata (itemscope, itemtype,
itemprop, itemref, content, datetime); a mark no longer given
goes. A container's or a Shape's label names it (aria-label) unless
an aria-label does, so a radio group reads as a radiogroup of radios
with their aria-checked, tabs as a tablist of tabs with their
aria-selected, and aria-hidden decoration is left out
(test/aria.test.jsx).
Text selection
Selectable text selects as in a browser: a press and drag across Labels
selects their text in document order; a double press selects a word, a
triple a whole Label, and Shift extends; copy (the platform's shortcut or
menu while the canvas has focus) puts the selected text on the clipboard,
a line break between Labels. Inside an Edit the same gestures select its
value. Presses on Actions and press targets press them instead;
style.select: 'text' opts text in and 'none' leaves it out (both
inherit). copied() returns the selected text, selectAll() selects as
Ctrl or Cmd+A does.
Text actions
What an application's own menus do to text, as ren-core's text actions
give them (ren-core/host's EDIT and TEXT):
- An Edit's, on its ref (
editOf(ref)):value,selectionandsetSelection(kept while it has no focus),replace(text)over the selection as the user's edit (focused or not:changehears it, a controlled Edit shows its bound value again when the view does not take it, a one-line Edit drops its line breaks, astypedoes),selectAll,undo,redo,canUndo,canRedo,focus,secret,readOnlyanddisabled. A read-only or disabled Edit takes no replace, undo or redo. - Undo history. Each Edit keeps its user's edits (src/history.js) as
steps of value and selection: typing (keys, an IME's commit, the native
entry's insertions) joins the step before while it goes on where that one
left the caret, within a second, in the same word (a word and the spaces
after it are one step); deleting joins deleting so; a paste, a cut, a
drop (the native entry's), a deleted selection or
replacestands alone. Ctrl or Cmd+Z, Ctrl or Cmd+Shift+Z and Ctrl+Y walk it, and so does the native entry's undo and redo (itsbeforeinput, from the browser's Edit menu), which is cancelled; each step restores the value and selection and sendschange. An edit after an undo drops the steps undone; a value the view sets that the last edit did not leave (a controlled write, a mask) clears it, as a browser's field forgets its history when code sets its value. At most 100 steps are kept. - The root's, on the Document (an item's
getRootNode()):at(event)(textAt) is the static text under a pointer event's point,{ node, text, pressed }with a Label's (a run of text's, or an Action's owntext) whole text, selectable or not,pressedwhen an Action or another press target holds it (a button's label, a pressable row's amount: the application's menu decides whether to offer it); null over no text, an Edit or text an Edit holds, or a box lying over the text.select(node)(selectText) opens selection on that one text and selects all of it: it selects while open whatever its style and the behavior say (the I-beam over it, Ctrl or Cmd+A all of it, an Action's text too, its own painted as a Label's), a press in it places a caret there (a double press a word, a triple all of it) and a drag selects within it, pressing, focusing and clicking nothing that holds it; a press outside it, Escape or the function it returns ends it and the selection in it, and what holds it presses again; a focused Edit gives up the focus.selected()(selectedText) iscopied(): a focused Edit's selection ('' in a secret one), else the text selection.
Behavior
An application does not behave as a page: the Document's behavior
option (ren-core's Behavior, resolved by its behaviorOf into
document.behavior) is 'app' by default, 'page' for a page's, or
{ like, ...switches }, each switch over its like's on its own:
| Switch | 'app' | 'page' |
| --- | --- | --- |
| select | only an Edit and what select: 'text' opts in select; loose text does not | loose text selects too |
| selectAll | Ctrl or Cmd+A selects the focused Edit's value, else the text of the select: 'text' element the selection (or the focus) is in, else nothing, and never the browser's page | the page's selectable text; with none, the browser's |
| textCursor | the I-beam over an Edit and opted-in text only | over any selectable text |
| menu | no browser context menu outside an Edit | the browser's |
| editMenu | the browser's in an Edit (paste, spelling) | the same |
| callout | attach gives the canvas -webkit-touch-callout: none and a transparent tap highlight | the browser's |
Without select, attach also makes the canvas element itself
unselectable (user-select: none); what it sets is put back when it
stops. prevent="contextmenu" cancels the browser's menu under any
behavior.
A renderer on ren-canv
import { Document } from 'ren-canv';
class Mine extends Document {
constructor(options) {
super(fonts, options); // fonts: { width(text, size, face), metrics(size, face), close() }
}
get kind() { return 'mine'; } // Runtime's renderer name
get drawing() { return this.surface !== null; }
draw(frame, damage) { /* replay frame.ops and frame.texts; damage null: whole */ }
canvasFor(element) { /* the canvas of an application root, or null */ }
sized() { /* the viewport changed: make the surface again */ }
}present() lays out and paints when something changed, compares the frame
with the last one and calls draw with the damage (null to repaint whole,
never when nothing differs). A damage rectangle grows over each piece it
cuts that has an antialiased edge (text, a rounded fill, a border, a
figure), so a clip never runs along one; a plain fill on whole device
pixels clips exactly and stays out, so two changes beside a column's edge
do not pull in the columns' backgrounds (measured with ren-bench's busy-screen harness, documented in ren-skia's README, Benchmarks). The command buffer's format is in
src/draw.js.
Tests
npm install # ren-core and rrx-core (linked), pinned devDependencies
npm test # declarations, tsc --checkJs --strict, the TypeScript consumer, node:test twicetest/document.test.jsx drives a Document with a fixed-width measure and no
surface. ren-skia's suite checks the same engine through CanvasKit pixels.
The other suites each cover one part of the engine: action, actions
(text actions, undo, $presses), backend, bidi and bidi-oracle, contents, damage, edit and edit-runs, flow,
focusable (tabIndex and inert), fonts, layout, paint, place,
nonfinite, rtl-oracle, runs, selection, shaping and shaping-oracle,
taffy-load and worker.
test/timers.test.js fails a timer call that does not say why it is one, and
test/exports.test.js locks the public contract to test/exports.lock.json.
