From bd28d9d6486c62956ec52f67ba6975c509761511 Mon Sep 17 00:00:00 2001 From: Brackyt <60280126+Brackyt@users.noreply.github.com> Date: Tue, 27 Jan 2026 23:49:16 +0100 Subject: [PATCH] feat: Enhance ThemeEngine and apply new theming to SettingsActivity - Improve ThemeManager with Children property support and safe integer parsing. - Refactor SettingsActivity to use new theme elements. - Update BasicElements and LayoutElements for better rendering. --- lib/ThemeEngine/include/BasicElements.h | 215 +---------- lib/ThemeEngine/include/LayoutElements.h | 182 +++++++-- lib/ThemeEngine/include/ListElement.h | 1 + lib/ThemeEngine/include/ThemeContext.h | 378 +++++++++++++++---- lib/ThemeEngine/include/ThemeTypes.h | 14 +- lib/ThemeEngine/include/UIElement.h | 1 + lib/ThemeEngine/src/BasicElements.cpp | 256 ++++++++++++- lib/ThemeEngine/src/IniParser.cpp | 11 +- lib/ThemeEngine/src/LayoutElements.cpp | 70 ++-- lib/ThemeEngine/src/ThemeManager.cpp | 172 +++++---- src/activities/settings/SettingsActivity.cpp | 266 ++++++++++--- src/activities/settings/SettingsActivity.h | 16 +- 12 files changed, 1118 insertions(+), 464 deletions(-) diff --git a/lib/ThemeEngine/include/BasicElements.h b/lib/ThemeEngine/include/BasicElements.h index 839978f2..bd3922c4 100644 --- a/lib/ThemeEngine/include/BasicElements.h +++ b/lib/ThemeEngine/include/BasicElements.h @@ -24,16 +24,17 @@ class Container : public UIElement { public: explicit Container(const std::string& id) : UIElement(id), bgColorExpr(Expression::parse("0xFF")) {} - virtual ~Container() { - for (auto child : children) delete child; - } + virtual ~Container() = default; Container* asContainer() override { return this; } ElementType getType() const override { return ElementType::Container; } + const char* getTypeName() const override { return "Container"; } void addChild(UIElement* child) { children.push_back(child); } + void clearChildren() { children.clear(); } + const std::vector& getChildren() const { return children; } void setBackgroundColorExpr(const std::string& expr) { @@ -87,55 +88,7 @@ class Container : public UIElement { } } - void draw(const GfxRenderer& renderer, const ThemeContext& context) override { - if (!isVisible(context)) return; - - if (hasBg) { - std::string colStr = context.evaluatestring(bgColorExpr); - uint8_t color = Color::parse(colStr).value; - // Use dithered fill for grayscale values, solid fill for black/white - // Use rounded rect if borderRadius > 0 - if (color == 0x00) { - if (borderRadius > 0) { - renderer.fillRoundedRect(absX, absY, absW, absH, borderRadius, true); - } else { - renderer.fillRect(absX, absY, absW, absH, true); - } - } else if (color >= 0xF0) { - if (borderRadius > 0) { - renderer.fillRoundedRect(absX, absY, absW, absH, borderRadius, false); - } else { - renderer.fillRect(absX, absY, absW, absH, false); - } - } else { - if (borderRadius > 0) { - renderer.fillRoundedRectDithered(absX, absY, absW, absH, borderRadius, color); - } else { - renderer.fillRectDithered(absX, absY, absW, absH, color); - } - } - } - - // Handle dynamic border expression - bool drawBorder = border; - if (hasBorderExpr()) { - drawBorder = context.evaluateBool(borderExpr.rawExpr); - } - - if (drawBorder) { - if (borderRadius > 0) { - renderer.drawRoundedRect(absX, absY, absW, absH, borderRadius, true); - } else { - renderer.drawRect(absX, absY, absW, absH, true); - } - } - - for (auto child : children) { - child->draw(renderer, context); - } - - markClean(); - } + void draw(const GfxRenderer& renderer, const ThemeContext& context) override; }; // --- Rectangle --- @@ -143,10 +96,12 @@ class Rectangle : public UIElement { bool fill = false; Expression fillExpr; // Dynamic fill based on expression Expression colorExpr; + int borderRadius = 0; public: explicit Rectangle(const std::string& id) : UIElement(id), colorExpr(Expression::parse("0x00")) {} ElementType getType() const override { return ElementType::Rectangle; } + const char* getTypeName() const override { return "Rectangle"; } void setFill(bool f) { fill = f; @@ -163,26 +118,12 @@ class Rectangle : public UIElement { markDirty(); } - void draw(const GfxRenderer& renderer, const ThemeContext& context) override { - if (!isVisible(context)) return; - - std::string colStr = context.evaluatestring(colorExpr); - uint8_t color = Color::parse(colStr).value; - bool black = (color == 0x00); - - bool shouldFill = fill; - if (!fillExpr.empty()) { - shouldFill = context.evaluateBool(fillExpr.rawExpr); - } - - if (shouldFill) { - renderer.fillRect(absX, absY, absW, absH, black); - } else { - renderer.drawRect(absX, absY, absW, absH, black); - } - - markClean(); + void setBorderRadius(int r) { + borderRadius = r; + markDirty(); } + + void draw(const GfxRenderer& renderer, const ThemeContext& context) override; }; // --- Label --- @@ -201,6 +142,7 @@ class Label : public UIElement { public: explicit Label(const std::string& id) : UIElement(id), colorExpr(Expression::parse("0x00")) {} ElementType getType() const override { return ElementType::Label; } + const char* getTypeName() const override { return "Label"; } void setText(const std::string& expr) { textExpr = Expression::parse(expr); @@ -231,132 +173,7 @@ class Label : public UIElement { markDirty(); } - void draw(const GfxRenderer& renderer, const ThemeContext& context) override { - if (!isVisible(context)) return; - - std::string finalText = context.evaluatestring(textExpr); - if (finalText.empty()) { - markClean(); - return; - } - - std::string colStr = context.evaluatestring(colorExpr); - uint8_t color = Color::parse(colStr).value; - bool black = (color == 0x00); - - int textWidth = renderer.getTextWidth(fontId, finalText.c_str()); - int lineHeight = renderer.getLineHeight(fontId); - - // Split text into lines based on width - std::vector lines; - if (absW > 0 && textWidth > absW && maxLines > 1) { - // Logic to wrap text - std::string remaining = finalText; - while (!remaining.empty() && (int)lines.size() < maxLines) { - // If it fits, add entire line - if (renderer.getTextWidth(fontId, remaining.c_str()) <= absW) { - lines.push_back(remaining); - break; - } - - // Binary search for cut point - int len = remaining.length(); - int cut = len; - - // Find split point - // Optimistic start: approximate chars that fit - int avgCharWidth = renderer.getTextWidth(fontId, "a"); - if (avgCharWidth < 1) avgCharWidth = 8; - int approxChars = absW / avgCharWidth; - if (approxChars < 1) approxChars = 1; - if (approxChars >= len) approxChars = len - 1; - - // Refine from approxChars - int w = renderer.getTextWidth(fontId, remaining.substr(0, approxChars).c_str()); - if (w < absW) { - // Grow - for (int i = approxChars; i <= len; i++) { - if (renderer.getTextWidth(fontId, remaining.substr(0, i).c_str()) > absW) { - cut = i - 1; - break; - } - cut = i; - } - } else { - // Shrink - for (int i = approxChars; i > 0; i--) { - if (renderer.getTextWidth(fontId, remaining.substr(0, i).c_str()) <= absW) { - cut = i; - break; - } - } - } - - // Find last space before cut - if (cut < (int)remaining.length()) { - int space = -1; - for (int i = cut; i > 0; i--) { - if (remaining[i] == ' ') { - space = i; - break; - } - } - if (space != -1) cut = space; - } - - std::string line = remaining.substr(0, cut); - - // If we're at the last allowed line but still have more text - if ((int)lines.size() == maxLines - 1 && cut < (int)remaining.length()) { - if (ellipsis) { - line = renderer.truncatedText(fontId, remaining.c_str(), absW); - } - lines.push_back(line); - break; - } - - lines.push_back(line); - // Advance - if (cut < (int)remaining.length()) { - // Skip the space if check - if (remaining[cut] == ' ') cut++; - remaining = remaining.substr(cut); - } else { - remaining = ""; - } - } - } else { - // Single line handling (truncate if needed) - if (ellipsis && textWidth > absW && absW > 0) { - finalText = renderer.truncatedText(fontId, finalText.c_str(), absW); - } - lines.push_back(finalText); - } - - // Draw lines - int totalTextHeight = lines.size() * lineHeight; - int startY = absY; - - // Vertical centering - if (absH > 0 && totalTextHeight < absH) { - startY = absY + (absH - totalTextHeight) / 2; - } - - for (size_t i = 0; i < lines.size(); i++) { - int lineWidth = renderer.getTextWidth(fontId, lines[i].c_str()); - int drawX = absX; - - if (alignment == Alignment::Center && absW > 0) { - drawX = absX + (absW - lineWidth) / 2; - } else if (alignment == Alignment::Right && absW > 0) { - drawX = absX + absW - lineWidth; - } - - renderer.drawText(fontId, drawX, startY + i * lineHeight, lines[i].c_str(), black); - } - - markClean(); - } + void draw(const GfxRenderer& renderer, const ThemeContext& context) override; }; // --- BitmapElement --- @@ -371,6 +188,7 @@ class BitmapElement : public UIElement { cacheable = true; // Bitmaps benefit from caching } ElementType getType() const override { return ElementType::Bitmap; } + const char* getTypeName() const override { return "Bitmap"; } void setSrc(const std::string& src) { srcExpr = Expression::parse(src); @@ -416,6 +234,7 @@ class ProgressBar : public UIElement { {} ElementType getType() const override { return ElementType::ProgressBar; } + const char* getTypeName() const override { return "ProgressBar"; } void setValue(const std::string& expr) { valueExpr = Expression::parse(expr); @@ -484,6 +303,7 @@ class Divider : public UIElement { explicit Divider(const std::string& id) : UIElement(id), colorExpr(Expression::parse("0x00")) {} ElementType getType() const override { return ElementType::Divider; } + const char* getTypeName() const override { return "Divider"; } void setColorExpr(const std::string& expr) { colorExpr = Expression::parse(expr); @@ -531,6 +351,7 @@ class BatteryIcon : public UIElement { } ElementType getType() const override { return ElementType::BatteryIcon; } + const char* getTypeName() const override { return "BatteryIcon"; } void setValue(const std::string& expr) { valueExpr = Expression::parse(expr); diff --git a/lib/ThemeEngine/include/LayoutElements.h b/lib/ThemeEngine/include/LayoutElements.h index 050ef334..d2a59f7f 100644 --- a/lib/ThemeEngine/include/LayoutElements.h +++ b/lib/ThemeEngine/include/LayoutElements.h @@ -19,6 +19,7 @@ class HStack : public Container { public: HStack(const std::string& id) : Container(id) {} ElementType getType() const override { return ElementType::HStack; } + const char* getTypeName() const override { return "HStack"; } void setSpacing(int s) { spacing = s; @@ -71,6 +72,7 @@ class VStack : public Container { public: VStack(const std::string& id) : Container(id) {} ElementType getType() const override { return ElementType::VStack; } + const char* getTypeName() const override { return "VStack"; } void setSpacing(int s) { spacing = s; @@ -122,6 +124,7 @@ class Grid : public Container { public: Grid(const std::string& id) : Container(id) {} ElementType getType() const override { return ElementType::Grid; } + const char* getTypeName() const override { return "Grid"; } void setColumns(int c) { columns = c > 0 ? c : 1; @@ -182,7 +185,7 @@ class Badge : public UIElement { int fontId = 0; int paddingH = 8; // Horizontal padding int paddingV = 4; // Vertical padding - int cornerRadius = 0; + int borderRadius = 0; public: Badge(const std::string& id) : UIElement(id) { @@ -191,6 +194,7 @@ class Badge : public UIElement { } ElementType getType() const override { return ElementType::Badge; } + const char* getTypeName() const override { return "Badge"; } void setText(const std::string& expr) { textExpr = Expression::parse(expr); @@ -217,6 +221,11 @@ class Badge : public UIElement { markDirty(); } + void setBorderRadius(int r) { + borderRadius = r; + markDirty(); + } + void draw(const GfxRenderer& renderer, const ThemeContext& context) override { if (!isVisible(context)) return; @@ -226,29 +235,59 @@ class Badge : public UIElement { return; } - // Calculate badge size based on text + // Calculate badge size based on text content - always auto-sizes int textW = renderer.getTextWidth(fontId, text.c_str()); int textH = renderer.getLineHeight(fontId); int badgeW = textW + 2 * paddingH; int badgeH = textH + 2 * paddingV; - // Use absX, absY as position, but we may auto-size - if (absW == 0) absW = badgeW; - if (absH == 0) absH = badgeH; + // Badge always auto-sizes to content + int drawW = badgeW; + int drawH = badgeH; + + // Position the badge within its container + // If absW/absH are set, use them as bounding box for alignment + int drawX = absX; + int drawY = absY; + + // Right-align badge within bounding box if width is specified + if (absW > 0 && absW > drawW) { + drawX = absX + absW - drawW; + } + // Vertically center badge within bounding box if height is specified + if (absH > 0 && absH > drawH) { + drawY = absY + (absH - drawH) / 2; + } // Draw background std::string bgStr = context.evaluatestring(bgColorExpr); uint8_t bgColor = Color::parse(bgStr).value; - renderer.fillRect(absX, absY, absW, absH, bgColor == 0x00); + if (borderRadius > 0) { + if (bgColor == 0x00) { + renderer.fillRoundedRect(drawX, drawY, drawW, drawH, borderRadius, true); + } else if (bgColor >= 0xF0) { + renderer.fillRoundedRect(drawX, drawY, drawW, drawH, borderRadius, false); + } else { + renderer.fillRoundedRectDithered(drawX, drawY, drawW, drawH, borderRadius, bgColor); + } + } else { + renderer.fillRect(drawX, drawY, drawW, drawH, bgColor == 0x00); + } - // Draw border for contrast - renderer.drawRect(absX, absY, absW, absH, bgColor != 0x00); + // Draw border for contrast (only if not black background) + if (bgColor != 0x00) { + if (borderRadius > 0) { + renderer.drawRoundedRect(drawX, drawY, drawW, drawH, borderRadius, true); + } else { + renderer.drawRect(drawX, drawY, drawW, drawH, true); + } + } - // Draw text centered + // Draw text centered within the badge std::string fgStr = context.evaluatestring(fgColorExpr); uint8_t fgColor = Color::parse(fgStr).value; - int textX = absX + (absW - textW) / 2; - int textY = absY + (absH - textH) / 2; + int textX = drawX + paddingH; + int textY = drawY + paddingV; renderer.drawText(fontId, textX, textY, text.c_str(), fgColor == 0x00); markClean(); @@ -256,22 +295,28 @@ class Badge : public UIElement { }; // --- Toggle: On/Off Switch --- +// Fully themable toggle with track and knob +// Supports rounded or square appearance based on BorderRadius class Toggle : public UIElement { - Expression valueExpr; // Boolean expression - Expression onColorExpr; - Expression offColorExpr; + Expression valueExpr; // Boolean expression for on/off state + Expression onColorExpr; // Track color when ON + Expression offColorExpr; // Track color when OFF + Expression knobColorExpr; // Knob color (optional, defaults to opposite of track) int trackWidth = 44; int trackHeight = 24; int knobSize = 20; + int borderRadius = 0; // 0 = square, >0 = rounded (use trackHeight/2 for pill shape) + int knobRadius = 0; // Knob corner radius public: Toggle(const std::string& id) : UIElement(id) { valueExpr = Expression::parse("false"); onColorExpr = Expression::parse("0x00"); // Black when on - offColorExpr = Expression::parse("0xFF"); // White when off + offColorExpr = Expression::parse("0xCC"); // Light gray when off } ElementType getType() const override { return ElementType::Toggle; } + const char* getTypeName() const override { return "Toggle"; } void setValue(const std::string& expr) { valueExpr = Expression::parse(expr); @@ -285,6 +330,10 @@ class Toggle : public UIElement { offColorExpr = Expression::parse(expr); markDirty(); } + void setKnobColor(const std::string& expr) { + knobColorExpr = Expression::parse(expr); + markDirty(); + } void setTrackWidth(int w) { trackWidth = w; markDirty(); @@ -297,30 +346,109 @@ class Toggle : public UIElement { knobSize = s; markDirty(); } + void setBorderRadius(int r) { + borderRadius = r; + markDirty(); + } + void setKnobRadius(int r) { + knobRadius = r; + markDirty(); + } void draw(const GfxRenderer& renderer, const ThemeContext& context) override { if (!isVisible(context)) return; - bool isOn = context.evaluateBool(valueExpr.rawExpr); + // Evaluate the value - handle simple variable references directly + bool isOn = false; + std::string rawExpr = valueExpr.rawExpr; - // Get colors + // If it's a simple {variable} reference, resolve it directly + if (rawExpr.size() > 2 && rawExpr.front() == '{' && rawExpr.back() == '}') { + std::string varName = rawExpr.substr(1, rawExpr.size() - 2); + // Trim whitespace + size_t start = varName.find_first_not_of(" \t"); + size_t end = varName.find_last_not_of(" \t"); + if (start != std::string::npos) { + varName = varName.substr(start, end - start + 1); + } + isOn = context.getAnyAsBool(varName, false); + } else { + isOn = context.evaluateBool(rawExpr); + } + + // Get track color based on state std::string colorStr = isOn ? context.evaluatestring(onColorExpr) : context.evaluatestring(offColorExpr); uint8_t trackColor = Color::parse(colorStr).value; - // Draw track + // Calculate track position (centered vertically in bounding box) int trackX = absX; int trackY = absY + (absH - trackHeight) / 2; - renderer.fillRect(trackX, trackY, trackWidth, trackHeight, trackColor == 0x00); - renderer.drawRect(trackX, trackY, trackWidth, trackHeight, true); - // Draw knob + // Calculate effective border radius (capped at half height for pill shape) + int effectiveRadius = borderRadius; + if (effectiveRadius > trackHeight / 2) { + effectiveRadius = trackHeight / 2; + } + + // Draw track + if (effectiveRadius > 0) { + // Rounded track + if (trackColor == 0x00) { + renderer.fillRoundedRect(trackX, trackY, trackWidth, trackHeight, effectiveRadius, true); + } else if (trackColor >= 0xF0) { + renderer.fillRoundedRect(trackX, trackY, trackWidth, trackHeight, effectiveRadius, false); + renderer.drawRoundedRect(trackX, trackY, trackWidth, trackHeight, effectiveRadius, true); + } else { + renderer.fillRoundedRectDithered(trackX, trackY, trackWidth, trackHeight, effectiveRadius, trackColor); + renderer.drawRoundedRect(trackX, trackY, trackWidth, trackHeight, effectiveRadius, true); + } + } else { + // Square track + if (trackColor == 0x00) { + renderer.fillRect(trackX, trackY, trackWidth, trackHeight, true); + } else if (trackColor >= 0xF0) { + renderer.fillRect(trackX, trackY, trackWidth, trackHeight, false); + renderer.drawRect(trackX, trackY, trackWidth, trackHeight, true); + } else { + renderer.fillRectDithered(trackX, trackY, trackWidth, trackHeight, trackColor); + renderer.drawRect(trackX, trackY, trackWidth, trackHeight, true); + } + } + + // Calculate knob position int knobMargin = (trackHeight - knobSize) / 2; int knobX = isOn ? (trackX + trackWidth - knobSize - knobMargin) : (trackX + knobMargin); int knobY = trackY + knobMargin; - // Knob is opposite color of track - renderer.fillRect(knobX, knobY, knobSize, knobSize, trackColor != 0x00); - renderer.drawRect(knobX, knobY, knobSize, knobSize, true); + // Determine knob color + bool knobBlack; + if (!knobColorExpr.empty()) { + std::string knobStr = context.evaluatestring(knobColorExpr); + uint8_t knobColor = Color::parse(knobStr).value; + knobBlack = (knobColor == 0x00); + } else { + // Default: knob is opposite color of track + knobBlack = (trackColor >= 0x80); + } + + // Calculate effective knob radius + int effectiveKnobRadius = knobRadius; + if (effectiveKnobRadius > knobSize / 2) { + effectiveKnobRadius = knobSize / 2; + } + + // Draw knob + if (effectiveKnobRadius > 0) { + renderer.fillRoundedRect(knobX, knobY, knobSize, knobSize, effectiveKnobRadius, knobBlack); + if (!knobBlack) { + renderer.drawRoundedRect(knobX, knobY, knobSize, knobSize, effectiveKnobRadius, true); + } + } else { + renderer.fillRect(knobX, knobY, knobSize, knobSize, knobBlack); + if (!knobBlack) { + renderer.drawRect(knobX, knobY, knobSize, knobSize, true); + } + } markClean(); } @@ -337,6 +465,7 @@ class TabBar : public Container { public: TabBar(const std::string& id) : Container(id) {} ElementType getType() const override { return ElementType::TabBar; } + const char* getTypeName() const override { return "TabBar"; } void setSelected(const std::string& expr) { selectedExpr = Expression::parse(expr); @@ -414,9 +543,6 @@ class TabBar : public Container { } } - // Draw bottom border - renderer.drawLine(absX, absY + absH - 1, absX + absW - 1, absY + absH - 1, true); - markClean(); } }; @@ -437,6 +563,7 @@ class Icon : public UIElement { } ElementType getType() const override { return ElementType::Icon; } + const char* getTypeName() const override { return "Icon"; } void setSrc(const std::string& expr) { srcExpr = Expression::parse(expr); @@ -469,6 +596,7 @@ class ScrollIndicator : public UIElement { } ElementType getType() const override { return ElementType::ScrollIndicator; } + const char* getTypeName() const override { return "ScrollIndicator"; } void setPosition(const std::string& expr) { positionExpr = Expression::parse(expr); diff --git a/lib/ThemeEngine/include/ListElement.h b/lib/ThemeEngine/include/ListElement.h index b17c1275..c86852d5 100644 --- a/lib/ThemeEngine/include/ListElement.h +++ b/lib/ThemeEngine/include/ListElement.h @@ -34,6 +34,7 @@ class List : public Container { List(const std::string& id) : Container(id) {} ElementType getType() const override { return ElementType::List; } + const char* getTypeName() const override { return "List"; } void setSource(const std::string& s) { source = s; diff --git a/lib/ThemeEngine/include/ThemeContext.h b/lib/ThemeEngine/include/ThemeContext.h index 73222a56..8db01212 100644 --- a/lib/ThemeEngine/include/ThemeContext.h +++ b/lib/ThemeEngine/include/ThemeContext.h @@ -1,5 +1,7 @@ #pragma once +#include +#include #include #include #include @@ -82,6 +84,36 @@ class ThemeContext { return start < s.length(); } + // Helper to check if string is a hex number (0x..) + static bool isHexNumber(const std::string& s) { + if (s.size() < 3) return false; + if (!(s[0] == '0' && (s[1] == 'x' || s[1] == 'X'))) return false; + for (size_t i = 2; i < s.length(); i++) { + char c = s[i]; + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) return false; + } + return true; + } + + static int parseInt(const std::string& s) { + if (isHexNumber(s)) { + return static_cast(std::strtol(s.c_str(), nullptr, 16)); + } + if (isNumber(s)) { + return static_cast(std::strtol(s.c_str(), nullptr, 10)); + } + return 0; + } + + static bool coerceBool(const std::string& s) { + std::string v = trim(s); + if (v.empty()) return false; + if (v == "true" || v == "1") return true; + if (v == "false" || v == "0") return false; + if (isHexNumber(v) || isNumber(v)) return parseInt(v) != 0; + return true; + } + public: explicit ThemeContext(const ThemeContext* parent = nullptr) : parent(parent) {} @@ -89,6 +121,20 @@ class ThemeContext { void setInt(const std::string& key, int value) { ints[key] = value; } void setBool(const std::string& key, bool value) { bools[key] = value; } + // Helper to populate list data efficiently + void setListItem(const std::string& listName, int index, const std::string& property, const std::string& value) { + strings[listName + "." + std::to_string(index) + "." + property] = value; + } + void setListItem(const std::string& listName, int index, const std::string& property, int value) { + ints[listName + "." + std::to_string(index) + "." + property] = value; + } + void setListItem(const std::string& listName, int index, const std::string& property, bool value) { + bools[listName + "." + std::to_string(index) + "." + property] = value; + } + void setListItem(const std::string& listName, int index, const std::string& property, const char* value) { + strings[listName + "." + std::to_string(index) + "." + property] = value; + } + std::string getString(const std::string& key, const std::string& defaultValue = "") const { auto it = strings.find(key); if (it != strings.end()) return it->second; @@ -136,6 +182,34 @@ class ThemeContext { return ""; } + bool getAnyAsBool(const std::string& key, bool defaultValue = false) const { + auto bit = bools.find(key); + if (bit != bools.end()) return bit->second; + + auto iit = ints.find(key); + if (iit != ints.end()) return iit->second != 0; + + auto sit = strings.find(key); + if (sit != strings.end()) return coerceBool(sit->second); + + if (parent) return parent->getAnyAsBool(key, defaultValue); + return defaultValue; + } + + int getAnyAsInt(const std::string& key, int defaultValue = 0) const { + auto iit = ints.find(key); + if (iit != ints.end()) return iit->second; + + auto bit = bools.find(key); + if (bit != bools.end()) return bit->second ? 1 : 0; + + auto sit = strings.find(key); + if (sit != strings.end()) return parseInt(sit->second); + + if (parent) return parent->getAnyAsInt(key, defaultValue); + return defaultValue; + } + // Evaluate a complex boolean expression // Supports: !, &&, ||, ==, !=, <, >, <=, >=, parentheses bool evaluateBool(const std::string& expression) const { @@ -148,96 +222,233 @@ class ThemeContext { // Handle {var} wrapper if (expr.size() > 2 && expr.front() == '{' && expr.back() == '}') { - expr = expr.substr(1, expr.size() - 2); + expr = trim(expr.substr(1, expr.size() - 2)); } - // Handle negation - if (!expr.empty() && expr[0] == '!') { - return !evaluateBool(expr.substr(1)); - } + struct Token { + enum Type { Identifier, Number, String, Op, LParen, RParen, End }; + Type type; + std::string text; + }; - // Handle parentheses - if (!expr.empty() && expr[0] == '(') { - int depth = 1; - size_t closePos = 1; - while (closePos < expr.length() && depth > 0) { - if (expr[closePos] == '(') depth++; - if (expr[closePos] == ')') depth--; - closePos++; + struct Tokenizer { + const std::string& s; + size_t pos = 0; + Token peeked{Token::End, ""}; + bool hasPeek = false; + + explicit Tokenizer(const std::string& input) : s(input) {} + + static std::string trimCopy(const std::string& in) { + size_t start = in.find_first_not_of(" \t\n\r"); + if (start == std::string::npos) return ""; + size_t end = in.find_last_not_of(" \t\n\r"); + return in.substr(start, end - start + 1); } - if (closePos <= expr.length()) { - std::string inner = expr.substr(1, closePos - 2); - std::string rest = trim(expr.substr(closePos)); - bool innerResult = evaluateBool(inner); - // Check for && or || - if (rest.length() >= 2 && rest.substr(0, 2) == "&&") { - return innerResult && evaluateBool(rest.substr(2)); + void skipWs() { + while (pos < s.size() && (s[pos] == ' ' || s[pos] == '\t' || s[pos] == '\n' || s[pos] == '\r')) { + pos++; } - if (rest.length() >= 2 && rest.substr(0, 2) == "||") { - return innerResult || evaluateBool(rest.substr(2)); - } - return innerResult; } - } - // Handle && and || (lowest precedence) - size_t andPos = expr.find("&&"); - size_t orPos = expr.find("||"); + Token readToken() { + skipWs(); + if (pos >= s.size()) return {Token::End, ""}; + char c = s[pos]; - // Process || first (lower precedence than &&) - if (orPos != std::string::npos && (andPos == std::string::npos || orPos < andPos)) { - return evaluateBool(expr.substr(0, orPos)) || evaluateBool(expr.substr(orPos + 2)); - } - if (andPos != std::string::npos) { - return evaluateBool(expr.substr(0, andPos)) && evaluateBool(expr.substr(andPos + 2)); - } + if (c == '(') { + pos++; + return {Token::LParen, "("}; + } + if (c == ')') { + pos++; + return {Token::RParen, ")"}; + } - // Handle comparisons - size_t eqPos = expr.find("=="); - if (eqPos != std::string::npos) { - std::string left = trim(expr.substr(0, eqPos)); - std::string right = trim(expr.substr(eqPos + 2)); - return compareValues(left, right) == 0; - } + if (c == '{') { + size_t end = s.find('}', pos + 1); + std::string inner; + if (end == std::string::npos) { + inner = s.substr(pos + 1); + pos = s.size(); + } else { + inner = s.substr(pos + 1, end - pos - 1); + pos = end + 1; + } + return {Token::Identifier, trimCopy(inner)}; + } - size_t nePos = expr.find("!="); - if (nePos != std::string::npos) { - std::string left = trim(expr.substr(0, nePos)); - std::string right = trim(expr.substr(nePos + 2)); - return compareValues(left, right) != 0; - } + if (c == '"' || c == '\'') { + char quote = c; + pos++; + std::string out; + while (pos < s.size()) { + char ch = s[pos++]; + if (ch == '\\' && pos < s.size()) { + out.push_back(s[pos++]); + continue; + } + if (ch == quote) break; + out.push_back(ch); + } + return {Token::String, out}; + } - size_t gePos = expr.find(">="); - if (gePos != std::string::npos) { - std::string left = trim(expr.substr(0, gePos)); - std::string right = trim(expr.substr(gePos + 2)); - return compareValues(left, right) >= 0; - } + // Operators + if (pos + 1 < s.size()) { + std::string two = s.substr(pos, 2); + if (two == "&&" || two == "||" || two == "==" || two == "!=" || two == "<=" || two == ">=") { + pos += 2; + return {Token::Op, two}; + } + } + if (c == '!' || c == '<' || c == '>') { + pos++; + return {Token::Op, std::string(1, c)}; + } - size_t lePos = expr.find("<="); - if (lePos != std::string::npos) { - std::string left = trim(expr.substr(0, lePos)); - std::string right = trim(expr.substr(lePos + 2)); - return compareValues(left, right) <= 0; - } + // Number (decimal or hex) + if (isdigit(c) || (c == '-' && pos + 1 < s.size() && isdigit(s[pos + 1]))) { + size_t start = pos; + pos++; + if (pos + 1 < s.size() && s[start] == '0' && (s[pos] == 'x' || s[pos] == 'X')) { + pos++; // consume x + while (pos < s.size() && isxdigit(s[pos])) pos++; + } else { + while (pos < s.size() && isdigit(s[pos])) pos++; + } + return {Token::Number, s.substr(start, pos - start)}; + } - size_t gtPos = expr.find('>'); - if (gtPos != std::string::npos) { - std::string left = trim(expr.substr(0, gtPos)); - std::string right = trim(expr.substr(gtPos + 1)); - return compareValues(left, right) > 0; - } + // Identifier + if (isalpha(c) || c == '_' || c == '.') { + size_t start = pos; + pos++; + while (pos < s.size()) { + char ch = s[pos]; + if (isalnum(ch) || ch == '_' || ch == '.') { + pos++; + continue; + } + break; + } + return {Token::Identifier, s.substr(start, pos - start)}; + } - size_t ltPos = expr.find('<'); - if (ltPos != std::string::npos) { - std::string left = trim(expr.substr(0, ltPos)); - std::string right = trim(expr.substr(ltPos + 1)); - return compareValues(left, right) < 0; - } + // Unknown char, skip + pos++; + return readToken(); + } - // Simple variable lookup - return getBool(expr, false); + Token next() { + if (hasPeek) { + hasPeek = false; + return peeked; + } + return readToken(); + } + + Token peek() { + if (!hasPeek) { + peeked = readToken(); + hasPeek = true; + } + return peeked; + } + }; + + Tokenizer tz(expr); + + std::function parseOr; + std::function parseAnd; + std::function parseNot; + std::function parseComparison; + std::function parseValue; + + parseValue = [&]() -> std::string { + Token t = tz.next(); + if (t.type == Token::LParen) { + bool inner = parseOr(); + Token close = tz.next(); + if (close.type != Token::RParen) { + // best-effort: no-op + } + return inner ? "true" : "false"; + } + if (t.type == Token::String) { + return "'" + t.text + "'"; + } + if (t.type == Token::Number) { + return t.text; + } + if (t.type == Token::Identifier) { + return t.text; + } + return ""; + }; + + auto isComparisonOp = [](const Token& t) { + if (t.type != Token::Op) return false; + return t.text == "==" || t.text == "!=" || t.text == "<" || t.text == ">" || t.text == "<=" || t.text == ">="; + }; + + parseComparison = [&]() -> bool { + std::string left = parseValue(); + Token op = tz.peek(); + if (isComparisonOp(op)) { + tz.next(); + std::string right = parseValue(); + int cmp = compareValues(left, right); + if (op.text == "==") return cmp == 0; + if (op.text == "!=") return cmp != 0; + if (op.text == "<") return cmp < 0; + if (op.text == ">") return cmp > 0; + if (op.text == "<=") return cmp <= 0; + if (op.text == ">=") return cmp >= 0; + return false; + } + return coerceBool(resolveValue(left)); + }; + + parseNot = [&]() -> bool { + Token t = tz.peek(); + if (t.type == Token::Op && t.text == "!") { + tz.next(); + return !parseNot(); + } + return parseComparison(); + }; + + parseAnd = [&]() -> bool { + bool value = parseNot(); + while (true) { + Token t = tz.peek(); + if (t.type == Token::Op && t.text == "&&") { + tz.next(); + value = value && parseNot(); + continue; + } + break; + } + return value; + }; + + parseOr = [&]() -> bool { + bool value = parseAnd(); + while (true) { + Token t = tz.peek(); + if (t.type == Token::Op && t.text == "||") { + tz.next(); + value = value || parseAnd(); + continue; + } + break; + } + return value; + }; + + return parseOr(); } // Compare two values (handles variables, numbers, strings) @@ -246,9 +457,9 @@ class ThemeContext { std::string rightVal = resolveValue(right); // Try numeric comparison - if (isNumber(leftVal) && isNumber(rightVal)) { - int l = std::stoi(leftVal); - int r = std::stoi(rightVal); + if ((isNumber(leftVal) || isHexNumber(leftVal)) && (isNumber(rightVal) || isHexNumber(rightVal))) { + int l = parseInt(leftVal); + int r = parseInt(rightVal); return (l < r) ? -1 : (l > r) ? 1 : 0; } @@ -272,7 +483,7 @@ class ThemeContext { if (isNumber(v)) return v; // Check for hex color literals (0x00, 0xFF, etc.) - if (v.size() > 2 && v[0] == '0' && (v[1] == 'x' || v[1] == 'X')) { + if (isHexNumber(v)) { return v; } @@ -282,13 +493,18 @@ class ThemeContext { } // Check for boolean literals - if (v == "true" || v == "false") { + if (v == "true" || v == "false" || v == "1" || v == "0") { return v; } // Try to look up as variable - if (hasKey(v)) { - return getAnyAsString(v); + std::string varName = v; + if (varName.size() >= 2 && varName.front() == '{' && varName.back() == '}') { + varName = trim(varName.substr(1, varName.size() - 2)); + } + + if (hasKey(varName)) { + return getAnyAsString(varName); } // Return as literal if not found as variable diff --git a/lib/ThemeEngine/include/ThemeTypes.h b/lib/ThemeEngine/include/ThemeTypes.h index 5c2ada28..60724b65 100644 --- a/lib/ThemeEngine/include/ThemeTypes.h +++ b/lib/ThemeEngine/include/ThemeTypes.h @@ -17,10 +17,17 @@ struct Dimension { static Dimension parse(const std::string& str) { if (str.empty()) return Dimension(0, DimensionUnit::PIXELS); + auto safeParseInt = [](const std::string& s) { + char* end = nullptr; + long v = std::strtol(s.c_str(), &end, 10); + if (!end || end == s.c_str()) return 0; + return static_cast(v); + }; + if (str.back() == '%') { - return Dimension(std::stoi(str.substr(0, str.length() - 1)), DimensionUnit::PERCENT); + return Dimension(safeParseInt(str.substr(0, str.length() - 1)), DimensionUnit::PERCENT); } - return Dimension(std::stoi(str), DimensionUnit::PIXELS); + return Dimension(safeParseInt(str), DimensionUnit::PIXELS); } int resolve(int parentSize) const { @@ -45,7 +52,8 @@ struct Color { if (str.size() > 2 && str.substr(0, 2) == "0x") { return Color((uint8_t)std::strtol(str.c_str(), nullptr, 16)); } - return Color((uint8_t)std::stoi(str)); + // Safe fallback using strtol (returns 0 on error, no exception) + return Color((uint8_t)std::strtol(str.c_str(), nullptr, 10)); } }; diff --git a/lib/ThemeEngine/include/UIElement.h b/lib/ThemeEngine/include/UIElement.h index 6d639fdc..fd3a34f6 100644 --- a/lib/ThemeEngine/include/UIElement.h +++ b/lib/ThemeEngine/include/UIElement.h @@ -150,6 +150,7 @@ class UIElement { }; virtual ElementType getType() const { return ElementType::Base; } + virtual const char* getTypeName() const { return "UIElement"; } int getLayoutHeight() const { return absH; } int getLayoutWidth() const { return absW; } diff --git a/lib/ThemeEngine/src/BasicElements.cpp b/lib/ThemeEngine/src/BasicElements.cpp index 73ee5ae6..c6513fb4 100644 --- a/lib/ThemeEngine/src/BasicElements.cpp +++ b/lib/ThemeEngine/src/BasicElements.cpp @@ -9,6 +9,232 @@ namespace ThemeEngine { +// --- Container --- +void Container::draw(const GfxRenderer& renderer, const ThemeContext& context) { + if (!isVisible(context)) return; + + if (hasBg) { + std::string colStr = context.evaluatestring(bgColorExpr); + uint8_t color = Color::parse(colStr).value; + // Use dithered fill for grayscale values, solid fill for black/white + // Use rounded rect if borderRadius > 0 + if (color == 0x00) { + if (borderRadius > 0) { + renderer.fillRoundedRect(absX, absY, absW, absH, borderRadius, true); + } else { + renderer.fillRect(absX, absY, absW, absH, true); + } + } else if (color >= 0xF0) { + if (borderRadius > 0) { + renderer.fillRoundedRect(absX, absY, absW, absH, borderRadius, false); + } else { + renderer.fillRect(absX, absY, absW, absH, false); + } + } else { + if (borderRadius > 0) { + renderer.fillRoundedRectDithered(absX, absY, absW, absH, borderRadius, color); + } else { + renderer.fillRectDithered(absX, absY, absW, absH, color); + } + } + } + + // Handle dynamic border expression + bool drawBorder = border; + if (hasBorderExpr()) { + drawBorder = context.evaluateBool(borderExpr.rawExpr); + } + + if (drawBorder) { + if (borderRadius > 0) { + renderer.drawRoundedRect(absX, absY, absW, absH, borderRadius, true); + } else { + renderer.drawRect(absX, absY, absW, absH, true); + } + } + + for (auto child : children) { + child->draw(renderer, context); + } + + markClean(); +} + +// --- Rectangle --- +void Rectangle::draw(const GfxRenderer& renderer, const ThemeContext& context) { + if (!isVisible(context)) return; + + std::string colStr = context.evaluatestring(colorExpr); + uint8_t color = Color::parse(colStr).value; + + bool shouldFill = fill; + if (!fillExpr.empty()) { + shouldFill = context.evaluateBool(fillExpr.rawExpr); + } + + if (shouldFill) { + // Use dithered fill for grayscale values, solid fill for black/white + // Use rounded rect if borderRadius > 0 + if (color == 0x00) { + if (borderRadius > 0) { + renderer.fillRoundedRect(absX, absY, absW, absH, borderRadius, true); + } else { + renderer.fillRect(absX, absY, absW, absH, true); + } + } else if (color >= 0xF0) { + if (borderRadius > 0) { + renderer.fillRoundedRect(absX, absY, absW, absH, borderRadius, false); + } else { + renderer.fillRect(absX, absY, absW, absH, false); + } + } else { + if (borderRadius > 0) { + renderer.fillRoundedRectDithered(absX, absY, absW, absH, borderRadius, color); + } else { + renderer.fillRectDithered(absX, absY, absW, absH, color); + } + } + } else { + // Draw border + bool black = (color == 0x00); + if (borderRadius > 0) { + renderer.drawRoundedRect(absX, absY, absW, absH, borderRadius, black); + } else { + renderer.drawRect(absX, absY, absW, absH, black); + } + } + + markClean(); +} + +// --- Label --- +void Label::draw(const GfxRenderer& renderer, const ThemeContext& context) { + if (!isVisible(context)) return; + + std::string finalStr = context.evaluatestring(textExpr); + + if (finalStr.empty()) { + markClean(); + return; + } + + std::string colStr = context.evaluatestring(colorExpr); + uint8_t color = Color::parse(colStr).value; + bool black = (color == 0x00); + + int textWidth = renderer.getTextWidth(fontId, finalStr.c_str()); + int lineHeight = renderer.getLineHeight(fontId); + + std::vector lines; + if (absW > 0 && textWidth > absW && maxLines > 1) { + // Logic to wrap text + std::string remaining = finalStr; + while (!remaining.empty() && (int)lines.size() < maxLines) { + // If it fits, add entire line + if (renderer.getTextWidth(fontId, remaining.c_str()) <= absW) { + lines.push_back(remaining); + break; + } + + // Binary search for cut point + int len = remaining.length(); + int cut = len; + + // Find split point + // Optimistic start: approximate chars that fit + int avgCharWidth = renderer.getTextWidth(fontId, "a"); + if (avgCharWidth < 1) avgCharWidth = 8; + int approxChars = absW / avgCharWidth; + if (approxChars < 1) approxChars = 1; + if (approxChars >= len) approxChars = len - 1; + + // Refine from approxChars + int w = renderer.getTextWidth(fontId, remaining.substr(0, approxChars).c_str()); + if (w < absW) { + // Grow + for (int i = approxChars; i <= len; i++) { + if (renderer.getTextWidth(fontId, remaining.substr(0, i).c_str()) > absW) { + cut = i - 1; + break; + } + cut = i; + } + } else { + // Shrink + for (int i = approxChars; i > 0; i--) { + if (renderer.getTextWidth(fontId, remaining.substr(0, i).c_str()) <= absW) { + cut = i; + break; + } + } + } + + // Find last space before cut + if (cut < (int)remaining.length()) { + int space = -1; + for (int i = cut; i > 0; i--) { + if (remaining[i] == ' ') { + space = i; + break; + } + } + if (space != -1) cut = space; + } + + std::string line = remaining.substr(0, cut); + + // If we're at the last allowed line but still have more text + if ((int)lines.size() == maxLines - 1 && cut < (int)remaining.length()) { + if (ellipsis) { + line = renderer.truncatedText(fontId, remaining.c_str(), absW); + } + lines.push_back(line); + break; + } + + lines.push_back(line); + // Advance + if (cut < (int)remaining.length()) { + // Skip the space if check + if (remaining[cut] == ' ') cut++; + remaining = remaining.substr(cut); + } else { + remaining = ""; + } + } + } else { + // Single line handling (truncate if needed) + if (ellipsis && textWidth > absW && absW > 0) { + finalStr = renderer.truncatedText(fontId, finalStr.c_str(), absW); + } + lines.push_back(finalStr); + } + + // Draw lines + int totalTextHeight = lines.size() * lineHeight; + int startY = absY; + + // Vertical centering + if (absH > 0 && totalTextHeight < absH) { + startY = absY + (absH - totalTextHeight) / 2; + } + + for (size_t i = 0; i < lines.size(); i++) { + int lineWidth = renderer.getTextWidth(fontId, lines[i].c_str()); + int drawX = absX; + + if (alignment == Alignment::Center && absW > 0) { + drawX = absX + (absW - lineWidth) / 2; + } else if (alignment == Alignment::Right && absW > 0) { + drawX = absX + absW - lineWidth; + } + + renderer.drawText(fontId, drawX, startY + i * lineHeight, lines[i].c_str(), black); + } + + markClean(); +} + // --- BitmapElement --- void BitmapElement::draw(const GfxRenderer& renderer, const ThemeContext& context) { if (!isVisible(context)) { @@ -52,7 +278,6 @@ void BitmapElement::draw(const GfxRenderer& renderer, const ThemeContext& contex if (SdMan.openFileForRead("HOME", path, file)) { Bitmap bmp(file, true); // (file, dithering=true) if (bmp.parseHeaders() == BmpReaderError::Ok) { - // Center logic int drawX = absX; int drawY = absY; if (bmp.getWidth() < absW) drawX += (absW - bmp.getWidth()) / 2; @@ -69,7 +294,7 @@ void BitmapElement::draw(const GfxRenderer& renderer, const ThemeContext& contex } } - // 3. Fallback to RAM Cache (Standard method) + // 3. Fallback to RAM Cache (Standard method) if (!drawSuccess) { const std::vector* data = ThemeManager::get().getCachedAsset(path); if (data && !data->empty()) { @@ -168,15 +393,25 @@ void List::draw(const GfxRenderer& renderer, const ThemeContext& context) { ThemeContext itemContext(&context); std::string prefix = source + "." + std::to_string(i) + "."; - // Standard list item variables + // Standard list item variables - include all properties for full flexibility + std::string nameVal = context.getString(prefix + "Name"); + itemContext.setString("Item.Name", nameVal); itemContext.setString("Item.Title", context.getString(prefix + "Title")); - itemContext.setString("Item.Value", context.getString(prefix + "Value")); + itemContext.setString("Item.Value", context.getAnyAsString(prefix + "Value")); + itemContext.setString("Item.Type", context.getString(prefix + "Type")); + itemContext.setString("Item.ValueLabel", context.getString(prefix + "ValueLabel")); + itemContext.setString("Item.BgColor", context.getString(prefix + "BgColor")); itemContext.setBool("Item.Selected", context.getBool(prefix + "Selected")); + itemContext.setBool("Item.Value", context.getBool(prefix + "Value")); itemContext.setString("Item.Icon", context.getString(prefix + "Icon")); itemContext.setString("Item.Image", context.getString(prefix + "Image")); itemContext.setString("Item.Progress", context.getString(prefix + "Progress")); itemContext.setInt("Item.Index", i); itemContext.setInt("Item.Count", count); + // ValueIndex may not exist for all item types, so check first + if (context.hasKey(prefix + "ValueIndex")) { + itemContext.setInt("Item.ValueIndex", context.getInt(prefix + "ValueIndex")); + } // Viewport check if (direction == Direction::Horizontal) { @@ -239,14 +474,25 @@ void List::draw(const GfxRenderer& renderer, const ThemeContext& context) { ThemeContext itemContext(&context); std::string prefix = source + "." + std::to_string(i) + "."; + // Standard list item variables - include all properties for full flexibility + std::string nameVal = context.getString(prefix + "Name"); + itemContext.setString("Item.Name", nameVal); itemContext.setString("Item.Title", context.getString(prefix + "Title")); - itemContext.setString("Item.Value", context.getString(prefix + "Value")); + itemContext.setString("Item.Value", context.getAnyAsString(prefix + "Value")); + itemContext.setString("Item.Type", context.getString(prefix + "Type")); + itemContext.setString("Item.ValueLabel", context.getString(prefix + "ValueLabel")); + itemContext.setString("Item.BgColor", context.getString(prefix + "BgColor")); itemContext.setBool("Item.Selected", context.getBool(prefix + "Selected")); + itemContext.setBool("Item.Value", context.getBool(prefix + "Value")); itemContext.setString("Item.Icon", context.getString(prefix + "Icon")); itemContext.setString("Item.Image", context.getString(prefix + "Image")); itemContext.setString("Item.Progress", context.getString(prefix + "Progress")); itemContext.setInt("Item.Index", i); itemContext.setInt("Item.Count", count); + // ValueIndex may not exist for all item types, so check first + if (context.hasKey(prefix + "ValueIndex")) { + itemContext.setInt("Item.ValueIndex", context.getInt(prefix + "ValueIndex")); + } // Layout and draw the template for this item itemTemplate->layout(itemContext, absX, currentY, absW, itemH); diff --git a/lib/ThemeEngine/src/IniParser.cpp b/lib/ThemeEngine/src/IniParser.cpp index 022aa805..6a920a78 100644 --- a/lib/ThemeEngine/src/IniParser.cpp +++ b/lib/ThemeEngine/src/IniParser.cpp @@ -21,11 +21,8 @@ void IniParser::trim(std::string& s) { std::map> IniParser::parse(Stream& stream) { std::map> sections; - // stream check not strictly possible like file, can rely on available() - - std::string currentSection = ""; - String line; // Use Arduino String for easy file reading, then convert to - // std::string + std::string currentSection; + String line; while (stream.available()) { line = stream.readStringUntil('\n'); @@ -33,7 +30,7 @@ std::map> IniParser::parse(Strea trim(sLine); if (sLine.empty() || sLine[0] == ';' || sLine[0] == '#') { - continue; // Skip comments and empty lines + continue; } if (sLine.front() == '[' && sLine.back() == ']') { @@ -65,7 +62,7 @@ std::map> IniParser::parseString std::map> sections; std::stringstream ss(content); std::string line; - std::string currentSection = ""; + std::string currentSection; while (std::getline(ss, line)) { trim(line); diff --git a/lib/ThemeEngine/src/LayoutElements.cpp b/lib/ThemeEngine/src/LayoutElements.cpp index b8bc2d4e..938ac013 100644 --- a/lib/ThemeEngine/src/LayoutElements.cpp +++ b/lib/ThemeEngine/src/LayoutElements.cpp @@ -22,13 +22,23 @@ void Icon::draw(const GfxRenderer& renderer, const ThemeContext& context) { std::string colStr = context.evaluatestring(colorExpr); uint8_t color = Color::parse(colStr).value; - bool black = (color == 0x00); + // Draw as black if color is dark (< 0x80), as white if light + // This allows grayscale colors to render visibly + bool black = (color < 0x80); - // Use absW/absH if set, otherwise use iconSize - int w = absW > 0 ? absW : iconSize; - int h = absH > 0 ? absH : iconSize; - int cx = absX + w / 2; - int cy = absY + h / 2; + // iconSize determines the actual drawn icon size + // absW/absH determine the bounding box for centering + int drawSize = iconSize; + int boundW = absW > 0 ? absW : iconSize; + int boundH = absH > 0 ? absH : iconSize; + + // Center the icon within its bounding box + int iconX = absX + (boundW - drawSize) / 2; + int iconY = absY + (boundH - drawSize) / 2; + int w = drawSize; + int h = drawSize; + int cx = iconX + w / 2; + int cy = iconY + h / 2; // 1. Try to load as a theme asset (exact match or .bmp extension) std::string path = iconName; @@ -45,13 +55,14 @@ void Icon::draw(const GfxRenderer& renderer, const ThemeContext& context) { if (data && !data->empty()) { Bitmap bmp(data->data(), data->size()); if (bmp.parseHeaders() == BmpReaderError::Ok) { - renderer.drawTransparentBitmap(bmp, absX, absY, w, h); + renderer.drawTransparentBitmap(bmp, iconX, iconY, w, h); markClean(); return; } } // 2. Built-in icons (simple geometric shapes) as fallback + // All icons use iconX, iconY, w, h, cx, cy for proper centering if (iconName == "heart" || iconName == "favorite") { // Simple heart shape approximation int s = w / 4; @@ -66,8 +77,8 @@ void Icon::draw(const GfxRenderer& renderer, const ThemeContext& context) { // Book icon int bw = w * 2 / 3; int bh = h * 3 / 4; - int bx = absX + (w - bw) / 2; - int by = absY + (h - bh) / 2; + int bx = iconX + (w - bw) / 2; + int by = iconY + (h - bh) / 2; renderer.drawRect(bx, by, bw, bh, black); renderer.drawLine(bx + bw / 3, by, bx + bw / 3, by + bh - 1, black); // Pages @@ -77,8 +88,8 @@ void Icon::draw(const GfxRenderer& renderer, const ThemeContext& context) { // Folder icon int fw = w * 3 / 4; int fh = h * 2 / 3; - int fx = absX + (w - fw) / 2; - int fy = absY + (h - fh) / 2; + int fx = iconX + (w - fw) / 2; + int fy = iconY + (h - fh) / 2; // Tab renderer.fillRect(fx, fy, fw / 3, fh / 6, black); // Body @@ -93,15 +104,15 @@ void Icon::draw(const GfxRenderer& renderer, const ThemeContext& context) { renderer.drawRect(cx - ir, cy - ir, ir * 2, ir * 2, black); // Teeth int t = r / 3; - renderer.fillRect(cx - t / 2, absY, t, r - ir, black); + renderer.fillRect(cx - t / 2, iconY, t, r - ir, black); renderer.fillRect(cx - t / 2, cy + r, t, r - ir, black); - renderer.fillRect(absX, cy - t / 2, r - ir, t, black); + renderer.fillRect(iconX, cy - t / 2, r - ir, t, black); renderer.fillRect(cx + r, cy - t / 2, r - ir, t, black); } else if (iconName == "transfer" || iconName == "arrow" || iconName == "send") { // Arrow pointing right int aw = w / 2; int ah = h / 3; - int ax = absX + w / 4; + int ax = iconX + w / 4; int ay = cy - ah / 2; // Shaft renderer.fillRect(ax, ay, aw, ah, black); @@ -114,8 +125,8 @@ void Icon::draw(const GfxRenderer& renderer, const ThemeContext& context) { // Device/tablet icon int dw = w * 2 / 3; int dh = h * 3 / 4; - int dx = absX + (w - dw) / 2; - int dy = absY + (h - dh) / 2; + int dx = iconX + (w - dw) / 2; + int dy = iconY + (h - dh) / 2; renderer.drawRect(dx, dy, dw, dh, black); // Screen renderer.drawRect(dx + 2, dy + 2, dw - 4, dh - 8, black); @@ -125,18 +136,18 @@ void Icon::draw(const GfxRenderer& renderer, const ThemeContext& context) { // Battery icon int bw = w * 3 / 4; int bh = h / 2; - int bx = absX + (w - bw) / 2; - int by = absY + (h - bh) / 2; + int bx = iconX + (w - bw) / 2; + int by = iconY + (h - bh) / 2; renderer.drawRect(bx, by, bw - 3, bh, black); renderer.fillRect(bx + bw - 3, by + bh / 4, 3, bh / 2, black); } else if (iconName == "check" || iconName == "checkmark") { - // Checkmark - int x1 = absX + w / 4; + // Checkmark - use iconX/iconY for proper centering + int x1 = iconX + w / 4; int y1 = cy; int x2 = cx; - int y2 = absY + h * 3 / 4; - int x3 = absX + w * 3 / 4; - int y3 = absY + h / 4; + int y2 = iconY + h * 3 / 4; + int x3 = iconX + w * 3 / 4; + int y3 = iconY + h / 4; renderer.drawLine(x1, y1, x2, y2, black); renderer.drawLine(x2, y2, x3, y3, black); // Thicken @@ -163,11 +174,18 @@ void Icon::draw(const GfxRenderer& renderer, const ThemeContext& context) { renderer.drawLine(cx, cy + s - i, cx - s + i, cy, black); renderer.drawLine(cx, cy + s - i, cx + s - i, cy, black); } + } else if (iconName == "right") { + // Right arrow + int s = w / 3; + for (int i = 0; i < s; i++) { + renderer.drawLine(cx + s - i, cy, cx, cy - s + i, black); + renderer.drawLine(cx + s - i, cy, cx, cy + s - i, black); + } } else { // Unknown icon - draw placeholder - renderer.drawRect(absX, absY, w, h, black); - renderer.drawLine(absX, absY, absX + w - 1, absY + h - 1, black); - renderer.drawLine(absX + w - 1, absY, absX, absY + h - 1, black); + renderer.drawRect(iconX, iconY, w, h, black); + renderer.drawLine(iconX, iconY, iconX + w - 1, iconY + h - 1, black); + renderer.drawLine(iconX + w - 1, iconY, iconX, iconY + h - 1, black); } markClean(); diff --git a/lib/ThemeEngine/src/ThemeManager.cpp b/lib/ThemeEngine/src/ThemeManager.cpp index d10494e4..0330e330 100644 --- a/lib/ThemeEngine/src/ThemeManager.cpp +++ b/lib/ThemeEngine/src/ThemeManager.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include @@ -12,9 +13,7 @@ namespace ThemeEngine { -void ThemeManager::begin() { - // Default fonts or setup -} +void ThemeManager::begin() {} void ThemeManager::registerFont(const std::string& name, int id) { fontMap[name] = id; } @@ -56,6 +55,11 @@ UIElement* ThemeManager::createElement(const std::string& id, const std::string& return nullptr; } +// Parse integer safely - returns 0 on error +static int parseIntSafe(const std::string& val) { + return static_cast(std::strtol(val.c_str(), nullptr, 10)); +} + void ThemeManager::applyProperties(UIElement* elem, const std::map& props) { const auto elemType = elem->getType(); @@ -63,7 +67,7 @@ void ThemeManager::applyProperties(UIElement* elem, const std::mapsetX(Dimension::parse(val)); else if (key == "Y") @@ -77,7 +81,7 @@ void ThemeManager::applyProperties(UIElement* elem, const std::mapsetCacheable(val == "true" || val == "1"); - // ========== Rectangle properties ========== + // Rectangle properties else if (key == "Fill") { if (elemType == UIElement::ElementType::Rectangle) { auto rect = static_cast(elem); @@ -105,7 +109,7 @@ void ThemeManager::applyProperties(UIElement* elem, const std::mapasContainer()) { if (val.find('{') != std::string::npos) { @@ -117,24 +121,30 @@ void ThemeManager::applyProperties(UIElement* elem, const std::map(elem)->setPadding(std::stoi(val)); + static_cast(elem)->setPadding(parseIntSafe(val)); } else if (elemType == UIElement::ElementType::TabBar) { - static_cast(elem)->setPadding(std::stoi(val)); + static_cast(elem)->setPadding(parseIntSafe(val)); } } else if (key == "BorderRadius") { if (elemType == UIElement::ElementType::Container || elemType == UIElement::ElementType::HStack || elemType == UIElement::ElementType::VStack || elemType == UIElement::ElementType::Grid) { - static_cast(elem)->setBorderRadius(std::stoi(val)); + static_cast(elem)->setBorderRadius(parseIntSafe(val)); } else if (elemType == UIElement::ElementType::Bitmap) { - static_cast(elem)->setBorderRadius(std::stoi(val)); + static_cast(elem)->setBorderRadius(parseIntSafe(val)); + } else if (elemType == UIElement::ElementType::Rectangle) { + static_cast(elem)->setBorderRadius(parseIntSafe(val)); + } else if (elemType == UIElement::ElementType::Badge) { + static_cast(elem)->setBorderRadius(parseIntSafe(val)); + } else if (elemType == UIElement::ElementType::Toggle) { + static_cast(elem)->setBorderRadius(parseIntSafe(val)); } } else if (key == "Spacing") { if (elemType == UIElement::ElementType::HStack) { - static_cast(elem)->setSpacing(std::stoi(val)); + static_cast(elem)->setSpacing(parseIntSafe(val)); } else if (elemType == UIElement::ElementType::VStack) { - static_cast(elem)->setSpacing(std::stoi(val)); + static_cast(elem)->setSpacing(parseIntSafe(val)); } else if (elemType == UIElement::ElementType::List) { - static_cast(elem)->setSpacing(std::stoi(val)); + static_cast(elem)->setSpacing(parseIntSafe(val)); } } else if (key == "CenterVertical") { if (elemType == UIElement::ElementType::HStack) { @@ -146,7 +156,7 @@ void ThemeManager::applyProperties(UIElement* elem, const std::map(elem)->setText(val); @@ -176,7 +186,7 @@ void ThemeManager::applyProperties(UIElement* elem, const std::map(elem)->setMaxLines(std::stoi(val)); + static_cast(elem)->setMaxLines(parseIntSafe(val)); } } else if (key == "Ellipsis") { if (elemType == UIElement::ElementType::Label) { @@ -184,7 +194,7 @@ void ThemeManager::applyProperties(UIElement* elem, const std::map(elem); @@ -206,11 +216,11 @@ void ThemeManager::applyProperties(UIElement* elem, const std::map(elem)->setIconSize(std::stoi(val)); + static_cast(elem)->setIconSize(parseIntSafe(val)); } } - // ========== List properties ========== + // List properties else if (key == "Source") { if (elemType == UIElement::ElementType::List) { static_cast(elem)->setSource(val); @@ -221,11 +231,11 @@ void ThemeManager::applyProperties(UIElement* elem, const std::map(elem)->setItemHeight(std::stoi(val)); + static_cast(elem)->setItemHeight(parseIntSafe(val)); } } else if (key == "ItemWidth") { if (elemType == UIElement::ElementType::List) { - static_cast(elem)->setItemWidth(std::stoi(val)); + static_cast(elem)->setItemWidth(parseIntSafe(val)); } } else if (key == "Direction") { if (elemType == UIElement::ElementType::List) { @@ -233,21 +243,21 @@ void ThemeManager::applyProperties(UIElement* elem, const std::map(elem)->setColumns(std::stoi(val)); + static_cast(elem)->setColumns(parseIntSafe(val)); } else if (elemType == UIElement::ElementType::Grid) { - static_cast(elem)->setColumns(std::stoi(val)); + static_cast(elem)->setColumns(parseIntSafe(val)); } } else if (key == "RowSpacing") { if (elemType == UIElement::ElementType::Grid) { - static_cast(elem)->setRowSpacing(std::stoi(val)); + static_cast(elem)->setRowSpacing(parseIntSafe(val)); } } else if (key == "ColSpacing") { if (elemType == UIElement::ElementType::Grid) { - static_cast(elem)->setColSpacing(std::stoi(val)); + static_cast(elem)->setColSpacing(parseIntSafe(val)); } } - // ========== ProgressBar properties ========== + // ProgressBar properties else if (key == "Value") { if (elemType == UIElement::ElementType::ProgressBar) { static_cast(elem)->setValue(val); @@ -281,18 +291,18 @@ void ThemeManager::applyProperties(UIElement* elem, const std::map(elem)->setHorizontal(val == "true" || val == "1"); } } else if (key == "Thickness") { if (elemType == UIElement::ElementType::Divider) { - static_cast(elem)->setThickness(std::stoi(val)); + static_cast(elem)->setThickness(parseIntSafe(val)); } } - // ========== Toggle properties ========== + // Toggle properties else if (key == "OnColor") { if (elemType == UIElement::ElementType::Toggle) { static_cast(elem)->setOnColor(val); @@ -301,34 +311,42 @@ void ThemeManager::applyProperties(UIElement* elem, const std::map(elem)->setOffColor(val); } + } else if (key == "KnobColor") { + if (elemType == UIElement::ElementType::Toggle) { + static_cast(elem)->setKnobColor(val); + } } else if (key == "TrackWidth") { if (elemType == UIElement::ElementType::Toggle) { - static_cast(elem)->setTrackWidth(std::stoi(val)); + static_cast(elem)->setTrackWidth(parseIntSafe(val)); } else if (elemType == UIElement::ElementType::ScrollIndicator) { - static_cast(elem)->setTrackWidth(std::stoi(val)); + static_cast(elem)->setTrackWidth(parseIntSafe(val)); } } else if (key == "TrackHeight") { if (elemType == UIElement::ElementType::Toggle) { - static_cast(elem)->setTrackHeight(std::stoi(val)); + static_cast(elem)->setTrackHeight(parseIntSafe(val)); } } else if (key == "KnobSize") { if (elemType == UIElement::ElementType::Toggle) { - static_cast(elem)->setKnobSize(std::stoi(val)); + static_cast(elem)->setKnobSize(parseIntSafe(val)); + } + } else if (key == "KnobRadius") { + if (elemType == UIElement::ElementType::Toggle) { + static_cast(elem)->setKnobRadius(parseIntSafe(val)); } } - // ========== TabBar properties ========== + // TabBar properties else if (key == "Selected") { if (elemType == UIElement::ElementType::TabBar) { static_cast(elem)->setSelected(val); } } else if (key == "TabSpacing") { if (elemType == UIElement::ElementType::TabBar) { - static_cast(elem)->setTabSpacing(std::stoi(val)); + static_cast(elem)->setTabSpacing(parseIntSafe(val)); } } else if (key == "IndicatorHeight") { if (elemType == UIElement::ElementType::TabBar) { - static_cast(elem)->setIndicatorHeight(std::stoi(val)); + static_cast(elem)->setIndicatorHeight(parseIntSafe(val)); } } else if (key == "ShowIndicator") { if (elemType == UIElement::ElementType::TabBar) { @@ -336,7 +354,7 @@ void ThemeManager::applyProperties(UIElement* elem, const std::map(elem)->setPosition(val); @@ -351,14 +369,14 @@ void ThemeManager::applyProperties(UIElement* elem, const std::map(elem)->setPaddingH(std::stoi(val)); + static_cast(elem)->setPaddingH(parseIntSafe(val)); } } else if (key == "PaddingV") { if (elemType == UIElement::ElementType::Badge) { - static_cast(elem)->setPaddingV(std::stoi(val)); + static_cast(elem)->setPaddingV(parseIntSafe(val)); } } } @@ -385,7 +403,6 @@ const std::vector* ThemeManager::getCachedAsset(const std::string& path const ProcessedAsset* ThemeManager::getProcessedAsset(const std::string& path, GfxRenderer::Orientation orientation, int targetW, int targetH) { - // Include dimensions in cache key for scaled images std::string cacheKey = path; if (targetW > 0 && targetH > 0) { cacheKey += ":" + std::to_string(targetW) + "x" + std::to_string(targetH); @@ -414,8 +431,8 @@ void ThemeManager::clearAssetCaches() { } void ThemeManager::unloadTheme() { - for (auto it = elements.begin(); it != elements.end(); ++it) { - delete it->second; + for (auto& kv : elements) { + delete kv.second; } elements.clear(); clearAssetCaches(); @@ -456,30 +473,23 @@ void ThemeManager::loadTheme(const std::string& themeName) { if (SdMan.openFileForRead("Theme", path, file)) { sections = IniParser::parse(file); file.close(); - Serial.println("[ThemeManager] Loaded Default theme from SD card"); } } else { - Serial.println("[ThemeManager] Using embedded Default theme"); sections = IniParser::parseString(getDefaultThemeIni()); } currentThemeName = "Default"; } else { std::string path = "/themes/" + themeName + "/theme.ini"; - Serial.printf("[ThemeManager] Checking path: %s\n", path.c_str()); if (!SdMan.exists(path.c_str())) { - Serial.printf("[ThemeManager] Theme %s not found, using Default\n", themeName.c_str()); sections = IniParser::parseString(getDefaultThemeIni()); currentThemeName = "Default"; } else { FsFile file; if (SdMan.openFileForRead("Theme", path, file)) { - Serial.printf("[ThemeManager] Parsing theme file...\n"); sections = IniParser::parse(file); file.close(); - Serial.printf("[ThemeManager] Parsed %d sections from %s\n", (int)sections.size(), themeName.c_str()); } else { - Serial.printf("[ThemeManager] Failed to open %s, using Default\n", path.c_str()); sections = IniParser::parseString(getDefaultThemeIni()); currentThemeName = "Default"; } @@ -487,26 +497,27 @@ void ThemeManager::loadTheme(const std::string& themeName) { } // Read theme configuration from [Global] section - navBookCount = 1; // Default + navBookCount = 1; if (sections.count("Global")) { const auto& global = sections.at("Global"); if (global.count("NavBookCount")) { - navBookCount = std::stoi(global.at("NavBookCount")); + navBookCount = parseIntSafe(global.at("NavBookCount")); if (navBookCount < 1) navBookCount = 1; - if (navBookCount > 10) navBookCount = 10; // Reasonable max + if (navBookCount > 10) navBookCount = 10; } } - // Pass 1: Creation + // Pass 1: Create elements for (const auto& sec : sections) { - std::string id = sec.first; + const std::string& id = sec.first; const std::map& props = sec.second; if (id == "Global") continue; auto it = props.find("Type"); if (it == props.end()) continue; - std::string type = it->second; + + const std::string& type = it->second; if (type.empty()) continue; UIElement* elem = createElement(id, type); @@ -515,10 +526,10 @@ void ThemeManager::loadTheme(const std::string& themeName) { } } - // Pass 2: Properties & Parent Wiring + // Pass 2: Apply properties and wire parent relationships std::vector lists; for (const auto& sec : sections) { - std::string id = sec.first; + const std::string& id = sec.first; if (id == "Global") continue; if (elements.find(id) == elements.end()) continue; @@ -529,38 +540,63 @@ void ThemeManager::loadTheme(const std::string& themeName) { lists.push_back(static_cast(elem)); } + // Wire parent relationship (fallback if Children not specified) if (sec.second.count("Parent")) { - std::string parentId = sec.second.at("Parent"); + const std::string& parentId = sec.second.at("Parent"); if (elements.count(parentId)) { UIElement* parent = elements[parentId]; if (auto c = parent->asContainer()) { - c->addChild(elem); + const auto& children = c->getChildren(); + if (std::find(children.begin(), children.end(), elem) == children.end()) { + c->addChild(elem); + } } - } else { - Serial.printf("[ThemeManager] WARN: Parent %s not found for %s\n", parentId.c_str(), id.c_str()); + } + } + + // Children property - explicit ordering + if (sec.second.count("Children")) { + if (auto c = elem->asContainer()) { + c->clearChildren(); + + std::string s = sec.second.at("Children"); + size_t pos = 0; + + auto processChild = [&](const std::string& childName) { + std::string childId = childName; + size_t start = childId.find_first_not_of(" "); + size_t end = childId.find_last_not_of(" "); + if (start == std::string::npos) return; + childId = childId.substr(start, end - start + 1); + + if (elements.count(childId)) { + c->addChild(elements[childId]); + } + }; + + while ((pos = s.find(',')) != std::string::npos) { + processChild(s.substr(0, pos)); + s.erase(0, pos + 1); + } + processChild(s); } } } - // Pass 3: Resolve List Templates - for (auto l : lists) { + // Pass 3: Resolve list templates + for (auto* l : lists) { l->resolveTemplate(elements); } - - Serial.printf("[ThemeManager] Theme loaded with %d elements\n", (int)elements.size()); } void ThemeManager::renderScreen(const std::string& screenName, const GfxRenderer& renderer, const ThemeContext& context) { if (elements.count(screenName) == 0) { - Serial.printf("[ThemeManager] Screen '%s' not found\n", screenName.c_str()); return; } UIElement* root = elements[screenName]; - root->layout(context, 0, 0, renderer.getScreenWidth(), renderer.getScreenHeight()); - root->draw(renderer, context); } diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 65a9c7a1..a5400ad3 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -1,11 +1,16 @@ #include "SettingsActivity.h" #include -#include +#include "Battery.h" +#include "CalibreSettingsActivity.h" #include "CategorySettingsActivity.h" +#include "ClearCacheActivity.h" #include "CrossPointSettings.h" +#include "KOReaderSettingsActivity.h" #include "MappedInputManager.h" +#include "OtaUpdateActivity.h" +#include "ThemeSelectionActivity.h" #include "fontIds.h" const char *SettingsActivity::categoryNames[categoryCount] = { @@ -14,7 +19,6 @@ const char *SettingsActivity::categoryNames[categoryCount] = { namespace { constexpr int displaySettingsCount = 7; const SettingInfo displaySettings[displaySettingsCount] = { - // Should match with SLEEP_SCREEN_MODE SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}), SettingInfo::Enum("Sleep Screen Cover Mode", &CrossPointSettings::sleepScreenCoverMode, {"Fit", "Crop"}), SettingInfo::Enum("Sleep Screen Cover Filter", &CrossPointSettings::sleepScreenCoverFilter, @@ -67,7 +71,58 @@ const SettingInfo systemSettings[systemSettingsCount] = { {"1 min", "5 min", "10 min", "15 min", "30 min"}), SettingInfo::Action("KOReader Sync"), SettingInfo::Action("OPDS Browser"), SettingInfo::Action("Clear Cache"), SettingInfo::Action("Check for updates")}; -} // namespace + +// All categories with their settings +struct CategoryData { + const char* name; + const SettingInfo* settings; + int count; +}; + +const CategoryData allCategories[4] = { + {"Display", displaySettings, displaySettingsCount}, + {"Reader", readerSettings, readerSettingsCount}, + {"Controls", controlsSettings, controlsSettingsCount}, + {"System", systemSettings, systemSettingsCount}}; + +void updateContextForSetting(ThemeEngine::ThemeContext& ctx, const std::string& prefix, int i, const SettingInfo& info, + bool isSelected, bool fullUpdate) { + if (fullUpdate) { + ctx.setListItem(prefix, i, "Name", info.name); + ctx.setListItem(prefix, i, "Type", + info.type == SettingType::TOGGLE ? "Toggle" + : info.type == SettingType::ENUM ? "Enum" + : info.type == SettingType::ACTION ? "Action" + : info.type == SettingType::VALUE ? "Value" + : "Unknown"); + } + ctx.setListItem(prefix, i, "Selected", isSelected); + + // Values definitely need update + if (info.type == SettingType::TOGGLE && info.valuePtr) { + bool val = SETTINGS.*(info.valuePtr); + ctx.setListItem(prefix, i, "Value", val); + ctx.setListItem(prefix, i, "ValueLabel", val ? "On" : "Off"); + } else if (info.type == SettingType::ENUM && info.valuePtr) { + uint8_t val = SETTINGS.*(info.valuePtr); + if (val < info.enumValues.size()) { + ctx.setListItem(prefix, i, "Value", info.enumValues[val]); + ctx.setListItem(prefix, i, "ValueLabel", info.enumValues[val]); + ctx.setListItem(prefix, i, "ValueIndex", static_cast(val)); + } + } else if (info.type == SettingType::VALUE && info.valuePtr) { + int val = SETTINGS.*(info.valuePtr); + ctx.setListItem(prefix, i, "Value", val); + ctx.setListItem(prefix, i, "ValueLabel", std::to_string(val)); + } else if (info.type == SettingType::ACTION) { + if (fullUpdate) { + ctx.setListItem(prefix, i, "Value", ""); + ctx.setListItem(prefix, i, "ValueLabel", ""); + } + } +} +} // namespace +>>>>>>> e6b3ecc (feat: Enhance ThemeEngine and apply new theming to SettingsActivity) void SettingsActivity::taskTrampoline(void *param) { auto *self = static_cast(param); @@ -77,11 +132,14 @@ void SettingsActivity::taskTrampoline(void *param) { void SettingsActivity::onEnter() { Activity::onEnter(); renderingMutex = xSemaphoreCreateMutex(); - - // Reset selection to first category selectedCategoryIndex = 0; + selectedSettingIndex = 0; + + // For themed mode, provide all data upfront + if (ThemeEngine::ThemeManager::get().getElement("Settings")) { + updateThemeContext(true); // Full update + } - // Trigger first update updateRequired = true; xTaskCreate(&SettingsActivity::taskTrampoline, "SettingsActivityTask", @@ -95,8 +153,6 @@ void SettingsActivity::onEnter() { void SettingsActivity::onExit() { ActivityWithSubactivity::onExit(); - // Wait until not rendering to delete task to avoid killing mid-instruction to - // EPD xSemaphoreTake(renderingMutex, portMAX_DELAY); if (displayTaskHandle) { vTaskDelete(displayTaskHandle); @@ -107,14 +163,26 @@ void SettingsActivity::onExit() { } void SettingsActivity::loop() { + if (subActivityExitPending) { + subActivityExitPending = false; + exitActivity(); + updateThemeContext(true); + updateRequired = true; + } + if (subActivity) { subActivity->loop(); return; } - // Handle category selection + if (ThemeEngine::ThemeManager::get().getElement("Settings")) { + handleThemeInput(); + return; + } + + // Legacy mode if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { - enterCategory(selectedCategoryIndex); + enterCategoryLegacy(selectedCategoryIndex); return; } @@ -124,7 +192,6 @@ void SettingsActivity::loop() { return; } - // Handle navigation if (mappedInput.wasPressed(MappedInputManager::Button::Up) || mappedInput.wasPressed(MappedInputManager::Button::Left)) { // Move selection up (with wrap-around) @@ -142,39 +209,15 @@ void SettingsActivity::loop() { } } -void SettingsActivity::enterCategory(int categoryIndex) { - if (categoryIndex < 0 || categoryIndex >= categoryCount) { - return; - } +void SettingsActivity::enterCategoryLegacy(int categoryIndex) { + if (categoryIndex < 0 || categoryIndex >= categoryCount) return; xSemaphoreTake(renderingMutex, portMAX_DELAY); exitActivity(); - - const SettingInfo *settingsList = nullptr; - int settingsCount = 0; - - switch (categoryIndex) { - case 0: // Display - settingsList = displaySettings; - settingsCount = displaySettingsCount; - break; - case 1: // Reader - settingsList = readerSettings; - settingsCount = readerSettingsCount; - break; - case 2: // Controls - settingsList = controlsSettings; - settingsCount = controlsSettingsCount; - break; - case 3: // System - settingsList = systemSettings; - settingsCount = systemSettingsCount; - break; - } - enterNewActivity(new CategorySettingsActivity( - renderer, mappedInput, categoryNames[categoryIndex], settingsList, - settingsCount, [this] { + renderer, mappedInput, allCategories[categoryIndex].name, + allCategories[categoryIndex].settings, allCategories[categoryIndex].count, + [this] { exitActivity(); updateRequired = true; })); @@ -185,9 +228,11 @@ void SettingsActivity::displayTaskLoop() { while (true) { if (updateRequired && !subActivity) { updateRequired = false; - xSemaphoreTake(renderingMutex, portMAX_DELAY); - render(); - xSemaphoreGive(renderingMutex); + + if (xSemaphoreTake(renderingMutex, portMAX_DELAY) == pdTRUE) { + render(); + xSemaphoreGive(renderingMutex); + } } vTaskDelay(10 / portTICK_PERIOD_MS); } @@ -196,6 +241,13 @@ void SettingsActivity::displayTaskLoop() { void SettingsActivity::render() const { renderer.clearScreen(); + if (ThemeEngine::ThemeManager::get().getElement("Settings")) { + ThemeEngine::ThemeManager::get().renderScreen("Settings", renderer, themeContext); + renderer.displayBuffer(); + return; + } + + // Legacy rendering const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); @@ -206,7 +258,6 @@ void SettingsActivity::render() const { // Draw selection renderer.fillRect(0, 60 + selectedCategoryIndex * 30 - 2, pageWidth - 1, 30); - // Draw all categories for (int i = 0; i < categoryCount; i++) { const int categoryY = 60 + i * 30; // 30 pixels between categories @@ -221,11 +272,134 @@ void SettingsActivity::render() const { pageWidth - 20 - renderer.getTextWidth(SMALL_FONT_ID, CROSSPOINT_VERSION), pageHeight - 60, CROSSPOINT_VERSION); - // Draw help text const auto labels = mappedInput.mapLabels("« Back", "Select", "", ""); renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); - // Always use standard refresh for settings screen renderer.displayBuffer(); } + +void SettingsActivity::updateThemeContext(bool fullUpdate) { + themeContext.setInt("System.Battery", battery.readPercentage()); + + // Categories + if (fullUpdate) { + themeContext.setInt("Categories.Count", categoryCount); + } + + themeContext.setInt("Categories.Selected", selectedCategoryIndex); + + for (int i = 0; i < categoryCount; i++) { + if (fullUpdate) { + themeContext.setListItem("Categories", i, "Name", allCategories[i].name); + themeContext.setListItem("Categories", i, "SettingsCount", allCategories[i].count); + } + themeContext.setListItem("Categories", i, "Selected", i == selectedCategoryIndex); + } + + // Provide ALL settings for ALL categories + // Format: Category0.Settings.0.Name, Category0.Settings.1.Name, etc. + for (int cat = 0; cat < categoryCount; cat++) { + std::string catPrefix = "Category" + std::to_string(cat) + ".Settings"; + for (int i = 0; i < allCategories[cat].count; i++) { + bool isSelected = (cat == selectedCategoryIndex && i == selectedSettingIndex); + updateContextForSetting(themeContext, catPrefix, i, allCategories[cat].settings[i], isSelected, fullUpdate); + } + } + + // Also provide current category's settings as "Settings" for simpler themes + if (fullUpdate) { + themeContext.setInt("Settings.Count", allCategories[selectedCategoryIndex].count); + } + + for (int i = 0; i < allCategories[selectedCategoryIndex].count; i++) { + updateContextForSetting(themeContext, "Settings", i, allCategories[selectedCategoryIndex].settings[i], + i == selectedSettingIndex, fullUpdate); + } +} + +void SettingsActivity::handleThemeInput() { + const int currentCategorySettingsCount = allCategories[selectedCategoryIndex].count; + + // Up/Down navigates settings within current category + if (mappedInput.wasPressed(MappedInputManager::Button::Up)) { + selectedSettingIndex = (selectedSettingIndex > 0) ? (selectedSettingIndex - 1) : (currentCategorySettingsCount - 1); + updateThemeContext(false); // Partial update + updateRequired = true; + return; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Down)) { + selectedSettingIndex = (selectedSettingIndex < currentCategorySettingsCount - 1) ? (selectedSettingIndex + 1) : 0; + updateThemeContext(false); // Partial update + updateRequired = true; + return; + } + + // Left/Right/PageBack/PageForward switches categories + if (mappedInput.wasPressed(MappedInputManager::Button::Left) || + mappedInput.wasPressed(MappedInputManager::Button::PageBack)) { + selectedCategoryIndex = (selectedCategoryIndex > 0) ? (selectedCategoryIndex - 1) : (categoryCount - 1); + selectedSettingIndex = 0; // Reset to first setting in new category + updateThemeContext(true); // Full update (category changed) + updateRequired = true; + return; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Right) || + mappedInput.wasPressed(MappedInputManager::Button::PageForward)) { + selectedCategoryIndex = (selectedCategoryIndex < categoryCount - 1) ? (selectedCategoryIndex + 1) : 0; + selectedSettingIndex = 0; + updateThemeContext(true); // Full update + updateRequired = true; + return; + } + + // Confirm toggles/activates current setting + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + toggleCurrentSetting(); + updateThemeContext(false); // Values changed, partial update is enough (names don't change) + updateRequired = true; + return; + } + + // Back exits + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + SETTINGS.saveToFile(); + onGoHome(); + } +} + +void SettingsActivity::toggleCurrentSetting() { + const auto& setting = allCategories[selectedCategoryIndex].settings[selectedSettingIndex]; + + if (setting.type == SettingType::TOGGLE && setting.valuePtr) { + bool currentVal = SETTINGS.*(setting.valuePtr); + SETTINGS.*(setting.valuePtr) = !currentVal; + } else if (setting.type == SettingType::ENUM && setting.valuePtr) { + uint8_t val = SETTINGS.*(setting.valuePtr); + SETTINGS.*(setting.valuePtr) = (val + 1) % static_cast(setting.enumValues.size()); + } else if (setting.type == SettingType::VALUE && setting.valuePtr) { + int8_t val = SETTINGS.*(setting.valuePtr); + if (val + setting.valueRange.step > setting.valueRange.max) { + SETTINGS.*(setting.valuePtr) = setting.valueRange.min; + } else { + SETTINGS.*(setting.valuePtr) = val + setting.valueRange.step; + } + } else if (setting.type == SettingType::ACTION) { + if (strcmp(setting.name, "KOReader Sync") == 0) { + enterNewActivity(new KOReaderSettingsActivity(renderer, mappedInput, [this] { subActivityExitPending = true; })); + } else if (strcmp(setting.name, "Calibre Settings") == 0) { + enterNewActivity(new CalibreSettingsActivity(renderer, mappedInput, [this] { subActivityExitPending = true; })); + } else if (strcmp(setting.name, "Clear Cache") == 0) { + enterNewActivity(new ClearCacheActivity(renderer, mappedInput, [this] { subActivityExitPending = true; })); + } else if (strcmp(setting.name, "Check for updates") == 0) { + enterNewActivity(new OtaUpdateActivity(renderer, mappedInput, [this] { subActivityExitPending = true; })); + } else if (strcmp(setting.name, "Theme") == 0) { + enterNewActivity(new ThemeSelectionActivity(renderer, mappedInput, [this] { subActivityExitPending = true; })); + } + vTaskDelay(50 / portTICK_PERIOD_MS); + } + + SETTINGS.saveToFile(); +} diff --git a/src/activities/settings/SettingsActivity.h b/src/activities/settings/SettingsActivity.h index 821dda42..ed5ee443 100644 --- a/src/activities/settings/SettingsActivity.h +++ b/src/activities/settings/SettingsActivity.h @@ -4,9 +4,9 @@ #include #include -#include -#include +#include "ThemeContext.h" +#include "ThemeManager.h" #include "activities/ActivityWithSubactivity.h" class CrossPointSettings; @@ -16,7 +16,9 @@ class SettingsActivity final : public ActivityWithSubactivity { TaskHandle_t displayTaskHandle = nullptr; SemaphoreHandle_t renderingMutex = nullptr; bool updateRequired = false; - int selectedCategoryIndex = 0; // Currently selected category + bool subActivityExitPending = false; + int selectedCategoryIndex = 0; + int selectedSettingIndex = 0; const std::function onGoHome; static constexpr int categoryCount = 4; @@ -25,7 +27,13 @@ class SettingsActivity final : public ActivityWithSubactivity { static void taskTrampoline(void* param); [[noreturn]] void displayTaskLoop(); void render() const; - void enterCategory(int categoryIndex); + void enterCategoryLegacy(int categoryIndex); + + // Theme support + ThemeEngine::ThemeContext themeContext; + void updateThemeContext(bool fullUpdate = false); + void handleThemeInput(); + void toggleCurrentSetting(); public: explicit SettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,