diff --git a/Data/colours/color.dtd b/Data/colours/color.dtd index c39c5aae..d333bae6 100644 --- a/Data/colours/color.dtd +++ b/Data/colours/color.dtd @@ -30,6 +30,8 @@ gameGuideColor CDATA #IMPLIED oneButtonDynamicOuterGuidesColor CDATA #IMPLIED conversionNodeColor CDATA #IMPLIED + appearance (light|dark) #IMPLIED + companion CDATA #IMPLIED > diff --git a/Data/colours/colour.default.dark.xml b/Data/colours/colour.default.dark.xml new file mode 100644 index 00000000..a4480ee7 --- /dev/null +++ b/Data/colours/colour.default.dark.xml @@ -0,0 +1,34 @@ + + + + + diff --git a/Data/colours/colour.euroasian.dark.xml b/Data/colours/colour.euroasian.dark.xml new file mode 100644 index 00000000..85d93a72 --- /dev/null +++ b/Data/colours/colour.euroasian.dark.xml @@ -0,0 +1,33 @@ + + + + + diff --git a/Data/colours/colour.euroasian2.dark.xml b/Data/colours/colour.euroasian2.dark.xml new file mode 100644 index 00000000..92fa77ef --- /dev/null +++ b/Data/colours/colour.euroasian2.dark.xml @@ -0,0 +1,33 @@ + + + + + diff --git a/Data/colours/colour.rainbow.dark.xml b/Data/colours/colour.rainbow.dark.xml new file mode 100644 index 00000000..e3ba19af --- /dev/null +++ b/Data/colours/colour.rainbow.dark.xml @@ -0,0 +1,35 @@ + + + + + diff --git a/Data/colours/colour.thai.dark.xml b/Data/colours/colour.thai.dark.xml new file mode 100644 index 00000000..b86784b7 --- /dev/null +++ b/Data/colours/colour.thai.dark.xml @@ -0,0 +1,33 @@ + + + + + diff --git a/Data/colours/colour.turbo.dark.xml b/Data/colours/colour.turbo.dark.xml new file mode 100644 index 00000000..db919b97 --- /dev/null +++ b/Data/colours/colour.turbo.dark.xml @@ -0,0 +1,33 @@ + + + + + diff --git a/Data/colours/colour.vowels.dark.xml b/Data/colours/colour.vowels.dark.xml new file mode 100644 index 00000000..f5f4acc2 --- /dev/null +++ b/Data/colours/colour.vowels.dark.xml @@ -0,0 +1,33 @@ + + + + + diff --git a/Data/colours/colour.vowels2.dark.xml b/Data/colours/colour.vowels2.dark.xml new file mode 100644 index 00000000..efdf16a3 --- /dev/null +++ b/Data/colours/colour.vowels2.dark.xml @@ -0,0 +1,33 @@ + + + + + diff --git a/docs/C_API.md b/docs/C_API.md index 0003436f..10e41430 100644 --- a/docs/C_API.md +++ b/docs/C_API.md @@ -438,6 +438,24 @@ void dasher_set_palette(dasher_ctx* ctx, const char* palette_name); `dasher_get_palette_preview_colors` writes 4 ARGB preview colours into `out_colors` (must have room for 4 ints). Returns 0 on success. +### Appearance / dark mode (RFC 0007) + +Palettes may declare an `appearance` (`light`/`dark`) and a `companion` (their opposite-appearance partner) in their XML. Use these to follow the OS light/dark preference without hardcoding name pairs per frontend. + +```c +int dasher_get_palette_appearance(dasher_ctx* ctx, int index); // 0=unspecified, 1=light, 2=dark, -1=oor +const char* dasher_find_companion_palette(dasher_ctx* ctx, const char* palette_name); // NULL if none +int dasher_set_appearance(dasher_ctx* ctx, int appearance); // 1=light, 2=dark; 0 on success, -1 if no companion +``` + +`dasher_set_appearance` switches the current palette to its companion that matches the requested appearance; if the current palette already matches it is a no-op. If no companion is available it returns `-1` and leaves the palette unchanged (the user's explicit choice is respected). Companion lookup is bidirectional, so legacy palettes without metadata are still paired with a dark companion that names them. + +Typical frontend usage on an OS appearance change: + +```c +dasher_set_appearance(ctx, is_dark ? 2 : 1); +``` + ## Game Mode ```c diff --git a/src/CAPI.cpp b/src/CAPI.cpp index 8b24c5d1..26ba06a7 100644 --- a/src/CAPI.cpp +++ b/src/CAPI.cpp @@ -9,6 +9,7 @@ #include "DasherCore/XmlSettingsStore.h" #include "DasherCore/FileUtils.h" #include "DasherCore/ColorPalette.h" +#include "DasherCore/ColorIO.h" #include "DasherCore/GameModule.h" #include "DasherCore/Alphabet/AlphInfo.h" #include "DasherCore/Alphabet/AlphIO.h" @@ -988,6 +989,88 @@ DASHER_API void dasher_set_palette(dasher_ctx* ctx, const char* palette_name) { ctx->intf->SetStringParameter(Dasher::SP_COLOUR_ID, std::string(palette_name)); } +// ── Appearance / dark mode (RFC 0007) ────────────────────────────────────── + +namespace { +// Bidirectional companion lookup (RFC 0007). Returns the opposite-appearance +// partner palette, or nullptr if none. If the palette declares an explicit +// `companion`, that is used; otherwise we scan for a palette that declares this +// one as its companion (so legacy palettes without metadata are still paired). +const Dasher::ColorPalette* companionLookup(Dasher::CColorIO* colorIO, const std::string& name) { + if (!colorIO) return nullptr; + const Dasher::ColorPalette* p = colorIO->FindPalette(name); + if (!p || p->PaletteName != name) return nullptr; // FindPalette falls back to default + + // Forward: explicit companion declared and resolvable. + if (!p->CompanionName.empty()) { + const Dasher::ColorPalette* q = colorIO->FindPalette(p->CompanionName); + if (q && q->PaletteName == p->CompanionName && q != p) return q; + } + // Reverse: some other palette declares this one as its companion. + const auto* all = colorIO->GetKnownPalettes(); + for (const auto& [n, q] : *all) { + if (q == p) continue; + if (q->CompanionName == name) return q; + } + return nullptr; +} + +// Effective appearance, treating an unspecified palette whose companion is dark +// as effectively light (the common legacy-palette case). +Dasher::ColorPalette::Appearance effectiveAppearance(Dasher::CColorIO* colorIO, const Dasher::ColorPalette* p) { + using App = Dasher::ColorPalette::Appearance; + if (!p) return App::Unspecified; + if (p->AppearanceValue != App::Unspecified) return p->AppearanceValue; + const Dasher::ColorPalette* comp = companionLookup(colorIO, p->PaletteName); + if (comp && comp->AppearanceValue == App::Dark) return App::Light; + return App::Unspecified; +} +} // namespace + +DASHER_API int dasher_get_palette_appearance(dasher_ctx* ctx, int index) { + if (!ctx || !ctx->intf) return -1; + auto colorIO = ctx->intf->GetColorIO(); + if (!colorIO) return -1; + auto names = ctx->intf->GetPermittedValues(Dasher::SP_COLOUR_ID); + if (index < 0 || index >= static_cast(names.size())) return -1; + const Dasher::ColorPalette* p = colorIO->FindPalette(names[index]); + if (!p || p->PaletteName != names[index]) return 0; // not found -> unspecified + return static_cast(p->AppearanceValue); +} + +DASHER_API const char* dasher_find_companion_palette(dasher_ctx* ctx, const char* palette_name) { + if (!ctx || !ctx->intf || !palette_name) return nullptr; + auto colorIO = ctx->intf->GetColorIO(); + if (!colorIO) return nullptr; + const Dasher::ColorPalette* comp = companionLookup(colorIO, palette_name); + if (!comp) return nullptr; + ctx->tlString = comp->PaletteName; + return ctx->tlString.c_str(); +} + +DASHER_API int dasher_set_appearance(dasher_ctx* ctx, int appearance) { + if (!ctx || !ctx->intf) return -1; + if (appearance != 1 && appearance != 2) return -1; + using App = Dasher::ColorPalette::Appearance; + App target = (appearance == 1) ? App::Light : App::Dark; + + auto colorIO = ctx->intf->GetColorIO(); + if (!colorIO) return -1; + std::string currentName = ctx->intf->GetStringParameter(Dasher::SP_COLOUR_ID); + const Dasher::ColorPalette* current = colorIO->FindPalette(currentName); + + // Candidate 1: the current palette already matches. + if (current && effectiveAppearance(colorIO, current) == target) return 0; + + // Candidate 2: the current palette's companion matches. + const Dasher::ColorPalette* comp = companionLookup(colorIO, currentName); + if (comp && effectiveAppearance(colorIO, comp) == target) { + dasher_set_palette(ctx, comp->PaletteName.c_str()); + return 0; + } + return -1; // no suitable companion; leave the current palette unchanged +} + // ── Alphabets ────────────────────────────────────────────────────────────── DASHER_API int dasher_get_alphabet_count(dasher_ctx* ctx) { diff --git a/src/DasherCore/ColorIO.cpp b/src/DasherCore/ColorIO.cpp index 61a64771..4b87f21c 100644 --- a/src/DasherCore/ColorIO.cpp +++ b/src/DasherCore/ColorIO.cpp @@ -174,8 +174,19 @@ bool CColorIO::Parse(pugi::xml_document& document, const std::string, bool bUser std::string parentName = outer.attribute("parentName").as_string(HardcodedDefaultPalette->PaletteName.c_str()); std::string colorSchemeName = outer.attribute("name").as_string(); // definitely exists, we checked above + // RFC 0007: appearance classification + companion link (not colour values). + std::string appearanceStr = outer.attribute("appearance").as_string(""); + std::string companionName = outer.attribute("companion").as_string(""); + ColorPalette::Appearance appearance = ColorPalette::Appearance::Unspecified; + if (appearanceStr == "light") + appearance = ColorPalette::Appearance::Light; + else if (appearanceStr == "dark") + appearance = ColorPalette::Appearance::Dark; + for (pugi::xml_attribute attribute : outer.attributes()) { - if (strcmp(attribute.name(), "parentName") == 0 || strcmp(attribute.name(), "name") == 0) continue; + if (strcmp(attribute.name(), "parentName") == 0 || strcmp(attribute.name(), "name") == 0 || + strcmp(attribute.name(), "appearance") == 0 || strcmp(attribute.name(), "companion") == 0) + continue; if (strcmp(attribute.name(), "uiPreviewColors") == 0) { UIPreviewColors = GetAttributeAsColorList(attribute); @@ -231,8 +242,8 @@ bool CColorIO::Parse(pugi::xml_document& document, const std::string, bool bUser //"HardcodedDefault" is the parent for now, later on the parents get relinked by looking up the parentNames auto it2 = KnownPalettes.find(colorSchemeName); if (it2 != KnownPalettes.end()) delete it2->second; - KnownPalettes[colorSchemeName] = - new ColorPalette(HardcodedDefaultPalette, parentName, NamedColors, GroupColors, uiColorsArray, colorSchemeName); + KnownPalettes[colorSchemeName] = new ColorPalette(HardcodedDefaultPalette, parentName, NamedColors, GroupColors, + uiColorsArray, colorSchemeName, appearance, companionName); return true; } diff --git a/src/DasherCore/ColorPalette.cpp b/src/DasherCore/ColorPalette.cpp index b2ed5cc9..76c46a0a 100644 --- a/src/DasherCore/ColorPalette.cpp +++ b/src/DasherCore/ColorPalette.cpp @@ -60,9 +60,11 @@ ColorPalette::Color ColorPalette::Color::lerp(const Color& ColorA, const Color& ColorPalette::ColorPalette(ColorPalette* ParentPalette, std::string ParentPaletteName, const std::unordered_map& NamedColors, const std::unordered_map& GroupColors, - std::array UIPreviewColors, std::string PaletteName) + std::array UIPreviewColors, std::string PaletteName, Appearance appearance, + std::string companionName) : ParentPalette(ParentPalette), ParentPaletteName(std::move(ParentPaletteName)), PaletteName(PaletteName), - NamedColors(NamedColors), GroupColors(GroupColors), UIPreviewColors(UIPreviewColors) {} + AppearanceValue(appearance), CompanionName(std::move(companionName)), NamedColors(NamedColors), + GroupColors(GroupColors), UIPreviewColors(UIPreviewColors) {} const ColorPalette::Color& ColorPalette::GetAltColor(const std::vector& NormalColors, const std::vector& AltColors, bool useAlt, @@ -88,6 +90,20 @@ const ColorPalette::Color& ColorPalette::GetNamedColor(const NamedColor::knownCo } const std::array& ColorPalette::GetUIPreviewColors() const { + // A palette parsed without explicit preview colours and with no suitable group + // is assigned the error sentinel (black/magenta). For inheritance-based palettes + // (e.g. dark companions with no groups of their own), fall back to the parent's + // preview so the picker shows the parent's representative colours. (RFC 0007) + static const std::array sentinel = {Color(0, 0, 0, 255), Color(255, 0, 255, 255), Color(0, 0, 0, 255), + Color(255, 0, 255, 255)}; + bool isSentinel = true; + for (int i = 0; i < 4; i++) { + if (!(UIPreviewColors[i] == sentinel[i])) { + isSentinel = false; + break; + } + } + if (isSentinel && ParentPalette) return ParentPalette->GetUIPreviewColors(); return UIPreviewColors; } diff --git a/src/DasherCore/ColorPalette.h b/src/DasherCore/ColorPalette.h index a80af037..db618792 100644 --- a/src/DasherCore/ColorPalette.h +++ b/src/DasherCore/ColorPalette.h @@ -44,6 +44,11 @@ static const knownColorName gameGuide = "gameGuideColor"; class ColorPalette { public: + // Visual classification of a palette for system-appearance matching (RFC 0007). + // Unspecified palettes (e.g. legacy-format files without metadata) are still + // pairable via a bidirectional companion lookup. + enum class Appearance : int { Unspecified = 0, Light = 1, Dark = 2 }; + typedef struct Color { int Red = 0; int Green = 0; @@ -96,7 +101,8 @@ class ColorPalette { ColorPalette(ColorPalette* ParentPalette, std::string ParentPaletteName, const std::unordered_map& NamedColors, const std::unordered_map& GroupColors, - std::array UIPreviewColors, std::string PaletteName); + std::array UIPreviewColors, std::string PaletteName, + Appearance appearance = Appearance::Unspecified, std::string companionName = ""); const Color& GetAltColor(const std::vector& NormalColors, const std::vector& AltColors, bool useAlt, int Index) const; const Color& GetAltColor(const Color& NormalColor, const Color& AltColor, bool useAlt) const; @@ -106,6 +112,9 @@ class ColorPalette { std::string ParentPaletteName; std::string PaletteName; + Appearance AppearanceValue = Appearance::Unspecified; // RFC 0007: light/dark classification + std::string CompanionName; // RFC 0007: opposite-appearance partner name + const Color& GetNamedColor(const NamedColor::knownColorName& NamedColor, bool AskParent = true) const; const std::array& GetUIPreviewColors() const; diff --git a/src/dasher.h b/src/dasher.h index 2624ed9c..c834cd34 100644 --- a/src/dasher.h +++ b/src/dasher.h @@ -221,6 +221,33 @@ DASHER_API int dasher_get_palette_preview_colors(dasher_ctx* ctx, int index, int // Set the active colour palette by name. DASHER_API void dasher_set_palette(dasher_ctx* ctx, const char* palette_name); +// ── Appearance / dark mode (RFC 0007) ───────────────────────────────────── +// +// Palettes may declare an `appearance` ("light" or "dark") and a `companion` +// (the name of their opposite-appearance partner) in their XML. Frontends can +// use these to follow the OS light/dark preference by switching to the current +// palette's companion, without each frontend hardcoding name pairs. +// +// Legacy palettes without metadata report appearance 0 (unspecified) but are +// still paired via a bidirectional companion lookup: if palette A declares +// companion="B", then B's effective companion is A. + +// Get the appearance classification of a palette. +// Returns: 0 = unspecified, 1 = light, 2 = dark, -1 = index out of range. +DASHER_API int dasher_get_palette_appearance(dasher_ctx* ctx, int index); + +// Find the companion (opposite-appearance) palette for the given palette name. +// Lookup is bidirectional (see above). Returns the companion name (valid until +// the next API call), or NULL if the palette has no companion. +DASHER_API const char* dasher_find_companion_palette(dasher_ctx* ctx, const char* palette_name); + +// Switch to the companion of the current palette that matches the requested +// appearance. appearance: 1 = light, 2 = dark. +// If the current palette already matches, this is a no-op and returns 0. +// Returns 0 on success, -1 if no suitable companion is available (in which case +// the current palette is left unchanged — the user's explicit choice is respected). +DASHER_API int dasher_set_appearance(dasher_ctx* ctx, int appearance); + // ── Alphabets ───────────────────────────────────────────────────────────── // Get the number of available alphabets. diff --git a/tests/test_color_math.cpp b/tests/test_color_math.cpp index 5d915d62..a981e191 100644 --- a/tests/test_color_math.cpp +++ b/tests/test_color_math.cpp @@ -111,6 +111,90 @@ TEST(color_palette_switch) { printf("v color_palette_switch passed\n"); } +TEST(color_palette_appearance_classification) { + dasher_ctx* ctx = create_isolated_context(); + ASSERT(ctx); + dasher_set_screen_size(ctx, 800, 600); + + int palette_count = dasher_get_palette_count(ctx); + ASSERT(palette_count > 0); + + // Locate "Rainbow" (legacy light, no metadata) and "Rainbow Dark" (modern, dark). + int rainbow_idx = -1, rainbow_dark_idx = -1; + for (int i = 0; i < palette_count; i++) { + const char* name = dasher_get_palette_name(ctx, i); + if (strcmp(name, "Rainbow") == 0) rainbow_idx = i; + if (strcmp(name, "Rainbow Dark") == 0) rainbow_dark_idx = i; + } + ASSERT_NEQ(rainbow_idx, -1); + ASSERT_NEQ(rainbow_dark_idx, -1); + + // Legacy Rainbow carries no metadata -> unspecified (0). + ASSERT_EQ(dasher_get_palette_appearance(ctx, rainbow_idx), 0); + // Rainbow Dark declares appearance="dark" -> 2. + ASSERT_EQ(dasher_get_palette_appearance(ctx, rainbow_dark_idx), 2); + // Out-of-range index -> -1. + ASSERT_EQ(dasher_get_palette_appearance(ctx, palette_count), -1); + + dasher_destroy(ctx); + printf("v color_palette_appearance_classification passed\n"); +} + +TEST(color_palette_companion_lookup) { + dasher_ctx* ctx = create_isolated_context(); + ASSERT(ctx); + dasher_set_screen_size(ctx, 800, 600); + + // Bidirectional: Rainbow (legacy, no metadata) resolves to Rainbow Dark via the + // reverse scan; Rainbow Dark resolves to Rainbow via its explicit companion. + const char* comp_of_rainbow = dasher_find_companion_palette(ctx, "Rainbow"); + ASSERT(comp_of_rainbow); + ASSERT_STR_EQ(comp_of_rainbow, "Rainbow Dark"); + + const char* comp_of_dark = dasher_find_companion_palette(ctx, "Rainbow Dark"); + ASSERT(comp_of_dark); + ASSERT_STR_EQ(comp_of_dark, "Rainbow"); + + // A palette with no companion (Yellow on Blue is inherently dark) returns NULL. + const char* none = dasher_find_companion_palette(ctx, "Yellow on Blue"); + ASSERT(none == nullptr); + + dasher_destroy(ctx); + printf("v color_palette_companion_lookup passed\n"); +} + +TEST(color_palette_set_appearance) { + dasher_ctx* ctx = create_isolated_context(); + ASSERT(ctx); + dasher_set_screen_size(ctx, 800, 600); + + dasher_set_palette(ctx, "Rainbow"); + ASSERT_STR_EQ(dasher_get_current_palette(ctx), "Rainbow"); + + // Request dark -> switch to Rainbow Dark. + ASSERT_EQ(dasher_set_appearance(ctx, 2), 0); + ASSERT_STR_EQ(dasher_get_current_palette(ctx), "Rainbow Dark"); + + // Requesting dark again is a no-op (already on the dark variant). + ASSERT_EQ(dasher_set_appearance(ctx, 2), 0); + ASSERT_STR_EQ(dasher_get_current_palette(ctx), "Rainbow Dark"); + + // Request light -> switch back to Rainbow. + ASSERT_EQ(dasher_set_appearance(ctx, 1), 0); + ASSERT_STR_EQ(dasher_get_current_palette(ctx), "Rainbow"); + + // A palette with no companion cannot switch -> -1, and is left unchanged. + dasher_set_palette(ctx, "Yellow on Blue"); + ASSERT_EQ(dasher_set_appearance(ctx, 1), -1); + ASSERT_STR_EQ(dasher_get_current_palette(ctx), "Yellow on Blue"); + + // Invalid appearance value -> -1. + ASSERT_EQ(dasher_set_appearance(ctx, 9), -1); + + dasher_destroy(ctx); + printf("v color_palette_set_appearance passed\n"); +} + int main() { printf("Running color math tests...\n\n"); @@ -122,6 +206,9 @@ int main() { test_color_round_trip_many(); test_color_palette_preview_nonempty(); test_color_palette_switch(); + test_color_palette_appearance_classification(); + test_color_palette_companion_lookup(); + test_color_palette_set_appearance(); printf("\nAll color math tests passed!\n"); return 0;