acdream/src/AcDream.App/UI/UiRoot.cs
Erik ebfeaff840 feat(D.2b): UI render infra — overlay layer, DrawFill, crisp text, write-mode focus
The retail-look render + focus primitives this chat pass builds on:

- TextRenderer: an OVERLAY layer (sprite/rect/text buckets flushed AFTER the
  normal layer) so an open popup composites on top of everything incl. rect
  panel backgrounds; a DrawFill primitive (solid quad via a 1x1 white texture)
  routed through the SPRITE bucket so a panel background draws UNDER its text
  instead of being washed by the later rect bucket; and the text pass now
  disables SampleAlphaToCoverage + Multisample so glyph alpha edges aren't
  dithered into MSAA coverage (the "fuzzy text") — self-contained GL state
  per feedback_render_self_contained_gl_state.
- UiRenderContext.DrawStringDat: snap the line baseline to a whole pixel ONCE
  then add the integer per-glyph offset (retail DrawCharacter takes an int
  pen-Y + schar m_VerticalOffsetBefore) — fixes the "letters dip down" jitter
  at a fractional line origin. Outline pass is now opt-in (retail gates it per
  element via SetOutline; default off = crisp fill-only). Adds DrawFill +
  Begin/EndOverlayLayer.
- UiElement: OnDrawOverlay + DrawOverlays (second traversal), FindRoot (blur
  self), ResetAnchorCapture (re-baseline an anchored element after reflow).
- UiRoot: runs the overlay pass after the main tree; Tab/Enter focuses the
  DefaultTextInput (write-mode activation); a left click on a non-edit target
  blurs the focused input (exit write mode without submitting).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:23:48 +02:00

648 lines
25 KiB
C#

using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>Which edges of a window a resize-drag is affecting (corners combine two).</summary>
[System.Flags]
public enum ResizeEdges { None = 0, Left = 1, Right = 2, Top = 4, Bottom = 8 }
/// <summary>
/// Top-level UI container. Implements the retail "Device" responsibilities
/// (mouse cursor tracking, keyboard focus, modal overlay, mouse capture,
/// drag-drop state machine, tooltip timer). Routes Silk.NET input events
/// into the widget tree with retail-faithful <see cref="UiEvent"/>
/// semantics.
///
/// Retail analog: the <c>DAT_00837ff4</c> Device object (see
/// <c>docs/research/retail-ui/04-input-events.md §2</c>). That object has
/// a ~20-slot vtable; the methods we emulate here are:
///
/// <list type="bullet">
/// <item>+0x18 / +0x1C : <see cref="MouseX"/> / <see cref="MouseY"/></item>
/// <item>+0x34 : <see cref="RegisterTimerEvent"/> (tooltip delay)</item>
/// <item>+0x38 : <see cref="FireEvent"/></item>
/// <item>+0x44 : <see cref="KeyboardFocus"/></item>
/// <item>+0x48 / +0x4C : <see cref="SetCapture"/> / <see cref="ReleaseCapture"/></item>
/// <item>+0x74 / +0x78 : drag cursor set / reset</item>
/// </list>
///
/// When no widget consumes an event, the <see cref="WorldMouseFallThrough"/>
/// or <see cref="WorldKeyFallThrough"/> event fires so the game world
/// (camera, player controller) still receives input.
/// </summary>
public sealed class UiRoot : UiElement
{
// ── Device-level state ───────────────────────────────────────────────
public int MouseX { get; private set; }
public int MouseY { get; private set; }
public bool LeftButtonDown { get; private set; }
public bool RightButtonDown { get; private set; }
public bool MiddleButtonDown { get; private set; }
/// <summary>Widget currently receiving keyboard events.</summary>
public UiElement? KeyboardFocus { get; private set; }
/// <summary>The edit control activated by Tab/Enter when nothing is focused — retail's
/// chat input "write mode" toggle. Set by the host once the chat window is built.</summary>
public UiElement? DefaultTextInput { get; set; }
/// <summary>
/// Single modal overlay; while set, mouse clicks outside its rect
/// are ignored. Retail sets this via Device vtable +0x48.
/// </summary>
public UiPanel? Modal { get; set; }
/// <summary>Widget with mouse capture (during click-drag).</summary>
public UiElement? Captured { get; private set; }
/// <summary>
/// True when the pointer is over a widget OR a widget holds mouse capture.
/// The host ORs this into the InputDispatcher's WantCaptureMouse gate so game
/// actions (movement, world-pick) are suppressed while the user interacts with
/// a retail window — mirrors ImGui's WantCaptureMouse.
/// </summary>
public bool WantsMouse => Captured is not null || HitTestTopDown(MouseX, MouseY).element is not null;
/// <summary>True when a widget holds keyboard focus (e.g. a focused chat input).</summary>
public bool WantsKeyboard => KeyboardFocus is not null;
/// <summary>Current drag source (set between drag-begin and drop/cancel).</summary>
public UiElement? DragSource { get; private set; }
public object? DragPayload { get; private set; }
private UiElement? _lastDragHoverTarget;
private int _pressX, _pressY;
private bool _dragCandidate;
private UiElement? _windowDragTarget;
private int _windowDragOffX, _windowDragOffY;
private UiElement? _resizeTarget;
private ResizeEdges _resizeEdges;
private float _resizeStartX, _resizeStartY, _resizeStartW, _resizeStartH;
private int _resizeMouseX, _resizeMouseY;
private const int ResizeGrip = 5; // px proximity to an edge to start a resize
private const int DragDistanceThreshold = 3; // pixels, retail-observed
// Hover / tooltip tracking.
private UiElement? _hoverWidget;
private long _hoverStartedMs;
private const int TooltipDelayMs = 1000; // retail typical
private bool _tooltipFired;
private long _nowMs;
/// <summary>Raised when an event was not consumed by any widget.</summary>
public event Action<UiMouseButton, int, int, uint>? WorldMouseFallThrough;
/// <summary>Raised when a key was not consumed by any widget.</summary>
public event Action<int /*vk*/, uint /*lparam*/>? WorldKeyFallThrough;
/// <summary>Raised when mouse moved and no widget captured.</summary>
public event Action<int, int>? WorldMouseMoveFallThrough;
/// <summary>Raised on scroll fall-through (world zoom, etc.).</summary>
public event Action<int /*dy*/>? WorldScrollFallThrough;
private uint _nextEventId = 0x10000001u;
public override void AddChild(UiElement child)
{
if (child.EventId == 0) child.EventId = _nextEventId++;
base.AddChild(child);
}
// ── Per-frame pumping ────────────────────────────────────────────────
public void Tick(double dt, long nowMs)
{
_nowMs = nowMs;
// Tooltip timer: once mouse has hovered over the same widget for
// TooltipDelayMs, fire a Tooltip event on it exactly once.
if (_hoverWidget is not null && !_tooltipFired
&& _nowMs - _hoverStartedMs >= TooltipDelayMs)
{
var e = new UiEvent(_hoverWidget.EventId, _hoverWidget, UiEventType.Tooltip);
_hoverWidget.OnEvent(in e);
_tooltipFired = true;
}
TickSelfAndChildren(dt);
}
public void Draw(UiRenderContext ctx)
{
// Render children (panels) sorted by z-order — modal last so it
// sits on top.
DrawSelfAndChildren(ctx);
// Second pass: open popups/menus draw ON TOP of the whole tree (so e.g. the
// chat channel menu isn't greyed by the translucent chat panel that draws
// after it in the main pass). Routed to the renderer's overlay layer so it
// beats even rect backgrounds. Faithful to retail's root-level MakePopup.
ctx.BeginOverlayLayer();
DrawOverlays(ctx);
ctx.EndOverlayLayer();
}
// ── Input entry points (called from GameWindow's Silk.NET handlers) ──
public void OnMouseMove(int x, int y)
{
int dx = x - MouseX;
int dy = y - MouseY;
MouseX = x;
MouseY = y;
// Window resize takes precedence over move / drag-drop / hover.
if (_resizeTarget is not null)
{
var (nx, ny, nw, nh) = ResizeRect(
_resizeStartX, _resizeStartY, _resizeStartW, _resizeStartH,
_resizeEdges, x - _resizeMouseX, y - _resizeMouseY,
_resizeTarget.MinWidth, _resizeTarget.MinHeight);
_resizeTarget.Left = nx; _resizeTarget.Top = ny;
_resizeTarget.Width = nw; _resizeTarget.Height = nh;
return;
}
// Window-move drag takes precedence over drag-drop / hover / fall-through.
if (_windowDragTarget is not null)
{
_windowDragTarget.Left = x - _windowDragOffX;
_windowDragTarget.Top = y - _windowDragOffY;
return;
}
// If we have capture, deliver MouseMove to the captured widget
// AND drive drag state machine; do NOT fall through.
if (Captured is not null)
{
DispatchMouseMove(Captured, x, y);
// Promote to drag if candidate and moved far enough.
if (_dragCandidate && DragSource is null)
{
if (Math.Abs(x - _pressX) > DragDistanceThreshold
|| Math.Abs(y - _pressY) > DragDistanceThreshold)
{
BeginDrag(Captured, payload: null);
}
}
if (DragSource is not null)
UpdateDragHover(x, y);
return;
}
// Not captured: track hover for tooltips + fall through.
UpdateHover(x, y);
WorldMouseMoveFallThrough?.Invoke(x, y);
}
public void OnMouseDown(UiMouseButton btn, int x, int y, uint flags = 0)
{
MouseX = x; MouseY = y;
UpdateButtonFlag(btn, down: true);
_pressX = x; _pressY = y;
// Modal blocks clicks outside its bounds.
if (Modal is not null && !ContainsAbsolute(Modal, x, y))
return;
var (target, _, _) = HitTestTopDown(x, y);
if (target is null)
{
// Clicking the 3D world exits write mode (no submit) and returns control to
// the character — retail blurs the chat input on an outside click.
if (btn == UiMouseButton.Left) SetKeyboardFocus(null);
WorldMouseFallThrough?.Invoke(btn, x, y, flags);
return;
}
// Keyboard focus follows a left click: the input bar (an edit control) takes
// focus = enters write mode; clicking anything else (chrome, Send, scrollbar,
// menu, another window) blurs the input = exits write mode WITHOUT submitting.
if (btn == UiMouseButton.Left)
SetKeyboardFocus(target.AcceptsFocus ? target : null);
SetCapture(target);
// Window resize / move: find the window (Draggable or Resizable ancestor).
// A left-drag starting near an edge resizes; interior drag repositions;
// otherwise it's a normal drag-drop candidate.
var window = FindWindow(target);
if (btn == UiMouseButton.Left && window is not null)
{
var edges = window.Resizable ? HitEdges(window, x, y, ResizeGrip) : ResizeEdges.None;
if (edges != ResizeEdges.None)
{
// Edge resize still wins, even over a CapturesPointerDrag child:
// a resizable chat window can be resized from its frame.
_resizeTarget = window;
_resizeEdges = edges;
_resizeStartX = window.Left; _resizeStartY = window.Top;
_resizeStartW = window.Width; _resizeStartH = window.Height;
_resizeMouseX = x; _resizeMouseY = y;
_dragCandidate = false;
}
else if (target.CapturesPointerDrag)
{
// The pressed widget owns interior drags (e.g. text selection):
// do NOT move the ancestor window. The already-dispatched MouseDown
// event + SetCapture(target) let the target drive its own drag via
// the MouseMove events it receives while captured.
_dragCandidate = false;
}
else if (window.Draggable)
{
_windowDragTarget = window;
_windowDragOffX = x - (int)window.Left;
_windowDragOffY = y - (int)window.Top;
_dragCandidate = false;
}
else { _dragCandidate = true; }
}
else if (target.CapturesPointerDrag)
{
// No window ancestor, but the target still owns its interior drag.
_dragCandidate = false;
}
else
{
_dragCandidate = true;
}
// Dispatch raw MouseDown event (retail uses WM_LBUTTONDOWN = 0x201).
int rawType = btn switch
{
UiMouseButton.Left => UiEventType.MouseDown,
UiMouseButton.Right => UiEventType.RightDown,
UiMouseButton.Middle => UiEventType.MiddleDown,
_ => UiEventType.MouseDown,
};
// Deliver TARGET-LOCAL coords (consistent with MouseMove/MouseUp, which use
// target.ScreenPosition). HitTestTopDown's lx/ly are relative to the TOP-LEVEL
// child, so for a nested target (e.g. the chat view inset inside its window)
// they'd be offset by the child's position — which mis-anchored drag-select.
var sp = target.ScreenPosition;
var e = new UiEvent(target.EventId, target, rawType,
Data0: (int)flags, Data1: (int)(x - sp.X), Data2: (int)(y - sp.Y));
BubbleEvent(target, in e);
}
public void OnMouseUp(UiMouseButton btn, int x, int y, uint flags = 0)
{
MouseX = x; MouseY = y;
UpdateButtonFlag(btn, down: false);
if (_resizeTarget is not null)
{
_resizeTarget = null;
ReleaseCapture();
return;
}
if (_windowDragTarget is not null)
{
_windowDragTarget = null;
ReleaseCapture();
return;
}
if (DragSource is not null)
{
FinishDrag(x, y);
ReleaseCapture();
_dragCandidate = false;
return;
}
if (Captured is not null)
{
int rawType = btn switch
{
UiMouseButton.Left => UiEventType.MouseUp,
UiMouseButton.Right => UiEventType.RightUp,
UiMouseButton.Middle => UiEventType.MiddleUp,
_ => UiEventType.MouseUp,
};
var sp = Captured.ScreenPosition;
var raw = new UiEvent(Captured.EventId, Captured, rawType,
Data0: (int)flags,
Data1: (int)(x - sp.X), Data2: (int)(y - sp.Y));
BubbleEvent(Captured, in raw);
// If left-up over the same element that received the down, emit Click.
if (btn == UiMouseButton.Left && ContainsAbsolute(Captured, x, y))
{
var click = new UiEvent(Captured.EventId, Captured, UiEventType.Click,
Data0: (int)flags,
Data1: (int)(x - sp.X), Data2: (int)(y - sp.Y));
BubbleEvent(Captured, in click);
}
else if (btn == UiMouseButton.Right && ContainsAbsolute(Captured, x, y))
{
var click = new UiEvent(Captured.EventId, Captured, UiEventType.RightClick,
Data0: (int)flags);
BubbleEvent(Captured, in click);
}
ReleaseCapture();
_dragCandidate = false;
return;
}
// No capture — give the world a chance.
WorldMouseFallThrough?.Invoke(btn, x, y, flags);
}
public void OnScroll(int dy)
{
// Scroll goes to the widget under the cursor (not the focused one).
var (target, lx, ly) = HitTestTopDown(MouseX, MouseY);
if (target is null)
{
WorldScrollFallThrough?.Invoke(dy);
return;
}
var e = new UiEvent(target.EventId, target, UiEventType.Scroll, Data0: dy,
Data1: (int)lx, Data2: (int)ly);
BubbleEvent(target, in e);
}
public void OnKeyDown(int vk, uint lparam = 0)
{
// Nothing focused yet: Tab or Enter enters "write mode" by focusing the chat
// input (retail's chat-activation hotkeys). Consumed so the same press doesn't
// also fall through to a game hotkey.
if (KeyboardFocus is null && DefaultTextInput is not null
&& (vk == (int)Silk.NET.Input.Key.Tab
|| vk == (int)Silk.NET.Input.Key.Enter
|| vk == (int)Silk.NET.Input.Key.KeypadEnter))
{
SetKeyboardFocus(DefaultTextInput);
return;
}
// Focus widget first.
if (KeyboardFocus is not null)
{
var e = new UiEvent(KeyboardFocus.EventId, KeyboardFocus, UiEventType.KeyDown,
Data0: vk, Data1: (int)lparam);
if (BubbleEvent(KeyboardFocus, in e)) return;
}
// If the focused widget is NOT an edit control, also consult the modal /
// top panel. Edit controls absorb all keys (prevents hotkeys while typing).
if (KeyboardFocus is null || !KeyboardFocus.IsEditControl)
{
var root = Modal ?? (UiElement)this;
var e = new UiEvent(root.EventId, root, UiEventType.KeyDown,
Data0: vk, Data1: (int)lparam);
if (BubbleEvent(root, in e)) return;
}
WorldKeyFallThrough?.Invoke(vk, lparam);
}
public void OnKeyUp(int vk, uint lparam = 0)
{
if (KeyboardFocus is not null)
{
var e = new UiEvent(KeyboardFocus.EventId, KeyboardFocus, UiEventType.KeyUp,
Data0: vk, Data1: (int)lparam);
if (BubbleEvent(KeyboardFocus, in e)) return;
}
// Key up rarely falls through; game logic generally keys off KeyDown.
}
public void OnChar(int codepoint)
{
if (KeyboardFocus is null || !KeyboardFocus.IsEditControl) return;
var e = new UiEvent(KeyboardFocus.EventId, KeyboardFocus, UiEventType.Char,
Data0: codepoint);
BubbleEvent(KeyboardFocus, in e);
}
// ── Focus + capture ─────────────────────────────────────────────────
public void SetKeyboardFocus(UiElement? e)
{
if (KeyboardFocus == e) return;
if (KeyboardFocus is not null)
{
var lost = new UiEvent(KeyboardFocus.EventId, KeyboardFocus, UiEventType.FocusLost);
KeyboardFocus.OnEvent(in lost);
}
KeyboardFocus = e;
if (e is not null)
{
var gained = new UiEvent(e.EventId, e, UiEventType.FocusGained);
e.OnEvent(in gained);
}
}
public void SetCapture(UiElement e) => Captured = e;
public void ReleaseCapture() => Captured = null;
// ── Drag-drop (retail event chain 0x15 → 0x21 → 0x1C → 0x3E) ────────
private void BeginDrag(UiElement source, object? payload)
{
DragSource = source;
DragPayload = payload;
var e = new UiEvent(source.EventId, source, UiEventType.DragBegin, Payload: payload);
source.OnEvent(in e);
}
private void UpdateDragHover(int x, int y)
{
var (t, lx, ly) = HitTestTopDown(x, y);
if (ReferenceEquals(t, _lastDragHoverTarget)) return;
// Leave old target.
if (_lastDragHoverTarget is not null)
{
var eLeave = new UiEvent(DragSource!.EventId, _lastDragHoverTarget,
UiEventType.DragOver, Data1: x, Data2: y,
Payload: DragPayload);
_lastDragHoverTarget.OnEvent(in eLeave);
}
// Enter new target.
if (t is not null)
{
var eEnter = new UiEvent(DragSource!.EventId, t, UiEventType.DragEnter,
Data1: (int)lx, Data2: (int)ly,
Payload: DragPayload);
t.OnEvent(in eEnter);
}
_lastDragHoverTarget = t;
}
private void FinishDrag(int x, int y)
{
var (t, lx, ly) = HitTestTopDown(x, y);
var target = t ?? DragSource!;
var accepted = t is not null && t != DragSource;
var e = new UiEvent(DragSource!.EventId, target, UiEventType.DropReleased,
Data0: accepted ? 1 : 0,
Data1: (int)lx, Data2: (int)ly,
Payload: DragPayload);
target.OnEvent(in e);
DragSource = null;
DragPayload = null;
_lastDragHoverTarget = null;
}
// ── Hover / tooltip ─────────────────────────────────────────────────
private void UpdateHover(int x, int y)
{
var (w, _, _) = HitTestTopDown(x, y);
if (ReferenceEquals(w, _hoverWidget)) return;
if (_hoverWidget is not null)
{
var leave = new UiEvent(_hoverWidget.EventId, _hoverWidget, UiEventType.HoverLeave);
_hoverWidget.OnEvent(in leave);
}
_hoverWidget = w;
_hoverStartedMs = _nowMs;
_tooltipFired = false;
if (w is not null)
{
var enter = new UiEvent(w.EventId, w, UiEventType.HoverEnter);
w.OnEvent(in enter);
}
}
// ── Helpers ─────────────────────────────────────────────────────────
public void FireEvent(int type, UiElement target, object? payload = null)
{
var e = new UiEvent(target.EventId, target, type, Payload: payload);
target.OnEvent(in e);
}
public void RegisterTimerEvent(int type, UiElement target, int delayMs,
object? payload = null)
{
_timers.Add((_nowMs + delayMs, new UiEvent(target.EventId, target, type, Payload: payload)));
}
private readonly List<(long fireAt, UiEvent e)> _timers = new();
private void UpdateButtonFlag(UiMouseButton b, bool down)
{
switch (b)
{
case UiMouseButton.Left: LeftButtonDown = down; break;
case UiMouseButton.Right: RightButtonDown = down; break;
case UiMouseButton.Middle: MiddleButtonDown = down; break;
}
}
private (UiElement? element, float localX, float localY) HitTestTopDown(int x, int y)
{
// Modal gets exclusive hit-test.
if (Modal is not null)
{
var mp = Modal.ScreenPosition;
var mh = Modal.HitTest(x - mp.X, y - mp.Y);
if (mh is not null) return (mh, x - mp.X, y - mp.Y);
return (null, 0, 0);
}
// Walk top-level children in reverse Z-order (topmost first).
var kids = new UiElement[Children.Count];
for (int i = 0; i < Children.Count; i++) kids[i] = Children[i];
Array.Sort(kids, static (a, b) => b.ZOrder.CompareTo(a.ZOrder));
foreach (var c in kids)
{
var cp = c.ScreenPosition;
var hit = c.HitTest(x - cp.X, y - cp.Y);
if (hit is not null)
return (hit, x - cp.X, y - cp.Y);
}
return (null, 0, 0);
}
private static UiElement? FindWindow(UiElement? e)
{
while (e is not null)
{
if (e.Draggable || e.Resizable) return e;
e = e.Parent;
}
return null;
}
/// <summary>Which edges of <paramref name="w"/>'s screen rect the point
/// (<paramref name="x"/>,<paramref name="y"/>) is within <paramref name="grip"/> px of.
/// None if the point is outside the grip-expanded box entirely.</summary>
internal static ResizeEdges HitEdges(UiElement w, int x, int y, int grip)
{
float l = w.Left, t = w.Top, r = w.Left + w.Width, b = w.Top + w.Height;
if (x < l - grip || x > r + grip || y < t - grip || y > b + grip) return ResizeEdges.None;
var e = ResizeEdges.None;
if (System.Math.Abs(x - l) <= grip) e |= ResizeEdges.Left;
if (System.Math.Abs(x - r) <= grip) e |= ResizeEdges.Right;
if (System.Math.Abs(y - t) <= grip) e |= ResizeEdges.Top;
if (System.Math.Abs(y - b) <= grip) e |= ResizeEdges.Bottom;
if (!w.ResizeX) e &= ~(ResizeEdges.Left | ResizeEdges.Right);
if (!w.ResizeY) e &= ~(ResizeEdges.Top | ResizeEdges.Bottom);
return e;
}
/// <summary>Compute a resized rect from a start rect + drag delta + which edges,
/// clamping to (<paramref name="minW"/>,<paramref name="minH"/>). Left/Top edges
/// move the origin so the opposite edge stays put.</summary>
public static (float x, float y, float w, float h) ResizeRect(
float startX, float startY, float startW, float startH,
ResizeEdges edges, float dx, float dy, float minW, float minH)
{
float x = startX, y = startY, w = startW, h = startH;
if ((edges & ResizeEdges.Right) != 0) w = System.Math.Max(minW, startW + dx);
if ((edges & ResizeEdges.Bottom) != 0) h = System.Math.Max(minH, startH + dy);
if ((edges & ResizeEdges.Left) != 0) { float nw = System.Math.Max(minW, startW - dx); x = startX + (startW - nw); w = nw; }
if ((edges & ResizeEdges.Top) != 0) { float nh = System.Math.Max(minH, startH - dy); y = startY + (startH - nh); h = nh; }
return (x, y, w, h);
}
private static bool ContainsAbsolute(UiElement e, int x, int y)
{
var sp = e.ScreenPosition;
return x >= sp.X && x < sp.X + e.Width
&& y >= sp.Y && y < sp.Y + e.Height;
}
private void DispatchMouseMove(UiElement target, int x, int y)
{
var sp = target.ScreenPosition;
var e = new UiEvent(target.EventId, target, UiEventType.MouseMove,
Data1: (int)(x - sp.X), Data2: (int)(y - sp.Y));
BubbleEvent(target, in e);
}
/// <summary>
/// Call <see cref="UiElement.OnEvent"/> on <paramref name="start"/>;
/// if it returns false, walk the Parent chain.
/// </summary>
private bool BubbleEvent(UiElement start, in UiEvent e)
{
var w = start;
while (w is not null)
{
if (w.OnEvent(in e)) return true;
w = w.Parent;
}
return false;
}
protected override void OnDraw(UiRenderContext ctx)
{
// Root itself draws nothing; children do.
}
}