diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0f5d354
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+local/*
diff --git a/LICENSE.md b/LICENSE.md
new file mode 100644
index 0000000..26ba001
--- /dev/null
+++ b/LICENSE.md
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 Anar Bastanov
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/LivelyInfo.json b/LivelyInfo.json
new file mode 100644
index 0000000..2291ed2
--- /dev/null
+++ b/LivelyInfo.json
@@ -0,0 +1,12 @@
+{
+ "AppVersion": "1.0.0.0",
+ "Title": "Enchanted Cursor",
+ "Thumbnail": "res/thumbnail.png",
+ "Preview": "res/preview.gif",
+ "Desc": "Rainbow cursor effect using HTML5 Canvas.",
+ "Author": "Anar Bastanov",
+ "License": "MIT",
+ "Contact": "https://github.com/anar-bastanov/enchanted-cursor-wallpaper",
+ "Type": 1,
+ "FileName": "src/index.html"
+}
diff --git a/LivelyProperties.json b/LivelyProperties.json
new file mode 100644
index 0000000..24f664a
--- /dev/null
+++ b/LivelyProperties.json
@@ -0,0 +1,99 @@
+{
+ "labelCursor": {
+ "type": "label",
+ "value": "Cursor"
+ },
+ "radius": {
+ "text": "Radius",
+ "type": "slider",
+ "value": 5,
+ "min": 1,
+ "max": 20
+ },
+ "rainbowSpeed": {
+ "text": "Rainbow Speed",
+ "type": "slider",
+ "value": 30,
+ "min": 1,
+ "max": 100
+ },
+ "randomBrightness": {
+ "text": "Random Brightness",
+ "type": "slider",
+ "value": 70,
+ "min": 0,
+ "max": 100
+ },
+ "distortion": {
+ "text": "Distortion",
+ "type": "slider",
+ "value": 40,
+ "min": 0,
+ "max": 100
+ },
+ "labelWindow": {
+ "type": "label",
+ "value": "Window"
+ },
+ "fadeSpeed": {
+ "text": "Fade Speed",
+ "type": "slider",
+ "value": 1,
+ "min": 1,
+ "max": 100
+ },
+ "backgroundNoise": {
+ "text": "Background Noise",
+ "type": "slider",
+ "value": 25,
+ "min": 0,
+ "max": 100
+ },
+ "syncNoiseColor": {
+ "text": "Sync Noise Color",
+ "type": "checkbox",
+ "value": false
+ },
+ "screenSaturation": {
+ "text": "Screen Saturation",
+ "type": "slider",
+ "value": 50,
+ "min": 0,
+ "max": 100
+ },
+ "labelText": {
+ "type": "label",
+ "value": "Text"
+ },
+ "characters": {
+ "text": "Characters",
+ "type": "textbox",
+ "value": "ᔑʖᓵ↸ᒷ⎓⊣⍑╎⋮ꖌꖎᒲリ𝙹!¡ᑑ∷ᓭℸ⚍⍊∴/||⨅"
+ },
+ "fontSize": {
+ "text": "Font Size",
+ "type": "slider",
+ "value": 12,
+ "min": 8,
+ "max": 30
+ },
+ "fontName": {
+ "text": "Font Name",
+ "type": "dropdown",
+ "value": 11,
+ "items": [
+ "Arial",
+ "Verdana",
+ "Times New Roman",
+ "Georgia",
+ "Courier New",
+ "Lucida Console",
+ "Trebuchet MS",
+ "Tahoma",
+ "Palatino Linotype",
+ "Impact",
+ "Comic Sans MS",
+ "Consolas"
+ ]
+ }
+}
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ba05ac2
--- /dev/null
+++ b/README.md
@@ -0,0 +1,24 @@
+# Enchanted Cursor
+![Demo](res/preview.gif?raw=true "video")
+
+Desktop wallpaper with an enchanted cursor effect.
+Use with [Lively Wallpaper](https://github.com/rocksdanister/lively) software.
+
+## Customization
+The following parameters can be adjusted from a settings menu within Lively:
+* Effect radius
+* Rainbow color speed
+* Random brightness
+* Distortion in circle area
+* Fade speed
+* Background noise intensity
+* Sync background noise color
+* Screen saturation
+* Displayed character set
+* Font size
+* Font name
+
+## Download
+[Download](https://github.com/anar-bastanov/enchanted-cursor-wallpaper/releases/download/v1.0.0/enchanted-cursor-wallpaper.zip)
+
+Drag & drop the file into Lively window to install.
diff --git a/res/preview.gif b/res/preview.gif
new file mode 100644
index 0000000..6a9220e
Binary files /dev/null and b/res/preview.gif differ
diff --git a/res/thumbnail.png b/res/thumbnail.png
new file mode 100644
index 0000000..718446b
Binary files /dev/null and b/res/thumbnail.png differ
diff --git a/src/index.html b/src/index.html
new file mode 100644
index 0000000..f0b194a
--- /dev/null
+++ b/src/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Enchanted Cursor
+
+
+
+
+
+
diff --git a/src/script.js b/src/script.js
new file mode 100644
index 0000000..fccb770
--- /dev/null
+++ b/src/script.js
@@ -0,0 +1,229 @@
+const config = {
+ RADIUS: 5, // int [1, 20]
+ RAINBOW_SPEED: 0.3, // float (0, 1]
+ RANDOM_BRIGHTNESS: 0.7, // float [0, 1]
+ DISTORTION: 2, // float [0, 5]
+ FADE_DECAY: 50, // int [1, 50]
+ BACKGROUND_NOISE: 0.25, // float [0, 1]
+ SYNC_NOISE_COLOR: false, // bool
+ SCREEN_SATURATION: 0.5, // float [0, 1]
+ CHARACTERS: [..."ᔑʖᓵ↸ᒷ⎓⊣⍑╎⋮ꖌꖎᒲリ𝙹!¡ᑑ∷ᓭℸ⚍⍊∴/||⨅"], // string [1, 64]
+ FONT_SIZE: 12, // int [8, 30]
+ FONT_NAME: "Consolas, monospace",
+ __AVAILABLE_FONTS: [
+ "Arial, sans-serif",
+ "Verdana, sans-serif",
+ "Times New Roman, serif",
+ "Georgia, serif",
+ "Courier New, monospace",
+ "Lucida Console, monospace",
+ "Trebuchet MS, sans-serif",
+ "Tahoma, sans-serif",
+ "Palatino Linotype, serif",
+ "Impact, sans-serif",
+ "Comic Sans MS, cursive, sans-serif",
+ "Consolas, monospace",
+ ]
+};
+
+//# Setup
+
+const canvas = document.getElementById("c");
+const context = canvas.getContext("2d");
+
+canvas.height = window.innerHeight;
+canvas.width = window.innerWidth;
+
+context.font = config.FONT_SIZE + "px " + config.FONT_NAME; // for debugging on a browser
+
+let hue = 0;
+let mouseXOld = -1;
+let mouseYOld = -1;
+
+//# Main Functions
+
+function alignPosition(pos) {
+ const fontSize = config.FONT_SIZE;
+ return Math.floor(pos / fontSize) * fontSize;
+}
+
+function getRandomCharacter() {
+ const characters = config.CHARACTERS;
+ return characters[Math.floor(Math.random() * characters.length)];
+}
+
+function drawCharacter(char, posX, posY, hue, alpha = 1.0) {
+ const fontSize = config.FONT_SIZE;
+
+ context.fillStyle = "rgba(10,10,10, 1.0)";
+ context.fillRect(posX, posY - fontSize, fontSize, fontSize);
+
+ const r = Math.floor(127 * Math.sin(hue + (0 / 3) * Math.PI) + 128);
+ const g = Math.floor(127 * Math.sin(hue + (2 / 3) * Math.PI) + 128);
+ const b = Math.floor(127 * Math.sin(hue + (4 / 3) * Math.PI) + 128);
+
+ context.fillStyle = `rgba(${r},${g},${b}, ${alpha})`;
+ context.fillText(char, posX, posY);
+}
+
+function drawCursorEffect(mouseX, mouseY) {
+ const {
+ RADIUS: radius,
+ RAINBOW_SPEED: speed,
+ RANDOM_BRIGHTNESS: brightness,
+ DISTORTION: distortion,
+ FONT_SIZE: fontSize
+ } = config;
+ const cellX = alignPosition(mouseX);
+ const cellY = alignPosition(mouseY);
+
+ for (let x = -radius + 1; x < radius; ++x) {
+ for (let y = -radius + 1; y < radius; ++y) {
+ if (Math.sqrt(x * x + y * y) >= radius - 0.5)
+ continue;
+
+ const distance = Math.sqrt(x * x + y * y) / radius;
+
+ if (Math.random() > Math.pow(1 - distance, distortion))
+ continue;
+
+ const char = getRandomCharacter();
+ const posX = cellX + x * fontSize;
+ const posY = cellY + y * fontSize;
+ const alpha = 1.0 - brightness * Math.random();
+
+ drawCharacter(char, posX, posY, hue * speed, alpha);
+ }
+ }
+}
+
+function drawCursorEffectInLine(mouseXFrom, mouseYFrom, mouseXTo, mouseYTo) {
+ const diffX = mouseXTo - mouseXFrom;
+ const diffY = mouseYTo - mouseYFrom;
+ const length = config.FONT_SIZE * config.RADIUS / 1.5;
+ const distance = Math.sqrt(diffX * diffX + diffY * diffY);
+ const lengthX = length * diffX / distance;
+ const lengthY = length * diffY / distance;
+ const maxHuePeriod = 2 * Math.PI / config.RAINBOW_SPEED;
+
+ let i = distance;
+ do {
+ hue = (hue + min(length, i) / 75) % maxHuePeriod;
+ drawCursorEffect(mouseXTo, mouseYTo);
+ mouseXTo -= lengthX;
+ mouseYTo -= lengthY;
+ i -= length;
+ }
+ while (i > 0);
+}
+
+function drawNoiseEffect() {
+ const {
+ RAINBOW_SPEED: speed,
+ BACKGROUND_NOISE: noise,
+ SYNC_NOISE_COLOR: syncColor,
+ FONT_SIZE: fontSize
+ } = config;
+ const count = Math.sqrt(canvas.width * canvas.height) / fontSize * (0.2 * noise);
+
+ for (let i = 0; i < count; ++i) {
+ const char = getRandomCharacter();
+ const posX = alignPosition(Math.random() * canvas.width);
+ const posY = alignPosition(Math.random() * canvas.height);
+ const rhue = syncColor ? hue * speed : Math.random() * 2 * Math.PI;
+
+ drawCharacter(char, posX, posY, rhue);
+ }
+}
+
+function fadeScreen() {
+ context.fillStyle = "rgba(10,10,10, 0.05)";
+ context.fillRect(0, 0, canvas.width, canvas.height);
+ context.fillStyle = "rgba(0,0,0, 0.05)";
+ context.fillRect(0, 0, canvas.width, canvas.height);
+}
+
+//# Events
+
+window.onresize = () => {
+ location.reload();
+}
+
+canvas.onmouseleave = () => {
+ mouseXOld = -1;
+ mouseYOld = -1;
+}
+
+let fadeIntervalId = setInterval(fadeScreen, config.FADE_DECAY);
+
+setInterval(drawNoiseEffect, 50);
+
+document.onmousemove = (event) => {
+ const mouseX = event.x;
+ const mouseY = event.y;
+
+ if (mouseXOld < 0 || mouseYOld < 0)
+ drawCursorEffect(mouseX, mouseY);
+ else
+ drawCursorEffectInLine(mouseXOld, mouseYOld, mouseX, mouseY);
+
+ mouseXOld = mouseX;
+ mouseYOld = mouseY;
+}
+
+//# Lively API
+
+function livelyPropertyListener(name, val) {
+ switch (name) {
+ case "radius":
+ config.RADIUS = val;
+ break;
+ case "rainbowSpeed":
+ config.RAINBOW_SPEED = val / 100;
+ break;
+ case "randomBrightness":
+ config.RANDOM_BRIGHTNESS = val / 100;
+ break;
+ case "distortion":
+ config.DISTORTION = val / 20;
+ break;
+ case "fadeSpeed":
+ val = mapRange(val, 1, 100, 50, 1);
+ config.FADE_DECAY = val;
+ clearInterval(fadeIntervalId);
+ fadeIntervalId = setInterval(fadeScreen, val);
+ break;
+ case "backgroundNoise":
+ config.BACKGROUND_NOISE = val / 100;
+ break;
+ case "syncNoiseColor":
+ config.SYNC_NOISE_COLOR = val;
+ break;
+ case "screenSaturation":
+ config.SCREEN_SATURATION = val / 100;
+ canvas.style.filter = `saturate(${val + 100}%) brightness(${val + 100}%)`;
+ break;
+ case "characters":
+ config.CHARACTERS = val.length == 0 ? ['?'] : [...val.slice(0, 64)];
+ break;
+ case "fontSize":
+ config.FONT_SIZE = val;
+ context.font = val + "px " + config.FONT_NAME;
+ break;
+ case "fontName":
+ val = config.__AVAILABLE_FONTS[val];
+ config.FONT_NAME = val;
+ context.font = config.FONT_SIZE + "px " + val;
+ break;
+ }
+}
+
+//# Utility Functions
+
+function mapRange(value, inMin, inMax, outMin, outMax) {
+ return (value - inMin) * (outMax - outMin) / (inMax - inMin) + outMin;
+}
+
+function min(x, y) {
+ return x < y ? x : y;
+}
diff --git a/src/style.css b/src/style.css
new file mode 100644
index 0000000..da34060
--- /dev/null
+++ b/src/style.css
@@ -0,0 +1,16 @@
+* {
+ margin: 0;
+ padding: 0;
+}
+
+body {
+ background: rgba(10, 10, 10, 1.0);
+}
+
+canvas {
+ display: block;
+}
+
+#c {
+ filter: saturate(1.5) brightness(150%);
+}