using Godot; using System.IO; using Theriapolis.GodotHost.Platform; namespace Theriapolis.GodotHost.UI; /// /// Programmatic Theme builder for the codex design system. Builds a Godot /// from a and the shared /// CodexSpacing / CodexType tokens; the result is applied to a root /// Control to cascade through all descendants. /// /// Fonts: looks for FontFile assets under res://Fonts/ and falls /// back to Godot's default sans if missing. Drop these in to fully match /// the React prototype's typography: /// res://Fonts/CormorantGaramond-Regular.ttf (serif-display) /// res://Fonts/CormorantGaramond-Italic.ttf /// res://Fonts/CrimsonPro-Regular.ttf (serif-body) /// res://Fonts/JetBrainsMono-Regular.ttf (mono) /// /// Theme variations (.tres) aren't authored in the editor — the entire /// theme tree is constructed in code so palette changes are atomic and /// reviewable as code diffs. /// public static class CodexTheme { private static FontFile? _serifDisplay; private static FontFile? _serifDisplayItalic; private static FontFile? _serifBody; private static FontFile? _mono; private static bool _fontsLoaded; public static Theme Build() { EnsureFonts(); var palette = CodexPalette.Dark; var theme = new Theme(); // Defaults applied to every Control unless overridden. if (_serifBody is not null) theme.DefaultFont = _serifBody; theme.DefaultFontSize = CodexType.BodySize; ApplyPanel(theme, palette); ApplyLabel(theme, palette); ApplyButton(theme, palette); ApplyLineEdit(theme, palette); ApplyCheckBox(theme, palette); ApplyScrollContainer(theme, palette); return theme; } private static void ApplyPanel(Theme theme, CodexPalette p) { // Default Panel (used as background frame). Mirrors .app-frame's // border + bg-with-overlay treatment, simplified to a single // StyleBoxFlat with border. var box = new StyleBoxFlat { BgColor = p.Bg, BorderColor = p.Rule, CornerRadiusTopLeft = CodexSpacing.Radius, CornerRadiusTopRight = CodexSpacing.Radius, CornerRadiusBottomLeft = CodexSpacing.Radius, CornerRadiusBottomRight = CodexSpacing.Radius, }; box.SetBorderWidthAll(1); theme.SetStylebox("panel", "Panel", box); // Card variant — slightly raised against the page background. // Used by character-creation grid cards (Calling, History, etc.). var card = new StyleBoxFlat { BgColor = p.Bg2, BorderColor = p.Rule, CornerRadiusTopLeft = CodexSpacing.Radius, CornerRadiusTopRight = CodexSpacing.Radius, CornerRadiusBottomLeft = CodexSpacing.Radius, CornerRadiusBottomRight = CodexSpacing.Radius, ContentMarginLeft = 18, ContentMarginRight = 18, ContentMarginTop = 18, ContentMarginBottom = 16, }; card.SetBorderWidthAll(1); theme.SetStylebox("panel", "Card", card); var cardSelected = (StyleBoxFlat)card.Duplicate(); cardSelected.BorderColor = p.Seal; cardSelected.SetBorderWidthAll(1); cardSelected.ShadowColor = WithAlpha(p.Seal, 0.6f); cardSelected.ShadowSize = 14; cardSelected.ShadowOffset = new Vector2(0, 14); theme.SetStylebox("panel_selected", "Card", cardSelected); // Popover frame — gild border + soft shadow. Matches .trait-hint. var popover = new StyleBoxFlat { BgColor = p.Bg2, BorderColor = p.Gild, ContentMarginLeft = 16, ContentMarginRight = 16, ContentMarginTop = 14, ContentMarginBottom = 12, ShadowColor = new Color(0, 0, 0, 0.45f), ShadowSize = 18, ShadowOffset = new Vector2(0, 12), }; popover.SetBorderWidthAll(1); theme.SetStylebox("panel", "CodexPopover", popover); var popoverDetriment = (StyleBoxFlat)popover.Duplicate(); popoverDetriment.BorderColor = p.Seal; theme.SetStylebox("panel_detriment", "CodexPopover", popoverDetriment); } private static void ApplyLabel(Theme theme, CodexPalette p) { // Default Label — body type, soft ink. theme.SetColor("font_color", "Label", p.Ink); theme.SetFontSize("font_size", "Label", CodexType.BodySize); if (_serifBody is not null) theme.SetFont("font", "Label", _serifBody); // h1..h4 variations applied via SetTypeVariation in code (Godot 4 // type variations work by setting a control's ThemeTypeVariation). AddLabelVariation(theme, "H1", p.Ink, CodexType.H1Size, _serifDisplay); AddLabelVariation(theme, "H2", p.Ink, CodexType.H2Size, _serifDisplay); AddLabelVariation(theme, "H3", p.Ink, CodexType.H3Size, _serifDisplay); AddLabelVariation(theme, "H4", p.InkMute, CodexType.H4Size, _serifDisplay); // .codex-title — uppercased, wide tracking. AddLabelVariation(theme, "CodexTitle", p.Ink, CodexType.CodexTitleSize, _serifDisplay); // .codex-sub / .eyebrow / .meta — mono micro-text in muted ink. AddLabelVariation(theme, "Eyebrow", p.InkMute, CodexType.MonoEyebrowSize, _mono); // .nav-progress — same mono micro-text style. AddLabelVariation(theme, "Meta", p.InkMute, CodexType.MonoSmallSize, _mono); // .validation — italicised seal-red when error, mono-mute when ok. AddLabelVariation(theme, "ValidationError", p.Seal, CodexType.BodySize, _serifDisplay); AddLabelVariation(theme, "ValidationOk", p.InkMute, CodexType.MonoEyebrowSize, _mono); // Card name + body AddLabelVariation(theme, "CardName", p.Ink, CodexType.CardNameSize, _serifDisplay); AddLabelVariation(theme, "CardBody", p.InkSoft, CodexType.CardBodySize, _serifBody); AddLabelVariation(theme, "CardMeta", p.InkMute, CodexType.MonoSmallSize, _mono); // Stepper labels AddLabelVariation(theme, "StepperNum", p.InkMute, CodexType.StepperNumSize, _serifDisplay); AddLabelVariation(theme, "StepperName", p.InkMute, CodexType.StepperNameSize, _mono); } private static void AddLabelVariation(Theme theme, string variation, Color color, int size, FontFile? font) { theme.SetTypeVariation(variation, "Label"); theme.SetColor("font_color", variation, color); theme.SetFontSize("font_size", variation, size); if (font is not null) theme.SetFont("font", variation, font); } private static void ApplyButton(Theme theme, CodexPalette p) { if (_serifDisplay is not null) theme.SetFont("font", "Button", _serifDisplay); theme.SetFontSize("font_size", "Button", CodexType.BtnSize); theme.SetColor("font_color", "Button", p.Ink); theme.SetColor("font_hover_color", "Button", p.Bg); theme.SetColor("font_pressed_color", "Button", p.Bg); theme.SetColor("font_disabled_color","Button", WithAlpha(p.Ink, 0.4f)); var normal = MakeButtonBox(p.Bg, p.Ink); var hover = MakeButtonBox(p.Ink, p.Ink); var pressed = MakeButtonBox(p.InkSoft, p.Ink); var disabled = MakeButtonBox(p.Bg, WithAlpha(p.Ink, 0.4f)); theme.SetStylebox("normal", "Button", normal); theme.SetStylebox("hover", "Button", hover); theme.SetStylebox("pressed", "Button", pressed); theme.SetStylebox("disabled", "Button", disabled); // Primary variant — seal-red fill. theme.SetTypeVariation("PrimaryButton", "Button"); theme.SetColor("font_color", "PrimaryButton", p.Bg); theme.SetColor("font_hover_color", "PrimaryButton", p.Bg); theme.SetStylebox("normal", "PrimaryButton", MakeButtonBox(p.Seal, p.Seal)); theme.SetStylebox("hover", "PrimaryButton", MakeButtonBox(p.Seal2, p.Seal2)); theme.SetStylebox("pressed", "PrimaryButton", MakeButtonBox(p.Seal2, p.Seal2)); // Ghost variant — transparent background, rule-coloured border. theme.SetTypeVariation("GhostButton", "Button"); theme.SetColor("font_color", "GhostButton", p.InkSoft); theme.SetColor("font_hover_color", "GhostButton", p.Ink); theme.SetStylebox("normal", "GhostButton", MakeButtonBox(new Color(0, 0, 0, 0), p.Rule)); theme.SetStylebox("hover", "GhostButton", MakeButtonBox(WithAlpha(p.Ink, 0.06f), p.Rule)); theme.SetStylebox("pressed", "GhostButton", MakeButtonBox(WithAlpha(p.Ink, 0.12f), p.Rule)); } private static StyleBoxFlat MakeButtonBox(Color bg, Color border) { var box = new StyleBoxFlat { BgColor = bg, BorderColor = border, CornerRadiusTopLeft = CodexSpacing.Radius, CornerRadiusTopRight = CodexSpacing.Radius, CornerRadiusBottomLeft = CodexSpacing.Radius, CornerRadiusBottomRight = CodexSpacing.Radius, ContentMarginLeft = 22, ContentMarginRight = 22, ContentMarginTop = 10, ContentMarginBottom = 10, }; box.SetBorderWidthAll(1); return box; } private static void ApplyLineEdit(Theme theme, CodexPalette p) { if (_serifBody is not null) theme.SetFont("font", "LineEdit", _serifBody); theme.SetFontSize("font_size", "LineEdit", CodexType.BodySize); theme.SetColor("font_color", "LineEdit", p.Ink); theme.SetColor("caret_color", "LineEdit", p.Seal); theme.SetColor("selection_color", "LineEdit", WithAlpha(p.Gild, 0.5f)); var normal = new StyleBoxFlat { BgColor = p.Bg, BorderColor = p.Rule, ContentMarginLeft = 12, ContentMarginRight = 12, ContentMarginTop = 8, ContentMarginBottom = 8, }; normal.SetBorderWidthAll(1); theme.SetStylebox("normal", "LineEdit", normal); var focus = (StyleBoxFlat)normal.Duplicate(); focus.BorderColor = p.Gild; focus.SetBorderWidthAll(1); theme.SetStylebox("focus", "LineEdit", focus); } private static void ApplyCheckBox(Theme theme, CodexPalette p) { if (_serifBody is not null) theme.SetFont("font", "CheckBox", _serifBody); theme.SetFontSize("font_size", "CheckBox", CodexType.BodySize); theme.SetColor("font_color", "CheckBox", p.Ink); } private static void ApplyScrollContainer(Theme theme, CodexPalette p) { // Scroll bar pieces — keep subtle so they don't dominate the codex feel. var bg = new StyleBoxFlat { BgColor = WithAlpha(p.Rule, 0.15f) }; var grabber = new StyleBoxFlat { BgColor = WithAlpha(p.InkMute, 0.7f), CornerRadiusTopLeft = CodexSpacing.Radius, CornerRadiusTopRight = CodexSpacing.Radius, CornerRadiusBottomLeft = CodexSpacing.Radius, CornerRadiusBottomRight = CodexSpacing.Radius, }; theme.SetStylebox("scroll", "VScrollBar", bg); theme.SetStylebox("grabber", "VScrollBar", grabber); theme.SetStylebox("grabber_highlight", "VScrollBar", (StyleBoxFlat)grabber.Duplicate(true)); theme.SetStylebox("scroll", "HScrollBar", bg); theme.SetStylebox("grabber", "HScrollBar", grabber); } // ────────────────────────────────────────────────────────────────────── private static void EnsureFonts() { if (_fontsLoaded) return; _fontsLoaded = true; _serifDisplay = LoadFontFromFonts("CormorantGaramond-Regular.ttf"); _serifDisplayItalic = LoadFontFromFonts("CormorantGaramond-Italic.ttf"); _serifBody = LoadFontFromFonts("CrimsonPro-Regular.ttf"); _mono = LoadFontFromFonts("JetBrainsMono-Regular.ttf"); if (_serifDisplay is null && _serifBody is null && _mono is null) { GD.Print("[codex-theme] No fonts in res://Fonts/. Using Godot defaults. " + "Drop CormorantGaramond, CrimsonPro, JetBrainsMono TTFs into " + "res://Fonts/ for full design parity."); } } private static FontFile? LoadFontFromFonts(string filename) { // Try res://Fonts/ first (Godot-managed). string resPath = $"res://Fonts/{filename}"; if (ResourceLoader.Exists(resPath)) return ResourceLoader.Load(resPath); // Fall back to Content/Fonts/ via filesystem load (sibling of Gfx, // mirrors how MonoGame's CodexFonts loader walks). string fsPath = Path.Combine(ContentPaths.ContentRoot, "Fonts", filename); if (File.Exists(fsPath)) { var font = new FontFile(); font.LoadDynamicFont(fsPath); return font; } return null; } // ────────────────────────────────────────────────────────────────────── public static Color WithAlpha(Color c, float a) => new(c.R, c.G, c.B, a); }