feat(ui): add decision-log reader and overlay components

This commit is contained in:
0xrsydn 2026-09-22 12:45:46 +07:00
commit 2dc711a55c
9 changed files with 469 additions and 0 deletions

View file

@ -0,0 +1,184 @@
using Godot;
using JevOverlay.Core;
namespace JevOverlay.UI;
/// <summary>Godot controls only. This view cannot read files or call the game API.</summary>
public sealed class OverlayView
{
private readonly CanvasLayer layer = new() { Name = "JevDecisionOverlay", Layer = 100 };
private readonly PanelContainer panel = new() { MouseFilter = Control.MouseFilterEnum.Stop };
private readonly VBoxContainer body = new();
private readonly Label status = new() { AutowrapMode = TextServer.AutowrapMode.WordSmart };
private readonly RichTextLabel summary = TextArea();
private readonly RichTextLabel evidence = TextArea();
private readonly ItemList history = new()
{
CustomMinimumSize = new(0, 110),
FocusMode = Control.FocusModeEnum.None,
SizeFlagsVertical = Control.SizeFlags.ExpandFill,
};
private readonly Button collapse = Button("", "Collapse panel (F8)");
private readonly Button details = Button("Show questions and answers");
private readonly Button follow = Button("Follow latest");
private IReadOnlyList<DecisionEntry> entries = [];
private DecisionKey? selected;
private bool following = true;
private bool collapsed;
private bool expanded;
public OverlayView(SceneTree tree)
{
var root = new Control { MouseFilter = Control.MouseFilterEnum.Ignore };
layer.AddChild(root);
tree.Root.AddChild(layer);
root.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect);
root.AddChild(panel);
panel.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.TopRight);
var style = new StyleBoxFlat
{
BgColor = new Color(0.055f, 0.065f, 0.085f, 0.96f),
BorderColor = new Color(0.3f, 0.4f, 0.5f),
BorderWidthLeft = 1, BorderWidthRight = 1, BorderWidthTop = 1, BorderWidthBottom = 1,
CornerRadiusTopLeft = 8, CornerRadiusTopRight = 8,
CornerRadiusBottomLeft = 8, CornerRadiusBottomRight = 8,
ContentMarginLeft = 12, ContentMarginRight = 12,
ContentMarginTop = 10, ContentMarginBottom = 10,
};
panel.AddThemeStyleboxOverride("panel", style);
panel.Theme = new Theme { DefaultFontSize = 16 };
panel.AddThemeColorOverride("font_color", new Color(0.92f, 0.94f, 0.96f));
var layout = new VBoxContainer();
layout.AddThemeConstantOverride("separation", 8);
panel.AddChild(layout);
var header = new HBoxContainer();
layout.AddChild(header);
header.AddChild(new Label
{
Text = "Jev decisions · read only",
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
MouseFilter = Control.MouseFilterEnum.Ignore,
});
header.AddChild(collapse);
collapse.Pressed += ToggleCollapsed;
layout.AddChild(body);
body.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
body.AddThemeConstantOverride("separation", 8);
body.AddChild(status);
body.AddChild(summary);
body.AddChild(details);
body.AddChild(evidence);
evidence.Visible = false;
details.Pressed += () =>
{
expanded = !expanded;
evidence.Visible = expanded;
details.Text = expanded ? "Hide questions and answers" : "Show questions and answers";
};
body.AddChild(new Label { Text = "Recent decisions · select to inspect" });
body.AddChild(history);
body.AddChild(follow);
follow.ToggleMode = true;
follow.ButtonPressed = true;
follow.Toggled += enabled =>
{
following = enabled;
if (enabled) SelectLatest();
};
history.ItemSelected += index =>
{
if (index < 0 || index >= entries.Count) return;
following = false;
follow.SetPressedNoSignal(false);
selected = entries[(int)index].Key;
RenderSelection();
};
summary.Text = "Waiting for decision records.\nF8 collapses or expands this panel.";
}
public void ToggleCollapsed()
{
collapsed = !collapsed;
body.Visible = !collapsed;
collapse.Text = collapsed ? "+" : "";
collapse.TooltipText = collapsed ? "Expand panel (F8)" : "Collapse panel (F8)";
}
public void Resize(Vector2 viewport)
{
panel.OffsetLeft = -Math.Min(440, Math.Max(280, viewport.X - 32));
panel.OffsetRight = -16;
panel.OffsetTop = 64;
panel.OffsetBottom = collapsed ? 112 : Math.Min(viewport.Y - 16, expanded ? 764 : 584);
}
public void SetStatus(string text, string? tooltip = null)
{
status.Text = text;
status.TooltipText = tooltip ?? text;
}
public void Render(DecisionHistory model)
{
// Copy the list; selection must refer to the same ordering as the visible rows.
entries = model.Entries.ToArray();
history.Clear();
foreach (var entry in entries)
{
int index = history.AddItem($"#{entry.Key.Step} {entry.Source.ToUpperInvariant()} {entry.Action}");
history.SetItemTooltip(index, $"Session: {entry.Key.Session}\n{entry.Status}");
history.SetItemCustomFgColor(index, entry.Source switch
{
"jev" => new Color(0.55f, 0.8f, 1f),
"fallback" => new Color(1f, 0.75f, 0.4f),
_ => new Color(0.8f, 0.85f, 0.85f),
});
}
if (following) SelectLatest();
else RenderSelection();
}
private void SelectLatest()
{
selected = entries.Count == 0 ? null : entries[0].Key;
RenderSelection();
if (entries.Count > 0) history.EnsureCurrentIsVisible();
}
private void RenderSelection()
{
int index = -1;
for (int i = 0; i < entries.Count; i++)
if (entries[i].Key == selected) { index = i; break; }
if (index < 0)
{
summary.Text = selected is null ? "Waiting for decision records."
: "This decision is no longer in the recent history. Select Follow latest.";
evidence.Text = "No decision selected.";
}
else
{
history.Select(index);
summary.Text = entries[index].Summary();
evidence.Text = entries[index].Evidence();
}
}
public void Dispose() => layer.QueueFree();
private static Button Button(string text, string tooltip = "") => new()
{
Text = text, TooltipText = tooltip, FocusMode = Control.FocusModeEnum.None,
};
private static RichTextLabel TextArea() => new()
{
BbcodeEnabled = false,
SelectionEnabled = true,
ScrollActive = true,
CustomMinimumSize = new(0, 90),
SizeFlagsVertical = Control.SizeFlags.ExpandFill,
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
FocusMode = Control.FocusModeEnum.None,
};
}