Pārlūkot izejas kodu

Add text object support simple (#537)

* fix: support single-value vectors in user settings

Some wallpapers (e.g. scene 3024089275) pass a single scalar value
for properties the parser expected to be a vec2/3/4. preparseSize
previously threw; now it returns 1 and UserSettingParser treats the
value as a float, matching how numeric literals are already handled.

* feat(text): Phase 1 – static text object rendering via FreeType

Implements the first phase of text object support, resolving the
longstanding "Text objects are not supported yet" error logged for
every text element in a scene (issue #29 on upstream repo).

--- What is implemented ---

* Data model (Object.h, Types.h):
  - New `TextData` struct holding all text properties: text string,
    script source, pointsize, size, per-property UserSettingUniquePtr
    fields (scale, color, alpha, visible) to allow future property
    overrides, alignment, verticalalign, padding.
  - New `Text` class inheriting Object + TextData, forward-declared in
    Types.h alongside TextUniquePtr alias.

* Parser (ObjectParser.h/.cpp):
  - New `parseText()` static method, follows the same pattern as
    `parseImage()` and `parseParticle()`.
  - Handles both text forms:
      · Static:  `"text": "some string"` → stored in TextData::text
      · Dynamic: `"text": {"script": "..."}` → script source stored in
        TextData::script; rendered string left empty (Phase 2 concern).
  - Parses color as vec3/vec4 and widens to vec4 (same as CImage).
  - Replaces the previous stub `sLog.error("Text objects are not
    supported yet")`.

* Build (CMakeLists.txt):
  - Adds `find_package(Freetype REQUIRED)` after PulseAudio.
  - Adds CText.h/.cpp to COMMON_SOURCES.
  - Links `Freetype::Freetype` into the target.

* Renderer (CText.h/.cpp, CScene.cpp):
  - `CText : public CObject` (intentionally NOT CRenderable — no
    materials/passes/FBOs needed for static text in Phase 1; see note
    below about Phase 2).
  - `setup()` initialises FreeType, loads a system font (DejaVu Sans or
    Liberation Sans, first candidate found), rasterises the static text
    string into a single GL_RED texture, compiles a minimal GLSL 3.30
    shader pair (textured quad with color + alpha uniforms), and builds
    a VAO/VBO quad.
  - `render()` converts WE scene-space origin to GL coordinates
    accounting for vflip=true used on Wayland/GLFW outputs (gl_y =
    origin.y - scene_h/2; NOT the CImage-style scene_h/2 - origin.y).
  - Respects the `visible` UserSetting; skips rendering when false.
  - `CScene::createObject()` now recognises `object.is<Text>()` and
    instantiates CText the same way as CImage/CParticle.

--- What is NOT implemented (Phase 2) ---

* Dynamic / scripted text: objects where `"text"` is a JSON object
  with a `"script"` field are detected and logged as:
    "CText: skipping dynamic/scripted text (Phase 1 static-only): <name>"
  Evaluating those scripts requires integrating the existing
  ScriptEngine with a per-frame text-update path and wiring the result
  back into the texture. All the text properties already use
  UserSettingUniquePtr so they are ready for live updates.

* Wallpaper-bundled fonts: Phase 1 always falls back to system fonts.
  WE scenes ship .ttf files under materials/fonts/; Phase 2 should
  resolve the font path from the scene's asset locator.

* Multi-line layout, vertical alignment, padding: the current rasteriser
  renders a single line. The TextData fields (verticalalign, padding,
  size) are parsed and stored but not yet used for layout.

* Effect passes: because CText inherits CObject instead of CRenderable,
  it does not participate in the material/effects pipeline. Phase 2 may
  refactor CText to inherit CRenderable if effects on text are needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(scene): extract object type dispatch to reduce createObject complexity

Split the type-dispatch if/else chain from createObject() into a new
private method dispatchObjectType(). This lowers the cyclomatic
complexity of createObject() to satisfy the CI complexity check while
keeping the same behaviour.

No functional change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(text): address PR #537 review feedback

- CMakeLists: link Freetype to tests target so CI build finds ft2build.h
- parseText: normalize IVec3 color to 0-1 range instead of passing raw 0-255
- UserSettingParser: validate full scalar string with std::stof, raise on partial parse
- CText: iterate text as unsigned char so glyphs >127 load on signed-char platforms
- CScene: delete and return nullptr when CText::setup fails, avoid registering broken object
- CI/README: add libfreetype-dev to apt install lists
- CText: add TODO for Phase 2 embedded-font loading via AssetLocator

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: implement dynamic scripting system for CText objects

* refactor(text): reduce complexity of parseText and CText::setup

Extract helpers so both methods clear the auto-tester complexity
threshold. No behavior changes.

- ObjectParser::parseText: move script source resolution, scriptProperties
  parsing, string-setting construction, and color widening into
  anonymous-namespace free functions.
- CText::setup: split FreeType init, font loading (embedded/system),
  pixel size compensation, and script layer creation into private
  methods. Flow is now a linear sequence of early-returns.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Alan PV 4 mēneši atpakaļ
vecāks
revīzija
f42a5a0774

+ 7 - 7
.github/workflows/cmake.yml

@@ -58,7 +58,7 @@ jobs:
 
       - name: Install dependencies
         if: matrix.os == 'ubuntu-24.04'
-        run: sudo apt-get update && sudo apt-get -y install libgl-dev libglew-dev freeglut3-dev libsdl2-dev liblz4-dev libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libglm-dev libglfw3-dev libmpv-dev mpv libmpv2 libfftw3-dev
+        run: sudo apt-get update && sudo apt-get -y install libgl-dev libglew-dev freeglut3-dev libsdl2-dev liblz4-dev libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libglm-dev libglfw3-dev libmpv-dev mpv libmpv2 libfftw3-dev libfreetype-dev
 
       - name: Configure CMake
         # Configure CMake in a 'build' subdirectory. `CMAKE_BUILD_TYPE` is only required if you are using a single-configuration generator such as make.
@@ -99,10 +99,10 @@ jobs:
 
     - name: Install dependencies
       if: matrix.os == 'ubuntu-22.04'
-      run: sudo apt-get update && sudo apt-get -y install libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libgl-dev libglew-dev freeglut3-dev libsdl2-dev liblz4-dev libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libxxf86vm-dev libglm-dev libglfw3-dev libmpv-dev mpv libmpv1 libpulse-dev libpulse0 libfftw3-dev
+      run: sudo apt-get update && sudo apt-get -y install libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libgl-dev libglew-dev freeglut3-dev libsdl2-dev liblz4-dev libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libxxf86vm-dev libglm-dev libglfw3-dev libmpv-dev mpv libmpv1 libpulse-dev libpulse0 libfftw3-dev libfreetype-dev
     - name: Install dependencies
       if: matrix.os == 'ubuntu-24.04'
-      run: sudo apt-get update && sudo apt-get -y install libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libgl-dev libglew-dev freeglut3-dev libsdl2-dev liblz4-dev libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libxxf86vm-dev libglm-dev libglfw3-dev libmpv-dev mpv libmpv2 libpulse-dev libpulse0 libfftw3-dev
+      run: sudo apt-get update && sudo apt-get -y install libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libgl-dev libglew-dev freeglut3-dev libsdl2-dev liblz4-dev libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libxxf86vm-dev libglm-dev libglfw3-dev libmpv-dev mpv libmpv2 libpulse-dev libpulse0 libfftw3-dev libfreetype-dev
 
     - name: Configure CMake
       # Configure CMake in a 'build' subdirectory. `CMAKE_BUILD_TYPE` is only required if you are using a single-configuration generator such as make.
@@ -148,11 +148,11 @@ jobs:
 
       - name: Install dependencies
         if: matrix.os == 'ubuntu-22.04'
-        run: sudo apt-get update && sudo apt-get -y install libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libgl-dev libglew-dev freeglut3-dev libsdl2-dev liblz4-dev libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libxxf86vm-dev libglm-dev libglfw3-dev libmpv-dev mpv libmpv1 libpulse-dev libpulse0 libfftw3-dev wayland-scanner++ wayland-protocols libwayland-dev
+        run: sudo apt-get update && sudo apt-get -y install libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libgl-dev libglew-dev freeglut3-dev libsdl2-dev liblz4-dev libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libxxf86vm-dev libglm-dev libglfw3-dev libmpv-dev mpv libmpv1 libpulse-dev libpulse0 libfftw3-dev libfreetype-dev wayland-scanner++ wayland-protocols libwayland-dev
 
       - name: Install dependencies
         if: matrix.os == 'ubuntu-24.04'
-        run: sudo apt-get update && sudo apt-get -y install libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libgl-dev libglew-dev freeglut3-dev libsdl2-dev liblz4-dev libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libxxf86vm-dev libglm-dev libglfw3-dev libmpv-dev mpv libmpv2 libpulse-dev libpulse0 libfftw3-dev wayland-scanner++ wayland-protocols libwayland-dev
+        run: sudo apt-get update && sudo apt-get -y install libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libgl-dev libglew-dev freeglut3-dev libsdl2-dev liblz4-dev libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libxxf86vm-dev libglm-dev libglfw3-dev libmpv-dev mpv libmpv2 libpulse-dev libpulse0 libfftw3-dev libfreetype-dev wayland-scanner++ wayland-protocols libwayland-dev
 
       - name: Configure CMake
         # Configure CMake in a 'build' subdirectory. `CMAKE_BUILD_TYPE` is only required if you are using a single-configuration generator such as make.
@@ -190,11 +190,11 @@ jobs:
 
     - name: Install dependencies
       if: matrix.os == 'ubuntu-22.04'
-      run: sudo apt-get update && sudo apt-get -y install wayland-scanner++ wayland-protocols libwayland-dev libwayland-egl-backend-dev libglfw3-dev libglew-dev freeglut3-dev libsdl2-dev liblz4-dev libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libglm-dev libmpv-dev mpv libmpv1 libpulse-dev libpulse0 libfftw3-dev
+      run: sudo apt-get update && sudo apt-get -y install wayland-scanner++ wayland-protocols libwayland-dev libwayland-egl-backend-dev libglfw3-dev libglew-dev freeglut3-dev libsdl2-dev liblz4-dev libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libglm-dev libmpv-dev mpv libmpv1 libpulse-dev libpulse0 libfftw3-dev libfreetype-dev
 
     - name: Install dependencies
       if: matrix.os == 'ubuntu-24.04'
-      run: sudo apt-get update && sudo apt-get -y install wayland-scanner++ wayland-protocols libwayland-dev libwayland-egl-backend-dev libglfw3-dev libglew-dev freeglut3-dev libsdl2-dev liblz4-dev libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libglm-dev libmpv-dev mpv libmpv2 libpulse-dev libpulse0 libfftw3-dev
+      run: sudo apt-get update && sudo apt-get -y install wayland-scanner++ wayland-protocols libwayland-dev libwayland-egl-backend-dev libglfw3-dev libglew-dev freeglut3-dev libsdl2-dev liblz4-dev libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libglm-dev libmpv-dev mpv libmpv2 libpulse-dev libpulse0 libfftw3-dev libfreetype-dev
 
     - name: Configure CMake
       # Configure CMake in a 'build' subdirectory. `CMAKE_BUILD_TYPE` is only required if you are using a single-configuration generator such as make.

+ 5 - 0
CMakeLists.txt

@@ -35,6 +35,7 @@ find_package(MPV REQUIRED)
 find_package(LZ4 REQUIRED)
 find_package(FFMPEG REQUIRED)
 find_package(PulseAudio REQUIRED)
+find_package(Freetype REQUIRED)
 
 set(ENABLE_OPT OFF)
 set(SPIRV_CROSS_FORCE_PIC ON)
@@ -398,6 +399,8 @@ set(COMMON_SOURCES
     src/WallpaperEngine/Render/Objects/CSound.cpp
     src/WallpaperEngine/Render/Objects/CParticle.h
     src/WallpaperEngine/Render/Objects/CParticle.cpp
+    src/WallpaperEngine/Render/Objects/CText.h
+    src/WallpaperEngine/Render/Objects/CText.cpp
 
     src/WallpaperEngine/Render/CFBO.h
     src/WallpaperEngine/Render/CFBO.cpp
@@ -510,6 +513,7 @@ target_link_libraries (linux-wallpaperengine-lib PUBLIC
     ${FFMPEG_LIBRARIES}
     ${MPV_LIBRARY}
     ${PULSEAUDIO_LIBRARY}
+    Freetype::Freetype
     ${WAYLAND_LIBRARIES}
     ${X11_LIBRARIES}
     kissfft
@@ -534,6 +538,7 @@ if (BUILD_TESTING)
         ${FFMPEG_LIBRARIES}
         ${MPV_LIBRARY}
         ${PULSEAUDIO_LIBRARY}
+        Freetype::Freetype
         ${WAYLAND_LIBRARIES}
         ${X11_LIBRARIES}
         kissfft

+ 2 - 2
README.md

@@ -41,13 +41,13 @@ Install the required dependencies on Ubuntu/Debian-based systems:
 ### Ubuntu 22.04
 ```bash
 sudo apt-get update
-sudo apt-get install build-essential cmake libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libgl-dev libglew-dev freeglut3-dev libsdl2-dev liblz4-dev libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libxxf86vm-dev libglm-dev libglfw3-dev libmpv-dev mpv libmpv1 libpulse-dev libpulse0 libfftw3-dev
+sudo apt-get install build-essential cmake libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libgl-dev libglew-dev freeglut3-dev libsdl2-dev liblz4-dev libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libxxf86vm-dev libglm-dev libglfw3-dev libmpv-dev mpv libmpv1 libpulse-dev libpulse0 libfftw3-dev libfreetype-dev
 ```
 
 ### Ubuntu 24.04
 ```bash
 sudo apt-get update
-sudo apt-get install build-essential cmake libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libgl-dev libglew-dev freeglut3-dev libsdl2-dev liblz4-dev libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libxxf86vm-dev libglm-dev libglfw3-dev libmpv-dev mpv libmpv2 libpulse-dev libpulse0 libfftw3-dev
+sudo apt-get install build-essential cmake libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libgl-dev libglew-dev freeglut3-dev libsdl2-dev liblz4-dev libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libxxf86vm-dev libglm-dev libglfw3-dev libmpv-dev mpv libmpv2 libpulse-dev libpulse0 libfftw3-dev libfreetype-dev
 ```
 
 ### Alt linux

+ 1 - 1
src/WallpaperEngine/Data/Builders/VectorBuilder.h

@@ -38,7 +38,7 @@ public:
 	const char* third = second ? strchr (second + 1, ' ') : nullptr;
 
 	if (first == nullptr) {
-	    sLog.exception ("Invalid vector format: " + str + " (too few values, expected: 2, 3 or 4)");
+	    return 1;
 	}
 
 	if (second == nullptr) {

+ 42 - 0
src/WallpaperEngine/Data/Model/Object.h

@@ -1,5 +1,6 @@
 #pragma once
 
+#include <map>
 #include <optional>
 #include <string>
 #include <utility>
@@ -576,4 +577,45 @@ public:
 	Object (std::move (data)), ParticleData (std::move (particleData)) { };
     ~Particle () override = default;
 };
+
+/**
+ * Text object data. Phase 1 of text support covers only static text;
+ * dynamic (script-driven) text captures the script source for a future
+ * pass but renders whatever initial value the scene provides.
+ */
+struct TextData {
+    /** Initial text content to render (for scripted text, this is the `value` placeholder) */
+    std::string text;
+    /** Scripted text source — full JS, resolved inline or loaded from a .js asset. Empty for static text. */
+    std::string script;
+    /** Typed initial values for the script's scriptProperties, keyed by property name */
+    std::map<std::string, UserSettingUniquePtr> scriptProperties;
+    /** Font reference from scene (e.g. "fonts/VCR_OSD_MONO.ttf" or "systemfont_arial") */
+    std::string font;
+    /** Font size in points */
+    float pointsize;
+    /** Bounding box size */
+    glm::vec2 size;
+    /** Scale (x, y, z) */
+    UserSettingUniquePtr scale;
+    /** Text color as linear-space RGB */
+    UserSettingUniquePtr color;
+    /** Alpha multiplier */
+    UserSettingUniquePtr alpha;
+    /** Whether the text is visible */
+    UserSettingUniquePtr visible;
+    /** Horizontal alignment: "left", "center", "right" */
+    std::string alignment;
+    /** Vertical alignment: "top", "center", "bottom" */
+    std::string verticalalign;
+    /** Padding inside the bounding box */
+    int padding;
+};
+
+class Text : public Object, public TextData {
+public:
+    explicit Text (ObjectData data, TextData textData) noexcept :
+	Object (std::move (data)), TextData (std::move (textData)) { };
+    ~Text () override = default;
+};
 } // namespace WallpaperEngine::Data::Model

+ 2 - 0
src/WallpaperEngine/Data/Model/Types.h

@@ -19,6 +19,7 @@ class Image;
 struct ImageEffect;
 struct ImageEffectPassOverride;
 class Particle;
+class Text;
 class DynamicValue;
 class Property;
 class PropertySlider;
@@ -49,6 +50,7 @@ using ObjectUniquePtr = std::unique_ptr<Object>;
 using SoundUniquePtr = std::unique_ptr<Sound>;
 using ImageUniquePtr = std::unique_ptr<Image>;
 using ParticleUniquePtr = std::unique_ptr<Particle>;
+using TextUniquePtr = std::unique_ptr<Text>;
 using MaterialUniquePtr = std::unique_ptr<Material>;
 using MaterialPassUniquePtr = std::unique_ptr<MaterialPass>;
 using EffectUniquePtr = std::unique_ptr<Effect>;

+ 122 - 2
src/WallpaperEngine/Data/Parsers/ObjectParser.cpp

@@ -4,6 +4,7 @@
 #include "ModelParser.h"
 
 #include "ShaderConstantParser.h"
+#include "UserSettingParser.h"
 #include "WallpaperEngine/Data/Model/Object.h"
 #include "WallpaperEngine/Data/Model/Project.h"
 #include "WallpaperEngine/Logging/Log.h"
@@ -14,6 +15,78 @@
 using namespace WallpaperEngine::Data::Parsers;
 using namespace WallpaperEngine::Data::Model;
 
+namespace {
+// Wraps a string value in a UserSetting with a String-typed DynamicValue.
+// UserSettingParser would try to numeric-parse single-char strings like "-"/":",
+// so we bypass it for raw string script-property values.
+UserSettingUniquePtr makeStringSetting (const std::string& s) {
+    auto dv = std::make_unique<DynamicValue> ();
+    dv->update (s);
+    return std::make_unique<UserSetting> (UserSetting {
+	.value = std::move (dv),
+	.property = nullptr,
+	.condition = std::nullopt,
+    });
+}
+
+// Resolves the `script` field of a scripted text node: if it looks like a
+// single-line path ending in .js, read it through the asset locator.
+// Returns empty string on failure (caller treats empty script as static).
+std::string resolveScriptSource (std::string raw, const Project& project) {
+    const bool looksLikePath =
+	!raw.empty () && raw.find ('\n') == std::string::npos && raw.size () >= 3
+	&& raw.compare (raw.size () - 3, 3, ".js") == 0;
+
+    if (!looksLikePath || project.assetLocator == nullptr)
+	return raw;
+
+    try {
+	return project.assetLocator->readString (raw);
+    } catch (const std::exception& e) {
+	sLog.error ("CText: cannot load script asset '", raw, "': ", e.what ());
+	return {};
+    }
+}
+
+// Parses a text object's `scriptproperties` subtree into a map of UserSettings.
+// Plain string values bypass UserSettingParser (which stof-throws on "-"/":"),
+// object values with a string `value` field also go through the string path.
+std::map<std::string, UserSettingUniquePtr>
+parseScriptProperties (const JSON& propsObj, const Properties& properties) {
+    std::map<std::string, UserSettingUniquePtr> out;
+    for (const auto& [key, propData] : propsObj.items ()) {
+	try {
+	    if (propData.is_string ()) {
+		out.emplace (key, makeStringSetting (propData.template get<std::string> ()));
+		continue;
+	    }
+	    if (propData.is_object ()) {
+		if (const auto valueField = propData.find ("value");
+		    valueField != propData.end () && valueField->is_string ()) {
+		    out.emplace (key, makeStringSetting (valueField->template get<std::string> ()));
+		    continue;
+		}
+	    }
+	    out.emplace (key, UserSettingParser::parse (propData, properties));
+	} catch (const std::exception& e) {
+	    sLog.error ("CText: failed to parse scriptProperty '", key, "': ", e.what ());
+	}
+    }
+    return out;
+}
+
+// The `color` user setting may arrive as vec3 or ivec3; normalize to vec4 with
+// full alpha so downstream renderers can assume a uniform type.
+void widenColorToVec4 (DynamicValue& v) {
+    if (v.getType () == DynamicValue::UnderlyingType::Vec3) {
+	v.update (glm::vec4 (v.getVec3 (), 1.0f));
+    } else if (v.getType () == DynamicValue::UnderlyingType::IVec3) {
+	const glm::ivec3 icolor = v.getIVec3 ();
+	v.update (glm::vec4 (glm::vec3 (icolor), 255.0f) / 255.0f);
+    }
+}
+} // namespace
+
 ObjectUniquePtr ObjectParser::parse (const JSON& it, const Project& project) {
     const auto imageIt = it.find ("image");
     const auto soundIt = it.find ("sound");
@@ -55,7 +128,7 @@ ObjectUniquePtr ObjectParser::parse (const JSON& it, const Project& project) {
     } else if (particleIt != it.end ()) {
 	return parseParticle (it, project, std::move (basedata));
     } else if (textIt != it.end ()) {
-	sLog.error ("Text objects are not supported yet");
+	return parseText (it, project, std::move (basedata));
     } else if (lightIt != it.end ()) {
 	sLog.error ("Light objects are not supported yet");
     } else {
@@ -101,6 +174,53 @@ SoundUniquePtr ObjectParser::parseSound (const JSON& it, ObjectData base) {
     );
 }
 
+TextUniquePtr ObjectParser::parseText (const JSON& it, const Project& project, ObjectData base) {
+    const auto& properties = project.properties;
+    const auto textIt = it.require ("text", "Text object must have a text field");
+
+    std::string text;
+    std::string script;
+    std::map<std::string, UserSettingUniquePtr> scriptProps;
+
+    if (textIt.is_string ()) {
+	text = textIt.get<std::string> ();
+    } else if (textIt.is_object ()) {
+	// Scripted text: carries the JS source (either inline or as a .js asset path),
+	// an initial placeholder `value`, and typed initial values for scriptProperties.
+	if (const auto scriptIt = textIt.optional<std::string> ("script"); scriptIt.has_value ())
+	    script = resolveScriptSource (*scriptIt, project);
+
+	if (const auto valueIt = textIt.optional<std::string> ("value"); valueIt.has_value ())
+	    text = *valueIt;
+
+	if (const auto propsIt = textIt.find ("scriptproperties"); propsIt != textIt.end () && propsIt->is_object ())
+	    scriptProps = parseScriptProperties (*propsIt, properties);
+    }
+
+    auto result = std::make_unique<Text> (
+	std::move (base),
+	TextData {
+	    .text = std::move (text),
+	    .script = std::move (script),
+	    .scriptProperties = std::move (scriptProps),
+	    .font = it.optional ("font", std::string ()),
+	    .pointsize = it.optional ("pointsize", 32.0f),
+	    .size = it.optional ("size", glm::vec2 (0.0f)),
+	    .scale = it.user ("scale", properties, glm::vec3 (1.0f)),
+	    .color = it.user ("color", properties, glm::vec4 (1.0f)),
+	    .alpha = it.user ("alpha", properties, 1.0f),
+	    .visible = it.user ("visible", properties, true),
+	    .alignment = it.optional ("horizontalalign", it.optional ("alignment", std::string ("center"))),
+	    .verticalalign = it.optional ("verticalalign", std::string ("center")),
+	    .padding = it.optional ("padding", 0),
+	}
+    );
+
+    widenColorToVec4 (*result->color->value);
+
+    return result;
+}
+
 ImageUniquePtr
 ObjectParser::parseImage (const JSON& it, const Project& project, ObjectData base, const std::string& image) {
     const auto& properties = project.properties;
@@ -115,7 +235,7 @@ ObjectParser::parseImage (const JSON& it, const Project& project, ObjectData bas
 	    .visible = it.user ("visible", properties, true),
 	    .alpha = it.user ("alpha", properties, 1.0f),
 	    .color = it.user ("color", properties, glm::vec4 (1.0f)),
-	    .alignment = it.optional ("alignment", std::string ("center")),
+	    .alignment = it.optional ("horizontalalign", it.optional ("alignment", std::string ("center"))),
 	    .size = it.optional ("size", glm::vec2 (0.0f)),
 	    .parallaxDepth = it.user ("parallaxDepth", properties, glm::vec2 (0.0f)),
 	    .colorBlendMode = it.optional ("colorBlendMode", 0),

+ 1 - 0
src/WallpaperEngine/Data/Parsers/ObjectParser.h

@@ -23,6 +23,7 @@ private:
     static ImageUniquePtr
     parseImage (const JSON& it, const Project& project, ObjectData base, const std::string& image);
     static ParticleUniquePtr parseParticle (const JSON& it, const Project& project, ObjectData base);
+    static TextUniquePtr parseText (const JSON& it, const Project& project, ObjectData base);
     static std::vector<ImageEffectUniquePtr> parseEffects (const JSON& it, const Project& project);
     static ImageEffectUniquePtr parseEffect (const JSON& it, const Project& project);
     static std::vector<ImageEffectPassOverrideUniquePtr>

+ 9 - 1
src/WallpaperEngine/Data/Parsers/UserSettingParser.cpp

@@ -3,6 +3,7 @@
 #include "WallpaperEngine/Data/Model/Property.h"
 #include "WallpaperEngine/Data/Model/ScriptedDynamicValue.h"
 #include "WallpaperEngine/Data/Model/UserSetting.h"
+#include "WallpaperEngine/Logging/Log.h"
 
 using namespace WallpaperEngine::Data::Parsers;
 using namespace WallpaperEngine::Data::Builders;
@@ -53,7 +54,14 @@ UserSettingUniquePtr UserSettingParser::parse (const json& data, const Propertie
 	std::string str = valueIt;
 
 	// TODO: VALIDATE THIS IS RIGHT?
-	if (int size = VectorBuilder::preparseSize (str); size == 2) {
+	if (int size = VectorBuilder::preparseSize (str); size == 1) {
+	    size_t parsed = 0;
+	    const float scalar = std::stof (str, &parsed);
+	    if (parsed != str.size ()) {
+		sLog.exception ("Invalid scalar format: ", str);
+	    }
+	    value->update (scalar);
+	} else if (size == 2) {
 	    value->update (static_cast<glm::vec2> (valueIt));
 	} else if (size == 3) {
 	    value->update (static_cast<glm::vec3> (valueIt));

+ 394 - 0
src/WallpaperEngine/Render/Objects/CText.cpp

@@ -0,0 +1,394 @@
+#include "CText.h"
+
+#include <filesystem>
+#include <vector>
+
+#include <ft2build.h>
+#include FT_FREETYPE_H
+
+#include <glm/gtc/matrix_transform.hpp>
+#include <glm/gtc/type_ptr.hpp>
+
+#include "WallpaperEngine/Data/Model/DynamicValue.h"
+#include "WallpaperEngine/Data/Model/Object.h"
+#include "WallpaperEngine/Data/Model/UserSetting.h"
+#include "WallpaperEngine/Logging/Log.h"
+#include "WallpaperEngine/Render/Camera.h"
+#include "WallpaperEngine/Render/Wallpapers/CScene.h"
+#include "WallpaperEngine/Scripting/ScriptEngine.h"
+
+using namespace WallpaperEngine::Render::Objects;
+
+namespace {
+// TODO: Phase 2 – load font from wallpaper's materials/fonts/ using AssetLocator
+// Phase 1 uses a system font instead of the font shipped by the wallpaper.
+// Wallpaper Engine bundles .ttf files in `materials/fonts/`; wiring those in
+// is deferred to Phase 2 along with dynamic/scripted text.
+const std::vector<std::string> kFontCandidates = {
+    "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
+    "/usr/share/fonts/TTF/DejaVuSans.ttf",
+    "/usr/share/fonts/dejavu/DejaVuSans.ttf",
+    "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
+};
+
+const char* kVertexShader = R"glsl(
+#version 330 core
+layout(location = 0) in vec2 aPos;
+layout(location = 1) in vec2 aUV;
+uniform mat4 uMVP;
+out vec2 vUV;
+void main() {
+    vUV = aUV;
+    gl_Position = uMVP * vec4(aPos, 0.0, 1.0);
+}
+)glsl";
+
+const char* kFragmentShader = R"glsl(
+#version 330 core
+in vec2 vUV;
+uniform sampler2D uTexture;
+uniform vec4 uColor;
+out vec4 FragColor;
+void main() {
+    float coverage = texture(uTexture, vUV).r;
+    FragColor = vec4(uColor.rgb, uColor.a * coverage);
+}
+)glsl";
+
+GLuint compileShader (GLenum type, const char* source) {
+    GLuint shader = glCreateShader (type);
+    glShaderSource (shader, 1, &source, nullptr);
+    glCompileShader (shader);
+
+    GLint status = GL_FALSE;
+    glGetShaderiv (shader, GL_COMPILE_STATUS, &status);
+    if (status != GL_TRUE) {
+	char log[1024];
+	glGetShaderInfoLog (shader, sizeof (log), nullptr, log);
+	sLog.error ("CText shader compile failed: ", log);
+	glDeleteShader (shader);
+	return 0;
+    }
+    return shader;
+}
+} // namespace
+
+CText::CText (Wallpapers::CScene& scene, const Text& text) : CObject (scene, text), m_text (text) {}
+
+CText::~CText () {
+    if (m_layerHandle != Scripting::kInvalidLayerHandle) {
+	Scripting::ScriptEngine::instance ().destroyLayer (m_layerHandle);
+	m_layerHandle = Scripting::kInvalidLayerHandle;
+    }
+    if (m_vbo != 0) glDeleteBuffers (1, &m_vbo);
+    if (m_vao != 0) glDeleteVertexArrays (1, &m_vao);
+    if (m_program != 0) glDeleteProgram (m_program);
+    if (m_texture != 0) glDeleteTextures (1, &m_texture);
+    if (m_ftFace != nullptr) FT_Done_Face (m_ftFace);
+    if (m_ftLibrary != nullptr) FT_Done_FreeType (m_ftLibrary);
+}
+
+void CText::setup () {
+    const bool scripted = !m_text.script.empty ();
+
+    // Nothing to render and no script to produce text later → bail.
+    if (m_text.text.empty () && !scripted)
+	return;
+
+    if (!initFreeType ())
+	return;
+
+    if (!loadEmbeddedFont () && !loadSystemFont ())
+	return;
+
+    FT_Set_Pixel_Sizes (m_ftFace, 0, static_cast<FT_UInt> (computeEffectivePixelSize ()));
+
+    buildShader ();
+    // Scripted text may have an empty placeholder; use a single space so the
+    // glyph texture has non-zero dimensions until the script produces a value.
+    rebuildTextureFrom (m_text.text.empty () ? std::string (" ") : m_text.text);
+
+    if (scripted)
+	initScriptLayer ();
+
+    m_valid = m_texture != 0 && m_program != 0 && m_vao != 0;
+}
+
+bool CText::initFreeType () {
+    if (FT_Init_FreeType (&m_ftLibrary) == 0)
+	return true;
+    sLog.error ("CText: FT_Init_FreeType failed for object ", m_text.name);
+    return false;
+}
+
+bool CText::loadEmbeddedFont () {
+    // Wallpapers packed in .pkg don't expose physical paths, so we read the font
+    // into memory and use FT_New_Memory_Face. m_fontData must outlive the face.
+    // `systemfont_*` references signal "use a system font"; let the fallback handle them.
+    if (m_text.font.empty () || m_text.font.rfind ("systemfont_", 0) == 0)
+	return false;
+
+    try {
+	auto stream = getAssetLocator ().read (m_text.font);
+	stream->seekg (0, std::ios::end);
+	const auto size = stream->tellg ();
+	stream->seekg (0, std::ios::beg);
+	m_fontData.resize (static_cast<size_t> (size));
+	stream->read (reinterpret_cast<char*> (m_fontData.data ()), size);
+
+	if (FT_New_Memory_Face (m_ftLibrary, m_fontData.data (),
+		static_cast<FT_Long> (m_fontData.size ()), 0, &m_ftFace) == 0)
+	    return true;
+
+	sLog.error ("CText: FT_New_Memory_Face failed for '", m_text.font, "', falling back to system font");
+    } catch (const std::exception& e) {
+	sLog.error ("CText: cannot read font '", m_text.font, "': ", e.what (), ", falling back to system font");
+    }
+
+    m_fontData.clear ();
+    return false;
+}
+
+bool CText::loadSystemFont () {
+    std::string fontPath;
+    for (const auto& candidate : kFontCandidates) {
+	if (std::filesystem::exists (candidate)) {
+	    fontPath = candidate;
+	    break;
+	}
+    }
+    if (fontPath.empty ()) {
+	sLog.error ("CText: no usable system font found");
+	return false;
+    }
+    if (FT_New_Face (m_ftLibrary, fontPath.c_str (), 0, &m_ftFace) != 0) {
+	sLog.error ("CText: FT_New_Face failed for ", fontPath);
+	return false;
+    }
+    return true;
+}
+
+unsigned int CText::computeEffectivePixelSize () const {
+    // WE text objects often come with scale ~0.09 that, combined with a modest
+    // pointsize, would rasterize glyphs to ~2px on screen (invisible). Rasterize
+    // at higher resolution so that after the model scale is applied in render()
+    // the on-screen size matches the intended pointsize.
+    const glm::vec3 initialScale = m_text.scale->value->getVec3 ();
+    const float avgScale = (initialScale.x + initialScale.y) * 0.5f;
+    const float compensate = (avgScale > 0.0f && avgScale < 1.0f)
+	? std::min (1.0f / avgScale, 32.0f)
+	: 1.0f;
+    return std::max<unsigned int> (
+	1u, static_cast<unsigned int> (static_cast<float> (m_text.pointsize) * compensate));
+}
+
+void CText::initScriptLayer () {
+    std::map<std::string, DynamicValue*> initialProps;
+    for (const auto& [k, setting] : m_text.scriptProperties) {
+	if (setting && setting->value)
+	    initialProps.emplace (k, setting->value.get ());
+    }
+    m_layerHandle = Scripting::ScriptEngine::instance ().createLayerScript (
+	m_text.script, initialProps, m_text.text
+    );
+    if (m_layerHandle == Scripting::kInvalidLayerHandle)
+	sLog.error ("CText: createLayerScript failed for '", m_text.name, "'");
+}
+
+void CText::rebuildTextureFrom (const std::string& text) {
+    // Two-pass rasterization: first measure the bounding box, then rasterize
+    // every glyph into a single grayscale bitmap. Phase 1 renders one line —
+    // multi-line wrapping, alignment, and padding come with Phase 2.
+    //
+    // Safe to call repeatedly: GL handles (texture, VAO, VBO) are reused when
+    // already allocated, so dynamic/scripted text can regenerate the glyph
+    // bitmap every time the rendered string changes without leaking.
+    FT_GlyphSlot slot = m_ftFace->glyph;
+
+    int penX = 0;
+    int maxAscent = 0;
+    int maxDescent = 0;
+
+    for (unsigned char c : text) {
+	if (FT_Load_Char (m_ftFace, static_cast<FT_ULong> (c), FT_LOAD_RENDER) != 0) {
+	    continue;
+	}
+	penX += slot->advance.x >> 6;
+	maxAscent = std::max (maxAscent, slot->bitmap_top);
+	maxDescent = std::max (maxDescent, static_cast<int> (slot->bitmap.rows) - slot->bitmap_top);
+    }
+
+    const int width = std::max (1, penX);
+    const int height = std::max (1, maxAscent + maxDescent);
+    std::vector<uint8_t> pixels (static_cast<size_t> (width) * height, 0);
+
+    penX = 0;
+    for (unsigned char c : text) {
+	if (FT_Load_Char (m_ftFace, static_cast<FT_ULong> (c), FT_LOAD_RENDER) != 0) {
+	    continue;
+	}
+
+	const auto& bmp = slot->bitmap;
+	const int originX = penX + slot->bitmap_left;
+	const int originY = maxAscent - slot->bitmap_top;
+
+	for (unsigned int row = 0; row < bmp.rows; ++row) {
+	    for (unsigned int col = 0; col < bmp.width; ++col) {
+		const int dstX = originX + static_cast<int> (col);
+		const int dstY = originY + static_cast<int> (row);
+		if (dstX < 0 || dstX >= width || dstY < 0 || dstY >= height) continue;
+		pixels[static_cast<size_t> (dstY) * width + dstX] = bmp.buffer[row * bmp.pitch + col];
+	    }
+	}
+
+	penX += slot->advance.x >> 6;
+    }
+
+    const bool firstUpload = (m_texture == 0);
+    if (firstUpload) {
+	glGenTextures (1, &m_texture);
+    }
+    glBindTexture (GL_TEXTURE_2D, m_texture);
+    glPixelStorei (GL_UNPACK_ALIGNMENT, 1);
+    glTexImage2D (GL_TEXTURE_2D, 0, GL_RED, width, height, 0, GL_RED, GL_UNSIGNED_BYTE, pixels.data ());
+    if (firstUpload) {
+	glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
+	glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
+	glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
+	glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
+    }
+
+    m_textureSize = {width, height};
+    m_quadSize = {static_cast<float> (width), static_cast<float> (height)};
+    m_lastRenderedText = text;
+
+    uploadQuadVertices ();
+}
+
+void CText::buildShader () {
+    GLuint vs = compileShader (GL_VERTEX_SHADER, kVertexShader);
+    GLuint fs = compileShader (GL_FRAGMENT_SHADER, kFragmentShader);
+    if (vs == 0 || fs == 0) {
+	if (vs) glDeleteShader (vs);
+	if (fs) glDeleteShader (fs);
+	return;
+    }
+
+    m_program = glCreateProgram ();
+    glAttachShader (m_program, vs);
+    glAttachShader (m_program, fs);
+    glLinkProgram (m_program);
+    glDeleteShader (vs);
+    glDeleteShader (fs);
+
+    GLint status = GL_FALSE;
+    glGetProgramiv (m_program, GL_LINK_STATUS, &status);
+    if (status != GL_TRUE) {
+	char log[1024];
+	glGetProgramInfoLog (m_program, sizeof (log), nullptr, log);
+	sLog.error ("CText program link failed: ", log);
+	glDeleteProgram (m_program);
+	m_program = 0;
+	return;
+    }
+
+    m_uMVP = glGetUniformLocation (m_program, "uMVP");
+    m_uColor = glGetUniformLocation (m_program, "uColor");
+    m_uTexture = glGetUniformLocation (m_program, "uTexture");
+}
+
+void CText::uploadQuadVertices () {
+    // Quad centered at the origin, sized in pixels. Scene-space placement is
+    // done via the model matrix using the object's origin/scale. VBO contents
+    // are re-uploaded whenever the glyph bitmap is rebuilt so the quad always
+    // matches the current texture dimensions.
+    const float hx = m_quadSize.x * 0.5f;
+    const float hy = m_quadSize.y * 0.5f;
+    // With vflip=true (Wayland/GLFW), GL y- = screen top. So the quad bottom (y=-hy,
+    // lower GL y) appears at screen top. UV.v=0 here = FT glyph top → shows at screen top ✓
+    const float verts[] = {
+	// pos        // uv
+	-hx, -hy, 0.0f, 0.0f,
+	 hx, -hy, 1.0f, 0.0f,
+	 hx,  hy, 1.0f, 1.0f,
+	-hx, -hy, 0.0f, 0.0f,
+	 hx,  hy, 1.0f, 1.0f,
+	-hx,  hy, 0.0f, 1.0f,
+    };
+
+    const bool firstUpload = (m_vao == 0);
+    if (firstUpload) {
+	glGenVertexArrays (1, &m_vao);
+	glGenBuffers (1, &m_vbo);
+    }
+    glBindVertexArray (m_vao);
+    glBindBuffer (GL_ARRAY_BUFFER, m_vbo);
+    glBufferData (GL_ARRAY_BUFFER, sizeof (verts), verts, GL_DYNAMIC_DRAW);
+    if (firstUpload) {
+	glEnableVertexAttribArray (0);
+	glVertexAttribPointer (0, 2, GL_FLOAT, GL_FALSE, 4 * sizeof (float), reinterpret_cast<void*> (0));
+	glEnableVertexAttribArray (1);
+	glVertexAttribPointer (1, 2, GL_FLOAT, GL_FALSE, 4 * sizeof (float), reinterpret_cast<void*> (2 * sizeof (float)));
+    }
+    glBindVertexArray (0);
+}
+
+void CText::render () {
+    if (!m_valid) return;
+    if (!m_text.visible->value->getBool ()) return;
+
+    if (m_layerHandle != Scripting::kInvalidLayerHandle) {
+	auto& se = Scripting::ScriptEngine::instance ();
+	se.tickLayer (
+	    m_layerHandle,
+	    static_cast<double> (getScene ().getTime ()),
+	    static_cast<double> (getScene ().getDeltaTime ()),
+	    static_cast<double> (getScene ().getFps ())
+	);
+	const std::string current = se.layerText (m_layerHandle);
+	if (current != m_lastRenderedText) {
+	    rebuildTextureFrom (current.empty () ? std::string (" ") : current);
+	}
+    }
+
+    const glm::vec4 color = m_text.color->value->getVec4 ();
+    const float alpha = m_text.alpha->value->getFloat ();
+    const glm::vec3 scale = m_text.scale->value->getVec3 ();
+    const glm::vec3 origin = m_text.origin->value->getVec3 ();
+
+    // WE uses a Y-down coordinate system (origin at top-left, y increases downward).
+    // The final FBO is presented to screen with vflip=true on Wayland/GLFW, which maps
+    // GL y- to screen top and GL y+ to screen bottom. This effectively inverts Y again,
+    // so we need: gl_y = origin.y - scene_h/2  (not the CImage-style scene_h/2 - origin.y).
+    // CImage pre-compensates for X11 (no vflip) and gets corrected by the Wayland vflip.
+    // CText renders with direct vflip-aware coordinates.
+    const float scene_w = getScene ().getCamera ().getWidth ();
+    const float scene_h = getScene ().getCamera ().getHeight ();
+    const glm::vec3 gl_origin = {
+	origin.x - scene_w * 0.5f,
+	origin.y - scene_h * 0.5f,
+	origin.z,
+    };
+
+    glm::mat4 model = glm::translate (glm::mat4 (1.0f), gl_origin);
+    model = glm::scale (model, scale);
+
+    const glm::mat4 mvp = getScene ().getCamera ().getProjection ()
+			  * getScene ().getCamera ().getLookAt ()
+			  * model;
+
+    glEnable (GL_BLEND);
+    glBlendFunc (GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
+
+    glUseProgram (m_program);
+    glUniformMatrix4fv (m_uMVP, 1, GL_FALSE, glm::value_ptr (mvp));
+    glUniform4f (m_uColor, color.r, color.g, color.b, color.a * alpha);
+
+    glActiveTexture (GL_TEXTURE0);
+    glBindTexture (GL_TEXTURE_2D, m_texture);
+    glUniform1i (m_uTexture, 0);
+
+    glBindVertexArray (m_vao);
+    glDrawArrays (GL_TRIANGLES, 0, 6);
+    glBindVertexArray (0);
+}

+ 84 - 0
src/WallpaperEngine/Render/Objects/CText.h

@@ -0,0 +1,84 @@
+#pragma once
+
+#include <string>
+#include <vector>
+
+#include <GL/glew.h>
+#include <glm/vec2.hpp>
+#include <glm/vec4.hpp>
+
+#include "WallpaperEngine/Render/CObject.h"
+#include "WallpaperEngine/Scripting/ScriptEngine.h"
+
+// Forward-declare FreeType types to avoid leaking the header into users.
+struct FT_LibraryRec_;
+struct FT_FaceRec_;
+typedef struct FT_LibraryRec_* FT_Library;
+typedef struct FT_FaceRec_* FT_Face;
+
+namespace WallpaperEngine::Render::Wallpapers {
+class CScene;
+}
+
+namespace WallpaperEngine::Render::Objects {
+using namespace WallpaperEngine::Data::Model;
+
+/**
+ * Phase 1 text renderer.
+ *
+ * Renders static text objects as a single FreeType-rasterized RGBA texture
+ * drawn on a textured quad with its own minimal GLSL shader. Does NOT go
+ * through CRenderable / materials / passes — Phase 1 does not need effects.
+ *
+ * Phase 2 (scripted/dynamic text, alignment from properties, effect passes)
+ * is intentionally not implemented here. When the scene provides a dynamic
+ * `text: { script: "..." }` object this class captures the script source in
+ * the data model but renders an empty string — the Wallpaper Engine JS
+ * runtime required to evaluate it is out of scope for Phase 1.
+ */
+class CText final : public CObject {
+public:
+    CText (Wallpapers::CScene& scene, const Text& text);
+    ~CText () override;
+
+    void setup ();
+    void render () override;
+
+private:
+    // Rebuilds the glyph texture (and matching quad VBO) from the given string.
+    // Reuses existing GL handles if already allocated, so this is safe to call
+    // every time the rendered text changes.
+    void rebuildTextureFrom (const std::string& text);
+    void buildShader ();
+    void uploadQuadVertices ();
+
+    // setup() helpers (kept small to keep the setup flow linear).
+    bool initFreeType ();
+    bool loadEmbeddedFont ();
+    bool loadSystemFont ();
+    unsigned int computeEffectivePixelSize () const;
+    void initScriptLayer ();
+
+    const Text& m_text;
+    std::string m_lastRenderedText;
+    Scripting::ScriptLayerHandle m_layerHandle = Scripting::kInvalidLayerHandle;
+
+    FT_Library m_ftLibrary = nullptr;
+    FT_Face m_ftFace = nullptr;
+    std::vector<uint8_t> m_fontData;
+
+    GLuint m_texture = 0;
+    GLuint m_program = 0;
+    GLuint m_vao = 0;
+    GLuint m_vbo = 0;
+
+    GLint m_uMVP = -1;
+    GLint m_uColor = -1;
+    GLint m_uTexture = -1;
+
+    glm::ivec2 m_textureSize = {0, 0};
+    glm::vec2 m_quadSize = {0.0f, 0.0f};
+
+    bool m_valid = false;
+};
+} // namespace WallpaperEngine::Render::Objects

+ 48 - 12
src/WallpaperEngine/Render/Wallpapers/CScene.cpp

@@ -1,6 +1,7 @@
 #include "WallpaperEngine/Render/Objects/CImage.h"
 #include "WallpaperEngine/Render/Objects/CParticle.h"
 #include "WallpaperEngine/Render/Objects/CSound.h"
+#include "WallpaperEngine/Render/Objects/CText.h"
 
 #include "WallpaperEngine/Render/WallpaperState.h"
 
@@ -186,20 +187,33 @@ Render::CObject* CScene::createObject (const Object& object) {
 	this->createObject (**dep);
     }
 
+    renderObject = this->dispatchObjectType (object);
+
+    if (renderObject != nullptr) {
+	this->m_objects.emplace (renderObject->getId (), renderObject);
+    }
+
+    return renderObject;
+}
+
+Render::CObject* CScene::dispatchObjectType (const Object& object) {
     if (object.is<Image> ()) {
 	auto* image = new Objects::CImage (*this, *object.as<Image> ());
 
 	try {
 	    image->setup ();
 	} catch (std::runtime_error&) {
-	    // this error message is already printed, so just show extra info about it
 	    sLog.error ("Cannot setup image ", image->getImage ().name);
 	}
 
-	renderObject = image;
-    } else if (object.is<Sound> ()) {
-	renderObject = new Objects::CSound (*this, *object.as<Sound> ());
-    } else if (object.is<Particle> ()) {
+	return image;
+    }
+
+    if (object.is<Sound> ()) {
+	return new Objects::CSound (*this, *object.as<Sound> ());
+    }
+
+    if (object.is<Particle> ()) {
 	if (this->getContext ().getApp ().getContext ().settings.general.disableParticles == true) {
 	    sLog.debug ("Ignoring particle system (disabled in settings): ", object.as<Particle> ()->name);
 	    return nullptr;
@@ -213,17 +227,25 @@ Render::CObject* CScene::createObject (const Object& object) {
 	    sLog.error ("Cannot setup particle ", particle->getParticle ().name);
 	}
 
-	renderObject = particle;
-    } else {
-	sLog.debug ("Unknown object type, creating placeholder, empty object: ", object.id);
-	renderObject = new CObject (*this, object);
+	return particle;
     }
 
-    if (renderObject != nullptr) {
-	this->m_objects.emplace (renderObject->getId (), renderObject);
+    if (object.is<Text> ()) {
+	auto* text = new Objects::CText (*this, *object.as<Text> ());
+
+	try {
+	    text->setup ();
+	} catch (std::runtime_error&) {
+	    sLog.error ("Cannot setup text ", text->getObject ().name);
+	    delete text;
+	    return nullptr;
+	}
+
+	return text;
     }
 
-    return renderObject;
+    sLog.debug ("Unknown object type, creating placeholder, empty object: ", object.id);
+    return new CObject (*this, object);
 }
 
 void CScene::addObjectToRenderOrder (const Object& object) {
@@ -350,6 +372,20 @@ int CScene::getWidth () const { return this->m_camera->getWidth (); }
 
 int CScene::getHeight () const { return this->m_camera->getHeight (); }
 
+float CScene::getTime () const { return g_Time; }
+
+float CScene::getDeltaTime () const { return g_Time - g_TimeLast; }
+
+float CScene::getFps () const {
+    const float dt = g_Time - g_TimeLast;
+    // Guard against the first frame (where g_TimeLast is 0 so dt == g_Time)
+    // and division by zero on the very first call.
+    if (dt <= 1e-6f) {
+	return 60.0f;
+    }
+    return 1.0f / dt;
+}
+
 const glm::vec2* CScene::getMousePosition () const { return &this->m_mousePosition; }
 
 const glm::vec2* CScene::getMousePositionLast () const { return &this->m_mousePositionLast; }

+ 8 - 0
src/WallpaperEngine/Render/Wallpapers/CScene.h

@@ -28,6 +28,13 @@ public:
     [[nodiscard]] int getWidth () const override;
     [[nodiscard]] int getHeight () const override;
 
+    // Time accessors used by dynamic text layers (CText + ScriptEngine).
+    // Read from the application-wide g_Time/g_TimeLast globals that other
+    // renderers already consume via extern (e.g. CParticle).
+    [[nodiscard]] float getTime () const;
+    [[nodiscard]] float getDeltaTime () const;
+    [[nodiscard]] float getFps () const;
+
     const glm::vec2* getMousePosition () const;
     const glm::vec2* getMousePositionLast () const;
     const glm::vec2* getMousePositionNormalized () const;
@@ -44,6 +51,7 @@ protected:
 
 private:
     Render::CObject* createObject (const Object& object);
+    Render::CObject* dispatchObjectType (const Object& object);
     void addObjectToRenderOrder (const Object& object);
 
     std::unique_ptr<Camera> m_camera;

+ 247 - 1
src/WallpaperEngine/Scripting/ScriptEngine.cpp

@@ -44,6 +44,8 @@ JSValue ScriptEngine::dynamicValueToJS (const DynamicValue& value) const {
     JSContext* ctx = this->m_context;
 
     switch (value.getType ()) {
+	case DynamicValue::String:
+	    return JS_NewString (ctx, value.getString ().c_str ());
 	case DynamicValue::Float:
 	    return JS_NewFloat64 (ctx, value.getFloat ());
 	case DynamicValue::Int:
@@ -297,7 +299,8 @@ DynamicValueUniquePtr ScriptEngine::evaluate (
     }
 
     wrapper << body << "\n"
-	    << "  return update(globalThis.__currentValue);\n"
+	    << "  if (typeof update === 'function') return update(globalThis.__currentValue);\n"
+	    << "  return globalThis.__currentValue;\n"
 	    << "})();\n";
 
     std::string evalScript = wrapper.str ();
@@ -328,3 +331,246 @@ DynamicValueUniquePtr ScriptEngine::evaluate (
     JS_FreeValue (ctx, result);
     return dynResult;
 }
+
+// ---------------------------------------------------------------------------
+// Layer-script API (Phase 2)
+// ---------------------------------------------------------------------------
+
+void ScriptEngine::ensureLayerRegistry () {
+    if (this->m_layerRegistryReady || !this->m_context) {
+	return;
+    }
+    JSContext* ctx = this->m_context;
+    JSValue globalObj = JS_GetGlobalObject (ctx);
+    JS_SetPropertyStr (ctx, globalObj, "__layers", JS_NewObject (ctx));
+    JS_FreeValue (ctx, globalObj);
+    this->m_layerRegistryReady = true;
+}
+
+ScriptLayerHandle ScriptEngine::createLayerScript (
+    const std::string& scriptSource,
+    const std::map<std::string, DynamicValue*>& initialScriptProps,
+    const std::string& initialText
+) {
+    if (!this->m_context) {
+	sLog.error ("ScriptEngine: No JS context available");
+	return kInvalidLayerHandle;
+    }
+    this->ensureLayerRegistry ();
+
+    JSContext* ctx = this->m_context;
+    JSValue globalObj = JS_GetGlobalObject (ctx);
+
+    // Seed initial scriptProperties and text as temporary globals the IIFE reads.
+    JSValue seedProps = JS_NewObject (ctx);
+    for (const auto& [name, dynVal] : initialScriptProps) {
+	if (dynVal) {
+	    JS_SetPropertyStr (ctx, seedProps, name.c_str (), this->dynamicValueToJS (*dynVal));
+	}
+    }
+    JS_SetPropertyStr (ctx, globalObj, "__layerSeedProps", seedProps);
+    JS_SetPropertyStr (ctx, globalObj, "__layerSeedText", JS_NewString (ctx, initialText.c_str ()));
+
+    const ScriptLayerHandle id = this->m_nextLayerId++;
+
+    // Same stripping logic as evaluate(): WE scripts come as ES6 modules but
+    // QuickJS is easier to drive as plain script evaluation.
+    std::string body = scriptSource;
+    size_t pos;
+    while ((pos = body.find ("'use strict';")) != std::string::npos) {
+	body.erase (pos, 13);
+    }
+    while ((pos = body.find ("\"use strict\";")) != std::string::npos) {
+	body.erase (pos, 13);
+    }
+    while ((pos = body.find ("export ")) != std::string::npos) {
+	body.erase (pos, 7);
+    }
+
+    // The IIFE gives every layer its own closure for top-level vars and
+    // functions, so two layers that both define `function update()` or a
+    // top-level `var scriptProperties` don't clobber each other. Lifecycle
+    // hooks are captured into globalThis.__layers[id] so tick/destroy can
+    // reach them later. `typeof init === 'function'` is safe even when
+    // `init` was never declared — bare-identifier `typeof` never throws.
+    std::ostringstream wrapper;
+    wrapper << "(function() {\n"
+	    << "  var __id = " << id << ";\n"
+	    << "  var __props = Object.assign({}, globalThis.__layerSeedProps || {});\n"
+	    << "  var thisLayer = { text: String(globalThis.__layerSeedText || '') };\n"
+	    << "  var thisScene = {\n"
+	    << "    get time()        { var c = globalThis.__sceneCtx; return c ? c.time : 0; },\n"
+	    << "    get currentTime() { var c = globalThis.__sceneCtx; return c ? c.time : 0; },\n"
+	    << "    get dt()          { var c = globalThis.__sceneCtx; return c ? c.dt   : 0; },\n"
+	    << "    get fps()         { var c = globalThis.__sceneCtx; return c ? c.fps  : 60; },\n"
+	    << "  };\n"
+	    // Minimal WE `engine` shim. Real Wallpaper Engine exposes a broad API
+	    // (media events, audio buffer, user input); we provide just enough for
+	    // the common built-in text scripts to run without ReferenceError.
+	    // `frametime` is the per-frame delta in seconds (what InsertFPS reads).
+	    << "  var engine = {\n"
+	    << "    get frametime() { var c = globalThis.__sceneCtx; return c ? c.dt : 0; },\n"
+	    << "    get time()      { var c = globalThis.__sceneCtx; return c ? c.time : 0; },\n"
+	    << "  };\n"
+	    << "  function createScriptProperties() {\n"
+	    << "    var builder = {\n"
+	    << "      addSlider:   function(o){ if (!(o.name in __props)) __props[o.name] = o.value; return builder; },\n"
+	    << "      addCheckbox: function(o){ if (!(o.name in __props)) __props[o.name] = o.value; return builder; },\n"
+	    << "      addCombo:    function(o){ if (!(o.name in __props)) __props[o.name] = o.value; return builder; },\n"
+	    << "      addColor:    function(o){ if (!(o.name in __props)) __props[o.name] = o.value; return builder; },\n"
+	    << "      addText:     function(o){ if (!(o.name in __props)) __props[o.name] = o.value; return builder; },\n"
+	    << "      finish:      function(){ return __props; }\n"
+	    << "    };\n"
+	    << "    return builder;\n"
+	    << "  }\n"
+	    << body << "\n"
+	    // `_tick` wraps the user's `update()` so both WE text conventions work:
+	    //   A) `export function update() { thisLayer.text = …; }` (mutates in place)
+	    //   B) `export function update(value) { …; return value; }` (returns new text)
+	    // We pass the current text in, and if the return value is a string we
+	    // adopt it as the new `thisLayer.text`. Non-string / undefined return
+	    // leaves `thisLayer.text` as whatever the function assigned itself.
+	    << "  globalThis.__layers[__id] = {\n"
+	    << "    thisLayer: thisLayer,\n"
+	    << "    thisScene: thisScene,\n"
+	    << "    _init:    (typeof init    === 'function') ? init    : null,\n"
+	    << "    _destroy: (typeof destroy === 'function') ? destroy : null,\n"
+	    << "    _tick:    (typeof update  === 'function')\n"
+	    << "              ? function() {\n"
+	    << "                  var r = update(thisLayer.text);\n"
+	    << "                  if (typeof r === 'string') thisLayer.text = r;\n"
+	    << "                }\n"
+	    << "              : null,\n"
+	    << "    _scriptProperties: (typeof scriptProperties !== 'undefined') ? scriptProperties : __props\n"
+	    << "  };\n"
+	    << "})();\n";
+
+    const std::string evalScript = wrapper.str ();
+    JSValue result = JS_Eval (ctx, evalScript.c_str (), evalScript.size (), "<layer-script>", JS_EVAL_TYPE_GLOBAL);
+
+    // Unset seeds so they don't leak into the next createLayerScript call.
+    JS_SetPropertyStr (ctx, globalObj, "__layerSeedProps", JS_UNDEFINED);
+    JS_SetPropertyStr (ctx, globalObj, "__layerSeedText", JS_UNDEFINED);
+    JS_FreeValue (ctx, globalObj);
+
+    if (JS_IsException (result)) {
+	logJSException (ctx, "createLayerScript");
+	JS_FreeValue (ctx, result);
+	return kInvalidLayerHandle;
+    }
+    JS_FreeValue (ctx, result);
+
+    this->m_layerInitialized[id] = false;
+    return id;
+}
+
+void ScriptEngine::tickLayer (ScriptLayerHandle handle, double time, double deltaTime, double fps) {
+    if (!this->m_context || handle == kInvalidLayerHandle) {
+	return;
+    }
+    JSContext* ctx = this->m_context;
+    JSValue globalObj = JS_GetGlobalObject (ctx);
+
+    JSValue sceneCtx = JS_NewObject (ctx);
+    JS_SetPropertyStr (ctx, sceneCtx, "time", JS_NewFloat64 (ctx, time));
+    JS_SetPropertyStr (ctx, sceneCtx, "dt",   JS_NewFloat64 (ctx, deltaTime));
+    JS_SetPropertyStr (ctx, sceneCtx, "fps",  JS_NewFloat64 (ctx, fps));
+    JS_SetPropertyStr (ctx, globalObj, "__sceneCtx", sceneCtx);
+
+    JSValue layers = JS_GetPropertyStr (ctx, globalObj, "__layers");
+    JSValue layerObj = JS_GetPropertyUint32 (ctx, layers, static_cast<uint32_t> (handle));
+    JS_FreeValue (ctx, layers);
+
+    if (JS_IsUndefined (layerObj) || JS_IsNull (layerObj)) {
+	JS_FreeValue (ctx, layerObj);
+	JS_FreeValue (ctx, globalObj);
+	return;
+    }
+
+    auto callHook = [&] (const char* prop, const char* tag) {
+	JSValue fn = JS_GetPropertyStr (ctx, layerObj, prop);
+	if (JS_IsFunction (ctx, fn)) {
+	    JSValue ret = JS_Call (ctx, fn, layerObj, 0, nullptr);
+	    if (JS_IsException (ret)) {
+		logJSException (ctx, tag);
+	    }
+	    JS_FreeValue (ctx, ret);
+	}
+	JS_FreeValue (ctx, fn);
+    };
+
+    auto it = this->m_layerInitialized.find (handle);
+    if (it != this->m_layerInitialized.end () && !it->second) {
+	callHook ("_init", "layer.init");
+	it->second = true;
+    }
+    callHook ("_tick", "layer.update");
+
+    JS_FreeValue (ctx, layerObj);
+    JS_FreeValue (ctx, globalObj);
+}
+
+std::string ScriptEngine::layerText (ScriptLayerHandle handle) {
+    if (!this->m_context || handle == kInvalidLayerHandle) {
+	return {};
+    }
+    JSContext* ctx = this->m_context;
+    JSValue globalObj = JS_GetGlobalObject (ctx);
+    JSValue layers = JS_GetPropertyStr (ctx, globalObj, "__layers");
+    JSValue layerObj = JS_GetPropertyUint32 (ctx, layers, static_cast<uint32_t> (handle));
+
+    std::string result;
+    if (!JS_IsUndefined (layerObj) && !JS_IsNull (layerObj)) {
+	JSValue thisLayer = JS_GetPropertyStr (ctx, layerObj, "thisLayer");
+	JSValue textVal = JS_GetPropertyStr (ctx, thisLayer, "text");
+	if (!JS_IsUndefined (textVal) && !JS_IsNull (textVal)) {
+	    const char* cstr = JS_ToCString (ctx, textVal);
+	    if (cstr) {
+		result.assign (cstr);
+		JS_FreeCString (ctx, cstr);
+	    }
+	}
+	JS_FreeValue (ctx, textVal);
+	JS_FreeValue (ctx, thisLayer);
+    }
+
+    JS_FreeValue (ctx, layerObj);
+    JS_FreeValue (ctx, layers);
+    JS_FreeValue (ctx, globalObj);
+    return result;
+}
+
+void ScriptEngine::destroyLayer (ScriptLayerHandle handle) {
+    if (!this->m_context || handle == kInvalidLayerHandle) {
+	return;
+    }
+    JSContext* ctx = this->m_context;
+    JSValue globalObj = JS_GetGlobalObject (ctx);
+    JSValue layers = JS_GetPropertyStr (ctx, globalObj, "__layers");
+    JSValue layerObj = JS_GetPropertyUint32 (ctx, layers, static_cast<uint32_t> (handle));
+
+    if (!JS_IsUndefined (layerObj) && !JS_IsNull (layerObj)) {
+	JSValue fn = JS_GetPropertyStr (ctx, layerObj, "_destroy");
+	if (JS_IsFunction (ctx, fn)) {
+	    JSValue ret = JS_Call (ctx, fn, layerObj, 0, nullptr);
+	    if (JS_IsException (ret)) {
+		logJSException (ctx, "layer.destroy");
+	    }
+	    JS_FreeValue (ctx, ret);
+	}
+	JS_FreeValue (ctx, fn);
+    }
+    JS_FreeValue (ctx, layerObj);
+    JS_FreeValue (ctx, layers);
+    JS_FreeValue (ctx, globalObj);
+
+    // Remove the entry from globalThis.__layers so GC can reclaim its closures.
+    const std::string delScript = "delete globalThis.__layers[" + std::to_string (handle) + "];";
+    JSValue delResult = JS_Eval (ctx, delScript.c_str (), delScript.size (), "<layer-destroy>", JS_EVAL_TYPE_GLOBAL);
+    if (JS_IsException (delResult)) {
+	logJSException (ctx, "layer.destroy.delete");
+    }
+    JS_FreeValue (ctx, delResult);
+
+    this->m_layerInitialized.erase (handle);
+}

+ 64 - 0
src/WallpaperEngine/Scripting/ScriptEngine.h

@@ -14,6 +14,10 @@ extern "C" {
 namespace WallpaperEngine::Scripting {
 using namespace WallpaperEngine::Data::Model;
 
+// Opaque handle returned by createLayerScript. 0 means invalid / not created.
+using ScriptLayerHandle = int;
+static constexpr ScriptLayerHandle kInvalidLayerHandle = 0;
+
 class ScriptEngine {
 public:
     static ScriptEngine& instance ();
@@ -36,13 +40,73 @@ public:
 	const DynamicValue& currentValue
     );
 
+    // -------------------------------------------------------------------
+    // Layer-script API (Phase 2 — dynamic text)
+    // -------------------------------------------------------------------
+    //
+    // Wallpaper Engine text-object scripts follow a lifecycle pattern that
+    // cannot be evaluated with the simple `update(value) -> value` contract
+    // above. They typically look like:
+    //
+    //   'use strict';
+    //   export var scriptProperties = createScriptProperties()…finish();
+    //   export function init()   { /* subscribe to events, cache data    */ }
+    //   export function update() { thisLayer.text = computeCurrentText(); }
+    //
+    // The script mutates a `thisLayer` object in place instead of returning
+    // a value, and lifecycle functions are optional. The API below keeps
+    // per-layer state alive across frames so `init()` runs once and
+    // `update()` re-runs every tick.
+
+    /**
+     * Create a persistent "layer script" from a WE text-object script.
+     *
+     * @param scriptSource The full JS script text.
+     * @param initialScriptProps Initial values for scriptProperties entries.
+     *        Ownership stays with the caller; we only snapshot current values.
+     * @param initialText Initial value of `thisLayer.text` (usually the
+     *        static placeholder carried in the JSON).
+     * @return A positive handle, or kInvalidLayerHandle if evaluation failed.
+     */
+    ScriptLayerHandle createLayerScript (
+	const std::string& scriptSource,
+	const std::map<std::string, DynamicValue*>& initialScriptProps,
+	const std::string& initialText
+    );
+
+    /**
+     * Advance a layer by one frame.
+     *
+     * On the first call, invokes `init()` (if defined) before `update()`.
+     * Updates a `thisScene` context visible to the script (time, fps).
+     * Silently no-ops if the handle is invalid.
+     */
+    void tickLayer (ScriptLayerHandle handle, double time, double deltaTime, double fps);
+
+    /**
+     * Read the current value of `thisLayer.text` for the given layer.
+     * Returns an empty string if the handle is invalid.
+     */
+    std::string layerText (ScriptLayerHandle handle);
+
+    /**
+     * Tear down a layer: invokes `destroy()` (if defined) and frees state.
+     */
+    void destroyLayer (ScriptLayerHandle handle);
+
 private:
     ScriptEngine ();
 
     JSValue dynamicValueToJS (const DynamicValue& value) const;
     DynamicValueUniquePtr jsToDynamicValue (JSValue val, DynamicValue::UnderlyingType hint) const;
 
+    // Installs globalThis.__layers and related helpers. Called lazily.
+    void ensureLayerRegistry ();
+
     JSRuntime* m_runtime = nullptr;
     JSContext* m_context = nullptr;
+    ScriptLayerHandle m_nextLayerId = 1;
+    bool m_layerRegistryReady = false;
+    std::map<ScriptLayerHandle, bool> m_layerInitialized;
 };
 } // namespace WallpaperEngine::Scripting