Recursive AI

Hit testing and events for a UI

All onerender recipes

Use an HTML page as an interactive UI with OneRender: find the element under the pointer, listen for clicks that bubble, get :hover and keyboard focus, and repaint only what changed, on your own clock.

C#
using OneRender;
using OneRender.Image;
using OneRender.Interaction;
using OneRender.Layout;
using OneRender.Painting;
using RecursiveAI.OneHtml;
 
string html = """
<!doctype html>
<html lang="en">
<style>
  body { margin: 0; font: 16px sans-serif; background: #16202c; color: #e8edf3 }
  nav { display: flex; gap: 8px; padding: 16px }
  button { font: inherit; color: inherit; padding: 8px 14px; border: 0; border-radius: 6px; background: #2a3644 }
  button:hover { background: #3b82f6 }
  button:focus-visible { outline: 2px solid #fbbf24 }
</style>
<nav id="menu">
  <button id="play">Play</button>
  <button id="options">Options</button>
  <button id="quit">Quit</button>
</nav>
</html>
""";
 
InteractivePage ui = InteractivePage.Create(html,
    new HtmlPageOptions(FontProviders.CreateDefault()) { ViewportWidth = 400, ViewportHeight = 80 });
Document doc = ui.Page.Document;
Node menu = doc.GetElementById("menu");
Node quit = doc.GetElementById("quit");
 
// One listener on the container: clicks bubble up from the buttons.
ui.Events.AddEventListener(menu, "click", e =>
    Console.WriteLine($"click on #{e.Target.Node.GetAttribute("id")}, phase {e.Phase}"));
ui.Events.AddEventListener(quit, "click", e => { e.PreventDefault(); e.StopPropagation(); Console.WriteLine("quit handled here"); });
 
// Where is everything? Synchronous layout queries, like getBoundingClientRect.
Rect play = ui.Layout.GetBoundingClientRect(doc.GetElementById("play"));
Console.WriteLine($"#play at {play.X}, {play.Y}, {play.Width} x {play.Height}");
Console.WriteLine($"at (150, 30): #{ui.ElementFromPoint(150, 30).GetAttribute("id")}");
 
// Paint once; then feed input each frame with the host's clock.
var painter = new Painter();
var list = new RetainedDisplayList();
var paint = new PaintOptions(400, 80) { Scroll = ui.Page.Scroll };
painter.Paint(ui.Page.Fragments, list, paint);
list.ClearDamage();
 
double now = 16;
InputOutcome o = ui.HandlePointer(new PointerInput { Action = PointerAction.Move, X = play.X + 10, Y = play.Y + 10, Time = now });
Console.WriteLine($"move: {o}, hovered #{ui.HoveredElement.GetAttribute("id")}");
Click(play.X + 10, play.Y + 10);
Rect q = ui.Layout.GetBoundingClientRect(quit);
Click(q.X + 5, q.Y + 5);
 
o = ui.HandleKey(new KeyInput { Key = "Tab", Down = true, Time = now += 16 });
Console.WriteLine($"Tab: {o}, focused #{ui.FocusedElement.GetAttribute("id")}");
 
if ((o & InputOutcome.Relaid) != 0) ui.Repaint(painter, list, paint);
Console.WriteLine($"{list.Damage.Count} damaged area(s) to redraw");
File.WriteAllBytes("menu.png", DisplayListRasterizer.Render(list.Current, 400, 80).ToPng());
 
void Click(double x, double y)
{
    ui.HandlePointer(new PointerInput { Action = PointerAction.Down, X = x, Y = y, Button = 0, Buttons = MouseButtons.Primary, Time = now += 16 });
    ui.HandlePointer(new PointerInput { Action = PointerAction.Up, X = x, Y = y, Button = 0, Time = now += 16 });
}
Text
#play at 16, 16, 59.125 x 34
at (150, 30): #options
move: StatesChanged, Relaid, hovered #play
click on #play, phase Bubbling
quit handled here
Tab: StatesChanged, Relaid, FocusChanged, focused #play
2 damaged area(s) to redraw

In menu.png, Play has the yellow keyboard focus ring and Quit, still under the pointer, is blue.

How it works

  • Events follow the DOM standard: capture, target and bubble phases, PreventDefault, StopPropagation, and listener options (Capture, Once, Passive). A pointer down and up on the same element fire click, and focus moves to it.
  • Focus. Clicking a button focuses it, so Tab from Quit wraps around to Play. Focus that comes from the keyboard matches :focus-visible; MoveFocus, Focus and GetTabOrder are there for your own navigation.
  • Every input returns an InputOutcome. Relaid means layout changed: call Repaint, which rewrites only the changed parts of the retained list. Animated means only transforms or opacity moved: PatchAnimations updates the list with no layout. Scrolled asks for painter.PatchScroll(list).
  • The host owns time. Pass your clock in Time and call ui.Advance(nowMs) every frame to run CSS transitions and animations. Nothing reads the system clock, so a replay of the same inputs gives the same frames.
  • Layout queries (GetBoundingClientRect, GetClientRects, offset, client and scroll sizes, ElementFromPoint, ElementsFromPoint) answer synchronously, after any pending change is applied.

Also on the page

Form controls with Chrome’s default look, text editing with caret, selection, undo and IME composition (HandleKey, InsertText, CompositionStart), wheel scrolling with scroll snap (HandleWheel), and a select dropdown drawn in the same display list. See Forms and text editing and Transitions and animations.

For a UI built in C# with no HTML, see Build a styled tree in C#.