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.
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 }); }
#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 fireclick, 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,FocusandGetTabOrderare there for your own navigation. - Every input returns an
InputOutcome.Relaidmeans layout changed: callRepaint, which rewrites only the changed parts of the retained list.Animatedmeans only transforms or opacity moved:PatchAnimationsupdates the list with no layout.Scrolledasks forpainter.PatchScroll(list). - The host owns time. Pass your clock in
Timeand callui.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#.