using Amphora; using Annulus; using Azimuth; using Clave; using System; using System.Collections.Generic; using Prism; namespace Glass.Testbed.TestCases.Layout { /// /// Hoisting and modality on screen: what escapes an owner's clip, what order overlays land in, and who /// hears a click first. /// public class OverlaysTestCase : TestCase { /// /// A panel that re-derives its position from its owner every tick, in screen space for as long as it is /// hoisted. /// private class Overlay : Panel { /// /// Where to sit relative to the owner's CanvasRect. /// public Vector OffsetFromOwner { get; set; } public Overlay() { // An overlay wants all three: screen space, drawn last, clipped by nothing. Hoisted = true; AlwaysOnTop = true; ClipToAncestors = false; } public override void Update(Duration elapsed, UpdateState state) { base.Update(elapsed, state); // Unhoisted, Bounds are in the owner's own space already. Vector origin = Hoisted ? state.ParentCanvasRectOnScreen.TopLeft : Vector.Zero; Bounds = Bounds.WithPosition(origin + OffsetFromOwner); } } /// /// An Overlay that also takes first refusal on every click, and counts what it heard. /// private class ModalOverlay : Overlay { public int HeardCount { get; private set; } /// /// Whether to claim what it hears, which is the only thing that keeps input from the controls below. /// public bool Swallow { get; set; } public Action OnHeard; public ModalOverlay() { Modal = true; } public override bool MouseDown(in MouseState state, MouseButtonFlags button, in KeyboardSnapshot held, KeyboardManager keys) { base.MouseDown(state, button, held, keys); ++HeardCount; OnHeard?.Invoke(); return Swallow; } public override bool MouseUp(in MouseState state, MouseButtonFlags button, in KeyboardSnapshot held, KeyboardManager keys) { base.MouseUp(state, button, held, keys); return Swallow; } public override bool MouseMoveStart(in MouseState state, in KeyboardSnapshot held, KeyboardManager keys) { base.MouseMoveStart(state, held, keys); return Swallow; } public override bool MouseMove(in MouseState state, in KeyboardSnapshot held, KeyboardManager keys) { base.MouseMove(state, held, keys); return Swallow; } public override bool MouseMoveEnd(in MouseState state, in KeyboardSnapshot held, KeyboardManager keys) { base.MouseMoveEnd(state, held, keys); return Swallow; } public override bool Click(in MouseState state, MouseButtonFlags button, int clickCount, in KeyboardSnapshot held, KeyboardManager keys) { base.Click(state, button, clickCount, held, keys); return Swallow; } } /// /// The Control flags the grid can flip. /// [Flags] private enum ControlFlags { Hoisted = 1, AlwaysOnTop = 2, ClipToAncestors = 4, ClipsCanvas = 8, } private static readonly Vector OwnerSize = new Vector(150, 80); private static readonly Vector FlapSize = new Vector(110, 60); private static readonly Vector NestedFlapSize = new Vector(80, 40); private static readonly Vector RidingFlapSize = new Vector(90, 40); // The packsheet only has four colours besides the neutral, and there are seven boxes to tell apart. private static readonly Material PurpleFlatPanel = UiPacksheetMaterials.FlatPanel with { TintColor = Colors.MediumPurple }; private static readonly Material OrangeFlatPanel = UiPacksheetMaterials.FlatPanel with { TintColor = Colors.Orange }; private static readonly ButtonMaterial SmallCheckboxUnchecked = new ButtonMaterial(UiPacksheetMaterials.Checkbox_Unchecked); private static readonly ButtonMaterial SmallCheckboxChecked = new ButtonMaterial(UiPacksheetMaterials.Checkbox_Crossed); private static readonly ControlFlags[] FlagColumns = { ControlFlags.Hoisted, ControlFlags.AlwaysOnTop, ControlFlags.ClipToAncestors, ControlFlags.ClipsCanvas, }; private static Rect Encompassing(params Rect[] rects) { Vector topLeft = rects[0].TopLeft; Vector bottomRight = rects[0].BottomRight; for (int i = 1; i < rects.Length; ++i) { topLeft = Vector.Min(topLeft, rects[i].TopLeft); bottomRight = Vector.Max(bottomRight, rects[i].BottomRight); } return Rect.FromPositionAndSize(topLeft, bottomRight - topLeft); } private static Vector LabelSize(StringView text) { return Widgets.ButtonLabel(text).PreferredSize(Constraints.Unbounded); } /// /// A button sized to , so its label changing never resizes it. /// private static Button BuildButton(string debugName, Label label, Vector widestLabelSize) { label.Bounds = Rect.FromPositionAndSize(Vector.Zero, widestLabelSize); Button button = label.WrapIn(UiPacksheetMaterials.Button, ButtonBehavior.Default); button.DebugName = debugName; return button; } private static bool GetFlag(Control control, ControlFlags flag) { switch (flag) { case ControlFlags.Hoisted: return control.Hoisted; case ControlFlags.AlwaysOnTop: return control.AlwaysOnTop; case ControlFlags.ClipToAncestors: return control.ClipToAncestors; case ControlFlags.ClipsCanvas: return control.ClipsCanvas; default: throw new ArgumentOutOfRangeException(nameof(flag), flag, "Expected a single flag"); } } private static void SetFlag(Control control, ControlFlags flag, bool value) { switch (flag) { case ControlFlags.Hoisted: control.Hoisted = value; break; case ControlFlags.AlwaysOnTop: control.AlwaysOnTop = value; break; case ControlFlags.ClipToAncestors: control.ClipToAncestors = value; break; case ControlFlags.ClipsCanvas: control.ClipsCanvas = value; break; default: throw new ArgumentOutOfRangeException(nameof(flag), flag, "Expected a single flag"); } } private static Vector SmallCaptionSize(StringView text) { return Widgets.SmallCaption(text).PreferredSize(Constraints.Unbounded); } /// /// A checkbox that starts out showing, and then writes, on . /// private static Checkbox BuildFlagCheckbox(Control control, ControlFlags flag) { var checkbox = new Checkbox(ButtonBehavior.Default) { DebugName = $"{control.DebugName} {flag}", UncheckedMaterial = SmallCheckboxUnchecked, CheckedMaterial = SmallCheckboxChecked, Checked = GetFlag(control, flag), }; checkbox.OnCheckedChanged += delegate (bool checkedNow) { SetFlag(control, flag, checkedNow); }; return checkbox; } private static RectAggregator NewCells(Rect row) { return new RectAggregator( Rect.FromPositionAndSize(row.TopLeft, new Vector(0, row.Height)), new Vector(Margin, 0)); } private static void AddCentered(Control parent, Control child, Rect cell) { Vector size = child.Bounds.Size; child.Bounds = Rect.FromPositionAndSize(cell.TopLeft + Control.CenteredIn(cell.Size, size), size); parent.CanvasChildren.Add(child); } /// /// A grid of flag checkboxes, a row per box and a column per flag. Each row is led by a swatch of the box's /// own material, which is what ties a colour on screen to a name. /// private static Panel BuildFlagGrid(IReadOnlyList<(Control control, ControlFlags flags)> rows) { Vector boxSize = Vector.Max( SmallCheckboxUnchecked.PreferredSize(Constraints.Unbounded), SmallCheckboxChecked.PreferredSize(Constraints.Unbounded)); Scalar textHeight = 0; Scalar nameWidth = 0; foreach (var row in rows) { Vector nameSize = SmallCaptionSize(row.control.DebugName); nameWidth = Math.Max(nameWidth, nameSize.X); textHeight = Math.Max(textHeight, nameSize.Y); } Scalar rowHeaderWidth = textHeight + Margin + nameWidth; Scalar rowHeight = Math.Max(textHeight, boxSize.Y); var columnWidths = new Scalar[FlagColumns.Length]; for (int column = 0; column < FlagColumns.Length; ++column) { columnWidths[column] = Math.Max(SmallCaptionSize(FlagColumns[column].ToString()).X, boxSize.X); } var grid = new RectAggregator(new Vector(0, CaptionMargin)); var panel = new Panel { DebugName = "flag grid", Material = Material.Invisible }; RectAggregator headerCells = NewCells(grid.NewRow(rowHeight)); headerCells.NewCol(rowHeaderWidth); for (int column = 0; column < FlagColumns.Length; ++column) { var header = new Label(Widgets.SmallCaptionStyle, FlagColumns[column].ToString(), Constraints.Unbounded); AddCentered(panel, header, headerCells.NewCol(columnWidths[column])); } foreach (var row in rows) { RectAggregator cells = NewCells(grid.NewRow(rowHeight)); RectAggregator nameCells = NewCells(cells.NewCol(rowHeaderWidth)); var swatch = new Panel { DebugName = $"{row.control.DebugName} swatch", Material = row.control.Material, Bounds = Rect.FromPositionAndSize(Vector.Zero, new Vector(textHeight, textHeight)), }; AddCentered(panel, swatch, nameCells.NewCol(textHeight)); var name = new Label(Widgets.SmallCaptionStyle, row.control.DebugName, Constraints.Unbounded); AddCentered(panel, name, nameCells.NewCol(nameWidth)); for (int column = 0; column < FlagColumns.Length; ++column) { Rect cell = cells.NewCol(columnWidths[column]); if (row.flags.HasFlag(FlagColumns[column])) { Checkbox checkbox = BuildFlagCheckbox(row.control, FlagColumns[column]); checkbox.Bounds = Rect.FromPositionAndSize(Vector.Zero, boxSize); AddCentered(panel, checkbox, cell); } } } panel.Bounds = Rect.FromPositionAndSize(Vector.Zero, new Vector(headerCells.Rect.Width, grid.Rect.Height)); return panel; } private void BuildHoistDemo() { // Flush with the owner's right edge and halfway down it, so it overhangs the bottom. The clipped flap // shares the hoisted one's offset, so the two columns compare directly. Vector flapOffset = new Vector(OwnerSize.X - FlapSize.X, OwnerSize.Y / 2); // Centred on the flap's corner, so it pokes out past two of its edges. Vector nestedOffset = FlapSize - NestedFlapSize / 2; // Wholly outside the owner, so only escaping the owner's clip lets it be seen at all. Vector ridingOffset = new Vector(-RidingFlapSize.X / 3, OwnerSize.Y + Margin); Vector laterOffset = OwnerSize / 2; // What overhangs the hoist owner still counts toward the space its column claims. Rect hoistFootprint = Encompassing( Rect.FromPositionAndSize(Vector.Zero, OwnerSize), Rect.FromPositionAndSize(flapOffset, FlapSize), Rect.FromPositionAndSize(flapOffset + nestedOffset, NestedFlapSize), Rect.FromPositionAndSize(ridingOffset, RidingFlapSize), Rect.FromPositionAndSize(laterOffset, OwnerSize)); var clippedOwner = NewChild("clipped owner"); clippedOwner.Material = UiPacksheetMaterials.GreenFlatPanel; var clippedFlap = clippedOwner.CanvasChildren.NewChild("clipped flap"); clippedFlap.Material = UiPacksheetMaterials.RedFlatPanel; clippedFlap.Bounds = Rect.FromPositionAndSize(flapOffset, FlapSize); var hoistOwner = NewChild("hoisting owner"); hoistOwner.Material = UiPacksheetMaterials.FlatPanel; var hoistedFlap = new Overlay { DebugName = "hoisted flap", Material = PurpleFlatPanel, Bounds = Rect.FromPositionAndSize(Vector.Zero, FlapSize), OffsetFromOwner = flapOffset, }; hoistOwner.CanvasChildren.Add(hoistedFlap); // Sticks out past the flap, so it shows the nested hoist escaping a hoist's clip rather than merely // drawing after a sibling, which any ordinary child would do. var nestedFlap = new Overlay { DebugName = "nested hoist", Material = UiPacksheetMaterials.YellowFlatPanel, Bounds = Rect.FromPositionAndSize(Vector.Zero, NestedFlapSize), OffsetFromOwner = nestedOffset, }; hoistedFlap.CanvasChildren.Add(nestedFlap); // AlwaysOnTop without Hoisted: parent space, so it rides along with its owner for free and needs no // Update override, while still painting after the whole tree. var ridingFlap = hoistOwner.CanvasChildren.NewChild("riding flap"); ridingFlap.Material = UiPacksheetMaterials.BlueFlatPanel; ridingFlap.Bounds = Rect.FromPositionAndSize(ridingOffset, RidingFlapSize); ridingFlap.AlwaysOnTop = true; ridingFlap.ClipToAncestors = false; // Added last, so it paints over both owners — and still ends up under the hoists. var later = NewChild("later sibling"); later.Material = OrangeFlatPanel; Panel flagGrid = BuildFlagGrid(new List<(Control control, ControlFlags flags)> { (clippedOwner, ControlFlags.ClipsCanvas), (clippedFlap, ControlFlags.ClipToAncestors), (hoistOwner, ControlFlags.ClipsCanvas), (hoistedFlap, ControlFlags.Hoisted | ControlFlags.AlwaysOnTop | ControlFlags.ClipToAncestors), (nestedFlap, ControlFlags.Hoisted | ControlFlags.AlwaysOnTop | ControlFlags.ClipToAncestors), (ridingFlap, ControlFlags.AlwaysOnTop | ControlFlags.ClipToAncestors), (later, ControlFlags.AlwaysOnTop), }); Add(flagGrid); Rect stage = ContentAggregator.NewRow(Math.Max(hoistFootprint.Height, flagGrid.Bounds.Height)); Rect clippedSlot = stage.NewCol(OwnerSize.X, margin: Gutter); clippedOwner.Bounds = Rect.FromPositionAndSize(clippedSlot.TopLeft, OwnerSize); Rect hoistSlot = stage.NewCol(hoistFootprint.Width, margin: Gutter); hoistOwner.Bounds = Rect.FromPositionAndSize(hoistSlot.TopLeft - hoistFootprint.TopLeft, OwnerSize); later.Bounds = Rect.FromPositionAndSize(hoistOwner.Bounds.TopLeft + laterOffset, OwnerSize); flagGrid.Bounds = flagGrid.Bounds.WithPosition(stage.TopLeft); } private void BuildModalDemo() { int plainClicks = 0; var plainLabel = new Label(Widgets.ButtonLabelStyle, "clicked 0"); Button plainButton = BuildButton("plain button", plainLabel, LabelSize("clicked 000")); Add(plainButton); plainButton.OnActivate += delegate (ButtonBase _, in KeyboardSnapshot __, KeyboardManager ___) { ++plainClicks; plainLabel.Text = $"clicked {plainClicks}"; }; var swallowLabel = new Label(Widgets.ButtonLabelStyle, "swallow: off"); Button swallowButton = BuildButton("swallow toggle", swallowLabel, Vector.Max(LabelSize("swallow: off"), LabelSize("swallow: on"))); Vector heardSize = LabelSize("heard 000"); // Laid out before the modal exists, so the modal is sized to what it holds. The readout spans the width // so its centred text stays put as the count grows. Scalar contentWidth = Math.Max(heardSize.X, swallowButton.Bounds.Width); var content = new RectAggregator( Rect.FromPositionAndSize(Vector.Zero, new Vector(contentWidth, 0)), new Vector(0, Gutter)); var heardReadout = new Label(Widgets.ButtonLabelStyle, "heard 0") { Bounds = content.NewRow(heardSize.Y), }; Rect swallowRow = content.NewRow(swallowButton.Bounds.Height); swallowButton.Bounds = swallowButton.Bounds.WithPosition(swallowRow.TopLeft); Vector modalSize = UiPacksheetMaterials.YellowPanel.BoundsSizeFor(content.Rect.Size); Rect modalRow = ContentAggregator.NewRow(Math.Max(plainButton.Bounds.Height, modalSize.Y)); Rect buttonSlot = modalRow.NewCol(plainButton.Bounds.Width, margin: Gutter); plainButton.Bounds = plainButton.Bounds.WithPosition(buttonSlot.TopLeft); var modal = new ModalOverlay { DebugName = "modal overlay", Material = UiPacksheetMaterials.YellowPanel, Bounds = Rect.FromPositionAndSize(Vector.Zero, modalSize), OffsetFromOwner = modalRow.NewCol(modalSize.X).TopLeft, }; modal.CanvasChildren.Add(heardReadout); modal.CanvasChildren.Add(swallowButton); Add(modal); modal.OnHeard = delegate { heardReadout.Text = $"heard {modal.HeardCount}"; }; swallowButton.OnActivate += delegate (ButtonBase _, in KeyboardSnapshot __, KeyboardManager ___) { modal.Swallow = !modal.Swallow; swallowLabel.Text = modal.Swallow ? "swallow: on" : "swallow: off"; }; } protected override void Populate() { AddCaption("Left: a normal child, clipped to its owner"); AddCaption("Right: a hoisted one, escaping it and painting after the whole tree"); AddCaption("Also right: on top but not hoisted, so it rides with its owner in the owner's own space"); BuildHoistDemo(); AddCaption("A modal hears every click wherever it lands"); AddCaption("It will keep input off anything else"); BuildModalDemo(); } } }